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 match query.parse::<usize>() {
209 Ok(n) if n > 0 => {
210 vec![
211 Suggestion::new(t!("quick_open.goto_line", line = n.to_string()).to_string())
212 .with_description(t!("quick_open.press_enter").to_string())
213 .with_value(n.to_string()),
214 ]
215 }
216 _ => vec![
217 Suggestion::disabled(t!("quick_open.invalid_line").to_string())
218 .with_description(query.to_string()),
219 ],
220 }
221 }
222
223 fn on_select(
224 &self,
225 suggestion: Option<&Suggestion>,
226 _query: &str,
227 _context: &QuickOpenContext,
228 ) -> QuickOpenResult {
229 suggestion
230 .and_then(|s| s.value.as_deref())
231 .and_then(|v| v.parse::<usize>().ok())
232 .filter(|&n| n > 0)
233 .map(QuickOpenResult::GotoLine)
234 .unwrap_or(QuickOpenResult::None)
235 }
236
237 fn as_any(&self) -> &dyn std::any::Any {
238 self
239 }
240}
241
242const IGNORED_DIRS: &[&str] = &[
248 ".git",
249 "node_modules",
250 "target",
251 "__pycache__",
252 ".hg",
253 ".svn",
254 ".DS_Store",
255];
256
257const MAX_FILES: usize = 50_000;
258
259#[derive(Clone, Debug)]
261pub struct FileEntry {
262 relative_path: String,
263 frecency_score: f64,
264}
265
266#[derive(Clone)]
267struct FrecencyData {
268 access_count: u32,
269 last_access: std::time::Instant,
270}
271
272struct FileCache {
276 files: Option<std::sync::Arc<Vec<FileEntry>>>,
278 loading: bool,
280}
281
282#[derive(Clone)]
294pub struct FileProvider {
295 cache: std::sync::Arc<std::sync::Mutex<FileCache>>,
296 frecency: std::sync::Arc<std::sync::RwLock<std::collections::HashMap<String, FrecencyData>>>,
297 filesystem: std::sync::Arc<dyn crate::model::filesystem::FileSystem + Send + Sync>,
298 process_spawner: std::sync::Arc<dyn crate::services::remote::ProcessSpawner>,
299 runtime_handle: Option<tokio::runtime::Handle>,
300 async_sender: Option<std::sync::mpsc::Sender<crate::services::async_bridge::AsyncMessage>>,
301 cancel: std::sync::Arc<std::sync::atomic::AtomicBool>,
303}
304
305impl FileProvider {
306 pub fn new(
307 filesystem: std::sync::Arc<dyn crate::model::filesystem::FileSystem + Send + Sync>,
308 process_spawner: std::sync::Arc<dyn crate::services::remote::ProcessSpawner>,
309 runtime_handle: Option<tokio::runtime::Handle>,
310 async_sender: Option<std::sync::mpsc::Sender<crate::services::async_bridge::AsyncMessage>>,
311 ) -> Self {
312 Self {
313 cache: std::sync::Arc::new(std::sync::Mutex::new(FileCache {
314 files: None,
315 loading: false,
316 })),
317 frecency: std::sync::Arc::new(std::sync::RwLock::new(std::collections::HashMap::new())),
318 filesystem,
319 process_spawner,
320 runtime_handle,
321 async_sender,
322 cancel: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
323 }
324 }
325
326 pub fn clear_cache(&self) {
328 self.cancel
329 .store(true, std::sync::atomic::Ordering::Relaxed);
330 if let Ok(mut c) = self.cache.lock() {
331 c.files = None;
332 c.loading = false;
333 }
334 }
335
336 pub fn cancel_loading(&self) {
339 self.cancel
340 .store(true, std::sync::atomic::Ordering::Relaxed);
341 if let Ok(mut c) = self.cache.lock() {
342 c.loading = false;
343 }
344 }
345
346 pub fn set_cache(&self, files: std::sync::Arc<Vec<FileEntry>>) {
348 if let Ok(mut c) = self.cache.lock() {
349 c.files = Some(files);
350 c.loading = false;
351 }
352 }
353
354 pub fn set_partial_cache(&self, files: std::sync::Arc<Vec<FileEntry>>) {
357 if let Ok(mut c) = self.cache.lock() {
358 c.files = Some(files);
359 }
361 }
362
363 fn is_loading(&self) -> bool {
365 self.cache.lock().is_ok_and(|c| c.loading)
366 }
367
368 pub fn record_access(&self, path: &str) {
370 if let Ok(mut frecency) = self.frecency.write() {
371 let entry = frecency.entry(path.to_string()).or_insert(FrecencyData {
372 access_count: 0,
373 last_access: std::time::Instant::now(),
374 });
375 entry.access_count += 1;
376 entry.last_access = std::time::Instant::now();
377 }
378 }
379
380 fn get_frecency_score(&self, path: &str) -> f64 {
381 self.frecency
382 .read()
383 .ok()
384 .and_then(|m| m.get(path).map(frecency_score))
385 .unwrap_or(0.0)
386 }
387
388 fn probe_prefix(&self, cwd: &str, query: &str) -> Vec<FileEntry> {
396 use std::path::Path;
397
398 if query.is_empty() {
399 return vec![];
400 }
401
402 let abs_path = Path::new(cwd).join(query);
403 let mut results = Vec::new();
404
405 if let Ok(entries) = self.filesystem.read_dir(&abs_path) {
407 let query_trimmed = query.trim_end_matches('/');
408 for entry in entries {
409 if entry.is_file() && !entry.name.starts_with('.') {
410 let rel = format!("{}/{}", query_trimmed, entry.name);
411 results.push(FileEntry {
412 frecency_score: self.get_frecency_score(&rel),
413 relative_path: rel,
414 });
415 }
416 }
417 results.truncate(50);
418 return results;
419 }
420
421 let parent = match abs_path.parent() {
424 Some(p) => p,
425 None => return results,
426 };
427 let basename = match abs_path.file_name().and_then(|n| n.to_str()) {
428 Some(b) => b,
429 None => return results,
430 };
431
432 let rel_parent = match parent.strip_prefix(cwd) {
433 Ok(p) => {
434 let s = p.to_string_lossy().replace('\\', "/");
435 s
436 }
437 Err(_) => return results,
438 };
439
440 if let Ok(entries) = self.filesystem.read_dir(parent) {
441 for entry in entries {
442 if entry.name.starts_with('.') {
443 continue;
444 }
445 if !entry.name.starts_with(basename) {
446 continue;
447 }
448 if entry.is_file() {
449 let rel = if rel_parent.is_empty() {
450 entry.name.clone()
451 } else {
452 format!("{}/{}", rel_parent, entry.name)
453 };
454 results.push(FileEntry {
455 frecency_score: self.get_frecency_score(&rel),
456 relative_path: rel,
457 });
458 }
459 }
460 }
461
462 results
463 }
464
465 fn get_or_start_loading(&self, cwd: &str) -> Option<std::sync::Arc<Vec<FileEntry>>> {
471 let mut cache = self.cache.lock().ok()?;
472
473 if let Some(files) = &cache.files {
474 return Some(std::sync::Arc::clone(files));
475 }
476
477 if cache.loading {
478 return None; }
480
481 let (sender, handle) = match (&self.async_sender, &self.runtime_handle) {
483 (Some(s), Some(h)) => (s.clone(), h.clone()),
484 _ => {
485 drop(cache);
487 return self.load_files_sync(cwd);
488 }
489 };
490
491 cache.loading = true;
492 self.cancel
494 .store(false, std::sync::atomic::Ordering::Relaxed);
495 let cancel = std::sync::Arc::clone(&self.cancel);
496 let frecency = std::sync::Arc::clone(&self.frecency);
497 let filesystem = std::sync::Arc::clone(&self.filesystem);
498 let process_spawner = std::sync::Arc::clone(&self.process_spawner);
499 let cwd = cwd.to_string();
500
501 handle.spawn_blocking(move || {
502 if let Some(files) = try_git_files_blocking(&process_spawner, &cwd) {
504 let frecency_map = frecency.read().ok();
505 let entries: Vec<FileEntry> = files
506 .into_iter()
507 .map(|path| {
508 let score = frecency_map
509 .as_ref()
510 .and_then(|m| m.get(&path))
511 .map(frecency_score)
512 .unwrap_or(0.0);
513 FileEntry {
514 relative_path: path,
515 frecency_score: score,
516 }
517 })
518 .collect();
519 drop(sender.send(
522 crate::services::async_bridge::AsyncMessage::QuickOpenFilesLoaded {
523 files: std::sync::Arc::new(entries),
524 complete: true,
525 },
526 ));
527 return;
528 }
529
530 walk_dir_with_updates(&*filesystem, &cwd, &cancel, &frecency, &sender);
533 });
534
535 None
536 }
537
538 fn load_files_sync(&self, cwd: &str) -> Option<std::sync::Arc<Vec<FileEntry>>> {
540 let files = self
541 .try_git_files(cwd)
542 .or_else(|| self.try_walk_dir(cwd))
543 .unwrap_or_default();
544
545 let entries: Vec<FileEntry> = files
546 .into_iter()
547 .map(|path| FileEntry {
548 frecency_score: self.get_frecency_score(&path),
549 relative_path: path,
550 })
551 .collect();
552
553 let files = std::sync::Arc::new(entries);
554 self.set_cache(std::sync::Arc::clone(&files));
555 Some(files)
556 }
557
558 fn try_git_files(&self, cwd: &str) -> Option<Vec<String>> {
560 let handle = self.runtime_handle.as_ref()?;
561 try_git_files_with_handle(&self.process_spawner, cwd, handle)
562 }
563
564 fn try_walk_dir(&self, cwd: &str) -> Option<Vec<String>> {
566 let cancel = std::sync::atomic::AtomicBool::new(false);
567 try_walk_dir_blocking(&*self.filesystem, cwd, &cancel)
568 }
569}
570
571fn try_git_files_blocking(
581 spawner: &std::sync::Arc<dyn crate::services::remote::ProcessSpawner>,
582 cwd: &str,
583) -> Option<Vec<String>> {
584 let handle = tokio::runtime::Handle::try_current().ok()?;
586 try_git_files_with_handle(spawner, cwd, &handle)
587}
588
589fn try_git_files_with_handle(
590 spawner: &std::sync::Arc<dyn crate::services::remote::ProcessSpawner>,
591 cwd: &str,
592 handle: &tokio::runtime::Handle,
593) -> Option<Vec<String>> {
594 let result = handle
595 .block_on(spawner.spawn(
596 "git".to_string(),
597 vec![
598 "ls-files".to_string(),
599 "--cached".to_string(),
600 "--others".to_string(),
601 "--exclude-standard".to_string(),
602 ],
603 Some(cwd.to_string()),
604 ))
605 .ok()?;
606
607 if result.exit_code != 0 {
608 return None;
609 }
610
611 let files: Vec<String> = result
612 .stdout
613 .lines()
614 .filter(|line| !line.is_empty() && !line.starts_with(".git/"))
615 .map(|s| s.to_string())
616 .collect();
617
618 Some(files)
619}
620
621fn try_walk_dir_blocking(
623 fs: &dyn crate::model::filesystem::FileSystem,
624 cwd: &str,
625 cancel: &std::sync::atomic::AtomicBool,
626) -> Option<Vec<String>> {
627 use std::path::Path;
628
629 let base = Path::new(cwd);
630 let mut files = Vec::new();
631
632 drop(
634 fs.walk_files(base, IGNORED_DIRS, cancel, &mut |_path, rel| {
635 files.push(rel.to_string());
636 files.len() < MAX_FILES
637 }),
638 );
639
640 if files.is_empty() {
641 None
642 } else {
643 Some(files)
644 }
645}
646
647const WALK_UPDATE_INTERVAL: std::time::Duration = std::time::Duration::from_millis(300);
650
651fn walk_dir_with_updates(
654 fs: &dyn crate::model::filesystem::FileSystem,
655 cwd: &str,
656 cancel: &std::sync::atomic::AtomicBool,
657 frecency: &std::sync::RwLock<std::collections::HashMap<String, FrecencyData>>,
658 sender: &std::sync::mpsc::Sender<crate::services::async_bridge::AsyncMessage>,
659) {
660 use std::path::Path;
661
662 let base = Path::new(cwd);
663 let mut paths: Vec<String> = Vec::new();
664 let mut last_send = std::time::Instant::now();
665 let mut receiver_gone = false;
666
667 if let Err(e) = fs.walk_files(base, IGNORED_DIRS, cancel, &mut |_path, rel| {
671 paths.push(rel.to_string());
672
673 if last_send.elapsed() >= WALK_UPDATE_INTERVAL {
675 let frecency_map = frecency.read().ok();
676 let entries: Vec<FileEntry> = paths
677 .iter()
678 .map(|p| FileEntry {
679 frecency_score: frecency_map
680 .as_ref()
681 .and_then(|m| m.get(p).map(frecency_score))
682 .unwrap_or(0.0),
683 relative_path: p.clone(),
684 })
685 .collect();
686 if sender
687 .send(
688 crate::services::async_bridge::AsyncMessage::QuickOpenFilesLoaded {
689 files: std::sync::Arc::new(entries),
690 complete: false,
691 },
692 )
693 .is_err()
694 {
695 receiver_gone = true;
698 return false;
699 }
700 last_send = std::time::Instant::now();
701 }
702
703 paths.len() < MAX_FILES
704 }) {
705 tracing::debug!("Quick Open walk_files failed: {}", e);
706 }
707
708 if receiver_gone {
709 return;
710 }
711
712 let frecency_map = frecency.read().ok();
715 let entries: Vec<FileEntry> = paths
716 .into_iter()
717 .map(|p| {
718 let score = frecency_map
719 .as_ref()
720 .and_then(|m| m.get(&p).map(frecency_score))
721 .unwrap_or(0.0);
722 FileEntry {
723 relative_path: p,
724 frecency_score: score,
725 }
726 })
727 .collect();
728 drop(sender.send(
729 crate::services::async_bridge::AsyncMessage::QuickOpenFilesLoaded {
730 files: std::sync::Arc::new(entries),
731 complete: true,
732 },
733 ));
734}
735
736fn frecency_score(data: &FrecencyData) -> f64 {
738 let hours_since_access = data.last_access.elapsed().as_secs_f64() / 3600.0;
739 let recency_weight = if hours_since_access < 4.0 {
740 100.0
741 } else if hours_since_access < 24.0 {
742 70.0
743 } else if hours_since_access < 24.0 * 7.0 {
744 50.0
745 } else if hours_since_access < 24.0 * 30.0 {
746 30.0
747 } else if hours_since_access < 24.0 * 90.0 {
748 10.0
749 } else {
750 1.0
751 };
752 data.access_count as f64 * recency_weight
753}
754
755impl QuickOpenProvider for FileProvider {
756 fn prefix(&self) -> &str {
757 ""
758 }
759
760 fn suggestions(&self, query: &str, context: &QuickOpenContext) -> Vec<Suggestion> {
761 let (path_part, _, _) = super::parse_path_line_col(query);
763 let search_query = if path_part.is_empty() {
764 query
765 } else {
766 &path_part
767 };
768
769 if !self.filesystem.is_remote_connected() {
771 return vec![Suggestion::disabled(
772 "Remote connection lost — cannot list files".to_string(),
773 )];
774 }
775
776 let files = self.get_or_start_loading(&context.cwd);
779 let still_loading = self.is_loading();
780
781 let prefix_entries = if !search_query.is_empty() {
787 self.probe_prefix(&context.cwd, search_query)
788 } else {
789 vec![]
790 };
791
792 let has_files = files.as_ref().is_some_and(|f| !f.is_empty());
793
794 if !has_files && prefix_entries.is_empty() {
795 if still_loading {
796 return vec![Suggestion::disabled("Loading files…".to_string())];
797 } else {
798 return vec![Suggestion::disabled(t!("quick_open.no_files").to_string())];
799 }
800 }
801
802 let max_results = 100;
803
804 let prefix_set: std::collections::HashSet<&str> = prefix_entries
806 .iter()
807 .map(|e| e.relative_path.as_str())
808 .collect();
809
810 const PREFIX_PROBE_BOOST: i32 = 200;
812
813 let mut matcher = FuzzyMatcher::new(search_query);
818
819 let mut scored: Vec<(String, i32)> = Vec::new();
821
822 for entry in &prefix_entries {
824 let m = matcher.match_target(&entry.relative_path);
825 let base_score = if m.matched { m.score } else { 0 };
826 let frecency_boost = (entry.frecency_score / 100.0).min(20.0) as i32;
827 scored.push((
828 entry.relative_path.clone(),
829 base_score + frecency_boost + PREFIX_PROBE_BOOST,
830 ));
831 }
832
833 if let Some(files) = &files {
835 if search_query.is_empty() {
836 let mut entries: Vec<_> = files.iter().map(|f| (f, 0i32)).collect();
837 entries.sort_by(|a, b| {
838 b.0.frecency_score
839 .partial_cmp(&a.0.frecency_score)
840 .unwrap_or(std::cmp::Ordering::Equal)
841 });
842 entries.truncate(max_results);
843 for (f, s) in entries {
844 scored.push((f.relative_path.clone(), s));
845 }
846 } else {
847 for file in files.iter() {
848 if prefix_set.contains(file.relative_path.as_str()) {
850 continue;
851 }
852 let m = matcher.match_target(&file.relative_path);
853 if !m.matched {
854 continue;
855 }
856 let frecency_boost = (file.frecency_score / 100.0).min(20.0) as i32;
857 let mut score = m.score + frecency_boost;
858 if file.relative_path.starts_with(search_query) {
861 score += PREFIX_PROBE_BOOST;
862 }
863 scored.push((file.relative_path.clone(), score));
864 }
865 }
866 }
867
868 scored.sort_by(|a, b| b.1.cmp(&a.1));
869 scored.truncate(max_results);
870
871 let mut suggestions: Vec<Suggestion> = scored
872 .into_iter()
873 .map(|(path, _)| Suggestion::new(path.clone()).with_value(path))
874 .collect();
875
876 if still_loading {
877 let msg = if suggestions.is_empty() {
878 "Loading files…"
879 } else {
880 "Scanning for more files…"
881 };
882 suggestions.push(Suggestion::disabled(msg.to_string()));
883 }
884
885 suggestions
886 }
887
888 fn on_select(
889 &self,
890 suggestion: Option<&Suggestion>,
891 query: &str,
892 _context: &QuickOpenContext,
893 ) -> QuickOpenResult {
894 let (path_part, line, column) = super::parse_path_line_col(query);
895
896 if let Some(path) = suggestion.and_then(|s| s.value.as_deref()) {
898 self.record_access(path);
899 return QuickOpenResult::OpenFile {
900 path: path.to_string(),
901 line,
902 column,
903 };
904 }
905
906 if line.is_some() && !path_part.is_empty() {
908 self.record_access(&path_part);
909 return QuickOpenResult::OpenFile {
910 path: path_part,
911 line,
912 column,
913 };
914 }
915
916 QuickOpenResult::None
917 }
918
919 fn as_any(&self) -> &dyn std::any::Any {
920 self
921 }
922}
923
924#[cfg(test)]
925mod tests {
926 use super::*;
927 use crate::input::quick_open::BufferInfo;
928
929 fn make_test_context(cwd: &str) -> QuickOpenContext {
930 QuickOpenContext {
931 cwd: cwd.to_string(),
932 open_buffers: vec![
933 BufferInfo {
934 id: 1,
935 path: "/tmp/main.rs".to_string(),
936 name: "main.rs".to_string(),
937 modified: false,
938 },
939 BufferInfo {
940 id: 2,
941 path: "/tmp/lib.rs".to_string(),
942 name: "lib.rs".to_string(),
943 modified: true,
944 },
945 ],
946 active_buffer_id: 1,
947 active_buffer_path: Some("/tmp/main.rs".to_string()),
948 has_selection: false,
949 key_context: crate::input::keybindings::KeyContext::Normal,
950 custom_contexts: std::collections::HashSet::new(),
951 buffer_mode: None,
952 has_lsp_config: true,
953 }
954 }
955
956 #[test]
957 fn test_buffer_provider_suggestions() {
958 let provider = BufferProvider::new();
959 let context = make_test_context("/tmp");
960
961 let suggestions = provider.suggestions("", &context);
962 assert_eq!(suggestions.len(), 2);
963
964 let lib_suggestion = suggestions
966 .iter()
967 .find(|s| s.text.contains("lib.rs"))
968 .unwrap();
969 assert!(lib_suggestion.text.contains("[+]"));
970 }
971
972 #[test]
973 fn test_buffer_provider_filter() {
974 let provider = BufferProvider::new();
975 let context = make_test_context("/tmp");
976
977 let suggestions = provider.suggestions("main", &context);
978 assert_eq!(suggestions.len(), 1);
979 assert!(suggestions[0].text.contains("main.rs"));
980 }
981
982 #[test]
983 fn test_goto_line_provider() {
984 let provider = GotoLineProvider::new();
985 let context = make_test_context("/tmp");
986
987 let suggestions = provider.suggestions("42", &context);
989 assert_eq!(suggestions.len(), 1);
990 assert!(!suggestions[0].disabled);
991
992 let suggestions = provider.suggestions("", &context);
994 assert_eq!(suggestions.len(), 1);
995 assert!(suggestions[0].disabled);
996
997 let suggestions = provider.suggestions("abc", &context);
999 assert_eq!(suggestions.len(), 1);
1000 assert!(suggestions[0].disabled);
1001 }
1002
1003 #[test]
1004 fn test_goto_line_on_select() {
1005 let provider = GotoLineProvider::new();
1006 let context = make_test_context("/tmp");
1007
1008 let suggestions = provider.suggestions("42", &context);
1009 let result = provider.on_select(suggestions.first(), "42", &context);
1010 match result {
1011 QuickOpenResult::GotoLine(line) => assert_eq!(line, 42),
1012 _ => panic!("Expected GotoLine result"),
1013 }
1014 }
1015
1016 struct FailingSpawner;
1024
1025 #[async_trait::async_trait]
1026 impl crate::services::remote::ProcessSpawner for FailingSpawner {
1027 async fn spawn(
1028 &self,
1029 _command: String,
1030 _args: Vec<String>,
1031 _cwd: Option<String>,
1032 ) -> Result<crate::services::remote::SpawnResult, crate::services::remote::SpawnError>
1033 {
1034 Err(crate::services::remote::SpawnError::Process(
1035 "no git in test".to_string(),
1036 ))
1037 }
1038 }
1039
1040 fn make_file_provider() -> FileProvider {
1043 FileProvider::new(
1044 std::sync::Arc::new(crate::model::filesystem::StdFileSystem),
1045 std::sync::Arc::new(FailingSpawner),
1046 None, None, )
1049 }
1050
1051 #[test]
1052 fn test_file_provider_discovers_files_via_walk() {
1053 let dir = tempfile::tempdir().unwrap();
1054 let base = dir.path();
1055
1056 std::fs::write(base.join("main.rs"), b"fn main() {}").unwrap();
1058 std::fs::write(base.join("lib.rs"), b"pub mod foo;").unwrap();
1059 std::fs::create_dir(base.join("src")).unwrap();
1060 std::fs::write(base.join("src").join("foo.rs"), b"// foo").unwrap();
1061
1062 let provider = make_file_provider();
1063 let context = make_test_context(&base.display().to_string());
1064 let suggestions = provider.suggestions("", &context);
1065
1066 assert_eq!(suggestions.len(), 3);
1068 let paths: Vec<&str> = suggestions
1069 .iter()
1070 .filter_map(|s| s.value.as_deref())
1071 .collect();
1072 assert!(paths.contains(&"main.rs"));
1073 assert!(paths.contains(&"lib.rs"));
1074 assert!(paths.contains(&"src/foo.rs"));
1075 }
1076
1077 #[test]
1078 fn test_file_provider_skips_ignored_dirs() {
1079 let dir = tempfile::tempdir().unwrap();
1080 let base = dir.path();
1081
1082 std::fs::write(base.join("app.rs"), b"").unwrap();
1083 std::fs::create_dir(base.join("node_modules")).unwrap();
1085 std::fs::write(base.join("node_modules").join("pkg.js"), b"").unwrap();
1086 std::fs::create_dir(base.join("target")).unwrap();
1087 std::fs::write(base.join("target").join("debug.o"), b"").unwrap();
1088
1089 let provider = make_file_provider();
1090 let context = make_test_context(&base.display().to_string());
1091 let suggestions = provider.suggestions("", &context);
1092
1093 assert_eq!(suggestions.len(), 1);
1094 assert_eq!(suggestions[0].value.as_deref(), Some("app.rs"));
1095 }
1096
1097 #[test]
1098 fn test_file_provider_skips_hidden_files() {
1099 let dir = tempfile::tempdir().unwrap();
1100 let base = dir.path();
1101
1102 std::fs::write(base.join("visible.txt"), b"").unwrap();
1103 std::fs::write(base.join(".hidden"), b"").unwrap();
1104 std::fs::create_dir(base.join(".git")).unwrap();
1105 std::fs::write(base.join(".git").join("config"), b"").unwrap();
1106
1107 let provider = make_file_provider();
1108 let context = make_test_context(&base.display().to_string());
1109 let suggestions = provider.suggestions("", &context);
1110
1111 assert_eq!(suggestions.len(), 1);
1112 assert_eq!(suggestions[0].value.as_deref(), Some("visible.txt"));
1113 }
1114
1115 #[test]
1116 fn test_file_provider_fuzzy_filter() {
1117 let dir = tempfile::tempdir().unwrap();
1118 let base = dir.path();
1119
1120 std::fs::write(base.join("main.rs"), b"").unwrap();
1121 std::fs::write(base.join("lib.rs"), b"").unwrap();
1122 std::fs::write(base.join("README.md"), b"").unwrap();
1123
1124 let provider = make_file_provider();
1125 let context = make_test_context(&base.display().to_string());
1126 let suggestions = provider.suggestions("main", &context);
1127
1128 assert_eq!(suggestions.len(), 1);
1129 assert_eq!(suggestions[0].value.as_deref(), Some("main.rs"));
1130 }
1131
1132 #[test]
1133 fn test_file_provider_empty_dir() {
1134 let dir = tempfile::tempdir().unwrap();
1135
1136 let provider = make_file_provider();
1137 let context = make_test_context(&dir.path().display().to_string());
1138 let suggestions = provider.suggestions("", &context);
1139
1140 assert_eq!(suggestions.len(), 1);
1142 assert!(suggestions[0].disabled);
1143 }
1144
1145 #[test]
1155 fn test_probe_prefix_all_shapes() {
1156 let dir = tempfile::tempdir().unwrap();
1157 let base = dir.path();
1158
1159 std::fs::create_dir(base.join("etc")).unwrap();
1161 std::fs::write(base.join("etc").join("hosts"), b"").unwrap();
1162 std::fs::write(base.join("etc").join("hosts.allow"), b"").unwrap();
1163 std::fs::write(base.join("etc").join("hosts.deny"), b"").unwrap();
1164 std::fs::write(base.join("etc").join("passwd"), b"").unwrap();
1165
1166 std::fs::create_dir(base.join("src")).unwrap();
1168 std::fs::write(base.join("src").join("main.rs"), b"").unwrap();
1169 std::fs::write(base.join("src").join("lib.rs"), b"").unwrap();
1170
1171 std::fs::write(base.join("Makefile"), b"").unwrap();
1173 std::fs::write(base.join("Makefile.bak"), b"").unwrap();
1174 std::fs::write(base.join("README.md"), b"").unwrap();
1175
1176 let provider = make_file_provider();
1177 let cwd = base.display().to_string();
1178
1179 let r = provider.probe_prefix(&cwd, "etc/hosts");
1181 let paths: Vec<&str> = r.iter().map(|e| e.relative_path.as_str()).collect();
1182 assert!(
1183 paths.contains(&"etc/hosts"),
1184 "missing etc/hosts in {paths:?}"
1185 );
1186 assert!(
1187 paths.contains(&"etc/hosts.allow"),
1188 "missing etc/hosts.allow in {paths:?}"
1189 );
1190 assert!(
1191 paths.contains(&"etc/hosts.deny"),
1192 "missing etc/hosts.deny in {paths:?}"
1193 );
1194 assert!(
1195 !paths.contains(&"etc/passwd"),
1196 "passwd shouldn't match prefix 'hosts': {paths:?}"
1197 );
1198
1199 let r = provider.probe_prefix(&cwd, "src");
1201 let paths: Vec<&str> = r.iter().map(|e| e.relative_path.as_str()).collect();
1202 assert!(
1203 paths.contains(&"src/main.rs"),
1204 "missing src/main.rs in {paths:?}"
1205 );
1206 assert!(
1207 paths.contains(&"src/lib.rs"),
1208 "missing src/lib.rs in {paths:?}"
1209 );
1210
1211 let r = provider.probe_prefix(&cwd, "nonexistent/path/to/file");
1213 assert!(
1214 r.is_empty(),
1215 "nonexistent query should return empty, got {:?}",
1216 r.iter().map(|e| &e.relative_path).collect::<Vec<_>>()
1217 );
1218
1219 let r = provider.probe_prefix(&cwd, "Makefile");
1221 let paths: Vec<&str> = r.iter().map(|e| e.relative_path.as_str()).collect();
1222 assert!(paths.contains(&"Makefile"), "missing Makefile in {paths:?}");
1223 assert!(
1224 paths.contains(&"Makefile.bak"),
1225 "missing Makefile.bak in {paths:?}"
1226 );
1227 assert!(
1228 !paths.contains(&"README.md"),
1229 "README.md shouldn't match prefix 'Makefile': {paths:?}"
1230 );
1231 }
1232
1233 #[test]
1238 fn test_prefix_match_ranks_above_fuzzy_match() {
1239 let dir = tempfile::tempdir().unwrap();
1240 let base = dir.path();
1241
1242 std::fs::create_dir(base.join("src")).unwrap();
1245 std::fs::write(base.join("src").join("main.rs"), b"").unwrap();
1246 std::fs::write(base.join("src").join("manager.rs"), b"").unwrap();
1250
1251 let provider = make_file_provider();
1252 let context = make_test_context(&base.display().to_string());
1253 let suggestions = provider.suggestions("src/main", &context);
1254
1255 assert!(!suggestions.is_empty());
1257 assert_eq!(suggestions[0].value.as_deref(), Some("src/main.rs"));
1258 }
1259
1260 #[test]
1261 fn test_set_partial_cache_keeps_loading() {
1262 let provider = make_file_provider();
1263
1264 {
1266 let mut cache = provider.cache.lock().unwrap();
1267 cache.loading = true;
1268 }
1269
1270 let partial = std::sync::Arc::new(vec![FileEntry {
1272 relative_path: "foo.rs".to_string(),
1273 frecency_score: 0.0,
1274 }]);
1275 provider.set_partial_cache(partial);
1276
1277 assert!(provider.is_loading());
1278 assert!(provider.cache.lock().unwrap().files.is_some());
1279
1280 let final_files = std::sync::Arc::new(vec![FileEntry {
1282 relative_path: "foo.rs".to_string(),
1283 frecency_score: 0.0,
1284 }]);
1285 provider.set_cache(final_files);
1286
1287 assert!(!provider.is_loading());
1288 }
1289}