1use gpui::{actions, impl_actions, AppContext, ViewContext};
2use search::{buffer_search, BufferSearchBar, SearchMode, SearchOptions};
3use serde_derive::Deserialize;
4use workspace::{searchable::Direction, Pane, 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, Default)]
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 let direction = if action.backwards {
199 Direction::Prev
200 } else {
201 Direction::Next
202 };
203 cx.spawn(|_, mut cx| async move {
204 search.await?;
205 search_bar.update(&mut cx, |search_bar, cx| {
206 search_bar.select_match(direction, 1, cx)
207 })?;
208 anyhow::Ok(())
209 })
210 .detach_and_log_err(cx);
211 }
212 })
213}
214
215fn replace_command(
216 workspace: &mut Workspace,
217 action: &ReplaceCommand,
218 cx: &mut ViewContext<Workspace>,
219) {
220 let replacement = parse_replace_all(&action.query);
221 let pane = workspace.active_pane().clone();
222 pane.update(cx, |pane, cx| {
223 if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() {
224 let search = search_bar.update(cx, |search_bar, cx| {
225 if !search_bar.show(cx) {
226 return None;
227 }
228
229 let mut options = SearchOptions::default();
230 if replacement.is_case_sensitive {
231 options.set(SearchOptions::CASE_SENSITIVE, true)
232 }
233 let search = if replacement.search == "" {
234 search_bar.query(cx)
235 } else {
236 replacement.search
237 };
238
239 search_bar.set_replacement(Some(&replacement.replacement), cx);
240 search_bar.activate_search_mode(SearchMode::Regex, cx);
241 Some(search_bar.search(&search, Some(options), cx))
242 });
243 let Some(search) = search else { return };
244 let search_bar = search_bar.downgrade();
245 cx.spawn(|_, mut cx| async move {
246 search.await?;
247 search_bar.update(&mut cx, |search_bar, cx| {
248 if replacement.should_replace_all {
249 search_bar.select_last_match(cx);
250 search_bar.replace_all(&Default::default(), cx);
251 Vim::update(cx, |vim, cx| {
252 move_cursor(
253 vim,
254 Motion::StartOfLine {
255 display_lines: false,
256 },
257 None,
258 cx,
259 )
260 })
261 }
262 })?;
263 anyhow::Ok(())
264 })
265 .detach_and_log_err(cx);
266 }
267 })
268}
269
270fn parse_replace_all(query: &str) -> Replacement {
271 let mut chars = query.chars();
272 if Some('%') != chars.next() || Some('s') != chars.next() {
273 return Replacement::default();
274 }
275
276 let Some(delimeter) = chars.next() else {
277 return Replacement::default();
278 };
279
280 let mut search = String::new();
281 let mut replacement = String::new();
282 let mut flags = String::new();
283
284 let mut buffer = &mut search;
285
286 let mut escaped = false;
287 let mut phase = 0;
288
289 for c in chars {
290 if escaped {
291 escaped = false;
292 if phase == 1 && c.is_digit(10) {
293 // help vim users discover zed regex syntax
294 // (though we don't try and fix arbitrary patterns for them)
295 buffer.push('$')
296 } else if phase == 0 && c == '(' || c == ')' {
297 // un-escape parens
298 } else if c != delimeter {
299 buffer.push('\\')
300 }
301 buffer.push(c)
302 } else if c == '\\' {
303 escaped = true;
304 } else if c == delimeter {
305 if phase == 0 {
306 buffer = &mut replacement;
307 phase = 1;
308 } else if phase == 1 {
309 buffer = &mut flags;
310 phase = 2;
311 } else {
312 break;
313 }
314 } else {
315 buffer.push(c)
316 }
317 }
318
319 let mut replacement = Replacement {
320 search,
321 replacement,
322 should_replace_all: true,
323 is_case_sensitive: true,
324 };
325
326 for c in flags.chars() {
327 match c {
328 'g' | 'I' => {}
329 'c' | 'n' => replacement.should_replace_all = false,
330 'i' => replacement.is_case_sensitive = false,
331 _ => {}
332 }
333 }
334
335 replacement
336}
337
338#[cfg(test)]
339mod test {
340 use std::sync::Arc;
341
342 use editor::DisplayPoint;
343 use search::BufferSearchBar;
344
345 use crate::{state::Mode, test::VimTestContext};
346
347 #[gpui::test]
348 async fn test_move_to_next(
349 cx: &mut gpui::TestAppContext,
350 deterministic: Arc<gpui::executor::Deterministic>,
351 ) {
352 let mut cx = VimTestContext::new(cx, true).await;
353 cx.set_state("ˇhi\nhigh\nhi\n", Mode::Normal);
354
355 cx.simulate_keystrokes(["*"]);
356 deterministic.run_until_parked();
357 cx.assert_state("hi\nhigh\nˇhi\n", Mode::Normal);
358
359 cx.simulate_keystrokes(["*"]);
360 deterministic.run_until_parked();
361 cx.assert_state("ˇhi\nhigh\nhi\n", Mode::Normal);
362
363 cx.simulate_keystrokes(["#"]);
364 deterministic.run_until_parked();
365 cx.assert_state("hi\nhigh\nˇhi\n", Mode::Normal);
366
367 cx.simulate_keystrokes(["#"]);
368 deterministic.run_until_parked();
369 cx.assert_state("ˇhi\nhigh\nhi\n", Mode::Normal);
370
371 cx.simulate_keystrokes(["2", "*"]);
372 deterministic.run_until_parked();
373 cx.assert_state("ˇhi\nhigh\nhi\n", Mode::Normal);
374
375 cx.simulate_keystrokes(["g", "*"]);
376 deterministic.run_until_parked();
377 cx.assert_state("hi\nˇhigh\nhi\n", Mode::Normal);
378
379 cx.simulate_keystrokes(["n"]);
380 cx.assert_state("hi\nhigh\nˇhi\n", Mode::Normal);
381
382 cx.simulate_keystrokes(["g", "#"]);
383 deterministic.run_until_parked();
384 cx.assert_state("hi\nˇhigh\nhi\n", Mode::Normal);
385 }
386
387 #[gpui::test]
388 async fn test_search(
389 cx: &mut gpui::TestAppContext,
390 deterministic: Arc<gpui::executor::Deterministic>,
391 ) {
392 let mut cx = VimTestContext::new(cx, true).await;
393
394 cx.set_state("aa\nbˇb\ncc\ncc\ncc\n", Mode::Normal);
395 cx.simulate_keystrokes(["/", "c", "c"]);
396
397 let search_bar = cx.workspace(|workspace, cx| {
398 workspace
399 .active_pane()
400 .read(cx)
401 .toolbar()
402 .read(cx)
403 .item_of_type::<BufferSearchBar>()
404 .expect("Buffer search bar should be deployed")
405 });
406
407 search_bar.read_with(cx.cx, |bar, cx| {
408 assert_eq!(bar.query(cx), "cc");
409 });
410
411 deterministic.run_until_parked();
412
413 cx.update_editor(|editor, cx| {
414 let highlights = editor.all_text_background_highlights(cx);
415 assert_eq!(3, highlights.len());
416 assert_eq!(
417 DisplayPoint::new(2, 0)..DisplayPoint::new(2, 2),
418 highlights[0].0
419 )
420 });
421
422 cx.simulate_keystrokes(["enter"]);
423 cx.assert_state("aa\nbb\nˇcc\ncc\ncc\n", Mode::Normal);
424
425 // n to go to next/N to go to previous
426 cx.simulate_keystrokes(["n"]);
427 cx.assert_state("aa\nbb\ncc\nˇcc\ncc\n", Mode::Normal);
428 cx.simulate_keystrokes(["shift-n"]);
429 cx.assert_state("aa\nbb\nˇcc\ncc\ncc\n", Mode::Normal);
430
431 // ?<enter> to go to previous
432 cx.simulate_keystrokes(["?", "enter"]);
433 deterministic.run_until_parked();
434 cx.assert_state("aa\nbb\ncc\ncc\nˇcc\n", Mode::Normal);
435 cx.simulate_keystrokes(["?", "enter"]);
436 deterministic.run_until_parked();
437 cx.assert_state("aa\nbb\ncc\nˇcc\ncc\n", Mode::Normal);
438
439 // /<enter> to go to next
440 cx.simulate_keystrokes(["/", "enter"]);
441 deterministic.run_until_parked();
442 cx.assert_state("aa\nbb\ncc\ncc\nˇcc\n", Mode::Normal);
443
444 // ?{search}<enter> to search backwards
445 cx.simulate_keystrokes(["?", "b", "enter"]);
446 deterministic.run_until_parked();
447 cx.assert_state("aa\nbˇb\ncc\ncc\ncc\n", Mode::Normal);
448
449 // works with counts
450 cx.simulate_keystrokes(["4", "/", "c"]);
451 deterministic.run_until_parked();
452 cx.simulate_keystrokes(["enter"]);
453 cx.assert_state("aa\nbb\ncc\ncˇc\ncc\n", Mode::Normal);
454
455 // check that searching resumes from cursor, not previous match
456 cx.set_state("ˇaa\nbb\ndd\ncc\nbb\n", Mode::Normal);
457 cx.simulate_keystrokes(["/", "d"]);
458 deterministic.run_until_parked();
459 cx.simulate_keystrokes(["enter"]);
460 cx.assert_state("aa\nbb\nˇdd\ncc\nbb\n", Mode::Normal);
461 cx.update_editor(|editor, cx| editor.move_to_beginning(&Default::default(), cx));
462 cx.assert_state("ˇaa\nbb\ndd\ncc\nbb\n", Mode::Normal);
463 cx.simulate_keystrokes(["/", "b"]);
464 deterministic.run_until_parked();
465 cx.simulate_keystrokes(["enter"]);
466 cx.assert_state("aa\nˇbb\ndd\ncc\nbb\n", Mode::Normal);
467 }
468
469 #[gpui::test]
470 async fn test_non_vim_search(
471 cx: &mut gpui::TestAppContext,
472 deterministic: Arc<gpui::executor::Deterministic>,
473 ) {
474 let mut cx = VimTestContext::new(cx, false).await;
475 cx.set_state("ˇone one one one", Mode::Normal);
476 cx.simulate_keystrokes(["cmd-f"]);
477 deterministic.run_until_parked();
478
479 cx.assert_editor_state("«oneˇ» one one one");
480 cx.simulate_keystrokes(["enter"]);
481 cx.assert_editor_state("one «oneˇ» one one");
482 cx.simulate_keystrokes(["shift-enter"]);
483 cx.assert_editor_state("«oneˇ» one one one");
484 }
485}