返回 CodeWhale
file_tree.rs
根目录 / crates / tui / src / tui / file_tree.rs
1 //! File-tree pane — Ctrl+Shift+E toggles a left-side workspace file navigator.
2 //!
3 //! Shows the workspace directory tree with expandable directories. Up/Down
4 //! navigate, Enter expands/collapses directories or inserts `@path` for files,
5 //! Esc closes the pane.
6
7 use std::collections::{HashMap, HashSet};
8 use std::path::{Path, PathBuf};
9 use std::sync::{Arc, Mutex};
10
11 use ratatui::{
12 Frame,
13 layout::Rect,
14 style::Style,
15 text::{Line, Span},
16 widgets::{Block, BorderType, Borders, Padding, Paragraph, Wrap},
17 };
18
19 use crate::tui::menu_style;
20 use crate::tui::ui_text::truncate_line_to_width;
21 use codewhale_palette as palette;
22
23 // ---------------------------------------------------------------------------
24 // Public API
25 // ---------------------------------------------------------------------------
26
27 /// A single entry in the file tree.
28 #[derive(Debug, Clone)]
29 pub struct FileTreeEntry {
30 pub name: String,
31 pub path: PathBuf,
32 pub is_dir: bool,
33 pub depth: usize,
34 pub expanded: bool,
35 }
36
37 /// An in-flight background expand walk (#3900). The sequence number
38 /// distinguishes the latest walk for a directory from superseded ones so a
39 /// stale result can never be spliced in after a re-toggle.
40 #[derive(Debug, Clone)]
41 struct PendingExpand {
42 seq: u64,
43 cell: Arc<Mutex<Option<Vec<FileTreeEntry>>>>,
44 }
45
46 /// Mutable state for the file-tree pane.
47 #[derive(Debug, Clone)]
48 pub struct FileTreeState {
49 /// Flat list of visible entries (respects expanded/collapsed state).
50 pub entries: Vec<FileTreeEntry>,
51 /// Index into `entries` for the cursor.
52 pub cursor: usize,
53 /// Scroll offset into `entries`.
54 pub scroll_offset: usize,
55 /// Set of expanded directory paths (normalised).
56 pub expanded_dirs: HashSet<PathBuf>,
57 /// Workspace root.
58 pub workspace: PathBuf,
59 /// Whether the tree is still building (async initial walk in progress).
60 pub is_loading: bool,
61 /// Shared cell for async tree-building results (#399 S3).
62 loading_cell: Option<Arc<Mutex<Option<Vec<FileTreeEntry>>>>>,
63 /// In-flight expand walks keyed by normalised directory path (#3900).
64 pending_expands: HashMap<PathBuf, PendingExpand>,
65 /// Monotonic counter identifying the latest expand walk per directory.
66 expand_seq: u64,
67 }
68
69 impl FileTreeState {
70 /// Build a fresh tree state by walking `workspace`.
71 /// Spawns the initial walk on a background thread (#399 S3); without a
72 /// tokio runtime (plain unit tests) the walk runs synchronously.
73 pub fn new(workspace: &Path) -> Self {
74 let expanded_dirs = HashSet::new();
75 if tokio::runtime::Handle::try_current().is_err() {
76 let entries = build_file_tree_inner(workspace, &expanded_dirs, None);
77 return Self {
78 entries,
79 cursor: 0,
80 scroll_offset: 0,
81 expanded_dirs,
82 workspace: workspace.to_path_buf(),
83 is_loading: false,
84 loading_cell: None,
85 pending_expands: HashMap::new(),
86 expand_seq: 0,
87 };
88 }
89 let loading_cell = Arc::new(Mutex::new(None));
90 let cell = loading_cell.clone();
91 let ws = workspace.to_path_buf();
92 crate::utils::spawn_blocking_supervised("file-tree-build", move || {
93 let entries = build_file_tree_inner(&ws, &HashSet::new(), None);
94 if let Ok(mut guard) = cell.lock() {
95 *guard = Some(entries);
96 }
97 });
98 Self {
99 entries: Vec::new(),
100 cursor: 0,
101 scroll_offset: 0,
102 expanded_dirs,
103 workspace: workspace.to_path_buf(),
104 is_loading: true,
105 loading_cell: Some(loading_cell),
106 pending_expands: HashMap::new(),
107 expand_seq: 0,
108 }
109 }
110
111 /// Poll for async build results. Call from the render loop.
112 pub fn poll_loading(&mut self) {
113 if !self.is_loading {
114 return;
115 }
116 // Take the Arc out temporarily to avoid a double-borrow of self.
117 let cell = match self.loading_cell.take() {
118 Some(c) => c,
119 None => return,
120 };
121 let mut done = false;
122 if let Ok(mut guard) = cell.lock()
123 && let Some(entries) = guard.take()
124 {
125 self.entries = entries;
126 self.is_loading = false;
127 self.clamp_cursor();
128 done = true;
129 }
130 if !done {
131 // Put the cell back so we can poll again next frame.
132 self.loading_cell = Some(cell);
133 }
134 }
135
136 /// Drain any completed background walks (initial build or expands).
137 /// Returns `true` when new results were applied so the event loop can
138 /// schedule a repaint — without this, a walk finishing while the loop
139 /// is idle would leave the expanded directory looking empty until the
140 /// next unrelated input event (#3900).
141 pub fn poll_background(&mut self) -> bool {
142 let was_loading = self.is_loading;
143 self.poll_loading();
144 let finished_loading = was_loading && !self.is_loading;
145 let pending_before = self.pending_expands.len();
146 self.poll_pending_expands();
147 finished_loading || self.pending_expands.len() != pending_before
148 }
149
150 /// Poll for background expand-walk results and splice them in.
151 /// Call from the render loop, after [`Self::poll_loading`] (#3900).
152 pub fn poll_pending_expands(&mut self) {
153 if self.pending_expands.is_empty() {
154 return;
155 }
156 let mut ready: Vec<(PathBuf, u64, Vec<FileTreeEntry>)> = Vec::new();
157 for (dir, pending) in &self.pending_expands {
158 if let Ok(mut guard) = pending.cell.lock()
159 && let Some(children) = guard.take()
160 {
161 ready.push((dir.clone(), pending.seq, children));
162 }
163 }
164 for (dir, seq, children) in ready {
165 self.apply_expand_result(&dir, seq, children);
166 }
167 }
168
169 /// Move the cursor up by one.
170 pub fn cursor_up(&mut self) {
171 if self.cursor > 0 {
172 self.cursor -= 1;
173 }
174 self.clamp_scroll();
175 }
176
177 /// Move the cursor down by one.
178 pub fn cursor_down(&mut self) {
179 if self.cursor + 1 < self.entries.len() {
180 self.cursor += 1;
181 }
182 self.clamp_scroll();
183 }
184
185 /// Activate the entry under the cursor.
186 ///
187 /// Returns `Some(path)` when the entry is a file that should be
188 /// mentioned (`@path` inserted into the composer). Returns `None`
189 /// after toggling a directory expand/collapse.
190 pub fn activate(&mut self) -> Option<PathBuf> {
191 let entry = self.entries.get(self.cursor)?;
192 if entry.is_dir {
193 let norm = normalize_path(&entry.path);
194 if self.expanded_dirs.contains(&norm) {
195 self.collapse_dir_at(self.cursor);
196 } else {
197 self.expand_dir_at(self.cursor);
198 }
199 None
200 } else {
201 // Return the path relative to workspace.
202 entry.path.strip_prefix(&self.workspace).ok().map(|rel| {
203 let mut p = PathBuf::new();
204 for comp in rel.components() {
205 p.push(comp);
206 }
207 p
208 })
209 }
210 }
211
212 /// Collapse the directory at `idx` by splicing its visible descendants
213 /// out of the flat entry list — no filesystem I/O at all (#3900).
214 ///
215 /// Descendant directories stay in `expanded_dirs` so re-expanding the
216 /// parent restores their expanded state, matching the previous
217 /// full-rebuild behavior.
218 fn collapse_dir_at(&mut self, idx: usize) {
219 let Some(entry) = self.entries.get_mut(idx) else {
220 return;
221 };
222 if !entry.is_dir {
223 return;
224 }
225 let depth = entry.depth;
226 entry.expanded = false;
227 let norm = normalize_path(&entry.path);
228 self.expanded_dirs.remove(&norm);
229 // Drop any in-flight expand walk for this directory; its result must
230 // not splice into a collapsed node.
231 self.pending_expands.remove(&norm);
232
233 let end = self.entries[idx + 1..]
234 .iter()
235 .position(|e| e.depth <= depth)
236 .map_or(self.entries.len(), |offset| idx + 1 + offset);
237 let removed = end - (idx + 1);
238 self.entries.drain(idx + 1..end);
239 if self.cursor > idx {
240 self.cursor = if self.cursor < end {
241 idx
242 } else {
243 self.cursor - removed
244 };
245 }
246 self.clamp_cursor();
247 self.clamp_scroll();
248 }
249
250 /// Expand the directory at `idx`. The subtree walk runs on a background
251 /// thread and is spliced in by [`Self::poll_pending_expands`] (#3900);
252 /// without a tokio runtime (plain unit tests) it runs synchronously.
253 ///
254 /// The entry is marked expanded immediately (▼) so the keypress is
255 /// acknowledged; children appear when the walk completes.
256 fn expand_dir_at(&mut self, idx: usize) {
257 let Some(entry) = self.entries.get_mut(idx) else {
258 return;
259 };
260 if !entry.is_dir {
261 return;
262 }
263 entry.expanded = true;
264 let dir = entry.path.clone();
265 let norm = normalize_path(&dir);
266 self.expanded_dirs.insert(norm.clone());
267 self.expand_seq = self.expand_seq.wrapping_add(1);
268 let seq = self.expand_seq;
269 let ws = self.workspace.clone();
270 let expanded_snapshot = self.expanded_dirs.clone();
271
272 let cell = Arc::new(Mutex::new(None));
273 self.pending_expands.insert(
274 norm.clone(),
275 PendingExpand {
276 seq,
277 cell: cell.clone(),
278 },
279 );
280 if tokio::runtime::Handle::try_current().is_ok() {
281 crate::utils::spawn_blocking_supervised("file-tree-expand", move || {
282 let children = build_file_tree_inner(&ws, &expanded_snapshot, Some(&dir));
283 if let Ok(mut guard) = cell.lock() {
284 *guard = Some(children);
285 }
286 });
287 } else {
288 let children = build_file_tree_inner(&ws, &expanded_snapshot, Some(&dir));
289 self.apply_expand_result(&norm, seq, children);
290 }
291 }
292
293 /// Splice a completed expand walk into the entry list, unless it has
294 /// been superseded (newer walk for the same directory), the directory
295 /// was collapsed while the walk was in flight, or the directory is no
296 /// longer visible (an ancestor collapsed).
297 fn apply_expand_result(&mut self, dir: &Path, seq: u64, children: Vec<FileTreeEntry>) {
298 let is_current = self
299 .pending_expands
300 .get(dir)
301 .is_some_and(|pending| pending.seq == seq);
302 if !is_current {
303 return;
304 }
305 self.pending_expands.remove(dir);
306 if !self.expanded_dirs.contains(dir) {
307 return;
308 }
309 let Some(idx) = self
310 .entries
311 .iter()
312 .position(|e| e.is_dir && normalize_path(&e.path) == *dir)
313 else {
314 return;
315 };
316 let depth = self.entries[idx].depth;
317 // Defensive: never splice a subtree in twice.
318 if self.entries.get(idx + 1).is_some_and(|e| e.depth > depth) {
319 return;
320 }
321 self.entries[idx].expanded = true;
322 let inserted = children.len();
323 self.entries.splice(idx + 1..idx + 1, children);
324 if self.cursor > idx {
325 self.cursor += inserted;
326 }
327 self.clamp_cursor();
328 self.clamp_scroll();
329 }
330
331 /// Ensure the cursor is within bounds.
332 fn clamp_cursor(&mut self) {
333 if !self.entries.is_empty() && self.cursor >= self.entries.len() {
334 self.cursor = self.entries.len().saturating_sub(1);
335 }
336 }
337
338 /// Ensure the scroll offset keeps the cursor visible.
339 fn clamp_scroll(&mut self) {
340 let visible_height = 20usize; // will be overridden per render
341 if self.cursor < self.scroll_offset {
342 self.scroll_offset = self.cursor;
343 }
344 if self.scroll_offset + visible_height <= self.cursor {
345 self.scroll_offset = self.cursor.saturating_add(1).saturating_sub(visible_height);
346 }
347 }
348
349 /// Adjust scroll for a given visible height.
350 #[cfg(test)]
351 pub fn adjust_scroll(&mut self, visible: usize) {
352 if self.cursor < self.scroll_offset {
353 self.scroll_offset = self.cursor;
354 }
355 if visible > 0 && self.cursor >= self.scroll_offset + visible {
356 self.scroll_offset = self.cursor.saturating_add(1).saturating_sub(visible);
357 }
358 }
359 }
360
361 // ---------------------------------------------------------------------------
362 // Tree building
363 // ---------------------------------------------------------------------------
364
365 /// Build the flat visible-entry list.
366 ///
367 /// Walks the workspace directory recursively. Directories in `expanded_dirs`
368 /// have their children included; collapsed directories show only the directory
369 /// entry itself. Entries are sorted: directories first, then files, each group
370 /// alphabetically.
371 fn build_file_tree_inner(
372 workspace: &Path,
373 expanded_dirs: &HashSet<PathBuf>,
374 single_root: Option<&Path>,
375 ) -> Vec<FileTreeEntry> {
376 let mut entries: Vec<FileTreeEntry> = Vec::new();
377
378 // Determine which root to scan.
379 let scan_root = single_root.unwrap_or(workspace);
380
381 // Collect children of `scan_root`.
382 let mut children: Vec<(String, PathBuf, bool)> = Vec::new();
383 if let Ok(read_dir) = std::fs::read_dir(scan_root) {
384 for entry in read_dir.flatten() {
385 let path = entry.path();
386 // Skip well-known ignored directories.
387 if let Some(name) = path.file_name().and_then(|n| n.to_str())
388 && matches!(name, ".git" | "node_modules" | "target" | ".DS_Store")
389 {
390 continue;
391 }
392 let ft = match entry.file_type() {
393 Ok(ft) => ft,
394 Err(_) => continue,
395 };
396 let is_dir = ft.is_dir();
397 let name = path
398 .file_name()
399 .and_then(|n| n.to_str())
400 .map(|n| n.to_string())
401 .unwrap_or_default();
402 children.push((name, path, is_dir));
403 }
404 }
405
406 // Sort: dirs first, then files, alphabetical within each group.
407 // Decorate-sort-undecorate: precompute lowercase names to avoid
408 // allocating on every comparison.
409 let mut decorated: Vec<_> = children
410 .into_iter()
411 .map(|(name, path, is_dir)| {
412 let lower = name.to_lowercase();
413 (lower, name, path, is_dir)
414 })
415 .collect();
416 decorated.sort_by(
417 |(a_lower, _, _, a_dir), (b_lower, _, _, b_dir)| match (a_dir, b_dir) {
418 (true, false) => std::cmp::Ordering::Less,
419 (false, true) => std::cmp::Ordering::Greater,
420 _ => a_lower.cmp(b_lower),
421 },
422 );
423 children = decorated
424 .into_iter()
425 .map(|(_, name, path, is_dir)| (name, path, is_dir))
426 .collect();
427
428 // Compute depth for the current level.
429 let depth = if single_root.is_some() {
430 let rel = scan_root.strip_prefix(workspace).unwrap_or(scan_root);
431 rel.components().count()
432 } else {
433 0
434 };
435
436 for (name, path, is_dir) in &children {
437 let norm = normalize_path(path);
438 let is_expanded = *is_dir && expanded_dirs.contains(&norm);
439
440 entries.push(FileTreeEntry {
441 name: name.clone(),
442 path: path.clone(),
443 is_dir: *is_dir,
444 depth,
445 expanded: is_expanded,
446 });
447
448 // If it's an expanded directory, recurse.
449 if is_expanded {
450 let sub = build_file_tree_inner(workspace, expanded_dirs, Some(path));
451 entries.extend(sub);
452 }
453 }
454
455 entries
456 }
457
458 /// Normalise a path for use as a HashSet key.
459 fn normalize_path(path: &Path) -> PathBuf {
460 let components: Vec<_> = path.components().collect();
461 // Try to strip workspace prefix.
462 PathBuf::from_iter(components.iter().map(|c| c.as_os_str()))
463 }
464
465 // ---------------------------------------------------------------------------
466 // Rendering
467 // ---------------------------------------------------------------------------
468
469 const FILE_TREE_MIN_WIDTH: u16 = 20;
470
471 /// Render the file tree inside `area`.
472 /// Polls async loading state before rendering (#399 S3).
473 pub fn render_file_tree(
474 f: &mut Frame,
475 area: Rect,
476 state: &mut FileTreeState,
477 mode: palette::PaletteMode,
478 ) {
479 state.poll_loading();
480 state.poll_pending_expands();
481 if area.width < FILE_TREE_MIN_WIDTH || area.height < 3 {
482 return;
483 }
484
485 let content_width = area.width.saturating_sub(4) as usize;
486 let visible_rows = area.height.saturating_sub(3) as usize;
487
488 let scroll = state.scroll_offset;
489 let max_visible = visible_rows.max(1);
490
491 let mut lines: Vec<Line<'static>> = Vec::with_capacity(max_visible + 1);
492
493 if state.is_loading {
494 lines.push(Line::from(Span::styled(
495 " Building file tree...",
496 Style::default().fg(palette::TEXT_MUTED),
497 )));
498 } else if state.entries.is_empty() {
499 lines.push(Line::from(Span::styled(
500 " (empty)",
501 Style::default().fg(palette::TEXT_MUTED),
502 )));
503 } else {
504 let render_end = (scroll + max_visible).min(state.entries.len());
505 for idx in scroll..render_end {
506 let entry = &state.entries[idx];
507 let is_selected = idx == state.cursor;
508
509 // Build the line prefix: indent + expand/collapse marker + icon.
510 let indent = " ".repeat(entry.depth);
511 let expand_marker = if entry.is_dir {
512 if entry.expanded {
513 "\u{25BC} "
514 } else {
515 "\u{25B6} "
516 } // ▼ / ▶
517 } else {
518 " "
519 };
520 // No separate icon: the ▼/▶ expand marker already signals dirs,
521 // and SMP emoji (📁/📄, U+1F4C1/U+1F4C4) render at inconsistent
522 // column widths across terminals, breaking layout. See issue #1314.
523
524 // Build the display text.
525 let raw = format!("{indent}{expand_marker}{}", entry.name);
526 let display = truncate_line_to_width(&raw, content_width.max(1));
527
528 let style = if is_selected {
529 menu_style::selected_row_bg_style().fg(palette::SELECTION_TEXT)
530 } else {
531 Style::default().fg(palette::TEXT_PRIMARY)
532 };
533
534 lines.push(Line::from(Span::styled(display, style)));
535 }
536 }
537
538 // Pane chrome is the four-mode whale/light/grayscale/solarized ink; the
539 // backend remap carries the dark tokens into community presets exactly as
540 // it does for every other raw `palette` paint in this file. The pane floor
541 // is the one slot those base themes never lifted into `UiTheme`: the dark
542 // shells paint the raw ink field (remapped onto the live surface at draw
543 // time), the light and grey shells paint their panel tint.
544 let chrome = palette::UiTheme::for_mode(mode);
545 let pane_bg = match mode {
546 palette::PaletteMode::Dark => palette::WHALE_BG,
547 palette::PaletteMode::Light => palette::LIGHT_PANEL,
548 palette::PaletteMode::Grayscale | palette::PaletteMode::SolarizedLight => chrome.panel_bg,
549 };
550 // Horizontal padding only: `Padding::uniform(1)` ate two rows of a
551 // compact pane and left zero rows for content (#63 follow-up).
552 let section = Paragraph::new(lines).wrap(Wrap { trim: false }).block(
553 Block::default()
554 .title(Line::from(Span::styled(
555 " Files ",
556 Style::default().fg(chrome.accent_primary).bold(),
557 )))
558 .borders(Borders::ALL)
559 .border_type(BorderType::Plain)
560 .border_style(Style::default().fg(chrome.border))
561 .style(Style::default().bg(pane_bg))
562 .padding(Padding::horizontal(1)),
563 );
564
565 f.render_widget(section, area);
566 }
567
568 #[cfg(test)]
569 mod tests {
570 use super::*;
571 use std::time::Duration;
572
573 fn fixture_workspace() -> tempfile::TempDir {
574 let dir = tempfile::TempDir::new().expect("temp dir");
575 let root = dir.path();
576 std::fs::create_dir_all(root.join("src/nested")).expect("mkdir src/nested");
577 std::fs::create_dir_all(root.join("docs")).expect("mkdir docs");
578 std::fs::create_dir_all(root.join("node_modules/pkg")).expect("mkdir node_modules");
579 std::fs::write(root.join("README.md"), "readme").expect("write README.md");
580 std::fs::write(root.join("src/main.rs"), "fn main() {}").expect("write main.rs");
581 std::fs::write(root.join("src/Lib.rs"), "").expect("write Lib.rs");
582 std::fs::write(root.join("src/nested/mod.rs"), "").expect("write mod.rs");
583 std::fs::write(root.join("docs/guide.md"), "").expect("write guide.md");
584 dir
585 }
586
587 fn index_of(state: &FileTreeState, name: &str) -> usize {
588 state
589 .entries
590 .iter()
591 .position(|e| e.name == name)
592 .unwrap_or_else(|| panic!("entry {name} missing: {:?}", entry_names(state)))
593 }
594
595 fn entry_names(state: &FileTreeState) -> Vec<String> {
596 state.entries.iter().map(|e| e.name.clone()).collect()
597 }
598
599 fn expand_by_name(state: &mut FileTreeState, name: &str) {
600 state.cursor = index_of(state, name);
601 assert!(state.activate().is_none(), "expanding {name} returns None");
602 }
603
604 /// The incremental expand path (splice) must produce exactly the flat
605 /// list the old full rebuild produced, for several expansion orders.
606 #[test]
607 fn incremental_expand_matches_full_rebuild() {
608 let ws = fixture_workspace();
609 for order in [["src", "nested", "docs"], ["docs", "src", "nested"]] {
610 // Plain test: no tokio runtime, so expand walks run synchronously.
611 let mut state = FileTreeState::new(ws.path());
612 assert!(!state.is_loading, "sync fallback builds immediately");
613 for name in order {
614 expand_by_name(&mut state, name);
615 }
616
617 let oracle = build_file_tree_inner(ws.path(), &state.expanded_dirs, None);
618 assert_eq!(
619 state.entries.len(),
620 oracle.len(),
621 "entry count parity for order {order:?}: {:?}",
622 entry_names(&state)
623 );
624 for (spliced, rebuilt) in state.entries.iter().zip(oracle.iter()) {
625 assert_eq!(spliced.name, rebuilt.name);
626 assert_eq!(spliced.path, rebuilt.path);
627 assert_eq!(spliced.is_dir, rebuilt.is_dir);
628 assert_eq!(spliced.depth, rebuilt.depth);
629 assert_eq!(spliced.expanded, rebuilt.expanded);
630 }
631 }
632 }
633
634 #[test]
635 fn collapse_splices_out_subtree_without_io() {
636 let ws = fixture_workspace();
637 let mut state = FileTreeState::new(ws.path());
638 expand_by_name(&mut state, "src");
639 expand_by_name(&mut state, "nested");
640 assert!(state.entries.iter().any(|e| e.name == "mod.rs"));
641
642 // Collapse src: descendants leave the list, nested stays remembered.
643 let src_idx = index_of(&state, "src");
644 state.cursor = src_idx;
645 assert!(state.activate().is_none());
646
647 assert!(!state.entries[src_idx].expanded);
648 assert!(!state.entries.iter().any(|e| e.name == "main.rs"));
649 assert!(!state.entries.iter().any(|e| e.name == "mod.rs"));
650 assert_eq!(state.cursor, src_idx, "cursor stays on the collapsed dir");
651 let nested_norm = normalize_path(&ws.path().join("src/nested"));
652 assert!(
653 state.expanded_dirs.contains(&nested_norm),
654 "collapsing a parent keeps descendant expansion state"
655 );
656 }
657
658 #[test]
659 fn re_expand_restores_descendant_expansion() {
660 let ws = fixture_workspace();
661 let mut state = FileTreeState::new(ws.path());
662 expand_by_name(&mut state, "src");
663 expand_by_name(&mut state, "nested");
664 state.cursor = index_of(&state, "src");
665 assert!(state.activate().is_none()); // collapse
666 assert!(state.activate().is_none()); // re-expand
667
668 let nested_idx = index_of(&state, "nested");
669 assert!(state.entries[nested_idx].expanded);
670 assert!(
671 state.entries.iter().any(|e| e.name == "mod.rs"),
672 "re-expanding the parent restores the expanded child subtree: {:?}",
673 entry_names(&state)
674 );
675 }
676
677 #[test]
678 fn adjust_scroll_keeps_the_cursor_inside_the_visible_window() {
679 let ws = fixture_workspace();
680 let mut state = FileTreeState::new(ws.path());
681 state.cursor = state.entries.len().saturating_sub(1);
682 state.adjust_scroll(3);
683 assert!(state.cursor < state.scroll_offset + 3);
684
685 state.cursor = 0;
686 state.adjust_scroll(3);
687 assert_eq!(state.scroll_offset, 0);
688 }
689
690 #[test]
691 fn stale_expand_results_are_discarded() {
692 let ws = fixture_workspace();
693 let mut state = FileTreeState::new(ws.path());
694 expand_by_name(&mut state, "src");
695 let src_norm = normalize_path(&ws.path().join("src"));
696
697 // Collapse removes the pending walk; a result landing afterwards is
698 // dropped instead of splicing into the collapsed node.
699 state.cursor = index_of(&state, "src");
700 assert!(state.activate().is_none());
701 let ghost = vec![FileTreeEntry {
702 name: "ghost.rs".to_string(),
703 path: ws.path().join("src/ghost.rs"),
704 is_dir: false,
705 depth: 1,
706 expanded: false,
707 }];
708 state.apply_expand_result(&src_norm, state.expand_seq, ghost.clone());
709 assert!(!state.entries.iter().any(|e| e.name == "ghost.rs"));
710
711 // A superseded sequence number is also dropped, and the newer
712 // pending walk stays registered.
713 state.pending_expands.insert(
714 src_norm.clone(),
715 PendingExpand {
716 seq: 7,
717 cell: Arc::new(Mutex::new(None)),
718 },
719 );
720 state.expanded_dirs.insert(src_norm.clone());
721 state.apply_expand_result(&src_norm, 6, ghost);
722 assert!(!state.entries.iter().any(|e| e.name == "ghost.rs"));
723 assert!(
724 state.pending_expands.contains_key(&src_norm),
725 "a stale result must not clear the newer pending walk"
726 );
727 }
728
729 #[test]
730 fn splice_shifts_cursor_positioned_after_the_expanded_dir() {
731 let ws = fixture_workspace();
732 let mut state = FileTreeState::new(ws.path());
733 let src_norm = normalize_path(&ws.path().join("src"));
734 let src_idx = index_of(&state, "src");
735 let readme_idx = index_of(&state, "README.md");
736 assert!(readme_idx > src_idx);
737
738 state.expanded_dirs.insert(src_norm.clone());
739 state.entries[src_idx].expanded = true;
740 state.pending_expands.insert(
741 src_norm.clone(),
742 PendingExpand {
743 seq: 1,
744 cell: Arc::new(Mutex::new(None)),
745 },
746 );
747 state.cursor = readme_idx;
748 let children = build_file_tree_inner(
749 ws.path(),
750 &state.expanded_dirs,
751 Some(&ws.path().join("src")),
752 );
753 let inserted = children.len();
754 assert!(inserted > 0);
755
756 state.apply_expand_result(&src_norm, 1, children);
757
758 assert_eq!(state.cursor, readme_idx + inserted);
759 assert_eq!(state.entries[state.cursor].name, "README.md");
760 }
761
762 #[tokio::test]
763 async fn async_expand_splices_children_after_poll() {
764 let ws = fixture_workspace();
765 let mut state = FileTreeState::new(ws.path());
766 for _ in 0..500 {
767 state.poll_loading();
768 if !state.is_loading {
769 break;
770 }
771 tokio::time::sleep(Duration::from_millis(5)).await;
772 }
773 assert!(!state.is_loading, "initial async build completes");
774
775 state.cursor = index_of(&state, "src");
776 assert!(state.activate().is_none());
777 assert!(
778 state.entries[state.cursor].expanded,
779 "expand acknowledges the keypress immediately"
780 );
781
782 for _ in 0..500 {
783 state.poll_pending_expands();
784 if state.entries.iter().any(|e| e.name == "main.rs") {
785 break;
786 }
787 tokio::time::sleep(Duration::from_millis(5)).await;
788 }
789 assert!(
790 state.entries.iter().any(|e| e.name == "main.rs"),
791 "background walk results are spliced in on poll: {:?}",
792 entry_names(&state)
793 );
794 assert!(state.pending_expands.is_empty());
795 }
796
797 #[test]
798 fn poll_background_reports_applied_expand_results() {
799 let ws = fixture_workspace();
800 let mut state = FileTreeState::new(ws.path());
801 let src_norm = normalize_path(&ws.path().join("src"));
802 let src_idx = index_of(&state, "src");
803
804 state.expanded_dirs.insert(src_norm.clone());
805 state.entries[src_idx].expanded = true;
806 let children = build_file_tree_inner(
807 ws.path(),
808 &state.expanded_dirs,
809 Some(&ws.path().join("src")),
810 );
811 state.pending_expands.insert(
812 src_norm.clone(),
813 PendingExpand {
814 seq: 1,
815 cell: Arc::new(Mutex::new(Some(children))),
816 },
817 );
818
819 assert!(
820 state.poll_background(),
821 "a drained expand result must request a repaint"
822 );
823 assert!(state.entries.iter().any(|e| e.name == "main.rs"));
824 assert!(
825 state.pending_expands.is_empty(),
826 "applied expand must clear pending state"
827 );
828 assert!(
829 !state.poll_background(),
830 "idle poll must not request a second repaint"
831 );
832 }
833 }
834
834 lines RUST