1use 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
16pub struct CommandProvider {
22 command_registry:
24 std::sync::Arc<std::sync::RwLock<crate::input::command_registry::CommandRegistry>>,
25 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
109pub 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 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 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
186pub 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 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 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
270const 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#[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
300struct FileCache {
304 files: Option<std::sync::Arc<Vec<FileEntry>>>,
306 loading: bool,
308 loaded_cwd: Option<String>,
314}
315
316#[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: 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 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 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 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 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 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 }
423 }
424
425 fn is_loading(&self) -> bool {
427 self.cache.lock().is_ok_and(|c| c.loading)
428 }
429
430 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 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 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 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 fn get_or_start_loading(&self, cwd: &str) -> Option<std::sync::Arc<Vec<FileEntry>>> {
533 let mut cache = self.cache.lock().ok()?;
534
535 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; }
547 } else {
548 self.cancel
551 .store(true, std::sync::atomic::Ordering::Relaxed);
552 cache.files = None;
553 cache.loading = false;
554 }
555
556 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 drop(cache);
563 return self.load_files_sync(cwd);
564 }
565 };
566
567 cache.loading = true;
568 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 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 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 walk_dir_with_updates(&*filesystem, &cwd, &cancel, &frecency, &sender);
610 });
611
612 None
613 }
614
615 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 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 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
648fn try_git_files_blocking(
658 spawner: &std::sync::Arc<dyn crate::services::remote::ProcessSpawner>,
659 cwd: &str,
660) -> Option<Vec<String>> {
661 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
698fn 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 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
724const WALK_UPDATE_INTERVAL: std::time::Duration = std::time::Duration::from_millis(300);
727
728fn 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 if let Err(e) = fs.walk_files(base, IGNORED_DIRS, cancel, &mut |_path, rel| {
748 paths.push(rel.to_string());
749
750 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_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 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
815fn 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 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 if !self.filesystem.is_remote_connected() {
850 return vec![Suggestion::disabled(
851 "Remote connection lost — cannot list files".to_string(),
852 )];
853 }
854
855 let files = self.get_or_start_loading(&context.cwd);
858 let still_loading = self.is_loading();
859
860 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 let prefix_set: std::collections::HashSet<&str> = prefix_entries
885 .iter()
886 .map(|e| e.relative_path.as_str())
887 .collect();
888
889 const PREFIX_PROBE_BOOST: i32 = 200;
891
892 let mut matcher = FuzzyMatcher::new(search_query);
897
898 let mut scored: Vec<(String, i32)> = Vec::new();
900
901 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 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 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 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 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 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 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 let suggestions = provider.suggestions("42", &context);
1073 assert_eq!(suggestions.len(), 1);
1074 assert!(!suggestions[0].disabled);
1075
1076 let suggestions = provider.suggestions("", &context);
1078 assert_eq!(suggestions.len(), 1);
1079 assert!(suggestions[0].disabled);
1080
1081 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 #[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 #[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 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 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, None, )
1189 }
1190
1191 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 #[test]
1221 fn set_backends_repoints_spawner_and_invalidates_cache() {
1222 let mut fp = make_file_provider();
1223
1224 {
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 assert!(
1244 std::sync::Arc::ptr_eq(&fp.process_spawner, &new_spawner),
1245 "set_backends must adopt the new authority's spawner"
1246 );
1247 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 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 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 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 assert_eq!(suggestions.len(), 1);
1346 assert!(suggestions[0].disabled);
1347 }
1348
1349 #[test]
1359 fn test_probe_prefix_all_shapes() {
1360 let dir = tempfile::tempdir().unwrap();
1361 let base = dir.path();
1362
1363 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 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 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 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 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 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 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 #[test]
1442 fn test_prefix_match_ranks_above_fuzzy_match() {
1443 let dir = tempfile::tempdir().unwrap();
1444 let base = dir.path();
1445
1446 std::fs::create_dir(base.join("src")).unwrap();
1449 std::fs::write(base.join("src").join("main.rs"), b"").unwrap();
1450 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 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 {
1470 let mut cache = provider.cache.lock().unwrap();
1471 cache.loading = true;
1472 cache.loaded_cwd = Some("/proj".to_string());
1473 }
1474
1475 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 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 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}