From bb31879619369de55fdda4a1254b68867144c908 Mon Sep 17 00:00:00 2001 From: Seong Yong-ju Date: Tue, 2 Dec 2025 02:26:12 +0900 Subject: [PATCH] feat: add search functionality with incremental search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement comprehensive search functionality including: - Incremental search with live preview (/ to search) - Search navigation (n/N for next/previous match) - Auto-expand collapsed sections containing matches - Visual highlighting of matches (yellow for matches, bright yellow for current) - Restore cursor/scroll/collapsed state on search cancellation - Case-insensitive search - Forward-only search (simplified from bidirectional) Key changes: - Add Search, SearchNext, SearchPrevious operations - Merge Cancel and Quit into CancelOrQuit (q/Esc now cancel search first, then quit) - Add prompt_with_callback for live search preview - Implement SearchState (Inactive/Incremental/Active) management - Add search_match and current_search_match style configuration - Search pattern input does not default to previous value πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/app.rs | 52 +++ src/config.rs | 3 + src/default_config.toml | 34 +- src/ops/editor.rs | 80 +++- src/ops/mod.rs | 11 +- src/screen/mod.rs | 396 +++++++++++++++++- .../gitu__tests__branch__branch_menu.snap | 2 +- .../gitu__tests__commit__commit_menu.snap | 16 +- .../snapshots/gitu__tests__help_menu.snap | 12 +- .../gitu__tests__log__grep_prompt.snap | 10 +- .../gitu__tests__log__grep_set_example.snap | 10 +- .../gitu__tests__log__limit_prompt.snap | 10 +- .../gitu__tests__log__limit_set_10.snap | 10 +- .../gitu__tests__merge__merge_menu.snap | 10 +- ...enu_existing_push_remote_and_upstream.snap | 2 +- ...__pull_menu_no_remote_or_upstream_set.snap | 2 +- ...__tests__pull__pull_setup_push_remote.snap | 2 +- ...itu__tests__pull__pull_setup_upstream.snap | 2 +- ...ull__pull_setup_upstream_same_as_head.snap | 2 +- ...push__open_push_menu_after_dash_input.snap | 2 +- ...enu_existing_push_remote_and_upstream.snap | 12 +- ...__push_menu_no_remote_or_upstream_set.snap | 2 +- ...__tests__push__push_setup_push_remote.snap | 12 +- ...itu__tests__push__push_setup_upstream.snap | 2 +- ...ush__push_setup_upstream_same_as_head.snap | 2 +- .../gitu__tests__rebase__rebase_menu.snap | 18 +- .../gitu__tests__remote__remote_menu.snap | 2 +- .../gitu__tests__reset__reset_menu.snap | 2 +- .../snapshots/gitu__tests__revert_menu.snap | 2 +- .../gitu__tests__stash__stash_menu.snap | 10 +- 30 files changed, 630 insertions(+), 102 deletions(-) diff --git a/src/app.rs b/src/app.rs index b34a02d731..c6b0e3a514 100644 --- a/src/app.rs +++ b/src/app.rs @@ -55,6 +55,7 @@ pub(crate) struct State { pub clipboard: Option, needs_redraw: bool, file_watcher: Option, + pub search_pattern: Option, } pub(crate) struct App { @@ -106,6 +107,7 @@ impl App { clipboard, file_watcher: None, needs_redraw: true, + search_pattern: None, }, }; @@ -565,6 +567,28 @@ impl App { result } + pub(crate) fn search_prompt( + &mut self, + term: &mut Term, + prompt_text: &str, + on_change: &mut dyn FnMut(&mut Self, &str), + ) -> Res { + self.hide_menu(); + + self.state.prompt.set(prompt::PromptData { + prompt_text: format!("{}:", prompt_text).into(), + }); + let result = self.handle_search_prompt(term, on_change); + + self.unhide_menu(); + if result.is_err() { + self.close_menu(); + } + self.state.prompt.reset(term)?; + + result + } + fn handle_prompt(&mut self, term: &mut Term, params: &PromptParams) -> Res { self.redraw_now(term)?; @@ -582,6 +606,34 @@ impl App { } } + fn handle_search_prompt( + &mut self, + term: &mut Term, + on_change: &mut dyn FnMut(&mut Self, &str), + ) -> Res { + self.redraw_now(term)?; + let mut last_value = String::new(); + + loop { + let event = term.backend_mut().read_event()?; + self.handle_event(term, event)?; + + let current_value = self.state.prompt.state.value().to_string(); + if current_value != last_value { + on_change(self, ¤t_value); + last_value = current_value; + } + + if self.state.prompt.state.status().is_done() { + return Ok(self.state.prompt.state.value().to_string()); + } else if self.state.prompt.state.status().is_aborted() { + return Err(Error::PromptAborted); + } + + self.redraw_now(term)?; + } + } + pub fn confirm(&mut self, term: &mut Term, prompt: &'static str) -> Res<()> { self.hide_menu(); self.state.prompt.set(prompt::PromptData { diff --git a/src/config.rs b/src/config.rs index 124fe9dddb..b00bd8e421 100644 --- a/src/config.rs +++ b/src/config.rs @@ -80,6 +80,9 @@ pub struct StyleConfig { pub command: StyleConfigEntry, pub active_arg: StyleConfigEntry, pub hotkey: StyleConfigEntry, + + pub search_match: StyleConfigEntry, + pub current_search_match: StyleConfigEntry, } #[derive(Default, Debug, Deserialize)] diff --git a/src/default_config.toml b/src/default_config.toml index 970f1298f0..b309029381 100644 --- a/src/default_config.toml +++ b/src/default_config.toml @@ -86,8 +86,11 @@ command = { fg = "blue", mods = "BOLD" } active_arg = { fg = "light red", mods = "BOLD" } hotkey = { fg = "magenta" } +search_match = { bg = "yellow", fg = "black", mods = "BOLD" } +current_search_match = { bg = "light yellow", fg = "black", mods = "BOLD" } + [bindings] -root.quit = ["q", "esc"] +root.cancel_or_quit = ["q", "esc"] root.refresh = ["g"] root.toggle_section = ["tab"] root.move_up = ["k", "up"] @@ -99,6 +102,9 @@ root.move_next_section = ["alt+j", "alt+down"] root.move_parent_section = ["alt+h", "alt+left"] root.half_page_up = ["ctrl+u"] root.half_page_down = ["ctrl+d"] +root.search = ["/"] +root.search_next = ["n"] +root.search_previous = ["N"] root.show_refs = ["Y"] root.show = ["enter"] root.discard = ["K"] @@ -107,14 +113,14 @@ root.unstage = ["u"] root.copy_hash = ["y"] root.help_menu = ["h", "?"] -help_menu.quit = ["q", "h", "?", "esc"] +help_menu.cancel_or_quit = ["h", "?", "q", "esc"] root.branch_menu = ["b"] branch_menu.checkout = ["b"] branch_menu.checkout_new_branch = ["c"] branch_menu.spinoff = ["s"] branch_menu.delete = ["K"] -branch_menu.quit = ["q", "esc"] +branch_menu.cancel_or_quit = ["q", "esc"] root.commit_menu = ["c"] commit_menu.--all = ["-a"] @@ -128,19 +134,19 @@ commit_menu.commit_amend = ["a"] commit_menu.commit_extend = ["e"] commit_menu.commit_fixup = ["f"] commit_menu.commit_instant_fixup = ["F"] -commit_menu.quit = ["q", "esc"] +commit_menu.cancel_or_quit = ["q", "esc"] root.fetch_menu = ["f"] fetch_menu.--prune = ["-p"] fetch_menu.--tags = ["-t"] fetch_menu.fetch_all = ["a"] -fetch_menu.quit = ["q", "esc"] +fetch_menu.cancel_or_quit = ["q", "esc"] fetch_menu.fetch_elsewhere = ["e"] root.log_menu = ["l"] log_menu.log_current = ["l"] log_menu.log_other = ["o"] -log_menu.quit = ["q", "esc"] +log_menu.cancel_or_quit = ["q", "esc"] log_menu.-n = ["-n"] log_menu.--grep = ["-F"] @@ -150,14 +156,14 @@ merge_menu.--no-ff = ["-n"] merge_menu.merge_abort = ["a"] merge_menu.merge_continue = ["c"] merge_menu.merge = ["m"] -merge_menu.quit = ["q", ""] +merge_menu.cancel_or_quit = ["q", "esc"] root.pull_menu = ["F"] pull_menu.--rebase = ["-r"] pull_menu.pull_from_push_remote = ["p"] pull_menu.pull_from_upstream = ["u"] pull_menu.pull_from_elsewhere = ["e"] -pull_menu.quit = ["q", "esc"] +pull_menu.cancel_or_quit = ["q", "esc"] root.push_menu = ["P"] push_menu.--force-with-lease = ["-f"] @@ -167,7 +173,7 @@ push_menu.--dry-run = ["-n"] push_menu.push_to_push_remote = ["p"] push_menu.push_to_upstream = ["u"] push_menu.push_to_elsewhere = ["e"] -push_menu.quit = ["q", "esc"] +push_menu.cancel_or_quit = ["q", "esc"] root.rebase_menu = ["r"] rebase_menu.--keep-empty = ["-k"] @@ -182,19 +188,19 @@ rebase_menu.rebase_abort = ["a"] rebase_menu.rebase_continue = ["c"] rebase_menu.rebase_elsewhere = ["e"] rebase_menu.rebase_autosquash = ["f"] -rebase_menu.quit = ["q", "esc"] +rebase_menu.cancel_or_quit = ["q", "esc"] root.remote_menu=["M"] remote_menu.add_remote=["a"] remote_menu.remove_remote=["K"] remote_menu.rename_remote=["r"] -remote_menu.quit = ["q", "esc"] +remote_menu.cancel_or_quit = ["q", "esc"] root.reset_menu = ["X"] reset_menu.reset_soft = ["s"] reset_menu.reset_mixed = ["m"] reset_menu.reset_hard = ["h"] -reset_menu.quit = ["q", "esc"] +reset_menu.cancel_or_quit = ["q", "esc"] root.revert_menu = ["V"] revert_menu.--edit = ["-e"] @@ -203,7 +209,7 @@ revert_menu.--signoff = ["-s"] revert_menu.revert_abort = ["a"] revert_menu.revert_continue = ["c"] revert_menu.revert_commit = ["V"] -revert_menu.quit = ["q", "esc"] +revert_menu.cancel_or_quit = ["q", "esc"] root.stash_menu = ["z"] stash_menu.--all = ["-a"] @@ -215,4 +221,4 @@ stash_menu.stash_keep_index = ["x"] stash_menu.stash_pop = ["p"] stash_menu.stash_apply = ["a"] stash_menu.stash_drop = ["k"] -stash_menu.quit = ["q", "esc"] +stash_menu.cancel_or_quit = ["q", "esc"] diff --git a/src/ops/editor.rs b/src/ops/editor.rs index 1a7ec1e251..dbff213090 100644 --- a/src/ops/editor.rs +++ b/src/ops/editor.rs @@ -8,10 +8,17 @@ use crate::{ }; use std::rc::Rc; -pub(crate) struct Quit; -impl OpTrait for Quit { +pub(crate) struct CancelOrQuit; +impl OpTrait for CancelOrQuit { fn get_action(&self, _target: &ItemData) -> Option { Some(Rc::new(|app, term| { + // First, check if there's an active search and clear it + if app.screen().get_search_pattern().is_some() { + app.screen_mut().clear_search(); + app.state.search_pattern = None; + return Ok(()); + } + let menu = app .state .pending_menu @@ -38,7 +45,7 @@ impl OpTrait for Quit { } fn display(&self, _state: &State) -> String { - "Quit/Close".into() + "Cancel/Quit".into() } } @@ -315,3 +322,70 @@ impl OpTrait for HalfPageDown { "Half page down".into() } } + +pub(crate) struct Search; +impl OpTrait for Search { + fn get_action(&self, _target: &ItemData) -> Option { + Some(Rc::new(|app, term| { + let pattern = match app.search_prompt( + term, + "Search", + &mut |app, value| { + app.screen_mut().search(value, true); + }, + ) { + Ok(p) => p, + Err(_) => { + // Prompt was aborted (Esc pressed), clear the search + app.screen_mut().clear_search(); + app.state.search_pattern = None; + return Ok(()); + } + }; + + app.state.search_pattern = if pattern.is_empty() { + None + } else { + Some(pattern.clone()) + }; + + app.screen_mut().search(&pattern, false); + app.close_menu(); + Ok(()) + })) + } + + fn display(&self, _state: &State) -> String { + "Search".into() + } +} + +pub(crate) struct SearchNext; +impl OpTrait for SearchNext { + fn get_action(&self, _target: &ItemData) -> Option { + Some(Rc::new(|app, _term| { + app.screen_mut().search_next(); + app.close_menu(); + Ok(()) + })) + } + + fn display(&self, _state: &State) -> String { + "Next match".into() + } +} + +pub(crate) struct SearchPrevious; +impl OpTrait for SearchPrevious { + fn get_action(&self, _target: &ItemData) -> Option { + Some(Rc::new(|app, _term| { + app.screen_mut().search_previous(); + app.close_menu(); + Ok(()) + })) + } + + fn display(&self, _state: &State) -> String { + "Previous match".into() + } +} diff --git a/src/ops/mod.rs b/src/ops/mod.rs index 4fdbe02fae..0ab3429141 100644 --- a/src/ops/mod.rs +++ b/src/ops/mod.rs @@ -111,8 +111,12 @@ pub(crate) enum Op { HalfPageUp, HalfPageDown, + Search, + SearchNext, + SearchPrevious, + Refresh, - Quit, + CancelOrQuit, #[serde(untagged)] OpenMenu(Menu), @@ -125,7 +129,7 @@ pub(crate) enum Op { impl Op { pub fn implementation(self) -> Box { match self { - Op::Quit => Box::new(editor::Quit), + Op::CancelOrQuit => Box::new(editor::CancelOrQuit), Op::OpenMenu(menu) => Box::new(editor::OpenMenu(menu)), Op::Refresh => Box::new(editor::Refresh), Op::ToggleArg(name) => Box::new(editor::ToggleArg(name)), @@ -140,6 +144,9 @@ impl Op { Op::MoveParentSection => Box::new(editor::MoveParentSection), Op::HalfPageUp => Box::new(editor::HalfPageUp), Op::HalfPageDown => Box::new(editor::HalfPageDown), + Op::Search => Box::new(editor::Search), + Op::SearchNext => Box::new(editor::SearchNext), + Op::SearchPrevious => Box::new(editor::SearchPrevious), Op::Checkout => Box::new(branch::Checkout), Op::CheckoutNewBranch => Box::new(branch::CheckoutNewBranch), Op::Spinoff => Box::new(branch::Spinoff), diff --git a/src/screen/mod.rs b/src/screen/mod.rs index cdc3ed9579..b5f892c049 100644 --- a/src/screen/mod.rs +++ b/src/screen/mod.rs @@ -27,6 +27,23 @@ pub(crate) enum NavMode { IncludeHunkLines, } +enum SearchState { + Inactive, + Incremental { + pattern: String, + matches: Vec, + current_match_index: Option, + previous_cursor: usize, + previous_scroll: usize, + previous_collapsed: HashSet, + }, + Active { + pattern: String, + matches: Vec, + current_match_index: Option, + }, +} + pub(crate) struct Screen { pub(crate) size: Size, cursor: usize, @@ -36,6 +53,7 @@ pub(crate) struct Screen { items: Vec, line_index: Vec, collapsed: HashSet, + search: SearchState, } impl Screen { @@ -61,6 +79,7 @@ impl Screen { items: vec![], line_index: vec![], collapsed, + search: SearchState::Inactive, }; screen.update()?; @@ -272,6 +291,15 @@ impl Screen { .flatten() .map(|(i, _item)| i) .collect(); + + // Ensure cursor and scroll are within bounds after line_index changes + if !self.line_index.is_empty() { + self.cursor = self.cursor.min(self.line_index.len() - 1); + self.scroll = self.scroll.min(self.line_index.len() - 1); + } else { + self.cursor = 0; + self.scroll = 0; + } } fn is_cursor_off_screen(&self) -> bool { @@ -331,6 +359,308 @@ impl Screen { &self.items[self.line_index[self.cursor]] } + pub(crate) fn is_search_match(&self, item_index: usize) -> bool { + match &self.search { + SearchState::Inactive => false, + SearchState::Incremental { matches, .. } | SearchState::Active { matches, .. } => { + matches.contains(&item_index) + } + } + } + + pub(crate) fn is_current_search_match(&self, item_index: usize) -> bool { + match &self.search { + SearchState::Inactive => false, + SearchState::Incremental { + matches, + current_match_index, + .. + } + | SearchState::Active { + matches, + current_match_index, + .. + } => { + if let Some(current_idx) = current_match_index { + matches.get(*current_idx) == Some(&item_index) + } else { + false + } + } + } + } + + pub(crate) fn search(&mut self, pattern: &str, is_preview: bool) { + if pattern.is_empty() { + self.clear_search(); + return; + } + + // Determine if pattern changed and get previous state if needed + let (pattern_changed, previous_state) = match &self.search { + SearchState::Inactive => (true, None), + SearchState::Incremental { + pattern: old_pattern, + previous_collapsed, + previous_cursor, + previous_scroll, + .. + } => { + let changed = old_pattern != pattern; + if changed { + ( + true, + Some(( + previous_collapsed.clone(), + *previous_cursor, + *previous_scroll, + )), + ) + } else { + ( + false, + Some(( + previous_collapsed.clone(), + *previous_cursor, + *previous_scroll, + )), + ) + } + } + SearchState::Active { + pattern: old_pattern, + .. + } => (old_pattern != pattern, None), + }; + + // Save the current collapsed state and cursor position before expanding for search (only on first preview) + let (previous_collapsed, previous_cursor, previous_scroll) = + if pattern_changed && previous_state.is_none() && is_preview { + (self.collapsed.clone(), self.cursor, self.scroll) + } else { + previous_state.unwrap_or_else(|| (self.collapsed.clone(), self.cursor, self.scroll)) + }; + + // Search through all items (not just visible ones) + let pattern_lower = pattern.to_lowercase(); + let mut matches = Vec::new(); + for (item_index, item) in self.items.iter().enumerate() { + let line_text = item + .to_line(Arc::clone(&self.config)) + .to_string() + .to_lowercase(); + if line_text.contains(&pattern_lower) { + matches.push(item_index); + } + } + + // If pattern changed during preview, restore the previous collapsed state before re-expanding + if pattern_changed && is_preview { + self.collapsed = previous_collapsed.clone(); + self.update_line_index(); + } + + // Expand collapsed sections that contain matches + for &item_index in &matches { + self.expand_to_item(item_index); + } + + // Update line index after expanding sections + self.update_line_index(); + + // Move to the first match (always forward) + let current_match_index = if !matches.is_empty() { + let current_item_index = self.line_index.get(self.cursor).copied().unwrap_or(0); + + // Find the first match at or after the current position + let idx = matches + .iter() + .position(|&match_idx| match_idx >= current_item_index) + .or(Some(0)); // If no match after cursor, wrap to first match + + if let Some(match_idx) = idx { + self.move_to_match_with_matches(&matches, match_idx); + } + idx + } else { + // No matches found during preview - restore cursor and scroll position + if is_preview { + self.cursor = previous_cursor; + self.scroll = previous_scroll; + } + None + }; + + // Update search state based on preview mode + self.search = if is_preview { + SearchState::Incremental { + pattern: pattern.to_string(), + matches, + current_match_index, + previous_collapsed, + previous_cursor, + previous_scroll, + } + } else { + SearchState::Active { + pattern: pattern.to_string(), + matches, + current_match_index, + } + }; + } + + pub(crate) fn search_next(&mut self) { + let (matches, current_match_index) = match &self.search { + SearchState::Inactive => return, + SearchState::Incremental { + matches, + current_match_index, + .. + } + | SearchState::Active { + matches, + current_match_index, + .. + } => { + if matches.is_empty() { + return; + } + (matches.clone(), *current_match_index) + } + }; + + // Move to next match (forward) + let new_match_index = match current_match_index { + Some(idx) if idx + 1 < matches.len() => Some(idx + 1), + _ => Some(0), // Wrap to first match + }; + + if let Some(match_idx) = new_match_index { + self.move_to_match_with_matches(&matches, match_idx); + } + + // Update the search state with new current_match_index + match &mut self.search { + SearchState::Incremental { + current_match_index, + .. + } + | SearchState::Active { + current_match_index, + .. + } => { + *current_match_index = new_match_index; + } + SearchState::Inactive => {} + } + } + + pub(crate) fn search_previous(&mut self) { + let (matches, current_match_index) = match &self.search { + SearchState::Inactive => return, + SearchState::Incremental { + matches, + current_match_index, + .. + } + | SearchState::Active { + matches, + current_match_index, + .. + } => { + if matches.is_empty() { + return; + } + (matches.clone(), *current_match_index) + } + }; + + // Move to previous match (backward) + let new_match_index = match current_match_index { + Some(0) | None => Some(matches.len() - 1), // Wrap to last match + Some(idx) => Some(idx - 1), + }; + + if let Some(match_idx) = new_match_index { + self.move_to_match_with_matches(&matches, match_idx); + } + + // Update the search state with new current_match_index + match &mut self.search { + SearchState::Incremental { + current_match_index, + .. + } + | SearchState::Active { + current_match_index, + .. + } => { + *current_match_index = new_match_index; + } + SearchState::Inactive => {} + } + } + + pub(crate) fn clear_search(&mut self) { + // Restore the previous state if in incremental search + let restore_state = if let SearchState::Incremental { + previous_collapsed, + previous_cursor, + previous_scroll, + .. + } = &self.search + { + Some((previous_collapsed.clone(), *previous_cursor, *previous_scroll)) + } else { + None + }; + + if let Some((previous_collapsed, previous_cursor, previous_scroll)) = restore_state { + self.collapsed = previous_collapsed; + self.update_line_index(); + self.cursor = previous_cursor; + self.scroll = previous_scroll; + } + + self.search = SearchState::Inactive; + } + + pub(crate) fn get_search_pattern(&self) -> Option<&str> { + match &self.search { + SearchState::Inactive => None, + SearchState::Incremental { pattern, .. } | SearchState::Active { pattern, .. } => { + Some(pattern.as_str()) + } + } + } + + fn expand_to_item(&mut self, item_index: usize) { + // Expand all parent sections to make this item visible + let item = &self.items[item_index]; + let target_depth = item.depth; + + // Find all sections that could be parents of this item + for i in (0..item_index).rev() { + let potential_parent = &self.items[i]; + if potential_parent.data.is_section() && potential_parent.depth < target_depth { + // This is a parent section, expand it + self.collapsed.remove(&potential_parent.id); + } + } + } + + fn move_to_match_with_matches(&mut self, matches: &[usize], match_index: usize) { + if let Some(&item_index) = matches.get(match_index) { + // Find the line_index that corresponds to this item_index + if let Some(line_i) = self.line_index.iter().position(|&idx| idx == item_index) { + self.cursor = line_i; + self.scroll_fit_end(); + self.scroll_fit_start(); + } + } + } + pub(crate) fn is_valid_screen_line(&self, screen_line: usize) -> bool { let target_line_i = screen_line + self.scroll; if self.line_index.is_empty() || target_line_i >= self.line_index.len() { @@ -340,10 +670,12 @@ impl Screen { } fn line_views(&'_ self, area: Size) -> impl Iterator> { - let scan_start = self.scroll.min(self.cursor); + let scan_start = self.scroll.min(self.cursor).min(self.line_index.len()); let scan_end = (self.scroll + area.height as usize).min(self.line_index.len()); - let scan_highlight_range = scan_start..(scan_end); - let context_lines = self.scroll - scan_start; + // Ensure scan_end is never less than scan_start + let scan_end = scan_end.max(scan_start); + let scan_highlight_range = scan_start..scan_end; + let context_lines = self.scroll.saturating_sub(scan_start); self.line_index[scan_highlight_range] .iter() @@ -395,10 +727,64 @@ pub(crate) fn layout_screen<'a>(layout: &mut UiTree<'a>, size: Size, screen: &'a layout_span(layout, gutter_char); line.display.spans.into_iter().for_each(|span| { - let style = bg.patch(line.display.style).patch(span.style); - let span_width = span.content.graphemes(true).count(); + // Check if we need to highlight search matches in this span + if let Some(pattern) = screen.get_search_pattern() { + if screen.is_search_match(line.item_index) && !pattern.is_empty() { + // Split the span into parts: non-match and match + let pattern_lower = pattern.to_lowercase(); + let content_lower = span.content.to_lowercase(); + + let mut pos = 0; + let content_chars: Vec = span.content.chars().collect(); + + for (match_start, _) in content_lower.match_indices(&pattern_lower) { + // Add non-match part before this match + if match_start > pos { + let before: String = + content_chars[pos..match_start].iter().collect(); + let before_style = + bg.patch(line.display.style).patch(span.style); + ui::layout_span(layout, (before.into(), before_style)); + } + + // Add highlighted match part + let match_end = match_start + pattern.chars().count(); + let matched: String = + content_chars[match_start..match_end].iter().collect(); + + // Use current_search_match style for the current match, search_match for others + let search_style = + if screen.is_current_search_match(line.item_index) { + &style.current_search_match + } else { + &style.search_match + }; + + let match_style = bg + .patch(line.display.style) + .patch(span.style) + .patch(Style::from(search_style)); + ui::layout_span(layout, (matched.into(), match_style)); + + pos = match_end; + } + + // Add remaining non-match part + if pos < content_chars.len() { + let after: String = content_chars[pos..].iter().collect(); + let after_style = bg.patch(line.display.style).patch(span.style); + ui::layout_span(layout, (after.into(), after_style)); + } + + return; + } + } + + // No search highlighting needed + let style = bg.patch(line.display.style).patch(span.style); + if line_end + span_width >= size.width as usize { // Truncate the span and insert an ellipsis to indicate overflow let overflow = line_end + span_width - size.width as usize; diff --git a/src/tests/snapshots/gitu__tests__branch__branch_menu.snap b/src/tests/snapshots/gitu__tests__branch__branch_menu.snap index b8ca8a6f20..69b12672a7 100644 --- a/src/tests/snapshots/gitu__tests__branch__branch_menu.snap +++ b/src/tests/snapshots/gitu__tests__branch__branch_menu.snap @@ -21,5 +21,5 @@ expression: ctx.redact_buffer() c Checkout new branch | s Spinoff branch | K Delete branch | - q/esc Quit/Close | + q/esc Cancel/Quit | styles_hash: 63682a09d9ecb167 diff --git a/src/tests/snapshots/gitu__tests__commit__commit_menu.snap b/src/tests/snapshots/gitu__tests__commit__commit_menu.snap index 74bced0e5f..d8230a709a 100644 --- a/src/tests/snapshots/gitu__tests__commit__commit_menu.snap +++ b/src/tests/snapshots/gitu__tests__commit__commit_menu.snap @@ -15,11 +15,11 @@ expression: ctx.redact_buffer() | | ────────────────────────────────────────────────────────────────────────────────| - Commit Arguments | - c Commit -a Stage all modified and deleted files (--all) | - a amend -e Allow empty commit (--allow-empty) | - e extend -n Disable hooks (--no-verify) | - q/esc Quit/Close -R Claim authorship and reset author date (--reset-author| - -s Add Signed-off-by line (--signoff) | - -v Show diff of changes to be committed (--verbose) | -styles_hash: 88fc50d8209449af + Commit Arguments | + c Commit -a Stage all modified and deleted files (--all) | + a amend -e Allow empty commit (--allow-empty) | + e extend -n Disable hooks (--no-verify) | + q/esc Cancel/Quit -R Claim authorship and reset author date (--reset-autho| + -s Add Signed-off-by line (--signoff) | + -v Show diff of changes to be committed (--verbose) | +styles_hash: 9bcd40a257c3c9dc diff --git a/src/tests/snapshots/gitu__tests__help_menu.snap b/src/tests/snapshots/gitu__tests__help_menu.snap index 4167118d02..6e5384b8d3 100644 --- a/src/tests/snapshots/gitu__tests__help_menu.snap +++ b/src/tests/snapshots/gitu__tests__help_menu.snap @@ -5,8 +5,6 @@ expression: ctx.redact_buffer() β–ŒOn branch main | β–ŒYour branch is up to date with 'origin/main'. | | - Recent commits | - b66a0bf main origin/main add initial-file | ────────────────────────────────────────────────────────────────────────────────| Help Submenu On branch main | Y Show Refs b Branch tab Fold | @@ -19,7 +17,9 @@ expression: ctx.redact_buffer() alt+h/alt+left Parent section F Pull | ctrl+u Half page up P Push | ctrl+d Half page down r Rebase | - g Refresh X Reset | - q/esc Quit/Close V Revert | - z Stash | -styles_hash: f74c6b31abe71f25 + / Search X Reset | + n Next match V Revert | + N Previous match z Stash | + g Refresh | + q/esc Cancel/Quit | +styles_hash: 18ae49025e476d0b diff --git a/src/tests/snapshots/gitu__tests__log__grep_prompt.snap b/src/tests/snapshots/gitu__tests__log__grep_prompt.snap index d6966e4a76..26bf9a04f6 100644 --- a/src/tests/snapshots/gitu__tests__log__grep_prompt.snap +++ b/src/tests/snapshots/gitu__tests__log__grep_prompt.snap @@ -16,10 +16,10 @@ expression: ctx.redact_buffer() | | ────────────────────────────────────────────────────────────────────────────────| - Log Arguments | - l current -F Search messages (--grep) | - o other -n Limit number of commits (-n=256) | - q/esc Quit/Close | + Log Arguments | + l current -F Search messages (--grep) | + o other -n Limit number of commits (-n=256) | + q/esc Cancel/Quit | ────────────────────────────────────────────────────────────────────────────────| ? Search messages: β€Ί β–ˆ | -styles_hash: f2ffbba63af59676 +styles_hash: 58ae8702fdb4316b diff --git a/src/tests/snapshots/gitu__tests__log__grep_set_example.snap b/src/tests/snapshots/gitu__tests__log__grep_set_example.snap index 14edb11854..0869adc166 100644 --- a/src/tests/snapshots/gitu__tests__log__grep_set_example.snap +++ b/src/tests/snapshots/gitu__tests__log__grep_set_example.snap @@ -18,8 +18,8 @@ expression: ctx.redact_buffer() | | ────────────────────────────────────────────────────────────────────────────────| - Log Arguments | - l current -F Search messages (--grep=example) | - o other -n Limit number of commits (-n=256) | - q/esc Quit/Close | -styles_hash: 1e56855f48c264ea + Log Arguments | + l current -F Search messages (--grep=example) | + o other -n Limit number of commits (-n=256) | + q/esc Cancel/Quit | +styles_hash: 571ccce41d39a523 diff --git a/src/tests/snapshots/gitu__tests__log__limit_prompt.snap b/src/tests/snapshots/gitu__tests__log__limit_prompt.snap index 5423e6cc09..c65653981b 100644 --- a/src/tests/snapshots/gitu__tests__log__limit_prompt.snap +++ b/src/tests/snapshots/gitu__tests__log__limit_prompt.snap @@ -16,10 +16,10 @@ expression: ctx.redact_buffer() | | ────────────────────────────────────────────────────────────────────────────────| - Log Arguments | - l current -F Search messages (--grep) | - o other -n Limit number of commits (-n) | - q/esc Quit/Close | + Log Arguments | + l current -F Search messages (--grep) | + o other -n Limit number of commits (-n) | + q/esc Cancel/Quit | ────────────────────────────────────────────────────────────────────────────────| ? Limit number of commits (default 256): β€Ί β–ˆ | -styles_hash: 995bcfc3a40b8076 +styles_hash: 4362f16f6b240c7b diff --git a/src/tests/snapshots/gitu__tests__log__limit_set_10.snap b/src/tests/snapshots/gitu__tests__log__limit_set_10.snap index 0f9ce3d88b..649663f6f4 100644 --- a/src/tests/snapshots/gitu__tests__log__limit_set_10.snap +++ b/src/tests/snapshots/gitu__tests__log__limit_set_10.snap @@ -18,8 +18,8 @@ expression: ctx.redact_buffer() | | ────────────────────────────────────────────────────────────────────────────────| - Log Arguments | - l current -F Search messages (--grep) | - o other -n Limit number of commits (-n=10) | - q/esc Quit/Close | -styles_hash: c0b5b1667e9e6cf5 + Log Arguments | + l current -F Search messages (--grep) | + o other -n Limit number of commits (-n=10) | + q/esc Cancel/Quit | +styles_hash: beb80822c6a1a845 diff --git a/src/tests/snapshots/gitu__tests__merge__merge_menu.snap b/src/tests/snapshots/gitu__tests__merge__merge_menu.snap index 6bff6d0668..4f3db74b9e 100644 --- a/src/tests/snapshots/gitu__tests__merge__merge_menu.snap +++ b/src/tests/snapshots/gitu__tests__merge__merge_menu.snap @@ -17,9 +17,9 @@ expression: ctx.redact_buffer() | | ────────────────────────────────────────────────────────────────────────────────| - Merge Arguments | - m merge -f Fast-forward only (--ff-only) | - a abort -n No fast-forward (--no-ff) | + Merge Arguments | + m merge -f Fast-forward only (--ff-only) | + a abort -n No fast-forward (--no-ff) | c continue | - q/ Quit/Close | -styles_hash: 7d5a524b962b3c2 + q/esc Cancel/Quit | +styles_hash: 896dc66004d08f18 diff --git a/src/tests/snapshots/gitu__tests__pull__pull_menu_existing_push_remote_and_upstream.snap b/src/tests/snapshots/gitu__tests__pull__pull_menu_existing_push_remote_and_upstream.snap index 219628b70f..be760840cb 100644 --- a/src/tests/snapshots/gitu__tests__pull__pull_menu_existing_push_remote_and_upstream.snap +++ b/src/tests/snapshots/gitu__tests__pull__pull_menu_existing_push_remote_and_upstream.snap @@ -21,5 +21,5 @@ expression: ctx.redact_buffer() p from origin -r Rebase local commits (--rebase) | u from origin/main | e from elsewhere | - q/esc Quit/Close | + q/esc Cancel/Quit | styles_hash: 77ad4104e5ef8a5a diff --git a/src/tests/snapshots/gitu__tests__pull__pull_menu_no_remote_or_upstream_set.snap b/src/tests/snapshots/gitu__tests__pull__pull_menu_no_remote_or_upstream_set.snap index 9271450571..f585452957 100644 --- a/src/tests/snapshots/gitu__tests__pull__pull_menu_no_remote_or_upstream_set.snap +++ b/src/tests/snapshots/gitu__tests__pull__pull_menu_no_remote_or_upstream_set.snap @@ -21,5 +21,5 @@ expression: ctx.redact_buffer() p pushRemote, setting that -r Rebase local commits (--rebase) | u upstream, setting that | e from elsewhere | - q/esc Quit/Close | + q/esc Cancel/Quit | styles_hash: 9639b349a71ac562 diff --git a/src/tests/snapshots/gitu__tests__pull__pull_setup_push_remote.snap b/src/tests/snapshots/gitu__tests__pull__pull_setup_push_remote.snap index 219628b70f..be760840cb 100644 --- a/src/tests/snapshots/gitu__tests__pull__pull_setup_push_remote.snap +++ b/src/tests/snapshots/gitu__tests__pull__pull_setup_push_remote.snap @@ -21,5 +21,5 @@ expression: ctx.redact_buffer() p from origin -r Rebase local commits (--rebase) | u from origin/main | e from elsewhere | - q/esc Quit/Close | + q/esc Cancel/Quit | styles_hash: 77ad4104e5ef8a5a diff --git a/src/tests/snapshots/gitu__tests__pull__pull_setup_upstream.snap b/src/tests/snapshots/gitu__tests__pull__pull_setup_upstream.snap index 089cfceae3..82579c7a8a 100644 --- a/src/tests/snapshots/gitu__tests__pull__pull_setup_upstream.snap +++ b/src/tests/snapshots/gitu__tests__pull__pull_setup_upstream.snap @@ -21,5 +21,5 @@ expression: ctx.redact_buffer() p pushRemote, setting that -r Rebase local commits (--rebase) | u from main | e from elsewhere | - q/esc Quit/Close | + q/esc Cancel/Quit | styles_hash: 596dee06411c0ed2 diff --git a/src/tests/snapshots/gitu__tests__pull__pull_setup_upstream_same_as_head.snap b/src/tests/snapshots/gitu__tests__pull__pull_setup_upstream_same_as_head.snap index 24c345f387..f11cf6d088 100644 --- a/src/tests/snapshots/gitu__tests__pull__pull_setup_upstream_same_as_head.snap +++ b/src/tests/snapshots/gitu__tests__pull__pull_setup_upstream_same_as_head.snap @@ -18,7 +18,7 @@ expression: ctx.redact_buffer() p pushRemote, setting that -r Rebase local commits (--rebase) | u upstream, setting that | e from elsewhere | - q/esc Quit/Close | + q/esc Cancel/Quit | ────────────────────────────────────────────────────────────────────────────────| $ git branch --set-upstream-to new-branch | warning: not setting branch 'new-branch' as its own upstream | diff --git a/src/tests/snapshots/gitu__tests__push__open_push_menu_after_dash_input.snap b/src/tests/snapshots/gitu__tests__push__open_push_menu_after_dash_input.snap index 3aaafad19b..61cf781b2c 100644 --- a/src/tests/snapshots/gitu__tests__push__open_push_menu_after_dash_input.snap +++ b/src/tests/snapshots/gitu__tests__push__open_push_menu_after_dash_input.snap @@ -21,5 +21,5 @@ expression: ctx.redact_buffer() p pushRemote, setting that -n Dry run (--dry-run) | u to origin/main -F Force (--force) | e to elsewhere -f Force with lease (--force-with-lease) | - q/esc Quit/Close -h Disable hooks (--no-verify) | + q/esc Cancel/Quit -h Disable hooks (--no-verify) | styles_hash: ea7e8887809d15 diff --git a/src/tests/snapshots/gitu__tests__push__push_menu_existing_push_remote_and_upstream.snap b/src/tests/snapshots/gitu__tests__push__push_menu_existing_push_remote_and_upstream.snap index 0186116444..0a9348a9a1 100644 --- a/src/tests/snapshots/gitu__tests__push__push_menu_existing_push_remote_and_upstream.snap +++ b/src/tests/snapshots/gitu__tests__push__push_menu_existing_push_remote_and_upstream.snap @@ -17,9 +17,9 @@ expression: ctx.redact_buffer() | | ────────────────────────────────────────────────────────────────────────────────| - Push Arguments | - p to origin -n Dry run (--dry-run) | - u to origin/main -F Force (--force) | - e to elsewhere -f Force with lease (--force-with-lease) | - q/esc Quit/Close -h Disable hooks (--no-verify) | -styles_hash: bbb3756710e89a5b + Push Arguments | + p to origin -n Dry run (--dry-run) | + u to origin/main -F Force (--force) | + e to elsewhere -f Force with lease (--force-with-lease) | + q/esc Cancel/Quit -h Disable hooks (--no-verify) | +styles_hash: b8e6e3191ab7d875 diff --git a/src/tests/snapshots/gitu__tests__push__push_menu_no_remote_or_upstream_set.snap b/src/tests/snapshots/gitu__tests__push__push_menu_no_remote_or_upstream_set.snap index 6ee5a8593e..de65e2763a 100644 --- a/src/tests/snapshots/gitu__tests__push__push_menu_no_remote_or_upstream_set.snap +++ b/src/tests/snapshots/gitu__tests__push__push_menu_no_remote_or_upstream_set.snap @@ -21,5 +21,5 @@ expression: ctx.redact_buffer() p pushRemote, setting that -n Dry run (--dry-run) | u upstream, setting that -F Force (--force) | e to elsewhere -f Force with lease (--force-with-lease) | - q/esc Quit/Close -h Disable hooks (--no-verify) | + q/esc Cancel/Quit -h Disable hooks (--no-verify) | styles_hash: e59aeca8620fea8a diff --git a/src/tests/snapshots/gitu__tests__push__push_setup_push_remote.snap b/src/tests/snapshots/gitu__tests__push__push_setup_push_remote.snap index 0186116444..0a9348a9a1 100644 --- a/src/tests/snapshots/gitu__tests__push__push_setup_push_remote.snap +++ b/src/tests/snapshots/gitu__tests__push__push_setup_push_remote.snap @@ -17,9 +17,9 @@ expression: ctx.redact_buffer() | | ────────────────────────────────────────────────────────────────────────────────| - Push Arguments | - p to origin -n Dry run (--dry-run) | - u to origin/main -F Force (--force) | - e to elsewhere -f Force with lease (--force-with-lease) | - q/esc Quit/Close -h Disable hooks (--no-verify) | -styles_hash: bbb3756710e89a5b + Push Arguments | + p to origin -n Dry run (--dry-run) | + u to origin/main -F Force (--force) | + e to elsewhere -f Force with lease (--force-with-lease) | + q/esc Cancel/Quit -h Disable hooks (--no-verify) | +styles_hash: b8e6e3191ab7d875 diff --git a/src/tests/snapshots/gitu__tests__push__push_setup_upstream.snap b/src/tests/snapshots/gitu__tests__push__push_setup_upstream.snap index 281490b575..1dc13bad58 100644 --- a/src/tests/snapshots/gitu__tests__push__push_setup_upstream.snap +++ b/src/tests/snapshots/gitu__tests__push__push_setup_upstream.snap @@ -21,5 +21,5 @@ expression: ctx.redact_buffer() p pushRemote, setting that -n Dry run (--dry-run) | u to main -F Force (--force) | e to elsewhere -f Force with lease (--force-with-lease) | - q/esc Quit/Close -h Disable hooks (--no-verify) | + q/esc Cancel/Quit -h Disable hooks (--no-verify) | styles_hash: 245c32232419e29c diff --git a/src/tests/snapshots/gitu__tests__push__push_setup_upstream_same_as_head.snap b/src/tests/snapshots/gitu__tests__push__push_setup_upstream_same_as_head.snap index aaf9fd39a6..375fcc1362 100644 --- a/src/tests/snapshots/gitu__tests__push__push_setup_upstream_same_as_head.snap +++ b/src/tests/snapshots/gitu__tests__push__push_setup_upstream_same_as_head.snap @@ -18,7 +18,7 @@ expression: ctx.redact_buffer() p pushRemote, setting that -n Dry run (--dry-run) | u upstream, setting that -F Force (--force) | e to elsewhere -f Force with lease (--force-with-lease) | - q/esc Quit/Close -h Disable hooks (--no-verify) | + q/esc Cancel/Quit -h Disable hooks (--no-verify) | ────────────────────────────────────────────────────────────────────────────────| $ git branch --set-upstream-to new-branch | warning: not setting branch 'new-branch' as its own upstream | diff --git a/src/tests/snapshots/gitu__tests__rebase__rebase_menu.snap b/src/tests/snapshots/gitu__tests__rebase__rebase_menu.snap index 2ce4046695..582a3a6197 100644 --- a/src/tests/snapshots/gitu__tests__rebase__rebase_menu.snap +++ b/src/tests/snapshots/gitu__tests__rebase__rebase_menu.snap @@ -14,12 +14,12 @@ expression: ctx.redact_buffer() | | ────────────────────────────────────────────────────────────────────────────────| - Rebase Arguments | - a abort -a Autosquash (--autosquash) | - c continue -A Autostash (--autostash) | - e onto elsewhere -d Lie about committer date (--committer-date-is-author-d| - q/esc Quit/Close -i Interactive (--interactive) | - -k Keep empty commits (--keep-empty) | - -h Disable hooks (--no-verify) | - -p Preserve merges (--preserve-merges) | -styles_hash: d2d8fcdcc0101234 + Rebase Arguments | + a abort -a Autosquash (--autosquash) | + c continue -A Autostash (--autostash) | + e onto elsewhere -d Lie about committer date (--committer-date-is-author-| + q/esc Cancel/Quit -i Interactive (--interactive) | + -k Keep empty commits (--keep-empty) | + -h Disable hooks (--no-verify) | + -p Preserve merges (--preserve-merges) | +styles_hash: e71833a778526b63 diff --git a/src/tests/snapshots/gitu__tests__remote__remote_menu.snap b/src/tests/snapshots/gitu__tests__remote__remote_menu.snap index 00bfedc1e5..f25b79e02d 100644 --- a/src/tests/snapshots/gitu__tests__remote__remote_menu.snap +++ b/src/tests/snapshots/gitu__tests__remote__remote_menu.snap @@ -21,5 +21,5 @@ expression: ctx.redact_buffer() a add remote | K remove remote | r rename remote | - q/esc Quit/Close | + q/esc Cancel/Quit | styles_hash: 21e7ca3132665503 diff --git a/src/tests/snapshots/gitu__tests__reset__reset_menu.snap b/src/tests/snapshots/gitu__tests__reset__reset_menu.snap index a0d9b379fa..6b6d3e15be 100644 --- a/src/tests/snapshots/gitu__tests__reset__reset_menu.snap +++ b/src/tests/snapshots/gitu__tests__reset__reset_menu.snap @@ -21,5 +21,5 @@ expression: ctx.redact_buffer() s soft | m mixed | h hard | - q/esc Quit/Close | + q/esc Cancel/Quit | styles_hash: f58c5db415ea65fc diff --git a/src/tests/snapshots/gitu__tests__revert_menu.snap b/src/tests/snapshots/gitu__tests__revert_menu.snap index 75f72fbecc..a3cc1f4d44 100644 --- a/src/tests/snapshots/gitu__tests__revert_menu.snap +++ b/src/tests/snapshots/gitu__tests__revert_menu.snap @@ -21,5 +21,5 @@ expression: ctx.redact_buffer() a Abort -e Edit commit message (--edit) | c Continue -E Don't edit commit message (--no-edit) | V Revert commit(s) -s Add Signed-off-by lines (--signoff) | - q/esc Quit/Close | + q/esc Cancel/Quit | styles_hash: 75115da5ff8c60b1 diff --git a/src/tests/snapshots/gitu__tests__stash__stash_menu.snap b/src/tests/snapshots/gitu__tests__stash__stash_menu.snap index ffe84cf312..94e762aefa 100644 --- a/src/tests/snapshots/gitu__tests__stash__stash_menu.snap +++ b/src/tests/snapshots/gitu__tests__stash__stash_menu.snap @@ -13,13 +13,13 @@ expression: ctx.redact_buffer() | Recent commits | ────────────────────────────────────────────────────────────────────────────────| - Stash Arguments | - z both -a Also save untracked and ignored files (--all) | - a apply -u Also save untracked files (--include-untracked) | + Stash Arguments | + z both -a Also save untracked and ignored files (--all) | + a apply -u Also save untracked files (--include-untracked) | i index | w worktree | x keeping index | p pop | k drop | - q/esc Quit/Close | -styles_hash: d78f58222bd68be5 + q/esc Cancel/Quit | +styles_hash: b0436aa1c00694fe