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}
297
298#[derive(Clone)]
310pub struct FileProvider {
311 cache: std::sync::Arc<std::sync::Mutex<FileCache>>,
312 frecency: std::sync::Arc<std::sync::RwLock<std::collections::HashMap<String, FrecencyData>>>,
313 filesystem: std::sync::Arc<dyn crate::model::filesystem::FileSystem + Send + Sync>,
314 process_spawner: std::sync::Arc<dyn crate::services::remote::ProcessSpawner>,
315 runtime_handle: Option<tokio::runtime::Handle>,
316 async_sender: Option<std::sync::mpsc::Sender<crate::services::async_bridge::AsyncMessage>>,
317 cancel: std::sync::Arc<std::sync::atomic::AtomicBool>,
319}
320
321impl FileProvider {
322 pub fn new(
323 filesystem: std::sync::Arc<dyn crate::model::filesystem::FileSystem + Send + Sync>,
324 process_spawner: std::sync::Arc<dyn crate::services::remote::ProcessSpawner>,
325 runtime_handle: Option<tokio::runtime::Handle>,
326 async_sender: Option<std::sync::mpsc::Sender<crate::services::async_bridge::AsyncMessage>>,
327 ) -> Self {
328 Self {
329 cache: std::sync::Arc::new(std::sync::Mutex::new(FileCache {
330 files: None,
331 loading: false,
332 })),
333 frecency: std::sync::Arc::new(std::sync::RwLock::new(std::collections::HashMap::new())),
334 filesystem,
335 process_spawner,
336 runtime_handle,
337 async_sender,
338 cancel: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
339 }
340 }
341
342 pub fn clear_cache(&self) {
344 self.cancel
345 .store(true, std::sync::atomic::Ordering::Relaxed);
346 if let Ok(mut c) = self.cache.lock() {
347 c.files = None;
348 c.loading = false;
349 }
350 }
351
352 pub fn cancel_loading(&self) {
355 self.cancel
356 .store(true, std::sync::atomic::Ordering::Relaxed);
357 if let Ok(mut c) = self.cache.lock() {
358 c.loading = false;
359 }
360 }
361
362 pub fn set_cache(&self, files: std::sync::Arc<Vec<FileEntry>>) {
364 if let Ok(mut c) = self.cache.lock() {
365 c.files = Some(files);
366 c.loading = false;
367 }
368 }
369
370 pub fn set_partial_cache(&self, files: std::sync::Arc<Vec<FileEntry>>) {
373 if let Ok(mut c) = self.cache.lock() {
374 c.files = Some(files);
375 }
377 }
378
379 fn is_loading(&self) -> bool {
381 self.cache.lock().is_ok_and(|c| c.loading)
382 }
383
384 pub fn record_access(&self, path: &str) {
386 if let Ok(mut frecency) = self.frecency.write() {
387 let entry = frecency.entry(path.to_string()).or_insert(FrecencyData {
388 access_count: 0,
389 last_access: std::time::Instant::now(),
390 });
391 entry.access_count += 1;
392 entry.last_access = std::time::Instant::now();
393 }
394 }
395
396 fn get_frecency_score(&self, path: &str) -> f64 {
397 self.frecency
398 .read()
399 .ok()
400 .and_then(|m| m.get(path).map(frecency_score))
401 .unwrap_or(0.0)
402 }
403
404 fn probe_prefix(&self, cwd: &str, query: &str) -> Vec<FileEntry> {
412 use std::path::Path;
413
414 if query.is_empty() {
415 return vec![];
416 }
417
418 let abs_path = Path::new(cwd).join(query);
419 let mut results = Vec::new();
420
421 if let Ok(entries) = self.filesystem.read_dir(&abs_path) {
423 let query_trimmed = query.trim_end_matches('/');
424 for entry in entries {
425 if entry.is_file() && !entry.name.starts_with('.') {
426 let rel = format!("{}/{}", query_trimmed, entry.name);
427 results.push(FileEntry {
428 frecency_score: self.get_frecency_score(&rel),
429 relative_path: rel,
430 });
431 }
432 }
433 results.truncate(50);
434 return results;
435 }
436
437 let parent = match abs_path.parent() {
440 Some(p) => p,
441 None => return results,
442 };
443 let basename = match abs_path.file_name().and_then(|n| n.to_str()) {
444 Some(b) => b,
445 None => return results,
446 };
447
448 let rel_parent = match parent.strip_prefix(cwd) {
449 Ok(p) => {
450 let s = p.to_string_lossy().replace('\\', "/");
451 s
452 }
453 Err(_) => return results,
454 };
455
456 if let Ok(entries) = self.filesystem.read_dir(parent) {
457 for entry in entries {
458 if entry.name.starts_with('.') {
459 continue;
460 }
461 if !entry.name.starts_with(basename) {
462 continue;
463 }
464 if entry.is_file() {
465 let rel = if rel_parent.is_empty() {
466 entry.name.clone()
467 } else {
468 format!("{}/{}", rel_parent, entry.name)
469 };
470 results.push(FileEntry {
471 frecency_score: self.get_frecency_score(&rel),
472 relative_path: rel,
473 });
474 }
475 }
476 }
477
478 results
479 }
480
481 fn get_or_start_loading(&self, cwd: &str) -> Option<std::sync::Arc<Vec<FileEntry>>> {
487 let mut cache = self.cache.lock().ok()?;
488
489 if let Some(files) = &cache.files {
490 return Some(std::sync::Arc::clone(files));
491 }
492
493 if cache.loading {
494 return None; }
496
497 let (sender, handle) = match (&self.async_sender, &self.runtime_handle) {
499 (Some(s), Some(h)) => (s.clone(), h.clone()),
500 _ => {
501 drop(cache);
503 return self.load_files_sync(cwd);
504 }
505 };
506
507 cache.loading = true;
508 self.cancel
510 .store(false, std::sync::atomic::Ordering::Relaxed);
511 let cancel = std::sync::Arc::clone(&self.cancel);
512 let frecency = std::sync::Arc::clone(&self.frecency);
513 let filesystem = std::sync::Arc::clone(&self.filesystem);
514 let process_spawner = std::sync::Arc::clone(&self.process_spawner);
515 let cwd = cwd.to_string();
516
517 handle.spawn_blocking(move || {
518 if let Some(files) = try_git_files_blocking(&process_spawner, &cwd) {
520 let frecency_map = frecency.read().ok();
521 let entries: Vec<FileEntry> = files
522 .into_iter()
523 .map(|path| {
524 let score = frecency_map
525 .as_ref()
526 .and_then(|m| m.get(&path))
527 .map(frecency_score)
528 .unwrap_or(0.0);
529 FileEntry {
530 relative_path: path,
531 frecency_score: score,
532 }
533 })
534 .collect();
535 drop(sender.send(
538 crate::services::async_bridge::AsyncMessage::QuickOpenFilesLoaded {
539 files: std::sync::Arc::new(entries),
540 complete: true,
541 },
542 ));
543 return;
544 }
545
546 walk_dir_with_updates(&*filesystem, &cwd, &cancel, &frecency, &sender);
549 });
550
551 None
552 }
553
554 fn load_files_sync(&self, cwd: &str) -> Option<std::sync::Arc<Vec<FileEntry>>> {
556 let files = self
557 .try_git_files(cwd)
558 .or_else(|| self.try_walk_dir(cwd))
559 .unwrap_or_default();
560
561 let entries: Vec<FileEntry> = files
562 .into_iter()
563 .map(|path| FileEntry {
564 frecency_score: self.get_frecency_score(&path),
565 relative_path: path,
566 })
567 .collect();
568
569 let files = std::sync::Arc::new(entries);
570 self.set_cache(std::sync::Arc::clone(&files));
571 Some(files)
572 }
573
574 fn try_git_files(&self, cwd: &str) -> Option<Vec<String>> {
576 let handle = self.runtime_handle.as_ref()?;
577 try_git_files_with_handle(&self.process_spawner, cwd, handle)
578 }
579
580 fn try_walk_dir(&self, cwd: &str) -> Option<Vec<String>> {
582 let cancel = std::sync::atomic::AtomicBool::new(false);
583 try_walk_dir_blocking(&*self.filesystem, cwd, &cancel)
584 }
585}
586
587fn try_git_files_blocking(
597 spawner: &std::sync::Arc<dyn crate::services::remote::ProcessSpawner>,
598 cwd: &str,
599) -> Option<Vec<String>> {
600 let handle = tokio::runtime::Handle::try_current().ok()?;
602 try_git_files_with_handle(spawner, cwd, &handle)
603}
604
605fn try_git_files_with_handle(
606 spawner: &std::sync::Arc<dyn crate::services::remote::ProcessSpawner>,
607 cwd: &str,
608 handle: &tokio::runtime::Handle,
609) -> Option<Vec<String>> {
610 let result = handle
611 .block_on(spawner.spawn(
612 "git".to_string(),
613 vec![
614 "ls-files".to_string(),
615 "--cached".to_string(),
616 "--others".to_string(),
617 "--exclude-standard".to_string(),
618 ],
619 Some(cwd.to_string()),
620 ))
621 .ok()?;
622
623 if result.exit_code != 0 {
624 return None;
625 }
626
627 let files: Vec<String> = result
628 .stdout
629 .lines()
630 .filter(|line| !line.is_empty() && !line.starts_with(".git/"))
631 .map(|s| s.to_string())
632 .collect();
633
634 Some(files)
635}
636
637fn try_walk_dir_blocking(
639 fs: &dyn crate::model::filesystem::FileSystem,
640 cwd: &str,
641 cancel: &std::sync::atomic::AtomicBool,
642) -> Option<Vec<String>> {
643 use std::path::Path;
644
645 let base = Path::new(cwd);
646 let mut files = Vec::new();
647
648 drop(
650 fs.walk_files(base, IGNORED_DIRS, cancel, &mut |_path, rel| {
651 files.push(rel.to_string());
652 files.len() < MAX_FILES
653 }),
654 );
655
656 if files.is_empty() {
657 None
658 } else {
659 Some(files)
660 }
661}
662
663const WALK_UPDATE_INTERVAL: std::time::Duration = std::time::Duration::from_millis(300);
666
667fn walk_dir_with_updates(
670 fs: &dyn crate::model::filesystem::FileSystem,
671 cwd: &str,
672 cancel: &std::sync::atomic::AtomicBool,
673 frecency: &std::sync::RwLock<std::collections::HashMap<String, FrecencyData>>,
674 sender: &std::sync::mpsc::Sender<crate::services::async_bridge::AsyncMessage>,
675) {
676 use std::path::Path;
677
678 let base = Path::new(cwd);
679 let mut paths: Vec<String> = Vec::new();
680 let mut last_send = std::time::Instant::now();
681 let mut receiver_gone = false;
682
683 if let Err(e) = fs.walk_files(base, IGNORED_DIRS, cancel, &mut |_path, rel| {
687 paths.push(rel.to_string());
688
689 if last_send.elapsed() >= WALK_UPDATE_INTERVAL {
691 let frecency_map = frecency.read().ok();
692 let entries: Vec<FileEntry> = paths
693 .iter()
694 .map(|p| FileEntry {
695 frecency_score: frecency_map
696 .as_ref()
697 .and_then(|m| m.get(p).map(frecency_score))
698 .unwrap_or(0.0),
699 relative_path: p.clone(),
700 })
701 .collect();
702 if sender
703 .send(
704 crate::services::async_bridge::AsyncMessage::QuickOpenFilesLoaded {
705 files: std::sync::Arc::new(entries),
706 complete: false,
707 },
708 )
709 .is_err()
710 {
711 receiver_gone = true;
714 return false;
715 }
716 last_send = std::time::Instant::now();
717 }
718
719 paths.len() < MAX_FILES
720 }) {
721 tracing::debug!("Quick Open walk_files failed: {}", e);
722 }
723
724 if receiver_gone {
725 return;
726 }
727
728 let frecency_map = frecency.read().ok();
731 let entries: Vec<FileEntry> = paths
732 .into_iter()
733 .map(|p| {
734 let score = frecency_map
735 .as_ref()
736 .and_then(|m| m.get(&p).map(frecency_score))
737 .unwrap_or(0.0);
738 FileEntry {
739 relative_path: p,
740 frecency_score: score,
741 }
742 })
743 .collect();
744 drop(sender.send(
745 crate::services::async_bridge::AsyncMessage::QuickOpenFilesLoaded {
746 files: std::sync::Arc::new(entries),
747 complete: true,
748 },
749 ));
750}
751
752fn frecency_score(data: &FrecencyData) -> f64 {
754 let hours_since_access = data.last_access.elapsed().as_secs_f64() / 3600.0;
755 let recency_weight = if hours_since_access < 4.0 {
756 100.0
757 } else if hours_since_access < 24.0 {
758 70.0
759 } else if hours_since_access < 24.0 * 7.0 {
760 50.0
761 } else if hours_since_access < 24.0 * 30.0 {
762 30.0
763 } else if hours_since_access < 24.0 * 90.0 {
764 10.0
765 } else {
766 1.0
767 };
768 data.access_count as f64 * recency_weight
769}
770
771impl QuickOpenProvider for FileProvider {
772 fn prefix(&self) -> &str {
773 ""
774 }
775
776 fn suggestions(&self, query: &str, context: &QuickOpenContext) -> Vec<Suggestion> {
777 let (path_part, _, _) = super::parse_path_line_col(query);
779 let search_query = if path_part.is_empty() {
780 query
781 } else {
782 &path_part
783 };
784
785 if !self.filesystem.is_remote_connected() {
787 return vec![Suggestion::disabled(
788 "Remote connection lost — cannot list files".to_string(),
789 )];
790 }
791
792 let files = self.get_or_start_loading(&context.cwd);
795 let still_loading = self.is_loading();
796
797 let prefix_entries = if !search_query.is_empty() {
803 self.probe_prefix(&context.cwd, search_query)
804 } else {
805 vec![]
806 };
807
808 let has_files = files.as_ref().is_some_and(|f| !f.is_empty());
809
810 if !has_files && prefix_entries.is_empty() {
811 if still_loading {
812 return vec![Suggestion::disabled("Loading files…".to_string())];
813 } else {
814 return vec![Suggestion::disabled(t!("quick_open.no_files").to_string())];
815 }
816 }
817
818 let max_results = 100;
819
820 let prefix_set: std::collections::HashSet<&str> = prefix_entries
822 .iter()
823 .map(|e| e.relative_path.as_str())
824 .collect();
825
826 const PREFIX_PROBE_BOOST: i32 = 200;
828
829 let mut matcher = FuzzyMatcher::new(search_query);
834
835 let mut scored: Vec<(String, i32)> = Vec::new();
837
838 for entry in &prefix_entries {
840 let m = matcher.match_target(&entry.relative_path);
841 let base_score = if m.matched { m.score } else { 0 };
842 let frecency_boost = (entry.frecency_score / 100.0).min(20.0) as i32;
843 scored.push((
844 entry.relative_path.clone(),
845 base_score + frecency_boost + PREFIX_PROBE_BOOST,
846 ));
847 }
848
849 if let Some(files) = &files {
851 if search_query.is_empty() {
852 let mut entries: Vec<_> = files.iter().map(|f| (f, 0i32)).collect();
853 entries.sort_by(|a, b| {
854 b.0.frecency_score
855 .partial_cmp(&a.0.frecency_score)
856 .unwrap_or(std::cmp::Ordering::Equal)
857 });
858 entries.truncate(max_results);
859 for (f, s) in entries {
860 scored.push((f.relative_path.clone(), s));
861 }
862 } else {
863 for file in files.iter() {
864 if prefix_set.contains(file.relative_path.as_str()) {
866 continue;
867 }
868 let m = matcher.match_target(&file.relative_path);
869 if !m.matched {
870 continue;
871 }
872 let frecency_boost = (file.frecency_score / 100.0).min(20.0) as i32;
873 let mut score = m.score + frecency_boost;
874 if file.relative_path.starts_with(search_query) {
877 score += PREFIX_PROBE_BOOST;
878 }
879 scored.push((file.relative_path.clone(), score));
880 }
881 }
882 }
883
884 scored.sort_by(|a, b| b.1.cmp(&a.1));
885 scored.truncate(max_results);
886
887 let mut suggestions: Vec<Suggestion> = scored
888 .into_iter()
889 .map(|(path, _)| Suggestion::new(path.clone()).with_value(path))
890 .collect();
891
892 if still_loading {
893 let msg = if suggestions.is_empty() {
894 "Loading files…"
895 } else {
896 "Scanning for more files…"
897 };
898 suggestions.push(Suggestion::disabled(msg.to_string()));
899 }
900
901 suggestions
902 }
903
904 fn on_select(
905 &self,
906 suggestion: Option<&Suggestion>,
907 query: &str,
908 _context: &QuickOpenContext,
909 ) -> QuickOpenResult {
910 let (path_part, line, column) = super::parse_path_line_col(query);
911
912 if let Some(path) = suggestion.and_then(|s| s.value.as_deref()) {
914 self.record_access(path);
915 return QuickOpenResult::OpenFile {
916 path: path.to_string(),
917 line,
918 column,
919 };
920 }
921
922 if line.is_some() && !path_part.is_empty() {
924 self.record_access(&path_part);
925 return QuickOpenResult::OpenFile {
926 path: path_part,
927 line,
928 column,
929 };
930 }
931
932 QuickOpenResult::None
933 }
934
935 fn as_any(&self) -> &dyn std::any::Any {
936 self
937 }
938}
939
940#[cfg(test)]
941mod tests {
942 use super::*;
943 use crate::input::quick_open::BufferInfo;
944
945 fn make_test_context(cwd: &str) -> QuickOpenContext {
946 QuickOpenContext {
947 cwd: cwd.to_string(),
948 open_buffers: vec![
949 BufferInfo {
950 id: 1,
951 path: "/tmp/main.rs".to_string(),
952 name: "main.rs".to_string(),
953 modified: false,
954 },
955 BufferInfo {
956 id: 2,
957 path: "/tmp/lib.rs".to_string(),
958 name: "lib.rs".to_string(),
959 modified: true,
960 },
961 ],
962 active_buffer_id: 1,
963 active_buffer_path: Some("/tmp/main.rs".to_string()),
964 has_selection: false,
965 key_context: crate::input::keybindings::KeyContext::Normal,
966 custom_contexts: std::collections::HashSet::new(),
967 buffer_mode: None,
968 has_lsp_config: true,
969 relative_line_numbers: false,
970 }
971 }
972
973 #[test]
974 fn test_buffer_provider_suggestions() {
975 let provider = BufferProvider::new();
976 let context = make_test_context("/tmp");
977
978 let suggestions = provider.suggestions("", &context);
979 assert_eq!(suggestions.len(), 2);
980
981 let lib_suggestion = suggestions
983 .iter()
984 .find(|s| s.text.contains("lib.rs"))
985 .unwrap();
986 assert!(lib_suggestion.text.contains("[+]"));
987 }
988
989 #[test]
990 fn test_buffer_provider_filter() {
991 let provider = BufferProvider::new();
992 let context = make_test_context("/tmp");
993
994 let suggestions = provider.suggestions("main", &context);
995 assert_eq!(suggestions.len(), 1);
996 assert!(suggestions[0].text.contains("main.rs"));
997 }
998
999 #[test]
1000 fn test_goto_line_provider() {
1001 let provider = GotoLineProvider::new();
1002 let context = make_test_context("/tmp");
1003
1004 let suggestions = provider.suggestions("42", &context);
1006 assert_eq!(suggestions.len(), 1);
1007 assert!(!suggestions[0].disabled);
1008
1009 let suggestions = provider.suggestions("", &context);
1011 assert_eq!(suggestions.len(), 1);
1012 assert!(suggestions[0].disabled);
1013
1014 let suggestions = provider.suggestions("abc", &context);
1016 assert_eq!(suggestions.len(), 1);
1017 assert!(suggestions[0].disabled);
1018 }
1019
1020 #[test]
1021 fn test_goto_line_on_select() {
1022 let provider = GotoLineProvider::new();
1023 let context = make_test_context("/tmp");
1024
1025 let suggestions = provider.suggestions("42", &context);
1026 let result = provider.on_select(suggestions.first(), "42", &context);
1027 match result {
1028 QuickOpenResult::GotoLine(GotoLineTarget::Absolute(line)) => assert_eq!(line, 42),
1029 other => panic!("expected absolute GotoLine result, got {:?}", other),
1030 }
1031 }
1032
1033 #[test]
1036 fn test_goto_line_signed_is_relative_regardless_of_setting() {
1037 let provider = GotoLineProvider::new();
1038
1039 for relative_setting in [false, true] {
1040 let mut context = make_test_context("/tmp");
1041 context.relative_line_numbers = relative_setting;
1042
1043 for query in ["-5", "+3"] {
1044 let suggestions = provider.suggestions(query, &context);
1045 assert_eq!(suggestions.len(), 1, "query {query:?}");
1046 assert!(!suggestions[0].disabled, "query {query:?}");
1047 }
1048
1049 let suggestions = provider.suggestions("+3", &context);
1050 match provider.on_select(suggestions.first(), "+3", &context) {
1051 QuickOpenResult::GotoLine(GotoLineTarget::Relative(d)) => assert_eq!(d, 3),
1052 other => panic!("expected relative GotoLine, got {:?}", other),
1053 }
1054
1055 let suggestions = provider.suggestions("-7", &context);
1056 match provider.on_select(suggestions.first(), "-7", &context) {
1057 QuickOpenResult::GotoLine(GotoLineTarget::Relative(d)) => assert_eq!(d, -7),
1058 other => panic!("expected relative GotoLine, got {:?}", other),
1059 }
1060
1061 for bare in ["-", "+"] {
1062 let suggestions = provider.suggestions(bare, &context);
1063 assert_eq!(suggestions.len(), 1);
1064 assert!(suggestions[0].disabled);
1065 }
1066 }
1067 }
1068
1069 #[test]
1072 fn test_goto_line_unsigned_is_absolute_regardless_of_setting() {
1073 let provider = GotoLineProvider::new();
1074
1075 for relative_setting in [false, true] {
1076 let mut context = make_test_context("/tmp");
1077 context.relative_line_numbers = relative_setting;
1078
1079 let suggestions = provider.suggestions("42", &context);
1080 assert_eq!(suggestions.len(), 1);
1081 assert!(!suggestions[0].disabled);
1082 match provider.on_select(suggestions.first(), "42", &context) {
1083 QuickOpenResult::GotoLine(GotoLineTarget::Absolute(n)) => assert_eq!(n, 42),
1084 other => panic!("expected absolute GotoLine, got {:?}", other),
1085 }
1086 }
1087 }
1088
1089 struct FailingSpawner;
1097
1098 #[async_trait::async_trait]
1099 impl crate::services::remote::ProcessSpawner for FailingSpawner {
1100 async fn spawn(
1101 &self,
1102 _command: String,
1103 _args: Vec<String>,
1104 _cwd: Option<String>,
1105 ) -> Result<crate::services::remote::SpawnResult, crate::services::remote::SpawnError>
1106 {
1107 Err(crate::services::remote::SpawnError::Process(
1108 "no git in test".to_string(),
1109 ))
1110 }
1111 }
1112
1113 fn make_file_provider() -> FileProvider {
1116 FileProvider::new(
1117 std::sync::Arc::new(crate::model::filesystem::StdFileSystem),
1118 std::sync::Arc::new(FailingSpawner),
1119 None, None, )
1122 }
1123
1124 #[test]
1125 fn test_file_provider_discovers_files_via_walk() {
1126 let dir = tempfile::tempdir().unwrap();
1127 let base = dir.path();
1128
1129 std::fs::write(base.join("main.rs"), b"fn main() {}").unwrap();
1131 std::fs::write(base.join("lib.rs"), b"pub mod foo;").unwrap();
1132 std::fs::create_dir(base.join("src")).unwrap();
1133 std::fs::write(base.join("src").join("foo.rs"), b"// foo").unwrap();
1134
1135 let provider = make_file_provider();
1136 let context = make_test_context(&base.display().to_string());
1137 let suggestions = provider.suggestions("", &context);
1138
1139 assert_eq!(suggestions.len(), 3);
1141 let paths: Vec<&str> = suggestions
1142 .iter()
1143 .filter_map(|s| s.value.as_deref())
1144 .collect();
1145 assert!(paths.contains(&"main.rs"));
1146 assert!(paths.contains(&"lib.rs"));
1147 assert!(paths.contains(&"src/foo.rs"));
1148 }
1149
1150 #[test]
1151 fn test_file_provider_skips_ignored_dirs() {
1152 let dir = tempfile::tempdir().unwrap();
1153 let base = dir.path();
1154
1155 std::fs::write(base.join("app.rs"), b"").unwrap();
1156 std::fs::create_dir(base.join("node_modules")).unwrap();
1158 std::fs::write(base.join("node_modules").join("pkg.js"), b"").unwrap();
1159 std::fs::create_dir(base.join("target")).unwrap();
1160 std::fs::write(base.join("target").join("debug.o"), b"").unwrap();
1161
1162 let provider = make_file_provider();
1163 let context = make_test_context(&base.display().to_string());
1164 let suggestions = provider.suggestions("", &context);
1165
1166 assert_eq!(suggestions.len(), 1);
1167 assert_eq!(suggestions[0].value.as_deref(), Some("app.rs"));
1168 }
1169
1170 #[test]
1171 fn test_file_provider_skips_hidden_files() {
1172 let dir = tempfile::tempdir().unwrap();
1173 let base = dir.path();
1174
1175 std::fs::write(base.join("visible.txt"), b"").unwrap();
1176 std::fs::write(base.join(".hidden"), b"").unwrap();
1177 std::fs::create_dir(base.join(".git")).unwrap();
1178 std::fs::write(base.join(".git").join("config"), b"").unwrap();
1179
1180 let provider = make_file_provider();
1181 let context = make_test_context(&base.display().to_string());
1182 let suggestions = provider.suggestions("", &context);
1183
1184 assert_eq!(suggestions.len(), 1);
1185 assert_eq!(suggestions[0].value.as_deref(), Some("visible.txt"));
1186 }
1187
1188 #[test]
1189 fn test_file_provider_fuzzy_filter() {
1190 let dir = tempfile::tempdir().unwrap();
1191 let base = dir.path();
1192
1193 std::fs::write(base.join("main.rs"), b"").unwrap();
1194 std::fs::write(base.join("lib.rs"), b"").unwrap();
1195 std::fs::write(base.join("README.md"), b"").unwrap();
1196
1197 let provider = make_file_provider();
1198 let context = make_test_context(&base.display().to_string());
1199 let suggestions = provider.suggestions("main", &context);
1200
1201 assert_eq!(suggestions.len(), 1);
1202 assert_eq!(suggestions[0].value.as_deref(), Some("main.rs"));
1203 }
1204
1205 #[test]
1206 fn test_file_provider_empty_dir() {
1207 let dir = tempfile::tempdir().unwrap();
1208
1209 let provider = make_file_provider();
1210 let context = make_test_context(&dir.path().display().to_string());
1211 let suggestions = provider.suggestions("", &context);
1212
1213 assert_eq!(suggestions.len(), 1);
1215 assert!(suggestions[0].disabled);
1216 }
1217
1218 #[test]
1228 fn test_probe_prefix_all_shapes() {
1229 let dir = tempfile::tempdir().unwrap();
1230 let base = dir.path();
1231
1232 std::fs::create_dir(base.join("etc")).unwrap();
1234 std::fs::write(base.join("etc").join("hosts"), b"").unwrap();
1235 std::fs::write(base.join("etc").join("hosts.allow"), b"").unwrap();
1236 std::fs::write(base.join("etc").join("hosts.deny"), b"").unwrap();
1237 std::fs::write(base.join("etc").join("passwd"), b"").unwrap();
1238
1239 std::fs::create_dir(base.join("src")).unwrap();
1241 std::fs::write(base.join("src").join("main.rs"), b"").unwrap();
1242 std::fs::write(base.join("src").join("lib.rs"), b"").unwrap();
1243
1244 std::fs::write(base.join("Makefile"), b"").unwrap();
1246 std::fs::write(base.join("Makefile.bak"), b"").unwrap();
1247 std::fs::write(base.join("README.md"), b"").unwrap();
1248
1249 let provider = make_file_provider();
1250 let cwd = base.display().to_string();
1251
1252 let r = provider.probe_prefix(&cwd, "etc/hosts");
1254 let paths: Vec<&str> = r.iter().map(|e| e.relative_path.as_str()).collect();
1255 assert!(
1256 paths.contains(&"etc/hosts"),
1257 "missing etc/hosts in {paths:?}"
1258 );
1259 assert!(
1260 paths.contains(&"etc/hosts.allow"),
1261 "missing etc/hosts.allow in {paths:?}"
1262 );
1263 assert!(
1264 paths.contains(&"etc/hosts.deny"),
1265 "missing etc/hosts.deny in {paths:?}"
1266 );
1267 assert!(
1268 !paths.contains(&"etc/passwd"),
1269 "passwd shouldn't match prefix 'hosts': {paths:?}"
1270 );
1271
1272 let r = provider.probe_prefix(&cwd, "src");
1274 let paths: Vec<&str> = r.iter().map(|e| e.relative_path.as_str()).collect();
1275 assert!(
1276 paths.contains(&"src/main.rs"),
1277 "missing src/main.rs in {paths:?}"
1278 );
1279 assert!(
1280 paths.contains(&"src/lib.rs"),
1281 "missing src/lib.rs in {paths:?}"
1282 );
1283
1284 let r = provider.probe_prefix(&cwd, "nonexistent/path/to/file");
1286 assert!(
1287 r.is_empty(),
1288 "nonexistent query should return empty, got {:?}",
1289 r.iter().map(|e| &e.relative_path).collect::<Vec<_>>()
1290 );
1291
1292 let r = provider.probe_prefix(&cwd, "Makefile");
1294 let paths: Vec<&str> = r.iter().map(|e| e.relative_path.as_str()).collect();
1295 assert!(paths.contains(&"Makefile"), "missing Makefile in {paths:?}");
1296 assert!(
1297 paths.contains(&"Makefile.bak"),
1298 "missing Makefile.bak in {paths:?}"
1299 );
1300 assert!(
1301 !paths.contains(&"README.md"),
1302 "README.md shouldn't match prefix 'Makefile': {paths:?}"
1303 );
1304 }
1305
1306 #[test]
1311 fn test_prefix_match_ranks_above_fuzzy_match() {
1312 let dir = tempfile::tempdir().unwrap();
1313 let base = dir.path();
1314
1315 std::fs::create_dir(base.join("src")).unwrap();
1318 std::fs::write(base.join("src").join("main.rs"), b"").unwrap();
1319 std::fs::write(base.join("src").join("manager.rs"), b"").unwrap();
1323
1324 let provider = make_file_provider();
1325 let context = make_test_context(&base.display().to_string());
1326 let suggestions = provider.suggestions("src/main", &context);
1327
1328 assert!(!suggestions.is_empty());
1330 assert_eq!(suggestions[0].value.as_deref(), Some("src/main.rs"));
1331 }
1332
1333 #[test]
1334 fn test_set_partial_cache_keeps_loading() {
1335 let provider = make_file_provider();
1336
1337 {
1339 let mut cache = provider.cache.lock().unwrap();
1340 cache.loading = true;
1341 }
1342
1343 let partial = std::sync::Arc::new(vec![FileEntry {
1345 relative_path: "foo.rs".to_string(),
1346 frecency_score: 0.0,
1347 }]);
1348 provider.set_partial_cache(partial);
1349
1350 assert!(provider.is_loading());
1351 assert!(provider.cache.lock().unwrap().files.is_some());
1352
1353 let final_files = std::sync::Arc::new(vec![FileEntry {
1355 relative_path: "foo.rs".to_string(),
1356 frecency_score: 0.0,
1357 }]);
1358 provider.set_cache(final_files);
1359
1360 assert!(!provider.is_loading());
1361 }
1362}