diff --git a/Cargo.lock b/Cargo.lock index 3d95a3f31a6..2f63722843a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -18587,6 +18587,7 @@ dependencies = [ "smallvec", "telemetry", "theme", + "theme_settings", "ui", "util", "workspace", diff --git a/crates/gpui/src/platform/test/platform.rs b/crates/gpui/src/platform/test/platform.rs index 5e82fbe3d8c..df1d0f4d131 100644 --- a/crates/gpui/src/platform/test/platform.rs +++ b/crates/gpui/src/platform/test/platform.rs @@ -4,7 +4,7 @@ use crate::NoopTextSystem; use crate::PathPromptOptions; use crate::{ AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, DevicePixels, - DummyKeyboardMapper, ForegroundExecutor, Keymap, Platform, PlatformDisplay, + DummyKeyboardMapper, ForegroundExecutor, Keymap, OwnedMenu, Platform, PlatformDisplay, PlatformHeadlessRenderer, PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem, PromptButton, ScreenCaptureFrame, ScreenCaptureSource, ScreenCaptureStream, SharedString, SourceMetadata, SystemNotification, SystemNotificationResponse, Task, TestDisplay, TestWindow, @@ -45,6 +45,7 @@ pub(crate) struct TestPlatform { RefCell, Vec)>>>, headless_renderer_factory: Option Option>>>, weak: Weak, + menus: RefCell>, } #[derive(Clone)] @@ -159,6 +160,7 @@ impl TestPlatform { system_notifications: Default::default(), text_system, headless_renderer_factory, + menus: Default::default(), }) } @@ -573,7 +575,14 @@ impl Platform for TestPlatform { self.system_notifications.borrow_mut().response_callback = Some(callback); } - fn set_menus(&self, _menus: Vec, _keymap: &Keymap) {} + fn set_menus(&self, menus: Vec, _keymap: &Keymap) { + *self.menus.borrow_mut() = menus.into_iter().map(|menu| menu.owned()).collect() + } + + fn get_menus(&self) -> Option> { + Some(self.menus.borrow().clone()) + } + fn set_dock_menu(&self, _menu: Vec, _keymap: &Keymap) {} fn add_recent_document(&self, _paths: &Path) {} diff --git a/crates/title_bar/Cargo.toml b/crates/title_bar/Cargo.toml index 2d82ab65b91..57aceb8fc27 100644 --- a/crates/title_bar/Cargo.toml +++ b/crates/title_bar/Cargo.toml @@ -74,5 +74,6 @@ remote = { workspace = true, features = ["test-support"] } rpc = { workspace = true, features = ["test-support"] } semver.workspace = true settings = { workspace = true, features = ["test-support"] } +theme_settings.workspace = true util = { workspace = true, features = ["test-support"] } workspace = { workspace = true, features = ["test-support"] } diff --git a/crates/title_bar/src/application_menu.rs b/crates/title_bar/src/application_menu.rs index 369f232441a..8ab7590b5b1 100644 --- a/crates/title_bar/src/application_menu.rs +++ b/crates/title_bar/src/application_menu.rs @@ -1,4 +1,5 @@ use gpui::{Action, Entity, OwnedMenu, OwnedMenuItem, Subscription, actions}; +use project::DisableAiSettings; use settings::{Settings, SettingsStore}; use workspace::AccessibleMode; @@ -52,22 +53,42 @@ pub struct ApplicationMenu { impl ApplicationMenu { pub fn new(_: &mut Window, cx: &mut Context) -> Self { let menus = cx.get_menus().unwrap_or_default(); - // Re-render when settings change so toggling "accessible mode" expands or - // collapses the menu bar (see `all_menus_shown`). - let settings_subscription = cx.observe_global::(|_, cx| cx.notify()); + + let entries = Self::build_entries(menus); + let mut disable_ai = DisableAiSettings::get_global(cx).disable_ai; + let settings_subscription = + cx.observe_global::(move |application_menu, cx| { + let new_disable_ai = DisableAiSettings::get_global(cx).disable_ai; + if new_disable_ai != disable_ai { + disable_ai = new_disable_ai; + for entry in &application_menu.entries { + if entry.handle.is_deployed() { + entry.handle.hide(cx); + } + } + let menus = cx.get_menus().unwrap_or_default(); + application_menu.entries = Self::build_entries(menus); + } + cx.notify(); + }); + Self { - entries: menus - .into_iter() - .map(|menu| MenuEntry { - menu, - handle: PopoverMenuHandle::default(), - }) - .collect(), + entries, pending_menu_open: None, _settings_subscription: settings_subscription, } } + fn build_entries(menus: Vec) -> SmallVec<[MenuEntry; 8]> { + menus + .into_iter() + .map(|menu| MenuEntry { + menu, + handle: PopoverMenuHandle::default(), + }) + .collect() + } + fn sanitize_menu_items(items: Vec) -> Vec { let mut cleaned = Vec::new(); let mut last_was_separator = false; @@ -343,3 +364,200 @@ impl Render for ApplicationMenu { }) } } + +#[cfg(test)] +mod tests { + use gpui::{Action, Menu, MenuItem, OwnedMenu, OwnedMenuItem, TestAppContext, UpdateGlobal}; + use project::DisableAiSettings; + use settings::{Settings, SettingsStore}; + + use crate::application_menu::{ApplicationMenu, MenuEntry, OpenApplicationMenu}; + + fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + DisableAiSettings::register(cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); + }); + } + + fn owned_menu(name: &str, item_name: &str) -> OwnedMenu { + OwnedMenu { + name: name.into(), + disabled: false, + items: vec![OwnedMenuItem::Action { + name: item_name.to_string(), + action: OpenApplicationMenu(String::new()).boxed_clone(), + os_action: None, + checked: false, + disabled: false, + }], + } + } + + fn has_item(entries: &[MenuEntry], menu_name: &str, item_name: &str) -> bool { + entries.iter().any(|entry| { + entry.menu.name == menu_name + && entry.menu.items.iter().any( + |item| matches!(item, OwnedMenuItem::Action { name, .. } if name == item_name), + ) + }) + } + + fn menu_entry<'a>(entries: &'a [MenuEntry], menu_name: &str) -> &'a MenuEntry { + entries + .iter() + .find(|entry| entry.menu.name == menu_name) + .expect("expected menu") + } + + fn view_menu(include_agent_panel: bool) -> Menu { + let mut items = Vec::new(); + if include_agent_panel { + items.push(MenuItem::action( + "Agent Panel", + OpenApplicationMenu(String::new()), + )); + } + items.push(MenuItem::action( + "Diagnostics", + OpenApplicationMenu(String::new()), + )); + + Menu { + name: "View".into(), + items, + disabled: false, + } + } + + #[test] + fn test_build_entries_reflects_current_menu_data() { + let with_agent_panel = + ApplicationMenu::build_entries(vec![owned_menu("View", "Agent Panel")]); + assert!(has_item(&with_agent_panel, "View", "Agent Panel")); + + let without_agent_panel = + ApplicationMenu::build_entries(vec![owned_menu("View", "Diagnostics")]); + assert!(!has_item(&without_agent_panel, "View", "Agent Panel")); + } + + #[gpui::test] + fn test_entries_refresh_on_settings_change(cx: &mut TestAppContext) { + init_test(cx); + + cx.update(|cx| { + cx.set_menus(vec![view_menu(true)]); + }); + + let (app_menu, cx) = cx.add_window_view(|window, cx| ApplicationMenu::new(window, cx)); + let view_handle = cx.update(|window, cx| { + let view_handle = app_menu.read_with(cx, |app_menu, _| { + menu_entry(&app_menu.entries, "View").handle.clone() + }); + view_handle.show(window, cx); + view_handle + }); + assert!(view_handle.is_deployed()); + + assert!(app_menu.read_with(cx, |app_menu, _| has_item( + &app_menu.entries, + "View", + "Agent Panel" + ))); + + assert!(app_menu.read_with(cx, |app_menu, _| has_item( + &app_menu.entries, + "View", + "Diagnostics" + ))); + + cx.update(|_, cx| { + cx.set_menus(vec![view_menu(false)]); + DisableAiSettings::override_global(DisableAiSettings { disable_ai: true }, cx); + }); + + assert!(!view_handle.is_deployed()); + assert!( + !app_menu.read_with(cx, |app_menu, _| has_item( + &app_menu.entries, + "View", + "Agent Panel" + )), + "expected Agent Panel is not in the View menu" + ); + + assert!(app_menu.read_with(cx, |app_menu, _| has_item( + &app_menu.entries, + "View", + "Diagnostics" + ))); + + cx.update(|_, cx| { + cx.set_menus(vec![view_menu(true)]); + DisableAiSettings::override_global(DisableAiSettings { disable_ai: false }, cx); + }); + + assert!(app_menu.read_with(cx, |app_menu, _| has_item( + &app_menu.entries, + "View", + "Agent Panel" + ))); + assert!(app_menu.read_with(cx, |app_menu, _| has_item( + &app_menu.entries, + "View", + "Diagnostics" + ))); + } + + #[gpui::test] + fn test_entries_preserve_deployed_menu_on_unrelated_settings_change(cx: &mut TestAppContext) { + init_test(cx); + + cx.update(|cx| { + cx.set_menus(vec![ + Menu::new("Zed").items([MenuItem::action( + "Settings", + OpenApplicationMenu(String::new()), + )]), + view_menu(true), + ]); + }); + + let (app_menu, cx) = cx.add_window_view(|window, cx| ApplicationMenu::new(window, cx)); + + let zed_handle = cx.update(|window, cx| { + let zed_handle = app_menu.read_with(cx, |app_menu, _| { + menu_entry(&app_menu.entries, "Zed").handle.clone() + }); + zed_handle.show(window, cx); + zed_handle + }); + cx.run_until_parked(); + assert!(zed_handle.is_deployed()); + + cx.update(|window, cx| { + let view_handle = app_menu.read_with(cx, |app_menu, _| { + menu_entry(&app_menu.entries, "View").handle.clone() + }); + zed_handle.hide(cx); + view_handle.show(window, cx); + }); + cx.run_until_parked(); + assert!(!zed_handle.is_deployed()); + + assert!(app_menu.read_with(cx, |app_menu, _| { + menu_entry(&app_menu.entries, "View").handle.is_deployed() + })); + + cx.update(|_, cx| { + SettingsStore::update_global(cx, |_, _| {}); + }); + cx.run_until_parked(); + + assert!(app_menu.read_with(cx, |app_menu, _| { + menu_entry(&app_menu.entries, "View").handle.is_deployed() + })); + } +} diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 1285b166310..259ea57f019 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -2143,6 +2143,10 @@ pub fn handle_keymap_file_changes( let new_helix_enabled = vim_mode_setting::HelixModeSetting::get_global(cx).0; let new_disable_ai = DisableAiSettings::get_global(cx).disable_ai; + if new_disable_ai != old_disable_ai { + reload_menus(cx); + } + if new_base_keymap != old_base_keymap || new_vim_enabled != old_vim_enabled || new_helix_enabled != old_helix_enabled @@ -2313,6 +2317,11 @@ fn show_markdown_app_notification( }) } +fn reload_menus(cx: &mut App) { + let menus = app_menus(cx); + cx.set_menus(menus); +} + fn reload_keymaps(cx: &mut App, mut user_key_bindings: Vec) { cx.clear_key_bindings(); load_default_keymap(cx); @@ -2321,9 +2330,7 @@ fn reload_keymaps(cx: &mut App, mut user_key_bindings: Vec) { key_binding.set_meta(KeybindSource::User.meta()); } cx.bind_keys(filter_disabled_ai_bindings(user_key_bindings, cx)); - - let menus = app_menus(cx); - cx.set_menus(menus); + reload_menus(cx); // On Windows, this is set in the `update_jump_list` method of the `HistoryManager`. #[cfg(not(target_os = "windows"))] cx.set_dock_menu(vec![gpui::MenuItem::action( @@ -2865,8 +2872,8 @@ mod tests { use extension::ExtensionHostProxy; use fs::FakeFs; use gpui::{ - Action, AnyWindowHandle, App, AssetSource, BorrowAppContext, Modifiers, TestAppContext, - UpdateGlobal, VisualTestContext, WindowHandle, actions, point, px, + Action, AnyWindowHandle, App, AssetSource, BorrowAppContext, Modifiers, OwnedMenuItem, + TestAppContext, UpdateGlobal, VisualTestContext, WindowHandle, actions, point, px, }; use http_client::BlockedHttpClient; use language::LanguageRegistry; @@ -8146,4 +8153,155 @@ mod tests { window } + + #[gpui::test] + fn test_reload_keymaps_rebuilds_menus(cx: &mut TestAppContext) { + init_keymap_test(cx); + + cx.update(|cx| reload_keymaps(cx, Vec::new())); + cx.update(|cx| { + assert!( + has_view_item(cx, "Agent Panel"), + "expected Agent Panel in the View menu when AI is enabled" + ); + assert!( + has_view_item(cx, "Diagnostics"), + "expected Diagnostics to be in View menu" + ); + }); + + cx.update(|cx| { + SettingsStore::update_global(cx, |settings_store, cx| { + settings_store.update_user_settings(cx, |settings| { + settings.project.disable_ai = Some(SaturatingBool(true)) + }); + }) + }); + cx.update(|cx| { + reload_keymaps(cx, Vec::new()); + }); + cx.update(|cx| { + assert!( + !has_view_item(cx, "Agent Panel"), + "expected Agent Panel to be removed from the View menu after disabling AI" + ); + assert!( + has_view_item(cx, "Diagnostics"), + "expected Diagnostics to remain in the View menu" + ); + }); + + cx.update(|cx| { + SettingsStore::update_global(cx, |settings_store, cx| { + settings_store.update_user_settings(cx, |settings| { + settings.project.disable_ai = Some(SaturatingBool(false)); + }); + }); + }); + cx.update(|cx| reload_keymaps(cx, Vec::new())); + cx.update(|cx| { + assert!( + has_view_item(cx, "Agent Panel"), + "expected Agent Panel back in the View menu after re-enabling AI" + ); + assert!( + has_view_item(cx, "Diagnostics"), + "expected Diagnostics to remain in the View menu" + ); + }); + } + + #[gpui::test] + async fn test_disable_ai_menu_update_with_malformed_keymap(cx: &mut TestAppContext) { + let executor = cx.executor(); + let app_state = init_keymap_test(cx); + cx.update(|cx| reload_keymaps(cx, Vec::new())); + cx.update(|cx| { + assert!( + has_view_item(cx, "Agent Panel"), + "expected Agent Panel before disabling AI" + ); + assert!( + has_view_item(cx, "Diagnostics"), + "expected Diagnostics in the View menu" + ); + }); + + app_state + .fs + .save( + "/keymap.json".as_ref(), + &r#"!@!@this is not valid json"#.into(), + Default::default(), + ) + .await + .expect("failed to save malformed keymap.json to the fake fs"); + executor.run_until_parked(); + + cx.update(|cx| { + let (keymap_rx, keymap_watcher) = watch_config_file( + &executor, + app_state.fs.clone(), + PathBuf::from("/keymap.json"), + ); + watch_settings_files(app_state.fs.clone(), cx); + handle_keymap_file_changes(keymap_rx, keymap_watcher, cx); + }); + executor.run_until_parked(); + + app_state + .fs + .save( + paths::settings_file(), + &r#"{"disable_ai": true}"#.into(), + Default::default(), + ) + .await + .expect("failed to save disable_ai=true to the fake settings file"); + executor.run_until_parked(); + + cx.update(|cx| { + assert!( + !has_view_item(cx, "Agent Panel"), + "expected Agent Panel removed even though the keymap file is malformed" + ); + assert!( + has_view_item(cx, "Diagnostics"), + "expected Diagnostics to remain in the View menu" + ); + }); + + app_state + .fs + .save( + paths::settings_file(), + &r#"{"disable_ai": false}"#.into(), + Default::default(), + ) + .await + .expect("failed to save disable_ai=false to the fake settings file"); + executor.run_until_parked(); + + cx.update(|cx| { + assert!( + has_view_item(cx, "Agent Panel"), + "expected Agent Panel added back even though the keymap file is malformed" + ); + assert!( + has_view_item(cx, "Diagnostics"), + "expected Diagnostics to remain in the View menu" + ); + }); + } + + fn has_view_item(cx: &mut App, item_name: &str) -> bool { + cx.get_menus() + .expect("reload_keymaps should populate the menu bar") + .iter() + .find(|menu| menu.name == "View") + .expect("expected a View menu") + .items + .iter() + .any(|item| matches!(item, OwnedMenuItem::Action { name, .. } if name == item_name)) + } } diff --git a/crates/zed/src/zed/app_menus.rs b/crates/zed/src/zed/app_menus.rs index bba25325431..aff790d5cce 100644 --- a/crates/zed/src/zed/app_menus.rs +++ b/crates/zed/src/zed/app_menus.rs @@ -1,6 +1,8 @@ use collab_ui::collab_panel; use gpui::{App, Menu, MenuItem, OsAction}; +use project::DisableAiSettings; use release_channel::ReleaseChannel; +use settings::Settings; use terminal_view::terminal_panel; use zed_actions::{Quit, assistant, debug_panel, dev, git_panel, project_panel}; @@ -43,12 +45,18 @@ pub fn app_menus(cx: &mut App) -> Vec { MenuItem::action("Collab Panel", collab_panel::ToggleFocus), MenuItem::action("Terminal Panel", terminal_panel::Toggle), MenuItem::action("Debugger Panel", debug_panel::ToggleFocus), - MenuItem::action("Agent Panel", assistant::ToggleFocus), + ]; + + if !DisableAiSettings::get_global(cx).disable_ai { + view_items.push(MenuItem::action("Agent Panel", assistant::ToggleFocus)); + } + + view_items.extend([ MenuItem::action("Git Panel", git_panel::ToggleFocus), MenuItem::separator(), MenuItem::action("Diagnostics", diagnostics::Deploy), MenuItem::separator(), - ]; + ]); if ReleaseChannel::try_global(cx) == Some(ReleaseChannel::Dev) { view_items.push(MenuItem::action(