Merge pull request #282 from WilliamWang1721/feat/ssh-host-duplicate-menu
feat(ssh): duplicate saved hosts from context menu
This commit is contained in:
2 files changed
+232
No files matched your search
@@ -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 {
|
||||
@@ -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}")))
|
||||
|
||||
@@ -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(())));
|
||||
}
|
||||
Reference in new issue
Block a user