mirror of
https://github.com/stablyai/orca.git
synced 2026-11-06 03:57:35 +00:00
* fix(terminal): a file link opens in the workspace it was clicked in A terminal or chat link to a file another workspace on the same host holds used to switch to that workspace. It now opens as a plain read-only file in the workspace it was clicked in (local and WSL read on this computer; paired and managed servers read through the owning workspace's scope), with a toast naming the owner and an Open there action, and a Read-only badge in the editor header. HTML on server hosts, server workspaces the client does not know, directories, shift-click download and direct-SSH workspaces keep the switch. The view is watched with its owner through one derived scope (lib/open-file-owner-scope.ts); phones skip plain file tabs they cannot read by workspace-relative path. Fixes #32158 * fix(terminal): a fresh id per other-workspace hint, and owner lookups cached per route inputs * fix(terminal): only this computer's files open in place; SSH and server links keep opening in their workspace Servers read files only by workspace-relative path, so a read-only view of another workspace's server file needed owner rerouting in every read path. Paired and managed servers keep today's switch; the loader moves any restored read-only server tab of another workspace's file there as before.
251 lines
8.2 KiB
TypeScript
251 lines
8.2 KiB
TypeScript
// @vitest-environment happy-dom
|
|
|
|
import { act } from 'react'
|
|
import { createRoot, type Root } from 'react-dom/client'
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import type { OpenFile } from '@/store/slices/editor'
|
|
import type { FileContent } from './editor-panel-content-types'
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
readRuntimeFileContent: vi.fn(),
|
|
findWorkspaceFileRoute: vi.fn(),
|
|
migrateRestoredEditorFileOwner: vi.fn(),
|
|
getState: vi.fn()
|
|
}))
|
|
|
|
vi.mock('@/runtime/runtime-file-routing', () => ({
|
|
getRuntimeFileReadScope: vi.fn(
|
|
(
|
|
settings: { activeRuntimeEnvironmentId?: string | null } | null | undefined,
|
|
connectionId?: string
|
|
) => connectionId ?? settings?.activeRuntimeEnvironmentId ?? null
|
|
)
|
|
}))
|
|
vi.mock('@/runtime/runtime-file-read-client', () => ({
|
|
readRuntimeFileContent: mocks.readRuntimeFileContent
|
|
}))
|
|
vi.mock('@/runtime/runtime-file-watch-client', () => ({ subscribeRuntimeFileChanges: vi.fn() }))
|
|
|
|
vi.mock('@/runtime/runtime-git-diff-client', () => ({
|
|
getRuntimeGitBranchDiff: vi.fn(),
|
|
getRuntimeGitCommitDiff: vi.fn(),
|
|
getRuntimeGitDiff: vi.fn()
|
|
}))
|
|
vi.mock('@/runtime/runtime-git-client-context', () => ({ getRuntimeGitScope: vi.fn(() => null) }))
|
|
|
|
vi.mock('@/lib/connection-context', () => ({
|
|
getConnectionId: vi.fn(),
|
|
getConnectionIdForFile: vi.fn(),
|
|
isWorktreeConnectionResolved: vi.fn(() => true)
|
|
}))
|
|
|
|
vi.mock('@/lib/worktree-host-connection-phase', () => import('./local-host-test-fixture'))
|
|
|
|
vi.mock('@/lib/runtime-workspace-file-route', () => ({
|
|
findWorkspaceFileRoute: mocks.findWorkspaceFileRoute
|
|
}))
|
|
|
|
vi.mock('./migrate-restored-editor-file-owner', () => ({
|
|
migrateRestoredEditorFileOwner: mocks.migrateRestoredEditorFileOwner
|
|
}))
|
|
|
|
vi.mock('@/lib/worktree-runtime-owner', () => ({
|
|
getExecutionHostIdForWorktree: vi.fn(() => 'local')
|
|
}))
|
|
|
|
// These fixtures' workspaces live on runtime-1, so an unstamped tab resolves there.
|
|
vi.mock('@/lib/file-owner-runtime-target', () => {
|
|
const getRuntimeTargetForFileOwner = (
|
|
_state: unknown,
|
|
_worktreeId: string | null | undefined,
|
|
runtimeEnvironmentId: string | null | undefined
|
|
) =>
|
|
runtimeEnvironmentId === null
|
|
? { kind: 'local' as const }
|
|
: { kind: 'environment' as const, environmentId: runtimeEnvironmentId ?? 'runtime-1' }
|
|
return {
|
|
FILE_OWNER_UNRESOLVED_MESSAGE: 'unresolved',
|
|
getRuntimeTargetForFileOwner,
|
|
requireRuntimeTargetForFileOwner: getRuntimeTargetForFileOwner,
|
|
getRuntimeTargetForWorktreeOwner: (state: unknown, worktreeId: string | null | undefined) =>
|
|
getRuntimeTargetForFileOwner(state, worktreeId, undefined)
|
|
}
|
|
})
|
|
|
|
vi.mock('@/store', () => ({
|
|
useAppStore: Object.assign(
|
|
(select: (state: ReturnType<typeof mocks.getState>) => unknown) => select(mocks.getState()),
|
|
{ getState: mocks.getState }
|
|
)
|
|
}))
|
|
|
|
vi.mock('./useEditorPanelExternalContentEvents', () => ({
|
|
useEditorPanelExternalContentEvents: vi.fn(),
|
|
usePruneClosedEditorContent: vi.fn()
|
|
}))
|
|
|
|
vi.mock('./useEditorPanelFileLoadRetry', () => ({ useEditorPanelFileLoadRetry: vi.fn() }))
|
|
vi.mock('./useLocalLogTail', () => ({ useLocalLogTail: vi.fn() }))
|
|
|
|
import { useEditorPanelContentState } from './useEditorPanelContentState'
|
|
|
|
let latestFileContents: Record<string, FileContent> = {}
|
|
|
|
function createOpenFile(overrides: Partial<OpenFile>): OpenFile {
|
|
return {
|
|
id: '/repo/file.ts',
|
|
filePath: '/repo/file.ts',
|
|
relativePath: 'file.ts',
|
|
worktreeId: 'wt-1',
|
|
language: 'typescript',
|
|
isDirty: false,
|
|
mode: 'edit',
|
|
...overrides
|
|
}
|
|
}
|
|
|
|
function HookProbe({ activeFile }: { activeFile: OpenFile }): null {
|
|
latestFileContents = useEditorPanelContentState({
|
|
activeFile,
|
|
isChangesMode: false,
|
|
openFiles: [activeFile],
|
|
gitStatusEntries: undefined,
|
|
editorViewMode: {}
|
|
}).fileContents
|
|
return null
|
|
}
|
|
|
|
describe('remote sibling editor content routing', () => {
|
|
let container: HTMLDivElement | null = null
|
|
let root: Root | null = null
|
|
|
|
beforeEach(() => {
|
|
latestFileContents = {}
|
|
vi.stubGlobal('api', { fs: {} })
|
|
mocks.readRuntimeFileContent.mockReset()
|
|
mocks.findWorkspaceFileRoute.mockReset()
|
|
mocks.findWorkspaceFileRoute.mockReturnValue(null)
|
|
mocks.migrateRestoredEditorFileOwner.mockReset()
|
|
mocks.migrateRestoredEditorFileOwner.mockResolvedValue({ ok: true, fileId: 'owned-file' })
|
|
mocks.getState.mockReset()
|
|
mocks.getState.mockReturnValue({
|
|
settings: { activeRuntimeEnvironmentId: 'runtime-1' },
|
|
openFiles: [],
|
|
setLastKnownDiskSignature: vi.fn()
|
|
})
|
|
container = document.body.appendChild(document.createElement('div'))
|
|
root = createRoot(container)
|
|
})
|
|
|
|
afterEach(() => {
|
|
act(() => root?.unmount())
|
|
container?.remove()
|
|
})
|
|
|
|
it('keeps a client-local live-tail log on the client', async () => {
|
|
const logPath = '/Users/me/.codex/sessions/session.jsonl'
|
|
const activeFile = createOpenFile({
|
|
id: logPath,
|
|
filePath: logPath,
|
|
relativePath: logPath,
|
|
worktreeId: 'repo-runtime::/work/demo-project',
|
|
readOnly: true,
|
|
liveTail: true
|
|
})
|
|
mocks.readRuntimeFileContent.mockResolvedValue({ content: 'log line', isBinary: false })
|
|
|
|
await act(async () => root?.render(<HookProbe activeFile={activeFile} />))
|
|
|
|
await vi.waitFor(() => expect(latestFileContents[activeFile.id]?.content).toBe('log line'))
|
|
expect(mocks.findWorkspaceFileRoute).not.toHaveBeenCalled()
|
|
expect(mocks.readRuntimeFileContent).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
connectionId: undefined,
|
|
includeLocalLogMetadata: true,
|
|
access: { kind: 'user-file' }
|
|
})
|
|
)
|
|
})
|
|
|
|
it('atomically reparents an unstamped sibling file before reading', async () => {
|
|
const activeFile = createOpenFile({
|
|
id: '/work/repo-b/docs/readme.md',
|
|
filePath: '/work/repo-b/docs/readme.md',
|
|
relativePath: '/work/repo-b/docs/readme.md',
|
|
worktreeId: 'repo-a::/work/repo-a'
|
|
})
|
|
const route = {
|
|
worktreeId: 'repo-b::/work/repo-b',
|
|
relativePath: 'docs/readme.md',
|
|
executionHostId: 'runtime:runtime-1'
|
|
} as const
|
|
mocks.findWorkspaceFileRoute.mockReturnValue(route)
|
|
|
|
await act(async () => root?.render(<HookProbe activeFile={activeFile} />))
|
|
|
|
await vi.waitFor(() =>
|
|
expect(mocks.migrateRestoredEditorFileOwner).toHaveBeenCalledWith(
|
|
activeFile.id,
|
|
route,
|
|
'runtime-1'
|
|
)
|
|
)
|
|
expect(mocks.readRuntimeFileContent).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('moves a restored read-only server tab of another workspace file to that workspace', async () => {
|
|
const activeFile = createOpenFile({
|
|
id: '/work/repo-b/docs/readme.md',
|
|
filePath: '/work/repo-b/docs/readme.md',
|
|
relativePath: '/work/repo-b/docs/readme.md',
|
|
worktreeId: 'repo-a::/work/repo-a',
|
|
runtimeEnvironmentId: 'runtime-1',
|
|
readOnly: true
|
|
})
|
|
const route = {
|
|
worktreeId: 'repo-b::/work/repo-b',
|
|
relativePath: 'docs/readme.md',
|
|
executionHostId: 'runtime:runtime-1'
|
|
} as const
|
|
mocks.findWorkspaceFileRoute.mockReturnValue(route)
|
|
|
|
await act(async () => root?.render(<HookProbe activeFile={activeFile} />))
|
|
|
|
// Why: a server reads only by workspace-relative path, so no view of it stays in another workspace.
|
|
await vi.waitFor(() =>
|
|
expect(mocks.migrateRestoredEditorFileOwner).toHaveBeenCalledWith(
|
|
activeFile.id,
|
|
route,
|
|
'runtime-1'
|
|
)
|
|
)
|
|
expect(mocks.readRuntimeFileContent).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('reports a sibling-owner collision instead of remaining in loading state', async () => {
|
|
const activeFile = createOpenFile({
|
|
id: '/work/repo-b/docs/readme.md',
|
|
filePath: '/work/repo-b/docs/readme.md',
|
|
relativePath: '/work/repo-b/docs/readme.md',
|
|
worktreeId: 'repo-a::/work/repo-a'
|
|
})
|
|
mocks.findWorkspaceFileRoute.mockReturnValue({
|
|
worktreeId: 'repo-b::/work/repo-b',
|
|
relativePath: 'docs/readme.md',
|
|
executionHostId: 'runtime:runtime-1'
|
|
})
|
|
mocks.migrateRestoredEditorFileOwner.mockResolvedValue({
|
|
ok: false,
|
|
reason: 'collision'
|
|
})
|
|
|
|
await act(async () => root?.render(<HookProbe activeFile={activeFile} />))
|
|
|
|
await vi.waitFor(() =>
|
|
expect(latestFileContents[activeFile.id]?.loadError).toBe(
|
|
'The sibling file is already open; close one tab before restoring it.'
|
|
)
|
|
)
|
|
})
|
|
})
|