Rust · 13959 bytes Raw Blame History
1 //! File tree data structure
2
3 #![allow(dead_code)]
4
5 use std::path::{Path, PathBuf};
6 use std::fs;
7 use std::process::Command;
8 use std::collections::HashMap;
9
10 /// Git status for a file
11 #[derive(Debug, Clone, Default)]
12 pub struct GitStatus {
13 /// File is staged (added to index)
14 pub staged: bool,
15 /// File has unstaged changes
16 pub unstaged: bool,
17 /// File is untracked
18 pub untracked: bool,
19 /// File has incoming changes (after fetch)
20 pub incoming: bool,
21 /// File is gitignored
22 pub gitignored: bool,
23 }
24
25 /// A node in the file tree
26 #[derive(Debug, Clone)]
27 pub struct TreeNode {
28 /// File/directory name
29 pub name: String,
30 /// Full path
31 pub path: PathBuf,
32 /// Is this a directory?
33 pub is_dir: bool,
34 /// Is directory expanded?
35 pub expanded: bool,
36 /// Children (only for directories)
37 pub children: Vec<TreeNode>,
38 /// Depth in tree (for indentation)
39 pub depth: usize,
40 /// Git status for this file
41 pub git_status: GitStatus,
42 }
43
44 impl TreeNode {
45 /// Create a new tree node
46 pub fn new(path: PathBuf, depth: usize) -> Self {
47 let name = path
48 .file_name()
49 .and_then(|n| n.to_str())
50 .unwrap_or("")
51 .to_string();
52 let is_dir = path.is_dir();
53
54 Self {
55 name,
56 path,
57 is_dir,
58 expanded: depth == 0, // Root is expanded by default
59 children: Vec::new(),
60 depth,
61 git_status: GitStatus::default(),
62 }
63 }
64
65 /// Check if this is a hidden file (starts with .)
66 pub fn is_hidden(&self) -> bool {
67 self.name.starts_with('.')
68 }
69
70 /// Load children for a directory
71 pub fn load_children(&mut self, show_hidden: bool) {
72 if !self.is_dir {
73 return;
74 }
75
76 self.children.clear();
77
78 if let Ok(entries) = fs::read_dir(&self.path) {
79 let mut children: Vec<TreeNode> = entries
80 .filter_map(|e| e.ok())
81 .filter(|e| {
82 let name = e.file_name();
83 let name_str = name.to_string_lossy();
84 show_hidden || !name_str.starts_with('.')
85 })
86 .map(|e| TreeNode::new(e.path(), self.depth + 1))
87 .collect();
88
89 // Sort: directories first, then alphabetically
90 children.sort_by(|a, b| {
91 match (a.is_dir, b.is_dir) {
92 (true, false) => std::cmp::Ordering::Less,
93 (false, true) => std::cmp::Ordering::Greater,
94 _ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
95 }
96 });
97
98 self.children = children;
99 }
100 }
101
102 /// Toggle expanded state
103 pub fn toggle_expand(&mut self) {
104 if self.is_dir {
105 self.expanded = !self.expanded;
106 if self.expanded && self.children.is_empty() {
107 self.load_children(false);
108 }
109 }
110 }
111 }
112
113 /// File tree for the workspace
114 #[derive(Debug)]
115 pub struct FileTree {
116 /// Root node
117 pub root: TreeNode,
118 /// Show hidden files
119 pub show_hidden: bool,
120 /// Flattened visible items (for rendering and navigation)
121 visible_items: Vec<VisibleItem>,
122 }
123
124 /// A visible item in the flattened tree
125 #[derive(Debug, Clone)]
126 pub struct VisibleItem {
127 /// Path to the item
128 pub path: PathBuf,
129 /// Display name
130 pub name: String,
131 /// Is directory
132 pub is_dir: bool,
133 /// Is expanded (for directories)
134 pub expanded: bool,
135 /// Indentation depth
136 pub depth: usize,
137 /// Git status
138 pub git_status: GitStatus,
139 }
140
141 impl FileTree {
142 /// Create a new file tree rooted at the given path
143 pub fn new(root_path: &Path) -> Self {
144 let mut root = TreeNode::new(root_path.to_path_buf(), 0);
145 root.load_children(false);
146
147 let mut tree = Self {
148 root,
149 show_hidden: false,
150 visible_items: Vec::new(),
151 };
152 tree.rebuild_visible();
153 tree
154 }
155
156 /// Rebuild the flattened visible items list
157 pub fn rebuild_visible(&mut self) {
158 self.visible_items.clear();
159 self.collect_visible(&self.root.clone());
160 }
161
162 fn collect_visible(&mut self, node: &TreeNode) {
163 // Don't include root in visible items, but process its children
164 if node.depth > 0 {
165 self.visible_items.push(VisibleItem {
166 path: node.path.clone(),
167 name: node.name.clone(),
168 is_dir: node.is_dir,
169 expanded: node.expanded,
170 depth: node.depth,
171 git_status: node.git_status.clone(),
172 });
173 }
174
175 if node.is_dir && (node.expanded || node.depth == 0) {
176 for child in &node.children {
177 self.collect_visible(child);
178 }
179 }
180 }
181
182 /// Get visible items
183 pub fn visible_items(&self) -> &[VisibleItem] {
184 &self.visible_items
185 }
186
187 /// Number of visible items
188 pub fn len(&self) -> usize {
189 self.visible_items.len()
190 }
191
192 /// Toggle expand/collapse at index
193 pub fn toggle_at(&mut self, index: usize) {
194 if index >= self.visible_items.len() {
195 return;
196 }
197
198 let path = self.visible_items[index].path.clone();
199 self.toggle_path(&path);
200 self.rebuild_visible();
201 }
202
203 fn toggle_path(&mut self, path: &Path) {
204 Self::toggle_path_recursive(&mut self.root, path);
205 }
206
207 fn toggle_path_recursive(node: &mut TreeNode, path: &Path) -> bool {
208 if node.path == path {
209 node.toggle_expand();
210 return true;
211 }
212
213 for child in &mut node.children {
214 if Self::toggle_path_recursive(child, path) {
215 return true;
216 }
217 }
218
219 false
220 }
221
222 /// Get path at index
223 pub fn path_at(&self, index: usize) -> Option<&Path> {
224 self.visible_items.get(index).map(|i| i.path.as_path())
225 }
226
227 /// Check if item at index is a directory
228 pub fn is_dir_at(&self, index: usize) -> bool {
229 self.visible_items.get(index).map(|i| i.is_dir).unwrap_or(false)
230 }
231
232 /// Toggle showing hidden files
233 pub fn toggle_hidden(&mut self) {
234 self.show_hidden = !self.show_hidden;
235 self.reload();
236 }
237
238 /// Reload tree from disk
239 pub fn reload(&mut self) {
240 Self::reload_node(&mut self.root, self.show_hidden);
241 self.rebuild_visible();
242 }
243
244 fn reload_node(node: &mut TreeNode, show_hidden: bool) {
245 if node.is_dir && node.expanded {
246 node.load_children(show_hidden);
247 for child in &mut node.children {
248 Self::reload_node(child, show_hidden);
249 }
250 }
251 }
252
253 /// Update git status for all files in the tree
254 pub fn update_git_status(&mut self) {
255 let root_path = self.root.path.clone();
256 let status_map = get_git_status(&root_path);
257 Self::apply_git_status(&mut self.root, &status_map, &root_path);
258 // Smart collapse: only expand directories with dirty files
259 Self::smart_collapse_node(&mut self.root, true);
260 self.rebuild_visible();
261 }
262
263 fn apply_git_status(node: &mut TreeNode, status_map: &HashMap<PathBuf, GitStatus>, root: &Path) {
264 // Get relative path from root
265 if let Ok(rel_path) = node.path.strip_prefix(root) {
266 if let Some(status) = status_map.get(rel_path) {
267 node.git_status = status.clone();
268 } else {
269 node.git_status = GitStatus::default();
270 }
271 }
272
273 // Recurse into children
274 for child in &mut node.children {
275 Self::apply_git_status(child, status_map, root);
276 }
277 }
278
279 /// Check if tree has any dirty files (staged, unstaged, or untracked)
280 pub fn has_dirty_files(&self) -> bool {
281 Self::node_has_dirty(&self.root)
282 }
283
284 fn node_has_dirty(node: &TreeNode) -> bool {
285 if node.git_status.staged || node.git_status.unstaged || node.git_status.untracked {
286 return true;
287 }
288 for child in &node.children {
289 if Self::node_has_dirty(child) {
290 return true;
291 }
292 }
293 false
294 }
295
296 /// Smart collapse: Only expand directories that contain dirty files
297 /// Root is always expanded
298 pub fn smart_collapse(&mut self) {
299 Self::smart_collapse_node(&mut self.root, true);
300 self.rebuild_visible();
301 }
302
303 /// Returns true if node or any descendant has dirty files
304 fn smart_collapse_node(node: &mut TreeNode, is_root: bool) -> bool {
305 if !node.is_dir {
306 // Files: return whether they're dirty
307 return node.git_status.staged || node.git_status.unstaged || node.git_status.untracked;
308 }
309
310 // Directory: check all children first
311 let mut has_dirty_descendant = false;
312 for child in &mut node.children {
313 if Self::smart_collapse_node(child, false) {
314 has_dirty_descendant = true;
315 }
316 }
317
318 // Root stays expanded, others only expand if they have dirty descendants
319 if is_root {
320 node.expanded = true;
321 } else {
322 node.expanded = has_dirty_descendant;
323 }
324
325 has_dirty_descendant
326 }
327 }
328
329 /// Parse git status --porcelain output and return a map of file paths to git status
330 fn get_git_status(root: &Path) -> HashMap<PathBuf, GitStatus> {
331 let mut status_map = HashMap::new();
332
333 // Run git status --porcelain
334 let output = Command::new("git")
335 .arg("-C")
336 .arg(root)
337 .arg("status")
338 .arg("--porcelain")
339 .output();
340
341 if let Ok(output) = output {
342 if output.status.success() {
343 let stdout = String::from_utf8_lossy(&output.stdout);
344 for line in stdout.lines() {
345 if line.len() < 4 {
346 continue;
347 }
348
349 // Format: XY filename
350 // X = index status, Y = worktree status
351 let index_status = line.chars().next().unwrap_or(' ');
352 let worktree_status = line.chars().nth(1).unwrap_or(' ');
353 let filename = line[3..].trim();
354
355 // Handle renamed files (format: "R old -> new")
356 let filename = if filename.contains(" -> ") {
357 filename.split(" -> ").last().unwrap_or(filename)
358 } else {
359 filename
360 };
361
362 let path = PathBuf::from(filename);
363 let mut status = GitStatus::default();
364
365 // Check for ignored (!! status)
366 if index_status == '!' && worktree_status == '!' {
367 status.gitignored = true;
368 }
369 // Check for untracked
370 else if index_status == '?' && worktree_status == '?' {
371 status.untracked = true;
372 } else {
373 // Staged: any non-space, non-? in index position
374 if index_status != ' ' && index_status != '?' {
375 status.staged = true;
376 }
377 // Unstaged: any non-space, non-? in worktree position
378 if worktree_status != ' ' && worktree_status != '?' {
379 status.unstaged = true;
380 }
381 }
382
383 status_map.insert(path, status);
384 }
385 }
386 }
387
388 // Also get ignored files using --ignored flag
389 let output = Command::new("git")
390 .arg("-C")
391 .arg(root)
392 .arg("status")
393 .arg("--porcelain")
394 .arg("--ignored")
395 .output();
396
397 if let Ok(output) = output {
398 if output.status.success() {
399 let stdout = String::from_utf8_lossy(&output.stdout);
400 for line in stdout.lines() {
401 if line.len() < 4 {
402 continue;
403 }
404
405 let index_status = line.chars().next().unwrap_or(' ');
406 let worktree_status = line.chars().nth(1).unwrap_or(' ');
407
408 // !! means ignored
409 if index_status == '!' && worktree_status == '!' {
410 let filename = line[3..].trim();
411 let path = PathBuf::from(filename);
412
413 // Only add if not already in map with other status
414 status_map.entry(path).or_insert_with(|| {
415 let mut s = GitStatus::default();
416 s.gitignored = true;
417 s
418 });
419 }
420 }
421 }
422 }
423
424 // Get files with incoming changes (differ from upstream)
425 // Use git diff --name-only @{u}...HEAD to see files changed in upstream but not in local
426 let output = Command::new("git")
427 .arg("-C")
428 .arg(root)
429 .arg("diff")
430 .arg("--name-only")
431 .arg("HEAD...@{u}")
432 .output();
433
434 if let Ok(output) = output {
435 if output.status.success() {
436 let stdout = String::from_utf8_lossy(&output.stdout);
437 for line in stdout.lines() {
438 let filename = line.trim();
439 if filename.is_empty() {
440 continue;
441 }
442 let path = PathBuf::from(filename);
443
444 // Mark as having incoming changes
445 status_map
446 .entry(path)
447 .and_modify(|s| s.incoming = true)
448 .or_insert_with(|| {
449 let mut s = GitStatus::default();
450 s.incoming = true;
451 s
452 });
453 }
454 }
455 // If command fails (no upstream), that's fine - just no incoming indicators
456 }
457
458 status_map
459 }
460