Back to Cherry Studio

Directory Tree Architecture

docs/references/file/directory-tree.md

2.0.235.2 KB
Original Source

Directory Tree Architecture

SoT scopethis document owns: the DirectoryTreeBuilder primitive, the DirectoryTreeManager lifecycle service that owns its IPC surface, the renderer-side useDirectoryTree hook, the TreeNode shape shipped to both processes, and the .gitignore-driven scan/watch coordination. The boundary between this primitive and FileManager is stated in architecture.md §1.2 — in case of conflict, that document decides positioning, this document decides implementation.

Contract stability: the IPC contract, the TreeNode wire shape, and the resource model (one builder per (rootPath, options) pair, refcounted across treeIds with a dispose grace window) are binding commitments. When implementation reveals a contract that cannot be honored, revise this document first, then implement.


1. Positioning

1.1 Why a Separate Primitive

DirectoryTreeBuilder is the second top-level primitive inside the file module, parallel to FileManager. The two manage orthogonal resource concerns:

PrimitiveResourceStateBackingLifecycle
FileManagerFileEntry rows (internal + external) + content bytesDB + filesystemfile_entry + file association tablesalways-on WhenReady service
DirectoryTreeBuilderIn-memory TreeDirRoot mirror + chokidar watcherPure runtimeNone — FS is the source of truthper-(rootPath, options); refcounted

Neither subsumes the other:

  • A user can have a workspace folder that is watched but unmanaged (Notes opens any directory on disk without registering its files as FileEntrys) — that needs a tree, not entries.
  • A user can have files that are entered but unwatched (every internal-origin file under {userData}/Data/Files/) — that needs entries, not a tree.
  • A user can have both — a workspace whose contents are also referenced as external FileEntrys. The two primitives observe the same file path independently; neither has authority over the other's view.

Forcing the tree into FileManager (or vice versa) would put a DB-backed lifecycle in front of a pure-runtime scanning primitive, or vice versa — both incur cost the other doesn't need.

1.2 Why It's Not Just chokidar Inline

Three things sit on top of chokidar that any real caller would re-invent:

  1. Initial scan via ripgrep --files, not chokidar's own walker. Chokidar opens an FSEvents / inotify handle per directory; on a workspace with node_modules the install hits ulimit -n and surfaces as EMFILE. Ripgrep streams a flat list of files, then a single chokidar.FSWatcher is attached with a .gitignore-derived ignored predicate so the recursive watch never enters the excluded subtrees in the first place.
  2. (rootPath, options) dedupe — every file.tree.create call returns a unique treeId (the renderer needs one to route mutation pushes), but identical roots share one underlying builder. The expensive resource (FS scan + watcher install) lives main-side; dedupe must too.
  3. TreeNode class hierarchy with identity preservation — renames mutate path once at the subtree root and cascade via adjustChildrenPaths, so identity-based consumer caches (React keys, lookup maps) survive a rename. Rebuilding the subtree throws those caches away.

1.3 Relationship to DirectoryWatcher

createDirectoryWatcher (in src/main/services/file/watcher/) is the transport-level FS event source. DirectoryTreeBuilder is one of its consumers; DanglingCache is another. The watcher does not know about trees or entries; the builder does not implement its own FS-event protocol. This separation keeps the watcher reusable for non-tree consumers (DanglingCache, future external-file presence tracking) and keeps the builder testable against a synthetic watcher.


2. Module Layout

src/main/services/file/tree/   ← parallel to internal/ and watcher/
│
├── builder.ts            ← DirectoryTreeBuilder implementation
│     ├── createDirectoryTree(rootPath, options) — async factory
│     ├── initial scan via search.listDirectory → tree population
│     ├── chokidar attachment with .gitignore ignored predicate
│     ├── watcher event → tree mutation translation
│     └── dispose() — drop watcher subscription, idempotent
│
├── DirectoryTreeManager.ts  ← @Injectable, @ServicePhase(WhenReady)
│     ├── builderKey(rootPath, options) — dedupe key (normalized)
│     ├── create(sender, rootPath, options) — attach or share a builder
│     ├── dispose(treeId) — drop a consumer; tear down builder if last
│     ├── disposeAllForWebContents(id) — on `destroyed` cascade
│     └── disposed flag — short-circuits in-flight builders on onStop
│
├── search.ts             ← listDirectory: ripgrep + optional fuzzy match
│                           consumed only by builder.ts and ipc.ts
│
├── gitignore.ts          ← loadGitignorePredicate: parses .gitignore into
│                           a predicate fed to chokidar (ignored option) +
│                           an in-builder post-scan filter. Ripgrep honors
│                           .gitignore on its own via its default behavior.
│
├── index.ts              ← barrel: exports createDirectoryTree +
│                           DirectoryTreeBuilder type only
│
└── __tests__/            ← builder.test.ts / registry.test.ts /
                            TreeNode.test.ts / search.test.ts

src/shared/file/types/tree.ts   ← shared with renderer
├── DirectoryTreeOptionsSchema (Zod) — IPC validation source of truth
├── DirectoryTreeOptions = z.infer<...> — derived type
├── SerializedTreeNode — wire DTO (parentless, plain object)
├── TreeNode / TreeFile / TreeDir / TreeDirRoot — class hierarchy
├── TreeMutationEvent — added | removed | updated
├── CreateTreeIpcResult — { treeId, revision, snapshot }
└── TreeMutationPushPayload — { treeId, revision, event }

src/renderer/hooks/useDirectoryTree.ts   ← renderer hook
├── On mount → file.tree.create → rehydrate hierarchy → subscribe → file.tree.activate
├── On file.tree.mutation (filtered by treeId) → applyMutation in place
├── Returns { root, isLoading, error, version, treeId, getNode }
└── On unmount → file.tree.dispose

2.1 Why search.ts and gitignore.ts Live Here

Both files have exactly two callers each — builder.ts and ipc.ts (for the legacy File_ListDirectory channel that survives outside the tree primitive). They are tree implementation details: the chokidar ignored predicate and the in-builder post-scan filter both consult loadGitignorePredicate's output, so the rules the watcher applies and the rules the tree post-scan applies cannot drift. Ripgrep honors .gitignore on its own; the explicit --ignore-file argument is not wired today (it could be a future optimisation). Living next to builder.ts keeps the predicate's surface area inspectable.

If a future caller needs listDirectory outside the tree primitive (and outside the existing IPC), it can be promoted to the file-module common layer at that point. Until then, an extra utils/ directory between services/file/tree/ and these files would only be a naming smell — there's already a src/main/utils/file/ directory that owns FS primitives, and a second "utils" inside the tree module makes the distinction unreadable.

2.2 No @main/data Imports

src/main/services/file/tree/** does not import from @main/data/** and never will. The tree is a runtime concern; persistence is orthogonal (noteTable is a sparse state overlay on top of FS paths, not a tree mirror). Enforcement is the import-graph regex test in builder.test.ts ("the tree primitive does not import @main/data") — that test is the contract. (An eslint no-restricted-imports rule could be added later for a faster signal, but the test is what actually fails CI today.)


3. Resource Model

3.1 Identity: treeId vs (rootPath, options)

Every file.tree.create call returns a unique treeId. The renderer uses this to filter mutation pushes (the file.tree.mutation event is shared across all live trees in a window). Distinct treeIds may share a builder:

file.tree.create('/work/notes', {...})  → treeId=t-1
file.tree.create('/work/notes', {...})  → treeId=t-2  ← same builder
file.tree.create('/work/code',  {...})  → treeId=t-3  ← new builder

Tear down t-1 → refcount on (/work/notes) builder = 1
Tear down t-2 → refcount = 0, grace timer queued
  T+500ms: timer fires → builder.dispose() → watcher FDs released

builderKey normalizes the path (backslash → forward slash) so Windows variants of the same directory collapse to one builder, then concatenates JSON.stringify(options ?? {}) separated by a NUL byte. Identical options produce identical keys; different extensions or withStats settings produce distinct keys (and distinct watchers).

3.2 Dispose Grace Window

DISPOSE_GRACE_MS = 500. When the last consumer of a builder leaves, the actual teardown is deferred by this window. The motivation is React's commit ordering inside a single render: "deletion effects → insertion effects". When a keyed consumer is replaced or a tab unmounts and immediately remounts, file.tree.dispose(old) and file.tree.create(new) can occur back-to-back. Without the grace window, the unmount would tear down the watcher and the mount would pay a full rescan microseconds later.

500ms is long enough to span any realistic React commit (sub-millisecond in practice) and short enough that a genuine workspace close doesn't keep the watcher FDs alive noticeably.

3.3 In-Flight Cancellation

createDirectoryTree is async (ripgrep scan + chokidar attach). If onStop fires while a build is mid-flight, the registry sets this.disposed = true and the awaiting acquireBuilder checks this flag after the await:

ts
const builder = await createDirectoryTree(rootPath, options)
if (this.disposed) {
  await builder.dispose()
  throw new Error('DirectoryTreeManager stopped during in-flight builder creation')
}

Without this, the freshly-built builder would resolve after disposeAll() cleared the bookkeeping maps and would re-insert itself with no further cleanup path — an orphan watcher.

3.4 webContents-Destroyed Cascade

The registry tracks webContentsId → Set<treeId>. When sender.once('destroyed') fires (e.g. a window closes), all trees owned by that sender are disposed in one pass. Renderer-side cleanup via file.tree.dispose is preferred (it triggers the grace window), but this cascade is the safety net for crashed windows.

The listener alone is not sufficient: create awaits the ripgrep scan before it can subscribe, and a window that closes during that await has already fired destroyed — a later once never replays it. create therefore re-checks sender.isDestroyed() after acquiring the builder and, if the owner is gone, disposes the consumer through the normal path (so an otherwise-idle builder is released) and rejects.

Note: the cascade routes each disposal through the regular dispose(treeId) path, which still arms the 500 ms grace window for each shared builder whose refcount drops to zero. The renderer is gone so no remount is coming, but waiting an extra 500 ms before tearing the watcher down has no observable cost. Test fixtures that need synchronous teardown call disposeAll() instead.

3.5 Children Ordering

Children inside a TreeDir are sorted once, at the end of the initial scan: folders-first, then basename.localeCompare. Watcher-driven added events attachChild to the end of the parent's _children record, so children added after the initial scan accumulate in arrival order rather than alphabetical order.

This is intentional — the alternative is re-sorting on every mutation, which on a large workspace under git checkout storms turns into hundreds of sorts. Consumers that care about ordering for display should re-sort at the UI layer keyed on the version counter exposed by useDirectoryTree (UI-layer useMemo over Object.values(parent.children) is the expected pattern).


4. IPC Contract

4.1 Routes

The tree primitive rides IpcApi — schemas in src/shared/ipc/schemas/file.ts, handlers in src/main/ipc/handlers/file.ts.

Route / eventDirectionPayloadReturns
file.tree.createrenderer → main{ rootPath, options? }{ treeId, revision, snapshot: SerializedTreeNode }
file.tree.activaterenderer → main{ treeId, revision }boolean (true when activated)
file.tree.disposerenderer → main{ treeId }void
file.tree.renamerenderer → main{ treeId, oldPath, newName }boolean (true if applied)
file.tree.mutationmain → renderer (push){ treeId, revision, event: TreeMutationEvent }

The file.tree.* subtree places these alongside file.read / file.open / etc. — the tree primitive is part of the file module, so its route namespace is too.

file.tree.mutation is a class-B topic stream: DirectoryTreeManager sends each payload straight to the owning consumer's WebContents rather than broadcasting, so a tree costs only the window that asked for it.

4.2 Validation

The IpcRouter parses every request payload against its route schema before the handler runs. rootPath must satisfy AbsoluteFilePathSchema (non-empty, no null bytes, starts with /, or a drive letter followed by / or \ — either case, e.g. C:\ or C:/). options is validated against DirectoryTreeOptionsSchema — the same schema whose z.infer produces the DirectoryTreeOptions TypeScript type, so wire shape and static type cannot drift. Activation additionally requires a non-negative integer revision matching the snapshot baseline.

A malformed payload rejects with an IpcError(VALIDATION_FAILED) carrying the zod issues; handlers never see an unvalidated object.

file.tree.create also needs a managed window sender — the mutation stream is addressed by the caller's WebContents, so a tree whose owner is not a WindowManager window could never receive a push and the route refuses instead of leaking a watcher. When the manager is torn down mid-create it rejects with the FILE_DIRECTORY_TREE_STOPPED domain code, which useDirectoryTree swallows silently (the UI is going away).

A treeId is an identifier, not a capability. Every window shares the one IpcApi request channel, so activate / dispose / rename carry the caller's WebContents id and the manager refuses any tree owned by another window. Ownership must never rest on UUID entropy.

The pending queue is bounded at MAX_PENDING_MUTATIONS (1000). A renderer that creates a tree and then never activates — hung, paused, or non-conforming — would otherwise accumulate a full payload per watcher event for as long as the window lives, and the destroyed cascade cannot help while it is alive. Overflow disposes the consumer, so the late activate returns false. A deliberate ceiling was chosen over an activation timer because the memory is what is actually at risk; an idle un-activated tree costs one watcher, which the window's teardown already reclaims.

activate returning false is recoverable, and callers must recover. It means main dropped the consumer, so the snapshot in hand can never be completed — but the condition is transient and a fresh snapshot is current by construction. Both consumers therefore run a bounded create → subscribe → activate retry (MAX_ACTIVATION_ATTEMPTS, 3) rather than surfacing an error: nothing re-runs those effects on their own, so a single refusal would otherwise leave the workspace tree hidden or an expanded directory frozen for the rest of the session. Distinguish this from a revision gap, which is terminal (above) — there a new snapshot cannot tell the mirror what it missed.

4.3 Renderer Surface

ts
ipcApi.request('file.tree.create', { rootPath, options })        → Promise<CreateTreeIpcResult>
ipcApi.request('file.tree.activate', { treeId, revision })       → Promise<boolean>
ipcApi.request('file.tree.dispose', { treeId })                  → Promise<void>
ipcApi.request('file.tree.rename', { treeId, oldPath, newName })  → Promise<boolean>
ipcApi.on('file.tree.mutation', callback)                        → () => void  // unsubscribe

Every file.tree.mutation subscriber receives all pushes delivered to its window regardless of which tree they belong to; consumers must filter by payload.treeId.

Two rules bind every caller of file.tree.create, not just useDirectoryTree:

  • A created tree must be activated. A consumer starts pending; mutations queue main-side until activate and are never delivered otherwise. Skipping it leaves the caller's view frozen at its snapshot while the queue grows for the tree's lifetime. A refused activation must be retried with a fresh create (§4.2), and each abandoned round must unsubscribe and dispose — the subscription and the main-side tree are both already attached by then.
  • Side consumers of a tree owned by useDirectoryTree must use its onMutation callback, not their own ipcApi.on. activate flushes the buffered mutations before it resolves, so a subscription keyed on the hook's published treeId is installed strictly after that replay and would miss it. The hook's own listener is installed before activate and forwards every accepted mutation, already filtered to its tree.

create and activate form a two-phase snapshot-to-stream handoff. Main subscribes the new consumer to the shared builder before taking its snapshot, then buffers every later mutation for that consumer. create returns the snapshot together with its baseline revision; the renderer builds its mirror and installs onMutation before calling activate. Main then flushes buffered events in revision order and switches the consumer to live forwarding. There is therefore no interval in which a mutation can fall between the snapshot and the renderer listener, and no compensating filesystem scan is required.

Revisions are monotonic per treeId. The snapshot owns its returned revision, and every later mutation increments it by one. Renderer mirrors ignore already-applied revisions and treat a forward gap as a broken stream rather than silently accepting stale state.

A gap is terminal. There is no replay path, so the mirror can never catch up: useDirectoryTree unsubscribes, disposes the tree, clears root/treeId and reports the error exactly once. Staying subscribed would re-gap on every later push — a fresh Error per push, which a consumer that toasts on error turns into an unbounded run of toasts, while a mirror nobody can trust keeps a watcher and its IPC traffic alive.

4.4 Explicit Rename

In-place only. The route takes a newName, never a destination path, and the target is resolved against oldPath's parent. This is not a policy choice but the limit of the primitive: TreeNode.path repoints a basename inside the node's existing parent (repointChild) and has no detach/attach step, so a cross-parent move would leave the node in the old parent's children while its path and the lookup index claim the new one. SafeNameSchema rejects path separators, so a move is unexpressible at the boundary rather than validated away behind it. A genuine move must arrive as chokidar's removed + added (identity lost, state consistent) until the class hierarchy grows re-parenting.

file.tree.rename is invoked by callers that just performed a file-system rename (e.g. Notes after file.rename). The flow:

  1. Renderer performs the FS rename (already happens today).
  2. Renderer calls ipcApi.request('file.tree.rename', { treeId, oldPath, newName }); main resolves the destination as dirname(oldPath)/newName.
  3. Main side DirectoryTreeBuilder.rename(oldPath, newPath):
    • Mutates the existing TreeNode instance via the path setter, which cascades through adjustChildrenPaths and repoints the parent's _children map.
    • Re-keys the internal Map<path, TreeNode> so descendants are reachable under their new paths.
    • Marks (oldPath, newPath) in a per-builder dedup window (1 second).
    • Emits a renamed mutation event to every consumer of this builder.
  4. Chokidar's subsequent unlink(oldPath) + add(newPath) events arrive within ~200 ms and are suppressed by the dedup window.
  5. Renderer hook applyMutation handles renamed by re-running step 3's path mutation in the renderer's mirror — identity preserved across the rename.

Returns false when the node at oldPath is missing (race: chokidar's unlink already fired before the explicit call arrived). In that case the renderer already saw removed + added; identity is lost but state stays consistent.


5. TreeNode Class Hierarchy

5.1 Why Classes, Not Plain DTOs

The tree is the source of identity for two operations the renderer cares about:

  • RenametreeNode.path is mutated once at the subtree root, then adjustChildrenPaths recurses. Consumers holding a reference to the same TreeNode instance still have a valid handle.
  • Reverse lookupO(1) Map<absPath, TreeNode> index. The renderer hook keeps this in sync with mutations.

A plain DTO approach would force rebuilding the subtree on every rename, which destroys identity-based caches (React keys, hashmap lookups), and would force consumers to revalidate every reference after every mutation.

5.1.1 Why TreeDirRoot Is a Separate Class

TreeDirRoot extends TreeDir has no extra fields and no overridden behaviour today — only the constructor signature differs (TreeDirRoot(rootPath) vs. new TreeDir({ path, stats? })). It exists as a type brand:

  • useDirectoryTree returns TreeDirRoot | null, so the type system tells the caller "this is a tree root, not an interior directory". Callers can't accidentally pass a leaf directory where a root is expected.
  • It is the documented extension point for root-only state — a future rootPath history, watcher status, or withStats-summary field belongs on TreeDirRoot, not TreeDir, and the type brand keeps the migration mechanical.

If neither concern materialises, this class can be deleted and useDirectoryTree can return TreeDir | null — semantics are unchanged. Keep it until the call sites prove the brand is wasted.

5.2 Wire Shape: SerializedTreeNode

For IPC transit, the class hierarchy serializes to a plain object via toJSON(). The parent pointer is omitted (JSON has no cycles). The renderer reconstructs the class hierarchy via rootFromSerialized(snapshot); parent pointers are re-established by walking the tree and using a WeakMap to track parents during reconstruction.

SerializedTreeNode = {
  kind: 'file' | 'directory'
  path: string
  basename: string
  children?: Record<string, SerializedTreeNode>   // only on directories
  stats?: { mtime, birthtime }                    // only when withStats: true
}

5.3 Mutation Events

Four event types, applied to the renderer mirror in applyMutation:

  • added{ path, kind, basename, parentPath, stats? }. Creates a new TreeFile or TreeDir, attaches under parentPath.
  • removed{ path }. Removes the node and (if directory) all descendants from the index.
  • updated{ path, stats }. Updates node.stats in place; only fires when the tree was built with withStats: true.
  • renamed{ oldPath, newPath, basename }. Mutates the existing TreeNode instance via the path setter (identity preserved); cascades to descendants when a directory is renamed. Only emitted via the explicit file.tree.rename route — chokidar cannot synthesize this on its own. See §4.4.

Renames observed by the watcher alone surface as removed + added (chokidar's native shape). When a caller wants identity preservation, it must invoke file.tree.rename after the FS-level rename — see §4.4.

5.4 External-Rename Identity Loss

A rename that originates outside Cherry — Finder, mv, an external editor — surfaces only through chokidar as unlink(oldPath) + add(newPath). The builder applies these as removed + added mutations, so the TreeNode for the renamed file is destroyed and a new one is created. Identity is lost: React keys re-key, downstream Map<path, TreeNode> lookups invalidate, editor cursors / useFileContent SWR caches / noteTable overlays observe "the old file disappeared, an unrelated new file appeared".

We considered pairing chokidar's unlink + add into a synthetic renamed event via heuristics (basename equality + timestamp proximity), the way VS Code / Atom do. We chose not to:

  • chokidar's inode tracking is not cross-platform. Windows ReadDirectoryChangesW does not expose inodes, so any pairing has to fall back to filename heuristics there — the implementation diverges per OS.
  • Filename + timestamp pairing has a measurable false-positive rate on bursty FS operations (git checkout switching branches, editor batch-saves, build pipelines rewriting bundles in place). A mis-paired identity — claiming "file A renamed to file B" when really A was deleted and B was created independently — is strictly worse than identity loss, because downstream caches now follow the wrong file silently.
  • The heuristic adds API surface every caller would have to reason about (pairing window, opt-out, false-positive handling).

Within-Cherry renames go through file.tree.rename (§4.4) and do preserve identity, because the caller knows it's a rename and the chokidar unlink + add are suppressed by the dedup window. The external-rename case sits outside that contract on purpose — Cherry can't claim "rename" on behalf of an event source that didn't tell us it was a rename.

Reconsider if external editor integration with the Notes workspace becomes a real pain point — most likely path is an opt-in builder option (e.g. pairExternalRenames: true) that enables heuristic pairing with documented false-positive risk, pushing the trade-off to the caller rather than forcing it on every consumer.


6. .gitignore Coordination

Single source of truth, three consumers. A constant DEFAULT_IGNORE_PATTERNS in gitignore.ts (gitignore-syntax: .DS_Store, Thumbs.db, desktop.ini, node_modules/, dist/, build/, .next/, .nuxt/, coverage/, .cache/, .vscode/, .idea/) seeds three places:

  • chokidar.FSWatcher.ignoredloadGitignorePredicate builds an ignore@7 predicate from the defaults + the user's .gitignore. chokidar consults the predicate so ignored directories never get a watch handle (the cure for the original EMFILE on node_modules-heavy repos).
  • The builder's post-scan filter — the same predicate is re-checked inside the builder after search.listDirectory returns, plus once more on every watcher event as a belt-and-suspenders guard against chokidar race orderings.
  • ripgrep's -g !pattern argumentsdefaultRipgrepGlobArgs() converts the same DEFAULT_IGNORE_PATTERNS constant into ripgrep CLI flags. ripgrep also honors .gitignore natively for the rest.

These three layers used to drift: the ripgrep glob list was an independent RIPGREP_EXCLUDE_GLOBS constant in search.ts. A .DS_Store written after mount slipped past chokidar even though the initial scan filtered it. The shared DEFAULT_IGNORE_PATTERNS closes that gap.

User-side .gitignore rules apply after the defaults, so a deliberate !node_modules etc. can still un-ignore them. .git/ is force-added last so a user !.git cannot un-ignore it (watching git internals is pointless and expensive).

The predicate is loaded asynchronously inside builder.init() (not in the constructor — readFileSync on a slow filesystem would block the main event loop).

A missing .gitignore is not the same as "no exclusion at all" — loadGitignorePredicate still returns a predicate with the defaults + .git, so the watcher / scan don't recurse into OS noise / build caches / git internals. The function returns null only when the ignore library itself fails to construct (effectively never in practice).

6.1 Extension Filter Lives in the Builder, Not Ripgrep

The extensions option (e.g. ['.md'] for Notes) is applied inside the builder: passesExtensionFilter strips non-matching paths after search.listDirectory returns, and the watcher's add handler re-checks before insertion. Ripgrep is not given an --iglob argument, so on a workspace with 100k files the IPC payload returned by listDirectory is the full file list before the filter shrinks it.

This is fine today (Notes' workspaces are typically a few hundred markdown files); the cost shows up only when a single tree's root contains both a huge unrelated subtree and an explicit extensions: ['.md'] option. If that combination ever matters, push the filter down to ripgrep via --iglobsearch.listDirectory already accepts the necessary arguments.

6.2 Mutation Events Are Not Server-Side Batched

Each watcher event becomes one file.tree.mutation IPC push. chokidar debounces within a single file (200ms stabilityThreshold) but does not batch across files, so a bursty FS operation — git checkout switching to a branch with hundreds of touched files — emits a corresponding burst of pushes. Each renderer hook runs applyMutation per push and ticks version, so a useMemo(() => sort(tree), [version]) consumer will recompute repeatedly through the burst.

Consumers should debounce their downstream work (useDeferredValue / useTransition / a version-keyed useMemo whose body is fast enough to absorb the storm). A microtask-batched TreeMutationBatchEvent would solve this at the wire layer if usage justifies it; not implemented today.


7. Lifecycle

DirectoryTreeManager is registered in serviceRegistry.ts with @ServicePhase(Phase.WhenReady). The lifecycle container instantiates it after DbService / CacheService / PreferenceService complete (no @DependsOn declaration needed — cross-phase ordering is automatic).

PhaseAction
onStopdisposeAll() — clears consumers, force-tears all shared builders, drops in-flight promises

The service registers no IPC of its own: the file.tree.* routes live in the IpcApi handler map (src/main/ipc/handlers/file.ts) and delegate to it via application.get('DirectoryTreeManager').


8. Renderer Hook

useDirectoryTree(rootPath, options?) on the renderer mirrors the builder. Contract:

ts
const { root, isLoading, error, version, treeId, getNode } = useDirectoryTree(rootPath, options)
  • root: TreeDirRoot | null — the live tree. Mutated in place; version ticks each time.
  • isLoading: booleantrue between mount and completion of the create/activate handoff.
  • error: Error | null — populated when create/activate fails or the mutation stream skips a revision; cleared on next mount.
  • version: number — monotonic counter. Increment on each applied mutation; use as a useMemo dependency for derived state (sorting, filtering, projecting).
  • treeId: string | null — for downstream side-subscribers to filter file.tree.mutation payloads.
  • getNode(absPath) — O(1) lookup in the local index. Stable identity across re-renders.

Re-creates only on rootPath change. Options are sampled at mount; changing them later does not trigger a rebuild — pass a different rootPath if you need a different scan.

8.1 Cancellation Discipline

The hook handles four overlapping concerns:

  1. Mid-flight rootPath change — the previous effect's cleanup sets cancelled = true; a later create/activate resolution cannot swap the old tree into state.
  2. Unmount during create or activate — cleanup removes the mutation listener and disposes any assigned treeId.
  3. Post-unmount rejection — the catch block guards on cancelled before calling setError.
  4. StrictMode mount-unmount-mount — the first mount's effect cleanup disposes its treeId; the second mount creates a fresh one. No leaked builders.

9. Boundaries

ConcernOwnerCross-reference
Filesystem watchingcreateDirectoryWatcher (transport)watcher.ts
FileEntry rows + atomic writesFileManagerfile-manager-architecture.md
noteTable sparse-state metadataNotes domain (renderer + DataApi)not part of tree concerns
.gitignore parsinggitignore.ts (this module)private to the tree primitive
Directory listing for non-tree callerssearch.listDirectory (same module)one IPC channel survives (File_ListDirectory)

The tree primitive does not:

  • Persist any of its state — every tree is rebuilt from disk on file.tree.create.
  • Read or write the DB — no @main/data/** imports.
  • Know about FileEntry — paths are paths; entries are managed orthogonally by FileManager.
  • Implement its own FS event source — it consumes createDirectoryWatcher.

10. Testing

Suites under src/main/services/file/tree/__tests__/:

  • builder.test.ts — initial scan, .gitignore honoring, chokidar fan-out, dispose cleanup, JSON round-trip (no parent cycles), @main/data import isolation (greps the source for forbidden imports).
  • DirectoryTreeManager.protocol.test.ts — the manager state machine against a fake builder: handshake buffering + flush order, per-treeId revision numbering, activation-baseline mismatch, the pending ceiling, ownership refusal, owner destroyed mid-creation, builder dedupe (including order-insensitive option keys), grace-window reuse and teardown.
  • DirectoryTreeManager.test.ts — the same manager over a real ripgrep scan + chokidar watcher: snapshot content, live mutation fan-out, explicit rename applied to the real builder.
  • TreeNode.test.ts — class invariants: rename cascade, identity preservation, JSON serialization shape.
  • search.test.tslistDirectory happy path + error branches (ripgrep unavailable, EACCES on root).

Keep the manager split. DirectoryTreeManager.test.ts is gated behind skipIf(!ripgrepAvailable), so it does not run in CI — a protocol assertion placed there is unverified no matter how green the run looks. Anything provable without the binary belongs in .protocol.test.ts, which always runs.

Renderer-side: src/renderer/hooks/__tests__/useDirectoryTree.test.tsx covers mount/unmount, mutation application, mid-flight cancel, StrictMode remount, post-unmount rejection, treeId mismatch filtering, side-consumer delivery of the activation replay, and the terminal revision-gap state (including a gap raised during activation).


  • architecture.md — module-level positioning (where this primitive sits relative to FileManager).
  • file-manager-architecture.md — sister FileEntry / FileRef primitive. Specifically: §8 ("DirectoryWatcher") for the watcher contract this primitive consumes, including the WatcherEvent shape (ready / add / addDir / unlink / unlinkDir / change / error).
  • src/shared/file/types/tree.ts — the wire types and class hierarchy this primitive emits.