From 8b272fa60171ddec1b48c0593bb6bdc82349fb97 Mon Sep 17 00:00:00 2001 From: Neil Date: Tue, 20 Oct 2026 16:27:54 -0700 Subject: [PATCH 1/3] feat(editor): unused editor-buffer identity key (phase 1b-1) Adds `editorBufferIdentityOf`, not consumed yet: [environment, host, pairing root, path key] with exact case, normalized Windows separators and drive letter, WSL alias folding only for a local Windows client, SSH keyed with its runtime environment, and a detached record id for retired hosts. Splits `resolveEditorModelOwner` out of the owner-key function; the owner key is unchanged. Co-Authored-By: Claude --- .../components/editor/editor-model-owner.ts | 12 +- .../buffers/editor-buffer-identity.test.ts | 215 ++++++++++++++++++ .../editor/buffers/editor-buffer-identity.ts | 84 +++++++ 3 files changed, 310 insertions(+), 1 deletion(-) create mode 100644 src/renderer/src/store/slices/editor/buffers/editor-buffer-identity.test.ts create mode 100644 src/renderer/src/store/slices/editor/buffers/editor-buffer-identity.ts diff --git a/src/renderer/src/components/editor/editor-model-owner.ts b/src/renderer/src/components/editor/editor-model-owner.ts index 9115c03b5219..21a90207803c 100644 --- a/src/renderer/src/components/editor/editor-model-owner.ts +++ b/src/renderer/src/components/editor/editor-model-owner.ts @@ -3,7 +3,12 @@ import type { WorktreeOperationRouteState } from '@/lib/worktree-operation-route import { resolveWorktreeOperationRoute } from '@/lib/worktree-operation-route' import { toRuntimeExecutionHostId, toSshExecutionHostId } from '../../../../shared/execution-host' -export function getEditorModelOwnerKey(file: OpenFile, state: WorktreeOperationRouteState): string { +export type EditorModelOwner = { environmentId: string | null; hostId: string | null } + +export function resolveEditorModelOwner( + file: OpenFile, + state: WorktreeOperationRouteState +): EditorModelOwner { const captured = file.operationProvenance?.generation.route const route = captured ?? @@ -28,6 +33,11 @@ export function getEditorModelOwnerKey(file: OpenFile, state: WorktreeOperationR : environmentId ? toRuntimeExecutionHostId(environmentId) : null + return { environmentId, hostId } +} + +export function getEditorModelOwnerKey(file: OpenFile, state: WorktreeOperationRouteState): string { + const { environmentId, hostId } = resolveEditorModelOwner(file, state) if (hostId === 'local' && !environmentId) { return '' } 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 new file mode 100644 index 000000000000..742a828ceaca --- /dev/null +++ b/src/renderer/src/store/slices/editor/buffers/editor-buffer-identity.test.ts @@ -0,0 +1,215 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { OpenFile } from '../types/open-file' +import type { WorktreeOperationRoute } from '@/lib/worktree-operation-route' +import { + onRuntimeEnvironmentsRetired, + replaceRuntimeEnvironmentRevisions +} from '@/runtime/runtime-environment-revision' +import { + detachedEditorBufferId, + editorBufferIdentityOf, + editorBufferPathKey, + isEditorBufferIdentityRetired +} from './editor-buffer-identity' + +const LOCAL: WorktreeOperationRoute = { executionHostId: 'local', runtimeEnvironmentId: null } + +function file(overrides: Partial = {}, route?: WorktreeOperationRoute): OpenFile { + return { + id: 'a', + filePath: '/repo/a.ts', + relativePath: 'a.ts', + worktreeId: 'wt', + language: 'typescript', + isDirty: false, + mode: 'edit', + ...(route + ? { + runtimeEnvironmentId: route.runtimeEnvironmentId, + operationProvenance: { + ownershipProjection: 'explicit' as const, + generation: { + route, + runtimeConnectionGeneration: null, + runtimePairingRevision: undefined, + runtimeSshGeneration: null, + nestedSshGeneration: null, + directSshGeneration: null + } + } + } + : {}), + ...overrides + } +} + +function keyOf(tab: OpenFile): string | undefined { + return editorBufferIdentityOf(tab, {})?.key +} + +function runtimeTab(id: string, pairingRevision?: number): OpenFile { + const tab = file({ id, runtimeEnvironmentId: 'env' }) + return pairingRevision === undefined + ? tab + : file( + { + id, + operationProvenance: { + ownershipProjection: 'explicit', + generation: { + route: { executionHostId: 'runtime:env', runtimeEnvironmentId: 'env' }, + runtimeConnectionGeneration: null, + runtimePairingRevision: pairingRevision, + runtimeSshGeneration: null, + nestedSshGeneration: null, + directSshGeneration: null + } + } + }, + { executionHostId: 'runtime:env', runtimeEnvironmentId: 'env' } + ) +} + +afterEach(() => { + vi.unstubAllGlobals() + replaceRuntimeEnvironmentRevisions([]) +}) + +describe('editorBufferPathKey', () => { + it.each([ + ['C:\\Repo\\A.ts', 'c:/Repo/A.ts'], + ['C:/Repo//A.ts', 'c:\\Repo\\A.ts'], + ['\\\\server\\share\\A.ts', '//server/share/A.ts'] + ])('treats %s and %s as one file', (left, right) => { + expect(editorBufferPathKey(left, false)).toBe(editorBufferPathKey(right, false)) + }) + + it.each([ + ['C:/Repo/A.ts', 'C:/repo/a.ts'], + ['\\\\server\\share\\A.ts', '\\\\server\\share\\a.ts'], + ['//wsl$/Ubuntu/home/me/A.ts', '//wsl$/Ubuntu/home/me/a.ts'], + ['/repo/A.ts', '/repo/a.ts'], + ['/repo/a\\b.ts', '/repo/a/b.ts'] + ])('never folds case or POSIX backslashes: %s is not %s', (left, right) => { + expect(editorBufferPathKey(left, true)).not.toBe(editorBufferPathKey(right, true)) + }) + + 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(editorBufferPathKey(left, true)).toBe(editorBufferPathKey(right, true)) + expect(editorBufferPathKey(left, false)).not.toBe(editorBufferPathKey(right, false)) + }) +}) + +describe('editorBufferIdentityOf', () => { + it('gives one local file in two workspaces one key', () => { + expect(keyOf(file({}, LOCAL))).toBe(keyOf(file({ id: 'b', worktreeId: 'wt-2' }, LOCAL))) + }) + + it('folds WSL aliases for a local Windows client only', () => { + 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', () => { + const direct = keyOf(file({ externalSshTargetId: 'target', runtimeEnvironmentId: null })) + const nested = keyOf(file({ externalSshTargetId: 'target', runtimeEnvironmentId: 'env' })) + const nestedElsewhere = keyOf( + file({ + id: 'b', + worktreeId: 'wt-2', + externalSshTargetId: 'target', + runtimeEnvironmentId: 'env' + }) + ) + const otherRuntime = keyOf( + file({ externalSshTargetId: 'target', runtimeEnvironmentId: 'env-2' }) + ) + + expect(direct).toBeDefined() + expect(new Set([direct, nested, otherRuntime]).size).toBe(3) + expect(nested).toBe(nestedElsewhere) + }) + + it('keeps a runtime host and the local machine apart for one path', () => { + expect(keyOf(file({}, LOCAL))).not.toBe(keyOf(runtimeTab('b'))) + }) + + it('never shares a tab whose owner is unresolved, or a non-edit tab', () => { + expect(editorBufferIdentityOf(file({ runtimeEnvironmentId: null }), {})).toBeNull() + expect(editorBufferIdentityOf(file({ mode: 'diff' }, LOCAL), {})).toBeNull() + }) + + it('joins a re-pair proven to be the same machine', () => { + replaceRuntimeEnvironmentRevisions([{ id: 'env', createdAt: 0, pairingRevision: 1 }]) + replaceRuntimeEnvironmentRevisions([{ id: 'env', createdAt: 0, pairingRevision: 2 }], { + retired: [], + sameHost: [{ id: 'env', fromRevision: 1, toRevision: 2 }] + }) + expect(keyOf(runtimeTab('old', 1))).toBe(keyOf(runtimeTab('new', 2))) + expect(keyOf(runtimeTab('old', 1))).toBe(keyOf(runtimeTab('unpinned'))) + }) + + it('keeps a pairing apart from an unproven re-pair', () => { + replaceRuntimeEnvironmentRevisions([{ id: 'env', createdAt: 0, pairingRevision: 1 }]) + replaceRuntimeEnvironmentRevisions([{ id: 'env', createdAt: 0, pairingRevision: 2 }], { + retired: [], + sameHost: [], + pending: [{ id: 'env', fromRevision: 1 }] + }) + expect(keyOf(runtimeTab('old', 1))).not.toBe(keyOf(runtimeTab('new', 2))) + }) + + 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), {}) + 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, {})], + ['local', local] + ] as const) { + if (identity && isEditorBufferIdentityRetired(identity, retired)) { + quarantine.set(viewId, detachedEditorBufferId(viewId)) + } + } + }) + replaceRuntimeEnvironmentRevisions([{ id: 'env', createdAt: 0, pairingRevision: 2 }], { + retired: ['env'], + sameHost: [] + }) + stop() + + // An unpinned tab re-resolves to the replacement: its key alone can't tell the machines apart. + const replacementKey = keyOf(runtimeTab('new')) + expect(keyOf(old)).toBe(replacementKey) + expect([...quarantine]).toEqual([['old', 'detached:old']]) + expect(quarantine.get('old')).not.toBe(replacementKey) + expect(detachedEditorBufferId('old')).not.toBe(detachedEditorBufferId('new')) + }) + + it('keeps a pinned tab of a retired pairing apart from the replacement', () => { + replaceRuntimeEnvironmentRevisions([{ id: 'env', createdAt: 0, pairingRevision: 1 }]) + replaceRuntimeEnvironmentRevisions([{ id: 'env', createdAt: 0, pairingRevision: 2 }], { + retired: ['env'], + sameHost: [] + }) + expect(keyOf(runtimeTab('old', 1))).not.toBe(keyOf(runtimeTab('new'))) + }) +}) 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 new file mode 100644 index 000000000000..ce4993a3687d --- /dev/null +++ b/src/renderer/src/store/slices/editor/buffers/editor-buffer-identity.ts @@ -0,0 +1,84 @@ +import type { OpenFile } from '../types/open-file' +import type { WorktreeOperationRouteState } from '@/lib/worktree-operation-route' +import { resolveEditorModelOwner } from '@/components/editor/editor-model-owner' +import { isLocalWindowsDesktopClient } from '@/lib/desktop-window-chrome' +import { resolveContinuedRuntimeEnvironmentRevision } from '@/runtime/runtime-environment-revision' +import { parseWslUncPath } from '../../../../../../shared/wsl-paths' +import { + isWindowsAbsolutePathLike, + 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 and path key: equal keys are one file. */ + key: string + environmentId: string | null +} + +/** + * Separators and the drive letter are normalized; 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. + */ +export function editorBufferPathKey(filePath: string, foldWslAliases: boolean): string { + const wsl = foldWslAliases ? parseWslUncPath(filePath) : null + if (wsl) { + // WSL distro names are case-insensitive; the Linux path under them is not. + return `//wsl/${wsl.distro.toLowerCase()}${wsl.linuxPath}` + } + if (!isWindowsAbsolutePathLike(filePath)) { + // A POSIX backslash is part of the file name. + return filePath + } + const slashed = normalizeRuntimePathSeparators(filePath) + return /^[A-Za-z]:/.test(slashed) ? `${slashed[0].toLowerCase()}${slashed.slice(1)}` : slashed +} + +/** Null for tabs that never share: non-edit views and owners not resolved yet. */ +export function editorBufferIdentityOf( + file: OpenFile, + state: WorktreeOperationRouteState +): EditorBufferIdentity | null { + if (file.mode !== 'edit') { + return null + } + const { environmentId, hostId } = resolveEditorModelOwner(file, state) + if (!hostId) { + return null + } + // A same-host re-pair resolves to the revision it continues; an unproven one stays apart. + const revisionRoot = environmentId + ? (resolveContinuedRuntimeEnvironmentRevision( + environmentId, + file.operationProvenance?.generation.runtimePairingRevision + ) ?? null) + : null + // `wsl$` and `wsl.localhost` name one distro only on Windows; elsewhere they are plain folders. + const foldWslAliases = hostId === 'local' && !environmentId && isLocalWindowsDesktopClient() + return { + key: JSON.stringify([ + environmentId, + hostId, + revisionRoot, + editorBufferPathKey(file.filePath, foldWslAliases) + ]), + environmentId + } +} + +/** True when the identity's environment now names another machine. */ +export function isEditorBufferIdentityRetired( + identity: EditorBufferIdentity, + retiredEnvironmentIds: readonly string[] +): boolean { + return identity.environmentId !== null && retiredEnvironmentIds.includes(identity.environmentId) +} + +/** + * A record id no identity key can equal. Records on a retired environment move here and are never + * merged into the replacement machine's records, even with an identical path and baseline. + */ +export function detachedEditorBufferId(viewId: string): string { + return `detached:${viewId}` +} From 20572dd850e55f17b0fe0951e9b1c8ee2360935e Mon Sep 17 00:00:00 2001 From: Neil Date: Tue, 20 Oct 2026 16:35:52 -0700 Subject: [PATCH 2/3] fix(editor): editor-buffer identity keys can't falsely merge files (phase 1b-1 review) - A folded WSL alias is a tagged tuple, so a real `\\wsl\` share can't collide with it. - `//` is ambiguous between POSIX and UNC, so it folds only for a known-Windows (local) host; drive and `\\` paths still fold separators and the drive letter on any host. - A proven UNC path folds its server name, matching the Monaco URI authority today. - SSH identity includes the target's endpoint incarnation: a target id edited to point at another machine gets a new key, a reconnect keeps it; an unknown endpoint never shares. - Documents the live pairing resolution and per-event retirement ids. Co-Authored-By: Claude --- .../buffers/editor-buffer-identity.test.ts | 110 +++++++++++++++--- .../editor/buffers/editor-buffer-identity.ts | 88 ++++++++++---- 2 files changed, 163 insertions(+), 35 deletions(-) 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 742a828ceaca..aca56dbfd13c 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 @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { OpenFile } from '../types/open-file' import type { WorktreeOperationRoute } from '@/lib/worktree-operation-route' import { @@ -43,8 +43,16 @@ function file(overrides: Partial = {}, route?: WorktreeOperationRoute) } } +const endpoints = new Map() +const sshEndpointOf = (environmentId: string | null, targetId: string): string | undefined => + endpoints.get(`${environmentId}|${targetId}`) ?? endpoints.get(`*|${targetId}`) + function keyOf(tab: OpenFile): string | undefined { - return editorBufferIdentityOf(tab, {})?.key + return editorBufferIdentityOf(tab, {}, sshEndpointOf)?.key +} + +function pathKey(filePath: string, flavor: 'win32' | 'unknown' = 'unknown', foldWsl = false) { + return JSON.stringify(editorBufferPathKey(filePath, flavor, foldWsl)) } function runtimeTab(id: string, pairingRevision?: number): OpenFile { @@ -70,6 +78,11 @@ function runtimeTab(id: string, pairingRevision?: number): OpenFile { ) } +beforeEach(() => { + endpoints.clear() + endpoints.set('*|target', 'host-a:22') +}) + afterEach(() => { vi.unstubAllGlobals() replaceRuntimeEnvironmentRevisions([]) @@ -79,27 +92,53 @@ describe('editorBufferPathKey', () => { it.each([ ['C:\\Repo\\A.ts', 'c:/Repo/A.ts'], ['C:/Repo//A.ts', 'c:\\Repo\\A.ts'], - ['\\\\server\\share\\A.ts', '//server/share/A.ts'] - ])('treats %s and %s as one file', (left, right) => { - expect(editorBufferPathKey(left, false)).toBe(editorBufferPathKey(right, false)) + ['\\\\server\\share\\A.ts', '\\\\SERVER\\share\\A.ts'] + ])('treats %s and %s as one file on any host', (left, right) => { + 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', 'win32')).toBe(pathKey('//server/share/a.ts', 'win32')) + expect(pathKey('\\\\server\\share\\a.ts', 'win32')).toBe( + pathKey('//SERVER/share/a.ts', 'win32') + ) }) 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'], ['/repo/A.ts', '/repo/a.ts'], ['/repo/a\\b.ts', '/repo/a/b.ts'] - ])('never folds case or POSIX backslashes: %s is not %s', (left, right) => { - expect(editorBufferPathKey(left, true)).not.toBe(editorBufferPathKey(right, true)) + ])('never folds path case or POSIX backslashes: %s is not %s', (left, right) => { + expect(pathKey(left, 'win32', true)).not.toBe(pathKey(right, 'win32', true)) + }) + + it('keeps an ambiguous `//` path exact unless the host is known 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', 'win32')).toBe(pathKey('//repo/a/b.ts', 'win32')) }) 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(editorBufferPathKey(left, true)).toBe(editorBufferPathKey(right, true)) - expect(editorBufferPathKey(left, false)).not.toBe(editorBufferPathKey(right, false)) + expect(pathKey(left, 'win32', true)).toBe(pathKey(right, 'win32', true)) + expect(pathKey(left, 'win32')).not.toBe(pathKey(right, 'win32')) + }) + + it('never lets a folded WSL key equal a real share named wsl', () => { + const folded = pathKey('\\\\wsl$\\Ubuntu\\home\\a.ts', 'win32', true) + for (const real of [ + '\\\\wsl\\ubuntu\\home\\a.ts', + '//wsl/ubuntu/home/a.ts', + '\\\\wsl\\Ubuntu\\home\\a.ts' + ]) { + expect(pathKey(real, 'win32', true)).not.toBe(folded) + } }) }) @@ -145,13 +184,50 @@ describe('editorBufferIdentityOf', () => { expect(nested).toBe(nestedElsewhere) }) + it('keeps a retargeted SSH id apart, and one machine together across a reconnect', () => { + const direct = file({ externalSshTargetId: 'target', runtimeEnvironmentId: null }) + const before = keyOf(direct) + // A reconnect moves the connection generation, not the endpoint. + const reconnected = file({ + id: 'b', + externalSshTargetId: 'target', + runtimeEnvironmentId: null, + operationProvenance: { + ownershipProjection: 'explicit', + generation: { + route: { executionHostId: 'ssh:target', runtimeEnvironmentId: null }, + runtimeConnectionGeneration: null, + runtimePairingRevision: undefined, + runtimeSshGeneration: null, + nestedSshGeneration: null, + directSshGeneration: 7 + } + } + }) + expect(keyOf(reconnected)).toBe(before) + + endpoints.set('*|target', 'host-b:22') + expect( + keyOf(file({ id: 'c', externalSshTargetId: 'target', runtimeEnvironmentId: null })) + ).not.toBe(before) + }) + + it('never shares an SSH tab whose endpoint is unknown', () => { + endpoints.clear() + expect( + keyOf(file({ externalSshTargetId: 'target', runtimeEnvironmentId: null })) + ).toBeUndefined() + }) + it('keeps a runtime host and the local machine apart for one path', () => { expect(keyOf(file({}, LOCAL))).not.toBe(keyOf(runtimeTab('b'))) }) it('never shares a tab whose owner is unresolved, or a non-edit tab', () => { - expect(editorBufferIdentityOf(file({ runtimeEnvironmentId: null }), {})).toBeNull() - expect(editorBufferIdentityOf(file({ mode: 'diff' }, LOCAL), {})).toBeNull() + expect( + editorBufferIdentityOf(file({ runtimeEnvironmentId: null }), {}, sshEndpointOf) + ).toBeNull() + expect(editorBufferIdentityOf(file({ mode: 'diff' }, LOCAL), {}, sshEndpointOf)).toBeNull() }) it('joins a re-pair proven to be the same machine', () => { @@ -177,12 +253,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), {}) + const local = editorBufferIdentityOf(file({}, LOCAL), {}, sshEndpointOf) 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, {})], + ['old', editorBufferIdentityOf(old, {}, sshEndpointOf)], ['local', local] ] as const) { if (identity && isEditorBufferIdentityRetired(identity, retired)) { @@ -204,6 +280,14 @@ describe('editorBufferIdentityOf', () => { expect(detachedEditorBufferId('old')).not.toBe(detachedEditorBufferId('new')) }) + it('resolves an unpinned tab live, so its key moves once revisions load', () => { + const unpinned = runtimeTab('a') + const beforeLoad = keyOf(unpinned) + replaceRuntimeEnvironmentRevisions([{ id: 'env', createdAt: 0, pairingRevision: 1 }]) + expect(keyOf(unpinned)).not.toBe(beforeLoad) + expect(keyOf(unpinned)).toBe(keyOf(runtimeTab('pinned', 1))) + }) + it('keeps a pinned tab of a retired pairing apart from the replacement', () => { replaceRuntimeEnvironmentRevisions([{ id: 'env', createdAt: 0, pairingRevision: 1 }]) replaceRuntimeEnvironmentRevisions([{ id: 'env', createdAt: 0, pairingRevision: 2 }], { 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 ce4993a3687d..fc441d2b11f2 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 @@ -3,48 +3,87 @@ import type { WorktreeOperationRouteState } from '@/lib/worktree-operation-route 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 { - isWindowsAbsolutePathLike, - normalizeRuntimePathSeparators -} from '../../../../../../shared/cross-platform-path' +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 and path key: equal keys are one file. */ + /** Environment, host, pairing root, SSH endpoint and path key: equal keys are one file. */ key: string environmentId: string | null } /** - * Separators and the drive letter are normalized; 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. + * The endpoint an SSH target id currently names. Target ids survive an in-place edit to another + * machine, while the connection generation also moves on a plain reconnect, so neither alone can + * key a file. Undefined when not known: that tab never shares. */ -export function editorBufferPathKey(filePath: string, foldWslAliases: boolean): string { +export type SshEndpointIncarnationOf = ( + environmentId: string | null, + targetId: string +) => string | undefined + +/** `win32` only when the host is known to be Windows; `unknown` never folds an ambiguous path. */ +export type EditorBufferHostPathFlavor = 'win32' | 'unknown' + +/** 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. + */ +export function editorBufferPathKey( + filePath: string, + flavor: EditorBufferHostPathFlavor, + foldWslAliases = false +): EditorBufferPathKey { const wsl = foldWslAliases ? parseWslUncPath(filePath) : null if (wsl) { - // WSL distro names are case-insensitive; the Linux path under them is not. - return `//wsl/${wsl.distro.toLowerCase()}${wsl.linuxPath}` + // Distro names are case-insensitive; the Linux path under them is not. + return ['wsl', wsl.distro.toLowerCase(), wsl.linuxPath] } - if (!isWindowsAbsolutePathLike(filePath)) { - // A POSIX backslash is part of the file name. + // 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 === 'win32' && filePath.startsWith('//')) + if (!drive && !unc) { return filePath } const slashed = normalizeRuntimePathSeparators(filePath) - return /^[A-Za-z]:/.test(slashed) ? `${slashed[0].toLowerCase()}${slashed.slice(1)}` : slashed + if (drive) { + return `${slashed[0].toLowerCase()}${slashed.slice(1)}` + } + // The UNC server is a case-insensitive host name, as in the Monaco model URI's authority. + const serverEnd = slashed.indexOf('/', 2) + return serverEnd === -1 + ? slashed.toLowerCase() + : `${slashed.slice(0, serverEnd).toLowerCase()}${slashed.slice(serverEnd)}` } -/** Null for tabs that never share: non-edit views and owners not resolved yet. */ +/** + * Null for tabs that never share: non-edit views, unresolved owners, and SSH targets whose endpoint + * is unknown. + * + * 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 + * change as a re-key or join of the existing record, never as a fresh record. + */ export function editorBufferIdentityOf( file: OpenFile, - state: WorktreeOperationRouteState + state: WorktreeOperationRouteState, + sshEndpointOf: SshEndpointIncarnationOf ): EditorBufferIdentity | null { if (file.mode !== 'edit') { return null } const { environmentId, hostId } = resolveEditorModelOwner(file, state) - if (!hostId) { + const host = parseExecutionHostId(hostId) + if (!host) { + return null + } + const sshEndpoint = host.kind === 'ssh' ? sshEndpointOf(environmentId, host.targetId) : null + if (sshEndpoint === undefined) { return null } // A same-host re-pair resolves to the revision it continues; an unproven one stays apart. @@ -54,20 +93,25 @@ export function editorBufferIdentityOf( file.operationProvenance?.generation.runtimePairingRevision ) ?? null) : null - // `wsl$` and `wsl.localhost` name one distro only on Windows; elsewhere they are plain folders. - const foldWslAliases = hostId === 'local' && !environmentId && isLocalWindowsDesktopClient() + // Only the local machine's OS is known here. `wsl$` and `wsl.localhost` name one distro only on + // Windows; a paired web client (including `orca serve` on Windows) keeps them apart, as today. + const localWindows = host.kind === 'local' && !environmentId && isLocalWindowsDesktopClient() return { key: JSON.stringify([ environmentId, - hostId, + host.id, revisionRoot, - editorBufferPathKey(file.filePath, foldWslAliases) + sshEndpoint, + editorBufferPathKey(file.filePath, localWindows ? 'win32' : 'unknown', localWindows) ]), environmentId } } -/** True when the identity's environment now names another machine. */ +/** + * True when the identity's environment now names another machine. Pass the ids from one + * `onRuntimeEnvironmentsRetired` event, not a long-lived list: a retired id can be re-paired. + */ export function isEditorBufferIdentityRetired( identity: EditorBufferIdentity, retiredEnvironmentIds: readonly string[] From edc4cffb64baf69e8b8603abcb6ef4230e2b147d Mon Sep 17 00:00:00 2001 From: Neil Date: Tue, 20 Oct 2026 16:41:49 -0700 Subject: [PATCH 3/3] fix(editor): identity takes the tab's captured SSH endpoint and a verified host path flavor - The SSH endpoint is the one the tab captured at open, passed in by the caller; a live lookup would match an old tab to whatever machine the target id names now. Missing means never share. - Remote hosts accept a verified path flavor (`windows` | `posix` | `unknown`), so a Windows host's `//SERVER/share` folds like `\\SERVER\share`, as the Monaco URI authority does today. Co-Authored-By: Claude --- .../buffers/editor-buffer-identity.test.ts | 113 +++++++++--------- .../editor/buffers/editor-buffer-identity.ts | 47 ++++---- 2 files changed, 82 insertions(+), 78 deletions(-) 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 aca56dbfd13c..de8d9dece64e 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 @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { OpenFile } from '../types/open-file' import type { WorktreeOperationRoute } from '@/lib/worktree-operation-route' import { @@ -9,7 +9,9 @@ import { detachedEditorBufferId, editorBufferIdentityOf, editorBufferPathKey, - isEditorBufferIdentityRetired + isEditorBufferIdentityRetired, + type EditorBufferHostFacts, + type EditorBufferHostPathFlavor } from './editor-buffer-identity' const LOCAL: WorktreeOperationRoute = { executionHostId: 'local', runtimeEnvironmentId: null } @@ -43,15 +45,17 @@ function file(overrides: Partial = {}, route?: WorktreeOperationRoute) } } -const endpoints = new Map() -const sshEndpointOf = (environmentId: string | null, targetId: string): string | undefined => - endpoints.get(`${environmentId}|${targetId}`) ?? endpoints.get(`*|${targetId}`) +const HOST_A: EditorBufferHostFacts = { capturedSshEndpoint: 'host-a:22' } -function keyOf(tab: OpenFile): string | undefined { - return editorBufferIdentityOf(tab, {}, sshEndpointOf)?.key +function keyOf(tab: OpenFile, facts: EditorBufferHostFacts = HOST_A): string | undefined { + return editorBufferIdentityOf(tab, {}, facts)?.key } -function pathKey(filePath: string, flavor: 'win32' | 'unknown' = 'unknown', foldWsl = false) { +function pathKey( + filePath: string, + flavor: EditorBufferHostPathFlavor = 'unknown', + foldWsl = false +) { return JSON.stringify(editorBufferPathKey(filePath, flavor, foldWsl)) } @@ -78,11 +82,6 @@ function runtimeTab(id: string, pairingRevision?: number): OpenFile { ) } -beforeEach(() => { - endpoints.clear() - endpoints.set('*|target', 'host-a:22') -}) - afterEach(() => { vi.unstubAllGlobals() replaceRuntimeEnvironmentRevisions([]) @@ -98,9 +97,11 @@ describe('editorBufferPathKey', () => { }) it('folds the UNC server name of a forward-slash path on a Windows host only', () => { - expect(pathKey('//SERVER/share/a.ts', 'win32')).toBe(pathKey('//server/share/a.ts', 'win32')) - expect(pathKey('\\\\server\\share\\a.ts', 'win32')).toBe( - pathKey('//SERVER/share/a.ts', 'win32') + 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') ) }) @@ -112,32 +113,32 @@ describe('editorBufferPathKey', () => { ['/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, 'win32', true)).not.toBe(pathKey(right, 'win32', true)) + expect(pathKey(left, 'windows', true)).not.toBe(pathKey(right, 'windows', true)) }) it('keeps an ambiguous `//` path exact unless the host is known 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', 'win32')).toBe(pathKey('//repo/a/b.ts', 'win32')) + 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, 'win32', true)).toBe(pathKey(right, 'win32', true)) - expect(pathKey(left, 'win32')).not.toBe(pathKey(right, 'win32')) + 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', 'win32', true) + 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, 'win32', true)).not.toBe(folded) + expect(pathKey(real, 'windows', true)).not.toBe(folded) } }) }) @@ -184,39 +185,37 @@ describe('editorBufferIdentityOf', () => { expect(nested).toBe(nestedElsewhere) }) - it('keeps a retargeted SSH id apart, and one machine together across a reconnect', () => { - const direct = file({ externalSshTargetId: 'target', runtimeEnvironmentId: null }) - const before = keyOf(direct) - // A reconnect moves the connection generation, not the endpoint. - const reconnected = file({ - id: 'b', - externalSshTargetId: 'target', - runtimeEnvironmentId: null, - operationProvenance: { - ownershipProjection: 'explicit', - generation: { - route: { executionHostId: 'ssh:target', runtimeEnvironmentId: null }, - runtimeConnectionGeneration: null, - runtimePairingRevision: undefined, - runtimeSshGeneration: null, - nestedSshGeneration: null, - directSshGeneration: 7 - } - } - }) - expect(keyOf(reconnected)).toBe(before) + 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) + }) - endpoints.set('*|target', 'host-b:22') - expect( - keyOf(file({ id: 'c', externalSshTargetId: 'target', runtimeEnvironmentId: null })) - ).not.toBe(before) + 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('never shares an SSH tab whose endpoint is unknown', () => { - endpoints.clear() - expect( - keyOf(file({ externalSshTargetId: 'target', runtimeEnvironmentId: null })) - ).toBeUndefined() + it('folds a forward-slash UNC server only on a remote host verified as Windows', () => { + const route: WorktreeOperationRoute = { + executionHostId: 'runtime:env', + runtimeEnvironmentId: 'env' + } + const upper = 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 })) + } }) it('keeps a runtime host and the local machine apart for one path', () => { @@ -224,10 +223,8 @@ describe('editorBufferIdentityOf', () => { }) it('never shares a tab whose owner is unresolved, or a non-edit tab', () => { - expect( - editorBufferIdentityOf(file({ runtimeEnvironmentId: null }), {}, sshEndpointOf) - ).toBeNull() - expect(editorBufferIdentityOf(file({ mode: 'diff' }, LOCAL), {}, sshEndpointOf)).toBeNull() + expect(editorBufferIdentityOf(file({ runtimeEnvironmentId: null }), {}, HOST_A)).toBeNull() + expect(editorBufferIdentityOf(file({ mode: 'diff' }, LOCAL), {}, HOST_A)).toBeNull() }) it('joins a re-pair proven to be the same machine', () => { @@ -253,12 +250,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), {}, sshEndpointOf) + const local = editorBufferIdentityOf(file({}, LOCAL), {}, HOST_A) 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, {}, sshEndpointOf)], + ['old', editorBufferIdentityOf(old, {}, HOST_A)], ['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 fc441d2b11f2..fc6eb8353cd4 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 @@ -14,18 +14,20 @@ export type EditorBufferIdentity = { environmentId: string | null } -/** - * The endpoint an SSH target id currently names. Target ids survive an in-place edit to another - * machine, while the connection generation also moves on a plain reconnect, so neither alone can - * key a file. Undefined when not known: that tab never shares. - */ -export type SshEndpointIncarnationOf = ( - environmentId: string | null, - targetId: string -) => string | undefined +/** `windows` or `posix` only when verified for the host; `unknown` never folds an ambiguous path. */ +export type EditorBufferHostPathFlavor = 'windows' | 'posix' | 'unknown' -/** `win32` only when the host is known to be Windows; `unknown` never folds an ambiguous path. */ -export type EditorBufferHostPathFlavor = 'win32' | '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] @@ -46,7 +48,7 @@ export function editorBufferPathKey( } // 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 === 'win32' && filePath.startsWith('//')) + const unc = filePath.startsWith('\\\\') || (flavor === 'windows' && filePath.startsWith('//')) if (!drive && !unc) { return filePath } @@ -62,8 +64,8 @@ export function editorBufferPathKey( } /** - * Null for tabs that never share: non-edit views, unresolved owners, and SSH targets whose endpoint - * is unknown. + * Null for tabs that never share: non-edit views, unresolved owners, and SSH tabs with no captured + * endpoint. * * 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 @@ -72,7 +74,7 @@ export function editorBufferPathKey( export function editorBufferIdentityOf( file: OpenFile, state: WorktreeOperationRouteState, - sshEndpointOf: SshEndpointIncarnationOf + hostFacts: EditorBufferHostFacts ): EditorBufferIdentity | null { if (file.mode !== 'edit') { return null @@ -82,8 +84,8 @@ export function editorBufferIdentityOf( if (!host) { return null } - const sshEndpoint = host.kind === 'ssh' ? sshEndpointOf(environmentId, host.targetId) : null - if (sshEndpoint === undefined) { + const sshEndpoint = host.kind === 'ssh' ? (hostFacts.capturedSshEndpoint ?? null) : null + if (host.kind === 'ssh' && !sshEndpoint) { return null } // A same-host re-pair resolves to the revision it continues; an unproven one stays apart. @@ -93,16 +95,21 @@ export function editorBufferIdentityOf( file.operationProvenance?.generation.runtimePairingRevision ) ?? null) : null - // Only the local machine's OS is known here. `wsl$` and `wsl.localhost` name one distro only on - // Windows; a paired web client (including `orca serve` on Windows) keeps them apart, as today. + // `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, localWindows ? 'win32' : 'unknown', localWindows) + editorBufferPathKey(file.filePath, flavor, localWindows) ]), environmentId }