Compare commits

..
18 changed files with 1410 additions and 434 deletions
Generated
+95 -4
View File
@@ -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
View File
@@ -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"]
+9
View File
@@ -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"
+9
View File
@@ -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: "导入配置"
+69
View File
@@ -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
View File
@@ -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();
}))
)
+5 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -1,2 +1,3 @@
pub mod local;
pub mod serial;
pub mod ssh;
+223
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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);
+4 -4
View File
@@ -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);
+48
View File
@@ -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 {