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