diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 30eaed6bd66d59..1c8709fed86b78 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -41,6 +41,7 @@ mod persistence; mod runnables; mod rust_analyzer_ext; pub mod scroll; +pub mod selection_controls; mod selections_collection; pub mod semantic_tokens; mod split; @@ -594,6 +595,50 @@ pub struct ContextMenuOptions { pub placement: Option, } +/// Controls which context-menu action groups are shown for an editor surface. +/// +/// The default policy preserves normal Zed behavior. Embedded hosts, such as +/// Cherrypick, can install a restricted policy when the host owns those actions +/// or intentionally omits them from its file-editor surface. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct EditorSurfacePolicy { + pub show_language_actions: bool, + pub show_agent_actions: bool, + pub show_preview_actions: bool, + pub show_terminal_actions: bool, + pub show_git_actions: bool, +} + +impl EditorSurfacePolicy { + /// Shows every action group used by the standard Zed editor. + pub const fn full() -> Self { + Self { + show_language_actions: true, + show_agent_actions: true, + show_preview_actions: true, + show_terminal_actions: true, + show_git_actions: true, + } + } + + /// Hides action groups that are handled by, or unavailable in, an embedded host. + pub const fn embedded() -> Self { + Self { + show_language_actions: false, + show_agent_actions: false, + show_preview_actions: false, + show_terminal_actions: false, + show_git_actions: false, + } + } +} + +impl Default for EditorSurfacePolicy { + fn default() -> Self { + Self::full() + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum ContextMenuPlacement { Above, @@ -1080,6 +1125,7 @@ pub struct Editor { show_selection_menu: Option, blame: Option>, blame_subscription: Option, + surface_policy: EditorSurfacePolicy, custom_context_menu: Option< Box< dyn 'static @@ -1736,6 +1782,7 @@ impl Editor { clone.needs_initial_data_update = self.enable_lsp_data; clone.enable_runnables = self.enable_runnables; clone.enable_code_lens = self.enable_code_lens; + clone.surface_policy = self.surface_policy; clone } @@ -2305,6 +2352,7 @@ impl Editor { }), blame: None, blame_subscription: None, + surface_policy: EditorSurfacePolicy::default(), bookmark_store, breakpoint_store, @@ -2730,11 +2778,15 @@ impl Editor { _: &mut Window, cx: &mut Context, ) { + let was_enabled = self.selection_menu_enabled(cx); self.show_selection_menu = self .show_selection_menu .map(|show_selections_menu| !show_selections_menu) .or_else(|| Some(!EditorSettings::get_global(cx).toolbar.selections_menu)); + if self.selection_menu_enabled(cx) != was_enabled { + cx.emit(EditorEvent::SelectionMenuChanged); + } cx.notify(); } @@ -2964,6 +3016,18 @@ impl Editor { self.in_project_search = in_project_search; } + /// Updates context-menu action visibility for this editor surface. + /// + /// Standalone Zed leaves the default policy in place; embedded hosts call + /// this to hide actions that the host owns or does not expose. + pub fn set_surface_policy(&mut self, policy: EditorSurfacePolicy, cx: &mut Context) { + if self.surface_policy == policy { + return; + } + self.surface_policy = policy; + cx.notify(); + } + pub fn set_custom_context_menu( &mut self, f: impl 'static @@ -11628,6 +11692,7 @@ pub enum EditorEvent { Saved, TitleChanged, FileHandleChanged, + SelectionMenuChanged, SelectionsChanged { local: bool, }, diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 21b62d96ab201b..5398f266756f25 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -843,6 +843,7 @@ fn test_clone(cx: &mut TestAppContext) { }); _ = editor.update(cx, |editor, window, cx| { + editor.set_surface_policy(EditorSurfacePolicy::embedded(), cx); editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges( selection_ranges @@ -915,6 +916,24 @@ fn test_clone(cx: &mut TestAppContext) { .display_ranges(&e.display_snapshot(cx))) .unwrap() ); + assert_eq!( + cloned_editor + .update(cx, |editor, _, _| editor.surface_policy) + .unwrap(), + EditorSurfacePolicy::embedded() + ); +} + +#[test] +fn test_embedded_surface_policy_hides_host_owned_actions() { + let policy = EditorSurfacePolicy::embedded(); + + assert!(!policy.show_language_actions); + assert!(!policy.show_agent_actions); + assert!(!policy.show_preview_actions); + assert!(!policy.show_terminal_actions); + assert!(!policy.show_git_actions); + assert_eq!(EditorSurfacePolicy::default(), EditorSurfacePolicy::full()); } #[gpui::test] diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs index f61546e289a7db..9a8682b07ac35d 100644 --- a/crates/editor/src/items.rs +++ b/crates/editor/src/items.rs @@ -60,7 +60,6 @@ use workspace::{ use zed_actions::preview::{ markdown::OpenPreview as OpenMarkdownPreview, svg::OpenPreview as OpenSvgPreview, }; - pub const MAX_TAB_TITLE_LEN: usize = 24; impl FollowableItem for Editor { @@ -1173,6 +1172,10 @@ impl Item for Editor { _window: &mut Window, cx: &mut Context, ) -> Vec<(SharedString, Box)> { + if !self.surface_policy.show_preview_actions { + return Vec::new(); + } + let mut actions = Vec::new(); let is_markdown = self diff --git a/crates/editor/src/mouse_context_menu.rs b/crates/editor/src/mouse_context_menu.rs index 879384fb6e65fd..56b6bd0047f51b 100644 --- a/crates/editor/src/mouse_context_menu.rs +++ b/crates/editor/src/mouse_context_menu.rs @@ -205,8 +205,9 @@ pub fn deploy_context_menu( .all::(&display_map) .into_iter() .any(|s| !s.is_empty()); - let has_git_repo = - buffer + let surface_policy = editor.surface_policy; + let has_git_repo = surface_policy.show_git_actions + && buffer .anchor_to_buffer_anchor(anchor) .is_some_and(|(buffer_anchor, _)| { project @@ -225,23 +226,25 @@ pub fn deploy_context_menu( cx, ); - let is_markdown = editor - .buffer() - .read(cx) - .as_singleton() - .and_then(|buffer| buffer.read(cx).language()) - .is_some_and(|language| language.name().as_ref() == "Markdown"); + let is_markdown = surface_policy.show_preview_actions + && editor + .buffer() + .read(cx) + .as_singleton() + .and_then(|buffer| buffer.read(cx).language()) + .is_some_and(|language| language.name().as_ref() == "Markdown"); - let is_svg = editor - .buffer() - .read(cx) - .as_singleton() - .and_then(|buffer| buffer.read(cx).file()) - .is_some_and(|file| { - std::path::Path::new(file.file_name(cx)) - .extension() - .is_some_and(|ext| ext.eq_ignore_ascii_case("svg")) - }); + let is_svg = surface_policy.show_preview_actions + && editor + .buffer() + .read(cx) + .as_singleton() + .and_then(|buffer| buffer.read(cx).file()) + .is_some_and(|file| { + std::path::Path::new(file.file_name(cx)) + .extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case("svg")) + }); ui::ContextMenu::build(window, cx, |menu, _window, _cx| { let builder = menu @@ -256,31 +259,39 @@ pub fn deploy_context_menu( run_to_cursor || (evaluate_selection && has_selections), |builder| builder.separator(), ) - .action("Go to Definition", Box::new(GoToDefinition)) - .action("Go to Declaration", Box::new(GoToDeclaration)) - .action("Go to Type Definition", Box::new(GoToTypeDefinition)) - .action("Go to Implementation", Box::new(GoToImplementation)) - .action( - "Find All References", - Box::new(FindAllReferences::default()), - ) - .separator() - .action("Rename Symbol", Box::new(Rename)) - .action("Format Buffer", Box::new(Format)) - .when(format_selections, |cx| { - cx.action("Format Selections", Box::new(FormatSelections)) + .when(surface_policy.show_language_actions, |builder| { + builder + .action("Go to Definition", Box::new(GoToDefinition)) + .action("Go to Declaration", Box::new(GoToDeclaration)) + .action("Go to Type Definition", Box::new(GoToTypeDefinition)) + .action("Go to Implementation", Box::new(GoToImplementation)) + .action( + "Find All References", + Box::new(FindAllReferences::default()), + ) + .separator() + .action("Rename Symbol", Box::new(Rename)) + .action("Format Buffer", Box::new(Format)) + .when(format_selections, |cx| { + cx.action("Format Selections", Box::new(FormatSelections)) + }) + .action( + "Show Code Actions", + Box::new(ToggleCodeActions { + deployed_from: None, + quick_launch: false, + }), + ) }) - .action( - "Show Code Actions", - Box::new(ToggleCodeActions { - deployed_from: None, - quick_launch: false, - }), + .when( + surface_policy.show_agent_actions && !disable_ai && has_selections, + |this| this.action("Add to Agent Thread", Box::new(AddSelectionToThread)), + ) + .when( + surface_policy.show_language_actions + || (surface_policy.show_agent_actions && !disable_ai && has_selections), + |builder| builder.separator(), ) - .when(!disable_ai && has_selections, |this| { - this.action("Add to Agent Thread", Box::new(AddSelectionToThread)) - }) - .separator() .action("Cut", Box::new(Cut)) .action("Copy", Box::new(Copy)) .action("Copy and Trim", Box::new(CopyAndTrim)) @@ -297,21 +308,26 @@ pub fn deploy_context_menu( .when(is_svg, |builder| { builder.action("Open SVG Preview", Box::new(OpenSvgPreview)) }) - .action_disabled_when( - !has_reveal_target, - "Open in Terminal", - Box::new(OpenInTerminal), - ) - .action_disabled_when( - !has_git_repo, - "Copy Permalink", - Box::new(CopyPermalinkToLine), - ) - .action_disabled_when( - !has_git_repo, - "View File History", - Box::new(git::FileHistory), - ); + .when(surface_policy.show_terminal_actions, |builder| { + builder.action_disabled_when( + !has_reveal_target, + "Open in Terminal", + Box::new(OpenInTerminal), + ) + }) + .when(surface_policy.show_git_actions, |builder| { + builder + .action_disabled_when( + !has_git_repo, + "Copy Permalink", + Box::new(CopyPermalinkToLine), + ) + .action_disabled_when( + !has_git_repo, + "View File History", + Box::new(git::FileHistory), + ) + }); match focus { Some(focus) => builder.context(focus), None => builder, diff --git a/crates/editor/src/selection_controls.rs b/crates/editor/src/selection_controls.rs new file mode 100644 index 00000000000000..d91494b65d9455 --- /dev/null +++ b/crates/editor/src/selection_controls.rs @@ -0,0 +1,280 @@ +use crate::{ + Editor, EditorEvent, + actions::{ + AddSelectionAbove, AddSelectionBelow, DuplicateLineDown, SelectAll, SelectLargerSyntaxNode, + SelectNext, SelectSmallerSyntaxNode, + }, +}; +use gpui::{ + App, Context, ElementId, Entity, EventEmitter, Focusable, Render, Subscription, Window, +}; +use settings::SettingsStore; +use ui::{ + ButtonStyle, ContextMenu, IconButton, IconName, IconSize, PopoverMenu, PopoverMenuHandle, + Tooltip, prelude::*, +}; +use workspace::{ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, item::ItemHandle}; + +#[derive(Clone, Copy, Debug)] +pub struct SelectionControlsMenuOptions { + pub include_duplicate_selection: bool, +} + +impl SelectionControlsMenuOptions { + pub const fn core() -> Self { + Self { + include_duplicate_selection: true, + } + } + + pub const fn without_duplicate_selection() -> Self { + Self { + include_duplicate_selection: false, + } + } +} + +pub fn selection_controls_menu_items( + menu: ContextMenu, + options: SelectionControlsMenuOptions, +) -> ContextMenu { + menu.action("Select All", Box::new(SelectAll)) + .action( + "Select Next Occurrence", + Box::new(SelectNext { + replace_newest: false, + }), + ) + .action("Expand Selection", Box::new(SelectLargerSyntaxNode)) + .action("Shrink Selection", Box::new(SelectSmallerSyntaxNode)) + .action( + "Add Cursor Above", + Box::new(AddSelectionAbove { + skip_soft_wrap: true, + }), + ) + .action( + "Add Cursor Below", + Box::new(AddSelectionBelow { + skip_soft_wrap: true, + }), + ) + .when(options.include_duplicate_selection, |this| { + this.separator() + .action("Duplicate Selection", Box::new(DuplicateLineDown)) + }) +} + +pub fn selection_controls_menu( + editor: Entity, + options: SelectionControlsMenuOptions, + window: &mut Window, + cx: &mut App, +) -> Entity { + let focus = editor.read(cx).focus_handle(cx); + + ContextMenu::build(window, cx, move |menu, _, _| { + selection_controls_menu_items(menu.context(focus.clone()), options) + }) +} + +pub fn selection_controls_popover_with_menu( + menu_id: impl Into, + trigger_id: impl Into, + handle: PopoverMenuHandle, + build_menu: impl Fn(&mut Window, &mut App) -> Option> + 'static, +) -> PopoverMenu { + PopoverMenu::new(menu_id) + .trigger_with_tooltip( + IconButton::new(trigger_id, IconName::CursorIBeam) + .icon_size(IconSize::Small) + .style(ButtonStyle::Subtle) + .toggle_state(handle.is_deployed()), + Tooltip::text("Selection Controls"), + ) + .with_handle(handle) + .anchor(gpui::Anchor::TopRight) + .menu(build_menu) +} + +pub fn selection_controls_popover( + menu_id: impl Into, + trigger_id: impl Into, + editor: Entity, + options: SelectionControlsMenuOptions, + handle: PopoverMenuHandle, +) -> PopoverMenu { + selection_controls_popover_with_menu(menu_id, trigger_id, handle, move |window, cx| { + Some(selection_controls_menu(editor.clone(), options, window, cx)) + }) +} + +pub struct EditorSelectionControls { + active_item: Option>, + handle: PopoverMenuHandle, + options: SelectionControlsMenuOptions, + active_editor_subscription: Option, + _settings_subscription: Subscription, +} + +impl EditorSelectionControls { + pub fn new(cx: &mut Context) -> Self { + Self::with_options(SelectionControlsMenuOptions::core(), cx) + } + + pub fn with_options(options: SelectionControlsMenuOptions, cx: &mut Context) -> Self { + let settings_subscription = cx.observe_global::(|this, cx| { + cx.emit(ToolbarItemEvent::ChangeLocation( + this.toolbar_item_location(cx), + )); + cx.notify(); + }); + + Self { + active_item: None, + handle: Default::default(), + options, + active_editor_subscription: None, + _settings_subscription: settings_subscription, + } + } + + fn active_editor(&self) -> Option> { + self.active_item + .as_ref() + .and_then(|item| item.downcast::()) + } + + fn toolbar_item_location(&self, cx: &App) -> ToolbarItemLocation { + if self + .active_editor() + .is_some_and(|editor| editor.read(cx).selection_menu_enabled(cx)) + { + ToolbarItemLocation::PrimaryRight + } else { + ToolbarItemLocation::Hidden + } + } + + fn subscribe_to_active_editor(&mut self, cx: &mut Context) { + self.active_editor_subscription.take(); + + let Some(editor) = self.active_editor() else { + return; + }; + + self.active_editor_subscription = Some(cx.subscribe( + &editor, + |this, _editor, event: &EditorEvent, cx| { + if matches!(event, EditorEvent::SelectionMenuChanged) { + cx.emit(ToolbarItemEvent::ChangeLocation( + this.toolbar_item_location(cx), + )); + cx.notify(); + } + }, + )); + } +} + +impl Render for EditorSelectionControls { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let Some(editor) = self.active_editor() else { + return div() + .id("empty editor selection controls") + .into_any_element(); + }; + + if !editor.read(cx).selection_menu_enabled(cx) { + return div() + .id("disabled editor selection controls") + .into_any_element(); + } + + selection_controls_popover( + "editor-selection-controls", + "toggle-editor-selection-controls", + editor, + self.options, + self.handle.clone(), + ) + .into_any_element() + } +} + +impl EventEmitter for EditorSelectionControls {} + +impl ToolbarItemView for EditorSelectionControls { + fn set_active_pane_item( + &mut self, + active_pane_item: Option<&dyn ItemHandle>, + _: &mut Window, + cx: &mut Context, + ) -> ToolbarItemLocation { + self.active_item = active_pane_item.map(ItemHandle::boxed_clone); + self.subscribe_to_active_editor(cx); + self.toolbar_item_location(cx) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{actions::ToggleSelectionMenu, test::build_editor}; + use gpui::TestAppContext; + use multi_buffer::MultiBuffer; + use std::{cell::RefCell, rc::Rc}; + + fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + assets::Assets.load_test_fonts(cx); + let store = SettingsStore::test(cx); + cx.set_global(store); + theme_settings::init(theme::LoadThemes::JustBase, cx); + release_channel::init(semver::Version::new(0, 0, 0), cx); + crate::init(cx); + }); + zlog::init_test(); + } + + #[gpui::test] + async fn selection_menu_toggle_updates_toolbar_location(cx: &mut TestAppContext) { + init_test(cx); + + let (editor, cx) = cx.add_window_view(|window, cx| { + let buffer = MultiBuffer::build_simple("abc", cx); + build_editor(buffer, window, cx) + }); + + let events = Rc::new(RefCell::new(Vec::new())); + let controls = cx.new({ + let events = events.clone(); + |cx| { + cx.subscribe(&cx.entity(), move |_, _, event: &ToolbarItemEvent, _| { + let ToolbarItemEvent::ChangeLocation(location) = event; + events.borrow_mut().push(*location); + }) + .detach(); + EditorSelectionControls::new(cx) + } + }); + + controls.update_in(cx, |controls, window, cx| { + let active_item: &dyn ItemHandle = &editor; + assert_eq!( + controls.set_active_pane_item(Some(active_item), window, cx), + ToolbarItemLocation::PrimaryRight + ); + }); + + editor.update_in(cx, |editor, window, cx| { + editor.toggle_selection_menu(&ToggleSelectionMenu, window, cx); + }); + + assert_eq!( + events.borrow().as_slice(), + &[ToolbarItemLocation::Hidden], + "toggling the active editor's per-buffer selection menu must move the toolbar item" + ); + } +} diff --git a/crates/file_finder/src/file_finder.rs b/crates/file_finder/src/file_finder.rs index 7c961bf13f96d7..60edc58afc7d56 100644 --- a/crates/file_finder/src/file_finder.rs +++ b/crates/file_finder/src/file_finder.rs @@ -84,17 +84,7 @@ impl FileFinder { ) { workspace.register_action( |workspace, action: &workspace::ToggleFileFinder, window, cx| { - let Some(file_finder) = workspace.active_modal::(cx) else { - Self::open(workspace, action.separate_history, window, cx).detach(); - return; - }; - - file_finder.update(cx, |file_finder, cx| { - file_finder.init_modifiers = Some(window.modifiers()); - file_finder.picker.update(cx, |picker, cx| { - picker.cycle_selection(window, cx); - }); - }); + toggle_file_finder(workspace, action.separate_history, window, cx); }, ); } @@ -332,6 +322,30 @@ impl FileFinder { } } +/// Toggle the file finder modal for a workspace. +/// +/// This preserves the behavior of the registered `ToggleFileFinder` action: +/// open the modal when it is absent, or cycle the existing picker selection +/// when the file finder is already active. +pub fn toggle_file_finder( + workspace: &mut Workspace, + separate_history: bool, + window: &mut Window, + cx: &mut Context, +) { + let Some(file_finder) = workspace.active_modal::(cx) else { + FileFinder::open(workspace, separate_history, window, cx).detach(); + return; + }; + + file_finder.update(cx, |file_finder, cx| { + file_finder.init_modifiers = Some(window.modifiers()); + file_finder.picker.update(cx, |picker, cx| { + picker.cycle_selection(window, cx); + }); + }); +} + impl EventEmitter for FileFinder {} impl Focusable for FileFinder { diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 9c23e7ff8cf5f9..a5702f73b000e5 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -134,6 +134,50 @@ impl State { } } +/// Controls which context-menu action groups are shown by the project panel. +/// +/// The default policy preserves normal Zed behavior. Embedded hosts, such as +/// Cherrypick, can install a restricted policy when the host owns those actions +/// or intentionally omits them from its file-browser surface. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ProjectPanelContextMenuPolicy { + pub show_terminal_actions: bool, + pub show_search_actions: bool, + pub show_compare_actions: bool, + pub show_git_actions: bool, + pub show_workspace_folder_actions: bool, +} + +impl ProjectPanelContextMenuPolicy { + /// Shows every action group used by the standard Zed project panel. + pub const fn full() -> Self { + Self { + show_terminal_actions: true, + show_search_actions: true, + show_compare_actions: true, + show_git_actions: true, + show_workspace_folder_actions: true, + } + } + + /// Hides action groups that are handled by, or unavailable in, an embedded host. + pub const fn embedded() -> Self { + Self { + show_terminal_actions: false, + show_search_actions: false, + show_compare_actions: false, + show_git_actions: false, + show_workspace_folder_actions: false, + } + } +} + +impl Default for ProjectPanelContextMenuPolicy { + fn default() -> Self { + Self::full() + } +} + pub struct ProjectPanel { project: Entity, fs: Arc, @@ -164,6 +208,7 @@ pub struct ProjectPanel { last_reported_update: Instant, update_visible_entries_task: UpdateVisibleEntriesTask, undo_manager: UndoManager, + context_menu_policy: ProjectPanelContextMenuPolicy, state: State, } @@ -880,6 +925,7 @@ impl ProjectPanel { }, update_visible_entries_task: Default::default(), undo_manager: UndoManager::new(workspace.weak_handle(), weak_project_panel, &cx), + context_menu_policy: ProjectPanelContextMenuPolicy::default(), }; this.update_visible_entries(None, false, false, window, cx); @@ -1097,9 +1143,12 @@ impl ProjectPanel { let should_hide_rename = is_root && (cfg!(target_os = "windows") || (settings.hide_root && visible_worktrees_count == 1)); - let should_show_compare = !is_dir && self.file_abs_paths_to_diff(cx).is_some(); + let context_menu_policy = self.context_menu_policy; + let should_show_compare = context_menu_policy.show_compare_actions + && !is_dir + && self.file_abs_paths_to_diff(cx).is_some(); - let (has_git_repo, has_history) = { + let (has_git_repo, has_history) = if context_menu_policy.show_git_actions { let project_path = project::ProjectPath { worktree_id, path: entry.path.clone(), @@ -1113,14 +1162,15 @@ impl ProjectPanel { .project_path_git_status(&project_path, cx) .is_some_and(|status| status.is_created()); (has_git_repo, has_history) + } else { + (false, false) }; - let has_pasteable_content = self.has_pasteable_content(cx); let entity = cx.entity(); let context_menu = ContextMenu::build(window, cx, |menu, _, cx| { menu.context(self.focus_handle.clone()).map(|menu| { if is_read_only { - menu.when(is_dir, |menu| { + menu.when(is_dir && context_menu_policy.show_search_actions, |menu| { menu.action("Search Inside", Box::new(NewSearchInDirectory)) }) } else { @@ -1136,8 +1186,10 @@ impl ProjectPanel { .when(is_local, |menu| { menu.action("Open in Default App", Box::new(OpenWithSystem)) }) - .action("Open in Terminal", Box::new(OpenInTerminal)) - .when(is_dir, |menu| { + .when(context_menu_policy.show_terminal_actions, |menu| { + menu.action("Open in Terminal", Box::new(OpenInTerminal)) + }) + .when(context_menu_policy.show_search_actions && is_dir, |menu| { menu.separator() .action("Find in Folder…", Box::new(NewSearchInDirectory)) }) @@ -1204,14 +1256,19 @@ impl ProjectPanel { .when(!is_root, |menu| { menu.action("Delete", Box::new(Delete { skip_prompt: false })) }) - .when(!is_collab && is_root, |menu| { - menu.separator() - .action( - "Add Folders to Project…", - Box::new(workspace::AddFolderToProject), - ) - .action("Remove from Project", Box::new(RemoveFromProject)) - }) + .when( + context_menu_policy.show_workspace_folder_actions + && !is_collab + && is_root, + |menu| { + menu.separator() + .action( + "Add Folders to Project…", + Box::new(workspace::AddFolderToProject), + ) + .action("Remove from Project", Box::new(RemoveFromProject)) + }, + ) .when(is_dir && !is_root, |menu| { menu.separator().action( "Collapse All", @@ -3905,6 +3962,22 @@ impl ProjectPanel { }) } + /// Updates context-menu action visibility for this project-panel surface. + /// + /// Standalone Zed leaves the default policy in place; embedded hosts call + /// this to hide actions that the host owns or does not expose. + pub fn set_context_menu_policy( + &mut self, + policy: ProjectPanelContextMenuPolicy, + cx: &mut Context, + ) { + if self.context_menu_policy == policy { + return; + } + self.context_menu_policy = policy; + cx.notify(); + } + /// Compared to selected_entry, this function resolves to the currently /// selected subentry if dir auto-folding is enabled. fn selected_sub_entry<'a>( @@ -6371,6 +6444,53 @@ impl ProjectPanel { dispatch_context } + /// Reveals, selects, and scrolls to the project entry at `absolute_path`. + /// + /// The path must belong to this panel's project and already have a loaded + /// project entry. + pub fn reveal_abs_path( + &mut self, + absolute_path: &Path, + window: &mut Window, + cx: &mut Context, + ) -> Result<()> { + let project = self.project.clone(); + let Some(project_path) = project + .read(cx) + .project_path_for_absolute_path(absolute_path, cx) + else { + anyhow::bail!("path is not part of the project"); + }; + let Some(entry_id) = project + .read(cx) + .entry_for_path(&project_path, cx) + .map(|entry| entry.id) + else { + anyhow::bail!("path has not been loaded in the project panel"); + }; + // `reveal_entry` -> `expand_entry` only records expanded ancestors when the + // worktree already has an `expanded_dir_ids` entry. That map is seeded + // asynchronously by `update_visible_entries` (its vacant branch inserts the + // root entry), so a reveal issued right after mount can beat it + // and `expand_entry`'s `get_mut` returns `None` — it silently no-ops and the + // tree never expands to the target file. Seed the worktree root here if + // it's still vacant (same value `update_visible_entries` would insert) so + // `expand_entry` writes the ancestors into `self.state` synchronously; every + // later `update_visible_entries` derives from that state and preserves them. + let worktree_id = project_path.worktree_id; + if !self.state.expanded_dir_ids.contains_key(&worktree_id) + && let Some(root_id) = project + .read(cx) + .worktree_for_id(worktree_id, cx) + .and_then(|worktree| worktree.read(cx).root_entry().map(|entry| entry.id)) + { + self.state + .expanded_dir_ids + .insert(worktree_id, vec![root_id]); + } + self.reveal_entry(project, entry_id, true, window, cx) + } + fn reveal_entry( &mut self, project: Entity, diff --git a/crates/project_panel/src/project_panel_tests.rs b/crates/project_panel/src/project_panel_tests.rs index a228749dc1692f..edd3ddb57b938e 100644 --- a/crates/project_panel/src/project_panel_tests.rs +++ b/crates/project_panel/src/project_panel_tests.rs @@ -21,6 +21,21 @@ use workspace::{ register_project_item, }; +#[test] +fn test_embedded_project_panel_context_menu_policy_hides_host_owned_actions() { + let policy = ProjectPanelContextMenuPolicy::embedded(); + + assert!(!policy.show_terminal_actions); + assert!(!policy.show_search_actions); + assert!(!policy.show_compare_actions); + assert!(!policy.show_git_actions); + assert!(!policy.show_workspace_folder_actions); + assert_eq!( + ProjectPanelContextMenuPolicy::default(), + ProjectPanelContextMenuPolicy::full() + ); +} + #[gpui::test] async fn test_visible_list(cx: &mut gpui::TestAppContext) { init_test(cx); @@ -5579,6 +5594,62 @@ async fn test_gitignored_and_always_included(cx: &mut gpui::TestAppContext) { ); } +#[gpui::test] +async fn test_reveal_abs_path_seeds_missing_expanded_state(cx: &mut gpui::TestAppContext) { + init_test_with_editor(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + "/project_root", + json!({ + ".git": {}, + "dir_1": { + "nested": { + "file_1.py": "# File contents", + }, + }, + "dir_2": { + "file_2.py": "# File contents", + }, + }), + ) + .await; + + let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await; + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let panel = workspace.update_in(cx, ProjectPanel::new); + cx.run_until_parked(); + + panel.update_in(cx, |panel, window, cx| { + panel.state.expanded_dir_ids.clear(); + panel + .reveal_abs_path( + Path::new("/project_root/dir_1/nested/file_1.py"), + window, + cx, + ) + .unwrap(); + }); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v project_root", + " > .git", + " v dir_1", + " v nested", + " file_1.py <== selected <== marked", + " > dir_2", + ], + "absolute-path reveal should seed the worktree root before expanding ancestors" + ); +} + #[gpui::test] async fn test_explicit_reveal(cx: &mut gpui::TestAppContext) { init_test_with_editor(cx); diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index ea3091a6fffbcc..8ef86a1edf3f11 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -390,6 +390,38 @@ impl fmt::Debug for Event { } } +/// Controls which context-menu action groups are shown by a workspace pane. +/// +/// The default policy preserves normal Zed behavior. Embedded hosts, such as +/// Cherrypick, can install a restricted policy when the host owns those actions +/// or intentionally omits them from its file-editor surface. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct PaneContextMenuPolicy { + pub show_terminal_actions: bool, +} + +impl PaneContextMenuPolicy { + /// Shows every action group used by the standard Zed pane. + pub const fn full() -> Self { + Self { + show_terminal_actions: true, + } + } + + /// Hides action groups that are handled by, or unavailable in, an embedded host. + pub const fn embedded() -> Self { + Self { + show_terminal_actions: false, + } + } +} + +impl Default for PaneContextMenuPolicy { + fn default() -> Self { + Self::full() + } +} + /// A container for 0 to many items that are open in the workspace. /// Treats all items uniformly via the [`ItemHandle`] trait, whether it's an editor, search results multibuffer, terminal or something else, /// responsible for managing item tabs, focus and zoom states and drag and drop features. @@ -438,6 +470,7 @@ pub struct Pane { /// Is None if navigation buttons are permanently turned off (and should not react to setting changes). /// Otherwise, when `display_nav_history_buttons` is Some, it determines whether nav buttons should be displayed. display_nav_history_buttons: Option, + context_menu_policy: PaneContextMenuPolicy, double_click_dispatch_action: Box, save_modals_spawned: HashSet, close_pane_if_empty: bool, @@ -610,6 +643,7 @@ impl Pane { display_nav_history_buttons: Some( TabBarSettings::get_global(cx).show_nav_history_buttons, ), + context_menu_policy: PaneContextMenuPolicy::default(), _subscriptions: subscriptions, double_click_dispatch_action, save_modals_spawned: HashSet::default(), @@ -884,6 +918,22 @@ impl Pane { cx.notify(); } + /// Updates context-menu action visibility for this pane surface. + /// + /// Standalone Zed leaves the default policy in place; embedded hosts call + /// this to hide actions that the host owns or does not expose. + pub fn set_context_menu_policy( + &mut self, + policy: PaneContextMenuPolicy, + cx: &mut Context, + ) { + if self.context_menu_policy == policy { + return; + } + self.context_menu_policy = policy; + cx.notify(); + } + pub fn nav_history_for_item(&self, item: &Entity) -> ItemNavHistory { ItemNavHistory { history: self.nav_history.clone(), @@ -3274,9 +3324,16 @@ impl Pane { let entry_abs_path = pane.read(cx).entry_abs_path(entry, cx); let reveal_path = entry_abs_path.clone(); - let parent_abs_path = entry_abs_path - .as_deref() - .and_then(|abs_path| Some(abs_path.parent()?.to_path_buf())); + let parent_abs_path = pane + .read(cx) + .context_menu_policy + .show_terminal_actions + .then(|| { + entry_abs_path.as_deref().and_then(|path| { + path.parent().map(|parent| parent.to_path_buf()) + }) + }) + .flatten(); let relative_path = project_path .map(|project_path| project_path.path) .filter(|_| has_relative_path); @@ -8706,6 +8763,17 @@ mod tests { } } + #[test] + fn test_embedded_pane_context_menu_policy_hides_host_owned_actions() { + let policy = PaneContextMenuPolicy::embedded(); + + assert!(!policy.show_terminal_actions); + assert_eq!( + PaneContextMenuPolicy::default(), + PaneContextMenuPolicy::full() + ); + } + #[gpui::test] async fn test_split_clone(cx: &mut TestAppContext) { for split_direction in SplitDirection::all() { diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 1b508163955103..ad0d2a86d93866 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -7894,6 +7894,14 @@ impl Workspace { self.modal_layer.read(cx).active_modal() } + /// Returns the workspace modal layer entity. + /// + /// Embedders that render a `Workspace` outside of `MultiWorkspace` can use + /// this to render workspace-owned modals such as the file finder. + pub fn modal_layer(&self) -> Entity { + self.modal_layer.clone() + } + /// Toggles a modal of type `V`. If a modal of the same type is currently active, /// it will be hidden. If a different modal is active, it will be replaced with the new one. /// If no modal is active, the new modal will be shown. diff --git a/crates/zed/src/zed/quick_action_bar.rs b/crates/zed/src/zed/quick_action_bar.rs index d1ffb58b79244c..0c3ed56e2f5570 100644 --- a/crates/zed/src/zed/quick_action_bar.rs +++ b/crates/zed/src/zed/quick_action_bar.rs @@ -3,12 +3,15 @@ mod repl_menu; use agent_settings::AgentSettings; use editor::actions::{ - AddSelectionAbove, AddSelectionBelow, CodeActionSource, DuplicateLineDown, GoToDiagnostic, - GoToHunk, GoToPreviousDiagnostic, GoToPreviousHunk, MoveLineDown, MoveLineUp, SelectAll, - SelectLargerSyntaxNode, SelectNext, SelectSmallerSyntaxNode, ToggleCodeActions, - ToggleDiagnostics, ToggleGoToLine, ToggleInlineDiagnostics, + CodeActionSource, DuplicateLineDown, GoToDiagnostic, GoToHunk, GoToPreviousDiagnostic, + GoToPreviousHunk, MoveLineDown, MoveLineUp, ToggleCodeActions, ToggleDiagnostics, + ToggleGoToLine, ToggleInlineDiagnostics, }; use editor::code_context_menus::{CodeContextMenu, ContextMenuOrigin}; +use editor::selection_controls::{ + SelectionControlsMenuOptions, selection_controls_menu_items, + selection_controls_popover_with_menu, +}; use editor::{Editor, EditorSettings}; use gpui::{ Action, Anchor, AnchoredPositionMode, ClickEvent, Context, ElementId, Entity, EventEmitter, @@ -247,76 +250,50 @@ impl Render for QuickActionBar { let has_selection = editor.update(cx, |editor, cx| { editor.has_non_empty_selection(&editor.display_snapshot(cx)) }); - let focus = editor.focus_handle(cx); - let disable_ai = DisableAiSettings::get_global(cx).disable_ai; - PopoverMenu::new("editor-selections-dropdown") - .trigger_with_tooltip( - IconButton::new("toggle_editor_selections_icon", IconName::CursorIBeam) - .icon_size(IconSize::Small) - .style(ButtonStyle::Subtle) - .toggle_state(self.toggle_selections_handle.is_deployed()), - Tooltip::text("Selection Controls"), - ) - .with_handle(self.toggle_selections_handle.clone()) - .anchor(Anchor::TopRight) - .menu(move |window, cx| { + selection_controls_popover_with_menu( + "editor-selections-dropdown", + "toggle_editor_selections_icon", + self.toggle_selections_handle.clone(), + move |window, cx| { let focus = focus.clone(); - let menu = ContextMenu::build(window, cx, move |menu, _, _| { - menu.context(focus.clone()) - .action("Select All", Box::new(SelectAll)) - .action( - "Select Next Occurrence", - Box::new(SelectNext { - replace_newest: false, - }), - ) - .action("Expand Selection", Box::new(SelectLargerSyntaxNode)) - .action("Shrink Selection", Box::new(SelectSmallerSyntaxNode)) - .action( - "Add Cursor Above", - Box::new(AddSelectionAbove { - skip_soft_wrap: true, - }), + Some(ContextMenu::build(window, cx, move |menu, _, _| { + selection_controls_menu_items( + menu.context(focus.clone()), + SelectionControlsMenuOptions::without_duplicate_selection(), + ) + .when(!disable_ai, |this| { + this.separator().action_disabled_when( + !has_selection, + "Add to Agent Thread", + Box::new(AddSelectionToThread), ) - .action( - "Add Cursor Below", - Box::new(AddSelectionBelow { - skip_soft_wrap: true, - }), - ) - .when(!disable_ai, |this| { - this.separator().action_disabled_when( - !has_selection, - "Add to Agent Thread", - Box::new(AddSelectionToThread), - ) - }) - .separator() - .action("Go to Symbol", Box::new(ToggleOutline)) - .action("Go to Line/Column", Box::new(ToggleGoToLine)) - .separator() - .action("Next Problem", Box::new(GoToDiagnostic::default())) - .action( - "Previous Problem", - Box::new(GoToPreviousDiagnostic::default()), - ) - .separator() - .action_disabled_when(!has_diff_hunks, "Next Hunk", Box::new(GoToHunk)) - .action_disabled_when( - !has_diff_hunks, - "Previous Hunk", - Box::new(GoToPreviousHunk), - ) - .separator() - .action("Move Line Up", Box::new(MoveLineUp)) - .action("Move Line Down", Box::new(MoveLineDown)) - .action("Duplicate Selection", Box::new(DuplicateLineDown)) - }); - Some(menu) - }) + }) + .separator() + .action("Go to Symbol", Box::new(ToggleOutline)) + .action("Go to Line/Column", Box::new(ToggleGoToLine)) + .separator() + .action("Next Problem", Box::new(GoToDiagnostic::default())) + .action( + "Previous Problem", + Box::new(GoToPreviousDiagnostic::default()), + ) + .separator() + .action_disabled_when(!has_diff_hunks, "Next Hunk", Box::new(GoToHunk)) + .action_disabled_when( + !has_diff_hunks, + "Previous Hunk", + Box::new(GoToPreviousHunk), + ) + .separator() + .action("Move Line Up", Box::new(MoveLineUp)) + .action("Move Line Down", Box::new(MoveLineDown)) + .action("Duplicate Selection", Box::new(DuplicateLineDown)) + })) + }, + ) }); let editor_focus_handle = editor.focus_handle(cx);