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