Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
31cb03a
feat(ssh): add host duplicate context action
WilliamWang1721 Sep 24, 2026
4423b59
test(ssh): cover duplicate host draft
WilliamWang1721 Sep 24, 2026
d4dbc31
style(ssh): apply rustfmt output
WilliamWang1721 Sep 24, 2026
703acd6
style(ssh): format duplicate host test
WilliamWang1721 Sep 24, 2026
7c66f22
test(ssh): add native copy screenshot probe
WilliamWang1721 Sep 24, 2026
2fdd145
ci: capture SSH host copy screenshots
WilliamWang1721 Sep 24, 2026
1da5980
ci: run SSH screenshot probe on feature branch
WilliamWang1721 Sep 24, 2026
584b201
fix(ssh): use native UI probe for screenshots
WilliamWang1721 Sep 24, 2026
e92ee7c
fix(ci): drive SSH screenshot menu with UI probe
WilliamWang1721 Sep 24, 2026
8c0ec89
style(ssh): apply rustfmt
WilliamWang1721 Sep 24, 2026
bc10750
fix(ssh): let GPUI own screenshot window teardown
WilliamWang1721 Sep 24, 2026
0b55fa1
fix(ssh): exercise screenshot copy through real menu
WilliamWang1721 Sep 24, 2026
eb91759
fix(ci): click SSH Copy in screenshot probe
WilliamWang1721 Sep 24, 2026
6dbbfe1
ci: retrigger PR #282 checks
WilliamWang1721 Sep 24, 2026
362bf9c
fix: close screenshot probe window cleanly
WilliamWang1721 Sep 24, 2026
bcbd712
fix: dismiss SSH copy popup before probe shutdown
WilliamWang1721 Sep 24, 2026
6d21407
test: allow window teardown before app quit
WilliamWang1721 Sep 24, 2026
8b8b37b
test: refresh probe window before teardown
WilliamWang1721 Sep 24, 2026
101e85a
test: release popup menu before native app shutdown
WilliamWang1721 Sep 24, 2026
fb2af4f
test: reset context menu before native teardown
WilliamWang1721 Sep 24, 2026
4ab724c
ci(ui): capture Windows and macOS review screenshots for PR #282
WilliamWang1721 Sep 25, 2026
793e683
ci(ui): replace Windows-only SSH screenshots with cross-platform review
WilliamWang1721 Sep 25, 2026
6afe399
ci(ui): fix native screenshot capture checks
WilliamWang1721 Sep 25, 2026
9d7e454
ci(ui): publish reviewer screenshots outside PR diff
WilliamWang1721 Sep 25, 2026
dcb3e7a
ci(ui): polish screenshots before PR presentation
WilliamWang1721 Sep 25, 2026
d5181c9
ci(ui): reuse release build for SSH screenshot probe
WilliamWang1721 Sep 25, 2026
9a88424
Merge commit '01a36a5a204a3212d9fe86b7d6059aa35a1280d2' into HEAD
Kuddev Sep 25, 2026
7f5279b
ci(review): exclude one-off screenshot workflows from functional changes
Kuddev Sep 25, 2026
66c876e
Merge commit 'b6bf2542d616b402d622d3ff0672bb9bfec3661d' into HEAD
Kuddev Sep 25, 2026
8c56181
fix(ssh): retain the source host in copied jump choices
Kuddev Sep 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions nebula_app/src/gpui_shell/ssh_settings/library.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,34 @@ impl SettingsPane {
self.ssh_library.reset_scroll();
}

fn duplicate_ssh_host(&mut self, host: String, window: &mut Window, cx: &mut Context<Self>) {
let label = self
.ssh_hosts
.profiles
.for_destination(&host)
.label
.filter(|label| !label.trim().is_empty())
.unwrap_or_else(|| host.clone());
let copy_label = self.ssh_hosts.profiles.next_default_label(label.trim());

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(copy_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<String> {
let query = self.ssh_library.search.read(cx).value();
let mut hosts = if self.ssh_library.scope == HostScope::Recent {
Expand Down Expand Up @@ -421,6 +449,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();
Expand All @@ -443,6 +473,52 @@ 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}")))
Expand Down
156 changes: 156 additions & 0 deletions nebula_app/src/gpui_shell/ssh_settings/library/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -60,6 +63,24 @@ 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);
let editor = pane.ssh_editor.as_ref().expect("copied host draft");
assert!(editor.original_destination.is_none());
assert!(
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");
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());
Expand All @@ -74,3 +95,138 @@ 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, 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");
assert!(!menu_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, 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(),
],
..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);
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": "host-row-ready",
"host": "Alpha",
}))
.unwrap(),
)
.unwrap();
for _ in 0..150 {
if output.join("capture-complete").exists() {
break;
}
cx.background_executor().timer(Duration::from_millis(200)).await;
}
}
*result.lock().unwrap() = Some(opened);

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)),
button: gpui::MouseButton::Right,
modifiers: gpui::Modifiers::default(),
click_count: 1,
first_mouse: false,
}),
cx,
);
window.remove_window();
});
drop(pane);
cx.update(|cx| cx.quit());
})
.detach();
},
);
assert_eq!(*after_run.lock().unwrap(), Some(Ok(())));
}
Loading