From bf32577dc80ac3579c4416252b9fb2f61f10e7bb Mon Sep 17 00:00:00 2001 From: GeekMr <140129782+WilliamWang1721@users.noreply.github.com> Date: Sat, 12 Sep 2026 22:07:27 +0800 Subject: [PATCH 01/51] fix(macos): align native window controls with titlebar buttons --- .github/workflows/preview-packages.yml | 9 ++- .github/workflows/release.yml | 8 +- INSTALL.md | 5 +- nebula_app/Cargo.toml | 3 + .../gpui_shell/workspace/window_titlebar.rs | 16 +++- .../workspace/window_titlebar/macos.rs | 80 +++++++++++++++++++ .../src/gpui_shell/workspace/windowing.rs | 14 +++- 7 files changed, 128 insertions(+), 7 deletions(-) create mode 100644 nebula_app/src/gpui_shell/workspace/window_titlebar/macos.rs diff --git a/.github/workflows/preview-packages.yml b/.github/workflows/preview-packages.yml index 3a80e45..426ddd2 100644 --- a/.github/workflows/preview-packages.yml +++ b/.github/workflows/preview-packages.yml @@ -302,10 +302,10 @@ jobs: fail-fast: false matrix: include: - - runner: macos-15 + - runner: macos-26 architecture: aarch64 native_architecture: arm64 - - runner: macos-15-intel + - runner: macos-26-intel architecture: x86_64 native_architecture: x86_64 runs-on: ${{ matrix.runner }} @@ -323,6 +323,11 @@ jobs: - name: Verify native runner architecture run: test "$(uname -m)" = "${{ matrix.native_architecture }}" + - name: Require the modern AppKit SDK for native window controls + run: | + sdk_version="$(xcrun --sdk macosx --show-sdk-version)" + test "${sdk_version%%.*}" -ge 26 + - name: Test native DMG capacity and mounted contents run: python3 -m unittest scripts.tests.test_macos_dmg -v diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1807f4a..fddf61c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -253,11 +253,11 @@ jobs: fail-fast: false matrix: include: - - runner: macos-15 + - runner: macos-26 architecture: aarch64 native_architecture: arm64 asset_architecture: arm64 - - runner: macos-15-intel + - runner: macos-26-intel architecture: x86_64 native_architecture: x86_64 asset_architecture: x64 @@ -272,6 +272,10 @@ jobs: python-version: "3.13" - name: Verify native runner architecture run: test "$(uname -m)" = "${{ matrix.native_architecture }}" + - name: Require the modern AppKit SDK for native window controls + run: | + sdk_version="$(xcrun --sdk macosx --show-sdk-version)" + test "${sdk_version%%.*}" -ge 26 - name: Test native DMG capacity and mounted contents run: python3 -m unittest scripts.tests.test_macos_dmg -v - uses: dtolnay/rust-toolchain@stable diff --git a/INSTALL.md b/INSTALL.md index e9dcdf8..d8d415d 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -128,7 +128,10 @@ Keep the extracted directory structure intact: 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. +tools with macOS SDK 26 or newer (`xcrun --sdk macosx --show-sdk-version`) for +the native Liquid Glass window controls. This build requirement does not raise +the macOS 14 minimum runtime version. Windows requires Windows 10 1809+ / 11 +and a supported Rust linker toolchain. ```powershell git clone https://github.com/Kuddev/pebrel diff --git a/nebula_app/Cargo.toml b/nebula_app/Cargo.toml index 8358e5d..451a293 100644 --- a/nebula_app/Cargo.toml +++ b/nebula_app/Cargo.toml @@ -181,7 +181,10 @@ objc2-foundation = { version = "0.3.1", default-features = false, features = [ objc2-app-kit = { version = "0.3.1", default-features = false, features = [ "std", "NSColorSpace", + "NSButton", + "NSControl", "NSResponder", + "NSToolbar", "NSView", "NSWindow", ] } diff --git a/nebula_app/src/gpui_shell/workspace/window_titlebar.rs b/nebula_app/src/gpui_shell/workspace/window_titlebar.rs index 2648b65..56c9377 100644 --- a/nebula_app/src/gpui_shell/workspace/window_titlebar.rs +++ b/nebula_app/src/gpui_shell/workspace/window_titlebar.rs @@ -1,5 +1,8 @@ use super::*; +#[cfg(target_os = "macos")] +pub(super) mod macos; + /// Prepaint records the actual pane column before any titlebar paint runs. Keep /// this cell for the workspace lifetime, so resizing and panel animations do not /// allocate a new shared slot or duplicate the body's layout calculations. @@ -94,8 +97,11 @@ impl NebulaWorkspace { .when(settings_active, |bar| { bar.border_b_1().border_color(crate::gpui_shell::theme::settings_hairline(cx)) }) + .when(top_tabs && !cfg!(target_os = "macos"), |bar| { + bar.pl(px(top_tabs::TOP_TAB_LEFT_INSET)) + }) .when(top_tabs, |bar| { - bar.pl(px(top_tabs::TOP_TAB_LEFT_INSET)).child(self.render_top_title_bar( + bar.child(self.render_top_title_bar( files_active, git_active, settings_active, @@ -112,6 +118,14 @@ impl NebulaWorkspace { )) }); + // AppKit owns both the control group and its geometry. Read the live + // frames so system layout, resize and full-screen transitions agree. + #[cfg(target_os = "macos")] + let bar = { + let (height, inset) = macos::layout(window); + bar.h(px(height)).pl(px(inset)) + }; + div() .relative() .flex_shrink_0() diff --git a/nebula_app/src/gpui_shell/workspace/window_titlebar/macos.rs b/nebula_app/src/gpui_shell/workspace/window_titlebar/macos.rs new file mode 100644 index 0000000..8302715 --- /dev/null +++ b/nebula_app/src/gpui_shell/workspace/window_titlebar/macos.rs @@ -0,0 +1,80 @@ +//! Keep the standard window controls in AppKit's native toolbar hierarchy. + +use gpui::Window; +use objc2::{MainThreadOnly, rc::Retained}; +use objc2_app_kit::{NSToolbar, NSView, NSWindow, NSWindowButton, NSWindowToolbarStyle}; +use objc2_foundation::{NSRect, ns_string}; +use winit::raw_window_handle::{HasWindowHandle, RawWindowHandle}; + +fn native_window(window: &Window) -> Option> { + let handle = HasWindowHandle::window_handle(window).ok()?; + let RawWindowHandle::AppKit(handle) = handle.as_raw() else { return None }; + // SAFETY: GPUI owns this live NSView and calls the adapter on the UI thread. + let view = unsafe { handle.ns_view.cast::().as_ref() }; + view.window() +} + +pub(in super::super) fn configure(window: &Window) { + let Some(native) = native_window(window) else { return }; + let toolbar = NSToolbar::initWithIdentifier( + NSToolbar::alloc(native.mtm()), + ns_string!("pebrel-window-controls"), + ); + // An empty native toolbar supplies the system's current window-control + // treatment. AppKit retains it; GPUI continues to render the app controls. + native.setToolbarStyle(NSWindowToolbarStyle::Unified); + native.setToolbar(Some(&toolbar)); +} + +pub(super) fn layout(window: &Window) -> (f32, f32) { + if window.is_fullscreen() { + return (52.0, 8.0); + } + let Some(native) = native_window(window) else { return (52.0, 96.0) }; + let Some(close) = native.standardWindowButton(NSWindowButton::CloseButton) else { + return (52.0, 96.0); + }; + let Some(zoom) = native.standardWindowButton(NSWindowButton::ZoomButton) else { + return (52.0, 96.0); + }; + let Some(content) = native.contentView() else { return (52.0, 96.0) }; + let close = close.convertRect_toView(close.bounds(), Some(&content)); + let zoom = zoom.convertRect_toView(zoom.bounds(), Some(&content)); + control_layout(close, zoom, content.bounds(), content.isFlipped()) +} + +fn control_layout(close: NSRect, zoom: NSRect, content: NSRect, flipped: bool) -> (f32, f32) { + let center = close.origin.y + close.size.height / 2.0; + let center_from_top = if flipped { + center - content.origin.y + } else { + content.origin.y + content.size.height - center + }; + ( + 2.0 * center_from_top as f32, + (zoom.origin.x + zoom.size.width - content.origin.x + 16.0) as f32, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use objc2_foundation::{NSPoint, NSSize}; + + #[test] + fn app_controls_follow_native_centers_and_leave_room_for_the_group() { + for height in [600.0, 900.0] { + for (inset, center) in [(19.0, 26.0), (12.0, 20.0)] { + for flipped in [false, true] { + let content = NSRect::new(NSPoint::new(5.0, 3.0), NSSize::new(900.0, height)); + let y = 3.0 + if flipped { center - 7.0 } else { height - center - 7.0 }; + let close = NSRect::new(NSPoint::new(5.0 + inset, y), NSSize::new(14.0, 14.0)); + let zoom = NSRect::new(NSPoint::new(5.0 + inset + 46.0, y), close.size); + let (bar_height, left_padding) = control_layout(close, zoom, content, flipped); + assert_eq!(bar_height / 2.0, center as f32); + assert_eq!(left_padding, (inset + 46.0 + 14.0 + 16.0) as f32); + } + } + } + } +} diff --git a/nebula_app/src/gpui_shell/workspace/windowing.rs b/nebula_app/src/gpui_shell/workspace/windowing.rs index 3cb2442..657159d 100644 --- a/nebula_app/src/gpui_shell/workspace/windowing.rs +++ b/nebula_app/src/gpui_shell/workspace/windowing.rs @@ -313,7 +313,17 @@ fn workspace_window_options(cx: &mut App, focus: bool, role: WindowRole) -> Wind WindowOptions { window_bounds: Some(WindowBounds::Windowed(bounds)), window_min_size: Some(size(px(760.0), px(540.0)).min(&bounds.size)), - titlebar: Some(TitleBar::title_bar_options()), + titlebar: Some(gpui::TitlebarOptions { + // A manual traffic-light position makes GPUI rewrite AppKit's + // titlebar container, breaking the system toolbar layout. + traffic_light_position: if cfg!(target_os = "macos") { + None + } else { + TitleBar::title_bar_options().traffic_light_position + }, + ..TitleBar::title_bar_options() + }), + app_owns_titlebar_drag: cfg!(target_os = "macos"), app_id: Some("pebrel".to_owned()), window_background: crate::gpui_shell::wallpaper::initial_background_appearance(), focus, @@ -382,6 +392,8 @@ fn open_workspace_window( let hwnd_out = hwnd_slot.clone(); let handle = cx.open_window(options, move |window, cx| { window.set_window_title(crate::brand::NAME); + #[cfg(target_os = "macos")] + super::window_titlebar::macos::configure(window); *hwnd_out.borrow_mut() = native_hwnd(window).unwrap_or_default(); #[cfg(windows)] crate::gpui_shell::set_native_window_icon(window); From 00c4e3bb7977a04602366660ab2fe32049a57655 Mon Sep 17 00:00:00 2001 From: GeekMr <140129782+WilliamWang1721@users.noreply.github.com> Date: Sat, 12 Sep 2026 22:27:29 +0800 Subject: [PATCH 02/51] fix(settings): confirm before restoring defaults --- nebula_app/i18n/en-US.json | 2 ++ nebula_app/i18n/zh-CN.json | 2 ++ nebula_app/src/gpui_shell/settings_pane.rs | 2 +- .../src/gpui_shell/settings_pane/reset.rs | 26 ++++++++++++++++++- 4 files changed, 30 insertions(+), 2 deletions(-) diff --git a/nebula_app/i18n/en-US.json b/nebula_app/i18n/en-US.json index 40365c5..4ea5bbf 100644 --- a/nebula_app/i18n/en-US.json +++ b/nebula_app/i18n/en-US.json @@ -6,6 +6,8 @@ "en_us": "English" }, "settings": { + "reset_title": "Restore default settings?", + "reset_description": "This will reset your settings and keyboard shortcuts. SSH hosts, credentials and history will be kept, and your current settings will be backed up. Continue?", "sidebar": { "application": "Application", "appearance": "Appearance", diff --git a/nebula_app/i18n/zh-CN.json b/nebula_app/i18n/zh-CN.json index 09d7b8a..aae25ad 100644 --- a/nebula_app/i18n/zh-CN.json +++ b/nebula_app/i18n/zh-CN.json @@ -6,6 +6,8 @@ "en_us": "English" }, "settings": { + "reset_title": "恢复默认设置?", + "reset_description": "这将重置你的设置与快捷键。SSH 主机、凭据和历史将保留,当前设置会备份。是否继续?", "sidebar": { "application": "应用", "appearance": "外观", diff --git a/nebula_app/src/gpui_shell/settings_pane.rs b/nebula_app/src/gpui_shell/settings_pane.rs index 5d99305..7dcd506 100644 --- a/nebula_app/src/gpui_shell/settings_pane.rs +++ b/nebula_app/src/gpui_shell/settings_pane.rs @@ -1279,7 +1279,7 @@ impl SettingsPane { .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))), + .on_click(cx.listener(|this, _, window, cx| this.confirm_reset_all_settings(window, cx))), ) .into_any_element() } diff --git a/nebula_app/src/gpui_shell/settings_pane/reset.rs b/nebula_app/src/gpui_shell/settings_pane/reset.rs index ef117cb..fd0d7ed 100644 --- a/nebula_app/src/gpui_shell/settings_pane/reset.rs +++ b/nebula_app/src/gpui_shell/settings_pane/reset.rs @@ -1,7 +1,31 @@ use super::*; impl SettingsPane { - pub(super) fn reset_all_settings(&mut self, window: &mut Window, cx: &mut Context) { + pub(super) fn confirm_reset_all_settings(&mut self, window: &mut Window, cx: &mut Context) { + use crate::i18n::Message; + + let language = crate::gpui_shell::config::ui_language(cx); + let pane = cx.entity().downgrade(); + window.open_dialog(cx, move |dialog, window, _cx| { + let pane = pane.clone(); + confirm_dialog( + dialog, + window, + language.text(Message::SettingsResetTitle), + language.text(Message::SettingsResetDescription), + language.text(Message::CommonRestoreDefaults), + language.text(Message::CommonCancel), + ButtonVariant::Danger, + ) + .on_ok(move |_, window, cx| { + let _ = pane.update(cx, |this, cx| this.reset_all_settings(window, cx)); + true + }) + }); + cx.notify(); + } + + 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() { From d456b391cf328000c24e59b306204a79841b88a6 Mon Sep 17 00:00:00 2001 From: GeekMr <140129782+WilliamWang1721@users.noreply.github.com> Date: Sat, 12 Sep 2026 22:41:55 +0800 Subject: [PATCH 03/51] feat: add login startup and silent launch settings --- nebula_app/i18n/en-US.json | 7 ++ nebula_app/i18n/zh-CN.json | 7 ++ nebula_app/src/gpui_shell/mod.rs | 6 +- nebula_app/src/gpui_shell/settings_pane.rs | 46 ++++++++++ .../settings_pane/initialization.rs | 1 + .../gpui_shell/settings_pane/navigation.rs | 2 +- .../src/gpui_shell/workspace/windowing.rs | 10 ++- nebula_app/src/platform/capabilities.rs | 5 ++ nebula_app/src/platform/startup.rs | 88 +++++++++++++++++++ nebula_settings/src/lib.rs | 6 ++ 10 files changed, 175 insertions(+), 3 deletions(-) diff --git a/nebula_app/i18n/en-US.json b/nebula_app/i18n/en-US.json index 40365c5..759b645 100644 --- a/nebula_app/i18n/en-US.json +++ b/nebula_app/i18n/en-US.json @@ -6,6 +6,13 @@ "en_us": "English" }, "settings": { + "startup": { + "launch_at_login": "Start at login", + "launch_at_login_help": "Start Pebrel when you sign in to Windows. Uses the same startup shortcut as the installer.", + "silent_start": "Start silently", + "silent_start_help": "Start in the background without showing a window. Enables the system tray; click its icon to reopen. Applies while the tray is enabled, except when opening a directory explicitly.", + "save_failed": "Could not change startup settings: {error}" + }, "sidebar": { "application": "Application", "appearance": "Appearance", diff --git a/nebula_app/i18n/zh-CN.json b/nebula_app/i18n/zh-CN.json index 09d7b8a..61bdb1f 100644 --- a/nebula_app/i18n/zh-CN.json +++ b/nebula_app/i18n/zh-CN.json @@ -6,6 +6,13 @@ "en_us": "English" }, "settings": { + "startup": { + "launch_at_login": "登录时自启动", + "launch_at_login_help": "登录 Windows 后启动 Pebrel,与安装器使用同一个启动快捷方式。", + "silent_start": "静默启动", + "silent_start_help": "启动后在后台运行,不显示窗口。开启时会同时启用系统托盘,点击托盘图标可重新打开窗口。托盘启用时生效,显式打开目录时除外。", + "save_failed": "无法更改启动设置:{error}" + }, "sidebar": { "application": "应用", "appearance": "外观", diff --git a/nebula_app/src/gpui_shell/mod.rs b/nebula_app/src/gpui_shell/mod.rs index 69a55c6..6fbbad3 100644 --- a/nebula_app/src/gpui_shell/mod.rs +++ b/nebula_app/src/gpui_shell/mod.rs @@ -146,7 +146,11 @@ pub fn run_shell(initial_cwd: Option) { #[cfg(windows)] crate::ai_hook::spawn_config_guard(); init(cx); - cx.activate(true); + if initial_cwd.is_some() + || !crate::platform::startup::start_hidden(&nebula_settings::RuntimeSettings::load()) + { + cx.activate(true); + } open_main_window(cx, ai_events, shell_rx, runtime_hub, initial_cwd); }); crate::tray::shutdown(); diff --git a/nebula_app/src/gpui_shell/settings_pane.rs b/nebula_app/src/gpui_shell/settings_pane.rs index 5d99305..1147ae9 100644 --- a/nebula_app/src/gpui_shell/settings_pane.rs +++ b/nebula_app/src/gpui_shell/settings_pane.rs @@ -88,6 +88,7 @@ pub struct SettingsPane { pub(super) focus_handle: FocusHandle, /// 渲染与写盘的单一事实源;每次 persist 后整体重载。 pub(super) runtime: RuntimeSettings, + launch_at_login: bool, /// 当前分区(`SECTIONS` 下标);默认落在应用主页。 active_section: usize, appearance_picker: Option, @@ -300,6 +301,33 @@ impl SettingsPane { self.request_cover_chrome(value, window, cx); return; } + if key == "launch_at_login" || key == "silent_start" { + let result = if key == "launch_at_login" { + crate::platform::startup::set_launch_at_login(value).map(|()| { + self.launch_at_login = value; + }) + } else { + let mut updates = vec![(key, (value as u8).to_string())]; + if value { + updates.push(("tray", "1".to_owned())); + } + self.try_persist(&updates, cx) + }; + if let Err(error) = result { + let language = crate::gpui_shell::config::ui_language(cx); + super::toast::toast( + window, + cx, + super::toast::ToastKind::Warning, + language.format( + crate::i18n::Message::SettingsStartupSaveFailed, + &[("error", &error.to_string())], + ), + ); + } + cx.notify(); + return; + } if key == "ai_toasts" { if let Err(error) = self.try_persist(&[(key, (value as u8).to_string())], cx) { let language = crate::gpui_shell::config::ui_language(cx); @@ -1120,6 +1148,24 @@ impl SettingsPane { // `platform::capabilities` 的说明)。 let caps = crate::platform::CAPABILITIES; self.group(language.pick("会话生命周期", "Session lifecycle"), cx) + .when(caps.launch_at_login, |group| { + group.child(self.switch_row( + "launch_at_login", + language.text(crate::i18n::Message::SettingsStartupLaunchAtLogin), + language.text(crate::i18n::Message::SettingsStartupLaunchAtLoginHelp), + self.launch_at_login, + cx, + )) + }) + .when(caps.hide_window_on_close, |group| { + group.child(self.switch_row( + "silent_start", + language.text(crate::i18n::Message::SettingsStartupSilentStart), + language.text(crate::i18n::Message::SettingsStartupSilentStartHelp), + self.runtime.silent_start, + cx, + )) + }) .when(caps.hide_window_on_close, |group| { group.child(self.switch_row( "keep_session", diff --git a/nebula_app/src/gpui_shell/settings_pane/initialization.rs b/nebula_app/src/gpui_shell/settings_pane/initialization.rs index fac590c..76a2609 100644 --- a/nebula_app/src/gpui_shell/settings_pane/initialization.rs +++ b/nebula_app/src/gpui_shell/settings_pane/initialization.rs @@ -484,6 +484,7 @@ impl SettingsPane { Self { focus_handle: cx.focus_handle(), runtime, + launch_at_login: crate::platform::startup::launch_at_login(), active_section: 1, appearance_picker: None, theme_picker_trigger: cx.focus_handle(), diff --git a/nebula_app/src/gpui_shell/settings_pane/navigation.rs b/nebula_app/src/gpui_shell/settings_pane/navigation.rs index 7cad2b7..45f6325 100644 --- a/nebula_app/src/gpui_shell/settings_pane/navigation.rs +++ b/nebula_app/src/gpui_shell/settings_pane/navigation.rs @@ -34,7 +34,7 @@ pub(super) const SECTION_SEARCH_TERMS: [&str; 10] = [ "network 网络 proxy 代理 connectivity 连接", "interaction 交互 copy 复制 paste 粘贴 tab 标签 panel 面板", "keymap key binding shortcut quick terminal 快速终端 独立窗口 已有窗口 按键映射 快捷键", - "advanced 高级 session 会话 tray 托盘 restore 恢复", + "advanced 高级 session 会话 tray 托盘 restore 恢复 startup autostart login silent 自启动 静默启动 开机 登录", "backup 备份 export 导出 restore 恢复", ]; diff --git a/nebula_app/src/gpui_shell/workspace/windowing.rs b/nebula_app/src/gpui_shell/workspace/windowing.rs index 3cb2442..89a7e3b 100644 --- a/nebula_app/src/gpui_shell/workspace/windowing.rs +++ b/nebula_app/src/gpui_shell/workspace/windowing.rs @@ -375,7 +375,14 @@ fn open_workspace_window( role: WindowRole, ) -> gpui::Result<(u64, Entity)> { let (runtime_window_id, runtime_hub) = allocate_window(cx); - let options = workspace_window_options(cx, focus, role); + let start_hidden = shell_events.is_some() + && matches!(startup, WorkspaceStartup::RestoreOrDefault) + && crate::platform::startup::start_hidden(&nebula_settings::RuntimeSettings::load()); + let mut options = workspace_window_options(cx, focus, role); + if start_hidden { + options.show = false; + options.focus = false; + } let workspace_slot = Rc::new(RefCell::new(None)); let hwnd_slot = Rc::new(RefCell::new(0isize)); let workspace_out = workspace_slot.clone(); @@ -397,6 +404,7 @@ fn open_workspace_window( cx, ) }); + workspace.update(cx, |workspace, _| workspace.window_hidden = start_hidden); if runtime_window_id == 1 && let Ok(path) = std::env::var("NEBULA_GPUI_OPEN_DOC") && !path.is_empty() diff --git a/nebula_app/src/platform/capabilities.rs b/nebula_app/src/platform/capabilities.rs index 64bb7d8..fd37c67 100644 --- a/nebula_app/src/platform/capabilities.rs +++ b/nebula_app/src/platform/capabilities.rs @@ -15,6 +15,8 @@ pub struct Capabilities { pub hide_window_on_close: bool, /// 系统托盘图标(`crate::tray`)。 pub system_tray: bool, + /// Manage the per-user login startup entry. + pub launch_at_login: bool, /// 系统通知后端已实现;实际投递仍受系统通知权限控制。 pub system_notifications: bool, /// 系统提示音(`platform::beep`)。 @@ -39,6 +41,7 @@ pub const CAPABILITIES: Capabilities = { Capabilities { hide_window_on_close: true, system_tray: true, + launch_at_login: true, system_notifications: true, system_bell: true, self_update_install: true, @@ -54,6 +57,7 @@ pub const CAPABILITIES: Capabilities = { Capabilities { hide_window_on_close: false, system_tray: false, + launch_at_login: false, system_notifications: true, system_bell: false, self_update_install: false, @@ -75,6 +79,7 @@ mod tests { let all = [ CAPABILITIES.hide_window_on_close, CAPABILITIES.system_tray, + CAPABILITIES.launch_at_login, CAPABILITIES.system_notifications, CAPABILITIES.system_bell, CAPABILITIES.self_update_install, diff --git a/nebula_app/src/platform/startup.rs b/nebula_app/src/platform/startup.rs index 06a5e15..f2ce192 100644 --- a/nebula_app/src/platform/startup.rs +++ b/nebula_app/src/platform/startup.rs @@ -21,3 +21,91 @@ pub fn prepare_gui() { } super::notifications::prepare(); } + +pub(crate) fn start_hidden(settings: &nebula_settings::RuntimeSettings) -> bool { + super::CAPABILITIES.hide_window_on_close && settings.silent_start && settings.tray +} + +/// The installer and Settings manage the same per-user Startup shortcut. +pub(crate) fn launch_at_login() -> bool { + #[cfg(windows)] + return startup_shortcut().is_ok_and(|path| path.is_file()); + #[cfg(not(windows))] + false +} + +pub(crate) fn set_launch_at_login(enabled: bool) -> std::io::Result<()> { + #[cfg(windows)] + { + use windows::Win32::System::Com::{ + CLSCTX_INPROC_SERVER, COINIT_APARTMENTTHREADED, CoCreateInstance, CoInitializeEx, + CoUninitialize, IPersistFile, + }; + use windows::Win32::UI::Shell::{IShellLinkW, ShellLink}; + use windows::core::{HSTRING, Interface, w}; + + let path = startup_shortcut()?; + if !enabled { + return match std::fs::remove_file(path) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + result => result, + }; + } + let executable = std::env::current_exe()?; + // SAFETY: COM calls stay on this thread; interfaces are dropped before + // balancing the successful initialization, including on save errors. + unsafe { + CoInitializeEx(None, COINIT_APARTMENTTHREADED).ok().map_err(std::io::Error::other)?; + let result = (|| -> windows::core::Result<()> { + let link: IShellLinkW = CoCreateInstance(&ShellLink, None, CLSCTX_INPROC_SERVER)?; + link.SetPath(&HSTRING::from(executable.as_os_str()))?; + link.SetArguments(w!("--gpui"))?; + if let Some(home) = super::dirs::home_dir() { + link.SetWorkingDirectory(&HSTRING::from(home.as_os_str()))?; + } + link.cast::()?.Save(&HSTRING::from(path.as_os_str()), true) + })(); + CoUninitialize(); + result.map_err(std::io::Error::other) + } + } + #[cfg(not(windows))] + { + let _ = enabled; + Err(std::io::ErrorKind::Unsupported.into()) + } +} + +#[cfg(windows)] +fn startup_shortcut() -> std::io::Result { + use std::os::windows::ffi::OsStringExt; + use windows::Win32::System::Com::CoTaskMemFree; + use windows::Win32::UI::Shell::{FOLDERID_Startup, KF_FLAG_DEFAULT, SHGetKnownFolderPath}; + + // SAFETY: the known-folder buffer is copied before its COM allocation is freed. + unsafe { + let path = SHGetKnownFolderPath(&FOLDERID_Startup, KF_FLAG_DEFAULT, None) + .map_err(std::io::Error::other)?; + let directory = std::ffi::OsString::from_wide(path.as_wide()); + CoTaskMemFree(Some(path.0.cast())); + Ok(std::path::PathBuf::from(directory).join("Pebrel.lnk")) + } +} + +#[cfg(test)] +mod tests { + #[test] + fn silent_start_requires_a_tray_and_native_window_hiding() { + use nebula_settings::{RawSettings, RuntimeSettings}; + + for (text, expected) in [ + ("", false), + ("silent_start=1\ntray=0", false), + ("silent_start=0\ntray=1", false), + ("silent_start=1\ntray=1", super::super::CAPABILITIES.hide_window_on_close), + ] { + let settings = RuntimeSettings::from_raw(&RawSettings::from_text(text)); + assert_eq!(super::start_hidden(&settings), expected); + } + } +} diff --git a/nebula_settings/src/lib.rs b/nebula_settings/src/lib.rs index e097f80..7a5827c 100644 --- a/nebula_settings/src/lib.rs +++ b/nebula_settings/src/lib.rs @@ -984,6 +984,8 @@ pub struct RuntimeSettings { /// from the Application settings page when this is disabled. pub auto_check_updates: bool, pub keep_session: bool, + /// Start the first window hidden when a system tray is available and enabled. + pub silent_start: bool, pub restore_session: bool, pub resume_ai: bool, /// 常驻系统托盘图标。 @@ -1128,6 +1130,7 @@ impl RuntimeSettings { fetch: raw.bool_on("fetch").unwrap_or(false), auto_check_updates: raw.bool_on("auto_check_updates").unwrap_or(true), keep_session: raw.bool_on("keep_session").unwrap_or(false), + silent_start: raw.bool_on("silent_start").unwrap_or(false), restore_session: raw.bool_on("restore_session").unwrap_or(true), resume_ai: raw.bool_on("resume_ai").unwrap_or(true), tray: raw.bool_on("tray").unwrap_or(true), @@ -1322,6 +1325,7 @@ mod tests { fetch=1\n\ powerline=0\n\ keep_session=1\n\ + silent_start=1\n\ restore_session=0\n\ resume_ai=0\n\ tray=0\n\ @@ -1359,6 +1363,7 @@ mod tests { assert_eq!(settings.cell_width_mode, CellWidthModeName::Relaxed); assert!(settings.fetch); assert!(settings.keep_session); + assert!(settings.silent_start); assert!(!settings.restore_session); assert!(!settings.resume_ai); assert!(!settings.tray); @@ -1399,6 +1404,7 @@ mod tests { assert!(!settings.fetch); assert!(settings.auto_check_updates); assert!(!settings.keep_session); + assert!(!settings.silent_start); assert!(settings.restore_session); assert!(settings.resume_ai); assert!(settings.tray); From 87138f20a625506e696c2f3ce57537217930ed1e Mon Sep 17 00:00:00 2001 From: gao-jian-bin <1820474883@qq.com> Date: Sun, 13 Sep 2026 00:51:00 +0800 Subject: [PATCH 04/51] fix(i18n): make Git drawer follow the application language --- nebula_app/i18n/en-US.json | 47 +++++ nebula_app/i18n/zh-CN.json | 47 +++++ nebula_app/src/display/side_panel/mod.rs | 22 ++- nebula_app/src/display/side_panel/notice.rs | 130 ++++++++++++++ nebula_app/src/display/side_panel/render.rs | 2 +- nebula_app/src/display/side_panel/tests.rs | 9 +- nebula_app/src/display/side_panel/vcs.rs | 22 ++- nebula_app/src/gpui_shell/workspace.rs | 10 +- .../src/gpui_shell/workspace/file_tree.rs | 23 +-- .../src/gpui_shell/workspace/sidebar.rs | 5 +- .../src/gpui_shell/workspace/top_tabs.rs | 2 +- .../src/gpui_shell/workspace/vcs_panel.rs | 165 +++++++++--------- .../workspace/vcs_panel/commit_input.rs | 113 ++++++++++++ .../workspace/vcs_panel/relative_time.rs | 61 +++++++ nebula_app/tests/i18n_contract.rs | 16 ++ 15 files changed, 554 insertions(+), 120 deletions(-) create mode 100644 nebula_app/src/display/side_panel/notice.rs create mode 100644 nebula_app/src/gpui_shell/workspace/vcs_panel/commit_input.rs create mode 100644 nebula_app/src/gpui_shell/workspace/vcs_panel/relative_time.rs diff --git a/nebula_app/i18n/en-US.json b/nebula_app/i18n/en-US.json index 40365c5..cee0540 100644 --- a/nebula_app/i18n/en-US.json +++ b/nebula_app/i18n/en-US.json @@ -376,6 +376,8 @@ "save_copy": "Save a copy" }, "files": { + "path_unavailable": "The path no longer exists", + "delete_failed": "Could not delete: {error}", "ignore_add": "Add to .gitignore", "ignore_remove": "Remove from .gitignore", "ignore_added": "Added to .gitignore: {entry}", @@ -462,5 +464,50 @@ "exported": "Host metadata exported without credentials", "imported": "Added {added} hosts; skipped {skipped} existing hosts", "imported_partial": "Added {added} hosts, but could not update their visibility. Retry the import: {error}" + }, + "vcs": { + "changes": "Changes", + "staged": "Staged", + "merge_conflicts": "Merge conflicts", + "no_conflicts": "No unresolved conflicts", + "no_changes": "No changes", + "no_history": "No commits yet", + "no_branch": "(no branch)", + "confirm_discard": "Discard?", + "discard": "Discard changes", + "stage": "Stage", + "unstage": "Unstage", + "resolve_in_merge_editor": "Resolve in the three-way merge editor", + "commit_changes": "Commit / changes", + "graph": "Graph", + "history_graph": "Commit graph", + "conflicts": "Conflicts", + "resolve_conflicts": "Resolve conflicts", + "commit": "Commit", + "commit_placeholder": "Commit message...", + "stage_all": "Stage all", + "pull": "Pull", + "push": "Push", + "push_ahead": "Push ↑{count}", + "follow_directory": "Follow terminal directory", + "not_repository": "The current directory is not in a Git/SVN repository", + "refresh_status": "Refresh {vcs} status", + "close_status": "Close {vcs} status", + "toggle_git": "Git status (Ctrl+Shift+G)", + "time_now": "just now", + "time_minute": "1 minute ago", + "time_minutes": "{count} minutes ago", + "time_hour": "1 hour ago", + "time_hours": "{count} hours ago", + "time_day": "1 day ago", + "time_days": "{count} days ago", + "time_month": "1 month ago", + "time_months": "{count} months ago", + "time_year": "1 year ago", + "time_years": "{count} years ago", + "directory_unavailable": "The selected directory is unavailable", + "following_directory": "The selected directory is unavailable; following the current directory", + "command_failed": "{program} failed", + "task_start_failed": "Could not start the version control task: {error}" } } diff --git a/nebula_app/i18n/zh-CN.json b/nebula_app/i18n/zh-CN.json index 09d7b8a..650fc46 100644 --- a/nebula_app/i18n/zh-CN.json +++ b/nebula_app/i18n/zh-CN.json @@ -376,6 +376,8 @@ "save_copy": "保存副本" }, "files": { + "path_unavailable": "路径已不存在", + "delete_failed": "删除失败:{error}", "ignore_add": "加入 .gitignore", "ignore_remove": "从 .gitignore 移除", "ignore_added": "已写入 .gitignore:{entry}", @@ -462,5 +464,50 @@ "exported": "主机信息已导出,不含凭据", "imported": "已新增 {added} 台主机,跳过 {skipped} 台已有主机", "imported_partial": "已新增 {added} 台主机,但显示状态未能更新。请重试导入:{error}" + }, + "vcs": { + "changes": "变更", + "staged": "已暂存", + "merge_conflicts": "合并冲突", + "no_conflicts": "没有待解决的冲突", + "no_changes": "没有更改", + "no_history": "还没有提交历史", + "no_branch": "(无分支)", + "confirm_discard": "确认丢弃", + "discard": "丢弃改动", + "stage": "暂存", + "unstage": "取消暂存", + "resolve_in_merge_editor": "在三栏合并器中解决", + "commit_changes": "提交 / 变更", + "graph": "线路", + "history_graph": "历史线路", + "conflicts": "冲突", + "resolve_conflicts": "解决冲突", + "commit": "提交", + "commit_placeholder": "提交信息…", + "stage_all": "全部暂存", + "pull": "拉取", + "push": "推送", + "push_ahead": "推送 ↑{count}", + "follow_directory": "回到终端当前目录", + "not_repository": "当前目录不在 Git/SVN 仓库中", + "refresh_status": "刷新 {vcs} 状态", + "close_status": "关闭 {vcs} 状态", + "toggle_git": "Git 状态 (Ctrl+Shift+G)", + "time_now": "刚刚", + "time_minute": "1 分钟前", + "time_minutes": "{count} 分钟前", + "time_hour": "1 小时前", + "time_hours": "{count} 小时前", + "time_day": "1 天前", + "time_days": "{count} 天前", + "time_month": "1 个月前", + "time_months": "{count} 个月前", + "time_year": "1 年前", + "time_years": "{count} 年前", + "directory_unavailable": "所选目录不可用", + "following_directory": "所选目录不可用,已跟随当前目录", + "command_failed": "{program} 失败", + "task_start_failed": "无法启动版本控制任务: {error}" } } diff --git a/nebula_app/src/display/side_panel/mod.rs b/nebula_app/src/display/side_panel/mod.rs index 2215aa6..5a5f90d 100644 --- a/nebula_app/src/display/side_panel/mod.rs +++ b/nebula_app/src/display/side_panel/mod.rs @@ -25,12 +25,14 @@ use unicode_width::UnicodeWidthChar; mod enumerate; mod gitignore; mod icons; +mod notice; #[cfg(feature = "legacy-shell")] mod render; mod search; #[cfg(test)] mod tests; mod vcs; +pub(crate) use notice::PanelNotice; // 子模块的项一律 `pub(crate)`:本 crate 是 bin,没有下游用户,所以拆分不必 // 把内部实现抬到 `pub`。glob 转发让 `display::side_panel::X` 这层路径不变。 @@ -395,7 +397,7 @@ pub struct SidePanel { /// `custom_root` 互斥,且不改变终端实际 cwd / Git 跟随位置。 custom_wsl_root: Option, /// Visible feedback for an invalid/disappeared custom root. - root_notice: Option, + root_notice: Option, /// Flattened visible tree rows for the Files view. rows: Vec, /// Last unfiltered tree snapshot. Search results replace `rows`, but @@ -448,7 +450,7 @@ pub struct SidePanel { /// Set by the worker when it finishes — `sync` folds it into a refresh. op_done: std::sync::Arc, /// Last operation's error (empty = success), shown on the summary line. - op_error: std::sync::Arc>, + op_error: std::sync::Arc>, /// A snapshot worker (fs walk + git subprocesses) is in flight. Guards /// against stacking workers; a refresh requested meanwhile re-arms /// `needs_refresh` and runs after this one lands. @@ -617,7 +619,7 @@ impl SidePanel { let custom_invalidated = self.custom_root.as_ref().is_some_and(|root| !root.is_dir()); if custom_invalidated { self.custom_root = None; - self.root_notice = Some("所选目录不可用,已跟随当前目录".to_owned()); + self.root_notice = Some(PanelNotice::FollowingDirectory); } let next_root = self .custom_root @@ -718,7 +720,7 @@ impl SidePanel { /// belongs to one window and this field is deliberately never serialized. pub fn set_custom_root(&mut self, root: PathBuf) -> bool { if !root.is_dir() { - self.root_notice = Some("所选目录不可用".to_owned()); + self.root_notice = Some(PanelNotice::DirectoryUnavailable); return false; } let changed = self.custom_root.as_ref() != Some(&root) || self.root.as_ref() != Some(&root); @@ -826,8 +828,12 @@ impl SidePanel { self.scroll = 0; } - pub fn root_notice(&self) -> Option<&str> { - self.root_notice.as_deref() + pub fn root_notice(&self) -> Option { + self.localized_root_notice(crate::i18n::UiLanguage::ZhCn) + } + + pub fn localized_root_notice(&self, language: crate::i18n::UiLanguage) -> Option { + self.root_notice.as_ref().map(|notice| notice.text(language)) } /// Only real Git file rows are interactive. Section headers and the blank @@ -854,8 +860,8 @@ impl SidePanel { } /// 面板顶部的一句话提示(复用根目录不可用的同一条 UI)。 - pub fn set_notice(&mut self, message: String) { - self.root_notice = Some(message); + pub fn set_notice(&mut self, message: impl Into) { + self.root_notice = Some(message.into()); } fn current_index_root(&self) -> Option { diff --git a/nebula_app/src/display/side_panel/notice.rs b/nebula_app/src/display/side_panel/notice.rs new file mode 100644 index 0000000..87ddaaa --- /dev/null +++ b/nebula_app/src/display/side_panel/notice.rs @@ -0,0 +1,130 @@ +//! Store application notices as outcomes so a cached result follows the UI language. +//! Command output and user content remain verbatim. + +use crate::i18n::{Message, UiLanguage}; + +#[derive(Clone, Debug)] +pub(crate) enum PanelNotice { + Raw(String), + DirectoryUnavailable, + FollowingDirectory, + CommandFailed(String), + TaskStartFailed(String), + PathUnavailable, + DeleteFailed(String), + IgnoreAdded(String), + IgnoreRemoved(String), + IgnorePresent(String), + IgnoreVisible(String), + IgnoreFailed(String), +} + +impl Default for PanelNotice { + fn default() -> Self { + Self::Raw(String::new()) + } +} + +impl From for PanelNotice { + fn from(value: String) -> Self { + Self::Raw(value) + } +} + +impl From<&str> for PanelNotice { + fn from(value: &str) -> Self { + Self::Raw(value.to_owned()) + } +} + +impl PanelNotice { + pub(super) fn text(&self, language: UiLanguage) -> String { + match self { + Self::Raw(text) => text.clone(), + Self::DirectoryUnavailable => { + language.text(Message::VcsDirectoryUnavailable).to_owned() + }, + Self::FollowingDirectory => language.text(Message::VcsFollowingDirectory).to_owned(), + Self::CommandFailed(program) => { + language.format(Message::VcsCommandFailed, &[("program", program)]) + }, + Self::TaskStartFailed(error) => { + language.format(Message::VcsTaskStartFailed, &[("error", error)]) + }, + Self::PathUnavailable => language.text(Message::FilesPathUnavailable).to_owned(), + Self::DeleteFailed(error) => { + language.format(Message::FilesDeleteFailed, &[("error", error)]) + }, + Self::IgnoreAdded(entry) => { + language.format(Message::FilesIgnoreAdded, &[("entry", entry)]) + }, + Self::IgnoreRemoved(entry) => { + language.format(Message::FilesIgnoreRemoved, &[("entry", entry)]) + }, + Self::IgnorePresent(entry) => { + language.format(Message::FilesIgnorePresent, &[("entry", entry)]) + }, + Self::IgnoreVisible(entry) => { + language.format(Message::FilesIgnoreVisible, &[("entry", entry)]) + }, + Self::IgnoreFailed(error) => { + language.format(Message::FilesIgnoreFailed, &[("error", error)]) + }, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cached_notices_follow_language_changes_without_repeating_the_operation() { + let notice = PanelNotice::FollowingDirectory; + assert_eq!(notice.text(UiLanguage::ZhCn), "所选目录不可用,已跟随当前目录"); + assert_eq!( + notice.text(UiLanguage::EnUs), + "The selected directory is unavailable; following the current directory" + ); + assert_eq!(notice.text(UiLanguage::FrFr), notice.text(UiLanguage::EnUs)); + assert_eq!( + PanelNotice::DirectoryUnavailable.text(UiLanguage::EnUs), + "The selected directory is unavailable" + ); + assert_eq!(PanelNotice::CommandFailed("git".into()).text(UiLanguage::EnUs), "git failed"); + assert_eq!(PanelNotice::CommandFailed("git".into()).text(UiLanguage::ZhCn), "git 失败"); + } + + #[test] + fn external_errors_and_placeholder_like_arguments_are_preserved() { + let stderr = "fatal: 无法读取 分支/{error}"; + for language in UiLanguage::ALL { + assert_eq!(PanelNotice::Raw(stderr.into()).text(*language), stderr); + } + let notice = PanelNotice::TaskStartFailed("OS error: {program}".into()); + assert_eq!( + notice.text(UiLanguage::EnUs), + "Could not start the version control task: OS error: {program}" + ); + } + + #[test] + fn file_notices_can_be_reused_by_the_git_drawer_in_a_new_language() { + let cases = [ + (PanelNotice::PathUnavailable, "The path no longer exists"), + (PanelNotice::DeleteFailed("denied".into()), "Could not delete: denied"), + (PanelNotice::IgnoreAdded("/build/".into()), "Added to .gitignore: /build/"), + (PanelNotice::IgnoreRemoved("/build/".into()), "No longer ignored: /build/"), + (PanelNotice::IgnorePresent("/build/".into()), "Already ignored: /build/"), + (PanelNotice::IgnoreVisible("/build/".into()), "Already visible: /build/"), + ( + PanelNotice::IgnoreFailed("denied".into()), + "Could not change Git ignore rules: denied", + ), + ]; + for (notice, english) in cases { + assert_ne!(notice.text(UiLanguage::ZhCn), english); + assert_eq!(notice.text(UiLanguage::EnUs), english); + } + } +} diff --git a/nebula_app/src/display/side_panel/render.rs b/nebula_app/src/display/side_panel/render.rs index b11c250..790dd4d 100644 --- a/nebula_app/src/display/side_panel/render.rs +++ b/nebula_app/src/display/side_panel/render.rs @@ -481,7 +481,7 @@ pub(crate) fn draw_text( as usize) .max(4); let (summary, summary_ink) = if let Some(notice) = panel.root_notice() { - (clip_tail(notice, summary_cols), Rgb::new(sk.danger.r, sk.danger.g, sk.danger.b)) + (clip_tail(¬ice, summary_cols), Rgb::new(sk.danger.r, sk.danger.g, sk.danger.b)) } else { ( panel diff --git a/nebula_app/src/display/side_panel/tests.rs b/nebula_app/src/display/side_panel/tests.rs index 7f34b3b..d6fcc3e 100644 --- a/nebula_app/src/display/side_panel/tests.rs +++ b/nebula_app/src/display/side_panel/tests.rs @@ -143,7 +143,12 @@ mod tests { assert!(panel.sync(Some(cwd.clone()))); assert!(!panel.custom_root_active()); assert_eq!(panel.root(), Some(cwd.as_path())); - assert_eq!(panel.root_notice(), Some("所选目录不可用,已跟随当前目录")); + assert_eq!(panel.root_notice().as_deref(), Some("所选目录不可用,已跟随当前目录")); + + assert_eq!( + panel.localized_root_notice(crate::i18n::UiLanguage::EnUs).as_deref(), + Some("The selected directory is unavailable; following the current directory") + ); panel.wait_snapshot(); panel.file_index.release_for_test(); @@ -165,7 +170,7 @@ mod tests { assert!(panel.sync(Some(root.clone()))); assert!(!panel.custom_root_active()); assert_eq!(panel.root(), Some(root.as_path())); - assert_eq!(panel.root_notice(), Some("所选目录不可用,已跟随当前目录")); + assert_eq!(panel.root_notice().as_deref(), Some("所选目录不可用,已跟随当前目录")); } #[test] diff --git a/nebula_app/src/display/side_panel/vcs.rs b/nebula_app/src/display/side_panel/vcs.rs index 0f49bf9..8624413 100644 --- a/nebula_app/src/display/side_panel/vcs.rs +++ b/nebula_app/src/display/side_panel/vcs.rs @@ -252,11 +252,15 @@ impl SidePanel { /// Last mutation's error, if any (cleared by the next successful op). pub fn op_error(&self) -> Option { - let e = self.op_error.lock().ok()?; - (!e.is_empty()).then(|| e.clone()) + self.localized_op_error(crate::i18n::UiLanguage::ZhCn) } - fn set_op_error(&mut self, message: impl Into) { + pub fn localized_op_error(&self, language: crate::i18n::UiLanguage) -> Option { + let text = self.op_error.lock().ok()?.text(language); + (!text.is_empty()).then_some(text) + } + + fn set_op_error(&mut self, message: impl Into) { if let Ok(mut error) = self.op_error.lock() { *error = message.into(); } @@ -274,7 +278,7 @@ impl SidePanel { let done = self.op_done.clone(); let error = self.op_error.clone(); if let Ok(mut message) = error.lock() { - message.clear(); + *message = PanelNotice::default(); } let display_name = program.display().to_string(); let spawn_result = @@ -287,16 +291,16 @@ impl SidePanel { cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW } let msg = match cmd.output() { - Ok(out) if out.status.success() => String::new(), + Ok(out) if out.status.success() => PanelNotice::default(), Ok(out) => { let err = String::from_utf8_lossy(&out.stderr); // First meaningful line is enough for a status strip. err.lines() .find(|l| !l.trim().is_empty()) - .unwrap_or(&format!("{display_name} 失败")) - .to_string() + .map(|line| PanelNotice::Raw(line.to_owned())) + .unwrap_or_else(|| PanelNotice::CommandFailed(display_name.clone())) }, - Err(e) => format!("{display_name}: {e}"), + Err(e) => PanelNotice::Raw(format!("{display_name}: {e}")), }; if let Ok(mut slot) = error.lock() { *slot = msg; @@ -306,7 +310,7 @@ impl SidePanel { }); if let Err(spawn_error) = spawn_result { self.op_running.store(false, Ordering::Relaxed); - self.set_op_error(format!("无法启动版本控制任务: {spawn_error}")); + self.set_op_error(PanelNotice::TaskStartFailed(spawn_error.to_string())); } } diff --git a/nebula_app/src/gpui_shell/workspace.rs b/nebula_app/src/gpui_shell/workspace.rs index 9a7cf81..47c1fe6 100644 --- a/nebula_app/src/gpui_shell/workspace.rs +++ b/nebula_app/src/gpui_shell/workspace.rs @@ -920,7 +920,7 @@ pub struct NebulaWorkspace { _command_manager_subscription: Subscription, /// Git/SVN 提交信息输入(GPUI 输入组件);提交动作直达共享模型 /// `vcs_commit_message`,不经旧壳的内部输入状态机。 - git_commit_input: Entity, + git_commit_input: vcs_panel::CommitInput, /// Git 树"丢弃改动"的二次确认(路径);任何其他 VCS 操作都清掉它。 vcs_discard_confirm: Option, /// 命令面板的行覆盖:`None` = 常规命令目录,`Some` = 某个专用列表 @@ -1098,7 +1098,7 @@ impl NebulaWorkspace { workspace_ui_language().pick("搜索文件和文件夹…", "Search files and folders..."), ) }); - let git_commit_input = cx.new(|cx| InputState::new(window, cx).placeholder("提交信息…")); + let git_commit_input = vcs_panel::CommitInput::new(window, cx); let command_palette_subscription = cx.subscribe_in( &command_palette_input, window, @@ -2592,12 +2592,12 @@ impl NebulaWorkspace { /// 提交按钮/Enter:读 GPUI 输入框的消息直达共享模型(git 提交暂存区、 /// svn 提交工作副本),成功入队后清空输入。 fn submit_vcs_commit(&mut self, window: &mut Window, cx: &mut Context) { - let message = self.git_commit_input.read(cx).value().trim().to_string(); + let message = self.git_commit_input.input.read(cx).value().trim().to_string(); if message.is_empty() { return; } self.side_panel.vcs_commit_message(&message); - self.git_commit_input.update(cx, |input, cx| input.set_value("", window, cx)); + self.git_commit_input.input.update(cx, |input, cx| input.set_value("", window, cx)); cx.notify(); } @@ -3201,7 +3201,7 @@ impl NebulaWorkspace { // 页签——用户想看的永远是"当前这台机器上的文件"。 crate::display::side_panel::PanelView::Files if remote => self.render_remote_files(cx), crate::display::side_panel::PanelView::Files => self.render_file_tree(cx), - crate::display::side_panel::PanelView::Git => self.render_git_tree(cx), + crate::display::side_panel::PanelView::Git => self.render_git_tree(window, cx), }; div() .h_full() diff --git a/nebula_app/src/gpui_shell/workspace/file_tree.rs b/nebula_app/src/gpui_shell/workspace/file_tree.rs index 24216b3..a1ba0ec 100644 --- a/nebula_app/src/gpui_shell/workspace/file_tree.rs +++ b/nebula_app/src/gpui_shell/workspace/file_tree.rs @@ -3,6 +3,7 @@ //! 几何与密度以旧 OpenGL 壳的 `display::side_panel::panel_layout` //! (side_panel.rs:1695)和它的行水洗(同文件 2712-2800)为基准,不自创数字。 +use crate::display::side_panel::PanelNotice; use std::ops::Range; use std::path::PathBuf; @@ -546,7 +547,7 @@ impl NebulaWorkspace { })), ), ) - .when_some(self.side_panel.root_notice(), |panel, notice| { + .when_some(self.side_panel.localized_root_notice(crate::gpui_shell::config::ui_language(cx)), |panel, notice| { panel.child(div().text_xs().text_color(theme.warning).child(notice.to_owned())) }) .child(search_box) @@ -774,7 +775,7 @@ impl NebulaWorkspace { cx: &mut Context, ) { if !path.exists() { - self.side_panel.set_notice("路径已不存在".to_owned()); + self.side_panel.set_notice(PanelNotice::PathUnavailable); cx.notify(); return; } @@ -810,7 +811,7 @@ impl NebulaWorkspace { this.side_panel.request_refresh(); this.sync_side_panel_to_active(false, cx); }, - Err(error) => this.side_panel.set_notice(format!("删除失败:{error}")), + Err(error) => this.side_panel.set_notice(PanelNotice::DeleteFailed(error)), } cx.notify(); }); @@ -836,22 +837,16 @@ impl NebulaWorkspace { let result = task.await; let _ = this.update(cx, |this, cx| { use crate::display::side_panel::IgnoreOutcome; - use crate::i18n::Message; - let language = crate::gpui_shell::config::ui_language(cx); let notice = match result { - Ok(IgnoreOutcome::Added { entry, .. }) => { - language.format(Message::FilesIgnoreAdded, &[("entry", &entry)]) - }, - Ok(IgnoreOutcome::Removed { entry, .. }) => { - language.format(Message::FilesIgnoreRemoved, &[("entry", &entry)]) - }, + Ok(IgnoreOutcome::Added { entry, .. }) => PanelNotice::IgnoreAdded(entry), + Ok(IgnoreOutcome::Removed { entry, .. }) => PanelNotice::IgnoreRemoved(entry), Ok(IgnoreOutcome::AlreadyPresent { entry }) => { - language.format(Message::FilesIgnorePresent, &[("entry", &entry)]) + PanelNotice::IgnorePresent(entry) }, Ok(IgnoreOutcome::AlreadyVisible { entry }) => { - language.format(Message::FilesIgnoreVisible, &[("entry", &entry)]) + PanelNotice::IgnoreVisible(entry) }, - Err(error) => language.format(Message::FilesIgnoreFailed, &[("error", &error)]), + Err(error) => PanelNotice::IgnoreFailed(error), }; this.side_panel.set_notice(notice); this.side_panel.request_refresh(); diff --git a/nebula_app/src/gpui_shell/workspace/sidebar.rs b/nebula_app/src/gpui_shell/workspace/sidebar.rs index 4b114c7..9949947 100644 --- a/nebula_app/src/gpui_shell/workspace/sidebar.rs +++ b/nebula_app/src/gpui_shell/workspace/sidebar.rs @@ -1005,7 +1005,10 @@ impl NebulaWorkspace { .icon(IconName::Github) .ghost() .selected(git_active) - .tooltip("Git 状态 (Ctrl+Shift+G)") + .tooltip( + crate::gpui_shell::config::ui_language(cx) + .text(crate::i18n::Message::VcsToggleGit), + ) .on_click(cx.listener(|this, _, _, cx| { this.toggle_git_tree(cx); })), diff --git a/nebula_app/src/gpui_shell/workspace/top_tabs.rs b/nebula_app/src/gpui_shell/workspace/top_tabs.rs index f9968fc..d50dd06 100644 --- a/nebula_app/src/gpui_shell/workspace/top_tabs.rs +++ b/nebula_app/src/gpui_shell/workspace/top_tabs.rs @@ -702,7 +702,7 @@ impl NebulaWorkspace { .icon(IconName::Github) .ghost() .selected(git_active) - .tooltip("Git 状态 (Ctrl+Shift+G)") + .tooltip(crate::gpui_shell::config::ui_language(cx).text(crate::i18n::Message::VcsToggleGit)) .on_click(cx.listener(|this, _, _, cx| this.toggle_git_tree(cx))), ), ) diff --git a/nebula_app/src/gpui_shell/workspace/vcs_panel.rs b/nebula_app/src/gpui_shell/workspace/vcs_panel.rs index 39cdce0..b8788fe 100644 --- a/nebula_app/src/gpui_shell/workspace/vcs_panel.rs +++ b/nebula_app/src/gpui_shell/workspace/vcs_panel.rs @@ -6,12 +6,19 @@ //! 在搬的过程中悄悄改掉某个 trait 的引入方式。 use super::*; +use crate::i18n::{Message, UiLanguage}; use gpui_component::menu::PopupMenuItem; +mod commit_input; +mod relative_time; +pub(super) use commit_input::CommitInput; +use relative_time::git_relative_time_at; + impl NebulaWorkspace { pub(super) fn render_side_panel_switch(&self, cx: &mut Context) -> impl IntoElement { use crate::display::side_panel::PanelView; + let language = crate::gpui_shell::config::ui_language(cx); let files = self.side_panel.view == PanelView::Files; let git = self.side_panel.view == PanelView::Git; let git_count = self @@ -30,7 +37,7 @@ impl NebulaWorkspace { .child( Button::new("side-panel-files") .icon(IconName::FolderClosed) - .label("文件") + .label(language.text(Message::CommonFiles)) .small() .selected(files) .on_click(cx.listener(|this, _, _, cx| { @@ -50,7 +57,13 @@ impl NebulaWorkspace { ) } - pub(super) fn render_git_tree(&mut self, cx: &mut Context) -> gpui::AnyElement { + pub(super) fn render_git_tree( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> gpui::AnyElement { + let language = crate::gpui_shell::config::ui_language(cx); + self.git_commit_input.sync_language(window, cx); let view_switch = self.render_side_panel_switch(cx).into_any_element(); let theme = cx.theme(); let muted = theme.muted_foreground; @@ -72,8 +85,8 @@ impl NebulaWorkspace { let vcs = git.as_ref().map(|info| info.vcs); let git_view = self.side_panel.git_view; let op_running = self.side_panel.op_running(); - let op_error = self.side_panel.op_error(); - // 浏览定位是否生效——决定要不要画"回到终端当前目录"。链式构建里不能 + let op_error = self.side_panel.localized_op_error(language); + // 浏览定位是否生效——决定要不要画“回到终端当前目录”。链式构建里不能 // 再借 `self`,所以这些和下面的弱引用都在这里一次取好。 let browsing_elsewhere = self.side_panel.custom_root_active(); // 下拉菜单的动作要在自己的闭包里回到本实体;`cx.listener` 只能给 @@ -106,7 +119,7 @@ impl NebulaWorkspace { .px_2() .text_sm() .text_color(muted) - .child("还没有提交历史") + .child(language.text(Message::VcsNoHistory)) .into_any_element(), ); } @@ -148,7 +161,7 @@ impl NebulaWorkspace { let meta = format!( "{} · {} · {}", commit.author, - git_relative_time(commit.timestamp), + git_relative_time(commit.timestamp, language), commit.short_hash ); rows.push( @@ -188,10 +201,11 @@ impl NebulaWorkspace { info.conflicts.iter().map(|(_, path)| path.as_str()).collect(); // 变更页保留三组;冲突页只列冲突。路径从后两组过滤,因为数据层 // 为旧壳兼容仍把冲突同时留在 staged/unstaged。 - let mut sections: Vec<(&str, Vec<&(char, String)>, RowOps)> = Vec::new(); + let mut sections: Vec<(&str, &str, Vec<&(char, String)>, RowOps)> = Vec::new(); if !info.conflicts.is_empty() { sections.push(( - "合并冲突", + "conflicts", + language.text(Message::VcsMergeConflicts), info.conflicts.iter().collect(), if is_git { RowOps::Conflict } else { RowOps::Svn }, )); @@ -201,25 +215,28 @@ impl NebulaWorkspace { match info.vcs { VcsKind::Git if git_view == GitPanelView::Changes => { sections.push(( - "已暂存", + "staged", + language.text(Message::VcsStaged), info.staged.iter().filter(not_conflicted).collect(), RowOps::Staged, )); sections.push(( - "变更", + "changes", + language.text(Message::VcsChanges), info.unstaged.iter().filter(not_conflicted).collect(), RowOps::Unstaged, )); }, VcsKind::Git => {}, VcsKind::Svn => sections.push(( + "svn", "修改", info.unstaged.iter().filter(not_conflicted).collect(), RowOps::Svn, )), VcsKind::SvnRepository => {}, } - let clean = sections.iter().all(|(_, entries, _)| entries.is_empty()); + let clean = sections.iter().all(|(_, _, entries, _)| entries.is_empty()); if clean && info.vcs != VcsKind::SvnRepository { rows.push( div() @@ -228,15 +245,15 @@ impl NebulaWorkspace { .text_sm() .text_color(muted) .child(if is_git && git_view == GitPanelView::Conflicts { - "没有待解决的冲突" + language.text(Message::VcsNoConflicts) } else { - "没有更改" + language.text(Message::VcsNoChanges) }) .into_any_element(), ); } let discard_confirm = self.vcs_discard_confirm.clone(); - for (section, entries, ops) in sections { + for (section_id, section, entries, ops) in sections { if entries.is_empty() { continue; } @@ -272,7 +289,7 @@ impl NebulaWorkspace { None => (relative_path.clone(), String::new()), }; let row_group = - SharedString::from(format!("vcs-row-actions-{section}-{index}")); + SharedString::from(format!("vcs-row-actions-{section_id}-{index}")); let open_path = path.clone(); let stage_path = relative_path.clone(); let svn_add_path = relative_path.clone(); @@ -294,7 +311,7 @@ impl NebulaWorkspace { rows.push( h_flex() .id(SharedString::from(format!( - "git-tree-row-{section}-{index}-{relative_path}" + "git-tree-row-{section_id}-{index}-{relative_path}" ))) .group(row_group.clone()) .h(px(30.0)) @@ -340,7 +357,7 @@ impl NebulaWorkspace { .when(can_discard, |row| { row.child( Button::new(SharedString::from(format!( - "vcs-discard-{section}-{index}" + "vcs-discard-{section_id}-{index}" ))) .map(|button| { if discard_armed { @@ -348,7 +365,7 @@ impl NebulaWorkspace { .label(if svn_revert { "确认还原" } else { - "确认丢弃" + language.text(Message::VcsConfirmDiscard) }) .danger() .xsmall() @@ -360,7 +377,7 @@ impl NebulaWorkspace { .tooltip(if svn_revert { "还原 SVN 改动" } else { - "丢弃改动" + language.text(Message::VcsDiscard) }) } }) @@ -396,12 +413,12 @@ impl NebulaWorkspace { .when(ops == RowOps::Unstaged && is_git, |row| { row.child( Button::new(SharedString::from(format!( - "vcs-stage-{section}-{index}" + "vcs-stage-{section_id}-{index}" ))) .icon(IconName::Plus) .ghost() .xsmall() - .tooltip("暂存") + .tooltip(language.text(Message::VcsStage)) .invisible() .group_hover(row_group.clone(), |button| button.visible()) .on_click( @@ -416,7 +433,7 @@ impl NebulaWorkspace { .when(svn_add, |row| { row.child( Button::new(SharedString::from(format!( - "svn-add-{section}-{index}" + "svn-add-{section_id}-{index}" ))) .icon(IconName::Plus) .ghost() @@ -436,7 +453,7 @@ impl NebulaWorkspace { .when(svn_resolve, |row| { row.child( Button::new(SharedString::from(format!( - "svn-resolve-{section}-{index}" + "svn-resolve-{section_id}-{index}" ))) .label("解决") .ghost() @@ -461,18 +478,18 @@ impl NebulaWorkspace { &menu_target, &menu_path, index, - section, + section_id, )) }) .when(ops == RowOps::Staged, |row| { row.child( Button::new(SharedString::from(format!( - "vcs-unstage-{section}-{index}" + "vcs-unstage-{section_id}-{index}" ))) .icon(IconName::Minus) .ghost() .xsmall() - .tooltip("取消暂存") + .tooltip(language.text(Message::VcsUnstage)) .invisible() .group_hover(row_group.clone(), |button| button.visible()) .on_click( @@ -487,7 +504,7 @@ impl NebulaWorkspace { .when(ops == RowOps::Conflict && is_git, |row| { row.child( Button::new(SharedString::from(format!( - "git-resolve-{section}-{index}" + "git-resolve-{section_id}-{index}" ))) .icon( Icon::new(Icon::empty()) @@ -495,7 +512,7 @@ impl NebulaWorkspace { ) .ghost() .xsmall() - .tooltip("在三栏合并器中解决") + .tooltip(language.text(Message::VcsResolveInMergeEditor)) .on_click( cx.listener(move |this, _, window, cx| { this.open_git_merge_tab( @@ -541,7 +558,7 @@ impl NebulaWorkspace { .child(div().font_family(symbol).text_sm().text_color(muted).child("\u{ea68}")) .child(div().flex_1().min_w_0().text_sm().truncate().child( if info.branch.is_empty() { - "(no branch)".to_owned() + language.text(Message::VcsNoBranch).to_owned() } else { info.branch.clone() }, @@ -576,9 +593,11 @@ impl NebulaWorkspace { .flex_1() .ghost() .small() - .when(git_view == GitPanelView::Changes, |button| button.label("变更")) + .when(git_view == GitPanelView::Changes, |button| { + button.label(language.text(Message::VcsChanges)) + }) .selected(git_view == GitPanelView::Changes) - .tooltip("提交 / 变更") + .tooltip(language.text(Message::VcsCommitChanges)) .on_click(cx.listener(|this, _, _, cx| { this.side_panel.select_git_view(GitPanelView::Changes); cx.notify(); @@ -593,9 +612,11 @@ impl NebulaWorkspace { .flex_1() .ghost() .small() - .when(git_view == GitPanelView::History, |button| button.label("线路")) + .when(git_view == GitPanelView::History, |button| { + button.label(language.text(Message::VcsGraph)) + }) .selected(git_view == GitPanelView::History) - .tooltip("历史线路") + .tooltip(language.text(Message::VcsHistoryGraph)) .on_click(cx.listener(|this, _, _, cx| { this.side_panel.select_git_view(GitPanelView::History); cx.notify(); @@ -610,9 +631,11 @@ impl NebulaWorkspace { .flex_1() .ghost() .small() - .when(git_view == GitPanelView::Conflicts, |button| button.label("冲突")) + .when(git_view == GitPanelView::Conflicts, |button| { + button.label(language.text(Message::VcsConflicts)) + }) .selected(git_view == GitPanelView::Conflicts) - .tooltip("解决冲突") + .tooltip(language.text(Message::VcsResolveConflicts)) .on_click(cx.listener(|this, _, _, cx| { this.side_panel.select_git_view(GitPanelView::Conflicts); cx.notify(); @@ -628,7 +651,14 @@ impl NebulaWorkspace { .flex_shrink_0() .px(px(6.0)) .items_center() - .child(div().flex_1().min_w_0().text_sm().font_semibold().child("历史线路")) + .child( + div() + .flex_1() + .min_w_0() + .text_sm() + .font_semibold() + .child(language.text(Message::VcsHistoryGraph)), + ) .child(div().text_xs().text_color(muted).child(info.history.len().to_string())) }); @@ -717,10 +747,10 @@ impl NebulaWorkspace { h_flex() .gap_1() .items_center() - .child(div().flex_1().min_w_0().child(Input::new(&self.git_commit_input))) + .child(div().flex_1().min_w_0().child(Input::new(&self.git_commit_input.input))) .child( Button::new("vcs-commit") - .label("提交") + .label(language.text(Message::VcsCommit)) .small() .disabled(!commit_ready) .on_click(cx.listener(|this, _, window, cx| { @@ -738,7 +768,7 @@ impl NebulaWorkspace { .items_center() .child( Button::new("git-stage-all") - .label("全部暂存") + .label(language.text(Message::VcsStageAll)) .small() .disabled(op_running || unstaged_len == 0) .on_click(cx.listener(|this, _, _, cx| { @@ -748,7 +778,7 @@ impl NebulaWorkspace { ) .child( Button::new("git-pull") - .label("拉取") + .label(language.text(Message::VcsPull)) .small() .disabled(op_running) .on_click(cx.listener(|this, _, _, cx| { @@ -759,9 +789,12 @@ impl NebulaWorkspace { .child( Button::new("git-push") .label(if ahead > 0 { - SharedString::from(format!("推送 ↑{ahead}")) + SharedString::from(language.format( + Message::VcsPushAhead, + &[("count", &ahead.to_string())], + )) } else { - SharedString::from("推送") + SharedString::from(language.text(Message::VcsPush)) }) .small() .disabled(op_running || ahead == 0) @@ -893,7 +926,7 @@ impl NebulaWorkspace { .when(browsing_elsewhere, |panel| { panel.child( Button::new("vcs-follow-cwd") - .label("回到终端当前目录") + .label(language.text(Message::VcsFollowDirectory)) .ghost() .xsmall() .on_click(cx.listener(|this, _, _, cx| { @@ -918,11 +951,11 @@ impl NebulaWorkspace { .when_some(action_strip, |panel, row| panel.child(row)) .when(git.is_none(), |panel| { panel.child( - div().py_3().text_sm().text_color(muted).child("当前目录不在 Git/SVN 仓库中"), + div().py_3().text_sm().text_color(muted).child(language.text(Message::VcsNotRepository)), ) }) - .when_some(self.side_panel.root_notice(), |panel, notice| { - panel.child(div().text_xs().text_color(theme.warning).child(notice.to_owned())) + .when_some(self.side_panel.localized_root_notice(language), |panel, notice| { + panel.child(div().text_xs().text_color(theme.warning).child(notice)) }) .child( v_flex() @@ -940,7 +973,7 @@ impl NebulaWorkspace { .icon(IconName::Redo2) .ghost() .xsmall() - .tooltip(format!("刷新 {vcs_label} 状态")) + .tooltip(language.format(Message::VcsRefreshStatus, &[("vcs", vcs_label)])) .on_click(cx.listener(|this, _, _, cx| { this.side_panel.request_refresh(); this.sync_side_panel_to_active(false, cx); @@ -952,7 +985,7 @@ impl NebulaWorkspace { .icon(IconName::Close) .ghost() .xsmall() - .tooltip(format!("关闭 {vcs_label} 状态")) + .tooltip(language.format(Message::VcsCloseStatus, &[("vcs", vcs_label)])) .on_click(cx.listener(|this, _, _, cx| { this.toggle_git_tree(cx); })), @@ -1019,11 +1052,11 @@ impl NebulaWorkspace { target: &gpui::WeakEntity, path: &str, index: usize, - section: &str, + section_id: &str, ) -> impl IntoElement { let target = target.clone(); let path = path.to_owned(); - Button::new(SharedString::from(format!("svn-row-more-{section}-{index}"))) + Button::new(SharedString::from(format!("svn-row-more-{section_id}-{index}"))) .icon(IconName::Ellipsis) .ghost() .xsmall() @@ -1427,31 +1460,17 @@ fn human_size(bytes: u64) -> String { /// 提交时间的人读形式。Git 提供 Unix 秒,显示层自己计算,结果不受机器上 /// `git log` 的 locale 影响;未来时间(系统时钟回拨)按“刚刚”处理。 -fn git_relative_time(timestamp: i64) -> String { +fn git_relative_time(timestamp: i64, language: UiLanguage) -> String { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|duration| duration.as_secs() as i64) .unwrap_or(timestamp); - git_relative_time_at(timestamp, now) -} - -fn git_relative_time_at(timestamp: i64, now: i64) -> String { - let seconds = now.saturating_sub(timestamp).max(0); - match seconds { - 0..=59 => "刚刚".to_owned(), - 60..=3599 => format!("{} 分钟前", seconds / 60), - 3600..=86_399 => format!("{} 小时前", seconds / 3600), - 86_400..=2_592_000 => format!("{} 天前", seconds / 86_400), - 2_592_001..=31_536_000 => format!("{} 个月前", seconds / 2_592_000), - _ => format!("{} 年前", seconds / 31_536_000), - } + git_relative_time_at(timestamp, now, language) } #[cfg(test)] mod tests { - use super::{ - GitLaneEdgeKind, GitRefKind, git_graph_rows, git_ref_labels, git_relative_time_at, - }; + use super::{GitLaneEdgeKind, GitRefKind, git_graph_rows, git_ref_labels}; use crate::display::side_panel::GitCommit; #[test] @@ -1500,16 +1519,4 @@ mod tests { assert_eq!(refs[3].label, "feature/ui"); assert_eq!(refs[3].kind, GitRefKind::Local); } - - #[test] - fn git_history_relative_time_uses_stable_boundaries() { - let now = 100_000_000; - assert_eq!(git_relative_time_at(now + 1, now), "刚刚"); - assert_eq!(git_relative_time_at(now - 59, now), "刚刚"); - assert_eq!(git_relative_time_at(now - 60, now), "1 分钟前"); - assert_eq!(git_relative_time_at(now - 3_600, now), "1 小时前"); - assert_eq!(git_relative_time_at(now - 86_400, now), "1 天前"); - assert_eq!(git_relative_time_at(now - 2_592_001, now), "1 个月前"); - assert_eq!(git_relative_time_at(now - 31_536_001, now), "1 年前"); - } } diff --git a/nebula_app/src/gpui_shell/workspace/vcs_panel/commit_input.rs b/nebula_app/src/gpui_shell/workspace/vcs_panel/commit_input.rs new file mode 100644 index 0000000..c774b5d --- /dev/null +++ b/nebula_app/src/gpui_shell/workspace/vcs_panel/commit_input.rs @@ -0,0 +1,113 @@ +//! Keep the commit draft while refreshing its placeholder after a language change. + +use gpui::{App, AppContext as _, Entity, Window}; +use gpui_component::input::InputState; + +use crate::gpui_shell::config::ui_language; +use crate::i18n::{Message, UiLanguage}; + +pub(in crate::gpui_shell::workspace) struct CommitInput { + pub(in crate::gpui_shell::workspace) input: Entity, + language: UiLanguage, +} + +#[cfg(all(test, feature = "gpui-test-support"))] +mod tests { + use super::*; + use gpui::{Element as _, IntoElement as _, Render, RenderOnce as _, div}; + use gpui_component::{ElementExt as _, input::Input}; + use std::sync::{Arc, Mutex}; + + struct CommitInputProbe { + commit: CommitInput, + placeholder: Arc>>, + } + + impl Render for CommitInputProbe { + fn render( + &mut self, + window: &mut Window, + cx: &mut gpui::Context, + ) -> impl gpui::IntoElement { + self.commit.sync_language(window, cx); + let input = self.commit.input.clone(); + let placeholder = self.placeholder.clone(); + div().on_prepaint(move |_, window, cx| { + let element = Input::new(&input).render(window, cx).into_element(); + let mut node = gpui::accesskit::Node::new(gpui::accesskit::Role::TextInput); + element.write_a11y_info(&mut node); + *placeholder.lock().unwrap() = node.placeholder().map(str::to_owned); + }) + } + } + + #[gpui::test] + fn commit_placeholder_tracks_language_without_replacing_the_draft( + cx: &mut gpui::TestAppContext, + ) { + cx.update(gpui_component::init); + let placeholder = Arc::new(Mutex::new(None)); + let captured = placeholder.clone(); + // The early-startup fallback is English even before Settings is registered. + let (probe, cx) = cx.add_window_view(|window, cx| CommitInputProbe { + commit: CommitInput::new(window, cx), + placeholder, + }); + cx.update(|window, cx| { + let _ = window.draw(cx); + }); + assert_eq!(captured.lock().unwrap().as_deref(), Some("Commit message...")); + let input = probe.read_with(cx, |probe, _| probe.commit.input.clone()); + cx.update(|window, cx| { + input.update(cx, |input, cx| { + input.set_value("fix: 保留 draft", window, cx); + input.set_selected_range(0..3, cx); + }); + }); + let original_id = input.entity_id(); + let original_cursor = input.read_with(cx, |input, _| input.cursor_position()); + let original_selection = input.read_with(cx, |input, _| input.selected_value()); + assert_eq!(original_selection, "fix"); + for (language, expected) in [ + (UiLanguage::ZhCn, "提交信息…"), + (UiLanguage::for_locale(Some("en-GB")), "Commit message..."), + (UiLanguage::FrFr, "Commit message..."), + (UiLanguage::ZhCn, "提交信息…"), + ] { + cx.update(|window, cx| { + let mut settings = + crate::gpui_shell::config::Settings::load(nebula_settings::ThemeName::Nord); + settings.ui_language = language; + cx.set_global(settings); + probe.update(cx, |_, cx| cx.notify()); + let _ = window.draw(cx); + }); + assert_eq!(captured.lock().unwrap().as_deref(), Some(expected)); + assert_eq!(probe.read_with(cx, |probe, _| probe.commit.input.entity_id()), original_id); + assert_eq!(input.read_with(cx, |input, _| input.value()), "fix: 保留 draft"); + assert_eq!(input.read_with(cx, |input, _| input.cursor_position()), original_cursor); + assert_eq!(input.read_with(cx, |input, _| input.selected_value()), original_selection); + } + } +} + +impl CommitInput { + pub(in crate::gpui_shell::workspace) fn new(window: &mut Window, cx: &mut App) -> Self { + let language = ui_language(cx); + let input = cx.new(|cx| { + InputState::new(window, cx).placeholder(language.text(Message::VcsCommitPlaceholder)) + }); + Self { input, language } + } + + pub(super) fn sync_language(&mut self, window: &mut Window, cx: &mut App) { + let language = ui_language(cx); + if self.language == language { + return; + } + self.input.update(cx, |input, cx| { + input.set_placeholder(language.text(Message::VcsCommitPlaceholder), window, cx); + }); + self.language = language; + } +} diff --git a/nebula_app/src/gpui_shell/workspace/vcs_panel/relative_time.rs b/nebula_app/src/gpui_shell/workspace/vcs_panel/relative_time.rs new file mode 100644 index 0000000..1b5285d --- /dev/null +++ b/nebula_app/src/gpui_shell/workspace/vcs_panel/relative_time.rs @@ -0,0 +1,61 @@ +//! Localized Git history timestamps, independent of the renderer and system clock. + +use crate::i18n::{Message, UiLanguage}; + +pub(super) fn git_relative_time_at(timestamp: i64, now: i64, language: UiLanguage) -> String { + let seconds = now.saturating_sub(timestamp).max(0); + let (count, singular, plural) = match seconds { + 0..=59 => return language.text(Message::VcsTimeNow).to_owned(), + 60..=3599 => (seconds / 60, Message::VcsTimeMinute, Message::VcsTimeMinutes), + 3600..=86_399 => (seconds / 3600, Message::VcsTimeHour, Message::VcsTimeHours), + 86_400..=2_592_000 => (seconds / 86_400, Message::VcsTimeDay, Message::VcsTimeDays), + 2_592_001..=31_536_000 => { + (seconds / 2_592_000, Message::VcsTimeMonth, Message::VcsTimeMonths) + }, + _ => (seconds / 31_536_000, Message::VcsTimeYear, Message::VcsTimeYears), + }; + if count == 1 { + language.text(singular).to_owned() + } else { + language.format(plural, &[("count", &count.to_string())]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn git_history_relative_time_uses_stable_boundaries() { + let now = 100_000_000; + assert_eq!(git_relative_time_at(now + 1, now, UiLanguage::ZhCn), "刚刚"); + assert_eq!(git_relative_time_at(now - 59, now, UiLanguage::ZhCn), "刚刚"); + assert_eq!(git_relative_time_at(now - 60, now, UiLanguage::ZhCn), "1 分钟前"); + assert_eq!(git_relative_time_at(now - 3_600, now, UiLanguage::ZhCn), "1 小时前"); + assert_eq!(git_relative_time_at(now - 86_400, now, UiLanguage::ZhCn), "1 天前"); + assert_eq!(git_relative_time_at(now - 2_592_001, now, UiLanguage::ZhCn), "1 个月前"); + assert_eq!(git_relative_time_at(now - 31_536_001, now, UiLanguage::ZhCn), "1 年前"); + } + + #[test] + fn git_history_time_uses_the_active_language_and_english_fallback() { + let now = 100_000_000; + for (seconds, expected) in [ + (-1, "just now"), + (59, "just now"), + (60, "1 minute ago"), + (120, "2 minutes ago"), + (3_600, "1 hour ago"), + (7_200, "2 hours ago"), + (86_400, "1 day ago"), + (172_800, "2 days ago"), + (2_592_001, "1 month ago"), + (5_184_000, "2 months ago"), + (31_536_001, "1 year ago"), + (63_072_000, "2 years ago"), + ] { + assert_eq!(git_relative_time_at(now - seconds, now, UiLanguage::EnUs), expected); + assert_eq!(git_relative_time_at(now - seconds, now, UiLanguage::FrFr), expected); + } + } +} diff --git a/nebula_app/tests/i18n_contract.rs b/nebula_app/tests/i18n_contract.rs index 0862483..42062dc 100644 --- a/nebula_app/tests/i18n_contract.rs +++ b/nebula_app/tests/i18n_contract.rs @@ -2,6 +2,10 @@ mod catalog_builder; #[path = "../src/i18n/mod.rs"] mod i18n; +#[path = "../src/display/side_panel/notice.rs"] +mod panel_notice; +#[path = "../src/gpui_shell/workspace/vcs_panel/relative_time.rs"] +mod vcs_relative_time; use std::alloc::{GlobalAlloc, Layout, System}; use std::cell::Cell; @@ -48,6 +52,8 @@ fn first_and_repeated_translation_lookups_allocate_nothing() { for language in i18n::UiLanguage::ALL { for _ in 0..1_000 { black_box(language.text(black_box(i18n::Message::SettingsSidebarNetwork))); + black_box(language.text(black_box(i18n::Message::VcsChanges))); + black_box(language.text(black_box(i18n::Message::VcsCommitPlaceholder))); 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"))); @@ -62,3 +68,13 @@ fn embedded_translations_stay_within_the_initial_payload_budget() { assert!(i18n::TRANSLATED_BYTES < 256 * 1024); assert!(i18n::MESSAGE_COUNT >= 200); } + +#[test] +fn vcs_messages_follow_resolved_english_and_fall_back_for_partial_locales() { + let english = i18n::UiLanguage::for_locale(Some("en-GB")); + assert_eq!(english.tr("vcs.changes"), "Changes"); + assert_eq!(english.tr("vcs.commit_placeholder"), "Commit message..."); + assert_eq!(i18n::UiLanguage::ZhCn.tr("vcs.changes"), "变更"); + assert_eq!(i18n::UiLanguage::FrFr.tr("vcs.changes"), "Changes"); + assert_eq!(english.tr_args("vcs.refresh_status", &[("vcs", "Git")]), "Refresh Git status"); +} From 3cd755a5620157f595322fb0e3521f4450f6e5cb Mon Sep 17 00:00:00 2001 From: Nash Aschenbath Date: Sun, 13 Sep 2026 12:19:08 +0800 Subject: [PATCH 05/51] fix(terminal): preserve explicit selection foreground colors --- docs/lua-configuration.md | 24 +++++++ nebula_app/src/gpui_shell/config.rs | 72 +++++++++++++++++++- nebula_app/src/gpui_shell/terminal/colors.rs | 2 +- 3 files changed, 95 insertions(+), 3 deletions(-) diff --git a/docs/lua-configuration.md b/docs/lua-configuration.md index bd0a4a6..77ac7bd 100644 --- a/docs/lua-configuration.md +++ b/docs/lua-configuration.md @@ -105,6 +105,30 @@ Files successfully loaded through `require` are added to the live-reload watch list. Extra local files can be registered with `pebrel.add_to_config_reload_watch_list(path)`. +## Terminal Selection Colors (TOML Compatibility) + +For a legacy `pebrel.toml` configuration, the GPUI terminal accepts explicit +selection foreground and background colors in `#rrggbb` or `0xrrggbb` form: + +```toml +[colors.selection] +foreground = "#102030" +background = "#e5e9f0" +``` + +Previously the GPUI adapter loaded the selection background but ignored the +foreground, which could leave selected text unreadable against a custom +background. It also cleared a custom foreground when applying a theme such as +Paper that does not declare selection colors. Both values now survive loading +and themes that do not override them. A theme that explicitly declares selection +colors, such as Nord, retains its existing precedence. + +Missing or invalid foreground colors keep the existing fallback. Relative +`CellForeground` / `CellBackground` values are not added by this change. The +regression tests cover parsing and theme precedence; this does not change the +answer reader's selection overlay, formula selection layout, or the default +selection contrast. No new setting, dependency, or per-frame work is introduced. + ## Platform Values The module exposes stable runtime paths and platform data: diff --git a/nebula_app/src/gpui_shell/config.rs b/nebula_app/src/gpui_shell/config.rs index e87af53..3f98075 100644 --- a/nebula_app/src/gpui_shell/config.rs +++ b/nebula_app/src/gpui_shell/config.rs @@ -270,7 +270,9 @@ fn apply_theme(palette: &mut Palette, theme: nebula_settings::ThemeName) { if let Some(selection) = exact.selection_background { palette.selection = rgba8(selection); } - palette.selection_foreground = exact.selection_foreground.map(rgba8); + if let Some(selection_foreground) = exact.selection_foreground { + palette.selection_foreground = Some(rgba8(selection_foreground)); + } return; } if term.is_light { @@ -552,6 +554,7 @@ fn build_palette(raw: &RawColors) -> Palette { // 用户显式选区色保持主应用的不透明语义。 palette.selection = selection; } + palette.selection_foreground = raw.selection.foreground.as_deref().and_then(parse_rgb); let ansi8 = |group: &RawAnsi8| -> [Option; 8] { [ @@ -604,7 +607,10 @@ fn build_palette(raw: &RawColors) -> Palette { #[cfg(test)] mod tests { - use super::{Settings, apply_theme, resolve_ui_language, rgba8, runtime_background}; + use super::{ + RawColors, Settings, apply_theme, build_palette, resolve_ui_language, rgba8, + runtime_background, + }; use crate::display::UiLanguage; use crate::gpui_shell::terminal::colors::Palette; use nebula_settings::{LanguagePref, ThemeName}; @@ -652,6 +658,68 @@ mod tests { assert_eq!(palette.selection_foreground, None); } + #[test] + fn explicit_selection_colors_are_loaded_together() { + let raw: RawColors = toml::from_str( + r##" + [selection] + foreground = "#102030" + background = "0xe5e9f0" + "##, + ) + .unwrap(); + let palette = build_palette(&raw); + + assert_eq!(palette.selection_foreground, Some(rgba8([0x10, 0x20, 0x30]))); + assert_eq!(palette.selection, rgba8([0xe5, 0xe9, 0xf0])); + } + + #[test] + fn invalid_or_relative_selection_foreground_keeps_default() { + for foreground in ["#invalid", "#fff", "CellForeground", "CellBackground"] { + let raw: RawColors = toml::from_str(&format!( + "[selection]\nforeground = {foreground:?}\nbackground = \"#e5e9f0\"\n" + )) + .unwrap(); + let palette = build_palette(&raw); + + assert_eq!(palette.selection_foreground, None, "{foreground}"); + assert_eq!(palette.selection, rgba8([0xe5, 0xe9, 0xf0])); + } + } + + #[test] + fn theme_without_selection_colors_preserves_user_foreground() { + let foreground = rgba8([0x10, 0x20, 0x30]); + let background = rgba8([0xe5, 0xe9, 0xf0]); + for theme in [ThemeName::Nebula, ThemeName::Paper] { + let mut palette = Palette { + selection_foreground: Some(foreground), + selection: background, + ..Palette::default() + }; + + apply_theme(&mut palette, theme); + + assert_eq!(palette.selection_foreground, Some(foreground)); + assert_eq!(palette.selection, background); + } + } + + #[test] + fn theme_with_selection_colors_keeps_its_existing_precedence() { + let mut palette = Palette { + selection_foreground: Some(rgba8([1, 2, 3])), + selection: rgba8([4, 5, 6]), + ..Palette::default() + }; + + apply_theme(&mut palette, ThemeName::Nord); + + assert_eq!(palette.selection_foreground, Some(rgba8([0x2e, 0x34, 0x40]))); + assert_eq!(palette.selection, rgba8([0xe5, 0xe9, 0xf0])); + } + #[test] fn themes_apply_only_the_colors_they_declare() { let mut nord = Palette::default(); diff --git a/nebula_app/src/gpui_shell/terminal/colors.rs b/nebula_app/src/gpui_shell/terminal/colors.rs index cda7772..ec5b856 100644 --- a/nebula_app/src/gpui_shell/terminal/colors.rs +++ b/nebula_app/src/gpui_shell/terminal/colors.rs @@ -27,7 +27,7 @@ pub struct Palette { /// 选区叠加色。主应用默认是反色语义;本壳以半透明叠加近似, /// 用户显式配置 `colors.selection.background` 时用不透明具体色。 pub selection: Rgba, - /// 主题明确声明的选区文字色;普通 Nebula 主题保留原字色。 + /// 主题或用户明确声明的选区文字色;未声明时保留原字色。 pub selection_foreground: Option, /// ANSI 0-15。 pub ansi: [Rgba; 16], From 9a4663eaeb1310602d6c5ad533a46ef992cadb75 Mon Sep 17 00:00:00 2001 From: Nash Aschenbath Date: Sun, 13 Sep 2026 15:26:11 +0800 Subject: [PATCH 06/51] fix(reader): keep selected text visible under highlight --- docs/math-rendering-test.md | 37 +++ docs/screenshots/reader-selection-opacity.png | Bin 0 -> 27686 bytes nebula_app/src/gpui_shell/theme.rs | 8 +- .../src/gpui_shell/theme/selection_tests.rs | 222 ++++++++++++++++++ 4 files changed, 266 insertions(+), 1 deletion(-) create mode 100644 docs/screenshots/reader-selection-opacity.png create mode 100644 nebula_app/src/gpui_shell/theme/selection_tests.rs diff --git a/docs/math-rendering-test.md b/docs/math-rendering-test.md index 58507c2..8861c10 100644 --- a/docs/math-rendering-test.md +++ b/docs/math-rendering-test.md @@ -2,6 +2,43 @@ 这是一份给 Nebula Markdown 阅读器使用的数学渲染截图样例。本文只把成对的 `$...$` 和 `$$...$$` 识别为数学公式;裸露 TeX 命令仍然按普通 Markdown 文本显示。 +## 正文选区可读性 + +回答阅读器与 Markdown 文本组件的选区绘制在文字上方。之前主题适配直接复用 +列表选中背景:在 MintLight、Nord 等主题中,这个背景不透明,会遮住已经绘制的 +字形。主题适配现在保留原色相,并遵循 gpui-component 对文字选区的 0.3 不透明度(alpha) +上限;更透明的主题保持原值。列表、侧栏的实色选中背景不受影响。 + +验证时,在 MintLight 和 Nord 中分别拖选下面这句正文,检查选中后文字仍可读、 +布局不移动,复制得到的内容与所选文字一致;再缩窄窗口检查换行后的选择。 + +alpha beta gamma delta + +自动回归覆盖全部内置主题的选区颜色和透明度、主题 token 同步,以及真实 +TextView 控件在深浅主题与宽窄布局下的鼠标拖选、键盘复制。另在 Windows 200% +缩放下对 MintLight 宽布局、Nord 窄布局做了原生窗口对照,修复前后的截图仅裁剪 +标题栏与留白,未重绘内容: + +![阅读器选区修复前后对照,修复后文字仍然可读](screenshots/reader-selection-opacity.png) + +原生预览可通过默认忽略的 `native_reader_selection_preview` 测试复核:先将 +`PEBREL_SELECTION_QA_DIR` 设置为新的绝对输出目录,按需设置 +`PEBREL_SELECTION_QA_THEME`(默认 MintLight)和 `PEBREL_SELECTION_QA_WIDTH` +(默认 480),然后运行: + +```text +cargo test --locked -p nebula --bin pebrel --features gpui-test-support native_reader_selection_preview -- --ignored --nocapture --test-threads=1 +``` + +测试只对自己的原生 TextView 窗口分发选择事件,不启动终端或 AI 会话、不改系统 +剪贴板。输出目录出现 `ready.json` 后可截图,写入 `capture-complete` 文件即可 +结束预览;没有信号时最多等待约一分钟后退出。 + +虚拟窗口测试不等同于完整产品验收;Linux/macOS 原生窗口、其他缩放和真实答案 +内容仍需对应平台检查。本修复不改变 +LaTeX 解析、公式选择时的源码回退、剪贴板格式或终端网格的配色;没有新增依赖 +或逐帧计算,颜色只在主题应用时适配。 + ## 1. 行内公式 行内公式应该和中文、英文正常混排:圆的面积是 $S=\pi r^2$,勾股定理是 $a^2+b^2=c^2$,欧拉恒等式是 $e^{i\pi}+1=0$。这里再放一个带上下标的表达式 $x_{i+1}^2+y_{j-1}^2$,检查基线、间距和换行。 diff --git a/docs/screenshots/reader-selection-opacity.png b/docs/screenshots/reader-selection-opacity.png new file mode 100644 index 0000000000000000000000000000000000000000..17271f0ba9589c9cf3e90760946c4149761d87f0 GIT binary patch literal 27686 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
CBNq42{F^78Ts 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 z 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 Wx4|v-$(-YnmwB%6EMMaF`~L$9?O4wM literal 0 HcmV?d00001 diff --git a/nebula_app/src/gpui_shell/theme.rs b/nebula_app/src/gpui_shell/theme.rs index 4208b98..a221916 100644 --- a/nebula_app/src/gpui_shell/theme.rs +++ b/nebula_app/src/gpui_shell/theme.rs @@ -8,6 +8,9 @@ use nebula_settings::ThemeName; mod syntax; +#[cfg(all(test, feature = "gpui-test-support"))] +mod selection_tests; + struct DocumentColors { background: Hsla, foreground: Hsla, @@ -647,7 +650,10 @@ fn apply_skin_tokens(chrome: NebulaTheme, cx: &mut App) { // 焦点 / 选择 / 链接 / 拖拽。 theme.ring = ink(sk.accent); theme.caret = ink(sk.accent); - theme.selection = wash(sk.accent_soft); + // TextView paints selection over glyphs. Match gpui-component's 0.3 alpha + // cap instead of passing through the opaque selected surface from Skin. + let selection = wash(sk.accent_soft); + theme.selection = selection.alpha(selection.a.min(0.3)); theme.link = ink(sk.accent); theme.link_hover = shift3(sk.accent.r, sk.accent.g, sk.accent.b, 0.10); theme.link_active = shift3(sk.accent.r, sk.accent.g, sk.accent.b, 0.18); diff --git a/nebula_app/src/gpui_shell/theme/selection_tests.rs b/nebula_app/src/gpui_shell/theme/selection_tests.rs new file mode 100644 index 0000000..3747346 --- /dev/null +++ b/nebula_app/src/gpui_shell/theme/selection_tests.rs @@ -0,0 +1,222 @@ +use gpui::{ + App, AppContext as _, Context, Entity, InteractiveElement as _, IntoElement, Modifiers, + MouseButton, ParentElement as _, Render, StatefulInteractiveElement as _, Styled as _, + TestAppContext, Window, div, point, px, +}; +use gpui_component::{ + ActiveTheme as _, Root, Theme, ThemeMode, WindowExt as _, + text::{TextView, TextViewState}, +}; +use nebula_settings::ThemeName; + +use super::{apply_skin_tokens, chrome_theme, wash}; + +fn apply_reader_theme(name: ThemeName, cx: &mut App) { + let chrome = chrome_theme(name); + let mode = if chrome.skin().is_light { ThemeMode::Light } else { ThemeMode::Dark }; + Theme::change(mode, None, cx); + apply_skin_tokens(chrome, cx); + // Match the product shell at full opacity without loading wallpaper, + // preferences, or tray integrations into the isolated test process. + let background = super::shell_color(chrome); + let theme = Theme::global_mut(cx); + theme.background = background; + theme.tokens.background = background.into(); +} + +#[gpui::test] +fn text_selection_theme_preserves_rgb_and_caps_overlay_alpha(cx: &mut TestAppContext) { + cx.update(|cx| { + gpui_component::init(cx); + for name in ThemeName::BUILTIN { + let chrome = chrome_theme(name); + let original = wash(chrome.skin().accent_soft); + apply_reader_theme(name, cx); + + let theme = cx.theme(); + let selection = theme.selection; + assert_eq!( + (selection.h, selection.s, selection.l), + (original.h, original.s, original.l) + ); + assert_eq!(selection.a, original.a.min(0.3), "{name:?}"); + assert!(selection.a > 0.0, "{name:?}"); + assert_eq!(theme.tokens.selection.color, selection, "{name:?}"); + assert_eq!(theme.tokens.selection.background, selection.into(), "{name:?}"); + // Solid selection surfaces for lists are not text overlays. + assert_eq!(theme.list_active, original, "{name:?}"); + } + }); +} + +const SELECTED_TEXT: &str = "alpha beta gamma delta"; + +struct ReaderSelectionFixture { + text: Entity, + width: f32, +} + +impl Render for ReaderSelectionFixture { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + div() + .id("reader-selection-fixture") + .debug_selector(|| "reader-selection-fixture".to_owned()) + .w(px(self.width)) + .h(px(180.0)) + .text_size(px(16.0)) + .text_color(cx.theme().foreground) + .bg(cx.theme().background) + .child(TextView::new(&self.text).selectable(true)) + } +} + +#[gpui::test] +fn reader_drag_selection_keeps_text_and_copy_after_theme_switch(cx: &mut TestAppContext) { + cx.update(gpui_component::init); + let mut fixture = None; + let (_, cx) = cx.add_window_view(|window, cx| { + let view = cx.new(|cx| ReaderSelectionFixture { + text: cx.new(|cx| TextViewState::markdown(SELECTED_TEXT, cx)), + width: 480.0, + }); + fixture = Some(view.clone()); + Root::new(view, window, cx) + }); + let fixture = fixture.unwrap(); + + for (name, width) in [(ThemeName::MintLight, 480.0), (ThemeName::Nord, 160.0)] { + cx.update(|window, cx| { + window.clear_text_selection(cx); + apply_reader_theme(name, cx); + fixture.update(cx, |view, cx| { + view.width = width; + cx.notify(); + }); + let _ = window.draw(cx); + }); + cx.run_until_parked(); + let bounds = cx.debug_bounds("reader-selection-fixture").unwrap(); + let start = bounds.origin + point(px(1.0), px(10.0)); + let end = bounds.bottom_right() - point(px(2.0), px(2.0)); + cx.simulate_mouse_down(start, MouseButton::Left, Modifiers::default()); + cx.simulate_mouse_move(end, Some(MouseButton::Left), Modifiers::default()); + cx.simulate_mouse_up(end, MouseButton::Left, Modifiers::default()); + cx.update(|window, cx| { + let _ = window.draw(cx); + assert_eq!(window.selected_text(cx).trim(), SELECTED_TEXT, "{name:?}"); + assert!(cx.theme().selection.a <= 0.3, "{name:?}"); + }); + assert_eq!(cx.debug_bounds("reader-selection-fixture").unwrap(), bounds); + let copy = if cfg!(target_os = "macos") { "cmd-c" } else { "ctrl-c" }; + cx.simulate_keystrokes(copy); + let copied = cx.read_from_clipboard().and_then(|item| item.text()).unwrap(); + assert_eq!(copied.trim(), SELECTED_TEXT, "{name:?}"); + } +} + +/// Native visual check for the same TextView used by the answer reader. Input +/// events are dispatched to this test window, never to another desktop window. +/// The probe does not touch the OS clipboard or start a terminal/AI session. +#[cfg(target_os = "windows")] +#[test] +#[ignore = "requires a Windows desktop and PEBREL_SELECTION_QA_DIR for screenshots"] +fn native_reader_selection_preview() { + use gpui::{ + Bounds, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PlatformInput, WindowBounds, + WindowOptions, size, + }; + use std::{path::PathBuf, time::Duration}; + + let output = PathBuf::from( + std::env::var_os("PEBREL_SELECTION_QA_DIR").expect("set QA output directory"), + ); + let name = std::env::var("PEBREL_SELECTION_QA_THEME").unwrap_or_else(|_| "MintLight".into()); + let name = ThemeName::from_prompt_name(&name).expect("a built-in theme name"); + let width: f32 = std::env::var("PEBREL_SELECTION_QA_WIDTH") + .unwrap_or_else(|_| "480".into()) + .parse() + .expect("numeric width"); + std::fs::create_dir_all(&output).unwrap(); + let ready = output.join("ready.json"); + assert!(!ready.exists(), "use a fresh QA directory"); + let ready_after_run = ready.clone(); + + gpui_platform::application().run(move |cx| { + gpui_component::init(cx); + apply_reader_theme(name, cx); + let selection = cx.theme().selection; + let window = cx + .open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(Bounds::new( + point(px(40.0), px(60.0)), + size(px(680.0), px(260.0)), + ))), + focus: false, + show: true, + ..Default::default() + }, + |window, cx| { + let view = cx.new(|cx| ReaderSelectionFixture { + text: cx.new(|cx| TextViewState::markdown(SELECTED_TEXT, cx)), + width, + }); + cx.new(|cx| Root::new(view, window, cx)) + }, + ) + .unwrap(); + cx.spawn(async move |cx| { + cx.background_executor().timer(Duration::from_millis(500)).await; + let selected = cx + .update_window(window.into(), |_, window, cx| { + let start = point(px(1.0), px(10.0)); + let end = point(px(width - 2.0), px(178.0)); + window.dispatch_event( + PlatformInput::MouseDown(MouseDownEvent { + position: start, + button: MouseButton::Left, + modifiers: Modifiers::default(), + click_count: 1, + first_mouse: false, + }), + cx, + ); + window.dispatch_event( + PlatformInput::MouseMove(MouseMoveEvent { + position: end, + pressed_button: Some(MouseButton::Left), + modifiers: Modifiers::default(), + }), + cx, + ); + window.dispatch_event( + PlatformInput::MouseUp(MouseUpEvent { + position: end, + button: MouseButton::Left, + modifiers: Modifiers::default(), + click_count: 1, + }), + cx, + ); + let _ = window.draw(cx); + window.selected_text(cx) + }) + .unwrap(); + assert_eq!(selected.trim(), SELECTED_TEXT); + cx.background_executor().timer(Duration::from_millis(250)).await; + std::fs::write(&ready, serde_json::to_vec(&serde_json::json!({ + "pid": std::process::id(), "theme": name.prompt_name(), + "width": width, "selected_text": selected.trim(), "selection_alpha": selection.a, + })).unwrap()).unwrap(); + for _ in 0..120 { + if output.join("capture-complete").exists() { + break; + } + cx.background_executor().timer(Duration::from_millis(500)).await; + } + cx.update(|cx| cx.quit()); + }) + .detach(); + }); + assert!(ready_after_run.exists(), "native probe did not reach selected state"); +} From ae66b69e7818f3d11f71d11fb9800013906ba53f Mon Sep 17 00:00:00 2001 From: GeekMr <140129782+WilliamWang1721@users.noreply.github.com> Date: Sun, 13 Sep 2026 16:52:06 +0800 Subject: [PATCH 07/51] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E5=8D=95?= =?UTF-8?q?=E7=8B=AC=E8=AE=BE=E7=BD=AE=E4=B8=AD=E6=96=87=E7=BB=88=E7=AB=AF?= =?UTF-8?q?=E5=AD=97=E4=BD=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- nebula_app/i18n/en-US.json | 3 +++ nebula_app/i18n/zh-CN.json | 3 +++ nebula_app/src/gpui_shell/config.rs | 15 ++++++++++++ nebula_app/src/gpui_shell/settings_pane.rs | 1 + .../settings_pane/appearance_advanced.rs | 10 +++++++- .../gpui_shell/settings_pane/font_picker.rs | 4 ++-- .../settings_pane/initialization.rs | 23 +++++++++++++++++++ nebula_app/src/gpui_shell/terminal/element.rs | 6 +++++ nebula_settings/src/lib.rs | 5 ++++ nebula_settings/src/reset.rs | 1 + 10 files changed, 68 insertions(+), 3 deletions(-) diff --git a/nebula_app/i18n/en-US.json b/nebula_app/i18n/en-US.json index 40365c5..4674cfd 100644 --- a/nebula_app/i18n/en-US.json +++ b/nebula_app/i18n/en-US.json @@ -77,6 +77,9 @@ "background_cover_description": "The image will extend beneath the title bar, window buttons, tabs, and SSH sidebar. Low-contrast images may make controls harder to see; the interface retains a minimum-opacity safeguard." }, "font": { + "english": "English terminal font", + "chinese": "Chinese terminal font", + "chinese_description": "Used for Chinese and other full-width characters. Enter a font family or comma-separated families; leave empty to use the English font settings. Applies on Enter or leaving the field.", "fallback_description": "The first family is preferred; missing glyphs fall back through the remaining families, with bundled Maple as the final safe fallback.", "group_description": "The first family is primary; the rest are used in fallback order.", "loading": "Enumerating system fonts...", diff --git a/nebula_app/i18n/zh-CN.json b/nebula_app/i18n/zh-CN.json index 09d7b8a..35fd88e 100644 --- a/nebula_app/i18n/zh-CN.json +++ b/nebula_app/i18n/zh-CN.json @@ -77,6 +77,9 @@ "background_cover_description": "背景图会延伸到标题栏、窗口按钮、Tab 与 SSH 侧栏下方,低对比度图片可能影响操作可见性;界面仍会保留最低不透明度保护。" }, "font": { + "english": "英文终端字体", + "chinese": "中文终端字体", + "chinese_description": "用于中文等全宽字符。输入字体名称,多个字体以逗号分隔;留空时使用英文字体设置。按回车或离开输入框后生效。", "fallback_description": "第 1 项优先显示;缺失字形依次使用后续字体,内置 Maple 是最终安全兜底。", "group_description": "第 1 项是主字体,其余按顺序回退", "loading": "正在枚举系统字体…", diff --git a/nebula_app/src/gpui_shell/config.rs b/nebula_app/src/gpui_shell/config.rs index e87af53..26c4354 100644 --- a/nebula_app/src/gpui_shell/config.rs +++ b/nebula_app/src/gpui_shell/config.rs @@ -42,6 +42,7 @@ pub struct Settings { /// 已解析的界面语言。GPUI 组件只读这个内存全局,渲染路径不得重复读盘。 pub ui_language: UiLanguage, pub font_family: String, + pub font_cjk: Option<[gpui::Font; 4]>, pub font_bold_family: String, pub font_italic_family: String, pub font_bold_italic_family: String, @@ -193,6 +194,20 @@ impl Settings { cjk_bold_regular: runtime.cjk_bold_regular, shell_id: runtime.shell.clone(), font_family: normal_family, + font_cjk: runtime.font_family_cjk.as_deref().map(|family| { + use gpui::{FontStyle, FontWeight}; + [ + (FontWeight::NORMAL, FontStyle::Normal), + (FontWeight::BOLD, FontStyle::Normal), + (FontWeight::NORMAL, FontStyle::Italic), + (FontWeight::BOLD, FontStyle::Italic), + ] + .map(|(weight, style)| gpui::Font { + weight, + style, + ..crate::font_install::gpui_font_with_fallbacks(family) + }) + }), load_notice, // 这里是唯一能正确合并「主题自带几何」与「用户显式覆盖」的地方: // `theme` 已是 follow_system 折算后的**生效**主题,runtime 是同一次 diff --git a/nebula_app/src/gpui_shell/settings_pane.rs b/nebula_app/src/gpui_shell/settings_pane.rs index 5d99305..550cca3 100644 --- a/nebula_app/src/gpui_shell/settings_pane.rs +++ b/nebula_app/src/gpui_shell/settings_pane.rs @@ -175,6 +175,7 @@ pub struct SettingsPane { /// GPUI text system 已注册的导入族名(启动扫描 + 本次导入累计)。 font_imported: Vec, font_family_input: Entity, + font_family_cjk_input: Entity, /// 字体输入框上一帧的窗口坐标。字体目录是宽弹层,不能把整条设置行当 /// 锚点;否则输入框在右侧、菜单却会从正文左缘展开。 font_picker_trigger_bounds: Option>, diff --git a/nebula_app/src/gpui_shell/settings_pane/appearance_advanced.rs b/nebula_app/src/gpui_shell/settings_pane/appearance_advanced.rs index 741eb29..bd0a867 100644 --- a/nebula_app/src/gpui_shell/settings_pane/appearance_advanced.rs +++ b/nebula_app/src/gpui_shell/settings_pane/appearance_advanced.rs @@ -96,11 +96,19 @@ impl SettingsPane { let terminal = self .group(language.pick("终端外观", "Terminal appearance"), cx) .child(self.row( - language.pick("终端字体", "Terminal font"), + language.text(crate::i18n::Message::SettingsFontEnglish), help("font_family", language), font_picker, cx, )) + .child(self.row( + language.text(crate::i18n::Message::SettingsFontChinese), + language.text(crate::i18n::Message::SettingsFontChineseDescription), + Input::new(&self.font_family_cjk_input) + .w(px(SETTINGS_SELECT_WIDTH)) + .aria_label(language.text(crate::i18n::Message::SettingsFontChinese)), + cx, + )) .child(self.terminal_font_size_row(cx)) .child(self.select_row( "cell_width_mode", 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 2a9b42c..ea23ee5 100644 --- a/nebula_app/src/gpui_shell/settings_pane/font_picker.rs +++ b/nebula_app/src/gpui_shell/settings_pane/font_picker.rs @@ -219,7 +219,7 @@ impl SettingsPane { cx.stop_propagation(); cx.notify(); }))) - .aria_label(language.pick("终端字体", "Terminal font")), + .aria_label(language.text(crate::i18n::Message::SettingsFontEnglish)), ) // 弹层仍须取输入框的真实窗口坐标,才能正确处理滚动、缩放与 DPI。 .child( @@ -246,7 +246,7 @@ impl SettingsPane { return control.into_any_element(); } self.row( - language.pick("终端字体", "Terminal font"), + language.text(crate::i18n::Message::SettingsFontEnglish), 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 index fac590c..e3792f9 100644 --- a/nebula_app/src/gpui_shell/settings_pane/initialization.rs +++ b/nebula_app/src/gpui_shell/settings_pane/initialization.rs @@ -439,6 +439,28 @@ impl SettingsPane { }, )); + let font_family_cjk_input = cx.new(|cx| { + InputState::new(window, cx) + .default_value(runtime.font_family_cjk.clone().unwrap_or_default()) + }); + subscriptions.push(cx.subscribe_in( + &font_family_cjk_input, + window, + |this: &mut Self, _, event: &InputEvent, window, cx| { + if matches!(event, InputEvent::PressEnter { .. } | InputEvent::Blur) { + let value = crate::font_install::normalize_font_family_chain( + &this.font_family_cjk_input.read(cx).value(), + ); + this.font_family_cjk_input.update(cx, |input, cx| { + input.set_value(value.clone(), window, cx); + }); + if this.runtime.font_family_cjk.as_deref().unwrap_or_default() != value { + this.persist(&[("font_family_cjk", value)], 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]); @@ -548,6 +570,7 @@ impl SettingsPane { font_system: None, font_imported: Vec::new(), font_family_input, + font_family_cjk_input, font_picker_trigger_bounds: None, backup_selection: crate::encrypted_backup::BackupSelection::default(), backup_pass_input: cx.new(|cx| { diff --git a/nebula_app/src/gpui_shell/terminal/element.rs b/nebula_app/src/gpui_shell/terminal/element.rs index 1955790..094e7a0 100644 --- a/nebula_app/src/gpui_shell/terminal/element.rs +++ b/nebula_app/src/gpui_shell/terminal/element.rs @@ -497,8 +497,14 @@ impl Element for TerminalElement { .try_global::() .map(|settings| settings.cjk_bold_regular) .unwrap_or(true); + let cjk_fonts = cx + .try_global::() + .and_then(|settings| settings.font_cjk.clone()); let pick_font = |bold: bool, italic: bool, wide: bool| { let bold = bold && !(wide && cjk_bold_regular); + if let Some(fonts) = cjk_fonts.as_ref().filter(|_| wide) { + return fonts[usize::from(bold) + 2 * usize::from(italic)].clone(); + } match (bold, italic) { (false, false) => font.clone(), (true, false) => bold_font.clone(), diff --git a/nebula_settings/src/lib.rs b/nebula_settings/src/lib.rs index e097f80..692f61f 100644 --- a/nebula_settings/src/lib.rs +++ b/nebula_settings/src/lib.rs @@ -941,6 +941,7 @@ pub struct RuntimeSettings { /// 系统外观变化时自动在主题家族的深浅成员间切换(默认关,尊重显式选择)。 pub follow_system_theme: bool, pub font_family: Option, + pub font_family_cjk: Option, /// **逻辑像素**(旧壳写盘语义:设置页 spinner 与 Ctrl+滚轮缩放持久化时 /// 已除以 scale factor)。`None` = 跟随 nebula.toml 的 `font.size`(pt)。 pub font_size_px: Option, @@ -1081,6 +1082,7 @@ impl RuntimeSettings { .unwrap_or_default(), follow_system_theme: raw.bool_on("follow_system_theme").unwrap_or(false), font_family: raw.value("font_family").map(str::to_owned), + font_family_cjk: raw.value("font_family_cjk").map(str::to_owned), font_size_px: raw.f32("font_size").map(|size| size.clamp(4.0, 96.0)), cursor_shape: raw.value("cursor_shape").and_then(CursorShapeName::from_settings), cursor_blink: raw.bool_on("cursor_blink"), @@ -1305,6 +1307,7 @@ mod tests { completion_style=popup\n\ shell=pwsh\n\ font_family=Maple Mono Normal NF CN\n\ + font_family_cjk=PingFang SC\n\ font_size=16.3\n\ cursor_shape=beam\n\ cursor_blink=1\n\ @@ -1339,6 +1342,7 @@ mod tests { assert_eq!(settings.language, LanguagePref::ZhCn); assert_eq!(settings.theme, ThemeName::SilverLight); assert_eq!(settings.font_family.as_deref(), Some("Maple Mono Normal NF CN")); + assert_eq!(settings.font_family_cjk.as_deref(), Some("PingFang SC")); // font_size 键存的是逻辑像素(旧壳写盘语义),不做 pt 换算。 assert_eq!(settings.font_size_px, Some(16.3)); assert_eq!(settings.cursor_shape, Some(CursorShapeName::Beam)); @@ -1381,6 +1385,7 @@ mod tests { assert_eq!(settings.language, LanguagePref::System); assert_eq!(settings.theme, ThemeName::Nord); assert_eq!(settings.font_family, None); + assert_eq!(settings.font_family_cjk, None); assert!(!settings.copy_on_select); assert!(settings.multiline_paste_confirm, "多行粘贴确认默认开(上游兼容)"); assert!(settings.tab_close_visible, "标签关闭按钮默认可见(上游兼容)"); diff --git a/nebula_settings/src/reset.rs b/nebula_settings/src/reset.rs index 2e8c87c..c54b470 100644 --- a/nebula_settings/src/reset.rs +++ b/nebula_settings/src/reset.rs @@ -10,6 +10,7 @@ const RESET_KEYS: &[&str] = &[ "app_icon", "follow_system_theme", "font_family", + "font_family_cjk", "font_size", "cursor_shape", "cursor_blink", From 7db14e5130016971d2350d30bb8ab9fe055da3d3 Mon Sep 17 00:00:00 2001 From: GeekMr <140129782+WilliamWang1721@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:06:05 +0800 Subject: [PATCH 08/51] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E7=8B=AC?= =?UTF-8?q?=E7=AB=8B=E7=95=8C=E9=9D=A2=E5=AD=97=E4=BD=93=E5=92=8C=E5=AD=97?= =?UTF-8?q?=E5=8F=B7=E8=AE=BE=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对应 https://github.com/Kuddev/pebrel/issues/123 --- nebula_app/i18n/en-US.json | 4 ++ nebula_app/i18n/zh-CN.json | 4 ++ nebula_app/src/gpui_shell/config.rs | 2 + nebula_app/src/gpui_shell/settings_pane.rs | 15 ++---- .../gpui_shell/settings_pane/appearance.rs | 37 ++++++++------- .../settings_pane/appearance_advanced.rs | 11 ++++- .../settings_pane/initialization.rs | 47 ++++++++++++------- .../src/gpui_shell/ssh_settings/library.rs | 2 +- .../terminal/ssh_connect_overlay.rs | 4 +- nebula_app/src/gpui_shell/theme.rs | 12 ++++- .../src/gpui_shell/workspace/pane_header.rs | 4 +- .../src/gpui_shell/workspace/sidebar.rs | 13 ++--- .../src/gpui_shell/workspace/top_tabs.rs | 4 +- nebula_settings/src/lib.rs | 10 ++++ nebula_settings/src/reset.rs | 2 + 15 files changed, 107 insertions(+), 64 deletions(-) diff --git a/nebula_app/i18n/en-US.json b/nebula_app/i18n/en-US.json index 4674cfd..34b60ab 100644 --- a/nebula_app/i18n/en-US.json +++ b/nebula_app/i18n/en-US.json @@ -77,6 +77,10 @@ "background_cover_description": "The image will extend beneath the title bar, window buttons, tabs, and SSH sidebar. Low-contrast images may make controls harder to see; the interface retains a minimum-opacity safeguard." }, "font": { + "ui_family": "Interface font", + "ui_family_description": "Enter an installed font family; leave empty for the default. Applies on Enter or leaving the field.", + "ui_size": "Interface font size", + "ui_size_description": "Changes interface text and component sizing independently of terminal text.", "english": "English terminal font", "chinese": "Chinese terminal font", "chinese_description": "Used for Chinese and other full-width characters. Enter a font family or comma-separated families; leave empty to use the English font settings. Applies on Enter or leaving the field.", diff --git a/nebula_app/i18n/zh-CN.json b/nebula_app/i18n/zh-CN.json index 35fd88e..c38c597 100644 --- a/nebula_app/i18n/zh-CN.json +++ b/nebula_app/i18n/zh-CN.json @@ -77,6 +77,10 @@ "background_cover_description": "背景图会延伸到标题栏、窗口按钮、Tab 与 SSH 侧栏下方,低对比度图片可能影响操作可见性;界面仍会保留最低不透明度保护。" }, "font": { + "ui_family": "界面字体", + "ui_family_description": "输入已安装的字体名称;留空使用默认字体。按回车或离开输入框后生效。", + "ui_size": "界面字号", + "ui_size_description": "独立调整界面文字和组件字号,不影响终端文字。", "english": "英文终端字体", "chinese": "中文终端字体", "chinese_description": "用于中文等全宽字符。输入字体名称,多个字体以逗号分隔;留空时使用英文字体设置。按回车或离开输入框后生效。", diff --git a/nebula_app/src/gpui_shell/config.rs b/nebula_app/src/gpui_shell/config.rs index 26c4354..a6046c6 100644 --- a/nebula_app/src/gpui_shell/config.rs +++ b/nebula_app/src/gpui_shell/config.rs @@ -51,6 +51,7 @@ pub struct Settings { /// 配置文件的基准字号,不含设置页/Ctrl+滚轮持久化的终端缩放。 /// 启动窗口按它定形,和旧壳的 `window_size` 契约一致。 pub base_font_size_px: f32, + pub ui_font_size_px: f32, /// 字体 cell 的物理像素偏移;旧壳 Windows 默认 y=4,必须在设备像素 /// 域参与取整,才能在 125%/150% DPI 下保持同一行数。 pub font_offset_x: f32, @@ -166,6 +167,7 @@ impl Settings { font_bold_italic_family: secondary(&raw.font.bold_italic), font_size_px, base_font_size_px, + ui_font_size_px: runtime.ui_font_size_px.unwrap_or(base_font_size_px), font_offset_x: f32::from(offset.x), font_offset_y: f32::from(offset.y), palette, diff --git a/nebula_app/src/gpui_shell/settings_pane.rs b/nebula_app/src/gpui_shell/settings_pane.rs index 550cca3..32b7427 100644 --- a/nebula_app/src/gpui_shell/settings_pane.rs +++ b/nebula_app/src/gpui_shell/settings_pane.rs @@ -176,6 +176,7 @@ pub struct SettingsPane { font_imported: Vec, font_family_input: Entity, font_family_cjk_input: Entity, + ui_font_family_input: Entity, /// 字体输入框上一帧的窗口坐标。字体目录是宽弹层,不能把整条设置行当 /// 锚点;否则输入框在右侧、菜单却会从正文左缘展开。 font_picker_trigger_bounds: Option>, @@ -377,14 +378,9 @@ impl SettingsPane { self.sync_background_color_picker(window, cx); } - /// Workspace tab 与设置正文共用的主文字字号事实源;导航使用组件小字号。 - /// - /// 旧壳合同(display/mod.rs `ui_font_px`):chrome 排版锚定**配置字号** - /// (nebula.toml `font.size`,默认 11.25pt = 15px),终端的持久化缩放 - /// (设置 spinner / Ctrl+滚轮写入的 `font_size=`)只影响终端网格, - /// 不得放大侧栏与设置文字。 + /// 界面独立字号;未设置时保留原配置字号,终端缩放不影响这里。 fn font_size_px(&self, cx: &App) -> f32 { - cx.global::().base_font_size_px + cx.global::().ui_font_size_px } /// 终端字号(预览与「终端字号」步进行显示的值)。 @@ -392,11 +388,6 @@ impl SettingsPane { cx.global::().font_size_px } - fn set_font_size(&mut self, size: f32, cx: &mut Context) { - let size = size.clamp(4.0, 96.0); - self.persist(&[("font_size", format!("{size:.2}"))], cx); - } - /// 拖拽期间只做"改 alpha + 重绘"这两件必须的事,落盘与整套热应用 /// 交给 [`Self::schedule_slider_persist`]。旧壳拖 opacity 就是改内存 + 重绘, /// 这条快路径是为了对回那个手感。 diff --git a/nebula_app/src/gpui_shell/settings_pane/appearance.rs b/nebula_app/src/gpui_shell/settings_pane/appearance.rs index d965e97..cc13a4e 100644 --- a/nebula_app/src/gpui_shell/settings_pane/appearance.rs +++ b/nebula_app/src/gpui_shell/settings_pane/appearance.rs @@ -88,45 +88,46 @@ impl SettingsPane { .into_any_element() } - pub(super) fn terminal_font_size_row(&self, cx: &Context) -> gpui::AnyElement { + pub(super) fn font_size_row(&self, ui: bool, cx: &Context) -> gpui::AnyElement { let language = crate::gpui_shell::config::ui_language(cx); - let size = self.terminal_font_size_px(cx); + let size = if ui { self.font_size_px(cx) } else { self.terminal_font_size_px(cx) }; + let (key, min, max) = if ui { ("ui_font_size", 10.0, 24.0) } else { ("font_size", 4.0, 96.0) }; let stepper = h_flex() .w(px(142.0)) .h(px(36.0)) .items_center() .child( - Button::new("appearance-font-smaller") + Button::new(SharedString::from(format!("{key}-smaller"))) .icon(IconName::Minus) .ghost() .size(px(34.0)) - .disabled(size <= 4.0) + .disabled(size <= min) .tooltip(language.pick("减小字号", "Decrease font size")) - .on_click(cx.listener(|this, _, _, cx| { - this.set_font_size( - (this.terminal_font_size_px(cx).ceil() - 1.0).round(), - cx, - ); + .on_click(cx.listener(move |this, _, _, cx| { + let next = (size.ceil() - 1.0).clamp(min, max); + this.persist(&[(key, format!("{next:.2}"))], cx); })), ) .child(div().flex_1().text_center().child(format!("{size:.0} px"))) .child( - Button::new("appearance-font-larger") + Button::new(SharedString::from(format!("{key}-larger"))) .icon(IconName::Plus) .ghost() .size(px(34.0)) - .disabled(size >= 96.0) + .disabled(size >= max) .tooltip(language.pick("增大字号", "Increase font size")) - .on_click(cx.listener(|this, _, _, cx| { - this.set_font_size( - (this.terminal_font_size_px(cx).floor() + 1.0).round(), - cx, - ); + .on_click(cx.listener(move |this, _, _, cx| { + let next = (size.floor() + 1.0).clamp(min, max); + this.persist(&[(key, format!("{next:.2}"))], cx); })), ); self.row( - language.pick("终端字号(Ctrl+滚轮缩放)", "Terminal font size (Ctrl+wheel)"), - language.pick("只调整终端文字大小。", "Changes only the terminal text size."), + if ui { language.text(crate::i18n::Message::SettingsFontUiSize) } else { + language.pick("终端字号(Ctrl+滚轮缩放)", "Terminal font size (Ctrl+wheel)") + }, + if ui { language.text(crate::i18n::Message::SettingsFontUiSizeDescription) } else { + language.pick("只调整终端文字大小。", "Changes only the terminal text size.") + }, stepper, cx, ) 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 bd0a867..feaa88e 100644 --- a/nebula_app/src/gpui_shell/settings_pane/appearance_advanced.rs +++ b/nebula_app/src/gpui_shell/settings_pane/appearance_advanced.rs @@ -61,6 +61,15 @@ impl SettingsPane { )); let interface = self .group(language.pick("界面", "Interface"), cx) + .child(self.row( + language.text(crate::i18n::Message::SettingsFontUiFamily), + language.text(crate::i18n::Message::SettingsFontUiFamilyDescription), + Input::new(&self.ui_font_family_input) + .w(px(SETTINGS_SELECT_WIDTH)) + .aria_label(language.text(crate::i18n::Message::SettingsFontUiFamily)), + cx, + )) + .child(self.font_size_row(true, cx)) .child(self.switch_row( "tab_close_visible", language.pick("显示标签关闭按钮", "Show tab close buttons"), @@ -109,7 +118,7 @@ impl SettingsPane { .aria_label(language.text(crate::i18n::Message::SettingsFontChinese)), cx, )) - .child(self.terminal_font_size_row(cx)) + .child(self.font_size_row(false, cx)) .child(self.select_row( "cell_width_mode", language.pick("字体间距", "Character spacing"), diff --git a/nebula_app/src/gpui_shell/settings_pane/initialization.rs b/nebula_app/src/gpui_shell/settings_pane/initialization.rs index e3792f9..5bdb36e 100644 --- a/nebula_app/src/gpui_shell/settings_pane/initialization.rs +++ b/nebula_app/src/gpui_shell/settings_pane/initialization.rs @@ -443,23 +443,37 @@ impl SettingsPane { InputState::new(window, cx) .default_value(runtime.font_family_cjk.clone().unwrap_or_default()) }); - subscriptions.push(cx.subscribe_in( - &font_family_cjk_input, - window, - |this: &mut Self, _, event: &InputEvent, window, cx| { - if matches!(event, InputEvent::PressEnter { .. } | InputEvent::Blur) { - let value = crate::font_install::normalize_font_family_chain( - &this.font_family_cjk_input.read(cx).value(), - ); - this.font_family_cjk_input.update(cx, |input, cx| { - input.set_value(value.clone(), window, cx); - }); - if this.runtime.font_family_cjk.as_deref().unwrap_or_default() != value { - this.persist(&[("font_family_cjk", value)], cx); + let ui_font_family_input = cx.new(|cx| { + InputState::new(window, cx) + .default_value(runtime.ui_font_family.clone().unwrap_or_default()) + }); + for (key, input) in [ + ("font_family_cjk", &font_family_cjk_input), + ("ui_font_family", &ui_font_family_input), + ] { + subscriptions.push(cx.subscribe_in( + input, + window, + move |this: &mut Self, input, event: &InputEvent, window, cx| { + if matches!(event, InputEvent::PressEnter { .. } | InputEvent::Blur) { + let raw = input.read(cx).value(); + let (value, current) = if key == "ui_font_family" { + (raw.trim().to_owned(), &this.runtime.ui_font_family) + } else { + ( + crate::font_install::normalize_font_family_chain(&raw), + &this.runtime.font_family_cjk, + ) + }; + let changed = current.as_deref().unwrap_or_default() != value; + input.update(cx, |input, cx| input.set_value(value.clone(), window, cx)); + if changed { + this.persist(&[(key, value)], cx); + } } - } - }, - )); + }, + )); + } let bg_picker_hsv = { let term = crate::gpui_shell::theme::chrome_theme_resolved(cx).palette().term_bg; @@ -571,6 +585,7 @@ impl SettingsPane { font_imported: Vec::new(), font_family_input, font_family_cjk_input, + ui_font_family_input, font_picker_trigger_bounds: None, backup_selection: crate::encrypted_backup::BackupSelection::default(), backup_pass_input: cx.new(|cx| { diff --git a/nebula_app/src/gpui_shell/ssh_settings/library.rs b/nebula_app/src/gpui_shell/ssh_settings/library.rs index 65ea75c..a23a8c0 100644 --- a/nebula_app/src/gpui_shell/ssh_settings/library.rs +++ b/nebula_app/src/gpui_shell/ssh_settings/library.rs @@ -226,7 +226,7 @@ impl SettingsPane { let symbol_family: SharedString = crate::font_install::REQUIRED_FONT_FAMILY.into(); let font_px = cx .try_global::() - .map(|s| s.base_font_size_px) + .map(|s| s.ui_font_size_px) .unwrap_or(15.0); let title_h = font_px; let subtitle_h = font_px * 0.78; 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 bd1b60e..a1ba366 100644 --- a/nebula_app/src/gpui_shell/terminal/ssh_connect_overlay.rs +++ b/nebula_app/src/gpui_shell/terminal/ssh_connect_overlay.rs @@ -293,9 +293,9 @@ pub(super) fn overlay( // chrome 文本锚定配置字号(旧壳 ui_font 合同),不跟终端缩放。 let ui_px = cx .try_global::() - .map(|settings| settings.base_font_size_px) + .map(|settings| settings.ui_font_size_px) .unwrap_or(15.0); - let chrome_family = theme.mono_font_family.clone(); + let chrome_family = theme.font_family.clone(); let log_family: SharedString = crate::font_install::REQUIRED_FONT_FAMILY.into(); let ink_strong = theme.sidebar_accent_foreground; let ink = theme.foreground; diff --git a/nebula_app/src/gpui_shell/theme.rs b/nebula_app/src/gpui_shell/theme.rs index 4208b98..20f5baa 100644 --- a/nebula_app/src/gpui_shell/theme.rs +++ b/nebula_app/src/gpui_shell/theme.rs @@ -547,6 +547,7 @@ fn soften(c: crate::display::color::Rgb, keep: f32) -> crate::display::color::Rg } fn apply_skin_tokens(chrome: NebulaTheme, cx: &mut App) { + let runtime = nebula_settings::RuntimeSettings::load(); let sk = chrome.skin(); let transparent = hsla(0.0, 0.0, 0.0, 0.0); let theme = Theme::global_mut(cx); @@ -666,8 +667,8 @@ fn apply_skin_tokens(chrome: NebulaTheme, cx: &mut App) { // 字号与圆角:控件 pill 档 = 旧壳 UI_CORNER_RADIUS_LOGICAL(8);浮层 // 12,低于终端卡的 14——三档呼应旧壳的圆角层级。 - theme.font_size = px(14.0); - theme.mono_font_size = px(13.0); + theme.font_size = px(runtime.ui_font_size_px.unwrap_or(14.0)); + theme.mono_font_size = theme.font_size * (13.0 / 14.0); // 整壳的兜底字体(fork `root.rs` 用 `theme.font_family` 给根容器)。上游 // 默认 `.SystemUIFont` 在 Windows 上没落到 UI 字体,中文最终回落进终端等 @@ -685,6 +686,13 @@ fn apply_skin_tokens(chrome: NebulaTheme, cx: &mut App) { // 随终端主字体变化,进而破坏 chrome 的既定间距。 theme.mono_font_family = crate::font_install::REQUIRED_FONT_FAMILY.into(); } + theme.font_family = runtime + .ui_font_family + .unwrap_or_else(|| { + if cfg!(target_os = "windows") { "Microsoft YaHei UI" } else { ".SystemUIFont" } + .to_owned() + }) + .into(); theme.radius = px(crate::display::UI_CORNER_RADIUS_LOGICAL); theme.radius_lg = px(12.0); diff --git a/nebula_app/src/gpui_shell/workspace/pane_header.rs b/nebula_app/src/gpui_shell/workspace/pane_header.rs index 473c7fe..acf2402 100644 --- a/nebula_app/src/gpui_shell/workspace/pane_header.rs +++ b/nebula_app/src/gpui_shell/workspace/pane_header.rs @@ -258,9 +258,9 @@ impl NebulaWorkspace { let bar_bg = if focused { theme.sidebar_accent.opacity(0.55) } else { theme.muted.opacity(0.28) }; let settings = cx.try_global::(); - let chrome_family = theme.mono_font_family.clone(); + let chrome_family = theme.font_family.clone(); let symbol_family: SharedString = crate::font_install::REQUIRED_FONT_FAMILY.into(); - let label_px = settings.map(|settings| settings.base_font_size_px).unwrap_or(15.0); + let label_px = settings.map(|settings| settings.ui_font_size_px).unwrap_or(15.0); let title_px = label_px * 0.78; let PaneTitle { logo, glyph, text } = self.pane_title(view, cx, dark); let group: SharedString = format!("pane-header-{pane_id}").into(); diff --git a/nebula_app/src/gpui_shell/workspace/sidebar.rs b/nebula_app/src/gpui_shell/workspace/sidebar.rs index 4b114c7..07e14fb 100644 --- a/nebula_app/src/gpui_shell/workspace/sidebar.rs +++ b/nebula_app/src/gpui_shell/workspace/sidebar.rs @@ -240,13 +240,10 @@ impl NebulaWorkspace { let tab_reveal = settings .map(|settings| settings.tab_reveal) .unwrap_or(nebula_settings::TabRevealName::Slide); - let chrome_family = theme.mono_font_family.clone(); + let chrome_family = theme.font_family.clone(); let symbol_family: SharedString = crate::font_install::REQUIRED_FONT_FAMILY.into(); - // 旧壳合同(display/mod.rs `ui_font_px`):chrome 锚定**配置字号** - // (nebula.toml `font.size` 默认 11.25pt = 15px)。终端的持久化缩放 - // (`font_size=` 键)只影响终端网格,侧栏不得跟着变粗/变大; - // 固定 14px 的旧毛病(比旧壳小一号)也不能回潮。 - let label_px = settings.map(|settings| settings.base_font_size_px).unwrap_or(15.0); + // 界面字号独立于终端缩放。 + let label_px = settings.map(|settings| settings.ui_font_size_px).unwrap_or(15.0); let cell_w = self.sidebar_cell_width(window, &chrome_family, label_px); // 受约束拖拽的渲染参数:激活后被拖行骑指针位移,落点槽位由位移换算。 @@ -1041,9 +1038,9 @@ impl NebulaWorkspace { let (ink, dim, badge_fill) = (theme.foreground, theme.muted_foreground, theme.muted); let dark = theme.is_dark(); let settings = cx.try_global::(); - let chrome_family = theme.mono_font_family.clone(); + let chrome_family = theme.font_family.clone(); let symbol_family: SharedString = crate::font_install::REQUIRED_FONT_FAMILY.into(); - let label_px = settings.map(|settings| settings.base_font_size_px).unwrap_or(15.0); + let label_px = settings.map(|settings| settings.ui_font_size_px).unwrap_or(15.0); let TabPresentation { title, logo_image, program_glyph, pane_count, .. } = self.tab_presentation(self.active, cx, dark); slot.child( diff --git a/nebula_app/src/gpui_shell/workspace/top_tabs.rs b/nebula_app/src/gpui_shell/workspace/top_tabs.rs index f9968fc..704de7f 100644 --- a/nebula_app/src/gpui_shell/workspace/top_tabs.rs +++ b/nebula_app/src/gpui_shell/workspace/top_tabs.rs @@ -141,9 +141,9 @@ impl NebulaWorkspace { let tab_reveal = settings .map(|settings| settings.tab_reveal) .unwrap_or(nebula_settings::TabRevealName::Slide); - let chrome_family = theme.mono_font_family.clone(); + let chrome_family = theme.font_family.clone(); let symbol_family: SharedString = crate::font_install::REQUIRED_FONT_FAMILY.into(); - let label_px = settings.map(|settings| settings.base_font_size_px).unwrap_or(15.0); + let label_px = settings.map(|settings| settings.ui_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.top_tab_count()); diff --git a/nebula_settings/src/lib.rs b/nebula_settings/src/lib.rs index 692f61f..19ae7bf 100644 --- a/nebula_settings/src/lib.rs +++ b/nebula_settings/src/lib.rs @@ -942,6 +942,8 @@ pub struct RuntimeSettings { pub follow_system_theme: bool, pub font_family: Option, pub font_family_cjk: Option, + pub ui_font_family: Option, + pub ui_font_size_px: Option, /// **逻辑像素**(旧壳写盘语义:设置页 spinner 与 Ctrl+滚轮缩放持久化时 /// 已除以 scale factor)。`None` = 跟随 nebula.toml 的 `font.size`(pt)。 pub font_size_px: Option, @@ -1083,6 +1085,8 @@ impl RuntimeSettings { follow_system_theme: raw.bool_on("follow_system_theme").unwrap_or(false), font_family: raw.value("font_family").map(str::to_owned), font_family_cjk: raw.value("font_family_cjk").map(str::to_owned), + ui_font_family: raw.value("ui_font_family").map(str::to_owned), + ui_font_size_px: raw.f32("ui_font_size").map(|size| size.clamp(10.0, 24.0)), font_size_px: raw.f32("font_size").map(|size| size.clamp(4.0, 96.0)), cursor_shape: raw.value("cursor_shape").and_then(CursorShapeName::from_settings), cursor_blink: raw.bool_on("cursor_blink"), @@ -1307,6 +1311,8 @@ mod tests { completion_style=popup\n\ shell=pwsh\n\ font_family=Maple Mono Normal NF CN\n\ + ui_font_family=Arial\n\ + ui_font_size=18\n\ font_family_cjk=PingFang SC\n\ font_size=16.3\n\ cursor_shape=beam\n\ @@ -1343,6 +1349,8 @@ mod tests { assert_eq!(settings.theme, ThemeName::SilverLight); assert_eq!(settings.font_family.as_deref(), Some("Maple Mono Normal NF CN")); assert_eq!(settings.font_family_cjk.as_deref(), Some("PingFang SC")); + assert_eq!(settings.ui_font_family.as_deref(), Some("Arial")); + assert_eq!(settings.ui_font_size_px, Some(18.0)); // font_size 键存的是逻辑像素(旧壳写盘语义),不做 pt 换算。 assert_eq!(settings.font_size_px, Some(16.3)); assert_eq!(settings.cursor_shape, Some(CursorShapeName::Beam)); @@ -1386,6 +1394,8 @@ mod tests { assert_eq!(settings.theme, ThemeName::Nord); assert_eq!(settings.font_family, None); assert_eq!(settings.font_family_cjk, None); + assert_eq!(settings.ui_font_family, None); + assert_eq!(settings.ui_font_size_px, None); assert!(!settings.copy_on_select); assert!(settings.multiline_paste_confirm, "多行粘贴确认默认开(上游兼容)"); assert!(settings.tab_close_visible, "标签关闭按钮默认可见(上游兼容)"); diff --git a/nebula_settings/src/reset.rs b/nebula_settings/src/reset.rs index c54b470..cc71e31 100644 --- a/nebula_settings/src/reset.rs +++ b/nebula_settings/src/reset.rs @@ -11,6 +11,8 @@ const RESET_KEYS: &[&str] = &[ "follow_system_theme", "font_family", "font_family_cjk", + "ui_font_family", + "ui_font_size", "font_size", "cursor_shape", "cursor_blink", From db11967f5089d809a57411ad76c4f1346db4f6f1 Mon Sep 17 00:00:00 2001 From: GeekMr <140129782+WilliamWang1721@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:13:43 +0800 Subject: [PATCH 09/51] =?UTF-8?q?ci:=20=E6=89=A9=E5=B1=95=E5=9B=9B?= =?UTF-8?q?=E5=B9=B3=E5=8F=B0=E5=85=A8=E9=87=8F=E6=B5=8B=E8=AF=95=E5=85=A5?= =?UTF-8?q?=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/linux-lua.yml | 39 +++++++++++++++++++++++++-------- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/.github/workflows/linux-lua.yml b/.github/workflows/linux-lua.yml index 6a5b6b2..da8e1b7 100644 --- a/.github/workflows/linux-lua.yml +++ b/.github/workflows/linux-lua.yml @@ -1,6 +1,7 @@ -name: Linux Lua configuration +name: Full native tests on: + workflow_dispatch: push: paths: - "**/*.rs" @@ -20,15 +21,30 @@ permissions: contents: read jobs: - ubuntu-24-04: - runs-on: ubuntu-24.04 + native-tests: + name: Tests (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04, windows-2022, macos-15, macos-15-intel] + runs-on: ${{ matrix.os }} + timeout-minutes: 120 + env: + PYTHONUTF8: "1" + CARGO_TERM_COLOR: always steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" - name: Install native dependencies + if: runner.os == 'Linux' run: | sudo apt-get update sudo apt-get install -y --no-install-recommends \ - build-essential cmake pkg-config libasound2-dev libdbus-1-dev libudev-dev \ + build-essential cmake pkg-config zsh libasound2-dev libdbus-1-dev libudev-dev \ libfontconfig1-dev libfreetype6-dev \ libegl1-mesa-dev libgl1-mesa-dev \ libwayland-dev libxkbcommon-dev libxkbcommon-x11-dev \ @@ -38,8 +54,13 @@ jobs: with: toolchain: "1.97.1" - uses: Swatinem/rust-cache@v2 - - name: Test Lua configuration on Wayland and X11 build - run: | - cargo test --locked -p nebula config:: - - name: Check Linux release build - run: cargo check --workspace --release + - name: Test all Python helpers + run: python -m unittest discover -s scripts/tests -v + - name: Test all conformance harness helpers + run: python -m unittest discover -s scripts/conformance/tests -v + - name: Test the full Rust workspace + run: cargo test --locked --workspace + - name: Test all GPUI product and interaction cases + run: cargo test --locked -p nebula --bin pebrel --features gpui-test-support + - name: Check the release workspace + run: cargo check --locked --workspace --release From 879db0ea478da9f779f67557eeddebd0769db7a9 Mon Sep 17 00:00:00 2001 From: GeekMr <140129782+WilliamWang1721@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:19:09 +0800 Subject: [PATCH 10/51] =?UTF-8?q?ci:=20=E4=B8=BA=20PR=20#114=20=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=E9=92=88=E5=AF=B9=E6=80=A7=E5=8F=8A=E5=9B=9B=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0=E5=85=A8=E9=87=8F=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/linux-lua.yml | 53 +++++++++++++++++++++++++++------ 1 file changed, 44 insertions(+), 9 deletions(-) diff --git a/.github/workflows/linux-lua.yml b/.github/workflows/linux-lua.yml index 6a5b6b2..157eb75 100644 --- a/.github/workflows/linux-lua.yml +++ b/.github/workflows/linux-lua.yml @@ -1,6 +1,7 @@ -name: Linux Lua configuration +name: Full native tests on: + workflow_dispatch: push: paths: - "**/*.rs" @@ -20,26 +21,60 @@ permissions: contents: read jobs: - ubuntu-24-04: - runs-on: ubuntu-24.04 + native-tests: + name: Tests (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04, windows-2022, macos-26, macos-26-intel] + runs-on: ${{ matrix.os }} + timeout-minutes: 120 + env: + PYTHONUTF8: "1" + CARGO_TERM_COLOR: always steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" - name: Install native dependencies + if: runner.os == 'Linux' run: | sudo apt-get update sudo apt-get install -y --no-install-recommends \ - build-essential cmake pkg-config libasound2-dev libdbus-1-dev libudev-dev \ + build-essential cmake pkg-config zsh libasound2-dev libdbus-1-dev libudev-dev \ libfontconfig1-dev libfreetype6-dev \ libegl1-mesa-dev libgl1-mesa-dev \ libwayland-dev libxkbcommon-dev libxkbcommon-x11-dev \ libx11-xcb-dev libxcb1-dev libxcb-render0-dev \ libxcb-shape0-dev libxcb-xfixes0-dev libxcb-xinput-dev libxcb-xkb-dev libxi-dev + - name: Require the AppKit SDK used by this PR + if: runner.os == 'macOS' + run: | + sdk_version="$(xcrun --sdk macosx --show-sdk-version)" + test "${sdk_version%%.*}" -ge 26 - uses: dtolnay/rust-toolchain@stable with: toolchain: "1.97.1" - uses: Swatinem/rust-cache@v2 - - name: Test Lua configuration on Wayland and X11 build - run: | - cargo test --locked -p nebula config:: - - name: Check Linux release build - run: cargo check --workspace --release + - name: Test native titlebar alignment + if: runner.os == 'macOS' + run: >- + cargo test --locked -p nebula --bin pebrel --features gpui-test-support gpui_shell::workspace::window_titlebar::macos::tests:: + - name: Test all Python helpers + if: ${{ !cancelled() }} + run: python -m unittest discover -s scripts/tests -v + - name: Test all conformance harness helpers + if: ${{ !cancelled() }} + run: python -m unittest discover -s scripts/conformance/tests -v + - name: Test the full Rust workspace + if: ${{ !cancelled() }} + run: cargo test --locked --workspace + - name: Test all GPUI product and interaction cases + if: ${{ !cancelled() }} + run: cargo test --locked -p nebula --bin pebrel --features gpui-test-support + - name: Check the release workspace + if: ${{ !cancelled() }} + run: cargo check --locked --workspace --release From 27ca25b8ae9b5f6cd3230f8edea613e5c3c9680c Mon Sep 17 00:00:00 2001 From: GeekMr <140129782+WilliamWang1721@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:19:13 +0800 Subject: [PATCH 11/51] =?UTF-8?q?ci:=20=E4=B8=BA=20PR=20#115=20=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=E9=92=88=E5=AF=B9=E6=80=A7=E5=8F=8A=E5=9B=9B=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0=E5=85=A8=E9=87=8F=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/linux-lua.yml | 51 +++++++++++++++++++++++++++------ 1 file changed, 42 insertions(+), 9 deletions(-) diff --git a/.github/workflows/linux-lua.yml b/.github/workflows/linux-lua.yml index 6a5b6b2..1fc1346 100644 --- a/.github/workflows/linux-lua.yml +++ b/.github/workflows/linux-lua.yml @@ -1,6 +1,7 @@ -name: Linux Lua configuration +name: Full native tests on: + workflow_dispatch: push: paths: - "**/*.rs" @@ -20,15 +21,30 @@ permissions: contents: read jobs: - ubuntu-24-04: - runs-on: ubuntu-24.04 + native-tests: + name: Tests (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04, windows-2022, macos-15, macos-15-intel] + runs-on: ${{ matrix.os }} + timeout-minutes: 120 + env: + PYTHONUTF8: "1" + CARGO_TERM_COLOR: always steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" - name: Install native dependencies + if: runner.os == 'Linux' run: | sudo apt-get update sudo apt-get install -y --no-install-recommends \ - build-essential cmake pkg-config libasound2-dev libdbus-1-dev libudev-dev \ + build-essential cmake pkg-config zsh libasound2-dev libdbus-1-dev libudev-dev \ libfontconfig1-dev libfreetype6-dev \ libegl1-mesa-dev libgl1-mesa-dev \ libwayland-dev libxkbcommon-dev libxkbcommon-x11-dev \ @@ -38,8 +54,25 @@ jobs: with: toolchain: "1.97.1" - uses: Swatinem/rust-cache@v2 - - name: Test Lua configuration on Wayland and X11 build - run: | - cargo test --locked -p nebula config:: - - name: Check Linux release build - run: cargo check --workspace --release + - name: Test confirmation mouse dispatch and layout + run: >- + cargo test --locked -p nebula --bin pebrel --features gpui-test-support gpui_shell::prelude::tests::confirm_dialog + - name: Test reset backups and user data + if: ${{ !cancelled() }} + run: >- + cargo test --locked -p nebula-settings reset::tests:: + - name: Test all Python helpers + if: ${{ !cancelled() }} + run: python -m unittest discover -s scripts/tests -v + - name: Test all conformance harness helpers + if: ${{ !cancelled() }} + run: python -m unittest discover -s scripts/conformance/tests -v + - name: Test the full Rust workspace + if: ${{ !cancelled() }} + run: cargo test --locked --workspace + - name: Test all GPUI product and interaction cases + if: ${{ !cancelled() }} + run: cargo test --locked -p nebula --bin pebrel --features gpui-test-support + - name: Check the release workspace + if: ${{ !cancelled() }} + run: cargo check --locked --workspace --release From 12aa7624d05680081ecb6b9f970b0aec2f8a5917 Mon Sep 17 00:00:00 2001 From: GeekMr <140129782+WilliamWang1721@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:19:20 +0800 Subject: [PATCH 12/51] =?UTF-8?q?ci:=20=E4=B8=BA=20PR=20#117=20=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=E9=92=88=E5=AF=B9=E6=80=A7=E5=8F=8A=E5=9B=9B=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0=E5=85=A8=E9=87=8F=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/linux-lua.yml | 51 +++++++++++++++++++++++++++------ 1 file changed, 42 insertions(+), 9 deletions(-) diff --git a/.github/workflows/linux-lua.yml b/.github/workflows/linux-lua.yml index 6a5b6b2..a7b3b4a 100644 --- a/.github/workflows/linux-lua.yml +++ b/.github/workflows/linux-lua.yml @@ -1,6 +1,7 @@ -name: Linux Lua configuration +name: Full native tests on: + workflow_dispatch: push: paths: - "**/*.rs" @@ -20,15 +21,30 @@ permissions: contents: read jobs: - ubuntu-24-04: - runs-on: ubuntu-24.04 + native-tests: + name: Tests (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04, windows-2022, macos-15, macos-15-intel] + runs-on: ${{ matrix.os }} + timeout-minutes: 120 + env: + PYTHONUTF8: "1" + CARGO_TERM_COLOR: always steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" - name: Install native dependencies + if: runner.os == 'Linux' run: | sudo apt-get update sudo apt-get install -y --no-install-recommends \ - build-essential cmake pkg-config libasound2-dev libdbus-1-dev libudev-dev \ + build-essential cmake pkg-config zsh libasound2-dev libdbus-1-dev libudev-dev \ libfontconfig1-dev libfreetype6-dev \ libegl1-mesa-dev libgl1-mesa-dev \ libwayland-dev libxkbcommon-dev libxkbcommon-x11-dev \ @@ -38,8 +54,25 @@ jobs: with: toolchain: "1.97.1" - uses: Swatinem/rust-cache@v2 - - name: Test Lua configuration on Wayland and X11 build - run: | - cargo test --locked -p nebula config:: - - name: Check Linux release build - run: cargo check --workspace --release + - name: Test silent startup requirements + run: >- + cargo test --locked -p nebula --bin pebrel --features gpui-test-support platform::startup::tests:: + - name: Test startup parsing and reset coverage + if: ${{ !cancelled() }} + run: >- + cargo test --locked -p nebula-settings + - name: Test all Python helpers + if: ${{ !cancelled() }} + run: python -m unittest discover -s scripts/tests -v + - name: Test all conformance harness helpers + if: ${{ !cancelled() }} + run: python -m unittest discover -s scripts/conformance/tests -v + - name: Test the full Rust workspace + if: ${{ !cancelled() }} + run: cargo test --locked --workspace + - name: Test all GPUI product and interaction cases + if: ${{ !cancelled() }} + run: cargo test --locked -p nebula --bin pebrel --features gpui-test-support + - name: Check the release workspace + if: ${{ !cancelled() }} + run: cargo check --locked --workspace --release From e842cf20c1967dfd3fbc7436c8f69bb0321771a1 Mon Sep 17 00:00:00 2001 From: GeekMr <140129782+WilliamWang1721@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:21:19 +0800 Subject: [PATCH 13/51] =?UTF-8?q?ci:=20=E5=B0=86=E6=9E=B6=E6=9E=84?= =?UTF-8?q?=E4=B8=8E=E7=BF=BB=E8=AF=91=E5=90=88=E5=90=8C=E5=B9=B6=E5=85=A5?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/linux-lua.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/linux-lua.yml b/.github/workflows/linux-lua.yml index 157eb75..b8066f8 100644 --- a/.github/workflows/linux-lua.yml +++ b/.github/workflows/linux-lua.yml @@ -75,6 +75,12 @@ jobs: - name: Test all GPUI product and interaction cases if: ${{ !cancelled() }} run: cargo test --locked -p nebula --bin pebrel --features gpui-test-support + - name: Check source architecture + if: ${{ !cancelled() && runner.os == 'Linux' }} + run: python scripts/check_architecture.py + - name: Test translation and allocation contracts + if: ${{ !cancelled() && runner.os == 'Linux' }} + run: cargo test --locked --manifest-path tools/i18n-contract/Cargo.toml - name: Check the release workspace if: ${{ !cancelled() }} run: cargo check --locked --workspace --release From 6ee3edd4a172800630b3bbc5ff884deca60866a5 Mon Sep 17 00:00:00 2001 From: GeekMr <140129782+WilliamWang1721@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:21:22 +0800 Subject: [PATCH 14/51] =?UTF-8?q?ci:=20=E5=B0=86=E6=9E=B6=E6=9E=84?= =?UTF-8?q?=E4=B8=8E=E7=BF=BB=E8=AF=91=E5=90=88=E5=90=8C=E5=B9=B6=E5=85=A5?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/linux-lua.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/linux-lua.yml b/.github/workflows/linux-lua.yml index 1fc1346..d2b8e80 100644 --- a/.github/workflows/linux-lua.yml +++ b/.github/workflows/linux-lua.yml @@ -73,6 +73,12 @@ jobs: - name: Test all GPUI product and interaction cases if: ${{ !cancelled() }} run: cargo test --locked -p nebula --bin pebrel --features gpui-test-support + - name: Check source architecture + if: ${{ !cancelled() && runner.os == 'Linux' }} + run: python scripts/check_architecture.py + - name: Test translation and allocation contracts + if: ${{ !cancelled() && runner.os == 'Linux' }} + run: cargo test --locked --manifest-path tools/i18n-contract/Cargo.toml - name: Check the release workspace if: ${{ !cancelled() }} run: cargo check --locked --workspace --release From 09be466e9e16fce18b2c39e0fe3eea54211795d4 Mon Sep 17 00:00:00 2001 From: GeekMr <140129782+WilliamWang1721@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:50:14 +0800 Subject: [PATCH 15/51] =?UTF-8?q?fix:=20=E6=81=A2=E5=A4=8D=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E8=AE=BE=E7=BD=AE=E6=97=B6=E6=B8=85=E9=99=A4=E9=9D=99?= =?UTF-8?q?=E9=BB=98=E5=90=AF=E5=8A=A8=E9=80=89=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- nebula_settings/src/reset.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/nebula_settings/src/reset.rs b/nebula_settings/src/reset.rs index 2e8c87c..6562819 100644 --- a/nebula_settings/src/reset.rs +++ b/nebula_settings/src/reset.rs @@ -40,6 +40,7 @@ const RESET_KEYS: &[&str] = &[ "restore_session", "resume_ai", "tray", + "silent_start", "blur", "opacity", "background", From 28c071185b21cb62e9f97b70d0a8ed00fc1fa669 Mon Sep 17 00:00:00 2001 From: GeekMr <140129782+WilliamWang1721@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:50:14 +0800 Subject: [PATCH 16/51] =?UTF-8?q?test:=20=E4=BF=AE=E5=A4=8D=E8=B7=A8?= =?UTF-8?q?=E5=B9=B3=E5=8F=B0=E4=B8=B4=E6=97=B6=E8=B7=AF=E5=BE=84=E6=AF=94?= =?UTF-8?q?=E8=BE=83=E5=B9=B6=E8=A7=A6=E5=8F=91=20Python=20=E6=94=B9?= =?UTF-8?q?=E5=8A=A8=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/linux-lua.yml | 2 ++ scripts/conformance/tests/test_harness.py | 2 +- scripts/tests/test_architecture_budgets.py | 2 +- scripts/tests/test_xplat.py | 2 +- 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/linux-lua.yml b/.github/workflows/linux-lua.yml index da8e1b7..f64ffce 100644 --- a/.github/workflows/linux-lua.yml +++ b/.github/workflows/linux-lua.yml @@ -5,6 +5,7 @@ on: push: paths: - "**/*.rs" + - "scripts/**/*.py" - "**/Cargo.toml" - "Cargo.lock" - "rust-toolchain.toml" @@ -12,6 +13,7 @@ on: pull_request: paths: - "**/*.rs" + - "scripts/**/*.py" - "**/Cargo.toml" - "Cargo.lock" - "rust-toolchain.toml" diff --git a/scripts/conformance/tests/test_harness.py b/scripts/conformance/tests/test_harness.py index 950c0f0..e1b97e5 100644 --- a/scripts/conformance/tests/test_harness.py +++ b/scripts/conformance/tests/test_harness.py @@ -139,7 +139,7 @@ class ArchiveSafetyTests(unittest.TestCase): def test_macos_bundle_resolves_declared_executable_and_rejects_other_paths(self) -> None: with tempfile.TemporaryDirectory() as directory: - root = Path(directory) / "Pebrel Preview.app" + root = Path(directory).resolve() / "Pebrel Preview.app" binary = root / "Contents" / "MacOS" / "pebrel" binary.parent.mkdir(parents=True) binary.write_bytes(b"fixture") diff --git a/scripts/tests/test_architecture_budgets.py b/scripts/tests/test_architecture_budgets.py index bbfa0f6..91cd27a 100644 --- a/scripts/tests/test_architecture_budgets.py +++ b/scripts/tests/test_architecture_budgets.py @@ -13,7 +13,7 @@ class LineBudgetTests(unittest.TestCase): def setUp(self): self.temporary = tempfile.TemporaryDirectory() self.addCleanup(self.temporary.cleanup) - self.root = Path(self.temporary.name) + self.root = Path(self.temporary.name).resolve() (self.root / "app/src").mkdir(parents=True) def write(self, name, count): diff --git a/scripts/tests/test_xplat.py b/scripts/tests/test_xplat.py index 55720a1..cc238ad 100644 --- a/scripts/tests/test_xplat.py +++ b/scripts/tests/test_xplat.py @@ -18,7 +18,7 @@ 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.root = Path(self.temp.name).resolve() self.repo = self.root / "repo with spaces" self.scripts = self.repo / "scripts/xplat" shutil.copytree(ROOT / "scripts/xplat", self.scripts) From 5a8dade644b6d5630abb640d54d24c2cc9d03b41 Mon Sep 17 00:00:00 2001 From: GeekMr <140129782+WilliamWang1721@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:50:14 +0800 Subject: [PATCH 17/51] =?UTF-8?q?test:=20=E4=BF=AE=E5=A4=8D=E8=B7=A8?= =?UTF-8?q?=E5=B9=B3=E5=8F=B0=E4=B8=B4=E6=97=B6=E8=B7=AF=E5=BE=84=E6=AF=94?= =?UTF-8?q?=E8=BE=83=E5=B9=B6=E8=A7=A6=E5=8F=91=20Python=20=E6=94=B9?= =?UTF-8?q?=E5=8A=A8=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/linux-lua.yml | 2 ++ scripts/conformance/tests/test_harness.py | 2 +- scripts/tests/test_architecture_budgets.py | 2 +- scripts/tests/test_xplat.py | 2 +- 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/linux-lua.yml b/.github/workflows/linux-lua.yml index d2b8e80..83c7797 100644 --- a/.github/workflows/linux-lua.yml +++ b/.github/workflows/linux-lua.yml @@ -5,6 +5,7 @@ on: push: paths: - "**/*.rs" + - "scripts/**/*.py" - "**/Cargo.toml" - "Cargo.lock" - "rust-toolchain.toml" @@ -12,6 +13,7 @@ on: pull_request: paths: - "**/*.rs" + - "scripts/**/*.py" - "**/Cargo.toml" - "Cargo.lock" - "rust-toolchain.toml" diff --git a/scripts/conformance/tests/test_harness.py b/scripts/conformance/tests/test_harness.py index 950c0f0..e1b97e5 100644 --- a/scripts/conformance/tests/test_harness.py +++ b/scripts/conformance/tests/test_harness.py @@ -139,7 +139,7 @@ class ArchiveSafetyTests(unittest.TestCase): def test_macos_bundle_resolves_declared_executable_and_rejects_other_paths(self) -> None: with tempfile.TemporaryDirectory() as directory: - root = Path(directory) / "Pebrel Preview.app" + root = Path(directory).resolve() / "Pebrel Preview.app" binary = root / "Contents" / "MacOS" / "pebrel" binary.parent.mkdir(parents=True) binary.write_bytes(b"fixture") diff --git a/scripts/tests/test_architecture_budgets.py b/scripts/tests/test_architecture_budgets.py index bbfa0f6..91cd27a 100644 --- a/scripts/tests/test_architecture_budgets.py +++ b/scripts/tests/test_architecture_budgets.py @@ -13,7 +13,7 @@ class LineBudgetTests(unittest.TestCase): def setUp(self): self.temporary = tempfile.TemporaryDirectory() self.addCleanup(self.temporary.cleanup) - self.root = Path(self.temporary.name) + self.root = Path(self.temporary.name).resolve() (self.root / "app/src").mkdir(parents=True) def write(self, name, count): diff --git a/scripts/tests/test_xplat.py b/scripts/tests/test_xplat.py index 55720a1..cc238ad 100644 --- a/scripts/tests/test_xplat.py +++ b/scripts/tests/test_xplat.py @@ -18,7 +18,7 @@ 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.root = Path(self.temp.name).resolve() self.repo = self.root / "repo with spaces" self.scripts = self.repo / "scripts/xplat" shutil.copytree(ROOT / "scripts/xplat", self.scripts) From 7a26f9c380aefb0e648cfd457c904b99349158bf Mon Sep 17 00:00:00 2001 From: GeekMr <140129782+WilliamWang1721@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:50:14 +0800 Subject: [PATCH 18/51] =?UTF-8?q?test:=20=E4=BF=AE=E5=A4=8D=E8=B7=A8?= =?UTF-8?q?=E5=B9=B3=E5=8F=B0=E4=B8=B4=E6=97=B6=E8=B7=AF=E5=BE=84=E6=AF=94?= =?UTF-8?q?=E8=BE=83=E5=B9=B6=E8=A7=A6=E5=8F=91=20Python=20=E6=94=B9?= =?UTF-8?q?=E5=8A=A8=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/linux-lua.yml | 2 ++ scripts/conformance/tests/test_harness.py | 2 +- scripts/tests/test_architecture_budgets.py | 2 +- scripts/tests/test_xplat.py | 2 +- 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/linux-lua.yml b/.github/workflows/linux-lua.yml index b8066f8..723b568 100644 --- a/.github/workflows/linux-lua.yml +++ b/.github/workflows/linux-lua.yml @@ -5,6 +5,7 @@ on: push: paths: - "**/*.rs" + - "scripts/**/*.py" - "**/Cargo.toml" - "Cargo.lock" - "rust-toolchain.toml" @@ -12,6 +13,7 @@ on: pull_request: paths: - "**/*.rs" + - "scripts/**/*.py" - "**/Cargo.toml" - "Cargo.lock" - "rust-toolchain.toml" diff --git a/scripts/conformance/tests/test_harness.py b/scripts/conformance/tests/test_harness.py index 950c0f0..e1b97e5 100644 --- a/scripts/conformance/tests/test_harness.py +++ b/scripts/conformance/tests/test_harness.py @@ -139,7 +139,7 @@ class ArchiveSafetyTests(unittest.TestCase): def test_macos_bundle_resolves_declared_executable_and_rejects_other_paths(self) -> None: with tempfile.TemporaryDirectory() as directory: - root = Path(directory) / "Pebrel Preview.app" + root = Path(directory).resolve() / "Pebrel Preview.app" binary = root / "Contents" / "MacOS" / "pebrel" binary.parent.mkdir(parents=True) binary.write_bytes(b"fixture") diff --git a/scripts/tests/test_architecture_budgets.py b/scripts/tests/test_architecture_budgets.py index bbfa0f6..91cd27a 100644 --- a/scripts/tests/test_architecture_budgets.py +++ b/scripts/tests/test_architecture_budgets.py @@ -13,7 +13,7 @@ class LineBudgetTests(unittest.TestCase): def setUp(self): self.temporary = tempfile.TemporaryDirectory() self.addCleanup(self.temporary.cleanup) - self.root = Path(self.temporary.name) + self.root = Path(self.temporary.name).resolve() (self.root / "app/src").mkdir(parents=True) def write(self, name, count): diff --git a/scripts/tests/test_xplat.py b/scripts/tests/test_xplat.py index 55720a1..cc238ad 100644 --- a/scripts/tests/test_xplat.py +++ b/scripts/tests/test_xplat.py @@ -18,7 +18,7 @@ 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.root = Path(self.temp.name).resolve() self.repo = self.root / "repo with spaces" self.scripts = self.repo / "scripts/xplat" shutil.copytree(ROOT / "scripts/xplat", self.scripts) From 229575000896816a00f7e668a83260da5a55be48 Mon Sep 17 00:00:00 2001 From: GeekMr <140129782+WilliamWang1721@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:50:14 +0800 Subject: [PATCH 19/51] =?UTF-8?q?test:=20=E4=BF=AE=E5=A4=8D=E8=B7=A8?= =?UTF-8?q?=E5=B9=B3=E5=8F=B0=E4=B8=B4=E6=97=B6=E8=B7=AF=E5=BE=84=E6=AF=94?= =?UTF-8?q?=E8=BE=83=E5=B9=B6=E8=A7=A6=E5=8F=91=20Python=20=E6=94=B9?= =?UTF-8?q?=E5=8A=A8=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/linux-lua.yml | 2 ++ scripts/conformance/tests/test_harness.py | 2 +- scripts/tests/test_architecture_budgets.py | 2 +- scripts/tests/test_xplat.py | 2 +- 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/linux-lua.yml b/.github/workflows/linux-lua.yml index a7b3b4a..82e207f 100644 --- a/.github/workflows/linux-lua.yml +++ b/.github/workflows/linux-lua.yml @@ -5,6 +5,7 @@ on: push: paths: - "**/*.rs" + - "scripts/**/*.py" - "**/Cargo.toml" - "Cargo.lock" - "rust-toolchain.toml" @@ -12,6 +13,7 @@ on: pull_request: paths: - "**/*.rs" + - "scripts/**/*.py" - "**/Cargo.toml" - "Cargo.lock" - "rust-toolchain.toml" diff --git a/scripts/conformance/tests/test_harness.py b/scripts/conformance/tests/test_harness.py index 950c0f0..e1b97e5 100644 --- a/scripts/conformance/tests/test_harness.py +++ b/scripts/conformance/tests/test_harness.py @@ -139,7 +139,7 @@ class ArchiveSafetyTests(unittest.TestCase): def test_macos_bundle_resolves_declared_executable_and_rejects_other_paths(self) -> None: with tempfile.TemporaryDirectory() as directory: - root = Path(directory) / "Pebrel Preview.app" + root = Path(directory).resolve() / "Pebrel Preview.app" binary = root / "Contents" / "MacOS" / "pebrel" binary.parent.mkdir(parents=True) binary.write_bytes(b"fixture") diff --git a/scripts/tests/test_architecture_budgets.py b/scripts/tests/test_architecture_budgets.py index bbfa0f6..91cd27a 100644 --- a/scripts/tests/test_architecture_budgets.py +++ b/scripts/tests/test_architecture_budgets.py @@ -13,7 +13,7 @@ class LineBudgetTests(unittest.TestCase): def setUp(self): self.temporary = tempfile.TemporaryDirectory() self.addCleanup(self.temporary.cleanup) - self.root = Path(self.temporary.name) + self.root = Path(self.temporary.name).resolve() (self.root / "app/src").mkdir(parents=True) def write(self, name, count): diff --git a/scripts/tests/test_xplat.py b/scripts/tests/test_xplat.py index 55720a1..cc238ad 100644 --- a/scripts/tests/test_xplat.py +++ b/scripts/tests/test_xplat.py @@ -18,7 +18,7 @@ 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.root = Path(self.temp.name).resolve() self.repo = self.root / "repo with spaces" self.scripts = self.repo / "scripts/xplat" shutil.copytree(ROOT / "scripts/xplat", self.scripts) From bd415d740ca892a4c9988ec3771831b20b043a08 Mon Sep 17 00:00:00 2001 From: kuddev Date: Sun, 13 Sep 2026 19:44:26 +0800 Subject: [PATCH 20/51] style(settings): format reset confirmation --- nebula_app/src/gpui_shell/settings_pane/reset.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/nebula_app/src/gpui_shell/settings_pane/reset.rs b/nebula_app/src/gpui_shell/settings_pane/reset.rs index fd0d7ed..eaa8c07 100644 --- a/nebula_app/src/gpui_shell/settings_pane/reset.rs +++ b/nebula_app/src/gpui_shell/settings_pane/reset.rs @@ -1,7 +1,11 @@ use super::*; impl SettingsPane { - pub(super) fn confirm_reset_all_settings(&mut self, window: &mut Window, cx: &mut Context) { + pub(super) fn confirm_reset_all_settings( + &mut self, + window: &mut Window, + cx: &mut Context, + ) { use crate::i18n::Message; let language = crate::gpui_shell::config::ui_language(cx); From 7d48b6a4fdffb2746d608216696e75cdd77cc94c Mon Sep 17 00:00:00 2001 From: kuddev Date: Sun, 13 Sep 2026 20:06:03 +0800 Subject: [PATCH 21/51] style(settings): format independent font controls --- .../gpui_shell/settings_pane/appearance.rs | 11 ++++-- .../settings_pane/appearance_advanced.rs | 36 ++++++++++--------- .../settings_pane/initialization.rs | 7 ++-- 3 files changed, 31 insertions(+), 23 deletions(-) diff --git a/nebula_app/src/gpui_shell/settings_pane/appearance.rs b/nebula_app/src/gpui_shell/settings_pane/appearance.rs index cc13a4e..7337207 100644 --- a/nebula_app/src/gpui_shell/settings_pane/appearance.rs +++ b/nebula_app/src/gpui_shell/settings_pane/appearance.rs @@ -91,7 +91,8 @@ impl SettingsPane { pub(super) fn font_size_row(&self, ui: bool, cx: &Context) -> gpui::AnyElement { let language = crate::gpui_shell::config::ui_language(cx); let size = if ui { self.font_size_px(cx) } else { self.terminal_font_size_px(cx) }; - let (key, min, max) = if ui { ("ui_font_size", 10.0, 24.0) } else { ("font_size", 4.0, 96.0) }; + let (key, min, max) = + if ui { ("ui_font_size", 10.0, 24.0) } else { ("font_size", 4.0, 96.0) }; let stepper = h_flex() .w(px(142.0)) .h(px(36.0)) @@ -122,10 +123,14 @@ impl SettingsPane { })), ); self.row( - if ui { language.text(crate::i18n::Message::SettingsFontUiSize) } else { + if ui { + language.text(crate::i18n::Message::SettingsFontUiSize) + } else { language.pick("终端字号(Ctrl+滚轮缩放)", "Terminal font size (Ctrl+wheel)") }, - if ui { language.text(crate::i18n::Message::SettingsFontUiSizeDescription) } else { + if ui { + language.text(crate::i18n::Message::SettingsFontUiSizeDescription) + } else { language.pick("只调整终端文字大小。", "Changes only the terminal text size.") }, stepper, 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 feaa88e..db2748a 100644 --- a/nebula_app/src/gpui_shell/settings_pane/appearance_advanced.rs +++ b/nebula_app/src/gpui_shell/settings_pane/appearance_advanced.rs @@ -61,14 +61,16 @@ impl SettingsPane { )); let interface = self .group(language.pick("界面", "Interface"), cx) - .child(self.row( - language.text(crate::i18n::Message::SettingsFontUiFamily), - language.text(crate::i18n::Message::SettingsFontUiFamilyDescription), - Input::new(&self.ui_font_family_input) - .w(px(SETTINGS_SELECT_WIDTH)) - .aria_label(language.text(crate::i18n::Message::SettingsFontUiFamily)), - cx, - )) + .child( + self.row( + language.text(crate::i18n::Message::SettingsFontUiFamily), + language.text(crate::i18n::Message::SettingsFontUiFamilyDescription), + Input::new(&self.ui_font_family_input) + .w(px(SETTINGS_SELECT_WIDTH)) + .aria_label(language.text(crate::i18n::Message::SettingsFontUiFamily)), + cx, + ), + ) .child(self.font_size_row(true, cx)) .child(self.switch_row( "tab_close_visible", @@ -110,14 +112,16 @@ impl SettingsPane { font_picker, cx, )) - .child(self.row( - language.text(crate::i18n::Message::SettingsFontChinese), - language.text(crate::i18n::Message::SettingsFontChineseDescription), - Input::new(&self.font_family_cjk_input) - .w(px(SETTINGS_SELECT_WIDTH)) - .aria_label(language.text(crate::i18n::Message::SettingsFontChinese)), - cx, - )) + .child( + self.row( + language.text(crate::i18n::Message::SettingsFontChinese), + language.text(crate::i18n::Message::SettingsFontChineseDescription), + Input::new(&self.font_family_cjk_input) + .w(px(SETTINGS_SELECT_WIDTH)) + .aria_label(language.text(crate::i18n::Message::SettingsFontChinese)), + cx, + ), + ) .child(self.font_size_row(false, cx)) .child(self.select_row( "cell_width_mode", diff --git a/nebula_app/src/gpui_shell/settings_pane/initialization.rs b/nebula_app/src/gpui_shell/settings_pane/initialization.rs index 5bdb36e..6e4a33c 100644 --- a/nebula_app/src/gpui_shell/settings_pane/initialization.rs +++ b/nebula_app/src/gpui_shell/settings_pane/initialization.rs @@ -447,10 +447,9 @@ impl SettingsPane { InputState::new(window, cx) .default_value(runtime.ui_font_family.clone().unwrap_or_default()) }); - for (key, input) in [ - ("font_family_cjk", &font_family_cjk_input), - ("ui_font_family", &ui_font_family_input), - ] { + for (key, input) in + [("font_family_cjk", &font_family_cjk_input), ("ui_font_family", &ui_font_family_input)] + { subscriptions.push(cx.subscribe_in( input, window, From 78927d3325ee25fea9c59e72bdf95d9629614867 Mon Sep 17 00:00:00 2001 From: kuddev Date: Sun, 13 Sep 2026 20:33:42 +0800 Subject: [PATCH 22/51] refactor(macos): keep native window chrome behind the platform boundary Preserve native geometry coverage and publish lifecycle fixture PID files atomically so the newly enabled native CI cannot read an empty PID. --- .github/workflows/linux-lua.yml | 2 +- .../gpui_shell/workspace/window_titlebar.rs | 12 +++--- .../src/gpui_shell/workspace/windowing.rs | 19 ++------ nebula_app/src/platform/mod.rs | 2 + nebula_app/src/platform/window_chrome.rs | 43 +++++++++++++++++++ .../window_chrome}/macos.rs | 2 +- .../conformance/tests/test_posix_lifecycle.py | 6 ++- 7 files changed, 61 insertions(+), 25 deletions(-) create mode 100644 nebula_app/src/platform/window_chrome.rs rename nebula_app/src/{gpui_shell/workspace/window_titlebar => platform/window_chrome}/macos.rs (98%) diff --git a/.github/workflows/linux-lua.yml b/.github/workflows/linux-lua.yml index 723b568..6c7ac89 100644 --- a/.github/workflows/linux-lua.yml +++ b/.github/workflows/linux-lua.yml @@ -64,7 +64,7 @@ jobs: - name: Test native titlebar alignment if: runner.os == 'macOS' run: >- - cargo test --locked -p nebula --bin pebrel --features gpui-test-support gpui_shell::workspace::window_titlebar::macos::tests:: + cargo test --locked -p nebula --bin pebrel --features gpui-test-support platform::window_chrome::macos::tests:: - name: Test all Python helpers if: ${{ !cancelled() }} run: python -m unittest discover -s scripts/tests -v diff --git a/nebula_app/src/gpui_shell/workspace/window_titlebar.rs b/nebula_app/src/gpui_shell/workspace/window_titlebar.rs index 56c9377..aa6c875 100644 --- a/nebula_app/src/gpui_shell/workspace/window_titlebar.rs +++ b/nebula_app/src/gpui_shell/workspace/window_titlebar.rs @@ -1,8 +1,5 @@ use super::*; -#[cfg(target_os = "macos")] -pub(super) mod macos; - /// Prepaint records the actual pane column before any titlebar paint runs. Keep /// this cell for the workspace lifetime, so resizing and panel animations do not /// allocate a new shared slot or duplicate the body's layout calculations. @@ -90,6 +87,7 @@ impl NebulaWorkspace { cx: &mut Context, ) -> gpui::Div { let top_tabs = self.tabs_position == nebula_settings::TabsPositionName::Top; + let native_layout = crate::platform::window_chrome::layout(window); let bar = TitleBar::new() // Leave 8px above and below the existing 32px controls. .h(px(48.0)) @@ -97,7 +95,7 @@ impl NebulaWorkspace { .when(settings_active, |bar| { bar.border_b_1().border_color(crate::gpui_shell::theme::settings_hairline(cx)) }) - .when(top_tabs && !cfg!(target_os = "macos"), |bar| { + .when(top_tabs && native_layout.is_none(), |bar| { bar.pl(px(top_tabs::TOP_TAB_LEFT_INSET)) }) .when(top_tabs, |bar| { @@ -120,10 +118,10 @@ impl NebulaWorkspace { // AppKit owns both the control group and its geometry. Read the live // frames so system layout, resize and full-screen transitions agree. - #[cfg(target_os = "macos")] - let bar = { - let (height, inset) = macos::layout(window); + let bar = if let Some((height, inset)) = native_layout { bar.h(px(height)).pl(px(inset)) + } else { + bar }; div() diff --git a/nebula_app/src/gpui_shell/workspace/windowing.rs b/nebula_app/src/gpui_shell/workspace/windowing.rs index 657159d..29265b0 100644 --- a/nebula_app/src/gpui_shell/workspace/windowing.rs +++ b/nebula_app/src/gpui_shell/workspace/windowing.rs @@ -310,25 +310,15 @@ fn workspace_window_options(cx: &mut App, focus: bool, role: WindowRole) -> Wind ) }, ); - WindowOptions { + crate::platform::window_chrome::configure_options(WindowOptions { window_bounds: Some(WindowBounds::Windowed(bounds)), window_min_size: Some(size(px(760.0), px(540.0)).min(&bounds.size)), - titlebar: Some(gpui::TitlebarOptions { - // A manual traffic-light position makes GPUI rewrite AppKit's - // titlebar container, breaking the system toolbar layout. - traffic_light_position: if cfg!(target_os = "macos") { - None - } else { - TitleBar::title_bar_options().traffic_light_position - }, - ..TitleBar::title_bar_options() - }), - app_owns_titlebar_drag: cfg!(target_os = "macos"), + titlebar: Some(TitleBar::title_bar_options()), app_id: Some("pebrel".to_owned()), window_background: crate::gpui_shell::wallpaper::initial_background_appearance(), focus, ..Default::default() - } + }) }, #[cfg(windows)] WindowRole::QuickTerminal => { @@ -392,8 +382,7 @@ fn open_workspace_window( let hwnd_out = hwnd_slot.clone(); let handle = cx.open_window(options, move |window, cx| { window.set_window_title(crate::brand::NAME); - #[cfg(target_os = "macos")] - super::window_titlebar::macos::configure(window); + crate::platform::window_chrome::configure(window); *hwnd_out.borrow_mut() = native_hwnd(window).unwrap_or_default(); #[cfg(windows)] crate::gpui_shell::set_native_window_icon(window); diff --git a/nebula_app/src/platform/mod.rs b/nebula_app/src/platform/mod.rs index a49c7db..868374f 100644 --- a/nebula_app/src/platform/mod.rs +++ b/nebula_app/src/platform/mod.rs @@ -27,6 +27,8 @@ pub mod notifications; pub mod shell; pub mod shell_integration; pub mod startup; +#[cfg(feature = "gpui-shell")] +pub(crate) mod window_chrome; pub use capabilities::CAPABILITIES; diff --git a/nebula_app/src/platform/window_chrome.rs b/nebula_app/src/platform/window_chrome.rs new file mode 100644 index 0000000..47424a0 --- /dev/null +++ b/nebula_app/src/platform/window_chrome.rs @@ -0,0 +1,43 @@ +//! Native window decoration belongs to the platform boundary. Workspace code +//! supplies ordinary GPUI options and uses optional geometry without depending +//! on AppKit types or duplicating platform detection. + +use gpui::{Window, WindowOptions}; + +#[cfg(target_os = "macos")] +mod macos; + +pub(crate) fn configure_options(options: WindowOptions) -> WindowOptions { + #[cfg(target_os = "macos")] + { + let mut options = options; + if let Some(titlebar) = options.titlebar.as_mut() { + // A manual position makes GPUI rewrite AppKit's titlebar container, + // breaking the native toolbar layout. + titlebar.traffic_light_position = None; + } + options.app_owns_titlebar_drag = true; + options + } + #[cfg(not(target_os = "macos"))] + { + options + } +} + +pub(crate) fn configure(_window: &Window) { + #[cfg(target_os = "macos")] + macos::configure(_window); +} + +/// Height and left inset when native controls determine the titlebar geometry. +pub(crate) fn layout(_window: &Window) -> Option<(f32, f32)> { + #[cfg(target_os = "macos")] + { + Some(macos::layout(_window)) + } + #[cfg(not(target_os = "macos"))] + { + None + } +} diff --git a/nebula_app/src/gpui_shell/workspace/window_titlebar/macos.rs b/nebula_app/src/platform/window_chrome/macos.rs similarity index 98% rename from nebula_app/src/gpui_shell/workspace/window_titlebar/macos.rs rename to nebula_app/src/platform/window_chrome/macos.rs index 8302715..016c80d 100644 --- a/nebula_app/src/gpui_shell/workspace/window_titlebar/macos.rs +++ b/nebula_app/src/platform/window_chrome/macos.rs @@ -14,7 +14,7 @@ fn native_window(window: &Window) -> Option> { view.window() } -pub(in super::super) fn configure(window: &Window) { +pub(super) fn configure(window: &Window) { let Some(native) = native_window(window) else { return }; let toolbar = NSToolbar::initWithIdentifier( NSToolbar::alloc(native.mtm()), diff --git a/scripts/conformance/tests/test_posix_lifecycle.py b/scripts/conformance/tests/test_posix_lifecycle.py index 8cd3cb8..817ed50 100644 --- a/scripts/conformance/tests/test_posix_lifecycle.py +++ b/scripts/conformance/tests/test_posix_lifecycle.py @@ -29,7 +29,11 @@ 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())) +# Publish the PID only after the contents are complete. The parent polls for +# file existence, so creating the final path first exposes an empty file. +pid_path = root / (role + '.pid.tmp') +pid_path.write_text(str(os.getpid())) +pid_path.replace(root / (role + '.pid')) while True: if role == 'parent' and (root / 'exit').exists(): code = root / 'exit-code' From 492d0a969509ffa6925a46f0d7640d8373043f1b Mon Sep 17 00:00:00 2001 From: kuddev Date: Sun, 13 Sep 2026 20:34:18 +0800 Subject: [PATCH 23/51] test(reader): use shared platform detection for selection checks --- nebula_app/src/gpui_shell/theme/selection_tests.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/nebula_app/src/gpui_shell/theme/selection_tests.rs b/nebula_app/src/gpui_shell/theme/selection_tests.rs index 3747346..e0f4eec 100644 --- a/nebula_app/src/gpui_shell/theme/selection_tests.rs +++ b/nebula_app/src/gpui_shell/theme/selection_tests.rs @@ -107,7 +107,11 @@ fn reader_drag_selection_keeps_text_and_copy_after_theme_switch(cx: &mut TestApp assert!(cx.theme().selection.a <= 0.3, "{name:?}"); }); assert_eq!(cx.debug_bounds("reader-selection-fixture").unwrap(), bounds); - let copy = if cfg!(target_os = "macos") { "cmd-c" } else { "ctrl-c" }; + let copy = if crate::platform::Platform::current() == crate::platform::Platform::MacOS { + "cmd-c" + } else { + "ctrl-c" + }; cx.simulate_keystrokes(copy); let copied = cx.read_from_clipboard().and_then(|item| item.text()).unwrap(); assert_eq!(copied.trim(), SELECTED_TEXT, "{name:?}"); @@ -117,10 +121,14 @@ fn reader_drag_selection_keeps_text_and_copy_after_theme_switch(cx: &mut TestApp /// Native visual check for the same TextView used by the answer reader. Input /// events are dispatched to this test window, never to another desktop window. /// The probe does not touch the OS clipboard or start a terminal/AI session. -#[cfg(target_os = "windows")] #[test] #[ignore = "requires a Windows desktop and PEBREL_SELECTION_QA_DIR for screenshots"] fn native_reader_selection_preview() { + assert_eq!( + crate::platform::Platform::current(), + crate::platform::Platform::Windows, + "this native desktop probe requires Windows", + ); use gpui::{ Bounds, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PlatformInput, WindowBounds, WindowOptions, size, From 90df5811277d9357febf9522cb8a759176df2776 Mon Sep 17 00:00:00 2001 From: kuddev Date: Sun, 13 Sep 2026 20:34:59 +0800 Subject: [PATCH 24/51] refactor(settings): share platform detection for default UI fonts --- nebula_app/src/gpui_shell/theme.rs | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/nebula_app/src/gpui_shell/theme.rs b/nebula_app/src/gpui_shell/theme.rs index 20f5baa..0829216 100644 --- a/nebula_app/src/gpui_shell/theme.rs +++ b/nebula_app/src/gpui_shell/theme.rs @@ -678,21 +678,17 @@ fn apply_skin_tokens(chrome: NebulaTheme, cx: &mut App) { // 我们是终端,所以等宽在这里是**语义标记**而不是全局字体:路径、键帽、 // 命令、数值这类"机器读、要逐字符对齐、要能整段复制"的东西显式走 mono; // 标题和说明是给人读的,走 sans。 - #[cfg(target_os = "windows")] - { - theme.font_family = "Microsoft YaHei UI".into(); - // UI 中的等宽语义也必须稳定。终端字体由 TerminalView 单独读取; - // 若把用户字体组写进全局 theme,tab、标题和代码字面量的字宽都会 - // 随终端主字体变化,进而破坏 chrome 的既定间距。 - theme.mono_font_family = crate::font_install::REQUIRED_FONT_FAMILY.into(); - } - theme.font_family = runtime - .ui_font_family - .unwrap_or_else(|| { - if cfg!(target_os = "windows") { "Microsoft YaHei UI" } else { ".SystemUIFont" } - .to_owned() - }) - .into(); + let default_ui_font = + if crate::platform::Platform::current() == crate::platform::Platform::Windows { + // UI 中的等宽语义也必须稳定。终端字体由 TerminalView 单独读取; + // 若把用户字体组写进全局 theme,tab、标题和代码字面量的字宽都会 + // 随终端主字体变化,进而破坏 chrome 的既定间距。 + theme.mono_font_family = crate::font_install::REQUIRED_FONT_FAMILY.into(); + "Microsoft YaHei UI" + } else { + ".SystemUIFont" + }; + theme.font_family = runtime.ui_font_family.unwrap_or_else(|| default_ui_font.to_owned()).into(); theme.radius = px(crate::display::UI_CORNER_RADIUS_LOGICAL); theme.radius_lg = px(12.0); From 9b6afbdbaac6697f31cd75d1aac481bda3bf0105 Mon Sep 17 00:00:00 2001 From: kuddev Date: Sun, 13 Sep 2026 22:19:57 +0800 Subject: [PATCH 25/51] docs: record locally integrated PR changes --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b94ef6d..ec947ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ Every release entry is provided in English and Simplified Chinese. ### English +#### Added + +- Added a confirmation dialog to Settings → Restore defaults, with an explicit cancel action before any preferences are reset. Contributed by [@WilliamWang1721](https://github.com/WilliamWang1721) in [#115](https://github.com/Kuddev/pebrel/pull/115). +- Added Windows login-startup and silent-start switches in Settings → Advanced → Session lifecycle. Silent startup requires the system tray; opening a directory explicitly still shows a window. Contributed by [@WilliamWang1721](https://github.com/WilliamWang1721) in [#117](https://github.com/Kuddev/pebrel/pull/117). +- Added separate interface font and size controls, plus an optional CJK terminal font fallback list, in Settings → Appearance. Interface size can be adjusted from 10 to 24 px independently of terminal text. Contributed by [@WilliamWang1721](https://github.com/WilliamWang1721) in [#127](https://github.com/Kuddev/pebrel/pull/127). + #### Fixed - Fixed Windows terminal focus consuming `Alt+F4` and `Alt+Space`. `Alt+F4` now uses the normal window-close flow, and `Alt+Space`, followed by `N`, uses the system menu to minimize the window. @@ -21,6 +27,12 @@ Every release entry is provided in English and Simplified Chinese. ### 中文 +#### 新增 + +- 为“设置 → 恢复默认设置”加入确认弹窗,可在重置任何偏好前明确取消操作。由 [@WilliamWang1721](https://github.com/WilliamWang1721) 在 [#115](https://github.com/Kuddev/pebrel/pull/115) 中贡献。 +- 在 Windows 的“设置 → 高级 → 会话生命周期”中新增“登录时自启动”和“静默启动”开关。静默启动需要启用系统托盘;显式打开目录时仍显示窗口。由 [@WilliamWang1721](https://github.com/WilliamWang1721) 在 [#117](https://github.com/Kuddev/pebrel/pull/117) 中贡献。 +- 在“设置 → 外观”中新增独立的界面字体、界面字号,以及可选的中文终端字体回退列表。界面字号可在 10–24 px 间调整,不影响终端文字字号。由 [@WilliamWang1721](https://github.com/WilliamWang1721) 在 [#127](https://github.com/Kuddev/pebrel/pull/127) 中贡献。 + #### 修复 - 修复 Windows 终端获得焦点时吞掉 `Alt+F4` 和 `Alt+Space` 的问题。`Alt+F4` 现在进入正常窗口关闭流程,`Alt+Space` 后按 `N` 可通过系统菜单最小化窗口。 From 2c10bf8c3a8e42c0553c8183e575279bb1108780 Mon Sep 17 00:00:00 2001 From: kuddev Date: Mon, 14 Sep 2026 09:22:47 +0800 Subject: [PATCH 26/51] fix: address font layout and cold session restoration feedback --- CHANGELOG.md | 18 +- nebula_app/src/agent_detection/codex.toml | 5 + nebula_app/src/ai_agents.rs | 14 ++ nebula_app/src/display/input_state.rs | 19 +- nebula_app/src/display/mod.rs | 7 +- nebula_app/src/font_install.rs | 6 +- nebula_app/src/gpui_shell/config.rs | 6 +- nebula_app/src/gpui_shell/mod.rs | 12 +- nebula_app/src/gpui_shell/settings_pane.rs | 1 - .../settings_pane/appearance_advanced.rs | 20 +- .../gpui_shell/settings_pane/font_picker.rs | 30 ++- .../settings_pane/initialization.rs | 62 +++--- nebula_app/src/gpui_shell/terminal/session.rs | 21 ++ nebula_app/src/gpui_shell/terminal/view.rs | 186 +--------------- .../src/gpui_shell/terminal/view/layout.rs | 210 ++++++++++++++++++ .../src/gpui_shell/terminal/view/runtime.rs | 34 +-- .../terminal/view/startup_command.rs | 93 ++++++++ .../gpui_shell/terminal/view/startup_tests.rs | 167 ++++++++++++++ nebula_app/src/gpui_shell/workspace.rs | 54 ++--- .../src/gpui_shell/workspace/sidebar.rs | 63 +++--- .../gpui_shell/workspace/tab_duplication.rs | 2 +- .../src/gpui_shell/workspace/top_tabs.rs | 14 +- nebula_app/src/main.rs | 8 +- .../src/platform/ai_session_identity.rs | 58 ++++- nebula_app/src/product_ui/mod.rs | 7 +- 25 files changed, 762 insertions(+), 355 deletions(-) create mode 100644 nebula_app/src/gpui_shell/terminal/view/layout.rs create mode 100644 nebula_app/src/gpui_shell/terminal/view/startup_command.rs create mode 100644 nebula_app/src/gpui_shell/terminal/view/startup_tests.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ce0ad2..5555e40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ Every release entry is provided in English and Simplified Chinese. - Added a confirmation dialog to Settings → Restore defaults, with an explicit cancel action before any preferences are reset. Contributed by [@WilliamWang1721](https://github.com/WilliamWang1721) in [#115](https://github.com/Kuddev/pebrel/pull/115). - Added Windows login-startup and silent-start switches in Settings → Advanced → Session lifecycle. Silent startup requires the system tray; opening a directory explicitly still shows a window. Contributed by [@WilliamWang1721](https://github.com/WilliamWang1721) in [#117](https://github.com/Kuddev/pebrel/pull/117). -- Added separate interface font and size controls, plus an optional CJK terminal font fallback list, in Settings → Appearance. Interface size can be adjusted from 10 to 24 px independently of terminal text. Contributed by [@WilliamWang1721](https://github.com/WilliamWang1721) in [#127](https://github.com/Kuddev/pebrel/pull/127). +- Added an interface text-size control and a CJK terminal font fallback list in Settings → Appearance. Interface size can be adjusted from 10 to 24 px independently of terminal text. Contributed by [@WilliamWang1721](https://github.com/WilliamWang1721) in [#127](https://github.com/Kuddev/pebrel/pull/127). - Added “Focus follows mouse” in Settings → Interaction, off by default, and “Dim inactive panes” in Settings → Appearance, on by default. Both preferences take effect immediately. - Added right-click actions to the Shell launcher for choosing the default Shell and, on Windows, opening the selected Shell inside a new administrator Pebrel window. - Added Connect, Edit, and Delete to SSH launcher context menus. Delete uses the existing confirmation and undo flow. @@ -20,6 +20,12 @@ Every release entry is provided in English and Simplified Chinese. #### Fixed +- Aligned the English and Chinese terminal-font fields and kept the current font visible. Both default to the bundled Maple Mono NF CN font; the interface font-family setting has been removed while its text-size control remains. +- Preserved the inherited working directory when a bare `pebrel` launch opens a new terminal, including handover to a running Windows instance. +- Restored WSL panes in their saved guest directories, and kept queued AI resume commands and identities through shell initialization. Recognized the compact Codex resume screen without requiring another AI message, and cleared the foreground identity after the CLI exits. +- Prevented long WSL directory titles and distribution labels from overlapping in sidebar and top tabs. +- Ensured a quiet terminal receives its final startup viewport size even when no further output arrives. + - Fixed Windows terminal focus consuming `Alt+F4` and `Alt+Space`. `Alt+F4` now uses the normal window-close flow, and `Alt+Space`, followed by `N`, uses the system menu to minimize the window. - Aligned macOS title-bar controls and tab spacing with the native window-button geometry. Contributed by [@WilliamWang1721](https://github.com/WilliamWang1721) in [#114](https://github.com/Kuddev/pebrel/pull/114). - Fixed ignored terminal selection foreground colors and preserved custom selection colors in themes that do not define their own. Contributed by [@Aschenbath](https://github.com/Aschenbath) in [#125](https://github.com/Kuddev/pebrel/pull/125). @@ -29,7 +35,6 @@ Every release entry is provided in English and Simplified Chinese. #### Improved - - Localized existing Git controls, status messages, history timestamps and file-operation notices. Switching the interface language preserves the commit draft and its text selection. Contributed by [@gao-jian-bin](https://github.com/gao-jian-bin) in [#118](https://github.com/Kuddev/pebrel/pull/118). - Replaced the low-resolution Claude Code icon with artwork exported from SVG at 1024 pixels, and prepared Agent icons at their display size for smoother tab edges. @@ -39,7 +44,7 @@ Every release entry is provided in English and Simplified Chinese. - 为“设置 → 恢复默认设置”加入确认弹窗,可在重置任何偏好前明确取消操作。由 [@WilliamWang1721](https://github.com/WilliamWang1721) 在 [#115](https://github.com/Kuddev/pebrel/pull/115) 中贡献。 - 在 Windows 的“设置 → 高级 → 会话生命周期”中新增“登录时自启动”和“静默启动”开关。静默启动需要启用系统托盘;显式打开目录时仍显示窗口。由 [@WilliamWang1721](https://github.com/WilliamWang1721) 在 [#117](https://github.com/Kuddev/pebrel/pull/117) 中贡献。 -- 在“设置 → 外观”中新增独立的界面字体、界面字号,以及可选的中文终端字体回退列表。界面字号可在 10–24 px 间调整,不影响终端文字字号。由 [@WilliamWang1721](https://github.com/WilliamWang1721) 在 [#127](https://github.com/Kuddev/pebrel/pull/127) 中贡献。 +- 在“设置 → 外观”中新增独立的界面字号和中文终端字体回退列表。界面字号可在 10–24 px 间调整,不影响终端文字字号。由 [@WilliamWang1721](https://github.com/WilliamWang1721) 在 [#127](https://github.com/Kuddev/pebrel/pull/127) 中贡献。 - 在“设置 → 交互”中加入“焦点跟随鼠标”,默认关闭;在“设置 → 外观”中加入“调暗非活动窗格”,默认开启。两项设置均即时生效。 - Shell 启动菜单新增右键操作,可设为默认 Shell;Windows 下还可在新的管理员 Pebrel 窗口内打开所选 Shell。 - SSH 启动菜单新增“连接、编辑、删除”右键操作;删除沿用已有的确认与撤销流程。 @@ -47,6 +52,12 @@ Every release entry is provided in English and Simplified Chinese. #### 修复 +- 对齐中英文终端字体输入框并显示当前字体,两项默认均采用内置 Maple Mono NF CN;撤掉界面字体设置,保留界面字号。 +- 不带目录参数运行 `pebrel` 并创建新终端时,继承实际启动目录;交接给已有 Windows 实例时也保留该目录。 +- WSL 窗格恢复到保存的来宾目录;AI 恢复命令与会话身份可跨越 shell 初始化阶段,识别 Codex 的精简恢复界面,无需再发送 AI 消息,CLI 退出后清除前台身份。 +- 避免 WSL 长目录标题与发行版名称在侧栏、顶部标签中互相覆盖。 +- 即使没有新的终端输出,也会在启动布局稳定后将最终视口尺寸同步给终端。 + - 修复 Windows 终端获得焦点时吞掉 `Alt+F4` 和 `Alt+Space` 的问题。`Alt+F4` 现在进入正常窗口关闭流程,`Alt+Space` 后按 `N` 可通过系统菜单最小化窗口。 - 让 macOS 标题栏控件与标签间距跟随系统窗口按钮的实际几何位置。由 [@WilliamWang1721](https://github.com/WilliamWang1721) 在 [#114](https://github.com/Kuddev/pebrel/pull/114) 中贡献。 - 修复终端忽略选中文字前景色的问题;主题未定义自己的选区颜色时,保留用户的自定义选区颜色。由 [@Aschenbath](https://github.com/Aschenbath) 在 [#125](https://github.com/Kuddev/pebrel/pull/125) 中贡献。 @@ -56,7 +67,6 @@ Every release entry is provided in English and Simplified Chinese. #### 改进 - - 为现有 Git 控件、状态提示、历史时间和文件操作提示补充国际化;切换界面语言时保留提交草稿及其文字选区。由 [@gao-jian-bin](https://github.com/gao-jian-bin) 在 [#118](https://github.com/Kuddev/pebrel/pull/118) 中贡献。 - 将 Claude Code 的低分辨率图标替换为从 SVG 导出的 1024 像素图像,并按显示尺寸处理 Agent 图标,使标签图标边缘更平滑。 diff --git a/nebula_app/src/agent_detection/codex.toml b/nebula_app/src/agent_detection/codex.toml index c28a5ef..24c6249 100644 --- a/nebula_app/src/agent_detection/codex.toml +++ b/nebula_app/src/agent_detection/codex.toml @@ -8,6 +8,11 @@ id = "codex" [[identity]] contains = ["openai codex"] +# A restored conversation can omit the startup banner. Require its branded +# empty prompt immediately followed by the live model/effort footer at the tail. +[[identity]] +regex = ['(?m)^[ \t]*› Ask Codex to do anything[ \t]*\r?\n(?:[ \t]*\r?\n)*[ \t]*\S+[ \t]+(?:minimal|low|medium|high|xhigh|max)[ \t]*[·•][^\r\n]*\s*\z'] + [[rules]] id = "strong_blocker" state = "blocked" diff --git a/nebula_app/src/ai_agents.rs b/nebula_app/src/ai_agents.rs index 4106a1f..a8c896c 100644 --- a/nebula_app/src/ai_agents.rs +++ b/nebula_app/src/ai_agents.rs @@ -918,6 +918,20 @@ mod tests { assert_eq!(idle.status, AgentStatus::Idle); } + #[test] + fn review_regression_restored_codex_identity_requires_live_prompt_and_footer() { + let live = "› Ask Codex to do anything\n\n gpt-6-astra max · /mnt/d/temp_build/project · Saved conversation"; + assert_eq!(identify(live), Some(AgentKind::Codex)); + for text in [ + "› generic shell prompt", + "The CLI says Ask Codex to do anything.", + "› Ask Codex to do anything\nuser@host:~$ ", + "› Ask Codex to do anything\ngpt-6-astra max · /project\nuser@host:~$ ", + ] { + assert_eq!(identify(text), None, "{text}"); + } + } + #[test] fn branded_screen_chrome_identifies_codex_without_a_visible_host_process() { let screen = "OpenAI Codex (v0.42.0)\n\n› Ask Codex to do anything"; diff --git a/nebula_app/src/display/input_state.rs b/nebula_app/src/display/input_state.rs index 53134da..49512bf 100644 --- a/nebula_app/src/display/input_state.rs +++ b/nebula_app/src/display/input_state.rs @@ -125,7 +125,6 @@ pub(crate) fn nebula_input_from_raw_grid( nebula_prompt_line_from_raw_grid(terminal, cursor, typed_tail, env).map(|line| line.input) } -#[cfg(windows)] pub(crate) fn nebula_prompt_line_from_raw_grid( terminal: &Term, cursor: Point, @@ -136,6 +135,24 @@ pub(crate) fn nebula_prompt_line_from_raw_grid( prompt_line_snapshot(&text, typed_tail, env, terminal.nebula_prompt_active()) } +/// Cold startup has no typed input mirror yet. Readiness is distinct from +/// reconciling user keystrokes, which must continue to reject an empty mirror. +pub(crate) fn nebula_shell_ready_from_raw_grid( + terminal: &Term, + env: &SuggestEnv, +) -> bool { + let Some(text) = raw_grid_logical_line(terminal, terminal.grid().cursor.point) else { + return false; + }; + if let Some(line) = prompt_line_snapshot(&text, "", env, terminal.nebula_prompt_active()) { + return line.input.trim().is_empty(); + } + let prompt = text.trim_end(); + let Some(marker) = prompt.chars().next_back() else { return false }; + safe_shell_prompt_marker(prompt, marker, env) + && (terminal.nebula_prompt_active() || likely_prompt(prompt, marker, env)) +} + pub(crate) fn nebula_shell_prompt_restored_from_raw_grid( terminal: &Term, expected_prompt: &str, diff --git a/nebula_app/src/display/mod.rs b/nebula_app/src/display/mod.rs index 272757f..14a69bd 100644 --- a/nebula_app/src/display/mod.rs +++ b/nebula_app/src/display/mod.rs @@ -114,12 +114,11 @@ pub use context_menu_model::{ContextMenuAction, ContextMenuHit, ContextMenuTarge pub(crate) use file_operations::send_to_recycle_bin; 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, + nebula_input_text, nebula_prompt_line_from_raw_grid, + nebula_shell_prompt_restored_from_raw_grid, nebula_shell_ready_from_raw_grid, }; #[cfg(windows)] -pub(crate) use input_state::{ - nebula_input_from_raw_grid, nebula_prompt_line_from_raw_grid, nebula_raw_grid_row_preview, -}; +pub(crate) use input_state::{nebula_input_from_raw_grid, nebula_raw_grid_row_preview}; pub use program_identity::AiLogo; pub(crate) use program_identity::{ ai_logo, ai_logo_for_program, prepare_ai_logo_texture, program_icon, diff --git a/nebula_app/src/font_install.rs b/nebula_app/src/font_install.rs index 0e701ac..972703a 100644 --- a/nebula_app/src/font_install.rs +++ b/nebula_app/src/font_install.rs @@ -2,13 +2,13 @@ use std::path::{Path, PathBuf}; use sha2::{Digest, Sha256}; -pub const REQUIRED_FONT_FAMILY: &str = "Maple Mono Normal NF CN"; +pub const REQUIRED_FONT_FAMILY: &str = "Maple Mono NF CN"; /// The bundled face is shared by the legacy rasterizer and the GPUI text /// system. Keeping one static byte slice avoids letting the two shells drift /// to different font revisions. pub static REQUIRED_FONT_BYTES: &[u8] = - include_bytes!("../../assets/fonts/MapleMonoNormal-NF-CN-Regular.ttf"); + include_bytes!("../../assets/fonts/MapleMono-NF-CN-Regular.ttf"); /// 一个系统已安装字体族,连同平台给出的等宽判定。 /// @@ -214,7 +214,7 @@ pub fn gpui_font_with_fallbacks(value: &str) -> gpui::Font { } font } -pub const REQUIRED_FONT_FILE: &str = "MapleMonoNormal-NF-CN-Regular.ttf"; +pub const REQUIRED_FONT_FILE: &str = "MapleMono-NF-CN-Regular.ttf"; /// 枚举系统已安装字体族,带 DirectWrite 的权威等宽判定(`IsMonospacedFont`)。 /// diff --git a/nebula_app/src/gpui_shell/config.rs b/nebula_app/src/gpui_shell/config.rs index 621d81e..7c8c556 100644 --- a/nebula_app/src/gpui_shell/config.rs +++ b/nebula_app/src/gpui_shell/config.rs @@ -203,7 +203,11 @@ impl Settings { cjk_bold_regular: runtime.cjk_bold_regular, shell_id: runtime.shell.clone(), font_family: normal_family, - font_cjk: runtime.font_family_cjk.as_deref().map(|family| { + font_cjk: Some({ + let family = runtime + .font_family_cjk + .as_deref() + .unwrap_or(crate::font_install::REQUIRED_FONT_FAMILY); use gpui::{FontStyle, FontWeight}; [ (FontWeight::NORMAL, FontStyle::Normal), diff --git a/nebula_app/src/gpui_shell/mod.rs b/nebula_app/src/gpui_shell/mod.rs index 791c33a..46526d9 100644 --- a/nebula_app/src/gpui_shell/mod.rs +++ b/nebula_app/src/gpui_shell/mod.rs @@ -120,13 +120,14 @@ pub fn run_shell( { // 与另一份进程同时启动时,对方可能已经拿到 owner lock、但尚未来得及 // 发布 endpoint。短暂等待并交接,避免继续打开一个无控制面的空窗口。 + let handover_cwd = initial_cwd.clone().or_else(|| std::env::current_dir().ok()); for _ in 0..40 { let behavior = nebula_settings::RuntimeSettings::load().windowing_behavior; let handed_over = match behavior { nebula_settings::WindowingBehaviorName::UseNew => { - crate::runtime_api::try_open_window_existing(initial_cwd.as_deref()) + crate::runtime_api::try_open_window_existing(handover_cwd.as_deref()) }, - _ => initial_cwd.as_deref().map_or_else( + _ => handover_cwd.as_deref().map_or_else( crate::runtime_api::try_open_default_tab_existing, crate::runtime_api::try_open_directory_existing, ), @@ -193,9 +194,10 @@ fn register_bundled_fonts(cx: &App) { // GPUI resolves a family through the system collection first and silently // falls back when it is absent. Add Maple before any component/window can // resolve a font so the default remains the same private face as winit. - if let Err(error) = - cx.text_system().add_fonts(vec![Cow::Borrowed(crate::font_install::REQUIRED_FONT_BYTES)]) - { + if let Err(error) = cx.text_system().add_fonts(vec![ + Cow::Borrowed(crate::font_install::REQUIRED_FONT_BYTES), + Cow::Borrowed(include_bytes!("../../../assets/fonts/MapleMonoNormal-NF-CN-Regular.ttf")), + ]) { try_write_stderr(format_args!( "[nebula:gpui] failed to register bundled Maple font: {error}" )); diff --git a/nebula_app/src/gpui_shell/settings_pane.rs b/nebula_app/src/gpui_shell/settings_pane.rs index 9e03a30..eca4108 100644 --- a/nebula_app/src/gpui_shell/settings_pane.rs +++ b/nebula_app/src/gpui_shell/settings_pane.rs @@ -178,7 +178,6 @@ pub struct SettingsPane { font_imported: Vec, font_family_input: Entity, font_family_cjk_input: Entity, - ui_font_family_input: Entity, /// 字体输入框上一帧的窗口坐标。字体目录是宽弹层,不能把整条设置行当 /// 锚点;否则输入框在右侧、菜单却会从正文左缘展开。 font_picker_trigger_bounds: Option>, 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 919c6fc..03d32a5 100644 --- a/nebula_app/src/gpui_shell/settings_pane/appearance_advanced.rs +++ b/nebula_app/src/gpui_shell/settings_pane/appearance_advanced.rs @@ -61,16 +61,6 @@ impl SettingsPane { )); let interface = self .group(language.pick("界面", "Interface"), cx) - .child( - self.row( - language.text(crate::i18n::Message::SettingsFontUiFamily), - language.text(crate::i18n::Message::SettingsFontUiFamilyDescription), - Input::new(&self.ui_font_family_input) - .w(px(SETTINGS_SELECT_WIDTH)) - .aria_label(language.text(crate::i18n::Message::SettingsFontUiFamily)), - cx, - ), - ) .child(self.font_size_row(true, cx)) .child(self.switch_row( "dim_inactive_panes", @@ -123,9 +113,15 @@ impl SettingsPane { self.row( language.text(crate::i18n::Message::SettingsFontChinese), language.text(crate::i18n::Message::SettingsFontChineseDescription), - Input::new(&self.font_family_cjk_input) + div() + .debug_selector(|| "font-family-cjk-input".to_owned()) .w(px(SETTINGS_SELECT_WIDTH)) - .aria_label(language.text(crate::i18n::Message::SettingsFontChinese)), + .h(px(36.0)) + .child( + Input::new(&self.font_family_cjk_input).w_full().h_full().aria_label( + language.text(crate::i18n::Message::SettingsFontChinese), + ), + ), cx, ), ) 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 ea23ee5..448f5cc 100644 --- a/nebula_app/src/gpui_shell/settings_pane/font_picker.rs +++ b/nebula_app/src/gpui_shell/settings_pane/font_picker.rs @@ -198,14 +198,14 @@ impl SettingsPane { .relative() .w(px(SETTINGS_SELECT_WIDTH)) .min_w_0() - .h(px(32.0)) - .when(self.active_section == 1, |control| control.w_full().h(px(36.0))) + .h(px(36.0)) + .debug_selector(|| "font-family-input".to_owned()) .flex_shrink_0() .overflow_hidden() .child( Input::new(&self.font_family_input) .w_full() - .when(self.active_section == 1, |input| input.h(px(36.0))) + .h(px(36.0)) .cleanable(false) .suffix(Button::new("font-picker-chevron") .debug_selector(|| "font-picker-chevron".to_owned()).ghost().xsmall() @@ -558,7 +558,7 @@ impl SettingsPane { let panel = self.font_picker_open.then(|| self.font_picker_panel(panel_width, list_height, cx)); - div().relative().w_full().flex_shrink_0().child(row).when_some( + div().relative().w(px(SETTINGS_SELECT_WIDTH)).flex_shrink_0().child(row).when_some( panel.zip(trigger_bounds), |anchor, (panel, trigger_bounds)| { anchor.child( @@ -584,7 +584,9 @@ mod interaction_tests { use gpui_component::Root; #[gpui::test] - fn dropdown_arrow_toggles_and_search_filters_without_navigation_click(cx: &mut TestAppContext) { + fn review_regression_font_fields_align_and_dropdown_toggles_with_search( + cx: &mut TestAppContext, + ) { cx.update(|cx| { gpui_component::init(cx); cx.set_global(crate::gpui_shell::config::Settings::load( @@ -592,7 +594,7 @@ mod interaction_tests { )); }); let mut pane = None; - let (_, mut cx) = cx.add_window_view(|window, cx| { + let (_, cx) = cx.add_window_view(|window, cx| { let view = cx.new(|cx| SettingsPane::new(window, cx)); pane = Some(view.clone()); Root::new(view, window, cx) @@ -603,6 +605,22 @@ mod interaction_tests { cx.update(|window, cx| { let _ = window.draw(cx); }); + let english = cx.debug_bounds("font-family-input").expect("English field is visible"); + let chinese = cx.debug_bounds("font-family-cjk-input").expect("Chinese field is visible"); + assert_eq!(english.size.width, px(SETTINGS_SELECT_WIDTH)); + assert_eq!(english.size, chinese.size, "both font fields share the same dimensions"); + assert_eq!( + english.origin.x, chinese.origin.x, + "both font fields align in the control column" + ); + assert_eq!( + pane.read_with(cx, |pane, cx| pane.font_family_input.read(cx).value().to_string()), + REQUIRED_FONT_FAMILY + ); + assert_eq!( + pane.read_with(cx, |pane, cx| pane.font_family_cjk_input.read(cx).value().to_string()), + REQUIRED_FONT_FAMILY + ); let bounds = cx.debug_bounds("font-picker-chevron").expect("font field exposes a dropdown arrow"); let center = point( diff --git a/nebula_app/src/gpui_shell/settings_pane/initialization.rs b/nebula_app/src/gpui_shell/settings_pane/initialization.rs index 3c64625..2614357 100644 --- a/nebula_app/src/gpui_shell/settings_pane/initialization.rs +++ b/nebula_app/src/gpui_shell/settings_pane/initialization.rs @@ -440,39 +440,38 @@ impl SettingsPane { )); let font_family_cjk_input = cx.new(|cx| { - InputState::new(window, cx) - .default_value(runtime.font_family_cjk.clone().unwrap_or_default()) + InputState::new(window, cx).default_value( + runtime + .font_family_cjk + .clone() + .unwrap_or_else(|| crate::font_install::REQUIRED_FONT_FAMILY.to_owned()), + ) }); - let ui_font_family_input = cx.new(|cx| { - InputState::new(window, cx) - .default_value(runtime.ui_font_family.clone().unwrap_or_default()) - }); - for (key, input) in - [("font_family_cjk", &font_family_cjk_input), ("ui_font_family", &ui_font_family_input)] - { - subscriptions.push(cx.subscribe_in( - input, - window, - move |this: &mut Self, input, event: &InputEvent, window, cx| { - if matches!(event, InputEvent::PressEnter { .. } | InputEvent::Blur) { - let raw = input.read(cx).value(); - let (value, current) = if key == "ui_font_family" { - (raw.trim().to_owned(), &this.runtime.ui_font_family) - } else { - ( - crate::font_install::normalize_font_family_chain(&raw), - &this.runtime.font_family_cjk, - ) - }; - let changed = current.as_deref().unwrap_or_default() != value; - input.update(cx, |input, cx| input.set_value(value.clone(), window, cx)); - if changed { - this.persist(&[(key, value)], cx); - } + subscriptions.push(cx.subscribe_in( + &font_family_cjk_input, + window, + |this: &mut Self, input, event: &InputEvent, window, cx| { + if matches!(event, InputEvent::PressEnter { .. } | InputEvent::Blur) { + let raw = input.read(cx).value(); + let normalized = crate::font_install::normalize_font_family_chain(&raw); + let value = if normalized.is_empty() { + crate::font_install::REQUIRED_FONT_FAMILY.to_owned() + } else { + normalized + }; + let current = this + .runtime + .font_family_cjk + .as_deref() + .unwrap_or(crate::font_install::REQUIRED_FONT_FAMILY); + let changed = current != value; + input.update(cx, |input, cx| input.set_value(value.clone(), window, cx)); + if changed { + this.persist(&[("font_family_cjk", value)], cx); } - }, - )); - } + } + }, + )); let bg_picker_hsv = { let term = crate::gpui_shell::theme::chrome_theme_resolved(cx).palette().term_bg; @@ -585,7 +584,6 @@ impl SettingsPane { font_imported: Vec::new(), font_family_input, font_family_cjk_input, - ui_font_family_input, font_picker_trigger_bounds: None, backup_selection: crate::encrypted_backup::BackupSelection::default(), backup_pass_input: cx.new(|cx| { diff --git a/nebula_app/src/gpui_shell/terminal/session.rs b/nebula_app/src/gpui_shell/terminal/session.rs index 2560eb5..6b8eb4d 100644 --- a/nebula_app/src/gpui_shell/terminal/session.rs +++ b/nebula_app/src/gpui_shell/terminal/session.rs @@ -68,6 +68,27 @@ pub struct TerminalSession { pub shell_pid: u32, } +#[cfg(all(test, feature = "gpui-test-support"))] +pub(super) fn test_session() +-> (TerminalSession, std::sync::mpsc::Receiver) { + let (events, _) = super::event_mailbox::channel(); + let (stages, _) = unbounded(); + let term = Term::new( + Config::default(), + &GridSize { columns: 80, screen_lines: 24 }, + EventProxy { events, stages }, + ); + let (sender, receiver) = nebula_terminal::event_loop::EventLoopSender::standalone().unwrap(); + ( + TerminalSession { + term: Arc::new(FairMutex::new(term)), + notifier: Notifier(sender), + shell_pid: 0, + }, + receiver, + ) +} + /// 一次会话 spawn 的完整出口:会话句柄 + 终端事件流 + SSH 阶段流 /// (本地会话的阶段流永远安静,接收端可以直接丢弃)。 pub type SpawnedSession = ( diff --git a/nebula_app/src/gpui_shell/terminal/view.rs b/nebula_app/src/gpui_shell/terminal/view.rs index 48f7c7d..8d757fe 100644 --- a/nebula_app/src/gpui_shell/terminal/view.rs +++ b/nebula_app/src/gpui_shell/terminal/view.rs @@ -4,10 +4,14 @@ mod broadcast; mod confirmation; mod cwd_report; mod image_paste; +mod layout; mod notifications; mod path_drop; mod pointer; mod runtime; +mod startup_command; +#[cfg(all(test, feature = "gpui-test-support"))] +mod startup_tests; pub use broadcast::TerminalInput; pub use runtime::InputOrigin; @@ -335,6 +339,7 @@ pub struct TerminalView { /// 的 working/blocked 屏幕或权威 hook 前,不允许它伪造完成边沿。 agent_runtime_submit_pending: bool, pending_runtime_submit: Option, + pending_shell_command: Option, /// OSC 1337 图片的串行后台解码队列和有界像素缓存。图片不进入字符网格, /// 只用事件携带的绝对行锚定到对应的 scrollback 位置。 pub(super) inline_images: super::inline_image::InlineImageStore, @@ -800,6 +805,7 @@ impl TerminalView { idle_screen_streak: 0, agent_runtime_submit_pending: false, pending_runtime_submit: None, + pending_shell_command: None, inline_images: super::inline_image::InlineImageStore::default(), image_paste: image_paste::ImagePasteState::default(), path_drop: path_drop::PathDropState::default(), @@ -886,6 +892,7 @@ impl TerminalView { match event { TermEvent::Wakeup => { self.flush_pending_runtime_submit(cx); + self.flush_pending_shell_command(cx); cx.notify(); }, TermEvent::MouseCursorDirty => { @@ -1048,6 +1055,7 @@ impl TerminalView { fn mark_exited(&mut self, message: String, cx: &mut Context) { self.confirmation.invalidate(); self.pending_runtime_submit = None; + self.pending_shell_command = None; self.suggest.pending_command_prompt = None; if self.exited.is_none() { self.exited = Some(message); @@ -1185,184 +1193,6 @@ impl TerminalView { .detach(); } - /// 宿主宣告:下一次网格变化来自结构性布局改动,直接提交、不去抖。 - /// 见 `structural_resize` 字段注释。 - pub fn mark_structural_resize(&mut self) { - self.structural_resize = true; - } - - /// 元素 prepaint 回写布局:内容矩形与度量交给渲染合同裁定网格。 - /// 网格变化时同步 Term 与 ConPTY;行列不变但像素口径变化也上报 PTY - /// (应用可能关心像素度量);稳态帧 observe 返回 None,零额外开销。 - pub fn set_layout( - &mut self, - origin: Point, - cell_width: Pixels, - line_height: Pixels, - content: Size, - scale: f32, - cx: &mut Context, - ) { - self.origin = origin; - self.cell_width = cell_width; - self.line_height = line_height; - let metrics = CellMetrics { - cell_width: cell_width.as_f32(), - cell_height: line_height.as_f32(), - scale, - }; - let change = - self.viewports.observe(content.width.as_f32(), content.height.as_f32(), &metrics); - - // 启动稳定闸:开窗 resize 异步落地,首帧可能还是开窗前的旧尺寸。 - // 命中 spawn 网格前不向 Term/ConPTY 下发(PTY 出生即目标几何, - // 零下发收口);宽限期后仍未命中(小屏收拢等真实差异)则放行, - // 一次性按当前视口纠正。observe 会把过渡帧并进 current,因此 - // 释放判定不依赖本帧是否有增量。 - if !self.grid_synced { - let Some(viewport) = - change.map(|c| c.viewport).or_else(|| self.viewports.current().copied()) - else { - return; - }; - let landed = (viewport.cols, viewport.rows) - == (self.window_size.num_cols, self.window_size.num_lines); - if !landed && self.spawn_at.elapsed() < Self::STARTUP_GRID_GRACE { - return; - } - self.grid_synced = true; - self.cols = viewport.cols as usize; - self.rows = viewport.rows as usize; - if !landed { - self.commit_viewport(viewport); - } else { - self.window_size = viewport.window_size(); - } - return; - } - - let Some(change) = change else { - return; - }; - let viewport = change.viewport; - self.cols = viewport.cols as usize; - self.rows = viewport.rows as usize; - - // 本地网格立刻跟手,只有子进程那一半去抖(旧壳也通过 - // `resize_active_layout_grids` 采用同一策略)。两半的代价完全不对称:客户端 - // reflow 便宜且可逆,而每一次 `ResizePseudoConsole` 都让 conhost 重排 - // 自己的缓冲区,那些重排累积出的光标行漂移事后无从察觉。让网格落后于 - // 渲染就只能靠"视觉裁剪"预览未提交的几何,而裁剪只能裁行、无法重排列 - // ——宽度一变预览就是错的,且 Term 与屏幕不一致的每一毫秒里到达的字节 - // 都会按旧宽度进网格。 - if change.grid_changed { - self.resize_grid_only(viewport); - } - - // 结构性变化(分屏创建/关闭、zoom、面板开合)不去抖:它只来一次,没有 - // 后续帧可以合并,多等的每一毫秒都是子进程按旧几何输出的窗口期。旧壳在 - // 这些路径上走 `resize_active_layout()` 同步下发,这里复刻同一条合同。 - if std::mem::take(&mut self.structural_resize) { - self.pending_resize = None; - self.resize_epoch = self.resize_epoch.wrapping_add(1); - self.commit_viewport(viewport); - return; - } - - // ConPTY 的直通式 conhost 在 resize 时零输出,指望终端侧 reflow 与 - // 它内部 buffer rewrap 一致;两者的换行语义存在路径依赖差异,每多 - // 一次中间宽度的 ResizePseudoConsole 就多攒一分光标行漂移(字节取 - // 证:13 次提交后 PSReadLine 的 CUP 行比真实提示行高 7 行)。旧壳 - // (winit) 的模态拖拽天然只在松手后送达一次 resize,从不累积。这里 - // 复刻该合同:子进程那一半纯尾沿去抖——只进 pending,视口静默 - // RESIZE_SETTLE_DELAY 后一次性下发。净零手势(挤压后拖回原宽)最终 - // 提交同尺寸 no-op,rewrap 次数为零;网格已在上面逐帧跟手,所以去抖 - // 的代价只落在"子进程晚知道几十毫秒",屏幕上看不出来。 - self.pending_resize = Some(viewport); - self.schedule_settled_resize(cx); - } - - /// 只让本地网格 reflow 到 `viewport`,子进程留在旧几何上。 - /// - /// 走 `Msg::ResizeGrid` 而不是直接锁 `Term`:event_loop 的 resize 分支会先 - /// 把旧几何下已可读的字节全部消化掉,绝对 CUP 序列因此不会被解析进新宽度 - /// 的网格。UI 线程自己上锁 resize 就绕过了这道流边界保护。 - fn resize_grid_only(&mut self, viewport: TerminalViewport) { - let Some(session) = &self.session else { return }; - let mut notifier = nebula_terminal::event_loop::Notifier(session.notifier.0.clone()); - notifier.on_resize_grid(viewport.window_size()); - } - - /// Commit one viewport in grid-before-PTY order. Output produced after - /// `ResizePseudoConsole` therefore always parses against the same geometry - /// history ConPTY used to generate its absolute cursor coordinates. - fn commit_viewport(&mut self, viewport: TerminalViewport) { - let next = viewport.window_size(); - let grid_changed = (self.window_size.num_cols, self.window_size.num_lines) - != (next.num_cols, next.num_lines); - let pixel_changed = (self.window_size.cell_width, self.window_size.cell_height) - != (next.cell_width, next.cell_height); - if !grid_changed && !pixel_changed { - return; - } - - if let Some(session) = &self.session { - let mut notifier = nebula_terminal::event_loop::Notifier(session.notifier.0.clone()); - notifier.on_resize(next); - } - self.window_size = next; - } - - /// 一次拖拽手势(窗口边框/分屏把手)是否仍在进行。conhost 的 buffer - /// rewrap 与本地 reflow 的换行语义存在路径依赖差异,每一次中间几何的 - /// `ResizePseudoConsole` 都会累积光标行漂移(字节取证:一次拖拽 14 次 - /// 提交后 PSReadLine 的 CUP 行比真实提示行高 7 行,且 conhost 全程零 - /// 重绘字节,漂移无法事后察觉)。旧壳 (winit) 的模态拖拽天然只在松手 - /// 后送达一次 resize,从不出这个问题;GPUI 在模态循环内持续派发布局, - /// 时间去抖(150ms settle)与布局批次同周期,挡不住中间提交。因此按 - /// 手势门控:左键仍按住就不提交,settle 定时器自我续期到松手为止。 - #[cfg(windows)] - fn drag_gesture_active() -> bool { - use windows_sys::Win32::UI::Input::KeyboardAndMouse::{GetAsyncKeyState, VK_LBUTTON}; - // SAFETY: GetAsyncKeyState 只读全局按键状态,无副作用。 - (unsafe { GetAsyncKeyState(VK_LBUTTON as i32) } as u16 & 0x8000) != 0 - } - - #[cfg(not(windows))] - fn drag_gesture_active() -> bool { - false - } - - fn schedule_settled_resize(&mut self, cx: &mut Context) { - self.resize_epoch = self.resize_epoch.wrapping_add(1); - let epoch = self.resize_epoch; - let executor = cx.background_executor().clone(); - cx.spawn(async move |this, cx| { - executor.timer(Self::RESIZE_SETTLE_DELAY).await; - let _ = this.update(cx, |view, cx| { - if view.resize_epoch != epoch { - return; - } - let gate = Self::drag_gesture_active(); - if std::env::var_os("NEBULA_RESIZE_TRACE").is_some() { - crate::gpui_shell::try_write_stderr(format_args!( - "[nebula:resize-trace] settle-timer gate={gate}" - )); - } - if gate { - // 手势未松开:净零手势(挤压后拖回原宽)最终提交同尺寸 - // no-op,ConPTY 一次 rewrap 都不做。 - view.schedule_settled_resize(cx); - return; - } - let Some(viewport) = view.pending_resize.take() else { return }; - view.commit_viewport(viewport); - cx.notify(); - }); - }) - .detach(); - } - /// 旧壳 `change_font_size`:一步 1 逻辑 px,钳 4–64。写盘后通知宿主 /// 给所有 pane 热应用,chrome 仍锚定 toml 字号。 fn zoom_font_size(&mut self, step: f32, cx: &mut Context) { diff --git a/nebula_app/src/gpui_shell/terminal/view/layout.rs b/nebula_app/src/gpui_shell/terminal/view/layout.rs new file mode 100644 index 0000000..22c10a6 --- /dev/null +++ b/nebula_app/src/gpui_shell/terminal/view/layout.rs @@ -0,0 +1,210 @@ +//! Viewport synchronization and PTY resize lifetime. + +use super::*; + +impl TerminalView { + /// 宿主宣告:下一次网格变化来自结构性布局改动,直接提交、不去抖。 + /// 见 `structural_resize` 字段注释。 + pub fn mark_structural_resize(&mut self) { + self.structural_resize = true; + } + + /// 元素 prepaint 回写布局:内容矩形与度量交给渲染合同裁定网格。 + /// 网格变化时同步 Term 与 ConPTY;行列不变但像素口径变化也上报 PTY + /// (应用可能关心像素度量);稳态帧 observe 返回 None,零额外开销。 + pub fn set_layout( + &mut self, + origin: Point, + cell_width: Pixels, + line_height: Pixels, + content: Size, + scale: f32, + cx: &mut Context, + ) { + self.origin = origin; + self.cell_width = cell_width; + self.line_height = line_height; + let metrics = CellMetrics { + cell_width: cell_width.as_f32(), + cell_height: line_height.as_f32(), + scale, + }; + let change = + self.viewports.observe(content.width.as_f32(), content.height.as_f32(), &metrics); + + // 启动稳定闸:开窗 resize 异步落地,首帧可能还是开窗前的旧尺寸。 + // 命中 spawn 网格前不向 Term/ConPTY 下发(PTY 出生即目标几何, + // 零下发收口);宽限期后仍未命中(小屏收拢等真实差异)则放行, + // 一次性按当前视口纠正。observe 会把过渡帧并进 current,因此 + // 释放判定不依赖本帧是否有增量。 + if !self.grid_synced { + let Some(viewport) = + change.map(|c| c.viewport).or_else(|| self.viewports.current().copied()) + else { + return; + }; + let landed = (viewport.cols, viewport.rows) + == (self.window_size.num_cols, self.window_size.num_lines); + if !landed && self.spawn_at.elapsed() < Self::STARTUP_GRID_GRACE { + // A quiet shell may never produce another frame after this one. + // Arrange an owned deadline, keeping the latest observed geometry. + let first = self.pending_resize.replace(viewport).is_none(); + if first { + self.schedule_startup_grid_sync(cx); + } + return; + } + self.pending_resize = None; + self.grid_synced = true; + self.cols = viewport.cols as usize; + self.rows = viewport.rows as usize; + if !landed { + self.commit_viewport(viewport); + } else { + self.window_size = viewport.window_size(); + } + return; + } + + let Some(change) = change else { + return; + }; + let viewport = change.viewport; + self.cols = viewport.cols as usize; + self.rows = viewport.rows as usize; + + // 本地网格立刻跟手,只有子进程那一半去抖(旧壳也通过 + // `resize_active_layout_grids` 采用同一策略)。两半的代价完全不对称:客户端 + // reflow 便宜且可逆,而每一次 `ResizePseudoConsole` 都让 conhost 重排 + // 自己的缓冲区,那些重排累积出的光标行漂移事后无从察觉。让网格落后于 + // 渲染就只能靠"视觉裁剪"预览未提交的几何,而裁剪只能裁行、无法重排列 + // ——宽度一变预览就是错的,且 Term 与屏幕不一致的每一毫秒里到达的字节 + // 都会按旧宽度进网格。 + if change.grid_changed { + self.resize_grid_only(viewport); + } + + // 结构性变化(分屏创建/关闭、zoom、面板开合)不去抖:它只来一次,没有 + // 后续帧可以合并,多等的每一毫秒都是子进程按旧几何输出的窗口期。旧壳在 + // 这些路径上走 `resize_active_layout()` 同步下发,这里复刻同一条合同。 + if std::mem::take(&mut self.structural_resize) { + self.pending_resize = None; + self.resize_epoch = self.resize_epoch.wrapping_add(1); + self.commit_viewport(viewport); + return; + } + + // ConPTY 的直通式 conhost 在 resize 时零输出,指望终端侧 reflow 与 + // 它内部 buffer rewrap 一致;两者的换行语义存在路径依赖差异,每多 + // 一次中间宽度的 ResizePseudoConsole 就多攒一分光标行漂移(字节取 + // 证:13 次提交后 PSReadLine 的 CUP 行比真实提示行高 7 行)。旧壳 + // (winit) 的模态拖拽天然只在松手后送达一次 resize,从不累积。这里 + // 复刻该合同:子进程那一半纯尾沿去抖——只进 pending,视口静默 + // RESIZE_SETTLE_DELAY 后一次性下发。净零手势(挤压后拖回原宽)最终 + // 提交同尺寸 no-op,rewrap 次数为零;网格已在上面逐帧跟手,所以去抖 + // 的代价只落在"子进程晚知道几十毫秒",屏幕上看不出来。 + self.pending_resize = Some(viewport); + self.schedule_settled_resize(cx); + } + + fn schedule_startup_grid_sync(&mut self, cx: &mut Context) { + let delay = Self::STARTUP_GRID_GRACE.saturating_sub(self.spawn_at.elapsed()); + let executor = cx.background_executor().clone(); + cx.spawn(async move |this, cx| { + executor.timer(delay).await; + let _ = this.update(cx, |view, cx| { + if view.grid_synced { + return; + } + let Some(viewport) = view.pending_resize.take() else { return }; + view.grid_synced = true; + view.cols = viewport.cols as usize; + view.rows = viewport.rows as usize; + view.commit_viewport(viewport); + cx.notify(); + }); + }) + .detach(); + } + + /// 只让本地网格 reflow 到 `viewport`,子进程留在旧几何上。 + /// + /// 走 `Msg::ResizeGrid` 而不是直接锁 `Term`:event_loop 的 resize 分支会先 + /// 把旧几何下已可读的字节全部消化掉,绝对 CUP 序列因此不会被解析进新宽度 + /// 的网格。UI 线程自己上锁 resize 就绕过了这道流边界保护。 + fn resize_grid_only(&mut self, viewport: TerminalViewport) { + let Some(session) = &self.session else { return }; + let mut notifier = nebula_terminal::event_loop::Notifier(session.notifier.0.clone()); + notifier.on_resize_grid(viewport.window_size()); + } + + /// Commit one viewport in grid-before-PTY order. Output produced after + /// `ResizePseudoConsole` therefore always parses against the same geometry + /// history ConPTY used to generate its absolute cursor coordinates. + fn commit_viewport(&mut self, viewport: TerminalViewport) { + let next = viewport.window_size(); + let grid_changed = (self.window_size.num_cols, self.window_size.num_lines) + != (next.num_cols, next.num_lines); + let pixel_changed = (self.window_size.cell_width, self.window_size.cell_height) + != (next.cell_width, next.cell_height); + if !grid_changed && !pixel_changed { + return; + } + + if let Some(session) = &self.session { + let mut notifier = nebula_terminal::event_loop::Notifier(session.notifier.0.clone()); + notifier.on_resize(next); + } + self.window_size = next; + } + + /// 一次拖拽手势(窗口边框/分屏把手)是否仍在进行。conhost 的 buffer + /// rewrap 与本地 reflow 的换行语义存在路径依赖差异,每一次中间几何的 + /// `ResizePseudoConsole` 都会累积光标行漂移(字节取证:一次拖拽 14 次 + /// 提交后 PSReadLine 的 CUP 行比真实提示行高 7 行,且 conhost 全程零 + /// 重绘字节,漂移无法事后察觉)。旧壳 (winit) 的模态拖拽天然只在松手 + /// 后送达一次 resize,从不出这个问题;GPUI 在模态循环内持续派发布局, + /// 时间去抖(150ms settle)与布局批次同周期,挡不住中间提交。因此按 + /// 手势门控:左键仍按住就不提交,settle 定时器自我续期到松手为止。 + #[cfg(windows)] + fn drag_gesture_active() -> bool { + use windows_sys::Win32::UI::Input::KeyboardAndMouse::{GetAsyncKeyState, VK_LBUTTON}; + // SAFETY: GetAsyncKeyState 只读全局按键状态,无副作用。 + (unsafe { GetAsyncKeyState(VK_LBUTTON as i32) } as u16 & 0x8000) != 0 + } + + #[cfg(not(windows))] + fn drag_gesture_active() -> bool { + false + } + + fn schedule_settled_resize(&mut self, cx: &mut Context) { + self.resize_epoch = self.resize_epoch.wrapping_add(1); + let epoch = self.resize_epoch; + let executor = cx.background_executor().clone(); + cx.spawn(async move |this, cx| { + executor.timer(Self::RESIZE_SETTLE_DELAY).await; + let _ = this.update(cx, |view, cx| { + if view.resize_epoch != epoch { + return; + } + let gate = Self::drag_gesture_active(); + if std::env::var_os("NEBULA_RESIZE_TRACE").is_some() { + crate::gpui_shell::try_write_stderr(format_args!( + "[nebula:resize-trace] settle-timer gate={gate}" + )); + } + if gate { + // 手势未松开:净零手势(挤压后拖回原宽)最终提交同尺寸 + // no-op,ConPTY 一次 rewrap 都不做。 + view.schedule_settled_resize(cx); + return; + } + let Some(viewport) = view.pending_resize.take() else { return }; + view.commit_viewport(viewport); + cx.notify(); + }); + }) + .detach(); + } +} diff --git a/nebula_app/src/gpui_shell/terminal/view/runtime.rs b/nebula_app/src/gpui_shell/terminal/view/runtime.rs index ad157e1..d3fe856 100644 --- a/nebula_app/src/gpui_shell/terminal/view/runtime.rs +++ b/nebula_app/src/gpui_shell/terminal/view/runtime.rs @@ -114,6 +114,7 @@ impl TerminalView { self.ssh_connect_last_step = std::time::Instant::now(); if matches!(stage, crate::ssh_session::SshStage::Failed(_)) { self.pending_runtime_submit = None; + self.pending_shell_command = None; self.command_running = false; self.command_started = None; self.confirmation.invalidate(); @@ -130,8 +131,8 @@ impl TerminalView { 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(); + let program_changed = self.running_program.take().is_some(); + let title_changed = self.ai_session.take().is_some() || program_changed; self.invalidate_ai_session_probe(); self.primary_agent_pid = None; self.ai_session_from_probe = false; @@ -691,16 +692,6 @@ impl TerminalView { crate::ai_agents::AgentKind::parse(&identity.source)?.fork_command(&identity.session_id) } - /// 冷恢复把快照里的 hook 身份种回 pane,右键分叉不必再等下一条事件。 - pub fn seed_ai_session(&mut self, source: String, session_id: String, cx: &mut Context) { - if session_id.is_empty() { - return; - } - self.ai_session = Some(crate::display::AiSessionIdentity { source, session_id }); - cx.emit(TerminalViewEvent::TitleChanged); - cx.notify(); - } - pub(crate) fn prepare_ai_session_save(&mut self, cx: &mut Context) { if self.ai_session_from_probe { self.ai_session = None; @@ -758,12 +749,15 @@ impl TerminalView { ) { return; } - if let Some(session_id) = session_id { + if let Some(session) = 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, + session_id: session.session_id, }); + if let Some(cwd) = session.cwd { + view.process_event(nebula_terminal::event::Event::CwdReport(cwd), cx); + } view.ai_session_from_probe = true; cx.emit(TerminalViewEvent::TitleChanged); cx.notify(); @@ -773,17 +767,6 @@ impl TerminalView { .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) { - if command.is_empty() || self.exited.is_some() { - return; - } - let mut bytes = command.into_bytes(); - bytes.push(b'\r'); - self.write_input(bytes, cx); - } - /// 事件声明的 pane 与写管道进程的祖先链是否互相矛盾。 /// /// 三种情况都返回 `false`(放行):事件没有声明 pane(本就要回退焦点)、 @@ -1008,6 +991,7 @@ impl TerminalView { self.flush_pending_runtime_submit(cx); // 进程树对账必须排在下面那道早退之前:身份认不出来就早退,等于让 // 这个 pane 永远退出屏幕检测。 + self.flush_pending_shell_command(cx); self.reconcile_shell_activity(cx); self.probe_missing_codex_session(cx); let Some(session) = &self.session else { return }; diff --git a/nebula_app/src/gpui_shell/terminal/view/startup_command.rs b/nebula_app/src/gpui_shell/terminal/view/startup_command.rs new file mode 100644 index 0000000..b5c9266 --- /dev/null +++ b/nebula_app/src/gpui_shell/terminal/view/startup_command.rs @@ -0,0 +1,93 @@ +//! Resume commands wait for the new shell prompt before publishing live identity. + +use super::*; + +pub(super) struct PendingShellCommand { + text: String, + identity: Option, +} + +impl TerminalView { + pub(crate) fn seed_restored_cwd(&mut self, cwd: String, cx: &mut Context) { + self.process_event(TermEvent::CwdReport(cwd), cx); + } + + /// Keep a queued cold resume in the next snapshot without presenting it as + /// a live foreground agent before the shell accepts the command. + pub(crate) fn session_agent(&self) -> Option { + let pending = self.pending_shell_command.as_ref(); + let identity = + pending.and_then(|request| request.identity.as_ref()).or(self.ai_session.as_ref()); + if let Some(identity) = identity { + return Some(crate::session::AgentSession { + source: identity.source.clone(), + session_id: Some(identity.session_id.clone()), + }); + } + let kind = pending + .and_then(|request| crate::ai_agents::AgentKind::parse_command(&request.text)) + .or_else(|| { + self.running_program.as_deref().and_then(crate::ai_agents::AgentKind::parse) + })?; + Some(crate::session::AgentSession { source: kind.slug().to_owned(), session_id: None }) + } + + pub fn run_command(&mut self, text: String, cx: &mut Context) { + if text.is_empty() || self.exited.is_some() { + return; + } + self.pending_shell_command = Some(PendingShellCommand { text, identity: None }); + self.flush_pending_shell_command(cx); + } + + pub fn seed_ai_session(&mut self, source: String, session_id: String, cx: &mut Context) { + if session_id.is_empty() { + return; + } + let identity = crate::display::AiSessionIdentity { source, session_id }; + if let Some(pending) = self.pending_shell_command.as_mut() { + pending.identity = Some(identity); + } else if self.running_program.as_deref() == Some(identity.source.as_str()) { + self.ai_session = Some(identity); + cx.emit(TerminalViewEvent::TitleChanged); + cx.notify(); + } + } + + pub(super) fn flush_pending_shell_command(&mut self, cx: &mut Context) { + if self.pending_shell_command.is_none() + || self.pending_runtime_submit.is_some() + || self.exited.is_some() + { + return; + } + let Some(session) = &self.session else { return }; + let ready = { + let term = session.term.lock(); + !term.mode().intersects(TermMode::ALT_SCREEN | TermMode::VI) + && crate::display::nebula_shell_ready_from_raw_grid( + &term, + &self.suggest.suggest_env, + ) + }; + if !ready { + return; + } + let pending = self.pending_shell_command.take().expect("checked above"); + let kind = crate::ai_agents::AgentKind::parse_command(&pending.text); + if let Err(error) = self.runtime_prompt(pending.text.clone(), true, cx) { + log::warn!("Could not submit startup command: {error:?}"); + self.pending_shell_command = Some(pending); + return; + } + if let Some(kind) = kind { + self.running_program = Some(kind.slug().to_owned()); + self.ai_session = pending.identity; + self.agent_status = crate::ai_agents::AgentStatus::Working; + self.agent_status_source = crate::ai_agents::AgentStatusSource::Process; + self.agent_turn_active = false; + } + cx.emit(TerminalViewEvent::TitleChanged); + cx.notify(); + } +} diff --git a/nebula_app/src/gpui_shell/terminal/view/startup_tests.rs b/nebula_app/src/gpui_shell/terminal/view/startup_tests.rs new file mode 100644 index 0000000..b2cd285 --- /dev/null +++ b/nebula_app/src/gpui_shell/terminal/view/startup_tests.rs @@ -0,0 +1,167 @@ +use super::*; +use gpui::{Entity, TestAppContext, VisualTestContext, size}; +use gpui_component::Root; +use nebula_terminal::event::Event; +use std::sync::mpsc::Receiver; + +// Keep the terminal entity off the layout tree so tests control each viewport +// and PTY event explicitly, while using real GPUI tasks and terminal parsing. +struct Surface; +impl Render for Surface { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div().size_full() + } +} + +fn open(cx: &mut TestAppContext) -> (Entity, &mut VisualTestContext, Receiver) { + cx.update(|cx| { + gpui_component::init(cx); + cx.set_global(Settings::load(nebula_settings::ThemeName::Nord)); + }); + let mut result = None; + let (_, window) = cx.add_window_view(|window, cx| { + let view = cx.new(|cx| { + TerminalView::new( + 42, + (80, 24), + TerminalLaunch::Local { + cwd: None, + shell: Some(nebula_terminal::tty::Shell::new( + "pebrel-test-missing-shell-executable".into(), + vec![], + )), + shell_name: None, + }, + window, + cx, + ) + }); + let receiver = view.update(cx, |view, _| { + let (session, receiver) = session::test_session(); + view.session = Some(session); + view.error = None; + view.exited = None; + view.exec_context = None; + view.suggest.suggest_env = crate::display::SuggestEnv::Wsl { distro: "Debian".into() }; + receiver + }); + result = Some((view, receiver)); + Root::new(cx.new(|_| Surface), window, cx) + }); + let (view, receiver) = result.unwrap(); + (view, window, receiver) +} + +fn feed(view: &mut TerminalView, bytes: &[u8]) { + let mut term = view.session.as_ref().unwrap().term.lock(); + let mut parser = nebula_terminal::vte::ansi::Processor::< + nebula_terminal::vte::ansi::StdSyncHandler, + >::default(); + parser.advance(&mut *term, bytes); +} + +#[gpui::test] +fn review_regression_cold_resume_survives_initial_prompt_and_clears_on_exit( + cx: &mut TestAppContext, +) { + let (view, window, receiver) = open(cx); + window.update(|_, cx| view.update(cx, |view, cx| { + view.run_command("codex resume saved-42".into(), cx); + view.seed_ai_session("codex".into(), "saved-42".into(), cx); + assert!(receiver.try_recv().is_err(), "do not submit into shell initialization"); + assert_eq!(view.session_agent().unwrap().session_id.as_deref(), Some("saved-42")); + view.process_event(Event::CommandDone { exit_code: Some(0) }, cx); + assert!(view.pending_shell_command.is_some()); + feed(view, b"\x1b]133;A\x07hello@host:/home/hello$ "); + view.process_event(Event::Wakeup, cx); + assert!(view.pending_shell_command.is_none()); + assert_eq!(view.running_program.as_deref(), Some("codex")); + assert_eq!(view.ai_session.as_ref().unwrap().session_id, "saved-42"); + assert!(matches!(receiver.try_recv().unwrap(), Msg::Input(bytes) if bytes.as_ref() == b"codex resume saved-42")); + // The initial shell edge cannot consume the pending Enter or identity. + view.process_event(Event::CommandDone { exit_code: Some(0) }, cx); + assert!(view.ai_session.is_some()); + feed(view, b"codex resume saved-42"); + view.flush_pending_runtime_submit(cx); + assert!(matches!(receiver.try_recv().unwrap(), Msg::Input(bytes) if bytes.as_ref() == b"\r")); + view.process_event(Event::CommandStart, cx); + assert_eq!(view.runtime_agent().unwrap().kind, "codex"); + assert_eq!(view.ai_session.as_ref().unwrap().session_id, "saved-42"); + view.process_event(Event::CommandDone { exit_code: Some(0) }, cx); + assert!(view.running_program.is_none()); + assert!(view.ai_session.is_none(), "both foreground fields must clear together"); + assert!(view.runtime_agent().is_none()); + })); +} + +#[gpui::test] +fn review_regression_saved_identity_without_a_resume_command_is_not_live(cx: &mut TestAppContext) { + let (view, window, _) = open(cx); + window.update(|_, cx| { + view.update(cx, |view, cx| { + view.seed_ai_session("codex".into(), "saved-42".into(), cx); + assert!(view.ai_session.is_none()); + view.run_command("codex resume saved-42".into(), cx); + feed(view, b"Password: "); + view.process_event(Event::Wakeup, cx); + assert!( + view.pending_shell_command.is_some(), + "never send a command into authentication" + ); + assert!(view.running_program.is_none()); + }) + }); +} + +#[gpui::test] +fn review_regression_quiet_startup_and_maximize_deliver_the_latest_pty_size( + cx: &mut TestAppContext, +) { + let (view, window, receiver) = open(cx); + window.update(|_, cx| { + view.update(cx, |view, cx| { + view.set_layout( + point(px(0.0), px(0.0)), + px(10.0), + px(20.0), + size(px(1200.0), px(700.0)), + 1.0, + cx, + ); + view.set_layout( + point(px(0.0), px(0.0)), + px(10.0), + px(20.0), + size(px(1600.0), px(900.0)), + 1.0, + cx, + ); + assert!(receiver.try_recv().is_err(), "startup waits for final layout"); + }) + }); + window.run_until_parked(); + window.executor().advance_clock(TerminalView::STARTUP_GRID_GRACE); + window.run_until_parked(); + let sizes: Vec<_> = receiver + .try_iter() + .filter_map(|message| match message { + Msg::Resize(size) => Some((size.num_cols, size.num_lines)), + _ => None, + }) + .collect(); + assert_eq!(sizes, [(160, 45)], "one final resize even without a new terminal frame"); + window.update(|_, cx| { + view.update(cx, |view, cx| { + view.mark_structural_resize(); + view.set_layout( + point(px(0.0), px(0.0)), + px(10.0), + px(20.0), + size(px(800.0), px(500.0)), + 1.0, + cx, + ); + }) + }); + assert!(receiver.try_iter().any(|message| matches!(message, Msg::Resize(size) if size.num_cols == 80 && size.num_lines == 25))); +} diff --git a/nebula_app/src/gpui_shell/workspace.rs b/nebula_app/src/gpui_shell/workspace.rs index 070cb41..eaa38d1 100644 --- a/nebula_app/src/gpui_shell/workspace.rs +++ b/nebula_app/src/gpui_shell/workspace.rs @@ -1109,7 +1109,7 @@ impl NebulaWorkspace { if !runtime.restore_session || !this.try_restore_session(runtime.resume_ai, window, cx) { - this.add_terminal(window, cx); + this.add_terminal_at(std::env::current_dir().ok(), None, window, cx); } }, windowing::WorkspaceStartup::NewTerminal { cwd } => { @@ -1911,24 +1911,31 @@ impl NebulaWorkspace { let mut panes: Vec = Vec::new(); for (index, leaf) in layout.leaves().into_iter().enumerate() { let LayoutSession::Pane { cwd, agent } = leaf else { continue }; - let launch = if index == 0 { - Self::terminal_launch_from_session(&saved_launch, crate::session::valid_dir(cwd)) - } else { - // 共享 v4 与旧壳只把 Tab 的 launch 赋给首 Pane;其它叶子没有 - // 独立启动身份,保持既有 Default 恢复语义。 - crate::gpui_shell::terminal::view::TerminalLaunch::Local { - cwd: crate::session::valid_dir(cwd), - shell: None, - shell_name: None, - } - }; + let mut launch_session = + if index == 0 { saved_launch.clone() } else { Self::configured_local_launch(cx) }; + if matches!(launch_session, LaunchSession::Default) { + launch_session = Self::configured_local_launch(cx); + } + let guest_directory = + tab_duplication::inherit_guest_directory(&mut launch_session, cwd); + let local_cwd = if guest_directory { None } else { crate::session::valid_dir(cwd) }; + let launch = Self::terminal_launch_from_session(&launch_session, local_cwd); let command = restored_agent_command(resume_ai, agent.as_ref()); + let resuming = command.is_some(); let pane = self.new_pane(grid, launch, command, window, cx); + // Display and persist the restored location while the guest shell + // starts; subsequent OSC reports remain authoritative. + if guest_directory { + pane.view.update(cx, |view, cx| { + view.seed_restored_cwd(cwd.clone(), cx); + }); + } // 冷恢复已经知道这段对话的 hook 身份:种回 view,右键「分叉 // AI 会话」不必再等下一条带 session_id 的 hook。 - if let Some((source, session_id)) = agent - .as_ref() - .and_then(|agent| Some((agent.source.clone(), agent.session_id.clone()?))) + if resuming + && let Some((source, session_id)) = agent + .as_ref() + .and_then(|agent| Some((agent.source.clone(), agent.session_id.clone()?))) { pane.view.update(cx, |view, cx| view.seed_ai_session(source, session_id, cx)); } @@ -1978,22 +1985,7 @@ impl NebulaWorkspace { return (String::new(), None); }; let view = pane.view.read(cx); - let agent = view - .ai_session - .as_ref() - .map(|identity| AgentSession { - source: identity.source.clone(), - session_id: Some(identity.session_id.clone()), - }) - .or_else(|| { - view.running_program - .as_deref() - .filter(|program| crate::ai_agents::AgentKind::parse(program).is_some()) - .map(|program| AgentSession { - source: program.to_owned(), - session_id: None, - }) - }); + let agent = view.session_agent(); (view.cwd.clone(), agent) }; let layout = crate::gpui_shell::session_restore::layout_from_tree(tree, &leaf_data); diff --git a/nebula_app/src/gpui_shell/workspace/sidebar.rs b/nebula_app/src/gpui_shell/workspace/sidebar.rs index 85e01cd..b27f798 100644 --- a/nebula_app/src/gpui_shell/workspace/sidebar.rs +++ b/nebula_app/src/gpui_shell/workspace/sidebar.rs @@ -53,14 +53,21 @@ pub(super) fn resting_activity( } impl NebulaWorkspace { - /// 侧栏标签的保守字宽:塑形一个 "M" 取 advance,供旧壳列数截断逻辑 - /// 估算。必须使用 UI 字体;终端字体变化不属于 chrome 的布局输入。 - fn sidebar_cell_width(&self, window: &mut Window, family: &SharedString, size_px: f32) -> f32 { - let shaped = window.text_system().shape_line( - SharedString::new_static("M"), + /// Reserve the shaped shell label's width, capped to leave room for the title. + pub(super) fn shell_status_width( + window: &Window, + tag: Option<&SharedString>, + family: &SharedString, + size_px: f32, + minimum: f32, + maximum: f32, + ) -> f32 { + let Some(tag) = tag else { return minimum }; + let line = window.text_system().shape_line( + tag.clone(), px(size_px), &[gpui::TextRun { - len: 1, + len: tag.len(), font: gpui::font(family.clone()), color: gpui::Hsla::default(), background_color: None, @@ -69,8 +76,7 @@ impl NebulaWorkspace { }], None, ); - let width = f32::from(shaped.width); - if width > 0.5 { width } else { size_px * 0.6 } + f32::from(line.width).ceil().clamp(minimum, maximum.max(minimum)) } /// 旧壳 `icons::push_spinner` 的 canvas 复刻:暗轨道 + 绕行亮弧(占 @@ -244,7 +250,6 @@ impl NebulaWorkspace { let symbol_family: SharedString = crate::font_install::REQUIRED_FONT_FAMILY.into(); // 界面字号独立于终端缩放。 let label_px = settings.map(|settings| settings.ui_font_size_px).unwrap_or(15.0); - let cell_w = self.sidebar_cell_width(window, &chrome_family, label_px); // 受约束拖拽的渲染参数:激活后被拖行骑指针位移,落点槽位由位移换算。 let drag = self @@ -276,25 +281,15 @@ impl NebulaWorkspace { pane_count, } = self.tab_presentation(ix, cx, dark); let hover_group: SharedString = format!("sidebar-tab-hover-{ix}").into(); - // 可用列数 = (行宽 − 行内 px_2 − 行内 gap − 状态槽 − 行首图标槽) - // ÷ cell 宽。基准取上面的 `row_w`(已扣掉侧栏 p_2 与滚动条留白), - // 与行的实际宽度同源——否则算出的列数会比行能容纳的多出一格, - // 截断后的标题反过来把行撑开。省略号由旧壳同一份 - // `truncate_tab_label` 追加,两壳的裁切位置因此一致。 - // 行首图标槽只有一个:身份图标(设置 / AI logo / 程序字位)优先, - // 都没有时分屏标记才补位。三者互斥,所以扣一份宽即可。 let has_program_glyph = program_glyph.is_some(); - let has_icon = - is_settings || logo_image.is_some() || has_program_glyph || pane_count > 1; - let label_avail = row_w - - 16.0 - - TAB_STATUS_SLOT_W - - 8.0 - - if has_icon { TAB_LABEL_ICON_W + 8.0 } else { 0.0 } - - if pane_count > 1 { pane_header::split_badge_slot_w(label_px) } else { 0.0 }; - let label_cols = (label_avail / cell_w).floor().max(1.0) as usize; - let title: SharedString = - crate::display::truncate_tab_label(&title, label_cols).into(); + let status_width = Self::shell_status_width( + window, + shell_tag.as_ref(), + &chrome_family, + label_px * SIDEBAR_TAG_SCALE, + TAB_STATUS_SLOT_W, + row_w * 0.45, + ); let cross_window_drag = self.cross_window_drag_payload(ix, cx); // 用户明确设置过的标签色:行左侧一条竖光条(旧壳 strip,位置与 // 尺寸同源:左内缩 4、上下各留 7、宽 2.5)。默认标签不占这层 @@ -362,6 +357,9 @@ impl NebulaWorkspace { ), SidebarActivity::Idle => shell_tag.map(|tag| { div() + .w_full() + .min_w_0() + .truncate() .font_family(chrome_family.clone()) .text_size(px(label_px * SIDEBAR_TAG_SCALE)) .font_weight(FontWeight::NORMAL) @@ -514,10 +512,7 @@ impl NebulaWorkspace { ) }, ) - // 标签走终端字体 + 终端字号(旧壳 chrome 同源)。文本已按列 - // 截断,这里只需要不换行;再叠一层 `truncate()` 会把省略号 - // 自己裁掉(用户报的"直接截断")。重命名中的那一行原地换成 - // 输入框:点进去不该触发选中/拖拽,所以自己吃掉 mouse_down。 + // GPUI truncates the actual shaped title within its flex column. .child(match renaming { Some(input) => div() .flex_1() @@ -547,8 +542,7 @@ impl NebulaWorkspace { // 标签标题本身使用 Light;活动态只换前景/背景色, // 不再靠更粗字重强调,避免选中行看起来突然加粗。 .font_weight(FontWeight::LIGHT) - .whitespace_nowrap() - .overflow_hidden() + .truncate() .child(title) .into_any_element(), }) @@ -573,8 +567,9 @@ impl NebulaWorkspace { .child( div() .relative() - .w(px(TAB_STATUS_SLOT_W)) + .w(px(status_width)) .h_full() + .overflow_hidden() .flex_shrink_0() .when_some(resting_status, |slot, status| { slot.child( diff --git a/nebula_app/src/gpui_shell/workspace/tab_duplication.rs b/nebula_app/src/gpui_shell/workspace/tab_duplication.rs index 1a08a2d..b1ea7e7 100644 --- a/nebula_app/src/gpui_shell/workspace/tab_duplication.rs +++ b/nebula_app/src/gpui_shell/workspace/tab_duplication.rs @@ -5,7 +5,7 @@ use gpui::{Context, Window}; use super::NebulaWorkspace; use crate::session::LaunchSession; -fn inherit_guest_directory(launch: &mut LaunchSession, cwd: &str) -> bool { +pub(super) fn inherit_guest_directory(launch: &mut LaunchSession, cwd: &str) -> bool { let (program, args) = match launch { LaunchSession::Shell { program, args, .. } => (program, args), LaunchSession::Profile { command, args, .. } => (command, args), diff --git a/nebula_app/src/gpui_shell/workspace/top_tabs.rs b/nebula_app/src/gpui_shell/workspace/top_tabs.rs index f7ba58a..0bdd0e7 100644 --- a/nebula_app/src/gpui_shell/workspace/top_tabs.rs +++ b/nebula_app/src/gpui_shell/workspace/top_tabs.rs @@ -187,6 +187,14 @@ impl NebulaWorkspace { 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 }; + let status_width = Self::shell_status_width( + window, + shell_tag.as_ref(), + &chrome_family, + label_px * 0.8, + TOP_TAB_STATUS_W, + tab_w * 0.4, + ); let resting_status: Option = match activity { SidebarActivity::Running => { items_running.set(true); @@ -231,6 +239,9 @@ impl NebulaWorkspace { ), SidebarActivity::Idle => shell_tag.map(|tag| { div() + .w_full() + .min_w_0() + .truncate() .font_family(chrome_family.clone()) .text_size(px(label_px * 0.8)) .font_weight(FontWeight::NORMAL) @@ -453,8 +464,9 @@ impl NebulaWorkspace { .child( div() .relative() - .w(px(TOP_TAB_STATUS_W)) + .w(px(status_width)) .h_full() + .overflow_hidden() .flex_shrink_0() .when_some(resting_status, |slot, status| { slot.child( diff --git a/nebula_app/src/main.rs b/nebula_app/src/main.rs index f892b38..19f7a6d 100644 --- a/nebula_app/src/main.rs +++ b/nebula_app/src/main.rs @@ -527,7 +527,9 @@ fn try_hand_over_to_resident(options: &Options) -> bool { .window_options .terminal_options .resolved_working_directory() - .filter(|path| path.is_dir()); + .or_else(|| env::current_dir().ok()) + .filter(|path| path.is_dir()) + .and_then(|path| std::path::absolute(path).ok()); if !options.daemon && !has_command && nebula_settings::RuntimeSettings::load().windowing_behavior @@ -535,9 +537,7 @@ fn try_hand_over_to_resident(options: &Options) -> bool { { return runtime_api::try_open_window_existing(launch_dir.as_deref()); } - let plain_launch = !options.daemon - && options.window_options.terminal_options.working_directory.is_none() - && !has_command; + let plain_launch = !options.daemon && launch_dir.is_none() && !has_command; if plain_launch && runtime_api::try_open_default_tab_existing() { return true; } diff --git a/nebula_app/src/platform/ai_session_identity.rs b/nebula_app/src/platform/ai_session_identity.rs index 31fc9e7..7cc1529 100644 --- a/nebula_app/src/platform/ai_session_identity.rs +++ b/nebula_app/src/platform/ai_session_identity.rs @@ -62,6 +62,13 @@ struct ProbeRecord { first_line: String, } +/// Identity and working directory from the active rollout's own metadata. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct CodexSession { + pub session_id: String, + pub cwd: Option, +} + /// Probe the active Codex rollout for one pane. /// /// WSL panes are inspected inside the guest because Windows Toolhelp cannot @@ -71,7 +78,7 @@ struct ProbeRecord { pub(crate) fn probe_codex_session( pane_id: u64, exec_context: Option<&crate::runtime_exec::PaneExecContext>, -) -> Option { +) -> Option { let pane_id = pane_id.to_string(); let context = exec_context?; let instance = context.process_instance()?; @@ -90,7 +97,7 @@ pub(crate) fn probe_codex_session( } } -fn parse_probe_records(output: &str) -> Option { +fn parse_probe_context(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 { @@ -118,17 +125,30 @@ fn parse_probe_records(output: &str) -> Option { if filename_id != id { continue; } - if candidate.as_ref().is_some_and(|existing| existing != id) { + let context = CodexSession { + session_id: id.to_owned(), + cwd: payload + .get("cwd") + .and_then(serde_json::Value::as_str) + .filter(|cwd| cwd.starts_with('/') && !cwd.chars().any(char::is_control)) + .map(str::to_owned), + }; + if candidate.as_ref().is_some_and(|existing| existing != &context) { // 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 = Some(context); } candidate } +#[cfg(test)] +fn parse_probe_records(output: &str) -> Option { + parse_probe_context(output).map(|context| context.session_id) +} + fn parse_probe_record(line: &str) -> Option { let mut fields = line.splitn(4, '\t'); Some(ProbeRecord { @@ -170,7 +190,7 @@ fn probe_wsl( user: Option<&str>, pane_id: &str, instance: &str, -) -> Option { +) -> Option { let mut command = Command::new("wsl.exe"); command.args(wsl_probe_args(distro, user, pane_id, instance)); #[cfg(windows)] @@ -208,7 +228,7 @@ fn wsl_probe_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 { +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()?; @@ -238,7 +258,7 @@ fn run_probe_command(mut command: Command) -> Option { }; output.rewind().ok()?; let output = read_bounded(output, MAX_PROBE_OUTPUT).ok()?; - status.success().then(|| parse_probe_records(&String::from_utf8_lossy(&output)))? + status.success().then(|| parse_probe_context(&String::from_utf8_lossy(&output)))? } fn read_bounded(mut reader: impl Read, limit: usize) -> io::Result> { @@ -258,7 +278,7 @@ fn read_bounded(mut reader: impl Read, limit: usize) -> io::Result> { } #[cfg(unix)] -fn probe_local_proc(pane_id: &str, instance: &str) -> Option { +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; @@ -309,7 +329,7 @@ fn probe_local_proc(pane_id: &str, instance: &str) -> Option { } } } - parse_probe_records(&output) + parse_probe_context(&output) } #[cfg(unix)] @@ -400,6 +420,26 @@ mod tests { assert!(read_bounded(&b"12345"[..], 4).is_err()); } + #[test] + fn review_regression_active_rollout_supplies_cwd_without_accepting_invalid_paths() { + for (cwd, expected) in [ + ("/mnt/d/temp_build/project", Some("/mnt/d/temp_build/project")), + ("/home/hello/项目 with spaces", Some("/home/hello/项目 with spaces")), + ("relative/path", None), + ("/bad\npath", None), + ] { + let metadata = serde_json::json!({"type":"session_meta", "payload": { + "id": ROOT_ID, "cwd": cwd, "source": "cli", "thread_source": "user", + }}); + let output = format!( + "42\t7\t/home/hello/.codex/sessions/rollout-x-{ROOT_ID}.jsonl\t{metadata}\n" + ); + let context = parse_probe_context(&output).unwrap(); + assert_eq!(context.session_id, ROOT_ID); + assert_eq!(context.cwd.as_deref(), expected); + } + } + #[test] fn selects_cli_user_rollout_and_rejects_guardian_thread() { let output = format!( diff --git a/nebula_app/src/product_ui/mod.rs b/nebula_app/src/product_ui/mod.rs index b11f7f7..073586a 100644 --- a/nebula_app/src/product_ui/mod.rs +++ b/nebula_app/src/product_ui/mod.rs @@ -86,12 +86,13 @@ pub(crate) use command_completion::{ nebula_commands_handle, nebula_is_command_position, nebula_path_wants_directory, }; pub(crate) use file_operations::send_to_recycle_bin; +#[cfg(windows)] +pub(crate) use input_state::nebula_input_from_raw_grid; 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, + nebula_input_text, nebula_prompt_line_from_raw_grid, + nebula_shell_prompt_restored_from_raw_grid, nebula_shell_ready_from_raw_grid, }; -#[cfg(windows)] -pub(crate) use input_state::{nebula_input_from_raw_grid, nebula_prompt_line_from_raw_grid}; pub(crate) use network_proxy_model::{ MANUAL_PROXY_PROTOCOL_OPTIONS, ManualProxyProtocol, ProxyTestStatus, manual_proxy_parts, manual_proxy_value, From e7f6a4c7797cdab6b8431366b11f5d2e849070b4 Mon Sep 17 00:00:00 2001 From: kuddev Date: Mon, 14 Sep 2026 09:40:29 +0800 Subject: [PATCH 27/51] fix(settings): clarify the default Chinese terminal font --- nebula_app/i18n/en-US.json | 2 +- nebula_app/i18n/zh-CN.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/nebula_app/i18n/en-US.json b/nebula_app/i18n/en-US.json index 1706b4b..559f534 100644 --- a/nebula_app/i18n/en-US.json +++ b/nebula_app/i18n/en-US.json @@ -85,7 +85,7 @@ "ui_size_description": "Changes interface text and component sizing independently of terminal text.", "english": "English terminal font", "chinese": "Chinese terminal font", - "chinese_description": "Used for Chinese and other full-width characters. Enter a font family or comma-separated families; leave empty to use the English font settings. Applies on Enter or leaving the field.", + "chinese_description": "Used for Chinese and other full-width characters. Enter a font family or comma-separated families; leave empty to use Maple Mono NF CN. Applies on Enter or leaving the field.", "fallback_description": "The first family is preferred; missing glyphs fall back through the remaining families, with bundled Maple as the final safe fallback.", "group_description": "The first family is primary; the rest are used in fallback order.", "loading": "Enumerating system fonts...", diff --git a/nebula_app/i18n/zh-CN.json b/nebula_app/i18n/zh-CN.json index bfea6fc..584fff2 100644 --- a/nebula_app/i18n/zh-CN.json +++ b/nebula_app/i18n/zh-CN.json @@ -85,7 +85,7 @@ "ui_size_description": "独立调整界面文字和组件字号,不影响终端文字。", "english": "英文终端字体", "chinese": "中文终端字体", - "chinese_description": "用于中文等全宽字符。输入字体名称,多个字体以逗号分隔;留空时使用英文字体设置。按回车或离开输入框后生效。", + "chinese_description": "用于中文等全宽字符。输入字体名称,多个字体以逗号分隔;留空时使用 Maple Mono NF CN。按回车或离开输入框后生效。", "fallback_description": "第 1 项优先显示;缺失字形依次使用后续字体,内置 Maple 是最终安全兜底。", "group_description": "第 1 项是主字体,其余按顺序回退", "loading": "正在枚举系统字体…", From d662f2dd7930aa42c0f97f747310dcc330a86c11 Mon Sep 17 00:00:00 2001 From: kuddev Date: Mon, 14 Sep 2026 09:43:13 +0800 Subject: [PATCH 28/51] fix(settings): keep font candidates visible near the window edge --- CHANGELOG.md | 4 +- .../gpui_shell/settings_pane/font_picker.rs | 86 ++++++++++++++----- 2 files changed, 67 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5555e40..fdb7315 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,7 @@ Every release entry is provided in English and Simplified Chinese. #### Fixed -- Aligned the English and Chinese terminal-font fields and kept the current font visible. Both default to the bundled Maple Mono NF CN font; the interface font-family setting has been removed while its text-size control remains. +- Aligned the English and Chinese terminal-font fields, kept the current font visible, and placed font candidates above the field when space below is limited. Both default to the bundled Maple Mono NF CN font; the interface font-family setting has been removed while its text-size control remains. - Preserved the inherited working directory when a bare `pebrel` launch opens a new terminal, including handover to a running Windows instance. - Restored WSL panes in their saved guest directories, and kept queued AI resume commands and identities through shell initialization. Recognized the compact Codex resume screen without requiring another AI message, and cleared the foreground identity after the CLI exits. - Prevented long WSL directory titles and distribution labels from overlapping in sidebar and top tabs. @@ -52,7 +52,7 @@ Every release entry is provided in English and Simplified Chinese. #### 修复 -- 对齐中英文终端字体输入框并显示当前字体,两项默认均采用内置 Maple Mono NF CN;撤掉界面字体设置,保留界面字号。 +- 对齐中英文终端字体输入框并显示当前字体;下方空间不足时,候选列表向上展开。两项默认均采用内置 Maple Mono NF CN;撤掉界面字体设置,保留界面字号。 - 不带目录参数运行 `pebrel` 并创建新终端时,继承实际启动目录;交接给已有 Windows 实例时也保留该目录。 - WSL 窗格恢复到保存的来宾目录;AI 恢复命令与会话身份可跨越 shell 初始化阶段,识别 Codex 的精简恢复界面,无需再发送 AI 消息,CLI 退出后清除前台身份。 - 避免 WSL 长目录标题与发行版名称在侧栏、顶部标签中互相覆盖。 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 448f5cc..263b80d 100644 --- a/nebula_app/src/gpui_shell/settings_pane/font_picker.rs +++ b/nebula_app/src/gpui_shell/settings_pane/font_picker.rs @@ -3,6 +3,7 @@ use super::*; use crate::font_install::{FontSource, REQUIRED_FONT_FAMILY}; const FONT_PICKER_LIST_PREFERRED_HEIGHT: f32 = 220.0; +const FONT_PICKER_PANEL_WIDTH: f32 = 360.0; const FONT_PICKER_PANEL_CHROME_HEIGHT: f32 = 74.0; const FONT_PICKER_OFFSET_Y: f32 = 6.0; const FONT_PICKER_WINDOW_MARGIN: f32 = 8.0; @@ -400,6 +401,7 @@ impl SettingsPane { families.iter().any(|family| family.eq_ignore_ascii_case(&entry.name)); h_flex() .id(SharedString::from(format!("font-available-row-{index}"))) + .debug_selector(move || format!("font-available-{index}")) .h(px(38.0)) .w_full() .min_w_0() @@ -450,7 +452,9 @@ impl SettingsPane { let available_empty = available_rows.is_empty(); v_flex() - // 与输入字段使用同一真实宽度,右缘和左右边界都不会跳变。 + // The menu needs room for family names and ordering actions; the trigger + // remains aligned with the other settings fields. + .debug_selector(|| "font-picker-panel".to_owned()) .w(width) .max_w_full() .p_3() @@ -485,7 +489,7 @@ impl SettingsPane { ) }) .when(!self.font_loading, |panel| { - panel.child(v_flex().h(list_height).min_h_0().overflow_y_scrollbar().child( + panel.child(v_flex().debug_selector(|| "font-picker-list".to_owned()).h(list_height).min_h_0().overflow_y_scrollbar().child( v_flex() .w_full() // gpui-component 的纵向滚动条以 16px 绝对定位覆盖在 @@ -526,7 +530,7 @@ impl SettingsPane { }) } - /// 可编辑字体字段及其同宽延迟弹层;较长内容只在列表内部滚动。 + /// Keep the menu inside the window, preferring the side with room for candidates. pub(super) fn font_picker_dropdown( &mut self, window: &mut Window, @@ -534,27 +538,29 @@ impl SettingsPane { ) -> gpui::Div { let row = self.font_picker_row(cx); let trigger_bounds = self.font_picker_trigger_bounds; - let panel_width = trigger_bounds - .as_ref() - .map(|bounds| bounds.size.width) - .unwrap_or(px(SETTINGS_SELECT_WIDTH)); - let list_height = trigger_bounds + let viewport = window.viewport_size(); + let panel_width = px(FONT_PICKER_PANEL_WIDTH + .min((f32::from(viewport.width) - 2.0 * FONT_PICKER_WINDOW_MARGIN).max(0.0))); + let (open_above, list_height) = trigger_bounds .as_ref() .map(|bounds| { - let viewport_height = f32::from(window.viewport_size().height); - let trigger_bottom = f32::from(bounds.origin.y + bounds.size.height); - let available_below = (viewport_height - - trigger_bottom + let below = (f32::from(viewport.height - bounds.bottom_right().y) - FONT_PICKER_OFFSET_Y - FONT_PICKER_WINDOW_MARGIN) .max(0.0); - - // 头部保持稳定,只压缩候选区。弹层因此不会再因超出窗口 - // 而被整体吸附到输入框上方,候选区仍有明确高度承接滚轮。 - px((available_below - FONT_PICKER_PANEL_CHROME_HEIGHT) - .clamp(0.0, FONT_PICKER_LIST_PREFERRED_HEIGHT)) + let above = + (f32::from(bounds.origin.y) - FONT_PICKER_OFFSET_Y - FONT_PICKER_WINDOW_MARGIN) + .max(0.0); + let preferred = FONT_PICKER_PANEL_CHROME_HEIGHT + FONT_PICKER_LIST_PREFERRED_HEIGHT; + let open_above = below < preferred && above > below; + let available = if open_above { above } else { below }; + ( + open_above, + px((available - FONT_PICKER_PANEL_CHROME_HEIGHT) + .clamp(0.0, FONT_PICKER_LIST_PREFERRED_HEIGHT)), + ) }) - .unwrap_or(px(FONT_PICKER_LIST_PREFERRED_HEIGHT)); + .unwrap_or((false, px(FONT_PICKER_LIST_PREFERRED_HEIGHT))); let panel = self.font_picker_open.then(|| self.font_picker_panel(panel_width, list_height, cx)); @@ -564,9 +570,24 @@ impl SettingsPane { anchor.child( deferred( anchored() - .anchor(gpui::Anchor::TopRight) - .position(trigger_bounds.bottom_right()) - .offset(gpui::point(px(0.0), px(FONT_PICKER_OFFSET_Y))) + .anchor(if open_above { + gpui::Anchor::BottomRight + } else { + gpui::Anchor::TopRight + }) + .position(if open_above { + trigger_bounds.top_right() + } else { + trigger_bounds.bottom_right() + }) + .offset(gpui::point( + px(0.0), + px(if open_above { + -FONT_PICKER_OFFSET_Y + } else { + FONT_PICKER_OFFSET_Y + }), + )) .snap_to_window_with_margin(px(FONT_PICKER_WINDOW_MARGIN)) .child(panel), ) @@ -633,6 +654,29 @@ mod interaction_tests { cx.simulate_click(center, gpui::Modifiers::default()); cx.run_until_parked(); assert!(!pane.read_with(cx, |pane, _| pane.font_picker_open)); + // At the real 144-DPI window's logical height, placing the menu below + // the font field used to hide every available family under its header. + cx.simulate_resize(gpui::size(px(1280.0), px(735.0))); + cx.run_until_parked(); + let bounds = cx.debug_bounds("font-picker-chevron").unwrap(); + cx.simulate_click(bounds.center(), gpui::Modifiers::default()); + cx.run_until_parked(); + cx.update(|window, cx| { + let _ = window.draw(cx); + }); + let trigger = cx.debug_bounds("font-family-input").unwrap(); + let panel = cx.debug_bounds("font-picker-panel").unwrap(); + let list = cx.debug_bounds("font-picker-list").unwrap(); + let candidate = cx.debug_bounds("font-available-0").unwrap(); + assert_eq!(panel.size.width, px(FONT_PICKER_PANEL_WIDTH)); + assert!(panel.bottom_right().y <= trigger.origin.y, "menu opens above a low trigger"); + assert!( + candidate.origin.y >= list.origin.y + && candidate.bottom_right().y <= list.bottom_right().y, + "at least the first available family is fully visible without scrolling" + ); + cx.simulate_click(bounds.center(), gpui::Modifiers::default()); + cx.run_until_parked(); cx.update(|window, cx| { pane.update(cx, |pane, cx| { pane.settings_search_input From e3bc2773acd079482da1311e6a582a83e9812c3f Mon Sep 17 00:00:00 2001 From: kuddev Date: Mon, 14 Sep 2026 09:54:27 +0800 Subject: [PATCH 29/51] test(settings): verify font menu placement after focus scrolling --- .../src/gpui_shell/settings_pane/font_picker.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) 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 263b80d..d05d530 100644 --- a/nebula_app/src/gpui_shell/settings_pane/font_picker.rs +++ b/nebula_app/src/gpui_shell/settings_pane/font_picker.rs @@ -668,12 +668,20 @@ mod interaction_tests { let panel = cx.debug_bounds("font-picker-panel").unwrap(); let list = cx.debug_bounds("font-picker-list").unwrap(); let candidate = cx.debug_bounds("font-available-0").unwrap(); + assert!( + panel.origin.y >= px(0.0) && panel.bottom_right().y <= px(735.0), + "font menu stays within the window: {panel:?}" + ); assert_eq!(panel.size.width, px(FONT_PICKER_PANEL_WIDTH)); - assert!(panel.bottom_right().y <= trigger.origin.y, "menu opens above a low trigger"); + assert!( + panel.bottom_right().y <= trigger.origin.y + || panel.origin.y >= trigger.bottom_right().y, + "menu must not cover the field after focus scrolling: panel={panel:?}, trigger={trigger:?}" + ); assert!( candidate.origin.y >= list.origin.y && candidate.bottom_right().y <= list.bottom_right().y, - "at least the first available family is fully visible without scrolling" + "at least the first available family is fully visible without scrolling: list={list:?}, candidate={candidate:?}, trigger={trigger:?}" ); cx.simulate_click(bounds.center(), gpui::Modifiers::default()); cx.run_until_parked(); From bd0d3c96b5f760c219e948addb3c9d23fb7e44ef Mon Sep 17 00:00:00 2001 From: kuddev Date: Mon, 14 Sep 2026 09:56:07 +0800 Subject: [PATCH 30/51] fix(settings): anchor font menu measurement to the input bounds --- nebula_app/src/gpui_shell/settings_pane/font_picker.rs | 1 + 1 file changed, 1 insertion(+) 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 d05d530..9c20e97 100644 --- a/nebula_app/src/gpui_shell/settings_pane/font_picker.rs +++ b/nebula_app/src/gpui_shell/settings_pane/font_picker.rs @@ -241,6 +241,7 @@ impl SettingsPane { |_, _, _, _| {}, ) .absolute() + .inset_0() .size_full(), ); if self.active_section == 1 { From 0e1dd526ca050c0e38bb471fa0209fd7b4ded16a Mon Sep 17 00:00:00 2001 From: kuddev Date: Mon, 14 Sep 2026 11:40:21 +0800 Subject: [PATCH 31/51] fix(ui): restore monospace type for terminal labels --- CHANGELOG.md | 2 ++ nebula_app/src/gpui_shell/workspace/pane_header.rs | 2 +- nebula_app/src/gpui_shell/workspace/sidebar.rs | 8 ++++---- nebula_app/src/gpui_shell/workspace/top_tabs.rs | 2 +- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fdb7315..d49e175 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ Every release entry is provided in English and Simplified Chinese. #### Fixed - Aligned the English and Chinese terminal-font fields, kept the current font visible, and placed font candidates above the field when space below is limited. Both default to the bundled Maple Mono NF CN font; the interface font-family setting has been removed while its text-size control remains. +- Restored the bundled monospace font for sidebar labels, top tabs, and pane titles while retaining the independent interface text-size setting. - Preserved the inherited working directory when a bare `pebrel` launch opens a new terminal, including handover to a running Windows instance. - Restored WSL panes in their saved guest directories, and kept queued AI resume commands and identities through shell initialization. Recognized the compact Codex resume screen without requiring another AI message, and cleared the foreground identity after the CLI exits. - Prevented long WSL directory titles and distribution labels from overlapping in sidebar and top tabs. @@ -53,6 +54,7 @@ Every release entry is provided in English and Simplified Chinese. #### 修复 - 对齐中英文终端字体输入框并显示当前字体;下方空间不足时,候选列表向上展开。两项默认均采用内置 Maple Mono NF CN;撤掉界面字体设置,保留界面字号。 +- 侧栏标签、顶部标签和窗格标题恢复使用内置等宽字体,同时保留独立的界面字号设置。 - 不带目录参数运行 `pebrel` 并创建新终端时,继承实际启动目录;交接给已有 Windows 实例时也保留该目录。 - WSL 窗格恢复到保存的来宾目录;AI 恢复命令与会话身份可跨越 shell 初始化阶段,识别 Codex 的精简恢复界面,无需再发送 AI 消息,CLI 退出后清除前台身份。 - 避免 WSL 长目录标题与发行版名称在侧栏、顶部标签中互相覆盖。 diff --git a/nebula_app/src/gpui_shell/workspace/pane_header.rs b/nebula_app/src/gpui_shell/workspace/pane_header.rs index acf2402..f5a7c6e 100644 --- a/nebula_app/src/gpui_shell/workspace/pane_header.rs +++ b/nebula_app/src/gpui_shell/workspace/pane_header.rs @@ -258,7 +258,7 @@ impl NebulaWorkspace { let bar_bg = if focused { theme.sidebar_accent.opacity(0.55) } else { theme.muted.opacity(0.28) }; let settings = cx.try_global::(); - let chrome_family = theme.font_family.clone(); + let chrome_family = theme.mono_font_family.clone(); let symbol_family: SharedString = crate::font_install::REQUIRED_FONT_FAMILY.into(); let label_px = settings.map(|settings| settings.ui_font_size_px).unwrap_or(15.0); let title_px = label_px * 0.78; diff --git a/nebula_app/src/gpui_shell/workspace/sidebar.rs b/nebula_app/src/gpui_shell/workspace/sidebar.rs index b27f798..48ecbd4 100644 --- a/nebula_app/src/gpui_shell/workspace/sidebar.rs +++ b/nebula_app/src/gpui_shell/workspace/sidebar.rs @@ -239,14 +239,14 @@ impl NebulaWorkspace { let active_fg = theme.sidebar_accent_foreground; let hover_bg = theme.list_hover; let dark = theme.is_dark(); - // 标题/标签走稳定的 UI 字体;程序图标是 Nerd Font 字位,固定走随 - // 安装包提供的 Maple。用户设置的终端字体不得改变 chrome 几何。 + // 路径标签沿用稳定的等宽字体;程序图标是 Nerd Font 字位,固定走随 + // 安装包提供的 Maple。字号由独立的界面字号设置控制。 let settings = cx.try_global::(); let tab_close_visible = settings.map(|settings| settings.tab_close_visible).unwrap_or(true); let tab_reveal = settings .map(|settings| settings.tab_reveal) .unwrap_or(nebula_settings::TabRevealName::Slide); - let chrome_family = theme.font_family.clone(); + let chrome_family = theme.mono_font_family.clone(); let symbol_family: SharedString = crate::font_install::REQUIRED_FONT_FAMILY.into(); // 界面字号独立于终端缩放。 let label_px = settings.map(|settings| settings.ui_font_size_px).unwrap_or(15.0); @@ -1036,7 +1036,7 @@ impl NebulaWorkspace { let (ink, dim, badge_fill) = (theme.foreground, theme.muted_foreground, theme.muted); let dark = theme.is_dark(); let settings = cx.try_global::(); - let chrome_family = theme.font_family.clone(); + let chrome_family = theme.mono_font_family.clone(); let symbol_family: SharedString = crate::font_install::REQUIRED_FONT_FAMILY.into(); let label_px = settings.map(|settings| settings.ui_font_size_px).unwrap_or(15.0); let TabPresentation { title, logo_image, program_glyph, pane_count, .. } = diff --git a/nebula_app/src/gpui_shell/workspace/top_tabs.rs b/nebula_app/src/gpui_shell/workspace/top_tabs.rs index 0bdd0e7..438e99f 100644 --- a/nebula_app/src/gpui_shell/workspace/top_tabs.rs +++ b/nebula_app/src/gpui_shell/workspace/top_tabs.rs @@ -141,7 +141,7 @@ impl NebulaWorkspace { let tab_reveal = settings .map(|settings| settings.tab_reveal) .unwrap_or(nebula_settings::TabRevealName::Slide); - let chrome_family = theme.font_family.clone(); + let chrome_family = theme.mono_font_family.clone(); let symbol_family: SharedString = crate::font_install::REQUIRED_FONT_FAMILY.into(); let label_px = settings.map(|settings| settings.ui_font_size_px).unwrap_or(15.0); let tab_capacity_w = From bd7bf9c286a265ec11f51cc8f0ac545bad4a21a8 Mon Sep 17 00:00:00 2001 From: kuddev Date: Mon, 14 Sep 2026 12:07:51 +0800 Subject: [PATCH 32/51] feat(agents): recognize CodeBuddy CLI and its color icon --- CHANGELOG.md | 2 + extra/logo/README.md | 21 ++++++ extra/logo/ai_codebuddy.png | Bin 0 -> 141802 bytes extra/logo/ai_codebuddy.svg | 1 + nebula_app/src/agent_detection/codebuddy.toml | 20 +++++ nebula_app/src/ai_agents.rs | 69 +++++++++++++++++- nebula_app/src/display/program_identity.rs | 32 +++++++- 7 files changed, 140 insertions(+), 5 deletions(-) create mode 100644 extra/logo/ai_codebuddy.png create mode 100644 extra/logo/ai_codebuddy.svg create mode 100644 nebula_app/src/agent_detection/codebuddy.toml diff --git a/CHANGELOG.md b/CHANGELOG.md index d49e175..34f5bf5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Every release entry is provided in English and Simplified Chinese. - Added right-click actions to the Shell launcher for choosing the default Shell and, on Windows, opening the selected Shell inside a new administrator Pebrel window. - Added Connect, Edit, and Delete to SSH launcher context menus. Delete uses the existing confirmation and undo flow. - Added Trae CLI recognition and its tab icon, plus a dedicated Oh My Pi icon. +- Added CodeBuddy CLI recognition and its color tab icon, including its official command aliases and the observed Windows/WSL welcome screen. #### Fixed @@ -50,6 +51,7 @@ Every release entry is provided in English and Simplified Chinese. - Shell 启动菜单新增右键操作,可设为默认 Shell;Windows 下还可在新的管理员 Pebrel 窗口内打开所选 Shell。 - SSH 启动菜单新增“连接、编辑、删除”右键操作;删除沿用已有的确认与撤销流程。 - 新增 Trae CLI 识别与标签图标,并为 Oh My Pi 加入独立图标。 +- 新增 CodeBuddy CLI 识别和标签彩色图标,覆盖官方命令别名及已观察到的 Windows/WSL 欢迎界面。 #### 修复 diff --git a/extra/logo/README.md b/extra/logo/README.md index 55d3678..266cf77 100644 --- a/extra/logo/README.md +++ b/extra/logo/README.md @@ -117,6 +117,27 @@ Trae's `trae-cli` executable name is declared by ByteDance's under `[project.scripts]`. Detection and branding do not imply Hook, cold-start, resume, or fork support. +### CodeBuddy Code + +`ai_codebuddy.svg` contains the CodeBuddy color mark from LobeHub's icon +collection, with only a final newline added. It was retrieved on 2026-09-14 +at the following exact revision: + +- Source: https://github.com/lobehub/lobe-icons/blob/3928068a986819bedd4feca1a01a9ba9c3229f3b/packages/static-svg/icons/codebuddy-color.svg +- Collection license: [MIT](LICENSE-lobe-icons), copyright LobeHub. Product names + and trademarks remain with their respective owners; use identifies running + programs and does not imply endorsement. +- PNG SHA-256: `56ACBB748F6B324CD3D052EFD1E76A33811B36872489DF20DCB562A3C6087DE4` + +`ai_codebuddy.png` is a 1024 x 1024 export made with `@resvg/resvg-js` 2.6.2 +and the same rendering settings as Claude and Trae above. Both shells preserve +its colors on light and dark themes. + +The `codebuddy`, `cbc`, `codebuddy-code`, and `codebuddy-lowmem` aliases are +published in [`@tencent-ai/codebuddy-code` 2.150.0](https://registry.npmjs.org/@tencent-ai/codebuddy-code/2.150.0). +The `cbc-prewarm` helper is excluded. Recognition does not add session resume, +fork commands, or AI hooks. + ### Oh My Pi `ai_omp.svg` is a Pebrel-drawn purple gradient π mark following the maintainer's diff --git a/extra/logo/ai_codebuddy.png b/extra/logo/ai_codebuddy.png new file mode 100644 index 0000000000000000000000000000000000000000..51adca9ac146e48f606d38532f735abfc9af2ca1 GIT binary patch literal 141802 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 D?a3%z literal 0 HcmV?d00001 diff --git a/extra/logo/ai_codebuddy.svg b/extra/logo/ai_codebuddy.svg new file mode 100644 index 0000000..04b3e82 --- /dev/null +++ b/extra/logo/ai_codebuddy.svg @@ -0,0 +1 @@ +CodeBuddy diff --git a/nebula_app/src/agent_detection/codebuddy.toml b/nebula_app/src/agent_detection/codebuddy.toml new file mode 100644 index 0000000..e8bb88a --- /dev/null +++ b/nebula_app/src/agent_detection/codebuddy.toml @@ -0,0 +1,20 @@ +# Header and footer transcribed from the user's CodeBuddy Code 2.150.0 +# Windows/WSL screenshot (2026-09-14). This fallback covers hidden guest +# processes; a historical banner followed by a shell prompt is not live chrome. +id = "codebuddy" +aliases = ["cbc", "codebuddy-code", "codebuddy-lowmem"] + +[[identity]] +regex = [ + '\bCodeBuddy Code v[0-9]+\.[0-9]+\.[0-9]+\b', + '(?m)^[ \t]*/agent-mode to switch[^\r\n]*\? for shortcuts[^\r\n]*← for agents[ \t]*\s*\z', +] + +[[rules]] +id = "prompt_footer_idle" +state = "idle" +priority = 100 +region = "bottom_non_empty_lines(4)" +line_regex = ['^[ \t]*>[ \t]*$'] +regex = ['(?m)^[ \t]*/agent-mode to switch[^\r\n]*\? for shortcuts[^\r\n]*← for agents[ \t]*\s*\z'] +not = [{ contains = ["esc to interrupt"] }] diff --git a/nebula_app/src/ai_agents.rs b/nebula_app/src/ai_agents.rs index a8c896c..634f0ad 100644 --- a/nebula_app/src/ai_agents.rs +++ b/nebula_app/src/ai_agents.rs @@ -48,10 +48,11 @@ pub enum AgentKind { Qoder, Maki, Trae, + CodeBuddy, } impl AgentKind { - pub const ALL: [Self; 26] = [ + pub const ALL: [Self; 27] = [ Self::Claude, Self::Codex, Self::Gemini, @@ -78,6 +79,7 @@ impl AgentKind { Self::Qoder, Self::Maki, Self::Trae, + Self::CodeBuddy, ]; pub fn slug(self) -> &'static str { @@ -108,6 +110,7 @@ impl AgentKind { Self::Qoder => "qodercli", Self::Maki => "maki", Self::Trae => "trae-cli", + Self::CodeBuddy => "codebuddy", } } @@ -139,6 +142,7 @@ impl AgentKind { Self::Qoder => "Qoder", Self::Maki => "Maki", Self::Trae => "Trae CLI", + Self::CodeBuddy => "CodeBuddy Code", } } @@ -177,6 +181,8 @@ impl AgentKind { Self::Maki => &["maki"], // ByteDance's trae-agent declares this console entry point. Self::Trae => &["trae-cli"], + // @tencent-ai/codebuddy-code 2.150.0's interactive bin entries. + Self::CodeBuddy => &["codebuddy", "cbc", "codebuddy-code", "codebuddy-lowmem"], } } @@ -225,6 +231,9 @@ impl AgentKind { tokens .filter(|token| !token.starts_with('-')) + // The prewarm helper also lives inside the codebuddy-code package; + // its parent directory alone must not identify it as a live CLI. + .filter(|token| launcher_stem(token) != "cbc-prewarm") .find_map(|token| token.split(['/', '\\']).find_map(Self::parse)) } @@ -257,7 +266,8 @@ impl AgentKind { | Self::Kilo | Self::Qoder | Self::Maki - | Self::Trae => return None, + | Self::Trae + | Self::CodeBuddy => return None, Self::Kimi => format!("kimi --session {session_id}"), }) } @@ -497,6 +507,7 @@ const BUNDLED: &[(AgentKind, &str)] = &[ (AgentKind::Kilo, include_str!("agent_detection/kilo.toml")), (AgentKind::Qoder, include_str!("agent_detection/qodercli.toml")), (AgentKind::Maki, include_str!("agent_detection/maki.toml")), + (AgentKind::CodeBuddy, include_str!("agent_detection/codebuddy.toml")), ]; static CACHE: OnceLock> = OnceLock::new(); @@ -835,6 +846,60 @@ mod tests { assert_eq!(AgentKind::Trae.fork_command("session-1"), None); } + #[test] + fn codebuddy_entry_points_identify_the_cli_without_claiming_its_helper() { + for command in [ + "codebuddy", + "cbc", + "codebuddy-code", + "codebuddy-lowmem", + r"C:\tools\CODEBUDDY.CMD", + "/usr/bin/cbc", + ] { + assert_eq!(AgentKind::parse(command), Some(AgentKind::CodeBuddy), "{command}"); + } + for command in [ + "npx --yes @tencent-ai/codebuddy-code", + "node /opt/node_modules/@tencent-ai/codebuddy-code/dist/codebuddy.js", + "env DEBUG=1 codebuddy --help", + ] { + assert_eq!(AgentKind::parse_command(command), Some(AgentKind::CodeBuddy), "{command}"); + } + for command in [ + "cbc-prewarm", + "codebuddy-helper", + "cat codebuddy.md", + "node /opt/node_modules/@tencent-ai/codebuddy-code/bin/cbc-prewarm", + ] { + assert_eq!(AgentKind::parse_command(command), None, "{command}"); + } + assert_eq!(AgentKind::CodeBuddy.start_command(), None); + assert_eq!(AgentKind::CodeBuddy.resume_command("session-1"), None); + assert_eq!(AgentKind::CodeBuddy.fork_command("session-1"), None); + } + + #[test] + fn codebuddy_screen_identity_requires_brand_and_live_footer() { + // Transcribed from the user's 2.150.0 Windows/WSL screenshot, not a + // runtime capture. Only the observed prompt/footer establishes idle. + let screen = "╭─ CodeBuddy Code v2.150.0 ─╮\nTips for getting started\n\ + ────────────────\n> \n────────────────\n\ + /agent-mode to switch · ? for shortcuts ← for agents"; + assert_eq!(identify(screen), Some(AgentKind::CodeBuddy)); + let idle = detect("codebuddy", screen).unwrap(); + assert_eq!(idle.status, AgentStatus::Idle); + for text in [ + "CodeBuddy Code is a CLI.", + "CodeBuddy Code v2.150.0\nuser@host:~$ ", + "/agent-mode to switch · ? for shortcuts ← for agents", + "> generic prompt\n? for shortcuts ← for agents", + &format!("{screen}\nuser@host:~$ "), + ] { + assert_eq!(identify(text), None, "{text}"); + } + assert!(detect("codebuddy", &format!("{screen}\nuser@host:~$ ")).is_none()); + } + #[test] fn submitted_commands_resolve_agents_across_wsl_launch_forms() { for agent in AgentKind::ALL { diff --git a/nebula_app/src/display/program_identity.rs b/nebula_app/src/display/program_identity.rs index 1fd6dc7..f125932 100644 --- a/nebula_app/src/display/program_identity.rs +++ b/nebula_app/src/display/program_identity.rs @@ -12,10 +12,11 @@ pub enum AiLogo { Antigravity, Trae, OhMyPi, + CodeBuddy, } impl AiLogo { - pub(crate) const ALL: [Self; 8] = [ + pub(crate) const ALL: [Self; 9] = [ Self::Claude, Self::OpenAi, Self::OpenCode, @@ -24,6 +25,7 @@ impl AiLogo { Self::Antigravity, Self::Trae, Self::OhMyPi, + Self::CodeBuddy, ]; /// One source catalog for both shells. Official color assets are embedded unchanged. @@ -38,6 +40,7 @@ impl AiLogo { Self::Antigravity => include_bytes!("../../../extra/logo/ai_antigravity.png"), Self::Trae => include_bytes!("../../../extra/logo/ai_trae.png"), Self::OhMyPi => include_bytes!("../../../extra/logo/ai_omp.png"), + Self::CodeBuddy => include_bytes!("../../../extra/logo/ai_codebuddy.png"), } } @@ -46,7 +49,12 @@ impl AiLogo { Self::OpenAi | Self::Pi => false, // OpenCode stores a luma map: the frame is white, the inner block gray. Self::OpenCode => true, - Self::Claude | Self::Grok | Self::Antigravity | Self::Trae | Self::OhMyPi => return, + Self::Claude + | Self::Grok + | Self::Antigravity + | Self::Trae + | Self::OhMyPi + | Self::CodeBuddy => return, }; for pixel in pixels.chunks_exact_mut(4) { let luma = if preserve_luma { u16::from(pixel[0]) } else { 255 }; @@ -123,6 +131,7 @@ pub(crate) fn ai_logo_for_program(program: &str) -> Option { AgentKind::Antigravity => Some(AiLogo::Antigravity), AgentKind::Trae => Some(AiLogo::Trae), AgentKind::OhMyPi => Some(AiLogo::OhMyPi), + AgentKind::CodeBuddy => Some(AiLogo::CodeBuddy), _ => None, } } @@ -144,6 +153,7 @@ pub(crate) fn program_icon(program: &str) -> &'static str { "aider" | "goose" | "crush" | "ollama" => "\u{f06a9}", "opencode" | "trae-cli" => "\u{f489}", "pi" | "omp" | "oh-my-pi" => "\u{f135}", + "codebuddy" | "cbc" | "codebuddy-code" | "codebuddy-lowmem" => "\u{f06a9}", "git" | "lazygit" => "\u{f418}", "vim" | "nvim" | "vi" | "hx" | "nano" => "\u{e62b}", "ssh" | "mosh" => "\u{f489}", @@ -322,7 +332,7 @@ mod tests { #[test] fn vector_sourced_logos_keep_color_and_antialiased_edges_at_tab_sizes() { - for logo in [AiLogo::Claude, AiLogo::Trae, AiLogo::OhMyPi] { + for logo in [AiLogo::Claude, AiLogo::Trae, AiLogo::OhMyPi, AiLogo::CodeBuddy] { let (width, height, source) = decode_png(logo.png(false)); assert_eq!((width, height), (1024, 1024)); for size in [16, 18, 24, 27, 36, 48] { @@ -349,4 +359,20 @@ mod tests { assert_eq!(logo_for_command(command), Some(AiLogo::OhMyPi)); } } + + #[test] + fn codebuddy_commands_share_the_color_logo_without_matching_helpers() { + for command in [ + "codebuddy", + "cbc", + "codebuddy-code", + "codebuddy-lowmem", + r"C:\tools\CODEBUDDY.CMD --help", + ] { + assert_eq!(logo_for_command(command), Some(AiLogo::CodeBuddy), "{command}"); + } + for command in ["cbc-prewarm", "codebuddy-helper", "cat codebuddy.md"] { + assert_eq!(logo_for_command(command), None, "{command}"); + } + } } From 9875fa5b8dca021b1bad4badd0db21a458df8503 Mon Sep 17 00:00:00 2001 From: kuddev Date: Mon, 14 Sep 2026 12:12:02 +0800 Subject: [PATCH 33/51] test(icons): verify rounded-square edge coverage --- nebula_app/src/display/program_identity.rs | 34 +++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/nebula_app/src/display/program_identity.rs b/nebula_app/src/display/program_identity.rs index f125932..4e4a6ac 100644 --- a/nebula_app/src/display/program_identity.rs +++ b/nebula_app/src/display/program_identity.rs @@ -332,7 +332,7 @@ mod tests { #[test] fn vector_sourced_logos_keep_color_and_antialiased_edges_at_tab_sizes() { - for logo in [AiLogo::Claude, AiLogo::Trae, AiLogo::OhMyPi, AiLogo::CodeBuddy] { + for logo in [AiLogo::Claude, AiLogo::Trae, AiLogo::OhMyPi] { let (width, height, source) = decode_png(logo.png(false)); assert_eq!((width, height), (1024, 1024)); for size in [16, 18, 24, 27, 36, 48] { @@ -375,4 +375,36 @@ mod tests { assert_eq!(logo_for_command(command), None, "{command}"); } } + + #[test] + fn codebuddy_square_logo_smooths_edges_without_recoloring_the_brand() { + let logo = AiLogo::CodeBuddy; + let (width, height, source) = decode_png(logo.png(false)); + assert_eq!((width, height), (1024, 1024)); + let original = image::RgbaImage::from_raw(width, height, source.clone()).unwrap(); + let partial_coverage = + |pixels: &[u8]| pixels.chunks_exact(4).filter(|p| p[3] > 0 && p[3] < 255).count(); + for size in [16, 18, 24, 27, 36, 48] { + let (pixels, width, height) = prepare_ai_logo_texture(&source, width, height, size); + assert_eq!((width, height), (size, size)); + assert!(pixels.chunks_exact(4).any(|p| p[3] == 0)); + assert!(pixels.chunks_exact(4).any(|p| p[3] == 255)); + // A symmetric rounded square has fewer distinct edge alpha levels + // than the asymmetric marks above. Compare actual fractional edge + // coverage with a deliberately aliased nearest-neighbor sample. + let aliased = image::imageops::resize( + &original, + size, + size, + image::imageops::FilterType::Nearest, + ); + assert!(partial_coverage(&pixels) > partial_coverage(aliased.as_raw()), "{size}px"); + assert!(pixels.chunks_exact(4).any(|p| p[3] > 128 && p[2] > p[0].saturating_add(50))); + for ink in [[236, 239, 245], [35, 40, 50]] { + let mut tinted = pixels.clone(); + logo.tint_pixels(&mut tinted, ink); + assert_eq!(tinted, pixels); + } + } + } } From 792771a938d40f0ea4f32e249e794ec57c17feaf Mon Sep 17 00:00:00 2001 From: kuddev Date: Mon, 14 Sep 2026 20:23:56 +0800 Subject: [PATCH 34/51] release: prepare 1.8.0 and parallel native validation --- .github/workflows/linux-lua.yml | 24 ++-- .github/workflows/release.yml | 111 ++++++++++++------ CHANGELOG.md | 57 +++++++-- Cargo.lock | 14 +-- Cargo.toml | 6 +- docs/architecture-decisions.md | 33 ++++++ docs/release-notes/v1.8.0.md | 78 ++++++++++++ nebula-completions/Cargo.toml | 2 +- nebula_app/Cargo.toml | 12 +- nebula_app/windows/nebula.rc | 8 +- nebula_config/Cargo.toml | 4 +- nebula_config_derive/Cargo.toml | 4 +- nebula_settings/Cargo.toml | 2 +- nebula_split/Cargo.toml | 2 +- nebula_terminal/Cargo.toml | 2 +- scripts/build-installer.ps1 | 23 +--- scripts/build-windows-product.ps1 | 36 ++++++ scripts/ci_native_tests.py | 29 +++++ scripts/installer.iss | 6 +- scripts/package-release.ps1 | 28 +---- scripts/tests/build-windows-product.tests.ps1 | 44 +++++++ scripts/tests/package-release.tests.ps1 | 11 +- scripts/tests/test_ci_native_tests.py | 47 ++++++++ scripts/tests/test_stable_release.py | 22 ++-- tools/i18n-contract/Cargo.lock | 2 +- 25 files changed, 442 insertions(+), 165 deletions(-) create mode 100644 docs/release-notes/v1.8.0.md create mode 100644 scripts/build-windows-product.ps1 create mode 100644 scripts/ci_native_tests.py create mode 100644 scripts/tests/build-windows-product.tests.ps1 create mode 100644 scripts/tests/test_ci_native_tests.py diff --git a/.github/workflows/linux-lua.yml b/.github/workflows/linux-lua.yml index 6c7ac89..f7f477b 100644 --- a/.github/workflows/linux-lua.yml +++ b/.github/workflows/linux-lua.yml @@ -34,6 +34,8 @@ jobs: env: PYTHONUTF8: "1" CARGO_TERM_COLOR: always + RUST_BACKTRACE: "1" + RUST_TOOLCHAIN: "1.97.1" steps: - uses: actions/checkout@v4 with: @@ -61,22 +63,12 @@ jobs: with: toolchain: "1.97.1" - uses: Swatinem/rust-cache@v2 - - name: Test native titlebar alignment - if: runner.os == 'macOS' - run: >- - cargo test --locked -p nebula --bin pebrel --features gpui-test-support platform::window_chrome::macos::tests:: - - name: Test all Python helpers - if: ${{ !cancelled() }} - run: python -m unittest discover -s scripts/tests -v - - name: Test all conformance harness helpers - if: ${{ !cancelled() }} - run: python -m unittest discover -s scripts/conformance/tests -v - - name: Test the full Rust workspace - if: ${{ !cancelled() }} - run: cargo test --locked --workspace - - name: Test all GPUI product and interaction cases - if: ${{ !cancelled() }} - run: cargo test --locked -p nebula --bin pebrel --features gpui-test-support + with: + shared-key: ${{ runner.os == 'Windows' && 'windows' || runner.os == 'Linux' && 'linux' || 'macos' }} + cache-workspace-crates: true + cache-on-failure: true + - name: Test complete workspace and native harness + run: python scripts/ci_native_tests.py - name: Check source architecture if: ${{ !cancelled() && runner.os == 'Linux' }} run: python scripts/check_architecture.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fddf61c..4d89fb2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -106,6 +106,7 @@ jobs: python3 scripts/check_platform_cfg.py python3 -m unittest \ scripts.tests.test_stable_release \ + scripts.tests.test_ci_native_tests \ scripts.tests.test_preview_release \ scripts.tests.test_macos_dmg \ scripts.tests.test_ai_session_probe \ @@ -118,6 +119,66 @@ jobs: bash -n scripts/package-linux.sh scripts/package-macos.sh python3 -m py_compile scripts/stable_release.py scripts/preview_release.py + native-tests: + name: Native tests (${{ matrix.platform }}) + if: ${{ !inputs.windows_only }} + needs: prepare + timeout-minutes: 120 + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-22.04 + platform: linux-x64 + cache: linux + - runner: windows-2022 + platform: windows-x64 + cache: windows + - runner: macos-26 + platform: macos-arm64 + cache: macos + - runner: macos-26-intel + platform: macos-x64 + cache: macos + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - name: Install native test dependencies + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + build-essential cmake pkg-config zsh libasound2-dev libdbus-1-dev libudev-dev \ + libfontconfig1-dev libfreetype6-dev libegl1-mesa-dev libgl1-mesa-dev \ + libwayland-dev libxkbcommon-dev libxkbcommon-x11-dev libx11-xcb-dev \ + libxcb1-dev libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev \ + libxcb-xinput-dev libxcb-xkb-dev libxi-dev + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: ${{ env.RUST_TOOLCHAIN }} + - uses: Swatinem/rust-cache@v2 + with: + shared-key: ${{ matrix.cache }} + cache-workspace-crates: true + cache-on-failure: true + - name: Prepare pinned Windows console runtime + if: runner.os == 'Windows' + shell: pwsh + run: ./scripts/prepare-windows-runtime.ps1 -Destination assets/windows/conhost + - name: Test complete workspace and native harness + run: python scripts/ci_native_tests.py + - name: Upload native compilation timings + if: always() + uses: actions/upload-artifact@v4 + with: + name: native-timings-${{ matrix.platform }} + path: target/cargo-timings + if-no-files-found: warn + retention-days: 14 + linux: name: Linux x86_64 stable package if: ${{ !inputs.windows_only }} @@ -151,15 +212,9 @@ jobs: - 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 - cargo test --locked -p nebula --bin pebrel --features gpui-test-support gpui_shell:: + cache-workspace-crates: true - name: Build GPUI release binary - run: cargo build --locked --release -p nebula --bin pebrel --features gpui-shell + run: cargo build --locked --release -p nebula --bin pebrel --features gpui-shell --timings - name: Download pinned linuxdeploy env: LINUXDEPLOY: ${{ runner.temp }}/linuxdeploy-x86_64.AppImage @@ -284,15 +339,9 @@ jobs: - 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 - cargo test --locked -p nebula --bin pebrel --features gpui-test-support gpui_shell:: + cache-workspace-crates: true - name: Build GPUI release binary - run: cargo build --locked --release -p nebula --bin pebrel --features gpui-shell + run: cargo build --locked --release -p nebula --bin pebrel --features gpui-shell --timings - name: Import Developer ID signing credentials if: ${{ env.MACOS_SIGNING == 'developer-id' }} env: @@ -422,9 +471,9 @@ jobs: $run = Invoke-RestMethod -Uri "https://api.github.com/repos/$env:GITHUB_REPOSITORY/actions/runs/$env:VALIDATED_RUN" -Headers $headers -ErrorAction Stop if ($run.head_sha -ne $env:SELECTED_COMMIT) { throw 'Validated run source differs.' } $jobs = Invoke-RestMethod -Uri "https://api.github.com/repos/$env:GITHUB_REPOSITORY/actions/runs/$env:VALIDATED_RUN/jobs?per_page=100" -Headers $headers -ErrorAction Stop - $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.' } + $windows = @($jobs.jobs | Where-Object { $_.name -in @('Windows x64 stable package', 'Native tests (windows-x64)') }) + $tests = @($windows.steps | Where-Object { $_.name -in @('Test workspace and native harness', 'Test complete workspace and native harness') -and $_.conclusion -eq 'success' }) + if ($tests.Count -ne 1) { throw 'Prior Windows native tests did not pass.' } - uses: actions/checkout@v4 if: ${{ inputs.source_commit != '' }} with: @@ -438,32 +487,18 @@ jobs: - uses: Swatinem/rust-cache@v2 with: cache-on-failure: true + cache-workspace-crates: 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 == '' }} - shell: pwsh - run: | - python -m unittest scripts.conformance.tests.test_lifecycle scripts.conformance.tests.test_scrollback - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - cargo test --locked --workspace - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - cargo test --locked -p nebula --bin pebrel --features gpui-shell - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - cargo test --locked -p nebula --features gpui-test-support dialog - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - cargo test --locked -p nebula --bin pebrel --features gpui-test-support gpui_shell:: - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + if: ${{ inputs.windows_only && inputs.source_commit == '' }} + run: python scripts/ci_native_tests.py - name: Build GPUI release binary 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 + run: ./scripts/build-windows-product.ps1 -Configuration release -TargetDirectory target - name: Retain built Windows runtime before packaging uses: actions/upload-artifact@v4 with: @@ -519,7 +554,7 @@ jobs: aggregate: name: Verify stable assets and upload complete set if: ${{ !inputs.windows_only }} - needs: [prepare, linux, macos, windows] + needs: [prepare, native-tests, linux, macos, windows] runs-on: ubuntu-22.04 env: PACKAGE_VERSION: ${{ needs.prepare.outputs.version }} diff --git a/CHANGELOG.md b/CHANGELOG.md index a7cad15..499770b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ Every release entry is provided in English and Simplified Chinese. 每个版本条目均同时提供英文和简体中文说明。 -## Unreleased / 未发布 +## 1.8.0 - 2026-09-14 ### English @@ -20,29 +20,68 @@ Every release entry is provided in English and Simplified Chinese. - Fixed Windows terminal focus consuming `Alt+F4` and `Alt+Space`. `Alt+F4` now uses the normal window-close flow, and `Alt+Space`, followed by `N`, uses the system menu to minimize the window. - Fixed the top Settings tab disappearing when switching to another tab. It now remains available until explicitly closed, including when tabs overflow or the tab layout changes. - Fixed `Alt+1–9` and `Ctrl+1–9` tab shortcuts losing priority to terminal input. They now work while a terminal or CLI has focus and respect custom shortcut changes without restarting. +- Fixed WSL filename search failing when the directory path contains spaces or shell punctuation. Cancelling a search stops its own directory scan. +- Aligned macOS native window controls with the sidebar and Settings buttons, and preserved their reserved space in the top tab layout. Window controls follow the system appearance. Contributed by [@WilliamWang1721](https://github.com/WilliamWang1721) in [#114](https://github.com/Kuddev/pebrel/pull/114). +- Fixed the Git drawer retaining Chinese labels when the application resolves to English. Controls, history timestamps and application notices follow the selected language; switching languages preserves the commit draft and text selection. Contributed by [@gao-jian-bin](https://github.com/gao-jian-bin) in [#118](https://github.com/Kuddev/pebrel/pull/118). +- Fixed explicitly configured terminal selection text colors being ignored. Themes without their own selection foreground preserve that setting; themes with an explicit foreground retain their precedence. Contributed by [@Aschenbath](https://github.com/Aschenbath) in [#125](https://github.com/Kuddev/pebrel/pull/125). #### Improved +- Reduced file browsing and filename-search memory use by starting recursive searches only when a query is entered, reusing a bounded cache and streaming paths beyond that cache. Repeated panel use releases obsolete results and cancels superseded searches. Case, whole-word and regular-expression options remain available, with visible feedback for incomplete searches and errors. +- Reduced background-image memory use with bounded image loading and reuse while resizing the window. Replacing an image releases the previous resources; extended backgrounds retain their fit, alignment and text readability. - Replaced the low-resolution Claude Code icon with artwork exported from SVG at 1024 pixels, and prepared Agent icons at their display size for smoother tab edges. +Windows packages include a Pebrel installer and portable ZIP. Linux x64 packages and macOS Apple Silicon/Intel DMGs retain their Preview designation. macOS requires version 14 or later. Molecular structure rendering remains disabled. + ### 中文 #### 新增 -- 在“设置 → 交互”中加入“焦点跟随鼠标”,默认关闭;在“设置 → 外观”中加入“调暗非活动窗格”,默认开启。两项设置均即时生效。 -- Shell 启动菜单新增右键操作,可设为默认 Shell;Windows 下还可在新的管理员 Pebrel 窗口内打开所选 Shell。 -- SSH 启动菜单新增“连接、编辑、删除”右键操作;删除沿用已有的确认与撤销流程。 -- 新增 Trae CLI 识别与标签图标,并为 Oh My Pi 加入独立图标。 +- 新增:在“设置 → 交互”中加入“焦点跟随鼠标”,默认关闭;在“设置 → 外观”中加入“调暗非活动窗格”,默认开启。两项设置均即时生效。 +- 新增:Shell 启动菜单新增右键操作,可设为默认 Shell;Windows 下还可在新的管理员 Pebrel 窗口内打开所选 Shell。 +- 新增:SSH 启动菜单新增“连接、编辑、删除”右键操作;删除沿用已有的确认与撤销流程。 +- 新增: Trae CLI 识别与标签图标,并为 Oh My Pi 加入独立图标。 #### 修复 -- 修复 Windows 终端获得焦点时吞掉 `Alt+F4` 和 `Alt+Space` 的问题。`Alt+F4` 现在进入正常窗口关闭流程,`Alt+Space` 后按 `N` 可通过系统菜单最小化窗口。 -- 修复顶部设置标签在切换到其他标签后消失的问题。现在只有主动关闭才会移除,标签溢出或切换标签布局时也会保留。 -- 修复 `Alt+1–9` 和 `Ctrl+1–9` 标签快捷键优先级低于终端输入的问题。终端或 CLI 获得焦点时仍可切换标签,自定义快捷键的修改也无需重启即可生效。 +- 修复: Windows 终端获得焦点时吞掉 `Alt+F4` 和 `Alt+Space` 的问题。`Alt+F4` 现在进入正常窗口关闭流程,`Alt+Space` 后按 `N` 可通过系统菜单最小化窗口。 +- 修复:顶部设置标签在切换到其他标签后消失的问题。现在只有主动关闭才会移除,标签溢出或切换标签布局时也会保留。 +- 修复: `Alt+1–9` 和 `Ctrl+1–9` 标签快捷键优先级低于终端输入的问题。终端或 CLI 获得焦点时仍可切换标签,自定义快捷键的修改也无需重启即可生效。 +- 修复:目录路径含空格或 Shell 特殊符号时 WSL 文件名搜索失败的问题;取消搜索会停止该次目录扫描。 +- 修复:对齐 macOS 原生窗口按钮与侧栏、设置按钮,并在顶部标签布局中保留窗口按钮所需空间;窗口按钮外观跟随系统。由 [@WilliamWang1721](https://github.com/WilliamWang1721) 在 [#114](https://github.com/Kuddev/pebrel/pull/114) 中贡献。 +- 修复:应用解析为英文时 Git 面板仍保留中文标签的问题。控件、历史时间和应用提示跟随所选语言,切换语言时保留提交草稿和文字选区。由 [@gao-jian-bin](https://github.com/gao-jian-bin) 在 [#118](https://github.com/Kuddev/pebrel/pull/118) 中贡献。 +- 修复:终端忽略用户明确设置的选中文字颜色的问题。未指定选中文字颜色的主题会保留该设置;已明确指定的主题仍保持自身优先级。由 [@Aschenbath](https://github.com/Aschenbath) 在 [#125](https://github.com/Kuddev/pebrel/pull/125) 中贡献。 #### 改进 -- 将 Claude Code 的低分辨率图标替换为从 SVG 导出的 1024 像素图像,并按显示尺寸处理 Agent 图标,使标签图标边缘更平滑。 +- 改进:降低文件浏览与文件名搜索的内存占用:输入关键词后才开始递归搜索,复用有容量上限的缓存,并流式查找缓存之外的路径。重复使用文件面板时释放过期结果、取消已被替代的搜索;保留大小写、整词和正则选项,并明确提示搜索未覆盖全部文件或发生错误。 +- 改进:降低背景图内存占用,限制图片加载成本,并在调整窗口大小时复用图像资源;替换背景图会释放旧资源,扩展背景仍保持原有的适配、对齐和文字可读性。 +- 改进:将 Claude Code 的低分辨率图标替换为从 SVG 导出的 1024 像素图像,并按显示尺寸处理 Agent 图标,使标签图标边缘更平滑。 + +Windows 提供 Pebrel 安装器和 ZIP 便携包;Linux x64 包以及 macOS Apple Silicon/Intel DMG 继续标记为 Preview。macOS 最低运行版本为 14。分子结构渲染仍处于禁用状态。 + +### Contributors + +@WilliamWang1721 avatar@gao-jian-bin avatar@Aschenbath avatar + +**[@WilliamWang1721](https://github.com/WilliamWang1721)** - Aligned macOS native window controls and expanded native test coverage. / 对齐 macOS 原生窗口按钮,并扩展原生测试覆盖。([#114](https://github.com/Kuddev/pebrel/pull/114)) + +**[@gao-jian-bin](https://github.com/gao-jian-bin)** - Made Git drawer language follow application preferences while preserving editing state. / 让 Git 面板语言跟随应用设置,同时保留编辑状态。([#118](https://github.com/Kuddev/pebrel/pull/118)) + +**[@Aschenbath](https://github.com/Aschenbath)** - Preserved explicit terminal selection foreground colors. / 修正终端选中文字颜色的配置与主题优先级。([#125](https://github.com/Kuddev/pebrel/pull/125)) + + +--- + +**SHA256** + +- 改进:`Pebrel-v1.8.0-windows-x64.zip`: `PENDING FINAL BUILD` +- 改进:`Pebrel-v1.8.0-windows-x64-setup.exe`: `PENDING FINAL BUILD` +- 改进:`Pebrel-v1.8.0-linux-x64-preview.AppImage`: `PENDING FINAL BUILD` +- 改进:`Pebrel-v1.8.0-linux-x64-preview.deb`: `PENDING FINAL BUILD` +- 改进:`Pebrel-v1.8.0-linux-x64-preview.tar.gz`: `PENDING FINAL BUILD` +- 改进:`Pebrel-v1.8.0-macos-arm64-preview.dmg`: `PENDING FINAL BUILD` +- 改进:`Pebrel-v1.8.0-macos-x64-preview.dmg`: `PENDING FINAL BUILD` ## 1.7.0 - 2026-09-12 diff --git a/Cargo.lock b/Cargo.lock index 32399d6..a2c7305 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5087,7 +5087,7 @@ dependencies = [ [[package]] name = "nebula" -version = "1.7.0" +version = "1.8.0" dependencies = [ "ab_glyph_rasterizer", "aes-gcm", @@ -5174,7 +5174,7 @@ dependencies = [ [[package]] name = "nebula-completions" -version = "1.7.0" +version = "1.8.0" dependencies = [ "lscolors", "nu-ansi-term", @@ -5195,15 +5195,15 @@ dependencies = [ [[package]] name = "nebula-settings" -version = "1.7.0" +version = "1.8.0" [[package]] name = "nebula-split" -version = "1.7.0" +version = "1.8.0" [[package]] name = "nebula_config" -version = "1.7.0" +version = "1.8.0" dependencies = [ "log", "nebula_config_derive", @@ -5213,7 +5213,7 @@ dependencies = [ [[package]] name = "nebula_config_derive" -version = "1.7.0" +version = "1.8.0" dependencies = [ "log", "nebula_config", @@ -5230,7 +5230,7 @@ version = "0.6.0" [[package]] name = "nebula_terminal" -version = "1.7.0" +version = "1.8.0" dependencies = [ "base64", "bitflags 2.13.0", diff --git a/Cargo.toml b/Cargo.toml index 454a063..d98a66b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,8 +17,8 @@ edition = "2024" rust-version = "1.97.1" [profile.release] -# fat LTO 会用顶层 opt-level 重跑全程序 codegen,把下面的热路径 O3 钉子 -# 冲掉;thin + codegen-units=1 保住钉子,体积差距只有个位数百分点。 +# Thin LTO preserves package-specific optimization levels. Dependencies retain +# one codegen unit; the application uses parallel codegen with the same O3 level. lto = "thin" debug = 0 strip = "symbols" @@ -38,7 +38,7 @@ smol = { opt-level = 3 } gpui = { opt-level = 3 } gpui-component = { opt-level = 3 } nebula_terminal = { opt-level = 3 } -nebula = { opt-level = 3 } +nebula = { opt-level = 3, codegen-units = 16 } image = { opt-level = 3 } # Debug 壳仍要承担真实终端负载:GPUI 布局/字体塑形和 VT 解析若以 diff --git a/docs/architecture-decisions.md b/docs/architecture-decisions.md index bf67bb8..c9b7ca4 100644 --- a/docs/architecture-decisions.md +++ b/docs/architecture-decisions.md @@ -420,3 +420,36 @@ settings files. - **Replacement condition:** Revisit the budgets or an OS-backed index only with measured query latency, completeness and sustained allocation evidence. Do not restore eager full-tree indexing to improve a synthetic latency number. + +## ADR-0014 — Parallel release validation with one product build graph + +- **Date:** 2026-09-14 +- **Context:** The 1.7.0 Windows release job took 41m28s despite a full dependency + cache hit. Its logs show three application test compilations (4m44s, 9m16s and + 4m07s), a 15m08s release build, and 4m16s of additional dependency compilation + during packaging. The user requested a slowest-job target of ten minutes. +- **Decision:** Run the complete Rust workspace with the product interaction + feature enabled in one unfiltered invocation. Run native tests independently + from package construction; asset aggregation depends on both. Each native + platform still runs the complete Python helper and harness suites. Cache keys + preserve the existing platform namespace and retain workspace crates. Cargo + still validates source, profile and feature fingerprints before reuse. +- **Product compilation:** The application keeps O3 and Thin LTO and uses 16 + codegen units to parallelize its large translation unit. Other package settings + retain their existing values. Both Windows packagers call one explicit builder + for the product and its packaged hook, preserving the same feature graph. + Packaging still invokes Cargo and validates source freshness and binary identity. +- **Contract clarification:** The old packaging test required the literal + `--workspace --exclude nebula`, rejecting a valid explicit selection of the two + shipped binaries. It now verifies the actual selected packages, binaries and + product feature, plus failure propagation and restoration of the caller's target. + No test filtering, freshness bypass, size-budget increase or dependency change + is part of this decision. +- **Validation:** The release pipeline runs native tests, packaging fixtures, + package-size/identity checks and installed or mounted conformance on all four + platforms. Actual job timings determine whether the target is met; cache input + changes and first compilation must be reported separately. A configured timeout + or parallel scheduling alone is not evidence of a ten-minute successful build. +- **Revisit condition:** Retain only changes whose complete CI run and package + checks pass. Reconsider codegen partitioning if artifact size or runtime + measurements regress, and remove redundant caches if restore/save cost grows. diff --git a/docs/release-notes/v1.8.0.md b/docs/release-notes/v1.8.0.md new file mode 100644 index 0000000..c402f79 --- /dev/null +++ b/docs/release-notes/v1.8.0.md @@ -0,0 +1,78 @@ +# Pebrel 1.8.0 + +## English + +### Added + +- Added “Focus follows mouse” in Settings → Interaction, off by default, and “Dim inactive panes” in Settings → Appearance, on by default. Both preferences take effect immediately. +- Added right-click actions to the Shell launcher for choosing the default Shell and, on Windows, opening the selected Shell inside a new administrator Pebrel window. +- Added Connect, Edit, and Delete to SSH launcher context menus. Delete uses the existing confirmation and undo flow. +- Added Trae CLI recognition and its tab icon, plus a dedicated Oh My Pi icon. + +### Fixed + +- Fixed Windows terminal focus consuming `Alt+F4` and `Alt+Space`. `Alt+F4` now uses the normal window-close flow, and `Alt+Space`, followed by `N`, uses the system menu to minimize the window. +- Fixed the top Settings tab disappearing when switching to another tab. It now remains available until explicitly closed, including when tabs overflow or the tab layout changes. +- Fixed `Alt+1–9` and `Ctrl+1–9` tab shortcuts losing priority to terminal input. They now work while a terminal or CLI has focus and respect custom shortcut changes without restarting. +- Fixed WSL filename search failing when the directory path contains spaces or shell punctuation. Cancelling a search stops its own directory scan. +- Aligned macOS native window controls with the sidebar and Settings buttons, and preserved their reserved space in the top tab layout. Window controls follow the system appearance. Contributed by [@WilliamWang1721](https://github.com/WilliamWang1721) in [#114](https://github.com/Kuddev/pebrel/pull/114). +- Fixed the Git drawer retaining Chinese labels when the application resolves to English. Controls, history timestamps and application notices follow the selected language; switching languages preserves the commit draft and text selection. Contributed by [@gao-jian-bin](https://github.com/gao-jian-bin) in [#118](https://github.com/Kuddev/pebrel/pull/118). +- Fixed explicitly configured terminal selection text colors being ignored. Themes without their own selection foreground preserve that setting; themes with an explicit foreground retain their precedence. Contributed by [@Aschenbath](https://github.com/Aschenbath) in [#125](https://github.com/Kuddev/pebrel/pull/125). + +### Improved + +- Reduced file browsing and filename-search memory use by starting recursive searches only when a query is entered, reusing a bounded cache and streaming paths beyond that cache. Repeated panel use releases obsolete results and cancels superseded searches. Case, whole-word and regular-expression options remain available, with visible feedback for incomplete searches and errors. +- Reduced background-image memory use with bounded image loading and reuse while resizing the window. Replacing an image releases the previous resources; extended backgrounds retain their fit, alignment and text readability. +- Replaced the low-resolution Claude Code icon with artwork exported from SVG at 1024 pixels, and prepared Agent icons at their display size for smoother tab edges. + +Windows packages include a Pebrel installer and portable ZIP. Linux x64 packages and macOS Apple Silicon/Intel DMGs retain their Preview designation. macOS requires version 14 or later. Molecular structure rendering remains disabled. + +## 中文 + +### 新增 + +- 新增:在“设置 → 交互”中加入“焦点跟随鼠标”,默认关闭;在“设置 → 外观”中加入“调暗非活动窗格”,默认开启。两项设置均即时生效。 +- 新增:Shell 启动菜单新增右键操作,可设为默认 Shell;Windows 下还可在新的管理员 Pebrel 窗口内打开所选 Shell。 +- 新增:SSH 启动菜单新增“连接、编辑、删除”右键操作;删除沿用已有的确认与撤销流程。 +- 新增: Trae CLI 识别与标签图标,并为 Oh My Pi 加入独立图标。 + +### 修复 + +- 修复: Windows 终端获得焦点时吞掉 `Alt+F4` 和 `Alt+Space` 的问题。`Alt+F4` 现在进入正常窗口关闭流程,`Alt+Space` 后按 `N` 可通过系统菜单最小化窗口。 +- 修复:顶部设置标签在切换到其他标签后消失的问题。现在只有主动关闭才会移除,标签溢出或切换标签布局时也会保留。 +- 修复: `Alt+1–9` 和 `Ctrl+1–9` 标签快捷键优先级低于终端输入的问题。终端或 CLI 获得焦点时仍可切换标签,自定义快捷键的修改也无需重启即可生效。 +- 修复:目录路径含空格或 Shell 特殊符号时 WSL 文件名搜索失败的问题;取消搜索会停止该次目录扫描。 +- 修复:对齐 macOS 原生窗口按钮与侧栏、设置按钮,并在顶部标签布局中保留窗口按钮所需空间;窗口按钮外观跟随系统。由 [@WilliamWang1721](https://github.com/WilliamWang1721) 在 [#114](https://github.com/Kuddev/pebrel/pull/114) 中贡献。 +- 修复:应用解析为英文时 Git 面板仍保留中文标签的问题。控件、历史时间和应用提示跟随所选语言,切换语言时保留提交草稿和文字选区。由 [@gao-jian-bin](https://github.com/gao-jian-bin) 在 [#118](https://github.com/Kuddev/pebrel/pull/118) 中贡献。 +- 修复:终端忽略用户明确设置的选中文字颜色的问题。未指定选中文字颜色的主题会保留该设置;已明确指定的主题仍保持自身优先级。由 [@Aschenbath](https://github.com/Aschenbath) 在 [#125](https://github.com/Kuddev/pebrel/pull/125) 中贡献。 + +### 改进 + +- 改进:降低文件浏览与文件名搜索的内存占用:输入关键词后才开始递归搜索,复用有容量上限的缓存,并流式查找缓存之外的路径。重复使用文件面板时释放过期结果、取消已被替代的搜索;保留大小写、整词和正则选项,并明确提示搜索未覆盖全部文件或发生错误。 +- 改进:降低背景图内存占用,限制图片加载成本,并在调整窗口大小时复用图像资源;替换背景图会释放旧资源,扩展背景仍保持原有的适配、对齐和文字可读性。 +- 改进:将 Claude Code 的低分辨率图标替换为从 SVG 导出的 1024 像素图像,并按显示尺寸处理 Agent 图标,使标签图标边缘更平滑。 + +Windows 提供 Pebrel 安装器和 ZIP 便携包;Linux x64 包以及 macOS Apple Silicon/Intel DMG 继续标记为 Preview。macOS 最低运行版本为 14。分子结构渲染仍处于禁用状态。 + +## Contributors + +@WilliamWang1721 avatar@gao-jian-bin avatar@Aschenbath avatar + +**[@WilliamWang1721](https://github.com/WilliamWang1721)** - Aligned macOS native window controls and expanded native test coverage. / 对齐 macOS 原生窗口按钮,并扩展原生测试覆盖。([#114](https://github.com/Kuddev/pebrel/pull/114)) + +**[@gao-jian-bin](https://github.com/gao-jian-bin)** - Made Git drawer language follow application preferences while preserving editing state. / 让 Git 面板语言跟随应用设置,同时保留编辑状态。([#118](https://github.com/Kuddev/pebrel/pull/118)) + +**[@Aschenbath](https://github.com/Aschenbath)** - Preserved explicit terminal selection foreground colors. / 修正终端选中文字颜色的配置与主题优先级。([#125](https://github.com/Kuddev/pebrel/pull/125)) + + +--- + +**SHA256** + +- `Pebrel-v1.8.0-windows-x64.zip`: `PENDING FINAL BUILD` +- `Pebrel-v1.8.0-windows-x64-setup.exe`: `PENDING FINAL BUILD` +- `Pebrel-v1.8.0-linux-x64-preview.AppImage`: `PENDING FINAL BUILD` +- `Pebrel-v1.8.0-linux-x64-preview.deb`: `PENDING FINAL BUILD` +- `Pebrel-v1.8.0-linux-x64-preview.tar.gz`: `PENDING FINAL BUILD` +- `Pebrel-v1.8.0-macos-arm64-preview.dmg`: `PENDING FINAL BUILD` +- `Pebrel-v1.8.0-macos-x64-preview.dmg`: `PENDING FINAL BUILD` diff --git a/nebula-completions/Cargo.toml b/nebula-completions/Cargo.toml index 75e2541..d550f62 100644 --- a/nebula-completions/Cargo.toml +++ b/nebula-completions/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nebula-completions" -version = "1.7.0" +version = "1.8.0" edition.workspace = true [features] diff --git a/nebula_app/Cargo.toml b/nebula_app/Cargo.toml index 451a293..93a8151 100644 --- a/nebula_app/Cargo.toml +++ b/nebula_app/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nebula" -version = "1.7.0" +version = "1.8.0" license = "GPL-3.0-or-later" description = "Pebrel — a GPU-accelerated terminal emulator" readme = "README.md" @@ -15,15 +15,15 @@ path = "src/main.rs" [dependencies.nebula_terminal] path = "../nebula_terminal" -version = "1.7.0" +version = "1.8.0" [dependencies.nebula_config_derive] path = "../nebula_config_derive" -version = "1.7.0" +version = "1.8.0" [dependencies.nebula_config] path = "../nebula_config" -version = "1.7.0" +version = "1.8.0" # 可选:GPUI UI 层(feature = "gpui-shell",模块 src/gpui_shell/)。 # GPUI/component 的版本和 Git 来源由根 Cargo.toml 统一精确固定。 @@ -67,7 +67,7 @@ optional = true [dependencies.nebula-completions] path = "../nebula-completions" -version = "1.7.0" +version = "1.8.0" [dependencies] ahash = { version = "0.8.6", features = ["no-rng"] } @@ -141,7 +141,7 @@ rusqlite = { version = "0.40.2", features = ["bundled"] } sha1 = "0.11.0" [build-dependencies] -nebula-settings = { path = "../nebula_settings", version = "1.7.0" } +nebula-settings = { path = "../nebula_settings", version = "1.8.0" } gl_generator = { version = "0.14.0", optional = true } serde = "1" serde_json = "1" diff --git a/nebula_app/windows/nebula.rc b/nebula_app/windows/nebula.rc index e167b42..5ed706b 100644 --- a/nebula_app/windows/nebula.rc +++ b/nebula_app/windows/nebula.rc @@ -10,8 +10,8 @@ APP_MANIFEST RT_MANIFEST nebula.manifest #endif 1 VERSIONINFO -FILEVERSION 1,7,0,0 -PRODUCTVERSION 1,7,0,0 +FILEVERSION 1,8,0,0 +PRODUCTVERSION 1,8,0,0 FILEFLAGSMASK 0x3fL FILEFLAGS 0x0L FILEOS 0x40004L @@ -24,11 +24,11 @@ BEGIN BEGIN VALUE "CompanyName", "Kuddev" VALUE "FileDescription", "Pebrel" - VALUE "FileVersion", "1.7.0" + VALUE "FileVersion", "1.8.0" VALUE "InternalName", "nebula" VALUE "OriginalFilename", "pebrel.exe" VALUE "ProductName", "Pebrel" - VALUE "ProductVersion", "1.7.0" + VALUE "ProductVersion", "1.8.0" END END BLOCK "VarFileInfo" diff --git a/nebula_config/Cargo.toml b/nebula_config/Cargo.toml index dbd27a6..dc15094 100644 --- a/nebula_config/Cargo.toml +++ b/nebula_config/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nebula_config" -version = "1.7.0" +version = "1.8.0" license = "GPL-3.0-or-later" description = "Nebula configuration abstractions" homepage = "https://github.com/Kuddev/pebrel" @@ -14,5 +14,5 @@ serde = "1.0.163" toml.workspace = true [dev-dependencies] -nebula_config_derive = { version = "1.7.0", path = "../nebula_config_derive" } +nebula_config_derive = { version = "1.8.0", path = "../nebula_config_derive" } serde = { version = "1.0.163", features = ["derive"] } diff --git a/nebula_config_derive/Cargo.toml b/nebula_config_derive/Cargo.toml index fc5d038..2828977 100644 --- a/nebula_config_derive/Cargo.toml +++ b/nebula_config_derive/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nebula_config_derive" -version = "1.7.0" +version = "1.8.0" license = "GPL-3.0-or-later" description = "Failure resistant deserialization derive" homepage = "https://github.com/Kuddev/pebrel" @@ -18,7 +18,7 @@ syn = { version = "2.0.16", features = ["derive", "parsing", "proc-macro", "prin [dev-dependencies.nebula_config] path = "../nebula_config" -version = "1.7.0" +version = "1.8.0" [dev-dependencies] log = "0.4.11" diff --git a/nebula_settings/Cargo.toml b/nebula_settings/Cargo.toml index 43cfcce..c7c29f3 100644 --- a/nebula_settings/Cargo.toml +++ b/nebula_settings/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nebula-settings" -version = "1.7.0" +version = "1.8.0" license = "GPL-3.0-or-later" description = "Shared reader for Nebula runtime user settings (nebula_settings.txt) and theme terminal-color tables" publish = false diff --git a/nebula_split/Cargo.toml b/nebula_split/Cargo.toml index b1fa368..f57f9fe 100644 --- a/nebula_split/Cargo.toml +++ b/nebula_split/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nebula-split" -version = "1.7.0" +version = "1.8.0" license = "GPL-3.0-or-later" description = "Shared split-pane rules: layout tree, geometry, divider drag and directional focus" publish = false diff --git a/nebula_terminal/Cargo.toml b/nebula_terminal/Cargo.toml index 8a40565..b712354 100644 --- a/nebula_terminal/Cargo.toml +++ b/nebula_terminal/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nebula_terminal" -version = "1.7.0" +version = "1.8.0" license = "GPL-3.0-or-later" description = "Nebula terminal emulation core" readme = "../README.md" diff --git a/scripts/build-installer.ps1 b/scripts/build-installer.ps1 index 2d3040c..55112b3 100644 --- a/scripts/build-installer.ps1 +++ b/scripts/build-installer.ps1 @@ -85,28 +85,7 @@ if (-not (Test-Path -LiteralPath $installerScript -PathType Leaf)) { } if (-not $SkipBuild) { - Push-Location $repo - $previousTargetDirectory = $env:CARGO_TARGET_DIR - try { - $env:CARGO_TARGET_DIR = $cargoTargetRoot - $workspaceArgs = @('build', '--workspace', '--exclude', 'nebula', '--locked') - $gpuiArgs = @('build', '-p', 'nebula', '--bin', 'pebrel', '--features', 'gpui-shell', '--locked') - if ($Configuration -eq 'release') { - $workspaceArgs += '--release' - $gpuiArgs += '--release' - } - & cargo @workspaceArgs - if ($LASTEXITCODE -ne 0) { - throw "Cargo workspace build failed with exit code $LASTEXITCODE" - } - & cargo @gpuiArgs - if ($LASTEXITCODE -ne 0) { - throw "Cargo gpui-shell build failed with exit code $LASTEXITCODE" - } - } finally { - $env:CARGO_TARGET_DIR = $previousTargetDirectory - Pop-Location - } + & (Join-Path $PSScriptRoot 'build-windows-product.ps1') -Configuration $Configuration -TargetDirectory $cargoTargetRoot } $missing = @($requiredFiles | Where-Object { -not (Test-Path -LiteralPath $_ -PathType Leaf) }) diff --git a/scripts/build-windows-product.ps1 b/scripts/build-windows-product.ps1 new file mode 100644 index 0000000..89367e2 --- /dev/null +++ b/scripts/build-windows-product.ps1 @@ -0,0 +1,36 @@ +[CmdletBinding()] +param( + [ValidateSet('debug', 'release')] + [string] $Configuration = 'release', + [string] $TargetDirectory +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$repo = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +if ([string]::IsNullOrWhiteSpace($TargetDirectory)) { + $TargetDirectory = Join-Path $repo 'target' +} elseif (-not [IO.Path]::IsPathRooted($TargetDirectory)) { + $TargetDirectory = Join-Path $repo $TargetDirectory +} +$previousTargetDirectory = $env:CARGO_TARGET_DIR +Push-Location $repo +try { + $env:CARGO_TARGET_DIR = [IO.Path]::GetFullPath($TargetDirectory) + # Keep the feature graph identical for the initial build, ZIP and installer. + # The acceptance lab is tested by CI but does not supply a packaged binary. + $arguments = @( + 'build', '--locked', '--timings', + '-p', 'nebula', '--bin', 'pebrel', + '-p', 'nebula_hook', '--bin', 'pebrel-hook', + '--features', 'nebula/gpui-shell' + ) + if ($Configuration -eq 'release') { $arguments += '--release' } + & cargo @arguments + if ($LASTEXITCODE -ne 0) { + throw "Cargo Windows product build failed with exit code $LASTEXITCODE" + } +} finally { + $env:CARGO_TARGET_DIR = $previousTargetDirectory + Pop-Location +} diff --git a/scripts/ci_native_tests.py b/scripts/ci_native_tests.py new file mode 100644 index 0000000..8e5545b --- /dev/null +++ b/scripts/ci_native_tests.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +"""Run the complete native suite with one Rust workspace feature graph.""" + +from __future__ import annotations + +import subprocess +import sys + + +def native_commands() -> list[list[str]]: + return [ + [sys.executable, "-m", "unittest", "discover", "-s", "scripts/tests", "-v"], + [sys.executable, "-m", "unittest", "discover", "-s", "scripts/conformance/tests", "-v"], + [ + "cargo", "test", "--locked", "--workspace", + "--features", "nebula/gpui-test-support", "--timings", + ], + ] + + +def main() -> int: + for command in native_commands(): + print("Running:", " ".join(command), flush=True) + subprocess.run(command, check=True) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/installer.iss b/scripts/installer.iss index f8a9fbb..6fc0299 100644 --- a/scripts/installer.iss +++ b/scripts/installer.iss @@ -1,9 +1,9 @@ -#ifndef AppVersion - #define AppVersion "1.7.0" +#ifndef AppVersion + #define AppVersion "1.8.0" #endif #ifndef NumericVersion - #define NumericVersion "1.7.0.0" + #define NumericVersion "1.8.0.0" #endif #ifndef Configuration diff --git a/scripts/package-release.ps1 b/scripts/package-release.ps1 index 400d7b8..671146d 100644 --- a/scripts/package-release.ps1 +++ b/scripts/package-release.ps1 @@ -144,33 +144,7 @@ function Assert-FreshBinaries { } if (-not $SkipBuild) { - Push-Location $repo - $previousTargetDirectory = $env:CARGO_TARGET_DIR - try { - $env:CARGO_TARGET_DIR = $cargoTargetRoot - # Never build nebula without gpui-shell first: a workspace default - # binary overwrites the product exe with the legacy winit shell. - # Exclude nebula from the workspace build, then link GPUI last. - if ($Configuration -eq 'release') { - & cargo build --workspace --release --exclude nebula --locked - if ($LASTEXITCODE -ne 0) { - throw "Cargo workspace build failed with exit code $LASTEXITCODE" - } - & cargo build -p nebula --bin pebrel --release --features gpui-shell --locked - } else { - & cargo build --workspace --exclude nebula --locked - if ($LASTEXITCODE -ne 0) { - throw "Cargo workspace build failed with exit code $LASTEXITCODE" - } - & cargo build -p nebula --bin pebrel --features gpui-shell --locked - } - if ($LASTEXITCODE -ne 0) { - throw "Cargo gpui-shell build failed with exit code $LASTEXITCODE" - } - } finally { - $env:CARGO_TARGET_DIR = $previousTargetDirectory - Pop-Location - } + & (Join-Path $PSScriptRoot 'build-windows-product.ps1') -Configuration $Configuration -TargetDirectory $cargoTargetRoot } $missing = @($manifest.GetEnumerator() | Where-Object { diff --git a/scripts/tests/build-windows-product.tests.ps1 b/scripts/tests/build-windows-product.tests.ps1 new file mode 100644 index 0000000..d236a97 --- /dev/null +++ b/scripts/tests/build-windows-product.tests.ps1 @@ -0,0 +1,44 @@ +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$builder = Join-Path $PSScriptRoot '../build-windows-product.ps1' +$originalTarget = $env:CARGO_TARGET_DIR +$originalDirectory = (Get-Location).Path +$global:ProductBuildCalls = @() +$global:ProductBuildExit = 0 +function global:cargo { + $global:ProductBuildCalls += ,@($args) + $global:LASTEXITCODE = $global:ProductBuildExit +} +try { + $env:CARGO_TARGET_DIR = 'preserve-caller-target' + & $builder -Configuration release -TargetDirectory target + if ($global:ProductBuildCalls.Count -ne 1) { throw 'Expected one product build' } + $arguments = $global:ProductBuildCalls[0] + $packages = @(); $binaries = @() + for ($i = 0; $i -lt $arguments.Count - 1; $i++) { + if ($arguments[$i] -eq '-p') { $packages += $arguments[$i + 1] } + if ($arguments[$i] -eq '--bin') { $binaries += $arguments[$i + 1] } + } + if (($packages -join ',') -ne 'nebula,nebula_hook') { throw 'Unexpected package selection' } + if (($binaries -join ',') -ne 'pebrel,pebrel-hook') { throw 'Unexpected product binaries' } + foreach ($required in @('--locked', '--release', '--features', 'nebula/gpui-shell')) { + if ($required -notin $arguments) { throw "Missing product build argument: $required" } + } + if ('--workspace' -in $arguments) { throw 'Packaging must not build the acceptance lab' } + if ($env:CARGO_TARGET_DIR -ne 'preserve-caller-target') { throw 'Caller target changed' } + & $builder -Configuration debug -TargetDirectory target + if ('--release' -in $global:ProductBuildCalls[1]) { throw 'Debug build selected release' } + $global:ProductBuildExit = 27 + $failed = $false + try { & $builder -Configuration release -TargetDirectory target } catch { + $failed = $_.Exception.Message -match 'exit code 27' + } + if (-not $failed) { throw 'Cargo failure must fail packaging' } + if ($env:CARGO_TARGET_DIR -ne 'preserve-caller-target') { throw 'Failure changed caller target' } + if ((Get-Location).Path -ne $originalDirectory) { throw 'Failure changed caller directory' } + Write-Output 'build-windows-product.tests.ps1: PASS' +} finally { + $env:CARGO_TARGET_DIR = $originalTarget + Remove-Item Function:\cargo + Remove-Variable ProductBuildCalls,ProductBuildExit -Scope Global +} diff --git a/scripts/tests/package-release.tests.ps1 b/scripts/tests/package-release.tests.ps1 index 6226ccb..615aff3 100644 --- a/scripts/tests/package-release.tests.ps1 +++ b/scripts/tests/package-release.tests.ps1 @@ -38,15 +38,10 @@ try { } $scriptBody = Get-Content -LiteralPath $packageScript -Raw -Encoding UTF8 - if ($scriptBody -notmatch '--features gpui-shell') { - throw 'Release packaging must rebuild Pebrel with --features gpui-shell so the zip ships the GPUI shell.' - } - if ($scriptBody -notmatch '--bin pebrel') { - throw 'Release packaging must build the Pebrel product executable.' - } - if ($scriptBody -notmatch '--exclude nebula') { - throw 'Release packaging must exclude nebula from the workspace build so the default-feature binary cannot overwrite the GPUI shell.' + if ($scriptBody -notmatch 'build-windows-product.ps1') { + throw 'Release packaging must use the shared explicit product build.' } + & (Join-Path $PSScriptRoot 'build-windows-product.tests.ps1') if ($scriptBody -notmatch 'Assert-FreshBinaries') { throw 'Release packaging must refuse stale binaries (freshness guard missing).' diff --git a/scripts/tests/test_ci_native_tests.py b/scripts/tests/test_ci_native_tests.py new file mode 100644 index 0000000..4f25da8 --- /dev/null +++ b/scripts/tests/test_ci_native_tests.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import subprocess +import unittest +from unittest.mock import patch + +from scripts.ci_native_tests import main, native_commands + + +class NativeSuiteTests(unittest.TestCase): + def test_full_workspace_and_interactions_share_one_unfiltered_invocation(self): + rust = [command for command in native_commands() if command[0] == "cargo"] + self.assertEqual(len(rust), 1) + command = rust[0] + self.assertEqual(command[1], "test") + self.assertIn("--locked", command) + self.assertIn("--workspace", command) + self.assertEqual(command[command.index("--features") + 1], "nebula/gpui-test-support") + self.assertNotIn("--exclude", command) + self.assertNotIn("--lib", command) + self.assertNotIn("--skip", command) + self.assertNotIn("--", command) + + def test_both_python_test_roots_are_discovered(self): + suites = [command for command in native_commands() if "unittest" in command] + self.assertEqual( + {command[command.index("-s") + 1] for command in suites}, + {"scripts/tests", "scripts/conformance/tests"}, + ) + self.assertTrue(all("discover" in command for command in suites)) + + def test_success_runs_every_command_and_checks_exit_codes(self): + with patch("scripts.ci_native_tests.subprocess.run") as run: + self.assertEqual(main(), 0) + self.assertEqual(run.call_count, len(native_commands())) + self.assertTrue(all(call.kwargs["check"] for call in run.call_args_list)) + + def test_failure_stops_the_suite_and_is_not_reported_as_success(self): + with patch("scripts.ci_native_tests.subprocess.run") as run: + run.side_effect = subprocess.CalledProcessError(7, "test") + with self.assertRaises(subprocess.CalledProcessError): + main() + self.assertEqual(run.call_count, 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_stable_release.py b/scripts/tests/test_stable_release.py index e73dd17..7dd1fd6 100644 --- a/scripts/tests/test_stable_release.py +++ b/scripts/tests/test_stable_release.py @@ -68,21 +68,17 @@ def notes(checksum_placeholder: bool = True) -> str: class StableReleaseTests(unittest.TestCase): - def test_stable_workflow_runs_gpui_interaction_tests_on_every_platform(self) -> None: + def test_stable_workflow_requires_full_native_tests_before_aggregation(self) -> None: root = Path(__file__).resolve().parents[2] workflow = (root / ".github/workflows/release.yml").read_text(encoding="utf-8") - command = "cargo test --locked -p nebula --bin pebrel --features gpui-test-support gpui_shell::" - self.assertEqual(workflow.count(command), 3) - windows_step = workflow.split(" - name: Test workspace and native harness\n", 1)[1] - windows_step = windows_step.split(" - name:", 1)[0] - self.assertIn("shell: pwsh", windows_step) - lines = [line.strip() for line in windows_step.splitlines()] - commands = 0 - for index, line in enumerate(lines): - if line.startswith(("cargo test ", "python -m unittest ")): - commands += 1 - self.assertEqual(lines[index + 1], "if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }") - self.assertEqual(commands, 5) + native = workflow.split(" native-tests:\n", 1)[1].split("\n linux:\n", 1)[0] + for platform in ("linux-x64", "windows-x64", "macos-arm64", "macos-x64"): + self.assertIn("platform: " + platform, native) + self.assertIn("run: python scripts/ci_native_tests.py", native) + self.assertNotIn("continue-on-error", native) + aggregate = workflow.split("\n aggregate:\n", 1)[1].split("\n publish:\n", 1)[0] + self.assertIn("needs: [prepare, native-tests, linux, macos, windows]", aggregate) + self.assertNotIn("always()", aggregate) def test_native_packagers_expose_stable_channel_without_preview_id(self) -> None: root = Path(__file__).resolve().parents[2] diff --git a/tools/i18n-contract/Cargo.lock b/tools/i18n-contract/Cargo.lock index fef745b..f055b79 100644 --- a/tools/i18n-contract/Cargo.lock +++ b/tools/i18n-contract/Cargo.lock @@ -16,7 +16,7 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "nebula-settings" -version = "1.7.0" +version = "1.8.0" [[package]] name = "pebrel-i18n-contract" From 948a427280c032c7db2fe0dd86a37d8fd7518bce Mon Sep 17 00:00:00 2001 From: kuddev Date: Mon, 14 Sep 2026 20:35:37 +0800 Subject: [PATCH 35/51] ci: reduce native test compilation and artifact upload overhead --- .github/ci-profile.toml | 23 +++++++++++++++++++++++ .github/workflows/linux-lua.yml | 2 ++ .github/workflows/release.yml | 9 +++++++++ docs/architecture-decisions.md | 5 +++++ scripts/ci_native_tests.py | 7 ++++++- scripts/tests/test_ci_native_tests.py | 23 ++++++++++++++++++++++- 6 files changed, 67 insertions(+), 2 deletions(-) create mode 100644 .github/ci-profile.toml diff --git a/.github/ci-profile.toml b/.github/ci-profile.toml new file mode 100644 index 0000000..509b709 --- /dev/null +++ b/.github/ci-profile.toml @@ -0,0 +1,23 @@ +# Test compilation only; packaging continues to use profile.release. +# Named dependency overrides take precedence over "*", so reset those too. +[profile.ci] +inherits = "dev" +opt-level = 0 +debug = 0 +debug-assertions = true +overflow-checks = true +split-debuginfo = "off" +incremental = false +codegen-units = 256 +lto = false + +[profile.ci.package] +"*" = { opt-level = 0 } +resvg = { opt-level = 0 } +rustybuzz = { opt-level = 0 } +taffy = { opt-level = 0 } +ttf-parser = { opt-level = 0 } +smol = { opt-level = 0 } +gpui = { opt-level = 0 } +gpui_macros = { opt-level = 0 } +nebula_terminal = { opt-level = 0 } diff --git a/.github/workflows/linux-lua.yml b/.github/workflows/linux-lua.yml index f7f477b..b006226 100644 --- a/.github/workflows/linux-lua.yml +++ b/.github/workflows/linux-lua.yml @@ -10,6 +10,7 @@ on: - "Cargo.lock" - "rust-toolchain.toml" - ".github/workflows/linux-lua.yml" + - ".github/ci-profile.toml" pull_request: paths: - "**/*.rs" @@ -18,6 +19,7 @@ on: - "Cargo.lock" - "rust-toolchain.toml" - ".github/workflows/linux-lua.yml" + - ".github/ci-profile.toml" permissions: contents: read diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4d89fb2..5a0c8eb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -174,6 +174,7 @@ jobs: if: always() uses: actions/upload-artifact@v4 with: + compression-level: 0 name: native-timings-${{ matrix.platform }} path: target/cargo-timings if-no-files-found: warn @@ -283,6 +284,7 @@ jobs: if: always() uses: actions/upload-artifact@v4 with: + compression-level: 0 name: stable-package-linux-x64 path: | dist/*.AppImage @@ -294,6 +296,7 @@ jobs: if: always() uses: actions/upload-artifact@v4 with: + compression-level: 0 name: stable-evidence-linux-x64 path: dist/conformance if-no-files-found: warn @@ -424,6 +427,7 @@ jobs: if: always() uses: actions/upload-artifact@v4 with: + compression-level: 0 name: stable-package-macos-${{ matrix.asset_architecture }} path: dist/*.dmg if-no-files-found: error @@ -432,6 +436,7 @@ jobs: if: always() uses: actions/upload-artifact@v4 with: + compression-level: 0 name: stable-evidence-macos-${{ matrix.architecture }} path: dist/conformance if-no-files-found: warn @@ -502,6 +507,7 @@ jobs: - name: Retain built Windows runtime before packaging uses: actions/upload-artifact@v4 with: + compression-level: 0 name: stable-runtime-windows-x64 path: | target/release/pebrel.exe @@ -536,6 +542,7 @@ jobs: if: always() uses: actions/upload-artifact@v4 with: + compression-level: 0 name: stable-package-windows-x64 path: | dist/Pebrel-v${{ needs.prepare.outputs.version }}-windows-x64.zip @@ -546,6 +553,7 @@ jobs: if: always() uses: actions/upload-artifact@v4 with: + compression-level: 0 name: stable-evidence-windows-x64 path: dist/conformance if-no-files-found: warn @@ -593,6 +601,7 @@ jobs: - name: Upload complete stable release set uses: actions/upload-artifact@v4 with: + compression-level: 0 name: Pebrel-v${{ needs.prepare.outputs.version }} path: release-dist if-no-files-found: error diff --git a/docs/architecture-decisions.md b/docs/architecture-decisions.md index c9b7ca4..3d1cef7 100644 --- a/docs/architecture-decisions.md +++ b/docs/architecture-decisions.md @@ -434,6 +434,11 @@ settings files. platform still runs the complete Python helper and harness suites. Cache keys preserve the existing platform namespace and retain workspace crates. Cargo still validates source, profile and feature fingerprints before reuse. +- **Test profile:** An explicit CI-only profile removes developer-preview + optimization and debug information from test compilation, including named + dependency overrides. It retains debug assertions and overflow checks. The + native suite also checks the actual product feature configuration, since GPUI + test support changes dependency features. Release optimization is unaffected. - **Product compilation:** The application keeps O3 and Thin LTO and uses 16 codegen units to parallelize its large translation unit. Other package settings retain their existing values. Both Windows packagers call one explicit builder diff --git a/scripts/ci_native_tests.py b/scripts/ci_native_tests.py index 8e5545b..36022a4 100644 --- a/scripts/ci_native_tests.py +++ b/scripts/ci_native_tests.py @@ -8,11 +8,16 @@ import sys def native_commands() -> list[list[str]]: + profile = ["--config", ".github/ci-profile.toml", "--profile", "ci"] return [ [sys.executable, "-m", "unittest", "discover", "-s", "scripts/tests", "-v"], [sys.executable, "-m", "unittest", "discover", "-s", "scripts/conformance/tests", "-v"], [ - "cargo", "test", "--locked", "--workspace", + "cargo", "check", "--locked", *profile, "-p", "nebula", "--bin", "pebrel", + "--features", "gpui-shell", "--timings", + ], + [ + "cargo", "test", "--locked", *profile, "--workspace", "--features", "nebula/gpui-test-support", "--timings", ], ] diff --git a/scripts/tests/test_ci_native_tests.py b/scripts/tests/test_ci_native_tests.py index 4f25da8..a9490da 100644 --- a/scripts/tests/test_ci_native_tests.py +++ b/scripts/tests/test_ci_native_tests.py @@ -1,6 +1,8 @@ from __future__ import annotations import subprocess +from pathlib import Path +import tomllib import unittest from unittest.mock import patch @@ -9,7 +11,7 @@ from scripts.ci_native_tests import main, native_commands class NativeSuiteTests(unittest.TestCase): def test_full_workspace_and_interactions_share_one_unfiltered_invocation(self): - rust = [command for command in native_commands() if command[0] == "cargo"] + rust = [command for command in native_commands() if command[:2] == ["cargo", "test"]] self.assertEqual(len(rust), 1) command = rust[0] self.assertEqual(command[1], "test") @@ -21,6 +23,25 @@ class NativeSuiteTests(unittest.TestCase): self.assertNotIn("--skip", command) self.assertNotIn("--", command) + def test_actual_product_feature_graph_is_also_checked(self): + checks = [command for command in native_commands() if command[:2] == ["cargo", "check"]] + self.assertEqual(len(checks), 1) + command = checks[0] + self.assertEqual(command[command.index("--features") + 1], "gpui-shell") + self.assertEqual(command[command.index("--bin") + 1], "pebrel") + + def test_fast_test_profile_preserves_runtime_checks_and_resets_named_overrides(self): + root = Path(__file__).resolve().parents[2] + config = tomllib.loads((root / ".github/ci-profile.toml").read_text()) + workspace = tomllib.loads((root / "Cargo.toml").read_text()) + profile = config["profile"]["ci"] + self.assertEqual(profile["inherits"], "dev") + self.assertTrue(profile["debug-assertions"]) + self.assertTrue(profile["overflow-checks"]) + for package in workspace["profile"]["dev"]["package"]: + self.assertEqual(profile["package"][package]["opt-level"], 0) + self.assertNotIn("release", config["profile"]) + def test_both_python_test_roots_are_discovered(self): suites = [command for command in native_commands() if "unittest" in command] self.assertEqual( From 69f400fa19410c9fa48f804bc89e36601531912e Mon Sep 17 00:00:00 2001 From: kuddev Date: Mon, 14 Sep 2026 20:52:33 +0800 Subject: [PATCH 36/51] ci: separate compiled caches and reuse complete native validation --- .github/actions/rust-cache/action.yml | 105 ++++++++++++++++++++++++ .github/workflows/linux-lua.yml | 33 +++++++- .github/workflows/release.yml | 112 +++++++++++--------------- docs/architecture-decisions.md | 13 ++- scripts/tests/test_ci_cache.py | 68 ++++++++++++++++ scripts/tests/test_stable_release.py | 12 ++- 6 files changed, 267 insertions(+), 76 deletions(-) create mode 100644 .github/actions/rust-cache/action.yml create mode 100644 scripts/tests/test_ci_cache.py diff --git a/.github/actions/rust-cache/action.yml b/.github/actions/rust-cache/action.yml new file mode 100644 index 0000000..368f159 --- /dev/null +++ b/.github/actions/rust-cache/action.yml @@ -0,0 +1,105 @@ +name: Cargo downloads and compiled targets +description: Share downloads across jobs without duplicating them in every target cache + +inputs: + key: + description: Compiled workload (core, product, release or legacy) + required: true + target: + description: Compiled profile directory to retain, relative to the repository + required: true + legacy-key: + description: Previous combined cache key, restored only during migration + default: "" + extra-key: + description: Additional inputs for native build tools stored with this target + default: "" + +outputs: + cache-key: + description: Key to use when saving compiled targets + value: ${{ steps.identity.outputs.key }} + cache-hit: + description: Whether the compiled target cache matched exactly + value: ${{ steps.target.outputs.cache-hit }} + +runs: + using: composite + steps: + - name: Identify compiler and build environment + id: identity + shell: bash + env: + CACHE_OS: ${{ runner.os }} + CACHE_ARCH: ${{ runner.arch }} + CACHE_WORKLOAD: ${{ inputs.key }} + CACHE_MANIFESTS: ${{ hashFiles('Cargo.lock', '**/Cargo.toml', 'rust-toolchain.toml', '**/.cargo/config.toml', '.github/ci-profile.toml') }}${{ inputs.extra-key }} + run: | + python - <<'PY' + import hashlib + import json + import os + from pathlib import Path + import subprocess + facts = {"rustc": subprocess.check_output(["rustc", "-vV"], text=True)} + prefixes = ("CARGO_PROFILE_", "CFLAGS", "CXXFLAGS", "RUSTFLAGS") + names = {"CARGO_BUILD_TARGET", "CARGO_ENCODED_RUSTFLAGS", "RUSTC_WRAPPER", + "CC", "CXX", "ImageOS", "MACOSX_DEPLOYMENT_TARGET", "SDKROOT", "DEVELOPER_DIR"} + facts["environment"] = {key: value for key, value in os.environ.items() + if key in names or key.startswith(prefixes)} + if os.environ["CACHE_OS"] == "macOS": + facts["sdk"] = subprocess.check_output(["xcrun", "--sdk", "macosx", "--show-sdk-version"], text=True) + fingerprint = hashlib.sha256(json.dumps(facts, sort_keys=True).encode()).hexdigest()[:16] + prefix = "cargo-targets-v1-" + "-".join(os.environ[name] for name in + ("CACHE_OS", "CACHE_ARCH", "CACHE_WORKLOAD")) + prefix += "-" + fingerprint + "-" + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output: + output.write("key=" + prefix + os.environ["CACHE_MANIFESTS"] + "\n") + output.write("restore-key=" + prefix + "\n") + output.write("cargo-home=" + os.environ.get("CARGO_HOME", str(Path.home() / ".cargo")) + "\n") + PY + - name: Restore shared Cargo downloads + id: downloads + uses: actions/cache/restore@v4 + with: + # Keep the immutable archives/Git objects once per native platform. + # Cargo recreates checkouts and extracted registry sources from these. + path: | + ${{ steps.identity.outputs.cargo-home }}/registry/index + ${{ steps.identity.outputs.cargo-home }}/registry/cache + ${{ steps.identity.outputs.cargo-home }}/git/db + key: cargo-downloads-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('Cargo.lock', '**/Cargo.toml') }} + restore-keys: cargo-downloads-v1-${{ runner.os }}-${{ runner.arch }}- + - name: Restore compiled targets + id: target + uses: actions/cache/restore@v4 + with: + path: ${{ inputs.target }} + key: ${{ steps.identity.outputs.key }} + restore-keys: ${{ steps.identity.outputs.restore-key }} + - name: Recover an existing combined cache during migration + if: steps.target.outputs.cache-matched-key == '' && inputs.legacy-key != '' + uses: Swatinem/rust-cache@v2 + with: + shared-key: ${{ inputs.legacy-key }} + workspaces: . -> target + env-vars: MACOSX_DEPLOYMENT_TARGET + cache-bin: false + cache-on-failure: true + save-if: false + - name: Populate complete downloads before sharing the cache + if: steps.downloads.outputs.cache-hit != 'true' + shell: bash + run: | + host_target="$(rustc -vV | sed -n 's/^host: //p')" + test -n "$host_target" + cargo fetch --locked --target "$host_target" + - name: Save complete shared Cargo downloads + if: steps.downloads.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: | + ${{ steps.identity.outputs.cargo-home }}/registry/index + ${{ steps.identity.outputs.cargo-home }}/registry/cache + ${{ steps.identity.outputs.cargo-home }}/git/db + key: ${{ steps.downloads.outputs.cache-primary-key }} diff --git a/.github/workflows/linux-lua.yml b/.github/workflows/linux-lua.yml index b006226..2223825 100644 --- a/.github/workflows/linux-lua.yml +++ b/.github/workflows/linux-lua.yml @@ -2,7 +2,10 @@ name: Full native tests on: workflow_dispatch: + workflow_call: push: + # Releases call this entire workflow instead of duplicating branch-push jobs. + branches-ignore: ["release/**"] paths: - "**/*.rs" - "scripts/**/*.py" @@ -11,6 +14,7 @@ on: - "rust-toolchain.toml" - ".github/workflows/linux-lua.yml" - ".github/ci-profile.toml" + - ".github/actions/rust-cache/action.yml" pull_request: paths: - "**/*.rs" @@ -20,6 +24,7 @@ on: - "rust-toolchain.toml" - ".github/workflows/linux-lua.yml" - ".github/ci-profile.toml" + - ".github/actions/rust-cache/action.yml" permissions: contents: read @@ -64,13 +69,24 @@ jobs: - uses: dtolnay/rust-toolchain@stable with: toolchain: "1.97.1" - - uses: Swatinem/rust-cache@v2 + - uses: ./.github/actions/rust-cache + id: rust-cache with: - shared-key: ${{ runner.os == 'Windows' && 'windows' || runner.os == 'Linux' && 'linux' || 'macos' }} - cache-workspace-crates: true - cache-on-failure: true + key: native + target: target/ci + legacy-key: ${{ runner.os == 'Windows' && 'windows' || runner.os == 'Linux' && 'linux' || 'macos' }} + - name: Prepare pinned Windows console runtime + if: runner.os == 'Windows' + shell: pwsh + run: ./scripts/prepare-windows-runtime.ps1 -Destination assets/windows/conhost - name: Test complete workspace and native harness run: python scripts/ci_native_tests.py + - name: Save compiled workload + if: ${{ always() && steps.rust-cache.outcome == 'success' && steps.rust-cache.outputs.cache-hit != 'true' }} + uses: actions/cache/save@v4 + with: + path: target/ci + key: ${{ steps.rust-cache.outputs.cache-key }} - name: Check source architecture if: ${{ !cancelled() && runner.os == 'Linux' }} run: python scripts/check_architecture.py @@ -80,3 +96,12 @@ jobs: - name: Check the release workspace if: ${{ !cancelled() }} run: cargo check --locked --workspace --release + - name: Upload native compilation timings + if: always() + uses: actions/upload-artifact@v4 + with: + name: native-timings-${{ matrix.os }} + path: target/cargo-timings + if-no-files-found: warn + compression-level: 0 + retention-days: 14 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5a0c8eb..19b2f6a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -120,65 +120,10 @@ jobs: python3 -m py_compile scripts/stable_release.py scripts/preview_release.py native-tests: - name: Native tests (${{ matrix.platform }}) + name: Native tests if: ${{ !inputs.windows_only }} needs: prepare - timeout-minutes: 120 - strategy: - fail-fast: false - matrix: - include: - - runner: ubuntu-22.04 - platform: linux-x64 - cache: linux - - runner: windows-2022 - platform: windows-x64 - cache: windows - - runner: macos-26 - platform: macos-arm64 - cache: macos - - runner: macos-26-intel - platform: macos-x64 - cache: macos - runs-on: ${{ matrix.runner }} - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: "3.13" - - name: Install native test dependencies - if: runner.os == 'Linux' - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - build-essential cmake pkg-config zsh libasound2-dev libdbus-1-dev libudev-dev \ - libfontconfig1-dev libfreetype6-dev libegl1-mesa-dev libgl1-mesa-dev \ - libwayland-dev libxkbcommon-dev libxkbcommon-x11-dev libx11-xcb-dev \ - libxcb1-dev libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev \ - libxcb-xinput-dev libxcb-xkb-dev libxi-dev - - uses: dtolnay/rust-toolchain@stable - with: - toolchain: ${{ env.RUST_TOOLCHAIN }} - - uses: Swatinem/rust-cache@v2 - with: - shared-key: ${{ matrix.cache }} - cache-workspace-crates: true - cache-on-failure: true - - name: Prepare pinned Windows console runtime - if: runner.os == 'Windows' - shell: pwsh - run: ./scripts/prepare-windows-runtime.ps1 -Destination assets/windows/conhost - - name: Test complete workspace and native harness - run: python scripts/ci_native_tests.py - - name: Upload native compilation timings - if: always() - uses: actions/upload-artifact@v4 - with: - compression-level: 0 - name: native-timings-${{ matrix.platform }} - path: target/cargo-timings - if-no-files-found: warn - retention-days: 14 + uses: ./.github/workflows/linux-lua.yml linux: name: Linux x86_64 stable package @@ -210,12 +155,20 @@ jobs: - uses: dtolnay/rust-toolchain@stable with: toolchain: ${{ env.RUST_TOOLCHAIN }} - - uses: Swatinem/rust-cache@v2 + - uses: ./.github/actions/rust-cache + id: rust-cache with: - cache-on-failure: true - cache-workspace-crates: true + key: release + target: target/release + legacy-key: linux - name: Build GPUI release binary run: cargo build --locked --release -p nebula --bin pebrel --features gpui-shell --timings + - name: Save compiled workload + if: ${{ always() && steps.rust-cache.outcome == 'success' && steps.rust-cache.outputs.cache-hit != 'true' }} + uses: actions/cache/save@v4 + with: + path: target/release + key: ${{ steps.rust-cache.outputs.cache-key }} - name: Download pinned linuxdeploy env: LINUXDEPLOY: ${{ runner.temp }}/linuxdeploy-x86_64.AppImage @@ -339,12 +292,20 @@ jobs: - uses: dtolnay/rust-toolchain@stable with: toolchain: ${{ env.RUST_TOOLCHAIN }} - - uses: Swatinem/rust-cache@v2 + - uses: ./.github/actions/rust-cache + id: rust-cache with: - cache-on-failure: true - cache-workspace-crates: true + key: release + target: target/release + legacy-key: macos - name: Build GPUI release binary run: cargo build --locked --release -p nebula --bin pebrel --features gpui-shell --timings + - name: Save compiled workload + if: ${{ always() && steps.rust-cache.outcome == 'success' && steps.rust-cache.outputs.cache-hit != 'true' }} + uses: actions/cache/save@v4 + with: + path: target/release + key: ${{ steps.rust-cache.outputs.cache-key }} - name: Import Developer ID signing credentials if: ${{ env.MACOS_SIGNING == 'developer-id' }} env: @@ -476,7 +437,7 @@ jobs: $run = Invoke-RestMethod -Uri "https://api.github.com/repos/$env:GITHUB_REPOSITORY/actions/runs/$env:VALIDATED_RUN" -Headers $headers -ErrorAction Stop if ($run.head_sha -ne $env:SELECTED_COMMIT) { throw 'Validated run source differs.' } $jobs = Invoke-RestMethod -Uri "https://api.github.com/repos/$env:GITHUB_REPOSITORY/actions/runs/$env:VALIDATED_RUN/jobs?per_page=100" -Headers $headers -ErrorAction Stop - $windows = @($jobs.jobs | Where-Object { $_.name -in @('Windows x64 stable package', 'Native tests (windows-x64)') }) + $windows = @($jobs.jobs | Where-Object { $_.name -in @('Windows x64 stable package', 'Native tests (windows-x64)') -or $_.name -match 'Tests \(windows-2022\)$' }) $tests = @($windows.steps | Where-Object { $_.name -in @('Test workspace and native harness', 'Test complete workspace and native harness') -and $_.conclusion -eq 'success' }) if ($tests.Count -ne 1) { throw 'Prior Windows native tests did not pass.' } - uses: actions/checkout@v4 @@ -489,10 +450,17 @@ jobs: - uses: dtolnay/rust-toolchain@stable with: toolchain: ${{ env.RUST_TOOLCHAIN }} + - uses: ./.github/actions/rust-cache + id: rust-cache + with: + key: release + target: target/release + legacy-key: windows + if: ${{ inputs.source_commit == '' }} - uses: Swatinem/rust-cache@v2 + if: ${{ inputs.source_commit != '' }} with: cache-on-failure: true - cache-workspace-crates: true - name: Prepare pinned Windows console runtime shell: pwsh run: | @@ -503,7 +471,19 @@ jobs: run: python scripts/ci_native_tests.py - name: Build GPUI release binary shell: pwsh - run: ./scripts/build-windows-product.ps1 -Configuration release -TargetDirectory target + run: | + if (Test-Path scripts/build-windows-product.ps1) { + ./scripts/build-windows-product.ps1 -Configuration release -TargetDirectory target + } else { + cargo build --locked --release -p nebula --bin pebrel -p nebula_hook --bin pebrel-hook --features nebula/gpui-shell + if ($LASTEXITCODE -ne 0) { throw 'Windows product build failed' } + } + - name: Save compiled workload + if: ${{ always() && steps.rust-cache.outcome == 'success' && steps.rust-cache.outputs.cache-hit != 'true' }} + uses: actions/cache/save@v4 + with: + path: target/release + key: ${{ steps.rust-cache.outputs.cache-key }} - name: Retain built Windows runtime before packaging uses: actions/upload-artifact@v4 with: diff --git a/docs/architecture-decisions.md b/docs/architecture-decisions.md index 3d1cef7..bcc3746 100644 --- a/docs/architecture-decisions.md +++ b/docs/architecture-decisions.md @@ -431,9 +431,16 @@ settings files. - **Decision:** Run the complete Rust workspace with the product interaction feature enabled in one unfiltered invocation. Run native tests independently from package construction; asset aggregation depends on both. Each native - platform still runs the complete Python helper and harness suites. Cache keys - preserve the existing platform namespace and retain workspace crates. Cargo - still validates source, profile and feature fingerprints before reuse. + platform still runs the complete Python helper and harness suites. Separate + test and release cache keys prevent concurrent jobs from replacing one another's + compiled workload. Dependency archives and Git objects are shared within each + platform; only the relevant compiled profile is saved per workload. A fallback + reads existing combined caches during migration. Cargo still validates source, + profile and feature fingerprints before reuse. +- **Scheduling:** Stable releases call the same complete four-platform native + workflow used for contributions, including architecture, translation allocation + contracts and the release-workspace check. Release branch pushes omit a duplicate + automatic invocation; the release aggregation still requires the called suite. - **Test profile:** An explicit CI-only profile removes developer-preview optimization and debug information from test compilation, including named dependency overrides. It retains debug assertions and overflow checks. The diff --git a/scripts/tests/test_ci_cache.py b/scripts/tests/test_ci_cache.py new file mode 100644 index 0000000..3526327 --- /dev/null +++ b/scripts/tests/test_ci_cache.py @@ -0,0 +1,68 @@ +"""Exercise the cache identity code used by the composite action itself.""" + +import os +from pathlib import Path +import re +import tempfile +import textwrap +import unittest +from unittest.mock import patch + + +ACTION = Path(__file__).resolve().parents[2] / ".github/actions/rust-cache/action.yml" + + +class CacheIdentityTests(unittest.TestCase): + def identity(self, *, compiler="rustc 1.97.1", sdk="15.0", **changes): + source = ACTION.read_text(encoding="utf-8") + body = re.search(r"python - <<'PY'\n(.*?)\n PY", source, re.S) + self.assertIsNotNone(body, "cache identity must remain executable from this action") + code = textwrap.dedent(body.group(1)) + with tempfile.TemporaryDirectory() as temporary: + output = Path(temporary) / "outputs" + environment = { + "CACHE_OS": "Linux", "CACHE_ARCH": "X64", "CACHE_WORKLOAD": "ci-product", + "CACHE_MANIFESTS": "pinned-manifests", "GITHUB_OUTPUT": str(output), + "CARGO_HOME": str(Path(temporary) / "cargo"), **changes, + } + + def command(arguments, **kwargs): + if arguments == ["rustc", "-vV"]: + return compiler + self.assertEqual(arguments, ["xcrun", "--sdk", "macosx", "--show-sdk-version"]) + return sdk + + with patch.dict(os.environ, environment), patch("subprocess.check_output", side_effect=command): + exec(compile(code, str(ACTION), "exec"), {}) + return dict(line.split("=", 1) for line in output.read_text(encoding="utf-8").splitlines()) + + def test_compiler_flags_and_workload_isolate_compiled_targets(self): + baseline = self.identity()["key"] + for change in ( + {"compiler": "rustc 1.98.0"}, {"RUSTFLAGS": "-C opt-level=1"}, + {"CARGO_PROFILE_RELEASE_LTO": "false"}, {"CACHE_WORKLOAD": "release"}, + {"CACHE_ARCH": "ARM64"}, {"ImageOS": "a-different-runner-image"}, + ): + with self.subTest(change=change): + self.assertNotEqual(baseline, self.identity(**change)["key"]) + + def test_macos_sdk_and_deployment_floor_are_part_of_identity(self): + baseline = self.identity(CACHE_OS="macOS", sdk="15.0")["key"] + self.assertNotEqual(baseline, self.identity(CACHE_OS="macOS", sdk="16.0")["key"]) + self.assertNotEqual(baseline, self.identity(CACHE_OS="macOS", MACOSX_DEPLOYMENT_TARGET="13.0")["key"]) + + def test_dependency_change_can_restore_compatible_previous_targets(self): + before = self.identity(CACHE_MANIFESTS="before") + after = self.identity(CACHE_MANIFESTS="after") + self.assertNotEqual(before["key"], after["key"]) + self.assertEqual(before["restore-key"], after["restore-key"]) + self.assertTrue(after["key"].startswith(after["restore-key"])) + + def test_source_only_revision_and_package_labels_do_not_bust_dependencies(self): + before = self.identity(GITHUB_SHA="one", PREVIEW_ID="first") + after = self.identity(GITHUB_SHA="two", PREVIEW_ID="second") + self.assertEqual(before["key"], after["key"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_stable_release.py b/scripts/tests/test_stable_release.py index 7dd1fd6..0163fcf 100644 --- a/scripts/tests/test_stable_release.py +++ b/scripts/tests/test_stable_release.py @@ -72,9 +72,15 @@ class StableReleaseTests(unittest.TestCase): root = Path(__file__).resolve().parents[2] workflow = (root / ".github/workflows/release.yml").read_text(encoding="utf-8") native = workflow.split(" native-tests:\n", 1)[1].split("\n linux:\n", 1)[0] - for platform in ("linux-x64", "windows-x64", "macos-arm64", "macos-x64"): - self.assertIn("platform: " + platform, native) - self.assertIn("run: python scripts/ci_native_tests.py", native) + self.assertIn("uses: ./.github/workflows/linux-lua.yml", native) + shared = (root / ".github/workflows/linux-lua.yml").read_text(encoding="utf-8") + for platform in ("ubuntu-24.04", "windows-2022", "macos-26", "macos-26-intel"): + self.assertIn(platform, shared) + self.assertIn("workflow_call:", shared) + self.assertIn("run: python scripts/ci_native_tests.py", shared) + self.assertIn("cargo check --locked --workspace --release", shared) + self.assertIn("tools/i18n-contract/Cargo.toml", shared) + self.assertNotIn("continue-on-error", shared) self.assertNotIn("continue-on-error", native) aggregate = workflow.split("\n aggregate:\n", 1)[1].split("\n publish:\n", 1)[0] self.assertIn("needs: [prepare, native-tests, linux, macos, windows]", aggregate) From 9036df712f9a51b25c298275943acfe2fcae2517 Mon Sep 17 00:00:00 2001 From: kuddev Date: Mon, 14 Sep 2026 20:57:24 +0800 Subject: [PATCH 37/51] test(session): isolate ordinary persistence fixtures from runner privileges --- .../workspace/session_persistence.rs | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/nebula_app/src/gpui_shell/workspace/session_persistence.rs b/nebula_app/src/gpui_shell/workspace/session_persistence.rs index 581b9bd..b1d0463 100644 --- a/nebula_app/src/gpui_shell/workspace/session_persistence.rs +++ b/nebula_app/src/gpui_shell/workspace/session_persistence.rs @@ -119,6 +119,12 @@ mod tests { } use crate::session::{AgentSession, LayoutSession, TabSession}; + fn ordinary_window() -> SessionPersistence { + // Hosted Windows runners can be elevated. These tests exercise ordinary + // window persistence; privileged isolation has its own negative test. + SessionPersistence { isolated: false, ..SessionPersistence::default() } + } + fn sample_session() -> Session { let mut tab = TabSession::single("D:/work".into(), Some("Workspace".into()), None); tab.layout = Some(LayoutSession::Pane { @@ -145,7 +151,7 @@ mod tests { let directory = tempfile::tempdir().unwrap(); let path = directory.path().join("session.json"); let expected = sample_session(); - let mut state = SessionPersistence::default(); + let mut state = ordinary_window(); 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); @@ -160,7 +166,7 @@ mod tests { let directory = tempfile::tempdir().unwrap(); let path = directory.path().join("session.json"); let expected = sample_session(); - let mut state = SessionPersistence::default(); + let mut state = ordinary_window(); save_to(&mut state, &path, Some(expected.clone()), SaveReason::Checkpoint); drop(state); let restored = crate::session::load_from(&path).unwrap(); @@ -171,7 +177,7 @@ mod tests { #[test] fn teardown_events_cannot_overwrite_the_final_snapshot() { - let mut state = SessionPersistence::default(); + let mut state = ordinary_window(); let expected = sample_session(); state.save_with(Some(expected.clone()), SaveReason::Quit, |_| Ok(())); for reason in [SaveReason::Checkpoint, SaveReason::TabsClosed, SaveReason::WindowClose] { @@ -187,7 +193,7 @@ mod tests { #[test] fn explicitly_closing_all_tabs_does_not_resurrect_them() { - let mut state = SessionPersistence::default(); + let mut state = ordinary_window(); 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(())); @@ -198,7 +204,7 @@ mod tests { #[test] fn an_empty_startup_or_auxiliary_window_cannot_erase_a_saved_session() { - let mut state = SessionPersistence::default(); + let mut state = ordinary_window(); for current in [None, Some(Session::new(0, vec![]))] { state.save_with(current, SaveReason::Checkpoint, |_| { panic!("initial empty state must not reach storage") @@ -209,7 +215,7 @@ mod tests { #[test] fn failed_checkpoint_and_final_writes_are_retried() { - let mut state = SessionPersistence::default(); + let mut state = ordinary_window(); let session = sample_session(); let fail = |_: &Session| Err(std::io::Error::other("storage unavailable")); state.save_with(Some(session.clone()), SaveReason::Checkpoint, fail); @@ -225,7 +231,7 @@ mod tests { #[test] fn unchanged_checkpoints_do_not_rewrite_storage() { - let mut state = SessionPersistence::default(); + let mut state = ordinary_window(); state.save_with(Some(sample_session()), SaveReason::Checkpoint, |_| Ok(())); state.save_with(Some(sample_session()), SaveReason::Checkpoint, |_| { panic!("unchanged checkpoint must not write") @@ -234,7 +240,7 @@ mod tests { #[test] fn failed_window_close_is_retried_even_without_remaining_windows() { - let mut state = SessionPersistence::default(); + let mut state = ordinary_window(); state.save_with(Some(sample_session()), SaveReason::WindowClose, |_| { Err(std::io::Error::other("temporary write failure")) }); @@ -248,7 +254,7 @@ mod tests { #[test] fn failed_explicit_empty_snapshot_is_retried_without_resurrecting_tabs() { - let mut state = SessionPersistence::default(); + let mut state = ordinary_window(); 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")) From c5af789bc8e3857b92fdd3d0739155da8d677dbc Mon Sep 17 00:00:00 2001 From: kuddev Date: Mon, 14 Sep 2026 21:06:34 +0800 Subject: [PATCH 38/51] test(windows): run native conformance with an ordinary user token --- .github/workflows/release.yml | 3 +- docs/architecture-decisions.md | 8 + .../tests/test_windows_standard_user.py | 37 +++++ scripts/conformance/windows_standard_user.py | 153 ++++++++++++++++++ 4 files changed, 200 insertions(+), 1 deletion(-) create mode 100644 scripts/conformance/tests/test_windows_standard_user.py create mode 100644 scripts/conformance/windows_standard_user.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 19b2f6a..b7973e3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -419,6 +419,7 @@ jobs: shell: pwsh run: | Copy-Item scripts/prepare-windows-runtime.ps1 "$env:RUNNER_TEMP/prepare-windows-runtime.ps1" + Copy-Item scripts/conformance/windows_standard_user.py "$env:RUNNER_TEMP/windows-standard-user.py" 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 @@ -516,7 +517,7 @@ jobs: $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 + python "$env:RUNNER_TEMP/windows-standard-user.py" 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() diff --git a/docs/architecture-decisions.md b/docs/architecture-decisions.md index bcc3746..044a022 100644 --- a/docs/architecture-decisions.md +++ b/docs/architecture-decisions.md @@ -462,6 +462,14 @@ settings files. platforms. Actual job timings determine whether the target is met; cache input changes and first compilation must be reported separately. A configured timeout or parallel scheduling alone is not evidence of a ten-minute successful build. +- **Windows host privileges:** The first hosted run exposed administrator-token + dependence in ordinary-window persistence fixtures and runtime discovery. + Persistence tests now explicitly select ordinary-window state while retaining + privileged isolation tests. Native conformance launches under a restricted copy + of the runner's own token, verifies that elevation was removed and preserves + the desktop, environment and child exit status. It does not weaken the product's + administrator isolation or create a separate user account. Native launch tests + cover elevation, literal argument passing and successful/failed child exit. - **Revisit condition:** Retain only changes whose complete CI run and package checks pass. Reconsider codegen partitioning if artifact size or runtime measurements regress, and remove redundant caches if restore/save cost grows. diff --git a/scripts/conformance/tests/test_windows_standard_user.py b/scripts/conformance/tests/test_windows_standard_user.py new file mode 100644 index 0000000..ea65dd0 --- /dev/null +++ b/scripts/conformance/tests/test_windows_standard_user.py @@ -0,0 +1,37 @@ +import os +import unittest + +from scripts.conformance.windows_standard_user import WindowsTokens, run_python + + +@unittest.skipUnless(os.name == "nt", "requires native Windows process tokens") +class StandardUserLaunchTests(unittest.TestCase): + def test_child_is_unelevated_and_preserves_arguments_and_failure_code(self): + script = """ +import sys +from scripts.conformance.windows_standard_user import WindowsTokens +api = WindowsTokens() +token = api.current_token() +try: + assert not api.elevated(token), 'child retained administrator elevation' +finally: + api.kernel.CloseHandle(token) +assert sys.argv[1:] == ['path with spaces\\\\', '中文', 'literal & symbol'] +raise SystemExit(73) +""" + self.assertEqual(run_python(["-c", script, "path with spaces\\", "中文", + "literal & symbol"]), 73) + + def test_successful_child_does_not_change_parent_elevation(self): + api = WindowsTokens() + token = api.current_token() + try: + before = api.elevated(token) + self.assertEqual(run_python(["-c", "raise SystemExit(0)"]), 0) + self.assertEqual(api.elevated(token), before) + finally: + api.kernel.CloseHandle(token) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/conformance/windows_standard_user.py b/scripts/conformance/windows_standard_user.py new file mode 100644 index 0000000..00eb654 --- /dev/null +++ b/scripts/conformance/windows_standard_user.py @@ -0,0 +1,153 @@ +"""Run native conformance as an ordinary user on elevated Windows runners. + +Use a restricted copy of the current user token, retaining the same desktop, +profile and environment. Product administrator isolation stays enabled. +""" + +from __future__ import annotations + +import ctypes +from ctypes import wintypes as wt +import os +import subprocess +import sys + + +class SidAndAttributes(ctypes.Structure): + _fields_ = [("Sid", wt.LPVOID), ("Attributes", wt.DWORD)] + + +class StartupInfo(ctypes.Structure): + _fields_ = [ + ("cb", wt.DWORD), ("lpReserved", wt.LPWSTR), ("lpDesktop", wt.LPWSTR), + ("lpTitle", wt.LPWSTR), ("dwX", wt.DWORD), ("dwY", wt.DWORD), + ("dwXSize", wt.DWORD), ("dwYSize", wt.DWORD), ("dwXCountChars", wt.DWORD), + ("dwYCountChars", wt.DWORD), ("dwFillAttribute", wt.DWORD), + ("dwFlags", wt.DWORD), ("wShowWindow", wt.WORD), ("cbReserved2", wt.WORD), + ("lpReserved2", ctypes.POINTER(wt.BYTE)), ("hStdInput", wt.HANDLE), + ("hStdOutput", wt.HANDLE), ("hStdError", wt.HANDLE), + ] + + +class ProcessInfo(ctypes.Structure): + _fields_ = [("hProcess", wt.HANDLE), ("hThread", wt.HANDLE), + ("dwProcessId", wt.DWORD), ("dwThreadId", wt.DWORD)] + + +class WindowsTokens: + def __init__(self): + if os.name != "nt": + raise RuntimeError("the ordinary-user launcher requires Windows") + self.kernel = ctypes.WinDLL("kernel32", use_last_error=True) + self.security = ctypes.WinDLL("advapi32", use_last_error=True) + signatures = [ + (self.kernel, "GetCurrentProcess", wt.HANDLE, []), + (self.kernel, "GetStdHandle", wt.HANDLE, [wt.DWORD]), + (self.kernel, "CloseHandle", wt.BOOL, [wt.HANDLE]), + (self.kernel, "LocalFree", wt.HANDLE, [wt.HANDLE]), + (self.kernel, "WaitForSingleObject", wt.DWORD, [wt.HANDLE, wt.DWORD]), + (self.kernel, "GetExitCodeProcess", wt.BOOL, + [wt.HANDLE, ctypes.POINTER(wt.DWORD)]), + (self.security, "OpenProcessToken", wt.BOOL, + [wt.HANDLE, wt.DWORD, ctypes.POINTER(wt.HANDLE)]), + (self.security, "GetTokenInformation", wt.BOOL, + [wt.HANDLE, wt.DWORD, wt.LPVOID, wt.DWORD, ctypes.POINTER(wt.DWORD)]), + (self.security, "SetTokenInformation", wt.BOOL, + [wt.HANDLE, wt.DWORD, wt.LPVOID, wt.DWORD]), + (self.security, "GetLengthSid", wt.DWORD, [wt.LPVOID]), + (self.security, "ConvertStringSidToSidW", wt.BOOL, + [wt.LPCWSTR, ctypes.POINTER(wt.LPVOID)]), + (self.security, "CreateRestrictedToken", wt.BOOL, + [wt.HANDLE, wt.DWORD, wt.DWORD, wt.LPVOID, wt.DWORD, wt.LPVOID, + wt.DWORD, wt.LPVOID, ctypes.POINTER(wt.HANDLE)]), + (self.security, "CreateProcessAsUserW", wt.BOOL, + [wt.HANDLE, wt.LPCWSTR, wt.LPWSTR, wt.LPVOID, wt.LPVOID, wt.BOOL, + wt.DWORD, wt.LPVOID, wt.LPCWSTR, ctypes.POINTER(StartupInfo), + ctypes.POINTER(ProcessInfo)]), + ] + for library, name, result, arguments in signatures: + function = getattr(library, name) + function.restype, function.argtypes = result, arguments + + @staticmethod + def require(success): + if not success: + raise ctypes.WinError(ctypes.get_last_error()) + + def current_token(self): + token = wt.HANDLE() + # QUERY | DUPLICATE | ASSIGN_PRIMARY, for a restricted copy of ourselves. + self.require(self.security.OpenProcessToken( + self.kernel.GetCurrentProcess(), 0x000B, ctypes.byref(token))) + return token + + def elevated(self, token): + value, size = wt.DWORD(), wt.DWORD() + self.require(self.security.GetTokenInformation( + token, 20, ctypes.byref(value), ctypes.sizeof(value), ctypes.byref(size))) + return bool(value.value) + + def restrict(self, token): + restricted, sid = wt.HANDLE(), wt.LPVOID() + # LUA_TOKEN removes administrator membership and elevated privileges. + self.require(self.security.CreateRestrictedToken( + token, 0x4, 0, None, 0, None, 0, None, ctypes.byref(restricted))) + try: + self.require(self.security.ConvertStringSidToSidW( + "S-1-16-8192", ctypes.byref(sid))) # Medium integrity. + label = SidAndAttributes(sid, 0x20) # SE_GROUP_INTEGRITY. + self.require(self.security.SetTokenInformation( + restricted, 25, ctypes.byref(label), + ctypes.sizeof(label) + self.security.GetLengthSid(sid))) + if self.elevated(restricted): + raise RuntimeError("restricted token is still elevated; refusing conformance") + return restricted + except BaseException: + self.kernel.CloseHandle(restricted) + raise + finally: + if sid: + self.kernel.LocalFree(sid) + + def run(self, token, arguments): + command = ctypes.create_unicode_buffer(subprocess.list2cmdline(arguments)) + startup = StartupInfo() + startup.cb, startup.dwFlags = ctypes.sizeof(startup), 0x100 + startup.hStdInput = self.kernel.GetStdHandle(-10) + startup.hStdOutput = self.kernel.GetStdHandle(-11) + startup.hStdError = self.kernel.GetStdHandle(-12) + process = ProcessInfo() + self.require(self.security.CreateProcessAsUserW( + token, arguments[0], command, None, None, True, 0, None, os.getcwd(), + ctypes.byref(startup), ctypes.byref(process))) + try: + if self.kernel.WaitForSingleObject(process.hProcess, 0xFFFFFFFF) != 0: + raise ctypes.WinError(ctypes.get_last_error()) + code = wt.DWORD() + self.require(self.kernel.GetExitCodeProcess(process.hProcess, ctypes.byref(code))) + return code.value + finally: + self.kernel.CloseHandle(process.hThread) + self.kernel.CloseHandle(process.hProcess) + + +def run_python(arguments): + api = WindowsTokens() + token, restricted = api.current_token(), None + try: + command = [sys.executable, *arguments] + if not api.elevated(token): + return subprocess.run(command, check=False).returncode + restricted = api.restrict(token) + print("Running native conformance with a verified non-elevated token", flush=True) + return api.run(restricted, command) + finally: + if restricted: + api.kernel.CloseHandle(restricted) + api.kernel.CloseHandle(token) + + +if __name__ == "__main__": + if len(sys.argv) < 2: + raise SystemExit("usage: windows_standard_user.py [arguments...]") + raise SystemExit(run_python(sys.argv[1:])) From 1c77d217227c6a18af8c57fe4c95e8daacb6fd2b Mon Sep 17 00:00:00 2001 From: kuddev Date: Mon, 14 Sep 2026 21:30:45 +0800 Subject: [PATCH 39/51] ci: preserve native caches and correct Windows token access --- .github/actions/rust-cache/action.yml | 21 +++++++++++++------ .github/workflows/linux-lua.yml | 19 ++++++++++------- docs/architecture-decisions.md | 3 ++- .../tests/test_windows_standard_user.py | 13 ++++++++++++ scripts/conformance/windows_standard_user.py | 6 ++++-- scripts/tests/test_ci_cache.py | 11 +++++++++- 6 files changed, 56 insertions(+), 17 deletions(-) diff --git a/.github/actions/rust-cache/action.yml b/.github/actions/rust-cache/action.yml index 368f159..bf542b4 100644 --- a/.github/actions/rust-cache/action.yml +++ b/.github/actions/rust-cache/action.yml @@ -6,7 +6,7 @@ inputs: description: Compiled workload (core, product, release or legacy) required: true target: - description: Compiled profile directory to retain, relative to the repository + description: Compiled profile directories to retain, relative to the repository required: true legacy-key: description: Previous combined cache key, restored only during migration @@ -45,8 +45,10 @@ runs: prefixes = ("CARGO_PROFILE_", "CFLAGS", "CXXFLAGS", "RUSTFLAGS") names = {"CARGO_BUILD_TARGET", "CARGO_ENCODED_RUSTFLAGS", "RUSTC_WRAPPER", "CC", "CXX", "ImageOS", "MACOSX_DEPLOYMENT_TARGET", "SDKROOT", "DEVELOPER_DIR"} - facts["environment"] = {key: value for key, value in os.environ.items() - if key in names or key.startswith(prefixes)} + normalize = str.upper if os.name == "nt" else str + names = {normalize(name) for name in names} + facts["environment"] = {normalize(key): value for key, value in os.environ.items() + if normalize(key) in names or normalize(key).startswith(prefixes)} if os.environ["CACHE_OS"] == "macOS": facts["sdk"] = subprocess.check_output(["xcrun", "--sdk", "macosx", "--show-sdk-version"], text=True) fingerprint = hashlib.sha256(json.dumps(facts, sort_keys=True).encode()).hexdigest()[:16] @@ -56,7 +58,13 @@ runs: with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output: output.write("key=" + prefix + os.environ["CACHE_MANIFESTS"] + "\n") output.write("restore-key=" + prefix + "\n") - output.write("cargo-home=" + os.environ.get("CARGO_HOME", str(Path.home() / ".cargo")) + "\n") + # Recover the preceding Windows cache, which omitted ImageOS because + # Python uppercases environment names there. Cargo revalidates it. + if os.name == "nt": + facts["environment"].pop("IMAGEOS", None) + previous = hashlib.sha256(json.dumps(facts, sort_keys=True).encode()).hexdigest()[:16] + output.write("migration-key=" + prefix.replace(fingerprint, previous) + "\n") + output.write("cargo-home=" + (os.environ.get("CARGO_HOME") or str(Path.home() / ".cargo")) + "\n") PY - name: Restore shared Cargo downloads id: downloads @@ -76,14 +84,15 @@ runs: with: path: ${{ inputs.target }} key: ${{ steps.identity.outputs.key }} - restore-keys: ${{ steps.identity.outputs.restore-key }} + restore-keys: | + ${{ steps.identity.outputs.restore-key }} + ${{ steps.identity.outputs.migration-key }} - name: Recover an existing combined cache during migration if: steps.target.outputs.cache-matched-key == '' && inputs.legacy-key != '' uses: Swatinem/rust-cache@v2 with: shared-key: ${{ inputs.legacy-key }} workspaces: . -> target - env-vars: MACOSX_DEPLOYMENT_TARGET cache-bin: false cache-on-failure: true save-if: false diff --git a/.github/workflows/linux-lua.yml b/.github/workflows/linux-lua.yml index 2223825..8a1151f 100644 --- a/.github/workflows/linux-lua.yml +++ b/.github/workflows/linux-lua.yml @@ -73,7 +73,10 @@ jobs: id: rust-cache with: key: native - target: target/ci + extra-key: -release-check-v2 + target: | + target/ci + target/release legacy-key: ${{ runner.os == 'Windows' && 'windows' || runner.os == 'Linux' && 'linux' || 'macos' }} - name: Prepare pinned Windows console runtime if: runner.os == 'Windows' @@ -81,12 +84,6 @@ jobs: run: ./scripts/prepare-windows-runtime.ps1 -Destination assets/windows/conhost - name: Test complete workspace and native harness run: python scripts/ci_native_tests.py - - name: Save compiled workload - if: ${{ always() && steps.rust-cache.outcome == 'success' && steps.rust-cache.outputs.cache-hit != 'true' }} - uses: actions/cache/save@v4 - with: - path: target/ci - key: ${{ steps.rust-cache.outputs.cache-key }} - name: Check source architecture if: ${{ !cancelled() && runner.os == 'Linux' }} run: python scripts/check_architecture.py @@ -96,6 +93,14 @@ jobs: - name: Check the release workspace if: ${{ !cancelled() }} run: cargo check --locked --workspace --release + - name: Save compiled workload + if: ${{ always() && steps.rust-cache.outcome == 'success' && steps.rust-cache.outputs.cache-hit != 'true' }} + uses: actions/cache/save@v4 + with: + path: | + target/ci + target/release + key: ${{ steps.rust-cache.outputs.cache-key }} - name: Upload native compilation timings if: always() uses: actions/upload-artifact@v4 diff --git a/docs/architecture-decisions.md b/docs/architecture-decisions.md index 044a022..d2d13de 100644 --- a/docs/architecture-decisions.md +++ b/docs/architecture-decisions.md @@ -434,7 +434,8 @@ settings files. platform still runs the complete Python helper and harness suites. Separate test and release cache keys prevent concurrent jobs from replacing one another's compiled workload. Dependency archives and Git objects are shared within each - platform; only the relevant compiled profile is saved per workload. A fallback + platform; each workload saves only the profiles it uses (native tests also keep + release-check metadata). A fallback reads existing combined caches during migration. Cargo still validates source, profile and feature fingerprints before reuse. - **Scheduling:** Stable releases call the same complete four-platform native diff --git a/scripts/conformance/tests/test_windows_standard_user.py b/scripts/conformance/tests/test_windows_standard_user.py index ea65dd0..323f8b5 100644 --- a/scripts/conformance/tests/test_windows_standard_user.py +++ b/scripts/conformance/tests/test_windows_standard_user.py @@ -1,4 +1,5 @@ import os +import sys import unittest from scripts.conformance.windows_standard_user import WindowsTokens, run_python @@ -6,6 +7,18 @@ from scripts.conformance.windows_standard_user import WindowsTokens, run_python @unittest.skipUnless(os.name == "nt", "requires native Windows process tokens") class StandardUserLaunchTests(unittest.TestCase): + def test_restricted_token_can_launch_even_when_parent_is_already_ordinary(self): + api = WindowsTokens() + token, restricted = api.current_token(), None + try: + restricted = api.restrict(token) + self.assertFalse(api.elevated(restricted)) + self.assertEqual(api.run(restricted, [sys.executable, "-c", "raise SystemExit(19)"]), 19) + finally: + if restricted: + api.kernel.CloseHandle(restricted) + api.kernel.CloseHandle(token) + def test_child_is_unelevated_and_preserves_arguments_and_failure_code(self): script = """ import sys diff --git a/scripts/conformance/windows_standard_user.py b/scripts/conformance/windows_standard_user.py index 00eb654..683f603 100644 --- a/scripts/conformance/windows_standard_user.py +++ b/scripts/conformance/windows_standard_user.py @@ -76,9 +76,11 @@ class WindowsTokens: def current_token(self): token = wt.HANDLE() - # QUERY | DUPLICATE | ASSIGN_PRIMARY, for a restricted copy of ourselves. + # QUERY | DUPLICATE | ASSIGN_PRIMARY | ADJUST_DEFAULT. The restricted + # handle inherits this access mask; lowering its integrity needs the last + # right. The original token is never modified. self.require(self.security.OpenProcessToken( - self.kernel.GetCurrentProcess(), 0x000B, ctypes.byref(token))) + self.kernel.GetCurrentProcess(), 0x008B, ctypes.byref(token))) return token def elevated(self, token): diff --git a/scripts/tests/test_ci_cache.py b/scripts/tests/test_ci_cache.py index 3526327..a275880 100644 --- a/scripts/tests/test_ci_cache.py +++ b/scripts/tests/test_ci_cache.py @@ -32,7 +32,7 @@ class CacheIdentityTests(unittest.TestCase): self.assertEqual(arguments, ["xcrun", "--sdk", "macosx", "--show-sdk-version"]) return sdk - with patch.dict(os.environ, environment), patch("subprocess.check_output", side_effect=command): + with patch.dict(os.environ, environment, clear=True), patch("subprocess.check_output", side_effect=command): exec(compile(code, str(ACTION), "exec"), {}) return dict(line.split("=", 1) for line in output.read_text(encoding="utf-8").splitlines()) @@ -63,6 +63,15 @@ class CacheIdentityTests(unittest.TestCase): after = self.identity(GITHUB_SHA="two", PREVIEW_ID="second") self.assertEqual(before["key"], after["key"]) + @unittest.skipUnless(os.name == "nt", "Windows cache migration") + def test_windows_migration_preserves_compiler_and_workload_boundaries(self): + before = self.identity() + after = self.identity(ImageOS="win22") + self.assertEqual(before["restore-key"], after["migration-key"]) + for change in ({"compiler": "rustc 1.98.0"}, {"CACHE_WORKLOAD": "release"}, + {"RUSTFLAGS": "-C opt-level=1"}): + self.assertNotEqual(after["migration-key"], self.identity(**change)["migration-key"]) + if __name__ == "__main__": unittest.main() From af40388859f9458e5ccbb2d0e42e1eb6049b541e Mon Sep 17 00:00:00 2001 From: kuddev Date: Mon, 14 Sep 2026 21:44:18 +0800 Subject: [PATCH 40/51] ci: query restricted tokens with minimal permissions --- .github/workflows/windows-conformance.yml | 52 +++++++++++++++++++ .../tests/test_windows_standard_user.py | 5 +- scripts/conformance/windows_standard_user.py | 17 +++--- 3 files changed, 66 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/windows-conformance.yml diff --git a/.github/workflows/windows-conformance.yml b/.github/workflows/windows-conformance.yml new file mode 100644 index 0000000..21667b9 --- /dev/null +++ b/.github/workflows/windows-conformance.yml @@ -0,0 +1,52 @@ +name: Windows conformance diagnostics + +on: + workflow_dispatch: + inputs: + runtime_run: + description: Release run whose Windows runtime artifact should be diagnosed + required: true + type: string + +permissions: + contents: read + actions: read + +jobs: + diagnose: + runs-on: windows-2022 + timeout-minutes: 10 + env: + PYTHONUTF8: "1" + NEBULA_RESIZE_TRACE: "1" + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - uses: actions/download-artifact@v4 + with: + name: stable-runtime-windows-x64 + run-id: ${{ inputs.runtime_run }} + github-token: ${{ github.token }} + path: diagnostic-runtime + - name: Test native permission helpers + run: python -m unittest scripts.conformance.tests.test_windows_standard_user -v + - name: Read runtime file permissions + if: always() + shell: pwsh + run: | + Get-Acl diagnostic-runtime/pebrel.exe | Format-List + Get-Acl $env:pythonLocation/python.exe | Format-List + - name: Diagnose the previously built runtime + if: always() + shell: pwsh + run: | + python scripts/conformance/windows_standard_user.py scripts/conformance/run.py --app diagnostic-runtime/pebrel.exe --platform windows-x86_64 --output diagnostic-output/windows-report.json + - uses: actions/upload-artifact@v4 + if: always() + with: + name: windows-conformance-diagnostics + path: diagnostic-output + if-no-files-found: warn + retention-days: 7 diff --git a/scripts/conformance/tests/test_windows_standard_user.py b/scripts/conformance/tests/test_windows_standard_user.py index 323f8b5..9257d0b 100644 --- a/scripts/conformance/tests/test_windows_standard_user.py +++ b/scripts/conformance/tests/test_windows_standard_user.py @@ -9,7 +9,7 @@ from scripts.conformance.windows_standard_user import WindowsTokens, run_python class StandardUserLaunchTests(unittest.TestCase): def test_restricted_token_can_launch_even_when_parent_is_already_ordinary(self): api = WindowsTokens() - token, restricted = api.current_token(), None + token, restricted = api.current_token(0x008B), None try: restricted = api.restrict(token) self.assertFalse(api.elevated(restricted)) @@ -22,6 +22,7 @@ class StandardUserLaunchTests(unittest.TestCase): def test_child_is_unelevated_and_preserves_arguments_and_failure_code(self): script = """ import sys +import subprocess from scripts.conformance.windows_standard_user import WindowsTokens api = WindowsTokens() token = api.current_token() @@ -30,7 +31,7 @@ try: finally: api.kernel.CloseHandle(token) assert sys.argv[1:] == ['path with spaces\\\\', '中文', 'literal & symbol'] -raise SystemExit(73) +raise SystemExit(subprocess.call([sys.executable, '-c', 'raise SystemExit(73)'])) """ self.assertEqual(run_python(["-c", script, "path with spaces\\", "中文", "literal & symbol"]), 73) diff --git a/scripts/conformance/windows_standard_user.py b/scripts/conformance/windows_standard_user.py index 683f603..f0a4ff2 100644 --- a/scripts/conformance/windows_standard_user.py +++ b/scripts/conformance/windows_standard_user.py @@ -74,13 +74,12 @@ class WindowsTokens: if not success: raise ctypes.WinError(ctypes.get_last_error()) - def current_token(self): + def current_token(self, access=0x0008): token = wt.HANDLE() - # QUERY | DUPLICATE | ASSIGN_PRIMARY | ADJUST_DEFAULT. The restricted - # handle inherits this access mask; lowering its integrity needs the last - # right. The original token is never modified. + # Elevation checks only need QUERY. In particular, a child launched from + # an administrator's restricted token may not reopen it for adjustment. self.require(self.security.OpenProcessToken( - self.kernel.GetCurrentProcess(), 0x008B, ctypes.byref(token))) + self.kernel.GetCurrentProcess(), access, ctypes.byref(token))) return token def elevated(self, token): @@ -140,7 +139,13 @@ def run_python(arguments): command = [sys.executable, *arguments] if not api.elevated(token): return subprocess.run(command, check=False).returncode - restricted = api.restrict(token) + # QUERY | DUPLICATE | ASSIGN_PRIMARY | ADJUST_DEFAULT. Only the elevated + # parent needs these rights, to create and lower its restricted copy. + source = api.current_token(0x008B) + try: + restricted = api.restrict(source) + finally: + api.kernel.CloseHandle(source) print("Running native conformance with a verified non-elevated token", flush=True) return api.run(restricted, command) finally: From 67054fcf96f4c6838c1b5b488aa74e2938e24f46 Mon Sep 17 00:00:00 2001 From: kuddev Date: Mon, 14 Sep 2026 21:45:40 +0800 Subject: [PATCH 41/51] ci: expose isolated Windows diagnostics from native workflow --- .github/workflows/linux-lua.yml | 13 +++++++++++++ .github/workflows/release.yml | 3 +++ .github/workflows/windows-conformance.yml | 2 +- 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/.github/workflows/linux-lua.yml b/.github/workflows/linux-lua.yml index 8a1151f..646e32f 100644 --- a/.github/workflows/linux-lua.yml +++ b/.github/workflows/linux-lua.yml @@ -2,6 +2,11 @@ name: Full native tests on: workflow_dispatch: + inputs: + windows_diagnostics_run: + description: Diagnose a previous Windows runtime without running a release validation + type: string + default: "" workflow_call: push: # Releases call this entire workflow instead of duplicating branch-push jobs. @@ -28,9 +33,17 @@ on: permissions: contents: read + actions: read jobs: + windows-diagnostics: + if: ${{ github.event_name == 'workflow_dispatch' && inputs.windows_diagnostics_run != '' }} + uses: ./.github/workflows/windows-conformance.yml + with: + runtime_run: ${{ inputs.windows_diagnostics_run }} + native-tests: + if: ${{ inputs.windows_diagnostics_run == '' }} name: Tests (${{ matrix.os }}) strategy: fail-fast: false diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b7973e3..70cf187 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -121,6 +121,9 @@ jobs: native-tests: name: Native tests + permissions: + contents: read + actions: read if: ${{ !inputs.windows_only }} needs: prepare uses: ./.github/workflows/linux-lua.yml diff --git a/.github/workflows/windows-conformance.yml b/.github/workflows/windows-conformance.yml index 21667b9..db58fe6 100644 --- a/.github/workflows/windows-conformance.yml +++ b/.github/workflows/windows-conformance.yml @@ -1,7 +1,7 @@ name: Windows conformance diagnostics on: - workflow_dispatch: + workflow_call: inputs: runtime_run: description: Release run whose Windows runtime artifact should be diagnosed From c39d3338e534737b2f62034071da3604932d8379 Mon Sep 17 00:00:00 2001 From: kuddev Date: Mon, 14 Sep 2026 21:48:52 +0800 Subject: [PATCH 42/51] test(windows): exercise process containment after privilege reduction --- .github/workflows/windows-conformance.yml | 3 +++ scripts/conformance/harness.py | 6 +++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/windows-conformance.yml b/.github/workflows/windows-conformance.yml index db58fe6..f8c2aed 100644 --- a/.github/workflows/windows-conformance.yml +++ b/.github/workflows/windows-conformance.yml @@ -38,6 +38,9 @@ jobs: run: | Get-Acl diagnostic-runtime/pebrel.exe | Format-List Get-Acl $env:pythonLocation/python.exe | Format-List + - name: Test containment with an ordinary token + if: always() + run: python scripts/conformance/windows_standard_user.py -m unittest scripts.conformance.tests.test_lifecycle -v - name: Diagnose the previously built runtime if: always() shell: pwsh diff --git a/scripts/conformance/harness.py b/scripts/conformance/harness.py index d6b0180..92da174 100644 --- a/scripts/conformance/harness.py +++ b/scripts/conformance/harness.py @@ -337,13 +337,13 @@ class _WindowsJob: self._process_handles: list[int] = [] self._handle = self._api.CreateJobObjectW(None, None) if not self._handle: - raise ctypes.WinError(ctypes.get_last_error()) + raise ctypes.WinError(ctypes.get_last_error(), "CreateJobObjectW failed") 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()) + error = ctypes.WinError(ctypes.get_last_error(), "SetInformationJobObject failed") self.close() raise error @@ -351,7 +351,7 @@ class _WindowsJob: # 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()) + raise ctypes.WinError(ctypes.get_last_error(), "AssignProcessToJobObject failed") def terminate(self, timeout: float = 5.0) -> None: deadline = time.monotonic() + timeout From d2306b91ad0a2f26b6356e76623b80fd11fb9fbd Mon Sep 17 00:00:00 2001 From: kuddev Date: Mon, 14 Sep 2026 21:54:11 +0800 Subject: [PATCH 43/51] ci: grant restricted runners access to their own pipes and refresh caches --- .github/actions/rust-cache/action.yml | 7 +++- docs/architecture-decisions.md | 4 +- .../tests/test_windows_standard_user.py | 5 ++- scripts/conformance/windows_standard_user.py | 40 +++++++++++++++++++ scripts/tests/test_ci_cache.py | 10 +++-- 5 files changed, 60 insertions(+), 6 deletions(-) diff --git a/.github/actions/rust-cache/action.yml b/.github/actions/rust-cache/action.yml index bf542b4..2fd0f0e 100644 --- a/.github/actions/rust-cache/action.yml +++ b/.github/actions/rust-cache/action.yml @@ -33,6 +33,7 @@ runs: CACHE_OS: ${{ runner.os }} CACHE_ARCH: ${{ runner.arch }} CACHE_WORKLOAD: ${{ inputs.key }} + CACHE_REVISION: ${{ github.sha }} CACHE_MANIFESTS: ${{ hashFiles('Cargo.lock', '**/Cargo.toml', 'rust-toolchain.toml', '**/.cargo/config.toml', '.github/ci-profile.toml') }}${{ inputs.extra-key }} run: | python - <<'PY' @@ -56,7 +57,10 @@ runs: ("CACHE_OS", "CACHE_ARCH", "CACHE_WORKLOAD")) prefix += "-" + fingerprint + "-" with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output: - output.write("key=" + prefix + os.environ["CACHE_MANIFESTS"] + "\n") + # Immutable caches need a new entry after a source change, including + # when the prior revision failed before it compiled every profile. + output.write("key=" + prefix + os.environ["CACHE_MANIFESTS"] + "-" + os.environ["CACHE_REVISION"] + "\n") + output.write("manifest-key=" + prefix + os.environ["CACHE_MANIFESTS"] + "\n") output.write("restore-key=" + prefix + "\n") # Recover the preceding Windows cache, which omitted ImageOS because # Python uppercases environment names there. Cargo revalidates it. @@ -85,6 +89,7 @@ runs: path: ${{ inputs.target }} key: ${{ steps.identity.outputs.key }} restore-keys: | + ${{ steps.identity.outputs.manifest-key }} ${{ steps.identity.outputs.restore-key }} ${{ steps.identity.outputs.migration-key }} - name: Recover an existing combined cache during migration diff --git a/docs/architecture-decisions.md b/docs/architecture-decisions.md index d2d13de..23052d8 100644 --- a/docs/architecture-decisions.md +++ b/docs/architecture-decisions.md @@ -437,7 +437,9 @@ settings files. platform; each workload saves only the profiles it uses (native tests also keep release-check metadata). A fallback reads existing combined caches during migration. Cargo still validates source, - profile and feature fingerprints before reuse. + profile and feature fingerprints before reuse. Each source revision saves an + immutable entry while restoring compatible earlier revisions; a partially built + cache from a failed revision cannot prevent later successful cache updates. - **Scheduling:** Stable releases call the same complete four-platform native workflow used for contributions, including architecture, translation allocation contracts and the release-workspace check. Release branch pushes omit a duplicate diff --git a/scripts/conformance/tests/test_windows_standard_user.py b/scripts/conformance/tests/test_windows_standard_user.py index 9257d0b..2dc46cc 100644 --- a/scripts/conformance/tests/test_windows_standard_user.py +++ b/scripts/conformance/tests/test_windows_standard_user.py @@ -31,7 +31,10 @@ try: finally: api.kernel.CloseHandle(token) assert sys.argv[1:] == ['path with spaces\\\\', '中文', 'literal & symbol'] -raise SystemExit(subprocess.call([sys.executable, '-c', 'raise SystemExit(73)'])) +child = subprocess.run([sys.executable, '-c', 'print("pipe output"); raise SystemExit(73)'], + input='pipe input', capture_output=True, text=True) +assert child.stdout.strip() == 'pipe output' +raise SystemExit(child.returncode) """ self.assertEqual(run_python(["-c", script, "path with spaces\\", "中文", "literal & symbol"]), 73) diff --git a/scripts/conformance/windows_standard_user.py b/scripts/conformance/windows_standard_user.py index f0a4ff2..f9eff4f 100644 --- a/scripts/conformance/windows_standard_user.py +++ b/scripts/conformance/windows_standard_user.py @@ -57,6 +57,13 @@ class WindowsTokens: (self.security, "GetLengthSid", wt.DWORD, [wt.LPVOID]), (self.security, "ConvertStringSidToSidW", wt.BOOL, [wt.LPCWSTR, ctypes.POINTER(wt.LPVOID)]), + (self.security, "ConvertSidToStringSidW", wt.BOOL, + [wt.LPVOID, ctypes.POINTER(wt.LPWSTR)]), + (self.security, "ConvertStringSecurityDescriptorToSecurityDescriptorW", wt.BOOL, + [wt.LPCWSTR, wt.DWORD, ctypes.POINTER(wt.LPVOID), ctypes.POINTER(wt.DWORD)]), + (self.security, "GetSecurityDescriptorDacl", wt.BOOL, + [wt.LPVOID, ctypes.POINTER(wt.BOOL), ctypes.POINTER(wt.LPVOID), + ctypes.POINTER(wt.BOOL)]), (self.security, "CreateRestrictedToken", wt.BOOL, [wt.HANDLE, wt.DWORD, wt.DWORD, wt.LPVOID, wt.DWORD, wt.LPVOID, wt.DWORD, wt.LPVOID, ctypes.POINTER(wt.HANDLE)]), @@ -100,6 +107,7 @@ class WindowsTokens: self.require(self.security.SetTokenInformation( restricted, 25, ctypes.byref(label), ctypes.sizeof(label) + self.security.GetLengthSid(sid))) + self.set_user_default_dacl(restricted) if self.elevated(restricted): raise RuntimeError("restricted token is still elevated; refusing conformance") return restricted @@ -110,6 +118,38 @@ class WindowsTokens: if sid: self.kernel.LocalFree(sid) + def set_user_default_dacl(self, token): + # Elevated runners can default new objects to Administrators/SYSTEM. + # After filtering administrators, anonymous pipes then deny their own + # creator access. Grant the same user and SYSTEM access on new objects + # created with this temporary token; no existing file ACL is changed. + size = wt.DWORD() + self.security.GetTokenInformation(token, 1, None, 0, ctypes.byref(size)) + if not size.value: + raise ctypes.WinError(ctypes.get_last_error()) + user = ctypes.create_string_buffer(size.value) + self.require(self.security.GetTokenInformation( + token, 1, user, size, ctypes.byref(size))) + sid, descriptor = wt.LPWSTR(), wt.LPVOID() + try: + self.require(self.security.ConvertSidToStringSidW( + SidAndAttributes.from_buffer(user).Sid, ctypes.byref(sid))) + self.require(self.security.ConvertStringSecurityDescriptorToSecurityDescriptorW( + "D:(A;;GA;;;SY)(A;;GA;;;" + sid.value + ")", 1, + ctypes.byref(descriptor), None)) + present, defaulted, acl = wt.BOOL(), wt.BOOL(), wt.LPVOID() + self.require(self.security.GetSecurityDescriptorDacl( + descriptor, ctypes.byref(present), ctypes.byref(acl), ctypes.byref(defaulted))) + if not present.value or not acl: + raise RuntimeError("ordinary token requires an explicit user DACL") + self.require(self.security.SetTokenInformation( + token, 6, ctypes.byref(acl), ctypes.sizeof(acl))) # TokenDefaultDacl. + finally: + if descriptor: + self.kernel.LocalFree(descriptor) + if sid: + self.kernel.LocalFree(ctypes.cast(sid, wt.HANDLE)) + def run(self, token, arguments): command = ctypes.create_unicode_buffer(subprocess.list2cmdline(arguments)) startup = StartupInfo() diff --git a/scripts/tests/test_ci_cache.py b/scripts/tests/test_ci_cache.py index a275880..f3e51bc 100644 --- a/scripts/tests/test_ci_cache.py +++ b/scripts/tests/test_ci_cache.py @@ -23,6 +23,7 @@ class CacheIdentityTests(unittest.TestCase): environment = { "CACHE_OS": "Linux", "CACHE_ARCH": "X64", "CACHE_WORKLOAD": "ci-product", "CACHE_MANIFESTS": "pinned-manifests", "GITHUB_OUTPUT": str(output), + "CACHE_REVISION": "source-revision", "CARGO_HOME": str(Path(temporary) / "cargo"), **changes, } @@ -59,9 +60,12 @@ class CacheIdentityTests(unittest.TestCase): self.assertTrue(after["key"].startswith(after["restore-key"])) def test_source_only_revision_and_package_labels_do_not_bust_dependencies(self): - before = self.identity(GITHUB_SHA="one", PREVIEW_ID="first") - after = self.identity(GITHUB_SHA="two", PREVIEW_ID="second") - self.assertEqual(before["key"], after["key"]) + before = self.identity(CACHE_REVISION="one", PREVIEW_ID="first") + after = self.identity(CACHE_REVISION="two", PREVIEW_ID="second") + self.assertNotEqual(before["key"], after["key"]) + self.assertEqual(before["manifest-key"], after["manifest-key"]) + self.assertEqual(before["restore-key"], after["restore-key"]) + self.assertEqual(before["key"], self.identity(CACHE_REVISION="one", PREVIEW_ID="third")["key"]) @unittest.skipUnless(os.name == "nt", "Windows cache migration") def test_windows_migration_preserves_compiler_and_workload_boundaries(self): From 7f454f88bc65ddba1131a6d84b137c3060b674fc Mon Sep 17 00:00:00 2001 From: GeekMr <140129782+WilliamWang1721@users.noreply.github.com> Date: Mon, 14 Sep 2026 21:56:35 +0800 Subject: [PATCH 44/51] =?UTF-8?q?test:=20=E4=B8=BA=E5=8E=9F=E7=94=9F=20she?= =?UTF-8?q?ll=20=E8=BE=93=E5=87=BA=E9=AA=8C=E8=AF=81=E4=BF=9D=E7=95=99?= =?UTF-8?q?=E5=86=B7=E5=90=AF=E5=8A=A8=E7=AD=89=E5=BE=85=E6=97=B6=E9=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/conformance/tests/test_scrollback.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/conformance/tests/test_scrollback.py b/scripts/conformance/tests/test_scrollback.py index b43f5df..4ef8e1f 100644 --- a/scripts/conformance/tests/test_scrollback.py +++ b/scripts/conformance/tests/test_scrollback.py @@ -93,7 +93,8 @@ class ScrollbackCompletionTests(unittest.TestCase): capture_output=True, text=True, encoding="utf-8", - timeout=10, + # Include cold shell startup on shared CI runners; this checks output, not latency. + timeout=60, check=True, ) self.assertEqual( From 90f3a218c32877b48250276c5bdb64b004746e00 Mon Sep 17 00:00:00 2001 From: GeekMr <140129782+WilliamWang1721@users.noreply.github.com> Date: Mon, 14 Sep 2026 21:56:45 +0800 Subject: [PATCH 45/51] =?UTF-8?q?test:=20=E4=B8=BA=E5=8E=9F=E7=94=9F=20she?= =?UTF-8?q?ll=20=E8=BE=93=E5=87=BA=E9=AA=8C=E8=AF=81=E4=BF=9D=E7=95=99?= =?UTF-8?q?=E5=86=B7=E5=90=AF=E5=8A=A8=E7=AD=89=E5=BE=85=E6=97=B6=E9=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/conformance/tests/test_scrollback.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/conformance/tests/test_scrollback.py b/scripts/conformance/tests/test_scrollback.py index b43f5df..4ef8e1f 100644 --- a/scripts/conformance/tests/test_scrollback.py +++ b/scripts/conformance/tests/test_scrollback.py @@ -93,7 +93,8 @@ class ScrollbackCompletionTests(unittest.TestCase): capture_output=True, text=True, encoding="utf-8", - timeout=10, + # Include cold shell startup on shared CI runners; this checks output, not latency. + timeout=60, check=True, ) self.assertEqual( From 615ace707e3050941c612404d6dc470402417cee Mon Sep 17 00:00:00 2001 From: kuddev Date: Mon, 14 Sep 2026 22:04:12 +0800 Subject: [PATCH 46/51] test(conformance): wait for initial shell output during bounded boot --- .github/workflows/windows-conformance.yml | 1 + scripts/conformance/cases/boot.py | 13 ++++++- scripts/conformance/tests/test_harness.py | 43 ++++++++++++++++++++++- 3 files changed, 55 insertions(+), 2 deletions(-) diff --git a/.github/workflows/windows-conformance.yml b/.github/workflows/windows-conformance.yml index f8c2aed..cd2fcc5 100644 --- a/.github/workflows/windows-conformance.yml +++ b/.github/workflows/windows-conformance.yml @@ -19,6 +19,7 @@ jobs: env: PYTHONUTF8: "1" NEBULA_RESIZE_TRACE: "1" + NEBULA_BOOT_TRACE: "1" steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 diff --git a/scripts/conformance/cases/boot.py b/scripts/conformance/cases/boot.py index cccee9f..0a5376a 100644 --- a/scripts/conformance/cases/boot.py +++ b/scripts/conformance/cases/boot.py @@ -1,9 +1,12 @@ from __future__ import annotations +import time + from conformance.harness import ConformanceContext, PROTOCOL_NAME, PROTOCOL_VERSION, path_shape, require def run(ctx: ConformanceContext) -> dict[str, object]: + started = time.monotonic() snapshot = ctx.snapshot() ctx.refresh_targets(snapshot) windows = snapshot.get("windows") or [] @@ -47,6 +50,14 @@ def run(ctx: ConformanceContext) -> dict[str, object]: cwd = pane.get("cwd", "") require(path_shape(cwd) != "relative", f"pane cwd is not absolute: {cwd!r}") + # Runtime discovery and a child PID can exist before a cold shell emits any + # text. Finish the bounded boot phase before the echo case submits input; + # its own output deadline and single submission remain unchanged. + ctx.poll( + ctx.read, lambda read: bool(read["text"].strip()), + "default shell did not render initial output", ctx.startup_timeout, + ) + return { "window_count": 1, "default_tab_present": True, @@ -54,5 +65,5 @@ def run(ctx: ConformanceContext) -> dict[str, object]: "process_tree_available": True, "shell_category": shell, "cwd_shape": path_shape(cwd), - "startup_ms": ctx.startup_ms, + "startup_ms": ctx.startup_ms + round((time.monotonic() - started) * 1000), } diff --git a/scripts/conformance/tests/test_harness.py b/scripts/conformance/tests/test_harness.py index e1b97e5..0bddd08 100644 --- a/scripts/conformance/tests/test_harness.py +++ b/scripts/conformance/tests/test_harness.py @@ -8,7 +8,7 @@ import sys import tempfile import unittest from types import SimpleNamespace -from unittest.mock import patch +from unittest.mock import Mock, patch import zipfile from pathlib import Path @@ -31,6 +31,47 @@ from conformance.harness import ( # noqa: E402 from conformance.macos_launch import capture_screenshot +class BootReadinessTests(unittest.TestCase): + def context(self, outputs): + from conformance.harness import ConformanceContext, PROTOCOL_NAME, PROTOCOL_VERSION + + pane = {"id": 1, "cwd": r"C:\fixture"} + tab = {"panes": [pane]} + snapshot = {"protocol_version": PROTOCOL_VERSION, "windows": [{"tabs": [tab]}]} + context = SimpleNamespace( + snapshot=lambda: snapshot, refresh_targets=lambda value: None, + window_id=1, pane_id=1, startup_ms=10, startup_timeout=4, + description={"protocol": PROTOCOL_NAME, "protocol_version": PROTOCOL_VERSION, + "capabilities": ["runtime.snapshot", "window.close", "tab.new", + "tab.rename", "pane.split", "pane.resize", "pane.prompt", + "pane.paste", "pane.read", "pane.procs", "pane.send_key"]}, + detect_shell=lambda: "powershell", api=lambda *args: {"processes": [{"pid": 42}]}, + tab_for_pane=lambda *args: tab, read=Mock(side_effect=outputs), + ) + context.poll = ConformanceContext.poll.__get__(context) + return context + + def test_runtime_discovery_alone_does_not_finish_cold_shell_boot(self): + from conformance.cases.boot import run + + context = self.context([{"text": ""}, {"text": " \n"}, {"text": "PS C:\\fixture> "}]) + with patch("time.sleep"), patch("time.monotonic", side_effect=range(20)): + result = run(context) + self.assertEqual(context.read.call_count, 3) + self.assertTrue(result["default_tab_present"]) + self.assertGreater(result["startup_ms"], context.startup_ms) + + def test_silent_shell_fails_boot_instead_of_being_marked_ready(self): + from conformance.cases.boot import run + + context = self.context(None) + context.read.side_effect = None + context.read.return_value = {"text": ""} + with patch("time.sleep"), patch("time.monotonic", side_effect=range(20)): + with self.assertRaisesRegex(ConformanceError, "did not render initial output"): + run(context) + + class NormalizationTests(unittest.TestCase): def test_normalize_path_handles_drive_and_posix_paths(self) -> None: self.assertEqual(normalize_path(r"C:\Users\Test\nebula"), "/c/Users/Test/nebula") From e33465943ae66859b658159a51fc872de7a8071d Mon Sep 17 00:00:00 2001 From: kuddev Date: Tue, 15 Sep 2026 09:51:18 +0800 Subject: [PATCH 47/51] ci(macos): run release checks beside the complete native tests Separate the independent macOS release metadata checks from native test jobs while retaining both architectures and the full release gate. Preserve Git dependency checkouts in the shared Cargo cache and compile tests before checking the product graph. --- .github/actions/rust-cache/action.yml | 12 ++++-- .github/workflows/linux-lua.yml | 62 +++++++++++++++++++++++++-- scripts/ci_native_tests.py | 11 +++-- 3 files changed, 73 insertions(+), 12 deletions(-) diff --git a/.github/actions/rust-cache/action.yml b/.github/actions/rust-cache/action.yml index 2fd0f0e..309c24b 100644 --- a/.github/actions/rust-cache/action.yml +++ b/.github/actions/rust-cache/action.yml @@ -74,14 +74,17 @@ runs: id: downloads uses: actions/cache/restore@v4 with: - # Keep the immutable archives/Git objects once per native platform. - # Cargo recreates checkouts and extracted registry sources from these. + # Preserve Git source checkouts with their mtimes. Recreating them from + # git/db can make timestamp-based dependency fingerprints dirty even + # after compiled targets were restored. Registry crates remain immutable + # archives: Cargo can recreate their extracted sources without this risk. path: | ${{ steps.identity.outputs.cargo-home }}/registry/index ${{ steps.identity.outputs.cargo-home }}/registry/cache ${{ steps.identity.outputs.cargo-home }}/git/db - key: cargo-downloads-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('Cargo.lock', '**/Cargo.toml') }} - restore-keys: cargo-downloads-v1-${{ runner.os }}-${{ runner.arch }}- + ${{ steps.identity.outputs.cargo-home }}/git/checkouts + key: cargo-downloads-v2-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('Cargo.lock', '**/Cargo.toml') }} + restore-keys: cargo-downloads-v2-${{ runner.os }}-${{ runner.arch }}- - name: Restore compiled targets id: target uses: actions/cache/restore@v4 @@ -116,4 +119,5 @@ runs: ${{ steps.identity.outputs.cargo-home }}/registry/index ${{ steps.identity.outputs.cargo-home }}/registry/cache ${{ steps.identity.outputs.cargo-home }}/git/db + ${{ steps.identity.outputs.cargo-home }}/git/checkouts key: ${{ steps.downloads.outputs.cache-primary-key }} diff --git a/.github/workflows/linux-lua.yml b/.github/workflows/linux-lua.yml index 646e32f..3e6298e 100644 --- a/.github/workflows/linux-lua.yml +++ b/.github/workflows/linux-lua.yml @@ -86,10 +86,10 @@ jobs: id: rust-cache with: key: native - extra-key: -release-check-v2 + extra-key: ${{ runner.os != 'macOS' && '-release-check-v2' || '' }} target: | target/ci - target/release + ${{ runner.os != 'macOS' && 'target/release' || '' }} legacy-key: ${{ runner.os == 'Windows' && 'windows' || runner.os == 'Linux' && 'linux' || 'macos' }} - name: Prepare pinned Windows console runtime if: runner.os == 'Windows' @@ -104,7 +104,7 @@ jobs: if: ${{ !cancelled() && runner.os == 'Linux' }} run: cargo test --locked --manifest-path tools/i18n-contract/Cargo.toml - name: Check the release workspace - if: ${{ !cancelled() }} + if: ${{ !cancelled() && runner.os != 'macOS' }} run: cargo check --locked --workspace --release - name: Save compiled workload if: ${{ always() && steps.rust-cache.outcome == 'success' && steps.rust-cache.outputs.cache-hit != 'true' }} @@ -112,7 +112,7 @@ jobs: with: path: | target/ci - target/release + ${{ runner.os != 'macOS' && 'target/release' || '' }} key: ${{ steps.rust-cache.outputs.cache-key }} - name: Upload native compilation timings if: always() @@ -123,3 +123,57 @@ jobs: if-no-files-found: warn compression-level: 0 retention-days: 14 + + # The release check has no dependency on the test executable. Keeping it in + # the Intel test job added 11m33s after a 22m29s test/compile step in run + # 34856491552. Both jobs remain part of this reusable workflow's result, so + # the stable release aggregation still requires every check to succeed. + macos-release-check: + if: ${{ inputs.windows_diagnostics_run == '' }} + name: Release workspace (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [macos-26, macos-26-intel] + runs-on: ${{ matrix.os }} + timeout-minutes: 120 + env: + PYTHONUTF8: "1" + CARGO_TERM_COLOR: always + RUST_BACKTRACE: "1" + RUST_TOOLCHAIN: "1.97.1" + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - name: Require the AppKit SDK used by this PR + run: | + sdk_version="$(xcrun --sdk macosx --show-sdk-version)" + test "${sdk_version%%.*}" -ge 26 + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: "1.97.1" + - uses: ./.github/actions/rust-cache + id: rust-cache + with: + key: native-release-check + target: target/release + legacy-key: macos + - name: Check the release workspace + run: cargo check --locked --workspace --release --timings + - name: Save compiled workload + if: ${{ always() && steps.rust-cache.outcome == 'success' && steps.rust-cache.outputs.cache-hit != 'true' }} + uses: actions/cache/save@v4 + with: + path: target/release + key: ${{ steps.rust-cache.outputs.cache-key }} + - name: Upload release check compilation timings + if: always() + uses: actions/upload-artifact@v4 + with: + name: release-check-timings-${{ matrix.os }} + path: target/cargo-timings + if-no-files-found: warn + compression-level: 0 + retention-days: 14 diff --git a/scripts/ci_native_tests.py b/scripts/ci_native_tests.py index 36022a4..e68b682 100644 --- a/scripts/ci_native_tests.py +++ b/scripts/ci_native_tests.py @@ -12,14 +12,17 @@ def native_commands() -> list[list[str]]: return [ [sys.executable, "-m", "unittest", "discover", "-s", "scripts/tests", "-v"], [sys.executable, "-m", "unittest", "discover", "-s", "scripts/conformance/tests", "-v"], - [ - "cargo", "check", "--locked", *profile, "-p", "nebula", "--bin", "pebrel", - "--features", "gpui-shell", "--timings", - ], [ "cargo", "test", "--locked", *profile, "--workspace", "--features", "nebula/gpui-test-support", "--timings", ], + # Link the test graph first: check can reuse compatible compiled + # dependencies, while metadata-only check output cannot link the tests. + # Keep the actual production feature graph independently checked. + [ + "cargo", "check", "--locked", *profile, "-p", "nebula", "--bin", "pebrel", + "--features", "gpui-shell", "--timings", + ], ] From 224e39a2348d133fd2692695a0de5b235f36e1a8 Mon Sep 17 00:00:00 2001 From: kuddev Date: Tue, 15 Sep 2026 10:29:25 +0800 Subject: [PATCH 48/51] test(theme): adapt reader selection fixtures to resolved themes --- nebula_app/src/gpui_shell/theme/selection_tests.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nebula_app/src/gpui_shell/theme/selection_tests.rs b/nebula_app/src/gpui_shell/theme/selection_tests.rs index e0f4eec..3d538fa 100644 --- a/nebula_app/src/gpui_shell/theme/selection_tests.rs +++ b/nebula_app/src/gpui_shell/theme/selection_tests.rs @@ -9,16 +9,16 @@ use gpui_component::{ }; use nebula_settings::ThemeName; -use super::{apply_skin_tokens, chrome_theme, wash}; +use super::{ResolvedTheme, apply_skin_tokens, chrome_theme, wash}; fn apply_reader_theme(name: ThemeName, cx: &mut App) { - let chrome = chrome_theme(name); + let chrome = ResolvedTheme::builtin(name, None); let mode = if chrome.skin().is_light { ThemeMode::Light } else { ThemeMode::Dark }; Theme::change(mode, None, cx); - apply_skin_tokens(chrome, cx); + apply_skin_tokens(&chrome, cx); // Match the product shell at full opacity without loading wallpaper, // preferences, or tray integrations into the isolated test process. - let background = super::shell_color(chrome); + let background = super::shell_color(chrome.chrome_palette()); let theme = Theme::global_mut(cx); theme.background = background; theme.tokens.background = background.into(); From d426de4ae1cbd6559665d2f9766a4d87e123c6ac Mon Sep 17 00:00:00 2001 From: kuddev Date: Tue, 15 Sep 2026 10:51:15 +0800 Subject: [PATCH 49/51] fix(theme): share shorthand color parsing and guard native fixtures Accept three-digit RGB colors through the shared settings parser. Keep save-only assertions stable while other rendered fixtures apply themes to the same settings file. --- nebula_app/src/display/settings.rs | 9 +----- .../settings_pane/theme_studio_tests.rs | 30 +++++++++++++++++++ nebula_settings/src/lib.rs | 22 ++++++++++++-- 3 files changed, 51 insertions(+), 10 deletions(-) diff --git a/nebula_app/src/display/settings.rs b/nebula_app/src/display/settings.rs index 650eae0..a9e5142 100644 --- a/nebula_app/src/display/settings.rs +++ b/nebula_app/src/display/settings.rs @@ -1106,14 +1106,7 @@ pub(super) fn cursor_shape_settings_value(shape: CursorShape) -> &'static str { } pub(crate) fn parse_hex_rgb(value: &str) -> Option { - let hex = value.strip_prefix('#').unwrap_or(value); - if hex.len() != 6 { - return None; - } - let r = u8::from_str_radix(&hex[0..2], 16).ok()?; - let g = u8::from_str_radix(&hex[2..4], 16).ok()?; - let b = u8::from_str_radix(&hex[4..6], 16).ok()?; - Some(Rgb::new(r, g, b)) + nebula_settings::parse_hex_rgb(value).map(|[r, g, b]| Rgb::new(r, g, b)) } fn format_hex_rgb(rgb: Rgb) -> String { diff --git a/nebula_app/src/gpui_shell/settings_pane/theme_studio_tests.rs b/nebula_app/src/gpui_shell/settings_pane/theme_studio_tests.rs index 6e3eeb1..2b7a074 100644 --- a/nebula_app/src/gpui_shell/settings_pane/theme_studio_tests.rs +++ b/nebula_app/src/gpui_shell/settings_pane/theme_studio_tests.rs @@ -13,6 +13,15 @@ use nebula_settings::{RawSettings, RuntimeSettings, ThemeDefinition, ThemeName}; const TEST_SETTINGS: &str = "theme=Nord\nfollow_system_theme=0\napp_icon=graphite-violet\nfont_size=15\n"; +// These rendered fixtures share the real settings path and theme library. +// Readers must hold the same guard as Save/Apply tests so their before/after +// snapshots cannot observe another fixture's writes or restoration cleanup. +// Only this fixture group is serialized; the rest of the native suite stays parallel. +fn lock_theme_studio() -> std::sync::MutexGuard<'static, ()> { + static FIXTURES: std::sync::Mutex<()> = std::sync::Mutex::new(()); + FIXTURES.lock().unwrap_or_else(|error| error.into_inner()) +} + #[derive(Clone, Debug, PartialEq)] struct RuntimeSnapshot { theme: ThemeName, @@ -357,6 +366,7 @@ fn editor_template(pane: &Entity, cx: &mut VisualTestContext) -> T fn theme_editor_opens_from_the_picker_with_common_fields_and_collapsed_advanced( cx: &mut TestAppContext, ) { + let _fixture_guard = lock_theme_studio(); let (_pane, mut window) = open_settings(cx); open_theme_editor(&mut window); @@ -387,6 +397,7 @@ fn theme_editor_opens_from_the_picker_with_common_fields_and_collapsed_advanced( fn theme_editor_input_is_a_local_draft_until_apply_and_preserves_builtin_source( cx: &mut TestAppContext, ) { + let _fixture_guard = lock_theme_studio(); let (pane, mut window) = open_settings(cx); let before_runtime = runtime_snapshot(); let before_palette = palette_snapshot(&mut window); @@ -409,6 +420,7 @@ fn theme_editor_input_is_a_local_draft_until_apply_and_preserves_builtin_source( #[gpui::test] fn theme_editor_template_keyboard_selection_replaces_only_the_draft(cx: &mut TestAppContext) { + let _fixture_guard = lock_theme_studio(); let (pane, mut window) = open_settings(cx); let before_runtime = runtime_snapshot(); open_theme_editor(&mut window); @@ -430,6 +442,7 @@ fn theme_editor_template_keyboard_selection_replaces_only_the_draft(cx: &mut Tes fn theme_editor_dirty_back_and_escape_show_confirmation_and_keep_or_discard_draft( cx: &mut TestAppContext, ) { + let _fixture_guard = lock_theme_studio(); let (pane, mut window) = open_settings(cx); let before_runtime = runtime_snapshot(); let before_palette = palette_snapshot(&mut window); @@ -453,6 +466,7 @@ fn theme_editor_dirty_back_and_escape_show_confirmation_and_keep_or_discard_draf #[gpui::test] fn theme_editor_back_returns_to_picker_and_preserves_its_draft(cx: &mut TestAppContext) { + let _fixture_guard = lock_theme_studio(); let (pane, mut window) = open_settings(cx); click("open-theme-picker", &mut window); click_first_theme_option(&mut window); @@ -483,6 +497,7 @@ fn theme_editor_back_returns_to_picker_and_preserves_its_draft(cx: &mut TestAppC fn theme_editor_save_only_persists_a_copy_without_changing_source_selection( cx: &mut TestAppContext, ) { + let _fixture_guard = lock_theme_studio(); let (pane, mut window) = open_settings(cx); let before_runtime = runtime_snapshot(); let before_palette = palette_snapshot(&mut window); @@ -532,6 +547,7 @@ fn theme_editor_save_only_persists_a_copy_without_changing_source_selection( #[gpui::test] fn theme_editor_save_and_apply_persists_copy_and_reloads_its_foreground(cx: &mut TestAppContext) { + let _fixture_guard = lock_theme_studio(); let _settings_guard = SettingsBytesGuard::capture(); std::fs::write(nebula_settings::settings_path(), TEST_SETTINGS) .expect("write isolated apply settings"); @@ -593,6 +609,7 @@ fn theme_editor_save_and_apply_persists_copy_and_reloads_its_foreground(cx: &mut #[gpui::test] fn invalid_color_stays_dirty_and_save_rejects_after_a_later_valid_edit(cx: &mut TestAppContext) { + let _fixture_guard = lock_theme_studio(); let (pane, mut window) = open_settings(cx); let before_runtime = runtime_snapshot(); let before_palette = palette_snapshot(&mut window); @@ -627,6 +644,7 @@ fn invalid_color_stays_dirty_and_save_rejects_after_a_later_valid_edit(cx: &mut #[gpui::test] fn non_finite_font_values_stay_out_of_the_draft_and_save_is_rejected(cx: &mut TestAppContext) { + let _fixture_guard = lock_theme_studio(); let (pane, mut window) = open_settings(cx); let before_runtime = runtime_snapshot(); let before_palette = palette_snapshot(&mut window); @@ -655,6 +673,7 @@ fn non_finite_font_values_stay_out_of_the_draft_and_save_is_rejected(cx: &mut Te #[gpui::test] fn theme_editor_font_select_changes_only_the_draft(cx: &mut TestAppContext) { + let _fixture_guard = lock_theme_studio(); let (pane, mut window) = open_settings(cx); pane.update(&mut window, |pane, _| { // Keep the real selector independent of the fonts installed on the host. @@ -716,6 +735,7 @@ fn theme_editor_font_select_changes_only_the_draft(cx: &mut TestAppContext) { #[gpui::test] fn theme_editor_color_picker_is_draft_only_and_cancel_restores(cx: &mut TestAppContext) { + let _fixture_guard = lock_theme_studio(); let (pane, mut window) = open_settings(cx); let before_runtime = runtime_snapshot(); let before_palette = palette_snapshot(&mut window); @@ -774,6 +794,7 @@ fn theme_editor_color_picker_is_draft_only_and_cancel_restores(cx: &mut TestAppC #[gpui::test] fn theme_and_icon_filter_pills_select_independently(cx: &mut TestAppContext) { + let _fixture_guard = lock_theme_studio(); let (pane, mut window) = open_settings(cx); click("open-theme-picker", &mut window); let theme_counts = pane.read_with(&mut window, |pane, _| { @@ -810,6 +831,7 @@ fn theme_and_icon_filter_pills_select_independently(cx: &mut TestAppContext) { #[gpui::test] fn theme_picker_foreground_swatches_are_local_until_apply(cx: &mut TestAppContext) { + let _fixture_guard = lock_theme_studio(); let (pane, mut window) = open_settings(cx); let before_runtime = runtime_snapshot(); let before_palette = palette_snapshot(&mut window); @@ -851,6 +873,7 @@ fn theme_picker_foreground_swatches_are_local_until_apply(cx: &mut TestAppContex #[gpui::test] fn theme_foreground_drag_hex_and_cancel_stay_local_in_a_narrow_window(cx: &mut TestAppContext) { + let _fixture_guard = lock_theme_studio(); let (pane, mut window) = open_settings(cx); let settings_before = settings_file_snapshot(); let palette_before = palette_snapshot(&mut window); @@ -921,6 +944,7 @@ fn theme_foreground_drag_hex_and_cancel_stay_local_in_a_narrow_window(cx: &mut T #[gpui::test] fn theme_picker_foreground_swatches_fit_above_footer_in_a_short_window(cx: &mut TestAppContext) { + let _fixture_guard = lock_theme_studio(); let (pane, mut window) = open_settings(cx); let palette_before = palette_snapshot(&mut window); window.simulate_resize(size(px(900.0), px(590.0))); @@ -963,6 +987,7 @@ fn theme_picker_foreground_swatches_fit_above_footer_in_a_short_window(cx: &mut fn theme_transfer_import_uses_a_rendered_candidate_and_keeps_unknown_fields_and_preferences( cx: &mut TestAppContext, ) { + let _fixture_guard = lock_theme_studio(); let (import_path, fixture) = write_transfer_fixture("import"); let (pane, mut window) = open_settings(cx); let before_runtime = runtime_snapshot(); @@ -1012,6 +1037,7 @@ fn theme_transfer_import_uses_a_rendered_candidate_and_keeps_unknown_fields_and_ fn theme_transfer_export_writes_a_valid_native_document_without_publishing_preferences( cx: &mut TestAppContext, ) { + let _fixture_guard = lock_theme_studio(); let (import_path, fixture) = write_transfer_fixture("export"); let output_path = transfer_temp_path("export-output"); let (pane, mut window) = open_settings(cx); @@ -1065,6 +1091,7 @@ fn theme_transfer_export_writes_a_valid_native_document_without_publishing_prefe #[gpui::test] fn theme_transfer_cancel_and_invalid_input_leave_the_editor_recoverable(cx: &mut TestAppContext) { + let _fixture_guard = lock_theme_studio(); let invalid_path = transfer_temp_path("invalid"); std::fs::write(&invalid_path, b"not valid theme json").expect("write invalid theme"); let (pane, mut window) = open_settings(cx); @@ -1106,6 +1133,7 @@ fn theme_transfer_cancel_and_invalid_input_leave_the_editor_recoverable(cx: &mut #[gpui::test] fn theme_transfer_late_picker_result_is_ignored_after_rendered_cancel(cx: &mut TestAppContext) { + let _fixture_guard = lock_theme_studio(); let (import_path, _) = write_transfer_fixture("late"); let (pane, mut window) = open_settings(cx); let before_runtime = runtime_snapshot(); @@ -1137,6 +1165,7 @@ fn theme_transfer_late_picker_result_is_ignored_after_rendered_cancel(cx: &mut T fn theme_picker_recommended_foreground_is_local_until_apply_and_restores_after_reopen( cx: &mut TestAppContext, ) { + let _fixture_guard = lock_theme_studio(); let _settings_guard = SettingsBytesGuard::capture(); std::fs::write(nebula_settings::settings_path(), TEST_SETTINGS) .expect("write isolated picker settings"); @@ -1190,6 +1219,7 @@ fn theme_picker_recommended_foreground_is_local_until_apply_and_restores_after_r fn theme_editor_save_only_forks_a_non_active_custom_template_without_publishing_it( cx: &mut TestAppContext, ) { + let _fixture_guard = lock_theme_studio(); let _settings_guard = SettingsBytesGuard::capture(); std::fs::write(nebula_settings::settings_path(), TEST_SETTINGS) .expect("write isolated template settings"); diff --git a/nebula_settings/src/lib.rs b/nebula_settings/src/lib.rs index 95b9373..9e2112e 100644 --- a/nebula_settings/src/lib.rs +++ b/nebula_settings/src/lib.rs @@ -1244,13 +1244,19 @@ impl RuntimeSettings { } } -/// 解析 `#rrggbb`(旧壳 `parse_hex_rgb` 同款:# 前缀可省)。 +/// 解析 `#rgb` 或 `#rrggbb`;# 前缀可省,写盘统一使用六位形式。 pub fn parse_hex_rgb(value: &str) -> Option { let hex = value.trim(); let hex = hex.strip_prefix('#').unwrap_or(hex); - if hex.len() != 6 || !hex.is_ascii() { + if !matches!(hex.len(), 3 | 6) || !hex.bytes().all(|byte| byte.is_ascii_hexdigit()) { return None; } + if hex.len() == 3 { + let r = u8::from_str_radix(&hex[0..1], 16).ok()? * 17; + let g = u8::from_str_radix(&hex[1..2], 16).ok()? * 17; + let b = u8::from_str_radix(&hex[2..3], 16).ok()? * 17; + return Some([r, g, b]); + } let r = u8::from_str_radix(&hex[0..2], 16).ok()?; let g = u8::from_str_radix(&hex[2..4], 16).ok()?; let b = u8::from_str_radix(&hex[4..6], 16).ok()?; @@ -1688,6 +1694,18 @@ mod tests { assert_eq!(format_hex_rgb([0x8b, 0xd5, 0xca]), "#8bd5ca"); } + #[test] + fn shorthand_rgb_expands_and_rejects_non_hex_or_alpha_input() { + for value in ["#123", "123", " #123 "] { + assert_eq!(parse_hex_rgb(value), Some([0x11, 0x22, 0x33]), "{value:?}"); + } + assert_eq!(parse_hex_rgb("#aBc"), Some([0xaa, 0xbb, 0xcc])); + assert_eq!(format_hex_rgb(parse_hex_rgb("#aBc").unwrap()), "#aabbcc"); + for value in ["", "#", "#12", "#1234", "#12345", "#12345678", "#12g", "+1b2c3", "中文"] { + assert_eq!(parse_hex_rgb(value), None, "{value:?}"); + } + } + #[test] fn theme_names_roundtrip() { assert_eq!(ThemeName::Nebula.prompt_name(), "Nebula"); From 036d57a25cf8d915193546803bb7a394043b9836 Mon Sep 17 00:00:00 2001 From: kuddev Date: Tue, 15 Sep 2026 12:46:20 +0800 Subject: [PATCH 50/51] fix(tabs): restore 1.7 status alignment and unclipped glyphs --- .../src/gpui_shell/workspace/sidebar.rs | 140 ++++++++++++++++-- .../src/gpui_shell/workspace/top_tabs.rs | 24 +-- 2 files changed, 132 insertions(+), 32 deletions(-) diff --git a/nebula_app/src/gpui_shell/workspace/sidebar.rs b/nebula_app/src/gpui_shell/workspace/sidebar.rs index 48ecbd4..6b1c60a 100644 --- a/nebula_app/src/gpui_shell/workspace/sidebar.rs +++ b/nebula_app/src/gpui_shell/workspace/sidebar.rs @@ -53,6 +53,31 @@ pub(super) fn resting_activity( } impl NebulaWorkspace { + pub(super) fn shell_status_label( + tag: SharedString, + family: SharedString, + size_px: f32, + color: gpui::Hsla, + ) -> gpui::Div { + // As in 1.7, short labels keep their intrinsic width and align right in + // the status row. Only a long label is clipped to the available space. + div() + .max_w_full() + .min_w_0() + .truncate() + .font_family(family) + .text_size(px(size_px)) + .font_weight(FontWeight::NORMAL) + .text_color(color) + .child(tag) + } + + pub(super) fn tab_status_slot(width: f32) -> gpui::Div { + // Nerd Font ink can extend beyond its advance. Keep the 1.7 slot + // unclipped; the enclosing tab still clips at its own outer boundary. + div().relative().w(px(width)).h_full().flex_shrink_0() + } + /// Reserve the shaped shell label's width, capped to leave room for the title. pub(super) fn shell_status_width( window: &Window, @@ -356,16 +381,13 @@ impl NebulaWorkspace { .into_any_element(), ), SidebarActivity::Idle => shell_tag.map(|tag| { - div() - .w_full() - .min_w_0() - .truncate() - .font_family(chrome_family.clone()) - .text_size(px(label_px * SIDEBAR_TAG_SCALE)) - .font_weight(FontWeight::NORMAL) - .text_color(status_color) - .child(tag) - .into_any_element() + Self::shell_status_label( + tag, + chrome_family.clone(), + label_px * SIDEBAR_TAG_SCALE, + status_color, + ) + .into_any_element() }), }; // 三类行位移(旧壳 tab_drag_draw_y 的语义):被拖行骑指针, @@ -565,12 +587,7 @@ impl NebulaWorkspace { ) }) .child( - div() - .relative() - .w(px(status_width)) - .h_full() - .overflow_hidden() - .flex_shrink_0() + Self::tab_status_slot(status_width) .when_some(resting_status, |slot, status| { slot.child( h_flex() @@ -1090,6 +1107,97 @@ impl NebulaWorkspace { mod tests { use super::*; + #[cfg(feature = "gpui-test-support")] + mod layout { + use super::*; + use std::cell::RefCell; + use std::rc::Rc; + + struct StatusProbe { + label: SharedString, + font_size: f32, + mask: Rc>>>, + } + + impl Render for StatusProbe { + fn render( + &mut self, + _window: &mut Window, + _cx: &mut Context, + ) -> impl IntoElement { + let mask = self.mask.clone(); + h_flex() + .id("status-probe-row") + .debug_selector(|| "status-probe-row".to_owned()) + .w(px(200.0)) + .h(px(TAB_ROW_H)) + .px_2() + .overflow_hidden() + .child(div().flex_1()) + .child( + NebulaWorkspace::tab_status_slot(TAB_STATUS_SLOT_W) + .id("status-probe-slot") + .debug_selector(|| "status-probe-slot".to_owned()) + .child( + h_flex().absolute().inset_0().justify_end().items_center().child( + NebulaWorkspace::shell_status_label( + self.label.clone(), + ".SystemUIFont".into(), + self.font_size, + gpui::black(), + ) + .id("status-probe-label") + .debug_selector(|| "status-probe-label".to_owned()), + ), + ) + .child( + canvas( + |_, _, _| {}, + move |_, _, window, _| { + *mask.borrow_mut() = Some(window.content_mask().bounds); + }, + ) + .absolute() + .size_full(), + ), + ) + } + } + + #[gpui::test] + fn short_shells_align_right_long_shells_fit_and_glyph_ink_keeps_row_padding( + cx: &mut gpui::TestAppContext, + ) { + cx.update(gpui_component::init); + for font_size in [12.0, 15.0, 20.0] { + for label in ["sh", "debian", "a-very-long-shell-distribution-name"] { + let mask = Rc::new(RefCell::new(None)); + let recorded = mask.clone(); + let (_, visual) = cx.add_window_view(|_, _| StatusProbe { + label: label.into(), + font_size, + mask, + }); + let row = visual.debug_bounds("status-probe-row").unwrap(); + let slot = visual.debug_bounds("status-probe-slot").unwrap(); + let text = visual.debug_bounds("status-probe-label").unwrap(); + assert!(text.size.width <= slot.size.width); + assert_eq!( + text.right(), + slot.right(), + "shell labels retain the 1.7 right edge" + ); + if label == "sh" { + assert!(text.size.width < slot.size.width, "short labels stay intrinsic"); + } + let clip = recorded.borrow().expect("status paint mask"); + assert!(clip.right() > slot.right(), "glyph overhang must retain row padding"); + assert!(clip.right() <= row.right(), "the outer row still clips overflow"); + } + } + } + } + #[test] fn finished_dot_is_an_event_so_the_tab_you_are_watching_never_shows_it() { // 你正盯着 agent 跑完:那个点在你眼前亮起没有任何信息量。 diff --git a/nebula_app/src/gpui_shell/workspace/top_tabs.rs b/nebula_app/src/gpui_shell/workspace/top_tabs.rs index 438e99f..eddb08f 100644 --- a/nebula_app/src/gpui_shell/workspace/top_tabs.rs +++ b/nebula_app/src/gpui_shell/workspace/top_tabs.rs @@ -238,16 +238,13 @@ impl NebulaWorkspace { .into_any_element(), ), SidebarActivity::Idle => shell_tag.map(|tag| { - div() - .w_full() - .min_w_0() - .truncate() - .font_family(chrome_family.clone()) - .text_size(px(label_px * 0.8)) - .font_weight(FontWeight::NORMAL) - .text_color(status_color) - .child(tag) - .into_any_element() + Self::shell_status_label( + tag, + chrome_family.clone(), + label_px * 0.8, + status_color, + ) + .into_any_element() }), }; let strip = color.map(|color| gpui::Rgba { @@ -462,12 +459,7 @@ impl NebulaWorkspace { ) }) .child( - div() - .relative() - .w(px(status_width)) - .h_full() - .overflow_hidden() - .flex_shrink_0() + Self::tab_status_slot(status_width) .when_some(resting_status, |slot, status| { slot.child( h_flex() From 1673cf378cfbbbc96f86d348461cd83b40ca117f Mon Sep 17 00:00:00 2001 From: kuddev Date: Tue, 15 Sep 2026 13:55:08 +0800 Subject: [PATCH 51/51] chore: synchronize completed integration to main [skip ci] Carry the released 1.8.0 history and the completed workspace fixes through 036d57a to main. Preserve all uncommitted work in the existing worktrees. Skip CI for this synchronization at the maintainer's explicit request. The source tree is identical to 036d57a; release tags remain unchanged.