mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 16:03:17 +00:00
216 lines
6.5 KiB
TypeScript
216 lines
6.5 KiB
TypeScript
import type { GitStatusEntry, GitStatusResult } from '../../shared/git-status-types'
|
|
import type { RuntimeFileOpenResult } from '../../shared/runtime-types'
|
|
import type { CommandHandler, HandlerContext } from '../dispatch'
|
|
import { getOptionalStringFlag, getRequiredStringFlag } from '../flags'
|
|
import { printResult } from '../format'
|
|
import { RuntimeClientError } from '../runtime-client'
|
|
import { getOptionalWorktreeSelector, resolveCurrentWorktreeSelector } from '../selectors'
|
|
|
|
type FileOpenMode = 'edit' | 'diff'
|
|
type OpenChangedMode = FileOpenMode | 'both'
|
|
|
|
type FileOpenRecord = {
|
|
path: string
|
|
mode: FileOpenMode
|
|
staged?: boolean
|
|
opened: boolean
|
|
kind?: RuntimeFileOpenResult['kind']
|
|
skipped?: boolean
|
|
reason?: string
|
|
}
|
|
|
|
type FileOpenChangedResult = {
|
|
worktree: string
|
|
mode: OpenChangedMode
|
|
opened: FileOpenRecord[]
|
|
skipped: FileOpenRecord[]
|
|
totalChanged: number
|
|
}
|
|
|
|
async function getFileWorktreeSelector({ flags, cwd, client }: HandlerContext): Promise<string> {
|
|
const worktree = flags.get('worktree')
|
|
if (flags.has('worktree') && (typeof worktree !== 'string' || worktree.length === 0)) {
|
|
throw new RuntimeClientError('invalid_argument', 'Missing value for --worktree.')
|
|
}
|
|
const explicit = await getOptionalWorktreeSelector(flags, 'worktree', cwd, client)
|
|
if (explicit) {
|
|
return explicit
|
|
}
|
|
if (client.isRemote) {
|
|
throw new RuntimeClientError(
|
|
'invalid_argument',
|
|
'Remote file commands require --worktree because the client cwd cannot identify a server worktree.'
|
|
)
|
|
}
|
|
return await resolveCurrentWorktreeSelector(cwd, client)
|
|
}
|
|
|
|
function getOpenChangedMode(flags: Map<string, string | boolean>): OpenChangedMode {
|
|
const value = flags.get('mode')
|
|
if (flags.has('mode') && (typeof value !== 'string' || value.length === 0)) {
|
|
throw new RuntimeClientError(
|
|
'invalid_argument',
|
|
'Missing value for --mode. Use edit, diff, or both.'
|
|
)
|
|
}
|
|
const mode = getOptionalStringFlag(flags, 'mode') ?? 'diff'
|
|
if (mode === 'edit' || mode === 'diff' || mode === 'both') {
|
|
return mode
|
|
}
|
|
throw new RuntimeClientError('invalid_argument', 'Invalid --mode. Use edit, diff, or both.')
|
|
}
|
|
|
|
function canOpenEntryForEdit(entry: GitStatusEntry): string | null {
|
|
if (entry.status === 'deleted') {
|
|
return 'deleted file has no edit target'
|
|
}
|
|
if (entry.conflictStatus === 'unresolved') {
|
|
return 'unresolved conflict may not have a single editable file'
|
|
}
|
|
return null
|
|
}
|
|
|
|
async function openFileEdit(
|
|
ctx: HandlerContext,
|
|
worktree: string,
|
|
path: string
|
|
): Promise<FileOpenRecord> {
|
|
const result = await ctx.client.call<RuntimeFileOpenResult>('files.open', {
|
|
worktree,
|
|
relativePath: path
|
|
})
|
|
return {
|
|
path,
|
|
mode: 'edit',
|
|
opened: result.result.opened,
|
|
kind: result.result.kind,
|
|
...(result.result.opened ? {} : { skipped: true, reason: `${result.result.kind} file` })
|
|
}
|
|
}
|
|
|
|
async function openFileDiff(
|
|
ctx: HandlerContext,
|
|
worktree: string,
|
|
path: string,
|
|
staged: boolean
|
|
): Promise<FileOpenRecord> {
|
|
const result = await ctx.client.call<RuntimeFileOpenResult>('files.openDiff', {
|
|
worktree,
|
|
relativePath: path,
|
|
staged
|
|
})
|
|
return {
|
|
path,
|
|
mode: 'diff',
|
|
staged,
|
|
opened: result.result.opened,
|
|
kind: result.result.kind
|
|
}
|
|
}
|
|
|
|
function formatOpenChangedResult(result: FileOpenChangedResult): string {
|
|
if (result.totalChanged === 0) {
|
|
return 'No changed files.'
|
|
}
|
|
const lines = [`Opened ${result.opened.length} changed file targets.`]
|
|
if (result.skipped.length > 0) {
|
|
lines.push(`Skipped ${result.skipped.length} changed file targets:`)
|
|
for (const skipped of result.skipped) {
|
|
lines.push(`- ${skipped.path}: ${skipped.reason ?? 'not opened'}`)
|
|
}
|
|
}
|
|
return lines.join('\n')
|
|
}
|
|
|
|
function formatFileOpen(result: RuntimeFileOpenResult): string {
|
|
return result.opened
|
|
? `Opened ${result.relativePath}.`
|
|
: `Did not open ${result.relativePath}: ${result.kind} file.`
|
|
}
|
|
|
|
function formatFileDiff(result: RuntimeFileOpenResult): string {
|
|
return `Opened diff for ${result.relativePath}.`
|
|
}
|
|
|
|
export const FILE_HANDLERS: Record<string, CommandHandler> = {
|
|
'file open': async (ctx) => {
|
|
const relativePath = getRequiredStringFlag(ctx.flags, 'path')
|
|
const worktree = await getFileWorktreeSelector(ctx)
|
|
const result = await ctx.client.call<RuntimeFileOpenResult>('files.open', {
|
|
worktree,
|
|
relativePath
|
|
})
|
|
printResult(result, ctx.json, formatFileOpen)
|
|
},
|
|
'file diff': async (ctx) => {
|
|
const relativePath = getRequiredStringFlag(ctx.flags, 'path')
|
|
const staged = ctx.flags.get('staged') === true
|
|
const worktree = await getFileWorktreeSelector(ctx)
|
|
const result = await ctx.client.call<RuntimeFileOpenResult>('files.openDiff', {
|
|
worktree,
|
|
relativePath,
|
|
staged
|
|
})
|
|
printResult(result, ctx.json, formatFileDiff)
|
|
},
|
|
'file open-changed': async (ctx) => {
|
|
const mode = getOpenChangedMode(ctx.flags)
|
|
const worktree = await getFileWorktreeSelector(ctx)
|
|
const status = await ctx.client.call<GitStatusResult>('git.status', { worktree })
|
|
const opened: FileOpenRecord[] = []
|
|
const skipped: FileOpenRecord[] = []
|
|
const openedEditPaths = new Set<string>()
|
|
|
|
for (const entry of status.result.entries) {
|
|
if (mode === 'edit' || mode === 'both') {
|
|
const editSkipReason = canOpenEntryForEdit(entry)
|
|
if (editSkipReason) {
|
|
skipped.push({
|
|
path: entry.path,
|
|
mode: 'edit',
|
|
opened: false,
|
|
skipped: true,
|
|
reason: editSkipReason
|
|
})
|
|
} else if (!openedEditPaths.has(entry.path)) {
|
|
openedEditPaths.add(entry.path)
|
|
const record = await openFileEdit(ctx, worktree, entry.path)
|
|
const records = record.opened ? opened : skipped
|
|
records.push(record)
|
|
}
|
|
}
|
|
|
|
if (mode === 'diff' || mode === 'both') {
|
|
const staged = entry.area === 'staged'
|
|
if (entry.conflictStatus === 'unresolved') {
|
|
skipped.push({
|
|
path: entry.path,
|
|
mode: 'diff',
|
|
staged,
|
|
opened: false,
|
|
skipped: true,
|
|
reason: 'unresolved conflict may not have a single diff target'
|
|
})
|
|
} else {
|
|
opened.push(await openFileDiff(ctx, worktree, entry.path, staged))
|
|
}
|
|
}
|
|
}
|
|
|
|
printResult(
|
|
{
|
|
...status,
|
|
result: {
|
|
worktree,
|
|
mode,
|
|
opened,
|
|
skipped,
|
|
totalChanged: status.result.entries.length
|
|
}
|
|
},
|
|
ctx.json,
|
|
formatOpenChangedResult
|
|
)
|
|
}
|
|
}
|