返回 DeepSeek-TUI-2026
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::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, Stylize},
15 text::{Line, Span},
16 widgets::{Block, Paragraph, Wrap},
17 };
18
19 use crate::deepseek_theme::active_theme;
20 use crate::palette;
21 use crate::tui::ui::truncate_line_to_width;
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 /// Mutable state for the file-tree pane.
38 #[derive(Debug, Clone)]
39 pub struct FileTreeState {
40 /// Flat list of visible entries (respects expanded/collapsed state).
41 pub entries: Vec<FileTreeEntry>,
42 /// Index into `entries` for the cursor.
43 pub cursor: usize,
44 /// Scroll offset into `entries`.
45 pub scroll_offset: usize,
46 /// Set of expanded directory paths (normalised).
47 pub expanded_dirs: HashSet<PathBuf>,
48 /// Workspace root.
49 pub workspace: PathBuf,
50 /// Whether the tree is still building (async initial walk in progress).
51 pub is_loading: bool,
52 /// Shared cell for async tree-building results (#399 S3).
53 loading_cell: Option<Arc<Mutex<Option<Vec<FileTreeEntry>>>>>,
54 }
55
56 impl FileTreeState {
57 /// Build a fresh tree state by walking `workspace`.
58 /// Spawns the initial walk on a background thread (#399 S3).
59 pub fn new(workspace: &Path) -> Self {
60 let expanded_dirs = HashSet::new();
61 let loading_cell = Arc::new(Mutex::new(None));
62 let cell = loading_cell.clone();
63 let ws = workspace.to_path_buf();
64 crate::utils::spawn_blocking_supervised("file-tree-build", move || {
65 let entries = build_file_tree_inner(&ws, &HashSet::new(), None);
66 if let Ok(mut guard) = cell.lock() {
67 *guard = Some(entries);
68 }
69 });
70 Self {
71 entries: Vec::new(),
72 cursor: 0,
73 scroll_offset: 0,
74 expanded_dirs,
75 workspace: workspace.to_path_buf(),
76 is_loading: true,
77 loading_cell: Some(loading_cell),
78 }
79 }
80
81 /// Poll for async build results. Call from the render loop.
82 pub fn poll_loading(&mut self) {
83 if !self.is_loading {
84 return;
85 }
86 // Take the Arc out temporarily to avoid a double-borrow of self.
87 let cell = match self.loading_cell.take() {
88 Some(c) => c,
89 None => return,
90 };
91 let mut done = false;
92 if let Ok(mut guard) = cell.lock()
93 && let Some(entries) = guard.take()
94 {
95 self.entries = entries;
96 self.is_loading = false;
97 self.clamp_cursor();
98 done = true;
99 }
100 if !done {
101 // Put the cell back so we can poll again next frame.
102 self.loading_cell = Some(cell);
103 }
104 }
105
106 /// Rebuild the flat entry list from the current `expanded_dirs` set.
107 /// When loading is in progress, the rebuild is deferred.
108 pub fn rebuild(&mut self) {
109 if self.is_loading {
110 // Defer rebuild until async load completes
111 return;
112 }
113 self.entries = build_file_tree_inner(&self.workspace, &self.expanded_dirs, None);
114 self.clamp_cursor();
115 }
116
117 /// Move the cursor up by one.
118 pub fn cursor_up(&mut self) {
119 if self.cursor > 0 {
120 self.cursor -= 1;
121 }
122 self.clamp_scroll();
123 }
124
125 /// Move the cursor down by one.
126 pub fn cursor_down(&mut self) {
127 if self.cursor + 1 < self.entries.len() {
128 self.cursor += 1;
129 }
130 self.clamp_scroll();
131 }
132
133 /// Activate the entry under the cursor.
134 ///
135 /// Returns `Some(path)` when the entry is a file that should be
136 /// mentioned (`@path` inserted into the composer). Returns `None`
137 /// after toggling a directory expand/collapse.
138 pub fn activate(&mut self) -> Option<PathBuf> {
139 let entry = self.entries.get(self.cursor)?;
140 if entry.is_dir {
141 let norm = normalize_path(&entry.path);
142 if self.expanded_dirs.contains(&norm) {
143 self.expanded_dirs.remove(&norm);
144 } else {
145 self.expanded_dirs.insert(norm);
146 }
147 self.rebuild();
148 None
149 } else {
150 // Return the path relative to workspace.
151 entry.path.strip_prefix(&self.workspace).ok().map(|rel| {
152 let mut p = PathBuf::new();
153 for comp in rel.components() {
154 p.push(comp);
155 }
156 p
157 })
158 }
159 }
160
161 /// Ensure the cursor is within bounds.
162 fn clamp_cursor(&mut self) {
163 if !self.entries.is_empty() && self.cursor >= self.entries.len() {
164 self.cursor = self.entries.len().saturating_sub(1);
165 }
166 }
167
168 /// Ensure the scroll offset keeps the cursor visible.
169 fn clamp_scroll(&mut self) {
170 let visible_height = 20usize; // will be overridden per render
171 if self.cursor < self.scroll_offset {
172 self.scroll_offset = self.cursor;
173 }
174 if self.scroll_offset + visible_height <= self.cursor {
175 self.scroll_offset = self.cursor.saturating_add(1).saturating_sub(visible_height);
176 }
177 }
178
179 /// Adjust scroll for a given visible height.
180 #[allow(dead_code)]
181 pub fn adjust_scroll(&mut self, visible: usize) {
182 if self.cursor < self.scroll_offset {
183 self.scroll_offset = self.cursor;
184 }
185 if visible > 0 && self.cursor >= self.scroll_offset + visible {
186 self.scroll_offset = self.cursor.saturating_add(1).saturating_sub(visible);
187 }
188 }
189 }
190
191 // ---------------------------------------------------------------------------
192 // Tree building
193 // ---------------------------------------------------------------------------
194
195 /// Build the flat visible-entry list.
196 ///
197 /// Walks the workspace directory recursively. Directories in `expanded_dirs`
198 /// have their children included; collapsed directories show only the directory
199 /// entry itself. Entries are sorted: directories first, then files, each group
200 /// alphabetically.
201 fn build_file_tree_inner(
202 workspace: &Path,
203 expanded_dirs: &HashSet<PathBuf>,
204 single_root: Option<&Path>,
205 ) -> Vec<FileTreeEntry> {
206 let mut entries: Vec<FileTreeEntry> = Vec::new();
207
208 // Determine which root to scan.
209 let scan_root = single_root.unwrap_or(workspace);
210
211 // Collect children of `scan_root`.
212 let mut children: Vec<(String, PathBuf, bool)> = Vec::new();
213 if let Ok(read_dir) = std::fs::read_dir(scan_root) {
214 for entry in read_dir.flatten() {
215 let path = entry.path();
216 // Skip well-known ignored directories.
217 if let Some(name) = path.file_name().and_then(|n| n.to_str())
218 && matches!(name, ".git" | "node_modules" | "target" | ".DS_Store")
219 {
220 continue;
221 }
222 let ft = match entry.file_type() {
223 Ok(ft) => ft,
224 Err(_) => continue,
225 };
226 let is_dir = ft.is_dir();
227 let name = path
228 .file_name()
229 .and_then(|n| n.to_str())
230 .map(|n| n.to_string())
231 .unwrap_or_default();
232 children.push((name, path, is_dir));
233 }
234 }
235
236 // Sort: dirs first, then files, alphabetical within each group.
237 children.sort_by(
238 |(a_name, _, a_dir), (b_name, _, b_dir)| match (a_dir, b_dir) {
239 (true, false) => std::cmp::Ordering::Less,
240 (false, true) => std::cmp::Ordering::Greater,
241 _ => a_name.to_lowercase().cmp(&b_name.to_lowercase()),
242 },
243 );
244
245 // Compute depth for the current level.
246 let depth = if single_root.is_some() {
247 let rel = scan_root.strip_prefix(workspace).unwrap_or(scan_root);
248 rel.components().count()
249 } else {
250 0
251 };
252
253 for (name, path, is_dir) in &children {
254 let norm = normalize_path(path);
255 let is_expanded = *is_dir && expanded_dirs.contains(&norm);
256
257 entries.push(FileTreeEntry {
258 name: name.clone(),
259 path: path.clone(),
260 is_dir: *is_dir,
261 depth,
262 expanded: is_expanded,
263 });
264
265 // If it's an expanded directory, recurse.
266 if is_expanded {
267 let sub = build_file_tree_inner(workspace, expanded_dirs, Some(path));
268 entries.extend(sub);
269 }
270 }
271
272 entries
273 }
274
275 /// Normalise a path for use as a HashSet key.
276 fn normalize_path(path: &Path) -> PathBuf {
277 let components: Vec<_> = path.components().collect();
278 // Try to strip workspace prefix.
279 PathBuf::from_iter(components.iter().map(|c| c.as_os_str()))
280 }
281
282 // ---------------------------------------------------------------------------
283 // Rendering
284 // ---------------------------------------------------------------------------
285
286 const FILE_TREE_MIN_WIDTH: u16 = 20;
287
288 /// Render the file tree inside `area`.
289 /// Polls async loading state before rendering (#399 S3).
290 pub fn render_file_tree(f: &mut Frame, area: Rect, state: &mut FileTreeState) {
291 state.poll_loading();
292 if area.width < FILE_TREE_MIN_WIDTH || area.height < 3 {
293 return;
294 }
295
296 let content_width = area.width.saturating_sub(4) as usize;
297 let visible_rows = area.height.saturating_sub(3) as usize;
298
299 let scroll = state.scroll_offset;
300 let max_visible = visible_rows.max(1);
301
302 let mut lines: Vec<Line<'static>> = Vec::with_capacity(max_visible + 1);
303
304 if state.is_loading {
305 lines.push(Line::from(Span::styled(
306 " Building file tree...",
307 Style::default().fg(palette::TEXT_MUTED),
308 )));
309 } else if state.entries.is_empty() {
310 lines.push(Line::from(Span::styled(
311 " (empty)",
312 Style::default().fg(palette::TEXT_MUTED),
313 )));
314 } else {
315 let render_end = (scroll + max_visible).min(state.entries.len());
316 for idx in scroll..render_end {
317 let entry = &state.entries[idx];
318 let is_selected = idx == state.cursor;
319
320 // Build the line prefix: indent + expand/collapse marker + icon.
321 let indent = " ".repeat(entry.depth);
322 let expand_marker = if entry.is_dir {
323 if entry.expanded {
324 "\u{25BC} "
325 } else {
326 "\u{25B6} "
327 } // ▼ / ▶
328 } else {
329 " "
330 };
331 let icon = if entry.is_dir {
332 "\u{1F4C1} "
333 } else {
334 "\u{1F4C4} "
335 }; // 📁 / 📄
336
337 // Build the display text.
338 let raw = format!("{indent}{expand_marker}{icon}{}", entry.name);
339 let display = truncate_line_to_width(&raw, content_width.max(1));
340
341 let style = if is_selected {
342 Style::default()
343 .fg(palette::SELECTION_TEXT)
344 .bg(palette::SELECTION_BG)
345 } else {
346 Style::default().fg(palette::TEXT_PRIMARY)
347 };
348
349 lines.push(Line::from(Span::styled(display, style)));
350 }
351 }
352
353 // Use the same theme as the sidebar for consistent styling.
354 let theme = active_theme();
355 let section = Paragraph::new(lines).wrap(Wrap { trim: false }).block(
356 Block::default()
357 .title(Line::from(Span::styled(
358 " Files ",
359 Style::default().fg(theme.section_title_color).bold(),
360 )))
361 .borders(theme.section_borders)
362 .border_type(theme.section_border_type)
363 .border_style(Style::default().fg(theme.section_border_color))
364 .style(Style::default().bg(theme.section_bg))
365 .padding(theme.section_padding),
366 );
367
368 f.render_widget(section, area);
369 }
370
370 lines RUST