Skip to main content

fresh/input/quick_open/
providers.rs

1//! Built-in Quick Open Providers
2//!
3//! This module contains the standard providers:
4//! - FileProvider: Find files in the project (default, no prefix)
5//! - CommandProvider: Command palette (prefix: ">")
6//! - BufferProvider: Switch between open buffers (prefix: "#")
7//! - GotoLineProvider: Go to a specific line (prefix: ":")
8
9use super::{QuickOpenContext, QuickOpenProvider, QuickOpenResult};
10use crate::input::commands::Suggestion;
11use crate::input::fuzzy::FuzzyMatcher;
12use rust_i18n::t;
13
14// ============================================================================
15// Command Provider (prefix: ">")
16// ============================================================================
17
18/// Provider for the command palette
19pub struct CommandProvider {
20    /// Reference to the command registry for filtering
21    command_registry:
22        std::sync::Arc<std::sync::RwLock<crate::input::command_registry::CommandRegistry>>,
23    /// Keybinding resolver for showing shortcuts
24    keybinding_resolver:
25        std::sync::Arc<std::sync::RwLock<crate::input::keybindings::KeybindingResolver>>,
26}
27
28impl CommandProvider {
29    pub fn new(
30        command_registry: std::sync::Arc<
31            std::sync::RwLock<crate::input::command_registry::CommandRegistry>,
32        >,
33        keybinding_resolver: std::sync::Arc<
34            std::sync::RwLock<crate::input::keybindings::KeybindingResolver>,
35        >,
36    ) -> Self {
37        Self {
38            command_registry,
39            keybinding_resolver,
40        }
41    }
42}
43
44impl QuickOpenProvider for CommandProvider {
45    fn prefix(&self) -> &str {
46        ">"
47    }
48
49    fn suggestions(&self, query: &str, context: &QuickOpenContext) -> Vec<Suggestion> {
50        let registry = self.command_registry.read().unwrap();
51        let keybindings = self.keybinding_resolver.read().unwrap();
52
53        registry.filter(
54            query,
55            context.key_context.clone(),
56            &keybindings,
57            context.has_selection,
58            &context.custom_contexts,
59            context.buffer_mode.as_deref(),
60            context.has_lsp_config,
61        )
62    }
63
64    fn on_select(
65        &self,
66        suggestion: Option<&Suggestion>,
67        _query: &str,
68        _context: &QuickOpenContext,
69    ) -> QuickOpenResult {
70        let suggestion = match suggestion {
71            Some(s) if !s.disabled => s,
72            Some(_) => {
73                return QuickOpenResult::Error(t!("status.command_not_available").to_string())
74            }
75            None => return QuickOpenResult::None,
76        };
77
78        let registry = self.command_registry.read().unwrap();
79        let cmd = registry
80            .get_all()
81            .into_iter()
82            .find(|c| c.get_localized_name() == suggestion.text);
83
84        let Some(cmd) = cmd else {
85            return QuickOpenResult::None;
86        };
87
88        let action = cmd.action.clone();
89        let name = cmd.name.clone();
90        drop(registry);
91
92        if let Ok(mut reg) = self.command_registry.write() {
93            reg.record_usage(&name);
94        }
95        QuickOpenResult::ExecuteAction(action)
96    }
97
98    fn as_any(&self) -> &dyn std::any::Any {
99        self
100    }
101}
102
103// ============================================================================
104// Buffer Provider (prefix: "#")
105// ============================================================================
106
107/// Provider for switching between open buffers
108pub struct BufferProvider;
109
110impl BufferProvider {
111    pub fn new() -> Self {
112        Self
113    }
114}
115
116impl Default for BufferProvider {
117    fn default() -> Self {
118        Self::new()
119    }
120}
121
122impl QuickOpenProvider for BufferProvider {
123    fn prefix(&self) -> &str {
124        "#"
125    }
126
127    fn suggestions(&self, query: &str, context: &QuickOpenContext) -> Vec<Suggestion> {
128        // Build the matcher once and reuse it across all buffers.
129        let mut matcher = FuzzyMatcher::new(query);
130        let mut scored: Vec<(Suggestion, i32, usize)> = context
131            .open_buffers
132            .iter()
133            .filter(|buf| !buf.path.is_empty())
134            .filter_map(|buf| {
135                let m = matcher.match_target(&buf.name);
136                if !m.matched {
137                    return None;
138                }
139
140                let display_name = if buf.modified {
141                    format!("{} [+]", buf.name)
142                } else {
143                    buf.name.clone()
144                };
145
146                let suggestion = Suggestion::new(display_name)
147                    .with_description(buf.path.clone())
148                    .with_value(buf.id.to_string());
149                Some((suggestion, m.score, buf.id))
150            })
151            .collect();
152
153        // Sort by score (higher is better), then by ID (lower = older = higher priority when tied)
154        scored.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.2.cmp(&b.2)));
155        scored.into_iter().map(|(s, _, _)| s).collect()
156    }
157
158    fn on_select(
159        &self,
160        suggestion: Option<&Suggestion>,
161        _query: &str,
162        _context: &QuickOpenContext,
163    ) -> QuickOpenResult {
164        suggestion
165            .and_then(|s| s.value.as_deref())
166            .and_then(|v| v.parse::<usize>().ok())
167            .map(QuickOpenResult::ShowBuffer)
168            .unwrap_or(QuickOpenResult::None)
169    }
170
171    fn as_any(&self) -> &dyn std::any::Any {
172        self
173    }
174}
175
176// ============================================================================
177// Go to Line Provider (prefix: ":")
178// ============================================================================
179
180/// Provider for jumping to a specific line number
181pub struct GotoLineProvider;
182
183impl GotoLineProvider {
184    pub fn new() -> Self {
185        Self
186    }
187}
188
189impl Default for GotoLineProvider {
190    fn default() -> Self {
191        Self::new()
192    }
193}
194
195impl QuickOpenProvider for GotoLineProvider {
196    fn prefix(&self) -> &str {
197        ":"
198    }
199
200    fn suggestions(&self, query: &str, _context: &QuickOpenContext) -> Vec<Suggestion> {
201        if query.is_empty() {
202            return vec![
203                Suggestion::disabled(t!("quick_open.goto_line_hint").to_string())
204                    .with_description(t!("quick_open.goto_line_desc").to_string()),
205            ];
206        }
207
208        match query.parse::<usize>() {
209            Ok(n) if n > 0 => {
210                vec![
211                    Suggestion::new(t!("quick_open.goto_line", line = n.to_string()).to_string())
212                        .with_description(t!("quick_open.press_enter").to_string())
213                        .with_value(n.to_string()),
214                ]
215            }
216            _ => vec![
217                Suggestion::disabled(t!("quick_open.invalid_line").to_string())
218                    .with_description(query.to_string()),
219            ],
220        }
221    }
222
223    fn on_select(
224        &self,
225        suggestion: Option<&Suggestion>,
226        _query: &str,
227        _context: &QuickOpenContext,
228    ) -> QuickOpenResult {
229        suggestion
230            .and_then(|s| s.value.as_deref())
231            .and_then(|v| v.parse::<usize>().ok())
232            .filter(|&n| n > 0)
233            .map(QuickOpenResult::GotoLine)
234            .unwrap_or(QuickOpenResult::None)
235    }
236
237    fn as_any(&self) -> &dyn std::any::Any {
238        self
239    }
240}
241
242// ============================================================================
243// File Provider (default, no prefix)
244// ============================================================================
245
246/// Directory names to skip during file walking (shared with plugin_commands.rs pattern).
247const IGNORED_DIRS: &[&str] = &[
248    ".git",
249    "node_modules",
250    "target",
251    "__pycache__",
252    ".hg",
253    ".svn",
254    ".DS_Store",
255];
256
257const MAX_FILES: usize = 50_000;
258
259/// A single file entry in the Quick Open file list.
260#[derive(Clone, Debug)]
261pub struct FileEntry {
262    relative_path: String,
263    frecency_score: f64,
264}
265
266#[derive(Clone)]
267struct FrecencyData {
268    access_count: u32,
269    last_access: std::time::Instant,
270}
271
272/// Shared state between the FileProvider and its background loading task.
273///
274/// Wrapped in a single `Arc<Mutex<>>` to keep the FileProvider struct flat.
275struct FileCache {
276    /// The cached file list, or `None` if not yet loaded.
277    files: Option<std::sync::Arc<Vec<FileEntry>>>,
278    /// Whether a background load is in progress.
279    loading: bool,
280}
281
282/// Provider for finding files in the project.
283///
284/// Uses `git ls-files` via [`ProcessSpawner`] as the fast path (respects
285/// `.gitignore`, works on remote hosts), then falls back to recursive
286/// directory walking via the [`FileSystem`] trait.
287///
288/// File enumeration runs on a background thread to avoid blocking the UI.
289/// When the cache is empty, `suggestions()` returns a "Loading…" placeholder
290/// and kicks off a background task.  When the task finishes it sends an
291/// `AsyncMessage::QuickOpenFilesLoaded` which the editor handles by calling
292/// `set_cache()` and refreshing the prompt.
293#[derive(Clone)]
294pub struct FileProvider {
295    cache: std::sync::Arc<std::sync::Mutex<FileCache>>,
296    frecency: std::sync::Arc<std::sync::RwLock<std::collections::HashMap<String, FrecencyData>>>,
297    filesystem: std::sync::Arc<dyn crate::model::filesystem::FileSystem + Send + Sync>,
298    process_spawner: std::sync::Arc<dyn crate::services::remote::ProcessSpawner>,
299    runtime_handle: Option<tokio::runtime::Handle>,
300    async_sender: Option<std::sync::mpsc::Sender<crate::services::async_bridge::AsyncMessage>>,
301    /// Cancel flag shared with the background walk task.
302    cancel: std::sync::Arc<std::sync::atomic::AtomicBool>,
303}
304
305impl FileProvider {
306    pub fn new(
307        filesystem: std::sync::Arc<dyn crate::model::filesystem::FileSystem + Send + Sync>,
308        process_spawner: std::sync::Arc<dyn crate::services::remote::ProcessSpawner>,
309        runtime_handle: Option<tokio::runtime::Handle>,
310        async_sender: Option<std::sync::mpsc::Sender<crate::services::async_bridge::AsyncMessage>>,
311    ) -> Self {
312        Self {
313            cache: std::sync::Arc::new(std::sync::Mutex::new(FileCache {
314                files: None,
315                loading: false,
316            })),
317            frecency: std::sync::Arc::new(std::sync::RwLock::new(std::collections::HashMap::new())),
318            filesystem,
319            process_spawner,
320            runtime_handle,
321            async_sender,
322            cancel: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
323        }
324    }
325
326    /// Clear the file cache (e.g., after file system changes).
327    pub fn clear_cache(&self) {
328        self.cancel
329            .store(true, std::sync::atomic::Ordering::Relaxed);
330        if let Ok(mut c) = self.cache.lock() {
331            c.files = None;
332            c.loading = false;
333        }
334    }
335
336    /// Cancel any in-progress background file load.
337    /// Called when the user closes Quick Open so we don't keep walking.
338    pub fn cancel_loading(&self) {
339        self.cancel
340            .store(true, std::sync::atomic::Ordering::Relaxed);
341        if let Ok(mut c) = self.cache.lock() {
342            c.loading = false;
343        }
344    }
345
346    /// Update the file cache with final results from a completed background load.
347    pub fn set_cache(&self, files: std::sync::Arc<Vec<FileEntry>>) {
348        if let Ok(mut c) = self.cache.lock() {
349            c.files = Some(files);
350            c.loading = false;
351        }
352    }
353
354    /// Update the file cache with partial results while the background scan
355    /// is still running.  Unlike [`set_cache`], this keeps `loading = true`.
356    pub fn set_partial_cache(&self, files: std::sync::Arc<Vec<FileEntry>>) {
357        if let Ok(mut c) = self.cache.lock() {
358            c.files = Some(files);
359            // Keep c.loading = true — the walk is still in progress.
360        }
361    }
362
363    /// Returns `true` if a background file scan is in progress.
364    fn is_loading(&self) -> bool {
365        self.cache.lock().is_ok_and(|c| c.loading)
366    }
367
368    /// Record file access for frecency ranking
369    pub fn record_access(&self, path: &str) {
370        if let Ok(mut frecency) = self.frecency.write() {
371            let entry = frecency.entry(path.to_string()).or_insert(FrecencyData {
372                access_count: 0,
373                last_access: std::time::Instant::now(),
374            });
375            entry.access_count += 1;
376            entry.last_access = std::time::Instant::now();
377        }
378    }
379
380    fn get_frecency_score(&self, path: &str) -> f64 {
381        self.frecency
382            .read()
383            .ok()
384            .and_then(|m| m.get(path).map(frecency_score))
385            .unwrap_or(0.0)
386    }
387
388    /// Probe the filesystem directly for files matching `query` as a path
389    /// prefix.  This is fast (typically one `read_dir` call) and provides
390    /// immediate results even while the full recursive scan is in progress.
391    ///
392    /// For example, if `cwd` is `/` and `query` is `etc/hosts`, this will
393    /// list `/etc/` and return every file whose name starts with `hosts`
394    /// (e.g. `etc/hosts`, `etc/hosts.allow`, `etc/hosts.deny`).
395    fn probe_prefix(&self, cwd: &str, query: &str) -> Vec<FileEntry> {
396        use std::path::Path;
397
398        if query.is_empty() {
399            return vec![];
400        }
401
402        let abs_path = Path::new(cwd).join(query);
403        let mut results = Vec::new();
404
405        // If the query points to a directory, list its file contents.
406        if let Ok(entries) = self.filesystem.read_dir(&abs_path) {
407            let query_trimmed = query.trim_end_matches('/');
408            for entry in entries {
409                if entry.is_file() && !entry.name.starts_with('.') {
410                    let rel = format!("{}/{}", query_trimmed, entry.name);
411                    results.push(FileEntry {
412                        frecency_score: self.get_frecency_score(&rel),
413                        relative_path: rel,
414                    });
415                }
416            }
417            results.truncate(50);
418            return results;
419        }
420
421        // Otherwise, list the parent directory and filter by the basename
422        // prefix (e.g. query "etc/hosts" → parent "/etc", prefix "hosts").
423        let parent = match abs_path.parent() {
424            Some(p) => p,
425            None => return results,
426        };
427        let basename = match abs_path.file_name().and_then(|n| n.to_str()) {
428            Some(b) => b,
429            None => return results,
430        };
431
432        let rel_parent = match parent.strip_prefix(cwd) {
433            Ok(p) => {
434                let s = p.to_string_lossy().replace('\\', "/");
435                s
436            }
437            Err(_) => return results,
438        };
439
440        if let Ok(entries) = self.filesystem.read_dir(parent) {
441            for entry in entries {
442                if entry.name.starts_with('.') {
443                    continue;
444                }
445                if !entry.name.starts_with(basename) {
446                    continue;
447                }
448                if entry.is_file() {
449                    let rel = if rel_parent.is_empty() {
450                        entry.name.clone()
451                    } else {
452                        format!("{}/{}", rel_parent, entry.name)
453                    };
454                    results.push(FileEntry {
455                        frecency_score: self.get_frecency_score(&rel),
456                        relative_path: rel,
457                    });
458                }
459            }
460        }
461
462        results
463    }
464
465    /// Get the cached file list, or `None` if not yet loaded.
466    ///
467    /// If no cache exists and no load is in progress, spawns a background
468    /// task that will populate the cache and notify the UI via
469    /// `AsyncMessage::QuickOpenFilesLoaded`.
470    fn get_or_start_loading(&self, cwd: &str) -> Option<std::sync::Arc<Vec<FileEntry>>> {
471        let mut cache = self.cache.lock().ok()?;
472
473        if let Some(files) = &cache.files {
474            return Some(std::sync::Arc::clone(files));
475        }
476
477        if cache.loading {
478            return None; // already loading
479        }
480
481        // No cache, not loading — kick off background load
482        let (sender, handle) = match (&self.async_sender, &self.runtime_handle) {
483            (Some(s), Some(h)) => (s.clone(), h.clone()),
484            _ => {
485                // No async support — fall back to synchronous load
486                drop(cache);
487                return self.load_files_sync(cwd);
488            }
489        };
490
491        cache.loading = true;
492        // Reset cancel flag for this new load
493        self.cancel
494            .store(false, std::sync::atomic::Ordering::Relaxed);
495        let cancel = std::sync::Arc::clone(&self.cancel);
496        let frecency = std::sync::Arc::clone(&self.frecency);
497        let filesystem = std::sync::Arc::clone(&self.filesystem);
498        let process_spawner = std::sync::Arc::clone(&self.process_spawner);
499        let cwd = cwd.to_string();
500
501        handle.spawn_blocking(move || {
502            // Fast path: git ls-files returns everything at once.
503            if let Some(files) = try_git_files_blocking(&process_spawner, &cwd) {
504                let frecency_map = frecency.read().ok();
505                let entries: Vec<FileEntry> = files
506                    .into_iter()
507                    .map(|path| {
508                        let score = frecency_map
509                            .as_ref()
510                            .and_then(|m| m.get(&path))
511                            .map(frecency_score)
512                            .unwrap_or(0.0);
513                        FileEntry {
514                            relative_path: path,
515                            frecency_score: score,
516                        }
517                    })
518                    .collect();
519                // Send failure means the receiver has been dropped (editor
520                // shutting down); nothing more to do since we return below.
521                drop(sender.send(
522                    crate::services::async_bridge::AsyncMessage::QuickOpenFilesLoaded {
523                        files: std::sync::Arc::new(entries),
524                        complete: true,
525                    },
526                ));
527                return;
528            }
529
530            // Slow path: directory walk with periodic incremental updates so
531            // the UI can show partial results while the scan continues.
532            walk_dir_with_updates(&*filesystem, &cwd, &cancel, &frecency, &sender);
533        });
534
535        None
536    }
537
538    /// Synchronous fallback when no tokio runtime is available (e.g., tests).
539    fn load_files_sync(&self, cwd: &str) -> Option<std::sync::Arc<Vec<FileEntry>>> {
540        let files = self
541            .try_git_files(cwd)
542            .or_else(|| self.try_walk_dir(cwd))
543            .unwrap_or_default();
544
545        let entries: Vec<FileEntry> = files
546            .into_iter()
547            .map(|path| FileEntry {
548                frecency_score: self.get_frecency_score(&path),
549                relative_path: path,
550            })
551            .collect();
552
553        let files = std::sync::Arc::new(entries);
554        self.set_cache(std::sync::Arc::clone(&files));
555        Some(files)
556    }
557
558    /// Synchronous `try_git_files` — used by the sync fallback path.
559    fn try_git_files(&self, cwd: &str) -> Option<Vec<String>> {
560        let handle = self.runtime_handle.as_ref()?;
561        try_git_files_with_handle(&self.process_spawner, cwd, handle)
562    }
563
564    /// Synchronous `try_walk_dir` — used by the sync fallback path.
565    fn try_walk_dir(&self, cwd: &str) -> Option<Vec<String>> {
566        let cancel = std::sync::atomic::AtomicBool::new(false);
567        try_walk_dir_blocking(&*self.filesystem, cwd, &cancel)
568    }
569}
570
571// ---------------------------------------------------------------------------
572// Free functions used by both the sync path and the background task
573// ---------------------------------------------------------------------------
574
575/// List files via `git ls-files` using a `ProcessSpawner` (blocking).
576///
577/// Called from `spawn_blocking` so we can't hold a tokio runtime handle —
578/// `ProcessSpawner::spawn` is async, so we use `tokio::runtime::Handle::block_on`
579/// from *inside* the blocking thread.
580fn try_git_files_blocking(
581    spawner: &std::sync::Arc<dyn crate::services::remote::ProcessSpawner>,
582    cwd: &str,
583) -> Option<Vec<String>> {
584    // Inside spawn_blocking we can use Handle::current() since the runtime is alive.
585    let handle = tokio::runtime::Handle::try_current().ok()?;
586    try_git_files_with_handle(spawner, cwd, &handle)
587}
588
589fn try_git_files_with_handle(
590    spawner: &std::sync::Arc<dyn crate::services::remote::ProcessSpawner>,
591    cwd: &str,
592    handle: &tokio::runtime::Handle,
593) -> Option<Vec<String>> {
594    let result = handle
595        .block_on(spawner.spawn(
596            "git".to_string(),
597            vec![
598                "ls-files".to_string(),
599                "--cached".to_string(),
600                "--others".to_string(),
601                "--exclude-standard".to_string(),
602            ],
603            Some(cwd.to_string()),
604        ))
605        .ok()?;
606
607    if result.exit_code != 0 {
608        return None;
609    }
610
611    let files: Vec<String> = result
612        .stdout
613        .lines()
614        .filter(|line| !line.is_empty() && !line.starts_with(".git/"))
615        .map(|s| s.to_string())
616        .collect();
617
618    Some(files)
619}
620
621/// Walk the directory tree via `FileSystem::walk_files` (blocking).
622fn try_walk_dir_blocking(
623    fs: &dyn crate::model::filesystem::FileSystem,
624    cwd: &str,
625    cancel: &std::sync::atomic::AtomicBool,
626) -> Option<Vec<String>> {
627    use std::path::Path;
628
629    let base = Path::new(cwd);
630    let mut files = Vec::new();
631
632    // Errors (e.g., root doesn't exist) are treated as "no files found".
633    drop(
634        fs.walk_files(base, IGNORED_DIRS, cancel, &mut |_path, rel| {
635            files.push(rel.to_string());
636            files.len() < MAX_FILES
637        }),
638    );
639
640    if files.is_empty() {
641        None
642    } else {
643        Some(files)
644    }
645}
646
647/// Minimum interval between incremental partial-result updates sent to the UI
648/// during a directory walk.
649const WALK_UPDATE_INTERVAL: std::time::Duration = std::time::Duration::from_millis(300);
650
651/// Walk the directory tree, sending periodic partial updates to the UI so
652/// fuzzy results can be recalculated as new files are discovered.
653fn walk_dir_with_updates(
654    fs: &dyn crate::model::filesystem::FileSystem,
655    cwd: &str,
656    cancel: &std::sync::atomic::AtomicBool,
657    frecency: &std::sync::RwLock<std::collections::HashMap<String, FrecencyData>>,
658    sender: &std::sync::mpsc::Sender<crate::services::async_bridge::AsyncMessage>,
659) {
660    use std::path::Path;
661
662    let base = Path::new(cwd);
663    let mut paths: Vec<String> = Vec::new();
664    let mut last_send = std::time::Instant::now();
665    let mut receiver_gone = false;
666
667    // `walk_files` errors (e.g. root doesn't exist, permission denied at the
668    // top level) are treated as "no files found" — any paths already
669    // collected in `paths` are still surfaced via the final send below.
670    if let Err(e) = fs.walk_files(base, IGNORED_DIRS, cancel, &mut |_path, rel| {
671        paths.push(rel.to_string());
672
673        // Send a partial snapshot at regular intervals.
674        if last_send.elapsed() >= WALK_UPDATE_INTERVAL {
675            let frecency_map = frecency.read().ok();
676            let entries: Vec<FileEntry> = paths
677                .iter()
678                .map(|p| FileEntry {
679                    frecency_score: frecency_map
680                        .as_ref()
681                        .and_then(|m| m.get(p).map(frecency_score))
682                        .unwrap_or(0.0),
683                    relative_path: p.clone(),
684                })
685                .collect();
686            if sender
687                .send(
688                    crate::services::async_bridge::AsyncMessage::QuickOpenFilesLoaded {
689                        files: std::sync::Arc::new(entries),
690                        complete: false,
691                    },
692                )
693                .is_err()
694            {
695                // Receiver dropped (editor shutting down) — stop walking
696                // so we don't waste CPU on results nobody will see.
697                receiver_gone = true;
698                return false;
699            }
700            last_send = std::time::Instant::now();
701        }
702
703        paths.len() < MAX_FILES
704    }) {
705        tracing::debug!("Quick Open walk_files failed: {}", e);
706    }
707
708    if receiver_gone {
709        return;
710    }
711
712    // Send the final complete result.  If this fails the editor is shutting
713    // down — nothing we can do about that, so the error is ignored.
714    let frecency_map = frecency.read().ok();
715    let entries: Vec<FileEntry> = paths
716        .into_iter()
717        .map(|p| {
718            let score = frecency_map
719                .as_ref()
720                .and_then(|m| m.get(&p).map(frecency_score))
721                .unwrap_or(0.0);
722            FileEntry {
723                relative_path: p,
724                frecency_score: score,
725            }
726        })
727        .collect();
728    drop(sender.send(
729        crate::services::async_bridge::AsyncMessage::QuickOpenFilesLoaded {
730            files: std::sync::Arc::new(entries),
731            complete: true,
732        },
733    ));
734}
735
736/// Compute frecency score for a single entry.
737fn frecency_score(data: &FrecencyData) -> f64 {
738    let hours_since_access = data.last_access.elapsed().as_secs_f64() / 3600.0;
739    let recency_weight = if hours_since_access < 4.0 {
740        100.0
741    } else if hours_since_access < 24.0 {
742        70.0
743    } else if hours_since_access < 24.0 * 7.0 {
744        50.0
745    } else if hours_since_access < 24.0 * 30.0 {
746        30.0
747    } else if hours_since_access < 24.0 * 90.0 {
748        10.0
749    } else {
750        1.0
751    };
752    data.access_count as f64 * recency_weight
753}
754
755impl QuickOpenProvider for FileProvider {
756    fn prefix(&self) -> &str {
757        ""
758    }
759
760    fn suggestions(&self, query: &str, context: &QuickOpenContext) -> Vec<Suggestion> {
761        // Strip :line:col suffix so fuzzy matching works when the user appends a jump target
762        let (path_part, _, _) = super::parse_path_line_col(query);
763        let search_query = if path_part.is_empty() {
764            query
765        } else {
766            &path_part
767        };
768
769        // Show a clear error when the remote connection is lost
770        if !self.filesystem.is_remote_connected() {
771            return vec![Suggestion::disabled(
772                "Remote connection lost — cannot list files".to_string(),
773            )];
774        }
775
776        // Get cached files (may be partial during an in-progress scan) or
777        // kick off a background load.
778        let files = self.get_or_start_loading(&context.cwd);
779        let still_loading = self.is_loading();
780
781        // Fast prefix probe: check the filesystem directly for the query
782        // treated as a literal path prefix.  This gives instant results even
783        // before the recursive scan reaches the relevant directory, and is
784        // also valuable after the scan completes since the walk may have
785        // stopped at MAX_FILES before reaching the target file.
786        let prefix_entries = if !search_query.is_empty() {
787            self.probe_prefix(&context.cwd, search_query)
788        } else {
789            vec![]
790        };
791
792        let has_files = files.as_ref().is_some_and(|f| !f.is_empty());
793
794        if !has_files && prefix_entries.is_empty() {
795            if still_loading {
796                return vec![Suggestion::disabled("Loading files…".to_string())];
797            } else {
798                return vec![Suggestion::disabled(t!("quick_open.no_files").to_string())];
799            }
800        }
801
802        let max_results = 100;
803
804        // Collect prefix-probe paths for deduplication.
805        let prefix_set: std::collections::HashSet<&str> = prefix_entries
806            .iter()
807            .map(|e| e.relative_path.as_str())
808            .collect();
809
810        // Score bonus applied to files confirmed to exist via the prefix probe.
811        const PREFIX_PROBE_BOOST: i32 = 200;
812
813        // Build one matcher and reuse it for every target on this keystroke.
814        // The matcher owns the prepared pattern *and* two `Vec<char>` scratch
815        // buffers, so neither query preparation nor per-target allocation
816        // happens on the hot loop after its first iteration.
817        let mut matcher = FuzzyMatcher::new(search_query);
818
819        // We accumulate (path, score) pairs from both sources and merge.
820        let mut scored: Vec<(String, i32)> = Vec::new();
821
822        // 1) Prefix-probe results (filesystem-confirmed, high priority).
823        for entry in &prefix_entries {
824            let m = matcher.match_target(&entry.relative_path);
825            let base_score = if m.matched { m.score } else { 0 };
826            let frecency_boost = (entry.frecency_score / 100.0).min(20.0) as i32;
827            scored.push((
828                entry.relative_path.clone(),
829                base_score + frecency_boost + PREFIX_PROBE_BOOST,
830            ));
831        }
832
833        // 2) Cached file list (may be partial if scan is still running).
834        if let Some(files) = &files {
835            if search_query.is_empty() {
836                let mut entries: Vec<_> = files.iter().map(|f| (f, 0i32)).collect();
837                entries.sort_by(|a, b| {
838                    b.0.frecency_score
839                        .partial_cmp(&a.0.frecency_score)
840                        .unwrap_or(std::cmp::Ordering::Equal)
841                });
842                entries.truncate(max_results);
843                for (f, s) in entries {
844                    scored.push((f.relative_path.clone(), s));
845                }
846            } else {
847                for file in files.iter() {
848                    // Skip entries already present from the prefix probe.
849                    if prefix_set.contains(file.relative_path.as_str()) {
850                        continue;
851                    }
852                    let m = matcher.match_target(&file.relative_path);
853                    if !m.matched {
854                        continue;
855                    }
856                    let frecency_boost = (file.frecency_score / 100.0).min(20.0) as i32;
857                    let mut score = m.score + frecency_boost;
858                    // Boost files whose relative path starts with the query —
859                    // i.e. the query is a literal prefix of the path.
860                    if file.relative_path.starts_with(search_query) {
861                        score += PREFIX_PROBE_BOOST;
862                    }
863                    scored.push((file.relative_path.clone(), score));
864                }
865            }
866        }
867
868        scored.sort_by(|a, b| b.1.cmp(&a.1));
869        scored.truncate(max_results);
870
871        let mut suggestions: Vec<Suggestion> = scored
872            .into_iter()
873            .map(|(path, _)| Suggestion::new(path.clone()).with_value(path))
874            .collect();
875
876        if still_loading {
877            let msg = if suggestions.is_empty() {
878                "Loading files…"
879            } else {
880                "Scanning for more files…"
881            };
882            suggestions.push(Suggestion::disabled(msg.to_string()));
883        }
884
885        suggestions
886    }
887
888    fn on_select(
889        &self,
890        suggestion: Option<&Suggestion>,
891        query: &str,
892        _context: &QuickOpenContext,
893    ) -> QuickOpenResult {
894        let (path_part, line, column) = super::parse_path_line_col(query);
895
896        // Use the selected suggestion's path if available
897        if let Some(path) = suggestion.and_then(|s| s.value.as_deref()) {
898            self.record_access(path);
899            return QuickOpenResult::OpenFile {
900                path: path.to_string(),
901                line,
902                column,
903            };
904        }
905
906        // Fallback: direct path input with :line:col
907        if line.is_some() && !path_part.is_empty() {
908            self.record_access(&path_part);
909            return QuickOpenResult::OpenFile {
910                path: path_part,
911                line,
912                column,
913            };
914        }
915
916        QuickOpenResult::None
917    }
918
919    fn as_any(&self) -> &dyn std::any::Any {
920        self
921    }
922}
923
924#[cfg(test)]
925mod tests {
926    use super::*;
927    use crate::input::quick_open::BufferInfo;
928
929    fn make_test_context(cwd: &str) -> QuickOpenContext {
930        QuickOpenContext {
931            cwd: cwd.to_string(),
932            open_buffers: vec![
933                BufferInfo {
934                    id: 1,
935                    path: "/tmp/main.rs".to_string(),
936                    name: "main.rs".to_string(),
937                    modified: false,
938                },
939                BufferInfo {
940                    id: 2,
941                    path: "/tmp/lib.rs".to_string(),
942                    name: "lib.rs".to_string(),
943                    modified: true,
944                },
945            ],
946            active_buffer_id: 1,
947            active_buffer_path: Some("/tmp/main.rs".to_string()),
948            has_selection: false,
949            key_context: crate::input::keybindings::KeyContext::Normal,
950            custom_contexts: std::collections::HashSet::new(),
951            buffer_mode: None,
952            has_lsp_config: true,
953        }
954    }
955
956    #[test]
957    fn test_buffer_provider_suggestions() {
958        let provider = BufferProvider::new();
959        let context = make_test_context("/tmp");
960
961        let suggestions = provider.suggestions("", &context);
962        assert_eq!(suggestions.len(), 2);
963
964        // Modified buffer should show [+]
965        let lib_suggestion = suggestions
966            .iter()
967            .find(|s| s.text.contains("lib.rs"))
968            .unwrap();
969        assert!(lib_suggestion.text.contains("[+]"));
970    }
971
972    #[test]
973    fn test_buffer_provider_filter() {
974        let provider = BufferProvider::new();
975        let context = make_test_context("/tmp");
976
977        let suggestions = provider.suggestions("main", &context);
978        assert_eq!(suggestions.len(), 1);
979        assert!(suggestions[0].text.contains("main.rs"));
980    }
981
982    #[test]
983    fn test_goto_line_provider() {
984        let provider = GotoLineProvider::new();
985        let context = make_test_context("/tmp");
986
987        // Valid line number
988        let suggestions = provider.suggestions("42", &context);
989        assert_eq!(suggestions.len(), 1);
990        assert!(!suggestions[0].disabled);
991
992        // Empty query shows hint
993        let suggestions = provider.suggestions("", &context);
994        assert_eq!(suggestions.len(), 1);
995        assert!(suggestions[0].disabled);
996
997        // Invalid input
998        let suggestions = provider.suggestions("abc", &context);
999        assert_eq!(suggestions.len(), 1);
1000        assert!(suggestions[0].disabled);
1001    }
1002
1003    #[test]
1004    fn test_goto_line_on_select() {
1005        let provider = GotoLineProvider::new();
1006        let context = make_test_context("/tmp");
1007
1008        let suggestions = provider.suggestions("42", &context);
1009        let result = provider.on_select(suggestions.first(), "42", &context);
1010        match result {
1011            QuickOpenResult::GotoLine(line) => assert_eq!(line, 42),
1012            _ => panic!("Expected GotoLine result"),
1013        }
1014    }
1015
1016    // ====================================================================
1017    // FileProvider tests
1018    // ====================================================================
1019
1020    /// A ProcessSpawner that always fails — forces FileProvider to use the
1021    /// FileSystem walk fallback, which is exactly the code path that was
1022    /// broken on Windows and remote filesystems.
1023    struct FailingSpawner;
1024
1025    #[async_trait::async_trait]
1026    impl crate::services::remote::ProcessSpawner for FailingSpawner {
1027        async fn spawn(
1028            &self,
1029            _command: String,
1030            _args: Vec<String>,
1031            _cwd: Option<String>,
1032        ) -> Result<crate::services::remote::SpawnResult, crate::services::remote::SpawnError>
1033        {
1034            Err(crate::services::remote::SpawnError::Process(
1035                "no git in test".to_string(),
1036            ))
1037        }
1038    }
1039
1040    /// Create a FileProvider backed by StdFileSystem and a FailingSpawner
1041    /// (no runtime handle, so try_git_files is skipped entirely).
1042    fn make_file_provider() -> FileProvider {
1043        FileProvider::new(
1044            std::sync::Arc::new(crate::model::filesystem::StdFileSystem),
1045            std::sync::Arc::new(FailingSpawner),
1046            None, // no runtime → git ls-files path is skipped, sync fallback used
1047            None, // no async sender → sync fallback used
1048        )
1049    }
1050
1051    #[test]
1052    fn test_file_provider_discovers_files_via_walk() {
1053        let dir = tempfile::tempdir().unwrap();
1054        let base = dir.path();
1055
1056        // Create a small project structure
1057        std::fs::write(base.join("main.rs"), b"fn main() {}").unwrap();
1058        std::fs::write(base.join("lib.rs"), b"pub mod foo;").unwrap();
1059        std::fs::create_dir(base.join("src")).unwrap();
1060        std::fs::write(base.join("src").join("foo.rs"), b"// foo").unwrap();
1061
1062        let provider = make_file_provider();
1063        let context = make_test_context(&base.display().to_string());
1064        let suggestions = provider.suggestions("", &context);
1065
1066        // Should find all 3 files
1067        assert_eq!(suggestions.len(), 3);
1068        let paths: Vec<&str> = suggestions
1069            .iter()
1070            .filter_map(|s| s.value.as_deref())
1071            .collect();
1072        assert!(paths.contains(&"main.rs"));
1073        assert!(paths.contains(&"lib.rs"));
1074        assert!(paths.contains(&"src/foo.rs"));
1075    }
1076
1077    #[test]
1078    fn test_file_provider_skips_ignored_dirs() {
1079        let dir = tempfile::tempdir().unwrap();
1080        let base = dir.path();
1081
1082        std::fs::write(base.join("app.rs"), b"").unwrap();
1083        // These directories should be skipped
1084        std::fs::create_dir(base.join("node_modules")).unwrap();
1085        std::fs::write(base.join("node_modules").join("pkg.js"), b"").unwrap();
1086        std::fs::create_dir(base.join("target")).unwrap();
1087        std::fs::write(base.join("target").join("debug.o"), b"").unwrap();
1088
1089        let provider = make_file_provider();
1090        let context = make_test_context(&base.display().to_string());
1091        let suggestions = provider.suggestions("", &context);
1092
1093        assert_eq!(suggestions.len(), 1);
1094        assert_eq!(suggestions[0].value.as_deref(), Some("app.rs"));
1095    }
1096
1097    #[test]
1098    fn test_file_provider_skips_hidden_files() {
1099        let dir = tempfile::tempdir().unwrap();
1100        let base = dir.path();
1101
1102        std::fs::write(base.join("visible.txt"), b"").unwrap();
1103        std::fs::write(base.join(".hidden"), b"").unwrap();
1104        std::fs::create_dir(base.join(".git")).unwrap();
1105        std::fs::write(base.join(".git").join("config"), b"").unwrap();
1106
1107        let provider = make_file_provider();
1108        let context = make_test_context(&base.display().to_string());
1109        let suggestions = provider.suggestions("", &context);
1110
1111        assert_eq!(suggestions.len(), 1);
1112        assert_eq!(suggestions[0].value.as_deref(), Some("visible.txt"));
1113    }
1114
1115    #[test]
1116    fn test_file_provider_fuzzy_filter() {
1117        let dir = tempfile::tempdir().unwrap();
1118        let base = dir.path();
1119
1120        std::fs::write(base.join("main.rs"), b"").unwrap();
1121        std::fs::write(base.join("lib.rs"), b"").unwrap();
1122        std::fs::write(base.join("README.md"), b"").unwrap();
1123
1124        let provider = make_file_provider();
1125        let context = make_test_context(&base.display().to_string());
1126        let suggestions = provider.suggestions("main", &context);
1127
1128        assert_eq!(suggestions.len(), 1);
1129        assert_eq!(suggestions[0].value.as_deref(), Some("main.rs"));
1130    }
1131
1132    #[test]
1133    fn test_file_provider_empty_dir() {
1134        let dir = tempfile::tempdir().unwrap();
1135
1136        let provider = make_file_provider();
1137        let context = make_test_context(&dir.path().display().to_string());
1138        let suggestions = provider.suggestions("", &context);
1139
1140        // Should show "no files" disabled suggestion
1141        assert_eq!(suggestions.len(), 1);
1142        assert!(suggestions[0].disabled);
1143    }
1144
1145    // ====================================================================
1146    // Prefix probe tests
1147    // ====================================================================
1148
1149    /// Covers every probe_prefix behaviour in a single tempdir:
1150    ///   - basename-prefix match inside a subdirectory
1151    ///   - directory-listing match when the query *is* a directory
1152    ///   - empty result for a nonexistent path
1153    ///   - basename-prefix match at the cwd root (empty rel_parent path)
1154    #[test]
1155    fn test_probe_prefix_all_shapes() {
1156        let dir = tempfile::tempdir().unwrap();
1157        let base = dir.path();
1158
1159        // Subdirectory with multiple basename-prefix siblings + one unrelated file
1160        std::fs::create_dir(base.join("etc")).unwrap();
1161        std::fs::write(base.join("etc").join("hosts"), b"").unwrap();
1162        std::fs::write(base.join("etc").join("hosts.allow"), b"").unwrap();
1163        std::fs::write(base.join("etc").join("hosts.deny"), b"").unwrap();
1164        std::fs::write(base.join("etc").join("passwd"), b"").unwrap();
1165
1166        // Subdirectory for the directory-listing query
1167        std::fs::create_dir(base.join("src")).unwrap();
1168        std::fs::write(base.join("src").join("main.rs"), b"").unwrap();
1169        std::fs::write(base.join("src").join("lib.rs"), b"").unwrap();
1170
1171        // Root-level files with a basename-prefix sibling + one unrelated file
1172        std::fs::write(base.join("Makefile"), b"").unwrap();
1173        std::fs::write(base.join("Makefile.bak"), b"").unwrap();
1174        std::fs::write(base.join("README.md"), b"").unwrap();
1175
1176        let provider = make_file_provider();
1177        let cwd = base.display().to_string();
1178
1179        // 1. Basename prefix inside a subdirectory (rel_parent = "etc")
1180        let r = provider.probe_prefix(&cwd, "etc/hosts");
1181        let paths: Vec<&str> = r.iter().map(|e| e.relative_path.as_str()).collect();
1182        assert!(
1183            paths.contains(&"etc/hosts"),
1184            "missing etc/hosts in {paths:?}"
1185        );
1186        assert!(
1187            paths.contains(&"etc/hosts.allow"),
1188            "missing etc/hosts.allow in {paths:?}"
1189        );
1190        assert!(
1191            paths.contains(&"etc/hosts.deny"),
1192            "missing etc/hosts.deny in {paths:?}"
1193        );
1194        assert!(
1195            !paths.contains(&"etc/passwd"),
1196            "passwd shouldn't match prefix 'hosts': {paths:?}"
1197        );
1198
1199        // 2. Directory query — the query is itself a directory, so we list it.
1200        let r = provider.probe_prefix(&cwd, "src");
1201        let paths: Vec<&str> = r.iter().map(|e| e.relative_path.as_str()).collect();
1202        assert!(
1203            paths.contains(&"src/main.rs"),
1204            "missing src/main.rs in {paths:?}"
1205        );
1206        assert!(
1207            paths.contains(&"src/lib.rs"),
1208            "missing src/lib.rs in {paths:?}"
1209        );
1210
1211        // 3. Nonexistent path — neither parent-dir probe nor dir listing finds anything.
1212        let r = provider.probe_prefix(&cwd, "nonexistent/path/to/file");
1213        assert!(
1214            r.is_empty(),
1215            "nonexistent query should return empty, got {:?}",
1216            r.iter().map(|e| &e.relative_path).collect::<Vec<_>>()
1217        );
1218
1219        // 4. Basename prefix at the cwd root (rel_parent is empty).
1220        let r = provider.probe_prefix(&cwd, "Makefile");
1221        let paths: Vec<&str> = r.iter().map(|e| e.relative_path.as_str()).collect();
1222        assert!(paths.contains(&"Makefile"), "missing Makefile in {paths:?}");
1223        assert!(
1224            paths.contains(&"Makefile.bak"),
1225            "missing Makefile.bak in {paths:?}"
1226        );
1227        assert!(
1228            !paths.contains(&"README.md"),
1229            "README.md shouldn't match prefix 'Makefile': {paths:?}"
1230        );
1231    }
1232
1233    // ====================================================================
1234    // Prefix scoring boost tests
1235    // ====================================================================
1236
1237    #[test]
1238    fn test_prefix_match_ranks_above_fuzzy_match() {
1239        let dir = tempfile::tempdir().unwrap();
1240        let base = dir.path();
1241
1242        // Create files where "src/main" is a prefix of one path and
1243        // fuzzy-matches another.
1244        std::fs::create_dir(base.join("src")).unwrap();
1245        std::fs::write(base.join("src").join("main.rs"), b"").unwrap();
1246        // "some_random_main_file.rs" also fuzzy-matches "src/main" (the
1247        // characters s, r, c, m, a, i, n exist), but the prefix match
1248        // should rank higher.
1249        std::fs::write(base.join("src").join("manager.rs"), b"").unwrap();
1250
1251        let provider = make_file_provider();
1252        let context = make_test_context(&base.display().to_string());
1253        let suggestions = provider.suggestions("src/main", &context);
1254
1255        // The exact prefix match should be first
1256        assert!(!suggestions.is_empty());
1257        assert_eq!(suggestions[0].value.as_deref(), Some("src/main.rs"));
1258    }
1259
1260    #[test]
1261    fn test_set_partial_cache_keeps_loading() {
1262        let provider = make_file_provider();
1263
1264        // Simulate background loading start
1265        {
1266            let mut cache = provider.cache.lock().unwrap();
1267            cache.loading = true;
1268        }
1269
1270        // Partial cache update should keep loading = true
1271        let partial = std::sync::Arc::new(vec![FileEntry {
1272            relative_path: "foo.rs".to_string(),
1273            frecency_score: 0.0,
1274        }]);
1275        provider.set_partial_cache(partial);
1276
1277        assert!(provider.is_loading());
1278        assert!(provider.cache.lock().unwrap().files.is_some());
1279
1280        // Final set_cache should clear loading
1281        let final_files = std::sync::Arc::new(vec![FileEntry {
1282            relative_path: "foo.rs".to_string(),
1283            frecency_score: 0.0,
1284        }]);
1285        provider.set_cache(final_files);
1286
1287        assert!(!provider.is_loading());
1288    }
1289}