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    fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
105        self
106    }
107}
108
109// ============================================================================
110// Buffer Provider (prefix: "#")
111// ============================================================================
112
113/// Provider for switching between open buffers
114pub struct BufferProvider;
115
116impl BufferProvider {
117    pub fn new() -> Self {
118        Self
119    }
120}
121
122impl Default for BufferProvider {
123    fn default() -> Self {
124        Self::new()
125    }
126}
127
128impl QuickOpenProvider for BufferProvider {
129    fn prefix(&self) -> &str {
130        "#"
131    }
132
133    fn suggestions(&self, query: &str, context: &QuickOpenContext) -> Vec<Suggestion> {
134        // Build the matcher once and reuse it across all buffers.
135        let mut matcher = FuzzyMatcher::new(query);
136        let mut scored: Vec<(Suggestion, i32, usize)> = context
137            .open_buffers
138            .iter()
139            // Virtual buffers (plugin panels) have no path but should still be
140            // listed; file buffers without a path are unnamed scratch buffers.
141            .filter(|buf| buf.is_virtual || !buf.path.is_empty())
142            .filter_map(|buf| {
143                let m = matcher.match_target(&buf.name);
144                if !m.matched {
145                    return None;
146                }
147
148                let display_name = if buf.modified {
149                    format!("{} [+]", buf.name)
150                } else {
151                    buf.name.clone()
152                };
153
154                let suggestion = Suggestion::new(display_name)
155                    .with_description(buf.path.clone())
156                    .with_value(buf.id.to_string());
157                Some((suggestion, m.score, buf.id))
158            })
159            .collect();
160
161        // Sort by score (higher is better), then by ID (lower = older = higher priority when tied)
162        scored.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.2.cmp(&b.2)));
163        scored.into_iter().map(|(s, _, _)| s).collect()
164    }
165
166    fn on_select(
167        &self,
168        suggestion: Option<&Suggestion>,
169        _query: &str,
170        _context: &QuickOpenContext,
171    ) -> QuickOpenResult {
172        suggestion
173            .and_then(|s| s.value.as_deref())
174            .and_then(|v| v.parse::<usize>().ok())
175            .map(QuickOpenResult::ShowBuffer)
176            .unwrap_or(QuickOpenResult::None)
177    }
178
179    fn as_any(&self) -> &dyn std::any::Any {
180        self
181    }
182
183    fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
184        self
185    }
186}
187
188// ============================================================================
189// Go to Line Provider (prefix: ":")
190// ============================================================================
191
192/// Provider for jumping to a specific line number
193pub struct GotoLineProvider;
194
195impl GotoLineProvider {
196    pub fn new() -> Self {
197        Self
198    }
199}
200
201impl Default for GotoLineProvider {
202    fn default() -> Self {
203        Self::new()
204    }
205}
206
207impl QuickOpenProvider for GotoLineProvider {
208    fn prefix(&self) -> &str {
209        ":"
210    }
211
212    fn suggestions(&self, query: &str, _context: &QuickOpenContext) -> Vec<Suggestion> {
213        if query.is_empty() {
214            return vec![
215                Suggestion::disabled(t!("quick_open.goto_line_hint").to_string())
216                    .with_description(t!("quick_open.goto_line_desc").to_string()),
217            ];
218        }
219
220        // A bare sign isn't yet a valid number — show a hint and wait for digits.
221        if query == "-" || query == "+" {
222            return vec![
223                Suggestion::disabled(t!("quick_open.goto_line_hint").to_string())
224                    .with_description(t!("quick_open.relative_line_desc").to_string()),
225            ];
226        }
227
228        match parse_goto_line_input(query) {
229            Some(target) => {
230                let label = match target {
231                    GotoLineTarget::Absolute(n) => {
232                        t!("quick_open.goto_line", line = n.to_string()).to_string()
233                    }
234                    GotoLineTarget::Relative(d) => {
235                        // Format with explicit sign so "+3" reads back as "+3", not "3".
236                        t!("quick_open.goto_line", line = format!("{:+}", d)).to_string()
237                    }
238                };
239                vec![Suggestion::new(label)
240                    .with_description(t!("quick_open.press_enter").to_string())
241                    .with_value(query.to_string())]
242            }
243            None => vec![
244                Suggestion::disabled(t!("quick_open.invalid_line").to_string())
245                    .with_description(query.to_string()),
246            ],
247        }
248    }
249
250    fn on_select(
251        &self,
252        suggestion: Option<&Suggestion>,
253        _query: &str,
254        _context: &QuickOpenContext,
255    ) -> QuickOpenResult {
256        suggestion
257            .and_then(|s| s.value.as_deref())
258            .and_then(parse_goto_line_input)
259            .map(QuickOpenResult::GotoLine)
260            .unwrap_or(QuickOpenResult::None)
261    }
262
263    fn as_any(&self) -> &dyn std::any::Any {
264        self
265    }
266
267    fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
268        self
269    }
270}
271
272// ============================================================================
273// File Provider (default, no prefix)
274// ============================================================================
275
276/// Directory names to skip during file walking (shared with plugin_commands.rs pattern).
277const IGNORED_DIRS: &[&str] = &[
278    ".git",
279    "node_modules",
280    "target",
281    "__pycache__",
282    ".hg",
283    ".svn",
284    ".DS_Store",
285];
286
287const MAX_FILES: usize = 50_000;
288
289/// A single file entry in the Quick Open file list.
290#[derive(Clone, Debug)]
291pub struct FileEntry {
292    relative_path: String,
293    frecency_score: f64,
294}
295
296#[derive(Clone)]
297struct FrecencyData {
298    access_count: u32,
299    last_access: std::time::Instant,
300}
301
302/// Shared state between the FileProvider and its background loading task.
303///
304/// Wrapped in a single `Arc<Mutex<>>` to keep the FileProvider struct flat.
305struct FileCache {
306    /// The cached file list, or `None` if not yet loaded.
307    files: Option<std::sync::Arc<Vec<FileEntry>>>,
308    /// Whether a background load is in progress.
309    loading: bool,
310    /// The cwd the cached `files` (and the in-progress load) belong
311    /// to. A cache hit only counts when this matches the requested
312    /// cwd — otherwise switching windows/projects would keep serving
313    /// the first project's files. Late-arriving results for a stale
314    /// cwd are dropped on the same check.
315    loaded_cwd: Option<String>,
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                loaded_cwd: None,
353            })),
354            frecency: std::sync::Arc::new(std::sync::RwLock::new(std::collections::HashMap::new())),
355            filesystem,
356            process_spawner,
357            runtime_handle,
358            async_sender,
359            cancel: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
360        }
361    }
362
363    /// Re-point this provider at a new authority's filesystem + process
364    /// spawner (e.g. after `setAuthority` / a remote attach swaps the backend).
365    ///
366    /// The spawner is the important one: quick-open's fast path is
367    /// `git ls-files` through `process_spawner`, so a stale *local* spawner
368    /// would list host files in a remote session. The cached file list is from
369    /// the old backend, so it's cleared (which also cancels any in-flight walk).
370    pub fn set_backends(
371        &mut self,
372        filesystem: std::sync::Arc<dyn crate::model::filesystem::FileSystem + Send + Sync>,
373        process_spawner: std::sync::Arc<dyn crate::services::remote::ProcessSpawner>,
374    ) {
375        self.filesystem = filesystem;
376        self.process_spawner = process_spawner;
377        self.clear_cache();
378    }
379
380    /// Clear the file cache (e.g., after file system changes).
381    pub fn clear_cache(&self) {
382        self.cancel
383            .store(true, std::sync::atomic::Ordering::Relaxed);
384        if let Ok(mut c) = self.cache.lock() {
385            c.files = None;
386            c.loading = false;
387            c.loaded_cwd = None;
388        }
389    }
390
391    /// Cancel any in-progress background file load.
392    /// Called when the user closes Quick Open so we don't keep walking.
393    pub fn cancel_loading(&self) {
394        self.cancel
395            .store(true, std::sync::atomic::Ordering::Relaxed);
396        if let Ok(mut c) = self.cache.lock() {
397            c.loading = false;
398        }
399    }
400
401    /// Update the file cache with final results from a completed
402    /// background load. Dropped if `cwd` no longer matches the cache's
403    /// in-flight cwd — i.e. the user switched projects mid-load, so
404    /// these results are stale.
405    pub fn set_cache(&self, cwd: &str, files: std::sync::Arc<Vec<FileEntry>>) {
406        if let Ok(mut c) = self.cache.lock() {
407            if c.loaded_cwd.as_deref() != Some(cwd) {
408                return;
409            }
410            c.files = Some(files);
411            c.loading = false;
412        }
413    }
414
415    /// Update the file cache with partial results while the background scan
416    /// is still running.  Unlike [`set_cache`], this keeps `loading = true`.
417    pub fn set_partial_cache(&self, cwd: &str, files: std::sync::Arc<Vec<FileEntry>>) {
418        if let Ok(mut c) = self.cache.lock() {
419            if c.loaded_cwd.as_deref() != Some(cwd) {
420                return;
421            }
422            c.files = Some(files);
423            // Keep c.loading = true — the walk is still in progress.
424        }
425    }
426
427    /// Returns `true` if a background file scan is in progress.
428    fn is_loading(&self) -> bool {
429        self.cache.lock().is_ok_and(|c| c.loading)
430    }
431
432    /// Record file access for frecency ranking
433    pub fn record_access(&self, path: &str) {
434        if let Ok(mut frecency) = self.frecency.write() {
435            let entry = frecency.entry(path.to_string()).or_insert(FrecencyData {
436                access_count: 0,
437                last_access: std::time::Instant::now(),
438            });
439            entry.access_count += 1;
440            entry.last_access = std::time::Instant::now();
441        }
442    }
443
444    fn get_frecency_score(&self, path: &str) -> f64 {
445        self.frecency
446            .read()
447            .ok()
448            .and_then(|m| m.get(path).map(frecency_score))
449            .unwrap_or(0.0)
450    }
451
452    /// Probe the filesystem directly for files matching `query` as a path
453    /// prefix.  This is fast (typically one `read_dir` call) and provides
454    /// immediate results even while the full recursive scan is in progress.
455    ///
456    /// For example, if `cwd` is `/` and `query` is `etc/hosts`, this will
457    /// list `/etc/` and return every file whose name starts with `hosts`
458    /// (e.g. `etc/hosts`, `etc/hosts.allow`, `etc/hosts.deny`).
459    fn probe_prefix(&self, cwd: &str, query: &str) -> Vec<FileEntry> {
460        use std::path::Path;
461
462        if query.is_empty() {
463            return vec![];
464        }
465
466        let abs_path = Path::new(cwd).join(query);
467        let mut results = Vec::new();
468
469        // If the query points to a directory, list its file contents.
470        if let Ok(entries) = self.filesystem.read_dir(&abs_path) {
471            let query_trimmed = query.trim_end_matches('/');
472            for entry in entries {
473                if entry.is_file() && !entry.name.starts_with('.') {
474                    let rel = format!("{}/{}", query_trimmed, entry.name);
475                    results.push(FileEntry {
476                        frecency_score: self.get_frecency_score(&rel),
477                        relative_path: rel,
478                    });
479                }
480            }
481            results.truncate(50);
482            return results;
483        }
484
485        // Otherwise, list the parent directory and filter by the basename
486        // prefix (e.g. query "etc/hosts" → parent "/etc", prefix "hosts").
487        let parent = match abs_path.parent() {
488            Some(p) => p,
489            None => return results,
490        };
491        let basename = match abs_path.file_name().and_then(|n| n.to_str()) {
492            Some(b) => b,
493            None => return results,
494        };
495
496        let rel_parent = match parent.strip_prefix(cwd) {
497            Ok(p) => {
498                let s = p.to_string_lossy().replace('\\', "/");
499                s
500            }
501            Err(_) => return results,
502        };
503
504        if let Ok(entries) = self.filesystem.read_dir(parent) {
505            for entry in entries {
506                if entry.name.starts_with('.') {
507                    continue;
508                }
509                if !entry.name.starts_with(basename) {
510                    continue;
511                }
512                if entry.is_file() {
513                    let rel = if rel_parent.is_empty() {
514                        entry.name.clone()
515                    } else {
516                        format!("{}/{}", rel_parent, entry.name)
517                    };
518                    results.push(FileEntry {
519                        frecency_score: self.get_frecency_score(&rel),
520                        relative_path: rel,
521                    });
522                }
523            }
524        }
525
526        results
527    }
528
529    /// Get the cached file list, or `None` if not yet loaded.
530    ///
531    /// If no cache exists and no load is in progress, spawns a background
532    /// task that will populate the cache and notify the UI via
533    /// `AsyncMessage::QuickOpenFilesLoaded`.
534    fn get_or_start_loading(&self, cwd: &str) -> Option<std::sync::Arc<Vec<FileEntry>>> {
535        let mut cache = self.cache.lock().ok()?;
536
537        // A cache hit only counts for the cwd the files were loaded
538        // under. When the cwd changed (the user switched windows /
539        // projects), drop the stale list and reload — otherwise the
540        // picker keeps showing the first project's files.
541        let cwd_matches = cache.loaded_cwd.as_deref() == Some(cwd);
542        if cwd_matches {
543            if let Some(files) = &cache.files {
544                return Some(std::sync::Arc::clone(files));
545            }
546            if cache.loading {
547                return None; // already loading this cwd
548            }
549        } else {
550            // Stale cwd: cancel any in-flight load for the old cwd and
551            // reset so the load below starts fresh for `cwd`.
552            self.cancel
553                .store(true, std::sync::atomic::Ordering::Relaxed);
554            cache.files = None;
555            cache.loading = false;
556        }
557
558        // No cache for this cwd, not loading — kick off background load
559        cache.loaded_cwd = Some(cwd.to_string());
560        let (sender, handle) = match (&self.async_sender, &self.runtime_handle) {
561            (Some(s), Some(h)) => (s.clone(), h.clone()),
562            _ => {
563                // No async support — fall back to synchronous load
564                drop(cache);
565                return self.load_files_sync(cwd);
566            }
567        };
568
569        cache.loading = true;
570        // Reset cancel flag for this new load
571        self.cancel
572            .store(false, std::sync::atomic::Ordering::Relaxed);
573        let cancel = std::sync::Arc::clone(&self.cancel);
574        let frecency = std::sync::Arc::clone(&self.frecency);
575        let filesystem = std::sync::Arc::clone(&self.filesystem);
576        let process_spawner = std::sync::Arc::clone(&self.process_spawner);
577        let cwd = cwd.to_string();
578
579        handle.spawn_blocking(move || {
580            // Fast path: git ls-files returns everything at once.
581            if let Some(files) = try_git_files_blocking(&process_spawner, &cwd) {
582                let frecency_map = frecency.read().ok();
583                let entries: Vec<FileEntry> = files
584                    .into_iter()
585                    .map(|path| {
586                        let score = frecency_map
587                            .as_ref()
588                            .and_then(|m| m.get(&path))
589                            .map(frecency_score)
590                            .unwrap_or(0.0);
591                        FileEntry {
592                            relative_path: path,
593                            frecency_score: score,
594                        }
595                    })
596                    .collect();
597                // Send failure means the receiver has been dropped (editor
598                // shutting down); nothing more to do since we return below.
599                drop(sender.send(
600                    crate::services::async_bridge::AsyncMessage::QuickOpenFilesLoaded {
601                        cwd: cwd.clone(),
602                        files: std::sync::Arc::new(entries),
603                        complete: true,
604                    },
605                ));
606                return;
607            }
608
609            // Slow path: directory walk with periodic incremental updates so
610            // the UI can show partial results while the scan continues.
611            walk_dir_with_updates(&*filesystem, &cwd, &cancel, &frecency, &sender);
612        });
613
614        None
615    }
616
617    /// Synchronous fallback when no tokio runtime is available (e.g., tests).
618    fn load_files_sync(&self, cwd: &str) -> Option<std::sync::Arc<Vec<FileEntry>>> {
619        let files = self
620            .try_git_files(cwd)
621            .or_else(|| self.try_walk_dir(cwd))
622            .unwrap_or_default();
623
624        let entries: Vec<FileEntry> = files
625            .into_iter()
626            .map(|path| FileEntry {
627                frecency_score: self.get_frecency_score(&path),
628                relative_path: path,
629            })
630            .collect();
631
632        let files = std::sync::Arc::new(entries);
633        self.set_cache(cwd, std::sync::Arc::clone(&files));
634        Some(files)
635    }
636
637    /// Synchronous `try_git_files` — used by the sync fallback path.
638    fn try_git_files(&self, cwd: &str) -> Option<Vec<String>> {
639        let handle = self.runtime_handle.as_ref()?;
640        try_git_files_with_handle(&self.process_spawner, cwd, handle)
641    }
642
643    /// Synchronous `try_walk_dir` — used by the sync fallback path.
644    fn try_walk_dir(&self, cwd: &str) -> Option<Vec<String>> {
645        let cancel = std::sync::atomic::AtomicBool::new(false);
646        try_walk_dir_blocking(&*self.filesystem, cwd, &cancel)
647    }
648}
649
650// ---------------------------------------------------------------------------
651// Free functions used by both the sync path and the background task
652// ---------------------------------------------------------------------------
653
654/// List files via `git ls-files` using a `ProcessSpawner` (blocking).
655///
656/// Called from `spawn_blocking` so we can't hold a tokio runtime handle —
657/// `ProcessSpawner::spawn` is async, so we use `tokio::runtime::Handle::block_on`
658/// from *inside* the blocking thread.
659fn try_git_files_blocking(
660    spawner: &std::sync::Arc<dyn crate::services::remote::ProcessSpawner>,
661    cwd: &str,
662) -> Option<Vec<String>> {
663    // Inside spawn_blocking we can use Handle::current() since the runtime is alive.
664    let handle = tokio::runtime::Handle::try_current().ok()?;
665    try_git_files_with_handle(spawner, cwd, &handle)
666}
667
668fn try_git_files_with_handle(
669    spawner: &std::sync::Arc<dyn crate::services::remote::ProcessSpawner>,
670    cwd: &str,
671    handle: &tokio::runtime::Handle,
672) -> Option<Vec<String>> {
673    let result = handle
674        .block_on(spawner.spawn(
675            "git".to_string(),
676            vec![
677                "ls-files".to_string(),
678                "--cached".to_string(),
679                "--others".to_string(),
680                "--exclude-standard".to_string(),
681            ],
682            Some(cwd.to_string()),
683        ))
684        .ok()?;
685
686    if result.exit_code != 0 {
687        return None;
688    }
689
690    let files: Vec<String> = result
691        .stdout
692        .lines()
693        .filter(|line| !line.is_empty() && !line.starts_with(".git/"))
694        .map(|s| s.to_string())
695        .collect();
696
697    Some(files)
698}
699
700/// Walk the directory tree via `FileSystem::walk_files` (blocking).
701fn try_walk_dir_blocking(
702    fs: &dyn crate::model::filesystem::FileSystem,
703    cwd: &str,
704    cancel: &std::sync::atomic::AtomicBool,
705) -> Option<Vec<String>> {
706    use std::path::Path;
707
708    let base = Path::new(cwd);
709    let mut files = Vec::new();
710
711    // Errors (e.g., root doesn't exist) are treated as "no files found".
712    drop(
713        fs.walk_files(base, IGNORED_DIRS, cancel, &mut |_path, rel| {
714            files.push(rel.to_string());
715            files.len() < MAX_FILES
716        }),
717    );
718
719    if files.is_empty() {
720        None
721    } else {
722        Some(files)
723    }
724}
725
726/// Minimum interval between incremental partial-result updates sent to the UI
727/// during a directory walk.
728const WALK_UPDATE_INTERVAL: std::time::Duration = std::time::Duration::from_millis(300);
729
730/// Walk the directory tree, sending periodic partial updates to the UI so
731/// fuzzy results can be recalculated as new files are discovered.
732fn walk_dir_with_updates(
733    fs: &dyn crate::model::filesystem::FileSystem,
734    cwd: &str,
735    cancel: &std::sync::atomic::AtomicBool,
736    frecency: &std::sync::RwLock<std::collections::HashMap<String, FrecencyData>>,
737    sender: &std::sync::mpsc::Sender<crate::services::async_bridge::AsyncMessage>,
738) {
739    use std::path::Path;
740
741    let base = Path::new(cwd);
742    let mut paths: Vec<String> = Vec::new();
743    let mut last_send = std::time::Instant::now();
744    let mut receiver_gone = false;
745
746    // `walk_files` errors (e.g. root doesn't exist, permission denied at the
747    // top level) are treated as "no files found" — any paths already
748    // collected in `paths` are still surfaced via the final send below.
749    if let Err(e) = fs.walk_files(base, IGNORED_DIRS, cancel, &mut |_path, rel| {
750        paths.push(rel.to_string());
751
752        // Send a partial snapshot at regular intervals.
753        if last_send.elapsed() >= WALK_UPDATE_INTERVAL {
754            let frecency_map = frecency.read().ok();
755            let entries: Vec<FileEntry> = paths
756                .iter()
757                .map(|p| FileEntry {
758                    frecency_score: frecency_map
759                        .as_ref()
760                        .and_then(|m| m.get(p).map(frecency_score))
761                        .unwrap_or(0.0),
762                    relative_path: p.clone(),
763                })
764                .collect();
765            if sender
766                .send(
767                    crate::services::async_bridge::AsyncMessage::QuickOpenFilesLoaded {
768                        cwd: cwd.to_string(),
769                        files: std::sync::Arc::new(entries),
770                        complete: false,
771                    },
772                )
773                .is_err()
774            {
775                // Receiver dropped (editor shutting down) — stop walking
776                // so we don't waste CPU on results nobody will see.
777                receiver_gone = true;
778                return false;
779            }
780            last_send = std::time::Instant::now();
781        }
782
783        paths.len() < MAX_FILES
784    }) {
785        tracing::debug!("Quick Open walk_files failed: {}", e);
786    }
787
788    if receiver_gone {
789        return;
790    }
791
792    // Send the final complete result.  If this fails the editor is shutting
793    // down — nothing we can do about that, so the error is ignored.
794    let frecency_map = frecency.read().ok();
795    let entries: Vec<FileEntry> = paths
796        .into_iter()
797        .map(|p| {
798            let score = frecency_map
799                .as_ref()
800                .and_then(|m| m.get(&p).map(frecency_score))
801                .unwrap_or(0.0);
802            FileEntry {
803                relative_path: p,
804                frecency_score: score,
805            }
806        })
807        .collect();
808    drop(sender.send(
809        crate::services::async_bridge::AsyncMessage::QuickOpenFilesLoaded {
810            cwd: cwd.to_string(),
811            files: std::sync::Arc::new(entries),
812            complete: true,
813        },
814    ));
815}
816
817/// Compute frecency score for a single entry.
818fn frecency_score(data: &FrecencyData) -> f64 {
819    let hours_since_access = data.last_access.elapsed().as_secs_f64() / 3600.0;
820    let recency_weight = if hours_since_access < 4.0 {
821        100.0
822    } else if hours_since_access < 24.0 {
823        70.0
824    } else if hours_since_access < 24.0 * 7.0 {
825        50.0
826    } else if hours_since_access < 24.0 * 30.0 {
827        30.0
828    } else if hours_since_access < 24.0 * 90.0 {
829        10.0
830    } else {
831        1.0
832    };
833    data.access_count as f64 * recency_weight
834}
835
836impl QuickOpenProvider for FileProvider {
837    fn prefix(&self) -> &str {
838        ""
839    }
840
841    fn suggestions(&self, query: &str, context: &QuickOpenContext) -> Vec<Suggestion> {
842        // Strip :line:col suffix so fuzzy matching works when the user appends a jump target
843        let (path_part, _, _) = super::parse_path_line_col(query);
844        let search_query = if path_part.is_empty() {
845            query
846        } else {
847            &path_part
848        };
849
850        // Show a clear error when the remote connection is lost
851        if !self.filesystem.is_remote_connected() {
852            return vec![Suggestion::disabled(
853                "Remote connection lost — cannot list files".to_string(),
854            )];
855        }
856
857        // Get cached files (may be partial during an in-progress scan) or
858        // kick off a background load.
859        let files = self.get_or_start_loading(&context.cwd);
860        let still_loading = self.is_loading();
861
862        // Fast prefix probe: check the filesystem directly for the query
863        // treated as a literal path prefix.  This gives instant results even
864        // before the recursive scan reaches the relevant directory, and is
865        // also valuable after the scan completes since the walk may have
866        // stopped at MAX_FILES before reaching the target file.
867        let prefix_entries = if !search_query.is_empty() {
868            self.probe_prefix(&context.cwd, search_query)
869        } else {
870            vec![]
871        };
872
873        let has_files = files.as_ref().is_some_and(|f| !f.is_empty());
874
875        if !has_files && prefix_entries.is_empty() {
876            if still_loading {
877                return vec![Suggestion::disabled("Loading files…".to_string())];
878            } else {
879                return vec![Suggestion::disabled(t!("quick_open.no_files").to_string())];
880            }
881        }
882
883        let max_results = 100;
884
885        // Collect prefix-probe paths for deduplication.
886        let prefix_set: std::collections::HashSet<&str> = prefix_entries
887            .iter()
888            .map(|e| e.relative_path.as_str())
889            .collect();
890
891        // Score bonus applied to files confirmed to exist via the prefix probe.
892        const PREFIX_PROBE_BOOST: i32 = 200;
893
894        // Build one matcher and reuse it for every target on this keystroke.
895        // The matcher owns the prepared pattern *and* two `Vec<char>` scratch
896        // buffers, so neither query preparation nor per-target allocation
897        // happens on the hot loop after its first iteration.
898        let mut matcher = FuzzyMatcher::new(search_query);
899
900        // We accumulate (path, score) pairs from both sources and merge.
901        let mut scored: Vec<(String, i32)> = Vec::new();
902
903        // 1) Prefix-probe results (filesystem-confirmed, high priority).
904        for entry in &prefix_entries {
905            let m = matcher.match_target(&entry.relative_path);
906            let base_score = if m.matched { m.score } else { 0 };
907            let frecency_boost = (entry.frecency_score / 100.0).min(20.0) as i32;
908            scored.push((
909                entry.relative_path.clone(),
910                base_score + frecency_boost + PREFIX_PROBE_BOOST,
911            ));
912        }
913
914        // 2) Cached file list (may be partial if scan is still running).
915        if let Some(files) = &files {
916            if search_query.is_empty() {
917                let mut entries: Vec<_> = files.iter().map(|f| (f, 0i32)).collect();
918                entries.sort_by(|a, b| {
919                    b.0.frecency_score
920                        .partial_cmp(&a.0.frecency_score)
921                        .unwrap_or(std::cmp::Ordering::Equal)
922                });
923                entries.truncate(max_results);
924                for (f, s) in entries {
925                    scored.push((f.relative_path.clone(), s));
926                }
927            } else {
928                for file in files.iter() {
929                    // Skip entries already present from the prefix probe.
930                    if prefix_set.contains(file.relative_path.as_str()) {
931                        continue;
932                    }
933                    let m = matcher.match_target(&file.relative_path);
934                    if !m.matched {
935                        continue;
936                    }
937                    let frecency_boost = (file.frecency_score / 100.0).min(20.0) as i32;
938                    let mut score = m.score + frecency_boost;
939                    // Boost files whose relative path starts with the query —
940                    // i.e. the query is a literal prefix of the path.
941                    if file.relative_path.starts_with(search_query) {
942                        score += PREFIX_PROBE_BOOST;
943                    }
944                    scored.push((file.relative_path.clone(), score));
945                }
946            }
947        }
948
949        scored.sort_by(|a, b| b.1.cmp(&a.1));
950        scored.truncate(max_results);
951
952        let mut suggestions: Vec<Suggestion> = scored
953            .into_iter()
954            .map(|(path, _)| Suggestion::new(path.clone()).with_value(path))
955            .collect();
956
957        if still_loading {
958            let msg = if suggestions.is_empty() {
959                "Loading files…"
960            } else {
961                "Scanning for more files…"
962            };
963            suggestions.push(Suggestion::disabled(msg.to_string()));
964        }
965
966        suggestions
967    }
968
969    fn on_select(
970        &self,
971        suggestion: Option<&Suggestion>,
972        query: &str,
973        _context: &QuickOpenContext,
974    ) -> QuickOpenResult {
975        let (path_part, line, column) = super::parse_path_line_col(query);
976
977        // Use the selected suggestion's path if available
978        if let Some(path) = suggestion.and_then(|s| s.value.as_deref()) {
979            self.record_access(path);
980            return QuickOpenResult::OpenFile {
981                path: path.to_string(),
982                line,
983                column,
984            };
985        }
986
987        // Fallback: direct path input with :line:col
988        if line.is_some() && !path_part.is_empty() {
989            self.record_access(&path_part);
990            return QuickOpenResult::OpenFile {
991                path: path_part,
992                line,
993                column,
994            };
995        }
996
997        QuickOpenResult::None
998    }
999
1000    fn as_any(&self) -> &dyn std::any::Any {
1001        self
1002    }
1003
1004    fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
1005        self
1006    }
1007}
1008
1009#[cfg(test)]
1010mod tests {
1011    use super::*;
1012    use crate::input::quick_open::BufferInfo;
1013
1014    fn make_test_context(cwd: &str) -> QuickOpenContext {
1015        QuickOpenContext {
1016            cwd: cwd.to_string(),
1017            open_buffers: vec![
1018                BufferInfo {
1019                    id: 1,
1020                    path: "/tmp/main.rs".to_string(),
1021                    name: "main.rs".to_string(),
1022                    modified: false,
1023                    is_virtual: false,
1024                },
1025                BufferInfo {
1026                    id: 2,
1027                    path: "/tmp/lib.rs".to_string(),
1028                    name: "lib.rs".to_string(),
1029                    modified: true,
1030                    is_virtual: false,
1031                },
1032            ],
1033            active_buffer_id: 1,
1034            active_buffer_path: Some("/tmp/main.rs".to_string()),
1035            has_selection: false,
1036            key_context: crate::input::keybindings::KeyContext::Normal,
1037            custom_contexts: std::collections::HashSet::new(),
1038            buffer_mode: None,
1039            has_lsp_config: true,
1040            relative_line_numbers: false,
1041        }
1042    }
1043
1044    #[test]
1045    fn test_buffer_provider_suggestions() {
1046        let provider = BufferProvider::new();
1047        let context = make_test_context("/tmp");
1048
1049        let suggestions = provider.suggestions("", &context);
1050        assert_eq!(suggestions.len(), 2);
1051
1052        // Modified buffer should show [+]
1053        let lib_suggestion = suggestions
1054            .iter()
1055            .find(|s| s.text.contains("lib.rs"))
1056            .unwrap();
1057        assert!(lib_suggestion.text.contains("[+]"));
1058    }
1059
1060    #[test]
1061    fn test_buffer_provider_filter() {
1062        let provider = BufferProvider::new();
1063        let context = make_test_context("/tmp");
1064
1065        let suggestions = provider.suggestions("main", &context);
1066        assert_eq!(suggestions.len(), 1);
1067        assert!(suggestions[0].text.contains("main.rs"));
1068    }
1069
1070    /// Issue #2373: virtual buffers (empty `path`, `is_virtual = true`) must be
1071    /// listed by the `#` switcher, while pathless non-virtual buffers stay out.
1072    #[test]
1073    fn test_buffer_provider_includes_virtual_buffers() {
1074        let provider = BufferProvider::new();
1075        let mut context = make_test_context("/tmp");
1076        context.open_buffers.push(BufferInfo {
1077            id: 3,
1078            path: String::new(),
1079            name: "*blame:lib.rs*".to_string(),
1080            modified: false,
1081            is_virtual: true,
1082        });
1083        // A pathless, non-virtual buffer (e.g. unnamed scratch) must NOT appear.
1084        context.open_buffers.push(BufferInfo {
1085            id: 4,
1086            path: String::new(),
1087            name: "scratch".to_string(),
1088            modified: false,
1089            is_virtual: false,
1090        });
1091
1092        // Empty query lists everything that is eligible.
1093        let all = provider.suggestions("", &context);
1094        assert!(
1095            all.iter().any(|s| s.text.contains("*blame:lib.rs*")),
1096            "virtual buffer should be listed: {:?}",
1097            all.iter().map(|s| &s.text).collect::<Vec<_>>()
1098        );
1099        assert!(
1100            !all.iter().any(|s| s.text.contains("scratch")),
1101            "pathless non-virtual buffer should be excluded"
1102        );
1103
1104        // The virtual buffer is reachable by a fuzzy query, carrying its id.
1105        let filtered = provider.suggestions("blame", &context);
1106        assert_eq!(filtered.len(), 1);
1107        assert!(filtered[0].text.contains("*blame:lib.rs*"));
1108        assert_eq!(filtered[0].value.as_deref(), Some("3"));
1109    }
1110
1111    #[test]
1112    fn test_goto_line_provider() {
1113        let provider = GotoLineProvider::new();
1114        let context = make_test_context("/tmp");
1115
1116        // Valid line number
1117        let suggestions = provider.suggestions("42", &context);
1118        assert_eq!(suggestions.len(), 1);
1119        assert!(!suggestions[0].disabled);
1120
1121        // Empty query shows hint
1122        let suggestions = provider.suggestions("", &context);
1123        assert_eq!(suggestions.len(), 1);
1124        assert!(suggestions[0].disabled);
1125
1126        // Invalid input
1127        let suggestions = provider.suggestions("abc", &context);
1128        assert_eq!(suggestions.len(), 1);
1129        assert!(suggestions[0].disabled);
1130    }
1131
1132    #[test]
1133    fn test_goto_line_on_select() {
1134        let provider = GotoLineProvider::new();
1135        let context = make_test_context("/tmp");
1136
1137        let suggestions = provider.suggestions("42", &context);
1138        let result = provider.on_select(suggestions.first(), "42", &context);
1139        match result {
1140            QuickOpenResult::GotoLine(GotoLineTarget::Absolute(line)) => assert_eq!(line, 42),
1141            other => panic!("expected absolute GotoLine result, got {:?}", other),
1142        }
1143    }
1144
1145    /// Signed input is always interpreted as relative — independent of the
1146    /// `relative_line_numbers` display setting.
1147    #[test]
1148    fn test_goto_line_signed_is_relative_regardless_of_setting() {
1149        let provider = GotoLineProvider::new();
1150
1151        for relative_setting in [false, true] {
1152            let mut context = make_test_context("/tmp");
1153            context.relative_line_numbers = relative_setting;
1154
1155            for query in ["-5", "+3"] {
1156                let suggestions = provider.suggestions(query, &context);
1157                assert_eq!(suggestions.len(), 1, "query {query:?}");
1158                assert!(!suggestions[0].disabled, "query {query:?}");
1159            }
1160
1161            let suggestions = provider.suggestions("+3", &context);
1162            match provider.on_select(suggestions.first(), "+3", &context) {
1163                QuickOpenResult::GotoLine(GotoLineTarget::Relative(d)) => assert_eq!(d, 3),
1164                other => panic!("expected relative GotoLine, got {:?}", other),
1165            }
1166
1167            let suggestions = provider.suggestions("-7", &context);
1168            match provider.on_select(suggestions.first(), "-7", &context) {
1169                QuickOpenResult::GotoLine(GotoLineTarget::Relative(d)) => assert_eq!(d, -7),
1170                other => panic!("expected relative GotoLine, got {:?}", other),
1171            }
1172
1173            for bare in ["-", "+"] {
1174                let suggestions = provider.suggestions(bare, &context);
1175                assert_eq!(suggestions.len(), 1);
1176                assert!(suggestions[0].disabled);
1177            }
1178        }
1179    }
1180
1181    /// Unsigned input is always interpreted as absolute — independent of the
1182    /// `relative_line_numbers` display setting.
1183    #[test]
1184    fn test_goto_line_unsigned_is_absolute_regardless_of_setting() {
1185        let provider = GotoLineProvider::new();
1186
1187        for relative_setting in [false, true] {
1188            let mut context = make_test_context("/tmp");
1189            context.relative_line_numbers = relative_setting;
1190
1191            let suggestions = provider.suggestions("42", &context);
1192            assert_eq!(suggestions.len(), 1);
1193            assert!(!suggestions[0].disabled);
1194            match provider.on_select(suggestions.first(), "42", &context) {
1195                QuickOpenResult::GotoLine(GotoLineTarget::Absolute(n)) => assert_eq!(n, 42),
1196                other => panic!("expected absolute GotoLine, got {:?}", other),
1197            }
1198        }
1199    }
1200
1201    // ====================================================================
1202    // FileProvider tests
1203    // ====================================================================
1204
1205    /// A ProcessSpawner that always fails — forces FileProvider to use the
1206    /// FileSystem walk fallback, which is exactly the code path that was
1207    /// broken on Windows and remote filesystems.
1208    struct FailingSpawner;
1209
1210    #[async_trait::async_trait]
1211    impl crate::services::remote::ProcessSpawner for FailingSpawner {
1212        async fn spawn(
1213            &self,
1214            _command: String,
1215            _args: Vec<String>,
1216            _cwd: Option<String>,
1217        ) -> Result<crate::services::remote::SpawnResult, crate::services::remote::SpawnError>
1218        {
1219            Err(crate::services::remote::SpawnError::Process(
1220                "no git in test".to_string(),
1221            ))
1222        }
1223    }
1224
1225    /// Create a FileProvider backed by StdFileSystem and a FailingSpawner
1226    /// (no runtime handle, so try_git_files is skipped entirely).
1227    fn make_file_provider() -> FileProvider {
1228        FileProvider::new(
1229            std::sync::Arc::new(crate::model::filesystem::StdFileSystem),
1230            std::sync::Arc::new(FailingSpawner),
1231            None, // no runtime → git ls-files path is skipped, sync fallback used
1232            None, // no async sender → sync fallback used
1233        )
1234    }
1235
1236    /// A second distinguishable spawner so backend-swap tests can assert
1237    /// *which* spawner the provider now holds by `Arc` identity.
1238    struct OtherSpawner;
1239
1240    #[async_trait::async_trait]
1241    impl crate::services::remote::ProcessSpawner for OtherSpawner {
1242        async fn spawn(
1243            &self,
1244            _command: String,
1245            _args: Vec<String>,
1246            _cwd: Option<String>,
1247        ) -> Result<crate::services::remote::SpawnResult, crate::services::remote::SpawnError>
1248        {
1249            Err(crate::services::remote::SpawnError::Process(
1250                "other".to_string(),
1251            ))
1252        }
1253    }
1254
1255    /// `set_backends` re-points the provider's spawner + filesystem at the new
1256    /// authority and invalidates the cache built from the old one.
1257    ///
1258    /// This is the seam behind the "quick-open lists host files in a remote
1259    /// session" bug: the file list's fast path is `git ls-files` through
1260    /// `process_spawner`, so after an in-place authority swap the provider must
1261    /// adopt the new spawner — otherwise it keeps querying the previous
1262    /// backend. Before this re-pointing existed the provider was stuck on its
1263    /// construction-time (local) spawner, which is exactly what surfaced host
1264    /// files in a remote session.
1265    #[test]
1266    fn set_backends_repoints_spawner_and_invalidates_cache() {
1267        let mut fp = make_file_provider();
1268
1269        // Seed a cache entry as if a previous load under the old backend
1270        // populated it.
1271        {
1272            let mut c = fp.cache.lock().unwrap();
1273            c.loaded_cwd = Some("/old".to_string());
1274            c.files = Some(std::sync::Arc::new(vec![]));
1275        }
1276
1277        let new_fs: std::sync::Arc<dyn crate::model::filesystem::FileSystem + Send + Sync> =
1278            std::sync::Arc::new(crate::model::filesystem::StdFileSystem);
1279        let new_spawner: std::sync::Arc<dyn crate::services::remote::ProcessSpawner> =
1280            std::sync::Arc::new(OtherSpawner);
1281
1282        fp.set_backends(
1283            std::sync::Arc::clone(&new_fs),
1284            std::sync::Arc::clone(&new_spawner),
1285        );
1286
1287        // The provider now routes through the *new* spawner (identity check) …
1288        assert!(
1289            std::sync::Arc::ptr_eq(&fp.process_spawner, &new_spawner),
1290            "set_backends must adopt the new authority's spawner"
1291        );
1292        // … and the cache built from the old backend is gone.
1293        let c = fp.cache.lock().unwrap();
1294        assert!(
1295            c.files.is_none() && c.loaded_cwd.is_none(),
1296            "stale cache from the previous backend must be cleared"
1297        );
1298    }
1299
1300    #[test]
1301    fn test_file_provider_discovers_files_via_walk() {
1302        let dir = tempfile::tempdir().unwrap();
1303        let base = dir.path();
1304
1305        // Create a small project structure
1306        std::fs::write(base.join("main.rs"), b"fn main() {}").unwrap();
1307        std::fs::write(base.join("lib.rs"), b"pub mod foo;").unwrap();
1308        std::fs::create_dir(base.join("src")).unwrap();
1309        std::fs::write(base.join("src").join("foo.rs"), b"// foo").unwrap();
1310
1311        let provider = make_file_provider();
1312        let context = make_test_context(&base.display().to_string());
1313        let suggestions = provider.suggestions("", &context);
1314
1315        // Should find all 3 files
1316        assert_eq!(suggestions.len(), 3);
1317        let paths: Vec<&str> = suggestions
1318            .iter()
1319            .filter_map(|s| s.value.as_deref())
1320            .collect();
1321        assert!(paths.contains(&"main.rs"));
1322        assert!(paths.contains(&"lib.rs"));
1323        assert!(paths.contains(&"src/foo.rs"));
1324    }
1325
1326    #[test]
1327    fn test_file_provider_skips_ignored_dirs() {
1328        let dir = tempfile::tempdir().unwrap();
1329        let base = dir.path();
1330
1331        std::fs::write(base.join("app.rs"), b"").unwrap();
1332        // These directories should be skipped
1333        std::fs::create_dir(base.join("node_modules")).unwrap();
1334        std::fs::write(base.join("node_modules").join("pkg.js"), b"").unwrap();
1335        std::fs::create_dir(base.join("target")).unwrap();
1336        std::fs::write(base.join("target").join("debug.o"), b"").unwrap();
1337
1338        let provider = make_file_provider();
1339        let context = make_test_context(&base.display().to_string());
1340        let suggestions = provider.suggestions("", &context);
1341
1342        assert_eq!(suggestions.len(), 1);
1343        assert_eq!(suggestions[0].value.as_deref(), Some("app.rs"));
1344    }
1345
1346    #[test]
1347    fn test_file_provider_skips_hidden_files() {
1348        let dir = tempfile::tempdir().unwrap();
1349        let base = dir.path();
1350
1351        std::fs::write(base.join("visible.txt"), b"").unwrap();
1352        std::fs::write(base.join(".hidden"), b"").unwrap();
1353        std::fs::create_dir(base.join(".git")).unwrap();
1354        std::fs::write(base.join(".git").join("config"), b"").unwrap();
1355
1356        let provider = make_file_provider();
1357        let context = make_test_context(&base.display().to_string());
1358        let suggestions = provider.suggestions("", &context);
1359
1360        assert_eq!(suggestions.len(), 1);
1361        assert_eq!(suggestions[0].value.as_deref(), Some("visible.txt"));
1362    }
1363
1364    #[test]
1365    fn test_file_provider_fuzzy_filter() {
1366        let dir = tempfile::tempdir().unwrap();
1367        let base = dir.path();
1368
1369        std::fs::write(base.join("main.rs"), b"").unwrap();
1370        std::fs::write(base.join("lib.rs"), b"").unwrap();
1371        std::fs::write(base.join("README.md"), b"").unwrap();
1372
1373        let provider = make_file_provider();
1374        let context = make_test_context(&base.display().to_string());
1375        let suggestions = provider.suggestions("main", &context);
1376
1377        assert_eq!(suggestions.len(), 1);
1378        assert_eq!(suggestions[0].value.as_deref(), Some("main.rs"));
1379    }
1380
1381    #[test]
1382    fn test_file_provider_empty_dir() {
1383        let dir = tempfile::tempdir().unwrap();
1384
1385        let provider = make_file_provider();
1386        let context = make_test_context(&dir.path().display().to_string());
1387        let suggestions = provider.suggestions("", &context);
1388
1389        // Should show "no files" disabled suggestion
1390        assert_eq!(suggestions.len(), 1);
1391        assert!(suggestions[0].disabled);
1392    }
1393
1394    // ====================================================================
1395    // Prefix probe tests
1396    // ====================================================================
1397
1398    /// Covers every probe_prefix behaviour in a single tempdir:
1399    ///   - basename-prefix match inside a subdirectory
1400    ///   - directory-listing match when the query *is* a directory
1401    ///   - empty result for a nonexistent path
1402    ///   - basename-prefix match at the cwd root (empty rel_parent path)
1403    #[test]
1404    fn test_probe_prefix_all_shapes() {
1405        let dir = tempfile::tempdir().unwrap();
1406        let base = dir.path();
1407
1408        // Subdirectory with multiple basename-prefix siblings + one unrelated file
1409        std::fs::create_dir(base.join("etc")).unwrap();
1410        std::fs::write(base.join("etc").join("hosts"), b"").unwrap();
1411        std::fs::write(base.join("etc").join("hosts.allow"), b"").unwrap();
1412        std::fs::write(base.join("etc").join("hosts.deny"), b"").unwrap();
1413        std::fs::write(base.join("etc").join("passwd"), b"").unwrap();
1414
1415        // Subdirectory for the directory-listing query
1416        std::fs::create_dir(base.join("src")).unwrap();
1417        std::fs::write(base.join("src").join("main.rs"), b"").unwrap();
1418        std::fs::write(base.join("src").join("lib.rs"), b"").unwrap();
1419
1420        // Root-level files with a basename-prefix sibling + one unrelated file
1421        std::fs::write(base.join("Makefile"), b"").unwrap();
1422        std::fs::write(base.join("Makefile.bak"), b"").unwrap();
1423        std::fs::write(base.join("README.md"), b"").unwrap();
1424
1425        let provider = make_file_provider();
1426        let cwd = base.display().to_string();
1427
1428        // 1. Basename prefix inside a subdirectory (rel_parent = "etc")
1429        let r = provider.probe_prefix(&cwd, "etc/hosts");
1430        let paths: Vec<&str> = r.iter().map(|e| e.relative_path.as_str()).collect();
1431        assert!(
1432            paths.contains(&"etc/hosts"),
1433            "missing etc/hosts in {paths:?}"
1434        );
1435        assert!(
1436            paths.contains(&"etc/hosts.allow"),
1437            "missing etc/hosts.allow in {paths:?}"
1438        );
1439        assert!(
1440            paths.contains(&"etc/hosts.deny"),
1441            "missing etc/hosts.deny in {paths:?}"
1442        );
1443        assert!(
1444            !paths.contains(&"etc/passwd"),
1445            "passwd shouldn't match prefix 'hosts': {paths:?}"
1446        );
1447
1448        // 2. Directory query — the query is itself a directory, so we list it.
1449        let r = provider.probe_prefix(&cwd, "src");
1450        let paths: Vec<&str> = r.iter().map(|e| e.relative_path.as_str()).collect();
1451        assert!(
1452            paths.contains(&"src/main.rs"),
1453            "missing src/main.rs in {paths:?}"
1454        );
1455        assert!(
1456            paths.contains(&"src/lib.rs"),
1457            "missing src/lib.rs in {paths:?}"
1458        );
1459
1460        // 3. Nonexistent path — neither parent-dir probe nor dir listing finds anything.
1461        let r = provider.probe_prefix(&cwd, "nonexistent/path/to/file");
1462        assert!(
1463            r.is_empty(),
1464            "nonexistent query should return empty, got {:?}",
1465            r.iter().map(|e| &e.relative_path).collect::<Vec<_>>()
1466        );
1467
1468        // 4. Basename prefix at the cwd root (rel_parent is empty).
1469        let r = provider.probe_prefix(&cwd, "Makefile");
1470        let paths: Vec<&str> = r.iter().map(|e| e.relative_path.as_str()).collect();
1471        assert!(paths.contains(&"Makefile"), "missing Makefile in {paths:?}");
1472        assert!(
1473            paths.contains(&"Makefile.bak"),
1474            "missing Makefile.bak in {paths:?}"
1475        );
1476        assert!(
1477            !paths.contains(&"README.md"),
1478            "README.md shouldn't match prefix 'Makefile': {paths:?}"
1479        );
1480    }
1481
1482    // ====================================================================
1483    // Prefix scoring boost tests
1484    // ====================================================================
1485
1486    #[test]
1487    fn test_prefix_match_ranks_above_fuzzy_match() {
1488        let dir = tempfile::tempdir().unwrap();
1489        let base = dir.path();
1490
1491        // Create files where "src/main" is a prefix of one path and
1492        // fuzzy-matches another.
1493        std::fs::create_dir(base.join("src")).unwrap();
1494        std::fs::write(base.join("src").join("main.rs"), b"").unwrap();
1495        // "some_random_main_file.rs" also fuzzy-matches "src/main" (the
1496        // characters s, r, c, m, a, i, n exist), but the prefix match
1497        // should rank higher.
1498        std::fs::write(base.join("src").join("manager.rs"), b"").unwrap();
1499
1500        let provider = make_file_provider();
1501        let context = make_test_context(&base.display().to_string());
1502        let suggestions = provider.suggestions("src/main", &context);
1503
1504        // The exact prefix match should be first
1505        assert!(!suggestions.is_empty());
1506        assert_eq!(suggestions[0].value.as_deref(), Some("src/main.rs"));
1507    }
1508
1509    #[test]
1510    fn test_set_partial_cache_keeps_loading() {
1511        let provider = make_file_provider();
1512
1513        // Simulate background loading start for a specific cwd.
1514        {
1515            let mut cache = provider.cache.lock().unwrap();
1516            cache.loading = true;
1517            cache.loaded_cwd = Some("/proj".to_string());
1518        }
1519
1520        // Partial cache update should keep loading = true
1521        let partial = std::sync::Arc::new(vec![FileEntry {
1522            relative_path: "foo.rs".to_string(),
1523            frecency_score: 0.0,
1524        }]);
1525        provider.set_partial_cache("/proj", partial);
1526
1527        assert!(provider.is_loading());
1528        assert!(provider.cache.lock().unwrap().files.is_some());
1529
1530        // Final set_cache should clear loading
1531        let final_files = std::sync::Arc::new(vec![FileEntry {
1532            relative_path: "foo.rs".to_string(),
1533            frecency_score: 0.0,
1534        }]);
1535        provider.set_cache("/proj", final_files);
1536
1537        assert!(!provider.is_loading());
1538
1539        // A stale-cwd update is ignored.
1540        let stale = std::sync::Arc::new(vec![FileEntry {
1541            relative_path: "other.rs".to_string(),
1542            frecency_score: 0.0,
1543        }]);
1544        provider.set_cache("/different", stale);
1545        assert_eq!(
1546            provider.cache.lock().unwrap().files.as_ref().unwrap()[0].relative_path,
1547            "foo.rs",
1548            "results for a different cwd must not overwrite the current cache"
1549        );
1550    }
1551}