diff --git a/nebula_app/src/gpui_shell/ssh_settings/library.rs b/nebula_app/src/gpui_shell/ssh_settings/library.rs index 28072c66a..2760251b2 100644 --- a/nebula_app/src/gpui_shell/ssh_settings/library.rs +++ b/nebula_app/src/gpui_shell/ssh_settings/library.rs @@ -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) { + 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 { let query = self.ssh_library.search.read(cx).value(); let mut hosts = if self.ssh_library.scope == HostScope::Recent { @@ -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(); @@ -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}"))) 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..3aeae3333 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,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()); @@ -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(()))); +}