Skip to main content

wisp/components/
file_tree.rs

1use crate::git_diff::{FileDiff, FileStatus};
2
3pub enum FileTreeNode {
4    Directory { name: String, children: Vec<FileTreeNode>, expanded: bool },
5    File { file_index: usize, name: String, status: FileStatus, additions: usize, deletions: usize },
6}
7
8#[derive(Debug, Clone)]
9pub struct FileTreeEntry {
10    pub depth: usize,
11    pub kind: FileTreeEntryKind,
12}
13
14#[derive(Debug, Clone)]
15pub enum FileTreeEntryKind {
16    Directory { name: String, expanded: bool },
17    File { file_index: usize, name: String, status: FileStatus, additions: usize, deletions: usize },
18}
19
20pub struct FileTree {
21    roots: Vec<FileTreeNode>,
22    selected_visible: usize,
23    cached_entries: Option<Vec<FileTreeEntry>>,
24}
25
26impl FileTree {
27    pub fn empty() -> Self {
28        Self { roots: Vec::new(), selected_visible: 0, cached_entries: Some(Vec::new()) }
29    }
30
31    pub fn from_files(files: &[FileDiff]) -> Self {
32        let mut roots: Vec<FileTreeNode> = Vec::new();
33
34        for (idx, file) in files.iter().enumerate() {
35            let parts: Vec<&str> = file.path.split('/').collect();
36            insert_into_tree(&mut roots, &parts, idx, file);
37        }
38
39        sort_tree(&mut roots);
40        compress_paths(&mut roots);
41
42        let mut tree = Self { roots, selected_visible: 0, cached_entries: None };
43        tree.ensure_cache();
44        tree
45    }
46
47    pub fn select_file_index(&mut self, file_index: usize) {
48        self.ensure_cache();
49        let entries = self.cached_entries.as_ref().unwrap();
50        if let Some(pos) = entries
51            .iter()
52            .position(|e| matches!(&e.kind, FileTreeEntryKind::File { file_index: fi, .. } if *fi == file_index))
53        {
54            self.selected_visible = pos;
55        }
56    }
57
58    pub fn visible_entries(&self) -> Vec<FileTreeEntry> {
59        if let Some(entries) = &self.cached_entries {
60            return entries.clone();
61        }
62        let mut entries = Vec::new();
63        for node in &self.roots {
64            collect_visible(node, 0, &mut entries);
65        }
66        entries
67    }
68
69    pub fn selected_visible(&self) -> usize {
70        self.selected_visible
71    }
72
73    pub fn selected_file_index(&self) -> Option<usize> {
74        let entries = self.visible_entries();
75        entries.get(self.selected_visible).and_then(|e| match &e.kind {
76            FileTreeEntryKind::File { file_index, .. } => Some(*file_index),
77            FileTreeEntryKind::Directory { .. } => None,
78        })
79    }
80
81    pub fn navigate(&mut self, delta: isize) {
82        self.ensure_cache();
83        let count = self.cached_entries.as_ref().unwrap().len();
84        if count == 0 {
85            return;
86        }
87        crate::components::wrap_selection(&mut self.selected_visible, count, delta);
88    }
89
90    pub fn collapse_or_parent(&mut self) {
91        self.ensure_cache();
92        let entries = self.cached_entries.as_ref().unwrap();
93        let Some(entry) = entries.get(self.selected_visible) else {
94            return;
95        };
96
97        match &entry.kind {
98            FileTreeEntryKind::Directory { expanded: true, .. } => {
99                toggle_at(&mut self.roots, self.selected_visible);
100                self.invalidate_cache();
101            }
102            _ => {
103                if let Some(parent_idx) = find_parent_dir(entries, self.selected_visible) {
104                    self.selected_visible = parent_idx;
105                }
106            }
107        }
108    }
109
110    pub fn expand_or_enter(&mut self) -> bool {
111        self.ensure_cache();
112        let entries = self.cached_entries.as_ref().unwrap();
113        let Some(entry) = entries.get(self.selected_visible) else {
114            return false;
115        };
116
117        match &entry.kind {
118            FileTreeEntryKind::Directory { expanded: false, .. } => {
119                toggle_at(&mut self.roots, self.selected_visible);
120                self.invalidate_cache();
121                false
122            }
123            FileTreeEntryKind::Directory { expanded: true, .. } => {
124                // Already expanded, move into first child
125                if self.selected_visible + 1 < entries.len() {
126                    self.selected_visible += 1;
127                }
128                false
129            }
130            FileTreeEntryKind::File { .. } => true,
131        }
132    }
133
134    pub fn ensure_cache(&mut self) {
135        if self.cached_entries.is_none() {
136            let mut entries = Vec::new();
137            for node in &self.roots {
138                collect_visible(node, 0, &mut entries);
139            }
140            self.cached_entries = Some(entries);
141        }
142    }
143
144    fn invalidate_cache(&mut self) {
145        self.cached_entries = None;
146    }
147}
148
149fn insert_into_tree(nodes: &mut Vec<FileTreeNode>, parts: &[&str], file_index: usize, file: &FileDiff) {
150    if parts.len() == 1 {
151        nodes.push(FileTreeNode::File {
152            file_index,
153            name: parts[0].to_string(),
154            status: file.status,
155            additions: file.additions(),
156            deletions: file.deletions(),
157        });
158        return;
159    }
160
161    let dir_name = parts[0];
162    let existing = nodes.iter_mut().find(|n| matches!(n, FileTreeNode::Directory { name, .. } if name == dir_name));
163
164    if let Some(FileTreeNode::Directory { children, .. }) = existing {
165        insert_into_tree(children, &parts[1..], file_index, file);
166    } else {
167        let mut children = Vec::new();
168        insert_into_tree(&mut children, &parts[1..], file_index, file);
169        nodes.push(FileTreeNode::Directory { name: dir_name.to_string(), children, expanded: true });
170    }
171}
172
173fn sort_tree(nodes: &mut [FileTreeNode]) {
174    nodes.sort_by(|a, b| {
175        let a_is_dir = matches!(a, FileTreeNode::Directory { .. });
176        let b_is_dir = matches!(b, FileTreeNode::Directory { .. });
177        b_is_dir.cmp(&a_is_dir).then_with(|| node_name(a).cmp(node_name(b)))
178    });
179    for node in nodes.iter_mut() {
180        if let FileTreeNode::Directory { children, .. } = node {
181            sort_tree(children);
182        }
183    }
184}
185
186fn compress_paths(nodes: &mut [FileTreeNode]) {
187    for node in nodes.iter_mut() {
188        loop {
189            let should_compress = matches!(
190                node,
191                FileTreeNode::Directory { children, .. }
192                if children.len() == 1 && matches!(children[0], FileTreeNode::Directory { .. })
193            );
194            if !should_compress {
195                break;
196            }
197            if let FileTreeNode::Directory { name, children, .. } = node {
198                let child = children.remove(0);
199                if let FileTreeNode::Directory {
200                    name: child_name,
201                    children: child_children,
202                    expanded: child_expanded,
203                } = child
204                {
205                    *name = format!("{name}/{child_name}");
206                    *children = child_children;
207                    if let FileTreeNode::Directory { expanded, .. } = node {
208                        *expanded = child_expanded;
209                    }
210                }
211            }
212        }
213        if let FileTreeNode::Directory { children, .. } = node {
214            compress_paths(children);
215        }
216    }
217}
218
219fn node_name(node: &FileTreeNode) -> &str {
220    match node {
221        FileTreeNode::Directory { name, .. } | FileTreeNode::File { name, .. } => name,
222    }
223}
224
225fn collect_visible(node: &FileTreeNode, depth: usize, entries: &mut Vec<FileTreeEntry>) {
226    match node {
227        FileTreeNode::Directory { name, children, expanded } => {
228            entries.push(FileTreeEntry {
229                depth,
230                kind: FileTreeEntryKind::Directory { name: name.clone(), expanded: *expanded },
231            });
232            if *expanded {
233                for child in children {
234                    collect_visible(child, depth + 1, entries);
235                }
236            }
237        }
238        FileTreeNode::File { file_index, name, status, additions, deletions } => {
239            entries.push(FileTreeEntry {
240                depth,
241                kind: FileTreeEntryKind::File {
242                    file_index: *file_index,
243                    name: name.clone(),
244                    status: *status,
245                    additions: *additions,
246                    deletions: *deletions,
247                },
248            });
249        }
250    }
251}
252
253fn toggle_at(nodes: &mut [FileTreeNode], target_visible_idx: usize) {
254    let mut counter = 0;
255    toggle_at_inner(nodes, target_visible_idx, &mut counter);
256}
257
258fn toggle_at_inner(nodes: &mut [FileTreeNode], target: usize, counter: &mut usize) -> bool {
259    for node in nodes.iter_mut() {
260        if *counter == target {
261            if let FileTreeNode::Directory { expanded, .. } = node {
262                *expanded = !*expanded;
263            }
264            return true;
265        }
266        *counter += 1;
267        if let FileTreeNode::Directory { children, expanded: true, .. } = node
268            && toggle_at_inner(children, target, counter)
269        {
270            return true;
271        }
272    }
273    false
274}
275
276fn find_parent_dir(entries: &[FileTreeEntry], idx: usize) -> Option<usize> {
277    let current_depth = entries.get(idx)?.depth;
278    if current_depth == 0 {
279        return None;
280    }
281    (0..idx)
282        .rev()
283        .find(|&i| entries[i].depth < current_depth && matches!(entries[i].kind, FileTreeEntryKind::Directory { .. }))
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289    use crate::git_diff::{FileDiff, FileStatus, Hunk, PatchLine, PatchLineKind};
290
291    fn file(path: &str, status: FileStatus, additions: usize, deletions: usize) -> FileDiff {
292        let mut lines = Vec::new();
293        for i in 0..additions {
294            lines.push(PatchLine {
295                kind: PatchLineKind::Added,
296                text: format!("added {i}"),
297                old_line_no: None,
298                new_line_no: Some(i + 1),
299            });
300        }
301        for i in 0..deletions {
302            lines.push(PatchLine {
303                kind: PatchLineKind::Removed,
304                text: format!("removed {i}"),
305                old_line_no: Some(i + 1),
306                new_line_no: None,
307            });
308        }
309        FileDiff {
310            old_path: None,
311            path: path.to_string(),
312            status,
313            hunks: if lines.is_empty() {
314                vec![]
315            } else {
316                vec![Hunk {
317                    header: "@@ -1 +1 @@".to_string(),
318                    old_start: 1,
319                    old_count: deletions,
320                    new_start: 1,
321                    new_count: additions,
322                    lines,
323                }]
324            },
325            binary: false,
326        }
327    }
328
329    fn modified(path: &str) -> FileDiff {
330        file(path, FileStatus::Modified, 1, 1)
331    }
332
333    fn added(path: &str) -> FileDiff {
334        file(path, FileStatus::Added, 2, 0)
335    }
336
337    #[test]
338    fn from_files_groups_by_directory() {
339        let files = vec![modified("src/a.rs"), modified("src/b.rs"), modified("lib/c.rs")];
340        let tree = FileTree::from_files(&files);
341        let entries = tree.visible_entries();
342        // lib/ dir, c.rs, src/ dir, a.rs, b.rs = 5 entries
343        assert_eq!(entries.len(), 5);
344        assert!(matches!(&entries[0].kind, FileTreeEntryKind::Directory { name, .. } if name == "lib"));
345        assert!(matches!(&entries[1].kind, FileTreeEntryKind::File { name, .. } if name == "c.rs"));
346        assert!(matches!(&entries[2].kind, FileTreeEntryKind::Directory { name, .. } if name == "src"));
347        assert!(matches!(&entries[3].kind, FileTreeEntryKind::File { name, .. } if name == "a.rs"));
348        assert!(matches!(&entries[4].kind, FileTreeEntryKind::File { name, .. } if name == "b.rs"));
349    }
350
351    #[test]
352    fn visible_entries_respects_collapse() {
353        let files = vec![modified("src/a.rs"), modified("src/b.rs")];
354        let mut tree = FileTree::from_files(&files);
355        assert_eq!(tree.visible_entries().len(), 3); // dir + 2 files
356
357        // Collapse the directory
358        toggle_at(&mut tree.roots, 0);
359        tree.invalidate_cache();
360        assert_eq!(tree.visible_entries().len(), 1); // just the dir
361    }
362
363    #[test]
364    fn navigate_wraps() {
365        let files = vec![modified("a.rs"), modified("b.rs")];
366        let mut tree = FileTree::from_files(&files);
367        assert_eq!(tree.selected_visible, 0);
368        tree.navigate(1);
369        assert_eq!(tree.selected_visible, 1);
370        tree.navigate(1);
371        assert_eq!(tree.selected_visible, 0); // wraps
372    }
373
374    #[test]
375    fn collapse_or_parent_collapses_dir() {
376        let files = vec![modified("src/a.rs")];
377        let mut tree = FileTree::from_files(&files);
378        // selected is at dir (index 0)
379        tree.selected_visible = 0;
380        assert_eq!(tree.visible_entries().len(), 2);
381        tree.collapse_or_parent();
382        assert_eq!(tree.visible_entries().len(), 1);
383    }
384
385    #[test]
386    fn collapse_or_parent_moves_to_parent_from_file() {
387        let files = vec![modified("src/a.rs"), modified("src/b.rs")];
388        let mut tree = FileTree::from_files(&files);
389        tree.selected_visible = 1; // first file inside src/
390        tree.collapse_or_parent();
391        assert_eq!(tree.selected_visible, 0); // moved to parent dir
392    }
393
394    #[test]
395    fn expand_or_enter_returns_true_for_file() {
396        let files = vec![modified("a.rs")];
397        let mut tree = FileTree::from_files(&files);
398        assert!(tree.expand_or_enter());
399    }
400
401    #[test]
402    fn expand_or_enter_expands_collapsed_dir() {
403        let files = vec![modified("src/a.rs")];
404        let mut tree = FileTree::from_files(&files);
405        toggle_at(&mut tree.roots, 0); // collapse
406        tree.invalidate_cache();
407        assert_eq!(tree.visible_entries().len(), 1);
408        let result = tree.expand_or_enter();
409        assert!(!result); // not a file
410        assert_eq!(tree.visible_entries().len(), 2); // expanded again
411    }
412
413    #[test]
414    fn path_compression_for_single_child_dirs() {
415        let files = vec![modified("src/deep/nested/file.rs")];
416        let tree = FileTree::from_files(&files);
417        let entries = tree.visible_entries();
418        // Should compress src/deep/nested into single dir node
419        assert_eq!(entries.len(), 2); // compressed dir + file
420        match &entries[0].kind {
421            FileTreeEntryKind::Directory { name, .. } => {
422                assert_eq!(name, "src/deep/nested");
423            }
424            FileTreeEntryKind::File { .. } => panic!("expected directory"),
425        }
426    }
427
428    #[test]
429    fn selected_file_index_returns_none_for_dir() {
430        let files = vec![modified("src/a.rs")];
431        let tree = FileTree::from_files(&files);
432        // Index 0 is the dir
433        assert!(tree.selected_file_index().is_none());
434    }
435
436    #[test]
437    fn selected_file_index_returns_index_for_file() {
438        let files = vec![modified("a.rs")];
439        let tree = FileTree::from_files(&files);
440        assert_eq!(tree.selected_file_index(), Some(0));
441    }
442
443    #[test]
444    fn flat_files_no_grouping() {
445        let files = vec![modified("a.rs"), added("b.rs")];
446        let tree = FileTree::from_files(&files);
447        let entries = tree.visible_entries();
448        assert_eq!(entries.len(), 2);
449        assert!(matches!(&entries[0].kind, FileTreeEntryKind::File { name, .. } if name == "a.rs"));
450        assert!(matches!(&entries[1].kind, FileTreeEntryKind::File { name, .. } if name == "b.rs"));
451    }
452}