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(GotoLineTarget),
37    /// Do nothing (provider handled it internally)
38    None,
39    /// Show an error message
40    Error(String),
41}
42
43/// A parsed goto-line target. The presence of an explicit sign in the user's
44/// input chooses between absolute and relative jumps independently of the
45/// `relative_line_numbers` display setting.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum GotoLineTarget {
48    /// Absolute 1-based line number (input had no leading sign).
49    Absolute(usize),
50    /// Signed offset from the current cursor line (input had `+`/`-` prefix).
51    Relative(isize),
52}
53
54/// Parse a goto-line input string.
55///
56/// - `"500"` → `Absolute(500)`
57/// - `"+3"` → `Relative(3)`
58/// - `"-3"` → `Relative(-3)`
59/// - `"0"`, `"+0"`, `"-0"`, `""`, `"abc"` → `None`
60///
61/// Whitespace around the input is ignored. The leading-sign convention is
62/// independent of any display setting: the user's literal input decides.
63pub fn parse_goto_line_input(input: &str) -> Option<GotoLineTarget> {
64    let trimmed = input.trim();
65    if trimmed.is_empty() {
66        return None;
67    }
68    if let Some(rest) = trimmed
69        .strip_prefix('+')
70        .or_else(|| trimmed.strip_prefix('-'))
71    {
72        // Reject bare "+"/"-" and avoid double signs like "++3" or "+-3".
73        if rest.is_empty() || rest.starts_with('+') || rest.starts_with('-') {
74            return None;
75        }
76        let delta = trimmed.parse::<isize>().ok()?;
77        if delta == 0 {
78            return None;
79        }
80        Some(GotoLineTarget::Relative(delta))
81    } else {
82        let n = trimmed.parse::<usize>().ok()?;
83        if n == 0 {
84            return None;
85        }
86        Some(GotoLineTarget::Absolute(n))
87    }
88}
89
90/// Context provided to providers when generating suggestions
91#[derive(Debug, Clone)]
92pub struct QuickOpenContext {
93    /// Current working directory
94    pub cwd: String,
95    /// List of open buffer paths
96    pub open_buffers: Vec<BufferInfo>,
97    /// Active buffer ID
98    pub active_buffer_id: usize,
99    /// Active buffer path (if any)
100    pub active_buffer_path: Option<String>,
101    /// Whether there's an active selection
102    pub has_selection: bool,
103    /// Current key context
104    pub key_context: crate::input::keybindings::KeyContext,
105    /// Active custom contexts (for command filtering)
106    pub custom_contexts: std::collections::HashSet<String>,
107    /// Active buffer mode (e.g., "vi_normal")
108    pub buffer_mode: Option<String>,
109    /// Whether the active buffer's language has an LSP server configured
110    pub has_lsp_config: bool,
111    /// Whether relative line numbers are enabled
112    pub relative_line_numbers: bool,
113}
114
115/// Information about an open buffer
116#[derive(Debug, Clone)]
117pub struct BufferInfo {
118    pub id: usize,
119    pub path: String,
120    pub name: String,
121    pub modified: bool,
122}
123
124/// Parse a `path:line:col` string into its components.
125///
126/// Supports formats like `file.rs:10`, `file.rs:10:5`, and Windows paths with drive prefixes.
127pub fn parse_path_line_col(input: &str) -> (String, Option<usize>, Option<usize>) {
128    use std::path::{Component, Path};
129
130    let trimmed = input.trim();
131    if trimmed.is_empty() {
132        return (String::new(), None, None);
133    }
134
135    // Skip past Windows drive prefix (e.g., "C:") when looking for :line:col
136    let has_drive = Path::new(trimmed)
137        .components()
138        .next()
139        .is_some_and(|c| matches!(c, Component::Prefix(_)));
140    let search_start = if has_drive {
141        trimmed.find(':').map(|i| i + 1).unwrap_or(0)
142    } else {
143        0
144    };
145
146    let suffix = &trimmed[search_start..];
147    let parts: Vec<&str> = suffix.rsplitn(3, ':').collect();
148
149    // Reconstruct the path portion, re-attaching the drive prefix if needed
150    let rebuild_path = |rest: &str| {
151        if has_drive {
152            format!("{}{}", &trimmed[..search_start], rest)
153        } else {
154            rest.to_string()
155        }
156    };
157
158    // Try path:line:col, then path:line
159    let parsed = match parts.as_slice() {
160        [col_s, line_s, rest] if !rest.is_empty() => col_s
161            .parse::<usize>()
162            .ok()
163            .filter(|&c| c > 0)
164            .zip(line_s.parse::<usize>().ok().filter(|&l| l > 0))
165            .map(|(col, line)| (rebuild_path(rest), Some(line), Some(col))),
166        [line_s, rest] if !rest.is_empty() => line_s
167            .parse::<usize>()
168            .ok()
169            .filter(|&l| l > 0)
170            .map(|line| (rebuild_path(rest), Some(line), None)),
171        _ => None,
172    };
173
174    parsed.unwrap_or_else(|| (trimmed.to_string(), None, None))
175}
176
177/// Trait for quick open providers
178///
179/// Each provider handles a specific prefix and provides suggestions
180/// for that domain (files, commands, buffers, etc.)
181pub trait QuickOpenProvider: Send + Sync {
182    /// The prefix that triggers this provider (e.g., ">", "#", ":")
183    /// Empty string means this is the default provider (no prefix)
184    fn prefix(&self) -> &str;
185
186    /// Generate suggestions for the given query
187    ///
188    /// The query has already had the prefix stripped.
189    fn suggestions(&self, query: &str, context: &QuickOpenContext) -> Vec<Suggestion>;
190
191    /// Handle selection of a suggestion
192    ///
193    /// `suggestion` is the currently selected suggestion (already resolved by the caller).
194    /// `query` is the original query (without prefix).
195    fn on_select(
196        &self,
197        suggestion: Option<&Suggestion>,
198        query: &str,
199        context: &QuickOpenContext,
200    ) -> QuickOpenResult;
201
202    /// Downcast support for concrete provider access (e.g., updating cache).
203    fn as_any(&self) -> &dyn std::any::Any;
204
205    /// Mutable downcast support, for re-pointing concrete provider state
206    /// (e.g. swapping the `FileProvider`'s filesystem + process spawner when
207    /// the active authority changes).
208    fn as_any_mut(&mut self) -> &mut dyn std::any::Any;
209}
210
211/// Registry for quick open providers
212pub struct QuickOpenRegistry {
213    /// Providers indexed by their prefix
214    providers: HashMap<String, Box<dyn QuickOpenProvider>>,
215}
216
217impl QuickOpenRegistry {
218    pub fn new() -> Self {
219        Self {
220            providers: HashMap::new(),
221        }
222    }
223
224    /// Register a provider
225    ///
226    /// If a provider with the same prefix exists, it will be replaced.
227    pub fn register(&mut self, provider: Box<dyn QuickOpenProvider>) {
228        let prefix = provider.prefix().to_string();
229        self.providers.insert(prefix, provider);
230    }
231
232    /// Re-point the file provider's backends at the active authority's
233    /// filesystem + process spawner. The `FileProvider` captures these at
234    /// construction, so an *in-place* authority swap (`set_boot_authority` /
235    /// `set_session_authority`, used by the test harness and devcontainer
236    /// attach, which simulate the restart inline) would otherwise leave
237    /// quick-open's `git ls-files` fast path running against the old backend —
238    /// listing the previous authority's files. Call this whenever the
239    /// authority changes without a full reconstruction.
240    pub fn set_file_backends(
241        &mut self,
242        filesystem: std::sync::Arc<dyn crate::model::filesystem::FileSystem + Send + Sync>,
243        process_spawner: std::sync::Arc<dyn crate::services::remote::ProcessSpawner>,
244    ) {
245        for provider in self.providers.values_mut() {
246            if let Some(fp) = provider.as_any_mut().downcast_mut::<FileProvider>() {
247                fp.set_backends(filesystem, process_spawner);
248                return;
249            }
250        }
251    }
252
253    /// Get the provider for a given input
254    ///
255    /// Returns (provider, query_without_prefix)
256    pub fn get_provider_for_input<'a>(
257        &'a self,
258        input: &'a str,
259    ) -> Option<(&'a dyn QuickOpenProvider, &'a str)> {
260        // Try prefixes in order (longest first to handle overlapping prefixes)
261        let mut prefixes: Vec<_> = self.providers.keys().collect();
262        prefixes.sort_by_key(|b| std::cmp::Reverse(b.len()));
263
264        for prefix in prefixes {
265            if prefix.is_empty() {
266                continue; // Handle default provider last
267            }
268            if input.starts_with(prefix.as_str()) {
269                let query = &input[prefix.len()..];
270                return self.providers.get(prefix).map(|p| (p.as_ref(), query));
271            }
272        }
273
274        // Fall back to default provider (empty prefix)
275        self.providers.get("").map(|p| (p.as_ref(), input))
276    }
277}
278
279impl Default for QuickOpenRegistry {
280    fn default() -> Self {
281        Self::new()
282    }
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288
289    struct TestProvider {
290        prefix: String,
291    }
292
293    impl QuickOpenProvider for TestProvider {
294        fn prefix(&self) -> &str {
295            &self.prefix
296        }
297
298        fn suggestions(&self, _query: &str, _context: &QuickOpenContext) -> Vec<Suggestion> {
299            vec![]
300        }
301
302        fn on_select(
303            &self,
304            _suggestion: Option<&Suggestion>,
305            _query: &str,
306            _context: &QuickOpenContext,
307        ) -> QuickOpenResult {
308            QuickOpenResult::None
309        }
310
311        fn as_any(&self) -> &dyn std::any::Any {
312            self
313        }
314
315        fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
316            self
317        }
318    }
319
320    #[test]
321    fn parse_goto_line_input_absolute() {
322        assert_eq!(
323            parse_goto_line_input("500"),
324            Some(GotoLineTarget::Absolute(500))
325        );
326        assert_eq!(
327            parse_goto_line_input("  42 "),
328            Some(GotoLineTarget::Absolute(42))
329        );
330    }
331
332    #[test]
333    fn parse_goto_line_input_relative() {
334        assert_eq!(
335            parse_goto_line_input("+3"),
336            Some(GotoLineTarget::Relative(3))
337        );
338        assert_eq!(
339            parse_goto_line_input("-3"),
340            Some(GotoLineTarget::Relative(-3))
341        );
342        assert_eq!(
343            parse_goto_line_input(" -10 "),
344            Some(GotoLineTarget::Relative(-10))
345        );
346    }
347
348    #[test]
349    fn parse_goto_line_input_rejects_invalid() {
350        assert_eq!(parse_goto_line_input(""), None);
351        assert_eq!(parse_goto_line_input("   "), None);
352        assert_eq!(parse_goto_line_input("0"), None);
353        assert_eq!(parse_goto_line_input("+0"), None);
354        assert_eq!(parse_goto_line_input("-0"), None);
355        assert_eq!(parse_goto_line_input("+"), None);
356        assert_eq!(parse_goto_line_input("-"), None);
357        assert_eq!(parse_goto_line_input("++3"), None);
358        assert_eq!(parse_goto_line_input("+-3"), None);
359        assert_eq!(parse_goto_line_input("abc"), None);
360        assert_eq!(parse_goto_line_input("3a"), None);
361    }
362
363    #[test]
364    fn test_provider_routing() {
365        let mut registry = QuickOpenRegistry::new();
366
367        registry.register(Box::new(TestProvider {
368            prefix: "".to_string(),
369        }));
370        registry.register(Box::new(TestProvider {
371            prefix: ">".to_string(),
372        }));
373        registry.register(Box::new(TestProvider {
374            prefix: "#".to_string(),
375        }));
376
377        // Default provider for no prefix
378        let (provider, query) = registry.get_provider_for_input("hello").unwrap();
379        assert_eq!(provider.prefix(), "");
380        assert_eq!(query, "hello");
381
382        // Command provider
383        let (provider, query) = registry.get_provider_for_input(">save").unwrap();
384        assert_eq!(provider.prefix(), ">");
385        assert_eq!(query, "save");
386
387        // Buffer provider
388        let (provider, query) = registry.get_provider_for_input("#main").unwrap();
389        assert_eq!(provider.prefix(), "#");
390        assert_eq!(query, "main");
391    }
392}