From 1f8fd5c44ed1e5fffa40dbde713e97ef552ce426 Mon Sep 17 00:00:00 2001 From: OrcaWin <293788423+OrcaWin@users.noreply.github.com> Date: Tue, 11 Aug 2026 03:41:11 -0700 Subject: [PATCH] fix(editor): guard local WSL path aliases --- .../components/editor/editor-autosave.test.ts | 40 ++++- .../src/components/editor/editor-autosave.ts | 13 +- .../terminal-file-open-routing.ts | 15 +- .../terminal-link-handlers.test.ts | 50 +++++- .../useEditorExternalWatch-targets.test.ts | 57 +++++++ .../useEditorExternalWatch-wsl-repro.test.ts | 146 ++++++++++++++++++ .../src/hooks/useEditorExternalWatch.ts | 87 ++++++++++- src/renderer/src/lib/desktop-window-chrome.ts | 8 + src/renderer/src/store/slices/editor.test.ts | 130 ++++++++++++++++ src/renderer/src/store/slices/editor.ts | 27 +++- src/shared/cross-platform-path.test.ts | 27 ++++ src/shared/cross-platform-path.ts | 17 +- 12 files changed, 591 insertions(+), 26 deletions(-) create mode 100644 src/renderer/src/hooks/useEditorExternalWatch-wsl-repro.test.ts diff --git a/src/renderer/src/components/editor/editor-autosave.test.ts b/src/renderer/src/components/editor/editor-autosave.test.ts index 83968294782..67232a58583 100644 --- a/src/renderer/src/components/editor/editor-autosave.test.ts +++ b/src/renderer/src/components/editor/editor-autosave.test.ts @@ -258,8 +258,8 @@ describe('getOpenFilesForExternalFileChange', () => { }).map((file) => file.id) ).toEqual(['runtime-edit', 'runtime-diff']) }) - it('matches WSL edit tabs opened from a forward-slash UNC terminal link', () => { - // Why: terminal links use //wsl.localhost/Distro/... while file watchers emit \\wsl.localhost\Distro\... + it('matches restored WSL aliases only for a proven local Windows watcher', () => { + vi.stubGlobal('navigator', { userAgent: 'Windows' }) const terminalLinkTab = makeOpenFile({ id: '//wsl.localhost/Ubuntu/workspace/repo/file.ts', filePath: '//wsl.localhost/Ubuntu/workspace/repo/file.ts', @@ -270,8 +270,42 @@ describe('getOpenFilesForExternalFileChange', () => { getOpenFilesForExternalFileChange([terminalLinkTab], { worktreeId: 'wt-wsl', worktreePath: '\\\\wsl.localhost\\Ubuntu\\workspace\\repo', - relativePath: 'file.ts' + relativePath: 'file.ts', + runtimeEnvironmentId: null, + allowLocalWindowsWslAliases: true }).map((file) => file.id) ).toEqual(['//wsl.localhost/Ubuntu/workspace/repo/file.ts']) + + expect( + getOpenFilesForExternalFileChange([terminalLinkTab], { + worktreeId: 'wt-wsl', + worktreePath: '\\\\wsl.localhost\\Ubuntu\\workspace\\repo', + relativePath: 'file.ts', + runtimeEnvironmentId: null + }) + ).toEqual([]) + + vi.stubGlobal('navigator', { userAgent: 'Linux' }) + expect( + getOpenFilesForExternalFileChange([terminalLinkTab], { + worktreeId: 'wt-wsl', + worktreePath: '\\\\wsl.localhost\\Ubuntu\\workspace\\repo', + relativePath: 'file.ts', + runtimeEnvironmentId: null, + allowLocalWindowsWslAliases: true + }) + ).toEqual([]) + + vi.stubGlobal('navigator', { userAgent: 'Windows' }) + vi.stubGlobal('__ORCA_WEB_CLIENT__', true) + expect( + getOpenFilesForExternalFileChange([terminalLinkTab], { + worktreeId: 'wt-wsl', + worktreePath: '\\\\wsl.localhost\\Ubuntu\\workspace\\repo', + relativePath: 'file.ts', + runtimeEnvironmentId: null, + allowLocalWindowsWslAliases: true + }) + ).toEqual([]) }) }) diff --git a/src/renderer/src/components/editor/editor-autosave.ts b/src/renderer/src/components/editor/editor-autosave.ts index 2f1b6a5a7f6..c6d01a8cdf8 100644 --- a/src/renderer/src/components/editor/editor-autosave.ts +++ b/src/renderer/src/components/editor/editor-autosave.ts @@ -1,6 +1,7 @@ import { joinPath } from '@/lib/path' import type { OpenFile } from '@/store/slices/editor' -import { normalizeRuntimePathForComparison } from '../../../../shared/cross-platform-path' +import { areLocalWindowsWslPathAliases } from '../../../../shared/cross-platform-path' +import { isLocalWindowsDesktopClient } from '@/lib/desktop-window-chrome' import { DEFAULT_EDITOR_AUTO_SAVE_DELAY_MS, MAX_EDITOR_AUTO_SAVE_DELAY_MS, @@ -21,6 +22,7 @@ export type EditorPathMutationTarget = { worktreePath: string relativePath: string runtimeEnvironmentId?: string | null + allowLocalWindowsWslAliases?: true } export type EditorSaveQuiesceTarget = { fileId: string } | EditorPathMutationTarget @@ -139,9 +141,12 @@ export function getOpenFilesForExternalFileChange( return false } if (file.mode === 'edit' || file.mode === 'markdown-preview') { - // Why: WSL UNC paths from terminal links (//wsl.localhost/...) and file - // watchers (\\wsl.localhost\...) must fold to the same form before comparing. - return normalizeRuntimePathForComparison(file.filePath) === normalizeRuntimePathForComparison(absolutePath) + return ( + file.filePath === absolutePath || + (target.allowLocalWindowsWslAliases === true && + isLocalWindowsDesktopClient() && + areLocalWindowsWslPathAliases(file.filePath, absolutePath)) + ) } if (file.mode === 'diff') { return ( 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 4478c0f65fc..cd8fa2722dc 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 @@ -58,14 +58,17 @@ export function mapTerminalFilePath( worktreePath: string, wslDistro?: string | null ): string { - // Why: //wsl.localhost/... and \\wsl.localhost\... are the same file. Keep the - // backslash form so open tabs and file watchers match the Files sidebar (#13349). + const distro = + wslDistro === null ? null : wslDistro?.trim() || parseWslUncPath(worktreePath)?.distro + if (!distro || !filePath.startsWith('/')) { + return filePath + } + // Why: only a proven local WSL pane may reinterpret this POSIX-looking path; SSH/runtime paths stay literal. const alreadyUnc = parseWslUncPath(filePath) if (alreadyUnc) { return toWindowsWslPath(alreadyUnc.linuxPath, alreadyUnc.distro) } - const distro = wslDistro?.trim() || parseWslUncPath(worktreePath)?.distro - if (!distro || !filePath.startsWith('/')) { + if (filePath.startsWith('//')) { return filePath } // Why: /mnt/ is a Windows drive mounted into WSL — reach it directly @@ -78,8 +81,8 @@ export function mapTerminalFilePath( export function terminalLinkWslDistro( wslDistro: string | null | undefined, runtimeEnvironmentId: string | null | undefined -): string | null { - return runtimeEnvironmentId ? null : (wslDistro ?? null) +): string | null | undefined { + return runtimeEnvironmentId ? null : wslDistro } export function shouldOpenTerminalFileWithSystemDefault( 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 946af864ca3..6f9c0e12536 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 @@ -778,7 +778,8 @@ describe('handleOscLink', () => { { ...deps, startupCwd: '/root/workspace/myrepo', - worktreePath: '\\\\wsl.localhost\\Ubuntu\\home\\repo' + worktreePath: '\\\\wsl.localhost\\Ubuntu\\home\\repo', + wslDistro: 'Ubuntu' } ) await flushAsyncWork() @@ -810,7 +811,8 @@ describe('handleOscLink', () => { { metaKey: false, ctrlKey: true }, { ...deps, - worktreePath: '\\\\wsl.localhost\\Ubuntu\\home\\repo' + worktreePath: '\\\\wsl.localhost\\Ubuntu\\home\\repo', + wslDistro: 'Ubuntu' } ) await flushAsyncWork() @@ -952,6 +954,24 @@ describe('handleOscLink', () => { ) }) + it('keeps WSL-looking paths literal for a direct SSH pane', async () => { + setPlatform('Windows') + vi.mocked(getConnectionId).mockReturnValue('ssh-1') + const literalPath = '//wsl.localhost/Ubuntu/repo/file.ts' + + openDetectedFilePath(literalPath, null, null, { + worktreeId: 'wt-1', + worktreePath: '//wsl.localhost/Ubuntu/repo', + wslDistro: null + }) + await flushAsyncWork() + + expect(statMock).toHaveBeenCalledWith({ filePath: literalPath, connectionId: 'ssh-1' }) + expect(openFileMock).toHaveBeenCalledWith(expect.objectContaining({ filePath: literalPath }), { + forceContentReload: true + }) + }) + it('pins SSH links outside the worktree to their target host', async () => { setPlatform('Macintosh') vi.mocked(getConnectionId).mockReturnValue('ssh-1') @@ -994,6 +1014,12 @@ describe('handleOscLink', () => { }) await flushAsyncWork() + expect(openFileMock).toHaveBeenCalledWith( + expect.objectContaining({ + filePath: '//wsl.localhost/ubuntu/home/Alice/repo/src/main.ts' + }), + { forceContentReload: true } + ) expect(openFileMock).toHaveBeenCalledWith( expect.not.objectContaining({ externalSshTargetId: expect.anything() }), { forceContentReload: true } @@ -1665,6 +1691,7 @@ describe('createFilePathLinkProvider range bounds', () => { worktreeId: 'wt-1', worktreePath: '\\\\wsl.localhost\\Ubuntu\\home\\repo', runtimeEnvironmentId: null, + wslDistro: 'Ubuntu', pathExistsCache: new Map([ ['active\0\\\\wsl.localhost\\Ubuntu\\root\\workspace\\myrepo\\README.md', true] ]) @@ -1880,7 +1907,7 @@ describe('createFilePathLinkProvider range bounds', () => { const { provider, linkTooltip } = createProviderSetup( [makeBufferLine('/root/workspace/myrepo/README.md:5:3')], new Map(), - { worktreePath, startupCwd: '/root/workspace/myrepo' } + { worktreePath, wslDistro: 'Ubuntu', startupCwd: '/root/workspace/myrepo' } ) const links = await new Promise((resolve) => { @@ -1918,6 +1945,7 @@ describe('createFilePathLinkProvider range bounds', () => { ) const { provider } = createProviderSetup([makeBufferLine('README.md:5')], new Map(), { worktreePath: '\\\\wsl.localhost\\Ubuntu\\home\\repo', + wslDistro: 'Ubuntu', startupCwd: '/stale', getPaneLinkCwd: () => '/root/workspace/myrepo' }) @@ -1943,6 +1971,9 @@ describe('createFilePathLinkProvider range bounds', () => { expect( mapTerminalFilePath('\\\\server\\share\\file.md', '\\\\wsl.localhost\\Ubuntu\\repo') ).toBe('\\\\server\\share\\file.md') + expect(mapTerminalFilePath('//server/share/file.md', '\\\\wsl.localhost\\Ubuntu\\repo')).toBe( + '//server/share/file.md' + ) expect(mapTerminalFilePath('C:/repo/file.md', '\\\\wsl.localhost\\Ubuntu\\repo')).toBe( 'C:/repo/file.md' ) @@ -1953,6 +1984,19 @@ describe('createFilePathLinkProvider range bounds', () => { expect(mapTerminalFilePath('/mnt/c/repo/file.md', '/Users/a/repo')).toBe('/mnt/c/repo/file.md') }) + it('keeps WSL-looking paths literal without a local WSL owner', () => { + expect(mapTerminalFilePath('//wsl.localhost/Ubuntu/repo/file.md', '/remote/repo')).toBe( + '//wsl.localhost/Ubuntu/repo/file.md' + ) + expect( + mapTerminalFilePath( + '//wsl.localhost/Ubuntu/repo/file.md', + '\\\\wsl.localhost\\Ubuntu\\repo', + null + ) + ).toBe('//wsl.localhost/Ubuntu/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' diff --git a/src/renderer/src/hooks/useEditorExternalWatch-targets.test.ts b/src/renderer/src/hooks/useEditorExternalWatch-targets.test.ts index b2367e33721..046122f348c 100644 --- a/src/renderer/src/hooks/useEditorExternalWatch-targets.test.ts +++ b/src/renderer/src/hooks/useEditorExternalWatch-targets.test.ts @@ -104,6 +104,63 @@ describe('getEditorExternalWatchTargets', () => { ]) }) + it('enables WSL aliases for a proven-local Windows drive watcher', () => { + const repo = makeRepo('repo-local-drive') + const worktree = makeWorktree(repo.id, 'wt-local-drive') + worktree.path = 'C:\\repo' + + expect( + getEditorExternalWatchTargets( + makeState({ repo, worktree, openFiles: [makeOpenFile(worktree.id)] }) + ).targets + ).toEqual([ + { + worktreeId: 'wt-local-drive', + worktreePath: 'C:\\repo', + connectionId: undefined, + runtimeEnvironmentId: null, + allowLocalWindowsWslAliases: true + } + ]) + }) + + it('does not infer a local alias owner while repo metadata is missing', () => { + const repo = makeRepo('repo-unresolved') + const worktree = makeWorktree(repo.id, 'wt-unresolved') + worktree.path = 'C:\\repo' + const state = makeState({ repo, worktree, openFiles: [makeOpenFile(worktree.id)] }) + state.repos = [] + + expect(getEditorExternalWatchTargets(state).targets).toEqual([ + { + worktreeId: 'wt-unresolved', + worktreePath: 'C:\\repo', + connectionId: undefined, + runtimeEnvironmentId: null + } + ]) + }) + + it.each(['worktree', 'repo'] as const)( + 'does not grant local aliases for an unknown %s host stamp', + (stampOwner) => { + const repo = makeRepo('repo-unknown-host') + const worktree = makeWorktree(repo.id, 'wt-unknown-host') + worktree.path = 'C:\\repo' + if (stampOwner === 'worktree') { + worktree.hostId = 'future:host' as never + } else { + repo.executionHostId = 'future:host' as never + } + + const target = getEditorExternalWatchTargets( + makeState({ repo, worktree, openFiles: [makeOpenFile(worktree.id)] }) + ).targets[0] + + expect(target).not.toHaveProperty('allowLocalWindowsWslAliases') + } + ) + it('does not watch the active worktree while the sidebar is hidden', () => { const repo = makeRepo('repo-active') const worktree = makeWorktree(repo.id, 'wt-active') diff --git a/src/renderer/src/hooks/useEditorExternalWatch-wsl-repro.test.ts b/src/renderer/src/hooks/useEditorExternalWatch-wsl-repro.test.ts new file mode 100644 index 00000000000..487a31098f8 --- /dev/null +++ b/src/renderer/src/hooks/useEditorExternalWatch-wsl-repro.test.ts @@ -0,0 +1,146 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as EditorAutosaveModule from '@/components/editor/editor-autosave' +import type { FsChangedPayload } from '../../../shared/types' + +vi.mock('@/store', () => ({ useAppStore: { getState: vi.fn() } })) +vi.mock('@/components/editor/editor-autosave', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, notifyEditorExternalFileChange: vi.fn() } +}) + +import { useAppStore } from '@/store' +import { notifyEditorExternalFileChange } from '@/components/editor/editor-autosave' +import { createExternalWatchEventHandler } from './useEditorExternalWatch' + +const worktreePath = '\\\\wsl.localhost\\Ubuntu\\workspace\\repo' +const restoredPath = '//wsl.localhost/Ubuntu/workspace/repo/file.ts' + +function payload(): FsChangedPayload { + return { + worktreePath, + events: [ + { kind: 'update', absolutePath: '\\\\wsl.localhost\\Ubuntu\\workspace\\repo\\file.ts' } + ] + } +} + +describe('WSL watcher stale-refresh reproduction', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() + vi.stubGlobal('window', { dispatchEvent: vi.fn() }) + vi.stubGlobal('navigator', { userAgent: 'Windows' }) + vi.mocked(useAppStore.getState).mockReturnValue({ + openFiles: [ + { + id: restoredPath, + filePath: restoredPath, + relativePath: 'file.ts', + worktreeId: 'wt-wsl', + mode: 'edit', + isDirty: false + } + ], + setExternalMutation: vi.fn() + } as never) + }) + + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + }) + + it('reloads a restored forward-UNC tab from a local backslash watcher event', () => { + const { handleFsChanged, dispose } = createExternalWatchEventHandler(() => ({ + worktreeId: 'wt-wsl', + worktreePath, + connectionId: undefined, + runtimeEnvironmentId: null, + allowLocalWindowsWslAliases: true + })) + + handleFsChanged(payload()) + vi.advanceTimersByTime(100) + + expect(notifyEditorExternalFileChange).toHaveBeenCalledWith({ + worktreeId: 'wt-wsl', + worktreePath, + relativePath: 'file.ts', + runtimeEnvironmentId: null, + allowLocalWindowsWslAliases: true + }) + dispose() + }) + + it('keeps update aliases distinct for an SSH watcher', () => { + const { handleFsChanged, dispose } = createExternalWatchEventHandler(() => ({ + worktreeId: 'wt-wsl', + worktreePath, + connectionId: 'ssh-1', + runtimeEnvironmentId: null + })) + + handleFsChanged(payload()) + vi.advanceTimersByTime(100) + + expect(notifyEditorExternalFileChange).not.toHaveBeenCalled() + dispose() + }) + + it('keeps the same aliases distinct on a POSIX desktop', () => { + vi.stubGlobal('navigator', { userAgent: 'Linux' }) + const { handleFsChanged, dispose } = createExternalWatchEventHandler(() => ({ + worktreeId: 'wt-wsl', + worktreePath, + connectionId: undefined, + runtimeEnvironmentId: null, + allowLocalWindowsWslAliases: true + })) + + handleFsChanged(payload()) + vi.advanceTimersByTime(100) + + expect(notifyEditorExternalFileChange).not.toHaveBeenCalled() + dispose() + }) + + it('reloads a restored /mnt drive alias from a native-drive watcher event', () => { + const driveRoot = 'C:\\workspace\\repo' + const mountedPath = '//wsl.localhost/Ubuntu/mnt/c/workspace/repo/file.ts' + vi.mocked(useAppStore.getState).mockReturnValue({ + openFiles: [ + { + id: mountedPath, + filePath: mountedPath, + relativePath: 'file.ts', + worktreeId: 'wt-wsl', + mode: 'edit', + isDirty: false + } + ], + setExternalMutation: vi.fn() + } as never) + const { handleFsChanged, dispose } = createExternalWatchEventHandler(() => ({ + worktreeId: 'wt-wsl', + worktreePath: driveRoot, + connectionId: undefined, + runtimeEnvironmentId: null, + allowLocalWindowsWslAliases: true + })) + + handleFsChanged({ + worktreePath: driveRoot, + events: [{ kind: 'update', absolutePath: 'C:\\workspace\\repo\\file.ts' }] + }) + vi.advanceTimersByTime(100) + + expect(notifyEditorExternalFileChange).toHaveBeenCalledWith({ + worktreeId: 'wt-wsl', + worktreePath: driveRoot, + relativePath: 'file.ts', + runtimeEnvironmentId: null, + allowLocalWindowsWslAliases: true + }) + dispose() + }) +}) diff --git a/src/renderer/src/hooks/useEditorExternalWatch.ts b/src/renderer/src/hooks/useEditorExternalWatch.ts index 1b4ff62a44d..490a1381268 100644 --- a/src/renderer/src/hooks/useEditorExternalWatch.ts +++ b/src/renderer/src/hooks/useEditorExternalWatch.ts @@ -3,7 +3,10 @@ import { useEffect, useRef } from 'react' import { useAppStore, type AppState } from '@/store' import { basename, joinPath } from '@/lib/path' import { getExternalFileChangeRelativePath } from '@/components/right-sidebar/useFileExplorerWatch' -import { normalizeRuntimePathForComparison } from '../../../shared/cross-platform-path' +import { + isWindowsAbsolutePathLike, + normalizeRuntimePathForComparison +} from '../../../shared/cross-platform-path' import { canAutoSaveOpenFile, getOpenFilesForExternalFileChange, @@ -34,6 +37,8 @@ import { markFileChangedOnDisk } from '@/components/editor/editor-changed-on-dis import { getDiskBaselineSignature } from '@/components/editor/diff-content-signature' import { parseWorkspaceKey } from '../../../shared/workspace-scope' import { getFolderWorkspaceConnectionId } from '@/lib/folder-workspace-connection' +import { isLocalWindowsDesktopClient } from '@/lib/desktop-window-chrome' +import { parseExecutionHostId } from '../../../shared/execution-host' // Why: atomic writes burst same-path events; one reload dispatch each fans out into N EditorPanel rebuilds that can wedge the renderer (issue #826), so debounce per (worktreeId+path). const EXTERNAL_RELOAD_DEBOUNCE_MS = 75 @@ -71,6 +76,7 @@ type WatchedTarget = { worktreePath: string connectionId: string | undefined runtimeEnvironmentId: string | null + allowLocalWindowsWslAliases?: true } type ExternalWatchNotification = { @@ -78,6 +84,53 @@ type ExternalWatchNotification = { worktreePath: string relativePath: string runtimeEnvironmentId: string | null + allowLocalWindowsWslAliases?: true +} + +function localWslAliasOption( + target: Pick +): Pick { + return isLocalWindowsDesktopClient() && target.allowLocalWindowsWslAliases === true + ? { allowLocalWindowsWslAliases: true } + : {} +} + +function isLocalHostStamp(value: string | null | undefined): boolean { + if (!value?.trim()) { + return true + } + return parseExecutionHostId(value)?.kind === 'local' +} + +function canWatchLocalWindowsWslAliases(args: { + worktreePath: string + runtimeEnvironmentId: string | null + connectionId: string | null | undefined + worktree: AppState['worktreesByRepo'][string][number] | undefined + repo: AppState['repos'][number] | undefined + folderWorkspace: AppState['folderWorkspaces'][number] | undefined + projectGroup: AppState['projectGroups'][number] | undefined +}): boolean { + if ( + args.runtimeEnvironmentId !== null || + args.connectionId !== null || + !isWindowsAbsolutePathLike(args.worktreePath) + ) { + return false + } + if (args.worktree) { + return ( + !!args.repo && + !args.worktree.runtimeOwnerEnvironmentId?.trim() && + isLocalHostStamp(args.worktree.hostId) && + isLocalHostStamp(args.repo.executionHostId) + ) + } + return ( + !!args.folderWorkspace && + isLocalHostStamp(args.folderWorkspace.executionHostId) && + isLocalHostStamp(args.projectGroup?.executionHostId) + ) } type WatchedTargetsSnapshot = { @@ -117,7 +170,7 @@ let cachedWatchedTargetsSnapshot: WatchedTargetsSnapshot = { targets: [], target export function getWatchedTargetKey(target: WatchedTarget): string { // Why: include connectionId so a local placeholder watch is replaced by the real SSH watch once an SSH worktree's provider metadata hydrates. - return `${target.worktreeId}::${target.worktreePath}::${target.connectionId ?? 'local'}::${target.runtimeEnvironmentId ?? 'client'}` + return `${target.worktreeId}::${target.worktreePath}::${target.connectionId ?? 'local'}::${target.runtimeEnvironmentId ?? 'client'}::${target.allowLocalWindowsWslAliases === true ? 'wsl-aliases' : 'literal'}` } function openFileRuntimeOwner(file: Pick): string | null { @@ -202,9 +255,14 @@ export function getEditorExternalWatchTargets( continue } const repo = wt ? state.repos.find((r) => r.id === wt.repoId) : undefined + const projectGroup = folderWorkspace + ? state.projectGroups.find((group) => group.id === folderWorkspace.projectGroupId) + : undefined const connectionId = folderWorkspace ? getFolderWorkspaceConnectionId(state, folderWorkspace.id) - : repo?.connectionId + : repo + ? (repo.connectionId ?? null) + : undefined if (connectionId === undefined && folderWorkspace) { continue } @@ -216,7 +274,18 @@ export function getEditorExternalWatchTargets( worktreeId: id, worktreePath: wt?.path ?? folderWorkspace!.folderPath, connectionId: connectionId ?? undefined, - runtimeEnvironmentId: owner + runtimeEnvironmentId: owner, + ...(canWatchLocalWindowsWslAliases({ + worktreePath: wt?.path ?? folderWorkspace!.folderPath, + runtimeEnvironmentId: owner, + connectionId, + worktree: wt, + repo, + folderWorkspace, + projectGroup + }) + ? { allowLocalWindowsWslAliases: true as const } + : {}) } nextTargets.push(target) parts.push(getWatchedTargetKey(target)) @@ -569,7 +638,8 @@ export function createExternalWatchEventHandler( worktreeId: target.worktreeId, worktreePath: target.worktreePath, relativePath, - runtimeEnvironmentId: target.runtimeEnvironmentId + runtimeEnvironmentId: target.runtimeEnvironmentId, + ...localWslAliasOption(target) } const absolutePath = joinPath(notification.worktreePath, notification.relativePath) const matching = getOpenFilesForExternalFileChange(openFilesSnapshot, notification) @@ -885,7 +955,9 @@ function hasCleanExternalReloadTarget(notification: ExternalWatchNotification): export function getOverflowExternalReloadTargets( target: Pick & { + connectionId?: string runtimeEnvironmentId?: string | null + allowLocalWindowsWslAliases?: true } ): ExternalWatchNotification[] { const state = useAppStore.getState() @@ -908,7 +980,10 @@ export function getOverflowExternalReloadTargets( worktreeId: target.worktreeId, worktreePath: target.worktreePath, relativePath: file.relativePath, - runtimeEnvironmentId: target.runtimeEnvironmentId ?? null + runtimeEnvironmentId: target.runtimeEnvironmentId ?? null, + ...localWslAliasOption({ + allowLocalWindowsWslAliases: target.allowLocalWindowsWslAliases + }) }) } diff --git a/src/renderer/src/lib/desktop-window-chrome.ts b/src/renderer/src/lib/desktop-window-chrome.ts index 849b4bf8420..59683f15518 100644 --- a/src/renderer/src/lib/desktop-window-chrome.ts +++ b/src/renderer/src/lib/desktop-window-chrome.ts @@ -7,6 +7,14 @@ export function isPairedWebClientWindow(): boolean { return (globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ === true } +export function isLocalWindowsDesktopClient(): boolean { + return ( + !isPairedWebClientWindow() && + typeof navigator !== 'undefined' && + navigator.userAgent.includes('Windows') + ) +} + export function shouldRenderDesktopWindowChrome({ platform, isWebClient diff --git a/src/renderer/src/store/slices/editor.test.ts b/src/renderer/src/store/slices/editor.test.ts index b63a9d16e1a..50530c90382 100644 --- a/src/renderer/src/store/slices/editor.test.ts +++ b/src/renderer/src/store/slices/editor.test.ts @@ -713,6 +713,136 @@ describe('createEditorSlice openDiff', () => { expect(store.getState().openFiles[0]?.fileContentReloadNonce).toBe(2) }) + it('reuses a restored local WSL alias without folding the Linux path tail', () => { + vi.stubGlobal('navigator', { userAgent: 'Windows' }) + const store = createEditorStore() + const restoredPath = '//wsl.localhost/Ubuntu/home/Alice/repo/file.ts' + store.setState({ + openFiles: [ + { + id: restoredPath, + filePath: restoredPath, + relativePath: 'file.ts', + worktreeId: 'wt-1', + runtimeEnvironmentId: null, + language: 'typescript', + isDirty: false, + mode: 'edit' + } + ] + }) + + expect( + store.getState().openFile( + { + filePath: '\\\\wsl.localhost\\ubuntu\\home\\Alice\\repo\\file.ts', + relativePath: 'file.ts', + worktreeId: 'wt-1', + runtimeEnvironmentId: null, + language: 'typescript', + mode: 'edit' + }, + { suppressActiveRuntimeFallback: true } + ) + ).toBe(restoredPath) + expect(store.getState().openFiles).toHaveLength(1) + + store.getState().openFile( + { + filePath: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\repo\\file.ts', + relativePath: 'file.ts', + worktreeId: 'wt-1', + runtimeEnvironmentId: null, + language: 'typescript', + mode: 'edit' + }, + { suppressActiveRuntimeFallback: true } + ) + expect(store.getState().openFiles).toHaveLength(2) + vi.unstubAllGlobals() + }) + + it('keeps local WSL-looking aliases distinct on POSIX clients', () => { + vi.stubGlobal('navigator', { userAgent: 'Linux' }) + const store = createEditorStore() + store.setState({ + openFiles: [ + { + id: 'forward', + filePath: '//wsl.localhost/Ubuntu/repo/file.ts', + relativePath: 'file.ts', + worktreeId: 'wt-1', + runtimeEnvironmentId: null, + language: 'typescript', + isDirty: false, + mode: 'edit' + } + ] + }) + + store.getState().openFile( + { + filePath: '\\\\wsl.localhost\\Ubuntu\\repo\\file.ts', + relativePath: 'file.ts', + worktreeId: 'wt-1', + runtimeEnvironmentId: null, + language: 'typescript', + mode: 'edit' + }, + { suppressActiveRuntimeFallback: true } + ) + + expect(store.getState().openFiles).toHaveLength(2) + vi.unstubAllGlobals() + }) + + it('does not reuse WSL aliases for SSH-owned tabs', () => { + const store = createEditorStore() + store.setState({ + repos: [{ id: 'repo-1', path: '/repo', connectionId: 'ssh-1' }], + worktreesByRepo: { + 'repo-1': [{ id: 'wt-1', repoId: 'repo-1', path: '/repo', hostId: 'ssh:ssh-1' }] + }, + sshConnectionStates: new Map([ + [ + 'ssh-1', + { + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0, + connectionGeneration: 1 + } + ] + ]), + openFiles: [ + { + id: 'ssh-forward', + filePath: '//wsl.localhost/Ubuntu/repo/file.ts', + relativePath: 'file.ts', + worktreeId: 'wt-1', + runtimeEnvironmentId: null, + externalSshTargetId: 'ssh-1', + language: 'typescript', + isDirty: false, + mode: 'edit' + } + ] + } as never) + + store.getState().openFile({ + filePath: '\\\\wsl.localhost\\Ubuntu\\repo\\file.ts', + relativePath: 'file.ts', + worktreeId: 'wt-1', + runtimeEnvironmentId: null, + externalSshTargetId: 'ssh-1', + language: 'typescript', + mode: 'edit' + }) + + expect(store.getState().openFiles).toHaveLength(2) + }) + it('rebinds an existing external tab when it is reopened from a new SSH host', () => { const store = createEditorStore() const file = { diff --git a/src/renderer/src/store/slices/editor.ts b/src/renderer/src/store/slices/editor.ts index 4eda195dfa2..5493d3a89e9 100644 --- a/src/renderer/src/store/slices/editor.ts +++ b/src/renderer/src/store/slices/editor.ts @@ -10,7 +10,10 @@ import { import type { RecentlyClosedTabPosition } from './recently-closed-tabs' import { joinPath } from '@/lib/path' import { toast } from 'sonner' -import { isPathInsideOrEqual } from '../../../../shared/cross-platform-path' +import { + areLocalWindowsWslPathAliases, + isPathInsideOrEqual +} from '../../../../shared/cross-platform-path' import { resolveMarkdownLinkTarget } from '@/components/editor/markdown-internal-links' import { buildCheckRunDetailsTabId, @@ -107,6 +110,7 @@ import { import { createBrowserUuid } from '@/lib/browser-uuid' import { pruneTabGroupLayoutForGroups } from './tabs-hydration' import { sanitizeRecentTabIds } from './tab-group-state' +import { isLocalWindowsDesktopClient } from '@/lib/desktop-window-chrome' export type { ActiveRightSidebarTab, @@ -1027,6 +1031,23 @@ function isSameEditorOwner( ) } +function canReuseLocalWslAlias( + state: AppState, + existing: OpenFile, + file: Pick, + runtimeEnvironmentId: string | null | undefined +): boolean { + return ( + isLocalWindowsDesktopClient() && + runtimeOwnerKey(runtimeEnvironmentId) === null && + !existing.externalSshTargetId?.trim() && + !file.externalSshTargetId?.trim() && + getConnectionIdForFileFromState(state, file.worktreeId, file.filePath) === null && + getConnectionIdForFileFromState(state, existing.worktreeId, existing.filePath) === null && + areLocalWindowsWslPathAliases(existing.filePath, file.filePath) + ) +} + export function buildOwnedEditorFileId( filePath: string, worktreeId: string, @@ -1745,9 +1766,9 @@ export const createEditorSlice: StateCreator = (s const reusableOpenFileModes = getReusableOpenFileModes(file.mode) const existing = s.openFiles.find( (f) => - f.filePath === file.filePath && matchesEditorMode(f, reusableOpenFileModes) && - isSameEditorOwner(f, worktreeId, runtimeEnvironmentId) + isSameEditorOwner(f, worktreeId, runtimeEnvironmentId) && + (f.filePath === file.filePath || canReuseLocalWslAlias(s, f, file, runtimeEnvironmentId)) ) // Why: a snapshot's reopenId can be a stale shape — the same path is bare in whichever worktree opened it first and namespaced elsewhere — so honoring it while this owner's tab is already open would strand activeFileId and the unified tab on an id no OpenFile has. const id = existing diff --git a/src/shared/cross-platform-path.test.ts b/src/shared/cross-platform-path.test.ts index 398de1913a0..b817459301e 100644 --- a/src/shared/cross-platform-path.test.ts +++ b/src/shared/cross-platform-path.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { + areLocalWindowsWslPathAliases, isCaseInsensitiveRuntimeRoot, isPathInsideOrEqual, isRuntimePathAbsolute, @@ -8,6 +9,32 @@ import { resolveRuntimePath } from './cross-platform-path' +describe('local Windows WSL aliases', () => { + it('matches UNC aliases and mounted drives without folding Linux path case', () => { + expect( + areLocalWindowsWslPathAliases( + '//wsl.localhost/Ubuntu/home/Alice/file.ts', + '\\\\wsl$\\ubuntu\\home\\Alice\\file.ts' + ) + ).toBe(true) + expect( + areLocalWindowsWslPathAliases( + '//wsl.localhost/Ubuntu/home/Alice/file.ts', + '\\\\wsl.localhost\\Ubuntu\\home\\alice\\file.ts' + ) + ).toBe(false) + expect( + areLocalWindowsWslPathAliases( + '//wsl.localhost/Ubuntu/mnt/c/repo/file.ts', + 'C:\\repo\\file.ts' + ) + ).toBe(true) + expect( + areLocalWindowsWslPathAliases('//server/share/file.ts', '\\\\server\\share\\file.ts') + ).toBe(false) + }) +}) + describe('isCaseInsensitiveRuntimeRoot', () => { it('folds Windows drive and plain UNC roots', () => { expect(isCaseInsensitiveRuntimeRoot('C:\\repos\\app')).toBe(true) diff --git a/src/shared/cross-platform-path.ts b/src/shared/cross-platform-path.ts index 3ef49e310e0..becf29236bb 100644 --- a/src/shared/cross-platform-path.ts +++ b/src/shared/cross-platform-path.ts @@ -1,4 +1,4 @@ -import { isWslUncPath } from './wsl-paths' +import { isWslUncPath, parseWslUncPath, toWindowsWslPath } from './wsl-paths' const SLASH_CHAR_CODE = '/'.charCodeAt(0) @@ -56,6 +56,21 @@ export function normalizeRuntimePathForComparison(rawValue: string): string { return isWindowsPath ? normalized.toLowerCase() : normalized } +export function areLocalWindowsWslPathAliases(left: string, right: string): boolean { + const leftWslPath = parseWslUncPath(left) + const rightWslPath = parseWslUncPath(right) + if (!leftWslPath && !rightWslPath) { + return false + } + const normalize = (value: string): string => { + const wslPath = parseWslUncPath(value) + return normalizeRuntimePathForComparison( + wslPath ? toWindowsWslPath(wslPath.linuxPath, wslPath.distro) : value + ) + } + return normalize(left) === normalize(right) +} + export function isRuntimePathAbsolute( value: string, pathFlavor: 'posix' | 'windows' = isWindowsPathFlavor(value) ? 'windows' : 'posix'