Skip to content

feat(ui): live streaming folder-tree preview for the exclusion editor - #158

Merged
pmaxhogan merged 1 commit into
mainfrom
feat/exclusion-preview-tree
Jul 25, 2026
Merged

pmaxhogan merged 1 commit into
mainfrom
feat/exclusion-preview-tree

Conversation

@pmaxhogan

Copy link
Copy Markdown
Owner

Creating a source or editing its exclusions used to call preview_exclusions, which walks the whole tree before returning anything - on a large folder that is minutes of a bare "Loading..." with no sign of progress and no way to see what the rules are actually doing. This replaces it with a folder tree that streams in as the scan runs, and makes each row directly actionable.

The four spec points

a. Live tree while scanning. The walk emits exclusion_preview:batch events as it goes; the editor renders rows the moment they arrive, alongside running included/excluded counts and byte total, a pulsing "Still scanning..." indicator, and a "Scan complete" state once the terminal exclusion_preview:done lands (a cancelled walk deliberately does not claim completion).

b. Included vs excluded, not by colour alone. An excluded row gets three independent cues: muted colour, a strikethrough on the name, and a distinct glyph (a slashed circle vs a check). A visually-hidden "Included"/"Excluded" word carries the same distinction to screen readers. The design language follows Activity.vue / Settings.vue - teal accent, zinc surfaces, explicit dark-mode variants.

c. Everything starts collapsed. The root's immediate children render as collapsed rows with expand chevrons. A folder's children are not merely hidden but absent from the DOM until it is expanded, so the rendered node count tracks what the user has opened rather than what was streamed. A folder with a huge number of children is additionally paged at 200 behind a "Show N more" row.

d. Per-row "+" / "-". An included row offers "-" (exclude it), an excluded row offers "+" (re-include it). The click appends the glob as a new line to the matching textarea - skipping an exact duplicate, since patterns count against the source's 256-pattern cap - and immediately re-runs the walk, so the row's verdict updates without waiting for a blur.

Streaming architecture

preview_exclusions_start(req) validates exactly like preview_exclusions - the two now share resolve_preview_root_and_matcher, so the security-relevant half (glob validation, the exactly-one-selector rule, the dialog-token peek, the readable-dir check) cannot drift. It then spawns the blocking walk and returns a fresh preview_id immediately. The walk emits a batch every 400 nodes or 100ms, whichever comes first, and finishes with exclusion_preview:done carrying the exact totals. Every event is tagged with the generation id and the webview discards anything else, so a superseded walk's in-flight events cannot pollute the new tree.

The walk is breadth-first, which matters twice: a node's parent is always streamed before the node itself (the webview builds the tree incrementally and has nowhere to attach an orphan), and the root's own children appear in the first batch instead of DFS diving into one deep subtree.

PreviewRegistry keeps at most one live walk: starting a preview cancels the one it supersedes, so tweaking globs over a large folder cannot stack N concurrent full-tree walks. preview_exclusions_cancel(id) handles the nothing-replaces-it case (the editor closing). The walk polls the cancel flag between directories, every 256 entries, and at every batch boundary - so a cancel takes effect within one batch even inside a single enormous directory.

The old preview_exclusions is untouched and still registered.

Truncation cap: node details stop streaming after 50,000 nodes; truncated flips true and the tree stops growing, but the counts and byte total keep updating to the exact end of the walk. A 10M-file source therefore cannot OOM the webview while the summary line stays truthful. The UI shows "The tree stops here, but the counts above keep going and stay exact."

On the webview side the tree index is a plain non-reactive Map read through a treeVersion ref bumped once per coalesced flush (the pendingLive pattern from stores/activity.ts), so a burst of thousands of streamed nodes costs one reactive update per animation frame.

Pattern syntax for +/-

For a source-root-relative path, anchored_pattern_for_path produces a root-anchored glob: /docs/notes.txt for a file, /docs/build/ for a directory. The leading / is what stops it also matching sub/docs/notes.txt; the trailing / sets the ignore crate's is_only_dir, and matched_path_or_any_parents (the same call SourceMatcher::is_included makes) then applies it to everything beneath. The same string works on both sides - verbatim in exclude_patterns it forces the path out, and in include_patterns (where build_source_matcher prepends the !) it brings the path back, beating both the gitignore cascade and the defaults.

Glob metacharacters in real filenames are backslash-escaped (/odd\[1\].txt, /alt\{a,b\}.txt) and a trailing space is escaped as \ (add_line trims trailing whitespace otherwise). A path that cannot be one glob line - empty, containing a newline, or ending in non-space whitespace - yields None and the row simply gets no button, rather than an appended rule that would silently match something else.

This is verified against the real matcher, not asserted from the docs: tests build build_source_matcher over on-disk fixtures and check that the generated pattern flips exactly its own path and leaves prefix-sharing siblings (build.txt, builder/, docs/notes.txt.bak, sub/docs/notes.txt) untouched - including that an unescaped odd[1].txt would wrongly also exclude odd1.txt. The TypeScript anchoredPatternForPath mirrors it and both sides assert the same vector table.

Two real bugs the integration tests caught

  • The editor panel lives inside the per-source v-for, so Vue registered the tree's template ref as an array - .restart() was silently undefined and no rule edit re-classified.
  • applyRule called restart() before the parent's prop update had propagated, so every click re-scanned with the rules as they stood before it - the clicked row would come back unchanged. Fixed with a nextTick() before the restart.

Tests

  • Rust, 19 new: 6 in driven-core::exclude (the vector table plus four matcher-verified flip tests and the metacharacter-escaping one) and 13 in commands::exclusion_stream (parent-before-child ordering, node-threshold and interval batching, the truncation cap keeping counts exact, cancellation mid-walk and pre-cancelled, excluded-directory pruning with and without negations, and four registry tests including a superseded walk finishing late not deregistering its successor).
  • Vitest, 53 new: 35 store tests (the shared vector table, batch folding and sorting, stale-generation discard, the pre-id replay race, totals past truncation, overtaken-start cancellation) and 16 mount tests (collapsed-by-default, expand/collapse, non-colour cues, ARIA roles/levels, each +/- glob form, paging, unmount cancellation), plus 2 integration tests in settings-components.test.ts - whose preview_exclusions mocks are updated for the new flow.

Gates

cargo fmt --all --check clean; cargo clippy --workspace --all-targets no warnings; cargo test -p driven-core --lib 361 passed, -p driven-app --lib 262 passed; pnpm -C ui test:unit 396 passed (37 files); pnpm -C ui lint 0 errors; vue-tsc --noEmit clean.

Known limitations

  • A rule change clears the tree and re-streams rather than re-classifying in place. This is the honest behaviour (every verdict is stale under a new rule) and BFS puts the top level back within the first batch, but on a very large source the tree does visibly rebuild.
  • Expansion state resets on each re-scan, for the same reason.
  • The 50k node cap is not user-configurable.
  • settings.addSource.preview.truncated was removed in favour of settings.exclusionPreview.truncated, which explains that only the tree stops while the counts stay exact.

🤖 Generated with Claude Code

Replace the exclusion editor's minutes-long "Loading..." with a folder tree
that streams in as the scan walks it.

Backend: preview_exclusions_start / preview_exclusions_cancel run the walk on
a blocking thread and emit exclusion_preview:batch events (every 400 nodes or
100ms) plus a terminal :done, each tagged with a generation id. The walk is
breadth-first so a node's parent always streams first, node details stop at
50k (counts stay exact), and a single-slot registry cancels the walk each new
preview supersedes. Validation is shared verbatim with the one-shot
preview_exclusions, which still works.

UI: ExclusionPreviewTree + an incremental non-reactive tree index with one
coalesced flush per frame. Folders start collapsed and render children only
when expanded. Each row's +/- appends a root-anchored glob whose exact form is
pinned against build_source_matcher in the Rust tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JLB3E2Jm7knNJd37fVpH8X
@pmaxhogan
pmaxhogan force-pushed the feat/exclusion-preview-tree branch from d857dc8 to 7be9de6 Compare July 25, 2026 15:47
@github-actions

Copy link
Copy Markdown
Contributor

Coverage

Area main this PR delta
Rust (lib crates) 79.50% 79.65% +0.15 (OK)
UI (vue/ts) 90.39% 90.82% +0.43 (OK)

Gate: passed - no coverage regression (epsilon 0.1 pp).

@pmaxhogan
pmaxhogan merged commit 47ebe14 into main Jul 25, 2026
18 checks passed
@pmaxhogan
pmaxhogan deleted the feat/exclusion-preview-tree branch July 25, 2026 16:15
@github-project-automation github-project-automation Bot moved this from Todo to Done in Driven Jul 25, 2026
pmaxhogan added a commit that referenced this pull request Jul 25, 2026
🤖 I have created a release *beep* *boop*
---


## [2.3.0](v2.2.0...v2.3.0)
(2026-07-25)


### Features

* **core:** record a backup_done activity row when a run completes
([#160](#160))
([90cde5c](90cde5c))
* **ui:** files-uploaded stat card with sparkline and smoother Activity
load-in ([#157](#157))
([2d85c99](2d85c99))
* **ui:** live streaming folder-tree preview for the exclusion editor
([#158](#158))
([47ebe14](47ebe14))


### Bug Fixes

* **telemetry:** count bundled uploads in the anonymous aggregate
([#159](#159))
([11af7ea](11af7ea))
* **ui:** label bundle_upload and hook activity event types
([#154](#154))
([2998c76](2998c76))
* **ui:** make the backing-up bar a true determinate progress bar
([#155](#155))
([eed80a6](eed80a6))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

1 participant