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 b9fba2f98b2..f8a1751b233 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,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() - 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() + 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() + 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 }) } diff --git a/src/renderer/src/store/slices/editor-rekey-open-files.test.ts b/src/renderer/src/store/slices/editor-rekey-open-files.test.ts index ccba9dabc1f..6ff70995e0b 100644 --- a/src/renderer/src/store/slices/editor-rekey-open-files.test.ts +++ b/src/renderer/src/store/slices/editor-rekey-open-files.test.ts @@ -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') }) diff --git a/src/renderer/src/store/slices/editor.ts b/src/renderer/src/store/slices/editor.ts index 1a81c0624f4..f5b3e752d9a 100644 --- a/src/renderer/src/store/slices/editor.ts +++ b/src/renderer/src/store/slices/editor.ts @@ -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, filePath: string, worktreeId: string, @@ -2699,7 +2699,7 @@ export const createEditorSlice: StateCreator = (s ? { markdownPreviewSourceFileId: rekey.newMarkdownPreviewSourceFileId } : {}), ...(rekey.consumeUntitled - ? { isUntitled: false, deleteUntouchedOnClose: undefined } + ? { isUntitled: undefined, deleteUntouchedOnClose: undefined } : {}), ...(gatesEcho ? {