feat(editor): move coordinator drives rename/drag/undo/redo/untitled (stage 4b)

executeOpenEditorPathMove is the single transaction for every in-app move:
quiesce affected saves -> op-scoped begin (per runtime owner) -> on-disk rename
-> atomic in-place rekey (installs the content-verify gate/provenance) -> settle
-> re-verify any destination echo latched before the rekey. On failure the store
is untouched. Verification now triggers off the on-OpenFile provenance (consumed
on resolve) and the watcher latches pre-rekey destination events. Wired into all
five call sites; old renameOpenTabsPathOnDisk + separate remap removed from them.
2751 tests green. (TTL self-move registry now dead; removed next.)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil
2026-07-20 17:33:50 -07:00
co-authored by Orca
parent e45f4d4f31
commit fb2aa87796
8 changed files with 256 additions and 89 deletions
@@ -60,7 +60,6 @@ function EditorPanelInner({
const setMarkdownFrontmatterVisible = useAppStore((s) => s.setMarkdownFrontmatterVisible)
const markdownTableOfContentsVisible = useAppStore((s) => s.markdownTableOfContentsVisible)
const setMarkdownTableOfContentsVisible = useAppStore((s) => s.setMarkdownTableOfContentsVisible)
const closeFile = useAppStore((s) => s.closeFile)
const clearUntitled = useAppStore((s) => s.clearUntitled)
const editorDraftSelector = useMemo(
() => createEditorPanelDraftSelector(activeFile),
@@ -127,7 +126,7 @@ function EditorPanelInner({
requestRenameForFile,
closeRenameDialog,
handleRenameConfirm
} = useUntitledFileRename({ openFiles, closeFile, openFile, clearUntitled })
} = useUntitledFileRename({ openFiles, clearUntitled })
useClosedEditorTabCleanup(openFiles)
useMarkdownPreviewShortcut({ activeFile, panelRef, openMarkdownPreview })
@@ -1,26 +1,16 @@
import { useCallback, useState } from 'react'
import { getConnectionId } from '@/lib/connection-context'
import { detectLanguage } from '@/lib/language-detect'
import { dirname, joinPath } from '@/lib/path'
import { useAppStore } from '@/store'
import type { OpenFile } from '@/store/slices/editor'
import { createRuntimePath, runtimePathExists } from '@/runtime/runtime-file-client'
import { renameOpenTabsPathOnDisk } from '@/lib/rename-open-editor-tabs-path'
import { executeOpenEditorPathMove } from '@/lib/execute-open-editor-path-move'
import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client'
import { requestEditorFileSave, requestEditorSaveQuiesce } from './editor-autosave'
import { getUntitledFileRoot } from './untitled-file-rename-path'
type UseUntitledFileRenameParams = {
openFiles: OpenFile[]
closeFile: (filePath: string) => void
openFile: (file: {
filePath: string
relativePath: string
worktreeId: string
runtimeEnvironmentId?: string | null
language: string
mode: 'edit'
}) => void
clearUntitled: (fileId: string) => void
}
@@ -35,8 +25,6 @@ type UseUntitledFileRenameResult = {
export function useUntitledFileRename({
openFiles,
closeFile,
openFile,
clearUntitled
}: UseUntitledFileRenameParams): UseUntitledFileRenameResult {
const [renameDialogFileId, setRenameDialogFileId] = useState<string | null>(null)
@@ -97,24 +85,22 @@ export function useUntitledFileRename({
}
try {
await renameOpenTabsPathOnDisk(fileContext, oldPath, newPath)
// Retarget the untitled tab in place (the coordinator's rekey consumes
// its untitled status on this explicit rename), instead of close+reopen.
await executeOpenEditorPathMove({
context: fileContext,
fromPath: oldPath,
toPath: newPath,
worktreeId: renameDialogFile.worktreeId,
worktreePath: worktreeRoot
})
} catch (err) {
setRenameError(err instanceof Error ? err.message : 'Failed to rename file')
return
}
closeFile(renameDialogFile.id)
openFile({
filePath: newPath,
relativePath: newRelPath,
worktreeId: renameDialogFile.worktreeId,
runtimeEnvironmentId: renameDialogFile.runtimeEnvironmentId,
language: detectLanguage(newRelPath),
mode: 'edit'
})
closeRenameDialog()
},
[clearUntitled, closeFile, closeRenameDialog, openFile, renameDialogFile]
[clearUntitled, closeRenameDialog, renameDialogFile]
)
return {
@@ -12,10 +12,8 @@ import {
readWorkspaceFileDragPaths,
WORKSPACE_FILE_PATH_MIME
} from '@/lib/workspace-file-drag'
import { remapOpenEditorTabsForPathChange } from '@/lib/remap-open-editor-tabs-for-path-change'
import { requestEditorSaveQuiesce } from '@/components/editor/editor-autosave'
import { executeOpenEditorPathMove } from '@/lib/execute-open-editor-path-move'
import { commitFileExplorerOp } from './fileExplorerUndoRedo'
import { renameOpenTabsPathOnDisk } from '@/lib/rename-open-editor-tabs-path'
import { getRightSidebarWorktreeRuntimeSettings } from './file-explorer-runtime-owner'
function extractIpcErrorMessage(err: unknown, fallback: string): string {
@@ -105,8 +103,6 @@ export function useFileExplorerDragDrop({
refreshDir,
scrollRef
}: UseFileExplorerDragDropParams): UseFileExplorerDragDropResult {
const openFiles = useAppStore((s) => s.openFiles)
const [isRootDragOver, setIsRootDragOver] = useState(false)
const rootDragCounterRef = useRef(0)
const [dropTargetDir, setDropTargetDir] = useState<string | null>(null)
@@ -211,50 +207,45 @@ export function useFileExplorerDragDrop({
}
const newPath = joinPath(destDir, fileName)
const remapOpenTabsForMovedPath = (fromPath: string, toPath: string): void =>
remapOpenEditorTabsForPathChange({
fromPath,
toPath,
worktreePath,
worktreeId: activeWorktreeId
})
const run = async (): Promise<void> => {
const filesToMove = openFiles.filter((file) => {
if (file.filePath === sourcePath) {
return true
}
return (
file.filePath.startsWith(`${sourcePath}/`) ||
file.filePath.startsWith(`${sourcePath}\\`)
)
})
// Why: a file move changes the write target path. Let any in-flight
// autosave settle first, then carry draft state forward to the new tab
// id so explorer drag-and-drop does not silently drop unsaved edits.
await Promise.all(filesToMove.map((file) => requestEditorSaveQuiesce({ fileId: file.id })))
const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined
const fileContext = {
settings: getRightSidebarWorktreeRuntimeSettings(activeWorktreeId),
worktreeId: activeWorktreeId,
worktreePath,
connectionId
}
// The coordinator quiesces saves, retargets the open sessions in place,
// and settles the move as one transaction (was: quiesce + rename + remap).
try {
const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined
const fileContext = {
settings: getRightSidebarWorktreeRuntimeSettings(activeWorktreeId),
await executeOpenEditorPathMove({
context: fileContext,
fromPath: sourcePath,
toPath: newPath,
worktreeId: activeWorktreeId,
worktreePath,
connectionId
}
await renameOpenTabsPathOnDisk(fileContext, sourcePath, newPath)
worktreePath
})
commitFileExplorerOp({
undo: async () => {
await renameOpenTabsPathOnDisk(fileContext, newPath, sourcePath)
await executeOpenEditorPathMove({
context: fileContext,
fromPath: newPath,
toPath: sourcePath,
worktreeId: activeWorktreeId,
worktreePath
})
await Promise.all([refreshDir(destDir), refreshDir(sourceDir)])
remapOpenTabsForMovedPath(newPath, sourcePath)
},
redo: async () => {
await renameOpenTabsPathOnDisk(fileContext, sourcePath, newPath)
await executeOpenEditorPathMove({
context: fileContext,
fromPath: sourcePath,
toPath: newPath,
worktreeId: activeWorktreeId,
worktreePath
})
await Promise.all([refreshDir(sourceDir), refreshDir(destDir)])
remapOpenTabsForMovedPath(sourcePath, newPath)
}
})
} catch (err) {
@@ -262,11 +253,10 @@ export function useFileExplorerDragDrop({
return
}
await Promise.all([refreshDir(sourceDir), refreshDir(destDir)])
remapOpenTabsForMovedPath(sourcePath, newPath)
}
void run()
},
[worktreePath, activeWorktreeId, openFiles, refreshDir]
[worktreePath, activeWorktreeId, refreshDir]
)
const clearNativeDragState = useCallback(() => {
@@ -162,7 +162,8 @@ describe('self-move echo verification (content identity)', () => {
vi.mocked(useAppStore.getState).mockReturnValue({
openFiles: [file],
setExternalMutation,
setPendingLiveDiskVerification
setPendingLiveDiskVerification,
clearSelfMoveEcho: vi.fn()
} as never)
vi.mocked(getOpenFilesForExternalFileChange).mockReturnValue([file] as never)
}
@@ -23,7 +23,10 @@ import {
isRecentSelfMoveSource,
isRecentSelfMoveTarget
} from '@/components/editor/editor-self-move-registry'
import { isActiveMoveSourcePath } from '@/components/editor/editor-path-move-inflight'
import {
isActiveMoveSourcePath,
noteEditorPathMoveDestinationEvent
} from '@/components/editor/editor-path-move-inflight'
import type { FsChangedPayload } from '../../../shared/types'
import { findWorktreeById } from '@/store/slices/worktree-helpers'
import type { OpenFile } from '@/store/slices/editor'
@@ -650,8 +653,17 @@ export function createExternalWatchEventHandler(
relativePath,
runtimeEnvironmentId: target.runtimeEnvironmentId
}
const absolutePath = joinPath(notification.worktreePath, notification.relativePath)
const matching = getOpenFilesForExternalFileChange(openFilesSnapshot, notification)
if (matching.length === 0) {
// Latch a destination event that arrived before the rekey installed the
// tab (drag-drop's refresh-before-remap window), so the coordinator can
// content-verify it post-rekey instead of dropping it.
noteEditorPathMoveDestinationEvent(
target.worktreeId,
target.runtimeEnvironmentId,
absolutePath
)
// Why: notify the combined-diff tab so its section reloads. Its own
// dirty/section guards make a blanket reload safe, and there is no
// in-memory editor content to clobber, so self-write suppression is
@@ -661,17 +673,24 @@ export function createExternalWatchEventHandler(
}
continue
}
const absolutePath = joinPath(notification.worktreePath, notification.relativePath)
const dirtyMatches = matching.filter((f) => f.isDirty)
if (dirtyMatches.length > 0) {
// canAutoSaveOpenFile is the set of tabs that can hold unsaved edits —
// the tabs the changed-on-disk banner serves.
const dirtyIds = dirtyMatches.filter((f) => canAutoSaveOpenFile(f)).map((f) => f.id)
if (isRecentSelfMoveTarget(absolutePath, target.runtimeEnvironmentId)) {
// This path is a live self-move destination, so the event may be the
// move's own echo. Distinguish it from a real external write by disk
// identity, not by trusting the stamp: an echo leaves disk == the
// tab's baseline, a genuine write does not.
// A tab carrying move-echo provenance for this path (or, transitionally,
// a live TTL-registry target) may be seeing the move's own echo — settle
// it by disk identity: an echo leaves disk == the tab's baseline, a real
// external write does not.
const normalizedAbsolutePath = normalizeRuntimePathForComparison(absolutePath)
const isSelfMoveEcho =
dirtyMatches.some(
(f) =>
f.pendingSelfMoveEcho &&
normalizeRuntimePathForComparison(f.pendingSelfMoveEcho.targetPath) ===
normalizedAbsolutePath
) || isRecentSelfMoveTarget(absolutePath, target.runtimeEnvironmentId)
if (isSelfMoveEcho) {
scheduleSelfMoveEchoVerification(target, notification, dirtyIds)
} else {
// An external write on a dirty tab must not vanish silently (issue
@@ -802,6 +821,9 @@ type LiveMoveVerifyCandidate = {
fileId: string
baseline: string | undefined
generation: number
/** The move that installed the provenance; a newer move (different op) that
* re-homes the tab supersedes this verification even across a rekey. */
operationId?: string
}
// Resolves one candidate against the disk read. `diskSignature` is null for a
@@ -812,7 +834,7 @@ function resolveLiveMoveVerification(
diskSignature: string | null,
connectionId: string | undefined
): void {
const { fileId, baseline, generation } = candidate
const { fileId, baseline, generation, operationId } = candidate
if (liveMoveVerifyGeneration.get(fileId) !== generation) {
return // a newer verification owns this tab and its autosave gate
}
@@ -820,22 +842,57 @@ function resolveLiveMoveVerification(
const state = useAppStore.getState()
state.setPendingLiveDiskVerification(fileId, false)
const file = state.openFiles.find((f) => f.id === fileId)
// A save/reload/dismissal in the interim owns the newer state: skip if the tab
// resolved, or its baseline advanced past the one we captured.
// A save/reload/dismissal/newer-move in the interim owns the newer state: skip
// if the tab resolved, its baseline advanced, or a different move replaced the
// provenance we captured.
if (
!file ||
!file.isDirty ||
file.externalMutation === 'changed' ||
file.lastKnownDiskSignature !== baseline
file.lastKnownDiskSignature !== baseline ||
(operationId !== undefined && file.pendingSelfMoveEcho?.operationId !== operationId)
) {
return
}
// Consume the provenance: this echo is settled, so a later write at this path
// takes the normal changed-on-disk path.
state.clearSelfMoveEcho(fileId)
const isMoveEcho = baseline !== undefined && diskSignature === baseline
if (!isMoveEcho) {
markFileChangedOnDisk(state, file, { connectionId, origin: 'live' })
}
}
/**
* Drives content verification for tabs whose destination echo was LATCHED before
* the rekey installed them (the coordinator calls this after a successful move so
* a pre-rekey event isn't lost and the autosave gate can't strand).
*/
export function verifyLatchedMoveDestinations(
worktreePath: string,
connectionId: string | undefined,
fileIds: readonly string[]
): void {
const state = useAppStore.getState()
for (const fileId of fileIds) {
const file = state.openFiles.find((f) => f.id === fileId)
if (!file?.pendingSelfMoveEcho) {
continue
}
const runtimeEnvironmentId = file.runtimeEnvironmentId?.trim() || null
scheduleSelfMoveEchoVerification(
{ worktreeId: file.worktreeId, worktreePath, connectionId, runtimeEnvironmentId },
{
worktreeId: file.worktreeId,
worktreePath,
relativePath: file.relativePath,
runtimeEnvironmentId
},
[fileId]
)
}
}
// The move's own watcher echo leaves disk == the tab's disk baseline; a genuine
// external write does not. Read the destination once and settle each dirty tab
// by content identity. Autosave is suspended synchronously first so a write
@@ -858,7 +915,12 @@ function scheduleSelfMoveEchoVerification(
const generation = ++liveMoveVerifyCounter
liveMoveVerifyGeneration.set(fileId, generation)
state.setPendingLiveDiskVerification(fileId, true)
candidates.push({ fileId, baseline: file.lastKnownDiskSignature, generation })
candidates.push({
fileId,
baseline: file.lastKnownDiskSignature,
generation,
operationId: file.pendingSelfMoveEcho?.operationId
})
}
if (candidates.length === 0) {
return
@@ -0,0 +1,100 @@
import { useAppStore } from '@/store'
import { renameRuntimePath, type RuntimeFileOperationArgs } from '@/runtime/runtime-file-client'
import { requestEditorSaveQuiesce } from '@/components/editor/editor-autosave'
import {
beginEditorPathMove,
settleEditorPathMove
} from '@/components/editor/editor-path-move-inflight'
import {
isPathInsideOrEqual,
remapOpenEditorTabsForPathChange
} from '@/lib/remap-open-editor-tabs-for-path-change'
import { verifyLatchedMoveDestinations } from '@/hooks/useEditorExternalWatch'
import { normalizeAbsolutePathForComparison } from '@/components/right-sidebar/file-explorer-paths'
let moveOperationCounter = 0
/**
* Coordinates an Orca-owned move as one editor transaction: quiesce affected
* saves, register in-flight source suppression, run the on-disk rename, then
* atomically rekey the open sessions in place (installing the content-verify
* gate) and re-verify any destination echo that was latched before the rekey.
* On failure the store is untouched — only the suppression scope is released.
*
* Replaces the old renameOpenTabsPathOnDisk (self-move TTL) + separate remap.
*/
export async function executeOpenEditorPathMove(args: {
context: RuntimeFileOperationArgs
fromPath: string
toPath: string
worktreeId: string
worktreePath: string
}): Promise<void> {
const { context, fromPath, toPath, worktreeId, worktreePath } = args
const operationId = `editor-move-${(moveOperationCounter += 1)}`
const affected = useAppStore
.getState()
.openFiles.filter(
(f) => f.worktreeId === worktreeId && isPathInsideOrEqual(fromPath, f.filePath)
)
const newPathOf = (filePath: string): string => toPath + filePath.slice(fromPath.length)
// In-flight source suppression is scoped per runtime owner, since the same
// path can be open (and watched) under more than one owner.
const ownerSubOps: string[] = []
const owners = new Set(affected.map((f) => f.runtimeEnvironmentId?.trim() || null))
for (const owner of owners) {
const ownerFiles = affected.filter((f) => (f.runtimeEnvironmentId?.trim() || null) === owner)
const subOperationId = `${operationId}::${owner ?? 'local'}`
ownerSubOps.push(subOperationId)
beginEditorPathMove({
operationId: subOperationId,
worktreeId,
runtimeEnvironmentId: owner,
sourcePaths: ownerFiles.map((f) => f.filePath),
targetPaths: ownerFiles.map((f) => newPathOf(f.filePath))
})
}
// Let any in-flight autosave settle so a trailing write can't recreate the old
// path after the rename.
await Promise.all(affected.map((f) => requestEditorSaveQuiesce({ fileId: f.id })))
try {
await renameRuntimePath(context, fromPath, toPath)
} catch (err) {
for (const subOperationId of ownerSubOps) {
settleEditorPathMove(subOperationId)
}
throw err
}
// Commit: retarget the live sessions in place (the rekey installs the gate +
// provenance on dirty destinations), then settle the transaction.
remapOpenEditorTabsForPathChange({
fromPath,
toPath,
worktreePath,
worktreeId,
moveOperationId: operationId
})
const latchedTargets = new Set<string>()
for (const subOperationId of ownerSubOps) {
for (const target of settleEditorPathMove(subOperationId)) {
latchedTargets.add(target)
}
}
if (latchedTargets.size > 0) {
const latchedTabIds = useAppStore
.getState()
.openFiles.filter(
(f) =>
f.pendingSelfMoveEcho &&
latchedTargets.has(normalizeAbsolutePathForComparison(f.pendingSelfMoveEcho.targetPath))
)
.map((f) => f.id)
verifyLatchedMoveDestinations(worktreePath, context.connectionId, latchedTabIds)
}
}
+22 -8
View File
@@ -4,8 +4,7 @@ import { basename, dirname, joinPath } from '@/lib/path'
import { getConnectionId } from '@/lib/connection-context'
import { requestEditorSaveQuiesce } from '@/components/editor/editor-autosave'
import { commitFileExplorerOp } from '@/components/right-sidebar/fileExplorerUndoRedo'
import { remapOpenEditorTabsForPathChange } from '@/lib/remap-open-editor-tabs-for-path-change'
import { renameOpenTabsPathOnDisk } from '@/lib/rename-open-editor-tabs-path'
import { executeOpenEditorPathMove } from '@/lib/execute-open-editor-path-move'
/**
* Electron's ipcRenderer.invoke wraps errors as:
@@ -75,22 +74,37 @@ export async function renameFileOnDisk(args: RenameFileArgs): Promise<void> {
}
try {
await renameOpenTabsPathOnDisk(fileContext, oldPath, newPath)
remapOpenEditorTabsForPathChange({ fromPath: oldPath, toPath: newPath, worktreePath })
await executeOpenEditorPathMove({
context: fileContext,
fromPath: oldPath,
toPath: newPath,
worktreeId,
worktreePath
})
commitFileExplorerOp({
undo: async () => {
await renameOpenTabsPathOnDisk(fileContext, newPath, oldPath)
await executeOpenEditorPathMove({
context: fileContext,
fromPath: newPath,
toPath: oldPath,
worktreeId,
worktreePath
})
if (refreshDir) {
await refreshDir(parentDir)
}
remapOpenEditorTabsForPathChange({ fromPath: newPath, toPath: oldPath, worktreePath })
},
redo: async () => {
await renameOpenTabsPathOnDisk(fileContext, oldPath, newPath)
await executeOpenEditorPathMove({
context: fileContext,
fromPath: oldPath,
toPath: newPath,
worktreeId,
worktreePath
})
if (refreshDir) {
await refreshDir(parentDir)
}
remapOpenEditorTabsForPathChange({ fromPath: oldPath, toPath: newPath, worktreePath })
}
})
} catch (err) {
+15
View File
@@ -538,6 +538,8 @@ export type EditorSlice = {
clearPendingDiskBaselineVerification: (fileId: string) => void
setPendingDiskBaselineVerification: (fileId: string, value: boolean) => void
setPendingLiveDiskVerification: (fileId: string, value: boolean) => void
/** Consumes the move-echo provenance once its verification has settled. */
clearSelfMoveEcho: (fileId: string) => void
/** Atomically retargets open editor sessions across an Orca-owned move: one
* commit-only store update migrating every path-derived id + all id-keyed
* state, with no close/reopen lifecycle. Returns collision/stale without
@@ -2641,6 +2643,19 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
}
}),
clearSelfMoveEcho: (fileId) =>
set((s) => {
const file = s.openFiles.find((f) => f.id === fileId)
if (!file?.pendingSelfMoveEcho) {
return s
}
return {
openFiles: s.openFiles.map((f) =>
f.id === fileId ? { ...f, pendingSelfMoveEcho: undefined } : f
)
}
}),
rekeyOpenFilesForPathChange: ({ worktreeId, rekeys, moveOperationId }) => {
if (rekeys.length === 0) {
return { ok: true }