mirror of
https://github.com/rust-kotlin/ashell.git
synced 2026-10-03 16:00:18 +00:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44348d4d75 | ||
|
|
9163d3a970 | ||
|
|
d85c1f4f55 | ||
|
|
61e7fb7b0d | ||
|
|
1bb922f955 | ||
|
|
a06d1c668d | ||
|
|
3b9ee02cce | ||
|
|
e7ca7bdc23 |
Generated
+95
-4
@@ -315,7 +315,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ashell"
|
||||
version = "0.4.11"
|
||||
version = "0.4.12"
|
||||
dependencies = [
|
||||
"alacritty_terminal",
|
||||
"anyhow",
|
||||
@@ -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",
|
||||
@@ -361,6 +363,7 @@ dependencies = [
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
"walkdir",
|
||||
"winreg 0.52.0",
|
||||
"winres",
|
||||
"zip",
|
||||
]
|
||||
@@ -2760,7 +2763,7 @@ dependencies = [
|
||||
"itertools 0.14.0",
|
||||
"log",
|
||||
"lyon",
|
||||
"mach2",
|
||||
"mach2 0.5.0",
|
||||
"media",
|
||||
"metal",
|
||||
"num_cpus",
|
||||
@@ -2950,7 +2953,7 @@ dependencies = [
|
||||
"itertools 0.14.0",
|
||||
"libc",
|
||||
"log",
|
||||
"mach2",
|
||||
"mach2 0.5.0",
|
||||
"media",
|
||||
"metal",
|
||||
"objc",
|
||||
@@ -3683,6 +3686,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 +3965,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 +4132,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 +4439,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 +6950,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 +8205,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 +8391,7 @@ dependencies = [
|
||||
"itertools 0.14.0",
|
||||
"libc",
|
||||
"log",
|
||||
"mach2",
|
||||
"mach2 0.5.0",
|
||||
"nix 0.29.0",
|
||||
"percent-encoding",
|
||||
"regex",
|
||||
@@ -9563,6 +9644,16 @@ dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winreg"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winreg"
|
||||
version = "0.55.0"
|
||||
|
||||
+8
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ashell"
|
||||
version = "0.4.11"
|
||||
version = "0.4.12"
|
||||
edition = "2024"
|
||||
authors = ["ashell contributors"]
|
||||
description = "A GPUI Component based SSH and local terminal client"
|
||||
@@ -50,10 +50,17 @@ 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"
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
winreg = "0.52"
|
||||
|
||||
[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}"
|
||||
@@ -215,3 +220,7 @@ global_proxy_port: "Proxy Port"
|
||||
global_proxy_user: "Proxy Username"
|
||||
global_proxy_password: "Proxy Password"
|
||||
save_proxy: "Save Proxy"
|
||||
settings_config_file: "Config File"
|
||||
settings_backup_local_desc: "Export or import your unencrypted configuration file locally."
|
||||
backup_export: "Export Config"
|
||||
backup_import: "Import Config"
|
||||
|
||||
@@ -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}"
|
||||
@@ -217,3 +222,7 @@ global_proxy_port: "代理端口"
|
||||
global_proxy_user: "代理用户名"
|
||||
global_proxy_password: "代理密码"
|
||||
save_proxy: "保存代理设置"
|
||||
settings_config_file: "配置文件"
|
||||
settings_backup_local_desc: "在本地导出或导入你未加密的配置文件。"
|
||||
backup_export: "导出配置"
|
||||
backup_import: "导入配置"
|
||||
|
||||
@@ -126,4 +126,73 @@ impl Ashell {
|
||||
let _ = events.send(BackendEvent::SyncFinished(result));
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn export_local_config(
|
||||
&mut self,
|
||||
window: &mut gpui::Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let local_config = self.config.cache.clone();
|
||||
let file_dialog = rfd::AsyncFileDialog::new()
|
||||
.set_file_name("ashell-config.json")
|
||||
.add_filter("JSON", &["json"])
|
||||
.save_file();
|
||||
|
||||
cx.spawn_in(window, async move |_this, cx| {
|
||||
if let Some(file_handle) = file_dialog.await {
|
||||
let path = file_handle.path().to_path_buf();
|
||||
if let Ok(json_str) = serde_json::to_string_pretty(&local_config) {
|
||||
let _ = cx
|
||||
.background_executor()
|
||||
.spawn(async move {
|
||||
if let Err(err) = std::fs::write(path, json_str) {
|
||||
tracing::error!("failed to export local config: {err:#}");
|
||||
}
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
Ok::<(), anyhow::Error>(())
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub(crate) fn import_local_config(
|
||||
&mut self,
|
||||
window: &mut gpui::Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let file_dialog = rfd::AsyncFileDialog::new()
|
||||
.add_filter("JSON", &["json"])
|
||||
.pick_file();
|
||||
|
||||
cx.spawn_in(window, async move |this, mut cx| {
|
||||
if let Some(file_handle) = file_dialog.await {
|
||||
let path = file_handle.path().to_path_buf();
|
||||
let read_result = cx
|
||||
.background_executor()
|
||||
.spawn(async move { std::fs::read_to_string(path) })
|
||||
.await;
|
||||
|
||||
if let Ok(json_str) = read_result {
|
||||
if let Ok(config_file) =
|
||||
serde_json::from_str::<crate::session::config::ConfigFile>(&json_str)
|
||||
{
|
||||
let _ = gpui::AsyncWindowContext::update(&mut cx, |window, cx| {
|
||||
let _ = this.update(cx, |this, cx| {
|
||||
this.config.cache = config_file;
|
||||
if let Err(err) = this.config.save() {
|
||||
tracing::error!("failed to save imported config: {err:#}");
|
||||
} else {
|
||||
this.apply_loaded_config(window, cx);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok::<(), anyhow::Error>(())
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
|
||||
+370
-250
@@ -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()
|
||||
@@ -1328,6 +1400,21 @@ impl Ashell {
|
||||
return;
|
||||
}
|
||||
|
||||
if ev.keystroke.key == "backspace"
|
||||
&& !ev.keystroke.modifiers.control
|
||||
&& !ev.keystroke.modifiers.alt
|
||||
&& !ev.keystroke.modifiers.shift
|
||||
&& !ev.keystroke.modifiers.platform
|
||||
&& !ev.keystroke.modifiers.function
|
||||
{
|
||||
this.recording_action = None;
|
||||
this.keybind_error = None;
|
||||
this.config.set_key_binding(&action, "none");
|
||||
this.save_preferences_background();
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(new_key) = crate::app::keybinding_recorder::normalize_recorded_keystroke(ev) else {
|
||||
return;
|
||||
};
|
||||
@@ -1353,9 +1440,7 @@ impl Ashell {
|
||||
this.recording_action = None;
|
||||
this.keybind_error = None;
|
||||
this.config.set_key_binding(&action, &new_key);
|
||||
if let Err(err) = this.config.save() {
|
||||
tracing::error!("failed to save key binding: {err:#}");
|
||||
}
|
||||
this.save_preferences_background();
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
@@ -1528,7 +1613,7 @@ impl Ashell {
|
||||
.checked(current_style == crate::session::config::TitleBarStyle::Native)
|
||||
.on_click(window.listener_for(&view, |this, _, _, cx| {
|
||||
this.config.set_title_bar_style(crate::session::config::TitleBarStyle::Native);
|
||||
let _ = this.config.save();
|
||||
this.save_preferences_background();
|
||||
cx.notify();
|
||||
}))
|
||||
)
|
||||
@@ -1537,7 +1622,7 @@ impl Ashell {
|
||||
.checked(current_style == crate::session::config::TitleBarStyle::Integrated)
|
||||
.on_click(window.listener_for(&view, |this, _, _, cx| {
|
||||
this.config.set_title_bar_style(crate::session::config::TitleBarStyle::Integrated);
|
||||
let _ = this.config.save();
|
||||
this.save_preferences_background();
|
||||
cx.notify();
|
||||
}))
|
||||
);
|
||||
@@ -1772,7 +1857,7 @@ impl Ashell {
|
||||
.checked(view.read(cx).config.right_click_copy_paste())
|
||||
.on_click(window.listener_for(&view, |this, checked, _, cx| {
|
||||
this.config.set_right_click_copy_paste(*checked);
|
||||
let _ = this.config.save();
|
||||
this.save_preferences_background();
|
||||
cx.notify();
|
||||
}))
|
||||
.into_any_element()
|
||||
@@ -1791,7 +1876,7 @@ impl Ashell {
|
||||
.checked(view.read(cx).config.keyword_highlight())
|
||||
.on_click(window.listener_for(&view, |this, checked, _, cx| {
|
||||
this.config.set_keyword_highlight(*checked);
|
||||
let _ = this.config.save();
|
||||
this.save_preferences_background();
|
||||
cx.notify();
|
||||
}))
|
||||
.into_any_element()
|
||||
@@ -1810,7 +1895,7 @@ impl Ashell {
|
||||
.checked(view.read(cx).config.lock_layout())
|
||||
.on_click(window.listener_for(&view, |this, checked, _, cx| {
|
||||
this.config.set_lock_layout(*checked);
|
||||
let _ = this.config.save();
|
||||
this.save_preferences_background();
|
||||
cx.notify();
|
||||
}))
|
||||
.into_any_element()
|
||||
@@ -1847,7 +1932,7 @@ impl Ashell {
|
||||
.checked(pos == "Bottom")
|
||||
.on_click(window.listener_for(&view, |this, _, _window, cx| {
|
||||
this.config.set_monitoring_position("Bottom");
|
||||
let _ = this.config.save();
|
||||
this.save_preferences_background();
|
||||
cx.notify();
|
||||
}))
|
||||
)
|
||||
@@ -1856,7 +1941,7 @@ impl Ashell {
|
||||
.checked(pos == "Sidebar")
|
||||
.on_click(window.listener_for(&view, |this, _, _window, cx| {
|
||||
this.config.set_monitoring_position("Sidebar");
|
||||
let _ = this.config.save();
|
||||
this.save_preferences_background();
|
||||
cx.notify();
|
||||
}))
|
||||
)
|
||||
@@ -1865,7 +1950,7 @@ impl Ashell {
|
||||
.checked(pos == "Hidden")
|
||||
.on_click(window.listener_for(&view, |this, _, _window, cx| {
|
||||
this.config.set_monitoring_position("Hidden");
|
||||
let _ = this.config.save();
|
||||
this.save_preferences_background();
|
||||
cx.notify();
|
||||
}))
|
||||
);
|
||||
@@ -1950,6 +2035,41 @@ impl Ashell {
|
||||
)
|
||||
)
|
||||
)
|
||||
.page(
|
||||
SettingPage::new(t!("settings_config_file").to_string())
|
||||
.icon(IconName::File)
|
||||
.group(
|
||||
SettingGroup::new()
|
||||
.title(t!("settings_config_file").to_string())
|
||||
.item(SettingItem::new(
|
||||
t!("settings_backup_local_desc").to_string(),
|
||||
SettingField::render({
|
||||
let view = view.clone();
|
||||
move |_, window, _cx| {
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
Button::new("backup-export")
|
||||
.small()
|
||||
.label(t!("backup_export").to_string())
|
||||
.on_click(window.listener_for(&view, |this, _, window, cx| {
|
||||
this.export_local_config(window, cx);
|
||||
}))
|
||||
)
|
||||
.child(
|
||||
Button::new("backup-import")
|
||||
.small()
|
||||
.label(t!("backup_import").to_string())
|
||||
.on_click(window.listener_for(&view, |this, _, window, cx| {
|
||||
this.import_local_config(window, cx);
|
||||
}))
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
})
|
||||
))
|
||||
)
|
||||
)
|
||||
.page(
|
||||
SettingPage::new(t!("settings_sync").to_string())
|
||||
.icon(IconName::Globe)
|
||||
@@ -2037,7 +2157,7 @@ impl Ashell {
|
||||
.checked(view.read(cx).config.use_proxy())
|
||||
.on_click(window.listener_for(&view, |this, checked, _, cx| {
|
||||
this.config.set_use_proxy(*checked);
|
||||
let _ = this.config.save();
|
||||
this.save_preferences_background();
|
||||
cx.notify();
|
||||
}))
|
||||
.into_any_element()
|
||||
@@ -2056,7 +2176,7 @@ impl Ashell {
|
||||
.checked(view.read(cx).config.read_env_proxy())
|
||||
.on_click(window.listener_for(&view, |this, checked, _, cx| {
|
||||
this.config.set_read_env_proxy(*checked);
|
||||
let _ = this.config.save();
|
||||
this.save_preferences_background();
|
||||
cx.notify();
|
||||
}))
|
||||
.into_any_element()
|
||||
@@ -2125,7 +2245,7 @@ impl Ashell {
|
||||
this.config.set_global_proxy_port(port);
|
||||
this.config.set_global_proxy_user(user);
|
||||
this.config.set_global_proxy_password(password);
|
||||
let _ = this.config.save();
|
||||
this.save_preferences_background();
|
||||
cx.notify();
|
||||
}))
|
||||
)
|
||||
|
||||
@@ -218,7 +218,7 @@ pub(crate) fn unbind_all_workspace_keys(cx: &mut App, config: &ConfigStore) {
|
||||
|
||||
// Unbind both the default and configured keystroke
|
||||
bindings.push(KeyBinding::new(&default, Unbind(action_name.into()), None));
|
||||
if configured != default {
|
||||
if configured != default && configured != "none" && !configured.is_empty() {
|
||||
bindings.push(KeyBinding::new(
|
||||
&configured,
|
||||
Unbind(action_name.into()),
|
||||
@@ -262,7 +262,7 @@ pub(crate) fn find_conflict(
|
||||
continue;
|
||||
}
|
||||
let existing = configured_keystroke(config, action.id).unwrap_or_default();
|
||||
if !existing.is_empty() && existing == new_keystroke {
|
||||
if !existing.is_empty() && existing != "none" && existing == new_keystroke {
|
||||
return Some((action.id.to_string(), t!(action.label_key).to_string()));
|
||||
}
|
||||
}
|
||||
@@ -282,7 +282,9 @@ fn bind_workspace_actions(cx: &mut App, config: &ConfigStore) {
|
||||
bindings.push(KeyBinding::new(&default, Unbind(action_name.into()), None));
|
||||
}
|
||||
|
||||
bindings.push(KeyBinding::new(&configured, $action, None));
|
||||
if configured != "none" && !configured.is_empty() {
|
||||
bindings.push(KeyBinding::new(&configured, $action, None));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+142
-33
@@ -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>,
|
||||
@@ -325,6 +327,8 @@ pub(crate) struct Ashell {
|
||||
pub(crate) hovered_url: Option<HoveredUrl>,
|
||||
pub(crate) cmd_ctrl_pressed: bool,
|
||||
pub(crate) _subscriptions: Vec<gpui::Subscription>,
|
||||
pub(crate) save_lock: std::sync::Arc<tokio::sync::Mutex<()>>,
|
||||
pub(crate) save_latest_seq: std::sync::Arc<std::sync::atomic::AtomicU64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
@@ -401,6 +405,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 +515,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 +594,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,
|
||||
@@ -712,6 +720,8 @@ impl Ashell {
|
||||
hovered_url: None,
|
||||
cmd_ctrl_pressed: false,
|
||||
_subscriptions,
|
||||
save_lock: std::sync::Arc::new(tokio::sync::Mutex::new(())),
|
||||
save_latest_seq: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)),
|
||||
};
|
||||
|
||||
this.apply_theme_preferences(window, cx);
|
||||
@@ -720,6 +730,74 @@ impl Ashell {
|
||||
this
|
||||
}
|
||||
|
||||
pub(crate) fn apply_loaded_config(
|
||||
&mut self,
|
||||
window: &mut gpui::Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.follow_system_theme = self.config.follow_system_theme();
|
||||
self.theme_mode = match self.config.theme_mode() {
|
||||
"light" => ThemeMode::Light,
|
||||
"dark" => ThemeMode::Dark,
|
||||
_ => ThemeMode::Dark,
|
||||
};
|
||||
self.light_theme_name = self.config.light_theme_name().to_string().into();
|
||||
self.dark_theme_name = self.config.dark_theme_name().to_string().into();
|
||||
self.ui_font_size = self.config.ui_font_size();
|
||||
self.terminal_font_size = self.config.terminal_font_size();
|
||||
self.cursor_style = self.config.cursor_style();
|
||||
self.ui_font_family = self.config.ui_font_family().to_string().into();
|
||||
self.terminal_font_family = self.config.terminal_font_family().to_string().into();
|
||||
self.show_hidden_files = self.config.show_hidden_files();
|
||||
self.sftp_panel_minimized = self.config.sftp_panel_minimized();
|
||||
self.sidebar_collapsed = self.config.sidebar_collapsed();
|
||||
self.active_title_bar_style = self.config.title_bar_style();
|
||||
|
||||
// Apply theme preferences
|
||||
self.apply_theme_preferences(window, cx);
|
||||
|
||||
// Update inputs
|
||||
Self::set_input_value(
|
||||
&self.sync_endpoint_input,
|
||||
self.config.sync_endpoint().to_string(),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
Self::set_input_value(
|
||||
&self.sync_username_input,
|
||||
self.config.sync_username().to_string(),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
Self::set_input_value(
|
||||
&self.sync_s3_endpoint_input,
|
||||
self.config.sync_s3_endpoint().to_string(),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
Self::set_input_value(
|
||||
&self.sync_s3_region_input,
|
||||
self.config.sync_s3_region().to_string(),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
Self::set_input_value(
|
||||
&self.sync_s3_bucket_input,
|
||||
self.config.sync_s3_bucket().to_string(),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
Self::set_input_value(
|
||||
&self.sync_s3_object_key_input,
|
||||
self.config.sync_s3_object_key().to_string(),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
|
||||
// Notify
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn on_input_event(
|
||||
&mut self,
|
||||
input: &Entity<InputState>,
|
||||
@@ -778,9 +856,29 @@ impl Ashell {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn save_preferences_background(&mut self) {
|
||||
let local_config = self.config.cache.clone();
|
||||
let config_store = self.config.clone();
|
||||
let latest_seq = self.save_latest_seq.clone();
|
||||
let current_seq = latest_seq.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1;
|
||||
let save_lock = self.save_lock.clone();
|
||||
|
||||
self.runtime.spawn(async move {
|
||||
let _guard = save_lock.lock().await;
|
||||
if current_seq < latest_seq.load(std::sync::atomic::Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
let _ = tokio::task::spawn_blocking(move || {
|
||||
if let Err(err) = config_store.save_merged_preferences(local_config) {
|
||||
tracing::error!("failed to save merged preferences in background: {err:#}");
|
||||
}
|
||||
})
|
||||
.await;
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn start_event_pump(&self, cx: &mut Context<Self>) {
|
||||
cx.spawn(async move |this, cx| {
|
||||
let mut idle_frames = 0u32;
|
||||
let mut last_blink_time = std::time::Instant::now();
|
||||
loop {
|
||||
cx.background_executor()
|
||||
@@ -802,16 +900,9 @@ impl Ashell {
|
||||
>= std::time::Duration::from_millis(600);
|
||||
if changed || system_sampled || blink_due {
|
||||
cx.notify();
|
||||
idle_frames = 0;
|
||||
if blink_due {
|
||||
last_blink_time = now;
|
||||
}
|
||||
} else {
|
||||
idle_frames += 1;
|
||||
if idle_frames >= 60 {
|
||||
cx.notify();
|
||||
idle_frames = 0;
|
||||
}
|
||||
}
|
||||
})
|
||||
.is_err()
|
||||
@@ -1145,7 +1236,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 +1245,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 +1295,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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1308,8 +1416,9 @@ impl Ashell {
|
||||
};
|
||||
let size = bounds.size;
|
||||
if size.width.as_f32() > 400.0 && size.height.as_f32() > 300.0 {
|
||||
tracing::info!("[ui] saving layout state...");
|
||||
let mut config = ConfigStore::load().unwrap_or_else(|_| ConfigStore::in_memory());
|
||||
self.save_latest_seq
|
||||
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
let mut config = self.config.clone();
|
||||
let saved_bounds = match current_bounds {
|
||||
gpui::WindowBounds::Fullscreen(b) => {
|
||||
crate::session::config::SavedWindowBounds::Fullscreen {
|
||||
|
||||
+2
-6
@@ -126,9 +126,7 @@ impl Ashell {
|
||||
}
|
||||
rust_i18n::set_locale(&active_locale);
|
||||
gpui_component::set_locale(&active_locale);
|
||||
if let Err(err) = self.config.save() {
|
||||
tracing::warn!("failed to save language preferences: {err:#}");
|
||||
}
|
||||
self.save_preferences_background();
|
||||
window.refresh();
|
||||
cx.notify();
|
||||
}
|
||||
@@ -168,8 +166,6 @@ impl Ashell {
|
||||
self.light_theme_name.to_string(),
|
||||
self.dark_theme_name.to_string(),
|
||||
);
|
||||
if let Err(err) = self.config.save() {
|
||||
tracing::warn!("failed to save theme preferences: {err:#}");
|
||||
}
|
||||
self.save_preferences_background();
|
||||
}
|
||||
}
|
||||
|
||||
+16
-9
@@ -111,7 +111,7 @@ impl Ashell {
|
||||
}
|
||||
self.config
|
||||
.set_sftp_panel_minimized(self.sftp_panel_minimized);
|
||||
let _ = self.config.save();
|
||||
self.save_preferences_background();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
@@ -293,7 +293,7 @@ impl Ashell {
|
||||
.on_click(cx.listener(|this, checked, _, cx| {
|
||||
this.show_hidden_files = *checked;
|
||||
this.config.set_show_hidden_files(*checked);
|
||||
let _ = this.config.save();
|
||||
this.save_preferences_background();
|
||||
cx.notify();
|
||||
})),
|
||||
)
|
||||
@@ -1593,7 +1593,7 @@ impl Ashell {
|
||||
.on_click(cx.listener(|this, _, _, cx| {
|
||||
this.sidebar_collapsed = true;
|
||||
this.config.set_sidebar_collapsed(true);
|
||||
let _ = this.config.save();
|
||||
this.save_preferences_background();
|
||||
cx.notify();
|
||||
})),
|
||||
)
|
||||
@@ -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()
|
||||
@@ -1826,7 +1833,7 @@ impl Ashell {
|
||||
.on_click(cx.listener(|this, _, _, cx| {
|
||||
this.sidebar_collapsed = false;
|
||||
this.config.set_sidebar_collapsed(false);
|
||||
let _ = this.config.save();
|
||||
this.save_preferences_background();
|
||||
cx.notify();
|
||||
})),
|
||||
),
|
||||
@@ -2790,16 +2797,16 @@ impl Render for Ashell {
|
||||
.on_action(cx.listener(|this, _: &crate::ToggleSidebar, _, cx| {
|
||||
this.sidebar_collapsed = !this.sidebar_collapsed;
|
||||
this.config.set_sidebar_collapsed(this.sidebar_collapsed);
|
||||
let _ = this.config.save();
|
||||
this.save_preferences_background();
|
||||
cx.notify();
|
||||
}))
|
||||
.on_action(cx.listener(|this, _: &crate::ToggleSftpZoom, window, cx| {
|
||||
this.toggle_sftp_minimized(window, cx);
|
||||
}))
|
||||
.on_action(cx.listener(|this, _: &crate::FocusPaneLeft, _, _| this.focus_adjacent_pane("left")))
|
||||
.on_action(cx.listener(|this, _: &crate::FocusPaneRight, _, _| this.focus_adjacent_pane("right")))
|
||||
.on_action(cx.listener(|this, _: &crate::FocusPaneUp, _, _| this.focus_adjacent_pane("up")))
|
||||
.on_action(cx.listener(|this, _: &crate::FocusPaneDown, _, _| this.focus_adjacent_pane("down")))
|
||||
.on_action(cx.listener(|this, _: &crate::FocusPaneLeft, _, cx| this.focus_adjacent_pane("left", cx)))
|
||||
.on_action(cx.listener(|this, _: &crate::FocusPaneRight, _, cx| this.focus_adjacent_pane("right", cx)))
|
||||
.on_action(cx.listener(|this, _: &crate::FocusPaneUp, _, cx| this.focus_adjacent_pane("up", cx)))
|
||||
.on_action(cx.listener(|this, _: &crate::FocusPaneDown, _, cx| this.focus_adjacent_pane("down", cx)))
|
||||
.on_action(cx.listener(|this, _: &crate::SplitPaneLeft, _, cx| this.split_current_pane("left", cx)))
|
||||
.on_action(cx.listener(|this, _: &crate::SplitPaneRight, _, cx| this.split_current_pane("right", cx)))
|
||||
.on_action(cx.listener(|this, _: &crate::SplitPaneUp, _, cx| this.split_current_pane("up", cx)))
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -225,9 +225,9 @@ async fn connect_and_authenticate(
|
||||
events: &std::sync::mpsc::Sender<BackendEvent>,
|
||||
) -> Result<russh::client::Handle<ClientHandler>> {
|
||||
let config = Arc::new(client::Config {
|
||||
inactivity_timeout: Some(std::time::Duration::from_secs(600)),
|
||||
keepalive_interval: Some(std::time::Duration::from_secs(3)),
|
||||
keepalive_max: 2,
|
||||
inactivity_timeout: None,
|
||||
keepalive_interval: Some(std::time::Duration::from_secs(5)),
|
||||
keepalive_max: 3,
|
||||
..Default::default()
|
||||
});
|
||||
let addr = format!("{}:{}", session.host, session.port);
|
||||
|
||||
+213
-72
@@ -1,4 +1,4 @@
|
||||
use std::{fs, path::PathBuf};
|
||||
use std::{fs, path::PathBuf, sync::OnceLock};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use argon2::Argon2;
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -324,9 +364,10 @@ impl Default for ConfigFile {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ConfigStore {
|
||||
path: PathBuf,
|
||||
cache: ConfigFile,
|
||||
pub(crate) path: PathBuf,
|
||||
pub(crate) cache: ConfigFile,
|
||||
}
|
||||
|
||||
impl ConfigStore {
|
||||
@@ -788,6 +829,65 @@ impl ConfigStore {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn save_merged_preferences(&self, local_config: ConfigFile) -> Result<()> {
|
||||
if self.path.as_os_str().is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let hardware_uuid = get_hardware_uuid();
|
||||
|
||||
let mut disk_config = if self.path.exists() {
|
||||
if let Ok(raw_bytes) = fs::read(&self.path) {
|
||||
match decrypt_config(&raw_bytes, &hardware_uuid) {
|
||||
Ok(loaded) => loaded,
|
||||
Err(_) => serde_json::from_slice::<ConfigFile>(&raw_bytes)
|
||||
.unwrap_or_else(|_| self.cache.clone()),
|
||||
}
|
||||
} else {
|
||||
self.cache.clone()
|
||||
}
|
||||
} else {
|
||||
self.cache.clone()
|
||||
};
|
||||
|
||||
// Merge UI preference fields
|
||||
disk_config.follow_system_theme = local_config.follow_system_theme;
|
||||
disk_config.theme_mode = local_config.theme_mode;
|
||||
disk_config.light_theme_name = local_config.light_theme_name;
|
||||
disk_config.dark_theme_name = local_config.dark_theme_name;
|
||||
disk_config.locale = local_config.locale;
|
||||
disk_config.terminal_font_size = local_config.terminal_font_size;
|
||||
disk_config.ui_font_size = local_config.ui_font_size;
|
||||
disk_config.right_click_copy_paste = local_config.right_click_copy_paste;
|
||||
disk_config.keyword_highlight = local_config.keyword_highlight;
|
||||
disk_config.ui_font_family = local_config.ui_font_family;
|
||||
disk_config.terminal_font_family = local_config.terminal_font_family;
|
||||
disk_config.title_bar_style = local_config.title_bar_style;
|
||||
disk_config.cursor_style = local_config.cursor_style;
|
||||
disk_config.window_bounds = local_config.window_bounds;
|
||||
disk_config.workspace_panels = local_config.workspace_panels;
|
||||
disk_config.body_panels = local_config.body_panels;
|
||||
disk_config.show_hidden_files = local_config.show_hidden_files;
|
||||
disk_config.lock_layout = local_config.lock_layout;
|
||||
disk_config.monitoring_position = local_config.monitoring_position;
|
||||
disk_config.sidebar_collapsed = local_config.sidebar_collapsed;
|
||||
disk_config.sftp_panel_minimized = local_config.sftp_panel_minimized;
|
||||
|
||||
let encrypted_bytes = encrypt_config(&disk_config, &hardware_uuid)?;
|
||||
fs::write(&self.path, encrypted_bytes)
|
||||
.with_context(|| format!("failed to write {}", self.path.display()))?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
if let Ok(mut perms) = fs::metadata(&self.path).map(|m| m.permissions()) {
|
||||
perms.set_mode(0o600);
|
||||
let _ = fs::set_permissions(&self.path, perms);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub trait ProxyStream:
|
||||
@@ -799,8 +899,6 @@ impl<T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + Sync + 'st
|
||||
{
|
||||
}
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EnvProxy {
|
||||
pub proxy_type: String,
|
||||
@@ -993,64 +1091,60 @@ struct EncryptedConfigEnvelope {
|
||||
payload: String,
|
||||
}
|
||||
|
||||
fn get_hardware_uuid() -> String {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
if let Ok(output) = std::process::Command::new("ioreg")
|
||||
.args(&["-rd1", "-c", "IOPlatformExpertDevice"])
|
||||
.output()
|
||||
{
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
for line in stdout.lines() {
|
||||
if line.contains("IOPlatformUUID") {
|
||||
if let Some(uuid) = line.split('"').nth(3) {
|
||||
let uuid = uuid.trim().to_string();
|
||||
if !uuid.is_empty() {
|
||||
return uuid;
|
||||
static HARDWARE_UUID_CACHE: OnceLock<String> = OnceLock::new();
|
||||
|
||||
pub fn get_hardware_uuid() -> String {
|
||||
HARDWARE_UUID_CACHE
|
||||
.get_or_init(|| {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
if let Ok(output) = std::process::Command::new("ioreg")
|
||||
.args(&["-rd1", "-c", "IOPlatformExpertDevice"])
|
||||
.output()
|
||||
{
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
for line in stdout.lines() {
|
||||
if line.contains("IOPlatformUUID") {
|
||||
if let Some(uuid) = line.split('"').nth(3) {
|
||||
let uuid = uuid.trim().to_string();
|
||||
if !uuid.is_empty() {
|
||||
return uuid;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if let Ok(uuid) = std::fs::read_to_string("/sys/class/dmi/id/product_uuid") {
|
||||
let uuid = uuid.trim().to_string();
|
||||
if !uuid.is_empty() {
|
||||
return uuid;
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if let Ok(uuid) = std::fs::read_to_string("/sys/class/dmi/id/product_uuid") {
|
||||
let uuid = uuid.trim().to_string();
|
||||
if !uuid.is_empty() {
|
||||
return uuid;
|
||||
}
|
||||
}
|
||||
if let Ok(id) = std::fs::read_to_string("/etc/machine-id") {
|
||||
let id = id.trim().to_string();
|
||||
if !id.is_empty() {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
if let Ok(id) = std::fs::read_to_string("/var/lib/dbus/machine-id") {
|
||||
let id = id.trim().to_string();
|
||||
if !id.is_empty() {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Ok(id) = std::fs::read_to_string("/etc/machine-id") {
|
||||
let id = id.trim().to_string();
|
||||
if !id.is_empty() {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
if let Ok(id) = std::fs::read_to_string("/var/lib/dbus/machine-id") {
|
||||
let id = id.trim().to_string();
|
||||
if !id.is_empty() {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
if let Ok(output) = std::process::Command::new("reg")
|
||||
.args(&[
|
||||
"query",
|
||||
"HKLM\\SOFTWARE\\Microsoft\\Cryptography",
|
||||
"/v",
|
||||
"MachineGuid",
|
||||
])
|
||||
.output()
|
||||
{
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
for line in stdout.lines() {
|
||||
if line.contains("MachineGuid") {
|
||||
if let Some(guid) = line.split_whitespace().last() {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use winreg::RegKey;
|
||||
use winreg::enums::HKEY_LOCAL_MACHINE;
|
||||
let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
|
||||
if let Ok(subkey) = hklm.open_subkey("SOFTWARE\\Microsoft\\Cryptography") {
|
||||
if let Ok(guid) = subkey.get_value::<String, _>("MachineGuid") {
|
||||
let guid = guid.trim().to_string();
|
||||
if !guid.is_empty() {
|
||||
return guid;
|
||||
@@ -1058,23 +1152,10 @@ fn get_hardware_uuid() -> String {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Ok(output) = std::process::Command::new("wmic")
|
||||
.args(&["csproduct", "get", "uuid"])
|
||||
.output()
|
||||
{
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let lines: Vec<&str> = stdout.lines().collect();
|
||||
if lines.len() >= 2 {
|
||||
let uuid = lines[1].trim().to_string();
|
||||
if !uuid.is_empty() {
|
||||
return uuid;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
"ashell-default-hardware-uuid-fallback".to_string()
|
||||
"ashell-default-hardware-uuid-fallback".to_string()
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
fn encrypt_config(config: &ConfigFile, password: &str) -> Result<Vec<u8>> {
|
||||
@@ -1167,4 +1248,64 @@ mod tests {
|
||||
// Decrypt with wrong password should fail
|
||||
assert!(decrypt_config(&encrypted, "wrong-password").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_merged_preferences() {
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let path = temp_dir.join(format!("ashell-test-config-{}.json", Uuid::new_v4()));
|
||||
let mut store = ConfigStore {
|
||||
path: path.clone(),
|
||||
cache: ConfigFile::default(),
|
||||
};
|
||||
|
||||
let session = Session {
|
||||
id: "test-session-id".to_string(),
|
||||
name: "Test Session".to_string(),
|
||||
host: "1.2.3.4".to_string(),
|
||||
port: 22,
|
||||
user: "root".to_string(),
|
||||
auth: AuthMethod::Password,
|
||||
password: "pwd".to_string(),
|
||||
private_key_path: String::new(),
|
||||
private_key_inline: String::new(),
|
||||
passphrase: String::new(),
|
||||
last_used: None,
|
||||
proxy_type: String::new(),
|
||||
proxy_host: String::new(),
|
||||
proxy_port: None,
|
||||
proxy_user: String::new(),
|
||||
proxy_password: String::new(),
|
||||
protocol: "ssh".to_string(),
|
||||
baud_rate: 115200,
|
||||
};
|
||||
store.cache.sessions.push(session.clone());
|
||||
store.save().unwrap();
|
||||
|
||||
let mut local_config = ConfigFile::default();
|
||||
local_config.ui_font_size = 18.0;
|
||||
local_config.terminal_font_size = 20.0;
|
||||
local_config.show_hidden_files = true;
|
||||
|
||||
store.save_merged_preferences(local_config).unwrap();
|
||||
|
||||
let loaded_bytes = fs::read(&path).unwrap();
|
||||
let decrypted = decrypt_config(&loaded_bytes, &get_hardware_uuid()).unwrap();
|
||||
|
||||
assert_eq!(decrypted.ui_font_size, 18.0);
|
||||
assert_eq!(decrypted.terminal_font_size, 20.0);
|
||||
assert!(decrypted.show_hidden_files);
|
||||
|
||||
assert_eq!(decrypted.sessions.len(), 1);
|
||||
assert_eq!(decrypted.sessions[0].name, "Test Session");
|
||||
assert_eq!(decrypted.sessions[0].host, "1.2.3.4");
|
||||
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_key_binding_unbinding_none() {
|
||||
let mut store = ConfigStore::in_memory();
|
||||
store.set_key_binding("OpenSettings", "none");
|
||||
assert_eq!(store.key_bindings().get("OpenSettings").unwrap(), "none");
|
||||
}
|
||||
}
|
||||
|
||||
+190
-46
@@ -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 {
|
||||
@@ -313,9 +371,7 @@ impl Ashell {
|
||||
pub(crate) fn change_terminal_font_size(&mut self, delta: f32, cx: &mut Context<Self>) {
|
||||
self.terminal_font_size = (self.terminal_font_size + delta).clamp(10.0, 24.0);
|
||||
self.config.set_terminal_font_size(self.terminal_font_size);
|
||||
if let Err(err) = self.config.save() {
|
||||
tracing::warn!("failed to save terminal font size: {err:#}");
|
||||
}
|
||||
self.save_preferences_background();
|
||||
self.status = format!("terminal font size: {:.0}px", self.terminal_font_size).into();
|
||||
cx.notify();
|
||||
}
|
||||
@@ -323,9 +379,7 @@ impl Ashell {
|
||||
pub(crate) fn change_ui_font_size(&mut self, delta: f32, cx: &mut Context<Self>) {
|
||||
self.ui_font_size = (self.ui_font_size + delta).clamp(8.0, 24.0);
|
||||
self.config.set_ui_font_size(self.ui_font_size);
|
||||
if let Err(err) = self.config.save() {
|
||||
tracing::warn!("failed to save UI font size: {err:#}");
|
||||
}
|
||||
self.save_preferences_background();
|
||||
Theme::global_mut(cx).font_size = px(self.ui_font_size);
|
||||
self.status = format!("UI font size: {:.0}px", self.ui_font_size).into();
|
||||
cx.notify();
|
||||
@@ -339,9 +393,7 @@ impl Ashell {
|
||||
) {
|
||||
self.ui_font_family = family.into();
|
||||
self.config.set_ui_font_family(family);
|
||||
if let Err(err) = self.config.save() {
|
||||
tracing::warn!("failed to save UI font family: {err:#}");
|
||||
}
|
||||
self.save_preferences_background();
|
||||
crate::app::theme::set_theme_font_names(Theme::global_mut(cx), &self.ui_font_family);
|
||||
cx.notify();
|
||||
window.refresh();
|
||||
@@ -350,9 +402,7 @@ impl Ashell {
|
||||
pub(crate) fn change_terminal_font_family(&mut self, family: &str, cx: &mut Context<Self>) {
|
||||
self.terminal_font_family = family.into();
|
||||
self.config.set_terminal_font_family(family);
|
||||
if let Err(err) = self.config.save() {
|
||||
tracing::warn!("failed to save terminal font family: {err:#}");
|
||||
}
|
||||
self.save_preferences_background();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
@@ -363,15 +413,13 @@ impl Ashell {
|
||||
) {
|
||||
self.cursor_style = style;
|
||||
self.config.set_cursor_style(style);
|
||||
if let Err(err) = self.config.save() {
|
||||
tracing::warn!("failed to save cursor style: {err:#}");
|
||||
}
|
||||
self.save_preferences_background();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn reset_layout(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.config.set_layout_state(None, None, None);
|
||||
let _ = self.config.save();
|
||||
self.save_preferences_background();
|
||||
|
||||
self.is_layout_reset = true;
|
||||
self.workspace_panels = cx.new(|_| crate::app::resizable::ResizableState::default());
|
||||
@@ -389,6 +437,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 +501,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 +670,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 +754,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 +798,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 +1166,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 +1236,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
|
||||
@@ -1163,7 +1306,7 @@ impl Ashell {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn focus_adjacent_pane(&mut self, direction: &str) {
|
||||
pub(crate) fn focus_adjacent_pane(&mut self, direction: &str, cx: &mut Context<Self>) {
|
||||
if self.focused_pane_path.is_empty() {
|
||||
return;
|
||||
}
|
||||
@@ -1182,6 +1325,7 @@ impl Ashell {
|
||||
self.search_target_tab = None;
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -833,9 +833,9 @@ async fn connect_and_authenticate(
|
||||
session: &Session,
|
||||
) -> Result<Arc<russh::client::Handle<SftpClientHandler>>> {
|
||||
let config = Arc::new(client::Config {
|
||||
inactivity_timeout: Some(std::time::Duration::from_secs(600)),
|
||||
keepalive_interval: Some(std::time::Duration::from_secs(3)),
|
||||
keepalive_max: 2,
|
||||
inactivity_timeout: None,
|
||||
keepalive_interval: Some(std::time::Duration::from_secs(5)),
|
||||
keepalive_max: 3,
|
||||
..Default::default()
|
||||
});
|
||||
let addr = format!("{}:{}", session.host, session.port);
|
||||
|
||||
@@ -42,10 +42,10 @@ impl Ashell {
|
||||
&& !event.keystroke.modifiers.platform
|
||||
{
|
||||
match event.keystroke.key.to_ascii_lowercase().as_str() {
|
||||
"h" => self.focus_adjacent_pane("left"),
|
||||
"j" => self.focus_adjacent_pane("down"),
|
||||
"k" => self.focus_adjacent_pane("up"),
|
||||
"l" => self.focus_adjacent_pane("right"),
|
||||
"h" => self.focus_adjacent_pane("left", cx),
|
||||
"j" => self.focus_adjacent_pane("down", cx),
|
||||
"k" => self.focus_adjacent_pane("up", cx),
|
||||
"l" => self.focus_adjacent_pane("right", cx),
|
||||
"q" => {
|
||||
if let Some(active_id) = self.active_tab.clone() {
|
||||
self.close_tab(active_id, cx);
|
||||
|
||||
@@ -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,
|
||||
@@ -740,6 +764,18 @@ fn zed_like_to_esc_str(
|
||||
("^", TerminalModifiers::Ctrl) => Some("\x1e"),
|
||||
("_", TerminalModifiers::Ctrl) => Some("\x1f"),
|
||||
("?", TerminalModifiers::Ctrl) => Some("\x7f"),
|
||||
("f1", TerminalModifiers::None) => Some("\x1bOP"),
|
||||
("f2", TerminalModifiers::None) => Some("\x1bOQ"),
|
||||
("f3", TerminalModifiers::None) => Some("\x1bOR"),
|
||||
("f4", TerminalModifiers::None) => Some("\x1bOS"),
|
||||
("f5", TerminalModifiers::None) => Some("\x1b[15~"),
|
||||
("f6", TerminalModifiers::None) => Some("\x1b[17~"),
|
||||
("f7", TerminalModifiers::None) => Some("\x1b[18~"),
|
||||
("f8", TerminalModifiers::None) => Some("\x1b[19~"),
|
||||
("f9", TerminalModifiers::None) => Some("\x1b[20~"),
|
||||
("f10", TerminalModifiers::None) => Some("\x1b[21~"),
|
||||
("f11", TerminalModifiers::None) => Some("\x1b[23~"),
|
||||
("f12", TerminalModifiers::None) => Some("\x1b[24~"),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(esc) = manual_esc_str {
|
||||
@@ -758,6 +794,18 @@ fn zed_like_to_esc_str(
|
||||
"pagedown" => Some(format!("\x1b[6;{}~", modifier_code)),
|
||||
"end" => Some(format!("\x1b[1;{}F", modifier_code)),
|
||||
"home" => Some(format!("\x1b[1;{}H", modifier_code)),
|
||||
"f1" => Some(format!("\x1b[1;{}P", modifier_code)),
|
||||
"f2" => Some(format!("\x1b[1;{}Q", modifier_code)),
|
||||
"f3" => Some(format!("\x1b[1;{}R", modifier_code)),
|
||||
"f4" => Some(format!("\x1b[1;{}S", modifier_code)),
|
||||
"f5" => Some(format!("\x1b[15;{}~", modifier_code)),
|
||||
"f6" => Some(format!("\x1b[17;{}~", modifier_code)),
|
||||
"f7" => Some(format!("\x1b[18;{}~", modifier_code)),
|
||||
"f8" => Some(format!("\x1b[19;{}~", modifier_code)),
|
||||
"f9" => Some(format!("\x1b[20;{}~", modifier_code)),
|
||||
"f10" => Some(format!("\x1b[21;{}~", modifier_code)),
|
||||
"f11" => Some(format!("\x1b[23;{}~", modifier_code)),
|
||||
"f12" => Some(format!("\x1b[24;{}~", modifier_code)),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(esc) = modified {
|
||||
|
||||
Reference in New Issue
Block a user