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#[derive(Debug, Clone, Hash, Eq, PartialEq)]
13pub enum TreeNodeId {
14 Group(usize),
15 File(Option<usize>, String), }
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
28pub 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
36fn 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
60fn 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 let (added, removed) = if change.hunks.is_empty() {
84 (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 all_covered
111 .entry(path.clone())
112 .or_default()
113 .extend(change.hunks.iter());
114
115 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 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, Some(hunk_set) => {
176 if hunk_set.is_empty() {
178 false
179 } else {
180 (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
220pub 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}