From 6a6fcbebc48d914b0e4bde077851a4c5fc1c5dfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yara=20=F0=9F=8F=B3=EF=B8=8F=E2=80=8D=E2=9A=A7=EF=B8=8F?= Date: Fri, 19 Jun 2026 19:43:07 +0200 Subject: [PATCH] Add preview to pickers and make them resizable (#59604) Overhauls Zed's pickers to make them resizable and give them a preview. Closes #8279 ### Background The most requested Zed feature has the last year has been a [Telescope like search box](https://github.com/zed-industries/zed/issues/8279) [discussion](https://github.com/zed-industries/zed/discussions/22581). To understand why this is so popular we need to understand search can serve thee goals: - Navigation: fuzzy search is faster & easier then clicking in a file tree - Exploration: example, find a function by a word in its doc comment - Collecting: example, getting a list of functions to change The project search which shows results in a multibuffer is the perfect way to operate on a list of items. Navigation and Exploration need a lot of context around each result and offer fast navigation between them. For both of these live searching is also critical. The `telescope UI` is a picker with a preview to the right or below. It's offered in various editors and IDE's most famously Neovim (through the Telescope plugin), IntelliJ (natively), Helix (natively) and of course VScode (plugins) and it's _many_ forks. While having a UI like that for text search (our project search) is most requested the UX pattern is applied widely, from `find_all_references` to `bookmarks`. It enhances most pickers. Note that we have over 50 different picker modals! The community has tried to build something like this for Zed: - https://github.com/zed-industries/zed/pull/44530 - https://github.com/zed-industries/zed/pull/45307 - https://github.com/zed-industries/zed/pull/46478 - https://github.com/zed-industries/zed/pull/43790 These all became huge PR's that we could not merge for various reasons. This is a really hard feature to integrate in Zed! This PR got started as https://github.com/zed-industries/zed/pull/46478 and supercedes that. ### Design - Extend pickers to support an optional preview with minimal changes to the pickers themselves. - Make pickers resizable. - Complement the existing search do not replace it by having both UI's share the underlying search and allow freely switching between them. - Allow extending the preview to things other then files. - Maintain a clean design on all the pickers. ### Heigh level Implementation overview - Adds an `Option` to `Picker` - Gives `PickerDelegate` a method to communicate a preview to the Picker - Overhaul the way pickers are drawn to allow for resizing them. Implemented on the `Shape` and `SizeBouds` structs. - Adds a high level way to draw the `footer` and `editor` so we do not need to change much to the pickers. - Adds a new text finder Picker - Adds a way to take a running search from project search and hand it to the text finder Picker and the other way round - Give the file finder a preview ### Next steps A more detailed list and how to help out will be added to the tracking issue for [Pickes with previews](https://github.com/zed-industries/zed/issues/56037) - Add more previews to more pickers! - Enable selectioning multiple items in pickers and performing actions on those - Open selected items in a multibuffer - Add a way to restore the last picker - Make popovers (picker attached to some menu) resizable as well ## Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable ## Showcase TODO (will be done post merge) --- Release Notes: - Added resizing via dragging to all picker modals. - Added a preview to the File finder, the preview can be to the right or below. - Added a Text finder picker with a preview as alternative project search UI. The search is shared and allowes switch between UIs while running. --------- Co-authored-by: ozacod <47009516+ozacod@users.noreply.github.com> Co-authored-by: ozacod Co-authored-by: Danilo Leal --- Cargo.lock | 13 +- assets/keymaps/default-linux.json | 25 +- assets/keymaps/default-macos.json | 33 +- assets/keymaps/default-windows.json | 26 +- assets/keymaps/specific-overrides-macos.json | 42 + assets/keymaps/specific-overrides.json | 39 + crates/agent/src/tools/grep_tool.rs | 2 +- .../src/agent_configuration/tool_picker.rs | 4 + crates/agent_ui/src/agent_panel.rs | 6 + crates/agent_ui/src/config_options.rs | 9 +- .../agent_ui/src/language_model_selector.rs | 9 +- crates/agent_ui/src/model_selector.rs | 9 +- crates/agent_ui/src/profile_selector.rs | 9 +- crates/agent_ui/src/threads_archive_view.rs | 4 + .../src/collab_panel/channel_modal.rs | 4 + .../src/collab_panel/contact_finder.rs | 4 + crates/command_palette/src/command_palette.rs | 11 +- crates/debugger_ui/src/attach_modal.rs | 4 + crates/debugger_ui/src/new_process_modal.rs | 6 + crates/dev_container/src/lib.rs | 8 + crates/editor/src/display_map.rs | 1 + crates/editor/src/editor.rs | 17 +- .../src/encoding_selector.rs | 4 + .../src/extension_version_selector.rs | 4 + crates/extensions_ui/src/extensions_ui.rs | 7 +- crates/file_finder/Cargo.toml | 1 - crates/file_finder/src/file_finder.rs | 377 ++---- crates/file_finder/src/file_finder_tests.rs | 65 +- crates/git_ui/src/branch_picker.rs | 11 +- crates/git_ui/src/git_graph.rs | 4 + crates/git_ui/src/picker_prompt.rs | 4 + crates/git_ui/src/repository_selector.rs | 21 +- crates/git_ui/src/stash_picker.rs | 7 + crates/git_ui/src/worktree_picker.rs | 8 +- crates/gpui/src/geometry.rs | 14 +- crates/gpui/src/key_dispatch.rs | 60 + crates/language/src/buffer.rs | 21 +- .../src/language_selector.rs | 4 + .../src/line_ending_selector.rs | 4 + crates/onboarding/src/base_keymap_picker.rs | 4 + .../open_path_prompt/src/open_path_prompt.rs | 7 +- .../src/open_path_prompt_tests.rs | 2 +- crates/outline/src/outline.rs | 16 +- crates/picker/Cargo.toml | 9 + crates/picker/src/footer.rs | 228 ++++ crates/picker/src/head.rs | 4 - crates/picker/src/parts.rs | 27 + crates/picker/src/persistence.rs | 125 ++ crates/picker/src/picker.rs | 542 ++++---- crates/picker/src/preview.rs | 352 +++++ crates/picker/src/preview/render.rs | 82 ++ crates/picker/src/preview/state.rs | 0 crates/picker/src/render.rs | 370 ++++++ crates/picker/src/render/window_controls.rs | 501 ++++++++ crates/picker/src/shape.rs | 654 ++++++++++ crates/project/src/project_search.rs | 6 +- crates/project/src/search_history.rs | 5 + crates/project_symbols/src/project_symbols.rs | 6 +- crates/recent_projects/src/recent_projects.rs | 13 +- crates/recent_projects/src/remote_servers.rs | 6 +- .../src/sidebar_recent_projects.rs | 4 + crates/recent_projects/src/wsl_picker.rs | 4 + crates/repl/src/components/kernel_options.rs | 9 +- crates/search/Cargo.toml | 5 +- crates/search/src/project_search.rs | 424 +++--- crates/search/src/search.rs | 46 +- crates/search/src/text_finder.rs | 289 +++++ crates/search/src/text_finder/delegate.rs | 1136 +++++++++++++++++ crates/search/src/text_finder/render.rs | 20 + crates/settings/src/settings.rs | 11 + .../src/settings_profile_selector.rs | 4 + .../settings_ui/src/components/font_picker.rs | 9 +- .../src/components/icon_theme_picker.rs | 9 +- .../src/components/ollama_model_picker.rs | 9 +- .../src/components/theme_picker.rs | 9 +- crates/settings_ui/src/page_data.rs | 25 +- crates/settings_ui/src/settings_ui.rs | 1 - crates/snippets_ui/src/snippets_ui.rs | 4 + crates/tab_switcher/src/tab_switcher.rs | 4 + crates/tasks_ui/src/modal.rs | 4 + .../theme_selector/src/icon_theme_selector.rs | 4 + crates/theme_selector/src/theme_selector.rs | 4 + .../src/toolchain_selector.rs | 4 + crates/ui_input/src/ui_input.rs | 1 + crates/vim/src/state.rs | 12 +- crates/zed/src/zed.rs | 16 +- crates/zed_actions/src/lib.rs | 12 + script/check-keymaps | 1 + 88 files changed, 5121 insertions(+), 819 deletions(-) create mode 100644 assets/keymaps/specific-overrides-macos.json create mode 100644 assets/keymaps/specific-overrides.json create mode 100644 crates/picker/src/footer.rs create mode 100644 crates/picker/src/parts.rs create mode 100644 crates/picker/src/persistence.rs create mode 100644 crates/picker/src/preview.rs create mode 100644 crates/picker/src/preview/render.rs create mode 100644 crates/picker/src/preview/state.rs create mode 100644 crates/picker/src/render.rs create mode 100644 crates/picker/src/render/window_controls.rs create mode 100644 crates/picker/src/shape.rs create mode 100644 crates/search/src/text_finder.rs create mode 100644 crates/search/src/text_finder/delegate.rs create mode 100644 crates/search/src/text_finder/render.rs 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