1use gpui::{actions, impl_actions, AppContext, ViewContext};
2use search::{buffer_search, BufferSearchBar, SearchMode, SearchOptions};
3use serde_derive::Deserialize;
4use workspace::{searchable::Direction, Pane, Toast, Workspace};
5
6use crate::{motion::Motion, normal::move_cursor, state::SearchState, Vim};
7
8#[derive(Clone, Deserialize, PartialEq)]
9#[serde(rename_all = "camelCase")]
10pub(crate) struct MoveToNext {
11 #[serde(default)]
12 partial_word: bool,
13}
14
15#[derive(Clone, Deserialize, PartialEq)]
16#[serde(rename_all = "camelCase")]
17pub(crate) struct MoveToPrev {
18 #[serde(default)]
19 partial_word: bool,
20}
21
22#[derive(Clone, Deserialize, PartialEq)]
23pub(crate) struct Search {
24 #[serde(default)]
25 backwards: bool,
26}
27
28#[derive(Debug, Clone, PartialEq, Deserialize)]
29pub struct FindCommand {
30 pub query: String,
31 pub backwards: bool,
32}
33
34#[derive(Debug, Clone, PartialEq, Deserialize)]
35pub struct ReplaceCommand {
36 pub query: String,
37}
38
39#[derive(Debug)]
40struct Replacement {
41 search: String,
42 replacement: String,
43 should_replace_all: bool,
44 is_case_sensitive: bool,
45}
46
47impl_actions!(
48 vim,
49 [MoveToNext, MoveToPrev, Search, FindCommand, ReplaceCommand]
50);
51actions!(vim, [SearchSubmit]);
52
53pub(crate) fn init(cx: &mut AppContext) {
54 cx.add_action(move_to_next);
55 cx.add_action(move_to_prev);
56 cx.add_action(search);
57 cx.add_action(search_submit);
58 cx.add_action(search_deploy);
59
60 cx.add_action(find_command);
61 cx.add_action(replace_command);
62}
63
64fn move_to_next(workspace: &mut Workspace, action: &MoveToNext, cx: &mut ViewContext<Workspace>) {
65 move_to_internal(workspace, Direction::Next, !action.partial_word, cx)
66}
67
68fn move_to_prev(workspace: &mut Workspace, action: &MoveToPrev, cx: &mut ViewContext<Workspace>) {
69 move_to_internal(workspace, Direction::Prev, !action.partial_word, cx)
70}
71
72fn search(workspace: &mut Workspace, action: &Search, cx: &mut ViewContext<Workspace>) {
73 let pane = workspace.active_pane().clone();
74 let direction = if action.backwards {
75 Direction::Prev
76 } else {
77 Direction::Next
78 };
79 Vim::update(cx, |vim, cx| {
80 let count = vim.take_count(cx).unwrap_or(1);
81 pane.update(cx, |pane, cx| {
82 if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() {
83 search_bar.update(cx, |search_bar, cx| {
84 if !search_bar.show(cx) {
85 return;
86 }
87 let query = search_bar.query(cx);
88
89 search_bar.select_query(cx);
90 cx.focus_self();
91
92 if query.is_empty() {
93 search_bar.set_replacement(None, cx);
94 search_bar.set_search_options(SearchOptions::CASE_SENSITIVE, cx);
95 search_bar.activate_search_mode(SearchMode::Regex, cx);
96 }
97 vim.workspace_state.search = SearchState {
98 direction,
99 count,
100 initial_query: query.clone(),
101 };
102 });
103 }
104 })
105 })
106}
107
108// hook into the existing to clear out any vim search state on cmd+f or edit -> find.
109fn search_deploy(_: &mut Pane, _: &buffer_search::Deploy, cx: &mut ViewContext<Pane>) {
110 Vim::update(cx, |vim, _| vim.workspace_state.search = Default::default());
111 cx.propagate_action();
112}
113
114fn search_submit(workspace: &mut Workspace, _: &SearchSubmit, cx: &mut ViewContext<Workspace>) {
115 Vim::update(cx, |vim, cx| {
116 let pane = workspace.active_pane().clone();
117 pane.update(cx, |pane, cx| {
118 if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() {
119 search_bar.update(cx, |search_bar, cx| {
120 let state = &mut vim.workspace_state.search;
121 let mut count = state.count;
122 let direction = state.direction;
123
124 // in the case that the query has changed, the search bar
125 // will have selected the next match already.
126 if (search_bar.query(cx) != state.initial_query)
127 && state.direction == Direction::Next
128 {
129 count = count.saturating_sub(1)
130 }
131 state.count = 1;
132 search_bar.select_match(direction, count, cx);
133 search_bar.focus_editor(&Default::default(), cx);
134 });
135 }
136 });
137 })
138}
139
140pub fn move_to_internal(
141 workspace: &mut Workspace,
142 direction: Direction,
143 whole_word: bool,
144 cx: &mut ViewContext<Workspace>,
145) {
146 Vim::update(cx, |vim, cx| {
147 let pane = workspace.active_pane().clone();
148 let count = vim.take_count(cx).unwrap_or(1);
149 pane.update(cx, |pane, cx| {
150 if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() {
151 let search = search_bar.update(cx, |search_bar, cx| {
152 let mut options = SearchOptions::CASE_SENSITIVE;
153 options.set(SearchOptions::WHOLE_WORD, whole_word);
154 if search_bar.show(cx) {
155 search_bar
156 .query_suggestion(cx)
157 .map(|query| search_bar.search(&query, Some(options), cx))
158 } else {
159 None
160 }
161 });
162
163 if let Some(search) = search {
164 let search_bar = search_bar.downgrade();
165 cx.spawn(|_, mut cx| async move {
166 search.await?;
167 search_bar.update(&mut cx, |search_bar, cx| {
168 search_bar.select_match(direction, count, cx)
169 })?;
170 anyhow::Ok(())
171 })
172 .detach_and_log_err(cx);
173 }
174 }
175 });
176 vim.clear_operator(cx);
177 });
178}
179
180fn find_command(workspace: &mut Workspace, action: &FindCommand, cx: &mut ViewContext<Workspace>) {
181 let pane = workspace.active_pane().clone();
182 pane.update(cx, |pane, cx| {
183 if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() {
184 let search = search_bar.update(cx, |search_bar, cx| {
185 if !search_bar.show(cx) {
186 return None;
187 }
188 let mut query = action.query.clone();
189 if query == "" {
190 query = search_bar.query(cx);
191 };
192
193 search_bar.activate_search_mode(SearchMode::Regex, cx);
194 Some(search_bar.search(&query, Some(SearchOptions::CASE_SENSITIVE), cx))
195 });
196 let Some(search) = search else { return };
197 let search_bar = search_bar.downgrade();
198 cx.spawn(|_, mut cx| async move {
199 search.await?;
200 search_bar.update(&mut cx, |search_bar, cx| {
201 search_bar.select_match(Direction::Next, 1, cx)
202 })?;
203 anyhow::Ok(())
204 })
205 .detach_and_log_err(cx);
206 }
207 })
208}
209
210fn replace_command(
211 workspace: &mut Workspace,
212 action: &ReplaceCommand,
213 cx: &mut ViewContext<Workspace>,
214) {
215 let replacement = match parse_replace_all(&action.query) {
216 Ok(replacement) => replacement,
217 Err(message) => {
218 cx.handle().update(cx, |workspace, cx| {
219 workspace.show_toast(Toast::new(1544, message), cx)
220 });
221 return;
222 }
223 };
224 let pane = workspace.active_pane().clone();
225 pane.update(cx, |pane, cx| {
226 if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() {
227 let search = search_bar.update(cx, |search_bar, cx| {
228 if !search_bar.show(cx) {
229 return None;
230 }
231
232 let mut options = SearchOptions::default();
233 if replacement.is_case_sensitive {
234 options.set(SearchOptions::CASE_SENSITIVE, true)
235 }
236 let search = if replacement.search == "" {
237 search_bar.query(cx)
238 } else {
239 replacement.search
240 };
241
242 search_bar.set_replacement(Some(&replacement.replacement), cx);
243 search_bar.activate_search_mode(SearchMode::Regex, cx);
244 Some(search_bar.search(&search, Some(options), cx))
245 });
246 let Some(search) = search else { return };
247 let search_bar = search_bar.downgrade();
248 cx.spawn(|_, mut cx| async move {
249 search.await?;
250 search_bar.update(&mut cx, |search_bar, cx| {
251 if replacement.should_replace_all {
252 search_bar.select_last_match(cx);
253 search_bar.replace_all(&Default::default(), cx);
254 Vim::update(cx, |vim, cx| {
255 move_cursor(
256 vim,
257 Motion::StartOfLine {
258 display_lines: false,
259 },
260 None,
261 cx,
262 )
263 })
264 }
265 })?;
266 anyhow::Ok(())
267 })
268 .detach_and_log_err(cx);
269 }
270 })
271}
272
273fn parse_replace_all(query: &str) -> Result<Replacement, String> {
274 let mut chars = query.chars();
275 if Some('%') != chars.next() || Some('s') != chars.next() {
276 return Err("unsupported pattern".to_string());
277 }
278
279 let Some(delimeter) = chars.next() else {
280 return Err("unsupported pattern".to_string());
281 };
282 if delimeter == '\\' || !delimeter.is_ascii_punctuation() {
283 return Err(format!("cannot use {:?} as a search delimeter", delimeter));
284 }
285
286 let mut search = String::new();
287 let mut replacement = String::new();
288 let mut flags = String::new();
289
290 let mut buffer = &mut search;
291
292 let mut escaped = false;
293 let mut phase = 0;
294
295 for c in chars {
296 if escaped {
297 escaped = false;
298 if phase == 1 && c.is_digit(10) {
299 // help vim users discover zed regex syntax
300 // (though we don't try and fix arbitrary patterns for them)
301 buffer.push('$')
302 } else if phase == 0 && c == '(' || c == ')' {
303 // un-escape parens
304 } else if c != delimeter {
305 buffer.push('\\')
306 }
307 buffer.push(c)
308 } else if c == '\\' {
309 escaped = true;
310 } else if c == delimeter {
311 if phase == 0 {
312 buffer = &mut replacement;
313 phase = 1;
314 } else if phase == 1 {
315 buffer = &mut flags;
316 phase = 2;
317 } else {
318 return Err("trailing characters".to_string());
319 }
320 } else {
321 buffer.push(c)
322 }
323 }
324
325 let mut replacement = Replacement {
326 search,
327 replacement,
328 should_replace_all: true,
329 is_case_sensitive: true,
330 };
331
332 for c in flags.chars() {
333 match c {
334 'g' | 'I' => {} // defaults,
335 'c' | 'n' => replacement.should_replace_all = false,
336 'i' => replacement.is_case_sensitive = false,
337 _ => return Err(format!("unsupported flag {:?}", c)),
338 }
339 }
340
341 Ok(replacement)
342}
343
344#[cfg(test)]
345mod test {
346 use std::sync::Arc;
347
348 use editor::DisplayPoint;
349 use search::BufferSearchBar;
350
351 use crate::{state::Mode, test::VimTestContext};
352
353 #[gpui::test]
354 async fn test_move_to_next(
355 cx: &mut gpui::TestAppContext,
356 deterministic: Arc<gpui::executor::Deterministic>,
357 ) {
358 let mut cx = VimTestContext::new(cx, true).await;
359 cx.set_state("ˇhi\nhigh\nhi\n", Mode::Normal);
360
361 cx.simulate_keystrokes(["*"]);
362 deterministic.run_until_parked();
363 cx.assert_state("hi\nhigh\nˇhi\n", Mode::Normal);
364
365 cx.simulate_keystrokes(["*"]);
366 deterministic.run_until_parked();
367 cx.assert_state("ˇhi\nhigh\nhi\n", Mode::Normal);
368
369 cx.simulate_keystrokes(["#"]);
370 deterministic.run_until_parked();
371 cx.assert_state("hi\nhigh\nˇhi\n", Mode::Normal);
372
373 cx.simulate_keystrokes(["#"]);
374 deterministic.run_until_parked();
375 cx.assert_state("ˇhi\nhigh\nhi\n", Mode::Normal);
376
377 cx.simulate_keystrokes(["2", "*"]);
378 deterministic.run_until_parked();
379 cx.assert_state("ˇhi\nhigh\nhi\n", Mode::Normal);
380
381 cx.simulate_keystrokes(["g", "*"]);
382 deterministic.run_until_parked();
383 cx.assert_state("hi\nˇhigh\nhi\n", Mode::Normal);
384
385 cx.simulate_keystrokes(["n"]);
386 cx.assert_state("hi\nhigh\nˇhi\n", Mode::Normal);
387
388 cx.simulate_keystrokes(["g", "#"]);
389 deterministic.run_until_parked();
390 cx.assert_state("hi\nˇhigh\nhi\n", Mode::Normal);
391 }
392
393 #[gpui::test]
394 async fn test_search(
395 cx: &mut gpui::TestAppContext,
396 deterministic: Arc<gpui::executor::Deterministic>,
397 ) {
398 let mut cx = VimTestContext::new(cx, true).await;
399
400 cx.set_state("aa\nbˇb\ncc\ncc\ncc\n", Mode::Normal);
401 cx.simulate_keystrokes(["/", "c", "c"]);
402
403 let search_bar = cx.workspace(|workspace, cx| {
404 workspace
405 .active_pane()
406 .read(cx)
407 .toolbar()
408 .read(cx)
409 .item_of_type::<BufferSearchBar>()
410 .expect("Buffer search bar should be deployed")
411 });
412
413 search_bar.read_with(cx.cx, |bar, cx| {
414 assert_eq!(bar.query(cx), "cc");
415 });
416
417 deterministic.run_until_parked();
418
419 cx.update_editor(|editor, cx| {
420 let highlights = editor.all_text_background_highlights(cx);
421 assert_eq!(3, highlights.len());
422 assert_eq!(
423 DisplayPoint::new(2, 0)..DisplayPoint::new(2, 2),
424 highlights[0].0
425 )
426 });
427
428 cx.simulate_keystrokes(["enter"]);
429 cx.assert_state("aa\nbb\nˇcc\ncc\ncc\n", Mode::Normal);
430
431 // n to go to next/N to go to previous
432 cx.simulate_keystrokes(["n"]);
433 cx.assert_state("aa\nbb\ncc\nˇcc\ncc\n", Mode::Normal);
434 cx.simulate_keystrokes(["shift-n"]);
435 cx.assert_state("aa\nbb\nˇcc\ncc\ncc\n", Mode::Normal);
436
437 // ?<enter> to go to previous
438 cx.simulate_keystrokes(["?", "enter"]);
439 deterministic.run_until_parked();
440 cx.assert_state("aa\nbb\ncc\ncc\nˇcc\n", Mode::Normal);
441 cx.simulate_keystrokes(["?", "enter"]);
442 deterministic.run_until_parked();
443 cx.assert_state("aa\nbb\ncc\nˇcc\ncc\n", Mode::Normal);
444
445 // /<enter> to go to next
446 cx.simulate_keystrokes(["/", "enter"]);
447 deterministic.run_until_parked();
448 cx.assert_state("aa\nbb\ncc\ncc\nˇcc\n", Mode::Normal);
449
450 // ?{search}<enter> to search backwards
451 cx.simulate_keystrokes(["?", "b", "enter"]);
452 deterministic.run_until_parked();
453 cx.assert_state("aa\nbˇb\ncc\ncc\ncc\n", Mode::Normal);
454
455 // works with counts
456 cx.simulate_keystrokes(["4", "/", "c"]);
457 deterministic.run_until_parked();
458 cx.simulate_keystrokes(["enter"]);
459 cx.assert_state("aa\nbb\ncc\ncˇc\ncc\n", Mode::Normal);
460
461 // check that searching resumes from cursor, not previous match
462 cx.set_state("ˇaa\nbb\ndd\ncc\nbb\n", Mode::Normal);
463 cx.simulate_keystrokes(["/", "d"]);
464 deterministic.run_until_parked();
465 cx.simulate_keystrokes(["enter"]);
466 cx.assert_state("aa\nbb\nˇdd\ncc\nbb\n", Mode::Normal);
467 cx.update_editor(|editor, cx| editor.move_to_beginning(&Default::default(), cx));
468 cx.assert_state("ˇaa\nbb\ndd\ncc\nbb\n", Mode::Normal);
469 cx.simulate_keystrokes(["/", "b"]);
470 deterministic.run_until_parked();
471 cx.simulate_keystrokes(["enter"]);
472 cx.assert_state("aa\nˇbb\ndd\ncc\nbb\n", Mode::Normal);
473 }
474
475 #[gpui::test]
476 async fn test_non_vim_search(
477 cx: &mut gpui::TestAppContext,
478 deterministic: Arc<gpui::executor::Deterministic>,
479 ) {
480 let mut cx = VimTestContext::new(cx, false).await;
481 cx.set_state("ˇone one one one", Mode::Normal);
482 cx.simulate_keystrokes(["cmd-f"]);
483 deterministic.run_until_parked();
484
485 cx.assert_editor_state("«oneˇ» one one one");
486 cx.simulate_keystrokes(["enter"]);
487 cx.assert_editor_state("one «oneˇ» one one");
488 cx.simulate_keystrokes(["shift-enter"]);
489 cx.assert_editor_state("«oneˇ» one one one");
490 }
491}