diff --git a/src/renderer/src/components/terminal-pane/terminal-file-link-hit-testing.ts b/src/renderer/src/components/terminal-pane/terminal-file-link-hit-testing.ts index 5f72b287166..47c29c85b77 100644 --- a/src/renderer/src/components/terminal-pane/terminal-file-link-hit-testing.ts +++ b/src/renderer/src/components/terminal-pane/terminal-file-link-hit-testing.ts @@ -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, diff --git a/src/renderer/src/components/terminal-pane/terminal-file-open-routing.ts b/src/renderer/src/components/terminal-pane/terminal-file-open-routing.ts index 2b0d28ca0c5..4bb26e4bad0 100644 --- a/src/renderer/src/components/terminal-pane/terminal-file-open-routing.ts +++ b/src/renderer/src/components/terminal-pane/terminal-file-open-routing.ts @@ -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) { diff --git a/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts index 97f1c61638b..cdb76fe3e2a 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts @@ -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 } 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([ + ['/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() }, - pathExistsCache: new Map([ - ['/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((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((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((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((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((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 = [ diff --git a/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts b/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts index a3c9424cbcd..6b515226810 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts @@ -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 = '' }, diff --git a/src/renderer/src/components/terminal-pane/terminal-link-open-hints.ts b/src/renderer/src/components/terminal-pane/terminal-link-open-hints.ts new file mode 100644 index 00000000000..099bcc1f324 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-link-open-hints.ts @@ -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' +} diff --git a/src/renderer/src/components/terminal-pane/terminal-worktree-path-link.test.ts b/src/renderer/src/components/terminal-pane/terminal-worktree-path-link.test.ts new file mode 100644 index 00000000000..fbc28c01826 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-worktree-path-link.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'vitest' +import { + normalizeWorktreeRootPathForTerminalLink, + resolveKnownWorktreeRootPathLink +} from './terminal-worktree-path-link' + +type WorktreeRootPathState = NonNullable[1]> + +function createState( + worktreesByRepo: Record +): 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() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-worktree-path-link.ts b/src/renderer/src/components/terminal-pane/terminal-worktree-path-link.ts new file mode 100644 index 00000000000..01cba80a97e --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-worktree-path-link.ts @@ -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 +type WorktreeRootPathIndex = Map + +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 +} diff --git a/src/renderer/src/lib/terminal-links.test.ts b/src/renderer/src/lib/terminal-links.test.ts index 0efb43f4629..dd33466028b 100644 --- a/src/renderer/src/lib/terminal-links.test.ts +++ b/src/renderer/src/lib/terminal-links.test.ts @@ -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) diff --git a/src/renderer/src/lib/terminal-links.ts b/src/renderer/src/lib/terminal-links.ts index af6cbedb189..abe71c0bcb1 100644 --- a/src/renderer/src/lib/terminal-links.ts +++ b/src/renderer/src/lib/terminal-links.ts @@ -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