Skip to main content

mermaid_cli/render/widgets/
file_picker.rs

1//! @-mention file picker widget — renders the fuzzy-ranked file matches for
2//! the active `@`-token with the selected row highlighted. Visible whenever
3//! `UiState::file_picker_open()` (an @-token under the cursor, not
4//! dismissed, not a slash command); replaces the bottom status bar while
5//! open, same screen region as the slash palette.
6//!
7//! Keyboard handling lives in the reducer's file-picker interception block.
8//! This widget is purely presentational — it consumes pre-ranked matches
9//! and a selection index.
10
11use ratatui::{
12    buffer::Buffer,
13    layout::Rect,
14    style::{Modifier, Style},
15    text::{Line, Span},
16    widgets::{Block, Borders, Paragraph, Widget},
17};
18
19use crate::render::theme::Theme;
20
21/// Visible-row window, matching the slash palette's geometry.
22const MAX_VISIBLE_ROWS: usize = 8;
23
24pub struct FilePickerWidget<'a> {
25    pub theme: &'a Theme,
26    /// Fuzzy-ranked matches, best first (already capped by the reducer).
27    pub matches: &'a [String],
28    /// Index into `matches` of the highlighted row; clamped defensively.
29    pub selected_index: usize,
30    /// First walk still in flight and no cached list yet.
31    pub loading: bool,
32}
33
34impl<'a> Widget for FilePickerWidget<'a> {
35    fn render(self, area: Rect, buf: &mut Buffer) {
36        // Same scroll-window math as the slash palette: selection anchors at
37        // the bottom row once it passes the visible band.
38        let total = self.matches.len();
39        let selected = self.selected_index.min(total.saturating_sub(1));
40        let scroll_offset = if selected >= MAX_VISIBLE_ROWS {
41            selected + 1 - MAX_VISIBLE_ROWS
42        } else {
43            0
44        };
45        let visible_end = (scroll_offset + MAX_VISIBLE_ROWS).min(total);
46
47        let title = if total > MAX_VISIBLE_ROWS {
48            format!(
49                " Files ({}-{} of {})  ↑↓ navigate · Tab/Enter insert · Esc dismiss ",
50                scroll_offset + 1,
51                visible_end,
52                total
53            )
54        } else {
55            format!(" Files ({total})  ↑↓ navigate · Tab/Enter insert · Esc dismiss ")
56        };
57        let block = Block::default()
58            .borders(Borders::ALL)
59            .border_style(Style::new().fg(self.theme.colors.border.to_color()))
60            .title(title);
61
62        if self.matches.is_empty() {
63            let text = if self.loading {
64                "  Scanning project files..."
65            } else {
66                "  No matching files"
67            };
68            let line = Line::from(vec![Span::styled(
69                text,
70                Style::new().fg(self.theme.colors.text_disabled.to_color()),
71            )]);
72            Paragraph::new(vec![line]).block(block).render(area, buf);
73            return;
74        }
75
76        let mut lines: Vec<Line> = Vec::with_capacity(MAX_VISIBLE_ROWS);
77        for (offset, path) in self.matches[scroll_offset..visible_end].iter().enumerate() {
78            let absolute_index = scroll_offset + offset;
79            let is_selected = absolute_index == selected;
80
81            // Directory prefix muted, filename in primary — the eye lands on
82            // the basename first (Claude Code's weighting).
83            let (dir, name) = match path.rfind('/') {
84                // A trailing `/` marks a directory: weight the whole path.
85                Some(_) if path.ends_with('/') => ("", path.as_str()),
86                Some(idx) => path.split_at(idx + 1),
87                None => ("", path.as_str()),
88            };
89            let dir_style = if is_selected {
90                Style::new()
91                    .fg(self.theme.colors.text_secondary.to_color())
92                    .add_modifier(Modifier::REVERSED)
93            } else {
94                Style::new().fg(self.theme.colors.text_secondary.to_color())
95            };
96            let name_style = if is_selected {
97                Style::new()
98                    .fg(self.theme.colors.text_highlight.to_color())
99                    .add_modifier(Modifier::BOLD | Modifier::REVERSED)
100            } else {
101                Style::new().fg(self.theme.colors.text_primary.to_color())
102            };
103            lines.push(Line::from(vec![
104                Span::styled("  ", dir_style),
105                Span::styled(dir.to_string(), dir_style),
106                Span::styled(name.to_string(), name_style),
107            ]));
108        }
109        Paragraph::new(lines).block(block).render(area, buf);
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    fn render_lines(widget: FilePickerWidget<'_>, width: u16, height: u16) -> Vec<String> {
118        let area = Rect::new(0, 0, width, height);
119        let mut buf = Buffer::empty(area);
120        widget.render(area, &mut buf);
121        (0..height)
122            .map(|y| {
123                (0..width)
124                    .map(|x| buf[(x, y)].symbol().to_string())
125                    .collect()
126            })
127            .collect()
128    }
129
130    #[test]
131    fn renders_matches_with_count_in_title() {
132        let theme = Theme::dark();
133        let matches = vec!["src/main.rs".to_string(), "docs/".to_string()];
134        let lines = render_lines(
135            FilePickerWidget {
136                theme: &theme,
137                matches: &matches,
138                selected_index: 0,
139                loading: false,
140            },
141            60,
142            5,
143        );
144        let all = lines.join("\n");
145        assert!(all.contains("Files (2)"), "{all}");
146        assert!(all.contains("main.rs"), "{all}");
147        assert!(all.contains("docs/"), "{all}");
148    }
149
150    #[test]
151    fn out_of_range_selection_does_not_panic() {
152        let theme = Theme::dark();
153        let matches = vec!["a.rs".to_string()];
154        let lines = render_lines(
155            FilePickerWidget {
156                theme: &theme,
157                matches: &matches,
158                selected_index: 99,
159                loading: false,
160            },
161            40,
162            4,
163        );
164        assert!(lines.join("\n").contains("a.rs"));
165    }
166
167    #[test]
168    fn empty_states_distinguish_loading_from_no_match() {
169        let theme = Theme::dark();
170        let loading = render_lines(
171            FilePickerWidget {
172                theme: &theme,
173                matches: &[],
174                selected_index: 0,
175                loading: true,
176            },
177            50,
178            3,
179        )
180        .join("\n");
181        assert!(loading.contains("Scanning project files"), "{loading}");
182        let empty = render_lines(
183            FilePickerWidget {
184                theme: &theme,
185                matches: &[],
186                selected_index: 0,
187                loading: false,
188            },
189            50,
190            3,
191        )
192        .join("\n");
193        assert!(empty.contains("No matching files"), "{empty}");
194    }
195}