fix(terminal): a file link opens in the workspace it was clicked in (#32320)

* 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.
This commit is contained in:
Neil
2026-10-23 18:14:13 -07:00
committed by GitHub
parent 69db315f52
commit 359fec1a09
35 changed files with 1036 additions and 129 deletions
@@ -12,6 +12,7 @@ import {
import { useShortcutLabel } from '@/hooks/useShortcutLabel'
import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event'
import { translate } from '@/i18n/i18n'
import { Badge } from '@/components/ui/badge'
import type { OpenFile } from '@/store/slices/editor'
import { isVirtualEditorFile } from '@/store/slices/editor/tabs/editor-tab-content-type'
import { CLOSE_ALL_CONTEXT_MENUS_EVENT } from '@/lib/close-all-context-menus'
@@ -119,6 +120,11 @@ export function EditorPanelHeaderPath({
<span className="editor-header-path-file">{displayPath.fileName}</span>
</button>
)}
{!isRenaming && activeFile.readOnly === true && (
<Badge variant="outline">
{translate('auto.components.editor.EditorPanelHeader.readOnlyBadge', 'Read-only')}
</Badge>
)}
{/* Why: the toast is opacity-0 rather than display-none, so leaving it
mounted reserves ~100px of the row from the rename field for a
message that cannot fire while renaming. */}
@@ -55,7 +55,7 @@ export function pathMutationHoldKeys(
target: EditorPathMutationTarget
): Set<string> {
const keys = new Set(
getOpenFilesForExternalFileChange(state.openFiles, target).map((file) =>
getOpenFilesForExternalFileChange(state.openFiles, target, state).map((file) =>
editorFileHoldKey(state, file)
)
)
@@ -1,5 +1,7 @@
import { joinPath } from '@/lib/path'
import { openFileOwnerWorktreeId } from '@/lib/open-file-owner-scope'
import type { OpenFile } from '@/store/slices/editor'
import type { AppState } from '@/store/types'
import { areLocalWindowsWslPathAliases } from '../../../../shared/cross-platform-path'
import { isLocalWindowsDesktopClient } from '@/lib/desktop-window-chrome'
import {
@@ -142,7 +144,8 @@ export function normalizeAutoSaveDelayMs(value: unknown): number {
export function getOpenFilesForExternalFileChange(
openFiles: OpenFile[],
target: EditorPathMutationTarget
target: EditorPathMutationTarget,
catalog?: AppState
): OpenFile[] {
if (target.indexedOpenFiles) {
return target.indexedOpenFiles.matches(openFiles)
@@ -151,7 +154,7 @@ export function getOpenFilesForExternalFileChange(
const hasRuntimeOwnerFilter = Object.hasOwn(target, 'runtimeEnvironmentId')
const targetRuntimeOwner = target.runtimeEnvironmentId?.trim() || null
return openFiles.filter((file) => {
if (file.worktreeId !== target.worktreeId) {
if (openFileOwnerWorktreeId(file, catalog) !== target.worktreeId) {
return false
}
if (
@@ -84,7 +84,7 @@ export function externalChangeRecordViewIds(
target: EditorPathMutationTarget
): Set<string> {
const storeFiles = state?.openFiles ?? []
const matched = getOpenFilesForExternalFileChange([...panelFiles, ...storeFiles], target)
const matched = getOpenFilesForExternalFileChange([...panelFiles, ...storeFiles], target, state)
return new Set(
matched.flatMap((file) =>
state && storeFiles.some((tab) => tab.id === file.id)
@@ -14,7 +14,7 @@ export function createEditorExternalChangeTabReset(store: AppStoreApi): (event:
const state = store.getState()
// Why no sibling expansion: views of one file share a record, so each matches by path itself.
const matchingFiles = getOpenFilesForExternalFileChange(state.openFiles, detail)
const matchingFiles = getOpenFilesForExternalFileChange(state.openFiles, detail, state)
if (matchingFiles.length === 0) {
return
}
@@ -1,6 +1,8 @@
import { joinPath } from '@/lib/path'
import { openFileOwnerWorktreeId } from '@/lib/open-file-owner-scope'
import { getExternalFileChangeRelativePath } from '@/components/right-sidebar/useFileExplorerWatch'
import type { OpenFile } from '@/store/slices/editor'
import type { AppState } from '@/store/types'
import type { FsChangedPayload } from '../../../../shared/filesystem-entry-types'
import {
getLocalWindowsWslPathIdentity,
@@ -9,6 +11,8 @@ import {
} from '../../../../shared/cross-platform-path'
type WatchScope = {
/** Where a read-only cross-workspace view's owner is looked up; without it, views are not matched. */
catalog?: AppState
worktreeId: string
worktreePath: string
runtimeEnvironmentId: string | null
@@ -120,7 +124,7 @@ class IndexedOpenFileLookup {
let hasCombinedDiffConsumer = false
for (const [index, file] of openFiles.entries()) {
if (
file.worktreeId !== scope.worktreeId ||
openFileOwnerWorktreeId(file, scope.catalog) !== scope.worktreeId ||
openFileRuntimeOwner(file) !== scope.runtimeEnvironmentId
) {
continue
@@ -193,6 +193,35 @@ describe('remote sibling editor content routing', () => {
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',
@@ -0,0 +1,75 @@
import { statUserOpenedPath } from '@/lib/user-opened-local-path'
import type { TerminalFileContext } from './terminal-file-path-mapping'
import {
CLIENT_LOCAL_FILE_TARGET,
resolveHostWorkspaceFile,
type HostWorkspaceFile
} from './terminal-host-workspace-file'
export type TerminalLinkFileOwner = {
fileContext: TerminalFileContext
worktreeId: string
worktreePath: string
/** The path to open: the host's spelling when the host resolved it. */
filePath: string
stat: { isDirectory: boolean; escapesWorktree: boolean }
/** Another host workspace the link opens in. */
hostWorkspaceFile: HostWorkspaceFile | null
hostGrantId: string | null
clientLocal: boolean
}
/**
* Who owns a clicked link's file and how it may be read: a host grant, this computer, another
* workspace on the host (opened there), or the source workspace. Throws when the host cannot answer.
*/
export async function resolveTerminalLinkFileOwner(args: {
fileContext: TerminalFileContext
worktreeId: string
worktreePath: string
filePath: string
terminalHandle?: string | null
}): Promise<TerminalLinkFileOwner> {
const { fileContext, filePath } = args
const resolved: TerminalLinkFileOwner = {
fileContext,
worktreeId: args.worktreeId,
worktreePath: args.worktreePath,
filePath,
stat: { isDirectory: false, escapesWorktree: false },
hostWorkspaceFile: null,
hostGrantId: null,
clientLocal: false
}
const linkOwner = await resolveHostWorkspaceFile(fileContext, filePath, args.terminalHandle)
if (linkOwner?.kind === 'grant') {
return {
...resolved,
hostGrantId: linkOwner.grantId,
filePath: linkOwner.absolutePath,
stat: { isDirectory: false, escapesWorktree: true }
}
}
if (linkOwner?.kind === 'client') {
return {
...resolved,
clientLocal: true,
fileContext: { ...fileContext, target: CLIENT_LOCAL_FILE_TARGET, connectionId: undefined },
stat: { isDirectory: linkOwner.isDirectory, escapesWorktree: false }
}
}
if (!linkOwner) {
return { ...resolved, stat: await statUserOpenedPath(fileContext, filePath) }
}
// Why: the host already stat'ed the path inside the workspace that holds it; open it there.
const { worktreeId, worktreePath } = linkOwner
return {
...resolved,
filePath: linkOwner.absolutePath,
hostWorkspaceFile: linkOwner,
worktreeId,
worktreePath,
fileContext: { ...fileContext, worktreeId, worktreePath },
stat: { isDirectory: linkOwner.isDirectory, escapesWorktree: false }
}
}
@@ -1,7 +1,7 @@
import { getWorkspaceFilePreviewPlan, openFileInBrowserTab } from '@/lib/file-preview'
import { downloadAndOpenRemoteTerminalFile } from './terminal-remote-file-download-open'
import { findWorkspaceFileRoute } from '@/lib/runtime-workspace-file-route'
import { isPathInsideWorktree, toWorktreeRelativePath } from '@/lib/terminal-links'
import type { RuntimeWorkspaceFileRoute } from '@/lib/runtime-workspace-file-route'
import { canClientOsOpenWorkspaceFile } from '@/lib/workspace-file-host-routing'
import { isMissingRuntimePathError } from '@/runtime/runtime-file-metadata-client'
import { useAppStore } from '@/store'
@@ -18,17 +18,19 @@ import {
getConnectionExecutionHostId,
toRuntimeExecutionHostId
} from '../../../../shared/execution-host'
import { statUserOpenedPath } from '@/lib/user-opened-local-path'
import {
CLIENT_LOCAL_FILE_TARGET,
resolveHostWorkspaceFile,
type HostWorkspaceFile
} from './terminal-host-workspace-file'
import { isFloatingWorkspaceId } from '../../../../shared/floating-workspace-worktree'
import { captureEditorOpenLinkIntent } from '@/store/slices/editor/tabs/editor-open-target-group'
import { reserveDetectedFilePathOpenTurn } from './terminal-file-open-turn'
import { openHtmlFileInBrowser } from './terminal-html-file-browser-open'
import { showOpenInGroupRefusedToast } from './terminal-file-open-in-group-toast'
import {
dismissFileInOtherWorkspaceHint,
showFileInOtherWorkspaceHint
} from './terminal-file-other-workspace-hint'
import {
otherWorkspaceViewFields,
siblingWorkspaceLink
} from './terminal-file-other-workspace-owner'
import { resolveTerminalLinkFileOwner } from './terminal-file-link-owner-resolution'
import { isExplicitEditorScopeKept, openLinkedFileInEditor } from './terminal-file-link-editor-open'
export {
@@ -89,6 +91,7 @@ export function openDetectedFilePath(
terminalLinkWslDistro(deps.wslDistro, runtimeEnvironmentId)
)
const isCurrentOpen = reserveDetectedFilePathOpenTurn()
dismissFileInOtherWorkspaceHint()
// Why: capture at activation; focus may move while the host resolves the path.
const linkIntent = captureEditorOpenLinkIntent(
useAppStore.getState(),
@@ -109,13 +112,9 @@ export function openDetectedFilePath(
}
void (async () => {
let statResult
let fileContext = getTerminalFileContext(worktreeId, worktreePath, runtimeEnvironmentId)
const sourceRuntimeOwnerId = runtimeTargetEnvironmentId(fileContext.target)
let hostWorkspaceFile: HostWorkspaceFile | null = null
let hostGrantId: string | null = null
let clientLocal = false
if (!fileContext.sourceHostResolved) {
const sourceContext = getTerminalFileContext(worktreeId, worktreePath, runtimeEnvironmentId)
const sourceRuntimeOwnerId = runtimeTargetEnvironmentId(sourceContext.target)
if (!sourceContext.sourceHostResolved) {
// Why: with no owner the host is unknown — refuse rather than touch this machine's files.
deps.onOpenFailure?.({
verdict: 'unverifiable',
@@ -127,7 +126,7 @@ export function openDetectedFilePath(
const worktreeRootLink = resolveKnownWorktreeRootPathLink(
mappedFilePath,
useAppStore.getState(),
fileContext
sourceContext
)
if (worktreeRootLink) {
if (refuseExplicitNonEditorRoute()) {
@@ -146,33 +145,15 @@ export function openDetectedFilePath(
}
}
let owner
try {
const linkOwner = await resolveHostWorkspaceFile(
fileContext,
mappedFilePath,
deps.terminalHandle
)
if (linkOwner?.kind === 'grant') {
hostGrantId = linkOwner.grantId
mappedFilePath = linkOwner.absolutePath
statResult = { isDirectory: false, escapesWorktree: true }
} else if (linkOwner?.kind === 'client') {
clientLocal = true
fileContext = {
...fileContext,
target: CLIENT_LOCAL_FILE_TARGET,
connectionId: undefined
}
statResult = { isDirectory: linkOwner.isDirectory, escapesWorktree: false }
} else if (linkOwner) {
hostWorkspaceFile = linkOwner
// Why: the host already stat'ed the path inside the workspace that holds it; open it there.
;({ worktreeId, worktreePath, absolutePath: mappedFilePath } = hostWorkspaceFile)
fileContext = { ...fileContext, worktreeId, worktreePath }
statResult = { isDirectory: hostWorkspaceFile.isDirectory, escapesWorktree: false }
} else {
statResult = await statUserOpenedPath(fileContext, mappedFilePath)
}
owner = await resolveTerminalLinkFileOwner({
fileContext: sourceContext,
worktreeId,
worktreePath,
filePath: mappedFilePath,
terminalHandle: deps.terminalHandle
})
} catch (error) {
if (isCurrentOpen() && deps.onOpenFailure) {
// Why: loss of contact with the host is not evidence the file is gone.
@@ -187,6 +168,9 @@ export function openDetectedFilePath(
if (!isCurrentOpen()) {
return
}
const { fileContext, stat: statResult, hostWorkspaceFile, hostGrantId, clientLocal } = owner
let otherWorkspaceOwner: RuntimeWorkspaceFileRoute | null = null
;({ worktreeId, worktreePath, filePath: mappedFilePath } = owner)
const canOpenWithSystemDefault = shouldOpenTerminalFileWithSystemDefault(fileContext)
// Why: a remote workspace's hint and rows promise Orca before the path resolves, and an
@@ -257,10 +241,8 @@ export function openDetectedFilePath(
}
}
const store = useAppStore.getState()
let targetWorktreeId = worktreeId
const runtimeOwnerId = runtimeTargetEnvironmentId(fileContext.target) ?? sourceRuntimeOwnerId
let targetExecutionHostId =
const targetExecutionHostId =
hostWorkspaceFile?.executionHostId ??
(runtimeOwnerId
? toRuntimeExecutionHostId(runtimeOwnerId)
@@ -273,35 +255,27 @@ export function openDetectedFilePath(
if (maybeRelative !== null && maybeRelative.length > 0 && !statResult.escapesWorktree) {
relativePath = maybeRelative
}
} else if (
hostGrantId === null &&
!isFloatingWorkspaceId(worktreeId) &&
store.openFiles.some(
(openFile) => openFile.filePath === mappedFilePath && openFile.worktreeId !== worktreeId
)
) {
// Why: early resolution is only needed to avoid an existing sibling-tab collision.
const siblingRoute = findWorkspaceFileRoute(store, targetExecutionHostId, mappedFilePath)
if (siblingRoute) {
targetWorktreeId = siblingRoute.worktreeId
targetExecutionHostId = siblingRoute.executionHostId
relativePath = siblingRoute.relativePath
} else if (hostGrantId === null && !clientLocal && !hostWorkspaceFile) {
const sibling = siblingWorkspaceLink(targetExecutionHostId, mappedFilePath, worktreeId)
otherWorkspaceOwner = sibling.view
if (sibling.openInOwner) {
;({ worktreeId, relativePath } = sibling.openInOwner)
}
}
if (
explicitGroupRequest &&
!isExplicitEditorScopeKept(linkIntent, targetWorktreeId, targetExecutionHostId, clientLocal)
!isExplicitEditorScopeKept(linkIntent, worktreeId, targetExecutionHostId, clientLocal)
) {
// Why: refuse before activating the destination or touching editor state.
refuseExplicitNonEditorRoute()
return
}
if (targetWorktreeId) {
if (worktreeId) {
// Why: the route may name a folder-workspace key, and the same worktree id can exist
// on several hosts — dispatch by workspace shape and keep the resolved host.
activateAndRevealWorkspace(targetWorktreeId, {
activateAndRevealWorkspace(worktreeId, {
providesInitialSurface: true,
executionHostId: targetExecutionHostId
})
@@ -311,10 +285,11 @@ export function openDetectedFilePath(
{
filePath: mappedFilePath,
relativePath,
worktreeId: targetWorktreeId || '',
worktreeId: worktreeId || '',
runtimeEnvironmentId: clientLocal ? null : runtimeEnvironmentId,
// Why: a tab's owner must match its workspace's to be editable; this one is this computer's.
...(clientLocal && targetWorktreeId === worktreeId ? { readOnly: true } : {}),
// Why: a tab's owner must match its workspace's to be editable; this one is this computer's,
// or another workspace's, which "Open there" edits under its own owner.
...(clientLocal ? { readOnly: true } : otherWorkspaceViewFields(otherWorkspaceOwner)),
// Why: the grant is the only authority to read a host file outside its workspaces.
...(hostGrantId !== null ? { readOnly: true, terminalArtifactGrantId: hostGrantId } : {}),
// Why: absolute SSH paths outside the worktree otherwise look identical
@@ -331,6 +306,14 @@ export function openDetectedFilePath(
)
if (!opened) {
refuseExplicitNonEditorRoute()
} else if (otherWorkspaceOwner && isCurrentOpen()) {
showFileInOtherWorkspaceHint({
fileId: opened.fileId,
filePath: mappedFilePath,
owner: otherWorkspaceOwner,
runtimeEnvironmentId: runtimeOwnerId,
position: { line, column }
})
}
})()
}
@@ -0,0 +1,33 @@
import { describe, expect, it, vi } from 'vitest'
const openLinkedFileInEditorMock = vi.hoisted(() => vi.fn())
vi.mock('@/store', () => ({ useAppStore: { getState: () => ({}) } }))
vi.mock('@/lib/worktree-activation', () => ({ activateAndRevealWorkspace: vi.fn() }))
vi.mock('@/store/slices/editor/focus/editor-focus-reveal', () => ({
cancelPendingEditorLineRevealFrames: vi.fn()
}))
vi.mock('./terminal-file-link-editor-open', () => ({
openLinkedFileInEditor: openLinkedFileInEditorMock
}))
import { openFileInOwningWorkspace } from './terminal-file-other-workspace-hint'
import { reserveDetectedFilePathOpenTurn } from './terminal-file-open-turn'
describe('"Open there" from the other-workspace hint', () => {
it('yields its line reveal to a later link click', () => {
openFileInOwningWorkspace({
fileId: 'view',
filePath: '/b/report.md',
owner: { worktreeId: 'wt-b', relativePath: 'report.md', executionHostId: 'local' },
runtimeEnvironmentId: null,
position: { line: 3, column: null }
})
const isCurrent: () => boolean = openLinkedFileInEditorMock.mock.calls[0][3]
expect(isCurrent()).toBe(true)
reserveDetectedFilePathOpenTurn()
expect(isCurrent()).toBe(false)
})
})
@@ -0,0 +1,111 @@
import { toast } from 'sonner'
import { translate } from '@/i18n/i18n'
import { resolveWorktreeDisplayName } from '@/lib/worktree-default-display-name'
import { activateAndRevealWorkspace } from '@/lib/worktree-activation'
import type { RuntimeWorkspaceFileRoute } from '@/lib/runtime-workspace-file-route'
import { useAppStore } from '@/store'
import { selectKnownWorktreeById } from '@/store/selectors'
import { getIndexedRepoMap } from '@/store/worktree-repo-index'
import { openLinkedFileInEditor } from './terminal-file-link-editor-open'
import { reserveDetectedFilePathOpenTurn } from './terminal-file-open-turn'
// Why a fresh id per hint: sonner applies a dismissal later, so reusing one id would let the
// previous hint's dismissal remove the hint that replaced it.
let hintSequence = 0
let shownHintId: string | null = null
export type FileInOtherWorkspace = {
/** The tab the link opened here. */
fileId: string
filePath: string
owner: RuntimeWorkspaceFileRoute
runtimeEnvironmentId: string | null | undefined
position: { line: number | null; column: number | null }
}
/** Opens the file as its owning workspace's own file, with that workspace in front. */
export function openFileInOwningWorkspace(file: FileInOtherWorkspace): void {
const { owner } = file
// Why: a later link click supersedes this open's line reveal, as it would any link open.
const isCurrentOpen = reserveDetectedFilePathOpenTurn()
activateAndRevealWorkspace(owner.worktreeId, {
providesInitialSurface: true,
executionHostId: owner.executionHostId
})
openLinkedFileInEditor(
{
filePath: file.filePath,
relativePath: owner.relativePath,
worktreeId: owner.worktreeId,
runtimeEnvironmentId: file.runtimeEnvironmentId
},
null,
file.position,
isCurrentOpen
)
}
/** A newer link click supersedes the hint, so its action never opens an older link's file. */
export function dismissFileInOtherWorkspaceHint(): void {
if (shownHintId) {
toast.dismiss(shownHintId)
shownHintId = null
}
}
/** The link opened here; this names the workspace that holds the file and offers to go there. */
export function showFileInOtherWorkspaceHint(file: FileInOtherWorkspace): void {
const state = useAppStore.getState()
// Why: a reused editable tab is not the read-only view this hint describes.
if (state.openFiles.find((openFile) => openFile.id === file.fileId)?.readOnly !== true) {
return
}
const worktree = selectKnownWorktreeById(state, file.owner.worktreeId, file.owner.executionHostId)
const workspaceName = worktree ? resolveWorktreeDisplayName(worktree) : ''
// Why: worktrees of different projects share names like "main"; the project tells them apart.
const repoName =
worktree && 'repoId' in worktree
? getIndexedRepoMap(state.repos).get(worktree.repoId)?.displayName
: undefined
const hintId = `terminal-file-other-workspace-${++hintSequence}`
shownHintId = hintId
const hidden = (): void => {
if (shownHintId === hintId) {
shownHintId = null
}
}
toast(
workspaceName
? translate(
'auto.components.terminal.pane.TerminalLinkActionPopover.fileInOtherWorkspace',
'This file is in {{value0}}',
{
value0:
repoName && repoName !== workspaceName
? `${repoName} · ${workspaceName}`
: workspaceName
}
)
: translate(
'auto.components.terminal.pane.TerminalLinkActionPopover.fileInAnotherWorkspace',
'This file is in another workspace'
),
{
id: hintId,
duration: 8000,
description: translate(
'auto.components.terminal.pane.TerminalLinkActionPopover.openedHereReadOnly',
'Opened here as read-only'
),
action: {
label: translate(
'auto.components.terminal.pane.TerminalLinkActionPopover.openInOwningWorkspace',
'Open there'
),
onClick: () => openFileInOwningWorkspace(file)
},
onDismiss: hidden,
onAutoClose: hidden
}
)
}
@@ -0,0 +1,37 @@
import {
findWorkspaceFileRoute,
type RuntimeWorkspaceFileRoute
} from '@/lib/runtime-workspace-file-route'
import { useAppStore } from '@/store'
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../../shared/execution-host'
import { isFloatingWorkspaceId } from '../../../../shared/floating-workspace-worktree'
/**
* A link to a file another workspace on the same host holds. On this computer (local, WSL and
* folder workspaces) it is viewed here, read-only (#32158), since this computer reads the absolute
* path directly. SSH and server hosts read files only by workspace-relative path, so their file
* still opens in the owning workspace, which also reuses a tab already open there.
*/
export function siblingWorkspaceLink(
executionHostId: ExecutionHostId,
absolutePath: string,
sourceWorktreeId: string
): { view: RuntimeWorkspaceFileRoute | null; openInOwner: RuntimeWorkspaceFileRoute | null } {
// Why: floating-workspace files stay in their panel even when a project also contains the path.
if (isFloatingWorkspaceId(sourceWorktreeId)) {
return { view: null, openInOwner: null }
}
const found = findWorkspaceFileRoute(useAppStore.getState(), executionHostId, absolutePath)
const owner = found && found.worktreeId !== sourceWorktreeId ? found : null
return executionHostId === LOCAL_EXECUTION_HOST_ID
? { view: owner, openInOwner: null }
: { view: null, openInOwner: owner }
}
/** A view here of another local workspace's file: this computer's file, never saved here. */
export function otherWorkspaceViewFields(owner: RuntimeWorkspaceFileRoute | null): {
readOnly?: true
runtimeEnvironmentId?: null
} {
return owner ? { readOnly: true, runtimeEnvironmentId: null } : {}
}
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { activateAndRevealWorkspace, activateAndRevealWorktree } from '@/lib/worktree-activation'
import { getConnectionId } from '@/lib/connection-context'
import { openDetectedFilePath } from './terminal-link-handlers'
import { createTerminalLinkTestDoubles } from './terminal-link-handlers-test-fixtures'
import {
@@ -11,6 +12,7 @@ import {
} from './terminal-link-handlers-test-harness'
const findWorkspaceFileRouteMock = vi.hoisted(() => vi.fn())
const showHintMock = vi.hoisted(() => vi.fn())
const doubles = createTerminalLinkTestDoubles()
const {
storeState,
@@ -46,8 +48,17 @@ vi.mock('@/lib/runtime-workspace-file-route', () => ({
findWorkspaceFileRoute: findWorkspaceFileRouteMock
}))
vi.mock('./terminal-file-other-workspace-hint', () => ({
dismissFileInOtherWorkspaceHint: vi.fn(),
showFileInOtherWorkspaceHint: showHintMock
}))
installTerminalLinkTestEnvironment(doubles)
beforeEach(() => {
findWorkspaceFileRouteMock.mockReset()
})
describe('handleOscLink', () => {
it('opens local .html file paths in Orca browser tabs when links route to Orca', async () => {
setPlatform('Macintosh')
@@ -142,50 +153,83 @@ describe('handleOscLink', () => {
})
})
it('opens a sibling folder-workspace path under its owning host and workspace', async () => {
it('opens a sibling folder-workspace file read-only here and names its workspace', async () => {
const filePath = '/sibling/docs/SKILL.md'
storeState.openFiles = [{ filePath, worktreeId: 'folder:notes' }]
findWorkspaceFileRouteMock.mockReturnValueOnce({
const owner = {
worktreeId: 'folder:notes',
relativePath: 'docs/SKILL.md',
executionHostId: 'local'
})
openFileMock.mockImplementationOnce(() => {
storeState.activeFileIdByWorktree['folder:notes'] = 'owned-skill'
})
}
findWorkspaceFileRouteMock.mockReturnValueOnce(owner)
openDetectedFilePath(filePath, 12, null, deps)
await flushAsyncWork()
await flushDoubleRaf()
expect(activateAndRevealWorkspace).toHaveBeenCalledWith('folder:notes', {
expect(activateAndRevealWorkspace).toHaveBeenCalledTimes(1)
expect(activateAndRevealWorkspace).toHaveBeenCalledWith('wt-1', {
providesInitialSurface: true,
executionHostId: 'local'
})
expect(openFileMock).toHaveBeenCalledWith(
expect.objectContaining({
filePath,
relativePath: 'docs/SKILL.md',
worktreeId: 'folder:notes'
relativePath: filePath,
worktreeId: 'wt-1',
runtimeEnvironmentId: null,
readOnly: true
}),
{ forceContentReload: true }
)
expect(setPendingEditorRevealMock).toHaveBeenLastCalledWith(
expect.objectContaining({ fileId: 'owned-skill' })
)
expect(showHintMock).toHaveBeenCalledWith({
fileId: filePath,
filePath,
owner,
runtimeEnvironmentId: null,
position: { line: 12, column: null }
})
})
it('leaves ordinary external ownership lookup to the editor loader', async () => {
it('opens a direct SSH link to a sibling workspace in that workspace, as before', async () => {
vi.mocked(getConnectionId).mockReturnValue('ssh-1')
findWorkspaceFileRouteMock.mockReturnValue({
worktreeId: 'wt-2',
relativePath: 'x.ts',
executionHostId: 'ssh:ssh-1'
})
openDetectedFilePath('/other/x.ts', null, null, deps)
await flushAsyncWork()
// Why: a read-only absolute tab on a direct SSH connection would read as this computer's file.
expect(activateAndRevealWorkspace).toHaveBeenCalledWith('wt-2', {
providesInitialSurface: true,
executionHostId: 'ssh:ssh-1'
})
expect(openFileMock).toHaveBeenCalledWith(
expect.objectContaining({
filePath: '/other/x.ts',
relativePath: 'x.ts',
worktreeId: 'wt-2'
}),
{ forceContentReload: true }
)
expect(openFileMock.mock.calls[0][0]).not.toHaveProperty('readOnly')
expect(showHintMock).not.toHaveBeenCalled()
})
it('opens a file outside every workspace here, editable and without a hint', async () => {
const filePath = '/external/docs/readme.md'
openDetectedFilePath(filePath, null, null, deps)
await flushAsyncWork()
expect(findWorkspaceFileRouteMock).not.toHaveBeenCalled()
expect(openFileMock).toHaveBeenCalledWith(
expect.objectContaining({ filePath, worktreeId: 'wt-1' }),
{ forceContentReload: true }
)
expect(openFileMock.mock.calls[0][0]).not.toHaveProperty('readOnly')
expect(showHintMock).not.toHaveBeenCalled()
})
it('preserves explicit column for Orca opens from :line:column links', async () => {
@@ -8,6 +8,13 @@ export type TerminalLinkStoreSettings = {
activeRuntimeEnvironmentId?: string | null
}
export type TerminalLinkOpenFile = {
id?: string
filePath: string
worktreeId: string
readOnly?: boolean
}
export type TerminalLinkStoreState = {
settings: TerminalLinkStoreSettings | undefined
setActiveWorktree: Mock
@@ -17,7 +24,7 @@ export type TerminalLinkStoreState = {
setPendingEditorReveal: Mock
setMarkdownViewMode: Mock
activeFileIdByWorktree: Record<string, string | null>
openFiles: { filePath: string; worktreeId: string }[]
openFiles: TerminalLinkOpenFile[]
groupsByWorktree: Record<string, TabGroup[]>
unifiedTabsByWorktree: Record<
string,
@@ -35,6 +42,7 @@ export type TerminalLinkStoreState = {
}[]
>
folderWorkspaces: []
repos?: { id: string; displayName: string }[]
}
export type TerminalLinkTestDoubles = {
@@ -105,7 +113,7 @@ export function createTerminalLinkTestDoubles(): TerminalLinkTestDoubles {
setPendingEditorReveal: setPendingEditorRevealMock,
setMarkdownViewMode: setMarkdownViewModeMock,
activeFileIdByWorktree: {} as Record<string, string | null>,
openFiles: [] as { filePath: string; worktreeId: string }[],
openFiles: [],
unifiedTabsByWorktree: {},
groupsByWorktree: {},
worktreesByRepo: {},
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import { openDetectedFilePath } from './terminal-link-handlers'
import type { RuntimeWorkspaceFileRoute } from '@/lib/runtime-workspace-file-route'
import { activateAndRevealWorkspace } from '@/lib/worktree-activation'
import { downloadAndOpenRemoteTerminalFile } from './terminal-remote-file-download-open'
import { createTerminalLinkTestDoubles } from './terminal-link-handlers-test-fixtures'
@@ -8,6 +9,10 @@ import {
installTerminalLinkTestEnvironment
} from './terminal-link-handlers-test-harness'
const findWorkspaceFileRouteMock = vi.hoisted(() =>
vi.fn<() => RuntimeWorkspaceFileRoute | null>(() => null)
)
const showHintMock = vi.hoisted(() => vi.fn())
const doubles = createTerminalLinkTestDoubles()
const { storeState, statMock, openFileMock, openFilePathMock, runtimeEnvironmentCallMock } = doubles
@@ -30,6 +35,16 @@ vi.mock('./terminal-remote-file-download-open', () => ({
downloadAndOpenRemoteTerminalFile: vi.fn()
}))
// Why: this client's catalog lacks the host's other workspaces unless a spec says otherwise.
vi.mock('@/lib/runtime-workspace-file-route', () => ({
findWorkspaceFileRoute: findWorkspaceFileRouteMock
}))
vi.mock('./terminal-file-other-workspace-hint', () => ({
dismissFileInOtherWorkspaceHint: vi.fn(),
showFileInOtherWorkspaceHint: showHintMock
}))
installTerminalLinkTestEnvironment(doubles)
const featureWorktree = {
@@ -83,6 +98,36 @@ describe('a paired-server link outside its own workspace', () => {
)
})
it('opens a file in a workspace this client knows there, with no read-only view or hint', async () => {
findWorkspaceFileRouteMock.mockReturnValue({
worktreeId: 'wt-main',
relativePath: 'src/index.ts',
executionHostId: 'runtime:env-1'
})
hostAnswers({
worktree: 'wt-main',
relativePath: 'src/index.ts',
absolutePath: '/repo/src/index.ts',
exists: true,
isDirectory: false
})
openDetectedFilePath('/repo/src/index.ts', 3, null, featureWorktree)
await flushDoubleRaf()
// Why: a server reads only by workspace-relative path, so its file opens in its own workspace.
expect(activateAndRevealWorkspace).toHaveBeenCalledWith('wt-main', {
providesInitialSurface: true,
executionHostId: 'runtime:env-1'
})
expect(openFileMock).toHaveBeenCalledWith(
expect.objectContaining({ relativePath: 'src/index.ts', worktreeId: 'wt-main' }),
{ forceContentReload: true }
)
expect(openFileMock.mock.calls[0][0]).not.toHaveProperty('readOnly')
expect(showHintMock).not.toHaveBeenCalled()
})
it("opens the host's spelling of the path so the tab stays a workspace file", async () => {
hostAnswers({
worktree: 'wt-main',
@@ -32,6 +32,11 @@ vi.mock('@/store', () => ({
vi.mock('sonner', () => ({ toast: { error: toastErrorMock } }))
vi.mock('./terminal-file-other-workspace-hint', () => ({
dismissFileInOtherWorkspaceHint: vi.fn(),
showFileInOtherWorkspaceHint: vi.fn()
}))
vi.mock('@/lib/language-detect', () => ({
detectLanguage: () => 'plaintext'
}))
@@ -164,10 +169,9 @@ describe('terminal file links carry their source tab', () => {
expect(toastErrorMock).toHaveBeenCalledTimes(1)
})
it('refuses an explicit request routed to another workspace before activating it', async () => {
it('keeps an explicit request for another workspace file in the source group', async () => {
setPlatform('Macintosh')
seedSourceTab()
storeState.openFiles = [{ filePath: '/other/x.ts', worktreeId: 'wt-2' }]
findWorkspaceFileRouteMock.mockReturnValueOnce({
worktreeId: 'wt-2',
executionHostId: 'local',
@@ -177,9 +181,14 @@ describe('terminal file links carry their source tab', () => {
openDetectedFilePath('/other/x.ts', null, null, { ...sourceDeps, openInSourceGroup: true })
await flushAsyncWork()
expect(activateAndRevealWorkspace).not.toHaveBeenCalled()
expect(openFileForLinkMock).not.toHaveBeenCalled()
expect(toastErrorMock).toHaveBeenCalledTimes(1)
expect(activateAndRevealWorkspace).not.toHaveBeenCalledWith('wt-2', expect.anything())
expect(openFileForLinkMock).toHaveBeenCalledWith(
expect.objectContaining({ filePath: '/other/x.ts', worktreeId: 'wt-1', readOnly: true }),
expect.anything(),
expect.objectContaining({ targetGroupId: 'group-a' }),
expect.any(Function)
)
expect(toastErrorMock).not.toHaveBeenCalled()
})
it('keeps automatic HTML opens on their existing browser route', async () => {
@@ -0,0 +1,173 @@
// @vitest-environment happy-dom
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { Toaster } from 'sonner'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { activateAndRevealWorkspace } from '@/lib/worktree-activation'
import { openDetectedFilePath } from './terminal-link-handlers'
import { createTerminalLinkTestDoubles } from './terminal-link-handlers-test-fixtures'
const findWorkspaceFileRouteMock = vi.hoisted(() => vi.fn())
const doubles = createTerminalLinkTestDoubles()
const { storeState, openFileMock, statMock } = doubles
vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, fallback: string, values?: Record<string, string>) =>
fallback.replace(/\{\{(\w+)\}\}/g, (_match, name: string) => values?.[name] ?? '')
}))
vi.mock('@/store', () => ({ useAppStore: { getState: () => storeState } }))
vi.mock('@/store/selectors', () => ({
selectKnownWorktreeById: (_state: unknown, worktreeId: string) =>
worktreeId === 'wt-b'
? { repoId: 'repo-b', displayName: 'feature-b', branch: '', path: '/b' }
: null
}))
vi.mock('@/lib/language-detect', () => ({ detectLanguage: () => 'plaintext' }))
vi.mock('@/lib/worktree-activation', () => ({
activateAndRevealWorkspace: vi.fn(),
activateAndRevealWorktree: vi.fn()
}))
vi.mock('@/lib/connection-context', () => ({ getConnectionId: vi.fn(() => null) }))
vi.mock('@/lib/runtime-workspace-file-route', () => ({
findWorkspaceFileRoute: findWorkspaceFileRouteMock
}))
const sourceDeps = { worktreeId: 'wt-a', worktreePath: '/a' }
async function settle(): Promise<void> {
await act(async () => {
for (let i = 0; i < 5; i += 1) {
await new Promise((resolve) => setTimeout(resolve, 0))
}
})
}
beforeEach(() => {
vi.clearAllMocks()
storeState.openFiles = []
storeState.repos = [{ id: 'repo-b', displayName: 'project-b' }]
// Records each open as the store does, so the hint sees whether the tab is read-only.
openFileMock.mockImplementation(
(file: { filePath: string; worktreeId: string; readOnly?: boolean }) => {
storeState.openFiles.push({ id: file.filePath, ...file })
}
)
findWorkspaceFileRouteMock.mockReturnValue(null)
statMock.mockResolvedValue({ isDirectory: false })
Object.defineProperty(window, 'api', {
configurable: true,
value: {
shell: { openFilePath: vi.fn().mockResolvedValue(true) },
fs: { stat: statMock, pathExists: vi.fn().mockResolvedValue(true) }
}
})
render(<Toaster />)
})
afterEach(() => {
cleanup()
})
describe('a terminal file link clicked in workspace A', () => {
it('opens a file inside A in A, with no hint', async () => {
openDetectedFilePath('/a/src/main.ts', null, null, sourceDeps)
await settle()
expect(activateAndRevealWorkspace).toHaveBeenCalledTimes(1)
expect(activateAndRevealWorkspace).toHaveBeenCalledWith('wt-a', expect.anything())
expect(openFileMock).toHaveBeenCalledWith(
expect.objectContaining({ relativePath: 'src/main.ts', worktreeId: 'wt-a' }),
{ forceContentReload: true }
)
expect(openFileMock.mock.calls[0][0]).not.toHaveProperty('readOnly')
expect(screen.queryByText(/This file is in/)).toBeNull()
})
it('opens a file workspace B holds in A, and the hint opens it in B', async () => {
findWorkspaceFileRouteMock.mockReturnValue({
worktreeId: 'wt-b',
relativePath: 'report.md',
executionHostId: 'local'
})
openDetectedFilePath('/b/report.md', 7, null, sourceDeps)
await settle()
expect(activateAndRevealWorkspace).toHaveBeenCalledTimes(1)
expect(activateAndRevealWorkspace).toHaveBeenCalledWith('wt-a', expect.anything())
expect(openFileMock).toHaveBeenCalledWith(
expect.objectContaining({
filePath: '/b/report.md',
relativePath: '/b/report.md',
worktreeId: 'wt-a',
readOnly: true
}),
{ forceContentReload: true }
)
// Why the project: worktrees of different projects share names like "main".
expect(screen.getByText('This file is in project-b · feature-b')).not.toBeNull()
expect(screen.getByText('Opened here as read-only')).not.toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Open there' }))
await settle()
expect(activateAndRevealWorkspace).toHaveBeenLastCalledWith('wt-b', {
providesInitialSurface: true,
executionHostId: 'local'
})
expect(openFileMock).toHaveBeenLastCalledWith(
expect.objectContaining({
filePath: '/b/report.md',
relativePath: 'report.md',
worktreeId: 'wt-b',
runtimeEnvironmentId: null
}),
{ forceContentReload: true }
)
expect(openFileMock.mock.lastCall?.[0]).not.toHaveProperty('readOnly')
})
it('keeps the hint of a second link clicked while the first hint shows', async () => {
findWorkspaceFileRouteMock.mockReturnValue({
worktreeId: 'wt-b',
relativePath: 'report.md',
executionHostId: 'local'
})
openDetectedFilePath('/b/report.md', null, null, sourceDeps)
await settle()
openDetectedFilePath('/b/report.md', null, null, sourceDeps)
await settle()
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 300))
})
expect(screen.getByText('This file is in project-b · feature-b')).not.toBeNull()
})
it('shows no read-only hint when the link reused an editable tab here', async () => {
findWorkspaceFileRouteMock.mockReturnValue({
worktreeId: 'wt-b',
relativePath: 'report.md',
executionHostId: 'local'
})
openFileMock.mockImplementationOnce(() => {
storeState.openFiles.push({
id: '/b/report.md',
filePath: '/b/report.md',
worktreeId: 'wt-a'
})
})
openDetectedFilePath('/b/report.md', null, null, sourceDeps)
await settle()
expect(openFileMock).toHaveBeenCalledTimes(1)
expect(screen.queryByText(/This file is in/)).toBeNull()
})
})
@@ -15,7 +15,18 @@ import {
TEST_REPO
} from '@/store/slices/worktrees-slice-test-fixtures'
vi.mock('@/store', () => ({ useAppStore: { getState: vi.fn() } }))
// Why: the catalog says wt1 holds /r/wt1/a.ts once it knows the owner.
const catalog = vi.hoisted(() => ({ knowsOwner: true }))
// Why `repos` tracks the catalog: owner lookups are cached until a route input changes.
vi.mock('@/store', () => ({
useAppStore: { getState: vi.fn(() => ({ repos: catalog.knowsOwner ? 'known' : 'unknown' })) }
}))
vi.mock('@/lib/runtime-workspace-file-route', () => ({
findWorkspaceFileRoute: (_state: unknown, _hostId: string, absolutePath: string) =>
catalog.knowsOwner && absolutePath === '/r/wt1/a.ts'
? { worktreeId: 'wt1', relativePath: 'a.ts', executionHostId: 'local' }
: null
}))
type Repo = EditorExternalWatchTargetState['repos'][number]
type Worktree = EditorExternalWatchTargetState['worktreesByRepo'][string][number]
@@ -109,6 +120,38 @@ describe('background workspace watch trim', () => {
expect(watchedIds(state)).toEqual(['wt0', 'wt2', 'wt3', 'wt4'])
})
it("watches a read-only view of another workspace's file with that workspace", () => {
const state = makeState({
worktrees,
openFiles: [
tab('wt0', {
filePath: '/r/wt1/a.ts',
relativePath: '/r/wt1/a.ts',
readOnly: true
})
],
activeWorktreeId: 'wt0'
})
expect(watchedIds(state)).toEqual(['wt1'])
})
it("re-targets a view's watch once the catalog learns its owner", () => {
const state = makeState({
worktrees,
openFiles: [
tab('wt0', { filePath: '/r/wt1/a.ts', relativePath: '/r/wt1/a.ts', readOnly: true })
],
activeWorktreeId: 'wt0'
})
catalog.knowsOwner = false
try {
expect(watchedIds(state)).toEqual(['wt0'])
} finally {
catalog.knowsOwner = true
}
expect(watchedIds(state)).toEqual(['wt1'])
})
it('does not let a deleted workspace hold a warm slot', () => {
const state = makeState({
worktrees,
@@ -318,6 +318,7 @@ export function scheduleSelfWriteAwareEditorExternalReload(
}
function hasCleanExternalReloadTarget(notification: EditorExternalWatchNotification): boolean {
const matching = getOpenFilesForExternalFileChange(useAppStore.getState().openFiles, notification)
const state = useAppStore.getState()
const matching = getOpenFilesForExternalFileChange(state.openFiles, notification, state)
return matching.some((file) => !file.isDirty)
}
@@ -1,5 +1,7 @@
import { useAppStore } from '@/store'
import { openFileOwnerWorktreeId } from '@/lib/open-file-owner-scope'
import type { OpenFile } from '@/store/slices/editor'
import type { AppState } from '@/store/types'
import { basename } from '@/lib/path'
import { canAutoSaveOpenFile } from '@/components/editor/editor-autosave'
import { indexEditorExternalWatchBatchPaths } from '@/components/editor/editor-external-watch-path-index'
@@ -96,8 +98,10 @@ export function buildEditorExternalWatchEventHandler(
const reconcileTarget = (payload: FsChangedPayload, target: EditorExternalWatchTarget): void => {
// Why: one batch index keeps local WSL alias normalization out of event×tab loops.
const openFilesAtStart = useAppStore.getState().openFiles
const stateAtStart = useAppStore.getState()
const openFilesAtStart = stateAtStart.openFiles
const batchPaths = indexEditorExternalWatchBatchPaths(payload, openFilesAtStart, {
catalog: stateAtStart,
worktreeId: target.worktreeId,
worktreePath: target.worktreePath,
runtimeEnvironmentId: target.runtimeEnvironmentId,
@@ -125,7 +129,7 @@ export function buildEditorExternalWatchEventHandler(
const deletedOpenEditorIds = deletedOpenEditors.map(({ file }) => file.id)
const hasPairedCreate =
deletedOpenEditorIds.length > 0 &&
hasRenameCorrelatedCreate(payload, target.worktreeId, deletedOpenEditorIds, openFilesAtStart)
hasRenameCorrelatedCreate(payload, target.worktreeId, deletedOpenEditorIds, stateAtStart)
if (deletedOpenEditorIds.length > 0) {
if (hasPairedCreate) {
const setExternalMutation = useAppStore.getState().setExternalMutation
@@ -165,7 +169,7 @@ export function buildEditorExternalWatchEventHandler(
const state = useAppStore.getState()
for (const file of state.openFiles) {
if (
file.worktreeId === target.worktreeId &&
openFileOwnerWorktreeId(file, state) === target.worktreeId &&
getOpenFileRuntimeOwner(file) === target.runtimeEnvironmentId &&
(file.mode === 'edit' || file.mode === 'markdown-preview') &&
(file.externalMutation === 'deleted' || file.externalMutation === 'renamed') &&
@@ -262,16 +266,16 @@ function hasRenameCorrelatedCreate(
payload: FsChangedPayload,
worktreeId: string,
deletedOpenEditorIds: string[],
openFiles: OpenFile[]
state: AppState
): boolean {
if (deletedOpenEditorIds.length === 0) {
return false
}
const deletedIdSet = new Set(deletedOpenEditorIds)
const deletedBasenames = new Set<string>()
for (const file of openFiles) {
for (const file of state.openFiles) {
if (
file.worktreeId !== worktreeId ||
openFileOwnerWorktreeId(file, state) !== worktreeId ||
(file.mode !== 'edit' && file.mode !== 'markdown-preview') ||
!deletedIdSet.has(file.id)
) {
@@ -0,0 +1,101 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { OpenFile } from '@/store/slices/editor'
import { useAppStore } from '@/store'
import { createTestStore } from '@/store/slices/store-test-helpers'
import { buildEditorExternalWatchEventHandler } from './editor-external-watch-event-reconciliation'
import { indexEditorExternalWatchBatchPaths } from '@/components/editor/editor-external-watch-path-index'
vi.mock('@/store', () => ({ useAppStore: { getState: vi.fn() } }))
// Why: the catalog says workspace wt-b (root /b) holds /b/report.md.
vi.mock('@/lib/runtime-workspace-file-route', () => ({
findWorkspaceFileRoute: (_state: unknown, _hostId: string, absolutePath: string) =>
absolutePath === '/b/report.md'
? { worktreeId: 'wt-b', relativePath: 'report.md', executionHostId: 'local' }
: null
}))
const setExternalMutation = vi.fn()
const ownerTarget = {
worktreeId: 'wt-b',
worktreePath: '/b',
connectionId: undefined,
runtimeEnvironmentId: null
}
/** A read-only view in workspace A of B's file, as a session restore draws it: nothing extra stored. */
function restoredView(extra: Partial<OpenFile> = {}): OpenFile {
return {
id: 'view',
worktreeId: 'wt-a',
filePath: '/b/report.md',
relativePath: '/b/report.md',
language: 'markdown',
mode: 'edit',
isDirty: false,
readOnly: true,
...extra
}
}
function useOpenFiles(openFiles: OpenFile[]): void {
vi.mocked(useAppStore.getState).mockReturnValue({
...createTestStore().getState(),
openFiles,
setExternalMutation
})
}
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
describe("a read-only view of another workspace's file", () => {
it('matches the owning workspace watch after a restore', () => {
const view = restoredView()
useOpenFiles([view])
const index = indexEditorExternalWatchBatchPaths(
{ worktreePath: '/b', events: [{ kind: 'update', absolutePath: '/b/report.md' }] },
[view],
{ ...ownerTarget, catalog: useAppStore.getState() }
)
expect(index.matchingOpenFiles(index.changes[0])).toEqual([view])
})
it('clears its deleted mark when the owner recreates the file', () => {
useOpenFiles([restoredView({ externalMutation: 'deleted' })])
const { handleFsChanged, dispose } = buildEditorExternalWatchEventHandler((path) =>
path === '/b' ? ownerTarget : undefined
)
handleFsChanged({
worktreePath: '/b',
events: [{ kind: 'create', absolutePath: '/b/report.md' }]
})
expect(setExternalMutation).toHaveBeenCalledWith('view', null)
dispose()
})
it('is marked deleted when the owner deletes the file', () => {
useOpenFiles([restoredView()])
const { handleFsChanged, dispose } = buildEditorExternalWatchEventHandler((path) =>
path === '/b' ? ownerTarget : undefined
)
handleFsChanged({
worktreePath: '/b',
events: [{ kind: 'delete', absolutePath: '/b/report.md' }]
})
vi.runAllTimers()
expect(setExternalMutation).toHaveBeenCalledWith('view', 'deleted')
dispose()
})
})
@@ -18,6 +18,13 @@ vi.mock('@/runtime/runtime-path-existence-batch', () => ({
vi.mock('@/lib/file-owner-runtime-target', () => ({
getRuntimeTargetForFileOwner: () => ({ kind: 'local' })
}))
// Why: the catalog says wt-1 (root /repo, this test's watch target) holds docs/report.md.
vi.mock('@/lib/runtime-workspace-file-route', () => ({
findWorkspaceFileRoute: (_state: unknown, _hostId: string, absolutePath: string) =>
absolutePath === '/repo/docs/report.md'
? { worktreeId: 'wt-1', relativePath: 'docs/report.md', executionHostId: 'local' }
: null
}))
vi.mock('./editor-external-watch-disk-verification', () => ({
scheduleDebouncedEditorExternalReload: harness.scheduleReload,
scheduleEditorSelfMoveEchoVerification: harness.compareDirty
@@ -126,6 +133,22 @@ describe('reconcileEditorTabsAfterOverflow', () => {
expect(harness.scheduleReload).not.toHaveBeenCalled()
})
it("re-reads another workspace's read-only view of the watched workspace's file", async () => {
setTabs(
tab('view', {
worktreeId: 'wt-other',
filePath: '/repo/docs/report.md',
relativePath: '/repo/docs/report.md',
readOnly: true
})
)
harness.runtimePathsExist.mockResolvedValue([{ exists: true }])
reconcileEditorTabsAfterOverflow(target, true)
await vi.waitFor(() => expect(harness.scheduleReload).toHaveBeenCalledTimes(1))
expect(harness.scheduleReload).toHaveBeenCalledWith(notification('docs/report.md'))
})
it('a storm overflow reloads clean tabs without probing or dropping combined bodies', () => {
setTabs(
tab('a.md', { externalMutation: 'deleted' }),
@@ -1,4 +1,5 @@
import { useAppStore } from '@/store'
import { openFileOwnerScope } from '@/lib/open-file-owner-scope'
import type { OpenFile } from '@/store/slices/editor'
import type { EditorExternalWatchBatchPathIndex } from '@/components/editor/editor-external-watch-path-index'
import { isExternalReloadableEditorTab } from '@/components/editor/editor-autosave'
@@ -88,8 +89,9 @@ export function reconcileEditorTabsAfterOverflow(target: OverflowTarget, resync:
const state = useAppStore.getState()
const probes: EditTabProbe[] = []
for (const file of state.openFiles) {
const ownerScope = openFileOwnerScope(file, state)
if (
file.worktreeId !== target.worktreeId ||
ownerScope.worktreeId !== target.worktreeId ||
getOpenFileRuntimeOwner(file) !== (target.runtimeEnvironmentId ?? null) ||
!isExternalReloadableEditorTab(file)
) {
@@ -114,7 +116,7 @@ export function reconcileEditorTabsAfterOverflow(target: OverflowTarget, resync:
const notification: EditorExternalWatchNotification = {
worktreeId: target.worktreeId,
worktreePath: target.worktreePath,
relativePath: file.relativePath,
relativePath: ownerScope.relativePath,
runtimeEnvironmentId: target.runtimeEnvironmentId ?? null,
...getLocalWindowsWslAliasOption(target)
}
@@ -1,4 +1,5 @@
import type { AppState } from '@/store'
import { useAppStore, type AppState } from '@/store'
import { openFileOwnerWorktreeId } from '@/lib/open-file-owner-scope'
import type { OpenFile } from '@/store/slices/editor'
import { findWorktreeById } from '@/store/slices/worktree-helpers'
import { isVirtualEditorFile } from '@/store/slices/editor/tabs/editor-tab-content-type'
@@ -57,6 +58,7 @@ type WatchedTargetsSnapshot = {
unwatchedTargetsKey: string
}
let cachedOwnersKey = ''
let cachedOpenFiles: AppState['openFiles'] | null = null
let cachedWorktreesByRepo: AppState['worktreesByRepo'] | null = null
let cachedRepos: AppState['repos'] | null = null
@@ -149,7 +151,19 @@ export function selectEditorExternalWatchTargets(
): WatchedTargetsSnapshot {
const runtimeEnvironmentId = state.settings?.activeRuntimeEnvironmentId?.trim() || undefined
const activeWorkspaceExecutionHostId = state.activeWorkspaceExecutionHostId ?? null
// Why the store: this selector's narrowed state lacks the catalog the owner lookup reads, and
// the owners join the cache key because catalog fields outside it can move them.
const catalog = useAppStore.getState()
const watchWorktreeIds = new Map<string, string>()
for (const file of state.openFiles) {
const owner = openFileOwnerWorktreeId(file, catalog)
if (owner !== file.worktreeId) {
watchWorktreeIds.set(file.id, owner)
}
}
const ownersKey = [...watchWorktreeIds].join('|')
if (
cachedOwnersKey === ownersKey &&
cachedOpenFiles === state.openFiles &&
cachedWorktreesByRepo === selectWorktreesByRepo(state) &&
cachedRepos === state.repos &&
@@ -187,7 +201,11 @@ export function selectEditorExternalWatchTargets(
const consumers = watchEligibleIds.has(file.worktreeId)
? consumersByWorktreeId
: unwatchedConsumersByWorktreeId
addWatchConsumer(consumers, file.worktreeId, getOpenFileWatchConsumer(file))
addWatchConsumer(
consumers,
watchWorktreeIds.get(file.id) ?? file.worktreeId,
getOpenFileWatchConsumer(file)
)
}
const activeWorktreeId = state.activeWorktreeId
const activeWorktree = activeWorktreeId
@@ -238,6 +256,7 @@ export function selectEditorExternalWatchTargets(
const nextTargets = sortedEntries.map(([, target]) => target)
const targetsKey = sortedEntries.map(([key]) => key).join('|')
const unwatchedTargetsKey = [...unwatchedTargetKeys].sort().join('|')
cachedOwnersKey = ownersKey
cachedOpenFiles = state.openFiles
cachedWorktreesByRepo = selectWorktreesByRepo(state)
cachedRepos = state.repos
+6 -1
View File
@@ -3665,7 +3665,11 @@
"downloadOpenWithDefaultApp": "Download & open with default app",
"downloadOpenFailed": "Failed to download '{{value0}}'.",
"openInThisGroup": "Open in this group",
"openInThisGroupRefused": "Couldn't open {{value0}} in this group"
"openInThisGroupRefused": "Couldn't open {{value0}} in this group",
"fileInOtherWorkspace": "This file is in {{value0}}",
"fileInAnotherWorkspace": "This file is in another workspace",
"openInOwningWorkspace": "Open there",
"openedHereReadOnly": "Opened here as read-only"
},
"TerminalSessionStateSaveFailureDialog": {
"6bee0c8f17": "Open Disk Space Analyzer",
@@ -16226,6 +16230,7 @@
"6c4f1a8d2e": "Check details are unavailable."
},
"EditorPanelHeader": {
"readOnlyBadge": "Read-only",
"fb8331694e": "Open Preview to the Side",
"4157f3cbf3": "Open Markdown Preview",
"269ce4842b": "Copy Relative Path",
+6 -1
View File
@@ -3550,7 +3550,11 @@
"downloadOpenWithDefaultApp": "Descargar y abrir con la aplicación predeterminada",
"downloadOpenFailed": "No se pudo descargar '{{value0}}'.",
"openInThisGroup": "Abrir en este grupo",
"openInThisGroupRefused": "No se pudo abrir {{value0}} en este grupo"
"openInThisGroupRefused": "No se pudo abrir {{value0}} en este grupo",
"fileInOtherWorkspace": "Este archivo está en {{value0}}",
"fileInAnotherWorkspace": "Este archivo está en otro espacio de trabajo",
"openInOwningWorkspace": "Abrir allí",
"openedHereReadOnly": "Abierto aquí en modo de solo lectura"
},
"TerminalQuickCommandsSubmenu": {
"3ccc7981bb": "Anfitrión no disponible",
@@ -15936,6 +15940,7 @@
"6c4f1a8d2e": "Los detalles del check no están disponibles."
},
"EditorPanelHeader": {
"readOnlyBadge": "Solo lectura",
"fb8331694e": "Abrir vista previa al lado",
"4157f3cbf3": "Abrir vista previa de Markdown",
"269ce4842b": "Copiar ruta relativa",
+6 -1
View File
@@ -3553,7 +3553,11 @@
"downloadOpenWithDefaultApp": "Téléchargez et ouvrez avec l'application par défaut",
"downloadOpenFailed": "Échec du téléchargement de « {{value0}} ».",
"openInThisGroup": "Ouvrir dans ce groupe",
"openInThisGroupRefused": "Impossible d'ouvrir {{value0}} dans ce groupe"
"openInThisGroupRefused": "Impossible d'ouvrir {{value0}} dans ce groupe",
"fileInOtherWorkspace": "Ce fichier se trouve dans {{value0}}",
"fileInAnotherWorkspace": "Ce fichier se trouve dans un autre espace de travail",
"openInOwningWorkspace": "Ouvrir là-bas",
"openedHereReadOnly": "Ouvert ici en lecture seule"
},
"TerminalSessionStateSaveFailureDialog": {
"6bee0c8f17": "Ouvrir l'Analyseur d'espace disque",
@@ -16080,6 +16084,7 @@
"6c4f1a8d2e": "Les détails de la vérification sont indisponibles."
},
"EditorPanelHeader": {
"readOnlyBadge": "Lecture seule",
"fb8331694e": "Ouvrir l'aperçu sur le côté",
"4157f3cbf3": "Ouvrir l'aperçu Markdown",
"269ce4842b": "Copier le chemin relatif",
+6 -1
View File
@@ -3532,7 +3532,11 @@
"downloadOpenWithDefaultApp": "ダウンロードしてデフォルトのアプリで開く",
"downloadOpenFailed": "「{{value0}}」のダウンロードに失敗しました。",
"openInThisGroup": "このグループで開く",
"openInThisGroupRefused": "{{value0}} をこのグループで開けませんでした"
"openInThisGroupRefused": "{{value0}} をこのグループで開けませんでした",
"fileInOtherWorkspace": "このファイルは {{value0}} にあります",
"fileInAnotherWorkspace": "このファイルは別のワークスペースにあります",
"openInOwningWorkspace": "そこで開く",
"openedHereReadOnly": "ここでは読み取り専用で開いています"
},
"TerminalQuickCommandsSubmenu": {
"3ccc7981bb": "ホストが利用不可",
@@ -15920,6 +15924,7 @@
"6c4f1a8d2e": "チェック詳細を利用できません。"
},
"EditorPanelHeader": {
"readOnlyBadge": "読み取り専用",
"fb8331694e": "プレビューを横に開く",
"4157f3cbf3": "Markdown プレビューを開く",
"269ce4842b": "相対パスをコピー",
+6 -1
View File
@@ -3532,7 +3532,11 @@
"downloadOpenWithDefaultApp": "기본 앱 다운로드 및 열기",
"downloadOpenFailed": "'{{value0}}'을(를) 다운로드하지 못했습니다.",
"openInThisGroup": "이 그룹에서 열기",
"openInThisGroupRefused": "이 그룹에서 {{value0}}을(를) 열 수 없습니다"
"openInThisGroupRefused": "이 그룹에서 {{value0}}을(를) 열 수 없습니다",
"fileInOtherWorkspace": "이 파일은 {{value0}}에 있습니다",
"fileInAnotherWorkspace": "이 파일은 다른 워크스페이스에 있습니다",
"openInOwningWorkspace": "거기서 열기",
"openedHereReadOnly": "여기서는 읽기 전용으로 열렸습니다"
},
"TerminalQuickCommandsSubmenu": {
"3ccc7981bb": "호스트를 사용할 수 없음",
@@ -15920,6 +15924,7 @@
"6c4f1a8d2e": "체크 세부 정보를 사용할 수 없습니다."
},
"EditorPanelHeader": {
"readOnlyBadge": "읽기 전용",
"fb8331694e": "측면으로 미리보기 열기",
"4157f3cbf3": "Markdown 미리보기 열기",
"269ce4842b": "상대 경로 복사",
+6 -1
View File
@@ -12200,6 +12200,7 @@
"759463a221": "Não é possível abrir o diretório: {{value0}}"
},
"EditorPanelHeader": {
"readOnlyBadge": "Somente leitura",
"fb8331694e": "Abrir Pré-visualização ao Lado",
"4157f3cbf3": "Abrir Pré-visualização de Markdown",
"269ce4842b": "Copiar Caminho Relativo",
@@ -15515,7 +15516,11 @@
"downloadOpenWithDefaultApp": "Baixar e abrir com o aplicativo padrão",
"downloadOpenFailed": "Falha ao baixar '{{value0}}'.",
"openInThisGroup": "Abrir neste grupo",
"openInThisGroupRefused": "Não foi possível abrir {{value0}} neste grupo"
"openInThisGroupRefused": "Não foi possível abrir {{value0}} neste grupo",
"fileInOtherWorkspace": "Este arquivo está em {{value0}}",
"fileInAnotherWorkspace": "Este arquivo está em outro espaço de trabalho",
"openInOwningWorkspace": "Abrir lá",
"openedHereReadOnly": "Aberto aqui como somente leitura"
},
"MobileDriverOverlay": {
"c6460cf584": "Retomar",
+6 -1
View File
@@ -3532,7 +3532,11 @@
"downloadOpenWithDefaultApp": "下载并使用默认应用程序打开",
"downloadOpenFailed": "无法下载“{{value0}}”。",
"openInThisGroup": "在此分组中打开",
"openInThisGroupRefused": "无法在此分组中打开 {{value0}}"
"openInThisGroupRefused": "无法在此分组中打开 {{value0}}",
"fileInOtherWorkspace": "此文件位于 {{value0}}",
"fileInAnotherWorkspace": "此文件位于另一个工作区",
"openInOwningWorkspace": "在那里打开",
"openedHereReadOnly": "已在此处以只读方式打开"
},
"TerminalQuickCommandsSubmenu": {
"3ccc7981bb": "主机不可用",
@@ -15920,6 +15924,7 @@
"6c4f1a8d2e": "检查详情不可用。"
},
"EditorPanelHeader": {
"readOnlyBadge": "只读",
"fb8331694e": "打开侧面预览",
"4157f3cbf3": "打开 Markdown 预览",
"269ce4842b": "复制相对路径",
@@ -0,0 +1,63 @@
import type { OpenFile } from '@/store/slices/editor'
import type { AppState } from '@/store/types'
import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host'
import { isFloatingWorkspaceId } from '../../../shared/floating-workspace-worktree'
import { findWorkspaceFileRoute } from './runtime-workspace-file-route'
import { editorBufferRouteEpochOf } from '@/store/slices/editor/buffers/editor-buffer-identity-cache'
type OwnerScopedFile = Pick<
OpenFile,
| 'worktreeId'
| 'filePath'
| 'relativePath'
| 'readOnly'
| 'liveTail'
| 'runtimeEnvironmentId'
| 'terminalArtifactGrantId'
| 'externalSshTargetId'
>
type OwnerScope = { worktreeId: string; relativePath: string }
const ownerScopeCache = new WeakMap<object, { epoch: object; scope: OwnerScope }>()
/**
* The workspace whose watch covers an open file. A read-only view of a file another workspace on
* this computer holds (#32158) lives in one workspace but is watched with the one that holds the
* file, derived from the catalog so a restored view needs nothing stored.
*/
export function openFileOwnerScope(file: OwnerScopedFile, state: AppState): OwnerScope {
const own = { worktreeId: file.worktreeId, relativePath: file.relativePath }
// Why: only a read-only absolute view may sit outside the workspace holding its file; a live-tail
// log follows itself and must not pull in a recursive watch of a folder that holds it.
if (
file.readOnly !== true ||
file.liveTail === true ||
file.relativePath !== file.filePath ||
file.terminalArtifactGrantId ||
// Why: only this computer's files are viewed outside their workspace; an SSH or server
// tab naming another workspace's path is moved there by the loader instead.
file.externalSshTargetId ||
file.runtimeEnvironmentId?.trim() ||
isFloatingWorkspaceId(file.worktreeId)
) {
return own
}
// Why cached: watch selectors run on every store update; the catalog walk only on route changes.
const epoch = editorBufferRouteEpochOf(state)
const cached = ownerScopeCache.get(file)
if (cached?.epoch === epoch) {
return cached.scope
}
const route = findWorkspaceFileRoute(state, LOCAL_EXECUTION_HOST_ID, file.filePath)
const scope = route ? { worktreeId: route.worktreeId, relativePath: route.relativePath } : own
ownerScopeCache.set(file, { epoch, scope })
return scope
}
/** The workspace an open file is scoped to; without a catalog, the one it lives in. */
export function openFileOwnerWorktreeId(
file: OwnerScopedFile,
state: AppState | undefined
): string {
return state ? openFileOwnerScope(file, state).worktreeId : file.worktreeId
}
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest'
import { buildMobileSessionTabSnapshots } from './sync-runtime-graph/mobile-session-snapshots'
import { makeState } from './sync-runtime-graph-test-harness'
describe('mobile file tabs naming a file outside their workspace', () => {
it.each([
['a plain file', 'plaintext', []],
['a Markdown file', 'markdown', ['markdown']]
] as const)('publish %s only when the phone can read it', (_label, language, types) => {
const id = '/other/report.txt'
const state = makeState({
browserTabsByWorktree: {},
tabBarOrderByWorktree: { 'wt-1': [id] },
activeFileId: id,
activeFileIdByWorktree: { 'wt-1': id },
activeTabType: 'editor',
activeTabTypeByWorktree: { 'wt-1': 'editor' },
openFiles: [
{
id,
filePath: id,
relativePath: id,
worktreeId: 'wt-1',
language,
mode: 'edit',
readOnly: true,
isDirty: false
}
]
})
const snapshot = buildMobileSessionTabSnapshots(state)[0]
expect(snapshot?.tabs.map((tab) => tab.type)).toEqual(types)
})
})
@@ -79,12 +79,19 @@ export function isMobileFileDiffSource(
export function isMobilePublishableOpenFile(file: AppState['openFiles'][number]): boolean {
// Combined diff tabs use display labels as paths and need the desktop renderer.
return !(
if (
file.diffSource === 'combined-all' ||
file.diffSource === 'combined-uncommitted' ||
file.diffSource === 'combined-branch' ||
file.diffSource === 'combined-commit'
)
) {
return false
}
// Why: a phone reads a plain file tab by its workspace-relative path; a tab naming a file
// outside its workspace (an external file, or another workspace's file viewed here, #32158)
// has none. Markdown tabs are read through the desktop's tab instead, so they still show.
const isMarkdown = file.language === 'markdown' || file.mode === 'markdown-preview'
return isMarkdown || file.relativePath !== file.filePath
}
/**
+28 -15
View File
@@ -202,7 +202,7 @@ test('opens a terminal file link and observes an external edit @golden', async (
}
})
test('reuses a terminal file link already open in a sibling workspace @golden', async ({
test('opens a sibling workspace file here read-only, and the hint opens it there @golden', async ({
orcaPage
}) => {
test.setTimeout(180_000)
@@ -298,20 +298,33 @@ test('reuses a terminal file link already open in a sibling workspace @golden',
const editorHeader = orcaPage.locator('.editor-header-path').first()
await expect(editorHeader).toContainText('package.json', { timeout: 20_000 })
const renderedFile = (): Promise<{ filePath: string; activeWorktreeId: string | null }> =>
orcaPage
.evaluate(() => ({
filePath: window.__monacoEditorE2E?.filePath ?? '',
activeWorktreeId: window.__store?.getState()?.activeWorktreeId ?? null
}))
.then((rendered) => ({ ...rendered, filePath: canonicalFileIdentity(rendered.filePath) }))
// #32158: the click keeps the user in the workspace whose terminal printed the link.
await expect
.poll(
async () => {
const rendered = await orcaPage.evaluate(() => ({
filePath: window.__monacoEditorE2E?.filePath ?? '',
activeWorktreeId: window.__store?.getState()?.activeWorktreeId ?? null
}))
return {
filePath: canonicalFileIdentity(rendered.filePath),
activeWorktreeId: rendered.activeWorktreeId
}
},
{ timeout: 20_000, message: 'sibling workspace never rendered the linked file' }
)
.toEqual({ filePath: canonicalFileIdentity(filePath), activeWorktreeId: sibling.id })
.poll(renderedFile, { timeout: 20_000, message: 'the linked file never rendered here' })
.toEqual({ filePath: canonicalFileIdentity(filePath), activeWorktreeId: sourceWorktreeId })
await expect(orcaPage.getByText('Loading...', { exact: true })).toHaveCount(0)
const sourceTabReadOnly = await orcaPage.evaluate(
({ filePath, sourceWorktreeId }) =>
window.__store
?.getState()
.openFiles.find(
(file) => file.worktreeId === sourceWorktreeId && file.filePath === filePath
)?.readOnly === true,
{ filePath, sourceWorktreeId }
)
expect(sourceTabReadOnly).toBe(true)
const hint = orcaPage.locator('[data-sonner-toast]').filter({ hasText: 'This file is in' })
await expect(hint).toBeVisible()
await hint.getByRole('button', { name: 'Open there' }).click()
await expect
.poll(renderedFile, { timeout: 20_000, message: 'the hint never opened the file there' })
.toEqual({ filePath: canonicalFileIdentity(filePath), activeWorktreeId: sibling.id })
})