diff --git a/src/renderer/src/components/editor/EditorContent.tsx b/src/renderer/src/components/editor/EditorContent.tsx index 270622fda..7079770ff 100644 --- a/src/renderer/src/components/editor/EditorContent.tsx +++ b/src/renderer/src/components/editor/EditorContent.tsx @@ -389,6 +389,7 @@ export function EditorContent({ content={editorContent} filePath={activeFile.filePath} worktreeId={activeFile.worktreeId} + externalSshTargetId={activeFile.externalSshTargetId} runtimeEnvironmentId={activeFile.runtimeEnvironmentId} scrollCacheKey={`${editorViewStateKey}:rich`} onContentChange={onContentChangeWithFm} diff --git a/src/renderer/src/components/editor/ExternalFileChangeBanner.tsx b/src/renderer/src/components/editor/ExternalFileChangeBanner.tsx index d23421605..dda2fd2d5 100644 --- a/src/renderer/src/components/editor/ExternalFileChangeBanner.tsx +++ b/src/renderer/src/components/editor/ExternalFileChangeBanner.tsx @@ -91,7 +91,8 @@ export function keepTabEditsOverExternalChange(file: OpenFile): void { filePath: file.filePath, relativePath: file.relativePath, worktreeId: file.worktreeId, - connectionId: getConnectionIdForFile(file.worktreeId, file.filePath) ?? undefined + connectionId: getConnectionIdForFile(file.worktreeId, file.filePath) ?? undefined, + expectedExternalSshTargetId: file.externalSshTargetId }) .then((result) => { if (result.isBinary) { diff --git a/src/renderer/src/components/editor/ExternalFileChangeCompareDialog.tsx b/src/renderer/src/components/editor/ExternalFileChangeCompareDialog.tsx index eb6611abe..cb8230c33 100644 --- a/src/renderer/src/components/editor/ExternalFileChangeCompareDialog.tsx +++ b/src/renderer/src/components/editor/ExternalFileChangeCompareDialog.tsx @@ -60,7 +60,8 @@ export function ExternalFileChangeCompareDialog({ filePath: file.filePath, relativePath: file.relativePath, worktreeId: file.worktreeId, - connectionId: getConnectionIdForFile(file.worktreeId, file.filePath) ?? undefined + connectionId: getConnectionIdForFile(file.worktreeId, file.filePath) ?? undefined, + expectedExternalSshTargetId: file.externalSshTargetId }) .then((result) => { if (cancelled) { @@ -82,7 +83,14 @@ export function ExternalFileChangeCompareDialog({ return () => { cancelled = true } - }, [open, file.filePath, file.relativePath, file.worktreeId, file.runtimeEnvironmentId]) + }, [ + open, + file.filePath, + file.relativePath, + file.worktreeId, + file.runtimeEnvironmentId, + file.externalSshTargetId + ]) const language = detectLanguage(file.relativePath) diff --git a/src/renderer/src/components/editor/MarkdownPreview.tsx b/src/renderer/src/components/editor/MarkdownPreview.tsx index 26f791332..fe73dc078 100644 --- a/src/renderer/src/components/editor/MarkdownPreview.tsx +++ b/src/renderer/src/components/editor/MarkdownPreview.tsx @@ -134,6 +134,7 @@ type MarkdownPreviewSourceOpenFile = { relativePath: string worktreeId: string runtimeEnvironmentId?: string | null + externalSshTargetId?: string mode: string markdownPreviewSourceFileId?: string } @@ -622,12 +623,14 @@ export default function MarkdownPreview({ settings: settingsForRuntimeOwner(settings, resolvedSourceRuntimeEnvironmentId), worktreeId: sourceRoutingWorktreeId, worktreePath: worktreeRoot, - connectionId: sourceConnectionId + connectionId: sourceConnectionId, + expectedExternalSshTargetId: sourceOpenFile?.externalSshTargetId } : undefined, [ settings, sourceConnectionId, + sourceOpenFile?.externalSshTargetId, resolvedSourceRuntimeEnvironmentId, sourceRoutingWorktreeId, worktreeRoot diff --git a/src/renderer/src/components/editor/RichMarkdownEditor.tsx b/src/renderer/src/components/editor/RichMarkdownEditor.tsx index 5793861ab..04dfe8076 100644 --- a/src/renderer/src/components/editor/RichMarkdownEditor.tsx +++ b/src/renderer/src/components/editor/RichMarkdownEditor.tsx @@ -36,6 +36,7 @@ type RichMarkdownEditorProps = { content: string filePath: string worktreeId: string + externalSshTargetId?: string runtimeEnvironmentId?: string | null scrollCacheKey: string onContentChange: (content: string) => void @@ -60,6 +61,7 @@ export default function RichMarkdownEditor({ content, filePath, worktreeId, + externalSshTargetId, runtimeEnvironmentId, scrollCacheKey, onContentChange, @@ -164,6 +166,7 @@ export default function RichMarkdownEditor({ const reconcileRoundTripRef = useRichMarkdownReconcileRoundTrip({ htmlSuperscriptLinkContext, filePath, + externalSshTargetId, runtimeEnvironmentId, worktreeId, worktreeRoot @@ -220,6 +223,7 @@ export default function RichMarkdownEditor({ filePath, worktreeId, worktreeRoot, + externalSshTargetId, runtimeEnvironmentId, isMac, richMarkdownSpellcheckEnabled, @@ -312,6 +316,7 @@ export default function RichMarkdownEditor({ editor, fileId, filePath, + externalSshTargetId, isApplyingProgrammaticUpdateRef, lastCommittedMarkdownRef, originalSourceRef, diff --git a/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.test.ts b/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.test.ts index b0a381668..8e25daaba 100644 --- a/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.test.ts +++ b/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.test.ts @@ -250,6 +250,27 @@ describe('attachRestoredTabConflictScan', () => { } }) + it('does not verify an external SSH file through a replacement target', async () => { + const store = createEditorStore() + openRestoredDirtyTab(store, '/tmp/external.ts', 'original baseline') + store.setState({ + openFiles: store + .getState() + .openFiles.map((file) => ({ ...file, externalSshTargetId: 'ssh-original' })) + } as never) + mocks.getConnectionIdForFile.mockReturnValue('ssh-replacement') + + const detach = attachRestoredTabConflictScan(store) + try { + await vi.advanceTimersByTimeAsync(10) + expect(mocks.readRuntimeFileContent).not.toHaveBeenCalled() + expect(mocks.pathExists).not.toHaveBeenCalled() + expect(store.getState().openFiles[0]?.pendingDiskBaselineVerification).toBe(true) + } finally { + detach() + } + }) + it('caps concurrent verification reads and drains the queue without dropping tabs', async () => { // Why: a restored session with many dirty tabs must not fire one disk // read per tab at once — on SSH/remote runtimes that competes with diff --git a/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.ts b/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.ts index ce0623e29..92fde37b5 100644 --- a/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.ts +++ b/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.ts @@ -28,6 +28,15 @@ export function attachRestoredTabConflictScan(store: AppStoreApi): () => void { let activeVerifyReads = 0 let disposed = false + const getFileConnectionId = (file: OpenFile): string | undefined => { + const connectionId = getConnectionIdForFile(file.worktreeId, file.filePath) ?? undefined + const externalSshTargetId = file.externalSshTargetId?.trim() + if (externalSshTargetId && connectionId !== externalSshTargetId) { + throw new Error('External SSH file owner changed') + } + return connectionId + } + // Only local/SSH paths can be probed: for runtime-owned files window.api.fs would stat the client path and misreport it as gone. const probeFileMissing = async (file: OpenFile): Promise => { const settings = settingsForRuntimeOwner(store.getState().settings, file.runtimeEnvironmentId) @@ -37,7 +46,7 @@ export function attachRestoredTabConflictScan(store: AppStoreApi): () => void { try { const exists = await globalThis.window?.api?.fs?.pathExists?.({ filePath: file.filePath, - connectionId: getConnectionIdForFile(file.worktreeId, file.filePath) ?? undefined + connectionId: getFileConnectionId(file) }) return exists === false } catch { @@ -56,7 +65,8 @@ export function attachRestoredTabConflictScan(store: AppStoreApi): () => void { filePath: file.filePath, relativePath: file.relativePath, worktreeId: file.worktreeId, - connectionId: getConnectionIdForFile(file.worktreeId, file.filePath) ?? undefined + connectionId: getFileConnectionId(file), + expectedExternalSshTargetId: file.externalSshTargetId }) if (disposed) { return diff --git a/src/renderer/src/components/editor/rich-markdown-editor-config.ts b/src/renderer/src/components/editor/rich-markdown-editor-config.ts index 09f1479e9..5c0f93b69 100644 --- a/src/renderer/src/components/editor/rich-markdown-editor-config.ts +++ b/src/renderer/src/components/editor/rich-markdown-editor-config.ts @@ -44,6 +44,7 @@ export type EditorConfigParams = { filePath: string worktreeId: string worktreeRoot: string | null + externalSshTargetId?: string runtimeEnvironmentId?: string | null isMac: boolean richMarkdownSpellcheckEnabled: boolean @@ -97,6 +98,7 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE filePath, worktreeId, worktreeRoot, + externalSshTargetId, runtimeEnvironmentId, isMac, richMarkdownSpellcheckEnabled, @@ -223,6 +225,7 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE nextEditor, createRichMarkdownImageResolverContext({ filePath, + externalSshTargetId, runtimeEnvironmentId, settings, worktreeId, diff --git a/src/renderer/src/components/editor/rich-markdown-image-context.ts b/src/renderer/src/components/editor/rich-markdown-image-context.ts index bc7e4b9f5..a86d2cdfc 100644 --- a/src/renderer/src/components/editor/rich-markdown-image-context.ts +++ b/src/renderer/src/components/editor/rich-markdown-image-context.ts @@ -25,12 +25,14 @@ type RichMarkdownImageStorage = { export function createRichMarkdownImageResolverContext({ filePath, + externalSshTargetId, runtimeEnvironmentId, settings, worktreeId, worktreeRoot }: { filePath: string + externalSshTargetId?: string runtimeEnvironmentId?: string | null settings: RichMarkdownImageResolverSettings worktreeId: string @@ -43,7 +45,8 @@ export function createRichMarkdownImageResolverContext({ settings: settingsForRuntimeOwner(settings, runtimeEnvironmentId), worktreeId, worktreePath: worktreeRoot, - connectionId: getConnectionId(worktreeId) + connectionId: getConnectionId(worktreeId), + expectedExternalSshTargetId: externalSshTargetId } : undefined } @@ -83,6 +86,7 @@ function getRichMarkdownImageContextSignature(context: RichMarkdownImageResolver context.filePath, context.runtimeContext?.settings?.activeRuntimeEnvironmentId?.trim() ?? 'client', context.runtimeContext?.connectionId ?? 'local', + context.runtimeContext?.expectedExternalSshTargetId ?? '', context.runtimeContext?.worktreeId ?? 'unknown-worktree', context.runtimeContext?.worktreePath ?? '' ].join('\0') diff --git a/src/renderer/src/components/editor/useEditorPanelContentState.test.tsx b/src/renderer/src/components/editor/useEditorPanelContentState.test.tsx index 4ac009d24..5777fc7fb 100644 --- a/src/renderer/src/components/editor/useEditorPanelContentState.test.tsx +++ b/src/renderer/src/components/editor/useEditorPanelContentState.test.tsx @@ -190,6 +190,76 @@ describe('useEditorPanelContentState', () => { ) }) + it('loads an external SSH-host image when the tab is pinned to that target', async () => { + const activeFile = createOpenFile({ + id: '/tmp/ssh-preview.png', + filePath: '/tmp/ssh-preview.png', + relativePath: '/tmp/ssh-preview.png', + worktreeId: 'repo-ssh::/home/user/project', + externalSshTargetId: 'ssh-1' + } as never) + mocks.getConnectionIdForFile.mockReturnValue('ssh-1') + mocks.readRuntimeFileContent.mockResolvedValue({ + content: 'base64-image', + isBinary: true, + isImage: true, + mimeType: 'image/png' + }) + + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + + await act(async () => { + root?.render() + }) + + await vi.waitFor(() => expect(latestFileContents[activeFile.id]?.isImage).toBe(true)) + expect(mocks.readRuntimeFileContent).toHaveBeenCalledWith( + expect.objectContaining({ + filePath: '/tmp/ssh-preview.png', + relativePath: '/tmp/ssh-preview.png', + worktreeId: 'repo-ssh::/home/user/project', + connectionId: 'ssh-1', + expectedExternalSshTargetId: 'ssh-1' + }) + ) + }) + + it('rejects an external SSH-host tab after its target owner changes', async () => { + const activeFile = createOpenFile({ + id: '/tmp/ssh-preview.png', + filePath: '/tmp/ssh-preview.png', + relativePath: '/tmp/ssh-preview.png', + worktreeId: 'repo-ssh::/home/user/project', + externalSshTargetId: 'ssh-original' + } as never) + mocks.getConnectionIdForFile.mockReturnValue('ssh-replacement') + mocks.readRuntimeFileContent.mockRejectedValue( + new Error('External SSH files are not available after the workspace host changes.') + ) + + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + + await act(async () => { + root?.render() + }) + + await vi.waitFor(() => + expect(latestFileContents[activeFile.id]?.loadError).toBe( + 'External SSH files are not available after the workspace host changes.' + ) + ) + expect(mocks.readRuntimeFileContent).toHaveBeenCalledWith( + expect.objectContaining({ + connectionId: 'ssh-replacement', + expectedExternalSshTargetId: 'ssh-original' + }) + ) + }) + it('loads folder workspace branch diffs through the path-specific SSH connection', async () => { const activeFile = createOpenFile({ id: 'branch-diff', diff --git a/src/renderer/src/components/editor/useEditorPanelContentState.ts b/src/renderer/src/components/editor/useEditorPanelContentState.ts index 95a85c31c..19887474c 100644 --- a/src/renderer/src/components/editor/useEditorPanelContentState.ts +++ b/src/renderer/src/components/editor/useEditorPanelContentState.ts @@ -149,15 +149,21 @@ export function useEditorPanelContentState({ throw new Error(WORKTREE_OWNER_NOT_READY_ERROR) } if (restoredOpenFile?.filePath === filePath && restoredOpenFile.relativePath === filePath) { - if (readSettings?.activeRuntimeEnvironmentId?.trim() || connectionId) { + const externalSshTargetId = restoredOpenFile.externalSshTargetId?.trim() + if ( + !externalSshTargetId && + (readSettings?.activeRuntimeEnvironmentId?.trim() || connectionId) + ) { // Why: restored external-file tabs contain client-local absolute // paths. Remote runtime and SSH workspaces cannot read those paths // without an explicit upload/import flow. throw new Error('External local files are not available for remote workspaces.') } - // Why: restored external-file tabs need their main-process path grant - // refreshed because that authorization is only held in memory. - await window.api.fs.authorizeExternalPath({ targetPath: filePath }) + if (!externalSshTargetId) { + // Why: restored external-file tabs need their main-process path grant + // refreshed because that authorization is only held in memory. + await window.api.fs.authorizeExternalPath({ targetPath: filePath }) + } } const readScope = getRuntimeFileReadScope(readSettings, connectionId) const key = inFlightReadKey(readScope, filePath) @@ -174,6 +180,7 @@ export function useEditorPanelContentState({ relativePath: restoredOpenFile?.relativePath ?? relativePath, worktreeId, connectionId, + expectedExternalSshTargetId: restoredOpenFile?.externalSshTargetId, includeLocalLogMetadata: restoredOpenFile?.readOnly === true && restoredOpenFile.liveTail === true }) as Promise diff --git a/src/renderer/src/components/editor/useLocalImageSrc.test.ts b/src/renderer/src/components/editor/useLocalImageSrc.test.ts index 98907f270..f99ca2a77 100644 --- a/src/renderer/src/components/editor/useLocalImageSrc.test.ts +++ b/src/renderer/src/components/editor/useLocalImageSrc.test.ts @@ -223,6 +223,23 @@ describe('loadLocalImageSrc', () => { expect(readFile).toHaveBeenCalledTimes(2) }) + it('does not load an external SSH image through a replacement target', async () => { + const readFile = vi.fn().mockResolvedValue(binaryPreview()) + setReadFile(readFile) + + await expect( + loadLocalImageSrc('diagram.png', '/tmp/readme.md', null, { + settings: { activeRuntimeEnvironmentId: null }, + worktreeId: 'wt-1', + worktreePath: '/repo', + connectionId: 'ssh-2', + expectedExternalSshTargetId: 'ssh-1' + }) + ).resolves.toBeNull() + + expect(readFile).not.toHaveBeenCalled() + }) + it('does not update mounted hook state after unmount', async () => { const read = deferred() const readFile = vi.fn().mockReturnValue(read.promise) diff --git a/src/renderer/src/components/editor/useLocalImageSrc.ts b/src/renderer/src/components/editor/useLocalImageSrc.ts index d99c55913..9a8327af0 100644 --- a/src/renderer/src/components/editor/useLocalImageSrc.ts +++ b/src/renderer/src/components/editor/useLocalImageSrc.ts @@ -22,6 +22,7 @@ export function getLocalImageCacheKey( return [ runtimeEnvironmentId, runtimeContext?.connectionId ?? connectionId ?? 'local', + runtimeContext?.expectedExternalSshTargetId ?? '', runtimeContext?.worktreeId ?? 'unknown-worktree', absolutePath ].join('\0') diff --git a/src/renderer/src/components/editor/useRichMarkdownProgrammaticSync.ts b/src/renderer/src/components/editor/useRichMarkdownProgrammaticSync.ts index 92d1c4308..585cec52c 100644 --- a/src/renderer/src/components/editor/useRichMarkdownProgrammaticSync.ts +++ b/src/renderer/src/components/editor/useRichMarkdownProgrammaticSync.ts @@ -20,6 +20,7 @@ type RichMarkdownProgrammaticSyncOptions = { editor: Editor | null fileId: string filePath: string + externalSshTargetId?: string isApplyingProgrammaticUpdateRef: MutableRefObject lastCommittedMarkdownRef: MutableRefObject originalSourceRef: MutableRefObject @@ -46,6 +47,7 @@ export function useRichMarkdownProgrammaticSync({ editor, fileId, filePath, + externalSshTargetId, isApplyingProgrammaticUpdateRef, lastCommittedMarkdownRef, originalSourceRef, @@ -68,6 +70,7 @@ export function useRichMarkdownProgrammaticSync({ editor, createRichMarkdownImageResolverContext({ filePath, + externalSshTargetId, runtimeEnvironmentId, settings, worktreeId, @@ -79,6 +82,7 @@ export function useRichMarkdownProgrammaticSync({ } }, [ editor, + externalSshTargetId, filePath, isApplyingProgrammaticUpdateRef, runtimeEnvironmentId, diff --git a/src/renderer/src/components/editor/useRichMarkdownReconcileRoundTrip.ts b/src/renderer/src/components/editor/useRichMarkdownReconcileRoundTrip.ts index 556759a72..26a9844d8 100644 --- a/src/renderer/src/components/editor/useRichMarkdownReconcileRoundTrip.ts +++ b/src/renderer/src/components/editor/useRichMarkdownReconcileRoundTrip.ts @@ -7,6 +7,7 @@ import type { RichMarkdownHtmlSuperscriptLinkContext } from './rich-markdown-htm type ReconcileRoundTripParams = { htmlSuperscriptLinkContext: RichMarkdownHtmlSuperscriptLinkContext filePath: string + externalSshTargetId?: string runtimeEnvironmentId?: string | null worktreeId: string worktreeRoot: string | null @@ -21,6 +22,7 @@ type ReconcileRoundTripParams = { export function useRichMarkdownReconcileRoundTrip({ htmlSuperscriptLinkContext, filePath, + externalSshTargetId, runtimeEnvironmentId, worktreeId, worktreeRoot @@ -32,6 +34,7 @@ export function useRichMarkdownReconcileRoundTrip({ htmlSuperscriptLinkContext, imageResolverContext: createRichMarkdownImageResolverContext({ filePath, + externalSshTargetId, runtimeEnvironmentId, settings, worktreeId, diff --git a/src/renderer/src/components/terminal-pane/terminal-file-open-routing.ts b/src/renderer/src/components/terminal-pane/terminal-file-open-routing.ts index a022bcf49..c62b99ca9 100644 --- a/src/renderer/src/components/terminal-pane/terminal-file-open-routing.ts +++ b/src/renderer/src/components/terminal-pane/terminal-file-open-routing.ts @@ -191,7 +191,14 @@ export function openDetectedFilePath( worktreeId: worktreeId || '', language, mode: 'edit', - runtimeEnvironmentId + runtimeEnvironmentId, + // Why: absolute SSH paths outside the worktree otherwise look identical + // to client-local external files when the editor reloads or restores. + ...(relativePath === filePath && + !fileContext.settings?.activeRuntimeEnvironmentId?.trim() && + fileContext.connectionId + ? { externalSshTargetId: fileContext.connectionId } + : {}) }, { forceContentReload: true } ) diff --git a/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts index 6a6a0fc15..09068a4a1 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts @@ -933,6 +933,54 @@ describe('handleOscLink', () => { ) }) + it('pins SSH links outside the worktree to their target host', async () => { + setPlatform('Macintosh') + vi.mocked(getConnectionId).mockReturnValue('ssh-1') + + openDetectedFilePath('/tmp/ssh-preview.png', null, null, { + worktreeId: 'wt-1', + worktreePath: '/home/me/repo' + }) + await flushAsyncWork() + + expect(authorizeExternalPathMock).not.toHaveBeenCalled() + expect(statMock).toHaveBeenCalledWith({ + filePath: '/tmp/ssh-preview.png', + connectionId: 'ssh-1' + }) + expect(openFileMock).toHaveBeenCalledWith( + expect.objectContaining({ + filePath: '/tmp/ssh-preview.png', + relativePath: '/tmp/ssh-preview.png', + externalSshTargetId: 'ssh-1' + }), + { forceContentReload: true } + ) + }) + + it('does not pin runtime-owned links to the worktree SSH target', async () => { + setPlatform('Windows') + vi.mocked(getConnectionId).mockReturnValue('ssh-1') + runtimeEnvironmentCallMock.mockResolvedValueOnce({ + id: 'rpc-1', + ok: true, + result: { size: 1, isDirectory: false, mtime: 1 }, + _meta: { runtimeId: 'remote-runtime' } + }) + + openDetectedFilePath('//wsl.localhost/ubuntu/home/Alice/repo/src/main.ts', null, null, { + worktreeId: 'wt-1', + worktreePath: '//wsl$/Ubuntu/home/Alice/repo', + runtimeEnvironmentId: 'env-1' + }) + await flushAsyncWork() + + expect(openFileMock).toHaveBeenCalledWith( + expect.not.objectContaining({ externalSshTargetId: expect.anything() }), + { forceContentReload: true } + ) + }) + it('does not open SSH html file links as client-local file browser tabs', async () => { setPlatform('Macintosh') vi.mocked(getConnectionId).mockReturnValue('ssh-1') diff --git a/src/renderer/src/hooks/useEditorExternalWatch.ts b/src/renderer/src/hooks/useEditorExternalWatch.ts index c078a86b1..6ac41b3b6 100644 --- a/src/renderer/src/hooks/useEditorExternalWatch.ts +++ b/src/renderer/src/hooks/useEditorExternalWatch.ts @@ -617,8 +617,14 @@ function readFileForEchoVerification(args: { relativePath: string worktreeId: string | null | undefined connectionId: string | undefined + expectedExternalSshTargetId?: string }): ReturnType { - const key = `${args.runtimeEnvironmentId ?? ''}::${args.connectionId ?? ''}::${args.filePath}` + const key = [ + args.runtimeEnvironmentId ?? '', + args.connectionId ?? '', + args.expectedExternalSshTargetId ?? '', + args.filePath + ].join('::') let pending = inFlightEchoVerificationReads.get(key) if (!pending) { pending = readRuntimeFileContent({ @@ -628,7 +634,8 @@ function readFileForEchoVerification(args: { filePath: args.filePath, relativePath: args.relativePath, worktreeId: args.worktreeId ?? undefined, - connectionId: args.connectionId + connectionId: args.connectionId, + expectedExternalSshTargetId: args.expectedExternalSshTargetId }) inFlightEchoVerificationReads.set(key, pending) const release = (): void => { @@ -791,7 +798,8 @@ function scheduleSelfMoveEchoVerification( filePath: file.filePath, relativePath: file.relativePath, worktreeId: file.worktreeId, - connectionId: target.connectionId + connectionId: target.connectionId, + expectedExternalSshTargetId: file.externalSshTargetId }) .then((result) => { const diskSignature = result.isBinary ? null : getDiskBaselineSignature(result.content) @@ -826,7 +834,8 @@ function scheduleSelfWriteAwareExternalReload( filePath: file.filePath, relativePath: file.relativePath, worktreeId: file.worktreeId, - connectionId: target.connectionId + connectionId: target.connectionId, + expectedExternalSshTargetId: file.externalSshTargetId }) .then((result) => { if ( diff --git a/src/renderer/src/lib/editor-file-operation-owner.test.ts b/src/renderer/src/lib/editor-file-operation-owner.test.ts index 044a21f18..fbe2b4fb2 100644 --- a/src/renderer/src/lib/editor-file-operation-owner.test.ts +++ b/src/renderer/src/lib/editor-file-operation-owner.test.ts @@ -168,6 +168,35 @@ describe('editor file operation owner', () => { ).toThrow('Reopen the file') }) + it('rejects a restored external SSH file after its target changes', () => { + useAppStore.setState({ + repos: [{ id: 'repo', connectionId: 'ssh-replacement' } as never], + worktreesByRepo: { + repo: [{ id: worktreeId, repoId: 'repo', path: '/remote/repo' } as never] + }, + sshConnectionStates: new Map([ + [ + 'ssh-replacement', + { + targetId: 'ssh-replacement', + status: 'connected', + error: null, + reconnectAttempt: 0, + connectionGeneration: 1 + } + ] + ]) + }) + + expect(() => + getEditorFileOperationContext( + useAppStore.getState(), + { worktreeId, externalSshTargetId: 'ssh-original' }, + '/remote/repo' + ) + ).toThrow('Reopen the file') + }) + describe('folder workspaces', () => { const folderWorkspaceId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' const folderKey = `folder:${folderWorkspaceId}` diff --git a/src/renderer/src/lib/editor-file-operation-owner.ts b/src/renderer/src/lib/editor-file-operation-owner.ts index 3cc920f5f..770759150 100644 --- a/src/renderer/src/lib/editor-file-operation-owner.ts +++ b/src/renderer/src/lib/editor-file-operation-owner.ts @@ -133,6 +133,7 @@ export function getEditorFileOperationContext( file: { worktreeId: string runtimeEnvironmentId?: string | null + externalSshTargetId?: string operationProvenance?: EditorFileOperationProvenance }, worktreePath: string | null @@ -168,6 +169,15 @@ export function getEditorFileOperationContext( if (!host) { throw new Error(OWNER_CHANGED_MESSAGE) } + const externalSshTargetId = file.externalSshTargetId?.trim() + if ( + externalSshTargetId && + (host.kind !== 'ssh' || + route.runtimeEnvironmentId !== null || + host.targetId !== externalSshTargetId) + ) { + throw new Error(OWNER_CHANGED_MESSAGE) + } if (host?.kind === 'ssh' && provenance.expectedSshConnectionGeneration === undefined) { // Why: an old/partial SSH publication may be readable but cannot safely authorize mutations. throw new Error(OWNER_CHANGED_MESSAGE) diff --git a/src/renderer/src/lib/workspace-session-editor-drafts.test.ts b/src/renderer/src/lib/workspace-session-editor-drafts.test.ts index ad12ccec3..c935119a3 100644 --- a/src/renderer/src/lib/workspace-session-editor-drafts.test.ts +++ b/src/renderer/src/lib/workspace-session-editor-drafts.test.ts @@ -79,6 +79,29 @@ describe('workspace session editor drafts', () => { ]) }) + it('persists the SSH target that owns an external host file', () => { + const payload = buildWorkspaceSessionPayload( + createSnapshot({ + openFiles: [ + { + id: '/tmp/ssh-preview.png', + filePath: '/tmp/ssh-preview.png', + relativePath: '/tmp/ssh-preview.png', + worktreeId: 'wt-1', + language: 'png', + mode: 'edit', + isDirty: false, + externalSshTargetId: 'ssh-1' + } as never + ] + }) + ) + + expect(payload.openFilesByWorktree?.['wt-1']?.[0]).toEqual( + expect.objectContaining({ externalSshTargetId: 'ssh-1' }) + ) + }) + it('persists the disk baseline signature only alongside a dirty draft', () => { const payload = buildWorkspaceSessionPayload( createSnapshot({ diff --git a/src/renderer/src/lib/workspace-session.ts b/src/renderer/src/lib/workspace-session.ts index 461a161af..1324a8891 100644 --- a/src/renderer/src/lib/workspace-session.ts +++ b/src/renderer/src/lib/workspace-session.ts @@ -124,6 +124,7 @@ export function buildEditorSessionData( language: f.language, isPreview: f.isPreview || undefined, runtimeEnvironmentId: f.runtimeEnvironmentId, + externalSshTargetId: f.externalSshTargetId, // Why: persist readOnly only when true; absence is the writable default on restore. ...(f.readOnly === true ? { readOnly: true } : {}), ...(f.readOnly === true && f.liveTail === true ? { liveTail: true } : {}), diff --git a/src/renderer/src/runtime/mobile-markdown-bridge.ts b/src/renderer/src/runtime/mobile-markdown-bridge.ts index 6f9303224..b605c0148 100644 --- a/src/renderer/src/runtime/mobile-markdown-bridge.ts +++ b/src/renderer/src/runtime/mobile-markdown-bridge.ts @@ -246,7 +246,8 @@ async function readFileContent(file: OpenFile): Promise { filePath: file.filePath, relativePath: file.relativePath, worktreeId: file.worktreeId, - connectionId + connectionId, + expectedExternalSshTargetId: file.externalSshTargetId })) as FileContent if (result.isBinary) { throw new Error('binary_file') diff --git a/src/renderer/src/runtime/runtime-file-client.test.ts b/src/renderer/src/runtime/runtime-file-client.test.ts index bf8f92038..ee9f3eeac 100644 --- a/src/renderer/src/runtime/runtime-file-client.test.ts +++ b/src/renderer/src/runtime/runtime-file-client.test.ts @@ -159,6 +159,59 @@ describe('runtime file client', () => { expect(runtimeEnvironmentCall).not.toHaveBeenCalled() }) + it('reads an external SSH file only from its owning target', async () => { + const sshResult: RuntimeReadableFileContent = { content: 'remote', isBinary: false } + fsReadFile.mockResolvedValue(sshResult) + + await expect( + readRuntimeFileContent({ + settings: { activeRuntimeEnvironmentId: null }, + filePath: '/tmp/external.md', + relativePath: '/tmp/external.md', + worktreeId: 'wt-1', + connectionId: 'ssh-1', + expectedExternalSshTargetId: 'ssh-1' + }) + ).resolves.toBe(sshResult) + + expect(fsReadFile).toHaveBeenCalledWith({ + filePath: '/tmp/external.md', + connectionId: 'ssh-1', + includeLocalLogMetadata: undefined + }) + }) + + it('rejects an external SSH file read after the target changes', async () => { + await expect( + readRuntimeFileContent({ + settings: { activeRuntimeEnvironmentId: null }, + filePath: '/tmp/external.md', + relativePath: '/tmp/external.md', + worktreeId: 'wt-1', + connectionId: 'ssh-2', + expectedExternalSshTargetId: 'ssh-1' + }) + ).rejects.toThrow('External SSH files are not available after the workspace host changes.') + + expect(fsReadFile).not.toHaveBeenCalled() + }) + + it('rejects an external SSH file read through a runtime environment', async () => { + await expect( + readRuntimeFileContent({ + settings: { activeRuntimeEnvironmentId: 'env-1' }, + filePath: '/tmp/external.md', + relativePath: '/tmp/external.md', + worktreeId: 'wt-1', + connectionId: 'ssh-1', + expectedExternalSshTargetId: 'ssh-1' + }) + ).rejects.toThrow('External SSH files are not available after the workspace host changes.') + + expect(fsReadFile).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + it('binds direct SSH mutations to the captured target and generation', async () => { const context = { settings: { activeRuntimeEnvironmentId: null }, @@ -547,6 +600,23 @@ describe('runtime file client', () => { }) }) + it('rejects an external SSH image preview after the target changes', async () => { + await expect( + readRuntimeFilePreview( + { + settings: { activeRuntimeEnvironmentId: null }, + worktreeId: 'wt-1', + worktreePath: '/remote/repo', + connectionId: 'ssh-2', + expectedExternalSshTargetId: 'ssh-1' + }, + '/tmp/logo.png' + ) + ).rejects.toThrow('External SSH files are not available after the workspace host changes.') + + expect(fsReadFile).not.toHaveBeenCalled() + }) + it('does not fall back to client-local preview reads for remote-owned files outside the worktree', async () => { await expect( readRuntimeFilePreview( diff --git a/src/renderer/src/runtime/runtime-file-client.ts b/src/renderer/src/runtime/runtime-file-client.ts index e1119da74..788c17513 100644 --- a/src/renderer/src/runtime/runtime-file-client.ts +++ b/src/renderer/src/runtime/runtime-file-client.ts @@ -52,6 +52,7 @@ export type RuntimeFileReadArgs = { relativePath?: string worktreeId?: string connectionId?: string + expectedExternalSshTargetId?: string includeLocalLogMetadata?: boolean } @@ -63,6 +64,21 @@ export type RuntimeFileOperationArgs = { expectedExecutionHostId?: 'local' | `ssh:${string}` expectedSshTargetId?: string expectedSshConnectionGeneration?: number + expectedExternalSshTargetId?: string +} + +function assertExternalSshReadOwnership( + settings: Pick | null | undefined, + connectionId: string | undefined, + expectedExternalSshTargetId: string | undefined +): void { + const expectedTargetId = expectedExternalSshTargetId?.trim() + if ( + expectedTargetId && + (getActiveRuntimeTarget(settings).kind === 'environment' || connectionId !== expectedTargetId) + ) { + throw new Error('External SSH files are not available after the workspace host changes.') + } } function withSshMutationExpectation( @@ -227,8 +243,10 @@ export async function readRuntimeFileContent({ relativePath, worktreeId, connectionId, + expectedExternalSshTargetId, includeLocalLogMetadata }: RuntimeFileReadArgs): Promise { + assertExternalSshReadOwnership(settings, connectionId, expectedExternalSshTargetId) const target = getActiveRuntimeTarget(settings) if (target.kind !== 'environment') { return window.api.fs.readFile({ filePath, connectionId, includeLocalLogMetadata }) @@ -275,6 +293,11 @@ export async function readRuntimeFilePreview( context: RuntimeFileOperationArgs, filePath: string ): Promise { + assertExternalSshReadOwnership( + context.settings, + context.connectionId, + context.expectedExternalSshTargetId + ) const remoteArgs = getRemoteFileArgs(context, filePath) if (!remoteArgs) { if (hasRemoteRuntimeOwner(context)) { @@ -295,6 +318,11 @@ export async function downloadRuntimeFile( filePath: string, suggestedName: string ): Promise { + assertExternalSshReadOwnership( + context.settings, + context.connectionId, + context.expectedExternalSshTargetId + ) const remoteArgs = getRemoteFileArgs(context, filePath) if (!remoteArgs) { if (hasRemoteRuntimeOwner(context)) { diff --git a/src/renderer/src/store/slices/editor.test.ts b/src/renderer/src/store/slices/editor.test.ts index e75aa131a..e0860b457 100644 --- a/src/renderer/src/store/slices/editor.test.ts +++ b/src/renderer/src/store/slices/editor.test.ts @@ -653,6 +653,62 @@ describe('createEditorSlice openDiff', () => { expect(store.getState().openFiles[0]?.fileContentReloadNonce).toBe(2) }) + it('rebinds an existing external tab when it is reopened from a new SSH host', () => { + const store = createEditorStore() + const file = { + filePath: '/tmp/ssh-preview.png', + relativePath: '/tmp/ssh-preview.png', + worktreeId: 'wt-1', + language: 'png', + mode: 'edit' as const + } + + store.setState({ + repos: [{ id: 'repo-1', path: '/repo', connectionId: 'ssh-1' }], + sshConnectionStates: new Map([ + [ + 'ssh-1', + { + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0, + connectionGeneration: 1 + } + ] + ]) + } as never) + store.getState().openFile({ ...file, externalSshTargetId: 'ssh-1' }) + + store.setState({ + repos: [{ id: 'repo-1', path: '/repo', connectionId: 'ssh-2' }], + sshConnectionStates: new Map([ + [ + 'ssh-2', + { + targetId: 'ssh-2', + status: 'connected', + error: null, + reconnectAttempt: 0, + connectionGeneration: 2 + } + ] + ]) + } as never) + store.getState().openFile({ ...file, externalSshTargetId: 'ssh-2' }) + + expect(store.getState().openFiles).toHaveLength(1) + expect(store.getState().openFiles[0]?.externalSshTargetId).toBe('ssh-2') + expect(store.getState().openFiles[0]?.operationProvenance).toEqual( + expect.objectContaining({ + generation: expect.objectContaining({ + route: { executionHostId: 'ssh:ssh-2', runtimeEnvironmentId: null } + }), + expectedSshConnectionGeneration: 2 + }) + ) + }) + it('does not bump fileContentReloadNonce when a dirty file is re-opened', () => { const store = createEditorStore() @@ -1901,6 +1957,36 @@ describe('createEditorSlice markdown table of contents visibility', () => { }) describe('createEditorSlice openMarkdownPreview', () => { + it('keeps external SSH ownership after the source edit tab closes', () => { + const store = createEditorStore() + store.getState().openFile({ + filePath: '/tmp/notes.md', + relativePath: '/tmp/notes.md', + worktreeId: 'wt-1', + language: 'markdown', + mode: 'edit', + externalSshTargetId: 'ssh-1' + }) + + store.getState().openMarkdownPreview( + { + filePath: '/tmp/notes.md', + relativePath: '/tmp/notes.md', + worktreeId: 'wt-1', + language: 'markdown' + }, + { sourceFileId: '/tmp/notes.md' } + ) + store.getState().closeFile('/tmp/notes.md') + + expect(store.getState().openFiles).toEqual([ + expect.objectContaining({ + id: 'markdown-preview::/tmp/notes.md', + externalSshTargetId: 'ssh-1' + }) + ]) + }) + it('opens markdown preview as a separate read-only tab', () => { const store = createEditorStore() @@ -4941,4 +5027,30 @@ describe('read-only editor tabs (AI Vault View Log)', () => { expect(restored?.pendingDiskBaselineVerification).toBeUndefined() expect(store.getState().editorDrafts[LOG_PATH]).toBeUndefined() }) + + it('restores the SSH target that owns an external host file', () => { + const store = createEditorStore() + store.setState({ + worktreesByRepo: { 'repo-1': [{ id: 'wt-1' }] }, + folderWorkspaces: [] + } as never) + + store.getState().hydrateEditorSession({ + openFilesByWorktree: { + 'wt-1': [ + { + filePath: '/tmp/ssh-preview.png', + relativePath: '/tmp/ssh-preview.png', + worktreeId: 'wt-1', + language: 'png', + externalSshTargetId: 'ssh-1' + } + ] + } + } as never) + + expect(store.getState().openFiles[0]).toEqual( + expect.objectContaining({ externalSshTargetId: 'ssh-1' }) + ) + }) }) diff --git a/src/renderer/src/store/slices/editor.ts b/src/renderer/src/store/slices/editor.ts index 9b27ed30d..dcabf350b 100644 --- a/src/renderer/src/store/slices/editor.ts +++ b/src/renderer/src/store/slices/editor.ts @@ -215,6 +215,8 @@ export type OpenFile = { isDirty: boolean // Why: remote untitled cleanup must target the creating environment even if the user later switches runtime. runtimeEnvironmentId?: string | null + /** SSH target that owns an absolute path outside the worktree. */ + externalSshTargetId?: string /** Host provenance captured when the tab opened; mutations reject replacement owners. */ operationProvenance?: EditorFileOperationProvenance /** Why: preview tabs mirror a source file's live draft; storing its ID lets the preview follow unsaved edits without becoming editable. */ @@ -467,7 +469,12 @@ export type EditorSlice = { openMarkdownPreview: ( file: Pick< OpenFile, - 'filePath' | 'relativePath' | 'worktreeId' | 'language' | 'runtimeEnvironmentId' + | 'filePath' + | 'relativePath' + | 'worktreeId' + | 'language' + | 'runtimeEnvironmentId' + | 'externalSshTargetId' >, options?: { anchor?: string | null; targetGroupId?: string; sourceFileId?: string } ) => void @@ -1668,6 +1675,8 @@ export const createEditorSlice: StateCreator = (s if (existing) { // If opening as non-preview, also pin the existing tab const updatedPreview = isPreview ? existing.isPreview : false + const nextExternalSshTargetId = file.externalSshTargetId ?? existing.externalSshTargetId + const refreshExternalSshProvenance = file.externalSshTargetId !== undefined const fileContentReloadNonce = shouldRequestExistingFileContentReload( existing, file.mode, @@ -1689,6 +1698,8 @@ export const createEditorSlice: StateCreator = (s existing.relativePath !== file.relativePath || existing.worktreeId !== file.worktreeId || existing.runtimeEnvironmentId !== runtimeEnvironmentId || + existing.externalSshTargetId !== nextExternalSshTargetId || + refreshExternalSshProvenance || existing.fileContentReloadNonce !== fileContentReloadNonce if (!needsExistingUpdate) { return { ...activeResult, ...focusRequestUpdate } @@ -1703,6 +1714,10 @@ export const createEditorSlice: StateCreator = (s worktreeId: file.worktreeId, language: file.language, runtimeEnvironmentId, + externalSshTargetId: nextExternalSshTargetId, + operationProvenance: refreshExternalSshProvenance + ? operationProvenance + : f.operationProvenance, mode: file.mode, diffSource: file.diffSource, branchCompare: file.branchCompare, @@ -1915,6 +1930,9 @@ export const createEditorSlice: StateCreator = (s ['edit'] ) const id = `markdown-preview::${sourceFileId}` + const externalSshTargetId = + file.externalSshTargetId ?? + initialState.openFiles.find((openFile) => openFile.id === sourceFileId)?.externalSshTargetId const anchor = options?.anchor || undefined set((s) => { const existing = s.openFiles.find((openFile) => openFile.id === id) @@ -1927,6 +1945,7 @@ export const createEditorSlice: StateCreator = (s existing.relativePath !== file.relativePath || existing.filePath !== file.filePath || existing.language !== file.language || + existing.externalSshTargetId !== externalSshTargetId || existing.markdownPreviewSourceFileId !== sourceFileId || existing.markdownPreviewAnchor !== anchor || existing.mode !== 'markdown-preview' @@ -1941,6 +1960,7 @@ export const createEditorSlice: StateCreator = (s worktreeId: file.worktreeId, language: file.language, runtimeEnvironmentId, + externalSshTargetId, markdownPreviewSourceFileId: sourceFileId, markdownPreviewAnchor: anchor, mode: 'markdown-preview' as const @@ -1960,6 +1980,7 @@ export const createEditorSlice: StateCreator = (s language: file.language, isDirty: false, runtimeEnvironmentId, + externalSshTargetId, markdownPreviewSourceFileId: sourceFileId, markdownPreviewAnchor: anchor, mode: 'markdown-preview' @@ -4504,6 +4525,7 @@ export const createEditorSlice: StateCreator = (s isDirty: !isReadOnly && pf.dirtyDraftContent !== undefined, isPreview: pf.isPreview, runtimeEnvironmentId: pf.runtimeEnvironmentId, + externalSshTargetId: pf.externalSshTargetId, ...(isReadOnly ? { readOnly: true } : {}), ...(isReadOnly && pf.liveTail === true ? { liveTail: true } : {}), lastKnownDiskSignature: isReadOnly ? undefined : pf.lastKnownDiskSignature, diff --git a/src/shared/types.ts b/src/shared/types.ts index dcd040821..bf1b68c93 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1066,6 +1066,8 @@ export type PersistedOpenFile = { language: string isPreview?: boolean runtimeEnvironmentId?: string | null + /** SSH target that owns an absolute path outside the worktree. */ + externalSshTargetId?: string /** Unsaved editor buffer captured for hot exit; presence restores the tab dirty. */ dirtyDraftContent?: string /** Signature of the disk content the dirty draft is based on; lets restore diff --git a/src/shared/workspace-session-schema.test.ts b/src/shared/workspace-session-schema.test.ts index 97e3e68bd..33799b80c 100644 --- a/src/shared/workspace-session-schema.test.ts +++ b/src/shared/workspace-session-schema.test.ts @@ -14,6 +14,55 @@ describe('parseWorkspaceSession', () => { expect(result.ok).toBe(true) }) + it('preserves external SSH file ownership across session parsing', () => { + const result = parseWorkspaceSession({ + activeRepoId: null, + activeWorktreeId: 'wt', + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + openFilesByWorktree: { + wt: [ + { + filePath: '/tmp/external.png', + relativePath: '/tmp/external.png', + worktreeId: 'wt', + language: 'png', + externalSshTargetId: 'ssh-1' + } + ] + } + }) + + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.value.openFilesByWorktree?.wt?.[0]?.externalSshTargetId).toBe('ssh-1') + } + }) + + it('rejects blank external SSH file ownership', () => { + const result = parseWorkspaceSession({ + activeRepoId: null, + activeWorktreeId: 'wt', + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + openFilesByWorktree: { + wt: [ + { + filePath: '/tmp/external.png', + relativePath: '/tmp/external.png', + worktreeId: 'wt', + language: 'png', + externalSshTargetId: ' ' + } + ] + } + }) + + expect(result.ok).toBe(false) + }) + it('accepts a fully populated session with optional fields', () => { const result = parseWorkspaceSession({ activeRepoId: 'repo1', diff --git a/src/shared/workspace-session-schema.ts b/src/shared/workspace-session-schema.ts index 2bb832fd9..72ebd026e 100644 --- a/src/shared/workspace-session-schema.ts +++ b/src/shared/workspace-session-schema.ts @@ -161,6 +161,7 @@ const persistedOpenFileSchema = z.object({ language: z.string(), isPreview: z.boolean().optional(), runtimeEnvironmentId: z.string().nullable().optional(), + externalSshTargetId: z.string().trim().min(1).optional(), dirtyDraftContent: z.string().optional(), lastKnownDiskSignature: z.string().optional(), readOnly: z.boolean().optional(), diff --git a/tests/e2e/ssh-external-image-preview.spec.ts b/tests/e2e/ssh-external-image-preview.spec.ts new file mode 100644 index 000000000..6605403dd --- /dev/null +++ b/tests/e2e/ssh-external-image-preview.spec.ts @@ -0,0 +1,197 @@ +import { createHash } from 'node:crypto' +import type { Page } from '@stablyai/playwright-test' +import { connectDockerSshRelayTarget } from './helpers/docker-ssh-relay-connection' +import { + cleanupDockerSshRelayTarget, + DOCKER_SSH_RELAY_REMOTE_REPO_PATH, + execDockerSshRelayTargetCommand, + shellQuote, + startDockerSshRelayTarget, + type DockerSshRelayTarget +} from './helpers/docker-ssh-relay-target' +import { test, expect } from './helpers/orca-app' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { + getTerminalContent, + sendToTerminal, + waitForActivePanePtyId, + waitForActiveTerminalManager +} from './helpers/terminal' + +const RUN_DOCKER_SSH = process.env.ORCA_E2E_SSH_DOCKER === '1' +const REMOTE_IMAGE_PATH = '/tmp/orca-ssh-external-preview.png' +const IMAGE_BASE64 = + 'iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAAFklEQVR4AWN8z8DwnwEJMDGgAcICAO2mBAXmO4drAAAAAElFTkSuQmCC' + +type LinkProbe = { col: number; row: number; tabId: string } + +async function findTerminalLink(page: Page, text: string): Promise { + return page.evaluate((text) => { + const state = window.__store?.getState() + const tabId = state?.activeTabId ?? null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + if (!tabId || !pane) { + throw new Error('Active terminal pane is unavailable') + } + const terminal = pane.terminal + for (let row = 0; row < terminal.rows; row += 1) { + const line = terminal.buffer.active.getLine(terminal.buffer.active.viewportY + row) + const col = line?.translateToString(true).indexOf(text) ?? -1 + if (col >= 0) { + return { col: col + Math.floor(text.length / 2), row, tabId } + } + } + throw new Error('External image path is not visible in the terminal') + }, text) +} + +async function activateTerminalLink(page: Page, probe: LinkProbe, text: string): Promise { + await expect + .poll( + async () => { + await page.evaluate(({ col, row, tabId }) => { + const manager = window.__paneManagers?.get(tabId) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + const screen = pane?.terminal.element?.querySelector('.xterm-screen') + if (!pane || !screen) { + throw new Error('Active terminal screen is unavailable') + } + const rect = screen.getBoundingClientRect() + screen.dispatchEvent( + new MouseEvent('mousemove', { + bubbles: true, + cancelable: true, + clientX: rect.left + (col + 0.5) * (rect.width / pane.terminal.cols), + clientY: rect.top + (row + 0.5) * (rect.height / pane.terminal.rows) + }) + ) + }, probe) + return page.evaluate((tabId) => { + const manager = window.__paneManagers?.get(tabId) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + const core = pane?.terminal as unknown as + | { _core?: { linkifier?: { currentLink?: { link?: { text?: string } } } } } + | undefined + return core?._core?.linkifier?.currentLink?.link?.text ?? null + }, probe.tabId) + }, + { timeout: 10_000, message: 'External SSH image path did not become clickable' } + ) + .toContain(text) + + await page.evaluate(({ col, row, tabId }) => { + const manager = window.__paneManagers?.get(tabId) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + const screen = pane?.terminal.element?.querySelector('.xterm-screen') + if (!pane || !screen) { + throw new Error('Active terminal screen is unavailable') + } + const rect = screen.getBoundingClientRect() + const mouse = { + bubbles: true, + cancelable: true, + button: 0, + clientX: rect.left + (col + 0.5) * (rect.width / pane.terminal.cols), + clientY: rect.top + (row + 0.5) * (rect.height / pane.terminal.rows), + metaKey: navigator.userAgent.includes('Mac'), + ctrlKey: !navigator.userAgent.includes('Mac') + } + screen.dispatchEvent(new MouseEvent('mousedown', { ...mouse, buttons: 1 })) + screen.dispatchEvent(new MouseEvent('mouseup', mouse)) + }, probe) +} + +test.describe('SSH external image preview', () => { + test.skip(!RUN_DOCKER_SSH, 'Set ORCA_E2E_SSH_DOCKER=1 to run Docker-backed SSH tests.') + test.skip(process.platform === 'win32', 'The disposable SSH host uses POSIX tooling.') + + test('opens an image outside the worktree from a terminal link', async ({ + orcaPage, + registerPostElectronShutdownCleanup + }, testInfo) => { + test.slow() + let target: DockerSshRelayTarget | null = null + let cleanupDeferred = false + try { + target = startDockerSshRelayTarget(testInfo) + registerPostElectronShutdownCleanup(async () => cleanupDockerSshRelayTarget(target)) + cleanupDeferred = true + execDockerSshRelayTargetCommand( + target, + `printf '%s' ${shellQuote(IMAGE_BASE64)} | base64 -d > ${shellQuote(REMOTE_IMAGE_PATH)}` + ) + + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + const remote = await connectDockerSshRelayTarget(orcaPage, target, { + remotePath: DOCKER_SSH_RELAY_REMOTE_REPO_PATH + }) + await ensureTerminalVisible(orcaPage, 45_000) + await waitForActiveTerminalManager(orcaPage, 60_000) + const ptyId = await waitForActivePanePtyId(orcaPage, 60_000) + const readyMarker = `SSH_PREVIEW_READY_${Date.now()}` + const encodedReadyMarker = Buffer.from(readyMarker).toString('base64') + await sendToTerminal( + orcaPage, + ptyId, + `printf '%s' ${shellQuote(encodedReadyMarker)} | base64 -d; printf '\\n'\r` + ) + await expect + .poll(() => getTerminalContent(orcaPage, 30_000), { + timeout: 15_000, + message: 'SSH terminal did not execute the readiness marker' + }) + .toContain(readyMarker) + + await sendToTerminal(orcaPage, ptyId, `printf '%s\\n' ${shellQuote(REMOTE_IMAGE_PATH)}\r`) + await expect + .poll(() => getTerminalContent(orcaPage, 30_000), { + timeout: 15_000, + message: 'External image path did not reach the SSH terminal' + }) + .toContain(REMOTE_IMAGE_PATH) + + const probe = await findTerminalLink(orcaPage, REMOTE_IMAGE_PATH) + await activateTerminalLink(orcaPage, probe, REMOTE_IMAGE_PATH) + + const preview = orcaPage.locator(`img[alt="${REMOTE_IMAGE_PATH.split('/').at(-1)}"]`) + await expect(preview).toBeVisible({ timeout: 30_000 }) + expect(await preview.evaluate((element) => (element as HTMLImageElement).naturalWidth)).toBe( + 2 + ) + expect(await preview.getAttribute('src')).toBe(`data:image/png;base64,${IMAGE_BASE64}`) + await expect(orcaPage.getByText('Unable to load file', { exact: true })).toHaveCount(0) + + const state = await orcaPage.evaluate((filePath) => { + const file = window.__store?.getState().openFiles.find((item) => item.filePath === filePath) + return file + ? { + externalSshTargetId: file.externalSshTargetId, + relativePath: file.relativePath + } + : null + }, REMOTE_IMAGE_PATH) + expect(state).toEqual({ + externalSshTargetId: remote.targetId, + relativePath: REMOTE_IMAGE_PATH + }) + + const remoteHash = execDockerSshRelayTargetCommand( + target, + `sha256sum ${shellQuote(REMOTE_IMAGE_PATH)} | cut -d' ' -f1` + ) + expect(remoteHash).toBe( + createHash('sha256').update(Buffer.from(IMAGE_BASE64, 'base64')).digest('hex') + ) + await testInfo.attach('ssh-external-image-preview', { + body: await orcaPage.screenshot(), + contentType: 'image/png' + }) + } finally { + if (!cleanupDeferred) { + cleanupDockerSshRelayTarget(target) + } + } + }) +})