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        self.tree.ensure_cache();
124
125        let visible_entries = self.tree.visible_entries();
126        let tree_selected = self.tree.selected_visible();
127
128        let row_count = height.max(visible_entries.len());
129        let mut lines = Vec::with_capacity(height);
130
131        for i in 0..row_count {
132            let mut line = Line::default();
133            let queue_row = self.queued_comment_count > 0 && i == height.saturating_sub(1);
134            if queue_row {
135                let indicator = format!(
136                    " [{} comment{}]",
137                    self.queued_comment_count,
138                    if self.queued_comment_count == 1 { "" } else { "s" },
139                );
140                let padded = truncate_text(&indicator, width);
141                let pad = width.saturating_sub(padded.chars().count());
142                line.push_with_style(padded.as_ref(), Style::fg(theme.accent()).bg_color(theme.sidebar_bg()));
143                if pad > 0 {
144                    line.push_with_style(" ".repeat(pad), Style::default().bg_color(theme.sidebar_bg()));
145                }
146            } else {
147                let scrolled_i = i + self.cursor.scroll;
148                if let Some(entry) = visible_entries.get(scrolled_i) {
149                    render_file_tree_cell(&mut line, entry, scrolled_i == tree_selected, width, theme);
150                } else {
151                    line.push_with_style(" ".repeat(width), Style::default().bg_color(theme.sidebar_bg()));
152                }
153            }
154
155            lines.push(line);
156        }
157
158        lines.truncate(height);
159        Frame::new(lines)
160    }
161}
162
163fn render_file_tree_cell(
164    line: &mut Line,
165    entry: &FileTreeEntry,
166    is_selected: bool,
167    left_width: usize,
168    theme: &tui::Theme,
169) {
170    let style = row_style(is_selected, theme);
171    let marker = if is_selected { "> " } else { "  " };
172    let indent = "  ".repeat(entry.depth);
173    let prefix_width = 2 + entry.depth * 2 + 2;
174
175    match &entry.kind {
176        FileTreeEntryKind::Directory { name, expanded, .. } => {
177            let icon = if *expanded { "\u{25be} " } else { "\u{25b8} " };
178            let name_budget = left_width.saturating_sub(prefix_width);
179            let display_name = format!("{name}/");
180            let truncated = truncate_text(&display_name, name_budget);
181            let remaining = left_width.saturating_sub(prefix_width + truncated.chars().count());
182
183            line.push_with_style(format!("{marker}{indent}{icon}"), style);
184            line.push_with_style(truncated.as_ref(), style.bold());
185            if remaining > 0 {
186                line.push_with_style(" ".repeat(remaining), style);
187            }
188        }
189        FileTreeEntryKind::File { name, status, additions, deletions, .. } => {
190            let stats_str = format!("+{additions}/-{deletions}");
191            let name_budget = left_width.saturating_sub(prefix_width + 2 + stats_str.len() + 1);
192            let truncated = truncate_text(name, name_budget);
193
194            line.push_with_style(format!("{marker}{indent}  "), style);
195            push_status_marker(line, *status, is_selected, theme);
196            push_name_padding_stats(
197                line,
198                truncated.as_ref(),
199                style,
200                &stats_str,
201                *additions,
202                *deletions,
203                left_width.saturating_sub(prefix_width + 2),
204                is_selected,
205                theme,
206            );
207        }
208    }
209}
210
211fn row_style(is_selected: bool, theme: &tui::Theme) -> Style {
212    if is_selected { theme.selected_row_style() } else { Style::default().bg_color(theme.sidebar_bg()) }
213}
214
215fn push_status_marker(line: &mut Line, status: FileStatus, is_selected: bool, theme: &tui::Theme) {
216    let status_color = match status {
217        FileStatus::Deleted | FileStatus::Renamed => theme.diff_removed_fg(),
218        FileStatus::Modified => theme.text_secondary(),
219        FileStatus::Added | FileStatus::Untracked => theme.diff_added_fg(),
220    };
221    line.push_with_style(
222        format!("{} ", status.marker()),
223        if is_selected {
224            theme.selected_row_style_with_fg(status_color)
225        } else {
226            Style::fg(status_color).bg_color(theme.sidebar_bg())
227        },
228    );
229}
230
231#[allow(clippy::too_many_arguments)]
232fn push_name_padding_stats(
233    line: &mut Line,
234    name: &str,
235    name_style: Style,
236    stats_str: &str,
237    additions: usize,
238    deletions: usize,
239    available: usize,
240    is_selected: bool,
241    theme: &tui::Theme,
242) {
243    let name_width = name.chars().count();
244    let padding = available.saturating_sub(name_width + stats_str.len());
245
246    line.push_with_style(name, name_style);
247    if padding > 0 {
248        line.push_with_style(
249            " ".repeat(padding),
250            if is_selected { theme.selected_row_style() } else { Style::default().bg_color(theme.sidebar_bg()) },
251        );
252    }
253
254    let add_str = format!("+{additions}");
255    let del_str = format!("/-{deletions}");
256    line.push_with_style(
257        &add_str,
258        if is_selected {
259            theme.selected_row_style_with_fg(theme.diff_added_fg())
260        } else {
261            Style::fg(theme.diff_added_fg()).bg_color(theme.sidebar_bg())
262        },
263    );
264    line.push_with_style(
265        &del_str,
266        if is_selected {
267            theme.selected_row_style_with_fg(theme.diff_removed_fg())
268        } else {
269            Style::fg(theme.diff_removed_fg()).bg_color(theme.sidebar_bg())
270        },
271    );
272}