feat: auto-refresh File Explorer via filesystem watcher (#456)

* fix: address review findings

* feat: add filesystem watcher for auto-refreshing File Explorer

Adds @parcel/watcher-based filesystem watching so the File Explorer
automatically reflects external changes (creates, deletes, renames)
without manual refresh.
This commit is contained in:
Jinjing 2026-04-10 19:50:14 -07:00 committed by GitHub
parent aef508d2b3
commit 0f819032a0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 902 additions and 10 deletions

View file

@ -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"

View file

@ -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

View file

@ -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)

View file

@ -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<typeof setTimeout> | null
firstEventAt: number
}
// ── Per-root watcher state ───────────────────────────────────────────
type WatchedRoot = {
subscription: AsyncSubscription
listeners: Map<number, WebContents> // webContents.id -> WebContents
batch: DebouncedBatch
}
// ── Module state ─────────────────────────────────────────────────────
const watchedRoots = new Map<string, WatchedRoot>()
// ── 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<string, { type: 'create' | 'update' | 'delete'; index: number }>()
const deleteBeforeCreate = new Set<string>()
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<boolean | undefined> {
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<void> {
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<WatchedRoot> {
// 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<void> {
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<void> => {
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<void> {
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()
}

View file

@ -157,7 +157,17 @@ export function registerFilesystemHandlers(store: Store): void {
ipcMain.handle('fs:deletePath', async (_event, args: { targetPath: string }): Promise<void> => {
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)

View file

@ -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()
})
})

View file

@ -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)

View file

@ -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<string[]>
search: (args: SearchOptions) => Promise<SearchResult>
watchWorktree: (args: { worktreePath: string }) => Promise<void>
unwatchWorktree: (args: { worktreePath: string }) => Promise<void>
onFsChanged: (callback: (payload: FsChangedPayload) => void) => () => void
}
git: {
status: (args: { worktreePath: string }) => Promise<{ entries: GitStatusEntry[] }>

View file

@ -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<void> =>
ipcRenderer.invoke('fs:watchWorktree', args),
unwatchWorktree: (args: { worktreePath: string }): Promise<void> =>
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: {

View file

@ -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({

View file

@ -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<SetStateAction<Record<string, DirCache>>>,
deletedPath: string
): void {
const normalized = normalizeAbsolutePath(deletedPath)
setDirCache((prev) => {
let changed = false
const next: Record<string, DirCache> = {}
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<string>()
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
})
}

View file

@ -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<void>
refreshDir: (dirPath: string) => Promise<void>
selectedPath: string | null
setSelectedPath: Dispatch<SetStateAction<string | null>>
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
])

View file

@ -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<string, DirCache>
setDirCache: React.Dispatch<React.SetStateAction<Record<string, DirCache>>>
flatRows: TreeNode[]
rowsByPath: Map<string, TreeNode>
rootCache: DirCache | undefined
@ -138,6 +140,7 @@ export function useFileExplorerTree(
return {
dirCache,
setDirCache,
flatRows,
rowsByPath,
rootCache,

View file

@ -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<string, DirCache>
setDirCache: Dispatch<SetStateAction<Record<string, DirCache>>>
expanded: Set<string>
setSelectedPath: Dispatch<SetStateAction<string | null>>
refreshDir: (dirPath: string) => Promise<void>
refreshTree: () => Promise<void>
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<FsChangedPayload[]>([])
// ── 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<string>()
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])
}

View file

@ -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'