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};
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, additions, deletions, .. } => {
204 render_file_entry(&mut content, name, *status, *additions, *deletions, flags, theme);
205 }
206 }
207 } else {
208 content.push_text(" ".repeat(width));
209 }
210 lines.push(content);
211 }
212
213 lines.truncate(height);
214 Frame::new(lines)
215 }
216}
217
218fn entry_file_index(entry: &FileTreeEntry) -> Option<usize> {
219 match &entry.kind {
220 FileTreeEntryKind::File { file_index, .. } => Some(*file_index),
221 FileTreeEntryKind::Directory { .. } => None,
222 }
223}
224
225#[derive(Clone, Copy)]
226struct EntryFlags<'a> {
227 is_selected: bool,
228 indent: &'a str,
229 width: usize,
230 comments: usize,
231}
232
233fn render_directory_entry(
234 line: &mut Line,
235 name: &str,
236 expanded: bool,
237 depth: usize,
238 flags: EntryFlags<'_>,
239 theme: &tui::Theme,
240) {
241 let EntryFlags { is_selected, indent, width, .. } = flags;
242 let icon = if expanded { "▾" } else { "▸" };
243 let connector = if depth == 0 { "" } else { "── " };
244 let dir_style = row_fg_style(theme.info(), is_selected, theme);
245 let indicator = if is_selected { "▎" } else { " " };
246 let prefix_width = format!("{indicator}{indent}{connector}{icon} ").chars().count();
247 let name_budget = width.saturating_sub(prefix_width);
248 let display_name = format!("{name}/");
249 let truncated = truncate_text(&display_name, name_budget);
250
251 line.push_with_style(indicator, row_fg_style(theme.accent(), is_selected, theme));
252 line.push_with_style(format!("{indent}{connector}"), row_fg_style(theme.muted(), is_selected, theme));
253 line.push_with_style(format!("{icon} "), dir_style);
254 line.push_with_style(truncated.as_ref(), dir_style.bold());
255 line.extend_bg_to_width(width);
256}
257
258fn render_file_entry(
259 line: &mut Line,
260 name: &str,
261 status: FileStatus,
262 additions: usize,
263 deletions: usize,
264 flags: EntryFlags<'_>,
265 theme: &tui::Theme,
266) {
267 let EntryFlags { is_selected, indent, width, comments } = flags;
268 let style = row_style(is_selected, theme);
269 let guide_style = row_fg_style(theme.muted(), is_selected, theme);
270
271 let badge = (comments > 0).then(|| format!("◆{comments} "));
272 let badge_width = badge.as_deref().map_or(0, |b| b.chars().count());
273 let add_str = format!("+{additions}");
274 let del_str = format!(" -{deletions}");
275 let marker_str = format!(" {}", status.marker());
276 let suffix_width = badge_width + add_str.chars().count() + del_str.chars().count() + marker_str.chars().count();
277 let prefix = format!("{}{indent}── ", if is_selected { "▎" } else { " " });
278 let prefix_width = prefix.chars().count();
279 let name_budget = width.saturating_sub(prefix_width + suffix_width + 1);
280 let truncated = truncate_text(name, name_budget);
281
282 line.push_with_style(if is_selected { "▎" } else { " " }, row_fg_style(theme.accent(), is_selected, theme));
283 line.push_with_style(format!("{indent}── "), guide_style);
284 line.push_with_style(truncated.as_ref(), style);
285 let padding = width.saturating_sub(prefix_width + truncated.chars().count() + suffix_width);
286 if padding > 0 {
287 line.push_with_style(" ".repeat(padding), style);
288 }
289 if let Some(badge) = badge {
290 line.push_with_style(badge, row_fg_style(theme.accent(), is_selected, theme));
291 }
292 line.push_with_style(add_str, row_fg_style(theme.diff_added_fg(), is_selected, theme));
293 line.push_with_style(del_str, row_fg_style(theme.diff_removed_fg(), is_selected, theme));
294 line.push_with_style(marker_str, row_fg_style(file_status_color(status, theme), is_selected, theme));
295}
296
297fn row_style(is_selected: bool, theme: &tui::Theme) -> Style {
298 if is_selected { theme.selected_row_style() } else { Style::default() }
299}
300
301fn row_fg_style(fg: tui::Color, is_selected: bool, theme: &tui::Theme) -> Style {
302 if is_selected { theme.selected_row_style_with_fg(fg) } else { Style::fg(fg) }
303}
304
305fn tree_indent(entries: &[FileTreeEntry], index: usize) -> String {
306 let Some(entry) = entries.get(index) else {
307 return String::new();
308 };
309 if entry.depth == 0 {
310 return String::new();
311 }
312 let mut indent = String::new();
313 for level in 1..entry.depth {
314 indent.push_str(if level_continues(entries, index, level) { "│ " } else { " " });
315 }
316 indent.push(if level_continues(entries, index, entry.depth) { '├' } else { '└' });
317 indent
318}
319
320fn level_continues(entries: &[FileTreeEntry], index: usize, level: usize) -> bool {
321 for next in &entries[index + 1..] {
322 if next.depth < level {
323 return false;
324 }
325 if next.depth == level {
326 return true;
327 }
328 }
329 false
330}
331
332#[cfg(test)]
333mod tests {
334 use super::*;
335 use crate::git_diff::Hunk;
336 use tui::{KeyEvent, KeyModifiers};
337
338 fn modified(path: &str) -> FileDiff {
339 FileDiff {
340 old_path: None,
341 path: path.to_string(),
342 status: FileStatus::Modified,
343 hunks: vec![Hunk {
344 header: "@@ -1 +1 @@".to_string(),
345 old_start: 1,
346 old_count: 1,
347 new_start: 1,
348 new_count: 1,
349 lines: Vec::new(),
350 }],
351 binary: false,
352 }
353 }
354
355 #[tokio::test]
356 async fn active_indicator_follows_selected_directory() {
357 let mut panel = FileListPanel::new();
358 panel.rebuild_from_files(&[modified("lib/c.rs"), modified("src/a.rs")]);
359 panel.select_file_index(0);
360 panel.on_event(&Event::Key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE))).await;
361
362 let frame = panel.render(&ViewContext::new((32, 8)));
363 let lines = frame.lines();
364
365 assert!(!lines[3].plain_text().starts_with('▎'), "previous file row should not keep the active indicator");
366 assert!(lines[4].plain_text().starts_with('▎'), "selected directory row should have the active indicator");
367 }
368}