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, 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
37fn 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(); }
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 for i in 0..dirs.len() {
58 let candidate = format!("{}/{}", dirs.join("/"), filename);
59 if candidate.len() <= max_width {
60 return candidate;
61 }
62 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
79fn build_file_line(
81 path: &str,
82 is_untracked: bool,
83 suffix: &str,
84 added: usize,
85 removed: usize,
86 sidebar_width: u16,
87 path_overhead: u16,
88) -> Line<'static> {
89 let badge = if is_untracked { "[U] " } else { "" };
90 let stats = format!("{suffix} +{added} -{removed}");
91 let max_path_width = sidebar_width
92 .saturating_sub(path_overhead)
93 .saturating_sub(stats.len() as u16)
94 .saturating_sub(badge.len() as u16) as usize;
95 let display_path = abbreviate_path(path, max_path_width);
96
97 let mut spans = Vec::new();
98 if is_untracked {
99 spans.push(Span::styled(
100 "[U] ".to_string(),
101 Style::default().fg(Color::Cyan).add_modifier(Modifier::DIM),
102 ));
103 }
104 spans.push(Span::raw(format!("{display_path}{suffix} ")));
105 spans.push(Span::styled(
106 format!("+{added}"),
107 Style::default().fg(Color::Green),
108 ));
109 spans.push(Span::raw(" "));
110 spans.push(Span::styled(
111 format!("-{removed}"),
112 Style::default().fg(Color::Red),
113 ));
114 Line::from(spans)
115}
116
117fn build_flat_tree<'a>(app: &App, sidebar_width: u16) -> Vec<TreeItem<'a, TreeNodeId>> {
119 let path_overhead: u16 = 2 + 3 + 2;
121
122 app.diff_data
123 .files
124 .iter()
125 .map(|file| {
126 let path = file.target_file.trim_start_matches("b/").to_string();
127 let line = build_file_line(
128 &path, file.is_untracked, "", file.added_count, file.removed_count,
129 sidebar_width, path_overhead,
130 );
131 TreeItem::new_leaf(TreeNodeId::File(None, path), line)
132 })
133 .collect()
134}
135
136fn build_grouped_tree<'a>(
139 app: &App,
140 groups: &[crate::grouper::SemanticGroup],
141 sidebar_width: u16,
142) -> Vec<TreeItem<'a, TreeNodeId>> {
143 let mut all_covered: std::collections::HashMap<String, std::collections::HashSet<usize>> =
144 std::collections::HashMap::new();
145 let mut items: Vec<TreeItem<'a, TreeNodeId>> = Vec::new();
146
147 let nested_path_overhead: u16 = 2 + 3 + 2 + 2;
149
150 for (gi, group) in groups.iter().enumerate() {
151 let mut children: Vec<TreeItem<'a, TreeNodeId>> = Vec::new();
152 let mut group_added: usize = 0;
153 let mut group_removed: usize = 0;
154
155 for change in &group.changes() {
156 if let Some(file) = app.diff_data.files.iter().find(|f| {
157 let diff_path = f.target_file.trim_start_matches("b/");
158 diff_path == change.file || diff_path.ends_with(change.file.as_str())
159 }) {
160 let path = file.target_file.trim_start_matches("b/").to_string();
161
162 let (added, removed) = if change.hunks.is_empty() {
164 (file.added_count, file.removed_count)
166 } else {
167 change.hunks.iter().fold((0usize, 0usize), |(a, r), &hi| {
168 if let Some(hunk) = file.hunks.get(hi) {
169 let ha = hunk
170 .lines
171 .iter()
172 .filter(|l| l.line_type == crate::diff::LineType::Added)
173 .count();
174 let hr = hunk
175 .lines
176 .iter()
177 .filter(|l| l.line_type == crate::diff::LineType::Removed)
178 .count();
179 (a + ha, r + hr)
180 } else {
181 (a, r)
182 }
183 })
184 };
185
186 group_added += added;
187 group_removed += removed;
188
189 all_covered
191 .entry(path.clone())
192 .or_default()
193 .extend(change.hunks.iter());
194
195 let hunk_info = if change.hunks.is_empty() || change.hunks.len() == file.hunks.len()
197 {
198 String::new()
199 } else {
200 format!(" ({}/{} hunks)", change.hunks.len(), file.hunks.len())
201 };
202
203 let line = build_file_line(
204 &path, file.is_untracked, &hunk_info, added, removed,
205 sidebar_width, nested_path_overhead,
206 );
207 children.push(TreeItem::new_leaf(TreeNodeId::File(Some(gi), path), line));
208 }
209 }
210
211 if !children.is_empty() {
212 let header = Line::from(vec![
213 Span::styled(
214 format!("{} ", group.label),
215 Style::default()
216 .fg(app.theme.tree_group_fg)
217 .add_modifier(Modifier::BOLD),
218 ),
219 Span::styled(
220 format!("+{group_added}"),
221 Style::default().fg(Color::Green),
222 ),
223 Span::raw(" "),
224 Span::styled(
225 format!("-{group_removed}"),
226 Style::default().fg(Color::Red),
227 ),
228 Span::styled(
229 format!(", {} files", children.len()),
230 Style::default().fg(Color::DarkGray),
231 ),
232 ]);
233 if let Ok(item) = TreeItem::new(TreeNodeId::Group(gi), header, children) {
234 items.push(item);
235 }
236 }
237 }
238
239 let mut other_children: Vec<TreeItem<'a, TreeNodeId>> = Vec::new();
241 for file in &app.diff_data.files {
242 let path = file.target_file.trim_start_matches("b/").to_string();
243 let covered = all_covered.get(&path);
244
245 let is_other = match covered {
246 None => true, Some(hunk_set) => {
248 if hunk_set.is_empty() {
250 false
251 } else {
252 (0..file.hunks.len()).any(|hi| !hunk_set.contains(&hi))
254 }
255 }
256 };
257
258 if is_other {
259 let line = build_file_line(
260 &path, file.is_untracked, "", file.added_count, file.removed_count,
261 sidebar_width, nested_path_overhead,
262 );
263 other_children.push(TreeItem::new_leaf(TreeNodeId::File(Some(groups.len()), path), line));
264 }
265 }
266
267 if !other_children.is_empty() {
268 let header = Line::from(vec![Span::styled(
269 format!("Other ({} files)", other_children.len()),
270 Style::default()
271 .fg(Color::DarkGray)
272 .add_modifier(Modifier::BOLD),
273 )]);
274 if let Ok(item) =
275 TreeItem::new(TreeNodeId::Group(groups.len()), header, other_children)
276 {
277 items.push(item);
278 }
279 }
280
281 items
282}
283
284pub fn render_tree(app: &App, frame: &mut Frame, area: Rect) {
286 let items = build_tree_items(app, area.width);
287
288 let title = match app.grouping_status {
289 GroupingStatus::Loading => " Files [grouping...] ",
290 _ => " Files ",
291 };
292
293 let border_style = if app.focused_panel == FocusedPanel::FileTree {
294 Style::default().fg(app.theme.tree_group_fg)
295 } else {
296 Style::default().fg(app.theme.gutter_fg)
297 };
298
299 let tree = match Tree::new(&items) {
300 Ok(tree) => tree
301 .block(
302 Block::bordered()
303 .title(title)
304 .border_style(border_style),
305 )
306 .highlight_style(
307 Style::default()
308 .fg(app.theme.tree_highlight_fg)
309 .bg(app.theme.tree_highlight_bg)
310 .add_modifier(Modifier::BOLD),
311 )
312 .highlight_symbol(">> ")
313 .node_closed_symbol("> ")
314 .node_open_symbol("v ")
315 .node_no_children_symbol(" "),
316 Err(_) => return,
317 };
318
319 frame.render_stateful_widget(tree, area, &mut app.tree_state.borrow_mut());
320}
321
322#[cfg(test)]
323mod tests {
324 use super::*;
325
326 #[test]
327 fn test_abbreviate_path_fits() {
328 assert_eq!(abbreviate_path("src/main.rs", 30), "src/main.rs");
329 }
330
331 #[test]
332 fn test_abbreviate_path_short_dirs() {
333 assert_eq!(
337 abbreviate_path("src/app/components/routes.ts", 24),
338 "s/a/components/routes.ts"
339 );
340 }
341
342 #[test]
343 fn test_abbreviate_path_all_dirs() {
344 assert_eq!(
345 abbreviate_path("src/app/components/routes.ts", 15),
346 "s/a/c/routes.ts"
347 );
348 }
349
350 #[test]
351 fn test_abbreviate_path_hyphenated() {
352 assert_eq!(
353 abbreviate_path("src/app/components/sales-assistant/routes.ts", 20),
354 "s/a/c/s-a/routes.ts"
355 );
356 }
357
358 #[test]
359 fn test_abbreviate_path_single_component() {
360 assert_eq!(abbreviate_path("routes.ts", 5), "routes.ts");
361 }
362
363 #[test]
364 fn test_abbreviate_path_zero_width() {
365 assert_eq!(
366 abbreviate_path("src/main.rs", 0),
367 "src/main.rs"
368 );
369 }
370
371 #[test]
372 fn test_abbreviate_path_already_short() {
373 assert_eq!(abbreviate_path("a/b.rs", 10), "a/b.rs");
374 }
375
376 #[test]
377 fn test_abbreviate_path_exact_fit_after_partial() {
378 assert_eq!(
381 abbreviate_path("src/app/main.rs", 13),
382 "s/app/main.rs"
383 );
384 }
385}