diff --git a/src/renderer/src/components/editor/editor-model-owner.ts b/src/renderer/src/components/editor/editor-model-owner.ts index 9115c03b521..21a90207803 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 00000000000..de8d9dece64 --- /dev/null +++ b/src/renderer/src/store/slices/editor/buffers/editor-buffer-identity.test.ts @@ -0,0 +1,296 @@ +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, + type EditorBufferHostFacts, + type EditorBufferHostPathFlavor +} 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 + } +} + +const HOST_A: EditorBufferHostFacts = { capturedSshEndpoint: 'host-a:22' } + +function keyOf(tab: OpenFile, facts: EditorBufferHostFacts = HOST_A): string | undefined { + return editorBufferIdentityOf(tab, {}, facts)?.key +} + +function pathKey( + filePath: string, + flavor: EditorBufferHostPathFlavor = 'unknown', + foldWsl = false +) { + return JSON.stringify(editorBufferPathKey(filePath, flavor, foldWsl)) +} + +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 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', 'windows')).toBe( + pathKey('//server/share/a.ts', 'windows') + ) + expect(pathKey('\\\\server\\share\\a.ts', 'windows')).toBe( + pathKey('//SERVER/share/a.ts', 'windows') + ) + }) + + 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 path case or POSIX backslashes: %s is not %s', (left, right) => { + 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', '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) + } + }) +}) + +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('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', () => { + 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', () => { + 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 }), {}, HOST_A)).toBeNull() + expect(editorBufferIdentityOf(file({ mode: 'diff' }, LOCAL), {}, HOST_A)).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), {}, 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, {}, HOST_A)], + ['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('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 }], { + 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 00000000000..fc6eb8353cd --- /dev/null +++ b/src/renderer/src/store/slices/editor/buffers/editor-buffer-identity.ts @@ -0,0 +1,135 @@ +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 { 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. */ + 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. + */ +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] + } + // 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('//')) + if (!drive && !unc) { + return filePath + } + const slashed = normalizeRuntimePathSeparators(filePath) + 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, 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 + * change as a re-key or join of the existing record, never as a fresh record. + */ +export function editorBufferIdentityOf( + file: OpenFile, + state: WorktreeOperationRouteState, + hostFacts: EditorBufferHostFacts +): EditorBufferIdentity | null { + if (file.mode !== 'edit') { + return null + } + const { environmentId, hostId } = resolveEditorModelOwner(file, state) + const host = parseExecutionHostId(hostId) + if (!host) { + return null + } + 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. + const revisionRoot = environmentId + ? (resolveContinuedRuntimeEnvironmentRevision( + environmentId, + 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) + ]), + environmentId + } +} + +/** + * 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[] +): 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}` +}