1use super::{QuickOpenContext, QuickOpenProvider, QuickOpenResult};
10use crate::input::commands::Suggestion;
11use crate::input::fuzzy::FuzzyMatcher;
12use rust_i18n::t;
13
14pub struct CommandProvider {
20 command_registry:
22 std::sync::Arc<std::sync::RwLock<crate::input::command_registry::CommandRegistry>>,
23 keybinding_resolver:
25 std::sync::Arc<std::sync::RwLock<crate::input::keybindings::KeybindingResolver>>,
26}
27
28impl CommandProvider {
29 pub fn new(
30 command_registry: std::sync::Arc<
31 std::sync::RwLock<crate::input::command_registry::CommandRegistry>,
32 >,
33 keybinding_resolver: std::sync::Arc<
34 std::sync::RwLock<crate::input::keybindings::KeybindingResolver>,
35 >,
36 ) -> Self {
37 Self {
38 command_registry,
39 keybinding_resolver,
40 }
41 }
42}
43
44impl QuickOpenProvider for CommandProvider {
45 fn prefix(&self) -> &str {
46 ">"
47 }
48
49 fn suggestions(&self, query: &str, context: &QuickOpenContext) -> Vec<Suggestion> {
50 let registry = self.command_registry.read().unwrap();
51 let keybindings = self.keybinding_resolver.read().unwrap();
52
53 registry.filter(
54 query,
55 context.key_context.clone(),
56 &keybindings,
57 context.has_selection,
58 &context.custom_contexts,
59 context.buffer_mode.as_deref(),
60 context.has_lsp_config,
61 )
62 }
63
64 fn on_select(
65 &self,
66 suggestion: Option<&Suggestion>,
67 _query: &str,
68 _context: &QuickOpenContext,
69 ) -> QuickOpenResult {
70 let suggestion = match suggestion {
71 Some(s) if !s.disabled => s,
72 Some(_) => {
73 return QuickOpenResult::Error(t!("status.command_not_available").to_string())
74 }
75 None => return QuickOpenResult::None,
76 };
77
78 let registry = self.command_registry.read().unwrap();
79 let cmd = registry
80 .get_all()
81 .into_iter()
82 .find(|c| c.get_localized_name() == suggestion.text);
83
84 let Some(cmd) = cmd else {
85 return QuickOpenResult::None;
86 };
87
88 let action = cmd.action.clone();
89 let name = cmd.name.clone();
90 drop(registry);
91
92 if let Ok(mut reg) = self.command_registry.write() {
93 reg.record_usage(&name);
94 }
95 QuickOpenResult::ExecuteAction(action)
96 }
97
98 fn as_any(&self) -> &dyn std::any::Any {
99 self
100 }
101}
102
103pub struct BufferProvider;
109
110impl BufferProvider {
111 pub fn new() -> Self {
112 Self
113 }
114}
115
116impl Default for BufferProvider {
117 fn default() -> Self {
118 Self::new()
119 }
120}
121
122impl QuickOpenProvider for BufferProvider {
123 fn prefix(&self) -> &str {
124 "#"
125 }
126
127 fn suggestions(&self, query: &str, context: &QuickOpenContext) -> Vec<Suggestion> {
128 let mut matcher = FuzzyMatcher::new(query);
130 let mut scored: Vec<(Suggestion, i32, usize)> = context
131 .open_buffers
132 .iter()
133 .filter(|buf| !buf.path.is_empty())
134 .filter_map(|buf| {
135 let m = matcher.match_target(&buf.name);
136 if !m.matched {
137 return None;
138 }
139
140 let display_name = if buf.modified {
141 format!("{} [+]", buf.name)
142 } else {
143 buf.name.clone()
144 };
145
146 let suggestion = Suggestion::new(display_name)
147 .with_description(buf.path.clone())
148 .with_value(buf.id.to_string());
149 Some((suggestion, m.score, buf.id))
150 })
151 .collect();
152
153 scored.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.2.cmp(&b.2)));
155 scored.into_iter().map(|(s, _, _)| s).collect()
156 }
157
158 fn on_select(
159 &self,
160 suggestion: Option<&Suggestion>,
161 _query: &str,
162 _context: &QuickOpenContext,
163 ) -> QuickOpenResult {
164 suggestion
165 .and_then(|s| s.value.as_deref())
166 .and_then(|v| v.parse::<usize>().ok())
167 .map(QuickOpenResult::ShowBuffer)
168 .unwrap_or(QuickOpenResult::None)
169 }
170
171 fn as_any(&self) -> &dyn std::any::Any {
172 self
173 }
174}
175
176pub struct GotoLineProvider;
182
183impl GotoLineProvider {
184 pub fn new() -> Self {
185 Self
186 }
187}
188
189impl Default for GotoLineProvider {
190 fn default() -> Self {
191 Self::new()
192 }
193}
194
195impl QuickOpenProvider for GotoLineProvider {
196 fn prefix(&self) -> &str {
197 ":"
198 }
199
200 fn suggestions(&self, query: &str, context: &QuickOpenContext) -> Vec<Suggestion> {
201 if query.is_empty() {
202 return vec![
203 Suggestion::disabled(t!("quick_open.goto_line_hint").to_string())
204 .with_description(t!("quick_open.goto_line_desc").to_string()),
205 ];
206 }
207
208 if context.relative_line_numbers {
209 if query == "-" || query == "+" {
210 return vec![
211 Suggestion::disabled(t!("quick_open.goto_line_hint").to_string())
212 .with_description(t!("quick_open.relative_line_desc").to_string()),
213 ];
214 }
215
216 match query.parse::<isize>() {
217 Ok(n) if n != 0 => {
218 vec![Suggestion::new(
219 t!("quick_open.goto_line", line = n.to_string()).to_string(),
220 )
221 .with_description(t!("quick_open.press_enter").to_string())
222 .with_value(n.to_string())]
223 }
224 _ => vec![
225 Suggestion::disabled(t!("quick_open.invalid_line").to_string())
226 .with_description(query.to_string()),
227 ],
228 }
229 } else if query.starts_with('-') || query.starts_with('+') {
230 vec![
231 Suggestion::disabled(t!("quick_open.requires_relative").to_string())
232 .with_description(t!("quick_open.negative_requires_relative").to_string()),
233 ]
234 } else {
235 match query.parse::<usize>() {
236 Ok(n) if n > 0 => {
237 vec![Suggestion::new(
238 t!("quick_open.goto_line", line = n.to_string()).to_string(),
239 )
240 .with_description(t!("quick_open.press_enter").to_string())
241 .with_value(n.to_string())]
242 }
243 _ => vec![
244 Suggestion::disabled(t!("quick_open.invalid_line").to_string())
245 .with_description(query.to_string()),
246 ],
247 }
248 }
249 }
250
251 fn on_select(
252 &self,
253 suggestion: Option<&Suggestion>,
254 _query: &str,
255 context: &QuickOpenContext,
256 ) -> QuickOpenResult {
257 if context.relative_line_numbers {
258 suggestion
259 .and_then(|s| s.value.as_deref())
260 .and_then(|v| v.parse::<isize>().ok())
261 .map(QuickOpenResult::GotoLine)
262 .unwrap_or(QuickOpenResult::None)
263 } else {
264 suggestion
265 .and_then(|s| s.value.as_deref())
266 .and_then(|v| v.parse::<usize>().ok())
267 .filter(|&n| n > 0)
268 .map(|n| QuickOpenResult::GotoLine(n as isize))
269 .unwrap_or(QuickOpenResult::None)
270 }
271 }
272
273 fn as_any(&self) -> &dyn std::any::Any {
274 self
275 }
276}
277
278const IGNORED_DIRS: &[&str] = &[
284 ".git",
285 "node_modules",
286 "target",
287 "__pycache__",
288 ".hg",
289 ".svn",
290 ".DS_Store",
291];
292
293const MAX_FILES: usize = 50_000;
294
295#[derive(Clone, Debug)]
297pub struct FileEntry {
298 relative_path: String,
299 frecency_score: f64,
300}
301
302#[derive(Clone)]
303struct FrecencyData {
304 access_count: u32,
305 last_access: std::time::Instant,
306}
307
308struct FileCache {
312 files: Option<std::sync::Arc<Vec<FileEntry>>>,
314 loading: bool,
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 })),
353 frecency: std::sync::Arc::new(std::sync::RwLock::new(std::collections::HashMap::new())),
354 filesystem,
355 process_spawner,
356 runtime_handle,
357 async_sender,
358 cancel: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
359 }
360 }
361
362 pub fn clear_cache(&self) {
364 self.cancel
365 .store(true, std::sync::atomic::Ordering::Relaxed);
366 if let Ok(mut c) = self.cache.lock() {
367 c.files = None;
368 c.loading = false;
369 }
370 }
371
372 pub fn cancel_loading(&self) {
375 self.cancel
376 .store(true, std::sync::atomic::Ordering::Relaxed);
377 if let Ok(mut c) = self.cache.lock() {
378 c.loading = false;
379 }
380 }
381
382 pub fn set_cache(&self, files: std::sync::Arc<Vec<FileEntry>>) {
384 if let Ok(mut c) = self.cache.lock() {
385 c.files = Some(files);
386 c.loading = false;
387 }
388 }
389
390 pub fn set_partial_cache(&self, files: std::sync::Arc<Vec<FileEntry>>) {
393 if let Ok(mut c) = self.cache.lock() {
394 c.files = Some(files);
395 }
397 }
398
399 fn is_loading(&self) -> bool {
401 self.cache.lock().is_ok_and(|c| c.loading)
402 }
403
404 pub fn record_access(&self, path: &str) {
406 if let Ok(mut frecency) = self.frecency.write() {
407 let entry = frecency.entry(path.to_string()).or_insert(FrecencyData {
408 access_count: 0,
409 last_access: std::time::Instant::now(),
410 });
411 entry.access_count += 1;
412 entry.last_access = std::time::Instant::now();
413 }
414 }
415
416 fn get_frecency_score(&self, path: &str) -> f64 {
417 self.frecency
418 .read()
419 .ok()
420 .and_then(|m| m.get(path).map(frecency_score))
421 .unwrap_or(0.0)
422 }
423
424 fn probe_prefix(&self, cwd: &str, query: &str) -> Vec<FileEntry> {
432 use std::path::Path;
433
434 if query.is_empty() {
435 return vec![];
436 }
437
438 let abs_path = Path::new(cwd).join(query);
439 let mut results = Vec::new();
440
441 if let Ok(entries) = self.filesystem.read_dir(&abs_path) {
443 let query_trimmed = query.trim_end_matches('/');
444 for entry in entries {
445 if entry.is_file() && !entry.name.starts_with('.') {
446 let rel = format!("{}/{}", query_trimmed, entry.name);
447 results.push(FileEntry {
448 frecency_score: self.get_frecency_score(&rel),
449 relative_path: rel,
450 });
451 }
452 }
453 results.truncate(50);
454 return results;
455 }
456
457 let parent = match abs_path.parent() {
460 Some(p) => p,
461 None => return results,
462 };
463 let basename = match abs_path.file_name().and_then(|n| n.to_str()) {
464 Some(b) => b,
465 None => return results,
466 };
467
468 let rel_parent = match parent.strip_prefix(cwd) {
469 Ok(p) => {
470 let s = p.to_string_lossy().replace('\\', "/");
471 s
472 }
473 Err(_) => return results,
474 };
475
476 if let Ok(entries) = self.filesystem.read_dir(parent) {
477 for entry in entries {
478 if entry.name.starts_with('.') {
479 continue;
480 }
481 if !entry.name.starts_with(basename) {
482 continue;
483 }
484 if entry.is_file() {
485 let rel = if rel_parent.is_empty() {
486 entry.name.clone()
487 } else {
488 format!("{}/{}", rel_parent, entry.name)
489 };
490 results.push(FileEntry {
491 frecency_score: self.get_frecency_score(&rel),
492 relative_path: rel,
493 });
494 }
495 }
496 }
497
498 results
499 }
500
501 fn get_or_start_loading(&self, cwd: &str) -> Option<std::sync::Arc<Vec<FileEntry>>> {
507 let mut cache = self.cache.lock().ok()?;
508
509 if let Some(files) = &cache.files {
510 return Some(std::sync::Arc::clone(files));
511 }
512
513 if cache.loading {
514 return None; }
516
517 let (sender, handle) = match (&self.async_sender, &self.runtime_handle) {
519 (Some(s), Some(h)) => (s.clone(), h.clone()),
520 _ => {
521 drop(cache);
523 return self.load_files_sync(cwd);
524 }
525 };
526
527 cache.loading = true;
528 self.cancel
530 .store(false, std::sync::atomic::Ordering::Relaxed);
531 let cancel = std::sync::Arc::clone(&self.cancel);
532 let frecency = std::sync::Arc::clone(&self.frecency);
533 let filesystem = std::sync::Arc::clone(&self.filesystem);
534 let process_spawner = std::sync::Arc::clone(&self.process_spawner);
535 let cwd = cwd.to_string();
536
537 handle.spawn_blocking(move || {
538 if let Some(files) = try_git_files_blocking(&process_spawner, &cwd) {
540 let frecency_map = frecency.read().ok();
541 let entries: Vec<FileEntry> = files
542 .into_iter()
543 .map(|path| {
544 let score = frecency_map
545 .as_ref()
546 .and_then(|m| m.get(&path))
547 .map(frecency_score)
548 .unwrap_or(0.0);
549 FileEntry {
550 relative_path: path,
551 frecency_score: score,
552 }
553 })
554 .collect();
555 drop(sender.send(
558 crate::services::async_bridge::AsyncMessage::QuickOpenFilesLoaded {
559 files: std::sync::Arc::new(entries),
560 complete: true,
561 },
562 ));
563 return;
564 }
565
566 walk_dir_with_updates(&*filesystem, &cwd, &cancel, &frecency, &sender);
569 });
570
571 None
572 }
573
574 fn load_files_sync(&self, cwd: &str) -> Option<std::sync::Arc<Vec<FileEntry>>> {
576 let files = self
577 .try_git_files(cwd)
578 .or_else(|| self.try_walk_dir(cwd))
579 .unwrap_or_default();
580
581 let entries: Vec<FileEntry> = files
582 .into_iter()
583 .map(|path| FileEntry {
584 frecency_score: self.get_frecency_score(&path),
585 relative_path: path,
586 })
587 .collect();
588
589 let files = std::sync::Arc::new(entries);
590 self.set_cache(std::sync::Arc::clone(&files));
591 Some(files)
592 }
593
594 fn try_git_files(&self, cwd: &str) -> Option<Vec<String>> {
596 let handle = self.runtime_handle.as_ref()?;
597 try_git_files_with_handle(&self.process_spawner, cwd, handle)
598 }
599
600 fn try_walk_dir(&self, cwd: &str) -> Option<Vec<String>> {
602 let cancel = std::sync::atomic::AtomicBool::new(false);
603 try_walk_dir_blocking(&*self.filesystem, cwd, &cancel)
604 }
605}
606
607fn try_git_files_blocking(
617 spawner: &std::sync::Arc<dyn crate::services::remote::ProcessSpawner>,
618 cwd: &str,
619) -> Option<Vec<String>> {
620 let handle = tokio::runtime::Handle::try_current().ok()?;
622 try_git_files_with_handle(spawner, cwd, &handle)
623}
624
625fn try_git_files_with_handle(
626 spawner: &std::sync::Arc<dyn crate::services::remote::ProcessSpawner>,
627 cwd: &str,
628 handle: &tokio::runtime::Handle,
629) -> Option<Vec<String>> {
630 let result = handle
631 .block_on(spawner.spawn(
632 "git".to_string(),
633 vec![
634 "ls-files".to_string(),
635 "--cached".to_string(),
636 "--others".to_string(),
637 "--exclude-standard".to_string(),
638 ],
639 Some(cwd.to_string()),
640 ))
641 .ok()?;
642
643 if result.exit_code != 0 {
644 return None;
645 }
646
647 let files: Vec<String> = result
648 .stdout
649 .lines()
650 .filter(|line| !line.is_empty() && !line.starts_with(".git/"))
651 .map(|s| s.to_string())
652 .collect();
653
654 Some(files)
655}
656
657fn try_walk_dir_blocking(
659 fs: &dyn crate::model::filesystem::FileSystem,
660 cwd: &str,
661 cancel: &std::sync::atomic::AtomicBool,
662) -> Option<Vec<String>> {
663 use std::path::Path;
664
665 let base = Path::new(cwd);
666 let mut files = Vec::new();
667
668 drop(
670 fs.walk_files(base, IGNORED_DIRS, cancel, &mut |_path, rel| {
671 files.push(rel.to_string());
672 files.len() < MAX_FILES
673 }),
674 );
675
676 if files.is_empty() {
677 None
678 } else {
679 Some(files)
680 }
681}
682
683const WALK_UPDATE_INTERVAL: std::time::Duration = std::time::Duration::from_millis(300);
686
687fn walk_dir_with_updates(
690 fs: &dyn crate::model::filesystem::FileSystem,
691 cwd: &str,
692 cancel: &std::sync::atomic::AtomicBool,
693 frecency: &std::sync::RwLock<std::collections::HashMap<String, FrecencyData>>,
694 sender: &std::sync::mpsc::Sender<crate::services::async_bridge::AsyncMessage>,
695) {
696 use std::path::Path;
697
698 let base = Path::new(cwd);
699 let mut paths: Vec<String> = Vec::new();
700 let mut last_send = std::time::Instant::now();
701 let mut receiver_gone = false;
702
703 if let Err(e) = fs.walk_files(base, IGNORED_DIRS, cancel, &mut |_path, rel| {
707 paths.push(rel.to_string());
708
709 if last_send.elapsed() >= WALK_UPDATE_INTERVAL {
711 let frecency_map = frecency.read().ok();
712 let entries: Vec<FileEntry> = paths
713 .iter()
714 .map(|p| FileEntry {
715 frecency_score: frecency_map
716 .as_ref()
717 .and_then(|m| m.get(p).map(frecency_score))
718 .unwrap_or(0.0),
719 relative_path: p.clone(),
720 })
721 .collect();
722 if sender
723 .send(
724 crate::services::async_bridge::AsyncMessage::QuickOpenFilesLoaded {
725 files: std::sync::Arc::new(entries),
726 complete: false,
727 },
728 )
729 .is_err()
730 {
731 receiver_gone = true;
734 return false;
735 }
736 last_send = std::time::Instant::now();
737 }
738
739 paths.len() < MAX_FILES
740 }) {
741 tracing::debug!("Quick Open walk_files failed: {}", e);
742 }
743
744 if receiver_gone {
745 return;
746 }
747
748 let frecency_map = frecency.read().ok();
751 let entries: Vec<FileEntry> = paths
752 .into_iter()
753 .map(|p| {
754 let score = frecency_map
755 .as_ref()
756 .and_then(|m| m.get(&p).map(frecency_score))
757 .unwrap_or(0.0);
758 FileEntry {
759 relative_path: p,
760 frecency_score: score,
761 }
762 })
763 .collect();
764 drop(sender.send(
765 crate::services::async_bridge::AsyncMessage::QuickOpenFilesLoaded {
766 files: std::sync::Arc::new(entries),
767 complete: true,
768 },
769 ));
770}
771
772fn frecency_score(data: &FrecencyData) -> f64 {
774 let hours_since_access = data.last_access.elapsed().as_secs_f64() / 3600.0;
775 let recency_weight = if hours_since_access < 4.0 {
776 100.0
777 } else if hours_since_access < 24.0 {
778 70.0
779 } else if hours_since_access < 24.0 * 7.0 {
780 50.0
781 } else if hours_since_access < 24.0 * 30.0 {
782 30.0
783 } else if hours_since_access < 24.0 * 90.0 {
784 10.0
785 } else {
786 1.0
787 };
788 data.access_count as f64 * recency_weight
789}
790
791impl QuickOpenProvider for FileProvider {
792 fn prefix(&self) -> &str {
793 ""
794 }
795
796 fn suggestions(&self, query: &str, context: &QuickOpenContext) -> Vec<Suggestion> {
797 let (path_part, _, _) = super::parse_path_line_col(query);
799 let search_query = if path_part.is_empty() {
800 query
801 } else {
802 &path_part
803 };
804
805 if !self.filesystem.is_remote_connected() {
807 return vec![Suggestion::disabled(
808 "Remote connection lost — cannot list files".to_string(),
809 )];
810 }
811
812 let files = self.get_or_start_loading(&context.cwd);
815 let still_loading = self.is_loading();
816
817 let prefix_entries = if !search_query.is_empty() {
823 self.probe_prefix(&context.cwd, search_query)
824 } else {
825 vec![]
826 };
827
828 let has_files = files.as_ref().is_some_and(|f| !f.is_empty());
829
830 if !has_files && prefix_entries.is_empty() {
831 if still_loading {
832 return vec![Suggestion::disabled("Loading files…".to_string())];
833 } else {
834 return vec![Suggestion::disabled(t!("quick_open.no_files").to_string())];
835 }
836 }
837
838 let max_results = 100;
839
840 let prefix_set: std::collections::HashSet<&str> = prefix_entries
842 .iter()
843 .map(|e| e.relative_path.as_str())
844 .collect();
845
846 const PREFIX_PROBE_BOOST: i32 = 200;
848
849 let mut matcher = FuzzyMatcher::new(search_query);
854
855 let mut scored: Vec<(String, i32)> = Vec::new();
857
858 for entry in &prefix_entries {
860 let m = matcher.match_target(&entry.relative_path);
861 let base_score = if m.matched { m.score } else { 0 };
862 let frecency_boost = (entry.frecency_score / 100.0).min(20.0) as i32;
863 scored.push((
864 entry.relative_path.clone(),
865 base_score + frecency_boost + PREFIX_PROBE_BOOST,
866 ));
867 }
868
869 if let Some(files) = &files {
871 if search_query.is_empty() {
872 let mut entries: Vec<_> = files.iter().map(|f| (f, 0i32)).collect();
873 entries.sort_by(|a, b| {
874 b.0.frecency_score
875 .partial_cmp(&a.0.frecency_score)
876 .unwrap_or(std::cmp::Ordering::Equal)
877 });
878 entries.truncate(max_results);
879 for (f, s) in entries {
880 scored.push((f.relative_path.clone(), s));
881 }
882 } else {
883 for file in files.iter() {
884 if prefix_set.contains(file.relative_path.as_str()) {
886 continue;
887 }
888 let m = matcher.match_target(&file.relative_path);
889 if !m.matched {
890 continue;
891 }
892 let frecency_boost = (file.frecency_score / 100.0).min(20.0) as i32;
893 let mut score = m.score + frecency_boost;
894 if file.relative_path.starts_with(search_query) {
897 score += PREFIX_PROBE_BOOST;
898 }
899 scored.push((file.relative_path.clone(), score));
900 }
901 }
902 }
903
904 scored.sort_by(|a, b| b.1.cmp(&a.1));
905 scored.truncate(max_results);
906
907 let mut suggestions: Vec<Suggestion> = scored
908 .into_iter()
909 .map(|(path, _)| Suggestion::new(path.clone()).with_value(path))
910 .collect();
911
912 if still_loading {
913 let msg = if suggestions.is_empty() {
914 "Loading files…"
915 } else {
916 "Scanning for more files…"
917 };
918 suggestions.push(Suggestion::disabled(msg.to_string()));
919 }
920
921 suggestions
922 }
923
924 fn on_select(
925 &self,
926 suggestion: Option<&Suggestion>,
927 query: &str,
928 _context: &QuickOpenContext,
929 ) -> QuickOpenResult {
930 let (path_part, line, column) = super::parse_path_line_col(query);
931
932 if let Some(path) = suggestion.and_then(|s| s.value.as_deref()) {
934 self.record_access(path);
935 return QuickOpenResult::OpenFile {
936 path: path.to_string(),
937 line,
938 column,
939 };
940 }
941
942 if line.is_some() && !path_part.is_empty() {
944 self.record_access(&path_part);
945 return QuickOpenResult::OpenFile {
946 path: path_part,
947 line,
948 column,
949 };
950 }
951
952 QuickOpenResult::None
953 }
954
955 fn as_any(&self) -> &dyn std::any::Any {
956 self
957 }
958}
959
960#[cfg(test)]
961mod tests {
962 use super::*;
963 use crate::input::quick_open::BufferInfo;
964
965 fn make_test_context(cwd: &str) -> QuickOpenContext {
966 QuickOpenContext {
967 cwd: cwd.to_string(),
968 open_buffers: vec![
969 BufferInfo {
970 id: 1,
971 path: "/tmp/main.rs".to_string(),
972 name: "main.rs".to_string(),
973 modified: false,
974 },
975 BufferInfo {
976 id: 2,
977 path: "/tmp/lib.rs".to_string(),
978 name: "lib.rs".to_string(),
979 modified: true,
980 },
981 ],
982 active_buffer_id: 1,
983 active_buffer_path: Some("/tmp/main.rs".to_string()),
984 has_selection: false,
985 key_context: crate::input::keybindings::KeyContext::Normal,
986 custom_contexts: std::collections::HashSet::new(),
987 buffer_mode: None,
988 has_lsp_config: true,
989 relative_line_numbers: false,
990 }
991 }
992
993 #[test]
994 fn test_buffer_provider_suggestions() {
995 let provider = BufferProvider::new();
996 let context = make_test_context("/tmp");
997
998 let suggestions = provider.suggestions("", &context);
999 assert_eq!(suggestions.len(), 2);
1000
1001 let lib_suggestion = suggestions
1003 .iter()
1004 .find(|s| s.text.contains("lib.rs"))
1005 .unwrap();
1006 assert!(lib_suggestion.text.contains("[+]"));
1007 }
1008
1009 #[test]
1010 fn test_buffer_provider_filter() {
1011 let provider = BufferProvider::new();
1012 let context = make_test_context("/tmp");
1013
1014 let suggestions = provider.suggestions("main", &context);
1015 assert_eq!(suggestions.len(), 1);
1016 assert!(suggestions[0].text.contains("main.rs"));
1017 }
1018
1019 #[test]
1020 fn test_goto_line_provider() {
1021 let provider = GotoLineProvider::new();
1022 let context = make_test_context("/tmp");
1023
1024 let suggestions = provider.suggestions("42", &context);
1026 assert_eq!(suggestions.len(), 1);
1027 assert!(!suggestions[0].disabled);
1028
1029 let suggestions = provider.suggestions("", &context);
1031 assert_eq!(suggestions.len(), 1);
1032 assert!(suggestions[0].disabled);
1033
1034 let suggestions = provider.suggestions("abc", &context);
1036 assert_eq!(suggestions.len(), 1);
1037 assert!(suggestions[0].disabled);
1038 }
1039
1040 #[test]
1041 fn test_goto_line_on_select() {
1042 let provider = GotoLineProvider::new();
1043 let context = make_test_context("/tmp");
1044
1045 let suggestions = provider.suggestions("42", &context);
1046 let result = provider.on_select(suggestions.first(), "42", &context);
1047 match result {
1048 QuickOpenResult::GotoLine(line) => assert_eq!(line, 42),
1049 _ => panic!("Expected GotoLine result"),
1050 }
1051 }
1052
1053 #[test]
1054 fn test_goto_line_relative_mode() {
1055 let provider = GotoLineProvider::new();
1056
1057 let mut context = make_test_context("/tmp");
1058 context.relative_line_numbers = true;
1059
1060 let suggestions = provider.suggestions("-5", &context);
1061 assert_eq!(suggestions.len(), 1);
1062 assert!(!suggestions[0].disabled);
1063
1064 let suggestions = provider.suggestions("+3", &context);
1065 assert_eq!(suggestions.len(), 1);
1066 assert!(!suggestions[0].disabled);
1067
1068 let suggestions = provider.suggestions("-", &context);
1069 assert_eq!(suggestions.len(), 1);
1070 assert!(suggestions[0].disabled);
1071
1072 let suggestions = provider.suggestions("+", &context);
1073 assert_eq!(suggestions.len(), 1);
1074 assert!(suggestions[0].disabled);
1075 }
1076
1077 #[test]
1078 fn test_goto_line_relative_negative_without_mode() {
1079 let provider = GotoLineProvider::new();
1080
1081 let context = make_test_context("/tmp");
1082
1083 let suggestions = provider.suggestions("-5", &context);
1084 assert_eq!(suggestions.len(), 1);
1085 assert!(suggestions[0].disabled);
1086 assert!(suggestions[0].text.contains("relative"));
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}