diff --git a/src/main/ipc/filesystem.test.ts b/src/main/ipc/filesystem.test.ts index dd7ec9c26..3532d7a92 100644 --- a/src/main/ipc/filesystem.test.ts +++ b/src/main/ipc/filesystem.test.ts @@ -299,6 +299,8 @@ describe('registerFilesystemHandlers', () => { buffer.fill(0x61) return { bytesRead: buffer.length, buffer } }), + write: vi.fn().mockResolvedValue(undefined), + writeFile: vi.fn().mockResolvedValue(undefined), close: vi.fn() }) lstatMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })) @@ -485,6 +487,71 @@ describe('registerFilesystemHandlers', () => { expect(rmMock).not.toHaveBeenCalledWith(tempPath, expect.anything()) }) + it('streams runtime download chunks to a temp sibling then promotes on finish', async () => { + const writeFile = vi.fn().mockResolvedValue(undefined) + const close = vi.fn().mockResolvedValue(undefined) + openMock.mockResolvedValue({ writeFile, close }) + showSaveDialogMock.mockResolvedValue({ canceled: false, filePath: '/downloads/report.pdf' }) + statMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })) + registerFilesystemHandlers(store as never) + + const started = await handlers.get('fs:startDownloadedFile')!( + { sender: {} }, + { suggestedName: 'report.pdf' } + ) + expect(started).toMatchObject({ + canceled: false, + destinationPath: '/downloads/report.pdf' + }) + if (!started || typeof started !== 'object' || !('transferId' in started)) { + throw new Error('download did not start') + } + const transferId = started.transferId + + await expect( + handlers.get('fs:appendDownloadedFileChunk')!(null, { + transferId, + contentBase64: Buffer.from('hello').toString('base64') + }) + ).resolves.toEqual({ ok: true }) + await expect(handlers.get('fs:finishDownloadedFile')!(null, { transferId })).resolves.toEqual({ + canceled: false, + destinationPath: '/downloads/report.pdf' + }) + + const tempPath = openMock.mock.calls[0][0] + expect(path.dirname(tempPath)).toBe(path.normalize('/downloads')) + expect(openMock).toHaveBeenCalledWith(tempPath, 'wx') + expect(writeFile).toHaveBeenCalledWith(Buffer.from('hello')) + expect(close).toHaveBeenCalled() + expect(renameMock).toHaveBeenCalledWith(tempPath, '/downloads/report.pdf') + }) + + it('cleans up a runtime download temp file on cancel', async () => { + const close = vi.fn().mockResolvedValue(undefined) + openMock.mockResolvedValue({ writeFile: vi.fn(), close }) + showSaveDialogMock.mockResolvedValue({ canceled: false, filePath: '/downloads/report.pdf' }) + statMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })) + registerFilesystemHandlers(store as never) + + const started = await handlers.get('fs:startDownloadedFile')!( + { sender: {} }, + { suggestedName: 'report.pdf' } + ) + if (!started || typeof started !== 'object' || !('transferId' in started)) { + throw new Error('download did not start') + } + const tempPath = openMock.mock.calls[0][0] + + await expect( + handlers.get('fs:cancelDownloadedFile')!(null, { transferId: started.transferId }) + ).resolves.toEqual({ ok: true }) + + expect(close).toHaveBeenCalled() + expect(rmMock).toHaveBeenCalledWith(tempPath, { force: true }) + expect(renameMock).not.toHaveBeenCalled() + }) + it('cleans up the temp sibling when remote download transfer fails', async () => { const provider = { stat: vi.fn().mockResolvedValue({ size: 10, type: 'file', mtime: 123 }), diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index 021ee8f4f..9e9ff2767 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -1,6 +1,7 @@ /* eslint-disable max-lines */ import { BrowserWindow, dialog, ipcMain, shell } from 'electron' import { readdir, readFile, writeFile, stat, lstat, open, rename, rm } from 'fs/promises' +import type { FileHandle } from 'fs/promises' import { randomUUID } from 'crypto' import { dirname, extname, join, resolve } from 'path' import type { ChildProcess } from 'child_process' @@ -163,6 +164,24 @@ function sanitizeSaveDialogFilename(remoteBasename: string): string { return sanitized } +function decodeDownloadedFileContent(content: string, encoding: 'utf8' | 'base64'): Buffer { + if (encoding === 'base64') { + return Buffer.from(content, 'base64') + } + return Buffer.from(content, 'utf8') +} + +type DownloadSession = { + destinationPath: string + tempPath: string + destinationExisted: boolean + handle: FileHandle + cleanupTimer: ReturnType + senderId: number +} + +const DOWNLOAD_SESSION_TTL_MS = 30 * 60 * 1000 + function createSiblingTransferPath(destinationPath: string, suffix: string): string { return join(dirname(destinationPath), `.${randomUUID()}.${suffix}`) } @@ -430,6 +449,32 @@ export function registerFilesystemHandlers( commitMessageAgentEnv?: CommitMessageAgentEnvironmentResolvers ): void { const activeTextSearches = new Map() + const downloadSessions = new Map() + + async function closeDownloadSession( + transferId: string, + cleanupTemp: boolean + ): Promise { + const session = downloadSessions.get(transferId) + if (!session) { + return null + } + downloadSessions.delete(transferId) + clearTimeout(session.cleanupTimer) + await session.handle.close().catch(() => {}) + if (cleanupTemp) { + await cleanupLocalTransferPath(session.tempPath) + } + return session + } + + function cleanupDownloadSessionsForSender(senderId: number): void { + for (const [transferId, session] of Array.from(downloadSessions)) { + if (session.senderId === senderId) { + void closeDownloadSession(transferId, true) + } + } + } // ─── Filesystem ───────────────────────────────────────── ipcMain.handle( @@ -552,6 +597,153 @@ export function registerFilesystemHandlers( } ) + ipcMain.handle( + 'fs:saveDownloadedFile', + async ( + event, + args: { suggestedName?: string; content?: string; encoding?: 'utf8' | 'base64' } + ): Promise => { + const suggestedName = sanitizeSaveDialogFilename( + validateRequiredString(args?.suggestedName, 'suggestedName') + ) + if (typeof args?.content !== 'string') { + throw new Error('content is required') + } + const content = args.content + const encoding = args?.encoding === 'base64' ? 'base64' : 'utf8' + const parentWindow = BrowserWindow.fromWebContents(event.sender) ?? undefined + const dialogResult = parentWindow + ? await dialog.showSaveDialog(parentWindow, { defaultPath: suggestedName }) + : await dialog.showSaveDialog({ defaultPath: suggestedName }) + if (dialogResult.canceled || !dialogResult.filePath) { + return { canceled: true } + } + + const destinationPath = dialogResult.filePath + const { existed } = await inspectDownloadDestination(destinationPath) + const tempPath = createSiblingTransferPath(destinationPath, 'download') + let promoted = false + try { + await writeFile(tempPath, decodeDownloadedFileContent(content, encoding)) + await promoteDownloadedFile(tempPath, destinationPath, existed) + promoted = true + return { canceled: false, destinationPath } + } finally { + if (!promoted) { + await cleanupLocalTransferPath(tempPath) + } + } + } + ) + + ipcMain.handle( + 'fs:startDownloadedFile', + async ( + event, + args: { suggestedName?: string } + ): Promise< + | { canceled: true } + | { + canceled: false + transferId: string + destinationPath: string + } + > => { + const suggestedName = sanitizeSaveDialogFilename( + validateRequiredString(args?.suggestedName, 'suggestedName') + ) + const parentWindow = BrowserWindow.fromWebContents(event.sender) ?? undefined + const dialogResult = parentWindow + ? await dialog.showSaveDialog(parentWindow, { defaultPath: suggestedName }) + : await dialog.showSaveDialog({ defaultPath: suggestedName }) + if (dialogResult.canceled || !dialogResult.filePath) { + return { canceled: true } + } + + const destinationPath = dialogResult.filePath + const { existed } = await inspectDownloadDestination(destinationPath) + const tempPath = createSiblingTransferPath(destinationPath, 'download') + const transferId = randomUUID() + try { + const handle = await open(tempPath, 'wx') + const senderId = typeof event.sender.id === 'number' ? event.sender.id : Number.NaN + const cleanupTimer = setTimeout(() => { + void closeDownloadSession(transferId, true) + }, DOWNLOAD_SESSION_TTL_MS) + if (typeof cleanupTimer.unref === 'function') { + cleanupTimer.unref() + } + downloadSessions.set(transferId, { + destinationPath, + tempPath, + destinationExisted: existed, + handle, + cleanupTimer, + senderId + }) + event.sender.once?.('destroyed', () => cleanupDownloadSessionsForSender(senderId)) + return { canceled: false, transferId, destinationPath } + } catch (error) { + await cleanupLocalTransferPath(tempPath) + throw error + } + } + ) + + ipcMain.handle( + 'fs:appendDownloadedFileChunk', + async ( + _event, + args: { transferId?: string; contentBase64?: string } + ): Promise<{ ok: true }> => { + const transferId = validateRequiredString(args?.transferId, 'transferId') + const contentBase64 = validateRequiredString(args?.contentBase64, 'contentBase64') + const session = downloadSessions.get(transferId) + if (!session) { + throw new Error('Download session not found') + } + await session.handle.writeFile(Buffer.from(contentBase64, 'base64')) + return { ok: true } + } + ) + + ipcMain.handle( + 'fs:finishDownloadedFile', + async ( + _event, + args: { transferId?: string } + ): Promise<{ canceled: false; destinationPath: string }> => { + const transferId = validateRequiredString(args?.transferId, 'transferId') + const session = await closeDownloadSession(transferId, false) + if (!session) { + throw new Error('Download session not found') + } + let promoted = false + try { + await promoteDownloadedFile( + session.tempPath, + session.destinationPath, + session.destinationExisted + ) + promoted = true + return { canceled: false, destinationPath: session.destinationPath } + } finally { + if (!promoted) { + await cleanupLocalTransferPath(session.tempPath) + } + } + } + ) + + ipcMain.handle( + 'fs:cancelDownloadedFile', + async (_event, args: { transferId?: string }): Promise<{ ok: true }> => { + const transferId = validateRequiredString(args?.transferId, 'transferId') + await closeDownloadSession(transferId, true) + return { ok: true } + } + ) + ipcMain.handle( 'fs:listMarkdownDocuments', async ( diff --git a/src/main/runtime/orca-runtime-files.ts b/src/main/runtime/orca-runtime-files.ts index 05cd446ad..5b4c6047c 100644 --- a/src/main/runtime/orca-runtime-files.ts +++ b/src/main/runtime/orca-runtime-files.ts @@ -33,6 +33,7 @@ import { import type { RuntimeFileListResult, RuntimeFileOpenResult, + RuntimeFileReadChunkResult, RuntimeFilePreviewResult, RuntimeFileReadResult, RuntimeTerminalPathResolution @@ -469,6 +470,45 @@ export class RuntimeFileCommands { return { content: buffer.toString('utf-8'), isBinary: false } } + async readFileExplorerChunk( + worktreeSelector: string, + relativePath: string, + offset: number, + length: number + ): Promise { + const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath) + const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) + } + const fileStat = await provider.stat(target.path) + if (fileStat.type === 'directory') { + throw new Error('Cannot download a directory') + } + throw new Error('SSH runtime chunked download is unavailable; use the SSH download path') + } + + const filePath = await resolveAuthorizedPath(target.path, this.host.requireStore()) + const fileStats = await stat(filePath) + if (fileStats.isDirectory()) { + throw new Error('Cannot download a directory') + } + const handle = await open(filePath, 'r') + try { + const buffer = Buffer.alloc(Math.min(length, Math.max(0, fileStats.size - offset))) + const { bytesRead } = await handle.read(buffer, 0, buffer.byteLength, offset) + const chunk = buffer.subarray(0, bytesRead) + return { + contentBase64: chunk.toString('base64'), + bytesRead, + eof: offset + bytesRead >= fileStats.size + } + } finally { + await handle.close() + } + } + async writeFileExplorerFile( worktreeSelector: string, relativePath: string, diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 3c22a7655..a0c40a84b 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -4655,6 +4655,8 @@ export class OrcaRuntimeService { this.fileCommands.watchFileExplorer.bind(this.fileCommands) readFileExplorerPreview: RuntimeFileCommands['readFileExplorerPreview'] = this.fileCommands.readFileExplorerPreview.bind(this.fileCommands) + readFileExplorerChunk: RuntimeFileCommands['readFileExplorerChunk'] = + this.fileCommands.readFileExplorerChunk.bind(this.fileCommands) writeFileExplorerFile: RuntimeFileCommands['writeFileExplorerFile'] = this.fileCommands.writeFileExplorerFile.bind(this.fileCommands) writeFileExplorerFileBase64: RuntimeFileCommands['writeFileExplorerFileBase64'] = diff --git a/src/main/runtime/rpc/methods/files.test.ts b/src/main/runtime/rpc/methods/files.test.ts index e1195b7f1..3293d62fa 100644 --- a/src/main/runtime/rpc/methods/files.test.ts +++ b/src/main/runtime/rpc/methods/files.test.ts @@ -338,6 +338,33 @@ describe('file RPC methods', () => { }) }) + it('reads a file chunk for a selected worktree', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + readFileExplorerChunk: vi.fn().mockResolvedValue({ + contentBase64: 'YWJj', + bytesRead: 3, + eof: true + }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('files.readChunk', { + worktree: 'id:wt-1', + relativePath: 'archive.zip', + offset: 0, + length: 1024 + }) + ) + + expect(runtime.readFileExplorerChunk).toHaveBeenCalledWith('id:wt-1', 'archive.zip', 0, 1024) + expect(response).toMatchObject({ + ok: true, + result: { contentBase64: 'YWJj', bytesRead: 3, eof: true } + }) + }) + it('reads a file explorer directory for a selected worktree', async () => { const runtime = { getRuntimeId: () => 'test-runtime', diff --git a/src/main/runtime/rpc/methods/files.ts b/src/main/runtime/rpc/methods/files.ts index 185f85eec..a7ac09478 100644 --- a/src/main/runtime/rpc/methods/files.ts +++ b/src/main/runtime/rpc/methods/files.ts @@ -78,6 +78,15 @@ const FileWriteBase64Chunk = FileWriteBase64.extend({ append: z.boolean().optional() }) +const FileReadChunk = FileOpen.extend({ + offset: z.number().int().nonnegative(), + length: z + .number() + .int() + .positive() + .max(512 * 1024) +}) + const FileRename = WorktreeSelector.extend({ oldRelativePath: z .unknown() @@ -175,6 +184,17 @@ export const FILE_METHODS: RpcAnyMethod[] = [ handler: async (params, { runtime }) => runtime.readFileExplorerPreview(params.worktree, params.relativePath) }), + defineMethod({ + name: 'files.readChunk', + params: FileReadChunk, + handler: async (params, { runtime }) => + runtime.readFileExplorerChunk( + params.worktree, + params.relativePath, + params.offset, + params.length + ) + }), defineMethod({ name: 'files.readDir', params: FileTreePath, diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index e043459e1..24a114501 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -323,6 +323,27 @@ function appendPendingMultiplexOutput( stream.pendingOutputOverflowed ||= trimmed.overflowed } +function getOutputAfterSnapshotSeq( + chunk: TerminalOutputChunk, + snapshotSeq: number | undefined +): string | null { + if ( + typeof snapshotSeq !== 'number' || + typeof chunk.meta?.seq !== 'number' || + typeof chunk.meta.rawLength !== 'number' + ) { + return chunk.data + } + if (chunk.meta.seq <= snapshotSeq) { + return null + } + const chunkStartSeq = chunk.meta.seq - chunk.meta.rawLength + if (chunkStartSeq >= snapshotSeq) { + return chunk.data + } + return chunk.data.slice(snapshotSeq - chunkStartSeq) +} + function trimPendingOutputToBudget( pendingOutput: TerminalOutputChunk[], pendingOutputBytes: number @@ -1489,7 +1510,8 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ const size = runtime.getTerminalSize(ptyId) const displayMode = runtime.getMobileDisplayMode(ptyId) const layoutSeq = runtime.getLayout(ptyId)?.seq - const snapshotSeq = serialized?.seq ?? layoutSeq + const snapshotFrameSeq = serialized?.seq ?? layoutSeq + const snapshotOutputSeq = serialized?.seq if (!isMobile) { const fitOverride = runtime.getTerminalFitOverride(ptyId) emit({ @@ -1520,7 +1542,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ cols: serialized?.cols ?? size?.cols ?? 80, rows: serialized?.rows ?? size?.rows ?? 24, displayMode, - seq: snapshotSeq, + seq: snapshotFrameSeq, truncated: serialized ? read.truncated : isTerminalReadPayloadIncomplete(read), truncatedByByteBudget: serialized?.truncatedByByteBudget, source: serialized?.source, @@ -1532,7 +1554,10 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ stream.lastResizeCols = serialized?.cols ?? size?.cols stream.buffering = false for (const chunk of stream.pendingOutput.splice(0)) { - stream.outputBatcher.push(chunk.data, chunk.meta) + const uncoveredData = getOutputAfterSnapshotSeq(chunk, snapshotOutputSeq) + if (uncoveredData) { + stream.outputBatcher.push(uncoveredData, chunk.meta) + } } stream.pendingOutputBytes = 0 stream.pendingOutputOverflowed = false @@ -1778,16 +1803,17 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ .catch(() => runtime.cleanupSubscription(subscriptionId)) const sendFrame = ( opcode: TerminalStreamOpcode, - payload: Uint8Array = new Uint8Array() + payload: Uint8Array = new Uint8Array(), + frameSeq = cursor++ ): void => { if (closed || !sendBinary) { return } - sendBinary(encodeTerminalStreamFrame({ opcode, streamId, seq: cursor++, payload })) + sendBinary(encodeTerminalStreamFrame({ opcode, streamId, seq: frameSeq, payload })) } - outputBatcher = createTerminalOutputBatcher((data) => { - for (const chunk of iterateTerminalOutputFrameChunks(data)) { - sendFrame(TerminalStreamOpcode.Output, chunk.bytes) + outputBatcher = createTerminalOutputBatcher((data, meta) => { + for (const chunk of iterateTerminalOutputFrameChunks(data, meta)) { + sendFrame(TerminalStreamOpcode.Output, chunk.bytes, chunk.seq) } }) unregisterBinaryHandler = @@ -1842,7 +1868,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ return } - unsubscribeData = runtime.subscribeToTerminalData(ptyId, (data) => { + unsubscribeData = runtime.subscribeToTerminalData(ptyId, (data, meta) => { if (closed) { return } @@ -1854,13 +1880,13 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ const measurement = measureTerminalStreamByteLength(data, { stopAfterBytes: remainingBudget }) - pendingOutput.push({ data, bytes: measurement.byteLength }) + pendingOutput.push({ data, bytes: measurement.byteLength, meta }) pendingOutputBytes += measurement.byteLength const trimmed = trimPendingOutputToBudget(pendingOutput, pendingOutputBytes) pendingOutputBytes = trimmed.bytes return } - outputBatcher?.push(data) + outputBatcher?.push(data, meta) }) const read = await runtime.readTerminal(params.terminal) @@ -1874,7 +1900,9 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ // the mobile client's stale-event filter knows the high-water mark. // Undefined when the PTY has never transitioned (filter is fail-open). // See docs/mobile-terminal-layout-state-machine.md. - const seq = runtime.getLayout(ptyId)?.seq + const layoutSeq = runtime.getLayout(ptyId)?.seq + const snapshotFrameSeq = serialized?.seq ?? layoutSeq + const snapshotOutputSeq = serialized?.seq emit({ type: 'subscribed', streamId, @@ -1883,14 +1911,14 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ cols: serialized?.cols ?? size?.cols, rows: serialized?.rows ?? size?.rows, displayMode, - seq + seq: layoutSeq }) const snapshotStats = sendSnapshotFrames(sendFrame, { kind: 'scrollback', cols: serialized?.cols ?? size?.cols ?? 80, rows: serialized?.rows ?? size?.rows ?? 24, displayMode, - seq, + seq: snapshotFrameSeq, truncated: serialized ? read.truncated : isTerminalReadPayloadIncomplete(read), truncatedByByteBudget: serialized?.truncatedByByteBudget, oscLinks: serialized?.oscLinks, @@ -1910,7 +1938,10 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ lastResizeCols = serialized?.cols ?? size?.cols buffering = false for (const item of pendingOutput.splice(0)) { - outputBatcher.push(item.data, item.meta) + const uncoveredData = getOutputAfterSnapshotSeq(item, snapshotOutputSeq) + if (uncoveredData) { + outputBatcher.push(uncoveredData, item.meta) + } } pendingOutputBytes = 0 outputBatcher.flush() diff --git a/src/main/runtime/rpc/terminal-multiplex.test.ts b/src/main/runtime/rpc/terminal-multiplex.test.ts index 830268b78..a19507a4a 100644 --- a/src/main/runtime/rpc/terminal-multiplex.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex.test.ts @@ -1026,6 +1026,275 @@ describe('terminal multiplex RPC', () => { } }) + it('flushes output buffered during initial multiplex snapshot once', async () => { + vi.useFakeTimers() + try { + const messages: string[] = [] + const binaryFrames: Uint8Array[] = [] + const cleanups = new Map void>() + const dataListenerRef: { + current?: (data: string, meta?: { seq?: number; rawLength?: number }) => void + } = {} + let resolveSnapshot: (value: { data: string; cols: number; rows: number }) => void = () => {} + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn( + () => + new Promise<{ data: string; cols: number; rows: number }>((resolve) => { + resolveSnapshot = resolve + }) + ), + getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + subscribeToTerminalData: vi.fn((_: string, listener) => { + dataListenerRef.current = listener + return vi.fn() + }), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + getDriver: vi.fn().mockReturnValue({ kind: 'idle' }), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => { + const cleanup = cleanups.get(id) + cleanups.delete(id) + cleanup?.() + }), + waitForTerminal: vi.fn(() => new Promise(() => {})), + sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), + updateDesktopViewport: vi.fn().mockResolvedValue(true) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.subscribe', { + terminal: 'terminal-1', + client: { id: 'desktop-1', type: 'desktop' }, + capabilities: { terminalBinaryStream: 1 } + }), + (msg) => messages.push(msg), + { + connectionId: 'conn-buffered-output-on-subscribe', + sendBinary: (bytes) => binaryFrames.push(bytes), + registerBinaryStreamHandler: vi.fn(() => vi.fn()) + } + ) + + await vi.waitFor(() => expect(dataListenerRef.current).toBeDefined()) + dataListenerRef.current?.('starting shell\r\n', { seq: 16, rawLength: 16 }) + resolveSnapshot({ data: '', cols: 120, rows: 40 }) + await vi.waitFor(() => + expect(messages.some((msg) => JSON.parse(msg).result?.type === 'subscribed')).toBe(true) + ) + await vi.runOnlyPendingTimersAsync() + + const output = binaryFrames + .map((frame) => decodeTerminalStreamFrame(frame)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Output) + .map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : '')) + .join('') + expect(output).toBe('starting shell\r\n') + + runtime.cleanupSubscription('terminal-1:desktop-1') + await dispatchPromise + } finally { + vi.useRealTimers() + } + }) + + it('drops buffered multiplex output already covered by the initial snapshot seq', async () => { + vi.useFakeTimers() + try { + const messages: string[] = [] + const binaryFrames: Uint8Array[] = [] + const cleanups = new Map void>() + const dataListenerRef: { + current?: (data: string, meta?: { seq?: number; rawLength?: number }) => void + } = {} + let resolveSnapshot: (value: { + data: string + cols: number + rows: number + seq: number + }) => void = () => {} + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn( + () => + new Promise<{ data: string; cols: number; rows: number; seq: number }>((resolve) => { + resolveSnapshot = resolve + }) + ), + getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + subscribeToTerminalData: vi.fn((_: string, listener) => { + dataListenerRef.current = listener + return vi.fn() + }), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + getDriver: vi.fn().mockReturnValue({ kind: 'idle' }), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => { + const cleanup = cleanups.get(id) + cleanups.delete(id) + cleanup?.() + }), + waitForTerminal: vi.fn(() => new Promise(() => {})), + sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), + updateDesktopViewport: vi.fn().mockResolvedValue(true) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.subscribe', { + terminal: 'terminal-1', + client: { id: 'desktop-1', type: 'desktop' }, + capabilities: { terminalBinaryStream: 1 } + }), + (msg) => messages.push(msg), + { + connectionId: 'conn-buffered-output-covered-by-snapshot', + sendBinary: (bytes) => binaryFrames.push(bytes), + registerBinaryStreamHandler: vi.fn(() => vi.fn()) + } + ) + + await vi.waitFor(() => expect(dataListenerRef.current).toBeDefined()) + const startupLine = 'starting shell\r\n' + dataListenerRef.current?.(startupLine, { + seq: startupLine.length, + rawLength: startupLine.length + }) + resolveSnapshot({ + data: startupLine, + cols: 120, + rows: 40, + seq: startupLine.length + }) + await vi.waitFor(() => + expect(messages.some((msg) => JSON.parse(msg).result?.type === 'subscribed')).toBe(true) + ) + await vi.runOnlyPendingTimersAsync() + + const outputFrames = binaryFrames + .map((frame) => decodeTerminalStreamFrame(frame)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Output) + const snapshotStart = binaryFrames + .map((frame) => decodeTerminalStreamFrame(frame)) + .find((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotStart) + expect(snapshotStart && decodeTerminalStreamJson(snapshotStart.payload)).toMatchObject({ + seq: startupLine.length + }) + expect(outputFrames).toHaveLength(0) + + runtime.cleanupSubscription('terminal-1:desktop-1') + await dispatchPromise + } finally { + vi.useRealTimers() + } + }) + + it('replays only buffered multiplex output not covered by the initial snapshot seq', async () => { + vi.useFakeTimers() + try { + const messages: string[] = [] + const binaryFrames: Uint8Array[] = [] + const cleanups = new Map void>() + const dataListenerRef: { + current?: (data: string, meta?: { seq?: number; rawLength?: number }) => void + } = {} + let resolveSnapshot: (value: { + data: string + cols: number + rows: number + seq: number + }) => void = () => {} + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn( + () => + new Promise<{ data: string; cols: number; rows: number; seq: number }>((resolve) => { + resolveSnapshot = resolve + }) + ), + getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + subscribeToTerminalData: vi.fn((_: string, listener) => { + dataListenerRef.current = listener + return vi.fn() + }), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + getDriver: vi.fn().mockReturnValue({ kind: 'idle' }), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => { + const cleanup = cleanups.get(id) + cleanups.delete(id) + cleanup?.() + }), + waitForTerminal: vi.fn(() => new Promise(() => {})), + sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), + updateDesktopViewport: vi.fn().mockResolvedValue(true) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.subscribe', { + terminal: 'terminal-1', + client: { id: 'desktop-1', type: 'desktop' }, + capabilities: { terminalBinaryStream: 1 } + }), + (msg) => messages.push(msg), + { + connectionId: 'conn-buffered-output-partially-covered-by-snapshot', + sendBinary: (bytes) => binaryFrames.push(bytes), + registerBinaryStreamHandler: vi.fn(() => vi.fn()) + } + ) + + await vi.waitFor(() => expect(dataListenerRef.current).toBeDefined()) + const buffered = 'hello world' + dataListenerRef.current?.(buffered, { + seq: buffered.length, + rawLength: buffered.length + }) + resolveSnapshot({ + data: 'hello', + cols: 120, + rows: 40, + seq: 'hello'.length + }) + await vi.waitFor(() => + expect(messages.some((msg) => JSON.parse(msg).result?.type === 'subscribed')).toBe(true) + ) + await vi.runOnlyPendingTimersAsync() + + const output = binaryFrames + .map((frame) => decodeTerminalStreamFrame(frame)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Output) + .map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : '')) + .join('') + expect(output).toBe(' world') + + runtime.cleanupSubscription('terminal-1:desktop-1') + await dispatchPromise + } finally { + vi.useRealTimers() + } + }) + it('settles mobile multiplex PTY waits when the stream signal aborts before PTY spawn', async () => { const messages: string[] = [] const binaryFrames: Uint8Array[] = [] diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index 4ba19a3fd..aebeaf9ba 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -152,6 +152,7 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'files.open', 'files.openDiff', 'files.read', + 'files.readChunk', 'files.readPreview', 'files.resolveTerminalPath', 'git.abortMerge', diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 79d80adb8..7ae75a59a 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -2007,6 +2007,24 @@ export type PreloadApi = { filePath: string connectionId: string }) => Promise<{ canceled: true } | { canceled: false; destinationPath: string }> + saveDownloadedFile: (args: { + suggestedName: string + content: string + encoding: 'utf8' | 'base64' + }) => Promise<{ canceled: true } | { canceled: false; destinationPath: string }> + startDownloadedFile: (args: { + suggestedName: string + }) => Promise< + { canceled: true } | { canceled: false; transferId: string; destinationPath: string } + > + appendDownloadedFileChunk: (args: { + transferId: string + contentBase64: string + }) => Promise<{ ok: true }> + finishDownloadedFile: (args: { + transferId: string + }) => Promise<{ canceled: false; destinationPath: string }> + cancelDownloadedFile: (args: { transferId: string }) => Promise<{ ok: true }> listMarkdownDocuments: (args: { rootPath: string connectionId?: string diff --git a/src/preload/index.ts b/src/preload/index.ts index 08e31cab0..a3adca161 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2404,6 +2404,27 @@ const api = { connectionId: string }): Promise<{ canceled: true } | { canceled: false; destinationPath: string }> => ipcRenderer.invoke('fs:downloadFile', args), + saveDownloadedFile: (args: { + suggestedName: string + content: string + encoding: 'utf8' | 'base64' + }): Promise<{ canceled: true } | { canceled: false; destinationPath: string }> => + ipcRenderer.invoke('fs:saveDownloadedFile', args), + startDownloadedFile: (args: { + suggestedName: string + }): Promise< + { canceled: true } | { canceled: false; transferId: string; destinationPath: string } + > => ipcRenderer.invoke('fs:startDownloadedFile', args), + appendDownloadedFileChunk: (args: { + transferId: string + contentBase64: string + }): Promise<{ ok: true }> => ipcRenderer.invoke('fs:appendDownloadedFileChunk', args), + finishDownloadedFile: (args: { + transferId: string + }): Promise<{ canceled: false; destinationPath: string }> => + ipcRenderer.invoke('fs:finishDownloadedFile', args), + cancelDownloadedFile: (args: { transferId: string }): Promise<{ ok: true }> => + ipcRenderer.invoke('fs:cancelDownloadedFile', args), listMarkdownDocuments: (args: { rootPath: string connectionId?: string diff --git a/src/renderer/src/components/right-sidebar/FileExplorer.test.tsx b/src/renderer/src/components/right-sidebar/FileExplorer.test.tsx index 7084002cd..1722a4ed8 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorer.test.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorer.test.tsx @@ -23,8 +23,10 @@ import { import { FileExplorerVirtualRows } from './FileExplorerVirtualRows' import type { TreeNode } from './file-explorer-types' import { createFileExplorerRowProjection } from './file-explorer-row-projection' +import type * as RuntimeFileClient from '@/runtime/runtime-file-client' -const { toastErrorMock, toastSuccessMock } = vi.hoisted(() => ({ +const { downloadRuntimeFileMock, toastErrorMock, toastSuccessMock } = vi.hoisted(() => ({ + downloadRuntimeFileMock: vi.fn(), toastErrorMock: vi.fn(), toastSuccessMock: vi.fn() })) @@ -36,6 +38,14 @@ vi.mock('sonner', () => ({ } })) +vi.mock('@/runtime/runtime-file-client', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + downloadRuntimeFile: downloadRuntimeFileMock + } +}) + type ReactElementLike = { type: unknown props: Record @@ -565,15 +575,24 @@ describe('FileExplorerRow collapse folder action', () => { ).toBe(false) }) - it('shows remote download only for desktop SSH file-like rows', () => { + it('shows remote download only for desktop SSH or Remote Host file-like rows', () => { + const runtimeContext = { + settings: { activeRuntimeEnvironmentId: 'runtime-1' }, + worktreeId: 'wt-1', + worktreePath: '/repo' + } + expect(shouldShowRemoteDownloadAction(fileNode, 'ssh-1')).toBe(true) expect(shouldShowRemoteDownloadAction({ ...fileNode, isSymlink: true }, 'ssh-1')).toBe(true) + expect(shouldShowRemoteDownloadAction(fileNode, null, runtimeContext)).toBe(true) expect(shouldShowRemoteDownloadAction(fileNode, null)).toBe(false) expect(shouldShowRemoteDownloadAction(directoryNode, 'ssh-1')).toBe(false) + expect(shouldShowRemoteDownloadAction(directoryNode, null, runtimeContext)).toBe(false) ;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true expect(shouldShowRemoteDownloadAction(fileNode, 'ssh-1')).toBe(false) + expect(shouldShowRemoteDownloadAction(fileNode, null, runtimeContext)).toBe(false) }) it('shows OS file copy for single local rows and SSH file rows on desktop', () => { @@ -677,6 +696,43 @@ describe('FileExplorerRow collapse folder action', () => { expect(toastErrorMock).not.toHaveBeenCalled() }) + it('downloads Remote Host rows through the runtime download path', async () => { + const runtimeContext = { + settings: { activeRuntimeEnvironmentId: 'runtime-1' }, + worktreeId: 'wt-1', + worktreePath: '/repo' + } + downloadRuntimeFileMock.mockResolvedValueOnce({ + canceled: false, + destinationPath: '/downloads/index.ts' + }) + const openPath = vi.fn().mockResolvedValue(undefined) + ;( + globalThis as unknown as { + window: { + api: { + shell: { openPath: typeof openPath } + } + } + } + ).window = { api: { shell: { openPath } } } + + await downloadRemoteFile(fileNode, runtimeContext) + + expect(downloadRuntimeFileMock).toHaveBeenCalledWith( + runtimeContext, + '/repo/src/index.ts', + 'index.ts' + ) + expect(toastSuccessMock).toHaveBeenCalledWith("Downloaded 'index.ts'", { + action: { + label: 'Open', + onClick: expect.any(Function) + } + }) + expect(toastErrorMock).not.toHaveBeenCalled() + }) + it('shows a failure toast when remote download fails', async () => { const downloadFile = vi.fn().mockRejectedValue(new Error('Remote connection dropped')) ;( diff --git a/src/renderer/src/components/right-sidebar/FileExplorer.tsx b/src/renderer/src/components/right-sidebar/FileExplorer.tsx index cc7b7c1cd..bf8eb2736 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorer.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorer.tsx @@ -53,6 +53,7 @@ import { useFileExplorerVisibleRowProjection } from './useFileExplorerVisibleRow import { translate } from '@/i18n/i18n' import { CLOSE_ALL_CONTEXT_MENUS_EVENT } from '@/components/tab-bar/SortableTab' import type { RightSidebarExplorerView } from '../../../../shared/types' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' function FileExplorerFiles(): React.JSX.Element { const explorerView = useAppStore((s) => s.rightSidebarExplorerView) @@ -78,6 +79,9 @@ function FileExplorerFiles(): React.JSX.Element { const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) const activeWorktree = useActiveWorktree() const activeRepo = useRepoById(activeWorktree?.repoId ?? null) + const activeRuntimeEnvironmentId = useAppStore((s) => + getRuntimeEnvironmentIdForWorktree(s, activeWorktreeId) + ) const sshConnectedGeneration = useAppStore((s) => s.sshConnectedGeneration) const expandedDirs = useAppStore((s) => s.expandedDirs) const collapseAllDirs = useAppStore((s) => s.collapseAllDirs) @@ -99,6 +103,18 @@ function FileExplorerFiles(): React.JSX.Element { const toggleShowDotfilesForWorktree = useAppStore((s) => s.toggleShowDotfilesForWorktree) const worktreePath = activeWorktree?.path ?? null + const runtimeDownloadContext = useMemo( + () => + activeRuntimeEnvironmentId && activeWorktreeId && worktreePath + ? { + settings: { activeRuntimeEnvironmentId }, + worktreeId: activeWorktreeId, + worktreePath, + connectionId: activeRepo?.connectionId ?? undefined + } + : null, + [activeRepo?.connectionId, activeRuntimeEnvironmentId, activeWorktreeId, worktreePath] + ) const isFilesViewActive = explorerView === 'files' const visibleFilesWorktreePath = getVisibleFileExplorerWorktreePath({ explorerView, @@ -716,6 +732,7 @@ function FileExplorerFiles(): React.JSX.Element { flashingPath={flashingPath} deleteShortcutLabel={deleteShortcutLabel} connectionId={activeRepo?.connectionId ?? null} + runtimeDownloadContext={runtimeDownloadContext} onClick={handleRowClick} onDoubleClick={handleDoubleClick} onContextMenuSelect={preserveSelectionForContextMenu} diff --git a/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx b/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx index 7975df460..d24d3d656 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx @@ -50,6 +50,7 @@ import { isLocalPathOpenBlocked, showLocalPathOpenBlockedToast } from '@/lib/loc import { translate } from '@/i18n/i18n' import { extractIpcErrorMessage } from '@/lib/ipc-error' import { CLOSE_ALL_CONTEXT_MENUS_EVENT } from '@/components/tab-bar/SortableTab' +import { downloadRuntimeFile, type RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' const isMac = navigator.userAgent.includes('Mac') const isLinux = navigator.userAgent.includes('Linux') @@ -270,6 +271,7 @@ type FileExplorerRowProps = { isIgnored: boolean deleteShortcutLabel: string connectionId?: string | null + runtimeDownloadContext?: RuntimeFileOperationArgs | null canCollapseFolderSubtree: boolean targetDir: string targetDepth: number @@ -304,12 +306,13 @@ export function shouldShowFindInFolderAction(node: TreeNode): boolean { export function shouldShowRemoteDownloadAction( node: TreeNode, - connectionId?: string | null + connectionId?: string | null, + runtimeDownloadContext?: RuntimeFileOperationArgs | null ): boolean { // Why: Desktop-only because download depends on Electron's native save dialog. return ( !node.isDirectory && - Boolean(connectionId) && + Boolean(connectionId || runtimeDownloadContext) && (globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ !== true ) } @@ -328,9 +331,18 @@ export function shouldShowCopyFileAction( ) } -export async function downloadRemoteFile(node: TreeNode, connectionId: string): Promise { +export async function downloadRemoteFile( + node: TreeNode, + connectionIdOrRuntimeContext: string | RuntimeFileOperationArgs +): Promise { try { - const result = await window.api.fs.downloadFile({ filePath: node.path, connectionId }) + const result = + typeof connectionIdOrRuntimeContext === 'string' + ? await window.api.fs.downloadFile({ + filePath: node.path, + connectionId: connectionIdOrRuntimeContext + }) + : await downloadRuntimeFile(connectionIdOrRuntimeContext, node.path, node.name) // Why: Suppress toasts when the user cancels the native save dialog per design. if (result.canceled) { return @@ -396,6 +408,7 @@ export function FileExplorerRow({ isIgnored, deleteShortcutLabel, connectionId, + runtimeDownloadContext, canCollapseFolderSubtree, targetDir, targetDepth, @@ -426,7 +439,11 @@ export function FileExplorerRow({ const findInFolderShortcutLabel = useShortcutLabel('sidebar.search.toggle') const FileIcon = getFileTypeIcon(node.relativePath || node.name) const rowDropDir = node.isDirectory ? node.path : targetDir - const showRemoteDownloadAction = shouldShowRemoteDownloadAction(node, connectionId) + const showRemoteDownloadAction = shouldShowRemoteDownloadAction( + node, + connectionId, + runtimeDownloadContext + ) const showCopyFileAction = shouldShowCopyFileAction(node, connectionId, selectionSize) const { setRowDragNode, handleDragOver, handleDragEnter, handleDragLeave, handleDrop } = useFileExplorerRowDrag({ @@ -450,11 +467,12 @@ export function FileExplorerRow({ } }, [activeWorktreeId, node.path]) const handleDownload = useCallback(() => { - if (!connectionId) { + const downloadTarget = connectionId || runtimeDownloadContext + if (!downloadTarget) { return } - void downloadRemoteFile(node, connectionId) - }, [connectionId, node]) + void downloadRemoteFile(node, downloadTarget) + }, [connectionId, node, runtimeDownloadContext]) const handleCopyFile = useCallback(() => { void copyFileToOsClipboard(node, connectionId) }, [connectionId, node]) diff --git a/src/renderer/src/components/right-sidebar/FileExplorerVirtualRows.tsx b/src/renderer/src/components/right-sidebar/FileExplorerVirtualRows.tsx index 76af329a4..a400fef2c 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorerVirtualRows.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorerVirtualRows.tsx @@ -7,6 +7,7 @@ import { FileExplorerRow, InlineInputRow, type InlineInput } from './FileExplore import { shouldShowIgnoredDecoration, STATUS_COLORS } from './status-display' import type { DirCache, TreeNode } from './file-explorer-types' import type { FileExplorerRowProjection } from './file-explorer-row-projection' +import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' type FileExplorerVirtualRowsProps = { virtualizer: Virtualizer @@ -26,6 +27,7 @@ type FileExplorerVirtualRowsProps = { flashingPath: string | null deleteShortcutLabel: string connectionId?: string | null + runtimeDownloadContext?: RuntimeFileOperationArgs | null onClick: (node: TreeNode, event: React.MouseEvent) => void onDoubleClick: (node: TreeNode) => void onContextMenuSelect: (node: TreeNode) => void @@ -68,6 +70,7 @@ export function FileExplorerVirtualRows(props: FileExplorerVirtualRowsProps): Re flashingPath, deleteShortcutLabel, connectionId, + runtimeDownloadContext, onClick, onDoubleClick, onContextMenuSelect, @@ -168,6 +171,7 @@ export function FileExplorerVirtualRows(props: FileExplorerVirtualRowsProps): Re isIgnored={isIgnored} deleteShortcutLabel={deleteShortcutLabel} connectionId={connectionId} + runtimeDownloadContext={runtimeDownloadContext} canCollapseFolderSubtree={canCollapseFolderSubtree} targetDir={n.isDirectory ? n.path : dirname(n.path)} targetDepth={n.isDirectory ? n.depth + 1 : n.depth} diff --git a/src/renderer/src/runtime/runtime-file-client.test.ts b/src/renderer/src/runtime/runtime-file-client.test.ts index c3dc9c0f1..b56504351 100644 --- a/src/renderer/src/runtime/runtime-file-client.test.ts +++ b/src/renderer/src/runtime/runtime-file-client.test.ts @@ -6,6 +6,7 @@ import { copyRuntimePath, createRuntimePath, deleteRuntimePath, + downloadRuntimeFile, getRuntimeFileReadScope, importExternalPathsToRuntime, listRuntimeFiles, @@ -36,6 +37,12 @@ const fsDeletePath = vi.fn() const fsStat = vi.fn() const fsPathExists = vi.fn() const fsSearch = vi.fn() +const fsDownloadFile = vi.fn() +const fsSaveDownloadedFile = vi.fn() +const fsStartDownloadedFile = vi.fn() +const fsAppendDownloadedFileChunk = vi.fn() +const fsFinishDownloadedFile = vi.fn() +const fsCancelDownloadedFile = vi.fn() const fsImportExternalPaths = vi.fn() const fsStageExternalPathsForRuntimeUpload = vi.fn() const runtimeEnvironmentCall = vi.fn() @@ -56,6 +63,12 @@ beforeEach(() => { fsStat.mockReset() fsPathExists.mockReset() fsSearch.mockReset() + fsDownloadFile.mockReset() + fsSaveDownloadedFile.mockReset() + fsStartDownloadedFile.mockReset() + fsAppendDownloadedFileChunk.mockReset() + fsFinishDownloadedFile.mockReset() + fsCancelDownloadedFile.mockReset() fsImportExternalPaths.mockReset() fsStageExternalPathsForRuntimeUpload.mockReset() runtimeEnvironmentCall.mockReset() @@ -89,6 +102,12 @@ beforeEach(() => { stat: fsStat, pathExists: fsPathExists, search: fsSearch, + downloadFile: fsDownloadFile, + saveDownloadedFile: fsSaveDownloadedFile, + startDownloadedFile: fsStartDownloadedFile, + appendDownloadedFileChunk: fsAppendDownloadedFileChunk, + finishDownloadedFile: fsFinishDownloadedFile, + cancelDownloadedFile: fsCancelDownloadedFile, importExternalPaths: fsImportExternalPaths, stageExternalPathsForRuntimeUpload: fsStageExternalPathsForRuntimeUpload }, @@ -348,6 +367,96 @@ describe('runtime file client', () => { expect(runtimeEnvironmentCall).not.toHaveBeenCalled() }) + it('downloads remote runtime files in chunks instead of using preview content', async () => { + fsStartDownloadedFile.mockResolvedValue({ + canceled: false, + transferId: 'download-1', + destinationPath: '/downloads/archive.zip' + }) + fsAppendDownloadedFileChunk.mockResolvedValue({ ok: true }) + fsFinishDownloadedFile.mockResolvedValue({ + canceled: false, + destinationPath: '/downloads/archive.zip' + }) + runtimeEnvironmentCall + .mockResolvedValueOnce({ + id: 'chunk-1', + ok: true, + result: { contentBase64: 'YWJj', bytesRead: 3, eof: false }, + _meta: { runtimeId: 'remote-runtime' } + }) + .mockResolvedValueOnce({ + id: 'chunk-2', + ok: true, + result: { contentBase64: 'ZA==', bytesRead: 1, eof: true }, + _meta: { runtimeId: 'remote-runtime' } + }) + + await expect( + downloadRuntimeFile( + { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/remote/repo' + }, + '/remote/repo/archive.zip', + 'archive.zip' + ) + ).resolves.toEqual({ canceled: false, destinationPath: '/downloads/archive.zip' }) + + expect(fsStartDownloadedFile).toHaveBeenCalledWith({ suggestedName: 'archive.zip' }) + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(1, { + selector: 'env-1', + method: 'files.readChunk', + params: { + worktree: 'id:wt-1', + relativePath: 'archive.zip', + offset: 0, + length: 384 * 1024 + }, + timeoutMs: 60_000 + }) + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(2, { + selector: 'env-1', + method: 'files.readChunk', + params: { + worktree: 'id:wt-1', + relativePath: 'archive.zip', + offset: 3, + length: 384 * 1024 + }, + timeoutMs: 60_000 + }) + expect(fsAppendDownloadedFileChunk).toHaveBeenCalledTimes(2) + expect(fsFinishDownloadedFile).toHaveBeenCalledWith({ transferId: 'download-1' }) + expect(fsCancelDownloadedFile).not.toHaveBeenCalled() + }) + + it('cancels the local temp download when a remote chunk fails', async () => { + fsStartDownloadedFile.mockResolvedValue({ + canceled: false, + transferId: 'download-1', + destinationPath: '/downloads/archive.zip' + }) + fsCancelDownloadedFile.mockResolvedValue({ ok: true }) + runtimeEnvironmentCall.mockRejectedValueOnce(new Error('connection dropped')) + + await expect( + downloadRuntimeFile( + { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/remote/repo' + }, + '/remote/repo/archive.zip', + 'archive.zip' + ) + ).rejects.toThrow('connection dropped') + + expect(fsCancelDownloadedFile).toHaveBeenCalledWith({ transferId: 'download-1' }) + expect(fsFinishDownloadedFile).not.toHaveBeenCalled() + }) + it('routes root directory reads with an empty relative path', async () => { runtimeEnvironmentCall.mockResolvedValue({ id: 'rpc-1', diff --git a/src/renderer/src/runtime/runtime-file-client.ts b/src/renderer/src/runtime/runtime-file-client.ts index ee9d69334..b5bceb451 100644 --- a/src/renderer/src/runtime/runtime-file-client.ts +++ b/src/renderer/src/runtime/runtime-file-client.ts @@ -9,7 +9,11 @@ import type { SearchOptions, SearchResult } from '../../../shared/types' -import type { RuntimeFilePreviewResult, RuntimeFileReadResult } from '../../../shared/runtime-types' +import type { + RuntimeFilePreviewResult, + RuntimeFileReadChunkResult, + RuntimeFileReadResult +} from '../../../shared/runtime-types' import { callRuntimeRpc, getActiveRuntimeTarget, @@ -49,6 +53,10 @@ export type RuntimeFileOperationArgs = { connectionId?: string } +export type RuntimeFileDownloadResult = + | { canceled: true } + | { canceled: false; destinationPath: string } + type StagedRuntimeImportSource = | { sourcePath: string @@ -93,6 +101,7 @@ type RuntimeFileWatchEvent = | { type: 'end' } const REMOTE_UPLOAD_BASE64_CHUNK_CHARS = 512 * 1024 +const REMOTE_DOWNLOAD_CHUNK_BYTES = 384 * 1024 type RuntimeFileWatchListener = { onPayload: (payload: FsChangedPayload) => void @@ -179,6 +188,71 @@ export async function readRuntimeFilePreview( ) } +export async function downloadRuntimeFile( + context: RuntimeFileOperationArgs, + filePath: string, + suggestedName: string +): Promise { + const remoteArgs = getRemoteFileArgs(context, filePath) + if (!remoteArgs) { + if (hasRemoteRuntimeOwner(context)) { + throw new Error('Remote file is outside the owning runtime worktree') + } + if (context.connectionId) { + return window.api.fs.downloadFile({ filePath, connectionId: context.connectionId }) + } + const result = await readRuntimeFilePreview(context, filePath) + return window.api.fs.saveDownloadedFile({ + suggestedName, + content: result.content, + encoding: result.isBinary ? 'base64' : 'utf8' + }) + } + + const download = await window.api.fs.startDownloadedFile({ suggestedName }) + if (download.canceled) { + return download + } + + let finished = false + try { + let offset = 0 + for (;;) { + const chunk = await callRuntimeRpc( + remoteArgs.target, + 'files.readChunk', + { + worktree: remoteArgs.worktreeSelector, + relativePath: remoteArgs.relativePath, + offset, + length: REMOTE_DOWNLOAD_CHUNK_BYTES + }, + { timeoutMs: 60_000 } + ) + if (chunk.bytesRead > 0) { + await window.api.fs.appendDownloadedFileChunk({ + transferId: download.transferId, + contentBase64: chunk.contentBase64 + }) + } + offset += chunk.bytesRead + if (chunk.eof) { + break + } + if (chunk.bytesRead <= 0) { + throw new Error('Remote download stalled before reaching EOF') + } + } + const result = await window.api.fs.finishDownloadedFile({ transferId: download.transferId }) + finished = true + return result + } finally { + if (!finished) { + await window.api.fs.cancelDownloadedFile({ transferId: download.transferId }).catch(() => {}) + } + } +} + export async function readRuntimeDirectory( context: RuntimeFileOperationArgs, dirPath: string diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index ab9393029..eee02f762 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -1274,6 +1274,21 @@ function createFileApi(): NonNullable['fs']> { downloadFile: async () => { throw new Error('Remote file download is unavailable in paired web clients.') }, + saveDownloadedFile: async () => { + throw new Error('Remote file download is unavailable in paired web clients.') + }, + startDownloadedFile: async () => { + throw new Error('Remote file download is unavailable in paired web clients.') + }, + appendDownloadedFileChunk: async () => { + throw new Error('Remote file download is unavailable in paired web clients.') + }, + finishDownloadedFile: async () => { + throw new Error('Remote file download is unavailable in paired web clients.') + }, + cancelDownloadedFile: async () => { + throw new Error('Remote file download is unavailable in paired web clients.') + }, listMarkdownDocuments: async ({ rootPath }) => { const file = await resolveRuntimeFilePath(rootPath) return callRuntimeResult('files.listMarkdownDocuments', { diff --git a/src/shared/agent-detection.test.ts b/src/shared/agent-detection.test.ts index 1a42e3620..61796a29a 100644 --- a/src/shared/agent-detection.test.ts +++ b/src/shared/agent-detection.test.ts @@ -77,3 +77,25 @@ describe('MiMo title detection', () => { } ) }) + +describe('Pi-compatible title detection', () => { + it.each([ + ['\u280b OMP', 'OMP', 'working'], + ['OMP ready', 'OMP', 'idle'], + ['OMP - action required', 'OMP', 'permission'], + ['\u280b Pi', 'Pi', 'working'], + ['Pi ready', 'Pi', 'idle'], + ['Pi - action required', 'Pi', 'permission'] + ] as const)('classifies synthesized %s', (title, expectedLabel, expectedStatus) => { + expect(getAgentLabel(title)).toBe(expectedLabel) + expect(detectAgentStatusFromTitle(title)).toBe(expectedStatus) + }) + + it.each(['~/omp/working', 'omp-harness ready', '~/pi/working', 'pi-scratch ready'])( + 'does not classify path or hyphen false positive %s', + (title) => { + expect(getAgentLabel(title)).toBeNull() + expect(detectAgentStatusFromTitle(title)).toBeNull() + } + ) +}) diff --git a/src/shared/agent-detection.ts b/src/shared/agent-detection.ts index 4b8089351..196d6eadb 100644 --- a/src/shared/agent-detection.ts +++ b/src/shared/agent-detection.ts @@ -14,6 +14,10 @@ import { titleHasAgentName, titleHasAnyLegacyAgentName } from './agent-name-token-match' +import { + getPiCompatibleSyntheticAgentLabel, + getPiCompatibleSyntheticAgentStatus +} from './pi-compatible-synthetic-title' // Re-export so existing `agent-detection` importers keep working. export { AGENT_NAMES, titleHasAgentName } from './agent-name-token-match' @@ -319,6 +323,12 @@ export function getAgentLabel(title: string): string | null { if (isGeminiTerminalTitle(title)) { return 'Gemini CLI' } + // Why: Pi-compatible synthetic titles can carry braille spinners, which the + // generic agent-title heuristics would otherwise claim first. + const piCompatibleSyntheticAgentLabel = getPiCompatibleSyntheticAgentLabel(title) + if (piCompatibleSyntheticAgentLabel) { + return piCompatibleSyntheticAgentLabel + } // Why: Pi working titles include a braille spinner prefix, which would be // mistaken for Claude Code if we checked `isClaudeAgent` first. if (isPiAgentTitle(title)) { @@ -417,6 +427,13 @@ export function detectAgentStatusFromTitle(title: string): AgentStatus | null { return 'idle' } + // Why: resolve synthetic Pi/OMP permission/idle labels before the broader + // Pi and braille-spinner checks below. + const piCompatibleSyntheticAgentStatus = getPiCompatibleSyntheticAgentStatus(title) + if (piCompatibleSyntheticAgentStatus) { + return piCompatibleSyntheticAgentStatus + } + // Claude Code uses ✳ prefix for idle — must check before braille/agent-name // because the title text is the task description, not "Claude Code". if (title.startsWith(`${CLAUDE_IDLE} `) || title === CLAUDE_IDLE) { diff --git a/src/shared/pi-compatible-synthetic-title.ts b/src/shared/pi-compatible-synthetic-title.ts new file mode 100644 index 000000000..5e64f04c8 --- /dev/null +++ b/src/shared/pi-compatible-synthetic-title.ts @@ -0,0 +1,49 @@ +export type PiCompatibleSyntheticAgentLabel = 'Pi' | 'OMP' +export type PiCompatibleSyntheticAgentStatus = 'working' | 'permission' | 'idle' + +const PI_COMPATIBLE_SYNTHETIC_TITLE_RE = + /^\s*(?:[\u2800-\u28ff]\s+)?(pi|omp)(?:\s+-\s+action required|\s+(?:ready|idle|done))?\s*$/i +const PI_COMPATIBLE_IDLE_RE = /(?= 0x2800 && codePoint <= 0x28ff) { + return true + } + } + return false +} + +export function getPiCompatibleSyntheticAgentLabel( + title: string +): PiCompatibleSyntheticAgentLabel | null { + const match = PI_COMPATIBLE_SYNTHETIC_TITLE_RE.exec(title) + if (!match) { + return null + } + return match[1].toLowerCase() === 'omp' ? 'OMP' : 'Pi' +} + +export function getPiCompatibleSyntheticAgentStatus( + title: string +): PiCompatibleSyntheticAgentStatus | null { + if (!getPiCompatibleSyntheticAgentLabel(title)) { + return null + } + if (containsBrailleSpinner(title)) { + return 'working' + } + const lower = title.toLowerCase() + if ( + lower.includes('action required') || + lower.includes('permission') || + lower.includes('waiting') + ) { + return 'permission' + } + if (PI_COMPATIBLE_IDLE_RE.test(title)) { + return 'idle' + } + return null +} diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index 064f4092f..d42ca4237 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -327,6 +327,12 @@ export type RuntimeFilePreviewResult = { mimeType?: string } +export type RuntimeFileReadChunkResult = { + contentBase64: string + bytesRead: number + eof: boolean +} + export type RuntimeTerminalSummary = { handle: string ptyId: string | null diff --git a/src/shared/synthetic-agent-title.test.ts b/src/shared/synthetic-agent-title.test.ts index e4b80521e..dc51838ed 100644 --- a/src/shared/synthetic-agent-title.test.ts +++ b/src/shared/synthetic-agent-title.test.ts @@ -20,4 +20,16 @@ describe('synthetic agent titles', () => { expect(getSyntheticAgentTerminalTitle('devin', 'waiting')).toBe('Devin - action required') expect(shouldDriveSyntheticAgentTitleFromHook('devin', 'working')).toBe(true) }) + + it('provides Pi-compatible OMP titles for hook-driven status updates', () => { + expect(getSyntheticAgentTerminalTitle('omp', 'done')).toBe('OMP ready') + expect(getSyntheticAgentTerminalTitle('omp', 'waiting')).toBe('OMP - action required') + expect(shouldDriveSyntheticAgentTitleFromHook('omp', 'working')).toBe(true) + }) + + it('provides Pi titles for hook-driven status updates', () => { + expect(getSyntheticAgentTerminalTitle('pi', 'done')).toBe('Pi ready') + expect(getSyntheticAgentTerminalTitle('pi', 'waiting')).toBe('Pi - action required') + expect(shouldDriveSyntheticAgentTitleFromHook('pi', 'working')).toBe(true) + }) }) diff --git a/src/shared/synthetic-agent-title.ts b/src/shared/synthetic-agent-title.ts index d12d39c09..13fd017cf 100644 --- a/src/shared/synthetic-agent-title.ts +++ b/src/shared/synthetic-agent-title.ts @@ -26,6 +26,16 @@ export const SYNTHETIC_AGENT_TITLE_PROFILES: Record { + return await page.evaluate( + async ({ target, remotePath }) => { + const store = window.__store + if (!store) { + throw new Error('Store unavailable') + } + const credentialUnsub = window.api.ssh.onCredentialRequest((request) => { + void window.api.ssh.submitCredential({ requestId: request.requestId, value: null }) + }) + try { + const createdTarget = await window.api.ssh.addTarget({ + target: { + label: `Docker SSH Pi-Compatible Agent ${Date.now()}`, + host: '127.0.0.1', + port: target.port, + username: 'root', + identityFile: target.identityFile, + identitiesOnly: true, + relayGracePeriodSeconds: 1 + } + }) + const state = await window.api.ssh.connect({ targetId: createdTarget.id }) + if (!state || state.status !== 'connected') { + throw new Error(`SSH target did not connect: ${JSON.stringify(state)}`) + } + store.getState().setSshConnectionState(createdTarget.id, state) + const labels = new Map(store.getState().sshTargetLabels) + labels.set(createdTarget.id, createdTarget.label) + store.getState().setSshTargetLabels(labels) + + const result = await window.api.repos.addRemote({ + connectionId: createdTarget.id, + remotePath, + displayName: 'Docker SSH Pi-Compatible Agent' + }) + if ('error' in result) { + throw new Error(result.error) + } + await store.getState().fetchRepos() + await store.getState().fetchWorktrees(result.repo.id) + const worktree = (store.getState().worktreesByRepo[result.repo.id] ?? [])[0] + if (!worktree) { + throw new Error(`No remote worktree found for ${result.repo.path}`) + } + store.getState().setActiveWorktree(worktree.id) + if ((store.getState().tabsByWorktree[worktree.id] ?? []).length === 0) { + store.getState().createTab(worktree.id) + } + store.getState().setActiveTabType('terminal') + return { + targetId: createdTarget.id, + repoId: result.repo.id, + worktreeId: worktree.id + } + } finally { + credentialUnsub() + } + }, + { target, remotePath: DOCKER_SSH_RELAY_REMOTE_REPO_PATH } + ) +} + +async function emitOscTitle(page: Page, ptyId: string, title: string): Promise { + await sendToTerminal(page, ptyId, `printf '\\033]0;${title}\\007'\r`) +} + +async function findTerminalByPtyId(page: Page, ptyId: string): Promise { + return page.evaluate(async (ptyId) => { + const response = await window.api.runtime.call({ + method: 'terminal.list', + params: { limit: 50 } + }) + if (!response.ok) { + throw new Error(response.error.message) + } + const terminals = (response.result as { terminals: RuntimeTerminalSummary[] }).terminals + const terminal = terminals.find((candidate) => candidate.ptyId === ptyId) + if (!terminal) { + throw new Error( + `No runtime terminal for PTY ${ptyId}; terminals=${JSON.stringify(terminals)}` + ) + } + return terminal.handle + }, ptyId) +} + +async function readTerminalAgentStatus( + page: Page, + terminalHandle: string +): Promise { + return page.evaluate(async (terminal) => { + const response = await window.api.runtime.call({ + method: 'terminal.agentStatus', + params: { terminal } + }) + if (!response.ok) { + throw new Error(response.error.message) + } + return (response.result as { agentStatus: RuntimeTerminalStatus }).agentStatus + }, terminalHandle) +} + +test.describe('Docker SSH Pi-compatible agent titles', () => { + test.skip(!RUN_DOCKER_SSH, 'Set ORCA_E2E_SSH_DOCKER=1 to run Docker-backed SSH relay tests.') + test.skip(process.platform === 'win32', 'Docker SSH relay tests use POSIX ssh tooling.') + + test('classifies OMP and Pi title transitions from a remote terminal', async ({ + orcaPage + }, testInfo) => { + test.slow() + let target: DockerSshRelayTarget | null = null + try { + target = startDockerSshRelayTarget(testInfo) + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + const remote = await connectDockerRemote(orcaPage, target) + await ensureTerminalVisible(orcaPage, 45_000) + await waitForActiveTerminalManager(orcaPage, 60_000) + const ptyId = await waitForActivePanePtyId(orcaPage, 60_000) + const terminalHandle = await findTerminalByPtyId(orcaPage, ptyId) + + const marker = `PI_COMPATIBLE_TITLE_READY_${Date.now()}` + await sendToTerminal(orcaPage, ptyId, `printf '${marker}\\n'\r`) + await waitForTerminalOutput(orcaPage, marker, 20_000, 60_000) + + await emitOscTitle(orcaPage, ptyId, '\u280b OMP') + await expect + .poll(async () => readTerminalAgentStatus(orcaPage, terminalHandle), { + timeout: 10_000, + message: 'Remote OMP working title did not classify as an agent status' + }) + .toMatchObject({ isRunningAgent: true, status: 'working' }) + + await emitOscTitle(orcaPage, ptyId, 'OMP ready') + await expect + .poll(async () => readTerminalAgentStatus(orcaPage, terminalHandle), { + timeout: 10_000, + message: 'Remote OMP ready title did not classify as idle' + }) + .toMatchObject({ isRunningAgent: true, status: 'idle' }) + + await emitOscTitle(orcaPage, ptyId, '\u280b Pi') + await expect + .poll(async () => readTerminalAgentStatus(orcaPage, terminalHandle), { + timeout: 10_000, + message: 'Remote Pi working title did not classify as an agent status' + }) + .toMatchObject({ isRunningAgent: true, status: 'working' }) + + testInfo.annotations.push({ + type: 'docker-ssh-pi-compatible-title', + description: `target=${remote.targetId} repo=${remote.repoId} worktree=${remote.worktreeId} pty=${ptyId} terminal=${terminalHandle}` + }) + } finally { + cleanupDockerSshRelayTarget(target) + } + }) +})