iced_swdir_tree/directory_tree/node.rs
1//! In-memory tree types: [`TreeNode`] and [`TreeCache`].
2
3use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5
6use crate::Error;
7
8/// A single node in the directory tree.
9///
10/// All fields are public so downstream code (tests, custom renderers
11/// built on top of the widget's state) can inspect them, but the widget
12/// itself drives mutation through [`DirectoryTree::update`].
13///
14/// [`DirectoryTree::update`]: crate::DirectoryTree::update
15#[derive(Debug, Clone)]
16pub struct TreeNode {
17 /// Full path of the entry.
18 pub path: PathBuf,
19 /// `true` if the entry is a directory.
20 pub is_dir: bool,
21 /// `true` if the directory is currently expanded in the UI. Always
22 /// `false` for files.
23 pub is_expanded: bool,
24 /// `true` if this directory has already been scanned at least once,
25 /// even if the scan returned zero children. Distinguishes
26 /// "scanned and empty" from "not scanned yet" in the view.
27 pub is_loaded: bool,
28 /// Direct children. Empty until `is_loaded` is `true`.
29 pub children: Vec<TreeNode>,
30 /// `true` if the user has this node selected.
31 pub is_selected: bool,
32 /// Populated when a scan of *this directory* failed. For files this
33 /// is always `None`. The view layer uses this to render a greyed-out
34 /// or error-tinted row.
35 pub error: Option<Error>,
36}
37
38impl TreeNode {
39 /// Build the root node of a freshly-constructed [`DirectoryTree`].
40 ///
41 /// The root is always treated as a directory (we can't meaningfully
42 /// root a tree at a regular file).
43 ///
44 /// [`DirectoryTree`]: crate::DirectoryTree
45 pub(crate) fn new_root(path: PathBuf) -> Self {
46 Self {
47 path,
48 is_dir: true,
49 is_expanded: false,
50 is_loaded: false,
51 children: Vec::new(),
52 is_selected: false,
53 error: None,
54 }
55 }
56
57 /// Build a child node from a loaded entry.
58 pub(crate) fn from_entry(entry: &LoadedEntry) -> Self {
59 Self {
60 path: entry.path.clone(),
61 is_dir: entry.is_dir,
62 is_expanded: false,
63 is_loaded: false,
64 children: Vec::new(),
65 is_selected: false,
66 error: None,
67 }
68 }
69
70 /// Find a descendant (including `self`) by path, returning a
71 /// mutable reference.
72 ///
73 /// Uses path-prefix pruning: we only descend into subtrees that
74 /// could contain `target`, so the worst case is O(depth) not
75 /// O(total nodes).
76 pub(crate) fn find_mut(&mut self, target: &Path) -> Option<&mut TreeNode> {
77 if self.path == target {
78 return Some(self);
79 }
80 // Only descend if target lives under `self.path`. Without this
81 // check we'd walk every sibling subtree on every lookup.
82 if !target.starts_with(&self.path) {
83 return None;
84 }
85 for child in &mut self.children {
86 if let Some(hit) = child.find_mut(target) {
87 return Some(hit);
88 }
89 }
90 None
91 }
92
93 /// Clear the selection flag on every node in this subtree.
94 ///
95 /// Selection is single-select; setting a new selection is a
96 /// clear-then-set operation.
97 pub(crate) fn clear_selection(&mut self) {
98 self.is_selected = false;
99 for child in &mut self.children {
100 child.clear_selection();
101 }
102 }
103
104 /// Count nodes in this subtree (including `self`). Exposed primarily
105 /// for tests and diagnostics.
106 #[allow(dead_code)]
107 pub(crate) fn node_count(&self) -> usize {
108 1 + self.children.iter().map(Self::node_count).sum::<usize>()
109 }
110
111 /// Flat list of rows the view would render, in render order.
112 ///
113 /// Every ancestor-collapsed subtree is skipped. The returned
114 /// order is the same one the user sees on screen, so it is the
115 /// right order for keyboard navigation and Shift+click range
116 /// extension to reason about. Cost is O(visible nodes).
117 pub(crate) fn visible_rows(&self) -> Vec<VisibleRow<'_>> {
118 let mut out = Vec::new();
119 collect_visible(self, 0, &mut out);
120 out
121 }
122}
123
124/// A single visible row: the node, plus its indentation depth.
125///
126/// Crate-internal — used by the keyboard handler and by the
127/// multi-select range-extension path. Depth is cached on the row
128/// so callers don't have to re-walk from the root.
129#[derive(Debug)]
130pub(crate) struct VisibleRow<'a> {
131 pub node: &'a TreeNode,
132 #[allow(dead_code)]
133 pub depth: u32,
134}
135
136fn collect_visible<'a>(node: &'a TreeNode, depth: u32, out: &mut Vec<VisibleRow<'a>>) {
137 out.push(VisibleRow { node, depth });
138 if node.is_dir && node.is_expanded && node.is_loaded {
139 for child in &node.children {
140 collect_visible(child, depth + 1, out);
141 }
142 }
143}
144
145/// Lightweight, owned entry record produced by [`crate::walker`] and
146/// consumed by [`super::update`] to build [`TreeNode`]s.
147///
148/// We don't use `swdir::DirEntry` here directly — keeping swdir types
149/// out of the message enum means swdir can be a private dependency
150/// from the public API's point of view.
151#[derive(Debug, Clone)]
152pub struct LoadedEntry {
153 /// Full path of the entry.
154 pub path: PathBuf,
155 /// `true` if the entry is a directory. Symlinks to directories
156 /// are treated as files here (the widget never auto-follows them,
157 /// to stay robust against cycles).
158 pub is_dir: bool,
159 /// `true` if the entry itself is a symlink (regardless of target).
160 ///
161 /// Currently only used for cycle-avoidance diagnostics; kept here
162 /// so v0.4+ can render a symlink indicator without having to
163 /// re-stat every entry.
164 #[allow(dead_code)]
165 pub is_symlink: bool,
166 /// `true` if the entry is hidden per OS conventions.
167 ///
168 /// Persisted on the entry (not just consulted in the scan path)
169 /// so that a later filter change — e.g. flipping from
170 /// `FilesAndFolders` to `AllIncludingHidden` — can be applied
171 /// from the cache without another disk scan.
172 pub is_hidden: bool,
173}
174
175impl LoadedEntry {
176 /// `true` if this entry should be visible under `filter`.
177 ///
178 /// The rules mirror [`DirectoryFilter`](crate::DirectoryFilter)'s
179 /// predicates but operate on the per-entry flags we already have
180 /// in hand, keeping the decision O(1) rather than touching the
181 /// filesystem again.
182 pub(crate) fn passes(&self, filter: crate::DirectoryFilter) -> bool {
183 if filter.skips_hidden() && self.is_hidden {
184 return false;
185 }
186 if filter.skips_files() && !self.is_dir {
187 return false;
188 }
189 true
190 }
191}
192
193/// A path → children cache so that collapsing and re-expanding a folder
194/// does not re-scan the filesystem.
195///
196/// The cache stores **unfiltered** children (raw normalised entries).
197/// When the filter changes at runtime, [`DirectoryTree::set_filter`]
198/// re-derives each already-loaded directory's visible child list from
199/// its cached raw entries — no filesystem I/O, no flicker.
200///
201/// [`DirectoryTree::set_filter`]: crate::DirectoryTree::set_filter
202/// (In practice the raw entry set is small — a single directory's
203/// listing — so the extra memory cost of keeping both raw and
204/// filtered forms is not justified at this scale.)
205#[derive(Debug, Default, Clone)]
206pub struct TreeCache {
207 entries: HashMap<PathBuf, CacheEntry>,
208}
209
210/// A single cache line: the raw (unfiltered but normalized) listing
211/// of a directory plus the generation number at which it was
212/// recorded. Read by
213/// [`DirectoryTree::set_filter`](crate::DirectoryTree::set_filter)
214/// to re-derive filtered children without another scan.
215#[derive(Debug, Clone)]
216pub(crate) struct CacheEntry {
217 /// Generation this cache line was recorded with. Stale lines are
218 /// skipped rather than deleted, to avoid churn on repeat expansions.
219 #[allow(dead_code)]
220 pub generation: u64,
221 /// The raw, unfiltered children of the directory.
222 pub raw: Vec<LoadedEntry>,
223}
224
225impl TreeCache {
226 /// Insert or replace the cached entries for `dir`.
227 pub(crate) fn put(&mut self, dir: PathBuf, generation: u64, raw: Vec<LoadedEntry>) {
228 self.entries.insert(dir, CacheEntry { generation, raw });
229 }
230
231 /// Retrieve the raw entries previously recorded for `dir`, if any.
232 pub(crate) fn get(&self, dir: &Path) -> Option<&CacheEntry> {
233 self.entries.get(dir)
234 }
235
236 /// Drop every cached entry. Used when the filter changes in a way
237 /// that could affect membership (hidden → not-hidden, etc.).
238 #[allow(dead_code)]
239 pub(crate) fn clear(&mut self) {
240 self.entries.clear();
241 }
242}
243
244#[cfg(test)]
245mod tests;