← the Atlas

Code-tab filter strands empty directories

bug · budding ·

Why filtering the Code-tab tree left hollow, result-less folders — Pierre's remove promotes an emptied dir to EXPLICIT — and the directoryRemovalOps fix, with the /hickey + /lowy verdict.

Diagnosis · branch search-collapsae · confirmed against kolu source and @pierre/[email protected] internals. Fixed in #1096 (merged 2026-06-01) — directoryRemovalOps in packages/solid-pierre/src/pathReconcile.ts.

How the filter is wired

The Code-tab search is host-driven — Pierre’s own search is off, and kolu projects a path set into the tree:

type in filtersearchQuery()projectFileTreeSearch{ projectedPaths, expandedAncestors }<FileTree> batch + expand

What I verified in Pierre’s source

ClaimStatusSource
tree.batch([{remove}])store.removeremovePathtrueFileTreeController.js:522store.js:121-139
removeSubtree removes only the target file node, never its parenttruecanonical.js:406-441
After unlinking the file, removePath calls promoteEmptyAncestorsToExplicit(parentId)the bugcanonical.js:46-66 (line 56)
That walks up and flags every emptied directory EXPLICIT instead of deleting itthe bugcanonical.js:442-451
An EXPLICIT empty directory stays a visible rowtruecanonical.js:201,222,557
The wrapper only ever .expand()s; it never collapsestrueFileTree.tsx:198,219-222
The filter input has no debounce — every keystroke re-projectstrueFileSearchInput.tsx:19

The decisive few lines:

// path-store/src/canonical.js — removePath
const removedNodeIds = removeSubtree(state, nodeId);   // removes ONLY the file node
removeChildReference(state, parentId, nodeId, …);      // unlinks it from the parent
promoteEmptyAncestorsToExplicit(state, parentId);      // ← the emptied directory SURVIVES

// promoteEmptyAncestorsToExplicit
while (currentDirectoryId != null) {
  if (getDirectoryIndex(state, currentDirectoryId).childIds.length > 0) return;
  addNodeFlag(currentNode, PATH_STORE_NODE_FLAG_EXPLICIT);  // keep the empty folder visible
  currentDirectoryId = currentNode.parentId;               // …and keep walking up
}

Why the screenshot looks the way it does

Why the directories are present at all defect A

pathDiffOperations (then FileTree.tsx:59-73, now packages/solid-pierre/src/pathReconcile.ts) diffs only file path lists; directories never appear in projectedPaths, so no remove op ever targets a directory. Every directory that contained only non-matching files is emptied by the file removals and then promoted to a persistent empty folder. Net: the entire pre-search directory skeleton remains, now hollow.

Why they’re shown expanded defect B

Two compounding facts, both about expansion being monotonic:

.github/workflows is the lone collapsed row because no intermediate prefix ever expanded it. Even with a single paste of docs plans, the empty directories would still appear — just collapsed rather than expanded.

Fix — /hickey + /lowy verdict shipped

This landed in #1096 as directoryRemovalOps(prev, next) in packages/solid-pierre/src/pathReconcile.ts, invoked from FileTree.tsx. The shipped form supersedes the sketch below: it computes the disjoint maximal dead-subtree roots and removes them in a single batch, rather than per-directory tree.batch calls in a sorted loop.

Direction/hickey/lowyVerdict
A · prune emptied dirs in the wrapperYes — but derive dirs from the file set, don’t track appliedDirs (parallel array = silent-divergence)Yes — correct seam; both fileSearch.ts and the wrapper stay putchosen in Hickey’s derived form
B · collapse passNo — collapse() on a dir the wrapper doesn’t own destroys hand-opens; the expand loop is safe only because it’s monotonicAdd it for symmetryrejected
C · resetPaths on active filterNo — mode-branch state machine; “user-expansions” is a ghost variable Pierre never exposesNo — couples the search-agnostic wrapper to CodeTab’s search axisrejected

Resolving the disagreement on A — derive, don’t track

Lowy’s concrete prescription (“diff appliedDirs against new Set(expandPaths)”) has a latent filter-clear bug: on an empty query projectFileTreeSearch returns expandedAncestors: [] while projectedPaths is the full inventory. Using expandPaths as the directory authority would remove every directory the moment the user clears the filter. Deriving the authority from the file set is immune:

// after the file batch + appliedPaths = paths:
const prevDirs = new Set(appliedPaths.flatMap(ancestorDirectoryPaths));
const nextDirs = new Set(paths.flatMap(ancestorDirectoryPaths));   // full set on empty query ⇒ no removals
for (const dir of [...prevDirs].filter(d => !nextDirs.has(d))
                               .sort((a, b) => a.length - b.length)) {  // shallowest first
  const item = tree.getItem(dir);
  if (item) tree.batch([{ type: "remove", path: dir, recursive: true }]); // emptied dirs still hold empty child dirs
}

Two corrections to the originally-sketched op: (1) recursive: true — an emptied directory still contains its (now-explicit) empty child directories, so a bare remove throws; (2) shallowest-first so the maximal empty subtree is removed in one op. No new tracked state: appliedPaths stays the single source.

Why defect B folds into defect A

Once empty directories are removed, the “expanded empty rows” symptom is gone — an absent directory has no row to render open. And among the directories that survive, every one is an ancestor of a live match, so it must stay expanded. There is no “surviving directory that should be collapsed” case during an active filter — a collapse pass would re-hide matches and fight the user’s own collapse (the #867 feature). Lowy’s symmetry is a false symmetry: expand-to-reveal is mandatory, collapse-to-hide is never wanted.

Test plan done

fileSearch.test.ts tests only the pure projection and cannot see this bug — it never drives the Pierre batch where promoteEmptyAncestorsToExplicit lives. Both layers landed in packages/solid-pierre/src/pathReconcile.test.ts via #1096 , with the integration layer driving a real Pierre tree directly instead of mounting <FileTree> in jsdom: