fix(ssh): open external host images from terminal links (#10323)
* fix(ssh): open external host images from terminal links * fix(ssh): keep external file ownership host-scoped * fix(ssh): reject blank external file owners --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
parent
3dfbb10775
commit
01929fde40
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<boolean> => {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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(<HookProbe activeFile={activeFile} openFiles={[activeFile]} />)
|
||||
})
|
||||
|
||||
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(<HookProbe activeFile={activeFile} openFiles={[activeFile]} />)
|
||||
})
|
||||
|
||||
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',
|
||||
|
|
|
|||
|
|
@ -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<FileContent>
|
||||
|
|
|
|||
|
|
@ -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<PreviewResult>()
|
||||
const readFile = vi.fn().mockReturnValue(read.promise)
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ export function getLocalImageCacheKey(
|
|||
return [
|
||||
runtimeEnvironmentId,
|
||||
runtimeContext?.connectionId ?? connectionId ?? 'local',
|
||||
runtimeContext?.expectedExternalSshTargetId ?? '',
|
||||
runtimeContext?.worktreeId ?? 'unknown-worktree',
|
||||
absolutePath
|
||||
].join('\0')
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ type RichMarkdownProgrammaticSyncOptions = {
|
|||
editor: Editor | null
|
||||
fileId: string
|
||||
filePath: string
|
||||
externalSshTargetId?: string
|
||||
isApplyingProgrammaticUpdateRef: MutableRefObject<boolean>
|
||||
lastCommittedMarkdownRef: MutableRefObject<string>
|
||||
originalSourceRef: MutableRefObject<string>
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -617,8 +617,14 @@ function readFileForEchoVerification(args: {
|
|||
relativePath: string
|
||||
worktreeId: string | null | undefined
|
||||
connectionId: string | undefined
|
||||
expectedExternalSshTargetId?: string
|
||||
}): ReturnType<typeof readRuntimeFileContent> {
|
||||
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 (
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
|
|
|
|||
|
|
@ -246,7 +246,8 @@ async function readFileContent(file: OpenFile): Promise<string> {
|
|||
filePath: file.filePath,
|
||||
relativePath: file.relativePath,
|
||||
worktreeId: file.worktreeId,
|
||||
connectionId
|
||||
connectionId,
|
||||
expectedExternalSshTargetId: file.externalSshTargetId
|
||||
})) as FileContent
|
||||
if (result.isBinary) {
|
||||
throw new Error('binary_file')
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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<GlobalSettings, 'activeRuntimeEnvironmentId'> | 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<T extends object>(
|
||||
|
|
@ -227,8 +243,10 @@ export async function readRuntimeFileContent({
|
|||
relativePath,
|
||||
worktreeId,
|
||||
connectionId,
|
||||
expectedExternalSshTargetId,
|
||||
includeLocalLogMetadata
|
||||
}: RuntimeFileReadArgs): Promise<RuntimeReadableFileContent> {
|
||||
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<RuntimeFilePreviewResult> {
|
||||
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<RuntimeFileDownloadResult> {
|
||||
assertExternalSshReadOwnership(
|
||||
context.settings,
|
||||
context.connectionId,
|
||||
context.expectedExternalSshTargetId
|
||||
)
|
||||
const remoteArgs = getRemoteFileArgs(context, filePath)
|
||||
if (!remoteArgs) {
|
||||
if (hasRemoteRuntimeOwner(context)) {
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (s
|
|||
language: file.language,
|
||||
isDirty: false,
|
||||
runtimeEnvironmentId,
|
||||
externalSshTargetId,
|
||||
markdownPreviewSourceFileId: sourceFileId,
|
||||
markdownPreviewAnchor: anchor,
|
||||
mode: 'markdown-preview'
|
||||
|
|
@ -4504,6 +4525,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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<LinkProbe> {
|
||||
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<void> {
|
||||
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<HTMLElement>('.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<HTMLElement>('.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)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue