1use crate::components::common::VerticalCursor;
2use crate::components::file_tree::{FileTree, FileTreeEntry, FileTreeEntryKind};
3use crate::git_diff::{FileDiff, FileStatus};
4use tui::{Component, Event, Frame, KeyCode, Line, MouseEventKind, Style, ViewContext, truncate_text};
5
6pub struct FileListPanel {
7 tree: FileTree,
8 cursor: VerticalCursor,
9 queued_comment_count: usize,
10}
11
12pub enum FileListMessage {
13 Selected(usize),
14 FileOpened(usize),
15}
16
17impl Default for FileListPanel {
18 fn default() -> Self {
19 Self { tree: FileTree::empty(), cursor: VerticalCursor::new(), queued_comment_count: 0 }
20 }
21}
22
23impl FileListPanel {
24 pub fn new() -> Self {
25 Self::default()
26 }
27
28 pub fn rebuild_from_files(&mut self, files: &[FileDiff]) {
29 self.tree = FileTree::from_files(files);
30 self.cursor = VerticalCursor::new();
31 }
32
33 pub fn selected_file_index(&self) -> Option<usize> {
34 self.tree.selected_file_index()
35 }
36
37 pub fn select_file_index(&mut self, file_index: usize) {
38 self.tree.select_file_index(file_index);
39 }
40
41 pub fn set_queued_comment_count(&mut self, count: usize) {
42 self.queued_comment_count = count;
43 }
44
45 pub(crate) fn select_relative(&mut self, delta: isize) -> Option<usize> {
46 let prev_file = self.tree.selected_file_index();
47 self.tree.navigate(delta);
48 let new_file = self.tree.selected_file_index();
49 if let Some(idx) = new_file
50 && Some(idx) != prev_file
51 {
52 return Some(idx);
53 }
54 None
55 }
56
57 pub(crate) fn tree_collapse_or_parent(&mut self) {
58 self.tree.collapse_or_parent();
59 }
60
61 pub(crate) fn tree_expand_or_enter(&mut self) -> Option<usize> {
62 let is_file = self.tree.expand_or_enter();
63 if is_file { self.tree.selected_file_index() } else { None }
64 }
65
66 fn ensure_visible(&mut self, viewport_height: usize) {
67 self.cursor.ensure_visible(self.tree.selected_visible(), viewport_height);
68 }
69
70 pub fn tree_mut(&mut self) -> &mut FileTree {
71 &mut self.tree
72 }
73}
74
75impl Component for FileListPanel {
76 type Message = FileListMessage;
77
78 async fn on_event(&mut self, event: &Event) -> Option<Vec<Self::Message>> {
79 if let Event::Mouse(mouse) = event {
80 return match mouse.kind {
81 MouseEventKind::ScrollUp => {
82 Some(self.select_relative(-1).map(|idx| vec![FileListMessage::Selected(idx)]).unwrap_or_default())
83 }
84 MouseEventKind::ScrollDown => {
85 Some(self.select_relative(1).map(|idx| vec![FileListMessage::Selected(idx)]).unwrap_or_default())
86 }
87 _ => None,
88 };
89 }
90
91 let Event::Key(key) = event else {
92 return None;
93 };
94 match key.code {
95 KeyCode::Char('j') | KeyCode::Down => {
96 Some(self.select_relative(1).map(|idx| vec![FileListMessage::Selected(idx)]).unwrap_or_default())
97 }
98 KeyCode::Char('k') | KeyCode::Up => {
99 Some(self.select_relative(-1).map(|idx| vec![FileListMessage::Selected(idx)]).unwrap_or_default())
100 }
101 KeyCode::Char('h') | KeyCode::Left => {
102 self.tree_collapse_or_parent();
103 Some(vec![])
104 }
105 KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right => {
106 if let Some(idx) = self.tree_expand_or_enter() {
107 Some(vec![FileListMessage::FileOpened(idx)])
108 } else {
109 Some(vec![])
110 }
111 }
112 _ => None,
113 }
114 }
115
116 fn render(&mut self, ctx: &ViewContext) -> Frame {
117 let theme = &ctx.theme;
118 let width = ctx.size.width as usize;
119 let height = ctx.size.height as usize;
120
121 self.ensure_visible(height);
122
123 self.tree.ensure_cache();
124
125 let visible_entries = self.tree.visible_entries();
126 let tree_selected = self.tree.selected_visible();
127
128 let row_count = height.max(visible_entries.len());
129 let mut lines = Vec::with_capacity(height);
130
131 for i in 0..row_count {
132 let mut line = Line::default();
133 let queue_row = self.queued_comment_count > 0 && i == height.saturating_sub(1);
134 if queue_row {
135 let indicator = format!(
136 " [{} comment{}]",
137 self.queued_comment_count,
138 if self.queued_comment_count == 1 { "" } else { "s" },
139 );
140 let padded = truncate_text(&indicator, width);
141 let pad = width.saturating_sub(padded.chars().count());
142 line.push_with_style(padded.as_ref(), Style::fg(theme.accent()).bg_color(theme.sidebar_bg()));
143 if pad > 0 {
144 line.push_with_style(" ".repeat(pad), Style::default().bg_color(theme.sidebar_bg()));
145 }
146 } else {
147 let scrolled_i = i + self.cursor.scroll;
148 if let Some(entry) = visible_entries.get(scrolled_i) {
149 render_file_tree_cell(&mut line, entry, scrolled_i == tree_selected, width, theme);
150 } else {
151 line.push_with_style(" ".repeat(width), Style::default().bg_color(theme.sidebar_bg()));
152 }
153 }
154
155 lines.push(line);
156 }
157
158 lines.truncate(height);
159 Frame::new(lines)
160 }
161}
162
163fn render_file_tree_cell(
164 line: &mut Line,
165 entry: &FileTreeEntry,
166 is_selected: bool,
167 left_width: usize,
168 theme: &tui::Theme,
169) {
170 let style = row_style(is_selected, theme);
171 let marker = if is_selected { "> " } else { " " };
172 let indent = " ".repeat(entry.depth);
173 let prefix_width = 2 + entry.depth * 2 + 2;
174
175 match &entry.kind {
176 FileTreeEntryKind::Directory { name, expanded, .. } => {
177 let icon = if *expanded { "\u{25be} " } else { "\u{25b8} " };
178 let name_budget = left_width.saturating_sub(prefix_width);
179 let display_name = format!("{name}/");
180 let truncated = truncate_text(&display_name, name_budget);
181 let remaining = left_width.saturating_sub(prefix_width + truncated.chars().count());
182
183 line.push_with_style(format!("{marker}{indent}{icon}"), style);
184 line.push_with_style(truncated.as_ref(), style.bold());
185 if remaining > 0 {
186 line.push_with_style(" ".repeat(remaining), style);
187 }
188 }
189 FileTreeEntryKind::File { name, status, additions, deletions, .. } => {
190 let stats_str = format!("+{additions}/-{deletions}");
191 let name_budget = left_width.saturating_sub(prefix_width + 2 + stats_str.len() + 1);
192 let truncated = truncate_text(name, name_budget);
193
194 line.push_with_style(format!("{marker}{indent} "), style);
195 push_status_marker(line, *status, is_selected, theme);
196 push_name_padding_stats(
197 line,
198 truncated.as_ref(),
199 style,
200 &stats_str,
201 *additions,
202 *deletions,
203 left_width.saturating_sub(prefix_width + 2),
204 is_selected,
205 theme,
206 );
207 }
208 }
209}
210
211fn row_style(is_selected: bool, theme: &tui::Theme) -> Style {
212 if is_selected { theme.selected_row_style() } else { Style::default().bg_color(theme.sidebar_bg()) }
213}
214
215fn push_status_marker(line: &mut Line, status: FileStatus, is_selected: bool, theme: &tui::Theme) {
216 let status_color = match status {
217 FileStatus::Deleted | FileStatus::Renamed => theme.diff_removed_fg(),
218 FileStatus::Modified => theme.text_secondary(),
219 FileStatus::Added | FileStatus::Untracked => theme.diff_added_fg(),
220 };
221 line.push_with_style(
222 format!("{} ", status.marker()),
223 if is_selected {
224 theme.selected_row_style_with_fg(status_color)
225 } else {
226 Style::fg(status_color).bg_color(theme.sidebar_bg())
227 },
228 );
229}
230
231#[allow(clippy::too_many_arguments)]
232fn push_name_padding_stats(
233 line: &mut Line,
234 name: &str,
235 name_style: Style,
236 stats_str: &str,
237 additions: usize,
238 deletions: usize,
239 available: usize,
240 is_selected: bool,
241 theme: &tui::Theme,
242) {
243 let name_width = name.chars().count();
244 let padding = available.saturating_sub(name_width + stats_str.len());
245
246 line.push_with_style(name, name_style);
247 if padding > 0 {
248 line.push_with_style(
249 " ".repeat(padding),
250 if is_selected { theme.selected_row_style() } else { Style::default().bg_color(theme.sidebar_bg()) },
251 );
252 }
253
254 let add_str = format!("+{additions}");
255 let del_str = format!("/-{deletions}");
256 line.push_with_style(
257 &add_str,
258 if is_selected {
259 theme.selected_row_style_with_fg(theme.diff_added_fg())
260 } else {
261 Style::fg(theme.diff_added_fg()).bg_color(theme.sidebar_bg())
262 },
263 );
264 line.push_with_style(
265 &del_str,
266 if is_selected {
267 theme.selected_row_style_with_fg(theme.diff_removed_fg())
268 } else {
269 Style::fg(theme.diff_removed_fg()).bg_color(theme.sidebar_bg())
270 },
271 );
272}