Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions lang/en/LC_MESSAGES/meatshell.po
Original file line number Diff line number Diff line change
Expand Up @@ -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)"

Expand Down
6 changes: 6 additions & 0 deletions lang/zh/LC_MESSAGES/meatshell.po
Original file line number Diff line number Diff line change
Expand Up @@ -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 "代理(可选)"

Expand Down
65 changes: 47 additions & 18 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2045,6 +2045,33 @@ fn session_groups_model(store: &ConfigStore) -> ModelRc<SharedString> {
)))
}

fn string_model(values: Vec<String>) -> ModelRc<SharedString> {
ModelRc::from(Rc::new(VecModel::from(
values
.into_iter()
.map(SharedString::from)
.collect::<Vec<_>>(),
)))
}

fn ssh_config_models(
hosts: &[crate::ssh_config::ImportedHost],
) -> (
ModelRc<SharedString>,
ModelRc<SharedString>,
ModelRc<SharedString>,
ModelRc<SharedString>,
ModelRc<SharedString>,
) {
(
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
Expand Down Expand Up @@ -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, "");
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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());
Expand Down
54 changes: 54 additions & 0 deletions src/ssh_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<ImportedHost> {
let Some(home) = home_dir() else {
Expand Down Expand Up @@ -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 = "\
Expand Down
10 changes: 10 additions & 0 deletions ui/app.slint
Original file line number Diff line number Diff line change
Expand Up @@ -596,6 +596,11 @@ export component AppWindow inherits Window {
in-out property <string> dialog-flow: "none";
in-out property <bool> dialog-disable-shell-integration: false;
in-out property <string> 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 <bool> confirm-delete-open: false;
Expand Down Expand Up @@ -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(); }
Expand Down
132 changes: 131 additions & 1 deletion ui/session_dialog.slint
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,99 @@ component JumpCombo inherits VerticalLayout {
}
}

// Read-only dropdown for OpenSSH hosts parsed from ~/.ssh/config.
component SshConfigCombo inherits VerticalLayout {
in property <string> label;
in property <[string]> choices;
in-out property <int> 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.
Expand Down Expand Up @@ -324,6 +417,12 @@ export component SessionDialog inherits Rectangle {
in-out property <string> draft-flow: "none";
in-out property <bool> draft-disable-shell-integration: false;
in-out property <string> 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 <int> ssh-config-index: -1;

// Port forwards / tunnels (#56). `forwards` is the current list (fed from
// Rust); the nf-* properties hold the add-form fields.
Expand All @@ -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 {
Expand Down Expand Up @@ -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");
Expand Down