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