From 451d27da610cba569dc82a46953e653691a8d9e5 Mon Sep 17 00:00:00 2001 From: bufan <821869798@qq.com> Date: Sat, 5 Sep 2026 20:14:46 +0800 Subject: [PATCH 01/31] fix(terminal): support Shift+Enter newline and antigravity-cli idle detection - Support Shift+Enter as newline in Win32 input mode and Kitty keyboard protocol - Avoid false command line commit tracking on negotiated multiline Shift+Enter - Add prompt_idle detection rule for antigravity-cli and tighten permission_prompt - Add unit tests for antigravity-cli detection and keymap encoding Fixes https://github.com/Kuddev/nebula/issues/95 --- .../src/agent_detection/antigravity.toml | 57 ++++++++++++-- nebula_app/src/ai_agents.rs | 76 +++++++++++++++++++ nebula_app/src/gpui_shell/terminal/keymap.rs | 76 ++++++++++++++++++- nebula_app/src/gpui_shell/terminal/view.rs | 17 ++++- 4 files changed, 217 insertions(+), 9 deletions(-) diff --git a/nebula_app/src/agent_detection/antigravity.toml b/nebula_app/src/agent_detection/antigravity.toml index c83a3c9..cd3afba 100644 --- a/nebula_app/src/agent_detection/antigravity.toml +++ b/nebula_app/src/agent_detection/antigravity.toml @@ -5,24 +5,67 @@ aliases = ["agy", "antigravity-cli"] [[rules]] id = "permission_prompt" state = "blocked" -priority = 300 -region = "whole_recent" -contains = ["requesting permission for:"] +priority = 900 +region = "bottom_non_empty_lines(12)" any = [ - { contains = ["do you want to proceed?"] }, + { contains = ["requesting approval"], any = [ + { contains = ["[y]"] }, + { contains = ["(y/n)"] }, + { contains = ["[y/n]"] }, + { contains = ["always allow"] }, + { contains = ["esc to cancel"] }, + { contains = ["enter to confirm"] }, + ]}, + { contains = ["needs approval"], any = [ + { contains = ["[y]"] }, + { contains = ["(y/n)"] }, + { contains = ["[y/n]"] }, + { contains = ["always allow"] }, + { contains = ["esc to cancel"] }, + { contains = ["enter to confirm"] }, + ]}, + { contains = ["requesting permission"], any = [ + { contains = ["[y]"] }, + { contains = ["(y/n)"] }, + { contains = ["[y/n]"] }, + { contains = ["always allow"] }, + { contains = ["esc to cancel"] }, + { contains = ["enter to confirm"] }, + ]}, { contains = ["tab amend", "edit command"] }, + { contains = ["tab to amend"] }, ] [[rules]] id = "spinner_working" state = "working" -priority = 100 -region = "whole_recent" +priority = 400 +region = "bottom_non_empty_lines(8)" line_regex = ['^\s*[\u2800-\u28FF]+\s+\p{Alphabetic}+\w*ing\b'] [[rules]] id = "background_tasks_working" state = "working" -priority = 90 +priority = 380 region = "bottom_non_empty_lines(5)" line_regex = ['(?i)·\s*[1-9][0-9]*\s+task'] + +[[rules]] +id = "prompt_idle" +state = "idle" +priority = 100 +region = "bottom_non_empty_lines(6)" +any = [ + { contains = ["shift+tab to cycle"] }, + { contains = ["? for shortcuts"] }, + { line_regex = ['^\s*[›❯>](?:\s|$)'] }, +] +not = [ + { contains = ["esc to interrupt"] }, + { contains = ["esc to cancel"] }, + { contains = ["requesting approval"], any = [{ contains = ["[y]"] }, { contains = ["(y/n)"] }, { contains = ["[y/n]"] }, { contains = ["always allow"] }] }, + { contains = ["needs approval"], any = [{ contains = ["[y]"] }, { contains = ["(y/n)"] }, { contains = ["[y/n]"] }, { contains = ["always allow"] }] }, + { contains = ["requesting permission"], any = [{ contains = ["[y]"] }, { contains = ["(y/n)"] }, { contains = ["[y/n]"] }, { contains = ["always allow"] }] }, +] + + diff --git a/nebula_app/src/ai_agents.rs b/nebula_app/src/ai_agents.rs index 0ba3c8d..76fcaf5 100644 --- a/nebula_app/src/ai_agents.rs +++ b/nebula_app/src/ai_agents.rs @@ -920,4 +920,80 @@ mod tests { assert_eq!(found.status, AgentStatus::Blocked, "{agent} rule={}", found.rule_id); } } + + #[test] + fn real_antigravity_working_chrome_reads_working() { + let screen = " ⠋ Thinking... (8s)\n\ + ────────────────────────────────────────\n\ + › \n\ + ────────────────────────────────────────\n\ + \x20 Plan mode: research & plan only (shift+tab to cycle) · Press esc to interrupt generation"; + let detection = detect("antigravity", screen).unwrap(); + assert_eq!(detection.status, AgentStatus::Working, "rule={}", detection.rule_id); + } + + #[test] + fn real_antigravity_idle_chrome_reads_idle() { + let screen = " I have completed the task and updated the configuration.\n\ + ────────────────────────────────────────\n\ + › \n\ + ────────────────────────────────────────\n\ + \x20 Plan mode: research & plan only (shift+tab to cycle) · ? for shortcuts"; + let detection = detect("antigravity", screen).unwrap(); + assert_eq!(detection.status, AgentStatus::Idle, "rule={}", detection.rule_id); + } + + #[test] + fn antigravity_old_scrollback_spinner_does_not_prevent_idle() { + // 历史滚屏中留有上一轮的思考转圈符号;region 限制在底部后不能把已结束的回合误判为 Working。 + let screen = " ⠋ Thinking... (from previous turn 5m ago)\n\ + \x20 Result: success\n\ + \x20 Line 1\n\ + \x20 Line 2\n\ + \x20 Line 3\n\ + \x20 Line 4\n\ + \x20 Line 5\n\ + \x20 Line 6\n\ + \x20 Line 7\n\ + \x20 Line 8\n\ + \x20 Line 9\n\ + \x20 Line 10\n\ + ────────────────────────────────────────\n\ + › \n\ + ────────────────────────────────────────\n\ + \x20 Accept-edits mode: file edits auto-approved (shift+tab to cycle)"; + let detection = detect("antigravity", screen).unwrap(); + assert_eq!(detection.status, AgentStatus::Idle, "rule={}", detection.rule_id); + } + + #[test] + fn real_antigravity_permission_form_reads_blocked() { + let screen = " requesting permission for:\n\ + \x20 Run command: cargo test\n\ + \x20 Do you want to proceed?\n\ + \x20 › 1. Yes, allow\n\ + \x20 2. No, deny access\n\ + \x20 esc to cancel · tab amend"; + let detection = detect("antigravity", screen).unwrap(); + assert_eq!(detection.status, AgentStatus::Blocked, "rule={}", detection.rule_id); + } + + #[test] + fn antigravity_prose_mentioning_approval_while_idle_reads_idle() { + // 回答正文中提及 "needs approval for ...",但此时已完成并显示空闲提示符, + // 不得误判为 Blocked。 + for phrase in ["needs approval for", "requesting approval for", "requesting permission for:"] { + let screen = format!( + "│ The tool {phrase} operations outside the workspace.\n\ + │ Please confirm your settings if you plan to enable this.\n\ + ────────────────────────────────────────\n\ + › \n\ + ────────────────────────────────────────\n\ + \x20 Plan mode: research & plan only (shift+tab to cycle) · ? for shortcuts" + ); + let detection = detect("antigravity", &screen).unwrap(); + assert_eq!(detection.status, AgentStatus::Idle, "{phrase}: rule={}", detection.rule_id); + } + } } + diff --git a/nebula_app/src/gpui_shell/terminal/keymap.rs b/nebula_app/src/gpui_shell/terminal/keymap.rs index f2cc8fd..12ce034 100644 --- a/nebula_app/src/gpui_shell/terminal/keymap.rs +++ b/nebula_app/src/gpui_shell/terminal/keymap.rs @@ -165,8 +165,18 @@ fn virtual_key_of(key: &str) -> Option { /// Tab=0x09、Backspace=0x08):OpenConsole 1.22 的 VT 翻译层会丢弃 uChar=0 /// 的 VK_ESCAPE,于是读字节流的那类应用(Claude Code)收不到 Esc。修饰键与 /// 功能键保持 0,与真实键盘一致。逐条同旧壳 `control_char_fallback`。 +/// +/// Shift+Enter 在支持的终端应用中语义为换行(uChar=0x0A / 10)。 +/// 普通 Enter 保持 0x0D(CR),让子进程如常执行回车提交。 #[cfg(windows)] fn unicode_char_of(ks: &Keystroke) -> u16 { + if ks.key.as_str() == "enter" { + return if ks.modifiers.shift && !ks.modifiers.control && !ks.modifiers.alt { + b'\n' as u16 + } else { + b'\r' as u16 + }; + } // 平台已经判出文本的(含 Ctrl 变体)以它为准,与 WM_CHAR 语义一致。 // `key_char` 若是 NUL,当作没文本:真实键盘的 Esc 不会写出 U+0000。 if let Some(text) = ks.key_char.as_deref() { @@ -179,7 +189,6 @@ fn unicode_char_of(ks: &Keystroke) -> u16 { } match ks.key.as_str() { "escape" => 0x1b, - "enter" => b'\r' as u16, "tab" => b'\t' as u16, // 真实控制台的 Ctrl+Backspace 记录携带 Uc=DEL(0x7f,WM_CHAR 语义), // PSReadLine 的原生 Ctrl+Backspace=BackwardKillWord 绑定按此匹配。 @@ -287,6 +296,14 @@ pub fn encode(ks: &Keystroke, mode: &TermMode) -> Option> { match ks.key.as_str() { "enter" => { + // Kitty 模式下 Shift+Enter 编码为 CSI 13;2u,避免修饰丢失变成普通回车; + // 启用 REPORT_ALL_KEYS_AS_ESC 且无修饰时出 CSI 13u。 + if kitty_keyboard_active(mode) && ks.modifiers.shift && !ks.modifiers.control && !ks.modifiers.alt { + return Some(b"\x1b[13;2u".to_vec()); + } + if mode.contains(TermMode::REPORT_ALL_KEYS_AS_ESC) && modifier_param(ks) == 1 { + return Some(b"\x1b[13u".to_vec()); + } return Some(if mods.alt { b"\x1b\r".to_vec() } else { b"\r".to_vec() }); }, "backspace" => { @@ -511,4 +528,61 @@ mod tests { let kitty = TermMode::DISAMBIGUATE_ESC_CODES; assert_eq!(encode(&keystroke("backspace"), &kitty), Some(b"\x7f".to_vec())); } + + /// 传统 VT 路径:Enter 提交(\r),Alt+Enter 带 ESC 前缀(\x1b\r)。 + #[test] + fn enter_emits_cr_in_legacy_vt() { + let mode = TermMode::default(); + let mut ks = keystroke("enter"); + assert_eq!(encode(&ks, &mode), Some(b"\r".to_vec())); + ks.modifiers.shift = true; + assert_eq!(encode(&ks, &mode), Some(b"\r".to_vec())); + ks.modifiers.shift = false; + ks.modifiers.control = true; + assert_eq!(encode(&ks, &mode), Some(b"\r".to_vec())); + + let mut alt_enter = keystroke("enter"); + alt_enter.modifiers.alt = true; + assert_eq!(encode(&alt_enter, &mode), Some(b"\x1b\r".to_vec())); + alt_enter.modifiers.shift = true; + assert_eq!(encode(&alt_enter, &mode), Some(b"\x1b\r".to_vec())); + } + + /// kitty 协议下,Shift+Enter 编码为 CSI 13;2u,避免修饰丢失变成普通回车; + /// 仅 REPORT_ALL_KEYS_AS_ESC 下无修饰 Enter 编码为 CSI 13u。 + #[test] + fn enter_emits_kitty_u_sequence_when_modified() { + let mode = TermMode::DISAMBIGUATE_ESC_CODES; + let mut ks = keystroke("enter"); + // DISAMBIGUATE_ESC_CODES 下裸回车不转义,回落为 \r + assert_eq!(encode(&ks, &mode), Some(b"\r".to_vec())); + + ks.modifiers.shift = true; + assert_eq!(encode(&ks, &mode), Some(b"\x1b[13;2u".to_vec())); + + // REPORT_ALL_KEYS_AS_ESC 下裸回车也是 CSI 13u + let all_keys_mode = TermMode::REPORT_ALL_KEYS_AS_ESC; + let bare = keystroke("enter"); + assert_eq!(encode(&bare, &all_keys_mode), Some(b"\x1b[13u".to_vec())); + } + + /// Win32 input mode 下,Shift+Enter 携带 uChar=10(\n),裸 Enter 携带 uChar=13(\r)。 + #[cfg(windows)] + #[test] + fn enter_win32_record_carries_newline_for_shift() { + let mode = TermMode::WIN32_INPUT_MODE; + let parse_record = |bytes: Vec| -> (String, String) { + let text = String::from_utf8(bytes).unwrap(); + let first_record = text.split('_').next().unwrap(); + let fields: Vec<&str> = first_record.strip_prefix("\x1b[").unwrap().split(';').collect(); + (fields[0].to_owned(), fields[2].to_owned()) + }; + + let bare = keystroke("enter"); + assert_eq!(parse_record(encode(&bare, &mode).unwrap()), ("13".to_string(), "13".to_string())); + + let mut shift_enter = keystroke("enter"); + shift_enter.modifiers.shift = true; + assert_eq!(parse_record(encode(&shift_enter, &mode).unwrap()), ("13".to_string(), "10".to_string())); + } } diff --git a/nebula_app/src/gpui_shell/terminal/view.rs b/nebula_app/src/gpui_shell/terminal/view.rs index 444035f..9b7b8a9 100644 --- a/nebula_app/src/gpui_shell/terminal/view.rs +++ b/nebula_app/src/gpui_shell/terminal/view.rs @@ -1928,7 +1928,22 @@ impl TerminalView { let mods = &ks.modifiers; let plain_mods = !mods.control && !mods.alt && !mods.platform; match ks.key.as_str() { - "enter" => self.commit_line(cx), + "enter" => { + // 与 keymap 的 Shift+Enter 分流一致;传统 VT 仍发送 CR 并提交。 + let is_negotiated_multiline = mods.shift + && !mods.control + && !mods.alt + && (mode.intersects( + TermMode::DISAMBIGUATE_ESC_CODES + | TermMode::REPORT_ALL_KEYS_AS_ESC + | TermMode::REPORT_EVENT_TYPES, + ) || (cfg!(windows) + && mode.contains(TermMode::WIN32_INPUT_MODE) + && !mode.intersects(TermMode::KITTY_KEYBOARD_PROTOCOL))); + if !is_negotiated_multiline { + self.commit_line(cx); + } + }, "backspace" if mods.control && !mods.alt && !mods.platform => { crate::display::nebula_input_delete_word(&mut self.suggest); }, From e55c97bb4e365e64ba1b1e31d4395b54418ef787 Mon Sep 17 00:00:00 2001 From: Kuddev Date: Sun, 6 Sep 2026 09:06:39 +0800 Subject: [PATCH 02/31] Update README.md --- README.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/README.md b/README.md index b680fa0..6cceac7 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,7 @@

Pebrel

-> **Local branding preview / 本地品牌预览** — Pebrel is the new display name of Nebula Terminal. This worktree keeps version 1.5.0, `nebula.exe`, existing configuration paths, installer identity, and the Nebula update feed for compatibility. Pebrel-named packages are local test artifacts, not a new GitHub Release. Installing the setup package targets the existing Nebula installation; use the ZIP with isolated configuration for side-by-side testing. -> -> Pebrel 是 Nebula Terminal 的新展示名称。本工作区保留 1.5.0 版本、`nebula.exe`、现有配置路径、安装标识和 Nebula 更新源,以维持兼容。Pebrel 命名的包仅用于本地测试,不代表新的 GitHub Release。安装器沿用现有 Nebula 安装身份;需要并行测试时,请使用 ZIP 并配置隔离目录。 +

A GPU-accelerated terminal for Windows that keeps your sessions alive — close the window, your claude conversation survives.
From 4eb7165a67368c30105ceb8a363731460849ef33 Mon Sep 17 00:00:00 2001 From: kuddev Date: Sun, 6 Sep 2026 18:00:29 +0800 Subject: [PATCH 03/31] Integrate GPUI workspace updates and harden keyboard protocol state Keep the Antigravity detection rules and add the shared official icon catalog. Preserve negotiated Enter modifiers and restore native Windows Enter characters discarded by GPUI. Keep keyboard mode updates in the active stack frame, preserve independent screen stacks, and bound keyboard pushes without changing saved titles. Isolate native input and notification delivery from their UI and policy callers. Include the reviewed localization, SSH lifecycle, settings, completion, session, and notification updates. Local validation: 1062 GPUI tests, 209 terminal tests, translation allocation contracts, architecture and packaging checks. Native platform CI and the actual Antigravity input symptom remain under verification. --- .github/workflows/architecture.yml | 72 + architecture/dependencies.toml | 37 + architecture/file-budgets.txt | 23 + extra/logo/README.md | 16 + extra/logo/ai_antigravity.png | Bin 0 -> 71914 bytes nebula_app/Cargo.toml | 4 +- nebula_app/assets/icons/nebula-keymap.svg | 2 +- .../assets/icons/nebula-layout-grid.svg | 1 + .../assets/icons/nebula-mouse-pointer.svg | 1 + nebula_app/assets/icons/nebula-pin.svg | 4 + nebula_app/assets/icons/nebula-sliders.svg | 1 + nebula_app/build.rs | 61 +- nebula_app/build/i18n.rs | 221 ++ nebula_app/build/i18n/catalog.rs | 99 + nebula_app/i18n/de-DE.json | 177 ++ nebula_app/i18n/en-US.json | 135 +- nebula_app/i18n/es-ES.json | 177 ++ nebula_app/i18n/fr-FR.json | 248 +++ nebula_app/i18n/it-IT.json | 177 ++ nebula_app/i18n/ja-JP.json | 177 ++ nebula_app/i18n/ko-KR.json | 177 ++ nebula_app/i18n/pt-BR.json | 177 ++ nebula_app/i18n/ru-RU.json | 177 ++ nebula_app/i18n/zh-CN.json | 135 +- nebula_app/i18n/zh-TW.json | 177 ++ nebula_app/src/agent_detection/_shared.toml | 7 + .../src/agent_detection/antigravity.toml | 55 +- nebula_app/src/ai_agents.rs | 77 + nebula_app/src/ai_hook.rs | 4 +- nebula_app/src/config/template.rs | 72 +- nebula_app/src/display/command_palette.rs | 2 +- nebula_app/src/display/file_dialog.rs | 44 +- nebula_app/src/display/i18n.rs | 408 ---- nebula_app/src/display/message_queue_entry.rs | 2 +- nebula_app/src/display/mod.rs | 134 +- nebula_app/src/display/program_identity.rs | 215 +- nebula_app/src/display/settings.rs | 18 +- nebula_app/src/display/side_panel/tests.rs | 28 +- nebula_app/src/display/side_panel/vcs.rs | 8 +- nebula_app/src/display/ssh_connect.rs | 6 +- nebula_app/src/display/terminal_math.rs | 4 +- nebula_app/src/display/ui/mod.rs | 2 +- nebula_app/src/display/ui/widgets.rs | 2 +- nebula_app/src/event.rs | 2 +- nebula_app/src/gpui_shell/assets.rs | 8 + nebula_app/src/gpui_shell/background_color.rs | 4 +- nebula_app/src/gpui_shell/config.rs | 2 +- nebula_app/src/gpui_shell/mod.rs | 2 + nebula_app/src/gpui_shell/settings_pane.rs | 1881 ++--------------- .../gpui_shell/settings_pane/appearance.rs | 318 +-- .../settings_pane/appearance_advanced.rs | 81 +- .../settings_pane/appearance_picker.rs | 47 +- .../src/gpui_shell/settings_pane/design.rs | 66 +- .../gpui_shell/settings_pane/font_picker.rs | 2 +- .../settings_pane/initialization.rs | 579 +++++ .../gpui_shell/settings_pane/localization.rs | 135 ++ .../gpui_shell/settings_pane/navigation.rs | 204 ++ .../src/gpui_shell/settings_pane/reset.rs | 66 + .../gpui_shell/settings_pane/search_header.rs | 114 + .../gpui_shell/settings_pane/setting_help.rs | 221 ++ .../gpui_shell/settings_pane/shell_picker.rs | 185 ++ .../src/gpui_shell/settings_pane/status.rs | 324 +++ .../src/gpui_shell/settings_pane/tests.rs | 27 +- nebula_app/src/gpui_shell/ssh_settings.rs | 187 +- .../src/gpui_shell/ssh_settings/advanced.rs | 12 +- .../src/gpui_shell/ssh_settings/editor.rs | 79 +- .../terminal/completion_viewport.rs | 200 ++ .../src/gpui_shell/terminal/confirmation.rs | 393 ++++ nebula_app/src/gpui_shell/terminal/element.rs | 167 +- nebula_app/src/gpui_shell/terminal/keymap.rs | 607 ++++-- .../src/gpui_shell/terminal/keymap/win32.rs | 158 ++ nebula_app/src/gpui_shell/terminal/mod.rs | 4 +- .../terminal/ssh_connect_overlay.rs | 59 +- nebula_app/src/gpui_shell/terminal/suggest.rs | 12 + nebula_app/src/gpui_shell/terminal/view.rs | 222 +- .../gpui_shell/terminal/view/confirmation.rs | 61 + .../gpui_shell/terminal/view/notifications.rs | 76 + .../src/gpui_shell/terminal/view/pointer.rs | 169 +- .../src/gpui_shell/terminal/view/runtime.rs | 112 +- nebula_app/src/gpui_shell/theme.rs | 27 +- nebula_app/src/gpui_shell/toast.rs | 162 +- nebula_app/src/gpui_shell/workspace.rs | 205 +- .../src/gpui_shell/workspace/file_tree.rs | 2 +- .../src/gpui_shell/workspace/key_actions.rs | 8 +- .../src/gpui_shell/workspace/notifications.rs | 81 + .../src/gpui_shell/workspace/residency.rs | 32 +- .../src/gpui_shell/workspace/send_to_chat.rs | 10 +- .../workspace/session_persistence.rs | 242 +++ .../workspace/settings_navigation.rs | 141 ++ .../src/gpui_shell/workspace/sidebar.rs | 13 + .../src/gpui_shell/workspace/tab_scroll.rs | 6 +- .../src/gpui_shell/workspace/top_tabs.rs | 34 +- .../src/gpui_shell/workspace/update_dialog.rs | 17 +- .../src/gpui_shell/workspace/vcs_panel.rs | 12 +- .../gpui_shell/workspace/window_titlebar.rs | 7 + .../src/gpui_shell/workspace/windowing.rs | 256 ++- nebula_app/src/i18n/format.rs | 48 + nebula_app/src/i18n/locale.rs | 21 + nebula_app/src/i18n/mod.rs | 56 + nebula_app/src/i18n/outcomes.rs | 171 ++ nebula_app/src/i18n/tests.rs | 83 + nebula_app/src/main.rs | 3 + nebula_app/src/notify.rs | 363 ++-- nebula_app/src/platform/keyboard.rs | 29 + nebula_app/src/platform/mod.rs | 2 + nebula_app/src/platform/notifications.rs | 249 ++- nebula_app/src/platform/shell.rs | 3 +- nebula_app/src/product_ui/mod.rs | 4 +- nebula_app/src/product_ui/ui.rs | 4 +- nebula_app/src/runtime_api/cli.rs | 116 +- nebula_app/src/session.rs | 9 +- nebula_app/src/ssh_credentials.rs | 5 +- nebula_app/src/ssh_profiles.rs | 2 +- nebula_app/src/ssh_session.rs | 450 ++-- nebula_app/src/ssh_session/lifecycle.rs | 443 ++++ nebula_app/src/ssh_session/lifecycle/tests.rs | 356 ++++ nebula_app/tests/file_line_budget.rs | 180 +- nebula_app/tests/i18n_contract.rs | 64 + nebula_settings/src/language.rs | 141 ++ nebula_settings/src/lib.rs | 31 +- nebula_settings/src/reset.rs | 193 ++ nebula_terminal/src/term/keyboard.rs | 32 + .../src/term/keyboard_contract_tests.rs | 105 + nebula_terminal/src/term/mod.rs | 39 +- nebula_terminal/src/term/tests.rs | 28 + scripts/architecture/__init__.py | 0 scripts/architecture/budgets.py | 139 ++ scripts/architecture/dependencies.py | 134 ++ scripts/check_architecture.py | 78 + scripts/platform_cfg_budget.txt | 2 +- scripts/tests/test_architecture_budgets.py | 165 ++ .../tests/test_architecture_dependencies.py | 166 ++ scripts/tests/test_architecture_governance.py | 119 ++ scripts/tests/test_pebrel_branding.py | 2 +- tools/i18n-contract/Cargo.lock | 185 ++ tools/i18n-contract/Cargo.toml | 31 + tools/i18n-contract/build.rs | 8 + 137 files changed, 11610 insertions(+), 4254 deletions(-) create mode 100644 .github/workflows/architecture.yml create mode 100644 architecture/dependencies.toml create mode 100644 architecture/file-budgets.txt create mode 100644 extra/logo/ai_antigravity.png create mode 100644 nebula_app/assets/icons/nebula-layout-grid.svg create mode 100644 nebula_app/assets/icons/nebula-mouse-pointer.svg create mode 100644 nebula_app/assets/icons/nebula-pin.svg create mode 100644 nebula_app/assets/icons/nebula-sliders.svg create mode 100644 nebula_app/build/i18n.rs create mode 100644 nebula_app/build/i18n/catalog.rs create mode 100644 nebula_app/i18n/de-DE.json create mode 100644 nebula_app/i18n/es-ES.json create mode 100644 nebula_app/i18n/fr-FR.json create mode 100644 nebula_app/i18n/it-IT.json create mode 100644 nebula_app/i18n/ja-JP.json create mode 100644 nebula_app/i18n/ko-KR.json create mode 100644 nebula_app/i18n/pt-BR.json create mode 100644 nebula_app/i18n/ru-RU.json create mode 100644 nebula_app/i18n/zh-TW.json delete mode 100644 nebula_app/src/display/i18n.rs create mode 100644 nebula_app/src/gpui_shell/settings_pane/initialization.rs create mode 100644 nebula_app/src/gpui_shell/settings_pane/localization.rs create mode 100644 nebula_app/src/gpui_shell/settings_pane/navigation.rs create mode 100644 nebula_app/src/gpui_shell/settings_pane/reset.rs create mode 100644 nebula_app/src/gpui_shell/settings_pane/search_header.rs create mode 100644 nebula_app/src/gpui_shell/settings_pane/setting_help.rs create mode 100644 nebula_app/src/gpui_shell/settings_pane/shell_picker.rs create mode 100644 nebula_app/src/gpui_shell/settings_pane/status.rs create mode 100644 nebula_app/src/gpui_shell/terminal/completion_viewport.rs create mode 100644 nebula_app/src/gpui_shell/terminal/confirmation.rs create mode 100644 nebula_app/src/gpui_shell/terminal/keymap/win32.rs create mode 100644 nebula_app/src/gpui_shell/terminal/view/confirmation.rs create mode 100644 nebula_app/src/gpui_shell/terminal/view/notifications.rs create mode 100644 nebula_app/src/gpui_shell/workspace/notifications.rs create mode 100644 nebula_app/src/gpui_shell/workspace/session_persistence.rs create mode 100644 nebula_app/src/gpui_shell/workspace/settings_navigation.rs create mode 100644 nebula_app/src/gpui_shell/workspace/window_titlebar.rs create mode 100644 nebula_app/src/i18n/format.rs create mode 100644 nebula_app/src/i18n/locale.rs create mode 100644 nebula_app/src/i18n/mod.rs create mode 100644 nebula_app/src/i18n/outcomes.rs create mode 100644 nebula_app/src/i18n/tests.rs create mode 100644 nebula_app/src/platform/keyboard.rs create mode 100644 nebula_app/src/ssh_session/lifecycle.rs create mode 100644 nebula_app/src/ssh_session/lifecycle/tests.rs create mode 100644 nebula_app/tests/i18n_contract.rs create mode 100644 nebula_settings/src/language.rs create mode 100644 nebula_settings/src/reset.rs create mode 100644 nebula_terminal/src/term/keyboard.rs create mode 100644 nebula_terminal/src/term/keyboard_contract_tests.rs create mode 100644 scripts/architecture/__init__.py create mode 100644 scripts/architecture/budgets.py create mode 100644 scripts/architecture/dependencies.py create mode 100644 scripts/check_architecture.py create mode 100644 scripts/tests/test_architecture_budgets.py create mode 100644 scripts/tests/test_architecture_dependencies.py create mode 100644 scripts/tests/test_architecture_governance.py create mode 100644 tools/i18n-contract/Cargo.lock create mode 100644 tools/i18n-contract/Cargo.toml create mode 100644 tools/i18n-contract/build.rs diff --git a/.github/workflows/architecture.yml b/.github/workflows/architecture.yml new file mode 100644 index 0000000..acaf4bc --- /dev/null +++ b/.github/workflows/architecture.yml @@ -0,0 +1,72 @@ +name: Architecture contracts + +on: + pull_request: + push: + branches: [main] + merge_group: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: architecture-${{ github.ref }} + cancel-in-progress: true + +jobs: + architecture-contracts: + name: architecture-contracts + runs-on: ubuntu-24.04 + timeout-minutes: 15 + env: + PYTHONUTF8: "1" + PYTHONDONTWRITEBYTECODE: "1" + CARGO_TARGET_DIR: ${{ runner.temp }}/pebrel-i18n-contract + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - name: Test the guardrails themselves + run: >- + python3 -m unittest + scripts.tests.test_architecture_budgets + scripts.tests.test_architecture_dependencies + scripts.tests.test_architecture_governance + - name: Check source size and dependency direction + env: + ARCHITECTURE_BASE: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha || github.event.before }} + run: | + arguments=() + if [[ -n "$ARCHITECTURE_BASE" && ! "$ARCHITECTURE_BASE" =~ ^0+$ ]]; then + arguments+=(--base "$ARCHITECTURE_BASE") + fi + python3 scripts/check_architecture.py "${arguments[@]}" + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: "1.97.1" + - name: Test shared settings without the UI dependency graph + run: | + mkdir -p "$RUNNER_TEMP/pebrel-contracts" + rustc --test --edition 2024 --crate-name nebula_settings \ + nebula_settings/src/lib.rs -o "$RUNNER_TEMP/pebrel-contracts/settings" + "$RUNNER_TEMP/pebrel-contracts/settings" + - name: Test the Rust line-budget adapter + env: + CARGO_MANIFEST_DIR: ${{ github.workspace }}/nebula_app + run: | + rustc --test --edition 2024 nebula_app/tests/file_line_budget.rs \ + -o "$RUNNER_TEMP/pebrel-contracts/line-budget" + "$RUNNER_TEMP/pebrel-contracts/line-budget" + - name: Test renderer-independent translation and allocation contracts + run: cargo test --manifest-path tools/i18n-contract/Cargo.toml --locked + - name: Test branding and image compatibility + run: >- + python3 -m unittest + scripts.tests.test_pebrel_branding + scripts.tests.test_windows_resources + scripts.tests.test_app_icons diff --git a/architecture/dependencies.toml b/architecture/dependencies.toml new file mode 100644 index 0000000..4615804 --- /dev/null +++ b/architecture/dependencies.toml @@ -0,0 +1,37 @@ +version = 1 +renderer_packages = ["gpui", "gpui_platform", "gpui-component", "gpui-component-assets", "winit", "glutin", "crossfont"] + +[crates.nebula_app] +layer = "application" +dependencies = ["nebula_terminal", "nebula_config", "nebula_config_derive", "nebula_settings", "nebula_split", "nebula-completions"] +build-dependencies = ["nebula_settings"] +dev-dependencies = [] + +[crates.nebula_terminal] +layer = "core" + +[crates.nebula_config] +layer = "core" +dev-dependencies = ["nebula_config_derive"] + +[crates.nebula_config_derive] +layer = "core" +dev-dependencies = ["nebula_config"] + +[crates.nebula-completions] +layer = "core" + +[crates.nebula_settings] +layer = "core" +zero_production_dependencies = true + +[crates.nebula_split] +layer = "core" +zero_production_dependencies = true + +[crates.nebula_hook] +layer = "hook" +zero_production_dependencies = true + +[crates.nebula_gpui] +layer = "lab" diff --git a/architecture/file-budgets.txt b/architecture/file-budgets.txt new file mode 100644 index 0000000..a08a95d --- /dev/null +++ b/architecture/file-budgets.txt @@ -0,0 +1,23 @@ +limit 2000 +root nebula_app +root nebula_terminal +root nebula_config +root nebula_config_derive +root nebula-completions +root nebula_hook +root nebula_gpui +root nebula_settings +root nebula_split +root scripts +root tools +nebula_app/src/display/chrome.rs 2442 +nebula_app/src/display/command_palette.rs 3935 +nebula_app/src/display/markdown_view.rs 2067 +nebula_app/src/display/mod.rs 11150 +nebula_app/src/display/settings.rs 8434 +nebula_app/src/display/terminal_math.rs 2783 +nebula_app/src/event.rs 3594 +nebula_app/src/gpui_shell/terminal/view.rs 2416 +nebula_app/src/gpui_shell/workspace.rs 4798 +nebula_app/src/window_context.rs 3735 +nebula_terminal/src/term/mod.rs 2726 diff --git a/extra/logo/README.md b/extra/logo/README.md index c19f020..9d6d778 100644 --- a/extra/logo/README.md +++ b/extra/logo/README.md @@ -110,3 +110,19 @@ open-source asset license. These files are used only to identify a running `grok` or `grok-cli` process and do not imply xAI endorsement, approval, or sponsorship. The application selects the supplied dark or light file for contrast and scales it only when rendering; the committed files are unchanged. + +### Google Antigravity + +`ai_antigravity.png` is an unmodified copy of the full-color product icon from +Google's Antigravity press assets, verified on 2026-09-06: + +- Press assets: https://antigravity.google/press/ +- PNG: https://antigravity.google/assets/image/brand/antigravity-icon__full-color.png +- Dimensions: 540 x 540, with transparency +- SHA-256: `E0CD08CCD10CD8D08CCF0BA449823EE88495825C0841619618100D3AB089F51E` + +Google retains rights to its product names and logos. Publication in a press kit +does not make the logo an open-source asset or imply endorsement. The icon is +used to identify running `antigravity`, `antigravity-cli` and `agy` processes. +Both UI shells keep its original colors on light and dark themes and resize it +only for rendering. No LobeHub asset is embedded for Antigravity. diff --git a/extra/logo/ai_antigravity.png b/extra/logo/ai_antigravity.png new file mode 100644 index 0000000000000000000000000000000000000000..ce40c20fb8cf3b5d4fa94888d635f6550c522deb GIT binary patch literal 71914 zcmeENRaab1unjK3CAho01_|!&4#C~sg1bAxVQ_aR=-^HuxI2NtojdvNZ@6#w;qXcAgjrwaL`JNN1g1zrgo>ZEIA27wTlb~m(cSgXT}nu& z&`L#_424@#f#HTsL_DLzV+ib-`(7Eh>4h)VeiijVN%!E>a4dw;Dg1p!qSB$Ff~8T z_vsL)?>Wv<7QD$%3cUDn8EE%^FNy=-UDt9qw$>fk1KYB?=}X%>6QsB=8iT3>J4eG{ z`n3bu<(F;gf366zl!*Di+_^t`SiJX)@wkoiD2xbqcZ1@z4jHyy2LfMEd&%M?_W)v; z@sfK~zh?B|NABg;_C6zfU5G;eb<5DBLBQGl1es)J@D5#rv~}q?A6MB6OQ@*V8`0l+W<~0>dzmX_$+YQPT-X`;BX`G&0+E>d&=39 z4AQe!_ge+2PCMEs!NtgvOQvh*Cmgf>atEtqv<2Sy;84O)aS_qe7w>gbiRGb@k5~Qa zkEscKVZzQg0L%MB;D?ws_?wjfeHjgfN^E2*!+x4aD>^jj4gMKt-k20E#_W7IdRUch zQl?*35H>Ol6Y-}Eb8@6KTAbJg8X7XyTtOolB@S=K(rt|Xn_lSQ&!W9SxzmH0fE`=m zrE#fZ5M&|3zjgcLZD*}Zp|y=gd{2?Q95pdg{R>J_KUOpo23f5e{1OVp&J|~*2mn4A z!krQ;_Vh+OR9uwGnFP+CXd!_jtUB~T%qf83rSsbP&jnoHTf|5Ia><;Pz=PEQcrAO( zdT=1w_}KNjDPr(@!nkszaA!**4|3T#g~f)SpvSBv0+y-?hiH#3JzO~@4kdC{n>tc$ zBnO}fdu$j+Tb2-tQA$VWjgEsxRNmM(wOWK zq2g7-MFp`H7G4yz&CF;jh?%HHAG*K$h)1pR6(G!s5Rz7zmPL$4L(}1(9038Tnu=cP zB|0Zv}gU_X5X`49O=pFYleww!5N`}`69 zda5%uc9uMn7odnc#(vhXDfn)};3S22CS-t6*uM}R9Q2UM-msyhDo6lH5?MtwXtegg zTgv28pe$&Sh+3DdQ^nAmpoR~~)TSDX#KPWBw#h0Le!K{LD(j^N+e3fxKLGpyfvEhi z>qe*6GCsYh!x`5{9^|gnr9$?|>ijAhlhmhiFG7n}#zd-wuTr5@rH!#$Ne31oVpOEN zt{$j2koR7w@+wlb&>taU_ccqyMY*Ql>Hg7_tAqixY~k8$ZCIyE?wOcMgEf`-82EnL z2YSSQ8vtg^erSI2I1b||s1#?Z3^9DiH2iRT)6@T^UsFHPcWZIF`a9nL_d^B_5c&K4 zw}Wc(_@5*(FNC2xjWxn~+BuFyVjvo`awi?}YY4)H`UK;eV0UMlySe}c$wG<;oT=R} z`Yesyb<=!8@53LD!d`m#aU&rT3}Agi4Hnw??Hqo-G>jbHQwu~Y5lfd$0u&c=uT)%% z6**^E`~+Qj=By^naN3Ln$b08hs;u8y-v*D4sxIryfBouDPO*ejs(%WS_4ywkO@5a2vh^&^xh8lz3%XU{h|PE^FLD{eO(}VA@W>f*t(yi ziWM@%CRF}p_r`)99Az1xMGCviwTR+;up@0k2+Ul$d<>u?jQP6#f<4%#jDOluPN{rQ z52zjG&%X(U=sOX(AUTDP$F>=Tpl3|hA2dR3ffO#|-yM%=LJ)3sJU|?UH-|my$$gke%MNuECv<) z6wl<$IWCI^A2`dJqg#$urmFK^Oe5Z0ZOhU6#PkR5_sG_1#7< z=|(8nHsNgeNxtuS_~iob?yc6$nv)pvpZ}V{l|q#0z2Nab!06r*2>6{9DrTEStBO~t zhs&swTxr$aB0c=plhObCm%%(!%SK0;Y`2eDWX1|JdsB+Eb|xFjK>Z>N=jSF-e7E!) zMzK@S$kZxrJdJ};oI|ns-{?H)2+F4&|LwaG-`5T|8Y*-O8PG)^pP@HEjS;NxZ~uHg z-tHdB2T@x_;%h$@>t<;*TMR#`BK2;|`CCCTTMFXD-+Y`}e;luzD!ypt$CrsdBhZSUQ zl@3l5TeeXGHdcrQI>vR&-TM!|mRhzMbJI{znxs;;Y_R~(-(>1SL<;(N^Nut}Q*sk| z1<_rp820tL*WHKL_qKmVfaxX+nDZKcJn4IL>~)R`Fp?YGp9}CaQo5YraV8xXOo-2I zD*3`Wncr)`6+ca@u#}<|60RbFqipVbC2t*Cfn}YV*+DL?@e8vt!=dmnSKOd>Wp++I z=2E=)&&pUC*=_hS9aA#3%3x;>qm=>`wwhIG=K1Ln=g&qC>&fpO)s~q(H^UDfZ6v8+ zV^E*_XAD@N1(Mg=HWtvO?PjVic^Ek@4iWCIk2r8=e8l`GLfYUYcCZ=)@?4RCUDe`J zS`cyt-f%+!cYzL08l)EKh^n&ormmb_g<E3LIVX1T;_ zNvTqKxU|gYC}|yrC8S>2+!7yddgT*LI+`uF%HEsLTQ@I#JlUNBYMD~rGD3MH>SW1-fr=Q+;Q8JS%2w}z3>TEGKiPbiA zah<1=SSi}JoCIQO^(~hUN}P8t5)uaj0@I;Pb7E2DYdN(`3lx06kn5(3f+NjprKTT_ z-AJA+9aonFZeD~zQvXzh{hyzLc_qPVWK-Azc?r(Mb)j62$a9oeK}jjEH(KKuCWPmG z2UssVeS{y6`HE7UGOBs}g?i>1+t#G&x#ze%qOPCp_a+8OK#(s^~X(DBk zs|Yqud#kXOCJDr{2G+LNBqv1OeqJWlU`y6uA_vfq*sMyv;)D>(7FTSzuA;=wve~Z^F zL8OEf+clX|ra50H(QrWlgoBom(j{o?R~>b4es(?S%ti82?R1MNh?|zCcN46_*i+@U zab}|O5jokmCl!~&bCb5lkW6KhKF=`LcqMGNHWr^B60qcMx63cn&QSNPxgW1vCgrmx z(b!*m=k*D!3HSg;k8kZIoxP7*ADgIP0`#~(3noC1IahOVb-k6H{>C@&Nhy#F?{H#H zL_2z{uI!0v#D*{f#yyqtW-!3&SM9oQ?3qII8*p_EZyjEhW}6e*Y1A$@j>Js!Wg{J0 z^_$51-){=K<%rif6UemEnC;vR>1ACM$l0J3;ou{&2Ku8@oT{ z8+geg13sYi|7Z@=$TExB-sjFhV|GW1as_d2=sOd_o`CM&8mtMle8EN!5v4f7o?22o z_}Dp@b%aQ0h`E~-KR2|&z*9n^1gz0j65zNxf;0kbqcs_N?^e92-CTxgBB?w^SyBad zshWo$i8V&4e@TgMj>-_XN?MUOV~Iwxoemm$Ppxu=2BW4rCLzO^?CWb2`$$Ib+Z$>Z zvQIIHN%E&8L7XF-1Gn!3#-L-d>ro|n&>Uvzgu(hG-$eO?!QZCA0+AV!pJji+8_<}g&5!y9}n+xEz(%~hmg z;zA4IijKPPCmmWd)%YPw}v@&=HG=hTIVH$YLtd# zm~Wh%OQ_MU?O27;&WRk^lFQb?Zu`2gcYxV-!^mCV49CC(FrRkB;|I)J`Ndp4eEjWu zIFMU^t^`hY%p)0Mljrp+bI*^f|A2FUwZkVyCc$JGkZG_vX{cw_hx>+;F<}NrHA>yj5aG zT}pnSaFv@>CCF-Aj&Q(v=hA5ZhzV7C}V7-P*@##1;$K=j#Rk%P-nK8GSr^ zZcZy*6w4X=l_*o_^UMOAjwflXd+_I)!M6%-YSGZ% zDmVqmBxdseEJ+OR2Z2bDpv*!7qFpQK{VBf~E%X2O!|sN~2vanD2ZjxW1O)(}J^MG% z-B5?w_WVfUt6RyUe%AF7^augEE zQr&A?AyeU^a$x^2~SilU<*2mRPFfIec ztQM?_K?FlHAFnhW4}q_ zveO{tMSQE*Yh@OmtYFrh%{jf8JEAI5G|!kvrdDm0$7$fVQ|KN%VNd-H;W&;`5g@p! zC{{d3*Jr7Bb7x7#pCfDC=HO+Z1oYUWT`sb=I;d}3&=O&IoX}^A;86q>;2la{=)mMEfP-VN3eKi8|jvF{2-3vJSK-4w3$>JDXXHS z!05Co`9;n^B{pg=rC{cUsH!TYfL@!cUPb6H#wcWq0&&}<7E1Xt!PY&buY^4CG19F< zB#M!KeY$_hK|Szmg=+fq0@NJ(A{N&<&B4+>rjAB`4(!1th(5A^yzS9_H>57`sK&3; z-z`cf0r$cRgJ@Tzt93hm6F(VKzlo!?`l9-LuvG7}JPo`WRbH!(!oVy`h?>RO{)#-; z_LkesC}O#kO}KS#g-agq*%1l%z+Qu9*V-SkhGnd%@GMs%I3j7O683F~xpfb$CM(Vd zjGj|bo2y*2%93ZUbq3K(xjVc14*UfQ7xuYQwx#RmUBi^d{hqGZEzgg1l9wz0e_Lh# z$6sFgO^UmzQk%3V8ym<`1pf@ z*59RP7|r*U(Mm&Yb}NS>t?QPJkku`XY@$+70s_1HD9Sn9cg6NH*+uZn%fIB9eQi*( zqcP8BnCE0jD^8{(DA5&#;M=H^uk#wU!N@w0l5xloXMrd*)Und~y8AVGIQDkqf& zLKDngb}R&T0CiA-M>GMKYutC~#IU7M(3LP$-heujq&Re?`7AohE- zfhr%brZXD+{rwg0xgm0$GN_jT=;eL?XWJw8O6ms*8EI@$MRr1ZemV~}kj6y&Xut>i zfd{4LF|eT~-*!W7_-%GFYX1&Dhw$j`c@p0PbqvadAybj$qN~T(dq?gcgr|;EWs5v$G5AJD>{_b!Eums~O(d$v3uNIPQY;@T42WpAS~NclEiI zkyFeof!+zNM`xqQ`8yTpaj6M)DyF-3ioti+25~Nb@Rn0*<~ac9H;S4t6afhfjx5O1aU?T|AN6QK3l0D8dF~(BCA9&S=GR4ikP@QHhq<{T|tdXljt0-35U%4rAQAVbZumZpC9XG4ABgYj-Y0vBII%sdTBhGsx zf$obw+*&F}-2O?zPt~;Is+;l-=%&9qnG#EDJukMwF9iUq=SbHVtrEGB#A@Pxkff!Og zs{%2*GOWMUKv`pRrhnISZbt5F(~0N({qpr$6h8kptEdCob3Bm)3Es5=8aR`Ovc1r; zt1Yi!mcnWjVX=CseKW^knuJs$Sxu>5P;LyA_#q&{Daa){O#Qy|8M3F1oFss!l+=-o z;jKujf_bX!^eDUC66z@yfMQ)LpZO(fSVjhWhniN-Q6k!-N=t45^8{W4>c!^iwt6!} zzLu~~pmXcM;AHV1E=0@J@#BoB;?<)sz{L(mp=;xa+lW*|-&5sYjjG{f`!iIR z@Mpd&-_P*AV~X)h{E2`%iT})@O$T#(gf_*ANZ{_RF{UjG>}fcKUZTkO)g?twRV5Pd zCaGAwbUd?D8C8Xl^K7%cRuX*^SnDEtMnQQNfLMz(qq)gBc#>n}QLcqQ&LtO8%AZeD9@Q?$X zbN!WjeU%jz^h<@C#z4YOsM!sOig#IDDIO160{u}Z&&QSC$lweLopen5h%Ykc$@yVv z1<61`%e9uNo3ghjNZi@}gpN-hRp<0fB+y%+nG;@3&{s6rD$x%ixK}G{vR!I;p>Xn+ z(j@Ysp=RaDJvjnzi{=D=o}3DKFys**98`d7|J;Yhf9K88?@J;W(EPSL6X=$-?kKov zrZEOFemf$Yw3j7FATOQjgRe@&M)iAvoOm-_D~>QPZlg-;JA4j;0C`4_2RHIhY5M|w ztQ##&>}WQFy%ftdp$OkPeiKAVHkEs0>PpfZT*#szy*_{1lms zuYBTxa}(t7f&xfrfs1o?i!E|M;UtCBtzfO@(i zVXDZ8uj=*LbxR08R7NP*_17O8ThFTto+qP+3g=(Jtvo!!DvBX2mQx zSzrZysOONpw{}-uP_Hy1qHl~u1fr}ftaV!N#{8jgbe~9j3N4@OAfD^hxy%~fs3ocB zDRD89%38!6>{L#%R`De11 zxWbO`EYs3mlFK(N2dPx0F6C4*kt${@h&3hpZEow@^yBPbli{_|3Kd+(VKDjYxG+R} zu10qLQm~U{9OE(s;!MWhZ5?Q8$UAlXE&R+r$*ZBUYzyr3#oTdQS>h3+T+Kh4ZDEQi zXA*@DlmYq5`*1?r&$b{XbX6Z2Hd1xGe0}YC8S^-EVQ)PA#!cmTO-#XCS1^=;Y5q!_ zm9tv4z{~GqoFh>eo1<@=e=wZb)Hmqs@5EmttC+C#$hd1+sI3|wl>Hq;x{7J)Kt@>V z_T(2gDEo9x_~y}>KpGs5Abx@~l$oJ~fVZx{%rk99<|%Z%ER>bx#@VrXc$w{|0(m0} z-6SFlTUD;2-Eh#l87$=6Yc=YV zjj$m*!jd7}y6%>b%8Ac&RjD`nW4PBP(0iEGd_L2RlHkv`M3JWSF&uce@Ua{LHeeyl zntw?F5_98o7kal?)T}zw%9!h$tH!TtT13bL<7>J==mzb*^8}>v%D+D_Op=e#SyT+n zD=2CpOl<2{7Yx@gZi#hw(bt8wl^%4S^RlQ0xt)7%Gx%A%@IBGyG(Z zh+SvLH?D^bG(2c%6zK(mXkzFZ<;qBr2a`2PMh<}h@02BG&WOgQV|FFQOkqnd%hRzE zov)#_wFOnF%U$Ux`dYYx)&khxMSEp`3lK5!FKgN{T6ABll$ zG{E&e+^Rasic!|ogQqqs8^@K=&vED?@yLQzSD2zl;D&Zy!D;Q&yEf{63BfO#5Yof*ZsF~W! zZbDB@K*Vw`N~e+fYYlI+ zq+~I#$KtNcaTcb2ZaSz>7CO$RWDM|@1H^ERQDgO1ZSfgB6}YOzP+b>kywWPczh5Ze z#!X(1Jt3(_W`a*DZNo&#rKmeJ|H>6fNRnp<%XH72F(6JlI2!f7D1(Pt3c!_eDBi_5 zx|@)Rwl<+O?EM}3Zyd9%E$n!yXt792m&C_U`74nlx|m8!XI{!!UuKtFiJAJB+1ikz zy`o$1ux5TAAk9wnWuof?Zbb?IcN^)#(=Cw6T4CM-b0W2IT>CuQ}L$zJIrj0C+HP-%p6Lh@hAcU{V4RrDnNGoX}@bn zCdcpW1k*alLXUIS`qQiC1PPuR1ed)Go&urCh1|owpk?QPN|bJN&=ZwLY%^8@gfm5E zaDNv$zO6sxy41z%KnuF51vpb#pe#T7jR zNgN78k1Hx1Q9Ayk0LN8Tmj9D`%T98zQ9Kv*ca*pi~ms2-?c;<;v~fw=Y&aqjcy= znNNC*yIcmn_e;z|JITpgFeD1`hg;2sgR(`qBu=?qGKG44|kn=WX63^{%SC9>s2jtyKvHWybvWXjd!OY;KJvP)j?3&?V#?HX6!+mJk z;f8!i$2sSPtLBTvLu%q?xNa2uBloT0)6=N=KP~FX@a4#I1+?O32E(1iw;A7lv=!nC z99=4?_qCIl7gawL{n_rMekDyAI#D*99h+c~k9WU#-|w?OTj75dLfW}g(F!6NX7nQo zA4g@gBNd$QrG6G7t;3@wSYc7rh$ zGdU96+n%-dHWI~aP!|^kwW~^BbrtT|7iOztp(j;cUdR9j12c3FtT;>lb;6FvpB^zN z9p7k}-Vtdbm<_xv75O|H*5{`6uTlxA=cIws9`G&dqQ9LvtAw>>&t81Ly}5PvC!e+F z6R+6vTLLJzXRbKohq)O>NgRz-Nz^tj*}}zvN?e*wcD2Oc&{yzg=R_70`XsQCpw1Vi zsjKho*R(@n<&xP7ha!EAwKr{>1-*)_Gzw#JOG>j)c5F!@W45EhOFkL4{=STz5*3+K zj!ER!=^hJ2ISDRz8D6!3)?wHGi8-F^v15sY&612LlLLIbSevH@+sDRg_Z;h6Bf_c+ z!K#O^hjCiT^FwAjeI^&=Qcz8cfBheANfk#Pp+>4soh;faUWZo389eG7barL>Q~Q}^ zJFI4;t!1;tpG))`dWFSdPr47S7J&7-ZJyu0z=taPC;EiD{HB`csa5KDmQ3!ies5iA zn&-@-((+Yt74@b`6(LB3p;ii+L_+EgHi}sXp=(wx4y5|0%N_8_&3r6OTTe}opW6M5 zCSI}mE@@t?8zsE;Ib`zFJx==MKi|+;)LZ?38i3U-A=PIC!Wk{jw@8A_N?qKyJtOcl@uuO2^s|-fgd0 z1~LhX`6+dB_xHxAD4M0XpaTd0~5jqkQ#jD>l1fd!1V%_$WAS+5@c1>AfDJh-^N^6$CH~4PV;&7t_LGh^UA8Q~PNXgwIb2 zkbF51hvd+WxA(mLt?jqNJ9*_w5~pkcr`pY|GB!YurKOuwKC8vCUAHndP}Oh`r(!&V z;$>e!l&cZ=S3+wRIBvrrA>2sj?GtB(juY6RNuu#u$IgybAH{VzyeCd($kZ2gL6yOEm z1@hw`-`_DpFT?2I9>^Gtq8{NNap72Q%^%yJee+(7d7(@%?YBv0q&^~sbkp<2unZ%E zZekm`*Wn1XdSh=5a^k(ah1PLIl_uUArhdRdDvWs5m3Xn9J{_0A3x^-BS_S7W8Idg= z^$+CnUmH%eVw}x9!2L~djk|K)Zl6X6OH zwm4mvRl;9n?kgx*2GXBx=H$eVA|`VV++Qob_}J}xKl!);lS@NUaMj(5t|6w!#0&qS zF1Sn=+7>$gZr@&pn>zTqVWy{HUVK;zU~jwja+g;H_Z6~J;$ZAc2}ORQjACm!qKD}3P$yz!VkLt!keuNCa-1^coW ziA5H1<;e)c#e}{iw)m&g_5=DOJSqi_eLA4HT2-I&$udlCjOE3f2zrG3W;`ydU?|S3E_~^Obw_cgXDw9G( zY|VD02z`ErUxyx7%(<1Q_BixQ=Kip`@`ImNtFlZzOS4O<)x}1*9CSvq*3;nj3DwBw zJyW_N@JX=3H<~)iFSog^-R^*Am>=LLzz-I8N36pT={jcrJ6BJ}=Ri!1QH}`JZ)mcK z72xow??R?dTk7ela6;bAW_yhPpnIH`)n51ZfPFXc(M#XSVk9e_035=5rfCXL5?ev26Eey5S?+k z8*>Z7I+D{Dg%-9qA2oC>K6-OQE&%SR4=JDd%rHEQEpP1@eZMEwy=Z}ULHa#AtPsm3 z`EgqyCV53d=3C_o8|Nq-9&*TAPE0wH3yK_{=h7_Jd}7IxU8L7?g)#jNmJis#D3D&9_ZPY8d;AG!asBB(DdO|G{H2U zjs`+Su^O6XGxqL8=ANpRB(_*@A{eEPI8DaL9Qud77Cj)C|348N1Mo{wcPaowX51;%F`2^$w^#!^GrghnP$;|u>k>u2SA0E)0TT_Be}#50!OtpWj1V-DXP0|w;wn+ z0;%rwJym?{?@esI>!Cu)z*8X;>Vf*{PH95`sQrBTNZdfToAO zMXXq27U7jof3A1kL`-lDi^i3X^rHVo&PvW@Y&Rf*8fl@%HnI-Cq9O8w1j=T(VQag9 z8`{u=H}0Kq8F8q2{#Z^k|1qQLmZb@|kc}|lLKQpT z0T!T|aQ(*Mqz+*>xspB4uw7B#>$$Qdtsi%r8QLL&kkSKgdA2eV?f7a>4GvuK7pq$7 zMJHimRtkq8LTNASyV?j!G=&`F>P3kyY3Eb1&|hLjQq*Z&@#&TXQcsrk6Ivw)@fRNx zBokvf#1No`9L*9=?>5s+jDj|4T!|HaZ6<+{DS`>69wBcQzJpb?=i)N19A5|o=V}@COfqpeGuy0@&^q8`u6CqJlEId&cH)c2Y zK0|V?6-Sh$%#7ORzrx5$Epwv`@Z3eti)h5n94S&1DEW0mc5iMCj&o77bZH}UL3Ms_ zQ)^@oB$uw?eFKaOl(#72Fo=7L)?bxkoHAE2ZY^^=fdx&>R2K z=U0YhkcU&i7484+rdwhLn-(P9HOf9z%lWyLWP+5RVyYoJ&YBUqm|l(bhQ7z-D0RAe zjRM=9R)O(n?$WpD2QUA7vuH|-nn($Jnxh77;)(O_{KR&HT%~JVpf3-9@9o;=f0qpm zA^(fxtVeG@Ywc$JGRkM8pIdKTBE{)PO0uDPy3B=!9jVNrL_^#L zJUCkiUK36y3S`-TKQXG3!`;uv3<4Ky6Qf&ZPCt?`&9!8|f2&Zb#5Ek?HwNhgho7*g z+PKGL+5;?fY1>*-i%3mx-iP zt5|4se~g$@9o~G`RuDZH*Yl&Dr~Cq#jERi>iG9p8&ysiQ(gK_BM#ZZBi5p0n_r-V3bmYo)g91b6^&lutr7P;mU+YI`3|_e!y;`F4EnZPa?e*1iGStnJ>kH;D0MymJ6VGnX@91ELiH zy@wPqL*6tG`h8|J8kbI|1}apCMRv(eI`uQ7%z!G>lNBd@H!6dEfv(`c>_t!~L!G}H zO!JwJMt?}lYoR>1bo~=n z1RN=FhyxGH-beR{(&$9^ubOQ@eheVSneS6NdHNJJMfgg48f55We;9;rryjWOSAXa% z{**q!RQmYq?IsaSB>N}Q&Yt7iy%POhyw_Z(>dEHBL?2KI?}KaDHyTbLQP_-&{mJOi z2U11g4@s!BAd%^DMuVQ=GPwYx#xQRv*F|+ouWrdVD@X%6Mpvc5=~T&e$4Umwd3U<|39p}L(t#epY3*r;3vLTGqLSAr1-ZIaQ`pzn}icRv# z5|uzeowCzB+jrF@i+Q|CAkNULz^WJBKTb0s2D~3Zr(AL52(IoUI6+SiCPgnTQ`Huu z|0Z^kc-=H{^Ys<7Hg$XD%CLaX>)w-8#-t^D_J{0NSXtnGZl~lu48njZQfTcDBssN` zZT~QvJwDWl`AzIhdp68cRF6kYznUsQIow84a~Kzs>3&a^&54xCBcDfLd|$u+AKb|# zMUzsI-6|@CAZL#gD5=CYr1i5^xbd))GM%G*f$kES-$h@~O69eJRtvH%y@=thabi-Z zlo{~QsLrtV?Y?vUSrE(ES;~<8>_SD79KK-{(Hmd`)*;{t&e!C5Q zY%;Mx_+y+YOCqmuT}7tYhH;G2PeI`U`SCsL9JQ-nCqYu3JU2X}XRWOd5@9fzfsyD-Q8GtmKKt0MFvm9m{e0@zuVaKKcR zy?!mr-q~lJZ_|MQgaXS^Vm>(eY6G+Ht^mk=bFK@~%5ePQ-q>0PLM~I){so#^LN9&` zsRt!THWN~A#h>wulY;mm$ZmuNuBuK?Kn>^iwQ#AQ*7~)Q*oov94aLD`YADKLmYQni zgtcabB^Eew^r?k})Dbf(xmM>R0VKR%R4L%1>iLU@jd3+DWn69rvq&*9nSMw7w&bY9 z1jO=A7Ot523s**?cOj}{m1dh&@Y@sHcR&6kIHG$quZurPjDi;~#Pu9GN=1>vXORjW zW=k%AfJ|cy3IFD^Mv2(Tw}|U7v~py_zKzDD7Kq?-!j+>6wS^slisRJALk=1kmP7%+ ziqlu(KYp+UrCX;4A;zVRqiZ(MzUUQFs)g{@%Ui&BprNyQ5VX{D?qhb3RHrhH+asGv ziRC6%ibj6Z7>h3|!=olY!qAdCD5^x2gZbnCf_&t*tHPfSQ?kKk)PycJLrv#i0T%FW(rSvQH&`a-Bnac-JU(eSBZbRdLal`~9CJZ{< z@XwTxVW+HA)Mu0CD3yNZ%jEtu$2pGn*mSbI^iX6nyU!NS+Wfgyk1z;Ys-TDzi{UH3 zR|mrzqRL+zN_sJIxf|9r_FDyzx{&b~ci>_CqB0YWy9;Ls_%485^iRf^&cWXY1c&WI zuV|_q=STf77Ifsd0LJT}@ii)b`FcTwRqWOHNCu2G8RuCv_{{9Yv1JhU;Ps*fngGqG zhGtTF5wya*Z%`PDe*t{I)rcU&%t!&@jfZz%)G1QbrDG8@Fts-Y^5m|*iX4nykC?3O zdGY+y;4JuNo0o}m6t-i%KoT}(UPS?(y6NCx*NzB<)Gy0UNb|U4%6^Tg^ER^KbD4`D zy$N^Rks&J1ktX&+xoKb)bhM7`kpCWE9SSPPg&LgwO!4eODy`)6nb{h0Lq-wBtDI!~ zQQ*;ldpZSg@*kZ#tJ{5fzNw>zCkX?Jr_bdE`*XJ&-n|Lz@=!9TRra3l}MR<$}M zwRmQP9R9TD+i&NQxO&u)Qv`AVYUH!-F0Z-&i{AjocgHL$o>`_TX_J-=k&M9kV@MgS9z677Gmb8O|KoXgL@s=eh)$nrm zP=y+nvCB@h+av{>SIx5Qa=AL>lgxu4Aqo5Pqh6eA0ne86Ek|Xk=&G%LKLh*gqE60UoN{AAFukpbSy>t-OqoeA};gfF{wq%EZ+2b?o{zcm%1mt}UX_HCtiU z$jc3<@M%G3g(hhnn$%T6>HV-3sY~<$BS*zlEh(;=pKcEdP8$N0D&s-pF3q5Y=&Ce{&RQ%t#+Ol8@f2Xak)i9kTG&!_J{V03LCH zO(4D8>_G)PTe>HD@T<*caZGtl=um4VPyEfgQuCLLc?k|I45sn0=T`8j-^Mu5XDaq+ zBl`QV79)R>DMi3*&TQ0D!p}a_YW`~!fK%SFSMfiOrT$OLJ6B>xAH8cG zTU&Fk=$oI>V!V(~BRs2{j}P{3!bTSSG;>*3@J4zE+v)J>qu~kcMBa?~PT0jaAMej8 z5of{)xOO0tc41T;9fmuoi*Hduu0^RFBuRy6@Q}EzQWCMzaQgs9LJ^1LA5ZTQC$2;> z)Vd))tXR})G&pkAtfQE~YRqhnIK*M*{Mwh!r{;8%D;xdy?P@A}Ym24&X;Cs!RCFbo2nXZX*uuttkB+YFwa311p4%o-G{@*| zXwSWfsjS*+^+i7FHy-m&Shb|j?Funt58aA#_g-jNr-82k7a@RkJ$Tv!zAp@i_WC4_ zcN!APzMaH9@XojEXzsa=o?+J)*kldrpNL>V6|mo#jM1;7H0fE%0rz(|qH`(-h{SZhQ( zPTG>qf!xVx^QzP!`0Y>Yx)T$vNl&#c=~N>{{Y!~n(&VoKH#c{#TE7PRIGdI3x?Ga_hc(T~AN(h&cKgA8M9}X?34)z3v zZ+ucqH9k63>78rxP_hrYF_*zLU82E*L~_1(UtuoTrVTj<;TA%u5ox@vOJ{odnLaqUvkBXM=|330EnTno-$Mz(y2 zeAs96R9UhsvS`Bl)AHl!aOuL;13&)m0UbSb0ic0GaQXjpnDARl;Q5koSD;2JG)0JO z4MssDnuxie;!8IsJ=D5KS{{Cvf2YzwQR6VM<>=8(m!2vubTHOBxXf$+SxnKh*s{b~ zlq)GM8Es+{H~1ZS&Lp)mM!D1zvpN#qKDSoZswAOZvXb?jg>4@@uo38oeo56(=ZZ{E zeR!kHD`aS#sQ=(2_(v%<%di$y&`+bz)gz4GbOhc?;tmPykTB=K?5OZ z6*D^C5oqER5P?*YkeJPEdtGSTE#v_#C~C6NMs1>%4HmSQG#^Z$|NDRh#r8F+R5M&d zi=*NT1>NBkZfBcrIZp>4>i4|`9+oYC4g5G9c<@bX7A)rfA?Yd{n(n`@N+Sr2ZWtpr zy1Q#M3`R*vmvn=Gbl2$a8c5><2+|GGrF4hVh*G}rd;f*)e(v|&bI&>VFjbKF5K|A2 zEec%;T^yiVKqiKFlD)ByU}JL zV+CMEPW{s2Mt}wqGy8#lu~C@B54&&UzEv#Y&T81yjOfl|=fr*b2VUfq9$qrZC1wxt zqz!&ZS^Z-Ahd4F5nT}{p#8TEO(E%{DTg!)2yRRUg=2oMzj{{*Dee=nk|Mle~l?{)q zDLP6Jg6rz0J4xuXv#F(AuKxK^#~@PxH6@JuvE7vRNp?|b&G*JaB%F3UL9{exoz#gG zmDg^}%2QDz;fd`e#eNbBcWAbv<}b3(;F8#db9semvi%M8b9pUewr1b~Z>lMw&!a0V zc%==N=iWTNf#n4J{dy&MM#VcLN|DA;(|_jfm}Q+@BXG`S$#5I-Q>zjCRSSiaW7e&- zRaA{K!tEDgp5X>+$|b)8XHK`D+8ofYBD%>`EtSdK!!MT@*QQkbhih^SnnJ997 zOK8EYg9_S3=I2Gn*#DJ0nTh=CWJ6vYY#CUhQ*T>p^ZQ`L^(3dk&uA{6s@I+{dX!{s z-+1Y*Wg*Na_coXe#*_7Cd1vLH>^ARcs$ssKvIrBzSfMI{e%aWreA+H;@O9*qnuQZ* z?QitymoN7{U*jLij^~wMAthyD0h;BkZ|WB#ZelhFgBD;uw0HxqLPK((`D7H)-)|vN zR!!XLJAT9i-P(Vk*Ij?)?yG|Ov;`Neb+~GB@p?ROfA>0soTtSkcH4?@yS9{Dw3hQX z1}G5;HlB*Y(BGExChcjJCMQtip&UxYUT~uo=@#?1>m3Rem2A3!N%yb^>>VUJzEo~f zJjFDmF;!w?$-fLxN*^s$HN*74$TnS0SW(NDh{7c_>l^jMM>mWbNMpPf=8GU&YES#Q z1Tit-Oa8hUM~drS*~3TSr%@@ZMXU`|d1>-0wY(4HFKPXsh;xVL|FEaaUxt4QYC)K4 zQEtDD_0=k%@J_`JPBOf%1j;?l_4jn01a=g;u@*ZI#5atXn4uc)Ry9u!O_{5$n6$+J zhpz#J;-iiOD8i+8eax~wE%SnIJWy1$ z7w=Mbs<3T2l6Az&)$tMPi{J3sB@pi<`5`xT_U!eH;p5n4if5|$?k44T{w{KR0 zf)lpm{;c-$8)n)syotTaB)%v%hfYA7HTU<6>Xe=%E3_f;r05xvXWq$sTuKI=%0HQs zCCtLnf5bOk`#>mc{rK5`zb&g{~c26e!;2NOg2 z1`|BK(Nw)DPwoI`)%|m$ji|Gt))0gDA`$P2zsirArCTU4G&qi34KAILrw^L!-4vFq z*OWppJiBxSwqsgUbgOee>uKzB{c3=TW#7!{Xbk1o$Ouc^xu(fziSsL6(ho9vT4cqv zeK_&{w}aR97x}a%#C+beVDRe)x=UBp`nsM2DTRwPORMv z)VpSy&NfWQb>aPHG5c;=R>PzV2t6LGvS+$v{`n&qkGA09HvM(;d%U2(zimAqai4(q zizf$Z2YxGRq~M#`;-J$!Hnf-D(?YkY(Y=^4m!0Xg*`)P*Q4b_=bgh1B$AoYL9s2^( zKj@1TMPDThl)DqZdLH0FPDTvcvXQ~ zmhuHJ9m7Zacr~HGRqLvdm!0Q)y(Ia#1us|?(LU8Lj!3`BG%Q-T)DJT(=!FcJ63jFE z78r=M)9=y|A=L5jzRoZ&xWR6uZ49+zAs2vN&rXOV)gYl$P#^3bkz2f zPG;N$W&Gc-+WHj7+Sb#XY5f@6t`oqnK^gKc5g(P}EHY)+vQkqXG%IK5-BWkNd@`%} z(7XJ-3sk(^dDB;+XT1Vd^7>NmOGvwuZNVX#dAhHpgj}EjWsaTZm ztCVSRGwh`a0&d1@jTF7h_r~Z9#!C#x6l+>pdu($(uJVfC2o7p zb6O%tc*L7CwiOH&KJe7){q|vux@mfs@`+%cvAXoXQbAo21s-FI%G&emgDN0n`S}80 z^xpe)@fk>q`H(ipN>GMsp4{kbyPQ@Hw_1`m^=i44X2al+U*FQW8JK3jWt*pH{bppJ zL1Ytja3Xj;7tWpjozvbTOZEPl6m8eFZkxS^r zXo`U5LuL4ixihVvu~~1X3BeLy#>@*82jR~ONQ{1{f{2JihKkPHdhV>(OffS`1G6p$ zyl|3ozy7lz)ovV;YPvJ>?5GJ%3jrKA?O0Uw^69-!L%TRn+Ov1Hs+Llp^cMv-Lp@|2 z#+5ehh4|qe@LL+YH+{F2G(IBXevzh4+WEUdQOCc_4c9OR2vt##I5|##UC}P>jOe#0 z7DV95A;FQ_;+_mo^%RIlyeKf`-`Cj=Eg^&0$$hhZQkD~Ki9s@NA;6j!c;BF{_~F0h+$!DGzA`A#F8~Ip5B6vNVn7c zl{M-LaKm$sl;JmXMB6VHiZ%xhu*w)kXI9>xFEfR_=-lf&=x%8$ovXLTyZmAQZZtLQ z(+#sKEw}ZfW+3`Wv~P=ILd1WV<0f|7{T4P=pM3l**gC=9zj^8j`00Uq+qSx*@u^N+vX^rt54{?Xh>|RQldM zzLun3{q(}0e1+#L7AK0H*hKMQ<`&>E>OIMH((5YhY+(le;;g_ksmvcgxln4Y^sidO>Pf~2Y%hJ7hL8U0N7zu=>`02S?D`3W0+xe z_yM18kS~D<5O3e=f>~zi8pWsYk9;QTgjuD5aKN#}ty!!ut7vWfVbt~-4ajBzNpR^d zhr+qWi&Pw)!Rk6`eo7Kbc)~4{{Ez(_9iHCF zN6rjr^dK^fq<@lM`PDcvX7LcjuXJIcdMstt_l(lXxy{-3@-&iPScyEPje6d-M_SA3 zsq>|gWQy&NH;2C&e-~_afoVUz|8n1k(wzwpXXX(mir*jMp2WW{CmZa_lE7;VnCFuj zJ>tN2-{$Fq9ZuEg3un7(xdz8#CuIoot%FaSbT>D5!g<D*}h;^r@|Z1`Z@ zoYF0A$j8jmEqcwDpiqAw#y^%F2?-=yIzPtF%aU@qa<_z7?}^N%5_{z8P35e&qQC2p z5NOLt0f^G*@{2d>1#YbWeE*ilSzC`&YQ}a?2LNaG%)F7#v_yM@^j$DYKAfjNsW|KQ zX%ap+pc>cT1D{E}j~!?6PcusJb{{dIvN+b;k|wrls@Y27FR_9*6jmQ>7}f}>MG&G( zbNwEDa~j<^!&8QBXRGu@=rZ{|sWr4tjrq;6Lw!+J#!nt!blQnc@~pe|f}!IdAA^P( zXR9jU<+T2E@+hX!ci#6m=)XyvmzY#ry0v>ju?;>;rUj}qELanU`;;?JDAqmUDOn=l z;c3QSH^3b((y8y24Wm|Areg&;rf7#_6DNI7H?sJwq#L(${yI3hMq>EV5-qNl`Bb$5 zGeJofNi;9t5MPY7$aaXQ+l5J~H=%m0IW27RwhE8HEE3MFkv&lnS3XNHPkMs3Z2pB< zDUoDfyHQE6Q$v3-_x+2#u!BeFF-}2AW9(pV=De7S5UP4-BE~Fz_8|uKK2c(Sg}8S>n^{5`QzfqIJ;jaqJrL#Ui&JrfmD5pG@><4IRVah0iN77#4X z7|ESPO_xRt%moH@5k4IP3i~IaMEEN+`7u;ivKo;F9h%R(J!g-VH9F_?-nATaCgd{V zOfb|4SLU1?cu}z&1moDVukpz|OCYnTjI5^`4|FuI5DZrUxtFp?T8zrNr+k?mz4n+$rXI%>?ye zlRKXPuYHScF>LS*1hKU_Zw0|LAtsmuyyWw8hFs;(Ftoy3nt%!Y4JZ_ z-W5|^Ed+q7Z--1$6t;yGo>-Sjc8;D)Ckl8B3d2Mp0uB;2A=C*q%25os`k3wmi$+`8Ip zlSKHZJ;`flOx941!Vi4=<+=Z=tV0_r8+#lS+d)C!kV#n1qjK1`@l6>7U_#v;Iev^; zQ5dT{dUlj!CVQ{LDi95*nntxKz?0TELwOu10{-F3X}_ydDhEtU1*ZyM8HuCFvrGL58^_oh-&_ERiM|EZ#W?jH&YTbHAt zNsi=s&&@_irJ6y795_lWI=MWHbYI_xzN?wO_#)W%T|iGRLGU#B&AHdnfXiqq3Qm#g zFV&V-676q#X0hHMURBD`Ga3>adrPXTPYy`F=bgXQp-jyZpG%m^0#T;l#cwt1ohEK& zkMUG(J*pjPFmbiC2Vc__)mEzN{EE|o7j9UV2)Yu>iK}flxrli-!=Uf|zoxcYLHx9z z*<|(qb$)Z&-})u|ZN3z8DP&V!1QPLQ#vt-@>G#cont#u(hS^MTSYKwJaxPO5)7*fF zkV;~KS~M`xk7PV)E@GWls!gAX4+eMd8Fx2n zqWeM{Hmib?@d3Cx%Vf~d5NLhyB9MR21dIByGevodDo;OWpd)8Ko^P+or|sfMG(^1! z+ikmVRddwOcRSE~J>pKr8ahBYw0#-@D!aZ(R4(Runk_g2ff7HgE|D2@^p!T`#~+@@ z*WBW#@pT`Nkved!C}we3=}q97MmMGdf3it_{RT-Ac^hs=q>&s4I8)+MNWvGmpy;zn zo4dlvv7N5;>%s}m@-ncCmf^^3Cx<>oiRqcl8jktmCKtEm{tNWOE-z2;qOg_Lv3GC! zE^7(zVLJ^``B&Uxj(&*Cen>YV%ua~gsn4Hs_@;|#-8**COJ|m4AL-I@2KF(G)EEmA|!C0;GTQ)i?P{X3;L#fvv^4AnjX;%~N-no+@)>cbH zpV@N)TqlyV&+YTw+skz-=eFHN^G%igm=}=VPsZNkC!Omg{nG#|=IwJF`tN>TrL^6% z>m_VQzf$3dQ+?0@g)>kDS3p2S1qR}J~`Om+9ZhS`# z9@6GI^MtaWPc+A8K^^C7i_CF08EbfSR0cvGQ*o=ZZB3XSbpj+W|K7DGVi;UxF_|P6 zC*AA8qZ*6lR#bo_yhggcW;ctvlh_^qSxjILSbV}F29MF%5ngHN8TJqo*d1Ah58bwB zRkH)DUPP@|c%q>=4&iy4kL$2$V)Z3oB&p=e^x9 zGhgQM%lEQMmLQQLbz6e`Tm3zf6=w|$aQRm-jNT+0e5pHjVrc4+zFLjLR5DLu z45SUkY#HTCsPO$w9mbXbc4Z4~M9>hYQJGC90y;VnaKgSwwiF# z@f6i>0p17tbi1UNX&+C2r9+0JF1OdO=mt^;rwHuGs$G%}N=_w97GODawO@oUrda z3frhll1#mu8sXFWWqp9{VqEGrY`atmLspkRFe9C56j+@n}RdceU;kY?aeNioM54Cv~n@=qM&hP=K;;@vq9= zq~zD1=lphj`3%p6ptrQ&n!~2mJd?e`(|T%U0!@0Egww+%NIG*Z$MU|I2sYc)@ap`@ zAvdB7h;Gz2jcg^K*EbYSinS4Tq_K!s%K(wqd^UiS&~fNs@y*4t85mo>2o9piaR?^b zlu#{|M7{I5?Pka=&KN?U86epmCftzFH>q&7j=qycAJvak9``!`D57Q7YmZ-$$TKac zQl2ML*cfZ0QyQgL{l^&AbxlZv57$W_KhSB1=q$9)+| zZIjqjj@iohjO!dIV3O^Ly1ym73x8&*1 zeVHCXqFN1riLy*<@DF9BYgBUVi^d+=B3p_t(iW(lGl=1)h za*C@gbmshI4`KFQ5p|vlwe?`kJkG*!0yNnUDGo1izW4 zRZB3n#VP@2R*F@q(QRB?;ON!SKMToXnY(^acUG}+ zEdT@E9Ao+P`K#WM%`iyRuI-{~m{BQc6-;lcta2{|id$_f1s3~;PqGu#$!VEf?q1Jl zugp^0?ogk)igo5_7%OpUrlds_Du-UyEv(M|=Q=NYo~7A;HfSC*7tcyhNrYk`?L{h2 z99L%%s@$CgL@5mp;7;KC=1O~@Z+7e=me{>)^6rQ`NWrlnbsU*QP z12Gy;xesv(ctJy6B8qBNK3_aumwl|t`)Lkwp!Ax`P2O8qM#Jf#+T?cyxOAzcb{|D? z9V>iG>k^`f?Rks8s>KM6vr$A3sS~{Tu1hfxLR5dhdB6&Y?@Sb{Sj`-%W#xvax?hgF-#Ga~8(LC41813>9mGz+1qa1lPQQeDI zzfI!GR0ygL0q!&O4HK9Mz>i{_v`gKv&^eGe{o(aeZzjbWo;#j2PKb6ovix7z5QRXm zXm=YumB*ekS_jQRM;pcaA`yk@2ko*{1jUmStb>*<(Tde=`gSGPB$Iq{w&Z=N2mUEm z{+YKAbL)wGEt6Vj>tL{Y>6uQ{Ubk?>23c{Wn?CtlkDZlx+2sw!WZ0nJE}oekd*r~U zMU05~o}h*VHRpkbJbS+&%di5O&$tu47w#(t3-)XUYCM$rxalqSw{Lwqr#GAP>uOf{ zB3GizgX9&`f0%p@x*`nP$$L`fCnHa2a6`Q+L0QJ0YQ=H8&N(7_isb(3;+mkHAAS8@h!n1wlPHOSVeH51L2}IO2=B zl1rTf%EKZnegD?P1eHppvZL2#DYW-%3o*dx+04Y#R*!^n3GLEnvy<9|e}frYIy9!O zn%|phv+?gD4#_(23dbxcC$XNO1C7|%(@<|G&LCrF3m7Eom0Bi<;4m7>%Md^=yrE`x zKqSnMBO0k! zCRFM37R|x(A{Y8%L0DWc=N#Yj5Rsa*2Vgav65H=t5(G`7pA|tm`orpbui17>7|z)S_<-eTUrr9Xr~ml2Q5ze zHT)ll@V>o_)?(<`0XKUsOxzp|M$W^*w^`Fgi)K%~$(5 zDgR0NAm+2OPHU}|vDZnIZhjNRhxV_bnweD{mNdD1V=OCXc2=Y@RSzrlKiw1EzJDS1M8+4X;g_3a1^UYFY`w}ICzEVu_2QGO`f{9(2kbTf=S_R@$jli@%p zuzum{bEa3Vs2^yzHVppsLoX;kW#6o@Q)E_!@aEmv!oi+6pZ@0~riHe(AI1Na%2hH? z&H~9|h!VqwO8o>;_5OpBw5PSNqjt$txPJ&wabDi_D$Aamm68}qAL0AGB+pOqwl`-* z3D4a@4C7f)EVgm2njFoKb2%VpIsw$sz1fyyAW3Bsg#jSWbK2n-Vi(@zCS50e3*M1q z6FZ18s!*^Op)V-o*XGorPi5~(WY2<}Js@wT(?Y;6>L60~=+Y)y*PErNi|;gP3)RB} z<%?cyMAf>@IX0Ij8U<*Y$;Z;Jv+)5o#Yn;z%s$|oR^ilPpXY;GY1h1fvnsvJeg#&= zyX_@_fhSDEI5b5`42DnvOaP}~vxB4~L~xE2+6@=0mXP28oYDh>*xQ(<$h+VmvElWt z2Xx$Ink)NOS>Lzmv))ge>79V8H$z$5LdSk9aY(+|Emr>^wifeDyshm6-;oG&O4ZuW zrI2hBEk@M7s&&~@ETE>zg7djlL5f9CcyY+J(c>2%OTn(TF9`xP&tXP1e4hipSeW=L z{oag+&TeCuda0X+NtXP4JCRy}ouSEEh>B{^CbWCUgnnOiKs6Vo~3X?@axpoygQMM6+Gm@51y}g7-H@U%-63l z6&fLl?$bK?Vu86K@Wh76k1AWVQbBPj!k1mlH%+KYEyqDB;f|eOPZx|8De-8Ydx7mw z_YMhJ4$ec&Y{gI_x;!n*z-mib9>z`SKsjVsRiYsQkY`7!?_fBF_^Y+wwM4a?g5lrMe!q(BkX=yQdqf5n_84pILEK0HUT?<>zI&+-I?~ zs_$&qJI-dq>SqI4OA^!(tlNzIJa^a4gnruSxmrBJa5WtoUL@F9d?05-V>0+&n*O;F z!6-&Lf7fG?;P&&PF1-z1i`)hei&bZDy2KK6II3QqBez^%xBYu){l4h!(*+5xXC*|v z&*f0i!3prkdZW3eNg2B(Q(GmqY=v=|GTA}@jqyu%5pNPkdZgH3G?WY`Q1lUX`fMA{ z2@SM*1)Aj->*`8qPGFQC#;QdeQnS!VD26R=ulo~6eoWG7>Cf?Rt1uh8in$FlD7O<6 zd`jRd=a5SVkW1L?=}#t*CZ0;Jdcq5Q(qGcx5@xzI8NhQb*fZ^z*067Es8F^W30bt| zgqsF_G{3&&&!>S=giBW?n1%;z>6(c{kA3pdubc?5>qM5#Bymr}$=Y*oeZ7k~`~HhP zO5)Kh>1V!PfRJ_-u%>~|HY!a8sksB! zr_g@yy;d|VtA4dpo|$y&IYp45Tqk!xIBj zR!XS+2(va|->pNj>aIaI z;QFJ?r-Zm8L`J9B$IBPeyu$)LC8&v4(x-E5aS6nk7Uc67XdAhvdnU9DZ{{aT_aSNB zF(t|m=j?%J6yMA0n&X?K&6w3q*Iz_S$DS%DO<~Q;HkjR3ck3Koy~xnV9LN6*+Q;EZdCykTQ7co0YDQOB zcGOLB5JMzWN`254#W(a~c{sup{wr!UK~r~7c*4DunJp&(tZa8EZUz~=vxL&*n8ob( zc+rqXK7}b#)oH}@g-b~byId4Pl~y-ehrpe0!6%Fq*{i%;GaB5N5;Hy`?C@ewJYZS! z4YW7(Wg;dMUeT zFKg@F5sF>@C4L(M5X0Z9XHgbDRmBgJ<4?0_OOL)V%Gc(yOxXRR)$X5b^H=)9Gj-?d z?b=(@!;7W=ziERJts=7fd+ld}G%zk!KpDb-;`pl=jCjOh-!TKOzYC9wD$B-4Mo(LU zrB(l|#U0_{%`DDyQ3T^oUUKQTi-dS8-d2>#kk$900Ba#o2;1@d(ISuXT&bZ7ROyDt%+?Zl>E&yjMEF#F`r6?`{1@Ac<* z{SfWHHA>XX!d^f=hvv034V6Y)F*)OAyzj=vzk;zQgiK95*Ib2ba+4 z@y9TBPT7W0s?_}7gS`x=cCpXr@17eYUBlRuPHZK4fy;VD{##kyfiV61kBsL{@K-?F zD0l#g-XUSa$BJkKF*{&?yv1_VA}m{q*A_oBYn}DhR;Y*N#{dNgipmPHyVn`JSO)6| z>0cut=t`mXwfmSWKX-($J~-jNv|y_$kh2Jb6@armYY&a3NsIKWh~q~Zg+0`^s_%#p zGzbUo@Sd|SJ9@d+Khez*;1P@fnjyVYhzP5e^c3lDOfBg}E$LGnJ>vz}$U>1JjB4NN zYt@YUv7^C2$}_iC`#uwD6NLS{NR*x6u#i>7Pw5R?$LA`3Ny+?BJXX_1d7y>+HT|`l z$$bQ^GMTc4xMNuZvpDt5-Ov9O#uH`K7nZQ-l}SrE6^*qMQC3inl>Yo2=a_0ax()_haI}a=Yrpc~=CbXI>D8 z?dG$6$}`kS+;YzpDu?sS^l58$QigaUjlgurcLb$U5*8uv8P&uvqIFAsj1r{gV`Mk8 z&Ug$%>jtpG6=M1ePH@Abts17W^LHwqmNx+u7$>Kfeka~6oJV)-le`8hK!L4&L!>`l zwaZK&V7IT8(*zj%OF4^maYBORI2%LT`H)^XV!UB)`S7EhU3DQ&%Pse}PyPI3`UC#& z>rwUe1wU zq(O!jMj@0dZ(aMwxXLXx9T{@B`%cQ)La#l>r2*(2btY`phlRd{HPN zyZLT|hP=N10jCZ%>SO!|;D5Ko8k4%?MMfn=X?@?S(zr57WOTIIaSqb>a9OBx6GQN6 zc8Y(a(vwqg^~d;ral<8++Ino240$Pz72d_1N%z(x{<;KXZb0eHmJEkAmz08(*S|0& zKtxV(qcM&8X7WcRPtLCh>ilSi%v!ronD?+8Xki)@Co5m!~v&p;Y$OEQwx7o6Ezh6%Tt4d8+}Oc|1Yl;@oB}qGCb@;j(`7-TEz9g!TAy#H>oOVs(M+)gu@%zm8Da_eXNDHm;g z4O`YJ@jsC6&?VI@HC_U&CQe@N@tOXZ@=dj>aiGoheh>SSws6=st(z37g+m*$S+}Qj^{CA{0;9_$Rcd$f~KP6jC5CZZ^|AlI>lX7t1IXP4}f3dT5C? z{Pmp!&0CrPNr6D?Ejv;+(UVB7>n&BlV*G5zFQ$ZXk632WjpAt?xxE>RN`!5>hwY$I zL6KgQRKi6IEuod`q-QaRtI~P4%`wnx1dracQD7h5$g3i<{iC0?i_NcLYG#|u zDn|n6GUI?V^_73P;M_U6*t>f=O-A>;X=9_ZWS}tDA|^XNJ-H@yL_J7pr_%$c^G4&3 zy~us(-@UGRPYkc(Diw;Od7Z7=U`aKpjBU%_#}m-kj$uYO$gRe>NGmNei(Z0{SFTcw zqnCuuJ<*-O>=vt+1d)DW6B8wXU9|p+So#W!>QiUu>>mE^XBy=mc8=<0v9ex{%#dxx zBRUb}tzJT%>0*4tdEh^sJkwRhZ#H2Ru=*C_1bfTw`F{U{d+=X^f~a>C1evJH8Y zqR=vWwBAPwLhowyD~9j}4DyRF{-wSNSK~+R6eh_=v(ukfm#Y?2S%%l_X(C|DxJ)$Z zTSx7jR=wjzyb^AVur-z;N}9rfoCn(TSX&|I?ek0+*oWw!$4N1#ec z8 zvMh6NbTRDO7JE~BRZW9)T$4$H5v5#|Q$I(WF<7O0wY6*0Lce>tU+^oGtNS=-hG>?? zF15@NhY`SqSd|^hVK@cs)o}C9kmeEB_0Q}J^&+Vh$c!m!57Kh0i)@FoF%uYwJCy^X zdM28{r=nAqdhIU=_h3o;88ZqYA+I1((MWU@6Z&6CWUhoGrBB04&3mM%;4FrtdT1}x zUdHArrj^xG?8svmy<0vEpl2g7!46h(d0I6Phq_CFqOsP|vEGdODtVY2byVe!x)6x* zL0^Yx8fnn8gJ_V+h|EE9V5w-z1GiFA7u{c8k)%7DT7g+8kS=r91@_+KY;_6BHFaY9aF=UWpSf6x?X+hZ>vVU1o|#Tq+Hoa?V`;rXs5zleFt0mZ5b@ zT9`a=9(Aia5ci5Cr4wT`mk-$XBW}1NXjoBa+7Ony&b-36TbHoW|P$b}GMi-F*`VqJ&mG!_z#>0FW+J5fSU!Zt_N0l%#nm z%$P+tWoT_nLcZx!QJFzz1(!Kj4ii?kyjK0Ss36cd8BBIk&G?{oGYjo;buoHlNqHgX ziGvkxooy+okr=*3zjnp?jTB@N8<1HW%{O)ML?D_yfB$>lk@^NG?Oub|Rw`+YrIeU( zf{c=p_@WqQf{#Tdm2pt#Fbs>%u7L(1E+H5{H)!w1Nc(EFJ6pdZ`JZsOBbSOkL-&}{ z%RX4d1+#<*k)O zr5-qD;j?jbQBqn{EK2cG&H&30aBAiigz=i7lxZY7l~2nj37fu1qZGm+wn&61ZWtc) z6n4&Y@^90t`+BqBZ%p$PH*f$+8w~BpV6DI%!6(8?O|i6KJyJP!<={SdqhK`87-dS2M<4@0qn~ z{9aNIK2#Dq9e*J0QXFy>JkL&J3tr{o;|L63{;f~}rzNUL3{(%Ssu&WW!Flfgm`*2+ zK*qAi%M{7+#FSv&x*5o0fA(WkvCeQeVHa(vRjtX9NbyfyUnjJ47L6I~;%x#ipSb;Y zpg02n^(FhnF*g-RC`Mr2gu0QJ@uOpL2F5JMu@yTQ4?sDPUFsvJFQ9A{-anw>+!_fo ztF~%|))e&o7}YZ|@KYdzo(FKaqW!cI;qOvH-`Xh1%Sb#5qX+>k!_6HuO|gZG47V*A zCikVOt8(;3zgNZPE?7ZvcZUTTZDJ>lr0~hk;Ec6t;aob6_lqZV^gn&88^o*7zT|Qg zzY4dXw&Eihkuha1m&nhz>J3coOW8~@<7OteY8ExjzOyt!ncjTx_H*k9O@;MbF!?o{aOsE(u3E;VXIj4yF(BQq_s_Ja>bIXwW^ z_bg`o1w;C|Jkp^W!q}OTiwV@U?Q{a5T2pc=w;}&H>YN^h7dRQ%R^vo7Ax zO#-Ppw@^;Ak9BDjFtOJ<X_9LSi>2?8WbA8{I8ldx`6q+)dC zV?=zbz9!2{v8k0dktvS-by^QN?tJD~Up$q6zA#Ofciim4kLZenyq9lnDN@r|E0nu` zv4!*tr6Ck@+%8|}?^pF}w_AM=$NfU~F&BEEm1R>NFj<_9`@|tAy?inO2bFRq4@&ob-o4?2%w)B>QnEL4X)CMk^;QyuG|? zbk2mfC35&7^SLeJKmXf%O9LnQl{C>r;X$h<-$AU(Zu#ZtS(u0aqUV&)-IvY3MDJ421#)u9kql2K{bTj zZe)Bj&P&6-e}!6r<8r-d2a=k!d3t_hGfgISWvwLiXdR6IZ1Ccdx|+TQqmyBy^spHr z5Fh3dC?|BW$W~}@VYke6iLQ#uE&K5}Rt1PG!;Xm|5Xlb@p$DKcjoO4x#a(^J0r1_c zA9m|`&PW4bfu`=5d6;(rWMjs7{8kCAHx&}p*)c;Rk5dR;WjD8{1Mw3J+v-mr{*MCj z0{OxmL2RZOXWDW1p_~)}OqI&@dSU&O4ENd;J6kX1UJ;S4asCHqtM`ay&+2#FQPPY`WvO>4s>hM~v4j@W3 z6SvTwuc`$c^(0c$3RqSGo^j!cZPe?s(M`ohA?IO{-h$J3fkqT!XjuQ0*Y@$x>K_fc zHn#QXw2vb~=sh7?o#up>`U?c;_du?1wGKmh+#h~AbdBt`)An`rhI;o5_3s;zgePBe z#j4lz`3%huEhM@nCK~X%mm8%17g;dfKI{2?1;{N?8m}-#P*5$nw7=i<7%OzrBX)uo z=x*@9!xpHq;=%ST%zp7K!+a@qIx~K#5$^fo2M4&t|D!rF!$jKF(W`u%5P6=m59#(X0@9b@pk{K>J}Y%E@qsj7Fo7C*_u$9k`QACw(J$F78VfU zxWRZTIS0^i-=J!BLSUE3faE8l??hz+FcTB3mBWbDfP765BGO8VB@S^KM3)M3|1>t4 zZ^kp*K0?Q$;$Znpksa!4te~v2J+n5Wk4DTt+0VrYqu;cP@jY9)iI4Yq_e)O^-jifc zxs>_f#}e9y*WR;#aB$OJ^{g=er)#kl)h(*xi~}B2v%1|8!rAe}`_Axu!gTf z|Bs}rV2G;gy3!#rq;w87bcl4v49(C;he%03bm!2W0@4kV(%qecf^>IE!#jT8Pq^ov zz0caQ)^gsO)`(BV*G+Zi<{iHLr4=;1P81(tP#_P@q${{?^dO(qL|)AI6u~DbC%@S{ zk3i2^fvd%>{3!jv%1*m+bbfInlA5ZDwG1Pal{T|)`O?Qw+-ISzwV-LPEwan-@m}AE zpfgtE#OyrgPMz^(?QoG+#Q5Ha`(j}{M8kB^VQ6@i_HQ3ety5iOg^mt4F?W6QK8@5% z$L2VrU2wJ2nn97ShDF>#?dFxy5FK!C&eY~kG+mKaPa)n6TMl~%F409dOsLYZu>WhL z;_oVhi@i9Vb)&W?)4!xJ_%A6GFa0>FC0jswN|1XqNu$a@Ga>=NQs;}sAF)aCsjGTa zN|pP@#+Nh5t`(CGvQ;go2U6h&QVi)aQIl@>l5_K%ljO87GLp^kC*#U!u~T7y$bu!6 zH1bLV{U{TyX?7mzC|)P+c}tVxNkIxSOPuC0dh!Z=8Zr=p_6|=`Iu1%FQ7{%H+^cl^ z!cnjDOabk#W4M#5bFciE#eyF4HH9xO7Y39ALNLw}E~qF%-p15<6fTgPH`~fOb{2Ji zcS=hf&PeKj;a$=&H$?;z<@dd}gAbOI_PIlOwvs4w`0+l(m)Ez$Ey{B84?6Uw_s4!F z)cz##K0BLWTj=c|@$jv@j;tSsGc(`7XoL=ZBu7$KwV!HIoebszFPl8^%p zvoDyBCWQ#efntgp5GrEYT1^z)g}h86Znw|+nyMt*zA|l}8yNUFW5JJHTHSXhDKep( z`4@MUCAXwnib66xzGf-Rux;CmR`k8t((nO_S$?z+*S327W@DXX2`}+=s4%zloc?{U zlq8-ydk@MXv$Oc32Z>2F&G-;HAfg*>O73wVt)z^tB|w8cM{2}3J?Gq$xya{dgJyz9 z{iG#7#{u>Sk57jo2Z?%6Vo$6X?yKHp*mh?Lt#c!Css7icfn0;^{0Tma*0KLXZa z$~Mq?=qW(Js9>#iw(485{PjZ)`@hGLl&b6Y^n`)G{Sfm_n+GGtx;Po9GLIYG0D!Cm z7t08iDb!Qj(cTzO$jm%9X@g3Hn{&2g75#h4%;G^-A`CMlFHQPeQ+$JmwOu(^f*Br& z2(%;lKB$$DN5hwGPj$OSKf6Z!x&s3ED%~ZzE6>~?kodPmF5Ud->KHzF&MP?8~ETtff zg~;Li;*V5t2Y;$jun!_k9WG}NBC z`c!;*R8#gIwPs>SBCSC|g03Z3#X)lUutm9v#VF%Pi}lu2^X{|zfLf@yIs2gPfB4;g zlufaaI*A*MvY0v?e01@zX4J6vVTk?SP%RoGG!dM#20~exO97{o!?4-+>e_=N6l8}e zgy}akmj;>Ts3~c7-$U2Mq4w)yUs}PJJuhTH?U@@wWX|GcOa;=l^2VN6so9&D-gS;~EK?-l*PTBDd8Gs3K%qTemh!|=|LWFlZCXxNjxkSS9;0%V8 z4C~$NmckgLERE>EEIro>M`kl z%AN*ac92zyCJps!R3C^Pzg(>1;*SnCp{8C$5D&)lZg81n$`<}7AJb%*T}r2s zsLMRd3B;jMcAC1luQ*JBa{$NP6i@>FepKFZ1HwOlM|JDtYYWt;WfeiyV02>3Q%ESJ z2P-_`B#D6alzT{$JUkdY(K%$(rl zDCB|w?CE7SN{^nh{gV1YVsy^KgMM^zsf0pflv@=ue7mtcrR3^7<1;Py>6~r{RWc#! zAQ&RmP3tbwG@l&S{7SDu@$%cofumaY&Wf(6vT4W#bhFksP(}j_M0%4cxUddRhwiHQz5w52p;V9}&3Q5O|<9R~b0 zqKS>(RnUbR?w4+T0_8rkOm+I@^vV{C}xWx1zM_~aVn)OT`QWcRpAFdTj3y~0+sdnI6 z6-$oG&opV1{Zj03lmmwapZ)~| zV|r^xYR@|J?6_ulwS)?_%#AZmb1;_2dj%G8*#J6EfegsbQ|nTOrsMuE2#nc971f7c zlZV9O6D{vAdJCCv(Y4n@U9fe^5GIH;J{?AvgqB%3kA|Yj;_A+f$n1NDyBgo!VYYZh zK<5CI~qSUik>(&3V2^DhRYPz#mD&wGFnF(ok1V0xMT72Z`;51*=}Ef04JEQIF;AZ5`Vh zgJx>if4HhHXyiR`YR`Vb!uT(Ixx{0_q0(D%d_jCVH*m`K-l&*z0YnYYlF9#g&1L2~ z-lE_jhzLXEJcuA&x~N!~F9PikPLL7hdr>D)aHA%-HkXlQ-bcWjrs==WHBaO;(h#9Y zebdn65EkyKp~i@rtZ&YzT>5{jhKNM(CBhC5UgFdH8_ig(^NEtX6VqdKgIO^l4XHs~ zS9`xl+%QwHS&+j5o0-M1p1{7-AN58OQf&)b^9Ksl4^-I2Esb%IoR&*M_IebwQ`a;k6n zEFyEcPFbTo~$NrQxy5yrH-ER?yV=_ zHjb3ZY*hJuMD}H-*4v(iI}bCDZ_uiEDIBoh9XG>9nf-updy_Uj|p*TAXUIkh{7h#3WqO#(x>^xv#`r4S|#Z_JctO5 zWxZE{2fs`M027}$5jUY+yOfydjbvyKxcX%wb*&ok`R*N#MX3A(y^RoX9#fIdO#78! zvH2aoNJ;WyqDQ($oNn;;)Tht$EHB97o>e>ekaiDo!d!wBRD6Dx12TLx2W@=~Lp4FR zy!U=(NH zB@R~e#CCXFZ#lo@aO2T2Co)+f-L6P1(KWzh-kft`OXG;q1fCS&^OKTD)w8oR5)$bI zu9ic(!?hu5>y5ecSM``~k0%kub=>@T>tt|NCGjDCAw$k)g#w)(Uf(^_Vxa_}KY=-c zPidE+nox?cJi15)5cdDjzP6NVGEv?1cDa$J^Zot&>znd>;Y;gEL*Q)r;P zm0h>$`Xnr5^8DJ3IOXZ8~V{f!2pB$Pe4GZebJ zz`j^k*1ndAi3Fv5QrfIXrCqs|l2RywU7<(ib`W0%%}{Oz-qdBh z!;sn@Ns~Vl+08c1P<{I4O^?Ly%^8tWGA)IPB|Q?34VuP9{;EbqIJ8iY zNRKs*J_UToDxj~~F2K&#@einAd2D(tPW-Vou&zV9ED|AscugZx@AVjyW zzWROt)Gk=0;T$!EX7Ae$`AhpNt5UIXu%udxL=kGH+YC^Eq}$$r1>K8|b^|G1eU<~F zwtd*B290IVRmWoMpoeg67ix2T(~S=*F9x=l$mp{Hx18fDvz=T1NHIQeM z(Hvz&7sQd36y1i2@)E9YZz|LVB-V?W%mWIuFL@DrgF`P*XqYN{yA#6;A0*9oV!G_` zO6^0`>S6`k)k~8qQbL(6EVR1P38=(F2)OVwOl*i1urbKuxSQ7poUv^KvFwQTlLA(& zCrV^*op+mwV`M9IEiGXeF9LXgl{mbidgp4=ThlGUSbH{C8oS(r7dWD7>ND$bGH#~) z`D6Z@a_6;i`_-8bGyJOjbAwmKUHk8~JX?e$J~QfAd98o4#Mh~B#afpfhN=PLN}%FZ z(VN%|;DOkBO=Bw8+wO7Vx%A*i8oP00@6{1xZXMm(E%KHw#1hoJm~>Q&=3mIhRC%Avm8;^_rEj(R zY6+i(pKh|(-r7}xlLkBs&S2U8vN1!0neO+0;Cm>t|H@69?}A2de*!H_>69$7in)}w zZj$I|Wl8h&1DUq5R>=HtLJw&f7Vr#3ia?BFxjTepsxs(DGfHz@!}B(zMkrlx>ku?P ziRqa~sTl!>Q85yY<3uiiR0os=*Bqe|g1ay-!JmdQNL)-vjOCuI=Z2-loRa7Il73>p zZfQ#aVsELrad`s#A$(w!uM*olk`3b>%yv(*JAE4Ln&F3=*StUVFv@`knk*@F^c5e0 z6WN-S$lIoFT!vwB3Q7z=@ru>M{8zmU+^^Mo1oaSYxzg&M;Odo%AX8yj(E;Swm<<7l zuFT{3&UbhFW9}E>{L25*G_`*|N}sNJ?iGA-S@p2_S6qOa9}{lj%&l#s6#V|G_-Krv zZd&`9(TpjlQn?K=taC4$%>kN8e0_|3$G@K)wO)P?UP=iQBx(F&Z8Cl=l>?dNQ9N28 z4wws1B)ue;GrfkSzH$fTOwOJP)ceYtLo{stvXkcvRQz1N)3KuW0x)aEzLbL4o* z4)#9MK)gybE0x5l0bl?ZvLy z7J|z;j#q(_OYLKd94O1t8V420CR65D2UDZ9Dahe9dO5EW{1jupbs@w@8&kT*ck~8n zx9l16@v|%`779BuQk99c$UM;KQlSm~h1(KWm3Vo_1?}PPj8gEO2$%{wzzjm^EKfu~ zJn(oz&(I%P=U^46lOM^<OJ2^Z!cd4&eTVRDv`Qe;r$ zCD7&X_3IYo?AjH#x6eFsRL>d!L%%0|CD~tmzt3EVY=ekhBtX4$QzX4F7=Jag0!nxj zSPD1!I8=i~+@9DNcA^6qF~nu16hVB&_RGiSW6+3>!Tgl)J-&6&iq2&wfS#a>5*tmZ>gZ_8=Y) zzO(XA80P)Its^1>znN2|M7!-Jd*w}2Qkq^)+A;-X_3xTgjJH{dAV?__>Gx-hpBT#8 zyrr7&^A(n%Kh$HkLahn3`0{?EL*FLa5{;P%ZvEyN&heqtN9xF>z7T~9ytvS+2JKum zLq8|{G%ccOLWVTULab5EIzJQ>MtGgB-BPhB;xuVsjMQCaEZu$DAN{Ski1^SS0Z!RAg zh+4=J6G@65=s2_4Zl3(eogezZHl*l`@Lm>mp2q0)_uh=~*;Bzk?lL00tt{sLv`YO_ z+ot&4ld$DK#h?v8Y?!H@rlfX!E!O!L=6Qkynv95=jST=KxNP(n7f6Q%32iUoF2b8k z6tA06NG88br_hg0`W#S9%;!g}TrzSXLXV`*?oKpf8^z>^DwVL5pL!|xPMv);N|T6D zI0%`RHH1M|7GuBHc9B;v&mC^}VIbVqWu=coZh@(&@Cs|6U1-#(U}>mk=VqvJDkGg+ zK!5r8465V;T8=V|6f)YfP?5yYC^^W!zkP5T_h;fu!Ds335odapdd0wqNc-(|Sd%LE zQOlXIYwK@~!_CBZ0 z$QMJ-N)^#eLI`->)aUf@t+Q@bu@qi(kN0C@*!?ne9{P|8nK$WJk=75}r4H=T>BKlnGO&qTIH+2uD9&Su?I=;V;*<4Q-BZciTI8qFN#- z>LPfoOSG!QDJ9FzaCdN%-QLeEr(ySN$uZEYnrMNJ*(2Y6TbaFVv#c(ymBNrGVI5cy ziyfT%XVf%BDKz*HOuUL<9)py#f8OxlVIKk_e(ZA@7tHIf>X~SJw690f5i@ZsQJhsB z_@EHtZ$8bLxc7sVfdQn5h26?FqVA=EybV_A#1=c?ZItekO8%CqqY^Js#L}3E#PSI& z$%SBT9^Wtb9>q;NM1hO2or;M$hhU}N?W_98;DH7@==>{vzW2w ziXf&(DQ&j1;4Eq<2T$x^eA(BZ()I?If#j zb1wJ2-`J)2Q<%lxq)V17GZr6*f}EDvPG;>~qg;HhY#ty=&G7HkO4n;TORfu8&&yh!Z(TKN7 zK!`?5aCh_ySdjSxq4y4=7C{(`SxDAQwNX(bbQ})w0yB`{OB>x04%-T@Uv9Qy(V%_M@gd0?(eDsMDtl|V>ZW~me%`_U0JVFv* z@**TTbpn!U()Ox&d{jsUGVte#@vqn3wb55jZS;O6Rixa+XR1%3s(=t0>D2zb-s(;r z7Nan%CzWV6bj-C}jT{k1F|^e#f)Q>eLgK~4++&KCfHH`4scyd|IuTNfO;Y{^x001z zLKAdX6>uE)_dwH9_oR0%MwX*AmZGNMt0$#{i!9?j$U|7kg}lrnWILLg8m8YO#?p0U zN7vMuTJ}90D2;E3ov%k!aM2myI^Bo2b#kI4hWz@+(8xoXEM|%njvR7XIsBK5;!65F z4x_s!^F2m|a;|?czY^e{W%@;Ktx4)?pXWdrt3@gF5QP;22C09Sahs+Vnph2>k5^oT zjIDnH2cwt3c$5>7ODrBF8D;AB2p3|c#45$|bm;PHaqkrqNwHVE%duhWDSPk-c9i?; zcQuxK%i%zv)u;?A65-Bx8-L z?*`RL(i~VXVKhx@^GYJ3Ry$Ww+M8-94aM4yzN|}U^56dweWI@hA{lia9?c~vfC~}I zIJnpgcc?0IW~qBH zfzy4k=Q}^_LY{WP#cqP^u2Vm3*Fc%y7AmN7ff9RzA5fL$6S14{tlmZc&h2|9xU+)1 z39Azi0wxHYJ>Vg|r+kk^h@kp)^H>-r3c^QnUG5UY8tpwJrZLF$q^ z6Q>KHjZ2PSS@8$Fsd@yqo`P^k$vwN$bGmP~MUpqi)<+Y>@T*CY)dAC1E#WOVKKu;W zbT{?t7?&<|VW~&DRPR6(KE^A-gvc@i0;*Ea=+1t!B0)+InbglC;?Jr^@XnOWziHiG z*qP5QTrS;dD`9Mp{>7xGJH*E;MBGPQWi$lc7dL~<^s|y%ACeK7n6Do)ddM)rHZa=7 z$?iD(4Q1T@1>e-HHqir0v)qD|SYa`)OljVb*O+!o;nV7&~QQMsrEdBBw z5k{Sgf}aSlGAWOEe*nTR@>HFPz^I+K@C|O@eD?kJqyj-XDxH?z`unSjftzU~0+~=M zv@x6Hb0sx)bOze9fs5yk9WO=@n1>-P7iG#}$F2BhGe;g~4>==Q*BbPRSjm6?AP1aC zZW+w=g;P?=`4kh(1ws5N9f$fuF+U%wXe@ZkC`!}$j3d?SvQ`rwEVp$f{qF!!lTMuO z2tzL2eIvEYuscHv3tcG~oYX{$tbgf*9LoE~)N7UFHHz)nCcaUY4Sr=|qo@7Rci%ku zO}@6PzcK!sTHrK|1{6IQTvd%O^(}LSg)mi0NE<|_+kg0$NOcW}3(^5a4GMzXb=Qo` z7@bhc@xEE7?%b*reyx=8lDay00#j~_LK4-q!ZnSZXUp|0-{apdnb`e0(&+m12HQyY z7LR=uJx5h_(J=rtNOdURG6lsb2gfG};6cfoo4zLGmmG<3Q_7~G8%-$?O(aJ%;^o|2 zu`ITKET_K^CL4{$d5e#j>mQNvJBDy?^N>Y;%{mHvz!I5%=_|KF<_`R~<4S-U?)`{Aq<&c405vNQ&fOQuY9 zg*5y$k~Ft4X>fwdly9wC55*r#B0#%yOORoBETamAs5E-E)uAEGalz(Xm_*K({esw- zk%WRB$YvqGowHxe3JDJHyNQCpOM;)-gLv*Sg`)I3S)s$Kh7vn`?*Y&jnvBg3K~AyZ zzJ=8iG@l7Hr}(BTAyj?8Hmly?swC35O@pC`ZOr0PqtlbYDa^A%9XYxky53}IpN5%_ zAzl7J82q*cPTzIx$!@>bYiR~&F#j|u^*7a7 zdKvz7uMPdK*lhj+BAf%xbN+o5NU1OHzXejc87V!BsN@nv5Py8oN!|4o0!BzMaJ-jg zVag559fAd0G-8S<3VxJzuIxA`yI4DTpxA})cjXfNt25pi^q()L4U6+igw^5UH|KQz zPyowBjs}RQ+C4D%#9BFEg{PQQ7wd6g(AyM$uFIQyC=gcG#qRU1P)~ZT7{acjBi-iR zwb4uJa;L0vsUl6Uq>9lZLtkkZr=k!+=Tv@!0N&z*P|{70!5nC!uf`2bzx4R37@>9u z4SU9ktB%I87e>pPY89iSN8u?^^FkQ8{rU zaOi6TZ%RJ+hYWV(r`Nc*dH&MyOn~t(6TGbySp9>3Xd0SLa+LJ+U@q_+cj1NWuP4f} ztzn9afE^@(U%<~JL5w034WwI+1FZ|w-w9y2>X`vpTrH; z;%9u86rnrzV6mD6nE>megdt6s_eMPrVc!_tE=pQP-A>~ek#)r5JNF*r+In*tpn08P z0xG`P>N{g|=vR6!RhE8EG5nYa6?pv^ppHCtT18{vL@}l#z*_{soM2FhAI44ka`^8^+_txHgEpPkyOcRx#rzw?afH7vg z^Ho96U5xU`Jwl8iYB@JsnTb5i6q_yXIq>yz3tn@Iq5T7nV;2rxk3D}Fmc)dLlr54? zwqBPc`G8+ofHIeHHqS`o@v8ZfQ!<6phqYz8$=~pX)OA?rF>3{0&f$!FOGgkH3k^Xhg53CQ4fLX`Q3^ z%$73HO!&JM%ryMn6USp|d?mfZ%v4|&KM$A82pO=34EO31H>D+}wD<4Znxk_yE#+Qb z!WZdJM_$)f+pd?~EUnFKvlT%L{3i8isK@H^*ksHFb@gTCc5S0y+JeGNNpcF2J=Oye zpuAH+Y*2kEMLdOTIV&qc;v1x2`#kaUoZy?4fX@x&DO&fDR5#thSmFVwt#%P7@{9KbHVX!EUxfFdQTu zT<4JWX4AhIVSlD($e#7(6k42u*dmyb3nxF1TnVcRf=$lNVpPFiQu#XRDp~VOvvLV+ zvXq#VWuJ6G_bX;M&VXZ?w2%9pW6giO3a6fHD-m>yMRq_1njngneDVr>9!vb~= zb+c_QCizF{mX5nVvQxNVmG~Xlx$_P;W#4#Lb!FrhX05%cWXtNGMti87p-T+u{s#N| zyjvB$oxcJsq7Kv$fF@on_3hV#94_1=-^}>p z8#8I**|lyR*7me zo&FR@L;cWeb({7zvnQ2jKduhm)zSb^u<(&P|3Ia#jrILQ=L;)8ULj9QZzawI%noi= zgSDXks)Fe6`$XBVx~F-tWg~Z{SR#PX^z`Q=?st-0CY03uqqt+M5lO>! z;XyA}#*~=C7w?gqH?sMIvN{Sfb!24&)Cy{*WzngXOq;n!x7$vX8bmjT{miptL_hTo z_N+SM9dGX&gWhPdDY>jsbd6r?{{MZDak?2gqhv?DvnfSP?UsV-S}+I9Ncen&KZEY8SK&jO*6yQvEuHi zPzY-{wW_E@=5nl?hMaf%zsjDA&WXzpctf^d&TdafQItA_%g$X07ouuUszqhIe-G8P zC-!^jc9EvUWSN>cdS;tSguB#I4xHJ#Jw@AlAmJq?jBO4NJLj|ArvhbL9k<()4>coY zDJW_Q5)~w$bFA(%B@=C?t0}coJBO|Oa1(&Y>Sc{7f2WL{@q(j0ozsjHlV}L_Ej~Mx zerYD7=dq~o_l;&Xr~=X~-9;WN>j@B|Ths(ui-=MYlQK`j z+f0J=YRLCV(U#RahHc>vmV>T?rq*Q9|5*+V3;YLRA=~8jE2Qf)ao`G2WC7N4?k)(F z%91Bdr3yRM=4A(34}%TX<-*;;APe5RziW_Hi&E^8vChY_M%C1Um&Ki1Uw`%gPE{F8 zaNTiF#k;zE#PwghUs0!i+>TZJU9?=Dc`&znWMyn)9Wy@pe1G>N)&A%xrRce$?`-I> z(~7(+7{@~P%F6D{U)46G^z}u^DN6hg8<9LdYvZ^!kp1_Bxmmzp@xREor7D}PqthP- zZv%$<$HG42lm1Z~4|gejzp%P(VL9r}#l$6P`#vsiI8n}G8KV6Qn$r@(QFJpm`G+&< z@0vQ(*|{Ci7n`!Wi9`8iry@o0Jy} z!N}WPJ1uq$!e6V!Efu@E>0zn2;meFmt!V7#68=F))YM@(m$b9@$`_SATy!t<@^E=D zV&B1;OI?!tU-#N$r(t8bGvBgL<=nMbC;U3Gu9VlGp&yzD7uI4DcjOb zU9f!f3-1h`^*UZv7wEU{03JU%WKBVG^poFmnqcL`4Up_34zZhfAzBK&E0$4yQTf3P zFXnTjDE3-Uu31;fFNeyUnD!AmBNdT`7CEoD-&s5L8gJLF2a{#KP9AsdHD<`3e5UChbPA-$s zb0kp9LQNQzXJgISp2Fg)>G8#TY`PwR)xtz=B;+Zzy$Q=ph;LjUR=+wb(I-Os!g~>3;ayM3*BNc?$cxO{L`#@u@?B? zz9NWs>J^Sm+V*#C?dkRuLsEoRZG$FlC6M06SxJ!Vjbs3V zXzF)|!|-=D%o)qeS*(AYu_~{XJF9SUoYhiSbfl*en0D(0(cvl(Le~4a1#Ir`zDp;} z7zA57_B8wgwxYT&8G~T3pflPoG%Pze2S;ZL@sVe(0bK*KvvKN^Ro~y}gT&&Gf6JSP zAI_?7u0E`N-?CKesC{yNb-d@7hg=7V4BD+s$v4uADYl}6%)vb1pHLzKCU!EmN#iX& zmL&j?VnMu-BmH2WHX}X6yHZRMAY+AlI5m;^Nu=e_w4RsxXronKw(+I^CscPV#==kw zR^(3^fi~<*hh(jj@!s%^t|}VbFJ8wZ&?j zkvwGs`TBIrdi*v3v|7SVUW2O9JQPa5%qiWyswgXUo>cx9m=D(<-`|iyrj5N1H#v7S zx87^@@7`@*=T}ZGMAB$QhDcg0zQ`rv{_%A*_Lo07TMwN@j?f3hXo^TNU{2D~)e3Ny zQ{McLks_a(SmA|ae$3AWZ~%PB#xZvElkGAiD+kpRzBGJUE<0QB5G&Hbd#~dnC5fkd z%n2*ssn1^I8!@MUv{K$VF(^sORQdY#+x3FE!&~^JCK1-B(-SKczraZc_S#2$Q}@w9*qq9jXYmwdPXNtL3qX$J3D?xxxu9k!${av8jRQ z9`}?h`e8Q(l0=E8Way2bpHof>r&h`H92cd%KdMUO)Zr;rHv0dZjD z_*S0AtS}8}$Itj{grL6d4`rgIbM4h$g{NT;@q3tRcI?mg&L0#l7?iIfnmbCc;(ob5b9 z7^)6eB(O9oIU**#y9n_xG@=SX?iQM;OY{F_F?d=-1!x4%9O7l=nP!(QUC8Ms^$NpZcq{`qOFl!sztGXD_2PWI?fO4Zmw7%{M2ue&cK$6mdunrl zhd;u19JC9IZ@wplF>cB-Jd;vkE%WJgS*J)EWp&#|s4GMXo&G>Nl8<<90EK3bA0rR= zwk^l}7SgQG^qJ0Sk)tFLt&|OXDdZCf-1H15{B#$QQ_tH&ZW99Rp%i z^2Lx=KCH1c)u8HFM;E24YZqKUjZ!e#wpoR4{~OW3+A~s4%o{eKAya{3|Jsy_0J1sb zdS@YA<0FueC0%(VN#b!yo4S%~z-@(fgpE$IxWyJIsKx|hooW6SIap$s-uL*gN35C@W4z$yuq)H{pbFn1=Vl?-KGQj(8fuXCbyvx!KLC>1xw3RV zndg?9k4~WoK@k%vkbrBJ6~pY|&do5H*&8`#?sjQ&3Uh+PF{avMQ}=-WZ9HKqr18^o%f06i8$S z_jrJQDgvvu;}$mozS_R-$Gv2GO965TT+0Bt{{~*};(#Z!=I@=;zKU_*^W{ zzM2g^KG<5I8G~n}!@*-kRA}-1NIK>CDJ4#bRsjBJG3wj}kp&UBWQ;aS2-I!ozP{Y-6*} z_?mrNEXB~;!7gF^iAl3v1#4KyUygTXe?XJ}03z6C zcOoU5we%f3H`-@VET|)bMfz!|-UcgIt9_0~>q;HC$Z&!dmaM?}3~I0MznL4yV=Y8C z*C`P0W_{zvLeY6$7nO z_|^-XKnVql17Nf0yWJs8;e98pUOSH5Mvr*CA*&_`wXN3bpIKE?(R>;L;uRh5^2U6? zL7_$B5gg;c0aEnu4bm-Az5BIaXqIhi-3%G8fIFgFbBgf^-tRx|nTFLsQF_f{Nec%s z9X;B&$dUBtJADtOBVf#9 zqu;9wcJP>BNmG`wE|OHJhh1f0k!^}n{e3q2$V7_6V@=14pIFvZ`d3W`zM*Bd)S@Em z0lLq0?>TUb_OsK&;^UVkSq^&gcBd?v@yiClHz+zLj2=b}IXi0fJeK5v2D{*0?Xsw3{dO5SpUz(M@q_xBl z98{DZnV#uT%mjv+qItYAR@+Iji4xa|(7gQw4mvtX2Qx-8AkwFNzhF`qb;~Mx5-qie zeI1OW+*{2rc_NbvcxR`;B&!eBOrbMf;N?}CJ(>n#>E73(DrVilhtVyIM z(%zWc6zL)H>;UBle%P{r@&@nRGZVJ&_J#?)1*V4n#oxbw*K1~1lU4D~r8p_I9N@;? zs?QbU#?!FEfw&h^hv&x2^CBJTW3q0gqv%8WrEg=^>Z{~`bf4%9#h@qVG|a0}nvEki zJi|#-VdlsYGc-;TqRup*UzJaE9M?X1pS=6P+2s{iOQuTU@2TOy>3_DSpOZZYY{Z7g zc0ZYQ?MUh9uh&jY#aY${8q&Rh!uJu5$kYYBw5P2sw~=jYPYY6%>JtNN`XXVH_dXYh zX5|a1s0jFFH{miM73p?mNd8PRC5u#NP8&X(rnuRG*B{SDouE4knhhE;>fHm6ATvYm zbpBqhw0w0*#~m_QStbHE^M{BaMEt{Wf)E&)gJ59bwZ}WX!lo+599)tugi}vyjd0YsiX2btY5Jd6Xtba8Rlan_J+MN zQ5-vxtVg{uh|Ich-6IR>=yJ5K9I0}Dkak|R+B0zBnHTyQG<&R&YebF_E@sDmX}@?$ z0QB$u>p3koKNRFrat>ZIFJK*jd?sS+Rg#c(8Z zqD>534n6crQLg@%dW7Bxx~bB{Q^=ThE|bt!Y@h5VB#a&5snxeIc)IgE%?%165sRZM z<3b*>=iJD-ev9oZ6X}y&t}c^rmQu*VWGH&!U36_wf(Z`_O|+%22i6`Xo4TzLxNqG@rled0g2NqC(v7#*tr~S8Yub2@mY=! zNs*!EofGASi<+zn>MzBVxV)n9z1wG95;i|H$;#dLZdV1Nb}I(FED@Xp{ue`SI%(p& z*-vel&4Yi2#_kO^mH~%pg!0g@PnCU$7Ie6c zbW#a*Lh&e1v-^p9*zF(hG(9zuZRz?&w=4CIaq87Ny`Fk*j~Db#lriX-i7?49Q@|}c zfXvSLPR`W&Gk;r_Q#R3cSA50U);Xc?zB#VVVJ*=!`+bDA<<~^kZRCuUE=EU12H_Ce zcHj0h75PfB{@ca}D#W>vLp<5KkM(K0U7ok^wJ8~>a5V)YhI$vMpASDXkcTm1Bx5E) z-znItsZkcwhPug?qMwL1yyKmqA#lMtWk5rd?0%qPETF@;pa)*6X#g=|lM5-38L#a% zzR}Wo%Kdg!?rO(C|0Cwn%o=L3#nO}8Rqep;|Cp94KK=}T{SHi*<^S&m#Kje^zhFog z>3{N2fP|Hk;HZ&)8w1$ona~!ViQwV=Sr}3hD8p7T0ORF|cq0_8vW;#4T0kH42IB3^gGC0)K6fk z!eZ7Yk-%Rr8(9a`V$4g#>;KY_(?>(L{&(1Wt}N$-s#$+Zr1<`nec0Qu%YO%yWA#gw z5sv$YP~p+7J8jJ^Qg4qgIi*vkPo`VlZk1x9Mpj*zNy?;4?3-Svbu3$08}{Za>hk*M zsjv*gRvh<{Z>sATwB~J$v6!+e4&x-j5&e%hL(>ULVF6+wa#c>hzxJXTO!kEZ6qvIZ z1%>2T$u-xhgQ#@cXIA*>{{yWFwXGV&_J5lRqV=QM z3)ayJB5Ntd(5>20ll7Zs0IGtzih~mKmDPadwr7e(^Mn;*v;x*5hw|i}FYS9kM(8h$ zoZn=7hr3itTt}axjTwGX z=%>+5gjtvFV5R&R8sU0KD=M$XVE##LB?!|7lFeEc#D~IESE0`avq~@94RY7bc3mq_ zs1aE?iw&irLW1WZ`mG+ep;9(*_59(PRiI=GHNot4JS81~Ev+hiOlE-QtN-6c==;KI zt4>W{!2f9a2KLO_p6jV?+qP}nwyxT?ZQHgzwQakd+EY%aoxa!o9N+f~c8*{rIkR?> z>F_NVlTfrSU%eI&tHM?E07eyTU(DS#am(i}(`UdC4Kp>~8j|j2m3Wy{Rj%?Ve$vb+*M=+f5)CXIsjwNb!mHBNb3t8s34i)7 z)WefNi+s%WBf*cF`Ke=K=2$Db$2TNQNpiET2dI2=%}=&Ql{Uf;0N(X&@&BM2LyRIP zd5zw2#la3LZH~^P5$}=(q-~JTcK@bGF7+zYk*C;_s|`H`>{_G@&AiyXM!H{#lg-}~ zG*2cUh$IH$nw96h>wCXHENhTNNr3}{QQC_cc=V^xzdD_DNh5wL@?q+P==dqiayfv~ zqzdFG4Xp0d2y0N}%vNV@m@{FV*V>sfx8~8di1Bu1TJP6~cOZsPi$kGLy)8640$Bs7 zS@aDhA5XXU9}SWMojRJ`!pt=Z*P@WkqV(c`b-Aav zliVMsgjz?I3n;5Rwve3w2vub^-s-JC0pZgX&G87ZmsD(Qa1u^AHLcJel4k?=^ozD8l;~i?m1i%|=`sg-fgiK{q~qcy0?iy&&7=)fjc`QJWpX^B*aqOAx>NP{yrmOKD)!q5`jeNOAwf1dIQqEPpm0vsE_igR~4`zrL zIMBljagYjj`m%zHC{>4Si{$Gjv*0uB{WUF^FX0AUsqoTu@oJ(MO30<9scA%nn_N`J zwWTvVXOdf!2WFxfc%0?hC0vqgk_K(JoM*1bg_GE%4*eKo&WS?0yv@h2Szoa|&?&BRWj+6guQUUbi7kWqY(4;Kg?Fbe{B^>Ag%cSt* zmd35D?389gSO@gNj-#$bhwQ3mlyT0GlQRH%ENpc8vWY5yM^3)+-m-IMpRD3g@o`Th z_&lxRnDvdI;loF;xq-E}@E3(nZSr~)Q@dMkcZ$}k=1Eo(fiUqDFPl!Tne3Xe|J=Ev zTsm95Y{?^X$1FxYo-9|X1jZ$B^)@~k*irtGZf2j8WPSl$7>Y}wM%1913IYNOxA?5dH8~R zgoP$*Yqjv?l~=yVu_jIN*rkg3;4k;T|L#~Xztxrd8Q*3`Iqa}d6oYWG@lpYKERibXPtFV}svE*rMt@8wC!1}MXJBU)tjQxbqV_z{t zXyfuZb5^<=S({BO1LWiO#h;(+Wfm`nNOiSDh%eia;414ab&6<;LUnS1Y zV!!m7sU}*}2u@2W88Ow~#uH*dvxsi}I!fXQK+GYcR1&oL@iCZHFY4#tcT2-l*7_5pzyixc`W|I7&?wEhvIS4eKsw z$KEx|&+s3o!EmoelO%vRsoch_Mc?V7Y`A7kLDMn?%=Jd8o}-+xE3i_>tLWF!u@yV9 z|8q5fK4$x`O;@kGL)U3lYYP!0^J>#VzP(@m0We5l)>ipi)QHQQUu!Pcntagt*&&6F zsZ@H=IXh3FFnY_y#ES)K1i43N&oXi_el3* zI>DFfqVb%zJ}HNx&O^jM0EhgxbcX zLaaU^4c54gt2JH^5}+C|OV67Gg2v?T(9VEW;9^nQUX^@Va_6x0bD$^PA?PUpe7nVE zz$$j9%S|f^E|Me4O=Z!Ljqc?~bR@_g!&@k~Qq^p=PeqBZRReD?sc9JiB&D%{M+3j| z%L-ylJzrItQEb4$$V{+@PMtp;Pcz)vg-wsabaR*bh><`Y*Aa~p4TY(tQZM{H?mu|O zm?8eG&nwX>TXsL;StwU{)<#e;a>t&2;IVv-7Cu#`gW$nHHYUlRr$)yg<{&WT#Nw(U zhO>3JZVX|m{Tk}z#cEH_JBtS0a+c9gcgDlQum9}6+F>~a-XxY(&N>-Vu#jj zD7tppHl|lKkEyF#i#ChbnwsF`mf<#}E!Xr%;ilpMMrzEnR;cZ$=g>yLcUPQ&Cl~|x z4G82S5OIlBA!kt^+?6*(O7D^h8@ zP+22mMd>Q00CJtOLR?D8=zs0VY>N0KMhrlqc&W6uaaX%Fu=^RKWex@ZYAQZ#X;ld8^psHy?f2Q`rQBO%>>S+xYti_ zGS6QVjD0+pH!vP#CXV$(H~>X+w|`&lB2tW5yYzc}u%r^ph^eK!MA$*gFfxi0@2nd< zvdU~JKE#rWU-J6AR0Lvetv(_vrW)Wx{9u9QaN?Nb_k|JuT*bs`Tb1hRDf69T^0fR= z+8QZ&Gw)3g6F{qq*_<;vuiXE+2S4YL3_08pLny>zo&pADnieM@Q=E!2cuuE;Tb)=_ z!|bgEc@$Epue(>EU0tWzQn|zyJh%gQkDyO6K7$W}kl!oJ6=IW^7Uy&KRY3rH^{0UJ zqhhAQ6~ULNww4*<+NeZZ9@JE>c;s>)As`mu@ zvs0u{LwEP!oRa{~N5+V;A8&1$5FR>sy@4Fo#2kthWR#Lku1%KV?fZ+nk&M}r+z@Bo zBTQxiB?Zw9;_h7aV?FhabErAy zvZk+Jsz=`EWhDS0r1jJqn0P3WT5(Oz#No%~s)D9=lv$XG%`fZ6`kx-9Tr1sFSx##c zY%|Lk^=cx=+T?paP*UtFe&z%xKmFjI46^=vrl@|L$8KkxCKXPhX7-h|wuk1<+d;Ph zdC8m$Uc&D}6kzyzAYcMZ{|KY4X0fq+ejB>(irq|LaQfrDUwGX5y?-;J%GeHXkJafj zPc}?wLgWgs*DzY_Z9C!E>?mRD#9zCtAa|o7`iYjT5+&C=;Bk!WHU@cg0+{o; z(X;}euS>t|CO8gz>A+n6xg2!}`h}t}up+}k0er>Ohbhid!L&Ab-<%ATMF;F2{ z)f1kbN48Oy0>BTd6QOGs7a;>iFG=+dP(t3(>&h20EzkCa(b@H5gPj7~^8Y9qq$_&` zP#M!a{**Fq8+0Xd)&P5iZdsbS)BH|Txn#ou9zLfrE2r{BbLxJEyYKs@-4#pfV3E&# zgVj0BE@b>b{n!k&^!#Wf5@CPVWla^l{6#5aWoXcEl)JRM`>HNDJldoPp)PnW^*?c% zd_0T%99_hUunr}Jq=;9qFS(zTg`>PIJV$VG9ZRvQFL>@ z;Xl;Sk6E4Cq4`st{ieNhYGecB5Tv7@|D`f$L&U+p0vCC4-o)sB! z>0aq$9JUpK3Hn)G0XEBoz`f)?J2uAcUkJFXtf$QCRX=@y8}z6~EhKc*)NSR@FZBsZ z^*qIxhy4|OYUW6cY|4N@{b|tbjJFoGi|LQLtheHibFU|-h7u?w5K>N8Tkg4q;j2bW z4qHZTz2Idu^r(>VA21>Uff3~2YwkdQk9K0|%U2ao8cNEp^~g?v0;zHMD4gTVun8<% ziH{LTMU4c=l`JpzjNtWhPPYAku*7_4qP>jc4r<&7tXG}mPo@rE1?XdxW6(1fZxPoJa4Zs_jz%+I2I_N2Ccu@E@O@m&ed! zO1jU<=2mAW90gJatbj{qe6LBwJ4&NH8dkPSMAE)c$w3xc;-mwOOEK9}Qwi=1L(7yT zFJXtx&`Rh3lj+CKwKM#Cjsq$K#+sW`J^WC?MwqkG35*+grQ4Zrd(> z__E$M6`l3CGbiI~I3GY=?9i?DWX4#N0r*gY&pnfy4|i07XUq1$w%z@4fE#qD_Sc#nxLzkp$qJ6eBTU z<@vipTOSZnL9&&S=0fIH4xC0`>(b4%{?&a&@LKE`6}H*vBGPV~G6BdHU^?WIA1RVk zde;8nqp?th)F8E4Kg?lPHfL{iukr$?skW8VQZz&f*#rWcbX_}FhR$5PtPupn4~_$6 zhCvKg$L;AHEa#Z?JpB74QR#{vQR8a$rVD{WodC%OtB({YEX`HKX`J<6^)P> z>0Og&OBJSfixU_3zofU$Y+8oq)vl&MW+&<6;n2UXffaHL=5!ei(I8)Q4200F%?adq z?owFKbXzbPG4RZ**8}D;K3__0pJqIlNgtG-=T2Lz(RJM-{N6VkKU8~}c)TW7=y5i} z8j_25yVG2>EZ070rFmP9E45$g;L*qI(n1h7ZgWc(HvCNtl6d~UlK1gzCzrV_bgA+* zQ$n!->w4*%^Szh2cJ1PF8FB(Atce&{!dpnoZTVAEXIkeq($!d+ZFyAS33d=iC)}le zdd~w*HUV;O@9D!wmO3o^s=Ce1d0p4g)0t?dAGTXh`4>B=jBxn(NW0{ZKXyd;v~kdh zp4br4->g?T=+V*mq7bHDls{XR`kFZ>BI>FBRBe+wP*CETs%I$CyDP#2ksUYS@0<}4 z1LZL{tmg|ZXi7}5{ffz)9Dc636Afd-dYom!z4@?@_+R+ehNvg#c9UgQUReQ@t%eSq zL6_O;IIAiWO6_Cgd7WHDhNd&fM~UO^^I!F+EPS^*H2zTqBlS?QrUDt-1nR^Nrl}RU z6XATUui-AoUmZ}PACH=}vbDrEUI7dOL^`Z8A1D8Un$(r6F-%;_N8Sv!g#4Vupoc|< zMcC!}y36B#sKYp?!A(o37GfM+7lle2(bH~;AC&4^H?T5pQ>wPy5L1nqJ3i#8k68b* zGb_TFk}fcc@5(Nx3x5}FhD>5yIGS0461~k5$);;DZt^GJGEfnQvUov{i-k=|x-vJ< zGH=BDqd-fvU%f_tFLK11&1&oPl_vF3JCw9MIhnx069ozbwbYbBjtFW@h>Qy-9j-^t zD&#boaPgWHn+mNB#45~1Vxq=nn-kY+YU4-)=Ev+*bx8SrkhV(Bv*oh<^d%3i=^IgY zLGvQ&$1yYK`rX!!EvTNXt<}1{# z0sZp4@^r2H#UTi@K_=!Rjzoy?vR>z$Rp(D5|3aW_h^UC7|Gu~azm84w6Y7`*nRwxE zt~faG$3VBG^pg-|#Bq2o$Ke4YCRK~b4(NK-e{eMA`Rh0q9ZL*T9>SavT~J0x<1K3N zIy1MZMEqd1y+&s$W}YfEmUru(yN*#xT{=zG`b*Ve(4?bZ%)Z(Y{=v#h-8CYzbvO<# ziZI}ZXB^@GT=$B18Ik8(|3%CTtiaqQ+OA1=dN6pqltgnlX7!U}t|o)JZeJ%+S=gmc z@06_$4rhBRh-xVi=#nr&#>lAR0WS=Y72xi?Zl*v5m75|EuXzXf`h!*m%cHLm?uKaI zr}_o;6g9}VHLz&y;j8oZOdi@>zg*rpLegy?j}9Ii(sJ}c`~MCc_})i2H&Ok;MfL$3 zDiw`Ej*o@UpzX%Q6cfz*!zS$1hgLPi(N+H=Zb4dv0M_kL9VXt|VW$c`I;a1V(t>ka z8bv!aPu#bsJIZn;i&Z)8efjb8X+=-Yk@q&VEphdacx0>DO5`Y~`3RI(G7B9zdaM-& z(O1<#GSn!Gh?na7;!-%NyWJ0gTxUDYZw=Q-g|<>HLP0<2PFSEbPsY1=ugX)(WmJyF zI-tuSBG-tQw~+@%KV#?q-XVbw*uD8)7Q4(Z!$| z!L0vSmWcQ2zPiX3>L3FlN)JAe6V>-ha?*FIQrtj@^@z5EbRvk;?GciP)RM(6Q^P9PVR zD#dO{_e0wG9U&?jar@=N%@%j7=RX>e2~k-`E$f$V1yV-&c7@}KH9?=$!3#+HzMv26yr1!;9;#K5<4Fliby37A*c9ov2Xk<@_3^sbN6t3cvy0;Ens`ib=^xRJg{Ex9^i?N4IA1Gn5dYQO#i%Grs9c2ZM2pelRJ{j>B>NGx z0MvDccEmxN>V3=KWIi$d|4Jbd_0x`qL&8izjMw#E*EVMx`-eLZLTLPtn`mL&ERg|egBBR!WLjL?8dL|1|c{p~2I znsbbVD^1ZT@;QG%F6Apz9}2d!O%D1o&JF>h!a&hvF(Y5DOk5q6_D0H&_thjH+IjnS z;m?xMw{TwyPuO=seAHE>9V)SQGZv@gI#z5}ig}UcD5TgO10MEa!gEUlO0ut**e#4p zvPGB@mFEY6f(l*H+AQuihj>IdQPIn?duxOm;wAgg-l))-2?C^()X(A8=G`LRAiWo@ zix5$F#v~ZbjJurBovCx9x5x#3I-Vhg#-hnc(GoO-Q9lO`R3A;(XbJA#Cx&-50fnhr z++#4P4Hi|1C&?6)5&qJ0fF~y~>1$h8h?b)UbV}z3Ixz`?u?g^lq9?hW!b8PDYCxR@ zOC1ROd3B`%l4}0**Zk>x?NoDR23)t5xf&spS@0efToP{V2Y0sLQUu5|f*aGx=llvM_icmFq-N0J0#Qy|-SXB4&)^+Ox4jwfDE@li9Kk{`eFrn<&sX(J40aYR?O!~ub z=MN<^43o9;5rt42rZFJmG7lv8|FR=pm5n$G=;;iM?CuH) z_Zqm8I-T~%Blc-U7NnS_NLha|L0wZ)E?J= z@JiKzQsG!oQOUuup8=>NQ-xFm!ZqJyqIDBI)C4`@?}q?RTCA%*smMB1dP2+zC0N^D zE1I;{Aq{!rq_&Ta;eJy=&5fXcMj!{bq8m8;J}e@`&O;BtvRJ60;Vv!b>um!=ZN6%- z4>f|y2BK|bh`vCCmi*M!QR!`R_vE0L2J(vm`aC;ayI^;1SkVGr=l#Dou@L z6}~0HuBpEUg^~Oe5410yl>F8nHC5+Pc99r?u#ZxgP;Dy+X{CYpdSOd0n!K|hiLwM= z5MuJr#~|oQ!Ux#kP{aGr&=+ad*rl}Fj9G$}gYUq1s9wRy(z03+ZxLvbl9P4gP-3gh zjxeeIEZr(3gG@B!2ZB8Dh2NjCGt+h!vauFFo*MY9%8;C@j5^%N7>9(*K~nLKEv#h; zLy5;hQYsDTy8V&xNCxIDU86f-+Vje2s+eL#x+A#ZzEsT)D!bl98m|T;q++cig^C%M z40f`b;SY`<(Ff_oqEX>-n}H7>0OT2SUjhMxgb`hun-Lka^ksV7=g<&)8zOG4NrQE8 z1z~%0TUy@DPH;gjPoTcSGRuJnWhu-0=%+Mi8Q7GJfhEtGn|IqivUT}8K6T}bX=H>eGmn*`lJxw^4c9!tSWi&7mHvTd`*Rv)bx1DHmz@N>jXTvkJa zi}t+C;T0#F#A`@!*h$EtXTbZwp7zJb5=peOwmd_JQm6^a&TL6Znv0r@-mA%#U zaKs^$ca;(=c&?%v6-_Hz{?;D7Sf3D1bo`JHy@%|HJ3WNWl<;=sDv7;AUqcT^1QQK_%_? z_3Hupl~j#F6aN!N$Y{l!%AYDqc$ib1FN=@xa3@qRjIM$bV!u)Vic6n}S_60uoloJ8 z)Y(%$afew;%#;;`>pbNYew5OoA2zjHm24OoCE&m)2|5UP3KTM2jVgT+=;5$gEO7C? zi3Y*fDys8>tG7yqohFV&E)eiZt`BsTpsP}JEXq|BSH0t0FBx=pvp{aEU0 zq-2M~(SH>^e!UgBJR5xuW;MIa1CvQY^QW_8xGy;dOf~gm5U`-(9`zR2vy`~2)w3n&%{;W3 ziQiS)vrs)!5}_%Y0((TxjF2k?+<%XJ#EZzeDRaA^wm6rIJJpB;{kg%)&z~CgpEGTQ zc5WyZ&6BHZ#v_`0wcV=0K_E@V&vU9YTUWkT4 zE#?I#72$$Ms~m@yJ@=idsp}OuQRH*%f$xYtL+OcnkqbJ?eaL4Sj#!T|5^t{nVX8RC z=8Hd&+2rmxAY2GN+Uy@)YhU8^2{}_awb^UE9Jrxyzc}+7zY+u8{uCgeqpEYy(+KLW zBGCIKqv49d2Ac~%bv;J$deSk6ZJU58Jkrc?_KHux<$0*eRtRu^9EKJQiR7^O%0M6I zorIoSxZ@N^8li}6lv*Tp(pq8QhLEZM7{ka;)~J#J50&gi!H)SFNJD92FqRDRojj~? zPX`tnU>f6rX+LT@!?-(vJ{sAzZN`-lYY{g+cDO(2=STBGCYZTncEV`wm^=IfvLZ?@ zo%(Gd@^i#ex(CKQo&R5|jZ>X=8frtPs6KR^b&H&*mwm0U8`rob#VP{N4+|%PZOakY z^4**z>C3EBQWhLq2&qAul)~4AD_2=UQW@Z{VB|8Y`ZM+mkAd?p=jWK@_~*#6J*Be^H8*m*02Hxj87CD_Z_1 z1M%mn=kwm_(!fSfvONayA$gfkZS9fg>8On9SQ~`ZG z%DgUJL_8%h$mFYZVr!(l$S$JkVeb0319>J!j zgFjU6@_3DA7-7(1;V{G>czvd0N=79{7y9d|_wJm6X7SdJp z{s7k9=T^O9zb?H#WK~5NPI9bbM+YW~u!|#%dZvT45TE89UyB1>{`JYzfEYHOmt>wZ zqx_f3ZiM~$=~So&=p8`2Kod>5`5TlW!1=;dVWyOsZR+jAdP&$1yZAEb1QL6Jdq)EX zy%6e5*x2~A=`6&o;SGfP6d+?zv2g3p;-+nj`7v=C1vmH5+Ou@4@Jm@q{_X$X+*Ca) z>EyTE$v}&3j{iq8EF9s3U%#+J?G>TtkwH!d`C7h10Q@`x@blpQbLhP&04iJis5HME z2`_Q+!FE$!Hl%8Qv)^@yY{a_>Ce`&4U}j;pb2Wt09VA`r?)JN9tDx*noCG^J7=)%rFcXCKW6aQD*wJK67W@d0k38;xMGt1wol6&7%Z_3n;6rh`OtYToi&z zEDG9p&b44-^vfhxY(?SKE2Cv-FeAo?&?cNOcjnMg6cKy!x4-9e)~X#-`vTqGmkr3^ zOAjmIeoSEBJ5w-_Z<0_>VL7tz<1wAe*%iEu=ux~nksY|sRwY#kwHVO5p_^TEF3ZRp z9xloL`{Pe)2<6kx1;WG5h!4gpTsibMEVfA^k_zY1>%>O)_RNSJ^(ciD8mB`6ZJ3T* z$dEXKiSpt{0;0~P8QUu46L_6_wxYlDQl^dg55^>;wG>gtcCH>_4%8WiU{_wTNyx5PM4p+oiW#8%ixB* zaB5>~kTyfLt5iV_Fz*m%{)Af;HbTIi=?GK5z?m<2V8yTEm!l)2FvC|n<#7n8C|mUI zOnBO^y=m_oeJ%8VKSTiOZqnQZ&iq-PouZ8KiUfDj(n5Syjm5~wF|Fy$Alg6WR2h?p zdb)yfO*sawUKRoZ6e7&==1eqgx!-9x46YpVS5kBT8+{{N9s~an-CqvguNE=bfo+XX>OC4m+Lr$`MB$g-oP0-dOyL}qT#!73X|Fi&oBSvfWzk4@X- z?YGwo^QsUTeuO{q-2c?l%G~z?o1OiApgvR*A`Tw$p(EbWW5W!J$Bx27cgSWxABPw0 z73G?5h)~sO?Zui0vA*ckD2Xxh$ASd<80~Q>^7j?4uul_NbU@!i`LW9F?r+#kfu(TW zp0HF15YITfkH)yC)ZGYK>7PD0&zkOT9=}0SF_;1a$OFv+G@>kV$?+GMDO(Hc8jGEX z-D)d8Y>{5+-OZ%+UJ4`hBDNN#X`Rkilh|r{5(u&Beij(XP51`g?DhgvkP>vryLeqY zPle8%-SOB=__5x{KVDLr`VPG9bQ>ZDFtCC&cCL5v^2OAbX{Ta z(erfbJO%biPW+|s`9%L_@HnrNr6D{xEyzHnL>^}65gahc$uC@n3ay)dOXC^-)=qK| zLyAY|R+mR+`8(CSs*r0-oOg2vEdt~=1wQulw^t#)h48_DDF)+b#ifT{N3M3Ou*0v5 zI~!BVkbx&Jc~Gai;fR;(w`%4_ieYR+ma>*z*BB-TsyV0yKxUd$4aW};@dtLax|uklL3T<1D?$q$eaqD8=RdvL%hs^3dp!~ zB{HB!P-J!b@ir&s7YRRl5Dl&lwKemqlMM#^!ek1xc4(aUXYSgN(zj4D> z))CmDm3YuZMp7UHR7B|eM{eL+j9)qu`)Bihc5}poGCdij?<4@{b<_|;zFeV5;>T8* zTx~$Zdbj29125U2wQFW%iLR{X2w8&qI?=L?iLNn ze#tRAyPdRb#iQUn!shFoFuU95uF}HXCNd`J^2mB5d*60>CEH9U8u0HA_j-DQJ43gX ze@JN(_l~LCfWiBW;%c~<>n3$)Mu-v>a0-38c2c`^y2jNVQjUObb!J433|21Mkkd83 zgztZN`kS{p;4l+x5l~N|5_?H>Il&r`4zLDs(D1jEBRP;VY^Ft6k2S>@fx%8*0)GmX1u9C^A&8NV?vj$I&Bluu;~~A1Myj6bb4V)(G)LgketPs9%Jp*B_UP4&hM%fpElUW3o$iP+lum< z>z3}(D*o0ZVDsL|Z(99=(XU-a)KU-~Yft~&T>3;=F)!o*x`_%3J%q& z{?w5lF`Ni2xY4bXQ3_NiXnxh`V~$!LEjwpQlt&gpf9LHDWV;Ta95_xKTM)2?z?jPc zzi48h1lx)>NnuskpD0a@#tC<`LOl-P1SOZT~nQjL~X6OBVBXiL67AVk5f;g1jE{zQYd0kPPZs%JMUJ#e7ehGFsM z0u{=S0*My;3q=*27|!LA87eux0H&EF-)FYf!gon?n`|?IaSg20Ov0-Mx4GiJyxR=G zCm0Y~J0(a5)C1&$@&1ACmLE#Ipqa3}SrmNiEXIm60Q6UxDZg^@qF6iWT6{l?g?eq@ zn5ASWOIA`B$J6EDanl8DUz?U%SjvOhPMaJpI8#&cA*at7Po zxYaVN{LT~Vq2!~>WGtNpke&tp7=Ds_(5jUw_;$vPa1mu+t=p4gT?J5#q&o9i)*US+ zKx)8wDPrUn?TKBRCk%rBrA*++O34sgM-tC!y$0RgmfN`Zm#m@hnLQMCLM?7BJ;j_X zJ@ye}#n3g^B9m}(JO-vVX<9N+xn3sTCl#-q3_&&zK4J_JAegIEKWBHXE|_PC7DC*Q zUe}Oro#N@N=Wc@c(IPrs8^VB$!y`qDhcb(kiu#S^-;a83CJ2thpxM}`fM*}R+Ly*^ zD?q(QzVdzU{Z@gj!~v`~;Z=JYtK_ z;mX>9CWdmLmYiP%<=^S$VI*Ztt0|exaV9X1IP^J;N$wx{nSYvB`+Zq>;|TY-3e03Q zqsOw{rm6qD{0m#*pX+52e#=0YO-eG{!;ASRZsI0@2aA?$_Wf5-k@}*3?z0GLf=8Sjt zXj+_5R^FT}4UQ6HJNK_bbgK@@G@y9vc|Gvizn6AY4fq&ciR_#0SRdOeQ2`^V>wp2) zS})5|180Gv&K$^rj@r(}yKw4HXE)QSRERl&(%nW3L5kWSh3Gh%eGC+?^ed$B^8t~~K z63h}5#+MLU7amt{9C6;PC<8@>jXZ3xqhv_WKTbt=dISF>yurMUTPdvdohrmK?#dqy z#E+OoSU&|HIpPbpBxh7%`gvIiH97-R-v9-~q^@^MEMXVUVoN$4Xfy&#N>1@4crC|? zrF7s(3BjZFCwuK~VcUmo*_b4x3}xITchf|O?U%Nd+chi<#&8M3tPKnvszWLvH14!r zyZNPJg1<9e`lM;ma2uQ9*I#<**HvJTOfwu%;K)F50USRDeL=tD2718*Stp8Zej% zwUEZ|!*3spOKvXF;7P<^Yas4X1Jn?skR0T@R8NP z@BqORk14(twQ+mo(UnM}{ng&HM|=(z9{L`Q8Io@Q$Iw_Zw;feiqB*k(@~)AWJW>Wt z|Mk{8J-U>6W2oNRPC5c_KMs(_4BDCBF3(3#Ky|SHr;foZSsmU5dlqsuR@uZubd4-; zI~Z8lLXRe;8GGKVBGx2yb%wT{cG?a^d&I)xDa8myg^K^0BeiIw-PLyDO&G+xko)@m z6WAtyvNZOCnp)Q`tGaC*O6Da84V8u;Ds%q+4dA#Dj=A-BX2isr+gIkVS44h}3l=wp zsKBfav3Y;~mN5^uY*Y<{umxf;q^!)X+81PopK7;?RIM{Dj51s_+Fn1OLb6ELU_9bZ zyd)8lZk2-P8}Ez<{^)j6JSBHkzF?U|B#O=ujJ~R+%)=~t64WIw+c$X_2BWaLi^1i9A-D+Lg8BjR0DTG0M^d9pF92ES^1+G@xCNG{YlBY(EUgfks zJ0u|LwM!)|SFGj|=Fb~k)qKyM10HB$QzmUA#oRDDnDbM-0e-Z2rX)n>MYzK50TBy_F5LytEo5jWiTae~sfc5VqjLgH>O-gTB&b8Pkwk9$R zr2Odw_RJVf`OU)@3<7-$g>V(mM(naH-R|HN{uiF@HKcFI6P|vXBqu+K3uio!-pqqq zhuvszCXI$y=hf0(JW^1-FX)?>{WDH#bUk|^E=k}y&wMO=`LPF4)sdBWZiZU(((Zk?09xMu>sI6WkOCzs3iv-<*df8r{wAoZ&lVxh`^Nr%{ zpj`_lqj00%s`Y&$WWqn81%%JWAGVjjzxMa_d`8lVaRjgoA?RWu|8Uch>Y3-+!*g)i zp-Vn86bMwIF~$-$OtlDfu4~d1v9>fMvKpA}LW=O`ZVGM478W9Q08*L*4sqZ|0CwCVS3P5BA+R=UKu#Zsv6h)?k-CLqn< zh_wGP^vx*y&4W)cmkw{w9yP3lsS2|h+j6a|iKGLxD#o6-v}j;$PE;F;?zHS{G@;)_ zG$$tdted0q!8b2Wqy*eK0a>I>4W=34LbaXk79p$(X-%SBIy6mJ&l%rV>nt!aNjV_OBJ}%p;LgU;6)~KwT$ZIvpCQ=Vk1w0ENrfaUc9Q-Z@iD zMxKtCMPBredDQI}4~f6#->s!5LU<}_QcL859<=x!!E~hO|K1DqO%O;8`09x5k<3PW(sDGFs^9VP7r8 z!rzCRQ{unCyd5v8q^`(8$y&fPF63Nq5=9II&$Qi|pMQEd6|{NCH+^=)pi{mD?D0Ki zB`@P;f@Q`JzI%Z~^WQb@Nomv5o1VAtL1-!ZvoLe!tdiUqgJ`XRy5UH0Zw8vXVuS zv3?*GW@ctM1jekJC#d+ct2J0z^_P5t@JfE)G$hfkQssy|Cz)6^>w&!t)RR@vt zsG?PSA$82zf4Ta1q^ohECg+RwXt)A->g%RB6)#z*>t|iwzVZVHKSqTX6y{c zRDOEfC`}w8=WgYs$6TBqlxwhg?3arv<+Y=qn;D|LI8@*dWy(cP}jdqJ5w_;Tit~~Wgdw@q1;e#;QY!Zw-fQ<;HHmyQdDCclVV;b38_$n z-^{iHvL#m4*)!X;z&sV@Mw&}cpM2#rzf6JH0daKwU(oJmX_RWYh@5V1{gP`UvMqmN(#oPDEe$^Jd4V1%90gB6#xx3IT6n z5{J(uzj9am(L$qUH>WZfd}T|V)fgPOhsWM}MK%iYtnL(WhEFKTk0Ok5eMUqNY2NYu$KK?*_$ zae%klJtN>qabAftrm6yJy&l;K9GVKlt$K8pQUkV~6iK%pN=BG8)W*q93ui3@e3Pfn z6n8^Mm;2vWpp_8jakN;tm#OyDIO*Kp__L;*OVPuFEh+@agvB+jraO=1o7|Xfm6YwN z%{*rVE1nKLjL;dqwxRXKT6-%1>P^wXu!j;{1UbR+A6Tz|QZp>wuTASQnYv#;GQ=La zDD^Je>ym0otv76fJAey@Mkz8Q7$zDKUmGenJn*cQ69((+N_h3rdNNe1eBLSpXFo)h zfTO$uGA z+tvZ3jrb_@n)=~$w>Rx&R2*`{q0TF~(Jcg80wqkK6hHbOM*{nj5SVlv^3mb5skpO7 zGH&t|NqwMJkE(9)I{whR=X-qUn;Ui5=LMU7@4S)d2GG`k?^(%0)SM}+5hUzN=7zTC!?ybS zo2u+jA>`^PZWqanfUVpbZ?og<;dLmu)_X+S1Ibz50*|7msyfuJDF{Cq( z@T`VG=Ii|%N4P4j>WhzTu*J*!<9bT%B6(BCDDJw3-8TLc#or9Z#shNJzg>gbS&2&j zR52uMvHDCBI+{?|LFTJp&N`mNB8>B)7~gqB@(7p1Y!ob`XV&|8% zDcjE7a+CUZSmr-*23EYlHSM`T7av$4jU0Dg9=1yyUbV9{%SpZF!lOERJGU{aK;=8?j4J z9Fs_j+L_{WW42^Ekm}=z56O&)deMR$&3qznIRLhB5*iO7`PsK@Af^L0&iQ9%KTr1W z`Nr1eSE@MtPG{MB`qd%|X>-tLajB>=`81{y;zeuEo0fd%t^U=Vou=ivpJ1DpPB$%k(|R1N zB%I7~pOfwRDW?-65`(R9ZhqHN=puGtg7grMyf{W_0XV<20!s$RYQEB-j!d)HJGGeN zZheX2gDuF%NoC1xcw}(}zLKo&51`gMMMk&)Cj3TvL3ARv(7~=|v2D^`2HDOhZt(>x zK8a}f$f{*L$xFEWWVfR5qsy%K69acAg@s1GI6~lGjc~f&xI0?aklg2(6RgvpRt+nk z4_9^6DC~7iiYQwmD3)c9ws<&PQwEa*e@nYES&a{d?_ zwfgfA|H3Z;eC%OJOrbc$n@4bM+_Q)`S8rKUz+u^VeMq#RK%1twPPt0>?7&_xnF90l z@($JMVZchj+flU0q;MYiHo@m^9F0I(Fkqg0@%02rJS}N2lBQqp>Jq7HV1xg%bBDxk zCB?WReCOTYHIW#bHttUY9YU?V0GhuQMqL1f8QVi;!ltH?JQBIGy(sKk;a&bd8#lON znP6%BW$*>BekyJ{P- zKV0#U`x8Np+Dxw$Gn7uvB_5k#9`U(dBtszF9bTR&ua-bjBot_iqP*jzihmv^|JSIy z(}4qEdtm!bP7|>hh8u7H)|1PW9vx4bzE#{It`=4)hmKXW1fE!3ZoBLFe49Pv5f>YO z#RCK8V(_D@E141cfS&W8+61P&*})g^xH+iW(QXkm8*BE!%Q+z5uR{?{#qyQ3)6tW# zS2MH9m*7g7(^?Vjp?ORP z48KXHbbcHx6uu7o@U2O|6G2%jO<4xRYs!2NN6^ix%RtL2=%KTc4&X@ZPZOFT-3|~C z%|57Xe4X;*5aCbkVIiMC{X3s7lYt;KE3dkMZ{Zc=2Doz9USURr$NzIWj1%+GbBOXw zOUYU4Pa(HRAApwR{m%)C8NsIDUWp^`#dC>v%X8~CluZer(@VNYy0;*R=4BjkPA#ee z8#?Dw-6H;&UriqRl13f#dr(?8^)IPTk#x15$@%FpSxevCqTB>O|E}FiP47f=RQ7kir`zkoDXp1wL7W2#K43 zZ%wAq?NRVJ#+iIXf@Lh44*JFsqh+vwYI-ekI~&L6a)xc(5@DEXXc3)9ye6t3o?g?1b+I6}P~l5(#p znMIb0?TQaEd_FtN(}S5nlCR^<=VbS71(;nl+0qV&q~m_-aVjzG%ck`^V~-Vw%gp4h z$$dcbrPsaaia@h$@XG-*sRFx`$&9(sKT+^a-x&@(Z;|`5uYk+jf$N*I{_8CkL7}1c zN%ncaIx`gkC(l3{S|Sh6U?}#B>sD6ei2M1c@d74013v@#iI%=v0{~j}KOM16>|jP` zjVY$Sxj6EQmJ5*Vnc6Uj-dkeEO|2PQ``A(KqWV0;&X9m>17vSdC=d!KDiaLPzTH0A z07{1*0EJ6!C62VY3}hdXH>g2-?~DZUxV0$qWJRLj+^)^>yYtC5J!a!sD_uD`!Fe(<_vwvggCa7W4r3-3s6jh{1*m@aX!w_2r5rO9{} z8dgr4JtA>MpWLbvb^@txOWn&-IgS*L{xLlHG_vq@$UEl3ACYGuB0Z@T?Xy3X|A@g^k- z4Gz1np73UPmW=#97xALeFXLW|esiO~$tEzdR&|eUNpta{+D&Ygr>?ncjV*7uMkPc| zBa@^K87!7t)iZ^>^AQ#{rZh;Tz~I)e2!?I{stW7kg>2Ivt=9rrE7d@tp7s z98c;#_T!E=g5;j&emeQuhjVt2Pbyg@HU0s=CGu_SxuriC>$)x+^d>Dgx0frDq?%f) zOe`bO25Rl>q}?cj5Y-Rtq{X}vrR9aEC&rXZ`vvbLmpV-(P!X{&;F$OW(ptSE-p#1N zHLO)ctFtz1##r|C%sp-7V>BYuyRhbT^Jx@4{qjz@twsdqxqR!XW~Ut$zRRoqqwl_c zBDq3kf)CT`xX9IC)axzQlgbkOjqXuW{4kAVtw>`;yw{g18*`j3%OkAfgjZZ2@^>jS ze-6PrKo^vrQ$57fXU938h;vn|S8m^V{UW4yiF_wdyfLUggD#=W`%_(0y^dhNP)74D zv)#o5e3$b5Xza=C7^b>ePKhP2Tj@oIMPHqZUe~+b?@Fn4$`6o_yi74||q%<;lSW6B)o8*|xyhv|j?($r))3 zw=JW2==B|-G&Izq-l%UOOXTs+?5ijnjc&V(ecSlhKl{GTYSyJHPtAgk$b;i9zfZ*_ z-BT#d{yaSLzFu#4BoyEWRojK=>3df^SW+Z}0|>fln@}az%7gYwT{!^;N$;~EtGdPm zX?kOo8SWa7j-uGW4e+RVB_&}Cpu^OBv-Ff$S}jT-d^0G0?vg6qafX@t>K*m!b#d!* z(rMmqSV`CW43{69;9liivQYL2vM}H^7;!V`9Lkd2L*>$L>^Bv9W%rA1{^LBlY^({V zaqr-uOW&bWQ7$wGI%~2*%*2-XIvY`U-V#}I#8}U%qI6bdk?CF=M`$TO+BnqT_z$X6+4rOkJGsUJ?IGXKrus4S{D`f>%{$Zci+adUgkg1_DyN0OSQQXFb5j zP;wV`Tg<9Kz-1B9ibI0Gm`rIVx5mY0LC5H)?6lWmh4OSgIxHn8=K>r7@9RSke>uLP_Ggd$gAvxZt*C)NYrGm__*AnO6k z6(g1z{$ETUsxq83&Pdx=%-{7|f5ua!{ButG?-BiMW8%h}1Gg)26SrZgPyXp~SU!Hy zK?6^x&ATO!t(KLpX;8&mUjrcajsQh@m_9&WoQjrSJ%`8*6;wZdS=U%_$`Jcb`NOoK ztPx3P=2*d#jv^o@`KFR8j*0A`o-50D?8e(wN1nb)cazho3glmb6t~a1i|j6FGO>*X z!pL6qp8ud(hIN38NJn7(!~tAF-_j%DjBA7J+e1D(X;X#5kgwd(>`nf?KFW}&V?aP^ zNo;ZRj1|Qr(}Zf4Gs|k_W7C#~2)DtnC{EmcZ1*Au?o3vD_h433_)4FUwtS89Nu?v= zY+H_((4&A%h~PA&g1jWWXT##po=^p-H!Scrqo+yB&mZU^ktHk-xPzA-IvcZUH8l>+ z@>PGo-ANL+xx!ZW*e7Q8IQl5wT^|0!D69m`{lDjb7WjV_I1pdFBRcvq($O*X0fhq$ Nu<9$7a%J=2{{xI~{_6k$ literal 0 HcmV?d00001 diff --git a/nebula_app/Cargo.toml b/nebula_app/Cargo.toml index 74420b8..9b3d157 100644 --- a/nebula_app/Cargo.toml +++ b/nebula_app/Cargo.toml @@ -2,7 +2,7 @@ name = "nebula" version = "1.5.0" license = "GPL-3.0-or-later" -description = "Nebula Terminal — a premium GPU-accelerated terminal emulator" +description = "Pebrel — a GPU-accelerated terminal emulator" readme = "README.md" homepage = "https://github.com/Kuddev/nebula" repository = "https://github.com/Kuddev/nebula" @@ -138,7 +138,9 @@ rusqlite = { version = "0.40.2", features = ["bundled"] } sha1 = "0.11.0" [build-dependencies] +nebula-settings = { path = "../nebula_settings", version = "1.5.0" } gl_generator = { version = "0.14.0", optional = true } +serde = "1" serde_json = "1" [dev-dependencies] diff --git a/nebula_app/assets/icons/nebula-keymap.svg b/nebula_app/assets/icons/nebula-keymap.svg index 20e3aaa..26bef2d 100644 --- a/nebula_app/assets/icons/nebula-keymap.svg +++ b/nebula_app/assets/icons/nebula-keymap.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/nebula_app/assets/icons/nebula-layout-grid.svg b/nebula_app/assets/icons/nebula-layout-grid.svg new file mode 100644 index 0000000..9935437 --- /dev/null +++ b/nebula_app/assets/icons/nebula-layout-grid.svg @@ -0,0 +1 @@ + diff --git a/nebula_app/assets/icons/nebula-mouse-pointer.svg b/nebula_app/assets/icons/nebula-mouse-pointer.svg new file mode 100644 index 0000000..71503b2 --- /dev/null +++ b/nebula_app/assets/icons/nebula-mouse-pointer.svg @@ -0,0 +1 @@ + diff --git a/nebula_app/assets/icons/nebula-pin.svg b/nebula_app/assets/icons/nebula-pin.svg new file mode 100644 index 0000000..d23daff --- /dev/null +++ b/nebula_app/assets/icons/nebula-pin.svg @@ -0,0 +1,4 @@ + + + + diff --git a/nebula_app/assets/icons/nebula-sliders.svg b/nebula_app/assets/icons/nebula-sliders.svg new file mode 100644 index 0000000..542e624 --- /dev/null +++ b/nebula_app/assets/icons/nebula-sliders.svg @@ -0,0 +1 @@ + diff --git a/nebula_app/build.rs b/nebula_app/build.rs index e430c48..b402db3 100644 --- a/nebula_app/build.rs +++ b/nebula_app/build.rs @@ -1,15 +1,21 @@ -use std::collections::BTreeSet; use std::env; use std::path::Path; use std::process::Command; +#[path = "build/i18n.rs"] +mod i18n; + #[cfg(feature = "legacy-shell")] use gl_generator::{Api, Fallbacks, GlobalGenerator, Profile, Registry}; #[cfg(feature = "legacy-shell")] use std::fs::File; fn main() { - validate_i18n_catalogs(); + i18n::generate( + &Path::new(&env::var("CARGO_MANIFEST_DIR").unwrap()).join("i18n"), + &Path::new(&env::var("OUT_DIR").unwrap()).join("translations.rs"), + ) + .unwrap_or_else(|error| panic!("translation catalog validation failed: {error}")); let mut version = String::from(env!("CARGO_PKG_VERSION")); if let Some(commit_hash) = commit_hash() { @@ -56,57 +62,6 @@ fn generate_legacy_gl_bindings() { .unwrap(); } -fn validate_i18n_catalogs() { - let manifest_dir = env::var("CARGO_MANIFEST_DIR").unwrap(); - let i18n_dir = Path::new(&manifest_dir).join("i18n"); - let en_path = i18n_dir.join("en-US.json"); - let zh_path = i18n_dir.join("zh-CN.json"); - println!("cargo:rerun-if-changed={}", en_path.display()); - println!("cargo:rerun-if-changed={}", zh_path.display()); - - let en = catalog_keys(&en_path, "en-US"); - let zh = catalog_keys(&zh_path, "zh-CN"); - if en != zh { - let missing_zh = en.difference(&zh).cloned().collect::>(); - let missing_en = zh.difference(&en).cloned().collect::>(); - panic!( - "translation catalogs must have identical message ids; missing from zh-CN: \ - {missing_zh:?}; missing from en-US: {missing_en:?}" - ); - } -} - -fn catalog_keys(path: &Path, locale: &str) -> BTreeSet { - let source = std::fs::read_to_string(path) - .unwrap_or_else(|error| panic!("failed to read {locale} translation catalog: {error}")); - let root: serde_json::Value = serde_json::from_str(&source) - .unwrap_or_else(|error| panic!("invalid {locale} translation catalog: {error}")); - let mut keys = BTreeSet::new(); - collect_catalog_keys(&root, "", &mut keys, locale); - keys -} - -fn collect_catalog_keys( - value: &serde_json::Value, - prefix: &str, - keys: &mut BTreeSet, - locale: &str, -) { - match value { - serde_json::Value::Object(object) => { - for (key, value) in object { - let path = if prefix.is_empty() { key.clone() } else { format!("{prefix}.{key}") }; - collect_catalog_keys(value, &path, keys, locale); - } - }, - serde_json::Value::String(_) => { - assert!(!prefix.is_empty(), "{locale} translation catalog contains an empty id"); - assert!(keys.insert(prefix.to_owned()), "duplicate {locale} message id: {prefix}"); - }, - _ => panic!("{locale} translation catalog leaf {prefix} must be a string"), - } -} - /// Copy the side-by-side new-ConPTY (`conpty.dll` + `OpenConsole.exe`) next to /// the built exe so the matching OpenConsole host is selected at runtime. /// diff --git a/nebula_app/build/i18n.rs b/nebula_app/build/i18n.rs new file mode 100644 index 0000000..0049386 --- /dev/null +++ b/nebula_app/build/i18n.rs @@ -0,0 +1,221 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt::Write; +use std::path::Path; + +use nebula_settings::LanguagePref; + +#[path = "i18n/catalog.rs"] +mod catalog; + +pub fn generate(directory: &Path, output: &Path) -> Result<(), String> { + let mut catalogs = BTreeMap::new(); + for info in LanguagePref::LANGUAGES { + let path = directory.join(format!("{}.json", info.code)); + println!("cargo:rerun-if-changed={}", path.display()); + let source = std::fs::read_to_string(&path) + .map_err(|error| format!("{}: {error}", path.display()))?; + let messages = + catalog::parse(&source).map_err(|error| format!("{}: {error}", info.code))?; + catalogs.insert(info.code, messages); + } + let english = &catalogs["en-US"]; + let chinese = &catalogs["zh-CN"]; + if english.is_empty() || !english.keys().eq(chinese.keys()) { + return Err( + "English and Simplified Chinese must have identical nonempty message ids".into() + ); + } + for (locale, messages) in &catalogs { + for (key, message) in messages { + let base = + english.get(key).ok_or_else(|| format!("{locale}: unknown message id {key}"))?; + if catalog::placeholders(base)? != catalog::placeholders(message)? { + return Err(format!("{locale}: placeholder mismatch for {key}")); + } + } + } + let mut variants = BTreeSet::new(); + let keys = english.keys().collect::>(); + let mut code = String::new(); + code.push_str( + "#[derive(Debug, Clone, Copy, PartialEq, Eq)]\n#[repr(usize)]\npub enum Message {\n", + ); + for key in &keys { + let variant = message_variant(key)?; + if !variants.insert(variant.clone()) { + return Err(format!("message variant collision: {key}")); + } + writeln!(code, "{variant},").unwrap(); + } + code.push_str( + "}\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\n#[repr(usize)]\npub enum UiLanguage {\n", + ); + for info in LanguagePref::LANGUAGES { + writeln!(code, "{},", info.rust_variant).unwrap(); + } + code.push_str("}\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]\npub enum LanguagePreference {\n#[default]\nSystem,\n"); + for info in LanguagePref::LANGUAGES { + writeln!(code, "{},", info.rust_variant).unwrap(); + } + code.push_str("}\nimpl LanguagePreference {\npub const ALL: &'static [Self] = &[Self::System,"); + for info in LanguagePref::LANGUAGES { + write!(code, "Self::{},", info.rust_variant).unwrap(); + } + code.push_str("];\npub const fn shared(self) -> nebula_settings::LanguagePref { match self {\nSelf::System => nebula_settings::LanguagePref::System,\n"); + for info in LanguagePref::LANGUAGES { + writeln!(code, "Self::{0} => nebula_settings::LanguagePref::{0},", info.rust_variant) + .unwrap(); + } + code.push_str("}}\npub const fn explicit(self) -> Option { match self {\nSelf::System => None,\n"); + for info in LanguagePref::LANGUAGES { + writeln!(code, "Self::{0} => Some(UiLanguage::{0}),", info.rust_variant).unwrap(); + } + code.push_str("}}}\nimpl From for LanguagePreference {\nfn from(value: nebula_settings::LanguagePref) -> Self {match value {\nnebula_settings::LanguagePref::System => Self::System,\n"); + for info in LanguagePref::LANGUAGES { + writeln!(code, "nebula_settings::LanguagePref::{0} => Self::{0},", info.rust_variant) + .unwrap(); + } + code.push_str("}}}\nimpl UiLanguage {\npub const ALL: &'static [Self] = &["); + for info in LanguagePref::LANGUAGES { + write!(code, "Self::{},", info.rust_variant).unwrap(); + } + code.push_str("];\npub const fn code(self) -> &'static str { match self {\n"); + for info in LanguagePref::LANGUAGES { + writeln!(code, "Self::{} => {:?},", info.rust_variant, info.code).unwrap(); + } + code.push_str("}}\npub const fn gpui_component_locale(self) -> &'static str { match self {\n"); + for info in LanguagePref::LANGUAGES { + writeln!(code, "Self::{} => {:?},", info.rust_variant, info.component_locale).unwrap(); + } + code.push_str("}}}\nfn message_id(key: &str) -> Option { match key {\n"); + for key in &keys { + writeln!(code, "{key:?} => Some(Message::{}),", message_variant(key)?).unwrap(); + } + code.push_str("_ => None,\n}}\n"); + let mut aliases = BTreeMap::new(); + let mut ambiguous = BTreeSet::new(); + for (index, key) in keys.iter().enumerate() { + let source = &english[*key]; + if let Some(previous) = aliases.insert(source, index) { + if catalogs.values().any(|messages| messages.get(*key) != messages.get(keys[previous])) + { + ambiguous.insert(source); + } + } + } + code.push_str("fn source_id(english: &str) -> Option { match english {\n"); + for (source, index) in aliases { + if !ambiguous.contains(source) { + writeln!(code, "{source:?} => Some(Message::{}),", message_variant(keys[index])?) + .unwrap(); + } + } + code.push_str("_ => None,\n}}\n"); + writeln!(code, "const MESSAGES: [[&str; {}]; {}] = [", keys.len(), catalogs.len()).unwrap(); + for info in LanguagePref::LANGUAGES { + code.push_str("[\n"); + for key in &keys { + let message = catalogs[info.code].get(*key).unwrap_or(&english[*key]); + writeln!(code, "{message:?},").unwrap(); + } + code.push_str("],\n"); + } + code.push_str("];\n"); + writeln!(code, "pub const MESSAGE_COUNT: usize = {};", keys.len()).unwrap(); + let bytes = + catalogs.values().flat_map(|messages| messages.values()).map(String::len).sum::(); + writeln!(code, "pub const TRANSLATED_BYTES: usize = {bytes};").unwrap(); + std::fs::write(output, code).map_err(|error| error.to_string()) +} + +fn message_variant(key: &str) -> Result { + let mut variant = String::new(); + for part in key.split(['.', '_', '-']) { + let mut letters = part.chars(); + let first = letters.next().ok_or_else(|| format!("empty id segment: {key}"))?; + if !first.is_ascii_alphabetic() + || !letters.clone().all(|letter| letter.is_ascii_alphanumeric()) + { + return Err(format!("invalid message id: {key}")); + } + variant.push(first.to_ascii_uppercase()); + variant.extend(letters); + } + Ok(variant) +} + +#[cfg(test)] +mod tests { + use super::catalog; + + fn fixture(english: &str, chinese: &str, french: &str) -> Result { + static SEQUENCE: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let sequence = SEQUENCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let root = std::env::temp_dir() + .join(format!("pebrel-catalog-test-{}-{sequence}", std::process::id())); + std::fs::create_dir(&root).unwrap(); + for info in nebula_settings::LanguagePref::LANGUAGES { + let source = match info.code { + "en-US" => english, + "zh-CN" => chinese, + "fr-FR" => french, + _ => "{}", + }; + std::fs::write(root.join(format!("{}.json", info.code)), source).unwrap(); + } + let output = root.join("generated.rs"); + let result = super::generate(&root, &output) + .and_then(|()| std::fs::read_to_string(output).map_err(|error| error.to_string())); + std::fs::remove_dir_all(&root).unwrap(); + result + } + + #[test] + fn partial_catalogs_fall_back_at_build_time() { + let source = + fixture(r#"{"greeting":"Hello {name}"}"#, r#"{"greeting":"你好 {name}"}"#, "{}") + .unwrap(); + assert!(source.contains("pub enum Message")); + assert!(source.contains("Greeting,")); + assert!(source.matches("\"Hello {name}\",").count() >= 10); + } + + #[test] + fn rejects_unknown_keys_placeholder_mismatches_and_id_collisions() { + let english = r#"{"greeting":"Hello {name}"}"#; + let chinese = r#"{"greeting":"你好 {name}"}"#; + assert!(fixture(english, chinese, r#"{"extra":"Unknown"}"#).is_err()); + assert!(fixture(english, chinese, r#"{"greeting":"Bonjour {user}"}"#).is_err()); + let colliding = r#"{"a_b":"First","a":{"b":"Second"}}"#; + assert!(fixture(colliding, colliding, "{}").is_err()); + } + + #[test] + fn ambiguous_source_phrases_require_contextual_ids() { + let source = fixture( + r#"{"verb":"Open","state":"Open"}"#, + r#"{"verb":"打开","state":"打开状态"}"#, + r#"{"verb":"Ouvrir","state":"Ouvert"}"#, + ) + .unwrap(); + assert!(!source.contains("\"Open\" => Some(")); + assert!(source.contains("\"verb\" => Some(Message::Verb)")); + } + + #[test] + fn rejects_duplicate_empty_and_nonstring_messages() { + for source in + [r#"{"a":"A","a":"B"}"#, r#"{"a.b":"A","a":{"b":"B"}}"#, r#"{"a":null}"#, r#"{"a":""}"#] + { + assert!(catalog::parse(source).is_err(), "{source}"); + } + } + + #[test] + fn placeholders_support_unicode_text_repetitions_and_escaped_braces() { + let names = catalog::placeholders("你好 {name}: {{code}} {name} {count}").unwrap(); + assert_eq!(names.into_iter().collect::>(), ["count", "name"]); + assert!(catalog::placeholders("broken {name").is_err()); + assert!(catalog::placeholders("broken }").is_err()); + } +} diff --git a/nebula_app/build/i18n/catalog.rs b/nebula_app/build/i18n/catalog.rs new file mode 100644 index 0000000..51cca07 --- /dev/null +++ b/nebula_app/build/i18n/catalog.rs @@ -0,0 +1,99 @@ +use std::collections::BTreeMap; +use std::fmt; + +use serde::Deserialize; +use serde::de::{self, MapAccess, Visitor}; + +enum Node { + Text(String), + Group(BTreeMap), +} + +impl<'de> Deserialize<'de> for Node { + fn deserialize(deserializer: Deserializer) -> Result + where + Deserializer: de::Deserializer<'de>, + { + struct NodeVisitor; + impl<'de> Visitor<'de> for NodeVisitor { + type Value = Node; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a translation string or object") + } + + fn visit_str(self, value: &str) -> Result { + Ok(Node::Text(value.to_owned())) + } + + fn visit_string(self, value: String) -> Result { + Ok(Node::Text(value)) + } + + fn visit_map>(self, mut map: Map) -> Result { + let mut entries = BTreeMap::new(); + while let Some((key, value)) = map.next_entry::()? { + if key.is_empty() || entries.insert(key.clone(), value).is_some() { + return Err(de::Error::custom(format!("empty or duplicate key: {key:?}"))); + } + } + Ok(Node::Group(entries)) + } + } + deserializer.deserialize_any(NodeVisitor) + } +} + +pub fn parse(source: &str) -> Result, String> { + fn flatten( + node: Node, + prefix: &str, + output: &mut BTreeMap, + ) -> Result<(), String> { + match node { + Node::Text(message) => { + if prefix.is_empty() || message.trim().is_empty() { + return Err(format!("empty message or id: {prefix:?}")); + } + if output.insert(prefix.to_owned(), message).is_some() { + return Err(format!("duplicate flattened id: {prefix}")); + } + }, + Node::Group(entries) => { + for (key, value) in entries { + let key = if prefix.is_empty() { key } else { format!("{prefix}.{key}") }; + flatten(value, &key, output)?; + } + }, + } + Ok(()) + } + let root = serde_json::from_str(source).map_err(|error| error.to_string())?; + let mut output = BTreeMap::new(); + flatten(root, "", &mut output)?; + Ok(output) +} + +pub fn placeholders(message: &str) -> Result, String> { + let mut names = std::collections::BTreeSet::new(); + let mut rest = message; + while let Some(position) = rest.find(['{', '}']) { + rest = &rest[position..]; + if rest.starts_with("{{") || rest.starts_with("}}") { + rest = &rest[2..]; + continue; + } + if !rest.starts_with('{') { + return Err(format!("unmatched closing brace: {message}")); + } + let end = rest.find('}').ok_or_else(|| format!("unclosed placeholder: {message}"))?; + let name = &rest[1..end]; + if name.is_empty() || !name.bytes().all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') + { + return Err(format!("invalid placeholder {name:?}")); + } + names.insert(name); + rest = &rest[end + 1..]; + } + Ok(names) +} diff --git a/nebula_app/i18n/de-DE.json b/nebula_app/i18n/de-DE.json new file mode 100644 index 0000000..af6269f --- /dev/null +++ b/nebula_app/i18n/de-DE.json @@ -0,0 +1,177 @@ +{ + "language": { + "system": "Systemeinstellung", + "self_name": "Deutsch", + "zh_cn": "简体中文", + "en_us": "English" + }, + "settings": { + "sidebar": { + "application": "Anwendung", + "appearance": "Darstellung", + "profiles": "Terminal", + "providers": "KI-Anbieter", + "ssh": "SSH", + "network": "Netzwerk", + "interaction": "Interaktion", + "keymap": "Tastenbelegung", + "advanced": "Erweitert", + "backup": "Sicherung" + }, + "network": { + "title": "Netzwerkproxy", + "mode": { + "label": "Proxymodus", + "off": "Kein Proxy", + "system": "Systemeinstellung", + "custom": "Benutzerdefinierter Proxy" + }, + "address": { + "label": "Proxyadresse" + }, + "action": { + "test": "Netzwerk testen", + "testing": "Test läuft…" + }, + "route": { + "direct": "Direktverbindung", + "direct_address": "Direkte Adresse", + "custom_command": "Benutzerdefinierter Befehl" + } + }, + "font": { + "loading": "Systemschriftarten werden geladen…", + "no_matches": "Keine passenden Schriftarten" + } + }, + "common": { + "settings": "Einstellungen", + "language": "Sprache", + "about": "Über", + "theme": "Design", + "app_icon": "App-Symbol", + "application_icon": "Anwendungssymbol", + "font": "Schriftart", + "font_size": "Schriftgröße", + "size": "Größe", + "terminal_font": "Terminalschriftart", + "font_preview": "Schriftvorschau", + "increase_font": "Schrift vergrößern", + "decrease_font": "Schrift verkleinern", + "change_theme": "Design ändern", + "change_icon": "Symbol ändern", + "restore_defaults": "Standard wiederherstellen", + "reset": "Zurücksetzen", + "save": "Speichern", + "saved": "Gespeichert", + "cancel": "Abbrechen", + "confirm": "Bestätigen", + "close": "Schließen", + "open": "Öffnen", + "add": "Hinzufügen", + "remove": "Entfernen", + "delete": "Löschen", + "edit": "Bearbeiten", + "rename": "Umbenennen", + "copy": "Kopieren", + "paste": "Einfügen", + "cut": "Ausschneiden", + "select_all": "Alles auswählen", + "clear": "Leeren", + "search": "Suchen", + "search_settings": "Einstellungen suchen", + "no_results": "Keine Ergebnisse", + "no_matches": "Keine Treffer", + "refresh": "Aktualisieren", + "retry": "Erneut versuchen", + "loading": "Wird geladen…", + "on": "Ein", + "off": "Aus", + "enabled": "Aktiviert", + "disabled": "Deaktiviert", + "default": "Standard", + "custom": "Benutzerdefiniert", + "none": "Keine", + "auto": "Automatisch", + "all": "Alle", + "name": "Name", + "path": "Pfad", + "directory": "Verzeichnis", + "files": "Dateien", + "new_file": "Neue Datei", + "new_folder": "Neuer Ordner", + "new_tab": "Neuer Tab", + "new_window": "Neues Fenster", + "close_tab": "Tab schließen", + "duplicate_tab": "Tab duplizieren", + "terminal": "Terminal", + "connect": "Verbinden", + "disconnect": "Trennen", + "connected": "Verbunden", + "connecting": "Verbindung wird hergestellt…", + "host": "Host", + "username": "Benutzername", + "password": "Passwort", + "port": "Port", + "authentication": "Authentifizierung", + "private_key": "Privater Schlüssel", + "upload": "Hochladen", + "download": "Herunterladen", + "overwrite": "Überschreiben", + "skip": "Überspringen", + "keep_both": "Beide behalten", + "import": "Importieren", + "export": "Exportieren", + "choose": "Auswählen", + "browse": "Durchsuchen", + "version": "Version", + "check_updates": "Nach Updates suchen", + "checking": "Wird geprüft…", + "not_checked": "Noch nicht geprüft", + "up_to_date": "Aktuell", + "update_available": "Update verfügbar", + "view_update": "Update anzeigen", + "install_update": "Update installieren", + "later": "Später", + "light": "Hell", + "dark": "Dunkel", + "opacity": "Deckkraft", + "background": "Hintergrund", + "background_image": "Hintergrundbild", + "background_color": "Hintergrundfarbe", + "foreground": "Vordergrund", + "text": "Text", + "standard": "Standard", + "compact": "Kompakt", + "relaxed": "Locker", + "left": "Links", + "right": "Rechts", + "top": "Oben", + "bottom": "Unten", + "center": "Mitte", + "left_sidebar": "Linke Seitenleiste", + "scroll": "Scrollen", + "cursor": "Cursor", + "notifications": "Benachrichtigungen", + "sound": "Ton", + "visual": "Visuell", + "success": "Erfolgreich", + "failed": "Fehlgeschlagen", + "warning": "Warnung", + "details": "Details", + "help": "Hilfe", + "shortcuts": "Tastenkürzel", + "command": "Befehl", + "run": "Ausführen", + "stop": "Anhalten", + "history": "Verlauf", + "saved_commands": "Gespeicherte Befehle", + "session": "Sitzung", + "key": "Taste", + "action": "Aktion", + "update_notice": "Pebrel v{latest} ist verfügbar (aktuell v{current})", + "copied_lines": "{lines} Zeilen in die Zwischenablage kopiert", + "paste_lines": "{lines} Zeilen einfügen?", + "sent_to": "An {target} gesendet" + } +} diff --git a/nebula_app/i18n/en-US.json b/nebula_app/i18n/en-US.json index 16fe149..140eef6 100644 --- a/nebula_app/i18n/en-US.json +++ b/nebula_app/i18n/en-US.json @@ -9,7 +9,7 @@ "sidebar": { "application": "Application", "appearance": "Appearance", - "profiles": "Profiles", + "profiles": "Terminal", "providers": "AI Providers", "ssh": "SSH", "network": "Network", @@ -114,5 +114,138 @@ "http_status": "Service returned HTTP {status}", "start_failed": "Could not start the provider test: {error}" } + }, + "common": { + "settings": "Settings", + "language": "Language", + "about": "About", + "theme": "Theme", + "app_icon": "App icon", + "application_icon": "Application icon", + "font": "Font", + "font_size": "Font size", + "size": "Size", + "terminal_font": "Terminal font", + "font_preview": "Font preview", + "increase_font": "Increase font size", + "decrease_font": "Decrease font size", + "change_theme": "Change theme", + "change_icon": "Change icon", + "restore_defaults": "Restore defaults", + "reset": "Reset", + "save": "Save", + "saved": "Saved", + "cancel": "Cancel", + "confirm": "Confirm", + "close": "Close", + "open": "Open", + "add": "Add", + "remove": "Remove", + "delete": "Delete", + "edit": "Edit", + "rename": "Rename", + "copy": "Copy", + "paste": "Paste", + "cut": "Cut", + "select_all": "Select all", + "clear": "Clear", + "search": "Search", + "search_settings": "Search settings", + "no_results": "No results", + "no_matches": "No matches", + "refresh": "Refresh", + "retry": "Retry", + "loading": "Loading...", + "on": "On", + "off": "Off", + "enabled": "Enabled", + "disabled": "Disabled", + "default": "Default", + "custom": "Custom", + "none": "None", + "auto": "Auto", + "all": "All", + "name": "Name", + "path": "Path", + "directory": "Directory", + "files": "Files", + "new_file": "New file", + "new_folder": "New folder", + "new_tab": "New tab", + "new_window": "New window", + "close_tab": "Close tab", + "duplicate_tab": "Duplicate tab", + "terminal": "Terminal", + "connect": "Connect", + "disconnect": "Disconnect", + "connected": "Connected", + "connecting": "Connecting...", + "host": "Host", + "username": "Username", + "password": "Password", + "port": "Port", + "authentication": "Authentication", + "private_key": "Private key", + "upload": "Upload", + "download": "Download", + "overwrite": "Overwrite", + "skip": "Skip", + "keep_both": "Keep both", + "import": "Import", + "export": "Export", + "choose": "Choose", + "browse": "Browse", + "version": "Version", + "check_updates": "Check for updates", + "checking": "Checking...", + "not_checked": "Not checked yet", + "up_to_date": "Up to date", + "update_available": "Update available", + "view_update": "View update", + "install_update": "Install update", + "later": "Later", + "light": "Light", + "dark": "Dark", + "opacity": "Opacity", + "background": "Background", + "background_image": "Background image", + "background_color": "Background color", + "foreground": "Foreground", + "text": "Text", + "standard": "Standard", + "compact": "Compact", + "relaxed": "Relaxed", + "left": "Left", + "right": "Right", + "top": "Top", + "bottom": "Bottom", + "center": "Center", + "left_sidebar": "Left sidebar", + "scroll": "Scroll", + "cursor": "Cursor", + "notifications": "Notifications", + "sound": "Sound", + "visual": "Visual", + "success": "Success", + "failed": "Failed", + "warning": "Warning", + "details": "Details", + "help": "Help", + "shortcuts": "Keyboard shortcuts", + "command": "Command", + "run": "Run", + "stop": "Stop", + "history": "History", + "saved_commands": "Saved commands", + "session": "Session", + "key": "Key", + "action": "Action", + "update_notice": "Pebrel v{latest} is available (current v{current})", + "copied_lines": "Copied {lines} lines to clipboard", + "paste_lines": "Paste {lines} lines?", + "sent_to": "Sent to {target}", + "yes": "Yes", + "no": "No", + "confirmation_expired": "The confirmation request changed or is no longer active." } } diff --git a/nebula_app/i18n/es-ES.json b/nebula_app/i18n/es-ES.json new file mode 100644 index 0000000..080b3c6 --- /dev/null +++ b/nebula_app/i18n/es-ES.json @@ -0,0 +1,177 @@ +{ + "language": { + "system": "Seguir el sistema", + "self_name": "Español", + "zh_cn": "简体中文", + "en_us": "English" + }, + "settings": { + "sidebar": { + "application": "Aplicación", + "appearance": "Apariencia", + "profiles": "Terminal", + "providers": "Proveedores de IA", + "ssh": "SSH", + "network": "Red", + "interaction": "Interacción", + "keymap": "Atajos de teclado", + "advanced": "Avanzado", + "backup": "Copia de seguridad" + }, + "network": { + "title": "Proxy de red", + "mode": { + "label": "Modo de proxy", + "off": "Sin proxy", + "system": "Seguir el sistema", + "custom": "Proxy personalizado" + }, + "address": { + "label": "Dirección del proxy" + }, + "action": { + "test": "Probar red", + "testing": "Probando…" + }, + "route": { + "direct": "Conexión directa", + "direct_address": "Dirección directa", + "custom_command": "Comando personalizado" + } + }, + "font": { + "loading": "Cargando fuentes del sistema…", + "no_matches": "No hay fuentes coincidentes" + } + }, + "common": { + "settings": "Configuración", + "language": "Idioma", + "about": "Acerca de", + "theme": "Tema", + "app_icon": "Icono de la aplicación", + "application_icon": "Icono de la aplicación", + "font": "Fuente", + "font_size": "Tamaño de fuente", + "size": "Tamaño", + "terminal_font": "Fuente del terminal", + "font_preview": "Vista previa de fuente", + "increase_font": "Aumentar fuente", + "decrease_font": "Reducir fuente", + "change_theme": "Cambiar tema", + "change_icon": "Cambiar icono", + "restore_defaults": "Restaurar valores predeterminados", + "reset": "Restablecer", + "save": "Guardar", + "saved": "Guardado", + "cancel": "Cancelar", + "confirm": "Confirmar", + "close": "Cerrar", + "open": "Abrir", + "add": "Añadir", + "remove": "Quitar", + "delete": "Eliminar", + "edit": "Editar", + "rename": "Cambiar nombre", + "copy": "Copiar", + "paste": "Pegar", + "cut": "Cortar", + "select_all": "Seleccionar todo", + "clear": "Limpiar", + "search": "Buscar", + "search_settings": "Buscar ajustes", + "no_results": "Sin resultados", + "no_matches": "Sin coincidencias", + "refresh": "Actualizar", + "retry": "Reintentar", + "loading": "Cargando…", + "on": "Activado", + "off": "Desactivado", + "enabled": "Habilitado", + "disabled": "Deshabilitado", + "default": "Predeterminado", + "custom": "Personalizado", + "none": "Ninguno", + "auto": "Automático", + "all": "Todo", + "name": "Nombre", + "path": "Ruta", + "directory": "Directorio", + "files": "Archivos", + "new_file": "Nuevo archivo", + "new_folder": "Nueva carpeta", + "new_tab": "Nueva pestaña", + "new_window": "Nueva ventana", + "close_tab": "Cerrar pestaña", + "duplicate_tab": "Duplicar pestaña", + "terminal": "Terminal", + "connect": "Conectar", + "disconnect": "Desconectar", + "connected": "Conectado", + "connecting": "Conectando…", + "host": "Servidor", + "username": "Nombre de usuario", + "password": "Contraseña", + "port": "Puerto", + "authentication": "Autenticación", + "private_key": "Clave privada", + "upload": "Subir", + "download": "Descargar", + "overwrite": "Sobrescribir", + "skip": "Omitir", + "keep_both": "Conservar ambos", + "import": "Importar", + "export": "Exportar", + "choose": "Elegir", + "browse": "Examinar", + "version": "Versión", + "check_updates": "Buscar actualizaciones", + "checking": "Comprobando…", + "not_checked": "Sin comprobar", + "up_to_date": "Actualizado", + "update_available": "Actualización disponible", + "view_update": "Ver actualización", + "install_update": "Instalar actualización", + "later": "Más tarde", + "light": "Claro", + "dark": "Oscuro", + "opacity": "Opacidad", + "background": "Fondo", + "background_image": "Imagen de fondo", + "background_color": "Color de fondo", + "foreground": "Primer plano", + "text": "Texto", + "standard": "Estándar", + "compact": "Compacto", + "relaxed": "Amplio", + "left": "Izquierda", + "right": "Derecha", + "top": "Arriba", + "bottom": "Abajo", + "center": "Centro", + "left_sidebar": "Barra lateral izquierda", + "scroll": "Desplazar", + "cursor": "Cursor", + "notifications": "Notificaciones", + "sound": "Sonido", + "visual": "Visual", + "success": "Correcto", + "failed": "Error", + "warning": "Advertencia", + "details": "Detalles", + "help": "Ayuda", + "shortcuts": "Atajos de teclado", + "command": "Comando", + "run": "Ejecutar", + "stop": "Detener", + "history": "Historial", + "saved_commands": "Comandos guardados", + "session": "Sesión", + "key": "Tecla", + "action": "Acción", + "update_notice": "Pebrel v{latest} está disponible (actual: v{current})", + "copied_lines": "Se copiaron {lines} líneas al portapapeles", + "paste_lines": "¿Pegar {lines} líneas?", + "sent_to": "Enviado a {target}" + } +} diff --git a/nebula_app/i18n/fr-FR.json b/nebula_app/i18n/fr-FR.json new file mode 100644 index 0000000..1c2afd0 --- /dev/null +++ b/nebula_app/i18n/fr-FR.json @@ -0,0 +1,248 @@ +{ + "language": { + "system": "Suivre le système", + "self_name": "Français", + "zh_cn": "简体中文", + "en_us": "English" + }, + "settings": { + "sidebar": { + "application": "Application", + "appearance": "Apparence", + "profiles": "Terminal", + "providers": "Fournisseurs d’IA", + "ssh": "SSH", + "network": "Réseau", + "interaction": "Interaction", + "keymap": "Raccourcis clavier", + "advanced": "Avancé", + "backup": "Sauvegarde" + }, + "network": { + "title": "Proxy réseau", + "mode": { + "label": "Mode proxy", + "off": "Sans proxy", + "system": "Suivre le système", + "custom": "Proxy personnalisé" + }, + "address": { + "label": "Adresse du proxy" + }, + "action": { + "test": "Tester le réseau", + "testing": "Test en cours…" + }, + "route": { + "direct": "Connexion directe", + "direct_address": "Adresse directe", + "custom_command": "Commande personnalisée", + "proxy_server": "Proxy {server}", + "ssh_jump": "SSH {target}" + }, + "status": { + "idle": "Vérifier l’accès au réseau avec les paramètres actuels", + "running": "Test de l’accès au réseau avec les paramètres actuels…", + "success": "Connexion réseau réussie via {route} en {elapsed_ms} ms", + "failed": "Échec du test : {error}", + "unexpected_end": "Le test réseau s’est arrêté de manière inattendue ; réessayez", + "start_failed": "Impossible de démarrer le test réseau : {error}" + }, + "failure": { + "load_settings": "Impossible de lire les paramètres du proxy : {error}", + "invalid_settings": "Les paramètres du proxy sont invalides : {error}", + "timeout": "Le test réseau a expiré après {seconds} secondes", + "send_request": "Impossible d’envoyer la requête de test : {error}", + "read_response": "Impossible de lire la réponse du test : {error}", + "invalid_http_status_line": "Le site de test n’a pas renvoyé de ligne de statut HTTP valide : {line}", + "http_status": "Le site de test a renvoyé HTTP {status}", + "proxy_server": "Impossible d’accéder au réseau via le proxy {server} : {error}", + "custom_command": "La commande de proxy personnalisée a échoué : {error}", + "jump_task": "Impossible de préparer le serveur de rebond SSH : {error}", + "jump_resolve": "Impossible de résoudre le serveur de rebond SSH {target} : {error}", + "jump_connect": "Impossible de se connecter au serveur de rebond SSH {target} : {error}", + "jump_channel": "Impossible d’ouvrir un canal sortant via le serveur de rebond SSH {target} : {error}", + "direct": "Impossible de se connecter directement au site de test : {error}" + } + }, + "font": { + "loading": "Chargement des polices système…", + "no_matches": "Aucune police correspondante", + "fallback_description": "La première famille est prioritaire ; les glyphes manquants sont recherchés dans les familles suivantes, puis dans la police Maple intégrée en dernier recours.", + "group_description": "La première famille est principale ; les autres sont utilisées dans l’ordre de repli." + }, + "appearance": { + "background_color_description": "Remplace le fond du terminal fourni par le thème. Cela modifie aussi la couleur signalée aux applications par OSC 11, utilisée par les interfaces en terminal pour choisir le mode clair ou sombre.", + "background_cover_title": "Étendre l’image d’arrière-plan sous les commandes de la fenêtre ?", + "background_cover_description": "L’image s’étendra sous la barre de titre, les boutons de fenêtre, les onglets et la barre latérale SSH. Les images peu contrastées peuvent rendre les commandes moins visibles ; une opacité minimale est conservée." + }, + "keymap": { + "conflict": "{combo} est affecté à « {first} » et « {second} » ; seule la première action « {first} » sera exécutée.", + "help": "Cliquez sur une ligne pour réaffecter · Retour arrière rétablit la valeur par défaut · Échap annule" + }, + "input": { + "backup_secret": "Les identifiants sont stockés uniquement dans le gestionnaire d’identifiants du système" + }, + "ssh": { + "default_keys": "Sans indication, IdentityFile et les clés privées id_* par défaut sont utilisés.", + "address_hint": "Accepte user@host ; vous pouvez aussi coller ssh://host:2222", + "auth_auto_description": "Essaie d’abord les clés privées disponibles, puis demande un mot de passe. Ce mode convient à la plupart des cas.", + "auth_interactive_description": "Ne conserve aucun identifiant et les demande dans le terminal à la connexion, y compris pour l’authentification à deux facteurs.", + "save_password": "Enregistrer dans le Gestionnaire d’identifiants Windows", + "hide_config_host": "Masquer (les sources de configuration ne peuvent pas être supprimées)", + "empty_hint": "Utilisez « + Ajouter un hôte » ou gérez les alias dans ~/.ssh/config", + "shared_data_hint": "Partage les données avec l’ancienne interface. Supprimer un alias de configuration le masque ; il peut être restauré." + } + }, + "common": { + "settings": "Paramètres", + "language": "Langue", + "about": "À propos", + "theme": "Thème", + "app_icon": "Icône de l’application", + "application_icon": "Icône de l’application", + "font": "Police", + "font_size": "Taille de police", + "size": "Taille", + "terminal_font": "Police du terminal", + "font_preview": "Aperçu de la police", + "increase_font": "Agrandir la police", + "decrease_font": "Réduire la police", + "change_theme": "Changer de thème", + "change_icon": "Changer d’icône", + "restore_defaults": "Rétablir les valeurs par défaut", + "reset": "Réinitialiser", + "save": "Enregistrer", + "saved": "Enregistré", + "cancel": "Annuler", + "confirm": "Confirmer", + "close": "Fermer", + "open": "Ouvrir", + "add": "Ajouter", + "remove": "Retirer", + "delete": "Supprimer", + "edit": "Modifier", + "rename": "Renommer", + "copy": "Copier", + "paste": "Coller", + "cut": "Couper", + "select_all": "Tout sélectionner", + "clear": "Effacer", + "search": "Rechercher", + "search_settings": "Rechercher un paramètre", + "no_results": "Aucun résultat", + "no_matches": "Aucune correspondance", + "refresh": "Actualiser", + "retry": "Réessayer", + "loading": "Chargement…", + "on": "Activé", + "off": "Désactivé", + "enabled": "Activé", + "disabled": "Désactivé", + "default": "Par défaut", + "custom": "Personnalisé", + "none": "Aucun", + "auto": "Auto", + "all": "Tout", + "name": "Nom", + "path": "Chemin", + "directory": "Dossier", + "files": "Fichiers", + "new_file": "Nouveau fichier", + "new_folder": "Nouveau dossier", + "new_tab": "Nouvel onglet", + "new_window": "Nouvelle fenêtre", + "close_tab": "Fermer l’onglet", + "duplicate_tab": "Dupliquer l’onglet", + "terminal": "Terminal", + "connect": "Connecter", + "disconnect": "Déconnecter", + "connected": "Connecté", + "connecting": "Connexion…", + "host": "Hôte", + "username": "Nom d’utilisateur", + "password": "Mot de passe", + "port": "Port", + "authentication": "Authentification", + "private_key": "Clé privée", + "upload": "Téléverser", + "download": "Télécharger", + "overwrite": "Remplacer", + "skip": "Ignorer", + "keep_both": "Conserver les deux", + "import": "Importer", + "export": "Exporter", + "choose": "Choisir", + "browse": "Parcourir", + "version": "Version", + "check_updates": "Rechercher des mises à jour", + "checking": "Vérification…", + "not_checked": "Pas encore vérifié", + "up_to_date": "À jour", + "update_available": "Mise à jour disponible", + "view_update": "Voir la mise à jour", + "install_update": "Installer la mise à jour", + "later": "Plus tard", + "light": "Clair", + "dark": "Sombre", + "opacity": "Opacité", + "background": "Arrière-plan", + "background_image": "Image d’arrière-plan", + "background_color": "Couleur d’arrière-plan", + "foreground": "Premier plan", + "text": "Texte", + "standard": "Standard", + "compact": "Compact", + "relaxed": "Aéré", + "left": "Gauche", + "right": "Droite", + "top": "Haut", + "bottom": "Bas", + "center": "Centre", + "left_sidebar": "Barre latérale gauche", + "scroll": "Défilement", + "cursor": "Curseur", + "notifications": "Notifications", + "sound": "Son", + "visual": "Visuel", + "success": "Réussi", + "failed": "Échec", + "warning": "Avertissement", + "details": "Détails", + "help": "Aide", + "shortcuts": "Raccourcis clavier", + "command": "Commande", + "run": "Exécuter", + "stop": "Arrêter", + "history": "Historique", + "saved_commands": "Commandes enregistrées", + "session": "Session", + "key": "Touche", + "action": "Action", + "update_notice": "Pebrel v{latest} est disponible (version actuelle : v{current})", + "copied_lines": "{lines} lignes copiées dans le presse-papiers", + "paste_lines": "Coller {lines} lignes ?", + "sent_to": "Envoyé à {target}" + }, + "provider": { + "test": { + "success": "Connexion réussie (HTTP {status})", + "invalid_endpoint": "L’adresse doit commencer par http:// ou https://", + "missing_model": "Saisissez d’abord un modèle par défaut", + "missing_api_key": "Enregistrez d’abord une clé API", + "credential_read_failed": "Impossible de lire la clé API dans le gestionnaire d’identifiants", + "invalid_credential_encoding": "La clé API du gestionnaire d’identifiants présente un encodage invalide", + "timeout": "La connexion a expiré", + "host_not_found": "Serveur introuvable", + "connection_failed": "Impossible de se connecter au serveur", + "io_error": "Erreur réseau : {kind}", + "tls_failed": "Échec de la négociation TLS", + "request_failed": "Échec de la requête", + "auth_failed": "Échec de l’authentification (HTTP {status}) ; vérifiez la clé API", + "endpoint_not_found": "Point de terminaison introuvable (HTTP {status}) ; vérifiez l’adresse de l’API", + "rate_limited": "Limite de requêtes atteinte (HTTP {status}) ; le service est accessible, mais le quota ne peut pas être vérifié actuellement", + "http_status": "Le service a renvoyé HTTP {status}", + "start_failed": "Impossible de démarrer le test du fournisseur : {error}" + } + } +} diff --git a/nebula_app/i18n/it-IT.json b/nebula_app/i18n/it-IT.json new file mode 100644 index 0000000..2c05661 --- /dev/null +++ b/nebula_app/i18n/it-IT.json @@ -0,0 +1,177 @@ +{ + "language": { + "system": "Segui il sistema", + "self_name": "Italiano", + "zh_cn": "简体中文", + "en_us": "English" + }, + "settings": { + "sidebar": { + "application": "Applicazione", + "appearance": "Aspetto", + "profiles": "Terminale", + "providers": "Fornitori IA", + "ssh": "SSH", + "network": "Rete", + "interaction": "Interazione", + "keymap": "Scorciatoie da tastiera", + "advanced": "Avanzate", + "backup": "Backup" + }, + "network": { + "title": "Proxy di rete", + "mode": { + "label": "Modalità proxy", + "off": "Nessun proxy", + "system": "Segui il sistema", + "custom": "Proxy personalizzato" + }, + "address": { + "label": "Indirizzo proxy" + }, + "action": { + "test": "Verifica rete", + "testing": "Verifica in corso…" + }, + "route": { + "direct": "Connessione diretta", + "direct_address": "Indirizzo diretto", + "custom_command": "Comando personalizzato" + } + }, + "font": { + "loading": "Caricamento dei caratteri di sistema…", + "no_matches": "Nessun carattere corrispondente" + } + }, + "common": { + "settings": "Impostazioni", + "language": "Lingua", + "about": "Informazioni", + "theme": "Tema", + "app_icon": "Icona dell’app", + "application_icon": "Icona dell’applicazione", + "font": "Carattere", + "font_size": "Dimensione carattere", + "size": "Dimensione", + "terminal_font": "Carattere del terminale", + "font_preview": "Anteprima carattere", + "increase_font": "Ingrandisci carattere", + "decrease_font": "Riduci carattere", + "change_theme": "Cambia tema", + "change_icon": "Cambia icona", + "restore_defaults": "Ripristina predefiniti", + "reset": "Ripristina", + "save": "Salva", + "saved": "Salvato", + "cancel": "Annulla", + "confirm": "Conferma", + "close": "Chiudi", + "open": "Apri", + "add": "Aggiungi", + "remove": "Rimuovi", + "delete": "Elimina", + "edit": "Modifica", + "rename": "Rinomina", + "copy": "Copia", + "paste": "Incolla", + "cut": "Taglia", + "select_all": "Seleziona tutto", + "clear": "Cancella", + "search": "Cerca", + "search_settings": "Cerca impostazioni", + "no_results": "Nessun risultato", + "no_matches": "Nessuna corrispondenza", + "refresh": "Aggiorna", + "retry": "Riprova", + "loading": "Caricamento…", + "on": "Attivo", + "off": "Disattivo", + "enabled": "Abilitato", + "disabled": "Disabilitato", + "default": "Predefinito", + "custom": "Personalizzato", + "none": "Nessuno", + "auto": "Automatico", + "all": "Tutto", + "name": "Nome", + "path": "Percorso", + "directory": "Cartella", + "files": "File", + "new_file": "Nuovo file", + "new_folder": "Nuova cartella", + "new_tab": "Nuova scheda", + "new_window": "Nuova finestra", + "close_tab": "Chiudi scheda", + "duplicate_tab": "Duplica scheda", + "terminal": "Terminale", + "connect": "Connetti", + "disconnect": "Disconnetti", + "connected": "Connesso", + "connecting": "Connessione…", + "host": "Host", + "username": "Nome utente", + "password": "Password", + "port": "Porta", + "authentication": "Autenticazione", + "private_key": "Chiave privata", + "upload": "Carica", + "download": "Scarica", + "overwrite": "Sovrascrivi", + "skip": "Salta", + "keep_both": "Mantieni entrambi", + "import": "Importa", + "export": "Esporta", + "choose": "Scegli", + "browse": "Sfoglia", + "version": "Versione", + "check_updates": "Cerca aggiornamenti", + "checking": "Verifica in corso…", + "not_checked": "Non ancora verificato", + "up_to_date": "Aggiornato", + "update_available": "Aggiornamento disponibile", + "view_update": "Mostra aggiornamento", + "install_update": "Installa aggiornamento", + "later": "Più tardi", + "light": "Chiaro", + "dark": "Scuro", + "opacity": "Opacità", + "background": "Sfondo", + "background_image": "Immagine di sfondo", + "background_color": "Colore di sfondo", + "foreground": "Primo piano", + "text": "Testo", + "standard": "Standard", + "compact": "Compatto", + "relaxed": "Ampio", + "left": "Sinistra", + "right": "Destra", + "top": "Alto", + "bottom": "Basso", + "center": "Centro", + "left_sidebar": "Barra laterale sinistra", + "scroll": "Scorri", + "cursor": "Cursore", + "notifications": "Notifiche", + "sound": "Suono", + "visual": "Visivo", + "success": "Riuscito", + "failed": "Non riuscito", + "warning": "Avviso", + "details": "Dettagli", + "help": "Aiuto", + "shortcuts": "Scorciatoie da tastiera", + "command": "Comando", + "run": "Esegui", + "stop": "Interrompi", + "history": "Cronologia", + "saved_commands": "Comandi salvati", + "session": "Sessione", + "key": "Tasto", + "action": "Azione", + "update_notice": "Pebrel v{latest} è disponibile (attuale: v{current})", + "copied_lines": "{lines} righe copiate negli appunti", + "paste_lines": "Incollare {lines} righe?", + "sent_to": "Inviato a {target}" + } +} diff --git a/nebula_app/i18n/ja-JP.json b/nebula_app/i18n/ja-JP.json new file mode 100644 index 0000000..0a56ca2 --- /dev/null +++ b/nebula_app/i18n/ja-JP.json @@ -0,0 +1,177 @@ +{ + "language": { + "system": "システムに従う", + "self_name": "日本語", + "zh_cn": "简体中文", + "en_us": "English" + }, + "settings": { + "sidebar": { + "application": "アプリケーション", + "appearance": "外観", + "profiles": "ターミナル", + "providers": "AI プロバイダー", + "ssh": "SSH", + "network": "ネットワーク", + "interaction": "操作", + "keymap": "キー割り当て", + "advanced": "詳細設定", + "backup": "バックアップ" + }, + "network": { + "title": "ネットワークプロキシ", + "mode": { + "label": "プロキシモード", + "off": "プロキシなし", + "system": "システムに従う", + "custom": "カスタムプロキシ" + }, + "address": { + "label": "プロキシアドレス" + }, + "action": { + "test": "ネットワークをテスト", + "testing": "テスト中…" + }, + "route": { + "direct": "直接接続", + "direct_address": "直接アドレス", + "custom_command": "カスタムコマンド" + } + }, + "font": { + "loading": "システムフォントを読み込み中…", + "no_matches": "一致するフォントがありません" + } + }, + "common": { + "settings": "設定", + "language": "言語", + "about": "このアプリについて", + "theme": "テーマ", + "app_icon": "アプリアイコン", + "application_icon": "アプリアイコン", + "font": "フォント", + "font_size": "フォントサイズ", + "size": "サイズ", + "terminal_font": "ターミナルフォント", + "font_preview": "フォントのプレビュー", + "increase_font": "文字を大きくする", + "decrease_font": "文字を小さくする", + "change_theme": "テーマを変更", + "change_icon": "アイコンを変更", + "restore_defaults": "既定値に戻す", + "reset": "リセット", + "save": "保存", + "saved": "保存しました", + "cancel": "キャンセル", + "confirm": "確認", + "close": "閉じる", + "open": "開く", + "add": "追加", + "remove": "削除", + "delete": "削除", + "edit": "編集", + "rename": "名前を変更", + "copy": "コピー", + "paste": "貼り付け", + "cut": "切り取り", + "select_all": "すべて選択", + "clear": "クリア", + "search": "検索", + "search_settings": "設定を検索", + "no_results": "結果がありません", + "no_matches": "一致する項目がありません", + "refresh": "更新", + "retry": "再試行", + "loading": "読み込み中…", + "on": "オン", + "off": "オフ", + "enabled": "有効", + "disabled": "無効", + "default": "既定", + "custom": "カスタム", + "none": "なし", + "auto": "自動", + "all": "すべて", + "name": "名前", + "path": "パス", + "directory": "ディレクトリ", + "files": "ファイル", + "new_file": "新しいファイル", + "new_folder": "新しいフォルダー", + "new_tab": "新しいタブ", + "new_window": "新しいウィンドウ", + "close_tab": "タブを閉じる", + "duplicate_tab": "タブを複製", + "terminal": "ターミナル", + "connect": "接続", + "disconnect": "切断", + "connected": "接続済み", + "connecting": "接続中…", + "host": "ホスト", + "username": "ユーザー名", + "password": "パスワード", + "port": "ポート", + "authentication": "認証", + "private_key": "秘密鍵", + "upload": "アップロード", + "download": "ダウンロード", + "overwrite": "上書き", + "skip": "スキップ", + "keep_both": "両方を保持", + "import": "インポート", + "export": "エクスポート", + "choose": "選択", + "browse": "参照", + "version": "バージョン", + "check_updates": "更新を確認", + "checking": "確認中…", + "not_checked": "未確認", + "up_to_date": "最新です", + "update_available": "更新があります", + "view_update": "更新を表示", + "install_update": "更新をインストール", + "later": "後で", + "light": "ライト", + "dark": "ダーク", + "opacity": "不透明度", + "background": "背景", + "background_image": "背景画像", + "background_color": "背景色", + "foreground": "前景", + "text": "テキスト", + "standard": "標準", + "compact": "コンパクト", + "relaxed": "ゆったり", + "left": "左", + "right": "右", + "top": "上", + "bottom": "下", + "center": "中央", + "left_sidebar": "左サイドバー", + "scroll": "スクロール", + "cursor": "カーソル", + "notifications": "通知", + "sound": "サウンド", + "visual": "視覚通知", + "success": "成功", + "failed": "失敗", + "warning": "警告", + "details": "詳細", + "help": "ヘルプ", + "shortcuts": "キーボードショートカット", + "command": "コマンド", + "run": "実行", + "stop": "停止", + "history": "履歴", + "saved_commands": "保存済みコマンド", + "session": "セッション", + "key": "キー", + "action": "アクション", + "update_notice": "Pebrel v{latest} を利用できます(現在 v{current})", + "copied_lines": "{lines} 行をクリップボードにコピーしました", + "paste_lines": "{lines} 行を貼り付けますか?", + "sent_to": "{target} に送信しました" + } +} diff --git a/nebula_app/i18n/ko-KR.json b/nebula_app/i18n/ko-KR.json new file mode 100644 index 0000000..467393f --- /dev/null +++ b/nebula_app/i18n/ko-KR.json @@ -0,0 +1,177 @@ +{ + "language": { + "system": "시스템 설정 따르기", + "self_name": "한국어", + "zh_cn": "简体中文", + "en_us": "English" + }, + "settings": { + "sidebar": { + "application": "애플리케이션", + "appearance": "모양", + "profiles": "터미널", + "providers": "AI 공급자", + "ssh": "SSH", + "network": "네트워크", + "interaction": "상호 작용", + "keymap": "키 바인딩", + "advanced": "고급", + "backup": "백업" + }, + "network": { + "title": "네트워크 프록시", + "mode": { + "label": "프록시 모드", + "off": "프록시 사용 안 함", + "system": "시스템 설정 따르기", + "custom": "사용자 지정 프록시" + }, + "address": { + "label": "프록시 주소" + }, + "action": { + "test": "네트워크 테스트", + "testing": "테스트 중…" + }, + "route": { + "direct": "직접 연결", + "direct_address": "직접 주소", + "custom_command": "사용자 지정 명령" + } + }, + "font": { + "loading": "시스템 글꼴을 불러오는 중…", + "no_matches": "일치하는 글꼴이 없습니다" + } + }, + "common": { + "settings": "설정", + "language": "언어", + "about": "정보", + "theme": "테마", + "app_icon": "앱 아이콘", + "application_icon": "앱 아이콘", + "font": "글꼴", + "font_size": "글꼴 크기", + "size": "크기", + "terminal_font": "터미널 글꼴", + "font_preview": "글꼴 미리 보기", + "increase_font": "글꼴 크기 늘리기", + "decrease_font": "글꼴 크기 줄이기", + "change_theme": "테마 변경", + "change_icon": "아이콘 변경", + "restore_defaults": "기본값 복원", + "reset": "초기화", + "save": "저장", + "saved": "저장됨", + "cancel": "취소", + "confirm": "확인", + "close": "닫기", + "open": "열기", + "add": "추가", + "remove": "제거", + "delete": "삭제", + "edit": "편집", + "rename": "이름 바꾸기", + "copy": "복사", + "paste": "붙여넣기", + "cut": "잘라내기", + "select_all": "모두 선택", + "clear": "지우기", + "search": "검색", + "search_settings": "설정 검색", + "no_results": "결과 없음", + "no_matches": "일치 항목 없음", + "refresh": "새로 고침", + "retry": "다시 시도", + "loading": "불러오는 중…", + "on": "켜짐", + "off": "꺼짐", + "enabled": "활성화됨", + "disabled": "비활성화됨", + "default": "기본값", + "custom": "사용자 지정", + "none": "없음", + "auto": "자동", + "all": "모두", + "name": "이름", + "path": "경로", + "directory": "디렉터리", + "files": "파일", + "new_file": "새 파일", + "new_folder": "새 폴더", + "new_tab": "새 탭", + "new_window": "새 창", + "close_tab": "탭 닫기", + "duplicate_tab": "탭 복제", + "terminal": "터미널", + "connect": "연결", + "disconnect": "연결 해제", + "connected": "연결됨", + "connecting": "연결 중…", + "host": "호스트", + "username": "사용자 이름", + "password": "암호", + "port": "포트", + "authentication": "인증", + "private_key": "개인 키", + "upload": "업로드", + "download": "다운로드", + "overwrite": "덮어쓰기", + "skip": "건너뛰기", + "keep_both": "둘 다 유지", + "import": "가져오기", + "export": "내보내기", + "choose": "선택", + "browse": "찾아보기", + "version": "버전", + "check_updates": "업데이트 확인", + "checking": "확인 중…", + "not_checked": "아직 확인하지 않음", + "up_to_date": "최신 상태", + "update_available": "업데이트 가능", + "view_update": "업데이트 보기", + "install_update": "업데이트 설치", + "later": "나중에", + "light": "밝게", + "dark": "어둡게", + "opacity": "불투명도", + "background": "배경", + "background_image": "배경 이미지", + "background_color": "배경색", + "foreground": "전경", + "text": "텍스트", + "standard": "표준", + "compact": "좁게", + "relaxed": "넓게", + "left": "왼쪽", + "right": "오른쪽", + "top": "위쪽", + "bottom": "아래쪽", + "center": "가운데", + "left_sidebar": "왼쪽 사이드바", + "scroll": "스크롤", + "cursor": "커서", + "notifications": "알림", + "sound": "소리", + "visual": "시각 효과", + "success": "성공", + "failed": "실패", + "warning": "경고", + "details": "세부 정보", + "help": "도움말", + "shortcuts": "키보드 단축키", + "command": "명령", + "run": "실행", + "stop": "중지", + "history": "기록", + "saved_commands": "저장된 명령", + "session": "세션", + "key": "키", + "action": "동작", + "update_notice": "Pebrel v{latest} 사용 가능(현재 v{current})", + "copied_lines": "{lines}줄을 클립보드에 복사했습니다", + "paste_lines": "{lines}줄을 붙여넣을까요?", + "sent_to": "{target}(으)로 전송했습니다" + } +} diff --git a/nebula_app/i18n/pt-BR.json b/nebula_app/i18n/pt-BR.json new file mode 100644 index 0000000..d8a00b9 --- /dev/null +++ b/nebula_app/i18n/pt-BR.json @@ -0,0 +1,177 @@ +{ + "language": { + "system": "Seguir o sistema", + "self_name": "Português (Brasil)", + "zh_cn": "简体中文", + "en_us": "English" + }, + "settings": { + "sidebar": { + "application": "Aplicativo", + "appearance": "Aparência", + "profiles": "Terminal", + "providers": "Provedores de IA", + "ssh": "SSH", + "network": "Rede", + "interaction": "Interação", + "keymap": "Atalhos de teclado", + "advanced": "Avançado", + "backup": "Backup" + }, + "network": { + "title": "Proxy de rede", + "mode": { + "label": "Modo de proxy", + "off": "Sem proxy", + "system": "Seguir o sistema", + "custom": "Proxy personalizado" + }, + "address": { + "label": "Endereço do proxy" + }, + "action": { + "test": "Testar rede", + "testing": "Testando…" + }, + "route": { + "direct": "Conexão direta", + "direct_address": "Endereço direto", + "custom_command": "Comando personalizado" + } + }, + "font": { + "loading": "Carregando fontes do sistema…", + "no_matches": "Nenhuma fonte correspondente" + } + }, + "common": { + "settings": "Configurações", + "language": "Idioma", + "about": "Sobre", + "theme": "Tema", + "app_icon": "Ícone do aplicativo", + "application_icon": "Ícone do aplicativo", + "font": "Fonte", + "font_size": "Tamanho da fonte", + "size": "Tamanho", + "terminal_font": "Fonte do terminal", + "font_preview": "Prévia da fonte", + "increase_font": "Aumentar fonte", + "decrease_font": "Diminuir fonte", + "change_theme": "Alterar tema", + "change_icon": "Alterar ícone", + "restore_defaults": "Restaurar padrões", + "reset": "Redefinir", + "save": "Salvar", + "saved": "Salvo", + "cancel": "Cancelar", + "confirm": "Confirmar", + "close": "Fechar", + "open": "Abrir", + "add": "Adicionar", + "remove": "Remover", + "delete": "Excluir", + "edit": "Editar", + "rename": "Renomear", + "copy": "Copiar", + "paste": "Colar", + "cut": "Recortar", + "select_all": "Selecionar tudo", + "clear": "Limpar", + "search": "Pesquisar", + "search_settings": "Pesquisar configurações", + "no_results": "Nenhum resultado", + "no_matches": "Nenhuma correspondência", + "refresh": "Atualizar", + "retry": "Tentar novamente", + "loading": "Carregando…", + "on": "Ativado", + "off": "Desativado", + "enabled": "Habilitado", + "disabled": "Desabilitado", + "default": "Padrão", + "custom": "Personalizado", + "none": "Nenhum", + "auto": "Automático", + "all": "Todos", + "name": "Nome", + "path": "Caminho", + "directory": "Diretório", + "files": "Arquivos", + "new_file": "Novo arquivo", + "new_folder": "Nova pasta", + "new_tab": "Nova aba", + "new_window": "Nova janela", + "close_tab": "Fechar aba", + "duplicate_tab": "Duplicar aba", + "terminal": "Terminal", + "connect": "Conectar", + "disconnect": "Desconectar", + "connected": "Conectado", + "connecting": "Conectando…", + "host": "Host", + "username": "Nome de usuário", + "password": "Senha", + "port": "Porta", + "authentication": "Autenticação", + "private_key": "Chave privada", + "upload": "Enviar", + "download": "Baixar", + "overwrite": "Substituir", + "skip": "Ignorar", + "keep_both": "Manter ambos", + "import": "Importar", + "export": "Exportar", + "choose": "Escolher", + "browse": "Procurar", + "version": "Versão", + "check_updates": "Verificar atualizações", + "checking": "Verificando…", + "not_checked": "Ainda não verificado", + "up_to_date": "Atualizado", + "update_available": "Atualização disponível", + "view_update": "Ver atualização", + "install_update": "Instalar atualização", + "later": "Mais tarde", + "light": "Claro", + "dark": "Escuro", + "opacity": "Opacidade", + "background": "Fundo", + "background_image": "Imagem de fundo", + "background_color": "Cor de fundo", + "foreground": "Primeiro plano", + "text": "Texto", + "standard": "Padrão", + "compact": "Compacto", + "relaxed": "Amplo", + "left": "Esquerda", + "right": "Direita", + "top": "Topo", + "bottom": "Base", + "center": "Centro", + "left_sidebar": "Barra lateral esquerda", + "scroll": "Rolar", + "cursor": "Cursor", + "notifications": "Notificações", + "sound": "Som", + "visual": "Visual", + "success": "Sucesso", + "failed": "Falhou", + "warning": "Aviso", + "details": "Detalhes", + "help": "Ajuda", + "shortcuts": "Atalhos de teclado", + "command": "Comando", + "run": "Executar", + "stop": "Parar", + "history": "Histórico", + "saved_commands": "Comandos salvos", + "session": "Sessão", + "key": "Tecla", + "action": "Ação", + "update_notice": "Pebrel v{latest} está disponível (atual: v{current})", + "copied_lines": "{lines} linhas copiadas para a área de transferência", + "paste_lines": "Colar {lines} linhas?", + "sent_to": "Enviado para {target}" + } +} diff --git a/nebula_app/i18n/ru-RU.json b/nebula_app/i18n/ru-RU.json new file mode 100644 index 0000000..14e5a1e --- /dev/null +++ b/nebula_app/i18n/ru-RU.json @@ -0,0 +1,177 @@ +{ + "language": { + "system": "Как в системе", + "self_name": "Русский", + "zh_cn": "简体中文", + "en_us": "English" + }, + "settings": { + "sidebar": { + "application": "Приложение", + "appearance": "Внешний вид", + "profiles": "Терминал", + "providers": "Провайдеры ИИ", + "ssh": "SSH", + "network": "Сеть", + "interaction": "Взаимодействие", + "keymap": "Сочетания клавиш", + "advanced": "Дополнительно", + "backup": "Резервная копия" + }, + "network": { + "title": "Сетевой прокси", + "mode": { + "label": "Режим прокси", + "off": "Без прокси", + "system": "Как в системе", + "custom": "Свой прокси" + }, + "address": { + "label": "Адрес прокси" + }, + "action": { + "test": "Проверить сеть", + "testing": "Проверка…" + }, + "route": { + "direct": "Прямое соединение", + "direct_address": "Прямой адрес", + "custom_command": "Своя команда" + } + }, + "font": { + "loading": "Загрузка системных шрифтов…", + "no_matches": "Подходящие шрифты не найдены" + } + }, + "common": { + "settings": "Настройки", + "language": "Язык", + "about": "О программе", + "theme": "Тема", + "app_icon": "Значок приложения", + "application_icon": "Значок приложения", + "font": "Шрифт", + "font_size": "Размер шрифта", + "size": "Размер", + "terminal_font": "Шрифт терминала", + "font_preview": "Просмотр шрифта", + "increase_font": "Увеличить шрифт", + "decrease_font": "Уменьшить шрифт", + "change_theme": "Сменить тему", + "change_icon": "Сменить значок", + "restore_defaults": "Восстановить настройки", + "reset": "Сбросить", + "save": "Сохранить", + "saved": "Сохранено", + "cancel": "Отмена", + "confirm": "Подтвердить", + "close": "Закрыть", + "open": "Открыть", + "add": "Добавить", + "remove": "Убрать", + "delete": "Удалить", + "edit": "Изменить", + "rename": "Переименовать", + "copy": "Копировать", + "paste": "Вставить", + "cut": "Вырезать", + "select_all": "Выбрать всё", + "clear": "Очистить", + "search": "Поиск", + "search_settings": "Поиск настроек", + "no_results": "Нет результатов", + "no_matches": "Нет совпадений", + "refresh": "Обновить", + "retry": "Повторить", + "loading": "Загрузка…", + "on": "Вкл.", + "off": "Выкл.", + "enabled": "Включено", + "disabled": "Отключено", + "default": "По умолчанию", + "custom": "Свой", + "none": "Нет", + "auto": "Авто", + "all": "Все", + "name": "Название", + "path": "Путь", + "directory": "Каталог", + "files": "Файлы", + "new_file": "Новый файл", + "new_folder": "Новая папка", + "new_tab": "Новая вкладка", + "new_window": "Новое окно", + "close_tab": "Закрыть вкладку", + "duplicate_tab": "Дублировать вкладку", + "terminal": "Терминал", + "connect": "Подключиться", + "disconnect": "Отключиться", + "connected": "Подключено", + "connecting": "Подключение…", + "host": "Хост", + "username": "Имя пользователя", + "password": "Пароль", + "port": "Порт", + "authentication": "Аутентификация", + "private_key": "Закрытый ключ", + "upload": "Загрузить на сервер", + "download": "Скачать", + "overwrite": "Перезаписать", + "skip": "Пропустить", + "keep_both": "Сохранить оба", + "import": "Импортировать", + "export": "Экспортировать", + "choose": "Выбрать", + "browse": "Обзор", + "version": "Версия", + "check_updates": "Проверить обновления", + "checking": "Проверка…", + "not_checked": "Ещё не проверено", + "up_to_date": "Установлена последняя версия", + "update_available": "Доступно обновление", + "view_update": "Посмотреть обновление", + "install_update": "Установить обновление", + "later": "Позже", + "light": "Светлая", + "dark": "Тёмная", + "opacity": "Непрозрачность", + "background": "Фон", + "background_image": "Фоновое изображение", + "background_color": "Цвет фона", + "foreground": "Передний план", + "text": "Текст", + "standard": "Стандартный", + "compact": "Компактный", + "relaxed": "Свободный", + "left": "Слева", + "right": "Справа", + "top": "Сверху", + "bottom": "Снизу", + "center": "По центру", + "left_sidebar": "Левая панель", + "scroll": "Прокрутка", + "cursor": "Курсор", + "notifications": "Уведомления", + "sound": "Звук", + "visual": "Визуальный", + "success": "Успешно", + "failed": "Ошибка", + "warning": "Предупреждение", + "details": "Подробности", + "help": "Справка", + "shortcuts": "Сочетания клавиш", + "command": "Команда", + "run": "Выполнить", + "stop": "Остановить", + "history": "История", + "saved_commands": "Сохранённые команды", + "session": "Сеанс", + "key": "Клавиша", + "action": "Действие", + "update_notice": "Доступен Pebrel v{latest} (текущая версия v{current})", + "copied_lines": "Скопировано строк в буфер обмена: {lines}", + "paste_lines": "Вставить строки ({lines})?", + "sent_to": "Отправлено в {target}" + } +} diff --git a/nebula_app/i18n/zh-CN.json b/nebula_app/i18n/zh-CN.json index 2326d54..fcc054b 100644 --- a/nebula_app/i18n/zh-CN.json +++ b/nebula_app/i18n/zh-CN.json @@ -9,7 +9,7 @@ "sidebar": { "application": "应用", "appearance": "外观", - "profiles": "配置文件", + "profiles": "终端", "providers": "AI 供应商", "ssh": "SSH", "network": "网络", @@ -114,5 +114,138 @@ "http_status": "服务返回 HTTP {status}", "start_failed": "无法启动供应商测试:{error}" } + }, + "common": { + "settings": "设置", + "language": "语言", + "about": "关于", + "theme": "主题", + "app_icon": "应用图标", + "application_icon": "应用图标", + "font": "字体", + "font_size": "字号", + "size": "字号", + "terminal_font": "终端字体", + "font_preview": "字体预览", + "increase_font": "增大字号", + "decrease_font": "减小字号", + "change_theme": "更换主题", + "change_icon": "更换图标", + "restore_defaults": "恢复默认", + "reset": "重置", + "save": "保存", + "saved": "已保存", + "cancel": "取消", + "confirm": "确认", + "close": "关闭", + "open": "打开", + "add": "添加", + "remove": "移除", + "delete": "删除", + "edit": "编辑", + "rename": "重命名", + "copy": "复制", + "paste": "粘贴", + "cut": "剪切", + "select_all": "全选", + "clear": "清除", + "search": "搜索", + "search_settings": "搜索设置", + "no_results": "没有结果", + "no_matches": "没有匹配项", + "refresh": "刷新", + "retry": "重试", + "loading": "加载中…", + "on": "开启", + "off": "关", + "enabled": "已启用", + "disabled": "已禁用", + "default": "默认", + "custom": "自定义", + "none": "无", + "auto": "自动", + "all": "全部", + "name": "名称", + "path": "路径", + "directory": "目录", + "files": "文件", + "new_file": "新建文件", + "new_folder": "新建文件夹", + "new_tab": "新建标签", + "new_window": "新建窗口", + "close_tab": "关闭标签", + "duplicate_tab": "复制标签", + "terminal": "终端", + "connect": "连接", + "disconnect": "断开", + "connected": "已连接", + "connecting": "连接中…", + "host": "主机", + "username": "用户名", + "password": "密码", + "port": "端口", + "authentication": "认证", + "private_key": "私钥", + "upload": "上传", + "download": "下载", + "overwrite": "覆盖", + "skip": "跳过", + "keep_both": "保留两者", + "import": "导入", + "export": "导出", + "choose": "选择", + "browse": "浏览", + "version": "版本", + "check_updates": "检查更新", + "checking": "正在检查…", + "not_checked": "尚未检查", + "up_to_date": "已是最新版本", + "update_available": "发现新版本", + "view_update": "查看更新", + "install_update": "安装更新", + "later": "稍后", + "light": "浅色", + "dark": "深色", + "opacity": "不透明度", + "background": "背景", + "background_image": "背景图片", + "background_color": "背景颜色", + "foreground": "前景", + "text": "文字", + "standard": "标准", + "compact": "紧凑", + "relaxed": "宽松", + "left": "左侧", + "right": "右侧", + "top": "顶部", + "bottom": "底部", + "center": "居中", + "left_sidebar": "左侧边栏", + "scroll": "滚动", + "cursor": "光标", + "notifications": "通知", + "sound": "声音", + "visual": "闪烁", + "success": "成功", + "failed": "失败", + "warning": "警告", + "details": "详情", + "help": "帮助", + "shortcuts": "键盘快捷键", + "command": "命令", + "run": "运行", + "stop": "停止", + "history": "历史", + "saved_commands": "保存的命令", + "session": "会话", + "key": "按键", + "action": "操作", + "update_notice": "Pebrel v{latest} 已发布(当前 v{current})", + "copied_lines": "已复制 {lines} 行到剪贴板", + "paste_lines": "粘贴 {lines} 行文本?", + "sent_to": "已发送到 {target}", + "yes": "是", + "no": "否", + "confirmation_expired": "确认请求已经变更或结束。" } } diff --git a/nebula_app/i18n/zh-TW.json b/nebula_app/i18n/zh-TW.json new file mode 100644 index 0000000..c9d5a31 --- /dev/null +++ b/nebula_app/i18n/zh-TW.json @@ -0,0 +1,177 @@ +{ + "language": { + "system": "跟隨系統", + "self_name": "繁體中文", + "zh_cn": "简体中文", + "en_us": "English" + }, + "settings": { + "sidebar": { + "application": "應用程式", + "appearance": "外觀", + "profiles": "終端機", + "providers": "AI 供應商", + "ssh": "SSH", + "network": "網路", + "interaction": "互動", + "keymap": "按鍵對應", + "advanced": "進階", + "backup": "備份" + }, + "network": { + "title": "網路代理", + "mode": { + "label": "代理模式", + "off": "不使用代理", + "system": "跟隨系統", + "custom": "自訂代理" + }, + "address": { + "label": "代理位址" + }, + "action": { + "test": "測試網路", + "testing": "測試中…" + }, + "route": { + "direct": "直接連線", + "direct_address": "直連位址", + "custom_command": "自訂命令" + } + }, + "font": { + "loading": "正在列舉系統字型…", + "no_matches": "沒有符合的字型" + } + }, + "common": { + "settings": "設定", + "language": "語言", + "about": "關於", + "theme": "佈景主題", + "app_icon": "應用程式圖示", + "application_icon": "應用程式圖示", + "font": "字型", + "font_size": "字型大小", + "size": "大小", + "terminal_font": "終端機字型", + "font_preview": "字型預覽", + "increase_font": "放大字型", + "decrease_font": "縮小字型", + "change_theme": "更換佈景主題", + "change_icon": "更換圖示", + "restore_defaults": "還原預設值", + "reset": "重設", + "save": "儲存", + "saved": "已儲存", + "cancel": "取消", + "confirm": "確認", + "close": "關閉", + "open": "開啟", + "add": "新增", + "remove": "移除", + "delete": "刪除", + "edit": "編輯", + "rename": "重新命名", + "copy": "複製", + "paste": "貼上", + "cut": "剪下", + "select_all": "全選", + "clear": "清除", + "search": "搜尋", + "search_settings": "搜尋設定", + "no_results": "沒有結果", + "no_matches": "沒有符合項目", + "refresh": "重新整理", + "retry": "重試", + "loading": "載入中…", + "on": "開啟", + "off": "關閉", + "enabled": "已啟用", + "disabled": "已停用", + "default": "預設", + "custom": "自訂", + "none": "無", + "auto": "自動", + "all": "全部", + "name": "名稱", + "path": "路徑", + "directory": "目錄", + "files": "檔案", + "new_file": "新增檔案", + "new_folder": "新增資料夾", + "new_tab": "新增分頁", + "new_window": "新增視窗", + "close_tab": "關閉分頁", + "duplicate_tab": "複製分頁", + "terminal": "終端機", + "connect": "連線", + "disconnect": "中斷連線", + "connected": "已連線", + "connecting": "連線中…", + "host": "主機", + "username": "使用者名稱", + "password": "密碼", + "port": "連接埠", + "authentication": "驗證", + "private_key": "私密金鑰", + "upload": "上傳", + "download": "下載", + "overwrite": "覆寫", + "skip": "略過", + "keep_both": "保留兩者", + "import": "匯入", + "export": "匯出", + "choose": "選擇", + "browse": "瀏覽", + "version": "版本", + "check_updates": "檢查更新", + "checking": "正在檢查…", + "not_checked": "尚未檢查", + "up_to_date": "已是最新版本", + "update_available": "發現新版本", + "view_update": "檢視更新", + "install_update": "安裝更新", + "later": "稍後", + "light": "淺色", + "dark": "深色", + "opacity": "不透明度", + "background": "背景", + "background_image": "背景圖片", + "background_color": "背景色彩", + "foreground": "前景", + "text": "文字", + "standard": "標準", + "compact": "緊湊", + "relaxed": "寬鬆", + "left": "左側", + "right": "右側", + "top": "頂端", + "bottom": "底端", + "center": "置中", + "left_sidebar": "左側邊欄", + "scroll": "捲動", + "cursor": "游標", + "notifications": "通知", + "sound": "音效", + "visual": "閃爍", + "success": "成功", + "failed": "失敗", + "warning": "警告", + "details": "詳細資訊", + "help": "說明", + "shortcuts": "鍵盤快速鍵", + "command": "命令", + "run": "執行", + "stop": "停止", + "history": "歷程", + "saved_commands": "已儲存的命令", + "session": "工作階段", + "key": "按鍵", + "action": "動作", + "update_notice": "Pebrel v{latest} 已發布(目前 v{current})", + "copied_lines": "已複製 {lines} 行到剪貼簿", + "paste_lines": "貼上 {lines} 行文字?", + "sent_to": "已傳送至 {target}" + } +} diff --git a/nebula_app/src/agent_detection/_shared.toml b/nebula_app/src/agent_detection/_shared.toml index c85dad0..c7201ba 100644 --- a/nebula_app/src/agent_detection/_shared.toml +++ b/nebula_app/src/agent_detection/_shared.toml @@ -22,6 +22,13 @@ # 停在等人表态的框上。per-agent 的 900/700 规则更精确时由它们取胜;这里 # 只保证「装了新 CLI 但还没写 blocked 规则」时不至于把权限框当成工作中。 +[[rules]] +id = "shared_binary_confirmation" +state = "blocked" +priority = 800 +region = "bottom_non_empty_lines(1)" +line_regex = ['^.+\s*(?:\(\s*y(?:es)?\s*/\s*n(?:o)?\s*\)|\[\s*y(?:es)?\s*/\s*n(?:o)?\s*\])\s*[:?]?\s*$'] + [[rules]] id = "shared_awaiting_answer" state = "blocked" diff --git a/nebula_app/src/agent_detection/antigravity.toml b/nebula_app/src/agent_detection/antigravity.toml index c83a3c9..5e9eff8 100644 --- a/nebula_app/src/agent_detection/antigravity.toml +++ b/nebula_app/src/agent_detection/antigravity.toml @@ -5,24 +5,65 @@ aliases = ["agy", "antigravity-cli"] [[rules]] id = "permission_prompt" state = "blocked" -priority = 300 -region = "whole_recent" -contains = ["requesting permission for:"] +priority = 900 +region = "bottom_non_empty_lines(12)" any = [ - { contains = ["do you want to proceed?"] }, + { contains = ["requesting approval"], any = [ + { contains = ["[y]"] }, + { contains = ["(y/n)"] }, + { contains = ["[y/n]"] }, + { contains = ["always allow"] }, + { contains = ["esc to cancel"] }, + { contains = ["enter to confirm"] }, + ]}, + { contains = ["needs approval"], any = [ + { contains = ["[y]"] }, + { contains = ["(y/n)"] }, + { contains = ["[y/n]"] }, + { contains = ["always allow"] }, + { contains = ["esc to cancel"] }, + { contains = ["enter to confirm"] }, + ]}, + { contains = ["requesting permission"], any = [ + { contains = ["[y]"] }, + { contains = ["(y/n)"] }, + { contains = ["[y/n]"] }, + { contains = ["always allow"] }, + { contains = ["esc to cancel"] }, + { contains = ["enter to confirm"] }, + ]}, { contains = ["tab amend", "edit command"] }, + { contains = ["tab to amend"] }, ] [[rules]] id = "spinner_working" state = "working" -priority = 100 -region = "whole_recent" +priority = 400 +region = "bottom_non_empty_lines(8)" line_regex = ['^\s*[\u2800-\u28FF]+\s+\p{Alphabetic}+\w*ing\b'] [[rules]] id = "background_tasks_working" state = "working" -priority = 90 +priority = 380 region = "bottom_non_empty_lines(5)" line_regex = ['(?i)·\s*[1-9][0-9]*\s+task'] + +[[rules]] +id = "prompt_idle" +state = "idle" +priority = 100 +region = "bottom_non_empty_lines(6)" +any = [ + { contains = ["shift+tab to cycle"] }, + { contains = ["? for shortcuts"] }, + { line_regex = ['^\s*[›❯>](?:\s|$)'] }, +] +not = [ + { contains = ["esc to interrupt"] }, + { contains = ["esc to cancel"] }, + { contains = ["requesting approval"], any = [{ contains = ["[y]"] }, { contains = ["(y/n)"] }, { contains = ["[y/n]"] }, { contains = ["always allow"] }] }, + { contains = ["needs approval"], any = [{ contains = ["[y]"] }, { contains = ["(y/n)"] }, { contains = ["[y/n]"] }, { contains = ["always allow"] }] }, + { contains = ["requesting permission"], any = [{ contains = ["[y]"] }, { contains = ["(y/n)"] }, { contains = ["[y/n]"] }, { contains = ["always allow"] }] }, +] diff --git a/nebula_app/src/ai_agents.rs b/nebula_app/src/ai_agents.rs index 0ba3c8d..1753e72 100644 --- a/nebula_app/src/ai_agents.rs +++ b/nebula_app/src/ai_agents.rs @@ -920,4 +920,81 @@ mod tests { assert_eq!(found.status, AgentStatus::Blocked, "{agent} rule={}", found.rule_id); } } + + #[test] + fn real_antigravity_working_chrome_reads_working() { + let screen = " ⠋ Thinking... (8s)\n\ + ────────────────────────────────────────\n\ + › \n\ + ────────────────────────────────────────\n\ + \x20 Plan mode: research & plan only (shift+tab to cycle) · Press esc to interrupt generation"; + let detection = detect("antigravity", screen).unwrap(); + assert_eq!(detection.status, AgentStatus::Working, "rule={}", detection.rule_id); + } + + #[test] + fn real_antigravity_idle_chrome_reads_idle() { + let screen = " I have completed the task and updated the configuration.\n\ + ────────────────────────────────────────\n\ + › \n\ + ────────────────────────────────────────\n\ + \x20 Plan mode: research & plan only (shift+tab to cycle) · ? for shortcuts"; + let detection = detect("antigravity", screen).unwrap(); + assert_eq!(detection.status, AgentStatus::Idle, "rule={}", detection.rule_id); + } + + #[test] + fn antigravity_old_scrollback_spinner_does_not_prevent_idle() { + // 历史滚屏中留有上一轮的思考转圈符号;region 限制在底部后不能把已结束的回合误判为 Working。 + let screen = " ⠋ Thinking... (from previous turn 5m ago)\n\ + \x20 Result: success\n\ + \x20 Line 1\n\ + \x20 Line 2\n\ + \x20 Line 3\n\ + \x20 Line 4\n\ + \x20 Line 5\n\ + \x20 Line 6\n\ + \x20 Line 7\n\ + \x20 Line 8\n\ + \x20 Line 9\n\ + \x20 Line 10\n\ + ────────────────────────────────────────\n\ + › \n\ + ────────────────────────────────────────\n\ + \x20 Accept-edits mode: file edits auto-approved (shift+tab to cycle)"; + let detection = detect("antigravity", screen).unwrap(); + assert_eq!(detection.status, AgentStatus::Idle, "rule={}", detection.rule_id); + } + + #[test] + fn real_antigravity_permission_form_reads_blocked() { + let screen = " requesting permission for:\n\ + \x20 Run command: cargo test\n\ + \x20 Do you want to proceed?\n\ + \x20 › 1. Yes, allow\n\ + \x20 2. No, deny access\n\ + \x20 esc to cancel · tab amend"; + let detection = detect("antigravity", screen).unwrap(); + assert_eq!(detection.status, AgentStatus::Blocked, "rule={}", detection.rule_id); + } + + #[test] + fn antigravity_prose_mentioning_approval_while_idle_reads_idle() { + // 回答正文中提及 "needs approval for ...",但此时已完成并显示空闲提示符, + // 不得误判为 Blocked。 + for phrase in + ["needs approval for", "requesting approval for", "requesting permission for:"] + { + let screen = format!( + "│ The tool {phrase} operations outside the workspace.\n\ + │ Please confirm your settings if you plan to enable this.\n\ + ────────────────────────────────────────\n\ + › \n\ + ────────────────────────────────────────\n\ + \x20 Plan mode: research & plan only (shift+tab to cycle) · ? for shortcuts" + ); + let detection = detect("antigravity", &screen).unwrap(); + assert_eq!(detection.status, AgentStatus::Idle, "{phrase}: rule={}", detection.rule_id); + } + } } diff --git a/nebula_app/src/ai_hook.rs b/nebula_app/src/ai_hook.rs index 3fe9582..6b3948d 100644 --- a/nebula_app/src/ai_hook.rs +++ b/nebula_app/src/ai_hook.rs @@ -742,7 +742,9 @@ fn parse_envelope(bytes: &[u8]) -> Option { raw_context: sanitized_raw_context(&payload), }); let answer = crate::assistant_answer::AssistantAnswer::from_hook(&source, &payload); - let answer_cwd = payload.get("cwd").and_then(Value::as_str) + let answer_cwd = payload + .get("cwd") + .and_then(Value::as_str) .filter(|path| path.len() <= 4096 && !path.chars().any(char::is_control)) .map(std::path::PathBuf::from) .filter(|path| path.is_absolute()); diff --git a/nebula_app/src/config/template.rs b/nebula_app/src/config/template.rs index 8644eb7..ec97328 100644 --- a/nebula_app/src/config/template.rs +++ b/nebula_app/src/config/template.rs @@ -1,4 +1,3 @@ -use std::env; use std::fmt::{self, Display, Formatter}; use std::fs; use std::io::Write; @@ -40,7 +39,7 @@ impl Display for TemplateError { ), Self::InvalidLanguage(language) => write!( formatter, - "unsupported language {language:?}; expected system, zh-CN, or en-US" + "unsupported language {language:?}; expected system or a supported UI locale" ), Self::Io(error) => Display::fmt(error, formatter), } @@ -68,51 +67,17 @@ pub fn resolve_template_language( locale: Option<&str>, ) -> Result { let requested = explicit.or(saved).unwrap_or("system"); - match requested { - "zh-CN" => Ok(TemplateLanguage::ZhCn), - "en-US" => Ok(TemplateLanguage::EnUs), - "system" => Ok(if locale.is_some_and(is_chinese_locale) { - TemplateLanguage::ZhCn - } else { - TemplateLanguage::EnUs - }), - other => Err(TemplateError::InvalidLanguage(other.to_owned())), - } + let preference = crate::i18n::LanguagePreference::parse(requested) + .ok_or_else(|| TemplateError::InvalidLanguage(requested.to_owned()))?; + let language = + preference.explicit().unwrap_or_else(|| crate::i18n::UiLanguage::for_locale(locale)); + Ok(match language { + crate::i18n::UiLanguage::ZhCn | crate::i18n::UiLanguage::ZhTw => TemplateLanguage::ZhCn, + _ => TemplateLanguage::EnUs, + }) } -pub fn system_locale() -> Option { - system_locale_impl().or_else(environment_locale) -} - -#[cfg(windows)] -fn system_locale_impl() -> Option { - use windows_sys::Win32::Globalization::GetUserDefaultLocaleName; - - let mut buffer = [0_u16; 85]; - let length = unsafe { GetUserDefaultLocaleName(buffer.as_mut_ptr(), buffer.len() as i32) }; - if length <= 1 { - return None; - } - String::from_utf16(&buffer[..length as usize - 1]).ok() -} - -#[cfg(not(windows))] -fn system_locale_impl() -> Option { - None -} - -fn environment_locale() -> Option { - ["LC_ALL", "LC_MESSAGES", "LANG"] - .into_iter() - .find_map(|name| env::var(name).ok().filter(|value| !value.is_empty())) -} - -fn is_chinese_locale(locale: &str) -> bool { - locale - .split(['-', '_', '.', '@']) - .next() - .is_some_and(|language| language.eq_ignore_ascii_case("zh")) -} +pub use crate::i18n::system_locale; pub fn write_template( path: &Path, @@ -214,6 +179,23 @@ mod tests { ); } + #[test] + fn additional_ui_languages_use_existing_templates_without_errors() { + assert_eq!( + resolve_template_language(None, Some("fr-FR"), None).unwrap(), + TemplateLanguage::EnUs + ); + assert_eq!( + resolve_template_language(Some("zh-TW"), None, None).unwrap(), + TemplateLanguage::ZhCn + ); + for preference in crate::i18n::LanguagePreference::ALL { + assert!( + resolve_template_language(Some(preference.as_str()), None, Some("de-DE")).is_ok() + ); + } + } + #[test] fn existing_file_requires_force_and_force_creates_backup() { let temp = tempfile::tempdir().unwrap(); diff --git a/nebula_app/src/display/command_palette.rs b/nebula_app/src/display/command_palette.rs index 00e5c57..f1cc605 100644 --- a/nebula_app/src/display/command_palette.rs +++ b/nebula_app/src/display/command_palette.rs @@ -11,8 +11,8 @@ use std::path::PathBuf; -use super::{NebulaTheme, SizeInfo}; use super::ui::geometry as layout_geometry; +use super::{NebulaTheme, SizeInfo}; use crate::config::ui_config::Profile; use crate::shell_detect::DetectedShell; use unicode_width::UnicodeWidthChar; diff --git a/nebula_app/src/display/file_dialog.rs b/nebula_app/src/display/file_dialog.rs index ef7c5f9..14cdff7 100644 --- a/nebula_app/src/display/file_dialog.rs +++ b/nebula_app/src/display/file_dialog.rs @@ -86,30 +86,12 @@ pub(super) fn classify_private_key_contents(contents: &[u8]) -> PrivateKeyFileKi return PrivateKeyFileKind::PublicKey; } if [ - concat!( - "-----BEGIN OPENSSH ", - "PRIVATE KEY-----" - ), - concat!( - "-----BEGIN RSA ", - "PRIVATE KEY-----" - ), - concat!( - "-----BEGIN EC ", - "PRIVATE KEY-----" - ), - concat!( - "-----BEGIN ENCRYPTED ", - "PRIVATE KEY-----" - ), - concat!( - "-----BEGIN ", - "PRIVATE KEY-----" - ), - concat!( - "PuTTY-User-Key-", - "File-" - ), + concat!("-----BEGIN OPENSSH ", "PRIVATE KEY-----"), + concat!("-----BEGIN RSA ", "PRIVATE KEY-----"), + concat!("-----BEGIN EC ", "PRIVATE KEY-----"), + concat!("-----BEGIN ENCRYPTED ", "PRIVATE KEY-----"), + concat!("-----BEGIN ", "PRIVATE KEY-----"), + concat!("PuTTY-User-Key-", "File-"), ] .iter() .any(|header| trimmed.starts_with(header)) @@ -254,21 +236,15 @@ mod tests { fn encrypted_and_putty_private_key_headers_are_accepted_for_later_unlock() { assert_eq!( classify_private_key_contents( - concat!( - "-----BEGIN OPENSSH ", - "PRIVATE KEY-----\nnot-decoded-until-passphrase\n" - ) - .as_bytes() + concat!("-----BEGIN OPENSSH ", "PRIVATE KEY-----\nnot-decoded-until-passphrase\n") + .as_bytes() ), PrivateKeyFileKind::PrivateKey ); assert_eq!( classify_private_key_contents( - concat!( - "PuTTY-User-Key-", - "File-3: ssh-ed25519\nEncryption: aes256-cbc" - ) - .as_bytes() + concat!("PuTTY-User-Key-", "File-3: ssh-ed25519\nEncryption: aes256-cbc") + .as_bytes() ), PrivateKeyFileKind::PrivateKey ); diff --git a/nebula_app/src/display/i18n.rs b/nebula_app/src/display/i18n.rs deleted file mode 100644 index 8067fdc..0000000 --- a/nebula_app/src/display/i18n.rs +++ /dev/null @@ -1,408 +0,0 @@ -use std::collections::HashMap; -use std::sync::OnceLock; - -use crate::config::template::{self, TemplateLanguage}; -use crate::provider_test::ProviderTestOutcome; -use crate::proxy_test::{ProxyTestFailure, ProxyTestOutcome, ProxyTestRoute}; - -const EN_US_JSON: &str = include_str!("../../i18n/en-US.json"); -const ZH_CN_JSON: &str = include_str!("../../i18n/zh-CN.json"); - -#[derive(Debug)] -struct TranslationCatalogs { - en_us: HashMap, - zh_cn: HashMap, -} - -static TRANSLATIONS: OnceLock = OnceLock::new(); - -fn parse_catalog(source: &str, locale: &str) -> HashMap { - let root: serde_json::Value = serde_json::from_str(source).unwrap_or_else(|error| { - panic!("embedded {locale} translation catalog is invalid: {error}") - }); - let mut messages = HashMap::new(); - flatten_catalog(&root, "", &mut messages, locale); - messages -} - -fn flatten_catalog( - value: &serde_json::Value, - prefix: &str, - output: &mut HashMap, - locale: &str, -) { - match value { - serde_json::Value::Object(object) => { - for (key, value) in object { - let path = if prefix.is_empty() { key.clone() } else { format!("{prefix}.{key}") }; - flatten_catalog(value, &path, output, locale); - } - }, - serde_json::Value::String(message) => { - assert!(!prefix.is_empty(), "embedded {locale} catalog contains an empty message id"); - assert!( - output.insert(prefix.to_owned(), message.clone()).is_none(), - "embedded {locale} catalog contains duplicate message id {prefix}" - ); - }, - _ => panic!("embedded {locale} catalog leaf {prefix} must be a string"), - } -} - -fn catalogs() -> &'static TranslationCatalogs { - TRANSLATIONS.get_or_init(|| { - let en_us = parse_catalog(EN_US_JSON, "en-US"); - let zh_cn = parse_catalog(ZH_CN_JSON, "zh-CN"); - let mut en_keys = en_us.keys().collect::>(); - let mut zh_keys = zh_cn.keys().collect::>(); - en_keys.sort_unstable(); - zh_keys.sort_unstable(); - assert_eq!( - en_keys, zh_keys, - "embedded en-US and zh-CN catalogs must have identical message ids" - ); - TranslationCatalogs { en_us, zh_cn } - }) -} - -/// Persisted UI language choice. The serialized values are stable because the -/// runtime settings file is also a supported hand-editing surface. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum LanguagePreference { - #[default] - System, - ZhCn, - EnUs, -} - -impl LanguagePreference { - pub fn parse(value: &str) -> Option { - match value.trim() { - "system" => Some(Self::System), - "zh-CN" => Some(Self::ZhCn), - "en-US" => Some(Self::EnUs), - _ => None, - } - } - - pub const fn as_str(self) -> &'static str { - match self { - Self::System => "system", - Self::ZhCn => "zh-CN", - Self::EnUs => "en-US", - } - } - - pub fn resolved(self) -> UiLanguage { - let template = template::resolve_template_language( - Some(self.as_str()), - None, - template::system_locale().as_deref(), - ) - .unwrap_or(TemplateLanguage::EnUs); - match template { - TemplateLanguage::ZhCn => UiLanguage::ZhCn, - TemplateLanguage::EnUs => UiLanguage::EnUs, - } - } -} - -/// Resolved language used by the current process. `pick` keeps translations -/// adjacent at call sites, which makes missing English text obvious in review. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum UiLanguage { - ZhCn, - EnUs, -} - -impl UiLanguage { - pub const fn gpui_component_locale(self) -> &'static str { - match self { - Self::ZhCn => "zh-CN", - Self::EnUs => "en", - } - } - - pub const fn pick<'a>(self, zh_cn: &'a str, en_us: &'a str) -> &'a str { - match self { - Self::ZhCn => zh_cn, - Self::EnUs => en_us, - } - } - - /// Look up a message in the embedded in-memory catalog. Every locale uses - /// the stable target-locale -> English -> key fallback chain. - pub fn tr(self, key: &'static str) -> &'static str { - let catalogs = catalogs(); - let current = match self { - Self::ZhCn => &catalogs.zh_cn, - Self::EnUs => &catalogs.en_us, - }; - current.get(key).or_else(|| catalogs.en_us.get(key)).map(String::as_str).unwrap_or(key) - } - - /// Named template substitution is centralized so call sites do not invent - /// incompatible placeholder syntaxes. - pub fn tr_args(self, key: &'static str, args: &[(&str, &str)]) -> String { - let mut message = self.tr(key).to_owned(); - for (name, value) in args { - message = message.replace(&format!("{{{name}}}"), value); - } - message - } - - pub fn provider_test_message(self, outcome: &ProviderTestOutcome) -> String { - let status_message = - |key: &'static str, status: u16| self.tr_args(key, &[("status", &status.to_string())]); - match outcome { - ProviderTestOutcome::Success { status } => { - status_message("provider.test.success", *status) - }, - ProviderTestOutcome::InvalidEndpoint => { - self.tr("provider.test.invalid_endpoint").into() - }, - ProviderTestOutcome::MissingModel => self.tr("provider.test.missing_model").into(), - ProviderTestOutcome::MissingApiKey => self.tr("provider.test.missing_api_key").into(), - ProviderTestOutcome::CredentialReadFailed => { - self.tr("provider.test.credential_read_failed").into() - }, - ProviderTestOutcome::InvalidCredentialEncoding => { - self.tr("provider.test.invalid_credential_encoding").into() - }, - ProviderTestOutcome::Timeout => self.tr("provider.test.timeout").into(), - ProviderTestOutcome::HostNotFound => self.tr("provider.test.host_not_found").into(), - ProviderTestOutcome::ConnectionFailed => { - self.tr("provider.test.connection_failed").into() - }, - ProviderTestOutcome::Io { kind } => { - self.tr_args("provider.test.io_error", &[("kind", kind)]) - }, - ProviderTestOutcome::Tls => self.tr("provider.test.tls_failed").into(), - ProviderTestOutcome::RequestFailed => self.tr("provider.test.request_failed").into(), - ProviderTestOutcome::AuthFailed { status } => { - status_message("provider.test.auth_failed", *status) - }, - ProviderTestOutcome::EndpointNotFound { status } => { - status_message("provider.test.endpoint_not_found", *status) - }, - ProviderTestOutcome::RateLimited { status } => { - status_message("provider.test.rate_limited", *status) - }, - ProviderTestOutcome::HttpStatus { status } => { - status_message("provider.test.http_status", *status) - }, - ProviderTestOutcome::StartFailed { error } => { - self.tr_args("provider.test.start_failed", &[("error", error)]) - }, - } - } - - pub fn proxy_test_message(self, outcome: &ProxyTestOutcome, elapsed_ms: u64) -> String { - match outcome { - ProxyTestOutcome::Success(route) => { - let route = self.proxy_test_route(route); - self.tr_args( - "settings.network.status.success", - &[("route", &route), ("elapsed_ms", &elapsed_ms.to_string())], - ) - }, - ProxyTestOutcome::Failed(failure) => self.proxy_test_failure(failure), - } - } - - fn proxy_test_route(self, route: &ProxyTestRoute) -> String { - match route { - ProxyTestRoute::Direct => self.tr("settings.network.route.direct").into(), - ProxyTestRoute::DirectAddress => { - self.tr("settings.network.route.direct_address").into() - }, - ProxyTestRoute::CustomCommand => { - self.tr("settings.network.route.custom_command").into() - }, - ProxyTestRoute::ProxyServer(server) => { - self.tr_args("settings.network.route.proxy_server", &[("server", server)]) - }, - ProxyTestRoute::SshJump(target) => { - self.tr_args("settings.network.route.ssh_jump", &[("target", target)]) - }, - } - } - - fn proxy_test_failure(self, failure: &ProxyTestFailure) -> String { - match failure { - ProxyTestFailure::LoadSettings(error) => { - self.tr_args("settings.network.failure.load_settings", &[("error", error)]) - }, - ProxyTestFailure::InvalidSettings(error) => { - self.tr_args("settings.network.failure.invalid_settings", &[("error", error)]) - }, - ProxyTestFailure::Timeout { seconds } => self - .tr_args("settings.network.failure.timeout", &[("seconds", &seconds.to_string())]), - ProxyTestFailure::SendRequest(error) => { - self.tr_args("settings.network.failure.send_request", &[("error", error)]) - }, - ProxyTestFailure::ReadResponse(error) => { - self.tr_args("settings.network.failure.read_response", &[("error", error)]) - }, - ProxyTestFailure::InvalidHttpStatusLine(line) => { - self.tr_args("settings.network.failure.invalid_http_status_line", &[("line", line)]) - }, - ProxyTestFailure::HttpStatus { status } => self.tr_args( - "settings.network.failure.http_status", - &[("status", &status.to_string())], - ), - ProxyTestFailure::ProxyServer { server, error } => self.tr_args( - "settings.network.failure.proxy_server", - &[("server", server), ("error", error)], - ), - ProxyTestFailure::CustomCommand(error) => { - self.tr_args("settings.network.failure.custom_command", &[("error", error)]) - }, - ProxyTestFailure::JumpTask(error) => { - self.tr_args("settings.network.failure.jump_task", &[("error", error)]) - }, - ProxyTestFailure::JumpResolve { target, error } => self.tr_args( - "settings.network.failure.jump_resolve", - &[("target", target), ("error", error)], - ), - ProxyTestFailure::JumpConnect { target, error } => self.tr_args( - "settings.network.failure.jump_connect", - &[("target", target), ("error", error)], - ), - ProxyTestFailure::JumpChannel { target, error } => self.tr_args( - "settings.network.failure.jump_channel", - &[("target", target), ("error", error)], - ), - ProxyTestFailure::Direct(error) => { - self.tr_args("settings.network.failure.direct", &[("error", error)]) - }, - ProxyTestFailure::Start(error) => { - self.tr_args("settings.network.status.start_failed", &[("error", error)]) - }, - ProxyTestFailure::UnexpectedEnd => { - self.tr("settings.network.status.unexpected_end").into() - }, - } - } -} - -#[cfg(test)] -mod tests { - use super::{EN_US_JSON, LanguagePreference, UiLanguage, ZH_CN_JSON, parse_catalog}; - - #[test] - fn persisted_language_values_are_stable() { - for (raw, expected) in [ - ("system", LanguagePreference::System), - ("zh-CN", LanguagePreference::ZhCn), - ("en-US", LanguagePreference::EnUs), - ] { - assert_eq!(LanguagePreference::parse(raw), Some(expected)); - assert_eq!(expected.as_str(), raw); - } - assert_eq!(LanguagePreference::parse("zh"), None); - } - - #[test] - fn explicit_languages_do_not_depend_on_the_system_locale() { - assert_eq!(LanguagePreference::ZhCn.resolved(), UiLanguage::ZhCn); - assert_eq!(LanguagePreference::EnUs.resolved(), UiLanguage::EnUs); - } - - #[test] - fn embedded_catalogs_have_identical_string_keys() { - let en_us = parse_catalog(EN_US_JSON, "en-US"); - let zh_cn = parse_catalog(ZH_CN_JSON, "zh-CN"); - let mut en_keys = en_us.keys().collect::>(); - let mut zh_keys = zh_cn.keys().collect::>(); - en_keys.sort_unstable(); - zh_keys.sort_unstable(); - assert!(!en_keys.is_empty()); - assert_eq!(en_keys, zh_keys); - } - - #[test] - fn lookup_uses_locale_then_english_then_key() { - assert_eq!(UiLanguage::ZhCn.tr("settings.sidebar.network"), "网络"); - assert_eq!(UiLanguage::EnUs.tr("settings.sidebar.network"), "Network"); - assert_eq!(UiLanguage::EnUs.tr("missing.message.id"), "missing.message.id"); - } - - #[test] - fn named_arguments_are_substituted() { - assert_eq!( - UiLanguage::EnUs.tr_args( - "settings.network.status.success", - &[("route", "Direct"), ("elapsed_ms", "12")], - ), - "Network connection succeeded via Direct in 12 ms" - ); - } - - #[test] - fn provider_outcomes_are_localized_at_the_display_boundary() { - let outcome = crate::provider_test::ProviderTestOutcome::AuthFailed { status: 403 }; - assert_eq!( - UiLanguage::EnUs.provider_test_message(&outcome), - "Authentication failed (HTTP 403); check the API key" - ); - assert_eq!( - UiLanguage::ZhCn.provider_test_message(&outcome), - "鉴权失败(HTTP 403),请检查 API Key" - ); - } - - #[test] - fn proxy_routes_are_data_until_the_display_boundary() { - let outcome = - crate::proxy_test::ProxyTestOutcome::Success(crate::proxy_test::ProxyTestRoute::Direct); - assert_eq!( - UiLanguage::EnUs.proxy_test_message(&outcome, 12), - "Network connection succeeded via Direct connection in 12 ms" - ); - assert_eq!( - UiLanguage::ZhCn.proxy_test_message(&outcome, 12), - "网络连接正常 · 直接连接 · 12 ms" - ); - } - - #[test] - #[ignore = "manual i18n microbenchmark"] - fn measure_embedded_catalog_costs() { - use std::hint::black_box; - use std::time::Instant; - - let init_started = Instant::now(); - let en_us = parse_catalog(EN_US_JSON, "en-US"); - let zh_cn = parse_catalog(ZH_CN_JSON, "zh-CN"); - let mut en_keys = en_us.keys().collect::>(); - let mut zh_keys = zh_cn.keys().collect::>(); - en_keys.sort_unstable(); - zh_keys.sort_unstable(); - assert_eq!(en_keys, zh_keys); - let init_elapsed = init_started.elapsed(); - - let lookup_iterations = 2_000_000u128; - let lookup_started = Instant::now(); - for _ in 0..lookup_iterations { - black_box(UiLanguage::EnUs.tr("settings.sidebar.network")); - } - let lookup_ns = lookup_started.elapsed().as_nanos() / lookup_iterations; - - let template_iterations = 200_000u128; - let template_started = Instant::now(); - for _ in 0..template_iterations { - black_box(UiLanguage::EnUs.tr_args("provider.test.success", &[("status", "200")])); - } - let template_ns = template_started.elapsed().as_nanos() / template_iterations; - - eprintln!( - "i18n resources={} bytes, messages={}, init={} us, lookup={} ns, template={} ns", - EN_US_JSON.len() + ZH_CN_JSON.len(), - en_us.len(), - init_elapsed.as_micros(), - lookup_ns, - template_ns, - ); - } -} diff --git a/nebula_app/src/display/message_queue_entry.rs b/nebula_app/src/display/message_queue_entry.rs index 3050e60..dace3b9 100644 --- a/nebula_app/src/display/message_queue_entry.rs +++ b/nebula_app/src/display/message_queue_entry.rs @@ -240,7 +240,7 @@ fn summary_text(state: MessageQueueEntry, language: UiLanguage) -> String { let regular = state.pending.saturating_sub(state.high_risk); match language { UiLanguage::ZhCn => format!("{} 个高风险 · {} 个待处理", state.high_risk, regular), - UiLanguage::EnUs => format!("{} high risk · {} pending", state.high_risk, regular), + _ => format!("{} high risk · {} pending", state.high_risk, regular), } } diff --git a/nebula_app/src/display/mod.rs b/nebula_app/src/display/mod.rs index a6f9707..bc2f2c0 100644 --- a/nebula_app/src/display/mod.rs +++ b/nebula_app/src/display/mod.rs @@ -76,7 +76,6 @@ pub(crate) mod context_menu; mod context_menu_model; mod document_model; mod file_operations; -mod i18n; pub mod markdown_view; mod message_queue_entry; mod network_proxy_model; @@ -101,6 +100,7 @@ pub(crate) fn quick_terminal_hotkey_from_settings(config: &UiConfig) -> String { settings::nebula_settings_load(config).quick_terminal_hotkey } +pub use crate::i18n::{LanguagePreference, UiLanguage}; pub use background_color_model::BgPickerPart; pub(crate) use background_color_model::{BACKGROUND_SWATCHES, hsv_to_rgb, rgb_to_hsv}; pub(crate) use chrome::chrome_settings_button_rect; @@ -112,7 +112,6 @@ pub(crate) use command_completion::{ }; pub use context_menu_model::{ContextMenuAction, ContextMenuHit, ContextMenuTarget}; pub(crate) use file_operations::send_to_recycle_bin; -pub use i18n::{LanguagePreference, UiLanguage}; pub(crate) use input_state::{ nebula_clear_line, nebula_input_backspace, nebula_input_char, nebula_input_delete_word, nebula_input_text, nebula_shell_prompt_restored_from_raw_grid, @@ -752,16 +751,6 @@ pub(crate) fn replays_untrusted_terminal_output(line: &str) -> bool { ) || matches!(words.as_slice(), [journalctl, ..] if journalctl == "journalctl") } -/// Embedded brand logo assets, kept at their source pixel dimensions. -const AI_LOGO_CLAUDE_PNG: &[u8] = include_bytes!("../../../extra/logo/ai_claude.png"); -const AI_LOGO_GROK_DARK_PNG: &[u8] = include_bytes!("../../../extra/logo/ai_grok_dark.png"); -const AI_LOGO_GROK_LIGHT_PNG: &[u8] = include_bytes!("../../../extra/logo/ai_grok_light.png"); -const AI_LOGO_OPENAI_PNG: &[u8] = include_bytes!("../../../extra/logo/ai_openai.png"); -/// opencode's mark, rasterized from their `favicon.svg`. RGB carries luma -/// (frame=255, block=90), alpha the shape; tinted `ink × luma/255` at runtime. -const AI_LOGO_OPENCODE_PNG: &[u8] = include_bytes!("../../../extra/logo/ai_opencode.png"); -const AI_LOGO_PI_PNG: &[u8] = include_bytes!("../../../extra/logo/ai_pi.png"); - /// Texture ids for chrome logos live far above the inline-image counter /// (which starts at 1), so the two id spaces can share the renderer cache. const AI_LOGO_ID_BASE: u64 = 1 << 62; @@ -2418,12 +2407,12 @@ impl Display { ink: Rgb, target_size: u32, ) -> Option<(u64, std::sync::Arc>, (u32, u32))> { - // Claude and Grok keep their source colors. Grok ships official dark + // Color assets keep their source colors. Grok ships official dark // and light marks, selected to match the chrome ink without tinting. 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 => (logo, [0, 0, 0], target_size), + 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 => { @@ -2433,14 +2422,7 @@ impl Display { if let Some(cached) = self.nebula_ai_logo_cache.get(&key) { return Some(cached.clone()); } - let bytes: &[u8] = match logo { - AiLogo::Claude => AI_LOGO_CLAUDE_PNG, - AiLogo::Grok if grok_uses_light_mark => AI_LOGO_GROK_LIGHT_PNG, - AiLogo::Grok => AI_LOGO_GROK_DARK_PNG, - AiLogo::OpenAi => AI_LOGO_OPENAI_PNG, - AiLogo::OpenCode => AI_LOGO_OPENCODE_PNG, - AiLogo::Pi => AI_LOGO_PI_PNG, - }; + let bytes = logo.png(grok_uses_light_mark); let (width, height, mut rgba) = match crate::renderer::image::decode_png_bytes(bytes) { Ok(decoded) => decoded, // Unreachable for a valid embedded asset; degrade to no icon. @@ -2449,26 +2431,8 @@ impl Display { return None; }, }; - match logo { - AiLogo::OpenAi | AiLogo::Pi => { - for px in rgba.chunks_exact_mut(4) { - (px[0], px[1], px[2]) = (ink.r, ink.g, ink.b); - } - }, - AiLogo::OpenCode => { - // Stored grayscale = luma map (frame 255, screen-block 90). - // Tint to theme ink scaled by luma: frame → full ink, inner - // block → ~35% ink, keeping the two-tone mark on every theme. - for px in rgba.chunks_exact_mut(4) { - let luma = px[0] as u16; // R==G==B in the asset - px[0] = (ink.r as u16 * luma / 255) as u8; - px[1] = (ink.g as u16 * luma / 255) as u8; - px[2] = (ink.b as u16 * luma / 255) as u8; - } - }, - AiLogo::Claude | AiLogo::Grok => {}, - } - let (rgba, width, height) = if logo == AiLogo::Grok { + 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) @@ -10994,92 +10958,6 @@ mod nebula_ux_tests { values.iter().map(|value| (*value).to_owned()).collect() } - fn logo_for_command(command: &str) -> Option { - extract_program(command).as_deref().and_then(ai_logo) - } - - #[test] - fn running_program_identity_normalizes_grok_without_changing_fallbacks() { - for command in [ - "grok", - "grok-cli", - "GROK.CMD", - "C:\\Tools\\GROK-CLI.EXE --help", - "/usr/local/bin/grok", - ] { - assert_eq!(logo_for_command(command), Some(AiLogo::Grok), "{command}"); - } - - assert_eq!(logo_for_command("codex"), Some(AiLogo::OpenAi)); - assert_eq!(logo_for_command("C:\\Tools\\CLAUDE.EXE --resume"), Some(AiLogo::Claude)); - assert_eq!(program_icon("cargo"), "\u{e7a8}"); - assert_eq!(logo_for_command("unlisted-program"), None); - assert_eq!(program_icon("unlisted-program"), "\u{f04b}"); - assert_eq!(extract_program(""), None); - } - - #[cfg(all(feature = "png", not(target_os = "macos")))] - #[test] - fn official_grok_logos_are_embedded_unchanged_and_decodable() { - use sha2::{Digest, Sha256}; - - let assets = [ - ( - AI_LOGO_GROK_DARK_PNG, - [ - 0x37, 0xdd, 0xbc, 0xb6, 0xe2, 0xa7, 0xf2, 0xe4, 0xb3, 0xbe, 0x78, 0xa7, 0xd4, - 0x12, 0x96, 0xa3, 0xbc, 0x7e, 0xdf, 0x69, 0x26, 0x36, 0x24, 0x34, 0xef, 0xc0, - 0x0d, 0xf5, 0xa5, 0x6a, 0x35, 0x86, - ], - ), - ( - AI_LOGO_GROK_LIGHT_PNG, - [ - 0x35, 0x90, 0x56, 0xee, 0x89, 0x83, 0xcf, 0xa0, 0xba, 0x7e, 0x72, 0x79, 0x50, - 0x78, 0xc7, 0xc0, 0xdd, 0xf6, 0xc5, 0xd7, 0xa1, 0x87, 0x04, 0x01, 0xab, 0x96, - 0x0e, 0xd4, 0xf9, 0xdf, 0x9e, 0x53, - ], - ), - ]; - - for (png, expected_sha256) in assets { - assert_eq!(Sha256::digest(png).as_slice(), expected_sha256); - let (width, height, rgba) = crate::renderer::image::decode_png_bytes(png).unwrap(); - assert_eq!((width, height), (1024, 1024)); - assert!(rgba.chunks_exact(4).any(|pixel| pixel[3] != 0)); - } - } - - #[cfg(all(feature = "png", not(target_os = "macos")))] - #[test] - fn official_grok_logos_prepare_sharp_optically_centered_physical_textures() { - for png in [AI_LOGO_GROK_DARK_PNG, AI_LOGO_GROK_LIGHT_PNG] { - let (width, height, rgba) = crate::renderer::image::decode_png_bytes(png).unwrap(); - let (rgba, width, height) = prepare_ai_logo_texture(&rgba, width, height, 18); - - assert_eq!((width, height, rgba.len()), (18, 18, 18 * 18 * 4)); - let alpha_levels = rgba - .chunks_exact(4) - .map(|pixel| pixel[3]) - .filter(|alpha| *alpha > 0) - .collect::>(); - assert!(alpha_levels.len() >= 8); - - let (mass, weighted_x, weighted_y) = rgba.chunks_exact(4).enumerate().fold( - (0.0, 0.0, 0.0), - |(mass, weighted_x, weighted_y), (index, pixel)| { - let alpha = f64::from(pixel[3]); - let x = (index as u32 % width) as f64 + 0.5; - let y = (index as u32 / width) as f64 + 0.5; - (mass + alpha, weighted_x + x * alpha, weighted_y + y * alpha) - }, - ); - let center = f64::from(width) / 2.0; - assert!((weighted_x / mass - center).abs() <= 0.5); - assert!((weighted_y / mass - center).abs() <= 0.5); - } - } - #[test] fn file_uri_tooltip_shows_decoded_path() { // `ls --hyperlink` percent-encodes CJK names; the tooltip must not. diff --git a/nebula_app/src/display/program_identity.rs b/nebula_app/src/display/program_identity.rs index cb4d4e9..d3616f1 100644 --- a/nebula_app/src/display/program_identity.rs +++ b/nebula_app/src/display/program_identity.rs @@ -9,6 +9,40 @@ pub enum AiLogo { OpenCode, Pi, Grok, + Antigravity, +} + +impl AiLogo { + pub(crate) const ALL: [Self; 6] = + [Self::Claude, Self::OpenAi, Self::OpenCode, Self::Pi, Self::Grok, Self::Antigravity]; + + /// One source catalog for both shells. Official color assets are embedded unchanged. + pub(crate) fn png(self, light_ink: bool) -> &'static [u8] { + match self { + Self::Claude => include_bytes!("../../../extra/logo/ai_claude.png"), + Self::OpenAi => include_bytes!("../../../extra/logo/ai_openai.png"), + Self::OpenCode => include_bytes!("../../../extra/logo/ai_opencode.png"), + Self::Pi => include_bytes!("../../../extra/logo/ai_pi.png"), + 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"), + } + } + + pub(crate) fn tint_pixels(self, pixels: &mut [u8], ink: [u8; 3]) { + let preserve_luma = match self { + 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, + }; + for pixel in pixels.chunks_exact_mut(4) { + let luma = if preserve_luma { u16::from(pixel[0]) } else { 255 }; + for channel in 0..3 { + pixel[channel] = (u16::from(ink[channel]) * luma / 255) as u8; + } + } + } } pub(crate) fn prepare_ai_logo_texture( @@ -64,14 +98,17 @@ pub(crate) fn prepare_ai_logo_texture( } pub(crate) fn ai_logo_for_program(program: &str) -> Option { + use crate::ai_agents::AgentKind; + let normalized = extract_program(program).unwrap_or_else(|| program.trim().to_ascii_lowercase()); - match normalized.as_str() { - "claude" => Some(AiLogo::Claude), - "codex" => Some(AiLogo::OpenAi), - "opencode" => Some(AiLogo::OpenCode), - "pi" => Some(AiLogo::Pi), - "grok" | "grok-cli" => Some(AiLogo::Grok), + match AgentKind::parse(&normalized)? { + AgentKind::Claude => Some(AiLogo::Claude), + AgentKind::Codex => Some(AiLogo::OpenAi), + AgentKind::OpenCode => Some(AiLogo::OpenCode), + AgentKind::Pi => Some(AiLogo::Pi), + AgentKind::Grok => Some(AiLogo::Grok), + AgentKind::Antigravity => Some(AiLogo::Antigravity), _ => None, } } @@ -103,3 +140,169 @@ pub(crate) fn program_icon(program: &str) -> &'static str { _ => "\u{f04b}", } } + +#[cfg(test)] +mod tests { + use super::*; + + fn decode_png(bytes: &[u8]) -> (u32, u32, Vec) { + let image = image::load_from_memory(bytes).unwrap().into_rgba8(); + (image.width(), image.height(), image.into_raw()) + } + + fn logo_for_command(command: &str) -> Option { + extract_program(command).as_deref().and_then(ai_logo_for_program) + } + + #[test] + fn running_program_identity_normalizes_grok_without_changing_fallbacks() { + for command in [ + "grok", + "grok-cli", + "GROK.CMD", + "C:\\Tools\\GROK-CLI.EXE --help", + "/usr/local/bin/grok", + ] { + assert_eq!(logo_for_command(command), Some(AiLogo::Grok), "{command}"); + } + + assert_eq!(logo_for_command("codex"), Some(AiLogo::OpenAi)); + assert_eq!(logo_for_command("C:\\Tools\\CLAUDE.EXE --resume"), Some(AiLogo::Claude)); + assert_eq!(program_icon("cargo"), "\u{e7a8}"); + assert_eq!(logo_for_command("unlisted-program"), None); + assert_eq!(program_icon("unlisted-program"), "\u{f04b}"); + assert_eq!(extract_program(""), None); + } + + #[test] + fn official_grok_logos_are_embedded_unchanged_and_decodable() { + use sha2::{Digest, Sha256}; + + let assets = [ + ( + AiLogo::Grok.png(false), + [ + 0x37, 0xdd, 0xbc, 0xb6, 0xe2, 0xa7, 0xf2, 0xe4, 0xb3, 0xbe, 0x78, 0xa7, 0xd4, + 0x12, 0x96, 0xa3, 0xbc, 0x7e, 0xdf, 0x69, 0x26, 0x36, 0x24, 0x34, 0xef, 0xc0, + 0x0d, 0xf5, 0xa5, 0x6a, 0x35, 0x86, + ], + ), + ( + AiLogo::Grok.png(true), + [ + 0x35, 0x90, 0x56, 0xee, 0x89, 0x83, 0xcf, 0xa0, 0xba, 0x7e, 0x72, 0x79, 0x50, + 0x78, 0xc7, 0xc0, 0xdd, 0xf6, 0xc5, 0xd7, 0xa1, 0x87, 0x04, 0x01, 0xab, 0x96, + 0x0e, 0xd4, 0xf9, 0xdf, 0x9e, 0x53, + ], + ), + ]; + + for (png, expected_sha256) in assets { + assert_eq!(Sha256::digest(png).as_slice(), expected_sha256); + let (width, height, rgba) = decode_png(png); + assert_eq!((width, height), (1024, 1024)); + assert!(rgba.chunks_exact(4).any(|pixel| pixel[3] != 0)); + } + } + + #[test] + fn official_grok_logos_prepare_sharp_optically_centered_physical_textures() { + for png in [AiLogo::Grok.png(false), AiLogo::Grok.png(true)] { + let (width, height, rgba) = decode_png(png); + let (rgba, width, height) = prepare_ai_logo_texture(&rgba, width, height, 18); + + assert_eq!((width, height, rgba.len()), (18, 18, 18 * 18 * 4)); + let alpha_levels = rgba + .chunks_exact(4) + .map(|pixel| pixel[3]) + .filter(|alpha| *alpha > 0) + .collect::>(); + assert!(alpha_levels.len() >= 8); + + let (mass, weighted_x, weighted_y) = rgba.chunks_exact(4).enumerate().fold( + (0.0, 0.0, 0.0), + |(mass, weighted_x, weighted_y), (index, pixel)| { + let alpha = f64::from(pixel[3]); + let x = (index as u32 % width) as f64 + 0.5; + let y = (index as u32 / width) as f64 + 0.5; + (mass + alpha, weighted_x + x * alpha, weighted_y + y * alpha) + }, + ); + let center = f64::from(width) / 2.0; + assert!((weighted_x / mass - center).abs() <= 0.5); + assert!((weighted_y / mass - center).abs() <= 0.5); + } + } + + #[test] + fn antigravity_aliases_share_the_running_program_logo() { + for command in [ + "antigravity", + "agy", + "antigravity-cli", + "AGY.CMD --help", + "C:\\Tools\\ANTIGRAVITY-CLI.EXE", + "/usr/local/bin/antigravity", + ] { + assert_eq!(ai_logo_for_program(command), Some(AiLogo::Antigravity), "{command}"); + } + for command in ["gravity", "antigravity-helper", "agy-not-an-agent"] { + assert_eq!(ai_logo_for_program(command), None, "{command}"); + } + } + + #[test] + fn official_antigravity_logo_keeps_its_bytes_and_colors_on_both_themes() { + use sha2::{Digest, Sha256}; + + let png = AiLogo::Antigravity.png(false); + assert_eq!(png, AiLogo::Antigravity.png(true)); + assert_eq!( + Sha256::digest(png).as_slice(), + [ + 0xe0, 0xcd, 0x08, 0xcc, 0xd1, 0x0c, 0xd8, 0xd0, 0x8c, 0xcf, 0x0b, 0xa4, 0x49, 0x82, + 0x3e, 0xe8, 0x84, 0x95, 0x82, 0x5c, 0x08, 0x41, 0x61, 0x96, 0x18, 0x10, 0x0d, 0x3a, + 0xb0, 0x89, 0xf5, 0x1e, + ] + ); + let (width, height, source) = decode_png(png); + assert_eq!((width, height), (540, 540)); + assert!(source.chunks_exact(4).any(|pixel| pixel[3] == 0)); + assert!(source.chunks_exact(4).any(|pixel| pixel[3] == 255)); + for ink in [[236, 239, 245], [35, 40, 50]] { + let mut rgba = source.clone(); + AiLogo::Antigravity.tint_pixels(&mut rgba, ink); + assert_eq!(rgba, source); + for size in [16, 18, 24, 27, 36] { + let (pixels, actual_width, actual_height) = + prepare_ai_logo_texture(&rgba, width, height, size); + assert_eq!((actual_width, actual_height), (size, size)); + assert_eq!(pixels.len(), (size * size * 4) as usize); + assert!( + pixels + .chunks_exact(4) + .any(|pixel| { pixel[3] > 128 && pixel[0].abs_diff(pixel[2]) > 50 }) + ); + } + } + } + + #[test] + 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] { + let mut pixels = source; + logo.tint_pixels(&mut pixels, ink); + assert_eq!(pixels, source); + } + for logo in [AiLogo::OpenAi, AiLogo::Pi] { + let mut pixels = source; + logo.tint_pixels(&mut pixels, ink); + assert_eq!(pixels, [100, 200, 240, 128, 100, 200, 240, 64]); + } + let mut pixels = source; + AiLogo::OpenCode.tint_pixels(&mut pixels, ink); + assert_eq!(pixels, [100, 200, 240, 128, 50, 100, 120, 64]); + } +} diff --git a/nebula_app/src/display/settings.rs b/nebula_app/src/display/settings.rs index 50e9199..146172c 100644 --- a/nebula_app/src/display/settings.rs +++ b/nebula_app/src/display/settings.rs @@ -168,8 +168,7 @@ pub(super) const BACKGROUND_ALIGNMENT_OPTIONS: [BackgroundImageAlignment; 9] = [ BackgroundImageAlignment::BottomRight, ]; -pub(super) const LANGUAGE_OPTIONS: [LanguagePreference; 3] = - [LanguagePreference::System, LanguagePreference::ZhCn, LanguagePreference::EnUs]; +pub(super) const LANGUAGE_OPTIONS: &[LanguagePreference] = LanguagePreference::ALL; pub(super) const ACCEPT_OPTIONS: [AcceptKey; 3] = [AcceptKey::Both, AcceptKey::Tab, AcceptKey::Right]; @@ -532,8 +531,7 @@ fn backup_remote_input_display( fn language_label(preference: LanguagePreference, language: UiLanguage) -> &'static str { match preference { LanguagePreference::System => language.pick("跟随系统", "Follow system"), - LanguagePreference::ZhCn => "简体中文", - LanguagePreference::EnUs => "English", + _ => preference.shared().native_name(), } } @@ -7973,12 +7971,12 @@ pub(super) fn draw_text( mod tests { use super::{ CELL_WIDTH_MODE_OPTIONS, CellWidthMode, KeymapPaneState, NEW_TAB_POSITION_OPTIONS, - NebulaSettingsSection, NewTabPosition, ProxyChoice, ProxyPaneState, - SHOW_BACKUP_SETTINGS, SHOW_WEBDAV_SYNC_SETTINGS, STANDARD_ROW_ACTION_W, SettingsHit, - TabRevealMotion, UiLanguage, advanced_content_end, background_color_popup, - cell_width_mode_label, font_popup_row_count, font_popup_slot, new_tab_position_label, - opacity_from_pointer, proxy_section_title_y, row_action_rect, settings_geometry, - settings_hit, ssh_proxy_manual_controls, ssh_proxy_mode_control, ssh_proxy_test_button, + NebulaSettingsSection, NewTabPosition, ProxyChoice, ProxyPaneState, SHOW_BACKUP_SETTINGS, + SHOW_WEBDAV_SYNC_SETTINGS, STANDARD_ROW_ACTION_W, SettingsHit, TabRevealMotion, UiLanguage, + advanced_content_end, background_color_popup, cell_width_mode_label, font_popup_row_count, + font_popup_slot, new_tab_position_label, opacity_from_pointer, proxy_section_title_y, + row_action_rect, settings_geometry, settings_hit, ssh_proxy_manual_controls, + ssh_proxy_mode_control, ssh_proxy_test_button, }; use crate::display::SizeInfo; use crate::display::ui::tokens::Density; diff --git a/nebula_app/src/display/side_panel/tests.rs b/nebula_app/src/display/side_panel/tests.rs index 012517a..7f34b3b 100644 --- a/nebula_app/src/display/side_panel/tests.rs +++ b/nebula_app/src/display/side_panel/tests.rs @@ -6,6 +6,24 @@ use super::*; mod tests { use super::*; + fn remove_test_directory(path: &Path) { + let deadline = Instant::now() + Duration::from_secs(5); + loop { + match std::fs::remove_dir_all(path) { + Ok(()) => return, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return, + Err(error) + if cfg!(windows) + && matches!(error.raw_os_error(), Some(32 | 33)) + && Instant::now() < deadline => + { + std::thread::sleep(Duration::from_millis(20)); + }, + Err(error) => panic!("could not remove test directory {}: {error}", path.display()), + } + } + } + #[test] fn toggle_switches_views_without_closing() { let mut p = SidePanel::new(); @@ -104,7 +122,7 @@ mod tests { panel.wait_snapshot(); panel.file_index.release_for_test(); - std::fs::remove_dir_all(&base).unwrap(); + remove_test_directory(&base); } #[test] @@ -129,7 +147,7 @@ mod tests { panel.wait_snapshot(); panel.file_index.release_for_test(); - std::fs::remove_dir_all(&base).unwrap(); + remove_test_directory(&base); } #[test] @@ -398,7 +416,7 @@ mod tests { assert_eq!(rows[2].depth, 1); p.file_index.release_for_test(); - std::fs::remove_dir_all(&base).unwrap(); + remove_test_directory(&base); } #[test] @@ -455,7 +473,7 @@ mod tests { assert!(panel.custom_root_active(), "upward navigation is window-local"); panel.file_index.release_for_test(); - std::fs::remove_dir_all(&base).unwrap(); + remove_test_directory(&base); } #[test] @@ -485,7 +503,7 @@ mod tests { assert!(!panel.click_drag_source(&stale), "a changed source row must be ignored"); panel.file_index.release_for_test(); - std::fs::remove_dir_all(&base).unwrap(); + remove_test_directory(&base); } #[test] diff --git a/nebula_app/src/display/side_panel/vcs.rs b/nebula_app/src/display/side_panel/vcs.rs index b2edc7a..ee34448 100644 --- a/nebula_app/src/display/side_panel/vcs.rs +++ b/nebula_app/src/display/side_panel/vcs.rs @@ -1066,12 +1066,8 @@ pub(crate) fn parse_git_history(output: &str) -> Vec { let subject = fields.next().unwrap_or_default().to_owned(); let author = fields.next().unwrap_or_default().to_owned(); let timestamp = fields.next().and_then(|value| value.parse().ok()).unwrap_or(0); - let parent_hashes = fields - .next() - .unwrap_or_default() - .split_whitespace() - .map(str::to_owned) - .collect(); + let parent_hashes = + fields.next().unwrap_or_default().split_whitespace().map(str::to_owned).collect(); Some(GitCommit { full_hash, short_hash, diff --git a/nebula_app/src/display/ssh_connect.rs b/nebula_app/src/display/ssh_connect.rs index fce5b07..ea5544b 100644 --- a/nebula_app/src/display/ssh_connect.rs +++ b/nebula_app/src/display/ssh_connect.rs @@ -14,14 +14,14 @@ use std::time::{Duration, Instant}; use unicode_width::UnicodeWidthChar; +#[cfg(feature = "legacy-shell")] +use super::NebulaTheme; use super::SizeInfo; use super::color::Rgb; -use super::i18n::UiLanguage; #[cfg(feature = "legacy-shell")] use super::ui::icons; use super::ui::tokens::{Density, radius, space, type_scale}; -#[cfg(feature = "legacy-shell")] -use super::NebulaTheme; +use crate::i18n::UiLanguage; #[cfg(feature = "legacy-shell")] use crate::renderer::ui::{Gradient, Rgba, UiQuad}; #[cfg(feature = "legacy-shell")] diff --git a/nebula_app/src/display/terminal_math.rs b/nebula_app/src/display/terminal_math.rs index 805050d..ff594ab 100644 --- a/nebula_app/src/display/terminal_math.rs +++ b/nebula_app/src/display/terminal_math.rs @@ -949,8 +949,8 @@ impl TextGrid { mut position: GridPosition, delimiter: &[char], ) -> Option { - let standalone_opener = self.span_is_blank(open.row, position.column, self.columns) - && open.row == position.row; + let standalone_opener = + self.span_is_blank(open.row, position.column, self.columns) && open.row == position.row; let last_row = open.row.saturating_add(DISPLAY_BLOCK_GAP_SEARCH_ROWS); let mut content_rows = 0usize; let mut bridged = false; diff --git a/nebula_app/src/display/ui/mod.rs b/nebula_app/src/display/ui/mod.rs index b470541..7c2ca05 100644 --- a/nebula_app/src/display/ui/mod.rs +++ b/nebula_app/src/display/ui/mod.rs @@ -51,8 +51,8 @@ pub mod caret; mod color_math; -pub mod guardrails; pub(crate) mod geometry; +pub mod guardrails; pub mod icons; pub mod keycap; mod keycap_model; diff --git a/nebula_app/src/display/ui/widgets.rs b/nebula_app/src/display/ui/widgets.rs index 1c49fd6..7214520 100644 --- a/nebula_app/src/display/ui/widgets.rs +++ b/nebula_app/src/display/ui/widgets.rs @@ -17,8 +17,8 @@ use super::theme::Skin; use super::tokens::{Density, radius}; use crate::renderer::ui::{Rgba, UiQuad}; -pub(crate) use super::scrollbar::{OverlayScrollbar, overlay_scrollbar}; pub(crate) use super::geometry::{centered_y, pane_geometry, pane_geometry_in_horizontal_bounds}; +pub(crate) use super::scrollbar::{OverlayScrollbar, overlay_scrollbar}; pub(crate) type Rect = (f32, f32, f32, f32); diff --git a/nebula_app/src/event.rs b/nebula_app/src/event.rs index b3adee5..9f03fee 100644 --- a/nebula_app/src/event.rs +++ b/nebula_app/src/event.rs @@ -1413,7 +1413,7 @@ impl<'a, N: Notify + 'a, T: EventListener> input::ActionContext for ActionCon let language = self.display.ui_language(); let message = match language { UiLanguage::ZhCn => format!("已复制 {lines} 行到剪贴板"), - UiLanguage::EnUs => format!("Copied {lines} lines to clipboard"), + _ => format!("Copied {lines} lines to clipboard"), }; self.display.push_toast(message, ToastKind::Info); } diff --git a/nebula_app/src/gpui_shell/assets.rs b/nebula_app/src/gpui_shell/assets.rs index e5338c6..ef73ea8 100644 --- a/nebula_app/src/gpui_shell/assets.rs +++ b/nebula_app/src/gpui_shell/assets.rs @@ -27,6 +27,10 @@ macro_rules! icons { const NEBULA_ICONS: &[(&str, &[u8])] = icons![ "command-manager", "keymap", + "layout-grid", + "mouse-pointer", + "sliders", + "pin", "pencil", "trash-2", "vcs-changes", @@ -59,6 +63,10 @@ impl AssetSource for NebulaAssets { /// 自带图标的路径。走路径而不是 `IconName`:扩展那个枚举等于改 fork。 pub mod nav { + pub const LAYOUT_GRID: &str = "icons/nebula-layout-grid.svg"; + pub const MOUSE_POINTER: &str = "icons/nebula-mouse-pointer.svg"; + pub const SLIDERS: &str = "icons/nebula-sliders.svg"; + pub const PIN: &str = "icons/nebula-pin.svg"; /// 保存命令列表。闪电表达快捷执行,右侧三横线表达可管理的命令集合。 pub const COMMAND_MANAGER: &str = "icons/nebula-command-manager.svg"; /// 键盘。lucide 没有,而「按键映射」原来错挂在字号图标上。 diff --git a/nebula_app/src/gpui_shell/background_color.rs b/nebula_app/src/gpui_shell/background_color.rs index 293445d..b93005e 100644 --- a/nebula_app/src/gpui_shell/background_color.rs +++ b/nebula_app/src/gpui_shell/background_color.rs @@ -18,7 +18,7 @@ use crate::gpui_shell::prelude::*; use gpui_component::input::InputEvent; use nebula_settings::{format_hex_rgb, parse_hex_rgb}; -use super::SettingsPane; +use super::{SettingsPane, help}; const COMBOBOX_W: f32 = 220.0; const COMBOBOX_H: f32 = 32.0; @@ -290,7 +290,7 @@ impl SettingsPane { let trigger_bounds = self.bg_picker_trigger_bounds; let row = self.row_with_reset( language.pick("背景色", "Background color"), - language.tr("settings.appearance.background_color_description"), + help("background", language), custom, |this, window, cx| { this.persist(&[("background", String::new())], cx); diff --git a/nebula_app/src/gpui_shell/config.rs b/nebula_app/src/gpui_shell/config.rs index 05cc95f..f23052d 100644 --- a/nebula_app/src/gpui_shell/config.rs +++ b/nebula_app/src/gpui_shell/config.rs @@ -94,7 +94,7 @@ pub(crate) fn ui_language(cx: &App) -> UiLanguage { #[inline] fn resolve_ui_language(preference: nebula_settings::LanguagePref) -> UiLanguage { - LanguagePreference::parse(preference.settings_value()).unwrap_or_default().resolved() + LanguagePreference::from(preference).resolved() } impl Settings { diff --git a/nebula_app/src/gpui_shell/mod.rs b/nebula_app/src/gpui_shell/mod.rs index 71f3d5b..eee2574 100644 --- a/nebula_app/src/gpui_shell/mod.rs +++ b/nebula_app/src/gpui_shell/mod.rs @@ -58,6 +58,7 @@ pub(crate) fn try_write_stderr(args: std::fmt::Arguments<'_>) { /// GPUI 壳的跨线程唤醒:托盘、mux ATTACH、runtime 控制都汇到工作区 pump。 pub(crate) enum GpuiShellEvent { TrayFocus(Option), + NotificationFocus(Option), TrayQuit, MuxAttach, RuntimeControl(std::sync::Arc), @@ -71,6 +72,7 @@ pub(crate) enum GpuiShellEvent { /// spike 形态从专用线程调用。一个进程内只允许调用一次。 pub fn run_shell(initial_cwd: Option) { let (shell_tx, shell_rx) = std::sync::mpsc::channel(); + crate::notify::init_gpui_activation(shell_tx.clone()); crate::ssh_prompt::install({ let sender = shell_tx.clone(); move |request| sender.send(GpuiShellEvent::SshPrompt(request)).is_ok() diff --git a/nebula_app/src/gpui_shell/settings_pane.rs b/nebula_app/src/gpui_shell/settings_pane.rs index 95e312f..502f0cf 100644 --- a/nebula_app/src/gpui_shell/settings_pane.rs +++ b/nebula_app/src/gpui_shell/settings_pane.rs @@ -27,6 +27,7 @@ use gpui_component::select::{SelectEvent, SelectItem}; use nebula_settings::{RuntimeSettings, ThemeName, format_hex_rgb, persist_keys}; use design::GROUP_GAP; +use setting_help::{SettingHelp, help}; use std::sync::Arc; use std::time::Duration; @@ -45,210 +46,25 @@ mod backup; mod design; mod font_picker; mod providers; +mod reset; +mod search_header; +mod setting_help; mod theme_picker; +mod initialization; mod keymap; +mod localization; +mod navigation; +mod shell_picker; +mod status; -/// 主题下拉(展示名 = 持久化名,与旧壳一致)。 -const THEME_VALUES: [&str; 9] = [ - "Nebula", - "SilverLight", - "SteelDark", - "LimestoneLight", - "CoalDark", - "LinenLight", - "MossDark", - "Nord", - "Paper", -]; - -const REPOSITORY_URL: &str = "https://github.com/Kuddev/nebula"; -const BUG_REPORT_TEMPLATE: &str = "bug_report.yml"; - -/// 左侧分区的稳定路由表。2026-08-28 产品裁定:默认 GPUI 导航收敛为常用项, -/// 暂时隐藏“AI 供应商”和“备份”;页面实现与索引继续保留。后续恢复入口时只改 -/// [`HIDDEN_NAV_SECTIONS`],不得删除或重排这里的条目。 -const SECTION_IDS: [&str; 10] = [ - "application", - "appearance", - "profiles", - "providers", - "ssh", - "network", - "interaction", - "keymap", - "advanced", - "backup", -]; - -/// Bilingual search aliases for the stable section routes. Search is a route -/// finder, so a query such as "font", "opacity", or "更新" lands on the -/// section that owns the control instead of merely filtering the current page. -const SECTION_SEARCH_TERMS: [&str; 10] = [ - "application app 应用 update 更新 version 版本 github support 支持", - "appearance 外观 theme 主题 font 字体 opacity 透明度 background 背景 cursor 光标 icon 图标", - "profiles 配置文件 shell terminal 终端 completion 补全 startup 启动", - "providers provider ai 供应商 模型 api", - "ssh host 主机 remote 远程 connection 连接", - "network 网络 proxy 代理 connectivity 连接", - "interaction 交互 copy 复制 paste 粘贴 tab 标签 panel 面板", - "keymap key binding shortcut 按键映射 快捷键", - "advanced 高级 session 会话 tray 托盘 restore 恢复", - "backup 备份 export 导出 restore 恢复", -]; - -const HIDDEN_NAV_SECTIONS: &[usize] = &[3, 9]; - -/// 保留原来的分组展开顺序,组名不再渲染;数组里仍保存稳定的 [`SECTION_IDS`] -/// 下标,不复制设置状态或路由。 -const NAV_GROUPS: [(&str, &[usize]); 3] = - [("workspace", &[0, 1, 2, 6, 7]), ("connections", &[3, 4, 5]), ("system", &[8, 9])]; - -fn section_label(index: usize, language: crate::display::UiLanguage) -> &'static str { - match SECTION_IDS.get(index).copied() { - Some("application") => language.tr("settings.sidebar.application"), - Some("appearance") => language.tr("settings.sidebar.appearance"), - Some("profiles") => language.tr("settings.sidebar.profiles"), - Some("providers") => language.tr("settings.sidebar.providers"), - Some("ssh") => language.tr("settings.sidebar.ssh"), - Some("network") => language.tr("settings.sidebar.network"), - Some("interaction") => language.tr("settings.sidebar.interaction"), - Some("keymap") => language.tr("settings.sidebar.keymap"), - Some("advanced") => language.tr("settings.sidebar.advanced"), - Some("backup") => language.tr("settings.sidebar.backup"), - _ => "", - } -} - -fn is_nav_section_visible(index: usize) -> bool { - !HIDDEN_NAV_SECTIONS.contains(&index) -} - -fn visible_nav_sections() -> impl Iterator { - NAV_GROUPS - .iter() - .flat_map(|(_, sections)| sections.iter().copied()) - .filter(|index| is_nav_section_visible(*index)) -} - -// 这些几何值逐项来自旧壳 `display/settings.rs::settings_geometry`。GPUI -// 设置页沿用同一节奏,避免组件默认间距把标题、分组和表单压成一条均匀列表。 -const SETTINGS_NAV_WIDTH: f32 = 232.0; -const SETTINGS_HEADER_HEIGHT: f32 = 52.0; - -const SETTINGS_GROUP_GAP: f32 = 32.0; -const SETTINGS_GROUP_TITLE_HEIGHT: f32 = 26.0; -const SETTINGS_GROUP_TITLE_GAP: f32 = 16.0; -const SETTINGS_ROW_HEIGHT: f32 = 44.0; -const SETTINGS_ROW_GAP: f32 = 8.0; -/// 标准设置选择器的实际宽度。字体输入与 Select 共用,避免同列控件漂移。 -const SETTINGS_SELECT_WIDTH: f32 = 220.0; - -/// 列表/触发条上的展示名:族名偶尔带着导入文件后缀,界面上剥掉。 -fn font_display_name(name: &str) -> String { - let trimmed = name.trim(); - let lower = trimmed.to_ascii_lowercase(); - for ext in [".ttf", ".otf", ".ttc", ".otc", ".woff", ".woff2"] { - if let Some(stem) = lower.strip_suffix(ext) { - return trimmed[..stem.len()].to_owned(); - } - } - trimmed.to_owned() -} - -/// 导航图标。 -/// -/// 统一走路径字符串而不是 `IconName`:绝大多数取 lucide 的现成图标,但 -/// 「按键映射」在 lucide 里没有对应项(原来错挂成 `ALargeSmall`,那是字号 -/// 图标),只能自带一枚——两种来源用同一个类型表达,调用点就不必分叉。 -fn section_icon(index: usize) -> SharedString { - use gpui_component::IconNamed as _; - match index { - 0 => IconName::LayoutDashboard.path(), - 1 => IconName::Palette.path(), - 2 => IconName::GalleryVerticalEnd.path(), - 3 => IconName::Bot.path(), - 4 => IconName::SquareTerminal.path(), - 5 => IconName::Globe.path(), - 6 => IconName::Inspector.path(), - 7 => crate::gpui_shell::assets::nav::KEYMAP.into(), - 8 => IconName::Settings2.path(), - _ => IconName::Inbox.path(), - } -} - -fn chrome_theme(theme: ThemeName) -> crate::display::NebulaTheme { - use crate::display::NebulaTheme; - match theme { - ThemeName::Nebula => NebulaTheme::Nebula, - ThemeName::SilverLight => NebulaTheme::SilverLight, - ThemeName::SteelDark => NebulaTheme::SteelDark, - ThemeName::LimestoneLight => NebulaTheme::LimestoneLight, - ThemeName::CoalDark => NebulaTheme::CoalDark, - ThemeName::LinenLight => NebulaTheme::LinenLight, - ThemeName::MossDark => NebulaTheme::MossDark, - ThemeName::Nord => NebulaTheme::Nord, - ThemeName::Paper => NebulaTheme::Paper, - } -} - -fn rgb_hsla(r: u8, g: u8, b: u8) -> Hsla { - GpuiRgba { r: f32::from(r) / 255.0, g: f32::from(g) / 255.0, b: f32::from(b) / 255.0, a: 1.0 } - .into() -} - -fn query_component(value: &str) -> String { - const HEX: &[u8; 16] = b"0123456789ABCDEF"; - let mut encoded = String::with_capacity(value.len()); - for byte in value.bytes() { - if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') { - encoded.push(char::from(byte)); - } else { - encoded.push('%'); - encoded.push(char::from(HEX[(byte >> 4) as usize])); - encoded.push(char::from(HEX[(byte & 0x0f) as usize])); - } - } - encoded -} - -/// GitHub issue form 预填合同:模板、版本、平台、安装来源 -/// 和诊断摘要都来自当前运行实例,用户只需补充复现步骤与实际表现。 -fn issue_url() -> String { - let version = env!("CARGO_PKG_VERSION"); - let platform = match std::env::consts::OS { - "windows" => "Windows", - "macos" => "macOS", - "linux" => "Linux", - _ => "Other", - }; - let install_source = if cfg!(debug_assertions) { - "Built from source (cargo build / cargo run)" - } else { - "GitHub Release (.msi / .exe / portable archive)" - }; - let build = if cfg!(debug_assertions) { "debug" } else { "release" }; - let logs = format!( - "Reported from {} Settings ({} {version}).\n\nPlatform: {} {}\nBuild: {build}", - crate::brand::NAME, - crate::brand::NAME, - std::env::consts::OS, - std::env::consts::ARCH, - ); - let params = [ - ("template", BUG_REPORT_TEMPLATE), - ("title", "[Bug] "), - ("version", version), - ("platform", platform), - ("install_source", install_source), - ("logs", logs.as_str()), - ] - .into_iter() - .map(|(key, value)| format!("{key}={}", query_component(value))) - .collect::>() - .join("&"); - format!("{REPOSITORY_URL}/issues/new?{params}") -} +use localization::*; +use navigation::*; +use shell_picker::*; +pub(super) use status::SshStatus; +use status::{ + AboutUpdateState, BackupCompletion, BackupStatus, ProviderStatus, TerminalImportError, +}; /// 宿主(workspace)监听:设置已写盘 / 终端目录已变 / 请求打开 SSH 会话。 pub enum SettingsPaneEvent { @@ -264,640 +80,8 @@ pub enum SettingsPaneEvent { pub(super) type SharedSelect = Entity>>; type SharedShellSelect = Entity>>; -/// 固定下拉只在这里把稳定 value 映射为显示文案。创建和语言切换刷新共用 -/// 同一入口,避免 `SelectState` 留着构造时的旧语言。 -fn localized_select_labels( - key: &str, - values: &[&'static str], - language: crate::display::UiLanguage, -) -> Vec { - let labels: Vec<&'static str> = match key { - "language" => vec![ - language.tr("language.system"), - language.tr("language.zh_cn"), - language.tr("language.en_us"), - ], - "cursor_shape" => vec![ - language.pick("条形(│)", "Bar (│)"), - language.pick("下划线(_)", "Underscore (_)"), - language.pick("实心框(█)", "Filled box (█)"), - language.pick("空心框(□)", "Empty box (□)"), - ], - "tabs_position" => { - vec![language.pick("左侧边栏", "Left sidebar"), language.pick("顶部", "Top")] - }, - "tab_reveal" => vec![language.pick("滑动", "Slide"), language.pick("立即", "Instant")], - "density" => vec![language.pick("标准", "Standard"), language.pick("紧凑", "Compact")], - "new_tab_position" => vec![ - language.pick("当前标签之后", "After current tab"), - language.pick("列表末尾", "End of list"), - ], - "windowing_behavior" => vec![ - language.pick("创建新窗口", "Create a new window"), - language.pick("附加到最近使用的窗口", "Attach to the most recent window"), - language.pick( - "附加到此桌面最近使用的窗口", - "Attach to the most recent window on this desktop", - ), - ], - "vcs_display" => vec![ - language.pick("自动检测", "Auto detect"), - language.pick("仅 Git", "Git only"), - language.pick("仅 SVN", "SVN only"), - ], - "cell_width_mode" => { - vec![language.pick("紧凑", "Compact"), language.pick("宽松", "Relaxed")] - }, - "bell" => vec![ - language.pick("关", "Off"), - language.pick("闪烁", "Visual"), - language.pick("声音", "Sound"), - language.pick("闪烁 + 声音", "Visual + sound"), - ], - "blur" => vec![ - language.pick("无", "None"), - language.pick("Mica(低开销)", "Mica (low cost)"), - language.pick("Mica Alt(低开销)", "Mica Alt (low cost)"), - language.pick("Aero(玻璃)", "Aero (glass)"), - language.pick("Acrylic(高开销)", "Acrylic (high cost)"), - ], - "accept" => vec![ - language.pick("右方向键", "Right arrow"), - "Tab", - language.pick("Tab 或右方向键", "Tab or Right arrow"), - ], - "completion_style" => { - vec![language.pick("行内灰字", "Inline ghost"), language.pick("弹窗列表", "Popup list")] - }, - "background_image_fit" => vec![ - language.pick("拉伸", "Fill"), - language.pick("适应", "Uniform"), - language.pick("填充", "Uniform to fill"), - language.pick("原始尺寸", "Original size"), - ], - "background_image_alignment" => vec![ - language.pick("左上", "Top left"), - language.pick("顶部", "Top"), - language.pick("右上", "Top right"), - language.pick("左侧", "Left"), - language.pick("居中", "Center"), - language.pick("右侧", "Right"), - language.pick("左下", "Bottom left"), - language.pick("底部", "Bottom"), - language.pick("右下", "Bottom right"), - ], - "ssh_proxy_mode" => vec![ - language.tr("settings.network.mode.off"), - language.tr("settings.network.mode.system"), - language.tr("settings.network.mode.custom"), - ], - _ => values.to_vec(), - }; - debug_assert_eq!(labels.len(), values.len(), "localized select label/value mismatch: {key}"); - labels.into_iter().map(SharedString::from).collect() -} - -fn provider_input_placeholders(language: crate::display::UiLanguage) -> [&'static str; 5] { - [ - language.pick("供应商名称", "Provider name"), - language.pick("备注(可包含空格)", "Note (spaces allowed)"), - language.pick("官方网站", "Official website"), - language.pick("API 请求地址", "API endpoint"), - language.pick("默认模型", "Default model"), - ] -} - -fn localized_input_placeholder(key: &str, language: crate::display::UiLanguage) -> &'static str { - match key { - "ssh_label" => language.pick("例如:开发服务器", "e.g. Development server"), - "ssh_password" => language.pick("留空则连接时询问", "Leave empty to ask when connecting"), - "ssh_proxy_username" => language.pick("可选", "Optional"), - "ssh_proxy_password" => { - language.pick("留空则保留已存密码", "Leave empty to keep the saved password") - }, - "ssh_jump_host" => language - .pick("user@bastion:22 或 SSH config 别名", "user@bastion:22 or SSH config alias"), - "ssh_icon_filter" => language.pick("搜索图标…", "Search icons..."), - "font_family" => language.pick("输入字体名称", "Enter a font family"), - "backup_password" => { - language.pick("备份密码(至少 8 位)", "Backup password (at least 8 characters)") - }, - "backup_secret" => language.tr("settings.input.backup_secret"), - "keymap_search" => language.pick("搜索动作或按键…", "Search actions or keys..."), - _ => "", - } -} - -#[derive(Clone)] -struct ShellSelectItem { - id: String, - name: SharedString, - closed_image: Option>, - row_image: Option>, -} - -/// 下拉首行那条「导入终端目录」动作行的哨兵 id。 -/// -/// 它不是任何真实 shell:选中只触发目录扫描,绝不会写进 `shell` 设置。 -/// 取这个形状是因为 `shell_detect` 的 id 全是普通标识符(`pwsh`、`cmd`、 -/// `wsl:`、`profile:<家族>|`),双下划线包裹不可能与之相撞。 -const SHELL_IMPORT_ACTION_ID: &str = "__nebula_import_terminal_dir__"; - -#[derive(Clone, Debug, Default)] -enum AboutUpdateState { - #[default] - Idle, - Checking, - UpToDate(String), - Available(String), - Failed(String), -} - -#[derive(Clone, Debug)] -enum ProviderStatus { - Saved, - Added, - AtLeastOneRequired, - Deleted, - ApiKeySaved, - Testing, - TestResult { outcome: crate::provider_test::ProviderTestOutcome, elapsed_ms: u64 }, - CodexConfirmation, - AppliedToCodex(std::path::PathBuf), - Error(String), -} - -impl ProviderStatus { - fn is_error(&self) -> bool { - match self { - Self::AtLeastOneRequired | Self::Error(_) => true, - Self::TestResult { outcome, .. } => !outcome.is_success(), - _ => false, - } - } - - fn text(&self, language: crate::display::UiLanguage) -> String { - match self { - Self::Saved => language.pick("供应商配置已保存", "Provider settings saved").into(), - Self::Added => language.pick("已添加自定义供应商", "Custom provider added").into(), - Self::AtLeastOneRequired => { - language.pick("至少保留一个供应商", "Keep at least one provider").into() - }, - Self::Deleted => language - .pick("供应商及其凭据已删除", "Provider and its credentials deleted") - .into(), - Self::ApiKeySaved => language - .pick( - "API Key 已保存到系统凭据管理器", - "API key saved to the system credential manager", - ) - .into(), - Self::Testing => language.pick("正在测试连接…", "Testing connection...").into(), - Self::TestResult { outcome, elapsed_ms } => { - format!("{} · {elapsed_ms} ms", language.provider_test_message(outcome)) - }, - Self::CodexConfirmation => language - .pick( - "再次点击确认:API Key 将明文写入 Codex auth.json(原文件会备份)", - "Click again to confirm: the API key will be written in plain text to Codex auth.json (the original file will be backed up)", - ) - .into(), - Self::AppliedToCodex(path) => format!( - "{}: {}", - language.pick("已应用到 Codex", "Applied to Codex"), - path.display() - ), - Self::Error(error) => format!( - "{}: {error}", - language.pick("操作失败", "Operation failed") - ), - } - } -} - -#[derive(Clone, Debug)] -enum BackupCompletion { - Exported(std::path::PathBuf), - Restored, - Pushed(String), - Pulled(String), -} - -#[derive(Clone, Debug)] -enum BackupStatus { - PassphraseTooShort, - SelectionRequired, - Processing, - RemoteConfigSaved, - CredentialEmpty, - CredentialUnsupported, - CredentialSaved, - Completed(BackupCompletion), - Error(String), -} - -impl BackupStatus { - fn is_error(&self) -> bool { - matches!( - self, - Self::PassphraseTooShort - | Self::SelectionRequired - | Self::CredentialEmpty - | Self::CredentialUnsupported - | Self::Error(_) - ) - } - - fn text(&self, language: crate::display::UiLanguage) -> String { - match self { - Self::PassphraseTooShort => language - .pick("备份密码至少 8 位", "The backup password must be at least 8 characters") - .into(), - Self::SelectionRequired => language - .pick("请至少勾选一个备份类别", "Select at least one backup category") - .into(), - Self::Processing => language.pick("处理中…", "Processing...").into(), - Self::RemoteConfigSaved => { - language.pick("远端配置已保存", "Remote configuration saved").into() - }, - Self::CredentialEmpty => { - language.pick("凭据不能为空", "Credentials cannot be empty").into() - }, - Self::CredentialUnsupported => language - .pick( - "当前协议不需要独立凭据", - "The current protocol does not use a separate credential", - ) - .into(), - Self::CredentialSaved => language - .pick( - "凭据已写入系统凭据管理器", - "Credential saved to the system credential manager", - ) - .into(), - Self::Completed(BackupCompletion::Exported(path)) => format!( - "{}: {}", - language.pick("已导出加密备份", "Encrypted backup exported"), - path.display() - ), - Self::Completed(BackupCompletion::Restored) => language - .pick( - "已从备份恢复(字体/托盘等部分设置重启后生效)", - "Backup restored (some settings, including fonts and tray options, apply after restart)", - ) - .into(), - Self::Completed(BackupCompletion::Pushed(location)) => { - format!("{} {location}", language.pick("已推送到", "Pushed to")) - }, - Self::Completed(BackupCompletion::Pulled(name)) => format!( - "{} {name} {}", - language.pick("已从", "Restored from"), - language.pick("恢复(部分设置重启后生效)", "(some settings apply after restart)") - ), - Self::Error(error) => format!( - "{}: {error}", - language.pick("备份操作失败", "Backup operation failed") - ), - } - } -} - -#[derive(Debug)] -enum TerminalImportError { - Scan(String), - NoSupportedTerminal, - Load(String), - Import(String), - Save(String), -} - -impl TerminalImportError { - fn text(self, language: crate::display::UiLanguage) -> String { - match self { - Self::Scan(error) => format!( - "{}: {error}", - language.pick("无法扫描终端目录", "Could not scan the terminal directory") - ), - Self::NoSupportedTerminal => language - .pick( - "目录中未找到受支持的终端程序", - "No supported terminal program was found in the directory", - ) - .into(), - Self::Load(error) => format!( - "{}: {error}", - language.pick("无法读取终端配置", "Could not read terminal profiles") - ), - Self::Import(error) => format!( - "{}: {error}", - language.pick("无法导入终端", "Could not import the terminal") - ), - Self::Save(error) => format!( - "{}: {error}", - language.pick("无法保存终端配置", "Could not save terminal profiles") - ), - } - } -} - -#[derive(Clone, Debug)] -pub(super) enum SshStatus { - Saved(String), - Pinned, - Imported(usize), - Opening(String), - DeleteCommitted { hidden_config: bool }, - CleanupPartial(String), - Restored(String), - Validation(SshValidationError), - PersistFailed(String), - DeleteFailed(String), - UndoFailed(String), - TestStartFailed(String), - ProfileLoadFailed(String), - ProfileSaveFailed(String), - HostListSaveFailed(String), - CredentialSaveFailed(String), - SavedWithCleanupError { destination: String, error: String }, - Error(String), -} - -impl SshStatus { - pub(super) fn is_error(&self) -> bool { - matches!( - self, - Self::CleanupPartial(_) - | Self::Validation(_) - | Self::PersistFailed(_) - | Self::DeleteFailed(_) - | Self::UndoFailed(_) - | Self::TestStartFailed(_) - | Self::ProfileLoadFailed(_) - | Self::ProfileSaveFailed(_) - | Self::HostListSaveFailed(_) - | Self::CredentialSaveFailed(_) - | Self::SavedWithCleanupError { .. } - | Self::Error(_) - ) - } - - pub(super) fn text(&self, language: crate::display::UiLanguage) -> String { - match self { - Self::Saved(destination) => { - format!("{} {destination}", language.pick("已保存", "Saved")) - }, - Self::Pinned => language.pick("置顶状态已更新", "Pin status updated").into(), - Self::Imported(count) => format!( - "{} {count} {}", - language.pick("已导入,config 源共", "Imported"), - language.pick("个别名", "config aliases") - ), - Self::Opening(host) => { - format!("{} {host}…", language.pick("正在打开", "Opening")) - }, - Self::DeleteCommitted { hidden_config: true } => language - .pick( - "已隐藏 config 别名,并清理 Pebrel Profile 与凭据", - "Config alias hidden; Pebrel profile and credentials removed", - ) - .into(), - Self::DeleteCommitted { hidden_config: false } => language - .pick( - "已删除主机、Profile 与凭据", - "Host, profile, and credentials deleted", - ) - .into(), - Self::CleanupPartial(details) => format!( - "{}: {details}", - language.pick( - "主机已从列表移除,但部分清理失败", - "Host removed from the list, but some cleanup failed", - ) - ), - Self::Restored(host) => { - format!("{} {host}", language.pick("已恢复", "Restored")) - }, - Self::Validation(error) => error.text(language).into(), - Self::PersistFailed(error) => format!( - "{}: {error}", - language.pick("写入设置失败", "Failed to write settings") - ), - Self::DeleteFailed(error) => format!( - "{}: {error}", - language.pick("删除主机失败", "Failed to delete host") - ), - Self::UndoFailed(error) => { - format!("{}: {error}", language.pick("撤销失败", "Undo failed")) - }, - Self::TestStartFailed(error) => format!( - "{}: {error}", - language.pick("无法启动连接测试", "Could not start the connection test") - ), - Self::ProfileLoadFailed(error) => format!( - "{}: {error}", - language.pick("加载 SSH Profile 失败", "Failed to load the SSH profile") - ), - Self::ProfileSaveFailed(error) => format!( - "{}: {error}", - language.pick("保存 SSH Profile 失败", "Failed to save the SSH profile") - ), - Self::HostListSaveFailed(error) => format!( - "{}: {error}", - language.pick("保存主机列表失败", "Failed to save the host list") - ), - Self::CredentialSaveFailed(error) => format!( - "{}: {error}", - language.pick( - "Profile 已保存,但密码写入凭据管理器失败", - "The profile was saved, but the password could not be written to the credential manager", - ) - ), - Self::SavedWithCleanupError { destination, error } => format!( - "{} {destination}, {}: {error}", - language.pick("已保存", "Saved"), - language.pick( - "但旧地址凭据清理失败", - "but credentials for the previous address could not be removed", - ) - ), - Self::Error(error) => { - format!("{}: {error}", language.pick("SSH 操作失败", "SSH operation failed")) - }, - } - } -} - use super::ssh_settings::{SshDeleteUndo, SshEditorState, SshValidationError}; -impl ShellSelectItem { - fn new(id: String, name: String, scale_factor: f32) -> Self { - // Select 的闭态和菜单行尺寸不同。分别生成与物理像素一一对应的纹理, - // 避免把 128px 原图交给 GPUI 在每帧缩小而产生模糊边缘。 - let closed_image = crate::gpui_shell::widgets::shell_brand_image(&id, 20.0, scale_factor); - let row_image = crate::gpui_shell::widgets::shell_brand_image(&id, 24.0, scale_factor); - Self { id, name: name.into(), closed_image, row_image } - } - - /// 置顶的导入行。没有品牌贴图,[`Self::view`] 会给它文件夹图标。 - fn import_action(language: crate::display::UiLanguage) -> Self { - Self { - id: SHELL_IMPORT_ACTION_ID.to_owned(), - name: language.pick("导入终端目录…", "Import terminal directory...").into(), - closed_image: None, - row_image: None, - } - } - - fn is_import_action(&self) -> bool { - self.id == SHELL_IMPORT_ACTION_ID - } - - fn view(&self, size: f32, image: Option<&Arc>) -> gpui::AnyElement { - let icon: gpui::AnyElement = if let Some(image) = image { - gpui::StyledImage::object_fit( - img(image.clone()).size(px(size)).flex_shrink_0(), - gpui::ObjectFit::Contain, - ) - .into_any_element() - } else if self.is_import_action() { - // 动作行与真实 shell 行必须一眼分得开:文件夹口 = 「去别处拿」。 - Icon::new(IconName::FolderOpen).xsmall().into_any_element() - } else { - Icon::new(IconName::SquareTerminal).xsmall().into_any_element() - }; - h_flex() - .gap_2() - .items_center() - .child(icon) - .child(div().flex_1().min_w_0().child(self.name.clone())) - .into_any_element() - } -} - -/// 默认 Shell 下拉的全部候选,以及应当选中的行号。 -/// -/// 顺序 = 置顶导入行 → 已安装 shell(`detect_shells` 菜单序)→ 用户导入的 -/// 终端 profile。导入项以 `profile:<家族>|` 作设置值([`Profile::settings_id`] -/// 同一形状),品牌图标因此仍能按家族查到。 -fn shell_select_items( - current: &str, - scale_factor: f32, - language: crate::display::UiLanguage, -) -> (Vec, usize) { - let mut items: Vec = crate::shell_detect::detect_shells() - .into_iter() - .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), - ]; - } - // 导入的终端目录:`merge_terminal_profiles` 已把它们并进配置的 profile - // 列表,这里让设置页也能直接选为默认 Shell——否则导入完看不见结果。 - if let Ok(store) = crate::terminal_profiles::TerminalProfiles::load() { - for profile in store.as_config_profiles() { - let Some(id) = profile.settings_id() else { continue }; - if items.iter().any(|item| item.id == id) { - continue; - } - items.push(ShellSelectItem::new(id, profile.name, scale_factor)); - } - } - if !items.iter().any(|item| item.id == current) { - // 检测结果可能暂时找不到已保存的 WSL/profile id;先把它保留在首位, - // 用户仍可看到并重新选择,下一次检测恢复后不会丢失持久化值。 - items.insert( - 0, - ShellSelectItem::new( - current.to_owned(), - crate::shell_detect::display_name_for_id(current).to_owned(), - scale_factor, - ), - ); - } - let selected = items.iter().position(|item| item.id == current).unwrap_or(0); - // 导入行最后才插到首位:它不参与选中判定,所以选中行号整体后移一位。 - items.insert(0, ShellSelectItem::import_action(language)); - (items, selected + 1) -} - -/// 扫描目录并落盘(阻塞 IO,调用方须放后台执行器)。 -/// 逻辑与旧壳 `Display::import_terminal_directory` 逐句对齐,只是把 toast -/// 换成 `Result`,由 UI 线程决定怎么呈现。 -fn import_terminal_directory_blocking( - directory: &std::path::Path, -) -> Result { - let found = crate::terminal_profiles::scan_directory(directory) - .map_err(|error| TerminalImportError::Scan(error.to_string()))?; - if found.is_empty() { - return Err(TerminalImportError::NoSupportedTerminal); - } - let mut profiles = crate::terminal_profiles::TerminalProfiles::load() - .map_err(|error| TerminalImportError::Load(error.to_string()))?; - let count = found.len(); - for profile in found { - profiles.upsert(profile).map_err(|error| TerminalImportError::Import(error.to_string()))?; - } - profiles.save().map_err(|error| TerminalImportError::Save(error.to_string()))?; - Ok(count) -} - -/// 原生模态对话框不能在 GPUI update 借用中运行:它自己的消息泵会重入 -/// wndproc,造成 AppCell 二次可变借用。与 SSH 私钥选择器一样,先在 UI -/// 线程捕获 HWND,再让专用线程运行旧壳的 IFileOpenDialog。 -#[cfg(windows)] -fn pick_folder_with_wsl_places( - window: &Window, - title: &'static str, -) -> futures::channel::oneshot::Receiver> { - use winit::raw_window_handle::{HasWindowHandle, RawWindowHandle}; - - let owner = HasWindowHandle::window_handle(window) - .ok() - .and_then(|handle| match handle.as_raw() { - RawWindowHandle::Win32(handle) => Some(handle.hwnd.get() as usize), - _ => None, - }) - .unwrap_or(0); - let (tx, rx) = futures::channel::oneshot::channel(); - std::thread::spawn(move || { - let selected = crate::display::file_dialog::pick_folder_with_hwnd(owner as _, title); - let _ = tx.send(selected); - }); - rx -} - -impl SelectItem for ShellSelectItem { - type Value = String; - - fn title(&self) -> SharedString { - self.name.clone() - } - - fn display_title(&self) -> Option { - // 闭态留出 chevron 与上下内边距;20px 在 32px Select 内不会挤字。 - Some(self.view(20.0, self.closed_image.as_ref())) - } - - fn render(&self, _: &mut Window, _: &mut App) -> impl IntoElement { - // 旧壳 ShellPickerRow 的品牌图标是 24×24 逻辑像素。 - self.view(24.0, self.row_image.as_ref()) - } - - fn value(&self) -> &Self::Value { - &self.id - } - - fn matches(&self, query: &str) -> bool { - self.name.to_lowercase().contains(&query.to_lowercase()) - || self.id.to_lowercase().contains(&query.to_lowercase()) - } -} - pub struct SettingsPane { pub(super) focus_handle: FocusHandle, /// 渲染与写盘的单一事实源;每次 persist 后整体重载。 @@ -907,8 +91,7 @@ pub struct SettingsPane { appearance_picker: Option, theme_picker_trigger: FocusHandle, icon_picker_trigger: FocusHandle, - typography_expanded: bool, - appearance_advanced_expanded: bool, + expanded_setting_help: std::collections::HashSet<&'static str>, about_update: AboutUpdateState, about_update_seq: u64, about_last_checked: Option, @@ -1023,581 +206,6 @@ pub struct SettingsPane { } impl SettingsPane { - pub fn new(window: &mut Window, cx: &mut Context) -> Self { - let runtime = RuntimeSettings::load(); - let language = crate::gpui_shell::config::ui_language(cx); - let mut selects: Vec<(&'static str, SharedSelect, &'static [&'static str])> = Vec::new(); - let mut subscriptions = Vec::new(); - - let mut add_select = |key: &'static str, - values: &'static [&'static str], - current: &str, - window: &mut Window, - cx: &mut Context| { - let ix = values.iter().position(|v| *v == current).unwrap_or(0); - let select = cx.new(|cx| { - SelectState::new( - localized_select_labels(key, values, language), - Some(IndexPath::default().row(ix)), - window, - cx, - ) - }); - subscriptions.push(cx.subscribe_in( - &select, - window, - move |this: &mut Self, - entity: &SharedSelect, - event: &SelectEvent>, - window: &mut Window, - cx: &mut Context| { - if let SelectEvent::Confirm(Some(_)) = event { - let row = entity.read(cx).selected_index(cx).map(|path| path.row); - if let Some(value) = row.and_then(|row| values.get(row)) { - this.persist(&[(key, (*value).to_string())], cx); - if key == "language" { - this.refresh_localized_controls(window, cx); - cx.refresh_windows(); - } - } - } - }, - )); - selects.push((key, select, values)); - }; - - 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()); - - add_select( - "language", - &["system", "zh-CN", "en-US"], - runtime.language.settings_value(), - window, - cx, - ); - add_select("theme", &THEME_VALUES, runtime.theme.prompt_name(), window, cx); - // 选项顺序与文案照抄旧壳 `CURSOR_SHAPE_OPTIONS` / `cursor_shape_label`。 - add_select( - "cursor_shape", - &["beam", "underline", "block", "hollow"], - cursor_current, - window, - cx, - ); - add_select( - "tabs_position", - &["sidebar", "top"], - runtime.tabs_position.settings_value(), - window, - cx, - ); - add_select( - "tab_reveal", - &["slide", "instant"], - runtime.tab_reveal.settings_value(), - window, - cx, - ); - add_select( - "density", - &["standard", "compact"], - runtime.density.settings_value(), - window, - cx, - ); - add_select( - "new_tab_position", - &["after_current", "end"], - runtime.new_tab_position.settings_value(), - window, - cx, - ); - add_select( - "windowing_behavior", - &["use_new", "use_any_existing", "use_existing"], - runtime.windowing_behavior.settings_value(), - window, - cx, - ); - add_select( - "vcs_display", - &["auto", "git", "svn"], - runtime.vcs_display.settings_value(), - window, - cx, - ); - add_select( - "cell_width_mode", - &["compact", "relaxed"], - runtime.cell_width_mode.settings_value(), - window, - cx, - ); - // 文案照抄旧壳 `accept_label` / `completion_style_label`。 - add_select( - "bell", - &["none", "visual", "audible", "both"], - runtime.bell.settings_value(), - window, - cx, - ); - // 五档按 DWM 每帧成本排列,不是质量递进;用户按性能预算选择。 - // Aero 是实时玻璃,Acrylic 是实时材质模糊;两者都会采样窗口后方真实内容。 - // Mica / Mica Alt 使用系统壁纸 backdrop,不由 Nebula 读取或重采样。 - add_select( - "blur", - &["none", "mica", "mica-alt", "aero", "acrylic"], - runtime.blur.settings_value(), - window, - cx, - ); - add_select( - "accept", - &["right", "tab", "both"], - runtime.accept.settings_value(), - window, - cx, - ); - add_select( - "completion_style", - &["inline", "popup"], - runtime.completion_style.settings_value(), - window, - cx, - ); - // 壁纸 fit/对齐:存原文,经旧壳 renderer::image 的 parse 归一化 - // (兼容 cover/contain 等别名),展示用规范记号。 - let bgimg_fit = crate::renderer::image::BackgroundImageFit::parse( - runtime.background_image_fit.as_deref().unwrap_or(""), - ) - .unwrap_or_default() - .settings_value(); - // 顺序与文案照抄旧壳 `BACKGROUND_FIT_OPTIONS` / `background_image_fit_label`。 - add_select( - "background_image_fit", - &["fill", "uniform", "uniform_to_fill", "none"], - bgimg_fit, - window, - cx, - ); - let bgimg_align = crate::renderer::image::BackgroundImageAlignment::parse( - runtime.background_image_alignment.as_deref().unwrap_or(""), - ) - .unwrap_or_default() - .settings_value(); - // 九宫格顺序照抄旧壳 `BACKGROUND_ALIGNMENT_OPTIONS`(左上 → 右下)。 - add_select( - "background_image_alignment", - &[ - "top_left", - "top", - "top_right", - "left", - "center", - "right", - "bottom_left", - "bottom", - "bottom_right", - ], - bgimg_align, - window, - cx, - ); - add_select( - "ssh_proxy_mode", - &["off", "system", "custom"], - runtime.ssh_proxy_mode.settings_value(), - window, - cx, - ); - - // 与旧壳的默认 Shell 菜单共用检测层:不能在设置页另维护一份两项 - // 白名单,否则 CMD/Nushell/WSL 会出现在新建终端菜单,却无法设为默认。 - // 选项 = 彩色品牌 PNG(extra/shell-icons,与旧壳设置页/命令面板同 - // 一批资产)+ 名称,闭态与下拉同源(SelectItem::display_title/render)。 - let shell_icon_scale = window.scale_factor().max(0.5); - let (shell_items, shell_index) = - shell_select_items(&shell_current, shell_icon_scale, language); - let shell_select = cx.new(|cx| { - SelectState::new(shell_items, Some(IndexPath::default().row(shell_index)), window, cx) - }); - subscriptions.push(cx.subscribe_in( - &shell_select, - window, - move |this: &mut Self, - _entity: &SharedShellSelect, - event: &SelectEvent>, - window: &mut Window, - cx: &mut Context| { - if let SelectEvent::Confirm(Some(id)) = event { - // 置顶那行是动作不是选项:走导入流程,且不落盘。 - if id == SHELL_IMPORT_ACTION_ID { - this.import_terminal_directory(window, cx); - } else { - this.persist(&[("shell", id.clone())], cx); - } - } - }, - )); - - let bg_hex_input = { - let term = crate::gpui_shell::theme::chrome_theme_resolved(cx).palette().term_bg; - let rgb = runtime.background.unwrap_or([term.r, term.g, term.b]); - let input = cx.new(|cx| { - InputState::new(window, cx) - .placeholder("#rrggbb") - .default_value(format_hex_rgb(rgb)) - }); - subscriptions.push(cx.subscribe_in( - &input, - window, - |this: &mut Self, - _: &Entity, - event: &InputEvent, - window: &mut Window, - cx: &mut Context| { - this.on_bg_hex_event(event, window, cx); - }, - )); - input - }; - let opacity_slider = cx.new(|_| { - SliderState::new().min(0.00).max(1.00).step(0.05).default_value(runtime.opacity) - }); - subscriptions.push(cx.subscribe(&opacity_slider, |this, _, event: &SliderEvent, cx| { - if let SliderEvent::Change(value) = event { - this.set_opacity(value.start(), cx); - } - })); - let wallpaper_opacity_slider = cx.new(|_| { - SliderState::new() - .min(0.05) - .max(1.00) - .step(0.05) - .default_value(runtime.background_image_opacity) - }); - subscriptions.push(cx.subscribe( - &wallpaper_opacity_slider, - |this, _, event: &SliderEvent, cx| { - if let SliderEvent::Change(value) = event { - this.set_wallpaper_opacity(value.start(), cx); - } - }, - )); - let (proxy_protocol, proxy_address) = - crate::display::manual_proxy_parts(&runtime.ssh_proxy_url); - let proxy_protocol_ix = crate::display::MANUAL_PROXY_PROTOCOL_OPTIONS - .iter() - .position(|item| *item == proxy_protocol) - .unwrap_or(0); - let proxy_protocol_select = cx.new(|cx| { - SelectState::new( - vec![SharedString::from("SOCKS5"), SharedString::from("HTTP")], - Some(IndexPath::default().row(proxy_protocol_ix)), - window, - cx, - ) - }); - subscriptions.push(cx.subscribe_in( - &proxy_protocol_select, - window, - |this: &mut Self, - _, - event: &SelectEvent>, - _, - cx: &mut Context| { - if matches!(event, SelectEvent::Confirm(Some(_))) { - this.commit_proxy_address(cx); - } - }, - )); - let proxy_url_input = cx.new(|cx| { - InputState::new(window, cx) - .placeholder("127.0.0.1:7890") - .pattern(regex::Regex::new(r"^[^\s]{0,256}$").expect("static regex")) - .default_value(proxy_address.to_owned()) - }); - subscriptions.push(cx.subscribe_in( - &proxy_url_input, - window, - |this: &mut Self, _, event: &InputEvent, _, cx: &mut Context| { - this.on_proxy_address_event(event, cx); - }, - )); - let provider_store = crate::ai_providers::load(); - let active_provider = provider_store - .providers - .iter() - .find(|provider| provider.id == provider_store.active_id) - .or_else(|| provider_store.providers.first()) - .cloned() - .unwrap_or_else(|| { - crate::ai_providers::AiProvider::preset( - crate::ai_providers::ProviderKind::Custom, - "custom-1", - ) - }); - let provider_values = [ - active_provider.name, - active_provider.note, - active_provider.website_url, - active_provider.base_url, - active_provider.model, - ]; - let provider_placeholders = provider_input_placeholders(language); - let provider_inputs = provider_values - .into_iter() - .zip(provider_placeholders) - .map(|(value, placeholder)| { - cx.new(|cx| { - InputState::new(window, cx).placeholder(placeholder).default_value(value) - }) - }) - .collect(); - - // 远端备份的非密文槽位输入(容量按最多槽位的协议 S3 = 4);值在 - // 构造体内按当前协议回填。 - let backup_remote_inputs: Vec> = - (0..4).map(|_| cx.new(|cx| InputState::new(window, cx))).collect(); - - let ssh_username_input = cx.new(|cx| InputState::new(window, cx).placeholder("root")); - // 地址框现在只承担 host/IP;仍兼容粘贴整段 user@host,由共享 helper - // 在保存/测试时拆出内嵌用户名。 - let ssh_destination_input = - cx.new(|cx| InputState::new(window, cx).placeholder("example.com / 192.0.2.1")); - // 端口键入即过滤:至多 5 位数字(旧壳同规则;范围校验在保存/测试时做)。 - let ssh_port_input = cx.new(|cx| { - InputState::new(window, cx) - .placeholder("22") - .pattern(regex::Regex::new(r"^\d{0,5}$").expect("static regex")) - }); - let ssh_label_input = cx.new(|cx| { - InputState::new(window, cx) - .placeholder(localized_input_placeholder("ssh_label", language)) - }); - let ssh_password_input = cx.new(|cx| { - InputState::new(window, cx) - .masked(true) - .placeholder(localized_input_placeholder("ssh_password", language)) - }); - let ssh_icon_filter_input = cx.new(|cx| { - InputState::new(window, cx) - .placeholder(localized_input_placeholder("ssh_icon_filter", language)) - }); - let ssh_proxy_host_input = - cx.new(|cx| InputState::new(window, cx).placeholder("127.0.0.1")); - let ssh_proxy_port_input = cx.new(|cx| { - InputState::new(window, cx) - .placeholder("1080") - .pattern(regex::Regex::new(r"^\d{0,5}$").expect("static regex")) - }); - let ssh_proxy_username_input = cx.new(|cx| { - InputState::new(window, cx) - .placeholder(localized_input_placeholder("ssh_proxy_username", language)) - }); - let ssh_proxy_password_input = cx.new(|cx| { - InputState::new(window, cx) - .masked(true) - .placeholder(localized_input_placeholder("ssh_proxy_password", language)) - }); - let ssh_jump_host_input = cx.new(|cx| { - InputState::new(window, cx) - .placeholder(localized_input_placeholder("ssh_jump_host", language)) - }); - for input in [ - ssh_username_input.clone(), - ssh_destination_input.clone(), - ssh_port_input.clone(), - ssh_label_input.clone(), - ssh_password_input.clone(), - ssh_proxy_host_input.clone(), - ssh_proxy_port_input.clone(), - ssh_proxy_username_input.clone(), - ssh_proxy_password_input.clone(), - ssh_jump_host_input.clone(), - ] { - subscriptions.push(cx.subscribe_in( - &input, - window, - |this: &mut Self, _, event: &InputEvent, _, cx: &mut Context| { - if matches!(event, InputEvent::Change) { - this.touch_ssh_editor(cx); - } - }, - )); - } - let font_family_input = - Self::new_font_family_input(runtime.font_family.clone(), window, cx); - subscriptions.push(cx.subscribe_in( - &font_family_input, - window, - |this: &mut Self, _, event: &InputEvent, window, cx| { - this.on_font_family_input_event(event, window, cx); - }, - )); - - let bg_picker_hsv = { - let term = crate::gpui_shell::theme::chrome_theme_resolved(cx).palette().term_bg; - let rgb = runtime.background.unwrap_or([term.r, term.g, term.b]); - crate::display::rgb_to_hsv(crate::display::color::Rgb::new(rgb[0], rgb[1], rgb[2])) - }; - - // GPUI 会先匹配 KeyBinding action,再派发元素的 capture/bubble KeyDown。 - // 因此录制 PageDown 等已有快捷键必须在应用级 interceptor 提前截住; - // 焦点检查保证后台设置标签或普通设置输入不受影响。 - let keymap_interceptor = cx.listener(|this, event: &gpui::KeystrokeEvent, window, cx| { - if this.keymap_capture.is_some() && this.focus_handle.contains_focused(window, cx) { - cx.stop_propagation(); - this.handle_keymap_capture(&event.keystroke, cx); - } - }); - subscriptions.push(cx.intercept_keystrokes(keymap_interceptor)); - let appearance_interceptor = cx.listener(Self::intercept_appearance_picker); - subscriptions.push(cx.intercept_keystrokes(appearance_interceptor)); - - let settings_search_input = cx.new(|cx| { - InputState::new(window, cx).placeholder(language.pick( - "搜索全部设置,例如「字号」「透明度」「更新」", - "Search all settings, e.g. font, opacity, update", - )) - }); - subscriptions.push(cx.subscribe_in( - &settings_search_input, - window, - |_this: &mut Self, - _: &Entity, - event: &InputEvent, - _: &mut Window, - cx: &mut Context| { - if matches!(event, InputEvent::Change | InputEvent::Focus | InputEvent::Blur) { - cx.notify(); - } - }, - )); - - Self { - focus_handle: cx.focus_handle(), - runtime, - active_section: 0, - appearance_picker: None, - theme_picker_trigger: cx.focus_handle(), - icon_picker_trigger: cx.focus_handle(), - typography_expanded: false, - appearance_advanced_expanded: false, - about_update: AboutUpdateState::Idle, - about_update_seq: 0, - about_last_checked: None, - settings_search_input, - settings_search_trigger_bounds: None, - selects, - shell_select, - bg_picker_open: false, - bg_picker_hsv, - bg_picker_drag: None, - bg_hex_input, - bg_hex_focused: false, - bg_hex_syncing: false, - bg_picker_trigger_bounds: None, - bg_sv_bounds: None, - bg_hue_bounds: None, - opacity_slider, - wallpaper_opacity_slider, - proxy_url_input, - proxy_protocol_select, - proxy_test_seq: 0, - proxy_test_status: crate::display::ProxyTestStatus::Idle, - provider_store, - provider_inputs, - provider_status: None, - provider_test_seq: 0, - provider_test_running: false, - provider_codex_confirm: None, - ssh_hosts: crate::gpui_shell::ssh_hosts::SshHostLists::load(), - ssh_username_input, - ssh_destination_input, - ssh_port_input, - ssh_label_input, - ssh_password_input, - ssh_proxy_host_input, - ssh_proxy_port_input, - ssh_proxy_username_input, - ssh_proxy_password_input, - ssh_jump_host_input, - ssh_username_picker_open: false, - ssh_username_trigger_bounds: None, - ssh_icon_picker_open: false, - ssh_icon_filter_input, - ssh_icon_trigger_bounds: None, - ssh_editor: None, - ssh_editor_focus_handle: cx.focus_handle(), - ssh_editor_seq: 0, - ssh_test_seq: 0, - ssh_status: None, - ssh_show_hidden: false, - ssh_delete_confirm: None, - ssh_delete_undo: None, - ssh_undo_seq: 0, - font_picker_open: false, - font_loading: false, - font_system: None, - font_imported: Vec::new(), - font_family_input, - font_picker_trigger_bounds: None, - backup_selection: crate::encrypted_backup::BackupSelection::default(), - backup_pass_input: cx.new(|cx| { - InputState::new(window, cx) - .masked(true) - .placeholder(localized_input_placeholder("backup_password", language)) - }), - backup_status: None, - backup_busy: false, - backup_seq: 0, - backup_remote: { - let cfg = crate::backup_remote::BackupRemoteConfig::load(); - for (ix, input) in backup_remote_inputs.iter().enumerate() { - let value = cfg.slot(ix).unwrap_or_default().to_owned(); - input.update(cx, |input, cx| input.set_value(value, window, cx)); - } - cfg - }, - backup_remote_inputs, - backup_secret_input: cx.new(|cx| { - InputState::new(window, cx) - .masked(true) - .placeholder(localized_input_placeholder("backup_secret", language)) - }), - keymap_search_input: { - let input = cx.new(|cx| { - InputState::new(window, cx) - .placeholder(localized_input_placeholder("keymap_search", language)) - }); - subscriptions.push(cx.subscribe_in( - &input, - window, - |_this: &mut Self, - _: &Entity, - event: &InputEvent, - _: &mut Window, - cx: &mut Context| { - // 搜索词变化只影响可见行集合;捕获态不因打字被打断 - // (捕获期间焦点在分区根上,输入框收不到键)。 - if matches!(event, InputEvent::Change) { - cx.notify(); - } - }, - )); - input - }, - keymap_capture: None, - keymap_capture_preview: String::new(), - keymap_binds: nebula_settings::keybind_pairs(), - slider_persist: None, - _subscriptions: subscriptions, - } - } - /// 写盘 → 重载单一事实源与全局 `Settings` → 通知宿主热应用。 pub(super) fn persist(&mut self, updates: &[(&str, String)], cx: &mut Context) { if let Err(err) = self.try_persist(updates, cx) { @@ -1749,33 +357,7 @@ impl SettingsPane { self.sync_background_color_picker(window, cx); } - /// 旧壳“恢复默认设置”只重置外观,不触碰 Shell、SSH、快捷键等业务配置。 - fn reset_appearance(&mut self, window: &mut Window, cx: &mut Context) { - self.close_font_picker(window, false, cx); - self.persist( - &[ - ("theme", "Nebula".to_owned()), - ("app_icon", nebula_settings::AppIconName::default().settings_value().to_owned()), - ("font_family", String::new()), - ("font_size", String::new()), - ("follow_system_theme", "0".to_owned()), - ("opacity", "1".to_owned()), - ("background", String::new()), - ("background_image", String::new()), - ("background_image_opacity", "0.38".to_owned()), - ("background_image_fit", String::new()), - ("background_image_alignment", String::new()), - ("background_image_cover_chrome", "0".to_owned()), - ], - cx, - ); - self.typography_expanded = false; - let family = self.current_font_chain(cx); - self.font_family_input.update(cx, |input, cx| input.set_value(family, window, cx)); - self.sync_background_color_picker(window, cx); - } - - /// Workspace tab、设置导航和设置内容共用的主文字字号事实源。 + /// Workspace tab 与设置正文共用的主文字字号事实源;导航使用组件小字号。 /// /// 旧壳合同(display/mod.rs `ui_font_px`):chrome 排版锚定**配置字号** /// (nebula.toml `font.size`,默认 11.25pt = 15px),终端的持久化缩放 @@ -1997,7 +579,7 @@ impl SettingsPane { &self, key: &'static str, label: &'static str, - desc: &'static str, + desc: impl Into, cx: &mut Context, ) -> impl IntoElement { let select = self.select_of(key); @@ -2015,10 +597,7 @@ impl SettingsPane { let language = crate::gpui_shell::config::ui_language(cx); self.row( language.pick("默认 Shell", "Default shell"), - language.pick( - "新标签用哪个程序开。已经开着的标签不受影响——它们跟的是各自创建时的选择。", - "Chooses the program used for new tabs. Existing tabs are unaffected because each retains the shell chosen when it was created.", - ), + help("shell", language), div() .w(px(SETTINGS_SELECT_WIDTH)) .font_family(cx.theme().mono_font_family.clone()) @@ -2105,7 +684,7 @@ impl SettingsPane { &self, key: &'static str, label: &'static str, - desc: &'static str, + desc: impl Into, checked: bool, cx: &mut Context, ) -> impl IntoElement { @@ -2125,7 +704,7 @@ impl SettingsPane { &self, key: &'static str, label: &'static str, - desc: &'static str, + desc: impl Into, control: impl IntoElement, cx: &mut Context, ) -> gpui::AnyElement { @@ -2169,10 +748,7 @@ impl SettingsPane { let color = if has_dir { cx.theme().link } else { cx.theme().muted_foreground }; self.row( language.pick("启动目录", "Startup directory"), - language.pick( - "新标签落在哪个目录。不设则继承 Pebrel 自己的工作目录,从资源管理器右键进来时就是那个文件夹。", - "Chooses the directory for new tabs. When unset, they inherit Pebrel's working directory, such as the folder used to launch it from File Explorer.", - ), + help("startup_directory", language), h_flex() .gap_2() .items_center() @@ -2190,11 +766,13 @@ impl SettingsPane { })), ) .when(has_dir, |row| { - row.child(NebulaButton::new("startup-directory-clear").label(language.pick("清除", "Clear")).on_click( - cx.listener(|this, _, _, cx| { - this.clear_startup_directory(cx); - }), - )) + row.child( + NebulaButton::new("startup-directory-clear") + .label(language.pick("清除", "Clear")) + .on_click(cx.listener(|this, _, _, cx| { + this.clear_startup_directory(cx); + })), + ) }), cx, ) @@ -2274,7 +852,7 @@ impl SettingsPane { fn slider_row( &self, label: &'static str, - desc: &'static str, + desc: impl Into, state: &Entity, display: SharedString, cx: &Context, @@ -2332,10 +910,7 @@ impl SettingsPane { }); self.row_with_reset( language.pick("背景图片", "Background image"), - language.pick( - "铺在终端文字后面。图片本身不参与配色,字色仍由主题决定;看不清就调下面的不透明度。", - "Draws an image behind terminal text. The image does not affect colors; text colors still come from the theme. Reduce the opacity below if text is hard to read.", - ), + help("background_image", language), has_image, |this, _, cx| { this.persist(&[("background_image", String::new())], cx); @@ -2343,11 +918,13 @@ impl SettingsPane { h_flex() .items_center() .gap_2() - .child(NebulaButton::new("background-image-choose").label(language.pick("选择图片", "Choose image")).on_click( - cx.listener(|this, _, _, cx| { - this.choose_background_image(cx); - }), - )) + .child( + NebulaButton::new("background-image-choose") + .label(language.pick("选择图片", "Choose image")) + .on_click(cx.listener(|this, _, _, cx| { + this.choose_background_image(cx); + })), + ) .when_some(path_label, |row, name| { row.child( div() @@ -2399,42 +976,40 @@ impl SettingsPane { cx.notify(); } - fn section_profiles(&mut self, window: &mut Window, cx: &mut Context) -> gpui::Div { + fn section_profiles(&mut self, _window: &mut Window, cx: &mut Context) -> gpui::Div { let language = crate::gpui_shell::config::ui_language(cx); - let font_picker = self.font_picker_dropdown(window, cx); let terminal = self - .group(language.pick("终端", "Terminal"), cx) + .group(language.pick("启动", "Startup"), cx) .child(self.shell_select_row(cx)) - .child(self.startup_directory_row(cx)) - .child(self.select_row( - "bell", - language.pick("终端铃声", "Terminal bell"), - language.pick("程序发出 BEL 时的反应。开放工位上选闪烁,免得一次 Tab 补全失败响得整层楼都听见。", "Controls how Pebrel responds to BEL. Visual feedback is useful in shared spaces where an audible bell would be disruptive."), - cx, - )) - .child(font_picker); + .child(self.startup_directory_row(cx)); + let alerts = self.group(language.pick("提醒", "Alerts"), cx).child(self.select_row( + "bell", + language.pick("终端铃声", "Terminal bell"), + help("bell", language), + cx, + )); let completion = self .group(language.pick("补全", "Completion"), cx) .child(self.switch_row( "ghost", language.pick("启用命令补全", "Enable command completion"), - language.pick("按历史在光标后给出灰色建议,按下接受键才真正写进命令行。关掉后不再出现任何建议。", "Shows history-based suggestions after the cursor and inserts one only when the accept key is pressed. Disable this to hide all suggestions."), + help("ghost", language), self.runtime.ghost, cx, )) .child(self.select_row( "accept", language.pick("补全接受键", "Completion accept key"), - language.pick("用哪个键把灰色建议收下。Tab 在不少 shell 里已经绑了原生补全,撞车时改成右方向键。", "Chooses the key that accepts a suggestion. Many shells already use Tab for native completion; use Right arrow if they conflict."), + help("accept", language), cx, )) .child(self.select_row( "completion_style", language.pick("补全样式", "Completion style"), - language.pick("行内灰字续在光标后,不挡住下方输出;弹窗列表能一次看到多个候选,代价是会盖住一片终端内容。", "Inline ghost text follows the cursor without covering output. A popup shows several candidates at once but obscures part of the terminal."), + help("completion_style", language), cx, )); - v_flex().w_full().gap(px(GROUP_GAP)).child(terminal).child(completion) + v_flex().w_full().gap(px(GROUP_GAP)).child(terminal).child(completion).child(alerts) } fn section_interaction(&mut self, cx: &mut Context) -> gpui::Div { @@ -2448,35 +1023,29 @@ impl SettingsPane { self.group(language.pick("鼠标与选区", "Mouse and selection"), cx) .child(self.switch_row( "copy_on_select", - language.pick("选中即复制(关 = 右键复制/粘贴)", "Copy on select (off = right-click copy/paste)"), - language.pick("开着时松开鼠标就进剪贴板,右键直接粘贴。关掉后选中不复制,右键弹出复制/粘贴菜单。", "When enabled, releasing the mouse copies the selection and right-click pastes. When disabled, selection does not copy and right-click opens a copy/paste menu."), + language.pick("选中即复制", "Copy on select"), + help("copy_on_select", language), self.runtime.copy_on_select, cx, )) .child(self.switch_row( "multiline_paste_confirm", - language.pick( - "粘贴多行或高风险内容前询问", - "Ask before multiline or risky pastes", - ), - language.pick( - "在裸 shell 中粘贴换行、提权命令或控制字符时先确认。Bracketed paste 与全屏程序不受影响;关闭后直接粘贴。", - "Confirms line breaks, privileged commands, or control characters pasted into a plain shell. Bracketed paste and full-screen apps are unaffected; disabling this pastes directly.", - ), + language.pick("粘贴前确认", "Confirm pastes"), + help("multiline_paste_confirm", language), self.runtime.multiline_paste_confirm, cx, )) .child(self.switch_row( "panel_resize", language.pick("拖拽调节侧栏宽度", "Drag to resize the sidebar"), - language.pick("关掉后侧栏与面板的分界线钉死,拖不动;宽度仍可在别处改,只是不会被误拖。", "When disabled, the divider between the sidebar and panel cannot be dragged. The width can still be changed elsewhere without accidental resizing."), + help("panel_resize", language), self.runtime.panel_resize, cx, )) .child(self.switch_row( "cjk_bold_regular", - language.pick("CJK 粗体使用常规字形(提亮不加粗)", "Use regular glyphs for CJK bold (brighten without thickening)"), - language.pick("中日韩字形笔画密,字体引擎合成的伪粗体会糊成一团。开着时这些字改用提亮表达加粗,拉丁字母照旧走真粗体。", "Synthetic bold can blur dense CJK glyphs. When enabled, CJK bold is represented by brighter regular glyphs while Latin text still uses true bold."), + language.pick("中日韩粗体提亮", "Brighten CJK bold"), + help("cjk_bold_regular", language), self.runtime.cjk_bold_regular, cx, )), @@ -2486,31 +1055,31 @@ impl SettingsPane { .child(self.select_row( "tabs_position", language.pick("标签栏位置", "Tab bar position"), - language.pick("左侧边栏放得下完整路径和分屏结构,顶部则把纵向空间全留给终端。", "The left sidebar can show full paths and split structure; the top position reserves all vertical space for the terminal."), + help("tabs_position", language), cx, )) .child(self.select_row( "tab_reveal", language.pick("标签展开动效", "Tab reveal animation"), - language.pick("滑动=新标签带位移动画;即时=直接出现。远程桌面或低配机上选即时能少掉一次重绘。", "Slide animates new tabs into place; Instant shows them immediately. Instant avoids an extra redraw on remote desktops or slower computers."), + help("tab_reveal", language), cx, )) .child(self.select_row( "new_tab_position", language.pick("新标签位置", "New tab position"), - language.pick("只管新建的标签插在哪。会话恢复与工作区导入按各自记录的顺序排,不看这一项。", "Controls only where newly created tabs are inserted. Restored sessions and imported workspaces retain their recorded order."), + help("new_tab_position", language), cx, )) .child(self.select_row( "windowing_behavior", language.pick("新建实例行为", "New instance behavior"), - language.pick("从桌面或命令行再启动一次 Pebrel 时:另开一个窗口,还是把它作为新标签并进已有窗口。", "When Pebrel is launched again from the desktop or command line, choose whether to open a new window or attach the request as a tab in an existing window."), + help("windowing_behavior", language), cx, )) .child(self.select_row( "vcs_display", - language.pick("侧栏版本控制(Git/SVN)", "Sidebar version control (Git/SVN)"), - language.pick("侧栏那块状态读哪种仓库。自动检测按目录里的 `.git` / `.svn` 判断,只有两者并存时才需要手动指定。", "Chooses which repository the sidebar status reads. Auto detect checks `.git` and `.svn`; manual selection is only needed when both are present."), + language.pick("侧栏版本控制", "Sidebar version control"), + help("vcs_display", language), cx, )), ) @@ -2522,34 +1091,41 @@ impl SettingsPane { // `platform::capabilities` 的说明)。 let caps = crate::platform::CAPABILITIES; self.group(language.pick("会话生命周期", "Session lifecycle"), cx) - .when(caps.hide_window_on_close, |group| group.child(self.switch_row( - "keep_session", - language.pick("关窗后保留后台会话", "Keep sessions running after the window closes"), - language.pick("开着时点 × 只是把窗口收走,里面的 shell 继续在常驻进程里跑、可以再附着回来;关掉则连 shell 一起杀,未保存的东西会丢。", "When enabled, closing the window leaves its shells running in the resident process so they can be reattached. When disabled, closing the window terminates them and unsaved work is lost."), - self.runtime.keep_session, - cx, - ))) + .when(caps.hide_window_on_close, |group| { + group.child(self.switch_row( + "keep_session", + language.pick( + "关窗后保留后台会话", + "Keep sessions running after the window closes", + ), + help("keep_session", language), + self.runtime.keep_session, + cx, + )) + }) .child(self.switch_row( "restore_session", language.pick("启动时恢复上次标签", "Restore previous tabs at startup"), - language.pick("重开时按上次的标签与分屏布局重建,工作目录一起回来。进程不会续命——恢复出来的是新 shell。", "Rebuilds the previous tab and split layout, including working directories. Processes are not resumed; restored tabs start new shells."), + help("restore_session", language), self.runtime.restore_session, cx, )) .child(self.switch_row( "resume_ai", - language.pick("恢复会话时自动接续 AI 对话", "Resume AI conversations when restoring sessions"), - language.pick("恢复出来的 AI 标签接着上次那段对话,而不是开一段新的。", "Restored AI tabs continue their previous conversation instead of starting a new one."), + language.pick("恢复时接续 AI 对话", "Resume AI conversations"), + help("resume_ai", language), self.runtime.resume_ai, cx, )) - .when(caps.system_tray, |group| group.child(self.switch_row( - "tray", - language.pick("常驻系统托盘图标", "Keep an icon in the system tray"), - language.pick("在通知区域留一个图标,正在跑的 AI 会话从那里能直接看到状态。", "Keeps an icon in the notification area where the status of running AI sessions is visible."), - self.runtime.tray, - cx, - ))) + .when(caps.system_tray, |group| { + group.child(self.switch_row( + "tray", + language.pick("常驻系统托盘图标", "Keep an icon in the system tray"), + help("tray", language), + self.runtime.tray, + cx, + )) + }) } fn section_content(&mut self, window: &mut Window, cx: &mut Context) -> gpui::AnyElement { @@ -2582,18 +1158,7 @@ impl SettingsPane { let foreground = theme.foreground; let hover_bg = crate::gpui_shell::theme::settings_hover_bg(cx, false); let hairline = crate::gpui_shell::theme::settings_hairline(cx); - let back_row_h = px(34.0); - // 设置导航、内容和 workspace 左侧 tab 共享这一个主文字字号。 - let main_text_px = self.font_size_px(cx); - - // 一级标题靠左,二级导航略向右收进;只靠对齐关系建立层级, - // 不额外添加卡片或分组标题。 - // 顶上不再画「设置」二字:右栏页头已经写着当前分区名,两者叠在同一 - // 视线高度上就是同一件事说两遍;这个页面是不是设置页,窗口和 tab 早 - // 就说明了。省下的标题块空间直接归还给导航项。 - // - // 分栏靠一条 hairline 而不是留白:留白只说明"这两块不挨着",线才说明 - // "这是两个区"——导航是全局的,右栏是当前分区的,二者不是同一层。 + // 返回入口和分类共用紧凑菜单尺寸;字号与搜索框一致,不继承终端配置字号。 let mut nav = v_flex() .w(px(SETTINGS_NAV_WIDTH)) .h_full() @@ -2601,25 +1166,27 @@ impl SettingsPane { .px_2() .pt(px(12.0)) .pb(px(8.0)) - .gap(px(2.0)) + .gap(px(4.0)) + .text_sm() + .line_height(px(20.0)) .border_r_1() .border_color(hairline) .child( div() .id("settings-back") .mx_1() - .mb(px(24.0)) - .h(back_row_h) - .px_3() + .mb(px(20.0)) + .h(px(SETTINGS_NAV_ROW_HEIGHT)) + .px(px(10.0)) .flex() .items_center() - .gap_3() + .gap(px(8.0)) .rounded_md() .cursor_pointer() .text_color(muted) .hover(move |item| item.bg(hover_bg).text_color(foreground)) .on_click(cx.listener(|_, _, _, cx| cx.emit(SettingsPaneEvent::Close))) - .child(Icon::new(IconName::ArrowLeft).small()) + .child(Icon::new(IconName::ArrowLeft).size(px(SETTINGS_NAV_ICON_SIZE))) .child(language.pick("返回工作区", "Back to workspace")), ); for ix in visible_nav_sections() { @@ -2627,19 +1194,18 @@ impl SettingsPane { nav = nav.child( div() .id(("settings-nav", ix)) - .px_3() + .px(px(10.0)) .ml_1() .mr_1() - .h(px(40.0)) + .h(px(SETTINGS_NAV_ROW_HEIGHT)) .flex() .items_center() - .gap_3() + .gap(px(8.0)) .rounded_md() .cursor_pointer() - .text_size(px(main_text_px)) // 选中态同时改变底色、墨色和字重,余光扫过也能确认当前位置。 .font_weight(if active { - gpui::FontWeight::SEMIBOLD + gpui::FontWeight::MEDIUM } else { gpui::FontWeight::NORMAL }) @@ -2649,13 +1215,32 @@ impl SettingsPane { this.active_section = ix; cx.notify(); })) - .child(Icon::default().path(section_icon(ix)).small().text_color( - if active { active_icon } else { muted }, - )) + .child( + Icon::default() + .path(section_icon(ix)) + .size(px(SETTINGS_NAV_ICON_SIZE)) + .flex_shrink_0() + .text_color(if active { active_icon } else { muted }), + ) .child(section_label(ix, language)), ); } - nav.into_any_element() + nav.child(div().flex_1().min_h(px(24.0))) + .child( + Button::new("settings-restore-defaults") + .icon(IconName::Undo2) + .label(language.pick("恢复默认设置", "Restore defaults")) + .ghost() + .small() + .w_full() + .h(px(SETTINGS_NAV_ROW_HEIGHT)) + .justify_start() + .px_3() + .text_color(muted) + .tooltip(language.pick("恢复设置与快捷键;保留 SSH 主机、凭据和历史,并备份原设置。", "Restores settings and shortcuts. Keeps SSH hosts, credentials and history, and backs up current settings.")) + .on_click(cx.listener(|this, _, window, cx| this.reset_all_settings(window, cx))), + ) + .into_any_element() } } @@ -2669,7 +1254,6 @@ impl Focusable for SettingsPane { impl Render for SettingsPane { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let language = crate::gpui_shell::config::ui_language(cx); let nav = self.render_nav(cx); let content = self.section_content(window, cx); // 这里**不再**挂 font_family。旧壳设置页整页走终端 mono,是因为自绘 @@ -2680,66 +1264,10 @@ impl Render for SettingsPane { let font_picker_open = self.font_picker_open; let bg_picker_open = self.bg_picker_open && self.active_section == 1; let bg_dragging = self.bg_picker_drag.is_some(); - let ssh_editor_modal = self.ssh_editor_modal(cx); + let ssh_editor_modal = self.ssh_editor_modal(window, cx); let appearance_picker_modal = self.appearance_picker_modal(window, cx); - // 现有 reset 合同只覆盖外观键;其它页面显示同一个按钮会产生假承诺。 - let show_reset = self.active_section == 1; - let appearance_header = show_reset.then(|| self.appearance_page_header(cx)); - let appearance_colors = appearance_picker::AppearanceColors::current(cx); - let hairline = crate::gpui_shell::theme::settings_hairline(cx); - let search_hover = cx.theme().list_hover; - let reset_hover = cx.theme().list_hover; - let search_query = self.settings_search_input.read(cx).value().trim().to_lowercase(); - let search_results: Vec = if search_query.is_empty() { - Vec::new() - } else { - visible_nav_sections() - .filter(|index| { - SECTION_SEARCH_TERMS[*index].contains(&search_query) - || section_label(*index, language).to_lowercase().contains(&search_query) - }) - .take(6) - .collect() - }; - let search_focused = - self.settings_search_input.read(cx).focus_handle(cx).is_focused(window); - let search_panel = (search_focused && !search_results.is_empty()).then(|| { - v_flex() - .w(self - .settings_search_trigger_bounds - .map(|bounds| bounds.size.width) - .unwrap_or(px(680.0))) - .p_1() - .gap_1() - .rounded_md() - .border_1() - .border_color(hairline) - .bg(crate::gpui_shell::theme::settings_panel_bg(cx)) - .shadow_lg() - .occlude() - .children(search_results.into_iter().map(|index| { - h_flex() - .id(("settings-search-result", index)) - .h(px(36.0)) - .px_2() - .gap_2() - .rounded_md() - .cursor_pointer() - .hover(move |row| row.bg(search_hover)) - .on_click(cx.listener(move |this, _, window, cx| { - this.active_section = index; - this.settings_search_input.update(cx, |input, cx| { - input.set_value("", window, cx); - }); - cx.notify(); - })) - .child(Icon::default().path(section_icon(index)).small()) - .child(section_label(index, language)) - })) - }); - - let search_trigger = cx.entity().downgrade(); - let search_trigger_bounds = self.settings_search_trigger_bounds; + let application_page = self.active_section == 0; + let header = self.render_search_header(window, cx); div() .size_full() @@ -2783,9 +1311,7 @@ impl Render for SettingsPane { .rounded(crate::gpui_shell::theme::card_radius(cx)) .bg(crate::gpui_shell::theme::settings_panel_bg(cx)) .text_color(cx.theme().foreground) - .when(show_reset, |root| root.bg(appearance_colors.surface)) - // 主文字的字号/字重从设置根向两栏继承;局部仅允许说明文字、 - // 徽标等语义上的次级信息覆盖为更小字号。 + // 正文继承主字号;导航和搜索使用组件小字号,说明与徽标单独缩小。 .text_size(px(base_px)) .font_weight(gpui::FontWeight::NORMAL) .flex() @@ -2798,150 +1324,7 @@ impl Render for SettingsPane { .h_full() // 页头固定高度;正文单独滚动,滚动设置时仍能知道 // 自己在哪个分区,也不会让标题与首组的距离随内容变化。 - .when_some(appearance_header, |main, header| main.child(header)) - .when(!show_reset, |main| main.child( - div() - .h(px(SETTINGS_HEADER_HEIGHT)) - .flex_shrink_0() - .px_5() - .flex() - .items_center() - // 页头与正文之间画线,正文首组的上留白因此可以收 - // 掉:线本身已经完成了"标题到此为止"的交代,再留一 - // 段大空白就成了两遍。 - .border_b_1() - .border_color(hairline) - .child( - h_flex() - .flex_1() - .items_center() - .gap_3() - .child( - div() - .w(px(180.0)) - .flex_shrink_0() - // 全页唯一放大的一处文字。层级本该 - // 靠字重和位置做,但页头是页面唯一 - // 的锚点,允许它比正文大一档。 - .text_size(px(base_px * 1.2)) - .font_weight(gpui::FontWeight::SEMIBOLD) - .child(section_label(self.active_section, language)), - ) - .child( - div() - .flex_1() - .min_w_0() - .flex() - .justify_center() - .child( - div() - .relative() - .w_full() - .max_w(px(680.0)) - .child( - Input::new(&self.settings_search_input) - .cleanable(true) - .prefix( - Icon::new(IconName::Search) - .xsmall() - .text_color( - cx.theme() - .muted_foreground, - ), - ) - .aria_label(language.pick( - "在全部设置中搜索", - "Search all settings", - )), - ) - .child( - gpui::canvas( - move |bounds, _, cx| { - let _ = search_trigger.update( - cx, - |pane, cx| { - if pane - .settings_search_trigger_bounds - == Some(bounds) - { - return; - } - pane.settings_search_trigger_bounds = - Some(bounds); - cx.notify(); - }, - ); - }, - |_, _, _, _| {}, - ) - .absolute() - .size_full(), - ) - .when_some( - search_panel.zip(search_trigger_bounds), - |search, (panel, bounds)| { - search.child( - deferred( - anchored() - .anchor( - gpui::Anchor::TopLeft, - ) - .position( - bounds.bottom_left(), - ) - .offset(gpui::point( - px(0.0), - px(6.0), - )) - .snap_to_window_with_margin( - px(8.0), - ) - .child(panel), - ) - .with_priority(3), - ) - }, - ), - ), - ) - .child( - div() - .w(px(180.0)) - .flex_shrink_0() - .flex() - .justify_end() - .when(show_reset, |slot| { - slot.child( - div() - .id("settings-reset") - .size(px(24.0)) - .rounded_md() - .flex() - .items_center() - .justify_center() - .cursor_pointer() - .hover(move |el| el.bg(reset_hover)) - .tooltip(|window, cx| { - let language = crate::gpui_shell::config::ui_language(cx); - gpui_component::tooltip::Tooltip::new( - language.pick( - "还原外观为默认值", - "Restore appearance defaults", - ), - ) - .build(window, cx) - }) - .on_click(cx.listener( - |this, _, window, cx| { - this.reset_appearance(window, cx); - }, - )) - .child(Icon::new(IconName::Undo2)), - ) - }), - ), - ), - )) + .child(header) .child( v_flex() .flex_1() @@ -2953,7 +1336,7 @@ impl Render for SettingsPane { // 页(按键映射开头是搜索框)就会直接贴到线上。 .pt(px(20.0)) .pb(px(22.0)) - .when(show_reset, |content| content.px(px(40.0)).pt(px(28.0)).pb(px(30.0))) + .when(!application_page, |content| content.pt(px(28.0)).pb(px(30.0))) // 注意这层包装 `v_flex` 的 `w_full` 不能删(2026-08-23 // 又栽了一次):`overflow_y_scrollbar` 把内容层清成 // `Display::Block`,而 flex 容器在 block 父里 @@ -2988,7 +1371,7 @@ impl Render for SettingsPane { .w_full() .flex() .justify_center() - .child(v_flex().w_full().when(!show_reset, |content| content.max_w(px(960.0))).child(content)), + .child(v_flex().w_full().when(application_page, |content| content.max_w(px(960.0))).child(content)), ), ), ) diff --git a/nebula_app/src/gpui_shell/settings_pane/appearance.rs b/nebula_app/src/gpui_shell/settings_pane/appearance.rs index c0ccc72..d965e97 100644 --- a/nebula_app/src/gpui_shell/settings_pane/appearance.rs +++ b/nebula_app/src/gpui_shell/settings_pane/appearance.rs @@ -88,69 +88,13 @@ impl SettingsPane { .into_any_element() } - fn appearance_typography(&mut self, window: &mut Window, cx: &mut Context) -> gpui::Div { + pub(super) fn terminal_font_size_row(&self, cx: &Context) -> gpui::AnyElement { let language = crate::gpui_shell::config::ui_language(cx); - let colors = AppearanceColors::current(cx); let size = self.terminal_font_size_px(cx); - let family = self.current_font_chain(cx); - let summary = format!( - "{} · {size:.0} px", - font_display_name(family.split(',').next().unwrap_or(&family)) - ); - let expanded = self.typography_expanded; - let header = Button::new("appearance-typography-toggle") - .ghost() - .w_full() - .h(px(88.0)) - .px_0() - .child( - h_flex() - .w_full() - .gap(px(18.0)) - .justify_between() - .child(appearance_copy( - language.pick("终端字体", "Terminal font"), - language.pick( - "字体与字号,按需调整。", - "Adjust the font and size when needed.", - ), - colors, - )) - .child( - h_flex() - .max_w(px(280.0)) - .gap(px(19.0)) - .text_color(colors.secondary) - .text_size(px(12.0)) - .child(div().truncate().child(summary)) - .child( - Icon::new(if expanded { - IconName::ChevronDown - } else { - IconName::ChevronRight - }) - .size(px(14.0)), - ), - ), - ) - .on_click(cx.listener(|this, _, window, cx| { - this.typography_expanded = !this.typography_expanded; - if !this.typography_expanded { - this.close_font_picker(window, true, cx); - } - cx.notify(); - })); - let section = v_flex().w_full().border_b_1().border_color(colors.line).child(header); - if !expanded { - return section; - } - let font_picker = self.font_picker_dropdown(window, cx); let stepper = h_flex() .w(px(142.0)) .h(px(36.0)) - .rounded(px(6.0)) - .border_1() - .border_color(colors.control) + .items_center() .child( Button::new("appearance-font-smaller") .icon(IconName::Minus) @@ -165,7 +109,7 @@ impl SettingsPane { ); })), ) - .child(div().flex_1().text_center().text_size(px(12.0)).child(format!("{size:.0} px"))) + .child(div().flex_1().text_center().child(format!("{size:.0} px"))) .child( Button::new("appearance-font-larger") .icon(IconName::Plus) @@ -180,106 +124,13 @@ impl SettingsPane { ); })), ); - let theme = crate::gpui_shell::theme::effective_theme_name(cx); - section - .child( - h_flex() - .w_full() - .items_start() - .flex_wrap() - .gap(px(24.0)) - .pb(px(24.0)) - .child( - v_flex() - .flex_1() - .min_w(px(220.0)) - .gap(px(8.0)) - .child( - div() - .text_size(px(11.5)) - .text_color(colors.secondary) - .child(language.pick("字体", "Font")), - ) - .child(font_picker), - ) - .child( - v_flex() - .flex_1() - .gap(px(8.0)) - .child( - div() - .text_size(px(11.5)) - .text_color(colors.secondary) - .child(language.pick("字号", "Size")), - ) - .child(stepper), - ), - ) - .child( - v_flex() - .w_full() - .max_w(px(520.0)) - .pb(px(24.0)) - .child(self.terminal_appearance_preview(theme, true, false, window, cx)) - .child( - h_flex() - .mt(px(9.0)) - .justify_between() - .text_size(px(10.0)) - .text_color(colors.muted) - .child( - h_flex() - .gap(px(5.0)) - .child(Icon::new(IconName::Eye).size(px(11.0))) - .child(language.pick("字体预览", "Font preview")), - ) - .child(chrome_theme(theme).short_label()), - ), - ) - } - - pub(super) fn appearance_page_header(&self, cx: &mut Context) -> gpui::Div { - let language = crate::gpui_shell::config::ui_language(cx); - let colors = AppearanceColors::current(cx); - h_flex() - .w_full() - .text_color(colors.ink) - .min_h(px(110.0)) - .flex_shrink_0() - .px(px(40.0)) - .py(px(25.0)) - .gap(px(20.0)) - .border_b_1() - .border_color(colors.line) - .justify_between() - .child( - v_flex() - .min_w_0() - .gap(px(5.0)) - .child( - div() - .text_size(px(25.0)) - .font_semibold() - .child(language.pick("外观", "Appearance")), - ) - .child(div().text_size(px(12.0)).text_color(colors.secondary).child( - language.pick( - "主题、应用图标与显示偏好。", - "Themes, app icons and display preferences.", - ), - )), - ) - .child( - Button::new("appearance-reset") - .icon(IconName::Undo2) - .label(language.pick("恢复默认", "Restore defaults")) - .ghost() - .h(px(32.0)) - .px(px(10.0)) - .text_size(px(12.0)) - .text_color(colors.secondary) - .on_click(cx.listener(|this, _, window, cx| this.reset_appearance(window, cx))), - ) + self.row( + language.pick("终端字号(Ctrl+滚轮缩放)", "Terminal font size (Ctrl+wheel)"), + language.pick("只调整终端文字大小。", "Changes only the terminal text size."), + stepper, + cx, + ) + .into_any_element() } pub(super) fn section_appearance( @@ -291,133 +142,54 @@ impl SettingsPane { let colors = AppearanceColors::current(cx); let theme = self.appearance_trigger(true, window, cx); let icon = self.appearance_trigger(false, window, cx); - let typography = self.appearance_typography(window, cx); - let platform_note = if cfg!(windows) { - language.pick("选择自动保存。固定快捷方式与安装器仍使用默认图标。", "Choices are saved automatically. Pinned shortcuts and the installer keep the default icon.") - } else { - language.pick("选择自动保存。图标仅应用于应用内,不更改 Dock 或启动器图标。", "Choices are saved automatically. Icons change within the app, not in the Dock or launcher.") - }; - let advanced = - self.appearance_advanced_expanded.then(|| self.appearance_advanced_settings(cx)); - v_flex() + let selectors = v_flex() .w_full() - .text_color(colors.ink) - .child( - v_flex() - .w_full() - .child( - h_flex() - .w_full() - .min_h(px(58.0)) - .gap(px(25.0)) - .justify_between() - .child(appearance_copy( - language.pick("主题", "Theme"), - language.pick( - "终端与界面的配色,统一选择。", - "One palette for the terminal and interface.", - ), - colors, - )) - .child(theme), - ) - .child( - h_flex() - .w_full() - .mt(px(22.0)) - .pt(px(18.0)) - .gap(px(16.0)) - .justify_between() - .border_t_1() - .border_color(colors.line) - .text_size(px(11.5)) - .text_color(colors.secondary) - .child(language.pick( - "随系统自动切换深浅色", - "Switch between light and dark with the system", - )) - .child( - h_flex() - .gap(px(9.0)) - .child(language.pick("跟随系统", "Follow system")) - .child( - crate::gpui_shell::widgets::NebulaSwitch::new( - "appearance-follow-system", - ) - .checked(self.runtime.follow_system_theme) - .on_click( - cx.listener(|this, checked: &bool, window, cx| { - this.set_follow_system_theme(*checked, window, cx); - }), - ), - ), - ), - ), - ) + .gap(px(24.0)) .child( h_flex() .w_full() - .mt(px(29.0)) - .py(px(25.0)) + .min_h(px(58.0)) .gap(px(25.0)) + .items_center() + .justify_between() + .child(appearance_copy( + language.pick("主题", "Theme"), + language.pick( + "终端与界面的配色,统一选择。", + "One palette for the terminal and interface.", + ), + colors, + )) + .child(theme), + ) + .child(self.switch_row( + "follow_system_theme", + language.pick("跟随系统", "Follow system"), + language.pick( + "随系统自动切换深浅色。", + "Switches between light and dark with the system.", + ), + self.runtime.follow_system_theme, + cx, + )) + .child( + h_flex() + .w_full() + .min_h(px(58.0)) + .gap(px(25.0)) + .items_center() .justify_between() - .border_t_1() - .border_b_1() - .border_color(colors.line) .child(appearance_copy( language.pick("应用图标", "App icon"), language.pick( "独立于主题,切换配色时保持不变。", - "Independent of the theme. Stays the same when colors change.", + "Independent of the theme; changing colors keeps the icon.", ), colors, )) .child(icon), - ) - .child(typography) - .child( - h_flex() - .w_full() - .pt(px(21.0)) - .gap(px(12.0)) - .items_start() - .justify_between() - .child( - h_flex() - .flex_1() - .min_w_0() - .gap(px(6.0)) - .items_start() - .text_size(px(10.5)) - .text_color(colors.muted) - .child(Icon::new(IconName::Info).size(px(12.0))) - .child(platform_note), - ) - .child( - Button::new("appearance-advanced-toggle") - .ghost() - .h(px(24.0)) - .text_size(px(10.5)) - .text_color(colors.secondary) - .label(language.pick("更多外观设置", "More appearance settings")) - .icon(if self.appearance_advanced_expanded { - IconName::ChevronDown - } else { - IconName::ChevronRight - }) - .on_click(cx.listener(|this, _, window, cx| { - this.appearance_advanced_expanded = - !this.appearance_advanced_expanded; - if !this.appearance_advanced_expanded { - this.close_background_picker(cx); - window.focus(&this.focus_handle, cx); - } - cx.notify(); - })), - ), - ) - .when_some(advanced, |section, advanced| { - section.child(div().mt(px(24.0)).child(advanced)) - }) + ); + let settings = self.appearance_advanced_settings(window, cx); + v_flex().w_full().gap(px(GROUP_GAP)).child(selectors).child(settings) } } diff --git a/nebula_app/src/gpui_shell/settings_pane/appearance_advanced.rs b/nebula_app/src/gpui_shell/settings_pane/appearance_advanced.rs index 5c79030..741eb29 100644 --- a/nebula_app/src/gpui_shell/settings_pane/appearance_advanced.rs +++ b/nebula_app/src/gpui_shell/settings_pane/appearance_advanced.rs @@ -1,8 +1,13 @@ use super::*; impl SettingsPane { - pub(super) fn appearance_advanced_settings(&mut self, cx: &mut Context) -> gpui::Div { + pub(super) fn appearance_advanced_settings( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> gpui::Div { let language = crate::gpui_shell::config::ui_language(cx); + let font_picker = self.font_picker_dropdown(window, cx); let opacity: SharedString = format!("{:.0}%", self.runtime.opacity * 100.0).into(); let wallpaper_opacity: SharedString = format!("{:.0}%", self.runtime.background_image_opacity * 100.0).into(); @@ -10,19 +15,32 @@ impl SettingsPane { .group(language.pick("自定义背景", "Custom background"), cx) .child(self.background_color_row(cx)) .child(self.background_image_row(cx)) - .child(self.select_row("background_image_fit", language.pick("背景图像拉伸模式", "Background image fit"), language.pick("拉伸会把图压变形;适应保持比例、四周留边;填充保持比例但裁掉溢出的部分;原始尺寸按图片自己的像素铺。", "Fill distorts the image; Uniform preserves its aspect ratio and may leave margins; Uniform to fill preserves the ratio but crops overflow; Original size uses the image's own pixels."), cx)) - .child(self.select_row("background_image_alignment", language.pick("背景图像对齐", "Background image alignment"), language.pick("图片没铺满或被裁掉时,保留哪一侧。壁纸类的图通常选顶部或居中,主体才不会被切走。", "Choose which edge to preserve when the image does not fill the area or is cropped. Top or center usually keeps a wallpaper's subject visible."), cx)) + .child(self.select_row( + "background_image_fit", + language.pick("背景图像拉伸模式", "Background image fit"), + help("background_image_fit", language), + cx, + )) + .child(self.select_row( + "background_image_alignment", + language.pick("背景图像对齐", "Background image alignment"), + help("background_image_alignment", language), + cx, + )) .child(self.slider_row( language.pick("背景图像不透明度", "Background image opacity"), - language.pick("压得越低文字越清楚。图片不参与配色,字色始终由主题决定。", "Lower values make text easier to read. The image does not affect colors; text colors always come from the theme."), + help("background_image_opacity", language), &self.wallpaper_opacity_slider, wallpaper_opacity, cx, )) .child(self.switch_row( "background_image_cover_chrome", - language.pick("将背景图扩展到标题栏和侧边栏", "Extend the background image into the title bar and sidebar"), - language.pick("开着时整窗一张图,界面和终端连成一片;关掉则图片只铺终端区域,侧栏与标题栏保持纯色、文字更稳。", "When enabled, one image covers the entire window. When disabled, it covers only the terminal while the sidebar and title bar keep a solid background."), + language.pick( + "将背景图扩展到标题栏和侧边栏", + "Extend the background image into the title bar and sidebar", + ), + help("background_image_cover_chrome", language), self.runtime.background_image_cover_chrome, cx, )); @@ -31,13 +49,13 @@ impl SettingsPane { .child(self.select_row( "cursor_shape", language.pick("光标形状", "Cursor shape"), - language.pick("条形贴近编辑器的手感,实心框在满屏输出里最容易一眼找到。", "A bar feels closer to an editor; a filled box is easiest to spot in dense terminal output."), + help("cursor_shape", language), cx, )) .child(self.switch_row( "cursor_blink", language.pick("光标闪烁", "Blink cursor"), - language.pick("关掉后光标常亮。长时间盯屏时不闪更省心,代价是光标在密集输出里没那么显眼。", "When disabled, the cursor stays lit. A steady cursor is calmer during long sessions but less visible in dense output."), + help("cursor_blink", language), self.runtime.cursor_blink.unwrap_or(DEFAULT_CURSOR_BLINK), cx, )); @@ -46,42 +64,61 @@ impl SettingsPane { .child(self.switch_row( "tab_close_visible", language.pick("显示标签关闭按钮", "Show tab close buttons"), - language.pick( - "关闭后侧栏与顶栏的标签都不再显示关闭按钮;中键点击标签仍可关闭。", - "Hides close buttons in both sidebar and top tabs. Middle-click still closes a tab.", - ), + help("tab_close_visible", language), self.runtime.tab_close_visible, cx, )) .child(self.select_row( "language", - language.pick("语言", "Language"), - language.pick("只改 Pebrel 自己的界面。终端里程序输出什么语言由它们自己的环境变量决定,不受这里影响。", "Changes only Pebrel's interface. Programs inside the terminal choose their language from their own environment and are not affected."), + language.text(crate::i18n::Message::CommonLanguage), + help("language", language), + cx, + )) + .child(self.select_row( + "density", + language.pick("界面外观", "Interface density"), + help("density", language), cx, )) - .child(self.select_row("density", language.pick("界面外观", "Interface density"), language.pick("紧凑会收窄标签行高与设置页行距这些界面留白。终端内容的行距不归它管,那是字体的事。", "Compact reduces interface spacing such as tab height and Settings row gaps. It does not change terminal line spacing, which is controlled by the font."), cx)) .child(self.slider_row( language.pick("终端正文不透明度", "Terminal content opacity"), - language.pick("1 = 完全不透明。调低会透出后方窗口,配合下面的窗口模糊才不至于让文字压在杂乱内容上。", "1 is fully opaque. Lower values reveal windows behind Pebrel; use window blur to keep terminal text readable over busy content."), + help("opacity", language), &self.opacity_slider, opacity, cx, )) - .child(self.select_row("blur", language.pick("背景模糊", "Background blur"), language.pick("五者是五套成本模型,不是越靠后越好:Mica 只取系统壁纸的色调、最省;Aero 与 Acrylic 每帧实时模糊窗口后方的真实内容,Acrylic 还多一层着色与噪点。", "These are different performance models, not quality levels. Mica only samples the wallpaper tint and costs least; Aero and Acrylic blur live content behind the window every frame, and Acrylic adds tint and noise."), cx)); + .child(self.select_row( + "blur", + language.pick("背景模糊", "Background blur"), + help("blur", language), + cx, + )); let terminal = self .group(language.pick("终端外观", "Terminal appearance"), cx) - .child(self.select_row("cell_width_mode", language.pick("字体间距", "Character spacing"), language.pick("列宽的取整方式。紧凑向下取整、字更密;宽松向上补一像素,专治 `Maple Mono` 这类平均字宽带小数的字体把字形挤扁。只作用于终端网格。", "Controls how terminal cell width is rounded. Compact rounds down for denser text; Relaxed adds a pixel to prevent fonts with fractional widths, such as `Maple Mono`, from looking squeezed. This affects only the terminal grid."), cx)) + .child(self.row( + language.pick("终端字体", "Terminal font"), + help("font_family", language), + font_picker, + cx, + )) + .child(self.terminal_font_size_row(cx)) + .child(self.select_row( + "cell_width_mode", + language.pick("字体间距", "Character spacing"), + help("cell_width_mode", language), + cx, + )) .child(self.switch_row( "fetch", language.pick("启动欢迎信息", "Startup system information"), - language.pick("新会话开头跑一次 `fastfetch` 打印系统信息。默认关,因为开新标签会因此慢一拍。", "Runs `fastfetch` at the start of a new session. It is off by default because it adds a delay when opening a tab."), + help("fetch", language), self.runtime.fetch, cx, )) .child(self.switch_row( "powerline", language.pick("Powerline 提示符", "Powerline prompt"), - language.pick("给 Pebrel 注入的提示符加箭头分段。需要终端字体带 Powerline 字形,否则那些箭头会显示成方框。", "Adds arrow segments to Pebrel's injected prompt. The terminal font must include Powerline glyphs or the arrows will render as boxes."), + help("powerline", language), self.runtime.powerline, cx, )); @@ -89,9 +126,9 @@ impl SettingsPane { v_flex() .w_full() .gap(px(GROUP_GAP)) - .child(custom_background) + .child(terminal) .child(cursor) .child(interface) - .child(terminal) + .child(custom_background) } } diff --git a/nebula_app/src/gpui_shell/settings_pane/appearance_picker.rs b/nebula_app/src/gpui_shell/settings_pane/appearance_picker.rs index 985826a..b92b5cd 100644 --- a/nebula_app/src/gpui_shell/settings_pane/appearance_picker.rs +++ b/nebula_app/src/gpui_shell/settings_pane/appearance_picker.rs @@ -77,42 +77,19 @@ pub(super) struct AppearanceColors { impl AppearanceColors { pub(super) fn current(cx: &App) -> Self { let light = crate::gpui_shell::theme::chrome_theme_resolved(cx).palette().is_light; - let colors = if light { - [ - 0xffffff, 0xf6f7f8, 0xe5e9ed, 0x2c3440, 0x697586, 0x7e8998, 0xe9ecf0, 0xd9dfe6, - 0x35414f, 0xffffff, 0x1d2733, - ] - } else { - [ - 0x282d36, 0x303640, 0x3f4957, 0xe3e8ef, 0xafb9c7, 0x99a5b5, 0x3a414d, 0x4b5564, - 0xd8e1ed, 0x25303f, 0x080c14, - ] - }; - let [ - surface, - subtle, - selected, - ink, - secondary, - muted, - line, - control, - primary, - on_primary, - scrim, - ] = colors.map(|color| Hsla::from(gpui::rgb(color))); + let theme = cx.theme(); Self { - surface, - subtle, - selected, - ink, - secondary, - muted, - line, - control, - primary, - on_primary, - scrim: scrim.opacity(if light { 0.30 } else { 0.42 }), + surface: crate::gpui_shell::theme::settings_panel_bg(cx), + subtle: crate::gpui_shell::theme::settings_hover_bg(cx, false), + selected: theme.list_active, + ink: theme.foreground, + secondary: theme.muted_foreground, + muted: crate::gpui_shell::theme::faint_ink(cx), + line: crate::gpui_shell::theme::settings_hairline(cx), + control: theme.border, + primary: theme.primary, + on_primary: theme.primary_foreground, + scrim: Hsla::from(gpui::rgb(0x000000)).opacity(if light { 0.30 } else { 0.42 }), } } } diff --git a/nebula_app/src/gpui_shell/settings_pane/design.rs b/nebula_app/src/gpui_shell/settings_pane/design.rs index 45100b3..8bf2adf 100644 --- a/nebula_app/src/gpui_shell/settings_pane/design.rs +++ b/nebula_app/src/gpui_shell/settings_pane/design.rs @@ -10,8 +10,8 @@ //! 1. **层次是正交维度的乘积,不是字号堆叠。** 字号只有两档(正文 / 说明), //! 层级交给字重(600/500/400)和位置(标题出线)。把标题一放大就会出现三 //! 套字号,那是另一种病。 -//! 2. **留白表达归属,不表达距离。** 2 / 16 / 24 三级:2px 说"这两行是同一 -//! 件事",16px 说"这是两件事",24px 说"这是两组事"。均匀间距无论调多大都 +//! 2. **留白表达归属,不表达距离。** 4 / 24 / 32 三级:4px 说"这两行是同一 +//! 件事",24px 说"这是两件事",32px 说"这是两组事"。均匀间距无论调多大都 //! 只会得到一条稀疏的平线。 //! 3. **说明写后果,不写定义。**「关掉后关窗即杀掉所有 shell」比「控制会话 //! 保留行为」有用——用户在这一行要做的判断是"关掉会怎样"。 @@ -25,15 +25,15 @@ use super::*; /// 说明文字相对正文的字号比。全页只有两档字号:正文走用户基准字号,说明小 /// 一档。 pub(super) const DESC_SCALE: f32 = 0.82; -/// label ↔ 说明。这 2px 在说"这两行是同一件事"。 -const LABEL_DESC_GAP: f32 = 2.0; +/// label ↔ 说明。这 4px 在说"这两行是同一件事"。 +const LABEL_DESC_GAP: f32 = 4.0; /// 行内上下留白,行与行之间因此是它的两倍。用 padding 而不是行间 gap,左侧 /// 轨道才连得上——断成一截一截的话,"哪几段亮着"根本读不出来。 -const ROW_PAD_Y: f32 = 8.0; +const ROW_PAD_Y: f32 = 12.0; /// 紧凑密度下的同一个值(「界面外观」里的密度开关对设置页真实生效)。 -const ROW_PAD_Y_COMPACT: f32 = 5.0; +const ROW_PAD_Y_COMPACT: f32 = 8.0; /// 组与组。 -pub(super) const GROUP_GAP: f32 = 24.0; +pub(super) const GROUP_GAP: f32 = 32.0; /// 轨道宽度。 const RAIL_W: f32 = 2.0; /// 内容相对轨道的缩进。标题左对齐轨道本身、行内容缩进这么多——标题是命名者 @@ -79,7 +79,7 @@ impl SettingsPane { // 不设宽度则走 flex 交叉轴 stretch:布局算法直接拉伸,不依赖父宽解析。 v_flex().w_full().child( div() - .pb(px(5.0)) + .pb(px(10.0)) .text_size(px(base_px * DESC_SCALE)) .font_weight(FontWeight::SEMIBOLD) .text_color(cx.theme().muted_foreground) @@ -102,11 +102,11 @@ impl SettingsPane { pub(crate) fn row( &self, label: &'static str, - desc: &'static str, + desc: impl Into, control: impl IntoElement, cx: &Context, ) -> impl IntoElement { - self.row_shell(label, desc, None, false, RowLayout::Standard, control, cx) + self.row_shell(label, desc.into(), None, false, RowLayout::Standard, control, cx) } /// 带撤销的设置行:该项被覆盖过时,左侧轨道这一段亮起来,行内出现 ↶。 @@ -117,7 +117,7 @@ impl SettingsPane { pub(crate) fn row_with_reset( &self, label: &'static str, - desc: &'static str, + desc: impl Into, dirty: bool, on_reset: impl Fn(&mut Self, &mut Window, &mut Context) + 'static, control: impl IntoElement, @@ -147,7 +147,7 @@ impl SettingsPane { .child(Icon::new(IconName::Undo2).xsmall()) .into_any_element() }); - self.row_shell(label, desc, reset, dirty, RowLayout::Standard, control, cx) + self.row_shell(label, desc.into(), reset, dirty, RowLayout::Standard, control, cx) } /// 行 hover 组名。↶ 要跟着**整行**的 hover 显形,而不是自己被指到才现 @@ -200,7 +200,7 @@ impl SettingsPane { fn row_shell( &self, label: &'static str, - desc: &'static str, + desc: SettingHelp, reset: Option, dirty: bool, layout: RowLayout, @@ -209,6 +209,7 @@ impl SettingsPane { ) -> impl IntoElement { let theme = cx.theme(); let base_px = self.font_size_px(cx); + let expanded = self.expanded_setting_help.contains(label); let pad_y = if self.runtime.density == nebula_settings::DensityName::Compact { ROW_PAD_Y_COMPACT } else { @@ -227,16 +228,51 @@ impl SettingsPane { .text_color(theme.foreground) .child(label), ) + .when(desc.details.is_some(), |heading| { + let language = crate::gpui_shell::config::ui_language(cx); + let action = if expanded { + language.pick("收起说明", "Hide details") + } else { + language.pick("详细说明", "More details") + }; + heading.child( + Button::new(SharedString::from(format!("settings-help-{label}"))) + .icon(IconName::Info) + .ghost() + .size(px(22.0)) + .text_color(theme.muted_foreground) + .accessibility_id(SharedString::from(format!( + "settings-help-{label}" + ))) + .toggled(expanded) + .tooltip(format!("{label}: {action}")) + .on_click(cx.listener(move |this, _, _, cx| { + if !this.expanded_setting_help.insert(label) { + this.expanded_setting_help.remove(label); + } + cx.notify(); + })), + ) + }) .children(reset), ) - .when(!desc.is_empty(), |text| { + .when(!desc.summary.is_empty(), |text| { text.child( div() .mt(px(LABEL_DESC_GAP)) .text_size(px(base_px * DESC_SCALE)) .font_weight(FontWeight::NORMAL) .text_color(theme.muted_foreground) - .child(Self::desc_text(desc, cx)), + .child(Self::desc_text(desc.summary, cx)), + ) + }) + .when_some(desc.details.filter(|_| expanded), |text, details| { + text.child( + div() + .mt(px(8.0)) + .text_size(px(base_px * DESC_SCALE)) + .text_color(theme.muted_foreground) + .child(Self::desc_text(details, cx)), ) }); let control = control.into_any_element(); diff --git a/nebula_app/src/gpui_shell/settings_pane/font_picker.rs b/nebula_app/src/gpui_shell/settings_pane/font_picker.rs index bda3963..0202d88 100644 --- a/nebula_app/src/gpui_shell/settings_pane/font_picker.rs +++ b/nebula_app/src/gpui_shell/settings_pane/font_picker.rs @@ -235,7 +235,7 @@ impl SettingsPane { } self.row( language.pick("终端字体", "Terminal font"), - language.tr("settings.font.fallback_description"), + help("font_family", language), control, cx, ) diff --git a/nebula_app/src/gpui_shell/settings_pane/initialization.rs b/nebula_app/src/gpui_shell/settings_pane/initialization.rs new file mode 100644 index 0000000..c17126e --- /dev/null +++ b/nebula_app/src/gpui_shell/settings_pane/initialization.rs @@ -0,0 +1,579 @@ +use super::*; + +impl SettingsPane { + pub fn new(window: &mut Window, cx: &mut Context) -> Self { + let runtime = RuntimeSettings::load(); + let language = crate::gpui_shell::config::ui_language(cx); + let mut selects: Vec<(&'static str, SharedSelect, &'static [&'static str])> = Vec::new(); + let mut subscriptions = Vec::new(); + + let mut add_select = |key: &'static str, + values: &'static [&'static str], + current: &str, + window: &mut Window, + cx: &mut Context| { + let ix = values.iter().position(|v| *v == current).unwrap_or(0); + let select = cx.new(|cx| { + SelectState::new( + localized_select_labels(key, values, language), + Some(IndexPath::default().row(ix)), + window, + cx, + ) + }); + subscriptions.push(cx.subscribe_in( + &select, + window, + move |this: &mut Self, + entity: &SharedSelect, + event: &SelectEvent>, + window: &mut Window, + cx: &mut Context| { + if let SelectEvent::Confirm(Some(_)) = event { + let row = entity.read(cx).selected_index(cx).map(|path| path.row); + if let Some(value) = row.and_then(|row| values.get(row)) { + this.persist(&[(key, (*value).to_string())], cx); + if key == "language" { + this.refresh_localized_controls(window, cx); + cx.refresh_windows(); + } + } + } + }, + )); + selects.push((key, select, values)); + }; + + 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()); + + add_select( + "language", + nebula_settings::LanguagePref::VALUES, + runtime.language.settings_value(), + window, + cx, + ); + add_select("theme", &THEME_VALUES, runtime.theme.prompt_name(), window, cx); + // 选项顺序与文案照抄旧壳 `CURSOR_SHAPE_OPTIONS` / `cursor_shape_label`。 + add_select( + "cursor_shape", + &["beam", "underline", "block", "hollow"], + cursor_current, + window, + cx, + ); + add_select( + "tabs_position", + &["sidebar", "top"], + runtime.tabs_position.settings_value(), + window, + cx, + ); + add_select( + "tab_reveal", + &["slide", "instant"], + runtime.tab_reveal.settings_value(), + window, + cx, + ); + add_select( + "density", + &["standard", "compact"], + runtime.density.settings_value(), + window, + cx, + ); + add_select( + "new_tab_position", + &["after_current", "end"], + runtime.new_tab_position.settings_value(), + window, + cx, + ); + add_select( + "windowing_behavior", + &["use_new", "use_any_existing", "use_existing"], + runtime.windowing_behavior.settings_value(), + window, + cx, + ); + add_select( + "vcs_display", + &["auto", "git", "svn"], + runtime.vcs_display.settings_value(), + window, + cx, + ); + add_select( + "cell_width_mode", + &["compact", "relaxed"], + runtime.cell_width_mode.settings_value(), + window, + cx, + ); + // 文案照抄旧壳 `accept_label` / `completion_style_label`。 + add_select( + "bell", + &["none", "visual", "audible", "both"], + runtime.bell.settings_value(), + window, + cx, + ); + // 五档按 DWM 每帧成本排列,不是质量递进;用户按性能预算选择。 + // Aero 是实时玻璃,Acrylic 是实时材质模糊;两者都会采样窗口后方真实内容。 + // Mica / Mica Alt 使用系统壁纸 backdrop,不由 Nebula 读取或重采样。 + add_select( + "blur", + &["none", "mica", "mica-alt", "aero", "acrylic"], + runtime.blur.settings_value(), + window, + cx, + ); + add_select( + "accept", + &["right", "tab", "both"], + runtime.accept.settings_value(), + window, + cx, + ); + add_select( + "completion_style", + &["inline", "popup"], + runtime.completion_style.settings_value(), + window, + cx, + ); + // 壁纸 fit/对齐:存原文,经旧壳 renderer::image 的 parse 归一化 + // (兼容 cover/contain 等别名),展示用规范记号。 + let bgimg_fit = crate::renderer::image::BackgroundImageFit::parse( + runtime.background_image_fit.as_deref().unwrap_or(""), + ) + .unwrap_or_default() + .settings_value(); + // 顺序与文案照抄旧壳 `BACKGROUND_FIT_OPTIONS` / `background_image_fit_label`。 + add_select( + "background_image_fit", + &["fill", "uniform", "uniform_to_fill", "none"], + bgimg_fit, + window, + cx, + ); + let bgimg_align = crate::renderer::image::BackgroundImageAlignment::parse( + runtime.background_image_alignment.as_deref().unwrap_or(""), + ) + .unwrap_or_default() + .settings_value(); + // 九宫格顺序照抄旧壳 `BACKGROUND_ALIGNMENT_OPTIONS`(左上 → 右下)。 + add_select( + "background_image_alignment", + &[ + "top_left", + "top", + "top_right", + "left", + "center", + "right", + "bottom_left", + "bottom", + "bottom_right", + ], + bgimg_align, + window, + cx, + ); + add_select( + "ssh_proxy_mode", + &["off", "system", "custom"], + runtime.ssh_proxy_mode.settings_value(), + window, + cx, + ); + + // 与旧壳的默认 Shell 菜单共用检测层:不能在设置页另维护一份两项 + // 白名单,否则 CMD/Nushell/WSL 会出现在新建终端菜单,却无法设为默认。 + // 选项 = 彩色品牌 PNG(extra/shell-icons,与旧壳设置页/命令面板同 + // 一批资产)+ 名称,闭态与下拉同源(SelectItem::display_title/render)。 + let shell_icon_scale = window.scale_factor().max(0.5); + let (shell_items, shell_index) = + shell_select_items(&shell_current, shell_icon_scale, language); + let shell_select = cx.new(|cx| { + SelectState::new(shell_items, Some(IndexPath::default().row(shell_index)), window, cx) + }); + subscriptions.push(cx.subscribe_in( + &shell_select, + window, + move |this: &mut Self, + _entity: &SharedShellSelect, + event: &SelectEvent>, + window: &mut Window, + cx: &mut Context| { + if let SelectEvent::Confirm(Some(id)) = event { + // 置顶那行是动作不是选项:走导入流程,且不落盘。 + if id == SHELL_IMPORT_ACTION_ID { + this.import_terminal_directory(window, cx); + } else { + this.persist(&[("shell", id.clone())], cx); + } + } + }, + )); + + let bg_hex_input = { + let term = crate::gpui_shell::theme::chrome_theme_resolved(cx).palette().term_bg; + let rgb = runtime.background.unwrap_or([term.r, term.g, term.b]); + let input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder("#rrggbb") + .default_value(format_hex_rgb(rgb)) + }); + subscriptions.push(cx.subscribe_in( + &input, + window, + |this: &mut Self, + _: &Entity, + event: &InputEvent, + window: &mut Window, + cx: &mut Context| { + this.on_bg_hex_event(event, window, cx); + }, + )); + input + }; + let opacity_slider = cx.new(|_| { + SliderState::new().min(0.00).max(1.00).step(0.05).default_value(runtime.opacity) + }); + subscriptions.push(cx.subscribe(&opacity_slider, |this, _, event: &SliderEvent, cx| { + if let SliderEvent::Change(value) = event { + this.set_opacity(value.start(), cx); + } + })); + let wallpaper_opacity_slider = cx.new(|_| { + SliderState::new() + .min(0.05) + .max(1.00) + .step(0.05) + .default_value(runtime.background_image_opacity) + }); + subscriptions.push(cx.subscribe( + &wallpaper_opacity_slider, + |this, _, event: &SliderEvent, cx| { + if let SliderEvent::Change(value) = event { + this.set_wallpaper_opacity(value.start(), cx); + } + }, + )); + let (proxy_protocol, proxy_address) = + crate::display::manual_proxy_parts(&runtime.ssh_proxy_url); + let proxy_protocol_ix = crate::display::MANUAL_PROXY_PROTOCOL_OPTIONS + .iter() + .position(|item| *item == proxy_protocol) + .unwrap_or(0); + let proxy_protocol_select = cx.new(|cx| { + SelectState::new( + vec![SharedString::from("SOCKS5"), SharedString::from("HTTP")], + Some(IndexPath::default().row(proxy_protocol_ix)), + window, + cx, + ) + }); + subscriptions.push(cx.subscribe_in( + &proxy_protocol_select, + window, + |this: &mut Self, + _, + event: &SelectEvent>, + _, + cx: &mut Context| { + if matches!(event, SelectEvent::Confirm(Some(_))) { + this.commit_proxy_address(cx); + } + }, + )); + let proxy_url_input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder("127.0.0.1:7890") + .pattern(regex::Regex::new(r"^[^\s]{0,256}$").expect("static regex")) + .default_value(proxy_address.to_owned()) + }); + subscriptions.push(cx.subscribe_in( + &proxy_url_input, + window, + |this: &mut Self, _, event: &InputEvent, _, cx: &mut Context| { + this.on_proxy_address_event(event, cx); + }, + )); + let provider_store = crate::ai_providers::load(); + let active_provider = provider_store + .providers + .iter() + .find(|provider| provider.id == provider_store.active_id) + .or_else(|| provider_store.providers.first()) + .cloned() + .unwrap_or_else(|| { + crate::ai_providers::AiProvider::preset( + crate::ai_providers::ProviderKind::Custom, + "custom-1", + ) + }); + let provider_values = [ + active_provider.name, + active_provider.note, + active_provider.website_url, + active_provider.base_url, + active_provider.model, + ]; + let provider_placeholders = provider_input_placeholders(language); + let provider_inputs = provider_values + .into_iter() + .zip(provider_placeholders) + .map(|(value, placeholder)| { + cx.new(|cx| { + InputState::new(window, cx).placeholder(placeholder).default_value(value) + }) + }) + .collect(); + + // 远端备份的非密文槽位输入(容量按最多槽位的协议 S3 = 4);值在 + // 构造体内按当前协议回填。 + let backup_remote_inputs: Vec> = + (0..4).map(|_| cx.new(|cx| InputState::new(window, cx))).collect(); + + let ssh_username_input = cx.new(|cx| InputState::new(window, cx).placeholder("root")); + // 地址框现在只承担 host/IP;仍兼容粘贴整段 user@host,由共享 helper + // 在保存/测试时拆出内嵌用户名。 + let ssh_destination_input = + cx.new(|cx| InputState::new(window, cx).placeholder("example.com / 192.0.2.1")); + // 端口键入即过滤:至多 5 位数字(旧壳同规则;范围校验在保存/测试时做)。 + let ssh_port_input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder("22") + .pattern(regex::Regex::new(r"^\d{0,5}$").expect("static regex")) + }); + let ssh_label_input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder(localized_input_placeholder("ssh_label", language)) + }); + let ssh_password_input = cx.new(|cx| { + InputState::new(window, cx) + .masked(true) + .placeholder(localized_input_placeholder("ssh_password", language)) + }); + let ssh_icon_filter_input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder(localized_input_placeholder("ssh_icon_filter", language)) + }); + let ssh_proxy_host_input = + cx.new(|cx| InputState::new(window, cx).placeholder("127.0.0.1")); + let ssh_proxy_port_input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder("1080") + .pattern(regex::Regex::new(r"^\d{0,5}$").expect("static regex")) + }); + let ssh_proxy_username_input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder(localized_input_placeholder("ssh_proxy_username", language)) + }); + let ssh_proxy_password_input = cx.new(|cx| { + InputState::new(window, cx) + .masked(true) + .placeholder(localized_input_placeholder("ssh_proxy_password", language)) + }); + let ssh_jump_host_input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder(localized_input_placeholder("ssh_jump_host", language)) + }); + for input in [ + ssh_username_input.clone(), + ssh_destination_input.clone(), + ssh_port_input.clone(), + ssh_label_input.clone(), + ssh_password_input.clone(), + ssh_proxy_host_input.clone(), + ssh_proxy_port_input.clone(), + ssh_proxy_username_input.clone(), + ssh_proxy_password_input.clone(), + ssh_jump_host_input.clone(), + ] { + subscriptions.push(cx.subscribe_in( + &input, + window, + |this: &mut Self, _, event: &InputEvent, _, cx: &mut Context| { + if matches!(event, InputEvent::Change) { + this.touch_ssh_editor(cx); + } else if matches!(event, InputEvent::Focus | InputEvent::Blur) { + cx.notify(); + } + }, + )); + } + let font_family_input = + Self::new_font_family_input(runtime.font_family.clone(), window, cx); + subscriptions.push(cx.subscribe_in( + &font_family_input, + window, + |this: &mut Self, _, event: &InputEvent, window, cx| { + this.on_font_family_input_event(event, window, cx); + }, + )); + + let bg_picker_hsv = { + let term = crate::gpui_shell::theme::chrome_theme_resolved(cx).palette().term_bg; + let rgb = runtime.background.unwrap_or([term.r, term.g, term.b]); + crate::display::rgb_to_hsv(crate::display::color::Rgb::new(rgb[0], rgb[1], rgb[2])) + }; + + // GPUI 会先匹配 KeyBinding action,再派发元素的 capture/bubble KeyDown。 + // 因此录制 PageDown 等已有快捷键必须在应用级 interceptor 提前截住; + // 焦点检查保证后台设置标签或普通设置输入不受影响。 + let keymap_interceptor = cx.listener(|this, event: &gpui::KeystrokeEvent, window, cx| { + if this.keymap_capture.is_some() && this.focus_handle.contains_focused(window, cx) { + cx.stop_propagation(); + this.handle_keymap_capture(&event.keystroke, cx); + } + }); + subscriptions.push(cx.intercept_keystrokes(keymap_interceptor)); + let appearance_interceptor = cx.listener(Self::intercept_appearance_picker); + subscriptions.push(cx.intercept_keystrokes(appearance_interceptor)); + + let settings_search_input = cx.new(|cx| { + InputState::new(window, cx).placeholder(language.pick( + "搜索全部设置,例如「字号」「透明度」「更新」", + "Search all settings, e.g. font, opacity, update", + )) + }); + subscriptions.push(cx.subscribe_in( + &settings_search_input, + window, + |_this: &mut Self, + _: &Entity, + event: &InputEvent, + _: &mut Window, + cx: &mut Context| { + if matches!(event, InputEvent::Change | InputEvent::Focus | InputEvent::Blur) { + cx.notify(); + } + }, + )); + + Self { + focus_handle: cx.focus_handle(), + runtime, + active_section: 1, + appearance_picker: None, + theme_picker_trigger: cx.focus_handle(), + icon_picker_trigger: cx.focus_handle(), + expanded_setting_help: std::collections::HashSet::new(), + about_update: AboutUpdateState::Idle, + about_update_seq: 0, + about_last_checked: None, + settings_search_input, + settings_search_trigger_bounds: None, + selects, + shell_select, + bg_picker_open: false, + bg_picker_hsv, + bg_picker_drag: None, + bg_hex_input, + bg_hex_focused: false, + bg_hex_syncing: false, + bg_picker_trigger_bounds: None, + bg_sv_bounds: None, + bg_hue_bounds: None, + opacity_slider, + wallpaper_opacity_slider, + proxy_url_input, + proxy_protocol_select, + proxy_test_seq: 0, + proxy_test_status: crate::display::ProxyTestStatus::Idle, + provider_store, + provider_inputs, + provider_status: None, + provider_test_seq: 0, + provider_test_running: false, + provider_codex_confirm: None, + ssh_hosts: crate::gpui_shell::ssh_hosts::SshHostLists::load(), + ssh_username_input, + ssh_destination_input, + ssh_port_input, + ssh_label_input, + ssh_password_input, + ssh_proxy_host_input, + ssh_proxy_port_input, + ssh_proxy_username_input, + ssh_proxy_password_input, + ssh_jump_host_input, + ssh_username_picker_open: false, + ssh_username_trigger_bounds: None, + ssh_icon_picker_open: false, + ssh_icon_filter_input, + ssh_icon_trigger_bounds: None, + ssh_editor: None, + ssh_editor_focus_handle: cx.focus_handle(), + ssh_editor_seq: 0, + ssh_test_seq: 0, + ssh_status: None, + ssh_show_hidden: false, + ssh_delete_confirm: None, + ssh_delete_undo: None, + ssh_undo_seq: 0, + font_picker_open: false, + font_loading: false, + font_system: None, + font_imported: Vec::new(), + font_family_input, + font_picker_trigger_bounds: None, + backup_selection: crate::encrypted_backup::BackupSelection::default(), + backup_pass_input: cx.new(|cx| { + InputState::new(window, cx) + .masked(true) + .placeholder(localized_input_placeholder("backup_password", language)) + }), + backup_status: None, + backup_busy: false, + backup_seq: 0, + backup_remote: { + let cfg = crate::backup_remote::BackupRemoteConfig::load(); + for (ix, input) in backup_remote_inputs.iter().enumerate() { + let value = cfg.slot(ix).unwrap_or_default().to_owned(); + input.update(cx, |input, cx| input.set_value(value, window, cx)); + } + cfg + }, + backup_remote_inputs, + backup_secret_input: cx.new(|cx| { + InputState::new(window, cx) + .masked(true) + .placeholder(localized_input_placeholder("backup_secret", language)) + }), + keymap_search_input: { + let input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder(localized_input_placeholder("keymap_search", language)) + }); + subscriptions.push(cx.subscribe_in( + &input, + window, + |_this: &mut Self, + _: &Entity, + event: &InputEvent, + _: &mut Window, + cx: &mut Context| { + // 搜索词变化只影响可见行集合;捕获态不因打字被打断 + // (捕获期间焦点在分区根上,输入框收不到键)。 + if matches!(event, InputEvent::Change) { + cx.notify(); + } + }, + )); + input + }, + keymap_capture: None, + keymap_capture_preview: String::new(), + keymap_binds: nebula_settings::keybind_pairs(), + slider_persist: None, + _subscriptions: subscriptions, + } + } +} diff --git a/nebula_app/src/gpui_shell/settings_pane/localization.rs b/nebula_app/src/gpui_shell/settings_pane/localization.rs new file mode 100644 index 0000000..978097d --- /dev/null +++ b/nebula_app/src/gpui_shell/settings_pane/localization.rs @@ -0,0 +1,135 @@ +use gpui::SharedString; + +/// 固定下拉只在这里把稳定 value 映射为显示文案。创建和语言切换刷新共用 +/// 同一入口,避免 `SelectState` 留着构造时的旧语言。 +pub(super) fn localized_select_labels( + key: &str, + values: &[&'static str], + language: crate::display::UiLanguage, +) -> Vec { + let labels: Vec<&'static str> = match key { + "language" => nebula_settings::LanguagePref::ALL + .iter() + .map(|preference| { + if *preference == nebula_settings::LanguagePref::System { + language.tr("language.system") + } else { + preference.native_name() + } + }) + .collect(), + "cursor_shape" => vec![ + language.pick("条形(│)", "Bar (│)"), + language.pick("下划线(_)", "Underscore (_)"), + language.pick("实心框(█)", "Filled box (█)"), + language.pick("空心框(□)", "Empty box (□)"), + ], + "tabs_position" => { + vec![language.pick("左侧边栏", "Left sidebar"), language.pick("顶部", "Top")] + }, + "tab_reveal" => vec![language.pick("滑动", "Slide"), language.pick("立即", "Instant")], + "density" => vec![language.pick("标准", "Standard"), language.pick("紧凑", "Compact")], + "new_tab_position" => vec![ + language.pick("当前标签之后", "After current tab"), + language.pick("列表末尾", "End of list"), + ], + "windowing_behavior" => vec![ + language.pick("创建新窗口", "Create a new window"), + language.pick("附加到最近使用的窗口", "Attach to the most recent window"), + language.pick( + "附加到此桌面最近使用的窗口", + "Attach to the most recent window on this desktop", + ), + ], + "vcs_display" => vec![ + language.pick("自动检测", "Auto detect"), + language.pick("仅 Git", "Git only"), + language.pick("仅 SVN", "SVN only"), + ], + "cell_width_mode" => { + vec![language.pick("紧凑", "Compact"), language.pick("宽松", "Relaxed")] + }, + "bell" => vec![ + language.pick("关", "Off"), + language.pick("闪烁", "Visual"), + language.pick("声音", "Sound"), + language.pick("闪烁 + 声音", "Visual + sound"), + ], + "blur" => vec![ + language.pick("无", "None"), + language.pick("Mica(低开销)", "Mica (low cost)"), + language.pick("Mica Alt(低开销)", "Mica Alt (low cost)"), + language.pick("Aero(玻璃)", "Aero (glass)"), + language.pick("Acrylic(高开销)", "Acrylic (high cost)"), + ], + "accept" => vec![ + language.pick("右方向键", "Right arrow"), + "Tab", + language.pick("Tab 或右方向键", "Tab or Right arrow"), + ], + "completion_style" => { + vec![language.pick("行内灰字", "Inline ghost"), language.pick("弹窗列表", "Popup list")] + }, + "background_image_fit" => vec![ + language.pick("拉伸", "Fill"), + language.pick("适应", "Uniform"), + language.pick("填充", "Uniform to fill"), + language.pick("原始尺寸", "Original size"), + ], + "background_image_alignment" => vec![ + language.pick("左上", "Top left"), + language.pick("顶部", "Top"), + language.pick("右上", "Top right"), + language.pick("左侧", "Left"), + language.pick("居中", "Center"), + language.pick("右侧", "Right"), + language.pick("左下", "Bottom left"), + language.pick("底部", "Bottom"), + language.pick("右下", "Bottom right"), + ], + "ssh_proxy_mode" => vec![ + language.tr("settings.network.mode.off"), + language.tr("settings.network.mode.system"), + language.tr("settings.network.mode.custom"), + ], + _ => values.to_vec(), + }; + debug_assert_eq!(labels.len(), values.len(), "localized select label/value mismatch: {key}"); + labels.into_iter().map(SharedString::from).collect() +} + +pub(super) fn provider_input_placeholders( + language: crate::display::UiLanguage, +) -> [&'static str; 5] { + [ + language.pick("供应商名称", "Provider name"), + language.pick("备注(可包含空格)", "Note (spaces allowed)"), + language.pick("官方网站", "Official website"), + language.pick("API 请求地址", "API endpoint"), + language.pick("默认模型", "Default model"), + ] +} + +pub(super) fn localized_input_placeholder( + key: &str, + language: crate::display::UiLanguage, +) -> &'static str { + match key { + "ssh_label" => language.pick("例如:开发服务器", "e.g. Development server"), + "ssh_password" => language.pick("留空则连接时询问", "Leave empty to ask when connecting"), + "ssh_proxy_username" => language.pick("可选", "Optional"), + "ssh_proxy_password" => { + language.pick("留空则保留已存密码", "Leave empty to keep the saved password") + }, + "ssh_jump_host" => language + .pick("user@bastion:22 或 SSH config 别名", "user@bastion:22 or SSH config alias"), + "ssh_icon_filter" => language.pick("搜索图标…", "Search icons..."), + "font_family" => language.pick("输入字体名称", "Enter a font family"), + "backup_password" => { + language.pick("备份密码(至少 8 位)", "Backup password (at least 8 characters)") + }, + "backup_secret" => language.tr("settings.input.backup_secret"), + "keymap_search" => language.pick("搜索动作或按键…", "Search actions or keys..."), + _ => "", + } +} diff --git a/nebula_app/src/gpui_shell/settings_pane/navigation.rs b/nebula_app/src/gpui_shell/settings_pane/navigation.rs new file mode 100644 index 0000000..c618c3f --- /dev/null +++ b/nebula_app/src/gpui_shell/settings_pane/navigation.rs @@ -0,0 +1,204 @@ +use super::*; + +/// 主题下拉(展示名 = 持久化名,与旧壳一致)。 +pub(super) const THEME_VALUES: [&str; 9] = [ + "Nebula", + "SilverLight", + "SteelDark", + "LimestoneLight", + "CoalDark", + "LinenLight", + "MossDark", + "Nord", + "Paper", +]; + +pub(super) const REPOSITORY_URL: &str = "https://github.com/Kuddev/nebula"; +pub(super) const BUG_REPORT_TEMPLATE: &str = "bug_report.yml"; + +/// 左侧分区的稳定路由表。2026-08-28 产品裁定:默认 GPUI 导航收敛为常用项, +/// 暂时隐藏“AI 供应商”和“备份”;页面实现与索引继续保留。后续恢复入口时只改 +/// [`HIDDEN_NAV_SECTIONS`],不得删除或重排这里的条目。 +pub(super) const SECTION_IDS: [&str; 10] = [ + "application", + "appearance", + "profiles", + "providers", + "ssh", + "network", + "interaction", + "keymap", + "advanced", + "backup", +]; + +/// Bilingual search aliases for the stable section routes. Search is a route +/// finder, so a query such as "font", "opacity", or "更新" lands on the +/// 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 图标", + "profiles 配置文件 shell terminal 终端 completion 补全 startup 启动", + "providers provider ai 供应商 模型 api", + "ssh host 主机 remote 远程 connection 连接", + "network 网络 proxy 代理 connectivity 连接", + "interaction 交互 copy 复制 paste 粘贴 tab 标签 panel 面板", + "keymap key binding shortcut 按键映射 快捷键", + "advanced 高级 session 会话 tray 托盘 restore 恢复", + "backup 备份 export 导出 restore 恢复", +]; + +pub(super) const HIDDEN_NAV_SECTIONS: &[usize] = &[3, 9]; + +/// 保留原来的分组展开顺序,组名不再渲染;数组里仍保存稳定的 [`SECTION_IDS`] +/// 下标,不复制设置状态或路由。 +pub(super) const NAV_GROUPS: [(&str, &[usize]); 3] = + [("workspace", &[0, 1, 2, 6, 7]), ("connections", &[3, 4, 5]), ("system", &[8, 9])]; + +pub(super) fn section_label(index: usize, language: crate::display::UiLanguage) -> &'static str { + match SECTION_IDS.get(index).copied() { + Some("application") => language.tr("settings.sidebar.application"), + Some("appearance") => language.tr("settings.sidebar.appearance"), + Some("profiles") => language.tr("settings.sidebar.profiles"), + Some("providers") => language.tr("settings.sidebar.providers"), + Some("ssh") => language.tr("settings.sidebar.ssh"), + Some("network") => language.tr("settings.sidebar.network"), + Some("interaction") => language.tr("settings.sidebar.interaction"), + Some("keymap") => language.tr("settings.sidebar.keymap"), + Some("advanced") => language.tr("settings.sidebar.advanced"), + Some("backup") => language.tr("settings.sidebar.backup"), + _ => "", + } +} + +pub(super) fn is_nav_section_visible(index: usize) -> bool { + !HIDDEN_NAV_SECTIONS.contains(&index) +} + +pub(super) fn visible_nav_sections() -> impl Iterator { + NAV_GROUPS + .iter() + .flat_map(|(_, sections)| sections.iter().copied()) + .filter(|index| is_nav_section_visible(*index)) +} + +// 导航使用组件的小字号,与搜索菜单一致;尺寸是逻辑像素,由窗口统一处理 DPI。 +pub(super) const SETTINGS_NAV_WIDTH: f32 = 208.0; +pub(super) const SETTINGS_NAV_ROW_HEIGHT: f32 = 34.0; +pub(super) const SETTINGS_NAV_ICON_SIZE: f32 = 16.0; +pub(super) const SETTINGS_HEADER_HEIGHT: f32 = 74.0; + +// 正文分组与表单继续沿用现有几何节奏。 +pub(super) const SETTINGS_GROUP_GAP: f32 = 32.0; +pub(super) const SETTINGS_GROUP_TITLE_HEIGHT: f32 = 26.0; +pub(super) const SETTINGS_GROUP_TITLE_GAP: f32 = 16.0; +pub(super) const SETTINGS_ROW_HEIGHT: f32 = 48.0; +pub(super) const SETTINGS_ROW_GAP: f32 = 8.0; +/// 标准设置选择器的实际宽度。字体输入与 Select 共用,避免同列控件漂移。 +pub(super) const SETTINGS_SELECT_WIDTH: f32 = 220.0; + +/// 列表/触发条上的展示名:族名偶尔带着导入文件后缀,界面上剥掉。 +pub(super) fn font_display_name(name: &str) -> String { + let trimmed = name.trim(); + let lower = trimmed.to_ascii_lowercase(); + for ext in [".ttf", ".otf", ".ttc", ".otc", ".woff", ".woff2"] { + if let Some(stem) = lower.strip_suffix(ext) { + return trimmed[..stem.len()].to_owned(); + } + } + trimmed.to_owned() +} + +/// 导航图标。 +/// +/// 统一走路径字符串而不是 `IconName`:绝大多数取 lucide 的现成图标,但 +/// 「按键映射」在 lucide 里没有对应项(原来错挂成 `ALargeSmall`,那是字号 +/// 图标),只能自带一枚——两种来源用同一个类型表达,调用点就不必分叉。 +pub(super) fn section_icon(index: usize) -> SharedString { + use gpui_component::IconNamed as _; + match index { + 0 => crate::gpui_shell::assets::nav::LAYOUT_GRID.into(), + 1 => IconName::Palette.path(), + 2 => IconName::Folder.path(), + 3 => IconName::Bot.path(), + 4 => IconName::SquareTerminal.path(), + 5 => IconName::Globe.path(), + 6 => crate::gpui_shell::assets::nav::MOUSE_POINTER.into(), + 7 => crate::gpui_shell::assets::nav::KEYMAP.into(), + 8 => crate::gpui_shell::assets::nav::SLIDERS.into(), + _ => IconName::Inbox.path(), + } +} + +pub(super) fn chrome_theme(theme: ThemeName) -> crate::display::NebulaTheme { + use crate::display::NebulaTheme; + match theme { + ThemeName::Nebula => NebulaTheme::Nebula, + ThemeName::SilverLight => NebulaTheme::SilverLight, + ThemeName::SteelDark => NebulaTheme::SteelDark, + ThemeName::LimestoneLight => NebulaTheme::LimestoneLight, + ThemeName::CoalDark => NebulaTheme::CoalDark, + ThemeName::LinenLight => NebulaTheme::LinenLight, + ThemeName::MossDark => NebulaTheme::MossDark, + ThemeName::Nord => NebulaTheme::Nord, + ThemeName::Paper => NebulaTheme::Paper, + } +} + +pub(super) fn rgb_hsla(r: u8, g: u8, b: u8) -> Hsla { + GpuiRgba { r: f32::from(r) / 255.0, g: f32::from(g) / 255.0, b: f32::from(b) / 255.0, a: 1.0 } + .into() +} + +pub(super) fn query_component(value: &str) -> String { + const HEX: &[u8; 16] = b"0123456789ABCDEF"; + let mut encoded = String::with_capacity(value.len()); + for byte in value.bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') { + encoded.push(char::from(byte)); + } else { + encoded.push('%'); + encoded.push(char::from(HEX[(byte >> 4) as usize])); + encoded.push(char::from(HEX[(byte & 0x0f) as usize])); + } + } + encoded +} + +/// GitHub issue form 预填合同:模板、版本、平台、安装来源 +/// 和诊断摘要都来自当前运行实例,用户只需补充复现步骤与实际表现。 +pub(super) fn issue_url() -> String { + let version = env!("CARGO_PKG_VERSION"); + let platform = match std::env::consts::OS { + "windows" => "Windows", + "macos" => "macOS", + "linux" => "Linux", + _ => "Other", + }; + let install_source = if cfg!(debug_assertions) { + "Built from source (cargo build / cargo run)" + } else { + "GitHub Release (.msi / .exe / portable archive)" + }; + let build = if cfg!(debug_assertions) { "debug" } else { "release" }; + let logs = format!( + "Reported from {} Settings ({} {version}).\n\nPlatform: {} {}\nBuild: {build}", + crate::brand::NAME, + crate::brand::NAME, + std::env::consts::OS, + std::env::consts::ARCH, + ); + let params = [ + ("template", BUG_REPORT_TEMPLATE), + ("title", "[Bug] "), + ("version", version), + ("platform", platform), + ("install_source", install_source), + ("logs", logs.as_str()), + ] + .into_iter() + .map(|(key, value)| format!("{key}={}", query_component(value))) + .collect::>() + .join("&"); + format!("{REPOSITORY_URL}/issues/new?{params}") +} diff --git a/nebula_app/src/gpui_shell/settings_pane/reset.rs b/nebula_app/src/gpui_shell/settings_pane/reset.rs new file mode 100644 index 0000000..ef117cb --- /dev/null +++ b/nebula_app/src/gpui_shell/settings_pane/reset.rs @@ -0,0 +1,66 @@ +use super::*; + +impl SettingsPane { + pub(super) fn reset_all_settings(&mut self, window: &mut Window, cx: &mut Context) { + let language = crate::gpui_shell::config::ui_language(cx); + self.slider_persist = None; + let backup = match nebula_settings::restore_default_settings() { + Ok(backup) => backup, + Err(error) => { + crate::gpui_shell::toast::toast( + window, + cx, + crate::gpui_shell::toast::ToastKind::Warning, + format!( + "{}: {error}", + language.pick("恢复默认设置失败", "Failed to restore defaults") + ), + ); + return; + }, + }; + let active_section = self.active_section; + let about_update = std::mem::replace(&mut self.about_update, AboutUpdateState::Idle); + let about_last_checked = self.about_last_checked.take(); + let about_update_seq = self.about_update_seq; + let proxy_test_seq = self.proxy_test_seq.wrapping_add(1); + let provider_test_seq = self.provider_test_seq.wrapping_add(1); + let ssh_editor_seq = self.ssh_editor_seq.wrapping_add(1); + let ssh_test_seq = self.ssh_test_seq.wrapping_add(1); + let ssh_undo_seq = self.ssh_undo_seq.wrapping_add(1); + let backup_seq = self.backup_seq.wrapping_add(1); + let settings = crate::gpui_shell::config::Settings::load( + crate::gpui_shell::theme::effective_theme_name(cx), + ); + gpui_component::set_locale(settings.ui_language.gpui_component_locale()); + cx.set_global(settings); + *self = Self::new(window, cx); + self.active_section = active_section; + self.about_update = about_update; + self.about_last_checked = about_last_checked; + self.about_update_seq = about_update_seq; + self.proxy_test_seq = proxy_test_seq; + self.provider_test_seq = provider_test_seq; + self.ssh_editor_seq = ssh_editor_seq; + self.ssh_test_seq = ssh_test_seq; + self.ssh_undo_seq = ssh_undo_seq; + self.backup_seq = backup_seq; + window.focus(&self.focus_handle, cx); + cx.emit(SettingsPaneEvent::Changed); + cx.refresh_windows(); + cx.notify(); + crate::gpui_shell::toast::toast( + window, + cx, + crate::gpui_shell::toast::ToastKind::Success, + if backup.is_some() { + language.pick("已恢复默认设置;主机与凭据保持不变,原设置已备份。", "Defaults restored. Hosts and credentials are unchanged; previous settings were backed up.") + } else { + language.pick( + "已恢复默认设置;主机与凭据保持不变。", + "Defaults restored. Hosts and credentials are unchanged.", + ) + }, + ); + } +} diff --git a/nebula_app/src/gpui_shell/settings_pane/search_header.rs b/nebula_app/src/gpui_shell/settings_pane/search_header.rs new file mode 100644 index 0000000..ddf5aa1 --- /dev/null +++ b/nebula_app/src/gpui_shell/settings_pane/search_header.rs @@ -0,0 +1,114 @@ +use super::*; + +impl SettingsPane { + pub(super) fn render_search_header( + &self, + window: &Window, + cx: &Context, + ) -> gpui::AnyElement { + let language = crate::gpui_shell::config::ui_language(cx); + let query = self.settings_search_input.read(cx).value().trim().to_lowercase(); + let results: Vec = if query.is_empty() { + Vec::new() + } else { + visible_nav_sections() + .filter(|index| { + SECTION_SEARCH_TERMS[*index].contains(&query) + || section_label(*index, language).to_lowercase().contains(&query) + }) + .take(6) + .collect() + }; + let bounds = self.settings_search_trigger_bounds; + let hairline = crate::gpui_shell::theme::settings_hairline(cx); + let hover = cx.theme().list_hover; + let focused = self.settings_search_input.read(cx).focus_handle(cx).is_focused(window); + let panel = (focused && !results.is_empty()).then(|| { + v_flex() + .w(bounds.map(|bounds| bounds.size.width).unwrap_or(px(680.0))) + .p_1() + .gap_1() + .rounded_md() + .border_1() + .border_color(hairline) + .bg(crate::gpui_shell::theme::settings_panel_bg(cx)) + .text_sm() + .line_height(px(20.0)) + .shadow_lg() + .occlude() + .children(results.into_iter().map(|index| { + h_flex() + .id(("settings-search-result", index)) + .h(px(SETTINGS_NAV_ROW_HEIGHT)) + .px_2() + .gap_2() + .rounded_md() + .cursor_pointer() + .hover(move |row| row.bg(hover)) + .on_click(cx.listener(move |this, _, window, cx| { + this.active_section = index; + this.settings_search_input + .update(cx, |input, cx| input.set_value("", window, cx)); + cx.notify(); + })) + .child( + Icon::default() + .path(section_icon(index)) + .size(px(SETTINGS_NAV_ICON_SIZE)), + ) + .child(section_label(index, language)) + })) + }); + let trigger = cx.entity().downgrade(); + let search = div() + .relative() + .w_full() + .child( + Input::new(&self.settings_search_input) + .w_full() + .cleanable(true) + .prefix( + Icon::new(IconName::Search) + .xsmall() + .text_color(cx.theme().muted_foreground), + ) + .aria_label(language.pick("在全部设置中搜索", "Search all settings")), + ) + .child( + gpui::canvas( + move |bounds, _, cx| { + let _ = trigger.update(cx, |pane, cx| { + if pane.settings_search_trigger_bounds != Some(bounds) { + pane.settings_search_trigger_bounds = Some(bounds); + cx.notify(); + } + }); + }, + |_, _, _, _| {}, + ) + .absolute() + .size_full(), + ) + .when_some(panel.zip(bounds), |search, (panel, bounds)| { + search.child( + deferred( + anchored() + .anchor(gpui::Anchor::TopLeft) + .position(bounds.bottom_left()) + .offset(gpui::point(px(0.0), px(6.0))) + .snap_to_window_with_margin(px(8.0)) + .child(panel), + ) + .with_priority(3), + ) + }); + h_flex() + .w_full() + .h(px(SETTINGS_HEADER_HEIGHT)) + .flex_shrink_0() + .px_5() + .items_center() + .child(search) + .into_any_element() + } +} diff --git a/nebula_app/src/gpui_shell/settings_pane/setting_help.rs b/nebula_app/src/gpui_shell/settings_pane/setting_help.rs new file mode 100644 index 0000000..136f839 --- /dev/null +++ b/nebula_app/src/gpui_shell/settings_pane/setting_help.rs @@ -0,0 +1,221 @@ +use crate::display::UiLanguage; + +#[derive(Clone, Copy)] +pub(super) struct SettingHelp { + pub summary: &'static str, + pub details: Option<&'static str>, +} + +impl From<&'static str> for SettingHelp { + fn from(summary: &'static str) -> Self { + Self { summary, details: None } + } +} + +pub(super) fn help(key: &str, language: UiLanguage) -> SettingHelp { + let (summary, details) = match key { + "background" => ( + language.pick("只更改终端底色,设置页仍跟随主题。", "Changes only the terminal background; Settings keeps the theme colors."), + None, + ), + "background_image" => ( + language.pick("在终端文字后方显示图片。", "Shows an image behind terminal text."), + Some(language.pick("图片不改变文字配色;看不清时可降低背景图像不透明度。", "Text colors still come from the theme. Lower the image opacity if text is hard to read.")), + ), + "background_image_fit" => ( + language.pick("调整图片的铺放方式。", "Chooses how the background image fills the terminal."), + Some(language.pick("拉伸会改变比例;适应和填充保持比例;原始尺寸不缩放。", "Fill stretches the image. Uniform and Uniform to fill preserve its aspect ratio. Original size does not scale it.")), + ), + "background_image_alignment" => ( + language.pick("设置图片在终端中的位置。", "Positions the image within the terminal."), + None, + ), + "background_image_opacity" => ( + language.pick("数值越低,图片越淡。", "Lower values make the image less prominent."), + None, + ), + "background_image_cover_chrome" => ( + language.pick("关闭后,图片只显示在终端区域。", "When disabled, the image appears only behind the terminal."), + None, + ), + "cursor_shape" => ("", None), + "cursor_blink" => ( + language.pick("关闭后,光标保持常亮。", "When disabled, the cursor stays lit."), + None, + ), + "tab_close_visible" => ( + language.pick("隐藏后,仍可用鼠标中键关闭标签。", "Middle-click still closes tabs when their close buttons are hidden."), + None, + ), + "language" => ( + language.pick("只更改应用界面的语言。", "Changes only the application interface language."), + Some(language.pick("终端内程序的输出语言由各自的环境设置决定。", "Programs inside the terminal choose their output language from their own environment.")), + ), + "density" => ( + language.pick("调整界面留白,不改变终端行距。", "Adjusts interface spacing without changing terminal line spacing."), + None, + ), + "opacity" => ( + language.pick("100% 为完全不透明,不影响设置页。", "100% is fully opaque. The Settings page is unaffected."), + None, + ), + "blur" => ( + language.pick("选择窗口背景的模糊效果。", "Chooses the window background effect."), + Some(language.pick("Mica 系列开销较低;Aero 和 Acrylic 实时模糊窗口后方的内容,开销较高。", "Mica effects use fewer resources. Aero and Acrylic blur live content behind the window and cost more.")), + ), + "cell_width_mode" => ( + language.pick("调整终端字符的列宽。", "Adjusts terminal character cell width."), + Some(language.pick("紧凑向下取整;宽松向上补齐,避免小数字宽的字体显得拥挤。只影响终端网格。", "Compact rounds down. Relaxed rounds up to avoid squeezing fonts with fractional widths. Only the terminal grid is affected.")), + ), + "fetch" => ( + language.pick("新会话启动时显示系统信息。", "Shows system information when a new session starts."), + Some(language.pick("通过 fastfetch 显示系统信息;关闭可减少新标签的启动等待。", "Uses fastfetch to display system information. Disabling it reduces startup work for new tabs.")), + ), + "powerline" => ( + language.pick("显示分段提示符,需字体支持。", "Shows a segmented prompt. Requires a compatible font."), + Some(language.pick("字体须包含 Powerline 字形,否则分段箭头可能显示为方框。", "The font must include Powerline glyphs or the segment arrows may appear as boxes.")), + ), + "shell" => ( + language.pick("用于新标签,不影响已打开的终端。", "Used for new tabs. Existing terminals are unaffected."), + None, + ), + "startup_directory" => ( + language.pick("未设置时,继承应用的启动目录。", "When unset, inherits the directory used to launch the application."), + Some(language.pick("从资源管理器的文件夹中启动时,默认进入该文件夹。", "Launching from a File Explorer folder uses that folder by default.")), + ), + "bell" => ( + language.pick("程序提示时,选择声音或闪烁提醒。", "Chooses sound or visual feedback for terminal alerts."), + None, + ), + "font_family" => ( + language.pick("按字体组顺序查找可用字形。", "Uses the font group in order to find available glyphs."), + Some(language.pick("前面的字体缺少字形时,会继续尝试后面的字体。", "When a font lacks a glyph, the next font in the group is tried.")), + ), + "ghost" => ( + language.pick("根据历史命令给出建议,确认后才填入。", "Suggests commands from history and inserts them only after acceptance."), + None, + ), + "accept" => ( + language.pick("选择填入建议时使用的按键。", "Chooses the key used to accept a suggestion."), + Some(language.pick("如果 Tab 与 Shell 自带补全冲突,可改用右方向键。", "Use Right arrow if Tab conflicts with the shell's own completion.")), + ), + "completion_style" => ( + language.pick("选择行内建议或候选列表。", "Chooses inline suggestions or a list of candidates."), + Some(language.pick("行内建议不遮挡下方输出;候选列表能同时显示多项建议。", "Inline suggestions leave output visible. A popup list shows several candidates at once.")), + ), + "copy_on_select" => ( + language.pick("松开鼠标自动复制,右键直接粘贴。", "Copies on mouse release and pastes on right-click."), + Some(language.pick("关闭后,选中不会自动复制;右键改为打开复制与粘贴菜单。", "When disabled, selection does not copy automatically and right-click opens the copy/paste menu.")), + ), + "multiline_paste_confirm" => ( + language.pick("在普通 shell 中粘贴多行或高风险内容时先确认。", "Asks before multiline or risky pastes in a plain shell."), + Some(language.pick("检查换行、提权命令和控制字符。Bracketed paste 模式与全屏程序不受此项保护;关闭后直接粘贴。", "Checks line breaks, privileged commands and control characters. Bracketed paste and full-screen apps are unaffected; disabling this pastes directly.")), + ), + "panel_resize" => ( + language.pick("关闭后锁定分界线,避免误拖。", "Locks the divider when disabled to prevent accidental resizing."), + None, + ), + "cjk_bold_regular" => ( + language.pick("用提亮代替加粗,让密集笔画更清楚。", "Brightens dense glyphs instead of thickening their strokes."), + Some(language.pick("只影响中日韩(CJK)粗体字形,拉丁字母仍使用真正的粗体。", "Only CJK bold glyphs are affected. Latin letters still use true bold.")), + ), + "tabs_position" => ("", None), + "tab_reveal" => ( + language.pick("新标签滑动出现,或立即显示。", "Slides new tabs into view or shows them immediately."), + None, + ), + "new_tab_position" => ( + language.pick("只影响新标签,不改变恢复时的顺序。", "Affects new tabs only, not the order of restored tabs."), + None, + ), + "windowing_behavior" => ( + language.pick("再次启动应用时,新开窗口或加入已有窗口。", "Opens a new window or joins an existing one when launched again."), + None, + ), + "vcs_display" => ( + language.pick("选择侧栏显示 Git 还是 SVN 状态。", "Chooses whether the sidebar shows Git or SVN status."), + Some(language.pick("自动检测会识别 .git 和 .svn;同目录存在两者时可手动指定。", "Auto detect checks for .git and .svn. Choose manually when both are present.")), + ), + "keep_session" => ( + language.pick("关窗后终端继续运行;关闭此项会结束会话。", "Keeps terminals running after closing the window; disabling it ends sessions."), + Some(language.pick("关闭此项后,关窗会终止 Shell,未保存的工作可能丢失。开启后可重新附着到后台会话。", "When disabled, closing the window terminates its shells and may lose unsaved work. When enabled, background sessions can be reattached.")), + ), + "restore_session" => ( + language.pick("恢复标签、布局和目录,不恢复原进程。", "Restores tabs, layout and directories, not the original processes."), + Some(language.pick("恢复的标签会启动新的 Shell,不会让已结束的程序继续运行。", "Restored tabs start new shells; terminated programs are not resumed.")), + ), + "resume_ai" => ( + language.pick("恢复 AI 标签时,继续上次的对话。", "Continues the previous conversation when restoring an AI tab."), + None, + ), + "tray" => ( + language.pick("在通知区域查看 AI 会话状态。", "Shows AI session status in the notification area."), + None, + ), + _ => unreachable!("unknown settings help key: {key}"), + }; + SettingHelp { summary, details } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn descriptions_stay_short_in_both_interface_languages() { + let keys = [ + "background", + "background_image", + "background_image_fit", + "background_image_alignment", + "background_image_opacity", + "background_image_cover_chrome", + "cursor_shape", + "cursor_blink", + "tab_close_visible", + "language", + "density", + "opacity", + "blur", + "cell_width_mode", + "fetch", + "powerline", + "shell", + "startup_directory", + "bell", + "font_family", + "ghost", + "accept", + "completion_style", + "copy_on_select", + "multiline_paste_confirm", + "panel_resize", + "cjk_bold_regular", + "tabs_position", + "tab_reveal", + "new_tab_position", + "windowing_behavior", + "vcs_display", + "keep_session", + "restore_session", + "resume_ai", + "tray", + ]; + for key in keys { + for language in [UiLanguage::ZhCn, UiLanguage::EnUs] { + let description = help(key, language); + let limit = if language == UiLanguage::ZhCn { 32 } else { 100 }; + assert!(description.summary.chars().count() <= limit, "long summary: {key}"); + assert!(description.details.is_none_or(|details| !details.is_empty())); + } + } + } + + #[test] + fn critical_limits_remain_in_the_short_description() { + let language = UiLanguage::ZhCn; + assert!(help("keep_session", language).summary.contains("结束会话")); + assert!(help("restore_session", language).summary.contains("不恢复原进程")); + assert!(help("multiline_paste_confirm", language).details.unwrap().contains("全屏程序")); + } +} diff --git a/nebula_app/src/gpui_shell/settings_pane/shell_picker.rs b/nebula_app/src/gpui_shell/settings_pane/shell_picker.rs new file mode 100644 index 0000000..9830cf6 --- /dev/null +++ b/nebula_app/src/gpui_shell/settings_pane/shell_picker.rs @@ -0,0 +1,185 @@ +use super::*; + +#[derive(Clone)] +pub(super) struct ShellSelectItem { + pub(super) id: String, + pub(super) name: SharedString, + pub(super) closed_image: Option>, + pub(super) row_image: Option>, +} + +/// 下拉首行那条「导入终端目录」动作行的哨兵 id。 +/// +/// 它不是任何真实 shell:选中只触发目录扫描,绝不会写进 `shell` 设置。 +/// 取这个形状是因为 `shell_detect` 的 id 全是普通标识符(`pwsh`、`cmd`、 +/// `wsl:`、`profile:<家族>|`),双下划线包裹不可能与之相撞。 +pub(super) const SHELL_IMPORT_ACTION_ID: &str = "__nebula_import_terminal_dir__"; + +impl ShellSelectItem { + pub(super) fn new(id: String, name: String, scale_factor: f32) -> Self { + // Select 的闭态和菜单行尺寸不同。分别生成与物理像素一一对应的纹理, + // 避免把 128px 原图交给 GPUI 在每帧缩小而产生模糊边缘。 + let closed_image = crate::gpui_shell::widgets::shell_brand_image(&id, 20.0, scale_factor); + let row_image = crate::gpui_shell::widgets::shell_brand_image(&id, 24.0, scale_factor); + Self { id, name: name.into(), closed_image, row_image } + } + + /// 置顶的导入行。没有品牌贴图,[`Self::view`] 会给它文件夹图标。 + pub(super) fn import_action(language: crate::display::UiLanguage) -> Self { + Self { + id: SHELL_IMPORT_ACTION_ID.to_owned(), + name: language.pick("导入终端目录…", "Import terminal directory...").into(), + closed_image: None, + row_image: None, + } + } + + pub(super) fn is_import_action(&self) -> bool { + self.id == SHELL_IMPORT_ACTION_ID + } + + pub(super) fn view(&self, size: f32, image: Option<&Arc>) -> gpui::AnyElement { + let icon: gpui::AnyElement = if let Some(image) = image { + gpui::StyledImage::object_fit( + img(image.clone()).size(px(size)).flex_shrink_0(), + gpui::ObjectFit::Contain, + ) + .into_any_element() + } else if self.is_import_action() { + // 动作行与真实 shell 行必须一眼分得开:文件夹口 = 「去别处拿」。 + Icon::new(IconName::FolderOpen).xsmall().into_any_element() + } else { + Icon::new(IconName::SquareTerminal).xsmall().into_any_element() + }; + h_flex() + .gap_2() + .items_center() + .child(icon) + .child(div().flex_1().min_w_0().child(self.name.clone())) + .into_any_element() + } +} + +/// 默认 Shell 下拉的全部候选,以及应当选中的行号。 +/// +/// 顺序 = 置顶导入行 → 已安装 shell(`detect_shells` 菜单序)→ 用户导入的 +/// 终端 profile。导入项以 `profile:<家族>|` 作设置值([`Profile::settings_id`] +/// 同一形状),品牌图标因此仍能按家族查到。 +pub(super) fn shell_select_items( + current: &str, + scale_factor: f32, + language: crate::display::UiLanguage, +) -> (Vec, usize) { + let mut items: Vec = crate::shell_detect::detect_shells() + .into_iter() + .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), + ]; + } + // 导入的终端目录:`merge_terminal_profiles` 已把它们并进配置的 profile + // 列表,这里让设置页也能直接选为默认 Shell——否则导入完看不见结果。 + if let Ok(store) = crate::terminal_profiles::TerminalProfiles::load() { + for profile in store.as_config_profiles() { + let Some(id) = profile.settings_id() else { continue }; + if items.iter().any(|item| item.id == id) { + continue; + } + items.push(ShellSelectItem::new(id, profile.name, scale_factor)); + } + } + if !items.iter().any(|item| item.id == current) { + // 检测结果可能暂时找不到已保存的 WSL/profile id;先把它保留在首位, + // 用户仍可看到并重新选择,下一次检测恢复后不会丢失持久化值。 + items.insert( + 0, + ShellSelectItem::new( + current.to_owned(), + crate::shell_detect::display_name_for_id(current).to_owned(), + scale_factor, + ), + ); + } + let selected = items.iter().position(|item| item.id == current).unwrap_or(0); + // 导入行最后才插到首位:它不参与选中判定,所以选中行号整体后移一位。 + items.insert(0, ShellSelectItem::import_action(language)); + (items, selected + 1) +} + +/// 扫描目录并落盘(阻塞 IO,调用方须放后台执行器)。 +/// 逻辑与旧壳 `Display::import_terminal_directory` 逐句对齐,只是把 toast +/// 换成 `Result`,由 UI 线程决定怎么呈现。 +pub(super) fn import_terminal_directory_blocking( + directory: &std::path::Path, +) -> Result { + let found = crate::terminal_profiles::scan_directory(directory) + .map_err(|error| TerminalImportError::Scan(error.to_string()))?; + if found.is_empty() { + return Err(TerminalImportError::NoSupportedTerminal); + } + let mut profiles = crate::terminal_profiles::TerminalProfiles::load() + .map_err(|error| TerminalImportError::Load(error.to_string()))?; + let count = found.len(); + for profile in found { + profiles.upsert(profile).map_err(|error| TerminalImportError::Import(error.to_string()))?; + } + profiles.save().map_err(|error| TerminalImportError::Save(error.to_string()))?; + Ok(count) +} + +/// 原生模态对话框不能在 GPUI update 借用中运行:它自己的消息泵会重入 +/// wndproc,造成 AppCell 二次可变借用。与 SSH 私钥选择器一样,先在 UI +/// 线程捕获 HWND,再让专用线程运行旧壳的 IFileOpenDialog。 +#[cfg(windows)] +pub(super) fn pick_folder_with_wsl_places( + window: &Window, + title: &'static str, +) -> futures::channel::oneshot::Receiver> { + use winit::raw_window_handle::{HasWindowHandle, RawWindowHandle}; + + let owner = HasWindowHandle::window_handle(window) + .ok() + .and_then(|handle| match handle.as_raw() { + RawWindowHandle::Win32(handle) => Some(handle.hwnd.get() as usize), + _ => None, + }) + .unwrap_or(0); + let (tx, rx) = futures::channel::oneshot::channel(); + std::thread::spawn(move || { + let selected = crate::display::file_dialog::pick_folder_with_hwnd(owner as _, title); + let _ = tx.send(selected); + }); + rx +} + +impl SelectItem for ShellSelectItem { + type Value = String; + + fn title(&self) -> SharedString { + self.name.clone() + } + + fn display_title(&self) -> Option { + // 闭态留出 chevron 与上下内边距;20px 在 32px Select 内不会挤字。 + Some(self.view(20.0, self.closed_image.as_ref())) + } + + fn render(&self, _: &mut Window, _: &mut App) -> impl IntoElement { + // 旧壳 ShellPickerRow 的品牌图标是 24×24 逻辑像素。 + self.view(24.0, self.row_image.as_ref()) + } + + fn value(&self) -> &Self::Value { + &self.id + } + + fn matches(&self, query: &str) -> bool { + self.name.to_lowercase().contains(&query.to_lowercase()) + || self.id.to_lowercase().contains(&query.to_lowercase()) + } +} diff --git a/nebula_app/src/gpui_shell/settings_pane/status.rs b/nebula_app/src/gpui_shell/settings_pane/status.rs new file mode 100644 index 0000000..c28ba99 --- /dev/null +++ b/nebula_app/src/gpui_shell/settings_pane/status.rs @@ -0,0 +1,324 @@ +use crate::gpui_shell::ssh_settings::SshValidationError; + +#[derive(Clone, Debug, Default)] +pub(super) enum AboutUpdateState { + #[default] + Idle, + Checking, + UpToDate(String), + Available(String), + Failed(String), +} + +#[derive(Clone, Debug)] +pub(super) enum ProviderStatus { + Saved, + Added, + AtLeastOneRequired, + Deleted, + ApiKeySaved, + Testing, + TestResult { outcome: crate::provider_test::ProviderTestOutcome, elapsed_ms: u64 }, + CodexConfirmation, + AppliedToCodex(std::path::PathBuf), + Error(String), +} + +impl ProviderStatus { + pub(in crate::gpui_shell) fn is_error(&self) -> bool { + match self { + Self::AtLeastOneRequired | Self::Error(_) => true, + Self::TestResult { outcome, .. } => !outcome.is_success(), + _ => false, + } + } + + pub(in crate::gpui_shell) fn text(&self, language: crate::display::UiLanguage) -> String { + match self { + Self::Saved => language.pick("供应商配置已保存", "Provider settings saved").into(), + Self::Added => language.pick("已添加自定义供应商", "Custom provider added").into(), + Self::AtLeastOneRequired => { + language.pick("至少保留一个供应商", "Keep at least one provider").into() + }, + Self::Deleted => language + .pick("供应商及其凭据已删除", "Provider and its credentials deleted") + .into(), + Self::ApiKeySaved => language + .pick( + "API Key 已保存到系统凭据管理器", + "API key saved to the system credential manager", + ) + .into(), + Self::Testing => language.pick("正在测试连接…", "Testing connection...").into(), + Self::TestResult { outcome, elapsed_ms } => { + format!("{} · {elapsed_ms} ms", language.provider_test_message(outcome)) + }, + Self::CodexConfirmation => language + .pick( + "再次点击确认:API Key 将明文写入 Codex auth.json(原文件会备份)", + "Click again to confirm: the API key will be written in plain text to Codex auth.json (the original file will be backed up)", + ) + .into(), + Self::AppliedToCodex(path) => format!( + "{}: {}", + language.pick("已应用到 Codex", "Applied to Codex"), + path.display() + ), + Self::Error(error) => format!( + "{}: {error}", + language.pick("操作失败", "Operation failed") + ), + } + } +} +#[derive(Clone, Debug)] +pub(super) enum BackupCompletion { + Exported(std::path::PathBuf), + Restored, + Pushed(String), + Pulled(String), +} + +#[derive(Clone, Debug)] +pub(super) enum BackupStatus { + PassphraseTooShort, + SelectionRequired, + Processing, + RemoteConfigSaved, + CredentialEmpty, + CredentialUnsupported, + CredentialSaved, + Completed(BackupCompletion), + Error(String), +} + +impl BackupStatus { + pub(in crate::gpui_shell) fn is_error(&self) -> bool { + matches!( + self, + Self::PassphraseTooShort + | Self::SelectionRequired + | Self::CredentialEmpty + | Self::CredentialUnsupported + | Self::Error(_) + ) + } + + pub(in crate::gpui_shell) fn text(&self, language: crate::display::UiLanguage) -> String { + match self { + Self::PassphraseTooShort => language + .pick("备份密码至少 8 位", "The backup password must be at least 8 characters") + .into(), + Self::SelectionRequired => language + .pick("请至少勾选一个备份类别", "Select at least one backup category") + .into(), + Self::Processing => language.pick("处理中…", "Processing...").into(), + Self::RemoteConfigSaved => { + language.pick("远端配置已保存", "Remote configuration saved").into() + }, + Self::CredentialEmpty => { + language.pick("凭据不能为空", "Credentials cannot be empty").into() + }, + Self::CredentialUnsupported => language + .pick( + "当前协议不需要独立凭据", + "The current protocol does not use a separate credential", + ) + .into(), + Self::CredentialSaved => language + .pick( + "凭据已写入系统凭据管理器", + "Credential saved to the system credential manager", + ) + .into(), + Self::Completed(BackupCompletion::Exported(path)) => format!( + "{}: {}", + language.pick("已导出加密备份", "Encrypted backup exported"), + path.display() + ), + Self::Completed(BackupCompletion::Restored) => language + .pick( + "已从备份恢复(字体/托盘等部分设置重启后生效)", + "Backup restored (some settings, including fonts and tray options, apply after restart)", + ) + .into(), + Self::Completed(BackupCompletion::Pushed(location)) => { + format!("{} {location}", language.pick("已推送到", "Pushed to")) + }, + Self::Completed(BackupCompletion::Pulled(name)) => format!( + "{} {name} {}", + language.pick("已从", "Restored from"), + language.pick("恢复(部分设置重启后生效)", "(some settings apply after restart)") + ), + Self::Error(error) => format!( + "{}: {error}", + language.pick("备份操作失败", "Backup operation failed") + ), + } + } +} + +#[derive(Debug)] +pub(super) enum TerminalImportError { + Scan(String), + NoSupportedTerminal, + Load(String), + Import(String), + Save(String), +} + +impl TerminalImportError { + pub(super) fn text(self, language: crate::display::UiLanguage) -> String { + match self { + Self::Scan(error) => format!( + "{}: {error}", + language.pick("无法扫描终端目录", "Could not scan the terminal directory") + ), + Self::NoSupportedTerminal => language + .pick( + "目录中未找到受支持的终端程序", + "No supported terminal program was found in the directory", + ) + .into(), + Self::Load(error) => format!( + "{}: {error}", + language.pick("无法读取终端配置", "Could not read terminal profiles") + ), + Self::Import(error) => format!( + "{}: {error}", + language.pick("无法导入终端", "Could not import the terminal") + ), + Self::Save(error) => format!( + "{}: {error}", + language.pick("无法保存终端配置", "Could not save terminal profiles") + ), + } + } +} + +#[derive(Clone, Debug)] +pub(in crate::gpui_shell) enum SshStatus { + Saved(String), + Pinned, + Imported(usize), + Opening(String), + DeleteCommitted { hidden_config: bool }, + CleanupPartial(String), + Restored(String), + Validation(SshValidationError), + PersistFailed(String), + DeleteFailed(String), + UndoFailed(String), + TestStartFailed(String), + ProfileLoadFailed(String), + ProfileSaveFailed(String), + HostListSaveFailed(String), + CredentialSaveFailed(String), + SavedWithCleanupError { destination: String, error: String }, + Error(String), +} + +impl SshStatus { + pub(in crate::gpui_shell) fn is_error(&self) -> bool { + matches!( + self, + Self::CleanupPartial(_) + | Self::Validation(_) + | Self::PersistFailed(_) + | Self::DeleteFailed(_) + | Self::UndoFailed(_) + | Self::TestStartFailed(_) + | Self::ProfileLoadFailed(_) + | Self::ProfileSaveFailed(_) + | Self::HostListSaveFailed(_) + | Self::CredentialSaveFailed(_) + | Self::SavedWithCleanupError { .. } + | Self::Error(_) + ) + } + + pub(in crate::gpui_shell) fn text(&self, language: crate::display::UiLanguage) -> String { + match self { + Self::Saved(destination) => { + format!("{} {destination}", language.pick("已保存", "Saved")) + }, + Self::Pinned => language.pick("置顶状态已更新", "Pin status updated").into(), + Self::Imported(count) => format!( + "{} {count} {}", + language.pick("已导入,config 源共", "Imported"), + language.pick("个别名", "config aliases") + ), + Self::Opening(host) => { + format!("{} {host}…", language.pick("正在打开", "Opening")) + }, + Self::DeleteCommitted { hidden_config: true } => language + .pick( + "已隐藏 config 别名,并清理 Pebrel Profile 与凭据", + "Config alias hidden; Pebrel profile and credentials removed", + ) + .into(), + Self::DeleteCommitted { hidden_config: false } => language + .pick( + "已删除主机、Profile 与凭据", + "Host, profile, and credentials deleted", + ) + .into(), + Self::CleanupPartial(details) => format!( + "{}: {details}", + language.pick( + "主机已从列表移除,但部分清理失败", + "Host removed from the list, but some cleanup failed", + ) + ), + Self::Restored(host) => { + format!("{} {host}", language.pick("已恢复", "Restored")) + }, + Self::Validation(error) => error.text(language).into(), + Self::PersistFailed(error) => format!( + "{}: {error}", + language.pick("写入设置失败", "Failed to write settings") + ), + Self::DeleteFailed(error) => format!( + "{}: {error}", + language.pick("删除主机失败", "Failed to delete host") + ), + Self::UndoFailed(error) => { + format!("{}: {error}", language.pick("撤销失败", "Undo failed")) + }, + Self::TestStartFailed(error) => format!( + "{}: {error}", + language.pick("无法启动连接测试", "Could not start the connection test") + ), + Self::ProfileLoadFailed(error) => format!( + "{}: {error}", + language.pick("加载 SSH Profile 失败", "Failed to load the SSH profile") + ), + Self::ProfileSaveFailed(error) => format!( + "{}: {error}", + language.pick("保存 SSH Profile 失败", "Failed to save the SSH profile") + ), + Self::HostListSaveFailed(error) => format!( + "{}: {error}", + language.pick("保存主机列表失败", "Failed to save the host list") + ), + Self::CredentialSaveFailed(error) => format!( + "{}: {error}", + language.pick( + "Profile 已保存,但密码写入凭据管理器失败", + "The profile was saved, but the password could not be written to the credential manager", + ) + ), + Self::SavedWithCleanupError { destination, error } => format!( + "{} {destination}, {}: {error}", + language.pick("已保存", "Saved"), + language.pick( + "但旧地址凭据清理失败", + "but credentials for the previous address could not be removed", + ) + ), + Self::Error(error) => { + format!("{}: {error}", language.pick("SSH 操作失败", "SSH operation failed")) + }, + } + } +} diff --git a/nebula_app/src/gpui_shell/settings_pane/tests.rs b/nebula_app/src/gpui_shell/settings_pane/tests.rs index e55070b..26895c5 100644 --- a/nebula_app/src/gpui_shell/settings_pane/tests.rs +++ b/nebula_app/src/gpui_shell/settings_pane/tests.rs @@ -22,17 +22,14 @@ fn settings_nav_visibility_keeps_stable_routes_but_hides_two_entries() { } #[test] -fn settings_nav_preserves_the_group_expansion_order_without_headings() { +fn settings_nav_starts_with_application_then_frequent_options() { let visible: Vec<_> = visible_nav_sections().collect(); assert_eq!(visible, vec![0, 1, 2, 6, 7, 4, 5, 8]); let zh_labels: Vec<_> = visible .iter() .map(|index| section_label(*index, crate::display::UiLanguage::ZhCn)) .collect(); - assert_eq!( - zh_labels, - vec!["应用", "外观", "配置文件", "交互", "按键映射", "SSH", "网络", "高级"] - ); + assert_eq!(zh_labels, vec!["应用", "外观", "终端", "交互", "按键映射", "SSH", "网络", "高级"]); let en_labels: Vec<_> = visible .iter() .map(|index| section_label(*index, crate::display::UiLanguage::EnUs)) @@ -42,7 +39,7 @@ fn settings_nav_preserves_the_group_expansion_order_without_headings() { vec![ "Application", "Appearance", - "Profiles", + "Terminal", "Interaction", "Key Bindings", "SSH", @@ -55,14 +52,14 @@ fn settings_nav_preserves_the_group_expansion_order_without_headings() { #[test] fn localized_select_labels_keep_stable_value_cardinality() { let cases: &[(&str, &[&str])] = &[ - ("language", &["system", "zh-CN", "en-US"]), + ("language", nebula_settings::LanguagePref::VALUES), ("cursor_shape", &["beam", "underline", "block", "hollow"]), ("tabs_position", &["sidebar", "top"]), ("bell", &["off", "visual", "sound", "both"]), ]; for (key, values) in cases { - for language in [crate::display::UiLanguage::ZhCn, crate::display::UiLanguage::EnUs] { - assert_eq!(localized_select_labels(key, values, language).len(), values.len()); + for language in crate::display::UiLanguage::ALL { + assert_eq!(localized_select_labels(key, values, *language).len(), values.len()); } } assert_eq!( @@ -71,6 +68,18 @@ fn localized_select_labels_keep_stable_value_cardinality() { ); } +#[test] +fn language_picker_uses_native_names_and_translated_system_option() { + let labels = localized_select_labels( + "language", + nebula_settings::LanguagePref::VALUES, + crate::display::UiLanguage::FrFr, + ); + assert_eq!(labels[0], SharedString::from("Suivre le système")); + assert!(labels.contains(&SharedString::from("Français"))); + assert!(labels.contains(&SharedString::from("日本語"))); +} + #[test] fn cached_semantic_statuses_render_in_the_current_language() { let provider = ProviderStatus::Saved; diff --git a/nebula_app/src/gpui_shell/ssh_settings.rs b/nebula_app/src/gpui_shell/ssh_settings.rs index ea0afb0..92fa954 100644 --- a/nebula_app/src/gpui_shell/ssh_settings.rs +++ b/nebula_app/src/gpui_shell/ssh_settings.rs @@ -27,16 +27,58 @@ use crate::gpui_shell::prelude::*; use crate::gpui_shell::settings_pane::{SettingsPane, SettingsPaneEvent, SshStatus}; use crate::gpui_shell::widgets::NebulaButton; -mod editor; mod advanced; +mod editor; /// 删除撤销窗口时长,旧壳 Undo 条同值。 const SSH_DELETE_UNDO_SECS: u64 = 8; -const SSH_EDITOR_CTL_H: f32 = 36.0; +const SSH_EDITOR_CTL_H: f32 = 39.0; const SSH_EDITOR_AVATAR_H: f32 = 44.0; const SSH_HOST_ROW_H: f32 = 58.0; const SSH_HOST_GAP: f32 = 8.0; +const COMMON_SSH_USERNAMES: &[&str] = &["root", "ubuntu", "deploy", "admin", "debian", "ec2-user"]; + +fn username_candidates(recent: &[String], query: &str) -> Vec { + let query = query.trim().to_ascii_lowercase(); + let mut candidates = Vec::new(); + for username in recent.iter().map(String::as_str).chain(COMMON_SSH_USERNAMES.iter().copied()) { + let username = username.trim(); + if !username.is_empty() + && (query.is_empty() || username.to_ascii_lowercase().contains(&query)) + && !candidates.iter().any(|existing| existing == username) + { + candidates.push(username.to_owned()); + } + } + candidates.truncate(8); + candidates +} + +#[cfg(test)] +mod username_tests { + use super::*; + + #[test] + fn common_usernames_are_available_without_history() { + let candidates = username_candidates(&[], ""); + assert_eq!(&candidates[..4], &["root", "ubuntu", "deploy", "admin"]); + } + + #[test] + fn recent_usernames_are_kept_first_and_deduplicated() { + let recent = vec!["operator".to_owned(), "root".to_owned(), "operator".to_owned()]; + let candidates = username_candidates(&recent, ""); + assert_eq!(&candidates[..2], &["operator", "root"]); + assert_eq!(candidates.iter().filter(|username| *username == "root").count(), 1); + } + + #[test] + fn unmatched_manual_input_has_no_forced_suggestion() { + assert!(username_candidates(&[], "Custom-User").is_empty()); + assert_eq!(username_candidates(&[], "DEP"), vec!["deploy"]); + } +} fn push_username_suggestion(suggestions: &mut Vec, username: &str) { let username = username.trim(); @@ -258,7 +300,8 @@ impl SettingsPane { let profile_path = crate::display::nebula_data_dir().join("ssh_profiles.json"); match crate::ssh_profiles::SshProfiles::load(&profile_path) { Ok(mut profiles) => { - let proxy_credential = profiles.for_destination(&host).connection.proxy_credential_target(&host); + let proxy_credential = + profiles.for_destination(&host).connection.proxy_credential_target(&host); profiles.remove(&host); if let Err(error) = profiles.save(&profile_path) { cleanup_errors.push(format!("Profile: {error}")); @@ -338,7 +381,10 @@ impl SettingsPane { let mut editor = SshEditorState::new(self.ssh_editor_seq, destination); editor.username_suggestions = profiles.usernames(); let labels = profiles.labels(); - editor.jump_choices = self.ssh_hosts.merged().into_iter() + editor.jump_choices = self + .ssh_hosts + .merged() + .into_iter() .filter(|host| editor.original_destination.as_deref() != Some(host)) .map(|host| { let label = labels.get(&host).cloned().unwrap_or_else(|| host.clone()); @@ -358,17 +404,24 @@ impl SettingsPane { editor.original_connection = profile.connection.clone(); } let connection = &editor.connection; - self.ssh_proxy_host_input.update(cx, |input, cx| input.set_value(connection.proxy_host.clone(), window, cx)); + self.ssh_proxy_host_input + .update(cx, |input, cx| input.set_value(connection.proxy_host.clone(), window, cx)); self.ssh_proxy_port_input.update(cx, |input, cx| { - input.set_value(connection.proxy_port.map(|port| port.to_string()).unwrap_or_default(), window, cx); + input.set_value( + connection.proxy_port.map(|port| port.to_string()).unwrap_or_default(), + window, + cx, + ); input.set_placeholder(connection.effective_proxy_port().to_string(), window, cx); }); - self.ssh_proxy_username_input.update(cx, |input, cx| input.set_value(connection.proxy_username.clone(), window, cx)); + self.ssh_proxy_username_input + .update(cx, |input, cx| input.set_value(connection.proxy_username.clone(), window, cx)); self.ssh_proxy_password_input.update(cx, |input, cx| { input.set_value("", window, cx); input.set_masked(true, window, cx); }); - self.ssh_jump_host_input.update(cx, |input, cx| input.set_value(connection.jump_host.clone(), window, cx)); + self.ssh_jump_host_input + .update(cx, |input, cx| input.set_value(connection.jump_host.clone(), window, cx)); let label = profile.and_then(|profile| profile.label).unwrap_or_default(); self.ssh_username_input.update(cx, |input, cx| input.set_value(username, window, cx)); self.ssh_destination_input.update(cx, |input, cx| input.set_value(address, window, cx)); @@ -378,11 +431,15 @@ impl SettingsPane { self.ssh_password_input.update(cx, |input, cx| { input.set_value("", window, cx); let language = crate::gpui_shell::config::ui_language(cx); - input.set_placeholder(if adding { - language.pick("留空则连接时询问", "Leave empty to ask when connecting") - } else { - language.pick("留空则保留原有凭据", "Leave empty to keep existing credentials") - }, window, cx); + input.set_placeholder( + if adding { + language.pick("留空则连接时询问", "Leave empty to ask when connecting") + } else { + language.pick("留空则保留原有凭据", "Leave empty to keep existing credentials") + }, + window, + cx, + ); }); // 图标选择器每次开编辑器都从收起、无搜索词开始:上一台主机的搜索词 // 留在框里,下一台打开时列表看着像被莫名筛过。 @@ -510,14 +567,15 @@ impl SettingsPane { return; }, }; - let (proxy_password, password) = match self.ssh_test_passwords(&destination, &connection, cx) { - Ok(passwords) => passwords, - Err(error) => { - self.ssh_status = Some(SshStatus::Error(error.to_string())); - cx.notify(); - return; - }, - }; + let (proxy_password, password) = + match self.ssh_test_passwords(&destination, &connection, cx) { + Ok(passwords) => passwords, + Err(error) => { + self.ssh_status = Some(SshStatus::Error(error.to_string())); + cx.notify(); + return; + }, + }; let Some(editor) = self.ssh_editor.as_mut() else { return }; if editor.testing() { return; @@ -671,8 +729,12 @@ impl SettingsPane { cx.notify(); return; } - let mut password = zeroize::Zeroizing::new(self.ssh_password_input.read(cx).value().to_string()); - if password.is_empty() && editor.save_password && crate::display::auth_sections(editor.auth).0 { + let mut password = + zeroize::Zeroizing::new(self.ssh_password_input.read(cx).value().to_string()); + if password.is_empty() + && editor.save_password + && crate::display::auth_sections(editor.auth).0 + { if let Some(original) = original.as_deref().filter(|old| *old != destination) { match crate::ssh_credentials::load_stored_password(original) { Ok(Some(secret)) => { @@ -680,7 +742,8 @@ impl SettingsPane { match std::str::from_utf8(&secret) { Ok(secret) => *password = secret.to_owned(), Err(error) => { - self.ssh_status = Some(SshStatus::CredentialSaveFailed(error.to_string())); + self.ssh_status = + Some(SshStatus::CredentialSaveFailed(error.to_string())); self.ssh_editor = Some(editor); cx.notify(); return; @@ -745,7 +808,7 @@ impl SettingsPane { // ---- UI:可编辑用户名历史 ---- - fn ssh_username_control(&self, cx: &mut Context) -> gpui::AnyElement { + fn ssh_username_control(&self, window: &Window, cx: &mut Context) -> gpui::AnyElement { let pane = cx.entity().downgrade(); let open = self.ssh_username_picker_open; let language = crate::gpui_shell::config::ui_language(cx); @@ -756,18 +819,14 @@ impl SettingsPane { // 否则聚焦刚把候选打开,同一次点击又会被遮罩立即关闭。 .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) .child( - Input::new(&self.ssh_username_input) - .h(px(SSH_EDITOR_CTL_H)) - .text_sm() - .aria_label(language.pick("用户名", "Username")) - .bg(cx.theme().popover) + editor::editor_input(&self.ssh_username_input, language.pick("用户名", "Username"), window, cx) .suffix(Button::new("ssh-username-history") .icon(if open { IconName::ChevronUp } else { IconName::ChevronDown }) .ghost() .xsmall() .tooltip(crate::gpui_shell::config::ui_language(cx).pick( - "最近使用的用户名", - "Recently used usernames", + "选择常用或最近使用的用户名", + "Choose a common or recent username", )) .on_click(cx.listener(|this, _, window, cx| { this.toggle_ssh_username_picker(window, cx); @@ -823,18 +882,17 @@ impl SettingsPane { let theme = cx.theme(); let query = self.ssh_username_input.read(cx).value().trim().to_ascii_lowercase(); let current = self.ssh_username_input.read(cx).value().trim().to_owned(); - let suggestions = self.ssh_editor.as_ref()?.username_suggestions.clone(); + let suggestions = + username_candidates(&self.ssh_editor.as_ref()?.username_suggestions, &query); let rows: Vec = suggestions .into_iter() - .filter(|username| query.is_empty() || username.to_ascii_lowercase().contains(&query)) - .take(8) .enumerate() .map(|(index, username)| { let selected = username == current; let picked = username.clone(); h_flex() .id(SharedString::from(format!("ssh-username-row-{index}"))) - .h(px(30.0)) + .h(px(34.0)) .w_full() .px_2() .items_center() @@ -869,11 +927,14 @@ impl SettingsPane { .items_center() .text_xs() .text_color(theme.muted_foreground) - .child(language.pick("没有匹配的历史用户名", "No matching usernames")) + .child(language.pick( + "没有匹配项,可直接输入用户名", + "No matches. Enter a username directly.", + )) .into_any_element() } else { v_flex() - .h(px((row_count as f32 * 30.0).min(240.0))) + .h(px((row_count as f32 * 34.0).min(272.0))) .overflow_y_scrollbar() .children(rows) .into_any_element() @@ -924,19 +985,42 @@ impl SettingsPane { .justify_center() .cursor_pointer() .hover(|avatar| avatar.bg(theme.list_hover)) + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) // Nerd Font 的 advance 固定为 0.6em,墨迹却能宽到 1.2em;直接 // 居中文本盒会把溢出的那一半全留在右边。固定墨迹槽后,视觉中心 // 才不会随图标变化。 .child( div() .w(px(target_ink_width)) - .font_family(self.current_font_chain(cx)) + .font_family(crate::font_install::REQUIRED_FONT_FAMILY) .text_size(px(icon_size)) .child(icon.glyph.to_string()), ) .on_click(cx.listener(|this, _, window, cx| { this.toggle_ssh_icon_picker(window, cx); })) + .child( + div() + .id("ssh-icon-avatar-caret") + .absolute() + .right(px(-4.0)) + .bottom(px(-4.0)) + .size(px(15.0)) + .rounded_full() + .border_1() + .border_color(theme.border) + .bg(theme.popover) + .flex() + .items_center() + .justify_center() + .cursor_pointer() + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .on_click(cx.listener(|this, _, window, cx| { + cx.stop_propagation(); + this.toggle_ssh_icon_picker(window, cx); + })) + .child(Icon::new(IconName::ChevronDown).size(px(11.0)).text_color(theme.muted_foreground)), + ) // 与字体目录同法:零绘制 canvas 捕获头像的真实窗口坐标,弹层 // 据此锚定;滚动与 DPI 变化后依然贴着头像。 .child( @@ -1003,7 +1087,7 @@ impl SettingsPane { let muted = theme.muted_foreground; let hover_bg = theme.list_hover; let selected_bg = theme.list_active; - let font_chain = self.current_font_chain(cx); + let font_chain: SharedString = crate::font_install::REQUIRED_FONT_FAMILY.into(); let current = self .ssh_editor .as_ref() @@ -1184,6 +1268,8 @@ impl SettingsPane { .px_3() .items_center() .gap_3() + .when(ix == 0, |row| row.rounded_t(px(8.0))) + .when(ix + 1 == host_count, |row| row.rounded_b(px(8.0))) .when(ix + 1 < host_count, |row| { row.border_b_1().border_color(theme.border.opacity(0.5)) }) @@ -1201,20 +1287,7 @@ impl SettingsPane { .text_size(px(18.0)) .text_color(muted) .text_center() - .child(os_icon.glyph.to_string()) - // 旧壳把置顶记号压在图标槽右缘,不额外占一列;否则 - // 置顶行的主机标题会比其它行整体右移。 - .when(pinned, |slot| { - slot.child( - div() - .absolute() - .right(px(-2.0)) - .bottom(px(7.0)) - .text_size(px(8.0)) - .text_color(theme.primary) - .child("\u{eab4}"), - ) - }), + .child(os_icon.glyph.to_string()), ) .child( v_flex() @@ -1288,15 +1361,17 @@ impl SettingsPane { ) .child( Button::new(SharedString::from(format!("ssh-pin-{ix}"))) - .icon(if pinned { IconName::StarOff } else { IconName::Star }) + .icon(Icon::default().path(crate::gpui_shell::assets::nav::PIN)) .ghost() .small() + .selected(pinned) + .toggled(pinned) .tooltip(if pinned { language.pick("取消置顶", "Unpin") } else { language.pick("置顶", "Pin") }) - .invisible() + .when(!pinned, |button| button.invisible()) .group_hover(row_group.clone(), |button| button.visible()) .on_click(cx.listener(move |this, _, _, cx| { this.ssh_apply( diff --git a/nebula_app/src/gpui_shell/ssh_settings/advanced.rs b/nebula_app/src/gpui_shell/ssh_settings/advanced.rs index 2134898..09ab000 100644 --- a/nebula_app/src/gpui_shell/ssh_settings/advanced.rs +++ b/nebula_app/src/gpui_shell/ssh_settings/advanced.rs @@ -287,7 +287,7 @@ impl SettingsPane { (!password.is_empty()).then(|| password.to_string()) } - pub(super) fn ssh_editor_advanced(&self, cx: &mut Context) -> gpui::Div { + pub(super) fn ssh_editor_advanced(&self, window: &Window, cx: &mut Context) -> gpui::Div { let language = crate::gpui_shell::config::ui_language(cx); let editor = self.ssh_editor.as_ref().expect("open SSH editor"); let theme = cx.theme(); @@ -405,22 +405,22 @@ impl SettingsPane { h_flex().mt_4().gap_3().items_start() .child(div().flex_1().min_w_0().child(editor_field( language.pick("代理地址", "Proxy host"), - editor_input(&self.ssh_proxy_host_input, language.pick("代理地址", "Proxy host"), cx), + editor_input(&self.ssh_proxy_host_input, language.pick("代理地址", "Proxy host"), window, cx), ))) .child(div().w(px(84.0)).flex_shrink_0().child(editor_field( language.pick("端口", "Port"), - editor_input(&self.ssh_proxy_port_input, language.pick("代理端口", "Proxy port"), cx), + editor_input(&self.ssh_proxy_port_input, language.pick("代理端口", "Proxy port"), window, cx), ))), ) .child( h_flex().mt_4().gap_3().items_start() .child(div().flex_1().min_w_0().child(editor_field( language.pick("用户名(可选)", "Username (optional)"), - editor_input(&self.ssh_proxy_username_input, language.pick("代理用户名", "Proxy username"), cx), + editor_input(&self.ssh_proxy_username_input, language.pick("代理用户名", "Proxy username"), window, cx), ))) .child(div().flex_1().min_w_0().child(editor_field( language.pick("密码(可选)", "Password (optional)"), - editor_input(&self.ssh_proxy_password_input, language.pick("代理密码", "Proxy password"), cx) + editor_input(&self.ssh_proxy_password_input, language.pick("代理密码", "Proxy password"), window, cx) .mask_toggle().disabled(editor.clear_proxy_password), ))), ) @@ -475,7 +475,7 @@ impl SettingsPane { })), ), ) - .child(editor_input(&self.ssh_jump_host_input, language.pick("跳板主机", "Jump host"), cx)) + .child(editor_input(&self.ssh_jump_host_input, language.pick("跳板主机", "Jump host"), window, cx)) .when(editor.jump_picker_open, |section| { let choices = editor.jump_choices.iter().filter(|(host, _)| host != &destination).collect::>(); if choices.is_empty() { diff --git a/nebula_app/src/gpui_shell/ssh_settings/editor.rs b/nebula_app/src/gpui_shell/ssh_settings/editor.rs index 334a2a4..3ef49d6 100644 --- a/nebula_app/src/gpui_shell/ssh_settings/editor.rs +++ b/nebula_app/src/gpui_shell/ssh_settings/editor.rs @@ -1,15 +1,35 @@ use super::*; +use gpui::Focusable as _; const EDITOR_WIDTH: f32 = 512.0; -const EDITOR_HEIGHT: f32 = 660.0; +const EDITOR_HEIGHT: f32 = 720.0; const EDITOR_PADDING: f32 = 28.0; pub(super) fn editor_input( state: &Entity, label: &'static str, + window: &Window, cx: &gpui::App, ) -> Input { - Input::new(state).h(px(SSH_EDITOR_CTL_H)).text_sm().bg(cx.theme().popover).aria_label(label) + let theme = cx.theme(); + let focused = state.read(cx).focus_handle(cx).is_focused(window); + gpui::Styled::h(Input::new(state), px(SSH_EDITOR_CTL_H)) + .text_size(px(13.0)) + .px(px(12.0)) + .rounded(px(7.0)) + .bg(theme.popover) + .focus_bordered(false) + .border_color(if focused { theme.ring.opacity(0.8) } else { theme.input }) + .when(focused, |input| { + input.shadow(vec![gpui::BoxShadow { + inset: false, + color: theme.ring.opacity(0.13), + offset: gpui::point(px(0.0), px(0.0)), + blur_radius: px(0.0), + spread_radius: px(3.0), + }]) + }) + .aria_label(label) } pub(super) fn editor_field(label: &'static str, control: impl IntoElement) -> gpui::Div { @@ -32,6 +52,7 @@ pub(super) fn editor_hint(text: impl Into, cx: &gpui::App) -> gpui impl SettingsPane { pub(in crate::gpui_shell) fn ssh_editor_modal( &mut self, + window: &Window, cx: &mut Context, ) -> Option { let language = crate::gpui_shell::config::ui_language(cx); @@ -40,8 +61,11 @@ impl SettingsPane { let avatar = self.ssh_avatar(icon, cx); let icon_popup = self.ssh_icon_popup(cx); let username_popup = self.ssh_username_popup(cx); - let content = - if editor.advanced { self.ssh_editor_advanced(cx) } else { self.ssh_editor_basic(cx) }; + let content = if editor.advanced { + self.ssh_editor_advanced(window, cx) + } else { + self.ssh_editor_basic(window, cx) + }; let theme = cx.theme(); let title = if editor.original_destination.is_some() { language.pick("编辑 SSH 主机", "Edit SSH host") @@ -136,7 +160,7 @@ impl SettingsPane { .max_w(gpui::relative(1.0)) .max_h(gpui::relative(1.0)) .flex_none() - .rounded(px(12.0)) + .rounded(px(13.0)) .border_1() .border_color(theme.border.opacity(0.7)) .bg(theme.popover) @@ -145,7 +169,7 @@ impl SettingsPane { .overflow_hidden() .child( h_flex() - .h(px(84.0)) + .h(px(94.0)) .flex_shrink_0() .px(px(EDITOR_PADDING)) .gap_3() @@ -156,7 +180,12 @@ impl SettingsPane { .flex_1() .min_w_0() .gap_1() - .child(div().text_lg().font_semibold().child(title)) + .child( + div() + .text_size(px(19.0)) + .font_semibold() + .child(title), + ) .child(editor_hint( language.pick( "配置连接信息,随时快速访问。", @@ -243,14 +272,15 @@ impl SettingsPane { ) .child( h_flex() - .h(px(64.0)) + .h(px(71.0)) .flex_shrink_0() .px(px(EDITOR_PADDING)) .items_center() .justify_between() .border_t_1() .border_color(theme.border) - .bg(theme.group_box) + .bg(theme.muted) + .rounded_b(px(13.0)) .child( Button::new("ssh-editor-test") .debug_selector(|| "ssh-editor-test".to_owned()) @@ -274,7 +304,7 @@ impl SettingsPane { .label(language.pick("取消", "Cancel")) .ghost() .small() - .h(px(SSH_EDITOR_CTL_H)) + .h(px(35.0)) .on_click(cx.listener( |this, _, window, cx| { this.close_ssh_editor(window, cx) @@ -289,7 +319,7 @@ impl SettingsPane { .label(language.pick("保存", "Save")) .primary() .small() - .h(px(SSH_EDITOR_CTL_H)) + .h(px(35.0)) .min_w(px(74.0)) .on_click(cx.listener( |this, _, window, cx| { @@ -307,10 +337,10 @@ impl SettingsPane { ) } - fn ssh_editor_basic(&self, cx: &mut Context) -> gpui::Div { + fn ssh_editor_basic(&self, window: &Window, cx: &mut Context) -> gpui::Div { let language = crate::gpui_shell::config::ui_language(cx); - let username = self.ssh_username_control(cx); - let authentication = self.ssh_editor_authentication(cx); + let username = self.ssh_username_control(window, cx); + let authentication = self.ssh_editor_authentication(window, cx); let theme = cx.theme(); let validation = self.ssh_status.as_ref().and_then(|status| match status { SshStatus::Validation(error) => Some(*error), @@ -338,6 +368,7 @@ impl SettingsPane { .child(editor_input( &self.ssh_label_input, language.pick("主机名称", "Host name"), + window, cx, )), ) @@ -352,6 +383,7 @@ impl SettingsPane { editor_input( &self.ssh_destination_input, language.pick("主机地址", "Host address"), + window, cx, ) .when(address_error, |input| input.border_color(theme.danger)), @@ -360,8 +392,13 @@ impl SettingsPane { .child( div().w(px(84.0)).flex_shrink_0().child(editor_field( language.pick("端口", "Port"), - editor_input(&self.ssh_port_input, language.pick("端口", "Port"), cx) - .when(port_error, |input| input.border_color(theme.danger)), + editor_input( + &self.ssh_port_input, + language.pick("端口", "Port"), + window, + cx, + ) + .when(port_error, |input| input.border_color(theme.danger)), )), ), ) @@ -385,7 +422,7 @@ impl SettingsPane { ) } - fn ssh_editor_authentication(&self, cx: &mut Context) -> gpui::Div { + fn ssh_editor_authentication(&self, window: &Window, cx: &mut Context) -> gpui::Div { use crate::ssh_profiles::SshAuthMode; let language = crate::gpui_shell::config::ui_language(cx); @@ -409,7 +446,8 @@ impl SettingsPane { .small() .flex_1() .min_w_0() - .h(px(30.0)) + .h(px(31.0)) + .rounded(gpui_component::button::ButtonRounded::Size(px(5.0))) .px_1() .toggled(editor.auth == mode) .when(editor.auth == mode, |button| { @@ -431,8 +469,8 @@ impl SettingsPane { .mt_2() .p(px(3.0)) .gap(px(3.0)) - .rounded_lg() - .bg(theme.input) + .rounded(px(8.0)) + .bg(theme.muted) .children(controls), ) .when(shows_password, |body| { @@ -442,6 +480,7 @@ impl SettingsPane { editor_input( &self.ssh_password_input, language.pick("密码", "Password"), + window, cx, ) .mask_toggle(), diff --git a/nebula_app/src/gpui_shell/terminal/completion_viewport.rs b/nebula_app/src/gpui_shell/terminal/completion_viewport.rs new file mode 100644 index 0000000..c5deac3 --- /dev/null +++ b/nebula_app/src/gpui_shell/terminal/completion_viewport.rs @@ -0,0 +1,200 @@ +#[derive(Default)] +pub(super) struct CompletionViewport { + pub offset: usize, + pub hovered: Option, + pub scrollbar_grab: Option, + query: Option, + wheel_remainder: f32, + pointer: Option<(f32, f32)>, +} + +impl CompletionViewport { + pub fn clear(&mut self) { + self.offset = 0; + self.hovered = None; + self.scrollbar_grab = None; + self.wheel_remainder = 0.0; + self.query = None; + } + + pub fn update_query(&mut self, query: &str, count: usize) { + if self.query.as_deref() != Some(query) || count == 0 { + self.clear(); + self.query = Some(query.to_owned()); + } + } + + pub fn hover(&mut self, position: (f32, f32), index: Option) -> bool { + if self.pointer.replace(position) == Some(position) { + return false; + } + let changed = self.hovered != index; + self.hovered = index; + changed + } + + pub fn reveal(&mut self, selected: Option, count: usize, rows: usize) { + self.hovered = None; + let rows = rows.max(1); + let Some(selected) = selected.filter(|index| *index < count) else { return }; + if selected < self.offset { + self.offset = selected; + } else if selected >= self.offset.saturating_add(rows) { + self.offset = selected + 1 - rows; + } + self.offset = self.offset.min(count.saturating_sub(rows)); + } + + pub fn scroll_to(&mut self, offset: usize, count: usize, rows: usize) -> bool { + let next = offset.min(count.saturating_sub(rows)); + let changed = self.offset != next || self.hovered.is_some(); + self.offset = next; + self.hovered = None; + changed + } + + pub fn drag_scrollbar( + &mut self, + pointer_offset: f32, + travel: f32, + count: usize, + rows: usize, + released: bool, + ) -> bool { + let Some(grab) = self.scrollbar_grab else { return false }; + let progress = ((pointer_offset - grab) / travel.max(1.0)).clamp(0.0, 1.0); + let offset = (progress * count.saturating_sub(rows) as f32).round() as usize; + self.scroll_to(offset, count, rows); + if released { + self.scrollbar_grab = None; + } + true + } + + pub fn scroll(&mut self, delta: f32, row_height: f32, count: usize, rows: usize) -> bool { + if !delta.is_finite() || !row_height.is_finite() || row_height <= 0.0 { + return false; + } + self.wheel_remainder -= delta; + let movement = (self.wheel_remainder / row_height).trunc() as isize; + self.wheel_remainder -= movement as f32 * row_height; + let current = self.offset.min(count.saturating_sub(rows)); + self.scroll_to(current.saturating_add_signed(movement), count, rows) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wheel_scrolls_the_viewport_in_native_direction_without_wrapping() { + let mut viewport = CompletionViewport::default(); + assert!(viewport.scroll(-84.0, 28.0, 30, 8)); + assert_eq!(viewport.offset, 3); + viewport.scroll(28.0, 28.0, 30, 8); + assert_eq!(viewport.offset, 2); + viewport.scroll(-10000.0, 28.0, 30, 8); + assert_eq!(viewport.offset, 22); + viewport.scroll(10000.0, 28.0, 30, 8); + assert_eq!(viewport.offset, 0); + } + + #[test] + fn precise_wheel_deltas_accumulate_instead_of_selecting_candidates() { + let mut viewport = CompletionViewport::default(); + viewport.scroll(-10.0, 28.0, 30, 8); + viewport.scroll(-10.0, 28.0, 30, 8); + assert_eq!(viewport.offset, 0); + viewport.scroll(-10.0, 28.0, 30, 8); + assert_eq!(viewport.offset, 1); + } + + #[test] + fn keyboard_navigation_only_scrolls_when_selection_leaves_the_viewport() { + let mut viewport = CompletionViewport::default(); + viewport.scroll_to(8, 30, 8); + viewport.reveal(Some(10), 30, 8); + assert_eq!(viewport.offset, 8); + viewport.reveal(Some(18), 30, 8); + assert_eq!(viewport.offset, 11); + viewport.reveal(Some(2), 30, 8); + assert_eq!(viewport.offset, 2); + } + + #[test] + fn stationary_pointer_cannot_reclaim_hover_after_keyboard_or_wheel_input() { + let mut viewport = CompletionViewport::default(); + viewport.hover((10.0, 20.0), Some(3)); + viewport.reveal(Some(0), 30, 8); + assert!(!viewport.hover((10.0, 20.0), Some(3))); + assert_eq!(viewport.hovered, None); + viewport.hover((10.0, 21.0), Some(3)); + viewport.scroll(-28.0, 28.0, 30, 8); + assert!(!viewport.hover((10.0, 21.0), Some(4))); + assert_eq!(viewport.hovered, None); + } + + #[test] + fn query_changes_reset_scroll_but_repainting_the_same_query_does_not() { + let mut viewport = CompletionViewport::default(); + viewport.update_query("git", 30); + viewport.scroll_to(10, 30, 8); + viewport.update_query("git", 30); + assert_eq!(viewport.offset, 10); + viewport.update_query("git s", 20); + assert_eq!(viewport.offset, 0); + assert_eq!(viewport.hovered, None); + } + + #[test] + fn scrollbar_drag_tracks_outside_positions_and_applies_the_final_release() { + let mut viewport = CompletionViewport::default(); + viewport.scrollbar_grab = Some(7.0); + assert!(viewport.drag_scrollbar(42.0, 140.0, 30, 8, false)); + assert_eq!(viewport.offset, 6); + assert!(viewport.drag_scrollbar(-200.0, 140.0, 30, 8, false)); + assert_eq!(viewport.offset, 0); + assert!(viewport.drag_scrollbar(1000.0, 140.0, 30, 8, true)); + assert_eq!(viewport.offset, 22); + assert_eq!(viewport.scrollbar_grab, None); + assert!(!viewport.drag_scrollbar(-200.0, 140.0, 30, 8, false)); + assert_eq!(viewport.offset, 22); + } + + #[test] + fn releasing_a_drag_outside_preserves_the_keyboard_accept_target() { + use crate::display::{NebulaCompletionItem, NebulaCompletionKind, NebulaPaneState}; + + let mut state = NebulaPaneState::default(); + state.completion_items = (0..30) + .map(|index| NebulaCompletionItem { + label: format!("candidate-{index}"), + insert: index.to_string(), + kind: NebulaCompletionKind::Command, + }) + .collect(); + state.completion_selected = Some(3); + let mut viewport = CompletionViewport::default(); + viewport.scrollbar_grab = Some(7.0); + viewport.hovered = Some(4); + viewport.drag_scrollbar(42.0, 140.0, state.completion_items.len(), 8, false); + viewport.drag_scrollbar(1000.0, 140.0, state.completion_items.len(), 8, true); + + assert_eq!(viewport.offset, 22); + assert_eq!(viewport.hovered, None); + assert_eq!(viewport.scrollbar_grab, None); + assert_eq!(state.completion_selected, Some(3)); + assert_eq!(super::super::suggest::popup_take(&mut state).as_deref(), Some("3")); + } + + #[test] + fn cleared_viewport_cannot_capture_another_panes_drag() { + let mut viewport = CompletionViewport::default(); + assert!(!viewport.drag_scrollbar(100.0, 140.0, 30, 8, false)); + viewport.scrollbar_grab = Some(7.0); + viewport.clear(); + assert!(!viewport.drag_scrollbar(100.0, 140.0, 30, 8, true)); + assert_eq!(viewport.offset, 0); + } +} diff --git a/nebula_app/src/gpui_shell/terminal/confirmation.rs b/nebula_app/src/gpui_shell/terminal/confirmation.rs new file mode 100644 index 0000000..a8c1905 --- /dev/null +++ b/nebula_app/src/gpui_shell/terminal/confirmation.rs @@ -0,0 +1,393 @@ +use std::sync::OnceLock; +use std::sync::atomic::{AtomicU64, Ordering}; + +use nebula_terminal::term::TermMode; +use regex::Regex; + +#[derive(Clone)] +pub(crate) struct BinaryConfirmation { + pub id: u64, + pub question: String, +} + +#[derive(Clone, PartialEq, Eq)] +struct Fingerprint { + context: Vec, + program: String, + session_id: Option, + mode: TermMode, +} + +struct Pending { + public: BinaryConfirmation, + fingerprint: Fingerprint, + allow: String, + deny: String, +} + +#[derive(Default)] +pub(super) struct ConfirmationState { + pending: Option, + consumed: Option, + provider_request: Option, +} + +fn describe( + screen: &str, + program: &str, + session_id: Option<&str>, + mode: TermMode, + waiting: bool, +) -> Option<(Fingerprint, String, String, String)> { + if !waiting || program.is_empty() || mode.contains(TermMode::REPORT_ALL_KEYS_AS_ESC) { + return None; + } + let context: Vec = screen + .lines() + .rev() + .filter(|line| !line.trim().is_empty()) + .take(8) + .map(|line| line.trim_end().to_owned()) + .collect(); + let line = context.first()?.trim(); + if line.chars().count() > 280 { + return None; + } + static PATTERN: OnceLock = OnceLock::new(); + let pattern = PATTERN.get_or_init(|| { + Regex::new(r"(?i)^(?P.+?)\s*(?P\(|\[)\s*(?Py(?:es)?)\s*/\s*(?Pn(?:o)?)\s*(?P\)|\])\s*[:?]?\s*$") + .expect("valid binary confirmation pattern") + }); + let captures = pattern.captures(line)?; + if !matches!((&captures["open"], &captures["close"]), ("(", ")") | ("[", "]")) { + return None; + } + let question = captures["question"].trim().to_owned(); + let allow = captures["yes"].to_owned(); + let deny = captures["no"].to_owned(); + Some(( + Fingerprint { + context, + program: program.to_owned(), + session_id: session_id.map(str::to_owned), + mode, + }, + question, + allow, + deny, + )) +} + +impl ConfirmationState { + pub fn observe_waiting(&mut self, waiting: bool) { + if !waiting { + self.pending = None; + self.consumed = None; + self.provider_request = None; + } + } + + pub fn observe_input(&mut self, bytes: &[u8]) { + if !bytes.is_empty() { + self.invalidate(); + } + } + + pub fn set_provider_request(&mut self, request: Option<&str>) { + if let Some(request) = request + && self.provider_request.as_deref() != Some(request) + { + self.pending = None; + self.consumed = None; + self.provider_request = Some(request.to_owned()); + } + } + + pub fn invalidate(&mut self) { + if let Some(pending) = self.pending.take() { + self.consumed = Some(pending.fingerprint); + } + } + + pub fn capture( + &mut self, + screen: &str, + program: &str, + session_id: Option<&str>, + mode: TermMode, + waiting: bool, + ) -> Option { + let Some((fingerprint, question, allow, deny)) = + describe(screen, program, session_id, mode, waiting) + else { + self.invalidate(); + return None; + }; + if self.consumed.as_ref() == Some(&fingerprint) { + return None; + } + if let Some(pending) = &self.pending + && pending.fingerprint == fingerprint + { + return Some(pending.public.clone()); + } + static NEXT: AtomicU64 = AtomicU64::new(1); + let public = BinaryConfirmation { id: NEXT.fetch_add(1, Ordering::Relaxed), question }; + self.pending = Some(Pending { public: public.clone(), fingerprint, allow, deny }); + Some(public) + } + + pub fn answer( + &mut self, + request_id: u64, + allow: bool, + screen: &str, + program: &str, + session_id: Option<&str>, + mode: TermMode, + waiting: bool, + ) -> Option> { + if self.pending.as_ref()?.public.id != request_id { + return None; + } + let pending = self.pending.take()?; + self.consumed = Some(pending.fingerprint.clone()); + let (current, _, _, _) = describe(screen, program, session_id, mode, waiting)?; + if current != pending.fingerprint { + return None; + } + let mut reply = if allow { pending.allow } else { pending.deny }.into_bytes(); + reply.push(b'\r'); + Some(reply) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const PROMPT: &str = "Fixture action\nAllow this operation? [Y/n]"; + + #[test] + fn supported_binary_prompts_remain_waiting_in_the_screen_watchdog() { + for suffix in ["[Y/n]", "(yes/no)", "[ yes / no ]", "(Y/N):"] { + let screen = format!("\u{276f}\nAllow fixture operation? {suffix}"); + for program in ["claude", "codex", "pi"] { + let detection = crate::ai_agents::detect(program, &screen).unwrap(); + assert_eq!(detection.status, crate::ai_agents::AgentStatus::Blocked); + let mut state = ConfirmationState::default(); + assert!(state.capture(&screen, program, None, TermMode::empty(), true).is_some()); + } + } + let screen = "Documentation mentions (yes/no) answers\n\u{276f}"; + assert_eq!( + crate::ai_agents::detect("claude", screen).unwrap().status, + crate::ai_agents::AgentStatus::Idle + ); + } + + #[test] + fn explicit_binary_choices_preserve_the_displayed_reply() { + for (allow, expected) in [(true, b"Y\r".as_slice()), (false, b"n\r".as_slice())] { + let mut state = ConfirmationState::default(); + let question = + state.capture(PROMPT, "claude", Some("one"), TermMode::empty(), true).unwrap(); + assert_eq!(question.question, "Allow this operation?"); + assert_eq!( + state + .answer( + question.id, + allow, + PROMPT, + "claude", + Some("one"), + TermMode::empty(), + true + ) + .as_deref(), + Some(expected) + ); + assert!( + state + .answer( + question.id, + allow, + PROMPT, + "claude", + Some("one"), + TermMode::empty(), + true + ) + .is_none() + ); + assert!( + state.capture(PROMPT, "claude", Some("one"), TermMode::empty(), true).is_none() + ); + } + } + + #[test] + fn changed_prompts_sessions_and_readiness_cannot_receive_old_answers() { + for (screen, session, mode, waiting) in [ + ("shell> ", Some("one"), TermMode::empty(), false), + ("Different action\nAllow this operation? [Y/n]", Some("one"), TermMode::empty(), true), + (PROMPT, Some("two"), TermMode::empty(), true), + (PROMPT, Some("one"), TermMode::REPORT_ALL_KEYS_AS_ESC, true), + ] { + let mut state = ConfirmationState::default(); + let question = + state.capture(PROMPT, "claude", Some("one"), TermMode::empty(), true).unwrap(); + assert!( + state.answer(question.id, true, screen, "claude", session, mode, waiting).is_none() + ); + } + } + + #[test] + fn stale_buttons_do_not_consume_a_new_request() { + let mut state = ConfirmationState::default(); + let old = state.capture(PROMPT, "claude", None, TermMode::empty(), true).unwrap(); + let prompt = "Second action\nContinue? (yes/no)"; + let current = state.capture(prompt, "claude", None, TermMode::empty(), true).unwrap(); + assert_ne!(old.id, current.id); + assert!( + state.answer(old.id, true, prompt, "claude", None, TermMode::empty(), true).is_none() + ); + assert_eq!( + state + .answer(current.id, false, prompt, "claude", None, TermMode::empty(), true) + .as_deref(), + Some(b"no\r".as_slice()) + ); + } + + #[test] + fn ordinary_input_invalidates_pending_buttons() { + let mut state = ConfirmationState::default(); + let question = state.capture(PROMPT, "claude", None, TermMode::empty(), true).unwrap(); + state.observe_input(b"\r"); + assert!( + state + .answer(question.id, true, PROMPT, "claude", None, TermMode::empty(), true) + .is_none() + ); + assert!(state.capture(PROMPT, "claude", None, TermMode::empty(), true).is_none()); + } + + #[test] + fn empty_input_does_not_invalidate_pending_buttons() { + let mut state = ConfirmationState::default(); + let question = state.capture(PROMPT, "claude", None, TermMode::empty(), true).unwrap(); + state.observe_input(b""); + assert!( + state + .answer(question.id, true, PROMPT, "claude", None, TermMode::empty(), true) + .is_some() + ); + } + + #[test] + fn observed_work_then_wait_starts_a_new_same_screen_request_without_provider_ids() { + for answered in [false, true] { + let mut state = ConfirmationState::default(); + state.set_provider_request(None); + state.observe_waiting(true); + let first = + state.capture(PROMPT, "claude", Some("session"), TermMode::empty(), true).unwrap(); + if answered { + assert!( + state + .answer( + first.id, + true, + PROMPT, + "claude", + Some("session"), + TermMode::empty(), + true + ) + .is_some() + ); + } + state.observe_waiting(false); + state.observe_waiting(true); + state.set_provider_request(None); + let second = + state.capture(PROMPT, "claude", Some("session"), TermMode::empty(), true).unwrap(); + assert_ne!(first.id, second.id); + assert!( + state + .answer( + first.id, + true, + PROMPT, + "claude", + Some("session"), + TermMode::empty(), + true + ) + .is_none() + ); + assert!( + state + .answer( + second.id, + false, + PROMPT, + "claude", + Some("session"), + TermMode::empty(), + true + ) + .is_some() + ); + } + } + + #[test] + fn local_input_does_not_rearm_an_unchanged_screen_before_observed_progress() { + let mut state = ConfirmationState::default(); + let first = state.capture(PROMPT, "claude", None, TermMode::empty(), true).unwrap(); + state.observe_input(b"yes\r"); + assert!(state.capture(PROMPT, "claude", None, TermMode::empty(), false).is_none()); + state.observe_waiting(true); + assert!(state.capture(PROMPT, "claude", None, TermMode::empty(), true).is_none()); + assert!( + state.answer(first.id, true, PROMPT, "claude", None, TermMode::empty(), true).is_none() + ); + state.observe_waiting(false); + assert!(state.capture(PROMPT, "claude", None, TermMode::empty(), true).is_some()); + } + + #[test] + fn distinct_provider_requests_can_repeat_the_same_question() { + let mut state = ConfirmationState::default(); + state.set_provider_request(Some("request-1")); + let first = state.capture(PROMPT, "claude", None, TermMode::empty(), true).unwrap(); + assert!( + state.answer(first.id, true, PROMPT, "claude", None, TermMode::empty(), true).is_some() + ); + state.set_provider_request(Some("request-2")); + let second = state.capture(PROMPT, "claude", None, TermMode::empty(), true).unwrap(); + assert_ne!(first.id, second.id); + assert!( + state.answer(first.id, true, PROMPT, "claude", None, TermMode::empty(), true).is_none() + ); + assert!( + state + .answer(second.id, false, PROMPT, "claude", None, TermMode::empty(), true) + .is_some() + ); + } + + #[test] + fn unknown_or_nonbinary_questions_do_not_get_reply_buttons() { + let mut state = ConfirmationState::default(); + for prompt in + ["Proceed?", "Allow always / allow once / deny", "Continue? [y/n)", "Question? [a/b]"] + { + assert!(state.capture(prompt, "claude", None, TermMode::empty(), true).is_none()); + } + assert!(state.capture(PROMPT, "claude", None, TermMode::empty(), false).is_none()); + } +} diff --git a/nebula_app/src/gpui_shell/terminal/element.rs b/nebula_app/src/gpui_shell/terminal/element.rs index 369df79..9e0f0e3 100644 --- a/nebula_app/src/gpui_shell/terminal/element.rs +++ b/nebula_app/src/gpui_shell/terminal/element.rs @@ -15,9 +15,10 @@ use std::collections::HashSet; use gpui::{ - App, Bounds, ContentMask, Corners, CursorStyle, Element, ElementId, GlobalElementId, Hitbox, - HitboxBehavior, Hsla, InspectorElementId, LayoutId, PathBuilder, Pixels, Rgba, SharedString, - Style, TextRun, UnderlineStyle, Window, fill, outline, point, px, relative, size, + App, Bounds, ContentMask, Corners, CursorStyle, DispatchPhase, Element, ElementId, + GlobalElementId, Hitbox, HitboxBehavior, Hsla, InspectorElementId, LayoutId, MouseButton, + MouseMoveEvent, MouseUpEvent, PathBuilder, Pixels, Rgba, SharedString, Style, TextRun, + UnderlineStyle, Window, fill, outline, point, px, relative, size, }; use gpui_component::ActiveTheme as _; use nebula_terminal::grid::Dimensions as _; @@ -38,6 +39,35 @@ impl TerminalElement { pub fn new(view: gpui::Entity) -> Self { Self { view } } + + fn capture_completion_scrollbar_drag(&self, window: &mut Window, cx: &App) { + if self.view.read(cx).completion_viewport.scrollbar_grab.is_none() { + return; + } + let view = self.view.downgrade(); + window.on_mouse_event(move |event: &MouseMoveEvent, phase, _, cx| { + if phase == DispatchPhase::Capture + && view + .update(cx, |view, cx| view.move_completion_popup_scrollbar(event, cx)) + .unwrap_or(false) + { + cx.stop_propagation(); + } + }); + let view = self.view.downgrade(); + window.on_mouse_event(move |event: &MouseUpEvent, phase, _, cx| { + if phase == DispatchPhase::Capture + && event.button == MouseButton::Left + && view + .update(cx, |view, cx| { + view.finish_completion_popup_scrollbar(event.position, cx) + }) + .unwrap_or(false) + { + cx.stop_propagation(); + } + }); + } } pub struct TermLayout { @@ -215,6 +245,7 @@ impl Element for TerminalElement { window: &mut Window, cx: &mut App, ) { + self.capture_completion_scrollbar_drag(window, cx); let theme = self.view.read(cx).palette.clone(); // 元素不画底色:卡容器统一负责"卡底色(带窗口透明度)→ 壁纸" // 两层(旧壳 draw_window_backdrop 同模型),元素只画单元格背景、 @@ -646,19 +677,21 @@ impl Element for TerminalElement { // 内联 ghost 余量与弹窗补全:结果由 view 在本帧 render 时算好 // (引擎与旧壳共享),元素只负责画。IME 组合中让位给 preedit, // 与旧壳同一抑制条件;窗口失焦不灭(对齐旧壳 draw_pane)。 - let (ghost_text, popup_items, popup_selected) = { + let (ghost_text, popup_items, popup_selected, popup_offset, popup_hovered) = { let view = self.view.read(cx); let ime_active = view.marked_text.as_deref().is_some_and(|m| !m.is_empty()); let same_frame = view.suggest_anchor == Some((cursor.row as usize, cursor.col as usize)); if ime_active || !same_frame { - (None, Vec::new(), None) + (None, Vec::new(), None, 0, None) } else { ( (!view.suggest.suggestion.is_empty()) .then(|| view.suggest.suggestion.clone()), view.suggest.completion_items.clone(), view.suggest.completion_selected, + view.completion_viewport.offset, + view.completion_viewport.hovered, ) } }; @@ -690,6 +723,8 @@ impl Element for TerminalElement { cx, &popup_items, popup_selected, + popup_offset, + popup_hovered, cursor.row as usize, cursor_visual_col, layout, @@ -1083,7 +1118,7 @@ impl CompletionPopupLayout { ) } - fn panel_bounds(self, origin: gpui::Point) -> Bounds { + pub(super) fn panel_bounds(self, origin: gpui::Point) -> Bounds { let content = self.content_bounds(origin); let pad = px(COMPLETION_PANEL_PAD); Bounds::new( @@ -1099,12 +1134,37 @@ impl CompletionPopupLayout { size(content.size.width, self.row_height), ) } + + pub(super) fn scrollbar_bounds( + self, + origin: gpui::Point, + count: usize, + ) -> Option<(Bounds, Bounds)> { + if count <= self.rows { + return None; + } + let content = self.content_bounds(origin); + let track_height = content.size.height - px(4.0); + let track = Bounds::new( + point(content.right() + px(1.0), content.origin.y + px(2.0)), + size(px(2.0), track_height), + ); + let thumb_height = + (track_height * (self.rows as f32 / count as f32)).max(px(14.0)).min(track_height); + let progress = self.offset.min(count - self.rows) as f32 / (count - self.rows) as f32; + let thumb = Bounds::new( + point(track.origin.x, track.origin.y + (track_height - thumb_height) * progress), + size(track.size.width, thumb_height), + ); + Some((track, thumb)) + } } #[allow(clippy::too_many_arguments)] pub(super) fn completion_popup_layout( items: &[crate::display::NebulaCompletionItem], selected: Option, + offset: usize, cursor_row: usize, cursor_col: usize, screen_lines: usize, @@ -1168,7 +1228,7 @@ pub(super) fn completion_popup_layout( let panel_x = desired_panel_x.clamp(0.0, max_panel_x); let selected = selected.filter(|index| *index < items.len()); - let offset = selected.filter(|index| *index >= rows).map(|index| index + 1 - rows).unwrap_or(0); + let offset = offset.min(items.len().saturating_sub(rows)); Some(CompletionPopupLayout { content_x: px(panel_x + COMPLETION_PANEL_PAD), content_y: px(panel_y + COMPLETION_PANEL_PAD), @@ -1316,6 +1376,8 @@ fn paint_completion_popup( cx: &mut App, items: &[crate::display::NebulaCompletionItem], selected: Option, + offset: usize, + hovered: Option, cursor_row: usize, cursor_col: usize, layout: &TermLayout, @@ -1327,6 +1389,7 @@ fn paint_completion_popup( let Some(popup) = completion_popup_layout( items, selected, + offset, cursor_row, cursor_col, layout.rows, @@ -1368,13 +1431,18 @@ fn paint_completion_popup( for (row, item) in visible.iter().enumerate() { let row_bounds = popup.row_bounds(bounds.origin, row); let is_selected = Some(popup.offset + row) == popup.selected; + let is_hovered = Some(popup.offset + row) == hovered; let visual_kind = completion_visual_kind(item); let semantic = completion_kind_color(colors, visual_kind); - let row_radius = if is_selected { px(4.0) } else { px(0.0) }; - window.paint_quad( - fill(row_bounds, if is_selected { colors.selected_bg } else { colors.row_bg }) - .corner_radii(row_radius), - ); + let row_radius = if is_selected || is_hovered { px(4.0) } else { px(0.0) }; + let background = if is_selected { + colors.selected_bg + } else if is_hovered { + colors.selected_bg.opacity(0.45) + } else { + colors.row_bg + }; + window.paint_quad(fill(row_bounds, background).corner_radii(row_radius)); if is_selected { window.paint_quad(fill( Bounds::new(row_bounds.origin, size(px(2.0), row_bounds.size.height)), @@ -1439,28 +1507,9 @@ fn paint_completion_popup( ); } - if items.len() > popup.rows { - // 滚动条覆在面板右侧 4px 呼吸边内,不侵入固定 46px 标签列。 - let content = popup.content_bounds(bounds.origin); - let track_h = content.size.height - px(4.0); - let track_top = content.origin.y + px(2.0); - let content_right = content.origin.x + content.size.width; - let track_bounds = - Bounds::new(point(content_right + px(1.0), track_top), size(px(2.0), track_h)); - window.paint_quad(fill(track_bounds, colors.scroll_track).corner_radii(px(1.0))); - - let visible_ratio = popup.rows as f32 / items.len() as f32; - let thumb_h = (track_h * visible_ratio).max(px(14.0)).min(track_h); - let max_offset = items.len() - popup.rows; - let progress = popup.offset.min(max_offset) as f32 / max_offset as f32; - let thumb_top = track_top + (track_h - thumb_h) * progress; - window.paint_quad( - fill( - Bounds::new(point(content_right + px(1.0), thumb_top), size(px(2.0), thumb_h)), - colors.scroll_thumb, - ) - .corner_radii(px(1.0)), - ); + if let Some((track, thumb)) = popup.scrollbar_bounds(bounds.origin, items.len()) { + window.paint_quad(fill(track, colors.scroll_track).corner_radii(px(1.0))); + window.paint_quad(fill(thumb, colors.scroll_thumb).corner_radii(px(1.0))); } } @@ -1643,9 +1692,18 @@ mod tests { kind: NebulaCompletionKind::Command, }]; - let popup = - completion_popup_layout(&items, Some(0), 2, 7, 24, 80, gpui::px(8.0), gpui::px(21.0)) - .expect("短命令的精确候选也必须形成可见面板"); + let popup = completion_popup_layout( + &items, + Some(0), + 0, + 2, + 7, + 24, + 80, + gpui::px(8.0), + gpui::px(21.0), + ) + .expect("短命令的精确候选也必须形成可见面板"); assert_eq!(popup.rows, 1); assert!(popup.label_width >= 3); } @@ -1659,12 +1717,44 @@ mod tests { }]; let popup = - completion_popup_layout(&items, None, 2, 7, 24, 80, gpui::px(8.0), gpui::px(21.0)) + completion_popup_layout(&items, None, 0, 2, 7, 24, 80, gpui::px(8.0), gpui::px(21.0)) .expect("空选中态仍应显示候选面板"); assert_eq!(popup.selected, None); assert_eq!(popup.offset, 0); } + #[test] + fn scrolling_is_independent_of_selection_and_popup_position() { + let items = (0..30) + .map(|index| NebulaCompletionItem { + label: format!("command-{index}"), + insert: format!("{index}"), + kind: NebulaCompletionKind::Command, + }) + .collect::>(); + for cursor_row in [2, 22] { + let popup = completion_popup_layout( + &items, + Some(0), + 10, + cursor_row, + 7, + 24, + 80, + gpui::px(8.0), + gpui::px(21.0), + ) + .unwrap(); + assert_eq!(popup.offset, 10); + assert_eq!(popup.selected, Some(0)); + let (track, thumb) = popup + .scrollbar_bounds(gpui::point(gpui::px(0.0), gpui::px(0.0)), items.len()) + .unwrap(); + assert!(thumb.origin.y >= track.origin.y); + assert!(thumb.bottom() <= track.bottom()); + } + } + #[test] fn popup_flips_above_the_cursor_near_the_bottom_edge() { let items = [NebulaCompletionItem { @@ -1678,6 +1768,7 @@ mod tests { let popup = completion_popup_layout( &items, None, + 0, cursor_row, 7, 24, diff --git a/nebula_app/src/gpui_shell/terminal/keymap.rs b/nebula_app/src/gpui_shell/terminal/keymap.rs index f2cc8fd..7c924fa 100644 --- a/nebula_app/src/gpui_shell/terminal/keymap.rs +++ b/nebula_app/src/gpui_shell/terminal/keymap.rs @@ -3,6 +3,9 @@ //! 只编码控制键与带修饰组合;普通可打印字符(含中文 IME 提交文本)走 //! `EntityInputHandler::replace_text_in_range`,避免同一按键被编码两次。 +#[cfg(windows)] +mod win32; + use gpui::Keystroke; use nebula_terminal::term::TermMode; @@ -87,162 +90,7 @@ fn ctrl_char(c: char) -> Option { /// 一旦子进程要过 kitty 键盘标志,那份标志就是线上合同,压过 DECSET 9001 /// (口径逐字同旧壳 `input::terminal_input::use_win32_input_mode`)。 fn use_win32_input_mode(mode: &TermMode) -> bool { - mode.contains(TermMode::WIN32_INPUT_MODE) && !mode.intersects(TermMode::KITTY_KEYBOARD_PROTOCOL) -} - -/// 无修饰的字母/数字/空格必须交给 IME / `TranslateMessage`,不能编进 PTY。 -/// -/// GPUI 的 Windows 后端:`on_key_down` 一旦 `stop_propagation`,就不会再 -/// `TranslateMessage`。IME 组字(微软拼音)是 TranslateMessage 喂进去的; -/// 把 `n`/`i` 编成 KEY_EVENT_RECORD 等于把拼音当英文写进 shell,中文永远 -/// 起不来。旧壳对应合同是 `keyboard.rs`:`ime.preedit()` 期间直接 return。 -#[cfg(windows)] -fn win32_encodes_keystroke(ks: &Keystroke) -> bool { - if ks.modifiers.control || ks.modifiers.alt || ks.modifiers.platform { - return true; - } - let key = ks.key.as_str(); - if key == "space" { - return false; - } - let mut chars = key.chars(); - match (chars.next(), chars.next()) { - (Some(c), None) if c.is_ascii_alphanumeric() => false, - _ => true, - } -} - -/// GPUI 键名 → Win32 虚拟键码。 -/// -/// 旧壳从 winit fork 的 `RawKeyEventInfo` 直接拿到系统报的 VK;GPUI 的 -/// `Keystroke` 只有键名,所以这里按名字反查。表只覆盖**编码器会处理的键** -/// (控制键、方向、功能键)——可打印字符在 GPUI 走 IME 管道,不经这里。 -#[cfg(windows)] -fn virtual_key_of(key: &str) -> Option { - use windows_sys::Win32::UI::Input::KeyboardAndMouse::{ - VK_BACK, VK_DELETE, VK_DOWN, VK_END, VK_ESCAPE, VK_F1, VK_HOME, VK_INSERT, VK_LEFT, - VK_NEXT, VK_PRIOR, VK_RETURN, VK_RIGHT, VK_SPACE, VK_TAB, VK_UP, - }; - - let vk = match key { - "escape" => VK_ESCAPE, - "enter" => VK_RETURN, - "tab" => VK_TAB, - "backspace" => VK_BACK, - "space" => VK_SPACE, - "up" => VK_UP, - "down" => VK_DOWN, - "left" => VK_LEFT, - "right" => VK_RIGHT, - "home" => VK_HOME, - "end" => VK_END, - "insert" => VK_INSERT, - "delete" => VK_DELETE, - "pageup" => VK_PRIOR, - "pagedown" => VK_NEXT, - // F1..F24 在 VK 表里连号。 - key if key.starts_with('f') => { - let index: u16 = key[1..].parse().ok()?; - if !(1..=24).contains(&index) { - return None; - } - VK_F1 + index - 1 - }, - // 单个字符(Ctrl+C 一族):ASCII 字母数字的 VK 就是它的大写码点。 - key => { - let mut chars = key.chars(); - let (Some(c), None) = (chars.next(), chars.next()) else { return None }; - if !c.is_ascii_alphanumeric() { - return None; - } - c.to_ascii_uppercase() as u16 - }, - }; - Some(vk) -} - -/// 控制键必须携带真实 `KEY_EVENT_RECORD` 的字符值(Esc=0x1B、Enter=0x0D、 -/// Tab=0x09、Backspace=0x08):OpenConsole 1.22 的 VT 翻译层会丢弃 uChar=0 -/// 的 VK_ESCAPE,于是读字节流的那类应用(Claude Code)收不到 Esc。修饰键与 -/// 功能键保持 0,与真实键盘一致。逐条同旧壳 `control_char_fallback`。 -#[cfg(windows)] -fn unicode_char_of(ks: &Keystroke) -> u16 { - // 平台已经判出文本的(含 Ctrl 变体)以它为准,与 WM_CHAR 语义一致。 - // `key_char` 若是 NUL,当作没文本:真实键盘的 Esc 不会写出 U+0000。 - if let Some(text) = ks.key_char.as_deref() { - let mut units = text.encode_utf16(); - if let (Some(first), None) = (units.next(), units.next()) { - if first != 0 { - return first; - } - } - } - match ks.key.as_str() { - "escape" => 0x1b, - "enter" => b'\r' as u16, - "tab" => b'\t' as u16, - // 真实控制台的 Ctrl+Backspace 记录携带 Uc=DEL(0x7f,WM_CHAR 语义), - // PSReadLine 的原生 Ctrl+Backspace=BackwardKillWord 绑定按此匹配。 - // 给 0x08 会跌进 KeyChar 分发被当成 Ctrl+H——只删一个字符。 - "backspace" => { - if ks.modifiers.control { - 0x7f - } else { - 0x08 - } - }, - "space" => b' ' as u16, - _ => 0, - } -} - -/// 一条 ConPTY Win32 input 记录:`CSI Vk;Sc;Uc;Kd;Cs;Rc_`。 -/// -/// 认不出 VK 的键返回 `None`,调用方回落到传统 VT 编码——宁可少一条记录, -/// 也不要编一个 Vk=0 的假记录,那会让子进程读到一个不存在的键。 -#[cfg(windows)] -fn win32_input_record(ks: &Keystroke, key_down: bool) -> Option> { - use windows_sys::Win32::UI::Input::KeyboardAndMouse::{MAPVK_VK_TO_VSC, MapVirtualKeyW}; - - const SHIFT_PRESSED: u32 = 0x0010; - const LEFT_ALT_PRESSED: u32 = 0x0002; - const LEFT_CTRL_PRESSED: u32 = 0x0008; - - let vk = virtual_key_of(ks.key.as_str())?; - // 扫描码问系统要,不硬编码:非 US 布局与笔记本键盘上这张表并不通用。 - let scan_code = unsafe { MapVirtualKeyW(u32::from(vk), MAPVK_VK_TO_VSC) }; - let mut control_key_state = 0u32; - if ks.modifiers.shift { - control_key_state |= SHIFT_PRESSED; - } - if ks.modifiers.alt { - control_key_state |= LEFT_ALT_PRESSED; - } - if ks.modifiers.control { - control_key_state |= LEFT_CTRL_PRESSED; - } - let key_down = u8::from(key_down); - Some( - format!( - "\x1b[{};{};{};{key_down};{};1_", - vk, - scan_code, - unicode_char_of(ks), - control_key_state - ) - .into_bytes(), - ) -} - -/// GPUI 的终端视图只接到 key-down。旧壳对 9001 会再写一条 Kd=0 的抬起 -/// (`keyboard.rs` 的 `key_release` + `escape_carries_its_control_character_both_directions`)。 -/// 真实键盘也是 down+up:Codex 按 VK 看按下就够了,Claude Code / Ink 吃的是 -/// OpenConsole 翻译出的字节流,缺抬起时 Esc 常常一个字节都到不了。 -#[cfg(windows)] -fn win32_press_and_release(ks: &Keystroke) -> Option> { - let mut sequence = win32_input_record(ks, true)?; - sequence.extend(win32_input_record(ks, false)?); - Some(sequence) + crate::input::terminal_input::use_win32_input_mode(*mode) } /// 子进程是否请求过 kitty 键盘协议(三位标志任一)。kitty 是线上合同, @@ -265,18 +113,125 @@ fn kitty_escape(ks: &Keystroke, mode: &TermMode) -> Option> { Some(if param == 1 { b"\x1b[27u".to_vec() } else { format!("\x1b[27;{param}u").into_bytes() }) } +/// 修饰回车由对端编辑器解释,不能当成普通 shell 提交。传统 VT 无法区分 +/// Shift/Ctrl+Enter,仍沿用回车提交;Win32 和 kitty 则保留其修饰信息。 +pub(super) fn preserves_enter_modifiers(ks: &Keystroke, mode: &TermMode) -> bool { + let mods = &ks.modifiers; + let modified = mods.shift || mods.control || mods.alt; + ks.key == "enter" + && ((kitty_keyboard_active(mode) && (modified || mods.platform)) + || (cfg!(windows) && use_win32_input_mode(mode) && modified)) +} + +/// Opt-in diagnostics for Enter only; printable input and prompt text are never logged. +pub(super) fn trace_enter(ks: &Keystroke, mode: &TermMode, bytes: &[u8]) { + if ks.key != "enter" { + return; + } + static ENABLED: std::sync::OnceLock = std::sync::OnceLock::new(); + if !*ENABLED.get_or_init(|| std::env::var("NEBULA_TRACE_ENTER").is_ok_and(|value| value == "1")) + { + return; + } + let mods = &ks.modifiers; + log::info!( + target: "nebula::input", + "Enter shift={} ctrl={} alt={} super={} mode={mode:?} bytes={bytes:02x?}", + mods.shift, mods.control, mods.alt, mods.platform, + ); +} + +fn kitty_sequence(ks: &Keystroke, mode: &TermMode) -> Option> { + use crate::input::terminal_input::{KeyInput, build_sequence}; + use winit::event::ElementState; + use winit::keyboard::{Key, KeyLocation, ModifiersState, NamedKey}; + + let (logical_key, key_without_modifiers) = if ks.key == "enter" { + // 只有编码全部按键时,裸 Enter 才改用 CSI u。其余 kitty 模式下 + // 保留 CR;带修饰的 Enter 复用共享编码器,覆盖 Shift/Ctrl/Alt。 + if !kitty_keyboard_active(mode) + || !(preserves_enter_modifiers(ks, mode) + || mode.contains(TermMode::REPORT_ALL_KEYS_AS_ESC)) + { + return None; + } + (Key::Named(NamedKey::Enter), Key::Named(NamedKey::Enter)) + } else { + if !mode.intersects(TermMode::DISAMBIGUATE_ESC_CODES | TermMode::REPORT_ALL_KEYS_AS_ESC) + || !(ks.modifiers.control || ks.modifiers.alt) + { + return None; + } + let base = if ks.key == "space" { + ' ' + } else { + let mut characters = ks.key.chars(); + match (characters.next(), characters.next()) { + (Some(character), None) if character.is_ascii_graphic() => { + character.to_ascii_lowercase() + }, + _ => return None, + } + }; + let character = if ks.modifiers.shift { + ks.key_char + .as_deref() + .filter(|text| text.len() == 1 && text.as_bytes()[0].is_ascii_graphic()) + .and_then(|text| text.chars().next()) + .unwrap_or_else(|| base.to_ascii_uppercase()) + } else { + base + }; + (Key::Character(character.to_string().into()), Key::Character(base.to_string().into())) + }; + let input = KeyInput { + logical_key, + state: ElementState::Pressed, + location: KeyLocation::Standard, + repeat: false, + key_without_modifiers, + text_with_all_modifiers: None, + #[cfg(windows)] + raw: winit::platform::windows::RawKeyEventInfo { + virtual_key: 0, + scan_code: 0, + repeat_count: 1, + is_extended: false, + unicode_char: 0, + control_key_state: 0, + }, + }; + let mut modifiers = ModifiersState::empty(); + modifiers.set(ModifiersState::SHIFT, ks.modifiers.shift); + modifiers.set(ModifiersState::ALT, ks.modifiers.alt); + modifiers.set(ModifiersState::CONTROL, ks.modifiers.control); + modifiers.set(ModifiersState::SUPER, ks.modifiers.platform); + Some(build_sequence(&input, modifiers, *mode)) +} + /// 返回 `None` 表示这次按键不由编码器处理(交给 IME/文本输入路径)。 pub fn encode(ks: &Keystroke, mode: &TermMode) -> Option> { let mods = &ks.modifiers; + #[cfg(windows)] + if mods.control + && mods.alt + && ks + .key_char + .as_deref() + .is_some_and(|text| !text.is_empty() && !text.chars().any(char::is_control)) + { + return None; + } + // ConPTY 是带 Win32 input mode 标志创建的(见 `tty::windows::conpty`), // 子进程一发 DECSET 9001 就切到这份记录格式。旧壳在 `build_sequence` // 开头做同样的分流;GPUI 此前一直只发传统 VT,于是裸 `\x1b` 要由 // OpenConsole 的翻译层反译成 KEY_EVENT_RECORD,而它会丢掉 uChar=0 的 // VK_ESCAPE——读字节流的应用(Claude Code)因此收不到 Esc。 #[cfg(windows)] - if use_win32_input_mode(mode) && win32_encodes_keystroke(ks) { - if let Some(record) = win32_press_and_release(ks) { + if use_win32_input_mode(mode) && win32::win32_encodes_keystroke(ks) { + if let Some(record) = win32::win32_press_and_release(ks) { return Some(record); } } @@ -284,6 +239,9 @@ pub fn encode(ks: &Keystroke, mode: &TermMode) -> Option> { if let Some(bytes) = kitty_escape(ks, mode) { return Some(bytes); } + if let Some(bytes) = kitty_sequence(ks, mode) { + return Some(bytes); + } match ks.key.as_str() { "enter" => { @@ -451,10 +409,10 @@ mod tests { #[cfg(windows)] #[test] fn unknown_keys_fall_back_instead_of_forging_a_record() { - assert_eq!(virtual_key_of("f99"), None); - assert_eq!(virtual_key_of("capslock"), None); + assert_eq!(win32::virtual_key_of("f99"), None); + assert_eq!(win32::virtual_key_of("capslock"), None); // 方向键在 win32 模式下仍要出记录(它们有确定的 VK)。 - assert!(win32_input_record(&keystroke("up"), true).is_some()); + assert!(win32::win32_input_record(&keystroke("up"), true).is_some()); } /// 9001 记录路径与真实控制台同构:Ctrl+Backspace 的记录 Uc=DEL(0x7f), @@ -466,7 +424,7 @@ mod tests { let mut ks = keystroke("backspace"); ks.modifiers.control = true; assert_eq!( - win32_press_and_release(&ks), + win32::win32_press_and_release(&ks), Some(b"\x1b[8;14;127;1;8;1_\x1b[8;14;127;0;8;1_".to_vec()) ); } @@ -511,4 +469,329 @@ mod tests { let kitty = TermMode::DISAMBIGUATE_ESC_CODES; assert_eq!(encode(&keystroke("backspace"), &kitty), Some(b"\x7f".to_vec())); } + + #[test] + fn legacy_enter_chords_keep_the_existing_cr_encoding() { + for mode in + [TermMode::default(), TermMode::REPORT_ALTERNATE_KEYS, TermMode::REPORT_ASSOCIATED_TEXT] + { + for (shift, control, alt) in [ + (false, false, false), + (true, false, false), + (false, true, false), + (false, false, true), + (true, true, true), + ] { + let mut key = keystroke("enter"); + key.modifiers.shift = shift; + key.modifiers.control = control; + key.modifiers.alt = alt; + let expected: &[u8] = if alt { b"\x1b\r" } else { b"\r" }; + assert_eq!(encode(&key, &mode).as_deref(), Some(expected)); + assert!(!preserves_enter_modifiers(&key, &mode)); + } + } + } + + #[test] + fn kitty_enter_preserves_each_modifier_and_bare_enter_compatibility() { + for kitty in [ + TermMode::DISAMBIGUATE_ESC_CODES, + TermMode::REPORT_EVENT_TYPES, + TermMode::REPORT_ALL_KEYS_AS_ESC, + pi_keyboard_mode(), + ] { + for mode in [kitty, kitty | TermMode::WIN32_INPUT_MODE] { + for (shift, control, alt, platform, parameter) in [ + (false, false, false, false, 1), + (true, false, false, false, 2), + (false, false, true, false, 3), + (true, false, true, false, 4), + (false, true, false, false, 5), + (true, true, false, false, 6), + (false, true, true, false, 7), + (true, true, true, false, 8), + (false, false, false, true, 9), + ] { + let mut key = keystroke("enter"); + key.modifiers.shift = shift; + key.modifiers.control = control; + key.modifiers.alt = alt; + key.modifiers.platform = platform; + // Even an OS-provided CR/LF cannot erase kitty's key identity. + for text in [None, Some("\r"), Some("\n")] { + key.key_char = text.map(str::to_owned); + let expected = if parameter != 1 { + format!("\x1b[13;{parameter}u").into_bytes() + } else if mode.contains(TermMode::REPORT_ALL_KEYS_AS_ESC) { + b"\x1b[13u".to_vec() + } else { + b"\r".to_vec() + }; + assert_eq!(encode(&key, &mode), Some(expected), "{key:?} {mode:?}"); + assert_eq!(preserves_enter_modifiers(&key, &mode), parameter != 1); + } + } + } + } + } + + #[cfg(windows)] + #[test] + fn win32_enter_keeps_native_characters_and_modifier_bits_in_both_records() { + let mode = TermMode::WIN32_INPUT_MODE; + for (shift, control, alt, text, character, flags) in [ + (false, false, false, None, 13, 0), + (true, false, false, None, 13, 16), + (true, false, false, Some("\r"), 13, 16), + (false, true, false, None, 10, 8), + (false, true, false, Some("\n"), 10, 8), + (false, false, true, Some("\r"), 13, 2), + (true, true, false, Some("\n"), 10, 24), + ] { + let mut key = keystroke("enter"); + key.modifiers.shift = shift; + key.modifiers.control = control; + key.modifiers.alt = alt; + key.key_char = text.map(str::to_owned); + let encoded = String::from_utf8(encode(&key, &mode).unwrap()).unwrap(); + let records: Vec> = encoded + .split_inclusive('_') + .map(|record| { + record + .strip_prefix("\x1b[") + .unwrap() + .strip_suffix('_') + .unwrap() + .split(';') + .map(|field| field.parse().unwrap()) + .collect() + }) + .collect(); + assert_eq!(records.len(), 2, "{encoded:?}"); + for (index, record) in records.iter().enumerate() { + assert_eq!(record.len(), 6); + assert_eq!(record[0], 13, "VK_RETURN"); + assert_ne!(record[1], 0, "native scan code"); + assert_eq!(record[2], character, "keep WM_CHAR and the CR fallback"); + assert_eq!(record[3], u16::from(index == 0)); + assert_eq!(record[4], flags, "Shift/Ctrl/Alt are carried in Cs"); + assert_eq!(record[5], 1); + } + assert_eq!(preserves_enter_modifiers(&key, &mode), flags != 0); + } + } + + #[test] + fn only_enter_uses_modified_enter_tracking() { + for name in ["v", "tab", "escape", "backspace"] { + let mut key = keystroke(name); + key.modifiers.shift = true; + assert!(!preserves_enter_modifiers(&key, &pi_keyboard_mode())); + } + } + + #[test] + fn ctrl_j_keeps_its_newline_identity_across_protocol_negotiation() { + let mut key = keystroke("j"); + key.modifiers.control = true; + assert_eq!(encode(&key, &TermMode::default()), Some(b"\n".to_vec())); + assert!(!preserves_enter_modifiers(&key, &TermMode::default())); + for mode in [pi_keyboard_mode(), pi_keyboard_mode() | TermMode::WIN32_INPUT_MODE] { + assert_eq!(encode(&key, &mode), Some(b"\x1b[106;5u".to_vec())); + assert!(!preserves_enter_modifiers(&key, &mode)); + } + } + + fn pi_keyboard_mode() -> TermMode { + TermMode::DISAMBIGUATE_ESC_CODES + | TermMode::REPORT_EVENT_TYPES + | TermMode::REPORT_ALTERNATE_KEYS + } + + #[test] + fn kitty_alt_v_uses_csi_u_with_or_without_platform_text() { + let mut key = keystroke("v"); + key.modifiers.alt = true; + for mode in [ + TermMode::DISAMBIGUATE_ESC_CODES, + pi_keyboard_mode(), + TermMode::REPORT_ALL_KEYS_AS_ESC, + pi_keyboard_mode() | TermMode::WIN32_INPUT_MODE, + ] { + for text in [None, Some("v")] { + key.key_char = text.map(str::to_owned); + assert_eq!(encode(&key, &mode), Some(b"\x1b[118;3u".to_vec())); + } + } + } + + #[test] + fn kitty_ascii_chords_preserve_control_alt_and_shift() { + for (control, alt, shift, parameter) in [ + (false, true, false, 3), + (true, false, false, 5), + (true, true, false, 7), + (false, true, true, 4), + (true, false, true, 6), + (true, true, true, 8), + ] { + let mut key = keystroke("v"); + key.modifiers.control = control; + key.modifiers.alt = alt; + key.modifiers.shift = shift; + assert_eq!( + encode(&key, &TermMode::DISAMBIGUATE_ESC_CODES), + Some(format!("\x1b[118;{parameter}u").into_bytes()) + ); + let base = if shift { "118:86" } else { "118" }; + assert_eq!( + encode(&key, &pi_keyboard_mode()), + Some(format!("\x1b[{base};{parameter}u").into_bytes()) + ); + } + } + + #[test] + fn kitty_shifted_ascii_uses_the_unshifted_codepoint_and_reported_alternate() { + let mut key = keystroke("1"); + key.modifiers.control = true; + key.modifiers.shift = true; + key.key_char = Some("!".to_owned()); + assert_eq!(encode(&key, &TermMode::DISAMBIGUATE_ESC_CODES), Some(b"\x1b[49;6u".to_vec())); + assert_eq!(encode(&key, &pi_keyboard_mode()), Some(b"\x1b[49:33;6u".to_vec())); + } + + #[test] + fn kitty_modified_space_and_ascii_punctuation_use_csi_u() { + for (name, codepoint) in [("space", 32), ("[", 91), ("/", 47)] { + let mut key = keystroke(name); + key.modifiers.alt = true; + assert_eq!( + encode(&key, &pi_keyboard_mode()), + Some(format!("\x1b[{codepoint};3u").into_bytes()) + ); + } + } + + #[test] + fn alt_v_keeps_legacy_encoding_without_disambiguation() { + let mut key = keystroke("v"); + key.modifiers.alt = true; + for mode in + [TermMode::default(), TermMode::REPORT_ALTERNATE_KEYS, TermMode::REPORT_EVENT_TYPES] + { + assert_eq!(encode(&key, &mode), Some(b"\x1bv".to_vec())); + } + key.modifiers.control = true; + assert_eq!(encode(&key, &TermMode::default()), Some(b"\x1b\x16".to_vec())); + } + + #[test] + fn pi_flags_leave_plain_and_shifted_text_on_the_ime_path() { + assert!(!pi_keyboard_mode().contains(TermMode::REPORT_ALL_KEYS_AS_ESC)); + for name in ["v", "1", "space", "\u{4e2d}"] { + let mut key = keystroke(name); + assert_eq!(encode(&key, &pi_keyboard_mode()), None); + key.modifiers.shift = true; + assert_eq!(encode(&key, &pi_keyboard_mode()), None); + } + } + + #[cfg(windows)] + #[test] + fn altgr_text_is_not_encoded_as_a_control_alt_shortcut() { + let mut key = keystroke("q"); + key.modifiers.control = true; + key.modifiers.alt = true; + for text in ["@", "\u{20ac}"] { + key.key_char = Some(text.to_owned()); + for mode in [ + TermMode::default(), + TermMode::WIN32_INPUT_MODE, + pi_keyboard_mode(), + pi_keyboard_mode() | TermMode::WIN32_INPUT_MODE, + ] { + assert_eq!(encode(&key, &mode), None); + } + } + } + + #[derive(Clone, Default)] + struct KeyboardReplyRecorder(std::rc::Rc>>); + + impl nebula_terminal::event::EventListener for KeyboardReplyRecorder { + fn send_event(&self, event: nebula_terminal::event::Event) { + if let nebula_terminal::event::Event::PtyWrite(reply) = event { + self.0.borrow_mut().push(reply); + } + } + } + + #[test] + fn codex_keyboard_negotiation_preserves_enter_chords_until_reset() { + use nebula_terminal::term::Config; + use nebula_terminal::vte::ansi::Processor; + + let size = super::super::session::GridSize { columns: 80, screen_lines: 24 }; + let recorder = KeyboardReplyRecorder::default(); + let mut term = nebula_terminal::Term::new( + Config { kitty_keyboard: true, ..Config::default() }, + &size, + recorder.clone(), + ); + let mut parser: Processor = Processor::new(); + let mut shift_enter = keystroke("enter"); + shift_enter.modifiers.shift = true; + let mut ctrl_enter = keystroke("enter"); + ctrl_enter.modifiers.control = true; + + assert_eq!(encode(&shift_enter, term.mode()), Some(b"\r".to_vec())); + // Codex 0.153.4 requests disambiguation, event types and alternate keys. + parser.advance(&mut term, b"\x1b[>7u\x1b[?u"); + assert_eq!(*term.mode() & TermMode::KITTY_KEYBOARD_PROTOCOL, pi_keyboard_mode()); + assert_eq!(encode(&shift_enter, term.mode()), Some(b"\x1b[13;2u".to_vec())); + assert_eq!(encode(&ctrl_enter, term.mode()), Some(b"\x1b[13;5u".to_vec())); + assert_eq!(encode(&keystroke("enter"), term.mode()), Some(b"\r".to_vec())); + + parser.advance(&mut term, b"\x1b[7u\x1b[=0u\x1b[?u"); + assert_eq!(encode(&ctrl_enter, term.mode()), Some(b"\r".to_vec())); + assert_eq!(recorder.0.borrow().as_slice(), ["\x1b[?7u", "\x1b[?0u", "\x1b[?0u"]); + } + + #[test] + fn pi_keyboard_negotiation_controls_alt_v_and_pop_restores_legacy() { + use nebula_terminal::term::Config; + use nebula_terminal::vte::ansi::Processor; + + let recorder = KeyboardReplyRecorder::default(); + let size = super::super::session::GridSize { columns: 80, screen_lines: 24 }; + let mut term = nebula_terminal::Term::new( + Config { kitty_keyboard: true, ..Config::default() }, + &size, + recorder.clone(), + ); + let mut parser: Processor = Processor::new(); + let mut key = keystroke("v"); + key.modifiers.alt = true; + + parser.advance(&mut term, b"\x1b[?u"); + assert_eq!(encode(&key, term.mode()), Some(b"\x1bv".to_vec())); + parser.advance(&mut term, b"\x1b[>7u\x1b[?u"); + assert_eq!(*term.mode() & TermMode::KITTY_KEYBOARD_PROTOCOL, pi_keyboard_mode()); + assert_eq!(encode(&key, term.mode()), Some(b"\x1b[118;3u".to_vec())); + parser.advance(&mut term, b"\x1b[>0u\x1b[?u"); + assert_eq!(encode(&key, term.mode()), Some(b"\x1bv".to_vec())); + parser.advance(&mut term, b"\x1b[ bool { + if ks.modifiers.control || ks.modifiers.alt || ks.modifiers.platform { + return true; + } + let key = ks.key.as_str(); + if key == "space" { + return false; + } + let mut chars = key.chars(); + match (chars.next(), chars.next()) { + (Some(c), None) if c.is_ascii_alphanumeric() => false, + _ => true, + } +} + +/// GPUI 键名 → Win32 虚拟键码。 +/// +/// 旧壳从 winit fork 的 `RawKeyEventInfo` 直接拿到系统报的 VK;GPUI 的 +/// `Keystroke` 只有键名,所以这里按名字反查。表只覆盖**编码器会处理的键** +/// (控制键、方向、功能键)——可打印字符在 GPUI 走 IME 管道,不经这里。 +pub(super) fn virtual_key_of(key: &str) -> Option { + use windows_sys::Win32::UI::Input::KeyboardAndMouse::{ + VK_BACK, VK_DELETE, VK_DOWN, VK_END, VK_ESCAPE, VK_F1, VK_HOME, VK_INSERT, VK_LEFT, + VK_NEXT, VK_PRIOR, VK_RETURN, VK_RIGHT, VK_SPACE, VK_TAB, VK_UP, + }; + + let vk = match key { + "escape" => VK_ESCAPE, + "enter" => VK_RETURN, + "tab" => VK_TAB, + "backspace" => VK_BACK, + "space" => VK_SPACE, + "up" => VK_UP, + "down" => VK_DOWN, + "left" => VK_LEFT, + "right" => VK_RIGHT, + "home" => VK_HOME, + "end" => VK_END, + "insert" => VK_INSERT, + "delete" => VK_DELETE, + "pageup" => VK_PRIOR, + "pagedown" => VK_NEXT, + // F1..F24 在 VK 表里连号。 + key if key.starts_with('f') => { + let index: u16 = key[1..].parse().ok()?; + if !(1..=24).contains(&index) { + return None; + } + VK_F1 + index - 1 + }, + // 单个字符(Ctrl+C 一族):ASCII 字母数字的 VK 就是它的大写码点。 + key => { + let mut chars = key.chars(); + let (Some(c), None) = (chars.next(), chars.next()) else { return None }; + if !c.is_ascii_alphanumeric() { + return None; + } + c.to_ascii_uppercase() as u16 + }, + }; + Some(vk) +} + +/// 控制键必须携带真实 `KEY_EVENT_RECORD` 的字符值(Esc=0x1B、Enter=0x0D、 +/// Tab=0x09、Backspace=0x08):OpenConsole 1.22 的 VT 翻译层会丢弃 uChar=0 +/// 的 VK_ESCAPE,于是读字节流的那类应用(Claude Code)收不到 Esc。修饰键与 +/// 功能键保持 0,与真实键盘一致。逐条同旧壳 `control_char_fallback`。 +fn unicode_char_of(ks: &Keystroke, scan_code: u32) -> u16 { + // 平台已经判出文本的(含 Ctrl 变体)以它为准,与 WM_CHAR 语义一致。 + // `key_char` 若是 NUL,当作没文本:真实键盘的 Esc 不会写出 U+0000。 + if let Some(text) = ks.key_char.as_deref() { + let mut units = text.encode_utf16(); + if let (Some(first), None) = (units.next(), units.next()) { + if first != 0 { + return first; + } + } + } + match ks.key.as_str() { + "escape" => 0x1b, + "enter" => crate::platform::keyboard::enter_character( + scan_code, + ks.modifiers.shift, + ks.modifiers.control, + ks.modifiers.alt, + ), + "tab" => b'\t' as u16, + // 真实控制台的 Ctrl+Backspace 记录携带 Uc=DEL(0x7f,WM_CHAR 语义), + // PSReadLine 的原生 Ctrl+Backspace=BackwardKillWord 绑定按此匹配。 + // 给 0x08 会跌进 KeyChar 分发被当成 Ctrl+H——只删一个字符。 + "backspace" => { + if ks.modifiers.control { + 0x7f + } else { + 0x08 + } + }, + "space" => b' ' as u16, + _ => 0, + } +} + +/// 一条 ConPTY Win32 input 记录:`CSI Vk;Sc;Uc;Kd;Cs;Rc_`。 +/// +/// 认不出 VK 的键返回 `None`,调用方回落到传统 VT 编码——宁可少一条记录, +/// 也不要编一个 Vk=0 的假记录,那会让子进程读到一个不存在的键。 +pub(super) fn win32_input_record(ks: &Keystroke, key_down: bool) -> Option> { + use windows_sys::Win32::UI::Input::KeyboardAndMouse::{MAPVK_VK_TO_VSC, MapVirtualKeyW}; + + const SHIFT_PRESSED: u32 = 0x0010; + const LEFT_ALT_PRESSED: u32 = 0x0002; + const LEFT_CTRL_PRESSED: u32 = 0x0008; + + let vk = virtual_key_of(ks.key.as_str())?; + // 扫描码问系统要,不硬编码:非 US 布局与笔记本键盘上这张表并不通用。 + let scan_code = unsafe { MapVirtualKeyW(u32::from(vk), MAPVK_VK_TO_VSC) }; + let mut control_key_state = 0u32; + if ks.modifiers.shift { + control_key_state |= SHIFT_PRESSED; + } + if ks.modifiers.alt { + control_key_state |= LEFT_ALT_PRESSED; + } + if ks.modifiers.control { + control_key_state |= LEFT_CTRL_PRESSED; + } + let key_down = u8::from(key_down); + Some( + format!( + "\x1b[{};{};{};{key_down};{};1_", + vk, + scan_code, + unicode_char_of(ks, scan_code), + control_key_state + ) + .into_bytes(), + ) +} + +/// GPUI 的终端视图只接到 key-down。旧壳对 9001 会再写一条 Kd=0 的抬起 +/// (`keyboard.rs` 的 `key_release` + `escape_carries_its_control_character_both_directions`)。 +/// 真实键盘也是 down+up:Codex 按 VK 看按下就够了,Claude Code / Ink 吃的是 +/// OpenConsole 翻译出的字节流,缺抬起时 Esc 常常一个字节都到不了。 +pub(super) fn win32_press_and_release(ks: &Keystroke) -> Option> { + let mut sequence = win32_input_record(ks, true)?; + sequence.extend(win32_input_record(ks, false)?); + Some(sequence) +} diff --git a/nebula_app/src/gpui_shell/terminal/mod.rs b/nebula_app/src/gpui_shell/terminal/mod.rs index 2eb8c01..212ee89 100644 --- a/nebula_app/src/gpui_shell/terminal/mod.rs +++ b/nebula_app/src/gpui_shell/terminal/mod.rs @@ -3,8 +3,10 @@ //! 边界:本模块之外不出现 nebula_terminal 类型;nebula_terminal 之内不出现 //! GPUI 类型。会话生命周期归 `session`,绘制归 `element`,交互归 `view`。 -pub mod colors; mod answer_reader; +pub mod colors; +mod completion_viewport; +pub(super) mod confirmation; pub mod element; mod inline_image; pub mod keymap; diff --git a/nebula_app/src/gpui_shell/terminal/ssh_connect_overlay.rs b/nebula_app/src/gpui_shell/terminal/ssh_connect_overlay.rs index 91498b6..bd1b60e 100644 --- a/nebula_app/src/gpui_shell/terminal/ssh_connect_overlay.rs +++ b/nebula_app/src/gpui_shell/terminal/ssh_connect_overlay.rs @@ -17,12 +17,65 @@ use gpui::{ use crate::display::ssh_connect::{self, SshConnectState, stage_labels, stage_message}; use crate::gpui_shell::prelude::*; +pub(super) fn update_connection_state( + state: &mut Option, + destination: Option<&str>, + stage: crate::ssh_session::SshStage, +) { + use crate::ssh_session::SshStage; + + if stage == SshStage::Ready { + *state = None; + return; + } + let Some(destination) = destination else { return }; + if state.is_none() || stage == SshStage::Resolve { + *state = Some(SshConnectState::new(destination.to_owned())); + } + if let Some(state) = state.as_mut() { + state.set_stage(stage); + } +} + +#[cfg(test)] +mod connection_state_tests { + use super::*; + use crate::ssh_session::SshStage; + + #[test] + fn a_disconnected_ready_session_regains_a_retry_surface() { + let mut state = Some(SshConnectState::new("test@example.invalid".to_owned())); + update_connection_state(&mut state, Some("test@example.invalid"), SshStage::Ready); + assert!(state.is_none()); + update_connection_state( + &mut state, + Some("test@example.invalid"), + SshStage::Failed("Connection lost".to_owned()), + ); + let state = state.unwrap(); + assert!(state.failed()); + assert!(state.visible()); + assert_eq!(state.destination(), "test@example.invalid"); + assert_eq!(state.failure(), Some("Connection lost")); + } + + #[test] + fn retry_resets_failure_and_ready_removes_the_surface() { + let mut state = None; + update_connection_state(&mut state, Some("host"), SshStage::Failed("failed".into())); + update_connection_state(&mut state, Some("host"), SshStage::Resolve); + assert!(!state.as_ref().unwrap().failed()); + update_connection_state(&mut state, Some("host"), SshStage::Authenticate); + assert_eq!(state.as_ref().unwrap().stage(), SshStage::Authenticate); + update_connection_state(&mut state, Some("host"), SshStage::Ready); + assert!(state.is_none()); + } +} + /// UiLanguage 由设置折算(两壳同源)。 pub(super) fn language() -> crate::display::UiLanguage { let runtime = nebula_settings::RuntimeSettings::load(); - crate::display::LanguagePreference::parse(runtime.language.settings_value()) - .unwrap_or_default() - .resolved() + crate::display::LanguagePreference::from(runtime.language).resolved() } fn hsla_from_rgba(r: u8, g: u8, b: u8) -> Hsla { diff --git a/nebula_app/src/gpui_shell/terminal/suggest.rs b/nebula_app/src/gpui_shell/terminal/suggest.rs index d9e44eb..948b449 100644 --- a/nebula_app/src/gpui_shell/terminal/suggest.rs +++ b/nebula_app/src/gpui_shell/terminal/suggest.rs @@ -164,6 +164,18 @@ mod tests { assert!(!popup_active(&state)); } } + + #[test] + fn hovering_and_scrolling_do_not_change_the_keyboard_accept_target() { + let mut state = popup_state(); + popup_move(&mut state, 1); + let mut viewport = super::super::completion_viewport::CompletionViewport::default(); + viewport.hover((12.0, 28.0), Some(1)); + viewport.scroll(-28.0, 28.0, state.completion_items.len(), 1); + assert_eq!(viewport.offset, 1); + assert_eq!(state.completion_selected, Some(0)); + assert_eq!(popup_take(&mut state).as_deref(), Some(" upstream")); + } } /// 接受键(Tab/Right/Both)的判定复用旧壳 [`AcceptKey`]。 diff --git a/nebula_app/src/gpui_shell/terminal/view.rs b/nebula_app/src/gpui_shell/terminal/view.rs index 444035f..2af0db7 100644 --- a/nebula_app/src/gpui_shell/terminal/view.rs +++ b/nebula_app/src/gpui_shell/terminal/view.rs @@ -1,6 +1,8 @@ //! 终端视图:持有会话、处理输入与 IME、驱动重绘。 mod broadcast; +mod confirmation; +mod notifications; mod pointer; mod runtime; @@ -141,12 +143,8 @@ fn paste_needs_confirmation(text: &str, mode: TermMode) -> bool { /// 当前 UI 语言。`RuntimeSettings` 每次读盘,所以只在用户动作(复制提示、 /// 粘贴确认)时取,不进渲染热路径。 fn ui_language() -> crate::display::UiLanguage { - match nebula_settings::RuntimeSettings::load().language { - nebula_settings::LanguagePref::System => crate::display::LanguagePreference::System, - nebula_settings::LanguagePref::ZhCn => crate::display::LanguagePreference::ZhCn, - nebula_settings::LanguagePref::EnUs => crate::display::LanguagePreference::EnUs, - } - .resolved() + crate::display::LanguagePreference::from(nebula_settings::RuntimeSettings::load().language) + .resolved() } /// 终端视图对宿主(Panel/Workspace)暴露的状态变化。 @@ -172,9 +170,13 @@ pub enum TerminalViewEvent { /// Provider 明确上报的 permission/awaiting-input 上下文。宿主拥有 Window, /// 由它负责驻留提示、后台 Tab 标记和系统通知;raw context 不进入文案。 AiAttention(crate::ai_hook::AttentionContext), + Notification(crate::notify::Notification), /// 非应用鼠标模式下右键命中真实终端选区。菜单由 workspace 根持有, /// 避免每个 pane 都渲染一份带叠加阴影的 PopupMenu。 - SelectionContextMenuRequested { position: Point, text: String }, + SelectionContextMenuRequested { + position: Point, + text: String, + }, /// 程序上报的任务进度(OSC 9;4)变了。宿主把 pane 级状态投到 tab badge, /// 并且只把当前聚焦 pane 投到窗口级任务栏。 ProgressChanged(crate::taskbar::TaskProgress), @@ -252,6 +254,7 @@ pub enum SidebarActivity { pub struct TerminalView { pub(super) answers: crate::assistant_answer::AnswerInbox, + confirmation: super::confirmation::ConfirmationState, pub(super) answer_reader: Option>, preserve_agent_math_source: bool, pub pane_id: u64, @@ -331,8 +334,6 @@ pub struct TerminalView { /// 的 working/blocked 屏幕或权威 hook 前,不允许它伪造完成边沿。 agent_runtime_submit_pending: bool, pending_runtime_submit: Option, - /// OSC 9 程序通知,攒到 render(那里才有 `Window` 判聚焦)再呈现。 - pending_notify: Vec, /// OSC 1337 图片的串行后台解码队列和有界像素缓存。图片不进入字符网格, /// 只用事件携带的绝对行锚定到对应的 scrollback 位置。 pub(super) inline_images: super::inline_image::InlineImageStore, @@ -420,6 +421,7 @@ pub struct TerminalView { /// 计算在 `display::suggest_engine`,数据源在 `terminal::suggest` 的 /// 进程级单例。cwd 由 NEBULA| 标题协议同步进来。 pub(super) suggest: crate::display::NebulaPaneState, + pub(super) completion_viewport: super::completion_viewport::CompletionViewport, /// `suggest` 取样时的可视光标位置。旧壳在一次 Term 锁内同时读取提示行 /// 与光标;GPUI 的 render/paint 分两次取锁,因此用此锚点拒绝跨世代组合 /// (典型是退格回显夹在两次取锁之间造成 ghost 左右跳)。 @@ -441,8 +443,6 @@ pub struct TerminalView { /// BEL 视觉闪烁:整 pane 盖一层前景 12%,约 150ms。 pub(super) bell_flash: bool, bell_flash_epoch: u64, - /// 失焦时再递系统 toast / 驻留横幅(render 里才有 Window)。 - pending_bell_notify: Option<(String, String)>, } impl TerminalView { @@ -743,38 +743,7 @@ impl TerminalView { while let Some(stage) = stage_rx.next().await { if this .update(cx, |view: &mut Self, cx| { - view.ssh_stage = Some(stage.clone()); - // 连接卡片状态机(旧壳 ssh_connect_stage - // 同款合同):Ready 移除;Resolve 开新卡 - // 片;其余阶段推进。中途断线不会让用 - // 了半天的终端凭空复活一张卡片。 - match stage { - crate::ssh_session::SshStage::Ready => { - view.ssh_connect = None; - }, - other => { - match &mut view.ssh_connect { - Some(state) => state.set_stage(other), - None => { - if matches!( - other, - crate::ssh_session::SshStage::Resolve - ) { - if let Some(dest) = - view.ssh_destination.clone() - { - view.ssh_connect = Some( - crate::display::ssh_connect::SshConnectState::new(dest), - ); - view.ssh_connect_last_step = - std::time::Instant::now(); - } - } - }, - } - }, - } - cx.notify(); + view.apply_ssh_stage(stage, cx); }) .is_err() { @@ -829,6 +798,7 @@ impl TerminalView { math: super::math_overlay::MathOverlay::default(), answers: crate::assistant_answer::AnswerInbox::default(), answer_reader: None, + confirmation: super::confirmation::ConfirmationState::default(), preserve_agent_math_source: false, font: mono_font(&families[0], FontWeight::NORMAL, FontStyle::Normal), font_bold: mono_font(&families[1], FontWeight::BOLD, FontStyle::Normal), @@ -862,7 +832,6 @@ impl TerminalView { idle_screen_streak: 0, agent_runtime_submit_pending: false, pending_runtime_submit: None, - pending_notify: Vec::new(), inline_images: super::inline_image::InlineImageStore::default(), ssh_destination, exec_context, @@ -909,6 +878,7 @@ impl TerminalView { state }, suggest_anchor: None, + completion_viewport: super::completion_viewport::CompletionViewport::default(), ghost_enabled, accept, completion_style, @@ -918,7 +888,6 @@ impl TerminalView { last_task_state: None, bell_flash: false, bell_flash_epoch: 0, - pending_bell_notify: None, }; // 出生即把亮暗种进 Term:`Term::color_scheme_dark` 的默认值是「暗」, // 浅色主题下启动的 pane 如果不种,第一个 DECSET 2031 的订阅方会拿到 @@ -1084,6 +1053,7 @@ impl TerminalView { if self.pending_runtime_submit.is_some() { return; } + self.notify_command_done(cx); self.last_command_failed = exit_code.is_some_and(|code| code != 0); // 旧壳同款收尾:CLI 退回提示符后,它不再是这个 pane 的前台 // 事实——hook 稍后若仍在跑会重新点亮(handle_ai_hook 覆写)。 @@ -1097,13 +1067,12 @@ impl TerminalView { cx.notify(); }, TermEvent::Notify(body) => { - // 程序自己发的 OSC 9 通知:旧壳只在窗口没聚焦时才递出去 - // (event.rs:3291),聚焦时用户本来就在看这个 pane。这里攒着, - // render 拿到 `Window` 才判聚焦。 let body = body.trim().to_owned(); if !body.is_empty() { - self.pending_notify.push(body); - cx.notify(); + cx.emit(TerminalViewEvent::Notification(crate::notify::Notification::Text { + body, + program: self.running_program.clone(), + })); } }, TermEvent::AiHookEnvelope(envelope) => { @@ -1127,9 +1096,8 @@ impl TerminalView { fn drive_inline_image_decode(&mut self, cx: &mut Context) { let Some(pending) = self.inline_images.start_next() else { return }; - let decode = cx - .background_executor() - .spawn(async move { super::inline_image::decode(pending) }); + let decode = + cx.background_executor().spawn(async move { super::inline_image::decode(pending) }); cx.spawn(async move |this, cx| { let result = decode.await; let _ = this.update(cx, |view, cx| { @@ -1145,6 +1113,7 @@ impl TerminalView { /// `Exited` 只对宿主发一次;重复的退出信号(ChildExit 之后必然跟 Exit)只更新文案。 fn mark_exited(&mut self, message: String, cx: &mut Context) { + self.confirmation.invalidate(); self.pending_runtime_submit = None; self.suggest.pending_command_prompt = None; if self.exited.is_none() { @@ -1185,6 +1154,7 @@ impl TerminalView { /// 输入后回到底部并请求重绘。 fn write_input(&mut self, bytes: Vec, cx: &mut Context) { + self.confirmation.observe_input(&bytes); self.awaiting_input = false; if let Some(session) = &self.session { { @@ -1266,11 +1236,9 @@ impl TerminalView { if mode.visual() || (mode.audible() && !audible_ok) { self.flash_bell(cx); } - let program = self.running_program.clone(); - self.pending_bell_notify = Some(match program { - Some(name) => (name, "任务完成,等待输入".to_owned()), - None => (crate::brand::NAME.to_owned(), "终端响铃".to_owned()), - }); + cx.emit(TerminalViewEvent::Notification(crate::notify::Notification::Bell { + program: self.running_program.clone(), + })); cx.emit(TerminalViewEvent::Bell); cx.notify(); } @@ -1631,12 +1599,8 @@ impl TerminalView { // 原始按键可能紧接着再次到来;先清除选区,下一次 Copy 才能按 // “未处理”传播回终端,而不是重复复制并再次弹 toast。 session.term.lock().selection = None; - let message = match ui_language() { - crate::display::UiLanguage::EnUs => { - format!("Copied {lines} lines to clipboard") - }, - _ => format!("已复制 {lines} 行到剪贴板"), - }; + let message = ui_language() + .format(crate::i18n::Message::CommonCopiedLines, &[("lines", &lines.to_string())]); crate::gpui_shell::toast::toast(window, cx, crate::display::ToastKind::Info, message); cx.notify(); } @@ -1672,11 +1636,9 @@ impl TerminalView { // bracketed 的对端(codex/vim/PSReadLine)整块收下、自己决定怎么处理; // 裸 shell 是换行落地即执行。风险不同,话就得说得不一样。 let runs_line_by_line = !self.term_mode().contains(TermMode::BRACKETED_PASTE); - let title: SharedString = match language { - crate::display::UiLanguage::EnUs => format!("Paste {lines} lines?"), - _ => format!("粘贴 {lines} 行文本?"), - } - .into(); + let title: SharedString = language + .format(crate::i18n::Message::CommonPasteLines, &[("lines", &lines.to_string())]) + .into(); let body: SharedString = if runs_line_by_line { language.pick( "shell 会把这些内容逐行执行。请确认来源可信。", @@ -1827,14 +1789,19 @@ impl TerminalView { ) { #[cfg(windows)] { - if self.exited.is_some() || !self.ghost_enabled { + if self.exited.is_some() + || !self.ghost_enabled + || matches!(self.ssh_stage, Some(crate::ssh_session::SshStage::Failed(_))) + { self.suggest_anchor = None; self.suggest.clear_completion_hints(); + self.completion_viewport.clear(); return; } if self.session.is_none() { self.suggest_anchor = None; self.suggest.clear_completion_hints(); + self.completion_viewport.clear(); return; } match line { @@ -1847,11 +1814,16 @@ impl TerminalView { self.ghost_enabled, self.completion_style, ); + self.completion_viewport.update_query( + &self.suggest.screen_line, + self.suggest.completion_items.len(), + ); }, None => { self.suggest_anchor = None; self.suggest.screen_line.clear(); self.suggest.clear_completion_hints(); + self.completion_viewport.clear(); }, } } @@ -1928,6 +1900,9 @@ impl TerminalView { let mods = &ks.modifiers; let plain_mods = !mods.control && !mods.alt && !mods.platform; match ks.key.as_str() { + "enter" if keymap::preserves_enter_modifiers(ks, mode) => { + crate::display::nebula_clear_line(&mut self.suggest); + }, "enter" => self.commit_line(cx), "backspace" if mods.control && !mods.alt && !mods.platform => { crate::display::nebula_input_delete_word(&mut self.suggest); @@ -2011,17 +1986,24 @@ impl TerminalView { if suggest::popup_active(&self.suggest) { match ks.key.as_str() { // 候选自动出现时保持未选中,让 Up/Down 继续交给 shell - // 历史;Tab 或鼠标先建立选中态后,方向键才导航列表。 + // 历史;Tab 先建立选中态后,方向键才导航列表。 key @ ("tab" | "down" | "up") if key == "tab" || self.suggest.completion_selected.is_some() => { suggest::popup_move(&mut self.suggest, if key == "up" { -1 } else { 1 }); + let rows = self.completion_popup_geometry().map_or(8, |popup| popup.rows); + self.completion_viewport.reveal( + self.suggest.completion_selected, + self.suggest.completion_items.len(), + rows, + ); cx.notify(); cx.stop_propagation(); return; }, "escape" => { if suggest::popup_dismiss(&mut self.suggest) { + self.completion_viewport.clear(); cx.notify(); cx.stop_propagation(); return; @@ -2087,6 +2069,7 @@ impl TerminalView { self.track_encoded_key(ks, &mode, cx); if let Some(bytes) = keymap::encode(ks, &mode) { + keymap::trace_enter(ks, &mode, &bytes); self.write_user_key(ks.clone(), bytes, cx); cx.stop_propagation(); } @@ -2141,7 +2124,10 @@ impl Drop for TerminalView { impl Focusable for TerminalView { fn focus_handle(&self, cx: &App) -> FocusHandle { - self.answer_reader.as_ref().map_or_else(|| self.focus_handle.clone(), |reader| reader.read(cx).focus_handle.clone()) + self.answer_reader.as_ref().map_or_else( + || self.focus_handle.clone(), + |reader| reader.read(cx).focus_handle.clone(), + ) } } @@ -2251,38 +2237,6 @@ impl Render for TerminalView { if let Some(reader) = &self.answer_reader { return div().size_full().child(reader.clone()).into_any_element(); } - // OSC 9 程序通知:旧壳只在窗口没聚焦时递出去,聚焦时用户本来就在看这 - // 个 pane。这里是最早能同时拿到 `Window` 和 view 可变引用的地方。用驻留 - // 式 banner 而不是 toast——人不在看的时候弹一条 5 秒自动消失的提示等于 - // 没弹。推送 defer 到本轮 effect 之后,不在 render 中途改 `Root`。 - if !self.pending_notify.is_empty() { - let notes = std::mem::take(&mut self.pending_notify); - if !window.is_window_active() { - window.defer(cx, move |window, cx| { - for body in notes { - crate::gpui_shell::toast::banner( - window, - cx, - crate::display::ToastKind::Info, - body, - ); - } - }); - } - } - if let Some((title, body)) = self.pending_bell_notify.take() { - if !window.is_window_active() { - crate::notify::toast(&title, &body); - window.defer(cx, move |window, cx| { - crate::gpui_shell::toast::banner( - window, - cx, - crate::display::ToastKind::Info, - format!("{title} · {body}"), - ); - }); - } - } // 文件树拖到终端时的落点高亮:强调色压到很低的不透明度,与 hover 的 // 中性灰区分开——用户要能一眼看出"松手会落在这里"。 let drop_highlight = cx.theme().accent.opacity(0.18); @@ -2344,6 +2298,9 @@ impl Render for TerminalView { .on_hover(cx.listener(|this, hovered: &bool, _, cx| { if !*hovered { this.clear_link_hover(cx); + if this.completion_viewport.hovered.take().is_some() { + cx.notify(); + } } })); @@ -2390,14 +2347,29 @@ impl Render for TerminalView { } if let Some(answer) = self.answers.latest.clone() { let provider = if answer.provider == "claude" { "Claude Code" } else { "Codex" }; - return div().size_full().relative() + return div() + .size_full() + .relative() .child(root) - .child(crate::gpui_shell::prelude::h_flex().absolute().top_0().right_2().px_2().gap_2().items_center() - .bg(cx.theme().background) - .child(div().text_xs().child(format!("{provider} · 回答"))) - .child(crate::gpui_shell::prelude::Button::new("answer-open") - .label("阅读").small() - .on_click(cx.listener(|view, _, window, cx| view.open_answer(window, cx))))) + .child( + crate::gpui_shell::prelude::h_flex() + .absolute() + .top_0() + .right_2() + .px_2() + .gap_2() + .items_center() + .bg(cx.theme().background) + .child(div().text_xs().child(format!("{provider} · 回答"))) + .child( + crate::gpui_shell::prelude::Button::new("answer-open") + .label("阅读") + .small() + .on_click( + cx.listener(|view, _, window, cx| view.open_answer(window, cx)), + ), + ), + ) .into_any_element(); } root.into_any_element() @@ -2411,11 +2383,16 @@ impl TerminalView { if self.agent_status == crate::ai_agents::AgentStatus::Blocked { reader.update(cx, |reader, cx| reader.needs_attention(cx)); } - cx.subscribe_in(&reader, window, |view, _, _: &super::answer_reader::ReaderEvent, window, cx| { - view.answer_reader = None; - window.focus(&view.focus_handle, cx); - cx.notify(); - }).detach(); + cx.subscribe_in( + &reader, + window, + |view, _, _: &super::answer_reader::ReaderEvent, window, cx| { + view.answer_reader = None; + window.focus(&view.focus_handle, cx); + cx.notify(); + }, + ) + .detach(); let focus = reader.read(cx).focus_handle.clone(); window.focus(&focus, cx); self.answer_reader = Some(reader); @@ -2423,11 +2400,14 @@ impl TerminalView { } pub(super) fn uses_source_reader_math(&mut self) -> bool { - self.preserve_agent_math_source |= self.running_program.as_deref().is_some_and(|program| { - matches!(program, "claude" | "codex") - }) || self.ai_session.as_ref().is_some_and(|session| { - matches!(session.source.as_str(), "claude" | "codex") - }); + self.preserve_agent_math_source |= self + .running_program + .as_deref() + .is_some_and(|program| matches!(program, "claude" | "codex")) + || self + .ai_session + .as_ref() + .is_some_and(|session| matches!(session.source.as_str(), "claude" | "codex")); self.preserve_agent_math_source } } diff --git a/nebula_app/src/gpui_shell/terminal/view/confirmation.rs b/nebula_app/src/gpui_shell/terminal/view/confirmation.rs new file mode 100644 index 0000000..1636a46 --- /dev/null +++ b/nebula_app/src/gpui_shell/terminal/view/confirmation.rs @@ -0,0 +1,61 @@ +use gpui::{Context, EventEmitter as _}; + +use super::{TerminalView, TerminalViewEvent}; + +impl TerminalView { + pub(crate) fn capture_confirmation( + &mut self, + ) -> Option { + let (screen, mode) = self.runtime_screen_state()?; + let program = self.running_program.as_deref().unwrap_or_default(); + let waiting = self.confirmation_waiting(); + self.confirmation.capture( + &screen, + program, + self.ai_session.as_ref().map(|identity| identity.session_id.as_str()), + mode, + waiting, + ) + } + + fn confirmation_waiting(&self) -> bool { + self.marked_text.is_none() + && self + .running_program + .as_deref() + .and_then(crate::ai_agents::AgentKind::parse) + .is_some() + && matches!( + self.runtime_task_state(), + crate::runtime_api::RuntimeTaskState::Attention + | crate::runtime_api::RuntimeTaskState::WaitingInput + ) + } + + pub(crate) fn answer_confirmation( + &mut self, + request_id: u64, + allow: bool, + cx: &mut Context, + ) -> bool { + let Some((screen, mode)) = self.runtime_screen_state() else { return false }; + let waiting = self.confirmation_waiting(); + let reply = self.confirmation.answer( + request_id, + allow, + &screen, + self.running_program.as_deref().unwrap_or_default(), + self.ai_session.as_ref().map(|identity| identity.session_id.as_str()), + mode, + waiting, + ); + let Some(reply) = reply else { return false }; + self.write_input(reply, cx); + self.agent_status = crate::ai_agents::AgentStatus::Working; + self.agent_turn_active = true; + self.agent_runtime_submit_pending = true; + self.idle_screen_streak = 0; + cx.emit(TerminalViewEvent::TitleChanged); + true + } +} diff --git a/nebula_app/src/gpui_shell/terminal/view/notifications.rs b/nebula_app/src/gpui_shell/terminal/view/notifications.rs new file mode 100644 index 0000000..66b8e49 --- /dev/null +++ b/nebula_app/src/gpui_shell/terminal/view/notifications.rs @@ -0,0 +1,76 @@ +use crate::ai_agents::{AgentKind, AgentStatus}; +use gpui::{Context, EventEmitter as _}; + +impl super::TerminalView { + pub(crate) fn is_reading_answer(&self) -> bool { + self.answer_reader.is_some() + } + + pub(super) fn notify_command_done(&self, cx: &mut Context) { + if let Some(started) = self.command_started + && started.elapsed() >= crate::notify::COMMAND_NOTIFY_MIN + { + cx.emit(super::TerminalViewEvent::Notification( + crate::notify::Notification::CommandDone { + duration: started.elapsed(), + program: self.running_program.clone(), + }, + )); + } + } +} + +pub(super) fn screen_notification(previous: AgentStatus, next: AgentStatus) -> Option { + match next { + AgentStatus::Blocked if previous != AgentStatus::Blocked => Some(true), + AgentStatus::Done if matches!(previous, AgentStatus::Working | AgentStatus::Blocked) => { + Some(false) + }, + _ => None, + } +} + +pub(super) fn screen_program( + current: Option<&str>, + identified: Option, +) -> Option { + match current { + Some(program) if AgentKind::parse(program).is_some() => Some(program.to_owned()), + Some(program) if !screen_identity_allowed(Some(program)) => None, + _ => identified.map(|agent| agent.slug().to_owned()), + } +} + +pub(super) fn screen_identity_allowed(current: Option<&str>) -> bool { + current.is_none_or(|program| { + crate::process_tree::is_interactive_shell_command(program) + || crate::process_tree::display_name(program).eq_ignore_ascii_case("ssh") + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn screen_completion_and_attention_are_edges_not_idle_polling() { + assert_eq!(screen_notification(AgentStatus::Working, AgentStatus::Done), Some(false)); + assert_eq!(screen_notification(AgentStatus::Blocked, AgentStatus::Done), Some(false)); + assert_eq!(screen_notification(AgentStatus::Working, AgentStatus::Blocked), Some(true)); + assert_eq!(screen_notification(AgentStatus::Unknown, AgentStatus::Blocked), Some(true)); + assert_eq!(screen_notification(AgentStatus::Done, AgentStatus::Done), None); + assert_eq!(screen_notification(AgentStatus::Blocked, AgentStatus::Blocked), None); + assert_eq!(screen_notification(AgentStatus::Unknown, AgentStatus::Idle), None); + assert_eq!(screen_notification(AgentStatus::Idle, AgentStatus::Idle), None); + } + + #[test] + fn wsl_and_ssh_wrappers_allow_identifying_the_guest_agent() { + for wrapper in [None, Some("wsl"), Some("ssh"), Some("bash")] { + assert_eq!(screen_program(wrapper, Some(AgentKind::Claude)), Some("claude".into())); + } + assert_eq!(screen_program(Some("codex"), Some(AgentKind::Claude)), Some("codex".into())); + assert_eq!(screen_program(Some("vim"), Some(AgentKind::Claude)), None); + assert_eq!(screen_program(Some("wsl"), None), None); + } +} diff --git a/nebula_app/src/gpui_shell/terminal/view/pointer.rs b/nebula_app/src/gpui_shell/terminal/view/pointer.rs index adf8326..e21300e 100644 --- a/nebula_app/src/gpui_shell/terminal/view/pointer.rs +++ b/nebula_app/src/gpui_shell/terminal/view/pointer.rs @@ -197,21 +197,28 @@ impl TerminalView { } /// 像素命中与绘制共用同一份候选几何,截短列表或上翻时也不会点错行。 - pub(super) fn completion_popup_hit(&self, position: Point) -> Option { + pub(super) fn completion_popup_geometry( + &self, + ) -> Option { if !suggest::popup_active(&self.suggest) { return None; } let (cursor_row, cursor_col) = self.suggest_anchor?; - let popup = completion_popup_layout( + completion_popup_layout( &self.suggest.completion_items, self.suggest.completion_selected, + self.completion_viewport.offset, cursor_row, cursor_col, self.rows, self.cols, self.cell_width, self.line_height, - )?; + ) + } + + pub(super) fn completion_popup_hit(&self, position: Point) -> Option { + let popup = self.completion_popup_geometry()?; let content = popup.content_bounds(self.origin); if !content.contains(&position) { return None; @@ -221,47 +228,85 @@ impl TerminalView { (index < self.suggest.completion_items.len()).then_some(index) } - /// 右侧窄条不覆盖候选字符列;点击轨道按指针比例跳到完整候选集中的位置。 - pub(super) fn completion_popup_scrollbar_target( - &self, - position: Point, - ) -> Option { - if !suggest::popup_active(&self.suggest) { - return None; - } - let (cursor_row, cursor_col) = self.suggest_anchor?; - let popup = completion_popup_layout( - &self.suggest.completion_items, - self.suggest.completion_selected, - cursor_row, - cursor_col, - self.rows, - self.cols, - self.cell_width, - self.line_height, - )?; - let len = self.suggest.completion_items.len(); - if len <= popup.rows { - return None; - } + fn completion_popup_contains(&self, position: Point) -> bool { + self.completion_popup_geometry() + .is_some_and(|popup| popup.panel_bounds(self.origin).contains(&position)) + } + + fn completion_popup_scrollbar_grab(&self, position: Point) -> Option { + let popup = self.completion_popup_geometry()?; + let (track, thumb) = + popup.scrollbar_bounds(self.origin, self.suggest.completion_items.len())?; let content = popup.content_bounds(self.origin); - let top = content.origin.y; - let height = content.size.height; if position.x < content.right() || position.x >= content.right() + px(5.0) - || position.y < top - || position.y >= top + height + || position.y < track.origin.y + || position.y >= track.bottom() { return None; } - let progress = ((position.y - top).as_f32() / height.as_f32().max(1.0)).clamp(0.0, 1.0); - Some((progress * (len - 1) as f32).round() as usize) + Some(if position.y >= thumb.origin.y && position.y < thumb.bottom() { + (position.y - thumb.origin.y).as_f32() + } else { + thumb.size.height.as_f32() * 0.5 + }) + } + + fn scroll_completion_popup_to(&mut self, position: Point, released: bool) -> bool { + if self.completion_viewport.scrollbar_grab.is_none() { + return false; + } + let Some(popup) = self.completion_popup_geometry() else { + self.completion_viewport.scrollbar_grab = None; + return true; + }; + let count = self.suggest.completion_items.len(); + let Some((track, thumb)) = popup.scrollbar_bounds(self.origin, count) else { + self.completion_viewport.scrollbar_grab = None; + return true; + }; + self.completion_viewport.drag_scrollbar( + (position.y - track.origin.y).as_f32(), + (track.size.height - thumb.size.height).as_f32(), + count, + popup.rows, + released, + ) + } + + pub(in crate::gpui_shell::terminal) fn move_completion_popup_scrollbar( + &mut self, + event: &MouseMoveEvent, + cx: &mut Context, + ) -> bool { + let handled = if event.pressed_button == Some(MouseButton::Left) { + self.scroll_completion_popup_to(event.position, false) + } else { + self.completion_viewport.scrollbar_grab.take().is_some() + }; + if handled { + cx.notify(); + } + handled + } + + pub(in crate::gpui_shell::terminal) fn finish_completion_popup_scrollbar( + &mut self, + position: Point, + cx: &mut Context, + ) -> bool { + let handled = self.scroll_completion_popup_to(position, true); + if handled { + cx.notify(); + } + handled } /// 接受当前候选时只写入补全余量。即使余量为空也算已处理,Enter 不能 /// 继续透传成一次命令执行。 pub(super) fn accept_completion_popup(&mut self, cx: &mut Context) -> bool { let Some(insert) = suggest::popup_take(&mut self.suggest) else { return false }; + self.completion_viewport.clear(); let before = if self.suggest.screen_line.is_empty() { self.suggest.line_buf.clone() } else { @@ -289,9 +334,10 @@ impl TerminalView { return; } if event.button == MouseButton::Left - && let Some(index) = self.completion_popup_scrollbar_target(event.position) + && let Some(grab) = self.completion_popup_scrollbar_grab(event.position) { - self.suggest.completion_selected = Some(index); + self.completion_viewport.scrollbar_grab = Some(grab); + self.scroll_completion_popup_to(event.position, false); cx.notify(); cx.stop_propagation(); return; @@ -306,9 +352,14 @@ impl TerminalView { return; } } + if self.completion_popup_contains(event.position) { + cx.stop_propagation(); + return; + } // 面板外第一次点击只负责关闭浮层,避免同一击又在终端里开选区或触发 // TUI 鼠标协议;缓存当前行,行不变时浮层不会下一帧原地复活。 if suggest::popup_dismiss(&mut self.suggest) { + self.completion_viewport.clear(); cx.notify(); cx.stop_propagation(); return; @@ -388,16 +439,23 @@ impl TerminalView { _window: &mut Window, cx: &mut Context, ) { - // 候选行 hover 与键盘高亮共用选中索引;这样鼠标所见即 Enter 所收。 - // 移出面板时保留最后选择,继续按 Tab 不会突然跳回第一项。 - if event.pressed_button.is_none() - && let Some(index) = self.completion_popup_hit(event.position) - { - if self.suggest.completion_selected != Some(index) { - self.suggest.completion_selected = Some(index); + if self.move_completion_popup_scrollbar(event, cx) { + cx.stop_propagation(); + return; + } + if event.pressed_button.is_none() { + let index = self.completion_popup_hit(event.position); + if self + .completion_viewport + .hover((event.position.x.as_f32(), event.position.y.as_f32()), index) + { cx.notify(); } - return; + if self.completion_popup_contains(event.position) { + self.clear_link_hover(cx); + cx.stop_propagation(); + return; + } } // 拖条中:指针的整段轨迹都归滚动条,不进选区也不上报。 if let Some(grab) = self.scrollbar_drag { @@ -468,6 +526,10 @@ impl TerminalView { window: &mut Window, cx: &mut Context, ) { + if self.finish_completion_popup_scrollbar(event.position, cx) { + cx.stop_propagation(); + return; + } self.stop_selection_scroll(); if self.scrollbar_drag.take().is_some() { cx.notify(); @@ -501,10 +563,14 @@ impl TerminalView { /// 自动回滚停不下来,下一次移动还会继续改选区。 pub(super) fn on_mouse_up_out( &mut self, - _event: &MouseUpEvent, + event: &MouseUpEvent, window: &mut Window, cx: &mut Context, ) { + if self.finish_completion_popup_scrollbar(event.position, cx) { + cx.stop_propagation(); + return; + } self.stop_selection_scroll(); let dragging_scrollbar = self.scrollbar_drag.take().is_some(); if !self.selecting { @@ -742,15 +808,16 @@ impl TerminalView { cx.stop_propagation(); return; } - if suggest::popup_active(&self.suggest) - && (self.completion_popup_hit(event.position).is_some() - || self.completion_popup_scrollbar_target(event.position).is_some()) + if self.completion_popup_contains(event.position) + && let Some(popup) = self.completion_popup_geometry() { - if delta_y != 0.0 { - // 系统会把一格滚轮编码成“滚动 N 行”(Windows 常见为 3), - // 终端回滚区需要尊重这个倍率,候选选择器则不应该:一次滚轮 - // 手势只跨一个 item,否则 8 行视口会瞬间跳过半屏。 - suggest::popup_move(&mut self.suggest, if delta_y > 0.0 { -1 } else { 1 }); + let delta = event.delta.pixel_delta(popup.row_height).y.as_f32(); + if self.completion_viewport.scroll( + delta, + popup.row_height.as_f32(), + self.suggest.completion_items.len(), + popup.rows, + ) { cx.notify(); } cx.stop_propagation(); diff --git a/nebula_app/src/gpui_shell/terminal/view/runtime.rs b/nebula_app/src/gpui_shell/terminal/view/runtime.rs index 730c0a0..74277da 100644 --- a/nebula_app/src/gpui_shell/terminal/view/runtime.rs +++ b/nebula_app/src/gpui_shell/terminal/view/runtime.rs @@ -27,11 +27,35 @@ fn progress_sidebar_activity( } impl TerminalView { + pub(super) fn apply_ssh_stage( + &mut self, + stage: crate::ssh_session::SshStage, + cx: &mut Context, + ) { + self.ssh_stage = Some(stage.clone()); + super::super::ssh_connect_overlay::update_connection_state( + &mut self.ssh_connect, + self.ssh_destination.as_deref(), + stage.clone(), + ); + self.ssh_connect_last_step = std::time::Instant::now(); + if matches!(stage, crate::ssh_session::SshStage::Failed(_)) { + self.pending_runtime_submit = None; + self.command_running = false; + self.command_started = None; + self.confirmation.invalidate(); + self.answer_reader = None; + } + cx.emit(TerminalViewEvent::TitleChanged); + cx.notify(); + } + /// Clear foreground Agent identity after an authoritative command end or /// after the submitted shell prompt is observed again. OSC 133;D remains /// the primary edge; the cached-prompt path calls the same reset so the two /// lifecycle routes cannot drift apart. pub(super) fn clear_foreground_agent_state(&mut self) -> bool { + self.confirmation.observe_waiting(false); self.answers.close(); let title_changed = self.running_program.take().is_some() || self.ai_session.take().is_some(); @@ -702,11 +726,15 @@ impl TerminalView { // 进程就是主 agent(子代理必须由它 spawn,不可能先到)。 let from_primary_agent = self.claim_primary_agent(event); if from_primary_agent && self.ssh_destination.is_none() && self.exited.is_none() { - if self.answers.observe(event, self.pane_id) && let Some(reader) = &self.answer_reader { + if self.answers.observe(event, self.pane_id) + && let Some(reader) = &self.answer_reader + { reader.update(cx, |reader, cx| reader.answer_arrived(cx)); } } - if event.kind == AiHookKind::NeedsAttention && let Some(reader) = &self.answer_reader { + if event.kind == AiHookKind::NeedsAttention + && let Some(reader) = &self.answer_reader + { reader.update(cx, |reader, cx| reader.needs_attention(cx)); } if from_primary_agent @@ -772,11 +800,30 @@ impl TerminalView { } }, } - if event.kind == AiHookKind::NeedsAttention - && let Some(mut attention) = event.attention.clone() + if from_primary_agent { + self.confirmation.observe_waiting(self.agent_status == AgentStatus::Blocked); + } + if event.kind == AiHookKind::NeedsAttention { + self.confirmation.set_provider_request(event.event_id.as_deref()); + if let Some(mut attention) = event.attention.clone() { + attention.pane_id = Some(self.pane_id); + cx.emit(TerminalViewEvent::AiAttention(attention)); + } else { + cx.emit(TerminalViewEvent::Notification(crate::notify::Notification::AiTurn { + program: event.source.clone(), + message: event.message.clone(), + attention: true, + })); + } + } else if from_primary_agent + && event.kind == AiHookKind::TurnDone + && matches!(self.agent_status, AgentStatus::Done | AgentStatus::Blocked) { - attention.pane_id = Some(self.pane_id); - cx.emit(TerminalViewEvent::AiAttention(attention)); + cx.emit(TerminalViewEvent::Notification(crate::notify::Notification::AiTurn { + program: event.source.clone(), + message: event.message.clone(), + attention: self.agent_status == AgentStatus::Blocked, + })); } cx.emit(TerminalViewEvent::TitleChanged); cx.notify(); @@ -802,6 +849,12 @@ impl TerminalView { pub fn refresh_agent_screen_state(&mut self, cx: &mut Context) { use crate::ai_agents::AgentStatus; + if self.exited.is_some() + || matches!(self.ssh_stage, Some(crate::ssh_session::SshStage::Failed(_))) + { + return; + } + // Wakeup 会被 synchronized-update 合并;1 Hz 看门狗提供可靠的 // Grid 后置检查,确保 Runtime 文本回显后一定能补发 Enter。 self.flush_pending_runtime_submit(cx); @@ -852,23 +905,22 @@ impl TerminalView { cx.notify(); return; } - let program = match self.running_program.clone() { - Some(program) => program, - None => { - let Some(agent) = crate::ai_agents::identify(&screen) else { - self.idle_screen_streak = 0; - return; - }; - let program = agent.slug().to_owned(); - log::debug!("agent identity from screen: pane={} program={program}", self.pane_id); - self.running_program = Some(program.clone()); - self.agent_status_source = crate::ai_agents::AgentStatusSource::Screen; - self.agent_status_rule = None; - cx.emit(TerminalViewEvent::TitleChanged); - cx.notify(); - program - }, + let identify = + super::notifications::screen_identity_allowed(self.running_program.as_deref()); + let detected = identify.then(|| crate::ai_agents::identify(&screen)).flatten(); + let Some(program) = + super::notifications::screen_program(self.running_program.as_deref(), detected) + else { + self.idle_screen_streak = 0; + return; }; + if self.running_program.as_deref() != Some(program.as_str()) { + self.running_program = Some(program.clone()); + self.agent_status_source = crate::ai_agents::AgentStatusSource::Screen; + self.agent_status_rule = None; + cx.emit(TerminalViewEvent::TitleChanged); + cx.notify(); + } if crate::ai_agents::AgentKind::parse(&program).is_none() { return; } @@ -931,7 +983,17 @@ impl TerminalView { return; }, }; + self.confirmation.observe_waiting(next == AgentStatus::Blocked); if next != self.agent_status { + if let Some(attention) = + super::notifications::screen_notification(self.agent_status, next) + { + cx.emit(TerminalViewEvent::Notification(crate::notify::Notification::AiTurn { + program: program.clone(), + message: None, + attention, + })); + } log::debug!( "agent screen state: pane={} program={} {:?}->{next:?} rule={}", self.pane_id, @@ -1041,6 +1103,10 @@ impl TerminalView { } fn runtime_screen_snapshot(&self) -> Option { + self.runtime_screen_state().map(|(screen, _)| screen) + } + + pub(super) fn runtime_screen_state(&self) -> Option<(String, TermMode)> { let session = self.session.as_ref()?; let term = session.term.lock(); let lines = term.screen_lines(); @@ -1049,7 +1115,7 @@ impl TerminalView { } let start = TermPoint::new(Line(0), Column(0)); let end = TermPoint::new(Line(lines as i32 - 1), Column(term.columns().saturating_sub(1))); - Some(term.bounds_to_string(start, end)) + Some((term.bounds_to_string(start, end), *term.mode())) } pub(super) fn flush_pending_runtime_submit(&mut self, cx: &mut Context) { diff --git a/nebula_app/src/gpui_shell/theme.rs b/nebula_app/src/gpui_shell/theme.rs index a6bbf2f..c893298 100644 --- a/nebula_app/src/gpui_shell/theme.rs +++ b/nebula_app/src/gpui_shell/theme.rs @@ -467,7 +467,7 @@ pub fn settings_hairline(cx: &App) -> Hsla { pub fn settings_hover_bg(cx: &App, strong: bool) -> Hsla { let sk = chrome_theme(effective_theme_name(cx)).skin(); - let (hover_alpha, strong_alpha) = if sk.is_light { (22, 34) } else { (30, 46) }; + let (hover_alpha, strong_alpha) = if sk.is_light { (10, 18) } else { (30, 46) }; let alpha = if strong { strong_alpha } else { hover_alpha }; wash(Rgba::new(sk.accent.r, sk.accent.g, sk.accent.b, alpha)) } @@ -571,16 +571,27 @@ fn apply_skin_tokens(chrome: NebulaTheme, cx: &mut App) { theme.overlay = wash(sk.veil); // 悬停 / 选中水洗。 - theme.accent = wash(sk.hover); + let hover = if sk.is_light { + wash(Rgba::new(sk.accent.r, sk.accent.g, sk.accent.b, 10)) + } else { + wash(sk.hover) + }; + let selected = if sk.is_light { + wash(Rgba::new(sk.accent.r, sk.accent.g, sk.accent.b, 18)) + } else { + wash(sk.accent_soft) + }; + let list_selected = if sk.is_light { selected } else { wash(sk.hover_strong) }; + theme.accent = hover; theme.accent_foreground = ink(sk.ink_strong); - theme.list_hover = wash(sk.hover); - theme.list_active = wash(sk.hover_strong); + theme.list_hover = hover; + theme.list_active = list_selected; theme.list_active_border = transparent; theme.sidebar_foreground = ink(sk.ink); - theme.sidebar_accent = wash(sk.accent_soft); + theme.sidebar_accent = selected; theme.sidebar_accent_foreground = ink(sk.ink_strong); theme.tab_foreground = ink(sk.ink_dim); - theme.tab_active = wash(sk.accent_soft); + theme.tab_active = selected; theme.tab_active_foreground = ink(sk.ink_strong); // 按钮。 @@ -594,8 +605,8 @@ fn apply_skin_tokens(chrome: NebulaTheme, cx: &mut App) { theme.primary_active = shift3(soft_accent.r, soft_accent.g, soft_accent.b, 0.18); theme.primary_foreground = ink(sk.ink_on_accent); theme.secondary = wash(sk.surface); - theme.secondary_hover = wash(sk.hover); - theme.secondary_active = wash(sk.hover_strong); + theme.secondary_hover = hover; + theme.secondary_active = list_selected; theme.secondary_foreground = ink(sk.ink); // 语义三色。 diff --git a/nebula_app/src/gpui_shell/toast.rs b/nebula_app/src/gpui_shell/toast.rs index 4b42728..487f384 100644 --- a/nebula_app/src/gpui_shell/toast.rs +++ b/nebula_app/src/gpui_shell/toast.rs @@ -21,7 +21,7 @@ use gpui::{AnyElement, App, IntoElement as _, ParentElement as _, Styled as _, W use gpui_component::notification::Notification; use gpui_component::{Root, WindowExt as _}; -use crate::gpui_shell::prelude::v_flex; +use crate::gpui_shell::prelude::*; pub use crate::display::ToastKind; @@ -46,6 +46,26 @@ const TOAST_MAX_WIDTH: f32 = 440.0; const BANNER_TTL: Duration = Duration::from_secs(90); static LAST_TOAST: Mutex> = Mutex::new(None); +static PANE_BANNERS: Mutex = Mutex::new(PaneBannerGate { recent: Vec::new() }); + +#[derive(Default)] +struct PaneBannerGate { + recent: Vec<(u64, ToastKind, String, Instant)>, +} + +impl PaneBannerGate { + fn accepts(&mut self, pane_id: u64, kind: ToastKind, text: &str, now: Instant) -> bool { + self.recent + .retain(|(_, _, _, born)| now.saturating_duration_since(*born) < DUPLICATE_COOLDOWN); + if self.recent.iter().any(|(pane, previous_kind, previous_text, _)| { + *pane == pane_id && *previous_kind == kind && previous_text == text + }) { + return false; + } + self.recent.push((pane_id, kind, text.to_owned(), now)); + true + } +} fn is_duplicate(text: &str) -> bool { let mut last = LAST_TOAST.lock().unwrap_or_else(|poison| poison.into_inner()); @@ -137,7 +157,131 @@ pub fn banner(window: &mut Window, cx: &mut App, kind: ToastKind, text: impl Int return; } log::warn!("banner [{kind:?}]: {text}"); - let note = note(kind, text).autohide(false); + push_banner(window, cx, note(kind, text)); +} + +pub(crate) fn banner_for_pane( + window: &mut Window, + cx: &mut App, + kind: ToastKind, + text: impl Into, + pane_id: u64, +) { + let text = text.into(); + if text.trim().is_empty() + || !PANE_BANNERS.lock().unwrap_or_else(|poison| poison.into_inner()).accepts( + pane_id, + kind, + &text, + Instant::now(), + ) + { + return; + } + log::info!("pane banner [{kind:?}] pane={pane_id}: {text}"); + let notification = note(kind, text).on_click(move |_, _, cx| { + cx.defer(move |cx| super::workspace::windowing::focus_notification(Some(pane_id), cx)); + }); + push_banner(window, cx, notification); +} + +pub(crate) fn confirmation_for_pane( + window: &mut Window, + cx: &mut App, + text: String, + pane_id: u64, + confirmation: crate::gpui_shell::terminal::confirmation::BinaryConfirmation, +) { + struct ConfirmationNotice; + + let language = + crate::display::LanguagePreference::from(nebula_settings::RuntimeSettings::load().language) + .resolved(); + let allow_label = language.text(crate::i18n::Message::CommonYes).to_owned(); + let deny_label = language.text(crate::i18n::Message::CommonNo).to_owned(); + let request_id = confirmation.id; + let notification = note(ToastKind::Warning, text) + .id1::(format!("pane-{pane_id}-request-{request_id}")) + .on_click(move |_, _, cx| { + cx.defer(move |cx| super::workspace::windowing::focus_notification(Some(pane_id), cx)); + }) + .content(move |_, _, cx| { + v_flex() + .gap_2() + .child(div().text_sm().child(confirmation.question.clone())) + .child( + h_flex() + .gap_2() + .justify_end() + .child( + Button::new(("confirmation-deny", request_id)) + .icon(IconName::Close) + .label(deny_label.clone()) + .ghost() + .small() + .on_click(cx.listener(move |note, _, window, cx| { + cx.stop_propagation(); + let feedback = window.window_handle(); + note.dismiss(window, cx); + cx.defer(move |cx| { + reply_to_confirmation( + pane_id, request_id, false, feedback, cx, + ) + }); + })), + ) + .child( + Button::new(("confirmation-allow", request_id)) + .icon(IconName::Check) + .label(allow_label.clone()) + .primary() + .small() + .on_click(cx.listener(move |note, _, window, cx| { + cx.stop_propagation(); + let feedback = window.window_handle(); + note.dismiss(window, cx); + cx.defer(move |cx| { + reply_to_confirmation( + pane_id, request_id, true, feedback, cx, + ) + }); + })), + ), + ) + .into_any_element() + }); + push_banner(window, cx, notification); +} + +fn reply_to_confirmation( + pane_id: u64, + request_id: u64, + allow: bool, + feedback: gpui::AnyWindowHandle, + cx: &mut App, +) { + super::workspace::windowing::focus_notification(Some(pane_id), cx); + let applied = super::workspace::windowing::notification_view(pane_id, cx).is_some_and(|view| { + view.update(cx, |view, cx| view.answer_confirmation(request_id, allow, cx)) + }); + if !applied { + let _ = feedback.update(cx, |_, window, cx| { + let language = crate::display::LanguagePreference::from( + nebula_settings::RuntimeSettings::load().language, + ) + .resolved(); + toast( + window, + cx, + ToastKind::Warning, + language.text(crate::i18n::Message::CommonConfirmationExpired), + ); + }); + } +} + +fn push_banner(window: &mut Window, cx: &mut App, notification: Notification) { + let note = notification.autohide(false); if window.root::().flatten().is_some() { window.push_notification(note, cx); schedule_banner_dismiss(window, cx); @@ -187,4 +331,18 @@ mod tests { assert!(is_duplicate("toast-dup-gate-a")); assert!(!is_duplicate("toast-dup-gate-b"), "不同文本不受冷却影响"); } + + #[test] + fn pane_banners_keep_distinct_sources_and_attention() { + let now = Instant::now(); + let mut gate = PaneBannerGate::default(); + assert!(gate.accepts(1, ToastKind::Info, "done", now)); + assert!(gate.accepts(2, ToastKind::Info, "done", now)); + assert!(!gate.accepts(1, ToastKind::Info, "done", now)); + assert!(gate.accepts(1, ToastKind::Warning, "done", now)); + assert!(gate.accepts(1, ToastKind::Warning, "permission", now)); + assert!(!gate.accepts(1, ToastKind::Warning, "permission", now)); + assert!(gate.accepts(1, ToastKind::Info, "done", now + DUPLICATE_COOLDOWN)); + assert_eq!(gate.recent.len(), 1); + } } diff --git a/nebula_app/src/gpui_shell/workspace.rs b/nebula_app/src/gpui_shell/workspace.rs index debd3fa..2ad5157 100644 --- a/nebula_app/src/gpui_shell/workspace.rs +++ b/nebula_app/src/gpui_shell/workspace.rs @@ -49,6 +49,7 @@ mod agents; mod command_manager; mod file_tree; mod key_actions; +mod notifications; mod palette; mod pane_header; mod quick_jump; @@ -56,6 +57,8 @@ mod quick_terminal; mod remote_files; mod residency; mod send_to_chat; +mod session_persistence; +mod settings_navigation; mod sidebar; mod ssh_dialog; mod tab_drag; @@ -64,6 +67,7 @@ mod tab_scroll; mod top_tabs; mod update_dialog; mod vcs_panel; +mod window_titlebar; pub(crate) mod windowing; // 调用点分散在设置页与窗口层,原样再导出以免拆分波及它们。 @@ -428,19 +432,12 @@ type SplitBoundsStore = Rc), Bounds>>> type PaneBoundsStore = Rc>>>; fn decode_sidebar_logo( - bytes: &[u8], - tint: Option<([u8; 3], bool)>, + logo: crate::display::AiLogo, + dark: bool, target_size: u32, ) -> Option> { - let mut rgba = image::load_from_memory(bytes).ok()?.into_rgba8(); - if let Some((ink, preserve_luma)) = tint { - for pixel in rgba.chunks_exact_mut(4) { - let luma = if preserve_luma { u16::from(pixel[0]) } else { 255 }; - pixel[0] = (u16::from(ink[0]) * luma / 255) as u8; - pixel[1] = (u16::from(ink[1]) * luma / 255) as u8; - pixel[2] = (u16::from(ink[2]) * luma / 255) as u8; - } - } + let mut rgba = image::load_from_memory(logo.png(dark)).ok()?.into_rgba8(); + logo.tint_pixels(&mut rgba, if dark { [236, 239, 245] } else { [35, 40, 50] }); // 直接复用旧壳的 Lanczos3 物理像素预缩放与 alpha 质量中心校正。 // 先 tint 再缩放,避免 1024px 原图在 GPUI paint 阶段临时压到十几个 // 逻辑像素时产生灰边、锯齿与非整数 DPI 采样。 @@ -464,41 +461,12 @@ fn sidebar_logo_images( use crate::display::AiLogo; let mut images = HashMap::new(); - if let Some(image) = - decode_sidebar_logo(include_bytes!("../../../extra/logo/ai_claude.png"), None, target_size) - { - images.insert((AiLogo::Claude, true), image.clone()); - images.insert((AiLogo::Claude, false), image); - } - for (logo, bytes, preserve_luma) in [ - (AiLogo::OpenAi, include_bytes!("../../../extra/logo/ai_openai.png").as_slice(), false), - (AiLogo::OpenCode, include_bytes!("../../../extra/logo/ai_opencode.png").as_slice(), true), - (AiLogo::Pi, include_bytes!("../../../extra/logo/ai_pi.png").as_slice(), false), - ] { - if let Some(image) = - decode_sidebar_logo(bytes, Some(([236, 239, 245], preserve_luma)), target_size) - { - images.insert((logo, true), image); + for logo in AiLogo::ALL { + for dark in [false, true] { + if let Some(image) = decode_sidebar_logo(logo, dark, target_size) { + images.insert((logo, dark), image); + } } - if let Some(image) = - decode_sidebar_logo(bytes, Some(([35, 40, 50], preserve_luma)), target_size) - { - images.insert((logo, false), image); - } - } - if let Some(image) = decode_sidebar_logo( - include_bytes!("../../../extra/logo/ai_grok_light.png"), - None, - target_size, - ) { - images.insert((AiLogo::Grok, true), image); - } - if let Some(image) = decode_sidebar_logo( - include_bytes!("../../../extra/logo/ai_grok_dark.png"), - None, - target_size, - ) { - images.insert((AiLogo::Grok, false), image); } images } @@ -819,12 +787,8 @@ fn reveal_in_file_manager(path: &Path) { } fn workspace_ui_language() -> crate::display::UiLanguage { - match nebula_settings::RuntimeSettings::load().language { - nebula_settings::LanguagePref::System => crate::display::LanguagePreference::System, - nebula_settings::LanguagePref::ZhCn => crate::display::LanguagePreference::ZhCn, - nebula_settings::LanguagePref::EnUs => crate::display::LanguagePreference::EnUs, - } - .resolved() + crate::display::LanguagePreference::from(nebula_settings::RuntimeSettings::load().language) + .resolved() } fn new_tab_insert_index( @@ -1149,8 +1113,6 @@ pub struct NebulaWorkspace { spinner_frame_pending: std::cell::Cell, /// 上一次 render 是否真的画出了运行态徽章;滚出视口后不应继续唤醒窗口。 spinner_visible: std::cell::Cell, - /// 最近一次写盘的会话快照(1 Hz 自动保存的去重 + 退出收尾的素材)。 - last_saved_session: Option, /// 系统关闭按钮可能连续送来多次 should-close;确认框在场时只保留一份。 window_close_confirm_open: bool, /// `keep_session` 关窗后 HWND 已隐藏、PTY 仍在;托盘 / mux ATTACH 用来捞回。 @@ -1308,7 +1270,6 @@ impl NebulaWorkspace { cx.subscribe_in(&file_tree_search_input, window, Self::on_file_tree_search_event); let sidebar_logo_target_px = (TAB_LABEL_ICON_SIZE * window.scale_factor()).round().max(1.0) as u32; - let process_dispatcher = shell_events.is_some(); let mut this = Self { tabs: Vec::new(), tab_meta: Vec::new(), @@ -1385,7 +1346,6 @@ impl NebulaWorkspace { spinner_last: std::time::Instant::now(), spinner_frame_pending: std::cell::Cell::new(false), spinner_visible: std::cell::Cell::new(false), - last_saved_session: None, window_close_confirm_open: false, window_hidden: false, window_handle: window.window_handle(), @@ -1430,9 +1390,6 @@ impl NebulaWorkspace { if let Some(shell_events) = shell_events { Self::start_shell_event_pump(shell_events, cx); } - if process_dispatcher { - this.start_session_autosave(cx); - } this.apply_custom_keybinds(cx); let workspace = cx.entity().downgrade(); window.on_window_should_close(cx, move |window, cx| { @@ -1653,6 +1610,7 @@ impl NebulaWorkspace { crate::gpui_shell::apply_app_icon(runtime.app_icon, cx); self.sidebar_width = runtime.sidebar_width; self.tabs_position = runtime.tabs_position; + self.sync_settings_layout(); self.sidebar_resizing = false; self.reveal_if_tray_disabled(cx); cx.notify(); @@ -1741,7 +1699,7 @@ impl NebulaWorkspace { cx: &mut Context, ) -> u64 { if self.settings_open { - self.close_settings(window, cx); + self.leave_settings(window, cx); } let shell_tag = Self::launch_shell_tag(&launch_session); let grid = self.inherited_grid(cx); @@ -1790,7 +1748,7 @@ impl NebulaWorkspace { cx: &mut Context, ) { if self.settings_open { - self.close_settings(window, cx); + self.leave_settings(window, cx); } { let mut lists = crate::gpui_shell::ssh_hosts::SshHostLists::load(); @@ -1846,17 +1804,20 @@ impl NebulaWorkspace { ) { let Some(WorkspaceTab::Terminal { panes, .. }) = self.tabs.get(tab_ix) else { return }; let Some(old) = panes.iter().find(|pane| pane.id == pane_id) else { return }; - let grid = { + let (grid, remote_cwd) = { let view = old.view.read(cx); if view.ssh_destination.as_deref() != Some(destination.as_str()) { return; } - (view.grid_cols() as u16, view.grid_rows() as u16) + ( + (view.grid_cols() as u16, view.grid_rows() as u16), + (!view.cwd.is_empty()).then(|| view.cwd.clone()), + ) }; let launch = crate::gpui_shell::terminal::view::TerminalLaunch::Ssh { destination: destination.clone(), - cwd: None, + cwd: remote_cwd, }; let replacement = self.new_pane(grid, launch, None, window, cx); let replacement_id = replacement.id; @@ -2040,11 +2001,13 @@ impl NebulaWorkspace { (0..self.tabs.len()).find_map(|tab_ix| self.busy_process_in_tab(tab_ix, None, cx)) } - fn save_clean_window_session(&mut self, cx: &App) { - let mut snapshot = self.snapshot_session(cx); - crate::session::save_final(&mut snapshot); - // Drop 不再用最多滞后一秒的自动保存副本覆盖刚写下的准确快照。 - self.last_saved_session = None; + fn save_clean_window_session(&mut self, cx: &mut App) { + windowing::save_current_window_session( + self.runtime_window_id, + self.snapshot_session(cx), + session_persistence::SaveReason::WindowClose, + cx, + ); } /// GPUI 的 should-close 回调必须同步返回:无繁忙进程时直接允许系统关闭; @@ -2418,19 +2381,6 @@ impl NebulaWorkspace { Session::new(active_out, tabs) } - /// 1 Hz 自动保存(无变化跳过;崩溃/强杀最多丢一秒)。首笔成功写盘的 - /// 快照 `boot_attempts` 恒为 0,即旧壳「活过第一秒就解除断路器」。 - fn start_session_autosave(&self, cx: &mut Context) { - let executor = cx.background_executor().clone(); - cx.spawn(async move |_this, cx| { - loop { - executor.timer(Duration::from_millis(1000)).await; - cx.update(crate::gpui_shell::workspace::windowing::autosave_tick); - } - }) - .detach(); - } - /// 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. @@ -2449,12 +2399,8 @@ impl NebulaWorkspace { self.settings_surface = Some((view, subscription)); } - self.settings_restore_sidebar_collapsed = - settings_should_fold_sidebar(self.tabs_position).then_some(self.sidebar_collapsed); - if self.settings_restore_sidebar_collapsed == Some(false) { - self.sidebar_collapsed = true; - self.sidebar_fold_armed = true; - } + self.settings_open = true; + self.sync_settings_layout(); self.settings_restore_side_panel_open = self.side_panel.open; if self.side_panel.open { @@ -2463,7 +2409,6 @@ impl NebulaWorkspace { self.file_tree_menu = None; } - self.settings_open = true; 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)); @@ -2475,22 +2420,10 @@ impl NebulaWorkspace { if !self.settings_open { return; } - self.settings_open = false; - - if let Some(collapsed) = self.settings_restore_sidebar_collapsed.take() { - if self.sidebar_collapsed != collapsed { - self.sidebar_collapsed = collapsed; - self.sidebar_fold_armed = true; - } + self.leave_settings(window, cx); + if self.tabs.is_empty() { + windowing::close_empty_workspace_window(self.runtime_window_id, window, cx); } - - let restore_side_panel = std::mem::take(&mut self.settings_restore_side_panel_open); - if restore_side_panel && !self.side_panel.open { - let view = self.side_panel.view; - self.toggle_side_panel(view, cx); - } - self.focus_active(window, cx); - cx.notify(); } fn toggle_settings(&mut self, window: &mut Window, cx: &mut Context) { @@ -2730,23 +2663,22 @@ impl NebulaWorkspace { } }, TerminalViewEvent::AiAttention(attention) => { - if let Some((tab_ix, pane_id)) = self.locate_pane(view.entity_id()) { - if tab_ix != self.active - && let Some(meta) = self.tab_meta.get_mut(tab_ix) - { - meta.has_bell = true; - } - let body = attention.summary_for_pane(pane_id); - crate::gpui_shell::toast::banner( + if let Some((_, pane_id)) = self.locate_pane(view.entity_id()) { + self.deliver_pane_notification( + pane_id, + crate::notify::Notification::AiTurn { + program: attention.source.clone(), + message: Some(attention.summary_for_pane(pane_id)), + attention: true, + }, window, cx, - crate::display::ToastKind::Warning, - format!("{} · {body}", attention.source), ); - if !window.is_window_active() { - crate::notify::toast(&attention.source, &body); - } - cx.notify(); + } + }, + TerminalViewEvent::Notification(notification) => { + if let Some((_, pane_id)) = self.locate_pane(view.entity_id()) { + self.deliver_pane_notification(pane_id, notification.clone(), window, cx); } }, TerminalViewEvent::SelectionContextMenuRequested { position, text } => { @@ -2828,21 +2760,20 @@ impl NebulaWorkspace { } } - if self.tabs.is_empty() { - // 一路关标签关到空 = 正常退出:落一份 clean 空会话(下次启动 - // 走干净路径,不算崩溃)。Drop 兜底不再重写。 - let mut empty = crate::session::Session::new(0, Vec::new()); - crate::session::save_final(&mut empty); - self.last_saved_session = None; + if self.tabs.is_empty() && !self.settings_open { windowing::close_empty_workspace_window(self.runtime_window_id, window, cx); return; } if ix < self.active { self.active -= 1; } - if self.active >= self.tabs.len() { - self.active = self.tabs.len() - 1; - } + self.active = self.active.min(self.tabs.len().saturating_sub(1)); + windowing::save_current_window_session( + self.runtime_window_id, + self.snapshot_session(cx), + session_persistence::SaveReason::TabsClosed, + cx, + ); self.reveal_active_tab(); self.focus_active(window, cx); self.sync_side_panel_to_active(true, cx); @@ -4325,20 +4256,6 @@ impl NebulaWorkspace { } } -impl Drop for NebulaWorkspace { - fn drop(&mut self) { - // 正常退出的最后一笔:把最近的自动保存快照标上 `clean_exit` 落盘 - // (关窗/退出应用都会走到;强杀/断电走不到,崩溃判定据此)。 - // panic 展开也会进 Drop——那是崩溃现场,不能标成正常退出。 - if std::thread::panicking() { - return; - } - if let Some(mut session) = self.last_saved_session.take() { - crate::session::save_final(&mut session); - } - } -} - impl Render for NebulaWorkspace { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { if window.is_window_active() { @@ -4480,6 +4397,9 @@ impl Render for NebulaWorkspace { this.close_active(window, cx); })) .on_action(cx.listener(|this, _: &ToggleSidebar, _, cx| { + if this.settings_open { + return; + } this.sidebar_collapsed = !this.sidebar_collapsed; this.sidebar_fold_armed = true; cx.notify(); @@ -4589,8 +4509,7 @@ impl Render for NebulaWorkspace { // 侧栏模式保留旧壳的侧栏开关与齿轮。组件默认 34px 标题栏会让 // 32px 按钮几乎贴边,因此显式设为 48px、上下各留 8px;右侧 // 窗口控制仍共享同一标题带。 - TitleBar::new() - .h(px(48.0)) + window_titlebar::settings_aware_title_bar(settings_active, cx) .when(top_tabs, |bar| { // 正文卡在顶部模式保留 8px 左缝;标题栏默认 12px, // 这里覆写后首个 tab 才与卡内 powerline 严格同轴。 @@ -4620,7 +4539,7 @@ impl Render for NebulaWorkspace { .flex_row() .flex_1() .min_h_0() - .when(!top_tabs, |row| { + .when(!top_tabs && !settings_active, |row| { row.child(self.render_sidebar_slot(window, cx)).when( // 侧栏拖宽热区(旧壳 `panel_resize` 设置门控):贴在 // 侧栏右缘、零布局宽,不挤压终端卡。 diff --git a/nebula_app/src/gpui_shell/workspace/file_tree.rs b/nebula_app/src/gpui_shell/workspace/file_tree.rs index ce19b9d..f7151c4 100644 --- a/nebula_app/src/gpui_shell/workspace/file_tree.rs +++ b/nebula_app/src/gpui_shell/workspace/file_tree.rs @@ -325,7 +325,7 @@ impl NebulaWorkspace { indexed_count, indexed_more ), - crate::display::UiLanguage::EnUs => format!( + _ => format!( "{} results · {}{} indexed", self.side_panel.file_search_total(), indexed_count, diff --git a/nebula_app/src/gpui_shell/workspace/key_actions.rs b/nebula_app/src/gpui_shell/workspace/key_actions.rs index 74b453e..3d87833 100644 --- a/nebula_app/src/gpui_shell/workspace/key_actions.rs +++ b/nebula_app/src/gpui_shell/workspace/key_actions.rs @@ -25,7 +25,13 @@ impl NebulaWorkspace { if len == 0 { return; } - let ix = if next { (self.active + 1) % len } else { (self.active + len - 1) % len }; + let ix = if self.settings_open { + if next { 0 } else { len - 1 } + } else if next { + (self.active + 1) % len + } else { + (self.active + len - 1) % len + }; self.activate_tab(ix, window, cx); } diff --git a/nebula_app/src/gpui_shell/workspace/notifications.rs b/nebula_app/src/gpui_shell/workspace/notifications.rs new file mode 100644 index 0000000..0e2667c --- /dev/null +++ b/nebula_app/src/gpui_shell/workspace/notifications.rs @@ -0,0 +1,81 @@ +use gpui::{Context, Window}; + +use super::{NebulaWorkspace, WorkspaceTab}; +use crate::notify::Notification; + +fn source_is_visible(window_active: bool, source_active: bool, overlay_open: bool) -> bool { + window_active && source_active && !overlay_open +} + +impl NebulaWorkspace { + pub(super) fn deliver_pane_notification( + &mut self, + pane_id: u64, + notification: Notification, + window: &mut Window, + cx: &mut Context, + ) { + let Some(tab_index) = self.tab_of_pane(pane_id) else { return }; + let Some(WorkspaceTab::Terminal { panes, focused, .. }) = self.tabs.get(tab_index) else { + return; + }; + let reader_open = panes + .iter() + .find(|pane| pane.id == pane_id) + .is_some_and(|pane| pane.view.read(cx).is_reading_answer()); + let visible = source_is_visible( + window.is_window_active() && !self.window_hidden, + tab_index == self.active && *focused == pane_id, + self.settings_open || reader_open, + ); + let attention = notification.is_attention(); + let source_view = + panes.iter().find(|pane| pane.id == pane_id).map(|pane| pane.view.clone()); + let confirmation = if attention { + source_view.and_then(|view| view.update(cx, |view, _| view.capture_confirmation())) + } else { + None + }; + if visible && !attention { + return; + } + if !visible && let Some(meta) = self.tab_meta.get_mut(tab_index) { + meta.has_bell = true; + } + let (title, body) = notification.toast_text(); + let kind = if attention { + crate::display::ToastKind::Warning + } else { + crate::display::ToastKind::Info + }; + let text = format!("{title} \u{b7} {body}"); + if let Some(confirmation) = confirmation { + crate::gpui_shell::toast::confirmation_for_pane( + window, + cx, + text, + pane_id, + confirmation, + ); + } else { + crate::gpui_shell::toast::banner_for_pane(window, cx, kind, text, pane_id); + } + if !visible { + crate::notify::deliver_gpui(¬ification, pane_id); + } + cx.notify(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_the_visible_source_pane_suppresses_completion_notifications() { + assert!(source_is_visible(true, true, false)); + assert!(!source_is_visible(false, true, false)); + assert!(!source_is_visible(true, false, false)); + assert!(!source_is_visible(true, true, true)); + } +} diff --git a/nebula_app/src/gpui_shell/workspace/residency.rs b/nebula_app/src/gpui_shell/workspace/residency.rs index 05ac3ed..5d22639 100644 --- a/nebula_app/src/gpui_shell/workspace/residency.rs +++ b/nebula_app/src/gpui_shell/workspace/residency.rs @@ -75,6 +75,10 @@ impl NebulaWorkspace { for event in events { match event { GpuiShellEvent::TrayFocus(pane) => self.handle_tray_focus(*pane, cx), + GpuiShellEvent::NotificationFocus(pane) => { + let pane = *pane; + cx.defer(move |cx| super::windowing::focus_notification(pane, cx)); + }, GpuiShellEvent::TrayQuit => { self.quit_from_tray(cx); return; @@ -180,9 +184,12 @@ impl NebulaWorkspace { cx, ); } else { - while !self.tabs.is_empty() { - self.close_tab(self.tabs.len() - 1, window, cx); - } + super::windowing::close_saved_workspace_window( + self.runtime_window_id, + self.snapshot_session(cx), + window, + cx, + ); } let snapshot = super::windowing::publish_runtime_snapshot(cx); Ok(json!({ @@ -922,15 +929,7 @@ impl NebulaWorkspace { } fn quit_from_tray(&mut self, cx: &mut Context) { - self.save_clean_window_session(cx); - for tab in &self.tabs { - let WorkspaceTab::Terminal { panes, .. } = tab else { continue }; - for pane in panes { - pane.view.read(cx).shutdown(); - } - } - crate::tray::shutdown(); - cx.quit(); + cx.defer(super::windowing::quit_all); } pub(crate) fn tab_of_pane(&self, pane_id: u64) -> Option { @@ -999,9 +998,12 @@ impl NebulaWorkspace { { return false; } - let snapshot = self.snapshot_session(cx); - crate::session::save(&snapshot); - self.last_saved_session = Some(snapshot); + super::windowing::save_current_window_session( + self.runtime_window_id, + self.snapshot_session(cx), + super::session_persistence::SaveReason::Checkpoint, + cx, + ); crate::gpui_shell::hide_native_window(window); self.window_hidden = true; true diff --git a/nebula_app/src/gpui_shell/workspace/send_to_chat.rs b/nebula_app/src/gpui_shell/workspace/send_to_chat.rs index de074a8..a7fe955 100644 --- a/nebula_app/src/gpui_shell/workspace/send_to_chat.rs +++ b/nebula_app/src/gpui_shell/workspace/send_to_chat.rs @@ -433,12 +433,10 @@ impl NebulaWorkspace { window, cx, crate::display::ToastKind::Info, - match language { - crate::display::UiLanguage::EnUs => { - format!("Sent to {target}") - }, - _ => format!("已发送到 {target}"), - }, + language.format( + crate::i18n::Message::CommonSentTo, + &[("target", &target)], + ), ); true }, diff --git a/nebula_app/src/gpui_shell/workspace/session_persistence.rs b/nebula_app/src/gpui_shell/workspace/session_persistence.rs new file mode 100644 index 0000000..5bdd782 --- /dev/null +++ b/nebula_app/src/gpui_shell/workspace/session_persistence.rs @@ -0,0 +1,242 @@ +use crate::session::Session; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum SaveReason { + Checkpoint, + WindowClose, + TabsClosed, + Quit, +} + +#[derive(Default)] +pub(super) struct SessionPersistence { + latest: Option, + saved: Option, + quitting: bool, +} + +impl SessionPersistence { + pub(super) fn save(&mut self, current: Option, reason: SaveReason) { + self.save_with(current, reason, crate::session::try_save); + } + + fn save_with( + &mut self, + current: Option, + reason: SaveReason, + write: impl FnOnce(&Session) -> std::io::Result<()>, + ) { + let retry_checkpoint = reason == SaveReason::Checkpoint + && current.as_ref().is_none_or(|session| session.tabs.is_empty()); + let candidate = if self.quitting { + if reason != SaveReason::Quit { + return; + } + self.latest.clone() + } else { + match reason { + SaveReason::Checkpoint => { + current.filter(|session| !session.tabs.is_empty()).or_else(|| { + self.latest + .as_ref() + .filter(|latest| self.saved.as_ref() != Some(latest)) + .cloned() + }) + }, + SaveReason::WindowClose | SaveReason::TabsClosed => current, + SaveReason::Quit => current + .filter(|session| !session.tabs.is_empty()) + .or_else(|| self.latest.clone()), + } + }; + self.quitting |= reason == SaveReason::Quit; + let Some(mut session) = candidate else { return }; + if !retry_checkpoint { + session.clean_exit = matches!(reason, SaveReason::WindowClose | SaveReason::Quit); + } + self.latest = Some(session.clone()); + if self.saved.as_ref() == Some(&session) { + return; + } + match write(&session) { + Ok(()) => self.saved = Some(session), + Err(error) => log::warn!("Could not persist terminal session: {error}"), + } + } +} + +pub(super) fn combine_sessions( + sessions: impl IntoIterator, +) -> Option { + let mut combined = None; + for (active, session) in sessions { + let combined = combined.get_or_insert_with(|| Session::new(0, Vec::new())); + if active { + combined.active_tab = combined.tabs.len().saturating_add(session.active_tab); + } + combined.tabs.extend(session.tabs); + } + if let Some(session) = combined.as_mut() { + session.active_tab = session.active_tab.min(session.tabs.len().saturating_sub(1)); + } + combined +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::session::{AgentSession, LayoutSession, TabSession}; + + fn sample_session() -> Session { + let mut tab = TabSession::single("D:/work".into(), Some("Workspace".into()), None); + tab.layout = Some(LayoutSession::Pane { + cwd: tab.cwd.clone(), + agent: Some(AgentSession { + source: "claude".into(), + session_id: Some("saved-42".into()), + }), + }); + Session::new(0, vec![tab]) + } + + fn save_to( + state: &mut SessionPersistence, + path: &std::path::Path, + current: Option, + reason: SaveReason, + ) { + state.save_with(current, reason, |session| crate::session::save_to(path, session)); + } + + #[test] + fn closing_last_window_then_quitting_preserves_tabs_and_ai_identity() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("session.json"); + let expected = sample_session(); + let mut state = SessionPersistence::default(); + save_to(&mut state, &path, Some(expected.clone()), SaveReason::WindowClose); + save_to(&mut state, &path, None, SaveReason::Checkpoint); + save_to(&mut state, &path, None, SaveReason::Quit); + let restored = crate::session::load_from(&path).unwrap(); + assert!(crate::session::should_restore(&restored)); + assert!(restored.clean_exit); + assert_eq!(restored.tabs, expected.tabs); + } + + #[test] + fn process_kill_restores_the_last_checkpoint_without_an_exit_callback() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("session.json"); + let expected = sample_session(); + let mut state = SessionPersistence::default(); + save_to(&mut state, &path, Some(expected.clone()), SaveReason::Checkpoint); + drop(state); + let restored = crate::session::load_from(&path).unwrap(); + assert!(crate::session::should_restore(&restored)); + assert!(crate::session::was_crash(&restored)); + assert_eq!(restored.tabs, expected.tabs); + } + + #[test] + fn teardown_events_cannot_overwrite_the_final_snapshot() { + let mut state = SessionPersistence::default(); + let expected = sample_session(); + state.save_with(Some(expected.clone()), SaveReason::Quit, |_| Ok(())); + for reason in [SaveReason::Checkpoint, SaveReason::TabsClosed, SaveReason::WindowClose] { + state.save_with(Some(Session::new(0, vec![])), reason, |_| { + panic!("teardown must not write a second snapshot") + }); + } + state.save_with(Some(Session::new(0, vec![])), SaveReason::Quit, |_| { + panic!("repeated quit must retain the frozen snapshot") + }); + assert_eq!(state.saved.unwrap().tabs, expected.tabs); + } + + #[test] + fn explicitly_closing_all_tabs_does_not_resurrect_them() { + let mut state = SessionPersistence::default(); + state.save_with(Some(sample_session()), SaveReason::Checkpoint, |_| Ok(())); + state.save_with(Some(Session::new(0, vec![])), SaveReason::TabsClosed, |_| Ok(())); + state.save_with(None, SaveReason::Quit, |_| Ok(())); + let saved = state.saved.unwrap(); + assert!(!crate::session::should_restore(&saved)); + assert!(saved.clean_exit); + } + + #[test] + fn an_empty_startup_or_auxiliary_window_cannot_erase_a_saved_session() { + let mut state = SessionPersistence::default(); + for current in [None, Some(Session::new(0, vec![]))] { + state.save_with(current, SaveReason::Checkpoint, |_| { + panic!("initial empty state must not reach storage") + }); + } + state.save_with(None, SaveReason::Quit, |_| panic!("no snapshot to write")); + } + + #[test] + fn failed_checkpoint_and_final_writes_are_retried() { + let mut state = SessionPersistence::default(); + let session = sample_session(); + let fail = |_: &Session| Err(std::io::Error::other("storage unavailable")); + state.save_with(Some(session.clone()), SaveReason::Checkpoint, fail); + assert!(state.saved.is_none()); + state.save_with(Some(session.clone()), SaveReason::Checkpoint, |_| Ok(())); + assert_eq!(state.saved.as_ref(), Some(&session)); + state.save_with(Some(session.clone()), SaveReason::Quit, fail); + state.save_with(None, SaveReason::Quit, |_| Ok(())); + let saved = state.saved.unwrap(); + assert_eq!(saved.tabs, session.tabs); + assert!(saved.clean_exit); + } + + #[test] + fn unchanged_checkpoints_do_not_rewrite_storage() { + let mut state = SessionPersistence::default(); + state.save_with(Some(sample_session()), SaveReason::Checkpoint, |_| Ok(())); + state.save_with(Some(sample_session()), SaveReason::Checkpoint, |_| { + panic!("unchanged checkpoint must not write") + }); + } + + #[test] + fn failed_window_close_is_retried_even_without_remaining_windows() { + let mut state = SessionPersistence::default(); + state.save_with(Some(sample_session()), SaveReason::WindowClose, |_| { + Err(std::io::Error::other("temporary write failure")) + }); + state.save_with(None, SaveReason::Checkpoint, |session| { + assert!(session.clean_exit); + assert_eq!(session.tabs, sample_session().tabs); + Ok(()) + }); + assert!(state.saved.unwrap().clean_exit); + } + + #[test] + fn failed_explicit_empty_snapshot_is_retried_without_resurrecting_tabs() { + let mut state = SessionPersistence::default(); + state.save_with(Some(sample_session()), SaveReason::Checkpoint, |_| Ok(())); + state.save_with(Some(Session::new(0, vec![])), SaveReason::WindowClose, |_| { + Err(std::io::Error::other("temporary write failure")) + }); + state.save_with(None, SaveReason::Checkpoint, |session| { + assert!(session.tabs.is_empty()); + Ok(()) + }); + assert!(state.saved.unwrap().tabs.is_empty()); + } + + #[test] + fn all_windows_are_combined_with_the_active_tab_offset() { + let first = sample_session(); + let mut second = sample_session(); + second.tabs.push(TabSession::single("D:/other".into(), None, None)); + second.active_tab = 1; + let combined = combine_sessions([(false, first), (true, second)]).unwrap(); + assert_eq!(combined.tabs.len(), 3); + assert_eq!(combined.active_tab, 2); + assert!(combine_sessions([]).is_none()); + } +} diff --git a/nebula_app/src/gpui_shell/workspace/settings_navigation.rs b/nebula_app/src/gpui_shell/workspace/settings_navigation.rs new file mode 100644 index 0000000..bb4d4de --- /dev/null +++ b/nebula_app/src/gpui_shell/workspace/settings_navigation.rs @@ -0,0 +1,141 @@ +use gpui::{App, Context, Window}; +use nebula_settings::TabsPositionName; + +use super::{NebulaWorkspace, SidebarActivity, TabPresentation}; + +fn sidebar_state( + settings_open: bool, + position: TabsPositionName, + collapsed: bool, + restore: Option, +) -> (bool, Option) { + if settings_open && super::settings_should_fold_sidebar(position) { + (true, Some(restore.unwrap_or(collapsed))) + } else { + (restore.unwrap_or(collapsed), None) + } +} + +impl NebulaWorkspace { + pub(super) fn sync_settings_layout(&mut self) { + let (collapsed, restore) = sidebar_state( + self.settings_open, + self.tabs_position, + self.sidebar_collapsed, + self.settings_restore_sidebar_collapsed, + ); + self.settings_restore_sidebar_collapsed = restore; + if self.sidebar_collapsed != collapsed { + self.sidebar_collapsed = collapsed; + self.sidebar_fold_armed = true; + } + if self.settings_open && self.tabs_position == TabsPositionName::Top { + self.top_tabs_scroll.scroll_to_item(self.tabs.len()); + } + } + + pub(super) fn leave_settings(&mut self, window: &mut Window, cx: &mut Context) { + if !self.settings_open { + return; + } + self.settings_open = false; + self.sync_settings_layout(); + let restore_panel = std::mem::take(&mut self.settings_restore_side_panel_open); + if restore_panel && !self.side_panel.open { + self.toggle_side_panel(self.side_panel.view, cx); + } + self.focus_active(window, cx); + cx.notify(); + } + + pub(super) fn top_tab_count(&self) -> usize { + self.tabs.len() + usize::from(self.settings_open) + } + + pub(super) fn top_tab_presentation( + &self, + index: usize, + cx: &App, + dark: bool, + ) -> TabPresentation { + if index < self.tabs.len() { + return self.tab_presentation(index, cx, dark); + } + TabPresentation { + title: super::workspace_ui_language().text(crate::i18n::Message::CommonSettings).into(), + is_settings: true, + activity: SidebarActivity::Idle, + logo_image: None, + program_glyph: None, + shell_tag: None, + color: None, + renaming: None, + pane_count: 0, + } + } + + pub(super) fn activate_top_tab( + &mut self, + index: usize, + window: &mut Window, + cx: &mut Context, + ) { + if self.settings_open && index == self.tabs.len() { + self.open_settings(window, cx); + } else { + self.activate_tab(index, window, cx); + } + } + + pub(super) fn close_top_tab( + &mut self, + index: usize, + window: &mut Window, + cx: &mut Context, + ) { + if self.settings_open && index == self.tabs.len() { + self.close_settings(window, cx); + } else { + self.request_close_tab(index, window, cx); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sidebar_top_sidebar_round_trip_preserves_exclusive_settings() { + let mut state = sidebar_state(true, TabsPositionName::Sidebar, false, None); + assert_eq!(state, (true, Some(false))); + state = sidebar_state(true, TabsPositionName::Top, state.0, state.1); + assert_eq!(state, (false, None)); + state = sidebar_state(true, TabsPositionName::Sidebar, state.0, state.1); + assert_eq!(state, (true, Some(false))); + assert_eq!( + sidebar_state(false, TabsPositionName::Sidebar, state.0, state.1), + (false, None) + ); + } + + #[test] + fn settings_opened_in_top_mode_fold_the_sidebar_after_switching() { + let state = sidebar_state(true, TabsPositionName::Top, false, None); + assert_eq!(state, (false, None)); + assert_eq!( + sidebar_state(true, TabsPositionName::Sidebar, state.0, state.1), + (true, Some(false)) + ); + } + + #[test] + fn initially_collapsed_sidebar_stays_collapsed_after_repeated_switches() { + let mut state = (true, None); + for _ in 0..3 { + state = sidebar_state(true, TabsPositionName::Sidebar, state.0, state.1); + state = sidebar_state(true, TabsPositionName::Top, state.0, state.1); + } + assert_eq!(sidebar_state(false, TabsPositionName::Sidebar, state.0, state.1), (true, None)); + } +} diff --git a/nebula_app/src/gpui_shell/workspace/sidebar.rs b/nebula_app/src/gpui_shell/workspace/sidebar.rs index a98e14b..ac4734e 100644 --- a/nebula_app/src/gpui_shell/workspace/sidebar.rs +++ b/nebula_app/src/gpui_shell/workspace/sidebar.rs @@ -936,6 +936,7 @@ impl NebulaWorkspace { Button::new("toggle-sidebar") .icon(IconName::PanelLeft) .ghost() + .disabled(settings_active) // 侧栏是开关而非一次性动作:展开期间必须持续显示 // 选中底,和旧壳 `left_sidebar_visible()` 同义。 .selected(!self.sidebar_collapsed) @@ -1016,6 +1017,18 @@ impl NebulaWorkspace { /// 吃鼠标事件,标题栏本身的拖窗和双击最大化照旧。 fn render_collapsed_tab_title(&self, cx: &mut Context) -> gpui::Div { let slot = div().relative().flex_1().min_w_0().h_full(); + if self.settings_open { + return slot.child( + div() + .absolute() + .inset_0() + .flex() + .items_center() + .justify_center() + .text_color(cx.theme().foreground) + .child(crate::gpui_shell::config::ui_language(cx).pick("设置", "Settings")), + ); + } if !self.sidebar_collapsed || self.tabs.is_empty() { return slot; } diff --git a/nebula_app/src/gpui_shell/workspace/tab_scroll.rs b/nebula_app/src/gpui_shell/workspace/tab_scroll.rs index daff0aa..1e3b732 100644 --- a/nebula_app/src/gpui_shell/workspace/tab_scroll.rs +++ b/nebula_app/src/gpui_shell/workspace/tab_scroll.rs @@ -122,7 +122,11 @@ impl NebulaWorkspace { let window = self.tabs_window(); let next = reveal_index(self.active, window.scroll, window.show); self.tabs_scroll = clamp_scroll(next, window.max); - self.top_tabs_scroll.scroll_to_item(self.active); + self.top_tabs_scroll.scroll_to_item(if self.settings_open { + self.tabs.len() + } else { + self.active + }); } fn tabs_overlay_bar(&self) -> Option { diff --git a/nebula_app/src/gpui_shell/workspace/top_tabs.rs b/nebula_app/src/gpui_shell/workspace/top_tabs.rs index fe71050..f9968fc 100644 --- a/nebula_app/src/gpui_shell/workspace/top_tabs.rs +++ b/nebula_app/src/gpui_shell/workspace/top_tabs.rs @@ -146,8 +146,8 @@ impl NebulaWorkspace { let label_px = settings.map(|settings| settings.base_font_size_px).unwrap_or(15.0); let tab_capacity_w = (f32::from(window.viewport_size().width) - TOP_TAB_RESERVED_W).max(TOP_TAB_MIN_W); - let tab_w = tab_width(tab_capacity_w, self.tabs.len()); - let strip_w = tab_strip_width(tab_w, self.tabs.len()); + let tab_w = tab_width(tab_capacity_w, self.top_tab_count()); + let strip_w = tab_strip_width(tab_w, self.top_tab_count()); // 溢出时两端各让出一枚翻页按钮。这个反馈是单调的:`tab_w` 已被 // `TOP_TAB_MIN_W` 钳死,扣掉按钮宽只会让溢出更成立,不会在"画了按钮 // → 不溢出了 → 撤掉按钮"之间抖。 @@ -165,9 +165,10 @@ impl NebulaWorkspace { .filter(|drag| drag.active && drag.axis == TabDragAxis::Horizontal) .map(|drag| (drag.source, Self::drag_slot(drag, self.tabs.len()), drag.offset)); let items_running = std::cell::Cell::new(false); - let items = (0..self.tabs.len()) + let items = (0..self.top_tab_count()) .map(|ix| { - let active = ix == self.active; + let settings_navigation = self.settings_open && ix == self.tabs.len(); + let active = settings_navigation || (!self.settings_open && ix == self.active); let TabPresentation { title, is_settings, @@ -178,7 +179,7 @@ impl NebulaWorkspace { color, renaming, pane_count, - } = self.tab_presentation(ix, cx, dark); + } = self.top_tab_presentation(ix, cx, dark); let hover_group: SharedString = format!("top-tab-hover-{ix}").into(); let cross_window_drag = self.cross_window_drag_payload(ix, cx); let status_color = if active { active_fg } else { muted }; @@ -280,12 +281,15 @@ impl NebulaWorkspace { .when(!active && dragged, |item| item.text_color(muted).bg(hover_bg)) .on_click(cx.listener(move |this, _, window, cx| { cx.stop_propagation(); - this.activate_tab(ix, window, cx); + this.activate_top_tab(ix, window, cx); })) .on_mouse_down( MouseButton::Left, cx.listener(move |this, event: &MouseDownEvent, _, cx| { cx.stop_propagation(); + if settings_navigation { + return; + } this.tab_drag = Some(TabDrag { source: ix, cross_window: this.cross_window_drag_payload(ix, cx), @@ -304,18 +308,22 @@ impl NebulaWorkspace { MouseButton::Middle, cx.listener(move |this, _, window, cx| { cx.stop_propagation(); - this.request_close_tab(ix, window, cx); + this.close_top_tab(ix, window, cx); }), ) .on_double_click(cx.listener(move |this, _, window, cx| { cx.stop_propagation(); - this.begin_rename(ix, window, cx); + if !settings_navigation { + this.begin_rename(ix, window, cx); + } })) .on_mouse_down( MouseButton::Right, cx.listener(move |this, event: &MouseDownEvent, window, cx| { cx.stop_propagation(); - this.open_tab_context_menu(ix, event.position, window, cx); + if !settings_navigation { + this.open_tab_context_menu(ix, event.position, window, cx); + } }), ) .when_some(strip, |row, color| { @@ -462,7 +470,7 @@ impl NebulaWorkspace { .items_center() .invisible() .group_hover(hover_group, |slot| slot.visible()) - .when(tab_close_visible, |slot| { + .when(tab_close_visible || settings_navigation, |slot| { slot.child( Button::new(("top-close-tab", ix)) .icon(IconName::Close) @@ -471,7 +479,7 @@ impl NebulaWorkspace { .on_click(cx.listener( move |this, _, window, cx| { cx.stop_propagation(); - this.request_close_tab(ix, window, cx); + this.close_top_tab(ix, window, cx); }, )), ) @@ -755,8 +763,8 @@ impl NebulaWorkspace { } let capacity_w = (f32::from(window.viewport_size().width) - TOP_TAB_RESERVED_W).max(TOP_TAB_MIN_W); - let tab_w = tab_width(capacity_w, self.tabs.len()); - let strip_w = tab_strip_width(tab_w, self.tabs.len()); + let tab_w = tab_width(capacity_w, self.top_tab_count()); + let strip_w = tab_strip_width(tab_w, self.top_tab_count()); if !strip_overflows(strip_w, capacity_w) { return; } diff --git a/nebula_app/src/gpui_shell/workspace/update_dialog.rs b/nebula_app/src/gpui_shell/workspace/update_dialog.rs index 66ee996..b5353e5 100644 --- a/nebula_app/src/gpui_shell/workspace/update_dialog.rs +++ b/nebula_app/src/gpui_shell/workspace/update_dialog.rs @@ -20,15 +20,12 @@ pub(crate) fn show_update_notification( ) { let language = workspace_ui_language(); let title: SharedString = language.pick("发现新版本", "Update available").into(); - let message: SharedString = match language { - crate::display::UiLanguage::ZhCn => { - format!("Nebula v{} 已发布(当前 v{})", result.latest, result.current) - }, - crate::display::UiLanguage::EnUs => { - format!("Nebula v{} is available (current v{})", result.latest, result.current) - }, - } - .into(); + let message: SharedString = language + .format( + crate::i18n::Message::CommonUpdateNotice, + &[("latest", &result.latest), ("current", &result.current)], + ) + .into(); let action_label: SharedString = language.pick("查看更新", "View update").into(); let action_result = result.clone(); let notification = Notification::warning(message) @@ -127,7 +124,7 @@ fn show_download_outcome_notification( format_bytes(bytes) ) }, - crate::display::UiLanguage::EnUs => format!( + _ => format!( "The v{} installer passed SHA-256 verification ({})", downloaded_version, format_bytes(bytes) diff --git a/nebula_app/src/gpui_shell/workspace/vcs_panel.rs b/nebula_app/src/gpui_shell/workspace/vcs_panel.rs index 28fdd13..be8d305 100644 --- a/nebula_app/src/gpui_shell/workspace/vcs_panel.rs +++ b/nebula_app/src/gpui_shell/workspace/vcs_panel.rs @@ -177,11 +177,7 @@ impl NebulaWorkspace { ), ) .child( - div() - .truncate() - .text_xs() - .text_color(muted) - .child(meta), + div().truncate().text_xs().text_color(muted).child(meta), ), ) .into_any_element(), @@ -1172,11 +1168,7 @@ fn git_graph_rows(commits: &[crate::display::side_panel::GitCommit]) -> Vec TitleBar { + TitleBar::new().h(px(48.0)).when(settings_active, |bar| { + bar.border_b_1().border_color(crate::gpui_shell::theme::settings_hairline(cx)) + }) +} diff --git a/nebula_app/src/gpui_shell/workspace/windowing.rs b/nebula_app/src/gpui_shell/workspace/windowing.rs index 3c3886a..f57e69c 100644 --- a/nebula_app/src/gpui_shell/workspace/windowing.rs +++ b/nebula_app/src/gpui_shell/workspace/windowing.rs @@ -8,6 +8,7 @@ use std::cell::RefCell; use std::path::PathBuf; use std::rc::Rc; use std::sync::Arc; +use std::time::Duration; use gpui::{ AnyWindowHandle, App, AppContext as _, Bounds, Context, Entity, Global, IntoElement, @@ -18,6 +19,7 @@ use gpui_component::{ActiveTheme as _, Root, TitleBar}; use nebula_split::{SplitNav, SplitTree}; use serde_json::json; +use super::session_persistence::{SaveReason, SessionPersistence, combine_sessions}; use super::{NebulaWorkspace, TabMeta, WorkspaceTab, dock_tree}; use crate::gpui_shell::GpuiShellEvent; #[cfg(windows)] @@ -116,7 +118,7 @@ pub(crate) struct WindowRegistry { activation_sequence: u64, entries: Vec, runtime_hub: crate::runtime_api::RuntimeHub, - last_saved_session: Option, + session_persistence: SessionPersistence, #[cfg(windows)] quick_terminal: Option, _subscriptions: Vec, @@ -189,7 +191,7 @@ pub(crate) fn initialize(cx: &mut App, runtime_hub: crate::runtime_api::RuntimeH activation_sequence: 1, entries: Vec::new(), runtime_hub, - last_saved_session: None, + session_persistence: SessionPersistence::default(), #[cfg(windows)] quick_terminal: None, _subscriptions: Vec::new(), @@ -215,6 +217,14 @@ pub(crate) fn initialize(cx: &mut App, runtime_hub: crate::runtime_api::RuntimeH cx.global_mut::() ._subscriptions .extend([quit_subscription, closed_subscription]); + + cx.spawn(async move |cx| { + loop { + cx.background_executor().timer(Duration::from_secs(1)).await; + cx.update(autosave_tick); + } + }) + .detach(); } pub(crate) fn open_initial_window( @@ -636,6 +646,7 @@ fn route_entry(command: &RuntimeCommand, cx: &mut App) -> Result, cx: &mut App) { for event in events { match event { + GpuiShellEvent::NotificationFocus(pane_id) => focus_notification(pane_id, cx), GpuiShellEvent::TrayFocus(pane_id) => { let target = pane_id.and_then(|pane_id| entry_with_pane(pane_id, cx)).or_else(|| { @@ -670,9 +681,13 @@ pub(crate) fn dispatch_shell_events(events: Vec, cx: &mut App) { continue; }; let pending = request.clone(); - if entry.handle.update(cx, move |_, window, cx| { - super::ssh_dialog::show(pending, window, cx); - }).is_err() { + if entry + .handle + .update(cx, move |_, window, cx| { + super::ssh_dialog::show(pending, window, cx); + }) + .is_err() + { request.respond(crate::ssh_prompt::PromptResponse::Cancel); } }, @@ -1124,6 +1139,126 @@ pub(crate) fn is_quick_terminal_window(handle: AnyWindowHandle, cx: &App) -> boo } } +fn notification_target( + pane_id: Option, + candidates: impl IntoIterator)>, +) -> Option { + candidates.into_iter().find_map(|(entry, role, panes)| { + let matches = match pane_id { + Some(pane_id) => panes.contains(&pane_id), + None => role == WindowRole::Regular, + }; + matches.then_some(entry) + }) +} + +pub(crate) fn notification_view( + pane_id: u64, + cx: &App, +) -> Option> { + cx.global::().entries.iter().find_map(|entry| { + let workspace = entry.workspace.upgrade()?; + workspace.read(cx).tabs.iter().find_map(|tab| match tab { + WorkspaceTab::Terminal { panes, .. } => { + panes.iter().find(|pane| pane.id == pane_id).map(|pane| pane.view.clone()) + }, + _ => None, + }) + }) +} + +pub(crate) fn focus_notification(pane_id: Option, cx: &mut App) { + prune_entries(cx); + let active_window = cx.active_window(); + let mut entries = cx.global::().entries.clone(); + entries.sort_by_key(|entry| { + std::cmp::Reverse(( + active_window.is_some_and(|handle| handle == entry.handle), + entry.last_activated, + )) + }); + let target = notification_target( + pane_id, + entries.into_iter().filter_map(|entry| { + let workspace = entry.workspace.upgrade()?; + let panes = workspace + .read(cx) + .tabs + .iter() + .filter_map(|tab| match tab { + WorkspaceTab::Terminal { panes, .. } => Some(panes.iter().map(|pane| pane.id)), + _ => None, + }) + .flatten() + .collect(); + let role = entry.role; + Some((entry, role, panes)) + }), + ); + let Some(entry) = target else { return }; + #[cfg(windows)] + if entry.role == WindowRole::QuickTerminal { + let geometry = cx + .global::() + .quick_terminal + .as_ref() + .filter(|quick| quick.runtime_window_id == entry.runtime_window_id) + .map(|quick| quick.geometry); + let Some(geometry) = geometry else { return }; + if !super::quick_terminal::slide_native_window(entry.native_hwnd, geometry, 0.0) { + log::warn!("notification could not reveal quick terminal"); + return; + } + if let Some(quick) = cx.global_mut::().quick_terminal.as_mut() { + quick.target_visible = true; + quick.motion.snap_to(0.0); + quick.motion_clock.reset(); + quick.animation_generation = quick.animation_generation.wrapping_add(1); + } + super::quick_terminal::show_native_window(entry.native_hwnd); + } + let workspace = entry.workspace.clone(); + let _ = entry.handle.update(cx, move |_, window, cx| { + let _ = workspace.update(cx, |workspace, cx| { + let tab_ix = match pane_id { + Some(pane_id) => match workspace.tab_of_pane(pane_id) { + Some(tab_ix) => tab_ix, + None => return, + }, + None => workspace.active, + }; + #[cfg(windows)] + if let Some(hwnd) = native_hwnd(window) { + use windows_sys::Win32::UI::WindowsAndMessaging::{ + IsIconic, SW_RESTORE, ShowWindow, + }; + unsafe { + let hwnd = hwnd as *mut std::ffi::c_void; + if IsIconic(hwnd) != 0 { + ShowWindow(hwnd, SW_RESTORE); + } + } + } + focus_workspace_window(workspace, window); + workspace.activate_tab(tab_ix, window, cx); + if let Some(pane_id) = pane_id { + if let Some(WorkspaceTab::Terminal { zoomed, .. }) = workspace.tabs.get_mut(tab_ix) + { + *zoomed = false; + } + workspace.focus_pane(tab_ix, pane_id, window, cx); + } + if let Some(meta) = workspace.tab_meta.get_mut(tab_ix) { + meta.has_bell = false; + } + workspace.reveal_active_tab(); + workspace.focus_active(window, cx); + workspace.sync_side_panel_to_active(true, cx); + cx.notify(); + }); + }); +} + fn focus_entry(entry: &WindowEntry, pane_id: Option, cx: &mut App) { let workspace = entry.workspace.clone(); let _ = entry.handle.update(cx, move |_, window, cx| { @@ -1202,8 +1337,10 @@ pub(crate) fn autosave_tick(cx: &mut App) { save_combined_session(cx, false); } -fn combined_session(cx: &App) -> crate::session::Session { - let active_handle = cx.active_window(); +fn combined_session( + mut current: Option<(u64, crate::session::Session)>, + cx: &App, +) -> Option { let mut entries = cx .global::() .entries @@ -1212,32 +1349,44 @@ fn combined_session(cx: &App) -> crate::session::Session { .cloned() .collect::>(); entries.sort_by_key(|entry| entry.runtime_window_id); - let mut tabs = Vec::new(); - let mut active_tab = 0usize; + let active_handle = cx + .active_window() + .filter(|handle| entries.iter().any(|entry| entry.handle == *handle)) + .or_else(|| { + entries.iter().max_by_key(|entry| entry.last_activated).map(|entry| entry.handle) + }); + let mut sessions = Vec::new(); for entry in entries { - let Some(workspace) = entry.workspace.upgrade() else { continue }; - let session = workspace.read(cx).snapshot_session(cx); - if active_handle.is_some_and(|handle| handle == entry.handle) { - active_tab = tabs.len().saturating_add(session.active_tab); - } - tabs.extend(session.tabs); + let session = if current.as_ref().is_some_and(|(id, _)| *id == entry.runtime_window_id) { + current.take().expect("current window snapshot exists").1 + } else { + let Some(workspace) = entry.workspace.upgrade() else { continue }; + workspace.read(cx).snapshot_session(cx) + }; + sessions.push((active_handle == Some(entry.handle), session)); } - crate::session::Session::new(active_tab.min(tabs.len().saturating_sub(1)), tabs) + combine_sessions(sessions) } fn save_combined_session(cx: &mut App, clean: bool) { - let mut session = combined_session(cx); - let unchanged = cx - .global::() - .last_saved_session - .as_ref() - .is_some_and(|previous| previous == &session); - if clean { - crate::session::save_final(&mut session); - } else if !unchanged { - crate::session::save(&session); + let session = combined_session(None, cx); + let reason = if clean { SaveReason::Quit } else { SaveReason::Checkpoint }; + cx.global_mut::().session_persistence.save(session, reason); +} + +pub(super) fn save_current_window_session( + runtime_window_id: u64, + session: crate::session::Session, + reason: SaveReason, + cx: &mut App, +) { + if !cx.global::().entries.iter().any(|entry| { + entry.runtime_window_id == runtime_window_id && entry.role == WindowRole::Regular + }) { + return; } - cx.global_mut::().last_saved_session = Some(session); + let session = combined_session(Some((runtime_window_id, session)), cx); + cx.global_mut::().session_persistence.save(session, reason); } pub(crate) fn quit_all(cx: &mut App) { @@ -1319,19 +1468,32 @@ fn unregister(runtime_window_id: u64, cx: &mut App) { } } +pub(super) fn close_saved_workspace_window( + runtime_window_id: u64, + session: crate::session::Session, + window: &mut Window, + cx: &mut App, +) { + save_current_window_session(runtime_window_id, session, SaveReason::WindowClose, cx); + unregister(runtime_window_id, cx); + window.remove_window(); +} + pub(crate) fn close_empty_workspace_window( runtime_window_id: u64, window: &mut Window, cx: &mut App, ) { + let regular = cx.global::().entries.iter().any(|entry| { + entry.runtime_window_id == runtime_window_id && entry.role == WindowRole::Regular + }); unregister(runtime_window_id, cx); - let has_regular_window = - cx.global::().entries.iter().any(|entry| entry.role == WindowRole::Regular); - if !has_regular_window { - let mut empty = crate::session::Session::new(0, Vec::new()); - crate::session::save_final(&mut empty); - } else { - save_combined_session(cx, false); + if regular { + let session = combined_session(None, cx); + let reason = + if session.is_some() { SaveReason::TabsClosed } else { SaveReason::WindowClose }; + let session = session.unwrap_or_else(|| crate::session::Session::new(0, Vec::new())); + cx.global_mut::().session_persistence.save(Some(session), reason); } window.remove_window(); } @@ -1438,10 +1600,6 @@ impl NebulaWorkspace { self.active = self.active.min(self.tabs.len() - 1); } let source_became_empty = self.tabs.is_empty(); - if source_became_empty { - // 目标窗口接管了活体标签;源 workspace Drop 不得把空快照覆盖回去。 - self.last_saved_session = None; - } Some(DetachedTerminalTab { tab, meta, @@ -1603,6 +1761,28 @@ mod tests { use crate::ai_agents::AgentKind; use crate::runtime_api::{RuntimeKey, RuntimeKeyModifiers, RuntimeSplitDirection}; + #[test] + fn notification_target_routes_by_live_pane_not_original_window() { + let windows = + vec![(10, WindowRole::Regular, vec![1, 2]), (20, WindowRole::Regular, vec![3])]; + assert_eq!(notification_target(Some(3), windows.clone()), Some(20)); + assert_eq!(notification_target(Some(99), windows.clone()), None); + assert_eq!(notification_target(None, windows), Some(10)); + let moved = + vec![(10, WindowRole::Regular, vec![1, 2, 3]), (20, WindowRole::Regular, Vec::new())]; + assert_eq!(notification_target(Some(3), moved), Some(10)); + } + + #[cfg(windows)] + #[test] + fn notification_target_includes_quick_terminal_only_for_exact_pane() { + let windows = + vec![(30, WindowRole::QuickTerminal, vec![7]), (10, WindowRole::Regular, vec![1])]; + assert_eq!(notification_target(Some(7), windows.clone()), Some(30)); + assert_eq!(notification_target(None, windows), Some(10)); + assert_eq!(notification_target(Some(9), [(30, WindowRole::QuickTerminal, vec![7])]), None); + } + #[test] fn cross_window_client_position_uses_target_scale_factor() { assert_eq!(logical_client_position(240, 180, 1.5), (160.0, 120.0)); diff --git a/nebula_app/src/i18n/format.rs b/nebula_app/src/i18n/format.rs new file mode 100644 index 0000000..66e5475 --- /dev/null +++ b/nebula_app/src/i18n/format.rs @@ -0,0 +1,48 @@ +pub(super) fn substitute(template: &str, args: &[(&str, &str)]) -> String { + let extra = args.iter().map(|(_, value)| value.len()).sum::(); + let mut output = String::with_capacity(template.len().saturating_add(extra)); + let mut rest = template; + while let Some(position) = rest.find(['{', '}']) { + output.push_str(&rest[..position]); + rest = &rest[position..]; + if rest.starts_with("{{") || rest.starts_with("}}") { + output.push_str(&rest[..1]); + rest = &rest[2..]; + continue; + } + if !rest.starts_with('{') { + output.push('}'); + rest = &rest[1..]; + continue; + } + let Some(end) = rest.find('}') else { break }; + let name = &rest[1..end]; + let replacement = args.iter().find(|(argument, _)| *argument == name); + output.push_str(replacement.map(|(_, value)| *value).unwrap_or(&rest[..end + 1])); + rest = &rest[end + 1..]; + } + output.push_str(rest); + output +} + +#[cfg(test)] +mod tests { + use super::substitute; + + #[test] + fn argument_values_are_never_interpreted_as_more_placeholders() { + assert_eq!( + substitute("{name}: {status}", &[("name", "{status}"), ("status", "200")]), + "{status}: 200" + ); + } + + #[test] + fn preserves_unicode_missing_arguments_and_escaped_braces() { + assert_eq!( + substitute("你好 {name} / {missing} {{code}}", &[("name", "世界")]), + "你好 世界 / {missing} {code}" + ); + assert_eq!(substitute("unfinished {name", &[]), "unfinished {name"); + } +} diff --git a/nebula_app/src/i18n/locale.rs b/nebula_app/src/i18n/locale.rs new file mode 100644 index 0000000..ef0bda4 --- /dev/null +++ b/nebula_app/src/i18n/locale.rs @@ -0,0 +1,21 @@ +pub fn system_locale() -> Option { + native_locale().or_else(|| { + ["LC_ALL", "LC_MESSAGES", "LANG"] + .into_iter() + .find_map(|name| std::env::var(name).ok().filter(|value| !value.is_empty())) + }) +} + +#[cfg(windows)] +fn native_locale() -> Option { + use windows_sys::Win32::Globalization::GetUserDefaultLocaleName; + + let mut buffer = [0_u16; 85]; + let length = unsafe { GetUserDefaultLocaleName(buffer.as_mut_ptr(), buffer.len() as i32) }; + (length > 1).then(|| String::from_utf16(&buffer[..length as usize - 1]).ok()).flatten() +} + +#[cfg(not(windows))] +fn native_locale() -> Option { + None +} diff --git a/nebula_app/src/i18n/mod.rs b/nebula_app/src/i18n/mod.rs new file mode 100644 index 0000000..77840f4 --- /dev/null +++ b/nebula_app/src/i18n/mod.rs @@ -0,0 +1,56 @@ +mod format; +mod locale; + +pub use locale::system_locale; + +include!(concat!(env!("OUT_DIR"), "/translations.rs")); + +impl LanguagePreference { + pub fn parse(value: &str) -> Option { + nebula_settings::LanguagePref::from_settings(value).map(Self::from) + } + + pub const fn as_str(self) -> &'static str { + self.shared().settings_value() + } + + pub fn resolved(self) -> UiLanguage { + self.explicit().unwrap_or_else(|| UiLanguage::for_locale(system_locale().as_deref())) + } +} + +impl UiLanguage { + pub fn for_locale(locale: Option<&str>) -> Self { + locale + .and_then(nebula_settings::LanguagePref::from_locale) + .and_then(|preference| LanguagePreference::from(preference).explicit()) + .unwrap_or(Self::EnUs) + } + + pub const fn text(self, message: Message) -> &'static str { + MESSAGES[self as usize][message as usize] + } + + pub fn pick<'text>(self, zh_cn: &'text str, en_us: &'text str) -> &'text str { + match self { + Self::ZhCn => zh_cn, + Self::EnUs => en_us, + _ => source_id(en_us).map(|message| self.text(message)).unwrap_or(en_us), + } + } + + pub fn tr(self, key: &'static str) -> &'static str { + message_id(key).map(|message| self.text(message)).unwrap_or(key) + } + + pub fn format(self, message: Message, args: &[(&str, &str)]) -> String { + format::substitute(self.text(message), args) + } + + pub fn tr_args(self, key: &'static str, args: &[(&str, &str)]) -> String { + format::substitute(self.tr(key), args) + } +} + +#[cfg(test)] +mod tests; diff --git a/nebula_app/src/i18n/outcomes.rs b/nebula_app/src/i18n/outcomes.rs new file mode 100644 index 0000000..35432fd --- /dev/null +++ b/nebula_app/src/i18n/outcomes.rs @@ -0,0 +1,171 @@ +use crate::i18n::UiLanguage; +use crate::provider_test::ProviderTestOutcome; +use crate::proxy_test::{ProxyTestFailure, ProxyTestOutcome, ProxyTestRoute}; + +impl UiLanguage { + pub fn provider_test_message(self, outcome: &ProviderTestOutcome) -> String { + let status_message = + |key: &'static str, status: u16| self.tr_args(key, &[("status", &status.to_string())]); + match outcome { + ProviderTestOutcome::Success { status } => { + status_message("provider.test.success", *status) + }, + ProviderTestOutcome::InvalidEndpoint => { + self.tr("provider.test.invalid_endpoint").into() + }, + ProviderTestOutcome::MissingModel => self.tr("provider.test.missing_model").into(), + ProviderTestOutcome::MissingApiKey => self.tr("provider.test.missing_api_key").into(), + ProviderTestOutcome::CredentialReadFailed => { + self.tr("provider.test.credential_read_failed").into() + }, + ProviderTestOutcome::InvalidCredentialEncoding => { + self.tr("provider.test.invalid_credential_encoding").into() + }, + ProviderTestOutcome::Timeout => self.tr("provider.test.timeout").into(), + ProviderTestOutcome::HostNotFound => self.tr("provider.test.host_not_found").into(), + ProviderTestOutcome::ConnectionFailed => { + self.tr("provider.test.connection_failed").into() + }, + ProviderTestOutcome::Io { kind } => { + self.tr_args("provider.test.io_error", &[("kind", kind)]) + }, + ProviderTestOutcome::Tls => self.tr("provider.test.tls_failed").into(), + ProviderTestOutcome::RequestFailed => self.tr("provider.test.request_failed").into(), + ProviderTestOutcome::AuthFailed { status } => { + status_message("provider.test.auth_failed", *status) + }, + ProviderTestOutcome::EndpointNotFound { status } => { + status_message("provider.test.endpoint_not_found", *status) + }, + ProviderTestOutcome::RateLimited { status } => { + status_message("provider.test.rate_limited", *status) + }, + ProviderTestOutcome::HttpStatus { status } => { + status_message("provider.test.http_status", *status) + }, + ProviderTestOutcome::StartFailed { error } => { + self.tr_args("provider.test.start_failed", &[("error", error)]) + }, + } + } + + pub fn proxy_test_message(self, outcome: &ProxyTestOutcome, elapsed_ms: u64) -> String { + match outcome { + ProxyTestOutcome::Success(route) => { + let route = self.proxy_test_route(route); + self.tr_args( + "settings.network.status.success", + &[("route", &route), ("elapsed_ms", &elapsed_ms.to_string())], + ) + }, + ProxyTestOutcome::Failed(failure) => self.proxy_test_failure(failure), + } + } + + fn proxy_test_route(self, route: &ProxyTestRoute) -> String { + match route { + ProxyTestRoute::Direct => self.tr("settings.network.route.direct").into(), + ProxyTestRoute::DirectAddress => { + self.tr("settings.network.route.direct_address").into() + }, + ProxyTestRoute::CustomCommand => { + self.tr("settings.network.route.custom_command").into() + }, + ProxyTestRoute::ProxyServer(server) => { + self.tr_args("settings.network.route.proxy_server", &[("server", server)]) + }, + ProxyTestRoute::SshJump(target) => { + self.tr_args("settings.network.route.ssh_jump", &[("target", target)]) + }, + } + } + + fn proxy_test_failure(self, failure: &ProxyTestFailure) -> String { + match failure { + ProxyTestFailure::LoadSettings(error) => { + self.tr_args("settings.network.failure.load_settings", &[("error", error)]) + }, + ProxyTestFailure::InvalidSettings(error) => { + self.tr_args("settings.network.failure.invalid_settings", &[("error", error)]) + }, + ProxyTestFailure::Timeout { seconds } => self + .tr_args("settings.network.failure.timeout", &[("seconds", &seconds.to_string())]), + ProxyTestFailure::SendRequest(error) => { + self.tr_args("settings.network.failure.send_request", &[("error", error)]) + }, + ProxyTestFailure::ReadResponse(error) => { + self.tr_args("settings.network.failure.read_response", &[("error", error)]) + }, + ProxyTestFailure::InvalidHttpStatusLine(line) => { + self.tr_args("settings.network.failure.invalid_http_status_line", &[("line", line)]) + }, + ProxyTestFailure::HttpStatus { status } => self.tr_args( + "settings.network.failure.http_status", + &[("status", &status.to_string())], + ), + ProxyTestFailure::ProxyServer { server, error } => self.tr_args( + "settings.network.failure.proxy_server", + &[("server", server), ("error", error)], + ), + ProxyTestFailure::CustomCommand(error) => { + self.tr_args("settings.network.failure.custom_command", &[("error", error)]) + }, + ProxyTestFailure::JumpTask(error) => { + self.tr_args("settings.network.failure.jump_task", &[("error", error)]) + }, + ProxyTestFailure::JumpResolve { target, error } => self.tr_args( + "settings.network.failure.jump_resolve", + &[("target", target), ("error", error)], + ), + ProxyTestFailure::JumpConnect { target, error } => self.tr_args( + "settings.network.failure.jump_connect", + &[("target", target), ("error", error)], + ), + ProxyTestFailure::JumpChannel { target, error } => self.tr_args( + "settings.network.failure.jump_channel", + &[("target", target), ("error", error)], + ), + ProxyTestFailure::Direct(error) => { + self.tr_args("settings.network.failure.direct", &[("error", error)]) + }, + ProxyTestFailure::Start(error) => { + self.tr_args("settings.network.status.start_failed", &[("error", error)]) + }, + ProxyTestFailure::UnexpectedEnd => { + self.tr("settings.network.status.unexpected_end").into() + }, + } + } +} + +#[cfg(test)] +mod tests { + use super::UiLanguage; + + #[test] + fn provider_outcomes_are_localized_at_the_display_boundary() { + let outcome = crate::provider_test::ProviderTestOutcome::AuthFailed { status: 403 }; + assert_eq!( + UiLanguage::EnUs.provider_test_message(&outcome), + "Authentication failed (HTTP 403); check the API key" + ); + assert_eq!( + UiLanguage::ZhCn.provider_test_message(&outcome), + "鉴权失败(HTTP 403),请检查 API Key" + ); + } + + #[test] + fn proxy_routes_are_data_until_the_display_boundary() { + let outcome = + crate::proxy_test::ProxyTestOutcome::Success(crate::proxy_test::ProxyTestRoute::Direct); + assert_eq!( + UiLanguage::EnUs.proxy_test_message(&outcome, 12), + "Network connection succeeded via Direct connection in 12 ms" + ); + assert_eq!( + UiLanguage::ZhCn.proxy_test_message(&outcome, 12), + "网络连接正常 · 直接连接 · 12 ms" + ); + } +} diff --git a/nebula_app/src/i18n/tests.rs b/nebula_app/src/i18n/tests.rs new file mode 100644 index 0000000..f5edbc7 --- /dev/null +++ b/nebula_app/src/i18n/tests.rs @@ -0,0 +1,83 @@ +use super::{LanguagePreference, Message, UiLanguage}; + +#[test] +fn language_metadata_and_settings_values_remain_in_sync() { + assert_eq!(UiLanguage::ALL.len(), nebula_settings::LanguagePref::LANGUAGES.len()); + for preference in LanguagePreference::ALL { + assert_eq!(LanguagePreference::parse(preference.as_str()), Some(*preference)); + if let Some(language) = preference.explicit() { + assert_eq!(language.code(), preference.as_str()); + assert_eq!(preference.resolved(), language); + } + } +} + +#[test] +fn negotiates_locale_and_falls_back_to_english() { + assert_eq!(UiLanguage::for_locale(Some("fr_CA.UTF-8")), UiLanguage::FrFr); + assert_eq!(UiLanguage::for_locale(Some("zh-Hant-HK")), UiLanguage::ZhTw); + assert_eq!(UiLanguage::for_locale(Some("unsupported")), UiLanguage::EnUs); + assert_eq!(UiLanguage::for_locale(None), UiLanguage::EnUs); +} + +#[test] +fn typed_and_compatibility_lookups_agree() { + assert_eq!(UiLanguage::FrFr.text(Message::SettingsSidebarNetwork), "Réseau"); + assert_eq!(UiLanguage::JaJp.tr("settings.sidebar.network"), "ネットワーク"); + for language in UiLanguage::ALL { + assert_eq!( + language.tr("settings.sidebar.network"), + language.text(Message::SettingsSidebarNetwork) + ); + assert_eq!(language.tr("missing.message.id"), "missing.message.id"); + } +} + +#[test] +fn inline_migration_preserves_bilingual_text_and_english_fallback() { + assert_eq!(UiLanguage::ZhCn.pick("网络", "Network"), "网络"); + assert_eq!(UiLanguage::EnUs.pick("网络", "Network"), "Network"); + assert_eq!(UiLanguage::FrFr.pick("网络", "Network"), "Réseau"); + assert_eq!(UiLanguage::FrFr.pick("未迁移文案", "Unmigrated text"), "Unmigrated text"); +} + +#[test] +fn arguments_are_localized_without_losing_placeholders() { + assert_eq!( + UiLanguage::EnUs.tr_args("provider.test.success", &[("status", "200")]), + "Connection succeeded (HTTP 200)" + ); + assert_eq!( + UiLanguage::FrFr.tr_args("provider.test.success", &[("status", "200")]), + "Connexion réussie (HTTP 200)" + ); + assert_eq!( + UiLanguage::FrFr.format(Message::ProviderTestSuccess, &[("status", "200")]), + "Connexion réussie (HTTP 200)" + ); +} + +#[test] +#[ignore = "manual static translation microbenchmark"] +fn measure_static_catalog_costs() { + use std::hint::black_box; + use std::time::Instant; + + let count = 2_000_000u128; + let started = Instant::now(); + for _ in 0..count { + black_box(black_box(UiLanguage::FrFr).text(black_box(Message::SettingsSidebarNetwork))); + } + eprintln!( + "typed lookup: {} ns/op; {} messages; {} locales; {} translated bytes", + started.elapsed().as_nanos() / count, + super::MESSAGE_COUNT, + UiLanguage::ALL.len(), + super::TRANSLATED_BYTES + ); + let started = Instant::now(); + for _ in 0..count { + black_box(black_box(UiLanguage::FrFr).tr(black_box("settings.sidebar.network"))); + } + eprintln!("key lookup: {} ns/op", started.elapsed().as_nanos() / count); +} diff --git a/nebula_app/src/main.rs b/nebula_app/src/main.rs index f956740..27a47a9 100644 --- a/nebula_app/src/main.rs +++ b/nebula_app/src/main.rs @@ -67,11 +67,14 @@ mod font_install; mod git_worktree; #[cfg(feature = "gpui-shell")] mod gpui_shell; +mod i18n; #[cfg(feature = "legacy-shell")] mod input; #[cfg(all(feature = "gpui-shell", not(feature = "legacy-shell")))] #[path = "product_input.rs"] mod input; +#[path = "i18n/outcomes.rs"] +mod localized_status; mod logging; #[cfg(target_os = "macos")] mod macos; diff --git a/nebula_app/src/notify.rs b/nebula_app/src/notify.rs index f8b0ba8..947340e 100644 --- a/nebula_app/src/notify.rs +++ b/nebula_app/src/notify.rs @@ -23,14 +23,13 @@ //! crash. A small global throttle keeps a bell-happy background job from //! flooding the Action Center. -#[cfg(feature = "legacy-shell")] -use std::sync::{Mutex, OnceLock}; +#[cfg(any(feature = "gpui-shell", test))] +use std::collections::HashMap; +use std::sync::{Arc, Mutex, OnceLock}; use std::time::{Duration, Instant}; #[cfg(feature = "legacy-shell")] use winit::event_loop::EventLoopProxy; -#[cfg(feature = "legacy-shell")] -use winit::window::WindowId; #[cfg(feature = "legacy-shell")] use crate::display::window::Window; @@ -49,6 +48,42 @@ pub fn init_proxy(proxy: EventLoopProxy) { let _ = PROXY.set(proxy); } +pub use crate::platform::notifications::notify_test; +use crate::platform::notifications::{ToastActivation, toast_clickable}; + +#[cfg(feature = "gpui-shell")] +static GPUI_ACTIVATION: OnceLock> = + OnceLock::new(); + +#[cfg(feature = "gpui-shell")] +pub(crate) fn init_gpui_activation( + sender: std::sync::mpsc::Sender, +) { + let _ = GPUI_ACTIVATION.set(sender); +} + +#[cfg(feature = "gpui-shell")] +fn gpui_activation( + sender: std::sync::mpsc::Sender, + pane_id: Option, +) -> ToastActivation { + Arc::new(move || { + let _ = sender.send(crate::gpui_shell::GpuiShellEvent::NotificationFocus(pane_id)); + }) +} + +fn application_activation(pane_id: Option) -> Option { + #[cfg(feature = "gpui-shell")] + { + GPUI_ACTIVATION.get().map(|sender| gpui_activation(sender.clone(), pane_id)) + } + #[cfg(not(feature = "gpui-shell"))] + { + let _ = pane_id; + None + } +} + /// Something that happened in a pane which may deserve attention. #[derive(Debug, Clone)] pub enum Notification { @@ -74,7 +109,7 @@ pub enum Notification { impl Notification { /// Toast title + body. Title names the source ("Nebula" or the program); /// body carries the human detail. - fn toast_text(&self) -> (String, String) { + pub(crate) fn toast_text(&self) -> (String, String) { match self { Self::Bell { program } => match program { Some(p) => (p.clone(), "任务完成,等待输入".to_owned()), @@ -108,6 +143,10 @@ impl Notification { }, } } + + pub(crate) fn is_attention(&self) -> bool { + matches!(self, Self::AiTurn { attention: true, .. }) + } } /// Commands shorter than this never notify: quick `ls`-style commands would @@ -119,6 +158,40 @@ pub const COMMAND_NOTIFY_MIN: Duration = Duration::from_secs(10); /// toast, so a build script ringing BEL in a loop cannot flood Action Center. const TOAST_THROTTLE: Duration = Duration::from_secs(3); +#[cfg(any(feature = "gpui-shell", test))] +#[derive(Default)] +struct PaneNotificationThrottle { + recent: HashMap<(u64, bool), Instant>, +} + +#[cfg(any(feature = "gpui-shell", test))] +impl PaneNotificationThrottle { + fn accepts(&mut self, pane_id: u64, attention: bool, now: Instant) -> bool { + self.recent.retain(|_, last| now.saturating_duration_since(*last) < TOAST_THROTTLE); + if self.recent.contains_key(&(pane_id, attention)) { + return false; + } + self.recent.insert((pane_id, attention), now); + true + } +} + +#[cfg(feature = "gpui-shell")] +pub(crate) fn deliver_gpui(notification: &Notification, pane_id: u64) { + static THROTTLE: OnceLock> = OnceLock::new(); + let accepted = THROTTLE + .get_or_init(Mutex::default) + .lock() + .unwrap_or_else(|poison| poison.into_inner()) + .accepts(pane_id, notification.is_attention(), Instant::now()); + if !accepted { + log::debug!("notify: toast suppressed for pane={pane_id}: {notification:?}"); + return; + } + let (title, body) = notification.toast_text(); + spawn_toast(title, body, application_activation(Some(pane_id))); +} + /// Deliver `notification` for a window that is currently unfocused. /// /// Policy lives at the call sites: they only call this when the window is @@ -140,18 +213,18 @@ pub fn deliver(window: &Window, notification: &Notification, pane: Option) } let (title, body) = notification.toast_text(); - let focus = (window.id(), pane); + let window_id = window.id(); + let activation = PROXY.get().map(|proxy| { + let proxy = proxy.clone(); + Arc::new(move || { + let _ = proxy.send_event(Event::new(EventType::FocusWindow { pane }, window_id)); + }) as ToastActivation + }); log::debug!("notify: toast '{title}': '{body}'"); // 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. - if let Err(err) = std::thread::Builder::new() - .name("nebula-toast".into()) - .spawn(move || toast_clickable(&title, &body, Some(focus))) - { - // Taskbar flash already fired, so the user is not left with nothing. - log::warn!("notify: failed to spawn toast thread: {err}"); - } + spawn_toast(title, body, activation); } /// Global toast rate limit. Returns true when this one should be dropped. @@ -172,225 +245,77 @@ fn throttled() -> bool { /// Raise a native system toast. Best-effort: any failure is logged and /// swallowed (the taskbar flash already fired, so the user is not left with -/// nothing). Runs on the toast worker thread, never on the event loop. -#[cfg(all(windows, feature = "legacy-shell"))] +/// nothing). Native delivery runs on a worker thread, never on the event loop. pub(crate) fn toast(title: &str, body: &str) { - toast_clickable(title, body, None); + spawn_toast(title.to_owned(), body.to_owned(), application_activation(None)); } -#[cfg(all(windows, not(feature = "legacy-shell")))] -pub(crate) fn toast(title: &str, body: &str) { - toast_clickable(title, body); -} - -/// [`toast`], optionally wired for click-to-focus: activating the banner (or -/// its Action Center entry) surfaces `window` and, when a pane is named, its -/// tab. Uses the in-process WinRT Activated handler — no COM server, no -/// protocol registration. The one trade-off: clicks after Nebula exited do -/// nothing, which is exactly right (there is nothing left to focus). -#[cfg(windows)] -fn toast_clickable( - title: &str, - body: &str, - #[cfg(feature = "legacy-shell")] focus: Option<(WindowId, Option)>, -) { - use tauri_winrt_notification::{IconCrop, Toast}; - - // Attribute the toast to the Nebula AUMID so it reads "Nebula" instead of - // "Windows PowerShell". One registry write, cached per process. - win::ensure_aumid(); - - let mut toast = Toast::new(win::AUMID) - .title(title) - .text1(body) - .duration(tauri_winrt_notification::Duration::Short); - // Belt and braces: besides the AUMID IconUri (which some Windows builds - // cache stale), embed the logo per-toast as appLogoOverride so the banner - // always carries the Nebula mark next to the message. - if let Some(icon) = win::icon_path() { - toast = toast.icon(&icon, IconCrop::Square, crate::brand::NAME); - } - #[cfg(feature = "legacy-shell")] - if let Some((window, pane)) = focus { - if let Some(proxy) = PROXY.get() { - let proxy = proxy.clone(); - toast = toast.on_activated(move |_action| { - let _ = proxy.send_event(Event::new(EventType::FocusWindow { pane }, window)); - Ok(()) - }); - } - } - - match toast.show() { - Ok(()) => log::debug!("notify: toast shown"), - Err(err) => log::warn!("notify: toast failed: {err}"), +fn spawn_toast(title: String, body: String, activation: Option) { + if let Err(error) = std::thread::Builder::new() + .name("nebula-toast".into()) + .spawn(move || toast_clickable(&title, &body, activation)) + { + log::warn!("notify: failed to spawn toast thread: {error}"); } } -#[cfg(not(windows))] -pub(crate) fn toast(title: &str, body: &str) { - crate::platform::notifications::show(title, body); -} +#[cfg(test)] +mod delivery_tests { + use super::*; -#[cfg(all(not(windows), feature = "legacy-shell"))] -fn toast_clickable(title: &str, body: &str, _focus: Option<(WindowId, Option)>) { - toast(title, body); -} - -/// `nebula notify-test` entrypoint: run the full toast pipeline synchronously -/// (registration + show), printing per-step diagnostics to the console. Skips -/// the focus policy and throttle on purpose — the tester is looking at the -/// screen. Returns a process exit code. -#[cfg(windows)] -pub fn notify_test() -> i32 { - println!("[1/2] Registering AUMID '{}' ...", win::AUMID); - match win::register_aumid() { - Ok(()) => { - println!(r" OK (HKCU\Software\Classes\AppUserModelId\{})", win::AUMID); - match win::icon_path() { - Some(path) => println!(" icon: {}", path.display()), - None => println!(" icon: unavailable (banner will have no logo)"), - } - }, - Err(err) => { - eprintln!(" FAILED: {err}"); - return 1; - }, + #[test] + fn pane_throttle_does_not_suppress_another_pane_or_attention() { + let now = Instant::now(); + let mut throttle = PaneNotificationThrottle::default(); + assert!(throttle.accepts(1, false, now)); + assert!(throttle.accepts(2, false, now)); + assert!(!throttle.accepts(1, false, now)); + assert!(throttle.accepts(1, true, now)); + assert!(!throttle.accepts(1, true, now)); + assert!(throttle.accepts(2, true, now)); } - println!("[2/2] Showing toast ..."); - let mut toast = tauri_winrt_notification::Toast::new(win::AUMID) - .title(crate::brand::NAME) - .text1("通知链路正常:nebula notify-test") - .duration(tauri_winrt_notification::Duration::Short); - if let Some(icon) = win::icon_path() { - toast = toast.icon(&icon, tauri_winrt_notification::IconCrop::Square, crate::brand::NAME); - } - match toast.show() { - Ok(()) => { - println!(" OK — a toast should be on screen now."); - println!(); - println!("If nothing appeared, check Windows Settings > System > Notifications:"); - println!( - "the global toggle, Do Not Disturb / Focus Assist, and the {} entry.", - crate::brand::NAME - ); - 0 - }, - Err(err) => { - eprintln!(" FAILED: {err}"); - 1 - }, - } -} - -#[cfg(not(windows))] -pub fn notify_test() -> i32 { - println!("notify-test: system toasts are only implemented on Windows."); - 0 -} - -/// Windows-only: the Nebula AppUserModelID and its registration. -/// -/// A WinRT toast must be attributed to an AUMID that Windows can resolve to -/// an app identity, or it silently refuses to show. For an unpackaged app the -/// documented lightweight route is a registry key — -/// `HKCU\Software\Classes\AppUserModelId\` with a `DisplayName` value -/// (what Microsoft's own ToastNotificationManagerCompat writes). Per-user, no -/// admin rights, no COM, no Start-menu shortcut, and idempotent: rewriting -/// the same value is a cheap no-op, so a broken key self-heals on next run. -#[cfg(windows)] -mod win { - use std::path::PathBuf; - use std::sync::Mutex; - - use windows_sys::Win32::Foundation::ERROR_SUCCESS; - use windows_sys::Win32::System::Registry::{HKEY_CURRENT_USER, REG_SZ, RegSetKeyValueW}; - - /// AppUserModelID for Nebula. Toast notifications fire under this identity - /// so the system shows "Nebula" instead of "PowerShell" / "cmd.exe". - pub const AUMID: &str = "com.nebula.terminal"; - - /// Ensure the AUMID is registered. Best-effort, cached per process: the - /// write itself is a few syscalls, there is just no point repeating them - /// for every toast. - pub fn ensure_aumid() { - static REGISTERED: Mutex> = Mutex::new(None); - let variant = crate::app_icon::selected(); - let mut registered = REGISTERED.lock().unwrap_or_else(|error| error.into_inner()); - if *registered != Some(variant) { - if let Err(err) = register_icon(variant) { - log::warn!("notify: AUMID registration failed (toast may not appear): {err}"); - } else { - *registered = Some(variant); - } - } + #[test] + fn pane_throttle_expires_without_extending_on_repeats() { + let now = Instant::now(); + let mut throttle = PaneNotificationThrottle::default(); + assert!(throttle.accepts(1, false, now)); + assert!(!throttle.accepts(1, false, now + Duration::from_secs(2))); + assert!(throttle.accepts(1, false, now + TOAST_THROTTLE)); + assert_eq!(throttle.recent.len(), 1); } - /// Write `DisplayName` + `IconUri` under the AUMID key. `RegSetKeyValueW` - /// creates the missing subkey chain itself. The icon is best-effort: a - /// failed write only costs the logo, never the toast. - pub fn register_aumid() -> Result<(), String> { - register_icon(crate::app_icon::selected()) - } - - fn register_icon(variant: nebula_settings::AppIconName) -> Result<(), String> { - let subkey = format!(r"Software\Classes\AppUserModelId\{AUMID}"); - set_reg_sz(&subkey, "DisplayName", crate::brand::NAME)?; - match ensure_icon_file(variant) { - Some(icon) => set_reg_sz(&subkey, "IconUri", &icon.display().to_string())?, - None => log::debug!("notify: toast icon not materialized; banner shows no logo"), - } - Ok(()) - } - - /// The materialized icon path, for diagnostics (`nebula notify-test`). - pub fn icon_path() -> Option { - ensure_icon_file(crate::app_icon::selected()) - } - - fn ensure_icon_file(variant: nebula_settings::AppIconName) -> Option { - let bytes = crate::app_icon::png(variant, 256)?; - let directory = crate::platform::dirs::data_dir(); - let path = directory.join(format!("toast_icon-{}.png", variant.settings_value())); - let stale = std::fs::read(&path).ok().as_deref() != Some(bytes.as_ref()); - if stale { - std::fs::create_dir_all(directory).ok()?; - std::fs::write(&path, bytes).ok()?; - } - Some(path) - } - - /// Set one REG_SZ value under HKCU\`subkey`, creating the key as needed. - fn set_reg_sz(subkey: &str, name: &str, data: &str) -> Result<(), String> { - let subkey_w = to_wide(subkey); - let name_w = to_wide(name); - let data_w = to_wide(data); - let data_bytes = (data_w.len() * std::mem::size_of::()) as u32; - - // SAFETY: every pointer references a live, NUL-terminated UTF-16 - // buffer owned by this frame; RegSetKeyValueW copies the data before - // returning and creates intermediate keys as needed. - let status = unsafe { - RegSetKeyValueW( - HKEY_CURRENT_USER, - subkey_w.as_ptr(), - name_w.as_ptr(), - REG_SZ, - data_w.as_ptr().cast(), - data_bytes, - ) + #[test] + fn attention_importance_is_independent_of_completion_text() { + let done = + Notification::AiTurn { program: "codex".to_owned(), message: None, attention: false }; + let waiting = Notification::AiTurn { + program: "claude".to_owned(), + message: Some("Confirm command".to_owned()), + attention: true, }; - - if status == ERROR_SUCCESS { - Ok(()) - } else { - Err(format!("RegSetKeyValueW({name}) failed with status {status}")) - } + assert!(!done.is_attention()); + assert!(waiting.is_attention()); + assert_eq!(waiting.toast_text(), ("claude".to_owned(), "Confirm command".to_owned())); + assert!(!Notification::Bell { program: None }.is_attention()); + assert!( + !Notification::Text { body: "permission".to_owned(), program: None }.is_attention() + ); } - /// NUL-terminated UTF-16 for Win32 wide-string APIs. - fn to_wide(s: &str) -> Vec { - s.encode_utf16().chain(Some(0)).collect() + #[cfg(feature = "gpui-shell")] + #[test] + fn activation_preserves_exact_pane_and_generic_application_targets() { + use crate::gpui_shell::GpuiShellEvent; + + let (sender, receiver) = std::sync::mpsc::channel(); + let pane_activation = gpui_activation(sender.clone(), Some(42)); + let generic_activation = gpui_activation(sender, None); + pane_activation(); + generic_activation(); + assert!(matches!(receiver.try_recv(), Ok(GpuiShellEvent::NotificationFocus(Some(42))))); + assert!(matches!(receiver.try_recv(), Ok(GpuiShellEvent::NotificationFocus(None)))); + drop(receiver); + pane_activation(); } } diff --git a/nebula_app/src/platform/keyboard.rs b/nebula_app/src/platform/keyboard.rs new file mode 100644 index 0000000..917c1b3 --- /dev/null +++ b/nebula_app/src/platform/keyboard.rs @@ -0,0 +1,29 @@ +//! Native character data discarded by the GPUI keyboard adapter. + +/// Recover Return's native character without changing keyboard composition state. +/// GPUI filters control text out of `key_char`, including Ctrl+Enter's LF. +pub(crate) fn enter_character(scan_code: u32, shift: bool, control: bool, alt: bool) -> u16 { + use windows_sys::Win32::UI::Input::KeyboardAndMouse::{ + ToUnicode, VK_CONTROL, VK_MENU, VK_RETURN, VK_SHIFT, + }; + + let mut state = [0_u8; 256]; + state[VK_SHIFT as usize] = if shift { 0x80 } else { 0 }; + state[VK_CONTROL as usize] = if control { 0x80 } else { 0 }; + state[VK_MENU as usize] = if alt { 0x80 } else { 0 }; + let mut text = [0_u16; 2]; + // SAFETY: both buffers have the lengths required by ToUnicode. Flag 0x4 + // prevents dead-key state mutation; 0x1 selects menu translation semantics. + let count = unsafe { + ToUnicode( + u32::from(VK_RETURN), + scan_code, + state.as_ptr(), + text.as_mut_ptr(), + text.len() as i32, + 0x5, + ) + }; + // Zero is a valid absence of text. Never fabricate a partial UTF-16 unit. + if count == 1 { text[0] } else { 0 } +} diff --git a/nebula_app/src/platform/mod.rs b/nebula_app/src/platform/mod.rs index 4ce699e..0eb9ceb 100644 --- a/nebula_app/src/platform/mod.rs +++ b/nebula_app/src/platform/mod.rs @@ -15,6 +15,8 @@ pub mod dirs; pub mod file_manager; #[cfg(feature = "gpui-shell")] pub mod fonts; +#[cfg(all(windows, feature = "gpui-shell"))] +pub(crate) mod keyboard; pub mod notifications; pub mod shell; pub mod shell_integration; diff --git a/nebula_app/src/platform/notifications.rs b/nebula_app/src/platform/notifications.rs index 89ca2d3..edbef1d 100644 --- a/nebula_app/src/platform/notifications.rs +++ b/nebula_app/src/platform/notifications.rs @@ -1,3 +1,8 @@ +//! Native notification registration, delivery, and activation callbacks. +//! Attention policy and throttling belong to `crate::notify`. + +pub(crate) type ToastActivation = std::sync::Arc; + #[cfg(target_os = "macos")] static MACOS_READY: std::sync::OnceLock = std::sync::OnceLock::new(); @@ -24,10 +29,250 @@ pub fn show(title: &str, body: &str) { let title = title.to_owned(); let body = body.to_owned(); let _ = std::thread::Builder::new().name("nebula-notify".into()).spawn(move || { - if let Err(error) = - notify_rust::Notification::new().appname("Nebula").summary(&title).body(&body).show() + if let Err(error) = notify_rust::Notification::new() + .appname(crate::brand::NAME) + .summary(&title) + .body(&body) + .show() { log::warn!("System notification unavailable: {error}"); } }); } + +/// [`toast`], optionally wired for click-to-focus: activating the banner (or +/// its Action Center entry) surfaces `window` and, when a pane is named, its +/// tab. Uses the in-process WinRT Activated handler — no COM server, no +/// protocol registration. The one trade-off: clicks after Nebula exited do +/// nothing, which is exactly right (there is nothing left to focus). +#[cfg(windows)] +pub(crate) fn toast_clickable(title: &str, body: &str, activation: Option) { + use tauri_winrt_notification::{IconCrop, Toast}; + + // Attribute the toast to the Nebula AUMID so it reads "Nebula" instead of + // "Windows PowerShell". One registry write, cached per process. + win::ensure_aumid(); + + let mut toast = Toast::new(win::AUMID) + .title(title) + .text1(body) + .duration(tauri_winrt_notification::Duration::Short); + // Belt and braces: besides the AUMID IconUri (which some Windows builds + // cache stale), embed the logo per-toast as appLogoOverride so the banner + // always carries the Nebula mark next to the message. + if let Some(icon) = win::icon_path() { + toast = toast.icon(&icon, IconCrop::Square, crate::brand::NAME); + } + if let Some(activation) = activation { + toast = toast.on_activated(move |_action| { + activation(); + Ok(()) + }); + } + + match toast.show() { + Ok(()) => log::debug!("notify: toast shown"), + Err(err) => log::warn!("notify: toast failed: {err}"), + } +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +pub(crate) fn toast_clickable(title: &str, body: &str, activation: Option) { + #[cfg(target_os = "macos")] + { + if objc2_foundation::NSBundle::mainBundle().bundleIdentifier().is_none() { + log::warn!("System notifications require a registered Nebula application bundle"); + return; + } + crate::platform::notifications::prepare(); + } + let mut notification = notify_rust::Notification::new(); + notification.appname(crate::brand::NAME).summary(title).body(body); + if activation.is_some() { + let language = crate::i18n::LanguagePreference::from( + nebula_settings::RuntimeSettings::load().language, + ) + .resolved(); + notification.action("default", language.text(crate::i18n::Message::CommonOpen)); + } + match notification.show() { + Ok(handle) => { + if let Some(activation) = activation { + let result = handle.wait_for_response(move |response: ¬ify_rust::NotificationResponse| { + if response.is_default_action() + || matches!(response, notify_rust::NotificationResponse::Action(action) if action == "default") + { + activation(); + } + }); + if let Err(error) = result { + log::warn!("notify: activation listener failed: {error}"); + } + } + }, + Err(error) => log::warn!("notify: toast failed: {error}"), + } +} + +#[cfg(not(any(windows, target_os = "linux", target_os = "macos")))] +pub(crate) fn toast_clickable(title: &str, body: &str, _activation: Option) { + crate::platform::notifications::show(title, body); +} + +/// `nebula notify-test` entrypoint: run the full toast pipeline synchronously +/// (registration + show), printing per-step diagnostics to the console. Skips +/// the focus policy and throttle on purpose — the tester is looking at the +/// screen. Returns a process exit code. +#[cfg(windows)] +pub fn notify_test() -> i32 { + println!("[1/2] Registering AUMID '{}' ...", win::AUMID); + match win::register_aumid() { + Ok(()) => { + println!(r" OK (HKCU\Software\Classes\AppUserModelId\{})", win::AUMID); + match win::icon_path() { + Some(path) => println!(" icon: {}", path.display()), + None => println!(" icon: unavailable (banner will have no logo)"), + } + }, + Err(err) => { + eprintln!(" FAILED: {err}"); + return 1; + }, + } + + println!("[2/2] Showing toast ..."); + let mut toast = tauri_winrt_notification::Toast::new(win::AUMID) + .title(crate::brand::NAME) + .text1("通知链路正常:nebula notify-test") + .duration(tauri_winrt_notification::Duration::Short); + if let Some(icon) = win::icon_path() { + toast = toast.icon(&icon, tauri_winrt_notification::IconCrop::Square, crate::brand::NAME); + } + match toast.show() { + Ok(()) => { + println!(" OK — a toast should be on screen now."); + println!(); + println!("If nothing appeared, check Windows Settings > System > Notifications:"); + println!( + "the global toggle, Do Not Disturb / Focus Assist, and the {} entry.", + crate::brand::NAME + ); + 0 + }, + Err(err) => { + eprintln!(" FAILED: {err}"); + 1 + }, + } +} + +#[cfg(not(windows))] +pub fn notify_test() -> i32 { + println!("notify-test: system toasts are only implemented on Windows."); + 0 +} + +/// Windows-only: the Nebula AppUserModelID and its registration. +/// +/// A WinRT toast must be attributed to an AUMID that Windows can resolve to +/// an app identity, or it silently refuses to show. For an unpackaged app the +/// documented lightweight route is a registry key — +/// `HKCU\Software\Classes\AppUserModelId\` with a `DisplayName` value +/// (what Microsoft's own ToastNotificationManagerCompat writes). Per-user, no +/// admin rights, no COM, no Start-menu shortcut, and idempotent: rewriting +/// the same value is a cheap no-op, so a broken key self-heals on next run. +#[cfg(windows)] +mod win { + use std::path::PathBuf; + use std::sync::Mutex; + + use windows_sys::Win32::Foundation::ERROR_SUCCESS; + use windows_sys::Win32::System::Registry::{HKEY_CURRENT_USER, REG_SZ, RegSetKeyValueW}; + + /// AppUserModelID for Nebula. Toast notifications fire under this identity + /// so the system shows "Nebula" instead of "PowerShell" / "cmd.exe". + pub const AUMID: &str = "com.nebula.terminal"; + + /// Ensure the AUMID is registered. Best-effort, cached per process: the + /// write itself is a few syscalls, there is just no point repeating them + /// for every toast. + pub fn ensure_aumid() { + static REGISTERED: Mutex> = Mutex::new(None); + let variant = crate::app_icon::selected(); + let mut registered = REGISTERED.lock().unwrap_or_else(|error| error.into_inner()); + if *registered != Some(variant) { + if let Err(err) = register_icon(variant) { + log::warn!("notify: AUMID registration failed (toast may not appear): {err}"); + } else { + *registered = Some(variant); + } + } + } + + /// Write `DisplayName` + `IconUri` under the AUMID key. `RegSetKeyValueW` + /// creates the missing subkey chain itself. The icon is best-effort: a + /// failed write only costs the logo, never the toast. + pub fn register_aumid() -> Result<(), String> { + register_icon(crate::app_icon::selected()) + } + + fn register_icon(variant: nebula_settings::AppIconName) -> Result<(), String> { + let subkey = format!(r"Software\Classes\AppUserModelId\{AUMID}"); + set_reg_sz(&subkey, "DisplayName", crate::brand::NAME)?; + match ensure_icon_file(variant) { + Some(icon) => set_reg_sz(&subkey, "IconUri", &icon.display().to_string())?, + None => log::debug!("notify: toast icon not materialized; banner shows no logo"), + } + Ok(()) + } + + /// The materialized icon path, for diagnostics (`nebula notify-test`). + pub fn icon_path() -> Option { + ensure_icon_file(crate::app_icon::selected()) + } + + fn ensure_icon_file(variant: nebula_settings::AppIconName) -> Option { + let bytes = crate::app_icon::png(variant, 256)?; + let directory = crate::platform::dirs::data_dir(); + let path = directory.join(format!("toast_icon-{}.png", variant.settings_value())); + let stale = std::fs::read(&path).ok().as_deref() != Some(bytes.as_ref()); + if stale { + std::fs::create_dir_all(directory).ok()?; + std::fs::write(&path, bytes).ok()?; + } + Some(path) + } + + /// Set one REG_SZ value under HKCU\`subkey`, creating the key as needed. + fn set_reg_sz(subkey: &str, name: &str, data: &str) -> Result<(), String> { + let subkey_w = to_wide(subkey); + let name_w = to_wide(name); + let data_w = to_wide(data); + let data_bytes = (data_w.len() * std::mem::size_of::()) as u32; + + // SAFETY: every pointer references a live, NUL-terminated UTF-16 + // buffer owned by this frame; RegSetKeyValueW copies the data before + // returning and creates intermediate keys as needed. + let status = unsafe { + RegSetKeyValueW( + HKEY_CURRENT_USER, + subkey_w.as_ptr(), + name_w.as_ptr(), + REG_SZ, + data_w.as_ptr().cast(), + data_bytes, + ) + }; + + if status == ERROR_SUCCESS { + Ok(()) + } else { + Err(format!("RegSetKeyValueW({name}) failed with status {status}")) + } + } + + /// NUL-terminated UTF-16 for Win32 wide-string APIs. + fn to_wide(s: &str) -> Vec { + s.encode_utf16().chain(Some(0)).collect() + } +} diff --git a/nebula_app/src/platform/shell.rs b/nebula_app/src/platform/shell.rs index 2380e2f..b9af1b8 100644 --- a/nebula_app/src/platform/shell.rs +++ b/nebula_app/src/platform/shell.rs @@ -17,7 +17,8 @@ pub fn default_shell_id() -> String { #[cfg(unix)] { let shell = nebula_terminal::tty::default_shell_program().unwrap_or_default(); - Path::new(&shell).file_name() + Path::new(&shell) + .file_name() .and_then(|name| name.to_str()) .filter(|name| !name.is_empty()) .unwrap_or(default_unix_shell_id()) diff --git a/nebula_app/src/product_ui/mod.rs b/nebula_app/src/product_ui/mod.rs index c50b50b..b11f7f7 100644 --- a/nebula_app/src/product_ui/mod.rs +++ b/nebula_app/src/product_ui/mod.rs @@ -24,8 +24,6 @@ pub(crate) mod file_dialog; mod file_operations; #[path = "../display/hint.rs"] pub(crate) mod hint; -#[path = "../display/i18n.rs"] -mod i18n; #[path = "../display/image_viewer.rs"] pub mod image_viewer; #[path = "../display/input_state.rs"] @@ -60,7 +58,7 @@ mod text_path_model; mod toast_kind; pub mod ui; -pub use i18n::{LanguagePreference, UiLanguage}; +pub use crate::i18n::{LanguagePreference, UiLanguage}; pub use program_identity::AiLogo; pub use size_info::SizeInfo; pub(crate) use ssh_ui::merge_ssh_hosts; diff --git a/nebula_app/src/product_ui/ui.rs b/nebula_app/src/product_ui/ui.rs index f905206..954fd14 100644 --- a/nebula_app/src/product_ui/ui.rs +++ b/nebula_app/src/product_ui/ui.rs @@ -10,10 +10,10 @@ pub mod keycap; pub mod os_icons; #[path = "../display/ui/color_math.rs"] pub mod surface; -#[path = "../display/ui/theme.rs"] -pub mod theme; #[path = "../display/ui/text_field.rs"] pub(crate) mod text_field; +#[path = "../display/ui/theme.rs"] +pub mod theme; #[path = "../display/ui/tokens.rs"] pub mod tokens; #[path = "../display/ui/scrollbar.rs"] diff --git a/nebula_app/src/runtime_api/cli.rs b/nebula_app/src/runtime_api/cli.rs index f8e175b..8404a18 100644 --- a/nebula_app/src/runtime_api/cli.rs +++ b/nebula_app/src/runtime_api/cli.rs @@ -39,11 +39,21 @@ pub(super) fn request_once( } pub(super) fn print_response(response: &ApiResponse, pretty: bool) -> Result<(), Box> { - if pretty { - println!("{}", serde_json::to_string_pretty(response)?); + write_cli_response(&mut std::io::stdout().lock(), response, pretty) +} + +fn write_cli_response( + output: &mut impl Write, + response: &ApiResponse, + pretty: bool, +) -> Result<(), Box> { + let mut json = if pretty { + serde_json::to_string_pretty(response)? } else { - println!("{}", serde_json::to_string(response)?); - } + serde_json::to_string(response)? + }; + json.push('\n'); + write_cli_output(output, json.as_bytes())?; if response.ok { Ok(()) } else { @@ -53,6 +63,19 @@ pub(super) fn print_response(response: &ApiResponse, pretty: bool) -> Result<(), } } +fn write_cli_output(output: &mut impl Write, bytes: &[u8]) -> Result { + match output.write_all(bytes).and_then(|()| output.flush()) { + Ok(()) => Ok(true), + Err(error) + if error.kind() == std::io::ErrorKind::BrokenPipe + || (cfg!(windows) && matches!(error.raw_os_error(), Some(109 | 232 | 233))) => + { + Ok(false) + }, + Err(error) => Err(error), + } +} + /// CLI adapter. It deliberately speaks the same serialized protocol as any /// external client instead of calling Processor helpers in-process. pub fn run_cli(options: ControlOptions) -> Result<(), Box> { @@ -472,16 +495,19 @@ fn subscribe_cli(since: Option, timeout: Duration) -> Result<(), Box, + fail_flush: bool, + } + + impl Write for FailingOutput { + fn write(&mut self, bytes: &[u8]) -> Result { + if self.fail_flush { Ok(bytes.len()) } else { Err(self.error.take().unwrap()) } + } + + fn flush(&mut self) -> Result<(), IoError> { + Err(self.error.take().unwrap()) + } + } + + #[test] + fn json_output_keeps_compact_and_pretty_response_contracts() { + let response = ApiResponse::success("probe", json!({ "tabs": 2 })); + for pretty in [false, true] { + let mut output = Vec::new(); + write_cli_response(&mut output, &response, pretty).unwrap(); + assert_eq!(output.last(), Some(&b'\n')); + assert_eq!(serde_json::from_slice::(&output).unwrap(), response); + } + } + + #[test] + fn closed_output_on_write_or_flush_ends_without_panicking() { + let response = ApiResponse::success("probe", json!({})); + for fail_flush in [false, true] { + let mut output = FailingOutput { + error: Some(IoError::from(std::io::ErrorKind::BrokenPipe)), + fail_flush, + }; + write_cli_response(&mut output, &response, false).unwrap(); + } + } + + #[test] + fn subscriptions_stop_when_the_output_consumer_disconnects() { + let mut output = FailingOutput { + error: Some(IoError::from(std::io::ErrorKind::BrokenPipe)), + fail_flush: false, + }; + assert!(!write_cli_output(&mut output, b"event\n").unwrap()); + } + + #[test] + fn unrelated_output_errors_are_not_silently_ignored() { + let mut output = FailingOutput { + error: Some(IoError::from(std::io::ErrorKind::PermissionDenied)), + fail_flush: false, + }; + assert_eq!( + write_cli_output(&mut output, b"event\n").unwrap_err().kind(), + std::io::ErrorKind::PermissionDenied, + ); + } + + #[cfg(windows)] + #[test] + fn windows_pipe_closing_errors_do_not_trigger_runtime_error_dialogs() { + for code in [109, 232, 233] { + let mut output = + FailingOutput { error: Some(IoError::from_raw_os_error(code)), fail_flush: false }; + assert!(!write_cli_output(&mut output, b"event\n").unwrap()); + } + } +} + #[derive(Debug)] pub(super) struct PrintedCliError(String); diff --git a/nebula_app/src/session.rs b/nebula_app/src/session.rs index 4dd85c3..0c913f8 100644 --- a/nebula_app/src/session.rs +++ b/nebula_app/src/session.rs @@ -262,11 +262,16 @@ pub fn load_from(path: &Path) -> Option { /// The atomic replace matters here — this file is written every second and a /// crash mid-write must not cost the very session it exists to restore. pub fn save(session: &Session) { - if let Ok(json) = serde_json::to_string(session) { - let _ = crate::atomic_file::write(&session_path(), json.as_bytes()); + if let Err(error) = try_save(session) { + log::warn!("Could not persist terminal session: {error}"); } } +pub(crate) fn try_save(session: &Session) -> std::io::Result<()> { + let json = serde_json::to_string(session).map_err(std::io::Error::other)?; + crate::atomic_file::write(&session_path(), json.as_bytes()) +} + /// Write a session as a named workspace file. Pretty-printed — workspace /// files are user-visible artifacts meant to be read, diffed and versioned. pub fn save_to(path: &Path, session: &Session) -> std::io::Result<()> { diff --git a/nebula_app/src/ssh_credentials.rs b/nebula_app/src/ssh_credentials.rs index 56dd9be..8634fe1 100644 --- a/nebula_app/src/ssh_credentials.rs +++ b/nebula_app/src/ssh_credentials.rs @@ -405,10 +405,7 @@ pub fn forget_password(destination: &str) -> std::io::Result<()> { crate::platform::credentials::delete(&credential_target(destination)) } -pub fn store_private_key_passphrase( - private_key: &[u8], - passphrase: &[u8], -) -> std::io::Result<()> { +pub fn store_private_key_passphrase(private_key: &[u8], passphrase: &[u8]) -> std::io::Result<()> { crate::platform::credentials::store_with_username( &private_key_credential_target(private_key), "private-key", diff --git a/nebula_app/src/ssh_profiles.rs b/nebula_app/src/ssh_profiles.rs index 4ff8c2e..aaa1552 100644 --- a/nebula_app/src/ssh_profiles.rs +++ b/nebula_app/src/ssh_profiles.rs @@ -4,8 +4,8 @@ use std::path::{Path, PathBuf}; use serde::{Deserialize, Deserializer, Serialize}; mod connection; -pub use connection::{SshConnectionOptions, SshHostJumpMode, SshHostProxyMode}; pub(crate) use connection::validate_ssh_destination; +pub use connection::{SshConnectionOptions, SshHostJumpMode, SshHostProxyMode}; const PROFILE_VERSION: u32 = 1; const USERNAME_HISTORY_CAP: usize = 12; diff --git a/nebula_app/src/ssh_session.rs b/nebula_app/src/ssh_session.rs index 2e887e0..cc8a137 100644 --- a/nebula_app/src/ssh_session.rs +++ b/nebula_app/src/ssh_session.rs @@ -8,14 +8,12 @@ use std::collections::HashMap; use std::io; use std::path::{Path, PathBuf}; use std::process::Command; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::mpsc::Receiver; use std::sync::{Arc, OnceLock}; use std::time::Duration; -use log::{error, info, warn}; +use log::{info, warn}; use nebula_terminal::event::{Event as TerminalEvent, WindowSize}; -use nebula_terminal::event_loop::{EventLoopSender, Msg, StreamProcessor}; +use nebula_terminal::event_loop::{EventLoopSender, StreamProcessor}; use nebula_terminal::sync::FairMutex; use nebula_terminal::term::Term; use russh::ChannelMsg; @@ -28,12 +26,13 @@ use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; use crate::event::EventProxy; use crate::proxy_test::{ProxyTestFailure, ProxyTestOutcome, ProxyTestResult, ProxyTestRoute}; +mod lifecycle; mod route; use route::{ResolvedRoute, RouteTransport}; type SessionError = Box; type ClientSession = client::Handle; -type SharedSession = Arc>; +type SharedSession = Arc; struct AcquiredSession { key: String, @@ -391,6 +390,7 @@ struct ClientHandler { host: String, port: u16, allow_prompt: bool, + handshake: lifecycle::Handshake, #[cfg(test)] known_hosts_path: Option, } @@ -418,7 +418,10 @@ impl client::Handler for ClientHandler { Ok(true) => Ok(true), Ok(false) => { if self.allow_prompt - && confirm_new_host(&self.host, self.port, server_public_key).await + && self + .handshake + .confirm(confirm_new_host(&self.host, self.port, server_public_key)) + .await { if let Err(err) = russh::keys::known_hosts::learn_known_hosts( &self.host, @@ -486,203 +489,17 @@ pub fn spawn_session_at( event_proxy: H, ) -> io::Result { let (sender, receiver) = EventLoopSender::standalone()?; - let profiles_path = crate::display::nebula_data_dir().join("ssh_profiles.json"); - runtime()?.spawn(async move { - // 地址解析是第一个真实阶段:`ssh -G` 要读 ~/.ssh/config,慢链路上 - // 这一步本身就可能耗时。 - report_stage(Some(&event_proxy), SshStage::Resolve); - let raw = destination.clone(); - let resolved = tokio::task::spawn_blocking(move || { - let resolved = SshDestination::resolve(&raw)?; - let profiles = crate::ssh_profiles::SshProfiles::load(&profiles_path)?; - Ok::<_, io::Error>((resolved, profiles.for_destination(&raw))) - }) - .await; - // 会话是否曾经就绪。它把两种 `Err` 分开:连接建立**之前**失败要把 - // 卡片留在屏幕上让用户读原因;建立**之后**断开则是普通的会话结束, - // 必须照常退出 pane,否则会留下一个连不上也关不掉的空壳。 - let ready = Arc::new(AtomicBool::new(false)); - let result = match resolved { - Ok(Ok((destination, profile))) => { - run_session_async( - destination, - profile, - initial_size, - terminal.clone(), - event_proxy.clone(), - receiver, - ready.clone(), - initial_remote_cwd, - ) - .await - }, - Ok(Err(err)) => Err(err.into()), - Err(err) => Err(format!("SSH 地址解析任务失败: {err}").into()), - }; - match result { - Err(err) if !ready.load(Ordering::Relaxed) => { - // 这里刻意**不用** `error!`:它会额外推一条红色 message bar, - // 而连接卡片已经把同一条原因连同阶段和日志一起呈现了。同一个 - // 错误报两次,其中一次还被卡片的遮罩切成半残的红条。 - info!("直连 SSH 会话失败 {destination}: {err}"); - report_stage(Some(&event_proxy), SshStage::Failed(err.to_string())); - // 错误也写进 grid:卡片关掉之后它仍然留在回滚里。 - render_error(&terminal, &event_proxy, &format!("SSH 连接失败: {err}")); - // 这里**不** exit:pane 一退出 tab 就关了,卡片和它携带的 - // 失败原因会一起消失。收尾交给卡片上的「关闭」。 - }, - Err(err) => { - // 会话中途断开时没有卡片在场,message bar 是唯一的提示渠道。 - error!("直连 SSH 会话中断 {destination}: {err}"); - render_error(&terminal, &event_proxy, &format!("SSH 连接失败: {err}")); - terminal.lock().exit(); - }, - Ok(()) => terminal.lock().exit(), - } - event_proxy.send_event(TerminalEvent::Wakeup); - }); + runtime()?.spawn(lifecycle::run( + destination, + initial_remote_cwd, + initial_size, + terminal, + event_proxy, + receiver, + )); Ok(sender) } -async fn run_session_async( - destination: SshDestination, - profile: crate::ssh_profiles::SshProfileAuth, - initial_size: WindowSize, - terminal: Arc>>, - event_proxy: H, - receiver: Receiver, - ready: Arc, - initial_remote_cwd: Option, -) -> Result<(), SessionError> { - let mut acquired = - authenticated_session_at(&destination, &profile, Some(&event_proxy), 0).await?; - report_stage(Some(&event_proxy), SshStage::OpenShell); - let shell = - open_shell_channel(&acquired.session, initial_size, initial_remote_cwd.as_deref()).await; - let (mut channel, hook_token) = match shell { - Ok(shell) => shell, - Err(first_error) if acquired.reused => { - // `Handle::is_closed` 只反映已完成的关闭;TCP reset 与 channel-open - // 失败之间存在窗口。复用对象打不开首个 channel 时精确淘汰并只重试 - // 一次,新连接失败则把真实错误交给连接卡片。 - evict_pooled_session(&acquired.key, &acquired.session).await; - info!("复用 SSH transport 打开 channel 失败,重建连接: {first_error}"); - acquired = - authenticated_session_at(&destination, &profile, Some(&event_proxy), 0).await?; - report_stage(Some(&event_proxy), SshStage::OpenShell); - match open_shell_channel(&acquired.session, initial_size, initial_remote_cwd.as_deref()) - .await - { - Ok(shell) => shell, - Err(retry_error) => { - evict_pooled_session(&acquired.key, &acquired.session).await; - return Err(format!( - "SSH channel 打开失败(复用连接错误: {first_error};重建后错误: {retry_error})" - ) - .into()); - }, - } - }, - Err(error) => { - evict_pooled_session(&acquired.key, &acquired.session).await; - return Err(error); - }, - }; - // Shell 已就绪:连接卡片到此让位给真实终端,持续重绘随之停止。此后再 - // 出错就是会话中途断开,不该复活卡片。 - ready.store(true, Ordering::Relaxed); - report_stage(Some(&event_proxy), SshStage::Ready); - - let (input_tx, mut input_rx) = tokio::sync::mpsc::unbounded_channel(); - tokio::task::spawn_blocking(move || { - while let Ok(message) = receiver.recv() { - if input_tx.send(message).is_err() { - break; - } - } - }); - - let mut stream = StreamProcessor::default(); - stream.resize(initial_size); - stream.set_remote_hook_token(hook_token); - let result: Result<(), SessionError> = async { - loop { - let sync_deadline = stream.next_sync_timeout(); - tokio::select! { - message = input_rx.recv() => match message { - Some(Msg::Input(bytes)) => channel.data(bytes.as_ref()).await?, - Some(Msg::Resize(size)) => { - stream.resize(size); - channel.window_change( - u32::from(size.num_cols), - u32::from(size.num_lines), - u32::from(size.cell_width) * u32::from(size.num_cols), - u32::from(size.cell_height) * u32::from(size.num_lines), - ).await?; - }, - // 只改本地几何:远端保持旧尺寸,等去抖后的 `Resize` 才发 - // `window_change`。交互式拖拽每帧都会来一条。 - Some(Msg::ResizeGrid(size)) => stream.resize(size), - Some(Msg::Shutdown) | None => { - let _ = channel.eof().await; - break; - }, - }, - message = channel.wait() => match message { - Some(ChannelMsg::Data { data }) | Some(ChannelMsg::ExtendedData { data, .. }) => { - stream.feed(&mut *terminal.lock(), &event_proxy, data.as_ref()); - event_proxy.send_event(TerminalEvent::Wakeup); - }, - Some(ChannelMsg::ExitStatus { .. }) | Some(ChannelMsg::Eof) | None => break, - _ => {}, - }, - _ = wait_for_sync(sync_deadline), if sync_deadline.is_some() => { - stream.stop_sync(&mut *terminal.lock()); - event_proxy.send_event(TerminalEvent::Wakeup); - }, - } - } - Ok(()) - } - .await; - let transport_closed = acquired.session.lock().await.is_closed(); - if result.is_err() || transport_closed { - evict_pooled_session(&acquired.key, &acquired.session).await; - } - result -} - -async fn open_shell_channel( - session: &SharedSession, - initial_size: WindowSize, - initial_remote_cwd: Option<&str>, -) -> Result<(russh::Channel, String), SessionError> { - let channel = { - let session = session.lock().await; - session.channel_open_session().await? - }; - channel - .request_pty( - true, - "xterm-256color", - u32::from(initial_size.num_cols), - u32::from(initial_size.num_lines), - u32::from(initial_size.cell_width) * u32::from(initial_size.num_cols), - u32::from(initial_size.cell_height) * u32::from(initial_size.num_lines), - &[], - ) - .await?; - let hook_token = remote_hook_token()?; - channel.set_env(false, "NEBULA_REMOTE_HOOK_TOKEN", hook_token.clone()).await?; - // 远端 pane 的自声明是安全护栏而非连接前提;AcceptEnv 未放行时忽略。 - let _ = channel.set_env(false, "NEBULA_PANE_REMOTE", "1").await; - channel.request_shell(true).await?; - if let Some(command) = initial_remote_cd_command(initial_remote_cwd) { - channel.data_bytes(command).await?; - } - Ok((channel, hook_token)) -} - /// Build one shell-safe `cd` command for a path learned from OSC 7 or SFTP. /// Relative and control-character-bearing values are rejected: a duplicated /// tab must never turn terminal metadata into an arbitrary command stream. @@ -704,12 +521,6 @@ async fn evict_pooled_session(key: &str, session: &SharedSession) -> bool { matches } -async fn wait_for_sync(deadline: Option) { - if let Some(deadline) = deadline { - tokio::time::sleep_until(tokio::time::Instant::from_std(deadline)).await; - } -} - async fn authenticated_session( destination: &SshDestination, profile: &crate::ssh_profiles::SshProfileAuth, @@ -769,13 +580,10 @@ async fn authenticated_route( unattended: bool, ) -> Result { let key = route.pool_key(); - let existing = if unattended { - None - } else { - connection_pool().lock().await.get(&key).cloned() - }; + let existing = + if unattended { None } else { connection_pool().lock().await.get(&key).cloned() }; if let Some(existing) = existing { - if !existing.lock().await.is_closed() { + if !existing.is_closed() { info!("复用已认证 SSH 连接: {key}"); return Ok(AcquiredSession { key, @@ -784,7 +592,7 @@ async fn authenticated_route( jump_sessions: Vec::new(), }); } - connection_pool().lock().await.remove(&key); + evict_pooled_session(&key, &existing).await; } let config = Arc::new(client::Config { @@ -811,7 +619,7 @@ async fn authenticated_route( authenticate(&mut transport.session, &route.destination, &route.profile).await?; } - let session = Arc::new(tokio::sync::Mutex::new(transport.session)); + let session = Arc::new(transport.session); if unattended { return Ok(AcquiredSession { key, @@ -822,7 +630,7 @@ async fn authenticated_route( } let mut pool = connection_pool().lock().await; if let Some(existing) = pool.get(&key).cloned() { - if !existing.lock().await.is_closed() { + if !existing.is_closed() { return Ok(AcquiredSession { key, session: existing, @@ -849,10 +657,12 @@ async fn open_transport( unattended: bool, ) -> Result { let destination = &route.destination; + let handshake = lifecycle::Handshake::default(); let handler = ClientHandler { host: destination.host.clone(), port: destination.port, allow_prompt: !unattended, + handshake: handshake.clone(), #[cfg(test)] known_hosts_path: route.known_hosts_path.clone(), }; @@ -860,53 +670,61 @@ async fn open_transport( let session = match &route.transport { RouteTransport::Server(server) => { info!("经代理 {} 连接 {}:{}", server.display(), destination.host, destination.port); - let stream = crate::ssh_proxy::connect(server, &destination.host, destination.port) - .await - .map_err(|err| format!("经代理 {} 连接失败: {err}", server.display()))?; - client::connect_stream(config, stream, handler).await? + let stream = lifecycle::network( + "proxy connection", + crate::ssh_proxy::connect(server, &destination.host, destination.port), + ) + .await + .map_err(|err| format!("经代理 {} 连接失败: {err}", server.display()))?; + handshake.connect(client::connect_stream(config, stream, handler)).await? }, RouteTransport::Jump(jump) => { let spec = &jump.destination.original; info!("经跳板 {spec} 连接 {}:{}", destination.host, destination.port); - let acquired = Box::pin(authenticated_route( - jump, - None::<&NoopSshEventHost>, - unattended, - )) - .await - .map_err(|err| format!("连接跳板 {spec} 失败: {err}"))?; - let channel = { - let session = acquired.session.lock().await; - session - .channel_open_direct_tcpip( - destination.host.clone(), - u32::from(destination.port), - "127.0.0.1", - 0, - ) + let acquired = + Box::pin(authenticated_route(jump, None::<&NoopSshEventHost>, unattended)) .await - .map_err(|err| { - format!( - "经跳板 {spec} 转发到 {}:{} 失败: {err}", - destination.host, destination.port - ) - })? - }; + .map_err(|err| format!("连接跳板 {spec} 失败: {err}"))?; + let channel = lifecycle::network( + "jump channel", + acquired.session.channel_open_direct_tcpip( + destination.host.clone(), + u32::from(destination.port), + "127.0.0.1", + 0, + ), + ) + .await + .map_err(|err| { + format!( + "经跳板 {spec} 转发到 {}:{} 失败: {err}", + destination.host, destination.port + ) + })?; jump_sessions = acquired.jump_sessions; jump_sessions.push(acquired.session); - client::connect_stream(config, channel.into_stream(), handler).await? + handshake + .connect(client::connect_stream(config, channel.into_stream(), handler)) + .await? }, RouteTransport::Command(command) => { info!("经自定义命令连接 {}:{}", destination.host, destination.port); - let stream = - crate::ssh_proxy::connect_command(command, &destination.host, destination.port) - .await - .map_err(|err| format!("自定义代理命令启动失败: {err}"))?; - client::connect_stream(config, stream, handler).await? + let stream = lifecycle::network( + "proxy command", + crate::ssh_proxy::connect_command(command, &destination.host, destination.port), + ) + .await + .map_err(|err| format!("自定义代理命令启动失败: {err}"))?; + handshake.connect(client::connect_stream(config, stream, handler)).await? }, RouteTransport::Direct => { - client::connect(config, (destination.host.as_str(), destination.port), handler) - .await? + let stream = lifecycle::network( + "TCP connection", + tokio::net::TcpStream::connect((destination.host.as_str(), destination.port)), + ) + .await?; + stream.set_nodelay(true)?; + handshake.connect(client::connect_stream(config, stream, handler)).await? }, }; Ok(OpenedTransport { session, jump_sessions }) @@ -917,7 +735,13 @@ async fn authenticate( destination: &SshDestination, profile: &crate::ssh_profiles::SshProfileAuth, ) -> Result<(), SessionError> { - if session.authenticate_none(&destination.user).await?.success() { + if lifecycle::authentication( + "authentication response", + session.authenticate_none(&destination.user), + ) + .await? + .success() + { return Ok(()); } @@ -950,7 +774,9 @@ async fn authenticate( reusable_password = crate::ssh_credentials::load_stored_password(&destination.original) .unwrap_or_else(|error| { - warn!("Could not read saved SSH password; prompting instead: {error}"); + warn!( + "Could not read saved SSH password; prompting instead: {error}" + ); None }); loaded_stored_password = true; @@ -1044,10 +870,7 @@ pub(crate) async fn exec_capture( .map_err(|err| format!("SSH 地址解析任务失败: {err}"))??; let session = authenticated_session(&destination, &profile, None::<&NoopSshEventHost>).await?; - let mut channel = { - let session = session.lock().await; - session.channel_open_session().await? - }; + let mut channel = lifecycle::network("exec channel", session.channel_open_session()).await?; channel.exec(true, command).await?; if !script.is_empty() { channel.data_bytes(script.to_vec()).await?; @@ -1105,10 +928,7 @@ pub(crate) async fn open_sftp( // SFTP 面板自己有加载态,不参与终端 pane 的连接卡片。 let session = authenticated_session(&destination, &profile, None::<&NoopSshEventHost>).await?; - let channel = { - let session = session.lock().await; - session.channel_open_session().await? - }; + let channel = lifecycle::network("SFTP channel", session.channel_open_session()).await?; channel.request_subsystem(true, "sftp").await?; // 显式给参数而不是用上游默认值:默认单包 256 KiB 会让每个 READ/WRITE 都 // 顶满一个巨型请求(部分服务端在这个尺寸上会静默出错),而默认在途写 @@ -1177,9 +997,7 @@ async fn run_test(request: SshTestRequest) -> SshTestResult { let outcome = tokio::time::timeout(TEST_TIMEOUT, async { let resolved = tokio::task::spawn_blocking({ let raw = raw.clone(); - move || { - SshDestination::resolve(&raw) - } + move || SshDestination::resolve(&raw) }) .await .map_err(|err| -> SessionError { @@ -1193,13 +1011,9 @@ async fn run_test(request: SshTestRequest) -> SshTestResult { icon: None, connection: request.connection.clone(), }; - let route = resolve_connection_route( - &resolved, - &profile, - 0, - request.proxy_password.clone(), - ) - .await?; + let route = + resolve_connection_route(&resolved, &profile, 0, request.proxy_password.clone()) + .await?; test_connect(&route, &request).await }) .await; @@ -1452,30 +1266,28 @@ async fn proxy_test_stream( let route = resolve_connection_route(&jump_destination, &jump_profile, 1, None) .await .map_err(|error| ProxyTestFailure::JumpConnect { + target: spec.clone(), + error: error.to_string(), + })?; + let jump = authenticated_route(&route, None::<&NoopSshEventHost>, true).await.map_err( + |error| ProxyTestFailure::JumpConnect { target: spec.clone(), error: error.to_string(), - })?; - let jump = authenticated_route(&route, None::<&NoopSshEventHost>, true) + }, + )?; + let channel = jump + .session + .channel_open_direct_tcpip( + NETWORK_TEST_HOST, + u32::from(NETWORK_TEST_PORT), + "127.0.0.1", + 0, + ) .await - .map_err(|error| ProxyTestFailure::JumpConnect { + .map_err(|error| ProxyTestFailure::JumpChannel { target: spec.clone(), error: error.to_string(), })?; - let channel = { - let session = jump.session.lock().await; - session - .channel_open_direct_tcpip( - NETWORK_TEST_HOST, - u32::from(NETWORK_TEST_PORT), - "127.0.0.1", - 0, - ) - .await - .map_err(|error| ProxyTestFailure::JumpChannel { - target: spec.clone(), - error: error.to_string(), - })? - }; let mut sessions = jump.jump_sessions; sessions.push(jump.session); Ok(Box::new(RetainedNetworkStream { @@ -1490,10 +1302,7 @@ async fn proxy_test_stream( } } -async fn test_connect( - route: &ResolvedRoute, - request: &SshTestRequest, -) -> Result<(), SessionError> { +async fn test_connect(route: &ResolvedRoute, request: &SshTestRequest) -> Result<(), SessionError> { let config = Arc::new(client::Config { inactivity_timeout: None, keepalive_interval: None, @@ -1685,7 +1494,8 @@ async fn try_private_key( .and_then(|bytes| std::str::from_utf8(bytes).ok()) .and_then(|passphrase| russh::keys::load_secret_key(path, Some(passphrase)).ok()); if key.is_none() && stored.is_some() { - if let Err(error) = crate::ssh_credentials::forget_private_key_passphrase(&private_key) { + if let Err(error) = crate::ssh_credentials::forget_private_key_passphrase(&private_key) + { warn!("Could not remove rejected SSH key passphrase: {error}"); } } @@ -1716,10 +1526,12 @@ async fn try_private_key( let cert_path = PathBuf::from(format!("{}-cert.pub", path.display())); if cert_path.is_file() { if let Ok(certificate) = russh::keys::load_openssh_certificate(&cert_path) { - if session - .authenticate_openssh_cert(&destination.user, key.clone(), certificate) - .await? - .success() + if lifecycle::authentication( + "certificate authentication", + session.authenticate_openssh_cert(&destination.user, key.clone(), certificate), + ) + .await? + .success() { return Ok(true); } @@ -1728,14 +1540,20 @@ async fn try_private_key( let hash = rsa_hash_for(session, key.algorithm().is_rsa()).await; let key = PrivateKeyWithHashAlg::new(key, hash); - Ok(session.authenticate_publickey(&destination.user, key).await?.success()) + Ok(lifecycle::authentication( + "public-key authentication", + session.authenticate_publickey(&destination.user, key), + ) + .await? + .success()) } async fn rsa_hash_for(session: &ClientSession, rsa: bool) -> Option { if !rsa { return None; } - match session.best_supported_rsa_hash().await { + match lifecycle::network("server signature algorithms", session.best_supported_rsa_hash()).await + { Ok(Some(hash)) => hash, _ => Some(HashAlg::Sha512), } @@ -1747,7 +1565,12 @@ async fn authenticate_password( password: &[u8], ) -> Result { let password = String::from_utf8(password.to_vec())?; - Ok(session.authenticate_password(user, password).await?.success()) + Ok(lifecycle::authentication( + "password authentication", + session.authenticate_password(user, password), + ) + .await? + .success()) } async fn try_keyboard_interactive( @@ -1757,8 +1580,11 @@ async fn try_keyboard_interactive( allow_prompt: bool, ) -> Result { let mut attempt = KeyboardInteractiveAttempt::default(); - let mut state = - session.authenticate_keyboard_interactive_start(&destination.user, None::).await?; + let mut state = lifecycle::authentication( + "keyboard-interactive authentication", + session.authenticate_keyboard_interactive_start(&destination.user, None::), + ) + .await?; for _ in 0..8 { match state { KeyboardInteractiveAuthResponse::Success => { @@ -1789,7 +1615,11 @@ async fn try_keyboard_interactive( responses.push(String::from_utf8_lossy(&response).into_owned()); response.fill(0); } - state = session.authenticate_keyboard_interactive_respond(responses).await?; + state = lifecycle::authentication( + "keyboard-interactive response", + session.authenticate_keyboard_interactive_respond(responses), + ) + .await?; }, } } @@ -1809,8 +1639,10 @@ async fn prompt_secret( if crate::ssh_prompt::available() { let _initial = zeroize::Zeroizing::new(initial.unwrap_or_default()); return crate::ssh_prompt::secret( - destination, allow_save && crate::platform::credentials::can_store(), - ).await; + destination, + allow_save && crate::platform::credentials::can_store(), + ) + .await; } tokio::task::spawn_blocking(move || { let initial = zeroize::Zeroizing::new(initial.unwrap_or_default()); @@ -1854,8 +1686,12 @@ fn render_error( async fn confirm_new_host(host: &str, port: u16, key: &ssh_key::PublicKey) -> bool { if crate::ssh_prompt::available() { return crate::ssh_prompt::confirm_host( - host, port, key.fingerprint(ssh_key::HashAlg::Sha256).to_string(), - ).await.unwrap_or_else(|error| { + host, + port, + key.fingerprint(ssh_key::HashAlg::Sha256).to_string(), + ) + .await + .unwrap_or_else(|error| { warn!("SSH host confirmation failed: {error}"); false }); diff --git a/nebula_app/src/ssh_session/lifecycle.rs b/nebula_app/src/ssh_session/lifecycle.rs new file mode 100644 index 0000000..4d1912a --- /dev/null +++ b/nebula_app/src/ssh_session/lifecycle.rs @@ -0,0 +1,443 @@ +use std::collections::VecDeque; +use std::future::Future; +use std::io; +use std::ops::{Deref, DerefMut}; +use std::sync::Arc; +use std::sync::mpsc::{Receiver, RecvTimeoutError}; +use std::time::Duration; + +use nebula_terminal::event::{Event as TerminalEvent, WindowSize}; +use nebula_terminal::event_loop::{Msg, StreamProcessor}; +use nebula_terminal::sync::FairMutex; +use nebula_terminal::term::Term; +use russh::{Channel, ChannelMsg, client}; +use tokio::sync::{mpsc, watch}; + +use super::{SessionError, SharedSession, SshDestination, SshEventHost, SshStage}; + +pub(super) const NETWORK_TIMEOUT: Duration = Duration::from_secs(20); +const AUTH_RESPONSE_TIMEOUT: Duration = Duration::from_secs(300); + +pub(super) async fn authentication( + operation: &str, + future: impl Future>, +) -> Result { + network_with_budget(operation, AUTH_RESPONSE_TIMEOUT, future).await +} + +pub(super) async fn network( + operation: &str, + future: impl Future>, +) -> Result +where + Error: Into, +{ + network_with_budget(operation, NETWORK_TIMEOUT, future).await +} + +async fn network_with_budget( + operation: &str, + budget: Duration, + future: impl Future>, +) -> Result +where + Error: Into, +{ + tokio::time::timeout(budget, future) + .await + .map_err(|_| timed_out(operation))? + .map_err(Into::into) +} + +fn timed_out(operation: &str) -> SessionError { + io::Error::new(io::ErrorKind::TimedOut, format!("SSH {operation} timed out")).into() +} + +async fn cancelled(receiver: &mut watch::Receiver) { + while !*receiver.borrow_and_update() { + if receiver.changed().await.is_err() { + return; + } + } +} + +struct CancelOnDrop(Option>); + +impl Drop for CancelOnDrop { + fn drop(&mut self) { + if let Some(sender) = &self.0 { + sender.send_replace(true); + } + } +} + +#[derive(Clone)] +pub(super) struct Handshake { + prompting: watch::Sender, + cancelled: watch::Sender, +} + +impl Default for Handshake { + fn default() -> Self { + Self { prompting: watch::channel(false).0, cancelled: watch::channel(false).0 } + } +} + +impl Handshake { + pub(super) async fn confirm(&self, future: impl Future) -> bool { + let mut cancellation = self.cancelled.subscribe(); + self.prompting.send_replace(true); + let result = tokio::select! { + biased; + _ = cancelled(&mut cancellation) => false, + accepted = future => accepted, + }; + self.prompting.send_replace(false); + result + } + + pub(super) async fn connect( + &self, + future: impl Future>, + ) -> Result { + self.connect_with_budget(future, NETWORK_TIMEOUT).await + } + + async fn connect_with_budget( + &self, + future: impl Future>, + mut remaining: Duration, + ) -> Result { + let mut guard = CancelOnDrop(Some(self.cancelled.clone())); + let mut prompting = self.prompting.subscribe(); + tokio::pin!(future); + loop { + let paused = *prompting.borrow_and_update(); + let started = tokio::time::Instant::now(); + tokio::select! { + biased; + result = &mut future => { + guard.0 = None; + return result.map_err(Into::into); + }, + _ = prompting.changed() => {}, + _ = tokio::time::sleep(remaining), if !paused => return Err(timed_out("handshake")), + } + if !paused { + remaining = remaining.saturating_sub(started.elapsed()); + } + } + } +} + +fn input_bridge(receiver: Receiver) -> (mpsc::UnboundedReceiver, watch::Receiver) { + let (input_tx, input_rx) = mpsc::unbounded_channel(); + let (cancel_tx, cancel_rx) = watch::channel(false); + tokio::task::spawn_blocking(move || { + while !input_tx.is_closed() { + match receiver.recv_timeout(Duration::from_millis(100)) { + Ok(Msg::Shutdown) | Err(RecvTimeoutError::Disconnected) => { + let _ = cancel_tx.send(true); + break; + }, + Ok(message) => { + if input_tx.send(message).is_err() { + break; + } + }, + Err(RecvTimeoutError::Timeout) => {}, + } + } + }); + (input_rx, cancel_rx) +} + +pub(super) async fn run( + destination: String, + initial_remote_cwd: Option, + initial_size: WindowSize, + terminal: Arc>>, + event_proxy: H, + receiver: Receiver, +) { + let (mut input, mut cancellation) = input_bridge(receiver); + let work = async { + super::report_stage(Some(&event_proxy), SshStage::Resolve); + let raw = destination.clone(); + let profiles_path = crate::display::nebula_data_dir().join("ssh_profiles.json"); + let (resolved, profile) = network( + "address resolution", + tokio::task::spawn_blocking(move || { + let resolved = SshDestination::resolve(&raw)?; + let profiles = crate::ssh_profiles::SshProfiles::load(&profiles_path)?; + Ok::<_, io::Error>((resolved, profiles.for_destination(&raw))) + }), + ) + .await??; + let mut acquired = + super::authenticated_session_at(&resolved, &profile, Some(&event_proxy), 0).await?; + let (mut channel, hook_token) = + match open_shell(&acquired, initial_size, initial_remote_cwd.as_deref(), &event_proxy) + .await + { + Ok(opened) => opened, + Err(first_error) if acquired.reused => { + super::evict_pooled_session(&acquired.key, &acquired.session).await; + log::info!("SSH pooled channel failed; reconnecting: {first_error}"); + acquired = + super::authenticated_session_at(&resolved, &profile, Some(&event_proxy), 0) + .await?; + open_shell(&acquired, initial_size, initial_remote_cwd.as_deref(), &event_proxy) + .await? + }, + Err(error) => return Err(error), + }; + super::report_stage(Some(&event_proxy), SshStage::Ready); + let result = + pump(&mut channel, hook_token, initial_size, &terminal, &event_proxy, &mut input).await; + if result.is_err() || acquired.session.is_closed() { + super::evict_pooled_session(&acquired.key, &acquired.session).await; + } + result + }; + drive(work, &mut cancellation, &terminal, &event_proxy).await; +} + +async fn drive( + work: impl Future>, + cancellation: &mut watch::Receiver, + terminal: &Arc>>, + event_proxy: &H, +) { + let result = tokio::select! { + biased; + _ = cancelled(cancellation) => Ok(()), + result = work => result, + }; + finish(result, terminal, event_proxy); +} + +fn finish( + result: Result<(), SessionError>, + terminal: &Arc>>, + event_proxy: &H, +) { + match result { + Ok(()) => terminal.lock().exit(), + Err(error) => { + log::info!("SSH session stopped: {error}"); + super::report_stage(Some(event_proxy), SshStage::Failed(error.to_string())); + super::render_error(terminal, event_proxy, &format!("SSH connection stopped: {error}")); + }, + } + event_proxy.send_event(TerminalEvent::Wakeup); +} + +struct Opening { + key: String, + session: SharedSession, + finished: bool, +} + +impl Drop for Opening { + fn drop(&mut self) { + if !self.finished { + let key = self.key.clone(); + let session = self.session.clone(); + tokio::spawn(async move { + super::evict_pooled_session(&key, &session).await; + }); + } + } +} + +struct ShellChannel { + channel: Option>, + pending: VecDeque, + pending_bytes: usize, +} + +impl Deref for ShellChannel { + type Target = Channel; + fn deref(&self) -> &Self::Target { + self.channel.as_ref().expect("SSH channel exists until drop") + } +} + +impl DerefMut for ShellChannel { + fn deref_mut(&mut self) -> &mut Self::Target { + self.channel.as_mut().expect("SSH channel exists until drop") + } +} + +impl Drop for ShellChannel { + fn drop(&mut self) { + if let Some(channel) = self.channel.take() { + tokio::spawn(async move { + let _ = network("channel close", channel.close()).await; + }); + } + } +} + +async fn open_shell( + acquired: &super::AcquiredSession, + size: WindowSize, + remote_cwd: Option<&str>, + progress: &H, +) -> Result<(ShellChannel, String), SessionError> { + open_shell_with_budget(acquired, size, remote_cwd, progress, NETWORK_TIMEOUT).await +} + +async fn open_shell_with_budget( + acquired: &super::AcquiredSession, + size: WindowSize, + remote_cwd: Option<&str>, + progress: &H, + budget: Duration, +) -> Result<(ShellChannel, String), SessionError> { + super::report_stage(Some(progress), SshStage::OpenShell); + let mut opening = + Opening { key: acquired.key.clone(), session: acquired.session.clone(), finished: false }; + let result = network_with_budget( + "shell channel", + budget, + open_shell_channel(&acquired.session, size, remote_cwd), + ) + .await; + if result.is_ok() { + opening.finished = true; + } + result +} + +async fn open_shell_channel( + session: &SharedSession, + size: WindowSize, + remote_cwd: Option<&str>, +) -> Result<(ShellChannel, String), SessionError> { + let mut channel = ShellChannel { + channel: Some(session.channel_open_session().await?), + pending: VecDeque::new(), + pending_bytes: 0, + }; + channel + .request_pty( + true, + "xterm-256color", + u32::from(size.num_cols), + u32::from(size.num_lines), + u32::from(size.cell_width) * u32::from(size.num_cols), + u32::from(size.cell_height) * u32::from(size.num_lines), + &[], + ) + .await?; + wait_request_success(&mut channel, "PTY").await?; + let hook_token = super::remote_hook_token()?; + channel.set_env(false, "NEBULA_REMOTE_HOOK_TOKEN", hook_token.clone()).await?; + let _ = channel.set_env(false, "NEBULA_PANE_REMOTE", "1").await; + channel.request_shell(true).await?; + wait_request_success(&mut channel, "shell").await?; + if let Some(command) = super::initial_remote_cd_command(remote_cwd) { + channel.data_bytes(command).await?; + } + Ok((channel, hook_token)) +} + +async fn wait_request_success( + channel: &mut ShellChannel, + request: &str, +) -> Result<(), SessionError> { + loop { + match channel.wait().await { + Some(ChannelMsg::Success) => return Ok(()), + Some(ChannelMsg::WindowAdjusted { .. }) => {}, + Some(message @ (ChannelMsg::Data { .. } | ChannelMsg::ExtendedData { .. })) => { + let length = match &message { + ChannelMsg::Data { data } | ChannelMsg::ExtendedData { data, .. } => data.len(), + _ => unreachable!(), + }; + channel.pending_bytes = channel.pending_bytes.saturating_add(length); + if channel.pending_bytes > 1024 * 1024 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "SSH server sent too much data before shell confirmation", + ) + .into()); + } + channel.pending.push_back(message) + }, + _ => { + return Err(io::Error::new( + io::ErrorKind::ConnectionAborted, + format!("SSH server did not accept {request} request"), + ) + .into()); + }, + } + } +} + +async fn pump( + channel: &mut ShellChannel, + hook_token: String, + initial_size: WindowSize, + terminal: &FairMutex>, + event_proxy: &H, + input: &mut mpsc::UnboundedReceiver, +) -> Result<(), SessionError> { + let mut stream = StreamProcessor::default(); + stream.resize(initial_size); + stream.set_remote_hook_token(hook_token); + while let Some(message) = channel.pending.pop_front() { + if let ChannelMsg::Data { data } | ChannelMsg::ExtendedData { data, .. } = message { + stream.feed(&mut *terminal.lock(), event_proxy, data.as_ref()); + event_proxy.send_event(TerminalEvent::Wakeup); + } + } + let mut eof_deadline = None; + loop { + let sync_deadline = stream.next_sync_timeout(); + tokio::select! { + message = input.recv() => match message { + Some(Msg::Input(bytes)) => network("channel write", channel.data(bytes.as_ref())).await?, + Some(Msg::Resize(size)) => { + stream.resize(size); + network("channel resize", channel.window_change(u32::from(size.num_cols), u32::from(size.num_lines), + u32::from(size.cell_width) * u32::from(size.num_cols), + u32::from(size.cell_height) * u32::from(size.num_lines))).await?; + }, + Some(Msg::ResizeGrid(size)) => stream.resize(size), + Some(Msg::Shutdown) | None => return Ok(()), + }, + message = channel.wait() => match message { + Some(ChannelMsg::Data { data }) | Some(ChannelMsg::ExtendedData { data, .. }) => { + stream.feed(&mut *terminal.lock(), event_proxy, data.as_ref()); + event_proxy.send_event(TerminalEvent::Wakeup); + }, + Some(ChannelMsg::ExitStatus { .. }) => return Ok(()), + Some(ChannelMsg::Eof) => eof_deadline = Some(tokio::time::Instant::now() + Duration::from_secs(1)), + Some(ChannelMsg::Close | ChannelMsg::ExitSignal { .. }) | None => { + return Err(io::Error::new(io::ErrorKind::ConnectionAborted, "SSH connection ended without an exit status").into()); + }, + _ => {}, + }, + _ = wait_for_sync(sync_deadline), if sync_deadline.is_some() => { + stream.stop_sync(&mut *terminal.lock()); + event_proxy.send_event(TerminalEvent::Wakeup); + }, + _ = async { tokio::time::sleep_until(eof_deadline.expect("EOF deadline exists")).await }, if eof_deadline.is_some() => { + return Err(io::Error::new(io::ErrorKind::ConnectionAborted, "SSH connection ended without an exit status").into()); + }, + } + } +} + +async fn wait_for_sync(deadline: Option) { + if let Some(deadline) = deadline { + tokio::time::sleep_until(tokio::time::Instant::from_std(deadline)).await; + } +} + +#[cfg(test)] +mod tests; diff --git a/nebula_app/src/ssh_session/lifecycle/tests.rs b/nebula_app/src/ssh_session/lifecycle/tests.rs new file mode 100644 index 0000000..a455a3d --- /dev/null +++ b/nebula_app/src/ssh_session/lifecycle/tests.rs @@ -0,0 +1,356 @@ +use std::sync::Mutex; + +use nebula_terminal::event::EventListener; +use russh::keys::ssh_key::{Algorithm, PrivateKey}; +use russh::server::{self, Auth, ChannelOpenHandle, Session}; +use russh::{ChannelId, Pty}; +use tokio::net::TcpListener; + +use super::*; +use crate::ssh_session::route::{ResolvedRoute, RouteTransport}; +use crate::ssh_session::{AcquiredSession, NoopSshEventHost, authenticated_route}; + +#[derive(Clone, Default)] +struct Events { + exits: Arc, + stages: Arc>>, +} + +impl EventListener for Events { + fn send_event(&self, event: TerminalEvent) { + if matches!(event, TerminalEvent::Exit) { + self.exits.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + } + } +} + +impl SshEventHost for Events { + fn ssh_stage(&self, stage: SshStage) { + self.stages.lock().unwrap().push(stage); + } +} + +fn size() -> WindowSize { + WindowSize { num_cols: 80, num_lines: 24, cell_width: 8, cell_height: 16 } +} + +fn terminal(events: &Events) -> Arc>> { + Arc::new(FairMutex::new(Term::new(Default::default(), &size(), events.clone()))) +} + +fn check(future: impl Future) { + tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async { + tokio::time::timeout(Duration::from_secs(10), future) + .await + .expect("SSH regression timed out"); + }); +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Mode { + Open, + RejectPty, + RejectShell, + DropWithoutStatus, + ExitAfterEof, + HangFirstConnection, +} + +struct Loopback { + mode: Mode, + hang: bool, + data: mpsc::UnboundedSender>, + channels: Vec>, +} + +impl server::Handler for Loopback { + type Error = russh::Error; + + async fn auth_none(&mut self, _user: &str) -> Result { + Ok(Auth::Accept) + } + + async fn channel_open_session( + &mut self, + channel: Channel, + reply: ChannelOpenHandle, + _session: &mut Session, + ) -> Result<(), Self::Error> { + if self.hang { + std::future::pending::<()>().await; + } + reply.accept().await; + self.channels.push(channel); + Ok(()) + } + + async fn pty_request( + &mut self, + channel: ChannelId, + _term: &str, + _columns: u32, + _rows: u32, + _width: u32, + _height: u32, + _modes: &[(Pty, u32)], + session: &mut Session, + ) -> Result<(), Self::Error> { + if self.mode == Mode::RejectPty { + session.channel_failure(channel) + } else { + session.channel_success(channel) + } + } + + async fn shell_request( + &mut self, + channel: ChannelId, + session: &mut Session, + ) -> Result<(), Self::Error> { + if self.mode == Mode::RejectShell { + return session.channel_failure(channel); + } + session.data(channel, &b"welcome before shell confirmation\r\n"[..])?; + session.channel_success(channel)?; + match self.mode { + Mode::DropWithoutStatus => { + session.eof(channel)?; + session.close(channel)?; + }, + Mode::ExitAfterEof => { + session.eof(channel)?; + session.exit_status_request(channel, 0)?; + session.close(channel)?; + }, + _ => {}, + } + Ok(()) + } + + async fn data( + &mut self, + _channel: ChannelId, + data: &[u8], + _session: &mut Session, + ) -> Result<(), Self::Error> { + let _ = self.data.send(data.to_vec()); + Ok(()) + } +} + +struct Fixture { + route: ResolvedRoute, + data: mpsc::UnboundedReceiver>, + task: tokio::task::JoinHandle<()>, + _directory: tempfile::TempDir, +} + +impl Drop for Fixture { + fn drop(&mut self) { + self.task.abort(); + } +} + +impl Fixture { + async fn new(mode: Mode) -> Self { + let directory = tempfile::tempdir().unwrap(); + let known_hosts = directory.path().join("known_hosts"); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let key = PrivateKey::random(&mut rand::rng(), Algorithm::Ed25519).unwrap(); + russh::keys::known_hosts::learn_known_hosts_path( + "127.0.0.1", + address.port(), + key.public_key(), + &known_hosts, + ) + .unwrap(); + let config = Arc::new(server::Config { + keys: vec![key], + auth_rejection_time: Duration::ZERO, + auth_rejection_time_initial: Some(Duration::ZERO), + ..Default::default() + }); + let (data_tx, data) = mpsc::unbounded_channel(); + let task = tokio::spawn(async move { + let mut connections = tokio::task::JoinSet::new(); + let mut first = true; + loop { + let (stream, _) = listener.accept().await.unwrap(); + stream.set_nodelay(true).unwrap(); + let handler = Loopback { + mode, + hang: first && mode == Mode::HangFirstConnection, + data: data_tx.clone(), + channels: Vec::new(), + }; + first = false; + let config = config.clone(); + connections.spawn(async move { + if let Ok(session) = server::run_stream(config, stream, handler).await { + let _ = session.await; + } + }); + } + }); + let destination = format!("fixture@127.0.0.1:{}", address.port()); + let route = ResolvedRoute { + destination: SshDestination::parse(&destination).unwrap(), + profile: crate::ssh_profiles::SshProfiles::default().for_destination(&destination), + transport: RouteTransport::Direct, + known_hosts_path: Some(known_hosts), + }; + Self { route, data, task, _directory: directory } + } + + async fn connect(&self) -> AcquiredSession { + authenticated_route(&self.route, None::<&NoopSshEventHost>, false).await.unwrap() + } + + async fn forget(&self, session: &SharedSession) { + super::super::evict_pooled_session(&self.route.pool_key(), session).await; + } +} + +#[test] +fn cancellation_stops_startup_before_a_shell_exists() { + check(async { + let (sender, receiver) = std::sync::mpsc::channel(); + let (_input, mut cancellation) = input_bridge(receiver); + let events = Events::default(); + let terminal = terminal(&events); + sender.send(Msg::Shutdown).unwrap(); + drive(std::future::pending(), &mut cancellation, &terminal, &events).await; + assert_eq!(events.exits.load(std::sync::atomic::Ordering::Relaxed), 1); + assert!(events.stages.lock().unwrap().is_empty()); + }); +} + +#[test] +fn handshake_budget_excludes_interactive_host_confirmation() { + check(async { + let handshake = Handshake::default(); + let operation = async { + assert!( + handshake + .confirm(async { + tokio::time::sleep(Duration::from_millis(100)).await; + true + }) + .await + ); + Ok(()) + }; + handshake.connect_with_budget(operation, Duration::from_millis(30)).await.unwrap(); + }); +} + +#[test] +fn cancelled_handshake_cannot_leave_a_pending_host_prompt() { + check(async { + let handshake = Handshake::default(); + { + let connection = handshake.connect(std::future::pending::>()); + tokio::pin!(connection); + tokio::select! { + _ = &mut connection => panic!("handshake must still be waiting"), + _ = tokio::time::sleep(Duration::from_millis(10)) => {}, + } + } + assert!(!handshake.confirm(std::future::pending()).await); + }); +} + +#[test] +fn rejected_pty_and_shell_do_not_become_ready() { + check(async { + for mode in [Mode::RejectPty, Mode::RejectShell] { + let fixture = Fixture::new(mode).await; + let acquired = fixture.connect().await; + let events = Events::default(); + let result = open_shell(&acquired, size(), None, &events).await; + assert!(result.is_err()); + assert!( + !events.stages.lock().unwrap().iter().any(|stage| matches!(stage, SshStage::Ready)) + ); + fixture.forget(&acquired.session).await; + } + }); +} + +#[test] +fn shell_confirmation_preserves_early_output_and_remote_directory() { + check(async { + let mut fixture = Fixture::new(Mode::Open).await; + let acquired = fixture.connect().await; + let (channel, _) = + open_shell(&acquired, size(), Some("/srv/Team's App"), &Events::default()) + .await + .unwrap(); + assert!(channel.pending.iter().any( + |message| matches!(message, ChannelMsg::Data { data } if data.starts_with(b"welcome")) + )); + let command = fixture.data.recv().await.unwrap(); + assert_eq!(command, b"cd '/srv/Team'\\''s App'\r"); + drop(channel); + fixture.forget(&acquired.session).await; + }); +} + +#[test] +fn unexpected_disconnect_preserves_pane_but_explicit_exit_closes_it() { + check(async { + for mode in [Mode::DropWithoutStatus, Mode::ExitAfterEof] { + let fixture = Fixture::new(mode).await; + let acquired = fixture.connect().await; + let events = Events::default(); + let terminal = terminal(&events); + let (mut channel, token) = open_shell(&acquired, size(), None, &events).await.unwrap(); + let (_input_tx, mut input) = mpsc::unbounded_channel(); + let result = pump(&mut channel, token, size(), &terminal, &events, &mut input).await; + assert_eq!(result.is_ok(), mode == Mode::ExitAfterEof); + finish(result, &terminal, &events); + assert_eq!( + events.exits.load(std::sync::atomic::Ordering::Relaxed), + usize::from(mode == Mode::ExitAfterEof) + ); + assert_eq!( + events + .stages + .lock() + .unwrap() + .iter() + .any(|stage| matches!(stage, SshStage::Failed(_))), + mode == Mode::DropWithoutStatus + ); + fixture.forget(&acquired.session).await; + } + }); +} + +#[test] +fn hung_channel_does_not_lock_reuse_and_fresh_connection_can_retry() { + check(async { + let fixture = Fixture::new(Mode::HangFirstConnection).await; + let acquired = fixture.connect().await; + let events = Events::default(); + let opening = + open_shell_with_budget(&acquired, size(), None, &events, Duration::from_millis(100)); + let reuse = async { + tokio::time::sleep(Duration::from_millis(20)).await; + let reused = fixture.connect().await; + assert!(Arc::ptr_eq(&reused.session, &acquired.session)); + }; + let (result, ()) = tokio::join!(opening, reuse); + assert!(result.is_err()); + fixture.forget(&acquired.session).await; + let replacement = fixture.connect().await; + assert!(!Arc::ptr_eq(&replacement.session, &acquired.session)); + let (channel, _) = open_shell(&replacement, size(), None, &events).await.unwrap(); + assert!(!super::super::evict_pooled_session(&replacement.key, &acquired.session).await); + let reused = fixture.connect().await; + assert!(Arc::ptr_eq(&reused.session, &replacement.session)); + drop(channel); + fixture.forget(&replacement.session).await; + }); +} diff --git a/nebula_app/tests/file_line_budget.rs b/nebula_app/tests/file_line_budget.rs index 5e0dac0..ad65848 100644 --- a/nebula_app/tests/file_line_budget.rs +++ b/nebula_app/tests/file_line_budget.rs @@ -1,117 +1,103 @@ -//! 文件行数预算守护(docs/project-constraints.md 第 1 条)。 -//! -//! 单个 `.rs` 文件硬上限 2000 行;存量超标文件在 [`GRANDFATHERED`] 登记, -//! 登记值只允许下调。测试失败的正确动作是拆文件,不是上调预算。 - +use std::collections::{HashMap, HashSet}; use std::fs; use std::path::{Path, PathBuf}; -/// 新代码红线:超过它就必须拆(docs/project-constraints.md)。 -const MAX_LINES: usize = 2000; +const POLICY: &str = include_str!("../../architecture/file-budgets.txt"); -/// 存量豁免清单(相对仓库根,正斜杠路径 → 行数上限)。 -/// -/// 登记值 = 收录当天的行数加不足百行的呼吸空间:小修小补不触线,成规模 -/// 新增必须伴随拆分。拆小后同步下调或删除对应条目。 -const GRANDFATHERED: &[(&str, usize)] = &[ - ("nebula_app/src/display/mod.rs", 12000), - ("nebula_app/src/display/settings.rs", 8650), - // 2026-08-31 下调(5150 → 4980):VCS 抽屉页搬到 workspace/vcs_panel.rs。 - ("nebula_app/src/gpui_shell/workspace.rs", 4880), - // 2026-08-17 下调(3600 → 3550):网络区拆到 network_settings.rs。 - ("nebula_app/src/gpui_shell/settings_pane.rs", 3120), - ("nebula_app/src/display/command_palette.rs", 3980), - ("nebula_app/src/window_context.rs", 3750), - ("nebula_app/src/event.rs", 3620), - ("nebula_terminal/src/term/mod.rs", 2760), - // 2026-08-17 上调(2900 → 2970):draw_overlays 几何抽成两壳共享的 plan。 - ("nebula_app/src/display/terminal_math.rs", 2800), - // 2026-08-31 条目删除:side_panel.rs(4031 行)拆成 side_panel/{mod,vcs, - // enumerate,render,tests}.rs,最大的 render.rs 也只有 869 行,全部在红线内。 - ("nebula_app/src/gpui_shell/terminal/view.rs", 2460), - ("nebula_app/src/display/chrome.rs", 2550), - ("nebula_app/src/display/markdown_view.rs", 2150), -]; - -/// 这些目录不受预算约束:构建产物、vendored 第三方代码、版本库内部。 -fn excluded(component: &str) -> bool { - component == ".git" - || component == "third_party" - || component == "target" - || component.starts_with(".target") +fn line_count(content: &[u8]) -> usize { + content.iter().filter(|byte| **byte == b'\n').count() + + usize::from(!content.is_empty() && content.last() != Some(&b'\n')) } -fn collect_rust_files(root: &Path, out: &mut Vec) { - let Ok(entries) = fs::read_dir(root) else { - return; - }; - for entry in entries.flatten() { - let path = entry.path(); +fn collect_sources(root: &Path, files: &mut Vec) { + assert!(!root.is_symlink(), "source directory must not be a symlink: {}", root.display()); + let cargo_root = root.join("Cargo.toml").is_file(); + for entry in fs::read_dir(root).expect("source directories must be readable") { + let entry = entry.expect("source directory entries must be readable"); let name = entry.file_name(); let name = name.to_string_lossy(); - // 隐藏项(.git、.tmp_* 草稿等)不是源码,不受预算约束。 - if name.starts_with('.') { + if name == "node_modules" || name == "__pycache__" || (cargo_root && name == "target") { continue; } + let path = entry.path(); + assert!(!path.is_symlink(), "source must not be a symlink: {}", path.display()); if path.is_dir() { - if !excluded(&name) { - collect_rust_files(&path, out); - } - } else if path.extension().is_some_and(|extension| extension == "rs") { - out.push(path); + collect_sources(&path, files); + } else if path + .extension() + .and_then(|value| value.to_str()) + .is_some_and(|value| matches!(value, "rs" | "py" | "ps1" | "mjs" | "js" | "sh" | "lua")) + { + files.push(path); } } } #[test] fn every_source_file_respects_its_line_budget() { - let repo_root = Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .expect("nebula_app sits directly under the repo root") - .to_path_buf(); - - let mut files = Vec::new(); - collect_rust_files(&repo_root, &mut files); - assert!( - files.len() > 100, - "行数守护扫到的文件数异常({}),排除规则可能吞掉了源码目录", - files.len() - ); - - let mut violations = Vec::new(); - let mut stale_grants = Vec::new(); - for path in files { - let relative = path - .strip_prefix(&repo_root) - .expect("collected files live under the repo root") - .to_string_lossy() - .replace('\\', "/"); - let Ok(content) = fs::read(&path) else { - panic!("无法读取 {relative}"); - }; - // 按换行字节计数:与编码无关,空文件为 0 行。 - let lines = content.iter().filter(|byte| **byte == b'\n').count() - + usize::from(!content.is_empty() && content.last() != Some(&b'\n')); - let grant = - GRANDFATHERED.iter().find(|(name, _)| *name == relative).map(|(_, budget)| *budget); - let budget = grant.unwrap_or(MAX_LINES); - if lines > budget { - violations.push(format!("{relative}: {lines} 行 > 预算 {budget}")); - } - // 豁免文件瘦身到红线以内后,条目必须删除,防止预算复胖。 - if let Some(budget) = grant - && lines <= MAX_LINES - { - stale_grants.push(format!( - "{relative}: 已降到 {lines} 行(≤{MAX_LINES}),豁免条目({budget})应删除" - )); + let repo_root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap(); + let mut limit = None; + let mut roots = HashSet::new(); + let mut exceptions = HashMap::new(); + for record in POLICY.lines().filter(|record| !record.trim().is_empty()) { + let fields = record.split_whitespace().collect::>(); + assert_eq!(fields.len(), 2, "invalid budget record: {record}"); + match fields[0] { + "limit" => { + assert!(limit.is_none(), "duplicate default limit"); + limit = Some(fields[1].parse::().unwrap()); + }, + "root" => assert!(roots.insert(fields[1]), "duplicate source root"), + name => { + let count = fields[1].parse::().unwrap(); + assert!(exceptions.insert(name, count).is_none(), "duplicate exception: {name}"); + }, } } - - assert!( - violations.is_empty(), - "以下文件超出行数预算(docs/project-constraints.md 第 1 条,正确动作是拆文件):\n{}", - violations.join("\n") - ); - assert!(stale_grants.is_empty(), "以下豁免条目已过期:\n{}", stale_grants.join("\n")); + let limit = limit.expect("missing default limit"); + assert!(limit > 0 && !roots.is_empty()); + let mut files = Vec::new(); + for root in roots { + assert!(!Path::new(root).is_absolute() && !root.contains("..") && !root.contains(':')); + let mut directory = repo_root.to_path_buf(); + for component in Path::new(root).components() { + directory.push(component); + assert!(!directory.is_symlink(), "source ancestor must not be a symlink"); + } + collect_sources(&repo_root.join(root), &mut files); + } + assert!(files.len() > 100, "unexpectedly empty source scan: {}", files.len()); + files.sort(); + let mut errors = Vec::new(); + for path in files { + let name = path.strip_prefix(repo_root).unwrap().to_string_lossy().replace('\\', "/"); + let content = fs::read(&path).expect("source files must be readable"); + let count = line_count(&content); + let exception = exceptions.remove(name.as_str()); + let budget = exception.unwrap_or(limit); + if count > budget { + errors.push(format!("{name}: {count} lines > {budget}; split by responsibility")); + } + if exception.is_some() && count <= limit { + errors.push(format!("{name}: remove the obsolete exception ({count} lines)")); + } + } + for name in exceptions.keys() { + errors.push(format!("{name}: remove the missing-source exception")); + } + assert!(errors.is_empty(), "docs/project-constraints.md:\n{}", errors.join("\n")); +} + +#[test] +fn physical_lines_ignore_encoding_and_newline_convention() { + for (content, expected) in [ + ("", 0), + ("\n", 1), + ("a\nb", 2), + ("a\r\nb\r\n", 2), + ("中文\n", 1), + ("vertical\u{000b}tab", 1), + ] { + assert_eq!(line_count(content.as_bytes()), expected); + } } diff --git a/nebula_app/tests/i18n_contract.rs b/nebula_app/tests/i18n_contract.rs new file mode 100644 index 0000000..0862483 --- /dev/null +++ b/nebula_app/tests/i18n_contract.rs @@ -0,0 +1,64 @@ +#[path = "../build/i18n.rs"] +mod catalog_builder; +#[path = "../src/i18n/mod.rs"] +mod i18n; + +use std::alloc::{GlobalAlloc, Layout, System}; +use std::cell::Cell; +use std::hint::black_box; + +thread_local! { + static TRACK_ALLOCATIONS: Cell = const { Cell::new(false) }; + static ALLOCATIONS: Cell = const { Cell::new(0) }; +} + +struct CountingAllocator; + +fn record_allocation() { + let _ = TRACK_ALLOCATIONS.try_with(|tracking| { + if tracking.get() { + let _ = ALLOCATIONS.try_with(|count| count.set(count.get() + 1)); + } + }); +} + +unsafe impl GlobalAlloc for CountingAllocator { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + record_allocation(); + unsafe { System.alloc(layout) } + } + + unsafe fn dealloc(&self, pointer: *mut u8, layout: Layout) { + unsafe { System.dealloc(pointer, layout) } + } + + unsafe fn realloc(&self, pointer: *mut u8, layout: Layout, size: usize) -> *mut u8 { + record_allocation(); + unsafe { System.realloc(pointer, layout, size) } + } +} + +#[global_allocator] +static ALLOCATOR: CountingAllocator = CountingAllocator; + +#[test] +fn first_and_repeated_translation_lookups_allocate_nothing() { + ALLOCATIONS.with(|count| count.set(0)); + TRACK_ALLOCATIONS.with(|tracking| tracking.set(true)); + for language in i18n::UiLanguage::ALL { + for _ in 0..1_000 { + black_box(language.text(black_box(i18n::Message::SettingsSidebarNetwork))); + black_box(language.tr(black_box("settings.sidebar.network"))); + black_box(language.pick(black_box("网络"), black_box("Network"))); + black_box(language.pick(black_box("新文案"), black_box("Unmigrated text"))); + } + } + TRACK_ALLOCATIONS.with(|tracking| tracking.set(false)); + assert_eq!(ALLOCATIONS.with(Cell::get), 0); +} + +#[test] +fn embedded_translations_stay_within_the_initial_payload_budget() { + assert!(i18n::TRANSLATED_BYTES < 256 * 1024); + assert!(i18n::MESSAGE_COUNT >= 200); +} diff --git a/nebula_settings/src/language.rs b/nebula_settings/src/language.rs new file mode 100644 index 0000000..f803d8c --- /dev/null +++ b/nebula_settings/src/language.rs @@ -0,0 +1,141 @@ +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct LanguageInfo { + pub preference: LanguagePref, + pub code: &'static str, + pub native_name: &'static str, + pub component_locale: &'static str, + pub rust_variant: &'static str, +} + +macro_rules! languages { + ($( $variant:ident => ($code:literal, $native:literal, $component:literal) ),+ $(,)?) => { + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] + pub enum LanguagePref { + #[default] + System, + $( $variant, )+ + } + + impl LanguagePref { + pub const ALL: &'static [Self] = &[Self::System, $( Self::$variant, )+]; + pub const VALUES: &'static [&'static str] = &["system", $( $code, )+]; + pub const LANGUAGES: &'static [LanguageInfo] = &[ + $( LanguageInfo { + preference: Self::$variant, + code: $code, + native_name: $native, + component_locale: $component, + rust_variant: stringify!($variant), + }, )+ + ]; + + pub fn from_settings(value: &str) -> Option { + match value.trim() { + "system" => Some(Self::System), + $( $code => Some(Self::$variant), )+ + _ => None, + } + } + + pub const fn settings_value(self) -> &'static str { + match self { + Self::System => "system", + $( Self::$variant => $code, )+ + } + } + + pub const fn native_name(self) -> &'static str { + match self { + Self::System => "System", + $( Self::$variant => $native, )+ + } + } + } + }; +} + +languages! { + ZhCn => ("zh-CN", "简体中文", "zh-CN"), + EnUs => ("en-US", "English", "en"), + ZhTw => ("zh-TW", "繁體中文", "zh-TW"), + FrFr => ("fr-FR", "Français", "en"), + DeDe => ("de-DE", "Deutsch", "en"), + EsEs => ("es-ES", "Español", "en"), + PtBr => ("pt-BR", "Português (Brasil)", "en"), + ItIt => ("it-IT", "Italiano", "it"), + RuRu => ("ru-RU", "Русский", "en"), + JaJp => ("ja-JP", "日本語", "en"), + KoKr => ("ko-KR", "한국어", "en"), +} + +impl LanguagePref { + pub fn from_locale(locale: &str) -> Option { + let locale = locale.trim().split(['.', '@']).next()?; + let mut parts = locale.split(['-', '_']); + let primary = parts.next()?; + if primary.eq_ignore_ascii_case("zh") { + let mut traditional_script = false; + let mut simplified_script = false; + let mut traditional_region = false; + for part in parts { + traditional_script |= part.eq_ignore_ascii_case("Hant"); + simplified_script |= part.eq_ignore_ascii_case("Hans"); + traditional_region |= + ["TW", "HK", "MO"].iter().any(|region| part.eq_ignore_ascii_case(region)); + } + if traditional_script { + return Some(Self::ZhTw); + } + if simplified_script { + return Some(Self::ZhCn); + } + return Some(if traditional_region { Self::ZhTw } else { Self::ZhCn }); + } + Self::LANGUAGES + .iter() + .find(|info| { + info.code.split('-').next().is_some_and(|base| base.eq_ignore_ascii_case(primary)) + }) + .map(|info| info.preference) + } +} + +#[cfg(test)] +mod tests { + use super::LanguagePref; + + #[test] + fn every_language_round_trips_without_changing_saved_values() { + assert_eq!(&LanguagePref::VALUES[..3], &["system", "zh-CN", "en-US"]); + for (preference, value) in LanguagePref::ALL.iter().zip(LanguagePref::VALUES) { + assert_eq!(preference.settings_value(), *value); + assert_eq!(LanguagePref::from_settings(value), Some(*preference)); + assert!(!preference.native_name().is_empty()); + } + assert_eq!(LanguagePref::from_settings("zh"), None); + assert_eq!(LanguagePref::from_settings("unknown"), None); + } + + #[test] + fn system_locale_negotiates_regions_scripts_and_posix_suffixes() { + for (locale, expected) in [ + ("fr_CA.UTF-8", LanguagePref::FrFr), + ("DE-at", LanguagePref::DeDe), + ("es_MX", LanguagePref::EsEs), + ("pt-PT", LanguagePref::PtBr), + ("ja_JP.UTF-8", LanguagePref::JaJp), + ("ko-KR", LanguagePref::KoKr), + ("ru_RU@variant", LanguagePref::RuRu), + ("it-CH", LanguagePref::ItIt), + ("en-GB", LanguagePref::EnUs), + ("zh-Hant", LanguagePref::ZhTw), + ("zh_HK", LanguagePref::ZhTw), + ("zh-Hans-TW", LanguagePref::ZhCn), + ("zh_CN.UTF-8", LanguagePref::ZhCn), + ] { + assert_eq!(LanguagePref::from_locale(locale), Some(expected), "{locale}"); + } + assert_eq!(LanguagePref::from_locale("C.UTF-8"), None); + assert_eq!(LanguagePref::from_locale("unknown"), None); + } +} diff --git a/nebula_settings/src/lib.rs b/nebula_settings/src/lib.rs index ed2cde9..369bcf9 100644 --- a/nebula_settings/src/lib.rs +++ b/nebula_settings/src/lib.rs @@ -18,6 +18,10 @@ use std::path::PathBuf; mod app_icon; pub use app_icon::{AppIconName, AppIconPalette}; +mod language; +pub use language::{LanguageInfo, LanguagePref}; +mod reset; +pub use reset::restore_default_settings; /// Settings/data directory shared by every shell and renderer. /// @@ -572,33 +576,6 @@ impl CursorShapeName { /// 下面的小枚举都是 `nebula_settings.txt` 的键值域,取值与旧壳设置页 /// 逐字一致(parse 宽容:未知值回退默认)。 -#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)] -pub enum LanguagePref { - #[default] - System, - ZhCn, - EnUs, -} - -impl LanguagePref { - pub fn from_settings(value: &str) -> Option { - match value.trim() { - "system" => Some(Self::System), - "zh-CN" => Some(Self::ZhCn), - "en-US" => Some(Self::EnUs), - _ => None, - } - } - - pub fn settings_value(self) -> &'static str { - match self { - Self::System => "system", - Self::ZhCn => "zh-CN", - Self::EnUs => "en-US", - } - } -} - #[derive(Copy, Clone, Debug, PartialEq, Eq, Default)] pub enum AcceptKeyName { Right, diff --git a/nebula_settings/src/reset.rs b/nebula_settings/src/reset.rs new file mode 100644 index 0000000..fd5007e --- /dev/null +++ b/nebula_settings/src/reset.rs @@ -0,0 +1,193 @@ +use std::fs::{self, OpenOptions}; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +const RESET_KEYS: &[&str] = &[ + "language", + "theme", + "app_icon", + "follow_system_theme", + "font_family", + "font_size", + "cursor_shape", + "cursor_blink", + "copy_on_select", + "multiline_paste_confirm", + "tab_close_visible", + "terminal_proxy", + "powerline", + "shell", + "executor", + "startup_directory", + "ghost", + "accept", + "completion_style", + "cjk_bold_regular", + "tabs_position", + "tab_reveal", + "density", + "new_tab_position", + "windowing_behavior", + "cell_width_mode", + "vcs_display", + "bell", + "fetch", + "auto_check_updates", + "keep_session", + "restore_session", + "resume_ai", + "tray", + "blur", + "opacity", + "background", + "background_image", + "background_image_opacity", + "background_image_fit", + "background_image_alignment", + "background_image_cover_chrome", + "panel_resize", + "sidebar_w", + "ssh_proxy_mode", + "ssh_proxy_url", + "ssh_proxy_no_proxy", + "quick_terminal_hotkey", + "pane_card_radius", + "pane_card_gutter", + "pane_card_shadow", + "pane_card_divider", + "keybind", +]; + +static RESET_SEQUENCE: AtomicU64 = AtomicU64::new(0); + +pub fn restore_default_settings() -> io::Result> { + restore_defaults_at(&super::settings_path()) +} + +fn default_settings_text(text: &str) -> String { + text.split_inclusive('\n') + .filter(|line| { + !line.split_once('=').is_some_and(|(key, _)| { + RESET_KEYS.iter().any(|known| key.trim().eq_ignore_ascii_case(known)) + }) + }) + .collect() +} + +fn restore_defaults_at(path: &Path) -> io::Result> { + let original = match fs::read_to_string(path) { + Ok(text) => Some(text), + Err(error) if error.kind() == io::ErrorKind::NotFound => None, + Err(error) => return Err(error), + }; + let restored = default_settings_text(original.as_deref().unwrap_or_default()); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let timestamp = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_nanos(); + let sequence = RESET_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let suffix = format!("{timestamp}-{}-{sequence}", std::process::id()); + let backup = + original.as_ref().map(|_| path.with_extension(format!("before-reset-{suffix}.bak"))); + if let (Some(text), Some(backup)) = (&original, &backup) { + let mut file = OpenOptions::new().write(true).create_new(true).open(backup)?; + file.write_all(text.as_bytes())?; + file.sync_all()?; + } + let temporary = path.with_extension(format!("reset-{suffix}.tmp")); + let mut file = OpenOptions::new().write(true).create_new(true).open(&temporary)?; + let written = file.write_all(restored.as_bytes()).and_then(|_| file.sync_all()); + drop(file); + let result = written.and_then(|_| fs::rename(&temporary, path)); + if result.is_err() { + let _ = fs::remove_file(&temporary); + } + result.map(|_| backup) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{RawSettings, RuntimeSettings}; + + #[test] + fn reset_removes_all_overrides_and_keeps_user_data() { + let text = "# preferences\r\n THEME = Nord\r\ncopy_on_select=1\nkeybind=ctrl+x:Copy\nFONT_SIZE=30\nexecutor=custom\nssh_hosts=saved-host\nai_provider=custom\nfuture_setting=keep\n"; + let result = default_settings_text(text); + assert_eq!( + result, + "# preferences\r\nssh_hosts=saved-host\nai_provider=custom\nfuture_setting=keep\n" + ); + let runtime = RuntimeSettings::from_raw(&RawSettings::from_text(&result)); + let defaults = RuntimeSettings::from_raw(&RawSettings::default()); + assert_eq!(runtime.theme, defaults.theme); + assert_eq!(runtime.copy_on_select, defaults.copy_on_select); + assert!(runtime.font_size_px.is_none()); + assert!(runtime.shell.is_none()); + assert!(crate::keybind_pairs_from_text(&result).is_empty()); + } + + #[test] + fn duplicate_keys_and_legacy_aliases_cannot_override_the_reset() { + let text = "theme=Nord\nTHEME=Paper\nshell=pwsh\nexecutor=cmd\nkeybind=ctrl+a:Copy\nkeybind=ctrl+b:Paste\n"; + assert_eq!(default_settings_text(text), ""); + } + + #[test] + fn reset_covers_every_runtime_settings_key() { + let source = include_str!("lib.rs"); + let reader = source.split("pub fn from_raw(raw: &RawSettings) -> Self").nth(1).unwrap(); + let reader: String = reader + .split("/// 解析") + .next() + .unwrap() + .chars() + .filter(|character| !character.is_whitespace()) + .collect(); + for accessor in ["raw.value(\"", "raw.f32(\"", "raw.bool_on(\""] { + for expression in reader.split(accessor).skip(1) { + let key = expression.split('"').next().unwrap(); + assert!(RESET_KEYS.contains(&key), "missing reset key: {key}"); + } + } + } + + #[test] + fn reset_backs_up_the_original_without_touching_adjacent_files() { + let directory = std::env::temp_dir().join(format!( + "nebula-reset-test-{}-{}", + std::process::id(), + RESET_SEQUENCE.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir_all(&directory).unwrap(); + let path = directory.join("nebula_settings.txt"); + let hosts = directory.join("ssh_hosts.json"); + fs::write(&path, "theme=Nord\nssh_hosts=keep\n").unwrap(); + fs::write(&hosts, "user data").unwrap(); + let backup = restore_defaults_at(&path).unwrap().unwrap(); + assert_eq!(fs::read_to_string(&backup).unwrap(), "theme=Nord\nssh_hosts=keep\n"); + assert_eq!(fs::read_to_string(&path).unwrap(), "ssh_hosts=keep\n"); + assert_eq!(fs::read_to_string(&hosts).unwrap(), "user data"); + let next_backup = restore_defaults_at(&path).unwrap().unwrap(); + assert_ne!(backup, next_backup); + assert_eq!(fs::read_to_string(&backup).unwrap(), "theme=Nord\nssh_hosts=keep\n"); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn invalid_settings_file_is_not_overwritten() { + let directory = std::env::temp_dir().join(format!( + "nebula-reset-invalid-{}-{}", + std::process::id(), + RESET_SEQUENCE.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir_all(&directory).unwrap(); + let path = directory.join("nebula_settings.txt"); + fs::write(&path, [0xff, 0xfe, 0x80]).unwrap(); + assert!(restore_defaults_at(&path).is_err()); + assert_eq!(fs::read(&path).unwrap(), [0xff, 0xfe, 0x80]); + fs::remove_dir_all(directory).unwrap(); + } +} diff --git a/nebula_terminal/src/term/keyboard.rs b/nebula_terminal/src/term/keyboard.rs new file mode 100644 index 0000000..435d352 --- /dev/null +++ b/nebula_terminal/src/term/keyboard.rs @@ -0,0 +1,32 @@ +//! Conversion between terminal flags and the keyboard protocol reported to applications. + +use super::TermMode; +use crate::vte::ansi::KeyboardModes; + +const KEYBOARD_FLAGS: [(KeyboardModes, TermMode); 5] = [ + (KeyboardModes::DISAMBIGUATE_ESC_CODES, TermMode::DISAMBIGUATE_ESC_CODES), + (KeyboardModes::REPORT_EVENT_TYPES, TermMode::REPORT_EVENT_TYPES), + (KeyboardModes::REPORT_ALTERNATE_KEYS, TermMode::REPORT_ALTERNATE_KEYS), + (KeyboardModes::REPORT_ALL_KEYS_AS_ESC, TermMode::REPORT_ALL_KEYS_AS_ESC), + (KeyboardModes::REPORT_ASSOCIATED_TEXT, TermMode::REPORT_ASSOCIATED_TEXT), +]; + +impl From for TermMode { + fn from(value: KeyboardModes) -> Self { + let mut mode = Self::empty(); + for (keyboard_flag, terminal_flag) in KEYBOARD_FLAGS { + mode.set(terminal_flag, value.contains(keyboard_flag)); + } + mode + } +} + +impl From for KeyboardModes { + fn from(value: TermMode) -> Self { + let mut mode = Self::empty(); + for (keyboard_flag, terminal_flag) in KEYBOARD_FLAGS { + mode.set(keyboard_flag, value.contains(terminal_flag)); + } + mode + } +} diff --git a/nebula_terminal/src/term/keyboard_contract_tests.rs b/nebula_terminal/src/term/keyboard_contract_tests.rs new file mode 100644 index 0000000..55ef751 --- /dev/null +++ b/nebula_terminal/src/term/keyboard_contract_tests.rs @@ -0,0 +1,105 @@ +use super::*; + +use crate::term::test::TermSize; +use crate::vte::ansi::Processor; + +#[derive(Clone, Default)] +struct Replies(std::rc::Rc>>); + +impl EventListener for Replies { + fn send_event(&self, event: Event) { + if let Event::PtyWrite(text) = event { + self.0.borrow_mut().push(text); + } + } +} + +fn assert_steps(steps: &[(&[u8], u8)]) { + let replies = Replies::default(); + let mut term = Term::new( + Config { kitty_keyboard: true, ..Config::default() }, + &TermSize::new(80, 24), + replies.clone(), + ); + let mut parser: Processor = Processor::new(); + for &(input, expected) in steps { + parser.advance(&mut term, input); + parser.advance(&mut term, b"\x1b[?u"); + assert_eq!( + std::mem::take(&mut *replies.0.borrow_mut()), + [format!("\x1b[?{expected}u")], + "input={input:?}" + ); + assert_eq!( + *term.mode() & TermMode::KITTY_KEYBOARD_PROTOCOL, + TermMode::from(KeyboardModes::from_bits(expected).unwrap()), + "input={input:?}" + ); + } +} + +#[test] +fn keyboard_contract_set_without_push_survives_nested_modes() { + assert_steps(&[ + (b"\x1b[=5u", 5), + (b"\x1b[>1u", 1), + (b"\x1b[=7u", 7), + (b"\x1b[>3u", 3), + (b"\x1b[1u\x1b[=7u", 7), + (b"\x1b[?1049l", 5), + (b"\x1b[?1049h", 7), + (b"\x1b[?1049l", 5), + ]); +} + +#[test] +fn keyboard_contract_union_and_difference_persist_after_nested_pop() { + assert_steps(&[ + (b"\x1b[>1u", 1), + (b"\x1b[=2;2u", 3), + (b"\x1b[>8u", 8), + (b"\x1b[8u", 8), + (b"\x1b[1u"); + } + assert!(term.keyboard_mode_stack.len() <= KEYBOARD_MODE_STACK_MAX_DEPTH); + assert_eq!(term.title_stack, [Some("saved title".into())]); +} + +#[test] +fn keyboard_contract_reset_clears_both_screens() { + assert_steps(&[ + (b"\x1b[=5u", 5), + (b"\x1b[?1049h\x1b[>3u", 3), + (b"\x1bc", 0), + (b"\x1b[?1049h", 0), + (b"\x1b[?1049l", 0), + ]); +} diff --git a/nebula_terminal/src/term/mod.rs b/nebula_terminal/src/term/mod.rs index cf77e16..92bff98 100644 --- a/nebula_terminal/src/term/mod.rs +++ b/nebula_terminal/src/term/mod.rs @@ -30,6 +30,9 @@ use crate::vte::ansi::{ pub mod cell; pub mod color; mod damage; +mod keyboard; +#[cfg(test)] +mod keyboard_contract_tests; mod renderable; pub mod search; @@ -103,29 +106,6 @@ bitflags! { } } -impl From for TermMode { - fn from(value: KeyboardModes) -> Self { - let mut mode = Self::empty(); - - let disambiguate_esc_codes = value.contains(KeyboardModes::DISAMBIGUATE_ESC_CODES); - mode.set(TermMode::DISAMBIGUATE_ESC_CODES, disambiguate_esc_codes); - - let report_event_types = value.contains(KeyboardModes::REPORT_EVENT_TYPES); - mode.set(TermMode::REPORT_EVENT_TYPES, report_event_types); - - let report_alternate_keys = value.contains(KeyboardModes::REPORT_ALTERNATE_KEYS); - mode.set(TermMode::REPORT_ALTERNATE_KEYS, report_alternate_keys); - - let report_all_keys_as_esc = value.contains(KeyboardModes::REPORT_ALL_KEYS_AS_ESC); - mode.set(TermMode::REPORT_ALL_KEYS_AS_ESC, report_all_keys_as_esc); - - let report_associated_text = value.contains(KeyboardModes::REPORT_ASSOCIATED_TEXT); - mode.set(TermMode::REPORT_ASSOCIATED_TEXT, report_associated_text); - - mode - } -} - impl Default for TermMode { fn default() -> TermMode { TermMode::SHOW_CURSOR @@ -1531,8 +1511,7 @@ impl Handler for Term { } trace!("Reporting active keyboard mode"); - let current_mode = - self.keyboard_mode_stack.last().unwrap_or(&KeyboardModes::NO_MODE).bits(); + let current_mode = KeyboardModes::from(self.mode).bits(); let text = format!("\x1b[?{current_mode}u"); self.event_proxy.send_event(Event::PtyWrite(text)); } @@ -1546,7 +1525,7 @@ impl Handler for Term { trace!("Pushing `{mode:?}` keyboard mode into the stack"); if self.keyboard_mode_stack.len() >= KEYBOARD_MODE_STACK_MAX_DEPTH { - let removed = self.title_stack.remove(0); + let removed = self.keyboard_mode_stack.remove(0); trace!( "Removing '{removed:?}' from bottom of keyboard mode stack that exceeds its \ maximum depth" @@ -1579,6 +1558,14 @@ impl Handler for Term { } self.set_keyboard_mode(mode.into(), apply); + // CSI = changes the current frame, including the implicit base frame. + // Nested applications and screen switches must restore the changed flags. + let active = KeyboardModes::from(self.mode); + if let Some(frame) = self.keyboard_mode_stack.last_mut() { + *frame = active; + } else { + self.keyboard_mode_stack.push(active); + } } #[inline] diff --git a/nebula_terminal/src/term/tests.rs b/nebula_terminal/src/term/tests.rs index d4d9120..719fa65 100644 --- a/nebula_terminal/src/term/tests.rs +++ b/nebula_terminal/src/term/tests.rs @@ -41,6 +41,34 @@ impl EventListener for WriteRecorder { } } +#[test] +fn keyboard_queries_follow_live_flags_after_replace_union_and_difference() { + let size = TermSize::new(5, 5); + let events = WriteRecorder::default(); + let mut term = + Term::new(Config { kitty_keyboard: true, ..Config::default() }, &size, events.clone()); + let mut parser: ansi::Processor = ansi::Processor::new(); + + for (sequence, expected) in [ + ("\x1b[=31u", 31), + ("\x1b[=0u", 0), + ("\x1b[>7u", 7), + ("\x1b[=0u", 0), + ("\x1b[=3;2u", 3), + ("\x1b[=1;3u", 2), + ("\x1b[ int: + return content.count(b"\n") + int(bool(content) and not content.endswith(b"\n")) + + +def relative_path(value: str) -> str: + path = PurePosixPath(value) + if (not value or path.is_absolute() or ".." in path.parts + or "\\" in value or ":" in value or path.as_posix() != value + or any(part.startswith(".") for part in path.parts)): + raise ValueError(f"invalid first-party path: {value!r}") + return value + + +def checked_path(root: Path, relative: str) -> Path: + path = PurePosixPath(relative) + if path.is_absolute() or "\\" in relative or ":" in relative: + raise ValueError(f"invalid repository path: {relative}") + boundary = root.resolve() + current = boundary + for component in path.parts: + current = current.parent if component == ".." else current / component + if not current.is_relative_to(boundary) or current.is_symlink(): + raise ValueError(f"out-of-repository or symlinked source path: {relative}") + return current + + +@dataclass +class Budget: + limit: int + roots: tuple[str, ...] + exceptions: dict[str, int] + + @classmethod + def parse(cls, text: str) -> "Budget": + limit = None + roots = [] + exceptions = {} + for number, record in enumerate(text.splitlines(), 1): + if not record.strip(): + continue + fields = record.split() + if len(fields) != 2: + raise ValueError(f"{BUDGET_PATH}:{number}: expected two fields") + name, value = fields + if name == "limit": + if limit is not None or not value.isascii() or not value.isdecimal() or int(value) < 1: + raise ValueError("invalid or duplicate line limit") + limit = int(value) + elif name == "root": + relative_path(value) + if value in roots: + raise ValueError(f"duplicate source root: {value}") + roots.append(value) + else: + relative_path(name) + if name in exceptions or not value.isascii() or not value.isdecimal(): + raise ValueError(f"invalid or duplicate exception: {name}") + exceptions[name] = int(value) + if limit is None or not roots: + raise ValueError("line budget needs a limit and source roots") + for name, count in exceptions.items(): + if count <= limit or PurePosixPath(name).suffix not in SOURCE_SUFFIXES: + raise ValueError(f"invalid exception: {name} ({count})") + if not any(name.startswith(root + "/") for root in roots): + raise ValueError(f"exception outside source roots: {name}") + for root in roots: + if any(root.startswith(other + "/") for other in roots if other != root): + raise ValueError(f"overlapping source root: {root}") + return cls(limit, tuple(roots), exceptions) + + +def source_files(root: Path, roots: tuple[str, ...]): + def fail(error): + raise error + + for relative in roots: + directory = checked_path(root, relative) + if directory.is_symlink() or not directory.is_dir(): + raise ValueError(f"missing or symlinked source root: {relative}") + for parent, directories, files in os.walk(directory, onerror=fail): + excluded = EXCLUDED | ({"target"} if "Cargo.toml" in files else set()) + directories[:] = sorted( + name for name in directories + if name not in excluded + ) + for name in directories + files: + if (Path(parent) / name).is_symlink(): + raise ValueError(f"symlink in first-party sources: {parent}/{name}") + for name in sorted(files): + path = Path(parent) / name + if path.suffix in SOURCE_SUFFIXES: + yield path + + +def check(root: Path, budget: Budget, previous=None, read_previous=None): + errors = [] + notices = [] + counts = { + path.relative_to(root.resolve()).as_posix(): line_count(path.read_bytes()) + for path in source_files(root, budget.roots) + } + if not counts: + errors.append("no first-party sources found") + if previous is not None: + if budget.limit > previous.limit: + errors.append("line limit may not increase against the base revision") + if not set(previous.roots).issubset(budget.roots): + errors.append("source roots may not be removed from the budget") + for name, limit in budget.exceptions.items(): + if name not in previous.exceptions or limit > previous.exceptions[name]: + errors.append(f"{name}: new or increased exception against base revision") + for name, count in counts.items(): + limit = budget.exceptions.get(name, budget.limit) + if previous is not None and name in previous.exceptions and read_previous: + content = read_previous(name) + if content is None: + errors.append(f"{name}: exception has no source in base revision") + else: + limit = min(limit, max(budget.limit, line_count(content))) + if count > limit: + errors.append(f"{name}: {count} lines > {limit}; split by responsibility") + elif name in budget.exceptions: + if count <= budget.limit: + errors.append(f"{name}: remove the exception; now {count} lines") + elif count < budget.exceptions[name]: + notices.append(f"{name}: tighten exception to {count}") + for name in sorted(budget.exceptions.keys() - counts.keys()): + errors.append(f"{name}: remove missing-source exception") + return errors, notices, counts diff --git a/scripts/architecture/dependencies.py b/scripts/architecture/dependencies.py new file mode 100644 index 0000000..38e9466 --- /dev/null +++ b/scripts/architecture/dependencies.py @@ -0,0 +1,134 @@ +from pathlib import Path +import tomllib + +from scripts.architecture.budgets import checked_path, relative_path + + +POLICY_PATH = "architecture/dependencies.toml" +KINDS = ("dependencies", "build-dependencies", "dev-dependencies") + + +def load(path: Path) -> dict: + return tomllib.loads(path.read_text(encoding="utf-8-sig")) + + +def dependency_entries(manifest: dict, workspace: dict): + for section in [manifest, *manifest.get("target", {}).values()]: + for kind in KINDS: + for alias, original in section.get(kind, {}).items(): + specification = {"version": original} if isinstance(original, str) else original + inherited = specification.get("workspace", False) + if inherited: + if alias not in workspace.get("dependencies", {}): + raise ValueError(f"unresolved workspace dependency: {alias}") + base = workspace["dependencies"][alias] + base = {"version": base} if isinstance(base, str) else base + specification = dict(base, **specification) + yield kind, alias, specification, inherited + + +def cycles(graph: dict[str, set[str]]) -> list[str]: + visited = set() + active = [] + errors = [] + + def visit(node): + if node in active: + start = active.index(node) + errors.append("production dependency cycle: " + " -> ".join(active[start:] + [node])) + return + if node in visited: + return + active.append(node) + for target in sorted(graph[node]): + visit(target) + active.pop() + visited.add(node) + + for node in sorted(graph): + visit(node) + return errors + + +def check(root: Path, policy: dict, source_roots=None, scanned_paths=None): + workspace = load(root / "Cargo.toml")["workspace"] + members = workspace["members"] + if not isinstance(members, list) or not members or len(set(members)) != len(members): + raise ValueError("workspace architecture needs nonempty, unique members") + if any(any(character in member for character in "*?[") for member in members): + raise ValueError("workspace members must be explicit for architecture classification") + for member in members: + relative_path(member) + checked_path(root, member) + if workspace.get("exclude"): + raise ValueError("workspace exclusions require explicit architecture support") + rules = policy.get("crates", {}) + errors = [] + if policy.get("version") != 1: + raise ValueError("unsupported dependency policy version") + if policy.keys() - {"version", "crates", "renderer_packages"}: + raise ValueError("unknown dependency policy field") + for member in sorted(set(members) ^ rules.keys()): + errors.append(f"{member}: workspace membership and architecture classification differ") + manifests = {member: load(root / member / "Cargo.toml") for member in members} + names = {manifest["package"]["name"]: member for member, manifest in manifests.items()} + paths = {(root / member).resolve(): member for member in members} + if len(names) != len(members): + raise ValueError("duplicate workspace package names") + graph = {member: set() for member in members} + source_roots = source_roots if source_roots is not None else members + forbidden = set(policy.get("renderer_packages", [])) + for member, manifest in manifests.items(): + rule = rules.get(member) + if rule is None: + continue + if rule.keys() - {*KINDS, "layer", "zero_production_dependencies"}: + errors.append(f"{member}: unknown dependency rule field") + if rule.get("layer") not in {"core", "application", "lab", "hook"}: + errors.append(f"{member}: invalid architecture layer") + continue + targets = [manifest.get("lib", {})] + targets.extend(target for kind in ("bin", "test", "bench", "example") for target in manifest.get(kind, [])) + build = manifest["package"].get("build") + if isinstance(build, str): + targets.append({"path": build}) + for target_spec in targets: + if "path" in target_spec: + target_path = checked_path(root, member + "/" + target_spec["path"]) + covered = any(target_path.is_relative_to(checked_path(root, source)) for source in source_roots) + if scanned_paths is not None: + covered = target_path.relative_to(root.resolve()).as_posix() in scanned_paths + if not target_path.is_file() or not covered or target_path.suffix != ".rs": + errors.append(f"{member}: invalid custom source path {target_spec['path']}") + for kind in KINDS: + for target in rule.get(kind, []): + if target not in manifests or target == member: + errors.append(f"{member}: invalid {kind} allowance: {target}") + for kind, alias, specification, inherited in dependency_entries(manifest, workspace): + name = specification.get("package", alias) + target = None + if "path" in specification: + directory = root if inherited else root / member + resolved = (directory / specification["path"]).resolve() + local_target = paths.get(resolved) + if local_target is None: + errors.append(f"{member}: unclassified path dependency {alias}") + continue + if names.get(name) != local_target: + errors.append(f"{member}: dependency package/path mismatch for {alias}") + continue + target = local_target + if rule.get("zero_production_dependencies", False) and kind != "dev-dependencies": + errors.append(f"{member}: zero-dependency contract forbids {kind} {alias}") + if rule["layer"] in {"core", "hook"} and kind != "dev-dependencies" and name in forbidden: + errors.append(f"{member}: renderer dependency {name} is not allowed in core") + if target is not None: + if target not in rule.get(kind, []): + errors.append(f"{member}: forbidden {kind} edge to {target}") + if kind != "dev-dependencies": + target_layer = rules.get(target, {}).get("layer") + if ((rule["layer"] == "core" and target_layer != "core") + or (rule["layer"] == "application" and target_layer == "lab")): + errors.append(f"{member}: forbidden production layer direction to {target}") + graph[member].add(target) + return errors + cycles(graph), members diff --git a/scripts/check_architecture.py b/scripts/check_architecture.py new file mode 100644 index 0000000..0375e35 --- /dev/null +++ b/scripts/check_architecture.py @@ -0,0 +1,78 @@ +import argparse +from pathlib import Path +import subprocess +import sys + +if __package__ in (None, ""): + sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from scripts.architecture import budgets, dependencies + + +ROOT = Path(__file__).resolve().parents[1] + + +class Revision: + def __init__(self, root: Path, reference: str): + self.root = root + result = subprocess.run( + ["git", "rev-parse", "--verify", "--end-of-options", reference + "^{commit}"], + cwd=root, capture_output=True, text=True, encoding="utf-8", check=True, + ) + self.commit = result.stdout.strip() + + def read(self, path: str): + result = subprocess.run( + ["git", "--literal-pathspecs", "ls-tree", "--name-only", "-z", self.commit, "--", path], + cwd=self.root, capture_output=True, check=True, + ) + if not result.stdout: + return None + return subprocess.run( + ["git", "show", f"{self.commit}:{path}"], + cwd=self.root, capture_output=True, check=True, + ).stdout + + +def run(root: Path, base: str | None = None, report: bool = False) -> int: + budget = budgets.Budget.parse((root / budgets.BUDGET_PATH).read_text(encoding="utf-8")) + revision = Revision(root, base) if base else None + previous_source = revision.read(budgets.BUDGET_PATH) if revision else None + previous = budgets.Budget.parse(previous_source.decode("utf-8")) if previous_source is not None else None + errors, notices, counts = budgets.check(root, budget, previous, revision.read if revision else None) + dependency_errors, members = dependencies.check( + root, dependencies.load(root / dependencies.POLICY_PATH), budget.roots, set(counts) + ) + errors.extend(dependency_errors) + for member in members: + if member not in budget.roots: + errors.append(f"{member}: workspace member missing from line-budget roots") + print(f"Architecture: {len(counts)} source files, {len(members)} crates, {len(budget.exceptions)} legacy exceptions") + advisory = sorted(((name, count) for name, count in counts.items() if count > 800), key=lambda item: -item[1]) + print(f"Review guidance only: {len(advisory)} files exceed the 800-line soft target") + if revision and previous is None: + print("Initial policy adoption: no budget in base; review the initial debt inventory") + for notice in notices: + print(f"NOTICE: {notice}") + if report: + for name, count in advisory: + print(f"REVIEW: {name}: {count} lines") + for error in errors: + print(f"ERROR: {error}", file=sys.stderr) + return int(bool(errors)) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Offline first-party size and dependency contracts (Python 3.11+)") + parser.add_argument("--base", help="PR base commit; rejects new/increased debt and regrowth") + parser.add_argument("--report", action="store_true", help="also list advisory-size files") + options = parser.parse_args() + try: + return run(ROOT, options.base, options.report) + except (OSError, ValueError, KeyError, subprocess.CalledProcessError) as error: + print(f"Architecture check could not complete: {error}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/platform_cfg_budget.txt b/scripts/platform_cfg_budget.txt index 9bae829..84fa17b 100644 --- a/scripts/platform_cfg_budget.txt +++ b/scripts/platform_cfg_budget.txt @@ -1 +1 @@ -494 +491 diff --git a/scripts/tests/test_architecture_budgets.py b/scripts/tests/test_architecture_budgets.py new file mode 100644 index 0000000..bbfa0f6 --- /dev/null +++ b/scripts/tests/test_architecture_budgets.py @@ -0,0 +1,165 @@ +from pathlib import Path +import io +import subprocess +import tempfile +import unittest +from unittest.mock import patch + +from scripts.architecture.budgets import Budget, check, line_count, source_files +from scripts import check_architecture + + +class LineBudgetTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + (self.root / "app/src").mkdir(parents=True) + + def write(self, name, count): + path = self.root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"source\n" * count) + + def policy(self, exceptions="", limit=2000): + return Budget.parse(f"limit {limit}\nroot app\n{exceptions}") + + def test_physical_lines(self): + for content, expected in [(b"", 0), (b"\n", 1), (b"a\nb", 2), + (b"a\r\nb\r\n", 2), ("中文\n".encode(), 1), + (b"vertical\vtab", 1)]: + with self.subTest(content=content): + self.assertEqual(line_count(content), expected) + + def test_soft_target_is_not_a_failure(self): + for count in (0, 1, 800, 801, 2000): + with self.subTest(count=count): + self.write("app/src/view.rs", count) + self.assertEqual(check(self.root, self.policy())[0], []) + + def test_hard_limit_includes_scripts_and_untracked_files(self): + for suffix in ("rs", "py", "ps1", "mjs", "js", "sh", "lua"): + with self.subTest(suffix=suffix): + self.write(f"app/src/new.{suffix}", 2001) + self.assertTrue(check(self.root, self.policy())[0]) + + def test_exact_exception_and_tightening_notice(self): + self.write("app/src/legacy.rs", 2100) + errors, notices, _ = check(self.root, self.policy("app/src/legacy.rs 2101")) + self.assertEqual(errors, []) + self.assertIn("tighten exception to 2100", notices[0]) + self.assertEqual(check(self.root, self.policy("app/src/legacy.rs 2100"))[0], []) + + def test_deleted_and_shrunk_exceptions_fail(self): + budget = self.policy("app/src/legacy.rs 2100") + self.write("app/src/other.rs", 1) + self.assertIn("missing-source", check(self.root, budget)[0][0]) + self.write("app/src/legacy.rs", 2000) + self.assertIn("remove the exception", check(self.root, budget)[0][0]) + + def test_duplicate_and_invalid_records_fail(self): + for text in ( + "limit 2000\nlimit 2000\nroot app", "root app", "limit 0\nroot app", + "limit 2000\nroot app\nroot app", "limit 2000\nroot ../app", "limit ٢٠٠٠\nroot app", + "limit 2000\nroot /app", "limit 2000\nroot D:/app", + "limit 2000\nroot app\nroot app/src", "limit 2000\nroot app\nbogus 2001", + "limit 2000\nroot app\napp/src/file.rs 2000", + "limit 2000\nroot app\nother/file.rs 2100", + "limit 2000\nroot app\napp/src/file.rs 2100\napp/src/file.rs 2100", + ): + with self.subTest(text=text): + with self.assertRaises(ValueError): + Budget.parse(text) + + def test_scanner_includes_hidden_source_but_not_build_outputs(self): + (self.root / "app/Cargo.toml").write_text("[package]\n", encoding="utf-8") + for name in ("app/src/normal.rs", "app/src/target/real.rs", "app/.internal/source.rs", + "app/target/generated.rs", "tmp/probe.rs", "third_party/copied.rs"): + self.write(name, 1) + names = {path.relative_to(self.root).as_posix() for path in source_files(self.root, ("app",))} + self.assertEqual(names, {"app/src/normal.rs", "app/src/target/real.rs", "app/.internal/source.rs"}) + + def test_missing_empty_and_unreadable_roots_fail(self): + with self.assertRaises(ValueError): + list(source_files(self.root, ("missing",))) + self.assertIn("no first-party sources", check(self.root, self.policy())[0][0]) + with patch("scripts.architecture.budgets.os.walk", side_effect=PermissionError("unreadable")): + with self.assertRaises(PermissionError): + list(source_files(self.root, ("app",))) + + def test_symlinks_are_not_followed(self): + self.write("outside.rs", 1) + try: + (self.root / "app/src/link.rs").symlink_to(self.root / "outside.rs") + except OSError as error: + self.skipTest(f"symlink unavailable: {error}") + with self.assertRaises(ValueError): + list(source_files(self.root, ("app",))) + + def test_symlinked_root_ancestor_is_rejected(self): + try: + (self.root / "redirect").symlink_to(self.root / "app", target_is_directory=True) + except OSError as error: + self.skipTest(f"symlink unavailable: {error}") + with self.assertRaises(ValueError): + list(source_files(self.root, ("redirect/src",))) + + def test_empty_existing_base_budget_is_not_bootstrap(self): + with patch("scripts.check_architecture.Revision") as revision: + revision.return_value.read.return_value = b"" + with self.assertRaises(ValueError): + check_architecture.run(check_architecture.ROOT, base="HEAD") + + def test_base_rejects_limit_increase_and_scope_removal(self): + self.write("app/src/view.rs", 1) + self.assertIn("may not increase", check(self.root, self.policy(limit=2100), self.policy())[0][0]) + previous = Budget.parse("limit 2000\nroot app\nroot other") + self.assertIn("may not be removed", check(self.root, self.policy(), previous)[0][0]) + + def test_base_rejects_new_or_increased_exceptions(self): + self.write("app/src/legacy.rs", 2100) + current = self.policy("app/src/legacy.rs 2100") + self.assertIn("new or increased", check(self.root, current, self.policy())[0][0]) + previous = self.policy("app/src/legacy.rs 2099") + self.assertIn("new or increased", check(self.root, current, previous)[0][0]) + + def test_base_actual_size_prevents_regrowth_under_stale_budget(self): + self.write("app/src/legacy.rs", 2101) + budget = self.policy("app/src/legacy.rs 2200") + errors = check(self.root, budget, budget, lambda name: b"line\n" * 2100)[0] + self.assertIn("2101 lines > 2100", errors[0]) + + def test_base_that_already_shrank_below_limit_cannot_regrow(self): + self.write("app/src/legacy.rs", 2100) + budget = self.policy("app/src/legacy.rs 2200") + errors = check(self.root, budget, budget, lambda name: b"line\n" * 1800)[0] + self.assertIn("2100 lines > 2000", errors[0]) + + def test_missing_base_source_is_an_error(self): + self.write("app/src/legacy.rs", 2100) + budget = self.policy("app/src/legacy.rs 2100") + self.assertIn("no source in base", check(self.root, budget, budget, lambda name: None)[0][0]) + + def test_invalid_base_is_not_treated_as_bootstrap(self): + with patch("scripts.check_architecture.subprocess.run", side_effect=subprocess.CalledProcessError(128, "git")): + with self.assertRaises(subprocess.CalledProcessError): + check_architecture.Revision(self.root, "missing") + + def test_unreadable_base_tree_is_not_treated_as_missing_budget(self): + revision = object.__new__(check_architecture.Revision) + revision.root = self.root + revision.commit = "a" * 40 + with patch("scripts.check_architecture.subprocess.run", side_effect=subprocess.CalledProcessError(128, "git")): + with self.assertRaises(subprocess.CalledProcessError): + revision.read("architecture/file-budgets.txt") + + def test_cli_fails_closed_when_configuration_cannot_be_read(self): + with (patch.object(check_architecture, "ROOT", self.root), + patch("sys.argv", ["check_architecture"]), + patch("sys.stderr", new_callable=io.StringIO) as output): + self.assertEqual(check_architecture.main(), 1) + self.assertIn("could not complete", output.getvalue()) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_architecture_dependencies.py b/scripts/tests/test_architecture_dependencies.py new file mode 100644 index 0000000..722ab60 --- /dev/null +++ b/scripts/tests/test_architecture_dependencies.py @@ -0,0 +1,166 @@ +from pathlib import Path +import tempfile +import unittest + +from scripts.architecture.dependencies import check + + +class DependencyTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.workspace() + self.manifest("app", "application") + self.manifest("core", "core-model") + self.manifest("derive", "derive-helper") + self.policy = { + "version": 1, + "renderer_packages": ["gpui", "winit"], + "crates": { + "app": {"layer": "application", "dependencies": ["core"]}, + "core": {"layer": "core", "dev-dependencies": ["derive"]}, + "derive": {"layer": "core", "dev-dependencies": ["core"]}, + }, + } + + def workspace(self, extra="", members='"app", "core", "derive"'): + (self.root / "Cargo.toml").write_text(f"[workspace]\nmembers = [{members}]\n{extra}", encoding="utf-8") + + def manifest(self, member, package, extra=""): + directory = self.root / member + directory.mkdir(exist_ok=True) + (directory / "Cargo.toml").write_text( + f'[package]\nname = "{package}"\nversion = "0.0.0"\n{extra}', encoding="utf-8" + ) + + def errors(self): + return check(self.root, self.policy)[0] + + def test_legal_inward_dependency_and_package_rename(self): + self.manifest("app", "application", '[dependencies]\nmodel = { package = "core-model", path = "../core" }') + self.assertEqual(self.errors(), []) + + def test_reverse_dependency_is_rejected(self): + self.manifest("core", "core-model", '[dependencies]\napplication = { path = "../app" }') + self.assertIn("forbidden dependencies edge to app", self.errors()[0]) + + def test_core_cannot_whitelist_a_reverse_production_layer(self): + self.policy["crates"]["core"]["dependencies"] = ["app"] + self.manifest("core", "core-model", '[dependencies]\napplication = { path = "../app" }') + self.assertIn("forbidden production layer direction", self.errors()[0]) + + def test_optional_target_and_renamed_renderer_are_checked(self): + self.manifest("core", "core-model", '''[target.'cfg(windows)'.dependencies] +display_adapter = { package = "gpui", version = "0.2", optional = true }''') + self.assertIn("renderer dependency gpui", self.errors()[0]) + + def test_workspace_inheritance_resolves_paths_from_workspace_root(self): + self.workspace('[workspace.dependencies]\nmodel = { package = "core-model", path = "core" }') + self.manifest("app", "application", '[dependencies]\nmodel.workspace = true') + self.assertEqual(self.errors(), []) + + def test_workspace_inherited_renderer_is_checked(self): + self.workspace('[workspace.dependencies]\nwindow = { package = "winit", version = "0.30" }') + self.manifest("core", "core-model", '[dependencies]\nwindow.workspace = true') + self.assertIn("renderer dependency winit", self.errors()[0]) + + def test_unresolved_workspace_dependency_fails(self): + self.manifest("core", "core-model", '[dependencies]\nmissing.workspace = true') + with self.assertRaises(ValueError): + self.errors() + + def test_build_dependencies_are_checked(self): + self.manifest("core", "core-model", '[build-dependencies]\napplication = { path = "../app" }') + self.assertIn("forbidden build-dependencies", self.errors()[0]) + + def test_production_cycle_is_rejected_even_when_edges_are_allowed(self): + self.policy["crates"]["core"]["build-dependencies"] = ["app"] + self.manifest("core", "core-model", '[build-dependencies]\napplication = { path = "../app" }') + self.manifest("app", "application", '[dependencies]\ncore-model = { path = "../core" }') + self.assertTrue(any("production dependency cycle" in error for error in self.errors())) + + def test_cargo_legal_dev_cycle_is_not_a_production_cycle(self): + self.manifest("core", "core-model", '[dev-dependencies]\nderive-helper = { path = "../derive" }') + self.manifest("derive", "derive-helper", '[dev-dependencies]\ncore-model = { path = "../core" }') + self.assertEqual(self.errors(), []) + + def test_zero_production_dependencies_allow_test_tools(self): + self.policy["crates"]["core"]["zero_production_dependencies"] = True + self.manifest("core", "core-model", '[dev-dependencies]\nproptest = "1"') + self.assertEqual(self.errors(), []) + self.manifest("core", "core-model", '[dependencies]\nserde = "1"') + self.assertIn("zero-dependency contract", self.errors()[0]) + + def test_unclassified_workspace_member_is_rejected(self): + self.manifest("lab", "lab") + self.workspace(members='"app", "core", "derive", "lab"') + self.assertIn("classification differ", self.errors()[0]) + + def test_unclassified_path_dependency_cannot_hide_an_edge(self): + self.manifest("app", "application", '[dependencies]\nmodel = { path = "../unknown" }') + self.assertIn("unclassified path dependency", self.errors()[0]) + + def test_mismatched_package_and_path_is_rejected(self): + self.manifest("app", "application", '[dependencies]\nderive-helper = { path = "../core" }') + self.assertIn("package/path mismatch", self.errors()[0]) + + def test_comment_or_module_alias_is_not_a_dependency(self): + self.manifest("core", "core-model", '# crate::gpui and #[path = "../display.rs"] are not dependencies') + (self.root / "core/source.rs").write_text('const TEXT: &str = "gpui::Window";', encoding="utf-8") + self.assertEqual(self.errors(), []) + + def test_invalid_toml_and_unknown_policy_fields_fail(self): + self.policy["crates"]["core"]["zero_dependenices"] = True + self.assertIn("unknown dependency rule field", self.errors()[0]) + (self.root / "core/Cargo.toml").write_text("[broken", encoding="utf-8") + with self.assertRaises(ValueError): + self.errors() + + def test_unhandled_workspace_layout_fails_instead_of_skipping(self): + for extra, members in [('exclude = ["other"]', '"app", "core", "derive"'), + ("", '"crates/*"'), ("", '"../outside"')]: + with self.subTest(extra=extra, members=members): + self.workspace(extra, members) + with self.assertRaises(ValueError): + self.errors() + + def test_empty_or_duplicate_workspace_cannot_pass(self): + for members in ("", '"app", "app"'): + with self.subTest(members=members): + self.workspace(members=members) + with self.assertRaises(ValueError): + self.errors() + + def test_custom_source_paths_cannot_escape_coverage(self): + self.manifest("core", "core-model", '[lib]\npath = "../outside.rs"') + self.assertIn("invalid custom source path", self.errors()[0]) + + def test_same_package_from_registry_is_not_a_workspace_edge(self): + self.manifest("app", "application", '[dev-dependencies]\nprevious = { package = "core-model", version = "=0.9.0" }') + self.assertEqual(self.errors(), []) + + def test_shared_custom_source_is_allowed_inside_scanned_roots(self): + (self.root / "derive/shared.rs").write_text("", encoding="utf-8") + self.manifest("core", "core-model", '[lib]\npath = "../derive/shared.rs"') + self.assertEqual(self.errors(), []) + + def test_custom_target_inside_excluded_output_is_rejected(self): + (self.root / "core/target").mkdir() + (self.root / "core/target/real.rs").write_text("", encoding="utf-8") + self.manifest("core", "core-model", '[lib]\npath = "target/real.rs"') + errors = check(self.root, self.policy, scanned_paths=set())[0] + self.assertIn("invalid custom source path", errors[0]) + + def test_symlinked_workspace_ancestor_is_rejected(self): + try: + (self.root / "redirect").symlink_to(self.root, target_is_directory=True) + except OSError as error: + self.skipTest(f"symlink unavailable: {error}") + self.workspace(members='"redirect/core"') + with self.assertRaises(ValueError): + self.errors() + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_architecture_governance.py b/scripts/tests/test_architecture_governance.py new file mode 100644 index 0000000..f6f3069 --- /dev/null +++ b/scripts/tests/test_architecture_governance.py @@ -0,0 +1,119 @@ +from pathlib import Path +import re +import subprocess +import unittest + + +ROOT = Path(__file__).resolve().parents[2] +DOCUMENTS = ( + "CONTRIBUTING.md", "docs/project-constraints.md", "docs/architecture.md", + "docs/architecture-decisions.md", "docs/engineering-evidence.md", + "docs/internationalization.md", +) + + +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", + capture_output=True, + ) + self.assertIn(result.returncode, (0, 1), result.stderr) + return set(result.stdout.splitlines()) + + def test_contributor_documents_exist_and_are_not_ignored(self): + for name in DOCUMENTS: + with self.subTest(name=name): + self.assertTrue((ROOT / name).is_file()) + self.assertEqual(self.ignored_paths(DOCUMENTS), set(), "public contributor docs must not be ignored") + + def test_private_investigations_remain_ignored(self): + paths = ( + "docs/private-product-comparison.md", "docs/new-investigation/README.md", + "docs/screenshots/vendor-gap-analysis.md", "docs/release-notes/vendor-study.md", + "docs/screenshots/vendor-research.md", "docs/screenshots/vendor-competitor-notes.md", + "docs/screenshots/research/raw-capture.json", + "docs/release-notes/reference-projects/vendor/src/main.rs", + "docs/screenshots/external-probes/inspect.ps1", + "research/vendor/README.md", "reference-projects/vendor/src/main.rs", + "external-probes/inspect-vendor.py", "impeccable/research.md", + ) + self.assertEqual(self.ignored_paths(paths), set(paths)) + + def test_html_prototypes_do_not_escape_through_public_doc_directories(self): + paths = ( + "settings.html", "settings.htm", "docs/design/settings/index.html", + "docs/screenshots/settings.html", "docs/release-notes/demo.htm", + "scripts/design/settings-prototype.html", "tools/design/settings_mockup.html", + "scripts/design/settings-prototype.htm", "tools/design/settings_mockup.htm", + "prototypes/sidebar/assets/preview.png", "scripts/render_shell_icon.html", + ) + self.assertEqual(self.ignored_paths(paths), set(paths)) + + def test_scratch_scripts_and_caches_remain_ignored(self): + paths = ( + "tmp/test-expanded-icons-20260905.ps1", "tmp/check-tool.py", + "tmp/vendor-probe/main.rs", "tmp-check.ps1", "tmp_check.py", + "scratch/inspect-window.ps1", ".tmp-check.py", ".probe-round/probe.ps1", + "scripts/ghost_repro/probe.ps1", "scripts/drawer_repro.ps1", + "scripts/__pycache__/helper.cpython-313.pyc", "tools/diagnostic.pyc", + "scripts/diagnostic.pyo", ".pytest_cache/v/cache/nodeids", + ".mypy_cache/check.json", ".ruff_cache/cache.bin", + ) + self.assertEqual(self.ignored_paths(paths), set(paths)) + + def test_maintained_tests_dependencies_and_public_assets_stay_visible(self): + paths = ( + *DOCUMENTS, ".github/CODEOWNERS", ".github/PULL_REQUEST_TEMPLATE.md", + ".github/workflows/architecture.yml", "architecture/dependencies.toml", + "architecture/file-budgets.txt", "scripts/check_architecture.py", + "scripts/tests/test_architecture_governance.py", "scripts/tests/new_regression.py", + "scripts/conformance/tests/test_harness.py", "scripts/launch_probe_instance.ps1", + "scripts/probe_ssh_connect.ps1", "tools/probe_ime.rs", + "tools/i18n-contract/Cargo.toml", "tools/i18n-contract/Cargo.lock", + "nebula_app/tests/i18n_contract.rs", "nebula_app/tests/fixtures/page.html", + "nebula_app/i18n/fr-FR.json", "third_party/winit-0.30.13/Cargo.toml", + "third_party/winit-0.30.13/src/lib.rs", "docs/screenshots/SHOTLIST.md", + "docs/screenshots/hero.png", "docs/release-notes/v1.5.0.md", + "docs/skills/nebula-runtime/SKILL.md", + ) + self.assertEqual(self.ignored_paths(paths), set()) + + def test_relative_document_links_resolve(self): + for name in DOCUMENTS: + document = ROOT / name + text = document.read_text(encoding="utf-8") + for link in re.findall(r"\]\(([^)]+)\)", text): + if "://" in link or link.startswith("#"): + continue + target = link.split("#", 1)[0] + with self.subTest(document=name, target=target): + self.assertTrue((document.parent / target).exists()) + + def test_required_job_is_unfiltered_and_unprivileged(self): + workflow = (ROOT / ".github/workflows/architecture.yml").read_text(encoding="utf-8") + for required in (" pull_request:", " merge_group:", "contents: read", + "name: architecture-contracts", "persist-credentials: false", + "fetch-depth: 0", "--base", "tools/i18n-contract/Cargo.toml --locked", + "scripts.tests.test_architecture_governance"): + with self.subTest(required=required): + self.assertIn(required, workflow) + for unsafe in ("pull_request_target:", "continue-on-error:", "paths:", "paths-ignore:"): + with self.subTest(unsafe=unsafe): + self.assertNotIn(unsafe, workflow) + + def test_review_and_activation_contracts_are_documented(self): + owners = (ROOT / ".github/CODEOWNERS").read_text(encoding="utf-8") + self.assertRegex(owners, r"(?m)^\*\s+@[A-Za-z0-9]") + template = (ROOT / ".github/PULL_REQUEST_TEMPLATE.md").read_text(encoding="utf-8") + self.assertIn("CONTRIBUTING.md", template) + self.assertIn("counterexample", template) + constraints = (ROOT / "docs/project-constraints.md").read_text(encoding="utf-8") + self.assertIn("do **not** activate GitHub merge protection", constraints) + self.assertIn("Empty/invalid budgets", constraints) + self.assertIn("an author cannot approve their own PR", constraints) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_pebrel_branding.py b/scripts/tests/test_pebrel_branding.py index f6768c2..7027845 100644 --- a/scripts/tests/test_pebrel_branding.py +++ b/scripts/tests/test_pebrel_branding.py @@ -66,7 +66,7 @@ class PebrelBrandingTests(unittest.TestCase): self.assertNotIn("Pebrel-", download) def test_notification_identity_is_not_renamed(self): - source = self.source("nebula_app/src/notify.rs") + source = self.source("nebula_app/src/platform/notifications.rs") self.assertIn('AUMID: &str = "com.nebula.terminal";', source) self.assertIn('set_reg_sz(&subkey, "DisplayName", crate::brand::NAME)', source) diff --git a/tools/i18n-contract/Cargo.lock b/tools/i18n-contract/Cargo.lock new file mode 100644 index 0000000..f23a136 --- /dev/null +++ b/tools/i18n-contract/Cargo.lock @@ -0,0 +1,185 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "nebula-settings" +version = "1.5.0" + +[[package]] +name = "pebrel-i18n-contract" +version = "0.0.0" +dependencies = [ + "nebula-settings", + "serde", + "serde_json", + "windows-sys", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/tools/i18n-contract/Cargo.toml b/tools/i18n-contract/Cargo.toml new file mode 100644 index 0000000..47deb3b --- /dev/null +++ b/tools/i18n-contract/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "pebrel-i18n-contract" +version = "0.0.0" +edition = "2024" +publish = false + +[workspace] + +[lib] +path = "../../nebula_app/tests/i18n_contract.rs" + +[dependencies] +nebula-settings = { path = "../../nebula_settings" } +serde = "1" +serde_json = "1" + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.59", features = ["Win32_Globalization"] } + +[build-dependencies] +nebula-settings = { path = "../../nebula_settings" } +serde = "1" +serde_json = "1" + +[profile.dev] +debug = 0 +incremental = false + +[profile.release] +debug = 0 +incremental = false diff --git a/tools/i18n-contract/build.rs b/tools/i18n-contract/build.rs new file mode 100644 index 0000000..09fa0c6 --- /dev/null +++ b/tools/i18n-contract/build.rs @@ -0,0 +1,8 @@ +#[path = "../../nebula_app/build/i18n.rs"] +mod i18n; + +fn main() { + let root = std::path::PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()); + let output = std::path::PathBuf::from(std::env::var("OUT_DIR").unwrap()); + i18n::generate(&root.join("../../nebula_app/i18n"), &output.join("translations.rs")).unwrap(); +} From ff4a3932231b806b1a70d9ec7e64dddef4698107 Mon Sep 17 00:00:00 2001 From: kuddev Date: Sun, 6 Sep 2026 18:20:53 +0800 Subject: [PATCH 04/31] test: replay observed Antigravity keyboard negotiation Check the captured startup sequence that enables Win32 mode, replaces Kitty flags with zero then one, and queries the active flags. Assert Shift/Ctrl+Enter and Ctrl+J encoding and reset compatibility. This covers protocol negotiation before login, not authenticated prompt behavior. --- nebula_app/src/gpui_shell/terminal/keymap.rs | 42 ++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/nebula_app/src/gpui_shell/terminal/keymap.rs b/nebula_app/src/gpui_shell/terminal/keymap.rs index 7c924fa..655ee79 100644 --- a/nebula_app/src/gpui_shell/terminal/keymap.rs +++ b/nebula_app/src/gpui_shell/terminal/keymap.rs @@ -728,6 +728,48 @@ mod tests { } } + #[test] + fn antigravity_startup_queries_applied_flags_and_preserves_newline_chords() { + use nebula_terminal::term::Config; + use nebula_terminal::vte::ansi::Processor; + + let size = super::super::session::GridSize { columns: 80, screen_lines: 24 }; + let recorder = KeyboardReplyRecorder::default(); + let mut term = nebula_terminal::Term::new( + Config { kitty_keyboard: true, ..Config::default() }, + &size, + recorder.clone(), + ); + let mut parser: Processor = Processor::new(); + // Captured from AGY 1.1.7's outer ConPTY stream before authentication. + // The host's 9001 mode must not override the later Kitty negotiation. + parser.advance(&mut term, b"\x1b[?9001h\x1b[=0;1u\x1b[=1;1u\x1b[?u"); + assert!(term.mode().contains(TermMode::WIN32_INPUT_MODE)); + assert_eq!( + *term.mode() & TermMode::KITTY_KEYBOARD_PROTOCOL, + TermMode::DISAMBIGUATE_ESC_CODES + ); + for (name, shift, control, expected) in [ + ("enter", false, false, "\r"), + ("enter", true, false, "\x1b[13;2u"), + ("enter", false, true, "\x1b[13;5u"), + ("j", false, true, "\x1b[106;5u"), + ] { + let mut key = keystroke(name); + key.modifiers.shift = shift; + key.modifiers.control = control; + assert_eq!(encode(&key, term.mode()), Some(expected.as_bytes().to_vec())); + } + + // A later reset must restore the legacy path without a stale reply. + parser.advance(&mut term, b"\x1b[=0;1u\x1b[?u\x1b[?9001l"); + assert_eq!(recorder.0.borrow().as_slice(), ["\x1b[?1u", "\x1b[?0u"]); + assert_eq!(encode(&keystroke("enter"), term.mode()), Some(b"\r".to_vec())); + let mut ctrl_j = keystroke("j"); + ctrl_j.modifiers.control = true; + assert_eq!(encode(&ctrl_j, term.mode()), Some(b"\n".to_vec())); + } + #[test] fn codex_keyboard_negotiation_preserves_enter_chords_until_reset() { use nebula_terminal::term::Config; From 013aeb85355d743437d8e3aa47ca605c1848d115 Mon Sep 17 00:00:00 2001 From: kuddev Date: Sun, 6 Sep 2026 18:42:31 +0800 Subject: [PATCH 05/31] fix(ci): resolve runner temporary directory at step scope GitHub rejects runner.temp in jobs..env before scheduling any job. Put the translation target directory on its Cargo step, where the runner context is available. --- .github/workflows/architecture.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/architecture.yml b/.github/workflows/architecture.yml index acaf4bc..291125c 100644 --- a/.github/workflows/architecture.yml +++ b/.github/workflows/architecture.yml @@ -22,7 +22,6 @@ jobs: env: PYTHONUTF8: "1" PYTHONDONTWRITEBYTECODE: "1" - CARGO_TARGET_DIR: ${{ runner.temp }}/pebrel-i18n-contract steps: - uses: actions/checkout@v4 with: @@ -63,6 +62,8 @@ jobs: -o "$RUNNER_TEMP/pebrel-contracts/line-budget" "$RUNNER_TEMP/pebrel-contracts/line-budget" - name: Test renderer-independent translation and allocation contracts + env: + CARGO_TARGET_DIR: ${{ runner.temp }}/pebrel-i18n-contract run: cargo test --manifest-path tools/i18n-contract/Cargo.toml --locked - name: Test branding and image compatibility run: >- From af7db8400d651428f721fe28f4687ca61564d2b3 Mon Sep 17 00:00:00 2001 From: kuddev Date: Sun, 6 Sep 2026 20:38:19 +0800 Subject: [PATCH 06/31] fix(gpui): adopt unavailable native handles for virtual windows Use the narrowly patched GPUI TestWindow contract so macOS password input tests reach the existing no-native-handle path. Synchronize all application and component GPUI revisions, with the original baseline and removal condition recorded in Cargo.toml. --- Cargo.lock | 50 +++++++++++++++++++++++++------------------------- Cargo.toml | 18 ++++++++++++++---- 2 files changed, 39 insertions(+), 29 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 12bdde5..1ba59de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1349,7 +1349,7 @@ dependencies = [ [[package]] name = "collections" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=eb8e1c8b5502b7007465fbbc465f4a736fa39210#eb8e1c8b5502b7007465fbbc465f4a736fa39210" +source = "git+https://github.com/Kuddev/zed?rev=1b913031d01f5b3df8fcab3e8c2ac3568e7f5524#1b913031d01f5b3df8fcab3e8c2ac3568e7f5524" dependencies = [ "gpui_util", "indexmap", @@ -1959,7 +1959,7 @@ dependencies = [ [[package]] name = "derive_refineable" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=eb8e1c8b5502b7007465fbbc465f4a736fa39210#eb8e1c8b5502b7007465fbbc465f4a736fa39210" +source = "git+https://github.com/Kuddev/zed?rev=1b913031d01f5b3df8fcab3e8c2ac3568e7f5524#1b913031d01f5b3df8fcab3e8c2ac3568e7f5524" dependencies = [ "proc-macro2", "quote", @@ -3094,7 +3094,7 @@ dependencies = [ [[package]] name = "gpui" version = "0.2.2" -source = "git+https://github.com/Kuddev/zed?rev=eb8e1c8b5502b7007465fbbc465f4a736fa39210#eb8e1c8b5502b7007465fbbc465f4a736fa39210" +source = "git+https://github.com/Kuddev/zed?rev=1b913031d01f5b3df8fcab3e8c2ac3568e7f5524#1b913031d01f5b3df8fcab3e8c2ac3568e7f5524" dependencies = [ "accesskit", "anyhow", @@ -3180,7 +3180,7 @@ dependencies = [ [[package]] name = "gpui-component" version = "0.5.2" -source = "git+https://github.com/Kuddev/gpui-component?rev=fa3763c681dba5b3ea028395148488e1281d478d#fa3763c681dba5b3ea028395148488e1281d478d" +source = "git+https://github.com/Kuddev/gpui-component?rev=05bb03f54d08b80ac059f7daea52707745e05956#05bb03f54d08b80ac059f7daea52707745e05956" dependencies = [ "aho-corasick", "anyhow", @@ -3263,7 +3263,7 @@ dependencies = [ [[package]] name = "gpui-component-assets" version = "0.5.1" -source = "git+https://github.com/Kuddev/gpui-component?rev=fa3763c681dba5b3ea028395148488e1281d478d#fa3763c681dba5b3ea028395148488e1281d478d" +source = "git+https://github.com/Kuddev/gpui-component?rev=05bb03f54d08b80ac059f7daea52707745e05956#05bb03f54d08b80ac059f7daea52707745e05956" dependencies = [ "anyhow", "gpui", @@ -3277,7 +3277,7 @@ dependencies = [ [[package]] name = "gpui-component-macros" version = "0.5.1" -source = "git+https://github.com/Kuddev/gpui-component?rev=fa3763c681dba5b3ea028395148488e1281d478d#fa3763c681dba5b3ea028395148488e1281d478d" +source = "git+https://github.com/Kuddev/gpui-component?rev=05bb03f54d08b80ac059f7daea52707745e05956#05bb03f54d08b80ac059f7daea52707745e05956" dependencies = [ "proc-macro2", "quote", @@ -3287,7 +3287,7 @@ dependencies = [ [[package]] name = "gpui_linux" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=eb8e1c8b5502b7007465fbbc465f4a736fa39210#eb8e1c8b5502b7007465fbbc465f4a736fa39210" +source = "git+https://github.com/Kuddev/zed?rev=1b913031d01f5b3df8fcab3e8c2ac3568e7f5524#1b913031d01f5b3df8fcab3e8c2ac3568e7f5524" dependencies = [ "accesskit", "accesskit_unix", @@ -3339,7 +3339,7 @@ dependencies = [ [[package]] name = "gpui_macos" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=eb8e1c8b5502b7007465fbbc465f4a736fa39210#eb8e1c8b5502b7007465fbbc465f4a736fa39210" +source = "git+https://github.com/Kuddev/zed?rev=1b913031d01f5b3df8fcab3e8c2ac3568e7f5524#1b913031d01f5b3df8fcab3e8c2ac3568e7f5524" dependencies = [ "accesskit", "accesskit_macos", @@ -3387,7 +3387,7 @@ dependencies = [ [[package]] name = "gpui_macros" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=eb8e1c8b5502b7007465fbbc465f4a736fa39210#eb8e1c8b5502b7007465fbbc465f4a736fa39210" +source = "git+https://github.com/Kuddev/zed?rev=1b913031d01f5b3df8fcab3e8c2ac3568e7f5524#1b913031d01f5b3df8fcab3e8c2ac3568e7f5524" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -3398,7 +3398,7 @@ dependencies = [ [[package]] name = "gpui_platform" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=eb8e1c8b5502b7007465fbbc465f4a736fa39210#eb8e1c8b5502b7007465fbbc465f4a736fa39210" +source = "git+https://github.com/Kuddev/zed?rev=1b913031d01f5b3df8fcab3e8c2ac3568e7f5524#1b913031d01f5b3df8fcab3e8c2ac3568e7f5524" dependencies = [ "console_error_panic_hook", "gpui", @@ -3411,7 +3411,7 @@ dependencies = [ [[package]] name = "gpui_shared_string" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=eb8e1c8b5502b7007465fbbc465f4a736fa39210#eb8e1c8b5502b7007465fbbc465f4a736fa39210" +source = "git+https://github.com/Kuddev/zed?rev=1b913031d01f5b3df8fcab3e8c2ac3568e7f5524#1b913031d01f5b3df8fcab3e8c2ac3568e7f5524" dependencies = [ "schemars", "serde", @@ -3421,7 +3421,7 @@ dependencies = [ [[package]] name = "gpui_util" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=eb8e1c8b5502b7007465fbbc465f4a736fa39210#eb8e1c8b5502b7007465fbbc465f4a736fa39210" +source = "git+https://github.com/Kuddev/zed?rev=1b913031d01f5b3df8fcab3e8c2ac3568e7f5524#1b913031d01f5b3df8fcab3e8c2ac3568e7f5524" dependencies = [ "anyhow", "log", @@ -3431,7 +3431,7 @@ dependencies = [ [[package]] name = "gpui_web" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=eb8e1c8b5502b7007465fbbc465f4a736fa39210#eb8e1c8b5502b7007465fbbc465f4a736fa39210" +source = "git+https://github.com/Kuddev/zed?rev=1b913031d01f5b3df8fcab3e8c2ac3568e7f5524#1b913031d01f5b3df8fcab3e8c2ac3568e7f5524" dependencies = [ "anyhow", "console_error_panic_hook", @@ -3454,7 +3454,7 @@ dependencies = [ [[package]] name = "gpui_wgpu" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=eb8e1c8b5502b7007465fbbc465f4a736fa39210#eb8e1c8b5502b7007465fbbc465f4a736fa39210" +source = "git+https://github.com/Kuddev/zed?rev=1b913031d01f5b3df8fcab3e8c2ac3568e7f5524#1b913031d01f5b3df8fcab3e8c2ac3568e7f5524" dependencies = [ "anyhow", "bytemuck", @@ -3484,7 +3484,7 @@ dependencies = [ [[package]] name = "gpui_windows" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=eb8e1c8b5502b7007465fbbc465f4a736fa39210#eb8e1c8b5502b7007465fbbc465f4a736fa39210" +source = "git+https://github.com/Kuddev/zed?rev=1b913031d01f5b3df8fcab3e8c2ac3568e7f5524#1b913031d01f5b3df8fcab3e8c2ac3568e7f5524" dependencies = [ "accesskit", "accesskit_windows", @@ -3780,7 +3780,7 @@ dependencies = [ [[package]] name = "http_client" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=eb8e1c8b5502b7007465fbbc465f4a736fa39210#eb8e1c8b5502b7007465fbbc465f4a736fa39210" +source = "git+https://github.com/Kuddev/zed?rev=1b913031d01f5b3df8fcab3e8c2ac3568e7f5524#1b913031d01f5b3df8fcab3e8c2ac3568e7f5524" dependencies = [ "anyhow", "async-compression", @@ -4742,7 +4742,7 @@ checksum = "7ebb8d8732c6a6df3d8f032a82911cfc747e00efb95cc46e8d0acd5b5b88570c" [[package]] name = "media" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=eb8e1c8b5502b7007465fbbc465f4a736fa39210#eb8e1c8b5502b7007465fbbc465f4a736fa39210" +source = "git+https://github.com/Kuddev/zed?rev=1b913031d01f5b3df8fcab3e8c2ac3568e7f5524#1b913031d01f5b3df8fcab3e8c2ac3568e7f5524" dependencies = [ "anyhow", "bindgen", @@ -6210,7 +6210,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perf" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=eb8e1c8b5502b7007465fbbc465f4a736fa39210#eb8e1c8b5502b7007465fbbc465f4a736fa39210" +source = "git+https://github.com/Kuddev/zed?rev=1b913031d01f5b3df8fcab3e8c2ac3568e7f5524#1b913031d01f5b3df8fcab3e8c2ac3568e7f5524" dependencies = [ "collections", "serde", @@ -7109,7 +7109,7 @@ dependencies = [ [[package]] name = "refineable" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=eb8e1c8b5502b7007465fbbc465f4a736fa39210#eb8e1c8b5502b7007465fbbc465f4a736fa39210" +source = "git+https://github.com/Kuddev/zed?rev=1b913031d01f5b3df8fcab3e8c2ac3568e7f5524#1b913031d01f5b3df8fcab3e8c2ac3568e7f5524" dependencies = [ "derive_refineable", ] @@ -7704,7 +7704,7 @@ dependencies = [ [[package]] name = "scheduler" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=eb8e1c8b5502b7007465fbbc465f4a736fa39210#eb8e1c8b5502b7007465fbbc465f4a736fa39210" +source = "git+https://github.com/Kuddev/zed?rev=1b913031d01f5b3df8fcab3e8c2ac3568e7f5524#1b913031d01f5b3df8fcab3e8c2ac3568e7f5524" dependencies = [ "async-task", "backtrace", @@ -8567,7 +8567,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "sum_tree" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=eb8e1c8b5502b7007465fbbc465f4a736fa39210#eb8e1c8b5502b7007465fbbc465f4a736fa39210" +source = "git+https://github.com/Kuddev/zed?rev=1b913031d01f5b3df8fcab3e8c2ac3568e7f5524#1b913031d01f5b3df8fcab3e8c2ac3568e7f5524" dependencies = [ "heapless", "log", @@ -9892,7 +9892,7 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "util_macros" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=eb8e1c8b5502b7007465fbbc465f4a736fa39210#eb8e1c8b5502b7007465fbbc465f4a736fa39210" +source = "git+https://github.com/Kuddev/zed?rev=1b913031d01f5b3df8fcab3e8c2ac3568e7f5524#1b913031d01f5b3df8fcab3e8c2ac3568e7f5524" dependencies = [ "perf", "quote", @@ -11903,7 +11903,7 @@ dependencies = [ [[package]] name = "zlog" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=eb8e1c8b5502b7007465fbbc465f4a736fa39210#eb8e1c8b5502b7007465fbbc465f4a736fa39210" +source = "git+https://github.com/Kuddev/zed?rev=1b913031d01f5b3df8fcab3e8c2ac3568e7f5524#1b913031d01f5b3df8fcab3e8c2ac3568e7f5524" dependencies = [ "anyhow", "chrono", @@ -11920,7 +11920,7 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "ztracing" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=eb8e1c8b5502b7007465fbbc465f4a736fa39210#eb8e1c8b5502b7007465fbbc465f4a736fa39210" +source = "git+https://github.com/Kuddev/zed?rev=1b913031d01f5b3df8fcab3e8c2ac3568e7f5524#1b913031d01f5b3df8fcab3e8c2ac3568e7f5524" dependencies = [ "tracing", "tracing-subscriber", @@ -11931,7 +11931,7 @@ dependencies = [ [[package]] name = "ztracing_macro" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=eb8e1c8b5502b7007465fbbc465f4a736fa39210#eb8e1c8b5502b7007465fbbc465f4a736fa39210" +source = "git+https://github.com/Kuddev/zed?rev=1b913031d01f5b3df8fcab3e8c2ac3568e7f5524#1b913031d01f5b3df8fcab3e8c2ac3568e7f5524" [[package]] name = "zune-core" diff --git a/Cargo.toml b/Cargo.toml index 629b173..ac52444 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -69,7 +69,7 @@ toml = "0.9.11" toml_edit = "0.24.0" # GPUI v1.16.1 把平台启动入口拆到独立 crate。它必须与 `gpui` 使用完全相同 # 的 Git URL 和 rev,否则 Cargo 会把同名类型解析成两套互不兼容的 crate。 -gpui_platform = { git = "https://github.com/Kuddev/zed", rev = "eb8e1c8b5502b7007465fbbc465f4a736fa39210", version = "=0.1.0" } +gpui_platform = { git = "https://github.com/Kuddev/zed", rev = "1b913031d01f5b3df8fcab3e8c2ac3568e7f5524", version = "=0.1.0" } # TODO: Drop this pin and return to the crates.io release once the fix ships there. [patch.crates-io] @@ -96,6 +96,16 @@ winit = { path = "third_party/winit-0.30.13" } # @ fa3763c681dba5b3ea028395148488e1281d478d # (c20dbef8 基线 + 下游虚拟窗口测试支持) # +# 当前虚拟窗口句柄修补(2026-09-06,保留以上上游/组件基线): +# Kuddev/zed nebula-v1.16.1-virtual-window-handles-20260906 +# @ 1b913031d01f5b3df8fcab3e8c2ac3568e7f5524 +# (TestWindow 对不存在的原生窗口/显示句柄返回 Unavailable;真实窗口路径不变) +# Kuddev/gpui-component nebula-v1.16.1-virtual-window-handles-20260906 +# @ 05bb03f54d08b80ac059f7daea52707745e05956 +# (五个 Zed 依赖同步到上面的同一提交,避免两套 GPUI 类型) +# 原因:macOS 密码输入的原生 content-type 适配已处理 Err,但虚拟窗口此前 +# 直接 panic,阻止完整对话框回归测试。上游包含此合同后可撤销这份薄修补。 +# # 所有 Zed 依赖统一使用 Kuddev URL:Cargo 的 source identity 包含 URL,应用 # 与 component 内部若混用官方/自有 URL,即使 SHA 相同也会解析成两套 gpui # 类型。新增 Zed/component 补丁必须从固定基线开独立分支、逐条提交并更新 @@ -161,6 +171,6 @@ winit = { path = "third_party/winit-0.30.13" } # 上游提供等价的下游 headless 测试支持后撤销本补丁。 # # 当前精确引用已包含补丁 1-3 与 6;Input 补丁 4-5 仍保持独立,尚未带入本次升级。 -gpui = { git = "https://github.com/Kuddev/zed", rev = "eb8e1c8b5502b7007465fbbc465f4a736fa39210", version = "=0.2.2" } -gpui-component = { git = "https://github.com/Kuddev/gpui-component", rev = "fa3763c681dba5b3ea028395148488e1281d478d", version = "=0.5.2" } -gpui-component-assets = { git = "https://github.com/Kuddev/gpui-component", rev = "fa3763c681dba5b3ea028395148488e1281d478d", version = "=0.5.1" } +gpui = { git = "https://github.com/Kuddev/zed", rev = "1b913031d01f5b3df8fcab3e8c2ac3568e7f5524", version = "=0.2.2" } +gpui-component = { git = "https://github.com/Kuddev/gpui-component", rev = "05bb03f54d08b80ac059f7daea52707745e05956", version = "=0.5.2" } +gpui-component-assets = { git = "https://github.com/Kuddev/gpui-component", rev = "05bb03f54d08b80ac059f7daea52707745e05956", version = "=0.5.1" } From 270d23c106ba16d435e343a7b72f60ff34f31bb1 Mon Sep 17 00:00:00 2001 From: kuddev Date: Sun, 6 Sep 2026 20:38:20 +0800 Subject: [PATCH 07/31] test(conformance): retain geometry evidence for native resize failures Keep resize assertions strict while preserving before/after observations, bounded prepaint traces, and Linux kernel PTY geometry after a failure. Cover diagnostic failures and cleanup; retain dependency caches when later conformance checks fail. --- .github/workflows/preview-packages.yml | 17 +++ nebula_app/src/gpui_shell/terminal/element.rs | 23 ++++ scripts/conformance/cases/resize.py | 70 ++++++++++ scripts/conformance/tests/test_resize.py | 123 ++++++++++++++++++ 4 files changed, 233 insertions(+) create mode 100644 scripts/conformance/tests/test_resize.py diff --git a/.github/workflows/preview-packages.yml b/.github/workflows/preview-packages.yml index 614e12c..1c387e8 100644 --- a/.github/workflows/preview-packages.yml +++ b/.github/workflows/preview-packages.yml @@ -104,6 +104,7 @@ jobs: scripts.tests.test_app_icons scripts.tests.test_git_attributes scripts.conformance.tests.test_harness + scripts.conformance.tests.test_resize - name: Compile Python helpers run: python3 -m compileall -q scripts/preview_release.py scripts/conformance @@ -143,6 +144,8 @@ jobs: toolchain: ${{ env.RUST_TOOLCHAIN }} - uses: Swatinem/rust-cache@v2 + with: + cache-on-failure: true - name: Test workspace natively run: cargo test --locked --workspace @@ -187,6 +190,8 @@ jobs: tar -tzf "$archive" "$root/usr/share/applications/io.github.kuddev.nebula.preview.desktop" - name: Run AppImage conformance under X11 + env: + NEBULA_RESIZE_TRACE: "1" run: | xvfb-run -a -s "-screen 0 1600x1000x24" \ python3 scripts/conformance/run.py \ @@ -195,6 +200,8 @@ jobs: --output dist/conformance/linux-appimage-report.json - name: Run AppImage conformance under Wayland + env: + NEBULA_RESIZE_TRACE: "1" run: | runtime_directory="$RUNNER_TEMP/nebula-wayland-runtime" mkdir -p "$runtime_directory" dist/conformance @@ -235,6 +242,8 @@ jobs: --output dist/conformance/linux-appimage-wayland-report.json - name: Install and run Debian package conformance under X11 + env: + NEBULA_RESIZE_TRACE: "1" run: | sudo apt-get install -y \ "./dist/NebulaTerminal-v$ASSET_VERSION-linux-x86_64.deb" @@ -298,6 +307,8 @@ jobs: toolchain: ${{ env.RUST_TOOLCHAIN }} - uses: Swatinem/rust-cache@v2 + with: + cache-on-failure: true - name: Test workspace natively run: cargo test --locked --workspace @@ -371,6 +382,8 @@ jobs: --force "${signing_args[@]}" - name: Mount final DMG and run conformance + env: + NEBULA_RESIZE_TRACE: "1" run: | dmg="dist/NebulaTerminal-v$ASSET_VERSION-macos-${{ matrix.architecture }}.dmg" mount_point="$RUNNER_TEMP/nebula-preview-${{ matrix.architecture }}" @@ -436,6 +449,8 @@ jobs: with: toolchain: ${{ env.RUST_TOOLCHAIN }} - uses: Swatinem/rust-cache@v2 + with: + cache-on-failure: true - name: Test workspace natively run: cargo test --locked --workspace - name: Test SSH confirmation and password dialogs @@ -443,6 +458,8 @@ jobs: - name: Build GPUI comparison binary run: cargo build --locked --release -p nebula --bin nebula --features gpui-shell - name: Run Windows conformance + env: + NEBULA_RESIZE_TRACE: "1" run: >- python scripts/conformance/run.py --app target/release/nebula.exe --platform windows-x86_64 --output dist/conformance/windows-report.json diff --git a/nebula_app/src/gpui_shell/terminal/element.rs b/nebula_app/src/gpui_shell/terminal/element.rs index 9e0f0e3..3fb0356 100644 --- a/nebula_app/src/gpui_shell/terminal/element.rs +++ b/nebula_app/src/gpui_shell/terminal/element.rs @@ -226,6 +226,29 @@ impl Element for TerminalElement { let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal); let view = self.view.read(cx); + // Bounded, opt-in evidence that a real layout reached this pane. PTY + // resize traces alone cannot distinguish missing prepaint from a + // viewport which still matches the spawn grid. + static TRACE_ENABLED: std::sync::OnceLock = std::sync::OnceLock::new(); + static TRACE_SAMPLES: std::sync::atomic::AtomicUsize = + std::sync::atomic::AtomicUsize::new(0); + if *TRACE_ENABLED.get_or_init(|| std::env::var_os("NEBULA_RESIZE_TRACE").is_some()) + && TRACE_SAMPLES.fetch_add(1, std::sync::atomic::Ordering::Relaxed) < 256 + { + crate::gpui_shell::try_write_stderr(format_args!( + "[nebula:resize-trace] prepaint pane={} origin=({:.2},{:.2}) content={:.2}x{:.2} cell={:.2}x{:.2} scale={:.2} viewport={}x{}", + view.pane_id, + bounds.origin.x.as_f32(), + bounds.origin.y.as_f32(), + bounds.size.width.as_f32(), + bounds.size.height.as_f32(), + cell_width.as_f32(), + line_height.as_f32(), + scale, + view.grid_cols(), + view.grid_rows(), + )); + } TermLayout { cell_width, line_height, diff --git a/scripts/conformance/cases/resize.py b/scripts/conformance/cases/resize.py index 4fa0c1f..e4ec1d1 100644 --- a/scripts/conformance/cases/resize.py +++ b/scripts/conformance/cases/resize.py @@ -1,8 +1,65 @@ from __future__ import annotations +import json +import os + from conformance.harness import ConformanceContext, require +def _linux_terminal_size(pid: int) -> dict[str, int]: + import fcntl + import struct + import termios + + # Read the shell's actual PTY geometry without relying on COLUMNS or the + # command-substitution stdout used by tput. O_NOCTTY preserves ownership. + fd = os.open(f"/proc/{pid}/fd/0", os.O_RDONLY | os.O_NOCTTY | os.O_CLOEXEC) + try: + packed = fcntl.ioctl(fd, termios.TIOCGWINSZ, bytes(8)) + rows, columns, _, _ = struct.unpack("HHHH", packed) + return {"rows": rows, "columns": columns} + finally: + os.close(fd) + + +def _write_failure_diagnostics( + ctx: ConformanceContext, + source: int, + sibling: int, + measurements: dict[str, int], + error: Exception, +) -> None: + details: dict[str, object] = { + "error": str(error), + "shell": ctx.shell, + "measurements": measurements, + "panes": {}, + } + try: + details["snapshot"] = ctx.api("runtime.snapshot", timeout=2.0) + except Exception as diagnostic_error: + details["snapshot_error"] = str(diagnostic_error) + panes: dict[str, object] = {} + for pane_id in (source, sibling): + params = {"window_id": ctx.window_id, "pane_id": pane_id} + pane: dict[str, object] = {} + try: + pane["read"] = ctx.api("pane.read", {**params, "lines": 40}, timeout=2.0) + except Exception as diagnostic_error: + pane["read_error"] = str(diagnostic_error) + if ctx.platform_family == "linux": + try: + processes = ctx.api("pane.procs", params, timeout=2.0) + pane["kernel_terminal_size"] = _linux_terminal_size(int(processes["root_pid"])) + except Exception as diagnostic_error: + pane["kernel_terminal_size_error"] = str(diagnostic_error) + panes[str(pane_id)] = pane + details["panes"] = panes + (ctx.artifact_dir / "resize-failure.json").write_text( + json.dumps(details, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" + ) + + def run(ctx: ConformanceContext) -> dict[str, object]: source = ctx.pane_id split = ctx.api( @@ -14,16 +71,21 @@ def run(ctx: ConformanceContext) -> dict[str, object]: }, ) sibling = int(split["action"]["pane_id"]) + measurements: dict[str, int] = {} try: source_before = ctx.measure_columns(source) + measurements["source_columns_before"] = source_before sibling_before = ctx.measure_columns(sibling) + measurements["sibling_columns_before"] = sibling_before resized = ctx.api( "pane.resize", {"window_id": ctx.window_id, "pane_id": source, "ratio": 0.70}, ) ratio = float(resized["action"]["ratio"]) source_after = ctx.measure_columns(source) + measurements["source_columns_after"] = source_after sibling_after = ctx.measure_columns(sibling) + measurements["sibling_columns_after"] = sibling_after require(abs(ratio - 0.70) < 0.001, f"runtime reported wrong split ratio: {ratio}") require( source_after > source_before, @@ -45,6 +107,14 @@ def run(ctx: ConformanceContext) -> dict[str, object]: "sibling_columns_before": sibling_before, "sibling_columns_after": sibling_after, } + except Exception as error: + # Capture only after a strict assertion fails. Extra observations must + # not turn into an implicit wait that makes a resize race disappear. + try: + _write_failure_diagnostics(ctx, source, sibling, measurements, error) + except Exception as diagnostic_error: + print(f"[resize] could not retain diagnostics: {diagnostic_error}", flush=True) + raise finally: ctx.best_effort_api("pane.close", {"window_id": ctx.window_id, "pane_id": sibling}) ctx.best_effort_api("window.focus", {"window_id": ctx.window_id, "pane_id": source}) diff --git a/scripts/conformance/tests/test_resize.py b/scripts/conformance/tests/test_resize.py new file mode 100644 index 0000000..c066cf6 --- /dev/null +++ b/scripts/conformance/tests/test_resize.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + +SCRIPTS_DIR = Path(__file__).resolve().parents[2] +if str(SCRIPTS_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPTS_DIR)) + +from conformance.cases import resize +from conformance.harness import ConformanceError + + +class ResizeContext: + window_id = 1 + pane_id = 10 + shell = "bash" + platform_family = "macos" + + def __init__(self, artifact_dir: Path, columns: list[int]) -> None: + self.artifact_dir = artifact_dir + self.columns = iter(columns) + self.cleanup: list[str] = [] + + def api(self, method: str, params=None, **kwargs): + if method == "pane.split": + return {"action": {"pane_id": 11}} + if method == "pane.resize": + return {"action": {"ratio": params["ratio"]}} + if method == "runtime.snapshot": + return {"windows": [{"id": self.window_id}]} + if method == "pane.read": + return {"text": "NEBULA_COLS_3_116"} + raise AssertionError(method) + + def measure_columns(self, pane_id: int) -> int: + return next(self.columns) + + def best_effort_api(self, method: str, params) -> None: + self.cleanup.append(method) + + +class ResizeDiagnosticsTests(unittest.TestCase): + def test_growing_and_shrinking_panes_pass_without_diagnostic_sampling(self) -> None: + with tempfile.TemporaryDirectory() as directory: + ctx = ResizeContext(Path(directory), [57, 56, 80, 33]) + with patch.object(resize, "_write_failure_diagnostics") as diagnose: + result = resize.run(ctx) + self.assertTrue(result["pty_columns_changed"]) + diagnose.assert_not_called() + self.assertEqual(ctx.cleanup, ["pane.close", "window.focus"]) + + def test_unchanged_spawn_width_still_fails_and_preserves_all_measurements(self) -> None: + with tempfile.TemporaryDirectory() as directory: + ctx = ResizeContext(Path(directory), [116, 116, 116, 116]) + with self.assertRaisesRegex(ConformanceError, "70% pane did not grow: 116 -> 116"): + resize.run(ctx) + details = json.loads((Path(directory) / "resize-failure.json").read_text()) + self.assertEqual(details["measurements"], { + "source_columns_before": 116, + "sibling_columns_before": 116, + "source_columns_after": 116, + "sibling_columns_after": 116, + }) + self.assertEqual(set(details["panes"]), {"10", "11"}) + self.assertEqual(ctx.cleanup, ["pane.close", "window.focus"]) + + def test_sibling_and_total_assertions_remain_strict(self) -> None: + for columns, message in [ + ([57, 56, 80, 56], "30% pane did not shrink"), + ([57, 56, 85, 15], "PTY column total changed"), + ]: + with self.subTest(columns=columns), tempfile.TemporaryDirectory() as directory: + ctx = ResizeContext(Path(directory), columns) + with self.assertRaisesRegex(ConformanceError, message): + resize.run(ctx) + self.assertEqual(ctx.cleanup, ["pane.close", "window.focus"]) + + def test_diagnostic_failure_cannot_replace_the_resize_failure(self) -> None: + with tempfile.TemporaryDirectory() as directory: + ctx = ResizeContext(Path(directory), [116] * 4) + with patch.object(resize, "_write_failure_diagnostics", side_effect=OSError("read-only")), \ + patch("builtins.print"): + with self.assertRaisesRegex(ConformanceError, "70% pane did not grow"): + resize.run(ctx) + self.assertEqual(ctx.cleanup, ["pane.close", "window.focus"]) + + @unittest.skipUnless(sys.platform == "linux", "reads the Linux process PTY") + def test_kernel_probe_follows_pty_geometry_even_with_stale_columns_environment(self) -> None: + import fcntl + import struct + import termios + + master, slave = os.openpty() + child = None + try: + child = subprocess.Popen( + [sys.executable, "-c", "import time; time.sleep(30)"], + stdin=slave, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + env={**os.environ, "COLUMNS": "116"}, + ) + for columns in (57, 82): + fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", 30, columns, 0, 0)) + self.assertEqual( + resize._linux_terminal_size(child.pid), + {"rows": 30, "columns": columns}, + ) + finally: + if child is not None: + child.terminate() + child.wait(timeout=5) + os.close(master) + os.close(slave) + + +if __name__ == "__main__": + unittest.main() From 1b469ecb2606515c3506ae880ceb32a32e49f3ff Mon Sep 17 00:00:00 2001 From: kuddev Date: Sun, 6 Sep 2026 22:40:41 +0800 Subject: [PATCH 08/31] fix(process): recognize login shells in close confirmation checks Treat ps names such as -bash and -zsh as their known interactive shells without extending git/plumbing exemptions or command parsing. Keep non-shell descendants visible so a running editor or build still blocks a close. --- nebula_app/src/process_tree.rs | 46 +++++++++++++++++++++++++++++++--- 1 file changed, 42 insertions(+), 4 deletions(-) diff --git a/nebula_app/src/process_tree.rs b/nebula_app/src/process_tree.rs index 65d3ce2..0089216 100644 --- a/nebula_app/src/process_tree.rs +++ b/nebula_app/src/process_tree.rs @@ -104,7 +104,12 @@ fn is_stateless_process(executable: &str) -> bool { return true; } let stem = name.strip_suffix(".exe").unwrap_or(&name); - INTERACTIVE_SHELLS.contains(&stem) || stem == "git" + // POSIX login shells can expose argv[0] as `-bash`/`-zsh` in ps output. + // Apply this convention only to a known shell, never to the git/plumbing + // exemptions or Windows executable names. Command parsing stays separate. + let login_shell = !name.ends_with(".exe") + && stem.strip_prefix('-').is_some_and(|shell| INTERACTIVE_SHELLS.contains(&shell)); + INTERACTIVE_SHELLS.contains(&stem) || login_shell || stem == "git" } /// Parse the stable three-column `ps` output used by Unix snapshots. The @@ -255,7 +260,11 @@ pub fn descendants(root_pid: u32) -> Result, String> { /// First non-stateless process under `root_pid` (the pane's shell), or `None` /// when the whole tree is safe to kill. The name is used in the confirm modal. pub fn busy_child(root_pid: u32) -> Option { - descendants(root_pid).ok()?.into_iter().skip(1).find_map(|process| { + busy_descendant(descendants(root_pid).ok()?) +} + +fn busy_descendant(processes: Vec) -> Option { + processes.into_iter().skip(1).find_map(|process| { (!is_stateless_process(&process.executable)).then_some(process.executable) }) } @@ -400,10 +409,39 @@ mod tests { use std::collections::HashMap; use super::{ - display_name, is_interactive_shell_command, parse_ps_line, resolve_agent_ancestor, - resolve_within_tree, + ProcessEntry, busy_descendant, display_name, is_interactive_shell_command, + is_stateless_process, parse_ps_line, resolve_agent_ancestor, resolve_within_tree, }; + #[test] + fn login_shell_process_names_are_stateless_without_exempting_other_programs() { + for name in ["-bash", "-zsh", "-sh", "-fish", "/bin/-bash"] { + assert!(is_stateless_process(name), "{name}"); + } + for name in ["-vim", "-python", "-git", "--bash", "-bash.exe", "-bash script.sh"] { + assert!(!is_stateless_process(name), "{name}"); + } + assert!(!is_interactive_shell_command("-bash")); + } + + #[test] + fn login_shell_nodes_do_not_hide_a_busy_descendant() { + let entries = |commands: &[&str]| { + commands + .iter() + .enumerate() + .map(|(depth, command)| ProcessEntry { + pid: depth as u32 + 1, + parent_pid: depth as u32, + executable: (*command).to_owned(), + depth: depth as u32, + }) + .collect() + }; + assert_eq!(busy_descendant(entries(&["login", "-bash"])), None); + assert_eq!(busy_descendant(entries(&["login", "-bash", "vim"])), Some("vim".to_owned())); + } + #[test] fn unix_process_rows_keep_the_complete_command_column() { assert_eq!( From 2b1b261823d8e9b6b68dc5c51445a9ed3598c970 Mon Sep 17 00:00:00 2001 From: kuddev Date: Sun, 6 Sep 2026 22:40:41 +0800 Subject: [PATCH 09/31] fix(conformance): wait for executed markers and contain Windows launches Build completion markers from fragments so command echo cannot satisfy output waits. Assign each Windows launch gate to an owned Job Object before starting the app, then reap retained member handles during restart and close. Preserve existing history and close limits and test unrelated-process survival. --- .github/workflows/preview-packages.yml | 7 + scripts/conformance/harness.py | 294 ++++++++++++++++--- scripts/conformance/tests/test_lifecycle.py | 243 +++++++++++++++ scripts/conformance/tests/test_scrollback.py | 106 +++++++ 4 files changed, 616 insertions(+), 34 deletions(-) create mode 100644 scripts/conformance/tests/test_lifecycle.py create mode 100644 scripts/conformance/tests/test_scrollback.py diff --git a/.github/workflows/preview-packages.yml b/.github/workflows/preview-packages.yml index 1c387e8..131b9a7 100644 --- a/.github/workflows/preview-packages.yml +++ b/.github/workflows/preview-packages.yml @@ -105,6 +105,8 @@ jobs: scripts.tests.test_git_attributes scripts.conformance.tests.test_harness scripts.conformance.tests.test_resize + scripts.conformance.tests.test_scrollback + scripts.conformance.tests.test_lifecycle - name: Compile Python helpers run: python3 -m compileall -q scripts/preview_release.py scripts/conformance @@ -445,6 +447,11 @@ jobs: timeout-minutes: 120 steps: - uses: actions/checkout@v4 + - name: Test native harness process ownership and completion markers + run: >- + python -m unittest + scripts.conformance.tests.test_lifecycle + scripts.conformance.tests.test_scrollback - uses: dtolnay/rust-toolchain@stable with: toolchain: ${{ env.RUST_TOOLCHAIN }} diff --git a/scripts/conformance/harness.py b/scripts/conformance/harness.py index 66f48d5..9e6e726 100644 --- a/scripts/conformance/harness.py +++ b/scripts/conformance/harness.py @@ -2,6 +2,8 @@ from __future__ import annotations +import ctypes +from ctypes import wintypes import fnmatch import glob import json @@ -10,6 +12,7 @@ import re import shutil import socket import subprocess +import sys import tempfile import time import zipfile @@ -21,6 +24,15 @@ PROTOCOL_VERSION = 1 MAX_RESPONSE_BYTES = 32 * 1024 * 1024 DEFAULT_STARTUP_TIMEOUT = 20.0 +# The Windows gate cannot start the app until its parent assigns the exact +# process HANDLE to a job. -I -S disables user/site startup hooks in this gate. +_WINDOWS_LAUNCH_GATE = ( + "import subprocess, sys\n" + "if sys.stdin.buffer.read(1) != b'G':\n" + " raise SystemExit(125)\n" + "raise SystemExit(subprocess.call(sys.argv[1:], stdin=subprocess.DEVNULL))\n" +) + class ConformanceError(RuntimeError): """A test precondition or behavioral assertion failed.""" @@ -244,6 +256,173 @@ class RuntimeClient: return response +class _WindowsJob: + """Own one harness launch and its descendants, including orphaned shells. + + Killing only Nebula can leave ConPTY children holding the test directory. + Job membership is established before the gated app launch, inherited by + descendants, and retained after the parent exits. No machine-wide process + enumeration or process-name matching is used. The job kills its members if the + harness itself exits unexpectedly and Windows closes its handle. + """ + + def __init__(self) -> None: + class BasicLimits(ctypes.Structure): + _fields_ = [ + ("PerProcessUserTimeLimit", ctypes.c_int64), + ("PerJobUserTimeLimit", ctypes.c_int64), + ("LimitFlags", wintypes.DWORD), + ("MinimumWorkingSetSize", ctypes.c_size_t), + ("MaximumWorkingSetSize", ctypes.c_size_t), + ("ActiveProcessLimit", wintypes.DWORD), + ("Affinity", ctypes.c_size_t), + ("PriorityClass", wintypes.DWORD), + ("SchedulingClass", wintypes.DWORD), + ] + + class ExtendedLimits(ctypes.Structure): + _fields_ = [ + ("BasicLimitInformation", BasicLimits), + ("IoInfo", ctypes.c_uint64 * 6), + ("ProcessMemoryLimit", ctypes.c_size_t), + ("JobMemoryLimit", ctypes.c_size_t), + ("PeakProcessMemoryUsed", ctypes.c_size_t), + ("PeakJobMemoryUsed", ctypes.c_size_t), + ] + + class Accounting(ctypes.Structure): + _fields_ = [ + ("TotalUserTime", ctypes.c_int64), + ("TotalKernelTime", ctypes.c_int64), + ("ThisPeriodTotalUserTime", ctypes.c_int64), + ("ThisPeriodTotalKernelTime", ctypes.c_int64), + ("TotalPageFaultCount", wintypes.DWORD), + ("TotalProcesses", wintypes.DWORD), + ("ActiveProcesses", wintypes.DWORD), + ("TotalTerminatedProcesses", wintypes.DWORD), + ] + + self._accounting = Accounting + self._api = ctypes.WinDLL("kernel32", use_last_error=True) + self._api.CreateJobObjectW.argtypes = (ctypes.c_void_p, wintypes.LPCWSTR) + self._api.CreateJobObjectW.restype = wintypes.HANDLE + self._api.SetInformationJobObject.argtypes = ( + wintypes.HANDLE, ctypes.c_int, ctypes.c_void_p, wintypes.DWORD, + ) + self._api.SetInformationJobObject.restype = wintypes.BOOL + self._api.AssignProcessToJobObject.argtypes = (wintypes.HANDLE, wintypes.HANDLE) + self._api.AssignProcessToJobObject.restype = wintypes.BOOL + self._api.TerminateJobObject.argtypes = (wintypes.HANDLE, wintypes.UINT) + self._api.TerminateJobObject.restype = wintypes.BOOL + self._api.QueryInformationJobObject.argtypes = ( + wintypes.HANDLE, ctypes.c_int, ctypes.c_void_p, wintypes.DWORD, + ctypes.POINTER(wintypes.DWORD), + ) + self._api.QueryInformationJobObject.restype = wintypes.BOOL + self._api.OpenProcess.argtypes = (wintypes.DWORD, wintypes.BOOL, wintypes.DWORD) + self._api.OpenProcess.restype = wintypes.HANDLE + self._api.IsProcessInJob.argtypes = ( + wintypes.HANDLE, wintypes.HANDLE, ctypes.POINTER(wintypes.BOOL), + ) + self._api.IsProcessInJob.restype = wintypes.BOOL + self._api.WaitForSingleObject.argtypes = (wintypes.HANDLE, wintypes.DWORD) + self._api.WaitForSingleObject.restype = wintypes.DWORD + self._api.CloseHandle.argtypes = (wintypes.HANDLE,) + self._api.CloseHandle.restype = wintypes.BOOL + self._process_handles: list[int] = [] + self._handle = self._api.CreateJobObjectW(None, None) + if not self._handle: + raise ctypes.WinError(ctypes.get_last_error()) + limits = ExtendedLimits() + limits.BasicLimitInformation.LimitFlags = 0x2000 # KILL_ON_JOB_CLOSE + if not self._api.SetInformationJobObject( + self._handle, 9, ctypes.byref(limits), ctypes.sizeof(limits) + ): + error = ctypes.WinError(ctypes.get_last_error()) + self.close() + raise error + + def assign(self, process: subprocess.Popen[bytes]) -> None: + # CPython retains the native process handle on Windows. Using that + # handle avoids opening a PID that could have been recycled. + if not self._api.AssignProcessToJobObject(self._handle, int(process._handle)): + raise ctypes.WinError(ctypes.get_last_error()) + + def terminate(self, timeout: float = 5.0) -> None: + deadline = time.monotonic() + timeout + self._capture_process_handles() + if not self._api.TerminateJobObject(self._handle, 1): + raise ctypes.WinError(ctypes.get_last_error()) + self._capture_process_handles() + while True: + accounting = self._accounting() + if not self._api.QueryInformationJobObject( + self._handle, 1, ctypes.byref(accounting), ctypes.sizeof(accounting), None + ): + raise ctypes.WinError(ctypes.get_last_error()) + if accounting.ActiveProcesses == 0: + break + if time.monotonic() >= deadline: + raise ConformanceError( + f"Windows launch job still has {accounting.ActiveProcesses} " + f"processes after {timeout:.1f}s of cleanup" + ) + time.sleep(0.01) + # ActiveProcesses can reach zero before final process teardown closes + # directory handles. Wait on the retained identities, not just that + # accounting value, including when the launch parent exited earlier. + for handle in self._process_handles: + remaining_ms = max(0, int((deadline - time.monotonic()) * 1000)) + result = self._api.WaitForSingleObject(handle, remaining_ms) + if result == 0xFFFFFFFF: + raise ctypes.WinError(ctypes.get_last_error()) + if result != 0: + raise ConformanceError(f"Windows job member did not exit within {timeout:.1f}s") + + def _capture_process_handles(self) -> None: + capacity = 16 + while True: + buffer = ctypes.create_string_buffer(8 + ctypes.sizeof(ctypes.c_size_t) * capacity) + if self._api.QueryInformationJobObject( + self._handle, 3, buffer, ctypes.sizeof(buffer), None + ): + count = wintypes.DWORD.from_buffer(buffer, 4).value + pids = (ctypes.c_size_t * count).from_buffer(buffer, 8) + break + error = ctypes.get_last_error() + if error != 234: # ERROR_MORE_DATA: members grew beyond the buffer. + raise ctypes.WinError(error) + capacity = max(capacity * 2, wintypes.DWORD.from_buffer(buffer).value) + for pid in pids: + # These are only candidates from this job, never a machine-wide + # process tree. Verify membership after opening to reject PID reuse. + handle = self._api.OpenProcess(0x00101000, False, pid) + if not handle: + error = ctypes.get_last_error() + if error == 87: # The process exited before its handle was opened. + continue + raise ctypes.WinError(error) + member = wintypes.BOOL() + if not self._api.IsProcessInJob(handle, self._handle, ctypes.byref(member)): + error = ctypes.WinError(ctypes.get_last_error()) + self._api.CloseHandle(handle) + raise error + if member.value: + self._process_handles.append(handle) + elif not self._api.CloseHandle(handle): + raise ctypes.WinError(ctypes.get_last_error()) + + def close(self) -> None: + if self._handle is not None: + if not self._api.CloseHandle(self._handle): + raise ctypes.WinError(ctypes.get_last_error()) + self._handle = None + while self._process_handles: + if not self._api.CloseHandle(self._process_handles[-1]): + raise ctypes.WinError(ctypes.get_last_error()) + self._process_handles.pop() + + class ConformanceContext: def __init__( self, @@ -262,6 +441,7 @@ class ConformanceContext: self.artifact_dir = artifact_dir.resolve() self.startup_timeout = startup_timeout self.process: subprocess.Popen[bytes] | None = None + self._windows_job: _WindowsJob | None = None self.client: RuntimeClient | None = None self.description: dict[str, Any] = {} self.startup_ms = 0 @@ -302,7 +482,10 @@ class ConformanceContext: ) def start(self, *, explicit_working_directory: bool = True) -> None: - require(self.process is None, "Nebula is already running in this context") + require( + self.process is None and self._windows_job is None, + "Nebula is already running in this context", + ) self._launch_number += 1 log_path = self.artifact_dir / f"nebula-{self._launch_number}.log" self._log_handle = log_path.open("wb") @@ -316,14 +499,7 @@ class ConformanceContext: command.extend(["--working-directory", os.fspath(self.work_dir)]) started = time.monotonic() try: - self.process = subprocess.Popen( - command, - cwd=self.work_dir, - env=env, - stdin=subprocess.DEVNULL, - stdout=self._log_handle, - stderr=subprocess.STDOUT, - ) + self.process = self._spawn_process(command, env) except OSError as error: self._close_log() raise ConformanceError(f"could not launch {self.app.executable}: {error}") from error @@ -333,8 +509,7 @@ class ConformanceContext: while time.monotonic() < deadline: if self.process.poll() is not None: code = self.process.returncode - self.process = None - self._close_log() + self.stop(force=True) raise ConformanceError(f"Nebula exited during startup with code {code}") if self.port_file.is_file(): try: @@ -365,21 +540,69 @@ class ConformanceContext: self.stop(force=True) raise ConformanceError(f"Nebula runtime did not become ready: {last_error}") - def stop(self, *, force: bool) -> None: - process = self.process - self.process = None - self.client = None - if process is not None and process.poll() is None: - if force: - process.kill() - else: - process.terminate() + def _spawn_process(self, command: list[str], env: dict[str, str]) -> subprocess.Popen[bytes]: + options = { + "cwd": self.work_dir, + "env": env, + "stdout": self._log_handle, + "stderr": subprocess.STDOUT, + } + if os.name != "nt": + return subprocess.Popen(command, stdin=subprocess.DEVNULL, **options) + job = _WindowsJob() + process = None + try: + process = subprocess.Popen( + [sys.executable, "-I", "-S", "-c", _WINDOWS_LAUNCH_GATE, *command], + stdin=subprocess.PIPE, **options, + ) + job.assign(process) + assert process.stdin is not None + with process.stdin: + process.stdin.write(b"G") + except BaseException: try: - process.wait(timeout=5) - except subprocess.TimeoutExpired: - process.kill() - process.wait(timeout=5) - self._close_log() + # Before assignment the gate has no child; after assignment + # the job owns anything it could have started. + if process is not None: + if process.poll() is None: + process.kill() + process.wait(timeout=5) + if process.stdin is not None: + process.stdin.close() + job.terminate() + finally: + job.close() + raise + self._windows_job = job + return process + + def stop(self, *, force: bool, timeout: float = 5.0) -> None: + process = self.process + try: + if self._windows_job is not None: + deadline = time.monotonic() + timeout + self._windows_job.terminate(timeout) + if process is not None: + process.wait(timeout=max(0, deadline - time.monotonic())) + self._windows_job.close() + elif process is not None and process.poll() is None: + if force: + process.kill() + else: + process.terminate() + try: + process.wait(timeout=timeout) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=timeout) + finally: + self._close_log() + # Preserve ownership if cleanup fails, so a caller can retry and the + # failure is not replaced by a silent temporary-directory exclusion. + self.process = None + self._windows_job = None + self.client = None def restart(self) -> int: self.stop(force=True) @@ -608,20 +831,24 @@ class ConformanceContext: def scrollback_command(self, lines: int, marker: str) -> str: shell = self.shell + require(len(marker) >= 2, "completion marker must contain at least two characters") + # The command is echoed before its output arrives. Only execution may + # assemble the complete token that wait_for_line uses as completion. + completion = self.marker_command(marker[:-1], marker[-1]) if shell in {"powershell", "pwsh"}: - return f"1..{lines} | ForEach-Object {{ 'NEBULA_SCROLL_' + $_ }}; '{marker}'" + return f"1..{lines} | ForEach-Object {{ 'NEBULA_SCROLL_' + $_ }}; {completion}" if shell == "cmd": - return f"for /L %i in (1,1,{lines}) do @echo NEBULA_SCROLL_%i & echo {marker}" + return f"(for /L %i in (1,1,{lines}) do @echo NEBULA_SCROLL_%i) & {completion}" if shell == "fish": - return f"for i in (seq 1 {lines}); echo NEBULA_SCROLL_$i; end; echo {marker}" + return f"for i in (seq 1 {lines}); echo NEBULA_SCROLL_$i; end; {completion}" if shell == "nu": return ( f"1..{lines} | each {{ |i| print $'NEBULA_SCROLL_($i)' }}; " - f"print '{marker}'" + f"{completion}" ) return ( f"i=1; while [ \"$i\" -le {lines} ]; do printf 'NEBULA_SCROLL_%s\\n' \"$i\"; " - f"i=$((i + 1)); done; printf '%s\\n' '{marker}'" + f"i=$((i + 1)); done; {completion}" ) def ensure_single_tab(self) -> None: @@ -659,6 +886,7 @@ class ConformanceContext: require(self.process is not None, "Nebula process is not running") process = self.process started = time.monotonic() + deadline = started + timeout try: self.api("window.close", {"window_id": self.window_id}) except ApiFailure: @@ -668,12 +896,10 @@ class ConformanceContext: # response is observed. The owned process exit is authoritative. pass try: - process.wait(timeout=timeout) + process.wait(timeout=max(0, deadline - time.monotonic())) except subprocess.TimeoutExpired as error: raise ConformanceError(f"window.close did not exit within {timeout:.1f}s") from error - self.process = None - self.client = None - self._close_log() + self.stop(force=True, timeout=max(0, deadline - time.monotonic())) if process.returncode != 0: raise ConformanceError(f"Nebula exited with code {process.returncode}") return round((time.monotonic() - started) * 1000) diff --git a/scripts/conformance/tests/test_lifecycle.py b/scripts/conformance/tests/test_lifecycle.py new file mode 100644 index 0000000..8e268a5 --- /dev/null +++ b/scripts/conformance/tests/test_lifecycle.py @@ -0,0 +1,243 @@ +from __future__ import annotations + +import ctypes +from ctypes import wintypes +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import time +from types import SimpleNamespace +import unittest +from unittest.mock import patch + +SCRIPTS_DIR = Path(__file__).resolve().parents[2] +if str(SCRIPTS_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPTS_DIR)) + +from conformance.harness import ConformanceContext, ConformanceError + + +REAL_POPEN = subprocess.Popen +FIXTURE = """ +from pathlib import Path +import os, subprocess, sys, time + +root = Path(sys.argv[1]) +role = sys.argv[2] +if role == 'grandchild': + time.sleep(60) +else: + next_role = 'child' if role == 'parent' else 'grandchild' + child = subprocess.Popen( + [sys.executable, '-I', '-S', __file__, str(root), next_role], + stdin=subprocess.DEVNULL, + ) + (root / (next_role + '.pid')).write_text(str(child.pid)) + deadline = time.monotonic() + 60 + while time.monotonic() < deadline: + if role == 'parent' and (root / 'exit').exists(): + code = root / 'exit-code' + raise SystemExit(int(code.read_text()) if code.exists() else 0) + time.sleep(0.02) +""" + + +class OwnedProcessHandle: + """Retain fixture process identity; never terminate by an unowned PID lookup.""" + + def __init__(self, pid: int) -> None: + self.api = ctypes.WinDLL("kernel32", use_last_error=True) + self.api.OpenProcess.argtypes = (wintypes.DWORD, wintypes.BOOL, wintypes.DWORD) + self.api.OpenProcess.restype = wintypes.HANDLE + self.api.WaitForSingleObject.argtypes = (wintypes.HANDLE, wintypes.DWORD) + self.api.WaitForSingleObject.restype = wintypes.DWORD + self.api.TerminateProcess.argtypes = (wintypes.HANDLE, wintypes.UINT) + self.api.TerminateProcess.restype = wintypes.BOOL + self.api.CloseHandle.argtypes = (wintypes.HANDLE,) + self.api.CloseHandle.restype = wintypes.BOOL + self.handle = self.api.OpenProcess(0x00100001, False, pid) + if not self.handle: + raise ctypes.WinError(ctypes.get_last_error()) + + def exited(self) -> bool: + return self.api.WaitForSingleObject(self.handle, 0) == 0 + + def close(self) -> None: + if self.handle is None: + return + try: + if not self.exited(): + terminated = self.api.TerminateProcess(self.handle, 1) + error = ctypes.WinError(ctypes.get_last_error()) if not terminated else None + # A job may already be terminating this process. ERROR_ACCESS_DENIED + # is benign only when the retained handle then proves it exited. + if self.api.WaitForSingleObject(self.handle, 5000) != 0: + if error is not None: + raise error + raise AssertionError("owned fixture did not terminate") + finally: + self.api.CloseHandle(self.handle) + self.handle = None + + +@unittest.skipUnless(os.name == "nt", "Windows Job Object process containment") +class WindowsLifecycleTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory(prefix="nebula-lifecycle-test-") + self.root = Path(self.temporary.name) + self.fixture = self.root / "fixture.py" + self.fixture.write_text(FIXTURE, encoding="utf-8") + self.app = SimpleNamespace(executable=self.root / "fixture-app.exe") + self.context = ConformanceContext( + self.app, "windows-test", self.root / "config", self.root / "work", + self.root / "artifacts", startup_timeout=5, + ) + self.context.prepare() + self.context.port_file.write_text("1 fixture-token", encoding="utf-8") + self.parents: list[subprocess.Popen] = [] + self.handles: list[OwnedProcessHandle] = [] + self.launches: list[Path] = [] + self.unrelated: subprocess.Popen | None = None + self.addCleanup(self.cleanup_owned_processes) + + def launch_fixture(command, **kwargs): + # Keep any platform launch gate intact; replace only the app under + # test with an inert private Python tree, never a GUI executable. + command = list(command) + index = command.index(os.fspath(self.app.executable)) + launch = self.root / f"launch-{len(self.launches) + 1}" + launch.mkdir() + self.launches.append(launch) + command[index:] = [ + sys.executable, "-I", "-S", os.fspath(self.fixture), + os.fspath(launch), "parent", + ] + process = REAL_POPEN(command, **kwargs) + self.parents.append(process) + return process + + client = SimpleNamespace(request=lambda *args, **kwargs: { + "result": {"windows": [{"tabs": [{"kind": "shell", "panes": [{}]}]}]}, + }) + self.launch_patch = patch("conformance.harness.subprocess.Popen", side_effect=launch_fixture) + self.client_patch = patch("conformance.harness.RuntimeClient.from_port_file", return_value=client) + self.launch_patch.start() + self.client_patch.start() + self.addCleanup(self.launch_patch.stop) + self.addCleanup(self.client_patch.stop) + + def cleanup_owned_processes(self) -> None: + failures = [] + try: + self.context.stop(force=True) + except Exception as error: + failures.append(error) + # Regressions must not leave even their failing fixtures running, and + # cleanup errors must remain visible without skipping later fixtures. + for handle in self.handles: + try: + handle.close() + except Exception as error: + failures.append(error) + for process in [*self.parents, self.unrelated]: + try: + if process is not None: + if process.poll() is None: + process.kill() + process.wait(timeout=5) + except Exception as error: + failures.append(error) + try: + self.temporary.cleanup() + except Exception as error: + failures.append(error) + if failures: + raise ExceptionGroup("owned fixture cleanup failed", failures) + + def capture_descendants(self) -> list[OwnedProcessHandle]: + captured = [] + for name in ("child", "grandchild"): + path = self.launches[-1] / f"{name}.pid" + deadline = time.monotonic() + 5 + while not path.is_file() and time.monotonic() < deadline: + time.sleep(0.02) + self.assertTrue(path.is_file(), f"fixture never created {name}") + handle = OwnedProcessHandle(int(path.read_text())) + self.handles.append(handle) + captured.append(handle) + return captured + + def test_force_stop_reaps_descendants_but_not_an_unrelated_process(self) -> None: + self.unrelated = REAL_POPEN( + [sys.executable, "-I", "-S", "-c", "import time; time.sleep(60)"], + stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) + self.context.start() + descendants = self.capture_descendants() + self.context.stop(force=True) + self.assertTrue(all(process.exited() for process in descendants)) + self.assertIsNone(self.unrelated.poll()) + self.context.work_dir.rmdir() + + def test_stop_still_reaps_children_after_the_parent_has_exited(self) -> None: + self.context.start() + descendants = self.capture_descendants() + (self.launches[-1] / "exit").touch() + self.context.process.wait(timeout=5) + self.context.stop(force=True) + self.assertTrue(all(process.exited() for process in descendants)) + + def test_restart_reaps_the_previous_launch_before_starting_another(self) -> None: + self.context.start() + previous = self.capture_descendants() + self.context.restart() + current = self.capture_descendants() + self.assertTrue(all(process.exited() for process in previous)) + self.assertTrue(all(not process.exited() for process in current)) + + def test_normal_window_close_also_reaps_leftover_descendants(self) -> None: + self.context.start() + descendants = self.capture_descendants() + with patch.object( + self.context, "api", side_effect=lambda *args, **kwargs: (self.launches[-1] / "exit").touch() + ): + self.context.close_and_wait() + self.assertTrue(all(process.exited() for process in descendants)) + + def test_window_close_preserves_the_application_exit_code(self) -> None: + self.context.start() + descendants = self.capture_descendants() + (self.launches[-1] / "exit-code").write_text("7") + with patch.object( + self.context, "api", + side_effect=lambda *args, **kwargs: (self.launches[-1] / "exit").touch(), + ): + with self.assertRaisesRegex(ConformanceError, "Nebula exited with code 7"): + self.context.close_and_wait() + self.assertTrue(all(process.exited() for process in descendants)) + + def test_assignment_failure_does_not_start_an_uncontained_application(self) -> None: + with patch("conformance.harness._WindowsJob.assign", side_effect=OSError("cannot assign")): + with self.assertRaisesRegex(ConformanceError, "cannot assign"): + self.context.start() + self.assertIsNone(self.context.process) + self.assertTrue(all(process.poll() is not None for process in self.parents)) + self.assertFalse((self.launches[-1] / "child.pid").exists()) + + def test_cleanup_errors_propagate_and_retain_ownership_for_retry(self) -> None: + self.context.start() + descendants = self.capture_descendants() + process, job = self.context.process, self.context._windows_job + with patch.object(job, "terminate", side_effect=OSError("cleanup unavailable")): + with self.assertRaisesRegex(OSError, "cleanup unavailable"): + self.context.stop(force=True) + self.assertIs(self.context.process, process) + self.assertIs(self.context._windows_job, job) + self.context.stop(force=True) + self.assertTrue(all(process.exited() for process in descendants)) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/conformance/tests/test_scrollback.py b/scripts/conformance/tests/test_scrollback.py new file mode 100644 index 0000000..b43f5df --- /dev/null +++ b/scripts/conformance/tests/test_scrollback.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import os +from pathlib import Path +import re +import shutil +import subprocess +import sys +import unittest +from unittest.mock import patch + +SCRIPTS_DIR = Path(__file__).resolve().parents[2] +if str(SCRIPTS_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPTS_DIR)) + +from conformance.harness import ConformanceContext + + +SHELLS = ("powershell", "pwsh", "cmd", "fish", "nu", "sh", "bash", "dash", "zsh") +MARKER = "NEBULA_SCROLL_DONE" + + +def context_for(shell: str) -> ConformanceContext: + context = object.__new__(ConformanceContext) + context.shell = shell + context.pane_id = 1 + return context + + +def available_shells() -> list[tuple[str, list[str]]]: + commands = { + "powershell": ["powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive", "-Command"], + "pwsh": ["pwsh", "-NoLogo", "-NoProfile", "-NonInteractive", "-Command"], + "cmd": ["cmd.exe", "/d", "/s", "/c"], + "fish": ["fish", "--no-config", "-c"], + "nu": ["nu", "--no-config-file", "-c"], + "sh": ["sh", "-c"], + "bash": ["bash", "--noprofile", "--norc", "-c"], + "dash": ["dash", "-c"], + "zsh": ["zsh", "-f", "-c"], + } + result = [] + for shell, command in commands.items(): + # Do not discover Windows interoperability launchers from a POSIX run. + if os.name != "nt" and shell in {"powershell", "cmd"}: + continue + executable = shutil.which(command[0]) + if ( + executable and os.name == "nt" and shell == "bash" + and Path(executable).parent.name.lower() in {"system32", "sysnative", "windowsapps"} + ): + # Windows' bash.exe is the WSL launcher, with different command + # argument parsing. The POSIX run tests Bash directly. + continue + if executable: + result.append((shell, [executable, *command[1:]])) + return result + + +class ScrollbackCompletionTests(unittest.TestCase): + def test_complete_marker_is_absent_from_every_shell_command(self) -> None: + for shell in SHELLS: + with self.subTest(shell=shell): + command = context_for(shell).scrollback_command(600, MARKER) + self.assertNotIn(MARKER, command) + + def test_command_echo_cannot_complete_the_output_wait(self) -> None: + for shell in SHELLS: + with self.subTest(shell=shell): + context = context_for(shell) + command = context.scrollback_command(600, MARKER) + observations = [ + {"text": f"prompt> {command}\nNEBULA_SCROLL_1"}, + {"text": "NEBULA_SCROLL_310\nNEBULA_SCROLL_311"}, + {"text": f"NEBULA_SCROLL_600\n{MARKER}"}, + ] + with patch.object(context, "read", side_effect=observations), patch( + "conformance.harness.time.sleep" + ): + _, completed = context.wait_for_line(re.compile(MARKER), timeout=1) + self.assertEqual(completed, observations[-1]) + + def test_real_shell_emits_completion_once_after_all_generated_rows(self) -> None: + shells = available_shells() + if not shells: + self.skipTest("No supported shell is installed") + for shell, invocation in shells: + with self.subTest(shell=shell): + command = context_for(shell).scrollback_command(6, MARKER) + result = subprocess.run( + [*invocation, command], + stdin=subprocess.DEVNULL, + capture_output=True, + text=True, + encoding="utf-8", + timeout=10, + check=True, + ) + self.assertEqual( + result.stdout.splitlines(), + [*(f"NEBULA_SCROLL_{index}" for index in range(1, 7)), MARKER], + ) + + +if __name__ == "__main__": + unittest.main() From bbb89a858c594917cb33969b1d3209cf324913ff Mon Sep 17 00:00:00 2001 From: kuddev Date: Sun, 6 Sep 2026 22:40:42 +0800 Subject: [PATCH 10/31] fix(gpui): adopt deferred X11 map-event processing Advance the synchronized Zed and component pins to drain XCB-buffered map events on an explicitly awakened idle callback. Preserve checked request errors and defer processing until window construction releases the App borrow. --- Cargo.lock | 50 +++++++++++++++++++++++++------------------------- Cargo.toml | 18 ++++++++++-------- 2 files changed, 35 insertions(+), 33 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1ba59de..a14330e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1349,7 +1349,7 @@ dependencies = [ [[package]] name = "collections" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=1b913031d01f5b3df8fcab3e8c2ac3568e7f5524#1b913031d01f5b3df8fcab3e8c2ac3568e7f5524" +source = "git+https://github.com/Kuddev/zed?rev=8a1a62727bcb2ead7352e32e7e0d2e919a943535#8a1a62727bcb2ead7352e32e7e0d2e919a943535" dependencies = [ "gpui_util", "indexmap", @@ -1959,7 +1959,7 @@ dependencies = [ [[package]] name = "derive_refineable" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=1b913031d01f5b3df8fcab3e8c2ac3568e7f5524#1b913031d01f5b3df8fcab3e8c2ac3568e7f5524" +source = "git+https://github.com/Kuddev/zed?rev=8a1a62727bcb2ead7352e32e7e0d2e919a943535#8a1a62727bcb2ead7352e32e7e0d2e919a943535" dependencies = [ "proc-macro2", "quote", @@ -3094,7 +3094,7 @@ dependencies = [ [[package]] name = "gpui" version = "0.2.2" -source = "git+https://github.com/Kuddev/zed?rev=1b913031d01f5b3df8fcab3e8c2ac3568e7f5524#1b913031d01f5b3df8fcab3e8c2ac3568e7f5524" +source = "git+https://github.com/Kuddev/zed?rev=8a1a62727bcb2ead7352e32e7e0d2e919a943535#8a1a62727bcb2ead7352e32e7e0d2e919a943535" dependencies = [ "accesskit", "anyhow", @@ -3180,7 +3180,7 @@ dependencies = [ [[package]] name = "gpui-component" version = "0.5.2" -source = "git+https://github.com/Kuddev/gpui-component?rev=05bb03f54d08b80ac059f7daea52707745e05956#05bb03f54d08b80ac059f7daea52707745e05956" +source = "git+https://github.com/Kuddev/gpui-component?rev=2dd896281102c7d5a0137ac7c754886dc0d5e1fc#2dd896281102c7d5a0137ac7c754886dc0d5e1fc" dependencies = [ "aho-corasick", "anyhow", @@ -3263,7 +3263,7 @@ dependencies = [ [[package]] name = "gpui-component-assets" version = "0.5.1" -source = "git+https://github.com/Kuddev/gpui-component?rev=05bb03f54d08b80ac059f7daea52707745e05956#05bb03f54d08b80ac059f7daea52707745e05956" +source = "git+https://github.com/Kuddev/gpui-component?rev=2dd896281102c7d5a0137ac7c754886dc0d5e1fc#2dd896281102c7d5a0137ac7c754886dc0d5e1fc" dependencies = [ "anyhow", "gpui", @@ -3277,7 +3277,7 @@ dependencies = [ [[package]] name = "gpui-component-macros" version = "0.5.1" -source = "git+https://github.com/Kuddev/gpui-component?rev=05bb03f54d08b80ac059f7daea52707745e05956#05bb03f54d08b80ac059f7daea52707745e05956" +source = "git+https://github.com/Kuddev/gpui-component?rev=2dd896281102c7d5a0137ac7c754886dc0d5e1fc#2dd896281102c7d5a0137ac7c754886dc0d5e1fc" dependencies = [ "proc-macro2", "quote", @@ -3287,7 +3287,7 @@ dependencies = [ [[package]] name = "gpui_linux" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=1b913031d01f5b3df8fcab3e8c2ac3568e7f5524#1b913031d01f5b3df8fcab3e8c2ac3568e7f5524" +source = "git+https://github.com/Kuddev/zed?rev=8a1a62727bcb2ead7352e32e7e0d2e919a943535#8a1a62727bcb2ead7352e32e7e0d2e919a943535" dependencies = [ "accesskit", "accesskit_unix", @@ -3339,7 +3339,7 @@ dependencies = [ [[package]] name = "gpui_macos" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=1b913031d01f5b3df8fcab3e8c2ac3568e7f5524#1b913031d01f5b3df8fcab3e8c2ac3568e7f5524" +source = "git+https://github.com/Kuddev/zed?rev=8a1a62727bcb2ead7352e32e7e0d2e919a943535#8a1a62727bcb2ead7352e32e7e0d2e919a943535" dependencies = [ "accesskit", "accesskit_macos", @@ -3387,7 +3387,7 @@ dependencies = [ [[package]] name = "gpui_macros" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=1b913031d01f5b3df8fcab3e8c2ac3568e7f5524#1b913031d01f5b3df8fcab3e8c2ac3568e7f5524" +source = "git+https://github.com/Kuddev/zed?rev=8a1a62727bcb2ead7352e32e7e0d2e919a943535#8a1a62727bcb2ead7352e32e7e0d2e919a943535" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -3398,7 +3398,7 @@ dependencies = [ [[package]] name = "gpui_platform" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=1b913031d01f5b3df8fcab3e8c2ac3568e7f5524#1b913031d01f5b3df8fcab3e8c2ac3568e7f5524" +source = "git+https://github.com/Kuddev/zed?rev=8a1a62727bcb2ead7352e32e7e0d2e919a943535#8a1a62727bcb2ead7352e32e7e0d2e919a943535" dependencies = [ "console_error_panic_hook", "gpui", @@ -3411,7 +3411,7 @@ dependencies = [ [[package]] name = "gpui_shared_string" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=1b913031d01f5b3df8fcab3e8c2ac3568e7f5524#1b913031d01f5b3df8fcab3e8c2ac3568e7f5524" +source = "git+https://github.com/Kuddev/zed?rev=8a1a62727bcb2ead7352e32e7e0d2e919a943535#8a1a62727bcb2ead7352e32e7e0d2e919a943535" dependencies = [ "schemars", "serde", @@ -3421,7 +3421,7 @@ dependencies = [ [[package]] name = "gpui_util" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=1b913031d01f5b3df8fcab3e8c2ac3568e7f5524#1b913031d01f5b3df8fcab3e8c2ac3568e7f5524" +source = "git+https://github.com/Kuddev/zed?rev=8a1a62727bcb2ead7352e32e7e0d2e919a943535#8a1a62727bcb2ead7352e32e7e0d2e919a943535" dependencies = [ "anyhow", "log", @@ -3431,7 +3431,7 @@ dependencies = [ [[package]] name = "gpui_web" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=1b913031d01f5b3df8fcab3e8c2ac3568e7f5524#1b913031d01f5b3df8fcab3e8c2ac3568e7f5524" +source = "git+https://github.com/Kuddev/zed?rev=8a1a62727bcb2ead7352e32e7e0d2e919a943535#8a1a62727bcb2ead7352e32e7e0d2e919a943535" dependencies = [ "anyhow", "console_error_panic_hook", @@ -3454,7 +3454,7 @@ dependencies = [ [[package]] name = "gpui_wgpu" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=1b913031d01f5b3df8fcab3e8c2ac3568e7f5524#1b913031d01f5b3df8fcab3e8c2ac3568e7f5524" +source = "git+https://github.com/Kuddev/zed?rev=8a1a62727bcb2ead7352e32e7e0d2e919a943535#8a1a62727bcb2ead7352e32e7e0d2e919a943535" dependencies = [ "anyhow", "bytemuck", @@ -3484,7 +3484,7 @@ dependencies = [ [[package]] name = "gpui_windows" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=1b913031d01f5b3df8fcab3e8c2ac3568e7f5524#1b913031d01f5b3df8fcab3e8c2ac3568e7f5524" +source = "git+https://github.com/Kuddev/zed?rev=8a1a62727bcb2ead7352e32e7e0d2e919a943535#8a1a62727bcb2ead7352e32e7e0d2e919a943535" dependencies = [ "accesskit", "accesskit_windows", @@ -3780,7 +3780,7 @@ dependencies = [ [[package]] name = "http_client" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=1b913031d01f5b3df8fcab3e8c2ac3568e7f5524#1b913031d01f5b3df8fcab3e8c2ac3568e7f5524" +source = "git+https://github.com/Kuddev/zed?rev=8a1a62727bcb2ead7352e32e7e0d2e919a943535#8a1a62727bcb2ead7352e32e7e0d2e919a943535" dependencies = [ "anyhow", "async-compression", @@ -4742,7 +4742,7 @@ checksum = "7ebb8d8732c6a6df3d8f032a82911cfc747e00efb95cc46e8d0acd5b5b88570c" [[package]] name = "media" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=1b913031d01f5b3df8fcab3e8c2ac3568e7f5524#1b913031d01f5b3df8fcab3e8c2ac3568e7f5524" +source = "git+https://github.com/Kuddev/zed?rev=8a1a62727bcb2ead7352e32e7e0d2e919a943535#8a1a62727bcb2ead7352e32e7e0d2e919a943535" dependencies = [ "anyhow", "bindgen", @@ -6210,7 +6210,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perf" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=1b913031d01f5b3df8fcab3e8c2ac3568e7f5524#1b913031d01f5b3df8fcab3e8c2ac3568e7f5524" +source = "git+https://github.com/Kuddev/zed?rev=8a1a62727bcb2ead7352e32e7e0d2e919a943535#8a1a62727bcb2ead7352e32e7e0d2e919a943535" dependencies = [ "collections", "serde", @@ -7109,7 +7109,7 @@ dependencies = [ [[package]] name = "refineable" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=1b913031d01f5b3df8fcab3e8c2ac3568e7f5524#1b913031d01f5b3df8fcab3e8c2ac3568e7f5524" +source = "git+https://github.com/Kuddev/zed?rev=8a1a62727bcb2ead7352e32e7e0d2e919a943535#8a1a62727bcb2ead7352e32e7e0d2e919a943535" dependencies = [ "derive_refineable", ] @@ -7704,7 +7704,7 @@ dependencies = [ [[package]] name = "scheduler" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=1b913031d01f5b3df8fcab3e8c2ac3568e7f5524#1b913031d01f5b3df8fcab3e8c2ac3568e7f5524" +source = "git+https://github.com/Kuddev/zed?rev=8a1a62727bcb2ead7352e32e7e0d2e919a943535#8a1a62727bcb2ead7352e32e7e0d2e919a943535" dependencies = [ "async-task", "backtrace", @@ -8567,7 +8567,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "sum_tree" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=1b913031d01f5b3df8fcab3e8c2ac3568e7f5524#1b913031d01f5b3df8fcab3e8c2ac3568e7f5524" +source = "git+https://github.com/Kuddev/zed?rev=8a1a62727bcb2ead7352e32e7e0d2e919a943535#8a1a62727bcb2ead7352e32e7e0d2e919a943535" dependencies = [ "heapless", "log", @@ -9892,7 +9892,7 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "util_macros" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=1b913031d01f5b3df8fcab3e8c2ac3568e7f5524#1b913031d01f5b3df8fcab3e8c2ac3568e7f5524" +source = "git+https://github.com/Kuddev/zed?rev=8a1a62727bcb2ead7352e32e7e0d2e919a943535#8a1a62727bcb2ead7352e32e7e0d2e919a943535" dependencies = [ "perf", "quote", @@ -11903,7 +11903,7 @@ dependencies = [ [[package]] name = "zlog" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=1b913031d01f5b3df8fcab3e8c2ac3568e7f5524#1b913031d01f5b3df8fcab3e8c2ac3568e7f5524" +source = "git+https://github.com/Kuddev/zed?rev=8a1a62727bcb2ead7352e32e7e0d2e919a943535#8a1a62727bcb2ead7352e32e7e0d2e919a943535" dependencies = [ "anyhow", "chrono", @@ -11920,7 +11920,7 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "ztracing" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=1b913031d01f5b3df8fcab3e8c2ac3568e7f5524#1b913031d01f5b3df8fcab3e8c2ac3568e7f5524" +source = "git+https://github.com/Kuddev/zed?rev=8a1a62727bcb2ead7352e32e7e0d2e919a943535#8a1a62727bcb2ead7352e32e7e0d2e919a943535" dependencies = [ "tracing", "tracing-subscriber", @@ -11931,7 +11931,7 @@ dependencies = [ [[package]] name = "ztracing_macro" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=1b913031d01f5b3df8fcab3e8c2ac3568e7f5524#1b913031d01f5b3df8fcab3e8c2ac3568e7f5524" +source = "git+https://github.com/Kuddev/zed?rev=8a1a62727bcb2ead7352e32e7e0d2e919a943535#8a1a62727bcb2ead7352e32e7e0d2e919a943535" [[package]] name = "zune-core" diff --git a/Cargo.toml b/Cargo.toml index ac52444..5fd26f3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -69,7 +69,7 @@ toml = "0.9.11" toml_edit = "0.24.0" # GPUI v1.16.1 把平台启动入口拆到独立 crate。它必须与 `gpui` 使用完全相同 # 的 Git URL 和 rev,否则 Cargo 会把同名类型解析成两套互不兼容的 crate。 -gpui_platform = { git = "https://github.com/Kuddev/zed", rev = "1b913031d01f5b3df8fcab3e8c2ac3568e7f5524", version = "=0.1.0" } +gpui_platform = { git = "https://github.com/Kuddev/zed", rev = "8a1a62727bcb2ead7352e32e7e0d2e919a943535", version = "=0.1.0" } # TODO: Drop this pin and return to the crates.io release once the fix ships there. [patch.crates-io] @@ -96,15 +96,17 @@ winit = { path = "third_party/winit-0.30.13" } # @ fa3763c681dba5b3ea028395148488e1281d478d # (c20dbef8 基线 + 下游虚拟窗口测试支持) # -# 当前虚拟窗口句柄修补(2026-09-06,保留以上上游/组件基线): +# 当前窗口生命周期修补(2026-09-06,保留以上上游/组件基线): # Kuddev/zed nebula-v1.16.1-virtual-window-handles-20260906 -# @ 1b913031d01f5b3df8fcab3e8c2ac3568e7f5524 +# @ 8a1a62727bcb2ead7352e32e7e0d2e919a943535 # (TestWindow 对不存在的原生窗口/显示句柄返回 Unavailable;真实窗口路径不变) # Kuddev/gpui-component nebula-v1.16.1-virtual-window-handles-20260906 -# @ 05bb03f54d08b80ac059f7daea52707745e05956 +# @ 2dd896281102c7d5a0137ac7c754886dc0d5e1fc # (五个 Zed 依赖同步到上面的同一提交,避免两套 GPUI 类型) # 原因:macOS 密码输入的原生 content-type 适配已处理 Err,但虚拟窗口此前 -# 直接 panic,阻止完整对话框回归测试。上游包含此合同后可撤销这份薄修补。 +# 直接 panic,阻止完整对话框回归测试。X11 checked MapWindow 还会把事件留在 +# XCB 内部队列而使 fd 不可读;延后 drain 并唤醒 calloop,才能启动首次刷新。 +# 该问题由原样 Xvfb/libxcb 与 CI AppImage 重现;上游包含这些合同后撤销薄修补。 # # 所有 Zed 依赖统一使用 Kuddev URL:Cargo 的 source identity 包含 URL,应用 # 与 component 内部若混用官方/自有 URL,即使 SHA 相同也会解析成两套 gpui @@ -171,6 +173,6 @@ winit = { path = "third_party/winit-0.30.13" } # 上游提供等价的下游 headless 测试支持后撤销本补丁。 # # 当前精确引用已包含补丁 1-3 与 6;Input 补丁 4-5 仍保持独立,尚未带入本次升级。 -gpui = { git = "https://github.com/Kuddev/zed", rev = "1b913031d01f5b3df8fcab3e8c2ac3568e7f5524", version = "=0.2.2" } -gpui-component = { git = "https://github.com/Kuddev/gpui-component", rev = "05bb03f54d08b80ac059f7daea52707745e05956", version = "=0.5.2" } -gpui-component-assets = { git = "https://github.com/Kuddev/gpui-component", rev = "05bb03f54d08b80ac059f7daea52707745e05956", version = "=0.5.1" } +gpui = { git = "https://github.com/Kuddev/zed", rev = "8a1a62727bcb2ead7352e32e7e0d2e919a943535", version = "=0.2.2" } +gpui-component = { git = "https://github.com/Kuddev/gpui-component", rev = "2dd896281102c7d5a0137ac7c754886dc0d5e1fc", version = "=0.5.2" } +gpui-component-assets = { git = "https://github.com/Kuddev/gpui-component", rev = "2dd896281102c7d5a0137ac7c754886dc0d5e1fc", version = "=0.5.1" } From 0687e31849ed487079fcdc6fe094727ebf7599ff Mon Sep 17 00:00:00 2001 From: kuddev Date: Mon, 7 Sep 2026 08:48:23 +0800 Subject: [PATCH 11/31] refactor(terminal): keep keyboard flag application with its conversions Consolidate the existing active-mode flag helper in term/keyboard.rs without changing its logic. Tighten the term module allowance to 2713 lines, preserving the stricter peer baseline and all keyboard state regressions. Verified 209 core tests and architecture checks against a3d25c9. --- architecture/file-budgets.txt | 2 +- nebula_terminal/src/term/keyboard.rs | 23 ++++++++++++++++++++--- nebula_terminal/src/term/mod.rs | 13 ------------- 3 files changed, 21 insertions(+), 17 deletions(-) diff --git a/architecture/file-budgets.txt b/architecture/file-budgets.txt index a08a95d..f01bef5 100644 --- a/architecture/file-budgets.txt +++ b/architecture/file-budgets.txt @@ -20,4 +20,4 @@ nebula_app/src/event.rs 3594 nebula_app/src/gpui_shell/terminal/view.rs 2416 nebula_app/src/gpui_shell/workspace.rs 4798 nebula_app/src/window_context.rs 3735 -nebula_terminal/src/term/mod.rs 2726 +nebula_terminal/src/term/mod.rs 2713 diff --git a/nebula_terminal/src/term/keyboard.rs b/nebula_terminal/src/term/keyboard.rs index 435d352..92b61d3 100644 --- a/nebula_terminal/src/term/keyboard.rs +++ b/nebula_terminal/src/term/keyboard.rs @@ -1,7 +1,9 @@ -//! Conversion between terminal flags and the keyboard protocol reported to applications. +//! Keyboard protocol flag conversion and application to the active terminal mode. -use super::TermMode; -use crate::vte::ansi::KeyboardModes; +use log::trace; + +use super::{Term, TermMode}; +use crate::vte::ansi::{KeyboardModes, KeyboardModesApplyBehavior}; const KEYBOARD_FLAGS: [(KeyboardModes, TermMode); 5] = [ (KeyboardModes::DISAMBIGUATE_ESC_CODES, TermMode::DISAMBIGUATE_ESC_CODES), @@ -30,3 +32,18 @@ impl From for KeyboardModes { mode } } + +impl Term { + #[inline] + pub(super) fn set_keyboard_mode(&mut self, mode: TermMode, apply: KeyboardModesApplyBehavior) { + let active_mode = self.mode & TermMode::KITTY_KEYBOARD_PROTOCOL; + self.mode &= !TermMode::KITTY_KEYBOARD_PROTOCOL; + let new_mode = match apply { + KeyboardModesApplyBehavior::Replace => mode, + KeyboardModesApplyBehavior::Union => active_mode.union(mode), + KeyboardModesApplyBehavior::Difference => active_mode.difference(mode), + }; + trace!("Setting keyboard mode to {new_mode:?}"); + self.mode |= new_mode; + } +} diff --git a/nebula_terminal/src/term/mod.rs b/nebula_terminal/src/term/mod.rs index 92bff98..7f8d51d 100644 --- a/nebula_terminal/src/term/mod.rs +++ b/nebula_terminal/src/term/mod.rs @@ -1250,19 +1250,6 @@ impl Term { Point::new(self.grid.cursor.point.line.0 as usize, self.grid.cursor.point.column); self.damage.damage_point(point); } - - #[inline] - fn set_keyboard_mode(&mut self, mode: TermMode, apply: KeyboardModesApplyBehavior) { - let active_mode = self.mode & TermMode::KITTY_KEYBOARD_PROTOCOL; - self.mode &= !TermMode::KITTY_KEYBOARD_PROTOCOL; - let new_mode = match apply { - KeyboardModesApplyBehavior::Replace => mode, - KeyboardModesApplyBehavior::Union => active_mode.union(mode), - KeyboardModesApplyBehavior::Difference => active_mode.difference(mode), - }; - trace!("Setting keyboard mode to {new_mode:?}"); - self.mode |= new_mode; - } } impl Dimensions for Term { From 95b40caaa8db5b1959b2b5c716a9d4380681d487 Mon Sep 17 00:00:00 2001 From: kuddev Date: Mon, 7 Sep 2026 08:50:57 +0800 Subject: [PATCH 12/31] fix(ci): free completed macOS test artifacts before packaging The Intel native job completed tests and the release build but hdiutil reported ENOSPC. Drop only the completed Nebula dev artifacts, retain dependency caches, and record source size and filesystem capacity around DMG creation. Keep native packaging errors and verification authoritative. --- .github/workflows/preview-packages.yml | 7 +++++++ scripts/package-macos.sh | 12 ++++++++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/.github/workflows/preview-packages.yml b/.github/workflows/preview-packages.yml index 131b9a7..0212d30 100644 --- a/.github/workflows/preview-packages.yml +++ b/.github/workflows/preview-packages.yml @@ -321,6 +321,13 @@ jobs: - name: Test SSH confirmation and password dialogs run: cargo test --locked -p nebula --features gpui-test-support dialog + - name: Release completed test artifacts before building the package + run: | + df -h . "${TMPDIR:-/tmp}" + du -sh target/debug + cargo clean --profile dev --package nebula + df -h . "${TMPDIR:-/tmp}" + - name: Build GPUI release binary run: cargo build --locked --release -p nebula --bin nebula --features gpui-shell diff --git a/scripts/package-macos.sh b/scripts/package-macos.sh index 156a202..80ad3cc 100644 --- a/scripts/package-macos.sh +++ b/scripts/package-macos.sh @@ -246,12 +246,20 @@ if [[ "$(lipo -archs "$contents/MacOS/nebula")" != "$expected_uname" ]]; then fi ln -s /Applications "$stage/Applications" -hdiutil create \ +df -h "$output_directory" "$work" +du -sh "$stage" +if hdiutil create \ -volname "Nebula Terminal Preview" \ -srcfolder "$stage" \ -ov \ -format UDZO \ - "$dmg_path" + "$dmg_path"; then + : +else + create_status=$? + df -h "$output_directory" "$work" + exit "$create_status" +fi hdiutil verify "$dmg_path" if [[ -n "$sign_identity" ]]; then codesign --force --sign "$sign_identity" --timestamp ${keychain_args[@]+"${keychain_args[@]}"} "$dmg_path" From cca2fe79745dbcf539250af3ea082c120da508a8 Mon Sep 17 00:00:00 2001 From: kuddev Date: Mon, 7 Sep 2026 09:25:35 +0800 Subject: [PATCH 13/31] fix(xplat): isolate metadata checks from native build outputs Use a marked metadata-only directory for both Cargo target and intermediate outputs, reject native targets and output/config overrides, and keep Cargo.lock fixed. Preserve peer scripts while preventing stub files from entering ordinary build caches. Add ten behavior regressions to Preview preparation; these tests record commands and do not substitute for native CI. --- .github/workflows/preview-packages.yml | 1 + scripts/tests/test_xplat.py | 176 +++++++++++++++++++++++++ scripts/xplat/check.sh | 88 +++++++++++-- scripts/xplat/fake-cc.sh | 0 4 files changed, 254 insertions(+), 11 deletions(-) create mode 100644 scripts/tests/test_xplat.py mode change 100644 => 100755 scripts/xplat/check.sh mode change 100644 => 100755 scripts/xplat/fake-cc.sh diff --git a/.github/workflows/preview-packages.yml b/.github/workflows/preview-packages.yml index 0212d30..ee403e5 100644 --- a/.github/workflows/preview-packages.yml +++ b/.github/workflows/preview-packages.yml @@ -103,6 +103,7 @@ jobs: scripts.tests.test_pebrel_branding scripts.tests.test_app_icons scripts.tests.test_git_attributes + scripts.tests.test_xplat scripts.conformance.tests.test_harness scripts.conformance.tests.test_resize scripts.conformance.tests.test_scrollback diff --git a/scripts/tests/test_xplat.py b/scripts/tests/test_xplat.py new file mode 100644 index 0000000..55720a1 --- /dev/null +++ b/scripts/tests/test_xplat.py @@ -0,0 +1,176 @@ +"""Exercise metadata-check isolation; the Cargo recorder performs no compilation.""" + +import json +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[2] +MAC_TARGET = "aarch64-apple-darwin" + + +@unittest.skipUnless(os.name == "posix" and shutil.which("bash"), "requires a POSIX Bash host") +class XplatIsolationTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix="nebula-xplat-test-") + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.repo = self.root / "repo with spaces" + self.scripts = self.repo / "scripts/xplat" + shutil.copytree(ROOT / "scripts/xplat", self.scripts) + self.output = self.repo / ".target-xplat/metadata-only-v1" + self.native = self.root / "native target" + self.native.mkdir() + self.native_sentinel = self.native / "keep.txt" + self.native_sentinel.write_text("native cache", encoding="utf-8") + self.calls = self.root / "cargo-calls.jsonl" + self.bin = self.root / "bin" + self.bin.mkdir() + rustc = self.bin / "rustc" + rustc.write_text( + '#!/usr/bin/env bash\nprintf "rustc test\\nhost: %s\\n" "$XPLAT_TEST_HOST"\n', + encoding="utf-8", + ) + cargo = self.bin / "cargo" + cargo.write_text( + "#!/usr/bin/env python3\n" + "import json, os, sys\n" + "from pathlib import Path\n" + "record = {'args': sys.argv[1:], 'target': os.environ['CARGO_TARGET_DIR'], " + "'build_dir': os.environ['CARGO_BUILD_BUILD_DIR'], " + "'cc': os.environ.get('CC_aarch64_apple_darwin')}\n" + "with Path(os.environ['XPLAT_TEST_LOG']).open('a', encoding='utf-8') as log:\n" + " log.write(json.dumps(record) + '\\n')\n" + "raise SystemExit(int(os.environ.get('XPLAT_TEST_EXIT', '0')))\n", + encoding="utf-8", + ) + rustc.chmod(0o755) + cargo.chmod(0o755) + + def run_check(self, *args, host="x86_64-pc-windows-msvc", exit_code=0): + env = { + **os.environ, + "PATH": str(self.bin) + os.pathsep + os.environ["PATH"], + "RUSTC": str(self.bin / "rustc"), + "CARGO_TARGET_DIR": str(self.native), + "CARGO_BUILD_BUILD_DIR": str(self.native / "intermediate"), + "XPLAT_TEST_HOST": host, + "XPLAT_TEST_LOG": str(self.calls), + "XPLAT_TEST_EXIT": str(exit_code), + } + return subprocess.run( + [str(self.scripts / "check.sh"), *(args or ("macos",))], + cwd=self.root, env=env, text=True, capture_output=True, timeout=10, + ) + + def recordings(self): + if not self.calls.exists(): + return [] + return [json.loads(line) for line in self.calls.read_text(encoding="utf-8").splitlines()] + + def build_out(self, base, package): + result = base / MAC_TARGET / "debug/build" / package / "out" + result.mkdir(parents=True) + return result + + def test_inherited_native_output_is_preserved_and_cargo_is_locked(self): + native_out = self.build_out(self.native, "media-native") + result = self.run_check("macos", "--features", "gpui-shell", "-p", "nebula") + self.assertEqual(result.returncode, 0, result.stderr) + call, = self.recordings() + self.assertEqual(call["target"], str(self.output)) + self.assertEqual(call["build_dir"], str(self.output)) + args = call["args"] + self.assertIn("--locked", args) + self.assertEqual(args[args.index("--target-dir") + 1], str(self.output)) + self.assertEqual(args[-4:], ["--features", "gpui-shell", "-p", "nebula"]) + self.assertEqual(list(native_out.iterdir()), []) + self.assertEqual(self.native_sentinel.read_text(encoding="utf-8"), "native cache") + self.assertIn("not a native build/test", result.stdout) + + def test_native_target_is_rejected_before_any_output_or_cargo_call(self): + for selector, host in [("macos", MAC_TARGET), ("linux", "x86_64-unknown-linux-gnu"), ("all", MAC_TARGET)]: + with self.subTest(selector=selector): + result = self.run_check(selector, host=host) + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("refusing native target", result.stderr) + self.assertEqual(self.recordings(), []) + self.assertFalse(self.output.exists()) + + def test_cargo_target_and_configuration_overrides_are_rejected(self): + for options in [ + ["--target-dir", str(self.native)], [f"--target-dir={self.native}"], + ["--target-d", str(self.native)], ["--config", "build.target-dir='target'"], + ["--config=build.target-dir='target'"], ["--target", "x86_64-unknown-linux-gnu"], + ["--manifest-path", "elsewhere/Cargo.toml"], ["--release"], + ]: + with self.subTest(options=options): + result = self.run_check("macos", *options) + self.assertEqual(result.returncode, 2, result.stderr) + self.assertEqual(self.recordings(), []) + self.assertFalse(self.output.exists()) + + def test_unmarked_existing_metadata_directory_is_not_repurposed(self): + self.output.mkdir(parents=True) + sentinel = self.output / "user-data" + sentinel.write_text("keep", encoding="utf-8") + result = self.run_check() + self.assertEqual(result.returncode, 2, result.stderr) + self.assertEqual(sentinel.read_text(encoding="utf-8"), "keep") + self.assertEqual(self.recordings(), []) + + def test_stubs_are_seeded_only_in_owned_metadata_output(self): + self.assertEqual(self.run_check().returncode, 0) + media = self.build_out(self.output, "media-check") + shaders = self.build_out(self.output, "gpui_macos-check") + native_media = self.build_out(self.native, "media-native") + native_shaders = self.build_out(self.native, "gpui_macos-native") + result = self.run_check() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual((media / "bindings.rs").read_bytes(), (self.scripts / "media-bindings-stub.rs").read_bytes()) + self.assertEqual((shaders / "shaders.metallib").read_bytes(), b"") + self.assertEqual(list(native_media.iterdir()), []) + self.assertEqual(list(native_shaders.iterdir()), []) + + def test_symlinked_output_root_is_rejected(self): + (self.repo / ".target-xplat").symlink_to(self.native, target_is_directory=True) + result = self.run_check() + self.assertEqual(result.returncode, 2, result.stderr) + self.assertEqual(self.recordings(), []) + self.assertEqual([path.name for path in self.native.iterdir()], ["keep.txt"]) + + def test_symlinked_stub_directory_cannot_escape_owned_output(self): + self.assertEqual(self.run_check().returncode, 0) + package = self.output / MAC_TARGET / "debug/build/media-escape" + package.mkdir(parents=True) + (package / "out").symlink_to(self.native, target_is_directory=True) + result = self.run_check() + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("escapes metadata directory", result.stderr) + self.assertEqual(len(self.recordings()), 1) + self.assertFalse((self.native / "bindings.rs").exists()) + + def test_symlinked_stub_file_cannot_overwrite_native_file(self): + self.assertEqual(self.run_check().returncode, 0) + media = self.build_out(self.output, "media-escape") + (media / "bindings.rs").symlink_to(self.native_sentinel) + result = self.run_check() + self.assertEqual(result.returncode, 2, result.stderr) + self.assertEqual(self.native_sentinel.read_text(encoding="utf-8"), "native cache") + + def test_cargo_failure_is_reported_unchanged(self): + result = self.run_check(exit_code=17) + self.assertEqual(result.returncode, 17) + self.assertEqual(len(self.recordings()), 1) + + def test_posix_compiler_can_be_invoked_directly(self): + result = subprocess.run( + [str(self.scripts / "fake-cc.sh"), "--version"], + text=True, capture_output=True, timeout=10, + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("cross-check stub", result.stdout) diff --git a/scripts/xplat/check.sh b/scripts/xplat/check.sh old mode 100644 new mode 100755 index f662d8b..4d88346 --- a/scripts/xplat/check.sh +++ b/scripts/xplat/check.sh @@ -1,14 +1,16 @@ #!/usr/bin/env bash # 在 Windows 本机对 macOS / Linux 目标做 `cargo check`,不需要对应的 SDK。 # -# 用法:scripts/xplat/check.sh [macos|linux|all] +# 用法:scripts/xplat/check.sh [macos|linux|all] [cargo check options] # # 这是跨目标元数据检查,可发现部分 cfg 与依赖声明不一致的问题 # (比如 Cargo.toml 里按平台排除了某个 crate 而代码无条件使用)。 # 使用占位 C 编译器与绑定,不能替代原生 runner 的真实 build/test。 -set -eu +# 输出固定在 .target-xplat/metadata-only-v1,绝不复用 CARGO_TARGET_DIR。 +set -euo pipefail here="$(cd "$(dirname "$0")" && pwd)" -repo="$(cd "$here/../.." && pwd)" +repo="$(cd "$here/../.." && pwd -P)" +fail() { echo "xplat: $*" >&2; exit 2; } targets=() case "${1:-all}" in macos) targets=(aarch64-apple-darwin) ;; @@ -16,29 +18,93 @@ case "${1:-all}" in all) targets=(aarch64-apple-darwin x86_64-unknown-linux-gnu) ;; *) echo "usage: $0 [macos|linux|all]" >&2; exit 2 ;; esac -export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-$repo/.target-xplat}" -case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) fake="$(cygpath -w "$here/fake-cc.cmd")" ;; *) fake="$here/fake-cc.sh" ;; esac +if [ "$#" -gt 0 ]; then shift; fi +# Keep feature/package selection and diagnostics, but reject Cargo configuration +# and output overrides (including abbreviated flags) before creating any files. +check_args=() +while [ "$#" -gt 0 ]; do + case "$1" in + --all-targets|--all-features|--no-default-features|--lib|--bins|--examples|--tests|--benches|--keep-going|-v|-vv|--verbose|-q|--quiet) + check_args+=("$1"); shift ;; + -p|--package|-F|--features|-j|--jobs|--bin|--example|--test|--bench|--exclude|--message-format|--color) + [ "$#" -gt 1 ] && [[ "$2" != -* ]] || fail "missing value for $1" + check_args+=("$1" "$2"); shift 2 ;; + --package=*|--features=*|--jobs=*|--bin=*|--example=*|--test=*|--bench=*|--exclude=*|--message-format=*|--color=*) + check_args+=("$1"); shift ;; + *) fail "unsupported check argument: $1 (target, output, profile and config overrides are disabled)" ;; + esac +done +cd "$repo" +host_info="$("${RUSTC:-rustc}" -vV)" +host="" +while IFS= read -r line; do + line="${line%$'\r'}" + case "$line" in 'host: '*) host="${line#host: }" ;; esac +done <<< "$host_info" +[ -n "$host" ] || fail "rustc did not report its host target" +for target in "${targets[@]}"; do + [ "$target" != "$host" ] || fail "refusing native target $target; use real cargo build/test" +done +target_dir="$repo/.target-xplat/metadata-only-v1" +marker="$target_dir/.nebula-metadata-only" +for dir in "$repo/.target-xplat" "$target_dir" "$marker"; do + [ ! -L "$dir" ] || fail "refusing symlinked metadata output: $dir" +done +if [ -e "$target_dir" ]; then + [ -d "$target_dir" ] && [ -f "$marker" ] && + [ "$(cat "$marker")" = 'nebula cross-target metadata only v1' ] || + fail "refusing existing output without its metadata-only marker: $target_dir" +else + mkdir -p "$target_dir" + printf '%s\n' 'nebula cross-target metadata only v1' > "$marker" +fi +cargo_target_dir="$target_dir" +case "$(uname -s)" in + MINGW*|MSYS*|CYGWIN*) + fake="$(cygpath -w "$here/fake-cc.cmd")" + cargo_target_dir="$(cygpath -w "$target_dir")" ;; + *) fake="$here/fake-cc.sh" ;; +esac +check_owned_directory() { + local resolved + resolved="$(cd "$1" && pwd -P)" + case "$resolved/" in "$target_dir/"*) ;; *) fail "stub output escapes metadata directory: $1" ;; esac +} status=0 seed_media_stub() { # zed `media` 的 build.rs 按宿主 cfg 决定是否生成 bindings.rs,Windows 宿主 # 交叉到 macOS 时什么都不生成。把桩放进它的 OUT_DIR(首轮 check 后才存在)。 - local root="$CARGO_TARGET_DIR/$1/debug/build" dir + local root="$target_dir/$1/debug/build" dir + [ -d "$root" ] || return 0 + check_owned_directory "$root" for dir in "$root"/media-*/out; do - [ -d "$dir" ] && [ ! -f "$dir/bindings.rs" ] && cp "$here/media-bindings-stub.rs" "$dir/bindings.rs" + [ -d "$dir" ] || continue + check_owned_directory "$dir" + [ ! -L "$dir/bindings.rs" ] || fail "symlinked bindings output: $dir" + if [ ! -e "$dir/bindings.rs" ]; then + cp "$here/media-bindings-stub.rs" "$dir/bindings.rs" + fi done # gpui_macos 用 `include_bytes!` 嵌 Metal 着色器库,同样只有 Mac 宿主能编; # check 不执行它,空文件即可。 for dir in "$root"/gpui_macos-*/out; do - [ -d "$dir" ] && [ ! -f "$dir/shaders.metallib" ] && : > "$dir/shaders.metallib" + [ -d "$dir" ] || continue + check_owned_directory "$dir" + [ ! -L "$dir/shaders.metallib" ] || fail "symlinked shader output: $dir" + if [ ! -e "$dir/shaders.metallib" ]; then + : > "$dir/shaders.metallib" + fi done return 0 } for target in "${targets[@]}"; do - echo "== cargo check --target $target" + echo "== metadata-only cargo check --target $target (not a native build/test)" env_target="$(echo "$target" | tr '-' '_')" case "$target" in *apple-darwin) seed_media_stub "$target" ;; esac - env "CC_${env_target}=$fake" "CXX_${env_target}=$fake" "AR_${env_target}=$fake" \ + env "CARGO_TARGET_DIR=$cargo_target_dir" "CARGO_BUILD_BUILD_DIR=$cargo_target_dir" \ + "CC_${env_target}=$fake" "CXX_${env_target}=$fake" "AR_${env_target}=$fake" \ "PKG_CONFIG_ALLOW_CROSS=1" "RUST_FONTCONFIG_DLOPEN=1" \ - cargo check --offline --workspace --target "$target" "${@:2}" || status=$? + cargo check --locked --offline --workspace --target "$target" \ + --target-dir "$cargo_target_dir" ${check_args[@]+"${check_args[@]}"} || status=$? done exit $status diff --git a/scripts/xplat/fake-cc.sh b/scripts/xplat/fake-cc.sh old mode 100644 new mode 100755 From 4fb0f8c7e2640edad905dc1535d6763aebee26b3 Mon Sep 17 00:00:00 2001 From: kuddev Date: Mon, 7 Sep 2026 14:48:15 +0800 Subject: [PATCH 14/31] fix(ci): provide an input seat for Wayland package validation Run Weston with its X11 backend inside one private Xvfb session, retaining compositor cleanup and a cleared DISPLAY for the actual Wayland application. This supplies the wl_seat absent from the headless backend without changing conformance assertions. --- .github/workflows/preview-packages.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/preview-packages.yml b/.github/workflows/preview-packages.yml index ee403e5..ac88b27 100644 --- a/.github/workflows/preview-packages.yml +++ b/.github/workflows/preview-packages.yml @@ -206,12 +206,17 @@ jobs: env: NEBULA_RESIZE_TRACE: "1" run: | + # Weston headless has no wl_seat. A private X11 backend provides + # input devices while DISPLAY is cleared for the Wayland client. + xvfb-run -a -s "-screen 0 1600x1000x24" bash -euo pipefail <<'WAYLAND_SESSION' runtime_directory="$RUNNER_TEMP/nebula-wayland-runtime" mkdir -p "$runtime_directory" dist/conformance chmod 0700 "$runtime_directory" XDG_RUNTIME_DIR="$runtime_directory" \ weston \ - --backend=headless-backend.so \ + --backend=x11-backend.so \ + --use-pixman \ + --no-config \ --socket=wayland-nebula \ --width=1600 \ --height=1000 \ @@ -243,6 +248,7 @@ jobs: --app "dist/NebulaTerminal-v$ASSET_VERSION-linux-x86_64.AppImage" \ --platform linux-x86_64-appimage-wayland \ --output dist/conformance/linux-appimage-wayland-report.json + WAYLAND_SESSION - name: Install and run Debian package conformance under X11 env: From acbabacf979b18d86135568c0247248be6c78889 Mon Sep 17 00:00:00 2001 From: kuddev Date: Mon, 7 Sep 2026 15:08:10 +0800 Subject: [PATCH 15/31] fix(preview): render macOS text and own complete POSIX launches Enable the native font backend, fit initial windows inside the usable display, and require terminal text in installed macOS screenshots. Isolate POSIX launch groups and reject runtime endpoints outside the current launch so AppImage restarts cannot reconnect to orphaned instances. --- .github/workflows/preview-packages.yml | 7 +- Cargo.lock | 1 + Cargo.toml | 3 +- .../src/gpui_shell/workspace/windowing.rs | 18 +- scripts/conformance/harness.py | 29 ++- scripts/conformance/macos_launch.py | 27 ++- scripts/conformance/macos_text.swift | 26 +++ .../conformance/tests/test_posix_lifecycle.py | 206 ++++++++++++++++++ scripts/preview_release.py | 3 +- scripts/tests/test_preview_release.py | 8 + 10 files changed, 314 insertions(+), 14 deletions(-) create mode 100644 scripts/conformance/macos_text.swift create mode 100644 scripts/conformance/tests/test_posix_lifecycle.py diff --git a/.github/workflows/preview-packages.yml b/.github/workflows/preview-packages.yml index ac88b27..cdf3917 100644 --- a/.github/workflows/preview-packages.yml +++ b/.github/workflows/preview-packages.yml @@ -108,6 +108,7 @@ jobs: scripts.conformance.tests.test_resize scripts.conformance.tests.test_scrollback scripts.conformance.tests.test_lifecycle + scripts.conformance.tests.test_posix_lifecycle - name: Compile Python helpers run: python3 -m compileall -q scripts/preview_release.py scripts/conformance @@ -322,8 +323,10 @@ jobs: - name: Test workspace natively run: cargo test --locked --workspace - - name: Test native shell startup contracts - run: python3 -m unittest scripts.tests.test_shell_integration -v + - name: Test native shell startup and harness process ownership + run: >- + python3 -m unittest scripts.tests.test_shell_integration + scripts.conformance.tests.test_posix_lifecycle -v - name: Test SSH confirmation and password dialogs run: cargo test --locked -p nebula --features gpui-test-support dialog diff --git a/Cargo.lock b/Cargo.lock index a14330e..6a046f0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3382,6 +3382,7 @@ dependencies = [ "smallvec", "strum 0.27.2", "uuid", + "zed-font-kit", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 5fd26f3..454a063 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -69,7 +69,8 @@ toml = "0.9.11" toml_edit = "0.24.0" # GPUI v1.16.1 把平台启动入口拆到独立 crate。它必须与 `gpui` 使用完全相同 # 的 Git URL 和 rev,否则 Cargo 会把同名类型解析成两套互不兼容的 crate。 -gpui_platform = { git = "https://github.com/Kuddev/zed", rev = "8a1a62727bcb2ead7352e32e7e0d2e919a943535", version = "=0.1.0" } +# Without font-kit, the macOS platform uses NoopTextSystem and renders no text. +gpui_platform = { git = "https://github.com/Kuddev/zed", rev = "8a1a62727bcb2ead7352e32e7e0d2e919a943535", version = "=0.1.0", features = ["font-kit"] } # TODO: Drop this pin and return to the crates.io release once the fix ships there. [patch.crates-io] diff --git a/nebula_app/src/gpui_shell/workspace/windowing.rs b/nebula_app/src/gpui_shell/workspace/windowing.rs index f57e69c..d380dd5 100644 --- a/nebula_app/src/gpui_shell/workspace/windowing.rs +++ b/nebula_app/src/gpui_shell/workspace/windowing.rs @@ -251,10 +251,24 @@ pub(crate) fn open_initial_window( fn workspace_window_options(cx: &mut App, focus: bool, role: WindowRole) -> WindowOptions { match role { WindowRole::Regular => { - let bounds = Bounds::centered(None, size(px(1080.0), px(720.0)), cx); + let preferred = size(px(1080.0), px(720.0)); + let bounds = cx.primary_display().map_or_else( + || Bounds::centered(None, preferred, cx), + |display| { + let visible = display.visible_bounds(); + let fitted = preferred.min(&visible.size); + Bounds::new( + point( + visible.origin.x + (visible.size.width - fitted.width) / 2.0, + visible.origin.y + (visible.size.height - fitted.height) / 2.0, + ), + fitted, + ) + }, + ); WindowOptions { window_bounds: Some(WindowBounds::Windowed(bounds)), - window_min_size: Some(size(px(760.0), px(540.0))), + window_min_size: Some(size(px(760.0), px(540.0)).min(&bounds.size)), titlebar: Some(TitleBar::title_bar_options()), app_id: Some("nebula".to_owned()), window_background: crate::gpui_shell::wallpaper::initial_background_appearance(), diff --git a/scripts/conformance/harness.py b/scripts/conformance/harness.py index 9e6e726..8535693 100644 --- a/scripts/conformance/harness.py +++ b/scripts/conformance/harness.py @@ -10,6 +10,7 @@ import json import os import re import shutil +import signal import socket import subprocess import sys @@ -521,6 +522,13 @@ class ConformanceContext: ) snapshot = snapshot_response.get("result") require(isinstance(snapshot, dict), "runtime snapshot is not an object") + if os.name == "posix": + runtime_pid = snapshot.get("process_id") + require( + type(runtime_pid) is int and runtime_pid > 0 + and os.getpgid(runtime_pid) == self.process.pid, + "runtime process does not belong to this launch", + ) windows = snapshot.get("windows") or [] ready = any( any( @@ -548,7 +556,9 @@ class ConformanceContext: "stderr": subprocess.STDOUT, } if os.name != "nt": - return subprocess.Popen(command, stdin=subprocess.DEVNULL, **options) + return subprocess.Popen( + command, stdin=subprocess.DEVNULL, start_new_session=True, **options, + ) job = _WindowsJob() process = None try: @@ -586,15 +596,20 @@ class ConformanceContext: if process is not None: process.wait(timeout=max(0, deadline - time.monotonic())) self._windows_job.close() - elif process is not None and process.poll() is None: - if force: - process.kill() - else: - process.terminate() + elif process is not None: + # An AppImage launcher forks the application. Its private + # group remains ours even after the launcher itself exits. + try: + os.killpg(process.pid, signal.SIGKILL if force else signal.SIGTERM) + except ProcessLookupError: + pass try: process.wait(timeout=timeout) except subprocess.TimeoutExpired: - process.kill() + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass process.wait(timeout=timeout) finally: self._close_log() diff --git a/scripts/conformance/macos_launch.py b/scripts/conformance/macos_launch.py index 42466d0..39fe8b2 100644 --- a/scripts/conformance/macos_launch.py +++ b/scripts/conformance/macos_launch.py @@ -23,6 +23,25 @@ if str(SCRIPTS) not in sys.path: from conformance.harness import ConformanceContext, ResolvedApp, RuntimeClient, require +def verify_rendered_text(ctx: ConformanceContext, probe: Path, log: BinaryIO) -> None: + marker = "PEBREL TEXT READY" + # Clear the echoed command before printing the marker; only rendered output + # can satisfy the screenshot observation. + ctx.prompt("printf '\\033[2J\\033[H%s%s\\n' 'PEBREL TEXT ' 'READY'") + ctx.wait_for_line(re.compile(r"^" + re.escape(marker) + r"$")) + screenshot = ctx.artifact_dir / "installed-app.png" + deadline = time.monotonic() + 15 + while time.monotonic() < deadline: + require(capture_screenshot(screenshot, log) == "captured", "installed app screenshot is unavailable") + result = subprocess.run([str(probe), str(screenshot), marker], + stdout=log, stderr=subprocess.STDOUT, timeout=15, check=False) + require(result.returncode in (0, 1), "screenshot text recognition failed") + if result.returncode == 0: + return + time.sleep(0.1) + raise RuntimeError("installed app did not render the terminal text marker") + + def capture_screenshot(destination: Path, log: BinaryIO) -> str: try: capture = subprocess.run(["/usr/sbin/screencapture", "-x", str(destination)], @@ -72,6 +91,11 @@ def main() -> int: launcher = None with (logs / "launchservices.log").open("wb") as log: try: + text_probe = root / "macos-text" + subprocess.run([ + "/usr/bin/xcrun", "swiftc", str(SCRIPTS / "conformance/macos_text.swift"), + "-module-cache-path", str(root / "swift-cache"), "-o", str(text_probe), + ], stdout=log, stderr=subprocess.STDOUT, timeout=90, check=True) launcher = subprocess.Popen( ["/usr/bin/open", "-n", "-W", "-a", str(installed), "--env", f"NEBULA_CONFIG_DIR={ctx.config_dir}", @@ -102,7 +126,8 @@ def main() -> int: ctx.wait_for_line(re.compile(r"NEBULA_GUI_LOCALE=[^\r\n]*UTF-?8_END", re.IGNORECASE)) ctx.prompt('printf "NEBULA_GUI_CWD=%s_END\\n" "$PWD"') ctx.wait_for_line(re.compile(r"NEBULA_GUI_CWD=" + re.escape(str(Path.home())) + r"_END")) - report["screenshot"] = capture_screenshot(logs / "installed-app.png", log) + verify_rendered_text(ctx, text_probe, log) + report.update(screenshot="captured", rendered_text=True) for window in ctx.snapshot().get("windows", []): ctx.api("window.close", {"window_id": window["id"]}) deadline = time.monotonic() + 5 diff --git a/scripts/conformance/macos_text.swift b/scripts/conformance/macos_text.swift new file mode 100644 index 0000000..14d0fd4 --- /dev/null +++ b/scripts/conformance/macos_text.swift @@ -0,0 +1,26 @@ +import Foundation +import Vision + +guard CommandLine.arguments.count == 3 else { + fputs("usage: macos-text IMAGE EXPECTED_TEXT\n", stderr) + exit(2) +} + +do { + let request = VNRecognizeTextRequest() + request.recognitionLevel = .accurate + request.usesLanguageCorrection = false + request.recognitionLanguages = ["en-US"] + let handler = VNImageRequestHandler(url: URL(fileURLWithPath: CommandLine.arguments[1])) + try handler.perform([request]) + let expected = CommandLine.arguments[2].uppercased() + let found = (request.results ?? []).contains { observation in + observation.topCandidates(1).contains { candidate in + candidate.string.uppercased().contains(expected) + } + } + exit(found ? 0 : 1) +} catch { + fputs("screenshot text recognition failed: \(error)\n", stderr) + exit(2) +} diff --git a/scripts/conformance/tests/test_posix_lifecycle.py b/scripts/conformance/tests/test_posix_lifecycle.py new file mode 100644 index 0000000..8cd3cb8 --- /dev/null +++ b/scripts/conformance/tests/test_posix_lifecycle.py @@ -0,0 +1,206 @@ +from __future__ import annotations + +import os +from pathlib import Path +import select +import signal +import subprocess +import sys +import tempfile +import time +from types import SimpleNamespace +import unittest +from unittest.mock import patch + +SCRIPTS_DIR = Path(__file__).resolve().parents[2] +if str(SCRIPTS_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPTS_DIR)) + +from conformance.harness import ConformanceContext, ConformanceError + + +REAL_POPEN = subprocess.Popen +FIXTURE = """ +from pathlib import Path +import os, subprocess, sys, time + +root = Path(sys.argv[1]) +role = sys.argv[2] +if role != 'grandchild': + next_role = 'child' if role == 'parent' else 'grandchild' + subprocess.Popen([sys.executable, '-I', '-S', __file__, str(root), next_role]) +(root / (role + '.pid')).write_text(str(os.getpid())) +while True: + if role == 'parent' and (root / 'exit').exists(): + code = root / 'exit-code' + raise SystemExit(int(code.read_text()) if code.exists() else 0) + time.sleep(0.01) +""" + + +@unittest.skipUnless(os.name == "posix", "POSIX launch process groups") +class PosixLifecycleTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory(prefix="nebula-posix-lifecycle-") + self.root = Path(self.temporary.name) + self.fixture = self.root / "fixture.py" + self.fixture.write_text(FIXTURE, encoding="utf-8") + self.app = SimpleNamespace(executable=self.root / "fixture-app") + self.context = ConformanceContext( + self.app, "linux-test", self.root / "config", self.root / "work", + self.root / "artifacts", startup_timeout=2, + ) + self.context.prepare() + self.context.port_file.write_text("1 fixture-token", encoding="utf-8") + self.parents: list[subprocess.Popen] = [] + self.launches: list[Path] = [] + self.descendants: list[int] = [] + self.unrelated: subprocess.Popen | None = None + self.addCleanup(self.cleanup_owned_processes) + + def launch_fixture(command, **kwargs): + launch = self.root / f"launch-{len(self.launches) + 1}" + launch.mkdir() + self.launches.append(launch) + # All fixture descendants inherit this pipe. EOF proves they have + # actually exited even when their former parent is already reaped. + kwargs["stdout"] = subprocess.PIPE + process = REAL_POPEN([ + sys.executable, "-I", "-S", os.fspath(self.fixture), + os.fspath(launch), "parent", + ], **kwargs) + self.parents.append(process) + return process + + self.client = SimpleNamespace(request=lambda *args, **kwargs: { + "result": { + "process_id": self.parents[-1].pid, + "windows": [{"tabs": [{"kind": "shell", "panes": [{}]}]}], + }, + }) + self.launch_patch = patch("conformance.harness.subprocess.Popen", side_effect=launch_fixture) + self.client_patch = patch( + "conformance.harness.RuntimeClient.from_port_file", return_value=self.client, + ) + self.launch_patch.start() + self.client_patch.start() + self.addCleanup(self.launch_patch.stop) + self.addCleanup(self.client_patch.stop) + + def capture_descendants(self) -> None: + for role in ("child", "grandchild"): + path = self.launches[-1] / f"{role}.pid" + deadline = time.monotonic() + 5 + while not path.is_file() and time.monotonic() < deadline: + time.sleep(0.01) + self.assertTrue(path.is_file(), f"fixture never created {role}") + self.descendants.append(int(path.read_text())) + + def assert_tree_exited(self, process: subprocess.Popen) -> None: + self.assertTrue(select.select([process.stdout], [], [], 2)[0], "launch descendants survived") + self.assertEqual(os.read(process.stdout.fileno(), 65536), b"") + + def cleanup_owned_processes(self) -> None: + self.context.stop(force=True) + # Keep the negative fixture bounded when running against the old harness. + for launch in self.launches: + for role in ("child", "grandchild"): + path = launch / f"{role}.pid" + if path.is_file() and path.read_text(): + self.descendants.append(int(path.read_text())) + for pid in set(self.descendants): + try: + os.kill(pid, signal.SIGKILL) + except ProcessLookupError: + pass + for process in [*self.parents, self.unrelated]: + if process is not None: + if process.poll() is None: + process.kill() + process.wait(timeout=5) + if process.stdout is not None: + process.stdout.close() + self.temporary.cleanup() + + def test_force_stop_terminates_the_launch_but_not_an_unrelated_process(self) -> None: + self.unrelated = REAL_POPEN( + [sys.executable, "-I", "-S", "-c", "import time; time.sleep(60)"], + stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) + self.context.start() + self.capture_descendants() + self.context.stop(force=True) + self.assert_tree_exited(self.parents[-1]) + self.assertIsNone(self.unrelated.poll()) + + def test_stop_terminates_children_after_the_wrapper_has_exited(self) -> None: + self.context.start() + self.capture_descendants() + (self.launches[-1] / "exit").touch() + self.context.process.wait(timeout=5) + self.context.stop(force=True) + self.assert_tree_exited(self.parents[-1]) + + def test_restart_terminates_the_previous_launch(self) -> None: + self.context.start() + self.capture_descendants() + self.context.restart() + self.capture_descendants() + self.assert_tree_exited(self.parents[0]) + self.assertFalse(select.select([self.parents[1].stdout], [], [], 0)[0]) + + def test_normal_close_terminates_leftover_children_and_preserves_exit_code(self) -> None: + for code in (0, 7): + with self.subTest(code=code): + self.context.start() + self.capture_descendants() + (self.launches[-1] / "exit-code").write_text(str(code)) + with patch.object( + self.context, "api", + side_effect=lambda *args, **kwargs: (self.launches[-1] / "exit").touch(), + ): + if code: + with self.assertRaisesRegex(ConformanceError, "Nebula exited with code 7"): + self.context.close_and_wait() + else: + self.context.close_and_wait() + self.assert_tree_exited(self.parents[-1]) + + def test_start_rejects_an_endpoint_from_another_launch(self) -> None: + self.context.startup_timeout = 0.15 + self.client.request = lambda *args, **kwargs: { + "result": { + "process_id": os.getpid(), + "windows": [{"tabs": [{"kind": "shell", "panes": [{}]}]}], + }, + } + with self.assertRaisesRegex(ConformanceError, "runtime process does not belong to this launch"): + self.context.start() + self.assert_tree_exited(self.parents[-1]) + + def test_start_accepts_the_application_beneath_a_wrapper(self) -> None: + self.client.request = lambda *args, **kwargs: { + "result": { + "process_id": int((self.launches[-1] / "child.pid").read_text()), + "windows": [{"tabs": [{"kind": "shell", "panes": [{}]}]}], + }, + } + self.context.start() + self.capture_descendants() + self.context.stop(force=True) + self.assert_tree_exited(self.parents[-1]) + + def test_cleanup_failure_retains_the_owned_group_for_retry(self) -> None: + self.context.start() + self.capture_descendants() + process = self.context.process + with patch("conformance.harness.os.killpg", side_effect=OSError("cleanup unavailable")): + with self.assertRaisesRegex(OSError, "cleanup unavailable"): + self.context.stop(force=True) + self.assertIs(self.context.process, process) + self.context.stop(force=True) + self.assert_tree_exited(process) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/preview_release.py b/scripts/preview_release.py index c1360c6..50e6d64 100644 --- a/scripts/preview_release.py +++ b/scripts/preview_release.py @@ -76,7 +76,8 @@ def validate_evidence(directory: Path, commit: str) -> None: if (report.get("status") != "passed" or report.get("commit") != commit or report.get("architecture") != architecture or report.get("launch_method") != "launchservices" - or report.get("utf8_locale") is not True or report.get("home_cwd") is not True): + or report.get("utf8_locale") is not True or report.get("home_cwd") is not True + or report.get("screenshot") != "captured" or report.get("rendered_text") is not True): raise ManifestError(f"installed macOS application did not pass LaunchServices smoke test: {name}") diff --git a/scripts/tests/test_preview_release.py b/scripts/tests/test_preview_release.py index d70cd6b..c654853 100644 --- a/scripts/tests/test_preview_release.py +++ b/scripts/tests/test_preview_release.py @@ -199,10 +199,18 @@ class PreviewReleaseTests(unittest.TestCase): (root / f"macos-{architecture}-launch.json").write_text(json.dumps({ "status": "passed", "commit": "a" * 40, "architecture": architecture, "launch_method": "launchservices", "utf8_locale": True, "home_cwd": True, + "screenshot": "captured", "rendered_text": True, }), encoding="utf-8") validate_evidence(root, "a" * 40) with self.assertRaisesRegex(ManifestError, "source commit"): validate_evidence(root, "b" * 40) + launch_path = root / "macos-aarch64-launch.json" + launch = json.loads(launch_path.read_text(encoding="utf-8")) + for field, value in (("rendered_text", False), ("screenshot", "unavailable")): + with self.subTest(field=field): + launch_path.write_text(json.dumps({**launch, field: value}), encoding="utf-8") + with self.assertRaisesRegex(ManifestError, "LaunchServices"): + validate_evidence(root, "a" * 40) (root / "macos-aarch64-launch.json").write_text('{}', encoding="utf-8") with self.assertRaisesRegex(ManifestError, "LaunchServices"): validate_evidence(root, "a" * 40) From 017927aa00e33c1cd4a5405003aa11be56fd2960 Mon Sep 17 00:00:00 2001 From: kuddev Date: Mon, 7 Sep 2026 17:09:00 +0800 Subject: [PATCH 16/31] fix(platform): own startup environment and migration diagnostics --- nebula_app/src/brand.rs | 96 ------------------------ nebula_app/src/main.rs | 5 +- nebula_app/src/platform/environment.rs | 100 +++++++++++++++++++++++++ nebula_app/src/platform/mod.rs | 1 + nebula_app/src/platform/startup.rs | 8 ++ 5 files changed, 111 insertions(+), 99 deletions(-) create mode 100644 nebula_app/src/platform/environment.rs diff --git a/nebula_app/src/brand.rs b/nebula_app/src/brand.rs index 71a7513..3ea586a 100644 --- a/nebula_app/src/brand.rs +++ b/nebula_app/src/brand.rs @@ -1,52 +1,6 @@ pub const NAME: &str = "Pebrel"; pub const DESCRIPTION: &str = "Pebrel — a GPU-accelerated terminal emulator"; -/// Must run before any threads start: environment mutation is process-global. -pub unsafe fn import_environment_aliases() { - for (name, value) in environment_aliases(std::env::vars_os().collect()) { - unsafe { std::env::set_var(name, value) }; - } -} - -pub(crate) fn environment_aliases( - variables: Vec<(std::ffi::OsString, std::ffi::OsString)>, -) -> Vec<(String, std::ffi::OsString)> { - let mut aliases = Vec::new(); - for (name, value) in &variables { - let Some(name) = name.to_str() else { continue }; - let canonical = if cfg!(windows) { name.to_ascii_uppercase() } else { name.to_owned() }; - let name = canonical.as_str(); - if let Some(suffix) = name.strip_prefix("PEBREL_") { - if configuration_override(suffix) && value.is_empty() { - continue; - } - aliases.push((format!("NEBULA_{suffix}"), value.clone())); - } else if let Some(suffix) = name.strip_prefix("NEBULA_") { - if configuration_override(suffix) && value.is_empty() { - continue; - } - let current = format!("PEBREL_{suffix}"); - if !variables.iter().any(|(name, value)| { - name.to_str().is_some_and(|name| { - let matches = if cfg!(windows) { - name.eq_ignore_ascii_case(¤t) - } else { - name == current - }; - matches && !(configuration_override(suffix) && value.is_empty()) - }) - }) { - aliases.push((current, value.clone())); - } - } - } - aliases -} - -fn configuration_override(suffix: &str) -> bool { - matches!(suffix, "CONFIG_DIR" | "CONFIG_FILE" | "GPUI_CONFIG") -} - #[cfg(test)] mod tests { use clap::CommandFactory; @@ -61,54 +15,4 @@ mod tests { assert_eq!(crate::config::window::Identity::default().title, super::NAME); assert_eq!(crate::config::window::DEFAULT_CLASS, "Pebrel"); } - - #[test] - fn environment_aliases_preserve_legacy_inputs_and_prefer_explicit_pebrel_values() { - let aliases = super::environment_aliases( - [ - ("NEBULA_CONFIG_DIR", "legacy"), - ("PEBREL_CONFIG_DIR", "current"), - ("NEBULA_PANE_REMOTE", "1"), - ("OTHER_TOOL", "unchanged"), - ] - .into_iter() - .map(|(name, value)| (name.into(), value.into())) - .collect(), - ); - assert_eq!( - aliases, - vec![ - ("NEBULA_CONFIG_DIR".to_owned(), "current".into()), - ("PEBREL_PANE_REMOTE".to_owned(), "1".into()), - ] - ); - } - - #[test] - fn empty_configuration_overrides_fall_back_without_reopening_an_empty_hook_scope() { - let aliases = super::environment_aliases( - [ - ("PEBREL_CONFIG_DIR", ""), - ("NEBULA_CONFIG_DIR", "legacy-config"), - ("PEBREL_CONFIG_FILE", ""), - ("NEBULA_CONFIG_FILE", "legacy.toml"), - ("PEBREL_GPUI_CONFIG", ""), - ("NEBULA_GPUI_CONFIG", "legacy-gpui.toml"), - ("PEBREL_NOTIFY_PIPE", ""), - ("NEBULA_NOTIFY_PIPE", "stale-pipe"), - ] - .into_iter() - .map(|(name, value)| (name.into(), value.into())) - .collect(), - ); - assert_eq!( - aliases, - vec![ - ("PEBREL_CONFIG_DIR".to_owned(), "legacy-config".into()), - ("PEBREL_CONFIG_FILE".to_owned(), "legacy.toml".into()), - ("PEBREL_GPUI_CONFIG".to_owned(), "legacy-gpui.toml".into()), - ("NEBULA_NOTIFY_PIPE".to_owned(), "".into()), - ] - ); - } } diff --git a/nebula_app/src/main.rs b/nebula_app/src/main.rs index 681defc..470ee0b 100644 --- a/nebula_app/src/main.rs +++ b/nebula_app/src/main.rs @@ -157,7 +157,7 @@ use crate::polling::{IoListener, ipc}; fn main() -> Result<(), Box> { // No worker threads exist yet; import the new override names for legacy readers. - unsafe { brand::import_environment_aliases() }; + unsafe { platform::environment::import_environment_aliases() }; // OpenSSH AskPass reuses the GUI executable as a credential helper. It // must exit before CLI parsing or window initialization because ssh passes // the human-readable prompt as an argument, not as a Nebula subcommand. @@ -179,8 +179,7 @@ fn main() -> Result<(), Box> { // Load command line options. let options = Options::new(); if let Err(error) = nebula_settings::migrate_legacy_data() { - #[cfg(windows)] - panic::report_startup_error(&error, options.subcommands.is_none()); + platform::startup::report_error(&error, options.subcommands.is_none()); return Err(error.into()); } #[cfg(windows)] diff --git a/nebula_app/src/platform/environment.rs b/nebula_app/src/platform/environment.rs new file mode 100644 index 0000000..c25eb74 --- /dev/null +++ b/nebula_app/src/platform/environment.rs @@ -0,0 +1,100 @@ +//! Startup compatibility for platform environment variable names. + +/// Must run before any threads start: environment mutation is process-global. +pub unsafe fn import_environment_aliases() { + for (name, value) in environment_aliases(std::env::vars_os().collect()) { + unsafe { std::env::set_var(name, value) }; + } +} + +pub(crate) fn environment_aliases( + variables: Vec<(std::ffi::OsString, std::ffi::OsString)>, +) -> Vec<(String, std::ffi::OsString)> { + let mut aliases = Vec::new(); + for (name, value) in &variables { + let Some(name) = name.to_str() else { continue }; + let canonical = if cfg!(windows) { name.to_ascii_uppercase() } else { name.to_owned() }; + let name = canonical.as_str(); + if let Some(suffix) = name.strip_prefix("PEBREL_") { + if configuration_override(suffix) && value.is_empty() { + continue; + } + aliases.push((format!("NEBULA_{suffix}"), value.clone())); + } else if let Some(suffix) = name.strip_prefix("NEBULA_") { + if configuration_override(suffix) && value.is_empty() { + continue; + } + let current = format!("PEBREL_{suffix}"); + if !variables.iter().any(|(name, value)| { + name.to_str().is_some_and(|name| { + let matches = if cfg!(windows) { + name.eq_ignore_ascii_case(¤t) + } else { + name == current + }; + matches && !(configuration_override(suffix) && value.is_empty()) + }) + }) { + aliases.push((current, value.clone())); + } + } + } + aliases +} + +fn configuration_override(suffix: &str) -> bool { + matches!(suffix, "CONFIG_DIR" | "CONFIG_FILE" | "GPUI_CONFIG") +} + +#[cfg(test)] +mod tests { + #[test] + fn environment_aliases_preserve_legacy_inputs_and_prefer_explicit_pebrel_values() { + let aliases = super::environment_aliases( + [ + ("NEBULA_CONFIG_DIR", "legacy"), + ("PEBREL_CONFIG_DIR", "current"), + ("NEBULA_PANE_REMOTE", "1"), + ("OTHER_TOOL", "unchanged"), + ] + .into_iter() + .map(|(name, value)| (name.into(), value.into())) + .collect(), + ); + assert_eq!( + aliases, + vec![ + ("NEBULA_CONFIG_DIR".to_owned(), "current".into()), + ("PEBREL_PANE_REMOTE".to_owned(), "1".into()), + ] + ); + } + + #[test] + fn empty_configuration_overrides_fall_back_without_reopening_an_empty_hook_scope() { + let aliases = super::environment_aliases( + [ + ("PEBREL_CONFIG_DIR", ""), + ("NEBULA_CONFIG_DIR", "legacy-config"), + ("PEBREL_CONFIG_FILE", ""), + ("NEBULA_CONFIG_FILE", "legacy.toml"), + ("PEBREL_GPUI_CONFIG", ""), + ("NEBULA_GPUI_CONFIG", "legacy-gpui.toml"), + ("PEBREL_NOTIFY_PIPE", ""), + ("NEBULA_NOTIFY_PIPE", "stale-pipe"), + ] + .into_iter() + .map(|(name, value)| (name.into(), value.into())) + .collect(), + ); + assert_eq!( + aliases, + vec![ + ("PEBREL_CONFIG_DIR".to_owned(), "legacy-config".into()), + ("PEBREL_CONFIG_FILE".to_owned(), "legacy.toml".into()), + ("PEBREL_GPUI_CONFIG".to_owned(), "legacy-gpui.toml".into()), + ("NEBULA_NOTIFY_PIPE".to_owned(), "".into()), + ] + ); + } +} diff --git a/nebula_app/src/platform/mod.rs b/nebula_app/src/platform/mod.rs index 0eb9ceb..dae0875 100644 --- a/nebula_app/src/platform/mod.rs +++ b/nebula_app/src/platform/mod.rs @@ -12,6 +12,7 @@ pub mod capabilities; pub mod credentials; pub mod dirs; +pub(crate) mod environment; pub mod file_manager; #[cfg(feature = "gpui-shell")] pub mod fonts; diff --git a/nebula_app/src/platform/startup.rs b/nebula_app/src/platform/startup.rs index 60da486..06a5e15 100644 --- a/nebula_app/src/platform/startup.rs +++ b/nebula_app/src/platform/startup.rs @@ -1,3 +1,11 @@ +/// Surface a pre-logger failure for a GUI launch; the caller also returns it on stderr. +pub(crate) fn report_error(error: &dyn std::fmt::Display, gui_launch: bool) { + #[cfg(windows)] + crate::panic::report_startup_error(error, gui_launch); + #[cfg(not(windows))] + let _ = (error, gui_launch); +} + pub fn prepare_gui() { #[cfg(target_os = "macos")] { From 2a2ca917c955fd7e67bf85f4415d537c5a9c5382 Mon Sep 17 00:00:00 2001 From: kuddev Date: Mon, 7 Sep 2026 17:11:29 +0800 Subject: [PATCH 17/31] fix(platform): route pane aliases through environment adapter --- nebula_app/src/agent_env.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nebula_app/src/agent_env.rs b/nebula_app/src/agent_env.rs index 8592b78..86a545a 100644 --- a/nebula_app/src/agent_env.rs +++ b/nebula_app/src/agent_env.rs @@ -60,7 +60,7 @@ const PATH_ENV: &str = "PATH"; /// [`crate::shell_detect::wsl_cwd_report_env`] 也会往里追加条目,本函数以 /// 环境表里的现值为基准合并,从而与调用顺序无关地保住两边的条目。 pub fn apply(env: &mut HashMap, pane_id: impl Display) { - let aliases = crate::brand::environment_aliases( + let aliases = crate::platform::environment::environment_aliases( env.iter().map(|(name, value)| (name.into(), value.into())).collect(), ); for (name, value) in aliases { From 426fc4653b5f1e4658639cc1061cab03c5f7cfd0 Mon Sep 17 00:00:00 2001 From: kuddev Date: Mon, 7 Sep 2026 17:13:25 +0800 Subject: [PATCH 18/31] fix(ci): pin Python for packaging and branding contracts --- .github/workflows/preview-packages.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/.github/workflows/preview-packages.yml b/.github/workflows/preview-packages.yml index 52ba3a4..cdf8c37 100644 --- a/.github/workflows/preview-packages.yml +++ b/.github/workflows/preview-packages.yml @@ -50,6 +50,9 @@ jobs: asset_version: ${{ steps.metadata.outputs.asset_version }} steps: - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" - name: Resolve package metadata id: metadata @@ -130,6 +133,9 @@ jobs: LINUXDEPLOY_SHA256: c20cd71e3a4e3b80c3483cef793cda3f4e990aca14014d23c544ca3ce1270b4d steps: - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" - name: Install build, package, and headless runtime dependencies run: | @@ -309,6 +315,9 @@ jobs: MACOSX_DEPLOYMENT_TARGET: "14.0" steps: - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" - name: Verify native runner architecture run: test "$(uname -m)" = "${{ matrix.native_architecture }}" @@ -468,6 +477,9 @@ jobs: timeout-minutes: 120 steps: - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" - name: Test native harness process ownership and completion markers run: >- python -m unittest @@ -510,6 +522,9 @@ jobs: ASSET_VERSION: ${{ needs.prepare.outputs.asset_version }} steps: - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" - uses: actions/download-artifact@v4 with: @@ -562,6 +577,9 @@ jobs: GH_TOKEN: ${{ github.token }} steps: - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" - uses: actions/download-artifact@v4 with: name: Pebrel-v${{ needs.prepare.outputs.asset_version }} From 0e0ac89a007962b78d6a6e0f2b4a66b4ba39be14 Mon Sep 17 00:00:00 2001 From: kuddev Date: Mon, 7 Sep 2026 18:20:02 +0800 Subject: [PATCH 19/31] fix: restore Codex conversations and prepare verified multi-platform releases --- .github/workflows/architecture.yml | 1 + .github/workflows/preview-packages.yml | 1 + .github/workflows/release.yml | 539 ++++++++++++++++++ CHANGELOG.md | 55 +- docs/architecture-decisions.md | 27 + docs/release-notes/v1.6.0.md | 58 +- nebula_app/src/agent_env.rs | 9 + nebula_app/src/gpui_shell/terminal/view.rs | 53 +- .../src/gpui_shell/terminal/view/runtime.rs | 150 ++++- nebula_app/src/gpui_shell/workspace.rs | 58 +- .../src/gpui_shell/workspace/closing.rs | 116 ++++ .../src/gpui_shell/workspace/windowing.rs | 23 + .../src/platform/ai_session_identity.rs | 469 +++++++++++++++ nebula_app/src/platform/mod.rs | 1 + nebula_app/src/runtime_exec.rs | 28 +- nebula_app/src/session.rs | 31 +- nebula_app/src/shell_detect.rs | 36 ++ nebula_app/src/update_check.rs | 9 +- .../linux/io.github.kuddev.pebrel.desktop | 12 + .../io.github.kuddev.pebrel.metainfo.xml | 26 + scripts/build-installer.ps1 | 2 +- scripts/installer.iss | 2 +- scripts/package-linux.sh | 120 ++-- scripts/package-macos.sh | 61 +- scripts/stable_release.py | 277 +++++++++ scripts/tests/installer.tests.ps1 | 2 +- scripts/tests/test_ai_session_probe.py | 61 ++ scripts/tests/test_pebrel_branding.py | 3 +- scripts/tests/test_stable_release.py | 186 ++++++ 29 files changed, 2219 insertions(+), 197 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 nebula_app/src/gpui_shell/workspace/closing.rs create mode 100644 nebula_app/src/platform/ai_session_identity.rs create mode 100644 packaging/linux/io.github.kuddev.pebrel.desktop create mode 100644 packaging/linux/io.github.kuddev.pebrel.metainfo.xml create mode 100644 scripts/stable_release.py create mode 100644 scripts/tests/test_ai_session_probe.py create mode 100644 scripts/tests/test_stable_release.py diff --git a/.github/workflows/architecture.yml b/.github/workflows/architecture.yml index 291125c..b062120 100644 --- a/.github/workflows/architecture.yml +++ b/.github/workflows/architecture.yml @@ -71,3 +71,4 @@ jobs: scripts.tests.test_pebrel_branding scripts.tests.test_windows_resources scripts.tests.test_app_icons + scripts.tests.test_ai_session_probe diff --git a/.github/workflows/preview-packages.yml b/.github/workflows/preview-packages.yml index cdf8c37..3a80e45 100644 --- a/.github/workflows/preview-packages.yml +++ b/.github/workflows/preview-packages.yml @@ -102,6 +102,7 @@ jobs: python3 -m unittest scripts.tests.test_preview_release scripts.tests.test_macos_dmg + scripts.tests.test_ai_session_probe scripts.tests.test_platform_cfg scripts.tests.test_windows_resources scripts.tests.test_pebrel_branding diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..4995667 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,539 @@ +name: Pebrel stable release + +run-name: >- + Pebrel ${{ inputs.version || github.ref_name }} stable release + +on: + push: + tags: ["v*.*.*"] + workflow_dispatch: + inputs: + version: + description: Stable Cargo package version to build and publish + required: true + type: string + publish: + description: Create the public GitHub Release after every package check passes + required: true + default: false + type: boolean + macos_signing: + description: macOS signing mode; Developer ID also requires notarization secrets + required: true + type: choice + options: [adhoc, developer-id] + default: adhoc + +concurrency: + group: stable-release-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: "1" + RUST_TOOLCHAIN: "1.97.1" + PYTHONUTF8: "1" + MACOS_SIGNING: ${{ inputs.macos_signing || 'adhoc' }} + +jobs: + prepare: + name: Validate stable release inputs + runs-on: ubuntu-22.04 + outputs: + version: ${{ steps.metadata.outputs.version }} + asset_version: ${{ steps.metadata.outputs.asset_version }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - name: Resolve and validate package metadata + id: metadata + env: + REQUESTED_VERSION: ${{ inputs.version }} + EVENT_NAME: ${{ github.event_name }} + REF_NAME: ${{ github.ref_name }} + run: | + version="$(python3 scripts/preview_release.py --print-package-version nebula_app/Cargo.toml)" + if [[ -n "$REQUESTED_VERSION" && "$REQUESTED_VERSION" != "$version" ]]; then + echo "requested version $REQUESTED_VERSION does not match Cargo package version $version" >&2 + exit 1 + fi + if [[ "$EVENT_NAME" == push && "$REF_NAME" != "v$version" ]]; then + echo "release tag $REF_NAME does not match Cargo package version $version" >&2 + exit 1 + fi + VERSION="$version" python3 - <<'PY' + import os + from scripts.stable_release import validate_version + validate_version(os.environ["VERSION"]) + if "-" in os.environ["VERSION"] or "+" in os.environ["VERSION"]: + raise SystemExit("stable releases require a plain semver without prerelease or build suffix") + PY + python3 - <<'PY' + from pathlib import Path + from scripts.stable_release import validate_changelog, validate_notes + import subprocess + version = subprocess.check_output( + ["python3", "scripts/preview_release.py", "--print-package-version", "nebula_app/Cargo.toml"], + text=True, + ).strip() + validate_notes(Path(f"docs/release-notes/v{version}.md"), version) + validate_changelog(Path("CHANGELOG.md"), version) + PY + { + echo "version=$version" + echo "asset_version=$version" + } >>"$GITHUB_OUTPUT" + - name: Check architecture and release helper contracts + run: | + python3 scripts/check_architecture.py + python3 scripts/check_platform_cfg.py + python3 -m unittest \ + scripts.tests.test_stable_release \ + scripts.tests.test_preview_release \ + scripts.tests.test_macos_dmg \ + scripts.tests.test_ai_session_probe \ + scripts.tests.test_platform_cfg \ + scripts.tests.test_windows_resources \ + scripts.tests.test_pebrel_branding \ + scripts.tests.test_app_icons \ + scripts.tests.test_git_attributes \ + scripts.tests.test_xplat + bash -n scripts/package-linux.sh scripts/package-macos.sh + python3 -m py_compile scripts/stable_release.py scripts/preview_release.py + + linux: + name: Linux x86_64 stable package + needs: prepare + runs-on: ubuntu-22.04 + timeout-minutes: 120 + env: + PACKAGE_VERSION: ${{ needs.prepare.outputs.version }} + LINUXDEPLOY_VERSION: 1-alpha-20251107-1 + LINUXDEPLOY_SHA256: c20cd71e3a4e3b80c3483cef793cda3f4e990aca14014d23c544ca3ce1270b4d + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - name: Install build, package, and headless runtime dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + appstream build-essential cmake curl desktop-file-utils dpkg-dev \ + imagemagick libasound2-dev libdbus-1-dev libegl1-mesa-dev \ + libfontconfig1-dev libfreetype6-dev libgl1-mesa-dev libudev-dev \ + libvulkan1 libwayland-dev libx11-dev libx11-xcb-dev \ + libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev \ + libxcb-xinput-dev libxcb-xkb-dev libxcb1-dev libxi-dev \ + libxkbcommon-dev libxkbcommon-x11-dev mesa-vulkan-drivers \ + patchelf pkg-config weston xauth xvfb zsh + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: ${{ env.RUST_TOOLCHAIN }} + - uses: Swatinem/rust-cache@v2 + with: + cache-on-failure: true + - name: Test workspace and startup contracts + run: | + cargo test --locked --workspace + cargo test --locked -p nebula --bin pebrel --features gpui-shell + python3 -m unittest scripts.tests.test_shell_integration + cargo test --locked -p nebula --features gpui-test-support dialog + - name: Build GPUI release binary + run: cargo build --locked --release -p nebula --bin pebrel --features gpui-shell + - name: Download pinned linuxdeploy + env: + LINUXDEPLOY: ${{ runner.temp }}/linuxdeploy-x86_64.AppImage + run: | + curl --fail --location --retry 3 --retry-all-errors --show-error \ + --output "$LINUXDEPLOY" \ + "https://github.com/linuxdeploy/linuxdeploy/releases/download/$LINUXDEPLOY_VERSION/linuxdeploy-x86_64.AppImage" + echo "$LINUXDEPLOY_SHA256 $LINUXDEPLOY" | sha256sum --check --strict + chmod 0755 "$LINUXDEPLOY" + echo "LINUXDEPLOY=$LINUXDEPLOY" >>"$GITHUB_ENV" + - name: Package stable Linux assets + run: | + SOURCE_DATE_EPOCH="$(git show -s --format=%ct "$GITHUB_SHA")" + export SOURCE_DATE_EPOCH + bash scripts/package-linux.sh \ + --channel stable \ + --binary target/release/pebrel \ + --version "$PACKAGE_VERSION" \ + --output-directory dist \ + --linuxdeploy "$LINUXDEPLOY" \ + --force + - name: Run AppImage conformance under X11 + env: + NEBULA_RESIZE_TRACE: "1" + run: | + xvfb-run -a -s "-screen 0 1600x1000x24" \ + python3 scripts/conformance/run.py \ + --app "dist/Pebrel-v$PACKAGE_VERSION-linux-x64.AppImage" \ + --platform linux-x86_64-appimage \ + --output dist/conformance/linux-appimage-report.json + - name: Run AppImage conformance under Wayland + env: + NEBULA_RESIZE_TRACE: "1" + run: | + xvfb-run -a -s "-screen 0 1600x1000x24" bash -euo pipefail <<'WAYLAND_SESSION' + runtime_directory="$RUNNER_TEMP/pebrel-wayland-runtime" + mkdir -p "$runtime_directory" dist/conformance + chmod 0700 "$runtime_directory" + XDG_RUNTIME_DIR="$runtime_directory" weston --backend=x11-backend.so \ + --use-pixman --no-config --socket=wayland-pebrel --width=1600 --height=1000 \ + --idle-time=0 --log=dist/conformance/weston.log & + weston_pid=$! + trap 'kill "$weston_pid" >/dev/null 2>&1 || true; wait "$weston_pid" >/dev/null 2>&1 || true' EXIT + for _ in $(seq 1 100); do + [[ -S "$runtime_directory/wayland-pebrel" ]] && break + kill -0 "$weston_pid" >/dev/null 2>&1 || exit 1 + sleep 0.1 + done + test -S "$runtime_directory/wayland-pebrel" + DISPLAY='' XDG_RUNTIME_DIR="$runtime_directory" WAYLAND_DISPLAY=wayland-pebrel \ + python3 scripts/conformance/run.py \ + --app "dist/Pebrel-v$PACKAGE_VERSION-linux-x64.AppImage" \ + --platform linux-x86_64-appimage-wayland \ + --output dist/conformance/linux-appimage-wayland-report.json + WAYLAND_SESSION + - name: Install and run Debian conformance + env: + NEBULA_RESIZE_TRACE: "1" + run: | + sudo apt-get install -y "./dist/Pebrel-v$PACKAGE_VERSION-linux-x64.deb" + test "$(dpkg-query -W -f='${Status}' pebrel)" = "install ok installed" + xvfb-run -a -s "-screen 0 1600x1000x24" \ + python3 scripts/conformance/run.py --app /usr/bin/pebrel \ + --platform linux-x86_64-deb --output dist/conformance/linux-deb-report.json + - name: Upload Linux stable assets + if: always() + uses: actions/upload-artifact@v4 + with: + name: stable-package-linux-x64 + path: | + dist/*.AppImage + dist/*.deb + dist/*.tar.gz + if-no-files-found: error + retention-days: 14 + - name: Upload Linux conformance evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: stable-evidence-linux-x64 + path: dist/conformance + if-no-files-found: warn + retention-days: 14 + + macos: + name: macOS ${{ matrix.architecture }} stable package + needs: prepare + timeout-minutes: 120 + strategy: + fail-fast: false + matrix: + include: + - runner: macos-15 + architecture: aarch64 + native_architecture: arm64 + asset_architecture: arm64 + - runner: macos-15-intel + architecture: x86_64 + native_architecture: x86_64 + asset_architecture: x64 + runs-on: ${{ matrix.runner }} + env: + PACKAGE_VERSION: ${{ needs.prepare.outputs.version }} + MACOSX_DEPLOYMENT_TARGET: "14.0" + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - name: Verify native runner architecture + run: test "$(uname -m)" = "${{ matrix.native_architecture }}" + - name: Test native DMG capacity and mounted contents + run: python3 -m unittest scripts.tests.test_macos_dmg -v + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: ${{ env.RUST_TOOLCHAIN }} + - uses: Swatinem/rust-cache@v2 + with: + cache-on-failure: true + - name: Test workspace and native startup contracts + run: | + cargo test --locked --workspace + cargo test --locked -p nebula --bin pebrel --features gpui-shell + python3 -m unittest scripts.tests.test_shell_integration scripts.conformance.tests.test_posix_lifecycle + cargo test --locked -p nebula --features gpui-test-support dialog + - name: Build GPUI release binary + run: cargo build --locked --release -p nebula --bin pebrel --features gpui-shell + - name: Import Developer ID signing credentials + if: ${{ env.MACOS_SIGNING == 'developer-id' }} + env: + APPLE_CERTIFICATE_BASE64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_API_KEY_BASE64: ${{ secrets.APPLE_API_KEY_BASE64 }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + run: | + export SIGNING_DIRECTORY="$RUNNER_TEMP/pebrel-signing" + mkdir -m 0700 "$SIGNING_DIRECTORY" + python3 - <<'PY' + import base64 + import os + from pathlib import Path + required = ["APPLE_CERTIFICATE_BASE64", "APPLE_CERTIFICATE_PASSWORD", "APPLE_SIGNING_IDENTITY", + "APPLE_API_KEY_BASE64", "APPLE_API_KEY_ID", "APPLE_API_ISSUER"] + if any(not os.environ.get(name) for name in required): + raise SystemExit("missing required Developer ID signing secret") + root = Path(os.environ["SIGNING_DIRECTORY"]) + for name, filename in [("APPLE_CERTIFICATE_BASE64", "certificate.p12"), ("APPLE_API_KEY_BASE64", "AuthKey.p8")]: + path = root / filename + path.write_bytes(base64.b64decode(os.environ[name], validate=True)) + path.chmod(0o600) + PY + keychain="$SIGNING_DIRECTORY/release.keychain-db" + password="$(openssl rand -hex 32)" + echo "::add-mask::$password" + security create-keychain -p "$password" "$keychain" + security set-keychain-settings -lut 21600 "$keychain" + security unlock-keychain -p "$password" "$keychain" + security import "$SIGNING_DIRECTORY/certificate.p12" -k "$keychain" -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$password" "$keychain" + xcrun notarytool store-credentials pebrel-release --keychain "$keychain" \ + --key "$SIGNING_DIRECTORY/AuthKey.p8" --key-id "$APPLE_API_KEY_ID" --issuer "$APPLE_API_ISSUER" + echo "PEBREL_SIGNING_KEYCHAIN=$keychain" >>"$GITHUB_ENV" + - name: Package stable macOS DMG + env: + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + run: | + signing_args=() + if [[ "$MACOS_SIGNING" == developer-id ]]; then + test -n "$PEBREL_SIGNING_KEYCHAIN" + signing_args=(--sign-identity "$APPLE_SIGNING_IDENTITY" --notary-profile pebrel-release --signing-keychain "$PEBREL_SIGNING_KEYCHAIN") + fi + bash scripts/package-macos.sh \ + --channel stable \ + --binary target/release/pebrel \ + --version "$PACKAGE_VERSION" \ + --architecture "${{ matrix.architecture }}" \ + --build-number "$GITHUB_RUN_NUMBER" \ + --output-directory dist --force "${signing_args[@]}" + - name: Mount DMG and run conformance + env: + NEBULA_RESIZE_TRACE: "1" + run: | + dmg="dist/Pebrel-v$PACKAGE_VERSION-macos-${{ matrix.asset_architecture }}.dmg" + mount_point="$RUNNER_TEMP/pebrel-stable-${{ matrix.architecture }}" + mkdir -p "$mount_point" + hdiutil attach "$dmg" -nobrowse -readonly -mountpoint "$mount_point" + trap 'hdiutil detach "$mount_point" >/dev/null 2>&1 || hdiutil detach "$mount_point" -force >/dev/null 2>&1 || true' EXIT + app="$mount_point/Pebrel.app" + codesign --verify --deep --strict --verbose=2 "$app" + plutil -lint "$app/Contents/Info.plist" + test "$(plutil -extract CFBundleIdentifier raw -o - "$app/Contents/Info.plist")" = "io.github.kuddev.pebrel" + test "$(lipo -archs "$app/Contents/MacOS/pebrel")" = "${{ matrix.native_architecture }}" + python3 scripts/conformance/run.py --app "$app" --platform "macos-${{ matrix.architecture }}" \ + --output "dist/conformance/macos-${{ matrix.architecture }}-report.json" + python3 scripts/conformance/macos_launch.py --app "$app" \ + --output "dist/conformance/macos-${{ matrix.architecture }}-launch.json" + - name: Remove temporary signing credentials + if: ${{ always() && env.MACOS_SIGNING == 'developer-id' }} + run: | + if [[ -f "$RUNNER_TEMP/pebrel-signing/release.keychain-db" ]]; then + security delete-keychain "$RUNNER_TEMP/pebrel-signing/release.keychain-db" + fi + rm -rf -- "$RUNNER_TEMP/pebrel-signing" + - name: Upload macOS stable asset + if: always() + uses: actions/upload-artifact@v4 + with: + name: stable-package-macos-${{ matrix.asset_architecture }} + path: dist/*.dmg + if-no-files-found: error + retention-days: 14 + - name: Upload macOS conformance evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: stable-evidence-macos-${{ matrix.architecture }} + path: dist/conformance + if-no-files-found: warn + retention-days: 14 + + windows: + name: Windows x64 stable package + needs: prepare + runs-on: windows-2022 + timeout-minutes: 120 + env: + PACKAGE_VERSION: ${{ needs.prepare.outputs.version }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: ${{ env.RUST_TOOLCHAIN }} + - uses: Swatinem/rust-cache@v2 + with: + cache-on-failure: true + - name: Test workspace and native harness + run: | + python -m unittest scripts.conformance.tests.test_lifecycle scripts.conformance.tests.test_scrollback + cargo test --locked --workspace + cargo test --locked -p nebula --bin pebrel --features gpui-shell + cargo test --locked -p nebula --features gpui-test-support dialog + - name: Build GPUI release binary + run: cargo build --locked --release -p nebula --bin pebrel --features gpui-shell + - name: Test Windows packaging scripts + shell: pwsh + run: | + ./scripts/tests/installer.tests.ps1 -TargetDirectory target + ./scripts/tests/package-release.tests.ps1 -TargetDirectory target + - name: Package ZIP and installer + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + ./scripts/package-release.ps1 -Version $env:PACKAGE_VERSION -PackageBrand Pebrel -OutputDirectory dist -TargetDirectory target -Force + ./scripts/build-installer.ps1 -Version $env:PACKAGE_VERSION -PackageBrand Pebrel -OutputDirectory dist -TargetDirectory target -Force + $canonical = Join-Path (Resolve-Path dist) "Pebrel-v$env:PACKAGE_VERSION-windows-x64-setup.exe" + $legacy = Join-Path (Resolve-Path dist) "NebulaTerminal-$env:PACKAGE_VERSION-windows-x64-setup.exe" + Copy-Item -LiteralPath $canonical -Destination $legacy -Force + $canonicalHash = (Get-FileHash -LiteralPath $canonical -Algorithm SHA256).Hash + $legacyHash = (Get-FileHash -LiteralPath $legacy -Algorithm SHA256).Hash + if ($canonicalHash -ne $legacyHash) { throw 'legacy installer alias differs from canonical installer' } + - name: Run Windows conformance + env: + NEBULA_RESIZE_TRACE: "1" + run: python scripts/conformance/run.py --app target/release/pebrel.exe --platform windows-x86_64 --output dist/conformance/windows-report.json + - name: Upload Windows stable assets + if: always() + uses: actions/upload-artifact@v4 + with: + name: stable-package-windows-x64 + path: | + dist/Pebrel-v${{ needs.prepare.outputs.version }}-windows-x64.zip + dist/Pebrel-v${{ needs.prepare.outputs.version }}-windows-x64-setup.exe + dist/NebulaTerminal-${{ needs.prepare.outputs.version }}-windows-x64-setup.exe + if-no-files-found: error + retention-days: 14 + - name: Upload Windows conformance evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: stable-evidence-windows-x64 + path: dist/conformance + if-no-files-found: warn + retention-days: 14 + + aggregate: + name: Verify stable assets and upload complete set + needs: [prepare, linux, macos, windows] + runs-on: ubuntu-22.04 + env: + PACKAGE_VERSION: ${{ needs.prepare.outputs.version }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - uses: actions/download-artifact@v4 + with: + pattern: stable-package-* + merge-multiple: true + path: release-dist + - uses: actions/download-artifact@v4 + with: + pattern: stable-evidence-* + merge-multiple: true + path: stable-evidence + - name: Validate conformance evidence and stable manifest + env: + SOURCE_COMMIT: ${{ github.sha }} + run: | + python3 - <<'PY' + import os + from pathlib import Path + from scripts.preview_release import validate_evidence + validate_evidence(Path("stable-evidence"), os.environ["SOURCE_COMMIT"]) + PY + python3 scripts/stable_release.py release-dist \ + --version "$PACKAGE_VERSION" \ + --notes "docs/release-notes/v$PACKAGE_VERSION.md" \ + --changelog CHANGELOG.md \ + --write-checksums release-dist/SHA256SUMS \ + --write-notes release-dist/RELEASE_NOTES.md + - name: Upload complete stable release set + uses: actions/upload-artifact@v4 + with: + name: Pebrel-v${{ needs.prepare.outputs.version }} + path: release-dist + if-no-files-found: error + retention-days: 30 + + publish: + name: Publish verified GitHub Release + if: ${{ github.event_name == 'push' || inputs.publish == true }} + needs: [prepare, aggregate] + runs-on: ubuntu-22.04 + permissions: + contents: write + env: + PACKAGE_VERSION: ${{ needs.prepare.outputs.version }} + GH_TOKEN: ${{ github.token }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - uses: actions/download-artifact@v4 + with: + name: Pebrel-v${{ needs.prepare.outputs.version }} + path: release-dist + - name: Create public stable Release + run: | + tag="v$PACKAGE_VERSION" + if gh release view "$tag" >/dev/null 2>&1; then + echo "refusing to replace existing Release: $tag" >&2 + exit 1 + fi + existing_tag_ref="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$tag" --jq '.object.type + " " + .object.sha' 2>/dev/null || true)" + existing_tag_commit="" + if [[ -n "$existing_tag_ref" ]]; then + read -r existing_tag_type existing_tag_object <<<"$existing_tag_ref" + if [[ "$existing_tag_type" == tag ]]; then + existing_tag_commit="$(gh api "repos/$GITHUB_REPOSITORY/git/tags/$existing_tag_object" --jq .object.sha)" + else + existing_tag_commit="$existing_tag_object" + fi + fi + if [[ -n "$existing_tag_commit" && "$existing_tag_commit" != "$GITHUB_SHA" ]]; then + echo "tag $tag already points to $existing_tag_commit, expected $GITHUB_SHA" >&2 + exit 1 + fi + gh release create "$tag" \ + release-dist/*.AppImage release-dist/*.deb release-dist/*.dmg \ + release-dist/*.tar.gz release-dist/*.zip release-dist/*-setup.exe \ + release-dist/SHA256SUMS \ + --target "$GITHUB_SHA" --title "Pebrel $PACKAGE_VERSION" \ + --notes-file release-dist/RELEASE_NOTES.md + gh api "repos/$GITHUB_REPOSITORY/releases/tags/$tag" >"$RUNNER_TEMP/stable-release.json" + tag_ref="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$tag" --jq '.object.type + " " + .object.sha')" + read -r tag_type tag_object <<<"$tag_ref" + if [[ "$tag_type" == tag ]]; then + tag_commit="$(gh api "repos/$GITHUB_REPOSITORY/git/tags/$tag_object" --jq .object.sha)" + else + tag_commit="$tag_object" + fi + python3 scripts/stable_release.py release-dist --version "$PACKAGE_VERSION" \ + --verify-release "$RUNNER_TEMP/stable-release.json" \ + --commit "$GITHUB_SHA" --tag-commit "$tag_commit" diff --git a/CHANGELOG.md b/CHANGELOG.md index a3f5b19..02c2d91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,24 +9,26 @@ Every release entry is provided in English and Simplified Chinese. ### English #### Added -- Clipboard images can be pasted into local, WSL, and SSH terminal sessions as PNG file paths. WSL paths are translated and SSH images are uploaded before insertion; failures are visible, and late results are discarded after input submission, interruption, or a session change. +- Release downloads now include Windows x64 ZIP and installer packages, Linux x64 AppImage, DEB and tar.gz packages, and separate macOS 14+ DMGs for Apple Silicon and Intel. macOS packages are ad-hoc signed; the first launch may require Open Anyway in System Settings > Privacy & Security. +- Pasting a clipboard image now writes it as a PNG and inserts its path into local, WSL, or SSH terminal sessions. WSL paths are translated and SSH images are uploaded before insertion; failures are reported, and results arriving after submission, interruption, or a session change are discarded. - Added an application icon picker with 25 color palettes and light/dark previews. -- Added an opt-in terminal network proxy setting. On Windows, new local sessions inherit the system HTTP/HTTPS proxy when enabled; existing sessions keep their environment. +- Added an opt-in terminal network proxy setting. On Windows, new local sessions inherit the system HTTP/HTTPS proxy when enabled; existing sessions keep their environment. Contributed by [@Sakyvo](https://github.com/Sakyvo) in [#94](https://github.com/Kuddev/pebrel/pull/94). - Added per-host SSH proxy and jump-host options, with separate proxy credentials and a connection-route preview. - Added an answer reader for captured Claude Code and Codex responses, with Markdown, formulas, source-text mode, and local image previews. - Added UI language choices for Traditional Chinese, French, German, Spanish, Brazilian Portuguese, Italian, Russian, Japanese, and Korean alongside English and Simplified Chinese. Initial coverage includes navigation, common actions, appearance, and network controls; untranslated text falls back to English. - Added a settings search that opens the matching section and a confirmed Restore defaults action that backs up preferences while retaining saved hosts and other user data. - Added the Antigravity product icon to terminal tabs and the sidebar, including its common launcher aliases, while retaining its original colors in light and dark themes. -- Recognized binary terminal confirmations can show Allow and Deny actions in a notification. The action is rejected if the prompt, session, or input state has changed, and is available only once for the captured request. +- Recognized binary terminal confirmation prompts now appear in notifications with Allow and Deny actions. An action is rejected when the prompt, session, or input state has changed, and each captured request can be handled only once. #### Fixed -- `Ctrl+C` copies and clears a terminal selection, and still sends an interrupt when no selection is present. Text inputs keep their own copy behavior. -- `Ctrl+Backspace` deletes a word in the supported legacy and kitty keyboard paths, including the managed PowerShell prompt. +- With AI session restoration enabled, Codex sessions in WSL and Linux can recover the current conversation ID when no hook reported it, so reopening the workspace resumes that conversation. Window close and the application's Quit action allow a bounded wait for the identity before saving. Ambiguous identities are not selected; native Windows and macOS continue to use hook-reported session IDs. +- `Ctrl+C` copies and clears a terminal selection, and still sends an interrupt when no selection is present. Text inputs keep their own copy behavior. Contributed by [@Sakyvo](https://github.com/Sakyvo) in [#93](https://github.com/Kuddev/pebrel/pull/93). +- `Ctrl+Backspace` deletes a word in the supported legacy and kitty keyboard paths, including the managed PowerShell prompt. Contributed by [@Sakyvo](https://github.com/Sakyvo) in [#92](https://github.com/Kuddev/pebrel/pull/92). - Running tab indicators keep animating when another window has focus; hidden or minimized windows stop requesting animation frames. - File-name search discards results from an old root, updates after creates, renames and deletes, and handles canonical file-watcher paths without losing the visible directory root. - Multiline confirmation text is measured with wrapping so longer messages fit the shared confirmation dialog. -- Improved Antigravity activity and permission detection so old spinner output and ordinary prose mentioning approval do not keep an idle session marked as running or waiting for permission. -- Closing the last regular window and then quitting no longer replaces saved tabs and AI session identities with an empty snapshot. Closing all tabs explicitly still starts an empty workspace next time, and failed session writes are retried. +- Antigravity activity and permission detection no longer lets old spinner output or ordinary prose mentioning approval mark an idle session as running or waiting for permission. Contributed by [@821869798](https://github.com/821869798) in [#96](https://github.com/Kuddev/pebrel/pull/96). +- Closing the last regular window and then quitting no longer replaces saved tabs with an empty snapshot. Explicitly closing all tabs still starts an empty workspace next time, and failed session writes are retried. - SSH startup now waits for PTY and shell confirmation before showing Ready, supports cancellation during connection setup, and stops stalled startup stages within a bounded timeout. Unexpected disconnects retain the pane and its failure state; explicit shell exits still close normally. - Duplicated WSL tabs retain the current guest directory and selected distribution. Duplicated SSH tabs retain the known remote working directory and quote it when starting the new remote shell. - Fixed Windows native SSH sessions consuming a remote application's first device-attributes response during local console startup, which could leave the application waiting with a blank screen. @@ -34,6 +36,7 @@ Every release entry is provided in English and Simplified Chinese. - Runtime CLI responses and event subscriptions stop cleanly when the program reading their output closes its pipe, instead of opening a panic dialog. Other output errors are still reported. #### Improved +- New package names consistently use `Pebrel-v--`. A byte-identical installer with the legacy Nebula filename remains available for existing Windows auto-update clients. - Made Titanium the default application icon and adjusted small-size proportions, prompt strokes, and transparent edges. - Unified the application, command, executable, helper, package, and managed integration names under Pebrel. New local sessions expose `PEBREL_*` environment variables alongside legacy aliases. - The Windows installer migrates registered `Nebula Terminal` installation directories to a sibling `Pebrel` directory, updates its shortcuts and PATH entry, and preserves custom directory names and unknown files. Cleanup failures are reported and can be retried. @@ -51,24 +54,26 @@ Every release entry is provided in English and Simplified Chinese. ### 中文 #### 新增 -- 新增把剪贴板图片转换为 PNG 文件路径后粘贴到本地、WSL 与 SSH 终端会话;WSL 会转换路径,SSH 会先上传图片。失败会明确提示,提交输入、中断或会话变化后到达的结果会被丢弃。 +- 发布下载新增 Windows x64 ZIP 与安装器、Linux x64 AppImage、DEB 与 tar.gz,以及分别适用于 Apple Silicon 和 Intel 的 macOS 14+ DMG。macOS 包采用临时签名,首次启动可能需要在“系统设置 > 隐私与安全性”中选择“仍要打开”。 +- 粘贴剪贴板图片时会将其写成 PNG,并把路径插入本地、WSL 或 SSH 终端会话。WSL 会转换路径,SSH 会先上传图片;失败会明确报告,提交输入、中断或会话变化后到达的结果会被丢弃。 - 新增应用图标选择器,提供 25 款配色及浅色、深色背景预览。 -- 新增默认关闭的终端网络代理设置。Windows 下启用后,新建本地会话继承系统 HTTP/HTTPS 代理;现有会话保留原有环境。 +- 新增默认关闭的终端网络代理设置。Windows 下启用后,新建本地会话继承系统 HTTP/HTTPS 代理;现有会话保留原有环境。由 [@Sakyvo](https://github.com/Sakyvo) 在 [#94](https://github.com/Kuddev/pebrel/pull/94) 中贡献。 - SSH 主机新增独立的代理与跳板配置,支持单独保存代理凭据并预览连接路线。 - 为已捕获的 Claude Code 和 Codex 回答新增阅读视图,支持 Markdown、公式、原文模式和本地图片预览。 - 在英语和简体中文之外,界面语言新增繁体中文、法语、德语、西班牙语、巴西葡萄牙语、意大利语、俄语、日语及韩语。初版覆盖导航、常用操作、外观与网络控件,未翻译文案回退英文。 - 新增可跳转到匹配分区的设置搜索,以及需要确认的“恢复默认”操作;重置前备份偏好设置,并保留已保存主机等用户数据。 - 终端标签与侧栏新增 Antigravity 产品图标,支持常见启动别名,并在浅色和深色主题中保留图标原色。 -- 可识别的终端二选一确认提示可在通知中显示允许与拒绝操作;提示、会话或输入状态变化后会拒绝过期操作,每个捕获的请求只能处理一次。 +- 可识别的终端二选一确认提示现在会在通知中显示允许与拒绝操作。提示、会话或输入状态变化后会拒绝操作,每个捕获的请求只能处理一次。 #### 修复 -- 终端中 `Ctrl+C` 会复制并清除选区,没有选区时仍发送中断;文本输入框保留自身的复制行为。 -- `Ctrl+Backspace` 在支持的传统和 kitty 键盘路径中按词删除,并适用于受管理的 PowerShell 提示符。 +- 启用 AI 会话恢复后,WSL 与 Linux 中的 Codex 在 hook 未上报会话 ID 时,可从当前会话补全 ID,使重新打开工作区时接续该对话。关闭窗口和应用内“退出”操作会在保存前限定等待身份查询完成;不会选择存在歧义的身份,原生 Windows 与 macOS 继续使用 hook 上报的会话 ID。 +- 终端中 `Ctrl+C` 会复制并清除选区,没有选区时仍发送中断;文本输入框保留自身的复制行为。由 [@Sakyvo](https://github.com/Sakyvo) 在 [#93](https://github.com/Kuddev/pebrel/pull/93) 中贡献。 +- `Ctrl+Backspace` 在支持的传统和 kitty 键盘路径中按词删除,并适用于受管理的 PowerShell 提示符。由 [@Sakyvo](https://github.com/Sakyvo) 在 [#92](https://github.com/Kuddev/pebrel/pull/92) 中贡献。 - 其他窗口获得焦点后,运行中的标签指示器继续播放动画;窗口隐藏或最小化时停止请求动画帧。 - 文件名搜索丢弃旧目录的结果,在创建、重命名和删除后更新,并正确处理文件监听器返回的规范路径,保留用户看到的目录根。 - 多行确认文案按换行后的尺寸测量,使较长消息能完整放入共享确认框。 -- 改进 Antigravity 的活动与权限提示识别,避免历史转圈输出或普通正文中提到审批时,把空闲会话误标为运行中或等待授权。 -- 关闭最后一个普通窗口后再退出,不再用空快照覆盖已保存标签与 AI 会话身份;明确关闭全部标签后,下次仍从空工作区开始,保存失败会重试。 +- 修复 Antigravity 的活动与权限提示识别,避免历史转圈输出或普通正文中提到审批时,把空闲会话误标为运行中或等待授权。由 [@821869798](https://github.com/821869798) 在 [#96](https://github.com/Kuddev/pebrel/pull/96) 中贡献。 +- 关闭最后一个普通窗口后再退出,不再用空快照覆盖已保存标签;明确关闭全部标签后,下次仍从空工作区开始,保存失败会重试。 - SSH 启动现在等待 PTY 与 shell 确认后才显示就绪,连接准备期间可取消,停滞的启动阶段会在限定时间内结束;意外断连会保留 pane 与失败状态,明确退出 shell 时仍正常关闭。 - 复制 WSL 标签时保留当前来宾目录与所选发行版;复制 SSH 标签时保留已知远端工作目录,并在启动新远端 shell 时正确引用该路径。 - 修复 Windows 原生 SSH 会话在本地控制台启动时消耗远端应用首个设备属性应答的问题;该问题可能导致远端应用停留在空白画面等待应答。 @@ -76,6 +81,7 @@ Every release entry is provided in English and Simplified Chinese. - 读取输出的程序关闭管道后,Runtime CLI 响应与事件订阅会正常结束,不再弹出 panic 对话框;其他输出错误仍会报告。 #### 改进 +- 新安装包统一采用 `Pebrel-v<版本>-<系统>-<架构>` 命名;同时保留与正式安装器字节一致的旧 Nebula 文件名资产,供已安装的 Windows 客户端自动更新。 - 将钛银设为默认应用图标,并调整小尺寸比例、提示符笔画与透明边缘。 - 应用、命令、可执行文件、辅助程序、安装包与受管理的集成统一使用 Pebrel 名称。新建本地会话同时提供 `PEBREL_*` 环境变量和旧版兼容别名。 - Windows 安装器会把已注册的 `Nebula Terminal` 安装目录迁到同级 `Pebrel`,更新其快捷方式与 PATH 项,并保留自定义目录名称和未知文件;清理失败时会明确报告并支持重试。 @@ -91,7 +97,26 @@ Every release entry is provided in English and Simplified Chinese. - 命令完成、响铃和 Agent 待处理通知现在跟随来源 pane,即使标签已移动到另一个窗口;点击通知会聚焦来源 pane,一个 pane 的活动不再抑制另一个 pane 的通知。 ### Contributors -- [@Sakyvo](https://github.com/Sakyvo): terminal network proxy, conditional `Ctrl+C` copy, and word deletion with `Ctrl+Backspace` / 终端网络代理、`Ctrl+C` 按选区复制及 `Ctrl+Backspace` 按词删除。 + +@Sakyvo avatar +@821869798 avatar + +**[@Sakyvo](https://github.com/Sakyvo)** — Added the opt-in Windows system proxy for new local sessions, conditional `Ctrl+C` selection copy, and word deletion with `Ctrl+Backspace` / 新增可选的 Windows 系统代理注入、`Ctrl+C` 按选区复制及 `Ctrl+Backspace` 按词删除([#94](https://github.com/Kuddev/pebrel/pull/94)、[#93](https://github.com/Kuddev/pebrel/pull/93)、[#92](https://github.com/Kuddev/pebrel/pull/92))。 + +**[@821869798](https://github.com/821869798)** — Added negotiated `Shift+Enter` multiline input and improved Antigravity idle and permission-state detection / 新增协商键盘协议下的 `Shift+Enter` 多行输入,并改进 Antigravity 空闲与权限状态识别([#96](https://github.com/Kuddev/pebrel/pull/96))。 + +--- + +**SHA256** + +- `Pebrel-v1.6.0-windows-x64.zip`: `PENDING FINAL BUILD` +- `Pebrel-v1.6.0-windows-x64-setup.exe`: `PENDING FINAL BUILD` +- `NebulaTerminal-1.6.0-windows-x64-setup.exe`: `PENDING FINAL BUILD` +- `Pebrel-v1.6.0-linux-x64.AppImage`: `PENDING FINAL BUILD` +- `Pebrel-v1.6.0-linux-x64.deb`: `PENDING FINAL BUILD` +- `Pebrel-v1.6.0-linux-x64.tar.gz`: `PENDING FINAL BUILD` +- `Pebrel-v1.6.0-macos-arm64.dmg`: `PENDING FINAL BUILD` +- `Pebrel-v1.6.0-macos-x64.dmg`: `PENDING FINAL BUILD` ## 1.5.0 - 2026-09-01 diff --git a/docs/architecture-decisions.md b/docs/architecture-decisions.md index 2e0450f..e9e180e 100644 --- a/docs/architecture-decisions.md +++ b/docs/architecture-decisions.md @@ -102,6 +102,33 @@ unrelated feature's growth. Remote approval/enforcement is not implied by this l - **Revisit condition:** Remove compatibility readers only after support for old clients and persisted configurations is explicitly retired. +## ADR-0004 - Current conversation identity at workspace save + +- **Status:** Implemented for the maintainer-reported restore defect, 2026-09-07; + validation is part of the 1.6.0 release candidate checks. +- **Context:** A Codex pane can identify its foreground program without receiving + a session ID from the CLI hook. Saving only the program restores the tab but + cannot construct an exact conversation resume command. +- **Decision:** Keep hook identities authoritative. For WSL and Linux, match the + process environment to both the Pebrel instance and pane, then read only the + first metadata record of open Codex rollout files. Accept one main conversation + whose filename and metadata agree. Do not choose by working directory or time. + Native Windows and macOS keep the existing hook path. +- **Lifecycle:** Queries use the existing background executor, a two-second time + limit and bounded output. Results carry the foreground command generation. + Window close and the application Quit action allow up to three seconds before + saving and stopping panes; the UI thread remains responsive. A refreshed + inferred identity is required at close. Hook identities are not replaced. +- **Persistence:** Reuse the existing optional `source` and `session_id` fields; + there is no new file format or dependency. A missing or ambiguous ID cannot + launch a different conversation as a fallback. Existing autosave and the final + operating-system shutdown callback save the identities already available. +- **Validation:** File save/load and exact resume commands, primary-thread + metadata selection, WSL user arguments, pane/instance isolation, output limits, + stale-result rejection, and a subprocess deadline have focused regressions. +- **Revisit condition:** Replace the procfs fallback when a supported CLI session + identity API covers these launches consistently. + ## 中文说明 记录重大取舍而非每次小修复;事实与测试能推翻旧决定。规范误伤、安全修复与旧预算冲突时, diff --git a/docs/release-notes/v1.6.0.md b/docs/release-notes/v1.6.0.md index d1b3b52..5a6b95b 100644 --- a/docs/release-notes/v1.6.0.md +++ b/docs/release-notes/v1.6.0.md @@ -3,24 +3,26 @@ ## English ### Added -- Clipboard images can be pasted into local, WSL, and SSH terminal sessions as PNG file paths. WSL paths are translated and SSH images are uploaded before insertion; failures are visible, and late results are discarded after input submission, interruption, or a session change. +- Release downloads now include Windows x64 ZIP and installer packages, Linux x64 AppImage, DEB and tar.gz packages, and separate macOS 14+ DMGs for Apple Silicon and Intel. macOS packages are ad-hoc signed; the first launch may require Open Anyway in System Settings > Privacy & Security. +- Pasting a clipboard image now writes it as a PNG and inserts its path into local, WSL, or SSH terminal sessions. WSL paths are translated and SSH images are uploaded before insertion; failures are reported, and results arriving after submission, interruption, or a session change are discarded. - Added an application icon picker with 25 color palettes and light/dark previews. -- Added an opt-in terminal network proxy setting. On Windows, new local sessions inherit the system HTTP/HTTPS proxy when enabled; existing sessions keep their environment. +- Added an opt-in terminal network proxy setting. On Windows, new local sessions inherit the system HTTP/HTTPS proxy when enabled; existing sessions keep their environment. Contributed by [@Sakyvo](https://github.com/Sakyvo) in [#94](https://github.com/Kuddev/pebrel/pull/94). - Added per-host SSH proxy and jump-host options, with separate proxy credentials and a connection-route preview. - Added an answer reader for captured Claude Code and Codex responses, with Markdown, formulas, source-text mode, and local image previews. - Added UI language choices for Traditional Chinese, French, German, Spanish, Brazilian Portuguese, Italian, Russian, Japanese, and Korean alongside English and Simplified Chinese. Initial coverage includes navigation, common actions, appearance, and network controls; untranslated text falls back to English. - Added a settings search that opens the matching section and a confirmed Restore defaults action that backs up preferences while retaining saved hosts and other user data. - Added the Antigravity product icon to terminal tabs and the sidebar, including its common launcher aliases, while retaining its original colors in light and dark themes. -- Recognized binary terminal confirmations can show Allow and Deny actions in a notification. The action is rejected if the prompt, session, or input state has changed, and is available only once for the captured request. +- Recognized binary terminal confirmation prompts now appear in notifications with Allow and Deny actions. An action is rejected when the prompt, session, or input state has changed, and each captured request can be handled only once. ### Fixed -- `Ctrl+C` copies and clears a terminal selection, and still sends an interrupt when no selection is present. Text inputs keep their own copy behavior. -- `Ctrl+Backspace` deletes a word in the supported legacy and kitty keyboard paths, including the managed PowerShell prompt. +- With AI session restoration enabled, Codex sessions in WSL and Linux can recover the current conversation ID when no hook reported it, so reopening the workspace resumes that conversation. Window close and the application's Quit action allow a bounded wait for the identity before saving. Ambiguous identities are not selected; native Windows and macOS continue to use hook-reported session IDs. +- `Ctrl+C` copies and clears a terminal selection, and still sends an interrupt when no selection is present. Text inputs keep their own copy behavior. Contributed by [@Sakyvo](https://github.com/Sakyvo) in [#93](https://github.com/Kuddev/pebrel/pull/93). +- `Ctrl+Backspace` deletes a word in the supported legacy and kitty keyboard paths, including the managed PowerShell prompt. Contributed by [@Sakyvo](https://github.com/Sakyvo) in [#92](https://github.com/Kuddev/pebrel/pull/92). - Running tab indicators keep animating when another window has focus; hidden or minimized windows stop requesting animation frames. - File-name search discards results from an old root, updates after creates, renames and deletes, and handles canonical file-watcher paths without losing the visible directory root. - Multiline confirmation text is measured with wrapping so longer messages fit the shared confirmation dialog. -- Improved Antigravity activity and permission detection so old spinner output and ordinary prose mentioning approval do not keep an idle session marked as running or waiting for permission. -- Closing the last regular window and then quitting no longer replaces saved tabs and AI session identities with an empty snapshot. Closing all tabs explicitly still starts an empty workspace next time, and failed session writes are retried. +- Antigravity activity and permission detection no longer lets old spinner output or ordinary prose mentioning approval mark an idle session as running or waiting for permission. Contributed by [@821869798](https://github.com/821869798) in [#96](https://github.com/Kuddev/pebrel/pull/96). +- Closing the last regular window and then quitting no longer replaces saved tabs with an empty snapshot. Explicitly closing all tabs still starts an empty workspace next time, and failed session writes are retried. - SSH startup now waits for PTY and shell confirmation before showing Ready, supports cancellation during connection setup, and stops stalled startup stages within a bounded timeout. Unexpected disconnects retain the pane and its failure state; explicit shell exits still close normally. - Duplicated WSL tabs retain the current guest directory and selected distribution. Duplicated SSH tabs retain the known remote working directory and quote it when starting the new remote shell. - Fixed Windows native SSH sessions consuming a remote application's first device-attributes response during local console startup, which could leave the application waiting with a blank screen. @@ -28,6 +30,7 @@ - Runtime CLI responses and event subscriptions stop cleanly when the program reading their output closes its pipe, instead of opening a panic dialog. Other output errors are still reported. ### Improved +- New package names consistently use `Pebrel-v--`. A byte-identical installer with the legacy Nebula filename remains available for existing Windows auto-update clients. - Made Titanium the default application icon and adjusted small-size proportions, prompt strokes, and transparent edges. - Unified the application, command, executable, helper, package, and managed integration names under Pebrel. New local sessions expose `PEBREL_*` environment variables alongside legacy aliases. - The Windows installer migrates registered `Nebula Terminal` installation directories to a sibling `Pebrel` directory, updates its shortcuts and PATH entry, and preserves custom directory names and unknown files. Cleanup failures are reported and can be retried. @@ -45,24 +48,26 @@ ## 中文 ### 新增 -- 新增把剪贴板图片转换为 PNG 文件路径后粘贴到本地、WSL 与 SSH 终端会话;WSL 会转换路径,SSH 会先上传图片。失败会明确提示,提交输入、中断或会话变化后到达的结果会被丢弃。 +- 发布下载新增 Windows x64 ZIP 与安装器、Linux x64 AppImage、DEB 与 tar.gz,以及分别适用于 Apple Silicon 和 Intel 的 macOS 14+ DMG。macOS 包采用临时签名,首次启动可能需要在“系统设置 > 隐私与安全性”中选择“仍要打开”。 +- 粘贴剪贴板图片时会将其写成 PNG,并把路径插入本地、WSL 或 SSH 终端会话。WSL 会转换路径,SSH 会先上传图片;失败会明确报告,提交输入、中断或会话变化后到达的结果会被丢弃。 - 新增应用图标选择器,提供 25 款配色及浅色、深色背景预览。 -- 新增默认关闭的终端网络代理设置。Windows 下启用后,新建本地会话继承系统 HTTP/HTTPS 代理;现有会话保留原有环境。 +- 新增默认关闭的终端网络代理设置。Windows 下启用后,新建本地会话继承系统 HTTP/HTTPS 代理;现有会话保留原有环境。由 [@Sakyvo](https://github.com/Sakyvo) 在 [#94](https://github.com/Kuddev/pebrel/pull/94) 中贡献。 - SSH 主机新增独立的代理与跳板配置,支持单独保存代理凭据并预览连接路线。 - 为已捕获的 Claude Code 和 Codex 回答新增阅读视图,支持 Markdown、公式、原文模式和本地图片预览。 - 在英语和简体中文之外,界面语言新增繁体中文、法语、德语、西班牙语、巴西葡萄牙语、意大利语、俄语、日语及韩语。初版覆盖导航、常用操作、外观与网络控件,未翻译文案回退英文。 - 新增可跳转到匹配分区的设置搜索,以及需要确认的“恢复默认”操作;重置前备份偏好设置,并保留已保存主机等用户数据。 - 终端标签与侧栏新增 Antigravity 产品图标,支持常见启动别名,并在浅色和深色主题中保留图标原色。 -- 可识别的终端二选一确认提示可在通知中显示允许与拒绝操作;提示、会话或输入状态变化后会拒绝过期操作,每个捕获的请求只能处理一次。 +- 可识别的终端二选一确认提示现在会在通知中显示允许与拒绝操作。提示、会话或输入状态变化后会拒绝操作,每个捕获的请求只能处理一次。 ### 修复 -- 终端中 `Ctrl+C` 会复制并清除选区,没有选区时仍发送中断;文本输入框保留自身的复制行为。 -- `Ctrl+Backspace` 在支持的传统和 kitty 键盘路径中按词删除,并适用于受管理的 PowerShell 提示符。 +- 启用 AI 会话恢复后,WSL 与 Linux 中的 Codex 在 hook 未上报会话 ID 时,可从当前会话补全 ID,使重新打开工作区时接续该对话。关闭窗口和应用内“退出”操作会在保存前限定等待身份查询完成;不会选择存在歧义的身份,原生 Windows 与 macOS 继续使用 hook 上报的会话 ID。 +- 终端中 `Ctrl+C` 会复制并清除选区,没有选区时仍发送中断;文本输入框保留自身的复制行为。由 [@Sakyvo](https://github.com/Sakyvo) 在 [#93](https://github.com/Kuddev/pebrel/pull/93) 中贡献。 +- `Ctrl+Backspace` 在支持的传统和 kitty 键盘路径中按词删除,并适用于受管理的 PowerShell 提示符。由 [@Sakyvo](https://github.com/Sakyvo) 在 [#92](https://github.com/Kuddev/pebrel/pull/92) 中贡献。 - 其他窗口获得焦点后,运行中的标签指示器继续播放动画;窗口隐藏或最小化时停止请求动画帧。 - 文件名搜索丢弃旧目录的结果,在创建、重命名和删除后更新,并正确处理文件监听器返回的规范路径,保留用户看到的目录根。 - 多行确认文案按换行后的尺寸测量,使较长消息能完整放入共享确认框。 -- 改进 Antigravity 的活动与权限提示识别,避免历史转圈输出或普通正文中提到审批时,把空闲会话误标为运行中或等待授权。 -- 关闭最后一个普通窗口后再退出,不再用空快照覆盖已保存标签与 AI 会话身份;明确关闭全部标签后,下次仍从空工作区开始,保存失败会重试。 +- 修复 Antigravity 的活动与权限提示识别,避免历史转圈输出或普通正文中提到审批时,把空闲会话误标为运行中或等待授权。由 [@821869798](https://github.com/821869798) 在 [#96](https://github.com/Kuddev/pebrel/pull/96) 中贡献。 +- 关闭最后一个普通窗口后再退出,不再用空快照覆盖已保存标签;明确关闭全部标签后,下次仍从空工作区开始,保存失败会重试。 - SSH 启动现在等待 PTY 与 shell 确认后才显示就绪,连接准备期间可取消,停滞的启动阶段会在限定时间内结束;意外断连会保留 pane 与失败状态,明确退出 shell 时仍正常关闭。 - 复制 WSL 标签时保留当前来宾目录与所选发行版;复制 SSH 标签时保留已知远端工作目录,并在启动新远端 shell 时正确引用该路径。 - 修复 Windows 原生 SSH 会话在本地控制台启动时消耗远端应用首个设备属性应答的问题;该问题可能导致远端应用停留在空白画面等待应答。 @@ -70,6 +75,7 @@ - 读取输出的程序关闭管道后,Runtime CLI 响应与事件订阅会正常结束,不再弹出 panic 对话框;其他输出错误仍会报告。 ### 改进 +- 新安装包统一采用 `Pebrel-v<版本>-<系统>-<架构>` 命名;同时保留与正式安装器字节一致的旧 Nebula 文件名资产,供已安装的 Windows 客户端自动更新。 - 将钛银设为默认应用图标,并调整小尺寸比例、提示符笔画与透明边缘。 - 应用、命令、可执行文件、辅助程序、安装包与受管理的集成统一使用 Pebrel 名称。新建本地会话同时提供 `PEBREL_*` 环境变量和旧版兼容别名。 - Windows 安装器会把已注册的 `Nebula Terminal` 安装目录迁到同级 `Pebrel`,更新其快捷方式与 PATH 项,并保留自定义目录名称和未知文件;清理失败时会明确报告并支持重试。 @@ -85,11 +91,23 @@ - 命令完成、响铃和 Agent 待处理通知现在跟随来源 pane,即使标签已移动到另一个窗口;点击通知会聚焦来源 pane,一个 pane 的活动不再抑制另一个 pane 的通知。 ## Contributors -- [@Sakyvo](https://github.com/Sakyvo): terminal network proxy, conditional `Ctrl+C` copy, and word deletion with `Ctrl+Backspace` / 终端网络代理、`Ctrl+C` 按选区复制及 `Ctrl+Backspace` 按词删除。 -## SHA256 +@Sakyvo avatar +@821869798 avatar -```text -5ADE2DB18BFCAB653240AE7B93D507587087C31D1FC684B331121426AE23915B Pebrel-v1.6.0-windows-x64.zip -400672F52E5D37965983C37293E07286B52BDD74F8D8462432349C8C646EE2CA Pebrel-1.6.0-windows-x64-setup.exe -``` +**[@Sakyvo](https://github.com/Sakyvo)** — Added the opt-in Windows system proxy for new local sessions, conditional `Ctrl+C` selection copy, and word deletion with `Ctrl+Backspace` / 新增可选的 Windows 系统代理注入、`Ctrl+C` 按选区复制及 `Ctrl+Backspace` 按词删除([#94](https://github.com/Kuddev/pebrel/pull/94)、[#93](https://github.com/Kuddev/pebrel/pull/93)、[#92](https://github.com/Kuddev/pebrel/pull/92))。 + +**[@821869798](https://github.com/821869798)** — Added negotiated `Shift+Enter` multiline input and improved Antigravity idle and permission-state detection / 新增协商键盘协议下的 `Shift+Enter` 多行输入,并改进 Antigravity 空闲与权限状态识别([#96](https://github.com/Kuddev/pebrel/pull/96))。 + +--- + +**SHA256** + +- `Pebrel-v1.6.0-windows-x64.zip`: `PENDING FINAL BUILD` +- `Pebrel-v1.6.0-windows-x64-setup.exe`: `PENDING FINAL BUILD` +- `NebulaTerminal-1.6.0-windows-x64-setup.exe`: `PENDING FINAL BUILD` +- `Pebrel-v1.6.0-linux-x64.AppImage`: `PENDING FINAL BUILD` +- `Pebrel-v1.6.0-linux-x64.deb`: `PENDING FINAL BUILD` +- `Pebrel-v1.6.0-linux-x64.tar.gz`: `PENDING FINAL BUILD` +- `Pebrel-v1.6.0-macos-arm64.dmg`: `PENDING FINAL BUILD` +- `Pebrel-v1.6.0-macos-x64.dmg`: `PENDING FINAL BUILD` diff --git a/nebula_app/src/agent_env.rs b/nebula_app/src/agent_env.rs index 86a545a..0fe8039 100644 --- a/nebula_app/src/agent_env.rs +++ b/nebula_app/src/agent_env.rs @@ -50,6 +50,10 @@ const LEGACY_CLI_ENV: &str = "NEBULA_CLI"; pub const BIN_DIR_ENV: &str = "PEBREL_BIN_DIR"; const LEGACY_BIN_DIR_ENV: &str = "NEBULA_BIN_DIR"; +/// 当前 Pebrel 进程的 PID。和 pane id 一起构成进程归属,避免两个同时运行的 +/// Pebrel 实例各自的 pane 1 被身份探测混在一起;数字值可原样穿过 WSL。 +pub const PROCESS_ENV: &str = "PEBREL_PROCESS_ID"; + const TERM_PROGRAM_ENV: &str = "TERM_PROGRAM"; const TERM_PROGRAM_VERSION_ENV: &str = "TERM_PROGRAM_VERSION"; const PATH_ENV: &str = "PATH"; @@ -71,6 +75,7 @@ pub fn apply(env: &mut HashMap, pane_id: impl Display) { let pane_id = pane_id.to_string(); insert_env(env, PANE_ENV, pane_id.clone()); insert_env(env, crate::ai_hook::LEGACY_PANE_ENV, pane_id); + insert_env(env, PROCESS_ENV, std::process::id().to_string()); insert_env(env, TERM_PROGRAM_ENV, TERM_PROGRAM.to_owned()); insert_env(env, TERM_PROGRAM_VERSION_ENV, env!("VERSION").to_owned()); @@ -166,6 +171,7 @@ const WSLENV_ENTRIES: &[&str] = &[ TERM_PROGRAM_VERSION_ENV, "PEBREL_CLI/p", "PEBREL_BIN_DIR/p", + PROCESS_ENV, "NEBULA_CLI/p", "NEBULA_BIN_DIR/p", ]; @@ -219,6 +225,8 @@ mod tests { let env = applied(17); assert_eq!(env.get(PANE_ENV).map(String::as_str), Some("17")); assert_eq!(env.get(crate::ai_hook::LEGACY_PANE_ENV), env.get(PANE_ENV)); + let process_id = std::process::id().to_string(); + assert_eq!(env.get(PROCESS_ENV).map(String::as_str), Some(process_id.as_str())); assert_eq!(env.get(LEGACY_CLI_ENV), env.get(CLI_ENV)); assert_eq!(env.get(LEGACY_BIN_DIR_ENV), env.get(BIN_DIR_ENV)); assert_eq!(env.get("TERM_PROGRAM").map(String::as_str), Some(TERM_PROGRAM)); @@ -299,6 +307,7 @@ mod tests { let names: Vec<&str> = WSLENV_ENTRIES.iter().copied().map(variable_name).collect(); 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:?}"); } #[cfg(windows)] diff --git a/nebula_app/src/gpui_shell/terminal/view.rs b/nebula_app/src/gpui_shell/terminal/view.rs index 652ba43..5fc7b6a 100644 --- a/nebula_app/src/gpui_shell/terminal/view.rs +++ b/nebula_app/src/gpui_shell/terminal/view.rs @@ -354,6 +354,13 @@ pub struct TerminalView { /// Hook-reported identity for exact resume/fork. Process/title inference is /// never accepted here because a wrong id would continue the wrong chat. pub ai_session: Option, + /// A hook is not guaranteed to be present when a pane starts. The Codex + /// rollout probe runs in the background and uses this generation to reject + /// a result from a previous foreground command. + pub(super) ai_session_probe_pending: bool, + pub(super) ai_session_from_probe: bool, + pub(super) ai_session_probe_epoch: u64, + pub(super) last_ai_session_probe: Option, error: Option, exited: Option, /// 滚动条拖拽中:按下时记下的「指针在拇指内的 y 偏移」,拖动全程据此 @@ -843,6 +850,10 @@ impl TerminalView { ssh_connect: None, ssh_connect_last_step: std::time::Instant::now(), ai_session: None, + ai_session_probe_pending: false, + ai_session_from_probe: false, + ai_session_probe_epoch: 0, + last_ai_session_probe: None, error, exited: None, scrollbar_drag: None, @@ -1005,47 +1016,7 @@ impl TerminalView { Err(error) => log::warn!("terminal image dropped: {error}"), } }, - TermEvent::CommandStart => { - self.answers.begin_command(); - // 程序身份来自 Enter 时捕获的完整命令行;直接启动和 - // npx/node/uvx 等包装启动都会归一成 Agent slug。它是 WSL - // 看不见来宾进程时的主通道,hook 信封仍是更准的覆盖层。 - let identity = - crate::ai_agents::AgentKind::parse_command(&self.suggest.last_committed) - .map(|agent| agent.slug().to_owned()) - .or_else(|| crate::display::extract_program(&self.suggest.last_committed)); - if identity != self.running_program { - self.running_program = identity; - cx.emit(TerminalViewEvent::TitleChanged); - } - if self - .running_program - .as_deref() - .and_then(crate::ai_agents::AgentKind::parse) - .is_some() - && !self.agent_hook_seen - { - self.agent_status_source = crate::ai_agents::AgentStatusSource::Process; - self.agent_status_rule = None; - } - self.mark_command_running(); - // 首个词就是一个交互式 shell(`cmd`、`wsl`、裸 `bash`):133;C - // 是真的,但这条「命令」其实是一个新提示符,133;D 永远不会来 - // ——那个 shell 接管了终端,而我们的集成不在它里面。立刻按「已被 - // 进程树反证」处理,转圈不必等 3 秒节流窗口。 - // - // 这不是把状态钉死:后续对账双向纠正,真在那个 shell 里跑起活儿 - // 会多出一个子进程,进程树看得见,状态会被拉回运行中。 - if crate::process_tree::is_interactive_shell_command(&self.suggest.last_committed) { - self.command_running_disproved = true; - } - if let Some(run) = &mut self.active_run - && run.phase == crate::runtime_api::RuntimeRunPhase::Submitted - { - run.phase = crate::runtime_api::RuntimeRunPhase::Started; - } - cx.notify(); - }, + TermEvent::CommandStart => self.on_command_start(cx), // 退出码接到「上一条命令失败」的未读徽章上(⚠ 三角);`Some(0)` 与 // `None`(没带退出码的 133;D)都算过关。记在下面那道 barrier 之后: // 属于上一轮/初始化的那个边沿不能改写徽章。 diff --git a/nebula_app/src/gpui_shell/terminal/view/runtime.rs b/nebula_app/src/gpui_shell/terminal/view/runtime.rs index 74277da..49428f3 100644 --- a/nebula_app/src/gpui_shell/terminal/view/runtime.rs +++ b/nebula_app/src/gpui_shell/terminal/view/runtime.rs @@ -27,6 +27,55 @@ fn progress_sidebar_activity( } impl TerminalView { + pub(super) fn on_command_start(&mut self, cx: &mut Context) { + self.answers.begin_command(); + // 程序身份来自 Enter 时捕获的完整命令行;直接启动和 + // npx/node/uvx 等包装启动都会归一成 Agent slug。它是 WSL + // 看不见来宾进程时的主通道,hook 信封仍是更准的覆盖层。 + let identity = crate::ai_agents::AgentKind::parse_command(&self.suggest.last_committed) + .map(|agent| agent.slug().to_owned()) + .or_else(|| crate::display::extract_program(&self.suggest.last_committed)); + // A probe belongs to one foreground command. Invalidate it + // before replacing the command identity so a slow WSL result + // from Codex A cannot become the identity of Codex B. + self.invalidate_ai_session_probe(); + if identity != self.running_program { + self.running_program = identity; + self.ai_session = None; + cx.emit(TerminalViewEvent::TitleChanged); + } + if self.running_program.as_deref().and_then(crate::ai_agents::AgentKind::parse).is_some() + && !self.agent_hook_seen + { + self.agent_status_source = crate::ai_agents::AgentStatusSource::Process; + self.agent_status_rule = None; + } + self.mark_command_running(); + self.probe_missing_codex_session(cx); + // 首个词就是一个交互式 shell(`cmd`、`wsl`、裸 `bash`):133;C + // 是真的,但这条「命令」其实是一个新提示符,133;D 永远不会来 + // ——那个 shell 接管了终端,而我们的集成不在它里面。立刻按「已被 + // 进程树反证」处理,转圈不必等 3 秒节流窗口。 + // + // 这不是把状态钉死:后续对账双向纠正,真在那个 shell 里跑起活儿 + // 会多出一个子进程,进程树看得见,状态会被拉回运行中。 + if crate::process_tree::is_interactive_shell_command(&self.suggest.last_committed) { + self.command_running_disproved = true; + } + if let Some(run) = &mut self.active_run + && run.phase == crate::runtime_api::RuntimeRunPhase::Submitted + { + run.phase = crate::runtime_api::RuntimeRunPhase::Started; + } + cx.notify(); + } + + pub(super) fn invalidate_ai_session_probe(&mut self) { + self.ai_session_probe_epoch = self.ai_session_probe_epoch.wrapping_add(1); + self.ai_session_probe_pending = false; + self.last_ai_session_probe = None; + } + pub(super) fn apply_ssh_stage( &mut self, stage: crate::ssh_session::SshStage, @@ -59,7 +108,9 @@ impl TerminalView { self.answers.close(); let title_changed = self.running_program.take().is_some() || self.ai_session.take().is_some(); + self.invalidate_ai_session_probe(); self.primary_agent_pid = None; + self.ai_session_from_probe = false; self.agent_status = crate::ai_agents::AgentStatus::Unknown; self.agent_status_source = crate::ai_agents::AgentStatusSource::Unknown; self.agent_status_rule = None; @@ -626,6 +677,78 @@ impl TerminalView { cx.notify(); } + pub(crate) fn prepare_ai_session_save(&mut self, cx: &mut Context) { + if self.ai_session_from_probe { + self.ai_session = None; + } + self.last_ai_session_probe = None; + self.probe_missing_codex_session(cx); + } + + pub(crate) fn ai_session_save_pending(&self) -> bool { + self.ai_session_probe_pending + } + + /// Read the active conversation metadata when its hook has not reported an ID. + /// File and process operations run on the background executor. Only results + /// for the same foreground command are applied; hook identities take priority. + pub(super) fn probe_missing_codex_session(&mut self, cx: &mut Context) { + if self.exited.is_some() + || (self.agent_hook_seen && !self.ai_session_from_probe && self.ai_session.is_some()) + || self.ai_session_probe_pending + || !self + .running_program + .as_deref() + .and_then(crate::ai_agents::AgentKind::parse) + .is_some_and(|agent| agent == crate::ai_agents::AgentKind::Codex) + { + return; + } + if self.last_ai_session_probe.is_some_and(|at| { + at.elapsed() + < std::time::Duration::from_secs(if self.ai_session.is_some() { 10 } else { 2 }) + }) { + return; + } + let Some(exec_context) = self.exec_context.clone() else { return }; + let epoch = self.ai_session_probe_epoch; + let pane_id = self.pane_id; + self.ai_session_probe_pending = true; + self.last_ai_session_probe = Some(std::time::Instant::now()); + let work = cx.background_executor().spawn(async move { + crate::platform::ai_session_identity::probe_codex_session(pane_id, Some(&exec_context)) + }); + cx.spawn(async move |this, cx| { + let session_id = work.await; + let _ = this.update(cx, |view, cx| { + if view.ai_session_probe_epoch == epoch { + view.ai_session_probe_pending = false; + } + if !probe_result_is_current( + view.ai_session_probe_epoch, + epoch, + view.agent_hook_seen + && !view.ai_session_from_probe + && view.ai_session.is_some(), + view.running_program.as_deref(), + ) { + return; + } + if let Some(session_id) = session_id { + log::debug!("agent session identity from active rollout: pane={pane_id}"); + view.ai_session = Some(crate::display::AiSessionIdentity { + source: "codex".to_owned(), + session_id, + }); + view.ai_session_from_probe = true; + cx.emit(TerminalViewEvent::TitleChanged); + cx.notify(); + } + }); + }) + .detach(); + } + /// Queue a full shell command. Like cold resume, input may arrive before /// the first prompt; ConPTY preserves ordering until the shell reads it. pub fn run_command(&mut self, command: String, cx: &mut Context) { @@ -764,6 +887,7 @@ impl TerminalView { self.agent_runtime_submit_pending = false; } if from_primary_agent && let Some(id) = event.session_id.as_deref() { + self.ai_session_from_probe = false; self.ai_session = Some(crate::display::AiSessionIdentity { source: event.source.clone(), session_id: id.to_owned(), @@ -861,6 +985,7 @@ impl TerminalView { // 进程树对账必须排在下面那道早退之前:身份认不出来就早退,等于让 // 这个 pane 永远退出屏幕检测。 self.reconcile_shell_activity(cx); + self.probe_missing_codex_session(cx); let Some(session) = &self.session else { return }; let (prompt_restored, screen) = { let term = session.term.lock(); @@ -1129,6 +1254,19 @@ impl TerminalView { } } +fn probe_result_is_current( + current_epoch: u64, + result_epoch: u64, + has_session: bool, + running_program: Option<&str>, +) -> bool { + current_epoch == result_epoch + && !has_session + && running_program + .and_then(crate::ai_agents::AgentKind::parse) + .is_some_and(|agent| agent == crate::ai_agents::AgentKind::Codex) +} + /// 一次写入是谁发起的。安全判定不看写了什么,看谁让写的。 #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum InputOrigin { @@ -1166,8 +1304,8 @@ fn runtime_chat_agent_kind( #[cfg(test)] mod tests { use super::{ - InputOrigin, SidebarActivity, local_context_refusal, progress_sidebar_activity, - runtime_chat_agent_kind, + InputOrigin, SidebarActivity, local_context_refusal, probe_result_is_current, + progress_sidebar_activity, runtime_chat_agent_kind, }; #[test] @@ -1215,6 +1353,14 @@ mod tests { ); } + #[test] + fn a_probe_from_an_older_command_cannot_claim_a_new_codex_process() { + assert!(!probe_result_is_current(8, 7, false, Some("codex"))); + assert!(!probe_result_is_current(8, 8, true, Some("codex"))); + assert!(!probe_result_is_current(8, 8, false, Some("claude"))); + assert!(probe_result_is_current(8, 8, false, Some("codex"))); + } + /// 强制层的判据:本地内容不得由程序自动送进远端 pane,用户当场选中则放行。 #[test] fn program_writes_never_carry_local_context_to_a_remote_pane() { diff --git a/nebula_app/src/gpui_shell/workspace.rs b/nebula_app/src/gpui_shell/workspace.rs index 7395505..13710fd 100644 --- a/nebula_app/src/gpui_shell/workspace.rs +++ b/nebula_app/src/gpui_shell/workspace.rs @@ -46,6 +46,7 @@ use gpui_component::notification::Notification; use nebula_split::{DIVIDER_GAP, HIT_SLOP, RemoveOutcome, SplitDirection, SplitNav, SplitTree}; mod agents; +mod closing; mod command_manager; mod file_tree; mod key_actions; @@ -1103,6 +1104,7 @@ pub struct NebulaWorkspace { spinner_visible: std::cell::Cell, /// 系统关闭按钮可能连续送来多次 should-close;确认框在场时只保留一份。 window_close_confirm_open: bool, + window_close_pending: bool, /// `keep_session` 关窗后 HWND 已隐藏、PTY 仍在;托盘 / mux ATTACH 用来捞回。 window_hidden: bool, /// 开窗时记下,mux `tab.new` 需要从 pump 拿到 `&mut Window`。 @@ -1335,6 +1337,7 @@ impl NebulaWorkspace { spinner_frame_pending: std::cell::Cell::new(false), spinner_visible: std::cell::Cell::new(false), window_close_confirm_open: false, + window_close_pending: false, window_hidden: false, window_handle: window.window_handle(), runtime_window_id, @@ -1998,61 +2001,6 @@ impl NebulaWorkspace { ); } - /// GPUI 的 should-close 回调必须同步返回:无繁忙进程时直接允许系统关闭; - /// 有繁忙进程时先返回 false,再由对话框确认回调显式移除窗口。 - fn should_close_window(&mut self, window: &mut Window, cx: &mut Context) -> bool { - let persist_session = self.window_role == windowing::WindowRole::Regular; - if persist_session && self.keep_session_on_close(window, cx) { - return false; - } - let Some(process) = self.busy_process_in_window(cx) else { - if persist_session { - self.save_clean_window_session(cx); - } - return true; - }; - if self.window_close_confirm_open { - return false; - } - self.window_close_confirm_open = true; - - let body: SharedString = format!("{process} 仍在运行,关闭窗口会中止它。").into(); - let confirm_workspace = cx.entity().downgrade(); - let close_workspace = confirm_workspace.clone(); - window.open_dialog(cx, move |dialog, window, _cx| { - let confirm_workspace = confirm_workspace.clone(); - let close_workspace = close_workspace.clone(); - confirm_dialog( - dialog, - window, - "关闭窗口?", - body.clone(), - "关闭", - "取消", - ButtonVariant::Danger, - ) - .on_ok(move |_, window, cx| { - let _ = confirm_workspace.update(cx, |workspace, cx| { - if persist_session { - workspace.save_clean_window_session(cx); - } - workspace.window_close_confirm_open = false; - // `remove_window` 是确认后的最终动作,不会重新触发 - // should-close,从而避免再次弹出同一确认框。 - window.remove_window(); - }); - true - }) - .on_close(move |_, _, cx| { - let _ = close_workspace.update(cx, |workspace, cx| { - workspace.window_close_confirm_open = false; - cx.notify(); - }); - }) - }); - false - } - fn request_close_pane( &mut self, tab_ix: usize, diff --git a/nebula_app/src/gpui_shell/workspace/closing.rs b/nebula_app/src/gpui_shell/workspace/closing.rs new file mode 100644 index 0000000..57ff31c --- /dev/null +++ b/nebula_app/src/gpui_shell/workspace/closing.rs @@ -0,0 +1,116 @@ +use super::*; + +impl NebulaWorkspace { + /// GPUI 的 should-close 回调必须同步返回:无繁忙进程时直接允许系统关闭; + /// 有繁忙进程时先返回 false,再由对话框确认回调显式移除窗口。 + pub(super) fn should_close_window( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> bool { + if self.window_close_pending { + return false; + } + let persist_session = self.window_role == windowing::WindowRole::Regular; + if persist_session && self.keep_session_on_close(window, cx) { + return false; + } + let Some(process) = self.busy_process_in_window(cx) else { + if persist_session { + self.finish_close_window(window, cx); + return false; + } + return true; + }; + if self.window_close_confirm_open { + return false; + } + self.window_close_confirm_open = true; + + let body: SharedString = format!("{process} 仍在运行,关闭窗口会中止它。").into(); + let confirm_workspace = cx.entity().downgrade(); + let close_workspace = confirm_workspace.clone(); + window.open_dialog(cx, move |dialog, window, _cx| { + let confirm_workspace = confirm_workspace.clone(); + let close_workspace = close_workspace.clone(); + confirm_dialog( + dialog, + window, + "关闭窗口?", + body.clone(), + "关闭", + "取消", + ButtonVariant::Danger, + ) + .on_ok(move |_, window, cx| { + let _ = confirm_workspace.update(cx, |workspace, cx| { + if persist_session { + workspace.finish_close_window(window, cx); + } + workspace.window_close_confirm_open = false; + // `remove_window` 是确认后的最终动作,不会重新触发 + // should-close,从而避免再次弹出同一确认框。 + if !persist_session { + window.remove_window(); + } + }); + true + }) + .on_close(move |_, _, cx| { + let _ = close_workspace.update(cx, |workspace, cx| { + workspace.window_close_confirm_open = false; + cx.notify(); + }); + }) + }); + false + } + + fn finish_close_window(&mut self, _window: &mut Window, cx: &mut Context) { + self.window_close_pending = true; + let panes = self.prepare_session_save(cx); + let handle = self.window_handle; + cx.spawn(async move |this, cx| { + wait_for_session_ids(&panes, cx).await; + let _ = handle.update(cx, |_, window, cx| { + let _ = this.update(cx, |workspace, cx| { + workspace.save_clean_window_session(cx); + window.remove_window(); + }); + }); + }) + .detach(); + } + + pub(super) fn prepare_session_save( + &mut self, + cx: &mut Context, + ) -> Vec> { + let panes = self + .tabs + .iter() + .filter_map(|tab| match tab { + WorkspaceTab::Terminal { panes, .. } => Some(panes), + _ => None, + }) + .flatten() + .map(|pane| pane.view.clone()) + .collect::>(); + for pane in &panes { + pane.update(cx, |view, cx| view.prepare_ai_session_save(cx)); + } + panes + } +} + +pub(super) async fn wait_for_session_ids(panes: &[Entity], cx: &mut gpui::AsyncApp) { + let deadline = std::time::Instant::now() + Duration::from_secs(3); + loop { + let pending = + cx.update(|cx| panes.iter().any(|pane| pane.read(cx).ai_session_save_pending())); + if !pending || std::time::Instant::now() >= deadline { + break; + } + cx.background_executor().timer(Duration::from_millis(25)).await; + } +} diff --git a/nebula_app/src/gpui_shell/workspace/windowing.rs b/nebula_app/src/gpui_shell/workspace/windowing.rs index 911511c..9b78399 100644 --- a/nebula_app/src/gpui_shell/workspace/windowing.rs +++ b/nebula_app/src/gpui_shell/workspace/windowing.rs @@ -119,6 +119,7 @@ pub(crate) struct WindowRegistry { entries: Vec, runtime_hub: crate::runtime_api::RuntimeHub, session_persistence: SessionPersistence, + quit_pending: bool, #[cfg(windows)] quick_terminal: Option, _subscriptions: Vec, @@ -192,6 +193,7 @@ pub(crate) fn initialize(cx: &mut App, runtime_hub: crate::runtime_api::RuntimeH entries: Vec::new(), runtime_hub, session_persistence: SessionPersistence::default(), + quit_pending: false, #[cfg(windows)] quick_terminal: None, _subscriptions: Vec::new(), @@ -1404,6 +1406,27 @@ pub(super) fn save_current_window_session( } pub(crate) fn quit_all(cx: &mut App) { + if cx.global::().quit_pending { + return; + } + cx.global_mut::().quit_pending = true; + let entries = cx.global::().entries.clone(); + let panes = entries + .iter() + .filter(|entry| entry.role == WindowRole::Regular) + .filter_map(|entry| { + entry.workspace.update(cx, |workspace, cx| workspace.prepare_session_save(cx)).ok() + }) + .flatten() + .collect::>(); + cx.spawn(async move |cx| { + super::closing::wait_for_session_ids(&panes, cx).await; + cx.update(finish_quit_all); + }) + .detach(); +} + +fn finish_quit_all(cx: &mut App) { save_combined_session(cx, true); prune_entries(cx); let entries = cx.global::().entries.clone(); diff --git a/nebula_app/src/platform/ai_session_identity.rs b/nebula_app/src/platform/ai_session_identity.rs new file mode 100644 index 0000000..31fc9e7 --- /dev/null +++ b/nebula_app/src/platform/ai_session_identity.rs @@ -0,0 +1,469 @@ +//! Find the current Codex conversation when no session ID was reported by its hook. +//! +//! Codex keeps the active rollout open, and its process environment carries +//! the pane ID supplied by the PTY launcher. This links a pane to its rollout +//! without relying on working-directory matches or file modification +//! time. Only the first metadata record is read, without conversation content. +//! The lookup is bounded and is only called by a background +//! task; UI code must never scan `/proc` or start `wsl.exe` synchronously. + +use std::io::{self, Read, Seek as _}; +use std::path::Path; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +const PROBE_TIMEOUT: Duration = Duration::from_secs(2); +const MAX_PROBE_OUTPUT: usize = 64 * 1024; +const MAX_PROBE_LINE: usize = 64 * 1024; +const MAX_PROBE_RECORDS: usize = 128; + +const PROBE_SCRIPT: &str = r###" +pane="$1" +instance="$2" +for proc in /proc/[0-9]*; do + pid=${proc##*/} + IFS= read -r comm < "$proc/comm" 2>/dev/null || continue + case "$comm" in + codex|codex.exe|codex-*) ;; + *) continue ;; + esac + [ -r "$proc/environ" ] || continue + environment=$(tr "\0" "\n" < "$proc/environ" 2>/dev/null || true) + pane_id=$(printf '%s\n' "$environment" | sed -n "s/^PEBREL_PANE_ID=//p" | head -n 1) + if [ "$pane_id" != "$pane" ]; then + pane_id=$(printf '%s\n' "$environment" | sed -n "s/^NEBULA_PANE_ID=//p" | head -n 1) + fi + process_id=$(printf '%s\n' "$environment" | sed -n "s/^PEBREL_PROCESS_ID=//p" | head -n 1) + [ "$pane_id" = "$pane" ] && [ "$process_id" = "$instance" ] || continue + codex_root=$(printf '%s\n' "$environment" | sed -n "s/^CODEX_HOME=//p" | head -n 1) + if [ -z "$codex_root" ]; then + guest_home=$(printf '%s\n' "$environment" | sed -n "s/^HOME=//p" | head -n 1) + [ -n "$guest_home" ] || continue + codex_root="$guest_home/.codex" + fi + for fd in "$proc"/fd/*; do + link=$(readlink "$fd" 2>/dev/null || true) + case "$link" in + "$codex_root"/sessions/*/rollout-*.jsonl) + first=$(head -c 65536 "$fd" 2>/dev/null | head -n 1) + [ -n "$first" ] || continue + printf '%s\t%s\t%s\t%s\n' "$pid" "${fd##*/}" "$link" "$first" + ;; + esac + done +done +"###; + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ProbeRecord { + _pid: u32, + _fd: u32, + link: String, + first_line: String, +} + +/// Probe the active Codex rollout for one pane. +/// +/// WSL panes are inspected inside the guest because Windows Toolhelp cannot +/// see Linux processes or their open session files. Linux hosts use the same +/// process metadata directly. Native Windows and macOS sessions continue to +/// use hook-reported identities; this fallback requires Linux procfs. +pub(crate) fn probe_codex_session( + pane_id: u64, + exec_context: Option<&crate::runtime_exec::PaneExecContext>, +) -> Option { + let pane_id = pane_id.to_string(); + let context = exec_context?; + let instance = context.process_instance()?; + if let Some(distro) = context.wsl_distribution() { + return probe_wsl(distro, context.wsl_user(), &pane_id, instance); + } + + #[cfg(unix)] + { + return probe_local_proc(&pane_id, instance); + } + #[cfg(not(unix))] + { + let _ = (pane_id, instance); + None + } +} + +fn parse_probe_records(output: &str) -> Option { + let mut candidate = None; + for record in output.lines().filter_map(parse_probe_record) { + let Ok(meta) = serde_json::from_str::(&record.first_line) else { + continue; + }; + if meta.get("type").and_then(serde_json::Value::as_str) != Some("session_meta") { + continue; + } + let payload = meta.get("payload").unwrap_or(&meta); + let Some(id) = payload.get("id").and_then(serde_json::Value::as_str) else { + continue; + }; + if !valid_uuid(id) { + continue; + } + // Codex's main interactive thread is marked as a CLI/user thread. + // Keep accepting older session_meta lines that lack these fields, but + // reject an explicit guardian/sub-agent marker. + if !matches_optional_string(payload, &meta, "source", "cli") + || !matches_optional_string(payload, &meta, "thread_source", "user") + { + continue; + } + let Some(filename_id) = rollout_id(Path::new(&record.link)) else { continue }; + if filename_id != id { + continue; + } + if candidate.as_ref().is_some_and(|existing| existing != id) { + // More than one distinct main/user rollout is ambiguous. A + // process can have guardian/sub-agent files open, but it must not + // have two possible user conversations silently choose one. + return None; + } + candidate = Some(id.to_owned()); + } + candidate +} + +fn parse_probe_record(line: &str) -> Option { + let mut fields = line.splitn(4, '\t'); + Some(ProbeRecord { + _pid: fields.next()?.parse().ok()?, + _fd: fields.next()?.parse().ok()?, + link: fields.next()?.to_owned(), + first_line: fields.next()?.to_owned(), + }) +} + +fn matches_optional_string( + payload: &serde_json::Value, + root: &serde_json::Value, + key: &str, + expected: &str, +) -> bool { + payload + .get(key) + .or_else(|| root.get(key)) + .map_or(true, |value| value.as_str() == Some(expected)) +} + +fn valid_uuid(id: &str) -> bool { + id.len() == 36 + && id.as_bytes().iter().enumerate().all(|(index, byte)| { + if matches!(index, 8 | 13 | 18 | 23) { *byte == b'-' } else { byte.is_ascii_hexdigit() } + }) +} + +fn rollout_id(path: &Path) -> Option { + let stem = path.file_stem()?.to_str()?; + let start = stem.len().checked_sub(36)?; + let id = stem.get(start..)?; + valid_uuid(id).then(|| id.to_owned()) +} + +fn probe_wsl( + distro: Option<&str>, + user: Option<&str>, + pane_id: &str, + instance: &str, +) -> Option { + let mut command = Command::new("wsl.exe"); + command.args(wsl_probe_args(distro, user, pane_id, instance)); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt as _; + command.creation_flags(0x0800_0000); // CREATE_NO_WINDOW + } + run_probe_command(command) +} + +fn wsl_probe_args( + distro: Option<&str>, + user: Option<&str>, + pane_id: &str, + instance: &str, +) -> Vec { + let mut args = Vec::with_capacity(9); + if let Some(distro) = distro { + args.extend(["--distribution".to_owned(), distro.to_owned()]); + } + if let Some(user) = user { + args.extend(["--user".to_owned(), user.to_owned()]); + } + args.extend([ + "--exec".to_owned(), + "sh".to_owned(), + "-c".to_owned(), + PROBE_SCRIPT.to_owned(), + "pebrel-ai-session-probe".to_owned(), + pane_id.to_owned(), + instance.to_owned(), + ]); + args +} + +/// Bound the guest lookup by time and output size. A temporary file avoids a +/// reader thread surviving when a descendant keeps the output handle open. +fn run_probe_command(mut command: Command) -> Option { + let mut output = tempfile::tempfile().ok()?; + command.stdin(Stdio::null()).stdout(output.try_clone().ok()?).stderr(Stdio::null()); + let mut child = command.spawn().ok()?; + let deadline = Instant::now() + PROBE_TIMEOUT; + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) + if Instant::now() < deadline + && output + .metadata() + .is_ok_and(|meta| meta.len() <= MAX_PROBE_OUTPUT as u64) => + { + std::thread::sleep(Duration::from_millis(10)); + }, + Ok(None) => { + let _ = child.kill(); + let _ = child.wait(); + return None; + }, + Err(_) => { + let _ = child.kill(); + let _ = child.wait(); + return None; + }, + } + }; + output.rewind().ok()?; + let output = read_bounded(output, MAX_PROBE_OUTPUT).ok()?; + status.success().then(|| parse_probe_records(&String::from_utf8_lossy(&output)))? +} + +fn read_bounded(mut reader: impl Read, limit: usize) -> io::Result> { + let mut output = Vec::with_capacity(limit.min(8 * 1024)); + let mut buffer = [0_u8; 8 * 1024]; + loop { + let read = reader.read(&mut buffer)?; + if read == 0 { + return Ok(output); + } + let remaining = limit.saturating_sub(output.len()); + if read > remaining { + return Err(io::Error::new(io::ErrorKind::InvalidData, "probe output exceeded limit")); + } + output.extend_from_slice(&buffer[..read]); + } +} + +#[cfg(unix)] +fn probe_local_proc(pane_id: &str, instance: &str) -> Option { + let deadline = Instant::now() + PROBE_TIMEOUT; + let mut output = String::new(); + let mut records = 0; + for entry in std::fs::read_dir("/proc").ok()?.flatten() { + if Instant::now() >= deadline { + return None; + } + let name = entry.file_name(); + let Some(pid) = name.to_str().and_then(|name| name.parse::().ok()) else { + continue; + }; + let proc_dir = entry.path(); + let Ok(comm) = std::fs::read_to_string(proc_dir.join("comm")) else { continue }; + if !is_codex_process_name(comm.trim()) { + continue; + } + let Ok(environment) = std::fs::read(proc_dir.join("environ")) else { continue }; + if !environment_has_value(&environment, b"PEBREL_PANE_ID", pane_id.as_bytes()) + && !environment_has_value(&environment, b"NEBULA_PANE_ID", pane_id.as_bytes()) + { + continue; + } + if !environment_has_value( + &environment, + crate::agent_env::PROCESS_ENV.as_bytes(), + instance.as_bytes(), + ) { + continue; + } + let Some(codex_home) = codex_home_from_environment(&environment) else { + continue; + }; + let Ok(fds) = std::fs::read_dir(proc_dir.join("fd")) else { continue }; + for fd in fds.flatten() { + let fd_path = fd.path(); + let Ok(link) = std::fs::read_link(&fd_path) else { continue }; + if !is_rollout_link(&link, &codex_home) { + continue; + } + let Ok(first_line) = read_first_line(&fd_path) else { continue }; + let fd_number = fd.file_name().to_string_lossy().parse::().ok(); + let Some(fd_number) = fd_number else { continue }; + use std::fmt::Write as _; + let _ = writeln!(output, "{pid}\t{fd_number}\t{}\t{first_line}", link.display()); + records += 1; + if records >= MAX_PROBE_RECORDS || output.len() >= MAX_PROBE_OUTPUT { + return None; + } + } + } + parse_probe_records(&output) +} + +#[cfg(unix)] +fn is_codex_process_name(name: &str) -> bool { + matches!(name, "codex" | "codex.exe") || name.starts_with("codex-") +} + +#[cfg(unix)] +fn environment_has_value(environment: &[u8], key: &[u8], expected: &[u8]) -> bool { + let mut prefix = Vec::with_capacity(key.len() + 1); + prefix.extend_from_slice(key); + prefix.push(b'='); + environment + .split(|byte| *byte == 0) + .any(|item| item.strip_prefix(prefix.as_slice()).is_some_and(|value| value == expected)) +} + +#[cfg(unix)] +fn environment_value<'a>(environment: &'a [u8], key: &[u8]) -> Option<&'a [u8]> { + let mut prefix = Vec::with_capacity(key.len() + 1); + prefix.extend_from_slice(key); + prefix.push(b'='); + environment + .split(|byte| *byte == 0) + .find_map(|item| item.strip_prefix(prefix.as_slice())) + .filter(|value| !value.is_empty()) +} + +#[cfg(unix)] +fn codex_home_from_environment(environment: &[u8]) -> Option { + use std::ffi::OsStr; + use std::os::unix::ffi::OsStrExt as _; + + let home = environment_value(environment, b"CODEX_HOME") + .or_else(|| environment_value(environment, b"HOME"))?; + let home = std::path::PathBuf::from(OsStr::from_bytes(home)); + if environment_value(environment, b"CODEX_HOME").is_some() { + Some(home) + } else { + Some(home.join(".codex")) + } +} + +#[cfg(unix)] +fn is_rollout_link(link: &Path, codex_home: &Path) -> bool { + let sessions = codex_home.join("sessions"); + link.strip_prefix(sessions).is_ok_and(|relative| { + relative.components().count() >= 2 + && link + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with("rollout-") && name.ends_with(".jsonl")) + }) +} + +#[cfg(unix)] +fn read_first_line(path: &Path) -> std::io::Result { + use std::io::{BufRead as _, BufReader}; + let file = std::fs::File::open(path)?; + let mut line = String::new(); + BufReader::new(file).take(MAX_PROBE_LINE as u64).read_line(&mut line)?; + if !line.ends_with(['\r', '\n']) && line.len() >= MAX_PROBE_LINE { + return Err(io::Error::new(io::ErrorKind::InvalidData, "session metadata line too long")); + } + Ok(line.trim_end_matches(['\r', '\n']).to_owned()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const ROOT_ID: &str = "01a079fa-4a9b-7d93-8a4a-4a7a9edaf247"; + const GUARDIAN_ID: &str = "01a079f7-7e36-7232-882b-f06d3bde9df8"; + + #[cfg(unix)] + #[test] + fn guest_lookup_returns_within_its_deadline() { + let mut command = Command::new("sh"); + command.args(["-c", "exec sleep 10"]); + let started = Instant::now(); + assert_eq!(run_probe_command(command), None); + assert!(started.elapsed() < Duration::from_secs(5)); + } + + #[test] + fn oversized_output_is_rejected_instead_of_selecting_a_partial_candidate() { + assert_eq!(read_bounded(&b"1234"[..], 4).unwrap(), b"1234"); + assert!(read_bounded(&b"12345"[..], 4).is_err()); + } + + #[test] + fn selects_cli_user_rollout_and_rejects_guardian_thread() { + let output = format!( + "1996\t37\t/home/hello/.codex/sessions/2026/09/07/rollout-x-{ROOT_ID}.jsonl\t{{\"type\":\"session_meta\",\"payload\":{{\"id\":\"{ROOT_ID}\",\"source\":\"cli\",\"thread_source\":\"user\"}}}}\n1996\t80\t/home/hello/.codex/sessions/2026/09/07/rollout-x-{GUARDIAN_ID}.jsonl\t{{\"type\":\"session_meta\",\"payload\":{{\"id\":\"{GUARDIAN_ID}\",\"source\":\"cli\",\"thread_source\":\"guardian\"}}}}\n" + ); + assert_eq!(parse_probe_records(&output).as_deref(), Some(ROOT_ID)); + } + + #[test] + fn rejects_a_rollout_bound_to_a_different_filename() { + let output = format!( + "1996\t37\t/home/hello/.codex/sessions/2026/09/07/rollout-x-{ROOT_ID}.jsonl\t{{\"type\":\"session_meta\",\"payload\":{{\"id\":\"{GUARDIAN_ID}\"}}}}\n" + ); + assert_eq!(parse_probe_records(&output), None); + } + + #[test] + fn accepts_legacy_session_meta_without_thread_markers() { + let output = format!( + "1996\t37\t/home/hello/.codex/sessions/2026/09/07/rollout-x-{ROOT_ID}.jsonl\t{{\"type\":\"session_meta\",\"payload\":{{\"id\":\"{ROOT_ID}\"}}}}\n" + ); + assert_eq!(parse_probe_records(&output).as_deref(), Some(ROOT_ID)); + } + + #[test] + fn wsl_probe_keeps_named_distribution_and_pane_as_arguments() { + let full = wsl_probe_args(Some("Debian"), Some("hello"), "7", "42"); + let named: Vec<_> = full.iter().map(String::as_str).take(5).collect(); + assert_eq!(named, vec!["--distribution", "Debian", "--user", "hello", "--exec"]); + let args = wsl_probe_args(None, None, "7", "42"); + assert_eq!(args.first().map(String::as_str), Some("--exec")); + assert_eq!(args[args.len() - 2], "7"); + assert_eq!(args.last().map(String::as_str), Some("42")); + assert_eq!(args[args.len() - 3], "pebrel-ai-session-probe"); + } + + #[test] + fn explicit_subagent_markers_are_not_accepted_as_the_main_thread() { + let output = format!( + "1996\t37\t/home/hello/.codex/sessions/2026/09/07/rollout-x-{ROOT_ID}.jsonl\t{{\"type\":\"session_meta\",\"payload\":{{\"id\":\"{ROOT_ID}\",\"source\":\"subagent\",\"thread_source\":\"user\"}}}}\n" + ); + assert_eq!(parse_probe_records(&output), None); + } + + #[test] + fn multiple_distinct_main_rollouts_are_rejected_as_ambiguous() { + let output = format!( + "1996\t37\t/home/hello/.codex/sessions/2026/09/07/rollout-x-{ROOT_ID}.jsonl\t{{\"type\":\"session_meta\",\"payload\":{{\"id\":\"{ROOT_ID}\",\"source\":\"cli\"}}}}\n1996\t38\t/home/hello/.codex/sessions/2026/09/07/rollout-x-{GUARDIAN_ID}.jsonl\t{{\"type\":\"session_meta\",\"payload\":{{\"id\":\"{GUARDIAN_ID}\",\"source\":\"cli\"}}}}\n" + ); + assert_eq!(parse_probe_records(&output), None); + } + + #[cfg(unix)] + #[test] + fn custom_codex_home_is_used_instead_of_the_default_directory() { + let environment = b"HOME=/home/hello\0CODEX_HOME=/srv/codex-data\0"; + assert_eq!( + codex_home_from_environment(environment).as_deref(), + Some(Path::new("/srv/codex-data")) + ); + let environment = b"HOME=/home/hello\0"; + assert_eq!( + codex_home_from_environment(environment).as_deref(), + Some(Path::new("/home/hello/.codex")) + ); + } +} diff --git a/nebula_app/src/platform/mod.rs b/nebula_app/src/platform/mod.rs index dae0875..db1dfe5 100644 --- a/nebula_app/src/platform/mod.rs +++ b/nebula_app/src/platform/mod.rs @@ -9,6 +9,7 @@ //! 切模块 + 模块内 `#[cfg]` 分派」,只保留真正值钱的两点:单一入口,以及 //! 能力探测(让 UI 隐藏入口,而不是让功能在别的平台报错)。 +pub(crate) mod ai_session_identity; pub mod capabilities; pub mod credentials; pub mod dirs; diff --git a/nebula_app/src/runtime_exec.rs b/nebula_app/src/runtime_exec.rs index 7fc260c..d0dbc50 100644 --- a/nebula_app/src/runtime_exec.rs +++ b/nebula_app/src/runtime_exec.rs @@ -19,7 +19,7 @@ use crate::runtime_api::{ApiError, RuntimeDispatch}; #[derive(Clone, Debug)] enum ExecLocation { Host, - Wsl { distro: Option }, + Wsl { distro: Option, user: Option }, } /// 创建 pane 时冻结的、可由独立 child 安全复用的上下文。 @@ -38,10 +38,24 @@ impl PaneExecContext { pub(crate) fn wsl_distribution(&self) -> Option> { match &self.location { ExecLocation::Host => None, - ExecLocation::Wsl { distro } => Some(distro.as_deref()), + ExecLocation::Wsl { distro, .. } => Some(distro.as_deref()), } } + pub(crate) fn wsl_user(&self) -> Option<&str> { + match &self.location { + ExecLocation::Host => None, + ExecLocation::Wsl { user, .. } => user.as_deref(), + } + } + + pub(crate) fn process_instance(&self) -> Option<&str> { + self.env + .get(crate::agent_env::PROCESS_ENV) + .map(String::as_str) + .filter(|value| !value.is_empty()) + } + pub(crate) fn from_pty_options(options: &nebula_terminal::tty::Options) -> Self { let location = options .shell @@ -50,6 +64,8 @@ impl PaneExecContext { .map_or(ExecLocation::Host, |shell| ExecLocation::Wsl { distro: crate::shell_detect::wsl_launch_distro(shell.program(), shell.args()) .map(str::to_owned), + user: crate::shell_detect::wsl_launch_user(shell.program(), shell.args()) + .map(str::to_owned), }); Self { location, @@ -171,11 +187,14 @@ fn build_command( command.args(&argv[1..]).current_dir(&cwd); execution = json!({ "environment": "host", "cwd": cwd }); }, - ExecLocation::Wsl { distro } => { + ExecLocation::Wsl { distro, user } => { command = Command::new("wsl.exe"); if let Some(distro) = distro { command.args(["--distribution", distro]); } + if let Some(user) = user { + command.args(["--user", user]); + } let guest_cwd = crate::shell_detect::wsl_guest_cwd(reported_cwd); if let Some(cwd) = guest_cwd { command.args(["--cd", cwd]); @@ -452,12 +471,13 @@ mod tests { assert_eq!(PaneExecContext::from_pty_options(&options).wsl_distribution(), Some(None)); options.shell = Some(nebula_terminal::tty::Shell::new( "wsl.exe".into(), - vec!["--distribution".into(), "Debian".into()], + vec!["--distribution".into(), "Debian".into(), "--user".into(), "hello".into()], )); assert_eq!( PaneExecContext::from_pty_options(&options).wsl_distribution(), Some(Some("Debian")) ); + assert_eq!(PaneExecContext::from_pty_options(&options).wsl_user(), Some("hello")); } #[test] diff --git a/nebula_app/src/session.rs b/nebula_app/src/session.rs index 0c913f8..a246e9d 100644 --- a/nebula_app/src/session.rs +++ b/nebula_app/src/session.rs @@ -66,7 +66,7 @@ pub enum SplitAxis { } /// 快照瞬间仍在一个 pane 前台运行的 AI CLI 对话。冷恢复用它接续会话: -/// 身份来自 hook 直报(claude `session_id` / codex `thread-id`),缺 id 的 +/// 身份来自 hook 直报或当前 Codex 会话元数据,缺 id 的 /// claude 退化成 `--continue`(按 cwd 找最近对话,恰好匹配恢复语义)。 /// /// 只存「安全启动描述」——来源名 + id,不存正文、不存启动参数原文。 @@ -515,6 +515,35 @@ mod tests { assert_eq!(cwds, ["a", "b", "c"]); } + #[test] + fn codex_conversations_survive_file_save_and_resume_by_exact_id() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("workspace.json"); + let ids = ["01a079fa-4a9b-7d93-8a4a-4a7a9edaf247", "01a079f7-7e36-7232-882b-f06d3bde9df8"]; + let tabs = ids + .iter() + .map(|id| { + let mut tab = TabSession::single("/home/user/project".into(), None, None); + tab.layout = Some(LayoutSession::Pane { + cwd: "/home/user/project".into(), + agent: Some(AgentSession { + source: "codex".into(), + session_id: Some((*id).into()), + }), + }); + tab + }) + .collect(); + save_to(&path, &Session::new(0, tabs)).unwrap(); + let restored = load_from(&path).unwrap(); + for (tab, id) in restored.tabs.iter().zip(ids) { + let Some(LayoutSession::Pane { agent: Some(agent), .. }) = &tab.layout else { + panic!("saved pane lost its conversation identity"); + }; + assert_eq!(agent.resume_command(), Some(format!("codex resume {id}"))); + } + } + #[test] fn workspace_files_round_trip_through_save_to_and_load_from() { let dir = std::env::temp_dir().join("nebula-session-test"); diff --git a/nebula_app/src/shell_detect.rs b/nebula_app/src/shell_detect.rs index 6dd30fa..e8ba914 100644 --- a/nebula_app/src/shell_detect.rs +++ b/nebula_app/src/shell_detect.rs @@ -496,6 +496,33 @@ pub fn wsl_launch_distro<'a>(program: &str, args: &'a [String]) -> Option<&'a st args.get(index + 1).map(String::as_str).filter(|distro| !distro.is_empty()) } +/// 一次 WSL 启动用的来宾用户:只认显式 `-u` / `--user`。探测来宾进程时必须 +/// 使用同一个用户,否则 `wsl.exe` 可能启动另一份默认用户环境,看不到目标 +/// Codex 的 `/proc`。 +pub fn wsl_launch_user<'a>(program: &str, args: &'a [String]) -> Option<&'a str> { + let filename = program.rsplit(['/', '\\']).next().unwrap_or(program); + let stem = std::path::Path::new(filename) + .file_stem() + .and_then(|stem| stem.to_str()) + .unwrap_or_default() + .to_ascii_lowercase(); + if stem != "wsl" { + return None; + } + for (index, arg) in args.iter().enumerate() { + if matches!(arg.as_str(), "--" | "--exec" | "-e") { + break; + } + if matches!(arg.as_str(), "-u" | "--user") { + return args.get(index + 1).map(String::as_str).filter(|user| !user.is_empty()); + } + if let Some(user) = arg.strip_prefix("--user=") { + return (!user.is_empty()).then_some(user); + } + } + None +} + /// 终端报的 cwd 是来宾侧的绝对路径吗(`/home/x`)。宿主路径(`D:\…`)不需要 /// 映射,交给普通路径分支即可。 pub fn wsl_guest_cwd(cwd: &str) -> Option<&str> { @@ -778,6 +805,15 @@ mod tests { assert_eq!(super::wsl_launch_distro("wsl.exe", &owned(&["-d"])), None); assert_eq!(super::wsl_launch_distro("wsl.exe", &owned(&["-d", ""])), None); assert_eq!(super::wsl_launch_distro("pwsh.exe", &owned(&["-d", "Debian"])), None); + + assert_eq!(super::wsl_launch_user("wsl.exe", &owned(&["--user", "hello"])), Some("hello")); + assert_eq!(super::wsl_launch_user("wsl.exe", &owned(&["--user=hello"])), Some("hello")); + assert_eq!(super::wsl_launch_user("wsl.exe", &owned(&["--user"])), None); + assert_eq!( + super::wsl_launch_user("wsl.exe", &owned(&["--exec", "tool", "--user", "remote"])), + None + ); + assert_eq!(super::wsl_launch_user("pwsh.exe", &owned(&["--user", "hello"])), None); } /// 只有来宾绝对路径需要映射;宿主路径与空 cwd 走普通分支。 diff --git a/nebula_app/src/update_check.rs b/nebula_app/src/update_check.rs index 2d73aa9..d6ad5ff 100644 --- a/nebula_app/src/update_check.rs +++ b/nebula_app/src/update_check.rs @@ -310,8 +310,9 @@ fn parse_latest_release(bytes: &[u8]) -> Result { Ok(LatestRelease { version, asset }) } -pub(crate) fn windows_x64_installer_names(version: &str) -> [String; 2] { +pub(crate) fn windows_x64_installer_names(version: &str) -> [String; 3] { [ + format!("Pebrel-v{version}-windows-x64-setup.exe"), format!("Pebrel-{version}-windows-x64-setup.exe"), format!("NebulaTerminal-{version}-windows-x64-setup.exe"), ] @@ -484,8 +485,8 @@ mod tests { #[test] fn installer_selection_prefers_pebrel_regardless_of_asset_order() { - let [pebrel, legacy] = windows_x64_installer_names("1.6.0"); - for names in [[&legacy, &pebrel], [&pebrel, &legacy]] { + let [pebrel, interim, legacy] = windows_x64_installer_names("1.6.0"); + for names in [[&legacy, &interim, &pebrel], [&pebrel, &legacy, &interim]] { let assets = names.map(|name| release_asset(name)).into_iter().collect(); let selected = select_windows_x64_installer("1.6.0", "", assets).unwrap(); assert_eq!(selected.name, pebrel); @@ -495,7 +496,7 @@ mod tests { #[test] fn installer_selection_accepts_legacy_only_releases_and_body_checksums() { - let [_, legacy] = windows_x64_installer_names("1.6.0"); + let [_, _, legacy] = windows_x64_installer_names("1.6.0"); let mut asset = release_asset(&legacy); asset.digest = None; let hash = "b".repeat(64); diff --git a/packaging/linux/io.github.kuddev.pebrel.desktop b/packaging/linux/io.github.kuddev.pebrel.desktop new file mode 100644 index 0000000..8eec56b --- /dev/null +++ b/packaging/linux/io.github.kuddev.pebrel.desktop @@ -0,0 +1,12 @@ +[Desktop Entry] +Type=Application +Name=Pebrel +GenericName=Terminal Emulator +Comment=GPU-accelerated terminal for local and remote workflows +Exec=pebrel +Icon=io.github.kuddev.pebrel +Terminal=false +Categories=System;TerminalEmulator; +Keywords=terminal;shell;prompt;ssh; +StartupNotify=true +StartupWMClass=Pebrel diff --git a/packaging/linux/io.github.kuddev.pebrel.metainfo.xml b/packaging/linux/io.github.kuddev.pebrel.metainfo.xml new file mode 100644 index 0000000..fdf2263 --- /dev/null +++ b/packaging/linux/io.github.kuddev.pebrel.metainfo.xml @@ -0,0 +1,26 @@ + + + io.github.kuddev.pebrel + Pebrel +

GPU-accelerated terminal for local and remote workflows + CC0-1.0 + GPL-3.0-or-later + Kuddev + +

+ Pebrel combines GPU-accelerated terminal rendering, local and + SSH sessions, file workflows, and command-line agent integrations. +

+
+ io.github.kuddev.pebrel.desktop + + pebrel + + + System + TerminalEmulator + + https://github.com/Kuddev/pebrel + https://github.com/Kuddev/pebrel/issues + + diff --git a/scripts/build-installer.ps1 b/scripts/build-installer.ps1 index b29f3b3..600cb71 100644 --- a/scripts/build-installer.ps1 +++ b/scripts/build-installer.ps1 @@ -57,7 +57,7 @@ if ([string]::IsNullOrWhiteSpace($TargetDirectory)) { } $cargoTargetRoot = [System.IO.Path]::GetFullPath($TargetDirectory) $targetRoot = Join-Path $cargoTargetRoot $Configuration -$setupPath = Join-Path $outputRoot "$PackageBrand-$Version-windows-x64-setup.exe" +$setupPath = Join-Path $outputRoot "$PackageBrand-v$Version-windows-x64-setup.exe" $requiredFiles = @( (Join-Path $targetRoot 'pebrel.exe'), diff --git a/scripts/installer.iss b/scripts/installer.iss index d5b9951..5d83dc4 100644 --- a/scripts/installer.iss +++ b/scripts/installer.iss @@ -50,7 +50,7 @@ LicenseFile={#RepoRoot}\LICENSE SetupIconFile={#RepoRoot}\nebula_app\windows\nebula.ico UninstallDisplayIcon={app}\pebrel.exe OutputDir={#RepoRoot}\dist -OutputBaseFilename={#PackageBrand}-{#AppVersion}-windows-x64-setup +OutputBaseFilename={#PackageBrand}-v{#AppVersion}-windows-x64-setup Compression=lzma2/max SolidCompression=yes WizardStyle=modern diff --git a/scripts/package-linux.sh b/scripts/package-linux.sh index 9ae6c5a..854ff75 100644 --- a/scripts/package-linux.sh +++ b/scripts/package-linux.sh @@ -5,18 +5,22 @@ export LC_ALL=C.UTF-8 usage() { cat <<'EOF' -Usage: scripts/package-linux.sh --binary PATH --version VERSION --preview-id ID +Usage: scripts/package-linux.sh --binary PATH --version VERSION + [--channel preview --preview-id ID|--channel stable] [--output-directory DIR] [--linuxdeploy PATH] [--force] -Builds a Linux x86_64 Preview AppImage, portable tar.gz, and Debian package -from one fresh Pebrel release binary. linuxdeploy must be supplied explicitly -or through LINUXDEPLOY; the CI workflow downloads a pinned, SHA-verified copy. +Builds a Linux x86_64 AppImage, portable tar.gz, and Debian package from one +fresh Pebrel release binary. Preview packages require --preview-id; stable +packages use formal Pebrel metadata and x64 asset names. linuxdeploy must be +supplied explicitly or through LINUXDEPLOY; CI downloads a pinned, +SHA-verified copy. EOF } binary="" version="" preview_id="" +channel="preview" output_directory="dist" linuxdeploy="${LINUXDEPLOY:-}" force=0 @@ -35,6 +39,10 @@ while [[ $# -gt 0 ]]; do preview_id="${2:-}" shift 2 ;; + --channel) + channel="${2:-}" + shift 2 + ;; --output-directory) output_directory="${2:-}" shift 2 @@ -59,7 +67,19 @@ while [[ $# -gt 0 ]]; do esac done -if [[ -z "$binary" || -z "$version" || -z "$preview_id" || -z "$linuxdeploy" ]]; then +if [[ "$channel" != "preview" && "$channel" != "stable" ]]; then + echo "unsupported package channel: $channel (expected preview or stable)" >&2 + exit 2 +fi +if [[ "$channel" == "preview" && -z "$preview_id" ]]; then + echo "Preview packages require --preview-id" >&2 + exit 2 +fi +if [[ "$channel" == "stable" && -n "$preview_id" ]]; then + echo "stable packages must not include --preview-id" >&2 + exit 2 +fi +if [[ -z "$binary" || -z "$version" || -z "$linuxdeploy" ]]; then usage >&2 exit 2 fi @@ -67,7 +87,7 @@ if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([+-][0-9A-Za-z.-]+)?$ ]]; then echo "invalid Cargo package version: $version" >&2 exit 2 fi -if [[ ! "$preview_id" =~ ^[0-9A-Za-z][0-9A-Za-z.-]{0,31}$ ]]; then +if [[ -n "$preview_id" && ! "$preview_id" =~ ^[0-9A-Za-z][0-9A-Za-z.-]{0,31}$ ]]; then echo "invalid Preview id: $preview_id" >&2 exit 2 fi @@ -100,8 +120,25 @@ linuxdeploy="$(cd "$(dirname "$linuxdeploy")" && pwd -P)/$(basename "$linuxdeplo mkdir -p "$output_directory" output_directory="$(cd "$output_directory" && pwd -P)" -desktop_source="$repo/packaging/linux/io.github.kuddev.pebrel.preview.desktop" -metainfo_source="$repo/packaging/linux/io.github.kuddev.pebrel.preview.metainfo.xml" +if [[ "$channel" == "stable" ]]; then + desktop_source="$repo/packaging/linux/io.github.kuddev.pebrel.desktop" + metainfo_source="$repo/packaging/linux/io.github.kuddev.pebrel.metainfo.xml" + desktop_id="io.github.kuddev.pebrel" + package_name="pebrel" + package_description="Pebrel GPU-accelerated terminal" + package_note="" + asset_architecture="x64" + release="$version" +else + desktop_source="$repo/packaging/linux/io.github.kuddev.pebrel.preview.desktop" + metainfo_source="$repo/packaging/linux/io.github.kuddev.pebrel.preview.metainfo.xml" + desktop_id="io.github.kuddev.pebrel.preview" + package_name="pebrel-preview" + package_description="Pebrel cross-platform Preview" + package_note=" This package is a Preview build and is not a stable release." + asset_architecture="x86_64" + release="$version-preview.$preview_id" +fi icon_source="$repo/extra/logo/nebula.png" for required in "$desktop_source" "$metainfo_source" "$icon_source" \ "$repo/README.md" "$repo/CHANGELOG.md" "$repo/INSTALL.md" \ @@ -153,10 +190,9 @@ if dpkg --compare-versions "$required_glibc" gt "2.35"; then exit 1 fi -release="$version-preview.$preview_id" -appimage_path="$output_directory/Pebrel-v$release-linux-x86_64.AppImage" -tar_path="$output_directory/Pebrel-v$release-linux-x86_64.tar.gz" -deb_path="$output_directory/Pebrel-v$release-linux-x86_64.deb" +appimage_path="$output_directory/Pebrel-v$release-linux-$asset_architecture.AppImage" +tar_path="$output_directory/Pebrel-v$release-linux-$asset_architecture.tar.gz" +deb_path="$output_directory/Pebrel-v$release-linux-$asset_architecture.deb" outputs=("$appimage_path" "$tar_path" "$deb_path") for output in "${outputs[@]}"; do if [[ -e "$output" && $force -ne 1 ]]; then @@ -178,37 +214,37 @@ trap cleanup EXIT common_root="$work/common" appdir="$work/Pebrel.AppDir" -deb_root="$work/debian/pebrel-preview" +deb_root="$work/debian/$package_name" mkdir -p \ "$common_root/usr/bin" \ "$common_root/usr/share/applications" \ "$common_root/usr/share/icons/hicolor/256x256/apps" \ "$common_root/usr/share/metainfo" \ - "$common_root/usr/share/doc/pebrel-preview/licenses" \ + "$common_root/usr/share/doc/$package_name/licenses" \ "$common_root/usr/share/bash-completion/completions" \ "$common_root/usr/share/fish/vendor_completions.d" \ "$common_root/usr/share/zsh/vendor-completions" install -m 0755 "$binary" "$common_root/usr/bin/pebrel" install -m 0644 "$desktop_source" \ - "$common_root/usr/share/applications/io.github.kuddev.pebrel.preview.desktop" + "$common_root/usr/share/applications/$desktop_id.desktop" install -m 0644 "$metainfo_source" \ - "$common_root/usr/share/metainfo/io.github.kuddev.pebrel.preview.metainfo.xml" + "$common_root/usr/share/metainfo/$desktop_id.metainfo.xml" convert "$icon_source" -resize 256x256 \ - "$common_root/usr/share/icons/hicolor/256x256/apps/io.github.kuddev.pebrel.preview.png" -install -m 0644 "$repo/README.md" "$common_root/usr/share/doc/pebrel-preview/README.md" -install -m 0644 "$repo/CHANGELOG.md" "$common_root/usr/share/doc/pebrel-preview/CHANGELOG.md" -install -m 0644 "$repo/INSTALL.md" "$common_root/usr/share/doc/pebrel-preview/INSTALL.md" -install -m 0644 "$repo/LICENSE" "$common_root/usr/share/doc/pebrel-preview/licenses/LICENSE" -install -m 0644 "$repo/LICENSE" "$common_root/usr/share/doc/pebrel-preview/copyright" + "$common_root/usr/share/icons/hicolor/256x256/apps/$desktop_id.png" +install -m 0644 "$repo/README.md" "$common_root/usr/share/doc/$package_name/README.md" +install -m 0644 "$repo/CHANGELOG.md" "$common_root/usr/share/doc/$package_name/CHANGELOG.md" +install -m 0644 "$repo/INSTALL.md" "$common_root/usr/share/doc/$package_name/INSTALL.md" +install -m 0644 "$repo/LICENSE" "$common_root/usr/share/doc/$package_name/licenses/LICENSE" +install -m 0644 "$repo/LICENSE" "$common_root/usr/share/doc/$package_name/copyright" install -m 0644 "$repo/THIRD-PARTY-NOTICES" \ - "$common_root/usr/share/doc/pebrel-preview/licenses/THIRD-PARTY-NOTICES" + "$common_root/usr/share/doc/$package_name/licenses/THIRD-PARTY-NOTICES" install -m 0644 "$repo/licenses/LICENSE-LUA" \ - "$common_root/usr/share/doc/pebrel-preview/licenses/LICENSE-LUA" + "$common_root/usr/share/doc/$package_name/licenses/LICENSE-LUA" install -m 0644 "$repo/licenses/LICENSE-MLUA" \ - "$common_root/usr/share/doc/pebrel-preview/licenses/LICENSE-MLUA" + "$common_root/usr/share/doc/$package_name/licenses/LICENSE-MLUA" install -m 0644 "$repo/licenses/LICENSE-LATIN-MODERN-MATH" \ - "$common_root/usr/share/doc/pebrel-preview/licenses/LICENSE-LATIN-MODERN-MATH" + "$common_root/usr/share/doc/$package_name/licenses/LICENSE-LATIN-MODERN-MATH" install -m 0644 "$repo/extra/completions/pebrel.bash" \ "$common_root/usr/share/bash-completion/completions/pebrel" install -m 0644 "$repo/extra/completions/pebrel.fish" \ @@ -217,9 +253,9 @@ install -m 0644 "$repo/extra/completions/_pebrel" \ "$common_root/usr/share/zsh/vendor-completions/_pebrel" desktop-file-validate \ - "$common_root/usr/share/applications/io.github.kuddev.pebrel.preview.desktop" + "$common_root/usr/share/applications/$desktop_id.desktop" appstreamcli validate --no-net \ - "$common_root/usr/share/metainfo/io.github.kuddev.pebrel.preview.metainfo.xml" + "$common_root/usr/share/metainfo/$desktop_id.metainfo.xml" cp -a "$common_root/." "$appdir/" tool_output="$work/linuxdeploy-output" @@ -232,8 +268,8 @@ mkdir -p "$tool_output" "$linuxdeploy" \ --appdir "$appdir" \ --executable "$appdir/usr/bin/pebrel" \ - --desktop-file "$appdir/usr/share/applications/io.github.kuddev.pebrel.preview.desktop" \ - --icon-file "$appdir/usr/share/icons/hicolor/256x256/apps/io.github.kuddev.pebrel.preview.png" \ + --desktop-file "$appdir/usr/share/applications/$desktop_id.desktop" \ + --icon-file "$appdir/usr/share/icons/hicolor/256x256/apps/$desktop_id.png" \ --output appimage ) mapfile -t generated_appimages < <(find "$tool_output" -maxdepth 1 -type f -name '*.AppImage' -print) @@ -255,7 +291,7 @@ if [[ ! "$source_date_epoch" =~ ^[0-9]+$ ]]; then echo "SOURCE_DATE_EPOCH must be an integer" >&2 exit 1 fi -archive_root="Pebrel-v$release-linux-x86_64" +archive_root="Pebrel-v$release-linux-$asset_architecture" tar \ --sort=name \ --mtime="@$source_date_epoch" \ @@ -269,15 +305,15 @@ tar \ mkdir -p "$deb_root" cp -a "$common_root/." "$deb_root/" cat >"$work/debian/control" < Standards-Version: 4.6.2 -Package: pebrel-preview +Package: $package_name Architecture: any -Description: Pebrel cross-platform Preview +Description: $package_description GPU-accelerated terminal for local and remote workflows. EOF dependency_line="$( @@ -291,9 +327,13 @@ fi dependencies="${dependency_line#shlibs:Depends=}" installed_size="$(du -sk "$deb_root/usr" | awk '{print $1}')" mkdir -p "$deb_root/DEBIAN" +expected_deb_version="$version" +if [[ "$channel" == "preview" ]]; then + expected_deb_version="$version~preview.$preview_id" +fi cat >"$deb_root/DEBIAN/control" <&2 exit 1 fi @@ -318,7 +358,7 @@ if [[ "$(dpkg-deb --field "$deb_path" Architecture)" != "amd64" ]]; then echo "Debian package architecture verification failed" >&2 exit 1 fi -if [[ "$(dpkg-deb --field "$deb_path" Version)" != "$version~preview.$preview_id" ]]; then +if [[ "$(dpkg-deb --field "$deb_path" Version)" != "$expected_deb_version" ]]; then echo "Debian package version verification failed" >&2 exit 1 fi diff --git a/scripts/package-macos.sh b/scripts/package-macos.sh index 344c834..0aa8c6c 100644 --- a/scripts/package-macos.sh +++ b/scripts/package-macos.sh @@ -5,12 +5,15 @@ export LC_ALL=en_US.UTF-8 usage() { cat <<'EOF' -Usage: scripts/package-macos.sh --binary PATH --version VERSION --preview-id ID +Usage: scripts/package-macos.sh --binary PATH --version VERSION + [--channel preview --preview-id ID|--channel stable] --architecture aarch64|x86_64 --build-number NUMBER [--output-directory DIR] [--force] [--sign-identity ID --notary-profile PROFILE [--signing-keychain PATH]] -Builds an ad-hoc-signed Pebrel Preview .app and packages it in a DMG. +Builds an ad-hoc-signed Pebrel .app and packages it in a DMG. Preview +packages require --preview-id; stable packages use formal bundle metadata and +arm64/x64 asset names. Developer ID mode requires both signing and notarization; failures never silently downgrade to an ad-hoc signature. EOF @@ -19,6 +22,7 @@ EOF binary="" version="" preview_id="" +channel="preview" architecture="" build_number="" output_directory="dist" @@ -41,6 +45,10 @@ while [[ $# -gt 0 ]]; do preview_id="${2:-}" shift 2 ;; + --channel) + channel="${2:-}" + shift 2 + ;; --architecture) architecture="${2:-}" shift 2 @@ -79,7 +87,19 @@ if [[ -n "$sign_identity" || -n "$notary_profile" || -n "$signing_keychain" ]]; fi fi -if [[ -z "$binary" || -z "$version" || -z "$preview_id" || \ +if [[ "$channel" != "preview" && "$channel" != "stable" ]]; then + echo "unsupported package channel: $channel (expected preview or stable)" >&2 + exit 2 +fi +if [[ "$channel" == "preview" && -z "$preview_id" ]]; then + echo "Preview packages require --preview-id" >&2 + exit 2 +fi +if [[ "$channel" == "stable" && -n "$preview_id" ]]; then + echo "stable packages must not include --preview-id" >&2 + exit 2 +fi +if [[ -z "$binary" || -z "$version" || \ -z "$architecture" || -z "$build_number" ]]; then usage >&2 exit 2 @@ -88,7 +108,7 @@ if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([+-][0-9A-Za-z.-]+)?$ ]]; then echo "invalid Cargo package version: $version" >&2 exit 2 fi -if [[ ! "$preview_id" =~ ^[0-9A-Za-z][0-9A-Za-z.-]{0,31}$ ]]; then +if [[ -n "$preview_id" && ! "$preview_id" =~ ^[0-9A-Za-z][0-9A-Za-z.-]{0,31}$ ]]; then echo "invalid Preview id: $preview_id" >&2 exit 2 fi @@ -101,7 +121,7 @@ if [[ ! "$build_number" =~ ^[1-9][0-9]*$ ]]; then exit 2 fi if [[ "$(uname -s)" != "Darwin" ]]; then - echo "macOS Preview packages must be built on a native macOS runner" >&2 + echo "macOS packages must be built on a native macOS runner" >&2 exit 1 fi @@ -165,8 +185,24 @@ while IFS= read -r dependency; do esac done < <(otool -L "$binary" | tail -n +2 | awk '{print $1}') -release="$version-preview.$preview_id" -dmg_path="$output_directory/Pebrel-v$release-macos-$architecture.dmg" +if [[ "$channel" == "stable" ]]; then + release="$version" + bundle_name="Pebrel" + bundle_id="io.github.kuddev.pebrel" + if [[ "$architecture" == "aarch64" ]]; then + asset_architecture="arm64" + else + asset_architecture="x64" + fi + volume_name="Pebrel" +else + release="$version-preview.$preview_id" + bundle_name="Pebrel Preview" + bundle_id="io.github.kuddev.pebrel.preview" + asset_architecture="$architecture" + volume_name="Pebrel Preview" +fi +dmg_path="$output_directory/Pebrel-v$release-macos-$asset_architecture.dmg" if [[ -e "$dmg_path" && $force -ne 1 ]]; then echo "package already exists: $dmg_path (pass --force to replace it)" >&2 exit 1 @@ -184,7 +220,7 @@ cleanup() { trap cleanup EXIT stage="$work/dmg-root" -app="$stage/Pebrel Preview.app" +app="$stage/$bundle_name.app" contents="$app/Contents" resources="$contents/Resources" mkdir -p "$contents/MacOS" "$resources/docs" "$resources/licenses" @@ -193,7 +229,10 @@ install -m 0644 "$plist_source" "$contents/Info.plist" plutil -replace CFBundleShortVersionString -string "$version" "$contents/Info.plist" plutil -replace CFBundleVersion -string "$build_number" "$contents/Info.plist" plutil -replace CFBundleGetInfoString -string \ - "Pebrel $version Preview $preview_id" "$contents/Info.plist" + "$bundle_name $version" "$contents/Info.plist" +plutil -replace CFBundleDisplayName -string "$bundle_name" "$contents/Info.plist" +plutil -replace CFBundleName -string "$bundle_name" "$contents/Info.plist" +plutil -replace CFBundleIdentifier -string "$bundle_id" "$contents/Info.plist" plutil -lint "$contents/Info.plist" iconset="$work/pebrel.iconset" @@ -236,7 +275,7 @@ else fi codesign --verify --deep --strict --verbose=2 "$app" if [[ "$(plutil -extract CFBundleIdentifier raw -o - "$contents/Info.plist")" != \ - "io.github.kuddev.pebrel.preview" ]]; then + "$bundle_id" ]]; then echo "application bundle identifier verification failed" >&2 exit 1 fi @@ -251,7 +290,7 @@ du -sh "$stage" size_mib="$(python3 "$script_directory/macos_dmg.py" "$stage")" echo "DMG filesystem capacity: ${size_mib} MiB" if hdiutil create \ - -volname "Pebrel Preview" \ + -volname "$volume_name" \ -fs HFS+ \ -size "${size_mib}m" \ -srcfolder "$stage" \ diff --git a/scripts/stable_release.py b/scripts/stable_release.py new file mode 100644 index 0000000..638983b --- /dev/null +++ b/scripts/stable_release.py @@ -0,0 +1,277 @@ +#!/usr/bin/env python3 +"""Validate the complete stable Pebrel asset set before publishing a Release.""" + +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import re +import sys + +if __package__: + from scripts.preview_release import MIN_ASSET_SIZE, VERSION_PATTERN, sha256, write_atomic +else: + # The release workflow invokes this file directly. In that mode Python puts + # `scripts/` on sys.path rather than the repository root. + from preview_release import MIN_ASSET_SIZE, VERSION_PATTERN, sha256, write_atomic + + +STABLE_SHA256_PLACEHOLDER = "" +PENDING_SHA256 = "PENDING FINAL BUILD" +STABLE_VERSION_PATTERN = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+$") + + +class StableReleaseError(ValueError): + """The stable asset set or release notes are incomplete or unsafe.""" + + +def validate_version(version: str) -> None: + if not VERSION_PATTERN.fullmatch(version) or not STABLE_VERSION_PATTERN.fullmatch(version): + raise StableReleaseError(f"invalid stable version: {version!r}") + + +def expected_asset_names(version: str) -> tuple[str, ...]: + validate_version(version) + return ( + f"Pebrel-v{version}-linux-x64.AppImage", + f"Pebrel-v{version}-linux-x64.deb", + f"Pebrel-v{version}-linux-x64.tar.gz", + f"Pebrel-v{version}-macos-arm64.dmg", + f"Pebrel-v{version}-macos-x64.dmg", + f"Pebrel-v{version}-windows-x64.zip", + f"Pebrel-v{version}-windows-x64-setup.exe", + f"NebulaTerminal-{version}-windows-x64-setup.exe", + ) + + +def _check_magic(path: Path) -> None: + with path.open("rb") as stream: + header = stream.read(8) + trailer = b"" + if path.suffix == ".dmg": + if path.stat().st_size < 512: + raise StableReleaseError(f"DMG is too small to contain a UDIF trailer: {path.name}") + stream.seek(-512, os.SEEK_END) + trailer = stream.read(4) + + if path.suffix == ".exe" and not header.startswith(b"MZ"): + raise StableReleaseError(f"Windows installer has no PE header: {path.name}") + if path.suffix == ".zip" and not header.startswith((b"PK\x03\x04", b"PK\x05\x06")): + raise StableReleaseError(f"Windows ZIP has an invalid ZIP header: {path.name}") + if path.name.endswith(".AppImage") and not header.startswith(b"\x7fELF"): + raise StableReleaseError(f"AppImage is not an ELF executable: {path.name}") + if path.suffix == ".deb" and header != b"!\n": + raise StableReleaseError(f"Debian package has an invalid ar header: {path.name}") + if path.name.endswith(".tar.gz") and not header.startswith(b"\x1f\x8b"): + raise StableReleaseError(f"tar.gz has an invalid gzip header: {path.name}") + if path.suffix == ".dmg" and trailer != b"koly": + raise StableReleaseError(f"DMG has no UDIF trailer: {path.name}") + + +def validate_assets(directory: Path, version: str) -> list[Path]: + expected = set(expected_asset_names(version)) + generated = {"SHA256SUMS", "RELEASE_NOTES.md"} + if not directory.is_dir(): + raise StableReleaseError(f"asset directory does not exist: {directory}") + + actual = {path.name for path in directory.iterdir() if path.name not in generated} + missing = sorted(expected - actual) + unexpected = sorted(actual - expected) + if missing or unexpected: + details: list[str] = [] + if missing: + details.append(f"missing: {', '.join(missing)}") + if unexpected: + details.append(f"unexpected: {', '.join(unexpected)}") + raise StableReleaseError("stable asset manifest differs: " + "; ".join(details)) + + assets = [directory / name for name in sorted(expected)] + for path in assets: + if not path.is_file() or path.is_symlink(): + raise StableReleaseError(f"asset is not a regular file: {path.name}") + if path.stat().st_size < MIN_ASSET_SIZE: + raise StableReleaseError(f"asset is unexpectedly small: {path.name}") + _check_magic(path) + + installer = directory / f"Pebrel-v{version}-windows-x64-setup.exe" + legacy = directory / f"NebulaTerminal-{version}-windows-x64-setup.exe" + if sha256(installer) != sha256(legacy): + raise StableReleaseError("legacy Windows installer alias is not byte-identical to Pebrel installer") + return assets + + +def validate_notes(source: Path, version: str) -> str: + notes = source.read_text(encoding="utf-8").strip() + if not notes.startswith(f"# Pebrel {version}\n"): + raise StableReleaseError("stable release notes have the wrong title") + for heading in ("## English", "## 中文", "## Contributors"): + if notes.splitlines().count(heading) != 1: + raise StableReleaseError(f"stable release notes require one {heading} section") + checksum_headings = ("\n## SHA256\n", "\n**SHA256**\n") + present_checksums = [heading for heading in checksum_headings if notes.count(heading) == 1] + if len(present_checksums) != 1: + raise StableReleaseError("stable release notes require one SHA256 section") + english_start = notes.index("\n## English\n") + chinese_start = notes.index("\n## 中文\n") + contributors_start = notes.index("\n## Contributors\n") + checksum_start = notes.index(present_checksums[0]) + if not english_start < chinese_start < contributors_start < checksum_start: + raise StableReleaseError("stable release notes sections are out of order") + pairs = (("Added", "新增"), ("Fixed", "修复"), ("Improved", "改进")) + english = notes[english_start:chinese_start] + chinese = notes[chinese_start:contributors_start] + if not any( + f"### {en}" in english and f"### {zh}" in chinese for en, zh in pairs + ): + raise StableReleaseError("stable release notes need matching bilingual change categories") + contributors = notes[contributors_start:checksum_start] + if not re.search(r"github\.com/[^)]+", contributors, flags=re.IGNORECASE): + raise StableReleaseError("stable release notes must identify contributors with GitHub links") + checksum_body = notes[checksum_start:].strip() + if STABLE_SHA256_PLACEHOLDER not in checksum_body: + listed_names = set(re.findall(r"`([^`]+)`:\s*`(?:[0-9a-fA-F]{64}|PENDING FINAL BUILD)`", checksum_body)) + listed_names.update( + match.group(1) + for match in re.finditer(r"(?m)^\s*[0-9a-fA-F]{64}\s{2}(\S+)\s*$", checksum_body) + ) + expected_names = set(expected_asset_names(version)) + if listed_names != expected_names: + missing = sorted(expected_names - listed_names) + unexpected = sorted(listed_names - expected_names) + details = [] + if missing: + details.append("missing: " + ", ".join(missing)) + if unexpected: + details.append("unexpected: " + ", ".join(unexpected)) + raise StableReleaseError("stable release notes SHA256 asset names differ: " + "; ".join(details)) + if ( + STABLE_SHA256_PLACEHOLDER not in checksum_body + and PENDING_SHA256 not in checksum_body + and not re.search(r"[0-9a-fA-F]{64}", checksum_body) + ): + raise StableReleaseError("stable release notes need a SHA256 placeholder or checksums") + return notes + "\n" + + +def checksum_text(assets: list[Path]) -> str: + return "".join(f"{sha256(path)} {path.name}\n" for path in assets) + + +def validate_changelog(source: Path, version: str) -> str: + changelog = source.read_text(encoding="utf-8") + match = re.search(rf"(?m)^## {re.escape(version)}(?: - [^\n]*)?\n", changelog) + if match is None: + raise StableReleaseError(f"CHANGELOG.md is missing the {version} entry") + entry = re.split(r"(?m)^## ", changelog[match.end() :], maxsplit=1)[0] + if not entry.strip(): + raise StableReleaseError(f"CHANGELOG.md has an empty {version} entry") + missing = [name for name in expected_asset_names(version) if name not in entry] + if missing: + raise StableReleaseError("CHANGELOG.md is missing stable asset names: " + ", ".join(missing)) + return entry + + +def verify_release(metadata: dict, directory: Path, version: str, commit: str, tag_commit: str) -> None: + expected_names = set(expected_asset_names(version)) | {"SHA256SUMS"} + if ( + metadata.get("name") != f"Pebrel {version}" + or metadata.get("tag_name") != f"v{version}" + or metadata.get("draft") is not False + or metadata.get("prerelease") is not False + or tag_commit != commit + ): + raise StableReleaseError("remote stable title, tag, commit, or publication state differs") + notes = (directory / "RELEASE_NOTES.md").read_text(encoding="utf-8") + if (metadata.get("body") or "").strip() != notes.strip(): + raise StableReleaseError("remote stable Release notes differ from the verified local notes") + assets = metadata.get("assets") or [] + if len(assets) != len(expected_names) or {asset.get("name") for asset in assets} != expected_names: + raise StableReleaseError("remote stable asset names differ") + for asset in assets: + path = directory / asset["name"] + if ( + asset.get("label") not in (None, "") + or asset.get("state") != "uploaded" + or asset.get("size") != path.stat().st_size + or asset.get("digest") != f"sha256:{sha256(path)}" + ): + raise StableReleaseError(f"remote asset label, state, size, or SHA256 differs: {path.name}") + + +def render_notes(notes: str, checksums: str) -> str: + if STABLE_SHA256_PLACEHOLDER in notes: + return notes.replace(STABLE_SHA256_PLACEHOLDER, f"```text\n{checksums.rstrip()}\n```") + if PENDING_SHA256 in notes: + rendered = notes + for line in checksums.splitlines(): + digest, name = line.split(" ", 1) + rendered = re.sub( + rf"(`{re.escape(name)}`:\s*`){re.escape(PENDING_SHA256)}(`)", + rf"\g<1>{digest}\g<2>", + rendered, + ) + if PENDING_SHA256 in rendered: + raise StableReleaseError("stable release notes contain an unknown pending checksum entry") + return rendered + else: + missing = [line for line in checksums.splitlines() if line not in notes] + if missing: + raise StableReleaseError( + "stable release notes have stale or incomplete SHA256 entries: " + + ", ".join(line.rsplit(" ", 1)[-1] for line in missing) + ) + return notes + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("directory", type=Path) + parser.add_argument("--version", required=True) + parser.add_argument("--notes", type=Path) + parser.add_argument("--changelog", type=Path) + parser.add_argument("--write-checksums", type=Path) + parser.add_argument("--write-notes", type=Path) + parser.add_argument("--verify-release", type=Path) + parser.add_argument("--commit") + parser.add_argument("--tag-commit") + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + try: + assets = validate_assets(args.directory.resolve(), args.version) + notes = None + if args.notes: + notes = validate_notes(args.notes.resolve(), args.version) + if args.changelog: + validate_changelog(args.changelog.resolve(), args.version) + checksums = checksum_text(assets) + if args.write_checksums: + write_atomic(args.write_checksums.resolve(), checksums) + if args.write_notes: + if notes is None: + raise StableReleaseError("--write-notes requires --notes") + write_atomic(args.write_notes.resolve(), render_notes(notes, checksums)) + if args.verify_release: + if not args.commit or not args.tag_commit: + raise StableReleaseError("--verify-release requires --commit and --tag-commit") + verify_release( + json.loads(args.verify_release.read_text(encoding="utf-8")), + args.directory.resolve(), + args.version, + args.commit, + args.tag_commit, + ) + for path in assets: + print(f"{sha256(path)} {path.name}") + except (StableReleaseError, OSError, json.JSONDecodeError) as error: + print(f"stable release error: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/tests/installer.tests.ps1 b/scripts/tests/installer.tests.ps1 index 3ca18a9..5acaf8e 100644 --- a/scripts/tests/installer.tests.ps1 +++ b/scripts/tests/installer.tests.ps1 @@ -34,7 +34,7 @@ $requiredPatterns = [ordered]@{ 'Pebrel display name' = 'AppName=Pebrel' 'compatible installer identity' = 'AppId=\{\{61022144-7D0A-4E54-94F2-C329A8F58656\}' 'Pebrel default asset name' = '#define PackageBrand "Pebrel"' - 'explicit package brand' = 'OutputBaseFilename=\{#PackageBrand\}-\{#AppVersion\}-windows-x64-setup' + 'explicit package brand' = 'OutputBaseFilename=\{#PackageBrand\}-v\{#AppVersion\}-windows-x64-setup' 'localized Chinese context menu label' = 'chinesesimplified\.OpenInPebrel=\S.+' 'directory background context menu' = 'Software\\Classes\\Directory\\Background\\shell\\Pebrel' 'selected directory context menu' = 'Software\\Classes\\Directory\\shell\\Pebrel' diff --git a/scripts/tests/test_ai_session_probe.py b/scripts/tests/test_ai_session_probe.py new file mode 100644 index 0000000..7b7ae7b --- /dev/null +++ b/scripts/tests/test_ai_session_probe.py @@ -0,0 +1,61 @@ +import json +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +CHILD = r''' +import ctypes, json, sys +ctypes.CDLL(None).prctl(15, b"codex", 0, 0, 0) +handles = [open(path, "rb") for path in json.loads(sys.argv[1])] +print("ready", flush=True) +sys.stdin.read() +''' + + +@unittest.skipUnless(sys.platform == "linux", "requires Linux procfs (also runs inside WSL)") +class ActiveRolloutProbeTests(unittest.TestCase): + def test_probe_binds_custom_home_to_exact_instance_and_pane(self): + source = (ROOT / "nebula_app/src/platform/ai_session_identity.rs").read_text() + script = source.split('const PROBE_SCRIPT: &str = r###"', 1)[1].split('"###;', 1)[0] + with tempfile.TemporaryDirectory(prefix="pebrel-identity-") as temporary: + codex_directory = Path(temporary) / "custom codex" + sessions = codex_directory / "sessions/2026/09/07" + sessions.mkdir(parents=True) + main_id = "01a079fa-4a9b-7d93-8a4a-4a7a9edaf247" + guardian_id = "01a079f7-7e36-7232-882b-f06d3bde9df8" + paths = [] + for identity, origin in ((main_id, "cli"), (guardian_id, {"subagent": "guardian"})): + path = sessions / f"rollout-x-{identity}.jsonl" + path.write_text(json.dumps({"type": "session_meta", "payload": { + "id": identity, "source": origin, + }}) + "\n", encoding="utf-8") + paths.append(str(path)) + instance = f"probe-test-{os.getpid()}" + environment = dict(os.environ, PEBREL_PROCESS_ID=instance, + PEBREL_PANE_ID="713", CODEX_HOME=str(codex_directory)) + child = subprocess.Popen([sys.executable, "-c", CHILD, json.dumps(paths)], + env=environment, stdin=subprocess.PIPE, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + try: + self.assertEqual(child.stdout.readline().strip(), "ready") + result = subprocess.run(["sh", "-c", script, "probe", "713", instance], + capture_output=True, text=True, check=True, timeout=5) + records = [json.loads(line.split("\t", 3)[3]) + for line in result.stdout.splitlines()] + self.assertEqual({item["payload"]["id"] for item in records}, + {main_id, guardian_id}) + for pane, owner in (("714", instance), ("713", instance + "-other")): + mismatch = subprocess.run(["sh", "-c", script, "probe", pane, owner], + capture_output=True, text=True, check=True, timeout=5) + self.assertEqual(mismatch.stdout, "") + finally: + child.communicate(timeout=5) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_pebrel_branding.py b/scripts/tests/test_pebrel_branding.py index f889ec9..ce3ff45 100644 --- a/scripts/tests/test_pebrel_branding.py +++ b/scripts/tests/test_pebrel_branding.py @@ -74,7 +74,7 @@ class PebrelBrandingTests(unittest.TestCase): self.assertIn("Stale binary:", source) installer = self.source("scripts/installer.iss") self.assertIn('#define PackageBrand "Pebrel"', installer) - self.assertIn("OutputBaseFilename={#PackageBrand}-{#AppVersion}-windows-x64-setup", installer) + self.assertIn("OutputBaseFilename={#PackageBrand}-v{#AppVersion}-windows-x64-setup", installer) builder = self.source("scripts/build-installer.ps1") self.assertIn('"/DPackageBrand=$PackageBrand"', builder) @@ -82,6 +82,7 @@ class PebrelBrandingTests(unittest.TestCase): check = self.source("nebula_app/src/update_check.rs") download = self.source("nebula_app/src/update_download.rs") self.assertIn('format!("Pebrel-{version}-windows-x64-setup.exe")', check) + self.assertIn('format!("Pebrel-v{version}-windows-x64-setup.exe")', check) self.assertIn('format!("NebulaTerminal-{version}-windows-x64-setup.exe")', check) self.assertIn("windows_x64_installer_names(version)", check) self.assertIn("windows_x64_installer_names(&asset.version).contains(&asset.name)", download) diff --git a/scripts/tests/test_stable_release.py b/scripts/tests/test_stable_release.py new file mode 100644 index 0000000..19a5c12 --- /dev/null +++ b/scripts/tests/test_stable_release.py @@ -0,0 +1,186 @@ +from __future__ import annotations + +from pathlib import Path +import tempfile +import unittest + +from scripts.stable_release import ( + STABLE_SHA256_PLACEHOLDER, + StableReleaseError, + expected_asset_names, + render_notes, + validate_assets, + validate_changelog, + validate_notes, + verify_release, +) +from scripts.preview_release import sha256 +from scripts.preview_release import MIN_ASSET_SIZE + + +VERSION = "1.6.0" + + +def write_fake_asset(path: Path) -> None: + if path.suffix == ".exe": + header = b"MZ" + b"\0" * 6 + elif path.suffix == ".zip": + header = b"PK\x03\x04" + b"\0" * 4 + elif path.name.endswith(".AppImage"): + header = b"\x7fELF\x02\x01\x01\0" + elif path.suffix == ".deb": + header = b"!\n" + elif path.name.endswith(".tar.gz"): + header = b"\x1f\x8b\x08\0\0\0\0\0" + elif path.suffix == ".dmg": + header = b"\0" * 8 + else: + raise AssertionError(path) + path.write_bytes(header + b"\0" * (MIN_ASSET_SIZE - len(header))) + if path.suffix == ".dmg": + with path.open("r+b") as stream: + stream.seek(-512, 2) + stream.write(b"koly") + + +def notes(checksum_placeholder: bool = True) -> str: + checksum = STABLE_SHA256_PLACEHOLDER if checksum_placeholder else "0" * 64 + return f"""# Pebrel {VERSION} + +## English + +### Added +- A stable package set. + +## 中文 + +### 新增 +- 稳定版安装包集合。 + +## Contributors + +\"@Kuddev + +## SHA256 + +{checksum} +""" + + +class StableReleaseTests(unittest.TestCase): + def test_native_packagers_expose_stable_channel_without_preview_id(self) -> None: + root = Path(__file__).resolve().parents[2] + linux = (root / "scripts/package-linux.sh").read_text(encoding="utf-8") + macos = (root / "scripts/package-macos.sh").read_text(encoding="utf-8") + for source in (linux, macos): + self.assertIn('--channel', source) + self.assertIn('channel" == "stable"', source) + self.assertIn('stable packages must not include --preview-id', source) + self.assertIn('asset_architecture="x64"', linux) + self.assertIn('asset_architecture="x64"', macos) + self.assertIn('asset_architecture="arm64"', macos) + + def test_expected_assets_use_stable_public_names(self) -> None: + names = expected_asset_names(VERSION) + self.assertIn("Pebrel-v1.6.0-linux-x64.AppImage", names) + self.assertIn("Pebrel-v1.6.0-macos-arm64.dmg", names) + self.assertIn("Pebrel-v1.6.0-macos-x64.dmg", names) + self.assertIn("Pebrel-v1.6.0-windows-x64-setup.exe", names) + self.assertIn("NebulaTerminal-1.6.0-windows-x64-setup.exe", names) + self.assertEqual(len(names), 8) + + def test_stable_manifest_rejects_prerelease_versions(self) -> None: + with self.assertRaisesRegex(StableReleaseError, "invalid stable version"): + expected_asset_names("1.6.0-rc.1") + + def test_complete_assets_require_identical_legacy_installer(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + for name in expected_asset_names(VERSION): + write_fake_asset(root / name) + self.assertEqual(len(validate_assets(root, VERSION)), 8) + legacy = root / "NebulaTerminal-1.6.0-windows-x64-setup.exe" + legacy.write_bytes(legacy.read_bytes()[:-1] + b"x") + with self.assertRaisesRegex(StableReleaseError, "byte-identical"): + validate_assets(root, VERSION) + + def test_asset_magic_is_checked(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + for name in expected_asset_names(VERSION): + write_fake_asset(root / name) + (root / "Pebrel-v1.6.0-linux-x64.AppImage").write_bytes(b"bad" + b"\0" * MIN_ASSET_SIZE) + with self.assertRaisesRegex(StableReleaseError, "ELF"): + validate_assets(root, VERSION) + + def test_notes_require_bilingual_categories_and_contributor_link(self) -> None: + with tempfile.TemporaryDirectory() as directory: + source = Path(directory) / "v1.6.0.md" + source.write_text(notes(), encoding="utf-8") + self.assertEqual(validate_notes(source, VERSION), notes()) + source.write_text(notes().replace("https://github.com/Kuddev", "https://example.invalid"), encoding="utf-8") + with self.assertRaisesRegex(StableReleaseError, "GitHub links"): + validate_notes(source, VERSION) + + def test_changelog_must_list_the_same_stable_asset_names(self) -> None: + with tempfile.TemporaryDirectory() as directory: + source = Path(directory) / "CHANGELOG.md" + source.write_text( + "## 1.6.0 - 2026-09-07\n\n" + + "\n".join(f"- `{name}`: `PENDING FINAL BUILD`" for name in expected_asset_names(VERSION)) + + "\n", + encoding="utf-8", + ) + self.assertTrue(validate_changelog(source, VERSION)) + source.write_text(source.read_text(encoding="utf-8").replace("Pebrel-v1.6.0", "Pebrel-1.6.0"), encoding="utf-8") + with self.assertRaisesRegex(StableReleaseError, "missing stable asset"): + validate_changelog(source, VERSION) + + def test_render_notes_replaces_placeholder_with_all_hashes(self) -> None: + rendered = render_notes(notes(), "a" * 64 + " Pebrel-v1.6.0-windows-x64.zip\n") + self.assertNotIn(STABLE_SHA256_PLACEHOLDER, rendered) + self.assertIn("a" * 64 + " Pebrel-v1.6.0-windows-x64.zip", rendered) + + def test_render_notes_replaces_pending_named_entries(self) -> None: + source = notes().replace(STABLE_SHA256_PLACEHOLDER, "- `Pebrel-v1.6.0-windows-x64.zip`: `PENDING FINAL BUILD`") + rendered = render_notes(source, "a" * 64 + " Pebrel-v1.6.0-windows-x64.zip\n") + self.assertIn("`Pebrel-v1.6.0-windows-x64.zip`: `" + "a" * 64, rendered) + + def test_render_notes_rejects_stale_explicit_hashes(self) -> None: + with self.assertRaisesRegex(StableReleaseError, "stale or incomplete"): + render_notes(notes(False), "a" * 64 + " Pebrel-v1.6.0-windows-x64.zip\n") + + def test_remote_release_verification_checks_all_assets_and_notes(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + for name in expected_asset_names(VERSION): + write_fake_asset(root / name) + (root / "SHA256SUMS").write_text("checksums\n", encoding="utf-8") + release_notes = notes().replace(STABLE_SHA256_PLACEHOLDER, "rendered checksums") + (root / "RELEASE_NOTES.md").write_text(release_notes, encoding="utf-8") + names = [*expected_asset_names(VERSION), "SHA256SUMS"] + metadata = { + "name": "Pebrel 1.6.0", + "tag_name": "v1.6.0", + "draft": False, + "prerelease": False, + "body": release_notes, + "assets": [ + { + "name": name, + "label": "", + "state": "uploaded", + "size": (root / name).stat().st_size, + "digest": f"sha256:{sha256(root / name)}", + } + for name in names + ], + } + verify_release(metadata, root, VERSION, "a" * 40, "a" * 40) + metadata["assets"][0]["label"] = "wrong" + with self.assertRaisesRegex(StableReleaseError, "label"): + verify_release(metadata, root, VERSION, "a" * 40, "a" * 40) + + +if __name__ == "__main__": + unittest.main() From 5b8285388d5f63b9962c135825aff039aaa3ce56 Mon Sep 17 00:00:00 2001 From: kuddev Date: Mon, 7 Sep 2026 18:37:56 +0800 Subject: [PATCH 20/31] docs: present current Pebrel workflows with separate language pages --- CODE_OF_CONDUCT.md | 23 + INSTALL.md | 43 +- README.md | 563 +++++------------------- README.zh-CN.md | 133 ++++++ SECURITY.md | 25 ++ scripts/build-installer.ps1 | 1 + scripts/installer.iss | 1 + scripts/package-linux.sh | 3 +- scripts/package-macos.sh | 2 + scripts/package-release.ps1 | 1 + scripts/tests/package-release.tests.ps1 | 5 +- 11 files changed, 314 insertions(+), 486 deletions(-) create mode 100644 CODE_OF_CONDUCT.md create mode 100644 README.zh-CN.md create mode 100644 SECURITY.md diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..290a2f3 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,23 @@ +# Code of Conduct + +Pebrel welcomes contributors and users with different backgrounds and levels of experience. + +## Participation + +- Treat other people with respect. Discuss code and ideas without personal attacks. +- Give constructive feedback and make space for questions and differing perspectives. +- Do not harass, threaten, discriminate against, or publish private information about others. +- Keep issues, pull requests, and project discussions relevant and free of spam. + +## Reporting + +Report conduct concerns privately to **fickleheartedkeys@163.com**. Include links +and enough context for the maintainers to review the situation. Do not include +unrelated private information. + +Maintainers may remove inappropriate content or restrict participation when needed +to protect project discussions. Reports will be handled with respect for the +privacy of those involved. + +This policy applies to the project's issues, pull requests, discussions, and other +spaces administered by the Pebrel maintainers. diff --git a/INSTALL.md b/INSTALL.md index a31eabf..a486cbf 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -5,23 +5,22 @@ Older releases keep their original filenames. Use the new installer to migrate an existing Nebula installation; configuration migration is handled by the application at startup. -## Linux Preview +## Linux -Cross-platform Preview runs provide three Linux x86_64 assets. They are test -builds rather than stable releases. +Stable releases provide three Linux x64 packages. - Debian/Ubuntu: install - `Pebrel-v-preview.-linux-x86_64.deb` with - `sudo apt install ./Pebrel-v-preview.-linux-x86_64.deb`. - Remove it with `sudo apt remove pebrel-preview`. + `Pebrel-v-linux-x64.deb` with + `sudo apt install ./Pebrel-v-linux-x64.deb`. + Remove it with `sudo apt remove pebrel`. - AppImage: make - `Pebrel-v-preview.-linux-x86_64.AppImage` executable and + `Pebrel-v-linux-x64.AppImage` executable and run it directly. It does not register itself with the system package manager. - Portable archive: extract the `tar.gz` and run its `AppRun` launcher. Keep the AppDir layout intact so bundled libraries are found correctly. -The initial Preview target is Linux x86_64 with glibc 2.35 or newer. The -Preview workflow is configured to build on Ubuntu 22.04 and run the same +The Linux target is x86_64 with glibc 2.35 or newer. The +release workflow builds on Ubuntu 22.04 and runs the same Runtime API conformance suite against the final AppImage under X11 and Wayland, and against the installed Debian package under X11. @@ -31,19 +30,19 @@ passphrases requires `libsecret-tools` and an unlocked Secret Service keyring recommends these dependencies. If storage is unavailable, enter the secret for the current connection instead; Pebrel does not silently claim to save it. -## macOS Preview +## macOS Download the DMG matching the Mac architecture: -- `macos-aarch64.dmg` for Apple Silicon Macs. -- `macos-x86_64.dmg` for Intel Macs. +- `Pebrel-v-macos-arm64.dmg` for Apple Silicon Macs. +- `Pebrel-v-macos-x64.dmg` for Intel Macs. -Open the DMG and drag **Pebrel Preview** into Applications. These -Preview builds default to ad-hoc signing, without Apple notarization. For a +Open the DMG and drag **Pebrel** into Applications. The 1.6.0 +packages use ad-hoc signing, without Apple notarization. For a download you have verified and trust, macOS may require **System Settings → Privacy & Security → Open Anyway** after an initial launch is blocked. Do not disable Gatekeeper globally. Developer ID/notarized builds are explicitly -identified in their Preview notes; that mode requires the maintainer's Apple +identified in their release notes; that mode requires the maintainer's Apple credentials and fails rather than falling back to ad-hoc signing. The workflow builds both architectures on native macOS 15 runners. It checks @@ -57,16 +56,20 @@ a UTF-8 locale and starts from the home directory when launched from `/`, while an explicit working directory is preserved. Both platforms use the embedded Maple Mono terminal font without requiring a system font installation. -Preview is not full Windows feature parity: tray/close-to-background residency, +Linux/macOS do not yet provide full Windows feature parity: tray/close-to-background residency, global quick-terminal hotkeys, automatic update installation, and automatic local AI-hook configuration are not enabled on Linux/macOS. Native IME, display scaling, notification permissions, and interactive SSH/SFTP still need native user testing. The release procedure and acceptance checklist are in [`docs/preview-release-checklist.md`](docs/preview-release-checklist.md). +Preview builds remain separate from stable releases. Their filenames contain +`-preview.` and use `x86_64` / `aarch64`; the Debian package is +`pebrel-preview` and the macOS bundle is **Pebrel Preview**. + ## Windows installer (recommended) -1. Download `Pebrel--windows-x64-setup.exe` from the +1. Download `Pebrel-v-windows-x64-setup.exe` from the [Releases](https://github.com/Kuddev/pebrel/releases/latest) page. 2. Follow the wizard to choose the installation directory and optional desktop or Windows sign-in shortcuts. The default per-user installation does not @@ -121,8 +124,10 @@ Keep the extracted directory structure intact: ## Build from source -Requirements: Windows 10 1809+ / 11 and [rustup](https://rustup.rs). The -repository pins Rust 1.97.1 in `rust-toolchain.toml`. +Install [rustup](https://rustup.rs) and the build dependencies for your platform. +The repository pins Rust 1.97.1 in `rust-toolchain.toml`. Linux dependency packages +are listed in `.github/workflows/release.yml`; macOS requires Xcode command-line +tools. Windows requires Windows 10 1809+ / 11 and a supported Rust linker toolchain. ```powershell git clone https://github.com/Kuddev/pebrel diff --git a/README.md b/README.md index 5525d8b..c192418 100644 --- a/README.md +++ b/README.md @@ -1,513 +1,148 @@

- Pebrel — 钛银 / Titanium + Pebrel Titanium icon

Pebrel

-Repository / 仓库:[Kuddev/pebrel](https://github.com/Kuddev/pebrel)。 -Existing `Kuddev/nebula` repository and Git links redirect here. -旧 `Kuddev/nebula` 仓库与 Git 链接会自动跳转到新地址。 - -Contributing / 参与贡献:[Guide / 指南](CONTRIBUTING.md) · [Architecture / 架构](docs/architecture.md) · [Engineering contracts / 工程规范](docs/project-constraints.md) -

- A GPU-accelerated terminal for Windows that keeps your sessions alive — close the window, your claude conversation survives.
- 一款 GPU 加速的 Windows 终端:关闭窗口不杀会话,重新打开,你的 claude 对话原样回来。 + A GPU-accelerated terminal, SSH workspace, and home for your AI CLI sessions.

- Rust - OpenGL - Windows - PowerShell - License + Built with Rust and GPUI for Windows, macOS, and Linux.
+ Split terminals · SSH & SFTP · Claude Code & Codex workflows · Native document reader

- Stars - Forks - Last commit - linux.do + Latest release + Windows, macOS, and Linux + GPL-3.0 license

- English · 简体中文 + Download the latest release

- Pebrel horizontal title-bar tabs and translucent background -

- -

- Claude Code running in Pebrel -

- -

- - Pebrel with OpenCode, Claude Code, and Codex in split panes + English · 简体中文

--- -## English - -### ✨ About - -Pebrel (formerly Nebula) is a terminal emulator for Windows, built in Rust on a -GPU-accelerated rendering core and designed around one idea: **your terminal -sessions are too valuable to die with a window**. It pairs a tmux-style -resident session model with a glass UI, an AI-CLI-aware sidebar, and a shell -experience that works without extra setup. - -### 🚀 Features - -**Sessions that survive** - -- **Session residency** — closing the window *detaches* instead of killing: - every PTY (your running `claude`, builds, SSH sessions) keeps running in a - resident process. Launch Pebrel again and the window re-attaches — same - processes, same scrollback, mid-conversation. -- **Cold session restore** — if the resident process is gone (reboot, crash), - the next launch still restores your tab layout and per-tab working - directories from a continuously autosaved snapshot, with a crash-loop - breaker. -- **Single instance** — a second launch hands over to the running instance - instead of piling up windows. - -**Built for AI-CLI workflows** -

- - AI-aware sidebar + Terminal tabs in Pebrel

-- **Real brand marks in the sidebar** — a tab running `claude` shows the - actual Anthropic starburst; `codex` shows the OpenAI blossom, tinted to the - theme. Other programs get Nerd Font icons (`gemini`, `copilot`, `git`, - `vim`, `cargo`, …). -- **Live turn state, wired to the source** — Pebrel installs Claude Code - hooks (and Codex notify) pointing at a bundled bridge - (`pebrel-hook.exe`, dependency-free): prompt submitted → spinner; turn - finished → dot + toast; Claude needs your input → toast carrying the actual - message text. Delivered over a local named pipe, with no shell integration - required. -- **Click-to-focus notifications** — every toast knows which pane raised it: - click one and Pebrel comes to the foreground, switches to that tab and - focuses that split. -- **Zero setup, self-healing** — the hook entries install on first boot and - re-install themselves if a config switcher rewrites the file (a watcher on - the config directory re-applies them). Scoped by environment: claude - running in any other terminal is untouched. `pebrel setup-ai --remove` - undoes everything. -- **Plays nice with existing notifiers** — codex has a single notify slot; - Pebrel wraps it (`--chain`) instead of stealing it, so a pre-existing - notifier keeps firing. -- **Fallback signals** — OSC 133 command tracking + BEL still cover every - other CLI: long builds toast on completion, with their duration. -- **Native SSH sessions** — saved hosts open directly through Pebrel's Rust SSH - transport: no wrapper shell and no external console window. Host aliases, - usernames, ports and identity files are resolved from `~/.ssh/config`; - authentication supports standard private keys and certificates, encrypted-key - passphrases, Windows Credential Manager passwords, and keyboard-interactive/MFA. - Accepted host keys use the standard `known_hosts` store, and additional tabs - reuse an already authenticated - connection to the same `user@host:port` for a faster second shell. -- **Built-in SFTP transfers** — open a remote file drawer from any saved SSH - host and reuse its authenticated connection. Browse or type remote paths, - filter entries, upload and download files or folders, create and rename - folders, recursively delete, follow symlink targets, and cancel transfers - with visible progress and errors. -- **AI-aware over SSH** — remote Hook envelopes can return through a - per-channel, randomly authenticated private OSC bridge. Pebrel validates the - channel token, replaces any remote pane identifier with the local pane, and - routes the event through the existing sidebar and Windows notification path. - The `pebrel ssh` compatibility command remains available for forwarding, - query and explicit-command forms that need the system SSH client. +## One Workspace -

- Pebrel native SSH session -

+Pebrel (formerly Nebula) brings local shells, remote hosts, files, and AI command-line +tools into a native desktop workspace. Arrange terminals in tabs and splits, follow +each agent's activity, and read its output without leaving the application. -**Performance & correctness** +### Terminals and Sessions -- **Instrumented startup** — the boot path is fully traceable - (`PEBREL_BOOT_TRACE=1`); no shell profile is loaded and history loads - lazily. -- **Modern ConPTY host** — ships the side-by-side ConPTY host for correct - resize behavior, with its startup handshake (DA1) pre-primed so a new tab - doesn't stall on that round-trip. -- **Coalesced resizing** — interactive drags resize the grid only; the PTY - learns its final size once, so full-screen TUIs don't smear redraws into - scrollback. +- Sidebar or top tabs, draggable split panes, and per-pane working directories. +- Saved workspace layouts and optional AI conversation restoration. On WSL and + Linux, Codex session metadata can supply an ID when its hook has not reported one. +- On Windows, optional background residency keeps running sessions alive when you + close the window. Restoring a conversation after the process exits is a separate + feature and requires a supported CLI and a usable session identity. +- History and path completions, configurable keybindings, and integrated shell prompts. -**Shell experience** +### SSH and Files -- **Inline ghost-text completions** — fish-style dim suggestions from command - history and filesystem paths; accept with `→` or `Tab`. -- **Persistent indexed history** — commands stored as JSONL under - `%APPDATA%\Pebrel`, shared across sessions, with prefix hints. -- **Powerline prompt built in** — themed gradient prompt with git branch and - clock, for PowerShell and Git Bash, no plugins to install. -- **Quality-of-life fixes** — unquoted `cd D:/Program Files` just works, bare - `$env:KEY=value` assignments are auto-quoted, `ls` gets colors and - clickable OSC 8 hyperlinks. +- Saved hosts, SSH config aliases, proxy and jump-host options, private-key and + keyboard-interactive authentication, and host-key verification. +- SFTP browsing, uploads and downloads, folder transfers, progress, and cancellation. +- Local file browsing and Git actions alongside your terminals. +- Duplicated WSL and SSH tabs retain their known working directory. -**Markdown that renders like GitHub** +### AI CLI Workflows -

- Pebrel rendering its own README: centred logo, badge row, and an embedded screenshot -

+- Claude Code, Codex, and other recognized CLIs have their own icons and activity + states. Supported hook events provide more precise progress and attention signals. +- Notifications follow their source pane; clicking one returns you to that terminal. +- Captured Claude Code and Codex answers open in a reader with Markdown, formulas, + source text, and local image previews. +- Clipboard images are saved as PNG files and their paths inserted into local, + WSL, or SSH sessions. Inline terminal images require the CLI to emit the supported + OSC 1337 protocol; image attachment previews depend on the CLI itself. -- **Markdown/GFM document tabs** — open Markdown, JSON, and plain-text files - directly from the file drawer with headings, lists, tables, code blocks, - links, quotes, word wrapping, and virtualized scrolling. -- **Images from anywhere** — remote `http(s)` URLs, `data:` URIs, absolute - paths, and paths relative to the document all resolve, so badge rows, logos, - and embedded screenshots land exactly where the file puts them. The - screenshot above is Pebrel rendering its own README. -- **The HTML that real READMEs use** — `

` centres its - content, several `` badges in one paragraph stay on a single row instead - of breaking into separate blocks, `
` breaks in document order, and - `` link badges survive as clickable images. -- **CJK-correct soft wraps** — CommonMark soft line breaks join as spaces, but - adjacent CJK characters join directly, so Chinese paragraphs don't pick up - the stray gaps other viewers insert. +### A Native, Configurable Interface -**Documents & native mathematics** +- GPU-accelerated GPUI interface, light and dark themes, backgrounds, and opacity controls. +- Application icon palettes and eleven UI language choices with English fallback + for untranslated text. +- Searchable settings, a command palette, and Lua configuration with validation and + live reload. Existing TOML configuration remains supported. +- Markdown document tabs and native mathematical typesetting without a WebView. -

- Native mathematics rendered in a Pebrel Markdown document -

+
+More screenshots -- **Native TeX mathematics** — inline `$...$` and display `$$...$$` formulas - are parsed and laid out entirely in Rust, then drawn through cached Latin - Modern Math glyphs and GPU rule quads. Fractions, radicals, scripts, limits, - integrals, matrices, scalable delimiters, Greek letters, and common symbols - render without a WebView, JavaScript runtime, or external TeX process. -- **Bounded document rendering** — oversized formulas fit the reading column, - long prose and failed formulas wrap, and parser, layout, cache, atlas, and - per-frame work all have explicit limits. See the - [math rendering fixture](docs/math-rendering-test.md) for the screenshot and - regression set. +

Claude Code session

+

AI CLI split panes

+

AI activity sidebar

+

Native SSH session

+

Native formula rendering

+

Application themes

-**Interface** +
-

- - Themes -

+## Download -- **Glass chrome & seven themes** — Pebrel plus three matched light/dark - pairs: Silver Light / Steel Dark, Limestone / Coal Dark, Linen Light / - Moss Dark. One skin system drives chrome, prompt and dialogs; every theme - persists across restarts. -- **Tabs & splits** — sidebar tabs with drag-to-reorder and drag-to-dock into - splits; unfocused panes dim instead of growing borders. -- **Files, Git and SFTP drawers** — browse local or remote files without - leaving the terminal. The Git drawer can stage, commit, pull fast-forward - updates, and push. -- **Quick terminal** — global Ctrl+` drops a Quake-style - terminal from the top edge. -- **Settings tab & command palette** — settings opens as a normal reusable tab, - with themes, language, background image/opacity, shell choice and completion - behavior; all persisted. -- **Inline images** — OSC 1337 image protocol support, lazily anchored to - scrollback rows. +Choose a package from the **[latest release](https://github.com/Kuddev/pebrel/releases/latest)**. -### 📥 Install +| System | Architecture | Packages | +| --- | --- | --- | +| Windows 10 1809+ / 11 | x64 | Installer `.exe`, portable `.zip` | +| Linux, glibc 2.35+ | x64 | `.AppImage`, `.deb`, portable `.tar.gz` | +| macOS, deployment target 14+ | Apple Silicon / arm64 | `.dmg` | +| macOS, deployment target 14+ | Intel / x64 | `.dmg` | -Pebrel embeds **Maple Mono Normal NF CN** for terminal text, powerline symbols, -and icons. The Windows installer also offers system font installation. -The font is licensed under SIL OFL 1.1. +Package names follow `Pebrel-v--`. Windows installers +end in `-setup.exe`. A legacy-named installer is also supplied for older automatic +update clients. -**Windows packages** are listed on -[Releases](https://github.com/Kuddev/pebrel/releases/latest). Pebrel 1.6 uses -`Pebrel--windows-x64-setup.exe` for installation and -`Pebrel-v-windows-x64.zip` for portable use. Extract the ZIP and run -`pebrel.exe`; keep `runtime/`, `docs/`, `skills/`, and `licenses/` in place. -Older releases retain their original asset names. See the -[installation guide](INSTALL.md) for upgrade and configuration migration details. +On Windows, run the installer or extract the ZIP and launch `pebrel.exe`, keeping +the bundled directories together. On Linux, install the DEB or make the AppImage +executable. On macOS, open the matching DMG and drag Pebrel into Applications. +Ad-hoc-signed macOS builds may require **Open Anyway** in **System Settings > +Privacy & Security** on first launch. Native macOS CI runs on macOS 15; the +deployment target alone does not establish validation on every older OS version. -**From source** +Windows currently also provides tray residency, the global quick-terminal hotkey, +automatic local AI-hook setup, and automatic update installation. These integrations +are not yet available on Linux or macOS. See [installation details](INSTALL.md) for +platform requirements and upgrading an existing Nebula installation. -```powershell +## Configure + +```sh +pebrel config init --language en-US +pebrel config check +``` + +The generated Lua configuration uses `require 'pebrel'` and +`pebrel.config_builder()`. Invalid reloads retain the last valid configuration. +See the [Lua configuration guide](docs/lua-configuration.md) for settings and examples. + +## Build + +With the pinned Rust toolchain and the platform dependencies installed: + +```sh cargo build --release --locked -p nebula --bin pebrel --features gpui-shell ``` -### Lua Configuration +The internal Cargo package is still named `nebula`; the application and command +are `pebrel`. GPUI is the product interface. The older renderer is available only +through the explicit `legacy-shell` feature. -Pebrel ships a vendored Lua 5.4 runtime with `require 'pebrel'` and -`pebrel.config_builder()`. Generate an annotated template with -`pebrel config init --language system|zh-CN|en-US`, then validate it without -opening the GUI using `pebrel config check`. Module `require` paths participate -in live reload; invalid edits keep the last-known-good configuration active. -Existing TOML files remain supported. See the -[Lua configuration guide](docs/lua-configuration.md) for APIs, discovery order, -Windows/Linux paths, arrays, modules, and reload behavior. +## Acknowledgements -### 🧩 Tech Stack - -| Layer | Tech | -| --- | --- | -| Language | Rust (2024 edition) | -| Rendering | OpenGL / OpenGL ES 2.0+, custom glyph & UI quad renderers | -| Terminal core | GPU-resident grid + VTE escape-sequence parsing, on an Alacritty-derived foundation | -| Session model | Resident mux process, loopback attach protocol | -| Shell integration | PowerShell + PSReadLine, Git Bash; OSC 7/8/9/133/1337 | -| Document math | pulldown-cmark + pulldown-latex + OpenType MATH (Latin Modern Math) | -| Fonts | Maple Mono Normal NF CN (Nerd Font glyphs, CJK-aware) | - -### 📦 Requirements - -- At least OpenGL ES 2.0 -- Windows 10 (1809+) / 11 with ConPTY support - -### Contact - -Questions and feedback: [fickleheartedkeys@163.com](mailto:fickleheartedkeys@163.com) - -### 🙏 Acknowledgements - -Pebrel's terminal core — the grid, the VTE escape-sequence parsing, and the PTY -plumbing — is derived from -**[Alacritty](https://github.com/alacritty/alacritty)**, a masterpiece of -terminal engineering. Sincere thanks to its maintainers and contributors; -Pebrel would not exist without their work. - -Pebrel's shell is built on **[GPUI](https://github.com/zed-industries/zed)** — the -GPU-accelerated UI framework developed by the [Zed](https://zed.dev) team — together with -**[gpui-component](https://github.com/longbridge/gpui-component)** from -[Longbridge](https://github.com/longbridge), which supplies the widget library. Both are -Apache-2.0 licensed and are used through lightly patched forks pinned in the workspace -manifest. Thank you for releasing this work as open source — Pebrel's interface would not -look the way it does without it. - -### 📜 License - -Released under the [GNU General Public License v3.0](LICENSE). - -Parts of Pebrel are derived from upstream open-source projects; their original -license and copyright notices are preserved in -[THIRD-PARTY-NOTICES](THIRD-PARTY-NOTICES). The work as a whole is distributed -under GPL-3.0. - ---- - -## 简体中文 - -### ✨ 简介 - -Pebrel(原名 Nebula)是一款 Windows 上的终端模拟器,以 Rust 编写,构建在 GPU 加速渲染 -内核之上,并围绕一个理念设计:**终端会话太宝贵,不该随窗口一起消失**。它 -把 tmux 式的常驻会话模型、玻璃质感界面、AI CLI 感知侧边栏和无需额外配置 -的 shell 体验组合在一起。 - -### 🚀 功能特性 - -**会话永生** - -- **会话驻留** — 关闭窗口是*分离*而不是杀死:所有 PTY(正在跑的 - `claude`、构建、SSH)继续在常驻进程里运行。再次启动 Pebrel,窗口原样接回 - —— 同样的进程、同样的回滚缓冲、对话进行到一半也不丢。 -- **冷恢复** — 常驻进程不在了(重启、崩溃)也没关系:下次启动从持续自动保 - 存的快照恢复标签布局和每个标签的工作目录,并带崩溃循环保护。 -- **单实例** — 重复启动会交棒给运行中的实例,不会堆一堆窗口。 - -**为 AI CLI 工作流而生** - -

- - AI 感知侧边栏 -

- -- **侧栏显示真品牌标识** — 跑 `claude` 的标签显示 Anthropic 珊瑚星芒, - `codex` 显示 OpenAI 花结(贴图渲染、跟随主题染色);其余程序用 - Nerd Font 图标(`gemini`、`copilot`、`git`、`vim`、`cargo`……)。 -- **回合状态实时直连** — Pebrel 自动安装 Claude Code hooks(及 Codex - notify),指向随包的桥接器 `pebrel-hook.exe`(无第三方依赖): - 提交 prompt → 转圈;回合完成 → 圆点 + 通知;claude 要你确认 → 通知里 - 带消息原文。经本地命名管道传递,无需任何 shell 集成。 -- **通知点击直达** — 每条 toast 都知道自己来自哪个 pane:点一下,Pebrel - 前置、切到那个标签、聚焦那个分屏。 -- **零配置、自愈合** — hook 条目首次启动自动写入;被配置切换工具覆盖后 - 会自动补回(配置目录上有监视器重新写入)。作用域由环境变量限定:其他 - 终端里的 claude 完全不受影响。`pebrel setup-ai --remove` 一键撤销。 -- **不抢占已有 notifier** — codex 只有一个 notify 槽位,Pebrel 用 - `--chain` 包装而非顶掉:原有通知程序照常触发。 -- **兜底信号** — OSC 133 命令跟踪 + BEL 覆盖其余所有 CLI:长构建完成也 - 弹通知,并带耗时。 -- **原生 SSH 会话** — 保存的主机现在直接通过 Pebrel 的 Rust SSH 传输连接, - 不再经过包装 Shell,也不会弹出外部控制台窗口。主机别名、用户名、端口和 - IdentityFile 会从 `~/.ssh/config` 解析;认证覆盖标准私钥与证书、加密密钥 - 口令、Windows 凭据管理器密码以及 keyboard-interactive/MFA。主机密钥写入 - 标准 `known_hosts`;同一 `user@host:port` 再开标签页会复用已认证连接,让第二个 - Shell 更快出现。 -- **内置 SFTP 传输** — 从已保存 SSH 主机即可打开远端文件抽屉,并复用已认证 - 连接。支持浏览或手动输入远端路径、筛选、上传/下载文件与文件夹、新建与重命名 - 文件夹、递归删除、跟随符号链接目标,以及带进度、取消和错误提示的后台传输。 -- **SSH 里也 AI 感知** — 远端 Hook 信封可通过每通道随机认证的私有 OSC 桥 - 返回。Pebrel 会校验通道令牌,用本地 Pane 身份覆盖远端字段,再复用现有侧栏 - 状态和 Windows 通知链路。需要端口转发、配置查询或显式远程命令时,仍可使用 - `pebrel ssh` 兼容命令交给系统 SSH 客户端处理。 - -

- Pebrel 原生 SSH 会话 -

- -**性能与正确性** - -- **启动全程可观测** — 启动路径全程打点(`PEBREL_BOOT_TRACE=1`),不加载 - shell profile,历史惰性加载。 -- **现代 ConPTY 宿主** — 内置 side-by-side ConPTY 宿主保证 resize 行为正确, - 并预热其启动握手(DA1),让新标签不必卡在这一次往返上。 -- **合并式 resize** — 拖动窗口时只调整网格,PTY 在拖动结束后一次性获知最终 - 尺寸,全屏 TUI 不会把重绘刷进历史。 - -**Shell 体验** - -- **行内幽灵补全** — fish 风格暗色建议,来自命令历史与文件路径, - `→` 或 `Tab` 接受。 -- **持久化索引历史** — 命令以 JSONL 存于 `%APPDATA%\Pebrel`,跨会话共享, - 提供前缀提示。 -- **内置 powerline 提示符** — 主题化渐变提示符,含 git 分支与时钟, - PowerShell 与 Git Bash 皆可用,无需安装任何插件。 -- **顺手的小修正** — 不带引号的 `cd D:/Program Files` 直接可用,裸 - `$env:KEY=value` 自动加引号,`ls` 带颜色和可点击的 OSC 8 超链接。 - -**Markdown 解析:照着 GitHub 的样子显示** - -

- Pebrel 正在渲染自己的 README:居中 logo、徽章横排、内嵌截图 -

- -- **Markdown/GFM 文档标签页** — 可以从文件抽屉直接打开 Markdown、JSON 和 - 纯文本文件,原生显示标题、列表、表格、代码块、链接、引用、自动换行和虚拟滚动。 -- **图片来源不挑** — 远程 `http(s)` 链接、`data:` URI、绝对路径,以及相对文档 - 自身位置的路径都能解析,徽章行、logo 和内嵌截图都落在文件让它们出现的地方。 - 上面这张图就是 Pebrel 在渲染自己的 README。 -- **真实 README 会用到的那些 HTML** — `

` 居中生效,同一段里 - 的多枚 `` 徽章横排显示而不会被拆成若干块,`
` 按文档顺序断行, - `` 形式的链接徽章不会整个丢失。 -- **中文软换行不留空隙** — CommonMark 的软换行按规范并作空格,但相邻中日韩 - 字符直接相连,中文段落不会出现别的阅读器常见的那些多余空格。 - -**文档与原生数学公式** - -

- Pebrel Markdown 文档中的原生数学公式 -

- -- **原生 TeX 数学排版** — 行内 `$...$` 与块级 `$$...$$` 公式全部由 Rust - 解析和布局,再使用缓存的 Latin Modern Math 字形与 GPU 规则线 quad 绘制。 - 分式、根式、上下标、极限、积分、矩阵、伸缩括号、希腊字母和常用符号均不经过 - WebView、JavaScript 运行时或外部 TeX 进程。 -- **有界文档渲染** — 超宽公式会收敛到阅读列,长文本和失败公式会继续换行; - 解析、布局、缓存、字形图集和每帧工作量均有明确上限。截图与回归内容见 - [数学渲染测试文档](docs/math-rendering-test.md)。 - -**界面** - -

- - 七套主题 -

- -- **玻璃质感与七套主题** — Pebrel + 三对明暗成组主题:Silver Light / - Steel Dark、Limestone / Coal Dark、Linen Light / Moss Dark。一套皮肤 - 系统驱动 chrome、提示符与弹窗,主题选择跨重启持久化。 -- **标签与分屏** — 侧边栏标签支持拖拽排序、拖入终端区四方位分屏;非焦点 - 面板压暗而非描边。 -- **文件、Git 与 SFTP 抽屉** — 不离开终端即可浏览本地或远端文件;Git 抽屉 - 支持暂存、提交、仅快进拉取和推送。 -- **快速终端** — 全局 Ctrl+` 从屏幕顶部滑出 - Quake 式终端。 -- **设置标签页与命令面板** — 设置像普通标签页一样打开和复用,支持主题、 - 跟随系统/简体中文/English、背景图/透明度、Shell 与补全行为,并跨重启持久化。 -- **行内图片** — 支持 OSC 1337 图片协议,惰性锚定到回滚行。 - -### 📥 安装 - -Pebrel 内嵌 **Maple Mono Normal NF CN**,用于终端文字、powerline 符号与图标。 -Windows 安装器同时提供可选的系统字体安装。字体使用 SIL OFL 1.1 许可。 - -**Windows 安装包**见 -[Releases](https://github.com/Kuddev/pebrel/releases/latest)。Pebrel 1.6 的安装器命名为 -`Pebrel--windows-x64-setup.exe`,便携包命名为 -`Pebrel-v-windows-x64.zip`。解压 ZIP 后运行 `pebrel.exe`,保留 -`runtime/`、`docs/`、`skills/` 与 `licenses/` 目录。旧版本资产保留原文件名。 -升级与配置迁移详见[安装指南](INSTALL.md)。 - -**从源码构建** - -```powershell -cargo build --release --locked -p nebula --bin pebrel --features gpui-shell -``` - -### Lua 配置 - -Pebrel 内置 Lua 5.4,使用 `require 'pebrel'` 与 -`pebrel.config_builder()`。运行 -`pebrel config init --language system|zh-CN|en-US` 可生成带完整注释的模板, -`pebrel config check` 可在不启动 GUI 的情况下检查语法与字段。通过 `require` -载入的模块会参与热重载;配置错误时继续使用上一份有效配置。已有 TOML 仍兼容。 -路径优先级、Windows/Linux 位置、数组、模块与重载说明见 -[Lua 配置指南](docs/lua-configuration.md)。 - -### 🧩 技术栈 - -| 层级 | 技术 | -| --- | --- | -| 语言 | Rust(2024 edition) | -| 渲染 | OpenGL / OpenGL ES 2.0+,自定义字形与 UI 四边形渲染器 | -| 终端内核 | GPU 常驻网格 + VTE 转义序列解析,基于 Alacritty 衍生的地基 | -| 会话模型 | 常驻 mux 进程,环回 attach 协议 | -| Shell 集成 | PowerShell + PSReadLine、Git Bash;OSC 7/8/9/133/1337 | -| 文档数学 | pulldown-cmark + pulldown-latex + OpenType MATH(Latin Modern Math) | -| 字体 | Maple Mono Normal NF CN(Nerd Font 图标,支持 CJK) | - -### 📦 环境要求 - -- 至少 OpenGL ES 2.0 -- Windows 10(1809+)/ 11,需 ConPTY 支持 - -### 联系方式 - -问题与反馈:[fickleheartedkeys@163.com](mailto:fickleheartedkeys@163.com) - -### 🙏 致谢 - -Pebrel 的终端内核——网格、VTE 转义序列解析与 PTY 管路——衍生自 -**[Alacritty](https://github.com/alacritty/alacritty)**,终端工程的杰作。 -衷心感谢它的维护者与贡献者们;没有他们的工作就没有 Pebrel。 - -Pebrel 的界面层构建在 **[GPUI](https://github.com/zed-industries/zed)** 之上——由 -[Zed](https://zed.dev) 团队开发的 GPU 加速 UI 框架——并使用 -[Longbridge](https://github.com/longbridge) 的 -**[gpui-component](https://github.com/longbridge/gpui-component)** 作为组件库。两者均以 -Apache-2.0 许可发布,Pebrel 通过带少量补丁的 fork 使用,版本在 workspace 清单中精确锁定。 -感谢把这些工作开源——没有它,Pebrel 现在的界面不会是这个样子。 - -### 📜 许可证 - -基于 [GNU 通用公共许可证 v3.0(GPL-3.0)](LICENSE) 发布。 - -Pebrel 的部分代码衍生自上游开源项目,其原始许可与版权声明保留于 -[THIRD-PARTY-NOTICES](THIRD-PARTY-NOTICES)。整体作品以 GPL-3.0 分发。 - ---- - -## 🔗 友情链接 / Community - -- **[linux.do](https://linux.do)** — 新的理想型社区 / a thriving developer community. - ---- - -## ⭐ Star History - - - - - - Star History Chart - - +Pebrel builds on [Alacritty](https://github.com/alacritty/alacritty), +[GPUI](https://github.com/zed-industries/zed), and +[gpui-component](https://github.com/longbridge/gpui-component). Terminal text uses +Maple Mono, and native formulas use Latin Modern Math. Upstream copyright and +license notices are preserved in `THIRD-PARTY-NOTICES` and `licenses/`. diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 0000000..beb6c8c --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,133 @@ +

+ Pebrel 钛银图标 +

+ +

Pebrel

+ +

+ GPU 加速终端、SSH 工作区,以及你的 AI CLI 会话空间。 +

+ +

+ 使用 Rust 与 GPUI 构建,面向 Windows、macOS 和 Linux。
+ 分屏终端 · SSH 与 SFTP · Claude Code 与 Codex 工作流 · 原生文档阅读 +

+ +

+ 最新版本 + Windows、macOS 与 Linux + GPL-3.0 许可证 +

+ +

+ 下载最新版本 +

+ +

+ English · 简体中文 +

+ +--- + +

+ Pebrel 终端标签 +

+ +## 一个工作区 + +Pebrel(原名 Nebula)把本地 Shell、远程主机、文件和 AI 命令行工具放进同一个原生桌面工作区。 +通过标签和分屏组织终端,关注各个 Agent 的活动,并直接在应用中阅读输出。 + +### 终端与会话 + +- 侧栏或顶部标签、可拖拽分屏,以及各窗格独立的工作目录。 +- 保存工作区布局,可选恢复 AI 对话。在 WSL 与 Linux 中,Codex 的 hook 未上报 ID 时, + 可从当前会话元数据补全身份。 +- Windows 下可选择后台驻留,让关窗后的会话继续运行。进程退出后重新接续已保存的对话 + 是另一项功能,需要 CLI 支持恢复,并且有可用的会话身份。 +- 历史记录与路径补全、自定义快捷键,以及集成的 Shell 提示符。 + +### SSH 与文件 + +- 保存主机、读取 SSH 配置别名、设置代理与跳板,支持私钥和交互式认证,并验证主机密钥。 +- SFTP 文件浏览、上传下载、文件夹传输、进度显示与取消。 +- 在终端旁浏览本地文件并执行 Git 操作。 +- 复制 WSL 与 SSH 标签时保留已知工作目录。 + +### AI CLI 工作流 + +- Claude Code、Codex 等可识别的 CLI 显示各自图标与活动状态;受支持的 hook 事件提供更准确的进度和待处理信号。 +- 通知跟随来源窗格,点击即可返回对应终端。 +- 已捕获的 Claude Code 与 Codex 回答可在阅读器中打开,支持 Markdown、公式、原文和本地图片预览。 +- 剪贴板图片会保存为 PNG,并把路径插入本地、WSL 或 SSH 会话。终端内直接显示图片需要 CLI + 输出受支持的 OSC 1337 协议;附件缩略图取决于 CLI 自身。 + +### 原生、可配置的界面 + +- GPU 加速的 GPUI 界面、浅色与深色主题、背景及不透明度设置。 +- 应用图标配色与十一种界面语言,未翻译内容回退英文。 +- 可搜索的设置、命令面板,以及支持检查和热重载的 Lua 配置;已有 TOML 配置仍可使用。 +- Markdown 文档标签与原生数学公式排版,不依赖 WebView。 + +
+更多截图 + +

Claude Code 会话

+

AI CLI 分屏

+

AI 活动侧栏

+

原生 SSH 会话

+

原生公式排版

+

应用主题

+ +
+ +## 下载 + +从 **[最新发布](https://github.com/Kuddev/pebrel/releases/latest)** 选择对应安装包。 + +| 系统 | 架构 | 安装包 | +| --- | --- | --- | +| Windows 10 1809+ / 11 | x64 | `.exe` 安装器、`.zip` 便携包 | +| Linux,glibc 2.35+ | x64 | `.AppImage`、`.deb`、`.tar.gz` 便携包 | +| macOS,部署目标为 14+ | Apple Silicon / arm64 | `.dmg` | +| macOS,部署目标为 14+ | Intel / x64 | `.dmg` | + +安装包统一采用 `Pebrel-v<版本>-<系统>-<架构>` 命名,Windows 安装器以 `-setup.exe` 结尾。 +同时提供旧名称的兼容安装器,供旧版自动更新客户端使用。 + +Windows 可运行安装器,或解压 ZIP 后启动 `pebrel.exe`,保留随包目录结构。 +Linux 可安装 DEB,或为 AppImage 添加执行权限。macOS 打开对应 DMG,把 Pebrel 拖入“应用程序”。 +采用临时签名的 macOS 包首次启动可能需要在“系统设置 > 隐私与安全性”中选择“仍要打开”。 +原生 macOS CI 运行在 macOS 15 上,部署目标并不代表每个较早系统版本都已通过运行验证。 + +系统托盘驻留、全局快速终端热键、自动配置本地 AI hook 和自动安装更新目前由 Windows 提供, +Linux 与 macOS 尚未提供这些集成。平台要求和旧版 Nebula 升级步骤见[安装说明](INSTALL.md)。 + +## 配置 + +```sh +pebrel config init --language zh-CN +pebrel config check +``` + +生成的 Lua 配置使用 `require 'pebrel'` 与 `pebrel.config_builder()`。 +重载失败时继续使用上一份有效配置。设置与示例见 [Lua 配置指南](docs/lua-configuration.md)。 + +## 构建 + +安装项目固定的 Rust 工具链与对应平台依赖后运行: + +```sh +cargo build --release --locked -p nebula --bin pebrel --features gpui-shell +``` + +内部 Cargo 包名仍为 `nebula`,应用与命令名为 `pebrel`。GPUI 是正式产品界面, +旧渲染器仅在显式启用 `legacy-shell` 时使用。 + +## 致谢 + +Pebrel 基于 [Alacritty](https://github.com/alacritty/alacritty)、 +[GPUI](https://github.com/zed-industries/zed) 与 +[gpui-component](https://github.com/longbridge/gpui-component) 构建。 +终端使用 Maple Mono 字体,原生公式使用 Latin Modern Math。 +上游版权与许可证声明保留在 `THIRD-PARTY-NOTICES` 和 `licenses/` 中。 diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..2f2bccf --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,25 @@ +# Security Policy + +## Reporting a Vulnerability + +Please report suspected security vulnerabilities privately to +**fickleheartedkeys@163.com** before posting technical details in a public issue. + +Include the affected Pebrel version, operating system, reproduction steps, expected +and observed behavior, and the potential impact. A small reproduction using test +data is preferable to logs containing real credentials or private conversations. + +Do not send passwords, access tokens, private keys, or unrelated personal data. +Use public issues for ordinary bugs that do not expose a security vulnerability. + +## Versions + +Use the latest stable release when checking whether a problem is still present. +Mention if it also affects a Preview build or the current `main` branch. +Older releases remain available for reference; availability does not imply +that every older version receives fixes. + +## Follow-up + +The maintainers will review the report and coordinate any necessary fix and public +disclosure. This policy does not promise a response deadline or a bug bounty. diff --git a/scripts/build-installer.ps1 b/scripts/build-installer.ps1 index 600cb71..2d3040c 100644 --- a/scripts/build-installer.ps1 +++ b/scripts/build-installer.ps1 @@ -65,6 +65,7 @@ $requiredFiles = @( (Join-Path $targetRoot 'conpty.dll'), (Join-Path $targetRoot 'OpenConsole.exe'), (Join-Path $repo 'README.md'), + (Join-Path $repo 'README.zh-CN.md'), (Join-Path $repo 'CHANGELOG.md'), (Join-Path $repo 'INSTALL.md'), (Join-Path $repo 'docs\lua-configuration.md'), diff --git a/scripts/installer.iss b/scripts/installer.iss index 5d83dc4..c2e09e4 100644 --- a/scripts/installer.iss +++ b/scripts/installer.iss @@ -98,6 +98,7 @@ Name: "autostart"; Description: "{cm:AutoStart}"; GroupDescription: "{cm:Additio [Files] Source: "{#BuildRoot}\pebrel.exe"; DestDir: "{app}"; Flags: ignoreversion Source: "{#RepoRoot}\README.md"; DestDir: "{app}"; Flags: ignoreversion +Source: "{#RepoRoot}\README.zh-CN.md"; DestDir: "{app}"; Flags: ignoreversion Source: "{#BuildRoot}\pebrel-hook.exe"; DestDir: "{app}\runtime"; Flags: ignoreversion Source: "{#BuildRoot}\conpty.dll"; DestDir: "{app}\runtime"; Flags: ignoreversion Source: "{#BuildRoot}\OpenConsole.exe"; DestDir: "{app}\runtime"; Flags: ignoreversion diff --git a/scripts/package-linux.sh b/scripts/package-linux.sh index 854ff75..66ace7a 100644 --- a/scripts/package-linux.sh +++ b/scripts/package-linux.sh @@ -141,7 +141,7 @@ else fi icon_source="$repo/extra/logo/nebula.png" for required in "$desktop_source" "$metainfo_source" "$icon_source" \ - "$repo/README.md" "$repo/CHANGELOG.md" "$repo/INSTALL.md" \ + "$repo/README.md" "$repo/README.zh-CN.md" "$repo/CHANGELOG.md" "$repo/INSTALL.md" \ "$repo/LICENSE" "$repo/THIRD-PARTY-NOTICES" \ "$repo/licenses/LICENSE-LUA" "$repo/licenses/LICENSE-MLUA" \ "$repo/licenses/LICENSE-LATIN-MODERN-MATH" \ @@ -233,6 +233,7 @@ install -m 0644 "$metainfo_source" \ convert "$icon_source" -resize 256x256 \ "$common_root/usr/share/icons/hicolor/256x256/apps/$desktop_id.png" install -m 0644 "$repo/README.md" "$common_root/usr/share/doc/$package_name/README.md" +install -m 0644 "$repo/README.zh-CN.md" "$common_root/usr/share/doc/$package_name/README.zh-CN.md" install -m 0644 "$repo/CHANGELOG.md" "$common_root/usr/share/doc/$package_name/CHANGELOG.md" install -m 0644 "$repo/INSTALL.md" "$common_root/usr/share/doc/$package_name/INSTALL.md" install -m 0644 "$repo/LICENSE" "$common_root/usr/share/doc/$package_name/licenses/LICENSE" diff --git a/scripts/package-macos.sh b/scripts/package-macos.sh index 0aa8c6c..a01068b 100644 --- a/scripts/package-macos.sh +++ b/scripts/package-macos.sh @@ -154,6 +154,7 @@ output_directory="$(cd "$output_directory" && pwd -P)" plist_source="$repo/packaging/macos/Info.plist" icon_source="$repo/extra/logo/nebula.png" for required in "$plist_source" "$icon_source" "$repo/README.md" \ + "$repo/README.zh-CN.md" \ "$repo/CHANGELOG.md" "$repo/INSTALL.md" "$repo/LICENSE" \ "$repo/THIRD-PARTY-NOTICES" "$repo/licenses/LICENSE-LUA" \ "$repo/licenses/LICENSE-MLUA" "$repo/licenses/LICENSE-LATIN-MODERN-MATH"; do @@ -254,6 +255,7 @@ EOF iconutil -c icns "$iconset" -o "$resources/pebrel.icns" install -m 0644 "$repo/README.md" "$resources/docs/README.md" +install -m 0644 "$repo/README.zh-CN.md" "$resources/docs/README.zh-CN.md" install -m 0644 "$repo/CHANGELOG.md" "$resources/docs/CHANGELOG.md" install -m 0644 "$repo/INSTALL.md" "$resources/docs/INSTALL.md" install -m 0644 "$repo/LICENSE" "$resources/licenses/LICENSE" diff --git a/scripts/package-release.ps1 b/scripts/package-release.ps1 index ca30b18..400d7b8 100644 --- a/scripts/package-release.ps1 +++ b/scripts/package-release.ps1 @@ -51,6 +51,7 @@ $temporaryZip = Join-Path $outputRoot ".$PackageBrand-v$Version-windows-x64-$PID $manifest = [ordered]@{ 'pebrel.exe' = Join-Path $targetRoot 'pebrel.exe' 'README.md' = Join-Path $repo 'README.md' + 'README.zh-CN.md' = Join-Path $repo 'README.zh-CN.md' 'runtime/pebrel-hook.exe' = Join-Path $targetRoot 'pebrel-hook.exe' 'runtime/conpty.dll' = Join-Path $targetRoot 'conpty.dll' 'runtime/OpenConsole.exe' = Join-Path $targetRoot 'OpenConsole.exe' diff --git a/scripts/tests/package-release.tests.ps1 b/scripts/tests/package-release.tests.ps1 index 2f063bf..6226ccb 100644 --- a/scripts/tests/package-release.tests.ps1 +++ b/scripts/tests/package-release.tests.ps1 @@ -71,6 +71,7 @@ try { $expected = @( 'README.md' + 'README.zh-CN.md' 'docs/CHANGELOG.md' 'docs/INSTALL.md' 'docs/lua-configuration.md' @@ -94,8 +95,8 @@ try { } $rootFiles = @($actual | Where-Object { -not $_.Contains('/') }) - if (@(Compare-Object -ReferenceObject @('README.md', 'pebrel.exe') -DifferenceObject $rootFiles).Count -ne 0) { - throw "ZIP root must contain only README.md and pebrel.exe" + if (@(Compare-Object -ReferenceObject @('README.md', 'README.zh-CN.md', 'pebrel.exe') -DifferenceObject $rootFiles).Count -ne 0) { + throw "ZIP root must contain the English and Chinese READMEs and pebrel.exe" } & $packageScript -Version 'unreleased' -PackageBrand NebulaTerminal -SkipBuild -AllowStale -OutputDirectory $resolvedOutput -TargetDirectory $TargetDirectory From d13d5cb3667a46fe10a3bc698b4ad91da23de5b3 Mon Sep 17 00:00:00 2001 From: kuddev Date: Mon, 7 Sep 2026 18:51:57 +0800 Subject: [PATCH 21/31] docs: clarify release changes and credit linked issues and contributions --- CHANGELOG.md | 140 ++++++++++++++++------------------- docs/release-notes/v1.6.0.md | 140 ++++++++++++++++------------------- 2 files changed, 128 insertions(+), 152 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 02c2d91..4a8f1ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,101 +9,89 @@ Every release entry is provided in English and Simplified Chinese. ### English #### Added -- Release downloads now include Windows x64 ZIP and installer packages, Linux x64 AppImage, DEB and tar.gz packages, and separate macOS 14+ DMGs for Apple Silicon and Intel. macOS packages are ad-hoc signed; the first launch may require Open Anyway in System Settings > Privacy & Security. -- Pasting a clipboard image now writes it as a PNG and inserts its path into local, WSL, or SSH terminal sessions. WSL paths are translated and SSH images are uploaded before insertion; failures are reported, and results arriving after submission, interruption, or a session change are discarded. -- Added an application icon picker with 25 color palettes and light/dark previews. -- Added an opt-in terminal network proxy setting. On Windows, new local sessions inherit the system HTTP/HTTPS proxy when enabled; existing sessions keep their environment. Contributed by [@Sakyvo](https://github.com/Sakyvo) in [#94](https://github.com/Kuddev/pebrel/pull/94). -- Added per-host SSH proxy and jump-host options, with separate proxy credentials and a connection-route preview. -- Added an answer reader for captured Claude Code and Codex responses, with Markdown, formulas, source-text mode, and local image previews. -- Added UI language choices for Traditional Chinese, French, German, Spanish, Brazilian Portuguese, Italian, Russian, Japanese, and Korean alongside English and Simplified Chinese. Initial coverage includes navigation, common actions, appearance, and network controls; untranslated text falls back to English. -- Added a settings search that opens the matching section and a confirmed Restore defaults action that backs up preferences while retaining saved hosts and other user data. -- Added the Antigravity product icon to terminal tabs and the sidebar, including its common launcher aliases, while retaining its original colors in light and dark themes. -- Recognized binary terminal confirmation prompts now appear in notifications with Allow and Deny actions. An action is rejected when the prompt, session, or input state has changed, and each captured request can be handled only once. + +- Added Linux x64 packages in AppImage, DEB, and portable tar.gz formats, plus separate macOS DMGs for Apple Silicon and Intel, alongside the Windows installer and ZIP. RPM packages are not included. macOS packages use ad-hoc signing and may require Open Anyway in System Settings > Privacy & Security on first launch. Addresses [#87](https://github.com/Kuddev/nebula/issues/87). +- Added clipboard image paste for local, WSL, and SSH terminals. Pebrel saves the image as a PNG and inserts a path that the session can access; SSH images are uploaded first. Attachment thumbnails and images in a CLI's conversation still depend on that CLI. +- Added a reader for captured Claude Code and Codex answers, with Markdown, formulas, original text, and local image previews. +- Added separate SOCKS5/HTTP proxy and jump-host settings for each SSH host, with saved proxy credentials and a connection-route preview. Addresses [#90](https://github.com/Kuddev/nebula/issues/90). +- Added an optional Windows system-proxy setting for newly opened local terminals. It is off by default and does not change existing sessions. Contributed by [@Sakyvo](https://github.com/Sakyvo) in [#94](https://github.com/Kuddev/pebrel/pull/94). +- Added nine UI language choices alongside English and Simplified Chinese: Traditional Chinese, French, German, Spanish, Brazilian Portuguese, Italian, Russian, Japanese, and Korean. Untranslated text falls back to English. +- Added 25 application icon palettes with light and dark previews. Titanium is now the default icon. +- Added settings search and a confirmed Restore defaults action. Preferences are backed up before resetting, and saved hosts and other user data are retained. +- Added Allow and Deny actions to notifications for recognized terminal confirmation prompts. Actions stop being available when the prompt or session changes, and each request can be handled only once. #### Fixed -- With AI session restoration enabled, Codex sessions in WSL and Linux can recover the current conversation ID when no hook reported it, so reopening the workspace resumes that conversation. Window close and the application's Quit action allow a bounded wait for the identity before saving. Ambiguous identities are not selected; native Windows and macOS continue to use hook-reported session IDs. -- `Ctrl+C` copies and clears a terminal selection, and still sends an interrupt when no selection is present. Text inputs keep their own copy behavior. Contributed by [@Sakyvo](https://github.com/Sakyvo) in [#93](https://github.com/Kuddev/pebrel/pull/93). -- `Ctrl+Backspace` deletes a word in the supported legacy and kitty keyboard paths, including the managed PowerShell prompt. Contributed by [@Sakyvo](https://github.com/Sakyvo) in [#92](https://github.com/Kuddev/pebrel/pull/92). -- Running tab indicators keep animating when another window has focus; hidden or minimized windows stop requesting animation frames. -- File-name search discards results from an old root, updates after creates, renames and deletes, and handles canonical file-watcher paths without losing the visible directory root. -- Multiline confirmation text is measured with wrapping so longer messages fit the shared confirmation dialog. -- Antigravity activity and permission detection no longer lets old spinner output or ordinary prose mentioning approval mark an idle session as running or waiting for permission. Contributed by [@821869798](https://github.com/821869798) in [#96](https://github.com/Kuddev/pebrel/pull/96). -- Closing the last regular window and then quitting no longer replaces saved tabs with an empty snapshot. Explicitly closing all tabs still starts an empty workspace next time, and failed session writes are retried. -- SSH startup now waits for PTY and shell confirmation before showing Ready, supports cancellation during connection setup, and stops stalled startup stages within a bounded timeout. Unexpected disconnects retain the pane and its failure state; explicit shell exits still close normally. -- Duplicated WSL tabs retain the current guest directory and selected distribution. Duplicated SSH tabs retain the known remote working directory and quote it when starting the new remote shell. -- Fixed Windows native SSH sessions consuming a remote application's first device-attributes response during local console startup, which could leave the application waiting with a blank screen. -- Modified Enter and ASCII shortcuts retain their negotiated keyboard-protocol modifiers, including Shift+Enter, Ctrl+Enter, and Alt+V. Keyboard-mode queries now report the active flags after a protocol reset. -- Runtime CLI responses and event subscriptions stop cleanly when the program reading their output closes its pipe, instead of opening a panic dialog. Other output errors are still reported. + +- Fixed reopening the workspace restoring only the tab for Codex sessions without a hook-reported conversation ID. On WSL and Linux, Pebrel can now identify the current conversation and save it before window close or the application's Quit action. Native Windows and macOS sessions continue to rely on hook-reported IDs. +- Fixed quitting after closing the last regular window replacing saved tabs with an empty workspace. Deliberately closing every tab still starts an empty workspace next time. +- Fixed `Ctrl+C` ignoring selected terminal text. It now copies and clears the selection; without a selection, it still interrupts the running command. Contributed by [@Sakyvo](https://github.com/Sakyvo) in [#93](https://github.com/Kuddev/pebrel/pull/93). +- Fixed `Ctrl+Backspace` deleting only one character in supported terminal input modes and the managed PowerShell prompt. It now deletes a word. Contributed by [@Sakyvo](https://github.com/Sakyvo) in [#92](https://github.com/Kuddev/pebrel/pull/92). +- Fixed `Shift+Enter` being treated as plain Enter in negotiated multiline input modes, and Antigravity continuing to show a busy indicator after an answer finished. `Ctrl+Enter`, `Alt+V`, and other modified keys also retain their negotiated modifiers. Addresses [#95](https://github.com/Kuddev/nebula/issues/95). Contributed in part by [@821869798](https://github.com/821869798) in [#96](https://github.com/Kuddev/pebrel/pull/96). +- Fixed Windows SSH sessions losing an application's first terminal-capability response during startup, which could leave a full-screen command-line application waiting on a blank screen. +- Fixed duplicated WSL and SSH tabs losing their known working directory. WSL tabs also retain the selected distribution and launch settings. +- Fixed file search showing results from a previously opened directory or missing newly created, renamed, and deleted files. +- Fixed long confirmation messages being clipped by the dialog instead of wrapping to fit. +- Fixed the Runtime CLI showing an error dialog when the program receiving its output closed the pipe. It now finishes cleanly in that case. #### Improved -- New package names consistently use `Pebrel-v--`. A byte-identical installer with the legacy Nebula filename remains available for existing Windows auto-update clients. -- Made Titanium the default application icon and adjusted small-size proportions, prompt strokes, and transparent edges. -- Unified the application, command, executable, helper, package, and managed integration names under Pebrel. New local sessions expose `PEBREL_*` environment variables alongside legacy aliases. -- The Windows installer migrates registered `Nebula Terminal` installation directories to a sibling `Pebrel` directory, updates its shortcuts and PATH entry, and preserves custom directory names and unknown files. Cleanup failures are reported and can be retried. -- Existing application data is copied into the Pebrel data directory without overwriting newer files or removing the old directory. New configuration names take precedence, while old environment variables and `require 'nebula'` Lua configurations remain compatible. -- The project repository is now `Kuddev/pebrel`. Existing `Kuddev/nebula` repository and Git URLs redirect to it; historical release asset filenames remain unchanged. -- File-name indexing applies incremental updates and recovers from watcher queue overflow with a rescan; editing a file's contents alone no longer rebuilds its name entry. -- Improved terminal formula parsing and rendering for streamed agent output, while preserving the original text when a formula cannot be rendered. -- Made the settings navigation more compact, kept Application first, labeled terminal options more clearly, and added matching navigation icons. Appearance settings now show font, cursor, interface, and background controls in groups with expandable help. -- Reduced the intensity of hover and selected backgrounds in light themes. -- Settings now appears as a selectable, closable item in the top tab layout. Switching tab layouts while Settings is open preserves the sidebar state, and closing the last terminal tab keeps Settings available until it is closed. -- SSH editing now offers common usernames alongside recent entries, keeps manual usernames available, and makes host icons and pin controls clearer. Connection screens show completed stages and failures more accurately. -- Terminal completion lists now scroll to reveal keyboard selections, support wheel scrolling and hover feedback, and clear stale list positions when the query changes. -- Completion, bell, and Agent attention notifications now follow their source pane, including after a tab moves to another window. Clicking a notification focuses that pane, and activity in one pane no longer suppresses another pane's notification. + +- Improved the upgrade from Nebula to Pebrel: the application, commands, installer, and managed shortcuts use the new name. The installer migrates the standard old installation directory while preserving custom locations; configuration migration keeps old data and does not overwrite newer files. +- Improved continuity after the repository rename. Old `Kuddev/nebula` repository and Git links redirect to `Kuddev/pebrel`, and historical download filenames remain available. +- Improved package naming to use `Pebrel-v--` consistently. A byte-identical Windows installer with the old Nebula filename remains available for existing automatic-update clients. +- Improved SSH connection feedback: startup can be cancelled, stalled connection stages time out, and an unexpected disconnect leaves the terminal and error visible. This improves the disappearing-window and stuck-connection behavior. Addresses [#89](https://github.com/Kuddev/nebula/issues/89). +- Improved streamed formula recognition and layout, while preserving the original text when a formula cannot be displayed. KaTeX syntax support remains partial. Addresses [#84](https://github.com/Kuddev/nebula/issues/84). +- Improved notifications so clicking one focuses the terminal that raised it, even after moving its tab to another window. Activity in a different terminal no longer suppresses that notification. +- Improved activity indicators so they continue animating when another application has focus; hidden and minimized windows stop requesting animation frames. +- Improved settings with clearer groups, compact navigation, and a normal closable Settings tab. Light-theme selection and hover colors are less intense, and SSH editing makes common usernames and host controls easier to find. +- Improved completion lists with wheel scrolling, visible keyboard selections, and hover feedback. Changing the query clears the old list position. ### 中文 #### 新增 -- 发布下载新增 Windows x64 ZIP 与安装器、Linux x64 AppImage、DEB 与 tar.gz,以及分别适用于 Apple Silicon 和 Intel 的 macOS 14+ DMG。macOS 包采用临时签名,首次启动可能需要在“系统设置 > 隐私与安全性”中选择“仍要打开”。 -- 粘贴剪贴板图片时会将其写成 PNG,并把路径插入本地、WSL 或 SSH 终端会话。WSL 会转换路径,SSH 会先上传图片;失败会明确报告,提交输入、中断或会话变化后到达的结果会被丢弃。 -- 新增应用图标选择器,提供 25 款配色及浅色、深色背景预览。 -- 新增默认关闭的终端网络代理设置。Windows 下启用后,新建本地会话继承系统 HTTP/HTTPS 代理;现有会话保留原有环境。由 [@Sakyvo](https://github.com/Sakyvo) 在 [#94](https://github.com/Kuddev/pebrel/pull/94) 中贡献。 -- SSH 主机新增独立的代理与跳板配置,支持单独保存代理凭据并预览连接路线。 -- 为已捕获的 Claude Code 和 Codex 回答新增阅读视图,支持 Markdown、公式、原文模式和本地图片预览。 -- 在英语和简体中文之外,界面语言新增繁体中文、法语、德语、西班牙语、巴西葡萄牙语、意大利语、俄语、日语及韩语。初版覆盖导航、常用操作、外观与网络控件,未翻译文案回退英文。 -- 新增可跳转到匹配分区的设置搜索,以及需要确认的“恢复默认”操作;重置前备份偏好设置,并保留已保存主机等用户数据。 -- 终端标签与侧栏新增 Antigravity 产品图标,支持常见启动别名,并在浅色和深色主题中保留图标原色。 -- 可识别的终端二选一确认提示现在会在通知中显示允许与拒绝操作。提示、会话或输入状态变化后会拒绝操作,每个捕获的请求只能处理一次。 + +- 新增 Linux x64 的 AppImage、DEB 和 tar.gz 便携包,以及分别适用于 Apple Silicon 和 Intel 的 macOS DMG;Windows 继续提供安装器与 ZIP 便携包。本次不包含 RPM。macOS 包采用临时签名,首次启动可能需要在“系统设置 > 隐私与安全性”中选择“仍要打开”。对应 [#87](https://github.com/Kuddev/nebula/issues/87)。 +- 新增本地、WSL 和 SSH 终端的剪贴板图片粘贴:将图片保存成 PNG,再插入当前会话可以访问的路径,SSH 会先上传图片。输入框缩略图和发送后的图片显示仍取决于所用 CLI。 +- 新增 Claude Code 和 Codex 回答阅读器,支持阅读已捕获回答的 Markdown、公式、原文和本地图片。 +- 新增每台 SSH 主机独立的 SOCKS5/HTTP 代理与跳板设置,可保存代理凭据并预览连接路线。对应 [#90](https://github.com/Kuddev/nebula/issues/90)。 +- 新增让新建本地终端使用 Windows 系统代理的选项,默认关闭,不影响已经打开的会话。由 [@Sakyvo](https://github.com/Sakyvo) 在 [#94](https://github.com/Kuddev/pebrel/pull/94) 中贡献。 +- 新增繁体中文、法语、德语、西班牙语、巴西葡萄牙语、意大利语、俄语、日语和韩语界面选项,加上原有英语和简体中文共十一种语言;尚未翻译的内容显示英文。 +- 新增 25 款应用图标配色,提供浅色和深色预览,并将钛银设为默认图标。 +- 新增设置搜索和需要确认的“恢复默认”操作;重置前备份偏好设置,保留已保存主机等用户数据。 +- 新增可识别终端确认提示的通知操作,可直接选择允许或拒绝;提示或会话变化后操作失效,同一请求只能处理一次。 #### 修复 -- 启用 AI 会话恢复后,WSL 与 Linux 中的 Codex 在 hook 未上报会话 ID 时,可从当前会话补全 ID,使重新打开工作区时接续该对话。关闭窗口和应用内“退出”操作会在保存前限定等待身份查询完成;不会选择存在歧义的身份,原生 Windows 与 macOS 继续使用 hook 上报的会话 ID。 -- 终端中 `Ctrl+C` 会复制并清除选区,没有选区时仍发送中断;文本输入框保留自身的复制行为。由 [@Sakyvo](https://github.com/Sakyvo) 在 [#93](https://github.com/Kuddev/pebrel/pull/93) 中贡献。 -- `Ctrl+Backspace` 在支持的传统和 kitty 键盘路径中按词删除,并适用于受管理的 PowerShell 提示符。由 [@Sakyvo](https://github.com/Sakyvo) 在 [#92](https://github.com/Kuddev/pebrel/pull/92) 中贡献。 -- 其他窗口获得焦点后,运行中的标签指示器继续播放动画;窗口隐藏或最小化时停止请求动画帧。 -- 文件名搜索丢弃旧目录的结果,在创建、重命名和删除后更新,并正确处理文件监听器返回的规范路径,保留用户看到的目录根。 -- 多行确认文案按换行后的尺寸测量,使较长消息能完整放入共享确认框。 -- 修复 Antigravity 的活动与权限提示识别,避免历史转圈输出或普通正文中提到审批时,把空闲会话误标为运行中或等待授权。由 [@821869798](https://github.com/821869798) 在 [#96](https://github.com/Kuddev/pebrel/pull/96) 中贡献。 -- 关闭最后一个普通窗口后再退出,不再用空快照覆盖已保存标签;明确关闭全部标签后,下次仍从空工作区开始,保存失败会重试。 -- SSH 启动现在等待 PTY 与 shell 确认后才显示就绪,连接准备期间可取消,停滞的启动阶段会在限定时间内结束;意外断连会保留 pane 与失败状态,明确退出 shell 时仍正常关闭。 -- 复制 WSL 标签时保留当前来宾目录与所选发行版;复制 SSH 标签时保留已知远端工作目录,并在启动新远端 shell 时正确引用该路径。 -- 修复 Windows 原生 SSH 会话在本地控制台启动时消耗远端应用首个设备属性应答的问题;该问题可能导致远端应用停留在空白画面等待应答。 -- 带修饰键的回车与 ASCII 快捷键会保留协商键盘协议中的修饰信息,包括 Shift+Enter、Ctrl+Enter 和 Alt+V;协议重置后的键盘模式查询现在返回实际生效的标志。 -- 读取输出的程序关闭管道后,Runtime CLI 响应与事件订阅会正常结束,不再弹出 panic 对话框;其他输出错误仍会报告。 + +- 修复部分 Codex 会话重开应用后只恢复标签、没有接续对话的问题。在 WSL 和 Linux 中,即使 hook 未上报 ID,也能识别当前对话,并在关闭窗口或通过应用内“退出”操作结束前保存;原生 Windows 和 macOS 会话仍依赖 hook 上报的 ID。 +- 修复关闭最后一个普通窗口后再退出,已保存的标签被空工作区覆盖的问题;主动关闭全部标签时,下次仍从空工作区开始。 +- 修复选中终端文字后按 `Ctrl+C` 没有复制的问题:现在会复制并清除选区,没有选区时仍中断当前命令。由 [@Sakyvo](https://github.com/Sakyvo) 在 [#93](https://github.com/Kuddev/pebrel/pull/93) 中贡献。 +- 修复受支持的终端输入模式和受管理 PowerShell 提示符中,`Ctrl+Backspace` 只删除一个字符的问题,现在可按词删除。由 [@Sakyvo](https://github.com/Sakyvo) 在 [#92](https://github.com/Kuddev/pebrel/pull/92) 中贡献。 +- 修复已协商多行输入模式下 `Shift+Enter` 被当成普通回车,以及 Antigravity 回答结束后仍一直转圈的问题;`Ctrl+Enter`、`Alt+V` 等按键也会保留协商协议要求的修饰信息。对应 [#95](https://github.com/Kuddev/nebula/issues/95)。其中部分修复由 [@821869798](https://github.com/821869798) 在 [#96](https://github.com/Kuddev/pebrel/pull/96) 中贡献。 +- 修复 Windows SSH 会话启动时丢失应用首个终端能力应答,可能使全屏命令行程序一直停在空白画面的问题。 +- 修复复制 WSL 和 SSH 标签后丢失已知工作目录的问题;WSL 同时保留所选发行版与启动设置。 +- 修复文件搜索残留上一个目录的结果,以及新建、重命名或删除文件后结果未及时更新的问题。 +- 修复较长确认消息被对话框截断的问题,现在会换行并适应内容高度。 +- 修复接收输出的程序关闭管道后,Runtime CLI 弹出错误窗口的问题;这种情况现在会正常结束。 #### 改进 -- 新安装包统一采用 `Pebrel-v<版本>-<系统>-<架构>` 命名;同时保留与正式安装器字节一致的旧 Nebula 文件名资产,供已安装的 Windows 客户端自动更新。 -- 将钛银设为默认应用图标,并调整小尺寸比例、提示符笔画与透明边缘。 -- 应用、命令、可执行文件、辅助程序、安装包与受管理的集成统一使用 Pebrel 名称。新建本地会话同时提供 `PEBREL_*` 环境变量和旧版兼容别名。 -- Windows 安装器会把已注册的 `Nebula Terminal` 安装目录迁到同级 `Pebrel`,更新其快捷方式与 PATH 项,并保留自定义目录名称和未知文件;清理失败时会明确报告并支持重试。 -- 既有应用数据会复制到 Pebrel 数据目录,不覆盖较新的文件,也不删除旧目录。新配置名称优先,旧环境变量与 `require 'nebula'` Lua 配置继续兼容。 -- 项目仓库现为 `Kuddev/pebrel`,已有的 `Kuddev/nebula` 仓库及 Git 地址会重定向到新地址;历史发布资产文件名保持不变。 -- 文件名索引采用增量更新,监听队列溢出时通过重新扫描恢复;仅编辑文件内容不再重建对应名称条目。 -- 改进流式 Agent 输出中的终端公式解析与绘制,无法渲染时保留原始文本。 -- 设置导航更紧凑,“应用”保持首项,终端选项名称更清晰,并配有含义匹配的图标;外观设置按字体、光标、界面和背景分组,提供可展开的详细说明。 -- 降低浅色主题中悬停与选中背景的色彩强度。 -- 顶部标签布局中,设置现在作为可选择、可关闭的标签显示;打开设置时切换标签布局会保留侧栏状态,关闭最后一个终端标签后仍可继续使用设置,直到关闭设置页。 -- SSH 编辑器在最近使用项之外提供常用用户名,保留手动输入,并改进主机图标与置顶控件;连接界面更准确地显示已完成阶段和失败状态。 -- 终端补全列表现在会滚动到键盘选中项,支持滚轮浏览与悬停反馈,并在查询变化后清除过期列表位置。 -- 命令完成、响铃和 Agent 待处理通知现在跟随来源 pane,即使标签已移动到另一个窗口;点击通知会聚焦来源 pane,一个 pane 的活动不再抑制另一个 pane 的通知。 + +- 改进 Nebula 到 Pebrel 的升级体验:应用、命令、安装器和受管理的快捷方式统一使用新名称。安装器会迁移标准旧安装目录并保留自定义位置;配置迁移保留旧数据,不覆盖较新的文件。 +- 改进仓库改名后的访问衔接:旧 `Kuddev/nebula` 仓库与 Git 链接会跳转到 `Kuddev/pebrel`,历史下载文件名继续保留。 +- 改进安装包命名,统一采用 `Pebrel-v<版本>-<系统>-<架构>`;同时提供与正式安装器字节一致的旧 Nebula 文件名资产,保证旧版自动更新客户端仍能找到安装包。 +- 改进 SSH 连接反馈:连接准备期间可以取消,停滞阶段会超时结束,意外断连后保留终端并显示错误。本次处理了窗口消失和连接一直卡住的相关表现。对应 [#89](https://github.com/Kuddev/nebula/issues/89)。 +- 改进连续输出时的公式识别与排版,无法显示的公式保留原文;KaTeX 语法支持仍有范围限制。对应 [#84](https://github.com/Kuddev/nebula/issues/84)。 +- 改进通知定位:点击通知会回到发出通知的终端,即使其标签已移动到另一个窗口;其他终端的活动不再抑制这条通知。 +- 改进运行状态指示器,切到其他应用后仍继续播放动画,窗口隐藏或最小化时停止请求动画帧。 +- 改进设置的分组、导航和标签体验,设置页可像普通标签一样关闭;降低浅色主题的选中与悬停颜色强度,SSH 编辑器中的常用用户名和主机控件也更容易找到。 +- 改进补全列表,支持滚轮浏览、自动显示键盘选中项和悬停反馈;查询变化后清除旧列表位置。 ### Contributors @Sakyvo avatar @821869798 avatar -**[@Sakyvo](https://github.com/Sakyvo)** — Added the opt-in Windows system proxy for new local sessions, conditional `Ctrl+C` selection copy, and word deletion with `Ctrl+Backspace` / 新增可选的 Windows 系统代理注入、`Ctrl+C` 按选区复制及 `Ctrl+Backspace` 按词删除([#94](https://github.com/Kuddev/pebrel/pull/94)、[#93](https://github.com/Kuddev/pebrel/pull/93)、[#92](https://github.com/Kuddev/pebrel/pull/92))。 +**[@Sakyvo](https://github.com/Sakyvo)** - Added the optional Windows terminal proxy, selection-aware `Ctrl+C`, and word deletion with `Ctrl+Backspace`. / 新增可选的 Windows 终端代理、按选区处理的 `Ctrl+C`,以及 `Ctrl+Backspace` 按词删除。([#94](https://github.com/Kuddev/pebrel/pull/94)、[#93](https://github.com/Kuddev/pebrel/pull/93)、[#92](https://github.com/Kuddev/pebrel/pull/92)) -**[@821869798](https://github.com/821869798)** — Added negotiated `Shift+Enter` multiline input and improved Antigravity idle and permission-state detection / 新增协商键盘协议下的 `Shift+Enter` 多行输入,并改进 Antigravity 空闲与权限状态识别([#96](https://github.com/Kuddev/pebrel/pull/96))。 +**[@821869798](https://github.com/821869798)** - Contributed `Shift+Enter` multiline input and Antigravity completion and permission-state detection. / 贡献 `Shift+Enter` 多行输入,以及 Antigravity 回答结束与权限状态识别。([#96](https://github.com/Kuddev/pebrel/pull/96)) --- diff --git a/docs/release-notes/v1.6.0.md b/docs/release-notes/v1.6.0.md index 5a6b95b..d8f92ff 100644 --- a/docs/release-notes/v1.6.0.md +++ b/docs/release-notes/v1.6.0.md @@ -3,101 +3,89 @@ ## English ### Added -- Release downloads now include Windows x64 ZIP and installer packages, Linux x64 AppImage, DEB and tar.gz packages, and separate macOS 14+ DMGs for Apple Silicon and Intel. macOS packages are ad-hoc signed; the first launch may require Open Anyway in System Settings > Privacy & Security. -- Pasting a clipboard image now writes it as a PNG and inserts its path into local, WSL, or SSH terminal sessions. WSL paths are translated and SSH images are uploaded before insertion; failures are reported, and results arriving after submission, interruption, or a session change are discarded. -- Added an application icon picker with 25 color palettes and light/dark previews. -- Added an opt-in terminal network proxy setting. On Windows, new local sessions inherit the system HTTP/HTTPS proxy when enabled; existing sessions keep their environment. Contributed by [@Sakyvo](https://github.com/Sakyvo) in [#94](https://github.com/Kuddev/pebrel/pull/94). -- Added per-host SSH proxy and jump-host options, with separate proxy credentials and a connection-route preview. -- Added an answer reader for captured Claude Code and Codex responses, with Markdown, formulas, source-text mode, and local image previews. -- Added UI language choices for Traditional Chinese, French, German, Spanish, Brazilian Portuguese, Italian, Russian, Japanese, and Korean alongside English and Simplified Chinese. Initial coverage includes navigation, common actions, appearance, and network controls; untranslated text falls back to English. -- Added a settings search that opens the matching section and a confirmed Restore defaults action that backs up preferences while retaining saved hosts and other user data. -- Added the Antigravity product icon to terminal tabs and the sidebar, including its common launcher aliases, while retaining its original colors in light and dark themes. -- Recognized binary terminal confirmation prompts now appear in notifications with Allow and Deny actions. An action is rejected when the prompt, session, or input state has changed, and each captured request can be handled only once. + +- Added Linux x64 packages in AppImage, DEB, and portable tar.gz formats, plus separate macOS DMGs for Apple Silicon and Intel, alongside the Windows installer and ZIP. RPM packages are not included. macOS packages use ad-hoc signing and may require Open Anyway in System Settings > Privacy & Security on first launch. Addresses [#87](https://github.com/Kuddev/nebula/issues/87). +- Added clipboard image paste for local, WSL, and SSH terminals. Pebrel saves the image as a PNG and inserts a path that the session can access; SSH images are uploaded first. Attachment thumbnails and images in a CLI's conversation still depend on that CLI. +- Added a reader for captured Claude Code and Codex answers, with Markdown, formulas, original text, and local image previews. +- Added separate SOCKS5/HTTP proxy and jump-host settings for each SSH host, with saved proxy credentials and a connection-route preview. Addresses [#90](https://github.com/Kuddev/nebula/issues/90). +- Added an optional Windows system-proxy setting for newly opened local terminals. It is off by default and does not change existing sessions. Contributed by [@Sakyvo](https://github.com/Sakyvo) in [#94](https://github.com/Kuddev/pebrel/pull/94). +- Added nine UI language choices alongside English and Simplified Chinese: Traditional Chinese, French, German, Spanish, Brazilian Portuguese, Italian, Russian, Japanese, and Korean. Untranslated text falls back to English. +- Added 25 application icon palettes with light and dark previews. Titanium is now the default icon. +- Added settings search and a confirmed Restore defaults action. Preferences are backed up before resetting, and saved hosts and other user data are retained. +- Added Allow and Deny actions to notifications for recognized terminal confirmation prompts. Actions stop being available when the prompt or session changes, and each request can be handled only once. ### Fixed -- With AI session restoration enabled, Codex sessions in WSL and Linux can recover the current conversation ID when no hook reported it, so reopening the workspace resumes that conversation. Window close and the application's Quit action allow a bounded wait for the identity before saving. Ambiguous identities are not selected; native Windows and macOS continue to use hook-reported session IDs. -- `Ctrl+C` copies and clears a terminal selection, and still sends an interrupt when no selection is present. Text inputs keep their own copy behavior. Contributed by [@Sakyvo](https://github.com/Sakyvo) in [#93](https://github.com/Kuddev/pebrel/pull/93). -- `Ctrl+Backspace` deletes a word in the supported legacy and kitty keyboard paths, including the managed PowerShell prompt. Contributed by [@Sakyvo](https://github.com/Sakyvo) in [#92](https://github.com/Kuddev/pebrel/pull/92). -- Running tab indicators keep animating when another window has focus; hidden or minimized windows stop requesting animation frames. -- File-name search discards results from an old root, updates after creates, renames and deletes, and handles canonical file-watcher paths without losing the visible directory root. -- Multiline confirmation text is measured with wrapping so longer messages fit the shared confirmation dialog. -- Antigravity activity and permission detection no longer lets old spinner output or ordinary prose mentioning approval mark an idle session as running or waiting for permission. Contributed by [@821869798](https://github.com/821869798) in [#96](https://github.com/Kuddev/pebrel/pull/96). -- Closing the last regular window and then quitting no longer replaces saved tabs with an empty snapshot. Explicitly closing all tabs still starts an empty workspace next time, and failed session writes are retried. -- SSH startup now waits for PTY and shell confirmation before showing Ready, supports cancellation during connection setup, and stops stalled startup stages within a bounded timeout. Unexpected disconnects retain the pane and its failure state; explicit shell exits still close normally. -- Duplicated WSL tabs retain the current guest directory and selected distribution. Duplicated SSH tabs retain the known remote working directory and quote it when starting the new remote shell. -- Fixed Windows native SSH sessions consuming a remote application's first device-attributes response during local console startup, which could leave the application waiting with a blank screen. -- Modified Enter and ASCII shortcuts retain their negotiated keyboard-protocol modifiers, including Shift+Enter, Ctrl+Enter, and Alt+V. Keyboard-mode queries now report the active flags after a protocol reset. -- Runtime CLI responses and event subscriptions stop cleanly when the program reading their output closes its pipe, instead of opening a panic dialog. Other output errors are still reported. + +- Fixed reopening the workspace restoring only the tab for Codex sessions without a hook-reported conversation ID. On WSL and Linux, Pebrel can now identify the current conversation and save it before window close or the application's Quit action. Native Windows and macOS sessions continue to rely on hook-reported IDs. +- Fixed quitting after closing the last regular window replacing saved tabs with an empty workspace. Deliberately closing every tab still starts an empty workspace next time. +- Fixed `Ctrl+C` ignoring selected terminal text. It now copies and clears the selection; without a selection, it still interrupts the running command. Contributed by [@Sakyvo](https://github.com/Sakyvo) in [#93](https://github.com/Kuddev/pebrel/pull/93). +- Fixed `Ctrl+Backspace` deleting only one character in supported terminal input modes and the managed PowerShell prompt. It now deletes a word. Contributed by [@Sakyvo](https://github.com/Sakyvo) in [#92](https://github.com/Kuddev/pebrel/pull/92). +- Fixed `Shift+Enter` being treated as plain Enter in negotiated multiline input modes, and Antigravity continuing to show a busy indicator after an answer finished. `Ctrl+Enter`, `Alt+V`, and other modified keys also retain their negotiated modifiers. Addresses [#95](https://github.com/Kuddev/nebula/issues/95). Contributed in part by [@821869798](https://github.com/821869798) in [#96](https://github.com/Kuddev/pebrel/pull/96). +- Fixed Windows SSH sessions losing an application's first terminal-capability response during startup, which could leave a full-screen command-line application waiting on a blank screen. +- Fixed duplicated WSL and SSH tabs losing their known working directory. WSL tabs also retain the selected distribution and launch settings. +- Fixed file search showing results from a previously opened directory or missing newly created, renamed, and deleted files. +- Fixed long confirmation messages being clipped by the dialog instead of wrapping to fit. +- Fixed the Runtime CLI showing an error dialog when the program receiving its output closed the pipe. It now finishes cleanly in that case. ### Improved -- New package names consistently use `Pebrel-v--`. A byte-identical installer with the legacy Nebula filename remains available for existing Windows auto-update clients. -- Made Titanium the default application icon and adjusted small-size proportions, prompt strokes, and transparent edges. -- Unified the application, command, executable, helper, package, and managed integration names under Pebrel. New local sessions expose `PEBREL_*` environment variables alongside legacy aliases. -- The Windows installer migrates registered `Nebula Terminal` installation directories to a sibling `Pebrel` directory, updates its shortcuts and PATH entry, and preserves custom directory names and unknown files. Cleanup failures are reported and can be retried. -- Existing application data is copied into the Pebrel data directory without overwriting newer files or removing the old directory. New configuration names take precedence, while old environment variables and `require 'nebula'` Lua configurations remain compatible. -- The project repository is now `Kuddev/pebrel`. Existing `Kuddev/nebula` repository and Git URLs redirect to it; historical release asset filenames remain unchanged. -- File-name indexing applies incremental updates and recovers from watcher queue overflow with a rescan; editing a file's contents alone no longer rebuilds its name entry. -- Improved terminal formula parsing and rendering for streamed agent output, while preserving the original text when a formula cannot be rendered. -- Made the settings navigation more compact, kept Application first, labeled terminal options more clearly, and added matching navigation icons. Appearance settings now show font, cursor, interface, and background controls in groups with expandable help. -- Reduced the intensity of hover and selected backgrounds in light themes. -- Settings now appears as a selectable, closable item in the top tab layout. Switching tab layouts while Settings is open preserves the sidebar state, and closing the last terminal tab keeps Settings available until it is closed. -- SSH editing now offers common usernames alongside recent entries, keeps manual usernames available, and makes host icons and pin controls clearer. Connection screens show completed stages and failures more accurately. -- Terminal completion lists now scroll to reveal keyboard selections, support wheel scrolling and hover feedback, and clear stale list positions when the query changes. -- Completion, bell, and Agent attention notifications now follow their source pane, including after a tab moves to another window. Clicking a notification focuses that pane, and activity in one pane no longer suppresses another pane's notification. + +- Improved the upgrade from Nebula to Pebrel: the application, commands, installer, and managed shortcuts use the new name. The installer migrates the standard old installation directory while preserving custom locations; configuration migration keeps old data and does not overwrite newer files. +- Improved continuity after the repository rename. Old `Kuddev/nebula` repository and Git links redirect to `Kuddev/pebrel`, and historical download filenames remain available. +- Improved package naming to use `Pebrel-v--` consistently. A byte-identical Windows installer with the old Nebula filename remains available for existing automatic-update clients. +- Improved SSH connection feedback: startup can be cancelled, stalled connection stages time out, and an unexpected disconnect leaves the terminal and error visible. This improves the disappearing-window and stuck-connection behavior. Addresses [#89](https://github.com/Kuddev/nebula/issues/89). +- Improved streamed formula recognition and layout, while preserving the original text when a formula cannot be displayed. KaTeX syntax support remains partial. Addresses [#84](https://github.com/Kuddev/nebula/issues/84). +- Improved notifications so clicking one focuses the terminal that raised it, even after moving its tab to another window. Activity in a different terminal no longer suppresses that notification. +- Improved activity indicators so they continue animating when another application has focus; hidden and minimized windows stop requesting animation frames. +- Improved settings with clearer groups, compact navigation, and a normal closable Settings tab. Light-theme selection and hover colors are less intense, and SSH editing makes common usernames and host controls easier to find. +- Improved completion lists with wheel scrolling, visible keyboard selections, and hover feedback. Changing the query clears the old list position. ## 中文 ### 新增 -- 发布下载新增 Windows x64 ZIP 与安装器、Linux x64 AppImage、DEB 与 tar.gz,以及分别适用于 Apple Silicon 和 Intel 的 macOS 14+ DMG。macOS 包采用临时签名,首次启动可能需要在“系统设置 > 隐私与安全性”中选择“仍要打开”。 -- 粘贴剪贴板图片时会将其写成 PNG,并把路径插入本地、WSL 或 SSH 终端会话。WSL 会转换路径,SSH 会先上传图片;失败会明确报告,提交输入、中断或会话变化后到达的结果会被丢弃。 -- 新增应用图标选择器,提供 25 款配色及浅色、深色背景预览。 -- 新增默认关闭的终端网络代理设置。Windows 下启用后,新建本地会话继承系统 HTTP/HTTPS 代理;现有会话保留原有环境。由 [@Sakyvo](https://github.com/Sakyvo) 在 [#94](https://github.com/Kuddev/pebrel/pull/94) 中贡献。 -- SSH 主机新增独立的代理与跳板配置,支持单独保存代理凭据并预览连接路线。 -- 为已捕获的 Claude Code 和 Codex 回答新增阅读视图,支持 Markdown、公式、原文模式和本地图片预览。 -- 在英语和简体中文之外,界面语言新增繁体中文、法语、德语、西班牙语、巴西葡萄牙语、意大利语、俄语、日语及韩语。初版覆盖导航、常用操作、外观与网络控件,未翻译文案回退英文。 -- 新增可跳转到匹配分区的设置搜索,以及需要确认的“恢复默认”操作;重置前备份偏好设置,并保留已保存主机等用户数据。 -- 终端标签与侧栏新增 Antigravity 产品图标,支持常见启动别名,并在浅色和深色主题中保留图标原色。 -- 可识别的终端二选一确认提示现在会在通知中显示允许与拒绝操作。提示、会话或输入状态变化后会拒绝操作,每个捕获的请求只能处理一次。 + +- 新增 Linux x64 的 AppImage、DEB 和 tar.gz 便携包,以及分别适用于 Apple Silicon 和 Intel 的 macOS DMG;Windows 继续提供安装器与 ZIP 便携包。本次不包含 RPM。macOS 包采用临时签名,首次启动可能需要在“系统设置 > 隐私与安全性”中选择“仍要打开”。对应 [#87](https://github.com/Kuddev/nebula/issues/87)。 +- 新增本地、WSL 和 SSH 终端的剪贴板图片粘贴:将图片保存成 PNG,再插入当前会话可以访问的路径,SSH 会先上传图片。输入框缩略图和发送后的图片显示仍取决于所用 CLI。 +- 新增 Claude Code 和 Codex 回答阅读器,支持阅读已捕获回答的 Markdown、公式、原文和本地图片。 +- 新增每台 SSH 主机独立的 SOCKS5/HTTP 代理与跳板设置,可保存代理凭据并预览连接路线。对应 [#90](https://github.com/Kuddev/nebula/issues/90)。 +- 新增让新建本地终端使用 Windows 系统代理的选项,默认关闭,不影响已经打开的会话。由 [@Sakyvo](https://github.com/Sakyvo) 在 [#94](https://github.com/Kuddev/pebrel/pull/94) 中贡献。 +- 新增繁体中文、法语、德语、西班牙语、巴西葡萄牙语、意大利语、俄语、日语和韩语界面选项,加上原有英语和简体中文共十一种语言;尚未翻译的内容显示英文。 +- 新增 25 款应用图标配色,提供浅色和深色预览,并将钛银设为默认图标。 +- 新增设置搜索和需要确认的“恢复默认”操作;重置前备份偏好设置,保留已保存主机等用户数据。 +- 新增可识别终端确认提示的通知操作,可直接选择允许或拒绝;提示或会话变化后操作失效,同一请求只能处理一次。 ### 修复 -- 启用 AI 会话恢复后,WSL 与 Linux 中的 Codex 在 hook 未上报会话 ID 时,可从当前会话补全 ID,使重新打开工作区时接续该对话。关闭窗口和应用内“退出”操作会在保存前限定等待身份查询完成;不会选择存在歧义的身份,原生 Windows 与 macOS 继续使用 hook 上报的会话 ID。 -- 终端中 `Ctrl+C` 会复制并清除选区,没有选区时仍发送中断;文本输入框保留自身的复制行为。由 [@Sakyvo](https://github.com/Sakyvo) 在 [#93](https://github.com/Kuddev/pebrel/pull/93) 中贡献。 -- `Ctrl+Backspace` 在支持的传统和 kitty 键盘路径中按词删除,并适用于受管理的 PowerShell 提示符。由 [@Sakyvo](https://github.com/Sakyvo) 在 [#92](https://github.com/Kuddev/pebrel/pull/92) 中贡献。 -- 其他窗口获得焦点后,运行中的标签指示器继续播放动画;窗口隐藏或最小化时停止请求动画帧。 -- 文件名搜索丢弃旧目录的结果,在创建、重命名和删除后更新,并正确处理文件监听器返回的规范路径,保留用户看到的目录根。 -- 多行确认文案按换行后的尺寸测量,使较长消息能完整放入共享确认框。 -- 修复 Antigravity 的活动与权限提示识别,避免历史转圈输出或普通正文中提到审批时,把空闲会话误标为运行中或等待授权。由 [@821869798](https://github.com/821869798) 在 [#96](https://github.com/Kuddev/pebrel/pull/96) 中贡献。 -- 关闭最后一个普通窗口后再退出,不再用空快照覆盖已保存标签;明确关闭全部标签后,下次仍从空工作区开始,保存失败会重试。 -- SSH 启动现在等待 PTY 与 shell 确认后才显示就绪,连接准备期间可取消,停滞的启动阶段会在限定时间内结束;意外断连会保留 pane 与失败状态,明确退出 shell 时仍正常关闭。 -- 复制 WSL 标签时保留当前来宾目录与所选发行版;复制 SSH 标签时保留已知远端工作目录,并在启动新远端 shell 时正确引用该路径。 -- 修复 Windows 原生 SSH 会话在本地控制台启动时消耗远端应用首个设备属性应答的问题;该问题可能导致远端应用停留在空白画面等待应答。 -- 带修饰键的回车与 ASCII 快捷键会保留协商键盘协议中的修饰信息,包括 Shift+Enter、Ctrl+Enter 和 Alt+V;协议重置后的键盘模式查询现在返回实际生效的标志。 -- 读取输出的程序关闭管道后,Runtime CLI 响应与事件订阅会正常结束,不再弹出 panic 对话框;其他输出错误仍会报告。 + +- 修复部分 Codex 会话重开应用后只恢复标签、没有接续对话的问题。在 WSL 和 Linux 中,即使 hook 未上报 ID,也能识别当前对话,并在关闭窗口或通过应用内“退出”操作结束前保存;原生 Windows 和 macOS 会话仍依赖 hook 上报的 ID。 +- 修复关闭最后一个普通窗口后再退出,已保存的标签被空工作区覆盖的问题;主动关闭全部标签时,下次仍从空工作区开始。 +- 修复选中终端文字后按 `Ctrl+C` 没有复制的问题:现在会复制并清除选区,没有选区时仍中断当前命令。由 [@Sakyvo](https://github.com/Sakyvo) 在 [#93](https://github.com/Kuddev/pebrel/pull/93) 中贡献。 +- 修复受支持的终端输入模式和受管理 PowerShell 提示符中,`Ctrl+Backspace` 只删除一个字符的问题,现在可按词删除。由 [@Sakyvo](https://github.com/Sakyvo) 在 [#92](https://github.com/Kuddev/pebrel/pull/92) 中贡献。 +- 修复已协商多行输入模式下 `Shift+Enter` 被当成普通回车,以及 Antigravity 回答结束后仍一直转圈的问题;`Ctrl+Enter`、`Alt+V` 等按键也会保留协商协议要求的修饰信息。对应 [#95](https://github.com/Kuddev/nebula/issues/95)。其中部分修复由 [@821869798](https://github.com/821869798) 在 [#96](https://github.com/Kuddev/pebrel/pull/96) 中贡献。 +- 修复 Windows SSH 会话启动时丢失应用首个终端能力应答,可能使全屏命令行程序一直停在空白画面的问题。 +- 修复复制 WSL 和 SSH 标签后丢失已知工作目录的问题;WSL 同时保留所选发行版与启动设置。 +- 修复文件搜索残留上一个目录的结果,以及新建、重命名或删除文件后结果未及时更新的问题。 +- 修复较长确认消息被对话框截断的问题,现在会换行并适应内容高度。 +- 修复接收输出的程序关闭管道后,Runtime CLI 弹出错误窗口的问题;这种情况现在会正常结束。 ### 改进 -- 新安装包统一采用 `Pebrel-v<版本>-<系统>-<架构>` 命名;同时保留与正式安装器字节一致的旧 Nebula 文件名资产,供已安装的 Windows 客户端自动更新。 -- 将钛银设为默认应用图标,并调整小尺寸比例、提示符笔画与透明边缘。 -- 应用、命令、可执行文件、辅助程序、安装包与受管理的集成统一使用 Pebrel 名称。新建本地会话同时提供 `PEBREL_*` 环境变量和旧版兼容别名。 -- Windows 安装器会把已注册的 `Nebula Terminal` 安装目录迁到同级 `Pebrel`,更新其快捷方式与 PATH 项,并保留自定义目录名称和未知文件;清理失败时会明确报告并支持重试。 -- 既有应用数据会复制到 Pebrel 数据目录,不覆盖较新的文件,也不删除旧目录。新配置名称优先,旧环境变量与 `require 'nebula'` Lua 配置继续兼容。 -- 项目仓库现为 `Kuddev/pebrel`,已有的 `Kuddev/nebula` 仓库及 Git 地址会重定向到新地址;历史发布资产文件名保持不变。 -- 文件名索引采用增量更新,监听队列溢出时通过重新扫描恢复;仅编辑文件内容不再重建对应名称条目。 -- 改进流式 Agent 输出中的终端公式解析与绘制,无法渲染时保留原始文本。 -- 设置导航更紧凑,“应用”保持首项,终端选项名称更清晰,并配有含义匹配的图标;外观设置按字体、光标、界面和背景分组,提供可展开的详细说明。 -- 降低浅色主题中悬停与选中背景的色彩强度。 -- 顶部标签布局中,设置现在作为可选择、可关闭的标签显示;打开设置时切换标签布局会保留侧栏状态,关闭最后一个终端标签后仍可继续使用设置,直到关闭设置页。 -- SSH 编辑器在最近使用项之外提供常用用户名,保留手动输入,并改进主机图标与置顶控件;连接界面更准确地显示已完成阶段和失败状态。 -- 终端补全列表现在会滚动到键盘选中项,支持滚轮浏览与悬停反馈,并在查询变化后清除过期列表位置。 -- 命令完成、响铃和 Agent 待处理通知现在跟随来源 pane,即使标签已移动到另一个窗口;点击通知会聚焦来源 pane,一个 pane 的活动不再抑制另一个 pane 的通知。 + +- 改进 Nebula 到 Pebrel 的升级体验:应用、命令、安装器和受管理的快捷方式统一使用新名称。安装器会迁移标准旧安装目录并保留自定义位置;配置迁移保留旧数据,不覆盖较新的文件。 +- 改进仓库改名后的访问衔接:旧 `Kuddev/nebula` 仓库与 Git 链接会跳转到 `Kuddev/pebrel`,历史下载文件名继续保留。 +- 改进安装包命名,统一采用 `Pebrel-v<版本>-<系统>-<架构>`;同时提供与正式安装器字节一致的旧 Nebula 文件名资产,保证旧版自动更新客户端仍能找到安装包。 +- 改进 SSH 连接反馈:连接准备期间可以取消,停滞阶段会超时结束,意外断连后保留终端并显示错误。本次处理了窗口消失和连接一直卡住的相关表现。对应 [#89](https://github.com/Kuddev/nebula/issues/89)。 +- 改进连续输出时的公式识别与排版,无法显示的公式保留原文;KaTeX 语法支持仍有范围限制。对应 [#84](https://github.com/Kuddev/nebula/issues/84)。 +- 改进通知定位:点击通知会回到发出通知的终端,即使其标签已移动到另一个窗口;其他终端的活动不再抑制这条通知。 +- 改进运行状态指示器,切到其他应用后仍继续播放动画,窗口隐藏或最小化时停止请求动画帧。 +- 改进设置的分组、导航和标签体验,设置页可像普通标签一样关闭;降低浅色主题的选中与悬停颜色强度,SSH 编辑器中的常用用户名和主机控件也更容易找到。 +- 改进补全列表,支持滚轮浏览、自动显示键盘选中项和悬停反馈;查询变化后清除旧列表位置。 ## Contributors @Sakyvo avatar @821869798 avatar -**[@Sakyvo](https://github.com/Sakyvo)** — Added the opt-in Windows system proxy for new local sessions, conditional `Ctrl+C` selection copy, and word deletion with `Ctrl+Backspace` / 新增可选的 Windows 系统代理注入、`Ctrl+C` 按选区复制及 `Ctrl+Backspace` 按词删除([#94](https://github.com/Kuddev/pebrel/pull/94)、[#93](https://github.com/Kuddev/pebrel/pull/93)、[#92](https://github.com/Kuddev/pebrel/pull/92))。 +**[@Sakyvo](https://github.com/Sakyvo)** - Added the optional Windows terminal proxy, selection-aware `Ctrl+C`, and word deletion with `Ctrl+Backspace`. / 新增可选的 Windows 终端代理、按选区处理的 `Ctrl+C`,以及 `Ctrl+Backspace` 按词删除。([#94](https://github.com/Kuddev/pebrel/pull/94)、[#93](https://github.com/Kuddev/pebrel/pull/93)、[#92](https://github.com/Kuddev/pebrel/pull/92)) -**[@821869798](https://github.com/821869798)** — Added negotiated `Shift+Enter` multiline input and improved Antigravity idle and permission-state detection / 新增协商键盘协议下的 `Shift+Enter` 多行输入,并改进 Antigravity 空闲与权限状态识别([#96](https://github.com/Kuddev/pebrel/pull/96))。 +**[@821869798](https://github.com/821869798)** - Contributed `Shift+Enter` multiline input and Antigravity completion and permission-state detection. / 贡献 `Shift+Enter` 多行输入,以及 Antigravity 回答结束与权限状态识别。([#96](https://github.com/Kuddev/pebrel/pull/96)) --- From 4f8a1ec5d15845f01d7039bf29e1ec2d21e6426d Mon Sep 17 00:00:00 2001 From: kuddev Date: Mon, 7 Sep 2026 19:51:57 +0800 Subject: [PATCH 22/31] fix(release): provision verified Windows runtime and support single-platform recovery --- .github/workflows/release.yml | 84 +++++++++++++++++++++++-- scripts/prepare-windows-runtime.ps1 | 61 ++++++++++++++++++ scripts/tests/windows-runtime.tests.ps1 | 28 +++++++++ 3 files changed, 169 insertions(+), 4 deletions(-) create mode 100644 scripts/prepare-windows-runtime.ps1 create mode 100644 scripts/tests/windows-runtime.tests.ps1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4995667..d112bc4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,6 +17,18 @@ on: required: true default: false type: boolean + windows_only: + description: Build only the missing Windows package, retaining other platform artifacts + type: boolean + default: false + source_commit: + description: Exact previously tested source commit for Windows-only packaging + type: string + default: "" + validated_run: + description: Existing stable run whose Windows native tests passed for source_commit + type: string + default: "" macos_signing: description: macOS signing mode; Developer ID also requires notarization secrets required: true @@ -25,7 +37,7 @@ on: default: adhoc concurrency: - group: stable-release-${{ github.ref }} + group: stable-release-${{ github.ref }}-${{ inputs.windows_only || false }} cancel-in-progress: false permissions: @@ -108,6 +120,7 @@ jobs: linux: name: Linux x86_64 stable package + if: ${{ !inputs.windows_only }} needs: prepare runs-on: ubuntu-22.04 timeout-minutes: 120 @@ -232,6 +245,7 @@ jobs: macos: name: macOS ${{ matrix.architecture }} stable package + if: ${{ !inputs.windows_only }} needs: prepare timeout-minutes: 120 strategy: @@ -373,10 +387,42 @@ jobs: needs: prepare runs-on: windows-2022 timeout-minutes: 120 + permissions: + contents: read + actions: read env: PACKAGE_VERSION: ${{ needs.prepare.outputs.version }} steps: - uses: actions/checkout@v4 + - name: Retain runtime preparation tool for the selected source + shell: pwsh + run: | + Copy-Item scripts/prepare-windows-runtime.ps1 "$env:RUNNER_TEMP/prepare-windows-runtime.ps1" + New-Item -ItemType Directory -Path "$env:RUNNER_TEMP/runtime-tests" -Force | Out-Null + Copy-Item scripts/tests/windows-runtime.tests.ps1 "$env:RUNNER_TEMP/runtime-tests/windows-runtime.tests.ps1" + - name: Verify the previous Windows test result + if: ${{ inputs.source_commit != '' }} + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + SELECTED_COMMIT: ${{ inputs.source_commit }} + VALIDATED_RUN: ${{ inputs.validated_run }} + WINDOWS_ONLY: ${{ inputs.windows_only }} + run: | + if ($env:WINDOWS_ONLY -ne 'true' -or $env:SELECTED_COMMIT -notmatch '^[0-9a-f]{40}$' -or $env:VALIDATED_RUN -notmatch '^[0-9]+$') { + throw 'Selecting old source requires Windows-only mode, an exact commit, and a validated run.' + } + $run = gh api "repos/$env:GITHUB_REPOSITORY/actions/runs/$env:VALIDATED_RUN" | ConvertFrom-Json + if ($LASTEXITCODE -ne 0 -or $run.head_sha -ne $env:SELECTED_COMMIT) { throw 'Validated run source differs.' } + $jobs = gh api "repos/$env:GITHUB_REPOSITORY/actions/runs/$env:VALIDATED_RUN/jobs?per_page=100" | ConvertFrom-Json + if ($LASTEXITCODE -ne 0) { throw 'Cannot verify prior native tests.' } + $windows = @($jobs.jobs | Where-Object name -eq 'Windows x64 stable package') + $tests = @($windows.steps | Where-Object { $_.name -eq 'Test workspace and native harness' -and $_.conclusion -eq 'success' }) + if ($windows.Count -ne 1 -or $tests.Count -ne 1) { throw 'Prior Windows native tests did not pass.' } + - uses: actions/checkout@v4 + if: ${{ inputs.source_commit != '' }} + with: + ref: ${{ inputs.source_commit }} - uses: actions/setup-python@v5 with: python-version: "3.13" @@ -386,14 +432,36 @@ jobs: - uses: Swatinem/rust-cache@v2 with: cache-on-failure: true + - name: Prepare pinned Windows console runtime + shell: pwsh + run: | + & "$env:RUNNER_TEMP/prepare-windows-runtime.ps1" -Destination assets/windows/conhost + & "$env:RUNNER_TEMP/runtime-tests/windows-runtime.tests.ps1" -ArchivePath (Join-Path ([IO.Path]::GetTempPath()) 'pebrel-conpty-source-v1.5.0.zip') - name: Test workspace and native harness + if: ${{ inputs.source_commit == '' }} run: | python -m unittest scripts.conformance.tests.test_lifecycle scripts.conformance.tests.test_scrollback cargo test --locked --workspace cargo test --locked -p nebula --bin pebrel --features gpui-shell cargo test --locked -p nebula --features gpui-test-support dialog - name: Build GPUI release binary - run: cargo build --locked --release -p nebula --bin pebrel --features gpui-shell + shell: pwsh + run: | + cargo build --locked --release -p nebula_hook --bin pebrel-hook + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + cargo build --locked --release -p nebula --bin pebrel --features gpui-shell + exit $LASTEXITCODE + - name: Retain built Windows runtime before packaging + uses: actions/upload-artifact@v4 + with: + name: stable-runtime-windows-x64 + path: | + target/release/pebrel.exe + target/release/pebrel-hook.exe + target/release/conpty.dll + target/release/OpenConsole.exe + if-no-files-found: error + retention-days: 30 - name: Test Windows packaging scripts shell: pwsh run: | @@ -412,9 +480,16 @@ jobs: $legacyHash = (Get-FileHash -LiteralPath $legacy -Algorithm SHA256).Hash if ($canonicalHash -ne $legacyHash) { throw 'legacy installer alias differs from canonical installer' } - name: Run Windows conformance + shell: pwsh env: NEBULA_RESIZE_TRACE: "1" - run: python scripts/conformance/run.py --app target/release/pebrel.exe --platform windows-x86_64 --output dist/conformance/windows-report.json + SELECTED_COMMIT: ${{ inputs.source_commit || github.sha }} + run: | + $actual = git rev-parse HEAD + if ($actual -ne $env:SELECTED_COMMIT) { throw 'Checked-out source differs from the requested build.' } + $env:GITHUB_SHA = $actual + python scripts/conformance/run.py --app target/release/pebrel.exe --platform windows-x86_64 --output dist/conformance/windows-report.json + exit $LASTEXITCODE - name: Upload Windows stable assets if: always() uses: actions/upload-artifact@v4 @@ -437,6 +512,7 @@ jobs: aggregate: name: Verify stable assets and upload complete set + if: ${{ !inputs.windows_only }} needs: [prepare, linux, macos, windows] runs-on: ubuntu-22.04 env: @@ -482,7 +558,7 @@ jobs: publish: name: Publish verified GitHub Release - if: ${{ github.event_name == 'push' || inputs.publish == true }} + if: ${{ !inputs.windows_only && (github.event_name == 'push' || inputs.publish == true) }} needs: [prepare, aggregate] runs-on: ubuntu-22.04 permissions: diff --git a/scripts/prepare-windows-runtime.ps1 b/scripts/prepare-windows-runtime.ps1 new file mode 100644 index 0000000..67b0d63 --- /dev/null +++ b/scripts/prepare-windows-runtime.ps1 @@ -0,0 +1,61 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string] $Destination, + [string] $ArchivePath +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$OutputEncoding = [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false) + +# Reuse the Microsoft 1.22 runtime pair already shipped with Pebrel's predecessor. +# Only these two redistributables are extracted, never the older application. +$archiveHash = '9B2413144C0434E29749CCBD5C2B0F93E930DAFD22634DD70D34B763037E0DA4' +$sourceUrl = 'https://github.com/Kuddev/pebrel/releases/download/v1.5.0/NebulaTerminal-v1.5.0-windows-x64.zip' +$expected = [ordered]@{ + 'conpty.dll' = '375BFB0479B6C53836AB307E3F9FD17BEDBD733F2E9690943D0F12E72FB80777' + 'OpenConsole.exe' = '55B18996761C88C351820E82508E05AB0EC2194AEEAD20724FDFAEEDEC076EF4' +} + +if ([string]::IsNullOrWhiteSpace($ArchivePath)) { + $ArchivePath = Join-Path ([System.IO.Path]::GetTempPath()) 'pebrel-conpty-source-v1.5.0.zip' + if (-not (Test-Path -LiteralPath $ArchivePath -PathType Leaf)) { + Invoke-WebRequest -Uri $sourceUrl -OutFile $ArchivePath -UseBasicParsing -TimeoutSec 120 + } +} +if ((Get-FileHash -LiteralPath $ArchivePath -Algorithm SHA256).Hash -ne $archiveHash) { + throw 'The pinned runtime source archive failed SHA256 verification.' +} + +Add-Type -AssemblyName System.IO.Compression.FileSystem +$archive = [System.IO.Compression.ZipFile]::OpenRead((Resolve-Path $ArchivePath).Path) +try { + New-Item -ItemType Directory -Path $Destination -Force | Out-Null + foreach ($name in $expected.Keys) { + $target = Join-Path $Destination $name + if ((Test-Path -LiteralPath $target -PathType Leaf) -and + (Get-FileHash -LiteralPath $target -Algorithm SHA256).Hash -eq $expected[$name]) { + continue + } + $entry = $archive.GetEntry("runtime\$name") + if ($null -eq $entry) { throw "Runtime archive is missing runtime/$name" } + $temporary = "$target.$([guid]::NewGuid().ToString('N')).tmp" + try { + $inputStream = $entry.Open() + try { + $outputStream = [System.IO.File]::Create($temporary) + try { $inputStream.CopyTo($outputStream) } finally { $outputStream.Dispose() } + } finally { $inputStream.Dispose() } + if ((Get-FileHash -LiteralPath $temporary -Algorithm SHA256).Hash -ne $expected[$name]) { + throw "The pinned $name failed SHA256 verification." + } + Move-Item -LiteralPath $temporary -Destination $target -Force + } finally { + if (Test-Path -LiteralPath $temporary) { Remove-Item -LiteralPath $temporary } + } + } +} finally { $archive.Dispose() } + +foreach ($name in $expected.Keys) { + Write-Output "$name SHA256 $($expected[$name])" +} diff --git a/scripts/tests/windows-runtime.tests.ps1 b/scripts/tests/windows-runtime.tests.ps1 new file mode 100644 index 0000000..f684460 --- /dev/null +++ b/scripts/tests/windows-runtime.tests.ps1 @@ -0,0 +1,28 @@ +[CmdletBinding()] +param([Parameter(Mandatory = $true)][string] $ArchivePath) + +$ErrorActionPreference = 'Stop' +$prepare = Join-Path $PSScriptRoot '../prepare-windows-runtime.ps1' +$root = Join-Path ([IO.Path]::GetTempPath()) "pebrel-runtime-test-$([guid]::NewGuid().ToString('N'))" +New-Item -ItemType Directory -Path $root | Out-Null +try { + $destination = Join-Path $root 'runtime' + & $prepare -Destination $destination -ArchivePath $ArchivePath + $before = @(Get-ChildItem $destination | Sort-Object Name | ForEach-Object { $_.LastWriteTimeUtc.Ticks }) + & $prepare -Destination $destination -ArchivePath $ArchivePath + $after = @(Get-ChildItem $destination | Sort-Object Name | ForEach-Object { $_.LastWriteTimeUtc.Ticks }) + if (@(Compare-Object $before $after).Count -ne 0) { throw 'Verified runtime files were unnecessarily replaced.' } + if (@(Get-ChildItem $destination).Count -ne 2) { throw 'Unexpected files were extracted.' } + + $invalid = Join-Path $root 'different.zip' + [IO.File]::WriteAllBytes($invalid, [byte[]]@(80, 75, 0, 0)) + $rejected = $false + try { & $prepare -Destination (Join-Path $root 'rejected') -ArchivePath $invalid } + catch { + if ($_.Exception.Message -notlike '*SHA256 verification*') { throw } + $rejected = $true + } + if (-not $rejected) { throw 'A different archive was accepted.' } + if (Test-Path (Join-Path $root 'rejected')) { throw 'Files were created before archive verification.' } + Write-Output 'windows-runtime.tests.ps1: PASS (pinned files, incremental reuse, hash mismatch)' +} finally { Remove-Item -LiteralPath $root -Recurse -Force } From f1dc1996cf74a9d158bc8e2b9a81dc2c26b6c7bc Mon Sep 17 00:00:00 2001 From: kuddev Date: Mon, 7 Sep 2026 20:30:45 +0800 Subject: [PATCH 23/31] release: finalize 1.6.0 checksums and mark Linux and macOS previews --- .github/workflows/release.yml | 1 + CHANGELOG.md | 20 ++++++++++---------- INSTALL.md | 23 ++++++++++++----------- README.md | 13 +++++++------ README.zh-CN.md | 7 ++++--- docs/release-notes/v1.6.0.md | 20 ++++++++++---------- scripts/stable_release.py | 25 ++++++++++++++++++++----- scripts/tests/test_stable_release.py | 8 ++++---- 8 files changed, 68 insertions(+), 49 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d112bc4..e621aee 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -544,6 +544,7 @@ jobs: PY python3 scripts/stable_release.py release-dist \ --version "$PACKAGE_VERSION" \ + --mark-platform-previews \ --notes "docs/release-notes/v$PACKAGE_VERSION.md" \ --changelog CHANGELOG.md \ --write-checksums release-dist/SHA256SUMS \ diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a8f1ff..ea24dc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ Every release entry is provided in English and Simplified Chinese. #### Added -- Added Linux x64 packages in AppImage, DEB, and portable tar.gz formats, plus separate macOS DMGs for Apple Silicon and Intel, alongside the Windows installer and ZIP. RPM packages are not included. macOS packages use ad-hoc signing and may require Open Anyway in System Settings > Privacy & Security on first launch. Addresses [#87](https://github.com/Kuddev/nebula/issues/87). +- Added Linux x64 Preview packages in AppImage, DEB, and portable tar.gz formats, plus separate macOS Preview DMGs for Apple Silicon and Intel, alongside the stable Windows installer and ZIP. RPM packages are not included. macOS packages use ad-hoc signing and may require Open Anyway in System Settings > Privacy & Security on first launch. Addresses [#87](https://github.com/Kuddev/nebula/issues/87). - Added clipboard image paste for local, WSL, and SSH terminals. Pebrel saves the image as a PNG and inserts a path that the session can access; SSH images are uploaded first. Attachment thumbnails and images in a CLI's conversation still depend on that CLI. - Added a reader for captured Claude Code and Codex answers, with Markdown, formulas, original text, and local image previews. - Added separate SOCKS5/HTTP proxy and jump-host settings for each SSH host, with saved proxy credentials and a connection-route preview. Addresses [#90](https://github.com/Kuddev/nebula/issues/90). @@ -49,7 +49,7 @@ Every release entry is provided in English and Simplified Chinese. #### 新增 -- 新增 Linux x64 的 AppImage、DEB 和 tar.gz 便携包,以及分别适用于 Apple Silicon 和 Intel 的 macOS DMG;Windows 继续提供安装器与 ZIP 便携包。本次不包含 RPM。macOS 包采用临时签名,首次启动可能需要在“系统设置 > 隐私与安全性”中选择“仍要打开”。对应 [#87](https://github.com/Kuddev/nebula/issues/87)。 +- 新增 Linux x64 Preview 的 AppImage、DEB 和 tar.gz 便携包,以及分别适用于 Apple Silicon 和 Intel 的 macOS Preview DMG;Windows 提供正式版安装器与 ZIP 便携包。本次不包含 RPM。macOS 包采用临时签名,首次启动可能需要在“系统设置 > 隐私与安全性”中选择“仍要打开”。对应 [#87](https://github.com/Kuddev/nebula/issues/87)。 - 新增本地、WSL 和 SSH 终端的剪贴板图片粘贴:将图片保存成 PNG,再插入当前会话可以访问的路径,SSH 会先上传图片。输入框缩略图和发送后的图片显示仍取决于所用 CLI。 - 新增 Claude Code 和 Codex 回答阅读器,支持阅读已捕获回答的 Markdown、公式、原文和本地图片。 - 新增每台 SSH 主机独立的 SOCKS5/HTTP 代理与跳板设置,可保存代理凭据并预览连接路线。对应 [#90](https://github.com/Kuddev/nebula/issues/90)。 @@ -97,14 +97,14 @@ Every release entry is provided in English and Simplified Chinese. **SHA256** -- `Pebrel-v1.6.0-windows-x64.zip`: `PENDING FINAL BUILD` -- `Pebrel-v1.6.0-windows-x64-setup.exe`: `PENDING FINAL BUILD` -- `NebulaTerminal-1.6.0-windows-x64-setup.exe`: `PENDING FINAL BUILD` -- `Pebrel-v1.6.0-linux-x64.AppImage`: `PENDING FINAL BUILD` -- `Pebrel-v1.6.0-linux-x64.deb`: `PENDING FINAL BUILD` -- `Pebrel-v1.6.0-linux-x64.tar.gz`: `PENDING FINAL BUILD` -- `Pebrel-v1.6.0-macos-arm64.dmg`: `PENDING FINAL BUILD` -- `Pebrel-v1.6.0-macos-x64.dmg`: `PENDING FINAL BUILD` +- `Pebrel-v1.6.0-windows-x64.zip`: `d57f9c61988ca950da1088415717137b85e022475e046d611f3595b48e66ce2d` +- `Pebrel-v1.6.0-windows-x64-setup.exe`: `369d5f93f34d7f90bde91b51a4f66259d1f277619f7e2b618fb6e03d449f605f` +- `NebulaTerminal-1.6.0-windows-x64-setup.exe`: `369d5f93f34d7f90bde91b51a4f66259d1f277619f7e2b618fb6e03d449f605f` +- `Pebrel-v1.6.0-linux-x64-preview.AppImage`: `91e44fef5f56b3bc5f840a603cbcbf1a8186c4fa042ea012a47b11855cc305cd` +- `Pebrel-v1.6.0-linux-x64-preview.deb`: `f3f4f6292f5460dd7ed626d9a5d8279ae89f7cce1bd21c90db7acd4164eef57a` +- `Pebrel-v1.6.0-linux-x64-preview.tar.gz`: `0e56fb2a01d8cce71e4cda8290f8edde1868d37e588cdbf443bc42dee18dd22b` +- `Pebrel-v1.6.0-macos-arm64-preview.dmg`: `1b747113b2d6f868f2547a365d72a0ec4a2d045900708f2f7c18315d2691cf74` +- `Pebrel-v1.6.0-macos-x64-preview.dmg`: `1bf53ef3dc957a8e3bac89b5a516f4c1153651c48eb58e912180f3ab48a46548` ## 1.5.0 - 2026-09-01 diff --git a/INSTALL.md b/INSTALL.md index a486cbf..e9dcdf8 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -5,16 +5,16 @@ Older releases keep their original filenames. Use the new installer to migrate an existing Nebula installation; configuration migration is handled by the application at startup. -## Linux +## Linux Preview -Stable releases provide three Linux x64 packages. +The 1.6.0 release provides three Linux x64 Preview packages. - Debian/Ubuntu: install - `Pebrel-v-linux-x64.deb` with - `sudo apt install ./Pebrel-v-linux-x64.deb`. + `Pebrel-v-linux-x64-preview.deb` with + `sudo apt install ./Pebrel-v-linux-x64-preview.deb`. Remove it with `sudo apt remove pebrel`. - AppImage: make - `Pebrel-v-linux-x64.AppImage` executable and + `Pebrel-v-linux-x64-preview.AppImage` executable and run it directly. It does not register itself with the system package manager. - Portable archive: extract the `tar.gz` and run its `AppRun` launcher. Keep the AppDir layout intact so bundled libraries are found correctly. @@ -30,12 +30,12 @@ passphrases requires `libsecret-tools` and an unlocked Secret Service keyring recommends these dependencies. If storage is unavailable, enter the secret for the current connection instead; Pebrel does not silently claim to save it. -## macOS +## macOS Preview Download the DMG matching the Mac architecture: -- `Pebrel-v-macos-arm64.dmg` for Apple Silicon Macs. -- `Pebrel-v-macos-x64.dmg` for Intel Macs. +- `Pebrel-v-macos-arm64-preview.dmg` for Apple Silicon Macs. +- `Pebrel-v-macos-x64-preview.dmg` for Intel Macs. Open the DMG and drag **Pebrel** into Applications. The 1.6.0 packages use ad-hoc signing, without Apple notarization. For a @@ -63,9 +63,10 @@ scaling, notification permissions, and interactive SSH/SFTP still need native user testing. The release procedure and acceptance checklist are in [`docs/preview-release-checklist.md`](docs/preview-release-checklist.md). -Preview builds remain separate from stable releases. Their filenames contain -`-preview.` and use `x86_64` / `aarch64`; the Debian package is -`pebrel-preview` and the macOS bundle is **Pebrel Preview**. +Earlier CI Preview archives may contain `-preview.` and use `x86_64` / +`aarch64` filenames; those older builds use the Debian package name +`pebrel-preview` and the macOS bundle name **Pebrel Preview**. The 1.6.0 platform +Preview packages use `pebrel` and **Pebrel**, as described above. ## Windows installer (recommended) diff --git a/README.md b/README.md index c192418..7470852 100644 --- a/README.md +++ b/README.md @@ -96,13 +96,14 @@ Choose a package from the **[latest release](https://github.com/Kuddev/pebrel/re | System | Architecture | Packages | | --- | --- | --- | | Windows 10 1809+ / 11 | x64 | Installer `.exe`, portable `.zip` | -| Linux, glibc 2.35+ | x64 | `.AppImage`, `.deb`, portable `.tar.gz` | -| macOS, deployment target 14+ | Apple Silicon / arm64 | `.dmg` | -| macOS, deployment target 14+ | Intel / x64 | `.dmg` | +| Linux Preview, glibc 2.35+ | x64 | `.AppImage`, `.deb`, portable `.tar.gz` | +| macOS Preview, deployment target 14+ | Apple Silicon / arm64 | `.dmg` | +| macOS Preview, deployment target 14+ | Intel / x64 | `.dmg` | -Package names follow `Pebrel-v--`. Windows installers -end in `-setup.exe`. A legacy-named installer is also supplied for older automatic -update clients. +Package names follow `Pebrel-v--`. Windows offers +stable installers ending in `-setup.exe` and portable ZIPs. Linux and macOS are +Preview builds, marked `-preview` before the extension. A legacy-named Windows +installer is also supplied for older automatic-update clients. On Windows, run the installer or extract the ZIP and launch `pebrel.exe`, keeping the bundled directories together. On Linux, install the DEB or make the AppImage diff --git a/README.zh-CN.md b/README.zh-CN.md index beb6c8c..3e8348c 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -88,11 +88,12 @@ Pebrel(原名 Nebula)把本地 Shell、远程主机、文件和 AI 命令行 | 系统 | 架构 | 安装包 | | --- | --- | --- | | Windows 10 1809+ / 11 | x64 | `.exe` 安装器、`.zip` 便携包 | -| Linux,glibc 2.35+ | x64 | `.AppImage`、`.deb`、`.tar.gz` 便携包 | -| macOS,部署目标为 14+ | Apple Silicon / arm64 | `.dmg` | -| macOS,部署目标为 14+ | Intel / x64 | `.dmg` | +| Linux Preview,glibc 2.35+ | x64 | `.AppImage`、`.deb`、`.tar.gz` 便携包 | +| macOS Preview,部署目标为 14+ | Apple Silicon / arm64 | `.dmg` | +| macOS Preview,部署目标为 14+ | Intel / x64 | `.dmg` | 安装包统一采用 `Pebrel-v<版本>-<系统>-<架构>` 命名,Windows 安装器以 `-setup.exe` 结尾。 +Windows 为正式版,Linux 与 macOS 文件名在扩展名前带有 `-preview` 标记。 同时提供旧名称的兼容安装器,供旧版自动更新客户端使用。 Windows 可运行安装器,或解压 ZIP 后启动 `pebrel.exe`,保留随包目录结构。 diff --git a/docs/release-notes/v1.6.0.md b/docs/release-notes/v1.6.0.md index d8f92ff..7669762 100644 --- a/docs/release-notes/v1.6.0.md +++ b/docs/release-notes/v1.6.0.md @@ -4,7 +4,7 @@ ### Added -- Added Linux x64 packages in AppImage, DEB, and portable tar.gz formats, plus separate macOS DMGs for Apple Silicon and Intel, alongside the Windows installer and ZIP. RPM packages are not included. macOS packages use ad-hoc signing and may require Open Anyway in System Settings > Privacy & Security on first launch. Addresses [#87](https://github.com/Kuddev/nebula/issues/87). +- Added Linux x64 Preview packages in AppImage, DEB, and portable tar.gz formats, plus separate macOS Preview DMGs for Apple Silicon and Intel, alongside the stable Windows installer and ZIP. RPM packages are not included. macOS packages use ad-hoc signing and may require Open Anyway in System Settings > Privacy & Security on first launch. Addresses [#87](https://github.com/Kuddev/nebula/issues/87). - Added clipboard image paste for local, WSL, and SSH terminals. Pebrel saves the image as a PNG and inserts a path that the session can access; SSH images are uploaded first. Attachment thumbnails and images in a CLI's conversation still depend on that CLI. - Added a reader for captured Claude Code and Codex answers, with Markdown, formulas, original text, and local image previews. - Added separate SOCKS5/HTTP proxy and jump-host settings for each SSH host, with saved proxy credentials and a connection-route preview. Addresses [#90](https://github.com/Kuddev/nebula/issues/90). @@ -43,7 +43,7 @@ ### 新增 -- 新增 Linux x64 的 AppImage、DEB 和 tar.gz 便携包,以及分别适用于 Apple Silicon 和 Intel 的 macOS DMG;Windows 继续提供安装器与 ZIP 便携包。本次不包含 RPM。macOS 包采用临时签名,首次启动可能需要在“系统设置 > 隐私与安全性”中选择“仍要打开”。对应 [#87](https://github.com/Kuddev/nebula/issues/87)。 +- 新增 Linux x64 Preview 的 AppImage、DEB 和 tar.gz 便携包,以及分别适用于 Apple Silicon 和 Intel 的 macOS Preview DMG;Windows 提供正式版安装器与 ZIP 便携包。本次不包含 RPM。macOS 包采用临时签名,首次启动可能需要在“系统设置 > 隐私与安全性”中选择“仍要打开”。对应 [#87](https://github.com/Kuddev/nebula/issues/87)。 - 新增本地、WSL 和 SSH 终端的剪贴板图片粘贴:将图片保存成 PNG,再插入当前会话可以访问的路径,SSH 会先上传图片。输入框缩略图和发送后的图片显示仍取决于所用 CLI。 - 新增 Claude Code 和 Codex 回答阅读器,支持阅读已捕获回答的 Markdown、公式、原文和本地图片。 - 新增每台 SSH 主机独立的 SOCKS5/HTTP 代理与跳板设置,可保存代理凭据并预览连接路线。对应 [#90](https://github.com/Kuddev/nebula/issues/90)。 @@ -91,11 +91,11 @@ **SHA256** -- `Pebrel-v1.6.0-windows-x64.zip`: `PENDING FINAL BUILD` -- `Pebrel-v1.6.0-windows-x64-setup.exe`: `PENDING FINAL BUILD` -- `NebulaTerminal-1.6.0-windows-x64-setup.exe`: `PENDING FINAL BUILD` -- `Pebrel-v1.6.0-linux-x64.AppImage`: `PENDING FINAL BUILD` -- `Pebrel-v1.6.0-linux-x64.deb`: `PENDING FINAL BUILD` -- `Pebrel-v1.6.0-linux-x64.tar.gz`: `PENDING FINAL BUILD` -- `Pebrel-v1.6.0-macos-arm64.dmg`: `PENDING FINAL BUILD` -- `Pebrel-v1.6.0-macos-x64.dmg`: `PENDING FINAL BUILD` +- `Pebrel-v1.6.0-windows-x64.zip`: `d57f9c61988ca950da1088415717137b85e022475e046d611f3595b48e66ce2d` +- `Pebrel-v1.6.0-windows-x64-setup.exe`: `369d5f93f34d7f90bde91b51a4f66259d1f277619f7e2b618fb6e03d449f605f` +- `NebulaTerminal-1.6.0-windows-x64-setup.exe`: `369d5f93f34d7f90bde91b51a4f66259d1f277619f7e2b618fb6e03d449f605f` +- `Pebrel-v1.6.0-linux-x64-preview.AppImage`: `91e44fef5f56b3bc5f840a603cbcbf1a8186c4fa042ea012a47b11855cc305cd` +- `Pebrel-v1.6.0-linux-x64-preview.deb`: `f3f4f6292f5460dd7ed626d9a5d8279ae89f7cce1bd21c90db7acd4164eef57a` +- `Pebrel-v1.6.0-linux-x64-preview.tar.gz`: `0e56fb2a01d8cce71e4cda8290f8edde1868d37e588cdbf443bc42dee18dd22b` +- `Pebrel-v1.6.0-macos-arm64-preview.dmg`: `1b747113b2d6f868f2547a365d72a0ec4a2d045900708f2f7c18315d2691cf74` +- `Pebrel-v1.6.0-macos-x64-preview.dmg`: `1bf53ef3dc957a8e3bac89b5a516f4c1153651c48eb58e912180f3ab48a46548` diff --git a/scripts/stable_release.py b/scripts/stable_release.py index 638983b..7747af0 100644 --- a/scripts/stable_release.py +++ b/scripts/stable_release.py @@ -35,17 +35,29 @@ def validate_version(version: str) -> None: def expected_asset_names(version: str) -> tuple[str, ...]: validate_version(version) return ( - f"Pebrel-v{version}-linux-x64.AppImage", - f"Pebrel-v{version}-linux-x64.deb", - f"Pebrel-v{version}-linux-x64.tar.gz", - f"Pebrel-v{version}-macos-arm64.dmg", - f"Pebrel-v{version}-macos-x64.dmg", + f"Pebrel-v{version}-linux-x64-preview.AppImage", + f"Pebrel-v{version}-linux-x64-preview.deb", + f"Pebrel-v{version}-linux-x64-preview.tar.gz", + f"Pebrel-v{version}-macos-arm64-preview.dmg", + f"Pebrel-v{version}-macos-x64-preview.dmg", f"Pebrel-v{version}-windows-x64.zip", f"Pebrel-v{version}-windows-x64-setup.exe", f"NebulaTerminal-{version}-windows-x64-setup.exe", ) +def mark_platform_previews(directory: Path, version: str) -> None: + for name in expected_asset_names(version): + if "-preview." not in name: + continue + original = directory / name.replace("-preview.", ".", 1) + target = directory / name + if original.exists(): + if target.exists() or original.is_symlink() or not original.is_file(): + raise StableReleaseError(f"cannot mark platform preview without replacing a file: {name}") + original.rename(target) + + def _check_magic(path: Path) -> None: with path.open("rb") as stream: header = stream.read(8) @@ -229,6 +241,7 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("directory", type=Path) parser.add_argument("--version", required=True) + parser.add_argument("--mark-platform-previews", action="store_true") parser.add_argument("--notes", type=Path) parser.add_argument("--changelog", type=Path) parser.add_argument("--write-checksums", type=Path) @@ -242,6 +255,8 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: def main(argv: list[str] | None = None) -> int: args = parse_args(argv) try: + if args.mark_platform_previews: + mark_platform_previews(args.directory.resolve(), args.version) assets = validate_assets(args.directory.resolve(), args.version) notes = None if args.notes: diff --git a/scripts/tests/test_stable_release.py b/scripts/tests/test_stable_release.py index 19a5c12..e80e292 100644 --- a/scripts/tests/test_stable_release.py +++ b/scripts/tests/test_stable_release.py @@ -82,9 +82,9 @@ class StableReleaseTests(unittest.TestCase): def test_expected_assets_use_stable_public_names(self) -> None: names = expected_asset_names(VERSION) - self.assertIn("Pebrel-v1.6.0-linux-x64.AppImage", names) - self.assertIn("Pebrel-v1.6.0-macos-arm64.dmg", names) - self.assertIn("Pebrel-v1.6.0-macos-x64.dmg", names) + self.assertIn("Pebrel-v1.6.0-linux-x64-preview.AppImage", names) + self.assertIn("Pebrel-v1.6.0-macos-arm64-preview.dmg", names) + self.assertIn("Pebrel-v1.6.0-macos-x64-preview.dmg", names) self.assertIn("Pebrel-v1.6.0-windows-x64-setup.exe", names) self.assertIn("NebulaTerminal-1.6.0-windows-x64-setup.exe", names) self.assertEqual(len(names), 8) @@ -109,7 +109,7 @@ class StableReleaseTests(unittest.TestCase): root = Path(directory) for name in expected_asset_names(VERSION): write_fake_asset(root / name) - (root / "Pebrel-v1.6.0-linux-x64.AppImage").write_bytes(b"bad" + b"\0" * MIN_ASSET_SIZE) + (root / "Pebrel-v1.6.0-linux-x64-preview.AppImage").write_bytes(b"bad" + b"\0" * MIN_ASSET_SIZE) with self.assertRaisesRegex(StableReleaseError, "ELF"): validate_assets(root, VERSION) From d3812c2234f0659f1b7cce76b770465966f1bd47 Mon Sep 17 00:00:00 2001 From: kuddev Date: Mon, 7 Sep 2026 20:39:33 +0800 Subject: [PATCH 24/31] release: record verified assets and synchronize published notes --- .agents/memory.md | 30 ++++++++++++++++++++-------- CHANGELOG.md | 3 +-- docs/release-notes/v1.6.0.md | 3 +-- scripts/stable_release.py | 3 ++- scripts/tests/test_stable_release.py | 2 ++ 5 files changed, 28 insertions(+), 13 deletions(-) diff --git a/.agents/memory.md b/.agents/memory.md index 86e5240..6dc3f03 100644 --- a/.agents/memory.md +++ b/.agents/memory.md @@ -1,6 +1,6 @@ # Pebrel 持久化发布记忆 -最后核验:2026-09-07,GitHub 仓库更名、旧链接跳转与历史发布资产一致性。1.6.0 仍为本地候选,本次没有创建正式 Release、推送发布提交或移动版本标签。 +最后核验:2026-09-07,Pebrel 1.6.0 已公开发布,Release ID 为 `384071347`。标签 `v1.6.0` 指向实际构建源码 `5b8285388d5f63b9962c135825aff039aaa3ce56`;后续说明和打包流程修订跟随 `main`,不移动此标签。Windows 为正式版,Linux 与 macOS 标注 Preview。 本文记录已经实际遇到并核实的发布陷阱,以及后续版本可直接执行的 Release Note 与 GitHub Release 规则。它不保存密钥、令牌、临时构建路径或未经验证的猜测。 @@ -20,21 +20,35 @@ 官方说明:[Renaming a repository](https://docs.github.com/en/repositories/creating-and-managing-repositories/renaming-a-repository)。历史版本说明、标签与资产文件名保持原样;仓库更名不会替已改名的 Release 资产创建文件名别名。 -1.6.0 候选的源码与打包脚本已采用以下新名称,但**这不代表 1.6.0 已经公开发布**: +1.6.0 已发布并核验以下名称(2026-09-07): -| 对象 | 1.6.0 候选名称 | +| 对象 | 1.6.0 发布名称 | | --- | --- | | 主程序与 CLI | `pebrel.exe` / `pebrel` | | AI hook 辅助程序 | `runtime/pebrel-hook.exe` | | Portable ZIP | `Pebrel-v1.6.0-windows-x64.zip` | -| Windows 安装器 | `Pebrel-1.6.0-windows-x64-setup.exe` | +| Windows 安装器 | `Pebrel-v1.6.0-windows-x64-setup.exe` | +| 旧版更新兼容安装器 | `NebulaTerminal-1.6.0-windows-x64-setup.exe`,与新名安装器字节一致 | +| Linux Preview | `Pebrel-v1.6.0-linux-x64-preview.AppImage` / `.deb` / `.tar.gz` | +| macOS Preview | `Pebrel-v1.6.0-macos-arm64-preview.dmg` / `Pebrel-v1.6.0-macos-x64-preview.dmg` | | Release Note | `docs/release-notes/v1.6.0.md`,标题 `Pebrel 1.6.0` | 旧客户端按精确的 `NebulaTerminal--windows-x64-setup.exe` 名称查找更新。 -未来公开发布 Pebrel 安装资产时,还需要提供相同安装器字节的旧命名兼容资产,或先完成明确的客户端迁移方案;不能把仓库重定向当成资产改名兼容。 -目前下载入口应指向真实的 `https://github.com/Kuddev/pebrel/releases/latest` 页面,不能生成指向尚未发布 Pebrel 资产的 `latest/download/...` 链接。 +本次保留了相同安装器字节的旧命名兼容资产;两份安装器 SHA256 均为 `369d5f93f34d7f90bde91b51a4f66259d1f277619f7e2b618fb6e03d449f605f`。自动迁移发生在安装器启动之后,不能替代旧客户端按精确文件名发现安装包的步骤。 +Pebrel 1.6 已识别新安装器名称。未来停止支持旧 Nebula 客户端直接自动升级后,新版本可不再提供旧名;已发布的兼容资产应继续保留,避免破坏历史升级入口和下载链接。 +下载入口可使用真实的 `https://github.com/Kuddev/pebrel/releases/latest` 页面;直接资产链接必须逐一对应实际文件名。 -下文保留 1.3.0 / 1.3.1 的已发布核验先例与通用发布步骤。其历史名称用于识别旧版本,新的候选名称以上表和当前打包脚本为准;最终 SHA256 仍必须在新文件生成后重新计算。 +### 1.6.0 发布核验与增量原则(2026-09-07) + +- [Release](https://github.com/Kuddev/pebrel/releases/tag/v1.6.0) 为非 draft、非 prerelease;后者表示 Windows 正式版,Linux/macOS 的 Preview 状态由文件名、README 与发布说明明确标注。 +- 共有八个安装资产及 `SHA256SUMS`。GitHub 返回的真实名称、空 label、uploaded 状态、大小和 digest 已逐项与下载到本地的最终文件核对。说明采用 1.4.1 的双语分类和贡献者头像结构,包含经原帖核实的 Issue 引用。 +- Linux、Apple Silicon、Intel macOS 产物来自运行 `34112627486` 的成功平台任务。Windows 在该轮已通过程序编译和原生测试,但打包自测缺少 hook 与 ConPTY 文件;运行 `34118913759` 仅补 Windows,并固定检出同一源码 `5b82853`,未重跑已通过的其他平台。 +- 六份终端验收报告和两份 macOS LaunchServices 报告均通过,并记录同一个构建源码提交。早先 Preview 运行 `34112511937` 的 Intel 镜像创建遇到 `Resource busy`;发布使用的是随后已完成并通过验收的上述正式构建产物,未用失败产物替代。 +- Windows CI 必须先准备 `pebrel-hook.exe`、`conpty.dll` 和 `OpenConsole.exe`,再运行打包自测。`scripts/prepare-windows-runtime.ps1` 从固定 SHA256 的 1.5.0 已发布归档中仅取出既有 Microsoft 1.22 运行文件,并逐文件校验;主程序和 hook 仍从本次源码构建。 +- 已成功的安装包须保留并复用。README、Release Note 和渠道标注变动不要求重编译程序;本次给非 Windows 文件添加 `-preview` 只改文件名,未改变内容或 SHA256。不得因此重跑全部平台。 +- 发布动作会触发标签上的旧工作流;已验证并上传资产后,应取消重复构建,而不是再次生成或覆盖同一发布资产。 + +下文保留 1.3.0 / 1.3.1 的已发布核验先例与通用发布步骤。其历史名称用于识别旧版本,1.6 起的新名称以上表为准;文件内容实际改变后必须重新计算 SHA256,不能套用旧值。 ## 1. 命名规范是接口,不是装饰 @@ -48,7 +62,7 @@ | Windows 安装器 | `NebulaTerminal-X.Y.Z-windows-x64-setup.exe` | `NebulaTerminal-1.3.1-windows-x64-setup.exe` | | Release Note 文件 | `docs/release-notes/vX.Y.Z.md` | `docs/release-notes/v1.3.1.md` | -- ZIP 带 `v`,安装器不带 `v`。不要为了“看起来统一”擅自改成同一种格式。 +- 历史版本的 ZIP 带 `v`、安装器不带 `v`,历史资产不改名。维护者在 1.6 发布中明确要求统一新名称,因此新的 Pebrel ZIP 与安装器均带 `v`;旧名兼容安装器保留原有格式。 - 应用内更新会按精确安装器文件名选择资产。发布资产改名不仅影响展示,还可能直接破坏自动更新。 - GitHub CLI 上传资产时,`#