Switch workspaces from terminal worktree paths (#5336)

This commit is contained in:
Brennan Benson
2026-06-13 22:07:43 -07:00
committed by GitHub
parent 2acf8549f3
commit 6397e0539e
9 changed files with 587 additions and 52 deletions
@@ -3,6 +3,7 @@ import { extractTerminalFileLinkCandidates, resolveTerminalFileLink } from '@/li
import { isRemoteRuntimeFileOperation } from '@/runtime/runtime-file-client'
import { getTerminalFileContext, openDetectedFilePath } from './terminal-file-open-routing'
import { getTerminalPathExistsCacheKey } from './terminal-path-exists-cache'
import { resolveKnownWorktreeRootPathLink } from './terminal-worktree-path-link'
import {
buildHardWrappedPathLogicalLineCandidates,
buildWrappedLogicalLine,
@@ -38,6 +39,7 @@ export function openFilePathLinkAtBufferPosition(
column: number | null
pathText: string
cachedExists: boolean | undefined
isKnownWorktreeRoot: boolean
}[] = []
for (const parsed of extractTerminalFileLinkCandidates(logicalLine.text)) {
const resolved = deps.startupCwd
@@ -61,20 +63,28 @@ export function openFilePathLinkAtBufferPosition(
isRemoteRuntimePath: isRemoteRuntimeFileOperation(fileContext, resolved.absolutePath),
runtimeEnvironmentId: deps.runtimeEnvironmentId
})
const isKnownWorktreeRoot = Boolean(resolveKnownWorktreeRootPathLink(resolved.absolutePath))
if (/[\\/]$/.test(parsed.pathText) && !isKnownWorktreeRoot) {
continue
}
matches.push({
absolutePath: resolved.absolutePath,
line: resolved.line,
column: resolved.column,
pathText: parsed.pathText,
cachedExists: deps.pathExistsCache?.get(cacheKey)
cachedExists: deps.pathExistsCache?.get(cacheKey),
isKnownWorktreeRoot
})
}
const cachedMatch = matches
.filter((match) => match.cachedExists)
.sort((a, b) => b.pathText.length - a.pathText.length)[0]
const knownWorktreeRootMatch = matches
.filter((match) => match.isKnownWorktreeRoot)
.sort((a, b) => b.pathText.length - a.pathText.length)[0]
const uncachedMatch = matches.find((match) => match.cachedExists !== false)
const match = cachedMatch ?? uncachedMatch
const match = cachedMatch ?? knownWorktreeRootMatch ?? uncachedMatch
if (match) {
openDetectedFilePath(match.absolutePath, match.line, match.column, {
...deps,
@@ -10,6 +10,7 @@ import {
import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client'
import { useAppStore } from '@/store'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import { resolveKnownWorktreeRootPathLink } from './terminal-worktree-path-link'
type TerminalFileOpenDeps = {
worktreeId: string
@@ -98,6 +99,21 @@ export function openDetectedFilePath(
let statResult
const fileContext = getTerminalFileContext(worktreeId, worktreePath, runtimeEnvironmentId)
const canOpenWithSystemDefault = shouldOpenTerminalFileWithSystemDefault(fileContext, filePath)
if (!openWithSystemDefault) {
const worktreeRootLink = resolveKnownWorktreeRootPathLink(filePath)
if (worktreeRootLink) {
// Why: root workspace switching must work for SSH/runtime paths without
// local auth/stat, while still coalescing provider + fallback clicks.
await Promise.resolve()
if (requestId !== latestOpenDetectedFilePathRequestId) {
return
}
activateAndRevealWorktree(worktreeRootLink.id)
return
}
}
try {
// Why: remote paths don't need local auth — the relay/runtime is the security boundary.
if (canOpenWithSystemDefault) {
@@ -17,6 +17,7 @@ import { handleOscLink } from './terminal-osc-link-routing'
import { installHttpLinkClickFallback } from './terminal-url-link-hit-testing'
import { registerHttpLinkStoreAccessor } from '@/lib/http-link-routing'
import { getConnectionId } from '@/lib/connection-context'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import {
createCompatibleRuntimeStatusResponseIfNeeded,
type RuntimeEnvironmentCallRequest
@@ -48,7 +49,8 @@ const storeState = {
setActiveWorktree: setActiveWorktreeMock,
createBrowserTab: createBrowserTabMock,
openFile: openFileMock,
setPendingEditorReveal: setPendingEditorRevealMock
setPendingEditorReveal: setPendingEditorRevealMock,
worktreesByRepo: {} as Record<string, { id: string; path: string }[]>
}
vi.mock('@/store', () => ({
@@ -104,6 +106,7 @@ beforeEach(() => {
vi.mocked(getConnectionId).mockReturnValue(null)
openFilePathMock.mockResolvedValue(true)
storeState.settings = undefined
storeState.worktreesByRepo = {}
registerHttpLinkStoreAccessor(() => storeState)
vi.stubGlobal('window', {
dispatchEvent: vi.fn(),
@@ -739,6 +742,114 @@ describe('handleOscLink', () => {
expect(openFileMock).not.toHaveBeenCalled()
})
it('switches to an exact known worktree root without local auth or stat', async () => {
setPlatform('Macintosh')
storeState.worktreesByRepo = {
repo: [{ id: 'wt-2', path: '/tmp/other-worktree' }]
}
openDetectedFilePath('/tmp/other-worktree', null, null, deps)
await flushAsyncWork()
expect(activateAndRevealWorktree).toHaveBeenCalledWith('wt-2')
expect(authorizeExternalPathMock).not.toHaveBeenCalled()
expect(statMock).not.toHaveBeenCalled()
expect(openFilePathMock).not.toHaveBeenCalled()
expect(openFileMock).not.toHaveBeenCalled()
})
it('coalesces duplicate known-root activation from provider and mouseup fallback', async () => {
setPlatform('Macintosh')
storeState.worktreesByRepo = {
repo: [{ id: 'wt-2', path: '/tmp/other-worktree' }]
}
openDetectedFilePath('/tmp/other-worktree', null, null, deps)
openDetectedFilePath('/tmp/other-worktree', null, null, deps)
await flushAsyncWork()
expect(activateAndRevealWorktree).toHaveBeenCalledTimes(1)
expect(activateAndRevealWorktree).toHaveBeenCalledWith('wt-2')
expect(authorizeExternalPathMock).not.toHaveBeenCalled()
expect(statMock).not.toHaveBeenCalled()
})
it('keeps shift+cmd/ctrl-click external open for a known worktree root', async () => {
setPlatform('Macintosh')
statMock.mockResolvedValueOnce({ isDirectory: true })
storeState.worktreesByRepo = {
repo: [{ id: 'wt-2', path: '/tmp/other-worktree' }]
}
openDetectedFilePath('/tmp/other-worktree', null, null, {
...deps,
openWithSystemDefault: true
})
await flushAsyncWork()
expect(authorizeExternalPathMock).toHaveBeenCalledWith({
targetPath: '/tmp/other-worktree'
})
expect(statMock).toHaveBeenCalled()
expect(openFilePathMock).toHaveBeenCalledWith('/tmp/other-worktree')
expect(activateAndRevealWorktree).not.toHaveBeenCalled()
expect(openFileMock).not.toHaveBeenCalled()
})
it('switches to an SSH worktree root from store state without filesystem probing', async () => {
setPlatform('Macintosh')
vi.mocked(getConnectionId).mockReturnValue('ssh-1')
storeState.worktreesByRepo = {
repo: [{ id: 'wt-2', path: '/home/me/other-worktree' }]
}
openDetectedFilePath('/home/me/other-worktree', null, null, {
worktreeId: 'wt-1',
worktreePath: '/home/me/repo'
})
await flushAsyncWork()
expect(activateAndRevealWorktree).toHaveBeenCalledWith('wt-2')
expect(authorizeExternalPathMock).not.toHaveBeenCalled()
expect(statMock).not.toHaveBeenCalled()
expect(openFilePathMock).not.toHaveBeenCalled()
})
it('switches to a Windows worktree root when resolved separators differ from store state', async () => {
setPlatform('Windows')
storeState.worktreesByRepo = {
repo: [{ id: 'wt-win', path: 'C:\\Users\\Alice\\Repo' }]
}
openDetectedFilePath('C:/Users/Alice/Repo', null, null, {
worktreeId: 'wt-1',
worktreePath: 'C:/Users/Alice/Current'
})
await flushAsyncWork()
expect(activateAndRevealWorktree).toHaveBeenCalledWith('wt-win')
expect(authorizeExternalPathMock).not.toHaveBeenCalled()
expect(statMock).not.toHaveBeenCalled()
expect(openFilePathMock).not.toHaveBeenCalled()
})
it('does not fall back to file or directory open if known-root activation fails', async () => {
setPlatform('Macintosh')
vi.mocked(activateAndRevealWorktree).mockReturnValueOnce(false)
storeState.worktreesByRepo = {
repo: [{ id: 'wt-2', path: '/tmp/other-worktree' }]
}
openDetectedFilePath('/tmp/other-worktree', null, null, deps)
await flushAsyncWork()
expect(activateAndRevealWorktree).toHaveBeenCalledWith('wt-2')
expect(authorizeExternalPathMock).not.toHaveBeenCalled()
expect(statMock).not.toHaveBeenCalled()
expect(openFilePathMock).not.toHaveBeenCalled()
expect(openFileMock).not.toHaveBeenCalled()
})
it('ignores stale async completion so latest local click wins for Orca open and reveal', async () => {
setPlatform('Macintosh')
const firstStat = createDeferred<{ isDirectory: boolean }>()
@@ -827,7 +938,16 @@ describe('createFilePathLinkProvider range bounds', () => {
}
}
function createProviderSetup(rows: TestBufferLine[]) {
function createProviderSetup(
rows: TestBufferLine[],
pathExistsCache = new Map<string, boolean>([
['/repo', true],
['/repo/CLAUDE.md', true],
['/repo/package.json', true],
['/repo/Folder With Space/content.js', true],
['/repo/My Folder', true]
])
) {
const pane = makePane(rows)
const managerRef = {
current: { getPanes: () => [pane] } as unknown as PaneManager
@@ -841,12 +961,7 @@ describe('createFilePathLinkProvider range bounds', () => {
startupCwd: '/repo',
managerRef,
linkProviderDisposablesRef: { current: new Map<number, IDisposable>() },
pathExistsCache: new Map<string, boolean>([
['/repo/CLAUDE.md', true],
['/repo/package.json', true],
['/repo/Folder With Space/content.js', true],
['/repo/My Folder', true]
])
pathExistsCache
},
linkTooltip,
getTerminalFileOpenHint()
@@ -993,6 +1108,96 @@ describe('createFilePathLinkProvider range bounds', () => {
)
})
it('shows switch and external-open hint for known worktree root hover', async () => {
setPlatform('Macintosh')
storeState.worktreesByRepo = {
repo: [{ id: 'wt-1', path: '/repo' }]
}
const { provider, linkTooltip } = createProviderSetup([makeBufferLine('/repo')])
const links = await new Promise<ILink[]>((resolve) => {
provider.provideLinks(1, (provided) => resolve(provided ?? []))
})
expect(links[0]).toBeDefined()
links[0]!.hover?.({} as MouseEvent, links[0]!.text)
expect(linkTooltip.textContent).toBe(
'/repo (⌘+click to switch workspace or ⇧⌘+click to open in Finder)'
)
})
it('shows a known worktree root link even when the exists cache says missing', async () => {
setPlatform('Macintosh')
storeState.worktreesByRepo = {
repo: [{ id: 'wt-1', path: '/repo' }]
}
const { provider, linkTooltip } = createProviderSetup(
[makeBufferLine('/repo')],
new Map([['active\0/repo', false]])
)
const links = await new Promise<ILink[]>((resolve) => {
provider.provideLinks(1, (provided) => resolve(provided ?? []))
})
expect(links.map((link) => link.text)).toEqual(['/repo'])
links[0]!.hover?.({} as MouseEvent, links[0]!.text)
expect(window.api.shell.pathExists).not.toHaveBeenCalled()
expect(linkTooltip.textContent).toBe(
'/repo (⌘+click to switch workspace or ⇧⌘+click to open in Finder)'
)
})
it('does not show an unknown trailing-slash directory link', async () => {
setPlatform('Macintosh')
const { provider } = createProviderSetup(
[makeBufferLine('/repo/unknown-dir/')],
new Map([['active\0/repo/unknown-dir', true]])
)
const links = await new Promise<ILink[]>((resolve) => {
provider.provideLinks(1, (provided) => resolve(provided ?? []))
})
expect(links).toEqual([])
expect(window.api.shell.pathExists).not.toHaveBeenCalled()
})
it('linkifies a known worktree root printed with a trailing slash', async () => {
setPlatform('Macintosh')
storeState.worktreesByRepo = {
repo: [{ id: 'wt-1', path: '/repo' }]
}
const { provider, linkTooltip } = createProviderSetup([makeBufferLine('/repo/')])
const links = await new Promise<ILink[]>((resolve) => {
provider.provideLinks(1, (provided) => resolve(provided ?? []))
})
expect(links.map((link) => link.text)).toContain('/repo/')
links[0]!.hover?.({} as MouseEvent, links[0]!.text)
expect(linkTooltip.textContent).toBe(
'/repo (⌘+click to switch workspace or ⇧⌘+click to open in Finder)'
)
})
it('does not advertise external open for SSH worktree root hover', async () => {
setPlatform('Windows')
vi.mocked(getConnectionId).mockReturnValue('ssh-1')
storeState.worktreesByRepo = {
repo: [{ id: 'wt-1', path: '/repo' }]
}
const { provider, linkTooltip } = createProviderSetup([makeBufferLine('/repo')])
const links = await new Promise<ILink[]>((resolve) => {
provider.provideLinks(1, (provided) => resolve(provided ?? []))
})
expect(links[0]).toBeDefined()
links[0]!.hover?.({} as MouseEvent, links[0]!.text)
expect(linkTooltip.textContent).toBe('/repo (Ctrl+click to switch workspace)')
})
it('shows the Orca hint for SSH file link hover', async () => {
setPlatform('Macintosh')
vi.mocked(getConnectionId).mockReturnValue('ssh-1')
@@ -1120,6 +1325,34 @@ describe('createFilePathLinkProvider range bounds', () => {
expect(openFilePathMock).not.toHaveBeenCalled()
})
it('switches to a known worktree root from direct fallback even when cache says missing', async () => {
setPlatform('Macintosh')
storeState.worktreesByRepo = {
repo: [{ id: 'wt-2', path: '/tmp/other-worktree' }]
}
const opened = openFilePathLinkAtBufferPosition(
makeBuffer([makeBufferLine('/tmp/other-worktree')]),
{ x: 5, y: 1 },
80,
{
startupCwd: '/tmp',
worktreeId: 'wt-1',
worktreePath: '/tmp',
runtimeEnvironmentId: null,
pathExistsCache: new Map([['active\0/tmp/other-worktree', false]])
}
)
await flushAsyncWork()
expect(opened).toBe(true)
expect(activateAndRevealWorktree).toHaveBeenCalledWith('wt-2')
expect(authorizeExternalPathMock).not.toHaveBeenCalled()
expect(statMock).not.toHaveBeenCalled()
expect(openFilePathMock).not.toHaveBeenCalled()
expect(openFileMock).not.toHaveBeenCalled()
})
it('opens a single-row file path with the system default from shift modifier fallback', async () => {
setPlatform('Macintosh')
@@ -1278,6 +1511,28 @@ describe('createFilePathLinkProvider range bounds', () => {
expect(openFilePathMock).not.toHaveBeenCalled()
})
it('does not open an unknown trailing-slash directory from direct fallback', async () => {
setPlatform('Macintosh')
const opened = openFilePathLinkAtBufferPosition(
makeBuffer([makeBufferLine('/repo/unknown-dir/')]),
{ x: 8, y: 1 },
80,
{
startupCwd: '/repo',
worktreeId: 'wt-1',
worktreePath: '/repo',
runtimeEnvironmentId: null,
pathExistsCache: new Map([['active\0/repo/unknown-dir', true]])
}
)
await flushAsyncWork()
expect(opened).toBe(false)
expect(openFilePathMock).not.toHaveBeenCalled()
expect(openFileMock).not.toHaveBeenCalled()
})
it('retries a wrapped file click even when xterm already marked the link active', async () => {
setPlatform('Macintosh')
const rows = [
@@ -28,9 +28,19 @@ import {
readTerminalPathExistsCache,
writeTerminalPathExistsCache
} from './terminal-path-exists-cache'
import {
getTerminalHtmlFileOpenHint,
getTerminalOrcaFileOpenHint,
getTerminalWorktreePathOpenHint,
getTerminalFileOpenHint,
getTerminalUrlOpenHint,
isMacPlatform
} from './terminal-link-open-hints'
import { resolveKnownWorktreeRootPathLink } from './terminal-worktree-path-link'
export { openDetectedFilePath } from './terminal-file-open-routing'
export { openFilePathLinkAtBufferPosition } from './terminal-file-link-hit-testing'
export { getTerminalFileOpenHint, getTerminalHtmlFileOpenHint, getTerminalUrlOpenHint }
export type LinkHandlerDeps = {
worktreeId: string
@@ -76,34 +86,6 @@ function preferLongestNonOverlappingLinks(links: ProvidedFileLink[]): ProvidedFi
)
}
function isMacPlatform(): boolean {
return navigator.userAgent.includes('Mac')
}
export function getTerminalFileOpenHint(): string {
return isMacPlatform()
? '⌘+click to open or ⇧⌘+click for default app'
: 'Ctrl+click to open or Shift+Ctrl+click for default app'
}
export function getTerminalOrcaFileOpenHint(): string {
return isMacPlatform() ? '⌘+click to open in Orca' : 'Ctrl+click to open in Orca'
}
// Why: local .html/.htm links keep the ordinary Orca browser route, with the
// same Shift+modifier escape hatch to the system default browser as URL links.
export function getTerminalHtmlFileOpenHint(): string {
return isMacPlatform()
? '⌘+click to open or ⇧⌘+click for default browser'
: 'Ctrl+click to open or Shift+Ctrl+click for default browser'
}
export function getTerminalUrlOpenHint(): string {
return isMacPlatform()
? '⌘+click to open or ⇧⌘+click for system browser'
: 'Ctrl+click to open or Shift+Ctrl+click for system browser'
}
export function createFilePathLinkProvider(
paneId: number,
deps: LinkHandlerDeps,
@@ -169,16 +151,24 @@ export function createFilePathLinkProvider(
isRemoteRuntimePath,
runtimeEnvironmentId
})
const cachedExists = readTerminalPathExistsCache(pathExistsCache, cacheKey)
const exists =
cachedExists ??
(fileContext.connectionId || isRemoteRuntimePath
? await runtimePathExists(fileContext, resolved.absolutePath)
: await window.api.shell.pathExists(resolved.absolutePath))
writeTerminalPathExistsCache(pathExistsCache, cacheKey, exists)
if (!exists) {
const worktreeRootLink = resolveKnownWorktreeRootPathLink(resolved.absolutePath)
if (/[\\/]$/.test(parsed.pathText) && !worktreeRootLink) {
return null
}
// Why: exact known workspace roots must stay clickable for SSH or
// stale local paths even when filesystem probing says "missing".
if (!worktreeRootLink) {
const cachedExists = readTerminalPathExistsCache(pathExistsCache, cacheKey)
const exists =
cachedExists ??
(fileContext.connectionId || isRemoteRuntimePath
? await runtimePathExists(fileContext, resolved.absolutePath)
: await window.api.shell.pathExists(resolved.absolutePath))
writeTerminalPathExistsCache(pathExistsCache, cacheKey, exists)
if (!exists) {
return null
}
}
return {
logicalLine,
@@ -203,11 +193,13 @@ export function createFilePathLinkProvider(
fileContext,
resolved.absolutePath
)
const hint = canOpenWithSystemDefault
? isHtmlFilePath(resolved.absolutePath)
? getTerminalHtmlFileOpenHint()
: openLinkHint
: getTerminalOrcaFileOpenHint()
const hint = worktreeRootLink
? getTerminalWorktreePathOpenHint(canOpenWithSystemDefault)
: canOpenWithSystemDefault
? isHtmlFilePath(resolved.absolutePath)
? getTerminalHtmlFileOpenHint()
: openLinkHint
: getTerminalOrcaFileOpenHint()
linkTooltip.textContent = `${resolved.absolutePath} (${hint})`
linkTooltip.style.display = ''
},
@@ -0,0 +1,37 @@
export function isMacPlatform(): boolean {
return navigator.userAgent.includes('Mac')
}
export function getTerminalFileOpenHint(): string {
return isMacPlatform()
? '⌘+click to open or ⇧⌘+click for default app'
: 'Ctrl+click to open or Shift+Ctrl+click for default app'
}
export function getTerminalOrcaFileOpenHint(): string {
return isMacPlatform() ? '⌘+click to open in Orca' : 'Ctrl+click to open in Orca'
}
// Why: local .html/.htm links keep the ordinary Orca browser route, with the
// same Shift+modifier escape hatch to the system default browser as URL links.
export function getTerminalHtmlFileOpenHint(): string {
return isMacPlatform()
? '⌘+click to open or ⇧⌘+click for default browser'
: 'Ctrl+click to open or Shift+Ctrl+click for default browser'
}
export function getTerminalUrlOpenHint(): string {
return isMacPlatform()
? '⌘+click to open or ⇧⌘+click for system browser'
: 'Ctrl+click to open or Shift+Ctrl+click for system browser'
}
export function getTerminalWorktreePathOpenHint(canOpenWithSystemDefault: boolean): string {
if (!canOpenWithSystemDefault) {
return isMacPlatform() ? '⌘+click to switch workspace' : 'Ctrl+click to switch workspace'
}
return isMacPlatform()
? '⌘+click to switch workspace or ⇧⌘+click to open in Finder'
: 'Ctrl+click to switch workspace or Shift+Ctrl+click to open folder'
}
@@ -0,0 +1,106 @@
import { describe, expect, it } from 'vitest'
import {
normalizeWorktreeRootPathForTerminalLink,
resolveKnownWorktreeRootPathLink
} from './terminal-worktree-path-link'
type WorktreeRootPathState = NonNullable<Parameters<typeof resolveKnownWorktreeRootPathLink>[1]>
function createState(
worktreesByRepo: Record<string, { id: string; path: string }[]>
): WorktreeRootPathState {
return { worktreesByRepo } as WorktreeRootPathState
}
describe('resolveKnownWorktreeRootPathLink', () => {
it('resolves an exact known worktree root path', () => {
const state = createState({
repo: [{ id: 'wt-1', path: '/repo/feature' }]
})
expect(resolveKnownWorktreeRootPathLink('/repo/feature', state)).toEqual({
id: 'wt-1',
path: '/repo/feature'
})
})
it('does not resolve an unknown directory path', () => {
const state = createState({
repo: [{ id: 'wt-1', path: '/repo/feature' }]
})
expect(resolveKnownWorktreeRootPathLink('/repo/other', state)).toBeNull()
})
it('does not resolve a path inside a known worktree', () => {
const state = createState({
repo: [{ id: 'wt-1', path: '/repo/feature' }]
})
expect(resolveKnownWorktreeRootPathLink('/repo/feature/src/main.ts', state)).toBeNull()
})
it('matches trailing separators without trimming filesystem roots', () => {
const state = createState({
repo: [
{ id: 'posix-root', path: '/' },
{ id: 'posix-wt', path: '/repo/feature' },
{ id: 'windows-root', path: 'C:\\' },
{ id: 'windows-wt', path: 'C:\\repo\\feature' }
]
})
expect(resolveKnownWorktreeRootPathLink('/repo/feature/', state)?.id).toBe('posix-wt')
expect(resolveKnownWorktreeRootPathLink('C:\\repo\\feature\\', state)?.id).toBe('windows-wt')
expect(normalizeWorktreeRootPathForTerminalLink('/')).toBe('/')
expect(normalizeWorktreeRootPathForTerminalLink('C:\\')).toBe('C:/')
})
it('returns no match for duplicate root paths', () => {
const state = createState({
repo: [
{ id: 'wt-1', path: '/repo/feature' },
{ id: 'wt-2', path: '/repo/feature/' }
]
})
expect(resolveKnownWorktreeRootPathLink('/repo/feature', state)).toBeNull()
})
it('rebuilds the cached root index when worktreesByRepo is replaced', () => {
const firstState = createState({
repo: [{ id: 'wt-1', path: '/repo/feature' }]
})
const nextState = createState({
repo: [{ id: 'wt-2', path: '/repo/feature' }]
})
expect(resolveKnownWorktreeRootPathLink('/repo/feature', firstState)?.id).toBe('wt-1')
expect(resolveKnownWorktreeRootPathLink('/repo/feature', nextState)?.id).toBe('wt-2')
})
it('matches Windows paths across native and resolved separator styles', () => {
const state = createState({
repo: [{ id: 'wt-1', path: 'C:\\Users\\Alice\\Repo' }]
})
expect(resolveKnownWorktreeRootPathLink('C:\\Users\\Alice\\Repo\\', state)?.id).toBe('wt-1')
expect(resolveKnownWorktreeRootPathLink('C:/Users/Alice/Repo', state)?.id).toBe('wt-1')
expect(resolveKnownWorktreeRootPathLink('C:\\Users\\Alice\\Repo\\src', state)).toBeNull()
expect(resolveKnownWorktreeRootPathLink('C:/Users/Alice/Repo/src', state)).toBeNull()
})
it('matches Windows and UNC roots case-insensitively without changing POSIX matching', () => {
const state = createState({
repo: [
{ id: 'wt-win', path: 'C:\\Users\\Alice\\Repo' },
{ id: 'wt-unc', path: '\\\\Server\\Share\\Repo' },
{ id: 'wt-posix', path: '/Users/Alice/Repo' }
]
})
expect(resolveKnownWorktreeRootPathLink('c:\\users\\alice\\repo', state)?.id).toBe('wt-win')
expect(resolveKnownWorktreeRootPathLink('//server/share/repo', state)?.id).toBe('wt-unc')
expect(resolveKnownWorktreeRootPathLink('/users/alice/repo', state)).toBeNull()
})
})
@@ -0,0 +1,87 @@
import { useAppStore } from '@/store'
import type { AppState } from '@/store/types'
import { normalizeAbsolutePath } from '@/lib/terminal-path-normalization'
export type WorktreeRootPathLink = {
id: string
path: string
}
type WorktreeRootPathState = Pick<AppState, 'worktreesByRepo'>
type WorktreeRootPathIndex = Map<string, WorktreeRootPathLink | null>
const EMPTY_WORKTREE_ROOT_PATH_INDEX: WorktreeRootPathIndex = new Map()
const worktreeRootPathIndexCache = new WeakMap<
WorktreeRootPathState['worktreesByRepo'],
WorktreeRootPathIndex
>()
function isPathSeparator(value: string): boolean {
return value === '/' || value === '\\'
}
function isDriveRoot(value: string): boolean {
return /^[A-Za-z]:[\\/]$/.test(value)
}
export function normalizeWorktreeRootPathForTerminalLink(path: string): string {
const normalizedAbsolutePath = normalizeAbsolutePath(path)
if (normalizedAbsolutePath) {
return normalizedAbsolutePath.normalized
}
let end = path.length
while (end > 1 && isPathSeparator(path[end - 1])) {
const candidate = path.slice(0, end)
if (candidate === '/' || isDriveRoot(candidate)) {
break
}
end -= 1
}
return path.slice(0, end)
}
function getWorktreeRootPathComparisonKey(path: string): string {
const normalizedAbsolutePath = normalizeAbsolutePath(path)
if (normalizedAbsolutePath) {
return normalizedAbsolutePath.comparisonKey
}
return normalizeWorktreeRootPathForTerminalLink(path)
}
function getWorktreeRootPathIndex(
worktreesByRepo: WorktreeRootPathState['worktreesByRepo'] | undefined
): WorktreeRootPathIndex {
if (!worktreesByRepo) {
return EMPTY_WORKTREE_ROOT_PATH_INDEX
}
const cachedIndex = worktreeRootPathIndexCache.get(worktreesByRepo)
if (cachedIndex) {
return cachedIndex
}
const index: WorktreeRootPathIndex = new Map()
for (const worktrees of Object.values(worktreesByRepo)) {
for (const worktree of worktrees) {
const comparisonKey = getWorktreeRootPathComparisonKey(worktree.path)
// Why: duplicate roots are ambiguous click targets; cache that ambiguity
// so terminal link detection avoids rescanning every workspace path.
index.set(
comparisonKey,
index.has(comparisonKey) ? null : { id: worktree.id, path: worktree.path }
)
}
}
worktreeRootPathIndexCache.set(worktreesByRepo, index)
return index
}
export function resolveKnownWorktreeRootPathLink(
path: string,
state: WorktreeRootPathState = useAppStore.getState()
): WorktreeRootPathLink | null {
const pathComparisonKey = getWorktreeRootPathComparisonKey(path)
return getWorktreeRootPathIndex(state.worktreesByRepo).get(pathComparisonKey) ?? null
}
@@ -128,6 +128,24 @@ describe('terminal path helpers', () => {
})
})
it('keeps trailing separators on directory-like absolute paths', () => {
const links = extractTerminalFileLinks('/Users/alice/worktree/')
expect(links).toHaveLength(1)
expect(links[0]).toMatchObject({
pathText: '/Users/alice/worktree/',
displayText: '/Users/alice/worktree/'
})
})
it('does not linkify root-only or relative trailing separator tokens', () => {
expect(extractTerminalFileLinks('progress 1 / 3')).toEqual([])
expect(extractTerminalFileLinks('/')).toEqual([])
expect(extractTerminalFileLinks('./')).toEqual([])
expect(extractTerminalFileLinks('../')).toEqual([])
expect(extractTerminalFileLinks('~/')).toEqual([])
expect(extractTerminalFileLinks('C:\\')).toEqual([])
})
it('detects an extensionless relative path ending in a spaced segment', () => {
const links = extractTerminalFileLinks('./My Folder')
expect(links).toHaveLength(1)
+15 -1
View File
@@ -91,7 +91,14 @@ function parsePathWithOptionalLineColumn(value: string): {
return null
}
const pathText = match[1]
if (!pathText || pathText.endsWith('/')) {
const hasLineOrColumn = Boolean(match[2] || match[3])
if (!pathText) {
return null
}
if (/^[\\/]\s/.test(pathText)) {
return null
}
if (/[\\/]$/.test(pathText) && (hasLineOrColumn || !canKeepTrailingSeparator(pathText))) {
return null
}
@@ -104,6 +111,13 @@ function parsePathWithOptionalLineColumn(value: string): {
return { pathText, line, column }
}
function canKeepTrailingSeparator(pathText: string): boolean {
if (/^[\\/]+$/.test(pathText) || /^~[\\/]$/.test(pathText) || /^[A-Za-z]:[\\/]$/.test(pathText)) {
return false
}
return /^(?:~[\\/]|[\\/]|[A-Za-z]:[\\/])/.test(pathText)
}
// Project files that look like filenames despite having no extension. The
// word detector otherwise requires a `.` in the token to keep noise down —
// without this list, `ls` output containing `Makefile` or `LICENSE` would