merge: include committed terminal controls in local preview

This commit is contained in:
kuddev committed 2026-09-14 08:46:45 +08:00
commit bd48ea6dbb
46 files changed
+2363 -302

No files matched your search

+16
View File
@@ -13,6 +13,10 @@ Every release entry is provided in English and Simplified Chinese.
- Added a confirmation dialog to Settings → Restore defaults, with an explicit cancel action before any preferences are reset. Contributed by [@WilliamWang1721](https://github.com/WilliamWang1721) in [#115](https://github.com/Kuddev/pebrel/pull/115).
- Added Windows login-startup and silent-start switches in Settings → Advanced → Session lifecycle. Silent startup requires the system tray; opening a directory explicitly still shows a window. Contributed by [@WilliamWang1721](https://github.com/WilliamWang1721) in [#117](https://github.com/Kuddev/pebrel/pull/117).
- Added separate interface font and size controls, plus an optional CJK terminal font fallback list, in Settings → Appearance. Interface size can be adjusted from 10 to 24 px independently of terminal text. Contributed by [@WilliamWang1721](https://github.com/WilliamWang1721) in [#127](https://github.com/Kuddev/pebrel/pull/127).
- 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
@@ -20,10 +24,14 @@ Every release entry is provided in English and Simplified Chinese.
- Aligned macOS title-bar controls and tab spacing with the native window-button geometry. Contributed by [@WilliamWang1721](https://github.com/WilliamWang1721) in [#114](https://github.com/Kuddev/pebrel/pull/114).
- Fixed ignored terminal selection foreground colors and preserved custom selection colors in themes that do not define their own. Contributed by [@Aschenbath](https://github.com/Aschenbath) in [#125](https://github.com/Kuddev/pebrel/pull/125).
- Kept selected text readable in the answer reader and other selectable text views by limiting the highlight opacity, while retaining solid list-selection backgrounds. Contributed by [@Aschenbath](https://github.com/Aschenbath) in [#126](https://github.com/Kuddev/pebrel/pull/126).
- 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
- Localized existing Git controls, status messages, history timestamps and file-operation notices. Switching the interface language preserves the commit draft and its text selection. Contributed by [@gao-jian-bin](https://github.com/gao-jian-bin) in [#118](https://github.com/Kuddev/pebrel/pull/118).
- 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.
### 中文
@@ -32,6 +40,10 @@ Every release entry is provided in English and Simplified Chinese.
- 为“设置 → 恢复默认设置”加入确认弹窗,可在重置任何偏好前明确取消操作。由 [@WilliamWang1721](https://github.com/WilliamWang1721) 在 [#115](https://github.com/Kuddev/pebrel/pull/115) 中贡献。
- 在 Windows 的“设置 → 高级 → 会话生命周期”中新增“登录时自启动”和“静默启动”开关。静默启动需要启用系统托盘;显式打开目录时仍显示窗口。由 [@WilliamWang1721](https://github.com/WilliamWang1721) 在 [#117](https://github.com/Kuddev/pebrel/pull/117) 中贡献。
- 在“设置 → 外观”中新增独立的界面字体、界面字号,以及可选的中文终端字体回退列表。界面字号可在 10–24 px 间调整,不影响终端文字字号。由 [@WilliamWang1721](https://github.com/WilliamWang1721) 在 [#127](https://github.com/Kuddev/pebrel/pull/127) 中贡献。
- 在“设置 → 交互”中加入“焦点跟随鼠标”,默认关闭;在“设置 → 外观”中加入“调暗非活动窗格”,默认开启。两项设置均即时生效。
- Shell 启动菜单新增右键操作,可设为默认 Shell;Windows 下还可在新的管理员 Pebrel 窗口内打开所选 Shell。
- SSH 启动菜单新增“连接、编辑、删除”右键操作;删除沿用已有的确认与撤销流程。
- 新增 Trae CLI 识别与标签图标,并为 Oh My Pi 加入独立图标。
#### 修复
@@ -39,10 +51,14 @@ Every release entry is provided in English and Simplified Chinese.
- 让 macOS 标题栏控件与标签间距跟随系统窗口按钮的实际几何位置。由 [@WilliamWang1721](https://github.com/WilliamWang1721) 在 [#114](https://github.com/Kuddev/pebrel/pull/114) 中贡献。
- 修复终端忽略选中文字前景色的问题;主题未定义自己的选区颜色时,保留用户的自定义选区颜色。由 [@Aschenbath](https://github.com/Aschenbath) 在 [#125](https://github.com/Kuddev/pebrel/pull/125) 中贡献。
- 限制回答阅读器等文本视图的选区高亮不透明度,让选中文字保持可读,同时保留列表选中项的实色背景。由 [@Aschenbath](https://github.com/Aschenbath) 在 [#126](https://github.com/Kuddev/pebrel/pull/126) 中贡献。
- 修复顶部设置标签在切换到其他标签后消失的问题。现在只有主动关闭才会移除,标签溢出或切换标签布局时也会保留。
- 修复 `Alt+1–9` 和 `Ctrl+1–9` 标签快捷键优先级低于终端输入的问题。终端或 CLI 获得焦点时仍可切换标签,自定义快捷键的修改也无需重启即可生效。
#### 改进
- 为现有 Git 控件、状态提示、历史时间和文件操作提示补充国际化;切换界面语言时保留提交草稿及其文字选区。由 [@gao-jian-bin](https://github.com/gao-jian-bin) 在 [#118](https://github.com/Kuddev/pebrel/pull/118) 中贡献。
- 将 Claude Code 的低分辨率图标替换为从 SVG 导出的 1024 像素图像,并按显示尺寸处理 Agent 图标,使标签图标边缘更平滑。
## 1.7.0 - 2026-09-12
+37
View File
@@ -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.
+21
View File
@@ -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.
+36
View File
@@ -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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 45 KiB

+1
View File
@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Claude</title><path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z" fill="#D97757" fill-rule="nonzero"></path></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
<title>Oh My Pi</title>
<defs>
<linearGradient id="pi" x1="10" y1="12" x2="51" y2="55" gradientUnits="userSpaceOnUse">
<stop stop-color="#e873d6"/>
<stop offset=".58" stop-color="#9362ef"/>
<stop offset="1" stop-color="#57a8ed"/>
</linearGradient>
</defs>
<path fill="url(#pi)" d="M8 12h48v10H44v34H34V22H24v25H14V22H8z"/>
</svg>

After

Width:  |  Height:  |  Size: 448 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+1
View File
@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>TRAE</title><path d="M24 20.541H3.428v-3.426H0V3.4h24V20.54zM3.428 17.115h17.144V6.827H3.428v10.288zm8.573-5.196l-2.425 2.424-2.424-2.424 2.424-2.424 2.425 2.424zm6.857-.001l-2.424 2.423-2.425-2.423 2.425-2.425 2.424 2.425z" fill="#32F08C"></path></svg>

After

Width:  |  Height:  |  Size: 378 B

+22 -1
View File
@@ -125,7 +125,16 @@
"silent_start": "Start silently",
"silent_start_help": "Start in the background without showing a window. Enables the system tray; click its icon to reopen. Applies while the tray is enabled, except when opening a directory explicitly.",
"save_failed": "Could not change startup settings: {error}"
}
},
"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": {
@@ -525,5 +534,17 @@
"following_directory": "The selected directory is unavailable; following the current directory",
"command_failed": "{program} failed",
"task_start_failed": "Could not start the version control task: {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}"
}
}
+22 -1
View File
@@ -125,7 +125,16 @@
"silent_start": "静默启动",
"silent_start_help": "启动后在后台运行,不显示窗口。开启时会同时启用系统托盘,点击托盘图标可重新打开窗口。托盘启用时生效,显式打开目录时除外。",
"save_failed": "无法更改启动设置:{error}"
}
},
"mouse": {
"focus_follows_mouse": "焦点跟随鼠标",
"focus_follows_mouse_description": "鼠标移入终端窗格时自动聚焦,默认关闭。"
},
"panes": {
"dim_inactive": "调暗非活动窗格",
"dim_inactive_description": "降低其他分屏窗格的亮度,突出当前活动窗格。"
},
"save_failed": "无法保存设置:{error}"
},
"provider": {
"test": {
@@ -525,5 +534,17 @@
"following_directory": "所选目录不可用,已跟随当前目录",
"command_failed": "{program} 失败",
"task_start_failed": "无法启动版本控制任务: {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}"
}
}
+3
View File
@@ -91,6 +91,7 @@ pub fn apply(env: &mut HashMap<String, String>, 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)]
+21 -2
View File
@@ -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 {
+2 -7
View File
@@ -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());
+50 -6
View File
@@ -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<AiLogo> {
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));
}
}
}
+14
View File
@@ -61,6 +61,9 @@ pub struct Settings {
pub cursor_blink: Option<bool>,
/// 选区完成即复制(旧壳 `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,
/// 标签关闭按钮与标签插入动画都在渲染热路径读取,必须随全局设置驻留内存。
@@ -179,6 +182,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,
@@ -448,6 +455,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)]
+17 -4
View File
@@ -75,7 +75,10 @@ pub(crate) enum GpuiShellEvent {
///
/// GPUI 拥有自己的消息循环:主窗形态从主线程调用(winit 不启动);
/// spike 形态从专用线程调用。一个进程内只允许调用一次。
pub fn run_shell(initial_cwd: Option<std::path::PathBuf>) {
pub fn run_shell(
initial_cwd: Option<std::path::PathBuf>,
initial_command: Option<crate::config::ui_config::Program>,
) {
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<std::path::PathBuf>) {
},
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 {
@@ -151,7 +157,7 @@ pub fn run_shell(initial_cwd: Option<std::path::PathBuf>) {
{
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();
}
@@ -224,9 +230,16 @@ fn open_main_window(
shell_events: std::sync::mpsc::Receiver<GpuiShellEvent>,
runtime_hub: crate::runtime_api::RuntimeHub,
initial_cwd: Option<std::path::PathBuf>,
initial_command: Option<crate::config::ui_config::Program>,
) {
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`)。
+22 -3
View File
@@ -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 面板若正打开,需要重建候选快照。
@@ -330,7 +331,7 @@ impl SettingsPane {
cx.notify();
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(
@@ -338,7 +339,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())],
),
);
@@ -679,6 +684,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),
@@ -1070,6 +1077,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::<crate::gpui_shell::config::Settings>()
.is_some_and(|settings| settings.focus_follows_mouse),
cx,
),
)
.child(self.switch_row(
"copy_on_select",
language.pick("选中即复制", "Copy on select"),
@@ -72,6 +72,13 @@ impl SettingsPane {
),
)
.child(self.font_size_row(true, 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"),
@@ -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>,
) {
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>,
) {
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();
}
}
@@ -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 恢复 startup autostart login silent 自启动 静默启动 开机 登录",
"backup 备份 export 导出 restore 恢复",
@@ -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>,
) {
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<String> {
let query = self.ssh_library.search.read(cx).value();
let hosts = if self.ssh_library.scope == HostScope::Recent {
@@ -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<Self>,
) {
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::<Settings>().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 任一),
@@ -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<TerminalView>,
input: Entity<InputState>,
overlay: bool,
requests: Rc<Cell<usize>>,
_subscription: gpui::Subscription,
}
impl Render for Probe {
fn render(&mut self, window: &mut Window, cx: &mut gpui::Context<Self>) -> 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<Probe>, 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<MouseButton>) {
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::<Settings>().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::<Settings>().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::<Settings>().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);
}
+36 -214
View File
@@ -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> {
[
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<SettingsPane>, 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_menu::LauncherContextMenu>,
launcher_admin_task: Option<gpui::Task<()>>,
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<Self>) {
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<Self>) {
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<Self>) {
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<Self>) {
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::<crate::gpui_shell::config::Settings>()
.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)
})
@@ -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<usiz
}
impl NebulaWorkspace {
pub(super) fn select_tab(
&mut self,
action: &super::keyboard_bindings::SelectTab,
window: &mut Window,
cx: &mut Context<Self>,
) {
// 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<Self>,
) {
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<Self>,
) {
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);
@@ -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<KeyBinding> {
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<usize>,
}
impl SelectTab {
fn from_config(action: &crate::config::Action) -> Option<Self> {
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::<ToggleShellPicker>());
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<usize> {
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<KeyBinding> {
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<Self>) {
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<Self>) {
let applied: Vec<_> = raw.iter().map(|(combo, _)| gpui_binding_combo(combo)).collect();
let defaults = crate::display::keymap::default_shortcuts();
let mut bindings = Vec::new();
@@ -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::<ToggleShellPicker>());
}
#[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<NebulaWorkspace>, 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<NebulaWorkspace>,
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<usize>,
mode: TermMode,
input: Vec<Keystroke>,
encoded: Vec<u8>,
}
impl Render for TerminalKeyProbe {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> 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());
});
}
}
@@ -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<PopupMenu>,
position: Point<Pixels>,
_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<Self> {
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<String> {
match self {
Self::Shell(shell) => Some(shell.id.clone()),
Self::Profile(profile) => profile.settings_id(),
Self::Ssh(_) => None,
}
}
fn local_launch(&self) -> Option<crate::session::LaunchSession> {
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<LauncherCommand> {
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<Pixels>,
window: &mut Window,
cx: &mut Context<Self>,
) {
if !self.shell_picker_open || !self.command_palette_open {
return;
}
let current = crate::platform::shell::effective_shell_id(
cx.try_global::<crate::gpui_shell::config::Settings>()
.and_then(|settings| settings.shell_id.as_deref()),
);
let is_default = target.default_id().is_some_and(|id| id.eq_ignore_ascii_case(&current));
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<gpui::AnyElement> {
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>,
) {
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<Self>,
) {
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<Self>,
) {
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;
@@ -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<NebulaWorkspace>, 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");
});
}
@@ -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() {
@@ -986,6 +986,10 @@ impl NebulaWorkspace {
window: &mut Window,
cx: &mut Context<Self>,
) -> 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 上会变成
// 既不关也不藏(设置页那一行也按同一能力位隐藏)。
@@ -8,11 +8,22 @@ pub(super) enum SaveReason {
Quit,
}
#[derive(Default)]
pub(super) struct SessionPersistence {
latest: Option<Session>,
saved: Option<Session>,
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 {
@@ -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<Self>) {
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<Self>) {
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<Self>) {
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<Self>,
) {
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<Self>,
) {
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);
@@ -0,0 +1,162 @@
use super::super::*;
use gpui::{Modifiers, TestAppContext, VisualTestContext};
fn open_workspace(
count: usize,
cx: &mut TestAppContext,
) -> (tempfile::TempDir, Entity<NebulaWorkspace>, 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<gpui::Pixels> {
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()));
}
@@ -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))
@@ -43,6 +43,7 @@ use crate::runtime_api::{
pub(crate) enum WorkspaceStartup {
RestoreOrDefault,
NewTerminal { cwd: Option<PathBuf> },
LaunchTerminal { cwd: Option<PathBuf>, launch: crate::session::LaunchSession },
Empty,
}
@@ -250,11 +251,13 @@ pub(crate) fn open_initial_window(
ai_events: std::sync::mpsc::Receiver<crate::ai_hook::AiHookEvent>,
shell_events: std::sync::mpsc::Receiver<GpuiShellEvent>,
initial_cwd: Option<PathBuf>,
initial_command: Option<crate::config::ui_config::Program>,
) {
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<PathBuf>,
command: Option<crate::config::ui_config::Program>,
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);
@@ -1479,6 +1543,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);
@@ -1515,7 +1582,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,
+4 -1
View File
@@ -239,7 +239,7 @@ fn main() -> Result<(), Box<dyn Error>> {
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
+153
View File
@@ -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<OsString> {
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<bool> {
use std::sync::OnceLock;
static ELEVATED: OnceLock<Result<bool, i32>> = OnceLock::new();
ELEVATED.get_or_init(query_token).clone().map_err(io::Error::from_raw_os_error)
}
#[cfg(windows)]
fn query_token() -> Result<bool, i32> {
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::<TOKEN_ELEVATION>() 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<bool> {
Ok(false)
}
#[cfg(windows)]
fn quoted_argument(argument: &OsStr) -> io::Result<Vec<u16>> {
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<LaunchOutcome> {
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<u16> = 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<u16> = "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::<SHELLEXECUTEINFOW>() 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<LaunchOutcome> {
Err(io::Error::new(io::ErrorKind::Unsupported, "UAC is only available on Windows"))
}
#[cfg(test)]
mod tests;
@@ -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<u16> = "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());
}
+1
View File
@@ -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;
+1
View File
@@ -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,
+129 -14
View File
@@ -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<Option<Endpoint>> = Mutex::new(None);
/// Private instances publish discovery only to their own local PTY children.
pub(crate) fn apply_child_endpoint(env: &mut std::collections::HashMap<String, String>) {
env.retain(|key, _| !key.eq_ignore_ascii_case(ENDPOINT_ENV));
if let Some(endpoint) =
CHILD_ENDPOINT.lock().unwrap_or_else(|error| error.into_inner()).as_ref()
{
env.insert(ENDPOINT_ENV.to_owned(), format!("{} {}", endpoint.port, endpoint.token));
}
}
fn parse_endpoint(data: &str) -> Option<Endpoint> {
let mut parts = data.split_whitespace();
let port = parts.next()?.parse().ok()?;
let token = parts.next()?;
if port == 0 || token.len() != 32 || !token.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return None;
}
Some(Endpoint { port, token: token.to_owned() })
}
fn port_file() -> PathBuf {
crate::display::nebula_data_dir().join("runtime.port")
}
@@ -17,13 +40,22 @@ fn legacy_port_file() -> PathBuf {
}
pub(super) fn read_endpoint() -> Option<Endpoint> {
// A hosted instance owns its endpoint even when launched from another
// Pebrel terminal. Child CLI processes have no server and use the env below.
if let Some(endpoint) = CHILD_ENDPOINT.lock().unwrap_or_else(|error| error.into_inner()).clone()
{
return Some(endpoint);
}
if let Some(value) = std::env::var_os(ENDPOINT_ENV) {
// An invalid explicit endpoint must not silently select another instance.
return value.to_str().and_then(parse_endpoint);
}
read_endpoint_from(port_file())
}
fn read_endpoint_from(path: PathBuf) -> Option<Endpoint> {
let data = std::fs::read_to_string(path).ok()?;
let mut parts = data.split_whitespace();
Some(Endpoint { port: parts.next()?.parse().ok()?, token: parts.next()?.to_owned() })
parse_endpoint(&data)
}
pub(super) fn endpoint_addr(endpoint: &Endpoint) -> SocketAddr {
@@ -92,6 +124,13 @@ fn try_open_tab_existing(dir: Option<&std::path::Path>) -> bool {
}
fn legacy_request(verb: &str) -> Option<()> {
let local = CHILD_ENDPOINT.lock().unwrap_or_else(|error| error.into_inner()).clone();
if let Some(endpoint) = local {
return legacy_request_to(verb, &endpoint);
}
if std::env::var_os(ENDPOINT_ENV).is_some() {
return read_endpoint().and_then(|endpoint| legacy_request_to(verb, &endpoint));
}
read_endpoint()
.and_then(|endpoint| legacy_request_to(verb, &endpoint))
// 已运行的 pre-v1 版本只发布 mux.port;升级期间仍允许普通启动交接。
@@ -114,8 +153,8 @@ fn legacy_request_to(verb: &str, endpoint: &Endpoint) -> Option<()> {
/// Resident versioned runtime API server.
pub struct RuntimeServer {
endpoint: Endpoint,
port_file: PathBuf,
_owner_lock: crate::atomic_file::LifetimeFileLock,
port_file: Option<PathBuf>,
_owner_lock: Option<crate::atomic_file::LifetimeFileLock>,
}
impl RuntimeServer {
@@ -133,19 +172,33 @@ impl RuntimeServer {
}
fn spawn_with_sink(sink: EventSink, hub: RuntimeHub) -> Option<Self> {
let path = port_file();
let owner_lock = match crate::atomic_file::try_lifetime_lock(&path) {
Ok(Some(lock)) => lock,
Ok(None) => {
Self::spawn_at(
sink,
hub,
(!crate::platform::elevation::requires_isolation()).then(port_file),
)
}
fn spawn_at(sink: EventSink, hub: RuntimeHub, path: Option<PathBuf>) -> Option<Self> {
let owner_lock = match path.as_ref().map(|path| crate::atomic_file::try_lifetime_lock(path))
{
Some(Ok(Some(lock))) => Some(lock),
None => None,
Some(Ok(None)) => {
info!("Runtime API owner is starting or already running; staying client-only");
return None;
},
Err(error) => {
Some(Err(error)) => {
warn!("Runtime API: cannot lock {path:?}: {error}; control plane disabled");
return None;
},
};
if read_endpoint().and_then(|endpoint| legacy_request_to("PING", &endpoint)).is_some() {
if path
.as_ref()
.and_then(|path| read_endpoint_from(path.clone()))
.and_then(|endpoint| legacy_request_to("PING", &endpoint))
.is_some()
{
info!("Runtime API server already running; this instance stays client-only");
return None;
}
@@ -153,7 +206,10 @@ impl RuntimeServer {
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).ok()?;
let endpoint = Endpoint { port: listener.local_addr().ok()?.port(), token: fresh_token() };
let contents = format!("{} {} {}\n", endpoint.port, endpoint.token, PROTOCOL_VERSION);
if crate::atomic_file::write(&path, contents.as_bytes()).is_err() {
if path
.as_ref()
.is_some_and(|path| crate::atomic_file::write(path, contents.as_bytes()).is_err())
{
warn!("Runtime API: cannot write {path:?}; control plane disabled");
return None;
}
@@ -163,15 +219,74 @@ impl RuntimeServer {
.name("nebula-runtime-api".into())
.spawn(move || serve(listener, server_token, sink, hub))
.is_ok();
spawned.then(|| Self { endpoint, port_file: path, _owner_lock: owner_lock })
if spawned {
*CHILD_ENDPOINT.lock().unwrap_or_else(|error| error.into_inner()) =
Some(endpoint.clone());
Some(Self { endpoint, port_file: path, _owner_lock: owner_lock })
} else {
if let Some(path) = path {
let _ = std::fs::remove_file(path);
}
None
}
}
}
impl Drop for RuntimeServer {
fn drop(&mut self) {
// Do not delete another process's newer discovery record.
if read_endpoint().as_ref() == Some(&self.endpoint) {
let _ = std::fs::remove_file(&self.port_file);
if let Some(path) = self.port_file.as_ref()
&& read_endpoint_from(path.clone()).as_ref() == Some(&self.endpoint)
{
let _ = std::fs::remove_file(path);
}
let mut child = CHILD_ENDPOINT.lock().unwrap_or_else(|error| error.into_inner());
if child.as_ref() == Some(&self.endpoint) {
*child = None;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn discovery_accepts_existing_records_and_rejects_invalid_endpoints() {
let token = "0123456789abcdef0123456789abcdef";
assert_eq!(
parse_endpoint(&format!("12345 {token} 1\n")),
Some(Endpoint { port: 12345, token: token.into() })
);
for invalid in [
"",
"0 0123456789abcdef0123456789abcdef",
"65536 token",
"12 invalid",
"12 0123456789abcdef0123456789abcdeg",
] {
assert!(parse_endpoint(invalid).is_none());
}
}
#[test]
fn private_server_keeps_discovery_off_disk_and_supplies_its_children() {
let server =
RuntimeServer::spawn_at(EventSink::Callback(Arc::new(|_| {})), RuntimeHub::new(), None)
.unwrap();
assert!(server.port_file.is_none());
assert!(server._owner_lock.is_none());
assert_eq!(read_endpoint().as_ref(), Some(&server.endpoint));
assert!(legacy_request_to("PING", &server.endpoint).is_some());
let mut env =
std::collections::HashMap::from([(ENDPOINT_ENV.to_owned(), "old endpoint".into())]);
apply_child_endpoint(&mut env);
assert_eq!(parse_endpoint(&env[ENDPOINT_ENV]).as_ref(), Some(&server.endpoint));
let mut wrong = server.endpoint.clone();
wrong.token = "00000000000000000000000000000000".into();
assert!(legacy_request_to("PING", &wrong).is_none());
drop(server);
apply_child_endpoint(&mut env);
assert!(!env.contains_key(ENDPOINT_ENV));
}
}
+33
View File
@@ -950,6 +950,10 @@ pub struct RuntimeSettings {
pub cursor_shape: Option<CursorShapeName>,
pub cursor_blink: Option<bool>,
pub copy_on_select: bool,
/// GUI override for mouse.focus_follows_mouse in TOML; absent there too means false.
pub focus_follows_mouse: Option<bool>,
/// Preserve the existing dimming of inactive split panes unless explicitly disabled.
pub dim_inactive_panes: bool,
/// 裸 shell 风险粘贴确认:开 = 换行、提权命令或控制字符先确认;关 = 直接粘贴。
pub multiline_paste_confirm: bool,
/// 标签页关闭按钮(叉号)是否渲染:关 = 不渲染,仍可用中键关闭。
@@ -1093,6 +1097,8 @@ impl RuntimeSettings {
cursor_shape: raw.value("cursor_shape").and_then(CursorShapeName::from_settings),
cursor_blink: raw.bool_on("cursor_blink"),
copy_on_select: raw.bool_on("copy_on_select").unwrap_or(false),
focus_follows_mouse: raw.bool_on("focus_follows_mouse"),
dim_inactive_panes: raw.bool_on("dim_inactive_panes").unwrap_or(true),
multiline_paste_confirm: raw.bool_on("multiline_paste_confirm").unwrap_or(true),
tab_close_visible: raw.bool_on("tab_close_visible").unwrap_or(true),
terminal_proxy: raw.bool_on("terminal_proxy").unwrap_or(false),
@@ -1219,6 +1225,33 @@ pub fn format_hex_rgb(rgb: Rgb8) -> String {
#[cfg(test)]
mod tests {
#[test]
fn pane_preferences_round_trip_and_allow_a_missing_mouse_override() {
let defaults = RuntimeSettings::from_raw(&RawSettings::default());
assert_eq!(defaults.focus_follows_mouse, None);
assert!(defaults.dim_inactive_panes);
let mut text = "theme=Nord\nfuture_option=keep\n".to_owned();
for (focus, dim) in [(true, false), (false, true)] {
text = apply_updates(
&text,
&[
("focus_follows_mouse", (focus as u8).to_string()),
("dim_inactive_panes", (dim as u8).to_string()),
],
);
let settings = RuntimeSettings::from_raw(&RawSettings::from_text(&text));
assert_eq!(settings.focus_follows_mouse, Some(focus));
assert_eq!(settings.dim_inactive_panes, dim);
assert_eq!(settings.theme, ThemeName::Nord);
assert!(text.contains("future_option=keep"));
}
text = apply_updates(&text, &[("focus_follows_mouse", String::new())]);
assert_eq!(
RuntimeSettings::from_raw(&RawSettings::from_text(&text)).focus_follows_mouse,
None
);
}
#[test]
fn empty_quick_terminal_hotkey_is_distinct_from_an_unset_preference() {
let unset = RuntimeSettings::from_raw(&RawSettings::from_text("theme=Nord\n"));
+12
View File
@@ -17,6 +17,8 @@ const RESET_KEYS: &[&str] = &[
"cursor_shape",
"cursor_blink",
"copy_on_select",
"focus_follows_mouse",
"dim_inactive_panes",
"multiline_paste_confirm",
"tab_close_visible",
"terminal_proxy",
@@ -120,6 +122,16 @@ mod tests {
use super::*;
use crate::{RawSettings, RuntimeSettings};
#[test]
fn reset_restores_split_dimming_and_removes_mouse_override() {
let restored =
default_settings_text("focus_follows_mouse=1\ndim_inactive_panes=0\ncustom=keep\n");
assert_eq!(restored, "custom=keep\n");
let settings = RuntimeSettings::from_raw(&RawSettings::from_text(&restored));
assert_eq!(settings.focus_follows_mouse, None);
assert!(settings.dim_inactive_panes);
}
#[test]
fn resetting_preferences_reenables_ai_toasts_without_erasing_other_data() {
let original = "ai_toasts=0\ncustom_data=keep\n";