docs/references/file/directory-tree.md
SoT scope — this document owns: the
DirectoryTreeBuilderprimitive, theDirectoryTreeManagerlifecycle service that owns its IPC surface, the renderer-sideuseDirectoryTreehook, theTreeNodeshape shipped to both processes, and the.gitignore-driven scan/watch coordination. The boundary between this primitive and FileManager is stated inarchitecture.md §1.2— in case of conflict, that document decides positioning, this document decides implementation.Contract stability: the IPC contract, the
TreeNodewire shape, and the resource model (one builder per(rootPath, options)pair, refcounted acrosstreeIds with a dispose grace window) are binding commitments. When implementation reveals a contract that cannot be honored, revise this document first, then implement.
DirectoryTreeBuilder is the second top-level primitive inside the file module, parallel to FileManager. The two manage orthogonal resource concerns:
| Primitive | Resource | State | Backing | Lifecycle |
|---|---|---|---|---|
| FileManager | FileEntry rows (internal + external) + content bytes | DB + filesystem | file_entry + file association tables | always-on WhenReady service |
| DirectoryTreeBuilder | In-memory TreeDirRoot mirror + chokidar watcher | Pure runtime | None — FS is the source of truth | per-(rootPath, options); refcounted |
Neither subsumes the other:
FileEntrys) — that needs a tree, not entries.{userData}/Data/Files/) — that needs entries, not a tree.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.
chokidar InlineThree things sit on top of chokidar that any real caller would re-invent:
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.(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.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.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.
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
search.ts and gitignore.ts Live HereBoth 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.
@main/data Importssrc/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.)
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).
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.
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:
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.
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.
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).
The tree primitive rides IpcApi — schemas in src/shared/ipc/schemas/file.ts, handlers in src/main/ipc/handlers/file.ts.
| Route / event | Direction | Payload | Returns |
|---|---|---|---|
file.tree.create | renderer → main | { rootPath, options? } | { treeId, revision, snapshot: SerializedTreeNode } |
file.tree.activate | renderer → main | { treeId, revision } | boolean (true when activated) |
file.tree.dispose | renderer → main | { treeId } | void |
file.tree.rename | renderer → main | { treeId, oldPath, newName } | boolean (true if applied) |
file.tree.mutation | main → 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.
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.
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:
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.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.
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:
ipcApi.request('file.tree.rename', { treeId, oldPath, newName }); main resolves the destination as dirname(oldPath)/newName.DirectoryTreeBuilder.rename(oldPath, newPath):
TreeNode instance via the path setter, which cascades through adjustChildrenPaths and repoints the parent's _children map.Map<path, TreeNode> so descendants are reachable under their new paths.(oldPath, newPath) in a per-builder dedup window (1 second).renamed mutation event to every consumer of this builder.unlink(oldPath) + add(newPath) events arrive within ~200 ms and are suppressed by the dedup window.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.
TreeNode Class HierarchyThe tree is the source of identity for two operations the renderer cares about:
treeNode.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.O(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.
TreeDirRoot Is a Separate ClassTreeDirRoot 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.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.
SerializedTreeNodeFor 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
}
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.
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:
ReadDirectoryChangesW does not expose inodes, so any pairing has to fall back to filename heuristics there — the implementation diverges per OS.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.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.
.gitignore CoordinationSingle 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.ignored — loadGitignorePredicate 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).search.listDirectory returns, plus once more on every watcher event as a belt-and-suspenders guard against chokidar race orderings.-g !pattern arguments — defaultRipgrepGlobArgs() 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).
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 --iglob — search.listDirectory already accepts the necessary arguments.
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.
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).
| Phase | Action |
|---|---|
onStop | disposeAll() — 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').
useDirectoryTree(rootPath, options?) on the renderer mirrors the builder. Contract:
const { root, isLoading, error, version, treeId, getNode } = useDirectoryTree(rootPath, options)
root: TreeDirRoot | null — the live tree. Mutated in place; version ticks each time.isLoading: boolean — true 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.
The hook handles four overlapping concerns:
rootPath change — the previous effect's cleanup sets cancelled = true; a later create/activate resolution cannot swap the old tree into state.treeId.cancelled before calling setError.| Concern | Owner | Cross-reference |
|---|---|---|
| Filesystem watching | createDirectoryWatcher (transport) | watcher.ts |
FileEntry rows + atomic writes | FileManager | file-manager-architecture.md |
noteTable sparse-state metadata | Notes domain (renderer + DataApi) | not part of tree concerns |
.gitignore parsing | gitignore.ts (this module) | private to the tree primitive |
| Directory listing for non-tree callers | search.listDirectory (same module) | one IPC channel survives (File_ListDirectory) |
The tree primitive does not:
file.tree.create.@main/data/** imports.FileEntry — paths are paths; entries are managed orthogonally by FileManager.createDirectoryWatcher.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.ts — listDirectory 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.