diff --git a/Cargo.lock b/Cargo.lock index ee82f7d975..98740df2a3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6593,7 +6593,6 @@ dependencies = [ "theme", "theme_settings", "ui", - "ui_input", "util", "workspace", "zed_actions", @@ -13439,16 +13438,23 @@ name = "picker" version = "0.1.0" dependencies = [ "anyhow", + "db", "editor", "gpui", + "language", "menu", + "multi_buffer", + "project", + "rope", "schemars 1.0.4", "serde", + "serde_json", "settings", "theme", "theme_settings", "ui", "ui_input", + "util", "workspace", "zed_actions", ] @@ -16184,20 +16190,23 @@ dependencies = [ "bitflags 2.10.0", "collections", "editor", + "file_icons", "fs", "futures 0.3.32", - "futures-lite 1.13.0", "gpui", "itertools 0.14.0", "language", "lsp", "menu", "multi_buffer", + "picker", "pretty_assertions", "project", "serde", "serde_json", "settings", + "smol", + "text", "theme", "theme_settings", "tracing", diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 2220068078..4a56cb77ce 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -451,6 +451,7 @@ "ctrl-shift-h": "search::ToggleReplace", "alt-ctrl-g": "search::ToggleRegex", "alt-ctrl-x": "search::ToggleRegex", + "ctrl-alt-p": "project_search::OpenTextFinder", }, }, { @@ -480,6 +481,7 @@ "ctrl-shift-h": "search::ToggleReplace", "alt-ctrl-g": "search::ToggleRegex", "alt-ctrl-x": "search::ToggleRegex", + "ctrl-alt-p": "project_search::OpenTextFinder", }, }, { @@ -1157,6 +1159,10 @@ "down": "menu::SelectNext", "tab": "picker::ConfirmCompletion", "alt-enter": ["picker::ConfirmInput", { "secondary": false }], + // Picker bindings (TogglePreview, SetPreviewRight/Below/Hidden, + // ToggleActionsMenu) live in keymaps/specific-overrides.json, which is + // loaded after the base keymap so they win over conflicting base-keymap + // Editor bindings. }, }, { @@ -1176,24 +1182,7 @@ "context": "FileFinder || (FileFinder > Picker > Editor)", "bindings": { "ctrl-p": "file_finder::Toggle", - "ctrl-shift-a": "file_finder::ToggleSplitMenu", - "ctrl-shift-i": "file_finder::ToggleFilterMenu", - }, - }, - { - "context": "FileFinder > Picker > Editor && end_of_input", - "bindings": { - "right": "file_finder::OpenWithoutDismiss", - }, - }, - { - "context": "FileFinder || (FileFinder > Picker > Editor) || (FileFinder > Picker > menu)", - "bindings": { - "ctrl-shift-p": "file_finder::SelectPrevious", - "ctrl-j": "pane::SplitDown", - "ctrl-k": "pane::SplitUp", - "ctrl-h": "pane::SplitLeft", - "ctrl-l": "pane::SplitRight", + "ctrl-shift-i": "search::ToggleIncludeIgnored", }, }, { diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 9310fce072..175a9b7b9b 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -490,6 +490,13 @@ "down": "search::NextHistoryQuery", }, }, + { + "context": "BufferSearchBar || ProjectSearchBar", + "use_key_equivalents": true, + "bindings": { + "ctrl-enter": "editor::Newline", + }, + }, { "context": "ProjectSearchBar", "use_key_equivalents": true, @@ -501,6 +508,7 @@ "cmd-shift-h": "search::ToggleReplace", "alt-cmd-g": "search::ToggleRegex", "alt-cmd-x": "search::ToggleRegex", + "cmd-alt-p": "project_search::OpenTextFinder", }, }, { @@ -534,6 +542,7 @@ "cmd-shift-h": "search::ToggleReplace", "alt-cmd-g": "search::ToggleRegex", "alt-cmd-x": "search::ToggleRegex", + "cmd-alt-p": "project_search::OpenTextFinder", }, }, { @@ -1210,6 +1219,10 @@ "tab": "picker::ConfirmCompletion", "alt-enter": ["picker::ConfirmInput", { "secondary": false }], "cmd-alt-enter": ["picker::ConfirmInput", { "secondary": true }], + // Picker bindings (TogglePreview, SetPreviewRight/Below/Hidden, + // ToggleActionsMenu) live in keymaps/specific-overrides-macos.json, which + // is loaded after the base keymap so they win over conflicting base-keymap + // Editor bindings. }, }, { @@ -1230,25 +1243,7 @@ "context": "FileFinder || (FileFinder > Picker > Editor)", "use_key_equivalents": true, "bindings": { - "cmd-shift-a": "file_finder::ToggleSplitMenu", - "cmd-shift-i": "file_finder::ToggleFilterMenu", - }, - }, - { - "context": "FileFinder > Picker > Editor && end_of_input", - "bindings": { - "right": "file_finder::OpenWithoutDismiss", - }, - }, - { - "context": "FileFinder || (FileFinder > Picker > Editor) || (FileFinder > Picker > menu)", - "use_key_equivalents": true, - "bindings": { - "cmd-shift-p": "file_finder::SelectPrevious", - "cmd-j": "pane::SplitDown", - "cmd-k": "pane::SplitUp", - "cmd-h": "pane::SplitLeft", - "cmd-l": "pane::SplitRight", + "cmd-shift-i": "search::ToggleIncludeIgnored", }, }, { diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index 1833efec32..50d4d83c23 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -452,6 +452,7 @@ "ctrl-shift-f": "search::FocusSearch", "ctrl-shift-h": "search::ToggleReplace", "alt-r": "search::ToggleRegex", // vscode + "ctrl-alt-p": "project_search::OpenTextFinder", }, }, { @@ -482,6 +483,7 @@ "escape": "project_search::ToggleFocus", "ctrl-shift-h": "search::ToggleReplace", "alt-r": "search::ToggleRegex", // vscode + "ctrl-alt-p": "project_search::OpenTextFinder", }, }, { @@ -1162,6 +1164,10 @@ "down": "menu::SelectNext", "tab": "picker::ConfirmCompletion", "alt-enter": ["picker::ConfirmInput", { "secondary": false }], + // Picker bindings (TogglePreview, SetPreviewRight/Below/Hidden, + // ToggleActionsMenu) live in keymaps/specific-overrides.json, which is + // loaded after the base keymap so they win over conflicting base-keymap + // Editor bindings. }, }, { @@ -1183,25 +1189,7 @@ "use_key_equivalents": true, "bindings": { "ctrl-p": "file_finder::Toggle", - "ctrl-shift-a": "file_finder::ToggleSplitMenu", - "ctrl-shift-i": "file_finder::ToggleFilterMenu", - }, - }, - { - "context": "FileFinder > Picker > Editor && end_of_input", - "bindings": { - "right": "file_finder::OpenWithoutDismiss", - }, - }, - { - "context": "FileFinder || (FileFinder > Picker > Editor) || (FileFinder > Picker > menu)", - "use_key_equivalents": true, - "bindings": { - "ctrl-shift-p": "file_finder::SelectPrevious", - "ctrl-j": "pane::SplitDown", - "ctrl-k": "pane::SplitUp", - "ctrl-h": "pane::SplitLeft", - "ctrl-l": "pane::SplitRight", + "ctrl-shift-i": "search::ToggleIncludeIgnored", }, }, { diff --git a/assets/keymaps/specific-overrides-macos.json b/assets/keymaps/specific-overrides-macos.json new file mode 100644 index 0000000000..9c3b95c377 --- /dev/null +++ b/assets/keymaps/specific-overrides-macos.json @@ -0,0 +1,42 @@ +// Put keybindings bound to a tight specific context that need to overwrite the +// base keymaps here. Only put them here if absolutely needed. In those cases +// also add a comment to the default keymaps that this binding exists here, to +// make it a little more discoverable. +// +// This is loaded after all the base keymaps (Atom, vim etc) but before the user +// keymaps, giving it the highest precedence of all the bindings set by us. +[ + { + "context": "Picker > Editor", + "use_key_equivalents": true, + "bindings": { + "cmd-shift-a": "picker::ToggleActionsMenu", + "cmd-alt-p": "picker::TogglePreview", + "cmd-alt-right": "picker::SetPreviewRight", + "cmd-alt-down": "picker::SetPreviewBelow", + "cmd-alt-up": "picker::SetPreviewHidden", + }, + }, + { + "context": "FileFinder || (FileFinder > Picker > Editor) || (FileFinder > Picker > menu)", + "use_key_equivalents": true, + "bindings": { + "cmd-shift-p": "file_finder::SelectPrevious", + "cmd-j": "pane::SplitDown", + "cmd-k": "pane::SplitUp", + "cmd-h": "pane::SplitLeft", + "cmd-l": "pane::SplitRight", + }, + }, + { + "context": "TextFinder || (TextFinder > Picker > Editor) || (TextFinder > Picker > menu)", + "use_key_equivalents": true, + "bindings": { + "cmd-alt-p": "text_finder::ToProjectSearch", + "cmd-j": "pane::SplitDown", + "cmd-k": "pane::SplitUp", + "cmd-h": "pane::SplitLeft", + "cmd-l": "pane::SplitRight", + }, + }, +] diff --git a/assets/keymaps/specific-overrides.json b/assets/keymaps/specific-overrides.json new file mode 100644 index 0000000000..05ee8e93e0 --- /dev/null +++ b/assets/keymaps/specific-overrides.json @@ -0,0 +1,39 @@ +// Put keybindings bound to a tight specific context that need to overwrite the +// base keymaps here. Only put them here if absolutely needed. In those cases +// also add a comment to the default keymaps that this binding exists here, to +// make it a little more discoverable. +// +// This is loaded after all the base keymaps (Atom, vim etc) but before the user +// keymaps, giving it the highest precedence of all the bindings set by us. +[ + { + "context": "Picker > Editor", + "bindings": { + "ctrl-shift-a": "picker::ToggleActionsMenu", + "ctrl-alt-p": "picker::TogglePreview", + "ctrl-alt-right": "picker::SetPreviewRight", + "ctrl-alt-down": "picker::SetPreviewBelow", + "ctrl-alt-up": "picker::SetPreviewHidden", + }, + }, + { + "context": "FileFinder || (FileFinder > Picker > Editor) || (FileFinder > Picker > menu)", + "bindings": { + "ctrl-shift-p": "file_finder::SelectPrevious", + "ctrl-j": "pane::SplitDown", + "ctrl-k": "pane::SplitUp", + "ctrl-h": "pane::SplitLeft", + "ctrl-l": "pane::SplitRight", + }, + }, + { + "context": "TextFinder || (TextFinder > Picker > Editor) || (TextFinder > Picker > menu)", + "bindings": { + "ctrl-alt-p": "text_finder::ToProjectSearch", + "ctrl-j": "pane::SplitDown", + "ctrl-k": "pane::SplitUp", + "ctrl-h": "pane::SplitLeft", + "ctrl-l": "pane::SplitRight", + }, + }, +] diff --git a/crates/agent/src/tools/grep_tool.rs b/crates/agent/src/tools/grep_tool.rs index 381639e3e2..00a8074c24 100644 --- a/crates/agent/src/tools/grep_tool.rs +++ b/crates/agent/src/tools/grep_tool.rs @@ -174,7 +174,7 @@ impl AgentTool for GrepTool { let project = project.downgrade(); // Keep the search alive for the duration of result iteration. Dropping this task is the // cancellation mechanism; we intentionally do not detach it. - let SearchResults {rx, _task_handle} = results; + let SearchResults {rx, ..} = results; futures::pin_mut!(rx); let mut output = String::new(); diff --git a/crates/agent_ui/src/agent_configuration/tool_picker.rs b/crates/agent_ui/src/agent_configuration/tool_picker.rs index 9fc1794419..d1d471ccb9 100644 --- a/crates/agent_ui/src/agent_configuration/tool_picker.rs +++ b/crates/agent_ui/src/agent_configuration/tool_picker.rs @@ -155,6 +155,10 @@ impl ToolPickerDelegate { impl PickerDelegate for ToolPickerDelegate { type ListItem = AnyElement; + fn name() -> &'static str { + "tool picker" + } + fn match_count(&self) -> usize { self.filtered_items.len() } diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 89245c94f6..96ee16fda4 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -7503,6 +7503,12 @@ mod tests { // rather than merely echoing the keystrokes. settings.terminal_init_command = Some("echo init_ran_$((6*7))".to_string()); AgentSettings::override_global(settings, cx); + + // Force a POSIX shell rather than relying on the developer's login + // shell, which may not support `$((...))` arithmetic (e.g. fish). + let mut terminal_settings = TerminalSettings::get_global(cx).clone(); + terminal_settings.shell = task::Shell::Program("/bin/sh".to_string()); + TerminalSettings::override_global(terminal_settings, cx); }); let terminal_id = TerminalId::new(); diff --git a/crates/agent_ui/src/config_options.rs b/crates/agent_ui/src/config_options.rs index 03446a61c3..1dea1c01a5 100644 --- a/crates/agent_ui/src/config_options.rs +++ b/crates/agent_ui/src/config_options.rs @@ -299,8 +299,9 @@ impl ConfigOptionSelector { Picker::nonsearchable_list(delegate, window, picker_cx) } .show_scrollbar(true) - .width(rems(20.)) - .max_height(Some(rems(20.).into())) + .minimum_results_width(rems(20.)) + .height(rems(20.)) + .no_vertical_padding() }) }; @@ -559,6 +560,10 @@ impl ConfigOptionPickerDelegate { impl PickerDelegate for ConfigOptionPickerDelegate { type ListItem = AnyElement; + fn name() -> &'static str { + "config options" + } + fn match_count(&self) -> usize { self.filtered_entries.len() } diff --git a/crates/agent_ui/src/language_model_selector.rs b/crates/agent_ui/src/language_model_selector.rs index 4f870b7cfb..aeba2a5067 100644 --- a/crates/agent_ui/src/language_model_selector.rs +++ b/crates/agent_ui/src/language_model_selector.rs @@ -47,8 +47,9 @@ pub fn language_model_selector( if popover_styles { Picker::list(delegate, window, cx) .show_scrollbar(true) - .width(rems(20.)) - .max_height(Some(rems(20.).into())) + .minimum_results_width(rems(20.)) + .height(rems(20.)) + .no_vertical_padding() } else { Picker::list(delegate, window, cx).show_scrollbar(true) } @@ -390,6 +391,10 @@ impl ModelMatcher { impl PickerDelegate for LanguageModelPickerDelegate { type ListItem = AnyElement; + fn name() -> &'static str { + "language model selector" + } + fn match_count(&self) -> usize { self.filtered_entries.len() } diff --git a/crates/agent_ui/src/model_selector.rs b/crates/agent_ui/src/model_selector.rs index 015d1b3a12..fa82018b8a 100644 --- a/crates/agent_ui/src/model_selector.rs +++ b/crates/agent_ui/src/model_selector.rs @@ -35,8 +35,9 @@ pub fn acp_model_selector( let delegate = ModelPickerDelegate::new(selector, focus_handle, window, cx); Picker::list(delegate, window, cx) .show_scrollbar(true) - .width(rems(20.)) - .max_height(Some(rems(20.).into())) + .minimum_results_width(rems(20.)) + .height(rems(20.)) + .no_vertical_padding() } enum ModelPickerEntry { @@ -192,6 +193,10 @@ impl ModelPickerDelegate { impl PickerDelegate for ModelPickerDelegate { type ListItem = AnyElement; + fn name() -> &'static str { + "model selector" + } + fn match_count(&self) -> usize { self.filtered_entries.len() } diff --git a/crates/agent_ui/src/profile_selector.rs b/crates/agent_ui/src/profile_selector.rs index 4ca3241161..247890f51a 100644 --- a/crates/agent_ui/src/profile_selector.rs +++ b/crates/agent_ui/src/profile_selector.rs @@ -123,8 +123,9 @@ impl ProfileSelector { let picker = cx.new(|cx| { Picker::list(delegate, window, cx) .show_scrollbar(true) - .width(rems(18.)) - .max_height(Some(rems(20.).into())) + .minimum_results_width(rems(18.)) + .height(rems(20.)) + .no_vertical_padding() }); self.picker = Some(picker); @@ -430,6 +431,10 @@ impl ProfilePickerDelegate { impl PickerDelegate for ProfilePickerDelegate { type ListItem = AnyElement; + fn name() -> &'static str { + "profile selector" + } + fn placeholder_text(&self, _: &mut Window, _: &mut App) -> Arc { "Search profiles…".into() } diff --git a/crates/agent_ui/src/threads_archive_view.rs b/crates/agent_ui/src/threads_archive_view.rs index f2c325a6bd..d8f1e56bb1 100644 --- a/crates/agent_ui/src/threads_archive_view.rs +++ b/crates/agent_ui/src/threads_archive_view.rs @@ -1287,6 +1287,10 @@ impl EventEmitter for ProjectPickerDelegate {} impl PickerDelegate for ProjectPickerDelegate { type ListItem = AnyElement; + fn name() -> &'static str { + "thread archive project picker" + } + fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { format!( "Associate the \"{}\" thread with...", diff --git a/crates/collab_ui/src/collab_panel/channel_modal.rs b/crates/collab_ui/src/collab_panel/channel_modal.rs index f9077a9ab3..3caa5a3fb2 100644 --- a/crates/collab_ui/src/collab_panel/channel_modal.rs +++ b/crates/collab_ui/src/collab_panel/channel_modal.rs @@ -259,6 +259,10 @@ pub struct ChannelModalDelegate { impl PickerDelegate for ChannelModalDelegate { type ListItem = ListItem; + fn name() -> &'static str { + "channel modal" + } + fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { "Search collaborator by username...".into() } diff --git a/crates/collab_ui/src/collab_panel/contact_finder.rs b/crates/collab_ui/src/collab_panel/contact_finder.rs index e665e660a9..190c27a483 100644 --- a/crates/collab_ui/src/collab_panel/contact_finder.rs +++ b/crates/collab_ui/src/collab_panel/contact_finder.rs @@ -71,6 +71,10 @@ impl Focusable for ContactFinder { impl PickerDelegate for ContactFinderDelegate { type ListItem = ListItem; + fn name() -> &'static str { + "contact finder" + } + fn match_count(&self) -> usize { self.potential_contacts.len() } diff --git a/crates/command_palette/src/command_palette.rs b/crates/command_palette/src/command_palette.rs index 569d8407b6..756eca33fc 100644 --- a/crates/command_palette/src/command_palette.rs +++ b/crates/command_palette/src/command_palette.rs @@ -125,7 +125,11 @@ impl CommandPalette { ); let picker = cx.new(|cx| { - let picker = Picker::uniform_list(delegate, window, cx); + let picker = Picker::uniform_list(delegate, window, cx) + .initial_width(rems(34.)) + .minimum_results_width(rems(30.)) + .height(rems(24.)) + .no_vertical_padding(); picker.set_query(query, window, cx); picker }); @@ -150,7 +154,6 @@ impl Render for CommandPalette { fn render(&mut self, _window: &mut Window, _: &mut Context) -> impl IntoElement { v_flex() .key_context("CommandPalette") - .w(rems(34.)) .child(self.picker.clone()) } } @@ -376,6 +379,10 @@ impl CommandPaletteDelegate { impl PickerDelegate for CommandPaletteDelegate { type ListItem = ListItem; + fn name() -> &'static str { + "command palette" + } + fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { "Execute a command...".into() } diff --git a/crates/debugger_ui/src/attach_modal.rs b/crates/debugger_ui/src/attach_modal.rs index 5f07f2a70d..2942bd117c 100644 --- a/crates/debugger_ui/src/attach_modal.rs +++ b/crates/debugger_ui/src/attach_modal.rs @@ -137,6 +137,10 @@ impl ModalView for AttachModal {} impl PickerDelegate for AttachModalDelegate { type ListItem = ListItem; + fn name() -> &'static str { + "attach modal" + } + fn match_count(&self) -> usize { self.matches.len() } diff --git a/crates/debugger_ui/src/new_process_modal.rs b/crates/debugger_ui/src/new_process_modal.rs index 9b523c04c6..ecd7d813bd 100644 --- a/crates/debugger_ui/src/new_process_modal.rs +++ b/crates/debugger_ui/src/new_process_modal.rs @@ -107,6 +107,8 @@ impl NewProcessModal { DebugDelegate::new(debug_panel.downgrade(), task_store.clone()); Picker::list(delegate, window, cx) .modal(false) + .height(rems(24.)) + .no_vertical_padding() .list_measure_all() }); @@ -1207,6 +1209,10 @@ impl DebugDelegate { impl PickerDelegate for DebugDelegate { type ListItem = ui::ListItem; + fn name() -> &'static str { + "debug scenario picker" + } + fn match_count(&self) -> usize { self.matches.len() } diff --git a/crates/dev_container/src/lib.rs b/crates/dev_container/src/lib.rs index 833caa830f..d90a06a67f 100644 --- a/crates/dev_container/src/lib.rs +++ b/crates/dev_container/src/lib.rs @@ -303,6 +303,10 @@ impl TemplatePickerDelegate { impl PickerDelegate for TemplatePickerDelegate { type ListItem = AnyElement; + fn name() -> &'static str { + "dev container template picker" + } + fn match_count(&self) -> usize { self.matching_indices.len() } @@ -486,6 +490,10 @@ impl FeaturePickerDelegate { impl PickerDelegate for FeaturePickerDelegate { type ListItem = AnyElement; + fn name() -> &'static str { + "dev container feature picker" + } + fn match_count(&self) -> usize { self.matching_indices.len() } diff --git a/crates/editor/src/display_map.rs b/crates/editor/src/display_map.rs index 4a5135a21e..9093fcfecf 100644 --- a/crates/editor/src/display_map.rs +++ b/crates/editor/src/display_map.rs @@ -180,6 +180,7 @@ pub enum HighlightKey { MatchingBracket, NavigationOverlay(NavigationOverlayKey), PendingInput, + PickerPreview, ProjectSearchView, Rename, SearchWithinRange, diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 32487c5390..30eaed6bd6 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -933,7 +933,7 @@ pub struct Editor { /// Whenever you want to modify the scroll position of the editor, you should /// usually use the existing available APIs as opposed to directly interacting /// with the scroll manager. - pub(crate) scroll_manager: ScrollManager, + pub scroll_manager: ScrollManager, /// When inline assist editors are linked, they all render cursors because /// typing enters text into each of them, even the ones that aren't focused. pub(crate) show_cursor_when_unfocused: bool, @@ -11759,6 +11759,21 @@ impl ui_input::ErasedEditor for ErasedEditorImpl { }); } + fn set_multiline(&self, max_lines: Option, _window: &mut Window, cx: &mut App) { + self.0.update(cx, |this, cx| { + if let Some(max_lines) = max_lines { + this.set_mode(EditorMode::AutoHeight { + min_lines: 1, + max_lines: Some(max_lines), + }); + this.set_soft_wrap_mode(language_settings::SoftWrap::EditorWidth, cx); + } else { + this.set_mode(EditorMode::SingleLine); + } + cx.notify(); + }); + } + fn focus_handle(&self, cx: &App) -> FocusHandle { self.0.read(cx).focus_handle(cx) } diff --git a/crates/encoding_selector/src/encoding_selector.rs b/crates/encoding_selector/src/encoding_selector.rs index e99b475de6..549ffda394 100644 --- a/crates/encoding_selector/src/encoding_selector.rs +++ b/crates/encoding_selector/src/encoding_selector.rs @@ -221,6 +221,10 @@ fn available_encodings() -> Vec<&'static Encoding> { impl PickerDelegate for EncodingSelectorDelegate { type ListItem = ListItem; + fn name() -> &'static str { + "encoding selector" + } + fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { "Reopen with encoding...".into() } diff --git a/crates/extensions_ui/src/extension_version_selector.rs b/crates/extensions_ui/src/extension_version_selector.rs index 6dd45954a7..1c123fc35c 100644 --- a/crates/extensions_ui/src/extension_version_selector.rs +++ b/crates/extensions_ui/src/extension_version_selector.rs @@ -92,6 +92,10 @@ impl ExtensionVersionSelectorDelegate { impl PickerDelegate for ExtensionVersionSelectorDelegate { type ListItem = ui::ListItem; + fn name() -> &'static str { + "extension version selector" + } + fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { "Select extension version...".into() } diff --git a/crates/extensions_ui/src/extensions_ui.rs b/crates/extensions_ui/src/extensions_ui.rs index 8b729cc4e2..22e13b54c9 100644 --- a/crates/extensions_ui/src/extensions_ui.rs +++ b/crates/extensions_ui/src/extensions_ui.rs @@ -275,7 +275,8 @@ pub fn init(cx: &mut App) { _ => { workspace.toggle_modal(window, cx, |window, cx| { let delegate = DevExtensionRebuildPickerDelegate::new(dev_extensions); - Picker::uniform_list(delegate, window, cx).width(rems(34.)) + Picker::uniform_list(delegate, window, cx) + .minimum_results_width(rems(34.)) }); } } @@ -1780,6 +1781,10 @@ impl DevExtensionRebuildPickerDelegate { impl PickerDelegate for DevExtensionRebuildPickerDelegate { type ListItem = ListItem; + fn name() -> &'static str { + "dev-extension-rebuild" + } + fn match_count(&self) -> usize { self.matches.len() } diff --git a/crates/file_finder/Cargo.toml b/crates/file_finder/Cargo.toml index b66d77da25..5690118281 100644 --- a/crates/file_finder/Cargo.toml +++ b/crates/file_finder/Cargo.toml @@ -32,7 +32,6 @@ settings.workspace = true serde.workspace = true theme.workspace = true ui.workspace = true -ui_input.workspace = true util.workspace = true workspace.workspace = true zed_actions.workspace = true diff --git a/crates/file_finder/src/file_finder.rs b/crates/file_finder/src/file_finder.rs index 8d3d3a8e6b..7c961bf13f 100644 --- a/crates/file_finder/src/file_finder.rs +++ b/crates/file_finder/src/file_finder.rs @@ -13,8 +13,8 @@ use fuzzy::{StringMatch, StringMatchCandidate}; use fuzzy_nucleo::{PathMatch, PathMatchCandidate}; use gpui::{ Action, AnyElement, App, Context, DismissEvent, Empty, Entity, EventEmitter, FocusHandle, - Focusable, KeyContext, Modifiers, ModifiersChangedEvent, ParentElement, Render, - StatefulInteractiveElement, Styled, Task, TaskExt, WeakEntity, Window, actions, rems, + Focusable, KeyContext, Modifiers, ModifiersChangedEvent, ParentElement, Render, Styled, Task, + TaskExt, WeakEntity, Window, actions, rems, }; use language::{BufferSnapshot, Point}; use open_path_prompt::{ @@ -36,12 +36,9 @@ use std::{ Arc, atomic::{self, AtomicBool}, }, + time::Duration, }; -use ui::{ - ButtonLike, CommonAnimationExt, ContextMenu, HighlightedLabel, Indicator, KeyBinding, ListItem, - ListItemSpacing, PopoverMenu, PopoverMenuHandle, TintColor, Tooltip, prelude::*, -}; -use ui_input::ErasedEditor; +use ui::{HighlightedLabel, Indicator, ListItem, ListItemSpacing, Tooltip, prelude::*}; use util::{ ResultExt, maybe, paths::{PathStyle, PathWithPosition}, @@ -59,35 +56,13 @@ actions!( [ /// Selects the previous item in the file finder. SelectPrevious, - /// Toggles the file filter menu. - ToggleFilterMenu, - /// Toggles the split direction menu. - ToggleSplitMenu, /// Opens the selected file in the editor without dismissing the file finder, /// so additional files can be opened in sequence. OpenWithoutDismiss ] ); -impl ModalView for FileFinder { - fn on_before_dismiss( - &mut self, - window: &mut Window, - cx: &mut Context, - ) -> workspace::DismissDecision { - let submenu_focused = self.picker.update(cx, |picker, cx| { - picker - .delegate - .filter_popover_menu_handle - .is_focused(window, cx) - || picker - .delegate - .split_popover_menu_handle - .is_focused(window, cx) - }); - workspace::DismissDecision::Dismiss(!submenu_focused) - } -} +impl ModalView for FileFinder {} pub struct FileFinder { picker: Entity>, @@ -191,7 +166,23 @@ impl FileFinder { } fn new(delegate: FileFinderDelegate, window: &mut Window, cx: &mut Context) -> Self { - let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx)); + let modal_max_width_setting = FileFinderSettings::get_global(cx).modal_max_width; + + let project = delegate.project.clone(); + let picker = cx.new(|cx| { + let picker = Picker::uniform_list_with_preview(delegate, project, window, cx) + .height(gpui::rems(24.)) + .no_vertical_padding(); + // The dedicated file finder width setting has been removed in favor of + // persisted picker sizes. For migration we still honor it as the initial + // width, but only if the user actually changed it from the default; + if modal_max_width_setting != FileFinderWidth::default() { + let modal_max_width = Self::modal_max_width(modal_max_width_setting, window); + picker.initial_width(Rems::from_pixels(modal_max_width, window)) + } else { + picker + } + }); let picker_focus_handle = picker.focus_handle(cx); picker.update(cx, |picker, _| { picker.delegate.focus_handle = picker_focus_handle.clone(); @@ -230,38 +221,6 @@ impl FileFinder { window.dispatch_action(Box::new(menu::SelectPrevious), cx); } - fn handle_filter_toggle_menu( - &mut self, - _: &ToggleFilterMenu, - window: &mut Window, - cx: &mut Context, - ) { - self.picker.update(cx, |picker, cx| { - let menu_handle = &picker.delegate.filter_popover_menu_handle; - if menu_handle.is_deployed() { - menu_handle.hide(cx); - } else { - menu_handle.show(window, cx); - } - }); - } - - fn handle_split_toggle_menu( - &mut self, - _: &ToggleSplitMenu, - window: &mut Window, - cx: &mut Context, - ) { - self.picker.update(cx, |picker, cx| { - let menu_handle = &picker.delegate.split_popover_menu_handle; - if menu_handle.is_deployed() { - menu_handle.hide(cx); - } else { - menu_handle.show(window, cx); - } - }); - } - fn handle_toggle_ignored( &mut self, _: &ToggleIncludeIgnored, @@ -385,16 +344,10 @@ impl Render for FileFinder { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let key_context = self.picker.read(cx).delegate.key_context(window, cx); - let file_finder_settings = FileFinderSettings::get_global(cx); - let modal_max_width = Self::modal_max_width(file_finder_settings.modal_max_width, window); - v_flex() .key_context(key_context) - .w(modal_max_width) .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed)) .on_action(cx.listener(Self::handle_select_prev)) - .on_action(cx.listener(Self::handle_filter_toggle_menu)) - .on_action(cx.listener(Self::handle_split_toggle_menu)) .on_action(cx.listener(Self::handle_toggle_ignored)) .on_action(cx.listener(Self::go_to_file_split_left)) .on_action(cx.listener(Self::go_to_file_split_right)) @@ -419,11 +372,10 @@ pub struct FileFinderDelegate { selected_index: usize, has_changed_selected_index: bool, cancel_flag: Arc, + search_in_flight: Arc, history_items: Vec, separate_history: bool, first_update: bool, - filter_popover_menu_handle: PopoverMenuHandle, - split_popover_menu_handle: PopoverMenuHandle, focus_handle: FocusHandle, include_ignored: Option, include_ignored_refresh: Task<()>, @@ -845,6 +797,7 @@ impl FoundPath { } const MAX_RECENT_SELECTIONS: usize = 20; +const SEARCH_DEBOUNCE: Duration = Duration::from_millis(100); pub enum Event { Selected(ProjectPath), @@ -985,11 +938,10 @@ impl FileFinderDelegate { has_changed_selected_index: false, selected_index: 0, cancel_flag: Arc::new(AtomicBool::new(false)), + search_in_flight: Arc::new(AtomicBool::new(false)), history_items, separate_history, first_update: true, - filter_popover_menu_handle: PopoverMenuHandle::default(), - split_popover_menu_handle: PopoverMenuHandle::default(), focus_handle: cx.focus_handle(), include_ignored: FileFinderSettings::get_global(cx).include_ignored, include_ignored_refresh: Task::ready(()), @@ -1493,17 +1445,9 @@ impl FileFinderDelegate { 0 } - fn key_context(&self, window: &Window, cx: &App) -> KeyContext { + fn key_context(&self, _window: &Window, _cx: &App) -> KeyContext { let mut key_context = KeyContext::new_with_defaults(); key_context.add("FileFinder"); - - if self.filter_popover_menu_handle.is_focused(window, cx) { - key_context.add("filter_menu_open"); - } - - if self.split_popover_menu_handle.is_focused(window, cx) { - key_context.add("split_menu_open"); - } key_context } @@ -1674,10 +1618,54 @@ fn full_path_budget( impl PickerDelegate for FileFinderDelegate { type ListItem = ListItem; + fn name() -> &'static str { + "file finder" + } + fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { "Search project files...".into() } + fn searchbar_trailer( + &self, + _window: &mut Window, + cx: &mut Context>, + ) -> Option { + let focus_handle = self.focus_handle.clone(); + let including_ignored = self.include_ignored == Some(true); + // Clicking includes ignored files unless they're already included, in + // which case it excludes them again (see `handle_toggle_ignored`). + let tooltip_label = if including_ignored { + "Exclude Ignored Files" + } else { + "Include Ignored Files" + }; + + let filter_button = IconButton::new("filter-ignored", IconName::Sliders) + .icon_size(IconSize::Small) + .toggle_state(including_ignored) + .when(self.include_ignored.is_some(), |this| { + this.indicator(Indicator::dot().color(Color::Info)) + }) + .tooltip(move |_window, cx| { + Tooltip::for_action_in(tooltip_label, &ToggleIncludeIgnored, &focus_handle, cx) + }) + .on_click(|_, window, cx| { + window.dispatch_action(ToggleIncludeIgnored.boxed_clone(), cx) + }); + Some( + h_flex() + .gap_1() + .child(filter_button) + .children(picker::parts::project_scan_indicator( + self.latest_search_query.is_some(), + &self.project, + cx, + )) + .into_any_element(), + ) + } + fn match_count(&self) -> usize { self.matches.len() } @@ -1777,12 +1765,20 @@ impl PickerDelegate for FileFinderDelegate { self.selected_index = 0; } cx.notify(); + self.search_in_flight + .store(false, atomic::Ordering::Release); Task::ready(()) } else { let query = parse_file_search_query(raw_query); let path = query.path_position.path.clone(); + let search_in_flight = self.search_in_flight.clone(); + let was_in_flight = search_in_flight.swap(true, atomic::Ordering::Relaxed); + cx.spawn_in(window, async move |this, cx| { + if was_in_flight { + cx.background_executor().timer(SEARCH_DEBOUNCE).await; + } let _ = maybe!(async move { let is_absolute_path = path.is_absolute(); let did_resolve_abs_path = is_absolute_path @@ -1804,6 +1800,7 @@ impl PickerDelegate for FileFinderDelegate { anyhow::Ok(()) }) .await; + search_in_flight.store(false, atomic::Ordering::Relaxed); }) } } @@ -1823,6 +1820,27 @@ impl PickerDelegate for FileFinderDelegate { .log_err(); } + fn try_get_preview_data_for_match(&self, cx: &App) -> Option { + let m = self.matches.get(self.selected_index)?; + match m { + Match::CreateNew(project_path) => { + let path_style = self.project.read(cx).path_style(cx); + let path_highlight = gpui::HighlightStyle { + color: Some(cx.theme().colors().text_accent), + ..Default::default() + }; + let mut message = picker::HighlightedTextBuilder::default(); + message.push_plain("Create file "); + message.push_styled(project_path.path.display(path_style), path_highlight); + message.push_plain("?"); + Some(picker::PreviewUpdate::message(message.build())) + } + _ => Some(picker::PreviewUpdate::from_path( + m.abs_path(&self.project, cx)?, + )), + } + } + fn render_match( &self, ix: usize, @@ -1891,189 +1909,20 @@ impl PickerDelegate for FileFinderDelegate { ) } - fn render_editor( + fn actions_menu( &self, - editor: &Arc, - window: &mut Window, - cx: &mut Context>, - ) -> Div { - let has_search_query = self.latest_search_query.is_some(); - let is_project_scan_running = { - let worktree_store = self.project.read(cx).worktree_store(); - !worktree_store.read(cx).initial_scan_completed() - }; - - h_flex() - .flex_none() - .h_9() - .px_2p5() - .justify_between() - .border_b_1() - .border_color(cx.theme().colors().border_variant) - .child(editor.render(window, cx)) - .when(is_project_scan_running && has_search_query, |this| { - this.child( - h_flex() - .id("project-scan-indicator") - .tooltip(Tooltip::text("Project Scan in Progress…")) - .child( - Icon::new(IconName::LoadCircle) - .color(Color::Accent) - .size(IconSize::Small) - .with_rotate_animation(2), - ), - ) - }) - } - - fn render_footer(&self, _: &mut Window, cx: &mut Context>) -> Option { - let focus_handle = self.focus_handle.clone(); - - Some( - h_flex() - .w_full() - .p_1p5() - .justify_between() - .border_t_1() - .border_color(cx.theme().colors().border_variant) - .child( - PopoverMenu::new("filter-menu-popover") - .with_handle(self.filter_popover_menu_handle.clone()) - .attach(gpui::Anchor::BottomRight) - .anchor(gpui::Anchor::BottomLeft) - .offset(gpui::Point { - x: px(1.0), - y: px(1.0), - }) - .trigger_with_tooltip( - IconButton::new("filter-trigger", IconName::Sliders) - .icon_size(IconSize::Small) - .icon_size(IconSize::Small) - .toggle_state(self.include_ignored.unwrap_or(false)) - .when(self.include_ignored.is_some(), |this| { - this.indicator(Indicator::dot().color(Color::Info)) - }), - { - let focus_handle = focus_handle.clone(); - move |_window, cx| { - Tooltip::for_action_in( - "Filter Options", - &ToggleFilterMenu, - &focus_handle, - cx, - ) - } - }, - ) - .menu({ - let focus_handle = focus_handle.clone(); - let include_ignored = self.include_ignored; - - move |window, cx| { - Some(ContextMenu::build(window, cx, { - let focus_handle = focus_handle.clone(); - move |menu, _, _| { - menu.context(focus_handle.clone()) - .header("Filter Options") - .toggleable_entry( - "Include Ignored Files", - include_ignored.unwrap_or(false), - ui::IconPosition::End, - Some(ToggleIncludeIgnored.boxed_clone()), - move |window, cx| { - window.focus(&focus_handle, cx); - window.dispatch_action( - ToggleIncludeIgnored.boxed_clone(), - cx, - ); - }, - ) - } - })) - } - }), - ) - .child( - h_flex() - .gap_0p5() - .child( - PopoverMenu::new("split-menu-popover") - .with_handle(self.split_popover_menu_handle.clone()) - .attach(gpui::Anchor::BottomRight) - .anchor(gpui::Anchor::BottomLeft) - .offset(gpui::Point { - x: px(1.0), - y: px(1.0), - }) - .trigger( - ButtonLike::new("split-trigger") - .child(Label::new("Split…")) - .selected_style(ButtonStyle::Tinted(TintColor::Accent)) - .child( - KeyBinding::for_action_in( - &ToggleSplitMenu, - &focus_handle, - cx, - ) - .size(rems_from_px(12.)), - ), - ) - .menu({ - let focus_handle = focus_handle.clone(); - - move |window, cx| { - Some(ContextMenu::build(window, cx, { - let focus_handle = focus_handle.clone(); - move |menu, _, _| { - menu.context(focus_handle) - .action( - "Split Left", - pane::SplitLeft::default().boxed_clone(), - ) - .action( - "Split Right", - pane::SplitRight::default().boxed_clone(), - ) - .action( - "Split Up", - pane::SplitUp::default().boxed_clone(), - ) - .action( - "Split Down", - pane::SplitDown::default().boxed_clone(), - ) - } - })) - } - }), - ) - .child( - Button::new("open-without-dismiss", "Keep Open") - .key_binding( - KeyBinding::for_action_in( - &OpenWithoutDismiss, - &focus_handle, - cx, - ) - .map(|kb| kb.size(rems_from_px(12.))), - ) - .on_click(|_, window, cx| { - window.dispatch_action(OpenWithoutDismiss.boxed_clone(), cx) - }), - ) - .child( - Button::new("open-selection", "Open") - .key_binding( - KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx) - .map(|kb| kb.size(rems_from_px(12.))), - ) - .on_click(|_, window, cx| { - window.dispatch_action(menu::Confirm.boxed_clone(), cx) - }), - ), - ) - .into_any(), - ) + _window: &mut Window, + _cx: &mut Context>, + ) -> Vec { + vec![ + picker::PickerAction::header("Split…"), + picker::PickerAction::button("Left", pane::SplitLeft::default().boxed_clone()), + picker::PickerAction::button("Right", pane::SplitRight::default().boxed_clone()), + picker::PickerAction::button("Up", pane::SplitUp::default().boxed_clone()), + picker::PickerAction::button("Down", pane::SplitDown::default().boxed_clone()), + picker::PickerAction::separator(), + picker::PickerAction::button("Open File", menu::Confirm.boxed_clone()), + ] } } diff --git a/crates/file_finder/src/file_finder_tests.rs b/crates/file_finder/src/file_finder_tests.rs index 5dddaf3760..bc80041707 100644 --- a/crates/file_finder/src/file_finder_tests.rs +++ b/crates/file_finder/src/file_finder_tests.rs @@ -198,7 +198,7 @@ async fn test_matching_paths(cx: &mut TestAppContext) { let (picker, workspace, cx) = build_find_picker(project, cx); - cx.simulate_input("bna"); + simulate_input(cx, "bna"); picker.update(cx, |picker, _| { assert_eq!(picker.delegate.matches.len(), 3); }); @@ -279,7 +279,7 @@ async fn test_matching_paths_with_colon(cx: &mut TestAppContext) { let (picker, _, cx) = build_find_picker(project, cx); // 'foo:' matches both files - cx.simulate_input("foo:"); + simulate_input(cx, "foo:"); picker.update(cx, |picker, _| { assert_eq!(picker.delegate.matches.len(), 3); assert_match_at_position(picker, 0, "foo.rs"); @@ -287,7 +287,7 @@ async fn test_matching_paths_with_colon(cx: &mut TestAppContext) { }); // 'foo:b' matches one of the files - cx.simulate_input("b"); + simulate_input(cx, "b"); picker.update(cx, |picker, _| { assert_eq!(picker.delegate.matches.len(), 2); assert_match_at_position(picker, 0, "foo:bar.rs"); @@ -296,7 +296,7 @@ async fn test_matching_paths_with_colon(cx: &mut TestAppContext) { cx.dispatch_action(editor::actions::Backspace); // 'foo:1' matches both files, specifying which row to jump to - cx.simulate_input("1"); + simulate_input(cx, "1"); picker.update(cx, |picker, _| { assert_eq!(picker.delegate.matches.len(), 3); assert_match_at_position(picker, 0, "foo.rs"); @@ -324,7 +324,7 @@ async fn test_unicode_paths(cx: &mut TestAppContext) { let (picker, workspace, cx) = build_find_picker(project, cx); - cx.simulate_input("g"); + simulate_input(cx, "g"); picker.update(cx, |picker, _| { assert_eq!(picker.delegate.matches.len(), 2); assert_match_at_position(picker, 1, "g"); @@ -432,7 +432,7 @@ async fn test_complex_path(cx: &mut TestAppContext) { let (picker, workspace, cx) = build_find_picker(project, cx); - cx.simulate_input("t"); + simulate_input(cx, "t"); picker.update(cx, |picker, _| { assert_eq!(picker.delegate.matches.len(), 2); assert_eq!( @@ -1433,7 +1433,7 @@ async fn test_history_items_uniqueness_for_multiple_worktree(cx: &mut TestAppCon }); let picker = open_file_picker(&workspace, cx); - cx.simulate_input("package.json"); + simulate_input(cx, "package.json"); picker.update(cx, |finder, _| { let matches = &finder.delegate.matches.matches; @@ -1990,7 +1990,7 @@ async fn test_history_match_positions(cx: &mut gpui::TestAppContext) { assert_eq!(history.len(), 1); let picker = open_file_picker(&workspace, cx); - cx.simulate_input("fir"); + simulate_input(cx, "fir"); picker.update_in(cx, |finder, window, cx| { let matches = &finder.delegate.matches.matches; assert_matches!( @@ -3358,7 +3358,7 @@ async fn test_history_items_vs_very_good_external_match(cx: &mut gpui::TestAppCo let finder = open_file_picker(&workspace, cx); let query = "collab_ui"; - cx.simulate_input(query); + simulate_input(cx, query); finder.update(cx, |picker, _| { let search_entries = collect_search_matches(picker).search_paths_only(); assert_eq!( @@ -3423,7 +3423,7 @@ async fn test_nonexistent_history_items_not_shown(cx: &mut gpui::TestAppContext) cx.run_until_parked(); let picker = open_file_picker(&workspace, cx); - cx.simulate_input("rs"); + simulate_input(cx, "rs"); picker.update(cx, |picker, _| { assert_eq!( @@ -3461,7 +3461,7 @@ async fn test_search_results_refreshed_on_worktree_updates(cx: &mut gpui::TestAp // Initial state let picker = open_file_picker(&workspace, cx); - cx.simulate_input("rs"); + simulate_input(cx, "rs"); picker.update(cx, |finder, _| { assert_eq!(finder.delegate.matches.len(), 3); assert_match_at_position(finder, 0, "lib.rs"); @@ -3554,7 +3554,7 @@ async fn test_search_results_refreshed_on_standalone_file_creation(cx: &mut gpui // it. Close the finder without confirming and use CloseActiveItem to close the file instead. let initial_history = { let picker = open_file_picker(&workspace, cx); - cx.simulate_input("new"); + simulate_input(cx, "new"); let history_items = picker.update(cx, |finder, _| { assert_eq!( finder.delegate.matches.len(), @@ -3629,7 +3629,7 @@ async fn test_search_results_refreshed_on_adding_and_removing_worktrees( // Initial state let picker = open_file_picker(&workspace, cx); - cx.simulate_input("rs"); + simulate_input(cx, "rs"); picker.update(cx, |finder, _| { assert_eq!(finder.delegate.matches.len(), 3); assert_match_at_position(finder, 0, "bar.rs"); @@ -3761,7 +3761,7 @@ async fn test_history_items_uniqueness_for_multiple_worktree_open_all_files( }); let picker = open_file_picker(&workspace, cx); - cx.simulate_input("package.json"); + simulate_input(cx, "package.json"); picker.update(cx, |finder, _| { let matches = &finder.delegate.matches.matches; @@ -3854,7 +3854,7 @@ async fn test_selected_match_stays_selected_after_matches_refreshed(cx: &mut gpu // Initial state let picker = open_file_picker(&workspace, cx); - cx.simulate_input("file"); + simulate_input(cx, "file"); let selected_index = 3; // Checking only the filename, not the whole path let selected_file = format!("file_{}.txt", 10 + selected_index); @@ -3914,7 +3914,7 @@ async fn test_first_match_selected_if_previous_one_is_not_in_the_match_list( // Initial state let picker = open_file_picker(&workspace, cx); - cx.simulate_input("file"); + simulate_input(cx, "file"); // Select even/file_2.txt cx.dispatch_action(SelectNext); @@ -4179,6 +4179,7 @@ async fn test_repeat_toggle_action(cx: &mut gpui::TestAppContext) { picker.update_matches(".txt".to_string(), window, cx) }); + cx.executor().advance_clock(SEARCH_DEBOUNCE); cx.run_until_parked(); picker.update(cx, |picker, _| { @@ -4220,8 +4221,7 @@ async fn test_open_without_dismiss_keeps_finder_open(cx: &mut TestAppContext) { let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; let (picker, workspace, cx) = build_find_picker(project, cx); - cx.simulate_input("file"); - cx.run_until_parked(); + simulate_input(cx, "file"); picker.update(cx, |picker, _| { assert!( picker.delegate.matches.len() >= 3, @@ -4277,8 +4277,7 @@ async fn test_open_without_dismiss_opens_multiple_files(cx: &mut TestAppContext) let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; let (_picker, workspace, cx) = build_find_picker(project, cx); - cx.simulate_input("a"); - cx.run_until_parked(); + simulate_input(cx, "a"); // Open the first match and stay in the finder. cx.dispatch_action(OpenWithoutDismiss); @@ -4343,8 +4342,7 @@ async fn test_open_without_dismiss_then_confirm_closes_finder(cx: &mut TestAppCo let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; let (picker, workspace, cx) = build_find_picker(project, cx); - cx.simulate_input("t"); - cx.run_until_parked(); + simulate_input(cx, "t"); picker.update(cx, |picker, _| { assert!(picker.delegate.matches.len() >= 2); }); @@ -4414,7 +4412,7 @@ async fn open_queried_buffer( cx: &mut gpui::VisualTestContext, ) -> Vec { let picker = open_file_picker(workspace, cx); - cx.simulate_input(input); + simulate_input(cx, input); let history_items = picker.update(cx, |finder, _| { assert_eq!( @@ -4427,6 +4425,10 @@ async fn open_queried_buffer( }); cx.dispatch_action(Confirm); + // Opening the buffer can trigger worktree updates that schedule a debounced + // refresh; advance past it so a deferred confirm (confirm_on_update) runs. + cx.executor().advance_clock(SEARCH_DEBOUNCE); + cx.run_until_parked(); cx.read(|cx| { let active_editor = workspace.read(cx).active_item_as::(cx).unwrap(); @@ -4480,6 +4482,17 @@ fn open_file_picker( active_file_picker(workspace, cx) } +/// Type `input` into the file finder and then let the debounced search run. +/// +/// `update_matches` delays the actual search by [`SEARCH_DEBOUNCE`] (see its +/// doc comment), and `run_until_parked` does not advance the clock, so tests +/// must move time forward for the search to execute. +fn simulate_input(cx: &mut VisualTestContext, input: &str) { + cx.simulate_input(input); + cx.executor().advance_clock(SEARCH_DEBOUNCE); + cx.run_until_parked(); +} + #[track_caller] fn active_file_picker( workspace: &Entity, @@ -4631,7 +4644,7 @@ async fn test_filename_precedence(cx: &mut TestAppContext) { let project = Project::test(app_state.fs.clone(), [path!("/src").as_ref()], cx).await; let (picker, _, cx) = build_find_picker(project, cx); - cx.simulate_input("layout"); + simulate_input(cx, "layout"); picker.update(cx, |finder, _| { let search_matches = collect_search_matches(finder).search_paths_only(); @@ -4747,7 +4760,7 @@ async fn test_clear_navigation_history(cx: &mut TestAppContext) { // Verify that file finder shows history items let picker = open_file_picker(&workspace, cx); - cx.simulate_input("fir"); + simulate_input(cx, "fir"); picker.update(cx, |finder, _| { let matches = collect_search_matches(finder); assert!( @@ -4783,7 +4796,7 @@ async fn test_clear_navigation_history(cx: &mut TestAppContext) { // Verify that file finder no longer shows history items let picker = open_file_picker(&workspace, cx); - cx.simulate_input("fir"); + simulate_input(cx, "fir"); picker.update(cx, |finder, _| { let matches = collect_search_matches(finder); assert!( diff --git a/crates/git_ui/src/branch_picker.rs b/crates/git_ui/src/branch_picker.rs index 86ab692e03..43158a32b2 100644 --- a/crates/git_ui/src/branch_picker.rs +++ b/crates/git_ui/src/branch_picker.rs @@ -164,7 +164,6 @@ enum BranchListStyle { } pub struct BranchList { - width: Rems, pub picker: Entity>, picker_focus_handle: FocusHandle, _subscriptions: Vec, @@ -278,6 +277,9 @@ impl BranchList { let picker = cx.new(|cx| { Picker::uniform_list(delegate, window, cx) + .minimum_results_width(width) + .height(rems(24.)) + .no_vertical_padding() .show_scrollbar(true) .modal(!embedded) }); @@ -340,7 +342,6 @@ impl BranchList { Self { picker, picker_focus_handle, - width, _subscriptions: subscriptions, embedded, } @@ -444,7 +445,6 @@ impl Render for BranchList { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { v_flex() .key_context("GitBranchSelector") - .w(self.width) .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed)) .on_action(cx.listener(Self::handle_delete)) .on_action(cx.listener(Self::handle_force_delete)) @@ -1047,6 +1047,10 @@ impl BranchListDelegate { impl PickerDelegate for BranchListDelegate { type ListItem = ListItem; + fn name() -> &'static str { + "branch picker" + } + fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { match self.state { PickerState::List | PickerState::NewRemote | PickerState::NewBranch => { @@ -2085,7 +2089,6 @@ mod tests { BranchList { picker, picker_focus_handle, - width: rems(34.), _subscriptions: vec![_subscription], embedded: false, } diff --git a/crates/git_ui/src/git_graph.rs b/crates/git_ui/src/git_graph.rs index 4771654704..36b63401c4 100644 --- a/crates/git_ui/src/git_graph.rs +++ b/crates/git_ui/src/git_graph.rs @@ -139,6 +139,10 @@ struct CommitTagPickerDelegate { impl PickerDelegate for CommitTagPickerDelegate { type ListItem = ListItem; + fn name() -> &'static str { + "commit-tag" + } + fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { "Copy Tag".into() } diff --git a/crates/git_ui/src/picker_prompt.rs b/crates/git_ui/src/picker_prompt.rs index 14daedda61..4426087683 100644 --- a/crates/git_ui/src/picker_prompt.rs +++ b/crates/git_ui/src/picker_prompt.rs @@ -118,6 +118,10 @@ impl PickerPromptDelegate { impl PickerDelegate for PickerPromptDelegate { type ListItem = ListItem; + fn name() -> &'static str { + "picker prompt" + } + fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { self.prompt.clone() } diff --git a/crates/git_ui/src/repository_selector.rs b/crates/git_ui/src/repository_selector.rs index 463540de90..54303bca6b 100644 --- a/crates/git_ui/src/repository_selector.rs +++ b/crates/git_ui/src/repository_selector.rs @@ -1,7 +1,6 @@ use crate::git_status_icon; use git::status::{FileStatus, StatusCode, TrackedStatus, UnmergedStatus, UnmergedStatusCode}; use gpui::{App, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Task, WeakEntity}; -use itertools::Itertools; use picker::{Picker, PickerDelegate, PickerEditorPosition}; use project::{Project, git_store::Repository}; use std::sync::Arc; @@ -25,7 +24,6 @@ pub fn open( } pub struct RepositorySelector { - width: Rems, picker: Entity>, } @@ -51,13 +49,6 @@ impl RepositorySelector { }); let filtered_repositories = repository_entries.clone(); - let widest_item_ix = repository_entries.iter().position_max_by(|a, b| { - a.read(cx) - .display_name() - .len() - .cmp(&b.read(cx).display_name().len()) - }); - let active_repository = git_store.read(cx).active_repository(); let selected_index = active_repository .as_ref() @@ -73,12 +64,13 @@ impl RepositorySelector { let picker = cx.new(|cx| { Picker::uniform_list(delegate, window, cx) - .widest_item(widest_item_ix) - .max_height(Some(rems(20.).into())) + .minimum_results_width(width) + .height(rems(20.)) + .no_vertical_padding() .show_scrollbar(true) }); - RepositorySelector { picker, width } + RepositorySelector { picker } } } @@ -125,7 +117,6 @@ impl Render for RepositorySelector { fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { div() .key_context("GitRepositorySelector") - .w(self.width) .child(self.picker.clone()) } } @@ -159,6 +150,10 @@ impl RepositorySelectorDelegate { impl PickerDelegate for RepositorySelectorDelegate { type ListItem = ListItem; + fn name() -> &'static str { + "repository selector" + } + fn match_count(&self) -> usize { self.filtered_repositories.len() } diff --git a/crates/git_ui/src/stash_picker.rs b/crates/git_ui/src/stash_picker.rs index 190fca9fa5..feaaaf7ab9 100644 --- a/crates/git_ui/src/stash_picker.rs +++ b/crates/git_ui/src/stash_picker.rs @@ -128,6 +128,9 @@ impl StashList { let delegate = StashListDelegate::new(repository, workspace, window, cx); let picker = cx.new(|cx| { Picker::uniform_list(delegate, window, cx) + .minimum_results_width(width) + .height(rems(24.)) + .no_vertical_padding() .show_scrollbar(true) .modal(!embedded) }); @@ -369,6 +372,10 @@ impl StashListDelegate { impl PickerDelegate for StashListDelegate { type ListItem = ListItem; + fn name() -> &'static str { + "stash picker" + } + fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { "Select a stash…".into() } diff --git a/crates/git_ui/src/worktree_picker.rs b/crates/git_ui/src/worktree_picker.rs index 4cefbde508..a8e18ed9e3 100644 --- a/crates/git_ui/src/worktree_picker.rs +++ b/crates/git_ui/src/worktree_picker.rs @@ -129,7 +129,9 @@ impl WorktreePicker { .list_measure_all() .show_scrollbar(true) .modal(false) - .max_height(Some(rems(20.).into())) + .minimum_results_width(rems(34.)) + .height(rems(20.)) + .no_vertical_padding() }); let picker_focus_handle = picker.focus_handle(cx); @@ -732,6 +734,10 @@ impl WorktreePickerDelegate { impl PickerDelegate for WorktreePickerDelegate { type ListItem = AnyElement; + fn name() -> &'static str { + "worktree picker" + } + fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { "Select or type to create a worktree…".into() } diff --git a/crates/gpui/src/geometry.rs b/crates/gpui/src/geometry.rs index e5951a1296..05dafbf91e 100644 --- a/crates/gpui/src/geometry.rs +++ b/crates/gpui/src/geometry.rs @@ -9,7 +9,7 @@ use refineable::Refineable; use schemars::{JsonSchema, json_schema}; use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; use std::borrow::Cow; -use std::ops::Range; +use std::ops::{AddAssign, Range}; use std::{ cmp::{self, PartialOrd}, fmt::{self, Display}, @@ -3238,10 +3238,16 @@ impl MulAssign for ScaledPixels { pub struct Rems(pub f32); impl Rems { + /// A length of zero. + pub const ZERO: Self = Self(0.0); /// Convert this Rem value to pixels. pub fn to_pixels(self, rem_size: Pixels) -> Pixels { self * rem_size } + /// Convert from pixels to Rem + pub fn from_pixels(length: Pixels, window: &gpui::Window) -> Self { + Self(length / window.rem_size()) + } } impl Mul for Rems { @@ -3252,6 +3258,12 @@ impl Mul for Rems { } } +impl AddAssign for Rems { + fn add_assign(&mut self, rhs: Rems) { + self.0 += rhs.0 + } +} + impl Display for Rems { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}rem", self.0) diff --git a/crates/gpui/src/key_dispatch.rs b/crates/gpui/src/key_dispatch.rs index fee75d5dad..8188d76294 100644 --- a/crates/gpui/src/key_dispatch.rs +++ b/crates/gpui/src/key_dispatch.rs @@ -724,6 +724,66 @@ mod tests { assert!(highest.is_some_and(|binding| binding.action.partial_eq(&TestAction))); } + /// Models the picker preview footer scenario: a picker action is bound in + /// `Picker > Editor`, but a base keymap binds the same chord to an editor + /// action in `Editor`. `Picker > Editor` and `Editor` resolve at the same + /// context depth, so at equal depth precedence is decided purely by load + /// order (later wins). Because base keymaps load after the default keymap, + /// the picker binding is shadowed unless it is (re)bound by an overlay that + /// loads after the base keymap - which is exactly what + /// `keymaps/specific-overrides*.json` does. + #[test] + fn test_overlay_after_base_restores_shadowed_picker_binding() { + // SecondaryTestAction stands in for the editor/base action (e.g. + // editor::AddSelectionBelow), TestAction for the picker action. + let contexts = vec![ + KeyContext::parse("Picker").unwrap(), + KeyContext::parse("Editor").unwrap(), + ]; + + // Default keymap (picker binding) followed by a base keymap that binds + // the same chord to an editor action: the base binding wins and the + // picker action is shadowed, so its footer tooltip renders no shortcut. + let shadowed = test_dispatch_tree(vec![ + KeyBinding::new("ctrl-alt-down", TestAction, Some("Picker > Editor")), + KeyBinding::new("ctrl-alt-down", SecondaryTestAction, Some("Editor")), + ]); + let highest = shadowed.highest_precedence_binding_for_action(&TestAction, &contexts); + assert!( + highest.is_none(), + "picker binding should be shadowed by the base editor binding" + ); + + // Re-binding the picker action in an overlay loaded after the base keymap + // restores it as the resolved binding. + let fixed = test_dispatch_tree(vec![ + KeyBinding::new("ctrl-alt-down", TestAction, Some("Picker > Editor")), + KeyBinding::new("ctrl-alt-down", SecondaryTestAction, Some("Editor")), + // overlay loaded last: + KeyBinding::new("ctrl-alt-down", TestAction, Some("Picker > Editor")), + ]); + let highest = fixed.highest_precedence_binding_for_action(&TestAction, &contexts); + assert!( + highest.is_some_and(|binding| binding.action.partial_eq(&TestAction)), + "overlay loaded after base should restore the picker binding" + ); + + // Conversely, putting the override in the default keymap (i.e. before the + // base keymap) does NOT help: the later base binding still wins at equal + // depth. This is why the overlay must be loaded after the base keymap. + let override_before_base = test_dispatch_tree(vec![ + KeyBinding::new("ctrl-alt-down", TestAction, Some("Picker > Editor")), + KeyBinding::new("ctrl-alt-down", TestAction, Some("Picker > Editor")), + KeyBinding::new("ctrl-alt-down", SecondaryTestAction, Some("Editor")), + ]); + let highest = + override_before_base.highest_precedence_binding_for_action(&TestAction, &contexts); + assert!( + highest.is_none(), + "an override loaded before the base binding cannot win the equal-depth tie" + ); + } + #[test] fn test_pending_has_binding_state() { let bindings = vec![ diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index 5b2b511192..413c50d262 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -43,6 +43,7 @@ use std::{ cell::Cell, cmp::{self, Ordering, Reverse}, collections::{BTreeMap, BTreeSet}, + fmt::Write as _, future::Future, iter::{self, Iterator, Peekable}, mem, @@ -618,8 +619,8 @@ pub struct HighlightedText { } #[derive(Default, Debug)] -struct HighlightedTextBuilder { - pub text: String, +pub struct HighlightedTextBuilder { + text: String, highlights: Vec<(Range, HighlightStyle)>, } @@ -692,6 +693,22 @@ impl HighlightedTextBuilder { } } + /// Append a displayable value to the text, highlighting its range with + /// `style`. + pub fn push_styled(&mut self, value: impl std::fmt::Display, style: HighlightStyle) { + let start = self.text.len(); + let _ = write!(&mut self.text, "{value}"); + let end = self.text.len(); + if end > start { + self.highlights.push((start..end, style)); + } + } + + /// Append a displayable value to the text without any highlighting. + pub fn push_plain(&mut self, value: impl std::fmt::Display) { + let _ = write!(&mut self.text, "{value}"); + } + pub fn add_text_from_buffer_range( &mut self, range: Range, diff --git a/crates/language_selector/src/language_selector.rs b/crates/language_selector/src/language_selector.rs index cd457cb50f..86e48c5636 100644 --- a/crates/language_selector/src/language_selector.rs +++ b/crates/language_selector/src/language_selector.rs @@ -198,6 +198,10 @@ impl LanguageSelectorDelegate { impl PickerDelegate for LanguageSelectorDelegate { type ListItem = ListItem; + fn name() -> &'static str { + "language selector" + } + fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { "Select a language…".into() } diff --git a/crates/line_ending_selector/src/line_ending_selector.rs b/crates/line_ending_selector/src/line_ending_selector.rs index 455807565f..9d7aa9e1f8 100644 --- a/crates/line_ending_selector/src/line_ending_selector.rs +++ b/crates/line_ending_selector/src/line_ending_selector.rs @@ -115,6 +115,10 @@ impl LineEndingSelectorDelegate { impl PickerDelegate for LineEndingSelectorDelegate { type ListItem = ListItem; + fn name() -> &'static str { + "line ending selector" + } + fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { "Select a line ending…".into() } diff --git a/crates/onboarding/src/base_keymap_picker.rs b/crates/onboarding/src/base_keymap_picker.rs index 63a2894a93..c989ed3ffc 100644 --- a/crates/onboarding/src/base_keymap_picker.rs +++ b/crates/onboarding/src/base_keymap_picker.rs @@ -102,6 +102,10 @@ impl BaseKeymapSelectorDelegate { impl PickerDelegate for BaseKeymapSelectorDelegate { type ListItem = ui::ListItem; + fn name() -> &'static str { + "base keymap selector" + } + fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { "Select a base keymap...".into() } diff --git a/crates/open_path_prompt/src/open_path_prompt.rs b/crates/open_path_prompt/src/open_path_prompt.rs index 0b6989aca0..7214ba570e 100644 --- a/crates/open_path_prompt/src/open_path_prompt.rs +++ b/crates/open_path_prompt/src/open_path_prompt.rs @@ -233,7 +233,8 @@ impl OpenPathPrompt { workspace.toggle_modal(window, cx, |window, cx| { let delegate = OpenPathDelegate::new(tx, lister.clone(), creating_path, cx).show_hidden(); - let picker = Picker::uniform_list(delegate, window, cx).width(rems(34.)); + let picker = + Picker::uniform_list(delegate, window, cx).minimum_results_width(rems(34.)); let mut query = lister.default_query(cx); if let Some(suggested_name) = suggested_name { query.push_str(&suggested_name); @@ -258,6 +259,10 @@ impl OpenPathPrompt { impl PickerDelegate for OpenPathDelegate { type ListItem = ui::ListItem; + fn name() -> &'static str { + "open path prompt" + } + fn match_count(&self) -> usize { let user_input = if let DirectoryState::Create { user_input, .. } = &self.directory_state { user_input diff --git a/crates/open_path_prompt/src/open_path_prompt_tests.rs b/crates/open_path_prompt/src/open_path_prompt_tests.rs index cfb06631dd..51eb33b66e 100644 --- a/crates/open_path_prompt/src/open_path_prompt_tests.rs +++ b/crates/open_path_prompt/src/open_path_prompt_tests.rs @@ -510,7 +510,7 @@ fn build_open_path_prompt( }; cx.new(|cx| { let picker = Picker::uniform_list(delegate, window, cx) - .width(rems(34.)) + .minimum_results_width(rems(34.)) .modal(false); let query = lister.default_query(cx); picker.set_query(&query, window, cx); diff --git a/crates/outline/src/outline.rs b/crates/outline/src/outline.rs index 516ee1c547..f1badca2ea 100644 --- a/crates/outline/src/outline.rs +++ b/crates/outline/src/outline.rs @@ -7,8 +7,8 @@ use editor::{MultiBufferOffset, RowHighlightOptions, SelectionEffects}; use fuzzy_nucleo::StringMatch; use gpui::{ App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, HighlightStyle, - ParentElement, Point, Render, Styled, StyledText, Task, TextStyle, WeakEntity, Window, div, - rems, + ParentElement, Point, Rems, Render, Styled, StyledText, Task, TextStyle, WeakEntity, Window, + div, rems, }; use language::{Outline, OutlineItem, OutlineSearchEntry}; use picker::{Picker, PickerDelegate}; @@ -143,7 +143,6 @@ impl ModalView for OutlineView { impl Render for OutlineView { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { v_flex() - .w(rems(34.)) .on_action(cx.listener( |_this: &mut OutlineView, _: &zed_actions::outline::ToggleOutline, @@ -180,7 +179,12 @@ impl OutlineView { let delegate = OutlineViewDelegate::new(cx.entity().downgrade(), outline, editor, cx); let picker = cx.new(|cx| { Picker::uniform_list(delegate, window, cx) - .max_height(Some(vh(0.75, window))) + .minimum_results_width(rems(34.)) + .height(Rems::from_pixels( + window.viewport_size().height * 0.75, + window, + )) + .no_vertical_padding() .show_scrollbar(true) }); OutlineView { picker } @@ -262,6 +266,10 @@ impl OutlineViewDelegate { impl PickerDelegate for OutlineViewDelegate { type ListItem = ListItem; + fn name() -> &'static str { + "outline view" + } + fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { "Search buffer symbols...".into() } diff --git a/crates/picker/Cargo.toml b/crates/picker/Cargo.toml index 7c01e8bfaa..0bd29b0a2b 100644 --- a/crates/picker/Cargo.toml +++ b/crates/picker/Cargo.toml @@ -17,16 +17,25 @@ test-support = [] [dependencies] anyhow.workspace = true +db.workspace = true +editor.workspace = true +language.workspace = true +rope.workspace = true gpui.workspace = true menu.workspace = true +project.workspace = true schemars.workspace = true serde.workspace = true +serde_json.workspace = true +settings.workspace = true theme.workspace = true theme_settings.workspace = true ui.workspace = true ui_input.workspace = true workspace.workspace = true zed_actions.workspace = true +multi_buffer.workspace = true +util.workspace = true [dev-dependencies] editor = { workspace = true, features = ["test-support"] } diff --git a/crates/picker/src/footer.rs b/crates/picker/src/footer.rs new file mode 100644 index 0000000000..c3bb4d2200 --- /dev/null +++ b/crates/picker/src/footer.rs @@ -0,0 +1,228 @@ +use std::rc::Rc; + +use gpui::{Action, FocusHandle, Focusable}; +use ui::{ContextMenu, Divider, FluentBuilder, KeyBinding, PopoverMenu, Tooltip, prelude::*}; + +use crate::Picker; +use crate::PickerDelegate; +use crate::SetPreviewBelow; +use crate::SetPreviewRight; +use crate::ToggleActionsMenu; +use crate::TogglePreview; +use crate::preview; + +/// Line in the Actions menu on the default footer. +pub enum PickerAction { + Header(SharedString), + Separator, + Entry { + label: SharedString, + action: Box, + toggled: Option, + }, +} + +impl PickerAction { + pub fn button(label: impl Into, action: Box) -> Self { + Self::Entry { + label: label.into(), + action, + toggled: None, + } + } + + /// A non-clickable section title. + pub fn header(label: impl Into) -> Self { + Self::Header(label.into()) + } + + /// A divider between groups of entries. + pub fn separator() -> Self { + Self::Separator + } + + /// Make it possible to turn the previous item on and off + pub fn toggled(mut self, toggled: bool) -> Self { + if let Self::Entry { toggled: t, .. } = &mut self { + *t = Some(toggled); + } + self + } + + pub(crate) fn add_to_menu(&self, menu: ContextMenu, focus_handle: &FocusHandle) -> ContextMenu { + match self { + Self::Header(label) => menu.header(label), + Self::Separator => menu.separator(), + Self::Entry { + label, + action, + toggled: Some(toggled), + } => { + let dispatched = action.boxed_clone(); + let handler_focus = focus_handle.clone(); + menu.toggleable_entry( + label, + *toggled, + ui::IconPosition::End, + Some(action.boxed_clone()), + move |window, cx| { + window.focus(&handler_focus, cx); + window.dispatch_action(dispatched.boxed_clone(), cx); + }, + ) + } + Self::Entry { + label, + action, + toggled: None, + } => menu.action(label, action.boxed_clone()), + } + } +} + +impl Picker { + pub(crate) fn render_footer( + &self, + window: &mut Window, + cx: &mut Context, + ) -> Option { + if let Some(footer) = self.delegate.render_footer(window, cx) { + return Some(footer); + } + self.render_default_footer(window, cx) + } + + fn render_default_footer( + &self, + window: &mut Window, + cx: &mut Context, + ) -> Option { + let actions = self.delegate.actions_menu(window, cx); + if self.preview.is_none() && actions.is_empty() { + return None; + } + + let focus_handle = self.focus_handle(cx); + + Some( + h_flex() + .w_full() + .p_1p5() + .justify_between() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .child(div().when(self.preview.is_some(), |this| { + this.child(self.render_preview_controls(window, cx)) + })) + .when(!actions.is_empty(), |this| { + this.child(self.render_actions_button(actions.into(), focus_handle, window, cx)) + }) + .into_any_element(), + ) + } + + fn render_preview_controls( + &self, + _window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement { + let focus_handle = self.focus_handle(cx); + let toggle_focus_handle = focus_handle.clone(); + let right_focus_handle = focus_handle.clone(); + let below_focus_handle = focus_handle.clone(); + let current = self.preview_layout().unwrap_or(preview::Layout::Hidden); + let preview_visible = current != preview::Layout::Hidden; + + h_flex() + .gap_1() + .child( + Button::new("picker-preview-toggle", "Preview") + .when(preview_visible, |this| this.color(Color::Accent)) + .key_binding( + KeyBinding::for_action_in(&TogglePreview, &focus_handle, cx) + .size(rems_from_px(12.)), + ) + .tooltip(move |_window, cx| { + Tooltip::for_action_in( + "Toggle Preview", + &TogglePreview, + &toggle_focus_handle, + cx, + ) + }) + .on_click( + cx.listener(|this, _, window, cx| this.toggle_preview_visible(window, cx)), + ), + ) + // The layout buttons (preview to the right / below) are only relevant + // once the preview is showing, so hide them while it's hidden. + .when(preview_visible, |this| { + this.child(div().child(Divider::vertical().color(ui::DividerColor::Border))) + .child( + IconButton::new("picker-preview-right", IconName::DiffSplit) + .icon_size(IconSize::Small) + .toggle_state(current == preview::Layout::Right) + .tooltip(move |_window, cx| { + Tooltip::for_action_in( + "Preview to the Right", + &SetPreviewRight, + &right_focus_handle, + cx, + ) + }) + .on_click(cx.listener(|this, _, window, cx| { + this.set_preview_layout(preview::Layout::Right, window, cx) + })), + ) + .child( + IconButton::new("picker-preview-below", IconName::DiffUnified) + .icon_size(IconSize::Small) + .toggle_state(current == preview::Layout::Below) + .tooltip(move |_window, cx| { + Tooltip::for_action_in( + "Preview Below", + &SetPreviewBelow, + &below_focus_handle, + cx, + ) + }) + .on_click(cx.listener(|this, _, window, cx| { + this.set_preview_layout(preview::Layout::Below, window, cx) + })), + ) + }) + } + + fn render_actions_button( + &self, + actions: Rc<[crate::footer::PickerAction]>, + focus_handle: gpui::FocusHandle, + _window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement { + let _ = cx; + PopoverMenu::new("picker-actions-menu") + .with_handle(self.actions_menu_handle.clone()) + .attach(gpui::Anchor::TopRight) + .anchor(gpui::Anchor::BottomRight) + .trigger( + Button::new("picker-actions-trigger", "Actions…") + .key_binding( + KeyBinding::for_action_in(&ToggleActionsMenu, &focus_handle, cx) + .size(rems_from_px(12.)), + ) + .selected_style(ui::ButtonStyle::Tinted(ui::TintColor::Accent)), + ) + .menu(move |window, cx| { + let actions = Rc::clone(&actions); + let focus_handle = focus_handle.clone(); + Some(ContextMenu::build(window, cx, move |mut menu, _, _| { + menu = menu.context(focus_handle.clone()); + for item in actions.iter() { + menu = item.add_to_menu(menu, &focus_handle); + } + menu + })) + }) + } +} diff --git a/crates/picker/src/head.rs b/crates/picker/src/head.rs index 18cff64fb6..d0315e5064 100644 --- a/crates/picker/src/head.rs +++ b/crates/picker/src/head.rs @@ -34,10 +34,6 @@ impl Head { cx, ) .detach(); - // cx.subscribe_in(&editor, window, |v, _, event, window, cx| { - // edit_handler(v, event, window, cx); - // }) - // .detach(); Self::Editor(editor) } diff --git a/crates/picker/src/parts.rs b/crates/picker/src/parts.rs new file mode 100644 index 0000000000..ad6a1cd3ce --- /dev/null +++ b/crates/picker/src/parts.rs @@ -0,0 +1,27 @@ +//! Components used in multiple pickers + +use gpui::Entity; +use project::Project; +use ui::{CommonAnimationExt, Tooltip, prelude::*}; + +pub fn project_scan_indicator( + has_query: bool, + project: &Entity, + cx: &App, +) -> Option { + let is_project_scan_running = { + let worktree_store = project.read(cx).worktree_store(); + !worktree_store.read(cx).initial_scan_completed() + }; + (has_query && is_project_scan_running).then(|| { + h_flex() + .id("project-scan-indicator") + .tooltip(Tooltip::text("Project Scan in Progress…")) + .child( + Icon::new(IconName::LoadCircle) + .color(Color::Accent) + .size(IconSize::Small) + .with_rotate_animation(2), + ) + }) +} diff --git a/crates/picker/src/persistence.rs b/crates/picker/src/persistence.rs new file mode 100644 index 0000000000..3e26d5fe10 --- /dev/null +++ b/crates/picker/src/persistence.rs @@ -0,0 +1,125 @@ +use anyhow::{Context, anyhow}; +use db::kvp::KeyValueStore; +use gpui::App; +use ui::Window; + +use crate::preview; +use crate::shape::{self, Centered, RelativeHeight, RelativeWidth, Shape, ViewportFraction}; + +const PICKERS_NAMESPACE: &str = "pickers"; + +pub(crate) fn store_shape_for_this_layout( + picker_delegate: &'static str, + preview_layout: Option, + shape: shape::Centered, + window: &ui::Window, + cx: &App, +) { + let shape = PickerConfig::from_centered(shape, window); + + let kvp = KeyValueStore::global(cx); + db::write_and_log(cx, async move || { + kvp.scoped(PICKERS_NAMESPACE) + .write( + shape_key(picker_delegate, preview_layout), + serde_json::to_string(&shape).context("Could not serialize size of picker")?, + ) + .await?; + // Must be written after the shape or loading can break + // if this future is ever cancelled. + kvp.scoped(PICKERS_NAMESPACE) + .write( + last_layout_key(picker_delegate), + layout_as_str(preview_layout).to_string(), + ) + .await + }); +} + +pub(crate) fn try_load_shape( + picker_delegate: &'static str, + preview_layout: impl Into>, + cx: &App, +) -> anyhow::Result> { + let Some(shape) = KeyValueStore::global(cx) + .scoped(PICKERS_NAMESPACE) + .read(&shape_key(picker_delegate, preview_layout.into())) + .context("Could not read picker shape from KeyValueStore")? + else { + return Ok(None); + }; + + let shape = serde_json::from_str::(&shape) + .context("Could not deserialize loaded picker shape from persistence")? + .into_centered(); + Ok(Some(Shape::HorizontallyCentered(shape))) +} + +pub(crate) fn load_last_preview_layout( + picker_delegate: &'static str, + cx: &App, +) -> anyhow::Result> { + let Some(last_layout) = KeyValueStore::global(cx) + .scoped(PICKERS_NAMESPACE) + .read(&last_layout_key(picker_delegate)) + .context("Could not read last picker layout from KeyValueStore")? + else { + return Ok(None); + }; + + parse_layout(&last_layout) +} + +fn shape_key(picker_delegate: &'static str, preview_layout: Option) -> String { + format!("{picker_delegate}/{}", layout_as_str(preview_layout)) +} + +fn last_layout_key(picker_delegate: &'static str) -> String { + format!("{picker_delegate}/LAST_PREVIEW_LAYOUT") +} + +fn layout_as_str(layout: Option) -> &'static str { + match layout { + Some(preview::Layout::Hidden) => "hidden", + Some(preview::Layout::Below) => "below", + Some(preview::Layout::Right) => "right", + None => "none", + } +} + +fn parse_layout(s: &str) -> anyhow::Result> { + Ok(Some(match s { + "hidden" => preview::Layout::Hidden, + "below" => preview::Layout::Below, + "right" => preview::Layout::Right, + "none" => return Ok(None), + _ => return Err(anyhow!("Unknown layout: `{}`", s)), + })) +} + +/// A resized picker for persisting its size. All values are stored as fractions +/// of the viewport so they remain meaningful across window sizes. +#[derive(Clone, Copy, serde::Serialize, serde::Deserialize)] +pub(crate) struct PickerConfig { + width: f32, // relative fraction of viewport + height: f32, // relative fraction of viewport + preview_size: f32, // relative fraction of viewport +} + +impl PickerConfig { + pub(crate) fn from_centered(shape: Centered, window: &Window) -> Self { + PickerConfig { + width: shape.width.as_viewport_fraction(window).raw(), + height: shape.height.as_viewport_fraction(window).raw(), + preview_size: shape.preview_size.raw(), + } + } + + pub(crate) fn into_centered(self) -> Centered { + Centered { + width: RelativeWidth::viewport(self.width.clamp(0.0, 1.0)), + height: RelativeHeight::viewport(self.height.clamp(0.0, 1.0)), + preview_size: ViewportFraction::fraction(self.preview_size.clamp(0.0, 1.0)), + } + } +} diff --git a/crates/picker/src/picker.rs b/crates/picker/src/picker.rs index 78a6837bc1..93d54e10a7 100644 --- a/crates/picker/src/picker.rs +++ b/crates/picker/src/picker.rs @@ -1,30 +1,43 @@ -mod head; -pub mod highlighted_match_with_paths; -pub mod popover_menu; - use anyhow::Result; - +use gpui::Action; use gpui::{ - Action, AnyElement, App, Bounds, ClickEvent, Context, DismissEvent, EventEmitter, FocusHandle, - Focusable, Length, ListSizingBehavior, ListState, MouseButton, MouseUpEvent, Pixels, Render, - ScrollStrategy, Task, UniformListScrollHandle, Window, actions, canvas, div, list, prelude::*, - uniform_list, + AnyElement, App, Bounds, ClickEvent, Context, DismissEvent, Entity, EventEmitter, FocusHandle, + Focusable, ListSizingBehavior, ListState, MouseButton, MouseUpEvent, Pixels, ScrollStrategy, + Task, UniformListScrollHandle, Window, actions, canvas, div, list, prelude::*, uniform_list, }; use head::Head; +use project::Project; use schemars::JsonSchema; use serde::Deserialize; use std::{ cell::Cell, cell::RefCell, collections::HashMap, ops::Range, rc::Rc, sync::Arc, time::Duration, }; -use theme_settings::ThemeSettings; -use ui::{ - Color, Divider, DocumentationAside, DocumentationSide, Label, ListItem, ListItemSpacing, - ScrollAxes, Scrollbars, WithScrollbar, prelude::*, utils::WithRemSize, v_flex, -}; -use ui_input::{ErasedEditor, ErasedEditorEvent}; -use workspace::{ModalView, item::Settings}; +use ui::{ContextMenu, Divider, DocumentationAside, PopoverMenuHandle, prelude::*, v_flex}; +use ui_input::ErasedEditorEvent; +use util::ResultExt; +use workspace::ModalView; use zed_actions::editor::{MoveDown, MoveUp}; +mod footer; +mod head; +pub mod highlighted_match_with_paths; +pub mod parts; +mod persistence; +pub mod popover_menu; +mod preview; +mod render; +mod shape; + +use crate::shape::RelativeHeight; +use crate::shape::RelativeWidth; +pub use footer::PickerAction; +pub use language::{HighlightedText, HighlightedTextBuilder}; +pub use preview::MatchLocation; +pub use preview::Preview; +pub use preview::PreviewSource; +pub use preview::Update as PreviewUpdate; +pub use ui_input::ErasedEditor; + enum ElementContainer { List(ListState), UniformList(UniformListScrollHandle), @@ -45,7 +58,19 @@ actions!( picker, [ /// Confirms the selected completion in the picker. - ConfirmCompletion + ConfirmCompletion, + /// Toggles the preview between hidden and visible. + TogglePreview, + /// Shows the preview to the right of the results. + SetPreviewRight, + /// Shows the preview below the results. + SetPreviewBelow, + /// Hides the preview. + SetPreviewHidden, + /// Opens the footer's actions menu. + ToggleActionsMenu, + /// Take the picker's content and open it in a multibuffer + ToMultiBuffer, ] ); @@ -67,11 +92,14 @@ pub struct Picker { pub delegate: D, element_container: ElementContainer, head: Head, + preview: Option, pending_update_matches: Option, confirm_on_update: Option, - width: Option, - widest_item: Option, - max_height: Option, + shape: shape::Shape, + /// set through [Picker::width] and [Picker::height] + default_shape: shape::Centered, + vertical_padding: shape::VerticalPadding, + size_bounds: shape::SizeBounds, /// An external control to display a scrollbar in the `Picker`. show_scrollbar: bool, /// Whether the `Picker` is rendered as a self-contained modal. @@ -82,6 +110,10 @@ pub struct Picker { picker_bounds: Rc>>>, /// Bounds tracking for items (for aside positioning) - maps item index to bounds item_bounds: Rc>>>, + shape_loaded_from_persistence: bool, + /// Handle for the default footer's Actions popover menu. Used to keep the + /// picker open while that menu has focus. + actions_menu_handle: PopoverMenuHandle, } #[derive(Debug, Default, Clone, Copy, PartialEq)] @@ -96,6 +128,9 @@ pub enum PickerEditorPosition { pub trait PickerDelegate: Sized + 'static { type ListItem: IntoElement; + /// Name of the picker, this is the key for serialization. We could use the + /// typename of the delegate but then a rename would break persistence. + fn name() -> &'static str; fn match_count(&self) -> usize; fn selected_index(&self) -> usize; fn separators_after_indices(&self) -> Vec { @@ -200,6 +235,24 @@ pub trait PickerDelegate: Sized + 'static { PickerEditorPosition::default() } + /// Prevent closing the modal on clicking in a popover menu that portrudes out + /// This is already set by the Actions menu from the picker, this is here to + /// support extra menus added by the delegate. + fn has_another_open_menu(&self, _window: &Window, _cx: &App) -> bool { + false + } + + /// An optional control rendered at the trailing edge of the search bar, e.g. + /// a filter toggle. Returning `Some` is the easy way to add such a control; + /// for full control over the search bar, override [`Self::render_editor`]. + fn searchbar_trailer( + &self, + _window: &mut Window, + _cx: &mut Context>, + ) -> Option { + None + } + fn render_editor( &self, editor: &Arc, @@ -217,7 +270,8 @@ pub trait PickerDelegate: Sized + 'static { .flex_none() .h_9() .px_2p5() - .child(editor.render(window, cx)), + .child(div().flex_1().child(editor.render(window, cx))) + .children(self.searchbar_trailer(window, cx)), ) .when( self.editor_position() == PickerEditorPosition::Start, @@ -225,6 +279,14 @@ pub trait PickerDelegate: Sized + 'static { ) } + fn try_get_preview_data_for_match(&self, _cx: &App) -> Option { + None + } + + /// Called on the delegate when opening a preview to the side. Delegates can + /// then change how much space they use for rendering the match + fn preview_layout_changed(&mut self, _layout_is_horizontal: bool) {} + fn render_match( &self, ix: usize, @@ -241,6 +303,10 @@ pub trait PickerDelegate: Sized + 'static { None } + /// Overrides the picker's footer. + /// + /// Note there normally isn't a footer unless this is set or the picker has + /// a preview. If the picker has a preview add actions to it using picker_actions. fn render_footer( &self, _window: &mut Window, @@ -249,6 +315,15 @@ pub trait PickerDelegate: Sized + 'static { None } + /// Make this non-empty to have an `Actions` menu show up in the footer + fn actions_menu( + &self, + _window: &mut Window, + _cx: &mut Context>, + ) -> Vec { + Vec::new() + } + fn documentation_aside( &self, _window: &mut Window, @@ -292,7 +367,61 @@ impl Picker { cx, ); - Self::new(delegate, ContainerKind::UniformList, head, window, cx) + Self::new(delegate, ContainerKind::UniformList, head, None, window, cx) + } + + /// A picker similar to [`uniform_list()`](Self::uniform_list) however this picker has a + /// preview window where it shows extra information. + pub fn uniform_list_with_preview( + delegate: D, + project: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let head = Head::editor( + delegate.placeholder_text(window, cx), + Self::on_input_editor_event, + window, + cx, + ); + + let preview = Preview::new_editor(project, window, cx); + Self::new( + delegate, + ContainerKind::UniformList, + head, + Some(preview), + window, + cx, + ) + } + + /// A picker similar to [`list()`](Self::list) (variable-height rows) but with + /// a preview window. Use this instead of [`uniform_list_with_preview()`](Self::uniform_list_with_preview) + /// when [`PickerDelegate::render_match`] can return rows of different heights + /// (e.g. section headers and separators interleaved with matches). + pub fn list_with_preview( + delegate: D, + project: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let head = Head::editor( + delegate.placeholder_text(window, cx), + Self::on_input_editor_event, + window, + cx, + ); + + let preview = Preview::new_editor(project, window, cx); + Self::new( + delegate, + ContainerKind::List, + head, + Some(preview), + window, + cx, + ) } /// A picker, which displays its matches using `gpui::uniform_list`, all matches should have the same height. @@ -304,7 +433,7 @@ impl Picker { ) -> Self { let head = Head::empty(Self::on_empty_head_blur, window, cx); - Self::new(delegate, ContainerKind::UniformList, head, window, cx) + Self::new(delegate, ContainerKind::UniformList, head, None, window, cx) } /// A picker, which displays its matches using `gpui::list`, matches can have different heights. @@ -313,7 +442,7 @@ impl Picker { pub fn nonsearchable_list(delegate: D, window: &mut Window, cx: &mut Context) -> Self { let head = Head::empty(Self::on_empty_head_blur, window, cx); - Self::new(delegate, ContainerKind::List, head, window, cx) + Self::new(delegate, ContainerKind::List, head, None, window, cx) } /// A picker, which displays its matches using `gpui::list`, matches can have different heights. @@ -327,30 +456,44 @@ impl Picker { cx, ); - Self::new(delegate, ContainerKind::List, head, window, cx) + Self::new(delegate, ContainerKind::List, head, None, window, cx) } fn new( delegate: D, container: ContainerKind, head: Head, + mut preview: Option, window: &mut Window, cx: &mut Context, ) -> Self { let element_container = Self::create_element_container(container); + if let Some(preview) = &mut preview { + preview.layout = persistence::load_last_preview_layout(D::name(), cx) + .log_err() + .flatten() + .unwrap_or_default(); + }; + let shape = persistence::try_load_shape(D::name(), preview.as_ref().map(|p| p.layout), cx) + .log_err() + .flatten(); let mut this = Self { delegate, head, element_container, pending_update_matches: None, confirm_on_update: None, - width: None, - widest_item: None, - max_height: Some(rems(24.).into()), + preview, + shape_loaded_from_persistence: shape.is_some(), + shape: shape.unwrap_or_default(), + default_shape: shape::Centered::default(), + vertical_padding: shape::VerticalPadding::default(), show_scrollbar: false, is_modal: true, picker_bounds: Rc::new(Cell::new(None)), item_bounds: Rc::new(RefCell::new(HashMap::default())), + size_bounds: shape::SizeBounds::default(), + actions_menu_handle: PopoverMenuHandle::default(), }; this.update_matches("".to_string(), window, cx); // give the delegate 4ms to render the first set of suggestions. @@ -370,21 +513,61 @@ impl Picker { } } - pub fn width(mut self, width: impl Into) -> Self { - self.width = Some(width.into()); + /// Sets the width the picker appears with if the user has never resized it + /// or when the user sets it back to it's default size. + pub fn initial_width(mut self, width: impl Into) -> Self { + let width = width.into(); + self.default_shape.width = width; + if !self.shape_loaded_from_persistence { + self.shape.set_initial_width(width); + } self } - pub fn widest_item(mut self, ix: Option) -> Self { - self.widest_item = ix; + /// Sets the minimum width, the picker can not be resized smaller then this. + /// Leave unset to use sane defaults. + /// + /// This applies to the results. If there is no preview that is the whole picker. + pub fn minimum_results_width(mut self, width: impl Into) -> Self { + self.size_bounds.min_results.width = width.into(); self } - pub fn max_height(mut self, max_height: Option) -> Self { - self.max_height = max_height; + /// Sets the width the picker appears with if the user has never resized it + /// or when the user sets it back to it's default size. + /// + /// # Padding + /// By default the picker will fill this space. If you want it to only grow + /// as large as it needs and treat the height as a bound use + /// [`no_vertical_padding`] + pub fn height(mut self, height: impl Into) -> Self { + let height = height.into(); + self.default_shape.height = height; + if !self.shape_loaded_from_persistence { + self.shape.set_initial_height(height); + } self } + /// Makes the picker shrink to fit its content rather than padding out to its + /// full height when there are fewer results than fit. + pub fn no_vertical_padding(mut self) -> Self { + self.vertical_padding = shape::VerticalPadding::None; + self + } + + fn vertical_padding(&self) -> shape::VerticalPadding { + let preview_visible = self + .preview + .as_ref() + .is_some_and(|preview| preview.layout != preview::Layout::Hidden); + if preview_visible { + shape::VerticalPadding::Pad + } else { + self.vertical_padding + } + } + pub fn show_scrollbar(mut self, show_scrollbar: bool) -> Self { self.show_scrollbar = show_scrollbar; self @@ -470,6 +653,11 @@ impl Picker { if let Some(action) = self.delegate.selected_index_changed(ix, window, cx) { action(window, cx); } + if let Some(preview) = &mut self.preview + && let Some(update) = self.delegate.try_get_preview_data_for_match(cx) + { + preview.update(update, window, cx); + } if scroll_to_index { self.scroll_to_item_index(ix); } @@ -619,6 +807,42 @@ impl Picker { } } + fn set_preview_right( + &mut self, + _: &SetPreviewRight, + window: &mut Window, + cx: &mut Context, + ) { + self.set_preview_layout(preview::Layout::Right, window, cx); + } + + fn set_preview_below( + &mut self, + _: &SetPreviewBelow, + window: &mut Window, + cx: &mut Context, + ) { + self.set_preview_layout(preview::Layout::Below, window, cx); + } + + fn set_preview_hidden( + &mut self, + _: &SetPreviewHidden, + window: &mut Window, + cx: &mut Context, + ) { + self.set_preview_layout(preview::Layout::Hidden, window, cx); + } + + fn toggle_actions_menu( + &mut self, + _: &ToggleActionsMenu, + window: &mut Window, + cx: &mut Context, + ) { + self.actions_menu_handle.toggle(window, cx); + } + fn handle_click( &mut self, ix: usize, @@ -659,7 +883,12 @@ impl Picker { self.update_matches(query, window, cx); } ErasedEditorEvent::Blurred => { - if self.is_modal && window.is_window_active() { + // Opening a footer/search-bar menu blurs the editor; don't + // dismiss the picker while such a menu is open/focused. + let menu_focused = self.actions_menu_handle.is_focused(window, cx) + || self.actions_menu_handle.is_deployed() + || self.delegate.has_another_open_menu(window, cx); + if self.is_modal && window.is_window_active() && !menu_focused { self.cancel(&menu::Cancel, window, cx); } } @@ -738,6 +967,12 @@ impl Picker { cx: &mut Context, ) { let match_count = self.delegate.match_count(); + if match_count == 0 + && let Some(preview) = &mut self.preview + { + preview.clear(cx) + } + match &mut self.element_container { ElementContainer::List(state) => match scroll_behavior { ScrollBehavior::RevealSelected => { @@ -760,6 +995,11 @@ impl Picker { }, } self.pending_update_matches = None; + if let Some(update) = self.delegate.try_get_preview_data_for_match(cx) + && let Some(preview) = &mut self.preview + { + preview.update(update, window, cx); + } if let Some(secondary) = self.confirm_on_update.take() { self.do_confirm(secondary, window, cx); } @@ -821,6 +1061,11 @@ impl Picker { .top_0() .left_0(), ) + .when(!self.delegate.select_on_hover(), |this| { + this.on_mouse_down(MouseButton::Left, |_, window, _cx| { + window.prevent_default(); + }) + }) .on_click(cx.listener(move |this, event: &ClickEvent, window, cx| { this.handle_click(ix, event.modifiers().secondary(), window, cx) })) @@ -862,10 +1107,12 @@ impl Picker { } fn render_element_container(&self, cx: &mut Context) -> impl IntoElement { - let sizing_behavior = if self.max_height.is_some() { - ListSizingBehavior::Infer - } else { - ListSizingBehavior::Auto + // When the picker shrinks to fit content (`None`), the list infers its + // size from its items. When the picker pads to its full height (`Pad`), + // the list fills the available space. + let sizing_behavior = match self.vertical_padding() { + shape::VerticalPadding::None => ListSizingBehavior::Infer, + shape::VerticalPadding::Pad => ListSizingBehavior::Auto, }; match &self.element_container { @@ -879,11 +1126,8 @@ impl Picker { }), ) .with_sizing_behavior(sizing_behavior) - .when_some(self.widest_item, |el, widest_item| { - el.with_width_from_item(Some(widest_item)) - }) .flex_grow_1() - .py(DynamicSpacing::Base04.rems(cx)) + .py_1() .track_scroll(&scroll_handle) .into_any_element(), ElementContainer::List(state) => list( @@ -908,6 +1152,43 @@ impl Picker { } } } + + fn preview_layout(&self) -> Option { + self.preview.as_ref().map(|p| p.layout) + } + + fn toggle_preview(&mut self, _: &TogglePreview, window: &mut Window, cx: &mut Context) { + self.toggle_preview_visible(window, cx); + } + + fn toggle_preview_visible(&mut self, window: &mut Window, cx: &mut Context) { + let next = match self.preview_layout() { + Some(preview::Layout::Hidden) | None => preview::Layout::Right, + Some(_) => preview::Layout::Hidden, + }; + self.set_preview_layout(next, window, cx); + } + + fn set_preview_layout( + &mut self, + layout: preview::Layout, + _window: &mut Window, + cx: &mut Context, + ) { + let Some(preview) = &mut self.preview else { + return; + }; + preview.layout = layout; + if let Some(previously_resized) = persistence::try_load_shape(D::name(), layout, cx) + .log_err() + .flatten() + { + self.shape = previously_resized; + } + self.delegate + .preview_layout_changed(matches!(layout, preview::Layout::Right)); + cx.notify(); + } } #[cfg(test)] @@ -935,6 +1216,10 @@ mod tests { impl PickerDelegate for TestDelegate { type ListItem = ui::ListItem; + fn name() -> &'static str { + "test" + } + fn match_count(&self) -> usize { self.items.len() } @@ -1081,174 +1366,3 @@ mod tests { impl EventEmitter for Picker {} impl ModalView for Picker {} - -impl Render for Picker { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let ui_font_size = ThemeSettings::get_global(cx).ui_font_size(cx); - let window_size = window.viewport_size(); - let rem_size = window.rem_size(); - let is_wide_window = window_size.width / rem_size > rems_from_px(800.).0; - - let aside = self.delegate.documentation_aside(window, cx); - - let editor_position = self.delegate.editor_position(); - let picker_bounds = self.picker_bounds.clone(); - let menu = v_flex() - .key_context("Picker") - .size_full() - .when_some(self.width, |el, width| el.w(width)) - .overflow_hidden() - .child( - canvas( - move |bounds, _window, _cx| { - picker_bounds.set(Some(bounds)); - }, - |_bounds, _state, _window, _cx| {}, - ) - .size_full() - .absolute() - .top_0() - .left_0(), - ) - // This is a bit of a hack to remove the modal styling when we're rendering the `Picker` - // as a part of a modal rather than the entire modal. - // - // We should revisit how the `Picker` is styled to make it more composable. - .when(self.is_modal, |this| this.elevation_3(cx)) - .on_action(cx.listener(Self::select_next)) - .on_action(cx.listener(Self::select_previous)) - .on_action(cx.listener(Self::editor_move_down)) - .on_action(cx.listener(Self::editor_move_up)) - .on_action(cx.listener(Self::select_first)) - .on_action(cx.listener(Self::select_last)) - .on_action(cx.listener(Self::cancel)) - .on_action(cx.listener(Self::confirm)) - .on_action(cx.listener(Self::secondary_confirm)) - .on_action(cx.listener(Self::confirm_completion)) - .on_action(cx.listener(Self::confirm_input)) - .children(match &self.head { - Head::Editor(editor) => { - if editor_position == PickerEditorPosition::Start { - Some(self.delegate.render_editor(&editor.clone(), window, cx)) - } else { - None - } - } - Head::Empty(empty_head) => Some(div().child(empty_head.clone())), - }) - .when(self.delegate.match_count() > 0, |el| { - el.child( - v_flex() - .id("element-container") - .relative() - .flex_grow_1() - .when_some(self.max_height, |div, max_h| div.max_h(max_h)) - .overflow_hidden() - .children(self.delegate.render_header(window, cx)) - .child(self.render_element_container(cx)) - .when(self.show_scrollbar, |this| { - let base_scrollbar_config = Scrollbars::new(ScrollAxes::Vertical); - - this.map(|this| match &self.element_container { - ElementContainer::List(state) => this.custom_scrollbars( - base_scrollbar_config.tracked_scroll_handle(state), - window, - cx, - ), - ElementContainer::UniformList(state) => this.custom_scrollbars( - base_scrollbar_config.tracked_scroll_handle(state), - window, - cx, - ), - }) - }), - ) - }) - .when(self.delegate.match_count() == 0, |el| { - el.when_some(self.delegate.no_matches_text(window, cx), |el, text| { - el.child( - v_flex() - .flex_grow_1() - .py(DynamicSpacing::Base04.rems(cx)) - .child( - ListItem::new("empty_state") - .inset(true) - .spacing(ListItemSpacing::Sparse) - .disabled(true) - .child(Label::new(text).color(Color::Muted)), - ), - ) - }) - }) - .children(self.delegate.render_footer(window, cx)) - .children(match &self.head { - Head::Editor(editor) => { - if editor_position == PickerEditorPosition::End { - Some(self.delegate.render_editor(&editor.clone(), window, cx)) - } else { - None - } - } - Head::Empty(empty_head) => Some(div().child(empty_head.clone())), - }); - - let Some(aside) = aside else { - return menu; - }; - - let render_aside = |aside: DocumentationAside, cx: &mut Context| { - WithRemSize::new(ui_font_size) - .occlude() - .elevation_2(cx) - .w_full() - .p_2() - .overflow_hidden() - .when(is_wide_window, |this| this.max_w_96()) - .when(!is_wide_window, |this| this.max_w_48()) - .child((aside.render)(cx)) - }; - - if is_wide_window { - let aside_index = self.delegate.documentation_aside_index(); - let picker_bounds = self.picker_bounds.get(); - let item_bounds = - aside_index.and_then(|ix| self.item_bounds.borrow().get(&ix).copied()); - - let item_position = match (picker_bounds, item_bounds) { - (Some(picker_bounds), Some(item_bounds)) => { - let relative_top = item_bounds.origin.y - picker_bounds.origin.y; - let height = item_bounds.size.height; - Some((relative_top, height)) - } - _ => None, - }; - - div() - .relative() - .child(menu) - // Only render the aside once we have bounds to avoid flicker - .when_some(item_position, |this, (top, height)| { - this.child( - h_flex() - .absolute() - .when(aside.side == DocumentationSide::Left, |el| { - el.right_full().mr_1() - }) - .when(aside.side == DocumentationSide::Right, |el| { - el.left_full().ml_1() - }) - .top(top) - .h(height) - .child(render_aside(aside, cx)), - ) - }) - } else { - v_flex() - .w_full() - .gap_1() - .justify_end() - .child(render_aside(aside, cx)) - .child(menu) - } - } -} diff --git a/crates/picker/src/preview.rs b/crates/picker/src/preview.rs new file mode 100644 index 0000000000..a39818ac1a --- /dev/null +++ b/crates/picker/src/preview.rs @@ -0,0 +1,352 @@ +use std::ops::Range; +use std::path::PathBuf; +use std::sync::Arc; + +use editor::{Editor, HighlightKey, MultiBuffer, RowHighlightOptions}; +use gpui::{App, Task}; +use gpui::{AppContext, Context, Entity, TaskExt, Window}; +use language::{Buffer, HighlightedText, HighlightedTextBuilder, ToPoint}; +use project::Project; +use rope::Point; +use ui::{ActiveTheme, IntoElement, Pixels, px}; +use util::rel_path::RelPath; + +pub mod render; + +/// The preview window of a [`Picker`](crate::Picker). +/// +/// Why an enum? While most pickers will want to show just the buffer +/// there will be some: like bookmarks with description that want to display +/// other metadata too. A preview for breakpoints could be part editor part +/// showing any condition (if any) and how many times the breakpoint got hit. +pub struct Preview { + content: Entity, + pub(crate) layout: Layout, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub(crate) enum Layout { + #[default] + Hidden, + Below, + Right, +} + +impl Preview { + pub fn new_editor(project: Entity, window: &mut Window, cx: &mut App) -> Self { + Preview { + content: cx.new(|cx| EditorPreview::new(project, window, cx)), + layout: Layout::default(), + } + } + + pub fn update(&mut self, update: Update, window: &mut Window, cx: &mut impl AppContext) { + // self.content since this will become a match to support non editor or composite previews + self.content.update(cx, |content, cx| { + content.update(update, window, cx); + }); + } + + pub fn render(&self, cx: &mut App) -> impl IntoElement { + let layout = self.layout; + self.content.update(cx, |content, cx| { + content.render(layout, cx).into_any_element() + }) + } + + /// Called after a resize to let the preview do resizing logic + /// like scrolling + pub fn adjust_to_new_size(&self, window: &mut Window, cx: &mut App) { + self.content.update(cx, |content, cx| { + content.scroll_to_focus_match(window, cx); + }) + } + + /// Empty the preview and show a placeholder message + pub(crate) fn clear(&self, cx: &mut App) { + self.content.update(cx, |content, _| content.clear()) + } +} + +/// Identifies what a preview should show. +pub enum PreviewSource { + /// The buffer is identified by its absolute path; the preview opens it. + /// + /// Used by pickers (like the file finder) that only know the path of the + /// match. + Path(PathBuf), + /// The buffer is provided directly. + /// + /// Used by pickers (like the text picker) that already hold the matched + /// buffer. + Buffer(Entity), + /// No buffer to show; display this message centered in the preview instead. + /// + /// Used by pickers that have a selection without a previewable buffer (like + /// the file finder's "create new file" entry). Built as a [`HighlightedText`] + /// so callers can emphasize parts of the message (e.g. a file path). + Message(HighlightedText), +} + +pub struct MatchLocation { + /// The location of the match (for highlighting) + pub anchor_range: Range, + /// The location of the match as an offset (for scrolling) + pub range: Range, +} + +/// An update for the [`Preview`] window of a [`Picker`](crate::Picker). +pub struct Update { + /// Where to source the buffer to preview. + pub source: PreviewSource, + /// The location to highlight and scroll to, if any. + pub match_location: Option, +} + +impl Update { + /// Preview the buffer at `abs_path` without highlighting anything. + pub fn from_path(abs_path: PathBuf) -> Self { + Self { + source: PreviewSource::Path(abs_path), + match_location: None, + } + } + + /// Show `message` centered in the preview instead of a buffer. + /// + /// The message is a [`HighlightedText`], so parts of it (e.g. a file path) + /// can be emphasized. + pub fn message(message: HighlightedText) -> Self { + Self { + source: PreviewSource::Message(message), + match_location: None, + } + } + + /// Preview `buffer`, highlighting and scrolling to `highlight`. + pub fn from_buffer(buffer: Entity, highlight: MatchLocation) -> Self { + Self { + source: PreviewSource::Buffer(buffer), + match_location: Some(highlight), + } + } +} + +struct SearchMatchLineHighlight; + +pub struct EditorPreview { + project: Entity, + current_path: Option>, + /// When set show a text message instead of a preview + message: Option, + preview_editor: Entity, +} + +impl EditorPreview { + fn new(project: Entity, window: &mut Window, cx: &mut Context) -> Self { + let preview_editor = cx.new(|cx: &mut Context| { + let capability = language::Capability::ReadWrite; // Later narrowed per buffer + let multi_buffer = cx.new(|_| MultiBuffer::without_headers(capability)); + let mut editor = Editor::for_multibuffer(multi_buffer, None, window, cx); + + // We want editing to happen in the multibuffer not in the modal. The editor acts + // as one big button. + editor.set_read_only(true); + editor.set_input_enabled(false); + editor.scroll_manager.set_forbid_vertical_scroll(true); + editor.disable_scrollbars_and_minimap(window, cx); + editor.disable_inline_diagnostics(); + editor.disable_diagnostics(cx); + editor.disable_expand_excerpt_buttons(cx); + editor.disable_mouse_wheel_zoom(); + editor.set_show_gutter(true, cx); // needed for line numbers + editor.set_show_line_numbers(true, cx); + editor.set_show_breakpoints(false, cx); + editor.set_show_bookmarks(false, cx); + editor.set_show_code_actions(false, cx); + editor.set_show_runnables(false, cx); + editor.set_show_git_diff_gutter(false, cx); + editor.set_show_wrap_guides(false, cx); + editor.set_show_indent_guides(false, cx); + editor.set_show_cursor_when_unfocused(true, cx); + editor.set_soft_wrap_mode(language::language_settings::SoftWrap::None, cx); + editor + }); + + let mut this = Self { + project, + preview_editor, + current_path: None, + message: None, + }; + this.clear(); // picker starts with no results. + this + } + + fn clear(&mut self) { + let mut message = HighlightedTextBuilder::default(); + message.push_plain("No results to preview"); + self.message = Some(message.build()); + } + + fn update(&mut self, update: Update, window: &mut Window, cx: &mut Context) { + let Update { + source, + match_location: highlight, + } = update; + + match source { + PreviewSource::Path(abs_path) => { + self.update_from_path(abs_path, highlight, window, cx); + } + PreviewSource::Buffer(buffer) => { + self.update_from_buffer(buffer, highlight, window, cx); + cx.notify(); + } + PreviewSource::Message(message) => { + self.message = Some(message); + cx.notify(); + } + } + } + + fn update_from_path( + &mut self, + abs_path: PathBuf, + highlight: Option, + window: &mut Window, + cx: &mut Context, + ) { + let open_task = self.project.update(cx, |project, cx| { + match project.project_path_for_absolute_path(&abs_path, cx) { + Some(project_path) => { + if let Some(buffer) = project.get_open_buffer(&project_path, cx) { + Task::ready(Ok(buffer)) + } else { + project.open_buffer(project_path, cx) + } + } + None => project.open_local_buffer(&abs_path, cx), + } + }); + + cx.spawn_in(window, async move |this, cx| { + let buffer = open_task.await?; + this.update_in(cx, |this, window, cx| { + this.update_from_buffer(buffer, highlight, window, cx); + cx.notify(); + })?; + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } + + fn update_from_buffer( + &mut self, + buffer: Entity, + highlight: Option, + window: &mut Window, + cx: &mut Context, + ) { + self.message = None; // show the preview editor + self.current_path = buffer.read(cx).file().map(|file| file.path().clone()); + + const MIN_LINE_HEIGHT_PX: Pixels = px(6.0); + const MARGIN: u32 = 2; // scrolling can offset things; + let max_rows = (window.viewport_size().height / MIN_LINE_HEIGHT_PX).ceil() as u32 + MARGIN; + + self.preview_editor.update(cx, |editor, cx| { + let focus_row = highlight + .as_ref() + .map(|hl| { + hl.anchor_range + .start + .to_point(&buffer.read(cx).text_snapshot()) + .row + }) + .unwrap_or_default() + .min(max_rows); + + let multi_buffer = editor.buffer().clone(); + multi_buffer.update(cx, |multi_buffer, cx| { + multi_buffer.clear(cx); + multi_buffer.set_excerpts_for_buffer( + buffer, + [Point::new(focus_row, 0)..Point::new(focus_row, 0)], + max_rows, + cx, + ); + }); + + editor.clear_row_highlights::(); + editor.clear_background_highlights(HighlightKey::PickerPreview, cx); + + let Some(highlight) = highlight else { + return; + }; + + let mb = multi_buffer.read(cx).snapshot(cx); + let Some(range) = mb + .anchor_in_excerpt(highlight.anchor_range.start) + .zip(mb.anchor_in_excerpt(highlight.anchor_range.end)) + .map(|(start, end)| start..end) + else { + return; + }; + + editor.highlight_rows::( + range.clone(), + |cx| cx.theme().colors().editor_active_line_background, + RowHighlightOptions::default(), + cx, + ); + + editor.highlight_background( + HighlightKey::PickerPreview, + &[range], + |_, theme| theme.colors().search_match_background, + cx, + ); + }); + self.scroll_to_focus_match(window, cx); + } + + /// Keep the scroll as far left as possible while showing the match. + /// Vertically center the match as much as possible + fn scroll_to_focus_match(&mut self, window: &mut Window, cx: &mut Context) { + self.preview_editor.update(cx, |editor, cx| { + let display_snapshot = editor.display_snapshot(cx); + let buffer_snapshot = display_snapshot.buffer_snapshot(); + let search_range = buffer_snapshot.anchor_before(multi_buffer::MultiBufferOffset(0)) + ..buffer_snapshot.anchor_after(buffer_snapshot.len()); + + // There is at most one highlighted match in the preview, so take the + // first background highlight range as the match to focus. + let Some((range, _)) = editor + .background_highlights_in_range(search_range, &display_snapshot, cx.theme()) + .into_iter() + .next() + else { + return; + }; + + let target_row = range.start.row().0 as f64; + let centered_y = editor + .visible_line_count() + .map_or(target_row, |visible_lines| { + (target_row - (visible_lines - 1.) / 2.).max(0.) + }); + + let start_column = range.start.column() as f64; + let end_column = range.end.column() as f64; + let centered_x = editor.visible_column_count().map_or(0., |visible_columns| { + let min_x_for_end = (end_column - visible_columns + 1.).max(0.); + min_x_for_end.min(start_column) + }); + + editor.scroll_manager.set_forbid_vertical_scroll(false); + editor.set_scroll_position(gpui::Point::new(centered_x, centered_y), window, cx); + editor.scroll_manager.set_forbid_vertical_scroll(true); + }) + } +} diff --git a/crates/picker/src/preview/render.rs b/crates/picker/src/preview/render.rs new file mode 100644 index 0000000000..4733c8ebf5 --- /dev/null +++ b/crates/picker/src/preview/render.rs @@ -0,0 +1,82 @@ +use gpui::{Action, StyledText}; +use language::HighlightedText; +use ui::prelude::*; + +use crate::ToMultiBuffer; + +use crate::preview; +use crate::preview::EditorPreview; + +impl EditorPreview { + pub(crate) fn render(&self, layout: preview::Layout, cx: &App) -> impl IntoElement { + match layout { + preview::Layout::Below => self.render_preview_below(cx).into_any_element(), + preview::Layout::Right => self.render_preview_right(cx).into_any_element(), + preview::Layout::Hidden => gpui::Empty.into_any_element(), + } + } +} + +impl EditorPreview { + pub(crate) fn render_preview_right(&self, cx: &App) -> impl IntoElement { + v_flex() + .size_full() + .rounded_t_md() + .rounded_b_md() + .child(self.render_message_or_editor(cx)) + } + + fn render_preview_below(&self, cx: &App) -> impl IntoElement { + v_flex() + .size_full() + .rounded_b_md() + .child(self.render_message_or_editor(cx)) + } + + fn render_message_or_editor(&self, cx: &App) -> impl IntoElement { + if let Some(message) = &self.message { + self.render_message(message, cx).into_any_element() + } else { + debug_assert!(!self.preview_editor.read(cx).is_empty(cx)); + div() + .flex_1() + .overflow_hidden() + .child(self.editor_as_giant_button()) + .into_any_element() + } + } + + fn render_message(&self, message: &HighlightedText, cx: &App) -> impl IntoElement { + // `with_highlights` inherits the container's text style (set below), + // while keeping the message's own highlights (e.g. the file path in + // the file finder's "Create new file" entry). + let content = StyledText::new(message.text.clone()) + .with_highlights(message.highlights.iter().cloned()) + .into_any_element(); + v_flex() + .size_full() + .items_center() + .justify_center() + .font_ui(cx) + .text_ui(cx) + .text_color(Color::Muted.color(cx)) + .child(content) + } + + fn editor_as_giant_button(&self) -> impl IntoElement { + div() + .relative() + .size_full() + .child(self.preview_editor.clone()) + .child( + div() + .id("picker-preview-editor") + .absolute() + .inset_0() + .occlude() + .on_click(|_, window, cx| { + window.dispatch_action(ToMultiBuffer.boxed_clone(), cx); + }), + ) + } +} diff --git a/crates/picker/src/preview/state.rs b/crates/picker/src/preview/state.rs new file mode 100644 index 0000000000..e69de29bb2 diff --git a/crates/picker/src/render.rs b/crates/picker/src/render.rs new file mode 100644 index 0000000000..7dd9bed62a --- /dev/null +++ b/crates/picker/src/render.rs @@ -0,0 +1,370 @@ +use gpui::canvas; +use settings::Settings; +use theme_settings::ThemeSettings; +use ui::{ + ActiveTheme, Color, Context, Disableable, DocumentationAside, DocumentationSide, FluentBuilder, + InteractiveElement, IntoElement, Label, LabelCommon, ListItem, ListItemSpacing, ParentElement, + Render, ScrollAxes, Scrollbars, Styled, StyledExt, Window, WithScrollbar, div, h_flex, + rems_from_px, utils::WithRemSize, v_flex, +}; + +use crate::shape::Shape; +use crate::{ + ElementContainer, Picker, PickerDelegate, PickerEditorPosition, Preview, + head::Head, + preview::Layout, + render::window_controls::{Bottom, Left, LeftCorner, Middle, Right, RightCorner}, +}; +use crate::{persistence, preview}; + +pub mod window_controls; + +impl Render for Picker { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + self.finish_any_completed_resize(window, cx); + + let content = match &self.preview { + Some( + preview @ Preview { + layout: Layout::Below, + .. + }, + ) => self + .render_with_preview_below(preview, window, cx) + .into_any_element(), + Some( + preview @ Preview { + layout: Layout::Right, + .. + }, + ) => self + .render_with_preview_right(preview, window, cx) + .into_any_element(), + Some(Preview { + layout: Layout::Hidden, + .. + }) + | None => self.render_results(window, cx).into_any_element(), + }; + + // The border, background and rounding wrap the whole picker (results and + // preview together). When there's a preview, clip the contents so its + // corners follow the rounded border. Avoid clipping otherwise, so a + // documentation aside (which is positioned outside the picker) isn't cut + // off. + let has_preview = self.preview.is_some(); + let content = div() + .when(self.is_modal, |this| this.elevation_3(cx)) + .when(has_preview, |this| this.overflow_hidden()) + .child(content); + + let layout = self.preview_layout().unwrap_or(Layout::Hidden); + + // Embedded pickers are not resizable + div().relative().child(content).when(self.is_modal, |this| { + // While resizing offset the (parent-centered) container + this.left(self.shape.horizontal_offset(window)) + .child(self.render_resize(Left, window, cx)) + .child(self.render_resize(Right(layout), window, cx)) + .child(self.render_resize(Bottom(layout), window, cx)) + .child(self.render_resize(LeftCorner(layout), window, cx)) + .child(self.render_resize(RightCorner(layout), window, cx)) + }) + } +} + +impl Picker { + pub(crate) fn render_results( + &self, + window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement { + let ui_font_size = ThemeSettings::get_global(cx).ui_font_size(cx); + let window_size = window.viewport_size(); + let rem_size = window.rem_size(); + let is_wide_window = window_size.width / rem_size > rems_from_px(800.).0; + + let aside = self.delegate.documentation_aside(window, cx); + + let editor_position = self.delegate.editor_position(); + let picker_bounds = self.picker_bounds.clone(); + let menu = v_flex() + .key_context("Picker") + .relative() + .map(|this| { + self.shape.apply_results_size( + self.preview_layout(), + &self.size_bounds, + self.vertical_padding(), + this, + window, + ) + }) + .child( + canvas( + move |bounds, _window, _cx| { + picker_bounds.set(Some(bounds)); + }, + |_bounds, _state, _window, _cx| {}, + ) + .size_full() + .absolute() + .top_0() + .left_0(), + ) + .on_action(cx.listener(Self::select_next)) + .on_action(cx.listener(Self::select_previous)) + .on_action(cx.listener(Self::editor_move_down)) + .on_action(cx.listener(Self::editor_move_up)) + .on_action(cx.listener(Self::select_first)) + .on_action(cx.listener(Self::select_last)) + .on_action(cx.listener(Self::cancel)) + .on_action(cx.listener(Self::confirm)) + .on_action(cx.listener(Self::secondary_confirm)) + .on_action(cx.listener(Self::confirm_completion)) + .on_action(cx.listener(Self::confirm_input)) + .on_action(cx.listener(Self::toggle_preview)) + .on_action(cx.listener(Self::set_preview_right)) + .on_action(cx.listener(Self::set_preview_below)) + .on_action(cx.listener(Self::set_preview_hidden)) + .on_action(cx.listener(Self::toggle_actions_menu)) + .children(match &self.head { + Head::Editor(editor) => { + if editor_position == PickerEditorPosition::Start { + Some(h_flex().w_full().child( + div().flex_1().child(self.delegate.render_editor( + &editor.clone(), + window, + cx, + )), + )) + } else { + None + } + } + Head::Empty(empty_head) => Some(h_flex().child(empty_head.clone())), + }) + .when(self.delegate.match_count() > 0, |el| { + el.child( + v_flex() + .id("element-container") + .relative() + .flex_grow_1() + .min_h_0() + .when_some( + self.shape.results_max_height( + &self.size_bounds, + self.vertical_padding(), + window, + ), + |this, max_height| this.max_h(max_height), + ) + .overflow_hidden() + .children(self.delegate.render_header(window, cx)) + .child(self.render_element_container(cx)) + .when(self.show_scrollbar, |this| { + let base_scrollbar_config = Scrollbars::new(ScrollAxes::Vertical); + + this.map(|this| match &self.element_container { + ElementContainer::List(state) => this.custom_scrollbars( + base_scrollbar_config.tracked_scroll_handle(state), + window, + cx, + ), + ElementContainer::UniformList(state) => this.custom_scrollbars( + base_scrollbar_config.tracked_scroll_handle(state), + window, + cx, + ), + }) + }), + ) + }) + .when(self.delegate.match_count() == 0, |el| { + el.when_some(self.delegate.no_matches_text(window, cx), |el, text| { + el.child( + v_flex().flex_grow_1().py_2().child( + ListItem::new("empty_state") + .inset(true) + .spacing(ListItemSpacing::Sparse) + .disabled(true) + .child(Label::new(text).color(Color::Muted)), + ), + ) + }) + }) + .children(self.render_footer(window, cx)) + .children(match &self.head { + Head::Editor(editor) => { + if editor_position == PickerEditorPosition::End { + Some(self.delegate.render_editor(&editor.clone(), window, cx)) + } else { + None + } + } + Head::Empty(empty_head) => Some(div().child(empty_head.clone())), + }); + + let Some(aside) = aside else { + return menu; + }; + + let render_aside = |aside: DocumentationAside, cx: &mut Context| { + WithRemSize::new(ui_font_size) + .occlude() + .elevation_2(cx) + .w_full() + .p_2() + .overflow_hidden() + .when(is_wide_window, |this| this.max_w_96()) + .when(!is_wide_window, |this| this.max_w_48()) + .child((aside.render)(cx)) + }; + + if is_wide_window { + let aside_index = self.delegate.documentation_aside_index(); + let picker_bounds = self.picker_bounds.get(); + let item_bounds = + aside_index.and_then(|ix| self.item_bounds.borrow().get(&ix).copied()); + + let item_position = match (picker_bounds, item_bounds) { + (Some(picker_bounds), Some(item_bounds)) => { + let relative_top = item_bounds.origin.y - picker_bounds.origin.y; + let height = item_bounds.size.height; + Some((relative_top, height)) + } + _ => None, + }; + + div() + .relative() + .child(menu) + // Only render the aside once we have bounds to avoid flicker + .when_some(item_position, |this, (top, height)| { + this.child( + h_flex() + .absolute() + .when(aside.side == DocumentationSide::Left, |el| { + el.right_full().mr_1() + }) + .when(aside.side == DocumentationSide::Right, |el| { + el.left_full().ml_1() + }) + .top(top) + .h(height) + .child(render_aside(aside, cx)), + ) + }) + } else { + v_flex() + .w_full() + .gap_1() + .justify_end() + .child(render_aside(aside, cx)) + .child(menu) + } + } + + fn render_with_preview_below( + &self, + preview: &Preview, + window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement { + h_flex() + .relative() + .child( + v_flex() + .h(self + .shape + .height(Some(preview::Layout::Below), &self.size_bounds, window)) + .child( + div() + .h(self.shape.results_height( + preview::Layout::Below, + &self.size_bounds, + window, + )) + .overflow_hidden() + .child(self.render_results(window, cx)), + ) + .child( + div() + .h(self.shape.preview_height( + preview::Layout::Below, + &self.size_bounds, + window, + )) + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .child(preview.render(cx)), + ), + ) + .child(self.render_resize(window_controls::Middle(preview.layout), window, cx)) + } + + pub(crate) fn render_with_preview_right( + &self, + preview: &Preview, + window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement { + v_flex() + .relative() + .child( + h_flex() + .h(self + .shape + .height(Some(Layout::Right), &self.size_bounds, window)) + .child( + div() + .flex_1() + .h_full() + .overflow_hidden() + .child(self.render_results(window, cx)), + ) + .child( + div() + .w(self + .shape + .preview_width(Layout::Right, &self.size_bounds, window)) + .map(|this| { + self.shape.apply_height( + Some(Layout::Right), + &self.size_bounds, + this, + window, + ) + }) + .border_l_1() + .border_color(cx.theme().colors().border_variant) + .overflow_hidden() + .child(preview.render(cx)), + ), + ) + .child(self.render_resize(Middle(Layout::Right), window, cx)) + } + + fn finish_any_completed_resize( + &mut self, + window: &mut Window, + cx: &mut Context<'_, Picker>, + ) { + if let Shape::Resizing(pos) = self.shape + && !cx.has_active_drag() + { + let centered = Shape::centered_and_relative(pos, self.preview_layout(), window); + persistence::store_shape_for_this_layout( + D::name(), + self.preview_layout(), + centered, + window, + cx, + ); + self.shape = Shape::HorizontallyCentered(centered); + if let Some(preview) = &mut self.preview { + preview.adjust_to_new_size(window, cx); + } + } + } +} diff --git a/crates/picker/src/render/window_controls.rs b/crates/picker/src/render/window_controls.rs new file mode 100644 index 0000000000..a604a600e1 --- /dev/null +++ b/crates/picker/src/render/window_controls.rs @@ -0,0 +1,501 @@ +//! This holds the resize logic for picker windows. +//! +//! # Resizing basics: +//! We render a resize handle (`render_resize`) for each side and corner that +//! can be dragged. The `Side` trait implementations (`Left`, `Right`, +//! `Bottom`, `LeftCorner`, `RightCorner`, ...) determine where each handle is +//! placed and how a drag changes the picker's shape. +//! +//! If there is a preview to the right or below there is an additional resize +//! handle on the divider between the results and the preview. +//! +//! The resize's are div's aka rectangles that are "placed" by specifying the +//! position of their sides. When hovering over these the cursor is changed +//! so it is clear you can resize. +//! +//! We set up two callbacks in each +//! - on_drag: fires when the user starts dragging +//! - on_drag_move: runs every frame while the user is dragging +//! +//! The actual shape of the resize is updated in `on_drag_move`. When a preview +//! is active dragging the outside edge modifies the +//! +//! # Resizing persistence +//! Each picker has a 'fixed' size and tracks it's last resize. When manually +//! resized the window size is stored as a percentage of the viewport +//! width/height. The size is serialized as soon as the use lets go of the drag. +//! +//! # Diagrams & Details +//! ```txt +//! ================ CHANGING WIDTH ====================================== +//! The picker position stays constant during the drag but it is centered +//! directly after. (when the user lets go) +//! ================ DRAGGING RIGHT ====================================== +//! +//! ------------ +//! | | | <- dragging this edge right +//! | | | +//! ------------ +//! leads to: +//! -------------------- +//! | | | <- dragged on this edge +//! | | preview | +//! -------------------- +//! +//! self.w_preview = w_preview + drag +//! ================ DRAGGING LEFT ======================================= +//! +//! ------------ +//! dragging this left -> | | | +//! | | | +//! ------------ +//! leads to: +//! ------------------- +//! | list | | +//! | | | +//! ------------------- +//! ``` + +use std::{any::type_name, marker::PhantomData}; + +use gpui::{ClickEvent, Context, CursorStyle, DragMoveEvent, MouseButton, Point, Styled, Window}; +use ui::prelude::*; + +use crate::shape::{Centered, PositionAndShape, Shape, SizeBounds}; +use crate::{Picker, PickerDelegate, preview::Layout}; + +pub struct DragPreview; + +impl Render for DragPreview { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div() + } +} + +#[derive(Clone, Copy)] +struct ResizeDrag { + shape_before: PositionAndShape, + phantom_data: PhantomData, + mouse_pos_before: Point, +} + +pub(crate) trait Side: Copy + 'static { + fn id() -> &'static str { + type_name::() + } + /// The thickness of the grab strip along the picker's edge. + /// + /// Expressed in rems so it scales with the user's UI font size. + fn handle_width(window: &Window) -> Pixels { + rems(0.375).to_pixels(window.rem_size()) + } + fn handle_offset(window: &Window) -> Pixels { + Self::handle_width(window) / 2.0 + } + /// How far the grab strip is inset from the top and bottom corners so it doesn't overlap the + /// corner resize handles. + fn corner_clearance(window: &Window) -> Pixels { + rems(1.125).to_pixels(window.rem_size()) + } + /// The resize cursor for this side's handle. + fn cursor(&self) -> CursorStyle; + /// Places and sizes the grab strip along this side's edge. + fn position( + &self, + div: gpui::Stateful
, + shape: PositionAndShape, + window: &Window, + ) -> gpui::Stateful
; + fn current_position_and_shape( + &self, + shape_before: PositionAndShape, + mouse_movement: Point, + ) -> PositionAndShape; + fn clamp( + &self, + working: &mut PositionAndShape, + bounds: &SizeBounds, + layout: Option, + window: &Window, + ); + fn revert_to_default_size(&self, shape: &mut Shape, default: &Centered, window: &Window); +} + +#[derive(Clone, Copy)] +pub(crate) struct Left; +impl Side for Left { + fn cursor(&self) -> CursorStyle { + CursorStyle::ResizeColumn + } + fn position( + &self, + div: gpui::Stateful
, + _: PositionAndShape, + window: &Window, + ) -> gpui::Stateful
{ + div.top_0() + .bottom(Self::corner_clearance(window)) + .w(Self::handle_width(window)) + .left(-Self::handle_offset(window)) + } + fn current_position_and_shape( + &self, + mut shape_before: PositionAndShape, + mouse_movement: Point, + ) -> PositionAndShape { + shape_before.left += mouse_movement.x; + shape_before + } + fn clamp( + &self, + working: &mut PositionAndShape, + bounds: &SizeBounds, + layout: Option, + window: &Window, + ) { + bounds.clamp_left_edge(working, layout, window); + bounds.clamp_divider(working, layout, window); + } + fn revert_to_default_size(&self, shape: &mut Shape, default: &Centered, _window: &Window) { + shape.reset_width(default); + } +} +#[derive(Clone, Copy)] +pub(crate) struct Right(pub(crate) Layout); +impl Side for Right { + fn cursor(&self) -> CursorStyle { + CursorStyle::ResizeColumn + } + fn position( + &self, + div: gpui::Stateful
, + _: PositionAndShape, + window: &Window, + ) -> gpui::Stateful
{ + div.top_0() + .bottom(Self::corner_clearance(window)) + .w(Self::handle_width(window)) + .right(-Self::handle_offset(window)) + } + fn current_position_and_shape( + &self, + mut shape_before: PositionAndShape, + mouse_movement: Point, + ) -> PositionAndShape { + if let Layout::Right = self.0 { + shape_before.preview += mouse_movement.x; + } + shape_before.right += mouse_movement.x; + shape_before + } + fn clamp( + &self, + working: &mut PositionAndShape, + bounds: &SizeBounds, + layout: Option, + window: &Window, + ) { + bounds.clamp_right_edge(working, layout, window); + bounds.clamp_divider(working, layout, window); + } + fn revert_to_default_size(&self, shape: &mut Shape, default: &Centered, _window: &Window) { + shape.reset_width(default); + } +} + +#[derive(Clone, Copy)] +pub(crate) struct Middle(pub(crate) Layout); +impl Side for Middle { + fn cursor(&self) -> CursorStyle { + match self.0 { + Layout::Hidden => { + unreachable!("This resize handle is not drawn when the preview is hidden") + } + Layout::Below => CursorStyle::ResizeRow, + Layout::Right => CursorStyle::ResizeColumn, + } + } + + fn position( + &self, + div: gpui::Stateful
, + shape: PositionAndShape, + window: &Window, + ) -> gpui::Stateful
{ + match self.0 { + Layout::Hidden => { + unreachable!("This resize handle is not drawn when the preview is hidden") + } + Layout::Below => div + .left(Self::corner_clearance(window)) + .right(Self::corner_clearance(window)) + .h(Self::handle_width(window)) + .bottom(shape.preview - Self::handle_offset(window)), + Layout::Right => div + .top_0() + .bottom(Self::corner_clearance(window)) + .w(Self::handle_width(window)) + .right(shape.preview - Self::handle_offset(window)), + } + } + + fn current_position_and_shape( + &self, + mut shape_before: PositionAndShape, + mouse_movement: Point, + ) -> PositionAndShape { + match self.0 { + Layout::Hidden => { + unreachable!("This resize handle is not drawn when the preview is hidden") + } + Layout::Below => shape_before.preview -= mouse_movement.y, + Layout::Right => shape_before.preview -= mouse_movement.x, + } + shape_before + } + fn clamp( + &self, + working: &mut PositionAndShape, + bounds: &SizeBounds, + layout: Option, + window: &Window, + ) { + // The divider only moves the preview; the outer edges are unchanged. + bounds.clamp_divider(working, layout, window); + } + fn revert_to_default_size(&self, shape: &mut Shape, _default: &Centered, window: &Window) { + shape.center_divider(self.0, window); + } +} + +#[derive(Clone, Copy)] +pub(crate) struct Bottom(pub(crate) Layout); +impl Side for Bottom { + fn cursor(&self) -> CursorStyle { + CursorStyle::ResizeRow + } + fn position( + &self, + div: gpui::Stateful
, + _: PositionAndShape, + window: &Window, + ) -> gpui::Stateful
{ + div.left(Self::corner_clearance(window)) + .right(Self::corner_clearance(window)) + .h(Self::handle_width(window)) + .bottom(-Self::handle_offset(window)) + } + fn current_position_and_shape( + &self, + mut shape_before: PositionAndShape, + mouse_movement: Point, + ) -> PositionAndShape { + if let Layout::Below = self.0 { + shape_before.preview += mouse_movement.y; + } + shape_before.bottom += mouse_movement.y; + shape_before + } + fn clamp( + &self, + working: &mut PositionAndShape, + bounds: &SizeBounds, + layout: Option, + window: &Window, + ) { + bounds.clamp_bottom_edge(working, layout, window); + bounds.clamp_divider(working, layout, window); + } + fn revert_to_default_size(&self, shape: &mut Shape, default: &Centered, _window: &Window) { + shape.reset_height(default); + } +} +#[derive(Clone, Copy)] +pub(crate) struct LeftCorner(pub(crate) Layout); +impl Side for LeftCorner { + fn cursor(&self) -> CursorStyle { + CursorStyle::ResizeUpRightDownLeft + } + fn position( + &self, + div: gpui::Stateful
, + _: PositionAndShape, + window: &Window, + ) -> gpui::Stateful
{ + div.w(Self::handle_width(window)) + .h(Self::handle_width(window)) + .left(-Self::handle_offset(window)) + .bottom(-Self::handle_offset(window)) + } + fn current_position_and_shape( + &self, + mut shape_before: PositionAndShape, + mouse_movement: Point, + ) -> PositionAndShape { + match self.0 { + Layout::Hidden => (), + Layout::Below => shape_before.preview += mouse_movement.y, + Layout::Right => shape_before.preview += mouse_movement.x, + } + shape_before.left += mouse_movement.x; + shape_before.bottom += mouse_movement.y; + shape_before + } + fn clamp( + &self, + working: &mut PositionAndShape, + bounds: &SizeBounds, + layout: Option, + window: &Window, + ) { + bounds.clamp_left_edge(working, layout, window); + bounds.clamp_bottom_edge(working, layout, window); + bounds.clamp_divider(working, layout, window); + } + fn revert_to_default_size(&self, shape: &mut Shape, default: &Centered, _window: &Window) { + shape.reset_width(default); + shape.reset_height(default); + } +} +#[derive(Clone, Copy)] +pub(crate) struct RightCorner(pub(crate) Layout); +impl Side for RightCorner { + fn cursor(&self) -> CursorStyle { + CursorStyle::ResizeUpLeftDownRight + } + fn position( + &self, + div: gpui::Stateful
, + _: PositionAndShape, + window: &Window, + ) -> gpui::Stateful
{ + div.w(Self::handle_width(window)) + .h(Self::handle_width(window)) + .right(-Self::handle_offset(window)) + .bottom(-Self::handle_offset(window)) + } + fn current_position_and_shape( + &self, + mut shape_before: PositionAndShape, + mouse_movement: Point, + ) -> PositionAndShape { + match self.0 { + Layout::Hidden => (), + Layout::Below => shape_before.preview += mouse_movement.y, + Layout::Right => shape_before.preview += mouse_movement.x, + } + shape_before.right += mouse_movement.x; + shape_before.bottom += mouse_movement.y; + shape_before + } + fn clamp( + &self, + working: &mut PositionAndShape, + bounds: &SizeBounds, + layout: Option, + window: &Window, + ) { + bounds.clamp_right_edge(working, layout, window); + bounds.clamp_bottom_edge(working, layout, window); + bounds.clamp_divider(working, layout, window); + } + fn revert_to_default_size(&self, shape: &mut Shape, default: &Centered, _window: &Window) { + shape.reset_width(default); + shape.reset_height(default); + } +} + +impl ResizeDrag { + fn start_new( + shape: Shape, + bounds: &SizeBounds, + layout: Option, + window: &mut Window, + ) -> Self { + Self { + mouse_pos_before: window.mouse_position(), + // Before rendering we always clamp so the current shape may not be + // within SizeBounds so use a clamped one + shape_before: shape.clamped_position_and_size(layout, bounds, window), + phantom_data: PhantomData, + } + } +} + +impl Picker { + /// Resizes the picker modal by dragging the handle on the given side or corner + pub(crate) fn render_resize( + &self, + side: S, + window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement { + div() + .id(S::id()) + .absolute() + .cursor(side.cursor()) + .map(|this| { + side.position( + this, + self.shape.clamped_position_and_size( + self.preview_layout(), + &self.size_bounds, + window, + ), + window, + ) + }) + .block_mouse_except_scroll() + .on_mouse_down(MouseButton::Left, do_nothing) + .on_drag( + ResizeDrag::::start_new( + self.shape, + &self.size_bounds, + self.preview_layout(), + window, + ), + |_, _, _, cx| cx.new(|_| DragPreview), + ) + .on_drag_move::>(cx.listener( + move |this, event: &DragMoveEvent>, window, cx| { + let drag = event.drag(cx); + let delta = event.event.position - drag.mouse_pos_before; + let mut working = side.current_position_and_shape(drag.shape_before, delta); + side.clamp( + &mut working, + &this.size_bounds, + this.preview_layout(), + window, + ); + this.shape = Shape::Resizing(working); + cx.notify(); + }, + )) + .on_click(cx.listener(move |this, event: &ClickEvent, window, cx| { + this.reset_size_to_default_on_double_click(side, event, window, cx) + })) + } + + fn reset_size_to_default_on_double_click( + &mut self, + side: S, + event: &ClickEvent, + window: &mut Window, + cx: &mut Context>, + ) { + if event.click_count() < 2 { + return; + } + side.revert_to_default_size(&mut self.shape, &self.default_shape, window); + let pos = + self.shape + .clamped_position_and_size(self.preview_layout(), &self.size_bounds, window); + self.shape = Shape::Resizing(pos); + cx.notify(); + } +} + +fn do_nothing(_: &gpui::MouseDownEvent, window: &mut Window, cx: &mut App) { + window.prevent_default(); + cx.stop_propagation(); +} diff --git a/crates/picker/src/shape.rs b/crates/picker/src/shape.rs new file mode 100644 index 0000000000..04c985cb33 --- /dev/null +++ b/crates/picker/src/shape.rs @@ -0,0 +1,654 @@ +use gpui::Window; +use gpui::{Pixels, Rems, Size}; +use ui::{Div, Styled}; + +use crate::preview::Layout; + +#[derive(Debug, Clone, Copy)] +pub(crate) struct PositionAndShape { + /// Absolute position of left most side of the picker + pub(crate) left: Pixels, + /// Absolute position of right most side of the picker + pub(crate) right: Pixels, + /// Absolute position of top most side of the picker + pub(crate) top: Pixels, + /// Absolute position of bottom most side of the picker + pub(crate) bottom: Pixels, + /// Relative position of divide between results and preview, + /// either a height or a width depends on previews layoutmode. + /// Should be zero when preview is disabled or hidden + pub(crate) preview: Pixels, +} + +macro_rules! relative_size { + ($name:ident, $accessor:ident) => { + /// Size type that is the sum of a relative size to the viewport and a + /// size relative to the font size (Rems). You can + /// add/subtract/multiple/divide to your harts content but once you + /// need a single unit you must provide a window to get it. + #[derive(Debug, Clone, Copy, PartialEq)] + pub struct $name { + viewport_fraction: f32, + rems: Rems, + } + + impl From for $name { + fn from(v: Rems) -> Self { + Self::rems(v) + } + } + + impl $name { + pub const FULL: Self = Self { + viewport_fraction: 1.0, + rems: Rems::ZERO, + }; + + pub const fn viewport(fraction: f32) -> Self { + debug_assert!(fraction <= 1.0); + debug_assert!(fraction >= 0.0); + Self { + viewport_fraction: fraction.clamp(0.0, 1.0), + rems: Rems::ZERO, + } + } + + pub const fn rems(val: Rems) -> Self { + Self { + viewport_fraction: 0.0, + rems: val, + } + } + + pub fn as_pixels(&self, window: &Window) -> Pixels { + self.viewport_fraction * window.viewport_size().$accessor + + self.rems * window.rem_size() + } + + pub fn from_pixels(width: Pixels, window: &Window) -> Self { + Self { + viewport_fraction: width / window.viewport_size().$accessor, + rems: Rems::ZERO, + } + } + + pub fn as_viewport_fraction(&self, window: &Window) -> ViewportFraction { + ViewportFraction( + self.viewport_fraction + + self.rems * window.rem_size() / window.viewport_size().$accessor, + ) + } + } + + impl std::ops::Add for $name { + type Output = Self; + + fn add(self, rhs: Self) -> Self::Output { + Self { + viewport_fraction: self.viewport_fraction + rhs.viewport_fraction, + rems: self.rems + rhs.rems, + } + } + } + + impl std::ops::Sub for $name { + type Output = Self; + + fn sub(self, rhs: Self) -> Self::Output { + Self { + viewport_fraction: self.viewport_fraction - rhs.viewport_fraction, + rems: self.rems - rhs.rems, + } + } + } + + impl std::ops::Sub for $name { + type Output = Self; + + fn sub(self, rhs: Rems) -> Self::Output { + Self { + viewport_fraction: self.viewport_fraction, + rems: self.rems - rhs, + } + } + } + + impl std::ops::Div for $name { + type Output = Self; + + fn div(mut self, rhs: f32) -> Self::Output { + self.viewport_fraction /= rhs; + self.rems = Rems(self.rems.0 / rhs); + self + } + } + + impl std::ops::Mul for $name { + type Output = Self; + + fn mul(mut self, rhs: f32) -> Self::Output { + self.viewport_fraction *= rhs; + self.rems = Rems(self.rems.0 * rhs); + self + } + } + }; +} + +relative_size!(RelativeHeight, height); +relative_size!(RelativeWidth, width); + +#[derive(Debug, Clone, Copy)] +pub struct ViewportFraction(f32); + +impl ViewportFraction { + pub(crate) const ZERO: Self = Self(0.0); + pub(crate) fn fraction(v: f32) -> Self { + debug_assert!( + (0.0..=1.0).contains(&v), + "ViewportFraction must be between zero and one" + ); + Self(v) + } + + pub(crate) fn width_as_pixels(&self, window: &Window) -> Pixels { + window.viewport_size().width * self.0 + } + pub(crate) fn height_as_pixels(&self, window: &Window) -> Pixels { + window.viewport_size().height * self.0 + } + + pub(crate) fn from_height_pixels(preview: Pixels, window: &Window) -> Self { + Self(preview / window.viewport_size().height) + } + + pub(crate) fn from_width_pixels(preview: Pixels, window: &Window) -> Self { + Self(preview / window.viewport_size().width) + } + + /// Returns the fraction of the viewport that this describes. + /// Guaranteed to be between zero and one + pub(crate) fn raw(&self) -> f32 { + self.0 + } +} + +impl std::ops::Mul for ViewportFraction { + type Output = Self; + + fn mul(self, rhs: f32) -> Self::Output { + Self(self.0 * rhs) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub(crate) enum VerticalPadding { + /// The picker always fills its height even if there are no results + #[default] + Pad, + /// Picker might be shorter then it's height if there is not enough to display + None, +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct Centered { + pub(crate) width: RelativeWidth, + pub(crate) height: RelativeHeight, + pub(crate) preview_size: ViewportFraction, +} + +#[derive(Debug, Clone, Copy)] +pub(crate) enum Shape { + Resizing(PositionAndShape), + /// This may be persisted between zero and three times: + /// - for a user resized picker without preview + /// - for a user resized picker with preview below + /// - for a user resized picker with preview right + HorizontallyCentered(Centered), +} + +#[derive(Debug)] +pub struct SizeBounds { + pub(crate) max_width: RelativeWidth, + pub(crate) max_height: RelativeHeight, + /// Minimum size of the results pane. + pub(crate) min_results: Size, + /// Minimum size of the preview pane. Along the split axis this only needs to + /// be large enough to grab the divider and shrink it back. + pub(crate) min_preview: Size, +} + +impl Default for SizeBounds { + fn default() -> Self { + Self { + max_width: RelativeWidth::viewport(0.95), + // Modals are placed at 5 rems from the top we also do not want them to go + // over the lower bar so clear another 5 rems there. + max_height: (RelativeHeight::FULL - Rems(10.0)) * 0.95, + min_results: Size { + width: Rems(15.0), + height: Rems(20.0), + }, + min_preview: Size { + width: Rems(8.0), + height: Rems(6.0), + }, + } + } +} + +impl SizeBounds { + /// Minimum total picker width for the given layout, composed from the + /// results and preview minimums (they stack along the split axis and share + /// the cross axis). + fn min_width(&self, layout: Option, window: &Window) -> Pixels { + let rem = window.rem_size(); + let results = self.min_results.width * rem; + let preview = self.min_preview.width * rem; + match layout { + Some(Layout::Right) => results + preview, + Some(Layout::Below) => results.max(preview), + Some(Layout::Hidden) | None => results, + } + } + + /// Minimum total picker height for the given layout. See [`Self::min_width`]. + fn min_height(&self, layout: Option, window: &Window) -> Pixels { + let rem = window.rem_size(); + let results = self.min_results.height * rem; + let preview = self.min_preview.height * rem; + match layout { + Some(Layout::Below) => results + preview, + Some(Layout::Right) => results.max(preview), + Some(Layout::Hidden) | None => results, + } + } + + /// Clamps a width in pixels to the configured min/max width. + pub(crate) fn clamp_width( + &self, + width: Pixels, + layout: Option, + window: &Window, + ) -> Pixels { + width + .min(self.max_width.as_pixels(window)) + .max(self.min_width(layout, window)) + } + + /// Clamps a height in pixels to the configured min/max height. + pub(crate) fn clamp_height( + &self, + height: Pixels, + layout: Option, + window: &Window, + ) -> Pixels { + height + .min(self.max_height.as_pixels(window)) + .max(self.min_height(layout, window)) + } + + /// Clamps the picker's width by moving its left edge back into bounds. + /// + /// For a preview-to-the-right drag the preview is corrected by the same + /// amount any snap moves the edge, so the results pane keeps its size at the + /// boundary. + pub(crate) fn clamp_left_edge( + &self, + working: &mut PositionAndShape, + layout: Option, + window: &Window, + ) { + let target_width = self.clamp_width(working.right - working.left, layout, window); + let new_left = working.right - target_width; + let correction = new_left - working.left; + working.left = new_left; + if layout == Some(Layout::Right) { + working.preview += correction; + } + } + + /// Clamps the picker's width by moving its right edge back into bounds. + /// See [`Self::clamp_left_edge`]. + pub(crate) fn clamp_right_edge( + &self, + working: &mut PositionAndShape, + layout: Option, + window: &Window, + ) { + let target_width = self.clamp_width(working.right - working.left, layout, window); + let new_right = working.left + target_width; + let correction = new_right - working.right; + working.right = new_right; + if layout == Some(Layout::Right) { + working.preview += correction; + } + } + + /// Clamps the picker's height by moving its bottom edge back into bounds. + /// See [`Self::clamp_left_edge`]. + pub(crate) fn clamp_bottom_edge( + &self, + working: &mut PositionAndShape, + layout: Option, + window: &Window, + ) { + let target_height = self.clamp_height(working.bottom - working.top, layout, window); + let new_bottom = working.top + target_height; + let correction = new_bottom - working.bottom; + working.bottom = new_bottom; + if layout == Some(Layout::Below) { + working.preview += correction; + } + } + + /// Clamps the divider between the results and the preview so both panes stay + /// above their minimums. Runs last in each side's clamp so the divider gets + /// the final say after the outer edges have been bounded. + pub(crate) fn clamp_divider( + &self, + working: &mut PositionAndShape, + layout: Option, + window: &Window, + ) { + let rem = window.rem_size(); + let (total, min_preview, min_results) = match layout { + Some(Layout::Right) => ( + working.right - working.left, + self.min_preview.width * rem, + self.min_results.width * rem, + ), + Some(Layout::Below) => ( + working.bottom - working.top, + self.min_preview.height * rem, + self.min_results.height * rem, + ), + Some(Layout::Hidden) | None => return, + }; + let max_preview = (total - min_results).max(min_preview); + working.preview = working.preview.clamp(min_preview, max_preview); + } + + /// Clamps a whole picker rect (results + preview) into bounds: the total size + /// against the per-layout min/max, then the divider so both panes keep their + /// minimums. Width is clamped about its center, height anchored at the top. + /// + /// This is idempotent on an already-bounded rect, so it can run on every + /// render and drag-start read without shifting an in-bounds shape. + pub(crate) fn clamp_position_and_size( + &self, + working: &mut PositionAndShape, + layout: Option, + window: &Window, + ) { + let target_width = self.clamp_width(working.right - working.left, layout, window); + let center = (working.left + working.right) / 2.0; + working.left = center - target_width / 2.0; + working.right = center + target_width / 2.0; + + let target_height = self.clamp_height(working.bottom - working.top, layout, window); + working.bottom = working.top + target_height; + + self.clamp_divider(working, layout, window); + } +} + +impl Shape { + pub(crate) fn picker_position_and_size( + &self, + layout: impl Into>, + window: &Window, + ) -> PositionAndShape { + match self { + Shape::Resizing(pos) => *pos, + Shape::HorizontallyCentered(Centered { + width, + height, + preview_size, + }) => PositionAndShape { + // W V: full width xxxxx: picker modal + // -----xxxxx------ left = (V - W) / 2 + // L R right = left + W = (V/2 - W/2) + W = V/2 + W/2 + left: ((RelativeWidth::FULL - *width) / 2.0).as_pixels(window), + right: (RelativeWidth::FULL / 2.0 + *width / 2.0).as_pixels(window), + top: Pixels::ZERO, + bottom: height.as_pixels(window), + preview: match layout.into() { + Some(Layout::Below) => preview_size.height_as_pixels(window), + Some(Layout::Right) => preview_size.width_as_pixels(window), + Some(Layout::Hidden) | None => Pixels::ZERO, + }, + }, + } + } + + /// The picker rect with all bounds applied (total size and divider). This is + /// the single source of truth for sizing during render and for seeding a + /// drag, so a shape left dirty (e.g. after a layout toggle changes the + /// minimums) is sanitized identically everywhere and never jumps on the + /// first drag. + pub(crate) fn clamped_position_and_size( + &self, + layout: Option, + bounds: &SizeBounds, + window: &Window, + ) -> PositionAndShape { + let mut pos = self.picker_position_and_size(layout, window); + bounds.clamp_position_and_size(&mut pos, layout, window); + pos + } + + pub(crate) fn results_position_and_size( + &self, + layout: Layout, + bounds: &SizeBounds, + window: &Window, + ) -> PositionAndShape { + let mut pos = self.clamped_position_and_size(Some(layout), bounds, window); + + match layout { + Layout::Below => pos.bottom -= pos.preview, + Layout::Right => pos.right -= pos.preview, + Layout::Hidden => (), + } + pos + } + + pub(crate) fn preview_position_and_size( + &self, + layout: Layout, + bounds: &SizeBounds, + window: &Window, + ) -> PositionAndShape { + let mut pos = self.clamped_position_and_size(Some(layout), bounds, window); + + match layout { + Layout::Below => pos.top = pos.bottom - pos.preview, + Layout::Right => pos.left = pos.right - pos.preview, + Layout::Hidden => (), + } + pos + } + + /// How far the center of the picker has been moved during dragging + /// this allows extending it on one side without the picker centering during + /// the resize. The drag is clamped to the size bounds (see + /// [`Side::current_position_and_shape`]), so the center stays in bounds too. + pub(crate) fn horizontal_offset(&self, window: &Window) -> Pixels { + let Shape::Resizing(PositionAndShape { left, right, .. }) = self else { + return Pixels::ZERO; // picker should be centered + }; + let center = (*left + *right) / 2.0; + let viewport_center = window.viewport_size().width / 2.0; + center - viewport_center // shifting the picker by this uncenters it again + } + + pub(crate) fn apply_results_size( + &self, + layout: impl Into>, + bounds: &SizeBounds, + vertical_padding: VerticalPadding, + div: Div, + window: &Window, + ) -> Div { + let layout = layout.into(); + // Work from the total picker size (full) to keep the divider from + // growing the picker when dragged. + let full = self.clamped_position_and_size(layout, bounds, window); + let width = match layout { + Some(Layout::Right) => (full.right - full.left) - full.preview, + _ => full.right - full.left, + }; + let div = div.w(width); + match vertical_padding { + VerticalPadding::None => div, + VerticalPadding::Pad => { + let height = match layout { + Some(Layout::Below) => (full.bottom - full.top) - full.preview, + _ => full.bottom - full.top, + }; + div.h(height) + } + } + } + + pub(crate) fn results_max_height( + &self, + bounds: &SizeBounds, + vertical_padding: VerticalPadding, + window: &Window, + ) -> Option { + match vertical_padding { + // No preview when results size themselves (no vertical padding), so + // clamp against the results-only minimum. + VerticalPadding::None => Some(self.height(None, bounds, window)), + VerticalPadding::Pad => None, + } + } + + /// The clamped total height of the picker for the given layout. + pub(crate) fn height( + &self, + layout: Option, + bounds: &SizeBounds, + window: &Window, + ) -> Pixels { + let pos = self.clamped_position_and_size(layout, bounds, window); + pos.bottom - pos.top + } + + pub(crate) fn apply_height( + &self, + layout: Option, + bounds: &SizeBounds, + div: Div, + window: &Window, + ) -> Div { + div.h(self.height(layout, bounds, window)) + } + + pub(crate) fn results_height( + &self, + layout: Layout, + bounds: &SizeBounds, + window: &Window, + ) -> Pixels { + let pos = self.results_position_and_size(layout, bounds, window); + pos.bottom - pos.top + } + + pub(crate) fn preview_width( + &self, + layout: Layout, + bounds: &SizeBounds, + window: &Window, + ) -> Pixels { + let pos = self.preview_position_and_size(layout, bounds, window); + pos.right - pos.left + } + + pub(crate) fn preview_height( + &self, + layout: Layout, + bounds: &SizeBounds, + window: &Window, + ) -> Pixels { + let pos = self.preview_position_and_size(layout, bounds, window); + pos.bottom - pos.top + } + + pub(crate) fn centered_and_relative( + pos: PositionAndShape, + layout: impl Into>, + window: &Window, + ) -> Centered { + use Layout as Pl; + let preview_size = match layout.into() { + Some(Pl::Below) => ViewportFraction::from_height_pixels(pos.preview, window), + Some(Pl::Right) => ViewportFraction::from_width_pixels(pos.preview, window), + Some(Pl::Hidden) | None => ViewportFraction::ZERO, + }; + + Centered { + width: RelativeWidth::from_pixels(pos.right - pos.left, window), + height: RelativeHeight::from_pixels(pos.bottom - pos.top, window), + preview_size, + } + } + + pub(crate) fn set_initial_width(&mut self, w: impl Into) { + if let Shape::HorizontallyCentered(Centered { width, .. }) = self { + *width = w.into(); + } + } + + pub(crate) fn set_initial_height(&mut self, h: impl Into) { + if let Shape::HorizontallyCentered(Centered { height, .. }) = self { + *height = h.into(); + } + } + + pub(crate) fn reset_width(&mut self, default: &Centered) { + if let Shape::HorizontallyCentered(Centered { width, .. }) = self { + *width = default.width; + } + } + + pub(crate) fn reset_height(&mut self, default: &Centered) { + if let Shape::HorizontallyCentered(Centered { height, .. }) = self { + *height = default.height; + } + } + + pub(crate) fn center_divider(&mut self, layout: Layout, window: &Window) { + if let Shape::HorizontallyCentered(Centered { + width, + height, + preview_size, + }) = self + { + let total = match layout { + Layout::Right => width.as_viewport_fraction(window), + Layout::Below => height.as_viewport_fraction(window), + Layout::Hidden => return, + }; + *preview_size = total * 0.5; + } + } +} + +impl Default for Centered { + fn default() -> Self { + Centered { + width: RelativeWidth::viewport(0.6), + height: RelativeHeight::viewport(0.6), + preview_size: ViewportFraction::fraction(0.3), + } + } +} + +impl Default for Shape { + fn default() -> Self { + Self::HorizontallyCentered(Centered::default()) + } +} diff --git a/crates/project/src/project_search.rs b/crates/project/src/project_search.rs index e865bb4d5c..600e7aa593 100644 --- a/crates/project/src/project_search.rs +++ b/crates/project/src/project_search.rs @@ -66,19 +66,19 @@ pub struct SearchResultsHandle { } pub struct SearchResults { - pub _task_handle: Task<()>, + pub task_handle: Task<()>, pub rx: Receiver, } impl SearchResultsHandle { pub fn results(self, cx: &mut App) -> SearchResults { SearchResults { - _task_handle: (self.trigger_search)(cx), + task_handle: (self.trigger_search)(cx), rx: self.results, } } pub fn matching_buffers(self, cx: &mut App) -> SearchResults> { SearchResults { - _task_handle: (self.trigger_search)(cx), + task_handle: (self.trigger_search)(cx), rx: self.matching_buffers, } } diff --git a/crates/project/src/search_history.rs b/crates/project/src/search_history.rs index a3b0c0a1bc..c4aa6b6229 100644 --- a/crates/project/src/search_history.rs +++ b/crates/project/src/search_history.rs @@ -120,4 +120,9 @@ impl SearchHistory { pub fn len(&self) -> usize { self.history.len() } + + /// Iterate over history entries from newest to oldest. + pub fn iter(&self) -> impl Iterator { + self.history.iter().rev().map(|s| s.as_str()) + } } diff --git a/crates/project_symbols/src/project_symbols.rs b/crates/project_symbols/src/project_symbols.rs index 2202ff35e1..3681d84bc8 100644 --- a/crates/project_symbols/src/project_symbols.rs +++ b/crates/project_symbols/src/project_symbols.rs @@ -26,7 +26,7 @@ pub fn init(cx: &mut App) { let handle = cx.entity().downgrade(); workspace.toggle_modal(window, cx, move |window, cx| { let delegate = ProjectSymbolsDelegate::new(handle, project); - Picker::uniform_list(delegate, window, cx).width(rems(34.)) + Picker::uniform_list(delegate, window, cx).minimum_results_width(rems(34.)) }) }, ); @@ -108,6 +108,10 @@ impl ProjectSymbolsDelegate { impl PickerDelegate for ProjectSymbolsDelegate { type ListItem = ListItem; + + fn name() -> &'static str { + "project symbols" + } fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { "Search project symbols...".into() } diff --git a/crates/recent_projects/src/recent_projects.rs b/crates/recent_projects/src/recent_projects.rs index 27cdeb43da..4d0b237964 100644 --- a/crates/recent_projects/src/recent_projects.rs +++ b/crates/recent_projects/src/recent_projects.rs @@ -604,7 +604,6 @@ pub fn add_wsl_distro( pub struct RecentProjects { pub picker: Entity>, - rem_width: f32, _subscriptions: Vec, } @@ -633,6 +632,9 @@ impl RecentProjects { let picker = cx.new(|cx| { Picker::list(delegate, window, cx) .list_measure_all() + .minimum_results_width(rems(rem_width)) + .height(rems(24.)) + .no_vertical_padding() .show_scrollbar(true) }); @@ -677,7 +679,6 @@ impl RecentProjects { .detach(); Self { picker, - rem_width, _subscriptions: subscriptions, } } @@ -852,7 +853,6 @@ impl Render for RecentProjects { .on_action(cx.listener(Self::handle_toggle_open_menu)) .on_action(cx.listener(Self::handle_remove_selected)) .on_action(cx.listener(Self::handle_add_to_workspace)) - .w(rems(self.rem_width)) .child(self.picker.clone()) } } @@ -932,6 +932,10 @@ impl EventEmitter for RecentProjectsDelegate {} impl PickerDelegate for RecentProjectsDelegate { type ListItem = AnyElement; + fn name() -> &'static str { + "recent projects" + } + fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { "Search projects…".into() } @@ -2569,7 +2573,8 @@ mod tests { Picker::list(delegate, window, cx) .list_measure_all() .show_scrollbar(true) - .max_height(Some(px(240.).into())) + .height(Rems::from_pixels(px(240.0), window)) + .no_vertical_padding() }); draw(cx); (picker, cx) diff --git a/crates/recent_projects/src/remote_servers.rs b/crates/recent_projects/src/remote_servers.rs index 86c4b29b80..017e7674d1 100644 --- a/crates/recent_projects/src/remote_servers.rs +++ b/crates/recent_projects/src/remote_servers.rs @@ -209,6 +209,10 @@ impl DevContainerPickerDelegate { impl PickerDelegate for DevContainerPickerDelegate { type ListItem = AnyElement; + fn name() -> &'static str { + "remote dev container picker" + } + fn match_count(&self) -> usize { self.matching_candidates.len() } @@ -400,7 +404,7 @@ impl ProjectPicker { let picker = cx.new(|cx| { let picker = Picker::uniform_list(delegate, window, cx) - .width(rems(34.)) + .minimum_results_width(rems(34.)) .modal(false); picker.set_query(&home_dir.to_string(), window, cx); picker diff --git a/crates/recent_projects/src/sidebar_recent_projects.rs b/crates/recent_projects/src/sidebar_recent_projects.rs index 91b1210029..7435c005c7 100644 --- a/crates/recent_projects/src/sidebar_recent_projects.rs +++ b/crates/recent_projects/src/sidebar_recent_projects.rs @@ -134,6 +134,10 @@ impl EventEmitter for SidebarRecentProjectsDelegate {} impl PickerDelegate for SidebarRecentProjectsDelegate { type ListItem = AnyElement; + fn name() -> &'static str { + "sidebar recent projects" + } + fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { "Search projects…".into() } diff --git a/crates/recent_projects/src/wsl_picker.rs b/crates/recent_projects/src/wsl_picker.rs index b09a040caa..6edf8a26df 100644 --- a/crates/recent_projects/src/wsl_picker.rs +++ b/crates/recent_projects/src/wsl_picker.rs @@ -75,6 +75,10 @@ impl EventEmitter for Picker {} impl picker::PickerDelegate for WslPickerDelegate { type ListItem = ListItem; + fn name() -> &'static str { + "WSL-distor-picker" + } + fn match_count(&self) -> usize { self.matches.len() } diff --git a/crates/repl/src/components/kernel_options.rs b/crates/repl/src/components/kernel_options.rs index e8fc7bee36..4503d260e7 100644 --- a/crates/repl/src/components/kernel_options.rs +++ b/crates/repl/src/components/kernel_options.rs @@ -206,6 +206,10 @@ impl KernelPickerDelegate { impl PickerDelegate for KernelPickerDelegate { type ListItem = ListItem; + fn name() -> &'static str { + "kernel picker" + } + fn match_count(&self) -> usize { self.filtered_entries.len() } @@ -477,8 +481,9 @@ where let picker_view = cx.new(|cx| { Picker::list(delegate, window, cx) .list_measure_all() - .width(rems(34.)) - .max_height(Some(rems(24.).into())) + .minimum_results_width(rems(34.)) + .height(rems(24.)) + .no_vertical_padding() }); PopoverMenu::new("kernel-switcher") diff --git a/crates/search/Cargo.toml b/crates/search/Cargo.toml index dac98f5245..3ef52302fa 100644 --- a/crates/search/Cargo.toml +++ b/crates/search/Cargo.toml @@ -26,17 +26,20 @@ any_vec.workspace = true bitflags.workspace = true collections.workspace = true editor.workspace = true +file_icons.workspace = true fs.workspace = true -futures-lite.workspace = true futures.workspace = true gpui.workspace = true language.workspace = true menu.workspace = true multi_buffer.workspace = true +picker.workspace = true project.workspace = true serde.workspace = true serde_json.workspace = true settings.workspace = true +smol.workspace = true +text.workspace = true theme.workspace = true theme_settings.workspace = true ui.workspace = true diff --git a/crates/search/src/project_search.rs b/crates/search/src/project_search.rs index e553071751..c014c6b422 100644 --- a/crates/search/src/project_search.rs +++ b/crates/search/src/project_search.rs @@ -1,12 +1,14 @@ use crate::{ - BufferSearchBar, FocusSearch, HighlightKey, NextHistoryQuery, PreviousHistoryQuery, ReplaceAll, - ReplaceNext, SearchOption, SearchOptions, SearchSource, SelectNextMatch, SelectPreviousMatch, + BufferSearchBar, EXCLUDE_PLACEHOLDER, FocusSearch, HighlightKey, INCLUDE_PLACEHOLDER, + NextHistoryQuery, PreviousHistoryQuery, REPLACE_PLACEHOLDER, ReplaceAll, ReplaceNext, + SearchOption, SearchOptions, SearchSource, SelectNextMatch, SelectPreviousMatch, ToggleCaseSensitive, ToggleIncludeIgnored, ToggleRegex, ToggleReplace, ToggleWholeWord, buffer_search::Deploy, search_bar::{ ActionButtonState, HistoryNavigationDirection, alignment_element, input_base_styles, render_action_button, render_text_input, should_navigate_history, }, + text_finder::TextFinder, }; use anyhow::Context as _; use collections::HashMap; @@ -20,10 +22,10 @@ use editor::{ }; use futures::{StreamExt, stream::FuturesOrdered}; use gpui::{ - Action, AnyElement, App, Axis, Context, Entity, EntityId, EventEmitter, FocusHandle, Focusable, - Global, Hsla, InteractiveElement, IntoElement, KeyContext, ParentElement, Point, Render, - SharedString, Styled, Subscription, Task, TaskExt, UpdateGlobal, WeakEntity, Window, actions, - div, + Action, AnyElement, App, AsyncApp, Axis, Context, Entity, EntityId, EventEmitter, FocusHandle, + Focusable, Global, Hsla, InteractiveElement, IntoElement, KeyContext, ParentElement, Point, + Render, SharedString, Styled, Subscription, Task, TaskExt, UpdateGlobal, WeakEntity, Window, + actions, div, }; use itertools::Itertools; use language::{Buffer, Language}; @@ -31,7 +33,7 @@ use menu::Confirm; use multi_buffer; use project::{ Project, ProjectPath, SearchResults, - search::{SearchInputKind, SearchQuery}, + search::{SearchInputKind, SearchQuery, SearchResult}, search_history::SearchHistoryCursor, }; use settings::Settings; @@ -40,7 +42,10 @@ use std::{ mem, ops::{Not, Range}, pin::pin, - sync::Arc, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, }; use ui::{ CommonAnimationExt, IconButtonShape, KeyBinding, Toggleable, Tooltip, prelude::*, @@ -66,7 +71,9 @@ actions!( /// Toggles the search filters panel. ToggleFilters, /// Toggles collapse/expand state of all search result excerpts. - ToggleAllSearchResults + ToggleAllSearchResults, + /// Open a text picker showing the current result in a modal. + OpenTextFinder ] ); @@ -97,7 +104,7 @@ fn split_glob_patterns(text: &str) -> Vec<&str> { } #[derive(Default)] -struct ActiveSettings(HashMap, ProjectSearchSettings>); +pub(crate) struct ActiveSettings(pub(crate) HashMap, ProjectSearchSettings>); impl Global for ActiveSettings {} @@ -229,17 +236,18 @@ fn contains_uppercase(str: &str) -> bool { } pub struct ProjectSearch { - project: Entity, - excerpts: Entity, - pending_search: Option>>, - match_ranges: Vec>, - active_query: Option, + pub(crate) project: Entity, + pub excerpts: Entity, + pub pending_search: Option>>>, + pub match_ranges: Vec>, + pub(crate) active_query: Option, last_search_query_text: Option, - search_id: usize, + pub search_id: usize, search_state: SearchState, search_history_cursor: SearchHistoryCursor, search_included_history_cursor: SearchHistoryCursor, search_excluded_history_cursor: SearchHistoryCursor, + pub project_search_turning_into_text_finder: Arc, _excerpts_subscription: Subscription, } @@ -283,13 +291,13 @@ enum InputPanel { } pub struct ProjectSearchView { - workspace: WeakEntity, + pub(crate) workspace: WeakEntity, focus_handle: FocusHandle, - entity: Entity, + pub(crate) entity: Entity, query_editor: Entity, replacement_editor: Entity, results_editor: Entity, - search_options: SearchOptions, + pub(crate) search_options: SearchOptions, panels_with_errors: HashMap, active_match_index: Option, search_id: usize, @@ -332,6 +340,7 @@ impl ProjectSearch { search_history_cursor: Default::default(), search_included_history_cursor: Default::default(), search_excluded_history_cursor: Default::default(), + project_search_turning_into_text_finder: Arc::new(AtomicBool::new(false)), _excerpts_subscription: subscription, } } @@ -359,6 +368,7 @@ impl ProjectSearch { search_history_cursor: self.search_history_cursor.clone(), search_included_history_cursor: self.search_included_history_cursor.clone(), search_excluded_history_cursor: self.search_excluded_history_cursor.clone(), + project_search_turning_into_text_finder: Arc::new(AtomicBool::new(false)), _excerpts_subscription: subscription, } }) @@ -421,6 +431,8 @@ impl ProjectSearch { } fn search(&mut self, query: SearchQuery, cx: &mut Context) { + let project_search_turning_into_text_finder = + Arc::clone(&self.project_search_turning_into_text_finder); let search = self.project.update(cx, |project, cx| { project .search_history_mut(SearchInputKind::Query) @@ -445,9 +457,6 @@ impl ProjectSearch { self.match_ranges.clear(); self.search_state = SearchState::Running(SearchActivity::Searching); self.pending_search = Some(cx.spawn(async move |project_search, cx| { - let SearchResults { rx, _task_handle } = search; - - let mut matches = pin!(rx.ready_chunks(1024)); project_search .update(cx, |project_search, cx| { project_search.match_ranges.clear(); @@ -457,89 +466,141 @@ impl ProjectSearch { }) .ok()?; - let mut limit_reached = false; - while let Some(results) = matches.next().await { - let (buffers_with_ranges, has_reached_limit, search_activity) = cx - .background_executor() - .spawn(async move { - let mut limit_reached = false; - let mut search_activity = None; - let mut buffers_with_ranges = Vec::with_capacity(results.len()); - for result in results { - match result { - project::search::SearchResult::Buffer { buffer, ranges } => { - buffers_with_ranges.push((buffer, ranges)); - } - project::search::SearchResult::LimitReached => { - limit_reached = true; - } - project::search::SearchResult::WaitingForScan => { - search_activity = Some(SearchActivity::WaitingForScan); - } - project::search::SearchResult::Searching => { - search_activity = Some(SearchActivity::Searching); - } - } - } - (buffers_with_ranges, limit_reached, search_activity) - }) - .await; - limit_reached |= has_reached_limit; - if let Some(search_activity) = search_activity { - project_search - .update(cx, |project_search, cx| { - project_search.search_state = SearchState::Running(search_activity); - cx.notify(); - }) - .ok()?; - } - let mut new_ranges = project_search - .update(cx, |project_search, cx| { - project_search.excerpts.update(cx, |excerpts, cx| { - buffers_with_ranges - .into_iter() - .map(|(buffer, ranges)| { - excerpts.set_anchored_excerpts_for_path( - PathKey::for_buffer(&buffer, cx), - buffer, - ranges, - multibuffer_context_lines(cx), - cx, - ) - }) - .collect::>() - }) - }) - .ok()?; - while let Some(new_ranges) = new_ranges.next().await { - // `new_ranges.next().await` likely never gets hit while still pending so `async_task` - // will not reschedule, starving other front end tasks, insert a yield point for that here - futures_lite::future::yield_now().await; - project_search - .update(cx, |project_search, cx| { - project_search.match_ranges.extend(new_ranges); - cx.notify(); - }) - .ok()?; - } - } - - project_search - .update(cx, |project_search, cx| { - project_search.search_state = if project_search.match_ranges.is_empty() { - SearchState::Completed(SearchCompletion::NoResults) - } else { - SearchState::Completed(SearchCompletion::Results { limit_reached }) - }; - project_search.pending_search.take(); - cx.notify(); - }) - .ok()?; - - None + consume_search_stream( + project_search, + search, + project_search_turning_into_text_finder, + cx, + ) + .await })); cx.notify(); } + + // At the point this is called the multibuffer has already been filled with + // plundered results from the text finder + pub(crate) fn hook_up_ongoing_search( + &mut self, + search_results: SearchResults, + cx: &mut Context, + ) { + let project_search_turning_into_text_finder = + Arc::clone(&self.project_search_turning_into_text_finder); + + self.pending_search = Some(cx.spawn(async move |project_search, cx| { + consume_search_stream( + project_search, + search_results, + project_search_turning_into_text_finder, + cx, + ) + .await + })); + cx.notify(); + } +} + +/// Drain a search result stream into the project search's multibuffer. +async fn consume_search_stream( + project_search: WeakEntity, + search_results: SearchResults, + project_search_turning_into_text_finder: Arc, + cx: &mut AsyncApp, +) -> Option> { + // Note: is cancel safe + let mut matches = pin!(search_results.rx.clone().ready_chunks(1024)); + + let mut limit_reached = false; + while let Some(results) = matches.next().await { + let (buffers_with_ranges, has_reached_limit, search_activity) = cx + .background_executor() + .spawn(async move { + let mut limit_reached = false; + let mut search_activity = None; + let mut buffers_with_ranges = Vec::with_capacity(results.len()); + for result in results { + match result { + project::search::SearchResult::Buffer { buffer, ranges } => { + buffers_with_ranges.push((buffer, ranges)); + } + project::search::SearchResult::LimitReached => { + limit_reached = true; + } + project::search::SearchResult::WaitingForScan => { + search_activity = Some(SearchActivity::WaitingForScan); + } + project::search::SearchResult::Searching => { + search_activity = Some(SearchActivity::Searching); + } + } + } + (buffers_with_ranges, limit_reached, search_activity) + }) + .await; + limit_reached |= has_reached_limit; + if let Some(search_activity) = search_activity { + project_search + .update(cx, |project_search, cx| { + project_search.search_state = SearchState::Running(search_activity); + cx.notify(); + }) + .ok()?; + } + let mut new_ranges = project_search + .update(cx, |project_search, cx| { + project_search.excerpts.update(cx, |excerpts, cx| { + buffers_with_ranges + .into_iter() + .map(|(buffer, ranges)| { + excerpts.set_anchored_excerpts_for_path( + PathKey::for_buffer(&buffer, cx), + buffer, + ranges, + multibuffer_context_lines(cx), + cx, + ) + }) + .collect::>() + }) + }) + .ok()?; + while let Some(new_ranges) = new_ranges.next().await { + // `new_ranges.next().await` likely never gets hit while still pending so `async_task` + // will not reschedule, starving other front end tasks, insert a yield point for that here + smol::future::yield_now().await; + project_search + .update(cx, |project_search, cx| { + project_search.match_ranges.extend(new_ranges); + cx.notify(); + }) + .ok()?; + } + + // We do not want to end the task before all the results taken + // from the mpsc rx are in + if project_search_turning_into_text_finder.load(Ordering::Relaxed) { + break; + } + } + + if project_search_turning_into_text_finder.load(Ordering::Relaxed) { + project_search_turning_into_text_finder.store(false, Ordering::Relaxed); // reset + return Some(search_results); + } + + project_search + .update(cx, |project_search, cx| { + project_search.search_state = if project_search.match_ranges.is_empty() { + SearchState::Completed(SearchCompletion::NoResults) + } else { + SearchState::Completed(SearchCompletion::Results { limit_reached }) + }; + project_search.pending_search.take(); + cx.notify(); + }) + .ok()?; + + None } #[derive(Clone, Debug, PartialEq, Eq)] @@ -554,8 +615,13 @@ impl EventEmitter for ProjectSearchView {} impl Render for ProjectSearchView { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let mut key_context = KeyContext::default(); + key_context.add("ProjectSearchView"); + if self.has_matches() { div() + .key_context(key_context) + .on_action(cx.listener(Self::open_text_finder)) .flex_1() .size_full() .track_focus(&self.focus_handle(cx)) @@ -587,6 +653,8 @@ impl Render for ProjectSearchView { let page_content = page_content.map(|text| div().child(text)); h_flex() + .key_context(key_context) + .on_action(cx.listener(Self::open_text_finder)) .size_full() .items_center() .justify_center() @@ -797,6 +865,15 @@ impl ProjectSearchView { self.entity.read(cx).match_ranges.clone() } + fn open_text_finder( + &mut self, + _: &OpenTextFinder, + window: &mut Window, + cx: &mut Context, + ) { + TextFinder::open_from_project_search(cx.entity(), window, cx).detach(); + } + fn toggle_filters(&mut self, cx: &mut Context) { self.filters_enabled = !self.filters_enabled; ActiveSettings::update_global(cx, |settings, cx| { @@ -1007,7 +1084,7 @@ impl ProjectSearchView { ); let replacement_editor = cx.new(|cx| { let mut editor = Editor::auto_height(1, 4, window, cx); - editor.set_placeholder_text("Replace in project…", window, cx); + editor.set_placeholder_text(REPLACE_PLACEHOLDER, window, cx); if let Some(text) = replacement_text { editor.set_text(text, window, cx); } @@ -1037,7 +1114,7 @@ impl ProjectSearchView { let included_files_editor = cx.new(|cx| { let mut editor = Editor::single_line(window, cx); - editor.set_placeholder_text("Include: crates/**/*.toml", window, cx); + editor.set_placeholder_text(INCLUDE_PLACEHOLDER, window, cx); editor }); @@ -1050,7 +1127,7 @@ impl ProjectSearchView { let excluded_files_editor = cx.new(|cx| { let mut editor = Editor::single_line(window, cx); - editor.set_placeholder_text("Exclude: vendor/*, *.lock", window, cx); + editor.set_placeholder_text(EXCLUDE_PLACEHOLDER, window, cx); editor }); @@ -1456,67 +1533,30 @@ impl ProjectSearchView { .count() > 1; - let query = if self.search_options.contains(SearchOptions::REGEX) { - match SearchQuery::regex( - text, - self.search_options.contains(SearchOptions::WHOLE_WORD), - self.search_options.contains(SearchOptions::CASE_SENSITIVE), - self.search_options.contains(SearchOptions::INCLUDE_IGNORED), - self.search_options - .contains(SearchOptions::ONE_MATCH_PER_LINE), - included_files, - excluded_files, - match_full_paths, - open_buffers, - ) { - Ok(query) => { - let should_unmark_error = self.panels_with_errors.remove(&InputPanel::Query); - if should_unmark_error.is_some() { - cx.notify(); - } - - Some(query) + let query = match self.search_options.build_query( + text, + included_files, + excluded_files, + match_full_paths, + open_buffers, + ) { + Ok(query) => { + let should_unmark_error = self.panels_with_errors.remove(&InputPanel::Query); + if should_unmark_error.is_some() { + cx.notify(); } - Err(e) => { - let should_mark_error = self - .panels_with_errors - .insert(InputPanel::Query, e.to_string()); - if should_mark_error.is_none() { - cx.notify(); - } - None - } + Some(query) } - } else { - match SearchQuery::text( - text, - self.search_options.contains(SearchOptions::WHOLE_WORD), - self.search_options.contains(SearchOptions::CASE_SENSITIVE), - self.search_options.contains(SearchOptions::INCLUDE_IGNORED), - included_files, - excluded_files, - match_full_paths, - open_buffers, - ) { - Ok(query) => { - let should_unmark_error = self.panels_with_errors.remove(&InputPanel::Query); - if should_unmark_error.is_some() { - cx.notify(); - } - - Some(query) + Err(e) => { + let should_mark_error = self + .panels_with_errors + .insert(InputPanel::Query, e.to_string()); + if should_mark_error.is_none() { + cx.notify(); } - Err(e) => { - let should_mark_error = self - .panels_with_errors - .insert(InputPanel::Query, e.to_string()); - if should_mark_error.is_none() { - cx.notify(); - } - None - } + None } }; if !self.panels_with_errors.is_empty() { @@ -1538,6 +1578,24 @@ impl ProjectSearchView { buffers } + /// The include/exclude path matchers currently configured on this view, + /// honoring `filters_enabled`. Read-only (unlike `build_search_query` it does + /// not record parse errors in `panels_with_errors`); invalid globs fall back + /// to a default (match-all) matcher. Shared with the text finder, which is + /// backed by the same view. + pub(crate) fn file_path_filters(&self, cx: &App) -> (PathMatcher, PathMatcher) { + if !self.filters_enabled { + return (PathMatcher::default(), PathMatcher::default()); + } + let included = self + .parse_path_matches(self.included_files_editor.read(cx).text(cx), cx) + .unwrap_or_default(); + let excluded = self + .parse_path_matches(self.excluded_files_editor.read(cx).text(cx), cx) + .unwrap_or_default(); + (included, excluded) + } + fn parse_path_matches(&self, text: String, cx: &App) -> anyhow::Result { let path_style = self.entity.read(cx).project.read(cx).path_style(cx); let queries = split_glob_patterns(&text) @@ -1598,6 +1656,35 @@ impl ProjectSearchView { window.focus(&editor_handle, cx); } + /// Apply some state (from the textfinder) to the project search UI + pub(crate) fn adopt_text_finder_state( + &mut self, + search_options: SearchOptions, + active_query: Option, + window: &mut Window, + cx: &mut Context, + ) { + self.search_options = search_options; + self.adjust_query_regex_language(cx); + if let Some(query) = active_query { + let query_text = query.as_str().to_string(); + self.entity.update(cx, |search, _| { + search.active_query = Some(query.clone()); + search.last_search_query_text = Some(query_text.clone()); + // Force `entity_changed` to treat this as a new search so the + // first match gets selected and scrolled into view. The text + // finder ran its searches via `project.search` directly, so the + // entity's `search_id` was never advanced. + search.search_id += 1; + }); + self.set_search_editor(SearchInputKind::Query, &query_text, window, cx); + self.focus_results_editor(window, cx); + } else { + self.focus_query_editor(window, cx); + } + self.entity_changed(window, cx); + } + fn set_query(&mut self, query: &str, window: &mut Window, cx: &mut Context) { self.set_search_editor(SearchInputKind::Query, query, window, cx); if EditorSettings::get_global(cx).use_smartcase_search @@ -2183,6 +2270,20 @@ impl ProjectSearchBar { }) } } + + fn open_text_finder( + &mut self, + _: &OpenTextFinder, + window: &mut Window, + cx: &mut Context, + ) { + let Some(search) = &self.active_project_search else { + tracing::warn!("active_project_search was none"); + return; + }; + + TextFinder::open_from_project_search(Entity::clone(search), window, cx).detach(); + } } impl Render for ProjectSearchBar { @@ -2572,6 +2673,7 @@ impl Render for ProjectSearchBar { }) .on_action(cx.listener(Self::select_next_match)) .on_action(cx.listener(Self::select_prev_match)) + .on_action(cx.listener(Self::open_text_finder)) .child(search_line) .children(query_error_line) .children(replace_line) diff --git a/crates/search/src/search.rs b/crates/search/src/search.rs index 8edcdd600b..dc71ec5c4a 100644 --- a/crates/search/src/search.rs +++ b/crates/search/src/search.rs @@ -2,11 +2,12 @@ use bitflags::bitflags; pub use buffer_search::BufferSearchBar; pub use editor::HighlightKey; use editor::SearchSettings; -use gpui::{Action, App, ClickEvent, FocusHandle, IntoElement, actions}; +use gpui::{Action, App, ClickEvent, Entity, FocusHandle, IntoElement, actions}; use project::search::SearchQuery; pub use project_search::ProjectSearchView; use ui::{ButtonStyle, IconButton, IconButtonShape}; use ui::{Tooltip, prelude::*}; +use util::paths::PathMatcher; use workspace::notifications::NotificationId; use workspace::{Toast, Workspace}; pub use zed_actions::search::ToggleIncludeIgnored; @@ -19,11 +20,13 @@ pub mod buffer_search; pub mod project_search; pub(crate) mod search_bar; pub mod search_status_button; +pub mod text_finder; pub fn init(cx: &mut App) { menu::init(); buffer_search::init(cx); project_search::init(cx); + text_finder::init(cx); } actions!( @@ -85,6 +88,10 @@ pub enum SearchOption { Backwards, } +const REPLACE_PLACEHOLDER: &str = "Replace in project…"; +const INCLUDE_PLACEHOLDER: &str = "Include: e.g. src/**/*.rs"; +const EXCLUDE_PLACEHOLDER: &str = "Exclude: e.g. vendor/*, *.lock"; + pub enum SearchSource<'a, 'b> { Buffer, Project(&'a Context<'b, ProjectSearchBar>), @@ -184,6 +191,43 @@ impl SearchOptions { options.set(SearchOptions::REGEX, settings.regex); options } + + /// Build a [`SearchQuery`] from these options, selecting the regex or text + /// constructor based on [`SearchOptions::REGEX`]. Inverse of + /// [`SearchOptions::from_query`]. + pub fn build_query( + &self, + query: impl ToString, + files_to_include: PathMatcher, + files_to_exclude: PathMatcher, + match_full_paths: bool, + buffers: Option>>, + ) -> anyhow::Result { + if self.contains(SearchOptions::REGEX) { + SearchQuery::regex( + query, + self.contains(SearchOptions::WHOLE_WORD), + self.contains(SearchOptions::CASE_SENSITIVE), + self.contains(SearchOptions::INCLUDE_IGNORED), + self.contains(SearchOptions::ONE_MATCH_PER_LINE), + files_to_include, + files_to_exclude, + match_full_paths, + buffers, + ) + } else { + SearchQuery::text( + query, + self.contains(SearchOptions::WHOLE_WORD), + self.contains(SearchOptions::CASE_SENSITIVE), + self.contains(SearchOptions::INCLUDE_IGNORED), + files_to_include, + files_to_exclude, + match_full_paths, + buffers, + ) + } + } } pub(crate) fn show_no_more_matches(window: &mut Window, cx: &mut App) { diff --git a/crates/search/src/text_finder.rs b/crates/search/src/text_finder.rs new file mode 100644 index 0000000000..4852e2e238 --- /dev/null +++ b/crates/search/src/text_finder.rs @@ -0,0 +1,289 @@ +use std::{ops::Range, sync::atomic::Ordering}; + +use gpui::{ + App, AppContext, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, + Modifiers, Subscription, Task, WeakEntity, actions, +}; +use language::Buffer; +use picker::Picker; + +use project::ProjectPath; +use text::Anchor; +use ui::Window; +use workspace::{DismissDecision, ModalView, Workspace}; + +mod delegate; +mod render; +use delegate::{Delegate, matches_to_multibuffer}; +use util::ResultExt as _; + +use crate::{ProjectSearchView, text_finder::delegate::PopulateProjectSearch}; + +actions!(text_finder, [ToProjectSearch,]); + +pub struct TextFinder { + picker: Entity>, + init_modifiers: Option, + _subscription: Subscription, +} + +pub fn init(cx: &mut App) { + cx.observe_new(TextFinder::register).detach(); +} + +impl TextFinder { + fn register( + workspace: &mut Workspace, + _window: Option<&mut Window>, + _: &mut Context, + ) { + pub use zed_actions::text_finder::Toggle; + workspace.register_action(|workspace, _: &Toggle, window, cx| { + let Some(text_picker) = workspace.active_modal::(cx) else { + Self::open(window, cx).detach(); + return; + }; + + text_picker.update(cx, |text_picker, cx| { + text_picker.init_modifiers = Some(window.modifiers()); + text_picker.picker.update(cx, |picker, cx| { + picker.cycle_selection(window, cx); + }); + }) + }); + } + + pub fn open_from_project_search( + project_search_view: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Task<()> { + let project_search_item_id = project_search_view.entity_id(); + cx.spawn_in(window, async move |_, cx| { + let workspace = + project_search_view.read_with(cx, |view, _| WeakEntity::clone(&view.workspace)); + let delegate = Delegate::new_from_project_search(project_search_view, cx).await; + workspace + .update_in(cx, |workspace, window, cx| { + remove_project_search_tab(project_search_item_id, workspace, window, cx); + workspace + .toggle_modal(window, cx, |window, cx| Self::new(delegate, window, cx)); + }) + .ok(); + }) + } + + /// Transition this text finder into a project search tab, carrying over the + /// current results (and any in-progress search stream) instead of re-running + /// the search. Inverse of [`Self::open_from_project_search`]. + fn to_project_search( + &mut self, + _: &ToProjectSearch, + window: &mut Window, + cx: &mut Context, + ) { + let picker = Entity::clone(&self.picker); + let workspace = self.weak_workspace(cx); + + let connected_task = self.take_search_task(cx); + let project_search_view = self.project_search_view(cx); + let query = picker.read(cx).delegate.active_query.clone(); + let search_options = picker.read(cx).delegate.search_options; + + cx.spawn_in(window, async move |this, cx| { + let search_stream = connected_task.unwrap_or(gpui::Task::ready(None)).await; + let matches = + picker.update(cx, |picker, _| std::mem::take(&mut picker.delegate.matches)); + + project_search_view + .update_in(cx, |view, window, cx| { + view.adopt_text_finder_state(search_options, query, window, cx); + }) + .log_err(); + + this.update(cx, |_, cx| cx.emit(DismissEvent)).log_err(); + workspace + .update_in(cx, |workspace, window, cx| { + workspace.add_item_to_active_pane( + Box::new(project_search_view.clone()), + None, + true, // focus item + window, + cx, + ); + }) + .log_err(); + + if let PopulateProjectSearch::SupersededByNewSearch = + matches_to_multibuffer(&project_search_view, &matches, cx).await + { + return; + } + + if let Some(stream) = search_stream { + project_search_view.update(cx, |view, cx| { + view.entity + .update(cx, |search, cx| search.hook_up_ongoing_search(stream, cx)); + }); + } + }) + .detach(); + } + + fn split_left( + &mut self, + _: &workspace::pane::SplitLeft, + window: &mut Window, + cx: &mut Context, + ) { + self.open_in_split(workspace::SplitDirection::Left, window, cx); + } + + fn split_right( + &mut self, + _: &workspace::pane::SplitRight, + window: &mut Window, + cx: &mut Context, + ) { + self.open_in_split(workspace::SplitDirection::Right, window, cx); + } + + fn split_up( + &mut self, + _: &workspace::pane::SplitUp, + window: &mut Window, + cx: &mut Context, + ) { + self.open_in_split(workspace::SplitDirection::Up, window, cx); + } + + fn split_down( + &mut self, + _: &workspace::pane::SplitDown, + window: &mut Window, + cx: &mut Context, + ) { + self.open_in_split(workspace::SplitDirection::Down, window, cx); + } + + fn open_in_split( + &mut self, + direction: workspace::SplitDirection, + window: &mut Window, + cx: &mut Context, + ) { + self.picker.update(cx, |picker, cx| { + picker.delegate.open_in_split(direction, window, cx); + }); + } + + fn weak_workspace(&self, cx: &App) -> WeakEntity { + let workspace = WeakEntity::clone( + &self + .picker + .read(cx) + .delegate + .project_search_view + .read(cx) + .workspace, + ); + workspace + } + + fn take_search_task( + &self, + cx: &mut App, + ) -> Option>>> { + self.picker + .read(cx) + .delegate + .text_finder_turning_into_project_search + .store(true, Ordering::Relaxed); + self.picker + .update(cx, |p, _| p.delegate.in_progress_search.take_connected()) + } + + pub fn open(window: &mut Window, cx: &mut Context) -> Task<()> { + cx.spawn_in(window, async move |workspace, cx| { + let Ok(delegate_task) = workspace.update_in(cx, |workspace, window, cx| { + Delegate::new(workspace, window, cx) + }) else { + return; + }; + + let delegate = delegate_task.await; + workspace + .update_in(cx, |workspace, window, cx| { + workspace + .toggle_modal(window, cx, |window, cx| Self::new(delegate, window, cx)); + }) + .ok(); + }) + } + + fn new(delegate: Delegate, window: &mut Window, cx: &mut Context) -> Self { + let project = delegate.project(cx).clone(); + let picker = cx.new(|cx| Picker::list_with_preview(delegate, project, window, cx)); + let picker_weak = picker.downgrade(); + let picker_focus_handle = picker.focus_handle(cx); + picker.update(cx, |picker, cx| { + picker.delegate.focus_handle = picker_focus_handle.clone(); + picker.delegate.hook_up_any_ongoing_search(picker_weak, cx); + }); + let subscription = cx.subscribe(&picker, |_, _, _: &DismissEvent, cx| { + cx.emit(DismissEvent); + }); + + Self { + picker, + init_modifiers: window.modifiers().modified().then_some(window.modifiers()), + _subscription: subscription, + } + } + + fn project_search_view(&self, cx: &mut App) -> Entity { + Entity::clone(&self.picker.read(cx).delegate.project_search_view) + } +} + +fn remove_project_search_tab( + project_search_item_id: gpui::EntityId, + workspace: &mut Workspace, + window: &mut Window, + cx: &mut Context<'_, Workspace>, +) { + if let Some(pane) = workspace.pane_for_item_id(project_search_item_id) { + pane.update(cx, |pane, cx| { + pane.remove_item(project_search_item_id, false, false, window, cx); + }); + } +} + +impl ModalView for TextFinder { + fn on_before_dismiss( + &mut self, + _window: &mut Window, + _cx: &mut Context, + ) -> DismissDecision { + DismissDecision::Dismiss(true) + } +} + +impl EventEmitter for TextFinder {} + +impl Focusable for TextFinder { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.picker.read(cx).focus_handle(cx) + } +} + +#[derive(Clone)] +pub struct SearchMatch { + pub path: ProjectPath, + pub buffer: Entity, + pub anchor_range: Range, + pub range: Range, + pub relative_range: Range, + pub line_text: String, + pub line_number: u32, +} diff --git a/crates/search/src/text_finder/delegate.rs b/crates/search/src/text_finder/delegate.rs new file mode 100644 index 0000000000..e0ecf2c6b6 --- /dev/null +++ b/crates/search/src/text_finder/delegate.rs @@ -0,0 +1,1136 @@ +//! The text_finder is a minimal modal interface to the project_search. It is +//! targeted towards search for exploration. It can also be used as a filter +//! step to the project_search. +//! +//! Basic interaction: +//! +//! ```txt +//! Open text finder --- Open file ---> File tab +//! +//! (text_finder action) +//! Open text finder --- ToProjectSearch ---> Project search tab +//! +//! Can also have a little loop where the user uses the ProjectSearch filters etc +//! to refine the search: +//! +//! (project search tab) +//! (removes tab, opens modal) +//! Project search tab --- ToTextFinder ---> Text finder modal +//! ^ | +//! | ToProjectSearch (adds tab, +//! | | closes modal) +//! | V +//! . -------- Project search tab +//! ``` +use std::ops::ControlFlow; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::{ops::Range, time::Duration}; + +use collections::{HashMap, HashSet}; +use editor::{MultiBufferSnapshot, PathKey, multibuffer_context_lines}; +use file_icons::FileIcons; +use futures::StreamExt; +use gpui::{ + AnyElement, AppContext, AsyncApp, DismissEvent, EntityId, HighlightStyle, StyledText, Task, + TextStyle, prelude::*, +}; +use gpui::{Entity, FocusHandle}; +use language::{Buffer, LanguageAwareStyling}; +use picker::{Picker, PickerDelegate}; +use project::{Project, ProjectPath}; +use project::{SearchResults, search::SearchQuery, search::SearchResult}; +use settings::Settings; +use smol::future::yield_now; +use text::Anchor; +use theme_settings::ThemeSettings; +use ui::{ + Divider, FluentBuilder, IconButtonShape, ListItem, ListItemSpacing, Toggleable, Tooltip, + prelude::*, +}; +use util::ResultExt; +use workspace::SplitDirection; +use workspace::Workspace; +use workspace::item::ItemSettings; + +use super::SearchMatch; +use crate::project_search::{ActiveSettings, ProjectSearch}; +use crate::{ProjectSearchView, SearchOption, SearchOptions}; + +pub struct Delegate { + pub(crate) project_search_view: Entity, + pub(crate) focus_handle: FocusHandle, + /// Flat list of every match, in result order. This is the canonical list + /// handed off to the project search; [`Self::entries`] is a grouped view + /// derived from it for rendering. + pub(crate) matches: Vec, + /// Display rows derived from [`Self::matches`]: a non-selectable header per + /// file, its matches, and separators between groups. Rebuilt via + /// [`Delegate::rebuild_entries`] whenever `matches` changes. `selected_index` + /// indexes into this list. + pub(crate) entries: Vec, + pub(crate) selected_index: usize, + pub(crate) cancel_flag: Arc, + pub(crate) text_finder_turning_into_project_search: Arc, + pub(crate) last_selection_change_time: Option, + pub(crate) last_click: Option<(usize, std::time::Instant)>, + pub(crate) search_options: SearchOptions, + /// Kept around for switching to project search + pub(crate) active_query: Option, + pub(crate) imported_from_project_search: bool, + /// When `is_ready` there is not a search in progress + pub(crate) in_progress_search: InProgressSearch, + pub(crate) unique_files: HashSet, + /// Largest line number across [`Self::matches`], used to size the line-number + /// column so every row's number right-aligns to the widest one. Recomputed in + /// [`Delegate::rebuild_entries`]. + pub(crate) max_line_number: u32, +} + +pub(crate) enum Entry { + Header(ProjectPath), + Match(usize), + Separator, +} + +async fn get_ongoing_search( + project_search_view: &Entity, + cx: &mut AsyncApp, +) -> Option> { + let ongoing_search = project_search_view.update(cx, |view, cx| { + view.entity.update(cx, |search, _| { + search.pending_search.take().inspect(|_| { + search + .project_search_turning_into_text_finder + .store(true, Ordering::Relaxed); + }) + }) + })?; + + ongoing_search.await +} + +fn multibuffer_ranges_to_search_matches<'a>( + match_ranges: &'a [Range], + multi_buffer: &'a editor::MultiBuffer, + snapshot: MultiBufferSnapshot, + cx: &'a App, +) -> impl Iterator + 'a { + match_ranges.iter().cloned().filter_map(move |mb_range| { + let (buffer_snapshot, text_range) = + snapshot.anchor_range_to_buffer_anchor_range(mb_range)?; + + let file = buffer_snapshot.file()?; + let path = ProjectPath { + worktree_id: file.worktree_id(cx), + path: Arc::clone(file.path()), + }; + let buffer = multi_buffer.buffer(buffer_snapshot.remote_id())?; + + let start_offset: usize = buffer_snapshot.summary_for_anchor(&text_range.start); + let end_offset: usize = buffer_snapshot.summary_for_anchor(&text_range.end); + let line_number = buffer_snapshot.offset_to_point(start_offset).row + 1; + + let text = buffer_snapshot.text(); + let line_start = text[..start_offset].rfind('\n').map(|i| i + 1).unwrap_or(0); + let line_end = text[start_offset..] + .find('\n') + .map(|i| start_offset + i) + .unwrap_or(text.len()); + let line_text = text[line_start..line_end].to_string(); + + let relative_start = start_offset - line_start; + let relative_end = end_offset - line_start; + + Some(SearchMatch { + path, + buffer, + anchor_range: text_range, + range: start_offset..end_offset, + relative_range: relative_start..relative_end, + line_text, + line_number, + }) + }) +} + +/// Stream the matches already sitting in the project search's multibuffer into +/// the picker, a chunk at a time. Inverse of [`matches_to_multibuffer`]. +async fn stream_plunder_to_picker( + project_search_view: Entity, + cancel_flag: Arc, + picker: gpui::WeakEntity>, + cx: &mut AsyncApp, +) { + let chunk_size = 1000; + let mut n_read = 0; + + loop { + if cancel_flag.load(Ordering::SeqCst) { + return; // user cancelled or changed the query + } + + let res = picker.update(cx, |picker, cx| { + let new_matches: Vec = { + let ps = project_search_view.read(cx).entity.read(cx); + let len = ps.match_ranges.len(); + if n_read >= len { + return ControlFlow::Break(()); + } + let end = (n_read + chunk_size).min(len); + let chunk = &ps.match_ranges[n_read..end]; + let multi_buffer = ps.excerpts.read(cx); + let snapshot = multi_buffer.snapshot(cx); + let matches = + multibuffer_ranges_to_search_matches(chunk, multi_buffer, snapshot, cx) + .collect(); + n_read = end; + matches + }; + + let delegate = &mut picker.delegate; + delegate + .unique_files + .extend(new_matches.iter().map(|m| m.path.clone())); + delegate.matches.extend(new_matches); + delegate.rebuild_entries(); + cx.notify(); + ControlFlow::Continue(()) + }); + + match res { + Ok(ControlFlow::Continue(())) => {} + Ok(ControlFlow::Break(())) | Err(_) => break, + } + + // Critical or the search transformation will hold the background thread for too long + yield_now().await; + } +} + +pub(crate) enum InProgressSearch { + Connected(Task>>), + Disconnected(SearchResults), + None, +} + +impl InProgressSearch { + /// If this is in disconnected state set it to None and return the search results + fn take_disconnected(&mut self) -> Option> { + if matches!(self, InProgressSearch::Disconnected(_)) { + let mut placeholder = InProgressSearch::None; + std::mem::swap(self, &mut placeholder); + match placeholder { + InProgressSearch::Disconnected(results_stream) => return Some(results_stream), + _ => unreachable!("guarded with matches! above"), + } + } else { + None + } + } + + /// If a search is currently streaming into the picker, take its task so it + /// can be awaited to recover the underlying result stream. + pub(crate) fn take_connected(&mut self) -> Option>>> { + if matches!(self, InProgressSearch::Connected(_)) { + match std::mem::replace(self, InProgressSearch::None) { + InProgressSearch::Connected(task) => Some(task), + _ => unreachable!("guarded with matches! above"), + } + } else { + None + } + } +} + +impl Delegate { + pub fn hook_up_any_ongoing_search( + &mut self, + picker: gpui::WeakEntity>, + cx: &App, + ) { + let cancel_flag = Arc::clone(&self.cancel_flag); + let text_finder_turning_into_project_search = + Arc::clone(&self.text_finder_turning_into_project_search); + let project_search_view = self.project_search_view.clone(); + let ongoing = self.in_progress_search.take_disconnected(); + + self.in_progress_search = InProgressSearch::Connected(cx.spawn(async move |cx| { + stream_plunder_to_picker(project_search_view, cancel_flag.clone(), picker.clone(), cx) + .await; + + if let Some(results_stream) = ongoing { + return stream_results_to_picker( + cancel_flag, + text_finder_turning_into_project_search, + picker, + results_stream, + ImportedMatches::Yes, + cx, + ) + .await; + } + None + })); + } + + pub fn new_from_project_search( + project_search: Entity, + cx: &mut AsyncApp, + ) -> Task { + cx.spawn(async move |cx| { + let ongoing = get_ongoing_search(&project_search, cx).await; + + let in_progress_search = if let Some(results_stream) = ongoing { + InProgressSearch::Disconnected(results_stream) + } else { + InProgressSearch::None + }; + + let (search_options, active_query, has_existing_matches) = + cx.read_entity(&project_search, |ps, cx| { + let entity = ps.entity.read(cx); + ( + ps.search_options, + entity.active_query.clone(), + !entity.match_ranges.is_empty(), + ) + }); + + let imported_from_project_search = + has_existing_matches || !matches!(in_progress_search, InProgressSearch::None); + + let this = cx.update(move |cx| Self { + project_search_view: project_search, + focus_handle: cx.focus_handle(), + matches: Vec::new(), + entries: Vec::new(), + selected_index: 0, + cancel_flag: Arc::new(AtomicBool::new(false)), + text_finder_turning_into_project_search: Arc::new(AtomicBool::new(false)), + last_selection_change_time: None, + last_click: None, + search_options, + active_query, + imported_from_project_search, + in_progress_search, + unique_files: HashSet::default(), + max_line_number: 0, + }); + + this + }) + } + + pub fn new( + workspace: &mut Workspace, + window: &mut Window, + cx: &mut Context, + ) -> Task { + let project = workspace.project().clone(); + let weak_workspace = workspace.weak_handle(); + let settings = cx + .global::() + .0 + .get(&project.downgrade()) + .cloned(); + + let search = cx.new(|cx| ProjectSearch::new(project, cx)); + let project_search = + cx.new(|cx| ProjectSearchView::new(weak_workspace, search, window, cx, settings)); + cx.spawn(async move |_, cx| Self::new_from_project_search(project_search, cx).await) + } + + pub(crate) fn project<'a>(&self, cx: &'a App) -> &'a Entity { + &self.project_search_view.read(cx).entity.read(cx).project + } + + /// Rebuilds the grouped [`Self::entries`] display list from the flat + /// [`Self::matches`]. Matches arrive grouped per file (one search result + /// per buffer), so consecutive matches share a path; we emit one header per + /// group and a separator before every group after the first. + /// + /// Selection is preserved across rebuilds: if a match was selected it stays + /// selected at its new row, otherwise we snap to the first selectable row. + pub(crate) fn rebuild_entries(&mut self) { + let previously_selected_match = match self.entries.get(self.selected_index) { + Some(Entry::Match(match_index)) => Some(*match_index), + _ => None, + }; + + let mut entries = Vec::with_capacity(self.matches.len()); + let mut last_path: Option<&ProjectPath> = None; + for (match_index, search_match) in self.matches.iter().enumerate() { + if last_path != Some(&search_match.path) { + if last_path.is_some() { + entries.push(Entry::Separator); + } + entries.push(Entry::Header(search_match.path.clone())); + last_path = Some(&search_match.path); + } + entries.push(Entry::Match(match_index)); + } + self.entries = entries; + self.max_line_number = self + .matches + .iter() + .map(|search_match| search_match.line_number) + .max() + .unwrap_or(0); + + self.selected_index = previously_selected_match + .and_then(|match_index| { + self.entries + .iter() + .position(|entry| matches!(entry, Entry::Match(other) if *other == match_index)) + }) + .or_else(|| self.first_selectable_index()) + .unwrap_or(0); + } + + fn first_selectable_index(&self) -> Option { + self.entries + .iter() + .position(|entry| matches!(entry, Entry::Match(_))) + } + + fn selected_search_match(&self) -> Option<&SearchMatch> { + match self.entries.get(self.selected_index)? { + Entry::Match(match_index) => self.matches.get(*match_index), + Entry::Header(_) | Entry::Separator => None, + } + } + + /// Opens the selected match in a new split in `direction`, then dismisses. + pub(crate) fn open_in_split( + &mut self, + direction: SplitDirection, + window: &mut Window, + cx: &mut Context>, + ) { + let Some(selected_match) = self.selected_search_match() else { + return; + }; + let path = selected_match.path.clone(); + let line_number = selected_match.line_number; + let Some(workspace) = self.project_search_view.read(cx).workspace.upgrade() else { + return; + }; + let open_task = workspace.update(cx, |workspace, cx| { + workspace.split_path_preview(path, false, Some(direction), window, cx) + }); + let row = line_number.saturating_sub(1); + cx.spawn_in(window, async move |_, cx| { + let item = open_task.await.log_err()?; + if let Some(active_editor) = item.downcast::() { + active_editor + .downgrade() + .update_in(cx, |editor, window, cx| { + editor.go_to_singleton_buffer_point(text::Point::new(row, 0), window, cx); + }) + .log_err(); + } + Some(()) + }) + .detach(); + cx.emit(DismissEvent); + } +} + +pub(crate) enum PopulateProjectSearch { + Completed, + SupersededByNewSearch, +} + +/// Convert the picker's list of matches into multibuffer. Inverse of +/// [`plunder_multibuffer`]. +pub(crate) async fn matches_to_multibuffer( + project_search_view: &Entity, + matches: &[SearchMatch], + cx: &mut AsyncApp, +) -> PopulateProjectSearch { + let mut buffer_order_in_text_finder: Vec = Vec::new(); + let mut by_buffer: HashMap<_, (_, Vec<_>)> = HashMap::default(); + + for m in matches { + let buffer = Entity::clone(&m.buffer); + by_buffer + .entry(buffer.entity_id()) + .and_modify(|(_, ranges)| ranges.push(m.anchor_range.clone())) + .or_insert_with(|| { + buffer_order_in_text_finder.push(buffer.entity_id()); + (buffer, vec![m.anchor_range.clone()]) + }); + } + + let excerpts = + project_search_view.read_with(cx, |view, cx| view.entity.read(cx).excerpts.clone()); + excerpts.update(cx, |excerpts, cx| excerpts.clear(cx)); + + // Every await point is a place where the user could type a search + // query in which case we gotta abort. Store the search id so we + // can check if that happened. + let search_id = project_search_view.update(cx, |view, cx| { + view.entity.update(cx, |search, _| { + search.match_ranges.clear(); + search.search_id + }) + }); + + let context_lines = cx.update(|cx| multibuffer_context_lines(cx)); + + let still_current = |cx: &mut AsyncApp| { + project_search_view.update(cx, |view, cx| view.entity.read(cx).search_id == search_id) + }; + + let mut excerpts_added = 0; + for buffer_id in buffer_order_in_text_finder { + if !still_current(cx) { + return PopulateProjectSearch::SupersededByNewSearch; + } + let (buffer, ranges) = by_buffer.remove(&buffer_id).expect("just put them in"); + excerpts_added += ranges.len(); + let new_ranges = excerpts + .update(cx, |excerpts, cx| { + excerpts.set_anchored_excerpts_for_path( + PathKey::for_buffer(&buffer, cx), + buffer, + ranges, + context_lines, + cx, + ) + }) + .await; + + if !still_current(cx) { + return PopulateProjectSearch::SupersededByNewSearch; + } + project_search_view.update(cx, |view, cx| { + view.entity.update(cx, |search, cx| { + search.match_ranges.extend(new_ranges); + cx.notify(); + }) + }); + + // Adding items to the multibuffer can take time. Be sure to not hold + // the foreground hostage. + if excerpts_added > 100 { + yield_now().await; + excerpts_added = 0; + } + } + PopulateProjectSearch::Completed +} + +const SEARCH_DEBOUNCE_MS: u64 = 100; +const CLICK_THRESHOLD_MS: u128 = 50; +const DOUBLE_CLICK_THRESHOLD_MS: u128 = 300; +const SEARCH_RESULTS_BATCH_SIZE: usize = 256; + +impl PickerDelegate for Delegate { + type ListItem = AnyElement; + + fn name() -> &'static str { + "text finder" + } + + fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { + "Search all files…".into() + } + + fn searchbar_trailer( + &self, + _window: &mut Window, + cx: &mut Context>, + ) -> Option { + let active = self.search_options; + let focus_handle = self.focus_handle.clone(); + let picker = cx.entity(); + + let filter_buttons = [ + SearchOption::CaseSensitive, + SearchOption::WholeWord, + SearchOption::Regex, + SearchOption::IncludeIgnored, + ] + .into_iter() + .map(|option| { + let options = option.as_options(); + let action = option.to_toggle_action(); + let label = option.label(); + let focus_handle = focus_handle.clone(); + let picker = picker.clone(); + IconButton::new( + ("text-finder-search-option", option as usize), + option.icon(), + ) + .icon_size(IconSize::Small) + .shape(IconButtonShape::Square) + .toggle_state(active.contains(options)) + .tooltip(move |_window, cx| Tooltip::for_action_in(label, action, &focus_handle, cx)) + .on_click(move |_, window, cx| { + picker.update(cx, |picker, cx| { + picker.delegate.search_options.toggle(options); + picker.refresh(window, cx); + }); + }) + }); + + Some( + h_flex() + .gap_1() + .children(filter_buttons) + .children(picker::parts::project_scan_indicator( + self.active_query.is_some(), + self.project(cx), + cx, + )) + .into_any_element(), + ) + } + + fn actions_menu( + &self, + _window: &mut Window, + _cx: &mut Context>, + ) -> Vec { + use gpui::Action as _; + vec![ + picker::PickerAction::header("Split…"), + picker::PickerAction::button( + "Left", + workspace::pane::SplitLeft::default().boxed_clone(), + ), + picker::PickerAction::button( + "Right", + workspace::pane::SplitRight::default().boxed_clone(), + ), + picker::PickerAction::button("Up", workspace::pane::SplitUp::default().boxed_clone()), + picker::PickerAction::button( + "Down", + workspace::pane::SplitDown::default().boxed_clone(), + ), + picker::PickerAction::separator(), + picker::PickerAction::button("Open File", menu::Confirm.boxed_clone()), + picker::PickerAction::button("To project search", super::ToProjectSearch.boxed_clone()), + ] + } + + fn match_count(&self) -> usize { + self.entries.len() + } + + fn can_select(&self, ix: usize, _window: &mut Window, _cx: &mut Context>) -> bool { + matches!(self.entries.get(ix), Some(Entry::Match(_))) + } + + fn selected_index(&self) -> usize { + self.selected_index + } + + fn select_on_hover(&self) -> bool { + false + } + + fn set_selected_index( + &mut self, + ix: usize, + _window: &mut Window, + _cx: &mut Context>, + ) { + self.selected_index = ix; + self.last_selection_change_time = Some(std::time::Instant::now()); + } + + fn update_matches( + &mut self, + query: String, + window: &mut Window, + cx: &mut Context>, + ) -> Task<()> { + self.cancel_flag + .store(true, std::sync::atomic::Ordering::SeqCst); + self.cancel_flag = Arc::new(AtomicBool::new(false)); + + let cancel_flag = Arc::clone(&self.cancel_flag); + let text_finder_turning_into_project_search = + Arc::clone(&self.text_finder_turning_into_project_search); + + // The picker runs `update_matches("")` once on open. When the text + // finder was opened from an existing project search, the query editor is + // empty but we have already plundered that search's matches. Preserve + // them on that first call, otherwise the modal would show up empty. + let imported_from_project_search = std::mem::take(&mut self.imported_from_project_search); + + let Some(search_query) = self.build_search_query(&query, cx) else { + if query.is_empty() && imported_from_project_search { + return Task::ready(()); + } + self.matches.clear(); + self.entries.clear(); + self.unique_files.clear(); + self.selected_index = 0; + self.active_query = None; + cx.notify(); + return Task::ready(()); + }; + + // Remember the exact query we are running so that a later switch to the + // project search hands over a query consistent with the results. + self.active_query = Some(search_query.clone()); + + let search_results = self.project_search_view.update(cx, |ps, cx| { + ps.entity.update(cx, |pr, cx| { + pr.project.update(cx, |p, cx| p.search(search_query, cx)) + }) + }); + + let (signal_done, match_updating_done) = futures::channel::oneshot::channel(); + self.in_progress_search = + InProgressSearch::Connected(cx.spawn_in(window, async move |picker, cx| { + cx.background_executor() + .timer(Duration::from_millis(SEARCH_DEBOUNCE_MS)) + .await; + + if cancel_flag.load(std::sync::atomic::Ordering::SeqCst) { + return None; + } + + let res = stream_results_to_picker( + cancel_flag, + text_finder_turning_into_project_search, + picker, + search_results, + ImportedMatches::No, + cx, + ) + .await; + + // We must own the search task so we can take out the search + // result stream in case we are transforming into project + // search. The picker relies on the task returned + // `PickerDelegate::update_matches` to detect when we are done + // updating. So we have a placeholder task that completes when + // this signal is send. + let _ = signal_done.send(()); + res + })); + + cx.notify(); + cx.spawn(async move |_, _| { + let _ = match_updating_done.await; + }) + } + + fn confirm(&mut self, _: bool, window: &mut Window, cx: &mut Context>) { + // Clicks (set_selected_index called immediately before confirm) require double-click. + // Enter key proceeds immediately. + let now = std::time::Instant::now(); + let is_click = self + .last_selection_change_time + .map(|t| now.duration_since(t).as_millis() < CLICK_THRESHOLD_MS) + .unwrap_or(false); + + if is_click { + let is_double_click = self + .last_click + .map(|(ix, t)| { + ix == self.selected_index + && now.duration_since(t).as_millis() < DOUBLE_CLICK_THRESHOLD_MS + }) + .unwrap_or(false); + self.last_click = Some((self.selected_index, now)); + + if !is_double_click { + cx.focus_self(window); + return; + } + } + + let Some(selected_match) = self.selected_search_match() else { + return; + }; + + let path = selected_match.path.clone(); + let line_number = selected_match.line_number; + + let Some(workspace) = self.project_search_view.read(cx).workspace.upgrade() else { + return; + }; + + let open_task = workspace.update(cx, |workspace, cx| { + workspace.open_path_preview(path, None, true, false, true, window, cx) + }); + + let row = line_number.saturating_sub(1); + cx.spawn_in(window, async move |_, cx| { + let item = open_task.await.log_err()?; + if let Some(active_editor) = item.downcast::() { + active_editor + .downgrade() + .update_in(cx, |editor, window, cx| { + editor.go_to_singleton_buffer_point(text::Point::new(row, 0), window, cx); + }) + .log_err(); + } + Some(()) + }) + .detach(); + + cx.emit(DismissEvent); + } + + fn dismissed(&mut self, _window: &mut Window, cx: &mut Context>) { + cx.emit(DismissEvent); + } + + fn try_get_preview_data_for_match(&self, _cx: &App) -> Option { + let m = self.selected_search_match()?; + Some(picker::PreviewUpdate::from_buffer( + m.buffer.clone(), + picker::MatchLocation { + anchor_range: m.anchor_range.clone(), + range: m.range.clone(), + }, + )) + } + + fn render_match( + &self, + ix: usize, + selected: bool, + _: &mut Window, + cx: &mut Context>, + ) -> Option { + match self.entries.get(ix)? { + Entry::Separator => Some( + div() + .py(DynamicSpacing::Base04.rems(cx)) + .child(Divider::horizontal()) + .into_any_element(), + ), + Entry::Header(path) => { + let path_style = self.project(cx).read(cx).path_style(cx); + let file_name = path + .path + .file_name() + .map(|name| name.to_string()) + .unwrap_or_default(); + let directory = path + .path + .parent() + .map(|parent| parent.display(path_style)) + .map(SharedString::new) + .unwrap_or_default(); + let file_icon = ItemSettings::get_global(cx) + .file_icons + .then(|| FileIcons::get_icon(path.path.as_std_path(), cx)) + .flatten() + .map(|icon| { + Icon::from_path(icon) + .color(Color::Muted) + .size(IconSize::Small) + }); + + Some( + h_flex() + .w_full() + .min_w_0() + .px(DynamicSpacing::Base06.rems(cx)) + .py_1() + .gap_1p5() + .children(file_icon) + .child( + h_flex() + .gap_1() + .child(Label::new(file_name).size(LabelSize::Small)) + .when(!directory.is_empty(), |this| { + this.child( + Label::new(directory) + .size(LabelSize::Small) + .color(Color::Muted) + .truncate_start(), + ) + }), + ) + .into_any_element(), + ) + } + Entry::Match(match_index) => { + let search_match = self.matches.get(*match_index)?; + Some( + ListItem::new(ix) + .spacing(ListItemSpacing::Sparse) + .inset(true) + .toggle_state(selected) + .child( + h_flex() + .w_full() + .min_w_0() + .gap_2p5() + .text_sm() + .child( + h_flex() + .w(rems( + (self.max_line_number.max(1).ilog10() + 1) as f32 * 0.5, + )) + .justify_end() + .child( + Label::new(search_match.line_number.to_string()).color( + Color::Custom( + cx.theme().colors().text_muted.opacity(0.5), + ), + ), + ), + ) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .child(render_matched_line(search_match, cx)), + ), + ) + .into_any_element(), + ) + } + } + } +} + +enum ImportedMatches { + No, + Yes, +} + +async fn stream_results_to_picker( + cancel_flag: Arc, + text_finder_turning_into_project_search: Arc, + picker: gpui::WeakEntity>, + search_results: SearchResults, + imported_matches: ImportedMatches, + cx: &mut AsyncApp, +) -> Option> { + let mut results_stream = std::pin::pin!( + search_results + .rx + .clone() + .ready_chunks(SEARCH_RESULTS_BATCH_SIZE) + ); + + let mut clear_existing = matches!(imported_matches, ImportedMatches::No); + while let Some(results) = results_stream.next().await { + if cancel_flag.load(std::sync::atomic::Ordering::SeqCst) { + break; + } + + let mut batch_matches = Vec::new(); + let mut limit_reached = false; + + for result in results { + match result { + SearchResult::Buffer { buffer, ranges } => { + let matches = Delegate::process_search_result(&buffer, &ranges, cx); + batch_matches.extend(matches); + } + SearchResult::LimitReached => { + limit_reached = true; + } + SearchResult::WaitingForScan | SearchResult::Searching => {} + } + } + + picker + .update(cx, |picker, cx| { + let delegate = &mut picker.delegate; + + if clear_existing { + delegate.matches.clear(); + delegate.entries.clear(); + delegate.unique_files.clear(); + delegate.selected_index = 0; + clear_existing = false; + } + + delegate + .unique_files + .extend(batch_matches.iter().map(|m| &m.path).cloned()); + delegate.matches.extend(batch_matches); + // Rebuild the grouped view and resnap the selection onto a + // selectable row (the header/separator rows are not selectable). + delegate.rebuild_entries(); + + cx.notify(); + }) + .log_err(); + + if limit_reached { + break; + } + + // Note the difference with the cancel flag. We need the results to be + // processed before taking out the search result stream. The cancel flag + // just needs to stop the search. + if text_finder_turning_into_project_search.load(Ordering::Relaxed) { + return Some(search_results); + } + + smol::future::yield_now().await; + } + None +} + +/// Renders the matched source line with syntax highlighting, overlaying the +/// search match with a highlighted background and bold weight. +fn render_matched_line(search_match: &SearchMatch, cx: &App) -> StyledText { + let settings = ThemeSettings::get_global(cx); + let text_style = TextStyle { + color: cx.theme().colors().text, + font_family: settings.buffer_font.family.clone(), + font_features: settings.buffer_font.features.clone(), + font_fallbacks: settings.buffer_font.fallbacks.clone(), + font_size: settings.buffer_font_size(cx).into(), + font_weight: settings.buffer_font.weight, + line_height: relative(1.), + ..Default::default() + }; + let original_line = &search_match.line_text; + let line_text = original_line.trim_start(); + let trim_offset = original_line.len() - line_text.len(); + + let search_match_style = HighlightStyle { + background_color: Some(cx.theme().colors().search_match_background), + font_weight: Some(gpui::FontWeight::BOLD), + ..Default::default() + }; + + let line_start_abs = search_match.range.start - search_match.relative_range.start; + let visible_start_abs = line_start_abs + trim_offset; + let visible_end_abs = line_start_abs + original_line.len(); + + // Syntax highlights for the visible (trimmed) portion of the line, with + // ranges relative to the start of the rendered text. + let snapshot = search_match.buffer.read(cx).snapshot(); + let syntax_theme = cx.theme().syntax(); + let mut syntax_highlights: Vec<(Range, HighlightStyle)> = Vec::new(); + let mut current_offset = 0; + for chunk in snapshot.chunks( + visible_start_abs..visible_end_abs, + LanguageAwareStyling { + tree_sitter: true, + diagnostics: false, + }, + ) { + let chunk_len = chunk.text.len(); + if let Some(style) = chunk + .syntax_highlight_id + .and_then(|id| syntax_theme.get(id).copied()) + { + syntax_highlights.push((current_offset..current_offset + chunk_len, style)); + } + current_offset += chunk_len; + } + + // The search match range, clamped to the visible area and made relative to + // the start of the rendered text. + let match_start = search_match + .range + .start + .clamp(visible_start_abs, visible_end_abs); + let match_end = search_match + .range + .end + .clamp(visible_start_abs, visible_end_abs); + let match_highlight = ( + match_start - visible_start_abs..match_end - visible_start_abs, + search_match_style, + ); + + let highlights = gpui::combine_highlights(syntax_highlights, [match_highlight]); + + StyledText::new(line_text.to_string()).with_default_highlights(&text_style, highlights) +} + +impl Delegate { + pub(crate) fn build_search_query( + &mut self, + query: &str, + cx: &mut Context>, + ) -> Option { + if query.is_empty() { + return None; + } + + // Reuse the include/exclude filters configured on the shared project + // search view so the text finder respects them too. + let (files_to_include, files_to_exclude) = + self.project_search_view.read(cx).file_path_filters(cx); + + // If the project contains multiple visible worktrees, we match the + // include/exclude patterns against full paths to allow them to be + // disambiguated. For single worktree projects we use worktree relative + // paths for convenience. + let match_full_paths = self.project(cx).read(cx).visible_worktrees(cx).count() > 1; + let open_buffers = None; + + self.search_options + .build_query( + query, + files_to_include, + files_to_exclude, + match_full_paths, + open_buffers, + ) + .log_err() + } + + /// Create things from MB + pub(crate) fn process_search_result( + buffer: &Entity, + ranges: &[Range], + cx: &AsyncApp, + ) -> Vec { + if ranges.is_empty() { + return Vec::new(); + } + + buffer.read_with(cx, |buf, cx| { + let file = buf.file(); + let path = file.map(|f| ProjectPath { + worktree_id: f.worktree_id(cx), + path: f.path().clone(), + }); + let text = buf.text(); + + let mut matches = Vec::new(); + for anchor_range in ranges { + let start_offset: usize = buf.summary_for_anchor(&anchor_range.start); + let end_offset: usize = buf.summary_for_anchor(&anchor_range.end); + let match_row = buf.offset_to_point(start_offset).row; + let line_number = match_row + 1; + let line_start = text[..start_offset].rfind('\n').map(|i| i + 1).unwrap_or(0); + let line_end = text[start_offset..] + .find('\n') + .map(|i| start_offset + i) + .unwrap_or(text.len()); + let line_text = text[line_start..line_end].to_string(); + + let relative_start = start_offset - line_start; + let relative_end = end_offset - line_start; + + if let Some(path) = &path { + matches.push(SearchMatch { + path: path.clone(), + buffer: buffer.clone(), + anchor_range: anchor_range.clone(), + range: start_offset..end_offset, + relative_range: relative_start..relative_end, + line_text, + line_number, + }); + } + } + matches + }) + } +} diff --git a/crates/search/src/text_finder/render.rs b/crates/search/src/text_finder/render.rs new file mode 100644 index 0000000000..d36587ce46 --- /dev/null +++ b/crates/search/src/text_finder/render.rs @@ -0,0 +1,20 @@ +use gpui::KeyContext; +use ui::{Context, InteractiveElement, IntoElement, ParentElement, Render, Window, v_flex}; + +use super::TextFinder; + +impl Render for TextFinder { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let mut key_context = KeyContext::new_with_defaults(); + key_context.add("TextFinder"); + + v_flex() + .key_context(key_context) + .on_action(cx.listener(Self::to_project_search)) + .on_action(cx.listener(Self::split_left)) + .on_action(cx.listener(Self::split_right)) + .on_action(cx.listener(Self::split_up)) + .on_action(cx.listener(Self::split_down)) + .child(self.picker.clone()) + } +} diff --git a/crates/settings/src/settings.rs b/crates/settings/src/settings.rs index 1b75f9395e..cec4aac903 100644 --- a/crates/settings/src/settings.rs +++ b/crates/settings/src/settings.rs @@ -155,6 +155,17 @@ pub fn vim_keymap() -> Cow<'static, str> { asset_str::(VIM_KEYMAP_PATH) } +/// Specific keybinding overrides. Loaded after the base keymap so they win over +/// conflicting base-keymap (and default `Editor`) bindings for the same chords, +/// while still allowing user keymaps (loaded last) to override them. Shared +/// across features - prefer adding a context block here over creating another +/// override keymap file. +#[cfg(target_os = "macos")] +pub const SPECIFIC_OVERRIDES_KEYMAP_PATH: &str = "keymaps/specific-overrides-macos.json"; + +#[cfg(not(target_os = "macos"))] +pub const SPECIFIC_OVERRIDES_KEYMAP_PATH: &str = "keymaps/specific-overrides.json"; + pub fn initial_user_settings_content() -> Cow<'static, str> { asset_str::("settings/initial_user_settings.json") } diff --git a/crates/settings_profile_selector/src/settings_profile_selector.rs b/crates/settings_profile_selector/src/settings_profile_selector.rs index c273a08ce7..d3cdde3a39 100644 --- a/crates/settings_profile_selector/src/settings_profile_selector.rs +++ b/crates/settings_profile_selector/src/settings_profile_selector.rs @@ -149,6 +149,10 @@ impl SettingsProfileSelectorDelegate { impl PickerDelegate for SettingsProfileSelectorDelegate { type ListItem = ListItem; + fn name() -> &'static str { + "settings profile selector" + } + fn placeholder_text(&self, _: &mut Window, _: &mut App) -> std::sync::Arc { "Select a settings profile...".into() } diff --git a/crates/settings_ui/src/components/font_picker.rs b/crates/settings_ui/src/components/font_picker.rs index 564d98c6d2..9ede232f79 100644 --- a/crates/settings_ui/src/components/font_picker.rs +++ b/crates/settings_ui/src/components/font_picker.rs @@ -56,6 +56,10 @@ impl FontPickerDelegate { impl PickerDelegate for FontPickerDelegate { type ListItem = AnyElement; + fn name() -> &'static str { + "font picker" + } + fn match_count(&self) -> usize { self.filtered_fonts.len() } @@ -176,6 +180,7 @@ pub fn font_picker( Picker::uniform_list(delegate, window, cx) .show_scrollbar(true) - .width(rems_from_px(210.)) - .max_height(Some(rems(18.).into())) + .minimum_results_width(rems_from_px(210.)) + .height(rems(18.)) + .no_vertical_padding() } diff --git a/crates/settings_ui/src/components/icon_theme_picker.rs b/crates/settings_ui/src/components/icon_theme_picker.rs index f369a8207d..1f01bb93ee 100644 --- a/crates/settings_ui/src/components/icon_theme_picker.rs +++ b/crates/settings_ui/src/components/icon_theme_picker.rs @@ -59,6 +59,10 @@ impl IconThemePickerDelegate { impl PickerDelegate for IconThemePickerDelegate { type ListItem = AnyElement; + fn name() -> &'static str { + "icon theme picker" + } + fn match_count(&self) -> usize { self.filtered_themes.len() } @@ -189,6 +193,7 @@ pub fn icon_theme_picker( Picker::uniform_list(delegate, window, cx) .show_scrollbar(true) - .width(rems_from_px(210.)) - .max_height(Some(rems(18.).into())) + .minimum_results_width(rems_from_px(210.)) + .height(rems(18.)) + .no_vertical_padding() } diff --git a/crates/settings_ui/src/components/ollama_model_picker.rs b/crates/settings_ui/src/components/ollama_model_picker.rs index b39b2fc61e..d628bcfc4b 100644 --- a/crates/settings_ui/src/components/ollama_model_picker.rs +++ b/crates/settings_ui/src/components/ollama_model_picker.rs @@ -62,6 +62,10 @@ impl OllamaModelPickerDelegate { impl PickerDelegate for OllamaModelPickerDelegate { type ListItem = AnyElement; + fn name() -> &'static str { + "ollama model picker" + } + fn match_count(&self) -> usize { self.filtered_models.len() } @@ -200,8 +204,9 @@ pub fn render_ollama_model_picker( Picker::uniform_list(delegate, window, cx) .show_scrollbar(true) - .width(rems_from_px(210.)) - .max_height(Some(rems(18.).into())) + .minimum_results_width(rems_from_px(210.)) + .height(rems(18.)) + .no_vertical_padding() })) }) .anchor(gpui::Anchor::TopLeft) diff --git a/crates/settings_ui/src/components/theme_picker.rs b/crates/settings_ui/src/components/theme_picker.rs index a1f1339a7a..89253692fd 100644 --- a/crates/settings_ui/src/components/theme_picker.rs +++ b/crates/settings_ui/src/components/theme_picker.rs @@ -54,6 +54,10 @@ impl ThemePickerDelegate { impl PickerDelegate for ThemePickerDelegate { type ListItem = AnyElement; + fn name() -> &'static str { + "theme picker" + } + fn match_count(&self) -> usize { self.filtered_themes.len() } @@ -174,6 +178,7 @@ pub fn theme_picker( Picker::uniform_list(delegate, window, cx) .show_scrollbar(true) - .width(rems_from_px(210.)) - .max_height(Some(rems(18.).into())) + .minimum_results_width(rems_from_px(210.)) + .height(rems(18.)) + .no_vertical_padding() } diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index 12c1816d6d..83833a4a78 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -3516,7 +3516,7 @@ fn search_and_files_page() -> SettingsPage { ] } - fn file_finder_section() -> [SettingsPageItem; 5] { + fn file_finder_section() -> [SettingsPageItem; 4] { [ SettingsPageItem::SectionHeader("File Finder"), // todo: null by default @@ -3562,29 +3562,6 @@ fn search_and_files_page() -> SettingsPage { metadata: None, files: USER, }), - SettingsPageItem::SettingItem(SettingItem { - title: "Modal Max Width", - description: "Determines how much space the file finder can take up in relation to the available window width.", - field: Box::new(SettingField { - organization_override: None, - json_path: Some("file_finder.modal_max_width"), - pick: |settings_content| { - settings_content - .file_finder - .as_ref()? - .modal_max_width - .as_ref() - }, - write: |settings_content, value, _| { - settings_content - .file_finder - .get_or_insert_default() - .modal_max_width = value; - }, - }), - metadata: None, - files: USER, - }), SettingsPageItem::SettingItem(SettingItem { title: "Skip Focus For Active In Search", description: "Whether the file finder should skip focus for the active file in search results.", diff --git a/crates/settings_ui/src/settings_ui.rs b/crates/settings_ui/src/settings_ui.rs index 1428c72901..8e98af5cca 100644 --- a/crates/settings_ui/src/settings_ui.rs +++ b/crates/settings_ui/src/settings_ui.rs @@ -561,7 +561,6 @@ fn init_renderers(cx: &mut App) { .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) - .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) diff --git a/crates/snippets_ui/src/snippets_ui.rs b/crates/snippets_ui/src/snippets_ui.rs index ffb136f625..9d9ad8f898 100644 --- a/crates/snippets_ui/src/snippets_ui.rs +++ b/crates/snippets_ui/src/snippets_ui.rs @@ -199,6 +199,10 @@ impl ScopeSelectorDelegate { impl PickerDelegate for ScopeSelectorDelegate { type ListItem = ListItem; + fn name() -> &'static str { + "snippet scope selector" + } + fn placeholder_text(&self, _window: &mut Window, _: &mut App) -> Arc { "Select snippet scope...".into() } diff --git a/crates/tab_switcher/src/tab_switcher.rs b/crates/tab_switcher/src/tab_switcher.rs index 7edaaf46c7..5e3701ced7 100644 --- a/crates/tab_switcher/src/tab_switcher.rs +++ b/crates/tab_switcher/src/tab_switcher.rs @@ -717,6 +717,10 @@ impl TabSwitcherDelegate { impl PickerDelegate for TabSwitcherDelegate { type ListItem = ListItem; + fn name() -> &'static str { + "tab switcher" + } + fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { "Search all tabs…".into() } diff --git a/crates/tasks_ui/src/modal.rs b/crates/tasks_ui/src/modal.rs index 22ad76ca6e..a590002aeb 100644 --- a/crates/tasks_ui/src/modal.rs +++ b/crates/tasks_ui/src/modal.rs @@ -237,6 +237,10 @@ const MAX_TAGS_LINE_LEN: usize = 30; impl PickerDelegate for TasksModalDelegate { type ListItem = ListItem; + fn name() -> &'static str { + "tasks modal" + } + fn match_count(&self) -> usize { self.matches.len() } diff --git a/crates/theme_selector/src/icon_theme_selector.rs b/crates/theme_selector/src/icon_theme_selector.rs index 9abcd9a40c..f677ce27da 100644 --- a/crates/theme_selector/src/icon_theme_selector.rs +++ b/crates/theme_selector/src/icon_theme_selector.rs @@ -162,6 +162,10 @@ impl IconThemeSelectorDelegate { impl PickerDelegate for IconThemeSelectorDelegate { type ListItem = ui::ListItem; + fn name() -> &'static str { + "icon theme selector" + } + fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { "Select Icon Theme...".into() } diff --git a/crates/theme_selector/src/theme_selector.rs b/crates/theme_selector/src/theme_selector.rs index 5fc3217f63..a245e95529 100644 --- a/crates/theme_selector/src/theme_selector.rs +++ b/crates/theme_selector/src/theme_selector.rs @@ -378,6 +378,10 @@ fn retain_original_opposing_theme( impl PickerDelegate for ThemeSelectorDelegate { type ListItem = ui::ListItem; + fn name() -> &'static str { + "theme selector" + } + fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { "Select Theme...".into() } diff --git a/crates/toolchain_selector/src/toolchain_selector.rs b/crates/toolchain_selector/src/toolchain_selector.rs index 010003cd57..a85b722a6d 100644 --- a/crates/toolchain_selector/src/toolchain_selector.rs +++ b/crates/toolchain_selector/src/toolchain_selector.rs @@ -898,6 +898,10 @@ impl ToolchainSelectorDelegate { impl PickerDelegate for ToolchainSelectorDelegate { type ListItem = ListItem; + fn name() -> &'static str { + "toolchain selector" + } + fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { self.placeholder_text.clone() } diff --git a/crates/ui_input/src/ui_input.rs b/crates/ui_input/src/ui_input.rs index 595f16d849..d913a2e708 100644 --- a/crates/ui_input/src/ui_input.rs +++ b/crates/ui_input/src/ui_input.rs @@ -21,6 +21,7 @@ pub trait ErasedEditor: 'static { fn move_selection_to_end(&self, window: &mut Window, _: &mut App); fn set_masked(&self, masked: bool, window: &mut Window, cx: &mut App); fn set_read_only(&self, read_only: bool, cx: &mut App); + fn set_multiline(&self, max_lines: Option, window: &mut Window, cx: &mut App); fn focus_handle(&self, cx: &App) -> FocusHandle; diff --git a/crates/vim/src/state.rs b/crates/vim/src/state.rs index b92adf4cb5..a5aa8ba4b5 100644 --- a/crates/vim/src/state.rs +++ b/crates/vim/src/state.rs @@ -1264,6 +1264,10 @@ pub struct RegistersViewDelegate { impl PickerDelegate for RegistersViewDelegate { type ListItem = Div; + fn name() -> &'static str { + "registers view" + } + fn match_count(&self) -> usize { self.matches.len() } @@ -1430,7 +1434,7 @@ impl RegistersView { }; Picker::nonsearchable_uniform_list(delegate, window, cx) - .width(rems(36.)) + .initial_width(rems(36.)) .modal(true) } } @@ -1478,6 +1482,10 @@ pub struct MarksViewDelegate { impl PickerDelegate for MarksViewDelegate { type ListItem = Div; + fn name() -> &'static str { + "marks view" + } + fn match_count(&self) -> usize { self.matches.len() } @@ -1793,7 +1801,7 @@ impl MarksView { workspace, }; Picker::nonsearchable_uniform_list(delegate, window, cx) - .width(rems(36.)) + .initial_width(rems(36.)) .modal(true) } } diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index e396a4b301..9aef010325 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -66,9 +66,9 @@ use rope::Rope; use search::project_search::ProjectSearchBar; use settings::{ BaseKeymap, DEFAULT_KEYMAP_PATH, DefaultOpenBehavior, InvalidSettingsError, KeybindSource, - KeymapFile, KeymapFileLoadResult, MigrationStatus, Settings, SettingsFile, SettingsStore, - VIM_KEYMAP_PATH, initial_local_debug_tasks_content, initial_project_settings_content, - initial_tasks_content, update_settings_file, + KeymapFile, KeymapFileLoadResult, MigrationStatus, SPECIFIC_OVERRIDES_KEYMAP_PATH, Settings, + SettingsFile, SettingsStore, VIM_KEYMAP_PATH, initial_local_debug_tasks_content, + initial_project_settings_content, initial_tasks_content, update_settings_file, }; use sidebar::Sidebar; #[cfg(debug_assertions)] @@ -2244,6 +2244,15 @@ pub fn load_default_keymap(cx: &mut App) { KeymapFile::load_asset(VIM_KEYMAP_PATH, Some(KeybindSource::Vim), cx).unwrap(), ); } + + cx.bind_keys( + KeymapFile::load_asset( + SPECIFIC_OVERRIDES_KEYMAP_PATH, + Some(KeybindSource::Default), + cx, + ) + .unwrap(), + ); } pub fn open_new_ssh_project_from_project( @@ -5358,6 +5367,7 @@ mod tests { "task", "terminal", "terminal_panel", + "text_finder", "theme", "theme_selector", "toast", diff --git a/crates/zed_actions/src/lib.rs b/crates/zed_actions/src/lib.rs index c16bf5ef22..0fe1b3fba0 100644 --- a/crates/zed_actions/src/lib.rs +++ b/crates/zed_actions/src/lib.rs @@ -372,6 +372,18 @@ pub mod command_palette { ); } +pub mod text_finder { + use gpui::actions; + + actions!( + text_finder, + [ + /// Opens the Project Search Picker. + Toggle, + ] + ); +} + pub mod project_panel { use gpui::actions; diff --git a/script/check-keymaps b/script/check-keymaps index 44745fa8b8..abda2118f8 100755 --- a/script/check-keymaps +++ b/script/check-keymaps @@ -7,6 +7,7 @@ result=$(git grep --no-color --line-number --fixed-strings -e "$pattern" -- \ 'assets/keymaps/' \ ':(exclude)assets/keymaps/storybook.json' \ ':(exclude)assets/keymaps/default-macos.json' \ + ':(exclude)assets/keymaps/specific-overrides-macos.json' \ ':(exclude)assets/keymaps/macos/*.json' || true) if [[ -n "${result}" ]]; then