use gpui::{actions, impl_actions, ViewContext}; use search::{buffer_search, BufferSearchBar, SearchMode, SearchOptions}; use serde_derive::Deserialize; use workspace::{searchable::Direction, Workspace}; use crate::{motion::Motion, normal::move_cursor, state::SearchState, Vim}; #[derive(Clone, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub(crate) struct MoveToNext { #[serde(default)] partial_word: bool, } #[derive(Clone, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub(crate) struct MoveToPrev { #[serde(default)] partial_word: bool, } #[derive(Clone, Deserialize, PartialEq)] pub(crate) struct Search { #[serde(default)] backwards: bool, } #[derive(Debug, Clone, PartialEq, Deserialize)] pub struct FindCommand { pub query: String, pub backwards: bool, } #[derive(Debug, Clone, PartialEq, Deserialize)] pub struct ReplaceCommand { pub query: String, } #[derive(Debug, Default)] struct Replacement { search: String, replacement: String, should_replace_all: bool, is_case_sensitive: bool, } actions!(vim, [SearchSubmit]); impl_actions!( vim, [FindCommand, ReplaceCommand, Search, MoveToPrev, MoveToNext] ); pub(crate) fn register(workspace: &mut Workspace, _: &mut ViewContext) { workspace.register_action(move_to_next); workspace.register_action(move_to_prev); workspace.register_action(search); workspace.register_action(search_submit); workspace.register_action(search_deploy); workspace.register_action(find_command); workspace.register_action(replace_command); } fn move_to_next(workspace: &mut Workspace, action: &MoveToNext, cx: &mut ViewContext) { move_to_internal(workspace, Direction::Next, !action.partial_word, cx) } fn move_to_prev(workspace: &mut Workspace, action: &MoveToPrev, cx: &mut ViewContext) { move_to_internal(workspace, Direction::Prev, !action.partial_word, cx) } fn search(workspace: &mut Workspace, action: &Search, cx: &mut ViewContext) { let pane = workspace.active_pane().clone(); let direction = if action.backwards { Direction::Prev } else { Direction::Next }; Vim::update(cx, |vim, cx| { let count = vim.take_count(cx).unwrap_or(1); pane.update(cx, |pane, cx| { if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::() { search_bar.update(cx, |search_bar, cx| { if !search_bar.show(cx) { return; } let query = search_bar.query(cx); search_bar.select_query(cx); cx.focus_self(); if query.is_empty() { search_bar.set_replacement(None, cx); search_bar.set_search_options(SearchOptions::CASE_SENSITIVE, cx); search_bar.activate_search_mode(SearchMode::Regex, cx); } vim.workspace_state.search = SearchState { direction, count, initial_query: query.clone(), }; }); } }) }) } // hook into the existing to clear out any vim search state on cmd+f or edit -> find. fn search_deploy(_: &mut Workspace, _: &buffer_search::Deploy, cx: &mut ViewContext) { Vim::update(cx, |vim, _| vim.workspace_state.search = Default::default()); cx.propagate(); } fn search_submit(workspace: &mut Workspace, _: &SearchSubmit, cx: &mut ViewContext) { Vim::update(cx, |vim, cx| { let pane = workspace.active_pane().clone(); pane.update(cx, |pane, cx| { if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::() { search_bar.update(cx, |search_bar, cx| { let state = &mut vim.workspace_state.search; let mut count = state.count; let direction = state.direction; // in the case that the query has changed, the search bar // will have selected the next match already. if (search_bar.query(cx) != state.initial_query) && state.direction == Direction::Next { count = count.saturating_sub(1) } state.count = 1; search_bar.select_match(direction, count, cx); search_bar.focus_editor(&Default::default(), cx); }); } }); }) } pub fn move_to_internal( workspace: &mut Workspace, direction: Direction, whole_word: bool, cx: &mut ViewContext, ) { Vim::update(cx, |vim, cx| { let pane = workspace.active_pane().clone(); let count = vim.take_count(cx).unwrap_or(1); pane.update(cx, |pane, cx| { if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::() { let search = search_bar.update(cx, |search_bar, cx| { let mut options = SearchOptions::CASE_SENSITIVE; options.set(SearchOptions::WHOLE_WORD, whole_word); if search_bar.show(cx) { search_bar .query_suggestion(cx) .map(|query| search_bar.search(&query, Some(options), cx)) } else { None } }); if let Some(search) = search { let search_bar = search_bar.downgrade(); cx.spawn(|_, mut cx| async move { search.await?; search_bar.update(&mut cx, |search_bar, cx| { search_bar.select_match(direction, count, cx) })?; anyhow::Ok(()) }) .detach_and_log_err(cx); } } }); vim.clear_operator(cx); }); } fn find_command(workspace: &mut Workspace, action: &FindCommand, cx: &mut ViewContext) { let pane = workspace.active_pane().clone(); pane.update(cx, |pane, cx| { if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::() { let search = search_bar.update(cx, |search_bar, cx| { if !search_bar.show(cx) { return None; } let mut query = action.query.clone(); if query == "" { query = search_bar.query(cx); }; search_bar.activate_search_mode(SearchMode::Regex, cx); Some(search_bar.search(&query, Some(SearchOptions::CASE_SENSITIVE), cx)) }); let Some(search) = search else { return }; let search_bar = search_bar.downgrade(); let direction = if action.backwards { Direction::Prev } else { Direction::Next }; cx.spawn(|_, mut cx| async move { search.await?; search_bar.update(&mut cx, |search_bar, cx| { search_bar.select_match(direction, 1, cx) })?; anyhow::Ok(()) }) .detach_and_log_err(cx); } }) } fn replace_command( workspace: &mut Workspace, action: &ReplaceCommand, cx: &mut ViewContext, ) { let replacement = parse_replace_all(&action.query); let pane = workspace.active_pane().clone(); pane.update(cx, |pane, cx| { let Some(search_bar) = pane.toolbar().read(cx).item_of_type::() else { return; }; let search = search_bar.update(cx, |search_bar, cx| { if !search_bar.show(cx) { return None; } let mut options = SearchOptions::default(); if replacement.is_case_sensitive { options.set(SearchOptions::CASE_SENSITIVE, true) } let search = if replacement.search == "" { search_bar.query(cx) } else { replacement.search }; search_bar.set_replacement(Some(&replacement.replacement), cx); search_bar.activate_search_mode(SearchMode::Regex, cx); Some(search_bar.search(&search, Some(options), cx)) }); let Some(search) = search else { return }; let search_bar = search_bar.downgrade(); cx.spawn(|_, mut cx| async move { search.await?; search_bar.update(&mut cx, |search_bar, cx| { if replacement.should_replace_all { search_bar.select_last_match(cx); search_bar.replace_all(&Default::default(), cx); Vim::update(cx, |vim, cx| { move_cursor( vim, Motion::StartOfLine { display_lines: false, }, None, cx, ) }) } })?; anyhow::Ok(()) }) .detach_and_log_err(cx); }) } // convert a vim query into something more usable by zed. // we don't attempt to fully convert between the two regex syntaxes, // but we do flip \( and \) to ( and ) (and vice-versa) in the pattern, // and convert \0..\9 to $0..$9 in the replacement so that common idioms work. fn parse_replace_all(query: &str) -> Replacement { let mut chars = query.chars(); if Some('%') != chars.next() || Some('s') != chars.next() { return Replacement::default(); } let Some(delimeter) = chars.next() else { return Replacement::default(); }; let mut search = String::new(); let mut replacement = String::new(); let mut flags = String::new(); let mut buffer = &mut search; let mut escaped = false; // 0 - parsing search // 1 - parsing replacement // 2 - parsing flags let mut phase = 0; for c in chars { if escaped { escaped = false; if phase == 1 && c.is_digit(10) { buffer.push('$') // unescape escaped parens } else if phase == 0 && c == '(' || c == ')' { } else if c != delimeter { buffer.push('\\') } buffer.push(c) } else if c == '\\' { escaped = true; } else if c == delimeter { if phase == 0 { buffer = &mut replacement; phase = 1; } else if phase == 1 { buffer = &mut flags; phase = 2; } else { break; } } else { // escape unescaped parens if phase == 0 && c == '(' || c == ')' { buffer.push('\\') } buffer.push(c) } } let mut replacement = Replacement { search, replacement, should_replace_all: true, is_case_sensitive: true, }; for c in flags.chars() { match c { 'g' | 'I' => {} 'c' | 'n' => replacement.should_replace_all = false, 'i' => replacement.is_case_sensitive = false, _ => {} } } replacement } // #[cfg(test)] // mod test { // use std::sync::Arc; // use editor::DisplayPoint; // use search::BufferSearchBar; // use crate::{state::Mode, test::VimTestContext}; // #[gpui::test] // async fn test_move_to_next( // cx: &mut gpui::TestAppContext, // deterministic: Arc, // ) { // let mut cx = VimTestContext::new(cx, true).await; // cx.set_state("ˇhi\nhigh\nhi\n", Mode::Normal); // cx.simulate_keystrokes(["*"]); // deterministic.run_until_parked(); // cx.assert_state("hi\nhigh\nˇhi\n", Mode::Normal); // cx.simulate_keystrokes(["*"]); // deterministic.run_until_parked(); // cx.assert_state("ˇhi\nhigh\nhi\n", Mode::Normal); // cx.simulate_keystrokes(["#"]); // deterministic.run_until_parked(); // cx.assert_state("hi\nhigh\nˇhi\n", Mode::Normal); // cx.simulate_keystrokes(["#"]); // deterministic.run_until_parked(); // cx.assert_state("ˇhi\nhigh\nhi\n", Mode::Normal); // cx.simulate_keystrokes(["2", "*"]); // deterministic.run_until_parked(); // cx.assert_state("ˇhi\nhigh\nhi\n", Mode::Normal); // cx.simulate_keystrokes(["g", "*"]); // deterministic.run_until_parked(); // cx.assert_state("hi\nˇhigh\nhi\n", Mode::Normal); // cx.simulate_keystrokes(["n"]); // cx.assert_state("hi\nhigh\nˇhi\n", Mode::Normal); // cx.simulate_keystrokes(["g", "#"]); // deterministic.run_until_parked(); // cx.assert_state("hi\nˇhigh\nhi\n", Mode::Normal); // } // #[gpui::test] // async fn test_search( // cx: &mut gpui::TestAppContext, // deterministic: Arc, // ) { // let mut cx = VimTestContext::new(cx, true).await; // cx.set_state("aa\nbˇb\ncc\ncc\ncc\n", Mode::Normal); // cx.simulate_keystrokes(["/", "c", "c"]); // let search_bar = cx.workspace(|workspace, cx| { // workspace // .active_pane() // .read(cx) // .toolbar() // .read(cx) // .item_of_type::() // .expect("Buffer search bar should be deployed") // }); // search_bar.read_with(cx.cx, |bar, cx| { // assert_eq!(bar.query(cx), "cc"); // }); // deterministic.run_until_parked(); // cx.update_editor(|editor, cx| { // let highlights = editor.all_text_background_highlights(cx); // assert_eq!(3, highlights.len()); // assert_eq!( // DisplayPoint::new(2, 0)..DisplayPoint::new(2, 2), // highlights[0].0 // ) // }); // cx.simulate_keystrokes(["enter"]); // cx.assert_state("aa\nbb\nˇcc\ncc\ncc\n", Mode::Normal); // // n to go to next/N to go to previous // cx.simulate_keystrokes(["n"]); // cx.assert_state("aa\nbb\ncc\nˇcc\ncc\n", Mode::Normal); // cx.simulate_keystrokes(["shift-n"]); // cx.assert_state("aa\nbb\nˇcc\ncc\ncc\n", Mode::Normal); // // ? to go to previous // cx.simulate_keystrokes(["?", "enter"]); // deterministic.run_until_parked(); // cx.assert_state("aa\nbb\ncc\ncc\nˇcc\n", Mode::Normal); // cx.simulate_keystrokes(["?", "enter"]); // deterministic.run_until_parked(); // cx.assert_state("aa\nbb\ncc\nˇcc\ncc\n", Mode::Normal); // // / to go to next // cx.simulate_keystrokes(["/", "enter"]); // deterministic.run_until_parked(); // cx.assert_state("aa\nbb\ncc\ncc\nˇcc\n", Mode::Normal); // // ?{search} to search backwards // cx.simulate_keystrokes(["?", "b", "enter"]); // deterministic.run_until_parked(); // cx.assert_state("aa\nbˇb\ncc\ncc\ncc\n", Mode::Normal); // // works with counts // cx.simulate_keystrokes(["4", "/", "c"]); // deterministic.run_until_parked(); // cx.simulate_keystrokes(["enter"]); // cx.assert_state("aa\nbb\ncc\ncˇc\ncc\n", Mode::Normal); // // check that searching resumes from cursor, not previous match // cx.set_state("ˇaa\nbb\ndd\ncc\nbb\n", Mode::Normal); // cx.simulate_keystrokes(["/", "d"]); // deterministic.run_until_parked(); // cx.simulate_keystrokes(["enter"]); // cx.assert_state("aa\nbb\nˇdd\ncc\nbb\n", Mode::Normal); // cx.update_editor(|editor, cx| editor.move_to_beginning(&Default::default(), cx)); // cx.assert_state("ˇaa\nbb\ndd\ncc\nbb\n", Mode::Normal); // cx.simulate_keystrokes(["/", "b"]); // deterministic.run_until_parked(); // cx.simulate_keystrokes(["enter"]); // cx.assert_state("aa\nˇbb\ndd\ncc\nbb\n", Mode::Normal); // } // #[gpui::test] // async fn test_non_vim_search( // cx: &mut gpui::TestAppContext, // deterministic: Arc, // ) { // let mut cx = VimTestContext::new(cx, false).await; // cx.set_state("ˇone one one one", Mode::Normal); // cx.simulate_keystrokes(["cmd-f"]); // deterministic.run_until_parked(); // cx.assert_editor_state("«oneˇ» one one one"); // cx.simulate_keystrokes(["enter"]); // cx.assert_editor_state("one «oneˇ» one one"); // cx.simulate_keystrokes(["shift-enter"]); // cx.assert_editor_state("«oneˇ» one one one"); // } // }