feat(ui): live streaming folder-tree preview for the exclusion editor - #158
Merged
Merged
Conversation
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
force-pushed
the
feat/exclusion-preview-tree
branch
from
July 25, 2026 15:47
d857dc8 to
7be9de6
Compare
Contributor
Coverage
Gate: passed - no coverage regression (epsilon 0.1 pp). |
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).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:batchevents 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 terminalexclusion_preview:donelands (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 likepreview_exclusions- the two now shareresolve_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 freshpreview_idimmediately. The walk emits a batch every 400 nodes or 100ms, whichever comes first, and finishes withexclusion_preview:donecarrying 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.
PreviewRegistrykeeps 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_exclusionsis untouched and still registered.Truncation cap: node details stop streaming after 50,000 nodes;
truncatedflips 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
Mapread through atreeVersionref bumped once per coalesced flush (thependingLivepattern fromstores/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_pathproduces a root-anchored glob:/docs/notes.txtfor a file,/docs/build/for a directory. The leading/is what stops it also matchingsub/docs/notes.txt; the trailing/sets theignorecrate'sis_only_dir, andmatched_path_or_any_parents(the same callSourceMatcher::is_includedmakes) then applies it to everything beneath. The same string works on both sides - verbatim inexclude_patternsit forces the path out, and ininclude_patterns(wherebuild_source_matcherprepends 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_linetrims trailing whitespace otherwise). A path that cannot be one glob line - empty, containing a newline, or ending in non-space whitespace - yieldsNoneand 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_matcherover 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 unescapedodd[1].txtwould wrongly also excludeodd1.txt. The TypeScriptanchoredPatternForPathmirrors it and both sides assert the same vector table.Two real bugs the integration tests caught
v-for, so Vue registered the tree's template ref as an array -.restart()was silently undefined and no rule edit re-classified.applyRulecalledrestart()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 anextTick()before the restart.Tests
driven-core::exclude(the vector table plus four matcher-verified flip tests and the metacharacter-escaping one) and 13 incommands::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).settings-components.test.ts- whosepreview_exclusionsmocks are updated for the new flow.Gates
cargo fmt --all --checkclean;cargo clippy --workspace --all-targetsno warnings;cargo test -p driven-core --lib361 passed,-p driven-app --lib262 passed;pnpm -C ui test:unit396 passed (37 files);pnpm -C ui lint0 errors;vue-tsc --noEmitclean.Known limitations
settings.addSource.preview.truncatedwas removed in favour ofsettings.exclusionPreview.truncated, which explains that only the tree stops while the counts stay exact.🤖 Generated with Claude Code