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.
29pub fn build_tree_items<'a>(app: &App) -> Vec<TreeItem<'a, TreeNodeId>> {
30    match &app.semantic_groups {
31        Some(groups) => build_grouped_tree(app, groups),
32        None => build_flat_tree(app),
33    }
34}
35
36/// Build a flat list of file items (pre-grouping or when no LLM is available).
37fn build_flat_tree<'a>(app: &App) -> Vec<TreeItem<'a, TreeNodeId>> {
38    app.diff_data
39        .files
40        .iter()
41        .map(|file| {
42            let path = file.target_file.trim_start_matches("b/").to_string();
43            let line = Line::from(vec![
44                Span::raw(format!("{path} ")),
45                Span::styled(
46                    format!("+{}", file.added_count),
47                    Style::default().fg(Color::Green),
48                ),
49                Span::raw(" "),
50                Span::styled(
51                    format!("-{}", file.removed_count),
52                    Style::default().fg(Color::Red),
53                ),
54            ]);
55            TreeItem::new_leaf(TreeNodeId::File(None, path), line)
56        })
57        .collect()
58}
59
60/// Build a grouped tree from semantic groups (hunk-level).
61/// Files can appear in multiple groups if their hunks are split.
62fn build_grouped_tree<'a>(
63    app: &App,
64    groups: &[crate::grouper::SemanticGroup],
65) -> Vec<TreeItem<'a, TreeNodeId>> {
66    let mut all_covered: std::collections::HashMap<String, std::collections::HashSet<usize>> =
67        std::collections::HashMap::new();
68    let mut items: Vec<TreeItem<'a, TreeNodeId>> = Vec::new();
69
70    for (gi, group) in groups.iter().enumerate() {
71        let mut children: Vec<TreeItem<'a, TreeNodeId>> = Vec::new();
72        let mut group_added: usize = 0;
73        let mut group_removed: usize = 0;
74
75        for change in &group.changes() {
76            if let Some(file) = app.diff_data.files.iter().find(|f| {
77                let diff_path = f.target_file.trim_start_matches("b/");
78                diff_path == change.file || diff_path.ends_with(change.file.as_str())
79            }) {
80                let path = file.target_file.trim_start_matches("b/").to_string();
81
82                // Count lines for the specific hunks in this group
83                let (added, removed) = if change.hunks.is_empty() {
84                    // All hunks
85                    (file.added_count, file.removed_count)
86                } else {
87                    change.hunks.iter().fold((0usize, 0usize), |(a, r), &hi| {
88                        if let Some(hunk) = file.hunks.get(hi) {
89                            let ha = hunk
90                                .lines
91                                .iter()
92                                .filter(|l| l.line_type == crate::diff::LineType::Added)
93                                .count();
94                            let hr = hunk
95                                .lines
96                                .iter()
97                                .filter(|l| l.line_type == crate::diff::LineType::Removed)
98                                .count();
99                            (a + ha, r + hr)
100                        } else {
101                            (a, r)
102                        }
103                    })
104                };
105
106                group_added += added;
107                group_removed += removed;
108
109                // Track covered hunks
110                all_covered
111                    .entry(path.clone())
112                    .or_default()
113                    .extend(change.hunks.iter());
114
115                // Show hunk count if not all hunks
116                let hunk_info = if change.hunks.is_empty() || change.hunks.len() == file.hunks.len()
117                {
118                    String::new()
119                } else {
120                    format!(" ({}/{} hunks)", change.hunks.len(), file.hunks.len())
121                };
122
123                let line = Line::from(vec![
124                    Span::raw(format!("{path}{hunk_info} ")),
125                    Span::styled(
126                        format!("+{added}"),
127                        Style::default().fg(Color::Green),
128                    ),
129                    Span::raw(" "),
130                    Span::styled(
131                        format!("-{removed}"),
132                        Style::default().fg(Color::Red),
133                    ),
134                ]);
135                children.push(TreeItem::new_leaf(TreeNodeId::File(Some(gi), path), line));
136            }
137        }
138
139        if !children.is_empty() {
140            let header = Line::from(vec![
141                Span::styled(
142                    format!("{} ", group.label),
143                    Style::default()
144                        .fg(app.theme.tree_group_fg)
145                        .add_modifier(Modifier::BOLD),
146                ),
147                Span::styled(
148                    format!("+{group_added}"),
149                    Style::default().fg(Color::Green),
150                ),
151                Span::raw(" "),
152                Span::styled(
153                    format!("-{group_removed}"),
154                    Style::default().fg(Color::Red),
155                ),
156                Span::styled(
157                    format!(", {} files", children.len()),
158                    Style::default().fg(Color::DarkGray),
159                ),
160            ]);
161            if let Ok(item) = TreeItem::new(TreeNodeId::Group(gi), header, children) {
162                items.push(item);
163            }
164        }
165    }
166
167    // Add "Other" group for hunks not in any semantic group
168    let mut other_children: Vec<TreeItem<'a, TreeNodeId>> = Vec::new();
169    for file in &app.diff_data.files {
170        let path = file.target_file.trim_start_matches("b/").to_string();
171        let covered = all_covered.get(&path);
172
173        let is_other = match covered {
174            None => true, // file not in any group
175            Some(hunk_set) => {
176                // If hunk_set is empty, the LLM said "all hunks" → fully covered
177                if hunk_set.is_empty() {
178                    false
179                } else {
180                    // Check if some hunks are uncovered
181                    (0..file.hunks.len()).any(|hi| !hunk_set.contains(&hi))
182                }
183            }
184        };
185
186        if is_other {
187            let line = Line::from(vec![
188                Span::raw(format!("{path} ")),
189                Span::styled(
190                    format!("+{}", file.added_count),
191                    Style::default().fg(Color::Green),
192                ),
193                Span::raw(" "),
194                Span::styled(
195                    format!("-{}", file.removed_count),
196                    Style::default().fg(Color::Red),
197                ),
198            ]);
199            other_children.push(TreeItem::new_leaf(TreeNodeId::File(Some(groups.len()), path), line));
200        }
201    }
202
203    if !other_children.is_empty() {
204        let header = Line::from(vec![Span::styled(
205            format!("Other ({} files)", other_children.len()),
206            Style::default()
207                .fg(Color::DarkGray)
208                .add_modifier(Modifier::BOLD),
209        )]);
210        if let Ok(item) =
211            TreeItem::new(TreeNodeId::Group(groups.len()), header, other_children)
212        {
213            items.push(item);
214        }
215    }
216
217    items
218}
219
220/// Render the file tree sidebar.
221pub fn render_tree(app: &App, frame: &mut Frame, area: Rect) {
222    let items = build_tree_items(app);
223
224    let title = match app.grouping_status {
225        GroupingStatus::Loading => " Files [grouping...] ",
226        _ => " Files ",
227    };
228
229    let border_style = if app.focused_panel == FocusedPanel::FileTree {
230        Style::default().fg(app.theme.tree_group_fg)
231    } else {
232        Style::default().fg(app.theme.gutter_fg)
233    };
234
235    let tree = match Tree::new(&items) {
236        Ok(tree) => tree
237            .block(
238                Block::bordered()
239                    .title(title)
240                    .border_style(border_style),
241            )
242            .highlight_style(
243                Style::default()
244                    .fg(app.theme.tree_highlight_fg)
245                    .bg(app.theme.tree_highlight_bg)
246                    .add_modifier(Modifier::BOLD),
247            )
248            .highlight_symbol(">> ")
249            .node_closed_symbol("> ")
250            .node_open_symbol("v ")
251            .node_no_children_symbol("  "),
252        Err(_) => return,
253    };
254
255    frame.render_stateful_widget(tree, area, &mut app.tree_state.borrow_mut());
256}