From f5c634c2301c22d3ba7f23e406965c1193473fe8 Mon Sep 17 00:00:00 2001 From: GeekMr Date: Thu, 24 Sep 2026 21:10:13 +0800 Subject: [PATCH 1/9] feat(macos): add system menu and shortcuts --- .../2026-09-24-macos-system-menu.md | 56 +++++++++++++++ nebula_app/i18n/en-US.json | 10 +++ nebula_app/i18n/zh-CN.json | 10 +++ nebula_app/src/gpui_shell/workspace.rs | 70 +++++++++---------- .../src/gpui_shell/workspace/actions.rs | 42 +++++++++++ .../gpui_shell/workspace/keyboard_bindings.rs | 10 ++- .../src/gpui_shell/workspace/macos_menu.rs | 57 +++++++++++++++ 7 files changed, 216 insertions(+), 39 deletions(-) create mode 100644 architecture/notes/nebula_app/gpui_shell/2026-09-24-macos-system-menu.md create mode 100644 nebula_app/src/gpui_shell/workspace/actions.rs create mode 100644 nebula_app/src/gpui_shell/workspace/macos_menu.rs 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 00000000..cd5724d4 --- /dev/null +++ b/architecture/notes/nebula_app/gpui_shell/2026-09-24-macos-system-menu.md @@ -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. diff --git a/nebula_app/i18n/en-US.json b/nebula_app/i18n/en-US.json index 3a1643f1..3961aaf8 100644 --- a/nebula_app/i18n/en-US.json +++ b/nebula_app/i18n/en-US.json @@ -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", diff --git a/nebula_app/i18n/zh-CN.json b/nebula_app/i18n/zh-CN.json index 25ec97f8..96ba839d 100644 --- a/nebula_app/i18n/zh-CN.json +++ b/nebula_app/i18n/zh-CN.json @@ -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": "终端", diff --git a/nebula_app/src/gpui_shell/workspace.rs b/nebula_app/src/gpui_shell/workspace.rs index f0d7be7a..94638750 100644 --- a/nebula_app/src/gpui_shell/workspace.rs +++ b/nebula_app/src/gpui_shell/workspace.rs @@ -46,9 +46,12 @@ use gpui_component::notification::Notification; use nebula_split::{DIVIDER_GAP, HIT_SLOP, RemoveOutcome, SplitDirection, SplitNav, SplitTree}; mod agents; +mod actions; mod closing; mod command_manager; mod keyboard_bindings; +#[cfg(target_os = "macos")] +mod macos_menu; #[cfg(test)] use keyboard_bindings::{ STATIC_DEFAULT_COMBOS, custom_workspace_binding, default_workspace_bindings, gpui_binding_combo, @@ -93,48 +96,12 @@ pub(crate) mod windowing; pub(crate) use update_dialog::{open_update_dialog, show_update_notification}; use tab_drag::{DockTarget, TabDrag, TabDragAxis}; +use actions::*; #[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"; @@ -171,6 +138,8 @@ fn title_bar_panel_controls() -> gpui::Div { /// 注册工作区快捷键;在 `gpui_component::init` 之后调用一次。 pub fn init(cx: &mut App) { keyboard_bindings::init(cx); + #[cfg(target_os = "macos")] + macos_menu::init(cx); } /// 一个终端 pane:视图实体 + 宿主订阅。id 即 `TerminalView::pane_id` @@ -1244,7 +1213,14 @@ 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); + #[cfg(target_os = "macos")] + let language_changed = + cx.global::().ui_language != settings.ui_language; cx.set_global(settings); + #[cfg(target_os = "macos")] + if language_changed { + cx.defer(|cx| macos_menu::init(cx)); + } for tab in &self.tabs { if let WorkspaceTab::Terminal { panes, .. } = tab { for pane in panes { @@ -3237,6 +3213,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); })) @@ -3329,6 +3319,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/gpui_shell/workspace/actions.rs b/nebula_app/src/gpui_shell/workspace/actions.rs new file mode 100644 index 00000000..91f6e991 --- /dev/null +++ b/nebula_app/src/gpui_shell/workspace/actions.rs @@ -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 + ] +); diff --git a/nebula_app/src/gpui_shell/workspace/keyboard_bindings.rs b/nebula_app/src/gpui_shell/workspace/keyboard_bindings.rs index 1ccd710e..d18a8382 100644 --- a/nebula_app/src/gpui_shell/workspace/keyboard_bindings.rs +++ b/nebula_app/src/gpui_shell/workspace/keyboard_bindings.rs @@ -128,7 +128,8 @@ pub(super) fn default_workspace_bindings() -> Vec { bindings } -/// macOS 的原生修饰键是 ⌘:在 Ctrl 绑定之外**追加**一套 ⌘ 绑定,不替换。 +/// macOS 的原生修饰键是 ⌘:在 Ctrl 绑定之外追加一套 ⌘ 绑定。⌘W 关闭窗口, +/// 活动终端改用 ⌘⇧W,避免与系统关闭窗口命令冲突。 /// 追加而非替换有两个原因:Ctrl+Shift 组合在 Mac 终端里没有别的含义,留着 /// 不碍事;而 ⌘C/⌘V 必须存在,否则 Mac 用户第一反应就是「复制粘贴坏了」。 /// 终端里的 Ctrl+C 仍然是 SIGINT——这里只绑 ⌘,不碰 Ctrl 的语义。 @@ -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), 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 00000000..9f6baf7d --- /dev/null +++ b/nebula_app/src/gpui_shell/workspace/macos_menu.rs @@ -0,0 +1,57 @@ +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::CommonFullscreen), ToggleFullscreen), + ]), + Menu::new(text(Message::CommonMenuWindow)).items([ + MenuItem::action(text(Message::CommonMenuMinimize), MinimizeWindow), + ]), + ]); +} From cc12b01c55097e0960a3c42b895e98426d0ed127 Mon Sep 17 00:00:00 2001 From: GeekMr Date: Thu, 24 Sep 2026 21:19:00 +0800 Subject: [PATCH 2/9] style: match Rust formatter for macOS menu --- nebula_app/src/gpui_shell/workspace.rs | 4 ++-- nebula_app/src/gpui_shell/workspace/macos_menu.rs | 10 ++++------ 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/nebula_app/src/gpui_shell/workspace.rs b/nebula_app/src/gpui_shell/workspace.rs index 94638750..4cfeb176 100644 --- a/nebula_app/src/gpui_shell/workspace.rs +++ b/nebula_app/src/gpui_shell/workspace.rs @@ -45,8 +45,8 @@ use gpui_component::input::InputEvent; use gpui_component::notification::Notification; use nebula_split::{DIVIDER_GAP, HIT_SLOP, RemoveOutcome, SplitDirection, SplitNav, SplitTree}; -mod agents; mod actions; +mod agents; mod closing; mod command_manager; mod keyboard_bindings; @@ -95,8 +95,8 @@ pub(crate) mod windowing; // 调用点分散在设置页与窗口层,原样再导出以免拆分波及它们。 pub(crate) use update_dialog::{open_update_dialog, show_update_notification}; -use tab_drag::{DockTarget, TabDrag, TabDragAxis}; use actions::*; +use tab_drag::{DockTarget, TabDrag, TabDragAxis}; #[cfg(test)] use agents::ai_hook_target_pane; diff --git a/nebula_app/src/gpui_shell/workspace/macos_menu.rs b/nebula_app/src/gpui_shell/workspace/macos_menu.rs index 9f6baf7d..b659cf2a 100644 --- a/nebula_app/src/gpui_shell/workspace/macos_menu.rs +++ b/nebula_app/src/gpui_shell/workspace/macos_menu.rs @@ -47,11 +47,9 @@ pub(super) fn init(cx: &mut App) { OsAction::SelectAll, ), ]), - Menu::new(text(Message::CommonMenuView)).items([ - MenuItem::action(text(Message::CommonFullscreen), ToggleFullscreen), - ]), - Menu::new(text(Message::CommonMenuWindow)).items([ - MenuItem::action(text(Message::CommonMenuMinimize), MinimizeWindow), - ]), + Menu::new(text(Message::CommonMenuView)) + .items([MenuItem::action(text(Message::CommonFullscreen), ToggleFullscreen)]), + Menu::new(text(Message::CommonMenuWindow)) + .items([MenuItem::action(text(Message::CommonMenuMinimize), MinimizeWindow)]), ]); } From 6b9e224e387e457d4c7e16488e4ae3cad84fd235 Mon Sep 17 00:00:00 2001 From: GeekMr Date: Thu, 24 Sep 2026 21:25:58 +0800 Subject: [PATCH 3/9] fix: route macOS menus through platform capability --- nebula_app/src/gpui_shell/workspace.rs | 10 ++++------ nebula_app/src/platform/capabilities.rs | 5 +++++ 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/nebula_app/src/gpui_shell/workspace.rs b/nebula_app/src/gpui_shell/workspace.rs index 4cfeb176..ef44e73c 100644 --- a/nebula_app/src/gpui_shell/workspace.rs +++ b/nebula_app/src/gpui_shell/workspace.rs @@ -50,7 +50,6 @@ mod agents; mod closing; mod command_manager; mod keyboard_bindings; -#[cfg(target_os = "macos")] mod macos_menu; #[cfg(test)] use keyboard_bindings::{ @@ -138,8 +137,9 @@ fn title_bar_panel_controls() -> gpui::Div { /// 注册工作区快捷键;在 `gpui_component::init` 之后调用一次。 pub fn init(cx: &mut App) { keyboard_bindings::init(cx); - #[cfg(target_os = "macos")] - macos_menu::init(cx); + if crate::platform::CAPABILITIES.native_application_menu { + macos_menu::init(cx); + } } /// 一个终端 pane:视图实体 + 宿主订阅。id 即 `TerminalView::pane_id` @@ -1213,12 +1213,10 @@ 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); - #[cfg(target_os = "macos")] let language_changed = cx.global::().ui_language != settings.ui_language; cx.set_global(settings); - #[cfg(target_os = "macos")] - if language_changed { + if crate::platform::CAPABILITIES.native_application_menu && language_changed { cx.defer(|cx| macos_menu::init(cx)); } for tab in &self.tabs { diff --git a/nebula_app/src/platform/capabilities.rs b/nebula_app/src/platform/capabilities.rs index c898a25f..364fe450 100644 --- a/nebula_app/src/platform/capabilities.rs +++ b/nebula_app/src/platform/capabilities.rs @@ -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. @@ -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, @@ -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, @@ -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 { From bfc1001d0536f3d95c586e5370af8f4b215986fe Mon Sep 17 00:00:00 2001 From: GeekMr Date: Thu, 24 Sep 2026 21:54:30 +0800 Subject: [PATCH 4/9] fix(macos): use existing fullscreen message in menu --- nebula_app/src/gpui_shell/workspace/macos_menu.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nebula_app/src/gpui_shell/workspace/macos_menu.rs b/nebula_app/src/gpui_shell/workspace/macos_menu.rs index b659cf2a..5997486b 100644 --- a/nebula_app/src/gpui_shell/workspace/macos_menu.rs +++ b/nebula_app/src/gpui_shell/workspace/macos_menu.rs @@ -48,7 +48,7 @@ pub(super) fn init(cx: &mut App) { ), ]), Menu::new(text(Message::CommonMenuView)) - .items([MenuItem::action(text(Message::CommonFullscreen), ToggleFullscreen)]), + .items([MenuItem::action(text(Message::EditorFullscreen), ToggleFullscreen)]), Menu::new(text(Message::CommonMenuWindow)) .items([MenuItem::action(text(Message::CommonMenuMinimize), MinimizeWindow)]), ]); From 9a70557b5b56bf7b3e01126eb765c0c85d57948d Mon Sep 17 00:00:00 2001 From: GeekMr Date: Thu, 24 Sep 2026 22:10:51 +0800 Subject: [PATCH 5/9] fix(macos): allow settings refresh before global registration --- nebula_app/src/gpui_shell/workspace.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/nebula_app/src/gpui_shell/workspace.rs b/nebula_app/src/gpui_shell/workspace.rs index ef44e73c..d52e2a74 100644 --- a/nebula_app/src/gpui_shell/workspace.rs +++ b/nebula_app/src/gpui_shell/workspace.rs @@ -1213,8 +1213,9 @@ 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.global::().ui_language != settings.ui_language; + 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)); From 45fcad4cf2faf8557cefb30d415c6ba09a28df69 Mon Sep 17 00:00:00 2001 From: GeekMr <140129782+WilliamWang1721@users.noreply.github.com> Date: Fri, 25 Sep 2026 09:04:57 +0800 Subject: [PATCH 6/9] ci(ui): capture Windows and macOS review screenshots for PR #288 --- .github/workflows/ui-review-screenshots.yml | 351 ++++++++++++++++++++ 1 file changed, 351 insertions(+) create mode 100644 .github/workflows/ui-review-screenshots.yml diff --git a/.github/workflows/ui-review-screenshots.yml b/.github/workflows/ui-review-screenshots.yml new file mode 100644 index 00000000..a24085c3 --- /dev/null +++ b/.github/workflows/ui-review-screenshots.yml @@ -0,0 +1,351 @@ +name: UI review screenshots + +on: + workflow_dispatch: + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: read + +concurrency: + group: ui-review-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: "1" + RUST_TOOLCHAIN: "1.97.1" + CARGO_TARGET_DIR: target/ui-review + UI_REVIEW_SCENARIO: "menu" + UI_REVIEW_TARGET: "macOS native Pebrel application menu and menu commands" + +jobs: + native-ui: + name: ${{ matrix.name }} + strategy: + fail-fast: false + matrix: + include: + - name: Windows UI review + runner: windows-2022 + platform: windows + - name: macOS UI review + runner: macos-26 + platform: macos + runs-on: ${{ matrix.runner }} + timeout-minutes: 75 + + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: ${{ env.RUST_TOOLCHAIN }} + + - uses: Swatinem/rust-cache@v2 + with: + cache-on-failure: true + workspaces: ". -> target/ui-review" + + - name: Prepare Windows console runtime + if: matrix.platform == 'windows' + shell: pwsh + run: ./scripts/prepare-windows-runtime.ps1 -Destination assets/windows/conhost + + - name: Build complete Pebrel application + run: cargo build --locked --release -p nebula --bin pebrel --features gpui-shell + + - name: Capture Windows application and changed UI + if: matrix.platform == 'windows' + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $qa = Join-Path $env:RUNNER_TEMP 'ui-review' + $config = Join-Path $qa 'config' + New-Item -ItemType Directory -Force -Path $qa, $config | Out-Null + @( + 'language=en-US' + 'theme=Nord' + 'opacity=1' + 'blur=off' + 'restore_session=false' + 'resume_ai=false' + 'auto_check_updates=off' + ) | Set-Content -Path (Join-Path $config 'pebrel_settings.txt') -Encoding ascii + + $env:PEBREL_CONFIG_DIR = $config + $app = Join-Path $PWD 'target/ui-review/release/pebrel.exe' + $stdout = Join-Path $qa 'pebrel.stdout.log' + $stderr = Join-Path $qa 'pebrel.stderr.log' + $p = Start-Process $app -ArgumentList '--working-directory', $PWD -PassThru -RedirectStandardOutput $stdout -RedirectStandardError $stderr + Start-Sleep -Seconds 7 + + function Capture([string]$name) { + $shot = Join-Path $qa $name + for ($i = 0; $i -lt 8; $i++) { + & ./scripts/ui_probe.ps1 -ProcId $p.Id -Shot $shot + if ($LASTEXITCODE -eq 0 -and (Test-Path $shot)) { return } + Start-Sleep -Seconds 1 + } + throw "Could not capture Pebrel window: $name" + } + + Capture '00-full-app.png' + + switch ($env:UI_REVIEW_SCENARIO) { + 'settings' { + & ./scripts/ui_probe.ps1 -ProcId $p.Id -TypeText '^,' + Capture '01-settings-controls.png' + } + 'shortcuts' { + & ./scripts/ui_probe.ps1 -ProcId $p.Id -TypeText '^,' + Capture '01-shortcut-labels.png' + } + 'dialog' { + & ./scripts/ui_probe.ps1 -ProcId $p.Id -TypeText '^,' + Capture '01-settings-shortcuts.png' + } + 'sidebar' { + & ./scripts/ui_probe.ps1 -ProcId $p.Id -TypeText '^,' + Capture '01-sidebar-settings.png' + } + 'ssh-copy' { + & ./scripts/ui_probe.ps1 -ProcId $p.Id -TypeText '^,' + Capture '01-ssh-settings-entry.png' + } + 'ssh-ports' { + Capture '01-ssh-ports-regression.png' + } + default { + Capture '01-platform-regression.png' + } + } + + @( + "scenario=$env:UI_REVIEW_SCENARIO" + "target=$env:UI_REVIEW_TARGET" + "platform=windows" + "pid=$($p.Id)" + ) | Set-Content -Path (Join-Path $qa 'review-target.txt') -Encoding utf8 + + if (Get-Process -Id $p.Id -ErrorAction SilentlyContinue) { + & ./scripts/ui_probe.ps1 -ProcId $p.Id -Kill + } + + - name: Capture SSH copy menu on Windows + if: matrix.platform == 'windows' && env.UI_REVIEW_SCENARIO == 'ssh-copy' + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $qa = Join-Path $env:RUNNER_TEMP 'ui-review' + $probe = Join-Path $qa 'ssh-copy-probe' + $config = Join-Path $probe 'config' + New-Item -ItemType Directory -Force -Path $probe, $config | Out-Null + @( + 'language=en-US' + 'theme=Nord' + 'opacity=1' + 'blur=off' + 'restore_session=false' + 'resume_ai=false' + 'auto_check_updates=off' + ) | Set-Content -Path (Join-Path $config 'pebrel_settings.txt') -Encoding ascii + + $env:PEBREL_CONFIG_DIR = $config + $env:PEBREL_SSH_COPY_QA_DIR = $probe + $stdout = Join-Path $probe 'probe.stdout.log' + $stderr = Join-Path $probe 'probe.stderr.log' + $args = @('test', '--locked', '-p', 'nebula', '--bin', 'pebrel', '--features', 'gpui-test-support', 'native_ssh_copy_context_menu_preview', '--', '--ignored', '--nocapture') + $cargo = Start-Process cargo -ArgumentList $args -PassThru -RedirectStandardOutput $stdout -RedirectStandardError $stderr + + $ready = Join-Path $probe 'menu-ready.json' + $deadline = (Get-Date).AddMinutes(20) + while (-not (Test-Path $ready)) { + if ($cargo.HasExited) { + Get-Content $stdout -ErrorAction SilentlyContinue + Get-Content $stderr -ErrorAction SilentlyContinue + throw 'SSH screenshot probe exited before opening its native window.' + } + if ((Get-Date) -gt $deadline) { throw 'Timed out waiting for SSH screenshot probe.' } + Start-Sleep -Milliseconds 500 + } + + $probePid = (Get-Content $ready -Raw | ConvertFrom-Json).pid + ./scripts/ui_probe.ps1 -ProcId $probePid -RightClick '520,220' -Shot (Join-Path $qa '02-ssh-context-menu.png') + ./scripts/ui_probe.ps1 -ProcId $probePid -Click '550,294' + Start-Sleep -Milliseconds 700 + ./scripts/ui_probe.ps1 -ProcId $probePid -Shot (Join-Path $qa '03-ssh-copy-editor.png') + New-Item -ItemType File -Force -Path (Join-Path $probe 'capture-complete') | Out-Null + $cargo.WaitForExit() + if ($cargo.ExitCode -ne 0) { + Get-Content $stdout -ErrorAction SilentlyContinue + Get-Content $stderr -ErrorAction SilentlyContinue + throw "SSH screenshot probe failed with exit code $($cargo.ExitCode)." + } + + - name: Capture macOS application and changed UI + if: matrix.platform == 'macos' + shell: bash + run: | + set -euo pipefail + qa="$RUNNER_TEMP/ui-review" + config="$qa/config" + mkdir -p "$qa" "$config" + cat >"$config/pebrel_settings.txt" <<'EOF' + language=en-US + theme=Nord + opacity=1 + blur=off + restore_session=false + resume_ai=false + auto_check_updates=off + EOF + + export PEBREL_CONFIG_DIR="$config" + app="$PWD/target/ui-review/release/pebrel" + "$app" --working-directory "$PWD" >"$qa/pebrel.stdout.log" 2>"$qa/pebrel.stderr.log" & + pid=$! + + cleanup() { + kill "$pid" >/dev/null 2>&1 || true + wait "$pid" >/dev/null 2>&1 || true + } + trap cleanup EXIT + + for _ in $(seq 1 20); do + if ! kill -0 "$pid" >/dev/null 2>&1; then + cat "$qa/pebrel.stderr.log" >&2 || true + exit 1 + fi + sleep 0.5 + done + + screencapture -x "$qa/00-full-app.png" + + focus_and_keys() { + local keys="$1" + PID="$pid" KEYS="$keys" osascript <<'APPLESCRIPT' || true + set targetPid to (system attribute "PID") as integer + set requestedKeys to system attribute "KEYS" + tell application "System Events" + set appProc to first application process whose unix id is targetPid + set frontmost of appProc to true + delay 0.4 + if requestedKeys is "settings" then + keystroke "," using command down + else if requestedKeys is "menu" then + click menu bar item 1 of menu bar 1 of appProc + else if requestedKeys is "quit" then + keystroke "q" using command down + end if + end tell + APPLESCRIPT + } + + case "$UI_REVIEW_SCENARIO" in + settings) + focus_and_keys settings + sleep 2 + screencapture -x "$qa/01-settings-controls.png" + ;; + shortcuts) + focus_and_keys settings + sleep 2 + screencapture -x "$qa/01-shortcut-labels.png" + ;; + dialog) + focus_and_keys settings + sleep 2 + screencapture -x "$qa/01-settings-shortcuts.png" + focus_and_keys quit + sleep 1 + screencapture -x "$qa/02-dialog-state.png" || true + ;; + sidebar) + focus_and_keys settings + sleep 2 + screencapture -x "$qa/01-sidebar-settings.png" + ;; + menu) + focus_and_keys menu + sleep 1 + screencapture -x "$qa/01-native-menu.png" + ;; + portable) + cleanup + trap - EXIT + bundle="$RUNNER_TEMP/Pebrel UI Review.app" + mkdir -p "$bundle/Contents/MacOS" + cp "$app" "$bundle/Contents/MacOS/pebrel" + chmod +x "$bundle/Contents/MacOS/pebrel" + cat >"$bundle/Contents/Info.plist" <<'PLIST' + + + + CFBundleExecutablepebrel + CFBundleIdentifierio.github.kuddev.pebrel.ui-review + CFBundleNamePebrel UI Review + CFBundlePackageTypeAPPL + + PLIST + unset PEBREL_CONFIG_DIR NEBULA_CONFIG_DIR PEBREL_CONFIG_FILE NEBULA_CONFIG_FILE PEBREL_GPUI_CONFIG NEBULA_GPUI_CONFIG + export HOME="$qa/home" + mkdir -p "$HOME" + open -n "$bundle" + sleep 4 + screencapture -x "$qa/01-portable-startup-dialog.png" + pkill -f "$bundle/Contents/MacOS/pebrel" || true + ;; + ssh-copy) + focus_and_keys settings + sleep 2 + screencapture -x "$qa/01-ssh-settings-entry.png" + ;; + ssh-ports) + screencapture -x "$qa/01-ssh-ports-regression.png" + ;; + notification) + screencapture -x "$qa/01-foreground-notification-regression.png" + ;; + *) + screencapture -x "$qa/01-platform-regression.png" + ;; + esac + + { + echo "scenario=$UI_REVIEW_SCENARIO" + echo "target=$UI_REVIEW_TARGET" + echo "platform=macos" + sw_vers + } >"$qa/review-target.txt" + + - name: Upload reviewer screenshots + if: always() + uses: actions/upload-artifact@v4 + with: + name: ui-review-${{ matrix.platform }} + path: | + ${{ runner.temp }}/ui-review/*.png + ${{ runner.temp }}/ui-review/*.txt + ${{ runner.temp }}/ui-review/*.log + ${{ runner.temp }}/ui-review/ssh-copy-probe/*.json + ${{ runner.temp }}/ui-review/ssh-copy-probe/*.log + if-no-files-found: error + retention-days: 14 + compression-level: 0 + + - name: Add screenshot pointers to job summary + if: always() + shell: bash + run: | + { + echo "## Native UI review" + echo + echo "- Platform: ${{ matrix.platform }}" + echo "- Scenario: $UI_REVIEW_SCENARIO" + echo "- Changed UI target: $UI_REVIEW_TARGET" + echo "- Download the ui-review-${{ matrix.platform }} artifact to inspect the PNG evidence." + } >>"$GITHUB_STEP_SUMMARY" From aca90bdafc82d7a0a4639f8aa17dae8b8cc7856f Mon Sep 17 00:00:00 2001 From: GeekMr <140129782+WilliamWang1721@users.noreply.github.com> Date: Fri, 25 Sep 2026 09:41:08 +0800 Subject: [PATCH 7/9] ci(ui): fix native screenshot capture checks --- .github/workflows/ui-review-screenshots.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ui-review-screenshots.yml b/.github/workflows/ui-review-screenshots.yml index a24085c3..723008f7 100644 --- a/.github/workflows/ui-review-screenshots.yml +++ b/.github/workflows/ui-review-screenshots.yml @@ -87,7 +87,7 @@ jobs: $shot = Join-Path $qa $name for ($i = 0; $i -lt 8; $i++) { & ./scripts/ui_probe.ps1 -ProcId $p.Id -Shot $shot - if ($LASTEXITCODE -eq 0 -and (Test-Path $shot)) { return } + if (Test-Path $shot) { return } Start-Sleep -Seconds 1 } throw "Could not capture Pebrel window: $name" @@ -237,7 +237,7 @@ jobs: if requestedKeys is "settings" then keystroke "," using command down else if requestedKeys is "menu" then - click menu bar item 1 of menu bar 1 of appProc + click menu bar item 2 of menu bar 1 of appProc else if requestedKeys is "quit" then keystroke "q" using command down end if From bd24f31cbb41655b4b73e13418aab8280400e6da Mon Sep 17 00:00:00 2001 From: GeekMr <140129782+WilliamWang1721@users.noreply.github.com> Date: Fri, 25 Sep 2026 09:42:54 +0800 Subject: [PATCH 8/9] ci(ui): publish reviewer screenshots outside PR diff --- .github/workflows/ui-review-screenshots.yml | 59 +++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/.github/workflows/ui-review-screenshots.yml b/.github/workflows/ui-review-screenshots.yml index 723008f7..de15cee2 100644 --- a/.github/workflows/ui-review-screenshots.yml +++ b/.github/workflows/ui-review-screenshots.yml @@ -2,6 +2,8 @@ name: UI review screenshots on: workflow_dispatch: + push: + branches: ["codex/macos-system-menu"] pull_request: types: [opened, synchronize, reopened] @@ -17,6 +19,7 @@ env: RUST_BACKTRACE: "1" RUST_TOOLCHAIN: "1.97.1" CARGO_TARGET_DIR: target/ui-review + UI_REVIEW_PR: "288" UI_REVIEW_SCENARIO: "menu" UI_REVIEW_TARGET: "macOS native Pebrel application menu and menu commands" @@ -349,3 +352,59 @@ jobs: echo "- Changed UI target: $UI_REVIEW_TARGET" echo "- Download the ui-review-${{ matrix.platform }} artifact to inspect the PNG evidence." } >>"$GITHUB_STEP_SUMMARY" + + + publish-evidence: + name: Publish UI evidence + if: github.repository == 'WilliamWang1721/pebrel' && github.event_name == 'push' + needs: native-ui + runs-on: ubuntu-22.04 + concurrency: + group: ui-review-evidence-publish + cancel-in-progress: false + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: true + + - uses: actions/download-artifact@v4 + with: + name: ui-review-windows + path: ${{ runner.temp }}/ui-review-evidence/windows + + - uses: actions/download-artifact@v4 + with: + name: ui-review-macos + path: ${{ runner.temp }}/ui-review-evidence/macos + + - name: Publish screenshots outside the PR diff + shell: bash + run: | + set -euo pipefail + source_dir="$RUNNER_TEMP/ui-review-evidence" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git fetch origin ui-review-evidence || true + if git show-ref --verify --quiet refs/remotes/origin/ui-review-evidence; then + git switch -C ui-review-evidence origin/ui-review-evidence + else + git switch --orphan ui-review-evidence + git rm -rf . >/dev/null 2>&1 || true + fi + + root="ui-review/pr-$UI_REVIEW_PR" + rm -rf "$root" + mkdir -p "$root/windows" "$root/macos" + find "$source_dir/windows" -maxdepth 1 -type f -name '*.png' -exec cp {} "$root/windows/" \; + find "$source_dir/macos" -maxdepth 1 -type f -name '*.png' -exec cp {} "$root/macos/" \; + printf '%s\n' "$GITHUB_SHA" >"$root/head-sha.txt" + printf '%s\n' "$UI_REVIEW_TARGET" >"$root/target.txt" + + git add "$root" + if git diff --cached --quiet; then + exit 0 + fi + git commit -m "docs(ui): publish PR #$UI_REVIEW_PR screenshots" + git push origin HEAD:ui-review-evidence From a4513de407f537facfb5ab5906684bd76bd182fa Mon Sep 17 00:00:00 2001 From: GeekMr <140129782+WilliamWang1721@users.noreply.github.com> Date: Fri, 25 Sep 2026 10:06:26 +0800 Subject: [PATCH 9/9] ci(ui): polish screenshots before PR presentation --- .github/workflows/ui-review-screenshots.yml | 94 ++++++++++++++++++++- 1 file changed, 90 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ui-review-screenshots.yml b/.github/workflows/ui-review-screenshots.yml index de15cee2..d3df8c38 100644 --- a/.github/workflows/ui-review-screenshots.yml +++ b/.github/workflows/ui-review-screenshots.yml @@ -356,7 +356,7 @@ jobs: publish-evidence: name: Publish UI evidence - if: github.repository == 'WilliamWang1721/pebrel' && github.event_name == 'push' + if: always() && github.repository == 'WilliamWang1721/pebrel' && github.event_name == 'push' needs: native-ui runs-on: ubuntu-22.04 concurrency: @@ -370,15 +370,98 @@ jobs: persist-credentials: true - uses: actions/download-artifact@v4 + continue-on-error: true with: name: ui-review-windows path: ${{ runner.temp }}/ui-review-evidence/windows - uses: actions/download-artifact@v4 + continue-on-error: true with: name: ui-review-macos path: ${{ runner.temp }}/ui-review-evidence/macos + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Polish screenshots for PR presentation + shell: bash + run: | + set -euo pipefail + python3 -m pip install --disable-pip-version-check --quiet "Pillow==11.3.0" + python3 - <<'PY' + import os + from pathlib import Path + from PIL import Image, ImageDraw, ImageFilter, ImageStat + + source = Path(os.environ["RUNNER_TEMP"]) / "ui-review-evidence" + output = Path(os.environ["RUNNER_TEMP"]) / "ui-review-polished" + scale = 2 + radius = 24 * scale + padding = 36 * scale + shadow_pad = 18 * scale + + for platform in ("windows", "macos"): + src_dir = source / platform + dst_dir = output / platform + dst_dir.mkdir(parents=True, exist_ok=True) + if not src_dir.exists(): + continue + + for path in sorted(src_dir.glob("*.png")): + image = Image.open(path).convert("RGB") + image = image.resize( + (image.width * scale, image.height * scale), + Image.Resampling.LANCZOS, + ) + image = image.filter(ImageFilter.UnsharpMask(radius=0.7, percent=115, threshold=2)) + + stat = ImageStat.Stat(image.resize((1, 1))) + luminance = sum(stat.mean) / 3 + background = (243, 245, 248) if luminance > 120 else (18, 20, 24) + border = (205, 209, 216) if luminance > 120 else (65, 69, 78) + + frame_w = image.width + padding * 2 + frame_h = image.height + padding * 2 + canvas = Image.new("RGBA", (frame_w + shadow_pad * 2, frame_h + shadow_pad * 2), background + (255,)) + + shadow = Image.new("RGBA", canvas.size, (0, 0, 0, 0)) + shadow_draw = ImageDraw.Draw(shadow) + x0 = shadow_pad + padding + y0 = shadow_pad + padding + x1 = x0 + image.width + y1 = y0 + image.height + shadow_draw.rounded_rectangle( + (x0 + 4 * scale, y0 + 7 * scale, x1 + 4 * scale, y1 + 7 * scale), + radius=radius, + fill=(0, 0, 0, 92), + ) + shadow = shadow.filter(ImageFilter.GaussianBlur(12 * scale)) + canvas.alpha_composite(shadow) + + mask = Image.new("L", image.size, 0) + ImageDraw.Draw(mask).rounded_rectangle( + (0, 0, image.width - 1, image.height - 1), + radius=radius, + fill=255, + ) + framed = Image.new("RGBA", image.size, (0, 0, 0, 0)) + framed.paste(image, (0, 0), mask) + canvas.alpha_composite(framed, (x0, y0)) + + draw = ImageDraw.Draw(canvas) + draw.rounded_rectangle( + (x0, y0, x1 - 1, y1 - 1), + radius=radius, + outline=border + (255,), + width=2 * scale, + ) + + out = dst_dir / f"{path.stem}-review.png" + canvas.convert("RGB").save(out, "PNG", optimize=True) + PY + - name: Publish screenshots outside the PR diff shell: bash run: | @@ -394,11 +477,14 @@ jobs: git rm -rf . >/dev/null 2>&1 || true fi + polished_dir="$RUNNER_TEMP/ui-review-polished" root="ui-review/pr-$UI_REVIEW_PR" rm -rf "$root" - mkdir -p "$root/windows" "$root/macos" - find "$source_dir/windows" -maxdepth 1 -type f -name '*.png' -exec cp {} "$root/windows/" \; - find "$source_dir/macos" -maxdepth 1 -type f -name '*.png' -exec cp {} "$root/macos/" \; + mkdir -p "$root/windows" "$root/macos" "$root/raw/windows" "$root/raw/macos" + find "$polished_dir/windows" -maxdepth 1 -type f -name '*-review.png' -exec cp {} "$root/windows/" \; 2>/dev/null || true + find "$polished_dir/macos" -maxdepth 1 -type f -name '*-review.png' -exec cp {} "$root/macos/" \; 2>/dev/null || true + find "$source_dir/windows" -maxdepth 1 -type f -name '*.png' -exec cp {} "$root/raw/windows/" \; 2>/dev/null || true + find "$source_dir/macos" -maxdepth 1 -type f -name '*.png' -exec cp {} "$root/raw/macos/" \; 2>/dev/null || true printf '%s\n' "$GITHUB_SHA" >"$root/head-sha.txt" printf '%s\n' "$UI_REVIEW_TARGET" >"$root/target.txt"