Skip to main content

quadraui/compose/
folder_picker.rs

1//! `FolderPickerController` — engine-level folder/workspace directory picker.
2//!
3//! Owns all state for an interactive directory-browsing modal:
4//! filesystem walking, fuzzy filtering, scroll, and selection. Renders
5//! via the existing [`Palette`] primitive (no new
6//! `Backend` trait method needed) and accepts backend-neutral
7//! [`UiEvent`]s.
8//!
9//! # Relation to vimcode
10//!
11//! Extracted verbatim from vimcode's TUI-local `FolderPickerState`
12//! (`src/tui_main/mod.rs`, ~285 lines). After the quadraui PR lands,
13//! vimcode removes the TUI-local code and rewires both TUI and GTK
14//! `OpenFolderDialog` through this controller.
15//!
16//! # Adoption status
17//!
18//! The vimcode migration above hasn't landed as of #825's audit — no
19//! confirmed external adopter yet. Kept public rather than demoted:
20//! full TUI + GTK demo coverage already exists
21//! (`examples/tui_folder_picker.rs`, `examples/gtk_folder_picker.rs`,
22//! `examples/common/folder_picker_app.rs`) plus a `TuiDriver` end-to-end
23//! test in `tests/tui_example_driver.rs`, and the extraction above is a
24//! concrete, named migration target, not a speculative one.
25//!
26//! # Usage pattern
27//!
28//! ```rust,ignore
29//! // Instantiate when the user triggers "Open Folder":
30//! let mut picker = FolderPickerController::new(
31//!     std::env::current_dir().unwrap(),
32//!     vec![],       // no extra file names to surface
33//!     false,        // don't show hidden dirs
34//! );
35//!
36//! // In AppLogic::render:
37//! let rect = /* palette popup bounds */;
38//! picker.render(rect, backend);
39//!
40//! // In AppLogic::handle:
41//! let visible_rows = /* rect.height as usize - PALETTE_CHROME_ROWS */;
42//! match picker.handle(&event, visible_rows) {
43//!     FolderPickerEvent::Confirmed { path } => { /* open workspace */ },
44//!     FolderPickerEvent::Cancelled             => { /* dismiss modal */ },
45//!     FolderPickerEvent::Consumed              => { /* redraw */ },
46//!     FolderPickerEvent::Ignored               => {},
47//! }
48//! ```
49//!
50//! # Scroll accounting
51//!
52//! `handle()` requires `visible_rows` — the number of list rows visible
53//! inside the palette chrome (title row + query row + optional scrollbar
54//! row). Consumers typically compute this as
55//! `rect.height as usize - PALETTE_CHROME_ROWS` where
56//! [`PALETTE_CHROME_ROWS`] = 4.
57
58use std::cmp::Reverse;
59use std::path::{Path, PathBuf};
60
61use crate::{
62    Backend, Icon, Key, Modifiers, NamedKey, Palette, PaletteItem, Rect, StyledText, UiEvent,
63    WidgetId,
64};
65
66/// Overhead rows consumed by the `Palette` chrome (title + query + borders).
67///
68/// Subtract from the popup height to get `visible_rows`:
69///
70/// ```rust
71/// # use quadraui::compose::folder_picker::PALETTE_CHROME_ROWS;
72/// let popup_height = 20usize;
73/// let visible_rows = popup_height.saturating_sub(PALETTE_CHROME_ROWS);
74/// # let _ = visible_rows;
75/// ```
76pub const PALETTE_CHROME_ROWS: usize = 4;
77
78/// Default recursion depth for [`FolderPickerController::new`].
79///
80/// Override per-instance with [`FolderPickerController::with_max_depth`].
81pub const DEFAULT_MAX_DEPTH: usize = 5;
82
83/// Default heavy build/dependency directory names that are skipped during
84/// the filesystem walk.
85///
86/// Override per-instance with [`FolderPickerController::with_ignore_dirs`].
87pub const DEFAULT_IGNORE_DIRS: &[&str] = &["target", "node_modules", "__pycache__"];
88
89/// What happened after [`FolderPickerController::handle`] processed an event.
90#[derive(Debug, Clone, PartialEq)]
91pub enum FolderPickerEvent {
92    /// The user confirmed a selection. The resolved absolute path is returned.
93    ///
94    /// The consumer should close the picker and act on the path (e.g.
95    /// open it as a workspace folder). If the user selected `..` the
96    /// controller navigates up internally and emits [`Consumed`] instead.
97    ///
98    /// [`Consumed`]: FolderPickerEvent::Consumed
99    Confirmed { path: PathBuf },
100    /// The user dismissed the picker (Escape).
101    Cancelled,
102    /// Event consumed — internal state changed, caller should redraw.
103    Consumed,
104    /// Event not relevant to this controller.
105    Ignored,
106}
107
108/// Cross-backend compose controller for a directory-browsing picker modal.
109///
110/// Combines filesystem walking, fuzzy filtering, keyboard navigation, and
111/// scroll tracking into a single reusable state machine. Rendering is
112/// delegated to the existing [`Palette`] primitive via
113/// [`Backend::draw_palette`].
114///
115/// See the [module-level documentation](self) for a usage example.
116pub struct FolderPickerController {
117    /// Widget ID used for the rendered `Palette`.
118    id: WidgetId,
119    /// Current browsing root. Changes on `navigate_to` / `navigate_up`.
120    root: PathBuf,
121    /// Live query string (typed characters build up incrementally).
122    query: String,
123    /// All candidate entries relative to `root` (unfiltered, stable).
124    all_entries: Vec<PathBuf>,
125    /// Currently filtered + ranked entries (subset of `all_entries`).
126    filtered: Vec<PathBuf>,
127    /// Index into `filtered` of the highlighted row.
128    selected: usize,
129    /// First visible row index (scroll offset into `filtered`).
130    scroll_top: usize,
131    /// Whether to surface hidden directories (names beginning with `.`).
132    show_hidden: bool,
133    /// Additional dotfile names to surface even when `show_hidden` is false.
134    ///
135    /// E.g. `[".vimcode-workspace"]` lets the consumer surface workspace
136    /// marker files without exposing all hidden files. Defaults to empty.
137    extra_file_names: Vec<String>,
138    /// Ignore directories with these names (heavy build / dependency dirs).
139    ///
140    /// Defaults: [`DEFAULT_IGNORE_DIRS`]. Override with
141    /// [`with_ignore_dirs`](Self::with_ignore_dirs).
142    ignore_dirs: Vec<String>,
143    /// Maximum recursion depth for the filesystem walk.
144    ///
145    /// Defaults to [`DEFAULT_MAX_DEPTH`]. Override with
146    /// [`with_max_depth`](Self::with_max_depth).
147    max_depth: usize,
148}
149
150impl FolderPickerController {
151    /// Create a new picker rooted at `root`.
152    ///
153    /// # Arguments
154    ///
155    /// - `root` — initial browsing directory (typically `env::current_dir()`
156    ///   or the engine's active workspace root).
157    /// - `extra_file_names` — dotfile names to surface even when
158    ///   `show_hidden` is false (e.g. `[".vimcode-workspace".to_string()]`).
159    ///   Pass an empty `Vec` for the common case.
160    /// - `show_hidden` — if true, all hidden directories are shown; if false,
161    ///   only names in `extra_file_names` are surfaced from hidden entries.
162    pub fn new(root: impl Into<PathBuf>, extra_file_names: Vec<String>, show_hidden: bool) -> Self {
163        let root = root.into();
164        let ignore_dirs: Vec<String> = DEFAULT_IGNORE_DIRS
165            .iter()
166            .map(|s| (*s).to_string())
167            .collect();
168        let max_depth = DEFAULT_MAX_DEPTH;
169        let all_entries = collect_dir_entries(
170            &root,
171            show_hidden,
172            &extra_file_names,
173            &ignore_dirs,
174            max_depth,
175        );
176        let filtered = all_entries.iter().take(50).cloned().collect();
177        Self {
178            id: WidgetId::new("folder_picker"),
179            root,
180            query: String::new(),
181            all_entries,
182            filtered,
183            selected: 0,
184            scroll_top: 0,
185            show_hidden,
186            extra_file_names,
187            ignore_dirs,
188            max_depth,
189        }
190    }
191
192    /// Override the `WidgetId` used by the rendered `Palette`.
193    ///
194    /// Use this when more than one folder picker may exist in the same
195    /// app, so each one has a distinct ID. Defaults to
196    /// `WidgetId::new("folder_picker")`.
197    ///
198    /// Returns `self` for builder-style chaining.
199    pub fn with_id(mut self, id: impl Into<String>) -> Self {
200        self.id = WidgetId::new(id.into());
201        self
202    }
203
204    /// Override the list of directory names to skip during the filesystem walk.
205    ///
206    /// Defaults to [`DEFAULT_IGNORE_DIRS`]. Pass an empty `Vec` to walk
207    /// every directory (use with care on large filesystems).
208    ///
209    /// Re-walks the current `root` so the new list takes effect immediately.
210    /// Returns `self` for builder-style chaining.
211    pub fn with_ignore_dirs(mut self, ignore_dirs: Vec<String>) -> Self {
212        self.ignore_dirs = ignore_dirs;
213        self.all_entries = collect_dir_entries(
214            &self.root,
215            self.show_hidden,
216            &self.extra_file_names,
217            &self.ignore_dirs,
218            self.max_depth,
219        );
220        self.refilter();
221        self
222    }
223
224    /// Override the maximum recursion depth for the filesystem walk.
225    ///
226    /// Defaults to [`DEFAULT_MAX_DEPTH`]. Higher values surface deeper
227    /// directories at the cost of a slower initial walk.
228    ///
229    /// Re-walks the current `root` so the new depth takes effect immediately.
230    /// Returns `self` for builder-style chaining.
231    pub fn with_max_depth(mut self, max_depth: usize) -> Self {
232        self.max_depth = max_depth;
233        self.all_entries = collect_dir_entries(
234            &self.root,
235            self.show_hidden,
236            &self.extra_file_names,
237            &self.ignore_dirs,
238            self.max_depth,
239        );
240        self.refilter();
241        self
242    }
243
244    // ── State accessors ───────────────────────────────────────────────
245
246    /// Current browsing root.
247    pub fn root(&self) -> &Path {
248        &self.root
249    }
250
251    /// Current query string.
252    pub fn query(&self) -> &str {
253        &self.query
254    }
255
256    /// Currently filtered entries (relative paths).
257    pub fn filtered(&self) -> &[PathBuf] {
258        &self.filtered
259    }
260
261    /// Index of the highlighted row in `filtered`.
262    pub fn selected(&self) -> usize {
263        self.selected
264    }
265
266    /// Scroll offset (first visible row index in `filtered`).
267    pub fn scroll_top(&self) -> usize {
268        self.scroll_top
269    }
270
271    /// The resolved absolute path for the currently selected entry, or `None`
272    /// if the list is empty.
273    ///
274    /// The `..` entry resolves to the parent of `root`; all others resolve to
275    /// `root.join(rel)`.
276    pub fn selected_path(&self) -> Option<PathBuf> {
277        let rel = self.filtered.get(self.selected)?;
278        if rel.as_os_str() == ".." {
279            self.root.parent().map(|p| p.to_path_buf())
280        } else {
281            Some(self.root.join(rel))
282        }
283    }
284
285    // ── Navigation ────────────────────────────────────────────────────
286
287    /// Navigate to `new_root`, clearing the query and reloading entries.
288    pub fn navigate_to(&mut self, new_root: PathBuf) {
289        self.root = new_root;
290        self.query.clear();
291        self.all_entries = collect_dir_entries(
292            &self.root,
293            self.show_hidden,
294            &self.extra_file_names,
295            &self.ignore_dirs,
296            self.max_depth,
297        );
298        self.filtered = self.all_entries.iter().take(50).cloned().collect();
299        self.selected = 0;
300        self.scroll_top = 0;
301    }
302
303    /// Navigate to the parent directory (no-op at filesystem root).
304    pub fn navigate_up(&mut self) {
305        if let Some(parent) = self.root.parent() {
306            self.navigate_to(parent.to_path_buf());
307        }
308    }
309
310    // ── Query editing ─────────────────────────────────────────────────
311
312    /// Append `c` to the query and refilter.
313    pub fn push_char(&mut self, c: char) {
314        self.query.push(c);
315        self.refilter();
316    }
317
318    /// Remove the last character from the query and refilter.
319    pub fn pop_char(&mut self) {
320        self.query.pop();
321        self.refilter();
322    }
323
324    /// Re-compute `filtered` from `all_entries` and the current `query`.
325    pub fn refilter(&mut self) {
326        self.filtered = filter_dir_entries(&self.all_entries, &self.query);
327        self.selected = 0;
328        self.scroll_top = 0;
329    }
330
331    // ── Selection movement ────────────────────────────────────────────
332
333    /// Move the selection one row up (clamped at 0).
334    pub fn move_up(&mut self) {
335        self.selected = self.selected.saturating_sub(1);
336    }
337
338    /// Move the selection one row down (clamped at last row).
339    pub fn move_down(&mut self) {
340        if !self.filtered.is_empty() {
341            self.selected = (self.selected + 1).min(self.filtered.len() - 1);
342        }
343    }
344
345    /// Clamp `scroll_top` so `selected` is always visible within
346    /// `visible_rows` rows.
347    pub fn sync_scroll(&mut self, visible_rows: usize) {
348        if visible_rows == 0 {
349            return;
350        }
351        if self.selected < self.scroll_top {
352            self.scroll_top = self.selected;
353        }
354        if self.selected >= self.scroll_top + visible_rows {
355            self.scroll_top = self.selected + 1 - visible_rows;
356        }
357    }
358
359    // ── Render ────────────────────────────────────────────────────────
360
361    /// Paint the picker as a `Palette` inside `rect`.
362    ///
363    /// Call this from `AppLogic::render` whenever the picker is open.
364    pub fn render(&self, rect: Rect, backend: &mut dyn Backend) {
365        let palette = self.build_palette(rect);
366        backend.draw_palette(rect, &palette);
367    }
368
369    // ── Handle ────────────────────────────────────────────────────────
370
371    /// Drive the state machine with a backend-neutral `UiEvent`.
372    ///
373    /// `visible_rows` is the number of list rows that fit inside the
374    /// palette chrome; compute it as
375    /// `popup_height.saturating_sub(PALETTE_CHROME_ROWS)`.
376    ///
377    /// # Canonical typing source
378    ///
379    /// Query characters are sourced from `UiEvent::KeyPressed`'s
380    /// `Key::Char(c)` arm only — never from `UiEvent::CharTyped`. Today
381    /// neither the TUI nor GTK backend emits `CharTyped`, and a future
382    /// backend (e.g. Win/macOS IME) might emit *both* for the same input;
383    /// listening to only `KeyPressed` avoids the double-insert risk. If a
384    /// future backend needs IME-composed input, route it through
385    /// `KeyPressed` as well or call [`push_char`](Self::push_char) directly.
386    pub fn handle(&mut self, event: &UiEvent, visible_rows: usize) -> FolderPickerEvent {
387        let result = match event {
388            UiEvent::KeyPressed { key, modifiers, .. } => self.handle_key(key, modifiers),
389            _ => FolderPickerEvent::Ignored,
390        };
391
392        // Keep scroll in sync after any state change.
393        if matches!(result, FolderPickerEvent::Consumed) {
394            self.sync_scroll(visible_rows);
395        }
396
397        result
398    }
399
400    // ── Internal helpers ──────────────────────────────────────────────
401
402    // Key dispatch.
403    //
404    // Trade-off (intentional, matches vimcode's existing behavior): the vim
405    // shortcuts `-` (parent dir), `k` (up), and `j` (down) are matched
406    // *before* the generic `Key::Char(c) => push_char` arm. That means the
407    // query input can never begin with — or contain — those three
408    // characters: a user typing "kubernetes" cannot start the query with
409    // `k`, nor include `j`/`-` anywhere. This is documented for downstream
410    // consumers; if any consumer needs typing-priority (e.g. searching
411    // codebases with hyphenated names), they can build their own dispatch
412    // by calling the state-mutation methods (`push_char`, `move_up`, …)
413    // directly instead of routing through `handle()`.
414    fn handle_key(&mut self, key: &Key, modifiers: &Modifiers) -> FolderPickerEvent {
415        let ctrl = modifiers.ctrl;
416        match key {
417            Key::Named(NamedKey::Escape) => FolderPickerEvent::Cancelled,
418
419            Key::Named(NamedKey::Enter) => {
420                // ".." → navigate up; anything else → confirm selection.
421                let is_dotdot = self
422                    .filtered
423                    .get(self.selected)
424                    .map(|p| p.as_os_str() == "..")
425                    .unwrap_or(false);
426                if is_dotdot {
427                    self.navigate_up();
428                    FolderPickerEvent::Consumed
429                } else if let Some(path) = self.selected_path() {
430                    FolderPickerEvent::Confirmed { path }
431                } else {
432                    FolderPickerEvent::Consumed
433                }
434            }
435
436            // '-' navigates up to parent (vim netrw convention).
437            Key::Char('-') if !ctrl => {
438                self.navigate_up();
439                FolderPickerEvent::Consumed
440            }
441
442            Key::Named(NamedKey::Up) => {
443                self.move_up();
444                FolderPickerEvent::Consumed
445            }
446            // 'k' vim-style up (only if no modifier).
447            Key::Char('k') if !ctrl => {
448                self.move_up();
449                FolderPickerEvent::Consumed
450            }
451
452            Key::Named(NamedKey::Down) => {
453                self.move_down();
454                FolderPickerEvent::Consumed
455            }
456            // 'j' vim-style down (only if no modifier).
457            Key::Char('j') if !ctrl => {
458                self.move_down();
459                FolderPickerEvent::Consumed
460            }
461
462            Key::Named(NamedKey::Backspace) => {
463                self.pop_char();
464                FolderPickerEvent::Consumed
465            }
466
467            Key::Char(c) if !ctrl => {
468                self.push_char(*c);
469                FolderPickerEvent::Consumed
470            }
471
472            _ => FolderPickerEvent::Ignored,
473        }
474    }
475
476    /// Build the `Palette` descriptor for the current state.
477    fn build_palette(&self, rect: Rect) -> Palette {
478        let r = self.root.to_string_lossy();
479        // Truncate from the left if the root path is too long.
480        // Reserve ~30 cells for palette chrome (borders + count chip + padding).
481        let max = (rect.width as usize).saturating_sub(30).max(10);
482        // `r.len()` is bytes; we must split on a UTF-8 char boundary so paths
483        // containing non-ASCII (e.g. Japanese, accented Latin) don't panic.
484        // Approach: walk char_indices() from the start, dropping the leftmost
485        // chars until the remaining suffix fits within `max` bytes.
486        let root_display = if r.len() > max {
487            let s: &str = r.as_ref();
488            let target_drop = s.len().saturating_sub(max);
489            // Find the first char boundary >= target_drop bytes from the start.
490            let mut split_at = s.len();
491            for (idx, _) in s.char_indices() {
492                if idx >= target_drop {
493                    split_at = idx;
494                    break;
495                }
496            }
497            format!("…{}", &s[split_at..])
498        } else {
499            r.into_owned()
500        };
501        let title = format!("Open Folder {root_display}");
502
503        let folder_icon = Icon::new("\u{1F4C1}", "\u{1F4C1}"); // 📁
504        let file_icon = Icon::new("\u{2699}", "\u{2699}"); // ⚙
505
506        let items: Vec<PaletteItem> = self
507            .filtered
508            .iter()
509            .map(|entry| {
510                // Entries that are files (not directories) get the file icon.
511                // We detect them by checking if the full path is a file — or
512                // by the heuristic that the entry name contains a dot and
513                // appears in `extra_file_names`.
514                let name = entry.file_name().and_then(|n| n.to_str()).unwrap_or("");
515                let is_extra_file = self.extra_file_names.iter().any(|e| e == name);
516                let icon = if is_extra_file {
517                    Some(file_icon.clone())
518                } else {
519                    Some(folder_icon.clone())
520                };
521                PaletteItem {
522                    text: StyledText::plain(entry.to_string_lossy().to_string()),
523                    detail: None,
524                    icon,
525                    match_positions: Vec::new(),
526                    depth: 0,
527                    expandable: false,
528                    expanded: false,
529                }
530            })
531            .collect();
532
533        Palette {
534            id: self.id,
535            title,
536            query: self.query.clone(),
537            query_cursor: self.query.len(),
538            items,
539            selected_idx: self.selected,
540            scroll_offset: self.scroll_top,
541            total_count: self.all_entries.len(),
542            has_focus: true,
543            show_query: true,
544            create_label: None,
545            preview: None,
546            mode: crate::primitives::palette::PaletteMode::List,
547        }
548    }
549}
550
551// ── Filesystem helpers ────────────────────────────────────────────────────────
552
553/// Walk `root` collecting relative paths of subdirectories (depth ≤ `max_depth`).
554///
555/// `..` is prepended (unless `root` is a filesystem root) and `.` is always
556/// included so the consumer can open the current directory directly.
557///
558/// Skips:
559/// - hidden names (starting with `.`) unless `show_hidden` is true or the
560///   name appears in `extra_file_names`.
561/// - directory names in `ignore_dirs` (e.g. `target`, `node_modules`).
562fn collect_dir_entries(
563    root: &Path,
564    show_hidden: bool,
565    extra_file_names: &[String],
566    ignore_dirs: &[String],
567    max_depth: usize,
568) -> Vec<PathBuf> {
569    let mut out = Vec::new();
570    // Prepend ".." so the user can navigate up (omit at filesystem root).
571    if root.parent().is_some() {
572        out.push(PathBuf::from(".."));
573    }
574    out.push(PathBuf::from("."));
575    walk_dir_entries_recursive(
576        root,
577        root,
578        &mut out,
579        0,
580        show_hidden,
581        extra_file_names,
582        ignore_dirs,
583        max_depth,
584    );
585    out
586}
587
588#[allow(clippy::too_many_arguments)]
589fn walk_dir_entries_recursive(
590    root: &Path,
591    dir: &Path,
592    out: &mut Vec<PathBuf>,
593    depth: usize,
594    show_hidden: bool,
595    extra_file_names: &[String],
596    ignore_dirs: &[String],
597    max_depth: usize,
598) {
599    if depth > max_depth {
600        return;
601    }
602    let mut entries: Vec<_> = match std::fs::read_dir(dir) {
603        Ok(e) => e.flatten().collect(),
604        Err(_) => return,
605    };
606    entries.sort_by_key(|e| e.file_name());
607    for entry in entries {
608        let path = entry.path();
609        let name = match path.file_name().and_then(|n| n.to_str()) {
610            Some(n) => n.to_owned(),
611            None => continue,
612        };
613        // Hidden entries: skip unless show_hidden or name is in extra_file_names.
614        if name.starts_with('.') && !show_hidden {
615            if path.is_file() && extra_file_names.iter().any(|e| e == &name) {
616                if let Ok(rel) = path.strip_prefix(root) {
617                    out.push(rel.to_path_buf());
618                }
619            }
620            continue;
621        }
622        // Skip heavy build/dep directories.
623        if ignore_dirs.iter().any(|d| d == &name) {
624            continue;
625        }
626        if path.is_dir() {
627            if let Ok(rel) = path.strip_prefix(root) {
628                out.push(rel.to_path_buf());
629            }
630            walk_dir_entries_recursive(
631                root,
632                &path,
633                out,
634                depth + 1,
635                show_hidden,
636                extra_file_names,
637                ignore_dirs,
638                max_depth,
639            );
640        }
641    }
642}
643
644/// Filter `all` by `query` using subsequence matching with a relevance score.
645///
646/// Returns up to 50 entries, sorted by descending score.
647/// If `query` is empty, the first 50 entries are returned unchanged.
648fn filter_dir_entries(all: &[PathBuf], query: &str) -> Vec<PathBuf> {
649    const CAP: usize = 50;
650    if query.is_empty() {
651        return all.iter().take(CAP).cloned().collect();
652    }
653    let q = query.to_lowercase();
654    let mut scored: Vec<(i32, &PathBuf)> = all
655        .iter()
656        .filter_map(|p| {
657            let display = p.to_string_lossy().to_lowercase();
658            dir_fuzzy_score(&display, &q).map(|s| (s, p))
659        })
660        .collect();
661    scored.sort_by_key(|&(s, _)| Reverse(s));
662    scored
663        .into_iter()
664        .take(CAP)
665        .map(|(_, p)| p.clone())
666        .collect()
667}
668
669/// Subsequence fuzzy match returning a relevance score, or `None` if the
670/// query is not a subsequence of `path`.
671///
672/// Higher scores mean the query characters appear closer together and/or
673/// at word boundaries (`/`, `\`, `_`, `-`, `.`). Both forward and backward
674/// slashes count so that Windows paths get the same boundary bonus as
675/// POSIX paths.
676///
677/// Thin wrapper over [`crate::text_util::fuzzy_score`] — the match
678/// positions it also returns aren't needed here (the folder picker doesn't
679/// highlight per-character matches), so this drops them and keeps the
680/// score-only signature the rest of this module already expects.
681fn dir_fuzzy_score(path: &str, query: &str) -> Option<i32> {
682    crate::text_util::fuzzy_score(path, query).map(|(score, _positions)| score)
683}
684
685// ── Tests ─────────────────────────────────────────────────────────────────────
686
687#[cfg(test)]
688mod tests {
689    use super::*;
690
691    /// Number of child directories [`picker_root`] creates. Chosen so
692    /// `filtered()` (which also carries `".."` and `"."`) holds well over
693    /// ten entries, the largest selection index any test below drives.
694    const PICKER_ROOT_CHILDREN: usize = 12;
695
696    /// A throwaway browsing root holding [`PICKER_ROOT_CHILDREN`] empty
697    /// child directories.
698    ///
699    /// Every controller test roots its picker here rather than at the
700    /// ambient `env::temp_dir()`: `FolderPickerController::new` walks the
701    /// root recursively to `DEFAULT_MAX_DEPTH`, so a shared system temp
702    /// directory makes both the entry list and the walk's cost depend on
703    /// whatever else happens to live there. The returned `TempDir` must
704    /// stay bound for the lifetime of the picker — dropping it deletes
705    /// the directory.
706    fn picker_root() -> tempfile::TempDir {
707        let dir = tempfile::tempdir().expect("create picker tempdir");
708        for i in 0..PICKER_ROOT_CHILDREN {
709            std::fs::create_dir(dir.path().join(format!("child{i:02}"))).expect("create child dir");
710        }
711        dir
712    }
713
714    // ── dir_fuzzy_score ───────────────────────────────────────────────
715
716    #[test]
717    fn fuzzy_score_exact_match_scores_high() {
718        let s = dir_fuzzy_score("src/main.rs", "src/main.rs");
719        assert!(s.is_some());
720        assert!(s.unwrap() > 0);
721    }
722
723    #[test]
724    fn fuzzy_score_subsequence_matches() {
725        // "sm" is a subsequence of "src/main"
726        let s = dir_fuzzy_score("src/main", "sm");
727        assert!(s.is_some());
728    }
729
730    #[test]
731    fn fuzzy_score_non_subsequence_is_none() {
732        let s = dir_fuzzy_score("src/main", "xyz");
733        assert!(s.is_none());
734    }
735
736    #[test]
737    fn fuzzy_score_boundary_bonus() {
738        // "m" starting at a "/" boundary should score higher than mid-word.
739        let at_boundary = dir_fuzzy_score("src/main", "m");
740        let mid_word = dir_fuzzy_score("abcmain", "m");
741        assert!(at_boundary.unwrap() > mid_word.unwrap());
742    }
743
744    #[test]
745    fn fuzzy_score_empty_query_none() {
746        // Empty query returns None — the caller (filter_dir_entries) handles
747        // the empty-query fast path before calling dir_fuzzy_score.
748        let s = dir_fuzzy_score("anything", "");
749        // Empty query: qi starts at 0, qb.len() is 0, so qi == qb.len()
750        // at the start → Some(100).  This is fine — filter_dir_entries
751        // short-circuits before reaching here for empty queries.
752        assert!(s.is_some());
753    }
754
755    // ── filter_dir_entries ────────────────────────────────────────────
756
757    #[test]
758    fn filter_returns_all_on_empty_query() {
759        let paths: Vec<PathBuf> = (0..10).map(|i| PathBuf::from(format!("dir{i}"))).collect();
760        let result = filter_dir_entries(&paths, "");
761        assert_eq!(result.len(), 10);
762    }
763
764    #[test]
765    fn filter_caps_at_50() {
766        let paths: Vec<PathBuf> = (0..100).map(|i| PathBuf::from(format!("dir{i}"))).collect();
767        let result = filter_dir_entries(&paths, "");
768        assert_eq!(result.len(), 50);
769    }
770
771    #[test]
772    fn filter_excludes_non_matching() {
773        let paths = vec![
774            PathBuf::from("src/main"),
775            PathBuf::from("docs/readme"),
776            PathBuf::from("tests/unit"),
777        ];
778        let result = filter_dir_entries(&paths, "src");
779        assert_eq!(result.len(), 1);
780        assert_eq!(result[0], PathBuf::from("src/main"));
781    }
782
783    #[test]
784    fn filter_sorts_by_score() {
785        // "sr" matches both "src/main" (boundary) and "usr/local" (mid-word).
786        let paths = vec![PathBuf::from("usr/local"), PathBuf::from("src/main")];
787        let result = filter_dir_entries(&paths, "sr");
788        // "src/main" should rank higher (boundary bonus on 's').
789        assert_eq!(result[0], PathBuf::from("src/main"));
790    }
791
792    // ── FolderPickerController state machine ──────────────────────────
793
794    #[test]
795    fn new_sets_dotdot_and_dot_entries() {
796        let dir = picker_root();
797        let tmp = dir.path().to_path_buf();
798        let picker = FolderPickerController::new(tmp, vec![], false);
799        // ".." and "." should always be the first two entries.
800        assert!(picker.all_entries.contains(&PathBuf::from("..")));
801        assert!(picker.all_entries.contains(&PathBuf::from(".")));
802        // An isolated root makes the whole walk deterministic: "..", "."
803        // and one entry per child directory, nothing else.
804        assert_eq!(
805            picker.filtered().len(),
806            PICKER_ROOT_CHILDREN + 2,
807            "isolated root must yield exactly \"..\", \".\" and one entry per child"
808        );
809    }
810
811    #[test]
812    fn push_and_pop_char_update_query() {
813        let dir = picker_root();
814        let tmp = dir.path().to_path_buf();
815        let mut picker = FolderPickerController::new(tmp, vec![], false);
816        picker.push_char('s');
817        picker.push_char('r');
818        assert_eq!(picker.query(), "sr");
819        picker.pop_char();
820        assert_eq!(picker.query(), "s");
821    }
822
823    #[test]
824    fn move_up_clamps_at_zero() {
825        let dir = picker_root();
826        let tmp = dir.path().to_path_buf();
827        let mut picker = FolderPickerController::new(tmp, vec![], false);
828        assert_eq!(picker.selected(), 0);
829        picker.move_up();
830        assert_eq!(picker.selected(), 0);
831    }
832
833    #[test]
834    fn move_down_advances_selection() {
835        let dir = picker_root();
836        let tmp = dir.path().to_path_buf();
837        let mut picker = FolderPickerController::new(tmp, vec![], false);
838        assert!(picker.filtered().len() >= 2);
839        picker.move_down();
840        assert_eq!(picker.selected(), 1);
841    }
842
843    #[test]
844    fn move_down_clamps_at_last() {
845        let dir = picker_root();
846        let tmp = dir.path().to_path_buf();
847        let mut picker = FolderPickerController::new(tmp, vec![], false);
848        let last = picker.filtered().len().saturating_sub(1);
849        for _ in 0..last + 5 {
850            picker.move_down();
851        }
852        assert_eq!(picker.selected(), last);
853    }
854
855    #[test]
856    fn sync_scroll_brings_selected_into_view() {
857        let dir = picker_root();
858        let tmp = dir.path().to_path_buf();
859        let mut picker = FolderPickerController::new(tmp, vec![], false);
860        // Force a large selection index.
861        assert!(picker.filtered().len() >= 10);
862        picker.selected = 9;
863        picker.sync_scroll(5);
864        assert!(picker.scroll_top + 5 > picker.selected);
865        assert!(picker.scroll_top <= picker.selected);
866    }
867
868    #[test]
869    fn sync_scroll_scrolls_up_when_needed() {
870        let dir = picker_root();
871        let tmp = dir.path().to_path_buf();
872        let mut picker = FolderPickerController::new(tmp, vec![], false);
873        assert!(picker.filtered().len() >= 5);
874        picker.scroll_top = 5;
875        picker.selected = 2; // above scroll_top
876        picker.sync_scroll(3);
877        assert_eq!(picker.scroll_top, 2);
878    }
879
880    #[test]
881    fn escape_event_returns_cancelled() {
882        let dir = picker_root();
883        let tmp = dir.path().to_path_buf();
884        let mut picker = FolderPickerController::new(tmp, vec![], false);
885        let ev = UiEvent::KeyPressed {
886            key: Key::Named(NamedKey::Escape),
887            modifiers: Modifiers::default(),
888            repeat: false,
889        };
890        assert_eq!(picker.handle(&ev, 10), FolderPickerEvent::Cancelled);
891    }
892
893    #[test]
894    fn enter_on_dotdot_navigates_up() {
895        // Use tempfile so parallel test runs don't race on a fixed name and
896        // a crashed prior run can't leak a stale marker directory.
897        let parent = tempfile::tempdir().expect("create parent tempdir");
898        let sub = parent.path().join("nav_up_child");
899        std::fs::create_dir_all(&sub).expect("create child dir");
900        let mut picker = FolderPickerController::new(sub.clone(), vec![], false);
901        // Select ".." (index 0, always prepended when parent exists).
902        picker.selected = 0;
903        assert_eq!(picker.filtered().first(), Some(&PathBuf::from("..")));
904        let ev = UiEvent::KeyPressed {
905            key: Key::Named(NamedKey::Enter),
906            modifiers: Modifiers::default(),
907            repeat: false,
908        };
909        let result = picker.handle(&ev, 10);
910        assert_eq!(result, FolderPickerEvent::Consumed);
911        assert_eq!(picker.root(), parent.path());
912    }
913
914    #[test]
915    fn enter_on_dot_returns_confirmed_with_root() {
916        let dir = picker_root();
917        let tmp = dir.path().to_path_buf();
918        let mut picker = FolderPickerController::new(tmp.clone(), vec![], false);
919        // "." is always at index 1 (after "..") when parent exists.
920        let dot_idx = picker
921            .filtered()
922            .iter()
923            .position(|p| p.as_os_str() == ".")
924            .expect("'.' should always be in filtered");
925        picker.selected = dot_idx;
926        let ev = UiEvent::KeyPressed {
927            key: Key::Named(NamedKey::Enter),
928            modifiers: Modifiers::default(),
929            repeat: false,
930        };
931        let result = picker.handle(&ev, 10);
932        assert_eq!(result, FolderPickerEvent::Confirmed { path: tmp });
933    }
934
935    #[test]
936    fn up_down_key_moves_selection() {
937        let dir = picker_root();
938        let tmp = dir.path().to_path_buf();
939        let mut picker = FolderPickerController::new(tmp, vec![], false);
940        assert!(picker.filtered().len() >= 2);
941        let ev_down = UiEvent::KeyPressed {
942            key: Key::Named(NamedKey::Down),
943            modifiers: Modifiers::default(),
944            repeat: false,
945        };
946        picker.handle(&ev_down, 10);
947        assert_eq!(picker.selected(), 1);
948
949        let ev_up = UiEvent::KeyPressed {
950            key: Key::Named(NamedKey::Up),
951            modifiers: Modifiers::default(),
952            repeat: false,
953        };
954        picker.handle(&ev_up, 10);
955        assert_eq!(picker.selected(), 0);
956    }
957
958    #[test]
959    fn key_char_appends_to_query() {
960        // Query characters arrive via `Key::Char` in `KeyPressed`, *not* via
961        // `CharTyped`. The latter is intentionally ignored to avoid
962        // double-insert when a future backend emits both for the same input.
963        let dir = picker_root();
964        let tmp = dir.path().to_path_buf();
965        let mut picker = FolderPickerController::new(tmp, vec![], false);
966        let ev = UiEvent::KeyPressed {
967            key: Key::Char('s'),
968            modifiers: Modifiers::default(),
969            repeat: false,
970        };
971        picker.handle(&ev, 10);
972        assert_eq!(picker.query(), "s");
973    }
974
975    #[test]
976    fn char_typed_is_ignored() {
977        // Documented contract: `CharTyped` is not the canonical typing
978        // source; only `Key::Char` in `KeyPressed` mutates the query.
979        let dir = picker_root();
980        let tmp = dir.path().to_path_buf();
981        let mut picker = FolderPickerController::new(tmp, vec![], false);
982        let ev = UiEvent::CharTyped('s');
983        let result = picker.handle(&ev, 10);
984        assert_eq!(result, FolderPickerEvent::Ignored);
985        assert_eq!(picker.query(), "");
986    }
987
988    #[test]
989    fn backspace_removes_last_char() {
990        let dir = picker_root();
991        let tmp = dir.path().to_path_buf();
992        let mut picker = FolderPickerController::new(tmp, vec![], false);
993        picker.push_char('s');
994        picker.push_char('r');
995        let ev = UiEvent::KeyPressed {
996            key: Key::Named(NamedKey::Backspace),
997            modifiers: Modifiers::default(),
998            repeat: false,
999        };
1000        picker.handle(&ev, 10);
1001        assert_eq!(picker.query(), "s");
1002    }
1003
1004    #[test]
1005    fn selected_path_resolves_dot_to_root() {
1006        let dir = picker_root();
1007        let tmp = dir.path().to_path_buf();
1008        let picker = FolderPickerController::new(tmp.clone(), vec![], false);
1009        let dot_idx = picker
1010            .filtered()
1011            .iter()
1012            .position(|p| p.as_os_str() == ".")
1013            .unwrap();
1014        let mut picker = picker;
1015        picker.selected = dot_idx;
1016        assert_eq!(picker.selected_path(), Some(tmp));
1017    }
1018
1019    #[test]
1020    fn dash_key_navigates_up() {
1021        let parent = tempfile::tempdir().expect("create parent tempdir");
1022        let sub = parent.path().join("dash_nav_child");
1023        std::fs::create_dir_all(&sub).expect("create child dir");
1024        let mut picker = FolderPickerController::new(sub.clone(), vec![], false);
1025        let ev = UiEvent::KeyPressed {
1026            key: Key::Char('-'),
1027            modifiers: Modifiers::default(),
1028            repeat: false,
1029        };
1030        let result = picker.handle(&ev, 10);
1031        assert_eq!(result, FolderPickerEvent::Consumed);
1032        assert_eq!(picker.root(), parent.path());
1033    }
1034
1035    #[test]
1036    fn build_palette_id_is_folder_picker() {
1037        let dir = picker_root();
1038        let tmp = dir.path().to_path_buf();
1039        let picker = FolderPickerController::new(tmp, vec![], false);
1040        let rect = Rect::new(0.0, 0.0, 80.0, 24.0);
1041        let palette = picker.build_palette(rect);
1042        assert_eq!(palette.id.as_str(), "folder_picker");
1043    }
1044
1045    #[test]
1046    fn build_palette_title_contains_open_folder() {
1047        let dir = picker_root();
1048        let tmp = dir.path().to_path_buf();
1049        let picker = FolderPickerController::new(tmp, vec![], false);
1050        let rect = Rect::new(0.0, 0.0, 80.0, 24.0);
1051        let palette = picker.build_palette(rect);
1052        assert!(palette.title.starts_with("Open Folder"));
1053    }
1054
1055    #[test]
1056    fn build_palette_items_match_filtered() {
1057        let dir = picker_root();
1058        let tmp = dir.path().to_path_buf();
1059        let picker = FolderPickerController::new(tmp, vec![], false);
1060        let rect = Rect::new(0.0, 0.0, 80.0, 24.0);
1061        let palette = picker.build_palette(rect);
1062        assert_eq!(palette.items.len(), picker.filtered().len());
1063    }
1064
1065    #[test]
1066    fn extra_file_names_gets_file_icon() {
1067        // Isolate the marker file in a fresh tempdir so concurrent test
1068        // runs don't race and a crashed prior run can't leak state.
1069        let root = tempfile::tempdir().expect("create marker tempdir");
1070        let marker = root.path().join(".my-workspace");
1071        std::fs::write(&marker, b"").expect("write marker file");
1072        let picker = FolderPickerController::new(
1073            root.path().to_path_buf(),
1074            vec![".my-workspace".to_string()],
1075            false,
1076        );
1077        let rect = Rect::new(0.0, 0.0, 80.0, 24.0);
1078        let palette = picker.build_palette(rect);
1079        // The marker file should appear in items with the ⚙ icon.
1080        let has_marker = palette.items.iter().any(|item| {
1081            item.text
1082                .spans
1083                .first()
1084                .map(|s| s.text.contains(".my-workspace"))
1085                .unwrap_or(false)
1086        });
1087        assert!(has_marker, "marker file should appear in palette items");
1088    }
1089
1090    #[test]
1091    fn build_palette_handles_non_ascii_root_path() {
1092        // `build_palette` must handle non-ASCII paths correctly: it must
1093        // validate UTF-8 char boundaries when slicing the root path to fit
1094        // the viewport width, rather than panicking on multi-byte characters.
1095        let long_japanese = PathBuf::from(format!("/a/{}", "ト".repeat(50)));
1096        let picker = FolderPickerController::new(long_japanese, vec![], false);
1097        // Narrow rect forces truncation: max = max(80-30, 10) = 50 bytes.
1098        let rect = Rect::new(0.0, 0.0, 80.0, 24.0);
1099        // Must not panic.
1100        let palette = picker.build_palette(rect);
1101        assert!(palette.title.starts_with("Open Folder "));
1102        // Sanity: ellipsis should be present and the suffix should still
1103        // be valid UTF-8 (implicit — `format!` would have panicked otherwise).
1104        assert!(palette.title.contains('…'));
1105    }
1106
1107    #[test]
1108    fn build_palette_handles_accented_root_path() {
1109        // Regression: shorter multi-byte (Latin-1 / Latin-Extended) paths
1110        // should also be safe.
1111        let path = PathBuf::from(
1112            "/home/utilisateur/répertoire/sous-répertoire/encore-plus-profond/données",
1113        );
1114        let picker = FolderPickerController::new(path, vec![], false);
1115        let rect = Rect::new(0.0, 0.0, 60.0, 24.0);
1116        let palette = picker.build_palette(rect);
1117        assert!(palette.title.starts_with("Open Folder "));
1118    }
1119
1120    // ── Windows-style path scoring ────────────────────────────────────
1121
1122    #[test]
1123    fn fuzzy_score_treats_backslash_as_boundary() {
1124        // On Windows, paths use `\\` as the separator. Both
1125        // `src\\main.rs`-style and `src/main.rs`-style paths should get
1126        // the same boundary bonus when matching at a directory boundary.
1127        let posix = dir_fuzzy_score("src/main", "m");
1128        let windows = dir_fuzzy_score("src\\main", "m");
1129        // Both should rank higher than a mid-word match.
1130        let mid = dir_fuzzy_score("abcmain", "m");
1131        assert!(posix.unwrap() > mid.unwrap());
1132        assert!(windows.unwrap() > mid.unwrap());
1133        // And they should rank equally (same boundary bonus).
1134        assert_eq!(posix.unwrap(), windows.unwrap());
1135    }
1136
1137    // ── Builder-method overrides ──────────────────────────────────────
1138
1139    #[test]
1140    fn with_id_overrides_widget_id() {
1141        let dir = picker_root();
1142        let tmp = dir.path().to_path_buf();
1143        let picker = FolderPickerController::new(tmp, vec![], false).with_id("picker_b");
1144        let rect = Rect::new(0.0, 0.0, 80.0, 24.0);
1145        assert_eq!(picker.build_palette(rect).id.as_str(), "picker_b");
1146    }
1147
1148    #[test]
1149    fn with_ignore_dirs_replaces_default_list() {
1150        let root = tempfile::tempdir().expect("tempdir");
1151        // Create `target` and `keep_me`; default list skips `target`.
1152        std::fs::create_dir_all(root.path().join("target")).unwrap();
1153        std::fs::create_dir_all(root.path().join("keep_me")).unwrap();
1154        // With an empty override list, `target` is no longer skipped.
1155        let picker = FolderPickerController::new(root.path().to_path_buf(), vec![], false)
1156            .with_ignore_dirs(vec![]);
1157        let has_target = picker
1158            .all_entries
1159            .iter()
1160            .any(|p| p == &PathBuf::from("target"));
1161        let has_keep = picker
1162            .all_entries
1163            .iter()
1164            .any(|p| p == &PathBuf::from("keep_me"));
1165        assert!(
1166            has_target,
1167            "target should be present after with_ignore_dirs(empty)"
1168        );
1169        assert!(has_keep);
1170    }
1171
1172    #[test]
1173    fn with_max_depth_limits_recursion() {
1174        let root = tempfile::tempdir().expect("tempdir");
1175        // Build a nested chain: depth1/depth2/depth3
1176        let d3 = root.path().join("d1").join("d2").join("d3");
1177        std::fs::create_dir_all(&d3).unwrap();
1178        // depth=0 should only emit the top-level "d1".
1179        let picker =
1180            FolderPickerController::new(root.path().to_path_buf(), vec![], false).with_max_depth(0);
1181        let has_d1 = picker.all_entries.iter().any(|p| p.ends_with("d1"));
1182        let has_d2 = picker.all_entries.iter().any(|p| p.ends_with("d2"));
1183        assert!(has_d1);
1184        assert!(!has_d2, "depth=0 should not surface nested subdirs");
1185    }
1186}