From 3f435fba28f82ab16f2e92d0e25a1740c5cf5878 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 20 Oct 2026 05:58:58 -0700 Subject: [PATCH] perf(editor): watch only active, recent and at-risk workspaces (#30415) * perf(editor): watch only active, recent and at-risk workspaces Background workspaces no longer hold a whole-tree native file watch. The active workspace, the 3 most recently visited ones, and any showing a deleted/renamed tab keep theirs; every other workspace's subscription is released (local, WSL, SSH via the relay, and runtime hosts alike). When a dropped workspace is watched again, its tabs are verified against disk through the existing paths: one overflow resync once the watch is in place reloads clean tabs and refreshes git status, Files and markdown lists; dirty tabs go through the restored-tab disk check, which shows the changed-on-disk banner. Saves stay guarded by the host-side disk check, so nothing is held while unwatched. Also: watch requests are shared per directory and host; root events reach every workspace on that root and SSH payloads carry their connection id; late SSH and local/WSL capacity-retry installs send an overflow resync. * fix(editor): catch up combined reviews, deletions, late installs and Files trees after a rewatch - Overflow catch-up drops cached combined-diff bodies for the workspace and re-reads every loaded section of a mounted review (snapshot-backed too). - Clean edit tabs are probed on the owning host first; a file proven gone is marked deleted and keeps its last content instead of reloading into an error. - Dirty tabs are checked from the overflow itself, so the check runs after the watch is installed, including late capacity/SSH installs. - Local/WSL Files trees also take the renderer-synthesized catch-up. - Deleted/renamed-tab pins are capped at 3 beyond the active and recent set. * fix(editor): catch-up keeps unsaved combined-diff edits and drops stale existence answers - Cached Changes views reset only clean loaded sections; dirty sections keep their drafts and baselines (also for per-path invalidation). - A mounted review re-reads loaded and in-flight sections on catch-up. - Existence answers carry a per-tab generation; a newer probe or watch event for the tab supersedes an older answer. - A tab is marked deleted only on a per-path ENOENT, never on a whole-request not-found or an old host's scalar fallback. * fix(editor): re-check dirty tabs requested mid-read, prove runtime deletions, chunk existence probes - A disk check requested while one is in flight is followed by one fresh read (coalesced) instead of being answered by the older reply. - The restored-tab check probes runtime-owned files on their host; a per-path ENOENT marks the tab deleted. After a retry ceiling it stops, clears the suspension and shows the changed-on-disk banner. - Overflow existence probes are split into host-sized chunks, in order. * fix(editor): a reload that finds the file gone keeps its content; stale absence answers re-read - A tab that already read its file and now gets a path-specific ENOENT on reload is marked deleted and keeps its last content, with or without an existence probe, so older paired servers are covered. - The restored-tab check applies its request generation after the absence probe too, so an older answer cannot resolve a newer request. --- .../filesystem-watcher-dormant-rearm.test.ts | 3 +- src/main/ipc/filesystem-watcher-handlers.ts | 9 +- .../ipc/filesystem-watcher-local-capacity.ts | 9 + ...filesystem-watcher-native-capacity.test.ts | 7 + src/main/ipc/filesystem-watcher-paths.ts | 9 + .../filesystem-watcher-remote-batch.test.ts | 9 +- .../ipc/filesystem-watcher-remote-install.ts | 3 +- .../filesystem-watcher-remote-rearm.test.ts | 3 +- .../ipc/filesystem-watcher-remote-removal.ts | 3 +- .../ipc/filesystem-watcher-remote-resync.ts | 4 +- .../ipc/filesystem-watcher-remote-retry.ts | 3 +- ...filesystem-watcher-terminal-resync.test.ts | 9 +- src/main/ipc/filesystem-watcher.test.ts | 24 +- ...ombined-diff-section-revalidation.test.tsx | 69 ++++ .../use-combined-diff-section-revalidation.ts | 38 ++- .../remember-view/combined-diff-view-cache.ts | 67 +++- .../editor/editor-deleted-file-reload.test.ts | 86 +++++ .../editor/editor-deleted-file-reload.ts | 54 +++ ...restored-tab-conflict-scan.recheck.test.ts | 131 ++++++++ .../editor-restored-tab-conflict-scan.ts | 87 ++++- .../editor/useEditorPanelFileContentLoader.ts | 24 +- .../useFileExplorerWatch.catch-up.test.tsx | 78 +++++ .../useFileExplorerWatch.host-root.test.tsx | 2 +- .../right-sidebar/useFileExplorerWatch.ts | 16 +- ...tor-external-watch-background-trim.test.ts | 243 ++++++++++++++ ...tor-external-watch-event-reconciliation.ts | 86 ++--- .../editor-external-watch-local-roots.ts | 75 +++++ .../editor-external-watch-overflow.test.ts | 200 +++++++++++ .../hooks/editor-external-watch-overflow.ts | 202 ++++++++++++ .../src/hooks/editor-external-watch-scope.ts | 61 ++++ .../editor-external-watch-target-rows.ts | 31 ++ .../hooks/editor-external-watch-targets.ts | 152 +++++---- .../hooks/editor-runtime-file-watch.test.ts | 2 + .../src/hooks/editor-runtime-file-watch.ts | 10 +- ...itorExternalWatch-background-trim.test.tsx | 270 +++++++++++++++ ...seEditorExternalWatch-shared-root.test.tsx | 212 ++++++++++++ ...EditorExternalWatch-subscriptions.test.tsx | 10 +- ...seEditorExternalWatch-switch-back.test.tsx | 311 ++++++++++++++++++ .../useEditorExternalWatch-targets.test.ts | 4 + .../src/hooks/useEditorExternalWatch.test.ts | 109 ------ .../src/hooks/useEditorExternalWatch.ts | 114 ++++--- .../src/hooks/worktree-file-change-event.ts | 12 + .../src/runtime/runtime-file-watch-client.ts | 23 +- .../runtime-path-existence-batch.test.ts | 29 ++ .../runtime/runtime-path-existence-batch.ts | 33 +- src/shared/filesystem-entry-types.ts | 2 + 46 files changed, 2610 insertions(+), 328 deletions(-) create mode 100644 src/renderer/src/components/editor/combined-diff/load-sections/use-combined-diff-section-revalidation.test.tsx create mode 100644 src/renderer/src/components/editor/editor-deleted-file-reload.test.ts create mode 100644 src/renderer/src/components/editor/editor-deleted-file-reload.ts create mode 100644 src/renderer/src/components/editor/editor-restored-tab-conflict-scan.recheck.test.ts create mode 100644 src/renderer/src/components/right-sidebar/useFileExplorerWatch.catch-up.test.tsx create mode 100644 src/renderer/src/hooks/editor-external-watch-background-trim.test.ts create mode 100644 src/renderer/src/hooks/editor-external-watch-local-roots.ts create mode 100644 src/renderer/src/hooks/editor-external-watch-overflow.test.ts create mode 100644 src/renderer/src/hooks/editor-external-watch-overflow.ts create mode 100644 src/renderer/src/hooks/editor-external-watch-scope.ts create mode 100644 src/renderer/src/hooks/useEditorExternalWatch-background-trim.test.tsx create mode 100644 src/renderer/src/hooks/useEditorExternalWatch-shared-root.test.tsx create mode 100644 src/renderer/src/hooks/useEditorExternalWatch-switch-back.test.tsx diff --git a/src/main/ipc/filesystem-watcher-dormant-rearm.test.ts b/src/main/ipc/filesystem-watcher-dormant-rearm.test.ts index e3fcc8e40f0..c090959e331 100644 --- a/src/main/ipc/filesystem-watcher-dormant-rearm.test.ts +++ b/src/main/ipc/filesystem-watcher-dormant-rearm.test.ts @@ -30,7 +30,8 @@ const WORKTREE_PATH = '/home/me/repo' const ARGS = { worktreePath: WORKTREE_PATH, connectionId: 'conn-1' } const OVERFLOW_PAYLOAD = { worktreePath: WORKTREE_PATH, - events: [{ kind: 'overflow', absolutePath: WORKTREE_PATH }] + events: [{ kind: 'overflow', absolutePath: WORKTREE_PATH }], + connectionId: 'conn-1' } const RETRY_GIVE_UP_MS = 61_000 const DORMANT_FIRST_MS = 60_000 diff --git a/src/main/ipc/filesystem-watcher-handlers.ts b/src/main/ipc/filesystem-watcher-handlers.ts index 923f7596329..fc5151b7f8f 100644 --- a/src/main/ipc/filesystem-watcher-handlers.ts +++ b/src/main/ipc/filesystem-watcher-handlers.ts @@ -62,7 +62,14 @@ export function registerFilesystemWatcherHandlers(): void { `[filesystem-watcher] SSH filesystem provider unavailable; retrying watch for ${args.worktreePath} on connection ${args.connectionId}` ) } - scheduleRemoteWatcherRetry(event.sender, args.connectionId, args.worktreePath) + // Why: writes before the retry lands were never reported, and editor tabs may be stale from an earlier unwatched spell. + scheduleRemoteWatcherRetry( + event.sender, + args.connectionId, + args.worktreePath, + undefined, + true + ) return } return diff --git a/src/main/ipc/filesystem-watcher-local-capacity.ts b/src/main/ipc/filesystem-watcher-local-capacity.ts index a66f7822b14..39343efaf98 100644 --- a/src/main/ipc/filesystem-watcher-local-capacity.ts +++ b/src/main/ipc/filesystem-watcher-local-capacity.ts @@ -7,6 +7,7 @@ import { registerWatcherSenderCleanup } from './filesystem-watcher-listener-lifecycle' import { isWatcherRemovalInProgressError } from './watcher-removal-gate' +import type { FsChangedPayload } from '../../shared/filesystem-entry-types' export type SubscribeLocalWatcher = (worktreePath: string, sender: WebContents) => Promise @@ -42,6 +43,14 @@ export function scheduleLocalCapacityRetry( console.error(`[filesystem-watcher] capacity retry failed for ${rootKey}:`, error) } }) + // Why: the IPC call returned before this install, so writes in between were never reported. + const root = watcherLifecycleState.watchedRoots.get(rootKey) + if (root?.listeners.has(listener.id) && !listener.isDestroyed()) { + listener.send('fs:changed', { + worktreePath: root.rootPath, + events: [{ kind: 'overflow', absolutePath: root.rootPath }] + } satisfies FsChangedPayload) + } }) ) }) diff --git a/src/main/ipc/filesystem-watcher-native-capacity.test.ts b/src/main/ipc/filesystem-watcher-native-capacity.test.ts index f70bf390499..b87729ea281 100644 --- a/src/main/ipc/filesystem-watcher-native-capacity.test.ts +++ b/src/main/ipc/filesystem-watcher-native-capacity.test.ts @@ -81,6 +81,13 @@ describe('native filesystem watcher capacity recovery', () => { releases.pop()?.() await vi.waitFor(() => expect(subscribeViaWatcherProcessMock).toHaveBeenCalledTimes(2)) + // Why: the IPC call returned before this install, so a write in between needs a resync. + await vi.waitFor(() => + expect(sender.send).toHaveBeenCalledWith('fs:changed', { + worktreePath: '/tmp/native-capacity-root', + events: [{ kind: 'overflow', absolutePath: '/tmp/native-capacity-root' }] + }) + ) releases.forEach((release) => release()) }) diff --git a/src/main/ipc/filesystem-watcher-paths.ts b/src/main/ipc/filesystem-watcher-paths.ts index 44cb2cc00e6..1b5aae57494 100644 --- a/src/main/ipc/filesystem-watcher-paths.ts +++ b/src/main/ipc/filesystem-watcher-paths.ts @@ -37,3 +37,12 @@ export function normalizeWatcherEventPath(eventPath: string): string { export function getRemoteWatcherKey(connectionId: string, worktreePath: string): string { return JSON.stringify([connectionId, normalizeRuntimePathForComparison(worktreePath)]) } + +export function getRemoteWatcherKeyConnectionId(key: string): string | undefined { + try { + const parsed: unknown = JSON.parse(key) + return Array.isArray(parsed) && typeof parsed[0] === 'string' ? parsed[0] : undefined + } catch { + return undefined + } +} diff --git a/src/main/ipc/filesystem-watcher-remote-batch.test.ts b/src/main/ipc/filesystem-watcher-remote-batch.test.ts index c7e33babf0b..ecc5bb83f36 100644 --- a/src/main/ipc/filesystem-watcher-remote-batch.test.ts +++ b/src/main/ipc/filesystem-watcher-remote-batch.test.ts @@ -81,7 +81,8 @@ describe('remote filesystem watcher batching', () => { expect(sender.send).toHaveBeenCalledTimes(1) expect(sender.send).toHaveBeenCalledWith('fs:changed', { worktreePath: WORKTREE_PATH, - events: [{ kind: 'update', absolutePath: `${WORKTREE_PATH}/a.ts` }] + events: [{ kind: 'update', absolutePath: `${WORKTREE_PATH}/a.ts` }], + connectionId: 'conn-1' }) await closeAllWatchers() vi.useRealTimers() @@ -103,7 +104,8 @@ describe('remote filesystem watcher batching', () => { expect(sender.send).toHaveBeenCalledTimes(1) expect(sender.send).toHaveBeenCalledWith('fs:changed', { worktreePath: WORKTREE_PATH, - events: [{ kind: 'delete', absolutePath: `${WORKTREE_PATH}/dist/app.js` }] + events: [{ kind: 'delete', absolutePath: `${WORKTREE_PATH}/dist/app.js` }], + connectionId: 'conn-1' }) await closeAllWatchers() vi.useRealTimers() @@ -125,7 +127,8 @@ describe('remote filesystem watcher batching', () => { expect(sender.send).toHaveBeenCalledTimes(1) expect(sender.send).toHaveBeenCalledWith('fs:changed', { worktreePath: WORKTREE_PATH, - events: [{ kind: 'overflow', absolutePath: WORKTREE_PATH }] + events: [{ kind: 'overflow', absolutePath: WORKTREE_PATH }], + connectionId: 'conn-1' }) await closeAllWatchers() vi.useRealTimers() diff --git a/src/main/ipc/filesystem-watcher-remote-install.ts b/src/main/ipc/filesystem-watcher-remote-install.ts index dc601c925a6..cc6f3efeba2 100644 --- a/src/main/ipc/filesystem-watcher-remote-install.ts +++ b/src/main/ipc/filesystem-watcher-remote-install.ts @@ -189,7 +189,8 @@ async function doInstallRemoteWatcher( try { listener.send('fs:changed', { worktreePath, - events + events, + connectionId } satisfies FsChangedPayload) } catch (err) { // Why: batching moved this send out of the SSH mux's notification try/catch into a bare diff --git a/src/main/ipc/filesystem-watcher-remote-rearm.test.ts b/src/main/ipc/filesystem-watcher-remote-rearm.test.ts index 77c17c0949c..00caaec49e3 100644 --- a/src/main/ipc/filesystem-watcher-remote-rearm.test.ts +++ b/src/main/ipc/filesystem-watcher-remote-rearm.test.ts @@ -80,7 +80,8 @@ describe('remote filesystem watcher re-arm', () => { expect(retryWatchMock).toHaveBeenCalledTimes(1) expect(senderOne.send).toHaveBeenCalledWith('fs:changed', { worktreePath: '/home/me/repo', - events: [{ kind: 'overflow', absolutePath: '/home/me/repo' }] + events: [{ kind: 'overflow', absolutePath: '/home/me/repo' }], + connectionId: 'conn-1' }) warnSpy.mockRestore() diff --git a/src/main/ipc/filesystem-watcher-remote-removal.ts b/src/main/ipc/filesystem-watcher-remote-removal.ts index 94da50724b3..3250bb9c9bc 100644 --- a/src/main/ipc/filesystem-watcher-remote-removal.ts +++ b/src/main/ipc/filesystem-watcher-remote-removal.ts @@ -118,7 +118,8 @@ export async function restoreRemoteWatcherAfterFailedRemoval( } sender.send('fs:changed', { worktreePath, - events: [{ kind: 'overflow', absolutePath: worktreePath }] + events: [{ kind: 'overflow', absolutePath: worktreePath }], + connectionId } satisfies FsChangedPayload) } // Why: an overlapping retried removal may have re-suspended owners into this same entry. diff --git a/src/main/ipc/filesystem-watcher-remote-resync.ts b/src/main/ipc/filesystem-watcher-remote-resync.ts index 918b16e6795..dc0637efb86 100644 --- a/src/main/ipc/filesystem-watcher-remote-resync.ts +++ b/src/main/ipc/filesystem-watcher-remote-resync.ts @@ -5,6 +5,7 @@ import { watcherLifecycleState } from './filesystem-watcher-lifecycle-state' import { isCurrentDesiredRemoteWatcher } from './filesystem-watcher-remote-desired' +import { getRemoteWatcherKeyConnectionId } from './filesystem-watcher-paths' function flushRemoteWatcherResync(key: string): void { const state = watcherLifecycleState.remoteWatcherResyncStates.get(key) @@ -30,7 +31,8 @@ function flushRemoteWatcherResync(key: string): void { try { listener.send('fs:changed', { worktreePath: state.worktreePath, - events: [{ kind: 'overflow', absolutePath: state.worktreePath }] + events: [{ kind: 'overflow', absolutePath: state.worktreePath }], + connectionId: getRemoteWatcherKeyConnectionId(key) } satisfies FsChangedPayload) sent = true } catch (error) { diff --git a/src/main/ipc/filesystem-watcher-remote-retry.ts b/src/main/ipc/filesystem-watcher-remote-retry.ts index 02c797e9c75..08be5428745 100644 --- a/src/main/ipc/filesystem-watcher-remote-retry.ts +++ b/src/main/ipc/filesystem-watcher-remote-retry.ts @@ -82,7 +82,8 @@ export function scheduleRemoteWatcherRetryCore( ) listener.send('fs:changed', { worktreePath, - events: [{ kind: 'overflow', absolutePath: worktreePath }] + events: [{ kind: 'overflow', absolutePath: worktreePath }], + connectionId } satisfies FsChangedPayload) } // Why: overflow only refreshes once — without this the watch stays dead until the app restarts. diff --git a/src/main/ipc/filesystem-watcher-terminal-resync.test.ts b/src/main/ipc/filesystem-watcher-terminal-resync.test.ts index e18956dae05..3c6b9c0e041 100644 --- a/src/main/ipc/filesystem-watcher-terminal-resync.test.ts +++ b/src/main/ipc/filesystem-watcher-terminal-resync.test.ts @@ -35,7 +35,8 @@ const WORKTREE_PATH = '/home/me/repo' const ARGS = { worktreePath: WORKTREE_PATH, connectionId: 'conn-1' } const OVERFLOW_PAYLOAD = { worktreePath: WORKTREE_PATH, - events: [{ kind: 'overflow', absolutePath: WORKTREE_PATH }] + events: [{ kind: 'overflow', absolutePath: WORKTREE_PATH }], + connectionId: 'conn-1' } type MockSender = { @@ -131,7 +132,7 @@ describe('remote filesystem watcher terminal retry resync', () => { expect(senderTwo.send).toHaveBeenCalledTimes(1) }) - it('does not resync a fast retry for an initial setup failure', async () => { + it('resyncs once a retried initial setup installs', async () => { vi.useFakeTimers() vi.spyOn(console, 'warn').mockImplementation(() => {}) const sender = createSender(1) @@ -145,7 +146,9 @@ describe('remote filesystem watcher terminal retry resync', () => { await vi.advanceTimersByTimeAsync(1_000) expect(watchMock).toHaveBeenCalledTimes(2) - expect(sender.send).not.toHaveBeenCalled() + // Why: writes before the retry landed were never reported, so the renderer must re-read. + await vi.advanceTimersByTimeAsync(5_000) + expect(sender.send).toHaveBeenCalledWith('fs:changed', OVERFLOW_PAYLOAD) }) it('lets provider registration supersede a pending terminal retry', async () => { diff --git a/src/main/ipc/filesystem-watcher.test.ts b/src/main/ipc/filesystem-watcher.test.ts index c932950e1f9..033624dabcf 100644 --- a/src/main/ipc/filesystem-watcher.test.ts +++ b/src/main/ipc/filesystem-watcher.test.ts @@ -186,7 +186,8 @@ describe('registerFilesystemWatcherHandlers', () => { await emitRemote(onEvents, [{ path: '/home/me/repo/file.txt', type: 'update' }]) expect(sendMock).toHaveBeenCalledWith('fs:changed', { worktreePath: '/home/me/repo', - events: [{ path: '/home/me/repo/file.txt', type: 'update' }] + events: [{ path: '/home/me/repo/file.txt', type: 'update' }], + connectionId: 'conn-1' }) warnSpy.mockRestore() handlers['fs:unwatchWorktree']( @@ -282,7 +283,8 @@ describe('registerFilesystemWatcherHandlers', () => { expect(senderOne.send).not.toHaveBeenCalled() expect(senderTwo.send).toHaveBeenCalledWith('fs:changed', { worktreePath: '/home/me/repo', - events: [{ kind: 'update', absolutePath: '/home/me/repo/file.ts' }] + events: [{ kind: 'update', absolutePath: '/home/me/repo/file.ts' }], + connectionId: 'conn-1' }) staleTerminal(new Error('late stale failure')) @@ -313,7 +315,8 @@ describe('registerFilesystemWatcherHandlers', () => { await vi.waitFor(() => expect(sender.send).toHaveBeenCalledWith('fs:changed', { worktreePath: '/home/me/repo', - events: [{ kind: 'overflow', absolutePath: '/home/me/repo' }] + events: [{ kind: 'overflow', absolutePath: '/home/me/repo' }], + connectionId: 'conn-1' }) ) @@ -321,7 +324,8 @@ describe('registerFilesystemWatcherHandlers', () => { await emitRemote(reinstalledEvents, [{ kind: 'update', absolutePath: '/home/me/repo/file.ts' }]) expect(sender.send).toHaveBeenCalledWith('fs:changed', { worktreePath: '/home/me/repo', - events: [{ kind: 'update', absolutePath: '/home/me/repo/file.ts' }] + events: [{ kind: 'update', absolutePath: '/home/me/repo/file.ts' }], + connectionId: 'conn-1' }) await closeAllWatchers() @@ -370,7 +374,8 @@ describe('registerFilesystemWatcherHandlers', () => { expect(retryWatchMock).toHaveBeenCalledTimes(1) expect(sender.send).toHaveBeenCalledWith('fs:changed', { worktreePath: '/home/me/repo', - events: [{ kind: 'overflow', absolutePath: '/home/me/repo' }] + events: [{ kind: 'overflow', absolutePath: '/home/me/repo' }], + connectionId: 'conn-1' }) warnSpy.mockRestore() @@ -441,7 +446,8 @@ describe('registerFilesystemWatcherHandlers', () => { for (const sender of [senderOne, senderTwo]) { expect(sender.send).toHaveBeenCalledWith('fs:changed', { worktreePath: '/home/me/repo', - events: [{ kind: 'overflow', absolutePath: '/home/me/repo' }] + events: [{ kind: 'overflow', absolutePath: '/home/me/repo' }], + connectionId: 'conn-1' }) } @@ -570,13 +576,15 @@ describe('registerFilesystemWatcherHandlers', () => { expect(watchMock).toHaveBeenCalledTimes(2) expect(sender.send).toHaveBeenCalledWith('fs:changed', { worktreePath: '/home/me/repo', - events: [{ kind: 'overflow', absolutePath: '/home/me/repo' }] + events: [{ kind: 'overflow', absolutePath: '/home/me/repo' }], + connectionId: 'conn-1' }) const replacementEvents = watchMock.mock.calls[1][1] as (events: unknown[]) => void await emitRemote(replacementEvents, [{ kind: 'update', absolutePath: '/home/me/repo/file.ts' }]) expect(sender.send).toHaveBeenLastCalledWith('fs:changed', { worktreePath: '/home/me/repo', - events: [{ kind: 'update', absolutePath: '/home/me/repo/file.ts' }] + events: [{ kind: 'update', absolutePath: '/home/me/repo/file.ts' }], + connectionId: 'conn-1' }) }) diff --git a/src/renderer/src/components/editor/combined-diff/load-sections/use-combined-diff-section-revalidation.test.tsx b/src/renderer/src/components/editor/combined-diff/load-sections/use-combined-diff-section-revalidation.test.tsx new file mode 100644 index 00000000000..cedf5ea750d --- /dev/null +++ b/src/renderer/src/components/editor/combined-diff/load-sections/use-combined-diff-section-revalidation.test.tsx @@ -0,0 +1,69 @@ +// @vitest-environment happy-dom +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import { ORCA_WORKTREE_FILE_CHANGE_EVENT } from '@/hooks/worktree-file-change-event' +import type { OpenFile } from '@/store/slices/editor' +import type { FsChangedPayload } from '../../../../../../shared/filesystem-entry-types' +import { useCombinedDiffSectionRevalidation } from './use-combined-diff-section-revalidation' + +const review: OpenFile = { + id: 'combined', + worktreeId: 'wt', + filePath: '/repo', + relativePath: 'Changes', + language: 'plaintext', + isDirty: false, + mode: 'diff', + diffSource: 'combined-uncommitted' +} + +function renderReview(): ReturnType { + const requestSectionReload = vi.fn() + renderHook(() => + useCombinedDiffSectionRevalidation({ + file: review, + gitStatusEntries: [], + // Why: index 3 is mid-read; that read may predate the missed write. + registry: { + loadedIndicesRef: { current: new Set([0, 2]) }, + loadingIndicesRef: { current: new Set([2, 3]) } + }, + requestSectionReload, + sectionIndexByKeyRef: { current: new Map() }, + sectionEntries: [], + // Why: snapshot-backed reviews skip status-driven reloads, so only the catch-up can refresh them. + shouldAutoReloadFromGitStatus: false, + treeMode: 'uncommitted' + }) + ) + return requestSectionReload +} + +function dispatch(payload: FsChangedPayload, runtimeEnvironmentId: string | null = null): void { + act(() => { + window.dispatchEvent( + new CustomEvent(ORCA_WORKTREE_FILE_CHANGE_EVENT, { + detail: { payload, runtimeEnvironmentId } + }) + ) + }) +} + +afterEach(cleanup) + +it('re-reads every loaded and in-flight section of a mounted review when its workspace catches up', () => { + const requestSectionReload = renderReview() + dispatch({ worktreePath: '/repo', events: [{ kind: 'overflow', absolutePath: '/repo' }] }) + expect(requestSectionReload.mock.calls).toEqual([[0], [2], [3]]) +}) + +it('ignores other workspaces, other hosts and ordinary path events', () => { + const requestSectionReload = renderReview() + dispatch({ worktreePath: '/other', events: [{ kind: 'overflow', absolutePath: '/other' }] }) + dispatch( + { worktreePath: '/repo', events: [{ kind: 'overflow', absolutePath: '/repo' }] }, + 'env-1' + ) + dispatch({ worktreePath: '/repo', events: [{ kind: 'update', absolutePath: '/repo/a.ts' }] }) + expect(requestSectionReload).not.toHaveBeenCalled() +}) diff --git a/src/renderer/src/components/editor/combined-diff/load-sections/use-combined-diff-section-revalidation.ts b/src/renderer/src/components/editor/combined-diff/load-sections/use-combined-diff-section-revalidation.ts index 3f603131a7f..51516e9af04 100644 --- a/src/renderer/src/components/editor/combined-diff/load-sections/use-combined-diff-section-revalidation.ts +++ b/src/renderer/src/components/editor/combined-diff/load-sections/use-combined-diff-section-revalidation.ts @@ -5,6 +5,8 @@ import { ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT, type EditorPathMutationTarget } from '../../editor-autosave' +import { ORCA_WORKTREE_FILE_CHANGE_EVENT } from '@/hooks/worktree-file-change-event' +import { normalizeRuntimePathForComparison } from '../../../../../../shared/cross-platform-path' import { buildCombinedGitStatusSignature } from '../resolve-changes/combined-diff-git-status-signature' import { getCombinedDiffFileTreeSectionKey, @@ -26,7 +28,7 @@ export function useCombinedDiffSectionRevalidation({ }: { file: OpenFile gitStatusEntries: GitStatusEntry[] - registry: CombinedDiffSectionLoadRegistry + registry: Pick requestSectionReload: (index: number) => void sectionIndexByKeyRef: React.RefObject> // The entry set is structurally stable while individual section content/loading state changes. @@ -35,7 +37,7 @@ export function useCombinedDiffSectionRevalidation({ shouldAutoReloadFromGitStatus: boolean treeMode: CombinedDiffFileTreeMode }): string { - const { loadedIndicesRef } = registry + const { loadedIndicesRef, loadingIndicesRef } = registry const combinedGitStatusSignature = React.useMemo(() => { if (!shouldAutoReloadFromGitStatus) { return '' @@ -105,5 +107,37 @@ export function useCombinedDiffSectionRevalidation({ treeMode ]) + useEffect(() => { + if (treeMode !== 'all' && treeMode !== 'uncommitted') { + return + } + // Why: an overflow (or a switch-back catch-up) names no paths, and snapshot-backed reviews skip + // status-driven reloads, so re-read every loaded working-tree section of this workspace. + const handler = (event: WindowEventMap[typeof ORCA_WORKTREE_FILE_CHANGE_EVENT]): void => { + const { payload, runtimeEnvironmentId } = event.detail + if ( + (runtimeEnvironmentId?.trim() || null) !== (file.runtimeEnvironmentId?.trim() || null) || + normalizeRuntimePathForComparison(payload.worktreePath) !== + normalizeRuntimePathForComparison(file.filePath) || + !payload.events.some((change) => change.kind === 'overflow') + ) { + return + } + // Why: an in-flight read may have started before the missed write, so retry it too. + for (const index of new Set([...loadedIndicesRef.current, ...loadingIndicesRef.current])) { + requestSectionReload(index) + } + } + window.addEventListener(ORCA_WORKTREE_FILE_CHANGE_EVENT, handler) + return () => window.removeEventListener(ORCA_WORKTREE_FILE_CHANGE_EVENT, handler) + }, [ + file.filePath, + file.runtimeEnvironmentId, + loadedIndicesRef, + loadingIndicesRef, + requestSectionReload, + treeMode + ]) + return combinedGitStatusSignature } diff --git a/src/renderer/src/components/editor/combined-diff/remember-view/combined-diff-view-cache.ts b/src/renderer/src/components/editor/combined-diff/remember-view/combined-diff-view-cache.ts index 8f450488087..8c681426ab5 100644 --- a/src/renderer/src/components/editor/combined-diff/remember-view/combined-diff-view-cache.ts +++ b/src/renderer/src/components/editor/combined-diff/remember-view/combined-diff-view-cache.ts @@ -139,14 +139,75 @@ export function rememberCombinedDiffView( }) } -export function forgetCombinedDiffViewsShowingPath(relativePath: string): void { +// Why: a cached view may hold unsaved section edits; invalidation resets only clean, loaded sections so +// they reload on remount, and a dirty section's guarded save or conflict bar handles a disk change. +function invalidateCleanSections( + view: CachedCombinedDiffViewState, + isStale: (section: DiffSection) => boolean +): CachedCombinedDiffViewState { + let changed = false + const sections = view.sections.map((section) => { + if ( + section.dirty || + section.saveConflict || + section.loading || + section.loadOnDemand === true || + !isStale(section) + ) { + return section + } + changed = true + return { + ...section, + originalContent: '', + modifiedContent: '', + loading: true, + error: undefined, + diffResult: null, + largeDiffRenderLimit: null, + contentGeneration: (section.contentGeneration ?? 0) + 1 + } + }) + if (!changed) { + return view + } + return { + ...view, + sections, + loadedIndices: view.loadedIndices.filter((index) => sections[index] === view.sections[index]) + } +} + +function invalidateCachedViews( + matches: (memory: CombinedDiffViewMemory) => boolean, + isStale: (section: DiffSection) => boolean +): void { for (const [viewKey, memory] of memoryByViewKey) { - if (memory.view?.sections.some((section) => section.path === relativePath)) { - memoryByViewKey.set(viewKey, { ...memory, view: undefined }) + if (!memory.view || !matches(memory)) { + continue + } + const view = invalidateCleanSections(memory.view, isStale) + if (view !== memory.view) { + memoryByViewKey.set(viewKey, { ...memory, view }) } } } +export function forgetCombinedDiffViewsShowingPath(relativePath: string): void { + invalidateCachedViews( + () => true, + (section) => section.path === relativePath + ) +} + +/** An overflow names no paths, so every clean cached section of the workspace may be stale. */ +export function forgetCombinedDiffViewBodiesForWorktree(worktreeId: string): void { + invalidateCachedViews( + (memory) => memory.worktreeId === worktreeId, + () => true + ) +} + export function renameCombinedDiffViewOwner(oldWorktreeId: string, newWorktreeId: string): void { for (const [viewKey, memory] of memoryByViewKey) { if (memory.worktreeId === oldWorktreeId) { diff --git a/src/renderer/src/components/editor/editor-deleted-file-reload.test.ts b/src/renderer/src/components/editor/editor-deleted-file-reload.test.ts new file mode 100644 index 00000000000..093efbb9848 --- /dev/null +++ b/src/renderer/src/components/editor/editor-deleted-file-reload.test.ts @@ -0,0 +1,86 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { OpenFile } from '@/store/slices/editor' +import type { FileContent } from './editor-panel-content-types' + +const setExternalMutation = vi.fn() +vi.mock('@/store', () => ({ useAppStore: { getState: () => ({ setExternalMutation }) } })) + +import { + applyEditorFileLoadFailure, + isPathSpecificMissingFileError +} from './editor-deleted-file-reload' + +const FILE = '/repo/a.ts' +const tab: OpenFile = { + id: FILE, + worktreeId: 'wt', + filePath: FILE, + relativePath: 'a.ts', + language: 'typescript', + mode: 'edit', + isDirty: false, + lastKnownDiskSignature: 'sig' +} +const failure: FileContent = { content: '', isBinary: false, loadError: 'gone' } +// An older paired server has no existence probe; its read fails with the host's fs message. +const oldServerReadError = new Error( + "Error invoking runtime method 'files.read': ENOENT: no such file or directory, open '/repo/a.ts'" +) + +function apply(error: unknown, previous: Record, file = tab) { + let contents = previous + applyEditorFileLoadFailure({ + error, + fileId: FILE, + filePath: FILE, + tabs: [file], + failure, + setFileContents: (update) => { + contents = typeof update === 'function' ? update(contents) : update + } + }) + return contents +} + +describe('applyEditorFileLoadFailure', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('keeps the last content and marks the tab deleted when an old server reports the file gone on catch-up', () => { + const contents = apply(oldServerReadError, { + [FILE]: { content: 'last seen', isBinary: false, isStale: true } + }) + + expect(contents[FILE]).toEqual({ content: 'last seen', isBinary: false, isStale: false }) + expect(setExternalMutation).toHaveBeenCalledWith(FILE, 'deleted') + }) + + it.each([ + ['a whole-request not-found', new Error('worktree not found')], + ['another file', new Error("ENOENT: no such file or directory, open '/repo/b.ts'")], + ['a connection failure', new Error('Remote connection dropped')] + ])('shows the read error for %s', (_label, error) => { + const contents = apply(error, { [FILE]: { content: 'last seen', isBinary: false } }) + + expect(contents[FILE]).toBe(failure) + expect(setExternalMutation).not.toHaveBeenCalled() + }) + + it('never claims a tab that has not read the file yet or holds unsaved edits', () => { + expect(apply(oldServerReadError, {}, { ...tab, lastKnownDiskSignature: undefined })[FILE]).toBe( + failure + ) + expect(apply(oldServerReadError, {}, { ...tab, isDirty: true })[FILE]).toBe(failure) + expect(setExternalMutation).not.toHaveBeenCalled() + }) + + it('matches the path regardless of separator spelling', () => { + expect( + isPathSpecificMissingFileError( + new Error("ENOENT: no such file or directory, open 'C:\\repo\\a.ts'"), + 'C:/repo/a.ts' + ) + ).toBe(true) + }) +}) diff --git a/src/renderer/src/components/editor/editor-deleted-file-reload.ts b/src/renderer/src/components/editor/editor-deleted-file-reload.ts new file mode 100644 index 00000000000..444871b13e7 --- /dev/null +++ b/src/renderer/src/components/editor/editor-deleted-file-reload.ts @@ -0,0 +1,54 @@ +import type { Dispatch, SetStateAction } from 'react' +import { useAppStore } from '@/store' +import type { OpenFile } from '@/store/slices/editor' +import { normalizeRuntimePathForComparison } from '../../../../shared/cross-platform-path' +import type { FileContent } from './editor-panel-content-types' + +// Node's fs message names the syscall and the path; IPC and runtime RPC both carry it verbatim. +const MISSING_FILE_MESSAGE = /ENOENT: no such file or directory, \w+ '([^']+)'/ + +/** True only when the failure names this exact file as missing — never a worktree or host lookup. */ +export function isPathSpecificMissingFileError(error: unknown, filePath: string): boolean { + const message = error instanceof Error ? error.message : String(error) + const missingPath = MISSING_FILE_MESSAGE.exec(message)?.[1] + return ( + missingPath !== undefined && + normalizeRuntimePathForComparison(missingPath) === normalizeRuntimePathForComparison(filePath) + ) +} + +/** + * Shows a failed read. A reload of a tab that already read the file (catch-up, watch event, stale reveal) found it gone: + * mark the tab deleted as a watch event would, and keep its last content instead of an empty error. + * Works without any existence probe, so hosts that predate one are covered too. + */ +export function applyEditorFileLoadFailure(args: { + error: unknown + fileId: string + filePath: string + tabs: readonly OpenFile[] + failure: FileContent + setFileContents: Dispatch>> +}): void { + const tab = args.tabs.find((file) => file.id === args.fileId) + if ( + !tab || + tab.mode !== 'edit' || + tab.isDirty || + !tab.lastKnownDiskSignature || + !isPathSpecificMissingFileError(args.error, args.filePath) + ) { + args.setFileContents((prev) => ({ ...prev, [args.fileId]: args.failure })) + return + } + if (tab.externalMutation !== 'deleted' && tab.externalMutation !== 'renamed') { + useAppStore.getState().setExternalMutation(tab.id, 'deleted') + } + args.setFileContents((prev) => { + const existing = prev[args.fileId] + if (!existing || existing.loadError !== undefined) { + return { ...prev, [args.fileId]: args.failure } + } + return existing.isStale ? { ...prev, [args.fileId]: { ...existing, isStale: false } } : prev + }) +} diff --git a/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.recheck.test.ts b/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.recheck.test.ts new file mode 100644 index 00000000000..eb0288d93d0 --- /dev/null +++ b/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.recheck.test.ts @@ -0,0 +1,131 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + readRuntimeFileContent: vi.fn(), + runtimePathsExist: vi.fn() +})) + +vi.mock('@/runtime/runtime-file-read-client', () => ({ + readRuntimeFileContent: mocks.readRuntimeFileContent +})) +vi.mock('@/runtime/runtime-path-existence-batch', () => ({ + runtimePathsExist: mocks.runtimePathsExist +})) +vi.mock('@/lib/connection-context', () => ({ getConnectionIdForFile: () => undefined })) +vi.mock('@/lib/file-owner-runtime-target', () => { + const owner = { kind: 'environment', environmentId: 'env-1' } + return { + getRuntimeTargetForFileOwner: () => owner, + requireRuntimeTargetForFileOwner: () => owner + } +}) + +import { createTestStore } from '@/store/slices/store-test-helpers' +import { + attachRestoredTabConflictScan, + MAX_VERIFY_ATTEMPTS, + requestRestoredTabReverification +} from './editor-restored-tab-conflict-scan' +import { getDiskBaselineSignature } from './diff-content-signature' + +const FILE = '/repo/a.ts' +let store: ReturnType +let detach: () => void + +const tab = () => store.getState().openFiles.find((file) => file.id === FILE)! + +beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() + store = createTestStore() + store.getState().openFile({ + filePath: FILE, + relativePath: 'a.ts', + worktreeId: 'wt-1', + runtimeEnvironmentId: 'env-1', + language: 'typescript', + mode: 'edit' + }) + store.getState().setEditorDraft(FILE, 'unsaved') + store.getState().markFileDirty(FILE, true) + store.getState().setLastKnownDiskSignature(FILE, getDiskBaselineSignature('v1')) +}) + +afterEach(() => { + detach() + vi.useRealTimers() +}) + +it('reads once more when a new check is requested while a read is in flight', async () => { + const replies: ((content: string) => void)[] = [] + mocks.readRuntimeFileContent.mockImplementation( + () => new Promise((resolve) => replies.push((content) => resolve({ content, isBinary: false }))) + ) + store.getState().setPendingDiskBaselineVerification(FILE, true) + detach = attachRestoredTabConflictScan(store) + await vi.advanceTimersByTimeAsync(0) + expect(replies).toHaveLength(1) + + // A second catch-up lands mid-read, after an external write. + requestRestoredTabReverification(FILE) + requestRestoredTabReverification(FILE) + replies[0]('v1') + await vi.advanceTimersByTimeAsync(0) + expect(tab().pendingDiskBaselineVerification).toBe(true) + // Coalesced: two requests mid-read still cost one more read. + expect(replies).toHaveLength(2) + + replies[1]('v2 external write') + await vi.advanceTimersByTimeAsync(0) + expect(tab().externalMutation).toBe('changed') + expect(tab().pendingDiskBaselineVerification).toBeFalsy() +}) + +it('marks a dirty runtime-owned tab deleted when its host says the file is gone', async () => { + mocks.readRuntimeFileContent.mockRejectedValue(new Error('read failed')) + mocks.runtimePathsExist.mockResolvedValue([{ exists: false }]) + store.getState().setPendingDiskBaselineVerification(FILE, true) + detach = attachRestoredTabConflictScan(store) + await vi.advanceTimersByTimeAsync(0) + + expect(mocks.runtimePathsExist).toHaveBeenCalledWith( + expect.objectContaining({ worktreePath: '/repo' }), + [FILE], + undefined, + { codedAbsenceOnly: true } + ) + expect(tab().externalMutation).toBe('deleted') + expect(tab().pendingDiskBaselineVerification).toBeFalsy() +}) + +it('stops retrying and shows the conflict banner when the disk can never be checked', async () => { + mocks.readRuntimeFileContent.mockRejectedValue(new Error('host unreachable')) + mocks.runtimePathsExist.mockResolvedValue([{ error: 'unverifiable' }]) + store.getState().setPendingDiskBaselineVerification(FILE, true) + detach = attachRestoredTabConflictScan(store) + await vi.advanceTimersByTimeAsync(10 * 60_000) + + expect(mocks.readRuntimeFileContent).toHaveBeenCalledTimes(MAX_VERIFY_ATTEMPTS) + expect(tab().pendingDiskBaselineVerification).toBeFalsy() + expect(tab().externalMutation).toBe('changed') +}) + +it('does not let an absence answer from before a newer check resolve that check', async () => { + let answerProbe: (rows: unknown) => void = () => {} + mocks.readRuntimeFileContent.mockRejectedValueOnce(new Error('read failed')) + mocks.runtimePathsExist.mockImplementationOnce( + () => new Promise((resolve) => (answerProbe = resolve)) + ) + store.getState().setPendingDiskBaselineVerification(FILE, true) + detach = attachRestoredTabConflictScan(store) + await vi.advanceTimersByTimeAsync(0) + + // The file comes back and a new catch-up asks again while the old probe is pending. + requestRestoredTabReverification(FILE) + mocks.readRuntimeFileContent.mockResolvedValueOnce({ content: 'v2 recreated', isBinary: false }) + answerProbe([{ exists: false }]) + await vi.advanceTimersByTimeAsync(0) + + expect(tab().externalMutation).toBe('changed') + expect(tab().pendingDiskBaselineVerification).toBeFalsy() +}) diff --git a/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.ts b/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.ts index d34751fd4cb..256ab693cad 100644 --- a/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.ts +++ b/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.ts @@ -12,6 +12,7 @@ import { import { canAutoSaveOpenFile } from './editor-autosave' import { getDiskBaselineSignature } from './diff-content-signature' import { markFileChangedOnDisk } from './editor-changed-on-disk-mark' +import { runtimePathsExist } from '@/runtime/runtime-path-existence-batch' type AppStoreApi = Pick, 'getState' | 'subscribe'> @@ -21,6 +22,23 @@ const VERIFY_SLOW_RETRY_MS = 15_000 const VERIFY_FAST_ATTEMPTS = 30 // Cap concurrent reads: many restored dirty tabs would otherwise fire N concurrent 15s RPCs competing with startup connection recovery. const MAX_CONCURRENT_VERIFY_READS = 3 +// Why: an unreachable host must not suspend autosave forever; give up with the conflict banner instead. +export const MAX_VERIFY_ATTEMPTS = VERIFY_FAST_ATTEMPTS + 10 + +// Why: a disk check requested while one is in flight must not be answered by the older read. +const verifyRequestGenerationByFileId = new Map() + +/** Asks for a fresh disk check of a tab; a read already in flight is followed by one more. */ +export function requestRestoredTabReverification(fileId: string): void { + verifyRequestGenerationByFileId.set( + fileId, + (verifyRequestGenerationByFileId.get(fileId) ?? 0) + 1 + ) +} + +function verifyRequestGeneration(fileId: string): number { + return verifyRequestGenerationByFileId.get(fileId) ?? 0 +} export function attachRestoredTabConflictScan(store: AppStoreApi): () => void { // Dedupes queued + in-flight verifications; the store's pending flag is the durable "needs verification" signal. @@ -48,10 +66,33 @@ export function attachRestoredTabConflictScan(store: AppStoreApi): () => void { file.worktreeId, file.runtimeEnvironmentId ) - // Why `!owner`: with no single owner, a client stat cannot prove the file gone. - if (owner?.kind !== 'local') { + // Why `!owner`: with no single owner, nothing can prove the file gone. + if (!owner) { return false } + if (owner.kind !== 'local') { + try { + const [row] = await runtimePathsExist( + { + target: owner, + worktreeId: file.worktreeId, + // Why: tabs keep their root implicitly as filePath minus relativePath. + worktreePath: file.filePath.slice( + 0, + Math.max(0, file.filePath.length - file.relativePath.length - 1) + ), + connectionId: getFileConnectionId(file) + }, + [file.filePath], + undefined, + // Why: only a per-path ENOENT on the owning host proves absence. + { codedAbsenceOnly: true } + ) + return row !== undefined && 'exists' in row && !row.exists + } catch { + return false + } + } try { const connectionId = getFileConnectionId(file) const access = connectionId ? undefined : editorTabFileAccess(store.getState(), file) @@ -67,9 +108,22 @@ export function attachRestoredTabConflictScan(store: AppStoreApi): () => void { } } + // Why: a newer check was requested mid-read; this answer may predate it, so keep the flag and read once more. + const rereadIfSuperseded = (fileId: string, requestGeneration: number): boolean => { + if (verifyRequestGeneration(fileId) === requestGeneration) { + return false + } + queueMicrotask(() => { + inFlightFileIds.delete(fileId) + scan() + }) + return true + } + const verify = async (file: OpenFile): Promise => { // ids are file paths: a stray leftover marker would skip a reopened same-path tab, so only a scheduled retry keeps it set. let retryScheduled = false + const requestGeneration = verifyRequestGeneration(file.id) try { const state = store.getState() const result = await readRuntimeFileContent({ @@ -88,6 +142,11 @@ export function attachRestoredTabConflictScan(store: AppStoreApi): () => void { if (!liveFile) { return } + if (rereadIfSuperseded(file.id, requestGeneration)) { + retryScheduled = true + return + } + attemptsByFileId.delete(file.id) // Verification resolved: lift autosave suspension regardless of outcome; wasPending flags a save that already re-baselined. const wasPending = liveFile.pendingDiskBaselineVerification === true store.getState().clearPendingDiskBaselineVerification(file.id) @@ -113,6 +172,10 @@ export function attachRestoredTabConflictScan(store: AppStoreApi): () => void { if (disposed) { return } + if (rereadIfSuperseded(file.id, requestGeneration)) { + retryScheduled = true + return + } // Definitive not-found = resolved: no newer disk content for a save to clobber, so mark deleted instead of retrying forever. const liveFile = store.getState().openFiles.find((f) => f.id === file.id) if (!liveFile) { @@ -130,6 +193,10 @@ export function attachRestoredTabConflictScan(store: AppStoreApi): () => void { } const attempts = (attemptsByFileId.get(file.id) ?? 0) + 1 attemptsByFileId.set(file.id, attempts) + if (attempts >= MAX_VERIFY_ATTEMPTS) { + giveUp(file) + return + } retryScheduled = true const timer = setTimeout( () => { @@ -147,6 +214,22 @@ export function attachRestoredTabConflictScan(store: AppStoreApi): () => void { } } + // Why: still unverifiable after every retry; resume nothing silently — the banner makes the user choose. + const giveUp = (file: OpenFile): void => { + attemptsByFileId.delete(file.id) + const liveFile = store.getState().openFiles.find((f) => f.id === file.id) + if (!liveFile?.pendingDiskBaselineVerification) { + return + } + store.getState().clearPendingDiskBaselineVerification(file.id) + if (liveFile.isDirty && liveFile.externalMutation !== 'changed') { + markFileChangedOnDisk(store.getState(), liveFile, { + connectionId: getConnectionIdForFile(file.worktreeId, file.filePath) ?? undefined, + origin: 'restore' + }) + } + } + const pumpVerifyQueue = (): void => { while (!disposed && activeVerifyReads < MAX_CONCURRENT_VERIFY_READS && verifyQueue.length > 0) { const fileId = verifyQueue.shift()! diff --git a/src/renderer/src/components/editor/useEditorPanelFileContentLoader.ts b/src/renderer/src/components/editor/useEditorPanelFileContentLoader.ts index 5e04d592fac..074decbd5e3 100644 --- a/src/renderer/src/components/editor/useEditorPanelFileContentLoader.ts +++ b/src/renderer/src/components/editor/useEditorPanelFileContentLoader.ts @@ -27,6 +27,7 @@ import { editorTabFileAccess, isClientLocalReadOnlyTab } from '@/lib/local-file- import { isFloatingWorkspaceId } from '../../../../shared/floating-workspace-worktree' import { chatScratchFileAddress } from '@/runtime/runtime-chat-scratch-file-address' import { stampCleanTabDiskBaseline } from './editor-clean-tab-disk-baseline' +import { applyEditorFileLoadFailure } from './editor-deleted-file-reload' const inFlightFileReads = new Map>() @@ -264,15 +265,20 @@ export function useEditorPanelFileContentLoader({ // classifiers downstream must see the token, not only its rendering (#21041). const loadErrorCode = !hostConnecting && err instanceof RuntimeRpcCallError ? err.code : undefined - setFileContents((prev) => ({ - ...prev, - [id]: { - content: '', - isBinary: false, - loadError: message, - ...(loadErrorCode ? { loadErrorCode } : {}) - } - })) + const failure: FileContent = { + content: '', + isBinary: false, + loadError: message, + ...(loadErrorCode ? { loadErrorCode } : {}) + } + applyEditorFileLoadFailure({ + error: err, + fileId: id, + filePath, + tabs: openFilesRef.current, + failure, + setFileContents + }) } finally { if (outstandingFileReadsRef.current[id] === generation) { delete outstandingFileReadsRef.current[id] diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerWatch.catch-up.test.tsx b/src/renderer/src/components/right-sidebar/useFileExplorerWatch.catch-up.test.tsx new file mode 100644 index 00000000000..7fb77b72c2f --- /dev/null +++ b/src/renderer/src/components/right-sidebar/useFileExplorerWatch.catch-up.test.tsx @@ -0,0 +1,78 @@ +// @vitest-environment happy-dom +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { + markWorktreeCatchUpPayload, + ORCA_WORKTREE_FILE_CHANGE_EVENT +} from '@/hooks/worktree-file-change-event' +import type { FsChangedPayload } from '../../../../shared/filesystem-entry-types' +import { useFileExplorerWatch } from './useFileExplorerWatch' + +vi.mock('@/store', () => ({ + useAppStore: Object.assign((selector: (state: unknown) => unknown) => selector({}), { + getState: () => ({ openFiles: [] }) + }) +})) +vi.mock('./file-explorer-operation-owner', () => { + const owner = { kind: 'local', executionHostId: 'local' } + return { + getFileExplorerOperationOwner: () => owner, + getFileExplorerOperationOwnerFromState: () => owner + } +}) + +const refreshDir = vi.fn(async () => {}) +const refreshTree = vi.fn(async () => 'refreshed' as const) +const ROOT = '/repo' + +function dispatch(payload: FsChangedPayload, runtimeEnvironmentId: string | null = null): void { + window.dispatchEvent( + new CustomEvent(ORCA_WORKTREE_FILE_CHANGE_EVENT, { detail: { payload, runtimeEnvironmentId } }) + ) +} + +const overflow = (): FsChangedPayload => ({ + worktreePath: ROOT, + events: [{ kind: 'overflow', absolutePath: ROOT }] +}) + +beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() + Object.defineProperty(window, 'api', { + configurable: true, + value: { fs: { onFsChanged: vi.fn(() => vi.fn()) } } + }) + renderHook(() => + useFileExplorerWatch({ + worktreePath: ROOT, + activeWorktreeId: 'wt', + dirCache: { [ROOT]: { children: [] } }, + setDirCache: vi.fn(), + expanded: new Set(), + setSelectedPath: vi.fn(), + refreshDir, + refreshTree, + inlineInput: null, + dragSourcePath: null, + isNativeDragOver: false + }) + ) +}) +afterEach(() => { + cleanup() + vi.useRealTimers() +}) + +it('refreshes a local Files tree on the catch-up sent when its workspace is watched again', async () => { + act(() => dispatch(markWorktreeCatchUpPayload(overflow()))) + await act(async () => vi.advanceTimersByTimeAsync(0)) + expect(refreshTree).toHaveBeenCalledTimes(1) +}) + +it('leaves ordinary bus events to the IPC listener so nothing is processed twice', async () => { + act(() => dispatch(overflow())) + act(() => dispatch(markWorktreeCatchUpPayload(overflow()), 'env-1')) + await act(async () => vi.advanceTimersByTimeAsync(0)) + expect(refreshTree).not.toHaveBeenCalled() +}) diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerWatch.host-root.test.tsx b/src/renderer/src/components/right-sidebar/useFileExplorerWatch.host-root.test.tsx index b13f035ea9b..89f1a81a0b3 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerWatch.host-root.test.tsx +++ b/src/renderer/src/components/right-sidebar/useFileExplorerWatch.host-root.test.tsx @@ -64,7 +64,7 @@ it('refreshes the selected host Explorer when another host publishes the same wo }) const [target] = targets const adapter = buildEditorExternalWatchEventHandler((worktreePath, runtimeEnvironmentId) => - targets.find( + targets.filter( (candidate) => candidate.worktreePath === worktreePath && candidate.runtimeEnvironmentId === runtimeEnvironmentId diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts b/src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts index 9360c8b6a63..6e54a0a70a8 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts @@ -7,7 +7,10 @@ import type { } from './file-explorer-types' import type { InlineInput } from './file-explorer-inline-input-row' import { useAppStore } from '@/store' -import { ORCA_WORKTREE_FILE_CHANGE_EVENT } from '@/hooks/worktree-file-change-event' +import { + isWorktreeCatchUpPayload, + ORCA_WORKTREE_FILE_CHANGE_EVENT +} from '@/hooks/worktree-file-change-event' import { normalizeRuntimePathForComparison } from '../../../../shared/cross-platform-path' import { getFileExplorerOperationOwner, @@ -237,17 +240,28 @@ export function useFileExplorerWatch({ handleFsChanged(event.detail.payload) } } + // Why: IPC-bus trees otherwise miss the renderer-synthesized catch-up after a workspace is watched again. + const handleCatchUp = (event: WindowEventMap[typeof ORCA_WORKTREE_FILE_CHANGE_EVENT]): void => { + if ( + event.detail.runtimeEnvironmentId === null && + isWorktreeCatchUpPayload(event.detail.payload) + ) { + handleFsChanged(event.detail.payload) + } + } let unsubscribeListener: (() => void) | null = null if (activeRuntimeEnvironmentId?.trim() && activeWorktreeId) { // The app watcher owns recovery; a separate stream would strand this consumer after reconnect. window.addEventListener(ORCA_WORKTREE_FILE_CHANGE_EVENT, handleRuntimeChange) } else { unsubscribeListener = window.api.fs.onFsChanged(handleFsChanged) + window.addEventListener(ORCA_WORKTREE_FILE_CHANGE_EVENT, handleCatchUp) } return () => { disposed = true window.removeEventListener(ORCA_WORKTREE_FILE_CHANGE_EVENT, handleRuntimeChange) + window.removeEventListener(ORCA_WORKTREE_FILE_CHANGE_EVENT, handleCatchUp) unsubscribeListener?.() if (activeResyncByWatchKey.get(currentWatchKey) === scheduler.requestFullRefresh) { activeResyncByWatchKey.delete(currentWatchKey) diff --git a/src/renderer/src/hooks/editor-external-watch-background-trim.test.ts b/src/renderer/src/hooks/editor-external-watch-background-trim.test.ts new file mode 100644 index 00000000000..5aeb98d0715 --- /dev/null +++ b/src/renderer/src/hooks/editor-external-watch-background-trim.test.ts @@ -0,0 +1,243 @@ +import { describe, expect, it, vi } from 'vitest' +import { + selectEditorExternalWatchTargets, + type EditorExternalWatchTargetState +} from './editor-external-watch-targets' +import { + PINNED_RECURSIVE_WATCH_WORKSPACE_COUNT, + WARM_RECURSIVE_WATCH_WORKSPACE_COUNT +} from './editor-external-watch-scope' +import { getWorktreeVisitKey } from '@/lib/worktree-visit-recency' +import { getDefaultSettings } from '../../../shared/constants' +import { + makeFolderWorkspace, + makeWorktree, + TEST_REPO +} from '@/store/slices/worktrees-slice-test-fixtures' + +vi.mock('@/store', () => ({ useAppStore: { getState: vi.fn() } })) + +type Repo = EditorExternalWatchTargetState['repos'][number] +type Worktree = EditorExternalWatchTargetState['worktreesByRepo'][string][number] +type OpenFile = EditorExternalWatchTargetState['openFiles'][number] +type FolderWorkspace = EditorExternalWatchTargetState['folderWorkspaces'][number] + +const repo = (id: string, connectionId: string | null = null): Repo => ({ + ...TEST_REPO, + id, + path: `/${id}`, + kind: 'git', + connectionId +}) + +const worktree = (repoId: string, id: string, path = `/${repoId}/${id}`): Worktree => + makeWorktree({ id, repoId, path }) + +const tab = (worktreeId: string, extra: Partial = {}): OpenFile => ({ + id: `${worktreeId}-file`, + worktreeId, + filePath: `/x/${worktreeId}/a.ts`, + relativePath: 'a.ts', + language: 'typescript', + mode: 'edit', + isDirty: false, + ...extra +}) + +function makeState(args: { + worktrees: Worktree[] + repos?: Repo[] + openFiles: OpenFile[] + activeWorktreeId?: string | null + visits?: Record + folderWorkspaces?: FolderWorkspace[] +}): EditorExternalWatchTargetState { + const worktreesByRepo: EditorExternalWatchTargetState['worktreesByRepo'] = {} + for (const wt of args.worktrees) { + ;(worktreesByRepo[wt.repoId] ??= []).push(wt) + } + return { + openFiles: args.openFiles, + worktreesByRepo, + repos: args.repos ?? [repo('r')], + activeWorktreeId: args.activeWorktreeId ?? null, + lastVisitedAtByWorktreeId: args.visits ?? {}, + rightSidebarOpen: false, + rightSidebarTab: 'explorer', + rightSidebarExplorerView: 'files', + gitStatusHugeByWorktree: {}, + sshConnectionStates: new Map(), + folderWorkspaces: args.folderWorkspaces ?? [], + projectGroups: [], + settings: getDefaultSettings('/tmp') + } +} + +const watchedIds = (state: EditorExternalWatchTargetState): string[] => + selectEditorExternalWatchTargets(state).targets.map((target) => target.worktreeId) + +describe('background workspace watch trim', () => { + const ids = ['wt0', 'wt1', 'wt2', 'wt3', 'wt4'] + const worktrees = ids.map((id) => worktree('r', id)) + + it('watches the active workspace and drops clean background ones', () => { + const state = makeState({ + worktrees, + openFiles: [tab('wt0'), tab('wt1')], + activeWorktreeId: 'wt0' + }) + expect(watchedIds(state)).toEqual(['wt0']) + expect([...selectEditorExternalWatchTargets(state).unwatchedTargetKeys]).toEqual([ + 'wt1::/r/wt1::local::client::literal' + ]) + }) + + it(`keeps the ${WARM_RECURSIVE_WATCH_WORKSPACE_COUNT} most recently visited workspaces and evicts older ones`, () => { + const state = makeState({ + worktrees, + openFiles: ids.map((id) => tab(id)), + activeWorktreeId: 'wt0', + // Why: mixes host-qualified and legacy bare keys, as persisted sessions do. + visits: { + wt0: 50, + [getWorktreeVisitKey('wt1', 'local')]: 1, + wt2: 2, + [getWorktreeVisitKey('wt3', 'local')]: 3, + wt4: 4 + } + }) + expect(watchedIds(state)).toEqual(['wt0', 'wt2', 'wt3', 'wt4']) + }) + + it('does not let a deleted workspace hold a warm slot', () => { + const state = makeState({ + worktrees, + openFiles: ids.map((id) => tab(id)), + activeWorktreeId: 'wt0', + visits: { gone: 100, 'also-gone': 99, wt1: 1, wt2: 2, wt3: 3 } + }) + expect(watchedIds(state)).toEqual(['wt0', 'wt1', 'wt2', 'wt3']) + }) + + it('releases a background workspace even with unsaved, combined diff or rich edit tabs', () => { + // Why: every editor save is disk-checked by the host now, so no edit surface needs a live watch. + const state = makeState({ + worktrees, + openFiles: [ + tab('wt0'), + tab('wt1', { isDirty: true }), + tab('wt2', { id: 'combined', mode: 'diff', diffSource: 'combined-uncommitted' }), + tab('wt3', { id: 'md', language: 'markdown' }), + tab('wt4', { id: 'csv', language: 'csv' }) + ], + activeWorktreeId: 'wt0' + }) + expect(watchedIds(state)).toEqual(['wt0']) + }) + + it.each(['deleted', 'renamed'] as const)( + 'keeps a background workspace with a clean %s tab watched', + (externalMutation) => { + const state = makeState({ + worktrees, + openFiles: [tab('wt0'), tab('wt1', { externalMutation }), tab('wt2')], + activeWorktreeId: 'wt0' + }) + expect(watchedIds(state)).toEqual(['wt0', 'wt1']) + } + ) + + it(`keeps at most ${PINNED_RECURSIVE_WATCH_WORKSPACE_COUNT} deleted/renamed-tab workspaces beyond the recent ones, evicting the least recent`, () => { + const pinnedIds = ['p1', 'p2', 'p3', 'p4', 'p5'] + const state = makeState({ + worktrees: [...worktrees, ...pinnedIds.map((id) => worktree('r', id))], + openFiles: [ + tab('wt0'), + tab('wt1'), + tab('wt2'), + tab('wt3'), + ...pinnedIds.map((id) => tab(id, { externalMutation: 'deleted' as const })) + ], + activeWorktreeId: 'wt0', + visits: { wt1: 100, wt2: 99, wt3: 98, p1: 1, p2: 2, p3: 3, p4: 4 } + }) + expect(watchedIds(state)).toEqual(['p2', 'p3', 'p4', 'wt0', 'wt1', 'wt2', 'wt3']) + // Why: an evicted pin is released like any other workspace, so switching back still catches it up. + expect([...selectEditorExternalWatchTargets(state).unwatchedTargetKeys].sort()).toEqual([ + 'p1::/r/p1::local::client::literal', + 'p5::/r/p5::local::client::literal' + ]) + }) + + it('never watches for a virtual tab', () => { + const state = makeState({ + worktrees, + openFiles: [tab('wt0', { mode: 'check-details' })], + activeWorktreeId: 'wt0' + }) + expect(watchedIds(state)).toEqual([]) + }) + + it('recomputes when the visit map changes', () => { + const state = makeState({ + worktrees, + openFiles: [tab('wt0'), tab('wt1')], + activeWorktreeId: 'wt0' + }) + expect(watchedIds(state)).toEqual(['wt0']) + expect(watchedIds({ ...state, lastVisitedAtByWorktreeId: { wt1: 1 } })).toEqual(['wt0', 'wt1']) + }) + + it('applies the same policy to SSH, runtime-owned and WSL workspaces', () => { + const sshRepo = repo('ssh-repo', 'ssh-1') + const sshWorktree = worktree('ssh-repo', 'wt-ssh', '/remote/wt-ssh') + const wslWorktree = worktree('r', 'wt-wsl', '\\\\wsl.localhost\\Ubuntu\\home\\me\\wt') + const openFiles = [ + tab('wt0'), + tab('wt-ssh'), + tab('wt1', { id: 'runtime-tab', runtimeEnvironmentId: 'env-1' }), + tab('wt-wsl') + ] + const state = makeState({ + worktrees: [...worktrees, sshWorktree, wslWorktree], + repos: [repo('r'), sshRepo], + openFiles, + activeWorktreeId: 'wt0' + }) + expect(watchedIds(state)).toEqual(['wt0']) + + const recent = { + ...state, + lastVisitedAtByWorktreeId: { 'wt-ssh': 3, wt1: 2, 'wt-wsl': 1 } + } + expect(selectEditorExternalWatchTargets(recent).targets).toEqual( + expect.arrayContaining([ + expect.objectContaining({ worktreeId: 'wt-ssh', connectionId: 'ssh-1' }), + expect.objectContaining({ worktreeId: 'wt1', runtimeEnvironmentId: 'env-1' }), + expect.objectContaining({ worktreeId: 'wt-wsl' }) + ]) + ) + }) + + it('treats folder workspaces like worktrees', () => { + const folder = (id: string): FolderWorkspace => + makeFolderWorkspace({ + id, + projectGroupId: 'g', + folderPath: `/folders/${id}`, + executionHostId: 'local' + }) + const state = makeState({ + worktrees: [], + folderWorkspaces: [folder('a'), folder('b')], + openFiles: [tab('folder:a'), tab('folder:b', { id: 'b' })], + activeWorktreeId: 'folder:a' + }) + expect(watchedIds(state)).toEqual(['folder:a']) + expect(watchedIds({ ...state, activeWorktreeId: 'folder:b' })).toEqual(['folder:b']) + expect(watchedIds({ ...state, lastVisitedAtByWorktreeId: { 'folder:b': 1 } })).toEqual([ + 'folder:a', + 'folder:b' + ]) + }) +}) diff --git a/src/renderer/src/hooks/editor-external-watch-event-reconciliation.ts b/src/renderer/src/hooks/editor-external-watch-event-reconciliation.ts index cec5322a530..1b02633d007 100644 --- a/src/renderer/src/hooks/editor-external-watch-event-reconciliation.ts +++ b/src/renderer/src/hooks/editor-external-watch-event-reconciliation.ts @@ -1,10 +1,7 @@ import { useAppStore } from '@/store' import type { OpenFile } from '@/store/slices/editor' import { basename } from '@/lib/path' -import { - canAutoSaveOpenFile, - isExternalReloadableEditorTab -} from '@/components/editor/editor-autosave' +import { canAutoSaveOpenFile } from '@/components/editor/editor-autosave' import { indexEditorExternalWatchBatchPaths } from '@/components/editor/editor-external-watch-path-index' import { getRecentSelfWrite } from '@/components/editor/editor-self-write-registry' import { @@ -29,8 +26,18 @@ import { scheduleSelfWriteAwareEditorExternalReload, type EditorExternalWatchNotification } from './editor-external-watch-disk-verification' +import { + reconcileEditorTabsAfterOverflow, + supersedeOverflowProbesForBatch +} from './editor-external-watch-overflow' import { WATCH_BATCH_INTERVAL_MS } from '../../../shared/filesystem-watch-batch-window' +function isTargetList( + found: EditorExternalWatchTarget | readonly EditorExternalWatchTarget[] | undefined +): found is readonly EditorExternalWatchTarget[] { + return Array.isArray(found) +} + // Why: macOS atomic writes split delete→create across payloads; debounce deletion so a same-path create cancels the tombstone before it paints. // Under sustained writes the halves land one batch interval apart plus stat jitter (WSL/9P, AV scans), so allow two intervals. export const EXTERNAL_MUTATION_DEBOUNCE_MS = WATCH_BATCH_INTERVAL_MS * 2 + 50 @@ -41,12 +48,18 @@ type PendingDeleteTimer = { } export function buildEditorExternalWatchEventHandler( - findTarget: ( + // Why: several workspaces can share one root (two folder workspaces on a directory); each reconciles its own tabs. + findTargets: ( worktreePath: string, - runtimeEnvironmentId: string | null - ) => EditorExternalWatchTarget | undefined + runtimeEnvironmentId: string | null, + connectionId: string | undefined + ) => EditorExternalWatchTarget | readonly EditorExternalWatchTarget[] | undefined ): { - handleFsChanged: (payload: FsChangedPayload, runtimeEnvironmentId?: string | null) => void + handleFsChanged: ( + payload: FsChangedPayload, + runtimeEnvironmentId?: string | null, + onlyTarget?: EditorExternalWatchTarget + ) => void dispose: () => void } { const pendingDeletes = new Map() @@ -58,21 +71,30 @@ export function buildEditorExternalWatchEventHandler( const handleFsChanged = ( payload: FsChangedPayload, - runtimeEnvironmentId: string | null = null + runtimeEnvironmentId: string | null = null, + onlyTarget?: EditorExternalWatchTarget ): void => { - const target = findTarget(payload.worktreePath, runtimeEnvironmentId) - if (!target) { + // Why: a re-watch resync is about one workspace; fanning it out would clear another's marks and reload its tabs. + const found = + onlyTarget ?? findTargets(payload.worktreePath, runtimeEnvironmentId, payload.connectionId) + const targets = isTargetList(found) ? found : found ? [found] : [] + if (targets.length === 0) { return } // Why: this app-level hook owns watcher subscriptions; other consumers listen here so they don't fight over watch/unwatch ownership. if (typeof window !== 'undefined' && typeof window.dispatchEvent === 'function') { window.dispatchEvent( new CustomEvent(ORCA_WORKTREE_FILE_CHANGE_EVENT, { - detail: { payload, runtimeEnvironmentId: target.runtimeEnvironmentId } + detail: { payload, runtimeEnvironmentId: targets[0].runtimeEnvironmentId } }) ) } + for (const target of targets) { + reconcileTarget(payload, target) + } + } + const reconcileTarget = (payload: FsChangedPayload, target: EditorExternalWatchTarget): void => { // Why: one batch index keeps local WSL alias normalization out of event×tab loops. const openFilesAtStart = useAppStore.getState().openFiles const batchPaths = indexEditorExternalWatchBatchPaths(payload, openFilesAtStart, { @@ -81,6 +103,7 @@ export function buildEditorExternalWatchEventHandler( runtimeEnvironmentId: target.runtimeEnvironmentId, ...getLocalWindowsWslAliasOption(target) }) + supersedeOverflowProbesForBatch(batchPaths, openFilesAtStart) const createOrUpdatePaths = batchPaths.createOrUpdatePaths for (const createdPath of createOrUpdatePaths.keys()) { const key = pendingKey(target.worktreeId, target.runtimeEnvironmentId, createdPath) @@ -154,10 +177,7 @@ export function buildEditorExternalWatchEventHandler( } if (payload.events.some((event) => event.kind === 'overflow')) { - // Why: overflow omits paths, so reload clean tabs and clear tombstones that may have been resurrected during the overrun. - for (const notification of collectOverflowEditorExternalReloadTargets(target)) { - scheduleDebouncedEditorExternalReload(notification) - } + reconcileEditorTabsAfterOverflow(target) return } if (batchPaths.changes.length === 0) { @@ -238,40 +258,6 @@ export function buildEditorExternalWatchEventHandler( return { handleFsChanged, dispose } } -export function collectOverflowEditorExternalReloadTargets( - target: Pick & - Partial< - Pick< - EditorExternalWatchTarget, - 'connectionId' | 'runtimeEnvironmentId' | 'allowLocalWindowsWslAliases' - > - > -): EditorExternalWatchNotification[] { - const state = useAppStore.getState() - const notifications: EditorExternalWatchNotification[] = [] - for (const file of state.openFiles) { - if ( - file.worktreeId !== target.worktreeId || - getOpenFileRuntimeOwner(file) !== (target.runtimeEnvironmentId ?? null) || - !isExternalReloadableEditorTab(file) || - file.isDirty - ) { - continue - } - if (file.externalMutation) { - state.setExternalMutation(file.id, null) - } - notifications.push({ - worktreeId: target.worktreeId, - worktreePath: target.worktreePath, - relativePath: file.relativePath, - runtimeEnvironmentId: target.runtimeEnvironmentId ?? null, - ...getLocalWindowsWslAliasOption(target) - }) - } - return notifications -} - function hasRenameCorrelatedCreate( payload: FsChangedPayload, worktreeId: string, diff --git a/src/renderer/src/hooks/editor-external-watch-local-roots.ts b/src/renderer/src/hooks/editor-external-watch-local-roots.ts new file mode 100644 index 00000000000..3e1e3d66d6f --- /dev/null +++ b/src/renderer/src/hooks/editor-external-watch-local-roots.ts @@ -0,0 +1,75 @@ +import { normalizeRuntimePathForComparison } from '../../../shared/cross-platform-path' +import type { EditorExternalWatchTarget } from './editor-external-watch-targets' + +type WatchRootArgs = { worktreePath: string; connectionId: string | undefined } + +type HeldWatchRoot = { + args: WatchRootArgs + consumers: number + installed: Promise + failed: boolean +} + +/** Local and SSH watch roots held by this renderer, counted per physical root. */ +export type HeldWatchRoots = Map + +// Why: main keeps one listener per renderer and root, so two workspace ids on one directory must share one watch. +function getWatchRootKey(target: WatchRootArgs): string { + return `${target.connectionId ?? 'local'}::${normalizeRuntimePathForComparison(target.worktreePath)}` +} + +/** Resolves once the root's watch request has settled, including when another consumer started it. */ +export function acquireWatchRoot( + roots: HeldWatchRoots, + target: EditorExternalWatchTarget, + onError: (error: unknown) => void +): Promise { + const key = getWatchRootKey(target) + const held = roots.get(key) + if (held) { + held.consumers += 1 + // Why: a failed request is not a watch; the next workspace to need this root retries it. + if (held.failed) { + requestWatch(held, onError) + } + return held.installed + } + const root: HeldWatchRoot = { + args: { worktreePath: target.worktreePath, connectionId: target.connectionId }, + consumers: 1, + installed: Promise.resolve(), + failed: false + } + roots.set(key, root) + requestWatch(root, onError) + return root.installed +} + +function requestWatch(root: HeldWatchRoot, onError: (error: unknown) => void): void { + root.failed = false + root.installed = window.api.fs.watchWorktree(root.args).catch((error: unknown) => { + root.failed = true + onError(error) + }) +} + +export function releaseWatchRoot(roots: HeldWatchRoots, target: EditorExternalWatchTarget): void { + const key = getWatchRootKey(target) + const held = roots.get(key) + if (!held) { + return + } + held.consumers -= 1 + if (held.consumers > 0) { + return + } + roots.delete(key) + void window.api.fs.unwatchWorktree(held.args) +} + +export function releaseAllWatchRoots(roots: HeldWatchRoots): void { + for (const held of roots.values()) { + void window.api.fs.unwatchWorktree(held.args) + } + roots.clear() +} diff --git a/src/renderer/src/hooks/editor-external-watch-overflow.test.ts b/src/renderer/src/hooks/editor-external-watch-overflow.test.ts new file mode 100644 index 00000000000..eeab418edec --- /dev/null +++ b/src/renderer/src/hooks/editor-external-watch-overflow.test.ts @@ -0,0 +1,200 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const harness = vi.hoisted(() => { + const state: Record = { openFiles: [] } + return { + state, + runtimePathsExist: vi.fn(), + scheduleReload: vi.fn(), + forgetCombinedBodies: vi.fn() + } +}) + +vi.mock('@/store', () => ({ useAppStore: { getState: () => harness.state } })) +vi.mock('@/runtime/runtime-path-existence-batch', () => ({ + runtimePathsExist: harness.runtimePathsExist +})) +vi.mock('@/lib/file-owner-runtime-target', () => ({ + getRuntimeTargetForFileOwner: () => ({ kind: 'local' }) +})) +vi.mock('./editor-external-watch-disk-verification', () => ({ + scheduleDebouncedEditorExternalReload: harness.scheduleReload +})) +vi.mock('@/components/editor/combined-diff/remember-view/combined-diff-view-cache', () => ({ + forgetCombinedDiffViewBodiesForWorktree: harness.forgetCombinedBodies +})) + +import type { OpenFile } from '@/store/slices/editor' +import { + reconcileEditorTabsAfterOverflow, + supersedeOverflowProbesForBatch +} from './editor-external-watch-overflow' + +const setExternalMutation = vi.fn() +const setPendingDiskBaselineVerification = vi.fn() + +function tab(id: string, extra: Record = {}): Record { + return { + id, + worktreeId: 'wt-1', + filePath: `/repo/${id}`, + relativePath: id, + mode: 'edit', + isDirty: false, + ...extra + } +} + +function setTabs(...openFiles: Record[]): void { + harness.state = { openFiles, setExternalMutation, setPendingDiskBaselineVerification } +} + +const target = { worktreeId: 'wt-1', worktreePath: '/repo' } +const notification = (relativePath: string, runtimeEnvironmentId: string | null = null) => ({ + worktreeId: 'wt-1', + worktreePath: '/repo', + relativePath, + runtimeEnvironmentId +}) + +describe('reconcileEditorTabsAfterOverflow', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('reloads clean tabs that still exist and clears stale tombstones', async () => { + setTabs( + tab('a.md', { externalMutation: 'deleted' }), + tab('staged.ts', { mode: 'diff', diffSource: 'staged' }) + ) + harness.runtimePathsExist.mockResolvedValue([{ exists: true }]) + reconcileEditorTabsAfterOverflow(target) + await vi.waitFor(() => expect(harness.scheduleReload).toHaveBeenCalledTimes(2)) + + expect(harness.scheduleReload).toHaveBeenCalledWith(notification('staged.ts')) + expect(harness.scheduleReload).toHaveBeenCalledWith(notification('a.md')) + expect(setExternalMutation).toHaveBeenCalledWith('a.md', null) + expect(harness.forgetCombinedBodies).toHaveBeenCalledWith('wt-1') + }) + + it('marks a tab deleted, keeping its content, when its file is gone on the owning host', async () => { + setTabs(tab('gone.ts'), tab('moved.ts', { externalMutation: 'renamed' }), tab('here.ts')) + harness.runtimePathsExist.mockResolvedValue([ + { exists: false }, + { exists: false }, + { exists: true } + ]) + reconcileEditorTabsAfterOverflow(target) + await vi.waitFor(() => expect(harness.scheduleReload).toHaveBeenCalledTimes(1)) + + expect(harness.scheduleReload).toHaveBeenCalledWith(notification('here.ts')) + expect(setExternalMutation).toHaveBeenCalledWith('gone.ts', 'deleted') + // Why: a rename mark is kept as is rather than downgraded or cleared. + expect(setExternalMutation).not.toHaveBeenCalledWith('moved.ts', expect.anything()) + }) + + it.each([ + ['the probe fails', () => harness.runtimePathsExist.mockRejectedValue(new Error('offline'))], + [ + 'the host answers with an error row', + () => harness.runtimePathsExist.mockResolvedValue([{ error: 'EACCES' }]) + ] + ])('reloads as before when %s', async (_label, arrange) => { + arrange() + setTabs(tab('a.ts')) + reconcileEditorTabsAfterOverflow(target) + await vi.waitFor(() => + expect(harness.scheduleReload).toHaveBeenCalledWith(notification('a.ts')) + ) + expect(setExternalMutation).not.toHaveBeenCalledWith('a.ts', 'deleted') + }) + + it('hands dirty tabs to the disk check instead of reloading them', () => { + setTabs(tab('dirty.ts', { isDirty: true, lastKnownDiskSignature: 'sig' })) + reconcileEditorTabsAfterOverflow(target) + + expect(setPendingDiskBaselineVerification).toHaveBeenCalledWith('dirty.ts', true) + expect(harness.runtimePathsExist).not.toHaveBeenCalled() + expect(harness.scheduleReload).not.toHaveBeenCalled() + }) + + it('only touches tabs of the matching runtime owner', async () => { + setTabs(tab('local.md'), tab('runtime.md', { runtimeEnvironmentId: 'env-1' })) + harness.runtimePathsExist.mockResolvedValue([{ exists: true }]) + reconcileEditorTabsAfterOverflow({ ...target, runtimeEnvironmentId: 'env-1' }) + await vi.waitFor(() => expect(harness.scheduleReload).toHaveBeenCalledTimes(1)) + + expect(harness.scheduleReload).toHaveBeenCalledWith(notification('runtime.md', 'env-1')) + expect(harness.runtimePathsExist).toHaveBeenCalledWith( + expect.anything(), + ['/repo/runtime.md'], + undefined, + // Why: only a per-path ENOENT may mark a tab deleted, never a whole-request "not found". + { codedAbsenceOnly: true } + ) + }) + + it('probes more tabs than one host batch allows in ordered chunks', async () => { + const ids = Array.from({ length: 129 }, (_, index) => `f${index}.ts`) + setTabs(...ids.map((id) => tab(id))) + harness.runtimePathsExist.mockImplementation(async (_context: unknown, paths: string[]) => + // The last tab (the only one in the second chunk) is gone. + paths.map((path) => ({ exists: path !== '/repo/f128.ts' })) + ) + reconcileEditorTabsAfterOverflow(target) + await vi.waitFor(() => expect(harness.scheduleReload).toHaveBeenCalledTimes(128)) + + expect(harness.runtimePathsExist.mock.calls.map(([, paths]) => paths.length)).toEqual([128, 1]) + expect(setExternalMutation).toHaveBeenCalledWith('f128.ts', 'deleted') + expect(harness.scheduleReload).not.toHaveBeenCalledWith(notification('f128.ts')) + }) + + it('drops an older existence answer once a newer probe for the tab has answered', async () => { + setTabs(tab('a.ts')) + let answerFirst: (rows: unknown) => void = () => {} + harness.runtimePathsExist + .mockImplementationOnce(() => new Promise((resolve) => (answerFirst = resolve))) + .mockResolvedValueOnce([{ exists: false }]) + reconcileEditorTabsAfterOverflow(target) + reconcileEditorTabsAfterOverflow(target) + await vi.waitFor(() => expect(setExternalMutation).toHaveBeenCalledWith('a.ts', 'deleted')) + + answerFirst([{ exists: true }]) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(setExternalMutation).not.toHaveBeenCalledWith('a.ts', null) + expect(harness.scheduleReload).not.toHaveBeenCalled() + }) + + it('drops a pending existence answer when a watch event for the tab arrives first', async () => { + const touched: OpenFile = { + id: 'a.ts', + worktreeId: 'wt-1', + filePath: '/repo/a.ts', + relativePath: 'a.ts', + language: 'typescript', + mode: 'edit', + isDirty: false + } + setTabs(touched) + let answer: (rows: unknown) => void = () => {} + harness.runtimePathsExist.mockImplementationOnce( + () => new Promise((resolve) => (answer = resolve)) + ) + reconcileEditorTabsAfterOverflow(target) + supersedeOverflowProbesForBatch( + { + createOrUpdatePaths: new Map(), + changes: [], + deletedOpenEditors: [{ file: touched, normalizedDeletePath: '/repo/a.ts' }], + hasCombinedDiffConsumer: false, + matchesCreateOrUpdate: () => false, + matchingOpenFiles: () => [] + }, + [touched] + ) + + answer([{ exists: true }]) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(harness.scheduleReload).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/hooks/editor-external-watch-overflow.ts b/src/renderer/src/hooks/editor-external-watch-overflow.ts new file mode 100644 index 00000000000..8a5dced4c57 --- /dev/null +++ b/src/renderer/src/hooks/editor-external-watch-overflow.ts @@ -0,0 +1,202 @@ +import { useAppStore } from '@/store' +import type { OpenFile } from '@/store/slices/editor' +import type { EditorExternalWatchBatchPathIndex } from '@/components/editor/editor-external-watch-path-index' +import { + canAutoSaveOpenFile, + isExternalReloadableEditorTab +} from '@/components/editor/editor-autosave' +import { forgetCombinedDiffViewBodiesForWorktree } from '@/components/editor/combined-diff/remember-view/combined-diff-view-cache' +import { getRuntimeTargetForFileOwner } from '@/lib/file-owner-runtime-target' +import { runtimePathsExist } from '@/runtime/runtime-path-existence-batch' +import { + PATH_EXISTENCE_BATCH_MAX, + type PathExistenceResult +} from '../../../shared/path-existence-batch' +import { requestRestoredTabReverification } from '@/components/editor/editor-restored-tab-conflict-scan' +import { + getLocalWindowsWslAliasOption, + getOpenFileRuntimeOwner, + type EditorExternalWatchTarget +} from './editor-external-watch-targets' +import { + scheduleDebouncedEditorExternalReload, + type EditorExternalWatchNotification +} from './editor-external-watch-disk-verification' + +// Why: an existence answer is stale once a newer probe or watch event for that tab arrives. +const probeGenerationByFileId = new Map() +let probeGenerationSeq = 0 + +function claimProbeGeneration(fileId: string): number { + probeGenerationSeq += 1 + probeGenerationByFileId.set(fileId, probeGenerationSeq) + return probeGenerationSeq +} + +/** Drops pending existence answers for tabs this watch batch touched. */ +export function supersedeOverflowProbesForBatch( + batchPaths: EditorExternalWatchBatchPathIndex, + openFiles: readonly OpenFile[] +): void { + if (probeGenerationByFileId.size === 0) { + return + } + const touched = new Set(batchPaths.deletedOpenEditors.map(({ file }) => file.id)) + for (const change of batchPaths.changes) { + for (const file of batchPaths.matchingOpenFiles(change)) { + touched.add(file.id) + } + } + for (const file of openFiles) { + if (probeGenerationByFileId.has(file.id) && batchPaths.matchesCreateOrUpdate(file)) { + touched.add(file.id) + } + } + for (const fileId of touched) { + if (probeGenerationByFileId.has(fileId)) { + claimProbeGeneration(fileId) + } + } +} + +type OverflowTarget = Pick & + Partial< + Pick< + EditorExternalWatchTarget, + 'connectionId' | 'runtimeEnvironmentId' | 'allowLocalWindowsWslAliases' + > + > + +type EditTabProbe = { + fileId: string + generation: number + filePath: string + runtimeEnvironmentId: string | null | undefined + notification: EditorExternalWatchNotification +} + +/** + * An overflow (or a switch-back catch-up) names no paths, so every tab of the workspace may be stale. + * Clean tabs reload unless disk proves the file gone; dirty tabs get the restored-tab disk check. + */ +export function reconcileEditorTabsAfterOverflow(target: OverflowTarget): void { + // Why: unmounted combined-diff tabs get no per-path reload, so drop their cached bodies before a remount replays them. + forgetCombinedDiffViewBodiesForWorktree(target.worktreeId) + const state = useAppStore.getState() + const probes: EditTabProbe[] = [] + for (const file of state.openFiles) { + if ( + file.worktreeId !== target.worktreeId || + getOpenFileRuntimeOwner(file) !== (target.runtimeEnvironmentId ?? null) || + !isExternalReloadableEditorTab(file) + ) { + continue + } + if (file.isDirty) { + // Why: a dirty tab is never reloaded; the restored-tab scan shows the changed-on-disk banner if disk moved. + // Mirrors the scan's eligibility so it always resolves the flag. + if ( + file.lastKnownDiskSignature && + file.externalMutation !== 'changed' && + canAutoSaveOpenFile(file) + ) { + requestRestoredTabReverification(file.id) + state.setPendingDiskBaselineVerification(file.id, true) + } + continue + } + const notification: EditorExternalWatchNotification = { + worktreeId: target.worktreeId, + worktreePath: target.worktreePath, + relativePath: file.relativePath, + runtimeEnvironmentId: target.runtimeEnvironmentId ?? null, + ...getLocalWindowsWslAliasOption(target) + } + if (file.mode !== 'edit') { + if (file.externalMutation) { + state.setExternalMutation(file.id, null) + } + scheduleDebouncedEditorExternalReload(notification) + continue + } + probes.push({ + fileId: file.id, + generation: claimProbeGeneration(file.id), + filePath: file.filePath, + runtimeEnvironmentId: file.runtimeEnvironmentId, + notification + }) + } + if (probes.length > 0) { + void reloadEditTabsUnlessGone(target, probes) + } +} + +async function probeEditTabs( + target: OverflowTarget, + probes: readonly EditTabProbe[] +): Promise { + const owner = getRuntimeTargetForFileOwner( + useAppStore.getState(), + target.worktreeId, + probes[0].runtimeEnvironmentId + ) + if (!owner) { + return null + } + const context = { + target: owner, + worktreeId: target.worktreeId, + worktreePath: target.worktreePath, + connectionId: target.connectionId + } + const paths = probes.map((probe) => probe.filePath) + // Why: the host rejects a batch above its limit outright, which would reload every tab. + const chunks: string[][] = [] + for (let start = 0; start < paths.length; start += PATH_EXISTENCE_BATCH_MAX) { + chunks.push(paths.slice(start, start + PATH_EXISTENCE_BATCH_MAX)) + } + try { + const results = await Promise.all( + chunks.map((chunk) => + // Why: a whole-request "not found" (worktree re-pairing) must never mark every tab deleted. + runtimePathsExist(context, chunk, undefined, { codedAbsenceOnly: true }) + ) + ) + return results.flat() + } catch { + // Why: a failed probe cannot prove absence; reload as before. + return null + } +} + +// Why: reloading a file deleted while unwatched would swap the last content for a read error; mark it deleted as a watch event would. +async function reloadEditTabsUnlessGone( + target: OverflowTarget, + probes: readonly EditTabProbe[] +): Promise { + const results = await probeEditTabs(target, probes) + const state = useAppStore.getState() + probes.forEach((probe, index) => { + if (probeGenerationByFileId.get(probe.fileId) !== probe.generation) { + return + } + probeGenerationByFileId.delete(probe.fileId) + const live = state.openFiles.find((file) => file.id === probe.fileId) + if (!live || live.isDirty || live.mode !== 'edit') { + return + } + const row = results?.[index] + if (row && 'exists' in row && !row.exists) { + // Why: keep a rename mark; without watch events a rename and a delete look the same. + if (live.externalMutation !== 'deleted' && live.externalMutation !== 'renamed') { + state.setExternalMutation(live.id, 'deleted') + } + return + } + if (live.externalMutation) { + state.setExternalMutation(live.id, null) + } + scheduleDebouncedEditorExternalReload(probe.notification) + }) +} diff --git a/src/renderer/src/hooks/editor-external-watch-scope.ts b/src/renderer/src/hooks/editor-external-watch-scope.ts new file mode 100644 index 00000000000..3c980c0596b --- /dev/null +++ b/src/renderer/src/hooks/editor-external-watch-scope.ts @@ -0,0 +1,61 @@ +import type { AppState } from '@/store' +import { getWorktreeIdFromVisitKey } from '@/lib/worktree-visit-recency' + +// Why: recently used workspaces keep their watch so switching back needs no re-read. +export const WARM_RECURSIVE_WATCH_WORKSPACE_COUNT = 3 +// Why: deleted/renamed-tab pins sit outside the recency set, so bound them or they grow without limit. +export const PINNED_RECURSIVE_WATCH_WORKSPACE_COUNT = 3 + +function mostRecent( + ids: Iterable, + visitedAtById: ReadonlyMap, + count: number +): string[] { + return [...ids] + .sort((left, right) => (visitedAtById.get(right) ?? 0) - (visitedAtById.get(left) ?? 0)) + .slice(0, count) +} + +/** Workspaces whose open tabs keep a native watch: the active one, the most recently visited, and a few showing a deleted/renamed tab. */ +export function selectWatchEligibleWorkspaceIds(args: { + activeWorktreeId: string | null + lastVisitedAtByWorktreeId: Readonly> | undefined + openFiles: AppState['openFiles'] + exists: (workspaceId: string) => boolean +}): ReadonlySet { + const ids = new Set() + if (args.activeWorktreeId) { + ids.add(args.activeWorktreeId) + } + // Why: one visit map entry per host, so take each workspace's latest. + const visitedAtById = new Map() + for (const [key, visitedAt] of Object.entries(args.lastVisitedAtByWorktreeId ?? {})) { + const id = getWorktreeIdFromVisitKey(key) + visitedAtById.set(id, Math.max(visitedAtById.get(id) ?? 0, visitedAt)) + } + const warmCandidates = [...visitedAtById.keys()].filter( + // Why: `exists` keeps deleted workspaces from holding warm slots. + (id) => id !== args.activeWorktreeId && args.exists(id) + ) + for (const id of mostRecent( + warmCandidates, + visitedAtById, + WARM_RECURSIVE_WATCH_WORKSPACE_COUNT + )) { + ids.add(id) + } + // Why: a live watch clears a deleted/renamed mark the moment the file reappears; a catch-up only re-checks on switch-back. + const pinned = new Set() + for (const file of args.openFiles) { + if ( + (file.externalMutation === 'deleted' || file.externalMutation === 'renamed') && + !ids.has(file.worktreeId) + ) { + pinned.add(file.worktreeId) + } + } + for (const id of mostRecent(pinned, visitedAtById, PINNED_RECURSIVE_WATCH_WORKSPACE_COUNT)) { + ids.add(id) + } + return ids +} diff --git a/src/renderer/src/hooks/editor-external-watch-target-rows.ts b/src/renderer/src/hooks/editor-external-watch-target-rows.ts index 0b2b77cca3d..796e86817fa 100644 --- a/src/renderer/src/hooks/editor-external-watch-target-rows.ts +++ b/src/renderer/src/hooks/editor-external-watch-target-rows.ts @@ -2,7 +2,9 @@ import type { AppState } from '@/store' import { getIndexedWorktreesById } from '@/store/worktree-repo-index' import { findRepoForHost } from '@/store/slices/repo-host-identity' import { getFolderWorkspaceConnectionId } from '@/lib/folder-workspace-connection' +import { isWindowsAbsolutePathLike } from '../../../shared/cross-platform-path' import { + isLocalExecutionHost, parseExecutionHostId, toRuntimeExecutionHostId, type ExecutionHostId @@ -91,3 +93,32 @@ export function resolveEditorExternalWatchTargetRows( : undefined return { worktree, repo, folderWorkspace, projectGroup, connectionId } } + +/** Only a proven-local Windows owner may match WSL alias paths for its watch root. */ +export function canWatchLocalWindowsWslAliases( + args: EditorExternalWatchTargetRows & { + worktreePath: string + runtimeEnvironmentId: string | null + } +): boolean { + if ( + args.runtimeEnvironmentId !== null || + args.connectionId !== null || + !isWindowsAbsolutePathLike(args.worktreePath) + ) { + return false + } + if (args.worktree) { + return ( + !!args.repo && + !args.worktree.runtimeOwnerEnvironmentId?.trim() && + isLocalExecutionHost(args.worktree.hostId) && + isLocalExecutionHost(args.repo.executionHostId) + ) + } + return ( + !!args.folderWorkspace && + isLocalExecutionHost(args.folderWorkspace.executionHostId) && + isLocalExecutionHost(args.projectGroup?.executionHostId) + ) +} diff --git a/src/renderer/src/hooks/editor-external-watch-targets.ts b/src/renderer/src/hooks/editor-external-watch-targets.ts index c420bd334fd..5a522b7d73a 100644 --- a/src/renderer/src/hooks/editor-external-watch-targets.ts +++ b/src/renderer/src/hooks/editor-external-watch-targets.ts @@ -5,16 +5,18 @@ import { isVirtualEditorFile } from '@/store/slices/editor/tabs/editor-tab-conte import { findRepoForHost } from '@/store/slices/repo-host-identity' import { isLocalWindowsDesktopClient } from '@/lib/desktop-window-chrome' import { runtimeEnvironmentIdWithLegacyFallbackUnlessTwin } from '@/lib/worktree-runtime-owner' -import { isWindowsAbsolutePathLike } from '../../../shared/cross-platform-path' import { - isLocalExecutionHost, parseExecutionHostId, toRuntimeExecutionHostId, toSshExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' import { isGitRepoKind } from '../../../shared/repo-kind' -import { resolveEditorExternalWatchTargetRows } from './editor-external-watch-target-rows' +import { + canWatchLocalWindowsWslAliases, + resolveEditorExternalWatchTargetRows +} from './editor-external-watch-target-rows' +import { selectWatchEligibleWorkspaceIds } from './editor-external-watch-scope' export type EditorExternalWatchTarget = { worktreeId: string @@ -39,11 +41,19 @@ export type EditorExternalWatchTargetState = Pick< | 'folderWorkspaces' | 'projectGroups' > & - Partial> + Partial< + Pick< + AppState, + 'activeWorkspaceExecutionHostId' | 'rightSidebarEffectiveTab' | 'lastVisitedAtByWorktreeId' + > + > type WatchedTargetsSnapshot = { targets: EditorExternalWatchTarget[] targetsKey: string + /** Keys of open-tab targets the background trim left unwatched; a later add of one must re-read its tabs. */ + unwatchedTargetKeys: ReadonlySet + unwatchedTargetsKey: string } let cachedOpenFiles: AppState['openFiles'] | null = null @@ -60,7 +70,13 @@ let cachedGitStatusHugeByWorktree: AppState['gitStatusHugeByWorktree'] | null = let cachedSshConnectionStates: AppState['sshConnectionStates'] | null = null let cachedFolderWorkspaces: AppState['folderWorkspaces'] | null = null let cachedProjectGroups: AppState['projectGroups'] | null = null -let cachedWatchedTargetsSnapshot: WatchedTargetsSnapshot = { targets: [], targetsKey: '' } +let cachedLastVisitedAtByWorktreeId: AppState['lastVisitedAtByWorktreeId'] | undefined +let cachedWatchedTargetsSnapshot: WatchedTargetsSnapshot = { + targets: [], + targetsKey: '', + unwatchedTargetKeys: new Set(), + unwatchedTargetsKey: '' +} export function getEditorExternalWatchTargetKey(target: EditorExternalWatchTarget): string { // Why: include connectionId so a local placeholder watch is replaced by the real SSH watch once an SSH worktree's provider metadata hydrates. @@ -127,37 +143,6 @@ function addWatchConsumer( consumers.set(`${consumer.owner ?? ''}::${consumer.hostId ?? ''}`, consumer) } -function canWatchLocalWindowsWslAliases(args: { - worktreePath: string - runtimeEnvironmentId: string | null - connectionId: string | null | undefined - worktree: AppState['worktreesByRepo'][string][number] | undefined - repo: AppState['repos'][number] | undefined - folderWorkspace: AppState['folderWorkspaces'][number] | undefined - projectGroup: AppState['projectGroups'][number] | undefined -}): boolean { - if ( - args.runtimeEnvironmentId !== null || - args.connectionId !== null || - !isWindowsAbsolutePathLike(args.worktreePath) - ) { - return false - } - if (args.worktree) { - return ( - !!args.repo && - !args.worktree.runtimeOwnerEnvironmentId?.trim() && - isLocalExecutionHost(args.worktree.hostId) && - isLocalExecutionHost(args.repo.executionHostId) - ) - } - return ( - !!args.folderWorkspace && - isLocalExecutionHost(args.folderWorkspace.executionHostId) && - isLocalExecutionHost(args.projectGroup?.executionHostId) - ) -} - export function selectEditorExternalWatchTargets( state: EditorExternalWatchTargetState ): WatchedTargetsSnapshot { @@ -177,19 +162,31 @@ export function selectEditorExternalWatchTargets( cachedGitStatusHugeByWorktree === state.gitStatusHugeByWorktree && cachedSshConnectionStates === state.sshConnectionStates && cachedFolderWorkspaces === state.folderWorkspaces && - cachedProjectGroups === state.projectGroups + cachedProjectGroups === state.projectGroups && + cachedLastVisitedAtByWorktreeId === state.lastVisitedAtByWorktreeId ) { return cachedWatchedTargetsSnapshot } const consumersByWorktreeId = new Map>() + const unwatchedConsumersByWorktreeId = new Map>() + const watchEligibleIds = selectWatchEligibleWorkspaceIds({ + activeWorktreeId: state.activeWorktreeId, + lastVisitedAtByWorktreeId: state.lastVisitedAtByWorktreeId, + openFiles: state.openFiles, + exists: (id) => resolveEditorExternalWatchTargetRows(state, id, null) !== null + }) // Why: watcher ownership is scoped by worktree + execution host — the same workspace id can be open locally, over SSH and in a runtime, and reads/saves already route per host. for (const file of state.openFiles) { // Why: a virtual tab (CI details, chat visual) has no file in the workspace to watch. if (isVirtualEditorFile(file)) { continue } - addWatchConsumer(consumersByWorktreeId, file.worktreeId, getOpenFileWatchConsumer(file)) + // Why: background workspaces drop their native watch; switching back re-verifies their tabs against disk. + const consumers = watchEligibleIds.has(file.worktreeId) + ? consumersByWorktreeId + : unwatchedConsumersByWorktreeId + addWatchConsumer(consumers, file.worktreeId, getOpenFileWatchConsumer(file)) } const activeWorktreeId = state.activeWorktreeId const activeWorktree = activeWorktreeId @@ -226,34 +223,12 @@ export function selectEditorExternalWatchTargets( ) } - const targetsByKey = new Map() - for (const [id, consumers] of consumersByWorktreeId) { - for (const { owner, hostId } of consumers.values()) { - const rows = resolveEditorExternalWatchTargetRows(state, id, hostId) - if (!rows || (rows.connectionId === undefined && rows.folderWorkspace)) { - continue - } - const { worktree, repo, folderWorkspace, projectGroup, connectionId } = rows - const target = { - worktreeId: id, - worktreePath: worktree?.path ?? folderWorkspace!.folderPath, - connectionId: connectionId ?? undefined, - runtimeEnvironmentId: owner, - ...(canWatchLocalWindowsWslAliases({ - worktreePath: worktree?.path ?? folderWorkspace!.folderPath, - runtimeEnvironmentId: owner, - connectionId, - worktree, - repo, - folderWorkspace, - projectGroup - }) - ? { allowLocalWindowsWslAliases: true as const } - : {}) - } - targetsByKey.set(getEditorExternalWatchTargetKey(target), target) - } - } + const targetsByKey = resolveWatchTargets(state, consumersByWorktreeId) + const unwatchedTargetKeys = new Set( + [...resolveWatchTargets(state, unwatchedConsumersByWorktreeId).keys()].filter( + (key) => !targetsByKey.has(key) + ) + ) // Why: consumers on one host (an unqualified legacy tab and its captured sibling) collapse to one watch. const sortedEntries = Array.from(targetsByKey).sort(([left], [right]) => @@ -261,6 +236,7 @@ export function selectEditorExternalWatchTargets( ) const nextTargets = sortedEntries.map(([, target]) => target) const targetsKey = sortedEntries.map(([key]) => key).join('|') + const unwatchedTargetsKey = [...unwatchedTargetKeys].sort().join('|') cachedOpenFiles = state.openFiles cachedWorktreesByRepo = state.worktreesByRepo cachedRepos = state.repos @@ -275,11 +251,51 @@ export function selectEditorExternalWatchTargets( cachedSshConnectionStates = state.sshConnectionStates cachedFolderWorkspaces = state.folderWorkspaces cachedProjectGroups = state.projectGroups + cachedLastVisitedAtByWorktreeId = state.lastVisitedAtByWorktreeId - if (targetsKey === cachedWatchedTargetsSnapshot.targetsKey) { + if ( + targetsKey === cachedWatchedTargetsSnapshot.targetsKey && + unwatchedTargetsKey === cachedWatchedTargetsSnapshot.unwatchedTargetsKey + ) { return cachedWatchedTargetsSnapshot } - cachedWatchedTargetsSnapshot = { targets: nextTargets, targetsKey } + cachedWatchedTargetsSnapshot = { + targets: nextTargets, + targetsKey, + unwatchedTargetKeys, + unwatchedTargetsKey + } return cachedWatchedTargetsSnapshot } + +function resolveWatchTargets( + state: EditorExternalWatchTargetState, + consumersByWorktreeId: ReadonlyMap> +): Map { + const targetsByKey = new Map() + for (const [id, consumers] of consumersByWorktreeId) { + for (const { owner, hostId } of consumers.values()) { + const rows = resolveEditorExternalWatchTargetRows(state, id, hostId) + if (!rows || (rows.connectionId === undefined && rows.folderWorkspace)) { + continue + } + const { worktree, folderWorkspace, connectionId } = rows + const target = { + worktreeId: id, + worktreePath: worktree?.path ?? folderWorkspace!.folderPath, + connectionId: connectionId ?? undefined, + runtimeEnvironmentId: owner, + ...(canWatchLocalWindowsWslAliases({ + worktreePath: worktree?.path ?? folderWorkspace!.folderPath, + runtimeEnvironmentId: owner, + ...rows + }) + ? { allowLocalWindowsWslAliases: true as const } + : {}) + } + targetsByKey.set(getEditorExternalWatchTargetKey(target), target) + } + } + return targetsByKey +} diff --git a/src/renderer/src/hooks/editor-runtime-file-watch.test.ts b/src/renderer/src/hooks/editor-runtime-file-watch.test.ts index 53960dafda4..0efea53eb2b 100644 --- a/src/renderer/src/hooks/editor-runtime-file-watch.test.ts +++ b/src/renderer/src/hooks/editor-runtime-file-watch.test.ts @@ -77,6 +77,7 @@ describe('editor runtime file watch recovery', () => { connectionId: undefined }, expect.any(Function), + expect.any(Function), expect.any(Function) ) expect(onPayload).not.toHaveBeenCalled() @@ -105,6 +106,7 @@ describe('editor runtime file watch recovery', () => { connectionId: undefined }, expect.any(Function), + expect.any(Function), expect.any(Function) ) expect(onPayload).toHaveBeenCalledWith({ diff --git a/src/renderer/src/hooks/editor-runtime-file-watch.ts b/src/renderer/src/hooks/editor-runtime-file-watch.ts index 02077dbd6f1..c309e5a7478 100644 --- a/src/renderer/src/hooks/editor-runtime-file-watch.ts +++ b/src/renderer/src/hooks/editor-runtime-file-watch.ts @@ -8,8 +8,10 @@ import { runtimeTargetForOwnerEnvironment } from '@/runtime/runtime-client-targe export function subscribeEditorRuntimeFileWatch( target: EditorExternalWatchTarget & { runtimeEnvironmentId: string }, onPayload: (payload: FsChangedPayload) => void, - onError: (error: unknown) => void + onError: (error: unknown) => void, + onFirstReady?: () => void ): () => void { + let readySeen = false let disposed = false let attempt = 0 let unsubscribe: (() => void) | null = null @@ -38,6 +40,12 @@ export function subscribeEditorRuntimeFileWatch( if (isCurrent()) { onError(error) } + }, + () => { + if (isCurrent() && !readySeen) { + readySeen = true + onFirstReady?.() + } } ) .then((stop) => { diff --git a/src/renderer/src/hooks/useEditorExternalWatch-background-trim.test.tsx b/src/renderer/src/hooks/useEditorExternalWatch-background-trim.test.tsx new file mode 100644 index 00000000000..bd178d116fb --- /dev/null +++ b/src/renderer/src/hooks/useEditorExternalWatch-background-trim.test.tsx @@ -0,0 +1,270 @@ +// @vitest-environment happy-dom + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' + +type TestWatchTarget = { + worktreeId: string + worktreePath: string + connectionId: string | undefined + runtimeEnvironmentId: string | null +} + +const harness = vi.hoisted(() => { + const targets: TestWatchTarget[] = [] + const unwatched: TestWatchTarget[] = [] + const runtimeReady: { current: (() => void) | undefined } = { current: undefined } + return { + runtimeReady, + keyOf: (target: TestWatchTarget): string => + `${target.worktreeId}::${target.runtimeEnvironmentId ?? 'client'}`, + targets, + unwatched, + // Why: the hook resyncs through the event handler's overflow path; this records those calls. + catchUp: vi.fn(), + runtimeUnsubscribe: vi.fn() + } +}) + +vi.mock('@/store', () => ({ + useAppStore: Object.assign((selector: (state: unknown) => unknown) => selector({}), { + getState: () => ({ openFiles: [], setPendingDiskBaselineVerification: vi.fn() }) + }) +})) +vi.mock('./editor-external-watch-targets', () => ({ + selectEditorExternalWatchTargets: () => ({ + targets: [...harness.targets], + targetsKey: harness.targets.map(harness.keyOf).join('|'), + unwatchedTargetKeys: new Set(harness.unwatched.map(harness.keyOf)), + unwatchedTargetsKey: harness.unwatched.map(harness.keyOf).join('|') + }), + getEditorExternalWatchTargetKey: harness.keyOf, + getOpenFileRuntimeOwner: () => null +})) +vi.mock('./editor-runtime-file-watch', () => ({ + subscribeEditorRuntimeFileWatch: ( + _target: unknown, + _onPayload: unknown, + _onError: unknown, + onFirstReady?: () => void + ) => { + harness.runtimeReady.current = onFirstReady + return harness.runtimeUnsubscribe + } +})) +vi.mock('./editor-external-watch-event-reconciliation', () => ({ + buildEditorExternalWatchEventHandler: () => ({ + handleFsChanged: harness.catchUp, + dispose: vi.fn() + }) +})) +vi.mock('./editor-external-watch-disk-verification', () => ({ + verifyLatchedEditorMoveDestinations: vi.fn() +})) + +import { useEditorExternalWatch } from './useEditorExternalWatch' + +function resyncOf(target: TestWatchTarget): [unknown, string | null, TestWatchTarget] { + return [ + { + worktreePath: target.worktreePath, + events: [{ kind: 'overflow', absolutePath: target.worktreePath }], + connectionId: target.connectionId + }, + target.runtimeEnvironmentId, + target + ] +} + +function WatchProbe(): null { + useEditorExternalWatch() + return null +} + +const active: TestWatchTarget = { + worktreeId: 'active', + worktreePath: '/active', + connectionId: undefined, + runtimeEnvironmentId: null +} +const background: TestWatchTarget = { + worktreeId: 'background', + worktreePath: '/background', + connectionId: undefined, + runtimeEnvironmentId: null +} +const runtimeBackground: TestWatchTarget = { + worktreeId: 'runtime-background', + worktreePath: '/runtime', + connectionId: undefined, + runtimeEnvironmentId: 'env-1' +} + +describe('useEditorExternalWatch with background trim', () => { + let container: HTMLDivElement + let root: Root + let previousApi: unknown + let watchWorktree: ReturnType + let unwatchWorktree: ReturnType + let resolveWatch: () => void + + async function render(targets: TestWatchTarget[], unwatched: TestWatchTarget[]): Promise { + harness.targets.splice(0, harness.targets.length, ...targets) + harness.unwatched.splice(0, harness.unwatched.length, ...unwatched) + await act(async () => root.render(createElement(WatchProbe))) + } + + beforeEach(() => { + vi.clearAllMocks() + watchWorktree = vi.fn( + () => + new Promise((resolve) => { + resolveWatch = resolve + }) + ) + unwatchWorktree = vi.fn().mockResolvedValue(undefined) + previousApi = (window as unknown as { api?: unknown }).api + ;(window as unknown as { api: unknown }).api = { + fs: { watchWorktree, unwatchWorktree, onFsChanged: vi.fn(() => vi.fn()) } + } + container = document.body.appendChild(document.createElement('div')) + root = createRoot(container) + }) + + afterEach(async () => { + await act(async () => root.unmount()) + container.remove() + ;(window as unknown as { api?: unknown }).api = previousApi + }) + + it('refreshes a dropped workspace once its watch request settles', async () => { + await render([active], [background]) + await render([active, background], []) + expect(watchWorktree).toHaveBeenLastCalledWith({ + worktreePath: '/background', + connectionId: undefined + }) + expect(harness.catchUp).not.toHaveBeenCalled() + + await act(async () => resolveWatch()) + expect(harness.catchUp).toHaveBeenCalledWith(...resyncOf(background)) + expect(harness.catchUp).toHaveBeenCalledTimes(1) + }) + + it('refreshes a dropped runtime workspace once its host reports the watch ready', async () => { + await render([active], [runtimeBackground]) + await render([active, runtimeBackground], []) + expect(harness.catchUp).not.toHaveBeenCalled() + + await act(async () => harness.runtimeReady.current?.()) + expect(harness.catchUp).toHaveBeenCalledWith(...resyncOf(runtimeBackground)) + }) + + it('releases and re-requests an SSH workspace watch through its connection', async () => { + const ssh: TestWatchTarget = { + worktreeId: 'ssh', + worktreePath: '/home/me/repo', + connectionId: 'conn-1', + runtimeEnvironmentId: null + } + await render([active, ssh], []) + await render([active], [ssh]) + expect(unwatchWorktree).toHaveBeenCalledWith({ + worktreePath: '/home/me/repo', + connectionId: 'conn-1' + }) + + await render([active, ssh], []) + expect(watchWorktree).toHaveBeenLastCalledWith({ + worktreePath: '/home/me/repo', + connectionId: 'conn-1' + }) + await act(async () => resolveWatch()) + expect(harness.catchUp).toHaveBeenCalledWith(...resyncOf(ssh)) + }) + + it('retries a failed directory watch when another workspace on it needs it', async () => { + const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + watchWorktree + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error('provider gone')) + await render([active, background], []) + expect(watchWorktree).toHaveBeenCalledTimes(2) + + await render([active, background, { ...background, worktreeId: 'sibling' }], []) + expect(watchWorktree).toHaveBeenCalledTimes(3) + expect(watchWorktree).toHaveBeenLastCalledWith({ + worktreePath: '/background', + connectionId: undefined + }) + } finally { + warning.mockRestore() + } + }) + + it('does not refresh a workspace whose tabs were just opened', async () => { + await render([active], []) + await render([active, background], []) + await act(async () => resolveWatch()) + + expect(harness.catchUp).not.toHaveBeenCalled() + }) + + it('keeps a directory watched while another workspace on it still needs it', async () => { + const sibling = { ...background, worktreeId: 'sibling' } + await render([active, background, sibling], []) + expect(watchWorktree).toHaveBeenCalledTimes(2) + + await render([active, sibling], [background]) + expect(unwatchWorktree).not.toHaveBeenCalled() + + await render([active], [background, sibling]) + expect(unwatchWorktree).toHaveBeenCalledTimes(1) + expect(unwatchWorktree).toHaveBeenCalledWith({ + worktreePath: '/background', + connectionId: undefined + }) + }) + + it('refreshes a re-watched workspace whose directory another workspace already watches', async () => { + const sibling = { ...background, worktreeId: 'sibling' } + await render([active, sibling], [background]) + await render([active, sibling, background], []) + + expect(watchWorktree).toHaveBeenCalledTimes(2) + await act(async () => resolveWatch()) + expect(harness.catchUp).toHaveBeenCalledWith(...resyncOf(background)) + }) + + it('only releases when a background workspace drops out', async () => { + await render([active, background], []) + watchWorktree.mockClear() + + await render([active], [background]) + expect(unwatchWorktree).toHaveBeenCalledTimes(1) + expect(unwatchWorktree).toHaveBeenCalledWith({ + worktreePath: '/background', + connectionId: undefined + }) + expect(watchWorktree).not.toHaveBeenCalled() + expect(harness.catchUp).not.toHaveBeenCalled() + }) + + it('leaves no watch behind after all tabs close or the app unmounts', async () => { + await render([active, background, runtimeBackground], []) + await render([], []) + expect(unwatchWorktree).toHaveBeenCalledTimes(2) + expect(harness.runtimeUnsubscribe).toHaveBeenCalledTimes(1) + + await render([active], [background]) + await act(async () => root.unmount()) + expect(unwatchWorktree).toHaveBeenCalledTimes(3) + expect(unwatchWorktree).toHaveBeenLastCalledWith({ + worktreePath: '/active', + connectionId: undefined + }) + root = createRoot(container) + }) +}) diff --git a/src/renderer/src/hooks/useEditorExternalWatch-shared-root.test.tsx b/src/renderer/src/hooks/useEditorExternalWatch-shared-root.test.tsx new file mode 100644 index 00000000000..b7b6f163729 --- /dev/null +++ b/src/renderer/src/hooks/useEditorExternalWatch-shared-root.test.tsx @@ -0,0 +1,212 @@ +// @vitest-environment happy-dom + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import type * as WatchTargets from './editor-external-watch-targets' + +type TestWatchTarget = { + worktreeId: string + worktreePath: string + connectionId: string | undefined + runtimeEnvironmentId: string | null +} + +const harness = vi.hoisted(() => { + const targets: TestWatchTarget[] = [] + const unwatched: TestWatchTarget[] = [] + const openFiles: Record[] = [] + return { + keyOf: (target: TestWatchTarget): string => + `${target.worktreeId}::${target.runtimeEnvironmentId ?? 'client'}`, + targets, + unwatched, + openFiles, + setExternalMutation: vi.fn() + } +}) + +vi.mock('@/store', () => ({ + useAppStore: Object.assign((selector: (state: unknown) => unknown) => selector({}), { + getState: () => ({ + openFiles: harness.openFiles, + setExternalMutation: harness.setExternalMutation, + setPendingDiskBaselineVerification: vi.fn() + }) + }) +})) +vi.mock('./editor-external-watch-targets', async (importOriginal) => ({ + ...(await importOriginal()), + selectEditorExternalWatchTargets: () => ({ + targets: [...harness.targets], + targetsKey: harness.targets.map(harness.keyOf).join('|'), + unwatchedTargetKeys: new Set(harness.unwatched.map(harness.keyOf)), + unwatchedTargetsKey: harness.unwatched.map(harness.keyOf).join('|') + }), + getEditorExternalWatchTargetKey: harness.keyOf +})) +vi.mock('./editor-runtime-file-watch', () => ({ + // Why: a shared runtime watch that is already ready reports it synchronously during subscribe. + subscribeEditorRuntimeFileWatch: ( + _target: unknown, + _onPayload: unknown, + _onError: unknown, + onFirstReady?: () => void + ) => { + onFirstReady?.() + return vi.fn() + } +})) + +import { useEditorExternalWatch } from './useEditorExternalWatch' +import { ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT } from '@/components/editor/editor-autosave' + +function WatchProbe(): null { + useEditorExternalWatch() + return null +} + +function tab(worktreeId: string, relativePath: string, runtimeEnvironmentId?: string) { + return { + id: `/shared/${worktreeId}/${relativePath}`, + filePath: `/shared/${relativePath}`, + relativePath, + worktreeId, + language: 'typescript', + mode: 'edit', + isDirty: false, + lastKnownDiskSignature: 'sig', + ...(runtimeEnvironmentId ? { runtimeEnvironmentId } : {}) + } +} + +describe('useEditorExternalWatch resync through the real reconciliation path', () => { + let container: HTMLDivElement + let root: Root + let previousApi: unknown + let emitFsChanged: (payload: unknown) => void + let reloads: string[] + const onReload = (event: Event): void => { + if (event instanceof CustomEvent) { + reloads.push(`${event.detail.worktreeId}:${event.detail.relativePath}`) + } + } + + async function render(targets: TestWatchTarget[], unwatched: TestWatchTarget[]): Promise { + harness.targets.splice(0, harness.targets.length, ...targets) + harness.unwatched.splice(0, harness.unwatched.length, ...unwatched) + await act(async () => root.render(createElement(WatchProbe))) + } + + beforeEach(() => { + vi.useFakeTimers() + reloads = [] + harness.openFiles.splice(0) + harness.setExternalMutation.mockClear() + previousApi = (window as unknown as { api?: unknown }).api + ;(window as unknown as { api: unknown }).api = { + fs: { + watchWorktree: vi.fn().mockResolvedValue(undefined), + unwatchWorktree: vi.fn().mockResolvedValue(undefined), + onFsChanged: vi.fn((listener: (payload: unknown) => void) => { + emitFsChanged = listener + return vi.fn() + }) + } + } + window.addEventListener(ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT, onReload) + container = document.body.appendChild(document.createElement('div')) + root = createRoot(container) + }) + + afterEach(async () => { + await act(async () => root.unmount()) + window.removeEventListener(ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT, onReload) + container.remove() + ;(window as unknown as { api?: unknown }).api = previousApi + vi.useRealTimers() + }) + + it('reloads clean tabs of every workspace that shares the root', async () => { + const a = { + worktreeId: 'folder:a', + worktreePath: '/shared', + connectionId: undefined, + runtimeEnvironmentId: null + } + const b = { ...a, worktreeId: 'folder:b' } + harness.openFiles.push(tab('folder:a', 'a.ts'), tab('folder:b', 'b.ts')) + await render([a, b], []) + + emitFsChanged({ + worktreePath: '/shared', + events: [{ kind: 'overflow', absolutePath: '/shared' }] + }) + await act(async () => vi.advanceTimersByTimeAsync(200)) + + expect(reloads.sort()).toEqual(['folder:a:a.ts', 'folder:b:b.ts']) + }) + + it('resyncs a runtime workspace whose shared watch reports ready synchronously', async () => { + const runtimeTarget = { + worktreeId: 'wt-runtime', + worktreePath: '/shared', + connectionId: undefined, + runtimeEnvironmentId: 'env-1' + } + harness.openFiles.push(tab('wt-runtime', 'r.ts', 'env-1')) + await render([], [runtimeTarget]) + await render([runtimeTarget], []) + await act(async () => vi.advanceTimersByTimeAsync(200)) + + expect(reloads).toEqual(['wt-runtime:r.ts']) + }) + + it('resyncs only the re-added workspace, leaving another on the same root alone', async () => { + const a = { + worktreeId: 'folder:a', + worktreePath: '/shared', + connectionId: undefined, + runtimeEnvironmentId: null + } + const b = { ...a, worktreeId: 'folder:b' } + harness.openFiles.push(tab('folder:a', 'a.ts'), tab('folder:b', 'b.ts'), { + ...tab('folder:b', 'gone.ts'), + externalMutation: 'deleted' + }) + await render([b], [a]) + await render([a, b], []) + await act(async () => vi.advanceTimersByTimeAsync(200)) + + expect(reloads).toEqual(['folder:a:a.ts']) + expect(harness.setExternalMutation).not.toHaveBeenCalled() + }) + + it('keeps a local and an SSH workspace at the same path apart', async () => { + const local = { + worktreeId: 'local-wt', + worktreePath: '/shared', + connectionId: undefined, + runtimeEnvironmentId: null + } + const ssh = { ...local, worktreeId: 'ssh-wt', connectionId: 'ssh-1' } + harness.openFiles.push(tab('local-wt', 'x.ts'), tab('ssh-wt', 'x.ts')) + await render([local, ssh], []) + + emitFsChanged({ + worktreePath: '/shared', + events: [{ kind: 'update', absolutePath: '/shared/x.ts' }] + }) + await act(async () => vi.advanceTimersByTimeAsync(200)) + expect(reloads).toEqual(['local-wt:x.ts']) + + reloads = [] + emitFsChanged({ + worktreePath: '/shared', + events: [{ kind: 'update', absolutePath: '/shared/x.ts' }], + connectionId: 'ssh-1' + }) + await act(async () => vi.advanceTimersByTimeAsync(200)) + expect(reloads).toEqual(['ssh-wt:x.ts']) + }) +}) diff --git a/src/renderer/src/hooks/useEditorExternalWatch-subscriptions.test.tsx b/src/renderer/src/hooks/useEditorExternalWatch-subscriptions.test.tsx index a55280182a2..2dc4fab01f6 100644 --- a/src/renderer/src/hooks/useEditorExternalWatch-subscriptions.test.tsx +++ b/src/renderer/src/hooks/useEditorExternalWatch-subscriptions.test.tsx @@ -22,7 +22,9 @@ const subscriptionState = vi.hoisted(() => ({ })) vi.mock('@/store', () => ({ - useAppStore: (selector: (state: unknown) => unknown) => selector({}) + useAppStore: Object.assign((selector: (state: unknown) => unknown) => selector({}), { + getState: () => ({ openFiles: [] }) + }) })) vi.mock('@/runtime/runtime-host-contact-regained', () => ({ subscribeRuntimeHostContactRegained: (id: string, listener: () => void) => { @@ -34,7 +36,11 @@ vi.mock('@/runtime/runtime-file-watch-client', () => ({ subscribeRuntimeFileChanges: subscriptionState.subscribeRuntimeFileChanges })) vi.mock('./editor-external-watch-targets', () => ({ - selectEditorExternalWatchTargets: () => subscriptionState.snapshot, + selectEditorExternalWatchTargets: () => ({ + unwatchedTargetKeys: new Set(), + unwatchedTargetsKey: '', + ...subscriptionState.snapshot + }), getEditorExternalWatchTargetKey: (target: TestWatchTarget) => [ target.worktreeId, diff --git a/src/renderer/src/hooks/useEditorExternalWatch-switch-back.test.tsx b/src/renderer/src/hooks/useEditorExternalWatch-switch-back.test.tsx new file mode 100644 index 00000000000..6b6c0b03b76 --- /dev/null +++ b/src/renderer/src/hooks/useEditorExternalWatch-switch-back.test.tsx @@ -0,0 +1,311 @@ +// @vitest-environment happy-dom + +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { act, createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' + +type TestWatchTarget = { + worktreeId: string + worktreePath: string + connectionId: string | undefined + runtimeEnvironmentId: string | null +} + +const harness = vi.hoisted(() => { + const targets: TestWatchTarget[] = [] + const unwatched: TestWatchTarget[] = [] + const holder: { store: { getState: () => unknown } | null } = { store: null } + return { + keyOf: (target: TestWatchTarget): string => `${target.worktreeId}::client`, + targets, + unwatched, + holder, + readRuntimeFileContent: vi.fn() + } +}) + +vi.mock('@/store', () => ({ + useAppStore: Object.assign( + (selector: (state: unknown) => unknown) => selector(harness.holder.store?.getState()), + { getState: () => harness.holder.store?.getState() } + ) +})) +vi.mock('./editor-external-watch-targets', async (importOriginal) => ({ + ...(await importOriginal>()), + selectEditorExternalWatchTargets: () => ({ + targets: [...harness.targets], + targetsKey: harness.targets.map(harness.keyOf).join('|'), + unwatchedTargetKeys: new Set(harness.unwatched.map(harness.keyOf)), + unwatchedTargetsKey: harness.unwatched.map(harness.keyOf).join('|') + }), + getEditorExternalWatchTargetKey: harness.keyOf +})) +vi.mock('@/runtime/runtime-file-read-client', () => ({ + readRuntimeFileContent: harness.readRuntimeFileContent +})) +vi.mock('@/lib/connection-context', () => ({ getConnectionIdForFile: () => undefined })) + +import { createTestStore } from '@/store/slices/store-test-helpers' +import { useEditorExternalWatch } from './useEditorExternalWatch' +import { attachRestoredTabConflictScan } from '@/components/editor/editor-restored-tab-conflict-scan' +import { getDiskBaselineSignature } from '@/components/editor/diff-content-signature' +import { + isAutosaveSuspendedForFile, + ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT +} from '@/components/editor/editor-autosave' +import { ORCA_WORKTREE_FILE_CHANGE_EVENT } from './worktree-file-change-event' +import { + claimCombinedDiffView, + clearCombinedDiffViewMemory, + readCombinedDiffViewMemory, + releaseCombinedDiffView, + rememberCombinedDiffView +} from '@/components/editor/combined-diff/remember-view/combined-diff-view-cache' + +function WatchProbe(): null { + useEditorExternalWatch() + return null +} + +const active: TestWatchTarget = { + worktreeId: 'active', + worktreePath: '/active', + connectionId: undefined, + runtimeEnvironmentId: null +} +const background: TestWatchTarget = { + worktreeId: 'background', + worktreePath: '/background', + connectionId: undefined, + runtimeEnvironmentId: null +} +const CLEAN = '/background/clean.ts' +const DIRTY = '/background/dirty.ts' + +describe('switching back to a workspace that was not watched', () => { + let store: ReturnType + let container: HTMLDivElement + let root: Root + let previousApi: unknown + let detachScan: () => void + let settleWatch: () => void + let emitIpc: (payload: unknown) => void + let pathExists: Mock<(args: { filePath: string }) => Promise> + let reloads: string[] + let fileChangeEvents: number + const onReload = (event: Event): void => { + if (event instanceof CustomEvent) { + reloads.push(event.detail.relativePath) + } + } + const onFileChange = (): void => { + fileChangeEvents += 1 + } + + const file = (id: string) => store.getState().openFiles.find((f) => f.id === id)! + + async function render(targets: TestWatchTarget[], unwatched: TestWatchTarget[]): Promise { + harness.targets.splice(0, harness.targets.length, ...targets) + harness.unwatched.splice(0, harness.unwatched.length, ...unwatched) + await act(async () => root.render(createElement(WatchProbe))) + } + + function openTab(filePath: string): void { + store.getState().openFile({ + filePath, + relativePath: filePath.slice('/background/'.length), + worktreeId: 'background', + language: 'typescript', + mode: 'edit' + }) + store.getState().setLastKnownDiskSignature(filePath, getDiskBaselineSignature('v1')) + } + + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers({ shouldAdvanceTime: true }) + store = createTestStore() + harness.holder.store = store + openTab(CLEAN) + openTab(DIRTY) + store.getState().setEditorDraft(DIRTY, 'unsaved edit') + store.getState().markFileDirty(DIRTY, true) + pathExists = vi.fn(async (_args: { filePath: string }) => true) + previousApi = (window as unknown as { api?: unknown }).api + ;(window as unknown as { api: unknown }).api = { + fs: { + watchWorktree: vi.fn( + () => + new Promise((resolve) => { + settleWatch = resolve + }) + ), + unwatchWorktree: vi.fn().mockResolvedValue(undefined), + onFsChanged: vi.fn((listener: (payload: unknown) => void) => { + emitIpc = listener + return vi.fn() + }), + pathExists, + pathsExist: vi.fn(async ({ filePaths }: { filePaths: string[] }) => + Promise.all( + filePaths.map(async (filePath) => ({ exists: await pathExists({ filePath }) })) + ) + ) + } + } + reloads = [] + fileChangeEvents = 0 + window.addEventListener(ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT, onReload) + window.addEventListener(ORCA_WORKTREE_FILE_CHANGE_EVENT, onFileChange) + detachScan = attachRestoredTabConflictScan(store) + container = document.body.appendChild(document.createElement('div')) + root = createRoot(container) + }) + + afterEach(async () => { + await act(async () => root.unmount()) + window.removeEventListener(ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT, onReload) + window.removeEventListener(ORCA_WORKTREE_FILE_CHANGE_EVENT, onFileChange) + detachScan() + clearCombinedDiffViewMemory() + container.remove() + vi.useRealTimers() + ;(window as unknown as { api?: unknown }).api = previousApi + }) + + async function switchBack(): Promise { + await render([active], [background]) + await render([active, background], []) + await act(async () => settleWatch()) + await act(async () => vi.advanceTimersByTimeAsync(200)) + } + + it('reloads clean tabs, banners a dirty tab changed on disk, and refreshes git status/Files once', async () => { + harness.readRuntimeFileContent.mockResolvedValue({ content: 'v2 agent write', isBinary: false }) + await switchBack() + + expect(reloads).toEqual(['clean.ts']) + expect(file(DIRTY).externalMutation).toBe('changed') + expect(file(DIRTY).isDirty).toBe(true) + expect(file(CLEAN).externalMutation).toBeFalsy() + expect(fileChangeEvents).toBe(1) + }) + + it('checks dirty tabs only once the watch is back, so a write in between still gets the banner', async () => { + harness.readRuntimeFileContent.mockResolvedValue({ content: 'v1', isBinary: false }) + await render([active], [background]) + await render([active, background], []) + await act(async () => vi.advanceTimersByTimeAsync(200)) + expect(harness.readRuntimeFileContent).not.toHaveBeenCalled() + + // An agent writes after the switch but before the watch is installed. + harness.readRuntimeFileContent.mockResolvedValue({ content: 'v2 late write', isBinary: false }) + await act(async () => settleWatch()) + await act(async () => vi.advanceTimersByTimeAsync(200)) + + expect(file(DIRTY).externalMutation).toBe('changed') + }) + + it('checks dirty tabs again when a late install (capacity, SSH retry) lands', async () => { + harness.readRuntimeFileContent.mockResolvedValue({ content: 'v1', isBinary: false }) + // The request returns while the watcher waits for capacity; the catch-up finds nothing yet. + await switchBack() + expect(file(DIRTY).externalMutation).toBeFalsy() + + harness.readRuntimeFileContent.mockResolvedValue({ + content: 'v2 before install', + isBinary: false + }) + // Main announces the late install with the same overflow it sends after a reconnect. + await act(async () => + emitIpc({ + worktreePath: '/background', + events: [{ kind: 'overflow', absolutePath: '/background' }] + }) + ) + await act(async () => vi.advanceTimersByTimeAsync(200)) + + expect(file(DIRTY).externalMutation).toBe('changed') + }) + + it('marks a clean tab deleted and keeps it when its file was deleted while unwatched', async () => { + harness.readRuntimeFileContent.mockResolvedValue({ content: 'v1', isBinary: false }) + pathExists.mockImplementation(async ({ filePath }: { filePath: string }) => filePath !== CLEAN) + await switchBack() + + expect(file(CLEAN).externalMutation).toBe('deleted') + expect(reloads).toEqual([]) + }) + + it('reloads clean sections of an unmounted combined-diff tab and keeps unsaved section edits', async () => { + const section = (path: string, dirty: boolean) => ({ + key: path, + path, + status: 'modified', + originalContent: 'base', + modifiedContent: dirty ? 'unsaved section edit' : 'old disk', + collapsed: false, + loading: false, + dirty, + diffResult: null, + largeDiffRenderLimit: null + }) + const viewKey = 'background::all-diffs::uncommitted::tab-1' + const lease = claimCombinedDiffView(viewKey, 'background') + releaseCombinedDiffView(lease) + rememberCombinedDiffView(lease, { + scrollTop: 120, + view: { + entrySignature: 'e', + gitStatusSignature: 'g', + sections: [section('clean.ts', false), section('dirty.ts', true)], + sectionHeights: {}, + loadedIndices: [0, 1], + sideBySide: false + } + }) + harness.readRuntimeFileContent.mockResolvedValue({ content: 'v1', isBinary: false }) + await switchBack() + + const view = readCombinedDiffViewMemory(viewKey)?.view + expect(view?.sections[0]).toMatchObject({ loading: true, modifiedContent: '' }) + expect(view?.sections[1]).toMatchObject({ + dirty: true, + originalContent: 'base', + modifiedContent: 'unsaved section edit' + }) + expect(view?.loadedIndices).toEqual([1]) + expect(readCombinedDiffViewMemory(viewKey)?.scrollTop).toBe(120) + }) + + it('leaves a dirty tab alone when disk still matches what it last read', async () => { + harness.readRuntimeFileContent.mockResolvedValue({ content: 'v1', isBinary: false }) + await switchBack() + + expect(file(DIRTY).externalMutation).toBeFalsy() + expect(file(DIRTY).pendingDiskBaselineVerification).toBeFalsy() + }) + + it('does not re-read tabs of a workspace that was never dropped', async () => { + await render([active], []) + await render([active, background], []) + await act(async () => settleWatch()) + await act(async () => vi.advanceTimersByTimeAsync(200)) + + expect(reloads).toEqual([]) + expect(harness.readRuntimeFileContent).not.toHaveBeenCalled() + expect(file(DIRTY).pendingDiskBaselineVerification).toBeFalsy() + }) + + it('never holds autosave on a tab edited before the watch is back', async () => { + // Why: saves are disk-checked by the host, so a stale edit is refused there rather than held here. + await render([active], [background]) + await render([active, background], []) + store.getState().setEditorDraft(CLEAN, 'typed before the watch settled') + store.getState().markFileDirty(CLEAN, true) + await act(async () => {}) + + expect(file(CLEAN).pendingDiskBaselineVerification).toBeFalsy() + expect(isAutosaveSuspendedForFile(file(CLEAN))).toBe(false) + }) +}) diff --git a/src/renderer/src/hooks/useEditorExternalWatch-targets.test.ts b/src/renderer/src/hooks/useEditorExternalWatch-targets.test.ts index f60e34cd5ad..1155d622f02 100644 --- a/src/renderer/src/hooks/useEditorExternalWatch-targets.test.ts +++ b/src/renderer/src/hooks/useEditorExternalWatch-targets.test.ts @@ -75,6 +75,10 @@ describe('getEditorExternalWatchTargets', () => { sshConnectionStates: args.sshConnectionStates ?? new Map(), folderWorkspaces: [], projectGroups: [], + // Why: these cases cover target resolution; a recent visit keeps the workspace inside the watch scope. + lastVisitedAtByWorktreeId: Object.fromEntries( + (args.openFiles ?? []).map((file) => [file.worktreeId, 1]) + ), settings: args.runtimeEnvironmentId === undefined ? null diff --git a/src/renderer/src/hooks/useEditorExternalWatch.test.ts b/src/renderer/src/hooks/useEditorExternalWatch.test.ts index c6c3908a9cd..f249f322eef 100644 --- a/src/renderer/src/hooks/useEditorExternalWatch.test.ts +++ b/src/renderer/src/hooks/useEditorExternalWatch.test.ts @@ -21,7 +21,6 @@ vi.mock('@/components/editor/editor-autosave', async (importOriginal) => { import { buildEditorExternalWatchEventHandler as createExternalWatchEventHandler, - collectOverflowEditorExternalReloadTargets as getOverflowExternalReloadTargets, EXTERNAL_MUTATION_DEBOUNCE_MS } from './editor-external-watch-event-reconciliation' import { getEditorExternalWatchTargetKey as getWatchedTargetKey } from './editor-external-watch-targets' @@ -74,114 +73,6 @@ describe('getWatchedTargetKey', () => { }) }) -describe('getOverflowExternalReloadTargets', () => { - const setExternalMutation = vi.fn() - - beforeEach(() => { - vi.clearAllMocks() - }) - - it('clears tombstones and reloads clean edit tabs on overflow', () => { - vi.mocked(useAppStore.getState).mockReturnValue({ - openFiles: [ - { - id: 'file-1', - worktreeId: 'wt-1', - worktreePath: '/repo', - relativePath: 'notes.md', - mode: 'edit', - isDirty: false, - externalMutation: 'deleted' - }, - { - id: 'file-2', - worktreeId: 'wt-1', - worktreePath: '/repo', - relativePath: 'dirty.md', - mode: 'edit', - isDirty: true - }, - { - id: 'file-3', - worktreeId: 'wt-1', - worktreePath: '/repo', - relativePath: 'staged.ts', - mode: 'diff', - diffSource: 'staged', - isDirty: false - } - ], - setExternalMutation - } as never) - - expect( - getOverflowExternalReloadTargets({ - worktreeId: 'wt-1', - worktreePath: '/repo' - }) - ).toEqual([ - { - worktreeId: 'wt-1', - worktreePath: '/repo', - relativePath: 'notes.md', - runtimeEnvironmentId: null - }, - { - worktreeId: 'wt-1', - worktreePath: '/repo', - relativePath: 'staged.ts', - runtimeEnvironmentId: null - } - ]) - expect(setExternalMutation).toHaveBeenCalledWith('file-1', null) - }) - - it('limits overflow reload targets to the matching runtime owner', () => { - vi.mocked(useAppStore.getState).mockReturnValue({ - openFiles: [ - { - id: 'local-file', - worktreeId: 'wt-1', - worktreePath: '/repo', - relativePath: 'local.md', - mode: 'edit', - isDirty: false, - externalMutation: 'deleted', - runtimeEnvironmentId: null - }, - { - id: 'runtime-file', - worktreeId: 'wt-1', - worktreePath: '/repo', - relativePath: 'runtime.md', - mode: 'edit', - isDirty: false, - externalMutation: 'deleted', - runtimeEnvironmentId: 'env-1' - } - ], - setExternalMutation - } as never) - - expect( - getOverflowExternalReloadTargets({ - worktreeId: 'wt-1', - worktreePath: '/repo', - runtimeEnvironmentId: 'env-1' - }) - ).toEqual([ - { - worktreeId: 'wt-1', - worktreePath: '/repo', - relativePath: 'runtime.md', - runtimeEnvironmentId: 'env-1' - } - ]) - expect(setExternalMutation).toHaveBeenCalledWith('runtime-file', null) - expect(setExternalMutation).not.toHaveBeenCalledWith('local-file', null) - }) -}) - describe('createExternalWatchEventHandler tombstone coalescing', () => { const setExternalMutation = vi.fn() const findTarget = ( diff --git a/src/renderer/src/hooks/useEditorExternalWatch.ts b/src/renderer/src/hooks/useEditorExternalWatch.ts index 8eef0f5324f..c13919d9632 100644 --- a/src/renderer/src/hooks/useEditorExternalWatch.ts +++ b/src/renderer/src/hooks/useEditorExternalWatch.ts @@ -8,8 +8,21 @@ import { selectEditorExternalWatchTargets, type EditorExternalWatchTarget } from './editor-external-watch-targets' +import { markWorktreeCatchUpPayload } from './worktree-file-change-event' import { buildEditorExternalWatchEventHandler } from './editor-external-watch-event-reconciliation' import { verifyLatchedEditorMoveDestinations } from './editor-external-watch-disk-verification' +import { + acquireWatchRoot, + releaseAllWatchRoots, + releaseWatchRoot, + type HeldWatchRoots +} from './editor-external-watch-local-roots' + +type FsChangedHandler = ( + payload: FsChangedPayload, + runtimeEnvironmentId?: string | null, + onlyTarget?: EditorExternalWatchTarget +) => void function warnExternalWatchFailure(target: EditorExternalWatchTarget, err: unknown): void { console.warn('[filesystem-watch] failed to watch worktree', { @@ -22,18 +35,18 @@ function warnExternalWatchFailure(target: EditorExternalWatchTarget, err: unknow /** Keeps editor filesystem subscriptions alive beyond any individual editor surface. */ export function useEditorExternalWatch(): void { - const { targets, targetsKey } = useAppStore(selectEditorExternalWatchTargets) + const snapshot = useAppStore(selectEditorExternalWatchTargets) const targetsRef = useRef([]) - const latestTargetsRef = useRef(targets) - latestTargetsRef.current = targets + const unwatchedTargetKeysRef = useRef>(new Set()) const remoteWatchUnsubsRef = useRef(new Map void>()) - const fsChangedHandlerRef = useRef< - ((payload: FsChangedPayload, runtimeEnvironmentId?: string | null) => void) | null - >(null) + const heldWatchRootsRef = useRef(new Map()) + const fsChangedHandlerRef = useRef(null) // Why: diff targets so unchanged worktrees keep their subscription; full teardown on every store change drops events in the gap. useEffect(() => { - const nextTargets = latestTargetsRef.current + const nextTargets = snapshot.targets + const previousUnwatchedKeys = unwatchedTargetKeysRef.current + unwatchedTargetKeysRef.current = snapshot.unwatchedTargetKeys const previousTargets = targetsRef.current const previousKeys = new Set(previousTargets.map(getEditorExternalWatchTargetKey)) const nextKeys = new Set(nextTargets.map(getEditorExternalWatchTargetKey)) @@ -44,6 +57,37 @@ export function useEditorExternalWatch(): void { (target) => !previousKeys.has(getEditorExternalWatchTargetKey(target)) ) + // Why: publish first — a runtime stream that is already ready resyncs synchronously and must find its target. + targetsRef.current = nextTargets + // Why: acquire before release so a root shared by an added and a removed target is never dropped in between. + for (const target of added) { + // Why: writes made while it was unwatched (background trim) were never reported. Resync like a + // reconnect does — one overflow through the normal pipeline — once the watch is in place. + const resync = previousUnwatchedKeys.has(getEditorExternalWatchTargetKey(target)) + ? () => + fsChangedHandlerRef.current?.( + markWorktreeCatchUpPayload({ + worktreePath: target.worktreePath, + events: [{ kind: 'overflow', absolutePath: target.worktreePath }], + connectionId: target.connectionId + }), + target.runtimeEnvironmentId, + target + ) + : undefined + if (target.runtimeEnvironmentId) { + // Why: the host's ready event means its watcher is installed, so nothing after the resync is lost. + subscribeRuntimeTarget(target, remoteWatchUnsubsRef.current, fsChangedHandlerRef, resync) + continue + } + const installed = acquireWatchRoot(heldWatchRootsRef.current, target, (err) => { + // Why: SSH providers can disappear while tabs still reference the worktree; report the failure without an uncaught renderer promise. + warnExternalWatchFailure(target, err) + }) + if (resync) { + void installed.then(resync) + } + } for (const target of removed) { const key = getEditorExternalWatchTargetKey(target) const remoteUnsubscribe = remoteWatchUnsubsRef.current.get(key) @@ -51,41 +95,26 @@ export function useEditorExternalWatch(): void { remoteUnsubscribe() remoteWatchUnsubsRef.current.delete(key) } else { - void window.api.fs.unwatchWorktree({ - worktreePath: target.worktreePath, - connectionId: target.connectionId - }) + releaseWatchRoot(heldWatchRootsRef.current, target) } } - for (const target of added) { - if (target.runtimeEnvironmentId) { - subscribeRuntimeTarget(target, remoteWatchUnsubsRef.current, fsChangedHandlerRef) - continue - } - void window.api.fs - .watchWorktree({ - worktreePath: target.worktreePath, - connectionId: target.connectionId - }) - .catch((err) => { - // Why: SSH providers can disappear while tabs still reference the worktree; report the failure without an uncaught renderer promise. - warnExternalWatchFailure(target, err) - }) - } - targetsRef.current = nextTargets // Why: final unmount cleanup owns teardown so target changes remain differential. - }, [targetsKey]) + // Why: the selector keeps the snapshot identity until a target or unwatched key changes. + }, [snapshot]) // Why: one stable fs:changed listener prevents target-key changes from opening an event-loss gap. useEffect(() => { const remoteWatchUnsubs = remoteWatchUnsubsRef.current + const heldWatchRoots = heldWatchRootsRef.current const { handleFsChanged, dispose } = buildEditorExternalWatchEventHandler( - (worktreePath, runtimeEnvironmentId) => - targetsRef.current.find( + (worktreePath, runtimeEnvironmentId, connectionId) => + targetsRef.current.filter( (target) => normalizeRuntimePathForComparison(target.worktreePath) === normalizeRuntimePathForComparison(worktreePath) && - target.runtimeEnvironmentId === runtimeEnvironmentId + target.runtimeEnvironmentId === runtimeEnvironmentId && + // Why: a local and an SSH root can share a path; runtime streams are already per host. + (runtimeEnvironmentId !== null || target.connectionId === connectionId) ) ) const unsubscribe = window.api.fs.onFsChanged((payload) => handleFsChanged(payload, null)) @@ -95,20 +124,13 @@ export function useEditorExternalWatch(): void { unsubscribe() dispose() fsChangedHandlerRef.current = null - for (const target of targetsRef.current) { - const key = getEditorExternalWatchTargetKey(target) - const remoteUnsubscribe = remoteWatchUnsubs.get(key) - if (remoteUnsubscribe) { - remoteUnsubscribe() - } else { - void window.api.fs.unwatchWorktree({ - worktreePath: target.worktreePath, - connectionId: target.connectionId - }) - } + for (const remoteUnsubscribe of remoteWatchUnsubs.values()) { + remoteUnsubscribe() } remoteWatchUnsubs.clear() + releaseAllWatchRoots(heldWatchRoots) targetsRef.current = [] + unwatchedTargetKeysRef.current = new Set() // Why: module-scoped reload timers survive StrictMode's synthetic cleanup; a late reload dispatch is harmless. } }, []) @@ -117,9 +139,8 @@ export function useEditorExternalWatch(): void { function subscribeRuntimeTarget( target: EditorExternalWatchTarget, remoteWatchUnsubs: Map void>, - fsChangedHandlerRef: { - current: ((payload: FsChangedPayload, runtimeEnvironmentId?: string | null) => void) | null - } + fsChangedHandlerRef: { current: FsChangedHandler | null }, + onFirstReady: (() => void) | undefined ): void { const runtimeEnvironmentId = target.runtimeEnvironmentId if (!runtimeEnvironmentId) { @@ -131,7 +152,8 @@ function subscribeRuntimeTarget( subscribeEditorRuntimeFileWatch( { ...target, runtimeEnvironmentId }, (payload) => fsChangedHandlerRef.current?.(payload, runtimeEnvironmentId), - (error) => warnExternalWatchFailure(target, error) + (error) => warnExternalWatchFailure(target, error), + onFirstReady ) ) } diff --git a/src/renderer/src/hooks/worktree-file-change-event.ts b/src/renderer/src/hooks/worktree-file-change-event.ts index db1627feec9..78c70867825 100644 --- a/src/renderer/src/hooks/worktree-file-change-event.ts +++ b/src/renderer/src/hooks/worktree-file-change-event.ts @@ -13,3 +13,15 @@ declare global { 'orca:worktree-file-change': CustomEvent } } + +// Why: a switch-back catch-up is synthesized in the renderer, so consumers on the raw IPC bus never see it. +const catchUpPayloads = new WeakSet() + +export function markWorktreeCatchUpPayload(payload: FsChangedPayload): FsChangedPayload { + catchUpPayloads.add(payload) + return payload +} + +export function isWorktreeCatchUpPayload(payload: FsChangedPayload): boolean { + return catchUpPayloads.has(payload) +} diff --git a/src/renderer/src/runtime/runtime-file-watch-client.ts b/src/renderer/src/runtime/runtime-file-watch-client.ts index ca7b3271cad..7f230d48336 100644 --- a/src/renderer/src/runtime/runtime-file-watch-client.ts +++ b/src/renderer/src/runtime/runtime-file-watch-client.ts @@ -15,6 +15,8 @@ type RuntimeFileWatchEvent = type RuntimeFileWatchListener = { onPayload: (payload: FsChangedPayload) => void onError?: (error: Error) => void + /** Fires once the host reports its watcher installed (immediately when joining a live one). */ + onReady?: () => void } type SharedRuntimeFileWatch = { @@ -26,6 +28,7 @@ type SharedRuntimeFileWatch = { remoteSubscriptionId: string | null keepStreamUntilReady: boolean closed: boolean + ready: boolean } const sharedRuntimeFileWatches = new Map() @@ -41,14 +44,17 @@ function getSharedRuntimeFileWatchKey( export async function subscribeRuntimeFileChanges( context: RuntimeFileOperationArgs, onPayload: (payload: FsChangedPayload) => void, - onError?: (error: Error) => void + onError?: (error: Error) => void, + onReady?: () => void ): Promise<() => void> { const target = context.target if (target.kind !== 'environment' || !context.worktreeId || !context.worktreePath) { - return window.api.fs.onFsChanged(onPayload) + const unsubscribe = window.api.fs.onFsChanged(onPayload) + onReady?.() + return unsubscribe } - const listener: RuntimeFileWatchListener = { onPayload, onError } + const listener: RuntimeFileWatchListener = { onPayload, onError, onReady } const key = getSharedRuntimeFileWatchKey( target.environmentId, context.worktreeId, @@ -60,6 +66,9 @@ export async function subscribeRuntimeFileChanges( sharedRuntimeFileWatches.set(key, shared) } shared.listeners.add(listener) + if (shared.ready) { + onReady?.() + } try { await shared.start } catch (err) { @@ -93,7 +102,8 @@ function createSharedRuntimeFileWatch( unsubscribe: null, remoteSubscriptionId: null, keepStreamUntilReady: isWebRuntimeFileWatchSharedSocket(), - closed: false + closed: false, + ready: false } // Why: editor reloads and the Explorer can watch the same remote worktree. // Keep one runtime WebSocket/server watcher and fan out events in renderer. @@ -157,6 +167,11 @@ function handleSharedRuntimeFileWatchResponse( if (!shared.keepStreamUntilReady) { unwatchSharedRuntimeFileWatch(shared) } + } else if (event.type === 'ready' && !shared.ready) { + shared.ready = true + for (const listener of Array.from(shared.listeners)) { + listener.onReady?.() + } } } else if (event.type === 'changed') { for (const listener of Array.from(shared.listeners)) { diff --git a/src/renderer/src/runtime/runtime-path-existence-batch.test.ts b/src/renderer/src/runtime/runtime-path-existence-batch.test.ts index fff0c170fad..43ab0453d14 100644 --- a/src/renderer/src/runtime/runtime-path-existence-batch.test.ts +++ b/src/renderer/src/runtime/runtime-path-existence-batch.test.ts @@ -86,3 +86,32 @@ it('remote errors stay errors, and an out-of-scope path cannot read the local fi ]) expect(fsPathExists).not.toHaveBeenCalled() }) +it('coded-absence mode reports a file gone only from a per-path answer', async () => { + const strict = { codedAbsenceOnly: true } + status(['files.pathsExist']) + runtimeEnvironmentCall.mockResolvedValue({ + id: 'batch', + ok: true, + result: [{ exists: false }, { error: 'ENOENT: no such file or directory' }] + }) + expect(await runtimePathsExist(context, paths.slice(0, 2), undefined, strict)).toEqual([ + { exists: false }, + { error: 'ENOENT: no such file or directory' } + ]) + // A whole-request "not found" (re-pairing) must not read as every path missing. + runtimeEnvironmentCall.mockResolvedValue({ + id: 'batch', + ok: false, + error: { code: 'not_found', message: 'worktree not found' } + }) + await expect(runtimePathsExist(context, [paths[0]], undefined, strict)).rejects.toThrow() +}) +it('coded-absence mode cannot get a per-path answer from an old host', async () => { + status([]) + expect( + await runtimePathsExist(context, [paths[0]], undefined, { codedAbsenceOnly: true }) + ).toEqual([{ error: expect.any(String) }]) + expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith( + expect.objectContaining({ method: 'files.stat' }) + ) +}) diff --git a/src/renderer/src/runtime/runtime-path-existence-batch.ts b/src/renderer/src/runtime/runtime-path-existence-batch.ts index 08cff9c47ed..21f1c0d708b 100644 --- a/src/renderer/src/runtime/runtime-path-existence-batch.ts +++ b/src/renderer/src/runtime/runtime-path-existence-batch.ts @@ -11,24 +11,36 @@ import { captureRuntimeEnvironmentRequestRevision } from './runtime-environment- import { callRuntimeRpc, runtimeEnvironmentSupportsCapability } from './runtime-rpc-client' import { RuntimeRpcCallError } from './runtime-rpc-result' +// Machine token, not UI copy: callers treat any error row as "cannot tell". +const UNVERIFIABLE_PATH_EXISTENCE = 'unverifiable' + +/** + * `codedAbsenceOnly`: report `exists: false` only from a per-path ENOENT, never from a message match + * or a whole-request "not found"; anything less certain comes back as an error row. + */ export async function runtimePathsExist( context: RuntimeFileOperationArgs, paths: string[], - expectedPairingRevision?: number + expectedPairingRevision?: number, + options: { codedAbsenceOnly?: boolean } = {} ): Promise { + const unverifiable = (): PathExistenceResult[] => + paths.map(() => ({ error: UNVERIFIABLE_PATH_EXISTENCE })) const routes = paths.map((path) => getRemoteFileArgs(context, path)) const first = routes[0] const expectedEnvironmentPairingRevision = first ? captureRuntimeEnvironmentRequestRevision(first.target.environmentId, expectedPairingRevision) : undefined - const fallback = () => - Promise.all( - paths.map((path) => - capturePathExistence(() => - runtimePathExists(context, path, expectedEnvironmentPairingRevision) + const fallback = async (): Promise => + options.codedAbsenceOnly + ? unverifiable() + : Promise.all( + paths.map((path) => + capturePathExistence(() => + runtimePathExists(context, path, expectedEnvironmentPairingRevision) + ) + ) ) - ) - ) if (!first || routes.some((route) => !route)) { if (routes.every((route) => !route) && window.api.fs.pathsExist) { // Scalar routing performs the same ownership fence before local IPC. @@ -62,6 +74,9 @@ export async function runtimePathsExist( ), paths.length ) + if (options.codedAbsenceOnly) { + return result + } // Preserve runtimePathExists's legacy missing-error interpretation. return result.map((row) => 'error' in row && isMissingRuntimePathError(row.error) ? { exists: false } : row @@ -70,7 +85,7 @@ export async function runtimePathsExist( if (error instanceof RuntimeRpcCallError && error.code === 'method_not_found') { return fallback() } - if (isMissingRuntimePathError(error)) { + if (!options.codedAbsenceOnly && isMissingRuntimePathError(error)) { return paths.map(() => ({ exists: false })) } throw error diff --git a/src/shared/filesystem-entry-types.ts b/src/shared/filesystem-entry-types.ts index ec769bc8e31..234e74a2c79 100644 --- a/src/shared/filesystem-entry-types.ts +++ b/src/shared/filesystem-entry-types.ts @@ -27,4 +27,6 @@ export type FsChangeEvent = { export type FsChangedPayload = { worktreePath: string events: FsChangeEvent[] + /** SSH connection the root was watched on; absent for local roots. Lets the renderer tell a local and an SSH root at the same path apart. */ + connectionId?: string }