diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8a5ec73..a92790b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,85 @@ Every release entry is provided in English and Simplified Chinese.
每个版本条目均同时提供英文和简体中文说明。
+## 1.7.0 - 2026-09-11
+
+### English
+
+#### Added
+
+- Added an AI message toast switch in Settings → Terminal → Alerts. Turning it off dismisses in-app AI cards and suppresses new ones, while retaining system notifications and tab status indicators. Addresses [#107](https://github.com/Kuddev/nebula/issues/107).
+- Added editable local and SSH text documents with save operations, changed-file checks, and preservation of unsaved text when a save fails.
+- Added reusable terminal layouts that open in a separate window and retain terminal launch settings without replaying command history.
+- Added dragging files from the system file manager into local and WSL terminals to insert quoted paths without executing them. Local files for SSH sessions can be uploaded through Remote Files. Addresses [#106](https://github.com/Kuddev/nebula/issues/106).
+- Expanded the built-in theme picker to 13 palettes, including Breeze, Mint, Catppuccin Mocha/Latte, and Glass light/dark variants. Glass uses static colors and does not automatically enable background blur.
+
+#### Fixed
+
+- Fixed the Default Shell setting and the new-terminal menu recommending PowerShell on macOS when no shell is configured. Both now use the same host-default resolver as the terminal backend. Contributed by [@YinBuLiao](https://github.com/YinBuLiao) in [#110](https://github.com/Kuddev/pebrel/pull/110).
+- Fixed mixed shell icons giving settings-menu rows different heights. Shells without a brand image also receive an icon in the new-terminal menu. Contributed by [@YinBuLiao](https://github.com/YinBuLiao) in [#111](https://github.com/Kuddev/pebrel/pull/111).
+- Shortened long and multi-line notification previews while retaining the original notification text in logs. Plain persistent notifications can be dismissed by clicking the card. Contributed in part by [@YinBuLiao](https://github.com/YinBuLiao) in [#112](https://github.com/Kuddev/pebrel/pull/112).
+- Improved foreground contrast for terminal text on light backgrounds while preserving color pairs that are already readable. Addresses [#104](https://github.com/Kuddev/nebula/issues/104).
+- Fixed the System language setting falling back to English when macOS applications launched from Finder have no locale environment variables.
+- Corrected Antigravity status detection so ordinary replies do not look like pending confirmations, while active question and file-review prompts are recognized as waiting for input. Addresses [#100](https://github.com/Kuddev/nebula/issues/100). Contributed by [@821869798](https://github.com/821869798) in [#101](https://github.com/Kuddev/pebrel/pull/101).
+
+#### Improved
+
+- Improved Markdown reading with bounded preview work and shared background formula rendering; unsupported or oversized expressions retain their source.
+- Added in-place feedback to code and formula copy controls, and kept document focus mode separate from operating-system fullscreen.
+- Made Git-ignored files and directories distinguishable with italic text in the file tree.
+
+Windows packages include an installer and portable ZIP. Linux x64 packages and macOS Apple Silicon/Intel DMGs retain their Preview designation. Molecular structure rendering remains disabled.
+
+### 中文
+
+#### 新增
+
+- 在“设置 → 终端 → 提醒”中新增“AI 消息弹窗”开关。关闭后收起已有的应用内 AI 卡片并停止显示新的卡片,同时保留系统通知和标签状态提示。对应 [#107](https://github.com/Kuddev/nebula/issues/107)。
+- 新增本地和 SSH 文本文档编辑,支持保存、文件外部变更检查,并在保存失败时保留未保存的内容。
+- 新增可复用的终端布局,在独立窗口中打开并保留终端启动设置,不重放命令历史。
+- 新增从系统文件管理器向本地和 WSL 终端拖入文件,插入加引号的路径而不执行;SSH 会话所需的本地文件可通过“远程文件”上传。对应 [#106](https://github.com/Kuddev/nebula/issues/106)。
+- 将内置主题选择器扩展为 13 套配色,包含 Breeze、Mint、Catppuccin Mocha/Latte,以及 Glass 浅色/深色。Glass 使用静态配色,不会自动开启背景模糊。
+
+#### 修复
+
+- 修复 macOS 未配置 Shell 时,“默认 Shell”设置和新建终端菜单推荐 PowerShell 的问题。两处现在与终端后端共用宿主默认 Shell 解析。由 [@YinBuLiao](https://github.com/YinBuLiao) 在 [#110](https://github.com/Kuddev/pebrel/pull/110) 中贡献。
+- 修复不同 Shell 图标使设置菜单行高不一致的问题;没有品牌图片的 Shell 在新建终端菜单中也会显示图标。由 [@YinBuLiao](https://github.com/YinBuLiao) 在 [#111](https://github.com/Kuddev/pebrel/pull/111) 中贡献。
+- 缩短长通知和多行通知的预览,同时在日志中保留通知原文。部分修复由 [@YinBuLiao](https://github.com/YinBuLiao) 在 [#112](https://github.com/Kuddev/pebrel/pull/112) 中贡献。
+- 改进浅色终端背景上的文字对比度,同时保留原本可读的配色。对应 [#104](https://github.com/Kuddev/nebula/issues/104)。
+- 修复 macOS 从 Finder 启动、缺少语言环境变量时,“跟随系统”回退到英文的问题。
+- 修正 Antigravity 状态识别:普通回答不再误报为等待确认,实际的问题选择框和文件审核框能识别为等待输入。对应 [#100](https://github.com/Kuddev/nebula/issues/100)。由 [@821869798](https://github.com/821869798) 在 [#101](https://github.com/Kuddev/pebrel/pull/101) 中贡献。
+
+#### 改进
+
+- 改进 Markdown 阅读,限制预览处理量,并共享后台公式渲染;不支持或超限的表达式保留源码。
+- 为代码和公式复制控件加入就地反馈,并将文档专注模式与系统全屏分开。
+- 文件树中被 Git 忽略的文件和目录使用斜体显示,便于区分。
+
+Windows 提供安装器和 ZIP 便携包;Linux x64 包以及 macOS Apple Silicon/Intel DMG 继续标记为 Preview。分子结构渲染仍处于禁用状态。
+
+### Contributors
+
+

+
+**[@YinBuLiao](https://github.com/YinBuLiao)** - Contributed host-default shell selection, consistent shell icons and spacing, and bounded notification previews. / 贡献宿主默认 Shell 选择、统一的 Shell 图标与间距,以及通知预览限幅。([#110](https://github.com/Kuddev/pebrel/pull/110)、[#111](https://github.com/Kuddev/pebrel/pull/111)、[#112](https://github.com/Kuddev/pebrel/pull/112))
+
+**[@821869798](https://github.com/821869798)** - Corrected Antigravity confirmation and question-state detection. / 修正 Antigravity 确认与问题选择状态识别。([#101](https://github.com/Kuddev/pebrel/pull/101))
+
+**[@Kuddev](https://github.com/Kuddev)** - Added document and layout workflows, the AI message toast preference, and release integration fixes. / 完成文档与布局工作流、AI 消息弹窗设置及发布整合修复。
+
+---
+
+**SHA256**
+
+- `Pebrel-v1.7.0-windows-x64.zip`: `PENDING FINAL BUILD`
+- `Pebrel-v1.7.0-windows-x64-setup.exe`: `PENDING FINAL BUILD`
+- `NebulaTerminal-1.7.0-windows-x64-setup.exe`: `PENDING FINAL BUILD`
+- `Pebrel-v1.7.0-linux-x64-preview.AppImage`: `PENDING FINAL BUILD`
+- `Pebrel-v1.7.0-linux-x64-preview.deb`: `PENDING FINAL BUILD`
+- `Pebrel-v1.7.0-linux-x64-preview.tar.gz`: `PENDING FINAL BUILD`
+- `Pebrel-v1.7.0-macos-arm64-preview.dmg`: `PENDING FINAL BUILD`
+- `Pebrel-v1.7.0-macos-x64-preview.dmg`: `PENDING FINAL BUILD`
+
## 1.6.0 - 2026-09-07
### English
diff --git a/Cargo.lock b/Cargo.lock
index 6e484e1..32399d6 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -5087,7 +5087,7 @@ dependencies = [
[[package]]
name = "nebula"
-version = "1.6.0"
+version = "1.7.0"
dependencies = [
"ab_glyph_rasterizer",
"aes-gcm",
@@ -5174,7 +5174,7 @@ dependencies = [
[[package]]
name = "nebula-completions"
-version = "1.6.0"
+version = "1.7.0"
dependencies = [
"lscolors",
"nu-ansi-term",
@@ -5195,15 +5195,15 @@ dependencies = [
[[package]]
name = "nebula-settings"
-version = "1.6.0"
+version = "1.7.0"
[[package]]
name = "nebula-split"
-version = "1.6.0"
+version = "1.7.0"
[[package]]
name = "nebula_config"
-version = "1.6.0"
+version = "1.7.0"
dependencies = [
"log",
"nebula_config_derive",
@@ -5213,7 +5213,7 @@ dependencies = [
[[package]]
name = "nebula_config_derive"
-version = "1.6.0"
+version = "1.7.0"
dependencies = [
"log",
"nebula_config",
@@ -5230,7 +5230,7 @@ version = "0.6.0"
[[package]]
name = "nebula_terminal"
-version = "1.6.0"
+version = "1.7.0"
dependencies = [
"base64",
"bitflags 2.13.0",
diff --git a/docs/release-notes/v1.7.0.md b/docs/release-notes/v1.7.0.md
new file mode 100644
index 0000000..c520515
--- /dev/null
+++ b/docs/release-notes/v1.7.0.md
@@ -0,0 +1,78 @@
+# Pebrel 1.7.0
+
+## English
+
+### Added
+
+- Added an AI message toast switch in Settings → Terminal → Alerts. Turning it off dismisses in-app AI cards and suppresses new ones, while retaining system notifications and tab status indicators. Addresses [#107](https://github.com/Kuddev/nebula/issues/107).
+- Added editable local and SSH text documents with save operations, changed-file checks, and preservation of unsaved text when a save fails.
+- Added reusable terminal layouts that open in a separate window and retain terminal launch settings without replaying command history.
+- Added dragging files from the system file manager into local and WSL terminals to insert quoted paths without executing them. Local files for SSH sessions can be uploaded through Remote Files. Addresses [#106](https://github.com/Kuddev/nebula/issues/106).
+- Expanded the built-in theme picker to 13 palettes, including Breeze, Mint, Catppuccin Mocha/Latte, and Glass light/dark variants. Glass uses static colors and does not automatically enable background blur.
+
+### Fixed
+
+- Fixed the Default Shell setting and the new-terminal menu recommending PowerShell on macOS when no shell is configured. Both now use the same host-default resolver as the terminal backend. Contributed by [@YinBuLiao](https://github.com/YinBuLiao) in [#110](https://github.com/Kuddev/pebrel/pull/110).
+- Fixed mixed shell icons giving settings-menu rows different heights. Shells without a brand image also receive an icon in the new-terminal menu. Contributed by [@YinBuLiao](https://github.com/YinBuLiao) in [#111](https://github.com/Kuddev/pebrel/pull/111).
+- Shortened long and multi-line notification previews while retaining the original notification text in logs. Plain persistent notifications can be dismissed by clicking the card. Contributed in part by [@YinBuLiao](https://github.com/YinBuLiao) in [#112](https://github.com/Kuddev/pebrel/pull/112).
+- Improved foreground contrast for terminal text on light backgrounds while preserving color pairs that are already readable. Addresses [#104](https://github.com/Kuddev/nebula/issues/104).
+- Fixed the System language setting falling back to English when macOS applications launched from Finder have no locale environment variables.
+- Corrected Antigravity status detection so ordinary replies do not look like pending confirmations, while active question and file-review prompts are recognized as waiting for input. Addresses [#100](https://github.com/Kuddev/nebula/issues/100). Contributed by [@821869798](https://github.com/821869798) in [#101](https://github.com/Kuddev/pebrel/pull/101).
+
+### Improved
+
+- Improved Markdown reading with bounded preview work and shared background formula rendering; unsupported or oversized expressions retain their source.
+- Added in-place feedback to code and formula copy controls, and kept document focus mode separate from operating-system fullscreen.
+- Made Git-ignored files and directories distinguishable with italic text in the file tree.
+
+Windows packages include an installer and portable ZIP. Linux x64 packages and macOS Apple Silicon/Intel DMGs retain their Preview designation. Molecular structure rendering remains disabled.
+
+## 中文
+
+### 新增
+
+- 在“设置 → 终端 → 提醒”中新增“AI 消息弹窗”开关。关闭后收起已有的应用内 AI 卡片并停止显示新的卡片,同时保留系统通知和标签状态提示。对应 [#107](https://github.com/Kuddev/nebula/issues/107)。
+- 新增本地和 SSH 文本文档编辑,支持保存、文件外部变更检查,并在保存失败时保留未保存的内容。
+- 新增可复用的终端布局,在独立窗口中打开并保留终端启动设置,不重放命令历史。
+- 新增从系统文件管理器向本地和 WSL 终端拖入文件,插入加引号的路径而不执行;SSH 会话所需的本地文件可通过“远程文件”上传。对应 [#106](https://github.com/Kuddev/nebula/issues/106)。
+- 将内置主题选择器扩展为 13 套配色,包含 Breeze、Mint、Catppuccin Mocha/Latte,以及 Glass 浅色/深色。Glass 使用静态配色,不会自动开启背景模糊。
+
+### 修复
+
+- 修复 macOS 未配置 Shell 时,“默认 Shell”设置和新建终端菜单推荐 PowerShell 的问题。两处现在与终端后端共用宿主默认 Shell 解析。由 [@YinBuLiao](https://github.com/YinBuLiao) 在 [#110](https://github.com/Kuddev/pebrel/pull/110) 中贡献。
+- 修复不同 Shell 图标使设置菜单行高不一致的问题;没有品牌图片的 Shell 在新建终端菜单中也会显示图标。由 [@YinBuLiao](https://github.com/YinBuLiao) 在 [#111](https://github.com/Kuddev/pebrel/pull/111) 中贡献。
+- 缩短长通知和多行通知的预览,同时在日志中保留通知原文。部分修复由 [@YinBuLiao](https://github.com/YinBuLiao) 在 [#112](https://github.com/Kuddev/pebrel/pull/112) 中贡献。
+- 改进浅色终端背景上的文字对比度,同时保留原本可读的配色。对应 [#104](https://github.com/Kuddev/nebula/issues/104)。
+- 修复 macOS 从 Finder 启动、缺少语言环境变量时,“跟随系统”回退到英文的问题。
+- 修正 Antigravity 状态识别:普通回答不再误报为等待确认,实际的问题选择框和文件审核框能识别为等待输入。对应 [#100](https://github.com/Kuddev/nebula/issues/100)。由 [@821869798](https://github.com/821869798) 在 [#101](https://github.com/Kuddev/pebrel/pull/101) 中贡献。
+
+### 改进
+
+- 改进 Markdown 阅读,限制预览处理量,并共享后台公式渲染;不支持或超限的表达式保留源码。
+- 为代码和公式复制控件加入就地反馈,并将文档专注模式与系统全屏分开。
+- 文件树中被 Git 忽略的文件和目录使用斜体显示,便于区分。
+
+Windows 提供安装器和 ZIP 便携包;Linux x64 包以及 macOS Apple Silicon/Intel DMG 继续标记为 Preview。分子结构渲染仍处于禁用状态。
+
+## Contributors
+
+

+
+**[@YinBuLiao](https://github.com/YinBuLiao)** - Contributed host-default shell selection, consistent shell icons and spacing, and bounded notification previews. / 贡献宿主默认 Shell 选择、统一的 Shell 图标与间距,以及通知预览限幅。([#110](https://github.com/Kuddev/pebrel/pull/110)、[#111](https://github.com/Kuddev/pebrel/pull/111)、[#112](https://github.com/Kuddev/pebrel/pull/112))
+
+**[@821869798](https://github.com/821869798)** - Corrected Antigravity confirmation and question-state detection. / 修正 Antigravity 确认与问题选择状态识别。([#101](https://github.com/Kuddev/pebrel/pull/101))
+
+**[@Kuddev](https://github.com/Kuddev)** - Added document and layout workflows, the AI message toast preference, and release integration fixes. / 完成文档与布局工作流、AI 消息弹窗设置及发布整合修复。
+
+---
+
+**SHA256**
+
+- `Pebrel-v1.7.0-windows-x64.zip`: `PENDING FINAL BUILD`
+- `Pebrel-v1.7.0-windows-x64-setup.exe`: `PENDING FINAL BUILD`
+- `NebulaTerminal-1.7.0-windows-x64-setup.exe`: `PENDING FINAL BUILD`
+- `Pebrel-v1.7.0-linux-x64-preview.AppImage`: `PENDING FINAL BUILD`
+- `Pebrel-v1.7.0-linux-x64-preview.deb`: `PENDING FINAL BUILD`
+- `Pebrel-v1.7.0-linux-x64-preview.tar.gz`: `PENDING FINAL BUILD`
+- `Pebrel-v1.7.0-macos-arm64-preview.dmg`: `PENDING FINAL BUILD`
+- `Pebrel-v1.7.0-macos-x64-preview.dmg`: `PENDING FINAL BUILD`
diff --git a/nebula-completions/Cargo.toml b/nebula-completions/Cargo.toml
index f61de2d..75e2541 100644
--- a/nebula-completions/Cargo.toml
+++ b/nebula-completions/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "nebula-completions"
-version = "1.6.0"
+version = "1.7.0"
edition.workspace = true
[features]
diff --git a/nebula_app/Cargo.toml b/nebula_app/Cargo.toml
index 6002e58..8358e5d 100644
--- a/nebula_app/Cargo.toml
+++ b/nebula_app/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "nebula"
-version = "1.6.0"
+version = "1.7.0"
license = "GPL-3.0-or-later"
description = "Pebrel — a GPU-accelerated terminal emulator"
readme = "README.md"
@@ -15,15 +15,15 @@ path = "src/main.rs"
[dependencies.nebula_terminal]
path = "../nebula_terminal"
-version = "1.6.0"
+version = "1.7.0"
[dependencies.nebula_config_derive]
path = "../nebula_config_derive"
-version = "1.6.0"
+version = "1.7.0"
[dependencies.nebula_config]
path = "../nebula_config"
-version = "1.6.0"
+version = "1.7.0"
# 可选:GPUI UI 层(feature = "gpui-shell",模块 src/gpui_shell/)。
# GPUI/component 的版本和 Git 来源由根 Cargo.toml 统一精确固定。
@@ -67,7 +67,7 @@ optional = true
[dependencies.nebula-completions]
path = "../nebula-completions"
-version = "1.6.0"
+version = "1.7.0"
[dependencies]
ahash = { version = "0.8.6", features = ["no-rng"] }
@@ -141,7 +141,7 @@ rusqlite = { version = "0.40.2", features = ["bundled"] }
sha1 = "0.11.0"
[build-dependencies]
-nebula-settings = { path = "../nebula_settings", version = "1.6.0" }
+nebula-settings = { path = "../nebula_settings", version = "1.7.0" }
gl_generator = { version = "0.14.0", optional = true }
serde = "1"
serde_json = "1"
diff --git a/nebula_app/src/display/ui/theme.rs b/nebula_app/src/display/ui/theme.rs
index 2a6d769..5cb6bea 100644
--- a/nebula_app/src/display/ui/theme.rs
+++ b/nebula_app/src/display/ui/theme.rs
@@ -1007,8 +1007,14 @@ mod tests {
assert_eq!(nord.accent, crate::display::color::Rgb::new(0x88, 0xc0, 0xd0));
let paper = NebulaTheme::Paper.skin();
- assert_eq!(paper.panel, crate::renderer::ui::Rgba::new(0xf5, 0xf4, 0xf0, 255));
- assert_eq!(paper.card, crate::renderer::ui::Rgba::new(0xfc, 0xfb, 0xf9, 255));
+ // Floating panels use the reviewed content background; the outer shell
+ // keeps its separate warm-gray token.
+ assert_eq!(paper.panel, crate::renderer::ui::Rgba::new(0xfc, 0xfb, 0xf9, 255));
+ assert_eq!(paper.card, crate::renderer::ui::Rgba::new(0xe9, 0xe8, 0xe1, 255));
+ assert_eq!(
+ NebulaTheme::Paper.palette().panel,
+ crate::renderer::ui::Rgba::new(0xf5, 0xf4, 0xf0, 255)
+ );
assert_eq!(paper.accent, crate::display::color::Rgb::new(0x2b, 0x5a, 0x38));
}
}
diff --git a/nebula_app/src/gpui_shell/file_editor/code_actions.rs b/nebula_app/src/gpui_shell/file_editor/code_actions.rs
index ec12484..b0c603c 100644
--- a/nebula_app/src/gpui_shell/file_editor/code_actions.rs
+++ b/nebula_app/src/gpui_shell/file_editor/code_actions.rs
@@ -310,6 +310,7 @@ fn render_language_picker(
anchored().snap_to_window_with_margin(px(8.0)).child(
div()
.id("markdown-language-popup")
+ .debug_selector(|| "markdown-language-popup".to_owned())
.occlude()
.w(px(224.0))
.max_h(px(280.0))
diff --git a/nebula_app/src/gpui_shell/file_editor/code_actions_tests.rs b/nebula_app/src/gpui_shell/file_editor/code_actions_tests.rs
index 5775bf8..4eadef9 100644
--- a/nebula_app/src/gpui_shell/file_editor/code_actions_tests.rs
+++ b/nebula_app/src/gpui_shell/file_editor/code_actions_tests.rs
@@ -24,6 +24,18 @@ fn open(path: PathBuf, cx: &mut TestAppContext) -> (Entity, Visual
(file.unwrap(), window.clone())
}
+fn press(key: &str, cx: &mut VisualTestContext) {
+ let keystroke = gpui::Keystroke::parse(key).unwrap();
+ cx.simulate_event(gpui::KeyDownEvent {
+ keystroke: keystroke.clone(),
+ is_held: false,
+ prefer_character_input: false,
+ });
+ // Div's keyboard click is committed on release. simulate_keystrokes only
+ // sends KeyDown, which cannot exercise the real click lifecycle.
+ cx.simulate_event(gpui::KeyUpEvent { keystroke });
+}
+
#[gpui::test]
fn picker_trigger_popup_search_and_rows_keep_html_geometry(cx: &mut TestAppContext) {
let directory = tempfile::tempdir().unwrap();
@@ -103,9 +115,15 @@ fn language_picker_keeps_mouse_and_keyboard_activation_distinct(cx: &mut TestApp
// Closing by Escape restores the trigger focus. Enter must then activate
// the same control through its keyboard click path, not through a mouse
// focus side effect.
- cx.simulate_keystrokes("escape");
+ press("escape", &mut cx);
assert!(cx.update(|window, _| window.last_input_was_keyboard()));
- cx.simulate_keystrokes("enter");
+ press("enter", &mut cx);
+ cx.update(|window, cx| {
+ let _ = window.draw(cx);
+ });
+ assert!(cx.debug_bounds("markdown-language-popup").is_some());
+ press("escape", &mut cx);
+ press("space", &mut cx);
cx.update(|window, cx| {
let _ = window.draw(cx);
});
diff --git a/nebula_app/src/gpui_shell/prelude.rs b/nebula_app/src/gpui_shell/prelude.rs
index 7f68825..518c5fe 100644
--- a/nebula_app/src/gpui_shell/prelude.rs
+++ b/nebula_app/src/gpui_shell/prelude.rs
@@ -189,7 +189,12 @@ mod tests {
#[gpui::test]
fn confirm_dialog_mouse_buttons_dispatch_cancel_and_confirm(cx: &mut TestAppContext) {
- cx.update(gpui_component::init);
+ cx.update(|cx| {
+ gpui_component::init(cx);
+ // Test button dispatch at the settled layout. The dialog's slide-in
+ // animation otherwise moves the hit target between mouse down/up.
+ cx.set_reduce_motion(true);
+ });
let (_, cx) = cx.add_window_view(|window, cx| {
let view = cx.new(|_| ConfirmDialogProbe);
Root::new(view, window, cx)
diff --git a/nebula_app/src/gpui_shell/settings_pane.rs b/nebula_app/src/gpui_shell/settings_pane.rs
index 0420c20..8d6c22f 100644
--- a/nebula_app/src/gpui_shell/settings_pane.rs
+++ b/nebula_app/src/gpui_shell/settings_pane.rs
@@ -56,6 +56,8 @@ mod keymap;
mod localization;
mod navigation;
mod shell_picker;
+#[cfg(all(test, feature = "gpui-test-support"))]
+mod shell_picker_tests;
mod status;
use localization::*;
@@ -547,7 +549,7 @@ impl SettingsPane {
/// 按当前设置值重建下拉候选(导入后新增行即时可见)。
fn refresh_shell_items(&mut self, window: &mut Window, cx: &mut Context) {
- let current = self.runtime.shell.clone().unwrap_or_else(|| "powershell".into());
+ let current = crate::platform::shell::effective_shell_id(self.runtime.shell.as_deref());
let (items, selected) = shell_select_items(
¤t,
window.scale_factor().max(0.5),
@@ -561,7 +563,7 @@ impl SettingsPane {
/// 把下拉选中项拨回设置里真正生效的 shell。
fn restore_shell_selection(&mut self, window: &mut Window, cx: &mut Context) {
- let current = self.runtime.shell.clone().unwrap_or_else(|| "powershell".into());
+ let current = crate::platform::shell::effective_shell_id(self.runtime.shell.as_deref());
self.shell_select.update(cx, |state, cx| {
state.set_selected_value(¤t, window, cx);
});
@@ -711,6 +713,7 @@ impl SettingsPane {
this.toggle(key, *checked, window, cx);
}),
);
+ let control = div().debug_selector(move || format!("nebula-switch-{key}")).child(control);
self.maybe_marked(key, label, desc, control, cx)
}
diff --git a/nebula_app/src/gpui_shell/settings_pane/appearance_picker_tests.rs b/nebula_app/src/gpui_shell/settings_pane/appearance_picker_tests.rs
index 0e58584..39d25a2 100644
--- a/nebula_app/src/gpui_shell/settings_pane/appearance_picker_tests.rs
+++ b/nebula_app/src/gpui_shell/settings_pane/appearance_picker_tests.rs
@@ -18,18 +18,18 @@ fn appearance_theme_catalog_matches_the_approved_gallery_order() {
"Mint Light",
"Mint Dark",
"Silver",
- "Pebrel",
- "Steel",
"Nord",
"Paper",
- "Moss",
"Limestone",
- "Coal",
- "Linen"
+ "Linen",
+ "Catppuccin Mocha",
+ "Catppuccin Latte",
+ "Glass Light",
+ "Glass Dark"
]
);
- assert_eq!(themes.choices(1).len(), 6);
- assert_eq!(themes.choices(2).len(), 7);
+ assert_eq!(themes.choices(1).len(), 8);
+ assert_eq!(themes.choices(2).len(), 5);
for choice in themes.choices(0) {
assert_ne!(themes.choices(1).contains(&choice), themes.choices(2).contains(&choice));
}
diff --git a/nebula_app/src/gpui_shell/settings_pane/initialization.rs b/nebula_app/src/gpui_shell/settings_pane/initialization.rs
index 98f1589..fac590c 100644
--- a/nebula_app/src/gpui_shell/settings_pane/initialization.rs
+++ b/nebula_app/src/gpui_shell/settings_pane/initialization.rs
@@ -46,7 +46,7 @@ impl SettingsPane {
let cursor_current =
runtime.cursor_shape.map(|shape| shape.settings_value()).unwrap_or("beam");
- let shell_current = runtime.shell.clone().unwrap_or_else(|| "powershell".into());
+ let shell_current = crate::platform::shell::effective_shell_id(runtime.shell.as_deref());
add_select(
"language",
diff --git a/nebula_app/src/gpui_shell/settings_pane/shell_picker.rs b/nebula_app/src/gpui_shell/settings_pane/shell_picker.rs
index 9830cf6..0a84954 100644
--- a/nebula_app/src/gpui_shell/settings_pane/shell_picker.rs
+++ b/nebula_app/src/gpui_shell/settings_pane/shell_picker.rs
@@ -15,6 +15,12 @@ pub(super) struct ShellSelectItem {
/// `wsl:`、`profile:<家族>|`),双下划线包裹不可能与之相撞。
pub(super) const SHELL_IMPORT_ACTION_ID: &str = "__nebula_import_terminal_dir__";
+/// 菜单行的逻辑边长(品牌贴图与图标槽同用)。
+pub(super) const SHELL_ROW_ICON_SIZE: f32 = 24.0;
+
+/// 回落字形的字号系数:见 `shell_detect::FALLBACK_ICON_SCALE`。
+const FALLBACK_ICON_SCALE: f32 = crate::shell_detect::FALLBACK_ICON_SCALE;
+
impl ShellSelectItem {
pub(super) fn new(id: String, name: String, scale_factor: f32) -> Self {
// Select 的闭态和菜单行尺寸不同。分别生成与物理像素一一对应的纹理,
@@ -47,15 +53,36 @@ impl ShellSelectItem {
.into_any_element()
} else if self.is_import_action() {
// 动作行与真实 shell 行必须一眼分得开:文件夹口 = 「去别处拿」。
- Icon::new(IconName::FolderOpen).xsmall().into_any_element()
+ Icon::new(IconName::FolderOpen).size(px(size * FALLBACK_ICON_SCALE)).into_any_element()
} else {
- Icon::new(IconName::SquareTerminal).xsmall().into_any_element()
+ // 没有品牌贴图的 shell 沿用旧壳那张按 id 取字的 Nerd Font 表
+ // (`icon_for_id`,设置行/命令面板同一口径)。字号按回落字形的
+ // 墨迹比例配平:品牌 PNG 的可见部分是自己边长的约 0.77(12%
+ // 安全边距),codicon terminal 的墨迹约 0.88 em——乘 0.88 之后
+ // 两种图标才真的同尺寸。
+ div()
+ .font_family(crate::font_install::REQUIRED_FONT_FAMILY)
+ .text_size(px(size * FALLBACK_ICON_SCALE))
+ .child(crate::shell_detect::icon_for_id(&self.id))
+ .into_any_element()
};
h_flex()
.gap_2()
.items_center()
- .child(icon)
- .child(div().flex_1().min_w_0().child(self.name.clone()))
+ // 图标槽固定成同一个边长,绝不随图标种类伸缩:gpui-component 的
+ // `List` 只量首行(这里是「导入终端目录」动作行)的高度,再拿它
+ // 排所有行;品牌 PNG 有 24px、回落图标只有 xsmall,混排时高行会
+ // 溢出自己的槽位,压住上下相邻行(选中高亮错位就是这么来的)。
+ .child(
+ div()
+ .size(px(size))
+ .flex_shrink_0()
+ .flex()
+ .items_center()
+ .justify_center()
+ .child(icon),
+ )
+ .child(div().flex_1().min_w_0().truncate().child(self.name.clone()))
.into_any_element()
}
}
@@ -75,12 +102,15 @@ pub(super) fn shell_select_items(
.map(|shell| ShellSelectItem::new(shell.id, shell.name, scale_factor))
.collect();
if items.is_empty() {
- // 非 Windows 构建不做安装探测,但历史配置仍支持这两个由 PTY
- // 集成层负责启动的稳定 id,设置页不能因此变成空下拉。
- items = vec![
- ShellSelectItem::new("powershell".into(), "PowerShell".into(), scale_factor),
- ShellSelectItem::new("bash".into(), "Git Bash".into(), scale_factor),
- ];
+ // 探测不到任何已安装 shell 时也要给出一行——用宿主默认 id,而不是
+ // 写死的 PowerShell/Git Bash:没有安装探测结果的 Unix 机器(例如
+ // `$SHELL` 与 `/etc/shells` 都读不到)默认是登录 shell,不是 PS。
+ let id = crate::platform::shell::default_shell_id();
+ items = vec![ShellSelectItem::new(
+ id.clone(),
+ crate::shell_detect::display_name_for_id(&id),
+ scale_factor,
+ )];
}
// 导入的终端目录:`merge_terminal_profiles` 已把它们并进配置的 profile
// 列表,这里让设置页也能直接选为默认 Shell——否则导入完看不见结果。
@@ -170,8 +200,10 @@ impl SelectItem for ShellSelectItem {
}
fn render(&self, _: &mut Window, _: &mut App) -> impl IntoElement {
- // 旧壳 ShellPickerRow 的品牌图标是 24×24 逻辑像素。
- self.view(24.0, self.row_image.as_ref())
+ // List measures the first visible row, which changes after searching.
+ // Every item must use the same content height; the component supplies
+ // the surrounding row padding and selection background.
+ self.view(SHELL_ROW_ICON_SIZE, self.row_image.as_ref())
}
fn value(&self) -> &Self::Value {
diff --git a/nebula_app/src/gpui_shell/settings_pane/shell_picker_tests.rs b/nebula_app/src/gpui_shell/settings_pane/shell_picker_tests.rs
new file mode 100644
index 0000000..08206ff
--- /dev/null
+++ b/nebula_app/src/gpui_shell/settings_pane/shell_picker_tests.rs
@@ -0,0 +1,130 @@
+//! Exercise the real Select popup, including its virtual list and search.
+
+use super::*;
+use gpui::{Modifiers, TestAppContext, VisualTestContext};
+use gpui_component::Root;
+use gpui_component::select::SearchableVec;
+
+#[derive(Clone)]
+struct ProbeItem(ShellSelectItem);
+
+impl SelectItem for ProbeItem {
+ type Value = String;
+
+ fn title(&self) -> SharedString {
+ self.0.title()
+ }
+
+ fn value(&self) -> &String {
+ self.0.value()
+ }
+
+ fn display_title(&self) -> Option {
+ self.0.display_title()
+ }
+
+ fn matches(&self, query: &str) -> bool {
+ self.0.matches(query)
+ }
+
+ fn render(&self, window: &mut Window, cx: &mut App) -> impl IntoElement {
+ let selector = format!("shell-row-{}", self.0.id);
+ div().debug_selector(move || selector.clone()).child(self.0.render(window, cx))
+ }
+}
+
+struct ShellPickerProbe {
+ select: Entity>>,
+}
+
+impl Render for ShellPickerProbe {
+ fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement {
+ div().size_full().p_4().child(
+ div()
+ .debug_selector(|| "shell-picker-trigger".to_owned())
+ .w(px(240.0))
+ .h(px(32.0))
+ .child(Select::new(&self.select)),
+ )
+ }
+}
+
+fn draw(cx: &mut VisualTestContext) {
+ cx.run_until_parked();
+ cx.update(|window, cx| {
+ let _ = window.draw(cx);
+ });
+}
+
+fn click(selector: &'static str, cx: &mut VisualTestContext) {
+ let position = cx.debug_bounds(selector).expect("visible picker control").center();
+ cx.simulate_mouse_down(position, MouseButton::Left, Modifiers::default());
+ cx.simulate_mouse_up(position, MouseButton::Left, Modifiers::default());
+ draw(cx);
+}
+
+#[gpui::test]
+fn shell_popup_keeps_mixed_rows_separate_before_and_after_search(cx: &mut TestAppContext) {
+ cx.update(gpui_component::init);
+ let mut select = None;
+ let (_, mut window) = cx.add_window_view(|window, cx| {
+ let items = vec![
+ ShellSelectItem::import_action(crate::display::UiLanguage::ZhCn),
+ ShellSelectItem::new("pwsh".into(), "PowerShell 7".into(), 1.0),
+ ShellSelectItem::new("powershell".into(), "PowerShell".into(), 1.0),
+ ShellSelectItem::new(
+ "wsl:Ubuntu".into(),
+ "WSL · Ubuntu · 长名称仍保持单行且不覆盖相邻选项".into(),
+ 1.0,
+ ),
+ ShellSelectItem::new("zsh".into(), "Zsh".into(), 1.0),
+ ]
+ .into_iter()
+ .map(ProbeItem)
+ .collect::>();
+ let state = cx.new(|cx| {
+ // Exercise the optional search mode with its actual filtering
+ // delegate; a plain Vec deliberately leaves the rows unfiltered.
+ SelectState::new(SearchableVec::new(items), Some(IndexPath::new(1)), window, cx)
+ .searchable(true)
+ });
+ select = Some(state.clone());
+ let view = cx.new(|_| ShellPickerProbe { select: state });
+ Root::new(view, window, cx)
+ });
+ draw(&mut window);
+ click("shell-picker-trigger", &mut window);
+
+ let selectors = [
+ "shell-row-__nebula_import_terminal_dir__",
+ "shell-row-pwsh",
+ "shell-row-powershell",
+ "shell-row-wsl:Ubuntu",
+ "shell-row-zsh",
+ ];
+ let bounds: Vec<_> = selectors
+ .iter()
+ .map(|selector| window.debug_bounds(selector).expect("real popup row"))
+ .collect();
+ for adjacent in bounds.windows(2) {
+ assert!(adjacent[0].origin.y + adjacent[0].size.height <= adjacent[1].origin.y);
+ }
+ for row in &bounds {
+ assert_eq!(row.size.height, bounds[1].size.height);
+ }
+
+ window.simulate_input("PowerShell");
+ draw(&mut window);
+ let first = window.debug_bounds("shell-row-pwsh").expect("filtered PowerShell 7 row");
+ let second = window.debug_bounds("shell-row-powershell").expect("filtered PowerShell row");
+ assert!(first.origin.y + first.size.height <= second.origin.y);
+ assert_eq!(second.origin.y - first.origin.y, bounds[2].origin.y - bounds[1].origin.y);
+ assert!(window.debug_bounds("shell-row-wsl:Ubuntu").is_none());
+ click("shell-row-powershell", &mut window);
+ window.update(|_, cx| {
+ assert_eq!(
+ select.as_ref().unwrap().read(cx).selected_value().map(String::as_str),
+ Some("powershell")
+ );
+ });
+}
diff --git a/nebula_app/src/gpui_shell/settings_pane/tests.rs b/nebula_app/src/gpui_shell/settings_pane/tests.rs
index f4c39b5..f2f663a 100644
--- a/nebula_app/src/gpui_shell/settings_pane/tests.rs
+++ b/nebula_app/src/gpui_shell/settings_pane/tests.rs
@@ -137,3 +137,60 @@ fn cached_semantic_statuses_render_in_the_current_language() {
assert_eq!(ssh.text(crate::display::UiLanguage::ZhCn), "正在打开 server.example…");
assert_eq!(ssh.text(crate::display::UiLanguage::EnUs), "Opening server.example…");
}
+
+/// Every row, including the import action, has the same icon slot height.
+/// The virtual list measures its first visible item; filtering must not change
+/// the row geometry when that item changes from an action to a shell.
+#[cfg(feature = "gpui-test-support")]
+mod shell_row_geometry {
+ use super::*;
+ use gpui::TestAppContext;
+
+ struct ShellRowProbe {
+ rows: Vec<(&'static str, ShellSelectItem)>,
+ }
+
+ impl Render for ShellRowProbe {
+ fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement {
+ // 把继承字号压到图标槽以下,让行高只由图标槽决定。这样断言测的
+ // 就是「行高与图标种类无关」这条不变量本身,而不是某套字体度量。
+ // 真实弹出列表与搜索另由 shell_picker_tests 覆盖。
+ v_flex().w(px(240.0)).text_size(px(8.0)).children(self.rows.iter().map(
+ |(selector, item)| {
+ div()
+ .debug_selector(move || (*selector).to_owned())
+ .child(item.render(window, cx))
+ },
+ ))
+ }
+ }
+
+ #[gpui::test]
+ fn every_row_shares_one_icon_slot_height(cx: &mut TestAppContext) {
+ cx.update(gpui_component::init);
+ let rows = vec![
+ (
+ "shell-probe-import",
+ ShellSelectItem::import_action(crate::display::UiLanguage::ZhCn),
+ ),
+ (
+ "shell-probe-brand",
+ ShellSelectItem::new("pwsh".to_owned(), "PowerShell 7".to_owned(), 1.0),
+ ),
+ ("shell-probe-glyph", ShellSelectItem::new("zsh".to_owned(), "Zsh".to_owned(), 1.0)),
+ ];
+ let (_, cx) = cx.add_window_view(|_, _| ShellRowProbe { rows });
+ let heights: Vec = ["shell-probe-import", "shell-probe-brand", "shell-probe-glyph"]
+ .iter()
+ .map(|selector| {
+ f32::from(cx.debug_bounds(selector).expect("shell row bounds").size.height)
+ })
+ .collect();
+ assert_eq!(heights[1], heights[2], "品牌图标行与字形回落行必须等高: {heights:?}");
+ assert_eq!(heights[1], SHELL_ROW_ICON_SIZE, "普通行的图标槽即整行高度: {heights:?}");
+ assert_eq!(
+ heights[0], heights[1],
+ "导入行和 Shell 行必须等高,搜索改变首行时不得改变行距: {heights:?}"
+ );
+ }
+}
diff --git a/nebula_app/src/gpui_shell/toast.rs b/nebula_app/src/gpui_shell/toast.rs
index ffc3a0d..3a05e2f 100644
--- a/nebula_app/src/gpui_shell/toast.rs
+++ b/nebula_app/src/gpui_shell/toast.rs
@@ -102,6 +102,11 @@ fn is_duplicate(text: &str) -> bool {
}
fn note(kind: ToastKind, text: String) -> Notification {
+ // 通知栏是「一眼带过」的层。正文先收短:模型回合完成的正文可能是整段
+ // 回答(OSC 9 更是无界),不设上限时按固定宽度排出来比窗口还高,锚在
+ // 右下就会整条溢出屏幕、把关闭按钮顶到看不见的地方。完整原文仍在日志
+ // 与终端的「阅读 / 原文」里。
+ let text = crate::notify::clamp_toast_body(&text);
let note = match kind {
ToastKind::Info => Notification::info(text),
ToastKind::Success => Notification::success(text),
@@ -155,6 +160,9 @@ pub fn render_layer(window: &mut Window, cx: &mut App) -> Option {
.items_end()
.justify_end()
.p(px(20.0))
+ // 约束动画和阴影的绘制范围。裁剪本身不限制卡片/堆叠高度,
+ // 关闭按钮是否可达仍须由窗口尺寸与真实布局回归验证。
+ .overflow_hidden()
// 用全视口透明宿主明确右下锚点;宿主不注册命中,只有实际
// Notification 卡片会 occlude/接收鼠标,周围终端仍可正常点击。
.child(v_flex().items_end().gap_2().children(items))
@@ -174,13 +182,17 @@ pub fn toast(window: &mut Window, cx: &mut App, kind: ToastKind, text: impl Into
/// 驻留一条消息(消息栏层):停留远长于 toast,但**有上限**,见
/// [`BANNER_TTL`]。用于「值得驻留」的事——没什么值得驻留的请用 [`toast`]。
+///
+/// 这里也挂一个空的 `on_click`:组件库的点击包装会先 `dismiss` 再调回调,
+/// 所以整张卡片点一下就消失。驻留消息的关闭按钮只在悬停时出现,没有这条
+/// 路径的话「怎么都关不掉」就是字面事实。
pub fn banner(window: &mut Window, cx: &mut App, kind: ToastKind, text: impl Into) {
let text = text.into();
if text.trim().is_empty() {
return;
}
log::warn!("banner [{kind:?}]: {text}");
- push_banner(window, cx, note(kind, text), false);
+ push_banner(window, cx, note(kind, text).on_click(|_, _, _| {}), false);
}
pub(crate) fn banner_for_pane(
diff --git a/nebula_app/src/gpui_shell/workspace.rs b/nebula_app/src/gpui_shell/workspace.rs
index e62b255..765fd03 100644
--- a/nebula_app/src/gpui_shell/workspace.rs
+++ b/nebula_app/src/gpui_shell/workspace.rs
@@ -63,6 +63,8 @@ mod remote_files;
mod residency;
mod send_to_chat;
mod session_persistence;
+mod shell_picker;
+use shell_picker::shell_palette_rows;
mod settings_navigation;
mod sidebar;
mod ssh_dialog;
@@ -769,89 +771,6 @@ fn settings_should_fold_sidebar(tabs_position: nebula_settings::TabsPositionName
tabs_position == nebula_settings::TabsPositionName::Sidebar
}
-/// 新建终端弹窗的行:已检测 shell + SSH 主机,分组对照旧壳
-/// `CommandPalette::open_profiles`(推荐 / 所有 Shell / SSH 主机)。
-/// 三点菜单与 Ctrl+K 打开的是这份列表,不是通用命令面板。
-fn shell_palette_rows(
- shells: Vec,
- profiles: Vec,
- ssh_hosts: impl IntoIterator- ,
- default_shell_id: &str,
- language: crate::display::UiLanguage,
- scale_factor: f32,
-) -> Vec {
- const SHELL_ICON_PX: f32 = 22.0;
- let recommended = language.pick("推荐", "Recommended");
- let all_shells = language.pick("所有 Shell", "All shells");
- let ssh_group = language.pick("SSH 主机", "SSH hosts");
- let mut rows: Vec = shells
- .into_iter()
- .map(|shell| {
- let is_default = shell.id == default_shell_id;
- WorkspacePaletteRow {
- group_order: if is_default { 0 } else { 1 },
- group: if is_default { recommended.to_owned() } else { all_shells.to_owned() },
- label: shell.name.clone(),
- hint: shell.program.clone(),
- hint_style: WorkspacePaletteHintStyle::Metadata,
- search: format!("{} {} shell profile", shell.name, shell.id).to_lowercase(),
- icon: crate::gpui_shell::widgets::shell_brand_image(
- &shell.id,
- SHELL_ICON_PX,
- scale_factor,
- ),
- icon_glyph: None,
- icon_path: None,
- action: WorkspacePaletteAction::LaunchShell(shell),
- }
- })
- .collect();
- rows.extend(profiles.into_iter().filter_map(|profile| {
- let id = profile.settings_id()?;
- let is_default = id.eq_ignore_ascii_case(default_shell_id);
- let icon_id = profile.shell_id.as_deref().unwrap_or(&id);
- let icon =
- crate::gpui_shell::widgets::shell_brand_image(icon_id, SHELL_ICON_PX, scale_factor);
- let label = profile.name.clone();
- let hint = profile.command.clone();
- Some(WorkspacePaletteRow {
- group_order: if is_default { 0 } else { 1 },
- group: if is_default { recommended.to_owned() } else { all_shells.to_owned() },
- search: format!("{} {} {} shell profile", profile.name, id, profile.command)
- .to_lowercase(),
- label,
- hint,
- hint_style: WorkspacePaletteHintStyle::Metadata,
- action: WorkspacePaletteAction::LaunchProfile(profile),
- icon,
- icon_glyph: None,
- icon_path: None,
- })
- }));
- if let Some(position) = rows.iter().position(|row| row.group_order == 0) {
- let default_row = rows.remove(position);
- rows.insert(0, default_row);
- }
- let ssh_icons = ssh_host_icon_ids(&crate::display::nebula_data_dir());
- rows.extend(ssh_hosts.into_iter().map(|host| {
- let glyph =
- crate::display::ui::os_icons::resolve(ssh_icons.get(&host).map(String::as_str)).glyph;
- WorkspacePaletteRow {
- group_order: 2,
- group: ssh_group.to_owned(),
- label: host.clone(),
- hint: "SSH".to_owned(),
- hint_style: WorkspacePaletteHintStyle::Metadata,
- search: format!("{host} ssh host remote lianjie 连接").to_lowercase(),
- action: WorkspacePaletteAction::LaunchSshHost(host),
- icon: None,
- icon_glyph: Some(glyph),
- icon_path: None,
- }
- }));
- rows
-}
-
fn ssh_host_icon_ids(data_dir: &Path) -> std::collections::HashMap {
crate::ssh_profiles::SshProfiles::load(&data_dir.join("ssh_profiles.json"))
.map(|profiles| profiles.icons())
@@ -2964,10 +2883,10 @@ impl NebulaWorkspace {
/// 三点 / Ctrl+K:旧壳 `NewTabMenu` → `open_shell_menu` → `PaletteMode::Profiles`。
fn open_shell_palette(&mut self, window: &mut Window, cx: &mut Context) {
self.command_manager_open = false;
- let default_shell_id = cx
- .try_global::()
- .and_then(|settings| settings.shell_id.clone())
- .unwrap_or_else(|| "powershell".to_owned());
+ let default_shell_id = crate::platform::shell::effective_shell_id(
+ cx.try_global::()
+ .and_then(|settings| settings.shell_id.as_deref()),
+ );
let language = workspace_ui_language();
let rows = shell_palette_rows(
crate::shell_detect::detect_shells(),
diff --git a/nebula_app/src/gpui_shell/workspace/notifications.rs b/nebula_app/src/gpui_shell/workspace/notifications.rs
index 89397d6..7e1a6b6 100644
--- a/nebula_app/src/gpui_shell/workspace/notifications.rs
+++ b/nebula_app/src/gpui_shell/workspace/notifications.rs
@@ -66,7 +66,8 @@ impl NebulaWorkspace {
} else {
None
};
- let (title, body) = notification.toast_text();
+ // Log the original message before the banner creates a bounded preview.
+ let (title, body) = notification.raw_toast_text();
let kind = if attention {
crate::display::ToastKind::Warning
} else {
diff --git a/nebula_app/src/gpui_shell/workspace/palette.rs b/nebula_app/src/gpui_shell/workspace/palette.rs
index 79547c6..ea09ef3 100644
--- a/nebula_app/src/gpui_shell/workspace/palette.rs
+++ b/nebula_app/src/gpui_shell/workspace/palette.rs
@@ -1,5 +1,19 @@
use super::*;
+/// 命令面板 / Shell 选择弹窗里图标轨的逻辑边长。品牌贴图、Nerd Font 字形
+/// 与组件库线性图标都排进同一个槽,行与行之间才不会有的缩进有的不缩进。
+const PALETTE_ICON_PX: f32 = 22.0;
+
+/// 非 shell 行(SSH 主机等)的字形字号。shell 行另有配平后的字号,见
+/// `shell_glyph_size`。
+const PALETTE_GLYPH_PX: f32 = 16.0;
+
+/// shell 行字形的字号:与品牌贴图按同一视觉尺寸配平(`shell_detect` 的
+/// 安全边距/墨迹比例是这条换算的唯一出处)。
+fn shell_glyph_size() -> f32 {
+ PALETTE_ICON_PX * crate::shell_detect::FALLBACK_ICON_SCALE
+}
+
impl NebulaWorkspace {
pub(super) fn render_command_palette(&mut self, cx: &mut Context) -> gpui::AnyElement {
use crate::display::ui::tokens::{control, radius, space};
@@ -103,20 +117,31 @@ impl NebulaWorkspace {
let icon_content = if let Some(image) = item.icon.clone() {
Some(
gpui::StyledImage::object_fit(
- img(image).size(px(22.0)),
+ img(image).size(px(PALETTE_ICON_PX)),
gpui::ObjectFit::Contain,
)
.into_any_element(),
)
} else if let Some(glyph) = item.icon_glyph {
+ // shell 行没有品牌贴图时画的是同一个 id-keyed 字形,字号要
+ // 跟品牌贴图配平;SSH 主机等行保持原来的字号。
+ let glyph_px = if matches!(
+ &item.action,
+ WorkspacePaletteAction::LaunchShell(_)
+ | WorkspacePaletteAction::LaunchProfile(_)
+ ) {
+ shell_glyph_size()
+ } else {
+ PALETTE_GLYPH_PX
+ };
Some(
div()
- .size(px(22.0))
+ .size(px(PALETTE_ICON_PX))
.flex()
.items_center()
.justify_center()
.font_family(crate::font_install::REQUIRED_FONT_FAMILY)
- .text_size(px(16.0))
+ .text_size(px(glyph_px))
.text_color(foreground)
.child(glyph.to_string())
.into_any_element(),
@@ -124,7 +149,7 @@ impl NebulaWorkspace {
} else {
item.icon_path.clone().map(|path| {
div()
- .size(px(22.0))
+ .size(px(PALETTE_ICON_PX))
.flex()
.items_center()
.justify_center()
@@ -133,8 +158,12 @@ impl NebulaWorkspace {
})
};
let icon_slot = has_icon_rail.then(|| {
- let slot =
- div().size(px(22.0)).flex_shrink_0().flex().items_center().justify_center();
+ let slot = div()
+ .size(px(PALETTE_ICON_PX))
+ .flex_shrink_0()
+ .flex()
+ .items_center()
+ .justify_center();
match icon_content {
Some(icon) => slot.child(icon).into_any_element(),
None => slot.into_any_element(),
diff --git a/nebula_app/src/gpui_shell/workspace/shell_picker.rs b/nebula_app/src/gpui_shell/workspace/shell_picker.rs
new file mode 100644
index 0000000..d4dde13
--- /dev/null
+++ b/nebula_app/src/gpui_shell/workspace/shell_picker.rs
@@ -0,0 +1,105 @@
+//! Shell/profile palette rows and their shared icon fallback.
+
+use super::{
+ WorkspacePaletteAction, WorkspacePaletteHintStyle, WorkspacePaletteRow, ssh_host_icon_ids,
+};
+
+/// shell 行的回落字形:没有品牌贴图时用 `shell_detect::icon_for_id` 的
+/// id-keyed Nerd Font 字形(与设置页下拉、命令面板同一口径)。
+///
+/// `has_brand` 为真时返回 `None`——贴图已经画了,两个都留同行会出现两个图标。
+fn fallback_shell_glyph(id: &str, has_brand: bool) -> Option {
+ if has_brand {
+ return None;
+ }
+ crate::shell_detect::icon_for_id(id).chars().next()
+}
+
+/// 新建终端弹窗的行:已检测 shell + SSH 主机,分组对照旧壳
+/// `CommandPalette::open_profiles`(推荐 / 所有 Shell / SSH 主机)。
+/// 三点菜单与 Ctrl+K 打开的是这份列表,不是通用命令面板。
+pub(super) fn shell_palette_rows(
+ shells: Vec,
+ profiles: Vec,
+ ssh_hosts: impl IntoIterator
- ,
+ default_shell_id: &str,
+ language: crate::display::UiLanguage,
+ scale_factor: f32,
+) -> Vec {
+ const SHELL_ICON_PX: f32 = 22.0;
+ let recommended = language.pick("推荐", "Recommended");
+ let all_shells = language.pick("所有 Shell", "All shells");
+ let ssh_group = language.pick("SSH 主机", "SSH hosts");
+ let mut rows: Vec = shells
+ .into_iter()
+ .map(|shell| {
+ let is_default = shell.id == default_shell_id;
+ let icon = crate::gpui_shell::widgets::shell_brand_image(
+ &shell.id,
+ SHELL_ICON_PX,
+ scale_factor,
+ );
+ // 没有品牌贴图的 shell(zsh、csh、ksh…)不能就这么空着:回落到
+ // 按 id 取字的 Nerd Font 字形,与设置页下拉同一口径。
+ let icon_glyph = fallback_shell_glyph(&shell.id, icon.is_some());
+ WorkspacePaletteRow {
+ group_order: if is_default { 0 } else { 1 },
+ group: if is_default { recommended.to_owned() } else { all_shells.to_owned() },
+ label: shell.name.clone(),
+ hint: shell.program.clone(),
+ hint_style: WorkspacePaletteHintStyle::Metadata,
+ search: format!("{} {} shell profile", shell.name, shell.id).to_lowercase(),
+ icon,
+ icon_glyph,
+ icon_path: None,
+ action: WorkspacePaletteAction::LaunchShell(shell),
+ }
+ })
+ .collect();
+ rows.extend(profiles.into_iter().filter_map(|profile| {
+ let id = profile.settings_id()?;
+ let is_default = id.eq_ignore_ascii_case(default_shell_id);
+ let icon_id = profile.shell_id.as_deref().unwrap_or(&id);
+ let icon =
+ crate::gpui_shell::widgets::shell_brand_image(icon_id, SHELL_ICON_PX, scale_factor);
+ // 借用在 `profile` 被移进 action 之前结束。
+ let icon_glyph = fallback_shell_glyph(icon_id, icon.is_some());
+ let label = profile.name.clone();
+ let hint = profile.command.clone();
+ Some(WorkspacePaletteRow {
+ group_order: if is_default { 0 } else { 1 },
+ group: if is_default { recommended.to_owned() } else { all_shells.to_owned() },
+ search: format!("{} {} {} shell profile", profile.name, id, profile.command)
+ .to_lowercase(),
+ label,
+ hint,
+ hint_style: WorkspacePaletteHintStyle::Metadata,
+ action: WorkspacePaletteAction::LaunchProfile(profile),
+ icon_glyph,
+ icon,
+ icon_path: None,
+ })
+ }));
+ if let Some(position) = rows.iter().position(|row| row.group_order == 0) {
+ let default_row = rows.remove(position);
+ rows.insert(0, default_row);
+ }
+ let ssh_icons = ssh_host_icon_ids(&crate::display::nebula_data_dir());
+ rows.extend(ssh_hosts.into_iter().map(|host| {
+ let glyph =
+ crate::display::ui::os_icons::resolve(ssh_icons.get(&host).map(String::as_str)).glyph;
+ WorkspacePaletteRow {
+ group_order: 2,
+ group: ssh_group.to_owned(),
+ label: host.clone(),
+ hint: "SSH".to_owned(),
+ hint_style: WorkspacePaletteHintStyle::Metadata,
+ search: format!("{host} ssh host remote lianjie 连接").to_lowercase(),
+ action: WorkspacePaletteAction::LaunchSshHost(host),
+ icon: None,
+ icon_glyph: Some(glyph),
+ icon_path: None,
+ }
+ }));
+ rows
+}
diff --git a/nebula_app/src/gpui_shell/workspace/tests.rs b/nebula_app/src/gpui_shell/workspace/tests.rs
index a83ab38..d478d93 100644
--- a/nebula_app/src/gpui_shell/workspace/tests.rs
+++ b/nebula_app/src/gpui_shell/workspace/tests.rs
@@ -581,6 +581,50 @@ fn shell_palette_puts_the_default_shell_first() {
assert_eq!(rows[0].group, "所有 Shell");
}
+/// 回归锁:新建终端弹窗里,没有品牌贴图的 shell 不能空着——必须回落到按 id
+/// 取字的 Nerd Font 字形。之前这一列只填了 `icon`,于是 zsh、csh、ksh 这些
+/// 整行左边什么都没有。
+#[test]
+fn shell_palette_falls_back_to_an_id_glyph_when_brand_art_is_absent() {
+ let shells = ["zsh", "csh", "ksh", "bash", "pwsh"]
+ .into_iter()
+ .map(|id| crate::shell_detect::DetectedShell {
+ name: crate::shell_detect::display_name_for_id(id),
+ id: id.to_owned(),
+ program: format!("/bin/{id}"),
+ args: Vec::new(),
+ })
+ .collect::>();
+ let rows = shell_palette_rows(
+ shells,
+ Vec::new(),
+ None::,
+ "zsh",
+ crate::display::UiLanguage::ZhCn,
+ 1.0,
+ );
+ let mut brandless = 0;
+ for row in &rows {
+ let WorkspacePaletteAction::LaunchShell(shell) = &row.action else { continue };
+ assert_eq!(
+ row.icon.is_none(),
+ row.icon_glyph.is_some(),
+ "`{}` 必须恰好有一种图标来源(品牌贴图或回落字形)",
+ shell.id
+ );
+ if row.icon.is_none() {
+ brandless += 1;
+ assert_eq!(
+ row.icon_glyph,
+ crate::shell_detect::icon_for_id(&shell.id).chars().next(),
+ "`{}` 的回落字形要与共享图标口径一致",
+ shell.id
+ );
+ }
+ }
+ assert!(brandless >= 3, "zsh/csh/ksh 都没有品牌贴图,都要有字形: {brandless}");
+}
+
#[test]
fn shell_palette_includes_imported_terminal_profiles() {
let profile = crate::config::ui_config::Profile {
diff --git a/nebula_app/src/notify.rs b/nebula_app/src/notify.rs
index 522b66e..40f32a2 100644
--- a/nebula_app/src/notify.rs
+++ b/nebula_app/src/notify.rs
@@ -106,6 +106,34 @@ pub enum Notification {
AiTurn { program: String, message: Option, attention: bool },
}
+/// Longest notification body any shell may render.
+///
+/// A toast/banner is a glance layer, not a reader. Codex's turn-complete
+/// notification carries the model's `last-assistant-message` (bounded only at
+/// 4 000 chars in `ai_hook`), and an OSC 9 body is unbounded; rendered at a
+/// fixed width that becomes a card taller than the window. Anchored to the
+/// bottom-right it then overflows off the top, taking its close button out of
+/// reach, and sits there for the whole banner TTL while covering whatever is
+/// underneath (including a split pane). Only the preview is bounded here — the
+/// full text stays in the log and in the terminal's answer reader.
+pub(crate) const TOAST_BODY_MAX_CHARS: usize = 160;
+
+/// Make a single-line preview of at most [`TOAST_BODY_MAX_CHARS`] characters.
+/// Line breaks and other whitespace become spaces; a short multi-line body
+/// must not create an arbitrarily tall card. Read only one character beyond
+/// the limit, and reserve the last character for `…` when text was omitted.
+/// Raw text remains available separately for logging and recovery.
+pub(crate) fn clamp_toast_body(body: &str) -> String {
+ let mut chars = body.chars().map(|ch| if ch.is_whitespace() { ' ' } else { ch });
+ let mut preview = String::with_capacity(body.len().min(TOAST_BODY_MAX_CHARS * 4));
+ preview.extend(chars.by_ref().take(TOAST_BODY_MAX_CHARS));
+ if chars.next().is_some() {
+ preview.pop();
+ preview.push('…');
+ }
+ preview
+}
+
impl Notification {
/// Source classification only. In-app preferences must not silence native
/// notifications or infer an AI source from arbitrary message text.
@@ -120,8 +148,13 @@ impl Notification {
}
/// Toast title + body. Title names the source ("Pebrel" or the program);
- /// body carries the human detail.
+ /// body carries the human detail, bounded to a glanceable length.
pub(crate) fn toast_text(&self) -> (String, String) {
+ let (title, body) = self.raw_toast_text();
+ (title, clamp_toast_body(&body))
+ }
+
+ pub(crate) fn raw_toast_text(&self) -> (String, String) {
match self {
Self::Bell { program } => match program {
Some(p) => (p.clone(), "任务完成,等待输入".to_owned()),
@@ -201,6 +234,7 @@ pub(crate) fn deliver_gpui(notification: &Notification, pane_id: u64) {
return;
}
let (title, body) = notification.toast_text();
+ log::debug!("notify: system toast source for pane={pane_id}: {notification:?}");
spawn_toast(title, body, application_activation(Some(pane_id)));
}
@@ -232,7 +266,7 @@ pub fn deliver(window: &Window, notification: &Notification, pane: Option)
let _ = proxy.send_event(Event::new(EventType::FocusWindow { pane }, window_id));
}) as ToastActivation
});
- log::debug!("notify: toast '{title}': '{body}'");
+ log::debug!("notify: toast source: {notification:?}");
// Fire-and-forget worker: the WinRT show() is a cross-process RPC (can
// take tens of ms — an eternity for the event loop), and notifications
// must never be able to take the terminal down with them.
@@ -363,6 +397,56 @@ mod delivery_tests {
);
}
+ /// 回归锁:一篇很长的模型回答不能变成一张撑破窗口的通知卡。正文只保留
+ /// 一眼能读的预览,其余在日志和终端「原文」里。
+ #[test]
+ fn oversized_notification_bodies_are_clamped_to_a_glanceable_preview() {
+ let long = "回答正文。".repeat(1_000);
+ let done = Notification::AiTurn {
+ program: "codex".to_owned(),
+ message: Some(long.clone()),
+ attention: false,
+ };
+ let (title, body) = done.toast_text();
+ assert_eq!(title, "codex");
+ assert!(body.chars().count() <= TOAST_BODY_MAX_CHARS, "正文必须被收短: {}", body.len());
+ assert!(body.ends_with('…'), "被截断的正文要以省略号收尾");
+ assert!(long.starts_with(body.trim_end_matches('…')), "保留的是原文开头");
+
+ // OSC 9 原文同样无界,走 Text 分支也要收短。
+ let (_, osc) =
+ Notification::Text { body: long, program: Some("claude".to_owned()) }.toast_text();
+ assert!(osc.chars().count() <= TOAST_BODY_MAX_CHARS);
+
+ // 幂等:已经收短过的正文再收一次不会多出第二个省略号。
+ let once = clamp_toast_body(&"x".repeat(1_000));
+ assert_eq!(clamp_toast_body(&once), once);
+ }
+
+ #[test]
+ fn notification_preview_bounds_line_breaks_and_preserves_raw_text() {
+ let source = format!("{}done", "x\n".repeat(60));
+ assert!(source.chars().count() < TOAST_BODY_MAX_CHARS);
+ let notification = Notification::Text { body: source.clone(), program: None };
+ let (_, preview) = notification.toast_text();
+ assert_eq!(preview.lines().count(), 1);
+ assert_eq!(preview, source.replace('\n', " "));
+ assert_eq!(notification.raw_toast_text().1, source);
+ assert_eq!(clamp_toast_body("a\r\nb\tc\u{2028}d\u{2029}e"), "a b c d e");
+ }
+
+ #[test]
+ fn notification_preview_preserves_short_text_and_unicode_boundaries() {
+ for count in [0, 1, TOAST_BODY_MAX_CHARS - 1, TOAST_BODY_MAX_CHARS] {
+ let source = "界".repeat(count);
+ assert_eq!(clamp_toast_body(&source), source);
+ }
+ let source = "😀".repeat(TOAST_BODY_MAX_CHARS + 1);
+ let preview = clamp_toast_body(&source);
+ assert_eq!(preview, format!("{}…", "😀".repeat(TOAST_BODY_MAX_CHARS - 1)));
+ assert_eq!(clamp_toast_body(&preview), preview);
+ }
+
#[cfg(feature = "gpui-shell")]
#[test]
fn activation_preserves_exact_pane_and_generic_application_targets() {
diff --git a/nebula_app/src/platform/shell.rs b/nebula_app/src/platform/shell.rs
index b9af1b8..565fa57 100644
--- a/nebula_app/src/platform/shell.rs
+++ b/nebula_app/src/platform/shell.rs
@@ -26,6 +26,21 @@ pub fn default_shell_id() -> String {
}
}
+/// The shell id a picker or label should treat as current: the configured
+/// value when it names anything, otherwise the host's own default. An empty or
+/// whitespace-only value is "unset", not a shell named `""`.
+///
+/// Every GPUI surface that shows a "default shell" must go through this —
+/// falling back to a hard-coded `powershell` made a Mac with no saved `shell=`
+/// display and recommend PowerShell instead of its login shell.
+pub fn effective_shell_id(configured: Option<&str>) -> String {
+ configured
+ .map(str::trim)
+ .filter(|id| !id.is_empty())
+ .map(str::to_owned)
+ .unwrap_or_else(default_shell_id)
+}
+
pub fn interactive_args(id: &str) -> Vec {
if cfg!(target_os = "macos") && matches!(id, "zsh" | "bash" | "fish") {
vec!["-l".to_owned()]
@@ -84,6 +99,23 @@ mod tests {
assert!(!default_shell_id().is_empty());
}
+ /// 未保存 `shell=` 时必须落到宿主默认,而不是某个硬编码 id。回归锁:
+ /// 设置页「默认 Shell」和新终端选择弹窗此前都硬编码回落到 `powershell`,
+ /// 于是没存过 `shell=` 的 Mac 上显示并推荐的是 PowerShell 而不是登录 shell。
+ #[test]
+ fn effective_shell_id_falls_back_to_the_host_default() {
+ let host = default_shell_id();
+ for configured in [None, Some(""), Some(" ")] {
+ assert_eq!(
+ effective_shell_id(configured),
+ host,
+ "未设置的值必须解析成宿主默认,不能是硬编码 id"
+ );
+ }
+ assert_eq!(effective_shell_id(Some(" bash ")), "bash");
+ assert_eq!(effective_shell_id(Some("pwsh")), "pwsh");
+ }
+
#[test]
fn bash_bootstrap_matches_the_host_contract() {
assert_eq!(uses_legacy_pty_bootstrap("bash"), cfg!(windows));
diff --git a/nebula_app/src/shell_detect.rs b/nebula_app/src/shell_detect.rs
index e8ba914..76d0b14 100644
--- a/nebula_app/src/shell_detect.rs
+++ b/nebula_app/src/shell_detect.rs
@@ -94,6 +94,12 @@ impl DetectedShell {
/// Nerd Font glyph for a shell id — shared by detected shells and the settings
/// row so a saved `shell=` always draws the same mark. WSL distro ids carry
/// a `wsl:` prefix; everything else matches whole.
+///
+/// Shells without brand artwork (zsh, sh, dash, csh, ksh, tcsh…) all share the
+/// one `cod-terminal` codepoint. Different Nerd Font glyphs carry very
+/// different ink for the same em (dev-terminal is ~0.7 em, codicon ~0.9), so
+/// mixing them in one dropdown renders at visibly different sizes; a single
+/// codepoint is what makes "every icon the same size" true by construction.
pub fn icon_for_id(id: &str) -> &'static str {
let id = profile_shell_id(id).unwrap_or(id).to_ascii_lowercase();
if id.starts_with("wsl") {
@@ -102,12 +108,17 @@ pub fn icon_for_id(id: &str) -> &'static str {
match id.as_str() {
"pwsh" | "powershell" => "\u{ebc7}", // codicon terminal-powershell
"cmd" => "\u{ebc4}", // codicon terminal-cmd
- "bash" | "git-bash" | "gitbash" | "zsh" | "sh" | "dash" => "\u{e795}", // devicon bash/terminal
- "fish" | "nu" => "\u{f489}", // generic terminal glyph
- _ => "\u{ea85}", // codicon terminal (fallback)
+ _ => "\u{ea85}", // codicon terminal (every other shell)
}
}
+/// 回落字形相对图标槽边长的字号系数。
+///
+/// 品牌 PNG 是从矢量图按 12% 安全边距栅格化的,可见部分约为自身边长的
+/// 0.77;`icon_for_id` 返回的 codicon terminal 墨迹约 0.88 em。两者乘上这
+/// 个系数之后才在视觉上是同一个尺寸——不乘就会「有的图标大、有的小」。
+pub const FALLBACK_ICON_SCALE: f32 = 0.88;
+
/// Full-color brand icon (embedded PNG, rasterized from vector artwork with
/// a 12% safe margin; see THIRD-PARTY-NOTICES) for a shell id — the terminal picker draws this textured
/// quad instead of the flat Nerd Font glyph. WSL distro ids map by distro
@@ -1060,6 +1071,21 @@ mod tests {
assert!(color_icon_png(profile_key).is_some());
}
+ /// 回归锁:没有品牌贴图的 shell 必须共用同一个字形码位。下拉把它们排在同
+ /// 一槽位、同一字号;混用不同 Nerd 码位时墨迹大小差很多(dev-terminal
+ /// 约 0.7 em,codicon 约 0.9),看起来就是「有的图标大、有的小」。
+ #[test]
+ fn fallback_shell_marks_share_one_glyph_so_they_render_at_one_size() {
+ let expected = icon_for_id("zsh");
+ for id in ["zsh", "sh", "dash", "csh", "ksh", "tcsh", "fish", "nu", "unknown-shell"] {
+ assert_eq!(icon_for_id(id), expected, "`{id}` 的回落字形必须与其它 shell 一致");
+ }
+ // 有品牌贴图的仍然保留各自的字形:贴图取不到时(导入的 profile 家族)
+ // 也不该和 POSIX shell 混成同一个印记。
+ assert_ne!(icon_for_id("pwsh"), expected);
+ assert_ne!(icon_for_id("cmd"), expected);
+ }
+
#[cfg(windows)]
#[test]
fn windows_detection_finds_the_builtins() {
diff --git a/nebula_app/windows/nebula.rc b/nebula_app/windows/nebula.rc
index be73c71..e167b42 100644
--- a/nebula_app/windows/nebula.rc
+++ b/nebula_app/windows/nebula.rc
@@ -10,8 +10,8 @@ APP_MANIFEST RT_MANIFEST nebula.manifest
#endif
1 VERSIONINFO
-FILEVERSION 1,6,0,0
-PRODUCTVERSION 1,6,0,0
+FILEVERSION 1,7,0,0
+PRODUCTVERSION 1,7,0,0
FILEFLAGSMASK 0x3fL
FILEFLAGS 0x0L
FILEOS 0x40004L
@@ -24,11 +24,11 @@ BEGIN
BEGIN
VALUE "CompanyName", "Kuddev"
VALUE "FileDescription", "Pebrel"
- VALUE "FileVersion", "1.6.0"
+ VALUE "FileVersion", "1.7.0"
VALUE "InternalName", "nebula"
VALUE "OriginalFilename", "pebrel.exe"
VALUE "ProductName", "Pebrel"
- VALUE "ProductVersion", "1.6.0"
+ VALUE "ProductVersion", "1.7.0"
END
END
BLOCK "VarFileInfo"
diff --git a/nebula_config/Cargo.toml b/nebula_config/Cargo.toml
index a55cb23..dbd27a6 100644
--- a/nebula_config/Cargo.toml
+++ b/nebula_config/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "nebula_config"
-version = "1.6.0"
+version = "1.7.0"
license = "GPL-3.0-or-later"
description = "Nebula configuration abstractions"
homepage = "https://github.com/Kuddev/pebrel"
@@ -14,5 +14,5 @@ serde = "1.0.163"
toml.workspace = true
[dev-dependencies]
-nebula_config_derive = { version = "1.6.0", path = "../nebula_config_derive" }
+nebula_config_derive = { version = "1.7.0", path = "../nebula_config_derive" }
serde = { version = "1.0.163", features = ["derive"] }
diff --git a/nebula_config_derive/Cargo.toml b/nebula_config_derive/Cargo.toml
index 8753422..fc5d038 100644
--- a/nebula_config_derive/Cargo.toml
+++ b/nebula_config_derive/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "nebula_config_derive"
-version = "1.6.0"
+version = "1.7.0"
license = "GPL-3.0-or-later"
description = "Failure resistant deserialization derive"
homepage = "https://github.com/Kuddev/pebrel"
@@ -18,7 +18,7 @@ syn = { version = "2.0.16", features = ["derive", "parsing", "proc-macro", "prin
[dev-dependencies.nebula_config]
path = "../nebula_config"
-version = "1.6.0"
+version = "1.7.0"
[dev-dependencies]
log = "0.4.11"
diff --git a/nebula_settings/Cargo.toml b/nebula_settings/Cargo.toml
index 689962f..43cfcce 100644
--- a/nebula_settings/Cargo.toml
+++ b/nebula_settings/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "nebula-settings"
-version = "1.6.0"
+version = "1.7.0"
license = "GPL-3.0-or-later"
description = "Shared reader for Nebula runtime user settings (nebula_settings.txt) and theme terminal-color tables"
publish = false
diff --git a/nebula_split/Cargo.toml b/nebula_split/Cargo.toml
index 5389491..b1fa368 100644
--- a/nebula_split/Cargo.toml
+++ b/nebula_split/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "nebula-split"
-version = "1.6.0"
+version = "1.7.0"
license = "GPL-3.0-or-later"
description = "Shared split-pane rules: layout tree, geometry, divider drag and directional focus"
publish = false
diff --git a/nebula_terminal/Cargo.toml b/nebula_terminal/Cargo.toml
index 800e0af..8a40565 100644
--- a/nebula_terminal/Cargo.toml
+++ b/nebula_terminal/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "nebula_terminal"
-version = "1.6.0"
+version = "1.7.0"
license = "GPL-3.0-or-later"
description = "Nebula terminal emulation core"
readme = "../README.md"
diff --git a/scripts/installer.iss b/scripts/installer.iss
index c2e09e4..f8a9fbb 100644
--- a/scripts/installer.iss
+++ b/scripts/installer.iss
@@ -1,9 +1,9 @@
#ifndef AppVersion
- #define AppVersion "1.6.0"
+ #define AppVersion "1.7.0"
#endif
#ifndef NumericVersion
- #define NumericVersion "1.6.0.0"
+ #define NumericVersion "1.7.0.0"
#endif
#ifndef Configuration
diff --git a/scripts/tests/test_architecture_governance.py b/scripts/tests/test_architecture_governance.py
index 3d963b5..1bdd04e 100644
--- a/scripts/tests/test_architecture_governance.py
+++ b/scripts/tests/test_architecture_governance.py
@@ -15,12 +15,19 @@ DOCUMENTS = (
class GovernanceTests(unittest.TestCase):
def ignored_paths(self, paths):
result = subprocess.run(
- ["git", "check-ignore", "--no-index", "--stdin"], cwd=ROOT,
- input="\n".join(paths) + "\n", text=True, encoding="utf-8",
+ ["git", "check-ignore", "--no-index", "--stdin", "-z"], cwd=ROOT,
+ # Binary NUL framing avoids Windows newline conversion and Git's
+ # quoted-path output; both would change the path being tested.
+ input=b"\0".join(path.encode("utf-8") for path in paths) + b"\0",
capture_output=True,
)
self.assertIn(result.returncode, (0, 1), result.stderr)
- return set(result.stdout.splitlines())
+ return {path.decode("utf-8") for path in result.stdout.split(b"\0") if path}
+
+ def test_git_path_transport_preserves_unicode_and_control_characters(self):
+ ignored = ("tmp/中文 空格.txt", 'tmp/quoted"name.txt', "tmp/line\nbreak.txt", "tmp/cr\rname.txt")
+ paths = (*ignored, "docs/architecture.md")
+ self.assertEqual(self.ignored_paths(paths), set(ignored))
def test_contributor_documents_exist_and_are_not_ignored(self):
for name in DOCUMENTS:
diff --git a/tools/i18n-contract/Cargo.lock b/tools/i18n-contract/Cargo.lock
index 939e9da..fef745b 100644
--- a/tools/i18n-contract/Cargo.lock
+++ b/tools/i18n-contract/Cargo.lock
@@ -16,7 +16,7 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "nebula-settings"
-version = "1.6.0"
+version = "1.7.0"
[[package]]
name = "pebrel-i18n-contract"