diff --git a/package.json b/package.json index 05525f28e..975867de8 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "@electron-toolkit/preload": "^3.0.2", "@electron-toolkit/utils": "^4.0.0", "@monaco-editor/react": "^4.7.0", + "@parcel/watcher": "^2.5.6", "@tanstack/react-virtual": "^3.13.23", "@tiptap/extension-code-block-lowlight": "^3.22.2", "@tiptap/extension-image": "^3.22.1", @@ -125,6 +126,7 @@ "packageManager": "pnpm@10.24.0+sha512.01ff8ae71b4419903b65c60fb2dc9d34cf8bb6e06d03bde112ef38f7a34d6904c424ba66bea5cdcf12890230bf39f9580473140ed9c946fef328b6e5238a345a", "pnpm": { "onlyBuiltDependencies": [ + "@parcel/watcher", "electron", "esbuild", "node-pty" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5efda2edc..5fe022531 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -31,6 +31,9 @@ importers: '@monaco-editor/react': specifier: ^4.7.0 version: 4.7.0(monaco-editor@0.55.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@parcel/watcher': + specifier: ^2.5.6 + version: 2.5.6 '@tanstack/react-virtual': specifier: ^3.13.23 version: 3.13.23(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -1223,6 +1226,94 @@ packages: cpu: [x64] os: [win32] + '@parcel/watcher-android-arm64@2.5.6': + resolution: {integrity: sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [android] + + '@parcel/watcher-darwin-arm64@2.5.6': + resolution: {integrity: sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [darwin] + + '@parcel/watcher-darwin-x64@2.5.6': + resolution: {integrity: sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [darwin] + + '@parcel/watcher-freebsd-x64@2.5.6': + resolution: {integrity: sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [freebsd] + + '@parcel/watcher-linux-arm-glibc@2.5.6': + resolution: {integrity: sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@parcel/watcher-linux-arm-musl@2.5.6': + resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + libc: [musl] + + '@parcel/watcher-linux-arm64-glibc@2.5.6': + resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@parcel/watcher-linux-arm64-musl@2.5.6': + resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@parcel/watcher-linux-x64-glibc@2.5.6': + resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@parcel/watcher-linux-x64-musl@2.5.6': + resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@parcel/watcher-win32-arm64@2.5.6': + resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [win32] + + '@parcel/watcher-win32-ia32@2.5.6': + resolution: {integrity: sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==} + engines: {node: '>= 10.0.0'} + cpu: [ia32] + os: [win32] + + '@parcel/watcher-win32-x64@2.5.6': + resolution: {integrity: sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [win32] + + '@parcel/watcher@2.5.6': + resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==} + engines: {node: '>= 10.0.0'} + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -6742,6 +6833,66 @@ snapshots: '@oxlint/binding-win32-x64-msvc@1.56.0': optional: true + '@parcel/watcher-android-arm64@2.5.6': + optional: true + + '@parcel/watcher-darwin-arm64@2.5.6': + optional: true + + '@parcel/watcher-darwin-x64@2.5.6': + optional: true + + '@parcel/watcher-freebsd-x64@2.5.6': + optional: true + + '@parcel/watcher-linux-arm-glibc@2.5.6': + optional: true + + '@parcel/watcher-linux-arm-musl@2.5.6': + optional: true + + '@parcel/watcher-linux-arm64-glibc@2.5.6': + optional: true + + '@parcel/watcher-linux-arm64-musl@2.5.6': + optional: true + + '@parcel/watcher-linux-x64-glibc@2.5.6': + optional: true + + '@parcel/watcher-linux-x64-musl@2.5.6': + optional: true + + '@parcel/watcher-win32-arm64@2.5.6': + optional: true + + '@parcel/watcher-win32-ia32@2.5.6': + optional: true + + '@parcel/watcher-win32-x64@2.5.6': + optional: true + + '@parcel/watcher@2.5.6': + dependencies: + detect-libc: 2.1.2 + is-glob: 4.0.3 + node-addon-api: 7.1.1 + picomatch: 4.0.3 + optionalDependencies: + '@parcel/watcher-android-arm64': 2.5.6 + '@parcel/watcher-darwin-arm64': 2.5.6 + '@parcel/watcher-darwin-x64': 2.5.6 + '@parcel/watcher-freebsd-x64': 2.5.6 + '@parcel/watcher-linux-arm-glibc': 2.5.6 + '@parcel/watcher-linux-arm-musl': 2.5.6 + '@parcel/watcher-linux-arm64-glibc': 2.5.6 + '@parcel/watcher-linux-arm64-musl': 2.5.6 + '@parcel/watcher-linux-x64-glibc': 2.5.6 + '@parcel/watcher-linux-x64-musl': 2.5.6 + '@parcel/watcher-win32-arm64': 2.5.6 + '@parcel/watcher-win32-ia32': 2.5.6 + '@parcel/watcher-win32-x64': 2.5.6 + '@pkgjs/parseargs@0.11.0': optional: true diff --git a/src/main/index.ts b/src/main/index.ts index caf51e115..bad3514c4 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -6,6 +6,7 @@ import { StatsCollector, initStatsPath } from './stats/collector' import { ClaudeUsageStore, initClaudeUsagePath } from './claude-usage/store' import { CodexUsageStore, initCodexUsagePath } from './codex-usage/store' import { killAllPty } from './ipc/pty' +import { closeAllWatchers } from './ipc/filesystem-watcher' import { registerCoreHandlers } from './ipc/register-core-handlers' import { triggerStartupNotificationRegistration } from './ipc/notifications' import { OrcaRuntimeService } from './runtime/orca-runtime' @@ -146,6 +147,7 @@ app.on('before-quit', () => { // agent_start events with no matching stops. stats?.flush() killAllPty() + void closeAllWatchers() if (runtimeRpc) { void runtimeRpc.stop().catch((error) => { console.error('[runtime] Failed to stop local RPC transport:', error) diff --git a/src/main/ipc/filesystem-watcher.ts b/src/main/ipc/filesystem-watcher.ts new file mode 100644 index 000000000..640f850cc --- /dev/null +++ b/src/main/ipc/filesystem-watcher.ts @@ -0,0 +1,358 @@ +import { ipcMain, type WebContents } from 'electron' +import * as path from 'path' +import { stat } from 'fs/promises' +import type { AsyncSubscription, Event as WatcherEvent } from '@parcel/watcher' +import type { FsChangeEvent, FsChangedPayload } from '../../shared/types' + +// ── Ignore patterns ────────────────────────────────────────────────── +// Why: high-churn directories are suppressed at the native watcher level +// so events never leave the OS kernel. This list is separate from the +// File Explorer display filter (which only hides rows). Directories like +// `dist` and `build` remain visible in the tree but will not auto-refresh. + +const WATCHER_IGNORE_DIRS: string[] = [ + '.git', + 'node_modules', + 'dist', + 'build', + '.next', + '.cache', + '__pycache__', + 'target', + '.venv' +] + +// ── Debounce helpers ───────────────────────────────────────────────── + +const DEBOUNCE_TRAILING_MS = 150 +const DEBOUNCE_MAX_WAIT_MS = 500 + +type DebouncedBatch = { + events: WatcherEvent[] + timer: ReturnType | null + firstEventAt: number +} + +// ── Per-root watcher state ─────────────────────────────────────────── + +type WatchedRoot = { + subscription: AsyncSubscription + listeners: Map // webContents.id -> WebContents + batch: DebouncedBatch +} + +// ── Module state ───────────────────────────────────────────────────── + +const watchedRoots = new Map() + +// ── Path normalization ─────────────────────────────────────────────── + +function normalizeRootPath(rootPath: string): string { + let resolved = path.resolve(rootPath) + // Why: on Windows, watcher events may report lowercase drive letters while + // stored worktree paths use uppercase. Normalizing here ensures the renderer's + // POSIX normalization produces casing-consistent results (see design §4.4). + if (/^[a-zA-Z]:/.test(resolved)) { + resolved = resolved.charAt(0).toUpperCase() + resolved.slice(1) + } + return resolved +} + +function normalizeEventPath(eventPath: string): string { + let resolved = path.resolve(eventPath) + if (/^[a-zA-Z]:/.test(resolved)) { + resolved = resolved.charAt(0).toUpperCase() + resolved.slice(1) + } + return resolved +} + +// ── Event coalescing ───────────────────────────────────────────────── +// Why: within a single flush window the same path can appear multiple times. +// Keep the last event per path, except: delete→create emits both (the delete +// triggers subtree cleanup, the create triggers parent refresh); create→delete +// is dropped entirely (net no-op). See design §4.4. + +function coalesceEvents( + raw: WatcherEvent[] +): { type: 'create' | 'update' | 'delete'; path: string }[] { + const lastByPath = new Map() + const deleteBeforeCreate = new Set() + + for (let i = 0; i < raw.length; i++) { + const evt = raw[i] + const p = normalizeEventPath(evt.path) + const prev = lastByPath.get(p) + + if (prev) { + // delete followed by create → emit both + if (prev.type === 'delete' && evt.type === 'create') { + deleteBeforeCreate.add(p) + } + // create followed by delete → net no-op, remove both + if (prev.type === 'create' && evt.type === 'delete') { + lastByPath.delete(p) + deleteBeforeCreate.delete(p) + continue + } + } + + lastByPath.set(p, { type: evt.type, index: i }) + + // Why: if a later event (e.g. update) supersedes a delete→create sequence, + // the stale delete must be dropped. Otherwise the final output would include + // a spurious delete + the new event type (e.g. delete→create→update would + // emit delete+update, but the file exists so the delete is wrong). See §4.4. + if (evt.type !== 'create' && deleteBeforeCreate.has(p)) { + deleteBeforeCreate.delete(p) + } + } + + const result: { type: 'create' | 'update' | 'delete'; path: string }[] = [] + + // Emit delete events first for paths that have delete→create + for (const p of deleteBeforeCreate) { + result.push({ type: 'delete', path: p }) + } + + // Emit the last event for each path + for (const [p, entry] of lastByPath) { + result.push({ type: entry.type, path: p }) + } + + return result +} + +// ── Stat helper for isDirectory ────────────────────────────────────── + +async function tryStatIsDirectory(filePath: string): Promise { + try { + const s = await stat(filePath) + return s.isDirectory() + } catch { + // Why: if stat fails (EPERM, vanished temp file), return undefined. + // The renderer treats undefined the same as a file event (parent-only + // invalidation), which is the safe default. See design §4.4. + return undefined + } +} + +// ── Flush and emit ─────────────────────────────────────────────────── + +async function flushBatch(rootKey: string, root: WatchedRoot): Promise { + const rawEvents = root.batch.events.splice(0) + root.batch.timer = null + root.batch.firstEventAt = 0 + + if (rawEvents.length === 0 || root.listeners.size === 0) { + return + } + + const coalesced = coalesceEvents(rawEvents) + + // Build the payload with isDirectory info + const events: FsChangeEvent[] = await Promise.all( + coalesced.map(async (evt) => { + // Why: for delete events the path no longer exists on disk, so stat is + // not possible. Set isDirectory to undefined and let the renderer infer + // from dirCache (if the deleted path is a dirCache key, it's a directory). + const isDirectory = evt.type === 'delete' ? undefined : await tryStatIsDirectory(evt.path) + + return { + kind: evt.type, + absolutePath: evt.path, + isDirectory + } + }) + ) + + const payload: FsChangedPayload = { + worktreePath: rootKey, + events + } + + for (const [, wc] of root.listeners) { + if (!wc.isDestroyed()) { + wc.send('fs:changed', payload) + } + } +} + +function scheduleBatchFlush(rootKey: string, root: WatchedRoot): void { + const now = Date.now() + + if (root.batch.firstEventAt === 0) { + root.batch.firstEventAt = now + } + + // If we've exceeded the max wait, flush immediately + if (now - root.batch.firstEventAt >= DEBOUNCE_MAX_WAIT_MS) { + if (root.batch.timer) { + clearTimeout(root.batch.timer) + } + void flushBatch(rootKey, root) + return + } + + // Trailing-edge debounce: reset timer on each new event + if (root.batch.timer) { + clearTimeout(root.batch.timer) + } + root.batch.timer = setTimeout(() => void flushBatch(rootKey, root), DEBOUNCE_TRAILING_MS) +} + +// ── Watcher creation ───────────────────────────────────────────────── + +async function createWatcher(rootKey: string, rootPath: string): Promise { + // Why: @parcel/watcher is a native module that may not load in all + // environments. Dynamic import keeps the require() lazy. + const watcher = await import('@parcel/watcher') + + const root: WatchedRoot = { + subscription: null!, + listeners: new Map(), + batch: { events: [], timer: null, firstEventAt: 0 } + } + + try { + root.subscription = await watcher.subscribe( + rootPath, + (err, events) => { + if (err) { + // Why: watcher errors (including watched-root deletion) are treated + // as overflow so the renderer conservatively refreshes all visible + // tree state rather than trusting possibly-invalid caches (§7.2, §7.3). + console.error(`[filesystem-watcher] error for ${rootKey}:`, err) + const overflowPayload: FsChangedPayload = { + worktreePath: rootKey, + events: [{ kind: 'overflow', absolutePath: rootKey }] + } + for (const [, wc] of root.listeners) { + if (!wc.isDestroyed()) { + wc.send('fs:changed', overflowPayload) + } + } + // Why: after a watcher error the native subscription may be invalid + // (e.g. watched root was deleted). Tear down the dead watcher so we + // don't leave a dangling subscription for a root that no longer + // exists on disk (§7.3). + if (root.batch.timer) { + clearTimeout(root.batch.timer) + } + void root.subscription.unsubscribe().catch(() => { + // Already errored — ignore cleanup failures + }) + watchedRoots.delete(rootKey) + return + } + + root.batch.events.push(...events) + scheduleBatchFlush(rootKey, root) + }, + { + ignore: WATCHER_IGNORE_DIRS + } + ) + } catch (err) { + // Why: if the watcher backend throws synchronously on a deleted root + // or permission error, log rather than crashing the main process (§7.3). + console.error(`[filesystem-watcher] failed to subscribe ${rootKey}:`, err) + throw err + } + + return root +} + +// ── Subscribe / Unsubscribe ────────────────────────────────────────── + +async function subscribe(worktreePath: string, sender: WebContents): Promise { + const rootKey = normalizeRootPath(worktreePath) + + let root = watchedRoots.get(rootKey) + if (!root) { + // Verify root exists and is a directory + try { + const s = await stat(rootKey) + if (!s.isDirectory()) { + console.warn(`[filesystem-watcher] not a directory: ${rootKey}`) + return + } + } catch { + console.warn(`[filesystem-watcher] cannot stat root: ${rootKey}`) + return + } + + try { + root = await createWatcher(rootKey, rootKey) + } catch { + // Why: createWatcher already logged the error. Swallow it here so the + // renderer's watchWorktree call resolves without crashing the main + // process. The watcher simply won't be active for this root (§7.3). + return + } + watchedRoots.set(rootKey, root) + } + + // Why: only register the `destroyed` listener once per sender. If the same + // renderer calls watchWorktree for the same root multiple times (e.g. after + // a React re-mount), re-registering would accumulate duplicate listeners + // that each call unsubscribe on destroy, causing redundant cleanup work. + if (!root.listeners.has(sender.id)) { + sender.once('destroyed', () => { + unsubscribe(rootKey, sender.id) + }) + } + + root.listeners.set(sender.id, sender) +} + +function unsubscribe(worktreePath: string, senderId: number): void { + const rootKey = normalizeRootPath(worktreePath) + const root = watchedRoots.get(rootKey) + if (!root) { + return + } + + root.listeners.delete(senderId) + + // Tear down the watcher when the last subscriber leaves + if (root.listeners.size === 0) { + if (root.batch.timer) { + clearTimeout(root.batch.timer) + } + void root.subscription.unsubscribe().catch((err: unknown) => { + console.error(`[filesystem-watcher] unsubscribe error for ${rootKey}:`, err) + }) + watchedRoots.delete(rootKey) + } +} + +// ── Public API ─────────────────────────────────────────────────────── + +export function registerFilesystemWatcherHandlers(): void { + ipcMain.handle( + 'fs:watchWorktree', + async (event, args: { worktreePath: string }): Promise => { + await subscribe(args.worktreePath, event.sender) + } + ) + + ipcMain.handle('fs:unwatchWorktree', (_event, args: { worktreePath: string }): void => { + const senderId = _event.sender.id + unsubscribe(args.worktreePath, senderId) + }) +} + +/** Tear down all watchers on app shutdown. */ +export async function closeAllWatchers(): Promise { + for (const [rootKey, root] of watchedRoots) { + if (root.batch.timer) { + clearTimeout(root.batch.timer) + } + try { + await root.subscription.unsubscribe() + } catch (err) { + console.error(`[filesystem-watcher] shutdown unsubscribe error for ${rootKey}:`, err) + } + } + watchedRoots.clear() +} diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index 3dd475ad2..f38db5048 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -157,7 +157,17 @@ export function registerFilesystemHandlers(store: Store): void { ipcMain.handle('fs:deletePath', async (_event, args: { targetPath: string }): Promise => { const targetPath = await resolveAuthorizedPath(args.targetPath, store) - await shell.trashItem(targetPath) + // Why: once auto-refresh exists, an external delete can race with a + // UI-initiated delete. Swallowing ENOENT keeps the action idempotent + // from the user's perspective (design §7.1). + try { + await shell.trashItem(targetPath) + } catch (error) { + if (isENOENT(error)) { + return + } + throw error + } }) registerFilesystemMutationHandlers(store) diff --git a/src/main/ipc/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers.test.ts index 9143f284d..b8a90c7a7 100644 --- a/src/main/ipc/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers.test.ts @@ -17,7 +17,8 @@ const { registerClipboardHandlersMock, registerUpdaterHandlersMock, registerBrowserHandlersMock, - setTrustedBrowserRendererWebContentsIdMock + setTrustedBrowserRendererWebContentsIdMock, + registerFilesystemWatcherHandlersMock } = vi.hoisted(() => ({ registerCliHandlersMock: vi.fn(), registerPreflightHandlersMock: vi.fn(), @@ -35,7 +36,8 @@ const { registerClipboardHandlersMock: vi.fn(), registerUpdaterHandlersMock: vi.fn(), registerBrowserHandlersMock: vi.fn(), - setTrustedBrowserRendererWebContentsIdMock: vi.fn() + setTrustedBrowserRendererWebContentsIdMock: vi.fn(), + registerFilesystemWatcherHandlersMock: vi.fn() })) vi.mock('./cli', () => ({ @@ -86,6 +88,10 @@ vi.mock('./filesystem', () => ({ registerFilesystemHandlers: registerFilesystemHandlersMock })) +vi.mock('./filesystem-watcher', () => ({ + registerFilesystemWatcherHandlers: registerFilesystemWatcherHandlersMock +})) + vi.mock('./runtime', () => ({ registerRuntimeHandlers: registerRuntimeHandlersMock })) @@ -121,6 +127,7 @@ describe('registerCoreHandlers', () => { registerUpdaterHandlersMock.mockReset() registerBrowserHandlersMock.mockReset() setTrustedBrowserRendererWebContentsIdMock.mockReset() + registerFilesystemWatcherHandlersMock.mockReset() }) it('passes the store through to handler registrars that need it', () => { @@ -155,5 +162,6 @@ describe('registerCoreHandlers', () => { expect(registerUpdaterHandlersMock).toHaveBeenCalled() expect(setTrustedBrowserRendererWebContentsIdMock).toHaveBeenCalledWith(null) expect(registerBrowserHandlersMock).toHaveBeenCalled() + expect(registerFilesystemWatcherHandlersMock).toHaveBeenCalled() }) }) diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index d42f1e1b6..14f4969b0 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -4,6 +4,7 @@ import type { Store } from '../persistence' import type { OrcaRuntimeService } from '../runtime/orca-runtime' import type { StatsCollector } from '../stats/collector' import { registerFilesystemHandlers } from './filesystem' +import { registerFilesystemWatcherHandlers } from './filesystem-watcher' import { registerClaudeUsageHandlers } from './claude-usage' import { registerCodexUsageHandlers } from './codex-usage' import { registerGitHubHandlers } from './github' @@ -46,6 +47,7 @@ export function registerCoreHandlers( registerSessionHandlers(store) registerUIHandlers(store) registerFilesystemHandlers(store) + registerFilesystemWatcherHandlers() registerRuntimeHandlers(runtime) registerClipboardHandlers() registerUpdaterHandlers(store) diff --git a/src/preload/api-types.d.ts b/src/preload/api-types.d.ts index 0b7c88c86..7b61b3128 100644 --- a/src/preload/api-types.d.ts +++ b/src/preload/api-types.d.ts @@ -3,6 +3,7 @@ import type { BrowserLoadError, CreateWorktreeResult, DirEntry, + FsChangedPayload, GlobalSettings, GitBranchCompareResult, GitConflictOperation, @@ -297,6 +298,9 @@ export type PreloadApi = { }) => Promise<{ size: number; isDirectory: boolean; mtime: number }> listFiles: (args: { rootPath: string }) => Promise search: (args: SearchOptions) => Promise + watchWorktree: (args: { worktreePath: string }) => Promise + unwatchWorktree: (args: { worktreePath: string }) => Promise + onFsChanged: (callback: (payload: FsChangedPayload) => void) => () => void } git: { status: (args: { worktreePath: string }) => Promise<{ entries: GitStatusEntry[] }> diff --git a/src/preload/index.ts b/src/preload/index.ts index 9fc6ba230..2b1e9a298 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -4,7 +4,7 @@ review and type drift checks easier than scattering these bindings across module import { contextBridge, ipcRenderer, webFrame, webUtils } from 'electron' import { electronAPI } from '@electron-toolkit/preload' import type { CliInstallStatus } from '../shared/cli-install-types' -import type { NotificationDispatchResult } from '../shared/types' +import type { FsChangedPayload, NotificationDispatchResult } from '../shared/types' import type { RuntimeStatus, RuntimeSyncWindowGraph } from '../shared/runtime-types' import { ORCA_EDITOR_SAVE_DIRTY_FILES_EVENT, @@ -501,7 +501,17 @@ const api = { }[] totalMatches: number truncated: boolean - }> => ipcRenderer.invoke('fs:search', args) + }> => ipcRenderer.invoke('fs:search', args), + watchWorktree: (args: { worktreePath: string }): Promise => + ipcRenderer.invoke('fs:watchWorktree', args), + unwatchWorktree: (args: { worktreePath: string }): Promise => + ipcRenderer.invoke('fs:unwatchWorktree', args), + onFsChanged: (callback: (payload: FsChangedPayload) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, payload: FsChangedPayload) => + callback(payload) + ipcRenderer.on('fs:changed', listener) + return () => ipcRenderer.removeListener('fs:changed', listener) + } }, git: { diff --git a/src/renderer/src/components/right-sidebar/FileExplorer.tsx b/src/renderer/src/components/right-sidebar/FileExplorer.tsx index 5e7f58c00..7d23b2070 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorer.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorer.tsx @@ -20,6 +20,7 @@ import { useActiveWorktreePath } from './useActiveWorktreePath' import { useFileDuplicate } from './useFileDuplicate' import { useFileExplorerDragDrop } from './useFileExplorerDragDrop' import { useFileExplorerTree } from './useFileExplorerTree' +import { useFileExplorerWatch } from './useFileExplorerWatch' export default function FileExplorer(): React.JSX.Element { const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) @@ -45,6 +46,7 @@ export default function FileExplorer(): React.JSX.Element { const { dirCache, + setDirCache, flatRows, rowsByPath, rootCache, @@ -91,7 +93,7 @@ export default function FileExplorer(): React.JSX.Element { activeWorktreeId, openFiles, closeFile, - refreshTree, + refreshDir, selectedPath, setSelectedPath, isMac, @@ -154,6 +156,19 @@ export default function FileExplorer(): React.JSX.Element { refreshDir }) + useFileExplorerWatch({ + worktreePath, + activeWorktreeId, + dirCache, + setDirCache, + expanded, + setSelectedPath, + refreshDir, + refreshTree, + inlineInput, + dragSourcePath + }) + const totalCount = flatRows.length + (inlineInputIndex >= 0 ? 1 : 0) const virtualizer = useVirtualizer({ diff --git a/src/renderer/src/components/right-sidebar/file-explorer-watcher-reconcile.ts b/src/renderer/src/components/right-sidebar/file-explorer-watcher-reconcile.ts new file mode 100644 index 000000000..517618f3f --- /dev/null +++ b/src/renderer/src/components/right-sidebar/file-explorer-watcher-reconcile.ts @@ -0,0 +1,79 @@ +import type { Dispatch, SetStateAction } from 'react' +import type { DirCache } from './file-explorer-types' +import { normalizeAbsolutePath, isPathEqualOrDescendant } from './file-explorer-paths' +import { useAppStore } from '@/store' + +// ── dirCache subtree purge ─────────────────────────────────────────── +// Why: dirCache is component-local useState in useFileExplorerTree, not +// Zustand. This helper accepts the setter so it can be called from the +// watch effect without Zustand coupling. See design §5.2. + +export function purgeDirCacheSubtree( + setDirCache: Dispatch>>, + deletedPath: string +): void { + const normalized = normalizeAbsolutePath(deletedPath) + setDirCache((prev) => { + let changed = false + const next: Record = {} + for (const key of Object.keys(prev)) { + if (isPathEqualOrDescendant(key, normalized)) { + changed = true + } else { + next[key] = prev[key] + } + } + return changed ? next : prev + }) +} + +// ── expandedDirs subtree purge ─────────────────────────────────────── +// Why: expandedDirs lives in Zustand keyed by worktreeId. After an +// external directory delete, all expanded descendants of the deleted +// path must be removed so the tree doesn't show phantom folders. + +export function purgeExpandedDirsSubtree(worktreeId: string, deletedPath: string): void { + const normalized = normalizeAbsolutePath(deletedPath) + useAppStore.setState((state) => { + const current = state.expandedDirs[worktreeId] + if (!current) { + return state + } + + const next = new Set() + let changed = false + for (const dirPath of current) { + if (isPathEqualOrDescendant(dirPath, normalized)) { + changed = true + } else { + next.add(dirPath) + } + } + + if (!changed) { + return state + } + + return { expandedDirs: { ...state.expandedDirs, [worktreeId]: next } } + }) +} + +// ── pendingExplorerReveal cleanup ──────────────────────────────────── +// Why: if the reveal target was inside a deleted subtree, keeping it +// would cause the reveal logic to expand stale ancestor directories. + +export function clearStalePendingReveal(deletedPath: string): void { + const normalized = normalizeAbsolutePath(deletedPath) + useAppStore.setState((state) => { + if ( + state.pendingExplorerReveal && + isPathEqualOrDescendant( + normalizeAbsolutePath(state.pendingExplorerReveal.filePath), + normalized + ) + ) { + return { pendingExplorerReveal: null } + } + return state + }) +} diff --git a/src/renderer/src/components/right-sidebar/useFileDeletion.ts b/src/renderer/src/components/right-sidebar/useFileDeletion.ts index d06c0d441..a534da95d 100644 --- a/src/renderer/src/components/right-sidebar/useFileDeletion.ts +++ b/src/renderer/src/components/right-sidebar/useFileDeletion.ts @@ -2,6 +2,7 @@ import { useCallback, useMemo, useState } from 'react' import type { Dispatch, SetStateAction } from 'react' import { toast } from 'sonner' import { useAppStore } from '@/store' +import { dirname } from '@/lib/path' import { isPathEqualOrDescendant } from './file-explorer-paths' import type { PendingDelete, TreeNode } from './file-explorer-types' import { requestEditorSaveQuiesce } from '@/components/editor/editor-autosave' @@ -13,7 +14,7 @@ type UseFileDeletionParams = { filePath: string }[] closeFile: (fileId: string) => void - refreshTree: () => Promise + refreshDir: (dirPath: string) => Promise selectedPath: string | null setSelectedPath: Dispatch> isMac: boolean @@ -50,7 +51,7 @@ export function useFileDeletion({ activeWorktreeId, openFiles, closeFile, - refreshTree, + refreshDir, selectedPath, setSelectedPath, isMac, @@ -120,7 +121,10 @@ export function useFileDeletion({ if (selectedPath && isPathEqualOrDescendant(selectedPath, node.path)) { setSelectedPath(null) } - await refreshTree() + // Why: use targeted refreshDir instead of refreshTree so only the parent + // directory is reloaded, preserving scroll position and avoiding redundant + // full-tree reloads (the watcher will also trigger a targeted refresh). + await refreshDir(dirname(node.path)) } catch (error) { const action = isWindows ? 'move to Recycle Bin' : 'move to Trash' toast.error(error instanceof Error ? error.message : `Failed to ${action} '${node.name}'.`) @@ -133,7 +137,7 @@ export function useFileDeletion({ isWindows, openFiles, pendingDelete, - refreshTree, + refreshDir, selectedPath, setSelectedPath ]) diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts b/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts index f9b0ecd9d..3388744f3 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts @@ -1,3 +1,4 @@ +import type React from 'react' import { useCallback, useMemo, useRef, useState } from 'react' import { joinPath, normalizeRelativePath } from '@/lib/path' import type { DirCache, TreeNode } from './file-explorer-types' @@ -6,6 +7,7 @@ import { shouldIncludeFileExplorerEntry } from './file-explorer-entries' type UseFileExplorerTreeResult = { dirCache: Record + setDirCache: React.Dispatch>> flatRows: TreeNode[] rowsByPath: Map rootCache: DirCache | undefined @@ -138,6 +140,7 @@ export function useFileExplorerTree( return { dirCache, + setDirCache, flatRows, rowsByPath, rootCache, diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts b/src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts new file mode 100644 index 000000000..d924d9740 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts @@ -0,0 +1,231 @@ +import { useEffect, useRef } from 'react' +import type { Dispatch, SetStateAction } from 'react' +import type { FsChangedPayload } from '../../../../shared/types' +import type { DirCache } from './file-explorer-types' +import type { InlineInput } from './FileExplorerRow' +import { normalizeAbsolutePath } from './file-explorer-paths' +import { dirname } from '@/lib/path' +import { + purgeDirCacheSubtree, + purgeExpandedDirsSubtree, + clearStalePendingReveal +} from './file-explorer-watcher-reconcile' + +type UseFileExplorerWatchParams = { + worktreePath: string | null + activeWorktreeId: string | null + dirCache: Record + setDirCache: Dispatch>> + expanded: Set + setSelectedPath: Dispatch> + refreshDir: (dirPath: string) => Promise + refreshTree: () => Promise + inlineInput: InlineInput | null + dragSourcePath: string | null +} + +/** + * Subscribes to filesystem watcher events for the active worktree and + * reconciles File Explorer state on external changes. + * + * Why: the renderer must explicitly tell main which worktree to watch + * because activeWorktreeId is renderer-local Zustand state (design §4.2). + */ +export function useFileExplorerWatch({ + worktreePath, + activeWorktreeId, + dirCache, + setDirCache, + expanded, + setSelectedPath, + refreshDir, + refreshTree, + inlineInput, + dragSourcePath +}: UseFileExplorerWatchParams): void { + // Keep refs for values accessed inside the event handler to avoid + // re-subscribing the IPC listener on every render. + const dirCacheRef = useRef(dirCache) + dirCacheRef.current = dirCache + + const expandedRef = useRef(expanded) + expandedRef.current = expanded + + const worktreeIdRef = useRef(activeWorktreeId) + worktreeIdRef.current = activeWorktreeId + + const inlineInputRef = useRef(inlineInput) + inlineInputRef.current = inlineInput + + const dragSourceRef = useRef(dragSourcePath) + dragSourceRef.current = dragSourcePath + + // Why: refreshDir and refreshTree are stored as refs so the merged + // subscribe+event effect does not re-subscribe the IPC listener when + // `expanded` changes (which gives refreshTree a new identity). Without + // refs, every expand/collapse would tear down and re-create the watcher + // subscription and IPC listener unnecessarily (review issue §1). + const refreshDirRef = useRef(refreshDir) + refreshDirRef.current = refreshDir + + const refreshTreeRef = useRef(refreshTree) + refreshTreeRef.current = refreshTree + + // Deferred events queue: events that arrive during inline input or drag + const deferredRef = useRef([]) + + // ── Subscribe, process events, and unsubscribe in one atomic effect ── + // Why: merging the subscribe/unsubscribe effect and the event-processing + // effect into a single useEffect eliminates a race where events from a + // new watcher could be lost during rapid worktree switches. When they were + // separate effects with the same `worktreePath` dependency, React could + // run the event-listener cleanup before the unsubscribe cleanup, creating + // a window where events arrive with no handler (review issue §3). + useEffect(() => { + if (!worktreePath) { + return + } + + const currentWorktreePath = worktreePath + + void window.api.fs.watchWorktree({ worktreePath }) + + function processPayload(payload: FsChangedPayload): void { + // Why: during rapid worktree switches, in-flight batched events from + // the old worktree can arrive after the switch. Processing them against + // the new worktree's tree state would corrupt dirCache (design §3). + if ( + normalizeAbsolutePath(payload.worktreePath) !== normalizeAbsolutePath(currentWorktreePath) + ) { + return + } + + const wtId = worktreeIdRef.current + if (!wtId) { + return + } + + const cache = dirCacheRef.current + const exp = expandedRef.current + + // Collect directories that need refreshing + const dirsToRefresh = new Set() + let needsFullRefresh = false + + for (const evt of payload.events) { + const normalizedPath = normalizeAbsolutePath(evt.absolutePath) + + if (evt.kind === 'overflow') { + needsFullRefresh = true + break + } + + if (evt.kind === 'delete') { + // Why: for delete events, isDirectory is undefined from the watcher + // (the path no longer exists). Infer from dirCache: if the deleted + // path is a dirCache key, it was an expanded directory (design §4.4). + const wasDirectory = normalizedPath in cache + + if (wasDirectory) { + purgeDirCacheSubtree(setDirCache, normalizedPath) + purgeExpandedDirsSubtree(wtId, normalizedPath) + } + + // Clear pendingExplorerReveal if it targets the deleted path or any + // descendant (for directory deletes). File deletes clear on exact match. + clearStalePendingReveal(normalizedPath) + + // Clear selectedPath if it points into the deleted subtree + setSelectedPath((prev) => { + if (prev && normalizeAbsolutePath(prev) === normalizedPath) { + return null + } + if ( + prev && + wasDirectory && + normalizeAbsolutePath(prev).startsWith(`${normalizedPath}/`) + ) { + return null + } + return prev + }) + + // Invalidate the parent directory + const parent = normalizeAbsolutePath(dirname(normalizedPath)) + if (parent in cache) { + dirsToRefresh.add(parent) + } + } else if (evt.kind === 'create') { + // Invalidate the parent directory + const parent = normalizeAbsolutePath(dirname(normalizedPath)) + if (parent in cache) { + dirsToRefresh.add(parent) + } + } else if (evt.kind === 'update') { + // Why: directory update events invalidate that directory. File-content + // update events are ignored in v1 (design §6.1). + if (evt.isDirectory === true) { + if (normalizedPath in cache) { + dirsToRefresh.add(normalizedPath) + } + } + // File updates: ignored in v1 + } + // 'rename' is deferred to v2 (design §5.3) + } + + if (needsFullRefresh) { + void refreshTreeRef.current() + return + } + + // Only refresh directories that are already loaded (in cache) and are + // either the root, expanded, or already have cached children. + for (const dirPath of dirsToRefresh) { + // Check the dir is the root or an expanded directory or already in cache + if ( + dirPath === normalizeAbsolutePath(currentWorktreePath) || + exp.has(dirPath) || + dirPath in dirCacheRef.current + ) { + void refreshDirRef.current(dirPath) + } + } + } + + const handleFsChanged = (payload: FsChangedPayload): void => { + // Why: defer watcher-triggered refreshes while inline input or drag-drop + // is active to avoid displacing the inline input row or shifting rows + // under the drag cursor (design §6.2). + if (inlineInputRef.current !== null || dragSourceRef.current !== null) { + deferredRef.current.push(payload) + return + } + + processPayload(payload) + } + + const unsubscribeListener = window.api.fs.onFsChanged(handleFsChanged) + + return () => { + unsubscribeListener() + void window.api.fs.unwatchWorktree({ worktreePath }) + deferredRef.current = [] + } + }, [worktreePath, setDirCache, setSelectedPath]) + + // ── Flush deferred events when interaction ends ──────────────────── + useEffect(() => { + if (inlineInput === null && dragSourcePath === null && deferredRef.current.length > 0) { + const deferred = deferredRef.current.splice(0) + // Re-process all deferred payloads now that the interaction is over. + // We trigger a simple refresh of all visible dirs since the deferred + // events may be stale and coalescing them is complex. + if (worktreePath) { + void refreshTreeRef.current() + } + // Clear the deferred queue (already spliced) + void deferred + } + }, [inlineInput, dragSourcePath, worktreePath]) +} diff --git a/src/shared/types.ts b/src/shared/types.ts index a6e950fae..0d063fd6c 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -416,6 +416,19 @@ export type DirEntry = { isSymlink: boolean } +// ─── Filesystem watcher ───────────────────────────────────── +export type FsChangeEvent = { + kind: 'create' | 'update' | 'delete' | 'rename' | 'overflow' + absolutePath: string + oldAbsolutePath?: string + isDirectory?: boolean +} + +export type FsChangedPayload = { + worktreePath: string + events: FsChangeEvent[] +} + // ─── Git Status ───────────────────────────────────────────── export type GitFileStatus = 'modified' | 'added' | 'deleted' | 'renamed' | 'untracked' | 'copied' export type GitStagingArea = 'staged' | 'unstaged' | 'untracked'