use std::borrow::Cow; use std::fs::File; use std::path::PathBuf; use ai::workspace::WorkspaceMetadata; use csv::Writer; use enclose::enclose; use galaxy_core::context_flag::ContextFlag; use itertools::Itertools; use settings::manager::SettingsManager; use settings::Setting as _; use warp_util::path::user_friendly_path; use warpui::actions::StandardAction; use warpui::keymap::{Keystroke, Trigger}; use warpui::platform::menu::{ CustomMenuItem, Menu, MenuBar, MenuItem, MenuItemProperties, MenuItemPropertyChanges, }; use warpui::windowing::WindowManager; use warpui::{AppContext, SingletonEntity}; use crate::ai::persisted_workspace::PersistedWorkspace; use crate::auth::AuthStateProvider; use crate::default_terminal::DefaultTerminal; use crate::features::{runtime_flags_menu_items, FeatureFlag}; use crate::root_view::OpenLaunchConfigArg; use crate::server::telemetry::LaunchConfigUiLocation; use crate::settings::{ AISettings, BlockVisibilitySettings, DebugSettings, DefaultSessionMode, SelectionSettings, }; use crate::terminal::alt_screen_reporting::AltScreenReporting; use crate::terminal::session_settings::SessionSettings; use crate::terminal::settings::{SpacingMode, TerminalSettings}; use crate::undo_close::UndoCloseStack; use crate::user_config::GalaxyConfig; use crate::util::bindings::{self, trigger_to_keystroke, CustomAction}; use crate::util::links; use crate::workspace::sync_inputs::SyncedInputState; use crate::{auth, report_if_error}; type CheckmarkStatusGetter = dyn 'static + Fn(&mut AppContext) -> bool; const ENABLE_SHELL_DEBUG_MODE_MENU_ITEM_NAME: &str = "Enable Shell Debug Mode (-x) for New Sessions"; const DISABLE_SHELL_DEBUG_MODE_MENU_ITEM_NAME: &str = "Disable Shell Debug Mode (-x) for New Sessions"; const ENABLE_IN_BAND_GENERATORS_MENU_ITEM_NAME: &str = "Enable In-band Generators for New Sessions"; const DISABLE_IN_BAND_GENERATORS_MENU_ITEM_NAME: &str = "Disable in-band generators for new sessions"; const ENABLE_PTY_RECORDING: &str = "Enable PTY Recording Mode (warp.pty.recording)"; const DISABLE_PTY_RECORDING: &str = "Disable PTY Recording Mode (warp.pty.recording)"; const SHOW_BOOTSTRAP_BLOCK_MENU_ITEM_NAME: &str = "Show Initialization Block"; const HIDE_BOOTSTRAP_BLOCK_MENU_ITEM_NAME: &str = "Hide Initialization Block"; const SHOW_IN_BAND_COMMAND_BLOCKS_MENU_ITEM_NAME: &str = "Show In-band Command Blocks"; const HIDE_IN_BAND_COMMAND_BLOCKS_MENU_ITEM_NAME: &str = "Hide In-band Command Blocks"; const SHOW_SSH_COMMAND_BLOCKS_MENU_ITEM_NAME: &str = "Show Enhanced SSH Blocks"; const HIDE_SSH_COMMAND_BLOCKS_MENU_ITEM_NAME: &str = "Hide Enhanced SSH Blocks"; const EXPORT_DEFAULT_SETTINGS_CSV_MENU_ITEM_NAME: &str = "Export Default Settings as CSV to home dir"; const SETTINGS_CSV_FILE_NAME: &str = "warp_default_settings.csv"; const MAX_RECENT_REPOS_IN_MENU: usize = 10; /// Creates the root app menu bar pub fn menu_bar(ctx: &mut AppContext) -> MenuBar { MenuBar::new(vec![ make_new_app_menu(ctx), make_new_file_menu(ctx), make_new_edit_menu(ctx), make_new_view_menu(ctx), make_new_tab_menu(ctx), make_new_blocks_menu(ctx), make_new_ai_menu(ctx), make_new_drive_menu(ctx), make_new_window_menu(), make_new_help_menu(), ]) } // Creates the app dock menu // Menu here instead of MenuBar, since we only need one vec in the dock // To create submenus, we could use MenuItem::Custom(CustomMenuItem::new_with_submenu(...)) pub fn dock_menu() -> Menu { Menu::new( "New Window", vec![MenuItem::Custom(CustomMenuItem::new( "New Window", move |ctx| { ctx.dispatch_global_action("root_view:open_new", &()); ctx.dispatch_global_action("workspace:save_app", &()); }, no_updates, Some(Keystroke::parse("cmd-n").expect("Valid keystroke")), ))], ) } fn custom_shortcut(action: CustomAction) -> Option { trigger_to_keystroke(&Trigger::Custom(action.into())) } fn default_name(action: CustomAction, ctx: &AppContext) -> String { ctx.description_for_custom_action(action.into(), bindings::MAC_MENUS_CONTEXT) .unwrap_or_else(|| { debug_assert!(false, "action should have a name: {action:?}"); "".into() }) } fn non_updateable_custom_item(action: CustomAction, ctx: &AppContext) -> MenuItem { MenuItem::Custom(CustomMenuItem::new( &default_name(action, ctx), custom_action_dispatcher(action), no_updates, custom_shortcut(action), )) } /// Return a Custom Menu Item whose CustomAction can be updated and /// whose checkmark status is determined by /// the bool result of should_be_checked, a provided closure fn updateable_custom_item_with_checkmark( action: CustomAction, ctx: &AppContext, should_be_checked: Box, ) -> MenuItem { MenuItem::Custom(CustomMenuItem::new( &default_name(action, ctx), custom_action_dispatcher(action), custom_action_updater(action, should_be_checked), custom_shortcut(action), )) } /// Return a Custom Menu Item whose CustomAction can be updated /// and is always unchecked fn updateable_custom_item_without_checkmark(action: CustomAction, ctx: &AppContext) -> MenuItem { updateable_custom_item_with_checkmark(action, ctx, Box::new(|_| false)) } fn make_new_app_menu(ctx: &AppContext) -> Menu { let mut menu_items = vec![updateable_custom_item_without_checkmark( CustomAction::ShowAboutGalaxy, ctx, )]; if !FeatureFlag::AvatarInTabBar.is_enabled() { menu_items.push(updateable_custom_item_without_checkmark( CustomAction::ToggleResourceCenter, ctx, )) } menu_items.extend([ MenuItem::Separator, updateable_custom_item_without_checkmark(CustomAction::ReferAFriend, ctx), MenuItem::Separator, ]); let preferences_menu_items = vec![ updateable_custom_item_without_checkmark(CustomAction::ShowSettings, ctx), MenuItem::Separator, updateable_custom_item_without_checkmark(CustomAction::ToggleKeybindingsPage, ctx), updateable_custom_item_without_checkmark(CustomAction::ConfigureKeybindings, ctx), MenuItem::Separator, updateable_custom_item_without_checkmark(CustomAction::ShowAppearance, ctx), MenuItem::Separator, ]; menu_items.push(MenuItem::Custom(CustomMenuItem::new_with_submenu( "Preferences", |_| (), no_updates, None, preferences_menu_items, ))); if FeatureFlag::Changelog.is_enabled() { menu_items.push(updateable_custom_item_without_checkmark( CustomAction::ViewChangelog, ctx, )); } #[cfg(target_os = "macos")] { menu_items.push(MenuItem::Services); } menu_items.push(MenuItem::Separator); menu_items.push(link_menu_item( "Privacy Policy...", links::PRIVACY_POLICY_URL.into(), )); let debug_menu_items = debug_menu_items(); if !debug_menu_items.is_empty() { menu_items.push(MenuItem::Custom(CustomMenuItem::new_with_submenu( "Debug", |_| (), no_updates, None, debug_menu_items, ))); } menu_items.push(MenuItem::Separator); menu_items.push(MenuItem::Standard(StandardAction::Hide)); menu_items.push(MenuItem::Standard(StandardAction::HideOtherApps)); menu_items.push(MenuItem::Standard(StandardAction::ShowAllApps)); menu_items.push(MenuItem::Separator); menu_items.push(MenuItem::Custom(CustomMenuItem::new( "Set Galaxy as Default Terminal", move |ctx| { DefaultTerminal::handle(ctx).update(ctx, |default_terminal, ctx| { default_terminal.make_warp_default(ctx) }); }, move |_props, ctx| { let default_terminal = DefaultTerminal::handle(ctx).as_ref(ctx); MenuItemPropertyChanges { disabled: Some( !DefaultTerminal::can_warp_become_default() || default_terminal.is_warp_default(), ), ..Default::default() } }, None, ))); menu_items.push(MenuItem::Separator); menu_items.push(MenuItem::Custom(CustomMenuItem::new( "Log out", auth::maybe_log_out, move |_, ctx| { let is_anonymous = AuthStateProvider::handle(ctx) .as_ref(ctx) .get() .is_anonymous_or_logged_out(); MenuItemPropertyChanges { disabled: Some(is_anonymous), ..Default::default() } }, None, ))); menu_items.push(MenuItem::Standard(StandardAction::Quit)); Menu::new("Galaxy", menu_items) } fn make_new_file_menu(ctx: &AppContext) -> Menu { let mut file_menu_options = make_new_elements_menu_items(ctx); file_menu_options.extend([ MenuItem::Separator, updateable_custom_item_without_checkmark(CustomAction::OpenRepository, ctx), MenuItem::Custom(CustomMenuItem::new_with_submenu( "Open Recent", |_| (), |_props, ctx| { let recent_repos = generate_recent_repos_for_menu(ctx); MenuItemPropertyChanges { submenu: Some(Some(make_recent_repos_menu_items(ctx))), disabled: Some(recent_repos.is_empty()), ..Default::default() } }, None, vec![], )), MenuItem::Separator, updateable_custom_item_without_checkmark(CustomAction::CloseCurrentSession, ctx), updateable_custom_item_without_checkmark(CustomAction::CloseWindow, ctx), ]); Menu::new("File", file_menu_options) } fn make_new_edit_menu(ctx: &AppContext) -> Menu { let mut edit_menu_items = vec![]; let group_1 = vec![ updateable_custom_item_without_checkmark(CustomAction::Undo, ctx), updateable_custom_item_without_checkmark(CustomAction::Redo, ctx), ]; let group_2 = vec![ updateable_custom_item_without_checkmark(CustomAction::Cut, ctx), updateable_custom_item_without_checkmark(CustomAction::Copy, ctx), updateable_custom_item_without_checkmark(CustomAction::Paste, ctx), updateable_custom_item_without_checkmark(CustomAction::SelectAll, ctx), updateable_custom_item_without_checkmark(CustomAction::ClearEditor, ctx), ]; let group_3 = vec![ updateable_custom_item_without_checkmark(CustomAction::AddNextOccurrence, ctx), updateable_custom_item_without_checkmark(CustomAction::AddCursorAbove, ctx), updateable_custom_item_without_checkmark(CustomAction::AddCursorBelow, ctx), ]; let group_4 = vec![ updateable_custom_item_without_checkmark(CustomAction::Find, ctx), updateable_custom_item_without_checkmark(CustomAction::GoToLine, ctx), updateable_custom_item_without_checkmark(CustomAction::FocusInput, ctx), ]; let group_5 = vec![ MenuItem::Custom(CustomMenuItem::new( "Use Galaxy's Prompt", move |ctx| ctx.dispatch_global_action("app:toggle_user_ps1", &()), move |_props, ctx| MenuItemPropertyChanges { checked: Some( SessionSettings::handle(ctx).read(ctx, |session_settings, _ctx| { !session_settings.honor_ps1.value() }), ), ..Default::default() }, None, )), MenuItem::Custom(CustomMenuItem::new( "Copy on Select within the Terminal", move |ctx| { ctx.dispatch_global_action("app:toggle_copy_on_select", &()); }, move |_props, ctx| MenuItemPropertyChanges { checked: Some( SelectionSettings::handle(ctx) .as_ref(ctx) .copy_on_select_enabled(), ), ..Default::default() }, None, )), ]; edit_menu_items.extend(group_1); edit_menu_items.push(MenuItem::Separator); edit_menu_items.extend(group_2); edit_menu_items.push(MenuItem::Separator); edit_menu_items.extend(group_3); edit_menu_items.push(MenuItem::Separator); edit_menu_items.extend(group_4); edit_menu_items.push(MenuItem::Separator); edit_menu_items.push(MenuItem::Custom(CustomMenuItem::new_with_submenu( "Synchronize Inputs", |_| (), no_updates, None, vec![ updateable_custom_item_without_checkmark( CustomAction::ToggleSyncAllTerminalInputsInAllTabs, ctx, ), updateable_custom_item_without_checkmark( CustomAction::ToggleSyncTerminalInputsInCurrentTab, ctx, ), updateable_custom_item_with_checkmark( CustomAction::DisableSyncTerminalInputs, ctx, Box::new(|ctx| { if let Some(window_id) = WindowManager::handle(ctx).as_ref(ctx).active_window() { SyncedInputState::handle(ctx) .read(ctx, |status, _| !status.is_syncing_any_inputs(window_id)) } else { false } }), ), ], ))); edit_menu_items.push(MenuItem::Separator); edit_menu_items.extend(group_5); Menu::new("Edit", edit_menu_items) } fn make_new_view_menu(ctx: &AppContext) -> Menu { let mut items = vec![ updateable_custom_item_without_checkmark(CustomAction::ToggleGalaxyDrive, ctx), MenuItem::Separator, updateable_custom_item_without_checkmark(CustomAction::CommandPalette, ctx), updateable_custom_item_without_checkmark(CustomAction::NavigationPalette, ctx), updateable_custom_item_without_checkmark(CustomAction::LaunchConfigPalette, ctx), updateable_custom_item_without_checkmark(CustomAction::FilesPalette, ctx), updateable_custom_item_without_checkmark(CustomAction::ToggleProjectExplorer, ctx), updateable_custom_item_without_checkmark(CustomAction::ToggleConversationListView, ctx), updateable_custom_item_without_checkmark(CustomAction::ToggleGlobalSearch, ctx), MenuItem::Separator, updateable_custom_item_without_checkmark(CustomAction::History, ctx), updateable_custom_item_without_checkmark(CustomAction::CommandSearch, ctx), updateable_custom_item_without_checkmark(CustomAction::Workflows, ctx), MenuItem::Separator, MenuItem::Custom(CustomMenuItem::new( "Toggle Mouse Reporting", move |ctx| { ctx.dispatch_global_action("workspace:toggle_mouse_reporting", &()); }, move |_props, ctx| { let mouse_reporting_enabled = AltScreenReporting::handle(ctx) .as_ref(ctx) .mouse_reporting_enabled .value(); MenuItemPropertyChanges { checked: Some(*mouse_reporting_enabled), ..Default::default() } }, None, )), MenuItem::Custom(CustomMenuItem::new( "Toggle Scroll Reporting", move |ctx| { ctx.dispatch_global_action("workspace:toggle_scroll_reporting", &()); }, move |_props, ctx| { let reporting = AltScreenReporting::handle(ctx).as_ref(ctx); MenuItemPropertyChanges { disabled: Some(!*reporting.mouse_reporting_enabled.value()), checked: Some(*reporting.scroll_reporting_enabled.value()), ..Default::default() } }, None, )), MenuItem::Custom(CustomMenuItem::new( "Toggle Focus Reporting", move |ctx| { ctx.dispatch_global_action("workspace:toggle_focus_reporting", &()); }, move |_props, ctx| { let reporting = AltScreenReporting::handle(ctx).as_ref(ctx); MenuItemPropertyChanges { checked: Some(*reporting.focus_reporting_enabled.value()), ..Default::default() } }, None, )), ]; let is_compact_mode = matches!( TerminalSettings::handle(ctx) .as_ref(ctx) .spacing_mode .value(), SpacingMode::Compact ); items.extend([ MenuItem::Separator, MenuItem::Custom(CustomMenuItem::new( "Compact Mode", move |ctx| { TerminalSettings::handle(ctx).update(ctx, |terminal_settings, ctx| { let current_value = *terminal_settings.spacing_mode; report_if_error!(terminal_settings .spacing_mode .set_value(current_value.other_mode(), ctx)); }); }, move |_props, _| MenuItemPropertyChanges { checked: Some(is_compact_mode), ..Default::default() }, None, )), MenuItem::Separator, ]); if FeatureFlag::UIZoom.is_enabled() { items.extend([ updateable_custom_item_without_checkmark(CustomAction::IncreaseZoom, ctx), updateable_custom_item_without_checkmark(CustomAction::DecreaseZoom, ctx), updateable_custom_item_without_checkmark(CustomAction::ResetZoom, ctx), MenuItem::Separator, ]); } else { items.extend([ updateable_custom_item_without_checkmark(CustomAction::IncreaseFontSize, ctx), updateable_custom_item_without_checkmark(CustomAction::DecreaseFontSize, ctx), updateable_custom_item_without_checkmark(CustomAction::ResetFontSize, ctx), MenuItem::Separator, ]); } Menu::new("View", items) } fn make_new_tab_menu(ctx: &AppContext) -> Menu { let items = vec![ updateable_custom_item_without_checkmark(CustomAction::RenameTab, ctx), MenuItem::Separator, updateable_custom_item_without_checkmark(CustomAction::SplitPaneRight, ctx), updateable_custom_item_without_checkmark(CustomAction::SplitPaneLeft, ctx), updateable_custom_item_without_checkmark(CustomAction::SplitPaneDown, ctx), updateable_custom_item_without_checkmark(CustomAction::SplitPaneUp, ctx), MenuItem::Separator, updateable_custom_item_without_checkmark(CustomAction::MoveTabLeft, ctx), updateable_custom_item_without_checkmark(CustomAction::MoveTabRight, ctx), MenuItem::Separator, updateable_custom_item_without_checkmark(CustomAction::CycleNextSession, ctx), updateable_custom_item_without_checkmark(CustomAction::CyclePrevSession, ctx), MenuItem::Separator, updateable_custom_item_without_checkmark(CustomAction::ActivateNextPane, ctx), updateable_custom_item_without_checkmark(CustomAction::ActivatePreviousPane, ctx), MenuItem::Separator, updateable_custom_item_without_checkmark(CustomAction::ToggleMaximizePane, ctx), MenuItem::Separator, updateable_custom_item_without_checkmark(CustomAction::CloseTab, ctx), updateable_custom_item_without_checkmark(CustomAction::CloseOtherTabs, ctx), updateable_custom_item_without_checkmark(CustomAction::CloseTabsRight, ctx), ]; Menu::new("Tab", items) } fn make_new_ai_menu(ctx: &AppContext) -> Menu { let mut items = vec![updateable_custom_item_without_checkmark( CustomAction::NewAgentModePane, ctx, )]; items.push(updateable_custom_item_without_checkmark( CustomAction::AttachSelectionAsAgentModeContext, ctx, )); items.extend([ MenuItem::Separator, updateable_custom_item_without_checkmark(CustomAction::AISearch, ctx), ]); if FeatureFlag::AIRules.is_enabled() { items.extend([ MenuItem::Separator, updateable_custom_item_without_checkmark(CustomAction::OpenAIFactCollection, ctx), ]); } if FeatureFlag::McpServer.is_enabled() && ContextFlag::ShowMCPServers.is_enabled() { items.push(updateable_custom_item_without_checkmark( CustomAction::OpenMCPServerCollection, ctx, )); } Menu::new("AI", items) } fn make_new_blocks_menu(ctx: &AppContext) -> Menu { let mut items = vec![ updateable_custom_item_without_checkmark(CustomAction::ClearBlocks, ctx), MenuItem::Separator, updateable_custom_item_without_checkmark(CustomAction::SelectBlockAbove, ctx), updateable_custom_item_without_checkmark(CustomAction::SelectBlockBelow, ctx), updateable_custom_item_without_checkmark(CustomAction::SelectAllBlocks, ctx), MenuItem::Separator, ]; items.push(updateable_custom_item_without_checkmark( CustomAction::ScrollToTopOfSelectedBlocks, ctx, )); items.push(updateable_custom_item_without_checkmark( CustomAction::ScrollToBottomOfSelectedBlocks, ctx, )); items.push(MenuItem::Separator); items.extend([ updateable_custom_item_without_checkmark(CustomAction::CreateBlockPermalink, ctx), non_updateable_custom_item(CustomAction::ViewSharedBlocks, ctx), updateable_custom_item_without_checkmark(CustomAction::ToggleBookmarkBlock, ctx), updateable_custom_item_without_checkmark(CustomAction::FindWithinBlock, ctx), MenuItem::Separator, updateable_custom_item_without_checkmark(CustomAction::CopyBlock, ctx), updateable_custom_item_without_checkmark(CustomAction::CopyBlockCommand, ctx), updateable_custom_item_without_checkmark(CustomAction::CopyBlockOutput, ctx), ]); let debug_items = block_menu_debug_items(); if !debug_items.is_empty() { items.push(MenuItem::Separator); items.extend(debug_items); } Menu::new("Blocks", items) } fn make_new_drive_menu(ctx: &AppContext) -> Menu { let mut items = vec![ updateable_custom_item_without_checkmark(CustomAction::NewPersonalWorkflow, ctx), updateable_custom_item_without_checkmark(CustomAction::NewPersonalNotebook, ctx), updateable_custom_item_without_checkmark(CustomAction::NewPersonalAIPrompt, ctx), ]; items.push(updateable_custom_item_without_checkmark( CustomAction::NewPersonalEnvVars, ctx, )); items.extend([ MenuItem::Separator, updateable_custom_item_without_checkmark(CustomAction::NewTeamWorkflow, ctx), updateable_custom_item_without_checkmark(CustomAction::NewTeamNotebook, ctx), updateable_custom_item_without_checkmark(CustomAction::NewTeamAIPrompt, ctx), ]); items.push(updateable_custom_item_without_checkmark( CustomAction::NewTeamEnvVars, ctx, )); items.extend([ MenuItem::Separator, updateable_custom_item_without_checkmark(CustomAction::ToggleGalaxyDrive, ctx), updateable_custom_item_without_checkmark(CustomAction::SearchDrive, ctx), updateable_custom_item_without_checkmark(CustomAction::OpenTeamSettings, ctx), updateable_custom_item_without_checkmark(CustomAction::OpenAIFactCollection, ctx), updateable_custom_item_without_checkmark(CustomAction::OpenMCPServerCollection, ctx), ]); items.push(updateable_custom_item_without_checkmark( CustomAction::SharePaneContents, ctx, )); if FeatureFlag::CreatingSharedSessions.is_enabled() { items.extend([ MenuItem::Separator, updateable_custom_item_without_checkmark(CustomAction::ShareCurrentSession, ctx), ]) } Menu::new("Drive", items) } /// Returns [`MenuItem`]s that aid debugging to be included in the Block menu. fn block_menu_debug_items() -> Vec { let mut items = vec![]; if FeatureFlag::ToggleBootstrapBlock.is_enabled() { items.push(toggle_bootstrap_block_menu_item()); } items.push(MenuItem::Custom(CustomMenuItem::new( SHOW_IN_BAND_COMMAND_BLOCKS_MENU_ITEM_NAME, move |ctx| { let handle = BlockVisibilitySettings::handle(ctx); handle.update(ctx, |block_visibility_settings, ctx| { let new_value = !block_visibility_settings .should_show_in_band_command_blocks .value(); if let Err(e) = block_visibility_settings .should_show_in_band_command_blocks .set_value(new_value, ctx) { log::error!("Failed to persist 'Show in-band command blocks' setting: {e}"); } }); }, move |_props, ctx| { let name = if BlockVisibilitySettings::handle(ctx).read(ctx, |settings, _ctx| { *settings.should_show_in_band_command_blocks.value() }) { HIDE_IN_BAND_COMMAND_BLOCKS_MENU_ITEM_NAME.to_owned() } else { SHOW_IN_BAND_COMMAND_BLOCKS_MENU_ITEM_NAME.to_owned() }; MenuItemPropertyChanges { name: Some(name), ..Default::default() } }, None, ))); items.push(MenuItem::Custom(CustomMenuItem::new( SHOW_SSH_COMMAND_BLOCKS_MENU_ITEM_NAME, move |ctx| { let handle = BlockVisibilitySettings::handle(ctx); handle.update(ctx, |block_visibility_settings, ctx| { let new_value = !block_visibility_settings.should_show_ssh_block.value(); if let Err(e) = block_visibility_settings .should_show_ssh_block .set_value(new_value, ctx) { log::error!("Failed to persist 'Show ssh command blocks' setting: {e}"); } }); }, move |_props, ctx| { let name = if BlockVisibilitySettings::handle(ctx).read(ctx, |settings, _ctx| { *settings.should_show_ssh_block.value() }) { HIDE_SSH_COMMAND_BLOCKS_MENU_ITEM_NAME.to_owned() } else { SHOW_SSH_COMMAND_BLOCKS_MENU_ITEM_NAME.to_owned() }; MenuItemPropertyChanges { name: Some(name), ..Default::default() } }, None, ))); items } fn toggle_bootstrap_block_menu_item() -> MenuItem { MenuItem::Custom(CustomMenuItem::new( SHOW_BOOTSTRAP_BLOCK_MENU_ITEM_NAME, move |ctx| { BlockVisibilitySettings::handle(ctx).update(ctx, |block_visibility_settings, ctx| { let new_value = !block_visibility_settings .should_show_bootstrap_block .value(); if let Err(e) = block_visibility_settings .should_show_bootstrap_block .set_value(new_value, ctx) { log::error!("Failed to persist 'Show bootstrap block' setting: {e}"); } }); }, move |_props, ctx| { let name = if BlockVisibilitySettings::handle(ctx).read(ctx, |settings, _ctx| { *settings.should_show_bootstrap_block.value() }) { HIDE_BOOTSTRAP_BLOCK_MENU_ITEM_NAME.to_owned() } else { SHOW_BOOTSTRAP_BLOCK_MENU_ITEM_NAME.to_owned() }; MenuItemPropertyChanges { name: Some(name), ..Default::default() } }, None, )) } fn make_new_window_menu() -> Menu { Menu::new( "Window", vec![ MenuItem::Standard(StandardAction::Minimize), MenuItem::Standard(StandardAction::Zoom), MenuItem::Standard(StandardAction::ToggleFullScreen), MenuItem::Separator, MenuItem::Standard(StandardAction::BringAllToFront), ], ) } fn debug_menu_items() -> Vec { let mut debug_menu_items = vec![]; if FeatureFlag::DebugMode.is_enabled() { debug_menu_items.push(MenuItem::Custom(CustomMenuItem::new( ENABLE_SHELL_DEBUG_MODE_MENU_ITEM_NAME, move |ctx| { DebugSettings::handle(ctx).update(ctx, |debug_settings, ctx| { let new_value = !debug_settings.is_shell_debug_mode_enabled.value(); if let Err(e) = debug_settings .is_shell_debug_mode_enabled .set_value(new_value, ctx) { log::error!("Failed to persist 'Debug mode' setting: {e}"); } }); }, move |_props, ctx| { let name = if DebugSettings::handle(ctx).read(ctx, |settings, _ctx| { *settings.is_shell_debug_mode_enabled.value() }) { DISABLE_SHELL_DEBUG_MODE_MENU_ITEM_NAME.to_owned() } else { ENABLE_SHELL_DEBUG_MODE_MENU_ITEM_NAME.to_owned() }; MenuItemPropertyChanges { name: Some(name), ..Default::default() } }, None, ))); debug_menu_items.push(MenuItem::Custom(CustomMenuItem::new( ENABLE_PTY_RECORDING, move |ctx| { DebugSettings::handle(ctx).update(ctx, |debug_settings, ctx| { let new_value = !debug_settings.recording_mode.value(); let _ = debug_settings.recording_mode.set_value(new_value, ctx); }); }, move |_props, ctx| { let name = if DebugSettings::handle(ctx) .read(ctx, |settings, _ctx| *settings.recording_mode.value()) { DISABLE_PTY_RECORDING.to_owned() } else { ENABLE_PTY_RECORDING.to_owned() }; MenuItemPropertyChanges { name: Some(name), ..Default::default() } }, None, ))); debug_menu_items.push(MenuItem::Custom(CustomMenuItem::new( ENABLE_IN_BAND_GENERATORS_MENU_ITEM_NAME, move |ctx| { DebugSettings::handle(ctx).update(ctx, |debug_settings, ctx| { let new_value = !debug_settings .are_in_band_generators_for_all_sessions_enabled .value(); if let Err(e) = debug_settings .are_in_band_generators_for_all_sessions_enabled .set_value(new_value, ctx) { log::error!("Failed to persist 'Enable in-band generators' setting: {e}"); } }); }, move |_props, ctx| { let name = if DebugSettings::handle(ctx).read(ctx, |settings, _ctx| { *settings .are_in_band_generators_for_all_sessions_enabled .value() }) { DISABLE_IN_BAND_GENERATORS_MENU_ITEM_NAME.to_owned() } else { ENABLE_IN_BAND_GENERATORS_MENU_ITEM_NAME.to_owned() }; MenuItemPropertyChanges { name: Some(name), ..Default::default() } }, None, ))); if !FeatureFlag::ToggleBootstrapBlock.is_enabled() { debug_menu_items.push(toggle_bootstrap_block_menu_item()); } debug_menu_items.push(MenuItem::Custom(CustomMenuItem::new( "Manually Toggle Network Status", move |ctx| ctx.dispatch_global_action("workspace:toggle_debug_network_status", &()), no_updates, None, ))); debug_menu_items.push(MenuItem::Custom(CustomMenuItem::new( EXPORT_DEFAULT_SETTINGS_CSV_MENU_ITEM_NAME, move |ctx| { let default_settings = SettingsManager::handle(ctx).as_ref(ctx).default_values(); let mut writer = Writer::from_writer( File::create( dirs::home_dir() .unwrap_or_default() .join(SETTINGS_CSV_FILE_NAME), ) .expect("Failed to create settings csv file"), ); writer .write_record(["storage_key", "value"]) .expect("Failed to write header record"); for (storage_key, value) in default_settings { log::debug!("Writing setting: {storage_key} = {value}"); writer .write_record(&[storage_key, value]) .expect("Failed to write settings record"); } let _ = writer.flush(); }, no_updates, None, ))); debug_menu_items.push(MenuItem::Custom(CustomMenuItem::new( "Create anonymous user", move |ctx| ctx.dispatch_global_action("workspace:debug_create_anonymous_user", &()), no_updates, None, ))); } if FeatureFlag::RuntimeFeatureFlags.is_enabled() { debug_menu_items.extend(runtime_flags_menu_items()); } debug_menu_items } fn link_menu_item(title: &'static str, link: Cow<'static, str>) -> MenuItem { MenuItem::Custom(CustomMenuItem::new( title, move |ctx| { ctx.open_url(&link); }, no_updates, None, )) } fn feedback_menu_item() -> MenuItem { MenuItem::Custom(CustomMenuItem::new( "Send Feedback...", move |ctx| { // Route through the root-view action so workspace windows can open the // guided AI flow, while non-workspace windows still fall back to the // browser-based feedback form. ctx.dispatch_global_action("root_view:send_feedback", &()); }, no_updates, None, )) } fn make_new_help_menu() -> Menu { Menu::new( "Help", vec![ feedback_menu_item(), link_menu_item("Galaxy Documentation...", links::USER_DOCS_URL.into()), link_menu_item("GitHub Issues...", links::GITHUB_ISSUES_URL.into()), link_menu_item("Galaxy Slack Community...", links::SLACK_URL.into()), ], ) } fn make_launch_config_menu_items(ctx: &mut AppContext) -> Vec { let mut launch_config_menu_items = vec![]; let launch_configs = GalaxyConfig::handle(ctx).as_ref(ctx).launch_configs(); for config in launch_configs { launch_config_menu_items.push(MenuItem::Custom(CustomMenuItem::new( &config.name, enclose!((config) move |ctx| { ctx.dispatch_global_action( "root_view:open_launch_config", &OpenLaunchConfigArg { launch_config: config.clone(), ui_location: LaunchConfigUiLocation::AppMenu, open_in_active_window: false, } ); ctx.dispatch_global_action("workspace:save_app", &()); }), no_updates, None, ))); } if !launch_config_menu_items.is_empty() { launch_config_menu_items.push(MenuItem::Separator); } // TODO(vorporeal): use non_updateable_custom_item() here instead launch_config_menu_items.push(MenuItem::Custom(CustomMenuItem::new( "Save New...", custom_action_dispatcher(CustomAction::SaveCurrentConfig), no_updates, custom_shortcut(CustomAction::SaveCurrentConfig), ))); launch_config_menu_items } fn make_new_elements_menu_items(ctx: &AppContext) -> Vec { // Dynamically assign the workspace:new_tab keystroke (cmd-t) to whichever item // matches the user's "Default mode for new sessions" setting. The non-default item // shows its dedicated keystroke instead. let mut new_elements_menu = vec![ MenuItem::Custom(CustomMenuItem::new( "New Window", open_new_window, no_updates, Some(Keystroke::parse("cmd-n").expect("Valid keystroke")), )), MenuItem::Custom(CustomMenuItem::new( "New Terminal Tab", open_new_default_tab_or_window, move |_props: &MenuItemProperties, ctx: &mut AppContext| { let mut changes = MenuItemPropertyChanges::default(); let is_default_session_mode_agent = AISettings::handle(ctx).read(ctx, |ai_settings, ctx| { ai_settings.is_any_ai_enabled(ctx) && ai_settings.default_session_mode(ctx) == DefaultSessionMode::Agent }); let trigger = if is_default_session_mode_agent { Trigger::Custom(CustomAction::NewTerminalTab.into()) } else { Trigger::Custom(CustomAction::NewTab.into()) }; let binding = ctx .get_key_bindings() .find(|b| b.trigger == &trigger || b.original_trigger == Some(&trigger)); if let Some(binding) = binding { changes.keystroke = Some(bindings::trigger_to_keystroke(binding.trigger)); } changes }, Some(Keystroke::parse("cmd-t").expect("Valid keystroke")), )), MenuItem::Custom(CustomMenuItem::new( "New Agent Tab", open_new_agent_tab_or_window, move |_props: &MenuItemProperties, ctx: &mut AppContext| { let mut changes = MenuItemPropertyChanges::default(); let (is_any_ai_enabled, is_default_session_mode_agent) = AISettings::handle(ctx) .read(ctx, |ai_settings, ctx| { let enabled = ai_settings.is_any_ai_enabled(ctx); let agent = enabled && ai_settings.default_session_mode(ctx) == DefaultSessionMode::Agent; (enabled, agent) }); if !is_any_ai_enabled { changes.disabled = Some(true); return changes; } let trigger = if is_default_session_mode_agent { Trigger::Custom(CustomAction::NewTab.into()) } else { Trigger::Custom(CustomAction::NewAgentTab.into()) }; let binding = ctx .get_key_bindings() .find(|b| b.trigger == &trigger || b.original_trigger == Some(&trigger)); if let Some(binding) = binding { changes.keystroke = Some(bindings::trigger_to_keystroke(binding.trigger)); } changes }, None, )), non_updateable_custom_item(CustomAction::NewFile, ctx), ]; let reopen_session_action_updater = custom_action_updater(CustomAction::ReopenClosedSession, Box::new(|_| false)); new_elements_menu.push(MenuItem::Custom(CustomMenuItem::new( "Reopen closed session", |ctx| { UndoCloseStack::handle(ctx).update(ctx, |stack, ctx| { stack.undo_close(ctx); }); }, move |props, ctx| { let mut changes = reopen_session_action_updater(props, ctx); changes.disabled = Some(UndoCloseStack::handle(ctx).as_ref(ctx).is_empty()); changes }, Some(Keystroke::parse("cmd-shift-T").expect("Valid keystroke")), ))); new_elements_menu.push(MenuItem::Custom(CustomMenuItem::new_with_submenu( "Launch Configurations", |_| (), |_props, ctx| MenuItemPropertyChanges { submenu: Some(Some(make_launch_config_menu_items(ctx))), ..Default::default() }, None, vec![], ))); new_elements_menu } /// \return a callback that dispatches a CustomAction, appropriate for plugging into a CustomMenuItem. fn custom_action_dispatcher(action: CustomAction) -> impl Fn(&mut AppContext) + 'static { move |ctx| { if let Some(wid) = WindowManager::handle(ctx).as_ref(ctx).active_window() { ctx.dispatch_custom_action(action, wid) } } } /// Dispatch events to open the user's default tab type in the active window /// or make a new window if there is no active window. fn open_new_default_tab_or_window(ctx: &mut AppContext) { if let Some(wid) = WindowManager::handle(ctx).as_ref(ctx).active_window() { ctx.dispatch_custom_action(CustomAction::NewTab, wid) } else { open_new_window(ctx) } } /// Dispatch events to open an agent tab in the active window /// or make a new window if there is no active window. fn open_new_agent_tab_or_window(ctx: &mut AppContext) { if let Some(wid) = WindowManager::handle(ctx).as_ref(ctx).active_window() { ctx.dispatch_custom_action(CustomAction::NewAgentTab, wid) } else { open_new_window(ctx) } } /// Dispatch event to open a new Warp window fn open_new_window(ctx: &mut AppContext) { ctx.dispatch_global_action("root_view:open_new", &()); ctx.dispatch_global_action("workspace:save_app", &()); } /// No-op updater function for custom menu items that never change. fn no_updates(_: &MenuItemProperties, _: &mut AppContext) -> MenuItemPropertyChanges { Default::default() } fn make_recent_repos_menu_items(ctx: &AppContext) -> Vec { let recent_repos = generate_recent_repos_for_menu(ctx); if recent_repos.is_empty() { return vec![]; } let home = dirs::home_dir().map(|p| p.display().to_string()); recent_repos .into_iter() .map(|path| { let full_path = path.display().to_string(); let display_path = user_friendly_path(&full_path, home.as_deref()).into_owned(); MenuItem::Custom(CustomMenuItem::new( &display_path, move |ctx| { ctx.dispatch_global_action("workspace:open_repository", &full_path); }, no_updates, None, )) }) .collect() } fn generate_recent_repos_for_menu(ctx: &AppContext) -> Vec { PersistedWorkspace::handle(ctx) .as_ref(ctx) .workspaces() .sorted_by(WorkspaceMetadata::most_recently_navigated) .take(MAX_RECENT_REPOS_IN_MENU) .map(|cbm| cbm.path) .collect::>() } /// \return a callback that updates a custom action based menu item based on the /// current keybinding context: /// 1) Whether the item should be enabled / disabled /// 2) The current keystroke bound to the item (either the default or a custom one) /// 3) The name of the item /// /// This updated is called by the mac menu system before the menu is opened. /// /// Note, that correctly disabling the key binding is important not just for limiting /// what a user can do in a given context, but also because if the keybinding is not /// disabled correctly, the mac menu system will capture key events when they should /// be flowing into the underlying views (e.g. ctrl-c needs to flow directly to the /// alt grid in emacs, whereas if our editor is active it's ok to dispatch a custom action) fn custom_action_updater( action: CustomAction, checkmark_status: Box, ) -> impl Fn(&MenuItemProperties, &mut AppContext) -> MenuItemPropertyChanges + 'static { move |_props, ctx| { let mut changes = MenuItemPropertyChanges::default(); ctx.update_custom_action_binding(action.into(), |binding| { changes.disabled = Some(binding.is_none()); if let Some(binding) = binding { if let Some(description) = binding.description { changes.name = Some( description .resolve(ctx, bindings::MAC_MENUS_CONTEXT) .into_owned(), ); } changes.keystroke = Some(bindings::trigger_to_keystroke(binding.trigger)); } }); changes.checked = Some(checkmark_status(ctx)); changes } }