mirror of
https://github.com/rust-kotlin/ashell.git
synced 2026-10-03 16:00:18 +00:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44348d4d75 | ||
|
|
9163d3a970 | ||
|
|
d85c1f4f55 | ||
|
|
61e7fb7b0d | ||
|
|
1bb922f955 | ||
|
|
a06d1c668d | ||
|
|
3b9ee02cce | ||
|
|
e7ca7bdc23 | ||
|
|
84eccb5521 | ||
|
|
4da9949c84 | ||
|
|
85fb354f80 | ||
|
|
584f962298 | ||
|
|
a32fd9437d | ||
|
|
4c292c060c | ||
|
|
c0a954ea0c |
Generated
+95
-4
@@ -315,7 +315,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ashell"
|
||||
version = "0.4.10"
|
||||
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.10"
|
||||
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}"
|
||||
@@ -134,6 +139,8 @@ settings_copy: "Copy Selection"
|
||||
settings_paste: "Paste Clipboard"
|
||||
edit_file: "Edit File"
|
||||
edit_file_tooltip: "Download to temporary directory, open in default editor, and auto upload on save."
|
||||
sync_cwd_tooltip: "Sync path from active terminal"
|
||||
sync_cwd: "Sync Path"
|
||||
auto_saved_and_uploaded: "Auto-saved & uploaded: %{base}"
|
||||
scroll: "Scroll ↓"
|
||||
clone: "Clone"
|
||||
@@ -213,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}"
|
||||
@@ -135,6 +140,8 @@ settings_copy: "复制选区"
|
||||
settings_paste: "粘贴剪贴板"
|
||||
edit_file: "编辑文件"
|
||||
edit_file_tooltip: "下载到临时目录,使用默认编辑器打开,并在保存后自动上传。"
|
||||
sync_cwd_tooltip: "从当前终端同步路径"
|
||||
sync_cwd: "同步路径"
|
||||
auto_saved_and_uploaded: "已自动保存并上传: %{base}"
|
||||
scroll: "滑动查看 ↓"
|
||||
clone: "克隆"
|
||||
@@ -215,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();
|
||||
}))
|
||||
)
|
||||
|
||||
@@ -128,12 +128,20 @@ pub(crate) const WORKSPACE_ACTIONS: &[WorkspaceAction] = &[
|
||||
WorkspaceAction {
|
||||
id: "Copy",
|
||||
label_key: "settings_copy",
|
||||
default_suffix: if cfg!(target_os = "macos") { "c" } else { "shift-c" },
|
||||
default_suffix: if cfg!(target_os = "macos") {
|
||||
"c"
|
||||
} else {
|
||||
"shift-c"
|
||||
},
|
||||
},
|
||||
WorkspaceAction {
|
||||
id: "Paste",
|
||||
label_key: "settings_paste",
|
||||
default_suffix: if cfg!(target_os = "macos") { "v" } else { "shift-v" },
|
||||
default_suffix: if cfg!(target_os = "macos") {
|
||||
"v"
|
||||
} else {
|
||||
"shift-v"
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -210,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()),
|
||||
@@ -254,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()));
|
||||
}
|
||||
}
|
||||
@@ -274,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));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -305,7 +315,15 @@ impl KeybindingsPage {
|
||||
let groups = [
|
||||
(
|
||||
"settings_group_keybind_general",
|
||||
vec!["OpenSettings", "OpenSession", "OpenTransfers", "NewSsh", "OpenSearch", "Copy", "Paste"],
|
||||
vec![
|
||||
"OpenSettings",
|
||||
"OpenSession",
|
||||
"OpenTransfers",
|
||||
"NewSsh",
|
||||
"OpenSearch",
|
||||
"Copy",
|
||||
"Paste",
|
||||
],
|
||||
),
|
||||
(
|
||||
"settings_group_keybind_zoom",
|
||||
|
||||
+210
-34
@@ -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()
|
||||
@@ -1010,7 +1101,7 @@ impl Ashell {
|
||||
}
|
||||
BackendEvent::TerminalTitleChanged { tab_id, title } => {
|
||||
if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == tab_id) {
|
||||
tab.title = title.clone();
|
||||
tab.dynamic_title = title;
|
||||
}
|
||||
}
|
||||
BackendEvent::SyncFinished(result) => {
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1228,6 +1336,73 @@ impl Ashell {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn sync_cwd_from_terminal(
|
||||
&mut self,
|
||||
_window: &mut gpui::Window,
|
||||
_cx: &mut Context<Self>,
|
||||
) {
|
||||
let active_id = self.active_tab.clone();
|
||||
let Some(active_id) = active_id else {
|
||||
return;
|
||||
};
|
||||
|
||||
if let Some(tab) = self.tabs.iter().find(|t| t.id == active_id) {
|
||||
let home_dir = if let Some(group) = self
|
||||
.tab_groups
|
||||
.iter()
|
||||
.find(|g| g.pane_root.contains(&tab.id))
|
||||
{
|
||||
group
|
||||
.sftp
|
||||
.as_ref()
|
||||
.map(|s| s.home_dir.as_str())
|
||||
.unwrap_or("/")
|
||||
} else {
|
||||
"/"
|
||||
};
|
||||
|
||||
let parsed = Self::parse_path_from_title(&tab.dynamic_title, home_dir);
|
||||
|
||||
if let Some(path) = parsed {
|
||||
if let Some(group) = self
|
||||
.tab_groups
|
||||
.iter_mut()
|
||||
.find(|g| g.pane_root.contains(&active_id))
|
||||
{
|
||||
if let Some(sftp) = group.sftp.as_mut() {
|
||||
sftp.current_path = path.clone();
|
||||
self.pending_sftp_path_sync = Some(path.clone());
|
||||
if let Some(handle) = self.sftp_handles.get(&group.id) {
|
||||
let _ = handle
|
||||
.commands
|
||||
.send(crate::sftp::SftpCommand::ListDir(path));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_path_from_title(title: &str, home_dir: &str) -> Option<String> {
|
||||
let title = title.strip_prefix("ASHELL_CWD:").unwrap_or(title);
|
||||
let path_part = if let Some(pos) = title.find(':') {
|
||||
title[pos + 1..].trim()
|
||||
} else {
|
||||
title.trim()
|
||||
};
|
||||
|
||||
if path_part.starts_with('/') {
|
||||
Some(path_part.to_string())
|
||||
} else if path_part == "~" {
|
||||
Some(home_dir.to_string())
|
||||
} else if let Some(rest) = path_part.strip_prefix("~/") {
|
||||
let home = home_dir.trim_end_matches('/');
|
||||
Some(format!("{}/{}", home, rest))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn save_layout_state(&self, window: &mut gpui::Window, cx: &gpui::App) {
|
||||
if self.is_layout_reset {
|
||||
tracing::info!("[ui] layout was reset, skipping save layout state.");
|
||||
@@ -1241,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 {
|
||||
|
||||
+13
-18
@@ -4,17 +4,15 @@ use std::{
|
||||
};
|
||||
|
||||
use gpui::{
|
||||
Along, AnyElement, App, Axis, Bounds, Context, Element, ElementId, Empty, Entity,
|
||||
EventEmitter, InteractiveElement as _, IntoElement, MouseMoveEvent, MouseUpEvent,
|
||||
ParentElement, Pixels, Render, RenderOnce, Style, StyleRefinement, Styled, Window, div,
|
||||
prelude::FluentBuilder, AppContext as _, IsZero as _,
|
||||
Along, AnyElement, App, AppContext as _, Axis, Bounds, Context, Element, ElementId, Empty,
|
||||
Entity, EventEmitter, InteractiveElement as _, IntoElement, IsZero as _, MouseMoveEvent,
|
||||
MouseUpEvent, ParentElement, Pixels, Render, RenderOnce, Style, StyleRefinement, Styled,
|
||||
Window, div, prelude::FluentBuilder,
|
||||
};
|
||||
|
||||
use gpui_component::{
|
||||
AxisExt, ElementExt, h_flex, v_flex, StyledExt,
|
||||
};
|
||||
use gpui_component::{AxisExt, ElementExt, StyledExt, h_flex, v_flex};
|
||||
|
||||
use super::{ResizableState, resizable_panel, resize_handle, PANEL_MIN_SIZE, ResizablePanelEvent};
|
||||
use super::{PANEL_MIN_SIZE, ResizablePanelEvent, ResizableState, resizable_panel, resize_handle};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct DragPanel;
|
||||
@@ -305,16 +303,13 @@ impl RenderOnce for ResizablePanel {
|
||||
let handle = if self.locked {
|
||||
handle
|
||||
} else {
|
||||
handle.on_drag(
|
||||
DragPanel,
|
||||
move |drag_panel, _, _, cx| {
|
||||
cx.stop_propagation();
|
||||
state.update(cx, |state, _| {
|
||||
state.resizing_panel_ix = Some(ix);
|
||||
});
|
||||
cx.new(|_| drag_panel.deref().clone())
|
||||
},
|
||||
)
|
||||
handle.on_drag(DragPanel, move |drag_panel, _, _, cx| {
|
||||
cx.stop_propagation();
|
||||
state.update(cx, |state, _| {
|
||||
state.resizing_panel_ix = Some(ix);
|
||||
});
|
||||
cx.new(|_| drag_panel.deref().clone())
|
||||
})
|
||||
};
|
||||
this.child(handle)
|
||||
})
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
use std::{cell::Cell, rc::Rc};
|
||||
|
||||
use gpui::{
|
||||
div, prelude::FluentBuilder as _, px, AnyElement, App, Axis, Element, ElementId, Entity,
|
||||
GlobalElementId, InteractiveElement, IntoElement, MouseDownEvent, MouseUpEvent,
|
||||
ParentElement as _, Pixels, Point, Render, StatefulInteractiveElement, Styled as _, Window,
|
||||
AnyElement, App, Axis, Element, ElementId, Entity, GlobalElementId, InteractiveElement,
|
||||
IntoElement, MouseDownEvent, MouseUpEvent, ParentElement as _, Pixels, Point, Render,
|
||||
StatefulInteractiveElement, Styled as _, Window, div, prelude::FluentBuilder as _, px,
|
||||
};
|
||||
|
||||
use gpui_component::{ActiveTheme as _, AxisExt as _, dock::DockPlacement};
|
||||
@@ -126,14 +126,13 @@ impl<T: 'static, E: 'static + Render> Element for ResizeHandle<T, E> {
|
||||
)
|
||||
})
|
||||
.map(|this| match self.placement {
|
||||
Some(DockPlacement::Left) => {
|
||||
this.when(has_drag, |this| this.cursor_col_resize())
|
||||
.top_0()
|
||||
.right(px(1.))
|
||||
.h_full()
|
||||
.w(HANDLE_SIZE)
|
||||
.pl(HANDLE_PADDING)
|
||||
}
|
||||
Some(DockPlacement::Left) => this
|
||||
.when(has_drag, |this| this.cursor_col_resize())
|
||||
.top_0()
|
||||
.right(px(1.))
|
||||
.h_full()
|
||||
.w(HANDLE_SIZE)
|
||||
.pl(HANDLE_PADDING),
|
||||
_ => this
|
||||
.when(axis.is_horizontal(), |this| {
|
||||
this.when(has_drag, |this| this.cursor_col_resize())
|
||||
|
||||
+33
-31
@@ -1,13 +1,14 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use gpui::{
|
||||
Context, Focusable as _, Hsla, IntoElement, InteractiveElement as _, MouseButton,
|
||||
Context, Focusable as _, Hsla, InteractiveElement as _, IntoElement, MouseButton,
|
||||
ParentElement as _, Styled as _, Window, div, prelude::FluentBuilder as _, px, rems,
|
||||
};
|
||||
use gpui_component::{
|
||||
ActiveTheme as _, Disableable as _, ElementExt as _, IconName, Sizable as _,
|
||||
button::{Button, ButtonVariants as _},
|
||||
h_flex, input::Input,
|
||||
h_flex,
|
||||
input::Input,
|
||||
};
|
||||
use rust_i18n::t;
|
||||
|
||||
@@ -214,7 +215,7 @@ impl Ashell {
|
||||
};
|
||||
|
||||
if let Some(tab) = tab {
|
||||
let snapshot = tab.render_snapshot();
|
||||
let snapshot = tab.render_snapshot(false);
|
||||
let display_offset = snapshot.display_offset as i32;
|
||||
let rows = snapshot.rows as i32;
|
||||
|
||||
@@ -274,7 +275,7 @@ impl Ashell {
|
||||
let Some(tab) = tab else {
|
||||
return None;
|
||||
};
|
||||
let snapshot = tab.render_snapshot();
|
||||
let snapshot = tab.render_snapshot(false);
|
||||
let display_offset = snapshot.display_offset as i32;
|
||||
let rows = snapshot.rows as i32;
|
||||
|
||||
@@ -323,23 +324,19 @@ impl Ashell {
|
||||
}
|
||||
|
||||
/// Render the search button (used in the tab bar).
|
||||
pub(crate) fn render_search_button(
|
||||
&self,
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl gpui::IntoElement {
|
||||
pub(crate) fn render_search_button(&self, cx: &mut Context<Self>) -> impl gpui::IntoElement {
|
||||
// Wrap in a div so .hover() doesn't conflict with Button's internal hover.
|
||||
div()
|
||||
.child(
|
||||
Button::new("search-btn")
|
||||
.ghost()
|
||||
.small()
|
||||
.rounded(px(999.))
|
||||
.icon(IconName::Search)
|
||||
.tooltip(t!("search").to_string())
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.toggle_search(window, cx);
|
||||
})),
|
||||
)
|
||||
div().child(
|
||||
Button::new("search-btn")
|
||||
.ghost()
|
||||
.small()
|
||||
.rounded(px(999.))
|
||||
.icon(IconName::Search)
|
||||
.tooltip(t!("search").to_string())
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.toggle_search(window, cx);
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
/// Render the expanded search bar overlay (when search is active).
|
||||
@@ -369,10 +366,13 @@ impl Ashell {
|
||||
this.search_bar_bounds = Some(bounds);
|
||||
});
|
||||
})
|
||||
.on_mouse_down(MouseButton::Left, cx.listener(|this, _, window, cx| {
|
||||
this.refocus_search_input(window, cx);
|
||||
cx.stop_propagation();
|
||||
}))
|
||||
.on_mouse_down(
|
||||
MouseButton::Left,
|
||||
cx.listener(|this, _, window, cx| {
|
||||
this.refocus_search_input(window, cx);
|
||||
cx.stop_propagation();
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
@@ -385,13 +385,15 @@ impl Ashell {
|
||||
.child(
|
||||
div()
|
||||
.w(px(200.))
|
||||
.on_key_down(cx.listener(|this, event: &gpui::KeyDownEvent, window, cx| {
|
||||
if event.keystroke.key.as_str() == "escape" {
|
||||
this.close_search(window, cx);
|
||||
window.prevent_default();
|
||||
cx.stop_propagation();
|
||||
}
|
||||
}))
|
||||
.on_key_down(cx.listener(
|
||||
|this, event: &gpui::KeyDownEvent, window, cx| {
|
||||
if event.keystroke.key.as_str() == "escape" {
|
||||
this.close_search(window, cx);
|
||||
window.prevent_default();
|
||||
cx.stop_propagation();
|
||||
}
|
||||
},
|
||||
))
|
||||
.child(Input::new(&self.search_input).small()),
|
||||
)
|
||||
.when(!current_display.is_empty(), |this| {
|
||||
|
||||
+11
-4
@@ -179,7 +179,14 @@ pub(crate) fn sync_macos_launch_environment() {
|
||||
}
|
||||
|
||||
fn read_proxy_from_env() -> Option<(String, String, Option<u16>, String, String)> {
|
||||
let vars = ["ALL_PROXY", "all_proxy", "HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy"];
|
||||
let vars = [
|
||||
"ALL_PROXY",
|
||||
"all_proxy",
|
||||
"HTTPS_PROXY",
|
||||
"https_proxy",
|
||||
"HTTP_PROXY",
|
||||
"http_proxy",
|
||||
];
|
||||
for var in vars {
|
||||
if let Ok(val) = std::env::var(var) {
|
||||
if val.is_empty() {
|
||||
@@ -301,13 +308,13 @@ pub(crate) fn open_main_window(cx: &mut App) {
|
||||
let focus_handle = view.read(cx).focus_handle.clone();
|
||||
window.focus(&focus_handle, cx);
|
||||
|
||||
|
||||
|
||||
let view_clone = view.clone();
|
||||
window.on_window_should_close(cx, move |window: &mut gpui::Window, cx: &mut gpui::App| {
|
||||
let handle = window.window_handle();
|
||||
if !cx.windows().contains(&handle) {
|
||||
tracing::warn!("[ui] window not found in app during close, skipping save layout state.");
|
||||
tracing::warn!(
|
||||
"[ui] window not found in app during close, skipping save layout state."
|
||||
);
|
||||
return true;
|
||||
}
|
||||
view_clone.read(cx).save_layout_state(window, cx);
|
||||
|
||||
+6
-7
@@ -17,7 +17,10 @@ use std::sync::atomic::{AtomicBool, Ordering};
|
||||
pub(crate) static USING_SYSTEM_MAPLE: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
pub(crate) fn load_fonts(cx: &mut App) -> Result<()> {
|
||||
let has_system_maple = cx.text_system().all_font_names().contains(&"Maple Mono NF CN".to_string());
|
||||
let has_system_maple = cx
|
||||
.text_system()
|
||||
.all_font_names()
|
||||
.contains(&"Maple Mono NF CN".to_string());
|
||||
if has_system_maple {
|
||||
USING_SYSTEM_MAPLE.store(true, Ordering::Relaxed);
|
||||
} else {
|
||||
@@ -123,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();
|
||||
}
|
||||
@@ -165,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();
|
||||
}
|
||||
}
|
||||
|
||||
+101
-61
@@ -1,3 +1,4 @@
|
||||
use crate::app::resizable::{h_resizable, resizable_panel, v_resizable};
|
||||
use gpui::{
|
||||
Context, ElementId, Focusable as _, FontWeight, Hsla, InteractiveElement as _, IntoElement,
|
||||
MouseButton, MouseDownEvent, ParentElement as _, PathBuilder, Pixels, Render,
|
||||
@@ -17,7 +18,6 @@ use gpui_component::{
|
||||
tab::{Tab, TabBar},
|
||||
v_flex,
|
||||
};
|
||||
use crate::app::resizable::{h_resizable, resizable_panel, v_resizable};
|
||||
use rust_i18n::t;
|
||||
|
||||
use crate::{
|
||||
@@ -26,7 +26,7 @@ use crate::{
|
||||
sftp::format_mtime,
|
||||
sftp::ops::is_editable_text_file,
|
||||
system::format_bytes,
|
||||
terminal::{self, TabKind, TerminalTab},
|
||||
terminal::{self, TabKind},
|
||||
};
|
||||
|
||||
impl Ashell {
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -197,6 +197,17 @@ impl Ashell {
|
||||
.when_some(active_sftp.clone(), |this, sftp| {
|
||||
let selected_entries = sftp.selected_entries.clone();
|
||||
this.child(
|
||||
Button::new("sftp-sync-cwd")
|
||||
.ghost()
|
||||
.small()
|
||||
.icon(IconName::SquareTerminal)
|
||||
.label(t!("sync_cwd").to_string())
|
||||
.tooltip(t!("sync_cwd_tooltip").to_string())
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.sync_cwd_from_terminal(window, cx);
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
Button::new("sftp-refresh")
|
||||
.ghost()
|
||||
.small()
|
||||
@@ -282,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();
|
||||
})),
|
||||
)
|
||||
@@ -1582,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();
|
||||
})),
|
||||
)
|
||||
@@ -1611,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()
|
||||
@@ -1815,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();
|
||||
})),
|
||||
),
|
||||
@@ -1984,7 +2002,7 @@ impl Ashell {
|
||||
) -> impl IntoElement {
|
||||
let is_macos = cfg!(target_os = "macos");
|
||||
let is_fullscreen = window.is_fullscreen();
|
||||
|
||||
|
||||
let is_active = cx.active_window() == Some(window.window_handle());
|
||||
|
||||
h_flex()
|
||||
@@ -1999,9 +2017,17 @@ impl Ashell {
|
||||
.id("window-close")
|
||||
.size(px(12.))
|
||||
.rounded_full()
|
||||
.bg(if is_active { hsla(3.0 / 360.0, 1.0, 0.67, 1.0) } else { hsla(0.0, 0.0, 0.8, 1.0) }) // Red or Inactive Grey
|
||||
.group_hover("window-controls", |s| s.bg(hsla(3.0 / 360.0, 1.0, 0.67, 1.0)))
|
||||
.when(!is_macos, |this| this.window_control_area(gpui::WindowControlArea::Close))
|
||||
.bg(if is_active {
|
||||
hsla(3.0 / 360.0, 1.0, 0.67, 1.0)
|
||||
} else {
|
||||
hsla(0.0, 0.0, 0.8, 1.0)
|
||||
}) // Red or Inactive Grey
|
||||
.group_hover("window-controls", |s| {
|
||||
s.bg(hsla(3.0 / 360.0, 1.0, 0.67, 1.0))
|
||||
})
|
||||
.when(!is_macos, |this| {
|
||||
this.window_control_area(gpui::WindowControlArea::Close)
|
||||
})
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.save_layout_state(window, cx);
|
||||
window.remove_window();
|
||||
@@ -2020,7 +2046,7 @@ impl Ashell {
|
||||
.text_color(hsla(3.0 / 360.0, 1.0, 0.15, 0.7))
|
||||
.opacity(0.0)
|
||||
.group_hover("window-controls", |s| s.opacity(1.0))
|
||||
.child("✕")
|
||||
.child("✕"),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
@@ -2028,9 +2054,17 @@ impl Ashell {
|
||||
.id("window-minimize")
|
||||
.size(px(12.))
|
||||
.rounded_full()
|
||||
.bg(if is_active { hsla(39.0 / 360.0, 1.0, 0.59, 1.0) } else { hsla(0.0, 0.0, 0.8, 1.0) }) // Yellow or Inactive Grey
|
||||
.group_hover("window-controls", |s| s.bg(hsla(39.0 / 360.0, 1.0, 0.59, 1.0)))
|
||||
.when(!is_macos, |this| this.window_control_area(gpui::WindowControlArea::Min))
|
||||
.bg(if is_active {
|
||||
hsla(39.0 / 360.0, 1.0, 0.59, 1.0)
|
||||
} else {
|
||||
hsla(0.0, 0.0, 0.8, 1.0)
|
||||
}) // Yellow or Inactive Grey
|
||||
.group_hover("window-controls", |s| {
|
||||
s.bg(hsla(39.0 / 360.0, 1.0, 0.59, 1.0))
|
||||
})
|
||||
.when(!is_macos, |this| {
|
||||
this.window_control_area(gpui::WindowControlArea::Min)
|
||||
})
|
||||
.on_click(|_, window, _| window.minimize_window())
|
||||
.hover(|s| s.bg(hsla(39.0 / 360.0, 1.0, 0.49, 1.0)))
|
||||
.active(|s| s.bg(hsla(39.0 / 360.0, 1.0, 0.39, 1.0)))
|
||||
@@ -2046,7 +2080,7 @@ impl Ashell {
|
||||
.text_color(hsla(39.0 / 360.0, 1.0, 0.15, 0.8))
|
||||
.opacity(0.0)
|
||||
.group_hover("window-controls", |s| s.opacity(1.0))
|
||||
.child("−")
|
||||
.child("−"),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
@@ -2054,9 +2088,17 @@ impl Ashell {
|
||||
.id("window-maximize")
|
||||
.size(px(12.))
|
||||
.rounded_full()
|
||||
.bg(if is_active { hsla(127.0 / 360.0, 0.68, 0.47, 1.0) } else { hsla(0.0, 0.0, 0.8, 1.0) }) // Green or Inactive Grey
|
||||
.group_hover("window-controls", |s| s.bg(hsla(127.0 / 360.0, 0.68, 0.47, 1.0)))
|
||||
.when(!is_macos, |this| this.window_control_area(gpui::WindowControlArea::Max))
|
||||
.bg(if is_active {
|
||||
hsla(127.0 / 360.0, 0.68, 0.47, 1.0)
|
||||
} else {
|
||||
hsla(0.0, 0.0, 0.8, 1.0)
|
||||
}) // Green or Inactive Grey
|
||||
.group_hover("window-controls", |s| {
|
||||
s.bg(hsla(127.0 / 360.0, 0.68, 0.47, 1.0))
|
||||
})
|
||||
.when(!is_macos, |this| {
|
||||
this.window_control_area(gpui::WindowControlArea::Max)
|
||||
})
|
||||
.on_click(|_, window, _| {
|
||||
if window.is_fullscreen() {
|
||||
window.toggle_fullscreen();
|
||||
@@ -2081,7 +2123,7 @@ impl Ashell {
|
||||
.text_color(hsla(127.0 / 360.0, 1.0, 0.15, 0.8))
|
||||
.opacity(0.0)
|
||||
.group_hover("window-controls", |s| s.opacity(1.0))
|
||||
.child("+")
|
||||
.child("+"),
|
||||
),
|
||||
)
|
||||
})
|
||||
@@ -2315,11 +2357,12 @@ impl Ashell {
|
||||
return this.render_home_page(cx).into_any_element();
|
||||
}
|
||||
let is_focused = path == this.focused_pane_path.as_slice();
|
||||
let keyword_highlight = this.config.keyword_highlight();
|
||||
let snapshot = this
|
||||
.tabs
|
||||
.iter()
|
||||
.find(|t| &t.id == tab_id)
|
||||
.map(TerminalTab::render_snapshot);
|
||||
.map(|t| t.render_snapshot(keyword_highlight));
|
||||
let Some(snapshot) = snapshot else {
|
||||
return div().into_any_element();
|
||||
};
|
||||
@@ -2334,7 +2377,10 @@ impl Ashell {
|
||||
let font_size = px(this.terminal_font_size);
|
||||
let line_height = px(this.terminal_line_height());
|
||||
let cell_width = px(this.terminal_cell_width());
|
||||
let is_url_hovered = this.hovered_url.as_ref().map_or(false, |hu| hu.tab_id == *tab_id);
|
||||
let is_url_hovered = this
|
||||
.hovered_url
|
||||
.as_ref()
|
||||
.map_or(false, |hu| hu.tab_id == *tab_id);
|
||||
let mut el = div()
|
||||
.size_full()
|
||||
.overflow_hidden()
|
||||
@@ -2375,44 +2421,38 @@ impl Ashell {
|
||||
.and_then(|tab| tab.disconnected_reason.clone());
|
||||
if let Some(reason) = disconnected_reason {
|
||||
let tab_id_for_reconnect = tab_id.clone();
|
||||
el = div()
|
||||
.size_full()
|
||||
.relative()
|
||||
.child(el)
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.bottom_0()
|
||||
.left_0()
|
||||
.right_0()
|
||||
el = div().size_full().relative().child(el).child(
|
||||
div().absolute().bottom_0().left_0().right_0().child(
|
||||
h_flex()
|
||||
.w_full()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.px_3()
|
||||
.py_1()
|
||||
.bg(cx.theme().danger.opacity(0.15))
|
||||
.child(
|
||||
h_flex()
|
||||
.w_full()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.px_3()
|
||||
.py_1()
|
||||
.bg(cx.theme().danger.opacity(0.15))
|
||||
div()
|
||||
.text_size(rems(0.85))
|
||||
.text_color(cx.theme().danger)
|
||||
.child(
|
||||
div()
|
||||
.text_size(rems(0.85))
|
||||
.text_color(cx.theme().danger)
|
||||
.child(t!("session_disconnected", "reason" = reason).to_string()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_size(rems(0.85))
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(format!("— {}", t!("press_enter_to_reconnect"))),
|
||||
)
|
||||
.on_mouse_down(
|
||||
MouseButton::Left,
|
||||
cx.listener(move |this, _, _, cx| {
|
||||
this.retry_disconnected_tab(&tab_id_for_reconnect, cx);
|
||||
}),
|
||||
t!("session_disconnected", "reason" = reason)
|
||||
.to_string(),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_size(rems(0.85))
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(format!("— {}", t!("press_enter_to_reconnect"))),
|
||||
)
|
||||
.on_mouse_down(
|
||||
MouseButton::Left,
|
||||
cx.listener(move |this, _, _, cx| {
|
||||
this.retry_disconnected_tab(&tab_id_for_reconnect, cx);
|
||||
}),
|
||||
),
|
||||
);
|
||||
),
|
||||
);
|
||||
}
|
||||
let indicator_color = this
|
||||
.tabs
|
||||
@@ -2606,7 +2646,7 @@ impl Render for Ashell {
|
||||
if let Some(scrollbar) = self.terminal_scrollbars.get(&active_id) {
|
||||
if let Some(new_display_offset) = scrollbar.future_display_offset.take() {
|
||||
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == active_id) {
|
||||
let current = tab.render_snapshot().display_offset;
|
||||
let current = tab.render_snapshot(false).display_offset;
|
||||
match new_display_offset.cmp(¤t) {
|
||||
std::cmp::Ordering::Greater => {
|
||||
tab.scroll_up_by(new_display_offset - current)
|
||||
@@ -2757,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();
|
||||
}
|
||||
}
|
||||
+8
-8
@@ -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);
|
||||
@@ -322,7 +322,9 @@ async fn connect_and_authenticate(
|
||||
break;
|
||||
}
|
||||
Ok(false) => {
|
||||
tracing::debug!("[ssh] public key auth failed with algorithm, trying next");
|
||||
tracing::debug!(
|
||||
"[ssh] public key auth failed with algorithm, trying next"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -344,8 +346,7 @@ async fn connect_and_authenticate(
|
||||
success
|
||||
} else {
|
||||
let success =
|
||||
authenticate_with_default_keys(&mut handle, &session.user, passphrase)
|
||||
.await?;
|
||||
authenticate_with_default_keys(&mut handle, &session.user, passphrase).await?;
|
||||
if !success {
|
||||
return Err(anyhow::anyhow!(
|
||||
"public key authentication failed for {}@{}:{} - no valid default key found in ~/.ssh/",
|
||||
@@ -417,8 +418,7 @@ async fn connect_and_authenticate(
|
||||
),
|
||||
});
|
||||
let success =
|
||||
authenticate_with_default_keys(&mut handle, &session.user, passphrase)
|
||||
.await?;
|
||||
authenticate_with_default_keys(&mut handle, &session.user, passphrase).await?;
|
||||
if !success {
|
||||
return Err(anyhow::anyhow!(
|
||||
"ssh-config authentication failed for {}@{}:{} - no valid default key found",
|
||||
|
||||
+4
-6
@@ -16,14 +16,12 @@ rust_i18n::i18n!("locales", fallback = "en");
|
||||
gpui::actions!(ashell_terminal, [TerminalTabKey, TerminalBacktabKey]);
|
||||
|
||||
pub(crate) use app::keybinding_recorder::{
|
||||
ClosePane, FocusPaneDown, FocusPaneLeft, FocusPaneRight, FocusPaneUp, NewSsh, OpenSearch,
|
||||
OpenSession, OpenSettings, OpenTransfers, SplitPaneDown, SplitPaneLeft, SplitPaneRight,
|
||||
SplitPaneUp, ToggleSftpZoom, ToggleSidebar, Copy, Paste,
|
||||
ClosePane, Copy, FocusPaneDown, FocusPaneLeft, FocusPaneRight, FocusPaneUp, NewSsh, OpenSearch,
|
||||
OpenSession, OpenSettings, OpenTransfers, Paste, SplitPaneDown, SplitPaneLeft, SplitPaneRight,
|
||||
SplitPaneUp, ToggleSftpZoom, ToggleSidebar,
|
||||
};
|
||||
|
||||
pub(crate) use app::{
|
||||
Ashell, PaneLayout, SelectorEntry, SftpContextMenuState, TabGroup,
|
||||
};
|
||||
pub(crate) use app::{Ashell, PaneLayout, SelectorEntry, SftpContextMenuState, TabGroup};
|
||||
|
||||
fn main() {
|
||||
app::startup::sync_macos_launch_environment();
|
||||
|
||||
+482
-132
@@ -1,7 +1,14 @@
|
||||
use std::{fs, path::PathBuf};
|
||||
use std::{fs, path::PathBuf, sync::OnceLock};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use argon2::Argon2;
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||
use chacha20poly1305::{
|
||||
XChaCha20Poly1305, XNonce,
|
||||
aead::{Aead, KeyInit},
|
||||
};
|
||||
use directories::BaseDirs;
|
||||
use rand::{RngCore, rngs::OsRng};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -13,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,
|
||||
@@ -41,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 {
|
||||
@@ -63,6 +82,8 @@ impl Session {
|
||||
proxy_port: None,
|
||||
proxy_user: String::new(),
|
||||
proxy_password: String::new(),
|
||||
protocol: "ssh".to_string(),
|
||||
baud_rate: 115200,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -317,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 {
|
||||
@@ -344,26 +392,33 @@ impl ConfigStore {
|
||||
}
|
||||
|
||||
let mut cache = if path.exists() {
|
||||
let raw = fs::read_to_string(&path)
|
||||
.with_context(|| format!("failed to read {}", path.display()))?;
|
||||
match serde_json::from_str::<ConfigFile>(&raw) {
|
||||
let raw_bytes =
|
||||
fs::read(&path).with_context(|| format!("failed to read {}", path.display()))?;
|
||||
let hardware_uuid = get_hardware_uuid();
|
||||
match decrypt_config(&raw_bytes, &hardware_uuid) {
|
||||
Ok(cache) => cache,
|
||||
Err(err) => {
|
||||
let backup_path = path.with_extension("json.bak");
|
||||
if let Err(backup_err) = fs::write(&backup_path, raw.as_bytes()) {
|
||||
tracing::warn!(
|
||||
"failed to parse config {}; backup to {} also failed: {backup_err:#}; parse error: {err:#}",
|
||||
path.display(),
|
||||
backup_path.display(),
|
||||
);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"failed to parse config {}; backed up the original to {} and loaded defaults: {err:#}",
|
||||
path.display(),
|
||||
backup_path.display(),
|
||||
);
|
||||
Err(decrypt_err) => {
|
||||
// Fallback to plain text JSON if decryption/parsing failed
|
||||
match serde_json::from_slice::<ConfigFile>(&raw_bytes) {
|
||||
Ok(cache) => cache,
|
||||
Err(json_err) => {
|
||||
let backup_path = path.with_extension("json.bak");
|
||||
if let Err(backup_err) = fs::write(&backup_path, &raw_bytes) {
|
||||
tracing::warn!(
|
||||
"failed to parse config {} (decrypt err: {decrypt_err:#}, json err: {json_err:#}); backup to {} also failed: {backup_err:#}",
|
||||
path.display(),
|
||||
backup_path.display(),
|
||||
);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"failed to parse config {} (decrypt err: {decrypt_err:#}, json err: {json_err:#}); backed up the original to {} and loaded defaults",
|
||||
path.display(),
|
||||
backup_path.display(),
|
||||
);
|
||||
}
|
||||
ConfigFile::default()
|
||||
}
|
||||
}
|
||||
ConfigFile::default()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -758,8 +813,68 @@ impl ConfigStore {
|
||||
if self.path.as_os_str().is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let raw = serde_json::to_string_pretty(&self.cache)?;
|
||||
fs::write(&self.path, raw)
|
||||
let hardware_uuid = get_hardware_uuid();
|
||||
let encrypted_bytes = encrypt_config(&self.cache, &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 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)]
|
||||
@@ -784,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,
|
||||
@@ -798,118 +911,126 @@ pub struct EnvProxy {
|
||||
pub static ENV_PROXY: OnceLock<Option<EnvProxy>> = OnceLock::new();
|
||||
|
||||
pub async fn connect_proxy(session: &Session) -> Result<Box<dyn ProxyStream>> {
|
||||
let target_host = &session.host;
|
||||
let target_host = session.host.clone();
|
||||
let target_port = session.port;
|
||||
let session = session.clone();
|
||||
|
||||
let config = ConfigStore::load().unwrap_or_else(|_| ConfigStore::in_memory());
|
||||
let (proxy_type, proxy_host, proxy_port, proxy_user, proxy_password) = {
|
||||
if !session.proxy_type.is_empty() && session.proxy_type != "none" {
|
||||
(
|
||||
session.proxy_type.clone(),
|
||||
session.proxy_host.clone(),
|
||||
session.proxy_port,
|
||||
session.proxy_user.clone(),
|
||||
session.proxy_password.clone(),
|
||||
)
|
||||
} else if config.cache.read_env_proxy
|
||||
&& ENV_PROXY.get().and_then(|opt| opt.as_ref()).is_some()
|
||||
{
|
||||
let env_p = ENV_PROXY.get().and_then(|opt| opt.as_ref()).unwrap();
|
||||
(
|
||||
env_p.proxy_type.clone(),
|
||||
env_p.host.clone(),
|
||||
env_p.port,
|
||||
env_p.user.clone(),
|
||||
env_p.pass.clone(),
|
||||
)
|
||||
} else if config.cache.use_proxy {
|
||||
(
|
||||
config.cache.global_proxy_type.clone(),
|
||||
config.cache.global_proxy_host.clone(),
|
||||
config.cache.global_proxy_port,
|
||||
config.cache.global_proxy_user.clone(),
|
||||
config.cache.global_proxy_password.clone(),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"none".to_string(),
|
||||
String::new(),
|
||||
None,
|
||||
String::new(),
|
||||
String::new(),
|
||||
)
|
||||
let connect_fut = async move {
|
||||
let target_host = &target_host;
|
||||
let config = ConfigStore::load().unwrap_or_else(|_| ConfigStore::in_memory());
|
||||
let (proxy_type, proxy_host, proxy_port, proxy_user, proxy_password) = {
|
||||
if !session.proxy_type.is_empty() && session.proxy_type != "none" {
|
||||
(
|
||||
session.proxy_type.clone(),
|
||||
session.proxy_host.clone(),
|
||||
session.proxy_port,
|
||||
session.proxy_user.clone(),
|
||||
session.proxy_password.clone(),
|
||||
)
|
||||
} else if config.cache.read_env_proxy
|
||||
&& ENV_PROXY.get().and_then(|opt| opt.as_ref()).is_some()
|
||||
{
|
||||
let env_p = ENV_PROXY.get().and_then(|opt| opt.as_ref()).unwrap();
|
||||
(
|
||||
env_p.proxy_type.clone(),
|
||||
env_p.host.clone(),
|
||||
env_p.port,
|
||||
env_p.user.clone(),
|
||||
env_p.pass.clone(),
|
||||
)
|
||||
} else if config.cache.use_proxy {
|
||||
(
|
||||
config.cache.global_proxy_type.clone(),
|
||||
config.cache.global_proxy_host.clone(),
|
||||
config.cache.global_proxy_port,
|
||||
config.cache.global_proxy_user.clone(),
|
||||
config.cache.global_proxy_password.clone(),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"none".to_string(),
|
||||
String::new(),
|
||||
None,
|
||||
String::new(),
|
||||
String::new(),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
if proxy_type != "none" && (proxy_host.is_empty() || proxy_port.is_none()) {
|
||||
let addr = format!("{}:{}", target_host, target_port);
|
||||
let stream = tokio::net::TcpStream::connect(&addr).await?;
|
||||
return Ok(Box::new(stream) as Box<dyn ProxyStream>);
|
||||
}
|
||||
|
||||
match proxy_type.as_str() {
|
||||
"socks5" | "socks5h" => {
|
||||
let proxy_port = proxy_port.unwrap_or(1080);
|
||||
let proxy_addr = format!("{}:{}", proxy_host, proxy_port);
|
||||
|
||||
if !proxy_user.is_empty() {
|
||||
let stream = tokio_socks::tcp::Socks5Stream::connect_with_password(
|
||||
proxy_addr.as_str(),
|
||||
(target_host.as_str(), target_port),
|
||||
&proxy_user,
|
||||
&proxy_password,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("SOCKS5 proxy connection failed: {}", e))?;
|
||||
Ok(Box::new(stream) as Box<dyn ProxyStream>)
|
||||
} else {
|
||||
let stream = tokio_socks::tcp::Socks5Stream::connect(
|
||||
proxy_addr.as_str(),
|
||||
(target_host.as_str(), target_port),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("SOCKS5 proxy connection failed: {}", e))?;
|
||||
Ok(Box::new(stream) as Box<dyn ProxyStream>)
|
||||
}
|
||||
}
|
||||
"http" => {
|
||||
let proxy_port = proxy_port.unwrap_or(8080);
|
||||
let proxy_addr = format!("{}:{}", proxy_host, proxy_port);
|
||||
|
||||
use tokio::io::AsyncWriteExt;
|
||||
let mut stream = tokio::net::TcpStream::connect(&proxy_addr)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("HTTP proxy connection failed: {}", e))?;
|
||||
|
||||
let mut request = format!(
|
||||
"CONNECT {}:{} HTTP/1.1\r\nHost: {}:{}\r\n",
|
||||
target_host, target_port, target_host, target_port
|
||||
);
|
||||
if !proxy_user.is_empty() {
|
||||
use base64::Engine as _;
|
||||
let auth = format!("{}:{}", proxy_user, proxy_password);
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(auth);
|
||||
request.push_str(&format!("Proxy-Authorization: Basic {}\r\n", encoded));
|
||||
}
|
||||
request.push_str("\r\n");
|
||||
|
||||
stream.write_all(request.as_bytes()).await?;
|
||||
|
||||
let mut response = [0u8; 1024];
|
||||
let n = tokio::io::AsyncReadExt::read(&mut stream, &mut response).await?;
|
||||
let resp_str = String::from_utf8_lossy(&response[..n]);
|
||||
if !resp_str.contains("200") && !resp_str.contains("established") {
|
||||
return Err(anyhow::anyhow!("HTTP proxy CONNECT failed: {}", resp_str));
|
||||
}
|
||||
|
||||
Ok(Box::new(stream) as Box<dyn ProxyStream>)
|
||||
}
|
||||
_ => {
|
||||
let addr = format!("{}:{}", target_host, target_port);
|
||||
let stream = tokio::net::TcpStream::connect(&addr).await?;
|
||||
Ok(Box::new(stream) as Box<dyn ProxyStream>)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if proxy_type != "none" && (proxy_host.is_empty() || proxy_port.is_none()) {
|
||||
let addr = format!("{}:{}", target_host, target_port);
|
||||
let stream = tokio::net::TcpStream::connect(&addr).await?;
|
||||
return Ok(Box::new(stream));
|
||||
}
|
||||
|
||||
match proxy_type.as_str() {
|
||||
"socks5" | "socks5h" => {
|
||||
let proxy_port = proxy_port.unwrap_or(1080);
|
||||
let proxy_addr = format!("{}:{}", proxy_host, proxy_port);
|
||||
|
||||
if !proxy_user.is_empty() {
|
||||
let stream = tokio_socks::tcp::Socks5Stream::connect_with_password(
|
||||
proxy_addr.as_str(),
|
||||
(target_host.as_str(), target_port),
|
||||
&proxy_user,
|
||||
&proxy_password,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("SOCKS5 proxy connection failed: {}", e))?;
|
||||
Ok(Box::new(stream))
|
||||
} else {
|
||||
let stream = tokio_socks::tcp::Socks5Stream::connect(
|
||||
proxy_addr.as_str(),
|
||||
(target_host.as_str(), target_port),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("SOCKS5 proxy connection failed: {}", e))?;
|
||||
Ok(Box::new(stream))
|
||||
}
|
||||
}
|
||||
"http" => {
|
||||
let proxy_port = proxy_port.unwrap_or(8080);
|
||||
let proxy_addr = format!("{}:{}", proxy_host, proxy_port);
|
||||
|
||||
use tokio::io::AsyncWriteExt;
|
||||
let mut stream = tokio::net::TcpStream::connect(&proxy_addr)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("HTTP proxy connection failed: {}", e))?;
|
||||
|
||||
let mut request = format!(
|
||||
"CONNECT {}:{} HTTP/1.1\r\nHost: {}:{}\r\n",
|
||||
target_host, target_port, target_host, target_port
|
||||
);
|
||||
if !proxy_user.is_empty() {
|
||||
use base64::Engine as _;
|
||||
let auth = format!("{}:{}", proxy_user, proxy_password);
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(auth);
|
||||
request.push_str(&format!("Proxy-Authorization: Basic {}\r\n", encoded));
|
||||
}
|
||||
request.push_str("\r\n");
|
||||
|
||||
stream.write_all(request.as_bytes()).await?;
|
||||
|
||||
let mut response = [0u8; 1024];
|
||||
let n = tokio::io::AsyncReadExt::read(&mut stream, &mut response).await?;
|
||||
let resp_str = String::from_utf8_lossy(&response[..n]);
|
||||
if !resp_str.contains("200") && !resp_str.contains("established") {
|
||||
return Err(anyhow::anyhow!("HTTP proxy CONNECT failed: {}", resp_str));
|
||||
}
|
||||
|
||||
Ok(Box::new(stream))
|
||||
}
|
||||
_ => {
|
||||
let addr = format!("{}:{}", target_host, target_port);
|
||||
let stream = tokio::net::TcpStream::connect(&addr).await?;
|
||||
Ok(Box::new(stream))
|
||||
}
|
||||
}
|
||||
tokio::time::timeout(std::time::Duration::from_secs(16), connect_fut)
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("connection timed out after 16 seconds"))?
|
||||
}
|
||||
|
||||
pub fn active_proxy(session: &Session) -> Option<(String, String, Option<u16>)> {
|
||||
@@ -959,3 +1080,232 @@ pub fn active_proxy(session: &Session) -> Option<(String, String, Option<u16>)>
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct EncryptedConfigEnvelope {
|
||||
format_version: u32,
|
||||
kdf: String,
|
||||
cipher: String,
|
||||
salt: String,
|
||||
nonce: String,
|
||||
payload: String,
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
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")]
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
"ashell-default-hardware-uuid-fallback".to_string()
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
fn encrypt_config(config: &ConfigFile, password: &str) -> Result<Vec<u8>> {
|
||||
let mut salt = [0u8; 16];
|
||||
let mut nonce = [0u8; 24];
|
||||
OsRng.fill_bytes(&mut salt);
|
||||
OsRng.fill_bytes(&mut nonce);
|
||||
|
||||
let mut key = [0u8; 32];
|
||||
Argon2::default()
|
||||
.hash_password_into(password.as_bytes(), &salt, &mut key)
|
||||
.map_err(|err| anyhow::anyhow!("derive encryption key: {err}"))?;
|
||||
|
||||
let plaintext = serde_json::to_vec(config).context("serialize config")?;
|
||||
let ciphertext = XChaCha20Poly1305::new((&key).into())
|
||||
.encrypt(XNonce::from_slice(&nonce), plaintext.as_ref())
|
||||
.map_err(|_| anyhow::anyhow!("encrypt config payload"))?;
|
||||
|
||||
serde_json::to_vec_pretty(&EncryptedConfigEnvelope {
|
||||
format_version: 1,
|
||||
kdf: "argon2id".to_string(),
|
||||
cipher: "xchacha20poly1305".to_string(),
|
||||
salt: STANDARD.encode(salt),
|
||||
nonce: STANDARD.encode(nonce),
|
||||
payload: STANDARD.encode(ciphertext),
|
||||
})
|
||||
.context("serialize encrypted config envelope")
|
||||
}
|
||||
|
||||
fn decrypt_config(raw: &[u8], password: &str) -> Result<ConfigFile> {
|
||||
let envelope: EncryptedConfigEnvelope =
|
||||
serde_json::from_slice(raw).context("parse encrypted config envelope")?;
|
||||
if envelope.format_version != 1
|
||||
|| envelope.kdf != "argon2id"
|
||||
|| envelope.cipher != "xchacha20poly1305"
|
||||
{
|
||||
return Err(anyhow::anyhow!("unsupported encrypted config format"));
|
||||
}
|
||||
let salt = STANDARD
|
||||
.decode(envelope.salt)
|
||||
.context("decode config salt")?;
|
||||
let nonce = STANDARD
|
||||
.decode(envelope.nonce)
|
||||
.context("decode config nonce")?;
|
||||
if nonce.len() != 24 {
|
||||
return Err(anyhow::anyhow!("invalid config nonce"));
|
||||
}
|
||||
let ciphertext = STANDARD
|
||||
.decode(envelope.payload)
|
||||
.context("decode encrypted config payload")?;
|
||||
|
||||
let mut key = [0u8; 32];
|
||||
Argon2::default()
|
||||
.hash_password_into(password.as_bytes(), &salt, &mut key)
|
||||
.map_err(|err| anyhow::anyhow!("derive encryption key: {err}"))?;
|
||||
|
||||
let plaintext = XChaCha20Poly1305::new((&key).into())
|
||||
.decrypt(XNonce::from_slice(&nonce), ciphertext.as_ref())
|
||||
.map_err(|_| {
|
||||
anyhow::anyhow!("cannot decrypt config; hardware UUID mismatch or corrupted data")
|
||||
})?;
|
||||
|
||||
serde_json::from_slice(&plaintext).context("parse decrypted config")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_get_hardware_uuid() {
|
||||
let uuid = get_hardware_uuid();
|
||||
assert!(!uuid.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_encryption_roundtrip() {
|
||||
let config = ConfigFile::default();
|
||||
let password = "test-password-123";
|
||||
let encrypted = encrypt_config(&config, password).unwrap();
|
||||
|
||||
// Ensure it doesn't contain plain text fields of default config
|
||||
let encrypted_str = String::from_utf8_lossy(&encrypted);
|
||||
assert!(!encrypted_str.contains("Maple Mono NF CN"));
|
||||
assert!(encrypted_str.contains("argon2id"));
|
||||
|
||||
let decrypted = decrypt_config(&encrypted, password).unwrap();
|
||||
assert_eq!(decrypted.terminal_font_family, config.terminal_font_family);
|
||||
|
||||
// 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");
|
||||
}
|
||||
}
|
||||
|
||||
+199
-50
@@ -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();
|
||||
@@ -102,9 +151,7 @@ impl Ashell {
|
||||
|
||||
let mut session = match self.ssh_auth_method {
|
||||
AuthMethod::Password => Session::password(host, port, user, password),
|
||||
AuthMethod::Key => {
|
||||
Session::key(host, port, user, key_path, key_inline, passphrase)
|
||||
}
|
||||
AuthMethod::Key => Session::key(host, port, user, key_path, key_inline, passphrase),
|
||||
AuthMethod::Config => {
|
||||
// Force key_inline to empty — config mode never uses inline key content.
|
||||
// The backend will try default keys from ~/.ssh/ if no explicit key path is set.
|
||||
@@ -155,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);
|
||||
@@ -163,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);
|
||||
@@ -178,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);
|
||||
@@ -201,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 {
|
||||
@@ -315,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();
|
||||
}
|
||||
@@ -325,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();
|
||||
@@ -341,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();
|
||||
@@ -352,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();
|
||||
}
|
||||
|
||||
@@ -365,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());
|
||||
@@ -391,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();
|
||||
@@ -450,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> {
|
||||
@@ -615,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() {
|
||||
@@ -648,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;
|
||||
@@ -682,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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -785,6 +903,13 @@ impl Ashell {
|
||||
}
|
||||
|
||||
pub(crate) fn handle_tab_close(&mut self, id: String) {
|
||||
if self
|
||||
.connection_progress
|
||||
.as_ref()
|
||||
.map_or(false, |p| p.tab_id == id)
|
||||
{
|
||||
self.connection_progress = None;
|
||||
}
|
||||
let group_ix = self
|
||||
.tab_groups
|
||||
.iter()
|
||||
@@ -1005,7 +1130,7 @@ impl Ashell {
|
||||
self.active_tab
|
||||
.as_ref()
|
||||
.and_then(|id| self.tabs.iter().find(|t| &t.id == id))
|
||||
.map(TerminalTab::render_snapshot)
|
||||
.map(|t| t.render_snapshot(self.config.keyword_highlight()))
|
||||
}
|
||||
|
||||
pub(crate) fn active_kind(&self) -> Option<TabKind> {
|
||||
@@ -1041,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>) {
|
||||
@@ -1107,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
|
||||
@@ -1158,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;
|
||||
}
|
||||
@@ -1177,6 +1325,7 @@ impl Ashell {
|
||||
self.search_target_tab = None;
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -177,4 +177,3 @@ mod tests {
|
||||
assert_eq!(entries[1].hostname, "5.6.7.8");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+5
-7
@@ -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);
|
||||
@@ -886,8 +886,7 @@ async fn connect_and_authenticate(
|
||||
let passphrase = session.passphrase.trim();
|
||||
let passphrase = (!passphrase.is_empty()).then_some(passphrase);
|
||||
let success =
|
||||
authenticate_with_default_keys(&mut handle, &session.user, passphrase)
|
||||
.await?;
|
||||
authenticate_with_default_keys(&mut handle, &session.user, passphrase).await?;
|
||||
if !success {
|
||||
return Err(anyhow!(
|
||||
"public key authentication failed for {}@{}:{} - no valid default key found in ~/.ssh/",
|
||||
@@ -940,8 +939,7 @@ async fn connect_and_authenticate(
|
||||
let passphrase = session.passphrase.trim();
|
||||
let passphrase = (!passphrase.is_empty()).then_some(passphrase);
|
||||
let success =
|
||||
authenticate_with_default_keys(&mut handle, &session.user, passphrase)
|
||||
.await?;
|
||||
authenticate_with_default_keys(&mut handle, &session.user, passphrase).await?;
|
||||
if !success {
|
||||
return Err(anyhow!(
|
||||
"ssh-config authentication failed for {}@{}:{} - no valid default key found",
|
||||
|
||||
+32
-17
@@ -375,7 +375,12 @@ impl TerminalElement {
|
||||
fn layout_grid(
|
||||
&self,
|
||||
cx: &App,
|
||||
) -> (Vec<LayoutRect>, Vec<BatchedTextRun>, Vec<LayoutCustomBlock>, Vec<LayoutUnderline>) {
|
||||
) -> (
|
||||
Vec<LayoutRect>,
|
||||
Vec<BatchedTextRun>,
|
||||
Vec<LayoutCustomBlock>,
|
||||
Vec<LayoutUnderline>,
|
||||
) {
|
||||
let view_read = self.view.read(cx);
|
||||
let hovered_url = view_read.hovered_url.clone();
|
||||
|
||||
@@ -439,12 +444,18 @@ impl TerminalElement {
|
||||
// Apply hover underline if mouse is hovering over this URL
|
||||
if let Some(hu) = &hovered_url {
|
||||
if hu.tab_id == self.tab_id
|
||||
&& hu.cells.contains(&(render_cell.row as usize, render_cell.col as usize))
|
||||
&& hu
|
||||
.cells
|
||||
.contains(&(render_cell.row as usize, render_cell.col as usize))
|
||||
{
|
||||
underlines.push(LayoutUnderline {
|
||||
row: render_cell.row,
|
||||
col: render_cell.col,
|
||||
cells: if cell.flags.contains(Flags::WIDE_CHAR) { 2 } else { 1 },
|
||||
cells: if cell.flags.contains(Flags::WIDE_CHAR) {
|
||||
2
|
||||
} else {
|
||||
1
|
||||
},
|
||||
color: style.color,
|
||||
});
|
||||
}
|
||||
@@ -515,7 +526,9 @@ impl TerminalElement {
|
||||
let cursor_style = self.view.read(cx).cursor_style;
|
||||
let show_cursor = match cursor_style {
|
||||
CursorStyle::Blink | CursorStyle::BeamBlink => {
|
||||
if let Ok(duration) = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
|
||||
if let Ok(duration) =
|
||||
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)
|
||||
{
|
||||
(duration.as_millis() / 600) % 2 == 0
|
||||
} else {
|
||||
true
|
||||
@@ -595,7 +608,7 @@ impl Element for TerminalElement {
|
||||
let tab_id = self.tab_id.clone();
|
||||
let _ = view.update(cx, |this, cx| {
|
||||
let old_bounds = this.terminal_bounds.insert(tab_id.clone(), bounds);
|
||||
|
||||
|
||||
// Sync PTY size unconditionally on every prepaint layout pass to ensure
|
||||
// absolute synchronization with GPUI layout regardless of intermediate events.
|
||||
let line_height = this.terminal_line_height();
|
||||
@@ -604,11 +617,11 @@ impl Element for TerminalElement {
|
||||
let h = bounds.size.height.as_f32();
|
||||
let cols = (w / cell_width).floor().max(1.0) as u16;
|
||||
let rows = (h / line_height).floor().max(1.0) as u16;
|
||||
|
||||
|
||||
if let Some(tab) = this.tabs.iter_mut().find(|t| t.id == tab_id) {
|
||||
tab.resize(cols, rows);
|
||||
}
|
||||
|
||||
|
||||
if old_bounds != Some(bounds) {
|
||||
cx.notify();
|
||||
}
|
||||
@@ -640,9 +653,14 @@ impl Element for TerminalElement {
|
||||
) {
|
||||
// Compute a vertical offset to center the text grid vertically,
|
||||
// distributing the leftover pixel remainder evenly to the top and bottom.
|
||||
let grid_height = prepaint.metrics.line_height * (prepaint.bounds.size.height.as_f32() / prepaint.metrics.line_height.as_f32()).floor();
|
||||
let grid_height = prepaint.metrics.line_height
|
||||
* (prepaint.bounds.size.height.as_f32() / prepaint.metrics.line_height.as_f32())
|
||||
.floor();
|
||||
let y_offset = ((prepaint.bounds.size.height - grid_height) / 2.0).max(px(0.0));
|
||||
let draw_origin = point(prepaint.bounds.origin.x, prepaint.bounds.origin.y + y_offset);
|
||||
let draw_origin = point(
|
||||
prepaint.bounds.origin.x,
|
||||
prepaint.bounds.origin.y + y_offset,
|
||||
);
|
||||
|
||||
for rect in &prepaint.rects {
|
||||
rect.paint(draw_origin, prepaint.metrics, window);
|
||||
@@ -657,10 +675,10 @@ impl Element for TerminalElement {
|
||||
}
|
||||
|
||||
for block in &prepaint.custom_blocks {
|
||||
let x = draw_origin.x.as_f32()
|
||||
+ block.col as f32 * prepaint.metrics.cell_width.as_f32();
|
||||
let y = draw_origin.y.as_f32()
|
||||
+ block.row as f32 * prepaint.metrics.line_height.as_f32();
|
||||
let x =
|
||||
draw_origin.x.as_f32() + block.col as f32 * prepaint.metrics.cell_width.as_f32();
|
||||
let y =
|
||||
draw_origin.y.as_f32() + block.row as f32 * prepaint.metrics.line_height.as_f32();
|
||||
paint_custom_block(
|
||||
window,
|
||||
block.c,
|
||||
@@ -801,10 +819,7 @@ fn merge_underlines(mut underlines: Vec<LayoutUnderline>) -> Vec<LayoutUnderline
|
||||
|
||||
for u in underlines {
|
||||
if let Some(last) = merged.last_mut() {
|
||||
if last.row == u.row
|
||||
&& last.color == u.color
|
||||
&& last.col + last.cells as i32 == u.col
|
||||
{
|
||||
if last.row == u.row && last.color == u.color && last.col + last.cells as i32 == u.col {
|
||||
last.cells += u.cells;
|
||||
continue;
|
||||
}
|
||||
|
||||
+467
-168
@@ -1,6 +1,6 @@
|
||||
use std::collections::HashMap;
|
||||
use gpui::Hsla;
|
||||
use crate::terminal::RenderCell;
|
||||
use gpui::Hsla;
|
||||
use std::collections::HashMap;
|
||||
|
||||
trait HslaExt {
|
||||
fn into_rgba_like(self, r: u8, g: u8, b: u8) -> Self;
|
||||
@@ -15,10 +15,19 @@ impl HslaExt for Hsla {
|
||||
let min = rf.min(gf).min(bf);
|
||||
let l = (max + min) / 2.0;
|
||||
if max == min {
|
||||
return Hsla { h: 0.0, s: 0.0, l, a: 1.0 };
|
||||
return Hsla {
|
||||
h: 0.0,
|
||||
s: 0.0,
|
||||
l,
|
||||
a: 1.0,
|
||||
};
|
||||
}
|
||||
let d = max - min;
|
||||
let s = if l > 0.5 { d / (2.0 - max - min) } else { d / (max + min) };
|
||||
let s = if l > 0.5 {
|
||||
d / (2.0 - max - min)
|
||||
} else {
|
||||
d / (max + min)
|
||||
};
|
||||
let h = if max == rf {
|
||||
((gf - bf) / d + if gf < bf { 6.0 } else { 0.0 }) / 6.0
|
||||
} else if max == gf {
|
||||
@@ -33,58 +42,58 @@ impl HslaExt for Hsla {
|
||||
#[derive(Debug, Clone)]
|
||||
struct HighlightColors {
|
||||
// Log levels
|
||||
error: Hsla, // ERROR, ERR
|
||||
critical: Hsla, // PANIC, FATAL, EMERGENCY, CRITICAL
|
||||
warning: Hsla, // WARNING, WARN
|
||||
info: Hsla, // INFO, NOTICE
|
||||
debug: Hsla, // DEBUG, TRACE, DBG
|
||||
alert: Hsla, // ALERT
|
||||
error: Hsla, // ERROR, ERR
|
||||
critical: Hsla, // PANIC, FATAL, EMERGENCY, CRITICAL
|
||||
warning: Hsla, // WARNING, WARN
|
||||
info: Hsla, // INFO, NOTICE
|
||||
debug: Hsla, // DEBUG, TRACE, DBG
|
||||
alert: Hsla, // ALERT
|
||||
|
||||
// Status indicators
|
||||
success: Hsla, // SUCCESS, OK, PASS, DONE, COMPLETED
|
||||
failure: Hsla, // FAILED, FAIL, FAILURE
|
||||
pending: Hsla, // PENDING, WAITING, PROCESSING
|
||||
running: Hsla, // RUNNING, ACTIVE, EXECUTING
|
||||
stopped: Hsla, // STOPPED, INACTIVE, HALTED, IDLE
|
||||
skipped: Hsla, // SKIPPED, SKIP
|
||||
success: Hsla, // SUCCESS, OK, PASS, DONE, COMPLETED
|
||||
failure: Hsla, // FAILED, FAIL, FAILURE
|
||||
pending: Hsla, // PENDING, WAITING, PROCESSING
|
||||
running: Hsla, // RUNNING, ACTIVE, EXECUTING
|
||||
stopped: Hsla, // STOPPED, INACTIVE, HALTED, IDLE
|
||||
skipped: Hsla, // SKIPPED, SKIP
|
||||
|
||||
// Network
|
||||
network_up: Hsla, // UP, ONLINE, CONNECTED
|
||||
network_down: Hsla, // DOWN, OFFLINE, UNREACHABLE
|
||||
timeout: Hsla, // TIMEOUT, TIMED OUT
|
||||
refused: Hsla, // REFUSED, REJECTED, DENIED
|
||||
network_up: Hsla, // UP, ONLINE, CONNECTED
|
||||
network_down: Hsla, // DOWN, OFFLINE, UNREACHABLE
|
||||
timeout: Hsla, // TIMEOUT, TIMED OUT
|
||||
refused: Hsla, // REFUSED, REJECTED, DENIED
|
||||
|
||||
// Security & Auth
|
||||
security: Hsla, // SSH, SSL, TLS, CERTIFICATE
|
||||
auth: Hsla, // AUTHENTICATED, AUTHORIZED, LOGIN
|
||||
danger: Hsla, // ROOT, SUDO, PASSWORD, SECRET
|
||||
security: Hsla, // SSH, SSL, TLS, CERTIFICATE
|
||||
auth: Hsla, // AUTHENTICATED, AUTHORIZED, LOGIN
|
||||
danger: Hsla, // ROOT, SUDO, PASSWORD, SECRET
|
||||
|
||||
// Operations
|
||||
started: Hsla, // START, BOOT, STARTING
|
||||
stopped_op: Hsla, // STOP, SHUTDOWN, STOPPING
|
||||
restart: Hsla, // RESTART, RESTARTING
|
||||
deploy: Hsla, // DEPLOY, DEPLOYED, DEPLOYMENT
|
||||
crashed: Hsla, // CRASH, CRASHED, SIGSEGV
|
||||
started: Hsla, // START, BOOT, STARTING
|
||||
stopped_op: Hsla, // STOP, SHUTDOWN, STOPPING
|
||||
restart: Hsla, // RESTART, RESTARTING
|
||||
deploy: Hsla, // DEPLOY, DEPLOYED, DEPLOYMENT
|
||||
crashed: Hsla, // CRASH, CRASHED, SIGSEGV
|
||||
|
||||
// Resources
|
||||
memory: Hsla, // MEMORY, RAM, SWAP, HEAP
|
||||
cpu: Hsla, // CPU, PROCESSOR, CORE
|
||||
disk: Hsla, // DISK, STORAGE, PARTITION, MOUNT
|
||||
memory: Hsla, // MEMORY, RAM, SWAP, HEAP
|
||||
cpu: Hsla, // CPU, PROCESSOR, CORE
|
||||
disk: Hsla, // DISK, STORAGE, PARTITION, MOUNT
|
||||
|
||||
// HTTP codes
|
||||
http_2xx: Hsla, // 200-299 Success
|
||||
http_3xx: Hsla, // 300-399 Redirect
|
||||
http_4xx: Hsla, // 400-499 Client Error
|
||||
http_5xx: Hsla, // 500-599 Server Error
|
||||
http_2xx: Hsla, // 200-299 Success
|
||||
http_3xx: Hsla, // 300-399 Redirect
|
||||
http_4xx: Hsla, // 400-499 Client Error
|
||||
http_5xx: Hsla, // 500-599 Server Error
|
||||
|
||||
// Dev / Exceptions
|
||||
exception: Hsla, // Exception, Traceback, Error type
|
||||
deprecated: Hsla, // DEPRECATED, TODO, FIXME
|
||||
exception: Hsla, // Exception, Traceback, Error type
|
||||
deprecated: Hsla, // DEPRECATED, TODO, FIXME
|
||||
|
||||
// Existing
|
||||
network: Hsla, // IP addresses
|
||||
url: Hsla, // http://, https://
|
||||
port: Hsla, // :22, :443, etc.
|
||||
network: Hsla, // IP addresses
|
||||
url: Hsla, // http://, https://
|
||||
port: Hsla, // :22, :443, etc.
|
||||
}
|
||||
|
||||
fn hsla(r: u8, g: u8, b: u8) -> Hsla {
|
||||
@@ -100,58 +109,58 @@ fn hsla(r: u8, g: u8, b: u8) -> Hsla {
|
||||
fn highlight_colors() -> HighlightColors {
|
||||
HighlightColors {
|
||||
// Log levels
|
||||
error: hsla(224, 96, 96), // #E06060 red
|
||||
critical: hsla(255, 50, 50), // #FF3232 bright red
|
||||
warning: hsla(232, 201, 122), // #E8C97A yellow
|
||||
info: hsla(108, 180, 238), // #6CB4EE blue
|
||||
debug: hsla(130, 140, 155), // #828C9B gray
|
||||
alert: hsla(213, 126, 234), // #D57EEA bright magenta
|
||||
error: hsla(224, 96, 96), // #E06060 red
|
||||
critical: hsla(255, 50, 50), // #FF3232 bright red
|
||||
warning: hsla(232, 201, 122), // #E8C97A yellow
|
||||
info: hsla(108, 180, 238), // #6CB4EE blue
|
||||
debug: hsla(130, 140, 155), // #828C9B gray
|
||||
alert: hsla(213, 126, 234), // #D57EEA bright magenta
|
||||
|
||||
// Status
|
||||
success: hsla(126, 198, 153), // #7EC699 green
|
||||
failure: hsla(232, 168, 124), // #E8A87C orange
|
||||
pending: hsla(232, 201, 122), // #E8C97A yellow
|
||||
running: hsla( 86, 206, 234), // #56CEEA cyan
|
||||
stopped: hsla(160, 165, 175), // #A0A5AF gray
|
||||
skipped: hsla(199, 146, 234), // #C792EA purple
|
||||
success: hsla(126, 198, 153), // #7EC699 green
|
||||
failure: hsla(232, 168, 124), // #E8A87C orange
|
||||
pending: hsla(232, 201, 122), // #E8C97A yellow
|
||||
running: hsla(86, 206, 234), // #56CEEA cyan
|
||||
stopped: hsla(160, 165, 175), // #A0A5AF gray
|
||||
skipped: hsla(199, 146, 234), // #C792EA purple
|
||||
|
||||
// Network
|
||||
network_up: hsla(126, 198, 153), // #7EC699 green
|
||||
network_down: hsla(224, 96, 96), // #E06060 red
|
||||
timeout: hsla(213, 126, 234), // #D57EEA magenta
|
||||
refused: hsla(245, 160, 80), // #F5A050 orange
|
||||
network_up: hsla(126, 198, 153), // #7EC699 green
|
||||
network_down: hsla(224, 96, 96), // #E06060 red
|
||||
timeout: hsla(213, 126, 234), // #D57EEA magenta
|
||||
refused: hsla(245, 160, 80), // #F5A050 orange
|
||||
|
||||
// Security
|
||||
security: hsla( 86, 206, 234), // #56CEEA cyan
|
||||
auth: hsla(126, 198, 153), // #7EC699 green
|
||||
danger: hsla(180, 50, 50), // #B43232 dark red
|
||||
security: hsla(86, 206, 234), // #56CEEA cyan
|
||||
auth: hsla(126, 198, 153), // #7EC699 green
|
||||
danger: hsla(180, 50, 50), // #B43232 dark red
|
||||
|
||||
// Operations
|
||||
started: hsla(126, 198, 153), // #7EC699 green
|
||||
stopped_op: hsla(224, 96, 96), // #E06060 red
|
||||
restart: hsla(232, 201, 122), // #E8C97A yellow
|
||||
deploy: hsla(100, 210, 140), // #64D28C bright green
|
||||
crashed: hsla(255, 50, 50), // #FF3232 bright red
|
||||
started: hsla(126, 198, 153), // #7EC699 green
|
||||
stopped_op: hsla(224, 96, 96), // #E06060 red
|
||||
restart: hsla(232, 201, 122), // #E8C97A yellow
|
||||
deploy: hsla(100, 210, 140), // #64D28C bright green
|
||||
crashed: hsla(255, 50, 50), // #FF3232 bright red
|
||||
|
||||
// Resources
|
||||
memory: hsla(199, 146, 234), // #C792EA purple
|
||||
cpu: hsla( 86, 206, 234), // #56CEEA cyan
|
||||
disk: hsla(108, 180, 238), // #6CB4EE blue
|
||||
memory: hsla(199, 146, 234), // #C792EA purple
|
||||
cpu: hsla(86, 206, 234), // #56CEEA cyan
|
||||
disk: hsla(108, 180, 238), // #6CB4EE blue
|
||||
|
||||
// HTTP codes
|
||||
http_2xx: hsla(126, 198, 153), // #7EC699 green
|
||||
http_3xx: hsla( 86, 206, 234), // #56CEEA cyan
|
||||
http_4xx: hsla(232, 201, 122), // #E8C97A yellow
|
||||
http_5xx: hsla(224, 96, 96), // #E06060 red
|
||||
http_2xx: hsla(126, 198, 153), // #7EC699 green
|
||||
http_3xx: hsla(86, 206, 234), // #56CEEA cyan
|
||||
http_4xx: hsla(232, 201, 122), // #E8C97A yellow
|
||||
http_5xx: hsla(224, 96, 96), // #E06060 red
|
||||
|
||||
// Dev
|
||||
exception: hsla(224, 96, 96), // #E06060 red
|
||||
deprecated: hsla(245, 160, 80), // #F5A050 orange
|
||||
exception: hsla(224, 96, 96), // #E06060 red
|
||||
deprecated: hsla(245, 160, 80), // #F5A050 orange
|
||||
|
||||
// Existing
|
||||
network: hsla(199, 146, 234), // #C792EA purple
|
||||
url: hsla( 86, 212, 199), // #56D4C7 teal
|
||||
port: hsla(130, 170, 200), // #82AAC8 muted teal
|
||||
network: hsla(199, 146, 234), // #C792EA purple
|
||||
url: hsla(86, 212, 199), // #56D4C7 teal
|
||||
port: hsla(130, 170, 200), // #82AAC8 muted teal
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,18 +211,40 @@ fn highlight_http_codes(
|
||||
// Specific HTTP codes to highlight
|
||||
const HTTP_CODES: &[(u16, bool)] = &[
|
||||
// 2xx
|
||||
(200, true), (201, true), (202, true), (204, true), (206, true),
|
||||
(200, true),
|
||||
(201, true),
|
||||
(202, true),
|
||||
(204, true),
|
||||
(206, true),
|
||||
// 3xx
|
||||
(301, true), (302, true), (304, true), (307, true), (308, true),
|
||||
(301, true),
|
||||
(302, true),
|
||||
(304, true),
|
||||
(307, true),
|
||||
(308, true),
|
||||
// 4xx
|
||||
(400, true), (401, true), (403, true), (404, true), (405, true),
|
||||
(408, true), (409, true), (410, true), (422, true), (429, true),
|
||||
(400, true),
|
||||
(401, true),
|
||||
(403, true),
|
||||
(404, true),
|
||||
(405, true),
|
||||
(408, true),
|
||||
(409, true),
|
||||
(410, true),
|
||||
(422, true),
|
||||
(429, true),
|
||||
// 5xx
|
||||
(500, true), (502, true), (503, true), (504, true),
|
||||
(500, true),
|
||||
(502, true),
|
||||
(503, true),
|
||||
(504, true),
|
||||
];
|
||||
|
||||
for i in 0..len.saturating_sub(2) {
|
||||
if !bytes[i].is_ascii_digit() || !bytes[i + 1].is_ascii_digit() || !bytes[i + 2].is_ascii_digit() {
|
||||
if !bytes[i].is_ascii_digit()
|
||||
|| !bytes[i + 1].is_ascii_digit()
|
||||
|| !bytes[i + 2].is_ascii_digit()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -249,10 +280,7 @@ fn highlight_http_codes(
|
||||
}
|
||||
}
|
||||
|
||||
pub fn highlight_cells(
|
||||
cells: &[RenderCell],
|
||||
rows: usize,
|
||||
) -> HashMap<(i32, i32), Hsla> {
|
||||
pub fn highlight_cells(cells: &[RenderCell], rows: usize) -> HashMap<(i32, i32), Hsla> {
|
||||
let colors = highlight_colors();
|
||||
|
||||
let mut row_chars: Vec<Vec<(i32, char)>> = vec![Vec::with_capacity(128); rows];
|
||||
@@ -289,178 +317,458 @@ pub fn highlight_cells(
|
||||
let text = chars_buf.as_str();
|
||||
|
||||
// ── 1. Critical errors (highest priority) ──────────────
|
||||
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
|
||||
&["PANIC", "EMERGENCY", "FATAL", "SEGFAULT", "CRITICAL",
|
||||
"OOM", "OUT OF MEMORY", "KERNEL PANIC", "CORE DUMPED", "BUS ERROR"],
|
||||
highlight_keywords(
|
||||
&mut map,
|
||||
text,
|
||||
&byte_to_col,
|
||||
row_i32,
|
||||
&[
|
||||
"PANIC",
|
||||
"EMERGENCY",
|
||||
"FATAL",
|
||||
"SEGFAULT",
|
||||
"CRITICAL",
|
||||
"OOM",
|
||||
"OUT OF MEMORY",
|
||||
"KERNEL PANIC",
|
||||
"CORE DUMPED",
|
||||
"BUS ERROR",
|
||||
],
|
||||
colors.critical,
|
||||
);
|
||||
|
||||
// ── 2. Error keywords ──────────────────────────────────
|
||||
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
|
||||
&["ERROR", "ERR"], colors.error);
|
||||
highlight_keywords(
|
||||
&mut map,
|
||||
text,
|
||||
&byte_to_col,
|
||||
row_i32,
|
||||
&["ERROR", "ERR"],
|
||||
colors.error,
|
||||
);
|
||||
|
||||
// ── 3. Alert ───────────────────────────────────────────
|
||||
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
|
||||
&["ALERT"], colors.alert);
|
||||
highlight_keywords(
|
||||
&mut map,
|
||||
text,
|
||||
&byte_to_col,
|
||||
row_i32,
|
||||
&["ALERT"],
|
||||
colors.alert,
|
||||
);
|
||||
|
||||
// ── 4. Warning keywords ────────────────────────────────
|
||||
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
|
||||
&["WARNING", "WARN"], colors.warning);
|
||||
highlight_keywords(
|
||||
&mut map,
|
||||
text,
|
||||
&byte_to_col,
|
||||
row_i32,
|
||||
&["WARNING", "WARN"],
|
||||
colors.warning,
|
||||
);
|
||||
|
||||
// ── 5. Info keywords ───────────────────────────────────
|
||||
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
|
||||
&["INFO", "INFORMATION", "NOTICE"], colors.info);
|
||||
highlight_keywords(
|
||||
&mut map,
|
||||
text,
|
||||
&byte_to_col,
|
||||
row_i32,
|
||||
&["INFO", "INFORMATION", "NOTICE"],
|
||||
colors.info,
|
||||
);
|
||||
|
||||
// ── 6. Debug keywords ──────────────────────────────────
|
||||
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
|
||||
&["DEBUG", "DBG", "TRACE"], colors.debug);
|
||||
highlight_keywords(
|
||||
&mut map,
|
||||
text,
|
||||
&byte_to_col,
|
||||
row_i32,
|
||||
&["DEBUG", "DBG", "TRACE"],
|
||||
colors.debug,
|
||||
);
|
||||
|
||||
// ── 7. Success status ──────────────────────────────────
|
||||
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
|
||||
&["SUCCESS", "SUCCEEDED", "SUCCESSFUL", "PASSED", "PASS",
|
||||
"OK", "DONE", "COMPLETED", "FINISHED", "COMPLETE"],
|
||||
highlight_keywords(
|
||||
&mut map,
|
||||
text,
|
||||
&byte_to_col,
|
||||
row_i32,
|
||||
&[
|
||||
"SUCCESS",
|
||||
"SUCCEEDED",
|
||||
"SUCCESSFUL",
|
||||
"PASSED",
|
||||
"PASS",
|
||||
"OK",
|
||||
"DONE",
|
||||
"COMPLETED",
|
||||
"FINISHED",
|
||||
"COMPLETE",
|
||||
],
|
||||
colors.success,
|
||||
);
|
||||
|
||||
// ── 8. Failure status ──────────────────────────────────
|
||||
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
|
||||
highlight_keywords(
|
||||
&mut map,
|
||||
text,
|
||||
&byte_to_col,
|
||||
row_i32,
|
||||
&["FAILED", "FAILURE", "FAIL", "NOT OK"],
|
||||
colors.failure,
|
||||
);
|
||||
|
||||
// ── 9. Pending / Waiting ───────────────────────────────
|
||||
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
|
||||
highlight_keywords(
|
||||
&mut map,
|
||||
text,
|
||||
&byte_to_col,
|
||||
row_i32,
|
||||
&["PENDING", "WAITING", "PROCESSING", "IN PROGRESS", "QUEUED"],
|
||||
colors.pending,
|
||||
);
|
||||
|
||||
// ── 10. Running / Active ───────────────────────────────
|
||||
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
|
||||
highlight_keywords(
|
||||
&mut map,
|
||||
text,
|
||||
&byte_to_col,
|
||||
row_i32,
|
||||
&["RUNNING", "ACTIVE", "EXECUTING", "IN_PROGRESS", "LIVE"],
|
||||
colors.running,
|
||||
);
|
||||
|
||||
// ── 11. Stopped / Inactive ─────────────────────────────
|
||||
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
|
||||
highlight_keywords(
|
||||
&mut map,
|
||||
text,
|
||||
&byte_to_col,
|
||||
row_i32,
|
||||
&["STOPPED", "INACTIVE", "HALTED", "IDLE", "PAUSED"],
|
||||
colors.stopped,
|
||||
);
|
||||
|
||||
// ── 12. Skipped ────────────────────────────────────────
|
||||
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
|
||||
&["SKIPPED", "SKIP", "SKIPPING"], colors.skipped);
|
||||
highlight_keywords(
|
||||
&mut map,
|
||||
text,
|
||||
&byte_to_col,
|
||||
row_i32,
|
||||
&["SKIPPED", "SKIP", "SKIPPING"],
|
||||
colors.skipped,
|
||||
);
|
||||
|
||||
// ── 13. Network UP ─────────────────────────────────────
|
||||
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
|
||||
&["UP", "ONLINE", "CONNECTED", "REACHABLE", "LISTENING",
|
||||
"ESTABLISHED", "LINK UP"],
|
||||
highlight_keywords(
|
||||
&mut map,
|
||||
text,
|
||||
&byte_to_col,
|
||||
row_i32,
|
||||
&[
|
||||
"UP",
|
||||
"ONLINE",
|
||||
"CONNECTED",
|
||||
"REACHABLE",
|
||||
"LISTENING",
|
||||
"ESTABLISHED",
|
||||
"LINK UP",
|
||||
],
|
||||
colors.network_up,
|
||||
);
|
||||
|
||||
// ── 14. Network DOWN ───────────────────────────────────
|
||||
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
|
||||
&["DOWN", "OFFLINE", "UNREACHABLE", "DISCONNECTED",
|
||||
"NOT LISTENING", "LINK DOWN", "NO CARRIER"],
|
||||
highlight_keywords(
|
||||
&mut map,
|
||||
text,
|
||||
&byte_to_col,
|
||||
row_i32,
|
||||
&[
|
||||
"DOWN",
|
||||
"OFFLINE",
|
||||
"UNREACHABLE",
|
||||
"DISCONNECTED",
|
||||
"NOT LISTENING",
|
||||
"LINK DOWN",
|
||||
"NO CARRIER",
|
||||
],
|
||||
colors.network_down,
|
||||
);
|
||||
|
||||
// ── 15. Timeout ────────────────────────────────────────
|
||||
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
|
||||
&["TIMEOUT", "TIMED OUT", "TIMEOUTS", "ETIMEDOUT", "SLOW", "LATENCY"],
|
||||
highlight_keywords(
|
||||
&mut map,
|
||||
text,
|
||||
&byte_to_col,
|
||||
row_i32,
|
||||
&[
|
||||
"TIMEOUT",
|
||||
"TIMED OUT",
|
||||
"TIMEOUTS",
|
||||
"ETIMEDOUT",
|
||||
"SLOW",
|
||||
"LATENCY",
|
||||
],
|
||||
colors.timeout,
|
||||
);
|
||||
|
||||
// ── 16. Refused / Denied ───────────────────────────────
|
||||
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
|
||||
&["REFUSED", "REJECTED", "DENIED", "PERMISSION DENIED",
|
||||
"ACCESS DENIED", "FORBIDDEN", "BLOCKED", "DROP"],
|
||||
highlight_keywords(
|
||||
&mut map,
|
||||
text,
|
||||
&byte_to_col,
|
||||
row_i32,
|
||||
&[
|
||||
"REFUSED",
|
||||
"REJECTED",
|
||||
"DENIED",
|
||||
"PERMISSION DENIED",
|
||||
"ACCESS DENIED",
|
||||
"FORBIDDEN",
|
||||
"BLOCKED",
|
||||
"DROP",
|
||||
],
|
||||
colors.refused,
|
||||
);
|
||||
|
||||
// ── 17. Security / Protocol ────────────────────────────
|
||||
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
|
||||
&["SSH", "SSHD", "SSL", "TLS", "HTTPS", "CERTIFICATE", "CERT",
|
||||
"FIREWALL", "IPTABLES", "ACL", "WAF"],
|
||||
highlight_keywords(
|
||||
&mut map,
|
||||
text,
|
||||
&byte_to_col,
|
||||
row_i32,
|
||||
&[
|
||||
"SSH",
|
||||
"SSHD",
|
||||
"SSL",
|
||||
"TLS",
|
||||
"HTTPS",
|
||||
"CERTIFICATE",
|
||||
"CERT",
|
||||
"FIREWALL",
|
||||
"IPTABLES",
|
||||
"ACL",
|
||||
"WAF",
|
||||
],
|
||||
colors.security,
|
||||
);
|
||||
|
||||
// ── 18. Authentication ─────────────────────────────────
|
||||
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
|
||||
&["AUTHENTICATED", "ACCEPTED", "AUTHORIZED", "LOGIN", "LOGOUT",
|
||||
"LOGGED IN", "LOGGED OUT", "SESSION"],
|
||||
highlight_keywords(
|
||||
&mut map,
|
||||
text,
|
||||
&byte_to_col,
|
||||
row_i32,
|
||||
&[
|
||||
"AUTHENTICATED",
|
||||
"ACCEPTED",
|
||||
"AUTHORIZED",
|
||||
"LOGIN",
|
||||
"LOGOUT",
|
||||
"LOGGED IN",
|
||||
"LOGGED OUT",
|
||||
"SESSION",
|
||||
],
|
||||
colors.auth,
|
||||
);
|
||||
|
||||
// ── 19. Danger (root/sudo/secrets) ─────────────────────
|
||||
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
|
||||
&["ROOT", "SUDO", "UID=0", "PASSWORD", "SECRET", "TOKEN",
|
||||
"API_KEY", "APIKEY", "PRIVATE KEY", "CREDENTIALS"],
|
||||
highlight_keywords(
|
||||
&mut map,
|
||||
text,
|
||||
&byte_to_col,
|
||||
row_i32,
|
||||
&[
|
||||
"ROOT",
|
||||
"SUDO",
|
||||
"UID=0",
|
||||
"PASSWORD",
|
||||
"SECRET",
|
||||
"TOKEN",
|
||||
"API_KEY",
|
||||
"APIKEY",
|
||||
"PRIVATE KEY",
|
||||
"CREDENTIALS",
|
||||
],
|
||||
colors.danger,
|
||||
);
|
||||
|
||||
// ── 20. Operations: Start ──────────────────────────────
|
||||
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
|
||||
&["STARTED", "START", "STARTING", "BOOT", "BOOTING", "LAUNCHED", "LAUNCH"],
|
||||
highlight_keywords(
|
||||
&mut map,
|
||||
text,
|
||||
&byte_to_col,
|
||||
row_i32,
|
||||
&[
|
||||
"STARTED", "START", "STARTING", "BOOT", "BOOTING", "LAUNCHED", "LAUNCH",
|
||||
],
|
||||
colors.started,
|
||||
);
|
||||
|
||||
// ── 21. Operations: Stop ───────────────────────────────
|
||||
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
|
||||
&["STOPPED", "STOP", "STOPPING", "SHUTDOWN", "SHUTTING DOWN", "TERMINATED"],
|
||||
highlight_keywords(
|
||||
&mut map,
|
||||
text,
|
||||
&byte_to_col,
|
||||
row_i32,
|
||||
&[
|
||||
"STOPPED",
|
||||
"STOP",
|
||||
"STOPPING",
|
||||
"SHUTDOWN",
|
||||
"SHUTTING DOWN",
|
||||
"TERMINATED",
|
||||
],
|
||||
colors.stopped_op,
|
||||
);
|
||||
|
||||
// ── 22. Operations: Restart ────────────────────────────
|
||||
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
|
||||
&["RESTARTED", "RESTART", "RESTARTING", "RELOAD", "RELOADED", "RELOADING"],
|
||||
highlight_keywords(
|
||||
&mut map,
|
||||
text,
|
||||
&byte_to_col,
|
||||
row_i32,
|
||||
&[
|
||||
"RESTARTED",
|
||||
"RESTART",
|
||||
"RESTARTING",
|
||||
"RELOAD",
|
||||
"RELOADED",
|
||||
"RELOADING",
|
||||
],
|
||||
colors.restart,
|
||||
);
|
||||
|
||||
// ── 23. Operations: Deploy ─────────────────────────────
|
||||
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
|
||||
&["DEPLOYED", "DEPLOYMENT", "DEPLOYING", "DEPLOY",
|
||||
"ROLLBACK", "ROLLED BACK", "ROLLING BACK", "UPGRADE", "UPGRADED"],
|
||||
highlight_keywords(
|
||||
&mut map,
|
||||
text,
|
||||
&byte_to_col,
|
||||
row_i32,
|
||||
&[
|
||||
"DEPLOYED",
|
||||
"DEPLOYMENT",
|
||||
"DEPLOYING",
|
||||
"DEPLOY",
|
||||
"ROLLBACK",
|
||||
"ROLLED BACK",
|
||||
"ROLLING BACK",
|
||||
"UPGRADE",
|
||||
"UPGRADED",
|
||||
],
|
||||
colors.deploy,
|
||||
);
|
||||
|
||||
// ── 24. Operations: Crash ──────────────────────────────
|
||||
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
|
||||
&["CRASH", "CRASHED", "CRASHING", "SIGSEGV", "SIGABRT", "SIGKILL",
|
||||
"DIED", "EXITED", "EXIT CODE", "CORE DUMP"],
|
||||
highlight_keywords(
|
||||
&mut map,
|
||||
text,
|
||||
&byte_to_col,
|
||||
row_i32,
|
||||
&[
|
||||
"CRASH",
|
||||
"CRASHED",
|
||||
"CRASHING",
|
||||
"SIGSEGV",
|
||||
"SIGABRT",
|
||||
"SIGKILL",
|
||||
"DIED",
|
||||
"EXITED",
|
||||
"EXIT CODE",
|
||||
"CORE DUMP",
|
||||
],
|
||||
colors.crashed,
|
||||
);
|
||||
|
||||
// ── 25. Resources: Memory ──────────────────────────────
|
||||
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
|
||||
highlight_keywords(
|
||||
&mut map,
|
||||
text,
|
||||
&byte_to_col,
|
||||
row_i32,
|
||||
&["MEMORY", "RAM", "HEAP", "STACK", "SWAP", "MEM"],
|
||||
colors.memory,
|
||||
);
|
||||
|
||||
// ── 26. Resources: CPU ─────────────────────────────────
|
||||
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
|
||||
&["CPU", "PROCESSOR", "CORE", "CORES", "THREAD", "THREADS", "LOAD"],
|
||||
highlight_keywords(
|
||||
&mut map,
|
||||
text,
|
||||
&byte_to_col,
|
||||
row_i32,
|
||||
&[
|
||||
"CPU",
|
||||
"PROCESSOR",
|
||||
"CORE",
|
||||
"CORES",
|
||||
"THREAD",
|
||||
"THREADS",
|
||||
"LOAD",
|
||||
],
|
||||
colors.cpu,
|
||||
);
|
||||
|
||||
// ── 27. Resources: Disk ────────────────────────────────
|
||||
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
|
||||
&["DISK", "STORAGE", "PARTITION", "MOUNT", "FILESYSTEM",
|
||||
"INODE", "IOPS", "READ", "WRITE"],
|
||||
highlight_keywords(
|
||||
&mut map,
|
||||
text,
|
||||
&byte_to_col,
|
||||
row_i32,
|
||||
&[
|
||||
"DISK",
|
||||
"STORAGE",
|
||||
"PARTITION",
|
||||
"MOUNT",
|
||||
"FILESYSTEM",
|
||||
"INODE",
|
||||
"IOPS",
|
||||
"READ",
|
||||
"WRITE",
|
||||
],
|
||||
colors.disk,
|
||||
);
|
||||
|
||||
// ── 28. Dev: Exceptions ────────────────────────────────
|
||||
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
|
||||
&["EXCEPTION", "TRACEBACK", "THROW", "THROWN", "STACKTRACE",
|
||||
"TYPEERROR", "VALUEERROR", "KEYERROR", "ATTRIBUTEERROR",
|
||||
"INDEXERROR", "RUNTIMEERROR", "IOERROR", "OSERROR",
|
||||
"NULLPOINTER", "NPE", "SEGFAULT"],
|
||||
highlight_keywords(
|
||||
&mut map,
|
||||
text,
|
||||
&byte_to_col,
|
||||
row_i32,
|
||||
&[
|
||||
"EXCEPTION",
|
||||
"TRACEBACK",
|
||||
"THROW",
|
||||
"THROWN",
|
||||
"STACKTRACE",
|
||||
"TYPEERROR",
|
||||
"VALUEERROR",
|
||||
"KEYERROR",
|
||||
"ATTRIBUTEERROR",
|
||||
"INDEXERROR",
|
||||
"RUNTIMEERROR",
|
||||
"IOERROR",
|
||||
"OSERROR",
|
||||
"NULLPOINTER",
|
||||
"NPE",
|
||||
"SEGFAULT",
|
||||
],
|
||||
colors.exception,
|
||||
);
|
||||
|
||||
// ── 29. Dev: Deprecated / TODO ─────────────────────────
|
||||
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
|
||||
&["DEPRECATED", "TODO", "FIXME", "HACK", "XXX", "WORKAROUND", "TEMPORARY"],
|
||||
highlight_keywords(
|
||||
&mut map,
|
||||
text,
|
||||
&byte_to_col,
|
||||
row_i32,
|
||||
&[
|
||||
"DEPRECATED",
|
||||
"TODO",
|
||||
"FIXME",
|
||||
"HACK",
|
||||
"XXX",
|
||||
"WORKAROUND",
|
||||
"TEMPORARY",
|
||||
],
|
||||
colors.deprecated,
|
||||
);
|
||||
|
||||
@@ -477,8 +785,6 @@ pub fn highlight_cells(
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ── 33. Port numbers ───────────────────────────────────
|
||||
for m in find_ports(text) {
|
||||
let port_len = find_port_len(&text[m..]);
|
||||
@@ -537,11 +843,7 @@ fn find_ip_len(text: &str) -> usize {
|
||||
len += 1;
|
||||
}
|
||||
|
||||
if dots == 3 && digits > 0 {
|
||||
len
|
||||
} else {
|
||||
0
|
||||
}
|
||||
if dots == 3 && digits > 0 { len } else { 0 }
|
||||
}
|
||||
|
||||
fn find_ip_addresses(text: &str) -> Vec<usize> {
|
||||
@@ -550,16 +852,12 @@ fn find_ip_addresses(text: &str) -> Vec<usize> {
|
||||
let len = bytes.len();
|
||||
|
||||
for i in 0..len {
|
||||
if bytes[i].is_ascii_digit()
|
||||
&& (i == 0 || is_boundary(bytes[i - 1] as char))
|
||||
{
|
||||
if bytes[i].is_ascii_digit() && (i == 0 || is_boundary(bytes[i - 1] as char)) {
|
||||
let remaining = &text[i..];
|
||||
let ip_len = find_ip_len(remaining);
|
||||
if ip_len > 0 {
|
||||
let ip_str = &remaining[..ip_len];
|
||||
let valid = ip_str
|
||||
.split('.')
|
||||
.all(|octet| octet.parse::<u8>().is_ok());
|
||||
let valid = ip_str.split('.').all(|octet| octet.parse::<u8>().is_ok());
|
||||
if valid {
|
||||
positions.push(i);
|
||||
}
|
||||
@@ -671,7 +969,9 @@ pub fn build_logical_lines<'a>(cells: &'a [RenderCell], rows: usize) -> Vec<Logi
|
||||
let wraps_from_prev = row_idx > 0 && {
|
||||
current_line.as_ref().map_or(false, |line| {
|
||||
line.row_cells.last().map_or(false, |rc| {
|
||||
rc.cell.flags.contains(alacritty_terminal::term::cell::Flags::WRAPLINE)
|
||||
rc.cell
|
||||
.flags
|
||||
.contains(alacritty_terminal::term::cell::Flags::WRAPLINE)
|
||||
})
|
||||
})
|
||||
};
|
||||
@@ -738,4 +1038,3 @@ pub fn find_url_at_cell(
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
|
||||
+15
-10
@@ -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);
|
||||
@@ -165,7 +165,7 @@ impl Ashell {
|
||||
return;
|
||||
};
|
||||
|
||||
if tab.render_snapshot().display_offset > 0 {
|
||||
if tab.render_snapshot(false).display_offset > 0 {
|
||||
tab.scroll_to_bottom();
|
||||
}
|
||||
tab.clear_selection();
|
||||
@@ -204,7 +204,7 @@ impl Ashell {
|
||||
return;
|
||||
};
|
||||
|
||||
if tab.render_snapshot().display_offset > 0 {
|
||||
if tab.render_snapshot(false).display_offset > 0 {
|
||||
tab.scroll_to_bottom();
|
||||
}
|
||||
|
||||
@@ -236,7 +236,7 @@ impl Ashell {
|
||||
return;
|
||||
};
|
||||
|
||||
if tab.render_snapshot().display_offset > 0 {
|
||||
if tab.render_snapshot(false).display_offset > 0 {
|
||||
tab.scroll_to_bottom();
|
||||
}
|
||||
tab.clear_selection();
|
||||
@@ -291,7 +291,7 @@ impl Ashell {
|
||||
return;
|
||||
};
|
||||
|
||||
if tab.render_snapshot().display_offset > 0 {
|
||||
if tab.render_snapshot(false).display_offset > 0 {
|
||||
tab.scroll_to_bottom();
|
||||
}
|
||||
tab.clear_selection();
|
||||
@@ -387,7 +387,12 @@ impl Ashell {
|
||||
if let Some((row, col, _side)) = self.terminal_grid_point_and_side(event.position) {
|
||||
if let Some(snapshot) = self.active_snapshot() {
|
||||
if let Some(active_id) = &self.active_tab {
|
||||
if let Some((url, url_cells)) = crate::terminal::highlight::find_url_at_cell(&snapshot.cells, snapshot.rows, row, col) {
|
||||
if let Some((url, url_cells)) = crate::terminal::highlight::find_url_at_cell(
|
||||
&snapshot.cells,
|
||||
snapshot.rows,
|
||||
row,
|
||||
col,
|
||||
) {
|
||||
hovered_url = Some(crate::app::HoveredUrl {
|
||||
url,
|
||||
tab_id: active_id.clone(),
|
||||
@@ -397,7 +402,7 @@ impl Ashell {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if self.hovered_url != hovered_url || self.cmd_ctrl_pressed != cmd_ctrl_pressed {
|
||||
self.hovered_url = hovered_url;
|
||||
self.cmd_ctrl_pressed = cmd_ctrl_pressed;
|
||||
|
||||
+72
-13
@@ -1,7 +1,7 @@
|
||||
pub mod custom_blocks;
|
||||
pub mod element;
|
||||
pub mod input;
|
||||
pub mod highlight;
|
||||
pub mod input;
|
||||
|
||||
use std::sync::mpsc::Sender;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -116,6 +121,7 @@ impl BackendTx {
|
||||
pub struct TerminalTab {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub dynamic_title: String,
|
||||
pub kind: TabKind,
|
||||
pub status: String,
|
||||
pub connected: bool,
|
||||
@@ -134,7 +140,12 @@ pub struct TerminalTab {
|
||||
pub rows: u16,
|
||||
pub backend: std::sync::Arc<std::sync::Mutex<BackendTx>>,
|
||||
pub scroll_pixel_y: f32,
|
||||
pub(crate) highlight_cache: std::cell::RefCell<Option<(Vec<RenderCell>, std::collections::HashMap<(i32, i32), gpui::Hsla>)>>,
|
||||
pub(crate) highlight_cache: std::cell::RefCell<
|
||||
Option<(
|
||||
Vec<RenderCell>,
|
||||
std::collections::HashMap<(i32, i32), gpui::Hsla>,
|
||||
)>,
|
||||
>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -222,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,
|
||||
@@ -233,7 +263,8 @@ impl TerminalTab {
|
||||
let shared_backend = std::sync::Arc::new(std::sync::Mutex::new(backend));
|
||||
Self {
|
||||
id: id.clone(),
|
||||
title,
|
||||
title: title.clone(),
|
||||
dynamic_title: title,
|
||||
kind,
|
||||
status,
|
||||
connected: matches!(kind, TabKind::Local),
|
||||
@@ -242,7 +273,7 @@ impl TerminalTab {
|
||||
backend_initialized: true,
|
||||
session: None,
|
||||
processor: Processor::new(),
|
||||
term: new_term(100, 30, shared_backend.clone(), id, events),
|
||||
term: new_term(100, 30, shared_backend.clone(), id, events.clone()),
|
||||
cols: 100,
|
||||
rows: 30,
|
||||
backend: shared_backend,
|
||||
@@ -312,7 +343,7 @@ impl TerminalTab {
|
||||
self.term.mode().contains(TermMode::APP_CURSOR)
|
||||
}
|
||||
|
||||
pub fn render_snapshot(&self) -> RenderSnapshot {
|
||||
pub fn render_snapshot(&self, keyword_highlight: bool) -> RenderSnapshot {
|
||||
let rows = self.rows;
|
||||
let cols = self.cols;
|
||||
let content = self.term.renderable_content();
|
||||
@@ -342,15 +373,13 @@ impl TerminalTab {
|
||||
}
|
||||
|
||||
// Get highlights from cache or recompute, only if keyword_highlight is enabled.
|
||||
let is_enabled = crate::session::config::ConfigStore::load()
|
||||
.map(|c| c.keyword_highlight())
|
||||
.unwrap_or(false);
|
||||
let is_enabled = keyword_highlight;
|
||||
|
||||
let highlights = if is_enabled {
|
||||
let mut cache = self.highlight_cache.borrow_mut();
|
||||
let cache_valid = cache.as_ref().is_some_and(|(cached_cells, _)| {
|
||||
cached_cells == &cells
|
||||
});
|
||||
let cache_valid = cache
|
||||
.as_ref()
|
||||
.is_some_and(|(cached_cells, _)| cached_cells == &cells);
|
||||
if cache_valid {
|
||||
cache.as_ref().unwrap().1.clone()
|
||||
} else {
|
||||
@@ -503,7 +532,10 @@ fn viewport_selection_from_range(
|
||||
} = selection.as_ref().copied()?;
|
||||
|
||||
let top_point = viewport_to_point(display_offset, Point::new(0, Column(0)));
|
||||
let bottom_point = viewport_to_point(display_offset, Point::new(rows.saturating_sub(1), Column(0)));
|
||||
let bottom_point = viewport_to_point(
|
||||
display_offset,
|
||||
Point::new(rows.saturating_sub(1), Column(0)),
|
||||
);
|
||||
|
||||
let top_line = top_point.line;
|
||||
let bottom_line = bottom_point.line;
|
||||
@@ -521,7 +553,10 @@ fn viewport_selection_from_range(
|
||||
} else if end.line > bottom_line {
|
||||
Point::new(rows.saturating_sub(1), Column(cols.saturating_sub(1)))
|
||||
} else {
|
||||
point_to_viewport(display_offset, end).unwrap_or(Point::new(rows.saturating_sub(1), Column(cols.saturating_sub(1))))
|
||||
point_to_viewport(display_offset, end).unwrap_or(Point::new(
|
||||
rows.saturating_sub(1),
|
||||
Column(cols.saturating_sub(1)),
|
||||
))
|
||||
};
|
||||
|
||||
Some(ViewportSelection {
|
||||
@@ -729,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 {
|
||||
@@ -747,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