diff --git a/assets/keymaps/linux/jetbrains.json b/assets/keymaps/linux/jetbrains.json new file mode 100644 index 0000000..40a66ab --- /dev/null +++ b/assets/keymaps/linux/jetbrains.json @@ -0,0 +1,229 @@ +[ + { + "bindings": { + "ctrl-alt-s": "zed::OpenSettings", + "ctrl-{": "pane::ActivatePreviousItem", + "ctrl-}": "pane::ActivateNextItem", + "shift-escape": null, // Unmap workspace::zoom + "ctrl-~": "git::Branch", + "ctrl-f2": "debugger::Stop", + "f6": "debugger::Pause", + "f7": "debugger::StepInto", + "f8": "debugger::StepOver", + "shift-f8": "debugger::StepOut", + "f9": "debugger::Continue", + "shift-f9": "debugger::Start", + "alt-shift-f9": "debugger::Start", + }, + }, + { + "context": "Editor", + "bindings": { + "ctrl->": ["zed::IncreaseBufferFontSize", { "persist": true }], + "ctrl-<": ["zed::DecreaseBufferFontSize", { "persist": true }], + "ctrl-shift-j": "editor::JoinLines", + "ctrl-d": "editor::DuplicateSelection", + "ctrl-y": "editor::DeleteLine", + "ctrl-m": "editor::ScrollCursorCenter", + "ctrl-pagedown": "editor::MovePageDown", + "ctrl-pageup": "editor::MovePageUp", + // "ctrl-alt-shift-b": "editor::SelectToPreviousWordStart", + "ctrl-alt-enter": "editor::NewlineAbove", + "shift-enter": "editor::NewlineBelow", + // "ctrl--": "editor::Fold", // TODO: `ctrl-numpad--` (numpad not implemented) + // "ctrl-+": "editor::UnfoldLines", // TODO: `ctrl-numpad+` (numpad not implemented) + "alt-shift-g": "editor::SplitSelectionIntoLines", + "alt-j": ["editor::SelectNext", { "replace_newest": false }], + "alt-shift-j": ["editor::SelectPrevious", { "replace_newest": false }], + "ctrl-/": ["editor::ToggleComments", { "advance_downwards": true, "comment_empty_lines": true }], + "ctrl-w": "editor::SelectLargerSyntaxNode", + "ctrl-shift-w": "editor::SelectSmallerSyntaxNode", + "shift-alt-up": "editor::MoveLineUp", + "shift-alt-down": "editor::MoveLineDown", + "ctrl-alt-l": "editor::Format", + "ctrl-alt-o": "editor::OrganizeImports", + "shift-f6": "editor::Rename", + "ctrl-alt-left": "pane::GoBack", + "ctrl-alt-right": "pane::GoForward", + "alt-f7": "editor::FindAllReferences", + "ctrl-alt-f7": "editor::FindAllReferences", + "ctrl-b": "editor::GoToDefinition", // Conflicts with workspace::ToggleLeftDock + "ctrl-alt-b": "editor::GoToImplementation", // Conflicts with workspace::ToggleRightDock + "ctrl-shift-b": "editor::GoToTypeDefinition", + "ctrl-alt-shift-b": "editor::GoToTypeDefinitionSplit", + "f2": "editor::GoToDiagnostic", + "shift-f2": "editor::GoToPreviousDiagnostic", + "ctrl-alt-shift-down": "editor::GoToHunk", + "ctrl-alt-shift-up": "editor::GoToPreviousHunk", + "ctrl-alt-z": "git::Restore", + "ctrl-home": "editor::MoveToBeginning", + "ctrl-end": "editor::MoveToEnd", + "ctrl-shift-home": "editor::SelectToBeginning", + "ctrl-shift-end": "editor::SelectToEnd", + "alt-left": "editor::MoveToPreviousSubwordStart", + "alt-right": "editor::MoveToNextSubwordEnd", + "shift-alt-left": "editor::SelectToPreviousSubwordStart", + "shift-alt-right": "editor::SelectToNextSubwordEnd", + "ctrl-f8": "editor::ToggleBreakpoint", + "ctrl-shift-f8": "editor::EditLogBreakpoint", + "ctrl-shift-u": "editor::ToggleCase", + }, + }, + { + "context": "Editor && mode == full", + "bindings": { + "ctrl-f12": "outline::Toggle", + "ctrl-r": ["buffer_search::Deploy", { "replace_enabled": true }], + "ctrl-e": "file_finder::Toggle", + "ctrl-shift-n": "file_finder::Toggle", + "ctrl-alt-n": "file_finder::Toggle", + "ctrl-g": "go_to_line::Toggle", + "alt-enter": "editor::ToggleCodeActions", + "ctrl-space": "editor::ShowCompletions", + "ctrl-q": "editor::Hover", + "ctrl-p": "editor::ShowSignatureHelp", + "ctrl-\\": "assistant::InlineAssist", + "ctrl-alt-h": "call_hierarchy::ShowIncomingCalls", + }, + }, + { + "context": "Editor && mode == auto_height", + "bindings": { + "shift-enter": "editor::Newline", + "ctrl-shift-enter": "editor::NewlineBelow", + }, + }, + { + "context": "BufferSearchBar", + "bindings": { + "shift-enter": "search::SelectPreviousMatch", + }, + }, + { + "context": "AcpThreadSearchBar > Editor", + "bindings": { + "shift-enter": "agent::SelectPreviousThreadMatch", + }, + }, + { + "context": "BufferSearchBar || ProjectSearchBar", + "bindings": { + "alt-c": "search::ToggleCaseSensitive", + "alt-e": "search::ToggleSelection", + "alt-x": "search::ToggleRegex", + "alt-w": "search::ToggleWholeWord", + }, + }, + { + "context": "Workspace", + "bindings": { + "ctrl-shift-f12": "workspace::ToggleAllDocks", + "ctrl-shift-r": ["pane::DeploySearch", { "replace_enabled": true }], + "alt-shift-f10": "task::Spawn", + "shift-f10": "task::Spawn", + "ctrl-f5": "task::Rerun", + "ctrl-e": "file_finder::Toggle", + "ctrl-k": "git_panel::ToggleFocus", // bug: This should also focus commit editor + "ctrl-shift-n": "file_finder::Toggle", + "ctrl-alt-n": "file_finder::Toggle", + "ctrl-n": "project_symbols::Toggle", + "ctrl-shift-a": "command_palette::Toggle", + "shift shift": "command_palette::Toggle", + "ctrl-alt-shift-n": "project_symbols::Toggle", + "alt-0": "git_panel::ToggleFocus", + "alt-1": "project_panel::ToggleFocus", + "alt-5": "debug_panel::ToggleFocus", + "alt-6": "diagnostics::Deploy", + "alt-7": "outline_panel::ToggleFocus", + }, + }, + { + "context": "Pane", // this is to override the default Pane mappings to switch tabs + "bindings": { + "alt-1": "project_panel::ToggleFocus", + "alt-2": null, // Bookmarks (left dock) + "alt-3": null, // Find Panel (bottom dock) + "alt-4": null, // Run Panel (bottom dock) + "alt-5": "debug_panel::ToggleFocus", + "alt-6": "diagnostics::Deploy", + "alt-7": "outline_panel::ToggleFocus", + "alt-8": null, // Services (bottom dock) + "alt-9": null, // Git History (bottom dock) + "alt-0": "git_panel::ToggleFocus", + }, + }, + { + "context": "Workspace || Editor", + "bindings": { + "alt-f12": "terminal_panel::Toggle", + "ctrl-shift-k": "git::Push", + }, + }, + { + "context": "Pane", + "bindings": { + "ctrl-alt-left": "pane::GoBack", + "ctrl-alt-right": "pane::GoForward", + "alt-left": "pane::ActivatePreviousItem", + "alt-right": "pane::ActivateNextItem", + }, + }, + { + "context": "ProjectPanel", + "bindings": { + "enter": "project_panel::Open", + "ctrl-shift-f": "project_panel::NewSearchInDirectory", + "backspace": ["project_panel::Trash", { "skip_prompt": false }], + "delete": ["project_panel::Trash", { "skip_prompt": false }], + "shift-delete": ["project_panel::Delete", { "skip_prompt": false }], + "shift-f6": "project_panel::Rename", + }, + }, + { + "context": "Terminal", + "bindings": { + "ctrl-shift-t": "workspace::NewTerminal", + "alt-f12": "workspace::CloseActiveDock", + "ctrl-up": "terminal::ScrollLineUp", + "ctrl-down": "terminal::ScrollLineDown", + "shift-pageup": "terminal::ScrollPageUp", + "shift-pagedown": "terminal::ScrollPageDown", + }, + }, + { "context": "GitPanel", "bindings": { "alt-0": "workspace::CloseActiveDock" } }, + { "context": "ProjectPanel", "bindings": { "alt-1": "workspace::CloseActiveDock" } }, + { "context": "DebugPanel", "bindings": { "alt-5": "workspace::CloseActiveDock" } }, + { "context": "Diagnostics > Editor", "bindings": { "alt-6": "pane::CloseActiveItem" } }, + { "context": "OutlinePanel", "bindings": { "alt-7": "workspace::CloseActiveDock" } }, + { + // `ctrl-alt-l` is bound to `editor::Format` (jetbrains "Reformat Code") in + // the Editor context above, which collides with the default + // `agent::OpenRulesLibrary` binding. Mirror the windows keymap and use + // `shift-alt-l` instead so the menu hint and the action stay in sync. + "context": "AgentPanel", + "bindings": { + "ctrl-alt-l": null, + "shift-alt-l": "agent::OpenRulesLibrary", + }, + }, + { + "context": "Dock || Workspace || OutlinePanel || ProjectPanel || CollabPanel", + "bindings": { + "escape": "editor::ToggleFocus", + "shift-escape": "workspace::CloseActiveDock", + }, + }, + { + "context": "Editor && mode == auto_height", + "bindings": { + "escape": "editor::Cancel", + }, + }, + { + "context": "CallHierarchyPicker > Picker > Editor", + "bindings": { + "ctrl-alt-h": "call_hierarchy::ToggleDirection", + "ctrl-k": null, + }, + }, +] diff --git a/assets/keymaps/macos/jetbrains.json b/assets/keymaps/macos/jetbrains.json new file mode 100644 index 0000000..8eadc80 --- /dev/null +++ b/assets/keymaps/macos/jetbrains.json @@ -0,0 +1,233 @@ +[ + { + "bindings": { + "cmd-{": "pane::ActivatePreviousItem", + "cmd-}": "pane::ActivateNextItem", + "cmd-0": "git_panel::ToggleFocus", // overrides `cmd-0` zoom reset + "shift-escape": null, // Unmap workspace::zoom + "cmd-~": "git::Branch", + "ctrl-f2": "debugger::Stop", + "f6": "debugger::Pause", + "f7": "debugger::StepInto", + "f8": "debugger::StepOver", + "shift-f8": "debugger::StepOut", + "f9": "debugger::Continue", + "shift-f9": "debugger::Start", + "alt-shift-f9": "debugger::Start", + }, + }, + { + "context": "Editor", + "bindings": { + "ctrl->": ["zed::IncreaseBufferFontSize", { "persist": true }], + "ctrl-<": ["zed::DecreaseBufferFontSize", { "persist": true }], + "ctrl-shift-j": "editor::JoinLines", + "cmd-d": "editor::DuplicateSelection", + "cmd-backspace": "editor::DeleteLine", + "cmd-pagedown": "editor::MovePageDown", + "cmd-pageup": "editor::MovePageUp", + "ctrl-alt-shift-b": "editor::SelectToPreviousWordStart", + "cmd-alt-enter": "editor::NewlineAbove", + "shift-enter": "editor::NewlineBelow", + "cmd--": "editor::Fold", + "cmd-+": "editor::UnfoldLines", + "alt-shift-g": "editor::SplitSelectionIntoLines", + "ctrl-g": ["editor::SelectNext", { "replace_newest": false }], + "ctrl-shift-g": "editor::UndoSelection", + "ctrl-cmd-g": ["editor::SelectPrevious", { "replace_newest": false }], + "cmd-/": ["editor::ToggleComments", { "advance_downwards": true, "comment_empty_lines": true }], + "alt-up": "editor::SelectLargerSyntaxNode", + "alt-down": "editor::SelectSmallerSyntaxNode", + "shift-alt-up": "editor::MoveLineUp", + "shift-alt-down": "editor::MoveLineDown", + "cmd-alt-l": "editor::Format", + "ctrl-alt-o": "editor::OrganizeImports", + "shift-f6": "editor::Rename", + "cmd-[": "pane::GoBack", + "cmd-]": "pane::GoForward", + "alt-f7": "editor::FindAllReferences", + "cmd-alt-f7": "editor::FindAllReferences", + "cmd-b": "editor::GoToDefinition", // Conflicts with workspace::ToggleLeftDock + "cmd-alt-b": "editor::GoToImplementation", + "cmd-shift-b": "editor::GoToTypeDefinition", + "cmd-alt-shift-b": "editor::GoToTypeDefinitionSplit", + "f2": "editor::GoToDiagnostic", + "shift-f2": "editor::GoToPreviousDiagnostic", + "ctrl-alt-shift-down": "editor::GoToHunk", + "ctrl-alt-shift-up": "editor::GoToPreviousHunk", + "cmd-home": "editor::MoveToBeginning", + "cmd-end": "editor::MoveToEnd", + "cmd-shift-home": "editor::SelectToBeginning", + "cmd-shift-end": "editor::SelectToEnd", + "alt-left": "editor::MoveToPreviousSubwordStart", + "alt-right": "editor::MoveToNextSubwordEnd", + "shift-alt-left": "editor::SelectToPreviousSubwordStart", + "shift-alt-right": "editor::SelectToNextSubwordEnd", + "ctrl-f8": "editor::ToggleBreakpoint", + "ctrl-shift-f8": "editor::EditLogBreakpoint", + "cmd-shift-u": "editor::ToggleCase", + }, + }, + { + "context": "Editor && mode == full", + "bindings": { + "cmd-f12": "outline::Toggle", + "cmd-r": ["buffer_search::Deploy", { "replace_enabled": true }], + "cmd-l": "go_to_line::Toggle", + "cmd-e": "file_finder::Toggle", + "cmd-shift-o": "file_finder::Toggle", + "cmd-shift-n": "file_finder::Toggle", + "alt-enter": "editor::ToggleCodeActions", + "ctrl-space": "editor::ShowCompletions", + "cmd-j": "editor::Hover", + "cmd-p": "editor::ShowSignatureHelp", + "cmd-\\": "assistant::InlineAssist", + "ctrl-alt-h": "call_hierarchy::ShowIncomingCalls", + }, + }, + { + "context": "Editor && mode == auto_height", + "bindings": { + "shift-enter": "editor::Newline", + "ctrl-shift-enter": "editor::NewlineBelow", + }, + }, + { + "context": "BufferSearchBar", + "bindings": { + "shift-enter": "search::SelectPreviousMatch", + }, + }, + { + "context": "AcpThreadSearchBar > Editor", + "bindings": { + "shift-enter": "agent::SelectPreviousThreadMatch", + }, + }, + { + "context": "BufferSearchBar || ProjectSearchBar", + "bindings": { + "alt-c": "search::ToggleCaseSensitive", + "alt-e": "search::ToggleSelection", + "alt-x": "search::ToggleRegex", + "alt-w": "search::ToggleWholeWord", + "ctrl-alt-c": "search::ToggleCaseSensitive", + "ctrl-alt-e": "search::ToggleSelection", + "ctrl-alt-w": "search::ToggleWholeWord", + "ctrl-alt-x": "search::ToggleRegex", + }, + }, + { + "context": "Workspace", + "bindings": { + "cmd-shift-f12": "workspace::ToggleAllDocks", + "cmd-shift-r": ["pane::DeploySearch", { "replace_enabled": true }], + "ctrl-alt-r": "task::Spawn", + "shift-f10": "task::Spawn", + "cmd-f5": "task::Rerun", + "cmd-e": "file_finder::Toggle", + "cmd-k": "git_panel::ToggleFocus", // bug: This should also focus commit editor + "cmd-shift-o": "file_finder::Toggle", + "cmd-shift-n": "file_finder::Toggle", + "cmd-n": "project_symbols::Toggle", + "cmd-shift-a": "command_palette::Toggle", + "shift shift": "command_palette::Toggle", + "cmd-alt-o": "project_symbols::Toggle", // JetBrains: Go to Symbol + "cmd-o": "project_symbols::Toggle", // JetBrains: Go to Class + "cmd-1": "project_panel::ToggleFocus", + "cmd-5": "debug_panel::ToggleFocus", + "cmd-6": "diagnostics::Deploy", + "cmd-7": "outline_panel::ToggleFocus", + }, + }, + { + "context": "Pane", // this is to override the default Pane mappings to switch tabs + "bindings": { + "cmd-1": "project_panel::ToggleFocus", + "cmd-2": null, // Bookmarks (left dock) + "cmd-3": null, // Find Panel (bottom dock) + "cmd-4": null, // Run Panel (bottom dock) + "cmd-5": "debug_panel::ToggleFocus", + "cmd-6": "diagnostics::Deploy", + "cmd-7": "outline_panel::ToggleFocus", + "cmd-8": null, // Services (bottom dock) + "cmd-9": null, // Git History (bottom dock) + "cmd-0": "git_panel::ToggleFocus", + }, + }, + { + "context": "Workspace || Editor", + "bindings": { + "alt-f12": "terminal_panel::Toggle", + "cmd-shift-k": "git::Push", + }, + }, + { + "context": "Pane", + "bindings": { + "cmd-alt-left": "pane::GoBack", + "cmd-alt-right": "pane::GoForward", + "alt-left": "pane::ActivatePreviousItem", + "alt-right": "pane::ActivateNextItem", + }, + }, + { + "context": "ProjectPanel", + "bindings": { + "enter": "project_panel::Open", + "cmd-shift-f": "project_panel::NewSearchInDirectory", + "cmd-backspace": ["project_panel::Trash", { "skip_prompt": false }], + "backspace": ["project_panel::Trash", { "skip_prompt": false }], + "delete": ["project_panel::Trash", { "skip_prompt": false }], + "shift-delete": ["project_panel::Delete", { "skip_prompt": false }], + "shift-f6": "project_panel::Rename", + }, + }, + { + "context": "Terminal", + "bindings": { + "cmd-t": "workspace::NewTerminal", + "alt-f12": "workspace::CloseActiveDock", + "cmd-up": "terminal::ScrollLineUp", + "cmd-down": "terminal::ScrollLineDown", + "shift-pageup": "terminal::ScrollPageUp", + "shift-pagedown": "terminal::ScrollPageDown", + }, + }, + { "context": "GitPanel", "bindings": { "cmd-0": "workspace::CloseActiveDock" } }, + { "context": "ProjectPanel", "bindings": { "cmd-1": "workspace::CloseActiveDock" } }, + { "context": "DebugPanel", "bindings": { "cmd-5": "workspace::CloseActiveDock" } }, + { "context": "Diagnostics > Editor", "bindings": { "cmd-6": "pane::CloseActiveItem" } }, + { "context": "OutlinePanel", "bindings": { "cmd-7": "workspace::CloseActiveDock" } }, + { + // `cmd-alt-l` is bound to `editor::Format` (jetbrains "Reformat Code") in + // the Editor context above, which collides with the default + // `agent::OpenRulesLibrary` binding. Mirror the windows keymap and use + // `shift-alt-l` instead so the menu hint and the action stay in sync. + "context": "AgentPanel", + "bindings": { + "cmd-alt-l": null, + "shift-alt-l": "agent::OpenRulesLibrary", + }, + }, + { + "context": "Dock || Workspace || OutlinePanel || ProjectPanel || CollabPanel", + "bindings": { + "escape": "editor::ToggleFocus", + "shift-escape": "workspace::CloseActiveDock", + }, + }, + { + "context": "Editor && mode == auto_height", + "bindings": { + "escape": "editor::Cancel", + }, + }, + { + "context": "CallHierarchyPicker > Picker > Editor", + "bindings": { + "ctrl-alt-h": "call_hierarchy::ToggleDirection", + "cmd-k": null, + }, + }, +] diff --git a/crates/search/src/buffer_search.rs b/crates/search/src/buffer_search.rs new file mode 100644 index 0000000..8726f97 --- /dev/null +++ b/crates/search/src/buffer_search.rs @@ -0,0 +1,4529 @@ +mod registrar; + +use crate::{ + FocusSearch, NextHistoryQuery, PreviousHistoryQuery, ReplaceAll, ReplaceNext, SearchOption, + SearchOptions, SearchSource, SelectAllMatches, SelectNextMatch, SelectPreviousMatch, + ToggleCaseSensitive, ToggleRegex, ToggleReplace, ToggleSelection, ToggleWholeWord, + buffer_search::registrar::WithResultsOrExternalQuery, + search_bar::{ + ActionButtonState, HistoryNavigationDirection, alignment_element, + filter_search_results_input, input_base_styles, render_action_button, render_text_input, + should_navigate_history, + }, +}; +use any_vec::AnyVec; +use collections::HashMap; +use editor::{ + DiffStyleControls, Editor, EditorSettings, MultiBufferOffset, SplittableEditor, + actions::{Backtab, FoldAll, Tab, ToggleFoldAll, ToggleSoftWrap, UnfoldAll}, + scroll::Autoscroll, +}; +use futures::channel::oneshot; +use gpui::{ + App, ClickEvent, Context, Entity, EventEmitter, Focusable, InteractiveElement as _, + IntoElement, KeyContext, ParentElement as _, Render, ScrollHandle, Styled, Subscription, Task, + TaskExt, WeakEntity, Window, div, +}; +use language::{Language, LanguageRegistry}; +use project::{ + search::SearchQuery, + search_history::{SearchHistory, SearchHistoryCursor}, +}; + +use settings::{SeedQuerySetting, Settings}; +use std::{any::TypeId, sync::Arc}; +use zed_actions::{outline::ToggleOutline, workspace::CopyPath, workspace::CopyRelativePath}; + +use ui::{BASE_REM_SIZE_IN_PX, IconButtonShape, Tooltip, prelude::*, utils::SearchInputWidth}; +use util::{ResultExt, paths::PathMatcher}; +use workspace::{ + ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace, + item::{ItemBufferKind, ItemHandle}, + searchable::{ + Direction, FilteredSearchRange, SearchEvent, SearchToken, SearchableItemHandle, + SelectSearchOptions, WeakSearchableItemHandle, + }, +}; + +pub use registrar::{DivRegistrar, register_pane_search_actions}; +use registrar::{ForDeployed, ForDismissed, SearchActionsRegistrar}; + +const MAX_BUFFER_SEARCH_HISTORY_SIZE: usize = 50; + +pub use zed_actions::buffer_search::{ + Deploy, DeployReplace, Dismiss, FocusEditor, UseSelectionForFind, +}; + +pub enum Event { + UpdateLocation, + Dismissed, +} + +pub fn init(cx: &mut App) { + cx.observe_new(|workspace: &mut Workspace, _, _| BufferSearchBar::register(workspace)) + .detach(); +} + +pub struct BufferSearchBar { + query_editor: Entity, + query_editor_focused: bool, + replacement_editor: Entity, + replacement_editor_focused: bool, + active_searchable_item: Option>, + active_match_index: Option, + #[cfg(target_os = "macos")] + active_searchable_item_subscriptions: Option<[Subscription; 2]>, + #[cfg(not(target_os = "macos"))] + active_searchable_item_subscriptions: Option, + #[cfg(target_os = "macos")] + pending_external_query: Option<(String, SearchOptions)>, + active_search: Option>, + searchable_items_with_matches: + HashMap, (AnyVec, SearchToken)>, + pending_search: Option>, + search_options: SearchOptions, + default_options: SearchOptions, + configured_options: SearchOptions, + query_error: Option, + dismissed: bool, + search_history: SearchHistory, + search_history_cursor: SearchHistoryCursor, + replace_enabled: bool, + selection_search_enabled: Option, + scroll_handle: ScrollHandle, + regex_language: Option>, + splittable_editor: Option>, + _splittable_editor_subscription: Option, +} + +impl EventEmitter for BufferSearchBar {} +impl EventEmitter for BufferSearchBar {} +impl Render for BufferSearchBar { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let focus_handle = self.focus_handle(cx); + let has_splittable_editor = self.splittable_editor.is_some(); + let split_buttons = self + .splittable_editor + .as_ref() + .and_then(|weak| weak.upgrade()) + .map(DiffStyleControls::new); + + let collapse_expand_button = if self.needs_expand_collapse_option(cx) { + let query_editor_focus = self.query_editor.focus_handle(cx); + + let is_collapsed = self + .active_searchable_item + .as_ref() + .and_then(|item| item.act_as_type(TypeId::of::(), cx)) + .and_then(|item| item.downcast::().ok()) + .map(|editor: Entity| editor.read(cx).has_any_buffer_folded(cx)) + .unwrap_or_default(); + let (icon, tooltip_label) = if is_collapsed { + (IconName::ChevronUpDown, "Expand All Files") + } else { + (IconName::ChevronDownUp, "Collapse All Files") + }; + + let collapse_expand_icon_button = |id| { + IconButton::new(id, icon) + .icon_size(IconSize::Small) + .tooltip(move |_, cx| { + Tooltip::for_action_in( + tooltip_label, + &ToggleFoldAll, + &query_editor_focus, + cx, + ) + }) + .on_click(cx.listener(|this, _: &ClickEvent, window, cx| { + this.toggle_fold_all(&ToggleFoldAll, window, cx); + })) + }; + + if self.dismissed { + return h_flex() + .pl_0p5() + .gap_1() + .child(collapse_expand_icon_button( + "multibuffer-collapse-expand-empty", + )) + .when(has_splittable_editor, |this| this.children(split_buttons)) + .into_any_element(); + } + + Some( + h_flex() + .gap_1() + .child(collapse_expand_icon_button("multibuffer-collapse-expand")) + .children(split_buttons) + .into_any_element(), + ) + } else { + None + }; + + let narrow_mode = + self.scroll_handle.bounds().size.width / window.rem_size() < 340. / BASE_REM_SIZE_IN_PX; + + let workspace::searchable::SearchOptions { + case, + word, + regex, + replacement, + selection, + select_all, + find_in_results, + } = self.supported_options(cx); + + self.query_editor.update(cx, |query_editor, cx| { + if query_editor.placeholder_text(cx).is_none() { + query_editor.set_placeholder_text("Search…", window, cx); + } + }); + + self.replacement_editor.update(cx, |editor, cx| { + editor.set_placeholder_text("Replace with…", window, cx); + }); + + let mut color_override = None; + let match_text = self + .active_searchable_item + .as_ref() + .and_then(|searchable_item| { + if self.query(cx).is_empty() { + return None; + } + let matches_count = self + .searchable_items_with_matches + .get(&searchable_item.downgrade()) + .map(|(matches, _)| matches.len()) + .unwrap_or(0); + if let Some(match_ix) = self.active_match_index { + Some(format!("{}/{}", match_ix + 1, matches_count)) + } else { + color_override = Some(Color::Error); // No matches found + None + } + }) + .unwrap_or_else(|| "0/0".to_string()); + let should_show_replace_input = self.replace_enabled && replacement; + let in_replace = self.replacement_editor.focus_handle(cx).is_focused(window); + + let theme_colors = cx.theme().colors(); + let query_border = if self.query_error.is_some() { + Color::Error.color(cx) + } else { + theme_colors.border + }; + let replacement_border = theme_colors.border; + + let container_width = window.viewport_size().width; + let input_width = SearchInputWidth::calc_width(container_width); + + let input_base_styles = + |border_color| input_base_styles(border_color, |div| div.w(input_width)); + + let input_style = if find_in_results { + filter_search_results_input(query_border, |div| div.w(input_width), cx) + } else { + input_base_styles(query_border) + }; + + let query_column = input_style + .child(div().flex_1().min_w_0().py_1().child(render_text_input( + &self.query_editor, + color_override, + cx, + ))) + .child( + h_flex() + .flex_none() + .gap_1() + .when(case, |div| { + div.child(SearchOption::CaseSensitive.as_button( + self.search_options, + SearchSource::Buffer, + focus_handle.clone(), + )) + }) + .when(word, |div| { + div.child(SearchOption::WholeWord.as_button( + self.search_options, + SearchSource::Buffer, + focus_handle.clone(), + )) + }) + .when(regex, |div| { + div.child(SearchOption::Regex.as_button( + self.search_options, + SearchSource::Buffer, + focus_handle.clone(), + )) + }), + ); + + let mode_column = h_flex() + .gap_1() + .min_w_64() + .when(replacement, |this| { + this.child(render_action_button( + "buffer-search-bar-toggle", + IconName::Replace, + self.replace_enabled.then_some(ActionButtonState::Toggled), + "Toggle Replace", + &ToggleReplace, + focus_handle.clone(), + )) + }) + .when(selection, |this| { + this.child( + IconButton::new( + "buffer-search-bar-toggle-search-selection-button", + IconName::Quote, + ) + .style(ButtonStyle::Subtle) + .shape(IconButtonShape::Square) + .when(self.selection_search_enabled.is_some(), |button| { + button.style(ButtonStyle::Filled) + }) + .on_click(cx.listener(|this, _: &ClickEvent, window, cx| { + this.toggle_selection(&ToggleSelection, window, cx); + })) + .toggle_state(self.selection_search_enabled.is_some()) + .tooltip({ + let focus_handle = focus_handle.clone(); + move |_window, cx| { + Tooltip::for_action_in( + "Toggle Search Selection", + &ToggleSelection, + &focus_handle, + cx, + ) + } + }), + ) + }) + .when(!find_in_results, |el| { + let query_focus = self.query_editor.focus_handle(cx); + let matches_column = h_flex() + .pl_2() + .ml_2() + .border_l_1() + .border_color(theme_colors.border_variant) + .child(render_action_button( + "buffer-search-nav-button", + ui::IconName::ChevronLeft, + self.active_match_index + .is_none() + .then_some(ActionButtonState::Disabled), + "Select Previous Match", + &SelectPreviousMatch, + query_focus.clone(), + )) + .child(render_action_button( + "buffer-search-nav-button", + ui::IconName::ChevronRight, + self.active_match_index + .is_none() + .then_some(ActionButtonState::Disabled), + "Select Next Match", + &SelectNextMatch, + query_focus.clone(), + )) + .when(!narrow_mode, |this| { + this.child(div().ml_2().min_w(rems_from_px(40_f32)).child( + Label::new(match_text).size(LabelSize::Small).color( + if self.active_match_index.is_some() { + Color::Default + } else { + Color::Disabled + }, + ), + )) + }); + + el.when(select_all, |el| { + el.child(render_action_button( + "buffer-search-nav-button", + IconName::SelectAll, + Default::default(), + "Select All Matches", + &SelectAllMatches, + query_focus.clone(), + )) + }) + .child(matches_column) + }) + .when(find_in_results, |el| { + el.child(render_action_button( + "buffer-search", + IconName::Close, + Default::default(), + "Close Search Bar", + &Dismiss, + focus_handle.clone(), + )) + }); + + let has_collapse_button = collapse_expand_button.is_some(); + + let search_line = h_flex() + .w_full() + .gap_2() + .when(find_in_results, |el| el.child(alignment_element())) + .when(!find_in_results && has_collapse_button, |el| { + el.pl_0p5().child(collapse_expand_button.expect("button")) + }) + .child(query_column) + .child(mode_column); + + let replace_line = should_show_replace_input.then(|| { + let replace_column = input_base_styles(replacement_border).child( + div() + .flex_1() + .py_1() + .child(render_text_input(&self.replacement_editor, None, cx)), + ); + let focus_handle = self.replacement_editor.read(cx).focus_handle(cx); + + let replace_actions = h_flex() + .min_w_64() + .gap_1() + .child(render_action_button( + "buffer-search-replace-button", + IconName::ReplaceNext, + Default::default(), + "Replace Next Match", + &ReplaceNext, + focus_handle.clone(), + )) + .child(render_action_button( + "buffer-search-replace-button", + IconName::ReplaceAll, + Default::default(), + "Replace All Matches", + &ReplaceAll, + focus_handle, + )); + + h_flex() + .w_full() + .gap_2() + .when(has_collapse_button, |this| this.child(alignment_element())) + .child(replace_column) + .child(replace_actions) + }); + + let mut key_context = KeyContext::new_with_defaults(); + key_context.add("BufferSearchBar"); + if in_replace { + key_context.add("in_replace"); + } + + let query_error_line = self.query_error.as_ref().map(|error| { + Label::new(error) + .size(LabelSize::Small) + .color(Color::Error) + .mt_neg_1() + .ml_2() + }); + + let search_line = + h_flex() + .relative() + .child(search_line) + .when(!narrow_mode && !find_in_results, |this| { + this.child( + h_flex() + .absolute() + .right_0() + .when(has_collapse_button, |this| { + this.pr_2() + .border_r_1() + .border_color(cx.theme().colors().border_variant) + }) + .child(render_action_button( + "buffer-search", + IconName::Close, + Default::default(), + "Close Search Bar", + &Dismiss, + focus_handle.clone(), + )), + ) + }); + + v_flex() + .id("buffer_search") + .gap_2() + .w_full() + .track_scroll(&self.scroll_handle) + .key_context(key_context) + .capture_action(cx.listener(Self::tab)) + .capture_action(cx.listener(Self::backtab)) + .capture_action(cx.listener(Self::toggle_fold_all)) + .capture_action(cx.listener(Self::toggle_soft_wrap)) + .on_action(cx.listener(Self::previous_history_query)) + .on_action(cx.listener(Self::next_history_query)) + .on_action(cx.listener(Self::dismiss)) + .on_action(cx.listener(Self::select_next_match)) + .on_action(cx.listener(Self::select_prev_match)) + .on_action(cx.listener(|this, _: &ToggleOutline, window, cx| { + if let Some(active_searchable_item) = &mut this.active_searchable_item { + active_searchable_item.relay_action(Box::new(ToggleOutline), window, cx); + } + })) + .on_action(cx.listener(|this, _: &CopyPath, window, cx| { + if let Some(active_searchable_item) = &mut this.active_searchable_item { + active_searchable_item.relay_action(Box::new(CopyPath), window, cx); + } + })) + .on_action(cx.listener(|this, _: &CopyRelativePath, window, cx| { + if let Some(active_searchable_item) = &mut this.active_searchable_item { + active_searchable_item.relay_action(Box::new(CopyRelativePath), window, cx); + } + })) + .when(replacement, |this| { + this.on_action(cx.listener(Self::toggle_replace)) + .on_action(cx.listener(Self::replace_next)) + .on_action(cx.listener(Self::replace_all)) + }) + .when(case, |this| { + this.on_action(cx.listener(Self::toggle_case_sensitive)) + }) + .when(word, |this| { + this.on_action(cx.listener(Self::toggle_whole_word)) + }) + .when(regex, |this| { + this.on_action(cx.listener(Self::toggle_regex)) + }) + .when(selection, |this| { + this.on_action(cx.listener(Self::toggle_selection)) + }) + .child(search_line) + .children(query_error_line) + .children(replace_line) + .into_any_element() + } +} + +impl Focusable for BufferSearchBar { + fn focus_handle(&self, cx: &App) -> gpui::FocusHandle { + self.query_editor.focus_handle(cx) + } +} + +impl ToolbarItemView for BufferSearchBar { + fn contribute_context(&self, context: &mut KeyContext, _cx: &App) { + if !self.dismissed { + context.add("buffer_search_deployed"); + } + } + + fn set_active_pane_item( + &mut self, + item: Option<&dyn ItemHandle>, + window: &mut Window, + cx: &mut Context, + ) -> ToolbarItemLocation { + cx.notify(); + self.active_searchable_item_subscriptions.take(); + self.splittable_editor = None; + self._splittable_editor_subscription = None; + self.pending_search.take(); + + if let Some(item) = self.active_searchable_item.take() { + item.set_select_search_options(None, cx); + } + + if let Some(splittable_editor) = item + .and_then(|item| item.act_as_type(TypeId::of::(), cx)) + .and_then(|entity| entity.downcast::().ok()) + { + self._splittable_editor_subscription = + Some(cx.observe(&splittable_editor, |_, _, cx| { + cx.notify(); + })); + self.splittable_editor = Some(splittable_editor.downgrade()); + } + + if let Some(searchable_item_handle) = + item.and_then(|item| item.to_searchable_item_handle(cx)) + { + let this = cx.entity().downgrade(); + + let search_event_subscription = searchable_item_handle.subscribe_to_search_events( + window, + cx, + Box::new(move |search_event, window, cx| { + if let Some(this) = this.upgrade() { + this.update(cx, |this, cx| { + this.on_active_searchable_item_event(search_event, window, cx) + }); + } + }), + ); + + #[cfg(target_os = "macos")] + { + let item_focus_handle = searchable_item_handle.item_focus_handle(cx); + + self.active_searchable_item_subscriptions = Some([ + search_event_subscription, + cx.on_focus(&item_focus_handle, window, |this, window, cx| { + if this.query_editor_focused || this.replacement_editor_focused { + // no need to read pasteboard since focus came from toolbar + return; + } + + cx.defer_in(window, |this, window, cx| { + let Some(item) = cx.read_from_find_pasteboard() else { + return; + }; + let Some(text) = item.text() else { + return; + }; + + if this.query(cx) == text { + return; + } + + let search_options = item + .metadata() + .and_then(|m| m.parse().ok()) + .and_then(SearchOptions::from_bits) + .unwrap_or(this.search_options); + + if this.dismissed { + this.pending_external_query = Some((text, search_options)); + } else { + drop(this.search(&text, Some(search_options), true, window, cx)); + } + }); + }), + ]); + } + #[cfg(not(target_os = "macos"))] + { + self.active_searchable_item_subscriptions = Some(search_event_subscription); + } + + let is_project_search = searchable_item_handle.supported_options(cx).find_in_results; + self.active_searchable_item = Some(searchable_item_handle); + self.sync_select_search_options(cx); + drop(self.update_matches(true, false, window, cx)); + if self.needs_expand_collapse_option(cx) && self.is_dismissed() { + return ToolbarItemLocation::PrimaryLeft; + } else if !self.is_dismissed() { + if is_project_search { + self.dismiss(&Default::default(), window, cx); + } else { + return self.deployed_toolbar_location(cx); + } + } + } + ToolbarItemLocation::Hidden + } +} + +impl BufferSearchBar { + pub fn query_editor_focused(&self) -> bool { + self.query_editor_focused + } + + pub fn register(registrar: &mut impl SearchActionsRegistrar) { + registrar.register_handler(ForDeployed(|this, _: &FocusSearch, window, cx| { + this.query_editor.focus_handle(cx).focus(window, cx); + this.select_query(window, cx); + })); + registrar.register_handler(ForDeployed( + |this, action: &ToggleCaseSensitive, window, cx| { + if this.supported_options(cx).case { + this.toggle_case_sensitive(action, window, cx); + } + }, + )); + registrar.register_handler(ForDeployed(|this, action: &ToggleWholeWord, window, cx| { + if this.supported_options(cx).word { + this.toggle_whole_word(action, window, cx); + } + })); + registrar.register_handler(ForDeployed(|this, action: &ToggleRegex, window, cx| { + if this.supported_options(cx).regex { + this.toggle_regex(action, window, cx); + } + })); + registrar.register_handler(ForDeployed(|this, action: &ToggleSelection, window, cx| { + if this.supported_options(cx).selection { + this.toggle_selection(action, window, cx); + } else { + cx.propagate(); + } + })); + registrar.register_handler(ForDeployed(|this, action: &ToggleReplace, window, cx| { + if this.supported_options(cx).replacement { + this.toggle_replace(action, window, cx); + } else { + cx.propagate(); + } + })); + registrar.register_handler(WithResultsOrExternalQuery( + |this, action: &SelectNextMatch, window, cx| { + if this.supported_options(cx).find_in_results { + cx.propagate(); + } else { + this.select_next_match(action, window, cx); + } + }, + )); + registrar.register_handler(WithResultsOrExternalQuery( + |this, action: &SelectPreviousMatch, window, cx| { + if this.supported_options(cx).find_in_results { + cx.propagate(); + } else { + this.select_prev_match(action, window, cx); + } + }, + )); + registrar.register_handler(WithResultsOrExternalQuery( + |this, action: &SelectAllMatches, window, cx| { + if this.supported_options(cx).find_in_results { + cx.propagate(); + } else { + this.select_all_matches(action, window, cx); + } + }, + )); + registrar.register_handler(ForDeployed( + |this, _: &editor::actions::Cancel, window, cx| { + this.dismiss(&Dismiss, window, cx); + }, + )); + registrar.register_handler(ForDeployed(|this, _: &Dismiss, window, cx| { + this.dismiss(&Dismiss, window, cx); + })); + + // register deploy buffer search for both search bar states, since we want to focus into the search bar + // when the deploy action is triggered in the buffer. + registrar.register_handler(ForDeployed(|this, deploy, window, cx| { + this.deploy(deploy, None, window, cx); + })); + registrar.register_handler(ForDismissed(|this, deploy, window, cx| { + this.deploy(deploy, None, window, cx); + })); + registrar.register_handler(ForDeployed(|this, _: &DeployReplace, window, cx| { + if this.supported_options(cx).find_in_results { + cx.propagate(); + } else { + this.deploy(&Deploy::replace(), None, window, cx); + } + })); + registrar.register_handler(ForDismissed(|this, _: &DeployReplace, window, cx| { + if this.supported_options(cx).find_in_results { + cx.propagate(); + } else { + this.deploy(&Deploy::replace(), None, window, cx); + } + })); + registrar.register_handler(ForDeployed( + |this, action: &UseSelectionForFind, window, cx| { + this.use_selection_for_find(action, window, cx); + }, + )); + registrar.register_handler(ForDismissed( + |this, action: &UseSelectionForFind, window, cx| { + this.use_selection_for_find(action, window, cx); + }, + )); + } + + pub fn new( + languages: Option>, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let query_editor = cx.new(|cx| { + let mut editor = Editor::auto_height(1, 4, window, cx); + editor.set_use_autoclose(false); + editor.set_use_selection_highlight(false); + editor + }); + cx.subscribe_in(&query_editor, window, Self::on_query_editor_event) + .detach(); + let replacement_editor = cx.new(|cx| Editor::auto_height(1, 4, window, cx)); + cx.subscribe(&replacement_editor, Self::on_replacement_editor_event) + .detach(); + + let search_options = SearchOptions::from_settings(&EditorSettings::get_global(cx).search); + if let Some(languages) = languages { + let query_buffer = query_editor + .read(cx) + .buffer() + .read(cx) + .as_singleton() + .expect("query editor should be backed by a singleton buffer"); + + query_buffer + .read(cx) + .set_language_registry(languages.clone()); + + cx.spawn(async move |buffer_search_bar, cx| { + use anyhow::Context as _; + + let regex_language = languages + .language_for_name("regex") + .await + .context("loading regex language")?; + + buffer_search_bar + .update(cx, |buffer_search_bar, cx| { + buffer_search_bar.regex_language = Some(regex_language); + buffer_search_bar.adjust_query_regex_language(cx); + }) + .ok(); + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } + + Self { + query_editor, + query_editor_focused: false, + replacement_editor, + replacement_editor_focused: false, + active_searchable_item: None, + active_searchable_item_subscriptions: None, + #[cfg(target_os = "macos")] + pending_external_query: None, + active_match_index: None, + searchable_items_with_matches: Default::default(), + default_options: search_options, + configured_options: search_options, + search_options, + pending_search: None, + query_error: None, + dismissed: true, + search_history: SearchHistory::new( + Some(MAX_BUFFER_SEARCH_HISTORY_SIZE), + project::search_history::QueryInsertionBehavior::ReplacePreviousIfContains, + ), + search_history_cursor: Default::default(), + active_search: None, + replace_enabled: false, + selection_search_enabled: None, + scroll_handle: ScrollHandle::new(), + regex_language: None, + splittable_editor: None, + _splittable_editor_subscription: None, + } + } + + pub fn is_dismissed(&self) -> bool { + self.dismissed + } + + pub fn dismiss(&mut self, _: &Dismiss, window: &mut Window, cx: &mut Context) { + self.dismissed = true; + cx.emit(Event::Dismissed); + self.query_error = None; + self.sync_select_search_options(cx); + + for searchable_item in self.searchable_items_with_matches.keys() { + if let Some(searchable_item) = + WeakSearchableItemHandle::upgrade(searchable_item.as_ref(), cx) + { + searchable_item.clear_matches(window, cx); + } + } + + let needs_collapse_expand = self.needs_expand_collapse_option(cx); + + if let Some(active_editor) = self.active_searchable_item.as_mut() { + self.selection_search_enabled = None; + self.replace_enabled = false; + active_editor.search_bar_visibility_changed(false, window, cx); + active_editor.toggle_filtered_search_ranges(None, window, cx); + let handle = active_editor.item_focus_handle(cx); + self.focus(&handle, window, cx); + } + + if needs_collapse_expand { + cx.emit(Event::UpdateLocation); + cx.emit(ToolbarItemEvent::ChangeLocation( + ToolbarItemLocation::PrimaryLeft, + )); + cx.notify(); + return; + } + cx.emit(Event::UpdateLocation); + cx.emit(ToolbarItemEvent::ChangeLocation( + ToolbarItemLocation::Hidden, + )); + cx.notify(); + } + + pub fn deploy( + &mut self, + deploy: &Deploy, + seed_query_override: Option, + window: &mut Window, + cx: &mut Context, + ) -> bool { + let filtered_search_range = if deploy.selection_search_enabled { + Some(FilteredSearchRange::Default) + } else { + None + }; + if self.show(window, cx) { + if let Some(active_item) = self.active_searchable_item.as_mut() { + active_item.toggle_filtered_search_ranges(filtered_search_range, window, cx); + } + self.search_suggested(seed_query_override, window, cx); + self.smartcase(window, cx); + self.replace_enabled |= deploy.replace_enabled; + self.selection_search_enabled = + self.selection_search_enabled + .or(if deploy.selection_search_enabled { + Some(FilteredSearchRange::Default) + } else { + None + }); + if deploy.focus { + let mut handle = self.query_editor.focus_handle(cx); + let mut select_query = true; + + let has_seed_text = self + .query_suggestion(seed_query_override, window, cx) + .is_some(); + if deploy.replace_enabled && has_seed_text { + handle = self.replacement_editor.focus_handle(cx); + select_query = false; + }; + + if select_query { + self.select_query(window, cx); + } + + window.focus(&handle, cx); + } + return true; + } + + cx.propagate(); + false + } + + pub fn toggle(&mut self, action: &Deploy, window: &mut Window, cx: &mut Context) { + if self.is_dismissed() { + self.deploy(action, None, window, cx); + } else { + self.dismiss(&Dismiss, window, cx); + } + } + + pub fn show(&mut self, window: &mut Window, cx: &mut Context) -> bool { + let Some(handle) = self.active_searchable_item.as_ref() else { + return false; + }; + + let configured_options = + SearchOptions::from_settings(&EditorSettings::get_global(cx).search); + let settings_changed = configured_options != self.configured_options; + + if self.dismissed && settings_changed { + // Only update configuration options when search bar is dismissed, + // so we don't miss updates even after calling show twice + self.configured_options = configured_options; + self.search_options = configured_options; + self.default_options = configured_options; + } + + // This isn't a normal setting; it's only applicable to vim search. + self.search_options.remove(SearchOptions::BACKWARDS); + + self.dismissed = false; + self.sync_select_search_options(cx); + self.adjust_query_regex_language(cx); + handle.search_bar_visibility_changed(true, window, cx); + cx.notify(); + cx.emit(Event::UpdateLocation); + cx.emit(ToolbarItemEvent::ChangeLocation( + self.deployed_toolbar_location(cx), + )); + true + } + + fn deployed_toolbar_location(&self, cx: &App) -> ToolbarItemLocation { + if self.needs_expand_collapse_option(cx) { + ToolbarItemLocation::PrimaryLeft + } else { + ToolbarItemLocation::Secondary + } + } + + fn uses_hidden_splittable_editor(&self, cx: &App) -> bool { + self.splittable_editor.is_none() + && self.active_searchable_item.as_ref().is_some_and(|item| { + item.act_as_type(TypeId::of::(), cx) + .is_some() + }) + } + + fn supported_options(&self, cx: &mut Context) -> workspace::searchable::SearchOptions { + self.active_searchable_item + .as_ref() + .map(|item| item.supported_options(cx)) + .unwrap_or_default() + } + + // We provide an expand/collapse button if we are in a multibuffer + // and not doing a project search. + fn needs_expand_collapse_option(&self, cx: &App) -> bool { + !self.uses_hidden_splittable_editor(cx) + && self.active_searchable_item.as_ref().is_some_and(|item| { + item.buffer_kind(cx) == ItemBufferKind::Multibuffer + && !item.supported_options(cx).find_in_results + }) + } + + fn toggle_fold_all(&mut self, _: &ToggleFoldAll, window: &mut Window, cx: &mut Context) { + self.toggle_fold_all_in_item(window, cx); + } + + // The query editor is an editor itself and would otherwise swallow this + // action without any visible effect, so relay it to the searched editor + // while keeping the focus in the search bar. + fn toggle_soft_wrap( + &mut self, + action: &ToggleSoftWrap, + window: &mut Window, + cx: &mut Context, + ) { + let Some(item) = &self.active_searchable_item else { + return; + }; + // A split diff editor picks which side to toggle and keeps both sides + // in sync, while its `act_as_type(Editor)` only exposes the RHS + // editor, so relay to the split editor itself when it is split. + if let Some(split_editor) = item.act_as_type(TypeId::of::(), cx) { + let split_editor = split_editor + .downcast::() + .expect("Is a splittable editor"); + if split_editor.read(cx).is_split() { + split_editor.update(cx, |split_editor, cx| { + split_editor.toggle_soft_wrap(action, window, cx) + }); + return; + } + } + if let Some(item) = item.act_as_type(TypeId::of::(), cx) { + let editor = item.downcast::().expect("Is an editor"); + editor.update(cx, |editor, cx| editor.toggle_soft_wrap(action, window, cx)); + } + } + + fn toggle_fold_all_in_item(&self, window: &mut Window, cx: &mut Context) { + if let Some(item) = &self.active_searchable_item { + if let Some(item) = item.act_as_type(TypeId::of::(), cx) { + let editor = item.downcast::().expect("Is an editor"); + editor.update(cx, |editor, cx| { + let is_collapsed = editor.has_any_buffer_folded(cx); + if is_collapsed { + editor.unfold_all(&UnfoldAll, window, cx); + } else { + editor.fold_all(&FoldAll, window, cx); + } + }) + } + } + } + + pub fn search_suggested( + &mut self, + seed_query_override: Option, + window: &mut Window, + cx: &mut Context, + ) { + let search = self + .query_suggestion(seed_query_override, window, cx) + .map(|suggestion| { + let suggestion = if self.default_options.contains(SearchOptions::REGEX) { + regex::escape(&suggestion) + } else { + suggestion + }; + self.search(&suggestion, Some(self.default_options), true, window, cx) + }); + + #[cfg(target_os = "macos")] + let search = search.or_else(|| { + self.pending_external_query + .take() + .map(|(query, options)| self.search(&query, Some(options), true, window, cx)) + }); + + if let Some(search) = search { + cx.spawn_in(window, async move |this, cx| { + if search.await.is_ok() { + this.update_in(cx, |this, window, cx| { + if !this.dismissed { + this.activate_current_match(window, cx) + } + }) + } else { + Ok(()) + } + }) + .detach_and_log_err(cx); + } + } + + pub fn activate_current_match(&mut self, window: &mut Window, cx: &mut Context) { + if let Some(match_ix) = self.active_match_index + && let Some(active_searchable_item) = self.active_searchable_item.as_ref() + && let Some((matches, token)) = self + .searchable_items_with_matches + .get(&active_searchable_item.downgrade()) + { + active_searchable_item.activate_match(match_ix, matches, *token, window, cx) + } + } + + pub fn select_query(&mut self, window: &mut Window, cx: &mut Context) { + self.query_editor.update(cx, |query_editor, cx| { + query_editor.select_all(&Default::default(), window, cx); + }); + } + + pub fn query(&self, cx: &App) -> String { + self.query_editor.read(cx).text(cx) + } + + pub fn replacement(&self, cx: &mut App) -> String { + self.replacement_editor.read(cx).text(cx) + } + + pub fn query_suggestion( + &mut self, + seed_query_override: Option, + window: &mut Window, + cx: &mut Context, + ) -> Option { + self.active_searchable_item + .as_ref() + .map(|searchable_item| { + searchable_item.query_suggestion(seed_query_override, window, cx) + }) + .filter(|suggestion| !suggestion.is_empty()) + } + + pub fn set_replacement(&mut self, replacement: Option<&str>, cx: &mut Context) { + if replacement.is_none() { + self.replace_enabled = false; + return; + } + self.replace_enabled = true; + self.replacement_editor + .update(cx, |replacement_editor, cx| { + replacement_editor + .buffer() + .update(cx, |replacement_buffer, cx| { + let len = replacement_buffer.len(cx); + replacement_buffer.edit( + [(MultiBufferOffset(0)..len, replacement.unwrap())], + None, + cx, + ); + }); + }); + } + + pub fn focus_replace(&mut self, window: &mut Window, cx: &mut Context) { + self.focus(&self.replacement_editor.focus_handle(cx), window, cx); + cx.notify(); + } + + pub fn search( + &mut self, + query: &str, + options: Option, + add_to_history: bool, + window: &mut Window, + cx: &mut Context, + ) -> oneshot::Receiver<()> { + let options = options.unwrap_or(self.default_options); + let updated = query != self.query(cx) || self.search_options != options; + if updated { + self.query_editor.update(cx, |query_editor, cx| { + query_editor.buffer().update(cx, |query_buffer, cx| { + let len = query_buffer.len(cx); + query_buffer.edit([(MultiBufferOffset(0)..len, query)], None, cx); + }); + query_editor.request_autoscroll(Autoscroll::fit(), cx); + }); + self.set_search_options(options, cx); + self.clear_matches(window, cx); + #[cfg(target_os = "macos")] + self.update_find_pasteboard(cx); + cx.notify(); + } + self.update_matches(!updated, add_to_history, window, cx) + } + + #[cfg(target_os = "macos")] + pub fn update_find_pasteboard(&mut self, cx: &mut App) { + cx.write_to_find_pasteboard(gpui::ClipboardItem::new_string_with_metadata( + self.query(cx), + self.search_options.bits().to_string(), + )); + } + + pub fn use_selection_for_find( + &mut self, + _: &UseSelectionForFind, + window: &mut Window, + cx: &mut Context, + ) { + self.deploy( + &Deploy { + focus: false, + replace_enabled: false, + selection_search_enabled: false, + }, + Some(SeedQuerySetting::Always), + window, + cx, + ); + } + + pub fn focus_editor(&mut self, _: &FocusEditor, window: &mut Window, cx: &mut Context) { + if let Some(active_editor) = self.active_searchable_item.as_ref() { + let handle = active_editor.item_focus_handle(cx); + window.focus(&handle, cx); + } + } + + pub fn toggle_search_option( + &mut self, + search_option: SearchOptions, + window: &mut Window, + cx: &mut Context, + ) { + self.search_options.toggle(search_option); + self.default_options = self.search_options; + drop(self.update_matches(false, false, window, cx)); + self.adjust_query_regex_language(cx); + self.sync_select_search_options(cx); + cx.notify(); + } + + pub fn has_search_option(&mut self, search_option: SearchOptions) -> bool { + self.search_options.contains(search_option) + } + + pub fn enable_search_option( + &mut self, + search_option: SearchOptions, + window: &mut Window, + cx: &mut Context, + ) { + if !self.search_options.contains(search_option) { + self.toggle_search_option(search_option, window, cx) + } + } + + pub fn set_search_within_selection( + &mut self, + search_within_selection: Option, + window: &mut Window, + cx: &mut Context, + ) -> Option> { + let active_item = self.active_searchable_item.as_mut()?; + self.selection_search_enabled = search_within_selection; + active_item.toggle_filtered_search_ranges(self.selection_search_enabled, window, cx); + cx.notify(); + Some(self.update_matches(false, false, window, cx)) + } + + pub fn set_search_options(&mut self, search_options: SearchOptions, cx: &mut Context) { + self.search_options = search_options; + self.adjust_query_regex_language(cx); + self.sync_select_search_options(cx); + cx.notify(); + } + + pub fn clear_search_within_ranges( + &mut self, + search_options: SearchOptions, + cx: &mut Context, + ) { + self.search_options = search_options; + self.adjust_query_regex_language(cx); + cx.notify(); + } + + fn select_next_match( + &mut self, + _: &SelectNextMatch, + window: &mut Window, + cx: &mut Context, + ) { + self.select_match(Direction::Next, 1, window, cx); + } + + fn select_prev_match( + &mut self, + _: &SelectPreviousMatch, + window: &mut Window, + cx: &mut Context, + ) { + self.select_match(Direction::Prev, 1, window, cx); + } + + pub fn select_all_matches( + &mut self, + _: &SelectAllMatches, + window: &mut Window, + cx: &mut Context, + ) { + if !self.dismissed + && self.active_match_index.is_some() + && let Some(searchable_item) = self.active_searchable_item.as_ref() + && let Some((matches, token)) = self + .searchable_items_with_matches + .get(&searchable_item.downgrade()) + { + searchable_item.select_matches(matches, *token, window, cx); + self.focus_editor(&FocusEditor, window, cx); + } + } + + pub fn select_match( + &mut self, + direction: Direction, + count: usize, + window: &mut Window, + cx: &mut Context, + ) { + #[cfg(target_os = "macos")] + if let Some((query, options)) = self.pending_external_query.take() { + let search_rx = self.search(&query, Some(options), true, window, cx); + cx.spawn_in(window, async move |this, cx| { + if search_rx.await.is_ok() { + this.update_in(cx, |this, window, cx| { + this.activate_current_match(window, cx); + }) + .ok(); + } + }) + .detach(); + + return; + } + + if let Some(index) = self.active_match_index + && let Some(searchable_item) = self.active_searchable_item.as_ref() + && let Some((matches, token)) = self + .searchable_items_with_matches + .get(&searchable_item.downgrade()) + .filter(|(matches, _)| !matches.is_empty()) + { + // If 'wrapscan' is disabled, searches do not wrap around the end of the file. + if !EditorSettings::get_global(cx).search_wrap + && ((direction == Direction::Next && index + count >= matches.len()) + || (direction == Direction::Prev && index < count)) + { + crate::show_no_more_matches(window, cx); + return; + } + let new_match_index = searchable_item + .match_index_for_direction(matches, index, direction, count, *token, window, cx); + self.active_match_index = Some(new_match_index); + + searchable_item.update_matches(matches, Some(new_match_index), *token, window, cx); + searchable_item.activate_match(new_match_index, matches, *token, window, cx); + } + } + + pub fn select_first_match(&mut self, window: &mut Window, cx: &mut Context) { + if let Some(searchable_item) = self.active_searchable_item.as_ref() + && let Some((matches, token)) = self + .searchable_items_with_matches + .get(&searchable_item.downgrade()) + { + if matches.is_empty() { + return; + } + searchable_item.update_matches(matches, Some(0), *token, window, cx); + searchable_item.activate_match(0, matches, *token, window, cx); + } + } + + pub fn select_last_match(&mut self, window: &mut Window, cx: &mut Context) { + if let Some(searchable_item) = self.active_searchable_item.as_ref() + && let Some((matches, token)) = self + .searchable_items_with_matches + .get(&searchable_item.downgrade()) + { + if matches.is_empty() { + return; + } + let new_match_index = matches.len() - 1; + searchable_item.update_matches(matches, Some(new_match_index), *token, window, cx); + searchable_item.activate_match(new_match_index, matches, *token, window, cx); + } + } + + fn on_query_editor_event( + &mut self, + _editor: &Entity, + event: &editor::EditorEvent, + window: &mut Window, + cx: &mut Context, + ) { + match event { + editor::EditorEvent::Focused => self.query_editor_focused = true, + editor::EditorEvent::Blurred => self.query_editor_focused = false, + editor::EditorEvent::Edited { .. } => { + self.smartcase(window, cx); + self.clear_matches(window, cx); + let search = self.update_matches(false, true, window, cx); + + cx.spawn_in(window, async move |this, cx| { + if search.await.is_ok() { + this.update_in(cx, |this, window, cx| { + this.activate_current_match(window, cx); + #[cfg(target_os = "macos")] + this.update_find_pasteboard(cx); + })?; + } + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } + _ => {} + } + } + + fn on_replacement_editor_event( + &mut self, + _: Entity, + event: &editor::EditorEvent, + _: &mut Context, + ) { + match event { + editor::EditorEvent::Focused => self.replacement_editor_focused = true, + editor::EditorEvent::Blurred => self.replacement_editor_focused = false, + _ => {} + } + } + + fn on_active_searchable_item_event( + &mut self, + event: &SearchEvent, + window: &mut Window, + cx: &mut Context, + ) { + match event { + SearchEvent::MatchesInvalidated => { + drop(self.update_matches(false, false, window, cx)); + } + SearchEvent::ActiveMatchChanged => self.update_match_index(window, cx), + } + } + + fn toggle_case_sensitive( + &mut self, + _: &ToggleCaseSensitive, + window: &mut Window, + cx: &mut Context, + ) { + self.toggle_search_option(SearchOptions::CASE_SENSITIVE, window, cx) + } + + fn toggle_whole_word( + &mut self, + _: &ToggleWholeWord, + window: &mut Window, + cx: &mut Context, + ) { + self.toggle_search_option(SearchOptions::WHOLE_WORD, window, cx) + } + + fn toggle_selection( + &mut self, + _: &ToggleSelection, + window: &mut Window, + cx: &mut Context, + ) { + self.set_search_within_selection( + if let Some(_) = self.selection_search_enabled { + None + } else { + Some(FilteredSearchRange::Default) + }, + window, + cx, + ); + } + + fn toggle_regex(&mut self, _: &ToggleRegex, window: &mut Window, cx: &mut Context) { + self.toggle_search_option(SearchOptions::REGEX, window, cx) + } + + fn clear_active_searchable_item_matches(&mut self, window: &mut Window, cx: &mut App) { + if let Some(active_searchable_item) = self.active_searchable_item.as_ref() { + self.active_match_index = None; + self.searchable_items_with_matches + .remove(&active_searchable_item.downgrade()); + active_searchable_item.clear_matches(window, cx); + } + } + + pub fn has_active_match(&self) -> bool { + self.active_match_index.is_some() + } + + fn clear_matches(&mut self, window: &mut Window, cx: &mut Context) { + let mut active_item_matches = None; + for (searchable_item, matches) in self.searchable_items_with_matches.drain() { + if let Some(searchable_item) = + WeakSearchableItemHandle::upgrade(searchable_item.as_ref(), cx) + { + if Some(&searchable_item) == self.active_searchable_item.as_ref() { + active_item_matches = Some((searchable_item.downgrade(), matches)); + } else { + searchable_item.clear_matches(window, cx); + } + } + } + + self.searchable_items_with_matches + .extend(active_item_matches); + } + + fn update_matches( + &mut self, + reuse_existing_query: bool, + add_to_history: bool, + window: &mut Window, + cx: &mut Context, + ) -> oneshot::Receiver<()> { + let (done_tx, done_rx) = oneshot::channel(); + let query = self.query(cx); + self.pending_search.take(); + #[cfg(target_os = "macos")] + self.pending_external_query.take(); + + if let Some(active_searchable_item) = self.active_searchable_item.as_ref() { + self.query_error = None; + if query.is_empty() { + self.clear_active_searchable_item_matches(window, cx); + let _ = done_tx.send(()); + cx.notify(); + } else { + let query: Arc<_> = if let Some(search) = + self.active_search.take().filter(|_| reuse_existing_query) + { + search + } else { + // Value doesn't matter, we only construct empty matchers with it + + if self.search_options.contains(SearchOptions::REGEX) { + match SearchQuery::regex( + query, + self.search_options.contains(SearchOptions::WHOLE_WORD), + self.search_options.contains(SearchOptions::CASE_SENSITIVE), + false, + self.search_options + .contains(SearchOptions::ONE_MATCH_PER_LINE), + PathMatcher::default(), + PathMatcher::default(), + false, + None, + ) { + Ok(query) => query.with_replacement(self.replacement(cx)), + Err(e) => { + self.query_error = Some(e.to_string()); + self.clear_active_searchable_item_matches(window, cx); + cx.notify(); + return done_rx; + } + } + } else { + match SearchQuery::text( + query, + self.search_options.contains(SearchOptions::WHOLE_WORD), + self.search_options.contains(SearchOptions::CASE_SENSITIVE), + false, + PathMatcher::default(), + PathMatcher::default(), + false, + None, + ) { + Ok(query) => query.with_replacement(self.replacement(cx)), + Err(e) => { + self.query_error = Some(e.to_string()); + self.clear_active_searchable_item_matches(window, cx); + cx.notify(); + return done_rx; + } + } + } + .into() + }; + + self.active_search = Some(query.clone()); + let query_text = query.as_str().to_string(); + + let matches_with_token = + active_searchable_item.find_matches_with_token(query, window, cx); + + let active_searchable_item = active_searchable_item.downgrade(); + self.pending_search = Some(cx.spawn_in(window, async move |this, cx| { + let (matches, token) = matches_with_token.await; + + this.update_in(cx, |this, window, cx| { + if let Some(active_searchable_item) = + WeakSearchableItemHandle::upgrade(active_searchable_item.as_ref(), cx) + { + this.searchable_items_with_matches + .insert(active_searchable_item.downgrade(), (matches, token)); + + this.update_match_index(window, cx); + + if add_to_history { + this.search_history + .add(&mut this.search_history_cursor, query_text); + } + if !this.dismissed { + let (matches, token) = this + .searchable_items_with_matches + .get(&active_searchable_item.downgrade()) + .unwrap(); + if matches.is_empty() { + active_searchable_item.clear_matches(window, cx); + } else { + active_searchable_item.update_matches( + matches, + this.active_match_index, + *token, + window, + cx, + ); + } + } + let _ = done_tx.send(()); + cx.notify(); + } + }) + .log_err(); + })); + } + } + done_rx + } + + fn reverse_direction_if_backwards(&self, direction: Direction) -> Direction { + if self.search_options.contains(SearchOptions::BACKWARDS) { + direction.opposite() + } else { + direction + } + } + + pub fn update_match_index(&mut self, window: &mut Window, cx: &mut Context) { + let direction = self.reverse_direction_if_backwards(Direction::Next); + let new_index = self + .active_searchable_item + .as_ref() + .and_then(|searchable_item| { + let (matches, token) = self + .searchable_items_with_matches + .get(&searchable_item.downgrade())?; + searchable_item.active_match_index(direction, matches, *token, window, cx) + }); + if new_index != self.active_match_index { + self.active_match_index = new_index; + if !self.dismissed { + if let Some(searchable_item) = self.active_searchable_item.as_ref() { + if let Some((matches, token)) = self + .searchable_items_with_matches + .get(&searchable_item.downgrade()) + { + if !matches.is_empty() { + searchable_item.update_matches(matches, new_index, *token, window, cx); + } + } + } + } + cx.notify(); + } + } + + fn tab(&mut self, _: &Tab, window: &mut Window, cx: &mut Context) { + self.cycle_field(Direction::Next, window, cx); + } + + fn backtab(&mut self, _: &Backtab, window: &mut Window, cx: &mut Context) { + self.cycle_field(Direction::Prev, window, cx); + } + fn cycle_field(&mut self, direction: Direction, window: &mut Window, cx: &mut Context) { + let mut handles = vec![self.query_editor.focus_handle(cx)]; + if self.replace_enabled { + handles.push(self.replacement_editor.focus_handle(cx)); + } + if let Some(item) = self.active_searchable_item.as_ref() { + handles.push(item.item_focus_handle(cx)); + } + let current_index = match handles.iter().position(|focus| focus.is_focused(window)) { + Some(index) => index, + None => return, + }; + + let new_index = match direction { + Direction::Next => (current_index + 1) % handles.len(), + Direction::Prev if current_index == 0 => handles.len() - 1, + Direction::Prev => (current_index - 1) % handles.len(), + }; + let next_focus_handle = &handles[new_index]; + self.focus(next_focus_handle, window, cx); + cx.stop_propagation(); + } + + fn next_history_query( + &mut self, + _: &NextHistoryQuery, + window: &mut Window, + cx: &mut Context, + ) { + if !should_navigate_history(&self.query_editor, HistoryNavigationDirection::Next, cx) { + cx.propagate(); + return; + } + + if let Some(new_query) = self + .search_history + .next(&mut self.search_history_cursor) + .map(str::to_string) + { + drop(self.search(&new_query, Some(self.search_options), false, window, cx)); + } else if let Some(draft) = self.search_history_cursor.take_draft() { + drop(self.search(&draft, Some(self.search_options), false, window, cx)); + } + } + + fn previous_history_query( + &mut self, + _: &PreviousHistoryQuery, + window: &mut Window, + cx: &mut Context, + ) { + if !should_navigate_history(&self.query_editor, HistoryNavigationDirection::Previous, cx) { + cx.propagate(); + return; + } + + if self.query(cx).is_empty() + && let Some(new_query) = self + .search_history + .current(&self.search_history_cursor) + .map(str::to_string) + { + drop(self.search(&new_query, Some(self.search_options), false, window, cx)); + return; + } + + let current_query = self.query(cx); + if let Some(new_query) = self + .search_history + .previous(&mut self.search_history_cursor, ¤t_query) + .map(str::to_string) + { + drop(self.search(&new_query, Some(self.search_options), false, window, cx)); + } + } + + fn focus(&self, handle: &gpui::FocusHandle, window: &mut Window, cx: &mut App) { + window.invalidate_character_coordinates(); + window.focus(handle, cx); + } + + fn toggle_replace(&mut self, _: &ToggleReplace, window: &mut Window, cx: &mut Context) { + if self.active_searchable_item.is_some() { + self.replace_enabled = !self.replace_enabled; + let handle = if self.replace_enabled { + self.replacement_editor.focus_handle(cx) + } else { + self.query_editor.focus_handle(cx) + }; + self.focus(&handle, window, cx); + cx.notify(); + } + } + + fn replace_next(&mut self, _: &ReplaceNext, window: &mut Window, cx: &mut Context) { + let mut should_propagate = true; + if !self.dismissed + && self.active_search.is_some() + && let Some(searchable_item) = self.active_searchable_item.as_ref() + && let Some(query) = self.active_search.as_ref() + && let Some((matches, token)) = self + .searchable_items_with_matches + .get(&searchable_item.downgrade()) + { + if let Some(active_index) = self.active_match_index { + let query = query + .as_ref() + .clone() + .with_replacement(self.replacement(cx)); + searchable_item.replace(matches.at(active_index), &query, *token, window, cx); + self.select_next_match(&SelectNextMatch, window, cx); + } + should_propagate = false; + } + if !should_propagate { + cx.stop_propagation(); + } + } + + pub fn replace_all(&mut self, _: &ReplaceAll, window: &mut Window, cx: &mut Context) { + if !self.dismissed + && self.active_search.is_some() + && let Some(searchable_item) = self.active_searchable_item.as_ref() + && let Some(query) = self.active_search.as_ref() + && let Some((matches, token)) = self + .searchable_items_with_matches + .get(&searchable_item.downgrade()) + { + let query = query + .as_ref() + .clone() + .with_replacement(self.replacement(cx)); + searchable_item.replace_all(&mut matches.iter(), &query, *token, window, cx); + } + } + + pub fn match_exists(&mut self, window: &mut Window, cx: &mut Context) -> bool { + self.update_match_index(window, cx); + self.active_match_index.is_some() + } + + pub fn should_use_smartcase_search(&mut self, cx: &mut Context) -> bool { + EditorSettings::get_global(cx).use_smartcase_search + } + + pub fn is_contains_uppercase(&mut self, str: &String) -> bool { + str.chars().any(|c| c.is_uppercase()) + } + + fn smartcase(&mut self, window: &mut Window, cx: &mut Context) { + if self.should_use_smartcase_search(cx) { + let query = self.query(cx); + if !query.is_empty() { + let is_case = self.is_contains_uppercase(&query); + if self.has_search_option(SearchOptions::CASE_SENSITIVE) != is_case { + self.toggle_search_option(SearchOptions::CASE_SENSITIVE, window, cx); + } + } + } + } + + fn adjust_query_regex_language(&self, cx: &mut App) { + let enable = self.search_options.contains(SearchOptions::REGEX); + let query_buffer = self + .query_editor + .read(cx) + .buffer() + .read(cx) + .as_singleton() + .expect("query editor should be backed by a singleton buffer"); + + if enable { + if let Some(regex_language) = self.regex_language.clone() { + query_buffer.update(cx, |query_buffer, cx| { + query_buffer.set_language(Some(regex_language), cx); + }) + } + } else { + query_buffer.update(cx, |query_buffer, cx| { + query_buffer.set_language(None, cx); + }) + } + } + + /// Updates the searchable item's search options to match the + /// search bar's current settings. This ensures that + /// editor's `select_next`/ `select_previous` operations respect the buffer + /// search bar's search options. + /// + /// Clears them when the search bar is dismissed so that + /// only the editor's settings are respected. + fn sync_select_search_options(&self, cx: &mut Context) { + let search_options = match self.dismissed { + true => None, + false => Some(SelectSearchOptions { + case_sensitive: self.search_options.contains(SearchOptions::CASE_SENSITIVE), + whole_word: self.search_options.contains(SearchOptions::WHOLE_WORD), + }), + }; + + if let Some(active_searchable_item) = self.active_searchable_item.as_ref() { + active_searchable_item.set_select_search_options(search_options, cx); + } + } +} + +#[cfg(test)] +mod tests { + use std::{ops::Range, time::Duration}; + + use super::*; + use editor::{ + DisplayPoint, Editor, HighlightKey, MultiBuffer, PathKey, + SELECTION_HIGHLIGHT_DEBOUNCE_TIMEOUT, SearchSettings, SelectionEffects, SoftWrap, + display_map::DisplayRow, test::editor_test_context::EditorTestContext, + }; + use futures::stream::StreamExt as _; + use gpui::{FocusHandle, Hsla, TestAppContext, UpdateGlobal, VisualTestContext}; + use language::{Buffer, Point}; + #[cfg(target_os = "macos")] + use project::Project; + use settings::{SearchSettingsContent, SettingsStore}; + use unindent::Unindent as _; + use util_macros::perf; + #[cfg(target_os = "macos")] + use workspace::{AppState, MultiWorkspace, Workspace}; + use workspace::{item::Item, searchable::SearchableItem}; + + fn init_globals(cx: &mut TestAppContext) { + cx.update(|cx| { + let store = settings::SettingsStore::test(cx); + cx.set_global(store); + editor::init(cx); + + theme_settings::init(theme::LoadThemes::JustBase, cx); + crate::init(cx); + }); + } + + fn init_multibuffer_test( + cx: &mut TestAppContext, + ) -> ( + Entity, + Entity, + &mut VisualTestContext, + ) { + init_globals(cx); + + let buffer1 = cx.new(|cx| { + Buffer::local( + r#" + A regular expression (shortened as regex or regexp;[1] also referred to as + rational expression[2][3]) is a sequence of characters that specifies a search + pattern in text. Usually such patterns are used by string-searching algorithms + for "find" or "find and replace" operations on strings, or for input validation. + "# + .unindent(), + cx, + ) + }); + + let buffer2 = cx.new(|cx| { + Buffer::local( + r#" + Some Additional text with the term regular expression in it. + There two lines. + "# + .unindent(), + cx, + ) + }); + + let multibuffer = cx.new(|cx| { + let mut buffer = MultiBuffer::new(language::Capability::ReadWrite); + + //[ExcerptRange::new(Point::new(0, 0)..Point::new(2, 0))] + buffer.set_excerpts_for_path( + PathKey::sorted(0), + buffer1, + [Point::new(0, 0)..Point::new(3, 0)], + 0, + cx, + ); + buffer.set_excerpts_for_path( + PathKey::sorted(1), + buffer2, + [Point::new(0, 0)..Point::new(1, 0)], + 0, + cx, + ); + + buffer + }); + let mut editor = None; + let window = cx.add_window(|window, cx| { + let default_key_bindings = settings::KeymapFile::load_asset_allow_partial_failure( + "keymaps/default-macos.json", + cx, + ) + .unwrap(); + cx.bind_keys(default_key_bindings); + editor = + Some(cx.new(|cx| Editor::for_multibuffer(multibuffer.clone(), None, window, cx))); + + let mut search_bar = BufferSearchBar::new(None, window, cx); + search_bar.set_active_pane_item(Some(&editor.clone().unwrap()), window, cx); + search_bar.show(window, cx); + search_bar + }); + let search_bar = window.root(cx).unwrap(); + + let cx = VisualTestContext::from_window(*window, cx).into_mut(); + + (editor.unwrap(), search_bar, cx) + } + + fn init_test( + cx: &mut TestAppContext, + ) -> ( + Entity, + Entity, + &mut VisualTestContext, + ) { + init_globals(cx); + let buffer = cx.new(|cx| { + Buffer::local( + r#" + A regular expression (shortened as regex or regexp;[1] also referred to as + rational expression[2][3]) is a sequence of characters that specifies a search + pattern in text. Usually such patterns are used by string-searching algorithms + for "find" or "find and replace" operations on strings, or for input validation. + "# + .unindent(), + cx, + ) + }); + let mut editor = None; + let window = cx.add_window(|window, cx| { + let default_key_bindings = settings::KeymapFile::load_asset_allow_partial_failure( + "keymaps/default-macos.json", + cx, + ) + .unwrap(); + cx.bind_keys(default_key_bindings); + editor = Some(cx.new(|cx| Editor::for_buffer(buffer.clone(), None, window, cx))); + let mut search_bar = BufferSearchBar::new(None, window, cx); + search_bar.set_active_pane_item(Some(&editor.clone().unwrap()), window, cx); + search_bar.show(window, cx); + search_bar + }); + let search_bar = window.root(cx).unwrap(); + + let cx = VisualTestContext::from_window(*window, cx).into_mut(); + + (editor.unwrap(), search_bar, cx) + } + + #[perf] + #[gpui::test] + async fn test_search_simple(cx: &mut TestAppContext) { + let (editor, search_bar, cx) = init_test(cx); + let display_points_of = |background_highlights: Vec<(Range, Hsla)>| { + background_highlights + .into_iter() + .map(|(range, _)| range) + .collect::>() + }; + // Search for a string that appears with different casing. + // By default, search is case-insensitive. + search_bar + .update_in(cx, |search_bar, window, cx| { + search_bar.search("us", None, true, window, cx) + }) + .await + .unwrap(); + editor.update_in(cx, |editor, window, cx| { + assert_eq!( + display_points_of(editor.all_text_background_highlights(window, cx)), + &[ + DisplayPoint::new(DisplayRow(2), 17)..DisplayPoint::new(DisplayRow(2), 19), + DisplayPoint::new(DisplayRow(2), 43)..DisplayPoint::new(DisplayRow(2), 45), + ] + ); + }); + + // Switch to a case sensitive search. + search_bar.update_in(cx, |search_bar, window, cx| { + search_bar.toggle_search_option(SearchOptions::CASE_SENSITIVE, window, cx); + }); + let mut editor_notifications = cx.notifications(&editor); + editor_notifications.next().await; + editor.update_in(cx, |editor, window, cx| { + assert_eq!( + display_points_of(editor.all_text_background_highlights(window, cx)), + &[DisplayPoint::new(DisplayRow(2), 43)..DisplayPoint::new(DisplayRow(2), 45),] + ); + }); + + // Search for a string that appears both as a whole word and + // within other words. By default, all results are found. + search_bar + .update_in(cx, |search_bar, window, cx| { + search_bar.search("or", None, true, window, cx) + }) + .await + .unwrap(); + editor.update_in(cx, |editor, window, cx| { + assert_eq!( + display_points_of(editor.all_text_background_highlights(window, cx)), + &[ + DisplayPoint::new(DisplayRow(0), 24)..DisplayPoint::new(DisplayRow(0), 26), + DisplayPoint::new(DisplayRow(0), 41)..DisplayPoint::new(DisplayRow(0), 43), + DisplayPoint::new(DisplayRow(2), 71)..DisplayPoint::new(DisplayRow(2), 73), + DisplayPoint::new(DisplayRow(3), 1)..DisplayPoint::new(DisplayRow(3), 3), + DisplayPoint::new(DisplayRow(3), 11)..DisplayPoint::new(DisplayRow(3), 13), + DisplayPoint::new(DisplayRow(3), 56)..DisplayPoint::new(DisplayRow(3), 58), + DisplayPoint::new(DisplayRow(3), 60)..DisplayPoint::new(DisplayRow(3), 62), + ] + ); + }); + + // Switch to a whole word search. + search_bar.update_in(cx, |search_bar, window, cx| { + search_bar.toggle_search_option(SearchOptions::WHOLE_WORD, window, cx); + }); + let mut editor_notifications = cx.notifications(&editor); + editor_notifications.next().await; + editor.update_in(cx, |editor, window, cx| { + assert_eq!( + display_points_of(editor.all_text_background_highlights(window, cx)), + &[ + DisplayPoint::new(DisplayRow(0), 41)..DisplayPoint::new(DisplayRow(0), 43), + DisplayPoint::new(DisplayRow(3), 11)..DisplayPoint::new(DisplayRow(3), 13), + DisplayPoint::new(DisplayRow(3), 56)..DisplayPoint::new(DisplayRow(3), 58), + ] + ); + }); + + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0) + ]) + }); + }); + search_bar.update_in(cx, |search_bar, window, cx| { + assert_eq!(search_bar.active_match_index, Some(0)); + search_bar.select_next_match(&SelectNextMatch, window, cx); + assert_eq!( + editor.update(cx, |editor, cx| editor + .selections + .display_ranges(&editor.display_snapshot(cx))), + [DisplayPoint::new(DisplayRow(0), 41)..DisplayPoint::new(DisplayRow(0), 43)] + ); + }); + search_bar.read_with(cx, |search_bar, _| { + assert_eq!(search_bar.active_match_index, Some(0)); + }); + + search_bar.update_in(cx, |search_bar, window, cx| { + search_bar.select_next_match(&SelectNextMatch, window, cx); + assert_eq!( + editor.update(cx, |editor, cx| editor + .selections + .display_ranges(&editor.display_snapshot(cx))), + [DisplayPoint::new(DisplayRow(3), 11)..DisplayPoint::new(DisplayRow(3), 13)] + ); + }); + search_bar.read_with(cx, |search_bar, _| { + assert_eq!(search_bar.active_match_index, Some(1)); + }); + + search_bar.update_in(cx, |search_bar, window, cx| { + search_bar.select_next_match(&SelectNextMatch, window, cx); + assert_eq!( + editor.update(cx, |editor, cx| editor + .selections + .display_ranges(&editor.display_snapshot(cx))), + [DisplayPoint::new(DisplayRow(3), 56)..DisplayPoint::new(DisplayRow(3), 58)] + ); + }); + search_bar.read_with(cx, |search_bar, _| { + assert_eq!(search_bar.active_match_index, Some(2)); + }); + + search_bar.update_in(cx, |search_bar, window, cx| { + search_bar.select_next_match(&SelectNextMatch, window, cx); + assert_eq!( + editor.update(cx, |editor, cx| editor + .selections + .display_ranges(&editor.display_snapshot(cx))), + [DisplayPoint::new(DisplayRow(0), 41)..DisplayPoint::new(DisplayRow(0), 43)] + ); + }); + search_bar.read_with(cx, |search_bar, _| { + assert_eq!(search_bar.active_match_index, Some(0)); + }); + + search_bar.update_in(cx, |search_bar, window, cx| { + search_bar.select_prev_match(&SelectPreviousMatch, window, cx); + assert_eq!( + editor.update(cx, |editor, cx| editor + .selections + .display_ranges(&editor.display_snapshot(cx))), + [DisplayPoint::new(DisplayRow(3), 56)..DisplayPoint::new(DisplayRow(3), 58)] + ); + }); + search_bar.read_with(cx, |search_bar, _| { + assert_eq!(search_bar.active_match_index, Some(2)); + }); + + search_bar.update_in(cx, |search_bar, window, cx| { + search_bar.select_prev_match(&SelectPreviousMatch, window, cx); + assert_eq!( + editor.update(cx, |editor, cx| editor + .selections + .display_ranges(&editor.display_snapshot(cx))), + [DisplayPoint::new(DisplayRow(3), 11)..DisplayPoint::new(DisplayRow(3), 13)] + ); + }); + search_bar.read_with(cx, |search_bar, _| { + assert_eq!(search_bar.active_match_index, Some(1)); + }); + + search_bar.update_in(cx, |search_bar, window, cx| { + search_bar.select_prev_match(&SelectPreviousMatch, window, cx); + assert_eq!( + editor.update(cx, |editor, cx| editor + .selections + .display_ranges(&editor.display_snapshot(cx))), + [DisplayPoint::new(DisplayRow(0), 41)..DisplayPoint::new(DisplayRow(0), 43)] + ); + }); + search_bar.read_with(cx, |search_bar, _| { + assert_eq!(search_bar.active_match_index, Some(0)); + }); + + // Park the cursor in between matches and ensure that going to the previous match + // selects the closest match to the left of the cursor. + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(1), 0)..DisplayPoint::new(DisplayRow(1), 0) + ]) + }); + }); + search_bar.update_in(cx, |search_bar, window, cx| { + search_bar.select_prev_match(&SelectPreviousMatch, window, cx); + assert_eq!( + editor.update(cx, |editor, cx| editor + .selections + .display_ranges(&editor.display_snapshot(cx))), + [DisplayPoint::new(DisplayRow(0), 41)..DisplayPoint::new(DisplayRow(0), 43)] + ); + }); + search_bar.read_with(cx, |search_bar, _| { + assert_eq!(search_bar.active_match_index, Some(0)); + }); + + // Park the cursor in between matches and ensure that going to the next match + // selects the closest match to the right of the cursor. + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(1), 0)..DisplayPoint::new(DisplayRow(1), 0) + ]) + }); + }); + search_bar.update_in(cx, |search_bar, window, cx| { + search_bar.select_next_match(&SelectNextMatch, window, cx); + assert_eq!( + editor.update(cx, |editor, cx| editor + .selections + .display_ranges(&editor.display_snapshot(cx))), + [DisplayPoint::new(DisplayRow(3), 11)..DisplayPoint::new(DisplayRow(3), 13)] + ); + }); + search_bar.read_with(cx, |search_bar, _| { + assert_eq!(search_bar.active_match_index, Some(1)); + }); + + // Park the cursor after the last match and ensure that going to the previous match + // selects the last match. + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(3), 60)..DisplayPoint::new(DisplayRow(3), 60) + ]) + }); + }); + search_bar.update_in(cx, |search_bar, window, cx| { + search_bar.select_prev_match(&SelectPreviousMatch, window, cx); + assert_eq!( + editor.update(cx, |editor, cx| editor + .selections + .display_ranges(&editor.display_snapshot(cx))), + [DisplayPoint::new(DisplayRow(3), 56)..DisplayPoint::new(DisplayRow(3), 58)] + ); + }); + search_bar.read_with(cx, |search_bar, _| { + assert_eq!(search_bar.active_match_index, Some(2)); + }); + + // Park the cursor after the last match and ensure that going to the next match + // wraps around and selects the first match. + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(3), 60)..DisplayPoint::new(DisplayRow(3), 60) + ]) + }); + }); + search_bar.update_in(cx, |search_bar, window, cx| { + search_bar.select_next_match(&SelectNextMatch, window, cx); + assert_eq!( + editor.update(cx, |editor, cx| editor + .selections + .display_ranges(&editor.display_snapshot(cx))), + [DisplayPoint::new(DisplayRow(0), 41)..DisplayPoint::new(DisplayRow(0), 43)] + ); + }); + search_bar.read_with(cx, |search_bar, _| { + assert_eq!(search_bar.active_match_index, Some(0)); + }); + + // Park the cursor before the first match and ensure that going to the previous match + // wraps around and selects the last match. + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0) + ]) + }); + }); + search_bar.update_in(cx, |search_bar, window, cx| { + assert_eq!(search_bar.active_match_index, Some(0)); + search_bar.select_prev_match(&SelectPreviousMatch, window, cx); + assert_eq!( + editor.update(cx, |editor, cx| editor + .selections + .display_ranges(&editor.display_snapshot(cx))), + [DisplayPoint::new(DisplayRow(3), 56)..DisplayPoint::new(DisplayRow(3), 58)] + ); + }); + search_bar.read_with(cx, |search_bar, _| { + assert_eq!(search_bar.active_match_index, Some(2)); + }); + } + + fn display_points_of( + background_highlights: Vec<(Range, Hsla)>, + ) -> Vec> { + background_highlights + .into_iter() + .map(|(range, _)| range) + .collect::>() + } + + #[perf] + #[gpui::test] + async fn test_search_option_handling(cx: &mut TestAppContext) { + let (editor, search_bar, cx) = init_test(cx); + + // show with options should make current search case sensitive + search_bar + .update_in(cx, |search_bar, window, cx| { + search_bar.show(window, cx); + search_bar.search("us", Some(SearchOptions::CASE_SENSITIVE), true, window, cx) + }) + .await + .unwrap(); + editor.update_in(cx, |editor, window, cx| { + assert_eq!( + display_points_of(editor.all_text_background_highlights(window, cx)), + &[DisplayPoint::new(DisplayRow(2), 43)..DisplayPoint::new(DisplayRow(2), 45),] + ); + }); + + // search_suggested should restore default options + search_bar.update_in(cx, |search_bar, window, cx| { + search_bar.search_suggested(None, window, cx); + assert_eq!(search_bar.search_options, SearchOptions::NONE) + }); + + // toggling a search option should update the defaults + search_bar + .update_in(cx, |search_bar, window, cx| { + search_bar.search( + "regex", + Some(SearchOptions::CASE_SENSITIVE), + true, + window, + cx, + ) + }) + .await + .unwrap(); + search_bar.update_in(cx, |search_bar, window, cx| { + search_bar.toggle_search_option(SearchOptions::WHOLE_WORD, window, cx) + }); + let mut editor_notifications = cx.notifications(&editor); + editor_notifications.next().await; + editor.update_in(cx, |editor, window, cx| { + assert_eq!( + display_points_of(editor.all_text_background_highlights(window, cx)), + &[DisplayPoint::new(DisplayRow(0), 35)..DisplayPoint::new(DisplayRow(0), 40),] + ); + }); + + // defaults should still include whole word + search_bar.update_in(cx, |search_bar, window, cx| { + search_bar.search_suggested(None, window, cx); + assert_eq!( + search_bar.search_options, + SearchOptions::CASE_SENSITIVE | SearchOptions::WHOLE_WORD + ) + }); + } + + #[perf] + #[gpui::test] + async fn test_search_select_all_matches(cx: &mut TestAppContext) { + init_globals(cx); + let buffer_text = r#" + A regular expression (shortened as regex or regexp;[1] also referred to as + rational expression[2][3]) is a sequence of characters that specifies a search + pattern in text. Usually such patterns are used by string-searching algorithms + for "find" or "find and replace" operations on strings, or for input validation. + "# + .unindent(); + let expected_query_matches_count = buffer_text + .chars() + .filter(|c| c.eq_ignore_ascii_case(&'a')) + .count(); + assert!( + expected_query_matches_count > 1, + "Should pick a query with multiple results" + ); + let buffer = cx.new(|cx| Buffer::local(buffer_text, cx)); + let window = cx.add_window(|_, _| gpui::Empty); + + let editor = window.build_entity(cx, |window, cx| { + Editor::for_buffer(buffer.clone(), None, window, cx) + }); + + let search_bar = window.build_entity(cx, |window, cx| { + let mut search_bar = BufferSearchBar::new(None, window, cx); + search_bar.set_active_pane_item(Some(&editor), window, cx); + search_bar.show(window, cx); + search_bar + }); + + window + .update(cx, |_, window, cx| { + search_bar.update(cx, |search_bar, cx| { + search_bar.search("a", None, true, window, cx) + }) + }) + .unwrap() + .await + .unwrap(); + let initial_selections = window + .update(cx, |_, window, cx| { + search_bar.update(cx, |search_bar, cx| { + let handle = search_bar.query_editor.focus_handle(cx); + window.focus(&handle, cx); + search_bar.activate_current_match(window, cx); + }); + assert!( + !editor.read(cx).is_focused(window), + "Initially, the editor should not be focused" + ); + let initial_selections = editor.update(cx, |editor, cx| { + let initial_selections = editor.selections.display_ranges(&editor.display_snapshot(cx)); + assert_eq!( + initial_selections.len(), 1, + "Expected to have only one selection before adding carets to all matches, but got: {initial_selections:?}", + ); + initial_selections + }); + search_bar.update(cx, |search_bar, cx| { + assert_eq!(search_bar.active_match_index, Some(0)); + let handle = search_bar.query_editor.focus_handle(cx); + window.focus(&handle, cx); + search_bar.select_all_matches(&SelectAllMatches, window, cx); + }); + assert!( + editor.read(cx).is_focused(window), + "Should focus editor after successful SelectAllMatches" + ); + search_bar.update(cx, |search_bar, cx| { + let all_selections = + editor.update(cx, |editor, cx| editor.selections.display_ranges(&editor.display_snapshot(cx))); + assert_eq!( + all_selections.len(), + expected_query_matches_count, + "Should select all `a` characters in the buffer, but got: {all_selections:?}" + ); + assert_eq!( + search_bar.active_match_index, + Some(0), + "Match index should not change after selecting all matches" + ); + }); + + search_bar.update(cx, |this, cx| this.select_next_match(&SelectNextMatch, window, cx)); + initial_selections + }).unwrap(); + + window + .update(cx, |_, window, cx| { + assert!( + editor.read(cx).is_focused(window), + "Should still have editor focused after SelectNextMatch" + ); + search_bar.update(cx, |search_bar, cx| { + let all_selections = editor.update(cx, |editor, cx| { + editor + .selections + .display_ranges(&editor.display_snapshot(cx)) + }); + assert_eq!( + all_selections.len(), + 1, + "On next match, should deselect items and select the next match" + ); + assert_ne!( + all_selections, initial_selections, + "Next match should be different from the first selection" + ); + assert_eq!( + search_bar.active_match_index, + Some(1), + "Match index should be updated to the next one" + ); + let handle = search_bar.query_editor.focus_handle(cx); + window.focus(&handle, cx); + search_bar.select_all_matches(&SelectAllMatches, window, cx); + }); + }) + .unwrap(); + window + .update(cx, |_, window, cx| { + assert!( + editor.read(cx).is_focused(window), + "Should focus editor after successful SelectAllMatches" + ); + search_bar.update(cx, |search_bar, cx| { + let all_selections = + editor.update(cx, |editor, cx| editor.selections.display_ranges(&editor.display_snapshot(cx))); + assert_eq!( + all_selections.len(), + expected_query_matches_count, + "Should select all `a` characters in the buffer, but got: {all_selections:?}" + ); + assert_eq!( + search_bar.active_match_index, + Some(1), + "Match index should not change after selecting all matches" + ); + }); + search_bar.update(cx, |search_bar, cx| { + search_bar.select_prev_match(&SelectPreviousMatch, window, cx); + }); + }) + .unwrap(); + let last_match_selections = window + .update(cx, |_, window, cx| { + assert!( + editor.read(cx).is_focused(window), + "Should still have editor focused after SelectPreviousMatch" + ); + + search_bar.update(cx, |search_bar, cx| { + let all_selections = editor.update(cx, |editor, cx| { + editor + .selections + .display_ranges(&editor.display_snapshot(cx)) + }); + assert_eq!( + all_selections.len(), + 1, + "On previous match, should deselect items and select the previous item" + ); + assert_eq!( + all_selections, initial_selections, + "Previous match should be the same as the first selection" + ); + assert_eq!( + search_bar.active_match_index, + Some(0), + "Match index should be updated to the previous one" + ); + all_selections + }) + }) + .unwrap(); + + window + .update(cx, |_, window, cx| { + search_bar.update(cx, |search_bar, cx| { + let handle = search_bar.query_editor.focus_handle(cx); + window.focus(&handle, cx); + search_bar.search("abas_nonexistent_match", None, true, window, cx) + }) + }) + .unwrap() + .await + .unwrap(); + window + .update(cx, |_, window, cx| { + search_bar.update(cx, |search_bar, cx| { + search_bar.select_all_matches(&SelectAllMatches, window, cx); + }); + assert!( + editor.update(cx, |this, _cx| !this.is_focused(window)), + "Should not switch focus to editor if SelectAllMatches does not find any matches" + ); + search_bar.update(cx, |search_bar, cx| { + let all_selections = + editor.update(cx, |editor, cx| editor.selections.display_ranges(&editor.display_snapshot(cx))); + assert_eq!( + all_selections, last_match_selections, + "Should not select anything new if there are no matches" + ); + assert!( + search_bar.active_match_index.is_none(), + "For no matches, there should be no active match index" + ); + }); + }) + .unwrap(); + } + + #[perf] + #[gpui::test] + async fn test_search_query_with_match_whole_word(cx: &mut TestAppContext) { + init_globals(cx); + let buffer_text = r#" + self.buffer.update(cx, |buffer, cx| { + buffer.edit( + edits, + Some(AutoindentMode::Block { + original_indent_columns, + }), + cx, + ) + }); + + this.buffer.update(cx, |buffer, cx| { + buffer.edit([(end_of_line..start_of_next_line, replace)], None, cx) + }); + "# + .unindent(); + let buffer = cx.new(|cx| Buffer::local(buffer_text, cx)); + let cx = cx.add_empty_window(); + + let editor = + cx.new_window_entity(|window, cx| Editor::for_buffer(buffer.clone(), None, window, cx)); + + let search_bar = cx.new_window_entity(|window, cx| { + let mut search_bar = BufferSearchBar::new(None, window, cx); + search_bar.set_active_pane_item(Some(&editor), window, cx); + search_bar.show(window, cx); + search_bar + }); + + search_bar + .update_in(cx, |search_bar, window, cx| { + search_bar.search( + "edit\\(", + Some(SearchOptions::WHOLE_WORD | SearchOptions::REGEX), + true, + window, + cx, + ) + }) + .await + .unwrap(); + + search_bar.update_in(cx, |search_bar, window, cx| { + search_bar.select_all_matches(&SelectAllMatches, window, cx); + }); + search_bar.update(cx, |_, cx| { + let all_selections = editor.update(cx, |editor, cx| { + editor + .selections + .display_ranges(&editor.display_snapshot(cx)) + }); + assert_eq!( + all_selections.len(), + 2, + "Should select all `edit(` in the buffer, but got: {all_selections:?}" + ); + }); + + search_bar + .update_in(cx, |search_bar, window, cx| { + search_bar.search( + "edit(", + Some(SearchOptions::WHOLE_WORD | SearchOptions::CASE_SENSITIVE), + true, + window, + cx, + ) + }) + .await + .unwrap(); + + search_bar.update_in(cx, |search_bar, window, cx| { + search_bar.select_all_matches(&SelectAllMatches, window, cx); + }); + search_bar.update(cx, |_, cx| { + let all_selections = editor.update(cx, |editor, cx| { + editor + .selections + .display_ranges(&editor.display_snapshot(cx)) + }); + assert_eq!( + all_selections.len(), + 2, + "Should select all `edit(` in the buffer, but got: {all_selections:?}" + ); + }); + } + + #[perf] + #[gpui::test] + async fn test_search_query_history(cx: &mut TestAppContext) { + let (_editor, search_bar, cx) = init_test(cx); + + // Add 3 search items into the history. + search_bar + .update_in(cx, |search_bar, window, cx| { + search_bar.search("a", None, true, window, cx) + }) + .await + .unwrap(); + search_bar + .update_in(cx, |search_bar, window, cx| { + search_bar.search("b", None, true, window, cx) + }) + .await + .unwrap(); + search_bar + .update_in(cx, |search_bar, window, cx| { + search_bar.search("c", Some(SearchOptions::CASE_SENSITIVE), true, window, cx) + }) + .await + .unwrap(); + // Ensure that the latest search is active. + search_bar.update(cx, |search_bar, cx| { + assert_eq!(search_bar.query(cx), "c"); + assert_eq!(search_bar.search_options, SearchOptions::CASE_SENSITIVE); + }); + + // Next history query after the latest should preserve the current query. + search_bar.update_in(cx, |search_bar, window, cx| { + search_bar.next_history_query(&NextHistoryQuery, window, cx); + }); + cx.background_executor.run_until_parked(); + search_bar.update(cx, |search_bar, cx| { + assert_eq!(search_bar.query(cx), "c"); + assert_eq!(search_bar.search_options, SearchOptions::CASE_SENSITIVE); + }); + search_bar.update_in(cx, |search_bar, window, cx| { + search_bar.next_history_query(&NextHistoryQuery, window, cx); + }); + cx.background_executor.run_until_parked(); + search_bar.update(cx, |search_bar, cx| { + assert_eq!(search_bar.query(cx), "c"); + assert_eq!(search_bar.search_options, SearchOptions::CASE_SENSITIVE); + }); + + // Previous query should navigate backwards through history. + search_bar.update_in(cx, |search_bar, window, cx| { + search_bar.previous_history_query(&PreviousHistoryQuery, window, cx); + }); + cx.background_executor.run_until_parked(); + search_bar.update(cx, |search_bar, cx| { + assert_eq!(search_bar.query(cx), "b"); + assert_eq!(search_bar.search_options, SearchOptions::CASE_SENSITIVE); + }); + + // Further previous items should go over the history in reverse order. + search_bar.update_in(cx, |search_bar, window, cx| { + search_bar.previous_history_query(&PreviousHistoryQuery, window, cx); + }); + cx.background_executor.run_until_parked(); + search_bar.update(cx, |search_bar, cx| { + assert_eq!(search_bar.query(cx), "a"); + assert_eq!(search_bar.search_options, SearchOptions::CASE_SENSITIVE); + }); + + // Previous items should never go behind the first history item. + search_bar.update_in(cx, |search_bar, window, cx| { + search_bar.previous_history_query(&PreviousHistoryQuery, window, cx); + }); + cx.background_executor.run_until_parked(); + search_bar.update(cx, |search_bar, cx| { + assert_eq!(search_bar.query(cx), "a"); + assert_eq!(search_bar.search_options, SearchOptions::CASE_SENSITIVE); + }); + search_bar.update_in(cx, |search_bar, window, cx| { + search_bar.previous_history_query(&PreviousHistoryQuery, window, cx); + }); + cx.background_executor.run_until_parked(); + search_bar.update(cx, |search_bar, cx| { + assert_eq!(search_bar.query(cx), "a"); + assert_eq!(search_bar.search_options, SearchOptions::CASE_SENSITIVE); + }); + + // Next items should go over the history in the original order. + search_bar.update_in(cx, |search_bar, window, cx| { + search_bar.next_history_query(&NextHistoryQuery, window, cx); + }); + cx.background_executor.run_until_parked(); + search_bar.update(cx, |search_bar, cx| { + assert_eq!(search_bar.query(cx), "b"); + assert_eq!(search_bar.search_options, SearchOptions::CASE_SENSITIVE); + }); + + search_bar + .update_in(cx, |search_bar, window, cx| { + search_bar.search("ba", None, true, window, cx) + }) + .await + .unwrap(); + search_bar.update(cx, |search_bar, cx| { + assert_eq!(search_bar.query(cx), "ba"); + assert_eq!(search_bar.search_options, SearchOptions::NONE); + }); + + // New search input should add another entry to history and move the selection to the end of the history. + search_bar.update_in(cx, |search_bar, window, cx| { + search_bar.previous_history_query(&PreviousHistoryQuery, window, cx); + }); + cx.background_executor.run_until_parked(); + search_bar.update(cx, |search_bar, cx| { + assert_eq!(search_bar.query(cx), "c"); + assert_eq!(search_bar.search_options, SearchOptions::NONE); + }); + search_bar.update_in(cx, |search_bar, window, cx| { + search_bar.previous_history_query(&PreviousHistoryQuery, window, cx); + }); + cx.background_executor.run_until_parked(); + search_bar.update(cx, |search_bar, cx| { + assert_eq!(search_bar.query(cx), "b"); + assert_eq!(search_bar.search_options, SearchOptions::NONE); + }); + search_bar.update_in(cx, |search_bar, window, cx| { + search_bar.next_history_query(&NextHistoryQuery, window, cx); + }); + cx.background_executor.run_until_parked(); + search_bar.update(cx, |search_bar, cx| { + assert_eq!(search_bar.query(cx), "c"); + assert_eq!(search_bar.search_options, SearchOptions::NONE); + }); + search_bar.update_in(cx, |search_bar, window, cx| { + search_bar.next_history_query(&NextHistoryQuery, window, cx); + }); + cx.background_executor.run_until_parked(); + search_bar.update(cx, |search_bar, cx| { + assert_eq!(search_bar.query(cx), "ba"); + assert_eq!(search_bar.search_options, SearchOptions::NONE); + }); + search_bar.update_in(cx, |search_bar, window, cx| { + search_bar.next_history_query(&NextHistoryQuery, window, cx); + }); + cx.background_executor.run_until_parked(); + search_bar.update(cx, |search_bar, cx| { + assert_eq!(search_bar.query(cx), "ba"); + assert_eq!(search_bar.search_options, SearchOptions::NONE); + }); + } + + #[perf] + #[gpui::test] + async fn test_search_query_history_autoscroll(cx: &mut TestAppContext) { + let (_editor, search_bar, cx) = init_test(cx); + + // Add a long multi-line query that exceeds the editor's max + // visible height (4 lines), then a short query. + let long_query = "line1\nline2\nline3\nline4\nline5\nline6"; + search_bar + .update_in(cx, |search_bar, window, cx| { + search_bar.search(long_query, None, true, window, cx) + }) + .await + .unwrap(); + search_bar + .update_in(cx, |search_bar, window, cx| { + search_bar.search("short", None, true, window, cx) + }) + .await + .unwrap(); + + // Navigate back to the long entry. Since "short" is single-line, + // the history navigation is allowed. + search_bar.update_in(cx, |search_bar, window, cx| { + search_bar.previous_history_query(&PreviousHistoryQuery, window, cx); + }); + cx.background_executor.run_until_parked(); + search_bar.update(cx, |search_bar, cx| { + assert_eq!(search_bar.query(cx), long_query); + }); + + // The cursor should be scrolled into view despite the content + // exceeding the editor's max visible height. + search_bar.update_in(cx, |search_bar, window, cx| { + let snapshot = search_bar + .query_editor + .update(cx, |editor, cx| editor.snapshot(window, cx)); + let cursor_row = search_bar + .query_editor + .read(cx) + .selections + .newest_display(&snapshot) + .head() + .row(); + let scroll_top = search_bar + .query_editor + .update(cx, |editor, cx| editor.scroll_position(cx).y); + let visible_lines = search_bar + .query_editor + .read(cx) + .visible_line_count() + .unwrap_or(0.0); + let scroll_bottom = scroll_top + visible_lines; + assert!( + (cursor_row.0 as f64) < scroll_bottom, + "cursor row {cursor_row:?} should be visible (scroll range {scroll_top}..{scroll_bottom})" + ); + }); + } + + #[perf] + #[gpui::test] + async fn test_replace_simple(cx: &mut TestAppContext) { + let (editor, search_bar, cx) = init_test(cx); + + search_bar + .update_in(cx, |search_bar, window, cx| { + search_bar.search("expression", None, true, window, cx) + }) + .await + .unwrap(); + + search_bar.update_in(cx, |search_bar, window, cx| { + search_bar.replacement_editor.update(cx, |editor, cx| { + // We use $1 here as initially we should be in Text mode, where `$1` should be treated literally. + editor.set_text("expr$1", window, cx); + }); + search_bar.replace_all(&ReplaceAll, window, cx) + }); + assert_eq!( + editor.read_with(cx, |this, cx| { this.text(cx) }), + r#" + A regular expr$1 (shortened as regex or regexp;[1] also referred to as + rational expr$1[2][3]) is a sequence of characters that specifies a search + pattern in text. Usually such patterns are used by string-searching algorithms + for "find" or "find and replace" operations on strings, or for input validation. + "# + .unindent() + ); + + // Search for word boundaries and replace just a single one. + search_bar + .update_in(cx, |search_bar, window, cx| { + search_bar.search("or", Some(SearchOptions::WHOLE_WORD), true, window, cx) + }) + .await + .unwrap(); + + search_bar.update_in(cx, |search_bar, window, cx| { + search_bar.replacement_editor.update(cx, |editor, cx| { + editor.set_text("banana", window, cx); + }); + search_bar.replace_next(&ReplaceNext, window, cx) + }); + // Notice how the first or in the text (shORtened) is not replaced. Neither are the remaining hits of `or` in the text. + assert_eq!( + editor.read_with(cx, |this, cx| { this.text(cx) }), + r#" + A regular expr$1 (shortened as regex banana regexp;[1] also referred to as + rational expr$1[2][3]) is a sequence of characters that specifies a search + pattern in text. Usually such patterns are used by string-searching algorithms + for "find" or "find and replace" operations on strings, or for input validation. + "# + .unindent() + ); + // Let's turn on regex mode. + search_bar + .update_in(cx, |search_bar, window, cx| { + search_bar.search( + "\\[([^\\]]+)\\]", + Some(SearchOptions::REGEX), + true, + window, + cx, + ) + }) + .await + .unwrap(); + search_bar.update_in(cx, |search_bar, window, cx| { + search_bar.replacement_editor.update(cx, |editor, cx| { + editor.set_text("${1}number", window, cx); + }); + search_bar.replace_all(&ReplaceAll, window, cx) + }); + assert_eq!( + editor.read_with(cx, |this, cx| { this.text(cx) }), + r#" + A regular expr$1 (shortened as regex banana regexp;1number also referred to as + rational expr$12number3number) is a sequence of characters that specifies a search + pattern in text. Usually such patterns are used by string-searching algorithms + for "find" or "find and replace" operations on strings, or for input validation. + "# + .unindent() + ); + // Now with a whole-word twist. + search_bar + .update_in(cx, |search_bar, window, cx| { + search_bar.search( + "a\\w+s", + Some(SearchOptions::REGEX | SearchOptions::WHOLE_WORD), + true, + window, + cx, + ) + }) + .await + .unwrap(); + search_bar.update_in(cx, |search_bar, window, cx| { + search_bar.replacement_editor.update(cx, |editor, cx| { + editor.set_text("things", window, cx); + }); + search_bar.replace_all(&ReplaceAll, window, cx) + }); + // The only word affected by this edit should be `algorithms`, even though there's a bunch + // of words in this text that would match this regex if not for WHOLE_WORD. + assert_eq!( + editor.read_with(cx, |this, cx| { this.text(cx) }), + r#" + A regular expr$1 (shortened as regex banana regexp;1number also referred to as + rational expr$12number3number) is a sequence of characters that specifies a search + pattern in text. Usually such patterns are used by string-searching things + for "find" or "find and replace" operations on strings, or for input validation. + "# + .unindent() + ); + } + + #[gpui::test] + async fn test_replace_focus(cx: &mut TestAppContext) { + let (editor, search_bar, cx) = init_test(cx); + + editor.update_in(cx, |editor, window, cx| { + editor.set_text("What a bad day!", window, cx) + }); + + search_bar + .update_in(cx, |search_bar, window, cx| { + search_bar.search("bad", None, true, window, cx) + }) + .await + .unwrap(); + + // Calling `toggle_replace` in the search bar ensures that the "Replace + // *" buttons are rendered, so we can then simulate clicking the + // buttons. + search_bar.update_in(cx, |search_bar, window, cx| { + search_bar.toggle_replace(&ToggleReplace, window, cx) + }); + + search_bar.update_in(cx, |search_bar, window, cx| { + search_bar.replacement_editor.update(cx, |editor, cx| { + editor.set_text("great", window, cx); + }); + }); + + // Focus on the editor instead of the search bar, as we want to ensure + // that pressing the "Replace Next Match" button will work, even if the + // search bar is not focused. + cx.focus(&editor); + + // We'll not simulate clicking the "Replace Next Match " button, asserting that + // the replacement was done. + let button_bounds = cx + .debug_bounds("ICON-ReplaceNext") + .expect("'Replace Next Match' button should be visible"); + cx.simulate_click(button_bounds.center(), gpui::Modifiers::none()); + + assert_eq!( + editor.read_with(cx, |editor, cx| editor.text(cx)), + "What a great day!" + ); + } + + struct ReplacementTestParams<'a> { + editor: &'a Entity, + search_bar: &'a Entity, + cx: &'a mut VisualTestContext, + search_text: &'static str, + search_options: Option, + replacement_text: &'static str, + replace_all: bool, + expected_text: String, + } + + async fn run_replacement_test(options: ReplacementTestParams<'_>) { + options + .search_bar + .update_in(options.cx, |search_bar, window, cx| { + if let Some(options) = options.search_options { + search_bar.set_search_options(options, cx); + } + search_bar.search( + options.search_text, + options.search_options, + true, + window, + cx, + ) + }) + .await + .unwrap(); + + options + .search_bar + .update_in(options.cx, |search_bar, window, cx| { + search_bar.replacement_editor.update(cx, |editor, cx| { + editor.set_text(options.replacement_text, window, cx); + }); + + if options.replace_all { + search_bar.replace_all(&ReplaceAll, window, cx) + } else { + search_bar.replace_next(&ReplaceNext, window, cx) + } + }); + + assert_eq!( + options + .editor + .read_with(options.cx, |this, cx| { this.text(cx) }), + options.expected_text + ); + } + + #[perf] + #[gpui::test] + async fn test_replace_special_characters(cx: &mut TestAppContext) { + let (editor, search_bar, cx) = init_test(cx); + + run_replacement_test(ReplacementTestParams { + editor: &editor, + search_bar: &search_bar, + cx, + search_text: "expression", + search_options: None, + replacement_text: r"\n", + replace_all: true, + expected_text: r#" + A regular \n (shortened as regex or regexp;[1] also referred to as + rational \n[2][3]) is a sequence of characters that specifies a search + pattern in text. Usually such patterns are used by string-searching algorithms + for "find" or "find and replace" operations on strings, or for input validation. + "# + .unindent(), + }) + .await; + + run_replacement_test(ReplacementTestParams { + editor: &editor, + search_bar: &search_bar, + cx, + search_text: "or", + search_options: Some(SearchOptions::WHOLE_WORD | SearchOptions::REGEX), + replacement_text: r"\\\n\\\\", + replace_all: false, + expected_text: r#" + A regular \n (shortened as regex \ + \\ regexp;[1] also referred to as + rational \n[2][3]) is a sequence of characters that specifies a search + pattern in text. Usually such patterns are used by string-searching algorithms + for "find" or "find and replace" operations on strings, or for input validation. + "# + .unindent(), + }) + .await; + + run_replacement_test(ReplacementTestParams { + editor: &editor, + search_bar: &search_bar, + cx, + search_text: r"(that|used) ", + search_options: Some(SearchOptions::REGEX), + replacement_text: r"$1\n", + replace_all: true, + expected_text: r#" + A regular \n (shortened as regex \ + \\ regexp;[1] also referred to as + rational \n[2][3]) is a sequence of characters that + specifies a search + pattern in text. Usually such patterns are used + by string-searching algorithms + for "find" or "find and replace" operations on strings, or for input validation. + "# + .unindent(), + }) + .await; + } + + #[gpui::test] + async fn test_replace_with_lookaround(cx: &mut TestAppContext) { + let (editor, search_bar, cx) = init_test(cx); + + editor.update_in(cx, |editor, window, cx| { + editor.set_text("316227766016837933199\n", window, cx) + }); + + run_replacement_test(ReplacementTestParams { + editor: &editor, + search_bar: &search_bar, + cx, + search_text: r"(\d)(?=(\d{4})+$)", + search_options: Some(SearchOptions::REGEX), + replacement_text: "$1,", + replace_all: true, + expected_text: "3,1622,7766,0168,3793,3199\n".to_string(), + }) + .await; + + editor.update_in(cx, |editor, window, cx| { + editor.set_text("Xfoo\nbar\n", window, cx) + }); + + run_replacement_test(ReplacementTestParams { + editor: &editor, + search_bar: &search_bar, + cx, + search_text: r"foo\n(?<=Xfoo\n)bar", + search_options: Some(SearchOptions::REGEX), + replacement_text: "BAZ", + replace_all: true, + expected_text: "Xfoo\nbar\n".to_string(), + }) + .await; + + editor.update_in(cx, |editor, window, cx| { + editor.set_text("food: bar\nfoo: bar\n", window, cx) + }); + + run_replacement_test(ReplacementTestParams { + editor: &editor, + search_bar: &search_bar, + cx, + search_text: r"(?<=foo: )bar", + search_options: Some(SearchOptions::REGEX), + replacement_text: "BAZ", + replace_all: false, + expected_text: "food: bar\nfoo: BAZ\n".to_string(), + }) + .await; + } + + #[gpui::test] + async fn test_replace_with_lookaround_in_multibuffer(cx: &mut TestAppContext) { + let (editor, search_bar, cx) = init_multibuffer_test(cx); + + run_replacement_test(ReplacementTestParams { + editor: &editor, + search_bar: &search_bar, + cx, + search_text: r"\w+(?= expression)", + search_options: Some(SearchOptions::REGEX), + replacement_text: "SOME", + replace_all: true, + expected_text: r#" + A SOME expression (shortened as regex or regexp;[1] also referred to as + SOME expression[2][3]) is a sequence of characters that specifies a search + pattern in text. Usually such patterns are used by string-searching algorithms + for "find" or "find and replace" operations on strings, or for input validation. + Some Additional text with the term SOME expression in it. + There two lines."# + .unindent(), + }) + .await; + } + + #[gpui::test] + async fn test_deploy_replace_focuses_replacement_editor(cx: &mut TestAppContext) { + init_globals(cx); + let (editor, search_bar, cx) = init_test(cx); + + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(0), 8)..DisplayPoint::new(DisplayRow(0), 16) + ]) + }); + }); + + search_bar.update_in(cx, |search_bar, window, cx| { + search_bar.deploy( + &Deploy { + focus: true, + replace_enabled: true, + selection_search_enabled: false, + }, + None, + window, + cx, + ); + }); + cx.run_until_parked(); + + search_bar.update_in(cx, |search_bar, window, cx| { + assert!( + search_bar + .replacement_editor + .focus_handle(cx) + .is_focused(window), + "replacement editor should be focused when deploying replace with a selection", + ); + assert!( + !search_bar.query_editor.focus_handle(cx).is_focused(window), + "search editor should not be focused when replacement editor is focused", + ); + }); + } + + #[gpui::test] + async fn test_toggle_soft_wrap_relays_to_searched_editor(cx: &mut TestAppContext) { + init_globals(cx); + let (editor, search_bar, cx) = init_test(cx); + + search_bar.update_in(cx, |search_bar, window, cx| { + search_bar.deploy( + &Deploy { + focus: true, + replace_enabled: false, + selection_search_enabled: false, + }, + None, + window, + cx, + ); + }); + cx.run_until_parked(); + + search_bar.update_in(cx, |search_bar, window, cx| { + assert!( + search_bar.query_editor.focus_handle(cx).is_focused(window), + "query editor should be focused after deploying the search bar", + ); + }); + editor.update_in(cx, |editor, _, cx| { + assert!( + matches!(editor.soft_wrap_mode(cx), SoftWrap::None), + "soft wrap should be disabled initially", + ); + }); + + cx.dispatch_action(ToggleSoftWrap); + cx.run_until_parked(); + + editor.update_in(cx, |editor, _, cx| { + assert!( + matches!(editor.soft_wrap_mode(cx), SoftWrap::EditorWidth), + "toggling soft wrap while the search bar is focused should affect the searched editor", + ); + }); + search_bar.update_in(cx, |search_bar, window, cx| { + assert!( + search_bar.query_editor.focus_handle(cx).is_focused(window), + "focus should stay in the search bar", + ); + }); + } + + #[cfg(target_os = "macos")] + #[gpui::test] + async fn test_cmd_e_then_cmd_g_uses_selection_for_find(cx: &mut TestAppContext) { + init_globals(cx); + let app_state = cx.update(AppState::test); + let project = Project::test(app_state.fs.clone(), [], cx).await; + let buffer = cx.new(|cx| { + Buffer::local( + r#" + dad + cat + mom + dog + dog + cat + dad + mom + "# + .unindent(), + cx, + ) + }); + let multibuffer = cx.update(|cx| MultiBuffer::build_from_buffer(buffer, cx)); + let mut editor = None; + let mut search_bar = None; + + let window = cx.add_window(|window, cx| { + let default_key_bindings = settings::KeymapFile::load_asset_allow_partial_failure( + "keymaps/default-macos.json", + cx, + ) + .unwrap(); + cx.bind_keys(default_key_bindings); + let workspace = cx.new(|cx| Workspace::test_new(project.clone(), window, cx)); + let multi_workspace = MultiWorkspace::new(workspace.clone(), window, cx); + let buffer_search_bar = cx.new(|cx| BufferSearchBar::new(None, window, cx)); + workspace.update(cx, |workspace, cx| { + workspace.active_pane().update(cx, |pane, cx| { + pane.toolbar().update(cx, |toolbar, cx| { + toolbar.add_item(buffer_search_bar.clone(), window, cx); + }); + }); + }); + let editor_handle = cx.new(|cx| { + Editor::new( + editor::EditorMode::full(), + multibuffer.clone(), + Some(project.clone()), + window, + cx, + ) + }); + workspace.update(cx, |workspace, cx| { + workspace.add_item_to_center(Box::new(editor_handle.clone()), window, cx); + }); + window.focus(&editor_handle.focus_handle(cx), cx); + search_bar = Some(buffer_search_bar); + editor = Some(editor_handle); + multi_workspace + }); + let cx = VisualTestContext::from_window(*window, cx).into_mut(); + let editor = editor.unwrap(); + let search_bar = search_bar.unwrap(); + + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { + selections.select_display_ranges([ + DisplayPoint::new(DisplayRow(3), 1)..DisplayPoint::new(DisplayRow(3), 1) + ]); + }); + }); + + cx.simulate_keystrokes("cmd-e"); + + search_bar.read_with(cx, |search_bar, cx| { + assert_eq!(search_bar.query(cx), "dog"); + assert_eq!(search_bar.active_match_index, Some(0)); + }); + cx.read(|cx| { + assert_eq!( + cx.read_from_find_pasteboard().and_then(|item| item.text()), + Some("dog".to_string()) + ); + }); + + cx.simulate_keystrokes("cmd-g"); + assert_eq!( + editor.update(cx, |editor, cx| editor + .selections + .display_ranges(&editor.display_snapshot(cx))), + [DisplayPoint::new(DisplayRow(4), 0)..DisplayPoint::new(DisplayRow(4), 3)] + ); + + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { + selections.select_display_ranges([ + DisplayPoint::new(DisplayRow(1), 1)..DisplayPoint::new(DisplayRow(1), 1) + ]); + }); + }); + + cx.simulate_keystrokes("cmd-e"); + + search_bar.read_with(cx, |search_bar, cx| { + assert_eq!(search_bar.query(cx), "cat"); + assert_eq!(search_bar.active_match_index, Some(0)); + }); + cx.read(|cx| { + assert_eq!( + cx.read_from_find_pasteboard().and_then(|item| item.text()), + Some("cat".to_string()) + ); + }); + + cx.simulate_keystrokes("cmd-g"); + assert_eq!( + editor.update(cx, |editor, cx| editor + .selections + .display_ranges(&editor.display_snapshot(cx))), + [DisplayPoint::new(DisplayRow(5), 0)..DisplayPoint::new(DisplayRow(5), 3)] + ); + } + + #[perf] + #[gpui::test] + async fn test_find_matches_in_selections_singleton_buffer_multiple_selections( + cx: &mut TestAppContext, + ) { + init_globals(cx); + let buffer = cx.new(|cx| { + Buffer::local( + r#" + aaa bbb aaa ccc + aaa bbb aaa ccc + aaa bbb aaa ccc + aaa bbb aaa ccc + aaa bbb aaa ccc + aaa bbb aaa ccc + "# + .unindent(), + cx, + ) + }); + let cx = cx.add_empty_window(); + let editor = + cx.new_window_entity(|window, cx| Editor::for_buffer(buffer.clone(), None, window, cx)); + + let search_bar = cx.new_window_entity(|window, cx| { + let mut search_bar = BufferSearchBar::new(None, window, cx); + search_bar.set_active_pane_item(Some(&editor), window, cx); + search_bar.show(window, cx); + search_bar + }); + + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges(vec![Point::new(1, 0)..Point::new(2, 4)]) + }) + }); + + search_bar.update_in(cx, |search_bar, window, cx| { + let deploy = Deploy { + focus: true, + replace_enabled: false, + selection_search_enabled: true, + }; + search_bar.deploy(&deploy, None, window, cx); + }); + + cx.run_until_parked(); + + search_bar + .update_in(cx, |search_bar, window, cx| { + search_bar.search("aaa", None, true, window, cx) + }) + .await + .unwrap(); + + editor.update(cx, |editor, cx| { + assert_eq!( + editor.search_background_highlights(cx), + &[ + Point::new(1, 0)..Point::new(1, 3), + Point::new(1, 8)..Point::new(1, 11), + Point::new(2, 0)..Point::new(2, 3), + ] + ); + }); + } + + #[perf] + #[gpui::test] + async fn test_find_matches_in_selections_multiple_excerpts_buffer_multiple_selections( + cx: &mut TestAppContext, + ) { + init_globals(cx); + let text = r#" + aaa bbb aaa ccc + aaa bbb aaa ccc + aaa bbb aaa ccc + aaa bbb aaa ccc + aaa bbb aaa ccc + aaa bbb aaa ccc + + aaa bbb aaa ccc + aaa bbb aaa ccc + aaa bbb aaa ccc + aaa bbb aaa ccc + aaa bbb aaa ccc + aaa bbb aaa ccc + "# + .unindent(); + + let cx = cx.add_empty_window(); + let editor = cx.new_window_entity(|window, cx| { + let multibuffer = MultiBuffer::build_multi( + [ + ( + &text, + vec![ + Point::new(0, 0)..Point::new(2, 0), + Point::new(4, 0)..Point::new(5, 0), + ], + ), + (&text, vec![Point::new(9, 0)..Point::new(11, 0)]), + ], + cx, + ); + Editor::for_multibuffer(multibuffer, None, window, cx) + }); + + let search_bar = cx.new_window_entity(|window, cx| { + let mut search_bar = BufferSearchBar::new(None, window, cx); + search_bar.set_active_pane_item(Some(&editor), window, cx); + search_bar.show(window, cx); + search_bar + }); + + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges(vec![ + Point::new(1, 0)..Point::new(1, 4), + Point::new(5, 3)..Point::new(6, 4), + ]) + }) + }); + + search_bar.update_in(cx, |search_bar, window, cx| { + let deploy = Deploy { + focus: true, + replace_enabled: false, + selection_search_enabled: true, + }; + search_bar.deploy(&deploy, None, window, cx); + }); + + cx.run_until_parked(); + + search_bar + .update_in(cx, |search_bar, window, cx| { + search_bar.search("aaa", None, true, window, cx) + }) + .await + .unwrap(); + + editor.update(cx, |editor, cx| { + assert_eq!( + editor.search_background_highlights(cx), + &[ + Point::new(1, 0)..Point::new(1, 3), + Point::new(5, 8)..Point::new(5, 11), + Point::new(6, 0)..Point::new(6, 3), + ] + ); + }); + } + + #[perf] + #[gpui::test] + async fn test_hides_and_uses_secondary_when_in_singleton_buffer(cx: &mut TestAppContext) { + let (editor, search_bar, cx) = init_test(cx); + + let initial_location = search_bar.update_in(cx, |search_bar, window, cx| { + search_bar.set_active_pane_item(Some(&editor), window, cx) + }); + + assert_eq!(initial_location, ToolbarItemLocation::Secondary); + + let mut events = cx.events::(&search_bar); + + search_bar.update_in(cx, |search_bar, window, cx| { + search_bar.dismiss(&Dismiss, window, cx); + }); + + assert_eq!( + events.try_recv().unwrap(), + (ToolbarItemEvent::ChangeLocation(ToolbarItemLocation::Hidden)) + ); + + search_bar.update_in(cx, |search_bar, window, cx| { + search_bar.show(window, cx); + }); + + assert_eq!( + events.try_recv().unwrap(), + (ToolbarItemEvent::ChangeLocation(ToolbarItemLocation::Secondary)) + ); + } + + #[perf] + #[gpui::test] + async fn test_uses_primary_left_when_in_multi_buffer(cx: &mut TestAppContext) { + let (editor, search_bar, cx) = init_multibuffer_test(cx); + + let initial_location = search_bar.update_in(cx, |search_bar, window, cx| { + search_bar.set_active_pane_item(Some(&editor), window, cx) + }); + + assert_eq!(initial_location, ToolbarItemLocation::PrimaryLeft); + + let mut events = cx.events::(&search_bar); + + search_bar.update_in(cx, |search_bar, window, cx| { + search_bar.dismiss(&Dismiss, window, cx); + }); + + assert_eq!( + events.try_recv().unwrap(), + (ToolbarItemEvent::ChangeLocation(ToolbarItemLocation::PrimaryLeft)) + ); + + search_bar.update_in(cx, |search_bar, window, cx| { + search_bar.show(window, cx); + }); + + assert_eq!( + events.try_recv().unwrap(), + (ToolbarItemEvent::ChangeLocation(ToolbarItemLocation::PrimaryLeft)) + ); + } + + #[perf] + #[gpui::test] + async fn test_no_expand_collapse_option_when_item_is_not_buffer_backed( + cx: &mut TestAppContext, + ) { + // Items that are backed by neither a singleton buffer nor a + // multibuffer, for example, the LSP log view, report + // `ItemBufferKind::None` and must not show the expand/collapse all + // files button. + + // A searchable item that reports `ItemBufferKind::None`, like the LSP + // log view. + struct NonBufferSearchableItem { + focus_handle: FocusHandle, + } + + impl EventEmitter<()> for NonBufferSearchableItem {} + impl EventEmitter for NonBufferSearchableItem {} + + impl Focusable for NonBufferSearchableItem { + fn focus_handle(&self, _: &App) -> FocusHandle { + self.focus_handle.clone() + } + } + + impl Render for NonBufferSearchableItem { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + } + } + + impl Item for NonBufferSearchableItem { + type Event = (); + + fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString { + "Non-buffer item".into() + } + + fn as_searchable( + &self, + handle: &Entity, + _: &App, + ) -> Option> { + Some(Box::new(handle.clone())) + } + + fn buffer_kind(&self, _: &App) -> ItemBufferKind { + ItemBufferKind::None + } + } + + impl SearchableItem for NonBufferSearchableItem { + type Match = (); + + fn clear_matches(&mut self, _: &mut Window, _: &mut Context) {} + fn update_matches( + &mut self, + _: &[Self::Match], + _: Option, + _: SearchToken, + _: &mut Window, + _: &mut Context, + ) { + } + fn query_suggestion( + &mut self, + _: Option, + _: &mut Window, + _: &mut Context, + ) -> String { + String::new() + } + fn activate_match( + &mut self, + _: usize, + _: &[Self::Match], + _: SearchToken, + _: &mut Window, + _: &mut Context, + ) { + } + fn select_matches( + &mut self, + _: &[Self::Match], + _: SearchToken, + _: &mut Window, + _: &mut Context, + ) { + } + fn replace( + &mut self, + _: &Self::Match, + _: &SearchQuery, + _: SearchToken, + _: &mut Window, + _: &mut Context, + ) { + } + fn find_matches( + &mut self, + _: Arc, + _: &mut Window, + _: &mut Context, + ) -> Task> { + Task::ready(Vec::new()) + } + fn active_match_index( + &mut self, + _: Direction, + _: &[Self::Match], + _: SearchToken, + _: &mut Window, + _: &mut Context, + ) -> Option { + None + } + } + + init_globals(cx); + let window = cx.add_window(|window, cx| { + let item = cx.new(|cx| NonBufferSearchableItem { + focus_handle: cx.focus_handle(), + }); + let mut search_bar = BufferSearchBar::new(None, window, cx); + search_bar.set_active_pane_item(Some(&item), window, cx); + search_bar + }); + let search_bar = window.root(cx).unwrap(); + let cx = VisualTestContext::from_window(*window, cx).into_mut(); + + search_bar.read_with(cx, |search_bar, cx| { + assert!( + !search_bar.needs_expand_collapse_option(cx), + "Items reporting ItemBufferKind::None must not get the expand/collapse button" + ); + }); + } + + #[perf] + #[gpui::test] + async fn test_hides_and_uses_secondary_when_part_of_project_search(cx: &mut TestAppContext) { + let (editor, search_bar, cx) = init_multibuffer_test(cx); + + editor.update(cx, |editor, _| { + editor.set_in_project_search(true); + }); + + let initial_location = search_bar.update_in(cx, |search_bar, window, cx| { + search_bar.set_active_pane_item(Some(&editor), window, cx) + }); + + assert_eq!(initial_location, ToolbarItemLocation::Hidden); + + let mut events = cx.events::(&search_bar); + + search_bar.update_in(cx, |search_bar, window, cx| { + search_bar.dismiss(&Dismiss, window, cx); + }); + + assert_eq!( + events.try_recv().unwrap(), + (ToolbarItemEvent::ChangeLocation(ToolbarItemLocation::Hidden)) + ); + + search_bar.update_in(cx, |search_bar, window, cx| { + search_bar.show(window, cx); + }); + + assert_eq!( + events.try_recv().unwrap(), + (ToolbarItemEvent::ChangeLocation(ToolbarItemLocation::Secondary)) + ); + } + + #[perf] + #[gpui::test] + async fn test_sets_collapsed_when_editor_fold_events_emitted(cx: &mut TestAppContext) { + let (editor, search_bar, cx) = init_multibuffer_test(cx); + + search_bar.update_in(cx, |search_bar, window, cx| { + search_bar.set_active_pane_item(Some(&editor), window, cx); + }); + + editor.update_in(cx, |editor, window, cx| { + editor.fold_all(&FoldAll, window, cx); + }); + cx.run_until_parked(); + + let is_collapsed = editor.read_with(cx, |editor, cx| editor.has_any_buffer_folded(cx)); + assert!(is_collapsed); + + editor.update_in(cx, |editor, window, cx| { + editor.unfold_all(&UnfoldAll, window, cx); + }); + cx.run_until_parked(); + + let is_collapsed = editor.read_with(cx, |editor, cx| editor.has_any_buffer_folded(cx)); + assert!(!is_collapsed); + } + + #[perf] + #[gpui::test] + async fn test_collapse_state_syncs_after_manual_buffer_fold(cx: &mut TestAppContext) { + let (editor, search_bar, cx) = init_multibuffer_test(cx); + + search_bar.update_in(cx, |search_bar, window, cx| { + search_bar.set_active_pane_item(Some(&editor), window, cx); + }); + + // Fold all buffers via fold_all + editor.update_in(cx, |editor, window, cx| { + editor.fold_all(&FoldAll, window, cx); + }); + cx.run_until_parked(); + + let has_any_folded = editor.read_with(cx, |editor, cx| editor.has_any_buffer_folded(cx)); + assert!( + has_any_folded, + "All buffers should be folded after fold_all" + ); + + // Manually unfold one buffer (simulating a chevron click) + let first_buffer_id = editor.read_with(cx, |editor, cx| { + editor + .buffer() + .read(cx) + .snapshot(cx) + .excerpts() + .nth(0) + .unwrap() + .context + .start + .buffer_id + }); + editor.update_in(cx, |editor, _window, cx| { + editor.unfold_buffer(first_buffer_id, cx); + }); + + let has_any_folded = editor.read_with(cx, |editor, cx| editor.has_any_buffer_folded(cx)); + assert!( + has_any_folded, + "Should still report folds when only one buffer is unfolded" + ); + + // Manually unfold the second buffer too + let second_buffer_id = editor.read_with(cx, |editor, cx| { + editor + .buffer() + .read(cx) + .snapshot(cx) + .excerpts() + .nth(1) + .unwrap() + .context + .start + .buffer_id + }); + editor.update_in(cx, |editor, _window, cx| { + editor.unfold_buffer(second_buffer_id, cx); + }); + + let has_any_folded = editor.read_with(cx, |editor, cx| editor.has_any_buffer_folded(cx)); + assert!( + !has_any_folded, + "No folds should remain after unfolding all buffers individually" + ); + + // Manually fold one buffer back + editor.update_in(cx, |editor, _window, cx| { + editor.fold_buffer(first_buffer_id, cx); + }); + + let has_any_folded = editor.read_with(cx, |editor, cx| editor.has_any_buffer_folded(cx)); + assert!( + has_any_folded, + "Should report folds after manually folding one buffer" + ); + } + + #[perf] + #[gpui::test] + async fn test_search_options_changes(cx: &mut TestAppContext) { + let (_editor, search_bar, cx) = init_test(cx); + update_search_settings( + SearchSettings { + button: true, + whole_word: false, + case_sensitive: false, + include_ignored: false, + regex: false, + center_on_match: false, + search_on_type: false, + }, + cx, + ); + + let deploy = Deploy { + focus: true, + replace_enabled: false, + selection_search_enabled: true, + }; + + search_bar.update_in(cx, |search_bar, window, cx| { + assert_eq!( + search_bar.search_options, + SearchOptions::NONE, + "Should have no search options enabled by default" + ); + search_bar.toggle_search_option(SearchOptions::WHOLE_WORD, window, cx); + assert_eq!( + search_bar.search_options, + SearchOptions::WHOLE_WORD, + "Should enable the option toggled" + ); + assert!( + !search_bar.dismissed, + "Search bar should be present and visible" + ); + search_bar.deploy(&deploy, None, window, cx); + assert_eq!( + search_bar.search_options, + SearchOptions::WHOLE_WORD, + "After (re)deploying, the option should still be enabled" + ); + + search_bar.dismiss(&Dismiss, window, cx); + search_bar.deploy(&deploy, None, window, cx); + assert_eq!( + search_bar.search_options, + SearchOptions::WHOLE_WORD, + "After hiding and showing the search bar, search options should be preserved" + ); + + search_bar.toggle_search_option(SearchOptions::REGEX, window, cx); + search_bar.toggle_search_option(SearchOptions::WHOLE_WORD, window, cx); + assert_eq!( + search_bar.search_options, + SearchOptions::REGEX, + "Should enable the options toggled" + ); + assert!( + !search_bar.dismissed, + "Search bar should be present and visible" + ); + search_bar.toggle_search_option(SearchOptions::WHOLE_WORD, window, cx); + }); + + update_search_settings( + SearchSettings { + button: true, + whole_word: false, + case_sensitive: true, + include_ignored: false, + regex: false, + center_on_match: false, + search_on_type: false, + }, + cx, + ); + search_bar.update_in(cx, |search_bar, window, cx| { + assert_eq!( + search_bar.search_options, + SearchOptions::REGEX | SearchOptions::WHOLE_WORD, + "Should have no search options enabled by default" + ); + + search_bar.deploy(&deploy, None, window, cx); + assert_eq!( + search_bar.search_options, + SearchOptions::REGEX | SearchOptions::WHOLE_WORD, + "Toggling a non-dismissed search bar with custom options should not change the default options" + ); + search_bar.dismiss(&Dismiss, window, cx); + search_bar.deploy(&deploy, None, window, cx); + assert_eq!( + search_bar.configured_options, + SearchOptions::CASE_SENSITIVE, + "After a settings update and toggling the search bar, configured options should be updated" + ); + assert_eq!( + search_bar.search_options, + SearchOptions::CASE_SENSITIVE, + "After a settings update and toggling the search bar, configured options should be used" + ); + }); + + update_search_settings( + SearchSettings { + button: true, + whole_word: true, + case_sensitive: true, + include_ignored: false, + regex: false, + center_on_match: false, + search_on_type: false, + }, + cx, + ); + + search_bar.update_in(cx, |search_bar, window, cx| { + search_bar.deploy(&deploy, None, window, cx); + search_bar.dismiss(&Dismiss, window, cx); + search_bar.show(window, cx); + assert_eq!( + search_bar.search_options, + SearchOptions::CASE_SENSITIVE | SearchOptions::WHOLE_WORD, + "Calling deploy on an already deployed search bar should not prevent settings updates from being detected" + ); + }); + } + + #[gpui::test] + async fn test_select_occurrence_case_sensitivity(cx: &mut TestAppContext) { + let (editor, search_bar, cx) = init_test(cx); + let mut editor_cx = EditorTestContext::for_editor_in(editor, cx).await; + + // Start with case sensitive search settings. + let mut search_settings = SearchSettings::default(); + search_settings.case_sensitive = true; + update_search_settings(search_settings, cx); + search_bar.update(cx, |search_bar, cx| { + let mut search_options = search_bar.search_options; + search_options.insert(SearchOptions::CASE_SENSITIVE); + search_bar.set_search_options(search_options, cx); + }); + + editor_cx.set_state("«ˇfoo»\nFOO\nFoo\nfoo"); + editor_cx.update_editor(|e, window, cx| { + e.select_next(&Default::default(), window, cx).unwrap(); + }); + editor_cx.assert_editor_state("«ˇfoo»\nFOO\nFoo\n«ˇfoo»"); + + // Update the search bar's case sensitivite toggle, so we can later + // confirm that `select_next` will now be case-insensitive. + editor_cx.set_state("«ˇfoo»\nFOO\nFoo\nfoo"); + search_bar.update_in(cx, |search_bar, window, cx| { + search_bar.toggle_case_sensitive(&Default::default(), window, cx); + }); + editor_cx.update_editor(|e, window, cx| { + e.select_next(&Default::default(), window, cx).unwrap(); + }); + editor_cx.assert_editor_state("«ˇfoo»\n«ˇFOO»\nFoo\nfoo"); + + // Confirm that, after dismissing the search bar, only the editor's + // search settings actually affect the behavior of `select_next`. + search_bar.update_in(cx, |search_bar, window, cx| { + search_bar.dismiss(&Default::default(), window, cx); + }); + editor_cx.set_state("«ˇfoo»\nFOO\nFoo\nfoo"); + editor_cx.update_editor(|e, window, cx| { + e.select_next(&Default::default(), window, cx).unwrap(); + }); + editor_cx.assert_editor_state("«ˇfoo»\nFOO\nFoo\n«ˇfoo»"); + + // Update the editor's search settings, disabling case sensitivity, to + // check that the value is respected. + let mut search_settings = SearchSettings::default(); + search_settings.case_sensitive = false; + update_search_settings(search_settings, cx); + editor_cx.set_state("«ˇfoo»\nFOO\nFoo\nfoo"); + editor_cx.update_editor(|e, window, cx| { + e.select_next(&Default::default(), window, cx).unwrap(); + }); + editor_cx.assert_editor_state("«ˇfoo»\n«ˇFOO»\nFoo\nfoo"); + } + + #[gpui::test] + async fn test_regex_search_does_not_highlight_non_matching_occurrences( + cx: &mut TestAppContext, + ) { + init_globals(cx); + let buffer = cx.new(|cx| { + Buffer::local( + "something is at the top\nsomething is behind something\nsomething is at the bottom\n", + cx, + ) + }); + let cx = cx.add_empty_window(); + let editor = + cx.new_window_entity(|window, cx| Editor::for_buffer(buffer.clone(), None, window, cx)); + let search_bar = cx.new_window_entity(|window, cx| { + let mut search_bar = BufferSearchBar::new(None, window, cx); + search_bar.set_active_pane_item(Some(&editor), window, cx); + search_bar.show(window, cx); + search_bar + }); + + search_bar.update_in(cx, |search_bar, window, cx| { + search_bar.toggle_search_option(SearchOptions::REGEX, window, cx); + }); + + search_bar + .update_in(cx, |search_bar, window, cx| { + search_bar.search("^something", None, true, window, cx) + }) + .await + .unwrap(); + + search_bar.update_in(cx, |search_bar, window, cx| { + search_bar.select_next_match(&SelectNextMatch, window, cx); + }); + + // Advance past the debounce so the selection occurrence highlight would + // have fired if it were not suppressed by the active buffer search. + cx.executor() + .advance_clock(SELECTION_HIGHLIGHT_DEBOUNCE_TIMEOUT + Duration::from_millis(1)); + cx.run_until_parked(); + + editor.update(cx, |editor, cx| { + assert!( + !editor.has_background_highlights(HighlightKey::SelectedTextHighlight), + "selection occurrence highlights must be suppressed during buffer search" + ); + assert_eq!( + editor.search_background_highlights(cx).len(), + 3, + "expected exactly 3 search highlights (one per line start)" + ); + }); + + // Manually select "something" — this should restore occurrence highlights + // because it clears the search-navigation flag. + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges([Point::new(0, 0)..Point::new(0, 9)]) + }); + }); + + cx.executor() + .advance_clock(SELECTION_HIGHLIGHT_DEBOUNCE_TIMEOUT + Duration::from_millis(1)); + cx.run_until_parked(); + + editor.update(cx, |editor, _cx| { + assert!( + editor.has_background_highlights(HighlightKey::SelectedTextHighlight), + "selection occurrence highlights must be restored after a manual selection" + ); + }); + } + + #[gpui::test] + async fn test_replace_with_non_ascii_characters(cx: &mut TestAppContext) { + let (editor, search_bar, cx) = init_test(cx); + + editor.update_in(cx, |editor, window, cx| { + editor.set_text("¥100 ¥200 ¥100", window, cx) + }); + + search_bar + .update_in(cx, |search_bar, window, cx| { + search_bar.search("¥", None, true, window, cx) + }) + .await + .unwrap(); + + search_bar.update_in(cx, |search_bar, window, cx| { + search_bar.replacement_editor.update(cx, |editor, cx| { + editor.set_text("\\n", window, cx); + }); + search_bar.replace_all(&ReplaceAll, window, cx) + }); + + assert_eq!( + editor.read_with(cx, |this, cx| this.text(cx)), + "\\n100 \\n200 \\n100" + ); + } + + #[gpui::test] + async fn test_seeded_query_is_escaped_in_regex_mode(cx: &mut TestAppContext) { + init_globals(cx); + let buffer = cx.new(|cx| Buffer::local("z.d\nzed\n", cx)); + let cx = cx.add_empty_window(); + let editor = + cx.new_window_entity(|window, cx| Editor::for_buffer(buffer.clone(), None, window, cx)); + let search_bar = cx.new_window_entity(|window, cx| { + let mut search_bar = BufferSearchBar::new(None, window, cx); + search_bar.set_active_pane_item(Some(&editor), window, cx); + search_bar.show(window, cx); + search_bar + }); + + // Select "z.d" on the first line. + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges([Point::new(0, 0)..Point::new(0, 3)]) + }); + }); + + search_bar.update_in(cx, |search_bar, window, cx| { + search_bar.toggle_search_option(SearchOptions::REGEX, window, cx); + search_bar.search_suggested(Some(SeedQuerySetting::Selection), window, cx); + }); + cx.run_until_parked(); + + search_bar.read_with(cx, |search_bar, cx| { + assert_eq!( + search_bar.query(cx), + r"z\.d", + "seeded regex query must be escaped" + ); + }); + + editor.update(cx, |editor, cx| { + assert_eq!( + editor.search_background_highlights(cx).len(), + 1, + "only the literal 'z.d' should match, not 'zed'" + ); + }); + } + + #[gpui::test] + async fn test_set_active_pane_item_syncs_select_search_options(cx: &mut TestAppContext) { + init_globals(cx); + + // Set up 2 editors, with both buffers containing a lowercase and + // uppercase version of the same word, so we can confirm whether case + // sensitivity is affecting `editor: select next` . + let cx = cx.add_empty_window(); + let buffer_a = cx.new(|cx| Buffer::local("zed\nZED\n", cx)); + let buffer_b = cx.new(|cx| Buffer::local("zed\nZED\n", cx)); + let editor_a = cx + .new_window_entity(|window, cx| Editor::for_buffer(buffer_a.clone(), None, window, cx)); + let editor_b = cx + .new_window_entity(|window, cx| Editor::for_buffer(buffer_b.clone(), None, window, cx)); + + let search_bar = cx.new_window_entity(|window, cx| { + let mut search_bar = BufferSearchBar::new(None, window, cx); + search_bar.set_active_pane_item(Some(&editor_a), window, cx); + search_bar.show(window, cx); + search_bar + }); + + search_bar.update(cx, |search_bar, cx| { + search_bar.set_search_options(SearchOptions::CASE_SENSITIVE, cx) + }); + + // Editor A should use the search bar's case sensitivity option. + editor_a.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { + selections.select_display_ranges([ + DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 3) + ]); + }); + + editor + .select_next(&Default::default(), window, cx) + .expect("selecting next should succeed, even if selections are not changed"); + + assert_eq!( + editor + .selections + .display_ranges(&editor.display_snapshot(cx)), + vec![DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 3)] + ); + }); + + // Switching the active item should move the search bar's options to + // Editor B. + search_bar.update_in(cx, |search_bar, window, cx| { + search_bar.set_active_pane_item(Some(&editor_b), window, cx) + }); + + editor_b.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { + selections.select_display_ranges([ + DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 3) + ]); + }); + + editor + .select_next(&Default::default(), window, cx) + .expect("selecting next should succeed, even if selections are not changed"); + + assert_eq!( + editor + .selections + .display_ranges(&editor.display_snapshot(cx)), + vec![DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 3)] + ); + }); + + // Editor A should now fall back to the default case insensitive search + // setting. + editor_a.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { + selections.select_display_ranges([ + DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 3) + ]); + }); + + editor + .select_next(&Default::default(), window, cx) + .expect("selecting next should succeed, even if selections are not changed"); + + assert_eq!( + editor + .selections + .display_ranges(&editor.display_snapshot(cx)), + vec![ + DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 3), + DisplayPoint::new(DisplayRow(1), 0)..DisplayPoint::new(DisplayRow(1), 3) + ] + ); + }); + } + + #[gpui::test] + async fn test_search_option_change_during_selections(cx: &mut TestAppContext) { + init_globals(cx); + + let cx = cx.add_empty_window(); + let buffer = cx.new(|cx| Buffer::local("abc\ndefabc\nghiabc\nabc", cx)); + let editor = + cx.new_window_entity(|window, cx| Editor::for_buffer(buffer.clone(), None, window, cx)); + + let search_bar = cx.new_window_entity(|window, cx| { + let mut search_bar = BufferSearchBar::new(None, window, cx); + search_bar.set_active_pane_item(Some(&editor), window, cx); + search_bar.show(window, cx); + search_bar + }); + + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { + selections.select_display_ranges([ + DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 3) + ]); + }); + + editor + .select_next(&Default::default(), window, cx) + .expect("should be able to select next"); + + assert_eq!( + editor + .selections + .display_ranges(&editor.display_snapshot(cx)), + vec![ + DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 3), + DisplayPoint::new(DisplayRow(1), 3)..DisplayPoint::new(DisplayRow(1), 6) + ] + ); + }); + + // Toggle the "Whole Word" search option, so we can later check that + // only the last line is now going to be selected. + search_bar.update_in(cx, |search_bar, window, cx| { + search_bar.toggle_whole_word(&Default::default(), window, cx) + }); + + editor.update_in(cx, |editor, window, cx| { + editor + .select_next(&Default::default(), window, cx) + .expect("should be able to select next"); + + assert_eq!( + editor + .selections + .display_ranges(&editor.display_snapshot(cx)), + vec![ + DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 3), + DisplayPoint::new(DisplayRow(1), 3)..DisplayPoint::new(DisplayRow(1), 6), + DisplayPoint::new(DisplayRow(3), 0)..DisplayPoint::new(DisplayRow(3), 3) + ] + ); + }); + } + + #[gpui::test] + async fn test_show_syncs_select_search_options(cx: &mut TestAppContext) { + init_globals(cx); + + let cx = cx.add_empty_window(); + let buffer = cx.new(|cx| Buffer::local("zed\nzedfoo\nzed", cx)); + let editor = + cx.new_window_entity(|window, cx| Editor::for_buffer(buffer.clone(), None, window, cx)); + + let search_bar = cx.new_window_entity(|window, cx| { + let mut search_bar = BufferSearchBar::new(None, window, cx); + search_bar.set_active_pane_item(Some(&editor), window, cx); + search_bar.show(window, cx); + search_bar + }); + + search_bar.update_in(cx, |search_bar, window, cx| { + search_bar.set_search_options(SearchOptions::WHOLE_WORD, cx); + + // Reopening the search bar with the same options should sync them + // back to the editor after dismissing cleared them from the editor. + search_bar.dismiss(&Default::default(), window, cx); + search_bar.show(window, cx) + }); + + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { + selections.select_display_ranges([ + DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 3) + ]); + }); + + editor + .select_next(&Default::default(), window, cx) + .expect("should be able to select next"); + + assert_eq!( + editor + .selections + .display_ranges(&editor.display_snapshot(cx)), + vec![ + DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 3), + DisplayPoint::new(DisplayRow(2), 0)..DisplayPoint::new(DisplayRow(2), 3) + ] + ); + }); + } + + fn update_search_settings(search_settings: SearchSettings, cx: &mut TestAppContext) { + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.editor.search = Some(SearchSettingsContent { + button: Some(search_settings.button), + whole_word: Some(search_settings.whole_word), + case_sensitive: Some(search_settings.case_sensitive), + include_ignored: Some(search_settings.include_ignored), + regex: Some(search_settings.regex), + center_on_match: Some(search_settings.center_on_match), + search_on_type: Some(search_settings.search_on_type), + }); + }); + }); + }); + } +}