rs-hop 0.4.1

Fuzzy-finder TUI to jump between git repositories and folders
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
//! A filesystem picker for repairing or choosing entry paths.
//!
//! It opens at a starting directory (for path repair, the nearest existing
//! ancestor of the broken path), lists its children with a typed filter, lets
//! the user descend/ascend, and returns the chosen path. Folders are always
//! selectable; files only when `allow_files` is set. The box shows an `xx/yy`
//! position badge in its border and `Ctrl+h` toggles hidden (dot-prefixed)
//! entries, which are hidden by default. Styled after `ratada`'s picker while
//! staying hop's own non-blocking overlay.

use std::cell::Cell;
use std::fs;
use std::path::{Path, PathBuf};

use crossterm::event::{KeyCode, KeyEvent};
use ratada::input::InputField;
use ratada::text::truncate;
use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Clear, Paragraph};

use crate::theme::Skin;
use crate::tui::list_layout::moved_cursor;
use crate::tui::presentation::{FieldView, field_spans};
use crate::tui::skin::Colors;
use crate::tui::widgets::centered_rect;

/// The label in front of the picker's filter line.
const FILTER_LABEL: &str = "filter: ";

/// Outcome of feeding a key to the picker.
pub enum PickerResult {
    /// Still browsing.
    Pending,
    /// The user chose this path.
    Selected(PathBuf),
    /// The user cancelled.
    Cancel,
}

/// One listed child entry.
struct Entry {
    path: PathBuf,
    name: String,
    is_dir: bool,
}

/// Browsable filesystem picker state.
pub struct PathPicker {
    current_dir: PathBuf,
    allow_files: bool,
    /// When false (the default), dot-prefixed entries are hidden.
    show_hidden: bool,
    entries: Vec<Entry>,
    visible: Vec<usize>,
    filter: InputField,
    cursor: usize,
    /// The list scroll offset, carried across frames by `ratada::list`.
    offset: Cell<usize>,
}

impl PathPicker {
    /// Opens the picker at `start` (or the filesystem root when it has no
    /// usable directory). `allow_files` lets files be chosen, not just folders.
    pub fn new(start: &Path, allow_files: bool) -> Self {
        let current_dir = start_dir(start);
        let mut picker = PathPicker {
            current_dir,
            allow_files,
            show_hidden: false,
            entries: Vec::new(),
            visible: Vec::new(),
            filter: InputField::new(""),
            cursor: 0,
            offset: Cell::new(0),
        };
        picker.reload();
        picker
    }

    /// Handles a key, navigating the tree or choosing/cancelling.
    pub fn handle_key(&mut self, key: KeyEvent) -> PickerResult {
        match key.code {
            KeyCode::Esc => return PickerResult::Cancel,
            _ if self.navigate(key) => {}
            KeyCode::Right => self.descend(),
            KeyCode::Left => self.ascend(),
            KeyCode::Backspace if self.filter.value().is_empty() => {
                self.ascend();
            }
            // `is_command`, so AltGr (Control+Alt) types instead of toggling.
            KeyCode::Char('h') if ratada::input::is_command(key) => {
                self.toggle_hidden();
            }
            KeyCode::Enter => return self.choose(),
            _ => {
                if self.filter.handle_key(key) {
                    self.apply_filter();
                }
            }
        }
        PickerResult::Pending
    }

    /// Inserts a bracketed paste into the filter and re-runs the match.
    pub fn paste(&mut self, text: &str) {
        self.filter.paste(text);
        self.apply_filter();
    }

    /// Renders the picker centred in `area`, ratada-style: a rounded box titled
    /// `Pick path` with the current directory on a dim header row, the caret
    /// filter, the scrollable entry list, and a compact footer; the `xx/yy`
    /// position badge sits in the bottom-right border.
    pub fn render(&self, frame: &mut Frame, area: Rect, skin: &Skin) {
        let colors = Colors::from_palette(&skin.palette);
        let rect =
            centered_rect(70, area.height.saturating_sub(4).max(8), area);
        frame.render_widget(Clear, rect);
        let block = ratada::chrome::modal_block(skin, "Pick path");
        let inner = block.inner(rect);
        frame.render_widget(block, rect);
        // The position badge reads as part of the bottom-right border.
        let badge =
            ratada::chrome::position_badge(self.cursor, self.visible.len());
        ratada::chrome::render_badge(frame, rect, skin, &badge);

        let width = inner.width as usize;
        let footer = ratada::shortcut_hints::lines(
            &[
                ("\u{2190}\u{2192}", "browse"),
                ("enter", "pick"),
                ("^H", "hidden"),
            ],
            skin.palette.accent_dim,
            width,
        );
        let footer_h = (footer.len() as u16).max(1);
        let rows = Layout::default()
            .direction(Direction::Vertical)
            .constraints([
                Constraint::Length(1),
                Constraint::Length(1),
                Constraint::Min(1),
                Constraint::Length(footer_h),
            ])
            .split(inner);

        frame.render_widget(
            Paragraph::new(Line::from(Span::styled(
                truncate(&self.current_dir.to_string_lossy(), width),
                Style::default().fg(colors.dim),
            ))),
            rows[0],
        );
        self.render_filter(frame, rows[1], skin);
        self.render_list(frame, rows[2], skin);
        frame.render_widget(Paragraph::new(footer), rows[3]);
    }

    /// Renders the filter line (the toolkit's block caret, scrolling with `…`).
    fn render_filter(&self, frame: &mut Frame, area: Rect, skin: &Skin) {
        let colors = Colors::from_palette(&skin.palette);
        let mut spans =
            vec![Span::styled(FILTER_LABEL, Style::default().fg(colors.dim))];
        spans.extend(field_spans(FieldView {
            field: &self.filter,
            palette: &skin.palette,
            width: (area.width as usize)
                .saturating_sub(FILTER_LABEL.chars().count()),
            focused: true,
        }));
        frame.render_widget(Paragraph::new(Line::from(spans)), area);
    }

    /// Renders the entry list via `ratada::list` (cursor highlight + scrollbar);
    /// directories keep the accent colour.
    fn render_list(&self, frame: &mut Frame, area: Rect, skin: &Skin) {
        let accent = Colors::from_palette(&skin.palette).accent;
        let width = area.width as usize;
        let rows: Vec<Line<'static>> = self
            .visible
            .iter()
            .map(|&index| {
                let entry = &self.entries[index];
                let marker = if entry.is_dir { "/" } else { " " };
                let line = Line::from(truncate(
                    &format!("{marker} {}", entry.name),
                    width,
                ));
                if entry.is_dir {
                    line.style(Style::default().fg(accent))
                } else {
                    line
                }
            })
            .collect();
        ratada::list::render(
            frame,
            area,
            skin,
            ratada::list::ListView {
                rows,
                selected: self.cursor,
                offset: &self.offset,
            },
        );
    }

    /// Applies a navigation key through the shared helper, reporting whether it
    /// was one. Anything else falls through to the filter field.
    fn navigate(&mut self, key: KeyEvent) -> bool {
        match moved_cursor(key, self.cursor, self.visible.len()) {
            Some(cursor) => {
                self.cursor = cursor;
                true
            }
            None => false,
        }
    }

    /// Descends into the highlighted directory.
    fn descend(&mut self) {
        if let Some(entry) = self.selected_entry()
            && entry.is_dir
        {
            self.current_dir = entry.path.clone();
            self.filter = InputField::new("");
            self.reload();
        }
    }

    /// Moves to the parent directory.
    fn ascend(&mut self) {
        if let Some(parent) = self.current_dir.parent() {
            self.current_dir = parent.to_path_buf();
            self.filter = InputField::new("");
            self.reload();
        }
    }

    /// Chooses the highlighted entry when it is selectable.
    fn choose(&mut self) -> PickerResult {
        match self.selected_entry() {
            Some(entry) if entry.is_dir || self.allow_files => {
                PickerResult::Selected(entry.path.clone())
            }
            _ => PickerResult::Pending,
        }
    }

    /// The highlighted entry, if any.
    fn selected_entry(&self) -> Option<&Entry> {
        self.visible
            .get(self.cursor)
            .map(|&index| &self.entries[index])
    }

    /// Reloads the children of the current directory and resets the cursor.
    fn reload(&mut self) {
        self.entries = read_children(
            &self.current_dir,
            self.allow_files,
            self.show_hidden,
        );
        self.cursor = 0;
        self.offset.set(0);
        self.apply_filter();
    }

    /// Toggles hidden (dot-prefixed) entries, keeping the current directory and
    /// filter; re-reads the directory and re-applies the filter.
    fn toggle_hidden(&mut self) {
        self.show_hidden = !self.show_hidden;
        self.entries = read_children(
            &self.current_dir,
            self.allow_files,
            self.show_hidden,
        );
        self.offset.set(0);
        self.apply_filter();
    }

    /// Recomputes the visible indices from the filter text.
    fn apply_filter(&mut self) {
        let needle = self.filter.value().to_lowercase();
        self.visible = self
            .entries
            .iter()
            .enumerate()
            .filter(|(_, entry)| {
                needle.is_empty() || entry.name.to_lowercase().contains(&needle)
            })
            .map(|(index, _)| index)
            .collect();
        if self.cursor >= self.visible.len() {
            self.cursor = self.visible.len().saturating_sub(1);
        }
    }
}

/// The directory to open at: `start` if it is a directory, else its parent,
/// else the filesystem root.
fn start_dir(start: &Path) -> PathBuf {
    if start.is_dir() {
        return start.to_path_buf();
    }
    start
        .parent()
        .map(Path::to_path_buf)
        .unwrap_or_else(|| PathBuf::from("/"))
}

/// Reads the children of `dir`: directories first (sorted), then files (sorted)
/// when `allow_files`. Dot-prefixed entries are skipped unless `show_hidden`.
/// Unreadable directories yield an empty list.
fn read_children(
    dir: &Path,
    allow_files: bool,
    show_hidden: bool,
) -> Vec<Entry> {
    let Ok(read_dir) = fs::read_dir(dir) else {
        return Vec::new();
    };
    let mut dirs: Vec<Entry> = Vec::new();
    let mut files: Vec<Entry> = Vec::new();
    for entry in read_dir.flatten() {
        let path = entry.path();
        let name = entry.file_name().to_string_lossy().into_owned();
        if !show_hidden && is_hidden(&name) {
            continue;
        }
        if path.is_dir() {
            dirs.push(Entry {
                path,
                name,
                is_dir: true,
            });
        } else if allow_files {
            files.push(Entry {
                path,
                name,
                is_dir: false,
            });
        }
    }
    dirs.sort_by_key(|entry| entry.name.to_lowercase());
    files.sort_by_key(|entry| entry.name.to_lowercase());
    dirs.into_iter().chain(files).collect()
}

/// Whether an entry name is hidden (dot-prefixed, the Unix convention).
fn is_hidden(name: &str) -> bool {
    name.starts_with('.')
}

#[cfg(test)]
mod tests {
    use std::fs;

    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};

    use super::{PathPicker, is_hidden};

    /// A picker over a fresh temp dir with one visible and one hidden entry.
    /// The name carries the pid so parallel test binaries cannot collide.
    fn picker_over_a_temp_dir() -> (PathPicker, std::path::PathBuf) {
        let dir = std::env::temp_dir()
            .join(format!("hop-path-picker-{}", std::process::id()));
        let _ = fs::remove_dir_all(&dir);
        fs::create_dir_all(dir.join("visible")).expect("temp dir is writable");
        fs::create_dir_all(dir.join(".hidden")).expect("temp dir is writable");
        (PathPicker::new(&dir, false), dir)
    }

    /// `Ctrl+H` toggles hidden entries, but the picker also has a filter field
    /// right there - so `AltGr+H` (Control+Alt) must type into the filter
    /// instead of toggling. That distinction is why this arm goes through
    /// `is_command` rather than a bare CONTROL check.
    #[test]
    fn altgr_h_types_into_the_filter_instead_of_toggling_hidden() {
        let (mut picker, dir) = picker_over_a_temp_dir();
        assert!(!picker.show_hidden);

        picker.handle_key(KeyEvent::new(
            KeyCode::Char('h'),
            KeyModifiers::CONTROL | KeyModifiers::ALT,
        ));
        assert!(!picker.show_hidden, "AltGr+H toggled the hidden entries");
        assert_eq!(picker.filter.value(), "h", "AltGr+H did not type");

        let _ = fs::remove_dir_all(dir);
    }

    #[test]
    fn ctrl_h_toggles_the_hidden_entries() {
        let (mut picker, dir) = picker_over_a_temp_dir();
        let ctrl_h = KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL);

        picker.handle_key(ctrl_h);
        assert!(picker.show_hidden);
        assert!(picker.filter.value().is_empty(), "Ctrl+H must not type");

        picker.handle_key(ctrl_h);
        assert!(!picker.show_hidden);

        let _ = fs::remove_dir_all(dir);
    }

    #[test]
    fn dot_prefixed_names_are_hidden() {
        assert!(is_hidden(".git"));
        assert!(is_hidden(".config"));
        assert!(!is_hidden("src"));
        assert!(!is_hidden("Cargo.toml"));
    }
}