Skip to main content

wisp/components/
file_list_panel.rs

1use crate::components::file_tree::{FileTree, FileTreeEntry, FileTreeEntryKind};
2use crate::git_diff::{FileDiff, FileStatus};
3use tui::{Component, Event, Frame, KeyCode, Line, MouseEventKind, Style, ViewContext, truncate_text};
4
5pub struct FileListPanel {
6    tree: FileTree,
7    scroll: usize,
8    queued_comment_count: usize,
9}
10
11pub enum FileListMessage {
12    Selected(usize),
13    FileOpened(usize),
14}
15
16impl Default for FileListPanel {
17    fn default() -> Self {
18        Self { tree: FileTree::empty(), scroll: 0, queued_comment_count: 0 }
19    }
20}
21
22impl FileListPanel {
23    pub fn new() -> Self {
24        Self::default()
25    }
26
27    pub fn rebuild_from_files(&mut self, files: &[FileDiff]) {
28        self.tree = FileTree::from_files(files);
29        self.scroll = 0;
30    }
31
32    pub fn selected_file_index(&self) -> Option<usize> {
33        self.tree.selected_file_index()
34    }
35
36    pub fn select_file_index(&mut self, file_index: usize) {
37        self.tree.select_file_index(file_index);
38    }
39
40    pub fn set_queued_comment_count(&mut self, count: usize) {
41        self.queued_comment_count = count;
42    }
43
44    pub(crate) fn select_relative(&mut self, delta: isize) -> Option<usize> {
45        let prev_file = self.tree.selected_file_index();
46        self.tree.navigate(delta);
47        let new_file = self.tree.selected_file_index();
48        if let Some(idx) = new_file
49            && Some(idx) != prev_file
50        {
51            return Some(idx);
52        }
53        None
54    }
55
56    pub(crate) fn tree_collapse_or_parent(&mut self) {
57        self.tree.collapse_or_parent();
58    }
59
60    pub(crate) fn tree_expand_or_enter(&mut self) -> Option<usize> {
61        let is_file = self.tree.expand_or_enter();
62        if is_file { self.tree.selected_file_index() } else { None }
63    }
64
65    fn ensure_visible(&mut self, viewport_height: usize) {
66        let selected = self.tree.selected_visible();
67        if selected < self.scroll {
68            self.scroll = selected;
69        } else if selected >= self.scroll + viewport_height {
70            self.scroll = selected.saturating_sub(viewport_height - 1);
71        }
72    }
73
74    pub fn tree_mut(&mut self) -> &mut FileTree {
75        &mut self.tree
76    }
77}
78
79impl Component for FileListPanel {
80    type Message = FileListMessage;
81
82    async fn on_event(&mut self, event: &Event) -> Option<Vec<Self::Message>> {
83        if let Event::Mouse(mouse) = event {
84            return match mouse.kind {
85                MouseEventKind::ScrollUp => {
86                    Some(self.select_relative(-1).map(|idx| vec![FileListMessage::Selected(idx)]).unwrap_or_default())
87                }
88                MouseEventKind::ScrollDown => {
89                    Some(self.select_relative(1).map(|idx| vec![FileListMessage::Selected(idx)]).unwrap_or_default())
90                }
91                _ => None,
92            };
93        }
94
95        let Event::Key(key) = event else {
96            return None;
97        };
98        match key.code {
99            KeyCode::Char('j') | KeyCode::Down => {
100                Some(self.select_relative(1).map(|idx| vec![FileListMessage::Selected(idx)]).unwrap_or_default())
101            }
102            KeyCode::Char('k') | KeyCode::Up => {
103                Some(self.select_relative(-1).map(|idx| vec![FileListMessage::Selected(idx)]).unwrap_or_default())
104            }
105            KeyCode::Char('h') | KeyCode::Left => {
106                self.tree_collapse_or_parent();
107                Some(vec![])
108            }
109            KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right => {
110                if let Some(idx) = self.tree_expand_or_enter() {
111                    Some(vec![FileListMessage::FileOpened(idx)])
112                } else {
113                    Some(vec![])
114                }
115            }
116            _ => None,
117        }
118    }
119
120    fn render(&mut self, ctx: &ViewContext) -> Frame {
121        let theme = &ctx.theme;
122        let width = ctx.size.width as usize;
123        let height = ctx.size.height as usize;
124
125        self.ensure_visible(height);
126
127        self.tree.ensure_cache();
128
129        let visible_entries = self.tree.visible_entries();
130        let tree_selected = self.tree.selected_visible();
131
132        let row_count = height.max(visible_entries.len());
133        let mut lines = Vec::with_capacity(height);
134
135        for i in 0..row_count {
136            let mut line = Line::default();
137            let queue_row = self.queued_comment_count > 0 && i == height.saturating_sub(1);
138            if queue_row {
139                let indicator = format!(
140                    " [{} comment{}] s:submit u:undo",
141                    self.queued_comment_count,
142                    if self.queued_comment_count == 1 { "" } else { "s" },
143                );
144                let padded = truncate_text(&indicator, width);
145                let pad = width.saturating_sub(padded.chars().count());
146                line.push_with_style(padded.as_ref(), Style::fg(theme.accent()).bg_color(theme.sidebar_bg()));
147                if pad > 0 {
148                    line.push_with_style(" ".repeat(pad), Style::default().bg_color(theme.sidebar_bg()));
149                }
150            } else {
151                let scrolled_i = i + self.scroll;
152                if let Some(entry) = visible_entries.get(scrolled_i) {
153                    render_file_tree_cell(&mut line, entry, scrolled_i == tree_selected, width, theme);
154                } else {
155                    line.push_with_style(" ".repeat(width), Style::default().bg_color(theme.sidebar_bg()));
156                }
157            }
158
159            lines.push(line);
160        }
161
162        lines.truncate(height);
163        Frame::new(lines)
164    }
165}
166
167fn render_file_tree_cell(
168    line: &mut Line,
169    entry: &FileTreeEntry,
170    is_selected: bool,
171    left_width: usize,
172    theme: &tui::Theme,
173) {
174    let style = row_style(is_selected, theme);
175    let marker = if is_selected { "> " } else { "  " };
176    let indent = "  ".repeat(entry.depth);
177    let prefix_width = 2 + entry.depth * 2 + 2;
178
179    match &entry.kind {
180        FileTreeEntryKind::Directory { name, expanded, .. } => {
181            let icon = if *expanded { "\u{25be} " } else { "\u{25b8} " };
182            let name_budget = left_width.saturating_sub(prefix_width);
183            let display_name = format!("{name}/");
184            let truncated = truncate_text(&display_name, name_budget);
185            let remaining = left_width.saturating_sub(prefix_width + truncated.chars().count());
186
187            line.push_with_style(format!("{marker}{indent}{icon}"), style);
188            line.push_with_style(truncated.as_ref(), style.bold());
189            if remaining > 0 {
190                line.push_with_style(" ".repeat(remaining), style);
191            }
192        }
193        FileTreeEntryKind::File { name, status, additions, deletions, .. } => {
194            let stats_str = format!("+{additions}/-{deletions}");
195            let name_budget = left_width.saturating_sub(prefix_width + 2 + stats_str.len() + 1);
196            let truncated = truncate_text(name, name_budget);
197
198            line.push_with_style(format!("{marker}{indent}  "), style);
199            push_status_marker(line, *status, is_selected, theme);
200            push_name_padding_stats(
201                line,
202                truncated.as_ref(),
203                style,
204                &stats_str,
205                *additions,
206                *deletions,
207                left_width.saturating_sub(prefix_width + 2),
208                is_selected,
209                theme,
210            );
211        }
212    }
213}
214
215fn row_style(is_selected: bool, theme: &tui::Theme) -> Style {
216    if is_selected { theme.selected_row_style() } else { Style::default().bg_color(theme.sidebar_bg()) }
217}
218
219fn push_status_marker(line: &mut Line, status: FileStatus, is_selected: bool, theme: &tui::Theme) {
220    let status_color = match status {
221        FileStatus::Deleted | FileStatus::Renamed => theme.diff_removed_fg(),
222        FileStatus::Modified => theme.text_secondary(),
223        FileStatus::Added | FileStatus::Untracked => theme.diff_added_fg(),
224    };
225    line.push_with_style(
226        format!("{} ", status.marker()),
227        if is_selected {
228            theme.selected_row_style_with_fg(status_color)
229        } else {
230            Style::fg(status_color).bg_color(theme.sidebar_bg())
231        },
232    );
233}
234
235#[allow(clippy::too_many_arguments)]
236fn push_name_padding_stats(
237    line: &mut Line,
238    name: &str,
239    name_style: Style,
240    stats_str: &str,
241    additions: usize,
242    deletions: usize,
243    available: usize,
244    is_selected: bool,
245    theme: &tui::Theme,
246) {
247    let name_width = name.chars().count();
248    let padding = available.saturating_sub(name_width + stats_str.len());
249
250    line.push_with_style(name, name_style);
251    if padding > 0 {
252        line.push_with_style(
253            " ".repeat(padding),
254            if is_selected { theme.selected_row_style() } else { Style::default().bg_color(theme.sidebar_bg()) },
255        );
256    }
257
258    let add_str = format!("+{additions}");
259    let del_str = format!("/-{deletions}");
260    line.push_with_style(
261        &add_str,
262        if is_selected {
263            theme.selected_row_style_with_fg(theme.diff_added_fg())
264        } else {
265            Style::fg(theme.diff_added_fg()).bg_color(theme.sidebar_bg())
266        },
267    );
268    line.push_with_style(
269        &del_str,
270        if is_selected {
271            theme.selected_row_style_with_fg(theme.diff_removed_fg())
272        } else {
273            Style::fg(theme.diff_removed_fg()).bg_color(theme.sidebar_bg())
274        },
275    );
276}