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::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}