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 7484ce6..e1a9373 100644
Binary files a/extra/logo/ai_claude.png and b/extra/logo/ai_claude.png differ
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 @@
+
diff --git a/extra/logo/ai_omp.png b/extra/logo/ai_omp.png
new file mode 100644
index 0000000..8671e96
Binary files /dev/null and b/extra/logo/ai_omp.png differ
diff --git a/extra/logo/ai_omp.svg b/extra/logo/ai_omp.svg
new file mode 100644
index 0000000..6c5e055
--- /dev/null
+++ b/extra/logo/ai_omp.svg
@@ -0,0 +1,11 @@
+
diff --git a/extra/logo/ai_trae.png b/extra/logo/ai_trae.png
new file mode 100644
index 0000000..97e1fb3
Binary files /dev/null and b/extra/logo/ai_trae.png differ
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 @@
+
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 |