From c18252cca5c5765db61a8fd800edf5075ccce673 Mon Sep 17 00:00:00 2001 From: kuddev Date: Sun, 13 Sep 2026 21:59:08 +0800 Subject: [PATCH] fix(gpui): preserve settings tabs and improve terminal controls Keep the top Settings tab available until explicitly closed and route numbered tab shortcuts through workspace actions before terminal input. Preserve custom shortcut overrides and restore focus when reselecting the current tab. Add opt-in mouse focus, configurable inactive-pane dimming, Shell and SSH launcher context menus, and administrator Shell launch inside a new Pebrel window with isolated control-plane discovery and session persistence. Refresh Claude artwork and add Trae CLI and Oh My Pi branding. Document the user-visible behavior in the bilingual Unreleased changelog. Validation: 337 GPUI tests, 109 platform/runtime/Agent tests, 47 settings tests, 14 i18n contract tests, product compilation, and architecture checks passed. Interactive UAC and visual acceptance remain manual. --- CHANGELOG.md | 26 ++ docs/architecture-decisions.md | 37 ++ extra/logo/LICENSE-lobe-icons | 21 + extra/logo/README.md | 36 ++ extra/logo/ai_claude.png | Bin 3851 -> 46460 bytes extra/logo/ai_claude.svg | 1 + extra/logo/ai_omp.png | Bin 0 -> 87134 bytes extra/logo/ai_omp.svg | 11 + extra/logo/ai_trae.png | Bin 0 -> 11959 bytes extra/logo/ai_trae.svg | 1 + nebula_app/i18n/en-US.json | 23 +- nebula_app/i18n/zh-CN.json | 23 +- nebula_app/src/agent_env.rs | 3 + nebula_app/src/ai_agents.rs | 23 +- nebula_app/src/display/mod.rs | 9 +- nebula_app/src/display/program_identity.rs | 56 ++- nebula_app/src/gpui_shell/config.rs | 14 + nebula_app/src/gpui_shell/mod.rs | 21 +- nebula_app/src/gpui_shell/settings_pane.rs | 25 +- .../settings_pane/appearance_advanced.rs | 7 + .../settings_pane/launcher_actions.rs | 38 ++ .../gpui_shell/settings_pane/navigation.rs | 4 +- .../src/gpui_shell/ssh_settings/library.rs | 12 + .../src/gpui_shell/terminal/view/pointer.rs | 19 +- .../gpui_shell/terminal/view/pointer/tests.rs | 153 ++++++++ nebula_app/src/gpui_shell/workspace.rs | 250 ++---------- .../src/gpui_shell/workspace/key_actions.rs | 36 +- .../gpui_shell/workspace/keyboard_bindings.rs | 233 ++++++++++-- .../workspace/keyboard_bindings/tests.rs | 301 +++++++++++++++ .../src/gpui_shell/workspace/launcher_menu.rs | 358 ++++++++++++++++++ .../workspace/launcher_menu/tests.rs | 145 +++++++ .../src/gpui_shell/workspace/palette.rs | 20 + .../src/gpui_shell/workspace/residency.rs | 4 + .../workspace/session_persistence.rs | 34 +- .../workspace/settings_navigation.rs | 70 +++- .../workspace/settings_navigation/ui_tests.rs | 162 ++++++++ .../src/gpui_shell/workspace/top_tabs.rs | 9 +- .../src/gpui_shell/workspace/windowing.rs | 77 +++- nebula_app/src/main.rs | 5 +- nebula_app/src/platform/elevation.rs | 153 ++++++++ nebula_app/src/platform/elevation/tests.rs | 65 ++++ nebula_app/src/platform/mod.rs | 1 + nebula_app/src/runtime_api.rs | 1 + nebula_app/src/runtime_api/server.rs | 143 ++++++- nebula_settings/src/lib.rs | 33 ++ nebula_settings/src/reset.rs | 12 + 46 files changed, 2373 insertions(+), 302 deletions(-) create mode 100644 extra/logo/LICENSE-lobe-icons create mode 100644 extra/logo/ai_claude.svg create mode 100644 extra/logo/ai_omp.png create mode 100644 extra/logo/ai_omp.svg create mode 100644 extra/logo/ai_trae.png create mode 100644 extra/logo/ai_trae.svg create mode 100644 nebula_app/src/gpui_shell/settings_pane/launcher_actions.rs create mode 100644 nebula_app/src/gpui_shell/terminal/view/pointer/tests.rs create mode 100644 nebula_app/src/gpui_shell/workspace/keyboard_bindings/tests.rs create mode 100644 nebula_app/src/gpui_shell/workspace/launcher_menu.rs create mode 100644 nebula_app/src/gpui_shell/workspace/launcher_menu/tests.rs create mode 100644 nebula_app/src/gpui_shell/workspace/settings_navigation/ui_tests.rs create mode 100644 nebula_app/src/platform/elevation.rs create mode 100644 nebula_app/src/platform/elevation/tests.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 286e998..a7cad15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,15 +8,41 @@ Every release entry is provided in English and Simplified Chinese. ### English +#### Added + +- Added “Focus follows mouse” in Settings → Interaction, off by default, and “Dim inactive panes” in Settings → Appearance, on by default. Both preferences take effect immediately. +- Added right-click actions to the Shell launcher for choosing the default Shell and, on Windows, opening the selected Shell inside a new administrator Pebrel window. +- Added Connect, Edit, and Delete to SSH launcher context menus. Delete uses the existing confirmation and undo flow. +- Added Trae CLI recognition and its tab icon, plus a dedicated Oh My Pi icon. + #### Fixed - Fixed Windows terminal focus consuming `Alt+F4` and `Alt+Space`. `Alt+F4` now uses the normal window-close flow, and `Alt+Space`, followed by `N`, uses the system menu to minimize the window. +- Fixed the top Settings tab disappearing when switching to another tab. It now remains available until explicitly closed, including when tabs overflow or the tab layout changes. +- Fixed `Alt+1–9` and `Ctrl+1–9` tab shortcuts losing priority to terminal input. They now work while a terminal or CLI has focus and respect custom shortcut changes without restarting. + +#### Improved + +- Replaced the low-resolution Claude Code icon with artwork exported from SVG at 1024 pixels, and prepared Agent icons at their display size for smoother tab edges. ### 中文 +#### 新增 + +- 在“设置 → 交互”中加入“焦点跟随鼠标”,默认关闭;在“设置 → 外观”中加入“调暗非活动窗格”,默认开启。两项设置均即时生效。 +- Shell 启动菜单新增右键操作,可设为默认 Shell;Windows 下还可在新的管理员 Pebrel 窗口内打开所选 Shell。 +- SSH 启动菜单新增“连接、编辑、删除”右键操作;删除沿用已有的确认与撤销流程。 +- 新增 Trae CLI 识别与标签图标,并为 Oh My Pi 加入独立图标。 + #### 修复 - 修复 Windows 终端获得焦点时吞掉 `Alt+F4` 和 `Alt+Space` 的问题。`Alt+F4` 现在进入正常窗口关闭流程,`Alt+Space` 后按 `N` 可通过系统菜单最小化窗口。 +- 修复顶部设置标签在切换到其他标签后消失的问题。现在只有主动关闭才会移除,标签溢出或切换标签布局时也会保留。 +- 修复 `Alt+1–9` 和 `Ctrl+1–9` 标签快捷键优先级低于终端输入的问题。终端或 CLI 获得焦点时仍可切换标签,自定义快捷键的修改也无需重启即可生效。 + +#### 改进 + +- 将 Claude Code 的低分辨率图标替换为从 SVG 导出的 1024 像素图像,并按显示尺寸处理 Agent 图标,使标签图标边缘更平滑。 ## 1.7.0 - 2026-09-12 diff --git a/docs/architecture-decisions.md b/docs/architecture-decisions.md index 442a049..00a13b2 100644 --- a/docs/architecture-decisions.md +++ b/docs/architecture-decisions.md @@ -306,3 +306,40 @@ unrelated feature's growth. Remote approval/enforcement is not implied by this l Native compilation and UI results must be reported separately. - **Revisit condition:** Add separate system-notification or per-agent controls only when requested, rather than expanding the meaning of this persisted key. + +## ADR-0010 — Administrator shells hosted by the GPUI product + +- **Status:** Requested by the maintainer and implemented, 2026-09-13. Native + Windows behavior tests passed; interactive UAC acceptance remains manual. +- **Context:** Elevating a shell executable directly opens an external console. + Elevating Pebrel without preserving the explicit startup command can instead + reach the ordinary resident process and lose both privilege and Shell selection. +- **Decision:** The launcher requests Windows UAC for the current Pebrel executable + on a worker. It passes the selected Shell's argument vector and working directory + through the existing CLI, using Windows argument quoting without a command shell. + GPUI startup consumes that command as its first terminal. An already elevated + process creates the terminal in its current workspace. +- **Ownership:** A process-token check separates elevated instances from ordinary + resident forwarding. Elevated instances use the existing loopback API transport + with a private endpoint supplied only in local PTY child environments, including + WSL passthrough. They do not publish a privileged bearer token in `runtime.port`, + acquire its ownership lock, restore/write the shared session, or hide on close. + Ordinary discovery files and settings formats remain compatible. Token-query + failure uses the isolated policy; launch failure remains visible to the user. +- **Lifetime and cost:** UAC runs off the UI thread, one request per workspace at + a time. Completion updates only a still-live workspace and does not dismiss a + subsequently opened picker. Token status is cached; endpoint injection runs only + during PTY creation. No application dependency or additional server is added. +- **Validation:** Tests cover native argument parsing, explicit-command startup, + ordinary-session exclusion, private discovery/authentication, right-click versus + launch, keyboard dismissal, and SSH target stability. Automated coverage does not + imply a completed UAC desktop acceptance test. +- **Revisit condition:** Supporting an elevated pane inside an existing ordinary + process requires a separately reviewed broker and authenticated PTY transport. + A future privileged-residency feature must have explicit recovery/discovery. + +The two pane preferences in the same request reuse `nebula_settings`: mouse focus +has an optional GUI override of the compatible TOML setting (default off), while +inactive-pane dimming defaults on to preserve the existing appearance. Both are +cached by the GPUI settings adapter; pointer movement and rendering do not read +settings files. diff --git a/extra/logo/LICENSE-lobe-icons b/extra/logo/LICENSE-lobe-icons new file mode 100644 index 0000000..1dd53d2 --- /dev/null +++ b/extra/logo/LICENSE-lobe-icons @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 LobeHub + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/extra/logo/README.md b/extra/logo/README.md index 9d6d778..55d3678 100644 --- a/extra/logo/README.md +++ b/extra/logo/README.md @@ -92,6 +92,42 @@ by Git and are not required by the asset tests. ## Third-Party Logo Assets +### Claude Code and Trae CLI + +`ai_claude.svg` and `ai_trae.svg` are vector masters from LobeHub's icon +collection, retrieved on 2026-09-13 at these exact revisions: + +- Claude: https://github.com/lobehub/lobe-icons/blob/f25f22c0e7295a76077fe417d23deeb56eefdf3e/packages/static-svg/icons/claude-color.svg +- Trae: https://github.com/lobehub/lobe-icons/blob/0935313ef153c255958754d9d2523862e069c2c9/packages/static-svg/icons/trae-color.svg +- Collection license: [MIT](LICENSE-lobe-icons), copyright LobeHub. Product names + and trademarks remain with their respective owners; use identifies running + programs and does not imply endorsement. + +The matching PNGs are 1024 x 1024 transparent exports made with +`@resvg/resvg-js` 2.6.2 and `fitTo: { mode: 'width', value: 1024 }`, without +changing geometry or colors. The renderer is a development tool, not a product +dependency. Both shells prepare antialiased textures at the requested physical +size and keep the source colors on light and dark themes. + +- Claude PNG SHA-256: `00EF583DAA55F37717C79269A2641C81B0E77F303BEA9E7EEF08482C84D76269` +- Trae PNG SHA-256: `C502272694D1ABDD70DBD783F51171A497C136391BE035F2D67146BD9EC0CF23` + +Trae's `trae-cli` executable name is declared by ByteDance's +[`trae-agent` project](https://github.com/bytedance/trae-agent/blob/main/pyproject.toml) +under `[project.scripts]`. Detection and branding do not imply Hook, cold-start, +resume, or fork support. + +### Oh My Pi + +`ai_omp.svg` is a Pebrel-drawn purple gradient π mark following the maintainer's +2026-09-13 visual reference. Its simple vector geometry is authored here; no +third-party application asset is embedded. This is a presentation variant, not the +upstream white π / orange connector artwork currently published in +[oh-my-pi/assets/icon.svg](https://github.com/can1357/oh-my-pi/blob/2be354322dee0cdd95bc42fc61c72ef9767b653b/assets/icon.svg). +`ai_omp.png` is a transparent 1024 x 1024 export made with the same resvg settings +as Claude and Trae above. It identifies the existing `omp` / `oh-my-pi` agent +identity and preserves its gradient in both themes. + ### Grok `ai_grok_dark.png` and `ai_grok_light.png` are unmodified copies of diff --git a/extra/logo/ai_claude.png b/extra/logo/ai_claude.png index 7484ce656766dd639d8fea1ee6f1b1e667648811..e1a93737506652c10c53093ea0fa61a423a59429 100644 GIT binary patch literal 46460 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 Du)lT$ diff --git a/extra/logo/ai_claude.svg b/extra/logo/ai_claude.svg new file mode 100644 index 0000000..db1cbc5 --- /dev/null +++ b/extra/logo/ai_claude.svg @@ -0,0 +1 @@ +Claude diff --git a/extra/logo/ai_omp.png b/extra/logo/ai_omp.png new file mode 100644 index 0000000000000000000000000000000000000000..8671e9635fc44323830c58db59a1a8c55c0c701d GIT binary patch literal 87134 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 + Oh My Pi + + + + + + + + + diff --git a/extra/logo/ai_trae.png b/extra/logo/ai_trae.png new file mode 100644 index 0000000000000000000000000000000000000000..97e1fb3c3c347ced87ec95f873cb4f22edfda951 GIT binary patch literal 11959 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 literal 0 HcmV?d00001 diff --git a/extra/logo/ai_trae.svg b/extra/logo/ai_trae.svg new file mode 100644 index 0000000..dbf31a6 --- /dev/null +++ b/extra/logo/ai_trae.svg @@ -0,0 +1 @@ +TRAE diff --git a/nebula_app/i18n/en-US.json b/nebula_app/i18n/en-US.json index 40365c5..743ef3c 100644 --- a/nebula_app/i18n/en-US.json +++ b/nebula_app/i18n/en-US.json @@ -109,7 +109,16 @@ "dedicated": "Dedicated window", "existing": "Show existing window" }, - "search_empty": "No matching settings" + "search_empty": "No matching settings", + "mouse": { + "focus_follows_mouse": "Focus follows mouse", + "focus_follows_mouse_description": "Focus a terminal pane when the pointer moves over it. Off by default." + }, + "panes": { + "dim_inactive": "Dim inactive panes", + "dim_inactive_description": "Darken other split panes to make the active pane easier to identify." + }, + "save_failed": "Could not save settings: {error}" }, "provider": { "test": { @@ -462,5 +471,17 @@ "exported": "Host metadata exported without credentials", "imported": "Added {added} hosts; skipped {skipped} existing hosts", "imported_partial": "Added {added} hosts, but could not update their visibility. Retry the import: {error}" + }, + "launcher": { + "open_admin": "Open as administrator", + "set_default": "Set as default Shell", + "current_default": "Current default Shell", + "connect": "Connect", + "edit": "Edit", + "delete": "Delete", + "default_saved": "{shell} is now the default Shell.", + "admin_started": "Requested {shell} in a new administrator Pebrel window.", + "admin_cancelled": "Administrator launch cancelled.", + "admin_failed": "Could not open an administrator terminal: {error}" } } diff --git a/nebula_app/i18n/zh-CN.json b/nebula_app/i18n/zh-CN.json index 09d7b8a..7f7834f 100644 --- a/nebula_app/i18n/zh-CN.json +++ b/nebula_app/i18n/zh-CN.json @@ -109,7 +109,16 @@ "dedicated": "独立窗口", "existing": "唤起已有窗口" }, - "search_empty": "没有匹配的设置" + "search_empty": "没有匹配的设置", + "mouse": { + "focus_follows_mouse": "焦点跟随鼠标", + "focus_follows_mouse_description": "鼠标移入终端窗格时自动聚焦,默认关闭。" + }, + "panes": { + "dim_inactive": "调暗非活动窗格", + "dim_inactive_description": "降低其他分屏窗格的亮度,突出当前活动窗格。" + }, + "save_failed": "无法保存设置:{error}" }, "provider": { "test": { @@ -462,5 +471,17 @@ "exported": "主机信息已导出,不含凭据", "imported": "已新增 {added} 台主机,跳过 {skipped} 台已有主机", "imported_partial": "已新增 {added} 台主机,但显示状态未能更新。请重试导入:{error}" + }, + "launcher": { + "open_admin": "以管理员身份打开", + "set_default": "设为默认 Shell", + "current_default": "当前默认 Shell", + "connect": "连接", + "edit": "编辑", + "delete": "删除", + "default_saved": "已将 {shell} 设为默认 Shell。", + "admin_started": "已请求在新的管理员 Pebrel 窗口中打开 {shell}。", + "admin_cancelled": "已取消管理员启动。", + "admin_failed": "无法打开管理员终端:{error}" } } diff --git a/nebula_app/src/agent_env.rs b/nebula_app/src/agent_env.rs index 0fe8039..70768d7 100644 --- a/nebula_app/src/agent_env.rs +++ b/nebula_app/src/agent_env.rs @@ -91,6 +91,7 @@ pub fn apply(env: &mut HashMap, pane_id: impl Display) { } } + crate::runtime_api::apply_child_endpoint(env); merge_wslenv(env); } @@ -172,6 +173,7 @@ const WSLENV_ENTRIES: &[&str] = &[ "PEBREL_CLI/p", "PEBREL_BIN_DIR/p", PROCESS_ENV, + crate::runtime_api::ENDPOINT_ENV, "NEBULA_CLI/p", "NEBULA_BIN_DIR/p", ]; @@ -308,6 +310,7 @@ mod tests { assert!(names.contains(&CLI_ENV), "{CLI_ENV} missing from WSLENV passthrough: {names:?}"); assert!(names.contains(&BIN_DIR_ENV), "{BIN_DIR_ENV} missing: {names:?}"); assert!(names.contains(&PROCESS_ENV), "{PROCESS_ENV} missing: {names:?}"); + assert!(names.contains(&crate::runtime_api::ENDPOINT_ENV)); } #[cfg(windows)] diff --git a/nebula_app/src/ai_agents.rs b/nebula_app/src/ai_agents.rs index d867a9f..4106a1f 100644 --- a/nebula_app/src/ai_agents.rs +++ b/nebula_app/src/ai_agents.rs @@ -47,10 +47,11 @@ pub enum AgentKind { Kilo, Qoder, Maki, + Trae, } impl AgentKind { - pub const ALL: [Self; 25] = [ + pub const ALL: [Self; 26] = [ Self::Claude, Self::Codex, Self::Gemini, @@ -76,6 +77,7 @@ impl AgentKind { Self::Kilo, Self::Qoder, Self::Maki, + Self::Trae, ]; pub fn slug(self) -> &'static str { @@ -105,6 +107,7 @@ impl AgentKind { Self::Kilo => "kilo", Self::Qoder => "qodercli", Self::Maki => "maki", + Self::Trae => "trae-cli", } } @@ -135,6 +138,7 @@ impl AgentKind { Self::Kilo => "Kilo Code", Self::Qoder => "Qoder", Self::Maki => "Maki", + Self::Trae => "Trae CLI", } } @@ -171,6 +175,8 @@ impl AgentKind { Self::Kilo => &["kilo", "kilo-code"], Self::Qoder => &["qodercli", "qoderclicn", "qoder", "qodercn"], Self::Maki => &["maki"], + // ByteDance's trae-agent declares this console entry point. + Self::Trae => &["trae-cli"], } } @@ -250,7 +256,8 @@ impl AgentKind { | Self::Kiro | Self::Kilo | Self::Qoder - | Self::Maki => return None, + | Self::Maki + | Self::Trae => return None, Self::Kimi => format!("kimi --session {session_id}"), }) } @@ -816,6 +823,18 @@ mod tests { assert_eq!(AgentKind::parse("cargo"), None); } + #[test] + fn trae_cli_identity_does_not_guess_session_commands() { + for command in ["trae-cli", r"C:\tools\TRAE-CLI.EXE", "/usr/bin/trae-cli"] { + assert_eq!(AgentKind::parse(command), Some(AgentKind::Trae)); + } + assert_eq!(AgentKind::parse_command("uv run trae-cli --help"), Some(AgentKind::Trae)); + assert_eq!(AgentKind::parse("trae-cli-helper"), None); + assert_eq!(AgentKind::Trae.start_command(), None); + assert_eq!(AgentKind::Trae.resume_command("session-1"), None); + assert_eq!(AgentKind::Trae.fork_command("session-1"), None); + } + #[test] fn submitted_commands_resolve_agents_across_wsl_launch_forms() { for agent in AgentKind::ALL { diff --git a/nebula_app/src/display/mod.rs b/nebula_app/src/display/mod.rs index 6e64731..272757f 100644 --- a/nebula_app/src/display/mod.rs +++ b/nebula_app/src/display/mod.rs @@ -2412,12 +2412,11 @@ impl Display { let grok_uses_light_mark = u32::from(ink.r) * 299 + u32::from(ink.g) * 587 + u32::from(ink.b) * 114 >= 128_000; let key = match logo { - AiLogo::Claude | AiLogo::Antigravity => (logo, [0, 0, 0], target_size), AiLogo::Grok if grok_uses_light_mark => (logo, [255, 255, 255], target_size), - AiLogo::Grok => (logo, [0, 0, 0], target_size), AiLogo::OpenAi | AiLogo::OpenCode | AiLogo::Pi => { (logo, [ink.r, ink.g, ink.b], target_size) }, + _ => (logo, [0, 0, 0], target_size), }; if let Some(cached) = self.nebula_ai_logo_cache.get(&key) { return Some(cached.clone()); @@ -2432,11 +2431,7 @@ impl Display { }, }; logo.tint_pixels(&mut rgba, [ink.r, ink.g, ink.b]); - let (rgba, width, height) = if matches!(logo, AiLogo::Grok | AiLogo::Antigravity) { - prepare_ai_logo_texture(&rgba, width, height, target_size) - } else { - (rgba, width, height) - }; + let (rgba, width, height) = prepare_ai_logo_texture(&rgba, width, height, target_size); let id = AI_LOGO_ID_BASE + self.nebula_ai_logo_cache.len() as u64; let entry = (id, std::sync::Arc::new(rgba), (width, height)); self.nebula_ai_logo_cache.insert(key, entry.clone()); diff --git a/nebula_app/src/display/program_identity.rs b/nebula_app/src/display/program_identity.rs index d3616f1..1fd6dc7 100644 --- a/nebula_app/src/display/program_identity.rs +++ b/nebula_app/src/display/program_identity.rs @@ -10,11 +10,21 @@ pub enum AiLogo { Pi, Grok, Antigravity, + Trae, + OhMyPi, } impl AiLogo { - pub(crate) const ALL: [Self; 6] = - [Self::Claude, Self::OpenAi, Self::OpenCode, Self::Pi, Self::Grok, Self::Antigravity]; + pub(crate) const ALL: [Self; 8] = [ + Self::Claude, + Self::OpenAi, + Self::OpenCode, + Self::Pi, + Self::Grok, + Self::Antigravity, + Self::Trae, + Self::OhMyPi, + ]; /// One source catalog for both shells. Official color assets are embedded unchanged. pub(crate) fn png(self, light_ink: bool) -> &'static [u8] { @@ -26,6 +36,8 @@ impl AiLogo { Self::Grok if light_ink => include_bytes!("../../../extra/logo/ai_grok_light.png"), Self::Grok => include_bytes!("../../../extra/logo/ai_grok_dark.png"), Self::Antigravity => include_bytes!("../../../extra/logo/ai_antigravity.png"), + Self::Trae => include_bytes!("../../../extra/logo/ai_trae.png"), + Self::OhMyPi => include_bytes!("../../../extra/logo/ai_omp.png"), } } @@ -34,7 +46,7 @@ impl AiLogo { Self::OpenAi | Self::Pi => false, // OpenCode stores a luma map: the frame is white, the inner block gray. Self::OpenCode => true, - Self::Claude | Self::Grok | Self::Antigravity => return, + Self::Claude | Self::Grok | Self::Antigravity | Self::Trae | Self::OhMyPi => return, }; for pixel in pixels.chunks_exact_mut(4) { let luma = if preserve_luma { u16::from(pixel[0]) } else { 255 }; @@ -109,6 +121,8 @@ pub(crate) fn ai_logo_for_program(program: &str) -> Option { AgentKind::Pi => Some(AiLogo::Pi), AgentKind::Grok => Some(AiLogo::Grok), AgentKind::Antigravity => Some(AiLogo::Antigravity), + AgentKind::Trae => Some(AiLogo::Trae), + AgentKind::OhMyPi => Some(AiLogo::OhMyPi), _ => None, } } @@ -128,8 +142,8 @@ pub(crate) fn program_icon(program: &str) -> &'static str { "copilot" => "\u{f4b8}", "cursor" | "cursor-agent" => "\u{f0ec3}", "aider" | "goose" | "crush" | "ollama" => "\u{f06a9}", - "opencode" => "\u{f489}", - "pi" => "\u{f135}", + "opencode" | "trae-cli" => "\u{f489}", + "pi" | "omp" | "oh-my-pi" => "\u{f135}", "git" | "lazygit" => "\u{f418}", "vim" | "nvim" | "vi" | "hx" | "nano" => "\u{e62b}", "ssh" | "mosh" => "\u{f489}", @@ -291,7 +305,7 @@ mod tests { fn shared_tint_preserves_color_assets_and_opencode_luminance() { let source = [255, 255, 255, 128, 128, 128, 128, 64]; let ink = [100, 200, 240]; - for logo in [AiLogo::Claude, AiLogo::Grok, AiLogo::Antigravity] { + for logo in [AiLogo::Claude, AiLogo::Grok, AiLogo::Antigravity, AiLogo::Trae] { let mut pixels = source; logo.tint_pixels(&mut pixels, ink); assert_eq!(pixels, source); @@ -305,4 +319,34 @@ mod tests { AiLogo::OpenCode.tint_pixels(&mut pixels, ink); assert_eq!(pixels, [100, 200, 240, 128, 50, 100, 120, 64]); } + + #[test] + fn vector_sourced_logos_keep_color_and_antialiased_edges_at_tab_sizes() { + for logo in [AiLogo::Claude, AiLogo::Trae, AiLogo::OhMyPi] { + let (width, height, source) = decode_png(logo.png(false)); + assert_eq!((width, height), (1024, 1024)); + for size in [16, 18, 24, 27, 36, 48] { + let (pixels, width, height) = prepare_ai_logo_texture(&source, width, height, size); + assert_eq!((width, height), (size, size)); + assert!(pixels.chunks_exact(4).any(|p| p[3] == 0)); + assert!(pixels.chunks_exact(4).any(|p| p[3] == 255)); + let edge_levels: std::collections::HashSet<_> = pixels + .chunks_exact(4) + .map(|p| p[3]) + .filter(|alpha| *alpha > 0 && *alpha < 255) + .collect(); + assert!(edge_levels.len() >= 8, "{logo:?} at {size}px"); + for ink in [[236, 239, 245], [35, 40, 50]] { + let mut tinted = pixels.clone(); + logo.tint_pixels(&mut tinted, ink); + assert_eq!(tinted, pixels); + } + } + } + assert_eq!(logo_for_command("TRAE-CLI.EXE"), Some(AiLogo::Trae)); + assert_eq!(logo_for_command("trae-cli-helper"), None); + for command in ["omp", "oh-my-pi", r"C:\tools\OMP.EXE"] { + assert_eq!(logo_for_command(command), Some(AiLogo::OhMyPi)); + } + } } diff --git a/nebula_app/src/gpui_shell/config.rs b/nebula_app/src/gpui_shell/config.rs index e87af53..06105a0 100644 --- a/nebula_app/src/gpui_shell/config.rs +++ b/nebula_app/src/gpui_shell/config.rs @@ -59,6 +59,9 @@ pub struct Settings { pub cursor_blink: Option, /// 选区完成即复制(旧壳 `copy_on_select` 设置)。 pub copy_on_select: bool, + /// Pointer handlers and split rendering only read these cached preferences. + pub focus_follows_mouse: bool, + pub dim_inactive_panes: bool, /// Cached in-app toast preference, independent of native system notifications. pub ai_toasts: bool, /// 标签关闭按钮与标签插入动画都在渲染热路径读取,必须随全局设置驻留内存。 @@ -176,6 +179,10 @@ impl Settings { }), cursor_blink: runtime.cursor_blink, copy_on_select: runtime.copy_on_select, + focus_follows_mouse: runtime + .focus_follows_mouse + .unwrap_or(raw.mouse.focus_follows_mouse), + dim_inactive_panes: runtime.dim_inactive_panes, ai_toasts: runtime.ai_toasts, tab_close_visible: runtime.tab_close_visible, tab_reveal: runtime.tab_reveal, @@ -429,6 +436,13 @@ fn merge_values(base: toml::Value, other: toml::Value) -> toml::Value { struct RawConfig { font: RawFont, colors: RawColors, + mouse: RawMouse, +} + +#[derive(Deserialize, Default)] +#[serde(default)] +struct RawMouse { + focus_follows_mouse: bool, } #[derive(Deserialize, Default)] diff --git a/nebula_app/src/gpui_shell/mod.rs b/nebula_app/src/gpui_shell/mod.rs index 69a55c6..8423216 100644 --- a/nebula_app/src/gpui_shell/mod.rs +++ b/nebula_app/src/gpui_shell/mod.rs @@ -75,7 +75,10 @@ pub(crate) enum GpuiShellEvent { /// /// GPUI 拥有自己的消息循环:主窗形态从主线程调用(winit 不启动); /// spike 形态从专用线程调用。一个进程内只允许调用一次。 -pub fn run_shell(initial_cwd: Option) { +pub fn run_shell( + initial_cwd: Option, + initial_command: Option, +) { let (shell_tx, shell_rx) = std::sync::mpsc::channel(); crate::notify::init_gpui_activation(shell_tx.clone()); crate::ssh_prompt::install({ @@ -111,7 +114,10 @@ pub fn run_shell(initial_cwd: Option) { }, runtime_hub.clone(), ); - if runtime_server.is_none() { + if runtime_server.is_none() + && initial_command.is_none() + && !crate::platform::elevation::requires_isolation() + { // 与另一份进程同时启动时,对方可能已经拿到 owner lock、但尚未来得及 // 发布 endpoint。短暂等待并交接,避免继续打开一个无控制面的空窗口。 for _ in 0..40 { @@ -147,7 +153,7 @@ pub fn run_shell(initial_cwd: Option) { crate::ai_hook::spawn_config_guard(); init(cx); cx.activate(true); - open_main_window(cx, ai_events, shell_rx, runtime_hub, initial_cwd); + open_main_window(cx, ai_events, shell_rx, runtime_hub, initial_cwd, initial_command); }); crate::tray::shutdown(); } @@ -220,9 +226,16 @@ fn open_main_window( shell_events: std::sync::mpsc::Receiver, runtime_hub: crate::runtime_api::RuntimeHub, initial_cwd: Option, + initial_command: Option, ) { workspace::windowing::initialize(cx, runtime_hub); - workspace::windowing::open_initial_window(cx, ai_events, shell_events, initial_cwd); + workspace::windowing::open_initial_window( + cx, + ai_events, + shell_events, + initial_cwd, + initial_command, + ); } /// 按 `tray` 设置挂上或摘掉系统托盘图标(旧壳 `tray::set_enabled`)。 diff --git a/nebula_app/src/gpui_shell/settings_pane.rs b/nebula_app/src/gpui_shell/settings_pane.rs index 5d99305..8b1a023 100644 --- a/nebula_app/src/gpui_shell/settings_pane.rs +++ b/nebula_app/src/gpui_shell/settings_pane.rs @@ -53,6 +53,7 @@ mod theme_picker; mod initialization; mod keymap; +mod launcher_actions; mod localization; mod navigation; mod shell_picker; @@ -70,7 +71,7 @@ use status::{ /// 宿主(workspace)监听:设置已写盘 / 终端目录已变 / 请求打开 SSH 会话。 pub enum SettingsPaneEvent { - /// Return to the workspace. Settings is a window-level page, not a tab. + /// Explicitly close Settings and return to the workspace. Close, Changed, /// 导入 Profile 已落盘;Tab 的 Shell 面板若正打开,需要重建候选快照。 @@ -300,7 +301,7 @@ impl SettingsPane { self.request_cover_chrome(value, window, cx); return; } - if key == "ai_toasts" { + if matches!(key, "ai_toasts" | "focus_follows_mouse" | "dim_inactive_panes") { if let Err(error) = self.try_persist(&[(key, (value as u8).to_string())], cx) { let language = crate::gpui_shell::config::ui_language(cx); super::toast::toast( @@ -308,7 +309,11 @@ impl SettingsPane { cx, super::toast::ToastKind::Warning, language.format( - crate::i18n::Message::SettingsNotificationsSaveFailed, + if key == "ai_toasts" { + crate::i18n::Message::SettingsNotificationsSaveFailed + } else { + crate::i18n::Message::SettingsSaveFailed + }, &[("error", &error.to_string())], ), ); @@ -659,6 +664,8 @@ impl SettingsPane { match key { "follow_system_theme" => flag!(follow_system_theme), "copy_on_select" => flag!(copy_on_select), + "focus_follows_mouse" => Some((cur.focus_follows_mouse.is_some(), String::new())), + "dim_inactive_panes" => flag!(dim_inactive_panes), "multiline_paste_confirm" => flag!(multiline_paste_confirm), "tab_close_visible" => flag!(tab_close_visible), "terminal_proxy" => flag!(terminal_proxy), @@ -1050,6 +1057,18 @@ impl SettingsPane { .gap(px(GROUP_GAP)) .child( self.group(language.pick("鼠标与选区", "Mouse and selection"), cx) + .child( + self.switch_row( + "focus_follows_mouse", + language.text(crate::i18n::Message::SettingsMouseFocusFollowsMouse), + language.text( + crate::i18n::Message::SettingsMouseFocusFollowsMouseDescription, + ), + cx.try_global::() + .is_some_and(|settings| settings.focus_follows_mouse), + cx, + ), + ) .child(self.switch_row( "copy_on_select", language.pick("选中即复制", "Copy on select"), diff --git a/nebula_app/src/gpui_shell/settings_pane/appearance_advanced.rs b/nebula_app/src/gpui_shell/settings_pane/appearance_advanced.rs index 741eb29..5716f7c 100644 --- a/nebula_app/src/gpui_shell/settings_pane/appearance_advanced.rs +++ b/nebula_app/src/gpui_shell/settings_pane/appearance_advanced.rs @@ -61,6 +61,13 @@ impl SettingsPane { )); let interface = self .group(language.pick("界面", "Interface"), cx) + .child(self.switch_row( + "dim_inactive_panes", + language.text(crate::i18n::Message::SettingsPanesDimInactive), + language.text(crate::i18n::Message::SettingsPanesDimInactiveDescription), + self.runtime.dim_inactive_panes, + cx, + )) .child(self.switch_row( "tab_close_visible", language.pick("显示标签关闭按钮", "Show tab close buttons"), diff --git a/nebula_app/src/gpui_shell/settings_pane/launcher_actions.rs b/nebula_app/src/gpui_shell/settings_pane/launcher_actions.rs new file mode 100644 index 0000000..b5072c8 --- /dev/null +++ b/nebula_app/src/gpui_shell/settings_pane/launcher_actions.rs @@ -0,0 +1,38 @@ +//! Settings entry points for actions initiated in the Shell/SSH launcher. + +use super::*; + +impl SettingsPane { + pub(in crate::gpui_shell) fn sync_launcher_shell( + &mut self, + window: &mut Window, + cx: &mut Context, + ) { + self.runtime = RuntimeSettings::load(); + self.restore_shell_selection(window, cx); + cx.notify(); + } + + pub(in crate::gpui_shell) fn manage_launcher_ssh( + &mut self, + host: String, + edit: bool, + window: &mut Window, + cx: &mut Context, + ) { + self.settings_search_input.update(cx, |input, cx| input.set_value("", window, cx)); + self.search_origin_section = None; + self.active_section = SECTION_IDS.iter().position(|section| *section == "ssh").unwrap(); + self.ssh_hosts = crate::gpui_shell::ssh_hosts::SshHostLists::load(); + self.prepare_launcher_ssh_host(&host, window, cx); + if edit { + self.open_ssh_editor(Some(host), window, cx); + } else { + if self.ssh_editor.is_some() { + self.close_ssh_editor(window, cx); + } + self.ssh_delete_confirm = Some(host); + } + cx.notify(); + } +} diff --git a/nebula_app/src/gpui_shell/settings_pane/navigation.rs b/nebula_app/src/gpui_shell/settings_pane/navigation.rs index 7cad2b7..f05556c 100644 --- a/nebula_app/src/gpui_shell/settings_pane/navigation.rs +++ b/nebula_app/src/gpui_shell/settings_pane/navigation.rs @@ -27,12 +27,12 @@ pub(super) const SECTION_IDS: [&str; 10] = [ /// section that owns the control instead of merely filtering the current page. pub(super) const SECTION_SEARCH_TERMS: [&str; 10] = [ "application app 应用 update 更新 version 版本 github support 支持", - "appearance 外观 theme 主题 font 字体 opacity 透明度 background 背景 cursor 光标 icon 图标", + "appearance 外观 theme 主题 font 字体 opacity 透明度 background 背景 cursor 光标 icon 图标 dim inactive panes 调暗非活动窗格 分屏变暗", "profiles 配置文件 shell terminal 终端 completion 补全 startup 启动 ai message notifications toast alerts bell 提醒 通知 弹窗 消息 右下角 ai消息通知 ai 消息通知 ai消息弹窗 ai 消息弹窗 铃声", "providers provider ai 供应商 模型 api", "ssh host 主机 remote 远程 connection 连接", "network 网络 proxy 代理 connectivity 连接", - "interaction 交互 copy 复制 paste 粘贴 tab 标签 panel 面板", + "interaction 交互 copy 复制 paste 粘贴 tab 标签 panel 面板 focus follows mouse 焦点跟随鼠标 自动聚焦", "keymap key binding shortcut quick terminal 快速终端 独立窗口 已有窗口 按键映射 快捷键", "advanced 高级 session 会话 tray 托盘 restore 恢复", "backup 备份 export 导出 restore 恢复", diff --git a/nebula_app/src/gpui_shell/ssh_settings/library.rs b/nebula_app/src/gpui_shell/ssh_settings/library.rs index 65ea75c..815fda1 100644 --- a/nebula_app/src/gpui_shell/ssh_settings/library.rs +++ b/nebula_app/src/gpui_shell/ssh_settings/library.rs @@ -53,6 +53,18 @@ impl HostLibraryState { } impl SettingsPane { + pub(in crate::gpui_shell) fn prepare_launcher_ssh_host( + &mut self, + host: &str, + window: &mut Window, + cx: &mut Context, + ) { + self.ssh_library.scope = HostScope::All; + self.ssh_library.group_filter = None; + self.ssh_library.search.update(cx, |input, cx| input.set_value(host, window, cx)); + self.ssh_library.reset_scroll(); + } + fn filtered_library_hosts(&self, cx: &gpui::App) -> Vec { let query = self.ssh_library.search.read(cx).value(); let hosts = if self.ssh_library.scope == HostScope::Recent { diff --git a/nebula_app/src/gpui_shell/terminal/view/pointer.rs b/nebula_app/src/gpui_shell/terminal/view/pointer.rs index 8d165ef..9458367 100644 --- a/nebula_app/src/gpui_shell/terminal/view/pointer.rs +++ b/nebula_app/src/gpui_shell/terminal/view/pointer.rs @@ -1,4 +1,8 @@ use super::*; +use gpui_component::WindowExt as _; + +#[cfg(all(test, feature = "gpui-test-support"))] +mod tests; impl TerminalView { pub(in crate::gpui_shell::terminal) fn scrollbar_thumb( @@ -453,7 +457,7 @@ impl TerminalView { pub(super) fn on_mouse_move( &mut self, event: &MouseMoveEvent, - _window: &mut Window, + window: &mut Window, cx: &mut Context, ) { if self.move_completion_popup_scrollbar(event, cx) { @@ -502,6 +506,19 @@ impl TerminalView { cx.notify(); return; } + // The element's hitbox already excludes occluding menus. Do not move + // focus during a drag or let pointer movement dismiss modal input. + if event.pressed_button.is_none() + && !cx.has_active_drag() + && window.is_window_active() + && !self.focus_handle.is_focused(window) + && cx.try_global::().is_some_and(|settings| settings.focus_follows_mouse) + && !window.has_active_dialog(cx) + && !window.has_active_sheet(cx) + { + window.focus(&self.focus_handle, cx); + cx.emit(TerminalViewEvent::FocusRequested); + } if self.mouse_mode_active(&event.modifiers) { self.clear_link_hover(cx); // 鼠标模式的移动上报:拖动 = 按钮码+32(需 DRAG 或 MOTION 任一), diff --git a/nebula_app/src/gpui_shell/terminal/view/pointer/tests.rs b/nebula_app/src/gpui_shell/terminal/view/pointer/tests.rs new file mode 100644 index 0000000..afaf667 --- /dev/null +++ b/nebula_app/src/gpui_shell/terminal/view/pointer/tests.rs @@ -0,0 +1,153 @@ +use super::*; +use gpui::prelude::FluentBuilder as _; +use gpui::{Entity, Focusable as _, Modifiers, Render, TestAppContext, VisualTestContext}; +use gpui_component::{ + Root, + input::{Input, InputState}, +}; +use std::cell::Cell; +use std::rc::Rc; + +struct Probe { + terminal: Entity, + input: Entity, + overlay: bool, + requests: Rc>, + _subscription: gpui::Subscription, +} + +impl Render for Probe { + fn render(&mut self, window: &mut Window, cx: &mut gpui::Context) -> impl IntoElement { + div() + .size_full() + .relative() + .flex() + .flex_col() + .child(div().h(px(40.0)).child(Input::new(&self.input))) + .child( + div() + .id("mouse-terminal") + .debug_selector(|| "mouse-terminal".to_owned()) + .flex_1() + .child(self.terminal.clone()), + ) + .when(self.overlay, |root| root.child(div().absolute().inset_0().occlude())) + .children(Root::render_dialog_layer(window, cx)) + } +} + +fn open(cx: &mut TestAppContext) -> (Entity, VisualTestContext) { + cx.update(|cx| { + gpui_component::init(cx); + let mut settings = Settings::load(nebula_settings::ThemeName::Nord); + settings.focus_follows_mouse = false; + cx.set_global(settings); + }); + let mut output = None; + let (_, window) = cx.add_window_view(|window, cx| { + let terminal = cx.new(|cx| { + TerminalView::new( + 1, + (80, 24), + super::super::TerminalLaunch::Local { + cwd: None, + // Exercise the real terminal element without creating a shell session. + shell: Some(nebula_terminal::tty::Shell::new( + "pebrel-test-missing-shell-executable".into(), + Vec::new(), + )), + shell_name: None, + }, + window, + cx, + ) + }); + let input = cx.new(|cx| InputState::new(window, cx)); + input.read(cx).focus_handle(cx).focus(window, cx); + let view = cx.new(|cx| { + let requests = Rc::new(Cell::new(0)); + let count = requests.clone(); + let subscription = cx.subscribe(&terminal, move |_, _, event, _| { + if matches!(event, TerminalViewEvent::FocusRequested) { + count.set(count.get() + 1); + } + }); + Probe { terminal, input, overlay: false, requests, _subscription: subscription } + }); + output = Some(view.clone()); + Root::new(view, window, cx) + }); + let mut window = window.clone(); + window.simulate_resize(gpui::size(px(700.0), px(500.0))); + // TestPlatform creates inactive windows; real pointer focus requires the + // OS activation event as well as an element focus handle. + window.update(|window, _| window.activate_window()); + draw(&mut window); + window.update(|window, _| assert!(window.is_window_active())); + (output.unwrap(), window) +} + +fn draw(cx: &mut VisualTestContext) { + cx.run_until_parked(); + cx.update(|window, cx| { + window.refresh(); + window.draw(cx).clear(cx); + }); +} + +fn move_over(cx: &mut VisualTestContext, button: Option) { + let position = cx.debug_bounds("mouse-terminal").unwrap().center(); + cx.simulate_mouse_move(position, button, Modifiers::default()); + cx.run_until_parked(); +} + +#[gpui::test] +fn hover_focus_requires_opt_in_and_emits_once(cx: &mut TestAppContext) { + let (probe, mut cx) = open(cx); + move_over(&mut cx, None); + assert_eq!(probe.read_with(&cx, |probe, _| probe.requests.get()), 0); + cx.update(|_, cx| cx.global_mut::().focus_follows_mouse = true); + move_over(&mut cx, None); + cx.update(|window, cx| { + assert!(probe.read(cx).terminal.read(cx).focus_handle.is_focused(window)) + }); + move_over(&mut cx, None); + assert_eq!(probe.read_with(&cx, |probe, _| probe.requests.get()), 1); + cx.update(|window, cx| { + cx.global_mut::().focus_follows_mouse = false; + probe.read(cx).input.read(cx).focus_handle(cx).focus(window, cx); + }); + move_over(&mut cx, None); + assert_eq!(probe.read_with(&cx, |probe, _| probe.requests.get()), 1); +} + +#[gpui::test] +fn hover_does_not_steal_focus_during_drag_overlay_dialog_or_inactive_window( + cx: &mut TestAppContext, +) { + let (probe, mut cx) = open(cx); + cx.update(|_, cx| cx.global_mut::().focus_follows_mouse = true); + for button in [MouseButton::Left, MouseButton::Right, MouseButton::Middle] { + move_over(&mut cx, Some(button)); + } + probe.update(&mut cx, |probe, cx| { + probe.overlay = true; + cx.notify(); + }); + draw(&mut cx); + move_over(&mut cx, None); + probe.update(&mut cx, |probe, cx| { + probe.overlay = false; + cx.notify(); + }); + draw(&mut cx); + cx.update(|window, cx| window.open_dialog(cx, |dialog, _, _| dialog.title("focus fixture"))); + draw(&mut cx); + move_over(&mut cx, None); + cx.update(|window, cx| window.close_dialog(cx)); + draw(&mut cx); + cx.deactivate_window(); + cx.update(|window, _| assert!(!window.is_window_active())); + move_over(&mut cx, None); + assert_eq!(probe.read_with(&cx, |probe, _| probe.requests.get()), 0); +} diff --git a/nebula_app/src/gpui_shell/workspace.rs b/nebula_app/src/gpui_shell/workspace.rs index 9a7cf81..3fb1044 100644 --- a/nebula_app/src/gpui_shell/workspace.rs +++ b/nebula_app/src/gpui_shell/workspace.rs @@ -49,10 +49,14 @@ mod agents; mod closing; mod command_manager; mod keyboard_bindings; -use keyboard_bindings::custom_workspace_binding; +#[cfg(test)] +use keyboard_bindings::{ + STATIC_DEFAULT_COMBOS, custom_workspace_binding, default_workspace_bindings, gpui_binding_combo, +}; mod documents; mod file_tree; mod key_actions; +mod launcher_menu; mod notifications; mod palette; mod pane_header; @@ -157,162 +161,9 @@ fn title_bar_panel_controls() -> gpui::Div { .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) } -/// 工作区静态默认绑定的 combo 集([`init`] 的镜像)。撤销已失效的自定义 -/// 注入时要排除:gpui 的 NoAction 打在静态默认键上会误杀基础功能。 -const STATIC_DEFAULT_COMBOS: &[&str] = &[ - "ctrl-shift-t", - "ctrl-shift-e", - "ctrl-shift-w", - "ctrl-shift-b", - "ctrl-,", - "ctrl-shift-p", - "ctrl-k", - "ctrl-shift-f", - "escape", - "ctrl-shift-d", - "ctrl-alt-shift-s", - "f2", - "ctrl-shift-enter", - "ctrl-alt-left", - "ctrl-alt-right", - "ctrl-alt-up", - "ctrl-alt-down", - "ctrl-tab", - "ctrl-shift-tab", - "ctrl-shift-pageup", - "ctrl-shift-pagedown", - "ctrl-shift-g", - "ctrl-=", - "ctrl-+", - "ctrl--", - "ctrl-0", - "ctrl-shift-c", - #[cfg(not(target_os = "macos"))] - "ctrl-c", - "ctrl-v", - "ctrl-shift-v", - "alt-enter", - "ctrl-shift-o", -]; - -/// `keybind=` 自定义表(两壳共读)中 config::Action → GPUI 工作区动作的 -/// 映射。仍跳过未接线的动作(prompt 跳转、搜索):编辑器可读写,这里不 -/// 注入。`CreateNewWindow` 复用同一个 GPUI App 和进程级 hook/runtime。 - -/// 存储格式 combo(`ctrl+shift+t`)→ gpui 绑定串(`ctrl-shift-t`)。键名 -/// 两套体系同构(小写命名键 + 单字符);digitN 折回数字,plus/minus 折回 -/// `+`/`-`(`+` 是存储分隔符,必须先占位再替换)。 -fn gpui_binding_combo(combo: &str) -> String { - combo - .replace("plus", "\u{1}") - .replace("minus", "\u{2}") - .replace('+', "-") - .replace("digit", "") - .replace('\u{1}', "+") - .replace('\u{2}', "-") -} - /// 注册工作区快捷键;在 `gpui_component::init` 之后调用一次。 pub fn init(cx: &mut App) { - cx.bind_keys(default_workspace_bindings()); - #[cfg(target_os = "macos")] - bind_macos_command_keys(cx); -} - -/// 工作区静态默认键位表;与 [`STATIC_DEFAULT_COMBOS`] 互为镜像。 -fn default_workspace_bindings() -> Vec { - [ - KeyBinding::new("ctrl-shift-t", NewTerminal, None), - KeyBinding::new("ctrl-alt-shift-s", recipes::OpenLayoutRecipes, None), - KeyBinding::new("ctrl-shift-e", NewWindow, None), - KeyBinding::new("ctrl-shift-w", CloseActiveTerminal, None), - KeyBinding::new("ctrl-shift-b", ToggleSidebar, None), - KeyBinding::new("ctrl-,", OpenSettings, None), - KeyBinding::new("ctrl-shift-p", ToggleCommandPalette, None), - KeyBinding::new("ctrl-k", ToggleShellPicker, None), - KeyBinding::new("ctrl-shift-f", ToggleFileTree, None), - // Esc 只在命令/Shell 面板打开时关面板。绑成 `None` 会在终端聚焦时 - // 抢走按键,CC/Codex 收不到 0x1b(旧壳无 overlay 时 Esc 一定进 PTY)。 - KeyBinding::new("escape", CloseCommandPalette, Some(PALETTE_KEY_CONTEXT)), - // 分屏(旧壳 nebula_key_bindings 同键位):ctrl+shift+d 左右、 - // ctrl+shift+s 上下、ctrl+shift+enter 缩放、ctrl+alt+方向切聚焦。 - KeyBinding::new("ctrl-shift-d", SplitRight, None), - KeyBinding::new("ctrl-shift-s", SplitDown, None), - // F2 重命名活动标签(旧壳同键位);右键菜单的键帽读的就是这条。 - KeyBinding::new("f2", RenameActiveTab, None), - KeyBinding::new("ctrl-shift-enter", ToggleZoom, None), - KeyBinding::new("ctrl-alt-left", FocusPaneLeft, None), - KeyBinding::new("ctrl-alt-right", FocusPaneRight, None), - KeyBinding::new("ctrl-alt-up", FocusPaneUp, None), - KeyBinding::new("ctrl-alt-down", FocusPaneDown, None), - KeyBinding::new("ctrl-tab", SelectNextTab, None), - KeyBinding::new("ctrl-shift-tab", SelectPreviousTab, None), - // 标签位置左右移动(WT 的 moveTab forward/backward;键位取 VS Code - // 的「移动编辑器」同构)。我们自己的回滚翻页只吃**不带 ctrl** 的 - // shift+pageup(view.rs 的滚动分支),两者不冲突。 - KeyBinding::new("ctrl-shift-pageup", MoveTabLeft, None), - KeyBinding::new("ctrl-shift-pagedown", MoveTabRight, None), - KeyBinding::new("ctrl-shift-g", ToggleGitPanel, None), - KeyBinding::new("ctrl-=", IncreaseFontSize, None), - KeyBinding::new("ctrl-+", IncreaseFontSize, None), - KeyBinding::new("ctrl--", DecreaseFontSize, None), - KeyBinding::new("ctrl-0", ResetFontSize, None), - KeyBinding::new("ctrl-shift-c", CopySelection, None), - // 复制优先(WT 语义):终端聚焦时有选区复制并清选区,无选区经 handler - // 的 `cx.propagate()` 落成 ^C。带终端上下文,重命名/输入框聚焦时 - // ctrl+c 归输入框自己(Input -> Copy)。 - #[cfg(not(target_os = "macos"))] - KeyBinding::new("ctrl-c", CopySelection, Some(crate::gpui_shell::terminal::KEY_CONTEXT)), - // 终端粘贴只在终端焦点路径命中。Input 自己带 `Input -> Paste`;这里若 - // 无上下文,会因注册更晚而抢走弹窗/设置页输入框的 Ctrl+V。 - KeyBinding::new("ctrl-v", PasteClipboard, Some(crate::gpui_shell::terminal::KEY_CONTEXT)), - KeyBinding::new( - "ctrl-shift-v", - PasteClipboard, - Some(crate::gpui_shell::terminal::KEY_CONTEXT), - ), - KeyBinding::new("ctrl-shift-v", gpui_component::input::Paste, Some("Input")), - KeyBinding::new("alt-enter", ToggleFullscreen, None), - KeyBinding::new("ctrl-shift-o", OpenQuickJump, None), - ] - .into() -} - -/// macOS 的原生修饰键是 ⌘:在 Ctrl 绑定之外**追加**一套 ⌘ 绑定,不替换。 -/// 追加而非替换有两个原因:Ctrl+Shift 组合在 Mac 终端里没有别的含义,留着 -/// 不碍事;而 ⌘C/⌘V 必须存在,否则 Mac 用户第一反应就是「复制粘贴坏了」。 -/// 终端里的 Ctrl+C 仍然是 SIGINT——这里只绑 ⌘,不碰 Ctrl 的语义。 -#[cfg(target_os = "macos")] -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-b", ToggleSidebar, None), - KeyBinding::new("cmd-,", OpenSettings, None), - KeyBinding::new("cmd-shift-p", ToggleCommandPalette, None), - KeyBinding::new("cmd-k", ToggleShellPicker, None), - KeyBinding::new("cmd-shift-f", ToggleFileTree, None), - KeyBinding::new("cmd-d", SplitRight, None), - KeyBinding::new("cmd-shift-d", SplitDown, None), - KeyBinding::new("cmd-shift-enter", ToggleZoom, None), - KeyBinding::new("cmd-alt-left", FocusPaneLeft, None), - KeyBinding::new("cmd-alt-right", FocusPaneRight, None), - KeyBinding::new("cmd-alt-up", FocusPaneUp, None), - KeyBinding::new("cmd-alt-down", FocusPaneDown, None), - KeyBinding::new("cmd-shift-]", SelectNextTab, None), - KeyBinding::new("cmd-shift-[", SelectPreviousTab, None), - KeyBinding::new("cmd-shift-g", ToggleGitPanel, None), - KeyBinding::new("cmd-=", IncreaseFontSize, None), - KeyBinding::new("cmd-+", IncreaseFontSize, None), - KeyBinding::new("cmd--", DecreaseFontSize, None), - KeyBinding::new("cmd-0", ResetFontSize, None), - KeyBinding::new("cmd-c", CopySelection, Some(crate::gpui_shell::terminal::KEY_CONTEXT)), - KeyBinding::new("cmd-v", PasteClipboard, Some(crate::gpui_shell::terminal::KEY_CONTEXT)), - KeyBinding::new("cmd-v", gpui_component::input::Paste, Some("Input")), - KeyBinding::new("cmd-ctrl-f", ToggleFullscreen, None), - KeyBinding::new("cmd-shift-o", OpenQuickJump, None), - ]); + keyboard_bindings::init(cx); } /// 一个终端 pane:视图实体 + 宿主订阅。id 即 `TerminalView::pane_id` @@ -852,6 +703,8 @@ pub struct NebulaWorkspace { /// Window-level Settings surface. It intentionally lives outside `tabs`: /// opening preferences must not alter tab order, session state, or PTYs. settings_surface: Option<(Entity, Subscription)>, + /// Presence of the singleton Settings tab, independent of the active page. + settings_tab_open: bool, settings_open: bool, /// Sidebar state sampled on entry. `None` means top-tab mode, where there /// is no left rail to fold and the setting must remain untouched. @@ -930,6 +783,8 @@ pub struct NebulaWorkspace { /// 三点 / Ctrl+K 打开的是旧壳 `PaletteMode::Profiles`,要画 /// 全部/SSH/Shell 芯片;Ctrl+Shift+P 的命令目录不走这条。 shell_picker_open: bool, + launcher_menu: Option, + launcher_admin_task: Option>, launcher_filter: crate::display::command_palette::LauncherFilter, /// `Some` 表示当前是统一 Quick Jump;值就是 provider scope。 /// 命令面板与 Shell picker 必须保持 `None`,避免跨入口泄漏筛选状态。 @@ -1157,6 +1012,7 @@ impl NebulaWorkspace { .saturating_add(1), active: 0, settings_surface: None, + settings_tab_open: false, settings_open: false, settings_restore_sidebar_collapsed: None, settings_restore_side_panel_open: false, @@ -1198,6 +1054,8 @@ impl NebulaWorkspace { vcs_discard_confirm: None, palette_override: None, shell_picker_open: false, + launcher_menu: None, + launcher_admin_task: None, launcher_filter: crate::display::command_palette::LauncherFilter::All, quick_jump_filter: None, command_palette_scroll: gpui::ScrollHandle::new(), @@ -1257,6 +1115,9 @@ impl NebulaWorkspace { windowing::WorkspaceStartup::NewTerminal { cwd } => { this.add_terminal_at(cwd, None, window, cx); }, + windowing::WorkspaceStartup::LaunchTerminal { launch, cwd } => { + this.add_terminal_with(launch, cwd, None, window, cx); + }, windowing::WorkspaceStartup::Empty => {}, } if let Some(ai_events) = ai_events { @@ -2166,59 +2027,6 @@ impl NebulaWorkspace { Session::new(active_out, tabs) } - /// Open Settings as a window-level page while preserving the active tab. - /// Side-tab mode folds its real left rail; top-tab mode has no such rail, - /// so touching `sidebar_collapsed` there would only create hidden state. - fn open_settings(&mut self, window: &mut Window, cx: &mut Context) { - if self.settings_open { - if let Some((view, _)) = self.settings_surface.as_ref() { - let focus = view.read(cx).focus_handle(cx); - window.defer(cx, move |window, cx| window.focus(&focus, cx)); - } - return; - } - - if self.settings_surface.is_none() { - let view = cx.new(|cx| SettingsPane::new(window, cx)); - let subscription = cx.subscribe_in(&view, window, Self::on_settings_event); - self.settings_surface = Some((view, subscription)); - } - - self.settings_open = true; - self.sync_settings_layout(); - - self.settings_restore_side_panel_open = self.side_panel.open; - if self.side_panel.open { - self.side_panel.open = false; - self.side_panel_anim_armed = true; - self.file_tree_menu = None; - } - - if let Some((view, _)) = self.settings_surface.as_ref() { - let focus = view.read(cx).focus_handle(cx); - window.defer(cx, move |window, cx| window.focus(&focus, cx)); - } - cx.notify(); - } - - fn close_settings(&mut self, window: &mut Window, cx: &mut Context) { - if !self.settings_open { - return; - } - self.leave_settings(window, cx); - if self.tabs.is_empty() { - windowing::close_empty_workspace_window(self.runtime_window_id, window, cx); - } - } - - fn toggle_settings(&mut self, window: &mut Window, cx: &mut Context) { - if self.settings_open { - self.close_settings(window, cx); - } else { - self.open_settings(window, cx); - } - } - /// 按视图实体反查 (tab 下标, pane id)。 fn locate_pane(&self, entity_id: gpui::EntityId) -> Option<(usize, u64)> { self.tabs.iter().enumerate().find_map(|(ix, tab)| match tab { @@ -2388,9 +2196,13 @@ impl NebulaWorkspace { } } - if self.tabs.is_empty() && !self.settings_open { - windowing::close_empty_workspace_window(self.runtime_window_id, window, cx); - return; + if self.tabs.is_empty() { + if self.settings_tab_open { + self.open_settings(window, cx); + } else { + windowing::close_empty_workspace_window(self.runtime_window_id, window, cx); + return; + } } if ix < self.active { self.active -= 1; @@ -2409,9 +2221,10 @@ impl NebulaWorkspace { } fn activate_tab(&mut self, ix: usize, window: &mut Window, cx: &mut Context) { - if self.settings_open { - self.close_settings(window, cx); + if ix >= self.tabs.len() { + return; } + self.leave_settings(window, cx); if ix < self.tabs.len() && ix != self.active { self.clear_reader_focus(cx); self.active = ix; @@ -2788,6 +2601,7 @@ impl NebulaWorkspace { } fn dismiss_palette_state(&mut self) { + self.launcher_menu = None; self.command_palette_open = false; self.palette_override = None; self.shell_picker_open = false; @@ -3541,7 +3355,13 @@ impl NebulaWorkspace { // 与旧壳一致:不用焦点描边,仅给非活动 pane 覆 30% // 黑色 veil。veil 只盖终端区——压暗标题条会把四个 // pane 的标题一起糊成灰。 - .when(!is_focused, |pane| pane.child(veil)), + .when( + !is_focused + && cx + .try_global::() + .is_none_or(|settings| settings.dim_inactive_panes), + |pane| pane.child(veil), + ), ) .into_any_element() }, @@ -4041,6 +3861,7 @@ impl Render for NebulaWorkspace { .on_action(cx.listener(|this, _: &FocusPaneDown, window, cx| { this.navigate_pane(SplitNav::Down, window, cx); })) + .on_action(cx.listener(Self::select_tab)) .on_action(cx.listener(|this, _: &SelectNextTab, window, cx| { this.select_adjacent_tab(true, window, cx); })) @@ -4344,6 +4165,7 @@ impl Render for NebulaWorkspace { root.child(menu) }) .when_some(self.render_tab_context_menu(), |root, menu| root.child(menu)) + .when_some(self.render_launcher_context_menu(), |root, menu| root.child(menu)) .when_some(self.render_selection_context_menu(), |root, menu| { root.child(menu) }) diff --git a/nebula_app/src/gpui_shell/workspace/key_actions.rs b/nebula_app/src/gpui_shell/workspace/key_actions.rs index 3d87833..2500393 100644 --- a/nebula_app/src/gpui_shell/workspace/key_actions.rs +++ b/nebula_app/src/gpui_shell/workspace/key_actions.rs @@ -1,4 +1,4 @@ -//! Workspace 快捷键动作实现(从 `workspace.rs` 拆出以守行数预算)。 +//! Workspace keyboard actions and their tab/focus transitions. use gpui::{Context, Window}; @@ -15,12 +15,43 @@ pub(super) fn move_target(active: usize, len: usize, right: bool) -> Option, + ) { + // Validate before activate_tab, which also leaves Settings. Missing + // numbered tabs must not change the active page or its focus. + let top = self.tabs_position == nebula_settings::TabsPositionName::Top; + let count = if top { self.top_tab_count() } else { self.tabs.len() }; + let Some(index) = action.index_for(count) else { return }; + if top { + self.activate_top_tab(index, window, cx); + } else { + self.activate_tab(index, window, cx); + } + // Selecting the current tab still returns focus from a side panel. + self.focus_active(window, cx); + } + pub(super) fn select_adjacent_tab( &mut self, next: bool, window: &mut Window, cx: &mut Context, ) { + if self.tabs_position == nebula_settings::TabsPositionName::Top { + let len = self.top_tab_count(); + if len == 0 { + return; + } + let current = if self.settings_open { self.tabs.len() } else { self.active }; + let next = if next { (current + 1) % len } else { (current + len - 1) % len }; + self.activate_top_tab(next, window, cx); + self.focus_active(window, cx); + return; + } let len = self.tabs.len(); if len == 0 { return; @@ -44,6 +75,9 @@ impl NebulaWorkspace { window: &mut Window, cx: &mut Context, ) { + if self.settings_open { + return; + } let Some(to) = move_target(self.active, self.tabs.len(), right) else { return }; let from = self.active; self.move_tab(from, to, window, cx); diff --git a/nebula_app/src/gpui_shell/workspace/keyboard_bindings.rs b/nebula_app/src/gpui_shell/workspace/keyboard_bindings.rs index 498c40c..8dd3357 100644 --- a/nebula_app/src/gpui_shell/workspace/keyboard_bindings.rs +++ b/nebula_app/src/gpui_shell/workspace/keyboard_bindings.rs @@ -1,42 +1,205 @@ -//! Registration and restoration of user keyboard overrides. +//! Workspace keyboard registration, shared action adaptation and user overrides. use super::*; +/// 工作区静态默认绑定的 combo 集([`init`] 的镜像)。撤销已失效的自定义 +/// 注入时要排除:gpui 的 NoAction 打在静态默认键上会误杀基础功能。 #[cfg(test)] -mod tests { - use super::*; - #[test] - fn cleared_shortcut_reaches_terminal_and_can_be_restored_without_restart() { +pub(super) const STATIC_DEFAULT_COMBOS: &[&str] = &[ + "ctrl-shift-t", + "ctrl-shift-e", + "ctrl-shift-w", + "ctrl-shift-b", + "ctrl-,", + "ctrl-shift-p", + "ctrl-k", + "ctrl-shift-f", + "escape", + "ctrl-shift-d", + "ctrl-alt-shift-s", + "f2", + "ctrl-shift-enter", + "ctrl-alt-left", + "ctrl-alt-right", + "ctrl-alt-up", + "ctrl-alt-down", + "ctrl-tab", + "ctrl-shift-tab", + "ctrl-shift-pageup", + "ctrl-shift-pagedown", + "ctrl-shift-g", + "ctrl-=", + "ctrl-+", + "ctrl--", + "ctrl-0", + "ctrl-shift-c", + #[cfg(not(target_os = "macos"))] + "ctrl-c", + "ctrl-v", + "ctrl-shift-v", + "alt-enter", + "ctrl-shift-o", +]; + +/// 存储格式 combo(`ctrl+shift+t`)→ gpui 绑定串(`ctrl-shift-t`)。键名 +/// 两套体系同构(小写命名键 + 单字符);digitN 折回数字,plus/minus 折回 +/// `+`/`-`(`+` 是存储分隔符,必须先占位再替换)。 +pub(super) fn gpui_binding_combo(combo: &str) -> String { + combo + .to_ascii_lowercase() + .replace("plus", "\u{1}") + .replace("minus", "\u{2}") + .replace('+', "-") + .replace("digit", "") + .replace('\u{1}', "+") + .replace('\u{2}', "-") +} + +/// 注册工作区快捷键;在 `gpui_component::init` 之后调用一次。 +pub(super) fn init(cx: &mut App) { + cx.bind_keys(default_workspace_bindings()); + #[cfg(target_os = "macos")] + bind_macos_command_keys(cx); +} + +/// 工作区静态默认键位表;与 [`STATIC_DEFAULT_COMBOS`] 互为镜像。 +pub(super) fn default_workspace_bindings() -> Vec { + let mut bindings = vec![ + KeyBinding::new("ctrl-shift-t", NewTerminal, None), + KeyBinding::new("ctrl-alt-shift-s", recipes::OpenLayoutRecipes, None), + KeyBinding::new("ctrl-shift-e", NewWindow, None), + KeyBinding::new("ctrl-shift-w", CloseActiveTerminal, None), + KeyBinding::new("ctrl-shift-b", ToggleSidebar, None), + KeyBinding::new("ctrl-,", OpenSettings, None), + KeyBinding::new("ctrl-shift-p", ToggleCommandPalette, None), + KeyBinding::new("ctrl-k", ToggleShellPicker, None), + KeyBinding::new("ctrl-shift-f", ToggleFileTree, None), + // Esc 只在命令/Shell 面板打开时关面板。绑成 `None` 会在终端聚焦时 + // 抢走按键,CC/Codex 收不到 0x1b(旧壳无 overlay 时 Esc 一定进 PTY)。 + KeyBinding::new("escape", CloseCommandPalette, Some(PALETTE_KEY_CONTEXT)), + // 分屏(旧壳 nebula_key_bindings 同键位):ctrl+shift+d 左右、 + // ctrl+shift+s 上下、ctrl+shift+enter 缩放、ctrl+alt+方向切聚焦。 + KeyBinding::new("ctrl-shift-d", SplitRight, None), + KeyBinding::new("ctrl-shift-s", SplitDown, None), + // F2 重命名活动标签(旧壳同键位);右键菜单的键帽读的就是这条。 + KeyBinding::new("f2", RenameActiveTab, None), + KeyBinding::new("ctrl-shift-enter", ToggleZoom, None), + KeyBinding::new("ctrl-alt-left", FocusPaneLeft, None), + KeyBinding::new("ctrl-alt-right", FocusPaneRight, None), + KeyBinding::new("ctrl-alt-up", FocusPaneUp, None), + KeyBinding::new("ctrl-alt-down", FocusPaneDown, None), + KeyBinding::new("ctrl-tab", SelectNextTab, None), + KeyBinding::new("ctrl-shift-tab", SelectPreviousTab, None), + // 移动标签与终端回滚翻页分开:回滚只处理不带 Ctrl 的 + // Shift+PageUp / PageDown,带 Ctrl 时移动当前标签。 + KeyBinding::new("ctrl-shift-pageup", MoveTabLeft, None), + KeyBinding::new("ctrl-shift-pagedown", MoveTabRight, None), + KeyBinding::new("ctrl-shift-g", ToggleGitPanel, None), + KeyBinding::new("ctrl-=", IncreaseFontSize, None), + KeyBinding::new("ctrl-+", IncreaseFontSize, None), + KeyBinding::new("ctrl--", DecreaseFontSize, None), + KeyBinding::new("ctrl-0", ResetFontSize, None), + KeyBinding::new("ctrl-shift-c", CopySelection, None), + // 复制优先:终端聚焦时有选区复制并清选区,无选区经 handler + // 的 `cx.propagate()` 落成 ^C。带终端上下文,重命名/输入框聚焦时 + // ctrl+c 归输入框自己(Input -> Copy)。 + #[cfg(not(target_os = "macos"))] + KeyBinding::new("ctrl-c", CopySelection, Some(crate::gpui_shell::terminal::KEY_CONTEXT)), + // 终端粘贴只在终端焦点路径命中。Input 自己带 `Input -> Paste`;这里若 + // 无上下文,会因注册更晚而抢走弹窗/设置页输入框的 Ctrl+V。 + KeyBinding::new("ctrl-v", PasteClipboard, Some(crate::gpui_shell::terminal::KEY_CONTEXT)), + KeyBinding::new( + "ctrl-shift-v", + PasteClipboard, + Some(crate::gpui_shell::terminal::KEY_CONTEXT), + ), + KeyBinding::new("ctrl-shift-v", gpui_component::input::Paste, Some("Input")), + KeyBinding::new("alt-enter", ToggleFullscreen, None), + KeyBinding::new("ctrl-shift-o", OpenQuickJump, None), + ]; + // Numeric shortcuts come from the same defaults shown by Settings and + // used by the legacy shell, including each platform's existing modifiers. + bindings.extend(crate::display::keymap::default_shortcuts().into_iter().filter_map( + |(combo, action)| { + let action = SelectTab::from_config(&action)?; + Some(KeyBinding::new(&gpui_binding_combo(&combo), action, None)) + }, + )); + bindings +} + +/// macOS 的原生修饰键是 ⌘:在 Ctrl 绑定之外**追加**一套 ⌘ 绑定,不替换。 +/// 追加而非替换有两个原因:Ctrl+Shift 组合在 Mac 终端里没有别的含义,留着 +/// 不碍事;而 ⌘C/⌘V 必须存在,否则 Mac 用户第一反应就是「复制粘贴坏了」。 +/// 终端里的 Ctrl+C 仍然是 SIGINT——这里只绑 ⌘,不碰 Ctrl 的语义。 +#[cfg(target_os = "macos")] +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-b", ToggleSidebar, None), + KeyBinding::new("cmd-,", OpenSettings, None), + KeyBinding::new("cmd-shift-p", ToggleCommandPalette, None), + KeyBinding::new("cmd-k", ToggleShellPicker, None), + KeyBinding::new("cmd-shift-f", ToggleFileTree, None), + KeyBinding::new("cmd-d", SplitRight, None), + KeyBinding::new("cmd-shift-d", SplitDown, None), + KeyBinding::new("cmd-shift-enter", ToggleZoom, None), + KeyBinding::new("cmd-alt-left", FocusPaneLeft, None), + KeyBinding::new("cmd-alt-right", FocusPaneRight, None), + KeyBinding::new("cmd-alt-up", FocusPaneUp, None), + KeyBinding::new("cmd-alt-down", FocusPaneDown, None), + KeyBinding::new("cmd-shift-]", SelectNextTab, None), + KeyBinding::new("cmd-shift-[", SelectPreviousTab, None), + KeyBinding::new("cmd-shift-g", ToggleGitPanel, None), + KeyBinding::new("cmd-=", IncreaseFontSize, None), + KeyBinding::new("cmd-+", IncreaseFontSize, None), + KeyBinding::new("cmd--", DecreaseFontSize, None), + KeyBinding::new("cmd-0", ResetFontSize, None), + KeyBinding::new("cmd-c", CopySelection, Some(crate::gpui_shell::terminal::KEY_CONTEXT)), + KeyBinding::new("cmd-v", PasteClipboard, Some(crate::gpui_shell::terminal::KEY_CONTEXT)), + KeyBinding::new("cmd-v", gpui_component::input::Paste, Some("Input")), + KeyBinding::new("cmd-ctrl-f", ToggleFullscreen, None), + KeyBinding::new("cmd-shift-o", OpenQuickJump, None), + ]); +} + +/// Typed GPUI adapter for the shared numbered/last-tab actions. The existing +/// keybind format remains the authority; this is not another persisted action. +#[derive(Clone, Debug, PartialEq, gpui::Action)] +#[action(namespace = nebula_workspace, no_json)] +pub(super) struct SelectTab { + index: Option, +} + +impl SelectTab { + fn from_config(action: &crate::config::Action) -> Option { use crate::config::Action; - use gpui::{KeyContext, Keymap, Keystroke}; - let contexts = [ - KeyContext::parse("Root").unwrap(), - KeyContext::parse(crate::gpui_shell::terminal::KEY_CONTEXT).unwrap(), - ]; - let input = [Keystroke::parse("ctrl-k").unwrap()]; - let original = custom_workspace_binding("ctrl+k", &Action::ToggleShellPicker).unwrap(); - let clear = workspace_binding_in_context( - "ctrl+k", - &Action::ReceiveChar, - Some(crate::gpui_shell::terminal::KEY_CONTEXT), - ) - .unwrap(); - let disabled = Keymap::new(vec![original.clone(), clear.clone()]); - let (bindings, pending) = disabled.bindings_for_input(&input, &contexts); - assert!(bindings.is_empty() && !pending, "No app action should consume the key"); - let restored = workspace_binding_in_context( - "ctrl+k", - &Action::ToggleShellPicker, - Some(crate::gpui_shell::terminal::KEY_CONTEXT), - ) - .unwrap(); - let restored = Keymap::new(vec![original, clear, restored]); - let (bindings, pending) = restored.bindings_for_input(&input, &contexts); - assert!(!pending); - assert!(bindings[0].action().as_any().is::()); + let index = match action { + Action::SelectTab1 => Some(0), + Action::SelectTab2 => Some(1), + Action::SelectTab3 => Some(2), + Action::SelectTab4 => Some(3), + Action::SelectTab5 => Some(4), + Action::SelectTab6 => Some(5), + Action::SelectTab7 => Some(6), + Action::SelectTab8 => Some(7), + Action::SelectTab9 => Some(8), + Action::SelectLastTab => None, + _ => return None, + }; + Some(Self { index }) + } + + pub(super) fn index_for(&self, tab_count: usize) -> Option { + self.index.or_else(|| tab_count.checked_sub(1)).filter(|&index| index < tab_count) } } +#[cfg(test)] +mod tests; + pub(super) fn custom_workspace_binding( combo: &str, action: &crate::config::Action, @@ -51,6 +214,9 @@ fn workspace_binding_in_context( ) -> Option { use crate::config::Action; let combo = gpui_binding_combo(combo); + if let Some(action) = SelectTab::from_config(action) { + return Some(KeyBinding::new(&combo, action, scope)); + } match action { Action::ToggleCommandPalette => Some(KeyBinding::new(&combo, ToggleCommandPalette, scope)), Action::ToggleShellPicker => Some(KeyBinding::new(&combo, ToggleShellPicker, scope)), @@ -92,7 +258,10 @@ fn workspace_binding_in_context( impl NebulaWorkspace { pub(super) fn apply_custom_keybinds(&mut self, cx: &mut Context) { - let raw = nebula_settings::keybind_pairs(); + self.update_keybinds(nebula_settings::keybind_pairs(), cx); + } + + fn update_keybinds(&mut self, raw: Vec<(String, String)>, cx: &mut Context) { let applied: Vec<_> = raw.iter().map(|(combo, _)| gpui_binding_combo(combo)).collect(); let defaults = crate::display::keymap::default_shortcuts(); let mut bindings = Vec::new(); diff --git a/nebula_app/src/gpui_shell/workspace/keyboard_bindings/tests.rs b/nebula_app/src/gpui_shell/workspace/keyboard_bindings/tests.rs new file mode 100644 index 0000000..f31baef --- /dev/null +++ b/nebula_app/src/gpui_shell/workspace/keyboard_bindings/tests.rs @@ -0,0 +1,301 @@ +use super::*; +#[test] +fn cleared_shortcut_reaches_terminal_and_can_be_restored_without_restart() { + use crate::config::Action; + use gpui::{KeyContext, Keymap, Keystroke}; + let contexts = [ + KeyContext::parse("Root").unwrap(), + KeyContext::parse(crate::gpui_shell::terminal::KEY_CONTEXT).unwrap(), + ]; + let input = [Keystroke::parse("ctrl-k").unwrap()]; + let original = custom_workspace_binding("ctrl+k", &Action::ToggleShellPicker).unwrap(); + let clear = workspace_binding_in_context( + "ctrl+k", + &Action::ReceiveChar, + Some(crate::gpui_shell::terminal::KEY_CONTEXT), + ) + .unwrap(); + let disabled = Keymap::new(vec![original.clone(), clear.clone()]); + let (bindings, pending) = disabled.bindings_for_input(&input, &contexts); + assert!(bindings.is_empty() && !pending, "No app action should consume the key"); + let restored = workspace_binding_in_context( + "ctrl+k", + &Action::ToggleShellPicker, + Some(crate::gpui_shell::terminal::KEY_CONTEXT), + ) + .unwrap(); + let restored = Keymap::new(vec![original, clear, restored]); + let (bindings, pending) = restored.bindings_for_input(&input, &contexts); + assert!(!pending); + assert!(bindings[0].action().as_any().is::()); +} + +#[cfg(feature = "gpui-test-support")] +mod dispatch { + use super::*; + use gpui::{FocusHandle, Keystroke, TestAppContext, VisualTestContext}; + use nebula_terminal::term::TermMode; + + fn press(combo: &str, cx: &mut VisualTestContext) { + let keystroke = Keystroke::parse(combo).unwrap(); + cx.simulate_event(KeyDownEvent { + keystroke: keystroke.clone(), + is_held: false, + prefer_character_input: false, + }); + cx.simulate_event(gpui::KeyUpEvent { keystroke }); + cx.run_until_parked(); + } + + fn open_workspace( + count: usize, + cx: &mut TestAppContext, + ) -> (tempfile::TempDir, Entity, VisualTestContext) { + let directory = tempfile::tempdir().unwrap(); + let paths: Vec<_> = (0..count) + .map(|index| { + let path = directory.path().join(format!("tab-{index}.txt")); + std::fs::write(&path, "fixture\n").unwrap(); + path + }) + .collect(); + let hub = crate::runtime_api::RuntimeHub::new(); + cx.update(|cx| { + gpui_component::init(cx); + crate::gpui_shell::math_view::register(cx); + crate::gpui_shell::file_editor::init(cx); + init(cx); + windowing::initialize(cx, hub.clone()); + }); + let mut workspace_out = None; + let (_, window) = cx.add_window_view(|window, cx| { + let workspace = cx.new(|cx| { + NebulaWorkspace::new( + window, + None, + None, + 1, + hub, + windowing::WorkspaceStartup::Empty, + windowing::WindowRole::Regular, + cx, + ) + }); + workspace.update(cx, |workspace, cx| { + // Exercise real workspace tabs without spawning a Shell or + // altering the machine's saved shortcuts/session. + workspace.update_keybinds(Vec::new(), cx); + for path in paths { + let view = cx + .new(|cx| crate::gpui_shell::code_tab::CodeTabView::new(path, window, cx)); + let subscription = cx.subscribe(&view, |_, _, _, _| {}); + workspace.insert_tab_at( + workspace.tabs.len(), + WorkspaceTab::Code { view, _subscription: subscription }, + TabMeta::default(), + ); + } + workspace.focus_active(window, cx); + }); + workspace_out = Some(workspace.clone()); + Root::new(workspace, window, cx) + }); + window.run_until_parked(); + (directory, workspace_out.unwrap(), window.clone()) + } + + fn assert_active( + workspace: &Entity, + index: usize, + cx: &mut VisualTestContext, + ) { + cx.update(|window, cx| { + let workspace = workspace.read(cx); + assert_eq!(workspace.active, index); + assert!(!workspace.settings_open); + let WorkspaceTab::Code { view, .. } = &workspace.tabs[index] else { + panic!("expected the selected fixture tab"); + }; + assert!(view.read(cx).focus_handle(cx).is_focused(window)); + }); + } + + #[gpui::test] + fn digits_switch_real_tabs_and_focus_in_both_tab_layouts(cx: &mut TestAppContext) { + let (_directory, workspace, mut cx) = open_workspace(10, cx); + for position in + [nebula_settings::TabsPositionName::Sidebar, nebula_settings::TabsPositionName::Top] + { + workspace.update(&mut cx, |workspace, cx| { + workspace.tabs_position = position; + workspace.tab_meta[8].has_bell = true; + cx.notify(); + }); + for modifier in ["ctrl", "alt"] { + for digit in (1..=9).rev() { + press(&format!("{modifier}-{digit}"), &mut cx); + assert_active(&workspace, digit - 1, &mut cx); + } + } + assert!(!workspace.read_with(&cx, |workspace, _| workspace.tab_meta[8].has_bell)); + } + } + + #[gpui::test] + fn missing_tab_preserves_settings_and_reselecting_a_tab_restores_focus( + cx: &mut TestAppContext, + ) { + let (_directory, workspace, mut cx) = open_workspace(3, cx); + cx.update(|window, cx| { + workspace.update(cx, |workspace, cx| workspace.open_settings(window, cx)); + }); + press("alt-9", &mut cx); + press("ctrl-4", &mut cx); + cx.update(|window, cx| { + let workspace = workspace.read(cx); + assert!(workspace.settings_open); + assert_eq!(workspace.active, 0); + let settings = &workspace.settings_surface.as_ref().unwrap().0; + assert!(settings.read(cx).focus_handle(cx).contains_focused(window, cx)); + }); + // The active index was already zero: this still has to leave Settings + // and return focus to the tab, rather than treating it as a no-op. + press("ctrl-1", &mut cx); + assert_active(&workspace, 0, &mut cx); + + cx.update(|window, cx| { + workspace.update(cx, |workspace, cx| workspace.begin_rename(0, window, cx)); + }); + press("alt-1", &mut cx); + assert_active(&workspace, 0, &mut cx); + } + + #[gpui::test] + fn numbered_shortcut_overrides_clear_and_restore_without_restart(cx: &mut TestAppContext) { + let (_directory, workspace, mut cx) = open_workspace(3, cx); + workspace.update(&mut cx, |workspace, cx| { + workspace.update_keybinds( + vec![ + ("ctrl+1".into(), "SelectTab3".into()), + ("alt+8".into(), "SelectLastTab".into()), + ], + cx, + ); + }); + press("ctrl-1", &mut cx); + assert_active(&workspace, 2, &mut cx); + press("ctrl-2", &mut cx); + press("alt-8", &mut cx); + assert_active(&workspace, 2, &mut cx); + + workspace.update(&mut cx, |workspace, cx| { + workspace.update_keybinds(vec![("ctrl+1".into(), "ReceiveChar".into())], cx); + }); + press("ctrl-1", &mut cx); + assert_active(&workspace, 2, &mut cx); + // Removing this override must find the shared display spelling + // ("Ctrl+1") and restore it despite the stored spelling ("ctrl+1"). + workspace.update(&mut cx, |workspace, cx| workspace.update_keybinds(Vec::new(), cx)); + press("ctrl-1", &mut cx); + assert_active(&workspace, 0, &mut cx); + } + + struct TerminalKeyProbe { + focus: FocusHandle, + selected: Option, + mode: TermMode, + input: Vec, + encoded: Vec, + } + + impl Render for TerminalKeyProbe { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + div() + .size_full() + .on_action(cx.listener(|this, action: &SelectTab, _, _| { + this.selected = action.index_for(10); + })) + .child( + div() + .size_full() + .key_context(crate::gpui_shell::terminal::KEY_CONTEXT) + .track_focus(&self.focus) + .on_key_down(cx.listener(|this, event: &KeyDownEvent, _, cx| { + this.input.push(event.keystroke.clone()); + if let Some(bytes) = crate::gpui_shell::terminal::keymap::encode( + &event.keystroke, + &this.mode, + ) { + this.encoded.extend(bytes); + cx.stop_propagation(); + } + })), + ) + } + } + + #[gpui::test] + fn numeric_actions_precede_terminal_encoding_and_unbound_digits_pass_through( + cx: &mut TestAppContext, + ) { + cx.update(|cx| { + gpui_component::init(cx); + init(cx); + }); + let mut probe_out = None; + let (_, cx) = cx.add_window_view(|window, cx| { + let probe = cx.new(|cx| TerminalKeyProbe { + focus: cx.focus_handle(), + selected: None, + mode: TermMode::empty(), + input: Vec::new(), + encoded: Vec::new(), + }); + let focus = probe.read(cx).focus.clone(); + focus.focus(window, cx); + probe_out = Some(probe.clone()); + Root::new(probe, window, cx) + }); + let probe = probe_out.unwrap(); + for mode in [ + TermMode::empty(), + TermMode::WIN32_INPUT_MODE, + TermMode::DISAMBIGUATE_ESC_CODES, + TermMode::REPORT_ALL_KEYS_AS_ESC, + ] { + probe.update(cx, |probe, _| probe.mode = mode); + for modifier in ["ctrl", "alt"] { + for digit in 1..=9 { + press(&format!("{modifier}-{digit}"), cx); + probe.read_with(cx, |probe, _| { + assert_eq!(probe.selected, Some(digit - 1)); + assert!(probe.input.is_empty(), "tab shortcuts must not reach PTY input"); + assert!(probe.encoded.is_empty()); + }); + } + } + } + // Plain digits and additional modifiers retain their terminal meaning. + for combo in ["1", "alt-0", "ctrl-alt-2", "ctrl-shift-2"] { + press(combo, cx); + probe.read_with(cx, |probe, _| { + assert_eq!(probe.selected, Some(8)); + assert_eq!(probe.input.last(), Some(&Keystroke::parse(combo).unwrap())); + }); + } + cx.update(|_, cx| { + cx.bind_keys([workspace_binding_in_context( + "alt+2", + &crate::config::Action::ReceiveChar, + Some(crate::gpui_shell::terminal::KEY_CONTEXT), + ) + .unwrap()]); + }); + press("alt-2", cx); + probe.read_with(cx, |probe, _| { + assert_eq!(probe.selected, Some(8)); + assert_eq!(probe.input.last(), Some(&Keystroke::parse("alt-2").unwrap())); + assert!(!probe.encoded.is_empty()); + }); + } +} diff --git a/nebula_app/src/gpui_shell/workspace/launcher_menu.rs b/nebula_app/src/gpui_shell/workspace/launcher_menu.rs new file mode 100644 index 0000000..0b83e94 --- /dev/null +++ b/nebula_app/src/gpui_shell/workspace/launcher_menu.rs @@ -0,0 +1,358 @@ +//! One context-menu host for the Shell/SSH launcher, with stable launch targets. + +use super::*; +use crate::gpui_shell::toast::{ToastKind, toast}; +use crate::i18n::Message; +use gpui::{Anchor, DismissEvent, Point, anchored, deferred}; +use gpui_component::menu::PopupMenuItem; + +pub(super) struct LauncherContextMenu { + menu: Entity, + position: Point, + _subscription: Subscription, +} + +#[derive(Clone)] +pub(super) enum LauncherTarget { + Shell(crate::shell_detect::DetectedShell), + Profile(crate::config::ui_config::Profile), + Ssh(String), +} + +impl LauncherTarget { + pub(super) fn from_action(action: &WorkspacePaletteAction) -> Option { + match action { + WorkspacePaletteAction::LaunchShell(shell) => Some(Self::Shell(shell.clone())), + WorkspacePaletteAction::LaunchProfile(profile) => Some(Self::Profile(profile.clone())), + WorkspacePaletteAction::LaunchSshHost(host) => Some(Self::Ssh(host.clone())), + _ => None, + } + } + + fn name(&self) -> &str { + match self { + Self::Shell(shell) => &shell.name, + Self::Profile(profile) => &profile.name, + Self::Ssh(host) => host, + } + } + + fn default_id(&self) -> Option { + match self { + Self::Shell(shell) => Some(shell.id.clone()), + Self::Profile(profile) => profile.settings_id(), + Self::Ssh(_) => None, + } + } + + fn local_launch(&self) -> Option { + match self { + Self::Shell(detected) => { + let shell = detected.shell(); + Some(crate::session::LaunchSession::Shell { + name: detected.name.clone(), + program: shell.program().to_owned(), + args: shell.args().to_vec(), + }) + }, + Self::Profile(profile) => { + Some(NebulaWorkspace::profile_launch_session(profile.clone())) + }, + Self::Ssh(_) => None, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum LauncherCommand { + OpenAdmin, + SetDefault, + Connect, + Edit, + Delete, +} + +impl LauncherCommand { + fn label(self) -> Message { + match self { + Self::OpenAdmin => Message::LauncherOpenAdmin, + Self::SetDefault => Message::LauncherSetDefault, + Self::Connect => Message::LauncherConnect, + Self::Edit => Message::LauncherEdit, + Self::Delete => Message::LauncherDelete, + } + } +} + +fn commands(target: &LauncherTarget) -> Vec { + if matches!(target, LauncherTarget::Ssh(_)) { + vec![LauncherCommand::Connect, LauncherCommand::Edit, LauncherCommand::Delete] + } else { + let mut commands = Vec::new(); + if crate::platform::elevation::SUPPORTED { + commands.push(LauncherCommand::OpenAdmin); + } + commands.push(LauncherCommand::SetDefault); + commands + } +} + +impl NebulaWorkspace { + pub(super) fn open_launcher_context_menu( + &mut self, + target: LauncherTarget, + position: Point, + window: &mut Window, + cx: &mut Context, + ) { + if !self.shell_picker_open || !self.command_palette_open { + return; + } + let current = crate::platform::shell::effective_shell_id( + cx.try_global::() + .and_then(|settings| settings.shell_id.as_deref()), + ); + let is_default = target.default_id().is_some_and(|id| id.eq_ignore_ascii_case(¤t)); + let pending = self.launcher_admin_task.is_some(); + let weak = cx.entity().downgrade(); + let language = workspace_ui_language(); + let menu = PopupMenu::build(window, cx, move |menu, _, _| { + commands(&target).into_iter().fold(menu.external_link_icon(false), |menu, command| { + let target = target.clone(); + let weak = weak.clone(); + let label = if command == LauncherCommand::SetDefault && is_default { + Message::LauncherCurrentDefault + } else { + command.label() + }; + menu.item( + PopupMenuItem::new(language.text(label)) + .disabled( + (command == LauncherCommand::SetDefault && is_default) + || (command == LauncherCommand::OpenAdmin && pending), + ) + .on_click(move |_, window, cx| { + if let Some(workspace) = weak.upgrade() { + workspace.update(cx, |workspace, cx| { + workspace.run_launcher_command( + command, + target.clone(), + window, + cx, + ) + }); + } + }), + ) + }) + }); + menu.focus_handle(cx).focus(window, cx); + let subscription = + cx.subscribe_in(&menu, window, |this, _, _: &DismissEvent, window, cx| { + this.launcher_menu = None; + if this.command_palette_open && this.shell_picker_open { + this.command_palette_input.read(cx).focus_handle(cx).focus(window, cx); + } + cx.notify(); + }); + self.launcher_menu = + Some(LauncherContextMenu { menu, position, _subscription: subscription }); + cx.notify(); + } + + pub(super) fn render_launcher_context_menu(&self) -> Option { + let state = self.launcher_menu.as_ref()?; + Some( + deferred( + anchored() + .position(state.position) + .anchor(Anchor::TopLeft) + .snap_to_window_with_margin(px(8.0)) + .child( + div() + .id("launcher-context-menu") + .debug_selector(|| "launcher-context-menu".to_owned()) + .child(state.menu.clone()), + ), + ) + .with_priority(2) + .into_any_element(), + ) + } + + fn run_launcher_command( + &mut self, + command: LauncherCommand, + target: LauncherTarget, + window: &mut Window, + cx: &mut Context, + ) { + self.launcher_menu = None; + match command { + LauncherCommand::SetDefault => self.set_launcher_default(&target, window, cx), + LauncherCommand::OpenAdmin => self.launch_administrator(&target, window, cx), + LauncherCommand::Connect => { + if let LauncherTarget::Ssh(host) = target { + self.dismiss_palette_state(); + self.add_ssh_terminal(host, window, cx); + } + }, + LauncherCommand::Edit | LauncherCommand::Delete => { + let LauncherTarget::Ssh(host) = target else { + return; + }; + self.dismiss_palette_state(); + self.open_settings(window, cx); + if let Some((pane, _)) = &self.settings_surface { + let edit = command == LauncherCommand::Edit; + let pane = pane.clone(); + window.defer(cx, move |window, cx| { + pane.update(cx, |pane, cx| { + pane.manage_launcher_ssh(host, edit, window, cx) + }); + }); + } + }, + } + if self.command_palette_open && self.shell_picker_open { + self.command_palette_input.read(cx).focus_handle(cx).focus(window, cx); + } + cx.notify(); + } + + fn set_launcher_default( + &mut self, + target: &LauncherTarget, + window: &mut Window, + cx: &mut Context, + ) { + let Some(id) = target.default_id() else { + return; + }; + let language = workspace_ui_language(); + if let Err(error) = nebula_settings::persist_keys(&[("shell", id.clone())]) { + toast( + window, + cx, + ToastKind::Warning, + language.format(Message::SettingsSaveFailed, &[("error", &error.to_string())]), + ); + return; + } + self.apply_runtime_settings(cx); + if let Some((pane, _)) = &self.settings_surface { + pane.update(cx, |pane, cx| pane.sync_launcher_shell(window, cx)); + } + if let Some(rows) = &mut self.palette_override { + for row in rows.iter_mut() { + let Some(target) = LauncherTarget::from_action(&row.action) else { + continue; + }; + let Some(row_id) = target.default_id() else { + continue; + }; + let selected = row_id.eq_ignore_ascii_case(&id); + row.group_order = if selected { 0 } else { 1 }; + row.group = language + .pick( + if selected { "推荐" } else { "所有 Shell" }, + if selected { "Recommended" } else { "All shells" }, + ) + .to_owned(); + } + rows.sort_by_key(|row| row.group_order); + } + self.command_palette_selected = self + .filtered_palette_rows(cx) + .iter() + .position(|row| { + LauncherTarget::from_action(&row.action) + .and_then(|target| target.default_id()) + .is_some_and(|row_id| row_id.eq_ignore_ascii_case(&id)) + }) + .unwrap_or(0); + toast( + window, + cx, + ToastKind::Success, + language.format(Message::LauncherDefaultSaved, &[("shell", target.name())]), + ); + } + + fn launch_administrator( + &mut self, + target: &LauncherTarget, + window: &mut Window, + cx: &mut Context, + ) { + use crate::platform::elevation; + if self.launcher_admin_task.is_some() { + return; + } + let Some(launch) = target.local_launch() else { + return; + }; + let cwd = Self::startup_directory().or_else(|| { + self.tabs + .get(self.active) + .and_then(WorkspaceTab::focused_view) + .and_then(|view| view.read(cx).local_cwd()) + }); + match elevation::is_elevated() { + Ok(true) => { + self.dismiss_palette_state(); + self.add_terminal_with(launch, cwd, None, window, cx); + return; + }, + Err(error) => { + toast( + window, + cx, + ToastKind::Warning, + workspace_ui_language() + .format(Message::LauncherAdminFailed, &[("error", &error.to_string())]), + ); + return; + }, + Ok(false) => {}, + } + let crate::gpui_shell::terminal::view::TerminalLaunch::Local { + shell: Some(shell), + cwd, + .. + } = Self::terminal_launch_from_session(&launch, cwd) + else { + return; + }; + let args = elevation::arguments(shell.program(), shell.args(), cwd.as_deref()); + let name = target.name().to_owned(); + self.close_command_palette(window, cx); + let task = cx.background_executor().spawn(async move { elevation::launch(&args) }); + self.launcher_admin_task = Some(cx.spawn_in(window, async move |this, cx| { + let result = task.await; + let _ = this.update_in(cx, |this, window, cx| { + this.launcher_admin_task = None; + let language = workspace_ui_language(); + let (kind, text) = match result { + Ok(elevation::LaunchOutcome::Started) => ( + ToastKind::Success, + language.format(Message::LauncherAdminStarted, &[("shell", &name)]), + ), + Ok(elevation::LaunchOutcome::Cancelled) => { + (ToastKind::Info, language.text(Message::LauncherAdminCancelled).to_owned()) + }, + Err(error) => ( + ToastKind::Warning, + language + .format(Message::LauncherAdminFailed, &[("error", &error.to_string())]), + ), + }; + toast(window, cx, kind, text); + cx.notify(); + }); + })); + } +} + +#[cfg(all(test, feature = "gpui-test-support"))] +mod tests; diff --git a/nebula_app/src/gpui_shell/workspace/launcher_menu/tests.rs b/nebula_app/src/gpui_shell/workspace/launcher_menu/tests.rs new file mode 100644 index 0000000..7fe6cc3 --- /dev/null +++ b/nebula_app/src/gpui_shell/workspace/launcher_menu/tests.rs @@ -0,0 +1,145 @@ +use super::*; +use gpui::{Modifiers, TestAppContext, VisualTestContext}; + +fn shell(id: &str) -> crate::shell_detect::DetectedShell { + crate::shell_detect::DetectedShell { + id: id.into(), + name: id.into(), + program: format!("{id}.exe"), + args: Vec::new(), + } +} + +fn open(cx: &mut TestAppContext) -> (Entity, VisualTestContext) { + let hub = crate::runtime_api::RuntimeHub::new(); + cx.update(|cx| { + gpui_component::init(cx); + crate::gpui_shell::math_view::register(cx); + crate::gpui_shell::file_editor::init(cx); + super::super::init(cx); + windowing::initialize(cx, hub.clone()); + }); + let mut output = None; + let (_, window) = cx.add_window_view(|window, cx| { + let view = cx.new(|cx| { + NebulaWorkspace::new( + window, + None, + None, + 1, + hub, + windowing::WorkspaceStartup::Empty, + windowing::WindowRole::Regular, + cx, + ) + }); + view.update(cx, |workspace, cx| { + workspace.palette_override = Some(shell_palette_rows( + vec![shell("cmd"), shell("pwsh")], + vec![], + ["test-host".into()], + "cmd", + workspace_ui_language(), + 1.0, + )); + workspace.command_palette_open = true; + workspace.shell_picker_open = true; + workspace.command_palette_input.read(cx).focus_handle(cx).focus(window, cx); + }); + output = Some(view.clone()); + Root::new(view, window, cx) + }); + let mut window = window.clone(); + window.simulate_resize(gpui::size(px(1200.0), px(900.0))); + draw(&mut window); + (output.unwrap(), window) +} + +fn draw(cx: &mut VisualTestContext) { + cx.run_until_parked(); + cx.update(|window, cx| { + window.refresh(); + window.draw(cx).clear(cx); + }); +} + +fn right_click(selector: &'static str, cx: &mut VisualTestContext) { + let bounds = cx.debug_bounds(selector).expect("launcher row"); + cx.simulate_mouse_down(bounds.center(), MouseButton::Right, Modifiers::default()); + cx.simulate_mouse_up(bounds.center(), MouseButton::Right, Modifiers::default()); + draw(cx); + assert!(cx.debug_bounds("launcher-context-menu").is_some()); +} + +#[test] +fn shell_and_ssh_commands_do_not_cross_categories() { + let ssh = LauncherTarget::Ssh("host".into()); + assert_eq!( + commands(&ssh), + [LauncherCommand::Connect, LauncherCommand::Edit, LauncherCommand::Delete] + ); + assert!(ssh.default_id().is_none() && ssh.local_launch().is_none()); + let local = LauncherTarget::Shell(shell("cmd")); + assert!(commands(&local).contains(&LauncherCommand::SetDefault)); + assert!(!commands(&local).contains(&LauncherCommand::Delete)); + assert_eq!( + commands(&local).contains(&LauncherCommand::OpenAdmin), + crate::platform::elevation::SUPPORTED + ); +} + +#[gpui::test] +fn right_click_opens_one_menu_without_launch_and_escape_restores_search(cx: &mut TestAppContext) { + let (workspace, mut cx) = open(cx); + for selector in ["command-palette-row-0", "command-palette-row-1", "command-palette-row-2"] { + right_click(selector, &mut cx); + workspace.read_with(&cx, |workspace, _| { + assert!(workspace.tabs.is_empty(), "right click must not create a session"); + assert!(workspace.command_palette_open && workspace.launcher_menu.is_some()); + }); + cx.simulate_keystrokes("escape"); + draw(&mut cx); + cx.update(|window, cx| { + let workspace = workspace.read(cx); + assert!(workspace.launcher_menu.is_none()); + assert!(workspace.command_palette_open, "Escape should only dismiss the context menu"); + assert!(workspace.command_palette_input.read(cx).focus_handle(cx).is_focused(window)); + }); + } +} + +#[gpui::test] +fn ssh_menu_keeps_its_target_when_launcher_rows_change(cx: &mut TestAppContext) { + let (workspace, mut cx) = open(cx); + right_click("command-palette-row-2", &mut cx); + workspace.update(&mut cx, |workspace, cx| { + workspace.palette_override.as_mut().unwrap().reverse(); + cx.notify(); + }); + // No item is initially selected; the second Down selects Edit. + cx.simulate_keystrokes("down down enter"); + draw(&mut cx); + workspace.read_with(&cx, |workspace, cx| { + assert!(workspace.tabs.is_empty()); + assert!(workspace.settings_open); + let pane = workspace.settings_surface.as_ref().unwrap().0.read(cx); + assert_eq!( + pane.ssh_editor.as_ref().unwrap().original_destination.as_deref(), + Some("test-host") + ); + }); +} + +#[gpui::test] +fn ssh_delete_only_arms_the_existing_confirmation(cx: &mut TestAppContext) { + let (workspace, mut cx) = open(cx); + right_click("command-palette-row-2", &mut cx); + cx.simulate_keystrokes("down down down enter"); + draw(&mut cx); + workspace.read_with(&cx, |workspace, cx| { + assert!(workspace.settings_open); + let pane = workspace.settings_surface.as_ref().unwrap().0.read(cx); + assert_eq!(pane.ssh_delete_confirm.as_deref(), Some("test-host")); + assert!(pane.ssh_delete_undo.is_none(), "no deletion before the user confirms"); + }); +} diff --git a/nebula_app/src/gpui_shell/workspace/palette.rs b/nebula_app/src/gpui_shell/workspace/palette.rs index ea09ef3..5749359 100644 --- a/nebula_app/src/gpui_shell/workspace/palette.rs +++ b/nebula_app/src/gpui_shell/workspace/palette.rs @@ -216,11 +216,16 @@ impl NebulaWorkspace { }; let action = item.action.clone(); + let context_target = self + .shell_picker_open + .then(|| launcher_menu::LauncherTarget::from_action(&action)) + .flatten(); let row_tooltip = item.label.clone(); let selected = ix == self.command_palette_selected; let hover_group = SharedString::from(format!("command-palette-row-hover-{ix}")); let row_content = h_flex() .id(SharedString::from(format!("command-palette-row-{ix}"))) + .debug_selector(move || format!("command-palette-row-{ix}")) .group(hover_group.clone()) .h(px(PALETTE_ROW_HEIGHT)) .flex_shrink_0() @@ -248,6 +253,21 @@ impl NebulaWorkspace { .child(item.label), ) .when_some(hint, |row, hint| row.child(hint)) + .when_some(context_target, |row, target| { + row.on_mouse_down( + MouseButton::Right, + cx.listener(move |this, event: &gpui::MouseDownEvent, window, cx| { + cx.stop_propagation(); + this.command_palette_selected = ix; + this.open_launcher_context_menu( + target.clone(), + event.position, + window, + cx, + ); + }), + ) + }) .on_click(cx.listener(move |this, _, window, cx| { this.command_palette_selected = ix; match action.clone() { diff --git a/nebula_app/src/gpui_shell/workspace/residency.rs b/nebula_app/src/gpui_shell/workspace/residency.rs index 5d22639..b1bde0c 100644 --- a/nebula_app/src/gpui_shell/workspace/residency.rs +++ b/nebula_app/src/gpui_shell/workspace/residency.rs @@ -986,6 +986,10 @@ impl NebulaWorkspace { window: &mut Window, cx: &mut Context, ) -> bool { + // Private administrator windows have no public resident discovery path. + if crate::platform::elevation::requires_isolation() { + return false; + } let runtime = nebula_settings::RuntimeSettings::load(); // 平台藏不了窗口就没有「驻留」可言:不拦关闭,否则 Unix 上会变成 // 既不关也不藏(设置页那一行也按同一能力位隐藏)。 diff --git a/nebula_app/src/gpui_shell/workspace/session_persistence.rs b/nebula_app/src/gpui_shell/workspace/session_persistence.rs index 5bdd782..581b9bd 100644 --- a/nebula_app/src/gpui_shell/workspace/session_persistence.rs +++ b/nebula_app/src/gpui_shell/workspace/session_persistence.rs @@ -8,11 +8,22 @@ pub(super) enum SaveReason { Quit, } -#[derive(Default)] pub(super) struct SessionPersistence { latest: Option, saved: Option, quitting: bool, + isolated: bool, +} + +impl Default for SessionPersistence { + fn default() -> Self { + Self { + latest: None, + saved: None, + quitting: false, + isolated: crate::platform::elevation::requires_isolation(), + } + } } impl SessionPersistence { @@ -26,6 +37,11 @@ impl SessionPersistence { reason: SaveReason, write: impl FnOnce(&Session) -> std::io::Result<()>, ) { + // An administrator window must not overwrite the ordinary workspace or + // cause its privileged shell command to be restored in a later session. + if self.isolated { + return; + } let retry_checkpoint = reason == SaveReason::Checkpoint && current.as_ref().is_none_or(|session| session.tabs.is_empty()); let candidate = if self.quitting { @@ -85,6 +101,22 @@ pub(super) fn combine_sessions( #[cfg(test)] mod tests { use super::*; + + #[test] + fn isolated_windows_never_write_shared_session_storage() { + let mut state = SessionPersistence { isolated: true, ..SessionPersistence::default() }; + for reason in [ + SaveReason::Checkpoint, + SaveReason::TabsClosed, + SaveReason::WindowClose, + SaveReason::Quit, + ] { + state.save_with(Some(sample_session()), reason, |_| { + panic!("privileged session reached shared storage") + }); + } + assert!(state.latest.is_none()); + } use crate::session::{AgentSession, LayoutSession, TabSession}; fn sample_session() -> Session { diff --git a/nebula_app/src/gpui_shell/workspace/settings_navigation.rs b/nebula_app/src/gpui_shell/workspace/settings_navigation.rs index bb4d4de..0055176 100644 --- a/nebula_app/src/gpui_shell/workspace/settings_navigation.rs +++ b/nebula_app/src/gpui_shell/workspace/settings_navigation.rs @@ -1,7 +1,10 @@ -use gpui::{App, Context, Window}; +use gpui::{App, AppContext as _, Context, Focusable as _, Window}; use nebula_settings::TabsPositionName; -use super::{NebulaWorkspace, SidebarActivity, TabPresentation}; +use super::{NebulaWorkspace, SettingsPane, SidebarActivity, TabPresentation, windowing}; + +#[cfg(all(test, feature = "gpui-test-support"))] +mod ui_tests; fn sidebar_state( settings_open: bool, @@ -17,6 +20,63 @@ fn sidebar_state( } impl NebulaWorkspace { + /// Open Settings as a window-level page while preserving the active tab. + /// Side-tab mode folds its real left rail; top-tab mode has no such rail, + /// so touching `sidebar_collapsed` there would only create hidden state. + pub(super) fn open_settings(&mut self, window: &mut Window, cx: &mut Context) { + if self.settings_open { + if let Some((view, _)) = self.settings_surface.as_ref() { + let focus = view.read(cx).focus_handle(cx); + window.defer(cx, move |window, cx| window.focus(&focus, cx)); + } + return; + } + + if self.settings_surface.is_none() { + let view = cx.new(|cx| SettingsPane::new(window, cx)); + let subscription = cx.subscribe_in(&view, window, Self::on_settings_event); + self.settings_surface = Some((view, subscription)); + } + + self.settings_tab_open = true; + self.settings_open = true; + self.sync_settings_layout(); + + self.settings_restore_side_panel_open = self.side_panel.open; + if self.side_panel.open { + self.side_panel.open = false; + self.side_panel_anim_armed = true; + self.file_tree_menu = None; + } + + if let Some((view, _)) = self.settings_surface.as_ref() { + let focus = view.read(cx).focus_handle(cx); + window.defer(cx, move |window, cx| window.focus(&focus, cx)); + } + cx.notify(); + } + + pub(super) fn close_settings(&mut self, window: &mut Window, cx: &mut Context) { + if !self.settings_tab_open { + return; + } + self.settings_tab_open = false; + self.leave_settings(window, cx); + self.reveal_active_tab(); + cx.notify(); + if self.tabs.is_empty() { + windowing::close_empty_workspace_window(self.runtime_window_id, window, cx); + } + } + + pub(super) fn toggle_settings(&mut self, window: &mut Window, cx: &mut Context) { + if self.settings_open { + self.close_settings(window, cx); + } else { + self.open_settings(window, cx); + } + } + pub(super) fn sync_settings_layout(&mut self) { let (collapsed, restore) = sidebar_state( self.settings_open, @@ -49,7 +109,7 @@ impl NebulaWorkspace { } pub(super) fn top_tab_count(&self) -> usize { - self.tabs.len() + usize::from(self.settings_open) + self.tabs.len() + usize::from(self.settings_tab_open) } pub(super) fn top_tab_presentation( @@ -80,7 +140,7 @@ impl NebulaWorkspace { window: &mut Window, cx: &mut Context, ) { - if self.settings_open && index == self.tabs.len() { + if self.settings_tab_open && index == self.tabs.len() { self.open_settings(window, cx); } else { self.activate_tab(index, window, cx); @@ -93,7 +153,7 @@ impl NebulaWorkspace { window: &mut Window, cx: &mut Context, ) { - if self.settings_open && index == self.tabs.len() { + if self.settings_tab_open && index == self.tabs.len() { self.close_settings(window, cx); } else { self.request_close_tab(index, window, cx); diff --git a/nebula_app/src/gpui_shell/workspace/settings_navigation/ui_tests.rs b/nebula_app/src/gpui_shell/workspace/settings_navigation/ui_tests.rs new file mode 100644 index 0000000..6788cc8 --- /dev/null +++ b/nebula_app/src/gpui_shell/workspace/settings_navigation/ui_tests.rs @@ -0,0 +1,162 @@ +use super::super::*; +use gpui::{Modifiers, TestAppContext, VisualTestContext}; + +fn open_workspace( + count: usize, + cx: &mut TestAppContext, +) -> (tempfile::TempDir, Entity, VisualTestContext) { + let directory = tempfile::tempdir().unwrap(); + let hub = crate::runtime_api::RuntimeHub::new(); + cx.update(|cx| { + gpui_component::init(cx); + cx.set_global(crate::gpui_shell::config::Settings::load(nebula_settings::ThemeName::Nord)); + crate::gpui_shell::math_view::register(cx); + crate::gpui_shell::file_editor::init(cx); + init(cx); + windowing::initialize(cx, hub.clone()); + }); + let mut workspace_out = None; + let (_, window) = cx.add_window_view(|window, cx| { + let workspace = cx.new(|cx| { + NebulaWorkspace::new( + window, + None, + None, + 1, + hub, + windowing::WorkspaceStartup::Empty, + windowing::WindowRole::Regular, + cx, + ) + }); + workspace.update(cx, |workspace, cx| { + workspace.tabs_position = nebula_settings::TabsPositionName::Top; + for index in 0..count { + let path = directory.path().join(format!("tab-{index}.txt")); + std::fs::write(&path, "fixture\n").unwrap(); + let view = + cx.new(|cx| crate::gpui_shell::code_tab::CodeTabView::new(path, window, cx)); + let subscription = cx.subscribe(&view, |_, _, _, _| {}); + workspace.insert_tab_at( + workspace.tabs.len(), + WorkspaceTab::Code { view, _subscription: subscription }, + TabMeta::default(), + ); + } + workspace.open_settings(window, cx); + }); + workspace_out = Some(workspace.clone()); + Root::new(workspace, window, cx) + }); + window.run_until_parked(); + window.simulate_resize(gpui::size(px(1280.0), px(900.0))); + workspace_out.as_ref().unwrap().update(window, |workspace, cx| { + workspace.tabs_position = nebula_settings::TabsPositionName::Top; + workspace.sync_settings_layout(); + cx.notify(); + }); + window.run_until_parked(); + (directory, workspace_out.unwrap(), window.clone()) +} + +fn click_tab(selector: &'static str, cx: &mut VisualTestContext) { + let bounds = tab_bounds(selector, cx); + cx.simulate_click(bounds.center(), Modifiers::default()); + cx.run_until_parked(); +} + +fn tab_bounds(selector: &'static str, cx: &mut VisualTestContext) -> gpui::Bounds { + cx.update(|window, cx| { + window.refresh(); + window.draw(cx).clear(cx); + }); + cx.debug_bounds(selector).expect("visible tab bounds") +} + +#[gpui::test] +fn switching_top_tabs_keeps_settings_reachable_until_explicit_close(cx: &mut TestAppContext) { + let (_directory, workspace, mut cx) = open_workspace(2, cx); + let settings_id = workspace.read_with(&cx, |workspace, _| { + assert_eq!(workspace.tabs_position, nebula_settings::TabsPositionName::Top); + assert_eq!(workspace.top_tab_count(), 3); + workspace.settings_surface.as_ref().unwrap().0.entity_id() + }); + for (index, selector) in [(0, "top-tab-0"), (1, "top-tab-1"), (0, "top-tab-0")] { + click_tab(selector, &mut cx); + workspace.read_with(&cx, |workspace, _| { + assert!(!workspace.settings_open); + assert_eq!(workspace.active, index); + assert_eq!(workspace.top_tab_count(), 3, "switching must retain the settings tab"); + }); + click_tab("top-tab-2", &mut cx); + workspace.read_with(&cx, |workspace, _| { + assert!(workspace.settings_open); + assert_eq!(workspace.top_tab_count(), 3); + assert_eq!(workspace.settings_surface.as_ref().unwrap().0.entity_id(), settings_id); + }); + } + click_tab("top-tab-0", &mut cx); + let bounds = tab_bounds("top-tab-2", &mut cx); + cx.simulate_mouse_down(bounds.center(), gpui::MouseButton::Middle, Modifiers::default()); + cx.simulate_mouse_up(bounds.center(), gpui::MouseButton::Middle, Modifiers::default()); + cx.run_until_parked(); + workspace.read_with(&cx, |workspace, _| { + assert!(!workspace.settings_open); + assert_eq!(workspace.active, 0); + assert_eq!(workspace.top_tab_count(), 2); + }); +} + +#[gpui::test] +fn keyboard_navigation_can_return_to_background_settings(cx: &mut TestAppContext) { + let (_directory, workspace, mut cx) = open_workspace(2, cx); + for shortcut in ["ctrl-1", "alt-1"] { + cx.simulate_keystrokes(shortcut); + assert!(!workspace.read_with(&cx, |workspace, _| workspace.settings_open)); + cx.simulate_keystrokes("ctrl-3"); + assert!(workspace.read_with(&cx, |workspace, _| workspace.settings_open)); + cx.simulate_keystrokes("ctrl-tab"); + assert!(!workspace.read_with(&cx, |workspace, _| workspace.settings_open)); + cx.simulate_keystrokes("ctrl-shift-tab"); + assert!(workspace.read_with(&cx, |workspace, _| workspace.settings_open)); + } + cx.simulate_keystrokes("ctrl-shift-w"); + assert_eq!(workspace.read_with(&cx, |workspace, _| workspace.top_tab_count()), 2); + cx.simulate_keystrokes("ctrl-3"); + assert!(!workspace.read_with(&cx, |workspace, _| workspace.settings_open)); +} + +#[gpui::test] +fn settings_survives_overflow_and_layout_changes(cx: &mut TestAppContext) { + let (_directory, workspace, mut cx) = open_workspace(12, cx); + cx.simulate_resize(gpui::size(px(760.0), px(900.0))); + cx.simulate_keystrokes("ctrl-1"); + for position in + [nebula_settings::TabsPositionName::Sidebar, nebula_settings::TabsPositionName::Top] + { + workspace.update(&mut cx, |workspace, cx| { + workspace.tabs_position = position; + workspace.sync_settings_layout(); + cx.notify(); + }); + assert_eq!(workspace.read_with(&cx, |workspace, _| workspace.top_tab_count()), 13); + } + cx.simulate_keystrokes("ctrl-shift-tab"); + let settings = tab_bounds("top-tab-12", &mut cx); + assert!(settings.origin.x >= px(0.0) && settings.right() <= px(760.0)); + workspace.read_with(&cx, |workspace, _| assert!(workspace.settings_open)); +} + +#[gpui::test] +fn closing_last_regular_tab_keeps_settings_until_it_is_closed(cx: &mut TestAppContext) { + let (_directory, workspace, mut cx) = open_workspace(1, cx); + click_tab("top-tab-0", &mut cx); + cx.simulate_keystrokes("ctrl-shift-w"); + workspace.read_with(&cx, |workspace, _| { + assert!(workspace.tabs.is_empty()); + assert!(workspace.settings_open); + assert_eq!(workspace.top_tab_count(), 1); + }); + cx.simulate_keystrokes("ctrl-shift-w"); + assert!(cx.read(|cx| cx.windows().is_empty())); +} diff --git a/nebula_app/src/gpui_shell/workspace/top_tabs.rs b/nebula_app/src/gpui_shell/workspace/top_tabs.rs index f9968fc..f5d369d 100644 --- a/nebula_app/src/gpui_shell/workspace/top_tabs.rs +++ b/nebula_app/src/gpui_shell/workspace/top_tabs.rs @@ -167,8 +167,12 @@ impl NebulaWorkspace { let items_running = std::cell::Cell::new(false); let items = (0..self.top_tab_count()) .map(|ix| { - let settings_navigation = self.settings_open && ix == self.tabs.len(); - let active = settings_navigation || (!self.settings_open && ix == self.active); + let settings_navigation = self.settings_tab_open && ix == self.tabs.len(); + let active = if settings_navigation { + self.settings_open + } else { + !self.settings_open && ix == self.active + }; let TabPresentation { title, is_settings, @@ -254,6 +258,7 @@ impl NebulaWorkspace { let row = h_flex() .id(("top-tab", ix)) + .debug_selector(|| format!("top-tab-{ix}")) .group(hover_group.clone()) .relative() .w(px(tab_w)) diff --git a/nebula_app/src/gpui_shell/workspace/windowing.rs b/nebula_app/src/gpui_shell/workspace/windowing.rs index 3cb2442..0fb0f58 100644 --- a/nebula_app/src/gpui_shell/workspace/windowing.rs +++ b/nebula_app/src/gpui_shell/workspace/windowing.rs @@ -43,6 +43,7 @@ use crate::runtime_api::{ pub(crate) enum WorkspaceStartup { RestoreOrDefault, NewTerminal { cwd: Option }, + LaunchTerminal { cwd: Option, launch: crate::session::LaunchSession }, Empty, } @@ -250,11 +251,13 @@ pub(crate) fn open_initial_window( ai_events: std::sync::mpsc::Receiver, shell_events: std::sync::mpsc::Receiver, initial_cwd: Option, + initial_command: Option, ) { - let startup = match initial_cwd { - Some(cwd) => WorkspaceStartup::NewTerminal { cwd: Some(cwd) }, - None => WorkspaceStartup::RestoreOrDefault, - }; + let startup = initial_startup( + initial_cwd, + initial_command, + crate::platform::elevation::requires_isolation(), + ); open_workspace_window( cx, startup, @@ -266,6 +269,67 @@ pub(crate) fn open_initial_window( .expect("failed to open Pebrel GPUI window"); } +fn initial_startup( + cwd: Option, + command: Option, + isolated: bool, +) -> WorkspaceStartup { + if let Some(command) = command { + let program = command.program().to_owned(); + let name = program.rsplit(['/', '\\']).next().unwrap_or(&program).to_owned(); + return WorkspaceStartup::LaunchTerminal { + cwd, + launch: crate::session::LaunchSession::Shell { + name, + program, + args: command.args().to_vec(), + }, + }; + } + if cwd.is_some() || isolated { + WorkspaceStartup::NewTerminal { cwd } + } else { + WorkspaceStartup::RestoreOrDefault + } +} + +#[cfg(test)] +mod startup_tests { + use super::*; + + #[test] + fn explicit_program_is_kept_with_its_arguments_and_directory() { + let command = crate::config::ui_config::Program::WithArgs { + program: "shell.exe".into(), + args: vec!["--literal=two words".into()], + }; + let cwd = Some(PathBuf::from("C:/work area")); + let WorkspaceStartup::LaunchTerminal { launch, cwd: actual_cwd } = + initial_startup(cwd.clone(), Some(command), true) + else { + panic!("explicit launch was discarded"); + }; + assert_eq!(actual_cwd, cwd); + assert_eq!( + launch, + crate::session::LaunchSession::Shell { + name: "shell.exe".into(), + program: "shell.exe".into(), + args: vec!["--literal=two words".into()] + } + ); + } + + #[test] + fn privileged_startup_never_restores_the_ordinary_session() { + assert!(matches!(initial_startup(None, None, false), WorkspaceStartup::RestoreOrDefault)); + assert!(matches!( + initial_startup(None, None, true), + WorkspaceStartup::NewTerminal { cwd: None } + )); + } +} + pub(super) fn open_recipe_window(session: crate::session::Session, cx: &mut App) { let result = open_workspace_window(cx, WorkspaceStartup::Empty, None, None, true, WindowRole::Regular); @@ -1470,6 +1534,9 @@ impl NebulaWorkspace { } else { let _ = source_handle.update(cx, move |_, source_window, cx| { let _ = source.update(cx, |source, cx| { + if source.tabs.is_empty() && source.settings_tab_open { + source.open_settings(source_window, cx); + } source.reveal_active_tab(); source.focus_active(source_window, cx); source.sync_side_panel_to_active(true, cx); @@ -1506,7 +1573,7 @@ impl NebulaWorkspace { if !self.tabs.is_empty() { self.active = self.active.min(self.tabs.len() - 1); } - let source_became_empty = self.tabs.is_empty(); + let source_became_empty = self.tabs.is_empty() && !self.settings_tab_open; Some(DetachedTerminalTab { tab, meta, diff --git a/nebula_app/src/main.rs b/nebula_app/src/main.rs index 864983c..f892b38 100644 --- a/nebula_app/src/main.rs +++ b/nebula_app/src/main.rs @@ -239,7 +239,7 @@ fn main() -> Result<(), Box> { if try_hand_over_to_resident(&options) { return Ok(()); } - gpui_shell::run_shell(initial_cwd); + gpui_shell::run_shell(initial_cwd, options.window_options.terminal_options.command()); return Ok(()); } @@ -519,6 +519,9 @@ fn wants_gpui_shell(options: &Options) -> bool { /// 驻留进程,再 `tab.new`。GPUI 与 winit 共用,避免第二份进程无声退出。 #[cfg(windows)] fn try_hand_over_to_resident(options: &Options) -> bool { + if platform::elevation::requires_isolation() { + return false; + } let has_command = options.window_options.terminal_options.command().is_some(); let launch_dir = options .window_options diff --git a/nebula_app/src/platform/elevation.rs b/nebula_app/src/platform/elevation.rs new file mode 100644 index 0000000..004f1ef --- /dev/null +++ b/nebula_app/src/platform/elevation.rs @@ -0,0 +1,153 @@ +//! Windows UAC launch adapter. The requested program is always hosted by Pebrel. + +use std::ffi::{OsStr, OsString}; +use std::io; +use std::path::Path; + +pub(crate) const SUPPORTED: bool = cfg!(windows); + +/// A failed token query must not publish a potentially privileged control plane. +pub(crate) fn requires_isolation() -> bool { + is_elevated().unwrap_or(true) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum LaunchOutcome { + Started, + Cancelled, +} + +/// CLI arguments, rather than a shell command: shell metacharacters stay literal. +pub(crate) fn arguments(program: &str, args: &[String], cwd: Option<&Path>) -> Vec { + let mut result = vec![OsString::from("--gpui")]; + if let Some(cwd) = cwd { + result.push("--working-directory".into()); + result.push(cwd.as_os_str().to_owned()); + } + result.push("-e".into()); + result.push(program.into()); + result.extend(args.iter().map(OsString::from)); + result +} + +#[cfg(windows)] +pub(crate) fn is_elevated() -> io::Result { + use std::sync::OnceLock; + static ELEVATED: OnceLock> = OnceLock::new(); + ELEVATED.get_or_init(query_token).clone().map_err(io::Error::from_raw_os_error) +} + +#[cfg(windows)] +fn query_token() -> Result { + use windows_sys::Win32::Foundation::{CloseHandle, GetLastError}; + use windows_sys::Win32::Security::{ + GetTokenInformation, TOKEN_ELEVATION, TOKEN_QUERY, TokenElevation, + }; + use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; + + // SAFETY: buffers have their declared sizes; the token is closed on every path. + unsafe { + let mut token = std::ptr::null_mut(); + if OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) == 0 { + return Err(GetLastError() as i32); + } + let mut info: TOKEN_ELEVATION = std::mem::zeroed(); + let mut returned = 0; + let ok = GetTokenInformation( + token, + TokenElevation, + (&mut info as *mut TOKEN_ELEVATION).cast(), + std::mem::size_of::() as u32, + &mut returned, + ); + let error = GetLastError(); + CloseHandle(token); + if ok == 0 { Err(error as i32) } else { Ok(info.TokenIsElevated != 0) } + } +} + +#[cfg(not(windows))] +pub(crate) fn is_elevated() -> io::Result { + Ok(false) +} + +#[cfg(windows)] +fn quoted_argument(argument: &OsStr) -> io::Result> { + use std::os::windows::ffi::OsStrExt; + let mut result = vec![b'"' as u16]; + let mut backslashes = 0; + for ch in argument.encode_wide() { + if ch == 0 { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "argument contains NUL")); + } + if ch == b'\\' as u16 { + backslashes += 1; + continue; + } + let count = if ch == b'"' as u16 { backslashes * 2 + 1 } else { backslashes }; + result.extend(std::iter::repeat_n(b'\\' as u16, count)); + result.push(ch); + backslashes = 0; + } + result.extend(std::iter::repeat_n(b'\\' as u16, backslashes * 2)); + result.push(b'"' as u16); + Ok(result) +} + +/// Runs on a worker: UAC may wait for the user and must not block the UI loop. +#[cfg(windows)] +pub(crate) fn launch(args: &[OsString]) -> io::Result { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Foundation::{ERROR_CANCELLED, GetLastError}; + use windows_sys::Win32::System::Com::{ + COINIT_APARTMENTTHREADED, CoInitializeEx, CoUninitialize, + }; + use windows_sys::Win32::UI::Shell::{ + SEE_MASK_FLAG_NO_UI, SEE_MASK_NOASYNC, SHELLEXECUTEINFOW, ShellExecuteExW, + }; + use windows_sys::Win32::UI::WindowsAndMessaging::SW_SHOWNORMAL; + + let executable = std::env::current_exe()?; + let file: Vec = executable.as_os_str().encode_wide().chain(Some(0)).collect(); + let mut parameters = Vec::new(); + for argument in args { + if !parameters.is_empty() { + parameters.push(b' ' as u16); + } + parameters.extend(quoted_argument(argument)?); + } + parameters.push(0); + let verb: Vec = "runas\0".encode_utf16().collect(); + // SAFETY: all strings and the initialized structure outlive ShellExecuteExW. + // SEE_MASK_NOASYNC completes the shell's launch handshake before COM teardown. + unsafe { + let com = CoInitializeEx(std::ptr::null(), COINIT_APARTMENTTHREADED as u32); + let mut info: SHELLEXECUTEINFOW = std::mem::zeroed(); + info.cbSize = std::mem::size_of::() as u32; + info.fMask = SEE_MASK_NOASYNC | SEE_MASK_FLAG_NO_UI; + info.lpVerb = verb.as_ptr(); + info.lpFile = file.as_ptr(); + info.lpParameters = parameters.as_ptr(); + info.nShow = SW_SHOWNORMAL; + let ok = ShellExecuteExW(&mut info); + let error = GetLastError(); + if com >= 0 { + CoUninitialize(); + } + if ok != 0 { + Ok(LaunchOutcome::Started) + } else if error == ERROR_CANCELLED { + Ok(LaunchOutcome::Cancelled) + } else { + Err(io::Error::from_raw_os_error(error as i32)) + } + } +} + +#[cfg(not(windows))] +pub(crate) fn launch(_args: &[OsString]) -> io::Result { + Err(io::Error::new(io::ErrorKind::Unsupported, "UAC is only available on Windows")) +} + +#[cfg(test)] +mod tests; diff --git a/nebula_app/src/platform/elevation/tests.rs b/nebula_app/src/platform/elevation/tests.rs new file mode 100644 index 0000000..ba8c9a0 --- /dev/null +++ b/nebula_app/src/platform/elevation/tests.rs @@ -0,0 +1,65 @@ +use super::*; + +#[test] +fn launch_arguments_target_pebrel_cli_and_preserve_selected_shell() { + let shell_args = vec!["--literal".into(), "$(value); `echo nope`".into()]; + let args = arguments("shell path.exe", &shell_args, Some(Path::new("C:\\work area\\"))); + assert_eq!( + args, + [ + "--gpui", + "--working-directory", + "C:\\work area\\", + "-e", + "shell path.exe", + "--literal", + "$(value); `echo nope`" + ] + .map(OsString::from) + ); + assert_eq!(arguments("pwsh.exe", &[], None), ["--gpui", "-e", "pwsh.exe"].map(OsString::from)); +} + +#[cfg(windows)] +#[test] +fn native_windows_argument_parser_round_trips_quotes_paths_and_metacharacters() { + use std::os::windows::ffi::OsStringExt; + use windows_sys::Win32::Foundation::LocalFree; + use windows_sys::Win32::UI::Shell::CommandLineToArgvW; + + let inputs = [ + "", + "simple", + "C:\\Program Files\\shell.exe", + "C:\\工作区\\", + "a\"b", + "x\\\"y", + "$(echo hidden); `literal` & | < > %PATH%", + "--option=two words", + ]; + let mut command: Vec = "pebrel.exe".encode_utf16().collect(); + for input in inputs { + command.push(b' ' as u16); + command.extend(quoted_argument(OsStr::new(input)).unwrap()); + } + command.push(0); + // SAFETY: the parser returns `count` NUL-terminated strings; LocalFree owns cleanup. + unsafe { + let mut count = 0; + let argv = CommandLineToArgvW(command.as_ptr(), &mut count); + assert!(!argv.is_null()); + let parsed: Vec<_> = std::slice::from_raw_parts(argv, count as usize) + .iter() + .map(|value| { + let mut len = 0; + while *value.add(len) != 0 { + len += 1; + } + OsString::from_wide(std::slice::from_raw_parts(*value, len)) + }) + .collect(); + LocalFree(argv.cast()); + assert_eq!(&parsed[1..], inputs.map(OsString::from)); + } + assert!(quoted_argument(OsStr::new("nul\0suffix")).is_err()); +} diff --git a/nebula_app/src/platform/mod.rs b/nebula_app/src/platform/mod.rs index a49c7db..b6c19cd 100644 --- a/nebula_app/src/platform/mod.rs +++ b/nebula_app/src/platform/mod.rs @@ -13,6 +13,7 @@ pub(crate) mod ai_session_identity; pub mod capabilities; pub mod credentials; pub mod dirs; +pub(crate) mod elevation; pub(crate) mod environment; pub(crate) mod file_drag; pub mod file_manager; diff --git a/nebula_app/src/runtime_api.rs b/nebula_app/src/runtime_api.rs index aeb8229..bfa7c18 100644 --- a/nebula_app/src/runtime_api.rs +++ b/nebula_app/src/runtime_api.rs @@ -28,6 +28,7 @@ pub(crate) use command::{ }; #[cfg(feature = "legacy-shell")] pub use server::dispatch_prompt; +pub(crate) use server::{ENDPOINT_ENV, apply_child_endpoint}; use server::{Endpoint, endpoint_addr, read_endpoint}; pub use server::{ RuntimeServer, try_open_default_tab_existing, try_open_directory_existing, diff --git a/nebula_app/src/runtime_api/server.rs b/nebula_app/src/runtime_api/server.rs index 27b2012..f7d6775 100644 --- a/nebula_app/src/runtime_api/server.rs +++ b/nebula_app/src/runtime_api/server.rs @@ -8,6 +8,29 @@ pub(super) struct Endpoint { pub(super) token: String, } +pub(crate) const ENDPOINT_ENV: &str = "PEBREL_RUNTIME_ENDPOINT"; +static CHILD_ENDPOINT: Mutex> = Mutex::new(None); + +/// Private instances publish discovery only to their own local PTY children. +pub(crate) fn apply_child_endpoint(env: &mut std::collections::HashMap) { + env.retain(|key, _| !key.eq_ignore_ascii_case(ENDPOINT_ENV)); + if let Some(endpoint) = + CHILD_ENDPOINT.lock().unwrap_or_else(|error| error.into_inner()).as_ref() + { + env.insert(ENDPOINT_ENV.to_owned(), format!("{} {}", endpoint.port, endpoint.token)); + } +} + +fn parse_endpoint(data: &str) -> Option { + let mut parts = data.split_whitespace(); + let port = parts.next()?.parse().ok()?; + let token = parts.next()?; + if port == 0 || token.len() != 32 || !token.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return None; + } + Some(Endpoint { port, token: token.to_owned() }) +} + fn port_file() -> PathBuf { crate::display::nebula_data_dir().join("runtime.port") } @@ -17,13 +40,22 @@ fn legacy_port_file() -> PathBuf { } pub(super) fn read_endpoint() -> Option { + // A hosted instance owns its endpoint even when launched from another + // Pebrel terminal. Child CLI processes have no server and use the env below. + if let Some(endpoint) = CHILD_ENDPOINT.lock().unwrap_or_else(|error| error.into_inner()).clone() + { + return Some(endpoint); + } + if let Some(value) = std::env::var_os(ENDPOINT_ENV) { + // An invalid explicit endpoint must not silently select another instance. + return value.to_str().and_then(parse_endpoint); + } read_endpoint_from(port_file()) } fn read_endpoint_from(path: PathBuf) -> Option { let data = std::fs::read_to_string(path).ok()?; - let mut parts = data.split_whitespace(); - Some(Endpoint { port: parts.next()?.parse().ok()?, token: parts.next()?.to_owned() }) + parse_endpoint(&data) } pub(super) fn endpoint_addr(endpoint: &Endpoint) -> SocketAddr { @@ -92,6 +124,13 @@ fn try_open_tab_existing(dir: Option<&std::path::Path>) -> bool { } fn legacy_request(verb: &str) -> Option<()> { + let local = CHILD_ENDPOINT.lock().unwrap_or_else(|error| error.into_inner()).clone(); + if let Some(endpoint) = local { + return legacy_request_to(verb, &endpoint); + } + if std::env::var_os(ENDPOINT_ENV).is_some() { + return read_endpoint().and_then(|endpoint| legacy_request_to(verb, &endpoint)); + } read_endpoint() .and_then(|endpoint| legacy_request_to(verb, &endpoint)) // 已运行的 pre-v1 版本只发布 mux.port;升级期间仍允许普通启动交接。 @@ -114,8 +153,8 @@ fn legacy_request_to(verb: &str, endpoint: &Endpoint) -> Option<()> { /// Resident versioned runtime API server. pub struct RuntimeServer { endpoint: Endpoint, - port_file: PathBuf, - _owner_lock: crate::atomic_file::LifetimeFileLock, + port_file: Option, + _owner_lock: Option, } impl RuntimeServer { @@ -133,19 +172,33 @@ impl RuntimeServer { } fn spawn_with_sink(sink: EventSink, hub: RuntimeHub) -> Option { - let path = port_file(); - let owner_lock = match crate::atomic_file::try_lifetime_lock(&path) { - Ok(Some(lock)) => lock, - Ok(None) => { + Self::spawn_at( + sink, + hub, + (!crate::platform::elevation::requires_isolation()).then(port_file), + ) + } + + fn spawn_at(sink: EventSink, hub: RuntimeHub, path: Option) -> Option { + let owner_lock = match path.as_ref().map(|path| crate::atomic_file::try_lifetime_lock(path)) + { + Some(Ok(Some(lock))) => Some(lock), + None => None, + Some(Ok(None)) => { info!("Runtime API owner is starting or already running; staying client-only"); return None; }, - Err(error) => { + Some(Err(error)) => { warn!("Runtime API: cannot lock {path:?}: {error}; control plane disabled"); return None; }, }; - if read_endpoint().and_then(|endpoint| legacy_request_to("PING", &endpoint)).is_some() { + if path + .as_ref() + .and_then(|path| read_endpoint_from(path.clone())) + .and_then(|endpoint| legacy_request_to("PING", &endpoint)) + .is_some() + { info!("Runtime API server already running; this instance stays client-only"); return None; } @@ -153,7 +206,10 @@ impl RuntimeServer { let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).ok()?; let endpoint = Endpoint { port: listener.local_addr().ok()?.port(), token: fresh_token() }; let contents = format!("{} {} {}\n", endpoint.port, endpoint.token, PROTOCOL_VERSION); - if crate::atomic_file::write(&path, contents.as_bytes()).is_err() { + if path + .as_ref() + .is_some_and(|path| crate::atomic_file::write(path, contents.as_bytes()).is_err()) + { warn!("Runtime API: cannot write {path:?}; control plane disabled"); return None; } @@ -163,15 +219,74 @@ impl RuntimeServer { .name("nebula-runtime-api".into()) .spawn(move || serve(listener, server_token, sink, hub)) .is_ok(); - spawned.then(|| Self { endpoint, port_file: path, _owner_lock: owner_lock }) + if spawned { + *CHILD_ENDPOINT.lock().unwrap_or_else(|error| error.into_inner()) = + Some(endpoint.clone()); + Some(Self { endpoint, port_file: path, _owner_lock: owner_lock }) + } else { + if let Some(path) = path { + let _ = std::fs::remove_file(path); + } + None + } } } impl Drop for RuntimeServer { fn drop(&mut self) { // Do not delete another process's newer discovery record. - if read_endpoint().as_ref() == Some(&self.endpoint) { - let _ = std::fs::remove_file(&self.port_file); + if let Some(path) = self.port_file.as_ref() + && read_endpoint_from(path.clone()).as_ref() == Some(&self.endpoint) + { + let _ = std::fs::remove_file(path); + } + let mut child = CHILD_ENDPOINT.lock().unwrap_or_else(|error| error.into_inner()); + if child.as_ref() == Some(&self.endpoint) { + *child = None; } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn discovery_accepts_existing_records_and_rejects_invalid_endpoints() { + let token = "0123456789abcdef0123456789abcdef"; + assert_eq!( + parse_endpoint(&format!("12345 {token} 1\n")), + Some(Endpoint { port: 12345, token: token.into() }) + ); + for invalid in [ + "", + "0 0123456789abcdef0123456789abcdef", + "65536 token", + "12 invalid", + "12 0123456789abcdef0123456789abcdeg", + ] { + assert!(parse_endpoint(invalid).is_none()); + } + } + + #[test] + fn private_server_keeps_discovery_off_disk_and_supplies_its_children() { + let server = + RuntimeServer::spawn_at(EventSink::Callback(Arc::new(|_| {})), RuntimeHub::new(), None) + .unwrap(); + assert!(server.port_file.is_none()); + assert!(server._owner_lock.is_none()); + assert_eq!(read_endpoint().as_ref(), Some(&server.endpoint)); + assert!(legacy_request_to("PING", &server.endpoint).is_some()); + let mut env = + std::collections::HashMap::from([(ENDPOINT_ENV.to_owned(), "old endpoint".into())]); + apply_child_endpoint(&mut env); + assert_eq!(parse_endpoint(&env[ENDPOINT_ENV]).as_ref(), Some(&server.endpoint)); + let mut wrong = server.endpoint.clone(); + wrong.token = "00000000000000000000000000000000".into(); + assert!(legacy_request_to("PING", &wrong).is_none()); + drop(server); + apply_child_endpoint(&mut env); + assert!(!env.contains_key(ENDPOINT_ENV)); + } +} diff --git a/nebula_settings/src/lib.rs b/nebula_settings/src/lib.rs index e097f80..5a3204a 100644 --- a/nebula_settings/src/lib.rs +++ b/nebula_settings/src/lib.rs @@ -947,6 +947,10 @@ pub struct RuntimeSettings { pub cursor_shape: Option, pub cursor_blink: Option, pub copy_on_select: bool, + /// GUI override for mouse.focus_follows_mouse in TOML; absent there too means false. + pub focus_follows_mouse: Option, + /// Preserve the existing dimming of inactive split panes unless explicitly disabled. + pub dim_inactive_panes: bool, /// 裸 shell 风险粘贴确认:开 = 换行、提权命令或控制字符先确认;关 = 直接粘贴。 pub multiline_paste_confirm: bool, /// 标签页关闭按钮(叉号)是否渲染:关 = 不渲染,仍可用中键关闭。 @@ -1085,6 +1089,8 @@ impl RuntimeSettings { cursor_shape: raw.value("cursor_shape").and_then(CursorShapeName::from_settings), cursor_blink: raw.bool_on("cursor_blink"), copy_on_select: raw.bool_on("copy_on_select").unwrap_or(false), + focus_follows_mouse: raw.bool_on("focus_follows_mouse"), + dim_inactive_panes: raw.bool_on("dim_inactive_panes").unwrap_or(true), multiline_paste_confirm: raw.bool_on("multiline_paste_confirm").unwrap_or(true), tab_close_visible: raw.bool_on("tab_close_visible").unwrap_or(true), terminal_proxy: raw.bool_on("terminal_proxy").unwrap_or(false), @@ -1210,6 +1216,33 @@ pub fn format_hex_rgb(rgb: Rgb8) -> String { #[cfg(test)] mod tests { + #[test] + fn pane_preferences_round_trip_and_allow_a_missing_mouse_override() { + let defaults = RuntimeSettings::from_raw(&RawSettings::default()); + assert_eq!(defaults.focus_follows_mouse, None); + assert!(defaults.dim_inactive_panes); + let mut text = "theme=Nord\nfuture_option=keep\n".to_owned(); + for (focus, dim) in [(true, false), (false, true)] { + text = apply_updates( + &text, + &[ + ("focus_follows_mouse", (focus as u8).to_string()), + ("dim_inactive_panes", (dim as u8).to_string()), + ], + ); + let settings = RuntimeSettings::from_raw(&RawSettings::from_text(&text)); + assert_eq!(settings.focus_follows_mouse, Some(focus)); + assert_eq!(settings.dim_inactive_panes, dim); + assert_eq!(settings.theme, ThemeName::Nord); + assert!(text.contains("future_option=keep")); + } + text = apply_updates(&text, &[("focus_follows_mouse", String::new())]); + assert_eq!( + RuntimeSettings::from_raw(&RawSettings::from_text(&text)).focus_follows_mouse, + None + ); + } + #[test] fn empty_quick_terminal_hotkey_is_distinct_from_an_unset_preference() { let unset = RuntimeSettings::from_raw(&RawSettings::from_text("theme=Nord\n")); diff --git a/nebula_settings/src/reset.rs b/nebula_settings/src/reset.rs index 2e8c87c..db3267a 100644 --- a/nebula_settings/src/reset.rs +++ b/nebula_settings/src/reset.rs @@ -14,6 +14,8 @@ const RESET_KEYS: &[&str] = &[ "cursor_shape", "cursor_blink", "copy_on_select", + "focus_follows_mouse", + "dim_inactive_panes", "multiline_paste_confirm", "tab_close_visible", "terminal_proxy", @@ -116,6 +118,16 @@ mod tests { use super::*; use crate::{RawSettings, RuntimeSettings}; + #[test] + fn reset_restores_split_dimming_and_removes_mouse_override() { + let restored = + default_settings_text("focus_follows_mouse=1\ndim_inactive_panes=0\ncustom=keep\n"); + assert_eq!(restored, "custom=keep\n"); + let settings = RuntimeSettings::from_raw(&RawSettings::from_text(&restored)); + assert_eq!(settings.focus_follows_mouse, None); + assert!(settings.dim_inactive_panes); + } + #[test] fn resetting_preferences_reenables_ai_toasts_without_erasing_other_data() { let original = "ai_toasts=0\ncustom_data=keep\n";