limit_cli/tui/
autocomplete.rs1use crate::file_finder::FileFinder;
6use limit_tui::components::FileMatchData;
7use std::path::PathBuf;
8
9pub struct FileAutocompleteManager {
11 file_finder: FileFinder,
13 state: Option<AutocompleteState>,
15 matches_buffer: Vec<FileMatchData>,
17}
18
19#[derive(Debug, Clone, Default)]
21pub struct AutocompleteState {
22 pub is_active: bool,
24 pub query: String,
26 pub trigger_pos: usize,
28 pub matches: Vec<FileMatchData>,
30 pub selected_index: usize,
32}
33
34impl FileAutocompleteManager {
35 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 #[inline]
46 pub fn is_active(&self) -> bool {
47 self.state.as_ref().is_some_and(|s| s.is_active)
48 }
49
50 #[inline]
52 pub fn state(&self) -> Option<&AutocompleteState> {
53 self.state.as_ref()
54 }
55
56 #[inline]
58 pub fn state_mut(&mut self) -> Option<&mut AutocompleteState> {
59 self.state.as_mut()
60 }
61
62 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 #[inline]
79 pub fn deactivate(&mut self) {
80 self.state = None;
81 }
82
83 pub fn update_query(&mut self, query: &str) {
85 if let Some(ref mut state) = self.state {
86 state.query.clear();
88 state.query.push_str(query);
89
90 let matches = self.get_matches(query);
92
93 if let Some(ref mut state) = self.state {
95 state.matches = matches;
96 state.selected_index = 0;
97 }
98 }
99 }
100
101 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 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 #[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 #[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 #[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 #[inline]
163 pub fn trigger_pos(&self) -> Option<usize> {
164 self.state.as_ref().map(|s| s.trigger_pos)
165 }
166
167 pub fn accept_completion(&mut self) -> Option<String> {
169 let selected = self.selected_match()?;
170
171 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 fn get_matches(&mut self, query: &str) -> Vec<FileMatchData> {
182 self.matches_buffer.clear();
184
185 let files = self.file_finder.scan_files().clone();
187
188 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 self.matches_buffer.clone()
198 }
199
200 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}