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)]
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
26pub 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
34fn 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
58fn 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 let (added, removed) = if change.hunks.is_empty() {
82 (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 all_covered
109 .entry(path.clone())
110 .or_default()
111 .extend(change.hunks.iter());
112
113 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 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, Some(hunk_set) => {
174 if hunk_set.is_empty() {
176 false
177 } else {
178 (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
218pub 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}