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