Skip to main content

limit_cli/tui/
autocomplete.rs

1//! File autocomplete manager for TUI
2//!
3//! Manages file path autocomplete with @ prefix.
4
5use crate::file_finder::FileFinder;
6use limit_tui::components::FileMatchData;
7use std::path::PathBuf;
8
9/// Manages file autocomplete state and operations
10pub struct FileAutocompleteManager {
11    /// Base path for file searching
12    base_path: PathBuf,
13    /// File finder instance
14    file_finder: FileFinder,
15    /// Current autocomplete state
16    state: Option<AutocompleteState>,
17    /// Reusable buffer for file matches (avoids allocations)
18    matches_buffer: Vec<FileMatchData>,
19}
20
21/// State for active autocomplete session
22#[derive(Debug, Clone, Default)]
23pub struct AutocompleteState {
24    /// Whether autocomplete is active
25    pub is_active: bool,
26    /// Query typed after @
27    pub query: String,
28    /// Position of @ trigger in input
29    pub trigger_pos: usize,
30    /// Matching files
31    pub matches: Vec<FileMatchData>,
32    /// Currently selected index
33    pub selected_index: usize,
34}
35
36impl FileAutocompleteManager {
37    /// Create a new autocomplete manager
38    pub fn new(working_dir: PathBuf) -> Self {
39        Self {
40            base_path: working_dir.clone(),
41            file_finder: FileFinder::new(working_dir),
42            state: None,
43            matches_buffer: Vec::with_capacity(64),
44        }
45    }
46
47    /// Get the base path for file searching
48    #[inline]
49    pub fn base_path(&self) -> &PathBuf {
50        &self.base_path
51    }
52
53    /// Check if autocomplete is currently active
54    #[inline]
55    pub fn is_active(&self) -> bool {
56        self.state.as_ref().is_some_and(|s| s.is_active)
57    }
58
59    /// Get current autocomplete state
60    #[inline]
61    pub fn state(&self) -> Option<&AutocompleteState> {
62        self.state.as_ref()
63    }
64
65    /// Get mutable autocomplete state
66    #[inline]
67    pub fn state_mut(&mut self) -> Option<&mut AutocompleteState> {
68        self.state.as_mut()
69    }
70
71    /// Activate autocomplete at the given trigger position
72    pub fn activate(&mut self, trigger_pos: usize) {
73        let matches = self.get_matches("");
74
75        self.state = Some(AutocompleteState {
76            is_active: true,
77            query: String::with_capacity(64),
78            trigger_pos,
79            matches,
80            selected_index: 0,
81        });
82
83        tracing::debug!("Activated autocomplete at pos {}", trigger_pos);
84    }
85
86    /// Deactivate autocomplete
87    #[inline]
88    pub fn deactivate(&mut self) {
89        self.state = None;
90    }
91
92    /// Update the query and refresh matches
93    pub fn update_query(&mut self, query: &str) {
94        if let Some(ref mut state) = self.state {
95            // First update query
96            state.query.clear();
97            state.query.push_str(query);
98
99            // Get matches separately to avoid borrow conflicts
100            let matches = self.get_matches(query);
101
102            // Update state
103            if let Some(ref mut state) = self.state {
104                state.matches = matches;
105                state.selected_index = 0;
106            }
107        }
108    }
109
110    /// Add a character to the query (avoids String allocation)
111    pub fn append_char(&mut self, c: char) {
112        if let Some(ref mut state) = self.state {
113            state.query.push(c);
114            let query = state.query.clone();
115
116            let matches = self.get_matches(&query);
117
118            if let Some(ref mut state) = self.state {
119                state.matches = matches;
120                state.selected_index = 0;
121            }
122        }
123    }
124
125    /// Remove last character from query
126    pub fn backspace(&mut self) -> bool {
127        let should_close = self
128            .state
129            .as_ref()
130            .map(|s| s.query.is_empty())
131            .unwrap_or(false);
132
133        if should_close {
134            return true;
135        }
136
137        if let Some(ref mut state) = self.state {
138            state.query.pop();
139            let query = state.query.clone();
140
141            let matches = self.get_matches(&query);
142
143            if let Some(ref mut state) = self.state {
144                state.matches = matches;
145                state.selected_index = 0;
146            }
147        }
148        false
149    }
150
151    /// Navigate up in matches
152    #[inline]
153    pub fn navigate_up(&mut self) {
154        if let Some(ref mut state) = self.state {
155            state.selected_index = state.selected_index.saturating_sub(1);
156        }
157    }
158
159    /// Navigate down in matches
160    #[inline]
161    pub fn navigate_down(&mut self) {
162        if let Some(ref mut state) = self.state {
163            let max_idx = state.matches.len().saturating_sub(1);
164            state.selected_index = state
165                .selected_index
166                .min(max_idx)
167                .saturating_add(1)
168                .min(max_idx);
169        }
170    }
171
172    /// Get selected match
173    #[inline]
174    pub fn selected_match(&self) -> Option<&FileMatchData> {
175        self.state
176            .as_ref()
177            .and_then(|s| s.matches.get(s.selected_index))
178    }
179
180    /// Get trigger position
181    #[inline]
182    pub fn trigger_pos(&self) -> Option<usize> {
183        self.state.as_ref().map(|s| s.trigger_pos)
184    }
185
186    /// Accept selected completion and return the text to insert
187    pub fn accept_completion(&mut self) -> Option<String> {
188        let selected = self.selected_match()?;
189
190        // Pre-allocate with space for path + space
191        let mut result = String::with_capacity(selected.path.len() + 1);
192        result.push_str(&selected.path);
193        result.push(' ');
194
195        self.state = None;
196        Some(result)
197    }
198
199    /// Get file matches for query (reuses internal buffer)
200    fn get_matches(&mut self, query: &str) -> Vec<FileMatchData> {
201        // Clear buffer for reuse
202        self.matches_buffer.clear();
203
204        // Scan files and clone to avoid holding borrow
205        let files = self.file_finder.scan_files().clone();
206
207        // Filter files (now we don't hold the borrow)
208        let matches = self.file_finder.filter_files(&files, query);
209
210        self.matches_buffer
211            .extend(matches.into_iter().map(|m| FileMatchData {
212                path: m.path.to_string_lossy().to_string(),
213                is_dir: m.is_dir,
214            }));
215
216        // Clone the buffer to return (matches_buffer is reused next call)
217        self.matches_buffer.clone()
218    }
219
220    /// Convert to legacy FileAutocompleteState for rendering
221    pub fn to_legacy_state(&self) -> Option<crate::tui::FileAutocompleteState> {
222        self.state
223            .as_ref()
224            .map(|s| crate::tui::FileAutocompleteState {
225                is_active: s.is_active,
226                query: s.query.clone(),
227                trigger_pos: s.trigger_pos,
228                matches: s.matches.clone(),
229                selected_index: s.selected_index,
230            })
231    }
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237    use std::env;
238
239    #[test]
240    fn test_autocomplete_manager_creation() {
241        let dir = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
242        let manager = FileAutocompleteManager::new(dir);
243        assert!(!manager.is_active());
244    }
245
246    #[test]
247    fn test_activate_deactivate() {
248        let dir = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
249        let mut manager = FileAutocompleteManager::new(dir);
250
251        assert!(!manager.is_active());
252
253        manager.activate(0);
254        assert!(manager.is_active());
255
256        manager.deactivate();
257        assert!(!manager.is_active());
258    }
259
260    #[test]
261    fn test_navigation() {
262        let dir = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
263        let mut manager = FileAutocompleteManager::new(dir);
264
265        manager.activate(0);
266
267        manager.navigate_up();
268        manager.navigate_down();
269    }
270
271    #[test]
272    fn test_navigation_with_empty_matches() {
273        let dir = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
274        let mut manager = FileAutocompleteManager::new(dir);
275
276        manager.activate(0);
277        manager.update_query("zzzzzzz_nonexistent_file_xyz");
278
279        manager.navigate_up();
280        manager.navigate_down();
281
282        let has_selection = manager.selected_match().is_some();
283        let match_count = manager.state().map(|s| s.matches.len()).unwrap_or(0);
284        if match_count == 0 {
285            assert!(!has_selection, "Should have no selection when no matches");
286        }
287    }
288
289    #[test]
290    fn test_accept_completion() {
291        let dir = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
292        let mut manager = FileAutocompleteManager::new(dir);
293
294        manager.activate(0);
295
296        if manager.selected_match().is_some() {
297            let result = manager.accept_completion();
298            assert!(result.is_some());
299            assert!(!manager.is_active(), "Should deactivate after accepting");
300        }
301    }
302
303    #[test]
304    fn test_accept_completion_empty() {
305        let dir = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
306        let mut manager = FileAutocompleteManager::new(dir);
307
308        manager.activate(0);
309        manager.update_query("zzzzzzz_nonexistent_file_xyz");
310
311        let result = manager.accept_completion();
312        assert!(result.is_none() || !manager.is_active());
313    }
314
315    #[test]
316    fn test_backspace_states() {
317        let dir = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
318        let mut manager = FileAutocompleteManager::new(dir);
319
320        let should_close = manager.backspace();
321        assert!(!should_close);
322
323        manager.activate(0);
324
325        let should_close = manager.backspace();
326        assert!(should_close, "Should close when query is empty");
327
328        manager.append_char('C');
329        assert!(
330            !manager.backspace(),
331            "Should not close when query has content"
332        );
333
334        let state = manager.state().unwrap();
335        assert_eq!(state.query, "");
336    }
337
338    #[test]
339    fn test_to_legacy_state() {
340        let dir = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
341        let manager = FileAutocompleteManager::new(dir);
342
343        assert!(manager.to_legacy_state().is_none());
344
345        let mut manager = manager;
346        manager.activate(5);
347
348        let legacy = manager.to_legacy_state();
349        assert!(legacy.is_some());
350
351        let legacy = legacy.unwrap();
352        assert!(legacy.is_active);
353        assert_eq!(legacy.query, "");
354        assert_eq!(legacy.trigger_pos, 5);
355        assert_eq!(legacy.selected_index, 0);
356    }
357
358    #[test]
359    fn test_update_query() {
360        let dir = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
361        let mut manager = FileAutocompleteManager::new(dir);
362
363        manager.activate(0);
364        manager.update_query("Cargo");
365
366        let state = manager.state().unwrap();
367        assert_eq!(state.query, "Cargo");
368        assert_eq!(
369            state.selected_index, 0,
370            "Should reset selection on query update"
371        );
372    }
373
374    #[test]
375    fn test_trigger_pos() {
376        let dir = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
377        let mut manager = FileAutocompleteManager::new(dir);
378
379        assert_eq!(manager.trigger_pos(), None);
380
381        manager.activate(10);
382        assert_eq!(manager.trigger_pos(), Some(10));
383
384        manager.deactivate();
385        assert_eq!(manager.trigger_pos(), None);
386    }
387
388    #[test]
389    fn test_navigation_bounds() {
390        let dir = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
391        let mut manager = FileAutocompleteManager::new(dir);
392
393        manager.activate(0);
394
395        let match_count = manager.state().map(|s| s.matches.len()).unwrap_or(0);
396
397        if match_count > 0 {
398            manager.navigate_up();
399            assert_eq!(manager.state().unwrap().selected_index, 0);
400
401            for _ in 0..match_count {
402                manager.navigate_down();
403            }
404
405            let final_index = manager.state().unwrap().selected_index;
406            assert!(final_index < match_count || match_count == 0);
407        }
408    }
409}