mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
* Revert "fix(terminal): a file link opens in the workspace it was clicked in (#32320)"
This reverts commit 359fec1a09.
* test: drop redundant openFiles cast restored by the revert
This commit is contained in:
@@ -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({
|
||||
<span className="editor-header-path-file">{displayPath.fileName}</span>
|
||||
</button>
|
||||
)}
|
||||
{!isRenaming && activeFile.readOnly === true && (
|
||||
<Badge variant="outline">
|
||||
{translate('auto.components.editor.EditorPanelHeader.readOnlyBadge', 'Read-only')}
|
||||
</Badge>
|
||||
)}
|
||||
{/* Why: the toast is opacity-0 rather than display-none, so leaving it
|
||||
mounted reserves ~100px of the row from the rename field for a
|
||||
message that cannot fire while renaming. */}
|
||||
|
||||
@@ -55,7 +55,7 @@ export function pathMutationHoldKeys(
|
||||
target: EditorPathMutationTarget
|
||||
): Set<string> {
|
||||
const keys = new Set(
|
||||
getOpenFilesForExternalFileChange(state.openFiles, target, state).map((file) =>
|
||||
getOpenFilesForExternalFileChange(state.openFiles, target).map((file) =>
|
||||
editorFileHoldKey(state, file)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -84,7 +84,7 @@ export function externalChangeRecordViewIds(
|
||||
target: EditorPathMutationTarget
|
||||
): Set<string> {
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(<HookProbe activeFile={activeFile} />))
|
||||
|
||||
// Why: a server reads only by workspace-relative path, so no view of it stays in another workspace.
|
||||
await vi.waitFor(() =>
|
||||
expect(mocks.migrateRestoredEditorFileOwner).toHaveBeenCalledWith(
|
||||
activeFile.id,
|
||||
route,
|
||||
'runtime-1'
|
||||
)
|
||||
)
|
||||
expect(mocks.readRuntimeFileContent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports a sibling-owner collision instead of remaining in loading state', async () => {
|
||||
const activeFile = createOpenFile({
|
||||
id: '/work/repo-b/docs/readme.md',
|
||||
|
||||
@@ -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<TerminalLinkFileOwner> {
|
||||
const { fileContext, filePath } = args
|
||||
const resolved: TerminalLinkFileOwner = {
|
||||
fileContext,
|
||||
worktreeId: args.worktreeId,
|
||||
worktreePath: args.worktreePath,
|
||||
filePath,
|
||||
stat: { isDirectory: false, escapesWorktree: false },
|
||||
hostWorkspaceFile: null,
|
||||
hostGrantId: null,
|
||||
clientLocal: false
|
||||
}
|
||||
const linkOwner = await resolveHostWorkspaceFile(fileContext, filePath, args.terminalHandle)
|
||||
if (linkOwner?.kind === 'grant') {
|
||||
return {
|
||||
...resolved,
|
||||
hostGrantId: linkOwner.grantId,
|
||||
filePath: linkOwner.absolutePath,
|
||||
stat: { isDirectory: false, escapesWorktree: true }
|
||||
}
|
||||
}
|
||||
if (linkOwner?.kind === 'client') {
|
||||
return {
|
||||
...resolved,
|
||||
clientLocal: true,
|
||||
fileContext: { ...fileContext, target: CLIENT_LOCAL_FILE_TARGET, connectionId: undefined },
|
||||
stat: { isDirectory: linkOwner.isDirectory, escapesWorktree: false }
|
||||
}
|
||||
}
|
||||
if (!linkOwner) {
|
||||
return { ...resolved, stat: await statUserOpenedPath(fileContext, filePath) }
|
||||
}
|
||||
// Why: the host already stat'ed the path inside the workspace that holds it; open it there.
|
||||
const { worktreeId, worktreePath } = linkOwner
|
||||
return {
|
||||
...resolved,
|
||||
filePath: linkOwner.absolutePath,
|
||||
hostWorkspaceFile: linkOwner,
|
||||
worktreeId,
|
||||
worktreePath,
|
||||
fileContext: { ...fileContext, worktreeId, worktreePath },
|
||||
stat: { isDirectory: linkOwner.isDirectory, escapesWorktree: false }
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { getWorkspaceFilePreviewPlan, openFileInBrowserTab } from '@/lib/file-preview'
|
||||
import { downloadAndOpenRemoteTerminalFile } from './terminal-remote-file-download-open'
|
||||
import { findWorkspaceFileRoute } from '@/lib/runtime-workspace-file-route'
|
||||
import { isPathInsideWorktree, toWorktreeRelativePath } from '@/lib/terminal-links'
|
||||
import type { RuntimeWorkspaceFileRoute } from '@/lib/runtime-workspace-file-route'
|
||||
import { canClientOsOpenWorkspaceFile } from '@/lib/workspace-file-host-routing'
|
||||
import { isMissingRuntimePathError } from '@/runtime/runtime-file-metadata-client'
|
||||
import { useAppStore } from '@/store'
|
||||
@@ -18,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 }
|
||||
})
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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 } : {}
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<string, string | null>
|
||||
openFiles: TerminalLinkOpenFile[]
|
||||
openFiles: { filePath: string; worktreeId: string }[]
|
||||
groupsByWorktree: Record<string, TabGroup[]>
|
||||
unifiedTabsByWorktree: Record<
|
||||
string,
|
||||
@@ -42,7 +35,6 @@ export type TerminalLinkStoreState = {
|
||||
}[]
|
||||
>
|
||||
folderWorkspaces: []
|
||||
repos?: { id: string; displayName: string }[]
|
||||
}
|
||||
|
||||
export type TerminalLinkTestDoubles = {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
-197
@@ -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<string, string>) =>
|
||||
fallback.replace(/\{\{(\w+)\}\}/g, (_match, name: string) => values?.[name] ?? '')
|
||||
}))
|
||||
|
||||
vi.mock('@/store', () => ({ useAppStore: { getState: () => storeState } }))
|
||||
|
||||
vi.mock('@/store/selectors', () => ({
|
||||
selectKnownWorktreeById: (_state: unknown, worktreeId: string) =>
|
||||
worktreeId === 'wt-b'
|
||||
? { repoId: 'repo-b', displayName: 'feature-b', branch: '', path: '/b' }
|
||||
: null
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/language-detect', () => ({ detectLanguage: () => 'plaintext' }))
|
||||
|
||||
vi.mock('@/lib/worktree-activation', () => ({
|
||||
activateAndRevealWorkspace: vi.fn(),
|
||||
activateAndRevealWorktree: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/connection-context', () => ({ getConnectionId: vi.fn(() => null) }))
|
||||
|
||||
vi.mock('@/lib/runtime-workspace-file-route', () => ({
|
||||
findWorkspaceFileRoute: findWorkspaceFileRouteMock
|
||||
}))
|
||||
|
||||
const sourceDeps = { worktreeId: 'wt-a', worktreePath: '/a' }
|
||||
|
||||
async function settle(): Promise<void> {
|
||||
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(<Toaster />)
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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<string>()
|
||||
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)
|
||||
) {
|
||||
|
||||
@@ -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> = {}): OpenFile {
|
||||
return {
|
||||
id: 'view',
|
||||
worktreeId: 'wt-a',
|
||||
filePath: '/b/report.md',
|
||||
relativePath: '/b/report.md',
|
||||
language: 'markdown',
|
||||
mode: 'edit',
|
||||
isDirty: false,
|
||||
readOnly: true,
|
||||
...extra
|
||||
}
|
||||
}
|
||||
|
||||
function useOpenFiles(openFiles: OpenFile[]): void {
|
||||
vi.mocked(useAppStore.getState).mockReturnValue({
|
||||
...createTestStore().getState(),
|
||||
openFiles,
|
||||
setExternalMutation
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe("a read-only view of another workspace's file", () => {
|
||||
it('matches the owning workspace watch after a restore', () => {
|
||||
const view = restoredView()
|
||||
useOpenFiles([view])
|
||||
const index = indexEditorExternalWatchBatchPaths(
|
||||
{ worktreePath: '/b', events: [{ kind: 'update', absolutePath: '/b/report.md' }] },
|
||||
[view],
|
||||
{ ...ownerTarget, catalog: useAppStore.getState() }
|
||||
)
|
||||
|
||||
expect(index.matchingOpenFiles(index.changes[0])).toEqual([view])
|
||||
})
|
||||
|
||||
it('clears its deleted mark when the owner recreates the file', () => {
|
||||
useOpenFiles([restoredView({ externalMutation: 'deleted' })])
|
||||
const { handleFsChanged, dispose } = buildEditorExternalWatchEventHandler((path) =>
|
||||
path === '/b' ? ownerTarget : undefined
|
||||
)
|
||||
|
||||
handleFsChanged({
|
||||
worktreePath: '/b',
|
||||
events: [{ kind: 'create', absolutePath: '/b/report.md' }]
|
||||
})
|
||||
|
||||
expect(setExternalMutation).toHaveBeenCalledWith('view', null)
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('is marked deleted when the owner deletes the file', () => {
|
||||
useOpenFiles([restoredView()])
|
||||
const { handleFsChanged, dispose } = buildEditorExternalWatchEventHandler((path) =>
|
||||
path === '/b' ? ownerTarget : undefined
|
||||
)
|
||||
|
||||
handleFsChanged({
|
||||
worktreePath: '/b',
|
||||
events: [{ kind: 'delete', absolutePath: '/b/report.md' }]
|
||||
})
|
||||
vi.runAllTimers()
|
||||
|
||||
expect(setExternalMutation).toHaveBeenCalledWith('view', 'deleted')
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
@@ -18,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' }),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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<string, string>()
|
||||
for (const file of state.openFiles) {
|
||||
const owner = openFileOwnerWorktreeId(file, catalog)
|
||||
if (owner !== file.worktreeId) {
|
||||
watchWorktreeIds.set(file.id, owner)
|
||||
}
|
||||
}
|
||||
const ownersKey = [...watchWorktreeIds].join('|')
|
||||
if (
|
||||
cachedOwnersKey === ownersKey &&
|
||||
cachedOpenFiles === state.openFiles &&
|
||||
cachedWorktreesByRepo === selectWorktreesByRepo(state) &&
|
||||
cachedRepos === state.repos &&
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "相対パスをコピー",
|
||||
|
||||
@@ -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": "상대 경로 복사",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "复制相对路径",
|
||||
|
||||
@@ -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<object, { epoch: object; scope: OwnerScope }>()
|
||||
|
||||
/**
|
||||
* The workspace whose watch covers an open file. A read-only view of a file another workspace on
|
||||
* this computer holds (#32158) lives in one workspace but is watched with the one that holds the
|
||||
* file, derived from the catalog so a restored view needs nothing stored.
|
||||
*/
|
||||
export function openFileOwnerScope(file: OwnerScopedFile, state: AppState): OwnerScope {
|
||||
const own = { worktreeId: file.worktreeId, relativePath: file.relativePath }
|
||||
// Why: only a read-only absolute view may sit outside the workspace holding its file; a live-tail
|
||||
// log follows itself and must not pull in a recursive watch of a folder that holds it.
|
||||
if (
|
||||
file.readOnly !== true ||
|
||||
file.liveTail === true ||
|
||||
file.relativePath !== file.filePath ||
|
||||
file.terminalArtifactGrantId ||
|
||||
// Why: only this computer's files are viewed outside their workspace; an SSH or server
|
||||
// tab naming another workspace's path is moved there by the loader instead.
|
||||
file.externalSshTargetId ||
|
||||
file.runtimeEnvironmentId?.trim() ||
|
||||
isFloatingWorkspaceId(file.worktreeId)
|
||||
) {
|
||||
return own
|
||||
}
|
||||
// Why cached: watch selectors run on every store update; the catalog walk only on route changes.
|
||||
const epoch = editorBufferRouteEpochOf(state)
|
||||
const cached = ownerScopeCache.get(file)
|
||||
if (cached?.epoch === epoch) {
|
||||
return cached.scope
|
||||
}
|
||||
const route = findWorkspaceFileRoute(state, LOCAL_EXECUTION_HOST_ID, file.filePath)
|
||||
const scope = route ? { worktreeId: route.worktreeId, relativePath: route.relativePath } : own
|
||||
ownerScopeCache.set(file, { epoch, scope })
|
||||
return scope
|
||||
}
|
||||
|
||||
/** The workspace an open file is scoped to; without a catalog, the one it lives in. */
|
||||
export function openFileOwnerWorktreeId(
|
||||
file: OwnerScopedFile,
|
||||
state: AppState | undefined
|
||||
): string {
|
||||
return state ? openFileOwnerScope(file, state).worktreeId : file.worktreeId
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user