mirror of
https://github.com/stablyai/orca
synced 2026-04-21 14:17:16 +00:00
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:
parent
aef508d2b3
commit
0f819032a0
15 changed files with 902 additions and 10 deletions
|
|
@ -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"
|
||||
|
|
|
|||
151
pnpm-lock.yaml
151
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
358
src/main/ipc/filesystem-watcher.ts
Normal file
358
src/main/ipc/filesystem-watcher.ts
Normal 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()
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
4
src/preload/api-types.d.ts
vendored
4
src/preload/api-types.d.ts
vendored
|
|
@ -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[] }>
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
])
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
Loading…
Reference in a new issue