From 53fb23a1945d902168090fcb89d51423863423bb Mon Sep 17 00:00:00 2001 From: muk Date: Thu, 19 Feb 2026 22:44:27 +0000 Subject: [PATCH] Add find and replace functionality to SQL editor Implements Ctrl+F (Find) and Ctrl+H (Find & Replace) in the editor (#35): - Inline find bar with live search as you type - Case-sensitive toggle with Ctrl+C in find mode - Match highlighting: current match in yellow, other matches in blue - Navigate matches with Enter/Shift+Enter or Up/Down arrows - Replace current match (Enter in replace field) - Replace all occurrences (Ctrl+Shift+Enter) - Pre-fills search with selected text when opening - Tab to switch between search and replace fields - Esc to close find bar - 15 unit tests covering search, navigation, and replacement Co-Authored-By: Claude Opus 4.6 --- src/ui/app.rs | 580 +++++++++++++++++++++++++++++++++++++++++++ src/ui/components.rs | 176 ++++++++++++- 2 files changed, 745 insertions(+), 11 deletions(-) diff --git a/src/ui/app.rs b/src/ui/app.rs index 72dd4ea..f7c8e67 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -75,6 +75,44 @@ impl ExportFormat { } } +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum FindReplaceField { + Search, + Replace, +} + +#[derive(Debug, Clone)] +pub struct FindReplaceState { + pub active: bool, + pub show_replace: bool, + pub search_text: String, + pub replace_text: String, + pub search_cursor: usize, + pub replace_cursor: usize, + pub focused_field: FindReplaceField, + /// Matches stored as (line, start_byte_col, end_byte_col) + pub matches: Vec<(usize, usize, usize)>, + pub current_match: usize, + pub case_sensitive: bool, +} + +impl Default for FindReplaceState { + fn default() -> Self { + Self { + active: false, + show_replace: false, + search_text: String::new(), + replace_text: String::new(), + search_cursor: 0, + replace_cursor: 0, + focused_field: FindReplaceField::Search, + matches: Vec::new(), + current_match: 0, + case_sensitive: false, + } + } +} + #[derive(Debug, Clone, Copy, PartialEq)] pub enum SidebarTab { Databases, @@ -117,6 +155,9 @@ pub struct App { pub editor: TextBuffer, pub query_history: QueryHistory, + // Find & Replace + pub find_replace: FindReplaceState, + // Layout pub editor_height_percent: u16, @@ -289,6 +330,7 @@ impl App { editor: TextBuffer::new(), query_history, + find_replace: FindReplaceState::default(), editor_height_percent: 40, @@ -384,6 +426,11 @@ impl App { _ => {} } + // Handle find/replace input when active (intercept before editor) + if self.find_replace.active && self.focus == Focus::Editor { + return self.handle_find_replace_input(key).await; + } + match self.focus { Focus::ConnectionDialog => self.handle_connection_dialog_input(key).await, Focus::Sidebar => self.handle_sidebar_input(key).await, @@ -757,6 +804,14 @@ impl App { KeyCode::Char('y') if ctrl => { self.editor.redo(); } + KeyCode::Char('f') if ctrl => { + self.open_find(false); + return Ok(()); + } + KeyCode::Char('h') if ctrl => { + self.open_find(true); + return Ok(()); + } KeyCode::Char('l') if ctrl => { // Clear editor self.editor.clear(); @@ -1510,6 +1565,340 @@ impl App { Ok(()) } + // --- Find & Replace --- + + fn open_find(&mut self, show_replace: bool) { + let fr = &mut self.find_replace; + if fr.active { + // If already open, just toggle replace visibility or refocus + fr.show_replace = show_replace; + if show_replace { + fr.focused_field = FindReplaceField::Replace; + } + return; + } + // Pre-fill search with selected text if any + if let Some(selected) = self.editor.get_selected_text() { + if !selected.contains('\n') { + self.find_replace.search_text = selected; + self.find_replace.search_cursor = self.find_replace.search_text.len(); + } + } + self.find_replace.active = true; + self.find_replace.show_replace = show_replace; + self.find_replace.focused_field = FindReplaceField::Search; + self.update_find_matches(); + } + + fn close_find(&mut self) { + self.find_replace.active = false; + self.find_replace.matches.clear(); + } + + fn update_find_matches(&mut self) { + self.find_replace.matches.clear(); + let needle = &self.find_replace.search_text; + if needle.is_empty() { + return; + } + + let case_sensitive = self.find_replace.case_sensitive; + let needle_lower = if case_sensitive { + needle.to_string() + } else { + needle.to_lowercase() + }; + + for (line_idx, line) in self.editor.lines.iter().enumerate() { + let haystack = if case_sensitive { + line.to_string() + } else { + line.to_lowercase() + }; + let mut start = 0; + while let Some(pos) = haystack[start..].find(&needle_lower) { + let abs_pos = start + pos; + self.find_replace + .matches + .push((line_idx, abs_pos, abs_pos + needle.len())); + start = abs_pos + 1; + } + } + + // Clamp current_match + if self.find_replace.matches.is_empty() { + self.find_replace.current_match = 0; + } else if self.find_replace.current_match >= self.find_replace.matches.len() { + self.find_replace.current_match = 0; + } + } + + fn find_next(&mut self) { + if self.find_replace.matches.is_empty() { + return; + } + self.find_replace.current_match = + (self.find_replace.current_match + 1) % self.find_replace.matches.len(); + self.jump_to_current_match(); + } + + fn find_prev(&mut self) { + if self.find_replace.matches.is_empty() { + return; + } + if self.find_replace.current_match == 0 { + self.find_replace.current_match = self.find_replace.matches.len() - 1; + } else { + self.find_replace.current_match -= 1; + } + self.jump_to_current_match(); + } + + fn jump_to_current_match(&mut self) { + if let Some(&(line, col, _)) = self + .find_replace + .matches + .get(self.find_replace.current_match) + { + self.editor.cursor_y = line; + self.editor.cursor_x = col; + self.editor.clear_selection(); + } + } + + /// Find the match closest to (or at) the current cursor position + fn find_nearest_match_index(&self) -> usize { + let cy = self.editor.cursor_y; + let cx = self.editor.cursor_x; + for (i, &(line, col, _)) in self.find_replace.matches.iter().enumerate() { + if line > cy || (line == cy && col >= cx) { + return i; + } + } + 0 // wrap around to first match + } + + fn replace_current(&mut self) { + if self.find_replace.matches.is_empty() { + return; + } + let idx = self.find_replace.current_match; + if let Some(&(line, start, end)) = self.find_replace.matches.get(idx) { + let replacement = self.find_replace.replace_text.clone(); + // Perform the replacement in the editor + self.editor.cursor_y = line; + self.editor.cursor_x = start; + self.editor.selection_start = Some((start, line)); + self.editor.cursor_x = end; + self.editor.delete_selection(); + self.editor.insert_text(&replacement); + + // Refresh matches + self.update_find_matches(); + // Keep current_match in range + if !self.find_replace.matches.is_empty() { + self.find_replace.current_match = + idx.min(self.find_replace.matches.len().saturating_sub(1)); + self.jump_to_current_match(); + } + } + } + + fn replace_all(&mut self) { + if self.find_replace.matches.is_empty() { + return; + } + let replacement = self.find_replace.replace_text.clone(); + let count = self.find_replace.matches.len(); + + // Replace in reverse order to preserve positions + for &(line, start, end) in self.find_replace.matches.iter().rev() { + self.editor.lines[line].replace_range(start..end, &replacement); + } + self.editor.modified = true; + + self.update_find_matches(); + self.set_status( + format!("Replaced {} occurrences", count), + StatusType::Success, + ); + } + + async fn handle_find_replace_input(&mut self, key: KeyEvent) -> Result<()> { + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + let shift = key.modifiers.contains(KeyModifiers::SHIFT); + + match key.code { + KeyCode::Esc => { + self.close_find(); + } + KeyCode::Tab => { + // Toggle between search and replace fields + if self.find_replace.show_replace { + self.find_replace.focused_field = + match self.find_replace.focused_field { + FindReplaceField::Search => FindReplaceField::Replace, + FindReplaceField::Replace => FindReplaceField::Search, + }; + } + } + KeyCode::Enter if ctrl && shift => { + // Replace all + if self.find_replace.show_replace { + self.replace_all(); + } + } + KeyCode::Enter if shift => { + // Find previous + self.find_prev(); + } + KeyCode::Enter => { + match self.find_replace.focused_field { + FindReplaceField::Search => { + // Find next + self.find_next(); + } + FindReplaceField::Replace => { + // Replace current + self.replace_current(); + } + } + } + KeyCode::Char('c') if ctrl => { + // Toggle case sensitivity + self.find_replace.case_sensitive = !self.find_replace.case_sensitive; + self.update_find_matches(); + let label = if self.find_replace.case_sensitive { + "Case sensitive" + } else { + "Case insensitive" + }; + self.set_status(format!("Search: {}", label), StatusType::Info); + } + KeyCode::Char(c) => { + match self.find_replace.focused_field { + FindReplaceField::Search => { + let cursor = self.find_replace.search_cursor; + self.find_replace.search_text.insert(cursor, c); + self.find_replace.search_cursor += 1; + self.update_find_matches(); + // Jump to nearest match from cursor + if !self.find_replace.matches.is_empty() { + self.find_replace.current_match = self.find_nearest_match_index(); + self.jump_to_current_match(); + } + } + FindReplaceField::Replace => { + let cursor = self.find_replace.replace_cursor; + self.find_replace.replace_text.insert(cursor, c); + self.find_replace.replace_cursor += 1; + } + } + } + KeyCode::Backspace => { + match self.find_replace.focused_field { + FindReplaceField::Search => { + if self.find_replace.search_cursor > 0 { + self.find_replace.search_cursor -= 1; + self.find_replace + .search_text + .remove(self.find_replace.search_cursor); + self.update_find_matches(); + if !self.find_replace.matches.is_empty() { + self.find_replace.current_match = + self.find_nearest_match_index(); + self.jump_to_current_match(); + } + } + } + FindReplaceField::Replace => { + if self.find_replace.replace_cursor > 0 { + self.find_replace.replace_cursor -= 1; + self.find_replace + .replace_text + .remove(self.find_replace.replace_cursor); + } + } + } + } + KeyCode::Delete => { + match self.find_replace.focused_field { + FindReplaceField::Search => { + if self.find_replace.search_cursor < self.find_replace.search_text.len() { + self.find_replace + .search_text + .remove(self.find_replace.search_cursor); + self.update_find_matches(); + } + } + FindReplaceField::Replace => { + if self.find_replace.replace_cursor + < self.find_replace.replace_text.len() + { + self.find_replace + .replace_text + .remove(self.find_replace.replace_cursor); + } + } + } + } + KeyCode::Left => { + match self.find_replace.focused_field { + FindReplaceField::Search => { + if self.find_replace.search_cursor > 0 { + self.find_replace.search_cursor -= 1; + } + } + FindReplaceField::Replace => { + if self.find_replace.replace_cursor > 0 { + self.find_replace.replace_cursor -= 1; + } + } + } + } + KeyCode::Right => { + match self.find_replace.focused_field { + FindReplaceField::Search => { + if self.find_replace.search_cursor < self.find_replace.search_text.len() { + self.find_replace.search_cursor += 1; + } + } + FindReplaceField::Replace => { + if self.find_replace.replace_cursor + < self.find_replace.replace_text.len() + { + self.find_replace.replace_cursor += 1; + } + } + } + } + KeyCode::Home => { + match self.find_replace.focused_field { + FindReplaceField::Search => self.find_replace.search_cursor = 0, + FindReplaceField::Replace => self.find_replace.replace_cursor = 0, + } + } + KeyCode::End => { + match self.find_replace.focused_field { + FindReplaceField::Search => { + self.find_replace.search_cursor = self.find_replace.search_text.len() + } + FindReplaceField::Replace => { + self.find_replace.replace_cursor = self.find_replace.replace_text.len() + } + } + } + KeyCode::Up => { + self.find_prev(); + } + KeyCode::Down => { + self.find_next(); + } + _ => {} + } + Ok(()) + } + fn copy_selected_cell(&mut self) { if let Some(result) = self.results.get(self.current_result) { if let Some(row) = result.rows.get(self.result_selected_row) { @@ -1593,3 +1982,194 @@ fn dialog_field_len(config: &ConnectionConfig, field_index: usize) -> usize { _ => 0, } } + +#[cfg(test)] +mod tests { + use super::*; + + fn app_with_text(text: &str) -> App { + let mut app = App::new(); + app.editor.set_text(text); + app + } + + // --- Find matches --- + + #[test] + fn test_find_matches_basic() { + let mut app = app_with_text("SELECT id FROM users WHERE id > 1"); + app.find_replace.search_text = "id".to_string(); + app.find_replace.active = true; + app.update_find_matches(); + assert_eq!(app.find_replace.matches.len(), 2); + assert_eq!(app.find_replace.matches[0], (0, 7, 9)); + assert_eq!(app.find_replace.matches[1], (0, 27, 29)); + } + + #[test] + fn test_find_matches_case_insensitive() { + let mut app = app_with_text("SELECT Id FROM users WHERE ID > 1"); + app.find_replace.search_text = "id".to_string(); + app.find_replace.active = true; + app.find_replace.case_sensitive = false; + app.update_find_matches(); + assert_eq!(app.find_replace.matches.len(), 2); + } + + #[test] + fn test_find_matches_case_sensitive() { + let mut app = app_with_text("SELECT Id FROM users WHERE ID > 1"); + app.find_replace.search_text = "id".to_string(); + app.find_replace.active = true; + app.find_replace.case_sensitive = true; + app.update_find_matches(); + assert_eq!(app.find_replace.matches.len(), 0); + } + + #[test] + fn test_find_matches_multiline() { + let mut app = app_with_text("SELECT id\nFROM users\nWHERE id = 1"); + app.find_replace.search_text = "id".to_string(); + app.find_replace.active = true; + app.update_find_matches(); + assert_eq!(app.find_replace.matches.len(), 2); + assert_eq!(app.find_replace.matches[0], (0, 7, 9)); // first line + assert_eq!(app.find_replace.matches[1], (2, 6, 8)); // third line + } + + #[test] + fn test_find_matches_empty_search() { + let mut app = app_with_text("hello world"); + app.find_replace.search_text = "".to_string(); + app.find_replace.active = true; + app.update_find_matches(); + assert!(app.find_replace.matches.is_empty()); + } + + #[test] + fn test_find_no_matches() { + let mut app = app_with_text("hello world"); + app.find_replace.search_text = "xyz".to_string(); + app.find_replace.active = true; + app.update_find_matches(); + assert!(app.find_replace.matches.is_empty()); + } + + // --- Find navigation --- + + #[test] + fn test_find_next_wraps() { + let mut app = app_with_text("aa bb aa bb aa"); + app.find_replace.search_text = "aa".to_string(); + app.find_replace.active = true; + app.update_find_matches(); + assert_eq!(app.find_replace.matches.len(), 3); + assert_eq!(app.find_replace.current_match, 0); + + app.find_next(); + assert_eq!(app.find_replace.current_match, 1); + + app.find_next(); + assert_eq!(app.find_replace.current_match, 2); + + app.find_next(); // wraps + assert_eq!(app.find_replace.current_match, 0); + } + + #[test] + fn test_find_prev_wraps() { + let mut app = app_with_text("aa bb aa"); + app.find_replace.search_text = "aa".to_string(); + app.find_replace.active = true; + app.update_find_matches(); + assert_eq!(app.find_replace.current_match, 0); + + app.find_prev(); // wraps to last + assert_eq!(app.find_replace.current_match, 1); + + app.find_prev(); + assert_eq!(app.find_replace.current_match, 0); + } + + // --- Replace --- + + #[test] + fn test_replace_current() { + let mut app = app_with_text("hello world hello"); + app.find_replace.search_text = "hello".to_string(); + app.find_replace.replace_text = "hi".to_string(); + app.find_replace.active = true; + app.update_find_matches(); + assert_eq!(app.find_replace.matches.len(), 2); + + app.replace_current(); + assert_eq!(app.editor.text(), "hi world hello"); + assert_eq!(app.find_replace.matches.len(), 1); // one match left + } + + #[test] + fn test_replace_all() { + let mut app = app_with_text("foo bar foo baz foo"); + app.find_replace.search_text = "foo".to_string(); + app.find_replace.replace_text = "qux".to_string(); + app.find_replace.active = true; + app.update_find_matches(); + assert_eq!(app.find_replace.matches.len(), 3); + + app.replace_all(); + assert_eq!(app.editor.text(), "qux bar qux baz qux"); + assert!(app.find_replace.matches.is_empty()); // search text "foo" no longer found + } + + #[test] + fn test_replace_all_different_lengths() { + let mut app = app_with_text("a b a b a"); + app.find_replace.search_text = "a".to_string(); + app.find_replace.replace_text = "xyz".to_string(); + app.find_replace.active = true; + app.update_find_matches(); + assert_eq!(app.find_replace.matches.len(), 3); + + app.replace_all(); + assert_eq!(app.editor.text(), "xyz b xyz b xyz"); + } + + // --- Open / Close --- + + #[test] + fn test_open_find() { + let mut app = App::new(); + app.open_find(false); + assert!(app.find_replace.active); + assert!(!app.find_replace.show_replace); + } + + #[test] + fn test_open_find_with_replace() { + let mut app = App::new(); + app.open_find(true); + assert!(app.find_replace.active); + assert!(app.find_replace.show_replace); + } + + #[test] + fn test_close_find() { + let mut app = App::new(); + app.open_find(false); + app.find_replace.search_text = "test".to_string(); + app.update_find_matches(); + app.close_find(); + assert!(!app.find_replace.active); + assert!(app.find_replace.matches.is_empty()); + } + + #[test] + fn test_open_find_prefills_selection() { + let mut app = app_with_text("hello world"); + app.editor.cursor_x = 0; + app.editor.start_selection(); + app.editor.cursor_x = 5; + app.open_find(false); + assert_eq!(app.find_replace.search_text, "hello"); + } +} diff --git a/src/ui/components.rs b/src/ui/components.rs index fb06b8a..939e4a2 100644 --- a/src/ui/components.rs +++ b/src/ui/components.rs @@ -8,8 +8,8 @@ use ratatui::{ use crate::db::SslMode; use crate::ui::{ - is_sql_function, is_sql_keyword, is_sql_type, App, Focus, SidebarTab, StatusType, Theme, - EXPORT_FORMATS, SPINNER_FRAMES, + is_sql_function, is_sql_keyword, is_sql_type, App, FindReplaceField, Focus, SidebarTab, + StatusType, Theme, EXPORT_FORMATS, SPINNER_FRAMES, }; pub fn draw(frame: &mut Frame, app: &App) { @@ -316,11 +316,46 @@ fn draw_editor(frame: &mut Frame, app: &App, area: Rect) { area, ); + // Calculate find/replace bar height + let find_bar_height = if app.find_replace.active { + if app.find_replace.show_replace { + 2u16 + } else { + 1u16 + } + } else { + 0u16 + }; + + // Split inner area for find bar and editor content + let (find_area, editor_content_area) = if find_bar_height > 0 && inner_area.height > find_bar_height + { + ( + Rect::new(inner_area.x, inner_area.y, inner_area.width, find_bar_height), + Rect::new( + inner_area.x, + inner_area.y + find_bar_height, + inner_area.width, + inner_area.height - find_bar_height, + ), + ) + } else { + ( + Rect::new(inner_area.x, inner_area.y, inner_area.width, 0), + inner_area, + ) + }; + + // Draw find/replace bar + if app.find_replace.active && find_area.height > 0 { + draw_find_bar(frame, app, find_area); + } + // Determine active query range for visual highlighting let query_range = app.get_current_query_line_range(); // Syntax highlight and render editor content - let visible_height = inner_area.height as usize; + let visible_height = editor_content_area.height as usize; let lines: Vec = app .editor .lines @@ -333,23 +368,116 @@ fn draw_editor(frame: &mut Frame, app: &App, area: Rect) { let in_active_query = query_range .map(|(start, end)| actual_line >= start && actual_line <= end) .unwrap_or(false); - highlight_sql_line(line_text, theme, actual_line, &app.editor, in_active_query) + highlight_sql_line( + line_text, + theme, + actual_line, + &app.editor, + in_active_query, + &app.find_replace, + ) }) .collect(); let paragraph = Paragraph::new(lines); - frame.render_widget(paragraph, inner_area); - - // Show cursor (offset by 2 for gutter prefix) - if focused { - let cursor_x = inner_area.x + 2 + app.editor.cursor_x as u16; - let cursor_y = inner_area.y + (app.editor.cursor_y - app.editor.scroll_offset) as u16; - if cursor_y < inner_area.y + inner_area.height { + frame.render_widget(paragraph, editor_content_area); + + // Show cursor + if focused && !app.find_replace.active { + let cursor_x = editor_content_area.x + 2 + app.editor.cursor_x as u16; + let cursor_y = editor_content_area.y + + (app.editor.cursor_y - app.editor.scroll_offset) as u16; + if cursor_y < editor_content_area.y + editor_content_area.height { frame.set_cursor_position((cursor_x, cursor_y)); } } } +fn draw_find_bar(frame: &mut Frame, app: &App, area: Rect) { + let theme = &app.theme; + let fr = &app.find_replace; + + let match_info = if fr.search_text.is_empty() { + String::new() + } else if fr.matches.is_empty() { + " (no matches)".to_string() + } else { + format!(" ({}/{})", fr.current_match + 1, fr.matches.len()) + }; + + let case_indicator = if fr.case_sensitive { " [Aa]" } else { " [.*]" }; + + // Search line + let search_label = " Find: "; + let search_line = Line::from(vec![ + Span::styled( + search_label, + Style::default() + .fg(theme.text_secondary) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + fr.search_text.clone(), + if matches!(fr.focused_field, FindReplaceField::Search) { + Style::default().fg(theme.text_accent) + } else { + Style::default().fg(theme.text_primary) + }, + ), + Span::styled(match_info, Style::default().fg(theme.text_muted)), + Span::styled(case_indicator, Style::default().fg(theme.text_muted)), + ]); + + let search_area = Rect::new(area.x, area.y, area.width, 1); + frame.render_widget( + Paragraph::new(search_line).style(Style::default().bg(theme.bg_secondary)), + search_area, + ); + + // Cursor in search field + if matches!(fr.focused_field, FindReplaceField::Search) { + let cursor_x = area.x + search_label.len() as u16 + fr.search_cursor as u16; + if cursor_x < area.x + area.width { + frame.set_cursor_position((cursor_x, area.y)); + } + } + + // Replace line (if visible) + if fr.show_replace && area.height >= 2 { + let replace_label = " Replace: "; + let replace_line = Line::from(vec![ + Span::styled( + replace_label, + Style::default() + .fg(theme.text_secondary) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + fr.replace_text.clone(), + if matches!(fr.focused_field, FindReplaceField::Replace) { + Style::default().fg(theme.text_accent) + } else { + Style::default().fg(theme.text_primary) + }, + ), + ]); + + let replace_area = Rect::new(area.x, area.y + 1, area.width, 1); + frame.render_widget( + Paragraph::new(replace_line).style(Style::default().bg(theme.bg_secondary)), + replace_area, + ); + + // Cursor in replace field + if matches!(fr.focused_field, FindReplaceField::Replace) { + let cursor_x = area.x + replace_label.len() as u16 + fr.replace_cursor as u16; + if cursor_x < area.x + area.width { + frame.set_cursor_position((cursor_x, area.y + 1)); + } + } + } +} + /// Determine if a line starts inside a block comment by scanning all previous lines. fn is_in_block_comment(lines: &[String], current_line: usize) -> bool { let mut depth = 0i32; @@ -377,7 +505,20 @@ fn highlight_sql_line<'a>( line_number: usize, editor: &crate::editor::TextBuffer, in_active_query: bool, + find_state: &crate::ui::FindReplaceState, ) -> Line<'a> { + // Collect match ranges for this line for highlighting + let match_ranges: Vec<(usize, usize, bool)> = if find_state.active && !find_state.search_text.is_empty() { + find_state + .matches + .iter() + .enumerate() + .filter(|(_, &(l, _, _))| l == line_number) + .map(|(i, &(_, start, end))| (start, end, i == find_state.current_match)) + .collect() + } else { + Vec::new() + }; let mut spans: Vec = Vec::new(); let mut current_word = String::new(); let mut in_string = false; @@ -421,8 +562,19 @@ fn highlight_sql_line<'a>( false }; + // Check if byte_idx falls inside a find match + let find_match = match_ranges + .iter() + .find(|&&(start, end, _)| byte_idx >= start && byte_idx < end); + let base_style = if is_selected { Style::default().bg(theme.selection) + } else if let Some(&&(_, _, is_current)) = find_match.as_ref() { + if is_current { + Style::default().bg(theme.warning).fg(theme.bg_primary) + } else { + Style::default().bg(theme.info).fg(theme.bg_primary) + } } else { Style::default() }; @@ -1304,6 +1456,8 @@ fn draw_help_overlay(frame: &mut Frame, app: &App) { " Ctrl+Z Undo", " Ctrl+Shift+Z/Y Redo", " Ctrl+A Select all", + " Ctrl+F Find", + " Ctrl+H Find & Replace", " Tab Insert spaces", "", " SIDEBAR",