Skip to main content

fresh/input/quick_open/
providers.rs

1//! Built-in Quick Open Providers
2//!
3//! This module contains the standard providers:
4//! - FileProvider: Find files in the project (default, no prefix)
5//! - CommandProvider: Command palette (prefix: ">")
6//! - BufferProvider: Switch between open buffers (prefix: "#")
7//! - GotoLineProvider: Go to a specific line (prefix: ":")
8
9use super::{QuickOpenContext, QuickOpenProvider, QuickOpenResult};
10use crate::input::commands::Suggestion;
11use crate::input::fuzzy::fuzzy_match;
12use rust_i18n::t;
13
14// ============================================================================
15// Command Provider (prefix: ">")
16// ============================================================================
17
18/// Provider for the command palette
19pub struct CommandProvider {
20    /// Reference to the command registry for filtering
21    command_registry:
22        std::sync::Arc<std::sync::RwLock<crate::input::command_registry::CommandRegistry>>,
23    /// Keybinding resolver for showing shortcuts
24    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                // Find the command by name
98                let commands = registry.get_all();
99                if let Some(cmd) = commands
100                    .iter()
101                    .find(|c| c.get_localized_name() == suggestion.text)
102                {
103                    // Record usage for frecency
104                    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
118// ============================================================================
119// Buffer Provider (prefix: "#")
120// ============================================================================
121
122/// Provider for switching between open buffers
123pub 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; // Skip unnamed buffers
157                }
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        // Sort by score (higher is better), then by ID (lower = older = higher priority when tied)
195        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
233// ============================================================================
234// Go to Line Provider (prefix: ":")
235// ============================================================================
236
237/// Provider for jumping to a specific line number
238pub 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        // Invalid input
291        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        // Try to parse from the suggestion value first, then from query
308        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
320// ============================================================================
321// File Provider (default, no prefix)
322// ============================================================================
323
324/// Provider for finding files in the project
325///
326/// This is the default provider (empty prefix) that provides file suggestions
327/// using git ls-files, fd, find, or directory traversal.
328pub struct FileProvider {
329    /// Cached file list (populated lazily)
330    file_cache: std::sync::Arc<std::sync::RwLock<Option<Vec<FileEntry>>>>,
331    /// Frecency data for ranking
332    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    /// Clear the file cache (e.g., after file system changes)
356    pub fn clear_cache(&self) {
357        if let Ok(mut cache) = self.file_cache.write() {
358            *cache = None;
359        }
360    }
361
362    /// Record file access for frecency ranking
363    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                // Mozilla-style frecency weighting
380                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    /// Load files from the project directory
401    fn load_files(&self, cwd: &str) -> Vec<FileEntry> {
402        // Check cache first
403        if let Ok(cache) = self.file_cache.read() {
404            if let Some(files) = cache.as_ref() {
405                return files.clone();
406            }
407        }
408
409        // Try different file discovery methods
410        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        // Add frecency scores
417        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        // Update cache
426        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            // Sort by frecency when no query
556            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            // Filter and score by fuzzy match
569            files
570                .into_iter()
571                .filter_map(|file| {
572                    let match_result = fuzzy_match(query, &file.relative_path);
573                    if match_result.matched {
574                        // Boost score by frecency (normalized)
575                        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        // Sort by score
585        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                    // Record access for frecency
613                    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        // Modified buffer should show [+]
680        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        // Valid line number
703        let suggestions = provider.suggestions("42", &context);
704        assert_eq!(suggestions.len(), 1);
705        assert!(!suggestions[0].disabled);
706
707        // Empty query shows hint
708        let suggestions = provider.suggestions("", &context);
709        assert_eq!(suggestions.len(), 1);
710        assert!(suggestions[0].disabled);
711
712        // Invalid input
713        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}