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