limit_cli/tui/
autocomplete.rs1use crate::file_finder::FileFinder;
6use limit_tui::components::FileMatchData;
7use std::path::PathBuf;
8
9pub struct FileAutocompleteManager {
11 base_path: PathBuf,
13 file_finder: FileFinder,
15 state: Option<AutocompleteState>,
17 matches_buffer: Vec<FileMatchData>,
19}
20
21#[derive(Debug, Clone, Default)]
23pub struct AutocompleteState {
24 pub is_active: bool,
26 pub query: String,
28 pub trigger_pos: usize,
30 pub matches: Vec<FileMatchData>,
32 pub selected_index: usize,
34}
35
36impl FileAutocompleteManager {
37 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 #[inline]
49 pub fn base_path(&self) -> &PathBuf {
50 &self.base_path
51 }
52
53 #[inline]
55 pub fn is_active(&self) -> bool {
56 self.state.as_ref().is_some_and(|s| s.is_active)
57 }
58
59 #[inline]
61 pub fn state(&self) -> Option<&AutocompleteState> {
62 self.state.as_ref()
63 }
64
65 #[inline]
67 pub fn state_mut(&mut self) -> Option<&mut AutocompleteState> {
68 self.state.as_mut()
69 }
70
71 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 #[inline]
88 pub fn deactivate(&mut self) {
89 self.state = None;
90 }
91
92 pub fn update_query(&mut self, query: &str) {
94 if let Some(ref mut state) = self.state {
95 state.query.clear();
97 state.query.push_str(query);
98
99 let matches = self.get_matches(query);
101
102 if let Some(ref mut state) = self.state {
104 state.matches = matches;
105 state.selected_index = 0;
106 }
107 }
108 }
109
110 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 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 #[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 #[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 #[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 #[inline]
182 pub fn trigger_pos(&self) -> Option<usize> {
183 self.state.as_ref().map(|s| s.trigger_pos)
184 }
185
186 pub fn accept_completion(&mut self) -> Option<String> {
188 let selected = self.selected_match()?;
189
190 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 fn get_matches(&mut self, query: &str) -> Vec<FileMatchData> {
201 self.matches_buffer.clear();
203
204 let files = self.file_finder.scan_files().clone();
206
207 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 self.matches_buffer.clone()
218 }
219
220 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}