refactor(editor): remap moves via atomic in-place rekey, not close+reopen (stage 4a)

remapOpenEditorTabsForPathChange now builds an owner-aware rekey plan (plain-path
id to the first owner, owner-qualified to the rest; previews resolve their source
to the moved edit's new id) and applies it via rekeyOpenFilesForPathChange in one
commit — preserving the full OpenFile + cursor/view/group/MRU state and closing
the close/reopen watcher-race window. Passes moveOperationId through so dirty
destinations get the content-verify gate. 4545 tests green.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil
2026-07-20 17:23:24 -07:00
co-authored by Orca
parent e82ed76ea5
commit e45f4d4f31
3 changed files with 105 additions and 93 deletions
@@ -1,6 +1,11 @@
import { useAppStore } from '@/store'
import { detectLanguage } from '@/lib/language-detect'
import { basename } from '@/lib/path'
import {
buildOwnedEditorFileId,
resolveEditorFileIdForOwner,
type OpenFilePathRekey
} from '@/store/slices/editor'
import {
normalizeRuntimePathSeparators,
relativePathInsideRoot
@@ -128,113 +133,120 @@ export function remapOpenEditorTabsForPathChange({
fromPath,
toPath,
worktreePath,
worktreeId
worktreeId,
moveOperationId
}: {
fromPath: string
toPath: string
worktreePath: string
worktreeId?: string
/** Passed by the move coordinator so dirty destinations get a content-verify
* gate + provenance installed atomically with the re-home. */
moveOperationId?: string
}): void {
const state = useAppStore.getState()
const filesToMove = state.openFiles.filter((file) => isPathInsideOrEqual(fromPath, file.filePath))
if (filesToMove.length === 0) {
return
}
const scopedWorktreeId = worktreeId ?? filesToMove[0]!.worktreeId
// Why: preview tabs refer to edit tab ids as their source, so edits must be
// remapped first before reopening markdown previews with updated source ids.
const remappedFileIds = new Map<string, string>()
const orderedFilesToMove = [...filesToMove].sort(
(a, b) => Number(a.mode === 'markdown-preview') - Number(b.mode === 'markdown-preview')
)
for (const file of orderedFilesToMove) {
const oldFilePath = file.filePath
const suffix = oldFilePath.slice(fromPath.length)
const updatedPath = toPath + suffix
const updatedRelative = getUpdatedRelativePath({
filePath: oldFilePath,
// Retarget the live edit session in place (atomic store rekey) instead of
// close+reopen: preserves the full OpenFile + all id-keyed state and closes
// the watcher-race window that close/reopen opened.
const updatedPathOf = (file: { filePath: string }): string =>
toPath + file.filePath.slice(fromPath.length)
const relativeOf = (file: {
filePath: string
relativePath: string
worktreeId: string
}): string =>
getUpdatedRelativePath({
filePath: file.filePath,
relativePath: file.relativePath,
worktreeId: file.worktreeId,
updatedPath,
updatedPath: updatedPathOf(file),
initiatingWorktreeId: worktreeId,
initiatingWorktreePath: worktreePath
})
const draft = state.editorDrafts[file.id]
const wasDirty = file.isDirty
// Carry the edit-session identity forward: the disk baseline lets the
// re-homed tab tell an external write from the move's own watcher echo, and
// any live conflict/verification state must not be dropped by the re-home.
const carriedSignature = file.lastKnownDiskSignature
const carriedMutation = file.externalMutation
const carriedPendingVerification = file.pendingDiskBaselineVerification
// Why: renameRuntimePath already moved the file. Clear the untitled marker
// before closeFile so its cleanup path does not try to delete the old path.
if (file.isUntitled) {
useAppStore.getState().clearUntitled(file.id)
// The plain-path id goes to the first owner claiming a destination; other
// owners of the same path get an owner-qualified id (mirrors what sequential
// openFile did). An existing unaffected tab at the destination is honoured via
// resolveEditorFileIdForOwner; a same-owner conflict is a real collision the
// rekey action rejects.
const ownerKeyOf = (file: { worktreeId: string; runtimeEnvironmentId?: string | null }): string =>
`${file.worktreeId}::${file.runtimeEnvironmentId?.trim() || ''}`
const plainPathOwner = new Map<string, string>()
const reservedSourceId = (file: {
filePath: string
worktreeId: string
runtimeEnvironmentId?: string | null
}): string => {
const updatedPath = updatedPathOf(file)
const ownerKey = ownerKeyOf(file)
const claimed = plainPathOwner.get(updatedPath)
if (claimed === ownerKey) {
return updatedPath
}
if (claimed !== undefined) {
return buildOwnedEditorFileId(updatedPath, file.worktreeId, file.runtimeEnvironmentId)
}
const id = resolveEditorFileIdForOwner(
state,
updatedPath,
file.worktreeId,
file.runtimeEnvironmentId,
['edit']
)
if (id === updatedPath) {
plainPathOwner.set(updatedPath, ownerKey)
}
return id
}
// Why: preview tabs use synthetic ids (`markdown-preview::...`) instead of
// filePath, so close the real tab id before reopening at the new path.
state.closeFile(file.id)
if (file.mode === 'edit') {
state.openFile(
{
filePath: updatedPath,
relativePath: updatedRelative,
worktreeId: file.worktreeId,
runtimeEnvironmentId: file.runtimeEnvironmentId,
language: detectLanguage(basename(updatedPath)),
mode: 'edit'
},
{ suppressActiveRuntimeFallback: file.runtimeEnvironmentId === null }
)
} else if (file.mode === 'markdown-preview') {
const remappedSourceFileId = file.markdownPreviewSourceFileId
? remappedFileIds.get(file.markdownPreviewSourceFileId)
: undefined
state.openMarkdownPreview(
{
filePath: updatedPath,
relativePath: updatedRelative,
worktreeId: file.worktreeId,
runtimeEnvironmentId: file.runtimeEnvironmentId,
language: 'markdown'
},
{
anchor: file.markdownPreviewAnchor ?? null,
// Why: preview-only tabs may point at an owner-qualified source id
// whose edit tab is not open. Let the store resolve that id for the
// renamed path instead of preserving the old path in the preview id.
sourceFileId: remappedSourceFileId
}
)
} else {
const rekeys: OpenFilePathRekey[] = []
// Edits first so a preview can point its source id at the moved edit's new id.
const newEditIdByOldId = new Map<string, string>()
for (const file of filesToMove) {
if (file.mode !== 'edit') {
continue
}
const freshState = useAppStore.getState()
const reopenedFile = freshState.openFiles.find(
(entry) =>
entry.filePath === updatedPath &&
entry.worktreeId === file.worktreeId &&
entry.mode === file.mode &&
(entry.runtimeEnvironmentId ?? null) === (file.runtimeEnvironmentId ?? null)
)
const reopenedFileId = reopenedFile?.id ?? updatedPath
remappedFileIds.set(file.id, reopenedFileId)
if (draft !== undefined) {
freshState.setEditorDraft(reopenedFileId, draft)
}
if (wasDirty) {
freshState.markFileDirty(reopenedFileId, true)
}
if (carriedSignature !== undefined) {
freshState.setLastKnownDiskSignature(reopenedFileId, carriedSignature)
}
if (carriedMutation) {
freshState.setExternalMutation(reopenedFileId, carriedMutation)
}
if (carriedPendingVerification) {
freshState.setPendingDiskBaselineVerification(reopenedFileId, true)
}
const newId = reservedSourceId(file)
newEditIdByOldId.set(file.id, newId)
rekeys.push({
oldFileId: file.id,
newFileId: newId,
oldFilePath: file.filePath,
newFilePath: updatedPathOf(file),
newRelativePath: relativeOf(file),
newLanguage: detectLanguage(basename(updatedPathOf(file))),
// Only an explicit rename of the file itself consumes untitled status; a
// containing-directory move keeps it.
consumeUntitled: file.isUntitled === true && file.filePath === fromPath
})
}
for (const file of filesToMove) {
if (file.mode !== 'markdown-preview') {
continue
}
const newSourceFileId =
(file.markdownPreviewSourceFileId
? newEditIdByOldId.get(file.markdownPreviewSourceFileId)
: undefined) ?? reservedSourceId(file)
rekeys.push({
oldFileId: file.id,
newFileId: `markdown-preview::${newSourceFileId}`,
oldFilePath: file.filePath,
newFilePath: updatedPathOf(file),
newRelativePath: relativeOf(file),
newMarkdownPreviewSourceFileId: newSourceFileId
})
}
if (rekeys.length === 0) {
return
}
useAppStore
.getState()
.rekeyOpenFilesForPathChange({ worktreeId: scopedWorktreeId, rekeys, moveOperationId })
}
@@ -185,7 +185,7 @@ describe('rekeyOpenFilesForPathChange', () => {
})
const moved = useAppStore.getState().openFiles[0]!
expect(moved.isUntitled).toBe(false)
expect(moved.isUntitled).toBeUndefined()
expect(moved.filePath).toBe('/repo/named.md')
})
+3 -3
View File
@@ -1044,7 +1044,7 @@ function isSameEditorOwner(
)
}
function buildOwnedEditorFileId(
export function buildOwnedEditorFileId(
filePath: string,
worktreeId: string,
runtimeEnvironmentId: string | null | undefined
@@ -1114,7 +1114,7 @@ function getReusableOpenFileModes(mode: OpenFile['mode']): readonly OpenFile['mo
return [mode]
}
function resolveEditorFileIdForOwner(
export function resolveEditorFileIdForOwner(
state: Pick<EditorSlice, 'openFiles'>,
filePath: string,
worktreeId: string,
@@ -2699,7 +2699,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
? { markdownPreviewSourceFileId: rekey.newMarkdownPreviewSourceFileId }
: {}),
...(rekey.consumeUntitled
? { isUntitled: false, deleteUntouchedOnClose: undefined }
? { isUntitled: undefined, deleteUntouchedOnClose: undefined }
: {}),
...(gatesEcho
? {