Skip to content
Draft
496 changes: 496 additions & 0 deletions .github/workflows/ui-review-screenshots.yml

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# 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.

## 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

English and Simplified Chinese catalog IDs were compared and are aligned; both
catalogs parse as JSON; `git diff --check` passes. `cargo check` could not run
because `cargo` is unavailable in the current environment. Native macOS menu
behavior has not been exercised in a running app.

## Supersedes

None.

## Revisit when

The GPUI menu API or workspace shutdown and close flows change.
10 changes: 10 additions & 0 deletions nebula_app/i18n/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,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",
Expand Down
10 changes: 10 additions & 0 deletions nebula_app/i18n/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,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": "终端",
Expand Down
69 changes: 32 additions & 37 deletions nebula_app/src/gpui_shell/workspace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
#[cfg(test)]
use keyboard_bindings::{
STATIC_DEFAULT_COMBOS, custom_workspace_binding, default_workspace_bindings, gpui_binding_combo,
Expand Down Expand Up @@ -92,49 +94,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";
Expand Down Expand Up @@ -171,6 +137,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`
Expand Down Expand Up @@ -1244,7 +1213,13 @@ impl NebulaWorkspace {
/// follow_system 折算)、逐终端刷新、重建 chrome 令牌。
fn apply_runtime_settings(&mut self, cx: &mut Context<Self>) {
let (runtime, settings) = crate::gpui_shell::config::Settings::load_current_snapshot(cx);
let language_changed = cx
.try_global::<crate::gpui_shell::config::Settings>()
.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 {
Expand Down Expand Up @@ -3237,6 +3212,20 @@ impl Render for NebulaWorkspace {
}
});
}))
.on_action(cx.listener(|this, _: &OpenAbout, window, cx| {
this.open_settings(window, cx);
}))
.on_action(cx.listener(|this, _: &CloseWindow, window, cx| {
if this.should_close_window(window, cx) {
window.remove_window();
}
}))
.on_action(cx.listener(|_, _: &QuitApplication, _, cx| {
windowing::quit_all(cx);
}))
.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);
}))
Expand Down Expand Up @@ -3329,6 +3318,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);
}))
Expand Down
42 changes: 42 additions & 0 deletions nebula_app/src/gpui_shell/workspace/actions.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
gpui::actions!(
nebula_workspace,
[
NewTerminal,
NewWindow,
CloseActiveTerminal,
OpenAbout,
CloseWindow,
QuitApplication,
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
]
);
10 changes: 8 additions & 2 deletions nebula_app/src/gpui_shell/workspace/keyboard_bindings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,8 @@ pub(super) fn default_workspace_bindings() -> Vec<KeyBinding> {
bindings
}

/// macOS 的原生修饰键是 ⌘:在 Ctrl 绑定之外**追加**一套 ⌘ 绑定,不替换。
/// macOS 的原生修饰键是 ⌘:在 Ctrl 绑定之外追加一套 ⌘ 绑定。⌘W 关闭窗口,
/// 活动终端改用 ⌘⇧W,避免与系统关闭窗口命令冲突。
/// 追加而非替换有两个原因:Ctrl+Shift 组合在 Mac 终端里没有别的含义,留着
/// 不碍事;而 ⌘C/⌘V 必须存在,否则 Mac 用户第一反应就是「复制粘贴坏了」。
/// 终端里的 Ctrl+C 仍然是 SIGINT——这里只绑 ⌘,不碰 Ctrl 的语义。
Expand All @@ -137,7 +138,12 @@ fn bind_macos_command_keys(cx: &mut App) {
cx.bind_keys([
KeyBinding::new("cmd-t", NewTerminal, None),
KeyBinding::new("cmd-n", NewWindow, None),
KeyBinding::new("cmd-w", CloseActiveTerminal, None),
KeyBinding::new("cmd-q", QuitApplication, None),
KeyBinding::new("cmd-w", CloseWindow, None),
KeyBinding::new("cmd-shift-w", CloseActiveTerminal, 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),
KeyBinding::new("cmd-shift-p", ToggleCommandPalette, None),
Expand Down
55 changes: 55 additions & 0 deletions nebula_app/src/gpui_shell/workspace/macos_menu.rs
Original file line number Diff line number Diff line change
@@ -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 = super::workspace_ui_language();
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), QuitApplication),
]),
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)]),
]);
}
5 changes: 5 additions & 0 deletions nebula_app/src/platform/capabilities.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,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.
Expand All @@ -43,6 +45,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,
Expand All @@ -59,6 +62,7 @@ pub const CAPABILITIES: Capabilities = {
system_tray: false,
launch_at_login: false,
system_notifications: true,
native_application_menu: cfg!(target_os = "macos"),
system_bell: false,
self_update_install: cfg!(target_os = "macos"),
ai_hook_server: false,
Expand Down Expand Up @@ -89,6 +93,7 @@ mod tests {
CAPABILITIES.credential_store,
CAPABILITIES.shell_context_menu,
];
assert_eq!(CAPABILITIES.native_application_menu, cfg!(target_os = "macos"));
if cfg!(windows) {
assert!(all.iter().all(|flag| *flag));
} else {
Expand Down
Loading