From 359fec1a09f78198a8b0213e7bad53d62a2f7f00 Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Fri, 23 Oct 2026 18:14:13 -0700
Subject: [PATCH] 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.
---
.../editor/EditorPanelHeaderPath.tsx | 6 +
.../editor/editor-autosave-holds.ts | 2 +-
.../src/components/editor/editor-autosave.ts | 7 +-
.../editor/editor-buffer-adapter.ts | 2 +-
.../editor-external-change-tab-reset.ts | 2 +-
.../editor-external-watch-path-index.ts | 6 +-
...torPanelRemoteSiblingContentState.test.tsx | 29 +++
.../terminal-file-link-owner-resolution.ts | 75 ++++++++
.../terminal-file-open-routing.ts | 111 +++++------
...terminal-file-other-workspace-hint.test.ts | 33 ++++
.../terminal-file-other-workspace-hint.ts | 111 +++++++++++
.../terminal-file-other-workspace-owner.ts | 37 ++++
.../terminal-link-file-open-routing.test.ts | 76 ++++++--
.../terminal-link-handlers-test-fixtures.ts | 12 +-
.../terminal-link-host-workspace-file.test.ts | 45 +++++
.../terminal-link-open-beside-source.test.ts | 19 +-
...-link-other-workspace-hint.sonner.test.tsx | 173 ++++++++++++++++++
...tor-external-watch-background-trim.test.ts | 45 ++++-
...editor-external-watch-disk-verification.ts | 3 +-
...tor-external-watch-event-reconciliation.ts | 16 +-
...xternal-watch-other-workspace-view.test.ts | 101 ++++++++++
.../editor-external-watch-overflow.test.ts | 23 +++
.../hooks/editor-external-watch-overflow.ts | 6 +-
.../hooks/editor-external-watch-targets.ts | 23 ++-
src/renderer/src/i18n/locales/en.json | 7 +-
src/renderer/src/i18n/locales/es.json | 7 +-
src/renderer/src/i18n/locales/fr.json | 7 +-
src/renderer/src/i18n/locales/ja.json | 7 +-
src/renderer/src/i18n/locales/ko.json | 7 +-
src/renderer/src/i18n/locales/pt.json | 7 +-
src/renderer/src/i18n/locales/zh.json | 7 +-
src/renderer/src/lib/open-file-owner-scope.ts | 63 +++++++
...-graph-outside-workspace-file-tabs.test.ts | 36 ++++
.../mobile-session-surfaces.ts | 11 +-
tests/e2e/golden-terminal-file-link.spec.ts | 43 +++--
35 files changed, 1036 insertions(+), 129 deletions(-)
create mode 100644 src/renderer/src/components/terminal-pane/terminal-file-link-owner-resolution.ts
create mode 100644 src/renderer/src/components/terminal-pane/terminal-file-other-workspace-hint.test.ts
create mode 100644 src/renderer/src/components/terminal-pane/terminal-file-other-workspace-hint.ts
create mode 100644 src/renderer/src/components/terminal-pane/terminal-file-other-workspace-owner.ts
create mode 100644 src/renderer/src/components/terminal-pane/terminal-link-other-workspace-hint.sonner.test.tsx
create mode 100644 src/renderer/src/hooks/editor-external-watch-other-workspace-view.test.ts
create mode 100644 src/renderer/src/lib/open-file-owner-scope.ts
create mode 100644 src/renderer/src/runtime/sync-runtime-graph-outside-workspace-file-tabs.test.ts
diff --git a/src/renderer/src/components/editor/EditorPanelHeaderPath.tsx b/src/renderer/src/components/editor/EditorPanelHeaderPath.tsx
index b3b5ce617e5..7e2033e69e9 100644
--- a/src/renderer/src/components/editor/EditorPanelHeaderPath.tsx
+++ b/src/renderer/src/components/editor/EditorPanelHeaderPath.tsx
@@ -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({
{displayPath.fileName}
)}
+ {!isRenaming && activeFile.readOnly === true && (
+
+ {translate('auto.components.editor.EditorPanelHeader.readOnlyBadge', 'Read-only')}
+
+ )}
{/* 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. */}
diff --git a/src/renderer/src/components/editor/editor-autosave-holds.ts b/src/renderer/src/components/editor/editor-autosave-holds.ts
index e06760c6fff..87c0086b299 100644
--- a/src/renderer/src/components/editor/editor-autosave-holds.ts
+++ b/src/renderer/src/components/editor/editor-autosave-holds.ts
@@ -55,7 +55,7 @@ export function pathMutationHoldKeys(
target: EditorPathMutationTarget
): Set {
const keys = new Set(
- getOpenFilesForExternalFileChange(state.openFiles, target).map((file) =>
+ getOpenFilesForExternalFileChange(state.openFiles, target, state).map((file) =>
editorFileHoldKey(state, file)
)
)
diff --git a/src/renderer/src/components/editor/editor-autosave.ts b/src/renderer/src/components/editor/editor-autosave.ts
index a70e8eefd08..927e3ce085a 100644
--- a/src/renderer/src/components/editor/editor-autosave.ts
+++ b/src/renderer/src/components/editor/editor-autosave.ts
@@ -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 (
diff --git a/src/renderer/src/components/editor/editor-buffer-adapter.ts b/src/renderer/src/components/editor/editor-buffer-adapter.ts
index 704fc84e5db..2b3004f6167 100644
--- a/src/renderer/src/components/editor/editor-buffer-adapter.ts
+++ b/src/renderer/src/components/editor/editor-buffer-adapter.ts
@@ -84,7 +84,7 @@ export function externalChangeRecordViewIds(
target: EditorPathMutationTarget
): Set {
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)
diff --git a/src/renderer/src/components/editor/editor-external-change-tab-reset.ts b/src/renderer/src/components/editor/editor-external-change-tab-reset.ts
index de05c0b4dc2..6c7c3ca6f38 100644
--- a/src/renderer/src/components/editor/editor-external-change-tab-reset.ts
+++ b/src/renderer/src/components/editor/editor-external-change-tab-reset.ts
@@ -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
}
diff --git a/src/renderer/src/components/editor/editor-external-watch-path-index.ts b/src/renderer/src/components/editor/editor-external-watch-path-index.ts
index 3ce5f2a432b..9fb64e813f3 100644
--- a/src/renderer/src/components/editor/editor-external-watch-path-index.ts
+++ b/src/renderer/src/components/editor/editor-external-watch-path-index.ts
@@ -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
diff --git a/src/renderer/src/components/editor/useEditorPanelRemoteSiblingContentState.test.tsx b/src/renderer/src/components/editor/useEditorPanelRemoteSiblingContentState.test.tsx
index 8bef9d62b8e..700c6a28384 100644
--- a/src/renderer/src/components/editor/useEditorPanelRemoteSiblingContentState.test.tsx
+++ b/src/renderer/src/components/editor/useEditorPanelRemoteSiblingContentState.test.tsx
@@ -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())
+
+ // 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',
diff --git a/src/renderer/src/components/terminal-pane/terminal-file-link-owner-resolution.ts b/src/renderer/src/components/terminal-pane/terminal-file-link-owner-resolution.ts
new file mode 100644
index 00000000000..a671413e44e
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/terminal-file-link-owner-resolution.ts
@@ -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 {
+ 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 }
+ }
+}
diff --git a/src/renderer/src/components/terminal-pane/terminal-file-open-routing.ts b/src/renderer/src/components/terminal-pane/terminal-file-open-routing.ts
index 7dd43859a78..06c8aab0c7d 100644
--- a/src/renderer/src/components/terminal-pane/terminal-file-open-routing.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-file-open-routing.ts
@@ -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 }
+ })
}
})()
}
diff --git a/src/renderer/src/components/terminal-pane/terminal-file-other-workspace-hint.test.ts b/src/renderer/src/components/terminal-pane/terminal-file-other-workspace-hint.test.ts
new file mode 100644
index 00000000000..972b801e673
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/terminal-file-other-workspace-hint.test.ts
@@ -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)
+ })
+})
diff --git a/src/renderer/src/components/terminal-pane/terminal-file-other-workspace-hint.ts b/src/renderer/src/components/terminal-pane/terminal-file-other-workspace-hint.ts
new file mode 100644
index 00000000000..f3e3d9cf2db
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/terminal-file-other-workspace-hint.ts
@@ -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
+ }
+ )
+}
diff --git a/src/renderer/src/components/terminal-pane/terminal-file-other-workspace-owner.ts b/src/renderer/src/components/terminal-pane/terminal-file-other-workspace-owner.ts
new file mode 100644
index 00000000000..73103741f70
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/terminal-file-other-workspace-owner.ts
@@ -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 } : {}
+}
diff --git a/src/renderer/src/components/terminal-pane/terminal-link-file-open-routing.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-file-open-routing.test.ts
index 5df3efdc217..1daf4249f6b 100644
--- a/src/renderer/src/components/terminal-pane/terminal-link-file-open-routing.test.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-link-file-open-routing.test.ts
@@ -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 () => {
diff --git a/src/renderer/src/components/terminal-pane/terminal-link-handlers-test-fixtures.ts b/src/renderer/src/components/terminal-pane/terminal-link-handlers-test-fixtures.ts
index 47a1bc8d6f9..adfe35259bc 100644
--- a/src/renderer/src/components/terminal-pane/terminal-link-handlers-test-fixtures.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-link-handlers-test-fixtures.ts
@@ -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
- openFiles: { filePath: string; worktreeId: string }[]
+ openFiles: TerminalLinkOpenFile[]
groupsByWorktree: Record
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,
- openFiles: [] as { filePath: string; worktreeId: string }[],
+ openFiles: [],
unifiedTabsByWorktree: {},
groupsByWorktree: {},
worktreesByRepo: {},
diff --git a/src/renderer/src/components/terminal-pane/terminal-link-host-workspace-file.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-host-workspace-file.test.ts
index c2ce28b467e..ce8a9d7d30c 100644
--- a/src/renderer/src/components/terminal-pane/terminal-link-host-workspace-file.test.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-link-host-workspace-file.test.ts
@@ -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',
diff --git a/src/renderer/src/components/terminal-pane/terminal-link-open-beside-source.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-open-beside-source.test.ts
index 1af0d9ab0d6..bc6d0ff54dc 100644
--- a/src/renderer/src/components/terminal-pane/terminal-link-open-beside-source.test.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-link-open-beside-source.test.ts
@@ -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 () => {
diff --git a/src/renderer/src/components/terminal-pane/terminal-link-other-workspace-hint.sonner.test.tsx b/src/renderer/src/components/terminal-pane/terminal-link-other-workspace-hint.sonner.test.tsx
new file mode 100644
index 00000000000..d2905aa0d35
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/terminal-link-other-workspace-hint.sonner.test.tsx
@@ -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) =>
+ 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 {
+ 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()
+})
+
+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()
+ })
+})
diff --git a/src/renderer/src/hooks/editor-external-watch-background-trim.test.ts b/src/renderer/src/hooks/editor-external-watch-background-trim.test.ts
index 5aeb98d0715..00bb2373cde 100644
--- a/src/renderer/src/hooks/editor-external-watch-background-trim.test.ts
+++ b/src/renderer/src/hooks/editor-external-watch-background-trim.test.ts
@@ -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,
diff --git a/src/renderer/src/hooks/editor-external-watch-disk-verification.ts b/src/renderer/src/hooks/editor-external-watch-disk-verification.ts
index 4904736fc6d..2476007f54b 100644
--- a/src/renderer/src/hooks/editor-external-watch-disk-verification.ts
+++ b/src/renderer/src/hooks/editor-external-watch-disk-verification.ts
@@ -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)
}
diff --git a/src/renderer/src/hooks/editor-external-watch-event-reconciliation.ts b/src/renderer/src/hooks/editor-external-watch-event-reconciliation.ts
index 721e64d7ba9..571bc4240d0 100644
--- a/src/renderer/src/hooks/editor-external-watch-event-reconciliation.ts
+++ b/src/renderer/src/hooks/editor-external-watch-event-reconciliation.ts
@@ -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()
- 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)
) {
diff --git a/src/renderer/src/hooks/editor-external-watch-other-workspace-view.test.ts b/src/renderer/src/hooks/editor-external-watch-other-workspace-view.test.ts
new file mode 100644
index 00000000000..e18efacab52
--- /dev/null
+++ b/src/renderer/src/hooks/editor-external-watch-other-workspace-view.test.ts
@@ -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 {
+ 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()
+ })
+})
diff --git a/src/renderer/src/hooks/editor-external-watch-overflow.test.ts b/src/renderer/src/hooks/editor-external-watch-overflow.test.ts
index 12e58286510..193a704e48c 100644
--- a/src/renderer/src/hooks/editor-external-watch-overflow.test.ts
+++ b/src/renderer/src/hooks/editor-external-watch-overflow.test.ts
@@ -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' }),
diff --git a/src/renderer/src/hooks/editor-external-watch-overflow.ts b/src/renderer/src/hooks/editor-external-watch-overflow.ts
index 7d544805af7..1f60fbc6923 100644
--- a/src/renderer/src/hooks/editor-external-watch-overflow.ts
+++ b/src/renderer/src/hooks/editor-external-watch-overflow.ts
@@ -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)
}
diff --git a/src/renderer/src/hooks/editor-external-watch-targets.ts b/src/renderer/src/hooks/editor-external-watch-targets.ts
index 7f36f0ec7b2..f8b12cbf232 100644
--- a/src/renderer/src/hooks/editor-external-watch-targets.ts
+++ b/src/renderer/src/hooks/editor-external-watch-targets.ts
@@ -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()
+ 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
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index 29b70315a13..3f53aa2b053 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -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",
diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json
index ab6d98762d2..d5dc9ad910b 100644
--- a/src/renderer/src/i18n/locales/es.json
+++ b/src/renderer/src/i18n/locales/es.json
@@ -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",
diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json
index e620a347183..e493b28659f 100644
--- a/src/renderer/src/i18n/locales/fr.json
+++ b/src/renderer/src/i18n/locales/fr.json
@@ -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",
diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json
index 667459bbb8e..e803923ba1c 100644
--- a/src/renderer/src/i18n/locales/ja.json
+++ b/src/renderer/src/i18n/locales/ja.json
@@ -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": "相対パスをコピー",
diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json
index 186097115dc..2aef1ace9fe 100644
--- a/src/renderer/src/i18n/locales/ko.json
+++ b/src/renderer/src/i18n/locales/ko.json
@@ -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": "상대 경로 복사",
diff --git a/src/renderer/src/i18n/locales/pt.json b/src/renderer/src/i18n/locales/pt.json
index 0f22fa75a83..2149f2ea74d 100644
--- a/src/renderer/src/i18n/locales/pt.json
+++ b/src/renderer/src/i18n/locales/pt.json
@@ -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",
diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json
index 869918fa5ff..feca068d4f1 100644
--- a/src/renderer/src/i18n/locales/zh.json
+++ b/src/renderer/src/i18n/locales/zh.json
@@ -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": "复制相对路径",
diff --git a/src/renderer/src/lib/open-file-owner-scope.ts b/src/renderer/src/lib/open-file-owner-scope.ts
new file mode 100644
index 00000000000..ff68c313c19
--- /dev/null
+++ b/src/renderer/src/lib/open-file-owner-scope.ts
@@ -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