mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 08:03:22 +00:00
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:
@@ -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')
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
? {
|
||||
|
||||
Reference in New Issue
Block a user