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