1use super::{QuickOpenContext, QuickOpenProvider, QuickOpenResult};
10use crate::input::commands::Suggestion;
11use crate::input::fuzzy::fuzzy_match;
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 name(&self) -> &str {
50 "Commands"
51 }
52
53 fn hint(&self) -> &str {
54 "> Commands"
55 }
56
57 fn suggestions(&self, query: &str, context: &QuickOpenContext) -> Vec<Suggestion> {
58 let registry = self.command_registry.read().unwrap();
59 let keybindings = self.keybinding_resolver.read().unwrap();
60
61 registry.filter(
62 query,
63 context.key_context,
64 &keybindings,
65 context.has_selection,
66 &context.custom_contexts,
67 context.buffer_mode.as_deref(),
68 context.has_lsp_config,
69 )
70 }
71
72 fn on_select(
73 &self,
74 selected_index: Option<usize>,
75 query: &str,
76 context: &QuickOpenContext,
77 ) -> QuickOpenResult {
78 let registry = self.command_registry.read().unwrap();
79 let keybindings = self.keybinding_resolver.read().unwrap();
80
81 let suggestions = registry.filter(
82 query,
83 context.key_context,
84 &keybindings,
85 context.has_selection,
86 &context.custom_contexts,
87 context.buffer_mode.as_deref(),
88 context.has_lsp_config,
89 );
90
91 if let Some(idx) = selected_index {
92 if let Some(suggestion) = suggestions.get(idx) {
93 if suggestion.disabled {
94 return QuickOpenResult::Error(t!("status.command_not_available").to_string());
95 }
96
97 let commands = registry.get_all();
99 if let Some(cmd) = commands
100 .iter()
101 .find(|c| c.get_localized_name() == suggestion.text)
102 {
103 drop(keybindings);
105 drop(registry);
106 if let Ok(mut reg) = self.command_registry.write() {
107 reg.record_usage(&cmd.name);
108 }
109 return QuickOpenResult::ExecuteAction(cmd.action.clone());
110 }
111 }
112 }
113
114 QuickOpenResult::None
115 }
116}
117
118pub struct BufferProvider;
124
125impl BufferProvider {
126 pub fn new() -> Self {
127 Self
128 }
129}
130
131impl Default for BufferProvider {
132 fn default() -> Self {
133 Self::new()
134 }
135}
136
137impl QuickOpenProvider for BufferProvider {
138 fn prefix(&self) -> &str {
139 "#"
140 }
141
142 fn name(&self) -> &str {
143 "Buffers"
144 }
145
146 fn hint(&self) -> &str {
147 "# Buffers"
148 }
149
150 fn suggestions(&self, query: &str, context: &QuickOpenContext) -> Vec<Suggestion> {
151 let mut suggestions: Vec<(Suggestion, i32, usize)> = context
152 .open_buffers
153 .iter()
154 .filter_map(|buf| {
155 if buf.path.is_empty() {
156 return None; }
158
159 let display_name = if buf.modified {
160 format!("{} [+]", buf.name)
161 } else {
162 buf.name.clone()
163 };
164
165 let match_result = if query.is_empty() {
166 crate::input::fuzzy::FuzzyMatch {
167 matched: true,
168 score: 0,
169 match_positions: vec![],
170 }
171 } else {
172 fuzzy_match(query, &buf.name)
173 };
174
175 if match_result.matched {
176 Some((
177 Suggestion {
178 text: display_name,
179 description: Some(buf.path.clone()),
180 value: Some(buf.id.to_string()),
181 disabled: false,
182 keybinding: None,
183 source: None,
184 },
185 match_result.score,
186 buf.id,
187 ))
188 } else {
189 None
190 }
191 })
192 .collect();
193
194 suggestions.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.2.cmp(&b.2)));
196
197 suggestions.into_iter().map(|(s, _, _)| s).collect()
198 }
199
200 fn on_select(
201 &self,
202 selected_index: Option<usize>,
203 query: &str,
204 context: &QuickOpenContext,
205 ) -> QuickOpenResult {
206 let suggestions = self.suggestions(query, context);
207
208 if let Some(idx) = selected_index {
209 if let Some(suggestion) = suggestions.get(idx) {
210 if let Some(value) = &suggestion.value {
211 if let Ok(buffer_id) = value.parse::<usize>() {
212 return QuickOpenResult::ShowBuffer(buffer_id);
213 }
214 }
215 }
216 }
217
218 QuickOpenResult::None
219 }
220
221 fn preview(
222 &self,
223 selected_index: usize,
224 context: &QuickOpenContext,
225 ) -> Option<(String, Option<usize>)> {
226 let suggestions = self.suggestions("", context);
227 suggestions
228 .get(selected_index)
229 .and_then(|s| s.description.clone().map(|path| (path, None)))
230 }
231}
232
233pub struct GotoLineProvider;
239
240impl GotoLineProvider {
241 pub fn new() -> Self {
242 Self
243 }
244}
245
246impl Default for GotoLineProvider {
247 fn default() -> Self {
248 Self::new()
249 }
250}
251
252impl QuickOpenProvider for GotoLineProvider {
253 fn prefix(&self) -> &str {
254 ":"
255 }
256
257 fn name(&self) -> &str {
258 "Go to Line"
259 }
260
261 fn hint(&self) -> &str {
262 ": Go to Line"
263 }
264
265 fn suggestions(&self, query: &str, _context: &QuickOpenContext) -> Vec<Suggestion> {
266 if query.is_empty() {
267 return vec![Suggestion {
268 text: t!("quick_open.goto_line_hint").to_string(),
269 description: Some(t!("quick_open.goto_line_desc").to_string()),
270 value: None,
271 disabled: true,
272 keybinding: None,
273 source: None,
274 }];
275 }
276
277 if let Ok(line_num) = query.parse::<usize>() {
278 if line_num > 0 {
279 return vec![Suggestion {
280 text: t!("quick_open.goto_line", line = line_num.to_string()).to_string(),
281 description: Some(t!("quick_open.press_enter").to_string()),
282 value: Some(line_num.to_string()),
283 disabled: false,
284 keybinding: None,
285 source: None,
286 }];
287 }
288 }
289
290 vec![Suggestion {
292 text: t!("quick_open.invalid_line").to_string(),
293 description: Some(query.to_string()),
294 value: None,
295 disabled: true,
296 keybinding: None,
297 source: None,
298 }]
299 }
300
301 fn on_select(
302 &self,
303 selected_index: Option<usize>,
304 query: &str,
305 _context: &QuickOpenContext,
306 ) -> QuickOpenResult {
307 if selected_index.is_some() {
309 if let Ok(line_num) = query.parse::<usize>() {
310 if line_num > 0 {
311 return QuickOpenResult::GotoLine(line_num);
312 }
313 }
314 }
315
316 QuickOpenResult::None
317 }
318}
319
320pub struct FileProvider {
329 file_cache: std::sync::Arc<std::sync::RwLock<Option<Vec<FileEntry>>>>,
331 frecency: std::sync::Arc<std::sync::RwLock<std::collections::HashMap<String, FrecencyData>>>,
333}
334
335#[derive(Clone)]
336struct FileEntry {
337 relative_path: String,
338 frecency_score: f64,
339}
340
341#[derive(Clone)]
342struct FrecencyData {
343 access_count: u32,
344 last_access: std::time::Instant,
345}
346
347impl FileProvider {
348 pub fn new() -> Self {
349 Self {
350 file_cache: std::sync::Arc::new(std::sync::RwLock::new(None)),
351 frecency: std::sync::Arc::new(std::sync::RwLock::new(std::collections::HashMap::new())),
352 }
353 }
354
355 pub fn clear_cache(&self) {
357 if let Ok(mut cache) = self.file_cache.write() {
358 *cache = None;
359 }
360 }
361
362 pub fn record_access(&self, path: &str) {
364 if let Ok(mut frecency) = self.frecency.write() {
365 let entry = frecency.entry(path.to_string()).or_insert(FrecencyData {
366 access_count: 0,
367 last_access: std::time::Instant::now(),
368 });
369 entry.access_count += 1;
370 entry.last_access = std::time::Instant::now();
371 }
372 }
373
374 fn get_frecency_score(&self, path: &str) -> f64 {
375 if let Ok(frecency) = self.frecency.read() {
376 if let Some(data) = frecency.get(path) {
377 let hours_since_access = data.last_access.elapsed().as_secs_f64() / 3600.0;
378
379 let recency_weight = if hours_since_access < 4.0 {
381 100.0
382 } else if hours_since_access < 24.0 {
383 70.0
384 } else if hours_since_access < 24.0 * 7.0 {
385 50.0
386 } else if hours_since_access < 24.0 * 30.0 {
387 30.0
388 } else if hours_since_access < 24.0 * 90.0 {
389 10.0
390 } else {
391 1.0
392 };
393
394 return data.access_count as f64 * recency_weight;
395 }
396 }
397 0.0
398 }
399
400 fn load_files(&self, cwd: &str) -> Vec<FileEntry> {
402 if let Ok(cache) = self.file_cache.read() {
404 if let Some(files) = cache.as_ref() {
405 return files.clone();
406 }
407 }
408
409 let files = self
411 .try_git_files(cwd)
412 .or_else(|| self.try_fd_files(cwd))
413 .or_else(|| self.try_find_files(cwd))
414 .unwrap_or_default();
415
416 let files: Vec<FileEntry> = files
418 .into_iter()
419 .map(|path| FileEntry {
420 frecency_score: self.get_frecency_score(&path),
421 relative_path: path,
422 })
423 .collect();
424
425 if let Ok(mut cache) = self.file_cache.write() {
427 *cache = Some(files.clone());
428 }
429
430 files
431 }
432
433 fn try_git_files(&self, cwd: &str) -> Option<Vec<String>> {
434 let output = std::process::Command::new("git")
435 .args(["ls-files", "--cached", "--others", "--exclude-standard"])
436 .current_dir(cwd)
437 .output()
438 .ok()?;
439
440 if !output.status.success() {
441 return None;
442 }
443
444 let files: Vec<String> = String::from_utf8_lossy(&output.stdout)
445 .lines()
446 .filter(|line| !line.is_empty() && !line.starts_with(".git/"))
447 .map(|s| s.to_string())
448 .collect();
449
450 Some(files)
451 }
452
453 fn try_fd_files(&self, cwd: &str) -> Option<Vec<String>> {
454 let output = std::process::Command::new("fd")
455 .args([
456 "--type",
457 "f",
458 "--hidden",
459 "--exclude",
460 ".git",
461 "--max-results",
462 "50000",
463 ])
464 .current_dir(cwd)
465 .output()
466 .ok()?;
467
468 if !output.status.success() {
469 return None;
470 }
471
472 let files: Vec<String> = String::from_utf8_lossy(&output.stdout)
473 .lines()
474 .filter(|line| !line.is_empty())
475 .map(|s| s.to_string())
476 .collect();
477
478 Some(files)
479 }
480
481 fn try_find_files(&self, cwd: &str) -> Option<Vec<String>> {
482 let output = std::process::Command::new("find")
483 .args([
484 ".",
485 "-type",
486 "f",
487 "-not",
488 "-path",
489 "*/.git/*",
490 "-not",
491 "-path",
492 "*/node_modules/*",
493 "-not",
494 "-path",
495 "*/target/*",
496 "-not",
497 "-path",
498 "*/__pycache__/*",
499 ])
500 .current_dir(cwd)
501 .output()
502 .ok()?;
503
504 if !output.status.success() {
505 return None;
506 }
507
508 let files: Vec<String> = String::from_utf8_lossy(&output.stdout)
509 .lines()
510 .filter(|line| !line.is_empty())
511 .map(|s| s.trim_start_matches("./").to_string())
512 .take(50000)
513 .collect();
514
515 Some(files)
516 }
517}
518
519impl Default for FileProvider {
520 fn default() -> Self {
521 Self::new()
522 }
523}
524
525impl QuickOpenProvider for FileProvider {
526 fn prefix(&self) -> &str {
527 ""
528 }
529
530 fn name(&self) -> &str {
531 "Files"
532 }
533
534 fn hint(&self) -> &str {
535 "Files"
536 }
537
538 fn suggestions(&self, query: &str, context: &QuickOpenContext) -> Vec<Suggestion> {
539 let files = self.load_files(&context.cwd);
540
541 if files.is_empty() {
542 return vec![Suggestion {
543 text: t!("quick_open.no_files").to_string(),
544 description: None,
545 value: None,
546 disabled: true,
547 keybinding: None,
548 source: None,
549 }];
550 }
551
552 let max_results = 100;
553
554 let mut scored_files: Vec<(FileEntry, i32)> = if query.is_empty() {
555 let mut files = files;
557 files.sort_by(|a, b| {
558 b.frecency_score
559 .partial_cmp(&a.frecency_score)
560 .unwrap_or(std::cmp::Ordering::Equal)
561 });
562 files
563 .into_iter()
564 .take(max_results)
565 .map(|f| (f, 0))
566 .collect()
567 } else {
568 files
570 .into_iter()
571 .filter_map(|file| {
572 let match_result = fuzzy_match(query, &file.relative_path);
573 if match_result.matched {
574 let frecency_boost = (file.frecency_score / 100.0).min(20.0) as i32;
576 Some((file, match_result.score + frecency_boost))
577 } else {
578 None
579 }
580 })
581 .collect()
582 };
583
584 scored_files.sort_by(|a, b| b.1.cmp(&a.1));
586 scored_files.truncate(max_results);
587
588 scored_files
589 .into_iter()
590 .map(|(file, _)| Suggestion {
591 text: file.relative_path.clone(),
592 description: None,
593 value: Some(file.relative_path),
594 disabled: false,
595 keybinding: None,
596 source: None,
597 })
598 .collect()
599 }
600
601 fn on_select(
602 &self,
603 selected_index: Option<usize>,
604 query: &str,
605 context: &QuickOpenContext,
606 ) -> QuickOpenResult {
607 let suggestions = self.suggestions(query, context);
608
609 if let Some(idx) = selected_index {
610 if let Some(suggestion) = suggestions.get(idx) {
611 if let Some(path) = &suggestion.value {
612 self.record_access(path);
614
615 return QuickOpenResult::OpenFile {
616 path: path.clone(),
617 line: None,
618 column: None,
619 };
620 }
621 }
622 }
623
624 QuickOpenResult::None
625 }
626
627 fn preview(
628 &self,
629 selected_index: usize,
630 context: &QuickOpenContext,
631 ) -> Option<(String, Option<usize>)> {
632 let suggestions = self.suggestions("", context);
633 suggestions
634 .get(selected_index)
635 .and_then(|s| s.value.clone().map(|path| (path, None)))
636 }
637}
638
639#[cfg(test)]
640mod tests {
641 use super::*;
642 use crate::input::quick_open::BufferInfo;
643
644 fn make_test_context() -> QuickOpenContext {
645 QuickOpenContext {
646 cwd: "/tmp".to_string(),
647 open_buffers: vec![
648 BufferInfo {
649 id: 1,
650 path: "/tmp/main.rs".to_string(),
651 name: "main.rs".to_string(),
652 modified: false,
653 },
654 BufferInfo {
655 id: 2,
656 path: "/tmp/lib.rs".to_string(),
657 name: "lib.rs".to_string(),
658 modified: true,
659 },
660 ],
661 active_buffer_id: 1,
662 active_buffer_path: Some("/tmp/main.rs".to_string()),
663 has_selection: false,
664 key_context: crate::input::keybindings::KeyContext::Normal,
665 custom_contexts: std::collections::HashSet::new(),
666 buffer_mode: None,
667 has_lsp_config: true,
668 }
669 }
670
671 #[test]
672 fn test_buffer_provider_suggestions() {
673 let provider = BufferProvider::new();
674 let context = make_test_context();
675
676 let suggestions = provider.suggestions("", &context);
677 assert_eq!(suggestions.len(), 2);
678
679 let lib_suggestion = suggestions
681 .iter()
682 .find(|s| s.text.contains("lib.rs"))
683 .unwrap();
684 assert!(lib_suggestion.text.contains("[+]"));
685 }
686
687 #[test]
688 fn test_buffer_provider_filter() {
689 let provider = BufferProvider::new();
690 let context = make_test_context();
691
692 let suggestions = provider.suggestions("main", &context);
693 assert_eq!(suggestions.len(), 1);
694 assert!(suggestions[0].text.contains("main.rs"));
695 }
696
697 #[test]
698 fn test_goto_line_provider() {
699 let provider = GotoLineProvider::new();
700 let context = make_test_context();
701
702 let suggestions = provider.suggestions("42", &context);
704 assert_eq!(suggestions.len(), 1);
705 assert!(!suggestions[0].disabled);
706
707 let suggestions = provider.suggestions("", &context);
709 assert_eq!(suggestions.len(), 1);
710 assert!(suggestions[0].disabled);
711
712 let suggestions = provider.suggestions("abc", &context);
714 assert_eq!(suggestions.len(), 1);
715 assert!(suggestions[0].disabled);
716 }
717
718 #[test]
719 fn test_goto_line_on_select() {
720 let provider = GotoLineProvider::new();
721 let context = make_test_context();
722
723 let result = provider.on_select(Some(0), "42", &context);
724 match result {
725 QuickOpenResult::GotoLine(line) => assert_eq!(line, 42),
726 _ => panic!("Expected GotoLine result"),
727 }
728 }
729}