Skip to main content

wisp/components/
file_list_panel.rs

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