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
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 #[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 #[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 #[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 #[inline]
173 pub fn trigger_pos(&self) -> Option<usize> {
174 self.state.as_ref().map(|s| s.trigger_pos)
175 }
176
177 pub fn accept_completion(&mut self) -> Option<String> {
179 let selected = self.selected_match()?;
180
181 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 fn get_matches(&mut self, query: &str) -> Vec<FileMatchData> {
192 self.matches_buffer.clear();
194
195 let files = self.file_finder.scan_files().clone();
197
198 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 self.matches_buffer.clone()
209 }
210
211 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}