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