mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
fix(editor): guard local WSL path aliases
This commit is contained in:
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user