fix(terminal): resolve WSL file links from the execution runtime, not the worktree path (#12465)
`mapTerminalFilePath` derived the WSL distro only from the *shape* of `worktreePath`. A worktree on a native Windows drive whose project runs under the WSL runtime gets a shell whose paths are POSIX, so no distro was found, the path went verbatim to a Win32 stat probe, and the candidate was dropped — no underline, no tooltip, inert Ctrl+click. Resolve the pane's distro from the execution runtime, falling back to the old worktree-shape derivation so existing behaviour is unchanged. Note the half of #8156 covered by merged #8215 (worktree on the WSL filesystem) was already fixed; this closes the remaining gap. Fixes #8156 Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
434959965a
commit
d0baa20d0e
|
|
@ -4,7 +4,8 @@ import { isRemoteRuntimeFileOperation } from '@/runtime/runtime-file-client'
|
|||
import {
|
||||
getTerminalFileContext,
|
||||
mapTerminalFilePath,
|
||||
openDetectedFilePath
|
||||
openDetectedFilePath,
|
||||
terminalLinkWslDistro
|
||||
} from './terminal-file-open-routing'
|
||||
import { getTerminalPathExistsCacheKey } from './terminal-path-exists-cache'
|
||||
import { resolveKnownWorktreeRootPathLink } from './terminal-worktree-path-link'
|
||||
|
|
@ -21,6 +22,7 @@ type FileLinkHitTestDeps = {
|
|||
worktreeId: string
|
||||
worktreePath: string
|
||||
runtimeEnvironmentId?: string | null
|
||||
wslDistro?: string | null
|
||||
pathExistsCache?: Map<string, boolean>
|
||||
openWithSystemDefault?: boolean
|
||||
}
|
||||
|
|
@ -61,7 +63,11 @@ export function openFilePathLinkAtBufferPosition(
|
|||
deps.worktreePath,
|
||||
deps.runtimeEnvironmentId
|
||||
)
|
||||
const mappedPath = mapTerminalFilePath(resolved.absolutePath, deps.worktreePath)
|
||||
const mappedPath = mapTerminalFilePath(
|
||||
resolved.absolutePath,
|
||||
deps.worktreePath,
|
||||
terminalLinkWslDistro(deps.wslDistro, deps.runtimeEnvironmentId)
|
||||
)
|
||||
const cacheKey = getTerminalPathExistsCacheKey({
|
||||
absolutePath: mappedPath,
|
||||
connectionId: fileContext.connectionId,
|
||||
|
|
|
|||
|
|
@ -11,12 +11,13 @@ import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client'
|
|||
import { useAppStore } from '@/store'
|
||||
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
|
||||
import { resolveKnownWorktreeRootPathLink } from './terminal-worktree-path-link'
|
||||
import { parseWslUncPath } from '../../../../shared/wsl-paths'
|
||||
import { parseWslUncPath, toWindowsWslPath } from '../../../../shared/wsl-paths'
|
||||
|
||||
type TerminalFileOpenDeps = {
|
||||
worktreeId: string
|
||||
worktreePath: string
|
||||
runtimeEnvironmentId?: string | null
|
||||
wslDistro?: string | null
|
||||
openWithSystemDefault?: boolean
|
||||
}
|
||||
|
||||
|
|
@ -50,12 +51,32 @@ export function getTerminalFileContext(
|
|||
}
|
||||
}
|
||||
|
||||
export function mapTerminalFilePath(filePath: string, worktreePath: string): string {
|
||||
const wslPath = parseWslUncPath(worktreePath)
|
||||
if (!wslPath || !filePath.startsWith('/') || filePath.startsWith('//')) {
|
||||
// Why: a WSL-runtime pane prints POSIX paths even when the worktree lives on a
|
||||
// Windows drive, so the distro must come from the pane runtime, not the path shape.
|
||||
export function mapTerminalFilePath(
|
||||
filePath: string,
|
||||
worktreePath: string,
|
||||
wslDistro?: string | null
|
||||
): string {
|
||||
const distro = wslDistro?.trim() || parseWslUncPath(worktreePath)?.distro
|
||||
if (!distro || !filePath.startsWith('/') || filePath.startsWith('//')) {
|
||||
return filePath
|
||||
}
|
||||
return `//wsl.localhost/${wslPath.distro}${filePath}`
|
||||
// Why: /mnt/<drive> is a Windows drive mounted into WSL — reach it directly
|
||||
// instead of routing a native file back through the 9P share.
|
||||
if (/^\/mnt\/[a-z](\/|$)/.test(filePath)) {
|
||||
return toWindowsWslPath(filePath, distro)
|
||||
}
|
||||
return `//wsl.localhost/${distro}${filePath}`
|
||||
}
|
||||
|
||||
// Why: remote-runtime panes print the remote host's POSIX paths; the local WSL
|
||||
// distro must never rewrite them.
|
||||
export function terminalLinkWslDistro(
|
||||
wslDistro: string | null | undefined,
|
||||
runtimeEnvironmentId: string | null | undefined
|
||||
): string | null {
|
||||
return runtimeEnvironmentId ? null : (wslDistro ?? null)
|
||||
}
|
||||
|
||||
export function shouldOpenTerminalFileWithSystemDefault(
|
||||
|
|
@ -101,7 +122,11 @@ export function openDetectedFilePath(
|
|||
deps: TerminalFileOpenDeps
|
||||
): void {
|
||||
const { openWithSystemDefault = false, runtimeEnvironmentId, worktreeId, worktreePath } = deps
|
||||
const mappedFilePath = mapTerminalFilePath(filePath, worktreePath)
|
||||
const mappedFilePath = mapTerminalFilePath(
|
||||
filePath,
|
||||
worktreePath,
|
||||
terminalLinkWslDistro(deps.wslDistro, runtimeEnvironmentId)
|
||||
)
|
||||
const requestId = ++latestOpenDetectedFilePathRequestId
|
||||
cancelPendingEditorRevealFrames()
|
||||
|
||||
|
|
|
|||
|
|
@ -1921,6 +1921,59 @@ describe('createFilePathLinkProvider range bounds', () => {
|
|||
|
||||
it('does not map POSIX paths for a native Windows worktree', () => {
|
||||
expect(mapTerminalFilePath('/repo/file.md', 'C:\\repo')).toBe('/repo/file.md')
|
||||
expect(mapTerminalFilePath('/mnt/c/repo/file.md', '/Users/a/repo')).toBe('/mnt/c/repo/file.md')
|
||||
})
|
||||
|
||||
it('maps POSIX paths with the pane WSL distro when the worktree is on a Windows drive', () => {
|
||||
expect(mapTerminalFilePath('/home/alice/notes.md', 'C:\\repo', 'Ubuntu')).toBe(
|
||||
'//wsl.localhost/Ubuntu/home/alice/notes.md'
|
||||
)
|
||||
expect(mapTerminalFilePath('/mnt/c/repo/README.md', 'C:\\repo', 'Ubuntu')).toBe(
|
||||
'C:\\repo\\README.md'
|
||||
)
|
||||
})
|
||||
|
||||
it('routes /mnt drive paths to the native Windows drive for a WSL worktree', () => {
|
||||
expect(mapTerminalFilePath('/mnt/c/repo/README.md', '\\\\wsl.localhost\\Ubuntu\\repo')).toBe(
|
||||
'C:\\repo\\README.md'
|
||||
)
|
||||
})
|
||||
|
||||
it('maps POSIX terminal links for a WSL-runtime pane on a Windows-drive worktree', async () => {
|
||||
const mappedPath = 'C:\\repo\\src\\main.ts'
|
||||
vi.mocked(window.api.shell.pathExists).mockImplementation(
|
||||
async (pathValue) => pathValue === mappedPath
|
||||
)
|
||||
const { provider } = createProviderSetup([makeBufferLine('src/main.ts:5')], new Map(), {
|
||||
worktreePath: 'C:\\repo',
|
||||
wslDistro: 'Ubuntu',
|
||||
startupCwd: '/mnt/c/repo',
|
||||
getPaneLinkCwd: () => '/mnt/c/repo'
|
||||
})
|
||||
|
||||
const links = await new Promise<ILink[]>((resolve) => {
|
||||
provider.provideLinks(1, (provided) => resolve(provided ?? []))
|
||||
})
|
||||
|
||||
expect(links).toHaveLength(1)
|
||||
expect(window.api.shell.pathExists).toHaveBeenCalledWith(mappedPath)
|
||||
})
|
||||
|
||||
it('ignores the pane WSL distro for remote runtime panes', async () => {
|
||||
setPlatform('Windows')
|
||||
storeState.settings = { activeRuntimeEnvironmentId: 'env-2' }
|
||||
|
||||
openDetectedFilePath('/home/alice/notes.md', null, null, {
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath: 'C:\\repo',
|
||||
wslDistro: 'Ubuntu',
|
||||
runtimeEnvironmentId: 'env-1'
|
||||
})
|
||||
await flushAsyncWork()
|
||||
|
||||
expect(authorizeExternalPathMock).toHaveBeenCalledWith({
|
||||
targetPath: '/home/alice/notes.md'
|
||||
})
|
||||
})
|
||||
|
||||
it('opens an existing extensionless spaced prefix from direct fallback cache', async () => {
|
||||
|
|
|
|||
|
|
@ -16,7 +16,8 @@ import {
|
|||
isHtmlFilePath,
|
||||
mapTerminalFilePath,
|
||||
openDetectedFilePath,
|
||||
shouldOpenTerminalFileWithSystemDefault
|
||||
shouldOpenTerminalFileWithSystemDefault,
|
||||
terminalLinkWslDistro
|
||||
} from './terminal-file-open-routing'
|
||||
import {
|
||||
buildHardWrappedPathLogicalLineCandidates,
|
||||
|
|
@ -38,6 +39,7 @@ import {
|
|||
} from './terminal-link-open-hints'
|
||||
import { resolveKnownWorktreeRootPathLink } from './terminal-worktree-path-link'
|
||||
import { isTerminalLinkActivation } from './terminal-link-activation'
|
||||
import { getTerminalBufferPositionForMouseEvent } from './terminal-mouse-buffer-position'
|
||||
|
||||
export { openDetectedFilePath } from './terminal-file-open-routing'
|
||||
export { mapTerminalFilePath } from './terminal-file-open-routing'
|
||||
|
|
@ -55,6 +57,7 @@ export type LinkHandlerDeps = {
|
|||
pathExistsCache: Map<string, boolean>
|
||||
runtimeEnvironmentId?: string | null
|
||||
terminalHomePath?: string | null
|
||||
wslDistro?: string | null
|
||||
getRuntimeEnvironmentIdForPane?: (paneId: number) => string | null
|
||||
}
|
||||
|
||||
|
|
@ -134,14 +137,18 @@ export function createFilePathLinkProvider(
|
|||
if (!resolved) {
|
||||
return null
|
||||
}
|
||||
const mappedPath = mapTerminalFilePath(resolved.absolutePath, worktreePath)
|
||||
const runtimeEnvironmentId =
|
||||
deps.getRuntimeEnvironmentIdForPane?.(paneId) ?? deps.runtimeEnvironmentId ?? null
|
||||
const mappedPath = mapTerminalFilePath(
|
||||
resolved.absolutePath,
|
||||
worktreePath,
|
||||
terminalLinkWslDistro(deps.wslDistro, runtimeEnvironmentId)
|
||||
)
|
||||
const range = rangeForParsedFileLink(logicalLine, parsed.startIndex, parsed.endIndex)
|
||||
if (!range) {
|
||||
return null
|
||||
}
|
||||
|
||||
const runtimeEnvironmentId =
|
||||
deps.getRuntimeEnvironmentIdForPane?.(paneId) ?? deps.runtimeEnvironmentId ?? null
|
||||
const fileContext = getTerminalFileContext(
|
||||
worktreeId,
|
||||
worktreePath,
|
||||
|
|
@ -186,6 +193,7 @@ export function createFilePathLinkProvider(
|
|||
worktreeId,
|
||||
worktreePath,
|
||||
runtimeEnvironmentId,
|
||||
wslDistro: deps.wslDistro,
|
||||
openWithSystemDefault: Boolean(event.shiftKey)
|
||||
})
|
||||
},
|
||||
|
|
@ -247,38 +255,6 @@ export function createFilePathLinkProvider(
|
|||
}
|
||||
}
|
||||
|
||||
function getTerminalScreenElement(terminal: Terminal): HTMLElement | null {
|
||||
return terminal.element?.querySelector('.xterm-screen') ?? null
|
||||
}
|
||||
|
||||
function getBufferPositionForTerminalMouseEvent(
|
||||
terminal: Terminal,
|
||||
event: MouseEvent
|
||||
): { x: number; y: number } | null {
|
||||
const screenElement = getTerminalScreenElement(terminal)
|
||||
if (!screenElement || terminal.cols <= 0 || terminal.rows <= 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const rect = screenElement.getBoundingClientRect()
|
||||
const relativeX = event.clientX - rect.left
|
||||
const relativeY = event.clientY - rect.top
|
||||
if (relativeX < 0 || relativeY < 0 || relativeX >= rect.width || relativeY >= rect.height) {
|
||||
return null
|
||||
}
|
||||
|
||||
const cellWidth = rect.width / terminal.cols
|
||||
const cellHeight = rect.height / terminal.rows
|
||||
if (cellWidth <= 0 || cellHeight <= 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
x: Math.floor(relativeX / cellWidth) + 1,
|
||||
y: Math.floor(relativeY / cellHeight) + terminal.buffer.active.viewportY + 1
|
||||
}
|
||||
}
|
||||
|
||||
export function installFilePathLinkClickFallback(
|
||||
paneId: number,
|
||||
terminal: Terminal,
|
||||
|
|
@ -290,7 +266,7 @@ export function installFilePathLinkClickFallback(
|
|||
return
|
||||
}
|
||||
|
||||
const position = getBufferPositionForTerminalMouseEvent(terminal, event)
|
||||
const position = getTerminalBufferPositionForMouseEvent(terminal, event)
|
||||
if (!position) {
|
||||
return
|
||||
}
|
||||
|
|
@ -309,6 +285,7 @@ export function installFilePathLinkClickFallback(
|
|||
worktreeId: deps.worktreeId,
|
||||
worktreePath: deps.worktreePath,
|
||||
runtimeEnvironmentId,
|
||||
wslDistro: deps.wslDistro,
|
||||
pathExistsCache: deps.pathExistsCache,
|
||||
openWithSystemDefault: Boolean(event.shiftKey)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,12 @@ export function handleOscLink(
|
|||
rawText: string,
|
||||
event: TerminalLinkEvent | undefined,
|
||||
deps: Pick<LinkHandlerDeps, 'worktreeId' | 'worktreePath'> &
|
||||
Partial<Pick<LinkHandlerDeps, 'runtimeEnvironmentId' | 'startupCwd' | 'terminalHomePath'>> & {
|
||||
Partial<
|
||||
Pick<
|
||||
LinkHandlerDeps,
|
||||
'runtimeEnvironmentId' | 'startupCwd' | 'terminalHomePath' | 'wslDistro'
|
||||
>
|
||||
> & {
|
||||
sourceOwner?: HttpLinkSourceOwner
|
||||
requestOpenLinksInAppPreference?: TerminalLinkRoutingPreferenceRequester
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context'
|
||||
import {
|
||||
getCachedWindowsTerminalCapabilities,
|
||||
hasCachedWindowsTerminalCapabilities
|
||||
} from '@/lib/windows-terminal-capabilities'
|
||||
import { parseWslUncPath } from '../../../../shared/wsl-paths'
|
||||
|
||||
type PaneWslDistroState = Parameters<typeof getLocalProjectExecutionRuntimeContext>[0]
|
||||
|
||||
/**
|
||||
* Distro whose POSIX paths a local pane prints. Mirrors the runtime resolution
|
||||
* `pty-connection` uses to decide whether the pane shell runs inside WSL, so a
|
||||
* worktree on a Windows drive with a WSL project runtime is still recognized.
|
||||
*/
|
||||
export function resolvePaneWslDistro(
|
||||
state: PaneWslDistroState,
|
||||
worktreeId: string,
|
||||
worktreePath: string
|
||||
): string | null {
|
||||
const capabilities = hasCachedWindowsTerminalCapabilities()
|
||||
? getCachedWindowsTerminalCapabilities()
|
||||
: null
|
||||
const projectRuntime = getLocalProjectExecutionRuntimeContext(state, worktreeId, undefined, {
|
||||
wslAvailable: capabilities?.wslAvailable,
|
||||
availableWslDistros: capabilities?.wslDistros ?? null
|
||||
})
|
||||
if (projectRuntime?.status === 'resolved') {
|
||||
return projectRuntime.runtime.kind === 'wsl' ? projectRuntime.runtime.distro : null
|
||||
}
|
||||
if (projectRuntime?.status === 'repair-required') {
|
||||
return projectRuntime.repair.preferredRuntime.distro
|
||||
}
|
||||
return parseWslUncPath(worktreePath)?.distro ?? null
|
||||
}
|
||||
|
|
@ -102,6 +102,7 @@ import {
|
|||
type ReconcilableBinding
|
||||
} from './terminal-dead-session-reconcile'
|
||||
import { getConnectionId } from '@/lib/connection-context'
|
||||
import { resolvePaneWslDistro } from './terminal-pane-wsl-distro'
|
||||
import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner'
|
||||
import { isPaneReplaying, type ReplayingPanesRef } from './replay-guard'
|
||||
import { canReleaseReplayedScrollbackFromStore } from './replayed-scrollback-store-release'
|
||||
|
|
@ -707,6 +708,9 @@ export function useTerminalPaneLifecycle({
|
|||
queuedInitialCwdRef.current = initialCwdResolution.queuedInitialCwd
|
||||
const startupCwd = initialCwdResolution.startupCwd
|
||||
const terminalHomePath = resolveTerminalHomePathFromEnv(startup?.env)
|
||||
const wslDistro = getConnectionId(worktreeId)
|
||||
? null
|
||||
: resolvePaneWslDistro(useAppStore.getState(), worktreeId, worktreePath)
|
||||
const getPaneLinkCwd = (paneId: number): string =>
|
||||
resolvePaneLinkCwd(paneCwdRef.current, paneId, startupCwd)
|
||||
const getHttpLinkSourceOwnerForPane = (paneId: number) =>
|
||||
|
|
@ -719,6 +723,7 @@ export function useTerminalPaneLifecycle({
|
|||
startupCwd,
|
||||
getPaneLinkCwd,
|
||||
terminalHomePath,
|
||||
wslDistro,
|
||||
managerRef,
|
||||
linkProviderDisposablesRef,
|
||||
pathExistsCache,
|
||||
|
|
|
|||
Loading…
Reference in New Issue