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 let visible_entries = self.tree.visible_entries();
124 let tree_selected = self.tree.selected_visible();
125
126 let mut lines = Vec::with_capacity(height);
127
128 for row in 0..height {
129 let mut line = Line::default();
130 let queue_row = self.queued_comment_count > 0 && row == height.saturating_sub(1);
131 if queue_row {
132 let indicator = format!(
133 " [{} comment{}]",
134 self.queued_comment_count,
135 if self.queued_comment_count == 1 { "" } else { "s" },
136 );
137 let padded = truncate_text(&indicator, width);
138 let pad = width.saturating_sub(padded.chars().count());
139 line.push_with_style(padded.as_ref(), Style::fg(theme.accent()).bg_color(theme.sidebar_bg()));
140 if pad > 0 {
141 line.push_with_style(" ".repeat(pad), Style::default().bg_color(theme.sidebar_bg()));
142 }
143 } else {
144 let entry_index = row + self.cursor.scroll;
145 if let Some(entry) = visible_entries.get(entry_index) {
146 render_file_tree_cell(&mut line, entry, entry_index == tree_selected, width, theme);
147 } else {
148 line.push_with_style(" ".repeat(width), Style::default().bg_color(theme.sidebar_bg()));
149 }
150 }
151
152 lines.push(line);
153 }
154
155 Frame::new(lines)
156 }
157}
158
159fn render_file_tree_cell(
160 line: &mut Line,
161 entry: &FileTreeEntry,
162 is_selected: bool,
163 left_width: usize,
164 theme: &tui::Theme,
165) {
166 let style = row_style(is_selected, theme);
167 let marker = if is_selected { "> " } else { " " };
168 let indent = " ".repeat(entry.depth);
169 let prefix_width = 2 + entry.depth * 2 + 2;
170
171 match &entry.kind {
172 FileTreeEntryKind::Directory { name, expanded, .. } => {
173 let icon = if *expanded { "\u{25be} " } else { "\u{25b8} " };
174 let name_budget = left_width.saturating_sub(prefix_width);
175 let display_name = format!("{name}/");
176 let truncated = truncate_text(&display_name, name_budget);
177 let remaining = left_width.saturating_sub(prefix_width + truncated.chars().count());
178
179 line.push_with_style(format!("{marker}{indent}{icon}"), style);
180 line.push_with_style(truncated.as_ref(), style.bold());
181 if remaining > 0 {
182 line.push_with_style(" ".repeat(remaining), style);
183 }
184 }
185 FileTreeEntryKind::File { name, status, additions, deletions, .. } => {
186 let stats_str = format!("+{additions}/-{deletions}");
187 let name_budget = left_width.saturating_sub(prefix_width + 2 + stats_str.len() + 1);
188 let truncated = truncate_text(name, name_budget);
189
190 line.push_with_style(format!("{marker}{indent} "), style);
191 push_status_marker(line, *status, is_selected, theme);
192 push_name_padding_stats(
193 line,
194 truncated.as_ref(),
195 style,
196 &stats_str,
197 *additions,
198 *deletions,
199 left_width.saturating_sub(prefix_width + 2),
200 is_selected,
201 theme,
202 );
203 }
204 }
205}
206
207fn row_style(is_selected: bool, theme: &tui::Theme) -> Style {
208 if is_selected { theme.selected_row_style() } else { Style::default().bg_color(theme.sidebar_bg()) }
209}
210
211fn push_status_marker(line: &mut Line, status: FileStatus, is_selected: bool, theme: &tui::Theme) {
212 let status_color = match status {
213 FileStatus::Deleted | FileStatus::Renamed => theme.diff_removed_fg(),
214 FileStatus::Modified => theme.text_secondary(),
215 FileStatus::Added | FileStatus::Untracked => theme.diff_added_fg(),
216 };
217 line.push_with_style(
218 format!("{} ", status.marker()),
219 if is_selected {
220 theme.selected_row_style_with_fg(status_color)
221 } else {
222 Style::fg(status_color).bg_color(theme.sidebar_bg())
223 },
224 );
225}
226
227#[allow(clippy::too_many_arguments)]
228fn push_name_padding_stats(
229 line: &mut Line,
230 name: &str,
231 name_style: Style,
232 stats_str: &str,
233 additions: usize,
234 deletions: usize,
235 available: usize,
236 is_selected: bool,
237 theme: &tui::Theme,
238) {
239 let name_width = name.chars().count();
240 let padding = available.saturating_sub(name_width + stats_str.len());
241
242 line.push_with_style(name, name_style);
243 if padding > 0 {
244 line.push_with_style(
245 " ".repeat(padding),
246 if is_selected { theme.selected_row_style() } else { Style::default().bg_color(theme.sidebar_bg()) },
247 );
248 }
249
250 let add_str = format!("+{additions}");
251 let del_str = format!("/-{deletions}");
252 line.push_with_style(
253 &add_str,
254 if is_selected {
255 theme.selected_row_style_with_fg(theme.diff_added_fg())
256 } else {
257 Style::fg(theme.diff_added_fg()).bg_color(theme.sidebar_bg())
258 },
259 );
260 line.push_with_style(
261 &del_str,
262 if is_selected {
263 theme.selected_row_style_with_fg(theme.diff_removed_fg())
264 } else {
265 Style::fg(theme.diff_removed_fg()).bg_color(theme.sidebar_bg())
266 },
267 );
268}