From fb2aa87796dbea7d35aceff6d15310880d39337b Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 20 Jul 2026 17:33:50 -0700 Subject: [PATCH] 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 --- .../src/components/editor/EditorPanel.tsx | 3 +- .../editor/useUntitledFileRename.ts | 36 ++----- .../right-sidebar/useFileExplorerDragDrop.ts | 72 ++++++------- .../useEditorExternalWatch-self-move.test.ts | 3 +- .../src/hooks/useEditorExternalWatch.ts | 86 ++++++++++++--- .../src/lib/execute-open-editor-path-move.ts | 100 ++++++++++++++++++ src/renderer/src/lib/rename-file.ts | 30 ++++-- src/renderer/src/store/slices/editor.ts | 15 +++ 8 files changed, 256 insertions(+), 89 deletions(-) create mode 100644 src/renderer/src/lib/execute-open-editor-path-move.ts diff --git a/src/renderer/src/components/editor/EditorPanel.tsx b/src/renderer/src/components/editor/EditorPanel.tsx index 43a9276b15e..d75fd9ec7dc 100644 --- a/src/renderer/src/components/editor/EditorPanel.tsx +++ b/src/renderer/src/components/editor/EditorPanel.tsx @@ -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 }) diff --git a/src/renderer/src/components/editor/useUntitledFileRename.ts b/src/renderer/src/components/editor/useUntitledFileRename.ts index ab89abac8ed..2453ab7af32 100644 --- a/src/renderer/src/components/editor/useUntitledFileRename.ts +++ b/src/renderer/src/components/editor/useUntitledFileRename.ts @@ -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(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 { diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts b/src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts index a9950ab217a..d2faa2282b0 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts @@ -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(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 => { - 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(() => { diff --git a/src/renderer/src/hooks/useEditorExternalWatch-self-move.test.ts b/src/renderer/src/hooks/useEditorExternalWatch-self-move.test.ts index 022eb380fdb..d6c95013f38 100644 --- a/src/renderer/src/hooks/useEditorExternalWatch-self-move.test.ts +++ b/src/renderer/src/hooks/useEditorExternalWatch-self-move.test.ts @@ -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) } diff --git a/src/renderer/src/hooks/useEditorExternalWatch.ts b/src/renderer/src/hooks/useEditorExternalWatch.ts index c8ea850c538..c8db2e3418e 100644 --- a/src/renderer/src/hooks/useEditorExternalWatch.ts +++ b/src/renderer/src/hooks/useEditorExternalWatch.ts @@ -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 diff --git a/src/renderer/src/lib/execute-open-editor-path-move.ts b/src/renderer/src/lib/execute-open-editor-path-move.ts new file mode 100644 index 00000000000..662aa796de8 --- /dev/null +++ b/src/renderer/src/lib/execute-open-editor-path-move.ts @@ -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 { + 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() + 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) + } +} diff --git a/src/renderer/src/lib/rename-file.ts b/src/renderer/src/lib/rename-file.ts index 6364a1ae9ce..544f52d9a49 100644 --- a/src/renderer/src/lib/rename-file.ts +++ b/src/renderer/src/lib/rename-file.ts @@ -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 { } 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) { diff --git a/src/renderer/src/store/slices/editor.ts b/src/renderer/src/store/slices/editor.ts index f5b3e752d9a..d32a0dba998 100644 --- a/src/renderer/src/store/slices/editor.ts +++ b/src/renderer/src/store/slices/editor.ts @@ -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 = (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 }