diff --git a/src/renderer/src/components/editor/editor-path-move-inflight.ts b/src/renderer/src/components/editor/editor-path-move-inflight.ts index cdba0e02b49..bf772044ebc 100644 --- a/src/renderer/src/components/editor/editor-path-move-inflight.ts +++ b/src/renderer/src/components/editor/editor-path-move-inflight.ts @@ -48,12 +48,22 @@ export function settleEditorPathMove(operationId: string): void { operations.delete(operationId) } +/** Cheap gate so watcher hot paths can skip per-file work when no move is live. */ +export function hasActiveEditorPathMoves(): boolean { + return operations.size > 0 +} + /** True when this delete is the source side of a live Orca-owned move. */ export function isActiveMoveSourcePath( worktreeId: string, runtimeEnvironmentId: string | null | undefined, absolutePath: string ): boolean { + // Fast path: this runs per deleted open-editor in the fs-watcher hot path, but a + // move is in flight only briefly and rarely. Skip the normalize regex when idle. + if (operations.size === 0) { + return false + } const normalizedPath = normalize(absolutePath) const scopedOwner = owner(runtimeEnvironmentId) for (const operation of operations.values()) { diff --git a/src/renderer/src/hooks/useEditorExternalWatch.ts b/src/renderer/src/hooks/useEditorExternalWatch.ts index 05ec688d6f4..fbffffcaea2 100644 --- a/src/renderer/src/hooks/useEditorExternalWatch.ts +++ b/src/renderer/src/hooks/useEditorExternalWatch.ts @@ -16,7 +16,10 @@ import { getRecentSelfWrite, type RecentSelfWrite } from '@/components/editor/editor-self-write-registry' -import { isActiveMoveSourcePath } from '@/components/editor/editor-path-move-inflight' +import { + hasActiveEditorPathMoves, + isActiveMoveSourcePath +} 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' @@ -418,20 +421,23 @@ export function createExternalWatchEventHandler( // Why: mark editor tabs deleted/renamed instead of closing them so the user keeps in-memory content; a paired create means rename, a lone delete is hard. // Why: snapshot openFiles once so the delete/rename helpers share a consistent view without N store reads per payload. const openFilesAtStart = useAppStore.getState().openFiles - const deletedOpenEditorIds = collectDeletedOpenEditorIds( + const deletedOpenEditorIdsRaw = collectDeletedOpenEditorIds( payload, target.worktreeId, target.runtimeEnvironmentId, openFilesAtStart - ).filter((fileId) => { - // Don't tombstone the source side of an in-flight Orca move (the tab is - // being re-homed, not deleted). - const file = openFilesAtStart.find((f) => f.id === fileId) - return ( - !file || - !isActiveMoveSourcePath(target.worktreeId, target.runtimeEnvironmentId, file.filePath) - ) - }) + ) + // Only pay the per-id lookup to suppress a move's own source-delete when a move + // is actually in flight; otherwise the whole delete batch stays O(deletes). + const deletedOpenEditorIds = hasActiveEditorPathMoves() + ? deletedOpenEditorIdsRaw.filter((fileId) => { + const file = openFilesAtStart.find((f) => f.id === fileId) + return ( + !file || + !isActiveMoveSourcePath(target.worktreeId, target.runtimeEnvironmentId, file.filePath) + ) + }) + : deletedOpenEditorIdsRaw // Why: correlate creates to deletes by basename to avoid mislabelling unrelated create+delete pairs as "renamed"; default to 'deleted' when we can't correlate. const hasPairedCreate = deletedOpenEditorIds.length > 0 && @@ -559,14 +565,19 @@ export function createExternalWatchEventHandler( const dirtyIds = dirtyMatches.filter((f) => canAutoSaveOpenFile(f)).map((f) => f.id) // A tab carrying move-echo provenance for this path 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 - ) + // the tab's baseline, a real external write does not. Only a tab WITH + // provenance can match, so skip the path normalize in the common + // external-write-to-dirty case where none carries it. + let isSelfMoveEcho = false + if (dirtyMatches.some((f) => f.pendingSelfMoveEcho)) { + const normalizedAbsolutePath = normalizeRuntimePathForComparison(absolutePath) + isSelfMoveEcho = dirtyMatches.some( + (f) => + f.pendingSelfMoveEcho && + normalizeRuntimePathForComparison(f.pendingSelfMoveEcho.targetPath) === + normalizedAbsolutePath + ) + } if (isSelfMoveEcho) { // A real destination fs event confirms the echo — consume the // provenance so a later genuine write takes the normal path.