mirror of
https://github.com/rust-kotlin/ashell.git
synced 2026-09-22 16:00:59 +00:00
feat: add support for serial port connections with virtual PTY fallback and automatic newline translation
This commit is contained in:
Generated
+83
-3
@@ -334,6 +334,7 @@ dependencies = [
|
||||
"hex",
|
||||
"hmac",
|
||||
"image",
|
||||
"libc",
|
||||
"menu",
|
||||
"notify 6.1.1",
|
||||
"open",
|
||||
@@ -347,6 +348,7 @@ dependencies = [
|
||||
"rust-i18n 3.1.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serialport",
|
||||
"sha2",
|
||||
"ssh-key",
|
||||
"sys-locale",
|
||||
@@ -2760,7 +2762,7 @@ dependencies = [
|
||||
"itertools 0.14.0",
|
||||
"log",
|
||||
"lyon",
|
||||
"mach2",
|
||||
"mach2 0.5.0",
|
||||
"media",
|
||||
"metal",
|
||||
"num_cpus",
|
||||
@@ -2950,7 +2952,7 @@ dependencies = [
|
||||
"itertools 0.14.0",
|
||||
"libc",
|
||||
"log",
|
||||
"mach2",
|
||||
"mach2 0.5.0",
|
||||
"media",
|
||||
"metal",
|
||||
"objc",
|
||||
@@ -3683,6 +3685,16 @@ dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "io-kit-sys"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "617ee6cf8e3f66f3b4ea67a4058564628cde41901316e19f559e14c7c72c5e7b"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"mach2 0.4.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "io-surface"
|
||||
version = "0.16.1"
|
||||
@@ -3952,6 +3964,26 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libudev"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78b324152da65df7bb95acfcaab55e3097ceaab02fb19b228a9eb74d55f135e0"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"libudev-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libudev-sys"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linebender_resource_handle"
|
||||
version = "0.1.1"
|
||||
@@ -4099,6 +4131,15 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
||||
|
||||
[[package]]
|
||||
name = "mach2"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mach2"
|
||||
version = "0.5.0"
|
||||
@@ -4397,6 +4438,17 @@ dependencies = [
|
||||
"pin-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.26.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"cfg-if",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.29.0"
|
||||
@@ -6897,6 +6949,25 @@ dependencies = [
|
||||
"serial-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serialport"
|
||||
version = "4.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4d91116f97173694f1642263b2ff837f80d933aa837e2314969f6728f661df3"
|
||||
dependencies = [
|
||||
"bitflags 2.12.1",
|
||||
"cfg-if",
|
||||
"core-foundation 0.10.0",
|
||||
"core-foundation-sys",
|
||||
"io-kit-sys",
|
||||
"libudev",
|
||||
"mach2 0.4.3",
|
||||
"nix 0.26.4",
|
||||
"scopeguard",
|
||||
"unescaper",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.10.6"
|
||||
@@ -8133,6 +8204,15 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unescaper"
|
||||
version = "0.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7285e83a80ce76f5e7bce79fa41f68d78ba62d1003cf27bf748ab24413808cf4"
|
||||
dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
version = "2.9.0"
|
||||
@@ -8310,7 +8390,7 @@ dependencies = [
|
||||
"itertools 0.14.0",
|
||||
"libc",
|
||||
"log",
|
||||
"mach2",
|
||||
"mach2 0.5.0",
|
||||
"nix 0.29.0",
|
||||
"percent-encoding",
|
||||
"regex",
|
||||
|
||||
@@ -50,10 +50,14 @@ base64 = "0.22"
|
||||
chacha20poly1305 = "0.10"
|
||||
hmac = "0.12"
|
||||
hex = "0.4"
|
||||
serialport = "4"
|
||||
rand = "0.8"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
|
||||
sha2 = "0.10"
|
||||
|
||||
[dev-dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[package.metadata.deb]
|
||||
maintainer = "ashell contributors"
|
||||
license-file = ["LICENSE"]
|
||||
|
||||
@@ -13,6 +13,11 @@ size: "SIZE"
|
||||
modified: "MODIFIED"
|
||||
new_ssh_connection: "New SSH Connection"
|
||||
create_or_edit_ssh_session: "Create or edit an SSH session"
|
||||
new_serial_connection: "New Serial Connection"
|
||||
create_or_edit_serial_session: "Create or edit a Serial session"
|
||||
serial_port: "Serial Port Path"
|
||||
baud_rate: "Baud Rate"
|
||||
session_name: "Session Name"
|
||||
new_folder: "New Folder"
|
||||
create_folder_success: "Created folder: %{name}"
|
||||
create_folder_failed: "Failed to create folder: %{err}"
|
||||
|
||||
@@ -13,6 +13,11 @@ size: "大小"
|
||||
modified: "修改时间"
|
||||
new_ssh_connection: "新建 SSH 连接"
|
||||
create_or_edit_ssh_session: "创建或编辑一个 SSH 会话"
|
||||
new_serial_connection: "新建串口连接"
|
||||
create_or_edit_serial_session: "创建或编辑串口会话"
|
||||
serial_port: "串口设备路径"
|
||||
baud_rate: "波特率"
|
||||
session_name: "会话名称"
|
||||
new_folder: "新建文件夹"
|
||||
create_folder_success: "成功创建文件夹: %{name}"
|
||||
create_folder_failed: "创建文件夹失败: %{err}"
|
||||
|
||||
+308
-236
@@ -26,6 +26,15 @@ impl Ashell {
|
||||
}
|
||||
self.active_dialog = Some(crate::app::DialogKind::NewSsh);
|
||||
|
||||
if let Some(id) = &self.editing_session_id {
|
||||
if let Some(session) = self.config.get(id) {
|
||||
self.session_protocol = session.protocol.clone();
|
||||
}
|
||||
} else {
|
||||
self.session_protocol = "ssh".to_string();
|
||||
}
|
||||
|
||||
let initial_is_serial = self.session_protocol == "serial";
|
||||
let view = cx.entity();
|
||||
let session_name_input = self.session_name_input.clone();
|
||||
let host_input = self.host_input.clone();
|
||||
@@ -40,10 +49,11 @@ impl Ashell {
|
||||
let proxy_port_input = self.proxy_port_input.clone();
|
||||
let proxy_user_input = self.proxy_user_input.clone();
|
||||
let proxy_password_input = self.proxy_password_input.clone();
|
||||
let baud_rate_input = self.baud_rate_input.clone();
|
||||
|
||||
window.open_dialog(cx, move |dialog: Dialog, _window, _cx| {
|
||||
dialog
|
||||
.title(t!("new_ssh_connection"))
|
||||
.title(if initial_is_serial { t!("new_serial_connection") } else { t!("new_ssh_connection") })
|
||||
.w(px(520.))
|
||||
.overlay_closable(true)
|
||||
.on_close({
|
||||
@@ -69,6 +79,7 @@ impl Ashell {
|
||||
let proxy_port_input = proxy_port_input.clone();
|
||||
let proxy_user_input = proxy_user_input.clone();
|
||||
let proxy_password_input = proxy_password_input.clone();
|
||||
let baud_rate_input = baud_rate_input.clone();
|
||||
move |content, window, cx| {
|
||||
let auth_method = view.read(cx).ssh_auth_method;
|
||||
let is_password = auth_method == AuthMethod::Password;
|
||||
@@ -77,6 +88,9 @@ impl Ashell {
|
||||
let is_editing = view.read(cx).editing_session_id.is_some();
|
||||
let proxy_type = view.read(cx).ssh_proxy_type.clone();
|
||||
let show_proxy_fields = proxy_type != "none";
|
||||
let protocol = view.read(cx).session_protocol.clone();
|
||||
let is_ssh = protocol == "ssh";
|
||||
let is_serial = protocol == "serial";
|
||||
content.child(
|
||||
v_flex()
|
||||
.gap_3()
|
||||
@@ -84,258 +98,307 @@ impl Ashell {
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
Button::new("ssh-auth-password")
|
||||
.label(t!("password").to_string())
|
||||
.when(is_password, |button| button.primary())
|
||||
Button::new("proto-ssh")
|
||||
.label("SSH")
|
||||
.when(is_ssh, |button| button.primary())
|
||||
.on_click(window.listener_for(
|
||||
&view,
|
||||
|this, _, _, cx| {
|
||||
this.set_ssh_auth_method(
|
||||
AuthMethod::Password,
|
||||
cx,
|
||||
)
|
||||
|this, _, window, cx| {
|
||||
this.set_session_protocol("ssh".to_string(), cx);
|
||||
Self::set_input_value(&this.port_input, "22", window, cx);
|
||||
},
|
||||
)),
|
||||
)
|
||||
.child(
|
||||
Button::new("ssh-auth-key")
|
||||
.label(t!("key").to_string())
|
||||
.when(is_key, |button| button.primary())
|
||||
Button::new("proto-serial")
|
||||
.label("Serial")
|
||||
.when(is_serial, |button| button.primary())
|
||||
.on_click(window.listener_for(
|
||||
&view,
|
||||
|this, _, _, cx| {
|
||||
this.set_ssh_auth_method(
|
||||
AuthMethod::Key,
|
||||
cx,
|
||||
)
|
||||
},
|
||||
)),
|
||||
)
|
||||
.child(
|
||||
Button::new("ssh-auth-config")
|
||||
.label(t!("ssh_config").to_string())
|
||||
.when(is_config, |button| button.primary())
|
||||
.on_click(window.listener_for(
|
||||
&view,
|
||||
|this, _, _, cx| {
|
||||
this.set_ssh_auth_method(
|
||||
AuthMethod::Config,
|
||||
cx,
|
||||
)
|
||||
|this, _, _window, cx| {
|
||||
this.set_session_protocol("serial".to_string(), cx);
|
||||
},
|
||||
)),
|
||||
),
|
||||
)
|
||||
.when(!is_config, |this| {
|
||||
this.child(Input::new(&session_name_input).tab_index(0))
|
||||
.child(Input::new(&host_input).tab_index(1))
|
||||
.when(is_serial, |this| {
|
||||
this.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.child(div().text_sm().text_color(cx.theme().muted_foreground).child(t!("session_name").to_string()))
|
||||
.child(Input::new(&session_name_input).tab_index(0))
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.child(div().text_sm().text_color(cx.theme().muted_foreground).child(t!("serial_port").to_string()))
|
||||
.child(Input::new(&host_input).tab_index(1))
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.child(div().text_sm().text_color(cx.theme().muted_foreground).child(t!("baud_rate").to_string()))
|
||||
.child(Input::new(&baud_rate_input).tab_index(2))
|
||||
)
|
||||
})
|
||||
.when(is_ssh, |this| {
|
||||
this.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
Button::new("ssh-auth-password")
|
||||
.label(t!("password").to_string())
|
||||
.when(is_password, |button| button.primary())
|
||||
.on_click(window.listener_for(
|
||||
&view,
|
||||
|this, _, _, cx| {
|
||||
this.set_ssh_auth_method(
|
||||
AuthMethod::Password,
|
||||
cx,
|
||||
)
|
||||
},
|
||||
)),
|
||||
)
|
||||
.child(
|
||||
Button::new("ssh-auth-key")
|
||||
.label(t!("key").to_string())
|
||||
.when(is_key, |button| button.primary())
|
||||
.on_click(window.listener_for(
|
||||
&view,
|
||||
|this, _, _, cx| {
|
||||
this.set_ssh_auth_method(
|
||||
AuthMethod::Key,
|
||||
cx,
|
||||
)
|
||||
},
|
||||
)),
|
||||
)
|
||||
.child(
|
||||
Button::new("ssh-auth-config")
|
||||
.label(t!("ssh_config").to_string())
|
||||
.when(is_config, |button| button.primary())
|
||||
.on_click(window.listener_for(
|
||||
&view,
|
||||
|this, _, _, cx| {
|
||||
this.set_ssh_auth_method(
|
||||
AuthMethod::Config,
|
||||
cx,
|
||||
)
|
||||
},
|
||||
)),
|
||||
),
|
||||
)
|
||||
.when(!is_config, |this| {
|
||||
this.child(Input::new(&session_name_input).tab_index(0))
|
||||
.child(Input::new(&host_input).tab_index(1))
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
Input::new(&port_input).w(px(96.)).tab_index(2),
|
||||
)
|
||||
.child(
|
||||
Input::new(&user_input).flex_1().tab_index(3),
|
||||
),
|
||||
)
|
||||
})
|
||||
.when(is_password, |this| {
|
||||
this.child(
|
||||
Input::new(&password_input).mask_toggle().tab_index(4),
|
||||
)
|
||||
})
|
||||
.when(is_key, |this| {
|
||||
this.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.cursor_pointer()
|
||||
.on_mouse_down(
|
||||
MouseButton::Left,
|
||||
window.listener_for(
|
||||
&view,
|
||||
|this, _, window, cx| {
|
||||
this.pick_ssh_key_path(window, cx);
|
||||
},
|
||||
),
|
||||
)
|
||||
.child(
|
||||
Input::new(&key_path_input).tab_index(4),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
Button::new("clear-key-path")
|
||||
.ghost()
|
||||
.icon(IconName::Close)
|
||||
.on_click(window.listener_for(
|
||||
&view,
|
||||
|this, _, window, cx| {
|
||||
Self::set_input_value(
|
||||
&this.key_path_input,
|
||||
"",
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
},
|
||||
)),
|
||||
),
|
||||
)
|
||||
.child(Input::new(&key_inline_input).h(px(128.)).tab_index(5))
|
||||
.child(Input::new(&passphrase_input).mask_toggle().tab_index(6))
|
||||
})
|
||||
.when(is_config, |this| {
|
||||
let entries = view.read(cx).ssh_config_entries.clone();
|
||||
let selected = view.read(cx).ssh_config_selected;
|
||||
let theme = cx.theme();
|
||||
if entries.is_empty() {
|
||||
this.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(theme.muted_foreground)
|
||||
.child(t!("ssh_config_empty").to_string()),
|
||||
)
|
||||
} else {
|
||||
this.child(
|
||||
div()
|
||||
.h(px(192.))
|
||||
.id("ssh-config-list")
|
||||
.track_scroll(
|
||||
&view.read(cx).connection_scroll_handle,
|
||||
)
|
||||
.overflow_y_scroll()
|
||||
.border_1()
|
||||
.border_color(theme.border)
|
||||
.rounded_md()
|
||||
.children(entries.iter().enumerate().map(
|
||||
|(i, entry)| {
|
||||
let is_selected = selected == Some(i);
|
||||
let label = if entry.user.is_empty() {
|
||||
format!(
|
||||
"{}:{}",
|
||||
entry.hostname, entry.port
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"{}@{}:{}",
|
||||
entry.user,
|
||||
entry.hostname,
|
||||
entry.port
|
||||
)
|
||||
};
|
||||
let alias_label =
|
||||
if entry.host_alias == entry.hostname {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" ({})", entry.host_alias)
|
||||
};
|
||||
let view_clone = view.clone();
|
||||
div()
|
||||
.id(("ssh-config-entry", i))
|
||||
.px_2()
|
||||
.py_1()
|
||||
.when(is_selected, |el| {
|
||||
el.bg(theme.selection)
|
||||
})
|
||||
.cursor_pointer()
|
||||
.hover(|el| el.bg(theme.selection))
|
||||
.text_sm()
|
||||
.child(format!("{label}{alias_label}"))
|
||||
.on_click(window.listener_for(
|
||||
&view_clone,
|
||||
move |this, _, window, cx| {
|
||||
this.select_ssh_config_entry(
|
||||
i, window, cx,
|
||||
);
|
||||
},
|
||||
))
|
||||
},
|
||||
)),
|
||||
)
|
||||
}
|
||||
})
|
||||
.when(!is_config, |this| {
|
||||
this.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.font_weight(FontWeight::BOLD)
|
||||
.child(t!("proxy").to_string()),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
Input::new(&port_input).w(px(96.)).tab_index(2),
|
||||
Button::new("proxy-none")
|
||||
.label(t!("proxy_none").to_string())
|
||||
.when(proxy_type == "none", |button| {
|
||||
button.primary()
|
||||
})
|
||||
.on_click(window.listener_for(
|
||||
&view,
|
||||
|this, _, _, cx| {
|
||||
this.set_ssh_proxy_type(
|
||||
"none".to_string(),
|
||||
cx,
|
||||
)
|
||||
},
|
||||
)),
|
||||
)
|
||||
.child(
|
||||
Input::new(&user_input).flex_1().tab_index(3),
|
||||
Button::new("proxy-socks5")
|
||||
.label("SOCKS5")
|
||||
.when(proxy_type == "socks5", |button| {
|
||||
button.primary()
|
||||
})
|
||||
.on_click(window.listener_for(
|
||||
&view,
|
||||
|this, _, _, cx| {
|
||||
this.set_ssh_proxy_type(
|
||||
"socks5".to_string(),
|
||||
cx,
|
||||
)
|
||||
},
|
||||
)),
|
||||
)
|
||||
.child(
|
||||
Button::new("proxy-http")
|
||||
.label("HTTP")
|
||||
.when(proxy_type == "http", |button| {
|
||||
button.primary()
|
||||
})
|
||||
.on_click(window.listener_for(
|
||||
&view,
|
||||
|this, _, _, cx| {
|
||||
this.set_ssh_proxy_type(
|
||||
"http".to_string(),
|
||||
cx,
|
||||
)
|
||||
},
|
||||
)),
|
||||
),
|
||||
)
|
||||
})
|
||||
.when(is_password, |this| {
|
||||
this.child(
|
||||
Input::new(&password_input).mask_toggle().tab_index(4),
|
||||
)
|
||||
})
|
||||
.when(is_key, |this| {
|
||||
this.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.cursor_pointer()
|
||||
.on_mouse_down(
|
||||
MouseButton::Left,
|
||||
window.listener_for(
|
||||
&view,
|
||||
|this, _, window, cx| {
|
||||
this.pick_ssh_key_path(window, cx);
|
||||
},
|
||||
.when(
|
||||
show_proxy_fields,
|
||||
|this| {
|
||||
this.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(Input::new(&proxy_host_input).flex_1())
|
||||
.child(
|
||||
Input::new(&proxy_port_input).w(px(96.)),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
Input::new(&key_path_input).tab_index(4),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
Button::new("clear-key-path")
|
||||
.ghost()
|
||||
.icon(IconName::Close)
|
||||
.on_click(window.listener_for(
|
||||
&view,
|
||||
|this, _, window, cx| {
|
||||
Self::set_input_value(
|
||||
&this.key_path_input,
|
||||
"",
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
},
|
||||
)),
|
||||
),
|
||||
)
|
||||
.child(Input::new(&key_inline_input).h(px(128.)).tab_index(5))
|
||||
.child(Input::new(&passphrase_input).mask_toggle().tab_index(6))
|
||||
})
|
||||
.when(is_config, |this| {
|
||||
let entries = view.read(cx).ssh_config_entries.clone();
|
||||
let selected = view.read(cx).ssh_config_selected;
|
||||
let theme = cx.theme();
|
||||
if entries.is_empty() {
|
||||
this.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(theme.muted_foreground)
|
||||
.child(t!("ssh_config_empty").to_string()),
|
||||
)
|
||||
} else {
|
||||
this.child(
|
||||
div()
|
||||
.h(px(192.))
|
||||
.id("ssh-config-list")
|
||||
.track_scroll(
|
||||
&view.read(cx).connection_scroll_handle,
|
||||
)
|
||||
.overflow_y_scroll()
|
||||
.border_1()
|
||||
.border_color(theme.border)
|
||||
.rounded_md()
|
||||
.children(entries.iter().enumerate().map(
|
||||
|(i, entry)| {
|
||||
let is_selected = selected == Some(i);
|
||||
let label = if entry.user.is_empty() {
|
||||
format!(
|
||||
"{}:{}",
|
||||
entry.hostname, entry.port
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"{}@{}:{}",
|
||||
entry.user,
|
||||
entry.hostname,
|
||||
entry.port
|
||||
)
|
||||
};
|
||||
let alias_label =
|
||||
if entry.host_alias == entry.hostname {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" ({})", entry.host_alias)
|
||||
};
|
||||
let view_clone = view.clone();
|
||||
div()
|
||||
.id(("ssh-config-entry", i))
|
||||
.px_2()
|
||||
.py_1()
|
||||
.when(is_selected, |el| {
|
||||
el.bg(theme.selection)
|
||||
})
|
||||
.cursor_pointer()
|
||||
.hover(|el| el.bg(theme.selection))
|
||||
.text_sm()
|
||||
.child(format!("{label}{alias_label}"))
|
||||
.on_click(window.listener_for(
|
||||
&view_clone,
|
||||
move |this, _, window, cx| {
|
||||
this.select_ssh_config_entry(
|
||||
i, window, cx,
|
||||
);
|
||||
},
|
||||
))
|
||||
},
|
||||
)),
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(Input::new(&proxy_user_input).flex_1())
|
||||
.child(
|
||||
Input::new(&proxy_password_input).flex_1(),
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
})
|
||||
.when(!is_config, |this| {
|
||||
this.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.font_weight(FontWeight::BOLD)
|
||||
.child(t!("proxy").to_string()),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
Button::new("proxy-none")
|
||||
.label(t!("proxy_none").to_string())
|
||||
.when(proxy_type == "none", |button| {
|
||||
button.primary()
|
||||
})
|
||||
.on_click(window.listener_for(
|
||||
&view,
|
||||
|this, _, _, cx| {
|
||||
this.set_ssh_proxy_type(
|
||||
"none".to_string(),
|
||||
cx,
|
||||
)
|
||||
},
|
||||
)),
|
||||
)
|
||||
.child(
|
||||
Button::new("proxy-socks5")
|
||||
.label("SOCKS5")
|
||||
.when(proxy_type == "socks5", |button| {
|
||||
button.primary()
|
||||
})
|
||||
.on_click(window.listener_for(
|
||||
&view,
|
||||
|this, _, _, cx| {
|
||||
this.set_ssh_proxy_type(
|
||||
"socks5".to_string(),
|
||||
cx,
|
||||
)
|
||||
},
|
||||
)),
|
||||
)
|
||||
.child(
|
||||
Button::new("proxy-http")
|
||||
.label("HTTP")
|
||||
.when(proxy_type == "http", |button| {
|
||||
button.primary()
|
||||
})
|
||||
.on_click(window.listener_for(
|
||||
&view,
|
||||
|this, _, _, cx| {
|
||||
this.set_ssh_proxy_type(
|
||||
"http".to_string(),
|
||||
cx,
|
||||
)
|
||||
},
|
||||
)),
|
||||
),
|
||||
)
|
||||
.when(
|
||||
show_proxy_fields,
|
||||
|this| {
|
||||
this.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(Input::new(&proxy_host_input).flex_1())
|
||||
.child(
|
||||
Input::new(&proxy_port_input).w(px(96.)),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(Input::new(&proxy_user_input).flex_1())
|
||||
.child(
|
||||
Input::new(&proxy_password_input).flex_1(),
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
.child(
|
||||
h_flex()
|
||||
@@ -538,12 +601,21 @@ impl Ashell {
|
||||
let is_selected =
|
||||
selected_index == ix + 2;
|
||||
let name = session.name.clone();
|
||||
let detail = format!(
|
||||
"{}@{}:{}",
|
||||
session.user,
|
||||
session.host,
|
||||
session.port
|
||||
);
|
||||
let detail = if session.protocol
|
||||
== "serial"
|
||||
{
|
||||
format!(
|
||||
"Serial: {}@{}",
|
||||
session.host, session.baud_rate
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"{}@{}:{}",
|
||||
session.user,
|
||||
session.host,
|
||||
session.port
|
||||
)
|
||||
};
|
||||
div()
|
||||
.id(("selector-open", ix))
|
||||
.w_full()
|
||||
|
||||
+46
-23
@@ -212,6 +212,8 @@ pub(crate) struct Ashell {
|
||||
pub(crate) key_path_input: Entity<InputState>,
|
||||
pub(crate) key_inline_input: Entity<InputState>,
|
||||
pub(crate) passphrase_input: Entity<InputState>,
|
||||
pub(crate) baud_rate_input: Entity<InputState>,
|
||||
pub(crate) session_protocol: String,
|
||||
pub(crate) ssh_proxy_type: String,
|
||||
pub(crate) proxy_host_input: Entity<InputState>,
|
||||
pub(crate) proxy_port_input: Entity<InputState>,
|
||||
@@ -401,6 +403,7 @@ impl Ashell {
|
||||
.placeholder("SSH private key passphrase (optional)")
|
||||
.masked(true)
|
||||
});
|
||||
let baud_rate_input = cx.new(|cx| InputState::new(window, cx).default_value("115200"));
|
||||
let proxy_host_input =
|
||||
cx.new(|cx| InputState::new(window, cx).placeholder(t!("proxy_host").to_string()));
|
||||
let proxy_port_input =
|
||||
@@ -510,6 +513,7 @@ impl Ashell {
|
||||
cx.subscribe_in(&key_path_input, window, Self::on_input_event),
|
||||
cx.subscribe_in(&key_inline_input, window, Self::on_input_event),
|
||||
cx.subscribe_in(&passphrase_input, window, Self::on_input_event),
|
||||
cx.subscribe_in(&baud_rate_input, window, Self::on_input_event),
|
||||
cx.subscribe_in(&proxy_host_input, window, Self::on_input_event),
|
||||
cx.subscribe_in(&proxy_port_input, window, Self::on_input_event),
|
||||
cx.subscribe_in(&proxy_user_input, window, Self::on_input_event),
|
||||
@@ -588,6 +592,8 @@ impl Ashell {
|
||||
key_path_input,
|
||||
key_inline_input,
|
||||
passphrase_input,
|
||||
baud_rate_input,
|
||||
session_protocol: "ssh".to_string(),
|
||||
ssh_proxy_type: "none".to_string(),
|
||||
proxy_host_input,
|
||||
proxy_port_input,
|
||||
@@ -1145,7 +1151,7 @@ impl Ashell {
|
||||
let mut retry_tabs = Vec::new();
|
||||
for (ix, tab) in self.tabs.iter().enumerate() {
|
||||
if !tab.connected && tab.session.is_some() && tab.id == progress.tab_id {
|
||||
retry_tabs.push((ix, tab.id.clone(), tab.session.clone().unwrap()));
|
||||
retry_tabs.push((ix, tab.id.clone(), tab.session.clone().unwrap(), tab.kind));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1154,19 +1160,34 @@ impl Ashell {
|
||||
return;
|
||||
}
|
||||
|
||||
for (ix, tab_id, session) in retry_tabs {
|
||||
for (ix, tab_id, session, tab_kind) in retry_tabs {
|
||||
// Close old backend
|
||||
self.tabs[ix].send_backend(crate::terminal::BackendCommand::Close);
|
||||
|
||||
// Spawn new backend
|
||||
let backend = crate::backend::ssh::spawn_ssh_terminal(
|
||||
self.runtime.handle(),
|
||||
tab_id.clone(),
|
||||
session.clone(),
|
||||
self.tabs[ix].cols,
|
||||
self.tabs[ix].rows,
|
||||
self.events_tx.clone(),
|
||||
);
|
||||
let backend = match tab_kind {
|
||||
crate::terminal::TabKind::Serial => {
|
||||
let b = crate::backend::serial::spawn_serial_client(
|
||||
self.runtime.handle(),
|
||||
tab_id.clone(),
|
||||
session.clone(),
|
||||
self.events_tx.clone(),
|
||||
);
|
||||
crate::terminal::BackendTx::Serial(b)
|
||||
}
|
||||
crate::terminal::TabKind::Ssh => {
|
||||
let b = crate::backend::ssh::spawn_ssh_terminal(
|
||||
self.runtime.handle(),
|
||||
tab_id.clone(),
|
||||
session.clone(),
|
||||
self.tabs[ix].cols,
|
||||
self.tabs[ix].rows,
|
||||
self.events_tx.clone(),
|
||||
);
|
||||
b
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
// Replace tab state
|
||||
self.tabs[ix].set_backend(backend);
|
||||
@@ -1189,20 +1210,22 @@ impl Ashell {
|
||||
.and_then(|t| t.session.clone());
|
||||
|
||||
if let Some(session) = group_session {
|
||||
if let Some(old_handle) = self.sftp_handles.remove(&group_id) {
|
||||
old_handle.close();
|
||||
}
|
||||
let sftp_handle = crate::sftp::spawn_sftp(
|
||||
self.runtime.handle(),
|
||||
group_id.clone(),
|
||||
session,
|
||||
self.events_tx.clone(),
|
||||
);
|
||||
self.sftp_handles.insert(group_id.clone(), sftp_handle);
|
||||
if session.protocol != "serial" {
|
||||
if let Some(old_handle) = self.sftp_handles.remove(&group_id) {
|
||||
old_handle.close();
|
||||
}
|
||||
let sftp_handle = crate::sftp::spawn_sftp(
|
||||
self.runtime.handle(),
|
||||
group_id.clone(),
|
||||
session,
|
||||
self.events_tx.clone(),
|
||||
);
|
||||
self.sftp_handles.insert(group_id.clone(), sftp_handle);
|
||||
|
||||
if let Some(group) = self.tab_groups.iter_mut().find(|g| g.id == group_id) {
|
||||
if let Some(sftp) = group.sftp.as_mut() {
|
||||
sftp.status = rust_i18n::t!("sftp_connecting").to_string();
|
||||
if let Some(group) = self.tab_groups.iter_mut().find(|g| g.id == group_id) {
|
||||
if let Some(sftp) = group.sftp.as_mut() {
|
||||
sftp.status = rust_i18n::t!("sftp_connecting").to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1622,6 +1622,13 @@ impl Ashell {
|
||||
"ssh".to_string()
|
||||
}
|
||||
}
|
||||
TabKind::Serial => {
|
||||
if let Some((_, session)) = self.active_ssh_session() {
|
||||
format!("serial / {}", session.name)
|
||||
} else {
|
||||
"serial".to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.active_title()
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
pub mod local;
|
||||
pub mod serial;
|
||||
pub mod ssh;
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
use crate::session::config::Session;
|
||||
use crate::terminal::{BackendCommand, BackendEvent};
|
||||
use std::io::{Read, Write};
|
||||
|
||||
/// Spawn the serial port backend threads.
|
||||
/// Returns a sender to send commands (like keyboard inputs) to the serial port.
|
||||
pub fn spawn_serial_client(
|
||||
_handle: &tokio::runtime::Handle,
|
||||
tab_id: String,
|
||||
session: Session,
|
||||
events_tx: std::sync::mpsc::Sender<BackendEvent>,
|
||||
) -> tokio::sync::mpsc::UnboundedSender<BackendCommand> {
|
||||
let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel::<BackendCommand>();
|
||||
|
||||
let tab_id_clone = tab_id.clone();
|
||||
let events_tx_clone = events_tx.clone();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let _ = events_tx_clone.send(BackendEvent::Status {
|
||||
tab_id: tab_id_clone.clone(),
|
||||
text: rust_i18n::t!("starting_connection").to_string(),
|
||||
});
|
||||
|
||||
let port_name = session.host;
|
||||
let baud_rate = session.baud_rate;
|
||||
|
||||
tracing::info!(
|
||||
"[serial] opening port {} at baud rate {}",
|
||||
port_name,
|
||||
baud_rate
|
||||
);
|
||||
|
||||
let mut port_result = serialport::new(&port_name, baud_rate)
|
||||
.timeout(std::time::Duration::from_millis(100))
|
||||
.open();
|
||||
|
||||
if port_result.is_err() && baud_rate != 0 {
|
||||
tracing::info!(
|
||||
"[serial] failed to open port with baud rate {}, retrying with 0 (virtual port mode)",
|
||||
baud_rate
|
||||
);
|
||||
port_result = serialport::new(&port_name, 0)
|
||||
.timeout(std::time::Duration::from_millis(100))
|
||||
.open();
|
||||
}
|
||||
|
||||
let mut port = match port_result {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
tracing::error!("[serial] failed to open port {}: {}", port_name, e);
|
||||
let _ = events_tx_clone.send(BackendEvent::Closed {
|
||||
tab_id: tab_id_clone,
|
||||
reason: format!("Failed to open serial port {port_name}: {e}"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut port_write = match port.try_clone() {
|
||||
Ok(pw) => pw,
|
||||
Err(e) => {
|
||||
tracing::error!("[serial] failed to clone port: {}", e);
|
||||
let _ = events_tx_clone.send(BackendEvent::Closed {
|
||||
tab_id: tab_id_clone,
|
||||
reason: format!("Failed to clone serial port: {e}"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Notify connected
|
||||
let _ = events_tx_clone.send(BackendEvent::Connected {
|
||||
tab_id: tab_id_clone.clone(),
|
||||
});
|
||||
|
||||
// Spawn write thread
|
||||
let tab_id_write = tab_id_clone.clone();
|
||||
let events_tx_write = events_tx_clone.clone();
|
||||
std::thread::spawn(move || {
|
||||
while let Some(cmd) = cmd_rx.blocking_recv() {
|
||||
match cmd {
|
||||
BackendCommand::Input(bytes) => {
|
||||
if let Err(e) = port_write.write_all(&bytes) {
|
||||
tracing::error!("[serial] write error: {}", e);
|
||||
let _ = events_tx_write.send(BackendEvent::Closed {
|
||||
tab_id: tab_id_write.clone(),
|
||||
reason: format!("Serial write error: {e}"),
|
||||
});
|
||||
break;
|
||||
}
|
||||
let _ = port_write.flush();
|
||||
}
|
||||
BackendCommand::Close => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Read loop in current thread
|
||||
let mut buf = [0u8; 1024];
|
||||
let mut last_was_cr = false;
|
||||
loop {
|
||||
match port.read(&mut buf) {
|
||||
Ok(n) if n > 0 => {
|
||||
let mut processed = Vec::with_capacity(n * 2);
|
||||
let read_bytes = &buf[..n];
|
||||
for i in 0..n {
|
||||
let b = read_bytes[i];
|
||||
if b == b'\n' {
|
||||
let prev_was_cr = if i > 0 {
|
||||
read_bytes[i - 1] == b'\r'
|
||||
} else {
|
||||
last_was_cr
|
||||
};
|
||||
if !prev_was_cr {
|
||||
processed.push(b'\r');
|
||||
}
|
||||
}
|
||||
processed.push(b);
|
||||
}
|
||||
last_was_cr = read_bytes[n - 1] == b'\r';
|
||||
|
||||
let _ = events_tx_clone.send(BackendEvent::Output {
|
||||
tab_id: tab_id_clone.clone(),
|
||||
bytes: processed,
|
||||
});
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(ref e) if e.kind() == std::io::ErrorKind::TimedOut => {}
|
||||
Err(e) => {
|
||||
tracing::info!("[serial] port read error/closed: {}", e);
|
||||
let _ = events_tx_clone.send(BackendEvent::Closed {
|
||||
tab_id: tab_id_clone,
|
||||
reason: format!("Serial read error: {e}"),
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
cmd_tx
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use portable_pty::{NativePtySystem, PtySize, PtySystem};
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_serial_read_write_simulation() {
|
||||
// 1. Create a PTY pair using portable-pty to simulate a serial device
|
||||
let pty_system = NativePtySystem::default();
|
||||
let pair = pty_system
|
||||
.openpty(PtySize {
|
||||
rows: 24,
|
||||
cols: 80,
|
||||
pixel_width: 0,
|
||||
pixel_height: 0,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
// On macOS/Linux, the slave name path behaves like a TTY device.
|
||||
let fd = pair.master.as_raw_fd().unwrap();
|
||||
let slave_name = unsafe {
|
||||
let ptr = libc::ptsname(fd);
|
||||
assert!(!ptr.is_null());
|
||||
std::ffi::CStr::from_ptr(ptr).to_string_lossy().into_owned()
|
||||
};
|
||||
println!("Simulating serial device on PTY slave path: {}", slave_name);
|
||||
|
||||
// 2. Spawn the serial backend targeting the PTY slave path
|
||||
let (events_tx, events_rx) = std::sync::mpsc::channel();
|
||||
let handle = tokio::runtime::Handle::current();
|
||||
let session = Session::serial(slave_name, 0);
|
||||
let cmd_tx = spawn_serial_client(&handle, "test-tab".to_string(), session, events_tx);
|
||||
|
||||
// Wait for the Status event
|
||||
let status_event = events_rx.recv_timeout(std::time::Duration::from_secs(2));
|
||||
assert!(status_event.is_ok(), "Failed to receive Status event");
|
||||
if let Ok(BackendEvent::Status { tab_id, .. }) = status_event {
|
||||
assert_eq!(tab_id, "test-tab");
|
||||
} else {
|
||||
panic!("Expected Status event, got: {:?}", status_event);
|
||||
}
|
||||
|
||||
// Wait for the Connected event
|
||||
let connected_event = events_rx.recv_timeout(std::time::Duration::from_secs(2));
|
||||
assert!(connected_event.is_ok(), "Failed to receive Connected event");
|
||||
if let Ok(BackendEvent::Connected { tab_id }) = connected_event {
|
||||
assert_eq!(tab_id, "test-tab");
|
||||
} else {
|
||||
panic!("Expected Connected event, got: {:?}", connected_event);
|
||||
}
|
||||
|
||||
// 3. Test Reading: Write to PTY master, verify serial backend outputs it to UI
|
||||
let mut master_writer = pair.master.take_writer().unwrap();
|
||||
master_writer.write_all(b"hello serial simulator").unwrap();
|
||||
master_writer.flush().unwrap();
|
||||
|
||||
let output_event = events_rx.recv_timeout(std::time::Duration::from_secs(2));
|
||||
assert!(output_event.is_ok(), "Failed to receive Output event");
|
||||
if let Ok(BackendEvent::Output { tab_id, bytes }) = output_event {
|
||||
assert_eq!(tab_id, "test-tab");
|
||||
assert_eq!(bytes, b"hello serial simulator");
|
||||
} else {
|
||||
panic!("Expected Output event");
|
||||
}
|
||||
|
||||
// 4. Test Writing: Send BackendCommand::Input to backend, verify PTY master reads it
|
||||
cmd_tx
|
||||
.send(BackendCommand::Input(b"world serial simulator".to_vec()))
|
||||
.unwrap();
|
||||
|
||||
let mut master_reader = pair.master.try_clone_reader().unwrap();
|
||||
let mut read_buf = [0u8; 128];
|
||||
let bytes_read = master_reader.read(&mut read_buf).unwrap();
|
||||
assert_eq!(&read_buf[..bytes_read], b"world serial simulator");
|
||||
|
||||
// 5. Clean up
|
||||
cmd_tx.send(BackendCommand::Close).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,14 @@ pub enum AuthMethod {
|
||||
Config,
|
||||
}
|
||||
|
||||
fn default_protocol() -> String {
|
||||
"ssh".to_string()
|
||||
}
|
||||
|
||||
fn default_baud_rate() -> u32 {
|
||||
115200
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Session {
|
||||
pub id: String,
|
||||
@@ -48,6 +56,10 @@ pub struct Session {
|
||||
pub proxy_user: String,
|
||||
#[serde(default)]
|
||||
pub proxy_password: String,
|
||||
#[serde(default = "default_protocol")]
|
||||
pub protocol: String,
|
||||
#[serde(default = "default_baud_rate")]
|
||||
pub baud_rate: u32,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
@@ -70,6 +82,8 @@ impl Session {
|
||||
proxy_port: None,
|
||||
proxy_user: String::new(),
|
||||
proxy_password: String::new(),
|
||||
protocol: "ssh".to_string(),
|
||||
baud_rate: 115200,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,6 +113,32 @@ impl Session {
|
||||
proxy_port: None,
|
||||
proxy_user: String::new(),
|
||||
proxy_password: String::new(),
|
||||
protocol: "ssh".to_string(),
|
||||
baud_rate: 115200,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn serial(port_name: String, baud_rate: u32) -> Self {
|
||||
let name = format!("serial://{port_name}@{baud_rate}");
|
||||
Self {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
name,
|
||||
host: port_name,
|
||||
port: 0,
|
||||
user: String::new(),
|
||||
auth: AuthMethod::Password,
|
||||
password: String::new(),
|
||||
private_key_path: String::new(),
|
||||
private_key_inline: String::new(),
|
||||
passphrase: String::new(),
|
||||
last_used: None,
|
||||
proxy_type: "none".to_string(),
|
||||
proxy_host: String::new(),
|
||||
proxy_port: None,
|
||||
proxy_user: String::new(),
|
||||
proxy_password: String::new(),
|
||||
protocol: "serial".to_string(),
|
||||
baud_rate,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+182
-29
@@ -56,6 +56,55 @@ impl Ashell {
|
||||
}
|
||||
|
||||
pub(crate) fn connect_ssh(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.session_protocol == "serial" {
|
||||
let session_name = self.session_name_input.read(cx).value().trim().to_string();
|
||||
let port_name = self.host_input.read(cx).value().trim().to_string();
|
||||
let baud_rate = self
|
||||
.baud_rate_input
|
||||
.read(cx)
|
||||
.value()
|
||||
.trim()
|
||||
.parse::<u32>()
|
||||
.unwrap_or(115200);
|
||||
|
||||
if port_name.is_empty() {
|
||||
self.status = "Serial port path is required".into();
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
|
||||
let name = if session_name.is_empty() {
|
||||
port_name.clone()
|
||||
} else {
|
||||
session_name
|
||||
};
|
||||
|
||||
let existing_id = self.editing_session_id.clone();
|
||||
let existing_last_used = existing_id
|
||||
.as_deref()
|
||||
.and_then(|id| self.config.get(id))
|
||||
.and_then(|session| session.last_used.clone());
|
||||
|
||||
let mut session = Session::serial(port_name, baud_rate);
|
||||
session.name = name;
|
||||
if let Some(id) = existing_id {
|
||||
session.id = id;
|
||||
}
|
||||
session.last_used = existing_last_used;
|
||||
|
||||
self.config.upsert(session.clone());
|
||||
if let Err(err) = self.config.save() {
|
||||
tracing::warn!("failed to save config: {err:#}");
|
||||
}
|
||||
|
||||
self.open_serial_session(session, cx);
|
||||
self.editing_session_id = None;
|
||||
self.active_dialog = None;
|
||||
window.close_dialog(cx);
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
|
||||
tracing::info!("[ui] user initiating new ssh connection from form");
|
||||
let session_name = self.session_name_input.read(cx).value().trim().to_string();
|
||||
let host = self.host_input.read(cx).value().trim().to_string();
|
||||
@@ -153,6 +202,7 @@ impl Ashell {
|
||||
self.editing_session_id = None;
|
||||
self.ssh_auth_method = AuthMethod::Password;
|
||||
self.ssh_config_selected = None;
|
||||
self.session_protocol = "ssh".to_string();
|
||||
Self::set_input_value(&self.session_name_input, "", window, cx);
|
||||
Self::set_input_value(&self.host_input, "", window, cx);
|
||||
Self::set_input_value(&self.port_input, "22", window, cx);
|
||||
@@ -161,6 +211,7 @@ impl Ashell {
|
||||
Self::set_input_value(&self.key_path_input, "", window, cx);
|
||||
Self::set_input_value(&self.key_inline_input, "", window, cx);
|
||||
Self::set_input_value(&self.passphrase_input, "", window, cx);
|
||||
Self::set_input_value(&self.baud_rate_input, "115200", window, cx);
|
||||
self.ssh_proxy_type = "none".to_string();
|
||||
Self::set_input_value(&self.proxy_host_input, "", window, cx);
|
||||
Self::set_input_value(&self.proxy_port_input, "", window, cx);
|
||||
@@ -176,6 +227,7 @@ impl Ashell {
|
||||
) {
|
||||
self.editing_session_id = Some(session.id.clone());
|
||||
self.ssh_auth_method = session.auth;
|
||||
self.session_protocol = session.protocol.clone();
|
||||
Self::set_input_value(&self.session_name_input, session.name.clone(), window, cx);
|
||||
Self::set_input_value(&self.host_input, session.host.clone(), window, cx);
|
||||
Self::set_input_value(&self.port_input, session.port.to_string(), window, cx);
|
||||
@@ -199,6 +251,12 @@ impl Ashell {
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
Self::set_input_value(
|
||||
&self.baud_rate_input,
|
||||
session.baud_rate.to_string(),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
self.ssh_proxy_type = if session.proxy_type.is_empty() {
|
||||
"none".to_string()
|
||||
} else {
|
||||
@@ -389,6 +447,11 @@ impl Ashell {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn set_session_protocol(&mut self, protocol: String, cx: &mut Context<Self>) {
|
||||
self.session_protocol = protocol;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn refresh_ssh_config(&mut self) {
|
||||
self.ssh_config_entries =
|
||||
crate::session::ssh_config::parse_ssh_config().unwrap_or_default();
|
||||
@@ -448,7 +511,11 @@ impl Ashell {
|
||||
cx.notify();
|
||||
return;
|
||||
};
|
||||
self.open_ssh_session(session, cx);
|
||||
if session.protocol == "serial" {
|
||||
self.open_serial_session(session, cx);
|
||||
} else {
|
||||
self.open_ssh_session(session, cx);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn selector_entries(&self) -> Vec<SelectorEntry> {
|
||||
@@ -613,6 +680,57 @@ impl Ashell {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn open_serial_session(&mut self, session: Session, cx: &mut Context<Self>) {
|
||||
tracing::info!(
|
||||
"[session] opening serial tab for session '{}' ({})",
|
||||
session.name,
|
||||
session.host
|
||||
);
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let backend = crate::backend::serial::spawn_serial_client(
|
||||
self.runtime.handle(),
|
||||
id.clone(),
|
||||
session.clone(),
|
||||
self.events_tx.clone(),
|
||||
);
|
||||
self.tabs.push(TerminalTab::new_serial(
|
||||
id.clone(),
|
||||
&session,
|
||||
crate::terminal::BackendTx::Serial(backend),
|
||||
self.events_tx.clone(),
|
||||
));
|
||||
self.active_tab = Some(id.clone());
|
||||
self.connection_progress = Some(crate::app::ConnectionProgress {
|
||||
tab_id: id.clone(),
|
||||
title: rust_i18n::t!("connecting").into(),
|
||||
lines: vec![rust_i18n::t!("starting_connection").into()],
|
||||
failed: false,
|
||||
});
|
||||
self.pane_root = PaneLayout::Single(id.clone());
|
||||
self.focused_pane_path = vec![];
|
||||
let group_id = Uuid::new_v4().to_string();
|
||||
self.tab_groups.push(TabGroup {
|
||||
id: group_id.clone(),
|
||||
title: session.name.clone(),
|
||||
pane_root: PaneLayout::Single(id.clone()),
|
||||
sftp: None,
|
||||
});
|
||||
self.active_group = Some(group_id.clone());
|
||||
self.tabs_scroll_handle.scroll_to_item(self.tabs.len() - 1);
|
||||
if let Some(session_id) = self.active_session_id() {
|
||||
if let Some(index) = self
|
||||
.config
|
||||
.sessions()
|
||||
.iter()
|
||||
.position(|s| s.id == session_id)
|
||||
{
|
||||
self.saved_scroll_handle.scroll_to_item(index);
|
||||
}
|
||||
}
|
||||
self.status = "serial tab opened".into();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn remove_saved_session(&mut self, session_id: String, cx: &mut Context<Self>) {
|
||||
self.config.remove(&session_id);
|
||||
if let Err(err) = self.config.save() {
|
||||
@@ -646,20 +764,30 @@ impl Ashell {
|
||||
self.tabs[ix].send_backend(BackendCommand::Close);
|
||||
|
||||
if let Some(session) = session {
|
||||
// SSH tab: spawn new SSH connection
|
||||
let backend = ssh::spawn_ssh_terminal(
|
||||
self.runtime.handle(),
|
||||
tab_id.to_string(),
|
||||
session.clone(),
|
||||
cols,
|
||||
rows,
|
||||
self.events_tx.clone(),
|
||||
);
|
||||
|
||||
// Swap the backend — the Term's internal listener shares the
|
||||
// same Arc<Mutex<BackendTx>>, so user input is automatically
|
||||
// routed to the new backend. Terminal history is preserved.
|
||||
self.tabs[ix].set_backend(backend);
|
||||
let tab_kind = self.tabs[ix].kind;
|
||||
match tab_kind {
|
||||
crate::terminal::TabKind::Serial => {
|
||||
let backend = crate::backend::serial::spawn_serial_client(
|
||||
self.runtime.handle(),
|
||||
tab_id.to_string(),
|
||||
session.clone(),
|
||||
self.events_tx.clone(),
|
||||
);
|
||||
self.tabs[ix].set_backend(crate::terminal::BackendTx::Serial(backend));
|
||||
}
|
||||
crate::terminal::TabKind::Ssh => {
|
||||
let backend = ssh::spawn_ssh_terminal(
|
||||
self.runtime.handle(),
|
||||
tab_id.to_string(),
|
||||
session.clone(),
|
||||
cols,
|
||||
rows,
|
||||
self.events_tx.clone(),
|
||||
);
|
||||
self.tabs[ix].set_backend(backend);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
self.tabs[ix].connected = false;
|
||||
self.tabs[ix].status = "connecting".into();
|
||||
self.tabs[ix].disconnected_reason = None;
|
||||
@@ -680,20 +808,22 @@ impl Ashell {
|
||||
.and_then(|t| t.session.clone());
|
||||
|
||||
if let Some(session) = group_session {
|
||||
if let Some(old_handle) = self.sftp_handles.remove(&group_id) {
|
||||
old_handle.close();
|
||||
}
|
||||
let sftp_handle = crate::sftp::spawn_sftp(
|
||||
self.runtime.handle(),
|
||||
group_id.clone(),
|
||||
session,
|
||||
self.events_tx.clone(),
|
||||
);
|
||||
self.sftp_handles.insert(group_id.clone(), sftp_handle);
|
||||
if session.protocol != "serial" {
|
||||
if let Some(old_handle) = self.sftp_handles.remove(&group_id) {
|
||||
old_handle.close();
|
||||
}
|
||||
let sftp_handle = crate::sftp::spawn_sftp(
|
||||
self.runtime.handle(),
|
||||
group_id.clone(),
|
||||
session,
|
||||
self.events_tx.clone(),
|
||||
);
|
||||
self.sftp_handles.insert(group_id.clone(), sftp_handle);
|
||||
|
||||
if let Some(group) = self.tab_groups.iter_mut().find(|g| g.id == group_id) {
|
||||
if let Some(sftp) = group.sftp.as_mut() {
|
||||
sftp.status = rust_i18n::t!("sftp_connecting").to_string();
|
||||
if let Some(group) = self.tab_groups.iter_mut().find(|g| g.id == group_id) {
|
||||
if let Some(sftp) = group.sftp.as_mut() {
|
||||
sftp.status = rust_i18n::t!("sftp_connecting").to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1046,7 +1176,11 @@ impl Ashell {
|
||||
}
|
||||
|
||||
pub(crate) fn session_detail(&self, session: &Session) -> String {
|
||||
format!("{}@{}:{}", session.user, session.host, session.port)
|
||||
if session.protocol == "serial" {
|
||||
format!("Serial: {}@{}", session.host, session.baud_rate)
|
||||
} else {
|
||||
format!("{}@{}:{}", session.user, session.host, session.port)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn split_current_pane(&mut self, direction: &str, cx: &mut Context<Self>) {
|
||||
@@ -1112,6 +1246,25 @@ impl Ashell {
|
||||
self.sftp_handles.insert(new_id.clone(), sftp_handle);
|
||||
TerminalTab::new_ssh(new_id.clone(), &session, backend, self.events_tx.clone())
|
||||
}
|
||||
TabKind::Serial => {
|
||||
let Some(session) = current_tab.session.clone() else {
|
||||
self.status = "cannot split: no session info".into();
|
||||
cx.notify();
|
||||
return;
|
||||
};
|
||||
let backend = crate::backend::serial::spawn_serial_client(
|
||||
self.runtime.handle(),
|
||||
new_id.clone(),
|
||||
session.clone(),
|
||||
self.events_tx.clone(),
|
||||
);
|
||||
TerminalTab::new_serial(
|
||||
new_id.clone(),
|
||||
&session,
|
||||
crate::terminal::BackendTx::Serial(backend),
|
||||
self.events_tx.clone(),
|
||||
)
|
||||
}
|
||||
};
|
||||
tab.resize(DEFAULT_COLS, DEFAULT_ROWS);
|
||||
// Do NOT add to tab_groups — pane stays within the existing group
|
||||
|
||||
@@ -23,6 +23,7 @@ use crate::system::SystemSnapshot;
|
||||
pub enum TabKind {
|
||||
Local,
|
||||
Ssh,
|
||||
Serial,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -98,6 +99,7 @@ pub enum BackendEvent {
|
||||
pub enum BackendTx {
|
||||
Local(Sender<BackendCommand>),
|
||||
Ssh(tokio::sync::mpsc::UnboundedSender<BackendCommand>),
|
||||
Serial(tokio::sync::mpsc::UnboundedSender<BackendCommand>),
|
||||
}
|
||||
|
||||
impl BackendTx {
|
||||
@@ -109,6 +111,9 @@ impl BackendTx {
|
||||
Self::Ssh(tx) => {
|
||||
let _ = tx.send(command);
|
||||
}
|
||||
Self::Serial(tx) => {
|
||||
let _ = tx.send(command);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -228,6 +233,25 @@ impl TerminalTab {
|
||||
tab
|
||||
}
|
||||
|
||||
pub fn new_serial(
|
||||
id: String,
|
||||
session: &Session,
|
||||
backend: BackendTx,
|
||||
events: std::sync::mpsc::Sender<BackendEvent>,
|
||||
) -> Self {
|
||||
let mut tab = Self::new(
|
||||
id,
|
||||
session.name.clone(),
|
||||
TabKind::Serial,
|
||||
format!("connecting serial://{}@{}", session.host, session.baud_rate),
|
||||
backend,
|
||||
events,
|
||||
);
|
||||
tab.session = Some(session.clone());
|
||||
tab.connected = false;
|
||||
tab
|
||||
}
|
||||
|
||||
fn new(
|
||||
id: String,
|
||||
title: String,
|
||||
|
||||
Reference in New Issue
Block a user