mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 08:37:49 +00:00
Switch workspaces from terminal worktree paths (#5336)
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user