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