diff --git a/src/main/providers/ssh-filesystem-provider.test.ts b/src/main/providers/ssh-filesystem-provider.test.ts index 037c7047756..84435e8d5a9 100644 --- a/src/main/providers/ssh-filesystem-provider.test.ts +++ b/src/main/providers/ssh-filesystem-provider.test.ts @@ -93,6 +93,24 @@ describe('SshFilesystemProvider', () => { }) }) + describe('getTempDir', () => { + it('reads and caches the remote temp directory from the relay', async () => { + mux.request.mockResolvedValue('/var/folders/remote') + + await expect(provider.getTempDir()).resolves.toBe('/var/folders/remote') + await expect(provider.getTempDir()).resolves.toBe('/var/folders/remote') + + expect(mux.request).toHaveBeenCalledTimes(1) + expect(mux.request).toHaveBeenCalledWith('fs.tempDir', {}) + }) + + it('falls back to /tmp when connected to an older relay', async () => { + mux.request.mockRejectedValue(Object.assign(new Error('Method not found'), { code: -32601 })) + + await expect(provider.getTempDir()).resolves.toBe('/tmp') + }) + }) + describe('writeFileBase64', () => { it('writes decoded bytes through SFTP', async () => { const written: Buffer[] = [] diff --git a/src/main/providers/ssh-filesystem-provider.ts b/src/main/providers/ssh-filesystem-provider.ts index cc334a3cf83..99444ffd6b4 100644 --- a/src/main/providers/ssh-filesystem-provider.ts +++ b/src/main/providers/ssh-filesystem-provider.ts @@ -26,6 +26,7 @@ export class SshFilesystemProvider implements IFilesystemProvider { // multiplexer. Without this, notification callbacks keep firing after // the provider is torn down on disconnect, routing events to stale state. private unsubscribeNotifications: (() => void) | null = null + private tempDirPromise: Promise | null = null // Why: relays from a previous build may not implement fs.readFileStream. // We log the fallback once per session at warn level so users on stale // relays get diagnosed quickly without per-read log spam. @@ -92,6 +93,20 @@ export class SshFilesystemProvider implements IFilesystemProvider { } } + async getTempDir(): Promise { + this.tempDirPromise ??= this.mux.request('fs.tempDir', {}).then( + (result) => result as string, + (err) => { + this.tempDirPromise = null + if (isMethodNotFoundError(err)) { + return '/tmp' + } + throw err + } + ) + return this.tempDirPromise + } + async writeFile(filePath: string, content: string): Promise { await this.mux.request('fs.writeFile', { filePath, content }) } diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index 32c6fdfb6ba..f51114008f6 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -121,6 +121,7 @@ export type FileReadResult = { export type IFilesystemProvider = { readDir(dirPath: string): Promise readFile(filePath: string): Promise + getTempDir?(): Promise writeFile(filePath: string, content: string): Promise writeFileBase64(filePath: string, contentBase64: string): Promise writeFileBase64Chunk(filePath: string, contentBase64: string, append: boolean): Promise diff --git a/src/main/window/clipboard-ipc-handlers.test.ts b/src/main/window/clipboard-ipc-handlers.test.ts index d0f0be0a312..2996e3f0c57 100644 --- a/src/main/window/clipboard-ipc-handlers.test.ts +++ b/src/main/window/clipboard-ipc-handlers.test.ts @@ -1,21 +1,37 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { removeHandlerMock, handleMock, + fsWriteFileMock, clipboardReadTextMock, clipboardWriteTextMock, clipboardReadImageMock, clipboardWriteImageMock, - nativeImageCreateFromBufferMock + nativeImageCreateFromBufferMock, + randomUUIDMock, + getSshFilesystemProviderMock } = vi.hoisted(() => ({ removeHandlerMock: vi.fn(), handleMock: vi.fn(), + fsWriteFileMock: vi.fn(), clipboardReadTextMock: vi.fn(), clipboardWriteTextMock: vi.fn(), clipboardReadImageMock: vi.fn(), clipboardWriteImageMock: vi.fn(), - nativeImageCreateFromBufferMock: vi.fn() + nativeImageCreateFromBufferMock: vi.fn(), + randomUUIDMock: vi.fn(() => '00000000-0000-4000-8000-000000000000'), + getSshFilesystemProviderMock: vi.fn() +})) + +vi.mock('node:fs/promises', () => ({ + default: { + writeFile: fsWriteFileMock + } +})) + +vi.mock('node:crypto', () => ({ + randomUUID: randomUUIDMock })) vi.mock('electron', () => ({ @@ -37,6 +53,10 @@ vi.mock('electron', () => ({ } })) +vi.mock('../providers/ssh-filesystem-dispatch', () => ({ + getSshFilesystemProvider: getSshFilesystemProviderMock +})) + import { registerClipboardHandlers } from './clipboard-ipc-handlers' function getRegisteredHandlers(): Map unknown> { @@ -52,13 +72,22 @@ function getRegisteredHandlers(): Map unknown> { describe('registerClipboardHandlers', () => { beforeEach(() => { + vi.spyOn(Date, 'now').mockReturnValue(1760000000000) removeHandlerMock.mockReset() handleMock.mockReset() + fsWriteFileMock.mockReset() clipboardReadTextMock.mockReset() clipboardWriteTextMock.mockReset() clipboardReadImageMock.mockReset() clipboardWriteImageMock.mockReset() nativeImageCreateFromBufferMock.mockReset() + randomUUIDMock.mockReset() + randomUUIDMock.mockReturnValue('00000000-0000-4000-8000-000000000000') + getSshFilesystemProviderMock.mockReset() + }) + + afterEach(() => { + vi.restoreAllMocks() }) it('registers normal and selection text clipboard IPC handlers', () => { @@ -90,4 +119,75 @@ describe('registerClipboardHandlers', () => { expect(removeHandlerMock).toHaveBeenCalledWith('clipboard:writeImage') expect(removeHandlerMock).toHaveBeenCalledWith('clipboard:saveImageAsTempFile') }) + + it('saves clipboard images to a local temp file when no connection is provided', async () => { + const png = Buffer.from([0, 1, 2, 3]) + clipboardReadImageMock.mockReturnValue({ + isEmpty: () => false, + toPNG: () => png + }) + + registerClipboardHandlers() + + const handlers = getRegisteredHandlers() + await expect(handlers.get('clipboard:saveImageAsTempFile')?.({}, undefined)).resolves.toBe( + '/tmp/orca-paste-1760000000000-00000000-0000-4000-8000-000000000000.png' + ) + expect(fsWriteFileMock).toHaveBeenCalledWith( + '/tmp/orca-paste-1760000000000-00000000-0000-4000-8000-000000000000.png', + png + ) + expect(getSshFilesystemProviderMock).not.toHaveBeenCalled() + }) + + it('uploads clipboard images to the SSH host when a connection is provided', async () => { + const png = Buffer.from([0, 1, 2, 3]) + const writeFileBase64 = vi.fn().mockResolvedValue(undefined) + const getTempDir = vi.fn().mockResolvedValue('/var/tmp') + clipboardReadImageMock.mockReturnValue({ + isEmpty: () => false, + toPNG: () => png + }) + getSshFilesystemProviderMock.mockReturnValue({ getTempDir, writeFileBase64 }) + + registerClipboardHandlers() + + const handlers = getRegisteredHandlers() + await expect( + handlers.get('clipboard:saveImageAsTempFile')?.({}, { connectionId: 'ssh-1' }) + ).resolves.toBe('/var/tmp/orca-paste-1760000000000-00000000-0000-4000-8000-000000000000.png') + expect(getSshFilesystemProviderMock).toHaveBeenCalledWith('ssh-1') + expect(getTempDir).toHaveBeenCalled() + expect(writeFileBase64).toHaveBeenCalledWith( + '/var/tmp/orca-paste-1760000000000-00000000-0000-4000-8000-000000000000.png', + png.toString('base64') + ) + expect(fsWriteFileMock).not.toHaveBeenCalled() + }) + + it('uses Windows path joining for Windows SSH temp directories', async () => { + const png = Buffer.from([0, 1, 2, 3]) + const writeFileBase64 = vi.fn().mockResolvedValue(undefined) + clipboardReadImageMock.mockReturnValue({ + isEmpty: () => false, + toPNG: () => png + }) + getSshFilesystemProviderMock.mockReturnValue({ + getTempDir: vi.fn().mockResolvedValue('C:\\Users\\alice\\AppData\\Local\\Temp'), + writeFileBase64 + }) + + registerClipboardHandlers() + + const handlers = getRegisteredHandlers() + await expect( + handlers.get('clipboard:saveImageAsTempFile')?.({}, { connectionId: 'ssh-1' }) + ).resolves.toBe( + 'C:\\Users\\alice\\AppData\\Local\\Temp\\orca-paste-1760000000000-00000000-0000-4000-8000-000000000000.png' + ) + expect(writeFileBase64).toHaveBeenCalledWith( + 'C:\\Users\\alice\\AppData\\Local\\Temp\\orca-paste-1760000000000-00000000-0000-4000-8000-000000000000.png', + png.toString('base64') + ) + }) }) diff --git a/src/main/window/clipboard-ipc-handlers.ts b/src/main/window/clipboard-ipc-handlers.ts index a8cf69ddf76..b4586d9ccfe 100644 --- a/src/main/window/clipboard-ipc-handlers.ts +++ b/src/main/window/clipboard-ipc-handlers.ts @@ -1,7 +1,47 @@ import fs from 'node:fs/promises' import path from 'node:path' +import { randomUUID } from 'node:crypto' import { app, clipboard, ipcMain, nativeImage } from 'electron' +import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' +import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' + +type SaveClipboardImageAsTempFileArgs = { + connectionId?: string | null +} + +const REMOTE_CLIPBOARD_IMAGE_TEMP_DIR = '/tmp' + +function joinRemotePath(basePath: string, fileName: string): string { + if (isWindowsAbsolutePathLike(basePath)) { + return path.win32.join(basePath, fileName) + } + return path.posix.join(basePath, fileName) +} + +async function saveClipboardImageBufferAsTempFile( + buffer: Buffer, + args?: SaveClipboardImageAsTempFileArgs +): Promise { + const fileName = `orca-paste-${Date.now()}-${randomUUID()}.png` + + if (args?.connectionId) { + const provider = getSshFilesystemProvider(args.connectionId) + if (!provider) { + throw new Error(`No filesystem provider for connection "${args.connectionId}"`) + } + const remoteTempDir = (await provider.getTempDir?.()) ?? REMOTE_CLIPBOARD_IMAGE_TEMP_DIR + const remotePath = joinRemotePath(remoteTempDir, fileName) + // Why: SSH terminal agents run on the remote host, so the pasted path must + // name a remote file. The provider's base64 path writes binary bytes via SFTP. + await provider.writeFileBase64(remotePath, buffer.toString('base64')) + return remotePath + } + + const tempPath = path.join(app.getPath('temp'), fileName) + await fs.writeFile(tempPath, buffer) + return tempPath +} export function registerClipboardHandlers(): void { ipcMain.removeHandler('clipboard:readText') @@ -16,15 +56,16 @@ export function registerClipboardHandlers(): void { // Why: terminals need to detect clipboard images to support tools like Claude // Code that accept image input via paste. Writes the clipboard image to a // temp file and returns the path, or null if the clipboard has no image. - ipcMain.handle('clipboard:saveImageAsTempFile', async () => { - const image = clipboard.readImage() - if (image.isEmpty()) { - return null + ipcMain.handle( + 'clipboard:saveImageAsTempFile', + async (_event, args?: SaveClipboardImageAsTempFileArgs) => { + const image = clipboard.readImage() + if (image.isEmpty()) { + return null + } + return saveClipboardImageBufferAsTempFile(image.toPNG(), args) } - const tempPath = path.join(app.getPath('temp'), `orca-paste-${Date.now()}.png`) - await fs.writeFile(tempPath, image.toPNG()) - return tempPath - }) + ) ipcMain.handle('clipboard:writeText', (_event, text: string) => clipboard.writeText(text)) ipcMain.handle('clipboard:writeSelectionText', (_event, text: string) => clipboard.writeText(text, 'selection') diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 75d7ee77464..c18b145f11c 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1660,7 +1660,9 @@ export type PreloadApi = { onTerminalZoom: (callback: (direction: 'in' | 'out' | 'reset') => void) => () => void readClipboardText: () => Promise readSelectionClipboardText: () => Promise - saveClipboardImageAsTempFile: () => Promise + saveClipboardImageAsTempFile: (args?: { + connectionId?: string | null + }) => Promise writeClipboardText: (text: string) => Promise writeSelectionClipboardText: (text: string) => Promise writeClipboardImage: (dataUrl: string) => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index 59ef60807ea..e3e033fec2b 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2471,8 +2471,9 @@ const api = { readClipboardText: (): Promise => ipcRenderer.invoke('clipboard:readText'), readSelectionClipboardText: (): Promise => ipcRenderer.invoke('clipboard:readSelectionText'), - saveClipboardImageAsTempFile: (): Promise => - ipcRenderer.invoke('clipboard:saveImageAsTempFile'), + saveClipboardImageAsTempFile: (args?: { + connectionId?: string | null + }): Promise => ipcRenderer.invoke('clipboard:saveImageAsTempFile', args), writeClipboardText: (text: string): Promise => ipcRenderer.invoke('clipboard:writeText', text), writeSelectionClipboardText: (text: string): Promise => diff --git a/src/relay/fs-handler.test.ts b/src/relay/fs-handler.test.ts index dd21dc09922..d3380032697 100644 --- a/src/relay/fs-handler.test.ts +++ b/src/relay/fs-handler.test.ts @@ -124,6 +124,7 @@ describe('FsHandler', () => { const methods = Array.from(dispatcher._requestHandlers.keys()) expect(methods).toContain('fs.readDir') expect(methods).toContain('fs.readFile') + expect(methods).toContain('fs.tempDir') expect(methods).toContain('fs.writeFile') expect(methods).toContain('fs.stat') expect(methods).toContain('fs.deletePath') @@ -142,6 +143,10 @@ describe('FsHandler', () => { expect(notifMethods).toContain('fs.unwatch') }) + it('tempDir returns the relay host temp directory', async () => { + await expect(dispatcher.callRequest('fs.tempDir')).resolves.toBe(tmpdir()) + }) + it('readDir returns sorted entries with directories first', async () => { mkdirSync(path.join(tmpDir, 'subdir')) writeFileSync(path.join(tmpDir, 'file.txt'), 'hello') diff --git a/src/relay/fs-handler.ts b/src/relay/fs-handler.ts index 15312d84ade..0d3202d213f 100644 --- a/src/relay/fs-handler.ts +++ b/src/relay/fs-handler.ts @@ -2,6 +2,7 @@ path expansion, file IO, search, streaming reads, Space scans, and watch lifecycle state. */ import { readdir, writeFile, stat, lstat, mkdir, rename, cp, rm, realpath } from 'fs/promises' import { execFile } from 'child_process' +import { tmpdir } from 'os' import { join } from 'path' import type { RelayDispatcher, RequestContext } from './dispatcher' import type { RelayContext } from './context' @@ -64,6 +65,7 @@ export class FsHandler { this.dispatcher.onRequest('fs.readDir', (p) => this.readDir(p)) this.dispatcher.onRequest('fs.readFile', (p) => this.readFile(p)) this.dispatcher.onRequest('fs.readFileStream', (p, c) => this.readFileStream(p, c)) + this.dispatcher.onRequest('fs.tempDir', () => this.tempDir()) this.dispatcher.onRequest('fs.writeFile', (p) => this.writeFile(p)) this.dispatcher.onRequest('fs.stat', (p) => this.stat(p)) this.dispatcher.onRequest('fs.deletePath', (p) => this.deletePath(p)) @@ -110,6 +112,10 @@ export class FsHandler { return readRelayFileStreamMetadata(filePath, this.dispatcher, this.streamRegistry, ctx) } + private async tempDir(): Promise { + return tmpdir() + } + private cancelStream(params: Record): void { const streamId = params.streamId as number | undefined if (typeof streamId === 'number') { diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 9ebab5d91da..6b813acfc91 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -83,6 +83,11 @@ type TerminalPaneProps = { onCloseTab: () => void } +function formatClipboardImagePasteError(error: unknown): string { + const detail = error instanceof Error ? error.message : String(error) + return `Image paste failed: ${detail}` +} + export default function TerminalPane({ tabId, worktreeId, @@ -896,12 +901,18 @@ export default function TerminalPane({ // Why: clipboard has no text — check for an image. This is the // image-only clipboard case (e.g. screenshot) where Chromium's paste // event would never fire on a textarea. We save the image to a temp - // file and paste the path so the terminal process can access it. - return window.api.ui.saveClipboardImageAsTempFile().then((filePath) => { - if (filePath) { - pane.terminal.paste(filePath) - } - }) + // file owned by the terminal host and paste that path. + const connectionId = getConnectionId(worktreeId) ?? null + return window.api.ui + .saveClipboardImageAsTempFile({ connectionId }) + .then((filePath) => { + if (filePath) { + pane.terminal.paste(filePath) + } + }) + .catch((error: unknown) => { + setTerminalError(formatClipboardImagePasteError(error)) + }) }) .catch(() => { /* ignore clipboard failures */ @@ -965,7 +976,7 @@ export default function TerminalPane({ container.removeEventListener('keydown', onKeyPaste, { capture: true }) container.removeEventListener('paste', onPaste, { capture: true }) } - }, [isActive]) + }, [isActive, worktreeId]) // Why: a click inside the terminal container is a deliberate interaction // with the pane — dismiss the bell indicator for this tab and worktree @@ -1166,10 +1177,12 @@ export default function TerminalPane({ managerRef, paneTransportsRef, paneCwdRef, + worktreeId, fallbackCwd: cwd ?? '', toggleExpandPane, onRequestClosePane: handleRequestClosePane, onSetTitle: handleStartRename, + onPasteError: setTerminalError, rightClickToPaste }) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts index 46bfaa4b9f9..1c0200aec78 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from 'react' import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager' import type { PtyTransport } from './pty-transport' +import { getConnectionId } from '@/lib/connection-context' import { resolveSplitCwd, type PaneCwdMap } from './resolve-split-cwd' import type { TerminalQuickCommand } from '../../../../shared/types' import { sendTerminalQuickCommandToPane } from './terminal-quick-command-dispatch' @@ -12,10 +13,12 @@ type UseTerminalPaneContextMenuDeps = { managerRef: React.RefObject paneTransportsRef: React.RefObject> paneCwdRef: React.RefObject + worktreeId: string fallbackCwd: string toggleExpandPane: (paneId: number) => void onRequestClosePane: (paneId: number) => void onSetTitle: (paneId: number) => void + onPasteError: (message: string) => void rightClickToPaste: boolean } @@ -43,10 +46,12 @@ export function useTerminalPaneContextMenu({ managerRef, paneTransportsRef, paneCwdRef, + worktreeId, fallbackCwd, toggleExpandPane, onRequestClosePane, onSetTitle, + onPasteError, rightClickToPaste }: UseTerminalPaneContextMenuDeps): TerminalMenuState { const contextPaneIdRef = useRef(null) @@ -107,12 +112,17 @@ export function useTerminalPaneContextMenu({ pane.terminal.focus() return } - // Why: clipboard has no text — check for an image (e.g. screenshot). - // Saves the image to a temp file and pastes the path so CLI tools like - // Claude Code can access it, consistent with the keyboard paste path. - const filePath = await window.api.ui.saveClipboardImageAsTempFile() - if (filePath) { - pane.terminal.paste(filePath) + // Why: clipboard has no text — check for an image (e.g. screenshot) and + // save it on the same host as this terminal before pasting the file path. + try { + const connectionId = getConnectionId(worktreeId) ?? null + const filePath = await window.api.ui.saveClipboardImageAsTempFile({ connectionId }) + if (filePath) { + pane.terminal.paste(filePath) + } + } catch (error) { + const detail = error instanceof Error ? error.message : String(error) + onPasteError(`Image paste failed: ${detail}`) } // Why: Radix returns focus to the menu trigger (the pane container) on // close, but xterm.js only accepts input when its own helper textarea is diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 3878db2e82b..7079616e119 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -779,7 +779,8 @@ function createWebUiApi(): NonNullable['ui']> { readClipboardText: () => navigator.clipboard?.readText?.() ?? Promise.resolve(''), readSelectionClipboardText: () => Promise.reject(new Error('Selection clipboard is unavailable in the web client')), - saveClipboardImageAsTempFile: () => Promise.resolve(null), + saveClipboardImageAsTempFile: (_args?: { connectionId?: string | null }) => + Promise.resolve(null), writeClipboardText: (text) => navigator.clipboard?.writeText?.(text) ?? Promise.resolve(), writeSelectionClipboardText: () => Promise.reject(new Error('Selection clipboard is unavailable in the web client')),