Open file links beside their source tab's group (#30399)

* Open file links beside their source tab's group

Clicking a file link in a terminal or agent chat now opens the file in an editor next to that tab, not in whichever group happens to have focus. A new "Open in this group" row pins the file to the source group when that group has neighbors.

- Capture the source tab at click time, so focus changes during path resolution don't redirect the file.
- Rank candidate groups by adjacency: a neighbor already showing the file, then a neighbor showing an editor, then any neighbor, then the source group.
- "Open in this group" never falls back to a browser, system app, or other group. If the group is gone or the target is not an editor, it shows a toast and does nothing.
- Line reveals target the exact view that was opened, and are dropped if that view closes before the editor applies them.

* rm scratch files

* Preserve empty split panes during tab reconciliation

Reconciliation removed every empty group, so an empty split pane the user had made beside a tab was collapsed on the next pass. A file link opened from that tab then landed in the source group instead of the empty pane. Reconciliation now removes only groups that stale-tab cleanup emptied, and keeps panes that were already empty. Tests cover terminal and agent-session sources in both worktree and folder workspaces.

* Preserve runtime owner for local file links
This commit is contained in:
Jinjing
2026-10-18 15:36:29 -07:00
committed by GitHub
parent 7884f6e738
commit 792c141fed
39 changed files with 1779 additions and 266 deletions
@@ -65,7 +65,8 @@ describe('resolveNativeChatFileLinkContext', () => {
).toEqual({
worktreeId: 'wt-1',
worktreePath: '/repo/worktree',
runtimeEnvironmentId: 'env-1'
runtimeEnvironmentId: 'env-1',
sourceTabId: 'tab-1'
})
})
@@ -97,7 +98,7 @@ describe('resolveNativeChatFileLinkContext', () => {
}),
structuredTab.id
)
).toEqual(context)
).toEqual({ ...context, sourceTabId: structuredTab.id })
})
it('resolves a detected-only workspace from the supplied snapshot catalog', () => {
@@ -114,7 +115,8 @@ describe('resolveNativeChatFileLinkContext', () => {
).toEqual({
worktreeId: 'wt-1',
worktreePath: '/repo/detected',
runtimeEnvironmentId: null
runtimeEnvironmentId: null,
sourceTabId: 'tab-1'
})
})
@@ -134,7 +136,8 @@ describe('resolveNativeChatFileLinkContext', () => {
).toEqual({
worktreeId: folderKey,
worktreePath: '/workspace/platform',
runtimeEnvironmentId: null
runtimeEnvironmentId: null,
sourceTabId: folderTab.id
})
})
})
@@ -184,7 +187,8 @@ describe('floating workspace native chat', () => {
expect(resolveNativeChatFileLinkContext(pinned, floatingTab.id)).toEqual({
worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
worktreePath: '/home/me/pinned',
runtimeEnvironmentId: null
runtimeEnvironmentId: null,
sourceTabId: floatingTab.id
})
})
@@ -12,6 +12,8 @@ export type NativeChatFileLinkContext = {
worktreeId: string
worktreePath: string
runtimeEnvironmentId: string | null
/** The chat's own tab (terminal or agent-session); editor opens place beside its group. */
sourceTabId?: string
}
export type NativeChatResolvedFileLink = {
@@ -120,7 +122,8 @@ function resolveNativeChatFileLinkContextForOwner(
return {
worktreeId,
worktreePath,
runtimeEnvironmentId: getRuntimeEnvironmentIdForWorktree(state, worktreeId)
runtimeEnvironmentId: getRuntimeEnvironmentIdForWorktree(state, worktreeId),
sourceTabId: terminalTabId
}
}
@@ -52,6 +52,7 @@ export function nativeChatFileOpenDeps(
worktreeId: context.worktreeId,
worktreePath: context.worktreePath,
runtimeEnvironmentId: context.runtimeEnvironmentId,
sourceTabId: context.sourceTabId,
onOpenFailure: (failure) =>
failure.verdict === 'unverifiable'
? showFileLinkUnverifiableToast(target.absolutePath, failure.error)
@@ -208,7 +208,8 @@ describe('native chat ownership selectors', () => {
expect(select(current)).toEqual({
worktreeId: 'chat-owner',
worktreePath: '/after',
runtimeEnvironmentId: 'owning-runtime'
runtimeEnvironmentId: 'owning-runtime',
sourceTabId: 'chat'
})
useAppStore.setState(current)
expect(select(initial)?.worktreePath).toBe('/before')
@@ -24,7 +24,7 @@ const deps = {
runtimeEnvironmentId: null
}
const createBrowserTab = vi.fn()
const openFile = vi.fn()
const openFileForLink = vi.fn(() => null)
const markWorktreeVisited = vi.fn()
const recordWorktreeVisit = vi.fn()
const revealWorktreeInSidebar = vi.fn()
@@ -68,7 +68,7 @@ beforeEach(() => {
activeBrowserTabId: 'main-browser',
rightSidebarTab: 'explorer',
createBrowserTab,
openFile,
openFileForLink,
markWorktreeVisited,
recordWorktreeVisit,
revealWorktreeInSidebar
@@ -141,9 +141,10 @@ describe('floating file links preserve main-window selection', () => {
await buildFileLinkActions(filePath, null, null, deps, { kind: 'local' }).primary.run()
await flushOpen()
expect(openFile).toHaveBeenCalledWith(
expect(openFileForLink).toHaveBeenCalledWith(
expect.objectContaining({ filePath, worktreeId: FLOATING_TERMINAL_WORKTREE_ID }),
{ forceContentReload: true }
{ forceContentReload: true },
null
)
expect(mocks.findWorkspaceFileRoute).not.toHaveBeenCalled()
expect(mainSelection()).toEqual(before)
@@ -153,9 +154,10 @@ describe('floating file links preserve main-window selection', () => {
const before = mainSelection()
openDetectedFilePath('/Users/me/floating/app.ts', null, null, deps)
await flushOpen()
expect(openFile).toHaveBeenCalledWith(
expect(openFileForLink).toHaveBeenCalledWith(
expect.objectContaining({ worktreeId: FLOATING_TERMINAL_WORKTREE_ID }),
{ forceContentReload: true }
{ forceContentReload: true },
null
)
expect(mainSelection()).toEqual(before)
})
@@ -5,16 +5,18 @@ const mocks = vi.hoisted(() => {
const settings: { activeRuntimeEnvironmentId: string | null } = {
activeRuntimeEnvironmentId: null
}
const tabGroups: Record<string, unknown> = {}
return {
canOpenWithSystemDefault: true,
openDetectedFilePath: vi.fn(),
settings,
worktreeRoot: false
worktreeRoot: false,
tabGroups
}
})
vi.mock('@/store', () => ({
useAppStore: { getState: () => ({ settings: mocks.settings }) }
useAppStore: { getState: () => ({ settings: mocks.settings, ...mocks.tabGroups }) }
}))
vi.mock('./terminal-file-open-routing', () => ({
@@ -72,6 +74,7 @@ beforeEach(() => {
mocks.canOpenWithSystemDefault = true
mocks.settings = { activeRuntimeEnvironmentId: null }
mocks.worktreeRoot = false
mocks.tabGroups = {}
vi.clearAllMocks()
})
@@ -291,3 +294,82 @@ describe('terminal file link actions', () => {
})
})
})
describe('open in this group', () => {
function seedGroups(groupIds: string[]): void {
const tabs = groupIds.map((groupId) => ({
id: `${groupId}-tab`,
entityId: `${groupId}-terminal`,
groupId,
contentType: 'terminal'
}))
mocks.tabGroups = {
unifiedTabsByWorktree: { 'wt-1': tabs },
groupsByWorktree: {
'wt-1': groupIds.map((id) => ({ id, activeTabId: `${id}-tab`, tabOrder: [`${id}-tab`] }))
},
layoutByWorktree: {
'wt-1':
groupIds.length === 1
? { type: 'leaf', groupId: groupIds[0] }
: {
type: 'split',
direction: 'horizontal',
first: { type: 'leaf', groupId: groupIds[0] },
second: { type: 'leaf', groupId: groupIds[1] }
}
}
}
}
function rowLabels(request: ReturnType<typeof vi.fn>): string[] {
const actionRequest = request.mock.calls[0][0]
return (actionRequest.secondaryActions ?? []).map((row: { label: string }) => row.label)
}
it('pins the source group when the source has a neighbor', () => {
seedGroups(['A', 'B'])
const request = vi.fn()
const sourceDeps = { ...deps, sourceTabId: 'A-terminal' }
handleTerminalFileLink('/repo/src/main.ts', 3, null, plainEvent(), sourceDeps, context(request))
const rows = request.mock.calls[0][0].secondaryActions
expect(rowLabels(request)).toContain('Open in this group')
rows.find((row: { label: string }) => row.label === 'Open in this group').run()
expect(mocks.openDetectedFilePath).toHaveBeenCalledWith('/repo/src/main.ts', 3, null, {
...sourceDeps,
openInSourceGroup: true
})
})
it('omits the row for a single group, an unknown source, or a workspace switch', () => {
seedGroups(['A'])
const single = vi.fn()
handleTerminalFileLink(
'/repo/a.ts',
null,
null,
plainEvent(),
{ ...deps, sourceTabId: 'A-terminal' },
context(single)
)
expect(rowLabels(single)).not.toContain('Open in this group')
seedGroups(['A', 'B'])
const unknown = vi.fn()
handleTerminalFileLink('/repo/a.ts', null, null, plainEvent(), deps, context(unknown))
expect(rowLabels(unknown)).not.toContain('Open in this group')
mocks.worktreeRoot = true
const root = vi.fn()
handleTerminalFileLink(
'/repo',
null,
null,
plainEvent(),
{ ...deps, sourceTabId: 'A-terminal' },
context(root)
)
expect(rowLabels(root)).not.toContain('Open in this group')
})
})
@@ -18,6 +18,7 @@ import { translate } from '@/i18n/i18n'
import { getRevealInFileManagerLabel, revealInFileManager } from '@/lib/reveal-in-file-manager'
import { useAppStore } from '@/store'
import { getLocalPathOpenOwnerForRoute, isLocalPathOpenBlocked } from '@/lib/local-path-open-guard'
import { canOpenLinkInSourceGroup } from '@/store/slices/editor/tabs/editor-open-target-group'
export type TerminalFileLinkActionDeps = {
worktreeId: string
@@ -27,6 +28,8 @@ export type TerminalFileLinkActionDeps = {
/** The paired-server terminal that printed the link; lets its host grant a file outside workspaces. */
terminalHandle?: string | null
onOpenFailure?: (failure: FileOpenFailure) => void
/** The tab that owns the link; editor opens place themselves relative to its group. */
sourceTabId?: string | null
}
export type FileLinkActions = Pick<
@@ -148,6 +151,21 @@ export function buildFileLinkActions(
run: () => revealInFileManager(mappedPath, revealOwner)
}
: null
// Why: eligibility is unknown until the path resolves, so the row may still be refused then.
const openInGroupRow =
!worktreeRoot &&
fileContext.sourceHostResolved &&
canOpenLinkInSourceGroup(useAppStore.getState(), deps.worktreeId, deps.sourceTabId)
? {
label: translate(
'auto.components.terminal.pane.TerminalLinkActionPopover.openInThisGroup',
'Open in this group'
),
run: () =>
openDetectedFilePath(filePath, line, column, { ...deps, openInSourceGroup: true })
}
: null
const secondaryActions = [openInGroupRow, revealRow].filter((row) => row !== null)
return {
destination: mappedPath,
kind: worktreeRoot ? 'workspace' : 'file',
@@ -164,6 +182,6 @@ export function buildFileLinkActions(
run: () => openDetectedFilePath(filePath, line, column, deps)
},
...(systemDefaultRow ? { alternate: systemDefaultRow } : {}),
...(revealRow ? { secondaryActions: [revealRow] } : {})
...(secondaryActions.length > 0 ? { secondaryActions } : {})
}
}
@@ -0,0 +1,70 @@
import { detectLanguage } from '@/lib/language-detect'
import { useAppStore } from '@/store'
import { scheduleEditorLineReveal } from '@/store/slices/editor/focus/editor-focus-reveal'
import { isLiveEditorOpenGroup } from '@/store/slices/editor/tabs/editor-open-target-group'
import type { OpenFile } from '@/store/slices/editor/types/open-file'
import type {
EditorOpenLinkIntent,
EditorOpenResult
} from '@/store/slices/editor/types/editor-open-link-intent'
import type { ExecutionHostId } from '../../../../shared/execution-host'
/** An explicit group request may only end in an editor in the scope it was captured from. */
export function isExplicitEditorScopeKept(
intent: EditorOpenLinkIntent | null,
worktreeId: string,
executionHostId: ExecutionHostId,
clientLocal: boolean
): boolean {
if (!intent?.targetGroupId || intent.workspaceId !== worktreeId || clientLocal) {
return false
}
return (
(intent.hostId === null || executionHostId === intent.hostId) &&
isLiveEditorOpenGroup(useAppStore.getState(), worktreeId, intent.targetGroupId)
)
}
/** The returned view still exists where the open put it, showing the same document. */
function isOpenedEditorViewCurrent(worktreeId: string, opened: EditorOpenResult): boolean {
return (useAppStore.getState().unifiedTabsByWorktree?.[worktreeId] ?? []).some(
(tab) =>
tab.id === opened.viewStateId &&
tab.groupId === opened.groupId &&
tab.entityId === opened.fileId
)
}
/** Opens a resolved link target beside its source, then reveals its line in exactly that view. */
export function openLinkedFileInEditor(
file: Omit<OpenFile, 'id' | 'isDirty' | 'language' | 'mode'>,
intent: EditorOpenLinkIntent | null,
position: { line: number | null; column: number | null },
isCurrentRequest: () => boolean
): EditorOpenResult | null {
const language = detectLanguage(file.filePath)
const opened = useAppStore
.getState()
.openFileForLink({ ...file, language, mode: 'edit' }, { forceContentReload: true }, intent)
if (!opened || position.line === null) {
return opened
}
if (language === 'markdown') {
// Why: rich Markdown has no line-based reveal consumer; line links must mount Monaco.
useAppStore.getState().setMarkdownViewMode(opened.fileId, 'source')
}
// Why: another view of the same document keeps its cursor, and a newer click (even one
// without a line) supersedes this reveal until the editor consumes it.
scheduleEditorLineReveal(
useAppStore.getState,
file.filePath,
position.line,
position.column ?? 1,
opened.fileId,
{
viewStateId: opened.viewStateId,
canApply: () => isCurrentRequest() && isOpenedEditorViewCurrent(file.worktreeId, opened)
}
)
return opened
}
@@ -19,6 +19,7 @@ type FileLinkHitTestDeps = {
terminalHandle?: string | null
pathExistsCache?: Map<string, boolean>
openWithSystemDefault?: boolean
sourceTabId?: string | null
}
export function openFilePathLinkAtBufferPosition(
@@ -0,0 +1,13 @@
import { toast } from 'sonner'
import { translate } from '@/i18n/i18n'
/** The file resolved to something other than an editor in the captured group, so nothing opened. */
export function showOpenInGroupRefusedToast(filePath: string): void {
toast.error(
translate(
'auto.components.terminal.pane.TerminalLinkActionPopover.openInThisGroupRefused',
"Couldn't open {{value0}} in this group",
{ value0: filePath }
)
)
}
@@ -1,7 +1,6 @@
import { absolutePathToFileUri } from '@/components/editor/markdown-internal-links'
import { getWorkspaceFilePreviewPlan, openFileInBrowserTab } from '@/lib/file-preview'
import { downloadAndOpenRemoteTerminalFile } from './terminal-remote-file-download-open'
import { detectLanguage } from '@/lib/language-detect'
import { findWorkspaceFileRoute } from '@/lib/runtime-workspace-file-route'
import { isPathInsideWorktree, toWorktreeRelativePath } from '@/lib/terminal-links'
import { canClientOsOpenWorkspaceFile } from '@/lib/workspace-file-host-routing'
@@ -18,8 +17,7 @@ import {
import {
LOCAL_EXECUTION_HOST_ID,
getConnectionExecutionHostId,
toRuntimeExecutionHostId,
type ExecutionHostId
toRuntimeExecutionHostId
} from '../../../../shared/execution-host'
import { statUserOpenedPath } from '@/lib/user-opened-local-path'
import {
@@ -28,6 +26,10 @@ import {
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 { cancelPendingEditorLineRevealFrames } from '@/store/slices/editor/focus/editor-focus-reveal'
import { showOpenInGroupRefusedToast } from './terminal-file-open-in-group-toast'
import { isExplicitEditorScopeKept, openLinkedFileInEditor } from './terminal-file-link-editor-open'
export {
getTerminalFileContext,
@@ -54,6 +56,10 @@ type TerminalFileOpenDeps = {
terminalHandle?: string | null
/** Reports a path that could not be verified before opening; skipped once a later open supersedes it. */
onOpenFailure?: (failure: FileOpenFailure) => void
/** The tab that owns the link; editor opens place themselves relative to its group. */
sourceTabId?: string | null
/** "Open in this group": pins the source tab's group at activation, or refuses. */
openInSourceGroup?: boolean
}
export function isHtmlFilePath(filePath: string): boolean {
@@ -84,33 +90,6 @@ export function shouldOpenTerminalFileWithSystemDefault(fileContext: TerminalFil
}
let latestOpenDetectedFilePathRequestId = 0
let pendingEditorRevealFrameIds: number[] = []
function cancelPendingEditorRevealFrames(): void {
if (typeof cancelAnimationFrame === 'function') {
for (const frameId of pendingEditorRevealFrameIds) {
cancelAnimationFrame(frameId)
}
}
pendingEditorRevealFrameIds = []
}
function schedulePendingEditorReveal(callback: () => void): void {
cancelPendingEditorRevealFrames()
const firstFrameId = requestAnimationFrame(() => {
pendingEditorRevealFrameIds = pendingEditorRevealFrameIds.filter(
(frameId) => frameId !== firstFrameId
)
const secondFrameId = requestAnimationFrame(() => {
pendingEditorRevealFrameIds = pendingEditorRevealFrameIds.filter(
(frameId) => frameId !== secondFrameId
)
callback()
})
pendingEditorRevealFrameIds.push(secondFrameId)
})
pendingEditorRevealFrameIds.push(firstFrameId)
}
export function openDetectedFilePath(
filePath: string,
@@ -126,11 +105,30 @@ export function openDetectedFilePath(
terminalLinkWslDistro(deps.wslDistro, runtimeEnvironmentId)
)
const requestId = ++latestOpenDetectedFilePathRequestId
cancelPendingEditorRevealFrames()
cancelPendingEditorLineRevealFrames()
// Why: capture at activation; focus may move while the host resolves the path.
const linkIntent = captureEditorOpenLinkIntent(
useAppStore.getState(),
deps.worktreeId,
deps.sourceTabId,
{ explicitSourceGroup: deps.openInSourceGroup === true }
)
const explicitGroupRequest = deps.openInSourceGroup === true
// Why: an explicit group request never substitutes a browser, system app, or download.
const refuseExplicitNonEditorRoute = (): boolean => {
if (!explicitGroupRequest) {
return false
}
if (requestId === latestOpenDetectedFilePathRequestId) {
showOpenInGroupRefusedToast(mappedFilePath)
}
return true
}
void (async () => {
let statResult
let fileContext = getTerminalFileContext(worktreeId, worktreePath, runtimeEnvironmentId)
const sourceRuntimeOwnerId = runtimeTargetEnvironmentId(fileContext.target)
let hostWorkspaceFile: HostWorkspaceFile | null = null
let hostGrantId: string | null = null
let clientLocal = false
@@ -149,6 +147,9 @@ export function openDetectedFilePath(
fileContext
)
if (worktreeRootLink) {
if (refuseExplicitNonEditorRoute()) {
return
}
// Why: root workspace switching must work for SSH/runtime paths without
// local auth/stat, while still coalescing provider + fallback clicks.
await Promise.resolve()
@@ -218,6 +219,9 @@ export function openDetectedFilePath(
}
if (statResult.isDirectory) {
if (refuseExplicitNonEditorRoute()) {
return
}
if (canOpenWithSystemDefault) {
await window.api.shell.openFilePath(mappedFilePath, LOCAL_EXECUTION_HOST_ID)
}
@@ -236,6 +240,10 @@ export function openDetectedFilePath(
// and remain the fallback if Shift+Cmd/Ctrl cannot launch the OS default.
if (isHtmlFilePath(mappedFilePath) && hostGrantId === null) {
if (canOpenWithSystemDefault) {
// Why: Orca's browser has no group placement in v1.
if (refuseExplicitNonEditorRoute()) {
return
}
openHtmlFileInBrowser(mappedFilePath, worktreeId, clientLocal)
return
}
@@ -243,6 +251,9 @@ export function openDetectedFilePath(
// unsupported plan (e.g. a paired doc outside the worktree) falls back to source.
const plan = getWorkspaceFilePreviewPlan(useAppStore.getState(), worktreeId, mappedFilePath)
if (plan.status === 'doc-preview') {
if (refuseExplicitNonEditorRoute()) {
return
}
activateAndRevealWorktree(worktreeId, { providesInitialSurface: true })
openFileInBrowserTab({ filePath: mappedFilePath, worktreeId })
return
@@ -251,7 +262,12 @@ export function openDetectedFilePath(
const store = useAppStore.getState()
let targetWorktreeId = worktreeId
let targetExecutionHostId: ExecutionHostId | undefined = hostWorkspaceFile?.executionHostId
const runtimeOwnerId = runtimeTargetEnvironmentId(fileContext.target) ?? sourceRuntimeOwnerId
let targetExecutionHostId =
hostWorkspaceFile?.executionHostId ??
(runtimeOwnerId
? toRuntimeExecutionHostId(runtimeOwnerId)
: getConnectionExecutionHostId(fileContext.connectionId))
let relativePath = mappedFilePath
if (worktreePath && isPathInsideWorktree(mappedFilePath, worktreePath)) {
const maybeRelative = toWorktreeRelativePath(mappedFilePath, worktreePath)
@@ -268,11 +284,7 @@ export function openDetectedFilePath(
)
) {
// Why: early resolution is only needed to avoid an existing sibling-tab collision.
const runtimeOwnerId = runtimeTargetEnvironmentId(fileContext.target)
const executionHostId = runtimeOwnerId
? toRuntimeExecutionHostId(runtimeOwnerId)
: getConnectionExecutionHostId(fileContext.connectionId)
const siblingRoute = findWorkspaceFileRoute(store, executionHostId, mappedFilePath)
const siblingRoute = findWorkspaceFileRoute(store, targetExecutionHostId, mappedFilePath)
if (siblingRoute) {
targetWorktreeId = siblingRoute.worktreeId
targetExecutionHostId = siblingRoute.executionHostId
@@ -280,23 +292,29 @@ export function openDetectedFilePath(
}
}
if (
explicitGroupRequest &&
!isExplicitEditorScopeKept(linkIntent, targetWorktreeId, targetExecutionHostId, clientLocal)
) {
// Why: refuse before activating the destination or touching editor state.
refuseExplicitNonEditorRoute()
return
}
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(targetWorktreeId, {
providesInitialSurface: true,
...(targetExecutionHostId ? { executionHostId: targetExecutionHostId } : {})
executionHostId: targetExecutionHostId
})
}
const language = detectLanguage(mappedFilePath)
store.openFile(
const opened = openLinkedFileInEditor(
{
filePath: mappedFilePath,
relativePath,
worktreeId: targetWorktreeId || '',
language,
mode: 'edit',
runtimeEnvironmentId: clientLocal ? null : runtimeEnvironmentId,
// Why: a tab's owner must match its workspace's to be editable; this one is this computer's.
...(clientLocal && targetWorktreeId === worktreeId ? { readOnly: true } : {}),
@@ -310,32 +328,12 @@ export function openDetectedFilePath(
? { externalSshTargetId: fileContext.connectionId }
: {})
},
{ forceContentReload: true }
linkIntent,
{ line, column },
() => requestId === latestOpenDetectedFilePathRequestId
)
if (line !== null) {
const openedStore = useAppStore.getState()
// Why: scope the reveal to the opened editor tab id so owner-qualified tabs
// across local/SSH/runtime contexts get it instead of an ambiguous path key.
const fileId = openedStore.activeFileIdByWorktree[targetWorktreeId] ?? mappedFilePath
if (language === 'markdown') {
// Why: rich Markdown has no line-based reveal consumer; line links must mount Monaco.
openedStore.setMarkdownViewMode(fileId, 'source')
}
const targetColumn = column ?? 1
store.setPendingEditorReveal(null)
schedulePendingEditorReveal(() => {
if (requestId !== latestOpenDetectedFilePathRequestId) {
return
}
store.setPendingEditorReveal({
filePath: mappedFilePath,
fileId,
line,
column: targetColumn,
matchLength: 0
})
})
if (!opened) {
refuseExplicitNonEditorRoute()
}
})()
}
@@ -116,13 +116,16 @@ describe('createFilePathLinkProvider range bounds', () => {
}),
{ forceContentReload: true }
)
expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(2, {
filePath: '\\\\wsl.localhost\\Ubuntu\\root\\workspace\\myrepo\\README.md',
fileId: '\\\\wsl.localhost\\Ubuntu\\root\\workspace\\myrepo\\README.md',
line: 5,
column: 3,
matchLength: 0
})
expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
filePath: '\\\\wsl.localhost\\Ubuntu\\root\\workspace\\myrepo\\README.md',
fileId: '\\\\wsl.localhost\\Ubuntu\\root\\workspace\\myrepo\\README.md',
line: 5,
column: 3,
matchLength: 0
})
)
})
it('opens a single-row file path with the system default from shift modifier fallback', async () => {
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { activateAndRevealWorkspace } from '@/lib/worktree-activation'
import { openDetectedFilePath } from './terminal-link-handlers'
import { downloadAndOpenRemoteTerminalFile } from './terminal-remote-file-download-open'
import { createTerminalLinkTestDoubles } from './terminal-link-handlers-test-fixtures'
@@ -78,6 +79,10 @@ describe('a link to this computer printed in a paired-server terminal', () => {
access: { kind: 'user-file' }
})
)
expect(activateAndRevealWorkspace).toHaveBeenCalledWith('wt-feat', {
providesInitialSurface: true,
executionHostId: 'runtime:env-1'
})
expect(openFileMock).toHaveBeenCalledWith(
expect.objectContaining({
filePath: '/Users/me/notes.md',
@@ -100,17 +100,21 @@ describe('handleOscLink', () => {
{ forceContentReload: true }
)
expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(1, null)
expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(2, {
filePath: '/tmp/src/main.ts',
fileId: '/tmp/src/main.ts',
line: 42,
column: 1,
matchLength: 0
})
expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
filePath: '/tmp/src/main.ts',
fileId: '/tmp/src/main.ts',
line: 42,
column: 1,
matchLength: 0
})
)
expect(openFilePathMock).not.toHaveBeenCalled()
// Why: the editor file is the surface — the cross-worktree jump must not add a shell.
expect(activateAndRevealWorkspace).toHaveBeenCalledWith('wt-1', {
providesInitialSurface: true
providesInitialSurface: true,
executionHostId: 'local'
})
})
@@ -168,13 +172,16 @@ describe('handleOscLink', () => {
await flushDoubleRaf()
expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(1, null)
expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(2, {
filePath: '/tmp/src/main.ts',
fileId: '/tmp/src/main.ts',
line: 42,
column: 7,
matchLength: 0
})
expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
filePath: '/tmp/src/main.ts',
fileId: '/tmp/src/main.ts',
line: 42,
column: 7,
matchLength: 0
})
)
expect(openFilePathMock).not.toHaveBeenCalled()
})
@@ -191,13 +198,15 @@ describe('handleOscLink', () => {
await flushDoubleRaf()
expect(setMarkdownViewModeMock).toHaveBeenCalledWith(fileId, 'source')
expect(setPendingEditorRevealMock).toHaveBeenLastCalledWith({
filePath,
fileId,
line: 230,
column: 1,
matchLength: 0
})
expect(setPendingEditorRevealMock).toHaveBeenLastCalledWith(
expect.objectContaining({
filePath,
fileId,
line: 230,
column: 1,
matchLength: 0
})
)
})
it('scopes non-Markdown line reveals to the owner-qualified editor tab', async () => {
@@ -213,13 +222,15 @@ describe('handleOscLink', () => {
await flushDoubleRaf()
expect(setMarkdownViewModeMock).not.toHaveBeenCalled()
expect(setPendingEditorRevealMock).toHaveBeenLastCalledWith({
filePath,
fileId,
line: 42,
column: 7,
matchLength: 0
})
expect(setPendingEditorRevealMock).toHaveBeenLastCalledWith(
expect.objectContaining({
filePath,
fileId,
line: 42,
column: 7,
matchLength: 0
})
)
})
it('uses the system default app for shift+cmd/ctrl-click file paths', async () => {
@@ -253,13 +264,16 @@ describe('handleOscLink', () => {
{ forceContentReload: true }
)
expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(1, null)
expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(2, {
filePath: '/tmp/src/main.ts',
fileId: '/tmp/src/main.ts',
line: 42,
column: 7,
matchLength: 0
})
expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
filePath: '/tmp/src/main.ts',
fileId: '/tmp/src/main.ts',
line: 42,
column: 7,
matchLength: 0
})
)
})
it('cancels a pending Monaco reveal frame when another file open starts', async () => {
@@ -306,12 +320,15 @@ describe('handleOscLink', () => {
{ forceContentReload: true }
)
expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(1, null)
expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(2, {
filePath: '/tmp/src/second.ts',
fileId: '/tmp/src/second.ts',
line: 20,
column: 3,
matchLength: 0
})
expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
filePath: '/tmp/src/second.ts',
fileId: '/tmp/src/second.ts',
line: 20,
column: 3,
matchLength: 0
})
)
})
})
@@ -1,5 +1,6 @@
import { vi, type Mock } from 'vitest'
import type { ExecutionHostId } from '../../../../shared/execution-host'
import type { Tab, TabGroup } from '../../../../shared/tab-types'
export type TerminalLinkStoreSettings = {
openLinksInApp?: boolean
@@ -12,10 +13,17 @@ export type TerminalLinkStoreState = {
setActiveWorktree: Mock
createBrowserTab: Mock
openFile: Mock
openFileForLink: Mock
setPendingEditorReveal: Mock
setMarkdownViewMode: Mock
activeFileIdByWorktree: Record<string, string | null>
openFiles: { filePath: string; worktreeId: string }[]
groupsByWorktree: Record<string, TabGroup[]>
unifiedTabsByWorktree: Record<
string,
(Pick<Tab, 'id' | 'entityId' | 'groupId'> &
Partial<Pick<Tab, 'contentType' | 'executionHostId'>>)[]
>
worktreesByRepo: Record<
string,
{
@@ -34,6 +42,7 @@ export type TerminalLinkTestDoubles = {
openFileUriMock: Mock
openFilePathMock: Mock
openFileMock: Mock
openFileForLinkMock: Mock
statMock: Mock
fsPathExistsMock: Mock
runtimeEnvironmentCallMock: Mock
@@ -62,15 +71,35 @@ export function createTerminalLinkTestDoubles(): TerminalLinkTestDoubles {
const setMarkdownViewModeMock = vi.fn()
const deps = { worktreeId: 'wt-1', worktreePath: '/tmp' }
// Why: specs assert the legacy openFile(file, options) shape; the link entry point delegates to it
// and records the view it "opened" so the guarded reveal can find it.
const openFileForLinkMock = vi.fn(
(file: { filePath: string; worktreeId: string }, options: unknown) => {
const returned: unknown = openFileMock(file, options)
const fileId =
typeof returned === 'string'
? returned
: (storeState.activeFileIdByWorktree[file.worktreeId] ?? file.filePath)
const view = { id: `view:${fileId}`, entityId: fileId, groupId: 'group-1' }
storeState.unifiedTabsByWorktree = {
...storeState.unifiedTabsByWorktree,
[file.worktreeId]: [...(storeState.unifiedTabsByWorktree[file.worktreeId] ?? []), view]
}
return { fileId, viewStateId: view.id, groupId: view.groupId }
}
)
const storeState: TerminalLinkStoreState = {
settings: undefined as TerminalLinkStoreSettings | undefined,
setActiveWorktree: setActiveWorktreeMock,
createBrowserTab: createBrowserTabMock,
openFile: openFileMock,
openFileForLink: openFileForLinkMock,
setPendingEditorReveal: setPendingEditorRevealMock,
setMarkdownViewMode: setMarkdownViewModeMock,
activeFileIdByWorktree: {} as Record<string, string | null>,
openFiles: [] as { filePath: string; worktreeId: string }[],
unifiedTabsByWorktree: {},
groupsByWorktree: {},
worktreesByRepo: {},
folderWorkspaces: [] as []
}
@@ -80,6 +109,7 @@ export function createTerminalLinkTestDoubles(): TerminalLinkTestDoubles {
openFileUriMock,
openFilePathMock,
openFileMock,
openFileForLinkMock,
statMock,
fsPathExistsMock,
runtimeEnvironmentCallMock,
@@ -58,6 +58,8 @@ export function installTerminalLinkTestEnvironment(doubles: TerminalLinkTestDoub
storeState.settings = undefined
storeState.activeFileIdByWorktree = {}
storeState.openFiles = []
storeState.unifiedTabsByWorktree = {}
storeState.groupsByWorktree = {}
storeState.worktreesByRepo = {}
registerHttpLinkStoreAccessor(() => storeState)
vi.stubGlobal('window', {
@@ -52,6 +52,8 @@ export type LinkHandlerDeps = {
/** The pane's terminal on its paired server, which may grant files it printed. */
getRuntimeTerminalHandleForPane?: (paneId: number) => string | null
getLinkActionContext?: (paneId: number) => TerminalLinkActionContext | null
/** The terminal tab whose panes print these links; editor opens place beside its group. */
sourceTabId?: string
}
type ProvidedFileLink = {
@@ -154,7 +156,8 @@ export function createFilePathLinkProvider(
worktreePath,
runtimeEnvironmentId,
wslDistro: deps.wslDistro,
terminalHandle
terminalHandle,
sourceTabId: deps.sourceTabId
},
deps.getLinkActionContext?.(paneId)
)
@@ -249,7 +252,8 @@ export function installFilePathLinkClickFallback(
wslDistro: deps.wslDistro,
terminalHandle: deps.getRuntimeTerminalHandleForPane?.(paneId) ?? null,
pathExistsCache: deps.pathExistsCache,
openWithSystemDefault: Boolean(event.shiftKey)
openWithSystemDefault: Boolean(event.shiftKey),
sourceTabId: deps.sourceTabId
}
)
if (opened) {
@@ -0,0 +1,249 @@
import { 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'
import {
createDeferred,
flushAsyncWork,
flushDoubleRaf,
installTerminalLinkTestEnvironment,
setPlatform
} from './terminal-link-handlers-test-harness'
import type { PendingEditorReveal } from '@/store/slices/editor/types/pending-editor-reveal'
const findWorkspaceFileRouteMock = vi.hoisted(() => vi.fn())
const toastErrorMock = vi.hoisted(() => vi.fn())
const doubles = createTerminalLinkTestDoubles()
const {
storeState,
deps,
openFileForLinkMock,
createBrowserTabMock,
openFilePathMock,
setPendingEditorRevealMock,
statMock
} = doubles
vi.mock('@/store', () => ({
useAppStore: {
getState: () => storeState
}
}))
vi.mock('sonner', () => ({ toast: { error: toastErrorMock } }))
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
}))
installTerminalLinkTestEnvironment(doubles)
function seedSourceTab(): void {
storeState.groupsByWorktree = {
'wt-1': [
{ id: 'group-a', worktreeId: 'wt-1', activeTabId: 'unified-term', tabOrder: ['unified-term'] }
]
}
storeState.unifiedTabsByWorktree = {
'wt-1': [
{ id: 'unified-term', entityId: 'term-1', groupId: 'group-a', contentType: 'terminal' }
]
}
}
const sourceDeps = { ...deps, sourceTabId: 'term-1' }
function lastReveal(): PendingEditorReveal {
return setPendingEditorRevealMock.mock.calls.at(-1)?.[0]
}
describe('terminal file links carry their source tab', () => {
it('captures the source tab at activation and passes it to the open', async () => {
setPlatform('Macintosh')
seedSourceTab()
const stat = createDeferred<{ isDirectory: boolean }>()
statMock.mockImplementationOnce(() => stat.promise)
openDetectedFilePath('/tmp/src/a.ts', null, null, sourceDeps)
// Why: a move after the click changes where ranking starts, not which tab was captured.
storeState.unifiedTabsByWorktree = {}
stat.resolve({ isDirectory: false })
await flushAsyncWork()
expect(openFileForLinkMock).toHaveBeenCalledWith(
expect.objectContaining({ filePath: '/tmp/src/a.ts', worktreeId: 'wt-1' }),
{ forceContentReload: true },
{ sourceTabId: 'unified-term', workspaceId: 'wt-1', hostId: null }
)
})
it('pins an explicit request to the source group', async () => {
setPlatform('Macintosh')
seedSourceTab()
openDetectedFilePath('/tmp/src/a.ts', null, null, { ...sourceDeps, openInSourceGroup: true })
await flushAsyncWork()
expect(openFileForLinkMock).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({
workspaceId: 'wt-1',
hostId: null,
targetGroupId: 'group-a'
})
)
})
it('refuses an explicit request that resolves to Orca browser HTML', async () => {
setPlatform('Macintosh')
seedSourceTab()
openDetectedFilePath('/tmp/report.html', null, null, { ...sourceDeps, openInSourceGroup: true })
await flushAsyncWork()
expect(createBrowserTabMock).not.toHaveBeenCalled()
expect(openFileForLinkMock).not.toHaveBeenCalled()
expect(toastErrorMock).toHaveBeenCalledTimes(1)
})
it('refuses a closed explicit group before activating its workspace', async () => {
setPlatform('Macintosh')
seedSourceTab()
const stat = createDeferred<{ isDirectory: boolean }>()
statMock.mockImplementationOnce(() => stat.promise)
openDetectedFilePath('/tmp/src/a.ts', null, null, { ...sourceDeps, openInSourceGroup: true })
storeState.groupsByWorktree = {}
stat.resolve({ isDirectory: false })
await flushAsyncWork()
expect(activateAndRevealWorkspace).not.toHaveBeenCalled()
expect(openFileForLinkMock).not.toHaveBeenCalled()
expect(toastErrorMock).toHaveBeenCalledTimes(1)
})
it('checks the resolved host even when no sibling-workspace route is needed', async () => {
setPlatform('Macintosh')
seedSourceTab()
storeState.unifiedTabsByWorktree['wt-1'][0].executionHostId = 'ssh:other-host'
openDetectedFilePath('/tmp/src/a.ts', null, null, { ...sourceDeps, openInSourceGroup: true })
await flushAsyncWork()
expect(activateAndRevealWorkspace).not.toHaveBeenCalled()
expect(openFileForLinkMock).not.toHaveBeenCalled()
expect(toastErrorMock).toHaveBeenCalledTimes(1)
})
it('refuses an explicit request that resolves to a directory', async () => {
setPlatform('Macintosh')
seedSourceTab()
statMock.mockResolvedValueOnce({ isDirectory: true })
openDetectedFilePath('/tmp/src', null, null, { ...sourceDeps, openInSourceGroup: true })
await flushAsyncWork()
expect(openFilePathMock).not.toHaveBeenCalled()
expect(openFileForLinkMock).not.toHaveBeenCalled()
expect(toastErrorMock).toHaveBeenCalledTimes(1)
})
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',
relativePath: 'x.ts'
})
openDetectedFilePath('/other/x.ts', null, null, { ...sourceDeps, openInSourceGroup: true })
await flushAsyncWork()
expect(activateAndRevealWorkspace).not.toHaveBeenCalled()
expect(openFileForLinkMock).not.toHaveBeenCalled()
expect(toastErrorMock).toHaveBeenCalledTimes(1)
})
it('keeps automatic HTML opens on their existing browser route', async () => {
setPlatform('Macintosh')
seedSourceTab()
openDetectedFilePath('/tmp/report.html', null, null, sourceDeps)
await flushAsyncWork()
expect(createBrowserTabMock).toHaveBeenCalledTimes(1)
expect(toastErrorMock).not.toHaveBeenCalled()
})
})
describe('terminal file link reveals target the opened view', () => {
it('scopes the reveal to the returned view', async () => {
setPlatform('Macintosh')
openDetectedFilePath('/tmp/src/a.ts', 12, 3, deps)
await flushAsyncWork()
await flushDoubleRaf()
const reveal = lastReveal()
expect(reveal).toMatchObject({
fileId: '/tmp/src/a.ts',
viewStateId: 'view:/tmp/src/a.ts',
line: 12,
column: 3
})
expect(reveal.canApply?.()).toBe(true)
})
it('invalidates a published reveal when a newer click has no line', async () => {
setPlatform('Macintosh')
openDetectedFilePath('/tmp/src/a.ts', 12, 3, deps)
await flushAsyncWork()
await flushDoubleRaf()
const reveal = lastReveal()
openDetectedFilePath('/tmp/src/b.ts', null, null, deps)
expect(reveal.canApply?.()).toBe(false)
})
it('invalidates a reveal whose view closed before the editor consumed it', async () => {
setPlatform('Macintosh')
openDetectedFilePath('/tmp/src/a.ts', 12, 3, deps)
await flushAsyncWork()
await flushDoubleRaf()
const reveal = lastReveal()
storeState.unifiedTabsByWorktree = { 'wt-1': [] }
expect(reveal.canApply?.()).toBe(false)
})
it('skips the reveal when the open was refused', async () => {
setPlatform('Macintosh')
openFileForLinkMock.mockReturnValueOnce(null)
openDetectedFilePath('/tmp/src/a.ts', 12, 3, deps)
await flushAsyncWork()
await flushDoubleRaf()
expect(setPendingEditorRevealMock).not.toHaveBeenCalled()
})
})
@@ -89,13 +89,16 @@ describe('handleOscLink', () => {
{ forceContentReload: true }
)
expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(1, null)
expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(2, {
filePath: 'C:/repo/src/index.ts',
fileId: 'C:/repo/src/index.ts',
line: 12,
column: 3,
matchLength: 0
})
expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
filePath: 'C:/repo/src/index.ts',
fileId: 'C:/repo/src/index.ts',
line: 12,
column: 3,
matchLength: 0
})
)
})
it('opens Windows UNC file URL links from Windows worktrees', async () => {
@@ -150,13 +153,16 @@ describe('handleOscLink', () => {
{ forceContentReload: true }
)
expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(1, null)
expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(2, {
filePath: '/tmp/test.txt',
fileId: '/tmp/test.txt',
line: 42,
column: 1,
matchLength: 0
})
expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
filePath: '/tmp/test.txt',
fileId: '/tmp/test.txt',
line: 42,
column: 1,
matchLength: 0
})
)
})
it('opens file URL links with the system default app for shift+cmd/ctrl-click', async () => {
@@ -193,13 +199,16 @@ describe('handleOscLink', () => {
{ forceContentReload: true }
)
expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(1, null)
expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(2, {
filePath: '/tmp/test.txt',
fileId: '/tmp/test.txt',
line: 42,
column: 7,
matchLength: 0
})
expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
filePath: '/tmp/test.txt',
fileId: '/tmp/test.txt',
line: 42,
column: 7,
matchLength: 0
})
)
})
it('opens UNC file URL links with line and column anchors', async () => {
@@ -227,13 +236,16 @@ describe('handleOscLink', () => {
{ forceContentReload: true }
)
expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(1, null)
expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(2, {
filePath: '//server/Share/Repo/src/app.ts',
fileId: '//server/Share/Repo/src/app.ts',
line: 12,
column: 3,
matchLength: 0
})
expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
filePath: '//server/Share/Repo/src/app.ts',
fileId: '//server/Share/Repo/src/app.ts',
line: 12,
column: 3,
matchLength: 0
})
)
})
it('opens relative OSC file links against the terminal cwd', async () => {
@@ -290,13 +302,16 @@ describe('handleOscLink', () => {
}),
{ forceContentReload: true }
)
expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(2, {
filePath: '\\\\wsl.localhost\\Ubuntu\\root\\workspace\\myrepo\\README.md',
fileId: '\\\\wsl.localhost\\Ubuntu\\root\\workspace\\myrepo\\README.md',
line: 5,
column: 3,
matchLength: 0
})
expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
filePath: '\\\\wsl.localhost\\Ubuntu\\root\\workspace\\myrepo\\README.md',
fileId: '\\\\wsl.localhost\\Ubuntu\\root\\workspace\\myrepo\\README.md',
line: 5,
column: 3,
matchLength: 0
})
)
})
it('maps file URL OSC links for a WSL worktree before opening them', async () => {
@@ -325,13 +340,16 @@ describe('handleOscLink', () => {
}),
{ forceContentReload: true }
)
expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(2, {
filePath: '\\\\wsl.localhost\\Ubuntu\\root\\workspace\\myrepo\\README.md',
fileId: '\\\\wsl.localhost\\Ubuntu\\root\\workspace\\myrepo\\README.md',
line: 5,
column: 3,
matchLength: 0
})
expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
filePath: '\\\\wsl.localhost\\Ubuntu\\root\\workspace\\myrepo\\README.md',
fileId: '\\\\wsl.localhost\\Ubuntu\\root\\workspace\\myrepo\\README.md',
line: 5,
column: 3,
matchLength: 0
})
)
})
it('opens tilde OSC file links against explicit terminal home when cwd is outside home', async () => {
@@ -72,13 +72,15 @@ describe('createFilePathLinkProvider range bounds', () => {
expect(openFileMock).toHaveBeenCalledWith(expect.objectContaining({ filePath: mappedPath }), {
forceContentReload: true
})
expect(setPendingEditorRevealMock).toHaveBeenLastCalledWith({
filePath: mappedPath,
fileId: mappedPath,
line: 5,
column: 3,
matchLength: 0
})
expect(setPendingEditorRevealMock).toHaveBeenLastCalledWith(
expect.objectContaining({
filePath: mappedPath,
fileId: mappedPath,
line: 5,
column: 3,
matchLength: 0
})
)
})
it('resolves relative POSIX terminal links against the pane cwd before mapping', async () => {
@@ -48,7 +48,7 @@ export function handleOscLink(
Partial<
Pick<
LinkHandlerDeps,
'runtimeEnvironmentId' | 'startupCwd' | 'terminalHomePath' | 'wslDistro'
'runtimeEnvironmentId' | 'startupCwd' | 'terminalHomePath' | 'wslDistro' | 'sourceTabId'
>
> & {
sourceOwner?: HttpLinkSourceOwner
@@ -172,7 +172,8 @@ export function prepareTerminalPaneMount(
return sourceOwner.kind === 'runtime' ? sourceOwner.runtimeEnvironmentId : null
},
getRuntimeTerminalHandleForPane: (id) => resolveTerminalRuntimeHandle(paneTransports.get(id)),
getLinkActionContext
getLinkActionContext,
sourceTabId: deps.tabId
}
let resizeRaf: number | null = null
const queueResizeAll = (focusActive: boolean): void => {
+3 -1
View File
@@ -3521,7 +3521,9 @@
"copiedLink": "Copied link",
"copyLinkFailed": "Failed to copy link",
"downloadOpenWithDefaultApp": "Download & open with default app",
"downloadOpenFailed": "Failed to download '{{value0}}'."
"downloadOpenFailed": "Failed to download '{{value0}}'.",
"openInThisGroup": "Open in this group",
"openInThisGroupRefused": "Couldn't open {{value0}} in this group"
},
"TerminalSessionStateSaveFailureDialog": {
"6bee0c8f17": "Open Disk Space Analyzer",
+3 -1
View File
@@ -3427,7 +3427,9 @@
"copiedLink": "Enlace copiado",
"copyLinkFailed": "No se pudo copiar el enlace",
"downloadOpenWithDefaultApp": "Descargar y abrir con la aplicación predeterminada",
"downloadOpenFailed": "No se pudo descargar '{{value0}}'."
"downloadOpenFailed": "No se pudo descargar '{{value0}}'.",
"openInThisGroup": "Abrir en este grupo",
"openInThisGroupRefused": "No se pudo abrir {{value0}} en este grupo"
},
"TerminalQuickCommandsSubmenu": {
"3ccc7981bb": "Anfitrión no disponible",
+3 -1
View File
@@ -3401,7 +3401,9 @@
"copiedLink": "Lien copié",
"copyLinkFailed": "Échec de la copie du lien",
"downloadOpenWithDefaultApp": "Téléchargez et ouvrez avec l'application par défaut",
"downloadOpenFailed": "Échec du téléchargement de « {{value0}} »."
"downloadOpenFailed": "Échec du téléchargement de « {{value0}} ».",
"openInThisGroup": "Ouvrir dans ce groupe",
"openInThisGroupRefused": "Impossible d'ouvrir {{value0}} dans ce groupe"
},
"TerminalSessionStateSaveFailureDialog": {
"6bee0c8f17": "Ouvrir l'Analyseur d'espace disque",
+3 -1
View File
@@ -3429,7 +3429,9 @@
"copiedLink": "コピーされたリンク",
"copyLinkFailed": "リンクのコピーに失敗しました",
"downloadOpenWithDefaultApp": "ダウンロードしてデフォルトのアプリで開く",
"downloadOpenFailed": "「{{value0}}」のダウンロードに失敗しました。"
"downloadOpenFailed": "「{{value0}}」のダウンロードに失敗しました。",
"openInThisGroup": "このグループで開く",
"openInThisGroupRefused": "{{value0}} をこのグループで開けませんでした"
},
"TerminalQuickCommandsSubmenu": {
"3ccc7981bb": "ホストが利用不可",
+3 -1
View File
@@ -3429,7 +3429,9 @@
"copiedLink": "복사된 링크",
"copyLinkFailed": "링크를 복사하지 못했습니다.",
"downloadOpenWithDefaultApp": "기본 앱 다운로드 및 열기",
"downloadOpenFailed": "'{{value0}}'을(를) 다운로드하지 못했습니다."
"downloadOpenFailed": "'{{value0}}'을(를) 다운로드하지 못했습니다.",
"openInThisGroup": "이 그룹에서 열기",
"openInThisGroupRefused": "이 그룹에서 {{value0}}을(를) 열 수 없습니다"
},
"TerminalQuickCommandsSubmenu": {
"3ccc7981bb": "호스트를 사용할 수 없음",
+3 -1
View File
@@ -3429,7 +3429,9 @@
"copiedLink": "已复制链接",
"copyLinkFailed": "复制链接失败",
"downloadOpenWithDefaultApp": "下载并使用默认应用程序打开",
"downloadOpenFailed": "无法下载“{{value0}}”。"
"downloadOpenFailed": "无法下载“{{value0}}”。",
"openInThisGroup": "在此分组中打开",
"openInThisGroupRefused": "无法在此分组中打开 {{value0}}"
},
"TerminalQuickCommandsSubmenu": {
"3ccc7981bb": "主机不可用",
@@ -0,0 +1,323 @@
import type { StoreApi } from 'zustand/vanilla'
import { describe, expect, it, vi } from 'vitest'
import { createEditorTabsStore } from './editor-slice-test-harness'
import type { AppState } from '../types'
import type { OpenFile } from './editor/types/open-file'
import { captureEditorOpenLinkIntent } from './editor/tabs/editor-open-target-group'
import { removeGroupLayoutLeaf } from '../../../../shared/workspace-layout/tab-group-layout-tree'
vi.mock('sonner', () => ({ toast: { error: vi.fn() } }))
function fileInput(filePath: string, worktreeId = 'wt-1'): Omit<OpenFile, 'id' | 'isDirty'> {
return {
filePath,
relativePath: filePath.replace('/repo/', ''),
worktreeId,
language: 'typescript',
mode: 'edit'
}
}
function splitRight(store: StoreApi<AppState>, groupId: string): string {
const created = store.getState().createEmptySplitGroup('wt-1', groupId, 'right')
if (!created) {
throw new Error('Expected split group')
}
return created
}
function addTab(
store: StoreApi<AppState>,
contentType: 'terminal' | 'agent-session',
id: string,
groupId?: string
): void {
store.getState().createUnifiedTab('wt-1', contentType, {
id,
entityId: `${id}-entity`,
label: id,
...(groupId ? { targetGroupId: groupId } : {})
})
}
function addEditor(store: StoreApi<AppState>, filePath: string, groupId: string): void {
store.getState().openFile(fileInput(filePath), { targetGroupId: groupId })
}
/** Drops a tab the way a close would, without the terminal slice the harness lacks. */
function removeTab(store: StoreApi<AppState>, tabId: string): void {
const state = store.getState()
store.setState({
unifiedTabsByWorktree: {
'wt-1': state.unifiedTabsByWorktree['wt-1']!.filter((tab) => tab.id !== tabId)
},
groupsByWorktree: {
'wt-1': state.groupsByWorktree['wt-1']!.map((group) => {
const tabOrder = group.tabOrder.filter((id) => id !== tabId)
return { ...group, tabOrder, activeTabId: tabOrder.at(-1) ?? null }
})
}
})
}
function removeGroup(store: StoreApi<AppState>, groupId: string): void {
const state = store.getState()
store.setState({
groupsByWorktree: {
'wt-1': state.groupsByWorktree['wt-1']!.filter((group) => group.id !== groupId)
},
layoutByWorktree: {
'wt-1': removeGroupLayoutLeaf(state.layoutByWorktree['wt-1']!, groupId)!
}
})
}
function focus(store: StoreApi<AppState>, groupId: string): void {
store.setState({ activeGroupIdByWorktree: { 'wt-1': groupId } })
}
function groupOfFile(store: StoreApi<AppState>, filePath: string): string[] {
return (store.getState().unifiedTabsByWorktree['wt-1'] ?? [])
.filter((tab) => tab.entityId === filePath)
.map((tab) => tab.groupId)
}
/** Three columns A–B–C with the source tab in A. */
function seedColumns(
store: StoreApi<AppState>,
source: 'terminal' | 'agent-session',
middle: 'terminal' | 'editor',
right: 'terminal' | 'editor'
): { a: string; b: string; c: string } {
addTab(store, source, 'source')
const a = store.getState().groupsByWorktree['wt-1']![0]!.id
const b = splitRight(store, a)
const c = splitRight(store, b)
for (const [groupId, kind] of [
[b, middle],
[c, right]
] as const) {
if (kind === 'editor') {
addEditor(store, `/repo/${groupId}-seed.ts`, groupId)
} else {
addTab(store, 'terminal', `${groupId}-terminal`, groupId)
}
}
focus(store, a)
return { a, b, c }
}
function linkIntent(store: StoreApi<AppState>, explicit = false) {
return captureEditorOpenLinkIntent(store.getState(), 'wt-1', 'source', {
explicitSourceGroup: explicit
})
}
describe('file links open beside their source', () => {
it('captures the source only within the workspace that owns the link', () => {
const store = createEditorTabsStore()
store.getState().createUnifiedTab('wt-2', 'terminal', { id: 'source' })
addTab(store, 'terminal', 'source')
expect(linkIntent(store)?.workspaceId).toBe('wt-1')
expect(captureEditorOpenLinkIntent(store.getState(), 'missing', 'source')).toBeNull()
})
it('moves a native-chat link from the chat group to the neighboring editor', () => {
const store = createEditorTabsStore()
addTab(store, 'agent-session', 'source')
const a = store.getState().groupsByWorktree['wt-1']![0]!.id
const b = splitRight(store, a)
addEditor(store, '/repo/seed.ts', b)
focus(store, a)
store.getState().openFile(fileInput('/repo/plain.ts'))
const opened = store
.getState()
.openFileForLink(fileInput('/repo/link.ts'), {}, linkIntent(store))
// Why: the plain open documents the unchanged baseline for callers without link intent.
expect(groupOfFile(store, '/repo/plain.ts')).toEqual([a])
expect(groupOfFile(store, '/repo/link.ts')).toEqual([b])
expect(opened).toMatchObject({ fileId: '/repo/link.ts', groupId: b })
})
it('covers the adjacent terminal instead of jumping to a nonadjacent editor', () => {
const store = createEditorTabsStore()
const { a, b, c } = seedColumns(store, 'terminal', 'terminal', 'editor')
store.getState().openFile(fileInput('/repo/plain.ts'))
focus(store, a)
store.getState().openFileForLink(fileInput('/repo/link.ts'), {}, linkIntent(store))
expect(groupOfFile(store, '/repo/plain.ts')).toEqual([c])
expect(groupOfFile(store, '/repo/link.ts')).toEqual([b])
})
it('selects an existing unselected view of the file over a nearby editor', () => {
const store = createEditorTabsStore()
const { a, b, c } = seedColumns(store, 'terminal', 'editor', 'terminal')
addEditor(store, '/repo/link.ts', c)
store.getState().activateTab(`${c}-terminal`)
focus(store, a)
const existing = store
.getState()
.unifiedTabsByWorktree['wt-1']!.find((tab) => tab.entityId === '/repo/link.ts')!
const opened = store
.getState()
.openFileForLink(fileInput('/repo/link.ts'), {}, linkIntent(store))
expect(groupOfFile(store, '/repo/link.ts')).toEqual([c])
expect(opened).toEqual({ fileId: '/repo/link.ts', viewStateId: existing.id, groupId: c })
expect(store.getState().groupsByWorktree['wt-1']!.find((g) => g.id === c)?.activeTabId).toBe(
existing.id
)
expect(b).not.toBe(c)
})
it('opens a second view in the neighbor when only the source holds the file', () => {
const store = createEditorTabsStore()
const { a, b } = seedColumns(store, 'terminal', 'terminal', 'terminal')
addEditor(store, '/repo/link.ts', a)
store.getState().activateTab('source')
const opened = store
.getState()
.openFileForLink(fileInput('/repo/link.ts'), {}, linkIntent(store))
expect(groupOfFile(store, '/repo/link.ts').sort()).toEqual([a, b].sort())
expect(store.getState().openFiles.filter((f) => f.filePath === '/repo/link.ts')).toHaveLength(1)
expect(opened?.groupId).toBe(b)
})
it('keeps placement on the captured source when focus moves during resolution', () => {
const store = createEditorTabsStore()
const { b, c } = seedColumns(store, 'terminal', 'terminal', 'terminal')
const intent = linkIntent(store)
focus(store, c)
store.getState().openFileForLink(fileInput('/repo/link.ts'), {}, intent)
expect(groupOfFile(store, '/repo/link.ts')).toEqual([b])
})
it('follows a source tab that moved to another group while its old group survives', () => {
const store = createEditorTabsStore()
const { a, b, c } = seedColumns(store, 'terminal', 'terminal', 'terminal')
addTab(store, 'terminal', 'keeps-a-alive', a)
const intent = linkIntent(store)
store.getState().moveUnifiedTabToGroup(intent!.sourceTabId, b)
store.getState().openFileForLink(fileInput('/repo/link.ts'), {}, intent)
// From B the preferred neighbor is C; a stale source in A would choose B.
expect(groupOfFile(store, '/repo/link.ts')).toEqual([c])
})
it('uses default placement once the source tab has closed', () => {
const store = createEditorTabsStore()
const { a, c } = seedColumns(store, 'terminal', 'terminal', 'editor')
addTab(store, 'terminal', 'keeps-a-alive', a)
const intent = linkIntent(store)
removeTab(store, intent!.sourceTabId)
focus(store, a)
store.getState().openFileForLink(fileInput('/repo/link.ts'), {}, intent)
// The focused terminal's existing preference sends the file to the visible editor.
expect(groupOfFile(store, '/repo/link.ts')).toEqual([c])
})
it('uses destination defaults when resolution routes to another workspace', () => {
const store = createEditorTabsStore()
seedColumns(store, 'terminal', 'terminal', 'terminal')
const opened = store
.getState()
.openFileForLink(fileInput('/other/link.ts', 'wt-2'), {}, linkIntent(store))
expect(opened?.groupId).toBe(store.getState().groupsByWorktree['wt-2']?.[0]?.id)
})
it('pins an explicit request to the captured group even when another group has the file', () => {
const store = createEditorTabsStore()
const { a, c } = seedColumns(store, 'terminal', 'terminal', 'terminal')
addEditor(store, '/repo/link.ts', c)
store.getState().activateTab('source')
const opened = store
.getState()
.openFileForLink(fileInput('/repo/link.ts'), {}, linkIntent(store, true))
expect(opened?.groupId).toBe(a)
expect(groupOfFile(store, '/repo/link.ts').sort()).toEqual([a, c].sort())
})
it('refuses an explicit request whose group closed, without touching editor state', () => {
const store = createEditorTabsStore()
const { b } = seedColumns(store, 'terminal', 'terminal', 'terminal')
store.getState().activateTab(`${b}-terminal`)
const intent = captureEditorOpenLinkIntent(store.getState(), 'wt-1', `${b}-terminal`, {
explicitSourceGroup: true
})
removeGroup(store, b)
const before = store.getState().openFiles
const opened = store.getState().openFileForLink(fileInput('/repo/link.ts'), {}, intent)
expect(opened).toBeNull()
expect(store.getState().openFiles).toBe(before)
expect(store.getState().groupsByWorktree['wt-1']!.some((g) => g.id === b)).toBe(false)
})
it('refuses when the chosen group disappears at the mutation seam', () => {
const store = createEditorTabsStore()
const { b } = seedColumns(store, 'terminal', 'terminal', 'terminal')
const intent = linkIntent(store)
const unsubscribe = store.subscribe((state, previous) => {
if (state.openFiles !== previous.openFiles) {
unsubscribe()
removeGroup(store, b)
}
})
const opened = store.getState().openFileForLink(fileInput('/repo/link.ts'), {}, intent)
expect(opened).toBeNull()
expect(groupOfFile(store, '/repo/link.ts')).toEqual([])
expect(store.getState().groupsByWorktree['wt-1']!.some((g) => g.id === b)).toBe(false)
})
it('returns the created view and focuses it', () => {
const store = createEditorTabsStore()
const { b } = seedColumns(store, 'terminal', 'terminal', 'terminal')
const opened = store
.getState()
.openFileForLink(fileInput('/repo/link.ts'), {}, linkIntent(store))
const view = store
.getState()
.unifiedTabsByWorktree['wt-1']!.find((tab) => tab.entityId === '/repo/link.ts')
expect(opened).toEqual({ fileId: '/repo/link.ts', viewStateId: view?.id, groupId: b })
expect(store.getState().activeGroupIdByWorktree['wt-1']).toBe(b)
expect(store.getState().pendingEditorFocusRequest).toMatchObject({
fileId: '/repo/link.ts',
viewStateId: view?.id
})
})
it('keeps the source in place when it is the only group', () => {
const store = createEditorTabsStore()
addTab(store, 'terminal', 'source')
const a = store.getState().groupsByWorktree['wt-1']![0]!.id
const opened = store
.getState()
.openFileForLink(fileInput('/repo/link.ts'), {}, linkIntent(store))
expect(opened?.groupId).toBe(a)
expect(store.getState().groupsByWorktree['wt-1']).toHaveLength(1)
})
})
@@ -1,64 +1,98 @@
import type { EditorGet, EditorSet } from '../types/editor-set-get'
import type { EditorSlice } from '../types/editor-slice'
import type { OpenFile } from '../types/open-file'
import type { OpenFileOptions } from '../types/editor-files-slice'
import type { EditorOpenLinkIntent, EditorOpenResult } from '../types/editor-open-link-intent'
import { openWorkspaceEditorItem } from '../tabs/workspace-editor-item'
import type { EditorTabContentType } from '../tabs/editor-tab-content-type'
import { editorTabContentTypeForMode } from '../tabs/editor-tab-content-type'
import { isLiveEditorOpenGroup } from '../tabs/editor-open-target-group'
import {
EDITOR_FOCUS_REQUEST_TTL_MS,
takeNextEditorFocusRequestToken
} from '../focus/editor-focus-reveal'
import { applyOpenFileToState } from './open-file-apply'
import { applyOpenFileToState, type OpenFileApplyScratch } from './open-file-apply'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../../../shared/layout-owner-switch'
import { recordReopenedEditorFileInLayoutOwner } from '../tabs/editor-tab-layout-owner'
export function createOpenFileAction(
set: EditorSet,
get: EditorGet
): Pick<EditorSlice, 'openFile'> {
return {
openFile: (file, options) => {
const editorItemWorktreeId = file.worktreeId
const editorItemLabel = file.relativePath
const editorItemContentType: EditorTabContentType =
file.mode === 'conflict-review' || file.mode === 'check-details' || file.mode === 'diff'
? file.mode
: 'editor'
const scratch = {
editorItemFileId: file.filePath,
editorItemTargetGroupId: options?.targetGroupId,
editorItemIsPreview: false
}
const before = ORCA_LAYOUT_OWNER_IS_MAIN ? get() : null
set((s) => applyOpenFileToState(s, file, options, scratch))
const editorItemViewStateId = openWorkspaceEditorItem(
): Pick<EditorSlice, 'openFile' | 'openFileForLink'> {
const open = (
file: Omit<OpenFile, 'id' | 'isDirty'>,
options: OpenFileOptions | undefined,
linkIntent: EditorOpenLinkIntent | null
): { fileId: string; result: EditorOpenResult | null } => {
const editorItemWorktreeId = file.worktreeId
const scratch: OpenFileApplyScratch = {
editorItemFileId: file.filePath,
editorItemTargetGroupId: options?.targetGroupId,
editorItemIsPreview: false,
refused: false
}
const before = ORCA_LAYOUT_OWNER_IS_MAIN ? get() : null
set((s) => applyOpenFileToState(s, file, options, scratch, linkIntent))
if (scratch.refused) {
return { fileId: scratch.editorItemFileId, result: null }
}
// Why: a reentrant update may remove the chosen group after selection; never recreate it.
if (
linkIntent &&
scratch.editorItemTargetGroupId &&
!isLiveEditorOpenGroup(get(), editorItemWorktreeId, scratch.editorItemTargetGroupId)
) {
return { fileId: scratch.editorItemFileId, result: null }
}
const editorItemViewStateId = openWorkspaceEditorItem(
get(),
scratch.editorItemFileId,
editorItemWorktreeId,
file.relativePath,
editorTabContentTypeForMode(file.mode),
scratch.editorItemIsPreview,
scratch.editorItemTargetGroupId,
options?.selection
)
if (ORCA_LAYOUT_OWNER_IS_MAIN && before) {
recordReopenedEditorFileInLayoutOwner(
before,
get(),
scratch.editorItemFileId,
editorItemWorktreeId,
editorItemLabel,
editorItemContentType,
scratch.editorItemIsPreview,
scratch.editorItemTargetGroupId,
options?.selection
editorItemViewStateId
)
if (ORCA_LAYOUT_OWNER_IS_MAIN && before) {
recordReopenedEditorFileInLayoutOwner(
before,
get(),
editorItemWorktreeId,
editorItemViewStateId
)
}
if (options?.focusEditor) {
set({
pendingEditorFocusRequest: {
fileId: scratch.editorItemFileId,
worktreeId: editorItemWorktreeId,
viewStateId: editorItemViewStateId,
expiresAt: Date.now() + EDITOR_FOCUS_REQUEST_TTL_MS,
token: takeNextEditorFocusRequestToken()
}
})
}
// Why: openWorkspaceEditorItem falls back to the file id when no tab exists; that is not a view.
const view = (get().unifiedTabsByWorktree?.[editorItemWorktreeId] ?? []).find(
(tab) => tab.id === editorItemViewStateId && tab.entityId === scratch.editorItemFileId
)
return {
fileId: scratch.editorItemFileId,
result: view
? { fileId: scratch.editorItemFileId, viewStateId: view.id, groupId: view.groupId }
: null
}
}
return {
openFile: (file, options) => open(file, options, null).fileId,
openFileForLink: (file, options, intent) => {
const { result } = open(file, { ...options, focusEditor: true }, intent)
// Why: created and reused views must land identically; creating in another group leaves focus behind.
if (result) {
get().focusGroup?.(file.worktreeId, result.groupId)
}
if (options?.focusEditor) {
set({
pendingEditorFocusRequest: {
fileId: scratch.editorItemFileId,
worktreeId: editorItemWorktreeId,
viewStateId: editorItemViewStateId,
expiresAt: Date.now() + EDITOR_FOCUS_REQUEST_TTL_MS,
token: takeNextEditorFocusRequestToken()
}
})
}
return scratch.editorItemFileId
return result
}
}
}
@@ -5,7 +5,6 @@ import { getRecentlyClosedTabPosition, pushRecentlyClosedTabKind } from '../../r
import type { AppState } from '../../../types'
import {
type ClosedEditorTabSnapshot,
type EditorTabSelection,
MAX_RECENT_CLOSED_EDITOR_TABS,
type OpenFile
} from '../types/open-file'
@@ -19,8 +18,11 @@ import {
} from '../file-ids/editor-file-ids'
import {
buildEditorActiveResult,
resolveEditorLinkOpenPlacement,
resolveEditorOpenTargetGroupId
} from '../tabs/editor-open-target-group'
import type { OpenFileOptions } from '../types/editor-files-slice'
import type { EditorOpenLinkIntent } from '../types/editor-open-link-intent'
import { resolveEditorPreviewIntent } from '../tabs/editor-preview-tab-setting'
import {
getReplaceablePreviewFileId,
@@ -32,24 +34,16 @@ export type OpenFileApplyScratch = {
editorItemTargetGroupId: string | undefined
/** Resolved against the setting inside the reducer so the tab can't disagree with its OpenFile. */
editorItemIsPreview: boolean
/** Set when a link's explicit target failed revalidation; state was left untouched. */
refused: boolean
}
export function applyOpenFileToState(
s: AppState,
file: Omit<OpenFile, 'id' | 'isDirty'>,
options:
| {
preview?: boolean
targetGroupId?: string
recordReplacedPreview?: boolean
suppressActiveRuntimeFallback?: boolean
forceContentReload?: boolean
focusEditor?: boolean
reopenId?: string
selection?: EditorTabSelection
}
| undefined,
scratch: OpenFileApplyScratch
options: OpenFileOptions | undefined,
scratch: OpenFileApplyScratch,
linkIntent: EditorOpenLinkIntent | null = null
): Partial<AppState> | AppState {
const worktreeId = file.worktreeId
let operationProvenance = file.operationProvenance
@@ -93,13 +87,20 @@ export function applyOpenFileToState(
runtimeEnvironmentId,
reusableOpenFileModes
)
const linkPlacement =
linkIntent && resolveEditorLinkOpenPlacement(s, worktreeId, linkIntent, id, file.mode)
if (linkPlacement?.kind === 'refused') {
scratch.refused = true
return s
}
scratch.editorItemFileId = id
const isPreview = resolveEditorPreviewIntent(s, options?.preview)
scratch.editorItemIsPreview = isPreview
const recordReplacedPreview = options?.recordReplacedPreview ?? false
// Why: resolve the target group up-front so preview replacement is scoped to it (group B open must not evict group A's preview).
const targetGroupId =
resolveEditorOpenTargetGroupId(s, worktreeId, options?.targetGroupId) ?? undefined
const requestedGroupId =
linkPlacement?.kind === 'group' ? linkPlacement.groupId : options?.targetGroupId
const targetGroupId = resolveEditorOpenTargetGroupId(s, worktreeId, requestedGroupId) ?? undefined
scratch.editorItemTargetGroupId = targetGroupId
const activeResult =
options?.selection === 'none' ? {} : buildEditorActiveResult(s, worktreeId, id)
@@ -0,0 +1,227 @@
import { describe, expect, it } from 'vitest'
import type { Tab, TabGroup, TabGroupLayoutNode } from '../../../../../../shared/tab-types'
import { findNeighbor, pickAdjacentEditorOpenGroupId } from './editor-open-adjacent-group'
const FILE_ID = '/repo/src/main.ts'
const leaf = (groupId: string): TabGroupLayoutNode => ({ type: 'leaf', groupId })
const columns = (
first: TabGroupLayoutNode,
second: TabGroupLayoutNode,
ratio?: number
): TabGroupLayoutNode => ({ type: 'split', direction: 'horizontal', first, second, ratio })
const rows = (
first: TabGroupLayoutNode,
second: TabGroupLayoutNode,
ratio?: number
): TabGroupLayoutNode => ({ type: 'split', direction: 'vertical', first, second, ratio })
function tab(id: string, groupId: string, contentType: Tab['contentType'], entityId = id): Tab {
return {
id,
entityId,
groupId,
worktreeId: 'wt-1',
contentType,
label: id,
customLabel: null,
color: null,
sortOrder: 0,
createdAt: 0
}
}
type Fixture = {
/** Groups whose selected tab is an editor (other than the file); the rest show a terminal. */
editors?: string[]
/** Groups holding an unselected tab of the file. */
withFile?: string[]
/** Groups holding an unselected, unrelated editor tab behind their terminal. */
inactiveEditors?: string[]
}
function collectGroupIds(node: TabGroupLayoutNode): string[] {
return node.type === 'leaf'
? [node.groupId]
: [...collectGroupIds(node.first), ...collectGroupIds(node.second)]
}
function buildState(layout: TabGroupLayoutNode, fixture: Fixture = {}) {
const groups: TabGroup[] = []
const tabs: Tab[] = []
for (const groupId of collectGroupIds(layout)) {
const active = fixture.editors?.includes(groupId)
? tab(`${groupId}-editor`, groupId, 'editor')
: tab(`${groupId}-terminal`, groupId, 'terminal')
tabs.push(active)
if (fixture.withFile?.includes(groupId)) {
tabs.push(tab(`${groupId}-file`, groupId, 'editor', FILE_ID))
}
if (fixture.inactiveEditors?.includes(groupId)) {
tabs.push(tab(`${groupId}-old-editor`, groupId, 'editor'))
}
groups.push({
id: groupId,
worktreeId: 'wt-1',
activeTabId: active.id,
tabOrder: tabs.filter((t) => t.groupId === groupId).map((t) => t.id)
})
}
return { groups, tabs }
}
function pick(layout: TabGroupLayoutNode, sourceGroupId: string, fixture?: Fixture) {
const { groups, tabs } = buildState(layout, fixture)
return pickAdjacentEditorOpenGroupId({
layout,
groups,
tabs,
sourceGroupId,
fileId: FILE_ID,
contentType: 'editor'
})
}
describe('pickAdjacentEditorOpenGroupId', () => {
it('opens across a two-column split in both directions', () => {
const layout = columns(leaf('A'), leaf('B'))
expect(pick(layout, 'A')).toBe('B')
expect(pick(layout, 'B')).toBe('A')
})
it('opens across a two-row split in both directions', () => {
const layout = rows(leaf('A'), leaf('B'))
expect(pick(layout, 'A')).toBe('B')
expect(pick(layout, 'B')).toBe('A')
})
it('prefers the right neighbor over the left in three columns', () => {
expect(pick(columns(leaf('A'), columns(leaf('B'), leaf('C'))), 'B')).toBe('C')
})
it('ignores the diagonal group in a four-way grid', () => {
const grid = columns(rows(leaf('TL'), leaf('BL')), rows(leaf('TR'), leaf('BR')))
expect(pick(grid, 'BR')).toBe('BL')
// Why: corner-only contact is not adjacency, so TL's editor earns no preference.
expect(pick(grid, 'BR', { editors: ['TL'] })).toBe('BL')
})
it('picks the upper-right group beside a tall source regardless of their height ratio', () => {
for (const ratio of [0.2, 0.5, 0.8]) {
expect(pick(columns(leaf('A'), rows(leaf('B'), leaf('C'), ratio)), 'A')).toBe('B')
}
})
it('lets a neighbor showing an editor beat side order', () => {
expect(pick(columns(leaf('A'), rows(leaf('B'), leaf('C'))), 'A', { editors: ['C'] })).toBe('C')
})
it('orders neighbors below a wide source left to right', () => {
expect(pick(rows(leaf('A'), columns(leaf('L'), leaf('R'))), 'A')).toBe('L')
})
it('compares equal-priority editors right, left, below, above', () => {
const editors = ['L', 'R', 'T', 'B']
const cross = rows(
leaf('T'),
rows(columns(leaf('L'), columns(leaf('S'), leaf('R'))), leaf('B'))
)
expect(pick(cross, 'S', { editors })).toBe('R')
expect(pick(cross, 'S', { editors: ['L', 'T', 'B'] })).toBe('L')
expect(pick(cross, 'S', { editors: ['T', 'B'] })).toBe('B')
expect(pick(cross, 'S', { editors: ['T'] })).toBe('T')
})
it('reuses a nonadjacent visible group that already holds the file', () => {
expect(pick(columns(leaf('A'), columns(leaf('B'), leaf('C'))), 'A', { withFile: ['C'] })).toBe(
'C'
)
})
it('prefers a neighboring view when the file has several', () => {
const layout = columns(leaf('A'), columns(leaf('B'), columns(leaf('C'), leaf('D'))))
expect(pick(layout, 'A', { withFile: ['B', 'D'] })).toBe('B')
expect(pick(layout, 'A', { withFile: ['C', 'D'] })).toBe('C')
})
it('opens a second view beside the source when only the source has the file', () => {
expect(pick(columns(leaf('A'), leaf('B')), 'A', { withFile: ['A'] })).toBe('B')
})
it('keeps a single group in place', () => {
expect(pick(leaf('A'), 'A')).toBe('A')
})
it('covers an adjacent agent before a nonadjacent editor without the file', () => {
expect(pick(columns(leaf('A'), columns(leaf('B'), leaf('C'))), 'A', { editors: ['C'] })).toBe(
'B'
)
})
it('selects an old unselected matching tab over a nearby editor', () => {
expect(
pick(columns(leaf('A'), columns(leaf('B'), leaf('C'))), 'A', {
editors: ['B'],
withFile: ['C']
})
).toBe('C')
})
it('does not treat an inactive editor tab as showing an editor', () => {
const layout = columns(leaf('L'), columns(leaf('S'), leaf('R')))
expect(pick(layout, 'S', { editors: ['L'], inactiveEditors: ['R'] })).toBe('L')
})
it('does not match a diff of the same file as a reusable view', () => {
const layout = columns(leaf('A'), columns(leaf('B'), leaf('C')))
const { groups, tabs } = buildState(layout)
tabs.push(tab('C-diff', 'C', 'diff', FILE_ID))
expect(
pickAdjacentEditorOpenGroupId({
layout,
groups,
tabs,
sourceGroupId: 'A',
fileId: FILE_ID,
contentType: 'editor'
})
).toBe('B')
})
it('is invariant to candidate order', () => {
const layout = columns(leaf('A'), rows(leaf('B'), leaf('C')))
const { groups, tabs } = buildState(layout, { editors: ['B', 'C'] })
const input = { layout, sourceGroupId: 'A', fileId: FILE_ID, contentType: 'editor' as const }
const forward = pickAdjacentEditorOpenGroupId({ ...input, groups, tabs })
const reversed = pickAdjacentEditorOpenGroupId({
...input,
groups: groups.toReversed(),
tabs: tabs.toReversed()
})
expect(forward).toBe('B')
expect(reversed).toBe(forward)
})
it('agrees across split histories that draw the same grid', () => {
const byColumns = columns(rows(leaf('TL'), leaf('BL')), rows(leaf('TR'), leaf('BR')))
const byRows = rows(columns(leaf('TL'), leaf('TR')), columns(leaf('BL'), leaf('BR')))
for (const source of ['TL', 'TR', 'BL', 'BR']) {
expect(pick(byRows, source)).toBe(pick(byColumns, source))
}
})
it('returns null when the source is not a live leaf', () => {
expect(pick(columns(leaf('A'), leaf('B')), 'gone')).toBeNull()
})
})
describe('findNeighbor', () => {
it('requires positive overlap along the shared edge', () => {
const source = { left: 0, top: 0, right: 0.5, bottom: 0.5 }
expect(findNeighbor(source, { left: 0.5, top: 0.5, right: 1, bottom: 1 })).toBeNull()
expect(findNeighbor(source, { left: 0.5, top: 0.25, right: 1, bottom: 1 })).toEqual({
side: 'right',
position: 0.25
})
})
})
@@ -0,0 +1,150 @@
import type { Tab, TabGroup, TabGroupLayoutNode } from '../../../../../../shared/tab-types'
import { isEditorTabContentType, type EditorTabContentType } from './editor-tab-content-type'
type LayoutRect = { left: number; top: number; right: number; bottom: number }
type LayoutLeaf = { groupId: string; rect: LayoutRect; layoutOrder: number }
type NeighborSide = 'right' | 'left' | 'below' | 'above'
type Neighbor = { side: NeighborSide; position: number }
type Candidate = {
id: string
neighbor: Neighbor | null
hasMatchingFile: boolean
showsEditor: boolean
layoutOrder: number
}
type Rank = readonly [number, number, number, number]
const EPSILON = 1e-9
const SIDE_ORDER = { right: 0, left: 1, below: 2, above: 3 } as const
function clampRatio(ratio: number | undefined): number {
return typeof ratio === 'number' && Number.isFinite(ratio) && ratio > 0 && ratio < 1 ? ratio : 0.5
}
/** Leaves in stable tree order, each projected into the unit square by its split ratios. */
function projectLayoutLeaves(layout: TabGroupLayoutNode): LayoutLeaf[] {
const leaves: LayoutLeaf[] = []
const visit = (node: TabGroupLayoutNode, rect: LayoutRect): void => {
if (node.type === 'leaf') {
if (rect.right - rect.left > EPSILON && rect.bottom - rect.top > EPSILON) {
leaves.push({ groupId: node.groupId, rect, layoutOrder: leaves.length })
}
return
}
const ratio = clampRatio(node.ratio)
if (node.direction === 'horizontal') {
const split = rect.left + (rect.right - rect.left) * ratio
visit(node.first, { ...rect, right: split })
visit(node.second, { ...rect, left: split })
} else {
const split = rect.top + (rect.bottom - rect.top) * ratio
visit(node.first, { ...rect, bottom: split })
visit(node.second, { ...rect, top: split })
}
}
visit(layout, { left: 0, top: 0, right: 1, bottom: 1 })
return leaves
}
function overlaps(a0: number, a1: number, b0: number, b1: number): boolean {
return Math.min(a1, b1) - Math.max(a0, b0) > EPSILON
}
function touches(a: number, b: number): boolean {
return Math.abs(a - b) <= EPSILON
}
/** Shared edge with positive overlap; corner-only contact is not adjacency. */
export function findNeighbor(source: LayoutRect, target: LayoutRect): Neighbor | null {
const verticalOverlap = overlaps(source.top, source.bottom, target.top, target.bottom)
const horizontalOverlap = overlaps(source.left, source.right, target.left, target.right)
if (verticalOverlap && touches(source.right, target.left)) {
return { side: 'right', position: target.top }
}
if (verticalOverlap && touches(source.left, target.right)) {
return { side: 'left', position: target.top }
}
if (horizontalOverlap && touches(source.bottom, target.top)) {
return { side: 'below', position: target.left }
}
if (horizontalOverlap && touches(source.top, target.bottom)) {
return { side: 'above', position: target.left }
}
return null
}
function rankCandidate(candidate: Candidate, sourceGroupId: string): Rank | null {
const { neighbor, hasMatchingFile, showsEditor, layoutOrder } = candidate
if (candidate.id === sourceGroupId) {
return [4, 0, 0, layoutOrder]
}
if (!neighbor) {
return hasMatchingFile ? [1, 0, 0, layoutOrder] : null
}
const priority = hasMatchingFile ? 0 : showsEditor ? 2 : 3
return [priority, SIDE_ORDER[neighbor.side], neighbor.position, layoutOrder]
}
function compareRanks(a: Rank, b: Rank): number {
return a[0] - b[0] || a[1] - b[1] || a[2] - b[2] || a[3] - b[3]
}
type AdjacentEditorOpenInput = {
layout: TabGroupLayoutNode
groups: readonly TabGroup[]
tabs: readonly Tab[]
sourceGroupId: string
/** File identity already resolved by the open path; never re-derived from paths here. */
fileId: string
contentType: EditorTabContentType
}
/**
* Minimum-rank destination for a file link: neighbor with the file, any group with the
* file, neighbor showing an editor, any neighbor, then the source. Null when the source
* is not a live leaf of this layout.
*/
export function pickAdjacentEditorOpenGroupId(input: AdjacentEditorOpenInput): string | null {
const leaves = projectLayoutLeaves(input.layout)
const source = leaves.find((leaf) => leaf.groupId === input.sourceGroupId)
const liveGroups = new Map(input.groups.map((group) => [group.id, group]))
if (!source || !liveGroups.has(source.groupId)) {
return null
}
const groupsWithFile = new Set<string>()
const tabsById = new Map<string, Tab>()
for (const tab of input.tabs) {
tabsById.set(tab.id, tab)
if (tab.entityId === input.fileId && tab.contentType === input.contentType) {
groupsWithFile.add(tab.groupId)
}
}
// Why: the source keeps its conversation visible, so a source-only copy earns no preference.
groupsWithFile.delete(source.groupId)
let bestId: string | null = null
let bestRank: Rank | null = null
for (const leaf of leaves) {
const group = liveGroups.get(leaf.groupId)
if (!group) {
continue
}
const activeTab = group.activeTabId ? tabsById.get(group.activeTabId) : undefined
const rank = rankCandidate(
{
id: leaf.groupId,
neighbor: leaf.groupId === source.groupId ? null : findNeighbor(source.rect, leaf.rect),
hasMatchingFile: groupsWithFile.has(leaf.groupId),
showsEditor: activeTab ? isEditorTabContentType(activeTab.contentType) : false,
layoutOrder: leaf.layoutOrder
},
source.groupId
)
if (rank && (!bestRank || compareRanks(rank, bestRank) < 0)) {
bestRank = rank
bestId = leaf.groupId
}
}
return bestId
}
@@ -1,7 +1,11 @@
import type { AppState } from '../../../types'
import type { Tab, TabGroup, WorkspaceVisibleTabType } from '../../../../../../shared/tab-types'
import { isEditorTabContentType } from './editor-tab-content-type'
import { editorTabContentTypeForMode, isEditorTabContentType } from './editor-tab-content-type'
import type { OpenFile } from '../types/open-file'
import { ownsGlobalSelection } from '../../../global-selection-owner'
import type { EditorOpenLinkIntent } from '../types/editor-open-link-intent'
import { pickAdjacentEditorOpenGroupId } from './editor-open-adjacent-group'
import { collectTabGroupLayoutGroupIds } from '../../../../../../shared/workspace-layout/tab-group-moves'
export function getGroupActiveTab(group: TabGroup, tabsById: Map<string, Tab>): Tab | null {
return group.activeTabId ? (tabsById.get(group.activeTabId) ?? null) : null
@@ -79,6 +83,118 @@ export function resolveEditorOpenTargetGroupId(
return recentEditorGroup?.id ?? activeGroup.id
}
type LinkPlacementState = Pick<
AppState,
'groupsByWorktree' | 'unifiedTabsByWorktree' | 'layoutByWorktree'
>
/** The unified tab a link came from; a terminal surface may know only its backing terminal id. */
function findLinkSourceTab(tabs: readonly Tab[], sourceTabId: string): Tab | undefined {
return (
tabs.find((tab) => tab.id === sourceTabId) ??
tabs.find((tab) => tab.contentType === 'terminal' && tab.entityId === sourceTabId)
)
}
/** Captures a link activation's source at click time, so later focus changes cannot redirect it. */
export function captureEditorOpenLinkIntent(
state: Pick<AppState, 'unifiedTabsByWorktree'>,
workspaceId: string,
sourceTabId: string | null | undefined,
options?: { explicitSourceGroup?: boolean }
): EditorOpenLinkIntent | null {
if (!sourceTabId) {
return null
}
const tab = findLinkSourceTab(state.unifiedTabsByWorktree?.[workspaceId] ?? [], sourceTabId)
if (!tab) {
return null
}
return {
sourceTabId: tab.id,
workspaceId,
hostId: tab.executionHostId ?? null,
...(options?.explicitSourceGroup ? { targetGroupId: tab.groupId } : {})
}
}
function liveLayoutGroupIds(state: LinkPlacementState, worktreeId: string): Set<string> {
const layout = state.layoutByWorktree?.[worktreeId]
const groupIds = new Set((state.groupsByWorktree?.[worktreeId] ?? []).map((group) => group.id))
if (!layout) {
return groupIds
}
return new Set([...collectTabGroupLayoutGroupIds(layout)].filter((id) => groupIds.has(id)))
}
export function isLiveEditorOpenGroup(
state: LinkPlacementState,
worktreeId: string,
groupId: string
): boolean {
return liveLayoutGroupIds(state, worktreeId).has(groupId)
}
/** "Open in this group" only differs from automatic placement when the source has company. */
export function canOpenLinkInSourceGroup(
state: LinkPlacementState,
workspaceId: string,
sourceTabId: string | null | undefined
): boolean {
const tab = sourceTabId
? findLinkSourceTab(state.unifiedTabsByWorktree?.[workspaceId] ?? [], sourceTabId)
: undefined
if (!tab) {
return false
}
const groupIds = liveLayoutGroupIds(state, workspaceId)
return groupIds.has(tab.groupId) && groupIds.size > 1
}
export type EditorLinkOpenPlacement =
| { kind: 'group'; groupId: string }
| { kind: 'default' }
| { kind: 'refused' }
/**
* Link-intent extension of resolveEditorOpenTargetGroupId: an explicit target is honored
* or refused as captured; an automatic open ranks groups around the source's current group.
*/
export function resolveEditorLinkOpenPlacement(
state: LinkPlacementState,
worktreeId: string,
intent: EditorOpenLinkIntent,
fileId: string,
mode: OpenFile['mode']
): EditorLinkOpenPlacement {
if (intent.targetGroupId) {
return intent.workspaceId === worktreeId &&
isLiveEditorOpenGroup(state, worktreeId, intent.targetGroupId)
? { kind: 'group', groupId: intent.targetGroupId }
: { kind: 'refused' }
}
// Why: groups in another workspace's layout have no spatial relationship to the source.
if (intent.workspaceId !== worktreeId) {
return { kind: 'default' }
}
const layout = state.layoutByWorktree?.[worktreeId]
const tabs = state.unifiedTabsByWorktree?.[worktreeId] ?? []
const source = tabs.find((tab) => tab.id === intent.sourceTabId)
// Why: a closed or re-hosted source falls back to defaults, never to its stale initial group.
if (!layout || !source || (source.executionHostId ?? null) !== intent.hostId) {
return { kind: 'default' }
}
const groupId = pickAdjacentEditorOpenGroupId({
layout,
groups: state.groupsByWorktree?.[worktreeId] ?? [],
tabs,
sourceGroupId: source.groupId,
fileId,
contentType: editorTabContentTypeForMode(mode)
})
return groupId ? { kind: 'group', groupId } : { kind: 'default' }
}
export function buildEditorActiveResult(
state: Pick<AppState, 'activeFileIdByWorktree' | 'activeTabTypeByWorktree' | 'activeWorktreeId'>,
worktreeId: string,
@@ -21,3 +21,7 @@ export function isEditorTabContentType(
export function isVirtualEditorFile(file: Pick<OpenFile, 'mode'>): boolean {
return file.mode === 'check-details'
}
export function editorTabContentTypeForMode(mode: OpenFile['mode']): EditorTabContentType {
return mode === 'conflict-review' || mode === 'check-details' || mode === 'diff' ? mode : 'editor'
}
@@ -21,12 +21,24 @@ import type {
EditorTabSelection,
OpenFile
} from './open-file'
import type { EditorOpenLinkIntent, EditorOpenResult } from './editor-open-link-intent'
import type { OpenFilePathRekey, RekeyOpenFilesResult } from './open-file-path-rekey'
import type {
RestoredEditorOwnerMigration,
RestoredEditorOwnerResult
} from './restored-editor-owner'
export type OpenFileOptions = {
preview?: boolean
targetGroupId?: string
recordReplacedPreview?: boolean
suppressActiveRuntimeFallback?: boolean
forceContentReload?: boolean
focusEditor?: boolean
reopenId?: string
selection?: EditorTabSelection
}
export type EditorFilesSlice = {
// Open files / editor tabs
openFiles: OpenFile[]
@@ -35,19 +47,13 @@ export type EditorFilesSlice = {
activeTabTypeByWorktree: Record<string, WorkspaceVisibleTabType> // worktreeId -> last active tab type
activeTabType: WorkspaceVisibleTabType
setActiveTabType: (type: WorkspaceVisibleTabType, worktreeId: string | null) => void
openFile: (
openFile: (file: Omit<OpenFile, 'id' | 'isDirty'>, options?: OpenFileOptions) => string
/** openFile for a captured link activation; returns the actual view, or null when refused. */
openFileForLink: (
file: Omit<OpenFile, 'id' | 'isDirty'>,
options?: {
preview?: boolean
targetGroupId?: string
recordReplacedPreview?: boolean
suppressActiveRuntimeFallback?: boolean
forceContentReload?: boolean
focusEditor?: boolean
reopenId?: string
selection?: EditorTabSelection
}
) => string
options: Omit<OpenFileOptions, 'targetGroupId' | 'focusEditor' | 'selection'> | undefined,
intent: EditorOpenLinkIntent | null
) => EditorOpenResult | null
openNewMarkdownInActiveWorkspace: (groupId: string) => Promise<void>
// Why: sequences openFile/setMarkdownViewMode/reveal around an async Monaco remount. See docs/markdown-internal-link-opening-design.md.
activateMarkdownLink: (
@@ -0,0 +1,18 @@
import type { ExecutionHostId } from '../../../../../../shared/execution-host'
/** A link's source, captured before async path resolution. */
export type EditorOpenLinkIntent = {
workspaceId: string
sourceTabId: string
/** The source tab's owning host at capture; null when the tab carries none. */
hostId: ExecutionHostId | null
/** Set only for "Open in this group"; otherwise follow the source tab's current group. */
targetGroupId?: string
}
/** The view an open actually created or reused; the file id alone names only the document. */
export type EditorOpenResult = {
fileId: string
viewStateId: string
groupId: string
}
@@ -0,0 +1,94 @@
import { describe, expect, it, vi } from 'vitest'
import { createTestStore, makeTab } from '../store-test-helpers'
import { createTabsSliceMockApi } from '../tabs-slice-test-harness'
import { captureEditorOpenLinkIntent } from '../editor/tabs/editor-open-target-group'
vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } }))
createTabsSliceMockApi()
function splitWorkspace(workspaceId: string, source: 'terminal' | 'agent-session' = 'terminal') {
const store = createTestStore()
store.getState().createUnifiedTab(workspaceId, source, { id: 'source', entityId: 'source' })
if (source === 'terminal') {
store.setState({
tabsByWorktree: {
[workspaceId]: [makeTab({ id: 'source', worktreeId: workspaceId, ptyId: 'pty-1' })]
},
ptyIdsByTabId: { source: ['pty-1'] }
})
}
const sourceGroupId = store.getState().groupsByWorktree[workspaceId][0].id
const emptyGroupId = store.getState().createEmptySplitGroup(workspaceId, sourceGroupId, 'right')
if (!emptyGroupId) {
throw new Error('Expected an empty split group')
}
return { store, sourceGroupId, emptyGroupId }
}
describe.each(['repo1::/tmp/feature', 'folder:folder-1'])('empty groups in %s', (workspaceId) => {
it.each(['terminal', 'agent-session'] as const)(
'preserves an empty neighbor through reconciliation before opening a %s link',
(source) => {
const { store, sourceGroupId, emptyGroupId } = splitWorkspace(workspaceId, source)
const before = store.getState()
const intent = captureEditorOpenLinkIntent(before, workspaceId, 'source')
expect(intent).not.toBeNull()
expect(before.activeGroupIdByWorktree[workspaceId]).toBe(emptyGroupId)
for (let pass = 0; pass < 2; pass++) {
expect(store.getState().reconcileWorktreeTabModel(workspaceId).renderableTabCount).toBe(1)
const state = store.getState()
expect(state.groupsByWorktree[workspaceId]).toBe(before.groupsByWorktree[workspaceId])
expect(state.layoutByWorktree[workspaceId]).toBe(before.layoutByWorktree[workspaceId])
expect(state.activeGroupIdByWorktree[workspaceId]).toBe(emptyGroupId)
}
const opened = store.getState().openFileForLink(
{
filePath: '/tmp/feature/link.ts',
relativePath: 'link.ts',
worktreeId: workspaceId,
language: 'typescript',
mode: 'edit'
},
{},
intent
)
expect(opened?.groupId).toBe(emptyGroupId)
const state = store.getState()
expect(state.unifiedTabsByWorktree[workspaceId]).toContainEqual(
expect.objectContaining({ id: opened?.viewStateId, groupId: emptyGroupId })
)
expect(
state.groupsByWorktree[workspaceId].find((group) => group.id === sourceGroupId)
).toMatchObject({ activeTabId: 'source', tabOrder: ['source'] })
}
)
it('removes a group emptied by stale-tab cleanup while preserving an already empty group', () => {
const { store, sourceGroupId, emptyGroupId } = splitWorkspace(workspaceId)
const layoutWithEmptyNeighbor = store.getState().layoutByWorktree[workspaceId]
const staleGroupId = store.getState().createEmptySplitGroup(workspaceId, emptyGroupId, 'right')
if (!staleGroupId) {
throw new Error('Expected a stale split group')
}
store.getState().createUnifiedTab(workspaceId, 'browser', {
id: 'stale-browser',
entityId: 'missing-browser',
targetGroupId: staleGroupId
})
store.getState().reconcileWorktreeTabModel(workspaceId)
const state = store.getState()
expect(state.groupsByWorktree[workspaceId].map((group) => group.id)).toEqual([
sourceGroupId,
emptyGroupId
])
expect(state.layoutByWorktree[workspaceId]).toEqual(layoutWithEmptyNeighbor)
expect(state.activeGroupIdByWorktree[workspaceId]).toBe(sourceGroupId)
expect(state.unifiedTabsByWorktree[workspaceId].map((tab) => tab.id)).toEqual(['source'])
})
})
@@ -157,7 +157,11 @@ export function projectWorktreeTabModelReconciliation(
})
const prunedGroups =
validTabs.length > 0
? nextGroupsWithEmpty.filter((group) => group.tabOrder.length > 0)
? nextGroupsWithEmpty.filter(
// Preserve intentional empty panes; only collapse groups emptied by stale-tab cleanup.
(group, index) =>
group.tabOrder.length > 0 || reconciledGroups[index].tabOrder.length === 0
)
: nextGroupsWithEmpty
const groupsChanged =
prunedGroups.length !== groups.length ||