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 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 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); toggle_at(&mut tree.roots, 0);
359 tree.invalidate_cache();
360 assert_eq!(tree.visible_entries().len(), 1); }
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); }
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 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; tree.collapse_or_parent();
391 assert_eq!(tree.selected_visible, 0); }
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); tree.invalidate_cache();
407 assert_eq!(tree.visible_entries().len(), 1);
408 let result = tree.expand_or_enter();
409 assert!(!result); assert_eq!(tree.visible_entries().len(), 2); }
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 assert_eq!(entries.len(), 2); 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 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}