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