From d9a89dcb0cd4ea88c51847b949f573e2409f3e11 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 20 Oct 2026 03:58:23 -0700 Subject: [PATCH] fix(editor): a rename retargets only editors whose own owner is the renaming host (#31091) * fix(editor): a rename retargets only editors whose own owner is the renaming host Each open tab is matched to the move by the owner it captured when it opened (its server and SSH host), not by looking its worktree id up again, so a same-id twin's editor on another host keeps its path and its unsaved draft. * fix(editor): a server route that names no machine never strands the renaming host's own tab * fix(editor): only an SSH host a tab names excludes it from a rename; a local route can be this app's view of one * fix(editor): a rename's fallback host check reads both sides at once, never a stale pre-rename host * fix(editor): a rename matches tabs by the rows' owners, never the Active Server focus * fix(editor): a tab from another workspace moves only when that workspace is on the host the rename went to * fix(editor): a rename's cross-workspace check uses the File Explorer's own host resolver on both sides --- .../lib/execute-open-editor-path-move.test.ts | 57 ++++++++ .../src/lib/execute-open-editor-path-move.ts | 13 +- .../src/lib/open-editor-move-owner.test.ts | 115 +++++++++++++++ .../src/lib/open-editor-move-owner.ts | 138 ++++++++++++++++++ ...p-open-editor-tabs-for-path-change.test.ts | 89 ++++++----- .../remap-open-editor-tabs-for-path-change.ts | 14 +- .../editor-layout-owner-records.test.ts | 14 +- 7 files changed, 385 insertions(+), 55 deletions(-) create mode 100644 src/renderer/src/lib/open-editor-move-owner.test.ts create mode 100644 src/renderer/src/lib/open-editor-move-owner.ts diff --git a/src/renderer/src/lib/execute-open-editor-path-move.test.ts b/src/renderer/src/lib/execute-open-editor-path-move.test.ts index 50a5962db01..cf3c1c433db 100644 --- a/src/renderer/src/lib/execute-open-editor-path-move.test.ts +++ b/src/renderer/src/lib/execute-open-editor-path-move.test.ts @@ -84,6 +84,63 @@ describe('executeOpenEditorPathMove', () => { expect(__activeEditorPathMoveCountForTests()).toBe(0) }) + it('never retargets the same path open from a same-id workspace on a server', async () => { + const localId = openDirtyTab() + const state = useAppStore.getState() + state.openFile({ + filePath: '/repo/a.md', + relativePath: 'a.md', + worktreeId: 'wt-1', + runtimeEnvironmentId: 'env-server', + language: 'markdown', + mode: 'edit' + }) + const remote = useAppStore + .getState() + .openFiles.find((file) => file.runtimeEnvironmentId === 'env-server')! + state.setEditorDraft(remote.id, 'server draft') + state.markFileDirty(remote.id, true) + + await executeOpenEditorPathMove({ + context: { ...CONTEXT, expectedExecutionHostId: 'local' }, + fromPath: '/repo/a.md', + toPath: '/repo/b.md', + worktreeId: 'wt-1', + worktreePath: '/repo' + }) + + const files = useAppStore.getState().openFiles + expect(files.find((file) => file.runtimeEnvironmentId !== 'env-server')!.filePath).toBe( + '/repo/b.md' + ) + expect(files.find((file) => file.id === remote.id)).toMatchObject({ + filePath: '/repo/a.md', + isDirty: true + }) + expect(useAppStore.getState().editorDrafts[remote.id]).toBe('server draft') + expect(useAppStore.getState().editorDrafts[localId]).toBeUndefined() + }) + + it('still moves its own tab when the workspace host focus changes while the rename runs', async () => { + openDirtyTab() + mocks.renameRuntimePath.mockImplementation(async () => { + useAppStore.setState({ + activeWorktreeId: 'wt-1', + activeWorkspaceExecutionHostId: 'runtime:env-other' + }) + }) + + await executeOpenEditorPathMove({ + context: { ...CONTEXT, expectedExecutionHostId: 'local' }, + fromPath: '/repo/a.md', + toPath: '/repo/b.md', + worktreeId: 'wt-1', + worktreePath: '/repo' + }) + + expect(useAppStore.getState().openFiles[0]!.filePath).toBe('/repo/b.md') + }) + it('declares the moved file as the document for a document-scoped rename', async () => { openDirtyTab() diff --git a/src/renderer/src/lib/execute-open-editor-path-move.ts b/src/renderer/src/lib/execute-open-editor-path-move.ts index d019705f6df..7cd1658475b 100644 --- a/src/renderer/src/lib/execute-open-editor-path-move.ts +++ b/src/renderer/src/lib/execute-open-editor-path-move.ts @@ -12,10 +12,8 @@ import { } from '@/lib/remap-open-editor-tabs-for-path-change' import { verifyLatchedMoveDestinations } from '@/hooks/useEditorExternalWatch' import { notifyHostOfMirroredEditorClose } from '@/runtime/close-mirrored-editor-tab' -import { - getExecutionHostIdForWorktree, - runtimeEnvironmentIdWithLegacyFallbackUnlessTwin -} from '@/lib/worktree-runtime-owner' +import { runtimeEnvironmentIdWithLegacyFallbackUnlessTwin } from '@/lib/worktree-runtime-owner' +import { editorMoveOwnerFromContext, isOpenFileOnMoveOwner } from '@/lib/open-editor-move-owner' import { documentFolderAccess } from '@/lib/local-file-access' let moveOperationCounter = 0 @@ -42,11 +40,9 @@ export async function executeOpenEditorPathMove(args: { // Host-scoped: the same abs path on another host (2nd SSH connection, local vs // runtime) is a distinct file the rename never touched — keep it out of the move. const moveState = useAppStore.getState() - const initiatingHostId = getExecutionHostIdForWorktree(moveState, worktreeId) + const owner = editorMoveOwnerFromContext(context, worktreeId) const affected = moveState.openFiles.filter( - (f) => - isPathInsideOrEqual(fromPath, f.filePath) && - getExecutionHostIdForWorktree(moveState, f.worktreeId) === initiatingHostId + (f) => isPathInsideOrEqual(fromPath, f.filePath) && isOpenFileOnMoveOwner(moveState, f, owner) ) // Suppress the move ROOT (prefix-matched) per scope, always including the @@ -108,6 +104,7 @@ export async function executeOpenEditorPathMove(args: { toPath, worktreePath, worktreeId, + owner, moveOperationId: operationId }) if (!rekeyResult.ok) { diff --git a/src/renderer/src/lib/open-editor-move-owner.test.ts b/src/renderer/src/lib/open-editor-move-owner.test.ts new file mode 100644 index 00000000000..3923efb8686 --- /dev/null +++ b/src/renderer/src/lib/open-editor-move-owner.test.ts @@ -0,0 +1,115 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { useAppStore } from '@/store' +import type { EditorFileOperationProvenance } from '@/lib/editor-file-operation-owner' +import type { WorktreeOperationRoute } from '@/lib/worktree-operation-route' +import { makeWorktree, TEST_REPO } from '@/store/slices/worktrees-slice-test-fixtures' +import { editorMoveOwnerFromContext, isOpenFileOnMoveOwner } from './open-editor-move-owner' + +function captured(route: WorktreeOperationRoute): EditorFileOperationProvenance { + return { + ownershipProjection: 'explicit', + generation: { + route, + runtimeConnectionGeneration: null, + runtimePairingRevision: undefined, + runtimeSshGeneration: null, + nestedSshGeneration: null, + directSshGeneration: null + } + } +} + +// As openFile records it: the tab's server matches the one its route names. +const tab = (operationProvenance?: EditorFileOperationProvenance) => ({ + worktreeId: 'wt-1', + runtimeEnvironmentId: operationProvenance?.generation.route.runtimeEnvironmentId ?? null, + ...(operationProvenance ? { operationProvenance } : {}) +}) + +describe('isOpenFileOnMoveOwner', () => { + beforeEach(() => { + useAppStore.setState(useAppStore.getInitialState(), true) + }) + + it('never moves a direct-SSH tab for a rename on this app, and moves it for its own host', () => { + const state = useAppStore.getState() + const local = tab(captured({ executionHostId: 'local', runtimeEnvironmentId: null })) + const ssh = tab(captured({ executionHostId: 'ssh:box', runtimeEnvironmentId: null })) + const fromLocal = editorMoveOwnerFromContext( + { target: { kind: 'local' }, expectedExecutionHostId: 'local' }, + 'wt-1' + ) + const fromSsh = editorMoveOwnerFromContext( + { target: { kind: 'local' }, expectedExecutionHostId: 'ssh:box' }, + 'wt-1' + ) + expect([local, ssh].map((file) => isOpenFileOnMoveOwner(state, file, fromLocal))).toEqual([ + true, + false + ]) + expect(isOpenFileOnMoveOwner(state, ssh, fromSsh)).toBe(true) + }) + + it('moves a tab recorded as local when the File Explorer resolves its folder to an SSH host', () => { + const state = useAppStore.getState() + // An unstamped folder of SSH repos: the tab's route says this app, its rename goes to the host. + const folderTab = tab(captured({ executionHostId: 'local', runtimeEnvironmentId: null })) + const owner = editorMoveOwnerFromContext( + { target: { kind: 'local' }, expectedExecutionHostId: 'ssh:box' }, + 'wt-1' + ) + expect(isOpenFileOnMoveOwner(state, folderTab, owner)).toBe(true) + }) + + it('moves a server tab whose route names only the server when the rename ran on its SSH host', () => { + const state = useAppStore.getState() + const serverTab = tab( + captured({ executionHostId: 'runtime:env-a', runtimeEnvironmentId: 'env-a' }) + ) + const owner = editorMoveOwnerFromContext( + { + target: { kind: 'environment', environmentId: 'env-a' }, + expectedExecutionHostId: 'ssh:box' + }, + 'wt-1' + ) + expect(isOpenFileOnMoveOwner(state, serverTab, owner)).toBe(true) + }) + + it('never moves another server’s tab for the same workspace id', () => { + const state = useAppStore.getState() + const owner = editorMoveOwnerFromContext( + { target: { kind: 'environment', environmentId: 'env-a' }, expectedExecutionHostId: 'local' }, + 'wt-1' + ) + expect( + isOpenFileOnMoveOwner( + state, + tab(captured({ executionHostId: 'runtime:env-b', runtimeEnvironmentId: 'env-b' })), + owner + ) + ).toBe(false) + expect(isOpenFileOnMoveOwner(state, tab(), owner)).toBe(false) + }) + + it('moves a tab from another workspace only when that workspace is on the renaming host', () => { + useAppStore.setState({ + repos: [ + { ...TEST_REPO, id: 'repo-ssh', connectionId: 'box' }, + { ...TEST_REPO, id: 'repo-local' } + ], + worktreesByRepo: { + 'repo-ssh': [makeWorktree({ id: 'wt-ssh', repoId: 'repo-ssh' })], + 'repo-local': [makeWorktree({ id: 'wt-local', repoId: 'repo-local' })] + } + }) + const state = useAppStore.getState() + const owner = editorMoveOwnerFromContext( + { target: { kind: 'local' }, expectedExecutionHostId: 'ssh:box' }, + 'wt-1' + ) + const inWorkspace = (worktreeId: string) => ({ worktreeId, runtimeEnvironmentId: null }) + expect(isOpenFileOnMoveOwner(state, inWorkspace('wt-ssh'), owner)).toBe(true) + expect(isOpenFileOnMoveOwner(state, inWorkspace('wt-local'), owner)).toBe(false) + }) +}) diff --git a/src/renderer/src/lib/open-editor-move-owner.ts b/src/renderer/src/lib/open-editor-move-owner.ts new file mode 100644 index 00000000000..bb14fa88f51 --- /dev/null +++ b/src/renderer/src/lib/open-editor-move-owner.ts @@ -0,0 +1,138 @@ +import type { OpenFile } from '@/store/slices/editor' +import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client-types' +import { parseExecutionHostId, toSshExecutionHostId } from '../../../shared/execution-host' +import { + getFileExplorerOperationOwnerFromState, + type FileExplorerOwnerState +} from '@/components/right-sidebar/file-explorer-operation-owner' +import { resolveOwner } from './resolve-owner' + +/** + * Who a file lives on: the paired server (null for this app) and, when known, the SSH host behind + * it (null for that runtime's own machine, undefined when nothing decisive recorded it). + */ +type FileOwner = { runtimeEnvironmentId: string | null; sshHostId: string | null | undefined } + +/** The host a move ran on, from the route its rename was sent through. */ +export type EditorMoveOwner = FileOwner & { + /** The renamed workspace, for tabs that recorded no decisive owner of their own. */ + worktreeId: string | undefined +} + +export function editorMoveOwnerFromContext( + context: Pick, + worktreeId: string | undefined +): EditorMoveOwner { + const expected = parseExecutionHostId(context.expectedExecutionHostId) + return { + runtimeEnvironmentId: + context.target.kind === 'environment' ? context.target.environmentId : null, + sshHostId: + expected?.kind === 'ssh' ? expected.id : expected?.kind === 'local' ? null : undefined, + worktreeId + } +} + +/** + * The tab's own owner, recorded when it opened; never re-derived from its worktree id alone. Only an + * SSH host it names is decisive: a `local` or server route can be this app's view of a host the + * File Explorer resolves further (an unstamped folder of SSH repos, a server's nested SSH target). + */ +function openFileOwner( + state: FileExplorerOwnerState, + file: Pick< + OpenFile, + 'externalSshTargetId' | 'operationProvenance' | 'runtimeEnvironmentId' | 'worktreeId' + > +): FileOwner { + const captured = file.operationProvenance?.generation.route + const capturedHost = parseExecutionHostId(captured?.executionHostId) + const runtimeEnvironmentId = + file.runtimeEnvironmentId !== undefined + ? file.runtimeEnvironmentId?.trim() || null + : captured + ? captured.runtimeEnvironmentId + : ownerEnvironmentId(state, file.worktreeId) + return { + runtimeEnvironmentId, + sshHostId: + capturedHost?.kind === 'ssh' + ? capturedHost.id + : file.externalSshTargetId + ? toSshExecutionHostId(file.externalSshTargetId) + : undefined + } +} + +/** + * Whether an open tab is the file the move renamed. Why its own owner: the same path open from a + * same-id workspace on another host is a different file, and its draft must stay where it is. + */ +export function isOpenFileOnMoveOwner( + state: FileExplorerOwnerState, + file: Pick< + OpenFile, + 'externalSshTargetId' | 'operationProvenance' | 'runtimeEnvironmentId' | 'worktreeId' + >, + owner: EditorMoveOwner +): boolean { + const own = openFileOwner(state, file) + if (own.runtimeEnvironmentId !== owner.runtimeEnvironmentId) { + return false + } + if (own.sshHostId !== undefined && owner.sshHostId !== undefined) { + return own.sshHostId === owner.sshHostId + } + // Why read now, from the rows: a lookup from before the rename, or one following focus, can disagree. + if (file.worktreeId === owner.worktreeId) { + return true + } + const tabHost = workspaceHost(state, file.worktreeId) + const moveHost = + owner.sshHostId === undefined + ? workspaceHost(state, owner.worktreeId) + : { runtimeEnvironmentId: owner.runtimeEnvironmentId, sshHostId: owner.sshHostId } + return ( + tabHost !== null && + moveHost !== null && + tabHost.runtimeEnvironmentId === moveHost.runtimeEnvironmentId && + tabHost.sshHostId === moveHost.sshHostId + ) +} + +/** + * Where the File Explorer sends a workspace's file operations, the resolver its rename used, so a + * folder of SSH repos reads as that host on both sides. Null for a twin. + */ +function workspaceHost( + state: FileExplorerOwnerState, + workspaceId: string | undefined +): FileOwner | null { + const owner = getFileExplorerOperationOwnerFromState(state, workspaceId) + switch (owner.kind) { + case 'local': + return { runtimeEnvironmentId: null, sshHostId: null } + case 'ssh': + return { runtimeEnvironmentId: null, sshHostId: toSshExecutionHostId(owner.connectionId) } + case 'runtime': { + const host = parseExecutionHostId(owner.executionHostId) + return { + runtimeEnvironmentId: owner.environmentId, + sshHostId: host?.kind === 'ssh' ? host.id : null + } + } + case 'unresolved': + // A workspace with no rows is this app's, as everywhere; a twin's host can't be told. + return workspaceId && resolveOwner(state, { workspaceId }).kind === 'missing' + ? { runtimeEnvironmentId: null, sshHostId: null } + : null + } +} + +/** The paired server a workspace's rows name, or null for this app, a twin or no row. */ +function ownerEnvironmentId(state: FileExplorerOwnerState, workspaceId: string): string | null { + const match = resolveOwner(state, { workspaceId }) + return match.kind === 'resolved' && match.owner.endpoint.kind === 'environment' + ? match.owner.endpoint.environmentId + : null +} diff --git a/src/renderer/src/lib/remap-open-editor-tabs-for-path-change.test.ts b/src/renderer/src/lib/remap-open-editor-tabs-for-path-change.test.ts index 758896c538f..5ae0fa1688d 100644 --- a/src/renderer/src/lib/remap-open-editor-tabs-for-path-change.test.ts +++ b/src/renderer/src/lib/remap-open-editor-tabs-for-path-change.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it } from 'vitest' import { useAppStore } from '@/store' import { remapOpenEditorTabsForPathChange } from './remap-open-editor-tabs-for-path-change' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' +import { editorMoveOwnerFromContext, type EditorMoveOwner } from '@/lib/open-editor-move-owner' function ownedEditorFileId( filePath: string, @@ -12,12 +13,16 @@ function ownedEditorFileId( return `editor:${encodeURIComponent(worktreeId)}:${encodeURIComponent(runtimeKey)}:${encodeURIComponent(filePath)}` } +function localMoveOwner(worktreeId: string | undefined): EditorMoveOwner { + return editorMoveOwnerFromContext({ target: { kind: 'local' } }, worktreeId) +} + describe('remapOpenEditorTabsForPathChange', () => { beforeEach(() => { useAppStore.setState(useAppStore.getInitialState(), true) }) - it('preserves runtime owners, drafts, dirty state, and markdown preview sources', () => { + it('moves only the renaming host’s editor of a same-id twin, with its draft and preview', () => { const state = useAppStore.getState() const worktreeId = 'wt-1' const worktreePath = '/repo' @@ -75,46 +80,40 @@ describe('remapOpenEditorTabsForPathChange', () => { fromPath: '/repo/docs', toPath: '/repo/notes', worktreePath, - worktreeId + worktreeId, + owner: editorMoveOwnerFromContext( + { + target: { kind: 'environment', environmentId: 'env-remote' }, + expectedExecutionHostId: 'local' + }, + worktreeId + ) }) const nextState = useAppStore.getState() - expect(nextState.openFiles.some((file) => file.filePath === oldPath)).toBe(false) - - const localRemapped = nextState.openFiles.find( - (file) => - file.filePath === newPath && file.mode === 'edit' && file.runtimeEnvironmentId === null - ) const remoteRemapped = nextState.openFiles.find( (file) => file.filePath === newPath && file.mode === 'edit' && file.runtimeEnvironmentId === 'env-remote' ) - expect(localRemapped).toMatchObject({ - relativePath: 'notes/readme.md', - isDirty: true, - runtimeEnvironmentId: null - }) - expect(remoteRemapped).toMatchObject({ - relativePath: 'notes/readme.md', - isDirty: true, - runtimeEnvironmentId: 'env-remote' - }) - expect(nextState.editorDrafts[localRemapped!.id]).toBe('local draft') + expect(remoteRemapped).toMatchObject({ relativePath: 'notes/readme.md', isDirty: true }) expect(nextState.editorDrafts[remoteRemapped!.id]).toBe('remote draft') - expect(nextState.editorDrafts[localEditId!]).toBeUndefined() expect(nextState.editorDrafts[remoteEdit!.id]).toBeUndefined() - - const remotePreview = nextState.openFiles.find( - (file) => file.mode === 'markdown-preview' && file.runtimeEnvironmentId === 'env-remote' - ) - expect(remotePreview).toMatchObject({ + expect( + nextState.openFiles.find( + (file) => file.mode === 'markdown-preview' && file.runtimeEnvironmentId === 'env-remote' + ) + ).toMatchObject({ filePath: newPath, relativePath: 'notes/readme.md', markdownPreviewAnchor: 'heading', markdownPreviewSourceFileId: remoteRemapped!.id }) + // The local twin's file never moved, so its tab and unsaved draft stay where they were. + const local = nextState.openFiles.find((file) => file.id === localEditId) + expect(local).toMatchObject({ filePath: oldPath, isDirty: true, runtimeEnvironmentId: null }) + expect(nextState.editorDrafts[localEditId!]).toBe('local draft') }) it('retargets preview-only markdown source ids to the moved owner path', () => { @@ -149,7 +148,8 @@ describe('remapOpenEditorTabsForPathChange', () => { fromPath: '/repo/docs', toPath: '/repo/notes', worktreePath, - worktreeId: 'wt-1' + worktreeId: 'wt-1', + owner: localMoveOwner('wt-1') }) const floatingPreview = useAppStore @@ -203,7 +203,8 @@ describe('remapOpenEditorTabsForPathChange', () => { fromPath: '/repo/a.md', toPath: '/repo/b.md', worktreePath: '/repo', - worktreeId: 'wt-a' + worktreeId: 'wt-a', + owner: localMoveOwner('wt-a') }) expect(result.ok).toBe(true) @@ -240,7 +241,8 @@ describe('remapOpenEditorTabsForPathChange', () => { fromPath: 'c:\\repo\\src', toPath: 'c:\\repo\\dst', worktreePath: 'c:\\repo', - worktreeId: 'wt-1' + worktreeId: 'wt-1', + owner: localMoveOwner('wt-1') }) expect(result.ok).toBe(true) @@ -273,7 +275,8 @@ describe('remapOpenEditorTabsForPathChange', () => { fromPath: '\\\\wsl$\\Ubuntu\\repo\\src', toPath: '\\\\wsl$\\Ubuntu\\repo\\dst', worktreePath: '\\\\wsl$\\Ubuntu\\repo', - worktreeId: 'wt-1' + worktreeId: 'wt-1', + owner: localMoveOwner('wt-1') }) expect(result.ok).toBe(true) @@ -301,7 +304,8 @@ describe('remapOpenEditorTabsForPathChange', () => { fromPath: '/repo/src', toPath: '/repo/dst', worktreePath: '/repo', - worktreeId: 'wt-1' + worktreeId: 'wt-1', + owner: localMoveOwner('wt-1') }) expect(useAppStore.getState().openFiles[0]!.filePath).toBe('/repo/dst/a\\b.txt') @@ -338,7 +342,8 @@ describe('remapOpenEditorTabsForPathChange', () => { fromPath: '/repo/src', toPath: '/repo/dst', worktreePath: '/repo', - worktreeId: 'wt-1' + worktreeId: 'wt-1', + owner: localMoveOwner('wt-1') }) const floating = useAppStore @@ -369,7 +374,8 @@ describe('remapOpenEditorTabsForPathChange', () => { fromPath: '/repo/src', toPath: '/repo/dst\\', worktreePath: '/repo', - worktreeId: 'wt-1' + worktreeId: 'wt-1', + owner: localMoveOwner('wt-1') }) expect(useAppStore.getState().openFiles[0]!.filePath).toBe('/repo/dst\\/a.ts') @@ -392,7 +398,8 @@ describe('remapOpenEditorTabsForPathChange', () => { fromPath: '/srv/team\\repo/src', toPath: '/srv/team\\repo/dst', worktreePath: '/srv/team\\repo', - worktreeId: 'wt-1' + worktreeId: 'wt-1', + owner: localMoveOwner('wt-1') }) // POSIX flavor: the separator stays `/`; the ancestor backslash is untouched. @@ -416,7 +423,8 @@ describe('remapOpenEditorTabsForPathChange', () => { fromPath: 'C:\\Repo\\\\Src', toPath: 'C:\\Repo\\Dst', worktreePath: 'C:\\Repo', - worktreeId: 'wt-1' + worktreeId: 'wt-1', + owner: localMoveOwner('wt-1') }) // Nested suffix preserved, no dropped or doubled separator. @@ -441,7 +449,8 @@ describe('remapOpenEditorTabsForPathChange', () => { fromPath: oldPath, toPath: newPath, worktreePath: '/repo', - worktreeId: 'wt-1' + worktreeId: 'wt-1', + owner: localMoveOwner('wt-1') }) expect(useAppStore.getState().openFiles).toHaveLength(1) @@ -473,7 +482,12 @@ describe('remapOpenEditorTabsForPathChange', () => { state.setLastKnownDiskSignature(oldId, 'sig-abc') state.setExternalMutation(oldId, 'changed') - remapOpenEditorTabsForPathChange({ fromPath: oldPath, toPath: newPath, worktreePath: '/repo' }) + remapOpenEditorTabsForPathChange({ + fromPath: oldPath, + toPath: newPath, + worktreePath: '/repo', + owner: localMoveOwner(undefined) + }) const moved = useAppStore.getState().openFiles.find((f) => f.filePath === newPath) expect(moved).toBeTruthy() @@ -503,7 +517,8 @@ describe('remapOpenEditorTabsForPathChange', () => { fromPath: '/repo/docs', toPath: '/repo/notes', worktreePath: '/repo', - worktreeId: 'wt-1' + worktreeId: 'wt-1', + owner: localMoveOwner('wt-1') }) const moved = useAppStore.getState().openFiles.find((f) => f.mode === 'diff') diff --git a/src/renderer/src/lib/remap-open-editor-tabs-for-path-change.ts b/src/renderer/src/lib/remap-open-editor-tabs-for-path-change.ts index fcac9a33584..dd837cd2c2a 100644 --- a/src/renderer/src/lib/remap-open-editor-tabs-for-path-change.ts +++ b/src/renderer/src/lib/remap-open-editor-tabs-for-path-change.ts @@ -1,7 +1,7 @@ import { useAppStore } from '@/store' import { detectLanguage } from '@/lib/language-detect' import { basename } from '@/lib/path' -import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' +import { isOpenFileOnMoveOwner, type EditorMoveOwner } from '@/lib/open-editor-move-owner' import { buildDiffEditorFileId, buildOwnedEditorFileId, @@ -192,25 +192,25 @@ export function remapOpenEditorTabsForPathChange({ toPath, worktreePath, worktreeId, + owner, moveOperationId }: { fromPath: string toPath: string worktreePath: string worktreeId?: string + /** The host the rename ran on; only tabs whose own owner it is are retargeted. */ + owner: EditorMoveOwner /** Passed by the move coordinator so dirty destinations get a content-verify * gate + provenance installed atomically with the re-home. */ moveOperationId?: string }): RekeyOpenFilesResult { const state = useAppStore.getState() - // The rename only touched the initiating execution host. The same absolute - // path can be open on a DIFFERENT host (a second SSH connection, or local vs - // runtime) as a distinct physical file — never retarget those tabs. - const initiatingHostId = getExecutionHostIdForWorktree(state, worktreeId) + // The rename only touched its own host. The same absolute path can be open on another host (a + // second SSH connection, local vs runtime, or a same-id twin) as a distinct file. const filesToMove = state.openFiles.filter( (file) => - isPathInsideOrEqual(fromPath, file.filePath) && - getExecutionHostIdForWorktree(state, file.worktreeId) === initiatingHostId + isPathInsideOrEqual(fromPath, file.filePath) && isOpenFileOnMoveOwner(state, file, owner) ) if (filesToMove.length === 0) { return { ok: true } diff --git a/src/renderer/src/store/slices/editor-layout-owner-records.test.ts b/src/renderer/src/store/slices/editor-layout-owner-records.test.ts index e917539df36..5f2b445028f 100644 --- a/src/renderer/src/store/slices/editor-layout-owner-records.test.ts +++ b/src/renderer/src/store/slices/editor-layout-owner-records.test.ts @@ -12,6 +12,7 @@ import type { LayoutCommand } from '../../../../shared/workspace-layout/workspac import type { WorkspaceLayoutModel } from '../../../../shared/workspace-layout/workspace-layout-model' import { publishWorkspaceLayout } from '../../../../shared/workspace-layout/workspace-layout-published' import type { WorkspaceLayoutChangeFrame } from '../../../../shared/workspace-layout/workspace-layout-stream-frames' +import { editorMoveOwnerFromContext, type EditorMoveOwner } from '@/lib/open-editor-move-owner' const mocks = vi.hoisted(() => ({ layoutOwnerIsMain: true, @@ -171,6 +172,10 @@ function openEdit(worktreeId: string, filePath: string, relativePath: string): s ) } +function localMoveOwner(worktreeId: string | undefined): EditorMoveOwner { + return editorMoveOwnerFromContext({ target: { kind: 'local' } }, worktreeId) +} + describe('editor records under frames, layout-owner build', () => { it('keeps the file record of every editor tab main keeps', async () => { const state = useAppStore.getState() @@ -253,7 +258,8 @@ describe('open files renamed on disk, layout-owner build', () => { fromPath: '/repo-a/src', toPath: '/repo-a/lib', worktreePath: '/repo-a', - worktreeId: WT + worktreeId: WT, + owner: localMoveOwner(WT) }) expect(result).toEqual({ ok: true }) @@ -284,7 +290,8 @@ describe('open files renamed on disk, layout-owner build', () => { fromPath: '/repo-a/a.ts', toPath: '/repo-a/b.ts', worktreePath: '/repo-a', - worktreeId: WT + worktreeId: WT, + owner: localMoveOwner(WT) }) ).toEqual({ ok: false, reason: 'collision' }) expect(applySent()).toEqual([]) @@ -295,7 +302,8 @@ describe('open files renamed on disk, layout-owner build', () => { fromPath: '/repo-a/a.ts', toPath: '/repo-a/c.ts', worktreePath: '/repo-a', - worktreeId: WT + worktreeId: WT, + owner: localMoveOwner(WT) }) ).toEqual({ ok: true }) expect(applySent()).toEqual([])