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 e19d27d5d86..eb7c1528e85 100644 --- a/src/renderer/src/lib/execute-open-editor-path-move.ts +++ b/src/renderer/src/lib/execute-open-editor-path-move.ts @@ -11,6 +11,7 @@ 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 } from '@/lib/worktree-runtime-owner' let moveOperationCounter = 0 @@ -33,12 +34,18 @@ export async function executeOpenEditorPathMove(args: { const { context, fromPath, toPath, worktreeId, worktreePath } = args const operationId = `editor-move-${(moveOperationCounter += 1)}` - // Every open session under the source path is affected — including one open in - // a DIFFERENT worktree (e.g. a floating workspace) at the same absolute path, - // which the in-place rekey also retargets — so quiesce/suppress/notify them all. - const affected = useAppStore - .getState() - .openFiles.filter((f) => isPathInsideOrEqual(fromPath, f.filePath)) + // Every open session under the source path on the SAME execution host is + // affected — including one in a DIFFERENT worktree (e.g. a floating workspace) + // at the same absolute path — so quiesce/suppress/notify them all. A tab at the + // same path on another host (2nd SSH connection, local vs runtime) is a distinct + // file the rename never touched; scoping by host keeps it out of the transaction. + const moveState = useAppStore.getState() + const initiatingHostId = getExecutionHostIdForWorktree(moveState, worktreeId) + const affected = moveState.openFiles.filter( + (f) => + isPathInsideOrEqual(fromPath, f.filePath) && + getExecutionHostIdForWorktree(moveState, f.worktreeId) === initiatingHostId + ) // In-flight source suppression is scoped per (worktree, runtime owner): the // same path can be open and watched under more than one worktree/owner. 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 f91ef45fb1b..f19cf2b23d2 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 @@ -164,6 +164,59 @@ describe('remapOpenEditorTabsForPathChange', () => { }) }) + it('leaves a same-path tab on a different execution host untouched', () => { + // Two SSH connections both have /repo/a.md open with separate dirty drafts. + // Renaming on connA's host must not retarget connB's tab (a distinct file). + const state = useAppStore.getState() + useAppStore.setState({ + repos: [ + { id: 'repoA', connectionId: 'connA' }, + { id: 'repoB', connectionId: 'connB' } + ] as never, + worktreesByRepo: { + repoA: [{ id: 'wt-a', repoId: 'repoA' }], + repoB: [{ id: 'wt-b', repoId: 'repoB' }] + } as never + }) + + const openDirtyAt = (worktreeId: string, draft: string): string => { + state.openFile( + { + filePath: '/repo/a.md', + relativePath: 'a.md', + worktreeId, + runtimeEnvironmentId: null, + language: 'markdown', + mode: 'edit' + }, + { suppressActiveRuntimeFallback: true } + ) + const id = useAppStore.getState().openFiles.find((f) => f.worktreeId === worktreeId)!.id + state.setEditorDraft(id, draft) + state.markFileDirty(id, true) + return id + } + openDirtyAt('wt-a', 'draft A') + const idB = openDirtyAt('wt-b', 'draft B') + + const result = remapOpenEditorTabsForPathChange({ + fromPath: '/repo/a.md', + toPath: '/repo/b.md', + worktreePath: '/repo', + worktreeId: 'wt-a' + }) + expect(result.ok).toBe(true) + + const files = useAppStore.getState().openFiles + // connA's tab moved; connB's tab kept its id, path, AND its dirty draft. + expect(files.find((f) => f.worktreeId === 'wt-a')!.filePath).toBe('/repo/b.md') + const tabB = files.find((f) => f.worktreeId === 'wt-b')! + expect(tabB.id).toBe(idB) + expect(tabB.filePath).toBe('/repo/a.md') + expect(tabB.isDirty).toBe(true) + expect(useAppStore.getState().editorDrafts[idB]).toBe('draft B') + }) + it('clears the untitled marker when remapping a renamed new markdown file', () => { const state = useAppStore.getState() const oldPath = '/repo/untitled.md' 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 841b99298a5..60752c19ec2 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,6 +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 { buildDiffEditorFileId, buildOwnedEditorFileId, @@ -147,7 +148,15 @@ export function remapOpenEditorTabsForPathChange({ moveOperationId?: string }): RekeyOpenFilesResult { const state = useAppStore.getState() - const filesToMove = state.openFiles.filter((file) => isPathInsideOrEqual(fromPath, file.filePath)) + // 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) + const filesToMove = state.openFiles.filter( + (file) => + isPathInsideOrEqual(fromPath, file.filePath) && + getExecutionHostIdForWorktree(state, file.worktreeId) === initiatingHostId + ) if (filesToMove.length === 0) { return { ok: true } }