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
119            .state
120            .as_ref()
121            .map(|s| s.query.is_empty())
122            .unwrap_or(false);
123
124        if should_close {
125            return true;
126        }
127
128        if let Some(ref mut state) = self.state {
129            state.query.pop();
130            let query = state.query.clone();
131
132            let matches = self.get_matches(&query);
133
134            if let Some(ref mut state) = self.state {
135                state.matches = matches;
136                state.selected_index = 0;
137            }
138        }
139        false
140    }
141
142    /// Navigate up in matches
143    #[inline]
144    pub fn navigate_up(&mut self) {
145        if let Some(ref mut state) = self.state {
146            state.selected_index = state.selected_index.saturating_sub(1);
147        }
148    }
149
150    /// Navigate down in matches
151    #[inline]
152    pub fn navigate_down(&mut self) {
153        if let Some(ref mut state) = self.state {
154            let max_idx = state.matches.len().saturating_sub(1);
155            state.selected_index = state
156                .selected_index
157                .min(max_idx)
158                .saturating_add(1)
159                .min(max_idx);
160        }
161    }
162
163    /// Get selected match
164    #[inline]
165    pub fn selected_match(&self) -> Option<&FileMatchData> {
166        self.state
167            .as_ref()
168            .and_then(|s| s.matches.get(s.selected_index))
169    }
170
171    /// Get trigger position
172    #[inline]
173    pub fn trigger_pos(&self) -> Option<usize> {
174        self.state.as_ref().map(|s| s.trigger_pos)
175    }
176
177    /// Accept selected completion and return the text to insert
178    pub fn accept_completion(&mut self) -> Option<String> {
179        let selected = self.selected_match()?;
180
181        // Pre-allocate with space for path + space
182        let mut result = String::with_capacity(selected.path.len() + 1);
183        result.push_str(&selected.path);
184        result.push(' ');
185
186        self.state = None;
187        Some(result)
188    }
189
190    /// Get file matches for query (reuses internal buffer)
191    fn get_matches(&mut self, query: &str) -> Vec<FileMatchData> {
192        // Clear buffer for reuse
193        self.matches_buffer.clear();
194
195        // Scan files and clone to avoid holding borrow
196        let files = self.file_finder.scan_files().clone();
197
198        // Filter files (now we don't hold the borrow)
199        let matches = self.file_finder.filter_files(&files, query);
200
201        self.matches_buffer
202            .extend(matches.into_iter().map(|m| FileMatchData {
203                path: m.path.to_string_lossy().to_string(),
204                is_dir: m.is_dir,
205            }));
206
207        // Clone the buffer to return (matches_buffer is reused next call)
208        self.matches_buffer.clone()
209    }
210
211    /// Convert to legacy FileAutocompleteState for rendering
212    pub fn to_legacy_state(&self) -> Option<crate::tui::FileAutocompleteState> {
213        self.state
214            .as_ref()
215            .map(|s| crate::tui::FileAutocompleteState {
216                is_active: s.is_active,
217                query: s.query.clone(),
218                trigger_pos: s.trigger_pos,
219                matches: s.matches.clone(),
220                selected_index: s.selected_index,
221            })
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228    use std::env;
229
230    #[test]
231    fn test_autocomplete_manager_creation() {
232        let dir = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
233        let manager = FileAutocompleteManager::new(dir);
234        assert!(!manager.is_active());
235    }
236
237    #[test]
238    fn test_activate_deactivate() {
239        let dir = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
240        let mut manager = FileAutocompleteManager::new(dir);
241
242        assert!(!manager.is_active());
243
244        manager.activate(0);
245        assert!(manager.is_active());
246
247        manager.deactivate();
248        assert!(!manager.is_active());
249    }
250
251    #[test]
252    fn test_navigation() {
253        let dir = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
254        let mut manager = FileAutocompleteManager::new(dir);
255
256        manager.activate(0);
257
258        manager.navigate_up();
259        manager.navigate_down();
260    }
261
262    #[test]
263    fn test_navigation_with_empty_matches() {
264        let dir = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
265        let mut manager = FileAutocompleteManager::new(dir);
266
267        manager.activate(0);
268        manager.update_query("zzzzzzz_nonexistent_file_xyz");
269
270        manager.navigate_up();
271        manager.navigate_down();
272
273        let has_selection = manager.selected_match().is_some();
274        let match_count = manager.state().map(|s| s.matches.len()).unwrap_or(0);
275        if match_count == 0 {
276            assert!(!has_selection, "Should have no selection when no matches");
277        }
278    }
279
280    #[test]
281    fn test_accept_completion() {
282        let dir = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
283        let mut manager = FileAutocompleteManager::new(dir);
284
285        manager.activate(0);
286
287        if manager.selected_match().is_some() {
288            let result = manager.accept_completion();
289            assert!(result.is_some());
290            assert!(!manager.is_active(), "Should deactivate after accepting");
291        }
292    }
293
294    #[test]
295    fn test_accept_completion_empty() {
296        let dir = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
297        let mut manager = FileAutocompleteManager::new(dir);
298
299        manager.activate(0);
300        manager.update_query("zzzzzzz_nonexistent_file_xyz");
301
302        let result = manager.accept_completion();
303        assert!(result.is_none() || !manager.is_active());
304    }
305
306    #[test]
307    fn test_backspace_states() {
308        let dir = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
309        let mut manager = FileAutocompleteManager::new(dir);
310
311        let should_close = manager.backspace();
312        assert!(!should_close);
313
314        manager.activate(0);
315
316        let should_close = manager.backspace();
317        assert!(should_close, "Should close when query is empty");
318
319        manager.append_char('C');
320        assert!(
321            !manager.backspace(),
322            "Should not close when query has content"
323        );
324
325        let state = manager.state().unwrap();
326        assert_eq!(state.query, "");
327    }
328
329    #[test]
330    fn test_to_legacy_state() {
331        let dir = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
332        let manager = FileAutocompleteManager::new(dir);
333
334        assert!(manager.to_legacy_state().is_none());
335
336        let mut manager = manager;
337        manager.activate(5);
338
339        let legacy = manager.to_legacy_state();
340        assert!(legacy.is_some());
341
342        let legacy = legacy.unwrap();
343        assert!(legacy.is_active);
344        assert_eq!(legacy.query, "");
345        assert_eq!(legacy.trigger_pos, 5);
346        assert_eq!(legacy.selected_index, 0);
347    }
348
349    #[test]
350    fn test_update_query() {
351        let dir = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
352        let mut manager = FileAutocompleteManager::new(dir);
353
354        manager.activate(0);
355        manager.update_query("Cargo");
356
357        let state = manager.state().unwrap();
358        assert_eq!(state.query, "Cargo");
359        assert_eq!(
360            state.selected_index, 0,
361            "Should reset selection on query update"
362        );
363    }
364
365    #[test]
366    fn test_trigger_pos() {
367        let dir = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
368        let mut manager = FileAutocompleteManager::new(dir);
369
370        assert_eq!(manager.trigger_pos(), None);
371
372        manager.activate(10);
373        assert_eq!(manager.trigger_pos(), Some(10));
374
375        manager.deactivate();
376        assert_eq!(manager.trigger_pos(), None);
377    }
378
379    #[test]
380    fn test_navigation_bounds() {
381        let dir = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
382        let mut manager = FileAutocompleteManager::new(dir);
383
384        manager.activate(0);
385
386        let match_count = manager.state().map(|s| s.matches.len()).unwrap_or(0);
387
388        if match_count > 0 {
389            manager.navigate_up();
390            assert_eq!(manager.state().unwrap().selected_index, 0);
391
392            for _ in 0..match_count {
393                manager.navigate_down();
394            }
395
396            let final_index = manager.state().unwrap().selected_index;
397            assert!(final_index < match_count || match_count == 0);
398        }
399    }
400}