Skip to main content

iced_swdir_tree/
directory_tree.rs

1//! The [`DirectoryTree`] state type — holds the tree's nodes, cache, and
2//! configuration, and is the owning handle the parent application keeps
3//! across frames.
4//!
5//! The `update` and `view` methods live in their own submodules
6//! ([`update`] and [`view`]) so this file stays focused on construction
7//! and configuration.
8
9pub(crate) mod config;
10pub(crate) mod drag;
11pub(crate) mod error;
12pub(crate) mod executor;
13pub(crate) mod icon;
14pub(crate) mod keyboard;
15pub(crate) mod message;
16pub(crate) mod node;
17pub(crate) mod search;
18pub(crate) mod selection;
19pub(crate) mod update;
20pub(crate) mod view;
21pub(crate) mod walker;
22
23use std::path::PathBuf;
24use std::sync::Arc;
25
26use self::{
27    config::{DirectoryFilter, TreeConfig},
28    executor::{ScanExecutor, ThreadExecutor},
29    node::{TreeCache, TreeNode},
30};
31
32/// A directory tree widget state.
33///
34/// Hold one `DirectoryTree` per visible tree in your application state.
35/// The widget is cheap to construct: [`DirectoryTree::new`] creates only
36/// the root node — child folders are scanned lazily when the user
37/// expands them.
38///
39/// ## Lifecycle
40///
41/// 1. [`DirectoryTree::new`] — build with a root path.
42/// 2. Optionally chain [`DirectoryTree::with_filter`] and/or
43///    [`DirectoryTree::with_max_depth`] to configure.
44/// 3. Call [`DirectoryTree::view`] from your `view` function.
45/// 4. Route emitted [`DirectoryTreeEvent`]s through your app's message
46///    system and pass them to [`DirectoryTree::update`], which returns an
47///    [`iced::Task`] the parent should `.map(..)` back into its own
48///    message type.
49///
50/// [`DirectoryTreeEvent`]: crate::DirectoryTreeEvent
51pub struct DirectoryTree {
52    /// The root of the tree. Always present even if traversal fails —
53    /// failure just surfaces as [`TreeNode::error`] being set on the root.
54    pub(crate) root: TreeNode,
55    /// Configuration applied uniformly while traversing.
56    pub(crate) config: TreeConfig,
57    /// Path → already-loaded children cache to avoid re-scanning on
58    /// repeated collapse/expand.
59    pub(crate) cache: TreeCache,
60    /// Monotonically increasing counter used to invalidate stale async
61    /// results when the same folder is expanded, collapsed, expanded
62    /// again (or when the tree is dropped / replaced).
63    pub(crate) generation: u64,
64    /// The set of currently-selected paths.
65    ///
66    /// v0.3 replaces v0.2's single `selected_path: Option<PathBuf>`
67    /// with a Vec for multi-select. The order here is **not**
68    /// semantically meaningful — treat it as a set. If you need the
69    /// "most recently touched" path (for e.g. a detail pane), use
70    /// [`DirectoryTree::active_path`]; if you need the pivot for
71    /// range extension, use [`DirectoryTree::anchor_path`].
72    ///
73    /// `TreeNode::is_selected` is a view-layer cache kept in sync
74    /// with this set by [`DirectoryTree::sync_selection_flags`].
75    pub(crate) selected_paths: Vec<std::path::PathBuf>,
76    /// The path the user most recently acted on (click, Space, etc.).
77    ///
78    /// This is also what [`DirectoryTree::selected_path`] returns,
79    /// which preserves v0.2's single-select API semantics for apps
80    /// that never used multi-select.
81    pub(crate) active_path: Option<std::path::PathBuf>,
82    /// The pivot for Shift+click range extension.
83    ///
84    /// Set by [`SelectionMode::Replace`](crate::SelectionMode) and
85    /// [`SelectionMode::Toggle`](crate::SelectionMode);
86    /// deliberately **not** updated by
87    /// [`SelectionMode::ExtendRange`](crate::SelectionMode) so
88    /// successive Shift+clicks all extend from the same origin —
89    /// matching Windows Explorer / Finder / VS Code behaviour.
90    pub(crate) anchor_path: Option<std::path::PathBuf>,
91    /// In-progress drag state, if the user currently has the mouse
92    /// button held after pressing on a row. `None` otherwise.
93    ///
94    /// See [`drag`](crate::directory_tree::drag) for the state
95    /// machine that governs this field. The widget itself performs
96    /// no filesystem operations; it just tracks the drag and emits
97    /// [`DragCompleted`](crate::DirectoryTreeEvent::DragCompleted)
98    /// on successful drop.
99    pub(crate) drag: Option<drag::DragState>,
100    /// **v0.5:** paths for which a prefetch-triggered scan is
101    /// currently in flight.
102    ///
103    /// Populated by the [`update`](crate::directory_tree::update)
104    /// dispatcher when it issues prefetch scans following a user
105    /// expansion; drained by `on_loaded` when each scan result
106    /// arrives. The presence of a path in this set is how the
107    /// widget tells "this scan result came from prefetch — don't
108    /// re-prefetch its children" apart from "this scan result came
109    /// from a user-initiated expand — do prefetch its children".
110    /// Prevents the exponential-cascade trap.
111    ///
112    /// When the user explicitly expands a path that's currently in
113    /// this set (rare but possible: they click faster than the
114    /// prefetch scan completes), `on_toggled` removes the path so
115    /// the eventual user-initiated result triggers its own prefetch
116    /// wave normally.
117    pub(crate) prefetching_paths: std::collections::HashSet<std::path::PathBuf>,
118    /// **v0.6:** incremental-search state.
119    ///
120    /// `None` when search is inactive (the default). When the app
121    /// calls [`DirectoryTree::set_search_query`] with a non-empty
122    /// query, this is populated with the query plus a cached set
123    /// of visible-under-search paths.
124    ///
125    /// The rest of the widget — rendering, keyboard nav — consults
126    /// this state automatically through [`TreeNode::visible_rows`].
127    /// See the [`search`] module docs for the full contract.
128    ///
129    /// [`DirectoryTree::set_search_query`]: Self::set_search_query
130    pub(crate) search: Option<search::SearchState>,
131    /// **v0.7:** the icon theme used by the view.
132    ///
133    /// Defaults to the crate's stock theme for the enabled feature
134    /// set ([`icon::LucideTheme`] when `icons` is on,
135    /// [`icon::UnicodeTheme`] when off). Applications can install
136    /// their own by implementing [`icon::IconTheme`] and calling
137    /// [`DirectoryTree::with_icon_theme`].
138    ///
139    /// Stored as `Arc<dyn IconTheme>` (matching the existing
140    /// `Arc<dyn ScanExecutor>` pattern) so `DirectoryTree` stays
141    /// trivially cloneable if callers ever need that, and so the
142    /// view layer can borrow the theme via `&dyn` without cloning.
143    ///
144    /// [`DirectoryTree::with_icon_theme`]: Self::with_icon_theme
145    pub(crate) icon_theme: Arc<dyn icon::IconTheme>,
146    /// Pluggable executor that runs blocking `scan_dir` calls.
147    ///
148    /// Defaults to [`ThreadExecutor`] (one `std::thread::spawn` per
149    /// expansion), which is correct but slightly wasteful for apps
150    /// that already run their own blocking-task pool. Swap it via
151    /// [`DirectoryTree::with_executor`].
152    pub(crate) executor: Arc<dyn ScanExecutor>,
153}
154
155impl DirectoryTree {
156    /// Create a new tree rooted at `root`.
157    ///
158    /// Only the root node is created eagerly; the first level of
159    /// children is scanned when the user first expands the root (or,
160    /// for convenience, when you call [`DirectoryTree::update`] with a
161    /// `Toggled(root)` event yourself).
162    ///
163    /// Defaults: [`DirectoryFilter::FilesAndFolders`], no depth limit.
164    pub fn new(root: PathBuf) -> Self {
165        let root_node = TreeNode::new_root(root.clone());
166        Self {
167            root: root_node,
168            config: TreeConfig {
169                root_path: root,
170                filter: DirectoryFilter::default(),
171                max_depth: None,
172                prefetch_per_parent: 0,
173                prefetch_skip: config::DEFAULT_PREFETCH_SKIP
174                    .iter()
175                    .map(|&s| s.to_string())
176                    .collect(),
177            },
178            cache: TreeCache::default(),
179            generation: 0,
180            selected_paths: Vec::new(),
181            active_path: None,
182            anchor_path: None,
183            drag: None,
184            prefetching_paths: std::collections::HashSet::new(),
185            search: None,
186            icon_theme: icon::default_theme(),
187            executor: Arc::new(ThreadExecutor),
188        }
189    }
190
191    /// Set the display filter.
192    ///
193    /// This is the builder form used at construction. For runtime
194    /// filter changes call [`DirectoryTree::set_filter`] — or use this
195    /// method with `std::mem::replace` / `std::mem::take`-style moves
196    /// if that fits the shape of your state better. Either route
197    /// re-derives visible children from the cache, so the tree
198    /// updates instantly without re-scanning the filesystem.
199    pub fn with_filter(mut self, filter: DirectoryFilter) -> Self {
200        self.set_filter(filter);
201        self
202    }
203
204    /// Limit how deep the widget will load. `depth == 0` means only the
205    /// root's direct children are ever loaded; `depth == 1` allows one
206    /// more level of descent; and so on. No limit by default.
207    pub fn with_max_depth(mut self, depth: u32) -> Self {
208        self.config.max_depth = Some(depth);
209        self
210    }
211
212    /// **v0.5:** configure parallel pre-expansion of visible descendants.
213    ///
214    /// When a user-initiated expansion finishes loading a folder,
215    /// the widget will eagerly issue background scans for up to
216    /// `limit` of that folder's direct children-that-are-folders, in
217    /// parallel via the widget's [`ScanExecutor`]. Those children's
218    /// data is loaded into the in-memory cache (`is_loaded = true`)
219    /// but they are **not** automatically expanded in the UI — the
220    /// user still controls what's drawn. When they click to expand
221    /// a prefetched child, no I/O happens: the expansion is instant.
222    ///
223    /// Passing `0` (the default) disables prefetch and restores
224    /// v0.1–0.4 behaviour exactly. Typical app values: `5`–`25`,
225    /// sized to the number of folder-children a user plausibly
226    /// targets with their next click. A very high value effectively
227    /// means "prefetch every child folder" — the crate doesn't cap
228    /// it, because apps with fast executors (tokio / rayon / smol)
229    /// can legitimately want that.
230    ///
231    /// ```ignore
232    /// let tree = DirectoryTree::new(root)
233    ///     .with_executor(my_tokio_executor)   // fast pool
234    ///     .with_prefetch_limit(20);           // up to 20 parallel scans
235    /// ```
236    ///
237    /// Prefetch is **one level deep only**: a folder that loaded via
238    /// prefetch does not itself trigger further prefetches. This
239    /// avoids the exponential `limit ^ depth` cascade that would
240    /// otherwise paper-over I/O costs the user didn't ask for.
241    ///
242    /// Prefetch respects [`with_max_depth`](Self::with_max_depth)
243    /// the same way user-initiated scans do — a prefetch target
244    /// past the cap is skipped, not scanned.
245    ///
246    /// See [`TreeConfig::prefetch_per_parent`] for the full contract.
247    pub fn with_prefetch_limit(mut self, limit: usize) -> Self {
248        self.config.prefetch_per_parent = limit;
249        self
250    }
251
252    /// **v0.6.1:** replace the prefetch skip list.
253    ///
254    /// The list holds basenames that [`with_prefetch_limit`](Self::with_prefetch_limit)-
255    /// driven scans will refuse to enter. Match is **exact-basename,
256    /// ASCII case-insensitive** — `"target"` skips `target/` and
257    /// `Target/` but not `my-target-files/`. The list applies
258    /// **only** to automatic prefetch; a user click on a skipped
259    /// folder still expands it normally.
260    ///
261    /// Replacing the list drops the default entries (see
262    /// [`DEFAULT_PREFETCH_SKIP`]). To add entries while keeping
263    /// the defaults, read them and append:
264    ///
265    /// ```ignore
266    /// use iced_swdir_tree::{DirectoryTree, DEFAULT_PREFETCH_SKIP};
267    ///
268    /// let mut skip: Vec<String> = DEFAULT_PREFETCH_SKIP
269    ///     .iter()
270    ///     .map(|&s| s.to_string())
271    ///     .collect();
272    /// skip.push("huge_media_library".into());
273    ///
274    /// let tree = DirectoryTree::new(root)
275    ///     .with_prefetch_limit(10)
276    ///     .with_prefetch_skip(skip);
277    /// ```
278    ///
279    /// To disable skipping entirely (dangerous — means `.git/` and
280    /// `node_modules/` *will* be prefetched), pass an empty list:
281    ///
282    /// ```ignore
283    /// let tree = DirectoryTree::new(root)
284    ///     .with_prefetch_limit(10)
285    ///     .with_prefetch_skip(Vec::<String>::new());
286    /// ```
287    ///
288    /// See [`DEFAULT_PREFETCH_SKIP`] for the set populated by
289    /// default.
290    ///
291    /// [`DEFAULT_PREFETCH_SKIP`]: crate::DEFAULT_PREFETCH_SKIP
292    pub fn with_prefetch_skip<I, S>(mut self, names: I) -> Self
293    where
294        I: IntoIterator<Item = S>,
295        S: Into<String>,
296    {
297        self.config.prefetch_skip = names.into_iter().map(Into::into).collect();
298        self
299    }
300
301    /// Route blocking `scan_dir` calls through a custom executor.
302    ///
303    /// By default the widget spawns a fresh `std::thread` per
304    /// expansion via [`ThreadExecutor`]. Apps that already manage
305    /// a blocking-task pool (tokio, smol, rayon, ...) can implement
306    /// [`ScanExecutor`] and swap it in here:
307    ///
308    /// ```ignore
309    /// use std::sync::Arc;
310    /// let tree = DirectoryTree::new(root).with_executor(Arc::new(MyTokioExecutor));
311    /// ```
312    ///
313    /// Calling this mid-session is allowed (the next scan will use
314    /// the new executor); in-flight scans initiated under the old
315    /// executor still complete through it.
316    ///
317    /// [`ScanExecutor`]: crate::ScanExecutor
318    /// [`ThreadExecutor`]: crate::ThreadExecutor
319    pub fn with_executor(mut self, executor: Arc<dyn ScanExecutor>) -> Self {
320        self.executor = executor;
321        self
322    }
323
324    /// **v0.7:** replace the icon theme.
325    ///
326    /// Install an [`IconTheme`](crate::IconTheme) implementation to
327    /// control which glyph, font, and size the view uses for each
328    /// [`IconRole`](crate::IconRole) (folder-closed / folder-open /
329    /// file / caret-right / caret-down / error).
330    ///
331    /// The crate ships two stock themes:
332    ///
333    /// * [`UnicodeTheme`](crate::UnicodeTheme) — always available,
334    ///   renders short Unicode symbols (📁 📂 📄 ⚠ ▸ ▾). Default
335    ///   when the `icons` feature is disabled.
336    /// * [`LucideTheme`](crate::LucideTheme) — available with the
337    ///   `icons` feature, renders real lucide vector glyphs.
338    ///   Default when `icons` is enabled.
339    ///
340    /// You don't need to call this if you're happy with the stock
341    /// default — `DirectoryTree::new` picks the right one for your
342    /// feature configuration automatically.
343    ///
344    /// Custom themes implement the [`IconTheme`](crate::IconTheme)
345    /// trait. A minimal example:
346    ///
347    /// ```
348    /// use std::sync::Arc;
349    /// use iced_swdir_tree::{
350    ///     DirectoryTree, IconRole, IconSpec, IconTheme,
351    /// };
352    ///
353    /// #[derive(Debug)]
354    /// struct LabelTheme;
355    ///
356    /// impl IconTheme for LabelTheme {
357    ///     fn glyph(&self, role: IconRole) -> IconSpec {
358    ///         let label: &'static str = match role {
359    ///             IconRole::FolderClosed => "[D]",
360    ///             IconRole::FolderOpen => "[O]",
361    ///             IconRole::File => "[F]",
362    ///             IconRole::Error => "[!]",
363    ///             IconRole::CaretRight => ">",
364    ///             IconRole::CaretDown => "v",
365    ///             _ => "?",
366    ///         };
367    ///         IconSpec::new(label)
368    ///     }
369    /// }
370    ///
371    /// let tree = DirectoryTree::new(".".into())
372    ///     .with_icon_theme(Arc::new(LabelTheme));
373    /// ```
374    ///
375    /// Note the `_ =>` fallback: [`IconRole`](crate::IconRole) is
376    /// `#[non_exhaustive]` so new variants may be added in future
377    /// minor releases.
378    pub fn with_icon_theme(mut self, theme: Arc<dyn icon::IconTheme>) -> Self {
379        self.icon_theme = theme;
380        self
381    }
382
383    /// Change the display filter at runtime. The tree re-derives its
384    /// visible children from the unfiltered cache, so the change is
385    /// instant — no re-scan, no blocking the UI.
386    ///
387    /// **Selection is preserved.** Selection is kept by path on the
388    /// widget, not on the [`TreeNode`]s that this call rebuilds, so
389    /// every selected path survives the filter swap. Paths that
390    /// become invisible under the new filter are not lost — flipping
391    /// the filter back re-reveals them unchanged. This is true for
392    /// both single and multi-select.
393    ///
394    /// **Expansion state is preserved too.** `rebuild_from_cache`
395    /// copies the whole child subtree from the old node into its
396    /// freshly-built replacement, so directories the user had opened
397    /// stay open.
398    pub fn set_filter(&mut self, filter: DirectoryFilter) {
399        if self.config.filter == filter {
400            return;
401        }
402        self.config.filter = filter;
403        rebuild_from_cache(&mut self.root, &self.cache, filter);
404        // Re-apply selection onto the new node graph. The `selected_paths`
405        // Vec is authoritative; the per-node `is_selected` caches
406        // need re-syncing after any mutation that drops and recreates
407        // nodes.
408        self.sync_selection_flags();
409        // v0.6: if a search query is active, re-run it against the
410        // post-filter node graph. A node that was a match may have
411        // been filtered out (e.g. switching to FoldersOnly while
412        // searching "readme.md"), or a newly-visible node may now
413        // match.
414        self.recompute_search_visibility();
415    }
416
417    /// Return the root path.
418    pub fn root_path(&self) -> &std::path::Path {
419        &self.config.root_path
420    }
421
422    /// Return the current filter.
423    pub fn filter(&self) -> DirectoryFilter {
424        self.config.filter
425    }
426
427    /// Return the current max depth, if any.
428    pub fn max_depth(&self) -> Option<u32> {
429        self.config.max_depth
430    }
431
432    /// Return a reference to the currently-active selected path, if any.
433    ///
434    /// The active path is the path the user most recently acted on —
435    /// the last row clicked, the last Space-toggled, the last target
436    /// of a Shift-range, etc. For single-select applications this is
437    /// exactly the one selected path and matches v0.2 semantics.
438    ///
439    /// For multi-select, use [`DirectoryTree::selected_paths`] to see
440    /// the whole set and [`DirectoryTree::anchor_path`] to read the
441    /// pivot for range extension.
442    ///
443    /// The returned path may point to a node that is currently
444    /// invisible (because an ancestor is collapsed, or because the
445    /// active filter hides it); the view layer handles that
446    /// gracefully.
447    pub fn selected_path(&self) -> Option<&std::path::Path> {
448        self.active_path.as_deref()
449    }
450
451    /// All currently-selected paths.
452    ///
453    /// Order is not semantically meaningful — treat the slice as a
454    /// set. The slice is empty iff nothing is selected. Runs in
455    /// O(1) (returns a reference to the internal Vec).
456    pub fn selected_paths(&self) -> &[std::path::PathBuf] {
457        &self.selected_paths
458    }
459
460    /// The anchor used as the pivot for
461    /// [`SelectionMode::ExtendRange`](crate::SelectionMode).
462    ///
463    /// The anchor is set by `Replace` and `Toggle` selections, and
464    /// is *not* moved by `ExtendRange` — so two successive
465    /// `Shift+click`s from the same starting point select different
466    /// ranges with the same origin.
467    ///
468    /// Returns `None` before the first selection.
469    pub fn anchor_path(&self) -> Option<&std::path::Path> {
470        self.anchor_path.as_deref()
471    }
472
473    /// `true` if `path` is in the selected set. O(n) in the set size.
474    pub fn is_selected(&self, path: &std::path::Path) -> bool {
475        self.selected_paths.iter().any(|p| p == path)
476    }
477
478    /// `true` when a drag gesture is in progress.
479    ///
480    /// Apps can use this to dim unrelated UI or change cursors,
481    /// but the widget's own rendering already reflects drag state
482    /// via the drop-target highlight.
483    pub fn is_dragging(&self) -> bool {
484        self.drag.is_some()
485    }
486
487    /// Read-only view of the currently-hovered drop target, iff
488    /// a drag is in progress and the cursor is over a valid folder.
489    ///
490    /// Returns `None` when there is no drag, or when the cursor is
491    /// over an invalid target (a file, one of the sources, a
492    /// descendant of a source, or empty space).
493    pub fn drop_target(&self) -> Option<&std::path::Path> {
494        self.drag.as_ref().and_then(|d| d.hover.as_deref())
495    }
496
497    /// Read-only view of the paths being dragged, iff a drag is in
498    /// progress. Empty slice if there's no drag.
499    pub fn drag_sources(&self) -> &[std::path::PathBuf] {
500        self.drag.as_ref().map_or(&[], |d| d.sources.as_slice())
501    }
502
503    /// **v0.6:** set or update the incremental search query.
504    ///
505    /// Apps typically call this from their `TextInput`'s `on_input`
506    /// callback. The widget narrows its visible rows to those
507    /// whose **basename matches the query as a case-insensitive
508    /// substring** — plus every ancestor of every match, so the
509    /// user sees the tree context leading to their matches.
510    ///
511    /// ```ignore
512    /// // In your update handler:
513    /// Message::SearchChanged(q) => {
514    ///     self.tree.set_search_query(q);
515    ///     Task::none()
516    /// }
517    /// ```
518    ///
519    /// An **empty string clears the search** — equivalent to
520    /// [`clear_search`](Self::clear_search). This is a deliberate
521    /// simplification: having three states (none / empty-string /
522    /// non-empty-string) tends to produce surprising UI where
523    /// clearing the text box leaves the widget in a visually
524    /// identical-but-semantically-distinct "searching for
525    /// nothing" mode. With this contract there are only two
526    /// states.
527    ///
528    /// Search operates on **already-loaded nodes only**. Matches
529    /// inside unloaded folders don't appear until the folder
530    /// loads (by user expansion or v0.5 prefetch). It does descend
531    /// into loaded-but-collapsed folders, though — collapsed
532    /// state doesn't hide content from search.
533    ///
534    /// Selection (including multi-selection) is **orthogonal** to
535    /// search and is fully preserved: a selected row hidden by
536    /// the query stays selected, and reappears when the query
537    /// clears.
538    ///
539    /// See the crate-internal `search` module for the full contract
540    /// (visible in the source tree at `src/directory_tree/search.rs`).
541    pub fn set_search_query(&mut self, query: impl Into<String>) {
542        let q: String = query.into();
543        if q.is_empty() {
544            self.search = None;
545            return;
546        }
547        self.search = Some(search::SearchState::new(q));
548        self.recompute_search_visibility();
549    }
550
551    /// Clear the active search query, if any. No-op if there is no
552    /// active search.
553    ///
554    /// After this call [`is_searching`](Self::is_searching) returns
555    /// `false`, [`search_query`](Self::search_query) returns
556    /// `None`, and the widget returns to its normal view where
557    /// rows are hidden only by `is_expanded` chain (plus the
558    /// ordinary [`DirectoryFilter`]).
559    ///
560    /// [`DirectoryFilter`]: crate::DirectoryFilter
561    pub fn clear_search(&mut self) {
562        self.search = None;
563    }
564
565    /// The current search query as the application set it
566    /// (preserving the app's original case), or `None` when search
567    /// is inactive.
568    pub fn search_query(&self) -> Option<&str> {
569        self.search.as_ref().map(|s| s.query.as_str())
570    }
571
572    /// `true` iff a search query is currently active.
573    ///
574    /// Convenience wrapper around [`search_query`](Self::search_query);
575    /// apps can use either depending on taste.
576    pub fn is_searching(&self) -> bool {
577        self.search.is_some()
578    }
579
580    /// Count of nodes that directly match the current search query.
581    ///
582    /// Returns `0` when no search is active. This is distinct from
583    /// "visible rows" — the visible set also includes ancestor
584    /// breadcrumbs leading down to matches, which are typically
585    /// not what the user wants counted in their UI's "X results"
586    /// display.
587    pub fn search_match_count(&self) -> usize {
588        self.search.as_ref().map_or(0, |s| s.match_count)
589    }
590
591    /// Recompute the cached set of visible-under-search paths.
592    ///
593    /// Walks every loaded node in the tree (ignoring `is_expanded`,
594    /// since search should find matches even inside collapsed-but-
595    /// loaded subtrees). Any node whose basename matches the
596    /// current query is a "match" — its path is added to
597    /// `visible_paths`, all its proper ancestors are added as
598    /// breadcrumbs, and the `match_count` is incremented.
599    ///
600    /// Called automatically on [`set_search_query`](Self::set_search_query),
601    /// [`set_filter`](Self::set_filter), and after every scan
602    /// merge in `on_loaded`. Applications don't need to call it
603    /// manually.
604    pub(crate) fn recompute_search_visibility(&mut self) {
605        let Some(state) = self.search.as_mut() else {
606            return;
607        };
608        let mut visible: std::collections::HashSet<std::path::PathBuf> =
609            std::collections::HashSet::new();
610        let mut match_count: usize = 0;
611        let _ = walk_for_search(
612            &self.root,
613            &state.query_lower,
614            &mut visible,
615            &mut match_count,
616        );
617        state.visible_paths = visible;
618        state.match_count = match_count;
619    }
620
621    /// Search-aware version of [`TreeNode::visible_rows`](crate::directory_tree::node::TreeNode::visible_rows).
622    ///
623    /// When no search is active, this delegates directly to the
624    /// node-level walker (which respects `is_expanded`).
625    ///
626    /// When a search is active, this walks the tree using the
627    /// cached [`SearchState::visible_paths`](crate::directory_tree::search::SearchState)
628    /// set instead of `is_expanded` — yielding only matches and
629    /// their ancestors, and descending into collapsed subtrees when
630    /// they contain matches. Indent depth is preserved so the view
631    /// still renders nested rows correctly.
632    pub(crate) fn visible_rows(&self) -> Vec<node::VisibleRow<'_>> {
633        match &self.search {
634            None => self.root.visible_rows(),
635            Some(state) => {
636                let mut out = Vec::new();
637                collect_search_visible(&self.root, 0, &state.visible_paths, &mut out);
638                out
639            }
640        }
641    }
642}
643
644/// Search-mode equivalent of
645/// [`node::collect_visible`](crate::directory_tree::node): walk the
646/// tree, yielding rows for nodes in `visible` and descending into
647/// them regardless of `is_expanded`.
648fn collect_search_visible<'a>(
649    node: &'a TreeNode,
650    depth: u32,
651    visible: &std::collections::HashSet<std::path::PathBuf>,
652    out: &mut Vec<node::VisibleRow<'a>>,
653) {
654    if !visible.contains(&node.path) {
655        return;
656    }
657    out.push(node::VisibleRow { node, depth });
658    // Always descend when search is active — ancestors-of-matches
659    // force children to render even if `is_expanded == false`.
660    // Non-matching siblings are filtered out by the visible check
661    // at the top of this function.
662    for child in &node.children {
663        collect_search_visible(child, depth + 1, visible, out);
664    }
665}
666
667/// Walk `node` and every loaded descendant, collecting matches and
668/// their ancestors into `visible`.
669///
670/// Returns `true` iff the subtree rooted at `node` contains at
671/// least one match (including `node` itself). The caller uses
672/// that signal to decide whether to add `node`'s own path as an
673/// ancestor-breadcrumb — which is the only reason we'd want `node`
674/// visible if it isn't itself a match.
675///
676/// Crucially, this walks **regardless of `is_expanded`**: search
677/// sees through collapse. Folders that have been loaded once but
678/// are currently collapsed still contribute their matches.
679fn walk_for_search(
680    node: &TreeNode,
681    query_lower: &str,
682    visible: &mut std::collections::HashSet<std::path::PathBuf>,
683    match_count: &mut usize,
684) -> bool {
685    let mut subtree_has_match = false;
686    for child in &node.children {
687        if walk_for_search(child, query_lower, visible, match_count) {
688            subtree_has_match = true;
689        }
690    }
691    let self_matches = search::matches_query(&node.path, query_lower);
692    if self_matches {
693        *match_count += 1;
694    }
695    if self_matches || subtree_has_match {
696        visible.insert(node.path.clone());
697        true
698    } else {
699        false
700    }
701}
702
703impl DirectoryTree {
704    /// Re-apply [`DirectoryTree::selected_paths`] to the per-node
705    /// `is_selected` flags used by the view.
706    ///
707    /// Called after any operation that may have replaced nodes
708    /// (e.g. `set_filter`, a fresh `Loaded` payload arriving for a
709    /// directory that contains selected children). Clearing every
710    /// flag and then re-setting only those in `selected_paths`
711    /// keeps the view in lockstep with the authoritative set.
712    pub(crate) fn sync_selection_flags(&mut self) {
713        self.root.clear_selection();
714        // Clone the paths out to avoid a borrow clash; the set is
715        // typically small (selected paths, not total nodes).
716        let paths: Vec<std::path::PathBuf> = self.selected_paths.clone();
717        for p in &paths {
718            if let Some(node) = self.root.find_mut(p) {
719                node.is_selected = true;
720            }
721        }
722    }
723}
724
725/// Re-derive the `children` list at every already-loaded directory
726/// in the tree from the unfiltered cache, applying `filter`.
727///
728/// Used by [`DirectoryTree::set_filter`] so a filter change is
729/// instant. Unloaded directories are skipped — their filter will be
730/// applied on first load, which is already correct without any help
731/// from here.
732///
733/// Expansion state is preserved: before replacing a directory's
734/// children we snapshot the `(path → is_expanded, is_loaded)` map of
735/// the *old* children, then apply it to the *new* children built from
736/// the raw cache. A directory the user had opened stays open, and a
737/// grandchild already loaded stays loaded. Selection is re-applied
738/// separately in [`DirectoryTree::set_filter`] via
739/// [`DirectoryTree::sync_selection_flag`] because the selection
740/// cursor lives on the widget, not on nodes.
741fn rebuild_from_cache(node: &mut TreeNode, cache: &node::TreeCache, filter: DirectoryFilter) {
742    if node.is_dir && node.is_loaded {
743        if let Some(cached) = cache.get(&node.path) {
744            // Snapshot old children by path so we can carry their
745            // `is_expanded`, `is_loaded`, and transitive `children`
746            // subtrees over. Without this, an ancestor's filter
747            // change would wipe every descendant's loaded state —
748            // even though none of the descendants' filesystem
749            // listings actually changed.
750            let mut previous: std::collections::HashMap<PathBuf, TreeNode> = node
751                .children
752                .drain(..)
753                .map(|c| (c.path.clone(), c))
754                .collect();
755            node.children = cached
756                .raw
757                .iter()
758                .filter(|e| e.passes(filter))
759                .map(|e| {
760                    // If this child already existed in the old tree,
761                    // move it over wholesale — that preserves every
762                    // flag and every deeper subtree in one step.
763                    // Otherwise it's genuinely a new appearance (e.g.
764                    // hidden → visible after flipping to
765                    // AllIncludingHidden), so we build a fresh node.
766                    previous
767                        .remove(&e.path)
768                        .unwrap_or_else(|| TreeNode::from_entry(e))
769                })
770                .collect();
771        } else {
772            // `is_loaded` without a cache line can happen for the
773            // error branch (we mark loaded even on failure). Leave
774            // the existing `children` slice — the error state is
775            // what matters for those.
776        }
777    }
778    for child in &mut node.children {
779        rebuild_from_cache(child, cache, filter);
780    }
781}