fix(editor): scope move rekey to the initiating execution host (round 6)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil
2026-07-20 21:42:43 -07:00
co-authored by Orca
parent 0e6bf30abb
commit cca28dc218
3 changed files with 76 additions and 7 deletions
@@ -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.
@@ -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'
@@ -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 }
}