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