Skip to main content

semantic_diff/ui/
file_tree.rs

1use crate::app::{App, FocusedPanel};
2use crate::grouper::GroupingStatus;
3use ratatui::layout::Rect;
4use ratatui::style::{Color, Modifier, Style};
5use ratatui::text::{Line, Span};
6use ratatui::widgets::Block;
7use ratatui::Frame;
8use tui_tree_widget::{Tree, TreeItem};
9
10/// Identifier for tree nodes.
11/// Files include an optional group index to disambiguate the same file in multiple groups.
12#[derive(Debug, Clone, Hash, Eq, PartialEq)]
13pub enum TreeNodeId {
14    Group(usize),
15    File(Option<usize>, String), // (group_index, path)
16}
17
18impl std::fmt::Display for TreeNodeId {
19    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
20        match self {
21            TreeNodeId::Group(i) => write!(f, "group-{i}"),
22            TreeNodeId::File(Some(gi), path) => write!(f, "file-{gi}-{path}"),
23            TreeNodeId::File(None, path) => write!(f, "file-{path}"),
24        }
25    }
26}
27
28/// Build tree items from current app state.
29/// `sidebar_width` is the total pixel/char width of the sidebar area (including borders).
30pub fn build_tree_items<'a>(app: &App, sidebar_width: u16) -> Vec<TreeItem<'a, TreeNodeId>> {
31    match &app.semantic_groups {
32        Some(groups) => build_grouped_tree(app, groups, sidebar_width),
33        None => build_flat_tree(app, sidebar_width),
34    }
35}
36
37/// Abbreviate directory components in a path to fit within `max_width` characters.
38///
39/// Keeps the filename intact and progressively abbreviates parent directories
40/// (left-to-right) to their first character per hyphen-separated segment.
41///
42/// Example: `src/app/components/sales-assistant/routes.ts` → `s/a/c/s-a/routes.ts`
43fn abbreviate_path(path: &str, max_width: usize) -> String {
44    if path.len() <= max_width || max_width == 0 {
45        return path.to_string();
46    }
47
48    let parts: Vec<&str> = path.split('/').collect();
49    if parts.len() <= 1 {
50        return path.to_string(); // just a filename, can't abbreviate
51    }
52
53    let filename = parts.last().unwrap();
54    let mut dirs: Vec<String> = parts[..parts.len() - 1].iter().map(|s| s.to_string()).collect();
55
56    // Abbreviate directories left-to-right until it fits
57    for i in 0..dirs.len() {
58        let candidate = format!("{}/{}", dirs.join("/"), filename);
59        if candidate.len() <= max_width {
60            return candidate;
61        }
62        // Abbreviate: for hyphenated names like "sales-assistant" → "s-a",
63        // for plain names like "components" → "c"
64        let dir = &dirs[i];
65        if dir.len() > 1 {
66            let abbreviated: String = dir
67                .split('-')
68                .filter_map(|seg| seg.chars().next())
69                .map(|c| c.to_string())
70                .collect::<Vec<_>>()
71                .join("-");
72            dirs[i] = abbreviated;
73        }
74    }
75
76    format!("{}/{}", dirs.join("/"), filename)
77}
78
79/// Build a Line for a file entry in the tree, with optional [U] badge, path abbreviation, and stats.
80fn build_file_line(
81    path: &str,
82    is_untracked: bool,
83    suffix: &str,
84    added: usize,
85    removed: usize,
86    sidebar_width: u16,
87    path_overhead: u16,
88) -> Line<'static> {
89    let badge = if is_untracked { "[U] " } else { "" };
90    let stats = format!("{suffix} +{added} -{removed}");
91    let max_path_width = sidebar_width
92        .saturating_sub(path_overhead)
93        .saturating_sub(stats.len() as u16)
94        .saturating_sub(badge.len() as u16) as usize;
95    let display_path = abbreviate_path(path, max_path_width);
96
97    let mut spans = Vec::new();
98    if is_untracked {
99        spans.push(Span::styled(
100            "[U] ".to_string(),
101            Style::default().fg(Color::Cyan).add_modifier(Modifier::DIM),
102        ));
103    }
104    spans.push(Span::raw(format!("{display_path}{suffix} ")));
105    spans.push(Span::styled(
106        format!("+{added}"),
107        Style::default().fg(Color::Green),
108    ));
109    spans.push(Span::raw(" "));
110    spans.push(Span::styled(
111        format!("-{removed}"),
112        Style::default().fg(Color::Red),
113    ));
114    Line::from(spans)
115}
116
117/// Build a flat list of file items (pre-grouping or when no LLM is available).
118fn build_flat_tree<'a>(app: &App, sidebar_width: u16) -> Vec<TreeItem<'a, TreeNodeId>> {
119    // Available width for path text: sidebar - borders(2) - highlight_symbol(3) - node_symbol(2)
120    let path_overhead: u16 = 2 + 3 + 2;
121
122    app.diff_data
123        .files
124        .iter()
125        .map(|file| {
126            let path = file.target_file.trim_start_matches("b/").to_string();
127            let line = build_file_line(
128                &path, file.is_untracked, "", file.added_count, file.removed_count,
129                sidebar_width, path_overhead,
130            );
131            TreeItem::new_leaf(TreeNodeId::File(None, path), line)
132        })
133        .collect()
134}
135
136/// Build a grouped tree from semantic groups (hunk-level).
137/// Files can appear in multiple groups if their hunks are split.
138fn build_grouped_tree<'a>(
139    app: &App,
140    groups: &[crate::grouper::SemanticGroup],
141    sidebar_width: u16,
142) -> Vec<TreeItem<'a, TreeNodeId>> {
143    let mut all_covered: std::collections::HashMap<String, std::collections::HashSet<usize>> =
144        std::collections::HashMap::new();
145    let mut items: Vec<TreeItem<'a, TreeNodeId>> = Vec::new();
146
147    // Available width for nested file path: sidebar - borders(2) - highlight(3) - node_symbol(2) - indent(2)
148    let nested_path_overhead: u16 = 2 + 3 + 2 + 2;
149
150    for (gi, group) in groups.iter().enumerate() {
151        let mut children: Vec<TreeItem<'a, TreeNodeId>> = Vec::new();
152        let mut group_added: usize = 0;
153        let mut group_removed: usize = 0;
154
155        for change in &group.changes() {
156            if let Some(file) = app.diff_data.files.iter().find(|f| {
157                let diff_path = f.target_file.trim_start_matches("b/");
158                diff_path == change.file || diff_path.ends_with(change.file.as_str())
159            }) {
160                let path = file.target_file.trim_start_matches("b/").to_string();
161
162                // Count lines for the specific hunks in this group
163                let (added, removed) = if change.hunks.is_empty() {
164                    // All hunks
165                    (file.added_count, file.removed_count)
166                } else {
167                    change.hunks.iter().fold((0usize, 0usize), |(a, r), &hi| {
168                        if let Some(hunk) = file.hunks.get(hi) {
169                            let ha = hunk
170                                .lines
171                                .iter()
172                                .filter(|l| l.line_type == crate::diff::LineType::Added)
173                                .count();
174                            let hr = hunk
175                                .lines
176                                .iter()
177                                .filter(|l| l.line_type == crate::diff::LineType::Removed)
178                                .count();
179                            (a + ha, r + hr)
180                        } else {
181                            (a, r)
182                        }
183                    })
184                };
185
186                group_added += added;
187                group_removed += removed;
188
189                // Track covered hunks
190                all_covered
191                    .entry(path.clone())
192                    .or_default()
193                    .extend(change.hunks.iter());
194
195                // Show hunk count if not all hunks
196                let hunk_info = if change.hunks.is_empty() || change.hunks.len() == file.hunks.len()
197                {
198                    String::new()
199                } else {
200                    format!(" ({}/{} hunks)", change.hunks.len(), file.hunks.len())
201                };
202
203                let line = build_file_line(
204                    &path, file.is_untracked, &hunk_info, added, removed,
205                    sidebar_width, nested_path_overhead,
206                );
207                children.push(TreeItem::new_leaf(TreeNodeId::File(Some(gi), path), line));
208            }
209        }
210
211        if !children.is_empty() {
212            let header = Line::from(vec![
213                Span::styled(
214                    format!("{} ", group.label),
215                    Style::default()
216                        .fg(app.theme.tree_group_fg)
217                        .add_modifier(Modifier::BOLD),
218                ),
219                Span::styled(
220                    format!("+{group_added}"),
221                    Style::default().fg(Color::Green),
222                ),
223                Span::raw(" "),
224                Span::styled(
225                    format!("-{group_removed}"),
226                    Style::default().fg(Color::Red),
227                ),
228                Span::styled(
229                    format!(", {} files", children.len()),
230                    Style::default().fg(Color::DarkGray),
231                ),
232            ]);
233            if let Ok(item) = TreeItem::new(TreeNodeId::Group(gi), header, children) {
234                items.push(item);
235            }
236        }
237    }
238
239    // Add "Other" group for hunks not in any semantic group
240    let mut other_children: Vec<TreeItem<'a, TreeNodeId>> = Vec::new();
241    for file in &app.diff_data.files {
242        let path = file.target_file.trim_start_matches("b/").to_string();
243        let covered = all_covered.get(&path);
244
245        let is_other = match covered {
246            None => true, // file not in any group
247            Some(hunk_set) => {
248                // If hunk_set is empty, the LLM said "all hunks" → fully covered
249                if hunk_set.is_empty() {
250                    false
251                } else {
252                    // Check if some hunks are uncovered
253                    (0..file.hunks.len()).any(|hi| !hunk_set.contains(&hi))
254                }
255            }
256        };
257
258        if is_other {
259            let line = build_file_line(
260                &path, file.is_untracked, "", file.added_count, file.removed_count,
261                sidebar_width, nested_path_overhead,
262            );
263            other_children.push(TreeItem::new_leaf(TreeNodeId::File(Some(groups.len()), path), line));
264        }
265    }
266
267    if !other_children.is_empty() {
268        let header = Line::from(vec![Span::styled(
269            format!("Other ({} files)", other_children.len()),
270            Style::default()
271                .fg(Color::DarkGray)
272                .add_modifier(Modifier::BOLD),
273        )]);
274        if let Ok(item) =
275            TreeItem::new(TreeNodeId::Group(groups.len()), header, other_children)
276        {
277            items.push(item);
278        }
279    }
280
281    items
282}
283
284/// Render the file tree sidebar.
285pub fn render_tree(app: &App, frame: &mut Frame, area: Rect) {
286    let items = build_tree_items(app, area.width);
287
288    let title = match app.grouping_status {
289        GroupingStatus::Loading => " Files [grouping...] ",
290        _ => " Files ",
291    };
292
293    let border_style = if app.focused_panel == FocusedPanel::FileTree {
294        Style::default().fg(app.theme.tree_group_fg)
295    } else {
296        Style::default().fg(app.theme.gutter_fg)
297    };
298
299    let tree = match Tree::new(&items) {
300        Ok(tree) => tree
301            .block(
302                Block::bordered()
303                    .title(title)
304                    .border_style(border_style),
305            )
306            .highlight_style(
307                Style::default()
308                    .fg(app.theme.tree_highlight_fg)
309                    .bg(app.theme.tree_highlight_bg)
310                    .add_modifier(Modifier::BOLD),
311            )
312            .highlight_symbol(">> ")
313            .node_closed_symbol("> ")
314            .node_open_symbol("v ")
315            .node_no_children_symbol("  "),
316        Err(_) => return,
317    };
318
319    frame.render_stateful_widget(tree, area, &mut app.tree_state.borrow_mut());
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325
326    #[test]
327    fn test_abbreviate_path_fits() {
328        assert_eq!(abbreviate_path("src/main.rs", 30), "src/main.rs");
329    }
330
331    #[test]
332    fn test_abbreviate_path_short_dirs() {
333        // "src/app/components/routes.ts" = 27 chars
334        // After abbreviating "src" → "s": "s/app/components/routes.ts" = 25
335        // Still > 24, abbreviate "app" → "a": "s/a/components/routes.ts" = 23
336        assert_eq!(
337            abbreviate_path("src/app/components/routes.ts", 24),
338            "s/a/components/routes.ts"
339        );
340    }
341
342    #[test]
343    fn test_abbreviate_path_all_dirs() {
344        assert_eq!(
345            abbreviate_path("src/app/components/routes.ts", 15),
346            "s/a/c/routes.ts"
347        );
348    }
349
350    #[test]
351    fn test_abbreviate_path_hyphenated() {
352        assert_eq!(
353            abbreviate_path("src/app/components/sales-assistant/routes.ts", 20),
354            "s/a/c/s-a/routes.ts"
355        );
356    }
357
358    #[test]
359    fn test_abbreviate_path_single_component() {
360        assert_eq!(abbreviate_path("routes.ts", 5), "routes.ts");
361    }
362
363    #[test]
364    fn test_abbreviate_path_zero_width() {
365        assert_eq!(
366            abbreviate_path("src/main.rs", 0),
367            "src/main.rs"
368        );
369    }
370
371    #[test]
372    fn test_abbreviate_path_already_short() {
373        assert_eq!(abbreviate_path("a/b.rs", 10), "a/b.rs");
374    }
375
376    #[test]
377    fn test_abbreviate_path_exact_fit_after_partial() {
378        // "src/app/main.rs" = 15 chars
379        // After abbreviating first: "s/app/main.rs" = 13 chars
380        assert_eq!(
381            abbreviate_path("src/app/main.rs", 13),
382            "s/app/main.rs"
383        );
384    }
385}