1use crate::app::{App, FocusedPanel};
2use crate::grouper::GroupingStatus;
3use ratatui::layout::Rect;
4use ratatui::style::{Color, Modifier, Style};
5use ratatui::text::{Line, Span, Text};
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 wrap_spans(spans: Vec<Span<'static>>, max_width: usize, indent: usize) -> Text<'static> {
82 if max_width == 0 {
83 return Text::from(Line::from(spans));
84 }
85
86 let total_len: usize = spans.iter().map(|s| s.content.len()).sum();
87 if total_len <= max_width {
88 return Text::from(Line::from(spans));
89 }
90
91 struct StyledWord {
93 text: String,
94 style: Style,
95 }
96 let mut words: Vec<StyledWord> = Vec::new();
97 for span in &spans {
98 let style = span.style;
99 let content = span.content.as_ref();
100 let mut start = 0;
102 for (i, ch) in content.char_indices() {
103 if ch == ' ' {
104 let end = i + 1;
106 if end > start {
107 words.push(StyledWord {
108 text: content[start..end].to_string(),
109 style,
110 });
111 start = end;
112 }
113 }
114 }
115 if start < content.len() {
116 words.push(StyledWord {
117 text: content[start..].to_string(),
118 style,
119 });
120 }
121 }
122
123 let mut lines: Vec<Line<'static>> = Vec::new();
124 let mut current_spans: Vec<Span<'static>> = Vec::new();
125 let mut current_len: usize = 0;
126 let mut is_first_line = true;
127
128 for word in words {
129 let word_len = word.text.len();
130 let line_max = if is_first_line { max_width } else { max_width.saturating_sub(indent) };
131
132 if current_len + word_len <= line_max || current_len == 0 {
133 current_spans.push(Span::styled(word.text, word.style));
135 current_len += word_len;
136 } else {
137 if let Some(last) = current_spans.last_mut() {
140 let trimmed = last.content.trim_end().to_string();
141 *last = Span::styled(trimmed, last.style);
142 }
143 lines.push(Line::from(std::mem::take(&mut current_spans)));
144 is_first_line = false;
145 current_len = 0;
146
147 let indent_str = " ".repeat(indent);
149 current_len += indent;
150 current_spans.push(Span::raw(indent_str));
151
152 current_spans.push(Span::styled(word.text, word.style));
153 current_len += word_len;
154 }
155 }
156
157 if !current_spans.is_empty() {
158 lines.push(Line::from(current_spans));
159 }
160
161 Text::from(lines)
162}
163
164fn build_file_text(
166 path: &str,
167 is_untracked: bool,
168 suffix: &str,
169 added: usize,
170 removed: usize,
171 sidebar_width: u16,
172 path_overhead: u16,
173) -> Line<'static> {
174 let badge = if is_untracked { "[U] " } else { "" };
175 let stats = format!("{suffix} +{added} -{removed}");
176 let max_path_width = sidebar_width
177 .saturating_sub(path_overhead)
178 .saturating_sub(stats.len() as u16)
179 .saturating_sub(badge.len() as u16) as usize;
180 let display_path = abbreviate_path(path, max_path_width);
181
182 let mut spans = Vec::new();
183 if is_untracked {
184 spans.push(Span::styled(
185 "[U] ".to_string(),
186 Style::default().fg(Color::Cyan).add_modifier(Modifier::DIM),
187 ));
188 }
189 spans.push(Span::raw(format!("{display_path}{suffix} ")));
190 spans.push(Span::styled(
191 format!("+{added}"),
192 Style::default().fg(Color::Green),
193 ));
194 spans.push(Span::raw(" "));
195 spans.push(Span::styled(
196 format!("-{removed}"),
197 Style::default().fg(Color::Red),
198 ));
199 Line::from(spans)
200}
201
202fn build_flat_tree<'a>(app: &App, sidebar_width: u16) -> Vec<TreeItem<'a, TreeNodeId>> {
204 let path_overhead: u16 = 2 + 3 + 2;
206
207 app.diff_data
208 .files
209 .iter()
210 .map(|file| {
211 let path = file.target_file.trim_start_matches("b/").to_string();
212 let line = build_file_text(
213 &path, file.is_untracked, "", file.added_count, file.removed_count,
214 sidebar_width, path_overhead,
215 );
216 TreeItem::new_leaf(TreeNodeId::File(None, path), line)
217 })
218 .collect()
219}
220
221fn build_grouped_tree<'a>(
224 app: &App,
225 groups: &[crate::grouper::SemanticGroup],
226 sidebar_width: u16,
227) -> Vec<TreeItem<'a, TreeNodeId>> {
228 let mut all_covered: std::collections::HashMap<String, std::collections::HashSet<usize>> =
229 std::collections::HashMap::new();
230 let mut items: Vec<TreeItem<'a, TreeNodeId>> = Vec::new();
231
232 let nested_path_overhead: u16 = 2 + 3 + 2 + 2;
234
235 let group_overhead: u16 = 2 + 3 + 2;
237
238 for (gi, group) in groups.iter().enumerate() {
239 let mut children: Vec<TreeItem<'a, TreeNodeId>> = Vec::new();
240 let mut group_added: usize = 0;
241 let mut group_removed: usize = 0;
242
243 for change in &group.changes() {
244 if let Some(file) = app.diff_data.files.iter().find(|f| {
245 let diff_path = f.target_file.trim_start_matches("b/");
246 diff_path == change.file || diff_path.ends_with(change.file.as_str())
247 }) {
248 let path = file.target_file.trim_start_matches("b/").to_string();
249
250 let (added, removed) = if change.hunks.is_empty() {
252 (file.added_count, file.removed_count)
254 } else {
255 change.hunks.iter().fold((0usize, 0usize), |(a, r), &hi| {
256 if let Some(hunk) = file.hunks.get(hi) {
257 let ha = hunk
258 .lines
259 .iter()
260 .filter(|l| l.line_type == crate::diff::LineType::Added)
261 .count();
262 let hr = hunk
263 .lines
264 .iter()
265 .filter(|l| l.line_type == crate::diff::LineType::Removed)
266 .count();
267 (a + ha, r + hr)
268 } else {
269 (a, r)
270 }
271 })
272 };
273
274 group_added += added;
275 group_removed += removed;
276
277 all_covered
279 .entry(path.clone())
280 .or_default()
281 .extend(change.hunks.iter());
282
283 let hunk_info = if change.hunks.is_empty() || change.hunks.len() == file.hunks.len()
285 {
286 String::new()
287 } else {
288 format!(" ({}/{} hunks)", change.hunks.len(), file.hunks.len())
289 };
290
291 let line = build_file_text(
292 &path, file.is_untracked, &hunk_info, added, removed,
293 sidebar_width, nested_path_overhead,
294 );
295 children.push(TreeItem::new_leaf(TreeNodeId::File(Some(gi), path), line));
296 }
297 }
298
299 if !children.is_empty() {
300 let header_spans = vec![
301 Span::styled(
302 format!("{} ", group.label),
303 Style::default()
304 .fg(app.theme.tree_group_fg)
305 .add_modifier(Modifier::BOLD),
306 ),
307 Span::styled(
308 format!("+{group_added}"),
309 Style::default().fg(Color::Green),
310 ),
311 Span::raw(" "),
312 Span::styled(
313 format!("-{group_removed}"),
314 Style::default().fg(Color::Red),
315 ),
316 Span::styled(
317 format!(", {} files", children.len()),
318 Style::default().fg(Color::DarkGray),
319 ),
320 ];
321 let available_width = sidebar_width.saturating_sub(group_overhead) as usize;
322 let header = wrap_spans(header_spans, available_width, 2);
323 if let Ok(item) = TreeItem::new(TreeNodeId::Group(gi), header, children) {
324 items.push(item);
325 }
326 }
327 }
328
329 let mut other_children: Vec<TreeItem<'a, TreeNodeId>> = Vec::new();
331 for file in &app.diff_data.files {
332 let path = file.target_file.trim_start_matches("b/").to_string();
333 let covered = all_covered.get(&path);
334
335 let is_other = match covered {
336 None => true, Some(hunk_set) => {
338 if hunk_set.is_empty() {
340 false
341 } else {
342 (0..file.hunks.len()).any(|hi| !hunk_set.contains(&hi))
344 }
345 }
346 };
347
348 if is_other {
349 let line = build_file_text(
350 &path, file.is_untracked, "", file.added_count, file.removed_count,
351 sidebar_width, nested_path_overhead,
352 );
353 other_children.push(TreeItem::new_leaf(TreeNodeId::File(Some(groups.len()), path), line));
354 }
355 }
356
357 if !other_children.is_empty() {
358 let header = Line::from(vec![Span::styled(
359 format!("Other ({} files)", other_children.len()),
360 Style::default()
361 .fg(Color::DarkGray)
362 .add_modifier(Modifier::BOLD),
363 )]);
364 if let Ok(item) =
365 TreeItem::new(TreeNodeId::Group(groups.len()), header, other_children)
366 {
367 items.push(item);
368 }
369 }
370
371 items
372}
373
374pub fn render_tree(app: &App, frame: &mut Frame, area: Rect) {
376 let items = build_tree_items(app, area.width);
377
378 let title = match app.grouping_status {
379 GroupingStatus::Loading => " Files [grouping...] ",
380 _ => " Files ",
381 };
382
383 let border_style = if app.focused_panel == FocusedPanel::FileTree {
384 Style::default().fg(app.theme.tree_group_fg)
385 } else {
386 Style::default().fg(app.theme.gutter_fg)
387 };
388
389 let tree = match Tree::new(&items) {
390 Ok(tree) => tree
391 .block(
392 Block::bordered()
393 .title(title)
394 .border_style(border_style),
395 )
396 .highlight_style(
397 Style::default()
398 .fg(app.theme.tree_highlight_fg)
399 .bg(app.theme.tree_highlight_bg)
400 .add_modifier(Modifier::BOLD),
401 )
402 .highlight_symbol(">> ")
403 .node_closed_symbol("> ")
404 .node_open_symbol("v ")
405 .node_no_children_symbol(" "),
406 Err(_) => return,
407 };
408
409 frame.render_stateful_widget(tree, area, &mut app.tree_state.borrow_mut());
410}
411
412#[cfg(test)]
413mod tests {
414 use super::*;
415
416 #[test]
417 fn test_abbreviate_path_fits() {
418 assert_eq!(abbreviate_path("src/main.rs", 30), "src/main.rs");
419 }
420
421 #[test]
422 fn test_abbreviate_path_short_dirs() {
423 assert_eq!(
427 abbreviate_path("src/app/components/routes.ts", 24),
428 "s/a/components/routes.ts"
429 );
430 }
431
432 #[test]
433 fn test_abbreviate_path_all_dirs() {
434 assert_eq!(
435 abbreviate_path("src/app/components/routes.ts", 15),
436 "s/a/c/routes.ts"
437 );
438 }
439
440 #[test]
441 fn test_abbreviate_path_hyphenated() {
442 assert_eq!(
443 abbreviate_path("src/app/components/sales-assistant/routes.ts", 20),
444 "s/a/c/s-a/routes.ts"
445 );
446 }
447
448 #[test]
449 fn test_abbreviate_path_single_component() {
450 assert_eq!(abbreviate_path("routes.ts", 5), "routes.ts");
451 }
452
453 #[test]
454 fn test_abbreviate_path_zero_width() {
455 assert_eq!(
456 abbreviate_path("src/main.rs", 0),
457 "src/main.rs"
458 );
459 }
460
461 #[test]
462 fn test_abbreviate_path_already_short() {
463 assert_eq!(abbreviate_path("a/b.rs", 10), "a/b.rs");
464 }
465
466 #[test]
467 fn test_abbreviate_path_exact_fit_after_partial() {
468 assert_eq!(
471 abbreviate_path("src/app/main.rs", 13),
472 "s/app/main.rs"
473 );
474 }
475}