mirror of
https://github.com/rust-kotlin/ashell.git
synced 2026-10-03 16:00:18 +00:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9163d3a970 | ||
|
|
d85c1f4f55 | ||
|
|
61e7fb7b0d | ||
|
|
1bb922f955 | ||
|
|
a06d1c668d | ||
|
|
3b9ee02cce | ||
|
|
e7ca7bdc23 | ||
|
|
84eccb5521 | ||
|
|
4da9949c84 | ||
|
|
85fb354f80 | ||
|
|
584f962298 | ||
|
|
a32fd9437d | ||
|
|
4c292c060c | ||
|
|
c0a954ea0c | ||
|
|
ab41ccbd6b | ||
|
|
43717e4378 | ||
|
|
31218f7a37 |
Generated
+95
-4
@@ -315,7 +315,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ashell"
|
||||
version = "0.4.9"
|
||||
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.9"
|
||||
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}"
|
||||
@@ -33,6 +38,8 @@ port: "Port"
|
||||
user: "User"
|
||||
password: "Password"
|
||||
key: "Key"
|
||||
ssh_config: "Config(~/.ssh/config)"
|
||||
ssh_config_empty: "No entries found in ~/.ssh/config"
|
||||
private_key_path: "Private Key Path"
|
||||
private_key_data: "Private Key Data"
|
||||
save_and_connect: "Save & Connect"
|
||||
@@ -132,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"
|
||||
@@ -211,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}"
|
||||
@@ -34,6 +39,8 @@ port: "端口 (Port)"
|
||||
user: "用户 (User)"
|
||||
password: "密码 (Password)"
|
||||
key: "密钥 (Key)"
|
||||
ssh_config: "配置(~/.ssh/config)"
|
||||
ssh_config_empty: "~/.ssh/config 中未找到条目"
|
||||
private_key_path: "私钥路径"
|
||||
private_key_data: "私钥内容"
|
||||
save_and_connect: "保存并连接"
|
||||
@@ -133,6 +140,8 @@ settings_copy: "复制选区"
|
||||
settings_paste: "粘贴剪贴板"
|
||||
edit_file: "编辑文件"
|
||||
edit_file_tooltip: "下载到临时目录,使用默认编辑器打开,并在保存后自动上传。"
|
||||
sync_cwd_tooltip: "从当前终端同步路径"
|
||||
sync_cwd: "同步路径"
|
||||
auto_saved_and_uploaded: "已自动保存并上传: %{base}"
|
||||
scroll: "滑动查看 ↓"
|
||||
clone: "克隆"
|
||||
@@ -213,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();
|
||||
}
|
||||
}
|
||||
|
||||
+768
-515
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
+261
-50
@@ -2,11 +2,11 @@ pub mod config_sync;
|
||||
pub mod constants;
|
||||
pub mod dialogs;
|
||||
pub mod keybinding_recorder;
|
||||
pub mod resizable;
|
||||
pub mod search;
|
||||
pub mod startup;
|
||||
pub mod theme;
|
||||
pub mod ui;
|
||||
pub mod resizable;
|
||||
|
||||
use std::{
|
||||
cell::{Cell, RefCell},
|
||||
@@ -17,6 +17,7 @@ use std::{
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use crate::app::resizable::ResizableState;
|
||||
use gpui::{
|
||||
AppContext as _, Bounds, Context, Entity, FocusHandle, Pixels, Point, SharedString, Size,
|
||||
UniformListScrollHandle, Window, point, px, size,
|
||||
@@ -26,12 +27,12 @@ use gpui_component::{
|
||||
input::{InputEvent, InputState},
|
||||
scroll::ScrollbarHandle,
|
||||
};
|
||||
use crate::app::resizable::ResizableState;
|
||||
use rust_i18n::t;
|
||||
use tokio::runtime::Runtime;
|
||||
|
||||
use crate::{
|
||||
session::config::{AuthMethod, ConfigStore},
|
||||
session::ssh_config::SshConfigEntry,
|
||||
system::{SystemSampler, SystemSnapshot},
|
||||
terminal::{self, BackendEvent, TabKind, TerminalTab},
|
||||
};
|
||||
@@ -211,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>,
|
||||
@@ -236,6 +239,8 @@ pub(crate) struct Ashell {
|
||||
pub(crate) sync_status: SharedString,
|
||||
pub(crate) sftp_path_input: Entity<InputState>,
|
||||
pub(crate) ssh_auth_method: AuthMethod,
|
||||
pub(crate) ssh_config_entries: Vec<SshConfigEntry>,
|
||||
pub(crate) ssh_config_selected: Option<usize>,
|
||||
pub(crate) editing_session_id: Option<String>,
|
||||
pub(crate) follow_system_theme: bool,
|
||||
pub(crate) theme_mode: ThemeMode,
|
||||
@@ -322,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)]
|
||||
@@ -398,16 +405,23 @@ impl Ashell {
|
||||
.placeholder("SSH private key passphrase (optional)")
|
||||
.masked(true)
|
||||
});
|
||||
let proxy_host_input = cx.new(|cx| InputState::new(window, cx).placeholder(t!("proxy_host").to_string()));
|
||||
let proxy_port_input = cx.new(|cx| InputState::new(window, cx).placeholder(t!("proxy_port").to_string()));
|
||||
let proxy_user_input = cx.new(|cx| InputState::new(window, cx).placeholder(t!("proxy_user").to_string()));
|
||||
let proxy_password_input = cx.new(|cx| InputState::new(window, cx).placeholder(t!("proxy_password").to_string()).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 =
|
||||
cx.new(|cx| InputState::new(window, cx).placeholder(t!("proxy_port").to_string()));
|
||||
let proxy_user_input =
|
||||
cx.new(|cx| InputState::new(window, cx).placeholder(t!("proxy_user").to_string()));
|
||||
let proxy_password_input = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.placeholder(t!("proxy_password").to_string())
|
||||
.masked(true)
|
||||
});
|
||||
let sftp_path_input = cx.new(|cx| InputState::new(window, cx).default_value("/"));
|
||||
let sftp_new_folder_input =
|
||||
cx.new(|cx| InputState::new(window, cx).placeholder(t!("new_folder").to_string()));
|
||||
let search_input = cx.new(|cx| {
|
||||
InputState::new(window, cx).placeholder(t!("search").to_string())
|
||||
});
|
||||
let search_input =
|
||||
cx.new(|cx| InputState::new(window, cx).placeholder(t!("search").to_string()));
|
||||
let config = ConfigStore::load().unwrap_or_else(|err| {
|
||||
tracing::warn!("failed to load config: {err:#}");
|
||||
ConfigStore::in_memory()
|
||||
@@ -420,7 +434,12 @@ impl Ashell {
|
||||
let global_proxy_port_input = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.placeholder(t!("proxy_port").to_string())
|
||||
.default_value(config.global_proxy_port().map(|p| p.to_string()).unwrap_or_default())
|
||||
.default_value(
|
||||
config
|
||||
.global_proxy_port()
|
||||
.map(|p| p.to_string())
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
});
|
||||
let global_proxy_user_input = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
@@ -496,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),
|
||||
@@ -574,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,
|
||||
@@ -599,6 +621,8 @@ impl Ashell {
|
||||
sync_status: t!("sync_not_run").into(),
|
||||
sftp_path_input,
|
||||
ssh_auth_method: AuthMethod::Password,
|
||||
ssh_config_entries: crate::session::ssh_config::parse_ssh_config().unwrap_or_default(),
|
||||
ssh_config_selected: None,
|
||||
editing_session_id: None,
|
||||
follow_system_theme,
|
||||
theme_mode,
|
||||
@@ -696,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);
|
||||
@@ -704,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>,
|
||||
@@ -762,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()
|
||||
@@ -781,19 +895,14 @@ impl Ashell {
|
||||
| crate::session::config::CursorStyle::BeamBlink
|
||||
);
|
||||
let now = std::time::Instant::now();
|
||||
let blink_due = is_blinking && now.duration_since(last_blink_time) >= std::time::Duration::from_millis(600);
|
||||
let blink_due = is_blinking
|
||||
&& now.duration_since(last_blink_time)
|
||||
>= 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()
|
||||
@@ -992,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) => {
|
||||
@@ -1127,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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1136,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);
|
||||
@@ -1171,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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1210,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.");
|
||||
@@ -1223,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 {
|
||||
@@ -1235,13 +1429,28 @@ impl Ashell {
|
||||
}
|
||||
}
|
||||
gpui::WindowBounds::Maximized(b) => {
|
||||
let mut restore_bounds = (b.origin.x.into(), b.origin.y.into(), b.size.width.into(), b.size.height.into());
|
||||
let mut restore_bounds = (
|
||||
b.origin.x.into(),
|
||||
b.origin.y.into(),
|
||||
b.size.width.into(),
|
||||
b.size.height.into(),
|
||||
);
|
||||
if let Some(existing_bounds) = config.window_bounds() {
|
||||
match existing_bounds {
|
||||
crate::session::config::SavedWindowBounds::Windowed { x, y, width, height } => {
|
||||
crate::session::config::SavedWindowBounds::Windowed {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
} => {
|
||||
restore_bounds = (*x, *y, *width, *height);
|
||||
}
|
||||
crate::session::config::SavedWindowBounds::Maximized { x, y, width, height } => {
|
||||
crate::session::config::SavedWindowBounds::Maximized {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
} => {
|
||||
restore_bounds = (*x, *y, *width, *height);
|
||||
}
|
||||
_ => {}
|
||||
@@ -1263,13 +1472,15 @@ impl Ashell {
|
||||
}
|
||||
}
|
||||
};
|
||||
let workspace_sizes: Vec<f32> = self.workspace_panels
|
||||
let workspace_sizes: Vec<f32> = self
|
||||
.workspace_panels
|
||||
.read(cx)
|
||||
.sizes()
|
||||
.iter()
|
||||
.map(|s| s.into())
|
||||
.collect();
|
||||
let mut body_sizes: Vec<f32> = self.body_panels
|
||||
let mut body_sizes: Vec<f32> = self
|
||||
.body_panels
|
||||
.read(cx)
|
||||
.sizes()
|
||||
.iter()
|
||||
|
||||
+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();
|
||||
}
|
||||
}
|
||||
+165
-86
@@ -9,12 +9,18 @@ use directories::BaseDirs;
|
||||
use russh::{
|
||||
ChannelMsg, Disconnect,
|
||||
client::{self, Handler},
|
||||
keys::{HashAlg, PrivateKey, decode_secret_key, key::PrivateKeyWithHashAlg, load_secret_key},
|
||||
keys::{PrivateKey, decode_secret_key, load_secret_key},
|
||||
};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use crate::{
|
||||
session::config::{AuthMethod, Session},
|
||||
session::{
|
||||
config::{AuthMethod, Session},
|
||||
ssh_keys::{
|
||||
authenticate_with_default_keys, normalize_inline_private_key, private_keys_with_algs,
|
||||
session_has_explicit_key,
|
||||
},
|
||||
},
|
||||
system::{SystemSnapshot, remote_snapshot_from_kv},
|
||||
terminal::{BackendCommand, BackendEvent, BackendTx},
|
||||
};
|
||||
@@ -219,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);
|
||||
@@ -230,12 +236,18 @@ async fn connect_and_authenticate(
|
||||
addr,
|
||||
session.user
|
||||
);
|
||||
let status_text = if let Some((ptype, phost, pport)) = crate::session::config::active_proxy(session) {
|
||||
let pport_val = pport.unwrap_or_else(|| if ptype == "http" { 8080 } else { 1080 });
|
||||
format!("connecting to {addr} via {} proxy {}:{}", ptype.to_uppercase(), phost, pport_val)
|
||||
} else {
|
||||
format!("opening tcp connection to {addr}")
|
||||
};
|
||||
let status_text =
|
||||
if let Some((ptype, phost, pport)) = crate::session::config::active_proxy(session) {
|
||||
let pport_val = pport.unwrap_or_else(|| if ptype == "http" { 8080 } else { 1080 });
|
||||
format!(
|
||||
"connecting to {addr} via {} proxy {}:{}",
|
||||
ptype.to_uppercase(),
|
||||
phost,
|
||||
pport_val
|
||||
)
|
||||
} else {
|
||||
format!("opening tcp connection to {addr}")
|
||||
};
|
||||
let _ = events.send(BackendEvent::Status {
|
||||
tab_id: tab_id.to_string(),
|
||||
text: status_text,
|
||||
@@ -267,7 +279,12 @@ async fn connect_and_authenticate(
|
||||
.context("password authentication failed")?
|
||||
}
|
||||
AuthMethod::Key => {
|
||||
let source = key_source_label(session);
|
||||
let has_explicit_key = session_has_explicit_key(session);
|
||||
let source = if has_explicit_key {
|
||||
key_source_label(session)
|
||||
} else {
|
||||
"~/.ssh/ default keys".to_string()
|
||||
};
|
||||
tracing::info!(
|
||||
"[ssh] sending key authentication for {}@{} (key source: {})",
|
||||
session.user,
|
||||
@@ -276,44 +293,143 @@ async fn connect_and_authenticate(
|
||||
);
|
||||
let _ = events.send(BackendEvent::Status {
|
||||
tab_id: tab_id.to_string(),
|
||||
text: format!("connected to {addr}, loading private key from {source}"),
|
||||
text: if has_explicit_key {
|
||||
format!("connected to {addr}, loading private key from {source}")
|
||||
} else {
|
||||
format!(
|
||||
"connected to {addr}, trying default keys from ~/.ssh/ for {}",
|
||||
session.user
|
||||
)
|
||||
},
|
||||
});
|
||||
let keypair = load_session_private_key(session)?;
|
||||
let algorithm = format!("{:?}", keypair.algorithm());
|
||||
let _ = events.send(BackendEvent::Status {
|
||||
tab_id: tab_id.to_string(),
|
||||
text: format!("private key loaded from {source}, algorithm {algorithm}, sending public key authentication for {}", session.user),
|
||||
});
|
||||
let keys = private_keys_with_algs(keypair).context("invalid private key")?;
|
||||
let mut success = false;
|
||||
for key in keys {
|
||||
match handle.authenticate_publickey(&session.user, key).await {
|
||||
Ok(true) => {
|
||||
success = true;
|
||||
break;
|
||||
}
|
||||
Ok(false) => {
|
||||
tracing::debug!("[ssh] public key auth failed with algorithm, trying next");
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!("[ssh] public key auth error: {:?}, trying next", e);
|
||||
continue;
|
||||
|
||||
let passphrase = session.passphrase.trim();
|
||||
let passphrase = (!passphrase.is_empty()).then_some(passphrase);
|
||||
|
||||
let success = if has_explicit_key {
|
||||
let keypair = load_session_private_key(session)?;
|
||||
let algorithm = format!("{:?}", keypair.algorithm());
|
||||
let _ = events.send(BackendEvent::Status {
|
||||
tab_id: tab_id.to_string(),
|
||||
text: format!("private key loaded from {source}, algorithm {algorithm}, sending public key authentication for {}", session.user),
|
||||
});
|
||||
let keys = private_keys_with_algs(keypair).context("invalid private key")?;
|
||||
let mut success = false;
|
||||
for key in keys {
|
||||
match handle.authenticate_publickey(&session.user, key).await {
|
||||
Ok(true) => {
|
||||
success = true;
|
||||
break;
|
||||
}
|
||||
Ok(false) => {
|
||||
tracing::debug!(
|
||||
"[ssh] public key auth failed with algorithm, trying next"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!("[ssh] public key auth error: {:?}, trying next", e);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !success {
|
||||
return Err(anyhow::anyhow!(
|
||||
"public key authentication failed for {}@{}:{} using {} ({})",
|
||||
session.user,
|
||||
session.host,
|
||||
session.port,
|
||||
source,
|
||||
algorithm
|
||||
));
|
||||
}
|
||||
if !success {
|
||||
return Err(anyhow::anyhow!(
|
||||
"public key authentication failed for {}@{}:{} using {} ({})",
|
||||
session.user,
|
||||
session.host,
|
||||
session.port,
|
||||
source,
|
||||
algorithm
|
||||
));
|
||||
}
|
||||
success
|
||||
} else {
|
||||
let success =
|
||||
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/",
|
||||
session.user,
|
||||
session.host,
|
||||
session.port
|
||||
));
|
||||
}
|
||||
success
|
||||
};
|
||||
success
|
||||
}
|
||||
AuthMethod::Config => {
|
||||
// SSH Config auth: try the identity file from config, or default keys
|
||||
let source = key_source_label(session);
|
||||
tracing::info!(
|
||||
"[ssh] sending ssh-config authentication for {}@{} (key source: {})",
|
||||
session.user,
|
||||
addr,
|
||||
source
|
||||
);
|
||||
let _ = events.send(BackendEvent::Status {
|
||||
tab_id: tab_id.to_string(),
|
||||
text: format!("connected to {addr}, loading private key from {source}"),
|
||||
});
|
||||
|
||||
// If an explicit key path is set from the SSH config IdentityFile, use it;
|
||||
// otherwise try default keys from ~/.ssh/
|
||||
// Note: for Config auth, we never use inline key content
|
||||
let has_explicit_key = !session.private_key_path.trim().is_empty();
|
||||
if has_explicit_key {
|
||||
let keypair = load_session_private_key(session)?;
|
||||
let algorithm = format!("{:?}", keypair.algorithm());
|
||||
let keys = private_keys_with_algs(keypair).context("invalid private key")?;
|
||||
let mut success = false;
|
||||
for key in keys {
|
||||
match handle.authenticate_publickey(&session.user, key).await {
|
||||
Ok(true) => {
|
||||
success = true;
|
||||
break;
|
||||
}
|
||||
Ok(false) => {
|
||||
continue;
|
||||
}
|
||||
Err(_) => {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !success {
|
||||
return Err(anyhow::anyhow!(
|
||||
"ssh-config key authentication failed for {}@{}:{} using {} ({})",
|
||||
session.user,
|
||||
session.host,
|
||||
session.port,
|
||||
source,
|
||||
algorithm
|
||||
));
|
||||
}
|
||||
success
|
||||
} else {
|
||||
let passphrase = session.passphrase.trim();
|
||||
let passphrase = (!passphrase.is_empty()).then_some(passphrase);
|
||||
let _ = events.send(BackendEvent::Status {
|
||||
tab_id: tab_id.to_string(),
|
||||
text: format!(
|
||||
"connected to {addr}, trying default keys from ~/.ssh/ for {}",
|
||||
session.user
|
||||
),
|
||||
});
|
||||
let success =
|
||||
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",
|
||||
session.user,
|
||||
session.host,
|
||||
session.port
|
||||
));
|
||||
}
|
||||
success
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if !authed {
|
||||
@@ -335,6 +451,10 @@ async fn connect_and_authenticate(
|
||||
session.port,
|
||||
key_source_label(session)
|
||||
),
|
||||
AuthMethod::Config => format!(
|
||||
"authentication failed: server rejected ssh-config authentication for {}@{}:{}",
|
||||
session.user, session.host, session.port
|
||||
),
|
||||
}
|
||||
));
|
||||
}
|
||||
@@ -387,47 +507,6 @@ fn load_session_private_key(session: &Session) -> Result<PrivateKey> {
|
||||
Err(anyhow!(errors.join("; ")))
|
||||
}
|
||||
|
||||
fn private_keys_with_algs(keypair: PrivateKey) -> Result<Vec<PrivateKeyWithHashAlg>> {
|
||||
let mut algs = Vec::new();
|
||||
let key_arc = Arc::new(keypair);
|
||||
|
||||
if key_arc.algorithm().is_rsa() {
|
||||
if let Ok(k) = PrivateKeyWithHashAlg::new(key_arc.clone(), Some(HashAlg::Sha512)) {
|
||||
algs.push(k);
|
||||
}
|
||||
if let Ok(k) = PrivateKeyWithHashAlg::new(key_arc.clone(), Some(HashAlg::Sha256)) {
|
||||
algs.push(k);
|
||||
}
|
||||
if let Ok(k) = PrivateKeyWithHashAlg::new(key_arc.clone(), None) {
|
||||
algs.push(k);
|
||||
}
|
||||
} else {
|
||||
if let Ok(k) = PrivateKeyWithHashAlg::new(key_arc.clone(), None) {
|
||||
algs.push(k);
|
||||
}
|
||||
}
|
||||
|
||||
if algs.is_empty() {
|
||||
return Err(anyhow!(
|
||||
"Failed to construct PrivateKeyWithHashAlg for any supported hash algorithm"
|
||||
));
|
||||
}
|
||||
|
||||
Ok(algs)
|
||||
}
|
||||
|
||||
fn normalize_inline_private_key(value: &str) -> String {
|
||||
let mut normalized = value
|
||||
.trim()
|
||||
.replace("\\r\\n", "\n")
|
||||
.replace("\\n", "\n")
|
||||
.replace("\r\n", "\n");
|
||||
if !normalized.ends_with('\n') {
|
||||
normalized.push('\n');
|
||||
}
|
||||
normalized
|
||||
}
|
||||
|
||||
fn expand_key_path(value: &str) -> Option<PathBuf> {
|
||||
if value.is_empty() {
|
||||
return None;
|
||||
|
||||
+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();
|
||||
|
||||
+507
-126
@@ -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;
|
||||
|
||||
@@ -10,6 +17,15 @@ use uuid::Uuid;
|
||||
pub enum AuthMethod {
|
||||
Password,
|
||||
Key,
|
||||
Config,
|
||||
}
|
||||
|
||||
fn default_protocol() -> String {
|
||||
"ssh".to_string()
|
||||
}
|
||||
|
||||
fn default_baud_rate() -> u32 {
|
||||
115200
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -40,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 {
|
||||
@@ -62,6 +82,8 @@ impl Session {
|
||||
proxy_port: None,
|
||||
proxy_user: String::new(),
|
||||
proxy_password: String::new(),
|
||||
protocol: "ssh".to_string(),
|
||||
baud_rate: 115200,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -316,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 {
|
||||
@@ -343,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 {
|
||||
@@ -438,7 +494,11 @@ impl ConfigStore {
|
||||
}
|
||||
|
||||
pub fn sync_s3_region(&self) -> &str {
|
||||
if self.cache.sync_s3_region.is_empty() { "us-east-1" } else { &self.cache.sync_s3_region }
|
||||
if self.cache.sync_s3_region.is_empty() {
|
||||
"us-east-1"
|
||||
} else {
|
||||
&self.cache.sync_s3_region
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sync_s3_bucket(&self) -> &str {
|
||||
@@ -446,7 +506,11 @@ impl ConfigStore {
|
||||
}
|
||||
|
||||
pub fn sync_s3_object_key(&self) -> &str {
|
||||
if self.cache.sync_s3_object_key.is_empty() { "ashell-sync.json" } else { &self.cache.sync_s3_object_key }
|
||||
if self.cache.sync_s3_object_key.is_empty() {
|
||||
"ashell-sync.json"
|
||||
} else {
|
||||
&self.cache.sync_s3_object_key
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_sync_connection(&mut self, endpoint: String, username: String) {
|
||||
@@ -749,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)]
|
||||
@@ -766,10 +890,14 @@ impl ConfigStore {
|
||||
}
|
||||
}
|
||||
|
||||
pub trait ProxyStream: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + Sync + 'static {}
|
||||
impl<T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + Sync + 'static> ProxyStream for T {}
|
||||
|
||||
use std::sync::OnceLock;
|
||||
pub trait ProxyStream:
|
||||
tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + Sync + 'static
|
||||
{
|
||||
}
|
||||
impl<T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + Sync + 'static> ProxyStream
|
||||
for T
|
||||
{
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EnvProxy {
|
||||
@@ -783,110 +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())
|
||||
}
|
||||
};
|
||||
|
||||
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,
|
||||
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(),
|
||||
)
|
||||
.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),
|
||||
(
|
||||
"none".to_string(),
|
||||
String::new(),
|
||||
None,
|
||||
String::new(),
|
||||
String::new(),
|
||||
)
|
||||
.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))
|
||||
}
|
||||
_ => {
|
||||
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?;
|
||||
Ok(Box::new(stream))
|
||||
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>)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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>)> {
|
||||
@@ -900,7 +1044,9 @@ pub fn active_proxy(session: &Session) -> Option<(String, String, Option<u16>)>
|
||||
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() {
|
||||
} 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(),
|
||||
@@ -918,13 +1064,248 @@ pub fn active_proxy(session: &Session) -> Option<(String, String, Option<u16>)>
|
||||
config.cache.global_proxy_password.clone(),
|
||||
)
|
||||
} else {
|
||||
("none".to_string(), String::new(), None, String::new(), String::new())
|
||||
(
|
||||
"none".to_string(),
|
||||
String::new(),
|
||||
None,
|
||||
String::new(),
|
||||
String::new(),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
if proxy_type != "none" && !proxy_host.is_empty() && proxy_port.is_some() {
|
||||
Some((proxy_type, proxy_host, proxy_port))
|
||||
} else {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
+290
-69
@@ -1,8 +1,10 @@
|
||||
pub mod config;
|
||||
pub mod ssh_config;
|
||||
pub mod ssh_keys;
|
||||
|
||||
use gpui::{
|
||||
AppContext as _, Context, Entity, KeyDownEvent, MouseButton, MouseDownEvent,
|
||||
MouseMoveEvent, SharedString, Window, px,
|
||||
AppContext as _, Context, Entity, KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent,
|
||||
SharedString, Window, px,
|
||||
};
|
||||
use gpui_component::{Theme, WindowExt as _, input::InputState};
|
||||
use rust_i18n::t;
|
||||
@@ -12,9 +14,7 @@ use self::config::{AuthMethod, Session};
|
||||
|
||||
use crate::{
|
||||
Ashell, PaneLayout, SelectorEntry, TabGroup,
|
||||
app::constants::{
|
||||
DEFAULT_COLS, DEFAULT_ROWS,
|
||||
},
|
||||
app::constants::{DEFAULT_COLS, DEFAULT_ROWS},
|
||||
backend::{local, ssh},
|
||||
terminal::{BackendCommand, RenderSnapshot, TabKind, TerminalTab},
|
||||
};
|
||||
@@ -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,13 +151,14 @@ impl Ashell {
|
||||
|
||||
let mut session = match self.ssh_auth_method {
|
||||
AuthMethod::Password => Session::password(host, port, user, password),
|
||||
AuthMethod::Key => {
|
||||
if key_path.is_empty() && key_inline.trim().is_empty() {
|
||||
self.status = "private key path or content is required".into();
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
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.
|
||||
let mut session =
|
||||
Session::key(host, port, user, key_path, String::new(), String::new());
|
||||
session.auth = AuthMethod::Config;
|
||||
session
|
||||
}
|
||||
};
|
||||
session.name = name;
|
||||
@@ -151,6 +201,8 @@ impl Ashell {
|
||||
pub(crate) fn reset_ssh_form(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
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);
|
||||
@@ -159,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);
|
||||
@@ -174,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);
|
||||
@@ -197,20 +251,44 @@ 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 {
|
||||
session.proxy_type.clone()
|
||||
};
|
||||
Self::set_input_value(&self.proxy_host_input, session.proxy_host.clone(), window, cx);
|
||||
Self::set_input_value(
|
||||
&self.proxy_host_input,
|
||||
session.proxy_host.clone(),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
Self::set_input_value(
|
||||
&self.proxy_port_input,
|
||||
session.proxy_port.map(|p| p.to_string()).unwrap_or_default(),
|
||||
session
|
||||
.proxy_port
|
||||
.map(|p| p.to_string())
|
||||
.unwrap_or_default(),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
Self::set_input_value(
|
||||
&self.proxy_user_input,
|
||||
session.proxy_user.clone(),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
Self::set_input_value(
|
||||
&self.proxy_password_input,
|
||||
session.proxy_password.clone(),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
Self::set_input_value(&self.proxy_user_input, session.proxy_user.clone(), window, cx);
|
||||
Self::set_input_value(&self.proxy_password_input, session.proxy_password.clone(), window, cx);
|
||||
}
|
||||
|
||||
pub(crate) fn pick_ssh_key_path(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
@@ -293,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();
|
||||
}
|
||||
@@ -303,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();
|
||||
@@ -319,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();
|
||||
@@ -330,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();
|
||||
}
|
||||
|
||||
@@ -343,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());
|
||||
@@ -362,9 +430,62 @@ impl Ashell {
|
||||
|
||||
pub(crate) fn set_ssh_auth_method(&mut self, method: AuthMethod, cx: &mut Context<Self>) {
|
||||
self.ssh_auth_method = method;
|
||||
if method == AuthMethod::Config {
|
||||
self.refresh_ssh_config();
|
||||
self.ssh_config_selected = None;
|
||||
}
|
||||
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();
|
||||
}
|
||||
|
||||
pub(crate) fn select_ssh_config_entry(
|
||||
&mut self,
|
||||
index: usize,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.ssh_config_selected = Some(index);
|
||||
if let Some(entry) = self.ssh_config_entries.get(index) {
|
||||
Self::set_input_value(
|
||||
&self.session_name_input,
|
||||
entry.host_alias.clone(),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
Self::set_input_value(&self.host_input, entry.hostname.clone(), window, cx);
|
||||
Self::set_input_value(&self.port_input, entry.port.to_string(), window, cx);
|
||||
// If no user specified in config, use current system user
|
||||
let user = if entry.user.is_empty() {
|
||||
std::env::var("USER")
|
||||
.or_else(|_| std::env::var("USERNAME"))
|
||||
.unwrap_or_else(|_| "root".to_string())
|
||||
} else {
|
||||
entry.user.clone()
|
||||
};
|
||||
Self::set_input_value(&self.user_input, user, window, cx);
|
||||
Self::set_input_value(
|
||||
&self.key_path_input,
|
||||
entry.identity_files.first().cloned().unwrap_or_default(),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
Self::set_input_value(&self.password_input, String::new(), window, cx);
|
||||
Self::set_input_value(&self.key_inline_input, String::new(), window, cx);
|
||||
Self::set_input_value(&self.passphrase_input, String::new(), window, cx);
|
||||
// Auto-connect on selection
|
||||
self.connect_ssh(window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn set_ssh_proxy_type(&mut self, proxy_type: String, cx: &mut Context<Self>) {
|
||||
self.ssh_proxy_type = proxy_type;
|
||||
cx.notify();
|
||||
@@ -380,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> {
|
||||
@@ -545,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() {
|
||||
@@ -578,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;
|
||||
@@ -612,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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -715,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()
|
||||
@@ -901,7 +1096,12 @@ impl Ashell {
|
||||
if event.modifiers.platform {
|
||||
if let Some((row, col, _side)) = self.terminal_grid_point_and_side(event.position) {
|
||||
if let Some(snapshot) = self.active_snapshot() {
|
||||
if let Some((url, _)) = crate::terminal::highlight::find_url_at_cell(&snapshot.cells, snapshot.rows, row, col) {
|
||||
if let Some((url, _)) = crate::terminal::highlight::find_url_at_cell(
|
||||
&snapshot.cells,
|
||||
snapshot.rows,
|
||||
row,
|
||||
col,
|
||||
) {
|
||||
let _ = open::that(&url);
|
||||
return;
|
||||
}
|
||||
@@ -913,7 +1113,8 @@ impl Ashell {
|
||||
if !text.is_empty() {
|
||||
cx.write_to_clipboard(gpui::ClipboardItem::new_string(text));
|
||||
if let Some(active_id) = &self.active_tab {
|
||||
if let Some(tab) = self.tabs.iter_mut().find(|tab| &tab.id == active_id) {
|
||||
if let Some(tab) = self.tabs.iter_mut().find(|tab| &tab.id == active_id)
|
||||
{
|
||||
tab.clear_selection();
|
||||
}
|
||||
}
|
||||
@@ -929,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> {
|
||||
@@ -965,13 +1166,13 @@ 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>) {
|
||||
tracing::info!(
|
||||
"[split] direction={} pane_root={:?} focused_path={:?} active_tab={:?} tabs={}",
|
||||
@@ -1035,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
|
||||
@@ -1086,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;
|
||||
}
|
||||
@@ -1105,6 +1325,7 @@ impl Ashell {
|
||||
self.search_target_tab = None;
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use directories::BaseDirs;
|
||||
|
||||
/// A parsed entry from ~/.ssh/config
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SshConfigEntry {
|
||||
/// The Host pattern (alias) from the config, e.g. "myserver"
|
||||
pub host_alias: String,
|
||||
/// The actual hostname (HostName), defaults to the host alias if not specified
|
||||
pub hostname: String,
|
||||
/// The user, defaults to empty (will use current OS user)
|
||||
pub user: String,
|
||||
/// The port, defaults to 22
|
||||
pub port: u16,
|
||||
/// Identity files specified for this host
|
||||
pub identity_files: Vec<String>,
|
||||
/// Whether this is a wildcard/pattern host (Host * or Host *)
|
||||
pub is_wildcard: bool,
|
||||
}
|
||||
|
||||
/// Parse ~/.ssh/config and return a list of concrete host entries.
|
||||
/// Wildcard patterns (Host *) are excluded.
|
||||
pub fn parse_ssh_config() -> Result<Vec<SshConfigEntry>> {
|
||||
let config_path = ssh_config_path()?;
|
||||
if !config_path.is_file() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(&config_path)
|
||||
.with_context(|| format!("failed to read {}", config_path.display()))?;
|
||||
|
||||
parse_ssh_config_content(&content)
|
||||
}
|
||||
|
||||
fn ssh_config_path() -> Result<PathBuf> {
|
||||
BaseDirs::new()
|
||||
.map(|dirs| dirs.home_dir().join(".ssh/config"))
|
||||
.context("failed to determine home directory")
|
||||
}
|
||||
|
||||
/// Parse the content of an ssh config file into entries.
|
||||
pub fn parse_ssh_config_content(content: &str) -> Result<Vec<SshConfigEntry>> {
|
||||
let mut entries: Vec<SshConfigEntry> = Vec::new();
|
||||
let mut current_host: Option<SshConfigEntry> = None;
|
||||
|
||||
for line in content.lines() {
|
||||
let line = line.trim();
|
||||
|
||||
// Skip empty lines and comments
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Split into keyword and value
|
||||
// OpenSSH supports both "keyword value" and "keyword=value" formats
|
||||
let (keyword, value) = if let Some(pos) = line.find('=') {
|
||||
(&line[..pos], line[pos + 1..].trim())
|
||||
} else if let Some(pos) = line.find(' ') {
|
||||
(&line[..pos], line[pos..].trim())
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let keyword_lower = keyword.trim().to_lowercase();
|
||||
let value = value.trim();
|
||||
|
||||
match keyword_lower.as_str() {
|
||||
"host" => {
|
||||
// Save previous entry if it exists and is not a wildcard
|
||||
if let Some(entry) = current_host.take() {
|
||||
if !entry.is_wildcard {
|
||||
entries.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
// Host line may contain multiple patterns (Host a b c)
|
||||
// Take the first non-wildcard pattern as the display alias
|
||||
let patterns: Vec<&str> = value.split_whitespace().collect();
|
||||
if patterns.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let host_alias = patterns
|
||||
.iter()
|
||||
.find(|p| !p.contains('*') && !p.contains('?'))
|
||||
.map(|p| p.to_string())
|
||||
.unwrap_or_else(|| patterns[0].to_string());
|
||||
|
||||
let is_wildcard = value.contains('*') || value.contains('?');
|
||||
|
||||
current_host = Some(SshConfigEntry {
|
||||
host_alias: host_alias.clone(),
|
||||
hostname: host_alias,
|
||||
user: String::new(),
|
||||
port: 22,
|
||||
identity_files: Vec::new(),
|
||||
is_wildcard,
|
||||
});
|
||||
}
|
||||
"hostname" => {
|
||||
if let Some(entry) = current_host.as_mut() {
|
||||
entry.hostname = value.to_string();
|
||||
}
|
||||
}
|
||||
"user" => {
|
||||
if let Some(entry) = current_host.as_mut() {
|
||||
entry.user = value.to_string();
|
||||
}
|
||||
}
|
||||
"port" => {
|
||||
if let Some(entry) = current_host.as_mut() {
|
||||
entry.port = value.parse::<u16>().unwrap_or(22);
|
||||
}
|
||||
}
|
||||
"identityfile" => {
|
||||
if let Some(entry) = current_host.as_mut() {
|
||||
entry.identity_files.push(value.to_string());
|
||||
}
|
||||
}
|
||||
// Skip Match blocks and Include directives — not supported yet
|
||||
"match" | "include" => {
|
||||
// If we encounter a Match block, flush the current Host entry
|
||||
// since Match blocks apply to all subsequent hosts until the next Match/Host
|
||||
if let Some(entry) = current_host.take() {
|
||||
if !entry.is_wildcard {
|
||||
entries.push(entry);
|
||||
}
|
||||
}
|
||||
// Don't create a new entry for Match/Include
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Save the last entry if it's not a wildcard
|
||||
if let Some(entry) = current_host.take() {
|
||||
if !entry.is_wildcard {
|
||||
entries.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_ssh_config_content() {
|
||||
let content = r#"
|
||||
Host myhost
|
||||
HostName 1.2.3.4
|
||||
User git
|
||||
Port 2222
|
||||
IdentityFile ~/.ssh/id_rsa
|
||||
|
||||
Host
|
||||
Host =
|
||||
|
||||
Host anotherhost
|
||||
HostName 5.6.7.8
|
||||
"#;
|
||||
|
||||
let entries = parse_ssh_config_content(content).unwrap();
|
||||
assert_eq!(entries.len(), 2);
|
||||
|
||||
assert_eq!(entries[0].host_alias, "myhost");
|
||||
assert_eq!(entries[0].hostname, "1.2.3.4");
|
||||
assert_eq!(entries[0].user, "git");
|
||||
assert_eq!(entries[0].port, 2222);
|
||||
assert_eq!(entries[0].identity_files, vec!["~/.ssh/id_rsa".to_string()]);
|
||||
|
||||
assert_eq!(entries[1].host_alias, "anotherhost");
|
||||
assert_eq!(entries[1].hostname, "5.6.7.8");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
use directories::BaseDirs;
|
||||
use russh::{
|
||||
client::{self, Handler},
|
||||
keys::{HashAlg, PrivateKey, key::PrivateKeyWithHashAlg, load_secret_key},
|
||||
};
|
||||
|
||||
use crate::session::config::Session;
|
||||
|
||||
pub const DEFAULT_KEY_NAMES: &[&str] = &["id_ed25519", "id_rsa", "id_ecdsa", "id_dsa"];
|
||||
|
||||
pub fn session_has_explicit_key(session: &Session) -> bool {
|
||||
!session.private_key_path.trim().is_empty()
|
||||
|| !normalize_inline_private_key(&session.private_key_inline).is_empty()
|
||||
}
|
||||
|
||||
pub fn normalize_inline_private_key(value: &str) -> String {
|
||||
let mut normalized = value
|
||||
.trim()
|
||||
.replace("\\r\\n", "\n")
|
||||
.replace("\\n", "\n")
|
||||
.replace("\r\n", "\n");
|
||||
if normalized.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
if !normalized.ends_with('\n') {
|
||||
normalized.push('\n');
|
||||
}
|
||||
normalized
|
||||
}
|
||||
|
||||
pub fn private_keys_with_algs(keypair: PrivateKey) -> Result<Vec<PrivateKeyWithHashAlg>> {
|
||||
let mut algs = Vec::new();
|
||||
let key_arc = Arc::new(keypair);
|
||||
|
||||
if key_arc.algorithm().is_rsa() {
|
||||
if let Ok(k) = PrivateKeyWithHashAlg::new(key_arc.clone(), Some(HashAlg::Sha512)) {
|
||||
algs.push(k);
|
||||
}
|
||||
if let Ok(k) = PrivateKeyWithHashAlg::new(key_arc.clone(), Some(HashAlg::Sha256)) {
|
||||
algs.push(k);
|
||||
}
|
||||
if let Ok(k) = PrivateKeyWithHashAlg::new(key_arc.clone(), None) {
|
||||
algs.push(k);
|
||||
}
|
||||
} else if let Ok(k) = PrivateKeyWithHashAlg::new(key_arc.clone(), None) {
|
||||
algs.push(k);
|
||||
}
|
||||
|
||||
if algs.is_empty() {
|
||||
return Err(anyhow!(
|
||||
"Failed to construct PrivateKeyWithHashAlg for any supported hash algorithm"
|
||||
));
|
||||
}
|
||||
|
||||
Ok(algs)
|
||||
}
|
||||
|
||||
pub async fn authenticate_with_default_keys<H>(
|
||||
handle: &mut client::Handle<H>,
|
||||
user: &str,
|
||||
passphrase: Option<&str>,
|
||||
) -> Result<bool>
|
||||
where
|
||||
H: Handler + Send + Sync,
|
||||
H::Error: Into<anyhow::Error>,
|
||||
{
|
||||
let Some(ssh_dir) = BaseDirs::new().map(|d| d.home_dir().join(".ssh")) else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
for key_name in DEFAULT_KEY_NAMES {
|
||||
let key_path = ssh_dir.join(key_name);
|
||||
if !key_path.exists() {
|
||||
continue;
|
||||
}
|
||||
tracing::debug!("[ssh] trying default key {}", key_path.display());
|
||||
match load_secret_key(&key_path, passphrase) {
|
||||
Ok(keypair) => {
|
||||
if let Ok(keys) = private_keys_with_algs(keypair) {
|
||||
for key in keys {
|
||||
match handle.authenticate_publickey(user, key).await {
|
||||
Ok(true) => return Ok(true),
|
||||
Ok(false) | Err(_) => continue,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
+109
-73
@@ -14,7 +14,7 @@ use flate2::read::GzDecoder;
|
||||
use russh::{
|
||||
Disconnect,
|
||||
client::{self, Handler},
|
||||
keys::{HashAlg, PrivateKey, decode_secret_key, key::PrivateKeyWithHashAlg, load_secret_key},
|
||||
keys::{PrivateKey, decode_secret_key, load_secret_key},
|
||||
};
|
||||
use russh_sftp::client::SftpSession;
|
||||
use tokio::{
|
||||
@@ -29,7 +29,13 @@ use zip::read::ZipArchive;
|
||||
use rust_i18n::t;
|
||||
|
||||
use crate::{
|
||||
session::config::{AuthMethod, Session},
|
||||
session::{
|
||||
config::{AuthMethod, Session},
|
||||
ssh_keys::{
|
||||
authenticate_with_default_keys, normalize_inline_private_key, private_keys_with_algs,
|
||||
session_has_explicit_key,
|
||||
},
|
||||
},
|
||||
terminal::BackendEvent,
|
||||
};
|
||||
|
||||
@@ -827,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);
|
||||
@@ -844,37 +850,107 @@ async fn connect_and_authenticate(
|
||||
.await
|
||||
.context("password authentication failed")?,
|
||||
AuthMethod::Key => {
|
||||
let keypair = load_session_private_key(session)?;
|
||||
let keys = private_keys_with_algs(keypair).context("invalid private key")?;
|
||||
let mut success = false;
|
||||
for key in keys {
|
||||
match handle.authenticate_publickey(&session.user, key).await {
|
||||
Ok(true) => {
|
||||
success = true;
|
||||
break;
|
||||
}
|
||||
Ok(false) => {
|
||||
tracing::debug!(
|
||||
"[sftp] public key auth failed with algorithm, trying next"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!("[sftp] public key auth error: {:?}, trying next", e);
|
||||
continue;
|
||||
let has_explicit_key = session_has_explicit_key(session);
|
||||
let success = if has_explicit_key {
|
||||
let keypair = load_session_private_key(session)?;
|
||||
let keys = private_keys_with_algs(keypair).context("invalid private key")?;
|
||||
let mut success = false;
|
||||
for key in keys {
|
||||
match handle.authenticate_publickey(&session.user, key).await {
|
||||
Ok(true) => {
|
||||
success = true;
|
||||
break;
|
||||
}
|
||||
Ok(false) => {
|
||||
tracing::debug!(
|
||||
"[sftp] public key auth failed with algorithm, trying next"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!("[sftp] public key auth error: {:?}, trying next", e);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !success {
|
||||
return Err(anyhow!(
|
||||
"public key authentication failed for {}@{}:{}",
|
||||
session.user,
|
||||
session.host,
|
||||
session.port
|
||||
));
|
||||
}
|
||||
if !success {
|
||||
return Err(anyhow!(
|
||||
"public key authentication failed for {}@{}:{}",
|
||||
session.user,
|
||||
session.host,
|
||||
session.port
|
||||
));
|
||||
}
|
||||
success
|
||||
} else {
|
||||
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?;
|
||||
if !success {
|
||||
return Err(anyhow!(
|
||||
"public key authentication failed for {}@{}:{} - no valid default key found in ~/.ssh/",
|
||||
session.user,
|
||||
session.host,
|
||||
session.port
|
||||
));
|
||||
}
|
||||
success
|
||||
};
|
||||
success
|
||||
}
|
||||
AuthMethod::Config => {
|
||||
// For Config auth, try the identity file from config entry, or default keys
|
||||
// Note: for Config auth, we never use inline key content
|
||||
let has_explicit_key = !session.private_key_path.trim().is_empty();
|
||||
|
||||
if has_explicit_key {
|
||||
let keypair = load_session_private_key(session)?;
|
||||
let keys = private_keys_with_algs(keypair).context("invalid private key")?;
|
||||
let mut success = false;
|
||||
for key in keys {
|
||||
match handle.authenticate_publickey(&session.user, key).await {
|
||||
Ok(true) => {
|
||||
success = true;
|
||||
break;
|
||||
}
|
||||
Ok(false) => {
|
||||
tracing::debug!(
|
||||
"[sftp] public key auth failed with algorithm, trying next"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!("[sftp] public key auth error: {:?}, trying next", e);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !success {
|
||||
return Err(anyhow!(
|
||||
"ssh-config key authentication failed for {}@{}:{}",
|
||||
session.user,
|
||||
session.host,
|
||||
session.port
|
||||
));
|
||||
}
|
||||
success
|
||||
} else {
|
||||
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?;
|
||||
if !success {
|
||||
return Err(anyhow!(
|
||||
"ssh-config authentication failed for {}@{}:{} - no valid default key found",
|
||||
session.user,
|
||||
session.host,
|
||||
session.port
|
||||
));
|
||||
}
|
||||
success
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if !authed {
|
||||
@@ -886,6 +962,7 @@ async fn connect_and_authenticate(
|
||||
match session.auth {
|
||||
AuthMethod::Password => "password",
|
||||
AuthMethod::Key => "public key",
|
||||
AuthMethod::Config => "ssh-config",
|
||||
},
|
||||
session.user,
|
||||
session.host,
|
||||
@@ -927,47 +1004,6 @@ fn load_session_private_key(session: &Session) -> Result<PrivateKey> {
|
||||
Err(anyhow!(errors.join("; ")))
|
||||
}
|
||||
|
||||
fn private_keys_with_algs(keypair: PrivateKey) -> Result<Vec<PrivateKeyWithHashAlg>> {
|
||||
let mut algs = Vec::new();
|
||||
let key_arc = Arc::new(keypair);
|
||||
|
||||
if key_arc.algorithm().is_rsa() {
|
||||
if let Ok(k) = PrivateKeyWithHashAlg::new(key_arc.clone(), Some(HashAlg::Sha512)) {
|
||||
algs.push(k);
|
||||
}
|
||||
if let Ok(k) = PrivateKeyWithHashAlg::new(key_arc.clone(), Some(HashAlg::Sha256)) {
|
||||
algs.push(k);
|
||||
}
|
||||
if let Ok(k) = PrivateKeyWithHashAlg::new(key_arc.clone(), None) {
|
||||
algs.push(k);
|
||||
}
|
||||
} else {
|
||||
if let Ok(k) = PrivateKeyWithHashAlg::new(key_arc.clone(), None) {
|
||||
algs.push(k);
|
||||
}
|
||||
}
|
||||
|
||||
if algs.is_empty() {
|
||||
return Err(anyhow!(
|
||||
"Failed to construct PrivateKeyWithHashAlg for any supported hash algorithm"
|
||||
));
|
||||
}
|
||||
|
||||
Ok(algs)
|
||||
}
|
||||
|
||||
fn normalize_inline_private_key(value: &str) -> String {
|
||||
let mut normalized = value
|
||||
.trim()
|
||||
.replace("\\r\\n", "\n")
|
||||
.replace("\\n", "\n")
|
||||
.replace("\r\n", "\n");
|
||||
if !normalized.ends_with('\n') {
|
||||
normalized.push('\n');
|
||||
}
|
||||
normalized
|
||||
}
|
||||
|
||||
fn expand_key_path(value: &str) -> Option<PathBuf> {
|
||||
if value.is_empty() {
|
||||
return None;
|
||||
|
||||
+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;
|
||||
|
||||
+48
-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 {
|
||||
|
||||
Reference in New Issue
Block a user