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.is_virtual || !buf.path.is_empty())
142 .filter_map(|buf| {
143 let m = matcher.match_target(&buf.name);
144 if !m.matched {
145 return None;
146 }
147
148 let display_name = if buf.modified {
149 format!("{} [+]", buf.name)
150 } else {
151 buf.name.clone()
152 };
153
154 let suggestion = Suggestion::new(display_name)
155 .with_description(buf.path.clone())
156 .with_value(buf.id.to_string());
157 Some((suggestion, m.score, buf.id))
158 })
159 .collect();
160
161 scored.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.2.cmp(&b.2)));
163 scored.into_iter().map(|(s, _, _)| s).collect()
164 }
165
166 fn on_select(
167 &self,
168 suggestion: Option<&Suggestion>,
169 _query: &str,
170 _context: &QuickOpenContext,
171 ) -> QuickOpenResult {
172 suggestion
173 .and_then(|s| s.value.as_deref())
174 .and_then(|v| v.parse::<usize>().ok())
175 .map(QuickOpenResult::ShowBuffer)
176 .unwrap_or(QuickOpenResult::None)
177 }
178
179 fn as_any(&self) -> &dyn std::any::Any {
180 self
181 }
182
183 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
184 self
185 }
186}
187
188pub struct GotoLineProvider;
194
195impl GotoLineProvider {
196 pub fn new() -> Self {
197 Self
198 }
199}
200
201impl Default for GotoLineProvider {
202 fn default() -> Self {
203 Self::new()
204 }
205}
206
207impl QuickOpenProvider for GotoLineProvider {
208 fn prefix(&self) -> &str {
209 ":"
210 }
211
212 fn suggestions(&self, query: &str, _context: &QuickOpenContext) -> Vec<Suggestion> {
213 if query.is_empty() {
214 return vec![
215 Suggestion::disabled(t!("quick_open.goto_line_hint").to_string())
216 .with_description(t!("quick_open.goto_line_desc").to_string()),
217 ];
218 }
219
220 if query == "-" || query == "+" {
222 return vec![
223 Suggestion::disabled(t!("quick_open.goto_line_hint").to_string())
224 .with_description(t!("quick_open.relative_line_desc").to_string()),
225 ];
226 }
227
228 match parse_goto_line_input(query) {
229 Some(target) => {
230 let label = match target {
231 GotoLineTarget::Absolute(n) => {
232 t!("quick_open.goto_line", line = n.to_string()).to_string()
233 }
234 GotoLineTarget::Relative(d) => {
235 t!("quick_open.goto_line", line = format!("{:+}", d)).to_string()
237 }
238 };
239 vec![Suggestion::new(label)
240 .with_description(t!("quick_open.press_enter").to_string())
241 .with_value(query.to_string())]
242 }
243 None => vec![
244 Suggestion::disabled(t!("quick_open.invalid_line").to_string())
245 .with_description(query.to_string()),
246 ],
247 }
248 }
249
250 fn on_select(
251 &self,
252 suggestion: Option<&Suggestion>,
253 _query: &str,
254 _context: &QuickOpenContext,
255 ) -> QuickOpenResult {
256 suggestion
257 .and_then(|s| s.value.as_deref())
258 .and_then(parse_goto_line_input)
259 .map(QuickOpenResult::GotoLine)
260 .unwrap_or(QuickOpenResult::None)
261 }
262
263 fn as_any(&self) -> &dyn std::any::Any {
264 self
265 }
266
267 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
268 self
269 }
270}
271
272const IGNORED_DIRS: &[&str] = &[
278 ".git",
279 "node_modules",
280 "target",
281 "__pycache__",
282 ".hg",
283 ".svn",
284 ".DS_Store",
285];
286
287const MAX_FILES: usize = 50_000;
288
289#[derive(Clone, Debug)]
291pub struct FileEntry {
292 relative_path: String,
293 frecency_score: f64,
294}
295
296#[derive(Clone)]
297struct FrecencyData {
298 access_count: u32,
299 last_access: std::time::Instant,
300}
301
302struct FileCache {
306 files: Option<std::sync::Arc<Vec<FileEntry>>>,
308 loading: bool,
310 loaded_cwd: Option<String>,
316}
317
318#[derive(Clone)]
330pub struct FileProvider {
331 cache: std::sync::Arc<std::sync::Mutex<FileCache>>,
332 frecency: std::sync::Arc<std::sync::RwLock<std::collections::HashMap<String, FrecencyData>>>,
333 filesystem: std::sync::Arc<dyn crate::model::filesystem::FileSystem + Send + Sync>,
334 process_spawner: std::sync::Arc<dyn crate::services::remote::ProcessSpawner>,
335 runtime_handle: Option<tokio::runtime::Handle>,
336 async_sender: Option<std::sync::mpsc::Sender<crate::services::async_bridge::AsyncMessage>>,
337 cancel: std::sync::Arc<std::sync::atomic::AtomicBool>,
339}
340
341impl FileProvider {
342 pub fn new(
343 filesystem: std::sync::Arc<dyn crate::model::filesystem::FileSystem + Send + Sync>,
344 process_spawner: std::sync::Arc<dyn crate::services::remote::ProcessSpawner>,
345 runtime_handle: Option<tokio::runtime::Handle>,
346 async_sender: Option<std::sync::mpsc::Sender<crate::services::async_bridge::AsyncMessage>>,
347 ) -> Self {
348 Self {
349 cache: std::sync::Arc::new(std::sync::Mutex::new(FileCache {
350 files: None,
351 loading: false,
352 loaded_cwd: None,
353 })),
354 frecency: std::sync::Arc::new(std::sync::RwLock::new(std::collections::HashMap::new())),
355 filesystem,
356 process_spawner,
357 runtime_handle,
358 async_sender,
359 cancel: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
360 }
361 }
362
363 pub fn set_backends(
371 &mut self,
372 filesystem: std::sync::Arc<dyn crate::model::filesystem::FileSystem + Send + Sync>,
373 process_spawner: std::sync::Arc<dyn crate::services::remote::ProcessSpawner>,
374 ) {
375 self.filesystem = filesystem;
376 self.process_spawner = process_spawner;
377 self.clear_cache();
378 }
379
380 pub fn clear_cache(&self) {
382 self.cancel
383 .store(true, std::sync::atomic::Ordering::Relaxed);
384 if let Ok(mut c) = self.cache.lock() {
385 c.files = None;
386 c.loading = false;
387 c.loaded_cwd = None;
388 }
389 }
390
391 pub fn cancel_loading(&self) {
394 self.cancel
395 .store(true, std::sync::atomic::Ordering::Relaxed);
396 if let Ok(mut c) = self.cache.lock() {
397 c.loading = false;
398 }
399 }
400
401 pub fn set_cache(&self, cwd: &str, files: std::sync::Arc<Vec<FileEntry>>) {
406 if let Ok(mut c) = self.cache.lock() {
407 if c.loaded_cwd.as_deref() != Some(cwd) {
408 return;
409 }
410 c.files = Some(files);
411 c.loading = false;
412 }
413 }
414
415 pub fn set_partial_cache(&self, cwd: &str, files: std::sync::Arc<Vec<FileEntry>>) {
418 if let Ok(mut c) = self.cache.lock() {
419 if c.loaded_cwd.as_deref() != Some(cwd) {
420 return;
421 }
422 c.files = Some(files);
423 }
425 }
426
427 fn is_loading(&self) -> bool {
429 self.cache.lock().is_ok_and(|c| c.loading)
430 }
431
432 pub fn record_access(&self, path: &str) {
434 if let Ok(mut frecency) = self.frecency.write() {
435 let entry = frecency.entry(path.to_string()).or_insert(FrecencyData {
436 access_count: 0,
437 last_access: std::time::Instant::now(),
438 });
439 entry.access_count += 1;
440 entry.last_access = std::time::Instant::now();
441 }
442 }
443
444 fn get_frecency_score(&self, path: &str) -> f64 {
445 self.frecency
446 .read()
447 .ok()
448 .and_then(|m| m.get(path).map(frecency_score))
449 .unwrap_or(0.0)
450 }
451
452 fn probe_prefix(&self, cwd: &str, query: &str) -> Vec<FileEntry> {
460 use std::path::Path;
461
462 if query.is_empty() {
463 return vec![];
464 }
465
466 let abs_path = Path::new(cwd).join(query);
467 let mut results = Vec::new();
468
469 if let Ok(entries) = self.filesystem.read_dir(&abs_path) {
471 let query_trimmed = query.trim_end_matches('/');
472 for entry in entries {
473 if entry.is_file() && !entry.name.starts_with('.') {
474 let rel = format!("{}/{}", query_trimmed, entry.name);
475 results.push(FileEntry {
476 frecency_score: self.get_frecency_score(&rel),
477 relative_path: rel,
478 });
479 }
480 }
481 results.truncate(50);
482 return results;
483 }
484
485 let parent = match abs_path.parent() {
488 Some(p) => p,
489 None => return results,
490 };
491 let basename = match abs_path.file_name().and_then(|n| n.to_str()) {
492 Some(b) => b,
493 None => return results,
494 };
495
496 let rel_parent = match parent.strip_prefix(cwd) {
497 Ok(p) => {
498 let s = p.to_string_lossy().replace('\\', "/");
499 s
500 }
501 Err(_) => return results,
502 };
503
504 if let Ok(entries) = self.filesystem.read_dir(parent) {
505 for entry in entries {
506 if entry.name.starts_with('.') {
507 continue;
508 }
509 if !entry.name.starts_with(basename) {
510 continue;
511 }
512 if entry.is_file() {
513 let rel = if rel_parent.is_empty() {
514 entry.name.clone()
515 } else {
516 format!("{}/{}", rel_parent, entry.name)
517 };
518 results.push(FileEntry {
519 frecency_score: self.get_frecency_score(&rel),
520 relative_path: rel,
521 });
522 }
523 }
524 }
525
526 results
527 }
528
529 fn get_or_start_loading(&self, cwd: &str) -> Option<std::sync::Arc<Vec<FileEntry>>> {
535 let mut cache = self.cache.lock().ok()?;
536
537 let cwd_matches = cache.loaded_cwd.as_deref() == Some(cwd);
542 if cwd_matches {
543 if let Some(files) = &cache.files {
544 return Some(std::sync::Arc::clone(files));
545 }
546 if cache.loading {
547 return None; }
549 } else {
550 self.cancel
553 .store(true, std::sync::atomic::Ordering::Relaxed);
554 cache.files = None;
555 cache.loading = false;
556 }
557
558 cache.loaded_cwd = Some(cwd.to_string());
560 let (sender, handle) = match (&self.async_sender, &self.runtime_handle) {
561 (Some(s), Some(h)) => (s.clone(), h.clone()),
562 _ => {
563 drop(cache);
565 return self.load_files_sync(cwd);
566 }
567 };
568
569 cache.loading = true;
570 self.cancel
572 .store(false, std::sync::atomic::Ordering::Relaxed);
573 let cancel = std::sync::Arc::clone(&self.cancel);
574 let frecency = std::sync::Arc::clone(&self.frecency);
575 let filesystem = std::sync::Arc::clone(&self.filesystem);
576 let process_spawner = std::sync::Arc::clone(&self.process_spawner);
577 let cwd = cwd.to_string();
578
579 handle.spawn_blocking(move || {
580 if let Some(files) = try_git_files_blocking(&process_spawner, &cwd) {
582 let frecency_map = frecency.read().ok();
583 let entries: Vec<FileEntry> = files
584 .into_iter()
585 .map(|path| {
586 let score = frecency_map
587 .as_ref()
588 .and_then(|m| m.get(&path))
589 .map(frecency_score)
590 .unwrap_or(0.0);
591 FileEntry {
592 relative_path: path,
593 frecency_score: score,
594 }
595 })
596 .collect();
597 drop(sender.send(
600 crate::services::async_bridge::AsyncMessage::QuickOpenFilesLoaded {
601 cwd: cwd.clone(),
602 files: std::sync::Arc::new(entries),
603 complete: true,
604 },
605 ));
606 return;
607 }
608
609 walk_dir_with_updates(&*filesystem, &cwd, &cancel, &frecency, &sender);
612 });
613
614 None
615 }
616
617 fn load_files_sync(&self, cwd: &str) -> Option<std::sync::Arc<Vec<FileEntry>>> {
619 let files = self
620 .try_git_files(cwd)
621 .or_else(|| self.try_walk_dir(cwd))
622 .unwrap_or_default();
623
624 let entries: Vec<FileEntry> = files
625 .into_iter()
626 .map(|path| FileEntry {
627 frecency_score: self.get_frecency_score(&path),
628 relative_path: path,
629 })
630 .collect();
631
632 let files = std::sync::Arc::new(entries);
633 self.set_cache(cwd, std::sync::Arc::clone(&files));
634 Some(files)
635 }
636
637 fn try_git_files(&self, cwd: &str) -> Option<Vec<String>> {
639 let handle = self.runtime_handle.as_ref()?;
640 try_git_files_with_handle(&self.process_spawner, cwd, handle)
641 }
642
643 fn try_walk_dir(&self, cwd: &str) -> Option<Vec<String>> {
645 let cancel = std::sync::atomic::AtomicBool::new(false);
646 try_walk_dir_blocking(&*self.filesystem, cwd, &cancel)
647 }
648}
649
650fn try_git_files_blocking(
660 spawner: &std::sync::Arc<dyn crate::services::remote::ProcessSpawner>,
661 cwd: &str,
662) -> Option<Vec<String>> {
663 let handle = tokio::runtime::Handle::try_current().ok()?;
665 try_git_files_with_handle(spawner, cwd, &handle)
666}
667
668fn try_git_files_with_handle(
669 spawner: &std::sync::Arc<dyn crate::services::remote::ProcessSpawner>,
670 cwd: &str,
671 handle: &tokio::runtime::Handle,
672) -> Option<Vec<String>> {
673 let result = handle
674 .block_on(spawner.spawn(
675 "git".to_string(),
676 vec![
677 "ls-files".to_string(),
678 "--cached".to_string(),
679 "--others".to_string(),
680 "--exclude-standard".to_string(),
681 ],
682 Some(cwd.to_string()),
683 ))
684 .ok()?;
685
686 if result.exit_code != 0 {
687 return None;
688 }
689
690 let files: Vec<String> = result
691 .stdout
692 .lines()
693 .filter(|line| !line.is_empty() && !line.starts_with(".git/"))
694 .map(|s| s.to_string())
695 .collect();
696
697 Some(files)
698}
699
700fn try_walk_dir_blocking(
702 fs: &dyn crate::model::filesystem::FileSystem,
703 cwd: &str,
704 cancel: &std::sync::atomic::AtomicBool,
705) -> Option<Vec<String>> {
706 use std::path::Path;
707
708 let base = Path::new(cwd);
709 let mut files = Vec::new();
710
711 drop(
713 fs.walk_files(base, IGNORED_DIRS, cancel, &mut |_path, rel| {
714 files.push(rel.to_string());
715 files.len() < MAX_FILES
716 }),
717 );
718
719 if files.is_empty() {
720 None
721 } else {
722 Some(files)
723 }
724}
725
726const WALK_UPDATE_INTERVAL: std::time::Duration = std::time::Duration::from_millis(300);
729
730fn walk_dir_with_updates(
733 fs: &dyn crate::model::filesystem::FileSystem,
734 cwd: &str,
735 cancel: &std::sync::atomic::AtomicBool,
736 frecency: &std::sync::RwLock<std::collections::HashMap<String, FrecencyData>>,
737 sender: &std::sync::mpsc::Sender<crate::services::async_bridge::AsyncMessage>,
738) {
739 use std::path::Path;
740
741 let base = Path::new(cwd);
742 let mut paths: Vec<String> = Vec::new();
743 let mut last_send = std::time::Instant::now();
744 let mut receiver_gone = false;
745
746 if let Err(e) = fs.walk_files(base, IGNORED_DIRS, cancel, &mut |_path, rel| {
750 paths.push(rel.to_string());
751
752 if last_send.elapsed() >= WALK_UPDATE_INTERVAL {
754 let frecency_map = frecency.read().ok();
755 let entries: Vec<FileEntry> = paths
756 .iter()
757 .map(|p| FileEntry {
758 frecency_score: frecency_map
759 .as_ref()
760 .and_then(|m| m.get(p).map(frecency_score))
761 .unwrap_or(0.0),
762 relative_path: p.clone(),
763 })
764 .collect();
765 if sender
766 .send(
767 crate::services::async_bridge::AsyncMessage::QuickOpenFilesLoaded {
768 cwd: cwd.to_string(),
769 files: std::sync::Arc::new(entries),
770 complete: false,
771 },
772 )
773 .is_err()
774 {
775 receiver_gone = true;
778 return false;
779 }
780 last_send = std::time::Instant::now();
781 }
782
783 paths.len() < MAX_FILES
784 }) {
785 tracing::debug!("Quick Open walk_files failed: {}", e);
786 }
787
788 if receiver_gone {
789 return;
790 }
791
792 let frecency_map = frecency.read().ok();
795 let entries: Vec<FileEntry> = paths
796 .into_iter()
797 .map(|p| {
798 let score = frecency_map
799 .as_ref()
800 .and_then(|m| m.get(&p).map(frecency_score))
801 .unwrap_or(0.0);
802 FileEntry {
803 relative_path: p,
804 frecency_score: score,
805 }
806 })
807 .collect();
808 drop(sender.send(
809 crate::services::async_bridge::AsyncMessage::QuickOpenFilesLoaded {
810 cwd: cwd.to_string(),
811 files: std::sync::Arc::new(entries),
812 complete: true,
813 },
814 ));
815}
816
817fn frecency_score(data: &FrecencyData) -> f64 {
819 let hours_since_access = data.last_access.elapsed().as_secs_f64() / 3600.0;
820 let recency_weight = if hours_since_access < 4.0 {
821 100.0
822 } else if hours_since_access < 24.0 {
823 70.0
824 } else if hours_since_access < 24.0 * 7.0 {
825 50.0
826 } else if hours_since_access < 24.0 * 30.0 {
827 30.0
828 } else if hours_since_access < 24.0 * 90.0 {
829 10.0
830 } else {
831 1.0
832 };
833 data.access_count as f64 * recency_weight
834}
835
836impl QuickOpenProvider for FileProvider {
837 fn prefix(&self) -> &str {
838 ""
839 }
840
841 fn suggestions(&self, query: &str, context: &QuickOpenContext) -> Vec<Suggestion> {
842 let (path_part, _, _) = super::parse_path_line_col(query);
844 let search_query = if path_part.is_empty() {
845 query
846 } else {
847 &path_part
848 };
849
850 if !self.filesystem.is_remote_connected() {
852 return vec![Suggestion::disabled(
853 "Remote connection lost — cannot list files".to_string(),
854 )];
855 }
856
857 let files = self.get_or_start_loading(&context.cwd);
860 let still_loading = self.is_loading();
861
862 let prefix_entries = if !search_query.is_empty() {
868 self.probe_prefix(&context.cwd, search_query)
869 } else {
870 vec![]
871 };
872
873 let has_files = files.as_ref().is_some_and(|f| !f.is_empty());
874
875 if !has_files && prefix_entries.is_empty() {
876 if still_loading {
877 return vec![Suggestion::disabled("Loading files…".to_string())];
878 } else {
879 return vec![Suggestion::disabled(t!("quick_open.no_files").to_string())];
880 }
881 }
882
883 let max_results = 100;
884
885 let prefix_set: std::collections::HashSet<&str> = prefix_entries
887 .iter()
888 .map(|e| e.relative_path.as_str())
889 .collect();
890
891 const PREFIX_PROBE_BOOST: i32 = 200;
893
894 let mut matcher = FuzzyMatcher::new(search_query);
899
900 let mut scored: Vec<(String, i32)> = Vec::new();
902
903 for entry in &prefix_entries {
905 let m = matcher.match_target(&entry.relative_path);
906 let base_score = if m.matched { m.score } else { 0 };
907 let frecency_boost = (entry.frecency_score / 100.0).min(20.0) as i32;
908 scored.push((
909 entry.relative_path.clone(),
910 base_score + frecency_boost + PREFIX_PROBE_BOOST,
911 ));
912 }
913
914 if let Some(files) = &files {
916 if search_query.is_empty() {
917 let mut entries: Vec<_> = files.iter().map(|f| (f, 0i32)).collect();
918 entries.sort_by(|a, b| {
919 b.0.frecency_score
920 .partial_cmp(&a.0.frecency_score)
921 .unwrap_or(std::cmp::Ordering::Equal)
922 });
923 entries.truncate(max_results);
924 for (f, s) in entries {
925 scored.push((f.relative_path.clone(), s));
926 }
927 } else {
928 for file in files.iter() {
929 if prefix_set.contains(file.relative_path.as_str()) {
931 continue;
932 }
933 let m = matcher.match_target(&file.relative_path);
934 if !m.matched {
935 continue;
936 }
937 let frecency_boost = (file.frecency_score / 100.0).min(20.0) as i32;
938 let mut score = m.score + frecency_boost;
939 if file.relative_path.starts_with(search_query) {
942 score += PREFIX_PROBE_BOOST;
943 }
944 scored.push((file.relative_path.clone(), score));
945 }
946 }
947 }
948
949 scored.sort_by(|a, b| b.1.cmp(&a.1));
950 scored.truncate(max_results);
951
952 let mut suggestions: Vec<Suggestion> = scored
953 .into_iter()
954 .map(|(path, _)| Suggestion::new(path.clone()).with_value(path))
955 .collect();
956
957 if still_loading {
958 let msg = if suggestions.is_empty() {
959 "Loading files…"
960 } else {
961 "Scanning for more files…"
962 };
963 suggestions.push(Suggestion::disabled(msg.to_string()));
964 }
965
966 suggestions
967 }
968
969 fn on_select(
970 &self,
971 suggestion: Option<&Suggestion>,
972 query: &str,
973 _context: &QuickOpenContext,
974 ) -> QuickOpenResult {
975 let (path_part, line, column) = super::parse_path_line_col(query);
976
977 if let Some(path) = suggestion.and_then(|s| s.value.as_deref()) {
979 self.record_access(path);
980 return QuickOpenResult::OpenFile {
981 path: path.to_string(),
982 line,
983 column,
984 };
985 }
986
987 if line.is_some() && !path_part.is_empty() {
989 self.record_access(&path_part);
990 return QuickOpenResult::OpenFile {
991 path: path_part,
992 line,
993 column,
994 };
995 }
996
997 QuickOpenResult::None
998 }
999
1000 fn as_any(&self) -> &dyn std::any::Any {
1001 self
1002 }
1003
1004 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
1005 self
1006 }
1007}
1008
1009#[cfg(test)]
1010mod tests {
1011 use super::*;
1012 use crate::input::quick_open::BufferInfo;
1013
1014 fn make_test_context(cwd: &str) -> QuickOpenContext {
1015 QuickOpenContext {
1016 cwd: cwd.to_string(),
1017 open_buffers: vec![
1018 BufferInfo {
1019 id: 1,
1020 path: "/tmp/main.rs".to_string(),
1021 name: "main.rs".to_string(),
1022 modified: false,
1023 is_virtual: false,
1024 },
1025 BufferInfo {
1026 id: 2,
1027 path: "/tmp/lib.rs".to_string(),
1028 name: "lib.rs".to_string(),
1029 modified: true,
1030 is_virtual: false,
1031 },
1032 ],
1033 active_buffer_id: 1,
1034 active_buffer_path: Some("/tmp/main.rs".to_string()),
1035 has_selection: false,
1036 key_context: crate::input::keybindings::KeyContext::Normal,
1037 custom_contexts: std::collections::HashSet::new(),
1038 buffer_mode: None,
1039 has_lsp_config: true,
1040 relative_line_numbers: false,
1041 }
1042 }
1043
1044 #[test]
1045 fn test_buffer_provider_suggestions() {
1046 let provider = BufferProvider::new();
1047 let context = make_test_context("/tmp");
1048
1049 let suggestions = provider.suggestions("", &context);
1050 assert_eq!(suggestions.len(), 2);
1051
1052 let lib_suggestion = suggestions
1054 .iter()
1055 .find(|s| s.text.contains("lib.rs"))
1056 .unwrap();
1057 assert!(lib_suggestion.text.contains("[+]"));
1058 }
1059
1060 #[test]
1061 fn test_buffer_provider_filter() {
1062 let provider = BufferProvider::new();
1063 let context = make_test_context("/tmp");
1064
1065 let suggestions = provider.suggestions("main", &context);
1066 assert_eq!(suggestions.len(), 1);
1067 assert!(suggestions[0].text.contains("main.rs"));
1068 }
1069
1070 #[test]
1073 fn test_buffer_provider_includes_virtual_buffers() {
1074 let provider = BufferProvider::new();
1075 let mut context = make_test_context("/tmp");
1076 context.open_buffers.push(BufferInfo {
1077 id: 3,
1078 path: String::new(),
1079 name: "*blame:lib.rs*".to_string(),
1080 modified: false,
1081 is_virtual: true,
1082 });
1083 context.open_buffers.push(BufferInfo {
1085 id: 4,
1086 path: String::new(),
1087 name: "scratch".to_string(),
1088 modified: false,
1089 is_virtual: false,
1090 });
1091
1092 let all = provider.suggestions("", &context);
1094 assert!(
1095 all.iter().any(|s| s.text.contains("*blame:lib.rs*")),
1096 "virtual buffer should be listed: {:?}",
1097 all.iter().map(|s| &s.text).collect::<Vec<_>>()
1098 );
1099 assert!(
1100 !all.iter().any(|s| s.text.contains("scratch")),
1101 "pathless non-virtual buffer should be excluded"
1102 );
1103
1104 let filtered = provider.suggestions("blame", &context);
1106 assert_eq!(filtered.len(), 1);
1107 assert!(filtered[0].text.contains("*blame:lib.rs*"));
1108 assert_eq!(filtered[0].value.as_deref(), Some("3"));
1109 }
1110
1111 #[test]
1112 fn test_goto_line_provider() {
1113 let provider = GotoLineProvider::new();
1114 let context = make_test_context("/tmp");
1115
1116 let suggestions = provider.suggestions("42", &context);
1118 assert_eq!(suggestions.len(), 1);
1119 assert!(!suggestions[0].disabled);
1120
1121 let suggestions = provider.suggestions("", &context);
1123 assert_eq!(suggestions.len(), 1);
1124 assert!(suggestions[0].disabled);
1125
1126 let suggestions = provider.suggestions("abc", &context);
1128 assert_eq!(suggestions.len(), 1);
1129 assert!(suggestions[0].disabled);
1130 }
1131
1132 #[test]
1133 fn test_goto_line_on_select() {
1134 let provider = GotoLineProvider::new();
1135 let context = make_test_context("/tmp");
1136
1137 let suggestions = provider.suggestions("42", &context);
1138 let result = provider.on_select(suggestions.first(), "42", &context);
1139 match result {
1140 QuickOpenResult::GotoLine(GotoLineTarget::Absolute(line)) => assert_eq!(line, 42),
1141 other => panic!("expected absolute GotoLine result, got {:?}", other),
1142 }
1143 }
1144
1145 #[test]
1148 fn test_goto_line_signed_is_relative_regardless_of_setting() {
1149 let provider = GotoLineProvider::new();
1150
1151 for relative_setting in [false, true] {
1152 let mut context = make_test_context("/tmp");
1153 context.relative_line_numbers = relative_setting;
1154
1155 for query in ["-5", "+3"] {
1156 let suggestions = provider.suggestions(query, &context);
1157 assert_eq!(suggestions.len(), 1, "query {query:?}");
1158 assert!(!suggestions[0].disabled, "query {query:?}");
1159 }
1160
1161 let suggestions = provider.suggestions("+3", &context);
1162 match provider.on_select(suggestions.first(), "+3", &context) {
1163 QuickOpenResult::GotoLine(GotoLineTarget::Relative(d)) => assert_eq!(d, 3),
1164 other => panic!("expected relative GotoLine, got {:?}", other),
1165 }
1166
1167 let suggestions = provider.suggestions("-7", &context);
1168 match provider.on_select(suggestions.first(), "-7", &context) {
1169 QuickOpenResult::GotoLine(GotoLineTarget::Relative(d)) => assert_eq!(d, -7),
1170 other => panic!("expected relative GotoLine, got {:?}", other),
1171 }
1172
1173 for bare in ["-", "+"] {
1174 let suggestions = provider.suggestions(bare, &context);
1175 assert_eq!(suggestions.len(), 1);
1176 assert!(suggestions[0].disabled);
1177 }
1178 }
1179 }
1180
1181 #[test]
1184 fn test_goto_line_unsigned_is_absolute_regardless_of_setting() {
1185 let provider = GotoLineProvider::new();
1186
1187 for relative_setting in [false, true] {
1188 let mut context = make_test_context("/tmp");
1189 context.relative_line_numbers = relative_setting;
1190
1191 let suggestions = provider.suggestions("42", &context);
1192 assert_eq!(suggestions.len(), 1);
1193 assert!(!suggestions[0].disabled);
1194 match provider.on_select(suggestions.first(), "42", &context) {
1195 QuickOpenResult::GotoLine(GotoLineTarget::Absolute(n)) => assert_eq!(n, 42),
1196 other => panic!("expected absolute GotoLine, got {:?}", other),
1197 }
1198 }
1199 }
1200
1201 struct FailingSpawner;
1209
1210 #[async_trait::async_trait]
1211 impl crate::services::remote::ProcessSpawner for FailingSpawner {
1212 async fn spawn(
1213 &self,
1214 _command: String,
1215 _args: Vec<String>,
1216 _cwd: Option<String>,
1217 ) -> Result<crate::services::remote::SpawnResult, crate::services::remote::SpawnError>
1218 {
1219 Err(crate::services::remote::SpawnError::Process(
1220 "no git in test".to_string(),
1221 ))
1222 }
1223 }
1224
1225 fn make_file_provider() -> FileProvider {
1228 FileProvider::new(
1229 std::sync::Arc::new(crate::model::filesystem::StdFileSystem),
1230 std::sync::Arc::new(FailingSpawner),
1231 None, None, )
1234 }
1235
1236 struct OtherSpawner;
1239
1240 #[async_trait::async_trait]
1241 impl crate::services::remote::ProcessSpawner for OtherSpawner {
1242 async fn spawn(
1243 &self,
1244 _command: String,
1245 _args: Vec<String>,
1246 _cwd: Option<String>,
1247 ) -> Result<crate::services::remote::SpawnResult, crate::services::remote::SpawnError>
1248 {
1249 Err(crate::services::remote::SpawnError::Process(
1250 "other".to_string(),
1251 ))
1252 }
1253 }
1254
1255 #[test]
1266 fn set_backends_repoints_spawner_and_invalidates_cache() {
1267 let mut fp = make_file_provider();
1268
1269 {
1272 let mut c = fp.cache.lock().unwrap();
1273 c.loaded_cwd = Some("/old".to_string());
1274 c.files = Some(std::sync::Arc::new(vec![]));
1275 }
1276
1277 let new_fs: std::sync::Arc<dyn crate::model::filesystem::FileSystem + Send + Sync> =
1278 std::sync::Arc::new(crate::model::filesystem::StdFileSystem);
1279 let new_spawner: std::sync::Arc<dyn crate::services::remote::ProcessSpawner> =
1280 std::sync::Arc::new(OtherSpawner);
1281
1282 fp.set_backends(
1283 std::sync::Arc::clone(&new_fs),
1284 std::sync::Arc::clone(&new_spawner),
1285 );
1286
1287 assert!(
1289 std::sync::Arc::ptr_eq(&fp.process_spawner, &new_spawner),
1290 "set_backends must adopt the new authority's spawner"
1291 );
1292 let c = fp.cache.lock().unwrap();
1294 assert!(
1295 c.files.is_none() && c.loaded_cwd.is_none(),
1296 "stale cache from the previous backend must be cleared"
1297 );
1298 }
1299
1300 #[test]
1301 fn test_file_provider_discovers_files_via_walk() {
1302 let dir = tempfile::tempdir().unwrap();
1303 let base = dir.path();
1304
1305 std::fs::write(base.join("main.rs"), b"fn main() {}").unwrap();
1307 std::fs::write(base.join("lib.rs"), b"pub mod foo;").unwrap();
1308 std::fs::create_dir(base.join("src")).unwrap();
1309 std::fs::write(base.join("src").join("foo.rs"), b"// foo").unwrap();
1310
1311 let provider = make_file_provider();
1312 let context = make_test_context(&base.display().to_string());
1313 let suggestions = provider.suggestions("", &context);
1314
1315 assert_eq!(suggestions.len(), 3);
1317 let paths: Vec<&str> = suggestions
1318 .iter()
1319 .filter_map(|s| s.value.as_deref())
1320 .collect();
1321 assert!(paths.contains(&"main.rs"));
1322 assert!(paths.contains(&"lib.rs"));
1323 assert!(paths.contains(&"src/foo.rs"));
1324 }
1325
1326 #[test]
1327 fn test_file_provider_skips_ignored_dirs() {
1328 let dir = tempfile::tempdir().unwrap();
1329 let base = dir.path();
1330
1331 std::fs::write(base.join("app.rs"), b"").unwrap();
1332 std::fs::create_dir(base.join("node_modules")).unwrap();
1334 std::fs::write(base.join("node_modules").join("pkg.js"), b"").unwrap();
1335 std::fs::create_dir(base.join("target")).unwrap();
1336 std::fs::write(base.join("target").join("debug.o"), b"").unwrap();
1337
1338 let provider = make_file_provider();
1339 let context = make_test_context(&base.display().to_string());
1340 let suggestions = provider.suggestions("", &context);
1341
1342 assert_eq!(suggestions.len(), 1);
1343 assert_eq!(suggestions[0].value.as_deref(), Some("app.rs"));
1344 }
1345
1346 #[test]
1347 fn test_file_provider_skips_hidden_files() {
1348 let dir = tempfile::tempdir().unwrap();
1349 let base = dir.path();
1350
1351 std::fs::write(base.join("visible.txt"), b"").unwrap();
1352 std::fs::write(base.join(".hidden"), b"").unwrap();
1353 std::fs::create_dir(base.join(".git")).unwrap();
1354 std::fs::write(base.join(".git").join("config"), b"").unwrap();
1355
1356 let provider = make_file_provider();
1357 let context = make_test_context(&base.display().to_string());
1358 let suggestions = provider.suggestions("", &context);
1359
1360 assert_eq!(suggestions.len(), 1);
1361 assert_eq!(suggestions[0].value.as_deref(), Some("visible.txt"));
1362 }
1363
1364 #[test]
1365 fn test_file_provider_fuzzy_filter() {
1366 let dir = tempfile::tempdir().unwrap();
1367 let base = dir.path();
1368
1369 std::fs::write(base.join("main.rs"), b"").unwrap();
1370 std::fs::write(base.join("lib.rs"), b"").unwrap();
1371 std::fs::write(base.join("README.md"), b"").unwrap();
1372
1373 let provider = make_file_provider();
1374 let context = make_test_context(&base.display().to_string());
1375 let suggestions = provider.suggestions("main", &context);
1376
1377 assert_eq!(suggestions.len(), 1);
1378 assert_eq!(suggestions[0].value.as_deref(), Some("main.rs"));
1379 }
1380
1381 #[test]
1382 fn test_file_provider_empty_dir() {
1383 let dir = tempfile::tempdir().unwrap();
1384
1385 let provider = make_file_provider();
1386 let context = make_test_context(&dir.path().display().to_string());
1387 let suggestions = provider.suggestions("", &context);
1388
1389 assert_eq!(suggestions.len(), 1);
1391 assert!(suggestions[0].disabled);
1392 }
1393
1394 #[test]
1404 fn test_probe_prefix_all_shapes() {
1405 let dir = tempfile::tempdir().unwrap();
1406 let base = dir.path();
1407
1408 std::fs::create_dir(base.join("etc")).unwrap();
1410 std::fs::write(base.join("etc").join("hosts"), b"").unwrap();
1411 std::fs::write(base.join("etc").join("hosts.allow"), b"").unwrap();
1412 std::fs::write(base.join("etc").join("hosts.deny"), b"").unwrap();
1413 std::fs::write(base.join("etc").join("passwd"), b"").unwrap();
1414
1415 std::fs::create_dir(base.join("src")).unwrap();
1417 std::fs::write(base.join("src").join("main.rs"), b"").unwrap();
1418 std::fs::write(base.join("src").join("lib.rs"), b"").unwrap();
1419
1420 std::fs::write(base.join("Makefile"), b"").unwrap();
1422 std::fs::write(base.join("Makefile.bak"), b"").unwrap();
1423 std::fs::write(base.join("README.md"), b"").unwrap();
1424
1425 let provider = make_file_provider();
1426 let cwd = base.display().to_string();
1427
1428 let r = provider.probe_prefix(&cwd, "etc/hosts");
1430 let paths: Vec<&str> = r.iter().map(|e| e.relative_path.as_str()).collect();
1431 assert!(
1432 paths.contains(&"etc/hosts"),
1433 "missing etc/hosts in {paths:?}"
1434 );
1435 assert!(
1436 paths.contains(&"etc/hosts.allow"),
1437 "missing etc/hosts.allow in {paths:?}"
1438 );
1439 assert!(
1440 paths.contains(&"etc/hosts.deny"),
1441 "missing etc/hosts.deny in {paths:?}"
1442 );
1443 assert!(
1444 !paths.contains(&"etc/passwd"),
1445 "passwd shouldn't match prefix 'hosts': {paths:?}"
1446 );
1447
1448 let r = provider.probe_prefix(&cwd, "src");
1450 let paths: Vec<&str> = r.iter().map(|e| e.relative_path.as_str()).collect();
1451 assert!(
1452 paths.contains(&"src/main.rs"),
1453 "missing src/main.rs in {paths:?}"
1454 );
1455 assert!(
1456 paths.contains(&"src/lib.rs"),
1457 "missing src/lib.rs in {paths:?}"
1458 );
1459
1460 let r = provider.probe_prefix(&cwd, "nonexistent/path/to/file");
1462 assert!(
1463 r.is_empty(),
1464 "nonexistent query should return empty, got {:?}",
1465 r.iter().map(|e| &e.relative_path).collect::<Vec<_>>()
1466 );
1467
1468 let r = provider.probe_prefix(&cwd, "Makefile");
1470 let paths: Vec<&str> = r.iter().map(|e| e.relative_path.as_str()).collect();
1471 assert!(paths.contains(&"Makefile"), "missing Makefile in {paths:?}");
1472 assert!(
1473 paths.contains(&"Makefile.bak"),
1474 "missing Makefile.bak in {paths:?}"
1475 );
1476 assert!(
1477 !paths.contains(&"README.md"),
1478 "README.md shouldn't match prefix 'Makefile': {paths:?}"
1479 );
1480 }
1481
1482 #[test]
1487 fn test_prefix_match_ranks_above_fuzzy_match() {
1488 let dir = tempfile::tempdir().unwrap();
1489 let base = dir.path();
1490
1491 std::fs::create_dir(base.join("src")).unwrap();
1494 std::fs::write(base.join("src").join("main.rs"), b"").unwrap();
1495 std::fs::write(base.join("src").join("manager.rs"), b"").unwrap();
1499
1500 let provider = make_file_provider();
1501 let context = make_test_context(&base.display().to_string());
1502 let suggestions = provider.suggestions("src/main", &context);
1503
1504 assert!(!suggestions.is_empty());
1506 assert_eq!(suggestions[0].value.as_deref(), Some("src/main.rs"));
1507 }
1508
1509 #[test]
1510 fn test_set_partial_cache_keeps_loading() {
1511 let provider = make_file_provider();
1512
1513 {
1515 let mut cache = provider.cache.lock().unwrap();
1516 cache.loading = true;
1517 cache.loaded_cwd = Some("/proj".to_string());
1518 }
1519
1520 let partial = std::sync::Arc::new(vec![FileEntry {
1522 relative_path: "foo.rs".to_string(),
1523 frecency_score: 0.0,
1524 }]);
1525 provider.set_partial_cache("/proj", partial);
1526
1527 assert!(provider.is_loading());
1528 assert!(provider.cache.lock().unwrap().files.is_some());
1529
1530 let final_files = std::sync::Arc::new(vec![FileEntry {
1532 relative_path: "foo.rs".to_string(),
1533 frecency_score: 0.0,
1534 }]);
1535 provider.set_cache("/proj", final_files);
1536
1537 assert!(!provider.is_loading());
1538
1539 let stale = std::sync::Arc::new(vec![FileEntry {
1541 relative_path: "other.rs".to_string(),
1542 frecency_score: 0.0,
1543 }]);
1544 provider.set_cache("/different", stale);
1545 assert_eq!(
1546 provider.cache.lock().unwrap().files.as_ref().unwrap()[0].relative_path,
1547 "foo.rs",
1548 "results for a different cwd must not overwrite the current cache"
1549 );
1550 }
1551}