ghostscope_ui/components/source_panel/
search.rs

1use crate::action::Action;
2use crate::components::command_panel::FileCompletionCache;
3use crate::model::panel_state::{SourcePanelMode, SourcePanelState};
4
5/// Handles source panel search functionality
6pub struct SourceSearch;
7
8impl SourceSearch {
9    /// Enter text search mode
10    pub fn enter_search_mode(state: &mut SourcePanelState) -> Vec<Action> {
11        state.mode = SourcePanelMode::TextSearch;
12        state.search_query.clear();
13        state.search_matches.clear();
14        state.current_match = None;
15        Vec::new()
16    }
17
18    /// Exit search mode
19    pub fn exit_search_mode(state: &mut SourcePanelState) -> Vec<Action> {
20        state.mode = SourcePanelMode::Normal;
21        state.search_query.clear();
22        state.search_matches.clear();
23        state.current_match = None;
24        Vec::new()
25    }
26
27    /// Add character to search query
28    pub fn push_search_char(state: &mut SourcePanelState, ch: char) -> Vec<Action> {
29        if state.mode == SourcePanelMode::TextSearch {
30            state.search_query.push(ch);
31            Self::update_search_matches(state);
32            // Auto-jump to first match as user types
33            if !state.search_matches.is_empty() {
34                state.current_match = Some(0);
35                Self::jump_to_match(state, 0);
36            }
37        }
38        Vec::new()
39    }
40
41    /// Remove character from search query
42    pub fn backspace_search(state: &mut SourcePanelState) -> Vec<Action> {
43        if state.mode == SourcePanelMode::TextSearch {
44            state.search_query.pop();
45            Self::update_search_matches(state);
46            // Auto-jump to first match after backspace
47            if !state.search_matches.is_empty() {
48                state.current_match = Some(0);
49                Self::jump_to_match(state, 0);
50            } else {
51                state.current_match = None;
52            }
53        }
54        Vec::new()
55    }
56
57    /// Confirm search and keep highlights visible (like vim)
58    pub fn confirm_search(state: &mut SourcePanelState) -> Vec<Action> {
59        if state.mode == SourcePanelMode::TextSearch {
60            // Exit search input mode but keep matches highlighted
61            state.mode = SourcePanelMode::Normal;
62
63            // If we have matches, stay at current match or go to first
64            if !state.search_matches.is_empty() && state.current_match.is_none() {
65                state.current_match = Some(0);
66                Self::jump_to_match(state, 0);
67            }
68        }
69        Vec::new()
70    }
71
72    /// Move to next search match (wraps to first when at last)
73    pub fn next_match(state: &mut SourcePanelState) -> Vec<Action> {
74        if !state.search_matches.is_empty() {
75            let current = state.current_match.unwrap_or(0);
76            let next = (current + 1) % state.search_matches.len();
77            state.current_match = Some(next);
78            Self::jump_to_match(state, next);
79
80            // Show wrap-around message if we went from last to first
81            if current == state.search_matches.len() - 1 && next == 0 {
82                tracing::info!("Search wrapped to top");
83            }
84        }
85        Vec::new()
86    }
87
88    /// Move to previous search match (wraps to last when at first)
89    pub fn prev_match(state: &mut SourcePanelState) -> Vec<Action> {
90        if !state.search_matches.is_empty() {
91            let current = state.current_match.unwrap_or(0);
92            let prev = if current == 0 {
93                state.search_matches.len() - 1
94            } else {
95                current - 1
96            };
97            state.current_match = Some(prev);
98            Self::jump_to_match(state, prev);
99
100            // Show wrap-around message if we went from first to last
101            if current == 0 && prev == state.search_matches.len() - 1 {
102                tracing::info!("Search wrapped to bottom");
103            }
104        }
105        Vec::new()
106    }
107
108    /// Enter file search mode
109    pub fn enter_file_search_mode(state: &mut SourcePanelState) -> Vec<Action> {
110        state.mode = SourcePanelMode::FileSearch;
111        state.file_search_query.clear();
112        state.file_search_cursor_pos = 0;
113        state.file_search_filtered_indices.clear();
114        state.file_search_selected = 0;
115        state.file_search_scroll = 0;
116        state.file_search_message = Some("Loading files...".to_string());
117        Vec::new()
118    }
119
120    /// Exit file search mode
121    pub fn exit_file_search_mode(state: &mut SourcePanelState) -> Vec<Action> {
122        state.mode = SourcePanelMode::Normal;
123        state.file_search_query.clear();
124        state.file_search_cursor_pos = 0;
125        state.file_search_filtered_indices.clear();
126        state.file_search_selected = 0;
127        state.file_search_scroll = 0;
128        state.file_search_message = None;
129        Vec::new()
130    }
131
132    /// Add character to file search query
133    pub fn push_file_search_char(
134        state: &mut SourcePanelState,
135        cache: &FileCompletionCache,
136        ch: char,
137    ) -> Vec<Action> {
138        if state.mode == SourcePanelMode::FileSearch {
139            let mut chars: Vec<char> = state.file_search_query.chars().collect();
140            chars.insert(state.file_search_cursor_pos, ch);
141            state.file_search_query = chars.into_iter().collect();
142            state.file_search_cursor_pos += 1;
143            Self::update_file_search_results(state, cache);
144        }
145        Vec::new()
146    }
147
148    /// Remove character from file search query
149    pub fn backspace_file_search(
150        state: &mut SourcePanelState,
151        cache: &FileCompletionCache,
152    ) -> Vec<Action> {
153        if state.mode == SourcePanelMode::FileSearch && state.file_search_cursor_pos > 0 {
154            let mut chars: Vec<char> = state.file_search_query.chars().collect();
155            chars.remove(state.file_search_cursor_pos - 1);
156            state.file_search_query = chars.into_iter().collect();
157            state.file_search_cursor_pos -= 1;
158            Self::update_file_search_results(state, cache);
159        }
160        Vec::new()
161    }
162
163    /// Clear entire file search query (Ctrl+U)
164    pub fn clear_file_search_query(
165        state: &mut SourcePanelState,
166        cache: &FileCompletionCache,
167    ) -> Vec<Action> {
168        if state.mode == SourcePanelMode::FileSearch {
169            state.file_search_query.clear();
170            state.file_search_cursor_pos = 0;
171            Self::update_file_search_results(state, cache);
172        }
173        Vec::new()
174    }
175
176    /// Delete previous word from file search query (Ctrl+W)
177    pub fn delete_word_file_search(
178        state: &mut SourcePanelState,
179        cache: &FileCompletionCache,
180    ) -> Vec<Action> {
181        if state.mode == SourcePanelMode::FileSearch && state.file_search_cursor_pos > 0 {
182            let chars: Vec<char> = state.file_search_query.chars().collect();
183            let mut start_pos = state.file_search_cursor_pos;
184
185            // Define word separators for file paths (include whitespace and path separators)
186            let is_separator = |c: char| {
187                c.is_whitespace() || c == '/' || c == '\\' || c == '.' || c == '-' || c == '_'
188            };
189
190            // Skip trailing separators backwards from cursor
191            while start_pos > 0 && is_separator(chars[start_pos - 1]) {
192                start_pos -= 1;
193            }
194
195            // Delete word characters backwards (until we hit a separator)
196            while start_pos > 0 && !is_separator(chars[start_pos - 1]) {
197                start_pos -= 1;
198            }
199
200            // Create new string by combining before start_pos and after cursor_pos
201            let mut new_chars = chars[..start_pos].to_vec();
202            new_chars.extend_from_slice(&chars[state.file_search_cursor_pos..]);
203
204            state.file_search_query = new_chars.into_iter().collect();
205            state.file_search_cursor_pos = start_pos;
206            Self::update_file_search_results(state, cache);
207        }
208        Vec::new()
209    }
210
211    /// Move cursor to beginning of search query (Ctrl+A)
212    pub fn move_cursor_to_start(state: &mut SourcePanelState) -> Vec<Action> {
213        if state.mode == SourcePanelMode::FileSearch {
214            state.file_search_cursor_pos = 0;
215        }
216        Vec::new()
217    }
218
219    /// Move cursor to end of search query (Ctrl+E)
220    pub fn move_cursor_to_end(state: &mut SourcePanelState) -> Vec<Action> {
221        if state.mode == SourcePanelMode::FileSearch {
222            state.file_search_cursor_pos = state.file_search_query.chars().count();
223        }
224        Vec::new()
225    }
226
227    /// Move cursor left one character (Ctrl+B)
228    pub fn move_cursor_left(state: &mut SourcePanelState) -> Vec<Action> {
229        if state.mode == SourcePanelMode::FileSearch && state.file_search_cursor_pos > 0 {
230            state.file_search_cursor_pos -= 1;
231        }
232        Vec::new()
233    }
234
235    /// Move cursor right one character (Ctrl+F)
236    pub fn move_cursor_right(state: &mut SourcePanelState) -> Vec<Action> {
237        if state.mode == SourcePanelMode::FileSearch {
238            let max_pos = state.file_search_query.chars().count();
239            if state.file_search_cursor_pos < max_pos {
240                state.file_search_cursor_pos += 1;
241            }
242        }
243        Vec::new()
244    }
245
246    /// Move file search selection up
247    pub fn move_file_search_up(state: &mut SourcePanelState) -> Vec<Action> {
248        if state.mode == SourcePanelMode::FileSearch
249            && !state.file_search_filtered_indices.is_empty()
250        {
251            if state.file_search_selected > 0 {
252                state.file_search_selected -= 1;
253            } else {
254                state.file_search_selected = state.file_search_filtered_indices.len() - 1;
255            }
256            Self::ensure_file_search_visible(state);
257        }
258        Vec::new()
259    }
260
261    /// Move file search selection down
262    pub fn move_file_search_down(state: &mut SourcePanelState) -> Vec<Action> {
263        if state.mode == SourcePanelMode::FileSearch
264            && !state.file_search_filtered_indices.is_empty()
265        {
266            state.file_search_selected =
267                (state.file_search_selected + 1) % state.file_search_filtered_indices.len();
268            Self::ensure_file_search_visible(state);
269        }
270        Vec::new()
271    }
272
273    /// Confirm file search selection
274    pub fn confirm_file_search(
275        state: &mut SourcePanelState,
276        cache: &FileCompletionCache,
277    ) -> Option<String> {
278        if state.mode == SourcePanelMode::FileSearch
279            && !state.file_search_filtered_indices.is_empty()
280        {
281            let real_idx = state.file_search_filtered_indices[state.file_search_selected];
282            let selected_file = cache.get_all_files().get(real_idx).cloned();
283            Self::exit_file_search_mode(state);
284            selected_file
285        } else {
286            None
287        }
288    }
289
290    /// Set file search results (updates cache)
291    pub fn set_file_search_files(
292        state: &mut SourcePanelState,
293        cache: &mut FileCompletionCache,
294        files: Vec<String>,
295    ) -> Vec<Action> {
296        cache.set_all_files(files);
297        state.file_search_message = None;
298        Self::update_file_search_results(state, cache);
299        Vec::new()
300    }
301
302    /// Set file search error
303    pub fn set_file_search_error(state: &mut SourcePanelState, error: String) -> Vec<Action> {
304        state.file_search_message = Some(format!("✗ {error}"));
305        state.file_search_filtered_indices.clear();
306        Vec::new()
307    }
308
309    /// Update search matches based on current query
310    fn update_search_matches(state: &mut SourcePanelState) {
311        let old_cursor_line = state.cursor_line;
312        let old_cursor_col = state.cursor_col;
313
314        state.search_matches.clear();
315        state.current_match = None;
316
317        if state.search_query.is_empty() {
318            return;
319        }
320
321        let query = state.search_query.to_lowercase();
322        for (line_idx, line) in state.content.iter().enumerate() {
323            let line_lower = line.to_lowercase();
324            let mut start = 0;
325            while let Some(pos) = line_lower[start..].find(&query) {
326                let match_start = start + pos;
327                let match_end = match_start + query.len();
328                state
329                    .search_matches
330                    .push((line_idx, match_start, match_end));
331                start = match_start + 1;
332            }
333        }
334
335        // Find the first match at or after current cursor position
336        if !state.search_matches.is_empty() {
337            let mut best_match = 0;
338            for (idx, (line_idx, col_start, _)) in state.search_matches.iter().enumerate() {
339                if *line_idx > old_cursor_line
340                    || (*line_idx == old_cursor_line && *col_start >= old_cursor_col)
341                {
342                    best_match = idx;
343                    break;
344                }
345            }
346            state.current_match = Some(best_match);
347        }
348    }
349
350    /// Jump to specific match
351    fn jump_to_match(state: &mut SourcePanelState, match_idx: usize) {
352        if let Some((line_idx, col_start, _)) = state.search_matches.get(match_idx) {
353            state.cursor_line = *line_idx;
354            state.cursor_col = *col_start; // Move cursor to match position
355
356            // Ensure cursor is visible vertically
357            // Note: should use actual panel height, but for now use conservative estimate
358            let visible_lines = 30; // Conservative estimate
359            if state.cursor_line < state.scroll_offset {
360                state.scroll_offset = state.cursor_line;
361            } else if state.cursor_line >= state.scroll_offset + visible_lines {
362                state.scroll_offset = state
363                    .cursor_line
364                    .saturating_sub(visible_lines.saturating_sub(1));
365            }
366
367            // Ensure cursor is visible horizontally
368            if let Some(current_line) = state.content.get(state.cursor_line) {
369                let line_number_width = 5; // "1234 " format
370                let border_width = 2; // left and right borders
371                let available_width = (state
372                    .area_width
373                    .saturating_sub(line_number_width + border_width))
374                    as usize;
375
376                if current_line.len() <= available_width {
377                    // Line fits entirely, no horizontal scrolling needed
378                    state.horizontal_scroll_offset = 0;
379                } else {
380                    // Line is longer than available width, need horizontal scrolling
381                    let scrolloff = available_width / 3; // Keep some context around cursor
382
383                    // Calculate ideal horizontal scroll position to center match
384                    let ideal_scroll = state.cursor_col.saturating_sub(scrolloff);
385
386                    // Calculate maximum possible scroll
387                    let max_scroll = current_line.len().saturating_sub(available_width);
388
389                    // Check if we're near the end of the line
390                    let near_end = state.cursor_col >= max_scroll.saturating_add(scrolloff);
391
392                    if near_end {
393                        // Near the end, scroll to show the end
394                        state.horizontal_scroll_offset = max_scroll;
395                    } else {
396                        // Normal case, scroll to keep cursor visible with context
397                        state.horizontal_scroll_offset = ideal_scroll.min(max_scroll);
398                    }
399                }
400            }
401        }
402    }
403
404    /// Update file search filtered results based on query
405    fn update_file_search_results(state: &mut SourcePanelState, cache: &FileCompletionCache) {
406        state.file_search_filtered_indices.clear();
407        state.file_search_selected = 0;
408        state.file_search_scroll = 0;
409
410        let all_files = cache.get_all_files();
411        if state.file_search_query.is_empty() {
412            // Show all files from cache
413            state.file_search_filtered_indices = (0..all_files.len()).collect();
414        } else {
415            // Filter files from cache based on query
416            let query = state.file_search_query.to_lowercase();
417            for (idx, file) in all_files.iter().enumerate() {
418                if file.to_lowercase().contains(&query) {
419                    state.file_search_filtered_indices.push(idx);
420                }
421            }
422        }
423    }
424
425    /// Ensure file search selection is visible
426    fn ensure_file_search_visible(state: &mut SourcePanelState) {
427        let visible_count = 10; // Show up to 10 files
428
429        if state.file_search_selected < state.file_search_scroll {
430            state.file_search_scroll = state.file_search_selected;
431        } else if state.file_search_selected >= state.file_search_scroll + visible_count {
432            state.file_search_scroll = state.file_search_selected.saturating_sub(visible_count - 1);
433        }
434    }
435}