diff --git a/src/renderer/src/components/editor/editor-autosave-holds.test.ts b/src/renderer/src/components/editor/editor-autosave-holds.test.ts index 1e768a11a2e..fefe4fd672e 100644 --- a/src/renderer/src/components/editor/editor-autosave-holds.test.ts +++ b/src/renderer/src/components/editor/editor-autosave-holds.test.ts @@ -56,4 +56,26 @@ describe('discard hold keys on a Windows client', () => { expect(key).not.toBe('a') expect(key).toBe(editorFileHoldKey(state, tab('b', 'c:/repo/a.ts', local))) }) + + it('holds a `\\\\wsl.localhost` tab while its file is discarded via `\\\\wsl$`', () => { + const local = { worktreeId: 'wt-1' } + const tabKey = editorFileHoldKey( + state, + tab('b', '\\\\wsl.localhost\\ubuntu\\repo\\a.ts', local) + ) + const discarded = pathMutationHoldKeys(state, { + worktreeId: 'wt-1', + worktreePath: '\\\\wsl$\\Ubuntu\\repo', + relativePath: 'a.ts' + }) + expect(tabKey).not.toBe('b') + expect(discarded.has(tabKey)).toBe(true) + // The Linux path keeps its case, and a real share named `wsl` is another machine. + expect( + discarded.has(editorFileHoldKey(state, tab('c', '\\\\wsl$\\Ubuntu\\repo\\A.ts', local))) + ).toBe(false) + expect( + discarded.has(editorFileHoldKey(state, tab('d', '\\\\wsl\\Ubuntu\\repo\\a.ts', local))) + ).toBe(false) + }) }) diff --git a/src/renderer/src/components/editor/editor-autosave-holds.ts b/src/renderer/src/components/editor/editor-autosave-holds.ts index fce5cd800db..e06760c6fff 100644 --- a/src/renderer/src/components/editor/editor-autosave-holds.ts +++ b/src/renderer/src/components/editor/editor-autosave-holds.ts @@ -4,15 +4,26 @@ import { joinPath } from '@/lib/path' import { getOpenFilesForExternalFileChange, type EditorPathMutationTarget } from './editor-autosave' import { resolveEditorModelOwner, resolvedEditorModelOwnerKey } from './editor-model-owner' import type { EditorModelOwner } from './editor-model-owner' -import { editorBufferOwnerPathKey } from '@/store/slices/editor/buffers/editor-buffer-identity' +import { + editorBufferOwnerPathKey, + isLocalWindowsEditorOwner +} from '@/store/slices/editor/buffers/editor-buffer-identity' +import { parseWslUncPath } from '../../../../shared/wsl-paths' // Why not the model URI: it normalizes paths by the client's rules, so on a Windows client // `/repo/a\b.ts` and `/repo/a/b.ts` on a POSIX host would collide. function physicalFileKey(owner: EditorModelOwner, filePath: string): string | null { const ownerKey = resolvedEditorModelOwnerKey(owner) - return ownerKey === null - ? null - : JSON.stringify([ownerKey, editorBufferOwnerPathKey(filePath, owner)]) + if (ownerKey === null) { + return null + } + // Why: a discard via `\\wsl$` must hold a tab opened via `\\wsl.localhost`. A tuple can't + // equal a real path string, so a share literally named `wsl` stays apart. + const wsl = isLocalWindowsEditorOwner(owner) ? parseWslUncPath(filePath) : null + const pathKey = wsl + ? ['wsl', wsl.distro.toLowerCase(), wsl.linuxPath] + : editorBufferOwnerPathKey(filePath, owner) + return JSON.stringify([ownerKey, pathKey]) } /** diff --git a/src/renderer/src/store/slices/editor/buffers/editor-buffer-identity.test.ts b/src/renderer/src/store/slices/editor/buffers/editor-buffer-identity.test.ts index de8d9dece64..cb9853331d6 100644 --- a/src/renderer/src/store/slices/editor/buffers/editor-buffer-identity.test.ts +++ b/src/renderer/src/store/slices/editor/buffers/editor-buffer-identity.test.ts @@ -9,9 +9,7 @@ import { detachedEditorBufferId, editorBufferIdentityOf, editorBufferPathKey, - isEditorBufferIdentityRetired, - type EditorBufferHostFacts, - type EditorBufferHostPathFlavor + isEditorBufferIdentityRetired } from './editor-buffer-identity' const LOCAL: WorktreeOperationRoute = { executionHostId: 'local', runtimeEnvironmentId: null } @@ -45,18 +43,12 @@ function file(overrides: Partial = {}, route?: WorktreeOperationRoute) } } -const HOST_A: EditorBufferHostFacts = { capturedSshEndpoint: 'host-a:22' } - -function keyOf(tab: OpenFile, facts: EditorBufferHostFacts = HOST_A): string | undefined { - return editorBufferIdentityOf(tab, {}, facts)?.key +function keyOf(tab: OpenFile): string | undefined { + return editorBufferIdentityOf(tab, {})?.key } -function pathKey( - filePath: string, - flavor: EditorBufferHostPathFlavor = 'unknown', - foldWsl = false -) { - return JSON.stringify(editorBufferPathKey(filePath, flavor, foldWsl)) +function pathKey(filePath: string, windows = false) { + return editorBufferPathKey(filePath, windows) } function runtimeTab(id: string, pairingRevision?: number): OpenFile { @@ -96,50 +88,27 @@ describe('editorBufferPathKey', () => { expect(pathKey(left)).toBe(pathKey(right)) }) - it('folds the UNC server name of a forward-slash path on a Windows host only', () => { - expect(pathKey('//SERVER/share/a.ts', 'windows')).toBe( - pathKey('//server/share/a.ts', 'windows') - ) - expect(pathKey('\\\\server\\share\\a.ts', 'windows')).toBe( - pathKey('//SERVER/share/a.ts', 'windows') - ) + it('folds the UNC server name of a forward-slash path on a Windows client only', () => { + expect(pathKey('//SERVER/share/a.ts', true)).toBe(pathKey('//server/share/a.ts', true)) + expect(pathKey('\\\\server\\share\\a.ts', true)).toBe(pathKey('//SERVER/share/a.ts', true)) }) it.each([ ['C:/Repo/A.ts', 'C:/repo/a.ts'], ['\\\\server\\share\\A.ts', '\\\\server\\share\\a.ts'], ['\\\\server\\Share\\a.ts', '\\\\server\\share\\a.ts'], - ['//wsl$/Ubuntu/home/me/A.ts', '//wsl$/Ubuntu/home/me/a.ts'], + ['\\\\wsl$\\Ubuntu\\home\\me\\a.ts', '\\\\wsl.localhost\\Ubuntu\\home\\me\\a.ts'], ['/repo/A.ts', '/repo/a.ts'], ['/repo/a\\b.ts', '/repo/a/b.ts'] - ])('never folds path case or POSIX backslashes: %s is not %s', (left, right) => { - expect(pathKey(left, 'windows', true)).not.toBe(pathKey(right, 'windows', true)) + ])('never folds path case, WSL aliases or POSIX backslashes: %s is not %s', (left, right) => { + expect(pathKey(left, true)).not.toBe(pathKey(right, true)) }) - it('keeps an ambiguous `//` path exact unless the host is known Windows', () => { + it('keeps an ambiguous `//` path exact unless the client is Windows', () => { // Valid POSIX names on Linux or an SSH host: a backslash is part of the file name. expect(pathKey('//repo/a\\b.ts')).not.toBe(pathKey('//repo/a/b.ts')) expect(pathKey('//SERVER/share/a.ts')).not.toBe(pathKey('//server/share/a.ts')) - expect(pathKey('//repo/a\\b.ts', 'windows')).toBe(pathKey('//repo/a/b.ts', 'windows')) - }) - - it.each([ - ['\\\\wsl$\\Ubuntu\\home\\me\\a.ts', '\\\\wsl.localhost\\ubuntu\\home\\me\\a.ts'], - ['//wsl$/Ubuntu/home/me/a.ts', '\\\\wsl.localhost\\Ubuntu\\home\\me\\a.ts'] - ])('folds the WSL aliases %s and %s only when asked', (left, right) => { - expect(pathKey(left, 'windows', true)).toBe(pathKey(right, 'windows', true)) - expect(pathKey(left, 'windows')).not.toBe(pathKey(right, 'windows')) - }) - - it('never lets a folded WSL key equal a real share named wsl', () => { - const folded = pathKey('\\\\wsl$\\Ubuntu\\home\\a.ts', 'windows', true) - for (const real of [ - '\\\\wsl\\ubuntu\\home\\a.ts', - '//wsl/ubuntu/home/a.ts', - '\\\\wsl\\Ubuntu\\home\\a.ts' - ]) { - expect(pathKey(real, 'windows', true)).not.toBe(folded) - } + expect(pathKey('//repo/a\\b.ts', true)).toBe(pathKey('//repo/a/b.ts', true)) }) }) @@ -148,25 +117,19 @@ describe('editorBufferIdentityOf', () => { expect(keyOf(file({}, LOCAL))).toBe(keyOf(file({ id: 'b', worktreeId: 'wt-2' }, LOCAL))) }) - it('folds WSL aliases for a local Windows client only', () => { + it('keeps WSL aliases apart on a local Windows client', () => { + vi.stubGlobal('navigator', { userAgent: 'Mozilla/5.0 (Windows NT 10.0)' }) const wslDollar = file({ filePath: '\\\\wsl$\\Ubuntu\\home\\a.ts' }, LOCAL) const wslLocalhost = file({ id: 'b', filePath: '\\\\wsl.localhost\\Ubuntu\\home\\a.ts' }, LOCAL) - vi.stubGlobal('navigator', { userAgent: 'Mozilla/5.0 (Macintosh)' }) expect(keyOf(wslDollar)).not.toBe(keyOf(wslLocalhost)) - - vi.stubGlobal('navigator', { userAgent: 'Mozilla/5.0 (Windows NT 10.0)' }) - expect(keyOf(wslDollar)).toBe(keyOf(wslLocalhost)) - const ssh: WorktreeOperationRoute = { - executionHostId: 'ssh:target', - runtimeEnvironmentId: null - } - expect(keyOf(file({ filePath: wslDollar.filePath }, ssh))).not.toBe( - keyOf(file({ filePath: wslLocalhost.filePath }, ssh)) - ) }) - it('keys an SSH target by its runtime environment as well as its target id', () => { + it('keys an SSH file by its connection target and runtime environment', () => { const direct = keyOf(file({ externalSshTargetId: 'target', runtimeEnvironmentId: null })) + const directElsewhere = keyOf( + file({ id: 'b', worktreeId: 'wt-2', externalSshTargetId: 'target' }) + ) + const otherTarget = keyOf(file({ externalSshTargetId: 'target-2' })) const nested = keyOf(file({ externalSshTargetId: 'target', runtimeEnvironmentId: 'env' })) const nestedElsewhere = keyOf( file({ @@ -181,41 +144,19 @@ describe('editorBufferIdentityOf', () => { ) expect(direct).toBeDefined() - expect(new Set([direct, nested, otherRuntime]).size).toBe(3) + expect(direct).toBe(directElsewhere) + expect(new Set([direct, otherTarget, nested, otherRuntime]).size).toBe(4) expect(nested).toBe(nestedElsewhere) }) - it('keys SSH tabs by the endpoint each captured, never the target id alone', () => { - const tab = (id: string) => - file({ id, externalSshTargetId: 'target', runtimeEnvironmentId: null }) - // The target id now points at B; the old tab still carries the endpoint it opened on. - const openedOnA = keyOf(tab('a'), { capturedSshEndpoint: 'host-a:22' }) - const openedOnB = keyOf(tab('b'), { capturedSshEndpoint: 'host-b:22' }) - expect(openedOnA).toBeDefined() - expect(openedOnA).not.toBe(openedOnB) - // A reconnect to the same machine captures the same endpoint. - expect(keyOf(tab('c'), { capturedSshEndpoint: 'host-a:22' })).toBe(openedOnA) - }) - - it('never shares an SSH tab with no captured endpoint', () => { - const tab = file({ externalSshTargetId: 'target', runtimeEnvironmentId: null }) - expect(keyOf(tab, {})).toBeUndefined() - expect(keyOf(tab, { capturedSshEndpoint: '' })).toBeUndefined() - }) - - it('folds a forward-slash UNC server only on a remote host verified as Windows', () => { + it('keeps a remote forward-slash UNC path apart from its backslash form', () => { const route: WorktreeOperationRoute = { executionHostId: 'runtime:env', runtimeEnvironmentId: 'env' } - const upper = file({ filePath: '//SERVER/share/a.ts' }, route) + const slash = file({ filePath: '//SERVER/share/a.ts' }, route) const backslash = file({ id: 'b', filePath: '\\\\SERVER\\share\\a.ts' }, route) - expect(keyOf(upper, { remotePathFlavor: 'windows' })).toBe( - keyOf(backslash, { remotePathFlavor: 'windows' }) - ) - for (const remotePathFlavor of ['posix', 'unknown'] as const) { - expect(keyOf(upper, { remotePathFlavor })).not.toBe(keyOf(backslash, { remotePathFlavor })) - } + expect(keyOf(slash)).not.toBe(keyOf(backslash)) }) it('keeps a runtime host and the local machine apart for one path', () => { @@ -223,8 +164,8 @@ describe('editorBufferIdentityOf', () => { }) it('never shares a tab whose owner is unresolved, or a non-edit tab', () => { - expect(editorBufferIdentityOf(file({ runtimeEnvironmentId: null }), {}, HOST_A)).toBeNull() - expect(editorBufferIdentityOf(file({ mode: 'diff' }, LOCAL), {}, HOST_A)).toBeNull() + expect(editorBufferIdentityOf(file({ runtimeEnvironmentId: null }), {})).toBeNull() + expect(editorBufferIdentityOf(file({ mode: 'diff' }, LOCAL), {})).toBeNull() }) it('joins a re-pair proven to be the same machine', () => { @@ -250,12 +191,12 @@ describe('editorBufferIdentityOf', () => { it('quarantines a retired host: its replacement with the same path stays apart', () => { replaceRuntimeEnvironmentRevisions([{ id: 'env', createdAt: 0, pairingRevision: 1 }]) const old = runtimeTab('old') - const local = editorBufferIdentityOf(file({}, LOCAL), {}, HOST_A) + const local = editorBufferIdentityOf(file({}, LOCAL), {}) const quarantine = new Map() // Retirement fires before the new revision is published, while the old key still stands. const stop = onRuntimeEnvironmentsRetired((retired) => { for (const [viewId, identity] of [ - ['old', editorBufferIdentityOf(old, {}, HOST_A)], + ['old', editorBufferIdentityOf(old, {})], ['local', local] ] as const) { if (identity && isEditorBufferIdentityRetired(identity, retired)) { diff --git a/src/renderer/src/store/slices/editor/buffers/editor-buffer-identity.ts b/src/renderer/src/store/slices/editor/buffers/editor-buffer-identity.ts index e0df41c4472..4197ea06d79 100644 --- a/src/renderer/src/store/slices/editor/buffers/editor-buffer-identity.ts +++ b/src/renderer/src/store/slices/editor/buffers/editor-buffer-identity.ts @@ -4,51 +4,24 @@ import { resolveEditorModelOwner } from '@/components/editor/editor-model-owner' import { isLocalWindowsDesktopClient } from '@/lib/desktop-window-chrome' import { resolveContinuedRuntimeEnvironmentRevision } from '@/runtime/runtime-environment-revision' import { parseExecutionHostId } from '../../../../../../shared/execution-host' -import { parseWslUncPath } from '../../../../../../shared/wsl-paths' import { normalizeRuntimePathSeparators } from '../../../../../../shared/cross-platform-path' /** The physical file an edit tab shows. Renderer-local; the workspace is never part of it. */ export type EditorBufferIdentity = { - /** Environment, host, pairing root, SSH endpoint and path key: equal keys are one file. */ + /** Environment, host, pairing root and path key: equal keys are one file. */ key: string environmentId: string | null } -/** `windows` or `posix` only when verified for the host; `unknown` never folds an ambiguous path. */ -export type EditorBufferHostPathFlavor = 'windows' | 'posix' | 'unknown' - -/** Facts about the tab's execution host, supplied per tab by the caller. */ -export type EditorBufferHostFacts = { - /** - * The SSH endpoint this tab captured when it opened. Never the target's current endpoint: a - * target id can be edited in place to name another machine, and a live lookup would then match - * an old tab to the new machine. Undefined for an SSH tab means it never shares. - */ - capturedSshEndpoint?: string - /** Verified path flavor of a remote host. The local client's flavor is detected here. */ - remotePathFlavor?: EditorBufferHostPathFlavor -} - -/** A WSL key is a tuple, so no real path string can ever equal it. */ -export type EditorBufferPathKey = string | readonly ['wsl', string, string] - /** * Case is kept, because case sensitivity can differ per directory and a folded key would merge * distinct files. Why not `normalizeRuntimePathForComparison`: it lowercases whole Windows paths. + * `windows` (the local Windows client only) also reads a `//` path as UNC. */ -export function editorBufferPathKey( - filePath: string, - flavor: EditorBufferHostPathFlavor, - foldWslAliases = false -): EditorBufferPathKey { - const wsl = foldWslAliases ? parseWslUncPath(filePath) : null - if (wsl) { - // Distro names are case-insensitive; the Linux path under them is not. - return ['wsl', wsl.distro.toLowerCase(), wsl.linuxPath] - } +export function editorBufferPathKey(filePath: string, windows: boolean): string { // Absolute POSIX paths start with `/`, so a drive or `\\` prefix proves Windows. `//` does not. const drive = /^[A-Za-z]:[\\/]/.test(filePath) - const unc = filePath.startsWith('\\\\') || (flavor === 'windows' && filePath.startsWith('//')) + const unc = filePath.startsWith('\\\\') || (windows && filePath.startsWith('//')) if (!drive && !unc) { return filePath } @@ -63,24 +36,25 @@ export function editorBufferPathKey( : `${slashed.slice(0, serverEnd).toLowerCase()}${slashed.slice(serverEnd)}` } -/** - * The path key of a file on a resolved owner, using only what the renderer verifies itself: a - * remote host's flavor is unknown here, so only paths that prove Windows (drive, `\\`) fold. - */ -export function editorBufferOwnerPathKey( - filePath: string, - owner: { environmentId: string | null; hostId: string | null } -): EditorBufferPathKey { - const localWindows = +type EditorPathOwner = { environmentId: string | null; hostId: string | null } + +/** The owner is this machine and this client is the local Windows desktop app. */ +export function isLocalWindowsEditorOwner(owner: EditorPathOwner): boolean { + return ( parseExecutionHostId(owner.hostId)?.kind === 'local' && !owner.environmentId && isLocalWindowsDesktopClient() - return editorBufferPathKey(filePath, localWindows ? 'windows' : 'unknown', localWindows) + ) +} + +/** The path key of a file on a resolved owner. Remote paths fold only when they prove Windows. */ +export function editorBufferOwnerPathKey(filePath: string, owner: EditorPathOwner): string { + return editorBufferPathKey(filePath, isLocalWindowsEditorOwner(owner)) } /** - * Null for tabs that never share: non-edit views, unresolved owners, and SSH tabs with no captured - * endpoint. + * Null for non-edit views and unresolved owners. SSH files are keyed by connection target; a + * retarget is caught by the guarded save. * * The key is not fixed for a live tab: an unpinned runtime tab resolves its pairing live (null * before the revision registry loads, the newest proven revision after). A caller must treat a key @@ -88,21 +62,17 @@ export function editorBufferOwnerPathKey( */ export function editorBufferIdentityOf( file: OpenFile, - state: WorktreeOperationRouteState, - hostFacts: EditorBufferHostFacts + state: WorktreeOperationRouteState ): EditorBufferIdentity | null { if (file.mode !== 'edit') { return null } - const { environmentId, hostId } = resolveEditorModelOwner(file, state) - const host = parseExecutionHostId(hostId) + const owner = resolveEditorModelOwner(file, state) + const host = parseExecutionHostId(owner.hostId) if (!host) { return null } - const sshEndpoint = host.kind === 'ssh' ? (hostFacts.capturedSshEndpoint ?? null) : null - if (host.kind === 'ssh' && !sshEndpoint) { - return null - } + const { environmentId } = owner // A same-host re-pair resolves to the revision it continues; an unproven one stays apart. const revisionRoot = environmentId ? (resolveContinuedRuntimeEnvironmentRevision( @@ -110,21 +80,12 @@ export function editorBufferIdentityOf( file.operationProvenance?.generation.runtimePairingRevision ) ?? null) : null - // `wsl$` and `wsl.localhost` name one distro only on the local Windows client; a paired web - // client (including `orca serve` on Windows) keeps them apart, as today. - const localWindows = host.kind === 'local' && !environmentId && isLocalWindowsDesktopClient() - const flavor = localWindows - ? 'windows' - : host.kind === 'local' && !environmentId - ? 'unknown' - : (hostFacts.remotePathFlavor ?? 'unknown') return { key: JSON.stringify([ environmentId, host.id, revisionRoot, - sshEndpoint, - editorBufferPathKey(file.filePath, flavor, localWindows) + editorBufferOwnerPathKey(file.filePath, owner) ]), environmentId }