dioxus_swdir_tree_core/tree.rs
1//! The widget root: all state, all accessors, and the entry points the
2//! embedding layer calls. State transitions live in the private
3//! `tree::transitions` submodule.
4
5use std::collections::HashSet;
6use std::path::{Path, PathBuf};
7
8use crate::search::{self, SearchState};
9
10use crate::cache::TreeCache;
11use crate::config::{DisplayFilter, TreeConfig};
12use crate::drag::DragState;
13use crate::node::TreeNode;
14use crate::scan::{self, LoadedOutcome};
15use crate::selection;
16
17pub(crate) mod transitions;
18
19/// The directory-tree widget state.
20///
21/// Owns UI state only — which folders are open, what has loaded, the
22/// active filter, and the selection set. It never creates, deletes,
23/// renames, moves, or writes anything on disk; filesystem operations
24/// belong to the application.
25#[derive(Debug, Clone, PartialEq)]
26pub struct DirectoryTree {
27 pub(crate) root: TreeNode,
28 pub(crate) config: TreeConfig,
29 pub(crate) cache: TreeCache,
30 pub(crate) generation: u32,
31 /// Insertion-ordered, duplicate-free authoritative selection set.
32 pub(crate) selected_paths: Vec<PathBuf>,
33 /// Most recently touched path (focus / active styling).
34 pub(crate) active_path: Option<PathBuf>,
35 /// Shift-range pivot. Set by Replace and Toggle; *not* moved by
36 /// ExtendRange (S6.3).
37 pub(crate) anchor_path: Option<PathBuf>,
38 /// Active drag session, or `None` when no drag is in progress.
39 pub(crate) drag: Option<DragState>,
40 /// Paths for which a speculative prefetch scan is in flight.
41 pub(crate) prefetching_paths: HashSet<PathBuf>,
42 /// Active incremental search session, or `None` when search is inactive.
43 pub(crate) search: Option<crate::search::SearchState>,
44}
45
46impl DirectoryTree {
47 /// Mount a tree at `root_path`. The root node is created eagerly and
48 /// is never removed or replaced; loading it still requires the first
49 /// expansion gesture.
50 pub fn new(root_path: impl Into<PathBuf>) -> Self {
51 let config = TreeConfig::new(root_path);
52 let root = TreeNode::new_root(config.root_path.clone());
53 Self {
54 root,
55 config,
56 cache: TreeCache::default(),
57 generation: 0,
58 selected_paths: Vec::new(),
59 active_path: None,
60 anchor_path: None,
61 drag: None,
62 prefetching_paths: HashSet::new(),
63 search: None,
64 }
65 }
66
67 /// Builder: set the initial display filter.
68 pub fn with_filter(mut self, filter: DisplayFilter) -> Self {
69 self.config.filter = filter;
70 self
71 }
72
73 /// Builder: cap the load depth (components below the root; `0`
74 /// means only the root's direct children are ever loaded).
75 pub fn with_max_depth(mut self, max_depth: u32) -> Self {
76 self.config.max_depth = Some(max_depth);
77 self
78 }
79
80 /// The root node. Always present.
81 pub fn root(&self) -> &TreeNode {
82 &self.root
83 }
84
85 /// Current configuration.
86 pub fn config(&self) -> &TreeConfig {
87 &self.config
88 }
89
90 /// Current display filter.
91 pub fn filter(&self) -> DisplayFilter {
92 self.config.filter
93 }
94
95 /// Current generation counter (diagnostics and tests).
96 pub fn generation(&self) -> u32 {
97 self.generation
98 }
99
100 /// Raw scan results accepted so far.
101 pub fn cache(&self) -> &TreeCache {
102 &self.cache
103 }
104
105 /// Find the node for `path`, if it is currently in the tree.
106 pub fn find(&self, path: &Path) -> Option<&TreeNode> {
107 self.root.find(path)
108 }
109
110 /// Depth of `path` below the root in components; `None` if `path`
111 /// is not the root or under it. The root itself is depth `0`.
112 pub fn depth_of(&self, path: &Path) -> Option<u32> {
113 let rel = path.strip_prefix(&self.config.root_path).ok()?;
114 Some(rel.components().count() as u32)
115 }
116
117 /// Switch the display filter, re-deriving every loaded node's child
118 /// list from the cache. Instant; **issues no I/O** and does not bump
119 /// the generation. Expansion and loaded state survive (children are
120 /// path-matched against the previous node graph). Selection flags
121 /// are re-synced so that paths hidden by the new filter remain
122 /// selected but their nodes' `is_selected` reflects reality once
123 /// visible again (S2.6 / S6.4).
124 pub fn set_filter(&mut self, filter: DisplayFilter) {
125 if filter == self.config.filter {
126 return;
127 }
128 self.config.filter = filter;
129 transitions::refresh_from_cache(&mut self.root, &self.cache, filter);
130 selection::sync_flags(&mut self.root, &self.selected_paths);
131 recompute_search_if_active(self); // S9.6 — filter first, then search
132 }
133
134 /// The ordered list of rows currently drawn: a depth-first pre-order
135 /// walk visiting the root and, beneath every directory that is
136 /// expanded **and** loaded, its (already filter-derived) children.
137 ///
138 /// The single source of draw order — the view, keyboard navigation,
139 /// and range selection all consume this list, so they never diverge.
140 pub fn visible_rows(&self) -> Vec<(&TreeNode, u32)> {
141 let mut rows = Vec::new();
142 if let Some(search) = &self.search {
143 collect_rows_search(&self.root, 0, &mut rows, &search.visible_paths);
144 } else {
145 collect_rows(&self.root, 0, &mut rows);
146 }
147 rows
148 }
149
150 /// Synchronously expand `path`: run [`DirectoryTree::on_toggled`],
151 /// execute any produced scan **on the current thread**, and merge.
152 ///
153 /// Returns `None` when no scan was needed (fast-path expand,
154 /// collapse, no-op) and `Some(outcome)` when a scan ran. This is the
155 /// port of upstream's `__test_expand_blocking`: it lets the
156 /// specification suite — and quick scripts — bypass all async
157 /// infrastructure. GUI code should use `on_toggled` with a worker
158 /// instead.
159 pub fn expand_blocking(&mut self, path: &Path) -> Option<LoadedOutcome> {
160 let request = self.on_toggled(path)?;
161 let payload = scan::run(&request);
162 Some(self.on_loaded(payload))
163 }
164
165 // ── Selection accessors ────────────────────────────────────────────
166
167 /// The full insertion-ordered selection set (S6.x).
168 pub fn selected_paths(&self) -> &[PathBuf] {
169 &self.selected_paths
170 }
171
172 /// The single-select view: the most recently touched path (S3.3).
173 ///
174 /// Returns `None` before any selection gesture. This is *not*
175 /// the last element of `selected_paths`; it is `active_path`,
176 /// which the component renders with the distinct "active" style.
177 pub fn selected_path(&self) -> Option<&Path> {
178 self.active_path.as_deref()
179 }
180
181 /// `true` iff `path` is in the selection set.
182 ///
183 /// This is the authoritative query; prefer it over reading
184 /// `node.is_selected`, which is a derived view hint.
185 pub fn is_selected(&self, path: &Path) -> bool {
186 self.selected_paths.iter().any(|p| p == path)
187 }
188
189 /// Builder: enable prefetch — after each user-initiated scan, speculatively
190 /// scan up to `n` direct folder-children (S8.2). `0` disables prefetch.
191 pub fn with_prefetch_limit(mut self, n: u32) -> Self {
192 self.config.prefetch_per_parent = n;
193 self
194 }
195
196 /// Builder: replace the prefetch skip list (S8.5).
197 ///
198 /// Entries are compared ASCII case-insensitively against each candidate
199 /// child's basename. Defaults to [`crate::config::DEFAULT_PREFETCH_SKIP`].
200 pub fn with_prefetch_skip(mut self, skip: impl IntoIterator<Item = impl Into<String>>) -> Self {
201 self.config.prefetch_skip = skip.into_iter().map(Into::into).collect();
202 self
203 }
204
205 /// Paths for which a speculative prefetch scan is currently in flight.
206 pub fn prefetching_paths(&self) -> &HashSet<PathBuf> {
207 &self.prefetching_paths
208 }
209
210 // ── Search accessors and mutation ─────────────────────────────────────
211
212 /// The active search query, or `None` when search is inactive.
213 pub fn search_query(&self) -> Option<&str> {
214 self.search.as_ref().map(|s| s.query.as_str())
215 }
216
217 /// The active search state (query, visible paths, match count), or
218 /// `None` when search is inactive.
219 pub fn search_state(&self) -> Option<&SearchState> {
220 self.search.as_ref()
221 }
222
223 /// Number of **direct** basename matches (S9.8). Use this for "N
224 /// results" displays; ancestor rows shown for context are excluded.
225 pub fn search_match_count(&self) -> usize {
226 self.search.as_ref().map_or(0, |s| s.match_count)
227 }
228
229 /// Activate or update the incremental search filter (S9.1–S9.9).
230 ///
231 /// An empty string clears the search (S9.4). Search never triggers
232 /// I/O — it filters only the already-loaded node graph (S9.9).
233 pub fn set_search_query(&mut self, query: &str) {
234 if query.is_empty() {
235 self.search = None;
236 return;
237 }
238 let query_lower = query.to_ascii_lowercase();
239 let mut visible = HashSet::new();
240 let mut match_count = 0;
241 search::walk_for_search(&self.root, &query_lower, &mut visible, &mut match_count);
242 self.search = Some(SearchState {
243 query: query.to_string(),
244 query_lower,
245 visible_paths: visible,
246 match_count,
247 });
248 }
249
250 /// Clear the active search and return to normal tree view.
251 ///
252 /// Equivalent to `set_search_query("")` (S9.4).
253 pub fn clear_search(&mut self) {
254 self.search = None;
255 }
256
257 /// The active drag session, or `None` when no drag is in progress.
258 pub fn drag_state(&self) -> Option<&DragState> {
259 self.drag.as_ref()
260 }
261}
262
263fn collect_rows<'a>(node: &'a TreeNode, depth: u32, rows: &mut Vec<(&'a TreeNode, u32)>) {
264 rows.push((node, depth));
265 if node.is_dir && node.is_expanded && node.is_loaded {
266 for child in &node.children {
267 collect_rows(child, depth + 1, rows);
268 }
269 }
270}
271
272/// Search-mode row collection: gates on `visible_paths`, descends into all
273/// loaded directories regardless of `is_expanded` (S9.3 — sees through collapse).
274fn collect_rows_search<'a>(
275 node: &'a TreeNode,
276 depth: u32,
277 rows: &mut Vec<(&'a TreeNode, u32)>,
278 visible_paths: &std::collections::HashSet<std::path::PathBuf>,
279) {
280 if !visible_paths.contains(&node.path) {
281 return;
282 }
283 rows.push((node, depth));
284 if node.is_dir && node.is_loaded {
285 for child in &node.children {
286 collect_rows_search(child, depth + 1, rows, visible_paths);
287 }
288 }
289}
290
291/// Recompute search visibility when search is active and the node graph
292/// has changed (S9.6, S9.7).
293pub(crate) fn recompute_search_if_active(tree: &mut DirectoryTree) {
294 let query_lower = match &tree.search {
295 Some(s) => s.query_lower.clone(),
296 None => return,
297 };
298 let mut visible = HashSet::new();
299 let mut match_count = 0;
300 search::walk_for_search(&tree.root, &query_lower, &mut visible, &mut match_count);
301 if let Some(s) = &mut tree.search {
302 s.visible_paths = visible;
303 s.match_count = match_count;
304 }
305}