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() + +/** + * 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 +} diff --git a/src/renderer/src/runtime/sync-runtime-graph-outside-workspace-file-tabs.test.ts b/src/renderer/src/runtime/sync-runtime-graph-outside-workspace-file-tabs.test.ts new file mode 100644 index 00000000000..fb6475ce468 --- /dev/null +++ b/src/renderer/src/runtime/sync-runtime-graph-outside-workspace-file-tabs.test.ts @@ -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) + }) +}) diff --git a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-surfaces.ts b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-surfaces.ts index 83757130126..2550795f83e 100644 --- a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-surfaces.ts +++ b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-surfaces.ts @@ -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 } /** diff --git a/tests/e2e/golden-terminal-file-link.spec.ts b/tests/e2e/golden-terminal-file-link.spec.ts index f05f448e30d..ad8346346f0 100644 --- a/tests/e2e/golden-terminal-file-link.spec.ts +++ b/tests/e2e/golden-terminal-file-link.spec.ts @@ -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 }) })