Skip to main content

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}