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
36    GotoLine(usize),
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}
65
66/// Information about an open buffer
67#[derive(Debug, Clone)]
68pub struct BufferInfo {
69    pub id: usize,
70    pub path: String,
71    pub name: String,
72    pub modified: bool,
73}
74
75/// Parse a `path:line:col` string into its components.
76///
77/// Supports formats like `file.rs:10`, `file.rs:10:5`, and Windows paths with drive prefixes.
78pub fn parse_path_line_col(input: &str) -> (String, Option<usize>, Option<usize>) {
79    use std::path::{Component, Path};
80
81    let trimmed = input.trim();
82    if trimmed.is_empty() {
83        return (String::new(), None, None);
84    }
85
86    // Skip past Windows drive prefix (e.g., "C:") when looking for :line:col
87    let has_drive = Path::new(trimmed)
88        .components()
89        .next()
90        .is_some_and(|c| matches!(c, Component::Prefix(_)));
91    let search_start = if has_drive {
92        trimmed.find(':').map(|i| i + 1).unwrap_or(0)
93    } else {
94        0
95    };
96
97    let suffix = &trimmed[search_start..];
98    let parts: Vec<&str> = suffix.rsplitn(3, ':').collect();
99
100    // Reconstruct the path portion, re-attaching the drive prefix if needed
101    let rebuild_path = |rest: &str| {
102        if has_drive {
103            format!("{}{}", &trimmed[..search_start], rest)
104        } else {
105            rest.to_string()
106        }
107    };
108
109    // Try path:line:col, then path:line
110    let parsed = match parts.as_slice() {
111        [col_s, line_s, rest] if !rest.is_empty() => col_s
112            .parse::<usize>()
113            .ok()
114            .filter(|&c| c > 0)
115            .zip(line_s.parse::<usize>().ok().filter(|&l| l > 0))
116            .map(|(col, line)| (rebuild_path(rest), Some(line), Some(col))),
117        [line_s, rest] if !rest.is_empty() => line_s
118            .parse::<usize>()
119            .ok()
120            .filter(|&l| l > 0)
121            .map(|line| (rebuild_path(rest), Some(line), None)),
122        _ => None,
123    };
124
125    parsed.unwrap_or_else(|| (trimmed.to_string(), None, None))
126}
127
128/// Trait for quick open providers
129///
130/// Each provider handles a specific prefix and provides suggestions
131/// for that domain (files, commands, buffers, etc.)
132pub trait QuickOpenProvider: Send + Sync {
133    /// The prefix that triggers this provider (e.g., ">", "#", ":")
134    /// Empty string means this is the default provider (no prefix)
135    fn prefix(&self) -> &str;
136
137    /// Generate suggestions for the given query
138    ///
139    /// The query has already had the prefix stripped.
140    fn suggestions(&self, query: &str, context: &QuickOpenContext) -> Vec<Suggestion>;
141
142    /// Handle selection of a suggestion
143    ///
144    /// `suggestion` is the currently selected suggestion (already resolved by the caller).
145    /// `query` is the original query (without prefix).
146    fn on_select(
147        &self,
148        suggestion: Option<&Suggestion>,
149        query: &str,
150        context: &QuickOpenContext,
151    ) -> QuickOpenResult;
152
153    /// Downcast support for concrete provider access (e.g., updating cache).
154    fn as_any(&self) -> &dyn std::any::Any;
155}
156
157/// Registry for quick open providers
158pub struct QuickOpenRegistry {
159    /// Providers indexed by their prefix
160    providers: HashMap<String, Box<dyn QuickOpenProvider>>,
161}
162
163impl QuickOpenRegistry {
164    pub fn new() -> Self {
165        Self {
166            providers: HashMap::new(),
167        }
168    }
169
170    /// Register a provider
171    ///
172    /// If a provider with the same prefix exists, it will be replaced.
173    pub fn register(&mut self, provider: Box<dyn QuickOpenProvider>) {
174        let prefix = provider.prefix().to_string();
175        self.providers.insert(prefix, provider);
176    }
177
178    /// Get the provider for a given input
179    ///
180    /// Returns (provider, query_without_prefix)
181    pub fn get_provider_for_input<'a>(
182        &'a self,
183        input: &'a str,
184    ) -> Option<(&'a dyn QuickOpenProvider, &'a str)> {
185        // Try prefixes in order (longest first to handle overlapping prefixes)
186        let mut prefixes: Vec<_> = self.providers.keys().collect();
187        prefixes.sort_by_key(|b| std::cmp::Reverse(b.len()));
188
189        for prefix in prefixes {
190            if prefix.is_empty() {
191                continue; // Handle default provider last
192            }
193            if input.starts_with(prefix.as_str()) {
194                let query = &input[prefix.len()..];
195                return self.providers.get(prefix).map(|p| (p.as_ref(), query));
196            }
197        }
198
199        // Fall back to default provider (empty prefix)
200        self.providers.get("").map(|p| (p.as_ref(), input))
201    }
202}
203
204impl Default for QuickOpenRegistry {
205    fn default() -> Self {
206        Self::new()
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    struct TestProvider {
215        prefix: String,
216    }
217
218    impl QuickOpenProvider for TestProvider {
219        fn prefix(&self) -> &str {
220            &self.prefix
221        }
222
223        fn suggestions(&self, _query: &str, _context: &QuickOpenContext) -> Vec<Suggestion> {
224            vec![]
225        }
226
227        fn on_select(
228            &self,
229            _suggestion: Option<&Suggestion>,
230            _query: &str,
231            _context: &QuickOpenContext,
232        ) -> QuickOpenResult {
233            QuickOpenResult::None
234        }
235
236        fn as_any(&self) -> &dyn std::any::Any {
237            self
238        }
239    }
240
241    #[test]
242    fn test_provider_routing() {
243        let mut registry = QuickOpenRegistry::new();
244
245        registry.register(Box::new(TestProvider {
246            prefix: "".to_string(),
247        }));
248        registry.register(Box::new(TestProvider {
249            prefix: ">".to_string(),
250        }));
251        registry.register(Box::new(TestProvider {
252            prefix: "#".to_string(),
253        }));
254
255        // Default provider for no prefix
256        let (provider, query) = registry.get_provider_for_input("hello").unwrap();
257        assert_eq!(provider.prefix(), "");
258        assert_eq!(query, "hello");
259
260        // Command provider
261        let (provider, query) = registry.get_provider_for_input(">save").unwrap();
262        assert_eq!(provider.prefix(), ">");
263        assert_eq!(query, "save");
264
265        // Buffer provider
266        let (provider, query) = registry.get_provider_for_input("#main").unwrap();
267        assert_eq!(provider.prefix(), "#");
268        assert_eq!(query, "main");
269    }
270}