fix(editor): guard local WSL path aliases

This commit is contained in:
OrcaWin
2026-08-11 14:21:15 -07:00
committed by OrcaWin
parent 885215126a
commit 1f8fd5c44e
12 changed files with 591 additions and 26 deletions
@@ -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([])
})
})
@@ -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 (
@@ -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/<drive> 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(
@@ -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<ILink[]>((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'
@@ -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')
@@ -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<typeof EditorAutosaveModule>()
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()
})
})
@@ -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<WatchedTarget, 'allowLocalWindowsWslAliases'>
): Pick<ExternalWatchNotification, 'allowLocalWindowsWslAliases'> {
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<OpenFile, 'runtimeEnvironmentId'>): 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<WatchedTarget, 'worktreeId' | 'worktreePath'> & {
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
})
})
}
@@ -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
@@ -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 = {
+24 -3
View File
@@ -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<OpenFile, 'filePath' | 'worktreeId' | 'runtimeEnvironmentId' | 'externalSshTargetId'>,
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<AppState, [], [], EditorSlice> = (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
+27
View File
@@ -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)
+16 -1
View File
@@ -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'