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:
Neil 2026-08-08 03:33:39 -07:00 committed by GitHub
parent 434959965a
commit d0baa20d0e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 151 additions and 46 deletions

View File

@ -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,

View File

@ -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()

View File

@ -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 () => {

View File

@ -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)
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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,