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_TreeCreate IPC 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
│ └── registerIpcHandlers() — File_TreeCreate / File_TreeDispose
│
├── 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, snapshot }
└── TreeMutationPushPayload — { treeId, event }
src/renderer/hooks/useDirectoryTree.ts ← renderer hook
├── On mount → File_TreeCreate → rehydrate TreeNode class hierarchy
├── On File_TreeMutation (filtered by treeId) → applyMutation in place
├── Returns { root, isLoading, error, version, treeId, getNode }
└── On unmount → File_TreeDispose
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_TreeCreate IPC call returns a unique treeId. The renderer uses this to filter mutation pushes (the File_TreeMutation channel is shared across all live trees in a window). Distinct treeIds may share a builder:
File_TreeCreate('/work/notes', {...}) → treeId=t-1
File_TreeCreate('/work/notes', {...}) → treeId=t-2 ← same builder
File_TreeCreate('/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_TreeDispose(old) and File_TreeCreate(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_TreeDispose is preferred (it triggers the grace window), but this cascade is the safety net for crashed windows.
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).
| Channel | Value | Direction | Payload | Returns |
|---|---|---|---|---|
File_TreeCreate | file:tree:create | renderer → main | { rootPath, options? } | { treeId, snapshot: SerializedTreeNode } |
File_TreeDispose | file:tree:dispose | renderer → main | { treeId } | void |
File_TreeRename | file:tree:rename | renderer → main | { treeId, oldPath, newPath } | boolean (true if applied) |
File_TreeMutation | file:tree:mutation | main → renderer (push) | { treeId, event: TreeMutationEvent } | — |
The file:tree:* prefix places these alongside File_Open / File_Read / etc. — the tree primitive is part of the file module, so its IPC namespace is too.
Both File_TreeCreate and File_TreeDispose validate their payloads through Zod at the handler boundary. 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.
A malformed payload rejects with a ZodError Promise rejection at the IPC boundary; the renderer's invoke() rejects with the same error. There is no silent narrowing — handlers never see an unvalidated object.
The preload bridge exposes the channels behind window.api.tree:
window.api.tree.create(rootPath, options?) → Promise<CreateTreeIpcResult>
window.api.tree.dispose(treeId) → Promise<void>
window.api.tree.rename(treeId, oldPath, newPath) → Promise<boolean>
window.api.tree.onMutation(callback) → () => void // unsubscribe
Each onMutation call registers its own ipcRenderer.on listener. All listeners receive every File_TreeMutation push regardless of which tree it belongs to; consumers must filter by payload.treeId. The useDirectoryTree hook does this internally and exposes its treeId so downstream side-subscribers can do the same.
File_TreeRename is invoked by callers that just performed a file-system rename (e.g. Notes after window.api.file.rename). The flow:
window.api.tree.rename(treeId, oldPath, newPath).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_TreeRename IPC — 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_TreeRename 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_TreeRename (§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_TreeMutation 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 |
|---|---|
onInit | registerIpcHandlers() — wires File_TreeCreate / File_TreeDispose |
onStop | disposeAll() — clears consumers, force-tears all shared builders, drops in-flight promises |
IPC handlers are registered via this.ipcHandle() (from BaseService), so they are auto-cleaned on stop. No manual ipcMain.removeHandler calls.
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 first File_TreeCreate resolution.error: Error | null — populated on rejected File_TreeCreate; 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_TreeMutation 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; the resolved snapshot calls disposeTree(createdTreeId) instead of swapping into state.createdTreeId was assigned before cleanup, the cleanup also calls disposeTree.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_TreeCreate.@main/data/** imports.FileEntry — paths are paths; entries are managed orthogonally by FileManager.createDirectoryWatcher.Three 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.test.ts — builder dedupe (including order-insensitive option keys), grace-window reuse, multi-consumer mutation fan-out, explicit-rename dispatch by treeId, webContents-destroyed cascade cleanup, in-flight cancellation under onStop.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).Renderer-side: src/renderer/hooks/__tests__/useDirectoryTree.test.tsx covers mount/unmount, mutation application, mid-flight cancel, StrictMode remount, post-unmount rejection, and treeId mismatch filtering.
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.