diff --git a/architecture/notes/nebula_app/gpui_shell/2026-09-24-macos-system-menu.md b/architecture/notes/nebula_app/gpui_shell/2026-09-24-macos-system-menu.md new file mode 100644 index 000000000..e13c5b7ce --- /dev/null +++ b/architecture/notes/nebula_app/gpui_shell/2026-09-24-macos-system-menu.md @@ -0,0 +1,59 @@ +# macOS system menu and close shortcuts + +## Status + +Accepted. + +## Context + +The GPUI workspace already closes terminal tabs with `cmd-w`, while native window +close must preserve its document, session, and busy-process checks. macOS users +also expect application quit and common editing commands in the system menu. + +## Evidence + +- [`keyboard_bindings.rs`](../../../../nebula_app/src/gpui_shell/workspace/keyboard_bindings.rs) + registered `cmd-w` for `CloseActiveTerminal`. +- [`closing.rs`](../../../../nebula_app/src/gpui_shell/workspace/closing.rs) + owns the existing window-close checks and persistence path. +- [`shutdown.rs`](../../../../nebula_app/src/gpui_shell/workspace/windowing/shutdown.rs) + owns the save and approval steps before quitting. + +## Decision + +Use GPUI's native application menus. `cmd-q` calls the shared graceful quit path; +`cmd-w` invokes the workspace close checks; terminal close moves to `cmd-shift-w`. +The app menu exposes About, Settings, Services, Hide, Hide Others, and Quit, with +File, Edit, View, and Window menus alongside it. About explicitly selects the +Application home and clears Settings search. Quit reuses the global `QuitApp` +action. The shared Command alias table moves terminal close to `cmd-shift-w`; +restoring Quit cannot reinstate the legacy `cmd-w` application-quit binding. + +## Rejected alternatives + +- Keep `cmd-w` for terminal close: conflicts with the expected system window-close + shortcut. +- Call `remove_window()` directly from the menu: bypasses the workspace close + checks and session persistence. +- Call `cx.quit()` directly: bypasses the shared quit approval and save path. + +## Consequences + +Window-menu close and the native close button share the same close behavior. +The menu is rebuilt when the selected UI language changes. No dependency or saved +format changes are introduced. + +## Validation + +The production macOS keymap regression checks window close, tab close and Quit +after restoring the configured Quit defaults. Architecture and whitespace checks +pass against the current base. Pinned rustfmt passes; GitHub native compilation, +workspace tests and macOS control acceptance remain required before readiness. + +## Supersedes + +None. + +## Revisit when + +The GPUI menu API or workspace shutdown and close flows change. diff --git a/nebula_app/i18n/en-US.json b/nebula_app/i18n/en-US.json index 08a04f57c..cf9f4de97 100644 --- a/nebula_app/i18n/en-US.json +++ b/nebula_app/i18n/en-US.json @@ -615,6 +615,16 @@ "new_folder": "New folder", "new_tab": "New tab", "new_window": "New window", + "close_window": "Close Window", + "hide_pebrel": "Hide Pebrel", + "hide_others": "Hide Others", + "quit_pebrel": "Quit Pebrel", + "menu_file": "File", + "menu_edit": "Edit", + "menu_view": "View", + "menu_window": "Window", + "menu_services": "Services", + "menu_minimize": "Minimize", "close_tab": "Close tab", "duplicate_tab": "Duplicate tab", "terminal": "Terminal", diff --git a/nebula_app/i18n/zh-CN.json b/nebula_app/i18n/zh-CN.json index 4aa7e0b9d..841c3cd32 100644 --- a/nebula_app/i18n/zh-CN.json +++ b/nebula_app/i18n/zh-CN.json @@ -615,6 +615,16 @@ "new_folder": "新建文件夹", "new_tab": "新建标签", "new_window": "新建窗口", + "close_window": "关闭窗口", + "hide_pebrel": "隐藏 Pebrel", + "hide_others": "隐藏其他应用", + "quit_pebrel": "退出 Pebrel", + "menu_file": "文件", + "menu_edit": "编辑", + "menu_view": "显示", + "menu_window": "窗口", + "menu_services": "服务", + "menu_minimize": "最小化", "close_tab": "关闭标签", "duplicate_tab": "复制标签", "terminal": "终端", diff --git a/nebula_app/src/display/keymap.rs b/nebula_app/src/display/keymap.rs index 876d8fe92..498dd77d2 100644 --- a/nebula_app/src/display/keymap.rs +++ b/nebula_app/src/display/keymap.rs @@ -140,9 +140,8 @@ impl ShortcutPolicy { pub(crate) const MACOS_COMMAND_ALIASES: &[(&str, Action)] = &[ ("cmd+t", Action::CreateNewTab), ("cmd+n", Action::CreateNewWindow), - // ⌘W 关标签页是 GPUI 壳的实际行为;配置表的 macOS 段把 ⌘W 记作 Quit, - // 这里必须压过它,否则解绑恢复会把 ⌘W 还给退出。 - ("cmd+w", Action::CloseTab), + // ⌘W 留给原生关窗;终端标签关闭的注册与设置恢复共用这个别名。 + ("shift+cmd+w", Action::CloseTab), ("shift+cmd+p", Action::ToggleCommandPalette), ("cmd+k", Action::ToggleShellPicker), ("shift+cmd+f", Action::ToggleFilesPanel), @@ -378,6 +377,10 @@ impl ShortcutPolicy { } shortcuts.extend(extra); } + if self.gpui && cfg!(target_os = "macos") { + // 原生关窗没有持久化 Action;恢复 Quit 不得把旧壳的 ⌘W 注入为退出应用。 + shortcuts.retain(|(combo, _)| !self.same_combo(combo, "cmd+w")); + } shortcuts } diff --git a/nebula_app/src/gpui_shell/settings_pane/about.rs b/nebula_app/src/gpui_shell/settings_pane/about.rs index c3df704a5..804e8f19e 100644 --- a/nebula_app/src/gpui_shell/settings_pane/about.rs +++ b/nebula_app/src/gpui_shell/settings_pane/about.rs @@ -1,6 +1,13 @@ use super::*; impl SettingsPane { + pub(crate) fn show_application_home(&mut self, window: &mut Window, cx: &mut Context) { + self.active_section = 0; + self.about_sponsor_open = false; + self.settings_search_input.update(cx, |input, cx| input.set_value("", window, cx)); + cx.notify(); + } + pub(super) fn about_action_row( id: &'static str, icon: IconName, diff --git a/nebula_app/src/gpui_shell/workspace.rs b/nebula_app/src/gpui_shell/workspace.rs index 18d08785e..81705cd4b 100644 --- a/nebula_app/src/gpui_shell/workspace.rs +++ b/nebula_app/src/gpui_shell/workspace.rs @@ -45,10 +45,12 @@ use gpui_component::input::InputEvent; use gpui_component::notification::Notification; use nebula_split::{DIVIDER_GAP, HIT_SLOP, RemoveOutcome, SplitDirection, SplitNav, SplitTree}; +mod actions; mod agents; mod closing; mod command_manager; mod keyboard_bindings; +mod macos_menu; mod render; #[cfg(test)] use keyboard_bindings::{ @@ -102,49 +104,13 @@ pub(crate) mod windowing; // 调用点分散在设置页与窗口层,原样再导出以免拆分波及它们。 pub(crate) use update_dialog::{open_update_dialog, show_update_notification}; +use actions::*; use tab_drag::{DockTarget, TabDrag, TabDragAxis}; #[cfg(test)] use agents::ai_hook_target_pane; use agents::{ai_session_palette_rows, restored_agent_command}; -gpui::actions!( - nebula_workspace, - [ - NewTerminal, - NewWindow, - CloseActiveTerminal, - ToggleSidebar, - OpenSettings, - ToggleCommandPalette, - CloseCommandPalette, - ToggleShellPicker, - CommandPaletteUp, - CommandPaletteDown, - ToggleFileTree, - ToggleGitPanel, - SplitRight, - SplitDown, - RenameActiveTab, - ToggleZoom, - FocusPaneLeft, - FocusPaneRight, - FocusPaneUp, - FocusPaneDown, - SelectNextTab, - SelectPreviousTab, - MoveTabLeft, - MoveTabRight, - IncreaseFontSize, - DecreaseFontSize, - ResetFontSize, - CopySelection, - PasteClipboard, - ToggleFullscreen, - OpenQuickJump - ] -); - /// 命令面板 / Shell 选择器罩层的 keymap context。Esc 必须挂在这里, /// 不能挂全局:否则 CC/Codex 的终止对话键到不了 PTY。 const PALETTE_KEY_CONTEXT: &str = "NebulaCommandPalette"; @@ -181,6 +147,9 @@ fn title_bar_panel_controls() -> gpui::Div { /// 注册工作区快捷键;在 `gpui_component::init` 之后调用一次。 pub fn init(cx: &mut App) { keyboard_bindings::init(cx); + if crate::platform::CAPABILITIES.native_application_menu { + macos_menu::init(cx); + } } /// 一个终端 pane:视图实体 + 宿主订阅。id 即 `TerminalView::pane_id` @@ -1123,7 +1092,13 @@ impl NebulaWorkspace { /// follow_system 折算)、逐终端刷新、重建 chrome 令牌。 fn apply_runtime_settings(&mut self, cx: &mut Context) { let (runtime, settings) = crate::gpui_shell::config::Settings::load_current_snapshot(cx); + let language_changed = cx + .try_global::() + .is_none_or(|previous| previous.ui_language != settings.ui_language); cx.set_global(settings); + if crate::platform::CAPABILITIES.native_application_menu && language_changed { + cx.defer(|cx| macos_menu::init(cx)); + } for tab in &self.tabs { if let WorkspaceTab::Terminal { panes, .. } = tab { for pane in panes { diff --git a/nebula_app/src/gpui_shell/workspace/actions.rs b/nebula_app/src/gpui_shell/workspace/actions.rs new file mode 100644 index 000000000..d1320c541 --- /dev/null +++ b/nebula_app/src/gpui_shell/workspace/actions.rs @@ -0,0 +1,41 @@ +gpui::actions!( + nebula_workspace, + [ + NewTerminal, + NewWindow, + CloseActiveTerminal, + CloseWindow, + OpenAbout, + HideApplication, + HideOtherApplications, + MinimizeWindow, + ToggleSidebar, + OpenSettings, + ToggleCommandPalette, + CloseCommandPalette, + ToggleShellPicker, + CommandPaletteUp, + CommandPaletteDown, + ToggleFileTree, + ToggleGitPanel, + SplitRight, + SplitDown, + RenameActiveTab, + ToggleZoom, + FocusPaneLeft, + FocusPaneRight, + FocusPaneUp, + FocusPaneDown, + SelectNextTab, + SelectPreviousTab, + MoveTabLeft, + MoveTabRight, + IncreaseFontSize, + DecreaseFontSize, + ResetFontSize, + CopySelection, + PasteClipboard, + ToggleFullscreen, + OpenQuickJump + ] +); diff --git a/nebula_app/src/gpui_shell/workspace/keyboard_bindings.rs b/nebula_app/src/gpui_shell/workspace/keyboard_bindings.rs index 457827d69..96dc92dc5 100644 --- a/nebula_app/src/gpui_shell/workspace/keyboard_bindings.rs +++ b/nebula_app/src/gpui_shell/workspace/keyboard_bindings.rs @@ -68,7 +68,7 @@ pub(super) fn init(cx: &mut App) { cx.bind_keys(default_workspace_bindings()); // 平台判定复用已有入口;共享表不意味着给其他平台注册 ⌘ 快捷键。 if crate::platform::Platform::current() == crate::platform::Platform::MacOS { - bind_macos_command_keys(cx); + cx.bind_keys(macos_command_bindings()); } // 退出不属于任何视图,挂全局兜底:⌘Q 与用户自定义的 `keybind=…:Quit` // 都落到与托盘退出同一条「先落盘会话与草稿,再停 PTY」的路径。 @@ -164,23 +164,28 @@ fn default_binding_source(binding: KeyBinding) -> KeyBinding { /// macOS 使用 ⌘ 承载字符类 GUI 默认键;仅 Control 的字符组合交给终端。 /// 保留现有 Ctrl+Tab、Ctrl+Shift 和 Ctrl+Alt 导航组合及显式用户覆盖。 +/// ⌘W 关闭窗口,活动终端改用 ⌘⇧W,避免与系统关闭窗口命令冲突。 /// /// 键位来自 `display::keymap::MACOS_COMMAND_ALIASES`:设置页的反查、解绑与 /// 恢复读的是同一张表,两处不会再各自漂移。注册必须留在这里、且早于用户 /// 自定义键,这样 `clear_action` 注入的 NoAction 才压得住静态默认绑定。 -fn bind_macos_command_keys(cx: &mut App) { +fn macos_command_bindings() -> Vec { let mut bindings: Vec = crate::display::keymap::MACOS_COMMAND_ALIASES .iter() .filter_map(|(combo, action)| workspace_binding_in_context(combo, action, None)) .collect(); // 剩下这些没有对应的 `config::Action`,或者需要单独的作用域。 bindings.extend([ + KeyBinding::new("cmd-w", CloseWindow, None), + KeyBinding::new("cmd-h", HideApplication, None), + KeyBinding::new("cmd-alt-h", HideOtherApplications, None), + KeyBinding::new("cmd-m", MinimizeWindow, None), KeyBinding::new("cmd-b", ToggleSidebar, None), KeyBinding::new("cmd-,", OpenSettings, None), // 设置页与对话框的输入框自带 ⌘V,作用域必须和终端分开。 KeyBinding::new("cmd-v", gpui_component::input::Paste, Some("Input")), ]); - cx.bind_keys(bindings.into_iter().map(default_binding_source)); + bindings.into_iter().map(default_binding_source).collect() } /// Typed GPUI adapter for the shared numbered/last-tab actions. The existing diff --git a/nebula_app/src/gpui_shell/workspace/keyboard_bindings/tests.rs b/nebula_app/src/gpui_shell/workspace/keyboard_bindings/tests.rs index 1a68f476c..aec4c4509 100644 --- a/nebula_app/src/gpui_shell/workspace/keyboard_bindings/tests.rs +++ b/nebula_app/src/gpui_shell/workspace/keyboard_bindings/tests.rs @@ -55,23 +55,25 @@ mod macos_command_keys { #[test] fn command_close_and_quit_dispatch_to_distinct_actions() { use gpui::{KeyContext, Keymap, Keystroke}; - // 合并后仍按共享别名表分派,防止配置中的 ⌘W/Quit 抢走关闭标签页动作。 - let bindings = crate::display::keymap::MACOS_COMMAND_ALIASES - .iter() - .filter_map(|(combo, action)| workspace_binding_in_context(combo, action, None)) - .collect(); - let keymap = Keymap::new(bindings); + let mut keymap = Keymap::new(macos_command_bindings()); + keymap.add_bindings( + crate::display::keymap::GPUI + .default_shortcuts() + .into_iter() + .filter(|(_, action)| *action == crate::config::Action::Quit) + .filter_map(|(combo, action)| workspace_binding_in_context(&combo, &action, None)), + ); for context in ["Root", crate::gpui_shell::terminal::KEY_CONTEXT] { let contexts = [KeyContext::parse(context).unwrap()]; - for (combo, quit) in [("cmd-w", false), ("cmd-q", true)] { + for combo in ["cmd-w", "cmd-shift-w", "cmd-q"] { let input = [Keystroke::parse(combo).unwrap()]; let (bindings, pending) = keymap.bindings_for_input(&input, &contexts); assert!(!pending); let action = bindings.first().expect("命令键必须有明确动作").action(); - if quit { - assert!(action.as_any().is::()); - } else { - assert!(action.as_any().is::()); + match combo { + "cmd-q" => assert!(action.as_any().is::()), + "cmd-w" => assert!(action.as_any().is::()), + _ => assert!(action.as_any().is::()), } } } diff --git a/nebula_app/src/gpui_shell/workspace/macos_menu.rs b/nebula_app/src/gpui_shell/workspace/macos_menu.rs new file mode 100644 index 000000000..1ab7fedab --- /dev/null +++ b/nebula_app/src/gpui_shell/workspace/macos_menu.rs @@ -0,0 +1,55 @@ +use gpui::{App, Menu, MenuItem, OsAction, SystemMenuType}; + +use super::*; +use crate::i18n::Message; + +pub(super) fn init(cx: &mut App) { + let language = crate::gpui_shell::config::ui_language(cx); + let text = |message| language.text(message); + cx.set_menus([ + Menu::new("Pebrel").items([ + MenuItem::action(format!("{} Pebrel", text(Message::CommonAbout)), OpenAbout), + MenuItem::separator(), + MenuItem::action(text(Message::CommonSettings), OpenSettings), + MenuItem::separator(), + MenuItem::os_submenu(text(Message::CommonMenuServices), SystemMenuType::Services), + MenuItem::separator(), + MenuItem::action(text(Message::CommonHidePebrel), HideApplication), + MenuItem::action(text(Message::CommonHideOthers), HideOtherApplications), + MenuItem::separator(), + MenuItem::action(text(Message::CommonQuitPebrel), keyboard_bindings::QuitApp), + ]), + Menu::new(text(Message::CommonMenuFile)).items([ + MenuItem::action(text(Message::CommonNewWindow), NewWindow), + MenuItem::action(text(Message::CommonNewTab), NewTerminal), + MenuItem::separator(), + MenuItem::action(text(Message::CommonCloseWindow), CloseWindow), + ]), + Menu::new(text(Message::CommonMenuEdit)).items([ + MenuItem::os_action( + text(Message::CommonCut), + gpui_component::input::Cut, + OsAction::Cut, + ), + MenuItem::os_action( + text(Message::CommonCopy), + gpui_component::input::Copy, + OsAction::Copy, + ), + MenuItem::os_action( + text(Message::CommonPaste), + gpui_component::input::Paste, + OsAction::Paste, + ), + MenuItem::os_action( + text(Message::CommonSelectAll), + gpui_component::input::SelectAll, + OsAction::SelectAll, + ), + ]), + Menu::new(text(Message::CommonMenuView)) + .items([MenuItem::action(text(Message::EditorFullscreen), ToggleFullscreen)]), + Menu::new(text(Message::CommonMenuWindow)) + .items([MenuItem::action(text(Message::CommonMenuMinimize), MinimizeWindow)]), + ]); +} diff --git a/nebula_app/src/gpui_shell/workspace/render.rs b/nebula_app/src/gpui_shell/workspace/render.rs index c8c2a6654..21d14b316 100644 --- a/nebula_app/src/gpui_shell/workspace/render.rs +++ b/nebula_app/src/gpui_shell/workspace/render.rs @@ -159,6 +159,20 @@ impl Render for NebulaWorkspace { } }); })) + .on_action(cx.listener(|this, _: &OpenAbout, window, cx| { + this.open_settings(window, cx); + if let Some((view, _)) = &this.settings_surface { + view.update(cx, |pane, cx| pane.show_application_home(window, cx)); + } + })) + .on_action(cx.listener(|this, _: &CloseWindow, window, cx| { + if this.should_close_window(window, cx) { + window.remove_window(); + } + })) + .on_action(cx.listener(|_, _: &HideApplication, _, cx| cx.hide())) + .on_action(cx.listener(|_, _: &HideOtherApplications, _, cx| cx.hide_other_apps())) + .on_action(cx.listener(|_, _: &MinimizeWindow, window, _| window.minimize_window())) .on_action(cx.listener(|this, _: &CloseActiveTerminal, window, cx| { this.close_active(window, cx); })) @@ -251,6 +265,12 @@ impl Render for NebulaWorkspace { cx.propagate(); } })) + .on_action(cx.listener(|this, _: &gpui_component::input::Copy, window, cx| { + this.copy_focused_terminal(window, cx); + })) + .on_action(cx.listener(|this, _: &gpui_component::input::Paste, window, cx| { + this.paste_focused_terminal(window, cx); + })) .on_action(cx.listener(|this, _: &PasteClipboard, window, cx| { this.paste_focused_terminal(window, cx); })) diff --git a/nebula_app/src/platform/capabilities.rs b/nebula_app/src/platform/capabilities.rs index f71861958..d2e7502fd 100644 --- a/nebula_app/src/platform/capabilities.rs +++ b/nebula_app/src/platform/capabilities.rs @@ -18,6 +18,8 @@ pub struct Capabilities { pub launch_at_login: bool, /// 系统通知后端已实现;实际投递仍受系统通知权限控制。 pub system_notifications: bool, + /// Native GPUI application menu is installed on macOS. + pub native_application_menu: bool, /// 系统提示音(`platform::beep`)。 pub system_bell: bool, /// Verified Windows installer or macOS bundle replacement is implemented. @@ -44,6 +46,7 @@ pub const CAPABILITIES: Capabilities = { system_tray: true, launch_at_login: true, system_notifications: true, + native_application_menu: false, system_bell: true, self_update_install: true, ai_hook_server: true, @@ -61,6 +64,7 @@ pub const CAPABILITIES: Capabilities = { system_tray: true, launch_at_login: true, system_notifications: true, + native_application_menu: cfg!(target_os = "macos"), system_bell: true, self_update_install: cfg!(target_os = "macos"), ai_hook_server: true, @@ -93,6 +97,7 @@ mod tests { CAPABILITIES.shell_context_menu, CAPABILITIES.native_shell_clipboard, ]; + assert_eq!(CAPABILITIES.native_application_menu, cfg!(target_os = "macos")); if cfg!(windows) { assert!(all.iter().all(|flag| *flag)); } else {