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