iced-swdir-tree 0.6.0

iced widget for file tree powered by swdir, supporting selection, lazy loading and filtering.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
//! The [`DirectoryTree`] state type — holds the tree's nodes, cache, and
//! configuration, and is the owning handle the parent application keeps
//! across frames.
//!
//! The `update` and `view` methods live in their own submodules
//! ([`update`] and [`view`]) so this file stays focused on construction
//! and configuration.

pub(crate) mod config;
pub(crate) mod drag;
pub(crate) mod error;
pub(crate) mod executor;
pub(crate) mod icon;
pub(crate) mod keyboard;
pub(crate) mod message;
pub(crate) mod node;
pub(crate) mod search;
pub(crate) mod selection;
pub(crate) mod update;
pub(crate) mod view;
pub(crate) mod walker;

use std::path::PathBuf;
use std::sync::Arc;

use self::{
    config::{DirectoryFilter, TreeConfig},
    executor::{ScanExecutor, ThreadExecutor},
    node::{TreeCache, TreeNode},
};

/// A directory tree widget state.
///
/// Hold one `DirectoryTree` per visible tree in your application state.
/// The widget is cheap to construct: [`DirectoryTree::new`] creates only
/// the root node — child folders are scanned lazily when the user
/// expands them.
///
/// ## Lifecycle
///
/// 1. [`DirectoryTree::new`] — build with a root path.
/// 2. Optionally chain [`DirectoryTree::with_filter`] and/or
///    [`DirectoryTree::with_max_depth`] to configure.
/// 3. Call [`DirectoryTree::view`] from your `view` function.
/// 4. Route emitted [`DirectoryTreeEvent`]s through your app's message
///    system and pass them to [`DirectoryTree::update`], which returns an
///    [`iced::Task`] the parent should `.map(..)` back into its own
///    message type.
///
/// [`DirectoryTreeEvent`]: crate::DirectoryTreeEvent
pub struct DirectoryTree {
    /// The root of the tree. Always present even if traversal fails —
    /// failure just surfaces as [`TreeNode::error`] being set on the root.
    pub(crate) root: TreeNode,
    /// Configuration applied uniformly while traversing.
    pub(crate) config: TreeConfig,
    /// Path → already-loaded children cache to avoid re-scanning on
    /// repeated collapse/expand.
    pub(crate) cache: TreeCache,
    /// Monotonically increasing counter used to invalidate stale async
    /// results when the same folder is expanded, collapsed, expanded
    /// again (or when the tree is dropped / replaced).
    pub(crate) generation: u64,
    /// The set of currently-selected paths.
    ///
    /// v0.3 replaces v0.2's single `selected_path: Option<PathBuf>`
    /// with a Vec for multi-select. The order here is **not**
    /// semantically meaningful — treat it as a set. If you need the
    /// "most recently touched" path (for e.g. a detail pane), use
    /// [`DirectoryTree::active_path`]; if you need the pivot for
    /// range extension, use [`DirectoryTree::anchor_path`].
    ///
    /// `TreeNode::is_selected` is a view-layer cache kept in sync
    /// with this set by [`DirectoryTree::sync_selection_flags`].
    pub(crate) selected_paths: Vec<std::path::PathBuf>,
    /// The path the user most recently acted on (click, Space, etc.).
    ///
    /// This is also what [`DirectoryTree::selected_path`] returns,
    /// which preserves v0.2's single-select API semantics for apps
    /// that never used multi-select.
    pub(crate) active_path: Option<std::path::PathBuf>,
    /// The pivot for Shift+click range extension.
    ///
    /// Set by [`SelectionMode::Replace`](crate::SelectionMode) and
    /// [`SelectionMode::Toggle`](crate::SelectionMode);
    /// deliberately **not** updated by
    /// [`SelectionMode::ExtendRange`](crate::SelectionMode) so
    /// successive Shift+clicks all extend from the same origin —
    /// matching Windows Explorer / Finder / VS Code behaviour.
    pub(crate) anchor_path: Option<std::path::PathBuf>,
    /// In-progress drag state, if the user currently has the mouse
    /// button held after pressing on a row. `None` otherwise.
    ///
    /// See [`drag`](crate::directory_tree::drag) for the state
    /// machine that governs this field. The widget itself performs
    /// no filesystem operations; it just tracks the drag and emits
    /// [`DragCompleted`](crate::DirectoryTreeEvent::DragCompleted)
    /// on successful drop.
    pub(crate) drag: Option<drag::DragState>,
    /// **v0.5:** paths for which a prefetch-triggered scan is
    /// currently in flight.
    ///
    /// Populated by the [`update`](crate::directory_tree::update)
    /// dispatcher when it issues prefetch scans following a user
    /// expansion; drained by `on_loaded` when each scan result
    /// arrives. The presence of a path in this set is how the
    /// widget tells "this scan result came from prefetch — don't
    /// re-prefetch its children" apart from "this scan result came
    /// from a user-initiated expand — do prefetch its children".
    /// Prevents the exponential-cascade trap.
    ///
    /// When the user explicitly expands a path that's currently in
    /// this set (rare but possible: they click faster than the
    /// prefetch scan completes), `on_toggled` removes the path so
    /// the eventual user-initiated result triggers its own prefetch
    /// wave normally.
    pub(crate) prefetching_paths: std::collections::HashSet<std::path::PathBuf>,
    /// **v0.6:** incremental-search state.
    ///
    /// `None` when search is inactive (the default). When the app
    /// calls [`DirectoryTree::set_search_query`] with a non-empty
    /// query, this is populated with the query plus a cached set
    /// of visible-under-search paths.
    ///
    /// The rest of the widget — rendering, keyboard nav — consults
    /// this state automatically through [`TreeNode::visible_rows`].
    /// See the [`search`] module docs for the full contract.
    ///
    /// [`DirectoryTree::set_search_query`]: Self::set_search_query
    pub(crate) search: Option<search::SearchState>,
    /// Pluggable executor that runs blocking `scan_dir` calls.
    ///
    /// Defaults to [`ThreadExecutor`] (one `std::thread::spawn` per
    /// expansion), which is correct but slightly wasteful for apps
    /// that already run their own blocking-task pool. Swap it via
    /// [`DirectoryTree::with_executor`].
    pub(crate) executor: Arc<dyn ScanExecutor>,
}

impl DirectoryTree {
    /// Create a new tree rooted at `root`.
    ///
    /// Only the root node is created eagerly; the first level of
    /// children is scanned when the user first expands the root (or,
    /// for convenience, when you call [`DirectoryTree::update`] with a
    /// `Toggled(root)` event yourself).
    ///
    /// Defaults: [`DirectoryFilter::FilesAndFolders`], no depth limit.
    pub fn new(root: PathBuf) -> Self {
        let root_node = TreeNode::new_root(root.clone());
        Self {
            root: root_node,
            config: TreeConfig {
                root_path: root,
                filter: DirectoryFilter::default(),
                max_depth: None,
                prefetch_per_parent: 0,
            },
            cache: TreeCache::default(),
            generation: 0,
            selected_paths: Vec::new(),
            active_path: None,
            anchor_path: None,
            drag: None,
            prefetching_paths: std::collections::HashSet::new(),
            search: None,
            executor: Arc::new(ThreadExecutor),
        }
    }

    /// Set the display filter.
    ///
    /// This is the builder form used at construction. For runtime
    /// filter changes call [`DirectoryTree::set_filter`] — or use this
    /// method with `std::mem::replace` / `std::mem::take`-style moves
    /// if that fits the shape of your state better. Either route
    /// re-derives visible children from the cache, so the tree
    /// updates instantly without re-scanning the filesystem.
    pub fn with_filter(mut self, filter: DirectoryFilter) -> Self {
        self.set_filter(filter);
        self
    }

    /// Limit how deep the widget will load. `depth == 0` means only the
    /// root's direct children are ever loaded; `depth == 1` allows one
    /// more level of descent; and so on. No limit by default.
    pub fn with_max_depth(mut self, depth: u32) -> Self {
        self.config.max_depth = Some(depth);
        self
    }

    /// **v0.5:** configure parallel pre-expansion of visible descendants.
    ///
    /// When a user-initiated expansion finishes loading a folder,
    /// the widget will eagerly issue background scans for up to
    /// `limit` of that folder's direct children-that-are-folders, in
    /// parallel via the widget's [`ScanExecutor`]. Those children's
    /// data is loaded into the in-memory cache (`is_loaded = true`)
    /// but they are **not** automatically expanded in the UI — the
    /// user still controls what's drawn. When they click to expand
    /// a prefetched child, no I/O happens: the expansion is instant.
    ///
    /// Passing `0` (the default) disables prefetch and restores
    /// v0.1–0.4 behaviour exactly. Typical app values: `5`–`25`,
    /// sized to the number of folder-children a user plausibly
    /// targets with their next click. A very high value effectively
    /// means "prefetch every child folder" — the crate doesn't cap
    /// it, because apps with fast executors (tokio / rayon / smol)
    /// can legitimately want that.
    ///
    /// ```ignore
    /// let tree = DirectoryTree::new(root)
    ///     .with_executor(my_tokio_executor)   // fast pool
    ///     .with_prefetch_limit(20);           // up to 20 parallel scans
    /// ```
    ///
    /// Prefetch is **one level deep only**: a folder that loaded via
    /// prefetch does not itself trigger further prefetches. This
    /// avoids the exponential `limit ^ depth` cascade that would
    /// otherwise paper-over I/O costs the user didn't ask for.
    ///
    /// Prefetch respects [`with_max_depth`](Self::with_max_depth)
    /// the same way user-initiated scans do — a prefetch target
    /// past the cap is skipped, not scanned.
    ///
    /// See [`TreeConfig::prefetch_per_parent`] for the full contract.
    pub fn with_prefetch_limit(mut self, limit: usize) -> Self {
        self.config.prefetch_per_parent = limit;
        self
    }

    /// Route blocking `scan_dir` calls through a custom executor.
    ///
    /// By default the widget spawns a fresh `std::thread` per
    /// expansion via [`ThreadExecutor`]. Apps that already manage
    /// a blocking-task pool (tokio, smol, rayon, ...) can implement
    /// [`ScanExecutor`] and swap it in here:
    ///
    /// ```ignore
    /// use std::sync::Arc;
    /// let tree = DirectoryTree::new(root).with_executor(Arc::new(MyTokioExecutor));
    /// ```
    ///
    /// Calling this mid-session is allowed (the next scan will use
    /// the new executor); in-flight scans initiated under the old
    /// executor still complete through it.
    ///
    /// [`ScanExecutor`]: crate::ScanExecutor
    /// [`ThreadExecutor`]: crate::ThreadExecutor
    pub fn with_executor(mut self, executor: Arc<dyn ScanExecutor>) -> Self {
        self.executor = executor;
        self
    }

    /// Change the display filter at runtime. The tree re-derives its
    /// visible children from the unfiltered cache, so the change is
    /// instant — no re-scan, no blocking the UI.
    ///
    /// **Selection is preserved.** Selection is kept by path on the
    /// widget, not on the [`TreeNode`]s that this call rebuilds, so
    /// every selected path survives the filter swap. Paths that
    /// become invisible under the new filter are not lost — flipping
    /// the filter back re-reveals them unchanged. This is true for
    /// both single and multi-select.
    ///
    /// **Expansion state is preserved too.** `rebuild_from_cache`
    /// copies the whole child subtree from the old node into its
    /// freshly-built replacement, so directories the user had opened
    /// stay open.
    pub fn set_filter(&mut self, filter: DirectoryFilter) {
        if self.config.filter == filter {
            return;
        }
        self.config.filter = filter;
        rebuild_from_cache(&mut self.root, &self.cache, filter);
        // Re-apply selection onto the new node graph. The `selected_paths`
        // Vec is authoritative; the per-node `is_selected` caches
        // need re-syncing after any mutation that drops and recreates
        // nodes.
        self.sync_selection_flags();
        // v0.6: if a search query is active, re-run it against the
        // post-filter node graph. A node that was a match may have
        // been filtered out (e.g. switching to FoldersOnly while
        // searching "readme.md"), or a newly-visible node may now
        // match.
        self.recompute_search_visibility();
    }

    /// Return the root path.
    pub fn root_path(&self) -> &std::path::Path {
        &self.config.root_path
    }

    /// Return the current filter.
    pub fn filter(&self) -> DirectoryFilter {
        self.config.filter
    }

    /// Return the current max depth, if any.
    pub fn max_depth(&self) -> Option<u32> {
        self.config.max_depth
    }

    /// Return a reference to the currently-active selected path, if any.
    ///
    /// The active path is the path the user most recently acted on —
    /// the last row clicked, the last Space-toggled, the last target
    /// of a Shift-range, etc. For single-select applications this is
    /// exactly the one selected path and matches v0.2 semantics.
    ///
    /// For multi-select, use [`DirectoryTree::selected_paths`] to see
    /// the whole set and [`DirectoryTree::anchor_path`] to read the
    /// pivot for range extension.
    ///
    /// The returned path may point to a node that is currently
    /// invisible (because an ancestor is collapsed, or because the
    /// active filter hides it); the view layer handles that
    /// gracefully.
    pub fn selected_path(&self) -> Option<&std::path::Path> {
        self.active_path.as_deref()
    }

    /// All currently-selected paths.
    ///
    /// Order is not semantically meaningful — treat the slice as a
    /// set. The slice is empty iff nothing is selected. Runs in
    /// O(1) (returns a reference to the internal Vec).
    pub fn selected_paths(&self) -> &[std::path::PathBuf] {
        &self.selected_paths
    }

    /// The anchor used as the pivot for
    /// [`SelectionMode::ExtendRange`](crate::SelectionMode).
    ///
    /// The anchor is set by `Replace` and `Toggle` selections, and
    /// is *not* moved by `ExtendRange` — so two successive
    /// `Shift+click`s from the same starting point select different
    /// ranges with the same origin.
    ///
    /// Returns `None` before the first selection.
    pub fn anchor_path(&self) -> Option<&std::path::Path> {
        self.anchor_path.as_deref()
    }

    /// `true` if `path` is in the selected set. O(n) in the set size.
    pub fn is_selected(&self, path: &std::path::Path) -> bool {
        self.selected_paths.iter().any(|p| p == path)
    }

    /// `true` when a drag gesture is in progress.
    ///
    /// Apps can use this to dim unrelated UI or change cursors,
    /// but the widget's own rendering already reflects drag state
    /// via the drop-target highlight.
    pub fn is_dragging(&self) -> bool {
        self.drag.is_some()
    }

    /// Read-only view of the currently-hovered drop target, iff
    /// a drag is in progress and the cursor is over a valid folder.
    ///
    /// Returns `None` when there is no drag, or when the cursor is
    /// over an invalid target (a file, one of the sources, a
    /// descendant of a source, or empty space).
    pub fn drop_target(&self) -> Option<&std::path::Path> {
        self.drag.as_ref().and_then(|d| d.hover.as_deref())
    }

    /// Read-only view of the paths being dragged, iff a drag is in
    /// progress. Empty slice if there's no drag.
    pub fn drag_sources(&self) -> &[std::path::PathBuf] {
        self.drag.as_ref().map_or(&[], |d| d.sources.as_slice())
    }

    /// **v0.6:** set or update the incremental search query.
    ///
    /// Apps typically call this from their `TextInput`'s `on_input`
    /// callback. The widget narrows its visible rows to those
    /// whose **basename matches the query as a case-insensitive
    /// substring** — plus every ancestor of every match, so the
    /// user sees the tree context leading to their matches.
    ///
    /// ```ignore
    /// // In your update handler:
    /// Message::SearchChanged(q) => {
    ///     self.tree.set_search_query(q);
    ///     Task::none()
    /// }
    /// ```
    ///
    /// An **empty string clears the search** — equivalent to
    /// [`clear_search`](Self::clear_search). This is a deliberate
    /// simplification: having three states (none / empty-string /
    /// non-empty-string) tends to produce surprising UI where
    /// clearing the text box leaves the widget in a visually
    /// identical-but-semantically-distinct "searching for
    /// nothing" mode. With this contract there are only two
    /// states.
    ///
    /// Search operates on **already-loaded nodes only**. Matches
    /// inside unloaded folders don't appear until the folder
    /// loads (by user expansion or v0.5 prefetch). It does descend
    /// into loaded-but-collapsed folders, though — collapsed
    /// state doesn't hide content from search.
    ///
    /// Selection (including multi-selection) is **orthogonal** to
    /// search and is fully preserved: a selected row hidden by
    /// the query stays selected, and reappears when the query
    /// clears.
    ///
    /// See the crate-internal `search` module for the full contract
    /// (visible in the source tree at `src/directory_tree/search.rs`).
    pub fn set_search_query(&mut self, query: impl Into<String>) {
        let q: String = query.into();
        if q.is_empty() {
            self.search = None;
            return;
        }
        self.search = Some(search::SearchState::new(q));
        self.recompute_search_visibility();
    }

    /// Clear the active search query, if any. No-op if there is no
    /// active search.
    ///
    /// After this call [`is_searching`](Self::is_searching) returns
    /// `false`, [`search_query`](Self::search_query) returns
    /// `None`, and the widget returns to its normal view where
    /// rows are hidden only by `is_expanded` chain (plus the
    /// ordinary [`DirectoryFilter`]).
    ///
    /// [`DirectoryFilter`]: crate::DirectoryFilter
    pub fn clear_search(&mut self) {
        self.search = None;
    }

    /// The current search query as the application set it
    /// (preserving the app's original case), or `None` when search
    /// is inactive.
    pub fn search_query(&self) -> Option<&str> {
        self.search.as_ref().map(|s| s.query.as_str())
    }

    /// `true` iff a search query is currently active.
    ///
    /// Convenience wrapper around [`search_query`](Self::search_query);
    /// apps can use either depending on taste.
    pub fn is_searching(&self) -> bool {
        self.search.is_some()
    }

    /// Count of nodes that directly match the current search query.
    ///
    /// Returns `0` when no search is active. This is distinct from
    /// "visible rows" — the visible set also includes ancestor
    /// breadcrumbs leading down to matches, which are typically
    /// not what the user wants counted in their UI's "X results"
    /// display.
    pub fn search_match_count(&self) -> usize {
        self.search.as_ref().map_or(0, |s| s.match_count)
    }

    /// Recompute the cached set of visible-under-search paths.
    ///
    /// Walks every loaded node in the tree (ignoring `is_expanded`,
    /// since search should find matches even inside collapsed-but-
    /// loaded subtrees). Any node whose basename matches the
    /// current query is a "match" — its path is added to
    /// `visible_paths`, all its proper ancestors are added as
    /// breadcrumbs, and the `match_count` is incremented.
    ///
    /// Called automatically on [`set_search_query`](Self::set_search_query),
    /// [`set_filter`](Self::set_filter), and after every scan
    /// merge in `on_loaded`. Applications don't need to call it
    /// manually.
    pub(crate) fn recompute_search_visibility(&mut self) {
        let Some(state) = self.search.as_mut() else {
            return;
        };
        let mut visible: std::collections::HashSet<std::path::PathBuf> =
            std::collections::HashSet::new();
        let mut match_count: usize = 0;
        let _ = walk_for_search(
            &self.root,
            &state.query_lower,
            &mut visible,
            &mut match_count,
        );
        state.visible_paths = visible;
        state.match_count = match_count;
    }

    /// Search-aware version of [`TreeNode::visible_rows`](crate::directory_tree::node::TreeNode::visible_rows).
    ///
    /// When no search is active, this delegates directly to the
    /// node-level walker (which respects `is_expanded`).
    ///
    /// When a search is active, this walks the tree using the
    /// cached [`SearchState::visible_paths`](crate::directory_tree::search::SearchState)
    /// set instead of `is_expanded` — yielding only matches and
    /// their ancestors, and descending into collapsed subtrees when
    /// they contain matches. Indent depth is preserved so the view
    /// still renders nested rows correctly.
    pub(crate) fn visible_rows(&self) -> Vec<node::VisibleRow<'_>> {
        match &self.search {
            None => self.root.visible_rows(),
            Some(state) => {
                let mut out = Vec::new();
                collect_search_visible(&self.root, 0, &state.visible_paths, &mut out);
                out
            }
        }
    }
}

/// Search-mode equivalent of
/// [`node::collect_visible`](crate::directory_tree::node): walk the
/// tree, yielding rows for nodes in `visible` and descending into
/// them regardless of `is_expanded`.
fn collect_search_visible<'a>(
    node: &'a TreeNode,
    depth: u32,
    visible: &std::collections::HashSet<std::path::PathBuf>,
    out: &mut Vec<node::VisibleRow<'a>>,
) {
    if !visible.contains(&node.path) {
        return;
    }
    out.push(node::VisibleRow { node, depth });
    // Always descend when search is active — ancestors-of-matches
    // force children to render even if `is_expanded == false`.
    // Non-matching siblings are filtered out by the visible check
    // at the top of this function.
    for child in &node.children {
        collect_search_visible(child, depth + 1, visible, out);
    }
}

/// Walk `node` and every loaded descendant, collecting matches and
/// their ancestors into `visible`.
///
/// Returns `true` iff the subtree rooted at `node` contains at
/// least one match (including `node` itself). The caller uses
/// that signal to decide whether to add `node`'s own path as an
/// ancestor-breadcrumb — which is the only reason we'd want `node`
/// visible if it isn't itself a match.
///
/// Crucially, this walks **regardless of `is_expanded`**: search
/// sees through collapse. Folders that have been loaded once but
/// are currently collapsed still contribute their matches.
fn walk_for_search(
    node: &TreeNode,
    query_lower: &str,
    visible: &mut std::collections::HashSet<std::path::PathBuf>,
    match_count: &mut usize,
) -> bool {
    let mut subtree_has_match = false;
    for child in &node.children {
        if walk_for_search(child, query_lower, visible, match_count) {
            subtree_has_match = true;
        }
    }
    let self_matches = search::matches_query(&node.path, query_lower);
    if self_matches {
        *match_count += 1;
    }
    if self_matches || subtree_has_match {
        visible.insert(node.path.clone());
        true
    } else {
        false
    }
}

impl DirectoryTree {
    /// Re-apply [`DirectoryTree::selected_paths`] to the per-node
    /// `is_selected` flags used by the view.
    ///
    /// Called after any operation that may have replaced nodes
    /// (e.g. `set_filter`, a fresh `Loaded` payload arriving for a
    /// directory that contains selected children). Clearing every
    /// flag and then re-setting only those in `selected_paths`
    /// keeps the view in lockstep with the authoritative set.
    pub(crate) fn sync_selection_flags(&mut self) {
        self.root.clear_selection();
        // Clone the paths out to avoid a borrow clash; the set is
        // typically small (selected paths, not total nodes).
        let paths: Vec<std::path::PathBuf> = self.selected_paths.clone();
        for p in &paths {
            if let Some(node) = self.root.find_mut(p) {
                node.is_selected = true;
            }
        }
    }
}

/// Re-derive the `children` list at every already-loaded directory
/// in the tree from the unfiltered cache, applying `filter`.
///
/// Used by [`DirectoryTree::set_filter`] so a filter change is
/// instant. Unloaded directories are skipped — their filter will be
/// applied on first load, which is already correct without any help
/// from here.
///
/// Expansion state is preserved: before replacing a directory's
/// children we snapshot the `(path → is_expanded, is_loaded)` map of
/// the *old* children, then apply it to the *new* children built from
/// the raw cache. A directory the user had opened stays open, and a
/// grandchild already loaded stays loaded. Selection is re-applied
/// separately in [`DirectoryTree::set_filter`] via
/// [`DirectoryTree::sync_selection_flag`] because the selection
/// cursor lives on the widget, not on nodes.
fn rebuild_from_cache(node: &mut TreeNode, cache: &node::TreeCache, filter: DirectoryFilter) {
    if node.is_dir && node.is_loaded {
        if let Some(cached) = cache.get(&node.path) {
            // Snapshot old children by path so we can carry their
            // `is_expanded`, `is_loaded`, and transitive `children`
            // subtrees over. Without this, an ancestor's filter
            // change would wipe every descendant's loaded state —
            // even though none of the descendants' filesystem
            // listings actually changed.
            let mut previous: std::collections::HashMap<PathBuf, TreeNode> = node
                .children
                .drain(..)
                .map(|c| (c.path.clone(), c))
                .collect();
            node.children = cached
                .raw
                .iter()
                .filter(|e| e.passes(filter))
                .map(|e| {
                    // If this child already existed in the old tree,
                    // move it over wholesale — that preserves every
                    // flag and every deeper subtree in one step.
                    // Otherwise it's genuinely a new appearance (e.g.
                    // hidden → visible after flipping to
                    // AllIncludingHidden), so we build a fresh node.
                    previous
                        .remove(&e.path)
                        .unwrap_or_else(|| TreeNode::from_entry(e))
                })
                .collect();
        } else {
            // `is_loaded` without a cache line can happen for the
            // error branch (we mark loaded even on failure). Leave
            // the existing `children` slice — the error state is
            // what matters for those.
        }
    }
    for child in &mut node.children {
        rebuild_from_cache(child, cache, filter);
    }
}