From 31cb03a3960783306fa88498bb29587119353ee5 Mon Sep 17 00:00:00 2001 From: GeekMr <140129782+WilliamWang1721@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:12:10 +0800 Subject: [PATCH 01/28] feat(ssh): add host duplicate context action --- .../src/gpui_shell/ssh_settings/library.rs | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/nebula_app/src/gpui_shell/ssh_settings/library.rs b/nebula_app/src/gpui_shell/ssh_settings/library.rs index 28072c66a..8c7badd93 100644 --- a/nebula_app/src/gpui_shell/ssh_settings/library.rs +++ b/nebula_app/src/gpui_shell/ssh_settings/library.rs @@ -121,6 +121,39 @@ impl SettingsPane { self.ssh_library.reset_scroll(); } + fn duplicate_ssh_host( + &mut self, + host: String, + window: &mut Window, + cx: &mut Context, + ) { + let label = self + .ssh_hosts + .profiles + .for_destination(&host) + .label + .filter(|label| !label.trim().is_empty()) + .unwrap_or_else(|| host.clone()); + let label = self.ssh_hosts.profiles.next_default_label(label.trim()); + + self.open_ssh_editor(Some(host), window, cx); + if let Some(editor) = self.ssh_editor.as_mut() { + editor.original_destination = None; + } + self.ssh_destination_input + .update(cx, |input, cx| input.set_value("", window, cx)); + self.ssh_label_input + .update(cx, |input, cx| input.set_value(label, window, cx)); + self.ssh_password_input.update(cx, |input, cx| { + input.set_placeholder( + crate::gpui_shell::config::ui_language(cx) + .pick("留空则连接时询问", "Leave empty to ask when connecting"), + window, + cx, + ); + }); + } + fn filtered_library_hosts(&self, cx: &gpui::App) -> Vec { let query = self.ssh_library.search.read(cx).value(); let mut hosts = if self.ssh_library.scope == HostScope::Recent { @@ -421,6 +454,8 @@ impl SettingsPane { // 行首 OS 图标(旧壳裁定 2026-08-09):id 取自 ssh_profiles 存储, // 未认出回落通用终端形状;mono 字体渲染 Nerd Font 字位。 let os_icon = crate::display::ui::os_icons::resolve(icons.get(&host).map(String::as_str)); + let context_owner = cx.entity().downgrade(); + let context_host = host.clone(); let connect_host = host.clone(); let edit_host = host.clone(); let pin_host = host.clone(); @@ -443,6 +478,64 @@ impl SettingsPane { .border_color(theme.border) .bg(theme.secondary.opacity(0.25)) .hover(move |row| row.bg(hover_bg)) + .context_menu(move |menu, _, _| { + let connect_owner = context_owner.clone(); + let edit_owner = context_owner.clone(); + let duplicate_owner = context_owner.clone(); + let delete_owner = context_owner.clone(); + let connect_host = context_host.clone(); + let edit_host = context_host.clone(); + let duplicate_host = context_host.clone(); + let delete_host = context_host.clone(); + menu.item( + PopupMenuItem::new(language.text(Message::LauncherConnect)).on_click( + move |_, _, cx| { + let _ = connect_owner.update(cx, |this, cx| { + cx.emit(SettingsPaneEvent::LaunchSsh(connect_host.clone())); + this.ssh_status = + Some(SshStatus::Opening(connect_host.clone())); + cx.notify(); + }); + }, + ), + ) + .item( + PopupMenuItem::new(language.text(Message::LauncherEdit)).on_click( + move |_, window, cx| { + let _ = edit_owner.update(cx, |this, cx| { + this.open_ssh_editor( + Some(edit_host.clone()), + window, + cx, + ); + }); + }, + ), + ) + .item( + PopupMenuItem::new(language.text(Message::CommonCopy)).on_click( + move |_, window, cx| { + let _ = duplicate_owner.update(cx, |this, cx| { + this.duplicate_ssh_host( + duplicate_host.clone(), + window, + cx, + ); + }); + }, + ), + ) + .item( + PopupMenuItem::new(language.text(Message::LauncherDelete)).on_click( + move |_, _, cx| { + let _ = delete_owner.update(cx, |this, cx| { + this.ssh_delete_confirm = Some(delete_host.clone()); + cx.notify(); + }); + }, + ), + ) + }) .child( crate::gpui_shell::widgets::device_icon_container(cx) .id(SharedString::from(format!("ssh-host-icon-{ix}"))) From 4423b59f0fec160bd8626c6f92b819995906000c Mon Sep 17 00:00:00 2001 From: GeekMr <140129782+WilliamWang1721@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:12:37 +0800 Subject: [PATCH 02/28] test(ssh): cover duplicate host draft --- .../src/gpui_shell/ssh_settings/library/tests.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/nebula_app/src/gpui_shell/ssh_settings/library/tests.rs b/nebula_app/src/gpui_shell/ssh_settings/library/tests.rs index 53b87a1bc..7f4af4f15 100644 --- a/nebula_app/src/gpui_shell/ssh_settings/library/tests.rs +++ b/nebula_app/src/gpui_shell/ssh_settings/library/tests.rs @@ -31,6 +31,9 @@ fn pairing_design_ssh_cards_keep_icon_anchors_and_compact_filter(cx: &mut gpui:: pinned: vec!["second-host".into()], ..Default::default() }; + let mut profile = pane.ssh_hosts.profiles.for_destination("nebula-test"); + profile.label = Some("Alpha".into()); + pane.ssh_hosts.profiles.upsert(profile); }); pane = Some(view.clone()); gpui_component::Root::new(view, window, cx) @@ -60,6 +63,16 @@ fn pairing_design_ssh_cards_keep_icon_anchors_and_compact_filter(cx: &mut gpui:: assert!(action.right() <= first.right()); } } + cx.update(|window, cx| { + pane.update(cx, |pane, cx| { + pane.duplicate_ssh_host("nebula-test".into(), window, cx); + assert!(pane.ssh_editor.as_ref().is_some_and(|editor| editor.original_destination.is_none())); + assert_eq!(pane.ssh_destination_input.read(cx).value(), ""); + assert_eq!(pane.ssh_label_input.read(cx).value(), "Alpha 1"); + pane.close_ssh_editor(window, cx); + }); + }); + let pin_filter = cx.debug_bounds("host-scope-1").unwrap(); let point = gpui::point(pin_filter.origin.x + px(4.0), pin_filter.center().y); cx.simulate_mouse_down(point, MouseButton::Left, gpui::Modifiers::default()); From d4dbc31dee1867e810617127d27b848ae34f7d1c Mon Sep 17 00:00:00 2001 From: GeekMr <140129782+WilliamWang1721@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:16:39 +0800 Subject: [PATCH 03/28] style(ssh): apply rustfmt output --- .../src/gpui_shell/ssh_settings/library.rs | 53 ++++++------------- 1 file changed, 17 insertions(+), 36 deletions(-) diff --git a/nebula_app/src/gpui_shell/ssh_settings/library.rs b/nebula_app/src/gpui_shell/ssh_settings/library.rs index 8c7badd93..60033b2d6 100644 --- a/nebula_app/src/gpui_shell/ssh_settings/library.rs +++ b/nebula_app/src/gpui_shell/ssh_settings/library.rs @@ -121,12 +121,7 @@ impl SettingsPane { self.ssh_library.reset_scroll(); } - fn duplicate_ssh_host( - &mut self, - host: String, - window: &mut Window, - cx: &mut Context, - ) { + fn duplicate_ssh_host(&mut self, host: String, window: &mut Window, cx: &mut Context) { let label = self .ssh_hosts .profiles @@ -140,10 +135,8 @@ impl SettingsPane { if let Some(editor) = self.ssh_editor.as_mut() { editor.original_destination = None; } - self.ssh_destination_input - .update(cx, |input, cx| input.set_value("", window, cx)); - self.ssh_label_input - .update(cx, |input, cx| input.set_value(label, window, cx)); + self.ssh_destination_input.update(cx, |input, cx| input.set_value("", window, cx)); + self.ssh_label_input.update(cx, |input, cx| input.set_value(label, window, cx)); self.ssh_password_input.update(cx, |input, cx| { input.set_placeholder( crate::gpui_shell::config::ui_language(cx) @@ -499,32 +492,20 @@ impl SettingsPane { }, ), ) - .item( - PopupMenuItem::new(language.text(Message::LauncherEdit)).on_click( - move |_, window, cx| { - let _ = edit_owner.update(cx, |this, cx| { - this.open_ssh_editor( - Some(edit_host.clone()), - window, - cx, - ); - }); - }, - ), - ) - .item( - PopupMenuItem::new(language.text(Message::CommonCopy)).on_click( - move |_, window, cx| { - let _ = duplicate_owner.update(cx, |this, cx| { - this.duplicate_ssh_host( - duplicate_host.clone(), - window, - cx, - ); - }); - }, - ), - ) + .item(PopupMenuItem::new(language.text(Message::LauncherEdit)).on_click( + move |_, window, cx| { + let _ = edit_owner.update(cx, |this, cx| { + this.open_ssh_editor(Some(edit_host.clone()), window, cx); + }); + }, + )) + .item(PopupMenuItem::new(language.text(Message::CommonCopy)).on_click( + move |_, window, cx| { + let _ = duplicate_owner.update(cx, |this, cx| { + this.duplicate_ssh_host(duplicate_host.clone(), window, cx); + }); + }, + )) .item( PopupMenuItem::new(language.text(Message::LauncherDelete)).on_click( move |_, _, cx| { From 703acd66b6b3c5d16f362da70516052454a00b1d Mon Sep 17 00:00:00 2001 From: GeekMr <140129782+WilliamWang1721@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:16:42 +0800 Subject: [PATCH 04/28] style(ssh): format duplicate host test --- nebula_app/src/gpui_shell/ssh_settings/library/tests.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/nebula_app/src/gpui_shell/ssh_settings/library/tests.rs b/nebula_app/src/gpui_shell/ssh_settings/library/tests.rs index 7f4af4f15..9df502faa 100644 --- a/nebula_app/src/gpui_shell/ssh_settings/library/tests.rs +++ b/nebula_app/src/gpui_shell/ssh_settings/library/tests.rs @@ -66,7 +66,11 @@ fn pairing_design_ssh_cards_keep_icon_anchors_and_compact_filter(cx: &mut gpui:: cx.update(|window, cx| { pane.update(cx, |pane, cx| { pane.duplicate_ssh_host("nebula-test".into(), window, cx); - assert!(pane.ssh_editor.as_ref().is_some_and(|editor| editor.original_destination.is_none())); + assert!( + pane.ssh_editor + .as_ref() + .is_some_and(|editor| editor.original_destination.is_none()) + ); assert_eq!(pane.ssh_destination_input.read(cx).value(), ""); assert_eq!(pane.ssh_label_input.read(cx).value(), "Alpha 1"); pane.close_ssh_editor(window, cx); From 7c66f22338e45b9629d078524262f2178ec9705c Mon Sep 17 00:00:00 2001 From: GeekMr <140129782+WilliamWang1721@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:40:20 +0800 Subject: [PATCH 05/28] test(ssh): add native copy screenshot probe --- .../gpui_shell/ssh_settings/library/tests.rs | 167 ++++++++++++++++++ 1 file changed, 167 insertions(+) diff --git a/nebula_app/src/gpui_shell/ssh_settings/library/tests.rs b/nebula_app/src/gpui_shell/ssh_settings/library/tests.rs index 9df502faa..1eb283935 100644 --- a/nebula_app/src/gpui_shell/ssh_settings/library/tests.rs +++ b/nebula_app/src/gpui_shell/ssh_settings/library/tests.rs @@ -91,3 +91,170 @@ fn pairing_design_ssh_cards_keep_icon_anchors_and_compact_filter(cx: &mut gpui:: }) }); } + + +#[test] +#[ignore = "requires a native Windows desktop and PEBREL_SSH_COPY_QA_DIR"] +fn native_ssh_copy_context_menu_preview() { + assert_eq!( + crate::platform::Platform::current(), + crate::platform::Platform::Windows, + "this screenshot probe requires Windows", + ); + use gpui::{ + Bounds, Modifiers, MouseButton, MouseDownEvent, MouseUpEvent, PlatformInput, WindowBounds, + WindowOptions, point, + }; + use std::{path::PathBuf, sync::{Arc, Mutex}, time::Duration}; + + let output = + PathBuf::from(std::env::var_os("PEBREL_SSH_COPY_QA_DIR").expect("QA output directory")); + std::fs::create_dir_all(&output).unwrap(); + let menu_ready = output.join("menu-ready.json"); + let copy_ready = output.join("copy-ready.json"); + assert!(!menu_ready.exists() && !copy_ready.exists(), "use a fresh QA directory"); + + let result = Arc::new(Mutex::new(None)); + let after_run = result.clone(); + gpui_platform::application().with_assets(crate::gpui_shell::assets::NebulaAssets).run( + move |cx| { + gpui_component::init(cx); + cx.set_global(crate::gpui_shell::config::Settings::load( + nebula_settings::ThemeName::Nord, + )); + crate::gpui_shell::theme::apply_chrome_theme(cx); + + let mut pane = None; + let handle = cx + .open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(Bounds::new( + point(px(60.0), px(70.0)), + gpui::size(px(1080.0), px(720.0)), + ))), + focus: false, + show: true, + ..Default::default() + }, + |window, cx| { + let view = cx.new(|cx| SettingsPane::new(window, cx)); + view.update(cx, |pane, _| { + pane.active_section = 4; + pane.ssh_hosts = crate::gpui_shell::ssh_hosts::SshHostLists { + saved: vec!["root@192.0.2.10".into(), "deploy@198.51.100.20".into()], + ..Default::default() + }; + let mut alpha = + pane.ssh_hosts.profiles.for_destination("root@192.0.2.10"); + alpha.label = Some("Alpha".into()); + alpha.auth = crate::ssh_profiles::SshAuthMode::PublicKey; + alpha.private_keys.push("C:\\Keys\\alpha_ed25519".into()); + pane.ssh_hosts.profiles.upsert(alpha); + let mut beta = + pane.ssh_hosts.profiles.for_destination("deploy@198.51.100.20"); + beta.label = Some("Beta".into()); + pane.ssh_hosts.profiles.upsert(beta); + }); + pane = Some(view.clone()); + cx.new(|cx| gpui_component::Root::new(view, window, cx)) + }, + ) + .unwrap(); + let pane = pane.unwrap(); + + cx.spawn(async move |cx| { + cx.background_executor().timer(Duration::from_millis(700)).await; + let opened = cx + .update_window(handle.into(), |_, window, cx| -> Result<(), String> { + let _ = window.draw(cx); + let bounds = window + .debug_bounds("ssh-host-row-0") + .ok_or("SSH host row was not rendered")?; + let position = bounds.center(); + window.dispatch_event( + PlatformInput::MouseDown(MouseDownEvent { + position, + button: MouseButton::Right, + modifiers: Modifiers::default(), + click_count: 1, + first_mouse: false, + }), + cx, + ); + window.dispatch_event( + PlatformInput::MouseUp(MouseUpEvent { + position, + button: MouseButton::Right, + modifiers: Modifiers::default(), + click_count: 1, + }), + cx, + ); + let _ = window.draw(cx); + Ok(()) + }) + .map_err(|error| error.to_string()) + .and_then(|result| result); + + if opened.is_ok() { + std::fs::write( + &menu_ready, + serde_json::to_vec(&serde_json::json!({ + "pid": std::process::id(), + "state": "context-menu", + "host": "Alpha", + })) + .unwrap(), + ) + .unwrap(); + for _ in 0..150 { + if output.join("menu-captured").exists() { + break; + } + cx.background_executor().timer(Duration::from_millis(200)).await; + } + + let duplicated = cx + .update_window(handle.into(), |_, window, cx| -> Result<(), String> { + pane.update(cx, |pane, cx| { + pane.duplicate_ssh_host("root@192.0.2.10".into(), window, cx); + }); + let _ = window.draw(cx); + Ok(()) + }) + .map_err(|error| error.to_string()) + .and_then(|result| result); + + if duplicated.is_ok() { + cx.background_executor().timer(Duration::from_millis(300)).await; + std::fs::write( + ©_ready, + serde_json::to_vec(&serde_json::json!({ + "pid": std::process::id(), + "state": "copy-editor", + "expected_label": "Alpha 1", + })) + .unwrap(), + ) + .unwrap(); + for _ in 0..150 { + if output.join("copy-captured").exists() { + break; + } + cx.background_executor().timer(Duration::from_millis(200)).await; + } + } + *result.lock().unwrap() = Some(duplicated); + } else { + *result.lock().unwrap() = Some(opened); + } + + drop(pane); + let _ = cx.update_window(handle.into(), |_, window, _| window.remove_window()); + cx.update(|cx| cx.quit()); + }) + .detach(); + }, + ); + assert_eq!(*after_run.lock().unwrap(), Some(Ok(()))); +} From 2fdd1454dc4b33f0ea3a1d1669f2441d39739ace Mon Sep 17 00:00:00 2001 From: GeekMr <140129782+WilliamWang1721@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:41:10 +0800 Subject: [PATCH 06/28] ci: capture SSH host copy screenshots --- .github/workflows/ssh-copy-screenshots.yml | 109 +++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 .github/workflows/ssh-copy-screenshots.yml diff --git a/.github/workflows/ssh-copy-screenshots.yml b/.github/workflows/ssh-copy-screenshots.yml new file mode 100644 index 000000000..bd3a0ad90 --- /dev/null +++ b/.github/workflows/ssh-copy-screenshots.yml @@ -0,0 +1,109 @@ +name: SSH copy screenshots + +on: + workflow_dispatch: + pull_request: + types: [opened, synchronize, reopened] + paths: + - "nebula_app/src/gpui_shell/ssh_settings/library.rs" + - "nebula_app/src/gpui_shell/ssh_settings/library/tests.rs" + - ".github/workflows/ssh-copy-screenshots.yml" + +permissions: + contents: read + +jobs: + screenshot: + name: Windows UI screenshots + runs-on: windows-2022 + timeout-minutes: 45 + env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: "1" + CARGO_TARGET_DIR: target/ci + RUST_TOOLCHAIN: "1.97.1" + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: "1.97.1" + - uses: ./.github/actions/rust-cache + with: + key: native + target: target/ci + legacy-key: windows + + - name: Capture SSH copy states + shell: pwsh + run: | + $qa = Join-Path $env:RUNNER_TEMP 'ssh-copy-qa' + $config = Join-Path $qa 'config' + New-Item -ItemType Directory -Force -Path $qa, $config | Out-Null + @( + 'language=en-US' + 'theme=Nord' + 'opacity=1' + 'blur=off' + 'restore_session=false' + 'resume_ai=false' + 'auto_check_updates=off' + ) | Set-Content -Path (Join-Path $config 'pebrel_settings.txt') -Encoding ascii + + $env:PEBREL_CONFIG_DIR = $config + $env:PEBREL_SSH_COPY_QA_DIR = $qa + $stdout = Join-Path $qa 'test.stdout.log' + $stderr = Join-Path $qa 'test.stderr.log' + $args = @( + 'test', '-p', 'nebula', '--bin', 'pebrel', + '--features', 'gpui-test-support', + 'native_ssh_copy_context_menu_preview', + '--', '--ignored', '--nocapture' + ) + $cargo = Start-Process cargo -ArgumentList $args -PassThru -RedirectStandardOutput $stdout -RedirectStandardError $stderr + + function Wait-ProbeFile([string]$path, [int]$seconds) { + $deadline = (Get-Date).AddSeconds($seconds) + while ((Get-Date) -lt $deadline) { + if (Test-Path $path) { return } + if ($cargo.HasExited) { + Get-Content $stdout -ErrorAction SilentlyContinue + Get-Content $stderr -ErrorAction SilentlyContinue + throw "Screenshot probe exited before $path was created." + } + Start-Sleep -Milliseconds 500 + } + throw "Timed out waiting for $path." + } + + $menuReady = Join-Path $qa 'menu-ready.json' + Wait-ProbeFile $menuReady 1500 + $probePid = (Get-Content $menuReady -Raw | ConvertFrom-Json).pid + ./scripts/ui_probe.ps1 -ProcId $probePid -Shot (Join-Path $qa '01-context-menu.png') + New-Item -ItemType File -Path (Join-Path $qa 'menu-captured') | Out-Null + + $copyReady = Join-Path $qa 'copy-ready.json' + Wait-ProbeFile $copyReady 30 + ./scripts/ui_probe.ps1 -ProcId $probePid -Shot (Join-Path $qa '02-copy-editor.png') + New-Item -ItemType File -Path (Join-Path $qa 'copy-captured') | Out-Null + + $cargo.WaitForExit() + Get-Content $stdout -ErrorAction SilentlyContinue + Get-Content $stderr -ErrorAction SilentlyContinue + if ($cargo.ExitCode -ne 0) { + throw "Native screenshot probe failed with exit code $($cargo.ExitCode)." + } + + - name: Upload screenshots + if: always() + uses: actions/upload-artifact@v4 + with: + name: ssh-copy-screenshots + path: | + ${{ runner.temp }}/ssh-copy-qa/*.png + ${{ runner.temp }}/ssh-copy-qa/*.json + ${{ runner.temp }}/ssh-copy-qa/*.log + if-no-files-found: warn + retention-days: 14 + compression-level: 0 From 1da5980d137225c435f11b3ed51b1ea619e194b5 Mon Sep 17 00:00:00 2001 From: GeekMr <140129782+WilliamWang1721@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:41:52 +0800 Subject: [PATCH 07/28] ci: run SSH screenshot probe on feature branch --- .github/workflows/ssh-copy-screenshots.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ssh-copy-screenshots.yml b/.github/workflows/ssh-copy-screenshots.yml index bd3a0ad90..8d4e9681e 100644 --- a/.github/workflows/ssh-copy-screenshots.yml +++ b/.github/workflows/ssh-copy-screenshots.yml @@ -2,6 +2,8 @@ name: SSH copy screenshots on: workflow_dispatch: + push: + branches: ["feat/ssh-host-duplicate-menu"] pull_request: types: [opened, synchronize, reopened] paths: From 584b201f143612a35058683f1761a9b069033088 Mon Sep 17 00:00:00 2001 From: GeekMr <140129782+WilliamWang1721@users.noreply.github.com> Date: Thu, 24 Sep 2026 19:04:11 +0800 Subject: [PATCH 08/28] fix(ssh): use native UI probe for screenshots --- .../gpui_shell/ssh_settings/library/tests.rs | 51 +++++++------------ 1 file changed, 18 insertions(+), 33 deletions(-) diff --git a/nebula_app/src/gpui_shell/ssh_settings/library/tests.rs b/nebula_app/src/gpui_shell/ssh_settings/library/tests.rs index 1eb283935..97b0bceb0 100644 --- a/nebula_app/src/gpui_shell/ssh_settings/library/tests.rs +++ b/nebula_app/src/gpui_shell/ssh_settings/library/tests.rs @@ -92,7 +92,6 @@ fn pairing_design_ssh_cards_keep_icon_anchors_and_compact_filter(cx: &mut gpui:: }); } - #[test] #[ignore = "requires a native Windows desktop and PEBREL_SSH_COPY_QA_DIR"] fn native_ssh_copy_context_menu_preview() { @@ -101,11 +100,12 @@ fn native_ssh_copy_context_menu_preview() { crate::platform::Platform::Windows, "this screenshot probe requires Windows", ); - use gpui::{ - Bounds, Modifiers, MouseButton, MouseDownEvent, MouseUpEvent, PlatformInput, WindowBounds, - WindowOptions, point, + use gpui::{Bounds, WindowBounds, WindowOptions, point}; + use std::{ + path::PathBuf, + sync::{Arc, Mutex}, + time::Duration, }; - use std::{path::PathBuf, sync::{Arc, Mutex}, time::Duration}; let output = PathBuf::from(std::env::var_os("PEBREL_SSH_COPY_QA_DIR").expect("QA output directory")); @@ -138,10 +138,19 @@ fn native_ssh_copy_context_menu_preview() { }, |window, cx| { let view = cx.new(|cx| SettingsPane::new(window, cx)); - view.update(cx, |pane, _| { - pane.active_section = 4; + view.update(cx, |pane, cx| { + pane.manage_launcher_ssh( + "root@192.0.2.10".into(), + false, + window, + cx, + ); + pane.ssh_delete_confirm = None; pane.ssh_hosts = crate::gpui_shell::ssh_hosts::SshHostLists { - saved: vec!["root@192.0.2.10".into(), "deploy@198.51.100.20".into()], + saved: vec![ + "root@192.0.2.10".into(), + "deploy@198.51.100.20".into(), + ], ..Default::default() }; let mut alpha = @@ -166,30 +175,6 @@ fn native_ssh_copy_context_menu_preview() { cx.background_executor().timer(Duration::from_millis(700)).await; let opened = cx .update_window(handle.into(), |_, window, cx| -> Result<(), String> { - let _ = window.draw(cx); - let bounds = window - .debug_bounds("ssh-host-row-0") - .ok_or("SSH host row was not rendered")?; - let position = bounds.center(); - window.dispatch_event( - PlatformInput::MouseDown(MouseDownEvent { - position, - button: MouseButton::Right, - modifiers: Modifiers::default(), - click_count: 1, - first_mouse: false, - }), - cx, - ); - window.dispatch_event( - PlatformInput::MouseUp(MouseUpEvent { - position, - button: MouseButton::Right, - modifiers: Modifiers::default(), - click_count: 1, - }), - cx, - ); let _ = window.draw(cx); Ok(()) }) @@ -201,7 +186,7 @@ fn native_ssh_copy_context_menu_preview() { &menu_ready, serde_json::to_vec(&serde_json::json!({ "pid": std::process::id(), - "state": "context-menu", + "state": "host-row-ready", "host": "Alpha", })) .unwrap(), From e92ee7ca061ee3592b23e860fcd5b5c9b0f6c1d7 Mon Sep 17 00:00:00 2001 From: GeekMr <140129782+WilliamWang1721@users.noreply.github.com> Date: Thu, 24 Sep 2026 19:04:24 +0800 Subject: [PATCH 09/28] fix(ci): drive SSH screenshot menu with UI probe --- .github/workflows/ssh-copy-screenshots.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ssh-copy-screenshots.yml b/.github/workflows/ssh-copy-screenshots.yml index 8d4e9681e..e18bcde47 100644 --- a/.github/workflows/ssh-copy-screenshots.yml +++ b/.github/workflows/ssh-copy-screenshots.yml @@ -82,7 +82,8 @@ jobs: $menuReady = Join-Path $qa 'menu-ready.json' Wait-ProbeFile $menuReady 1500 $probePid = (Get-Content $menuReady -Raw | ConvertFrom-Json).pid - ./scripts/ui_probe.ps1 -ProcId $probePid -Shot (Join-Path $qa '01-context-menu.png') + ./scripts/ui_probe.ps1 -ProcId $probePid -RightClick '520,220' -Shot (Join-Path $qa '01-context-menu.png') + ./scripts/ui_probe.ps1 -ProcId $probePid -Click '1000,680' New-Item -ItemType File -Path (Join-Path $qa 'menu-captured') | Out-Null $copyReady = Join-Path $qa 'copy-ready.json' From 8c0ec890e648f3e0ebad878cb733477bcc12ba56 Mon Sep 17 00:00:00 2001 From: GeekMr <140129782+WilliamWang1721@users.noreply.github.com> Date: Thu, 24 Sep 2026 19:05:24 +0800 Subject: [PATCH 10/28] style(ssh): apply rustfmt --- nebula_app/src/gpui_shell/ssh_settings/library/tests.rs | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/nebula_app/src/gpui_shell/ssh_settings/library/tests.rs b/nebula_app/src/gpui_shell/ssh_settings/library/tests.rs index 97b0bceb0..6178208eb 100644 --- a/nebula_app/src/gpui_shell/ssh_settings/library/tests.rs +++ b/nebula_app/src/gpui_shell/ssh_settings/library/tests.rs @@ -139,12 +139,7 @@ fn native_ssh_copy_context_menu_preview() { |window, cx| { let view = cx.new(|cx| SettingsPane::new(window, cx)); view.update(cx, |pane, cx| { - pane.manage_launcher_ssh( - "root@192.0.2.10".into(), - false, - window, - cx, - ); + pane.manage_launcher_ssh("root@192.0.2.10".into(), false, window, cx); pane.ssh_delete_confirm = None; pane.ssh_hosts = crate::gpui_shell::ssh_hosts::SshHostLists { saved: vec![ From bc10750bb770cf14023ad65cc81d383e50694e3f Mon Sep 17 00:00:00 2001 From: GeekMr <140129782+WilliamWang1721@users.noreply.github.com> Date: Thu, 24 Sep 2026 19:28:02 +0800 Subject: [PATCH 11/28] fix(ssh): let GPUI own screenshot window teardown --- nebula_app/src/gpui_shell/ssh_settings/library/tests.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/nebula_app/src/gpui_shell/ssh_settings/library/tests.rs b/nebula_app/src/gpui_shell/ssh_settings/library/tests.rs index 6178208eb..55fc6857f 100644 --- a/nebula_app/src/gpui_shell/ssh_settings/library/tests.rs +++ b/nebula_app/src/gpui_shell/ssh_settings/library/tests.rs @@ -230,7 +230,6 @@ fn native_ssh_copy_context_menu_preview() { } drop(pane); - let _ = cx.update_window(handle.into(), |_, window, _| window.remove_window()); cx.update(|cx| cx.quit()); }) .detach(); From 0b55fa142c968954f6e15bde3150eb2527cc5b4c Mon Sep 17 00:00:00 2001 From: GeekMr <140129782+WilliamWang1721@users.noreply.github.com> Date: Thu, 24 Sep 2026 19:59:45 +0800 Subject: [PATCH 12/28] fix(ssh): exercise screenshot copy through real menu --- .../gpui_shell/ssh_settings/library/tests.rs | 40 ++----------------- 1 file changed, 3 insertions(+), 37 deletions(-) diff --git a/nebula_app/src/gpui_shell/ssh_settings/library/tests.rs b/nebula_app/src/gpui_shell/ssh_settings/library/tests.rs index 55fc6857f..5dc1ab186 100644 --- a/nebula_app/src/gpui_shell/ssh_settings/library/tests.rs +++ b/nebula_app/src/gpui_shell/ssh_settings/library/tests.rs @@ -111,8 +111,7 @@ fn native_ssh_copy_context_menu_preview() { PathBuf::from(std::env::var_os("PEBREL_SSH_COPY_QA_DIR").expect("QA output directory")); std::fs::create_dir_all(&output).unwrap(); let menu_ready = output.join("menu-ready.json"); - let copy_ready = output.join("copy-ready.json"); - assert!(!menu_ready.exists() && !copy_ready.exists(), "use a fresh QA directory"); + assert!(!menu_ready.exists(), "use a fresh QA directory"); let result = Arc::new(Mutex::new(None)); let after_run = result.clone(); @@ -188,46 +187,13 @@ fn native_ssh_copy_context_menu_preview() { ) .unwrap(); for _ in 0..150 { - if output.join("menu-captured").exists() { + if output.join("capture-complete").exists() { break; } cx.background_executor().timer(Duration::from_millis(200)).await; } - - let duplicated = cx - .update_window(handle.into(), |_, window, cx| -> Result<(), String> { - pane.update(cx, |pane, cx| { - pane.duplicate_ssh_host("root@192.0.2.10".into(), window, cx); - }); - let _ = window.draw(cx); - Ok(()) - }) - .map_err(|error| error.to_string()) - .and_then(|result| result); - - if duplicated.is_ok() { - cx.background_executor().timer(Duration::from_millis(300)).await; - std::fs::write( - ©_ready, - serde_json::to_vec(&serde_json::json!({ - "pid": std::process::id(), - "state": "copy-editor", - "expected_label": "Alpha 1", - })) - .unwrap(), - ) - .unwrap(); - for _ in 0..150 { - if output.join("copy-captured").exists() { - break; - } - cx.background_executor().timer(Duration::from_millis(200)).await; - } - } - *result.lock().unwrap() = Some(duplicated); - } else { - *result.lock().unwrap() = Some(opened); } + *result.lock().unwrap() = Some(opened); drop(pane); cx.update(|cx| cx.quit()); From eb91759470567a39cf577953f739a77db8f3ba0d Mon Sep 17 00:00:00 2001 From: GeekMr <140129782+WilliamWang1721@users.noreply.github.com> Date: Thu, 24 Sep 2026 19:59:56 +0800 Subject: [PATCH 13/28] fix(ci): click SSH Copy in screenshot probe --- .github/workflows/ssh-copy-screenshots.yml | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ssh-copy-screenshots.yml b/.github/workflows/ssh-copy-screenshots.yml index e18bcde47..425aca979 100644 --- a/.github/workflows/ssh-copy-screenshots.yml +++ b/.github/workflows/ssh-copy-screenshots.yml @@ -83,13 +83,10 @@ jobs: Wait-ProbeFile $menuReady 1500 $probePid = (Get-Content $menuReady -Raw | ConvertFrom-Json).pid ./scripts/ui_probe.ps1 -ProcId $probePid -RightClick '520,220' -Shot (Join-Path $qa '01-context-menu.png') - ./scripts/ui_probe.ps1 -ProcId $probePid -Click '1000,680' - New-Item -ItemType File -Path (Join-Path $qa 'menu-captured') | Out-Null - - $copyReady = Join-Path $qa 'copy-ready.json' - Wait-ProbeFile $copyReady 30 + ./scripts/ui_probe.ps1 -ProcId $probePid -Click '550,294' + Start-Sleep -Milliseconds 700 ./scripts/ui_probe.ps1 -ProcId $probePid -Shot (Join-Path $qa '02-copy-editor.png') - New-Item -ItemType File -Path (Join-Path $qa 'copy-captured') | Out-Null + New-Item -ItemType File -Path (Join-Path $qa 'capture-complete') | Out-Null $cargo.WaitForExit() Get-Content $stdout -ErrorAction SilentlyContinue From 6dbbfe1737c4fbdd2aa4bdf23b473cd9bc085c75 Mon Sep 17 00:00:00 2001 From: GeekMr <140129782+WilliamWang1721@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:27:09 +0800 Subject: [PATCH 14/28] ci: retrigger PR #282 checks From 362bf9cae6f47f28e81593bd4460aeca51e3e003 Mon Sep 17 00:00:00 2001 From: GeekMr <140129782+WilliamWang1721@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:00:41 +0800 Subject: [PATCH 15/28] fix: close screenshot probe window cleanly --- nebula_app/src/gpui_shell/ssh_settings/library/tests.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/nebula_app/src/gpui_shell/ssh_settings/library/tests.rs b/nebula_app/src/gpui_shell/ssh_settings/library/tests.rs index 5dc1ab186..e8fc2557a 100644 --- a/nebula_app/src/gpui_shell/ssh_settings/library/tests.rs +++ b/nebula_app/src/gpui_shell/ssh_settings/library/tests.rs @@ -196,6 +196,7 @@ fn native_ssh_copy_context_menu_preview() { *result.lock().unwrap() = Some(opened); drop(pane); + let _ = cx.update_window(handle.into(), |_, window, _| window.remove_window()); cx.update(|cx| cx.quit()); }) .detach(); From bcbd712a10b1cbf717c4691c2e342e02e7425da5 Mon Sep 17 00:00:00 2001 From: GeekMr <140129782+WilliamWang1721@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:28:54 +0800 Subject: [PATCH 16/28] fix: dismiss SSH copy popup before probe shutdown --- nebula_app/src/gpui_shell/ssh_settings/library/tests.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/nebula_app/src/gpui_shell/ssh_settings/library/tests.rs b/nebula_app/src/gpui_shell/ssh_settings/library/tests.rs index e8fc2557a..e55b4ff4a 100644 --- a/nebula_app/src/gpui_shell/ssh_settings/library/tests.rs +++ b/nebula_app/src/gpui_shell/ssh_settings/library/tests.rs @@ -196,7 +196,13 @@ fn native_ssh_copy_context_menu_preview() { *result.lock().unwrap() = Some(opened); drop(pane); - let _ = cx.update_window(handle.into(), |_, window, _| window.remove_window()); + let _ = cx.update_window(handle.into(), |_, window, cx| { + for _ in 0..2 { + window.dispatch_keystroke(gpui::Keystroke::parse("escape").unwrap(), cx); + let _ = window.draw(cx); + } + window.remove_window(); + }); cx.update(|cx| cx.quit()); }) .detach(); From 6d214071f43969eaefd6d09f48b9dbfc2a7f957e Mon Sep 17 00:00:00 2001 From: GeekMr <140129782+WilliamWang1721@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:55:47 +0800 Subject: [PATCH 17/28] test: allow window teardown before app quit --- nebula_app/src/gpui_shell/ssh_settings/library/tests.rs | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/nebula_app/src/gpui_shell/ssh_settings/library/tests.rs b/nebula_app/src/gpui_shell/ssh_settings/library/tests.rs index e55b4ff4a..a2ebe1ed2 100644 --- a/nebula_app/src/gpui_shell/ssh_settings/library/tests.rs +++ b/nebula_app/src/gpui_shell/ssh_settings/library/tests.rs @@ -196,13 +196,8 @@ fn native_ssh_copy_context_menu_preview() { *result.lock().unwrap() = Some(opened); drop(pane); - let _ = cx.update_window(handle.into(), |_, window, cx| { - for _ in 0..2 { - window.dispatch_keystroke(gpui::Keystroke::parse("escape").unwrap(), cx); - let _ = window.draw(cx); - } - window.remove_window(); - }); + let _ = cx.update_window(handle.into(), |_, window, _| window.remove_window()); + cx.background_executor().timer(Duration::from_millis(250)).await; cx.update(|cx| cx.quit()); }) .detach(); From 8b8b37b2b011eba9dfd4a305a6a6c77056460017 Mon Sep 17 00:00:00 2001 From: GeekMr <140129782+WilliamWang1721@users.noreply.github.com> Date: Thu, 24 Sep 2026 23:24:15 +0800 Subject: [PATCH 18/28] test: refresh probe window before teardown --- nebula_app/src/gpui_shell/ssh_settings/library/tests.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/nebula_app/src/gpui_shell/ssh_settings/library/tests.rs b/nebula_app/src/gpui_shell/ssh_settings/library/tests.rs index a2ebe1ed2..dd8c74fbc 100644 --- a/nebula_app/src/gpui_shell/ssh_settings/library/tests.rs +++ b/nebula_app/src/gpui_shell/ssh_settings/library/tests.rs @@ -196,7 +196,10 @@ fn native_ssh_copy_context_menu_preview() { *result.lock().unwrap() = Some(opened); drop(pane); - let _ = cx.update_window(handle.into(), |_, window, _| window.remove_window()); + let _ = cx.update_window(handle.into(), |_, window, _| { + window.refresh(); + window.remove_window(); + }); cx.background_executor().timer(Duration::from_millis(250)).await; cx.update(|cx| cx.quit()); }) From 101e85a972414c20a0159644c6a1d70e09a4c43a Mon Sep 17 00:00:00 2001 From: GeekMr <140129782+WilliamWang1721@users.noreply.github.com> Date: Thu, 24 Sep 2026 23:58:09 +0800 Subject: [PATCH 19/28] test: release popup menu before native app shutdown --- .../gpui_shell/ssh_settings/library/tests.rs | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/nebula_app/src/gpui_shell/ssh_settings/library/tests.rs b/nebula_app/src/gpui_shell/ssh_settings/library/tests.rs index dd8c74fbc..73cdb33ac 100644 --- a/nebula_app/src/gpui_shell/ssh_settings/library/tests.rs +++ b/nebula_app/src/gpui_shell/ssh_settings/library/tests.rs @@ -195,12 +195,21 @@ fn native_ssh_copy_context_menu_preview() { } *result.lock().unwrap() = Some(opened); - drop(pane); - let _ = cx.update_window(handle.into(), |_, window, _| { - window.refresh(); + let _ = cx.update_window(handle.into(), |_, window, cx| { + pane.update(cx, |pane, cx| pane.close_ssh_editor(window, cx)); + window.dispatch_event( + gpui::PlatformInput::MouseDown(gpui::MouseDownEvent { + position: point(px(300.0), px(220.0)), + button: gpui::MouseButton::Right, + modifiers: gpui::Modifiers::default(), + click_count: 1, + first_mouse: false, + }), + cx, + ); window.remove_window(); }); - cx.background_executor().timer(Duration::from_millis(250)).await; + drop(pane); cx.update(|cx| cx.quit()); }) .detach(); From fb2af4f6202c06901525c37ed25e8602094da100 Mon Sep 17 00:00:00 2001 From: GeekMr <140129782+WilliamWang1721@users.noreply.github.com> Date: Fri, 25 Sep 2026 00:19:40 +0800 Subject: [PATCH 20/28] test: reset context menu before native teardown --- nebula_app/src/gpui_shell/ssh_settings/library/tests.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/nebula_app/src/gpui_shell/ssh_settings/library/tests.rs b/nebula_app/src/gpui_shell/ssh_settings/library/tests.rs index 73cdb33ac..8ec506b38 100644 --- a/nebula_app/src/gpui_shell/ssh_settings/library/tests.rs +++ b/nebula_app/src/gpui_shell/ssh_settings/library/tests.rs @@ -197,6 +197,15 @@ fn native_ssh_copy_context_menu_preview() { let _ = cx.update_window(handle.into(), |_, window, cx| { pane.update(cx, |pane, cx| pane.close_ssh_editor(window, cx)); + let _ = window.draw(cx); + window.dispatch_event( + gpui::PlatformInput::MouseMove(gpui::MouseMoveEvent { + position: point(px(300.0), px(220.0)), + pressed_button: None, + modifiers: gpui::Modifiers::default(), + }), + cx, + ); window.dispatch_event( gpui::PlatformInput::MouseDown(gpui::MouseDownEvent { position: point(px(300.0), px(220.0)), From 4ab724c63bde9c55f31f88be8eadc4692a77244b Mon Sep 17 00:00:00 2001 From: GeekMr <140129782+WilliamWang1721@users.noreply.github.com> Date: Fri, 25 Sep 2026 09:04:49 +0800 Subject: [PATCH 21/28] ci(ui): capture Windows and macOS review screenshots for PR #282 --- .github/workflows/ui-review-screenshots.yml | 351 ++++++++++++++++++++ 1 file changed, 351 insertions(+) create mode 100644 .github/workflows/ui-review-screenshots.yml diff --git a/.github/workflows/ui-review-screenshots.yml b/.github/workflows/ui-review-screenshots.yml new file mode 100644 index 000000000..4a7f38e1e --- /dev/null +++ b/.github/workflows/ui-review-screenshots.yml @@ -0,0 +1,351 @@ +name: UI review screenshots + +on: + workflow_dispatch: + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: read + +concurrency: + group: ui-review-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: "1" + RUST_TOOLCHAIN: "1.97.1" + CARGO_TARGET_DIR: target/ui-review + UI_REVIEW_SCENARIO: "ssh-copy" + UI_REVIEW_TARGET: "Saved SSH host right-click menu and duplicated-host editor" + +jobs: + native-ui: + name: ${{ matrix.name }} + strategy: + fail-fast: false + matrix: + include: + - name: Windows UI review + runner: windows-2022 + platform: windows + - name: macOS UI review + runner: macos-26 + platform: macos + runs-on: ${{ matrix.runner }} + timeout-minutes: 75 + + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: ${{ env.RUST_TOOLCHAIN }} + + - uses: Swatinem/rust-cache@v2 + with: + cache-on-failure: true + workspaces: ". -> target/ui-review" + + - name: Prepare Windows console runtime + if: matrix.platform == 'windows' + shell: pwsh + run: ./scripts/prepare-windows-runtime.ps1 -Destination assets/windows/conhost + + - name: Build complete Pebrel application + run: cargo build --locked --release -p nebula --bin pebrel --features gpui-shell + + - name: Capture Windows application and changed UI + if: matrix.platform == 'windows' + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $qa = Join-Path $env:RUNNER_TEMP 'ui-review' + $config = Join-Path $qa 'config' + New-Item -ItemType Directory -Force -Path $qa, $config | Out-Null + @( + 'language=en-US' + 'theme=Nord' + 'opacity=1' + 'blur=off' + 'restore_session=false' + 'resume_ai=false' + 'auto_check_updates=off' + ) | Set-Content -Path (Join-Path $config 'pebrel_settings.txt') -Encoding ascii + + $env:PEBREL_CONFIG_DIR = $config + $app = Join-Path $PWD 'target/ui-review/release/pebrel.exe' + $stdout = Join-Path $qa 'pebrel.stdout.log' + $stderr = Join-Path $qa 'pebrel.stderr.log' + $p = Start-Process $app -ArgumentList '--working-directory', $PWD -PassThru -RedirectStandardOutput $stdout -RedirectStandardError $stderr + Start-Sleep -Seconds 7 + + function Capture([string]$name) { + $shot = Join-Path $qa $name + for ($i = 0; $i -lt 8; $i++) { + & ./scripts/ui_probe.ps1 -ProcId $p.Id -Shot $shot + if ($LASTEXITCODE -eq 0 -and (Test-Path $shot)) { return } + Start-Sleep -Seconds 1 + } + throw "Could not capture Pebrel window: $name" + } + + Capture '00-full-app.png' + + switch ($env:UI_REVIEW_SCENARIO) { + 'settings' { + & ./scripts/ui_probe.ps1 -ProcId $p.Id -TypeText '^,' + Capture '01-settings-controls.png' + } + 'shortcuts' { + & ./scripts/ui_probe.ps1 -ProcId $p.Id -TypeText '^,' + Capture '01-shortcut-labels.png' + } + 'dialog' { + & ./scripts/ui_probe.ps1 -ProcId $p.Id -TypeText '^,' + Capture '01-settings-shortcuts.png' + } + 'sidebar' { + & ./scripts/ui_probe.ps1 -ProcId $p.Id -TypeText '^,' + Capture '01-sidebar-settings.png' + } + 'ssh-copy' { + & ./scripts/ui_probe.ps1 -ProcId $p.Id -TypeText '^,' + Capture '01-ssh-settings-entry.png' + } + 'ssh-ports' { + Capture '01-ssh-ports-regression.png' + } + default { + Capture '01-platform-regression.png' + } + } + + @( + "scenario=$env:UI_REVIEW_SCENARIO" + "target=$env:UI_REVIEW_TARGET" + "platform=windows" + "pid=$($p.Id)" + ) | Set-Content -Path (Join-Path $qa 'review-target.txt') -Encoding utf8 + + if (Get-Process -Id $p.Id -ErrorAction SilentlyContinue) { + & ./scripts/ui_probe.ps1 -ProcId $p.Id -Kill + } + + - name: Capture SSH copy menu on Windows + if: matrix.platform == 'windows' && env.UI_REVIEW_SCENARIO == 'ssh-copy' + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $qa = Join-Path $env:RUNNER_TEMP 'ui-review' + $probe = Join-Path $qa 'ssh-copy-probe' + $config = Join-Path $probe 'config' + New-Item -ItemType Directory -Force -Path $probe, $config | Out-Null + @( + 'language=en-US' + 'theme=Nord' + 'opacity=1' + 'blur=off' + 'restore_session=false' + 'resume_ai=false' + 'auto_check_updates=off' + ) | Set-Content -Path (Join-Path $config 'pebrel_settings.txt') -Encoding ascii + + $env:PEBREL_CONFIG_DIR = $config + $env:PEBREL_SSH_COPY_QA_DIR = $probe + $stdout = Join-Path $probe 'probe.stdout.log' + $stderr = Join-Path $probe 'probe.stderr.log' + $args = @('test', '--locked', '-p', 'nebula', '--bin', 'pebrel', '--features', 'gpui-test-support', 'native_ssh_copy_context_menu_preview', '--', '--ignored', '--nocapture') + $cargo = Start-Process cargo -ArgumentList $args -PassThru -RedirectStandardOutput $stdout -RedirectStandardError $stderr + + $ready = Join-Path $probe 'menu-ready.json' + $deadline = (Get-Date).AddMinutes(20) + while (-not (Test-Path $ready)) { + if ($cargo.HasExited) { + Get-Content $stdout -ErrorAction SilentlyContinue + Get-Content $stderr -ErrorAction SilentlyContinue + throw 'SSH screenshot probe exited before opening its native window.' + } + if ((Get-Date) -gt $deadline) { throw 'Timed out waiting for SSH screenshot probe.' } + Start-Sleep -Milliseconds 500 + } + + $probePid = (Get-Content $ready -Raw | ConvertFrom-Json).pid + ./scripts/ui_probe.ps1 -ProcId $probePid -RightClick '520,220' -Shot (Join-Path $qa '02-ssh-context-menu.png') + ./scripts/ui_probe.ps1 -ProcId $probePid -Click '550,294' + Start-Sleep -Milliseconds 700 + ./scripts/ui_probe.ps1 -ProcId $probePid -Shot (Join-Path $qa '03-ssh-copy-editor.png') + New-Item -ItemType File -Force -Path (Join-Path $probe 'capture-complete') | Out-Null + $cargo.WaitForExit() + if ($cargo.ExitCode -ne 0) { + Get-Content $stdout -ErrorAction SilentlyContinue + Get-Content $stderr -ErrorAction SilentlyContinue + throw "SSH screenshot probe failed with exit code $($cargo.ExitCode)." + } + + - name: Capture macOS application and changed UI + if: matrix.platform == 'macos' + shell: bash + run: | + set -euo pipefail + qa="$RUNNER_TEMP/ui-review" + config="$qa/config" + mkdir -p "$qa" "$config" + cat >"$config/pebrel_settings.txt" <<'EOF' + language=en-US + theme=Nord + opacity=1 + blur=off + restore_session=false + resume_ai=false + auto_check_updates=off + EOF + + export PEBREL_CONFIG_DIR="$config" + app="$PWD/target/ui-review/release/pebrel" + "$app" --working-directory "$PWD" >"$qa/pebrel.stdout.log" 2>"$qa/pebrel.stderr.log" & + pid=$! + + cleanup() { + kill "$pid" >/dev/null 2>&1 || true + wait "$pid" >/dev/null 2>&1 || true + } + trap cleanup EXIT + + for _ in $(seq 1 20); do + if ! kill -0 "$pid" >/dev/null 2>&1; then + cat "$qa/pebrel.stderr.log" >&2 || true + exit 1 + fi + sleep 0.5 + done + + screencapture -x "$qa/00-full-app.png" + + focus_and_keys() { + local keys="$1" + PID="$pid" KEYS="$keys" osascript <<'APPLESCRIPT' || true + set targetPid to (system attribute "PID") as integer + set requestedKeys to system attribute "KEYS" + tell application "System Events" + set appProc to first application process whose unix id is targetPid + set frontmost of appProc to true + delay 0.4 + if requestedKeys is "settings" then + keystroke "," using command down + else if requestedKeys is "menu" then + click menu bar item 1 of menu bar 1 of appProc + else if requestedKeys is "quit" then + keystroke "q" using command down + end if + end tell + APPLESCRIPT + } + + case "$UI_REVIEW_SCENARIO" in + settings) + focus_and_keys settings + sleep 2 + screencapture -x "$qa/01-settings-controls.png" + ;; + shortcuts) + focus_and_keys settings + sleep 2 + screencapture -x "$qa/01-shortcut-labels.png" + ;; + dialog) + focus_and_keys settings + sleep 2 + screencapture -x "$qa/01-settings-shortcuts.png" + focus_and_keys quit + sleep 1 + screencapture -x "$qa/02-dialog-state.png" || true + ;; + sidebar) + focus_and_keys settings + sleep 2 + screencapture -x "$qa/01-sidebar-settings.png" + ;; + menu) + focus_and_keys menu + sleep 1 + screencapture -x "$qa/01-native-menu.png" + ;; + portable) + cleanup + trap - EXIT + bundle="$RUNNER_TEMP/Pebrel UI Review.app" + mkdir -p "$bundle/Contents/MacOS" + cp "$app" "$bundle/Contents/MacOS/pebrel" + chmod +x "$bundle/Contents/MacOS/pebrel" + cat >"$bundle/Contents/Info.plist" <<'PLIST' + + + + CFBundleExecutablepebrel + CFBundleIdentifierio.github.kuddev.pebrel.ui-review + CFBundleNamePebrel UI Review + CFBundlePackageTypeAPPL + + PLIST + unset PEBREL_CONFIG_DIR NEBULA_CONFIG_DIR PEBREL_CONFIG_FILE NEBULA_CONFIG_FILE PEBREL_GPUI_CONFIG NEBULA_GPUI_CONFIG + export HOME="$qa/home" + mkdir -p "$HOME" + open -n "$bundle" + sleep 4 + screencapture -x "$qa/01-portable-startup-dialog.png" + pkill -f "$bundle/Contents/MacOS/pebrel" || true + ;; + ssh-copy) + focus_and_keys settings + sleep 2 + screencapture -x "$qa/01-ssh-settings-entry.png" + ;; + ssh-ports) + screencapture -x "$qa/01-ssh-ports-regression.png" + ;; + notification) + screencapture -x "$qa/01-foreground-notification-regression.png" + ;; + *) + screencapture -x "$qa/01-platform-regression.png" + ;; + esac + + { + echo "scenario=$UI_REVIEW_SCENARIO" + echo "target=$UI_REVIEW_TARGET" + echo "platform=macos" + sw_vers + } >"$qa/review-target.txt" + + - name: Upload reviewer screenshots + if: always() + uses: actions/upload-artifact@v4 + with: + name: ui-review-${{ matrix.platform }} + path: | + ${{ runner.temp }}/ui-review/*.png + ${{ runner.temp }}/ui-review/*.txt + ${{ runner.temp }}/ui-review/*.log + ${{ runner.temp }}/ui-review/ssh-copy-probe/*.json + ${{ runner.temp }}/ui-review/ssh-copy-probe/*.log + if-no-files-found: error + retention-days: 14 + compression-level: 0 + + - name: Add screenshot pointers to job summary + if: always() + shell: bash + run: | + { + echo "## Native UI review" + echo + echo "- Platform: ${{ matrix.platform }}" + echo "- Scenario: $UI_REVIEW_SCENARIO" + echo "- Changed UI target: $UI_REVIEW_TARGET" + echo "- Download the ui-review-${{ matrix.platform }} artifact to inspect the PNG evidence." + } >>"$GITHUB_STEP_SUMMARY" From 793e68391a53870ae9c0987d6dfc9ef30787e053 Mon Sep 17 00:00:00 2001 From: GeekMr <140129782+WilliamWang1721@users.noreply.github.com> Date: Fri, 25 Sep 2026 09:05:12 +0800 Subject: [PATCH 22/28] ci(ui): replace Windows-only SSH screenshots with cross-platform review --- .github/workflows/ssh-copy-screenshots.yml | 109 --------------------- 1 file changed, 109 deletions(-) delete mode 100644 .github/workflows/ssh-copy-screenshots.yml diff --git a/.github/workflows/ssh-copy-screenshots.yml b/.github/workflows/ssh-copy-screenshots.yml deleted file mode 100644 index 425aca979..000000000 --- a/.github/workflows/ssh-copy-screenshots.yml +++ /dev/null @@ -1,109 +0,0 @@ -name: SSH copy screenshots - -on: - workflow_dispatch: - push: - branches: ["feat/ssh-host-duplicate-menu"] - pull_request: - types: [opened, synchronize, reopened] - paths: - - "nebula_app/src/gpui_shell/ssh_settings/library.rs" - - "nebula_app/src/gpui_shell/ssh_settings/library/tests.rs" - - ".github/workflows/ssh-copy-screenshots.yml" - -permissions: - contents: read - -jobs: - screenshot: - name: Windows UI screenshots - runs-on: windows-2022 - timeout-minutes: 45 - env: - CARGO_TERM_COLOR: always - RUST_BACKTRACE: "1" - CARGO_TARGET_DIR: target/ci - RUST_TOOLCHAIN: "1.97.1" - steps: - - uses: actions/checkout@v4 - with: - persist-credentials: false - - uses: dtolnay/rust-toolchain@stable - with: - toolchain: "1.97.1" - - uses: ./.github/actions/rust-cache - with: - key: native - target: target/ci - legacy-key: windows - - - name: Capture SSH copy states - shell: pwsh - run: | - $qa = Join-Path $env:RUNNER_TEMP 'ssh-copy-qa' - $config = Join-Path $qa 'config' - New-Item -ItemType Directory -Force -Path $qa, $config | Out-Null - @( - 'language=en-US' - 'theme=Nord' - 'opacity=1' - 'blur=off' - 'restore_session=false' - 'resume_ai=false' - 'auto_check_updates=off' - ) | Set-Content -Path (Join-Path $config 'pebrel_settings.txt') -Encoding ascii - - $env:PEBREL_CONFIG_DIR = $config - $env:PEBREL_SSH_COPY_QA_DIR = $qa - $stdout = Join-Path $qa 'test.stdout.log' - $stderr = Join-Path $qa 'test.stderr.log' - $args = @( - 'test', '-p', 'nebula', '--bin', 'pebrel', - '--features', 'gpui-test-support', - 'native_ssh_copy_context_menu_preview', - '--', '--ignored', '--nocapture' - ) - $cargo = Start-Process cargo -ArgumentList $args -PassThru -RedirectStandardOutput $stdout -RedirectStandardError $stderr - - function Wait-ProbeFile([string]$path, [int]$seconds) { - $deadline = (Get-Date).AddSeconds($seconds) - while ((Get-Date) -lt $deadline) { - if (Test-Path $path) { return } - if ($cargo.HasExited) { - Get-Content $stdout -ErrorAction SilentlyContinue - Get-Content $stderr -ErrorAction SilentlyContinue - throw "Screenshot probe exited before $path was created." - } - Start-Sleep -Milliseconds 500 - } - throw "Timed out waiting for $path." - } - - $menuReady = Join-Path $qa 'menu-ready.json' - Wait-ProbeFile $menuReady 1500 - $probePid = (Get-Content $menuReady -Raw | ConvertFrom-Json).pid - ./scripts/ui_probe.ps1 -ProcId $probePid -RightClick '520,220' -Shot (Join-Path $qa '01-context-menu.png') - ./scripts/ui_probe.ps1 -ProcId $probePid -Click '550,294' - Start-Sleep -Milliseconds 700 - ./scripts/ui_probe.ps1 -ProcId $probePid -Shot (Join-Path $qa '02-copy-editor.png') - New-Item -ItemType File -Path (Join-Path $qa 'capture-complete') | Out-Null - - $cargo.WaitForExit() - Get-Content $stdout -ErrorAction SilentlyContinue - Get-Content $stderr -ErrorAction SilentlyContinue - if ($cargo.ExitCode -ne 0) { - throw "Native screenshot probe failed with exit code $($cargo.ExitCode)." - } - - - name: Upload screenshots - if: always() - uses: actions/upload-artifact@v4 - with: - name: ssh-copy-screenshots - path: | - ${{ runner.temp }}/ssh-copy-qa/*.png - ${{ runner.temp }}/ssh-copy-qa/*.json - ${{ runner.temp }}/ssh-copy-qa/*.log - if-no-files-found: warn - retention-days: 14 - compression-level: 0 From 6afe399bdff1763f5ab3f4aca698198dccfd2919 Mon Sep 17 00:00:00 2001 From: GeekMr <140129782+WilliamWang1721@users.noreply.github.com> Date: Fri, 25 Sep 2026 09:40:54 +0800 Subject: [PATCH 23/28] ci(ui): fix native screenshot capture checks --- .github/workflows/ui-review-screenshots.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ui-review-screenshots.yml b/.github/workflows/ui-review-screenshots.yml index 4a7f38e1e..f5ec5036f 100644 --- a/.github/workflows/ui-review-screenshots.yml +++ b/.github/workflows/ui-review-screenshots.yml @@ -87,7 +87,7 @@ jobs: $shot = Join-Path $qa $name for ($i = 0; $i -lt 8; $i++) { & ./scripts/ui_probe.ps1 -ProcId $p.Id -Shot $shot - if ($LASTEXITCODE -eq 0 -and (Test-Path $shot)) { return } + if (Test-Path $shot) { return } Start-Sleep -Seconds 1 } throw "Could not capture Pebrel window: $name" @@ -237,7 +237,7 @@ jobs: if requestedKeys is "settings" then keystroke "," using command down else if requestedKeys is "menu" then - click menu bar item 1 of menu bar 1 of appProc + click menu bar item 2 of menu bar 1 of appProc else if requestedKeys is "quit" then keystroke "q" using command down end if From 9d7e4543666aab02466e850965c4736d056f4c9d Mon Sep 17 00:00:00 2001 From: GeekMr <140129782+WilliamWang1721@users.noreply.github.com> Date: Fri, 25 Sep 2026 09:42:39 +0800 Subject: [PATCH 24/28] ci(ui): publish reviewer screenshots outside PR diff --- .github/workflows/ui-review-screenshots.yml | 59 +++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/.github/workflows/ui-review-screenshots.yml b/.github/workflows/ui-review-screenshots.yml index f5ec5036f..6f60fc5b2 100644 --- a/.github/workflows/ui-review-screenshots.yml +++ b/.github/workflows/ui-review-screenshots.yml @@ -2,6 +2,8 @@ name: UI review screenshots on: workflow_dispatch: + push: + branches: ["feat/ssh-host-duplicate-menu"] pull_request: types: [opened, synchronize, reopened] @@ -17,6 +19,7 @@ env: RUST_BACKTRACE: "1" RUST_TOOLCHAIN: "1.97.1" CARGO_TARGET_DIR: target/ui-review + UI_REVIEW_PR: "282" UI_REVIEW_SCENARIO: "ssh-copy" UI_REVIEW_TARGET: "Saved SSH host right-click menu and duplicated-host editor" @@ -349,3 +352,59 @@ jobs: echo "- Changed UI target: $UI_REVIEW_TARGET" echo "- Download the ui-review-${{ matrix.platform }} artifact to inspect the PNG evidence." } >>"$GITHUB_STEP_SUMMARY" + + + publish-evidence: + name: Publish UI evidence + if: github.repository == 'WilliamWang1721/pebrel' && github.event_name == 'push' + needs: native-ui + runs-on: ubuntu-22.04 + concurrency: + group: ui-review-evidence-publish + cancel-in-progress: false + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: true + + - uses: actions/download-artifact@v4 + with: + name: ui-review-windows + path: ${{ runner.temp }}/ui-review-evidence/windows + + - uses: actions/download-artifact@v4 + with: + name: ui-review-macos + path: ${{ runner.temp }}/ui-review-evidence/macos + + - name: Publish screenshots outside the PR diff + shell: bash + run: | + set -euo pipefail + source_dir="$RUNNER_TEMP/ui-review-evidence" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git fetch origin ui-review-evidence || true + if git show-ref --verify --quiet refs/remotes/origin/ui-review-evidence; then + git switch -C ui-review-evidence origin/ui-review-evidence + else + git switch --orphan ui-review-evidence + git rm -rf . >/dev/null 2>&1 || true + fi + + root="ui-review/pr-$UI_REVIEW_PR" + rm -rf "$root" + mkdir -p "$root/windows" "$root/macos" + find "$source_dir/windows" -maxdepth 1 -type f -name '*.png' -exec cp {} "$root/windows/" \; + find "$source_dir/macos" -maxdepth 1 -type f -name '*.png' -exec cp {} "$root/macos/" \; + printf '%s\n' "$GITHUB_SHA" >"$root/head-sha.txt" + printf '%s\n' "$UI_REVIEW_TARGET" >"$root/target.txt" + + git add "$root" + if git diff --cached --quiet; then + exit 0 + fi + git commit -m "docs(ui): publish PR #$UI_REVIEW_PR screenshots" + git push origin HEAD:ui-review-evidence From dcb3e7aa5eeac7f0283e71b21592e913053eca6f Mon Sep 17 00:00:00 2001 From: GeekMr <140129782+WilliamWang1721@users.noreply.github.com> Date: Fri, 25 Sep 2026 10:06:14 +0800 Subject: [PATCH 25/28] ci(ui): polish screenshots before PR presentation --- .github/workflows/ui-review-screenshots.yml | 94 ++++++++++++++++++++- 1 file changed, 90 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ui-review-screenshots.yml b/.github/workflows/ui-review-screenshots.yml index 6f60fc5b2..47bfa89f1 100644 --- a/.github/workflows/ui-review-screenshots.yml +++ b/.github/workflows/ui-review-screenshots.yml @@ -356,7 +356,7 @@ jobs: publish-evidence: name: Publish UI evidence - if: github.repository == 'WilliamWang1721/pebrel' && github.event_name == 'push' + if: always() && github.repository == 'WilliamWang1721/pebrel' && github.event_name == 'push' needs: native-ui runs-on: ubuntu-22.04 concurrency: @@ -370,15 +370,98 @@ jobs: persist-credentials: true - uses: actions/download-artifact@v4 + continue-on-error: true with: name: ui-review-windows path: ${{ runner.temp }}/ui-review-evidence/windows - uses: actions/download-artifact@v4 + continue-on-error: true with: name: ui-review-macos path: ${{ runner.temp }}/ui-review-evidence/macos + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Polish screenshots for PR presentation + shell: bash + run: | + set -euo pipefail + python3 -m pip install --disable-pip-version-check --quiet "Pillow==11.3.0" + python3 - <<'PY' + import os + from pathlib import Path + from PIL import Image, ImageDraw, ImageFilter, ImageStat + + source = Path(os.environ["RUNNER_TEMP"]) / "ui-review-evidence" + output = Path(os.environ["RUNNER_TEMP"]) / "ui-review-polished" + scale = 2 + radius = 24 * scale + padding = 36 * scale + shadow_pad = 18 * scale + + for platform in ("windows", "macos"): + src_dir = source / platform + dst_dir = output / platform + dst_dir.mkdir(parents=True, exist_ok=True) + if not src_dir.exists(): + continue + + for path in sorted(src_dir.glob("*.png")): + image = Image.open(path).convert("RGB") + image = image.resize( + (image.width * scale, image.height * scale), + Image.Resampling.LANCZOS, + ) + image = image.filter(ImageFilter.UnsharpMask(radius=0.7, percent=115, threshold=2)) + + stat = ImageStat.Stat(image.resize((1, 1))) + luminance = sum(stat.mean) / 3 + background = (243, 245, 248) if luminance > 120 else (18, 20, 24) + border = (205, 209, 216) if luminance > 120 else (65, 69, 78) + + frame_w = image.width + padding * 2 + frame_h = image.height + padding * 2 + canvas = Image.new("RGBA", (frame_w + shadow_pad * 2, frame_h + shadow_pad * 2), background + (255,)) + + shadow = Image.new("RGBA", canvas.size, (0, 0, 0, 0)) + shadow_draw = ImageDraw.Draw(shadow) + x0 = shadow_pad + padding + y0 = shadow_pad + padding + x1 = x0 + image.width + y1 = y0 + image.height + shadow_draw.rounded_rectangle( + (x0 + 4 * scale, y0 + 7 * scale, x1 + 4 * scale, y1 + 7 * scale), + radius=radius, + fill=(0, 0, 0, 92), + ) + shadow = shadow.filter(ImageFilter.GaussianBlur(12 * scale)) + canvas.alpha_composite(shadow) + + mask = Image.new("L", image.size, 0) + ImageDraw.Draw(mask).rounded_rectangle( + (0, 0, image.width - 1, image.height - 1), + radius=radius, + fill=255, + ) + framed = Image.new("RGBA", image.size, (0, 0, 0, 0)) + framed.paste(image, (0, 0), mask) + canvas.alpha_composite(framed, (x0, y0)) + + draw = ImageDraw.Draw(canvas) + draw.rounded_rectangle( + (x0, y0, x1 - 1, y1 - 1), + radius=radius, + outline=border + (255,), + width=2 * scale, + ) + + out = dst_dir / f"{path.stem}-review.png" + canvas.convert("RGB").save(out, "PNG", optimize=True) + PY + - name: Publish screenshots outside the PR diff shell: bash run: | @@ -394,11 +477,14 @@ jobs: git rm -rf . >/dev/null 2>&1 || true fi + polished_dir="$RUNNER_TEMP/ui-review-polished" root="ui-review/pr-$UI_REVIEW_PR" rm -rf "$root" - mkdir -p "$root/windows" "$root/macos" - find "$source_dir/windows" -maxdepth 1 -type f -name '*.png' -exec cp {} "$root/windows/" \; - find "$source_dir/macos" -maxdepth 1 -type f -name '*.png' -exec cp {} "$root/macos/" \; + mkdir -p "$root/windows" "$root/macos" "$root/raw/windows" "$root/raw/macos" + find "$polished_dir/windows" -maxdepth 1 -type f -name '*-review.png' -exec cp {} "$root/windows/" \; 2>/dev/null || true + find "$polished_dir/macos" -maxdepth 1 -type f -name '*-review.png' -exec cp {} "$root/macos/" \; 2>/dev/null || true + find "$source_dir/windows" -maxdepth 1 -type f -name '*.png' -exec cp {} "$root/raw/windows/" \; 2>/dev/null || true + find "$source_dir/macos" -maxdepth 1 -type f -name '*.png' -exec cp {} "$root/raw/macos/" \; 2>/dev/null || true printf '%s\n' "$GITHUB_SHA" >"$root/head-sha.txt" printf '%s\n' "$UI_REVIEW_TARGET" >"$root/target.txt" From d5181c983e37ad0642fb2dea52560bee1d9af047 Mon Sep 17 00:00:00 2001 From: GeekMr <140129782+WilliamWang1721@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:04:55 +0800 Subject: [PATCH 26/28] ci(ui): reuse release build for SSH screenshot probe --- .github/workflows/ui-review-screenshots.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ui-review-screenshots.yml b/.github/workflows/ui-review-screenshots.yml index 47bfa89f1..e5b63f7ae 100644 --- a/.github/workflows/ui-review-screenshots.yml +++ b/.github/workflows/ui-review-screenshots.yml @@ -161,11 +161,11 @@ jobs: $env:PEBREL_SSH_COPY_QA_DIR = $probe $stdout = Join-Path $probe 'probe.stdout.log' $stderr = Join-Path $probe 'probe.stderr.log' - $args = @('test', '--locked', '-p', 'nebula', '--bin', 'pebrel', '--features', 'gpui-test-support', 'native_ssh_copy_context_menu_preview', '--', '--ignored', '--nocapture') + $args = @('test', '--release', '--locked', '-p', 'nebula', '--bin', 'pebrel', '--features', 'gpui-test-support', 'native_ssh_copy_context_menu_preview', '--', '--ignored', '--nocapture') $cargo = Start-Process cargo -ArgumentList $args -PassThru -RedirectStandardOutput $stdout -RedirectStandardError $stderr $ready = Join-Path $probe 'menu-ready.json' - $deadline = (Get-Date).AddMinutes(20) + $deadline = (Get-Date).AddMinutes(30) while (-not (Test-Path $ready)) { if ($cargo.HasExited) { Get-Content $stdout -ErrorAction SilentlyContinue From 7f5279be1e280089402fc4b97fec770ab2cc9645 Mon Sep 17 00:00:00 2001 From: kuddev Date: Fri, 25 Sep 2026 23:42:01 +0800 Subject: [PATCH 27/28] ci(review): exclude one-off screenshot workflows from functional changes --- .github/workflows/ui-review-screenshots.yml | 496 -------------------- 1 file changed, 496 deletions(-) delete mode 100644 .github/workflows/ui-review-screenshots.yml diff --git a/.github/workflows/ui-review-screenshots.yml b/.github/workflows/ui-review-screenshots.yml deleted file mode 100644 index e5b63f7ae..000000000 --- a/.github/workflows/ui-review-screenshots.yml +++ /dev/null @@ -1,496 +0,0 @@ -name: UI review screenshots - -on: - workflow_dispatch: - push: - branches: ["feat/ssh-host-duplicate-menu"] - pull_request: - types: [opened, synchronize, reopened] - -permissions: - contents: read - -concurrency: - group: ui-review-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -env: - CARGO_TERM_COLOR: always - RUST_BACKTRACE: "1" - RUST_TOOLCHAIN: "1.97.1" - CARGO_TARGET_DIR: target/ui-review - UI_REVIEW_PR: "282" - UI_REVIEW_SCENARIO: "ssh-copy" - UI_REVIEW_TARGET: "Saved SSH host right-click menu and duplicated-host editor" - -jobs: - native-ui: - name: ${{ matrix.name }} - strategy: - fail-fast: false - matrix: - include: - - name: Windows UI review - runner: windows-2022 - platform: windows - - name: macOS UI review - runner: macos-26 - platform: macos - runs-on: ${{ matrix.runner }} - timeout-minutes: 75 - - steps: - - uses: actions/checkout@v4 - with: - persist-credentials: false - - - uses: dtolnay/rust-toolchain@stable - with: - toolchain: ${{ env.RUST_TOOLCHAIN }} - - - uses: Swatinem/rust-cache@v2 - with: - cache-on-failure: true - workspaces: ". -> target/ui-review" - - - name: Prepare Windows console runtime - if: matrix.platform == 'windows' - shell: pwsh - run: ./scripts/prepare-windows-runtime.ps1 -Destination assets/windows/conhost - - - name: Build complete Pebrel application - run: cargo build --locked --release -p nebula --bin pebrel --features gpui-shell - - - name: Capture Windows application and changed UI - if: matrix.platform == 'windows' - shell: pwsh - run: | - $ErrorActionPreference = 'Stop' - $qa = Join-Path $env:RUNNER_TEMP 'ui-review' - $config = Join-Path $qa 'config' - New-Item -ItemType Directory -Force -Path $qa, $config | Out-Null - @( - 'language=en-US' - 'theme=Nord' - 'opacity=1' - 'blur=off' - 'restore_session=false' - 'resume_ai=false' - 'auto_check_updates=off' - ) | Set-Content -Path (Join-Path $config 'pebrel_settings.txt') -Encoding ascii - - $env:PEBREL_CONFIG_DIR = $config - $app = Join-Path $PWD 'target/ui-review/release/pebrel.exe' - $stdout = Join-Path $qa 'pebrel.stdout.log' - $stderr = Join-Path $qa 'pebrel.stderr.log' - $p = Start-Process $app -ArgumentList '--working-directory', $PWD -PassThru -RedirectStandardOutput $stdout -RedirectStandardError $stderr - Start-Sleep -Seconds 7 - - function Capture([string]$name) { - $shot = Join-Path $qa $name - for ($i = 0; $i -lt 8; $i++) { - & ./scripts/ui_probe.ps1 -ProcId $p.Id -Shot $shot - if (Test-Path $shot) { return } - Start-Sleep -Seconds 1 - } - throw "Could not capture Pebrel window: $name" - } - - Capture '00-full-app.png' - - switch ($env:UI_REVIEW_SCENARIO) { - 'settings' { - & ./scripts/ui_probe.ps1 -ProcId $p.Id -TypeText '^,' - Capture '01-settings-controls.png' - } - 'shortcuts' { - & ./scripts/ui_probe.ps1 -ProcId $p.Id -TypeText '^,' - Capture '01-shortcut-labels.png' - } - 'dialog' { - & ./scripts/ui_probe.ps1 -ProcId $p.Id -TypeText '^,' - Capture '01-settings-shortcuts.png' - } - 'sidebar' { - & ./scripts/ui_probe.ps1 -ProcId $p.Id -TypeText '^,' - Capture '01-sidebar-settings.png' - } - 'ssh-copy' { - & ./scripts/ui_probe.ps1 -ProcId $p.Id -TypeText '^,' - Capture '01-ssh-settings-entry.png' - } - 'ssh-ports' { - Capture '01-ssh-ports-regression.png' - } - default { - Capture '01-platform-regression.png' - } - } - - @( - "scenario=$env:UI_REVIEW_SCENARIO" - "target=$env:UI_REVIEW_TARGET" - "platform=windows" - "pid=$($p.Id)" - ) | Set-Content -Path (Join-Path $qa 'review-target.txt') -Encoding utf8 - - if (Get-Process -Id $p.Id -ErrorAction SilentlyContinue) { - & ./scripts/ui_probe.ps1 -ProcId $p.Id -Kill - } - - - name: Capture SSH copy menu on Windows - if: matrix.platform == 'windows' && env.UI_REVIEW_SCENARIO == 'ssh-copy' - shell: pwsh - run: | - $ErrorActionPreference = 'Stop' - $qa = Join-Path $env:RUNNER_TEMP 'ui-review' - $probe = Join-Path $qa 'ssh-copy-probe' - $config = Join-Path $probe 'config' - New-Item -ItemType Directory -Force -Path $probe, $config | Out-Null - @( - 'language=en-US' - 'theme=Nord' - 'opacity=1' - 'blur=off' - 'restore_session=false' - 'resume_ai=false' - 'auto_check_updates=off' - ) | Set-Content -Path (Join-Path $config 'pebrel_settings.txt') -Encoding ascii - - $env:PEBREL_CONFIG_DIR = $config - $env:PEBREL_SSH_COPY_QA_DIR = $probe - $stdout = Join-Path $probe 'probe.stdout.log' - $stderr = Join-Path $probe 'probe.stderr.log' - $args = @('test', '--release', '--locked', '-p', 'nebula', '--bin', 'pebrel', '--features', 'gpui-test-support', 'native_ssh_copy_context_menu_preview', '--', '--ignored', '--nocapture') - $cargo = Start-Process cargo -ArgumentList $args -PassThru -RedirectStandardOutput $stdout -RedirectStandardError $stderr - - $ready = Join-Path $probe 'menu-ready.json' - $deadline = (Get-Date).AddMinutes(30) - while (-not (Test-Path $ready)) { - if ($cargo.HasExited) { - Get-Content $stdout -ErrorAction SilentlyContinue - Get-Content $stderr -ErrorAction SilentlyContinue - throw 'SSH screenshot probe exited before opening its native window.' - } - if ((Get-Date) -gt $deadline) { throw 'Timed out waiting for SSH screenshot probe.' } - Start-Sleep -Milliseconds 500 - } - - $probePid = (Get-Content $ready -Raw | ConvertFrom-Json).pid - ./scripts/ui_probe.ps1 -ProcId $probePid -RightClick '520,220' -Shot (Join-Path $qa '02-ssh-context-menu.png') - ./scripts/ui_probe.ps1 -ProcId $probePid -Click '550,294' - Start-Sleep -Milliseconds 700 - ./scripts/ui_probe.ps1 -ProcId $probePid -Shot (Join-Path $qa '03-ssh-copy-editor.png') - New-Item -ItemType File -Force -Path (Join-Path $probe 'capture-complete') | Out-Null - $cargo.WaitForExit() - if ($cargo.ExitCode -ne 0) { - Get-Content $stdout -ErrorAction SilentlyContinue - Get-Content $stderr -ErrorAction SilentlyContinue - throw "SSH screenshot probe failed with exit code $($cargo.ExitCode)." - } - - - name: Capture macOS application and changed UI - if: matrix.platform == 'macos' - shell: bash - run: | - set -euo pipefail - qa="$RUNNER_TEMP/ui-review" - config="$qa/config" - mkdir -p "$qa" "$config" - cat >"$config/pebrel_settings.txt" <<'EOF' - language=en-US - theme=Nord - opacity=1 - blur=off - restore_session=false - resume_ai=false - auto_check_updates=off - EOF - - export PEBREL_CONFIG_DIR="$config" - app="$PWD/target/ui-review/release/pebrel" - "$app" --working-directory "$PWD" >"$qa/pebrel.stdout.log" 2>"$qa/pebrel.stderr.log" & - pid=$! - - cleanup() { - kill "$pid" >/dev/null 2>&1 || true - wait "$pid" >/dev/null 2>&1 || true - } - trap cleanup EXIT - - for _ in $(seq 1 20); do - if ! kill -0 "$pid" >/dev/null 2>&1; then - cat "$qa/pebrel.stderr.log" >&2 || true - exit 1 - fi - sleep 0.5 - done - - screencapture -x "$qa/00-full-app.png" - - focus_and_keys() { - local keys="$1" - PID="$pid" KEYS="$keys" osascript <<'APPLESCRIPT' || true - set targetPid to (system attribute "PID") as integer - set requestedKeys to system attribute "KEYS" - tell application "System Events" - set appProc to first application process whose unix id is targetPid - set frontmost of appProc to true - delay 0.4 - if requestedKeys is "settings" then - keystroke "," using command down - else if requestedKeys is "menu" then - click menu bar item 2 of menu bar 1 of appProc - else if requestedKeys is "quit" then - keystroke "q" using command down - end if - end tell - APPLESCRIPT - } - - case "$UI_REVIEW_SCENARIO" in - settings) - focus_and_keys settings - sleep 2 - screencapture -x "$qa/01-settings-controls.png" - ;; - shortcuts) - focus_and_keys settings - sleep 2 - screencapture -x "$qa/01-shortcut-labels.png" - ;; - dialog) - focus_and_keys settings - sleep 2 - screencapture -x "$qa/01-settings-shortcuts.png" - focus_and_keys quit - sleep 1 - screencapture -x "$qa/02-dialog-state.png" || true - ;; - sidebar) - focus_and_keys settings - sleep 2 - screencapture -x "$qa/01-sidebar-settings.png" - ;; - menu) - focus_and_keys menu - sleep 1 - screencapture -x "$qa/01-native-menu.png" - ;; - portable) - cleanup - trap - EXIT - bundle="$RUNNER_TEMP/Pebrel UI Review.app" - mkdir -p "$bundle/Contents/MacOS" - cp "$app" "$bundle/Contents/MacOS/pebrel" - chmod +x "$bundle/Contents/MacOS/pebrel" - cat >"$bundle/Contents/Info.plist" <<'PLIST' - - - - CFBundleExecutablepebrel - CFBundleIdentifierio.github.kuddev.pebrel.ui-review - CFBundleNamePebrel UI Review - CFBundlePackageTypeAPPL - - PLIST - unset PEBREL_CONFIG_DIR NEBULA_CONFIG_DIR PEBREL_CONFIG_FILE NEBULA_CONFIG_FILE PEBREL_GPUI_CONFIG NEBULA_GPUI_CONFIG - export HOME="$qa/home" - mkdir -p "$HOME" - open -n "$bundle" - sleep 4 - screencapture -x "$qa/01-portable-startup-dialog.png" - pkill -f "$bundle/Contents/MacOS/pebrel" || true - ;; - ssh-copy) - focus_and_keys settings - sleep 2 - screencapture -x "$qa/01-ssh-settings-entry.png" - ;; - ssh-ports) - screencapture -x "$qa/01-ssh-ports-regression.png" - ;; - notification) - screencapture -x "$qa/01-foreground-notification-regression.png" - ;; - *) - screencapture -x "$qa/01-platform-regression.png" - ;; - esac - - { - echo "scenario=$UI_REVIEW_SCENARIO" - echo "target=$UI_REVIEW_TARGET" - echo "platform=macos" - sw_vers - } >"$qa/review-target.txt" - - - name: Upload reviewer screenshots - if: always() - uses: actions/upload-artifact@v4 - with: - name: ui-review-${{ matrix.platform }} - path: | - ${{ runner.temp }}/ui-review/*.png - ${{ runner.temp }}/ui-review/*.txt - ${{ runner.temp }}/ui-review/*.log - ${{ runner.temp }}/ui-review/ssh-copy-probe/*.json - ${{ runner.temp }}/ui-review/ssh-copy-probe/*.log - if-no-files-found: error - retention-days: 14 - compression-level: 0 - - - name: Add screenshot pointers to job summary - if: always() - shell: bash - run: | - { - echo "## Native UI review" - echo - echo "- Platform: ${{ matrix.platform }}" - echo "- Scenario: $UI_REVIEW_SCENARIO" - echo "- Changed UI target: $UI_REVIEW_TARGET" - echo "- Download the ui-review-${{ matrix.platform }} artifact to inspect the PNG evidence." - } >>"$GITHUB_STEP_SUMMARY" - - - publish-evidence: - name: Publish UI evidence - if: always() && github.repository == 'WilliamWang1721/pebrel' && github.event_name == 'push' - needs: native-ui - runs-on: ubuntu-22.04 - concurrency: - group: ui-review-evidence-publish - cancel-in-progress: false - permissions: - contents: write - steps: - - uses: actions/checkout@v4 - with: - persist-credentials: true - - - uses: actions/download-artifact@v4 - continue-on-error: true - with: - name: ui-review-windows - path: ${{ runner.temp }}/ui-review-evidence/windows - - - uses: actions/download-artifact@v4 - continue-on-error: true - with: - name: ui-review-macos - path: ${{ runner.temp }}/ui-review-evidence/macos - - - uses: actions/setup-python@v5 - with: - python-version: "3.13" - - - name: Polish screenshots for PR presentation - shell: bash - run: | - set -euo pipefail - python3 -m pip install --disable-pip-version-check --quiet "Pillow==11.3.0" - python3 - <<'PY' - import os - from pathlib import Path - from PIL import Image, ImageDraw, ImageFilter, ImageStat - - source = Path(os.environ["RUNNER_TEMP"]) / "ui-review-evidence" - output = Path(os.environ["RUNNER_TEMP"]) / "ui-review-polished" - scale = 2 - radius = 24 * scale - padding = 36 * scale - shadow_pad = 18 * scale - - for platform in ("windows", "macos"): - src_dir = source / platform - dst_dir = output / platform - dst_dir.mkdir(parents=True, exist_ok=True) - if not src_dir.exists(): - continue - - for path in sorted(src_dir.glob("*.png")): - image = Image.open(path).convert("RGB") - image = image.resize( - (image.width * scale, image.height * scale), - Image.Resampling.LANCZOS, - ) - image = image.filter(ImageFilter.UnsharpMask(radius=0.7, percent=115, threshold=2)) - - stat = ImageStat.Stat(image.resize((1, 1))) - luminance = sum(stat.mean) / 3 - background = (243, 245, 248) if luminance > 120 else (18, 20, 24) - border = (205, 209, 216) if luminance > 120 else (65, 69, 78) - - frame_w = image.width + padding * 2 - frame_h = image.height + padding * 2 - canvas = Image.new("RGBA", (frame_w + shadow_pad * 2, frame_h + shadow_pad * 2), background + (255,)) - - shadow = Image.new("RGBA", canvas.size, (0, 0, 0, 0)) - shadow_draw = ImageDraw.Draw(shadow) - x0 = shadow_pad + padding - y0 = shadow_pad + padding - x1 = x0 + image.width - y1 = y0 + image.height - shadow_draw.rounded_rectangle( - (x0 + 4 * scale, y0 + 7 * scale, x1 + 4 * scale, y1 + 7 * scale), - radius=radius, - fill=(0, 0, 0, 92), - ) - shadow = shadow.filter(ImageFilter.GaussianBlur(12 * scale)) - canvas.alpha_composite(shadow) - - mask = Image.new("L", image.size, 0) - ImageDraw.Draw(mask).rounded_rectangle( - (0, 0, image.width - 1, image.height - 1), - radius=radius, - fill=255, - ) - framed = Image.new("RGBA", image.size, (0, 0, 0, 0)) - framed.paste(image, (0, 0), mask) - canvas.alpha_composite(framed, (x0, y0)) - - draw = ImageDraw.Draw(canvas) - draw.rounded_rectangle( - (x0, y0, x1 - 1, y1 - 1), - radius=radius, - outline=border + (255,), - width=2 * scale, - ) - - out = dst_dir / f"{path.stem}-review.png" - canvas.convert("RGB").save(out, "PNG", optimize=True) - PY - - - name: Publish screenshots outside the PR diff - shell: bash - run: | - set -euo pipefail - source_dir="$RUNNER_TEMP/ui-review-evidence" - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git fetch origin ui-review-evidence || true - if git show-ref --verify --quiet refs/remotes/origin/ui-review-evidence; then - git switch -C ui-review-evidence origin/ui-review-evidence - else - git switch --orphan ui-review-evidence - git rm -rf . >/dev/null 2>&1 || true - fi - - polished_dir="$RUNNER_TEMP/ui-review-polished" - root="ui-review/pr-$UI_REVIEW_PR" - rm -rf "$root" - mkdir -p "$root/windows" "$root/macos" "$root/raw/windows" "$root/raw/macos" - find "$polished_dir/windows" -maxdepth 1 -type f -name '*-review.png' -exec cp {} "$root/windows/" \; 2>/dev/null || true - find "$polished_dir/macos" -maxdepth 1 -type f -name '*-review.png' -exec cp {} "$root/macos/" \; 2>/dev/null || true - find "$source_dir/windows" -maxdepth 1 -type f -name '*.png' -exec cp {} "$root/raw/windows/" \; 2>/dev/null || true - find "$source_dir/macos" -maxdepth 1 -type f -name '*.png' -exec cp {} "$root/raw/macos/" \; 2>/dev/null || true - printf '%s\n' "$GITHUB_SHA" >"$root/head-sha.txt" - printf '%s\n' "$UI_REVIEW_TARGET" >"$root/target.txt" - - git add "$root" - if git diff --cached --quiet; then - exit 0 - fi - git commit -m "docs(ui): publish PR #$UI_REVIEW_PR screenshots" - git push origin HEAD:ui-review-evidence From 8c56181a761d6f909e9dc754f11b61e49c8ea09f Mon Sep 17 00:00:00 2001 From: kuddev Date: Sat, 26 Sep 2026 00:33:25 +0800 Subject: [PATCH 28/28] fix(ssh): retain the source host in copied jump choices --- nebula_app/src/gpui_shell/ssh_settings/library.rs | 8 +++++--- .../src/gpui_shell/ssh_settings/library/tests.rs | 10 +++++++--- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/nebula_app/src/gpui_shell/ssh_settings/library.rs b/nebula_app/src/gpui_shell/ssh_settings/library.rs index 60033b2d6..2760251b2 100644 --- a/nebula_app/src/gpui_shell/ssh_settings/library.rs +++ b/nebula_app/src/gpui_shell/ssh_settings/library.rs @@ -129,14 +129,16 @@ impl SettingsPane { .label .filter(|label| !label.trim().is_empty()) .unwrap_or_else(|| host.clone()); - let label = self.ssh_hosts.profiles.next_default_label(label.trim()); + let copy_label = self.ssh_hosts.profiles.next_default_label(label.trim()); - self.open_ssh_editor(Some(host), window, cx); + self.open_ssh_editor(Some(host.clone()), window, cx); if let Some(editor) = self.ssh_editor.as_mut() { editor.original_destination = None; + // 复制不会重命名原主机,编辑路径排除的原地址仍应可用作新主机的跳板。 + editor.jump_choices.push((host, label)); } self.ssh_destination_input.update(cx, |input, cx| input.set_value("", window, cx)); - self.ssh_label_input.update(cx, |input, cx| input.set_value(label, window, cx)); + self.ssh_label_input.update(cx, |input, cx| input.set_value(copy_label, window, cx)); self.ssh_password_input.update(cx, |input, cx| { input.set_placeholder( crate::gpui_shell::config::ui_language(cx) diff --git a/nebula_app/src/gpui_shell/ssh_settings/library/tests.rs b/nebula_app/src/gpui_shell/ssh_settings/library/tests.rs index 8ec506b38..3aeae3333 100644 --- a/nebula_app/src/gpui_shell/ssh_settings/library/tests.rs +++ b/nebula_app/src/gpui_shell/ssh_settings/library/tests.rs @@ -66,10 +66,14 @@ fn pairing_design_ssh_cards_keep_icon_anchors_and_compact_filter(cx: &mut gpui:: cx.update(|window, cx| { pane.update(cx, |pane, cx| { pane.duplicate_ssh_host("nebula-test".into(), window, cx); + let editor = pane.ssh_editor.as_ref().expect("copied host draft"); + assert!(editor.original_destination.is_none()); assert!( - pane.ssh_editor - .as_ref() - .is_some_and(|editor| editor.original_destination.is_none()) + editor + .jump_choices + .iter() + .any(|(host, label)| host == "nebula-test" && label == "Alpha"), + "the copied-from host remains available as a jump host" ); assert_eq!(pane.ssh_destination_input.read(cx).value(), ""); assert_eq!(pane.ssh_label_input.read(cx).value(), "Alpha 1");