Skip to main content

fresh/input/quick_open/
mod.rs

1//! Quick Open Provider System
2//!
3//! A unified prompt system with prefix-based routing to different providers.
4//! Inspired by VSCode's Quick Open (Ctrl+P) which supports:
5//! - Empty prefix: file finder
6//! - `>`: command palette
7//! - `#`: symbol finder (future)
8//! - `@`: go to symbol in file (future)
9//! - `:`: go to line
10//!
11//! Providers are registered with a prefix and handle suggestion generation
12//! and selection for their domain.
13
14pub mod providers;
15
16pub use providers::{BufferProvider, CommandProvider, FileProvider, GotoLineProvider};
17
18use crate::input::commands::Suggestion;
19use crate::input::keybindings::Action;
20use std::collections::HashMap;
21
22/// Result of confirming a selection in a provider
23#[derive(Debug, Clone)]
24pub enum QuickOpenResult {
25    /// Execute an editor action
26    ExecuteAction(Action),
27    /// Open a file at optional line/column
28    OpenFile {
29        path: String,
30        line: Option<usize>,
31        column: Option<usize>,
32    },
33    /// Show a buffer by ID
34    ShowBuffer(usize),
35    /// Go to a line in the current buffer (positive = absolute line, negative = relative to current)
36    GotoLine(isize),
37    /// Do nothing (provider handled it internally)
38    None,
39    /// Show an error message
40    Error(String),
41}
42
43/// Context provided to providers when generating suggestions
44#[derive(Debug, Clone)]
45pub struct QuickOpenContext {
46    /// Current working directory
47    pub cwd: String,
48    /// List of open buffer paths
49    pub open_buffers: Vec<BufferInfo>,
50    /// Active buffer ID
51    pub active_buffer_id: usize,
52    /// Active buffer path (if any)
53    pub active_buffer_path: Option<String>,
54    /// Whether there's an active selection
55    pub has_selection: bool,
56    /// Current key context
57    pub key_context: crate::input::keybindings::KeyContext,
58    /// Active custom contexts (for command filtering)
59    pub custom_contexts: std::collections::HashSet<String>,
60    /// Active buffer mode (e.g., "vi_normal")
61    pub buffer_mode: Option<String>,
62    /// Whether the active buffer's language has an LSP server configured
63    pub has_lsp_config: bool,
64    /// Whether relative line numbers are enabled
65    pub relative_line_numbers: bool,
66}
67
68/// Information about an open buffer
69#[derive(Debug, Clone)]
70pub struct BufferInfo {
71    pub id: usize,
72    pub path: String,
73    pub name: String,
74    pub modified: bool,
75}
76
77/// Parse a `path:line:col` string into its components.
78///
79/// Supports formats like `file.rs:10`, `file.rs:10:5`, and Windows paths with drive prefixes.
80pub fn parse_path_line_col(input: &str) -> (String, Option<usize>, Option<usize>) {
81    use std::path::{Component, Path};
82
83    let trimmed = input.trim();
84    if trimmed.is_empty() {
85        return (String::new(), None, None);
86    }
87
88    // Skip past Windows drive prefix (e.g., "C:") when looking for :line:col
89    let has_drive = Path::new(trimmed)
90        .components()
91        .next()
92        .is_some_and(|c| matches!(c, Component::Prefix(_)));
93    let search_start = if has_drive {
94        trimmed.find(':').map(|i| i + 1).unwrap_or(0)
95    } else {
96        0
97    };
98
99    let suffix = &trimmed[search_start..];
100    let parts: Vec<&str> = suffix.rsplitn(3, ':').collect();
101
102    // Reconstruct the path portion, re-attaching the drive prefix if needed
103    let rebuild_path = |rest: &str| {
104        if has_drive {
105            format!("{}{}", &trimmed[..search_start], rest)
106        } else {
107            rest.to_string()
108        }
109    };
110
111    // Try path:line:col, then path:line
112    let parsed = match parts.as_slice() {
113        [col_s, line_s, rest] if !rest.is_empty() => col_s
114            .parse::<usize>()
115            .ok()
116            .filter(|&c| c > 0)
117            .zip(line_s.parse::<usize>().ok().filter(|&l| l > 0))
118            .map(|(col, line)| (rebuild_path(rest), Some(line), Some(col))),
119        [line_s, rest] if !rest.is_empty() => line_s
120            .parse::<usize>()
121            .ok()
122            .filter(|&l| l > 0)
123            .map(|line| (rebuild_path(rest), Some(line), None)),
124        _ => None,
125    };
126
127    parsed.unwrap_or_else(|| (trimmed.to_string(), None, None))
128}
129
130/// Trait for quick open providers
131///
132/// Each provider handles a specific prefix and provides suggestions
133/// for that domain (files, commands, buffers, etc.)
134pub trait QuickOpenProvider: Send + Sync {
135    /// The prefix that triggers this provider (e.g., ">", "#", ":")
136    /// Empty string means this is the default provider (no prefix)
137    fn prefix(&self) -> &str;
138
139    /// Generate suggestions for the given query
140    ///
141    /// The query has already had the prefix stripped.
142    fn suggestions(&self, query: &str, context: &QuickOpenContext) -> Vec<Suggestion>;
143
144    /// Handle selection of a suggestion
145    ///
146    /// `suggestion` is the currently selected suggestion (already resolved by the caller).
147    /// `query` is the original query (without prefix).
148    fn on_select(
149        &self,
150        suggestion: Option<&Suggestion>,
151        query: &str,
152        context: &QuickOpenContext,
153    ) -> QuickOpenResult;
154
155    /// Downcast support for concrete provider access (e.g., updating cache).
156    fn as_any(&self) -> &dyn std::any::Any;
157}
158
159/// Registry for quick open providers
160pub struct QuickOpenRegistry {
161    /// Providers indexed by their prefix
162    providers: HashMap<String, Box<dyn QuickOpenProvider>>,
163}
164
165impl QuickOpenRegistry {
166    pub fn new() -> Self {
167        Self {
168            providers: HashMap::new(),
169        }
170    }
171
172    /// Register a provider
173    ///
174    /// If a provider with the same prefix exists, it will be replaced.
175    pub fn register(&mut self, provider: Box<dyn QuickOpenProvider>) {
176        let prefix = provider.prefix().to_string();
177        self.providers.insert(prefix, provider);
178    }
179
180    /// Get the provider for a given input
181    ///
182    /// Returns (provider, query_without_prefix)
183    pub fn get_provider_for_input<'a>(
184        &'a self,
185        input: &'a str,
186    ) -> Option<(&'a dyn QuickOpenProvider, &'a str)> {
187        // Try prefixes in order (longest first to handle overlapping prefixes)
188        let mut prefixes: Vec<_> = self.providers.keys().collect();
189        prefixes.sort_by_key(|b| std::cmp::Reverse(b.len()));
190
191        for prefix in prefixes {
192            if prefix.is_empty() {
193                continue; // Handle default provider last
194            }
195            if input.starts_with(prefix.as_str()) {
196                let query = &input[prefix.len()..];
197                return self.providers.get(prefix).map(|p| (p.as_ref(), query));
198            }
199        }
200
201        // Fall back to default provider (empty prefix)
202        self.providers.get("").map(|p| (p.as_ref(), input))
203    }
204}
205
206impl Default for QuickOpenRegistry {
207    fn default() -> Self {
208        Self::new()
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215
216    struct TestProvider {
217        prefix: String,
218    }
219
220    impl QuickOpenProvider for TestProvider {
221        fn prefix(&self) -> &str {
222            &self.prefix
223        }
224
225        fn suggestions(&self, _query: &str, _context: &QuickOpenContext) -> Vec<Suggestion> {
226            vec![]
227        }
228
229        fn on_select(
230            &self,
231            _suggestion: Option<&Suggestion>,
232            _query: &str,
233            _context: &QuickOpenContext,
234        ) -> QuickOpenResult {
235            QuickOpenResult::None
236        }
237
238        fn as_any(&self) -> &dyn std::any::Any {
239            self
240        }
241    }
242
243    #[test]
244    fn test_provider_routing() {
245        let mut registry = QuickOpenRegistry::new();
246
247        registry.register(Box::new(TestProvider {
248            prefix: "".to_string(),
249        }));
250        registry.register(Box::new(TestProvider {
251            prefix: ">".to_string(),
252        }));
253        registry.register(Box::new(TestProvider {
254            prefix: "#".to_string(),
255        }));
256
257        // Default provider for no prefix
258        let (provider, query) = registry.get_provider_for_input("hello").unwrap();
259        assert_eq!(provider.prefix(), "");
260        assert_eq!(query, "hello");
261
262        // Command provider
263        let (provider, query) = registry.get_provider_for_input(">save").unwrap();
264        assert_eq!(provider.prefix(), ">");
265        assert_eq!(query, "save");
266
267        // Buffer provider
268        let (provider, query) = registry.get_provider_for_input("#main").unwrap();
269        assert_eq!(provider.prefix(), "#");
270        assert_eq!(query, "main");
271    }
272}