From 537f561d585cf9a970d304b0599236e898a0090f Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 24 Oct 2026 18:01:06 -0700 Subject: [PATCH] Revert "fix(terminal): a file link opens in the workspace it was clicked in (#32320)" (#33304) * Revert "fix(terminal): a file link opens in the workspace it was clicked in (#32320)" This reverts commit 359fec1a09f78198a8b0213e7bad53d62a2f7f00. * test: drop redundant openFiles cast restored by the revert --- .../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 | 74 ++----- .../terminal-link-handlers-test-fixtures.ts | 10 +- .../terminal-link-host-workspace-file.test.ts | 45 ---- .../terminal-link-open-beside-source.test.ts | 19 +- ...-link-other-workspace-hint.sonner.test.tsx | 197 ------------------ ...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, 127 insertions(+), 1058 deletions(-) delete mode 100644 src/renderer/src/components/terminal-pane/terminal-file-link-owner-resolution.ts delete mode 100644 src/renderer/src/components/terminal-pane/terminal-file-other-workspace-hint.test.ts delete mode 100644 src/renderer/src/components/terminal-pane/terminal-file-other-workspace-hint.ts delete mode 100644 src/renderer/src/components/terminal-pane/terminal-file-other-workspace-owner.ts delete mode 100644 src/renderer/src/components/terminal-pane/terminal-link-other-workspace-hint.sonner.test.tsx delete mode 100644 src/renderer/src/hooks/editor-external-watch-other-workspace-view.test.ts delete mode 100644 src/renderer/src/lib/open-file-owner-scope.ts delete 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 7e2033e69e9..b3b5ce617e5 100644 --- a/src/renderer/src/components/editor/EditorPanelHeaderPath.tsx +++ b/src/renderer/src/components/editor/EditorPanelHeaderPath.tsx @@ -12,7 +12,6 @@ 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' @@ -120,11 +119,6 @@ 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 87c0086b299..e06760c6fff 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, state).map((file) => + getOpenFilesForExternalFileChange(state.openFiles, target).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 927e3ce085a..a70e8eefd08 100644 --- a/src/renderer/src/components/editor/editor-autosave.ts +++ b/src/renderer/src/components/editor/editor-autosave.ts @@ -1,7 +1,5 @@ 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 { @@ -144,8 +142,7 @@ export function normalizeAutoSaveDelayMs(value: unknown): number { export function getOpenFilesForExternalFileChange( openFiles: OpenFile[], - target: EditorPathMutationTarget, - catalog?: AppState + target: EditorPathMutationTarget ): OpenFile[] { if (target.indexedOpenFiles) { return target.indexedOpenFiles.matches(openFiles) @@ -154,7 +151,7 @@ export function getOpenFilesForExternalFileChange( const hasRuntimeOwnerFilter = Object.hasOwn(target, 'runtimeEnvironmentId') const targetRuntimeOwner = target.runtimeEnvironmentId?.trim() || null return openFiles.filter((file) => { - if (openFileOwnerWorktreeId(file, catalog) !== target.worktreeId) { + if (file.worktreeId !== 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 2b3004f6167..704fc84e5db 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, state) + const matched = getOpenFilesForExternalFileChange([...panelFiles, ...storeFiles], target) 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 6c7c3ca6f38..de05c0b4dc2 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, state) + const matchingFiles = getOpenFilesForExternalFileChange(state.openFiles, detail) 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 9fb64e813f3..3ce5f2a432b 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,8 +1,6 @@ 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, @@ -11,8 +9,6 @@ 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 @@ -124,7 +120,7 @@ class IndexedOpenFileLookup { let hasCombinedDiffConsumer = false for (const [index, file] of openFiles.entries()) { if ( - openFileOwnerWorktreeId(file, scope.catalog) !== scope.worktreeId || + file.worktreeId !== 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 700c6a28384..8bef9d62b8e 100644 --- a/src/renderer/src/components/editor/useEditorPanelRemoteSiblingContentState.test.tsx +++ b/src/renderer/src/components/editor/useEditorPanelRemoteSiblingContentState.test.tsx @@ -193,35 +193,6 @@ 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 deleted file mode 100644 index a671413e44e..00000000000 --- a/src/renderer/src/components/terminal-pane/terminal-file-link-owner-resolution.ts +++ /dev/null @@ -1,75 +0,0 @@ -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 06c8aab0c7d..7dd43859a78 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,19 +18,17 @@ 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 { @@ -91,7 +89,6 @@ 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(), @@ -112,9 +109,13 @@ export function openDetectedFilePath( } void (async () => { - const sourceContext = getTerminalFileContext(worktreeId, worktreePath, runtimeEnvironmentId) - const sourceRuntimeOwnerId = runtimeTargetEnvironmentId(sourceContext.target) - if (!sourceContext.sourceHostResolved) { + 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) { // Why: with no owner the host is unknown — refuse rather than touch this machine's files. deps.onOpenFailure?.({ verdict: 'unverifiable', @@ -126,7 +127,7 @@ export function openDetectedFilePath( const worktreeRootLink = resolveKnownWorktreeRootPathLink( mappedFilePath, useAppStore.getState(), - sourceContext + fileContext ) if (worktreeRootLink) { if (refuseExplicitNonEditorRoute()) { @@ -145,15 +146,33 @@ export function openDetectedFilePath( } } - let owner try { - owner = await resolveTerminalLinkFileOwner({ - fileContext: sourceContext, - worktreeId, - worktreePath, - filePath: mappedFilePath, - terminalHandle: deps.terminalHandle - }) + 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) + } } catch (error) { if (isCurrentOpen() && deps.onOpenFailure) { // Why: loss of contact with the host is not evidence the file is gone. @@ -168,9 +187,6 @@ 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 @@ -241,8 +257,10 @@ export function openDetectedFilePath( } } + const store = useAppStore.getState() + let targetWorktreeId = worktreeId const runtimeOwnerId = runtimeTargetEnvironmentId(fileContext.target) ?? sourceRuntimeOwnerId - const targetExecutionHostId = + let targetExecutionHostId = hostWorkspaceFile?.executionHostId ?? (runtimeOwnerId ? toRuntimeExecutionHostId(runtimeOwnerId) @@ -255,27 +273,35 @@ export function openDetectedFilePath( if (maybeRelative !== null && maybeRelative.length > 0 && !statResult.escapesWorktree) { relativePath = maybeRelative } - } else if (hostGrantId === null && !clientLocal && !hostWorkspaceFile) { - const sibling = siblingWorkspaceLink(targetExecutionHostId, mappedFilePath, worktreeId) - otherWorkspaceOwner = sibling.view - if (sibling.openInOwner) { - ;({ worktreeId, relativePath } = sibling.openInOwner) + } 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 } } if ( explicitGroupRequest && - !isExplicitEditorScopeKept(linkIntent, worktreeId, targetExecutionHostId, clientLocal) + !isExplicitEditorScopeKept(linkIntent, targetWorktreeId, targetExecutionHostId, clientLocal) ) { // Why: refuse before activating the destination or touching editor state. refuseExplicitNonEditorRoute() return } - if (worktreeId) { + if (targetWorktreeId) { // 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(worktreeId, { + activateAndRevealWorkspace(targetWorktreeId, { providesInitialSurface: true, executionHostId: targetExecutionHostId }) @@ -285,11 +311,10 @@ export function openDetectedFilePath( { filePath: mappedFilePath, relativePath, - worktreeId: worktreeId || '', + worktreeId: targetWorktreeId || '', runtimeEnvironmentId: clientLocal ? null : runtimeEnvironmentId, - // 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: a tab's owner must match its workspace's to be editable; this one is this computer's. + ...(clientLocal && targetWorktreeId === worktreeId ? { readOnly: true } : {}), // 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 @@ -306,14 +331,6 @@ 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 deleted file mode 100644 index 972b801e673..00000000000 --- a/src/renderer/src/components/terminal-pane/terminal-file-other-workspace-hint.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -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 deleted file mode 100644 index f3e3d9cf2db..00000000000 --- a/src/renderer/src/components/terminal-pane/terminal-file-other-workspace-hint.ts +++ /dev/null @@ -1,111 +0,0 @@ -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 deleted file mode 100644 index 73103741f70..00000000000 --- a/src/renderer/src/components/terminal-pane/terminal-file-other-workspace-owner.ts +++ /dev/null @@ -1,37 +0,0 @@ -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 1daf4249f6b..5df3efdc217 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,6 +1,5 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { 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 { @@ -12,7 +11,6 @@ import { } from './terminal-link-handlers-test-harness' const findWorkspaceFileRouteMock = vi.hoisted(() => vi.fn()) -const showHintMock = vi.hoisted(() => vi.fn()) const doubles = createTerminalLinkTestDoubles() const { storeState, @@ -48,17 +46,8 @@ 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') @@ -153,83 +142,50 @@ describe('handleOscLink', () => { }) }) - it('opens a sibling folder-workspace file read-only here and names its workspace', async () => { + it('opens a sibling folder-workspace path under its owning host and workspace', async () => { const filePath = '/sibling/docs/SKILL.md' - const owner = { + storeState.openFiles = [{ filePath, worktreeId: 'folder:notes' }] + findWorkspaceFileRouteMock.mockReturnValueOnce({ worktreeId: 'folder:notes', relativePath: 'docs/SKILL.md', executionHostId: 'local' - } - findWorkspaceFileRouteMock.mockReturnValueOnce(owner) + }) + openFileMock.mockImplementationOnce(() => { + storeState.activeFileIdByWorktree['folder:notes'] = 'owned-skill' + }) openDetectedFilePath(filePath, 12, null, deps) await flushAsyncWork() await flushDoubleRaf() - expect(activateAndRevealWorkspace).toHaveBeenCalledTimes(1) - expect(activateAndRevealWorkspace).toHaveBeenCalledWith('wt-1', { + expect(activateAndRevealWorkspace).toHaveBeenCalledWith('folder:notes', { providesInitialSurface: true, executionHostId: 'local' }) expect(openFileMock).toHaveBeenCalledWith( expect.objectContaining({ filePath, - relativePath: filePath, - worktreeId: 'wt-1', - runtimeEnvironmentId: null, - readOnly: true + relativePath: 'docs/SKILL.md', + worktreeId: 'folder:notes' }), { forceContentReload: true } ) - expect(showHintMock).toHaveBeenCalledWith({ - fileId: filePath, - filePath, - owner, - runtimeEnvironmentId: null, - position: { line: 12, column: null } - }) - }) - - 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(setPendingEditorRevealMock).toHaveBeenLastCalledWith( + expect.objectContaining({ fileId: 'owned-skill' }) ) - 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 () => { + it('leaves ordinary external ownership lookup to the editor loader', 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 adfe35259bc..7c5901bf036 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,13 +8,6 @@ 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 @@ -24,7 +17,7 @@ export type TerminalLinkStoreState = { setPendingEditorReveal: Mock setMarkdownViewMode: Mock activeFileIdByWorktree: Record - openFiles: TerminalLinkOpenFile[] + openFiles: { filePath: string; worktreeId: string }[] groupsByWorktree: Record unifiedTabsByWorktree: Record< string, @@ -42,7 +35,6 @@ export type TerminalLinkStoreState = { }[] > folderWorkspaces: [] - repos?: { id: string; displayName: string }[] } export type TerminalLinkTestDoubles = { 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 ce8a9d7d30c..c2ce28b467e 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,6 +1,5 @@ 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' @@ -9,10 +8,6 @@ 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 @@ -35,16 +30,6 @@ 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 = { @@ -98,36 +83,6 @@ 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 bc6d0ff54dc..1af0d9ab0d6 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,11 +32,6 @@ 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' })) @@ -169,9 +164,10 @@ describe('terminal file links carry their source tab', () => { expect(toastErrorMock).toHaveBeenCalledTimes(1) }) - it('keeps an explicit request for another workspace file in the source group', async () => { + it('refuses an explicit request routed to another workspace before activating it', async () => { setPlatform('Macintosh') seedSourceTab() + storeState.openFiles = [{ filePath: '/other/x.ts', worktreeId: 'wt-2' }] findWorkspaceFileRouteMock.mockReturnValueOnce({ worktreeId: 'wt-2', executionHostId: 'local', @@ -181,14 +177,9 @@ describe('terminal file links carry their source tab', () => { openDetectedFilePath('/other/x.ts', null, null, { ...sourceDeps, openInSourceGroup: true }) await flushAsyncWork() - 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() + expect(activateAndRevealWorkspace).not.toHaveBeenCalled() + expect(openFileForLinkMock).not.toHaveBeenCalled() + expect(toastErrorMock).toHaveBeenCalledTimes(1) }) 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 deleted file mode 100644 index dd059f19c1a..00000000000 --- a/src/renderer/src/components/terminal-pane/terminal-link-other-workspace-hint.sonner.test.tsx +++ /dev/null @@ -1,197 +0,0 @@ -// @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 { - for (let i = 0; i < 5; i += 1) { - await act(async () => { - await vi.advanceTimersByTimeAsync(1) - }) - } -} - -beforeEach(() => { - vi.useFakeTimers() - 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() - vi.useRealTimers() -}) - -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() - findWorkspaceFileRouteMock.mockReturnValue({ - worktreeId: 'wt-b', - relativePath: 'notes.md', - executionHostId: 'local' - }) - openDetectedFilePath('/b/notes.md', 11, null, sourceDeps) - await settle() - await act(async () => { - await vi.advanceTimersToNextFrame() - }) - await act(async () => { - await vi.advanceTimersByTimeAsync(300) - }) - await act(async () => { - await vi.advanceTimersByTimeAsync(300) - }) - - expect(screen.getByText('This file is in project-b · feature-b')).not.toBeNull() - fireEvent.click(screen.getByRole('button', { name: 'Open there' })) - await settle() - - expect(openFileMock).toHaveBeenLastCalledWith( - expect.objectContaining({ - filePath: '/b/notes.md', - relativePath: 'notes.md', - worktreeId: 'wt-b' - }), - { forceContentReload: true } - ) - }) - - 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 00bb2373cde..5aeb98d0715 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,18 +15,7 @@ import { TEST_REPO } from '@/store/slices/worktrees-slice-test-fixtures' -// 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 -})) +vi.mock('@/store', () => ({ useAppStore: { getState: vi.fn() } })) type Repo = EditorExternalWatchTargetState['repos'][number] type Worktree = EditorExternalWatchTargetState['worktreesByRepo'][string][number] @@ -120,38 +109,6 @@ 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 2476007f54b..4904736fc6d 100644 --- a/src/renderer/src/hooks/editor-external-watch-disk-verification.ts +++ b/src/renderer/src/hooks/editor-external-watch-disk-verification.ts @@ -318,7 +318,6 @@ export function scheduleSelfWriteAwareEditorExternalReload( } function hasCleanExternalReloadTarget(notification: EditorExternalWatchNotification): boolean { - const state = useAppStore.getState() - const matching = getOpenFilesForExternalFileChange(state.openFiles, notification, state) + const matching = getOpenFilesForExternalFileChange(useAppStore.getState().openFiles, notification) 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 571bc4240d0..721e64d7ba9 100644 --- a/src/renderer/src/hooks/editor-external-watch-event-reconciliation.ts +++ b/src/renderer/src/hooks/editor-external-watch-event-reconciliation.ts @@ -1,7 +1,5 @@ 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' @@ -98,10 +96,8 @@ 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 stateAtStart = useAppStore.getState() - const openFilesAtStart = stateAtStart.openFiles + const openFilesAtStart = useAppStore.getState().openFiles const batchPaths = indexEditorExternalWatchBatchPaths(payload, openFilesAtStart, { - catalog: stateAtStart, worktreeId: target.worktreeId, worktreePath: target.worktreePath, runtimeEnvironmentId: target.runtimeEnvironmentId, @@ -129,7 +125,7 @@ export function buildEditorExternalWatchEventHandler( const deletedOpenEditorIds = deletedOpenEditors.map(({ file }) => file.id) const hasPairedCreate = deletedOpenEditorIds.length > 0 && - hasRenameCorrelatedCreate(payload, target.worktreeId, deletedOpenEditorIds, stateAtStart) + hasRenameCorrelatedCreate(payload, target.worktreeId, deletedOpenEditorIds, openFilesAtStart) if (deletedOpenEditorIds.length > 0) { if (hasPairedCreate) { const setExternalMutation = useAppStore.getState().setExternalMutation @@ -169,7 +165,7 @@ export function buildEditorExternalWatchEventHandler( const state = useAppStore.getState() for (const file of state.openFiles) { if ( - openFileOwnerWorktreeId(file, state) === target.worktreeId && + file.worktreeId === target.worktreeId && getOpenFileRuntimeOwner(file) === target.runtimeEnvironmentId && (file.mode === 'edit' || file.mode === 'markdown-preview') && (file.externalMutation === 'deleted' || file.externalMutation === 'renamed') && @@ -266,16 +262,16 @@ function hasRenameCorrelatedCreate( payload: FsChangedPayload, worktreeId: string, deletedOpenEditorIds: string[], - state: AppState + openFiles: OpenFile[] ): boolean { if (deletedOpenEditorIds.length === 0) { return false } const deletedIdSet = new Set(deletedOpenEditorIds) const deletedBasenames = new Set() - for (const file of state.openFiles) { + for (const file of openFiles) { if ( - openFileOwnerWorktreeId(file, state) !== worktreeId || + file.worktreeId !== 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 deleted file mode 100644 index e18efacab52..00000000000 --- a/src/renderer/src/hooks/editor-external-watch-other-workspace-view.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -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 193a704e48c..12e58286510 100644 --- a/src/renderer/src/hooks/editor-external-watch-overflow.test.ts +++ b/src/renderer/src/hooks/editor-external-watch-overflow.test.ts @@ -18,13 +18,6 @@ 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 @@ -133,22 +126,6 @@ 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 1f60fbc6923..7d544805af7 100644 --- a/src/renderer/src/hooks/editor-external-watch-overflow.ts +++ b/src/renderer/src/hooks/editor-external-watch-overflow.ts @@ -1,5 +1,4 @@ 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' @@ -89,9 +88,8 @@ 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 ( - ownerScope.worktreeId !== target.worktreeId || + file.worktreeId !== target.worktreeId || getOpenFileRuntimeOwner(file) !== (target.runtimeEnvironmentId ?? null) || !isExternalReloadableEditorTab(file) ) { @@ -116,7 +114,7 @@ export function reconcileEditorTabsAfterOverflow(target: OverflowTarget, resync: const notification: EditorExternalWatchNotification = { worktreeId: target.worktreeId, worktreePath: target.worktreePath, - relativePath: ownerScope.relativePath, + relativePath: file.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 f8b12cbf232..7f36f0ec7b2 100644 --- a/src/renderer/src/hooks/editor-external-watch-targets.ts +++ b/src/renderer/src/hooks/editor-external-watch-targets.ts @@ -1,5 +1,4 @@ -import { useAppStore, type AppState } from '@/store' -import { openFileOwnerWorktreeId } from '@/lib/open-file-owner-scope' +import type { AppState } from '@/store' 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' @@ -58,7 +57,6 @@ type WatchedTargetsSnapshot = { unwatchedTargetsKey: string } -let cachedOwnersKey = '' let cachedOpenFiles: AppState['openFiles'] | null = null let cachedWorktreesByRepo: AppState['worktreesByRepo'] | null = null let cachedRepos: AppState['repos'] | null = null @@ -151,19 +149,7 @@ 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 && @@ -201,11 +187,7 @@ export function selectEditorExternalWatchTargets( const consumers = watchEligibleIds.has(file.worktreeId) ? consumersByWorktreeId : unwatchedConsumersByWorktreeId - addWatchConsumer( - consumers, - watchWorktreeIds.get(file.id) ?? file.worktreeId, - getOpenFileWatchConsumer(file) - ) + addWatchConsumer(consumers, file.worktreeId, getOpenFileWatchConsumer(file)) } const activeWorktreeId = state.activeWorktreeId const activeWorktree = activeWorktreeId @@ -256,7 +238,6 @@ 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 a8f00e84d84..bb835618fae 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -3687,11 +3687,7 @@ "downloadOpenWithDefaultApp": "Download & open with default app", "downloadOpenFailed": "Failed to download '{{value0}}'.", "openInThisGroup": "Open 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" + "openInThisGroupRefused": "Couldn't open {{value0}} in this group" }, "TerminalSessionStateSaveFailureDialog": { "6bee0c8f17": "Open Disk Space Analyzer", @@ -16276,7 +16272,6 @@ "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 e83e26e2875..97f5c99a51c 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -3565,11 +3565,7 @@ "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", - "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" + "openInThisGroupRefused": "No se pudo abrir {{value0}} en este grupo" }, "TerminalQuickCommandsSubmenu": { "3ccc7981bb": "Anfitrión no disponible", @@ -15960,7 +15956,6 @@ "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 c3533c6dc35..e3f33dc5ca5 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -3553,11 +3553,7 @@ "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", - "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" + "openInThisGroupRefused": "Impossible d'ouvrir {{value0}} dans ce groupe" }, "TerminalSessionStateSaveFailureDialog": { "6bee0c8f17": "Ouvrir l'Analyseur d'espace disque", @@ -16104,7 +16100,6 @@ "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 b0fa73c3241..5d8e241faf0 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -3547,11 +3547,7 @@ "downloadOpenWithDefaultApp": "ダウンロードしてデフォルトのアプリで開く", "downloadOpenFailed": "「{{value0}}」のダウンロードに失敗しました。", "openInThisGroup": "このグループで開く", - "openInThisGroupRefused": "{{value0}} をこのグループで開けませんでした", - "fileInOtherWorkspace": "このファイルは {{value0}} にあります", - "fileInAnotherWorkspace": "このファイルは別のワークスペースにあります", - "openInOwningWorkspace": "そこで開く", - "openedHereReadOnly": "ここでは読み取り専用で開いています" + "openInThisGroupRefused": "{{value0}} をこのグループで開けませんでした" }, "TerminalQuickCommandsSubmenu": { "3ccc7981bb": "ホストが利用不可", @@ -15944,7 +15940,6 @@ "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 63e440c6080..12aabd61e15 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -3547,11 +3547,7 @@ "downloadOpenWithDefaultApp": "기본 앱 다운로드 및 열기", "downloadOpenFailed": "'{{value0}}'을(를) 다운로드하지 못했습니다.", "openInThisGroup": "이 그룹에서 열기", - "openInThisGroupRefused": "이 그룹에서 {{value0}}을(를) 열 수 없습니다", - "fileInOtherWorkspace": "이 파일은 {{value0}}에 있습니다", - "fileInAnotherWorkspace": "이 파일은 다른 워크스페이스에 있습니다", - "openInOwningWorkspace": "거기서 열기", - "openedHereReadOnly": "여기서는 읽기 전용으로 열렸습니다" + "openInThisGroupRefused": "이 그룹에서 {{value0}}을(를) 열 수 없습니다" }, "TerminalQuickCommandsSubmenu": { "3ccc7981bb": "호스트를 사용할 수 없음", @@ -15944,7 +15940,6 @@ "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 669514f31ab..6d95c0d78cd 100644 --- a/src/renderer/src/i18n/locales/pt.json +++ b/src/renderer/src/i18n/locales/pt.json @@ -12206,7 +12206,6 @@ "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", @@ -15522,11 +15521,7 @@ "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", - "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" + "openInThisGroupRefused": "Não foi possível abrir {{value0}} neste grupo" }, "MobileDriverOverlay": { "c6460cf584": "Retomar", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 2cedcf90f86..9f887e277b6 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -3547,11 +3547,7 @@ "downloadOpenWithDefaultApp": "下载并使用默认应用程序打开", "downloadOpenFailed": "无法下载“{{value0}}”。", "openInThisGroup": "在此分组中打开", - "openInThisGroupRefused": "无法在此分组中打开 {{value0}}", - "fileInOtherWorkspace": "此文件位于 {{value0}}", - "fileInAnotherWorkspace": "此文件位于另一个工作区", - "openInOwningWorkspace": "在那里打开", - "openedHereReadOnly": "已在此处以只读方式打开" + "openInThisGroupRefused": "无法在此分组中打开 {{value0}}" }, "TerminalQuickCommandsSubmenu": { "3ccc7981bb": "主机不可用", @@ -15944,7 +15940,6 @@ "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 deleted file mode 100644 index ff68c313c19..00000000000 --- a/src/renderer/src/lib/open-file-owner-scope.ts +++ /dev/null @@ -1,63 +0,0 @@ -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 deleted file mode 100644 index fb6475ce468..00000000000 --- a/src/renderer/src/runtime/sync-runtime-graph-outside-workspace-file-tabs.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -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 ac154ba0873..efd97af0c36 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 @@ -83,19 +83,12 @@ export function isMobilePublishableOpenFile(file: AppState['openFiles'][number]) return false } // Combined diff tabs use display labels as paths and need the desktop renderer. - if ( + return !( 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 ad8346346f0..f05f448e30d 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('opens a sibling workspace file here read-only, and the hint opens it there @golden', async ({ +test('reuses a terminal file link already open in a sibling workspace @golden', async ({ orcaPage }) => { test.setTimeout(180_000) @@ -298,33 +298,20 @@ test('opens a sibling workspace file here read-only, and the hint opens it there 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(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' }) + .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 }) + await expect(orcaPage.getByText('Loading...', { exact: true })).toHaveCount(0) })