1use crate::components::common::VerticalCursor;
2use crate::components::file_tree::{FileTree, FileTreeEntry, FileTreeEntryKind};
3use crate::components::git_diff::{file_status_color, header_rule, push_diff_stats};
4use crate::git_diff::{DiffScope, FileDiff, FileStatus, StageState};
5use tui::{Component, Event, Frame, KeyCode, Line, MouseEventKind, Style, ViewContext, truncate_line, truncate_text};
6
7const CHROME_HEIGHT: usize = 2;
8
9pub struct FileListPanel {
10 tree: FileTree,
11 cursor: VerticalCursor,
12 queued_comment_count: usize,
13 file_count: usize,
14 additions: usize,
15 deletions: usize,
16 focused: bool,
17 file_comment_counts: Vec<usize>,
18 diff_scope: DiffScope,
19 scroll_consumed_this_frame: bool,
20}
21
22pub enum FileListMessage {
23 Selected(usize),
24 FileOpened(usize),
25}
26
27impl Default for FileListPanel {
28 fn default() -> Self {
29 Self {
30 tree: FileTree::empty(),
31 cursor: VerticalCursor::new(),
32 queued_comment_count: 0,
33 file_count: 0,
34 additions: 0,
35 deletions: 0,
36 focused: false,
37 file_comment_counts: Vec::new(),
38 diff_scope: DiffScope::default(),
39 scroll_consumed_this_frame: false,
40 }
41 }
42}
43
44impl FileListPanel {
45 pub fn new() -> Self {
46 Self::default()
47 }
48
49 pub fn rebuild_from_files(&mut self, files: &[FileDiff]) {
50 self.file_count = files.len();
51 self.additions = files.iter().map(FileDiff::additions).sum();
52 self.deletions = files.iter().map(FileDiff::deletions).sum();
53 self.tree.rebuild_from_files(files);
54 self.file_comment_counts = vec![0; files.len()];
55 }
56
57 pub fn selected_file_index(&self) -> Option<usize> {
58 self.tree.selected_file_index()
59 }
60
61 pub fn selected_file_indices(&self) -> Vec<usize> {
62 self.tree.selected_file_indices()
63 }
64
65 pub fn select_file_index(&mut self, file_index: usize) {
66 self.tree.select_file_index(file_index);
67 }
68
69 pub fn sync_view_state(&mut self, queued_comment_count: usize, file_comment_counts: Vec<usize>) {
70 self.queued_comment_count = queued_comment_count;
71 self.file_comment_counts = file_comment_counts;
72 }
73
74 pub fn set_diff_scope(&mut self, diff_scope: DiffScope) {
75 self.diff_scope = diff_scope;
76 }
77
78 pub fn set_focused(&mut self, focused: bool) {
79 self.focused = focused;
80 }
81
82 pub(crate) fn select_relative(&mut self, delta: isize) -> Option<usize> {
83 let prev_file = self.tree.selected_file_index();
84 self.tree.navigate(delta);
85 let new_file = self.tree.selected_file_index();
86 if let Some(idx) = new_file
87 && Some(idx) != prev_file
88 {
89 return Some(idx);
90 }
91 None
92 }
93
94 pub(crate) fn tree_collapse_or_parent(&mut self) {
95 self.tree.collapse_or_parent();
96 }
97
98 pub(crate) fn tree_expand_or_enter(&mut self) -> Option<usize> {
99 let is_file = self.tree.expand_or_enter();
100 if is_file { self.tree.selected_file_index() } else { None }
101 }
102
103 fn header_row(&self, width: usize, theme: &tui::Theme) -> Line {
104 let title_fg = if self.focused { theme.accent() } else { theme.text_primary() };
105 let mut row = Line::default();
106 row.push_text(" ");
107 row.push_with_style(format!("Git Diff · {}", self.diff_scope.label()), Style::fg(title_fg).bold());
108 row.push_text(" ");
109 row.push_with_style(
110 format!("{} file{}", self.file_count, if self.file_count == 1 { "" } else { "s" }),
111 Style::fg(theme.text_primary()),
112 );
113 row.push_text(" ");
114 push_diff_stats(&mut row, self.additions, self.deletions, theme);
115 if self.queued_comment_count > 0 {
116 row.push_text(" ");
117 row.push_with_style(format!("◆{}", self.queued_comment_count), Style::fg(theme.accent()));
118 }
119 row.extend_bg_to_width(width);
120 truncate_line(&row, width)
121 }
122
123 fn ensure_visible(&mut self, viewport_height: usize) {
124 self.cursor.ensure_visible(self.tree.selected_visible(), viewport_height);
125 }
126}
127
128impl Component for FileListPanel {
129 type Message = FileListMessage;
130
131 async fn on_event(&mut self, event: &Event) -> Option<Vec<Self::Message>> {
132 if let Event::Mouse(mouse) = event {
133 return match mouse.kind {
134 MouseEventKind::ScrollUp => {
135 if self.scroll_consumed_this_frame {
136 return Some(vec![]);
137 }
138 self.scroll_consumed_this_frame = true;
139 Some(self.select_relative(-1).map(|idx| vec![FileListMessage::Selected(idx)]).unwrap_or_default())
140 }
141 MouseEventKind::ScrollDown => {
142 if self.scroll_consumed_this_frame {
143 return Some(vec![]);
144 }
145 self.scroll_consumed_this_frame = true;
146 Some(self.select_relative(1).map(|idx| vec![FileListMessage::Selected(idx)]).unwrap_or_default())
147 }
148 _ => None,
149 };
150 }
151
152 let Event::Key(key) = event else {
153 return None;
154 };
155 match key.code {
156 KeyCode::Char('j') | KeyCode::Down => {
157 Some(self.select_relative(1).map(|idx| vec![FileListMessage::Selected(idx)]).unwrap_or_default())
158 }
159 KeyCode::Char('k') | KeyCode::Up => {
160 Some(self.select_relative(-1).map(|idx| vec![FileListMessage::Selected(idx)]).unwrap_or_default())
161 }
162 KeyCode::Char('h') | KeyCode::Left => {
163 self.tree_collapse_or_parent();
164 Some(vec![])
165 }
166 KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right => {
167 if let Some(idx) = self.tree_expand_or_enter() {
168 Some(vec![FileListMessage::FileOpened(idx)])
169 } else {
170 Some(vec![])
171 }
172 }
173 _ => None,
174 }
175 }
176
177 fn render(&mut self, ctx: &ViewContext) -> Frame {
178 let theme = &ctx.theme;
179 self.scroll_consumed_this_frame = false;
180 let width = ctx.size.width as usize;
181 let height = ctx.size.height as usize;
182 if width < 2 {
183 return Frame::new((0..height).map(|_| Line::new(" ".repeat(width))).collect());
184 }
185
186 let tree_height = height.saturating_sub(CHROME_HEIGHT);
187 self.ensure_visible(tree_height);
188
189 let mut lines = Vec::with_capacity(height);
190 lines.push(self.header_row(width, theme));
191 lines.push(header_rule(width, theme));
192
193 let visible_entries = self.tree.visible_entries();
194 let tree_selected = self.tree.selected_visible();
195 for row in 0..tree_height {
196 let entry_index = row + self.cursor.scroll;
197 let mut content = Line::default();
198 if let Some(entry) = visible_entries.get(entry_index) {
199 let is_selected = entry_index == tree_selected;
200 let comments =
201 entry_file_index(entry).and_then(|index| self.file_comment_counts.get(index).copied()).unwrap_or(0);
202 let indent = tree_indent(&visible_entries, entry_index);
203 let flags = EntryFlags { is_selected, indent: &indent, width, comments };
204 match &entry.kind {
205 FileTreeEntryKind::Directory { name, expanded, staged, .. } => {
206 render_directory_entry(&mut content, name, *expanded, *staged, entry.depth, flags, theme);
207 }
208 FileTreeEntryKind::File { name, status, staged, additions, deletions, .. } => {
209 let row = FileRow {
210 name,
211 status: *status,
212 staged: *staged,
213 additions: *additions,
214 deletions: *deletions,
215 };
216 render_file_entry(&mut content, row, flags, theme);
217 }
218 }
219 } else {
220 content.push_text(" ".repeat(width));
221 }
222 lines.push(content);
223 }
224
225 lines.truncate(height);
226 Frame::new(lines)
227 }
228}
229
230fn entry_file_index(entry: &FileTreeEntry) -> Option<usize> {
231 match &entry.kind {
232 FileTreeEntryKind::File { file_index, .. } => Some(*file_index),
233 FileTreeEntryKind::Directory { .. } => None,
234 }
235}
236
237#[derive(Clone, Copy)]
238struct EntryFlags<'a> {
239 is_selected: bool,
240 indent: &'a str,
241 width: usize,
242 comments: usize,
243}
244
245fn render_directory_entry(
246 line: &mut Line,
247 name: &str,
248 expanded: bool,
249 staged: StageState,
250 depth: usize,
251 flags: EntryFlags<'_>,
252 theme: &tui::Theme,
253) {
254 let EntryFlags { is_selected, indent, width, .. } = flags;
255 let icon = if expanded { "▾" } else { "▸" };
256 let connector = if depth == 0 { "" } else { "─" };
257 let icon_gap = if depth == 0 { " " } else { " " };
258 let dir_style = row_fg_style(theme.info(), is_selected, theme);
259 let indicator = if is_selected { "▎" } else { " " };
260 let (checkbox, checkbox_color) = stage_checkbox(staged, theme);
261 let prefix_width = format!("{indicator}{indent}{connector}{icon}{icon_gap}").chars().count();
262
263 line.push_with_style(indicator, row_fg_style(theme.accent(), is_selected, theme));
264 line.push_with_style(format!("{indent}{connector}"), row_fg_style(theme.muted(), is_selected, theme));
265 line.push_with_style(format!("{icon}{icon_gap}"), dir_style);
266 push_name_padded_to_suffix(
267 line,
268 &format!("{name}/"),
269 dir_style.bold(),
270 dir_style,
271 prefix_width,
272 vec![(format!(" {checkbox}"), row_fg_style(checkbox_color, is_selected, theme))],
273 width,
274 );
275}
276
277#[derive(Clone, Copy)]
278struct FileRow<'a> {
279 name: &'a str,
280 status: FileStatus,
281 staged: StageState,
282 additions: usize,
283 deletions: usize,
284}
285
286fn render_file_entry(line: &mut Line, row: FileRow<'_>, flags: EntryFlags<'_>, theme: &tui::Theme) {
287 let FileRow { name, status, staged, additions, deletions } = row;
288 let EntryFlags { is_selected, indent, width, comments } = flags;
289 let style = row_style(is_selected, theme);
290 let guide_style = row_fg_style(theme.muted(), is_selected, theme);
291 let indicator = if is_selected { "▎" } else { " " };
292 let (checkbox, checkbox_color) = stage_checkbox(staged, theme);
293
294 let mut suffix = vec![(" ".to_string(), style)];
295 if comments > 0 {
296 suffix.push((format!("◆{comments} "), row_fg_style(theme.accent(), is_selected, theme)));
297 }
298 suffix.push((format!("+{additions}"), row_fg_style(theme.diff_added_fg(), is_selected, theme)));
299 suffix.push((format!(" -{deletions}"), row_fg_style(theme.diff_removed_fg(), is_selected, theme)));
300 suffix.push((format!(" {}", status.marker()), row_fg_style(file_status_color(status, theme), is_selected, theme)));
301 suffix.push((format!(" {checkbox}"), row_fg_style(checkbox_color, is_selected, theme)));
302 let prefix_width = format!("{indicator}{indent}── ").chars().count();
303
304 line.push_with_style(indicator, row_fg_style(theme.accent(), is_selected, theme));
305 line.push_with_style(format!("{indent}── "), guide_style);
306 push_name_padded_to_suffix(line, name, style, style, prefix_width, suffix, width);
307}
308
309fn push_name_padded_to_suffix(
310 line: &mut Line,
311 name: &str,
312 name_style: Style,
313 pad_style: Style,
314 prefix_width: usize,
315 suffix: Vec<(String, Style)>,
316 width: usize,
317) {
318 let suffix_width: usize = suffix.iter().map(|(text, _)| text.chars().count()).sum();
319 let truncated = truncate_text(name, width.saturating_sub(prefix_width + suffix_width));
320 line.push_with_style(truncated.as_ref(), name_style);
321 let padding = width.saturating_sub(prefix_width + truncated.chars().count() + suffix_width);
322 if padding > 0 {
323 line.push_with_style(" ".repeat(padding), pad_style);
324 }
325 for (text, style) in suffix {
326 line.push_with_style(text, style);
327 }
328 line.extend_bg_to_width(width);
329}
330
331fn stage_checkbox(staged: StageState, theme: &tui::Theme) -> (&'static str, tui::Color) {
332 match staged {
333 StageState::Staged => ("☑", theme.diff_added_fg()),
334 StageState::PartiallyStaged => ("▣", theme.warning()),
335 StageState::Unstaged => ("☐", theme.muted()),
336 }
337}
338
339fn row_style(is_selected: bool, theme: &tui::Theme) -> Style {
340 if is_selected { theme.selected_row_style() } else { Style::default() }
341}
342
343fn row_fg_style(fg: tui::Color, is_selected: bool, theme: &tui::Theme) -> Style {
344 if is_selected { theme.selected_row_style_with_fg(fg) } else { Style::fg(fg) }
345}
346
347fn tree_indent(entries: &[&FileTreeEntry], index: usize) -> String {
348 let Some(entry) = entries.get(index) else {
349 return String::new();
350 };
351 if entry.depth == 0 {
352 return String::new();
353 }
354 let mut indent = String::new();
355 for level in 1..entry.depth {
356 indent.push_str(if level_continues(entries, index, level) { "│ " } else { " " });
357 }
358 indent.push(if level_continues(entries, index, entry.depth) { '├' } else { '└' });
359 indent
360}
361
362fn level_continues(entries: &[&FileTreeEntry], index: usize, level: usize) -> bool {
363 for next in &entries[index + 1..] {
364 if next.depth < level {
365 return false;
366 }
367 if next.depth == level {
368 return true;
369 }
370 }
371 false
372}
373
374#[cfg(test)]
375mod tests {
376 use super::*;
377 use crate::git_diff::Hunk;
378 use tui::{KeyEvent, KeyModifiers};
379
380 fn modified(path: &str) -> FileDiff {
381 FileDiff {
382 old_path: None,
383 path: path.to_string(),
384 status: FileStatus::Modified,
385 staged: StageState::Unstaged,
386 hunks: vec![Hunk {
387 header: "@@ -1 +1 @@".to_string(),
388 old_start: 1,
389 old_count: 1,
390 new_start: 1,
391 new_count: 1,
392 lines: Vec::new(),
393 }],
394 binary: false,
395 }
396 }
397
398 #[tokio::test]
399 async fn active_indicator_follows_selected_directory() {
400 let mut panel = FileListPanel::new();
401 panel.rebuild_from_files(&[modified("lib/c.rs"), modified("src/a.rs")]);
402 panel.select_file_index(0);
403 panel.on_event(&Event::Key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE))).await;
404
405 let frame = panel.render(&ViewContext::new((32, 8)));
406 let lines = frame.lines();
407
408 assert!(!lines[3].plain_text().starts_with('▎'), "previous file row should not keep the active indicator");
409 assert!(lines[4].plain_text().starts_with('▎'), "selected directory row should have the active indicator");
410 }
411}