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([])