Skip to main content

fresh/input/quick_open/
providers.rs

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