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}