diff --git a/lang/en/LC_MESSAGES/meatshell.po b/lang/en/LC_MESSAGES/meatshell.po index 30b49135..e5f5ff86 100644 --- a/lang/en/LC_MESSAGES/meatshell.po +++ b/lang/en/LC_MESSAGES/meatshell.po @@ -381,6 +381,12 @@ msgstr "Use 'New session' (top-right) to add your first server" msgid "Import ~/.ssh/config" msgstr "Import ~/.ssh/config" +msgid "Import from SSH config" +msgstr "Import from SSH config" + +msgid "Choose a Host from ~/.ssh/config" +msgstr "Choose a Host from ~/.ssh/config" + msgid "Proxy (optional)" msgstr "Proxy (optional)" diff --git a/lang/zh/LC_MESSAGES/meatshell.po b/lang/zh/LC_MESSAGES/meatshell.po index df64871a..193ed48c 100644 --- a/lang/zh/LC_MESSAGES/meatshell.po +++ b/lang/zh/LC_MESSAGES/meatshell.po @@ -381,6 +381,12 @@ msgstr "点击右上角 “新建会话” 添加你的第一台服务器" msgid "Import ~/.ssh/config" msgstr "导入 ~/.ssh/config" +msgid "Import from SSH config" +msgstr "从 SSH 配置导入" + +msgid "Choose a Host from ~/.ssh/config" +msgstr "从 ~/.ssh/config 选择一个 Host" + msgid "Proxy (optional)" msgstr "代理(可选)" diff --git a/src/app.rs b/src/app.rs index 33a185f0..e89d0873 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2045,6 +2045,33 @@ fn session_groups_model(store: &ConfigStore) -> ModelRc { ))) } +fn string_model(values: Vec) -> ModelRc { + ModelRc::from(Rc::new(VecModel::from( + values + .into_iter() + .map(SharedString::from) + .collect::>(), + ))) +} + +fn ssh_config_models( + hosts: &[crate::ssh_config::ImportedHost], +) -> ( + ModelRc, + ModelRc, + ModelRc, + ModelRc, + ModelRc, +) { + ( + string_model(hosts.iter().map(|h| h.alias.clone()).collect()), + string_model(hosts.iter().map(|h| h.hostname.clone()).collect()), + string_model(hosts.iter().map(|h| h.port.to_string()).collect()), + string_model(hosts.iter().map(|h| h.user.clone()).collect()), + string_model(hosts.iter().map(|h| h.identity_file.clone()).collect()), + ) +} + /// Build the jump-host picker's parallel label/id lists for the session dialog /// (#211). Index 0 is always the "no jump host" entry (empty id); the rest are /// the saved SSH sessions except `exclude_id` (a session can't jump through @@ -2211,6 +2238,19 @@ fn wire_session_callbacks( ef_new.borrow_mut().clear(); w.set_session_groups(session_groups_model(&store_ng.borrow())); w.set_dialog_forwards(forward_model(&[])); + let ssh_config_hosts = crate::ssh_config::parse_default(); + let ( + ssh_config_aliases, + ssh_config_hostnames, + ssh_config_ports, + ssh_config_users, + ssh_config_key_paths, + ) = ssh_config_models(&ssh_config_hosts); + w.set_dialog_ssh_config_aliases(ssh_config_aliases); + w.set_dialog_ssh_config_hostnames(ssh_config_hostnames); + w.set_dialog_ssh_config_ports(ssh_config_ports); + w.set_dialog_ssh_config_users(ssh_config_users); + w.set_dialog_ssh_config_key_paths(ssh_config_key_paths); let empty = Session::new_empty(); let (jump_labels, jump_ids, jump_idx) = jump_candidates(&store_ng.borrow(), &empty.id, ""); @@ -2274,24 +2314,7 @@ fn wire_session_callbacks( if dup { continue; } - let auth = if h.identity_file.is_empty() { - AuthMethod::Password - } else { - AuthMethod::Key - }; - s.upsert(Session { - name: h.alias, - host: h.hostname, - port: h.port, - user: if h.user.is_empty() { - "root".into() - } else { - h.user - }, - auth, - private_key_path: h.identity_file, - ..Session::new_empty() - }); + s.upsert(h.into_session()); added += 1; } if added > 0 { @@ -2421,6 +2444,12 @@ fn wire_session_callbacks( if let Some(w) = weak.upgrade() { w.set_session_groups(session_groups_model(&store)); w.set_dialog_forwards(forward_model(&session.forwards)); + let empty_ssh_config = ssh_config_models(&[]); + w.set_dialog_ssh_config_aliases(empty_ssh_config.0); + w.set_dialog_ssh_config_hostnames(empty_ssh_config.1); + w.set_dialog_ssh_config_ports(empty_ssh_config.2); + w.set_dialog_ssh_config_users(empty_ssh_config.3); + w.set_dialog_ssh_config_key_paths(empty_ssh_config.4); w.set_dialog_id(session.id.clone().into()); w.set_dialog_name(session.name.clone().into()); w.set_dialog_host(session.host.clone().into()); diff --git a/src/ssh_config.rs b/src/ssh_config.rs index 1274cd1b..7ed93a7f 100644 --- a/src/ssh_config.rs +++ b/src/ssh_config.rs @@ -7,6 +7,8 @@ use std::path::{Path, PathBuf}; +use crate::config::{AuthMethod, Session}; + /// One importable host parsed from `~/.ssh/config`. #[derive(Debug, Clone)] pub struct ImportedHost { @@ -17,6 +19,38 @@ pub struct ImportedHost { pub identity_file: String, } +impl ImportedHost { + pub fn auth_method(&self) -> AuthMethod { + if self.identity_file.is_empty() { + AuthMethod::Password + } else { + AuthMethod::Key + } + } + + /// Convert an imported OpenSSH host block into a saved meatshell session. + /// + /// The existing bulk importer historically defaulted missing users to + /// `root`; keep that behavior for compatibility while the new-session + /// picker can still use the raw parsed fields and leave user blank. + pub fn into_session(self) -> Session { + let auth = self.auth_method(); + Session { + name: self.alias, + host: self.hostname, + port: self.port, + user: if self.user.is_empty() { + "root".into() + } else { + self.user + }, + auth, + private_key_path: self.identity_file, + ..Session::new_empty() + } + } +} + /// Parse the user's `~/.ssh/config` (returns empty if it doesn't exist). pub fn parse_default() -> Vec { let Some(home) = home_dir() else { @@ -205,6 +239,26 @@ Host alias-only assert_eq!(hosts[1].hostname, "alias-only"); } + #[test] + fn imported_host_converts_to_session() { + let cfg = "\ +Host prod + HostName prod.example.com + Port 2200 + IdentityFile ~/.ssh/prod +"; + let home = Path::new("/home/me"); + let mut hosts = parse_str(cfg, home); + let session = hosts.remove(0).into_session(); + + assert_eq!(session.name, "prod"); + assert_eq!(session.host, "prod.example.com"); + assert_eq!(session.port, 2200); + assert_eq!(session.user, "root"); + assert_eq!(session.auth, AuthMethod::Key); + assert!(session.private_key_path.ends_with("/.ssh/prod")); + } + #[test] fn rejects_invalid_hostnames() { let cfg = "\ diff --git a/ui/app.slint b/ui/app.slint index e3baf9d3..9e9452a0 100644 --- a/ui/app.slint +++ b/ui/app.slint @@ -596,6 +596,11 @@ export component AppWindow inherits Window { in-out property dialog-flow: "none"; in-out property dialog-disable-shell-integration: false; in-out property dialog-note; + in property <[string]> dialog-ssh-config-aliases; + in property <[string]> dialog-ssh-config-hostnames; + in property <[string]> dialog-ssh-config-ports; + in property <[string]> dialog-ssh-config-users; + in property <[string]> dialog-ssh-config-key-paths; // Delete-confirmation modal state (#28). in-out property confirm-delete-open: false; @@ -3131,6 +3136,11 @@ export component AppWindow inherits Window { draft-flow <=> root.dialog-flow; draft-disable-shell-integration <=> root.dialog-disable-shell-integration; draft-note <=> root.dialog-note; + ssh-config-aliases: root.dialog-ssh-config-aliases; + ssh-config-hostnames: root.dialog-ssh-config-hostnames; + ssh-config-ports: root.dialog-ssh-config-ports; + ssh-config-users: root.dialog-ssh-config-users; + ssh-config-key-paths: root.dialog-ssh-config-key-paths; forwards: root.dialog-forwards; submit(draft) => { root.session-dialog-submit(draft); } cancel => { root.session-dialog-cancel(); } diff --git a/ui/session_dialog.slint b/ui/session_dialog.slint index 60f4dfb0..844ba895 100644 --- a/ui/session_dialog.slint +++ b/ui/session_dialog.slint @@ -288,6 +288,99 @@ component JumpCombo inherits VerticalLayout { } } +// Read-only dropdown for OpenSSH hosts parsed from ~/.ssh/config. +component SshConfigCombo inherits VerticalLayout { + in property label; + in property <[string]> choices; + in-out property selected-index: -1; + callback picked(int /* index */); + spacing: 4px; + + Text { + text: root.label; + color: Theme.text-secondary; + font-size: Theme.fs-sm; + } + + Rectangle { + height: 32px; + border-radius: Theme.radius-sm; + border-width: 1px; + border-color: cfg-ta.has-hover ? Theme.accent : Theme.border-subtle; + background: Theme.bg-root; + + cfg-ta := TouchArea { + mouse-cursor: pointer; + clicked => { cfg-popup.show(); } + } + + Text { + x: 10px; + width: parent.width - 44px; + height: parent.height; + text: (root.selected-index >= 0 && root.selected-index < root.choices.length) + ? root.choices[root.selected-index] : @tr("Choose a Host from ~/.ssh/config"); + color: root.selected-index >= 0 ? Theme.text-primary : Theme.text-muted; + font-size: Theme.fs-md; + vertical-alignment: center; + overflow: elide; + } + + Rectangle { + x: parent.width - 36px; + width: 28px; + height: 28px; + border-radius: Theme.radius-sm; + background: cfg-ta.has-hover ? Theme.bg-hover : transparent; + Text { + text: "v"; + color: Theme.text-secondary; + font-size: Theme.fs-xs; + horizontal-alignment: center; + vertical-alignment: center; + } + } + + cfg-popup := PopupWindow { + x: 0; + y: parent.height + 3px; + width: parent.width; + Rectangle { + background: Theme.bg-elevated; + border-radius: Theme.radius-sm; + border-width: 1px; + border-color: Theme.border-subtle; + VerticalLayout { + padding: 4px; + spacing: 1px; + for c[idx] in root.choices : Rectangle { + height: 28px; + border-radius: Theme.radius-sm; + background: c-ta.has-hover ? Theme.bg-hover : transparent; + c-ta := TouchArea { + mouse-cursor: pointer; + clicked => { + root.selected-index = idx; + root.picked(idx); + cfg-popup.close(); + } + } + Text { + x: 10px; + width: parent.width - 20px; + text: c; + color: Theme.text-primary; + font-size: Theme.fs-md; + vertical-alignment: center; + overflow: elide; + } + } + } + } + } + } +} + // Modal dialog for creating / editing a session (SSH / Serial / Telnet). // The dialog is purely presentational — it stores values in mutable // properties and emits `submit` / `cancel` callbacks up to the main window. @@ -324,6 +417,12 @@ export component SessionDialog inherits Rectangle { in-out property draft-flow: "none"; in-out property draft-disable-shell-integration: false; in-out property draft-note; + in property <[string]> ssh-config-aliases; + in property <[string]> ssh-config-hostnames; + in property <[string]> ssh-config-ports; + in property <[string]> ssh-config-users; + in property <[string]> ssh-config-key-paths; + property ssh-config-index: -1; // Port forwards / tunnels (#56). `forwards` is the current list (fed from // Rust); the nf-* properties hold the add-form fields. @@ -347,7 +446,12 @@ export component SessionDialog inherits Rectangle { background: #000000aa; // Clear the host-required flag each time the dialog opens (#110). - changed is-open => { if (self.is-open) { self.host-missing = false; } } + changed is-open => { + if (self.is-open) { + self.host-missing = false; + self.ssh-config-index = -1; + } + } // Swallow clicks on the backdrop TouchArea { @@ -419,6 +523,32 @@ export component SessionDialog inherits Rectangle { } } + if !root.is-editing && root.draft-kind == "ssh" && root.ssh-config-aliases.length > 0 : SshConfigCombo { + label: @tr("Import from SSH config"); + choices: root.ssh-config-aliases; + selected-index <=> root.ssh-config-index; + picked(idx) => { + if (idx >= 0 + && idx < root.ssh-config-aliases.length + && idx < root.ssh-config-hostnames.length + && idx < root.ssh-config-ports.length + && idx < root.ssh-config-users.length + && idx < root.ssh-config-key-paths.length) { + root.draft-kind = "ssh"; + root.draft-name = root.ssh-config-aliases[idx]; + root.draft-host = root.ssh-config-hostnames[idx]; + root.draft-port = root.ssh-config-ports[idx]; + root.draft-user = root.ssh-config-users[idx]; + root.draft-key-path = root.ssh-config-key-paths[idx]; + root.draft-key-inline = ""; + root.draft-key-inline-mode = false; + root.draft-auth = root.ssh-config-key-paths[idx] == "" ? "password" : "key"; + root.draft-password = ""; + root.host-missing = false; + } + } + } + LabeledInput { label: @tr("Name"); placeholder: @tr("e.g. production web-01");