From d0baa20d0ec002699abd82d577d13a506b5fee07 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 8 Aug 2026 03:33:39 -0700 Subject: [PATCH] fix(terminal): resolve WSL file links from the execution runtime, not the worktree path (#12465) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- .../terminal-file-link-hit-testing.ts | 10 +++- .../terminal-file-open-routing.ts | 37 ++++++++++--- .../terminal-link-handlers.test.ts | 53 +++++++++++++++++++ .../terminal-pane/terminal-link-handlers.ts | 51 +++++------------- .../terminal-osc-link-routing.ts | 7 ++- .../terminal-pane/terminal-pane-wsl-distro.ts | 34 ++++++++++++ .../use-terminal-pane-lifecycle.ts | 5 ++ 7 files changed, 151 insertions(+), 46 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/terminal-pane-wsl-distro.ts 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 d48b731ea..d70130f23 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 @@ -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 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, 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 c62b99ca9..a0fd48405 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 @@ -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/ 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() 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 09068a4a1..3bb650b0e 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 @@ -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((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 () => { 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 7ce2c0375..2f4b9155a 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts @@ -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 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) } diff --git a/src/renderer/src/components/terminal-pane/terminal-osc-link-routing.ts b/src/renderer/src/components/terminal-pane/terminal-osc-link-routing.ts index e5dbe2b13..1f51187fd 100644 --- a/src/renderer/src/components/terminal-pane/terminal-osc-link-routing.ts +++ b/src/renderer/src/components/terminal-pane/terminal-osc-link-routing.ts @@ -29,7 +29,12 @@ export function handleOscLink( rawText: string, event: TerminalLinkEvent | undefined, deps: Pick & - Partial> & { + Partial< + Pick< + LinkHandlerDeps, + 'runtimeEnvironmentId' | 'startupCwd' | 'terminalHomePath' | 'wslDistro' + > + > & { sourceOwner?: HttpLinkSourceOwner requestOpenLinksInAppPreference?: TerminalLinkRoutingPreferenceRequester } diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-wsl-distro.ts b/src/renderer/src/components/terminal-pane/terminal-pane-wsl-distro.ts new file mode 100644 index 000000000..076f57cb9 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pane-wsl-distro.ts @@ -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[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 +} diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index 235243cb5..220dc8ad8 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -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,