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, Text};
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/// Wrap a sequence of styled spans into multiple `Line`s that fit within `max_width`.
80/// Uses word-level wrapping (splits on spaces). Continuation lines are indented by `indent` spaces.
81fn wrap_spans(spans: Vec<Span<'static>>, max_width: usize, indent: usize) -> Text<'static> {
82    if max_width == 0 {
83        return Text::from(Line::from(spans));
84    }
85
86    let total_len: usize = spans.iter().map(|s| s.content.len()).sum();
87    if total_len <= max_width {
88        return Text::from(Line::from(spans));
89    }
90
91    // Flatten all spans into word-level tokens preserving styles
92    struct StyledWord {
93        text: String,
94        style: Style,
95    }
96    let mut words: Vec<StyledWord> = Vec::new();
97    for span in &spans {
98        let style = span.style;
99        let content = span.content.as_ref();
100        // Split into segments preserving spaces as trailing chars on words
101        let mut start = 0;
102        for (i, ch) in content.char_indices() {
103            if ch == ' ' {
104                // Include this space with the preceding word
105                let end = i + 1;
106                if end > start {
107                    words.push(StyledWord {
108                        text: content[start..end].to_string(),
109                        style,
110                    });
111                    start = end;
112                }
113            }
114        }
115        if start < content.len() {
116            words.push(StyledWord {
117                text: content[start..].to_string(),
118                style,
119            });
120        }
121    }
122
123    let mut lines: Vec<Line<'static>> = Vec::new();
124    let mut current_spans: Vec<Span<'static>> = Vec::new();
125    let mut current_len: usize = 0;
126    let mut is_first_line = true;
127
128    for word in words {
129        let word_len = word.text.len();
130        let line_max = if is_first_line { max_width } else { max_width.saturating_sub(indent) };
131
132        if current_len + word_len <= line_max || current_len == 0 {
133            // Fits on current line, or it's the first word on the line (must place it)
134            current_spans.push(Span::styled(word.text, word.style));
135            current_len += word_len;
136        } else {
137            // Wrap to next line
138            // Trim trailing space from last span on current line
139            if let Some(last) = current_spans.last_mut() {
140                let trimmed = last.content.trim_end().to_string();
141                *last = Span::styled(trimmed, last.style);
142            }
143            lines.push(Line::from(std::mem::take(&mut current_spans)));
144            is_first_line = false;
145            current_len = 0;
146
147            // Add indent for continuation
148            let indent_str = " ".repeat(indent);
149            current_len += indent;
150            current_spans.push(Span::raw(indent_str));
151
152            current_spans.push(Span::styled(word.text, word.style));
153            current_len += word_len;
154        }
155    }
156
157    if !current_spans.is_empty() {
158        lines.push(Line::from(current_spans));
159    }
160
161    Text::from(lines)
162}
163
164/// Build a Line for a file entry in the tree, with optional [U] badge, path abbreviation, and stats.
165fn build_file_text(
166    path: &str,
167    is_untracked: bool,
168    suffix: &str,
169    added: usize,
170    removed: usize,
171    sidebar_width: u16,
172    path_overhead: u16,
173) -> Line<'static> {
174    let badge = if is_untracked { "[U] " } else { "" };
175    let stats = format!("{suffix} +{added} -{removed}");
176    let max_path_width = sidebar_width
177        .saturating_sub(path_overhead)
178        .saturating_sub(stats.len() as u16)
179        .saturating_sub(badge.len() as u16) as usize;
180    let display_path = abbreviate_path(path, max_path_width);
181
182    let mut spans = Vec::new();
183    if is_untracked {
184        spans.push(Span::styled(
185            "[U] ".to_string(),
186            Style::default().fg(Color::Cyan).add_modifier(Modifier::DIM),
187        ));
188    }
189    spans.push(Span::raw(format!("{display_path}{suffix} ")));
190    spans.push(Span::styled(
191        format!("+{added}"),
192        Style::default().fg(Color::Green),
193    ));
194    spans.push(Span::raw(" "));
195    spans.push(Span::styled(
196        format!("-{removed}"),
197        Style::default().fg(Color::Red),
198    ));
199    Line::from(spans)
200}
201
202/// Build a flat list of file items (pre-grouping or when no LLM is available).
203fn build_flat_tree<'a>(app: &App, sidebar_width: u16) -> Vec<TreeItem<'a, TreeNodeId>> {
204    // Available width for path text: sidebar - borders(2) - highlight_symbol(3) - node_symbol(2)
205    let path_overhead: u16 = 2 + 3 + 2;
206
207    app.diff_data
208        .files
209        .iter()
210        .map(|file| {
211            let path = file.target_file.trim_start_matches("b/").to_string();
212            let line = build_file_text(
213                &path, file.is_untracked, "", file.added_count, file.removed_count,
214                sidebar_width, path_overhead,
215            );
216            TreeItem::new_leaf(TreeNodeId::File(None, path), line)
217        })
218        .collect()
219}
220
221/// Build a grouped tree from semantic groups (hunk-level).
222/// Files can appear in multiple groups if their hunks are split.
223fn build_grouped_tree<'a>(
224    app: &App,
225    groups: &[crate::grouper::SemanticGroup],
226    sidebar_width: u16,
227) -> Vec<TreeItem<'a, TreeNodeId>> {
228    let mut all_covered: std::collections::HashMap<String, std::collections::HashSet<usize>> =
229        std::collections::HashMap::new();
230    let mut items: Vec<TreeItem<'a, TreeNodeId>> = Vec::new();
231
232    // Available width for nested file path: sidebar - borders(2) - highlight(3) - node_symbol(2) - indent(2)
233    let nested_path_overhead: u16 = 2 + 3 + 2 + 2;
234
235    // Available width for group header: sidebar - borders(2) - highlight(3) - node_symbol(2)
236    let group_overhead: u16 = 2 + 3 + 2;
237
238    for (gi, group) in groups.iter().enumerate() {
239        let mut children: Vec<TreeItem<'a, TreeNodeId>> = Vec::new();
240        let mut group_added: usize = 0;
241        let mut group_removed: usize = 0;
242
243        for change in &group.changes() {
244            if let Some(file) = app.diff_data.files.iter().find(|f| {
245                let diff_path = f.target_file.trim_start_matches("b/");
246                diff_path == change.file || diff_path.ends_with(change.file.as_str())
247            }) {
248                let path = file.target_file.trim_start_matches("b/").to_string();
249
250                // Count lines for the specific hunks in this group
251                let (added, removed) = if change.hunks.is_empty() {
252                    // All hunks
253                    (file.added_count, file.removed_count)
254                } else {
255                    change.hunks.iter().fold((0usize, 0usize), |(a, r), &hi| {
256                        if let Some(hunk) = file.hunks.get(hi) {
257                            let ha = hunk
258                                .lines
259                                .iter()
260                                .filter(|l| l.line_type == crate::diff::LineType::Added)
261                                .count();
262                            let hr = hunk
263                                .lines
264                                .iter()
265                                .filter(|l| l.line_type == crate::diff::LineType::Removed)
266                                .count();
267                            (a + ha, r + hr)
268                        } else {
269                            (a, r)
270                        }
271                    })
272                };
273
274                group_added += added;
275                group_removed += removed;
276
277                // Track covered hunks
278                all_covered
279                    .entry(path.clone())
280                    .or_default()
281                    .extend(change.hunks.iter());
282
283                // Show hunk count if not all hunks
284                let hunk_info = if change.hunks.is_empty() || change.hunks.len() == file.hunks.len()
285                {
286                    String::new()
287                } else {
288                    format!(" ({}/{} hunks)", change.hunks.len(), file.hunks.len())
289                };
290
291                let line = build_file_text(
292                    &path, file.is_untracked, &hunk_info, added, removed,
293                    sidebar_width, nested_path_overhead,
294                );
295                children.push(TreeItem::new_leaf(TreeNodeId::File(Some(gi), path), line));
296            }
297        }
298
299        if !children.is_empty() {
300            let header_spans = vec![
301                Span::styled(
302                    format!("{} ", group.label),
303                    Style::default()
304                        .fg(app.theme.tree_group_fg)
305                        .add_modifier(Modifier::BOLD),
306                ),
307                Span::styled(
308                    format!("+{group_added}"),
309                    Style::default().fg(Color::Green),
310                ),
311                Span::raw(" "),
312                Span::styled(
313                    format!("-{group_removed}"),
314                    Style::default().fg(Color::Red),
315                ),
316                Span::styled(
317                    format!(", {} files", children.len()),
318                    Style::default().fg(Color::DarkGray),
319                ),
320            ];
321            let available_width = sidebar_width.saturating_sub(group_overhead) as usize;
322            let header = wrap_spans(header_spans, available_width, 2);
323            if let Ok(item) = TreeItem::new(TreeNodeId::Group(gi), header, children) {
324                items.push(item);
325            }
326        }
327    }
328
329    // Add "Other" group for hunks not in any semantic group
330    let mut other_children: Vec<TreeItem<'a, TreeNodeId>> = Vec::new();
331    for file in &app.diff_data.files {
332        let path = file.target_file.trim_start_matches("b/").to_string();
333        let covered = all_covered.get(&path);
334
335        let is_other = match covered {
336            None => true, // file not in any group
337            Some(hunk_set) => {
338                // If hunk_set is empty, the LLM said "all hunks" → fully covered
339                if hunk_set.is_empty() {
340                    false
341                } else {
342                    // Check if some hunks are uncovered
343                    (0..file.hunks.len()).any(|hi| !hunk_set.contains(&hi))
344                }
345            }
346        };
347
348        if is_other {
349            let line = build_file_text(
350                &path, file.is_untracked, "", file.added_count, file.removed_count,
351                sidebar_width, nested_path_overhead,
352            );
353            other_children.push(TreeItem::new_leaf(TreeNodeId::File(Some(groups.len()), path), line));
354        }
355    }
356
357    if !other_children.is_empty() {
358        let header = Line::from(vec![Span::styled(
359            format!("Other ({} files)", other_children.len()),
360            Style::default()
361                .fg(Color::DarkGray)
362                .add_modifier(Modifier::BOLD),
363        )]);
364        if let Ok(item) =
365            TreeItem::new(TreeNodeId::Group(groups.len()), header, other_children)
366        {
367            items.push(item);
368        }
369    }
370
371    items
372}
373
374/// Render the file tree sidebar.
375pub fn render_tree(app: &App, frame: &mut Frame, area: Rect) {
376    let items = build_tree_items(app, area.width);
377
378    let title = match app.grouping_status {
379        GroupingStatus::Loading => " Files [grouping...] ",
380        _ => " Files ",
381    };
382
383    let border_style = if app.focused_panel == FocusedPanel::FileTree {
384        Style::default().fg(app.theme.tree_group_fg)
385    } else {
386        Style::default().fg(app.theme.gutter_fg)
387    };
388
389    let tree = match Tree::new(&items) {
390        Ok(tree) => tree
391            .block(
392                Block::bordered()
393                    .title(title)
394                    .border_style(border_style),
395            )
396            .highlight_style(
397                Style::default()
398                    .fg(app.theme.tree_highlight_fg)
399                    .bg(app.theme.tree_highlight_bg)
400                    .add_modifier(Modifier::BOLD),
401            )
402            .highlight_symbol(">> ")
403            .node_closed_symbol("> ")
404            .node_open_symbol("v ")
405            .node_no_children_symbol("  "),
406        Err(_) => return,
407    };
408
409    frame.render_stateful_widget(tree, area, &mut app.tree_state.borrow_mut());
410}
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415
416    #[test]
417    fn test_abbreviate_path_fits() {
418        assert_eq!(abbreviate_path("src/main.rs", 30), "src/main.rs");
419    }
420
421    #[test]
422    fn test_abbreviate_path_short_dirs() {
423        // "src/app/components/routes.ts" = 27 chars
424        // After abbreviating "src" → "s": "s/app/components/routes.ts" = 25
425        // Still > 24, abbreviate "app" → "a": "s/a/components/routes.ts" = 23
426        assert_eq!(
427            abbreviate_path("src/app/components/routes.ts", 24),
428            "s/a/components/routes.ts"
429        );
430    }
431
432    #[test]
433    fn test_abbreviate_path_all_dirs() {
434        assert_eq!(
435            abbreviate_path("src/app/components/routes.ts", 15),
436            "s/a/c/routes.ts"
437        );
438    }
439
440    #[test]
441    fn test_abbreviate_path_hyphenated() {
442        assert_eq!(
443            abbreviate_path("src/app/components/sales-assistant/routes.ts", 20),
444            "s/a/c/s-a/routes.ts"
445        );
446    }
447
448    #[test]
449    fn test_abbreviate_path_single_component() {
450        assert_eq!(abbreviate_path("routes.ts", 5), "routes.ts");
451    }
452
453    #[test]
454    fn test_abbreviate_path_zero_width() {
455        assert_eq!(
456            abbreviate_path("src/main.rs", 0),
457            "src/main.rs"
458        );
459    }
460
461    #[test]
462    fn test_abbreviate_path_already_short() {
463        assert_eq!(abbreviate_path("a/b.rs", 10), "a/b.rs");
464    }
465
466    #[test]
467    fn test_abbreviate_path_exact_fit_after_partial() {
468        // "src/app/main.rs" = 15 chars
469        // After abbreviating first: "s/app/main.rs" = 13 chars
470        assert_eq!(
471            abbreviate_path("src/app/main.rs", 13),
472            "s/app/main.rs"
473        );
474    }
475}