sql_cli/ui/search/
vim_search_manager.rs

1use crate::data::data_view::DataView;
2use crate::ui::viewport_manager::ViewportManager;
3use tracing::{debug, error, info, warn};
4
5/// Represents a search match in the data
6#[derive(Debug, Clone)]
7pub struct SearchMatch {
8    pub row: usize,
9    pub col: usize,
10    pub value: String,
11}
12
13/// State for vim-like search mode
14#[derive(Debug, Clone)]
15pub enum VimSearchState {
16    /// Not in search mode
17    Inactive,
18    /// Typing search pattern (/ mode)
19    Typing { pattern: String },
20    /// Search confirmed, navigating matches (after Enter)
21    Navigating {
22        pattern: String,
23        matches: Vec<SearchMatch>,
24        current_index: usize,
25    },
26}
27
28/// Manages vim-like forward search behavior
29pub struct VimSearchManager {
30    state: VimSearchState,
31    case_sensitive: bool,
32    last_search_pattern: Option<String>,
33}
34
35impl Default for VimSearchManager {
36    fn default() -> Self {
37        Self::new()
38    }
39}
40
41impl VimSearchManager {
42    #[must_use]
43    pub fn new() -> Self {
44        Self {
45            state: VimSearchState::Inactive,
46            case_sensitive: false,
47            last_search_pattern: None,
48        }
49    }
50
51    /// Start search mode (when / is pressed)
52    pub fn start_search(&mut self) {
53        info!(target: "vim_search", "Starting vim search mode");
54        self.state = VimSearchState::Typing {
55            pattern: String::new(),
56        };
57    }
58
59    /// Update search pattern and find first match dynamically
60    pub fn update_pattern(
61        &mut self,
62        pattern: String,
63        dataview: &DataView,
64        viewport: &mut ViewportManager,
65    ) -> Option<SearchMatch> {
66        debug!(target: "vim_search", "Updating pattern to: '{}'", pattern);
67
68        // Update state to typing mode with new pattern
69        self.state = VimSearchState::Typing {
70            pattern: pattern.clone(),
71        };
72
73        if pattern.is_empty() {
74            return None;
75        }
76
77        // Find all matches
78        let matches = self.find_matches(&pattern, dataview);
79
80        if let Some(first_match) = matches.first() {
81            debug!(target: "vim_search", 
82                "Found {} matches, navigating to first at ({}, {})", 
83                matches.len(), first_match.row, first_match.col);
84
85            // Navigate to first match and ensure it's visible
86            self.navigate_to_match(first_match, viewport);
87            Some(first_match.clone())
88        } else {
89            debug!(target: "vim_search", "No matches found for pattern: '{}'", pattern);
90            None
91        }
92    }
93
94    /// Confirm search (when Enter is pressed) - enter navigation mode
95    pub fn confirm_search(&mut self, dataview: &DataView, viewport: &mut ViewportManager) -> bool {
96        if let VimSearchState::Typing { pattern } = &self.state {
97            if pattern.is_empty() {
98                info!(target: "vim_search", "Empty pattern, canceling search");
99                self.cancel_search();
100                return false;
101            }
102
103            let pattern = pattern.clone();
104            let matches = self.find_matches(&pattern, dataview);
105
106            if matches.is_empty() {
107                warn!(target: "vim_search", "No matches found for pattern: '{}'", pattern);
108                self.cancel_search();
109                return false;
110            }
111
112            info!(target: "vim_search", 
113                "Confirming search with {} matches for pattern: '{}'", 
114                matches.len(), pattern);
115
116            // Navigate to first match
117            if let Some(first_match) = matches.first() {
118                self.navigate_to_match(first_match, viewport);
119            }
120
121            // Enter navigation mode
122            self.state = VimSearchState::Navigating {
123                pattern: pattern.clone(),
124                matches,
125                current_index: 0,
126            };
127            self.last_search_pattern = Some(pattern);
128            true
129        } else {
130            warn!(target: "vim_search", "confirm_search called in wrong state: {:?}", self.state);
131            false
132        }
133    }
134
135    /// Navigate to next match (n key)
136    pub fn next_match(&mut self, viewport: &mut ViewportManager) -> Option<SearchMatch> {
137        // First, update the index and get the match
138        let match_to_navigate = if let VimSearchState::Navigating {
139            matches,
140            current_index,
141            pattern,
142        } = &mut self.state
143        {
144            if matches.is_empty() {
145                return None;
146            }
147
148            // Log current state before moving
149            info!(target: "vim_search", 
150                "=== 'n' KEY PRESSED - BEFORE NAVIGATION ===");
151            info!(target: "vim_search", 
152                "Current match index: {}/{}, Pattern: '{}'", 
153                *current_index + 1, matches.len(), pattern);
154            info!(target: "vim_search", 
155                "Current viewport - rows: {:?}, cols: {:?}", 
156                viewport.get_viewport_rows(), viewport.viewport_cols());
157            info!(target: "vim_search", 
158                "Current crosshair position: row={}, col={}", 
159                viewport.get_crosshair_row(), viewport.get_crosshair_col());
160
161            // Wrap around to beginning
162            *current_index = (*current_index + 1) % matches.len();
163            let match_item = matches[*current_index].clone();
164
165            info!(target: "vim_search", 
166                "=== NEXT MATCH DETAILS ===");
167            info!(target: "vim_search", 
168                "Match {}/{}: row={}, visual_col={}, stored_value='{}'", 
169                *current_index + 1, matches.len(),
170                match_item.row, match_item.col, match_item.value);
171
172            // Double-check: Does this value actually contain our pattern?
173            if !match_item
174                .value
175                .to_lowercase()
176                .contains(&pattern.to_lowercase())
177            {
178                error!(target: "vim_search",
179                    "CRITICAL ERROR: Match value '{}' does NOT contain search pattern '{}'!",
180                    match_item.value, pattern);
181                error!(target: "vim_search",
182                    "This indicates the search index is corrupted or stale!");
183            }
184
185            // Log what we expect to find at this position
186            info!(target: "vim_search", 
187                "Expected: Cell at row {} col {} should contain substring '{}'", 
188                match_item.row, match_item.col, pattern);
189
190            // Verify the stored match actually contains the pattern
191            let stored_contains = match_item
192                .value
193                .to_lowercase()
194                .contains(&pattern.to_lowercase());
195            if stored_contains {
196                info!(target: "vim_search",
197                    "✓ Stored match '{}' contains pattern '{}'",
198                    match_item.value, pattern);
199            } else {
200                warn!(target: "vim_search",
201                    "CRITICAL: Stored match '{}' does NOT contain pattern '{}'!",
202                    match_item.value, pattern);
203            }
204
205            Some(match_item)
206        } else {
207            debug!(target: "vim_search", "next_match called but not in navigation mode");
208            None
209        };
210
211        // Then navigate to it if we have a match
212        if let Some(ref match_item) = match_to_navigate {
213            info!(target: "vim_search", 
214                "=== NAVIGATING TO MATCH ===");
215            self.navigate_to_match(match_item, viewport);
216
217            // Log state after navigation
218            info!(target: "vim_search", 
219                "=== AFTER NAVIGATION ===");
220            info!(target: "vim_search", 
221                "New viewport - rows: {:?}, cols: {:?}", 
222                viewport.get_viewport_rows(), viewport.viewport_cols());
223            info!(target: "vim_search", 
224                "New crosshair position: row={}, col={}", 
225                viewport.get_crosshair_row(), viewport.get_crosshair_col());
226            info!(target: "vim_search", 
227                "Crosshair should be at: row={}, col={} (visual coordinates)", 
228                match_item.row, match_item.col);
229        }
230
231        match_to_navigate
232    }
233
234    /// Navigate to previous match (N key)
235    pub fn previous_match(&mut self, viewport: &mut ViewportManager) -> Option<SearchMatch> {
236        // First, update the index and get the match
237        let match_to_navigate = if let VimSearchState::Navigating {
238            matches,
239            current_index,
240            pattern,
241        } = &mut self.state
242        {
243            if matches.is_empty() {
244                return None;
245            }
246
247            // Wrap around to end
248            *current_index = if *current_index == 0 {
249                matches.len() - 1
250            } else {
251                *current_index - 1
252            };
253
254            let match_item = matches[*current_index].clone();
255
256            info!(target: "vim_search", 
257                "Navigating to previous match {}/{} at ({}, {})", 
258                *current_index + 1, matches.len(), match_item.row, match_item.col);
259
260            Some(match_item)
261        } else {
262            debug!(target: "vim_search", "previous_match called but not in navigation mode");
263            None
264        };
265
266        // Then navigate to it if we have a match
267        if let Some(ref match_item) = match_to_navigate {
268            self.navigate_to_match(match_item, viewport);
269        }
270
271        match_to_navigate
272    }
273
274    /// Cancel search and return to normal mode
275    pub fn cancel_search(&mut self) {
276        info!(target: "vim_search", "Canceling search, returning to inactive state");
277        self.state = VimSearchState::Inactive;
278    }
279
280    /// Clear all search state and return to inactive mode
281    pub fn clear(&mut self) {
282        info!(target: "vim_search", "Clearing all search state");
283        self.state = VimSearchState::Inactive;
284        self.last_search_pattern = None;
285    }
286
287    /// Exit navigation mode but keep search pattern for later
288    pub fn exit_navigation(&mut self) {
289        if let VimSearchState::Navigating { pattern, .. } = &self.state {
290            self.last_search_pattern = Some(pattern.clone());
291        }
292        self.state = VimSearchState::Inactive;
293    }
294
295    /// Resume search with last pattern (for repeating search with /)
296    pub fn resume_last_search(
297        &mut self,
298        dataview: &DataView,
299        viewport: &mut ViewportManager,
300    ) -> bool {
301        if let Some(pattern) = &self.last_search_pattern {
302            let pattern = pattern.clone();
303            let matches = self.find_matches(&pattern, dataview);
304
305            if matches.is_empty() {
306                false
307            } else {
308                info!(target: "vim_search", 
309                    "Resuming search with pattern '{}', found {} matches", 
310                    pattern, matches.len());
311
312                // Navigate to first match
313                if let Some(first_match) = matches.first() {
314                    self.navigate_to_match(first_match, viewport);
315                }
316
317                self.state = VimSearchState::Navigating {
318                    pattern,
319                    matches,
320                    current_index: 0,
321                };
322                true
323            }
324        } else {
325            false
326        }
327    }
328
329    /// Check if currently in search mode
330    #[must_use]
331    pub fn is_active(&self) -> bool {
332        !matches!(self.state, VimSearchState::Inactive)
333    }
334
335    /// Check if in typing mode
336    #[must_use]
337    pub fn is_typing(&self) -> bool {
338        matches!(self.state, VimSearchState::Typing { .. })
339    }
340
341    /// Check if in navigation mode
342    #[must_use]
343    pub fn is_navigating(&self) -> bool {
344        matches!(self.state, VimSearchState::Navigating { .. })
345    }
346
347    /// Get current pattern
348    #[must_use]
349    pub fn get_pattern(&self) -> Option<String> {
350        match &self.state {
351            VimSearchState::Typing { pattern } => Some(pattern.clone()),
352            VimSearchState::Navigating { pattern, .. } => Some(pattern.clone()),
353            VimSearchState::Inactive => None,
354        }
355    }
356
357    /// Get current match info for status display
358    #[must_use]
359    pub fn get_match_info(&self) -> Option<(usize, usize)> {
360        match &self.state {
361            VimSearchState::Navigating {
362                matches,
363                current_index,
364                ..
365            } => Some((*current_index + 1, matches.len())),
366            _ => None,
367        }
368    }
369
370    /// Reset to first match (for 'g' key)
371    pub fn reset_to_first_match(&mut self, viewport: &mut ViewportManager) -> Option<SearchMatch> {
372        if let VimSearchState::Navigating {
373            matches,
374            current_index,
375            ..
376        } = &mut self.state
377        {
378            if matches.is_empty() {
379                return None;
380            }
381
382            // Reset to first match
383            *current_index = 0;
384            let first_match = matches[0].clone();
385
386            info!(target: "vim_search", 
387                "Resetting to first match at ({}, {})", 
388                first_match.row, first_match.col);
389
390            // Navigate to the first match
391            self.navigate_to_match(&first_match, viewport);
392            Some(first_match)
393        } else {
394            debug!(target: "vim_search", "reset_to_first_match called but not in navigation mode");
395            None
396        }
397    }
398
399    /// Find all matches in the dataview
400    fn find_matches(&self, pattern: &str, dataview: &DataView) -> Vec<SearchMatch> {
401        let mut matches = Vec::new();
402        let pattern_lower = if self.case_sensitive {
403            pattern.to_string()
404        } else {
405            pattern.to_lowercase()
406        };
407
408        info!(target: "vim_search", 
409            "=== FIND_MATCHES CALLED ===");
410        info!(target: "vim_search", 
411            "Pattern passed in: '{}', pattern_lower: '{}', case_sensitive: {}", 
412            pattern, pattern_lower, self.case_sensitive);
413
414        // Get the display column indices to map enumeration index to actual column index
415        let display_columns = dataview.get_display_columns();
416        debug!(target: "vim_search", 
417            "Display columns mapping: {:?} (count: {})", 
418            display_columns, display_columns.len());
419
420        // Search through all visible data
421        for row_idx in 0..dataview.row_count() {
422            if let Some(row) = dataview.get_row(row_idx) {
423                let mut first_match_in_row: Option<SearchMatch> = None;
424
425                // The row.values are in display order
426                for (enum_idx, value) in row.values.iter().enumerate() {
427                    let value_str = value.to_string();
428                    let search_value = if self.case_sensitive {
429                        value_str.clone()
430                    } else {
431                        value_str.to_lowercase()
432                    };
433
434                    if search_value.contains(&pattern_lower) {
435                        // For vim-like behavior, we prioritize the first match in each row
436                        // This prevents jumping between columns on the same row
437                        if first_match_in_row.is_none() {
438                            // IMPORTANT: The enum_idx is the position in row.values array,
439                            // which corresponds to the position in display_columns.
440                            // Since we're searching in visual/display order, we use enum_idx directly
441                            // as the visual column index for the viewport to understand.
442
443                            // Map enum_idx back to the actual DataTable column for debugging
444                            let actual_col = if enum_idx < display_columns.len() {
445                                display_columns[enum_idx]
446                            } else {
447                                enum_idx // Fallback, shouldn't happen
448                            };
449
450                            info!(target: "vim_search", 
451                                "Found first match in row {} at visual col {} (DataTable col {}, value '{}')", 
452                                row_idx, enum_idx, actual_col, value_str);
453
454                            // Extra validation - log if we find "Futures Trading"
455                            if value_str.contains("Futures Trading") {
456                                warn!(target: "vim_search",
457                                    "SUSPICIOUS: Found 'Futures Trading' as a match for pattern '{}' (search_value='{}', pattern_lower='{}')",
458                                    pattern, search_value, pattern_lower);
459                            }
460
461                            first_match_in_row = Some(SearchMatch {
462                                row: row_idx,
463                                col: enum_idx, // This is the visual column index in display order
464                                value: value_str,
465                            });
466                        } else {
467                            debug!(target: "vim_search", 
468                                "Skipping additional match in row {} at visual col {} (enum_idx {}): '{}'", 
469                                row_idx, enum_idx, enum_idx, value_str);
470                        }
471                    }
472                }
473
474                // Add the first match from this row if we found one
475                if let Some(match_item) = first_match_in_row {
476                    matches.push(match_item);
477                }
478            }
479        }
480
481        debug!(target: "vim_search", "Found {} total matches", matches.len());
482        matches
483    }
484
485    /// Navigate viewport to ensure match is visible and set crosshair
486    fn navigate_to_match(&self, match_item: &SearchMatch, viewport: &mut ViewportManager) {
487        info!(target: "vim_search", 
488            "=== NAVIGATE_TO_MATCH START ===");
489        info!(target: "vim_search", 
490            "Target match: row={} (absolute), col={} (visual), value='{}'", 
491            match_item.row, match_item.col, match_item.value);
492
493        // Get terminal dimensions to preserve width
494        let terminal_width = viewport.get_terminal_width();
495        let terminal_height = viewport.get_terminal_height();
496        info!(target: "vim_search",
497            "Terminal dimensions: width={}, height={}",
498            terminal_width, terminal_height);
499
500        // Get current viewport state BEFORE any changes
501        let viewport_rows = viewport.get_viewport_rows();
502        let viewport_cols = viewport.viewport_cols();
503        let viewport_height = viewport_rows.end - viewport_rows.start;
504        let viewport_width = viewport_cols.end - viewport_cols.start;
505
506        info!(target: "vim_search",
507            "Current viewport BEFORE changes:");
508        info!(target: "vim_search",
509            "  Rows: {:?} (height={})", viewport_rows, viewport_height);
510        info!(target: "vim_search",
511            "  Cols: {:?} (width={})", viewport_cols, viewport_width);
512        info!(target: "vim_search",
513            "  Current crosshair: row={}, col={}",
514            viewport.get_crosshair_row(), viewport.get_crosshair_col());
515
516        // ALWAYS center the match in the viewport for predictable behavior
517        // The match should appear at viewport position (height/2, width/2)
518        let new_row_start = match_item.row.saturating_sub(viewport_height / 2);
519        info!(target: "vim_search", 
520            "Centering row {} in viewport (height={}), new viewport start row={}", 
521            match_item.row, viewport_height, new_row_start);
522
523        // For columns, we can't just divide by 2 because columns have variable widths
524        // Instead, try to position the match column reasonably in view
525        // Start by trying to show a few columns before the match if possible
526        let new_col_start = match_item.col.saturating_sub(3); // Show 3 columns before if possible
527        info!(target: "vim_search", 
528            "Positioning column {} in viewport, new viewport start col={}", 
529            match_item.col, new_col_start);
530
531        // Log what we're about to do
532        info!(target: "vim_search",
533            "=== VIEWPORT UPDATE ===");
534        info!(target: "vim_search",
535            "Will call set_viewport with: row_start={}, col_start={}, width={}, height={}",
536            new_row_start, new_col_start, terminal_width, terminal_height);
537
538        // Update viewport with preserved terminal dimensions
539        viewport.set_viewport(
540            new_row_start,
541            new_col_start,
542            terminal_width, // Use actual terminal width, not column count!
543            terminal_height as u16,
544        );
545
546        // Get the updated viewport state
547        let final_viewport_rows = viewport.get_viewport_rows();
548        let final_viewport_cols = viewport.viewport_cols();
549
550        info!(target: "vim_search", 
551            "Viewport AFTER set_viewport: rows {:?}, cols {:?}", 
552            final_viewport_rows, final_viewport_cols);
553
554        // CRITICAL: Check if our target column is actually in the viewport!
555        if match_item.col < final_viewport_cols.start || match_item.col >= final_viewport_cols.end {
556            error!(target: "vim_search",
557                "CRITICAL ERROR: Target column {} is NOT in viewport {:?} after set_viewport!",
558                match_item.col, final_viewport_cols);
559            error!(target: "vim_search",
560                "We asked for col_start={}, but viewport gave us {:?}",
561                new_col_start, final_viewport_cols);
562        }
563
564        // Set the crosshair to the ABSOLUTE position of the match
565        // The viewport manager uses absolute coordinates internally
566        info!(target: "vim_search",
567            "=== CROSSHAIR POSITIONING ===");
568        info!(target: "vim_search",
569            "Setting crosshair to ABSOLUTE position: row={}, col={}",
570            match_item.row, match_item.col);
571
572        viewport.set_crosshair(match_item.row, match_item.col);
573
574        // Verify the match is centered in the viewport
575        let center_row =
576            final_viewport_rows.start + (final_viewport_rows.end - final_viewport_rows.start) / 2;
577        let center_col =
578            final_viewport_cols.start + (final_viewport_cols.end - final_viewport_cols.start) / 2;
579
580        info!(target: "vim_search",
581            "Viewport center is at: row={}, col={}",
582            center_row, center_col);
583        info!(target: "vim_search",
584            "Match is at: row={}, col={}",
585            match_item.row, match_item.col);
586        info!(target: "vim_search",
587            "Distance from center: row_diff={}, col_diff={}",
588            (match_item.row as i32 - center_row as i32).abs(),
589            (match_item.col as i32 - center_col as i32).abs());
590
591        // Get the viewport-relative position for verification
592        if let Some((vp_row, vp_col)) = viewport.get_crosshair_viewport_position() {
593            info!(target: "vim_search",
594                "Crosshair appears at viewport position: ({}, {})",
595                vp_row, vp_col);
596            info!(target: "vim_search",
597                "Viewport dimensions: {} rows x {} cols",
598                final_viewport_rows.end - final_viewport_rows.start,
599                final_viewport_cols.end - final_viewport_cols.start);
600            info!(target: "vim_search",
601                "Expected center position: ({}, {})",
602                (final_viewport_rows.end - final_viewport_rows.start) / 2,
603                (final_viewport_cols.end - final_viewport_cols.start) / 2);
604        } else {
605            error!(target: "vim_search",
606                "CRITICAL: Crosshair is NOT visible in viewport after centering!");
607        }
608
609        // Verify the match is actually visible in the viewport after scrolling
610        info!(target: "vim_search",
611            "=== VERIFICATION ===");
612
613        if match_item.row < final_viewport_rows.start || match_item.row >= final_viewport_rows.end {
614            error!(target: "vim_search", 
615                "ERROR: Match row {} is OUTSIDE viewport {:?} after scrolling!", 
616                match_item.row, final_viewport_rows);
617        } else {
618            info!(target: "vim_search",
619                "✓ Match row {} is within viewport {:?}",
620                match_item.row, final_viewport_rows);
621        }
622
623        if match_item.col < final_viewport_cols.start || match_item.col >= final_viewport_cols.end {
624            error!(target: "vim_search", 
625                "ERROR: Match column {} is OUTSIDE viewport {:?} after scrolling!", 
626                match_item.col, final_viewport_cols);
627        } else {
628            info!(target: "vim_search",
629                "✓ Match column {} is within viewport {:?}",
630                match_item.col, final_viewport_cols);
631        }
632
633        // Final summary
634        info!(target: "vim_search", 
635            "=== NAVIGATE_TO_MATCH COMPLETE ===");
636        info!(target: "vim_search",
637            "Match at absolute ({}, {}), crosshair at ({}, {}), viewport rows {:?} cols {:?}", 
638            match_item.row, match_item.col,
639            viewport.get_crosshair_row(), viewport.get_crosshair_col(),
640            final_viewport_rows, final_viewport_cols);
641    }
642
643    /// Set case sensitivity for search
644    pub fn set_case_sensitive(&mut self, case_sensitive: bool) {
645        self.case_sensitive = case_sensitive;
646        debug!(target: "vim_search", "Case sensitivity set to: {}", case_sensitive);
647    }
648
649    /// Set search state from external search (e.g., `SearchModesWidget`)
650    /// This allows 'n' and 'N' to work after a regular search
651    pub fn set_search_state_from_external(
652        &mut self,
653        pattern: String,
654        matches: Vec<(usize, usize)>,
655        dataview: &DataView,
656    ) {
657        info!(target: "vim_search", 
658            "Setting search state from external search: pattern='{}', {} matches", 
659            pattern, matches.len());
660
661        // Convert matches to SearchMatch format
662        let search_matches: Vec<SearchMatch> = matches
663            .into_iter()
664            .filter_map(|(row, col)| {
665                if let Some(row_data) = dataview.get_row(row) {
666                    if col < row_data.values.len() {
667                        Some(SearchMatch {
668                            row,
669                            col,
670                            value: row_data.values[col].to_string(),
671                        })
672                    } else {
673                        None
674                    }
675                } else {
676                    None
677                }
678            })
679            .collect();
680
681        if search_matches.is_empty() {
682            warn!(target: "vim_search", "No valid matches to set in vim search state");
683        } else {
684            let match_count = search_matches.len();
685
686            // Set the state to navigating
687            self.state = VimSearchState::Navigating {
688                pattern: pattern.clone(),
689                matches: search_matches,
690                current_index: 0,
691            };
692            self.last_search_pattern = Some(pattern);
693
694            info!(target: "vim_search", 
695                "Vim search state updated: {} matches ready for navigation", 
696                match_count);
697        }
698    }
699}