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        )
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                // Find the command by name
96                let commands = registry.get_all();
97                if let Some(cmd) = commands
98                    .iter()
99                    .find(|c| c.get_localized_name() == suggestion.text)
100                {
101                    // Record usage for frecency
102                    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
116// ============================================================================
117// Buffer Provider (prefix: "#")
118// ============================================================================
119
120/// Provider for switching between open buffers
121pub 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; // Skip unnamed buffers
155                }
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        // Sort by score (higher is better), then by ID (lower = older = higher priority when tied)
193        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
231// ============================================================================
232// Go to Line Provider (prefix: ":")
233// ============================================================================
234
235/// Provider for jumping to a specific line number
236pub 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        // Invalid input
289        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        // Try to parse from the suggestion value first, then from query
306        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
318// ============================================================================
319// File Provider (default, no prefix)
320// ============================================================================
321
322/// Provider for finding files in the project
323///
324/// This is the default provider (empty prefix) that provides file suggestions
325/// using git ls-files, fd, find, or directory traversal.
326pub struct FileProvider {
327    /// Cached file list (populated lazily)
328    file_cache: std::sync::Arc<std::sync::RwLock<Option<Vec<FileEntry>>>>,
329    /// Frecency data for ranking
330    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    /// Clear the file cache (e.g., after file system changes)
354    pub fn clear_cache(&self) {
355        if let Ok(mut cache) = self.file_cache.write() {
356            *cache = None;
357        }
358    }
359
360    /// Record file access for frecency ranking
361    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                // Mozilla-style frecency weighting
378                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    /// Load files from the project directory
399    fn load_files(&self, cwd: &str) -> Vec<FileEntry> {
400        // Check cache first
401        if let Ok(cache) = self.file_cache.read() {
402            if let Some(files) = cache.as_ref() {
403                return files.clone();
404            }
405        }
406
407        // Try different file discovery methods
408        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        // Add frecency scores
415        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        // Update cache
424        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            // Sort by frecency when no query
554            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            // Filter and score by fuzzy match
567            files
568                .into_iter()
569                .filter_map(|file| {
570                    let match_result = fuzzy_match(query, &file.relative_path);
571                    if match_result.matched {
572                        // Boost score by frecency (normalized)
573                        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        // Sort by score
583        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                    // Record access for frecency
611                    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        // Modified buffer should show [+]
677        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        // Valid line number
700        let suggestions = provider.suggestions("42", &context);
701        assert_eq!(suggestions.len(), 1);
702        assert!(!suggestions[0].disabled);
703
704        // Empty query shows hint
705        let suggestions = provider.suggestions("", &context);
706        assert_eq!(suggestions.len(), 1);
707        assert!(suggestions[0].disabled);
708
709        // Invalid input
710        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}