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