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