Skip to content

Commit cd9cd76

Browse files
sei40krclaude
andcommitted
feat: add search functionality with incremental search
Implement comprehensive search functionality including: - Incremental search with live preview (/ for forward, ? for backward) - 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 with pattern persistence Key changes: - Add SearchForward, SearchBackward, SearchNext, SearchPrevious operations - Separate Cancel operation from Quit (Esc now cancels search first) - Add prompt_with_callback for live search preview - Implement SearchState (Inactive/Incremental/Active) management - Add search_match and current_search_match style configuration 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 7d72007 commit cd9cd76

30 files changed

Lines changed: 735 additions & 101 deletions

src/app.rs

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ pub(crate) struct State {
5555
pub clipboard: Option<Clipboard>,
5656
needs_redraw: bool,
5757
file_watcher: Option<FileWatcher>,
58+
pub search_pattern: Option<String>,
5859
}
5960

6061
pub(crate) struct App {
@@ -106,6 +107,7 @@ impl App {
106107
clipboard,
107108
file_watcher: None,
108109
needs_redraw: true,
110+
search_pattern: None,
109111
},
110112
};
111113

@@ -543,6 +545,15 @@ impl App {
543545
}
544546

545547
pub fn prompt(&mut self, term: &mut Term, params: &PromptParams) -> Res<String> {
548+
self.prompt_with_callback(term, params, &mut |_, _| {})
549+
}
550+
551+
pub(crate) fn prompt_with_callback(
552+
&mut self,
553+
term: &mut Term,
554+
params: &PromptParams,
555+
on_change: &mut dyn FnMut(&mut Self, &str),
556+
) -> Res<String> {
546557
let prompt_text = if let Some(default) = (params.create_default_value)(self) {
547558
format!("{} (default {}):", params.prompt, default).into()
548559
} else {
@@ -554,7 +565,7 @@ impl App {
554565
}
555566

556567
self.state.prompt.set(prompt::PromptData { prompt_text });
557-
let result = self.handle_prompt(term, params);
568+
let result = self.handle_prompt_with_callback(term, params, on_change);
558569

559570
self.unhide_menu();
560571
if result.is_err() {
@@ -565,13 +576,25 @@ impl App {
565576
result
566577
}
567578

568-
fn handle_prompt(&mut self, term: &mut Term, params: &PromptParams) -> Res<String> {
579+
fn handle_prompt_with_callback(
580+
&mut self,
581+
term: &mut Term,
582+
params: &PromptParams,
583+
on_change: &mut dyn FnMut(&mut Self, &str),
584+
) -> Res<String> {
569585
self.redraw_now(term)?;
586+
let mut last_value = String::new();
570587

571588
loop {
572589
let event = term.backend_mut().read_event()?;
573590
self.handle_event(term, event)?;
574591

592+
let current_value = self.state.prompt.state.value().to_string();
593+
if current_value != last_value {
594+
on_change(self, &current_value);
595+
last_value = current_value;
596+
}
597+
575598
if self.state.prompt.state.status().is_done() {
576599
return get_prompt_result(params, self);
577600
} else if self.state.prompt.state.status().is_aborted() {

src/config.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,9 @@ pub struct StyleConfig {
8080
pub command: StyleConfigEntry,
8181
pub active_arg: StyleConfigEntry,
8282
pub hotkey: StyleConfigEntry,
83+
84+
pub search_match: StyleConfigEntry,
85+
pub current_search_match: StyleConfigEntry,
8386
}
8487

8588
#[derive(Default, Debug, Deserialize)]

src/default_config.toml

Lines changed: 35 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -86,8 +86,12 @@ command = { fg = "blue", mods = "BOLD" }
8686
active_arg = { fg = "light red", mods = "BOLD" }
8787
hotkey = { fg = "magenta" }
8888

89+
search_match = { bg = "yellow", fg = "black", mods = "BOLD" }
90+
current_search_match = { bg = "light yellow", fg = "black", mods = "BOLD" }
91+
8992
[bindings]
90-
root.quit = ["q", "esc"]
93+
root.quit = ["q"]
94+
root.cancel = ["esc"]
9195
root.refresh = ["g"]
9296
root.toggle_section = ["tab"]
9397
root.move_up = ["k", "up"]
@@ -99,6 +103,10 @@ root.move_next_section = ["alt+j", "alt+down"]
99103
root.move_parent_section = ["alt+h", "alt+left"]
100104
root.half_page_up = ["ctrl+u"]
101105
root.half_page_down = ["ctrl+d"]
106+
root.search_forward = ["/"]
107+
root.search_backward = ["?"]
108+
root.search_next = ["n"]
109+
root.search_previous = ["N"]
102110
root.show_refs = ["Y"]
103111
root.show = ["enter"]
104112
root.discard = ["K"]
@@ -107,14 +115,16 @@ root.unstage = ["u"]
107115
root.copy_hash = ["y"]
108116

109117
root.help_menu = ["h", "?"]
110-
help_menu.quit = ["q", "h", "?", "esc"]
118+
help_menu.quit = ["q"]
119+
help_menu.cancel = ["h", "?", "esc"]
111120

112121
root.branch_menu = ["b"]
113122
branch_menu.checkout = ["b"]
114123
branch_menu.checkout_new_branch = ["c"]
115124
branch_menu.spinoff = ["s"]
116125
branch_menu.delete = ["K"]
117-
branch_menu.quit = ["q", "esc"]
126+
branch_menu.quit = ["q"]
127+
branch_menu.cancel = ["esc"]
118128

119129
root.commit_menu = ["c"]
120130
commit_menu.--all = ["-a"]
@@ -128,19 +138,22 @@ commit_menu.commit_amend = ["a"]
128138
commit_menu.commit_extend = ["e"]
129139
commit_menu.commit_fixup = ["f"]
130140
commit_menu.commit_instant_fixup = ["F"]
131-
commit_menu.quit = ["q", "esc"]
141+
commit_menu.quit = ["q"]
142+
commit_menu.cancel = ["esc"]
132143

133144
root.fetch_menu = ["f"]
134145
fetch_menu.--prune = ["-p"]
135146
fetch_menu.--tags = ["-t"]
136147
fetch_menu.fetch_all = ["a"]
137-
fetch_menu.quit = ["q", "esc"]
148+
fetch_menu.quit = ["q"]
149+
fetch_menu.cancel = ["esc"]
138150
fetch_menu.fetch_elsewhere = ["e"]
139151

140152
root.log_menu = ["l"]
141153
log_menu.log_current = ["l"]
142154
log_menu.log_other = ["o"]
143-
log_menu.quit = ["q", "esc"]
155+
log_menu.quit = ["q"]
156+
log_menu.cancel = ["esc"]
144157
log_menu.-n = ["-n"]
145158
log_menu.--grep = ["-F"]
146159

@@ -150,14 +163,16 @@ merge_menu.--no-ff = ["-n"]
150163
merge_menu.merge_abort = ["a"]
151164
merge_menu.merge_continue = ["c"]
152165
merge_menu.merge = ["m"]
153-
merge_menu.quit = ["q", "<esc>"]
166+
merge_menu.quit = ["q"]
167+
merge_menu.cancel = ["esc"]
154168

155169
root.pull_menu = ["F"]
156170
pull_menu.--rebase = ["-r"]
157171
pull_menu.pull_from_push_remote = ["p"]
158172
pull_menu.pull_from_upstream = ["u"]
159173
pull_menu.pull_from_elsewhere = ["e"]
160-
pull_menu.quit = ["q", "esc"]
174+
pull_menu.quit = ["q"]
175+
pull_menu.cancel = ["esc"]
161176

162177
root.push_menu = ["P"]
163178
push_menu.--force-with-lease = ["-f"]
@@ -167,7 +182,8 @@ push_menu.--dry-run = ["-n"]
167182
push_menu.push_to_push_remote = ["p"]
168183
push_menu.push_to_upstream = ["u"]
169184
push_menu.push_to_elsewhere = ["e"]
170-
push_menu.quit = ["q", "esc"]
185+
push_menu.quit = ["q"]
186+
push_menu.cancel = ["esc"]
171187

172188
root.rebase_menu = ["r"]
173189
rebase_menu.--keep-empty = ["-k"]
@@ -182,19 +198,22 @@ rebase_menu.rebase_abort = ["a"]
182198
rebase_menu.rebase_continue = ["c"]
183199
rebase_menu.rebase_elsewhere = ["e"]
184200
rebase_menu.rebase_autosquash = ["f"]
185-
rebase_menu.quit = ["q", "esc"]
201+
rebase_menu.quit = ["q"]
202+
rebase_menu.cancel = ["esc"]
186203

187204
root.remote_menu=["M"]
188205
remote_menu.add_remote=["a"]
189206
remote_menu.remove_remote=["K"]
190207
remote_menu.rename_remote=["r"]
191-
remote_menu.quit = ["q", "esc"]
208+
remote_menu.quit = ["q"]
209+
remote_menu.cancel = ["esc"]
192210

193211
root.reset_menu = ["X"]
194212
reset_menu.reset_soft = ["s"]
195213
reset_menu.reset_mixed = ["m"]
196214
reset_menu.reset_hard = ["h"]
197-
reset_menu.quit = ["q", "esc"]
215+
reset_menu.quit = ["q"]
216+
reset_menu.cancel = ["esc"]
198217

199218
root.revert_menu = ["V"]
200219
revert_menu.--edit = ["-e"]
@@ -203,7 +222,8 @@ revert_menu.--signoff = ["-s"]
203222
revert_menu.revert_abort = ["a"]
204223
revert_menu.revert_continue = ["c"]
205224
revert_menu.revert_commit = ["V"]
206-
revert_menu.quit = ["q", "esc"]
225+
revert_menu.quit = ["q"]
226+
revert_menu.cancel = ["esc"]
207227

208228
root.stash_menu = ["z"]
209229
stash_menu.--all = ["-a"]
@@ -215,4 +235,5 @@ stash_menu.stash_keep_index = ["x"]
215235
stash_menu.stash_pop = ["p"]
216236
stash_menu.stash_apply = ["a"]
217237
stash_menu.stash_drop = ["k"]
218-
stash_menu.quit = ["q", "esc"]
238+
stash_menu.quit = ["q"]
239+
stash_menu.cancel = ["esc"]

src/ops/editor.rs

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,47 @@ impl OpTrait for Quit {
4242
}
4343
}
4444

45+
pub(crate) struct Cancel;
46+
impl OpTrait for Cancel {
47+
fn get_action(&self, _target: &ItemData) -> Option<Action> {
48+
Some(Rc::new(|app, term| {
49+
// First, check if there's an active search and clear it
50+
if app.screen().get_search_pattern().is_some() {
51+
app.screen_mut().clear_search();
52+
app.state.search_pattern = None;
53+
return Ok(());
54+
}
55+
56+
let menu = app
57+
.state
58+
.pending_menu
59+
.as_ref()
60+
.map(|pending_menu| pending_menu.menu);
61+
62+
if menu == root_menu(&app.state.config) {
63+
if app.state.screens.len() == 1 {
64+
if app.state.config.general.confirm_quit.enabled {
65+
app.confirm(term, "Really quit? (y or n)")?;
66+
};
67+
68+
app.state.quit = true;
69+
} else {
70+
app.state.screens.pop();
71+
}
72+
} else {
73+
app.close_menu();
74+
return Ok(());
75+
}
76+
77+
Ok(())
78+
}))
79+
}
80+
81+
fn display(&self, _state: &State) -> String {
82+
"Cancel/Close".into()
83+
}
84+
}
85+
4586
pub(crate) struct OpenMenu(pub crate::menu::Menu);
4687
impl OpTrait for OpenMenu {
4788
fn get_action(&self, _target: &ItemData) -> Option<Action> {
@@ -315,3 +356,115 @@ impl OpTrait for HalfPageDown {
315356
"Half page down".into()
316357
}
317358
}
359+
360+
pub(crate) struct SearchForward;
361+
impl OpTrait for SearchForward {
362+
fn get_action(&self, _target: &ItemData) -> Option<Action> {
363+
Some(Rc::new(|app, term| {
364+
let pattern = match app.prompt_with_callback(
365+
term,
366+
&PromptParams {
367+
prompt: "Search",
368+
create_default_value: Box::new(|app| app.state.search_pattern.clone()),
369+
hide_menu: true,
370+
},
371+
&mut |app, value| {
372+
app.screen_mut().search(value, crate::screen::SearchDirection::Forward, true);
373+
},
374+
) {
375+
Ok(p) => p,
376+
Err(_) => {
377+
// Prompt was aborted (Esc pressed), clear the search
378+
app.screen_mut().clear_search();
379+
app.state.search_pattern = None;
380+
return Ok(());
381+
}
382+
};
383+
384+
app.state.search_pattern = if pattern.is_empty() {
385+
None
386+
} else {
387+
Some(pattern.clone())
388+
};
389+
390+
app.screen_mut().search(&pattern, crate::screen::SearchDirection::Forward, false);
391+
app.close_menu();
392+
Ok(())
393+
}))
394+
}
395+
396+
fn display(&self, _state: &State) -> String {
397+
"Search forward".into()
398+
}
399+
}
400+
401+
pub(crate) struct SearchBackward;
402+
impl OpTrait for SearchBackward {
403+
fn get_action(&self, _target: &ItemData) -> Option<Action> {
404+
Some(Rc::new(|app, term| {
405+
let pattern = match app.prompt_with_callback(
406+
term,
407+
&PromptParams {
408+
prompt: "Search backward",
409+
create_default_value: Box::new(|app| app.state.search_pattern.clone()),
410+
hide_menu: true,
411+
},
412+
&mut |app, value| {
413+
app.screen_mut().search(value, crate::screen::SearchDirection::Backward, true);
414+
},
415+
) {
416+
Ok(p) => p,
417+
Err(_) => {
418+
// Prompt was aborted (Esc pressed), clear the search
419+
app.screen_mut().clear_search();
420+
app.state.search_pattern = None;
421+
return Ok(());
422+
}
423+
};
424+
425+
app.state.search_pattern = if pattern.is_empty() {
426+
None
427+
} else {
428+
Some(pattern.clone())
429+
};
430+
431+
app.screen_mut().search(&pattern, crate::screen::SearchDirection::Backward, false);
432+
app.close_menu();
433+
Ok(())
434+
}))
435+
}
436+
437+
fn display(&self, _state: &State) -> String {
438+
"Search backward".into()
439+
}
440+
}
441+
442+
pub(crate) struct SearchNext;
443+
impl OpTrait for SearchNext {
444+
fn get_action(&self, _target: &ItemData) -> Option<Action> {
445+
Some(Rc::new(|app, _term| {
446+
app.screen_mut().search_next();
447+
app.close_menu();
448+
Ok(())
449+
}))
450+
}
451+
452+
fn display(&self, _state: &State) -> String {
453+
"Next match".into()
454+
}
455+
}
456+
457+
pub(crate) struct SearchPrevious;
458+
impl OpTrait for SearchPrevious {
459+
fn get_action(&self, _target: &ItemData) -> Option<Action> {
460+
Some(Rc::new(|app, _term| {
461+
app.screen_mut().search_previous();
462+
app.close_menu();
463+
Ok(())
464+
}))
465+
}
466+
467+
fn display(&self, _state: &State) -> String {
468+
"Previous match".into()
469+
}
470+
}

0 commit comments

Comments
 (0)