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
105pub 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 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 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
178pub 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 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 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
258const 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#[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
288struct FileCache {
292 files: Option<std::sync::Arc<Vec<FileEntry>>>,
294 loading: bool,
296 loaded_cwd: Option<String>,
302}
303
304#[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: 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 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 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 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 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 }
394 }
395
396 fn is_loading(&self) -> bool {
398 self.cache.lock().is_ok_and(|c| c.loading)
399 }
400
401 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 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 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 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 fn get_or_start_loading(&self, cwd: &str) -> Option<std::sync::Arc<Vec<FileEntry>>> {
504 let mut cache = self.cache.lock().ok()?;
505
506 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; }
518 } else {
519 self.cancel
522 .store(true, std::sync::atomic::Ordering::Relaxed);
523 cache.files = None;
524 cache.loading = false;
525 }
526
527 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 drop(cache);
534 return self.load_files_sync(cwd);
535 }
536 };
537
538 cache.loading = true;
539 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 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 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 walk_dir_with_updates(&*filesystem, &cwd, &cancel, &frecency, &sender);
581 });
582
583 None
584 }
585
586 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 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 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
619fn try_git_files_blocking(
629 spawner: &std::sync::Arc<dyn crate::services::remote::ProcessSpawner>,
630 cwd: &str,
631) -> Option<Vec<String>> {
632 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
669fn 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 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
695const WALK_UPDATE_INTERVAL: std::time::Duration = std::time::Duration::from_millis(300);
698
699fn 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 if let Err(e) = fs.walk_files(base, IGNORED_DIRS, cancel, &mut |_path, rel| {
719 paths.push(rel.to_string());
720
721 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_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 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
786fn 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 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 if !self.filesystem.is_remote_connected() {
821 return vec![Suggestion::disabled(
822 "Remote connection lost — cannot list files".to_string(),
823 )];
824 }
825
826 let files = self.get_or_start_loading(&context.cwd);
829 let still_loading = self.is_loading();
830
831 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 let prefix_set: std::collections::HashSet<&str> = prefix_entries
856 .iter()
857 .map(|e| e.relative_path.as_str())
858 .collect();
859
860 const PREFIX_PROBE_BOOST: i32 = 200;
862
863 let mut matcher = FuzzyMatcher::new(search_query);
868
869 let mut scored: Vec<(String, i32)> = Vec::new();
871
872 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 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 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 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 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 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 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 let suggestions = provider.suggestions("42", &context);
1040 assert_eq!(suggestions.len(), 1);
1041 assert!(!suggestions[0].disabled);
1042
1043 let suggestions = provider.suggestions("", &context);
1045 assert_eq!(suggestions.len(), 1);
1046 assert!(suggestions[0].disabled);
1047
1048 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 #[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 #[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 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 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, None, )
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 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 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 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 assert_eq!(suggestions.len(), 1);
1249 assert!(suggestions[0].disabled);
1250 }
1251
1252 #[test]
1262 fn test_probe_prefix_all_shapes() {
1263 let dir = tempfile::tempdir().unwrap();
1264 let base = dir.path();
1265
1266 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 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 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 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 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 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 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 #[test]
1345 fn test_prefix_match_ranks_above_fuzzy_match() {
1346 let dir = tempfile::tempdir().unwrap();
1347 let base = dir.path();
1348
1349 std::fs::create_dir(base.join("src")).unwrap();
1352 std::fs::write(base.join("src").join("main.rs"), b"").unwrap();
1353 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 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 {
1373 let mut cache = provider.cache.lock().unwrap();
1374 cache.loading = true;
1375 cache.loaded_cwd = Some("/proj".to_string());
1376 }
1377
1378 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 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 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}