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