Compare commits

..
Author SHA1 Message Date
TomZz 44348d4d75 feat: support mapping F1-F12 keys to ANSI escape sequences (resolves #91) 2026-07-23 00:26:28 +08:00
TomZz 9163d3a970 bump version to 0.4.12 2026-07-23 00:12:10 +08:00
TomZz d85c1f4f55 perf: fix sluggishness when switching focus between panes 2026-07-23 00:06:54 +08:00
TomZz 61e7fb7b0d feat: optimize ssh keepalive timeout settings (fixes #86) 2026-07-22 23:58:17 +08:00
TomZz 1bb922f955 feat: add local configuration export and import settings 2026-07-22 23:58:17 +08:00
TomZz a06d1c668d feat: support unbinding shortcuts by pressing Backspace 2026-07-22 23:58:17 +08:00
TomZz 3b9ee02cce feat: fix Windows flashing, UI thread blocking, and idle redraws (fixes #90) 2026-07-22 23:58:17 +08:00
TomZz e7ca7bdc23 feat: add support for serial port connections with virtual PTY fallback and automatic newline translation 2026-07-20 17:06:21 +08:00
TomZz 84eccb5521 bump version to 0.4.11 2026-07-15 03:24:39 +08:00
TomZz 4da9949c84 perf: avoid reading and decrypting config file from disk repeatedly on every render frame 2026-07-15 03:23:36 +08:00
TomZz 85fb354f80 fix: add a 16-second connection timeout to tcp and proxy connections 2026-07-15 03:23:36 +08:00
TomZz 584f962298 feat: implement manual path sync from terminal to file manager (fixes #83) 2026-07-15 03:23:36 +08:00
TomZz a32fd9437d style: run cargo fmt 2026-07-15 02:26:16 +08:00
TomZz 4c292c060c fix: clear connection_progress on tab close to resolve stuck modal (fixes #82) 2026-07-15 02:20:08 +08:00
TomZz c0a954ea0c feat: implement hardware-encrypted configuration file 2026-07-15 02:20:06 +08:00
TomZz ab41ccbd6b bump version to 0.4.10 2026-07-08 19:55:29 +08:00
TomZz 43717e4378 fix(ssh): improve ssh config parser robustness and revert gitignore changes 2026-07-08 19:51:40 +08:00
tg.shi 31218f7a37 feat(ssh): connect via ~/.ssh/config entries
Add AuthMethod::Config: parse ~/.ssh/config and list concrete host
entries in the connection dialog. Selecting an entry pre-fills
host/port/user/identity file and connects.

- Extract shared key utilities into session::ssh_keys
  (private_keys_with_algs, normalize_inline_private_key,
  authenticate_with_default_keys, session_has_explicit_key), reused by
  backend::ssh and sftp to remove duplication.
- Add session::ssh_config parser for ~/.ssh/config (Host/HostName/User/
  Port/IdentityFile), skipping wildcard patterns and Match/Include.
- Key auth now falls back to default keys (~/.ssh/id_*) when no explicit
  key path or inline content is provided.
- Preserve escaped \r\n normalization in normalize_inline_private_key
  when refactoring into the shared module.
2026-07-08 11:50:51 +08:00
TomZz af5c2831f6 fix: remove redundant window drag area to fix window control overlay (resolves #73) 2026-07-04 12:11:42 +08:00
27 changed files with 3585 additions and 1277 deletions
Generated
+95 -4
View File
@@ -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
View File
@@ -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
View File
@@ -13,6 +13,11 @@ size: "SIZE"
modified: "MODIFIED"
new_ssh_connection: "New SSH Connection"
create_or_edit_ssh_session: "Create or edit an SSH session"
new_serial_connection: "New Serial Connection"
create_or_edit_serial_session: "Create or edit a Serial session"
serial_port: "Serial Port Path"
baud_rate: "Baud Rate"
session_name: "Session Name"
new_folder: "New Folder"
create_folder_success: "Created folder: %{name}"
create_folder_failed: "Failed to create folder: %{err}"
@@ -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
View File
@@ -13,6 +13,11 @@ size: "大小"
modified: "修改时间"
new_ssh_connection: "新建 SSH 连接"
create_or_edit_ssh_session: "创建或编辑一个 SSH 会话"
new_serial_connection: "新建串口连接"
create_or_edit_serial_session: "创建或编辑串口会话"
serial_port: "串口设备路径"
baud_rate: "波特率"
session_name: "会话名称"
new_folder: "新建文件夹"
create_folder_success: "成功创建文件夹: %{name}"
create_folder_failed: "创建文件夹失败: %{err}"
@@ -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: "导入配置"
+69
View File
@@ -126,4 +126,73 @@ impl Ashell {
let _ = events.send(BackendEvent::SyncFinished(result));
});
}
pub(crate) fn export_local_config(
&mut self,
window: &mut gpui::Window,
cx: &mut Context<Self>,
) {
let local_config = self.config.cache.clone();
let file_dialog = rfd::AsyncFileDialog::new()
.set_file_name("ashell-config.json")
.add_filter("JSON", &["json"])
.save_file();
cx.spawn_in(window, async move |_this, cx| {
if let Some(file_handle) = file_dialog.await {
let path = file_handle.path().to_path_buf();
if let Ok(json_str) = serde_json::to_string_pretty(&local_config) {
let _ = cx
.background_executor()
.spawn(async move {
if let Err(err) = std::fs::write(path, json_str) {
tracing::error!("failed to export local config: {err:#}");
}
})
.await;
}
}
Ok::<(), anyhow::Error>(())
})
.detach();
}
pub(crate) fn import_local_config(
&mut self,
window: &mut gpui::Window,
cx: &mut Context<Self>,
) {
let file_dialog = rfd::AsyncFileDialog::new()
.add_filter("JSON", &["json"])
.pick_file();
cx.spawn_in(window, async move |this, mut cx| {
if let Some(file_handle) = file_dialog.await {
let path = file_handle.path().to_path_buf();
let read_result = cx
.background_executor()
.spawn(async move { std::fs::read_to_string(path) })
.await;
if let Ok(json_str) = read_result {
if let Ok(config_file) =
serde_json::from_str::<crate::session::config::ConfigFile>(&json_str)
{
let _ = gpui::AsyncWindowContext::update(&mut cx, |window, cx| {
let _ = this.update(cx, |this, cx| {
this.config.cache = config_file;
if let Err(err) = this.config.save() {
tracing::error!("failed to save imported config: {err:#}");
} else {
this.apply_loaded_config(window, cx);
}
});
});
}
}
}
Ok::<(), anyhow::Error>(())
})
.detach();
}
}
+768 -515
View File
File diff suppressed because it is too large Load Diff
+24 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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)
})
+10 -11
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 -62
View File
@@ -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(&current) {
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)))
@@ -2819,7 +2859,6 @@ impl Render for Ashell {
.flex_1()
.min_w(px(0.))
.h_full()
.window_control_area(gpui::WindowControlArea::Drag)
.on_double_click(|_, window, _| {
#[cfg(target_os = "macos")]
window.titlebar_double_click();
+1
View File
@@ -1,2 +1,3 @@
pub mod local;
pub mod serial;
pub mod ssh;
+223
View File
@@ -0,0 +1,223 @@
use crate::session::config::Session;
use crate::terminal::{BackendCommand, BackendEvent};
use std::io::{Read, Write};
/// Spawn the serial port backend threads.
/// Returns a sender to send commands (like keyboard inputs) to the serial port.
pub fn spawn_serial_client(
_handle: &tokio::runtime::Handle,
tab_id: String,
session: Session,
events_tx: std::sync::mpsc::Sender<BackendEvent>,
) -> tokio::sync::mpsc::UnboundedSender<BackendCommand> {
let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel::<BackendCommand>();
let tab_id_clone = tab_id.clone();
let events_tx_clone = events_tx.clone();
std::thread::spawn(move || {
let _ = events_tx_clone.send(BackendEvent::Status {
tab_id: tab_id_clone.clone(),
text: rust_i18n::t!("starting_connection").to_string(),
});
let port_name = session.host;
let baud_rate = session.baud_rate;
tracing::info!(
"[serial] opening port {} at baud rate {}",
port_name,
baud_rate
);
let mut port_result = serialport::new(&port_name, baud_rate)
.timeout(std::time::Duration::from_millis(100))
.open();
if port_result.is_err() && baud_rate != 0 {
tracing::info!(
"[serial] failed to open port with baud rate {}, retrying with 0 (virtual port mode)",
baud_rate
);
port_result = serialport::new(&port_name, 0)
.timeout(std::time::Duration::from_millis(100))
.open();
}
let mut port = match port_result {
Ok(p) => p,
Err(e) => {
tracing::error!("[serial] failed to open port {}: {}", port_name, e);
let _ = events_tx_clone.send(BackendEvent::Closed {
tab_id: tab_id_clone,
reason: format!("Failed to open serial port {port_name}: {e}"),
});
return;
}
};
let mut port_write = match port.try_clone() {
Ok(pw) => pw,
Err(e) => {
tracing::error!("[serial] failed to clone port: {}", e);
let _ = events_tx_clone.send(BackendEvent::Closed {
tab_id: tab_id_clone,
reason: format!("Failed to clone serial port: {e}"),
});
return;
}
};
// Notify connected
let _ = events_tx_clone.send(BackendEvent::Connected {
tab_id: tab_id_clone.clone(),
});
// Spawn write thread
let tab_id_write = tab_id_clone.clone();
let events_tx_write = events_tx_clone.clone();
std::thread::spawn(move || {
while let Some(cmd) = cmd_rx.blocking_recv() {
match cmd {
BackendCommand::Input(bytes) => {
if let Err(e) = port_write.write_all(&bytes) {
tracing::error!("[serial] write error: {}", e);
let _ = events_tx_write.send(BackendEvent::Closed {
tab_id: tab_id_write.clone(),
reason: format!("Serial write error: {e}"),
});
break;
}
let _ = port_write.flush();
}
BackendCommand::Close => break,
_ => {}
}
}
});
// Read loop in current thread
let mut buf = [0u8; 1024];
let mut last_was_cr = false;
loop {
match port.read(&mut buf) {
Ok(n) if n > 0 => {
let mut processed = Vec::with_capacity(n * 2);
let read_bytes = &buf[..n];
for i in 0..n {
let b = read_bytes[i];
if b == b'\n' {
let prev_was_cr = if i > 0 {
read_bytes[i - 1] == b'\r'
} else {
last_was_cr
};
if !prev_was_cr {
processed.push(b'\r');
}
}
processed.push(b);
}
last_was_cr = read_bytes[n - 1] == b'\r';
let _ = events_tx_clone.send(BackendEvent::Output {
tab_id: tab_id_clone.clone(),
bytes: processed,
});
}
Ok(_) => {}
Err(ref e) if e.kind() == std::io::ErrorKind::TimedOut => {}
Err(e) => {
tracing::info!("[serial] port read error/closed: {}", e);
let _ = events_tx_clone.send(BackendEvent::Closed {
tab_id: tab_id_clone,
reason: format!("Serial read error: {e}"),
});
break;
}
}
}
});
cmd_tx
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
use portable_pty::{NativePtySystem, PtySize, PtySystem};
#[tokio::test]
async fn test_serial_read_write_simulation() {
// 1. Create a PTY pair using portable-pty to simulate a serial device
let pty_system = NativePtySystem::default();
let pair = pty_system
.openpty(PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
})
.unwrap();
// On macOS/Linux, the slave name path behaves like a TTY device.
let fd = pair.master.as_raw_fd().unwrap();
let slave_name = unsafe {
let ptr = libc::ptsname(fd);
assert!(!ptr.is_null());
std::ffi::CStr::from_ptr(ptr).to_string_lossy().into_owned()
};
println!("Simulating serial device on PTY slave path: {}", slave_name);
// 2. Spawn the serial backend targeting the PTY slave path
let (events_tx, events_rx) = std::sync::mpsc::channel();
let handle = tokio::runtime::Handle::current();
let session = Session::serial(slave_name, 0);
let cmd_tx = spawn_serial_client(&handle, "test-tab".to_string(), session, events_tx);
// Wait for the Status event
let status_event = events_rx.recv_timeout(std::time::Duration::from_secs(2));
assert!(status_event.is_ok(), "Failed to receive Status event");
if let Ok(BackendEvent::Status { tab_id, .. }) = status_event {
assert_eq!(tab_id, "test-tab");
} else {
panic!("Expected Status event, got: {:?}", status_event);
}
// Wait for the Connected event
let connected_event = events_rx.recv_timeout(std::time::Duration::from_secs(2));
assert!(connected_event.is_ok(), "Failed to receive Connected event");
if let Ok(BackendEvent::Connected { tab_id }) = connected_event {
assert_eq!(tab_id, "test-tab");
} else {
panic!("Expected Connected event, got: {:?}", connected_event);
}
// 3. Test Reading: Write to PTY master, verify serial backend outputs it to UI
let mut master_writer = pair.master.take_writer().unwrap();
master_writer.write_all(b"hello serial simulator").unwrap();
master_writer.flush().unwrap();
let output_event = events_rx.recv_timeout(std::time::Duration::from_secs(2));
assert!(output_event.is_ok(), "Failed to receive Output event");
if let Ok(BackendEvent::Output { tab_id, bytes }) = output_event {
assert_eq!(tab_id, "test-tab");
assert_eq!(bytes, b"hello serial simulator");
} else {
panic!("Expected Output event");
}
// 4. Test Writing: Send BackendCommand::Input to backend, verify PTY master reads it
cmd_tx
.send(BackendCommand::Input(b"world serial simulator".to_vec()))
.unwrap();
let mut master_reader = pair.master.try_clone_reader().unwrap();
let mut read_buf = [0u8; 128];
let bytes_read = master_reader.read(&mut read_buf).unwrap();
assert_eq!(&read_buf[..bytes_read], b"world serial simulator");
// 5. Clean up
cmd_tx.send(BackendCommand::Close).unwrap();
}
}
+165 -86
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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();
}
}
+179
View File
@@ -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");
}
}
+96
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -42,10 +42,10 @@ impl Ashell {
&& !event.keystroke.modifiers.platform
{
match event.keystroke.key.to_ascii_lowercase().as_str() {
"h" => self.focus_adjacent_pane("left"),
"j" => self.focus_adjacent_pane("down"),
"k" => self.focus_adjacent_pane("up"),
"l" => self.focus_adjacent_pane("right"),
"h" => self.focus_adjacent_pane("left", cx),
"j" => self.focus_adjacent_pane("down", cx),
"k" => self.focus_adjacent_pane("up", cx),
"l" => self.focus_adjacent_pane("right", cx),
"q" => {
if let Some(active_id) = self.active_tab.clone() {
self.close_tab(active_id, cx);
@@ -165,7 +165,7 @@ impl Ashell {
return;
};
if tab.render_snapshot().display_offset > 0 {
if tab.render_snapshot(false).display_offset > 0 {
tab.scroll_to_bottom();
}
tab.clear_selection();
@@ -204,7 +204,7 @@ impl Ashell {
return;
};
if tab.render_snapshot().display_offset > 0 {
if tab.render_snapshot(false).display_offset > 0 {
tab.scroll_to_bottom();
}
@@ -236,7 +236,7 @@ impl Ashell {
return;
};
if tab.render_snapshot().display_offset > 0 {
if tab.render_snapshot(false).display_offset > 0 {
tab.scroll_to_bottom();
}
tab.clear_selection();
@@ -291,7 +291,7 @@ impl Ashell {
return;
};
if tab.render_snapshot().display_offset > 0 {
if tab.render_snapshot(false).display_offset > 0 {
tab.scroll_to_bottom();
}
tab.clear_selection();
@@ -387,7 +387,12 @@ impl Ashell {
if let Some((row, col, _side)) = self.terminal_grid_point_and_side(event.position) {
if let Some(snapshot) = self.active_snapshot() {
if let Some(active_id) = &self.active_tab {
if let Some((url, url_cells)) = crate::terminal::highlight::find_url_at_cell(&snapshot.cells, snapshot.rows, row, col) {
if let Some((url, url_cells)) = crate::terminal::highlight::find_url_at_cell(
&snapshot.cells,
snapshot.rows,
row,
col,
) {
hovered_url = Some(crate::app::HoveredUrl {
url,
tab_id: active_id.clone(),
@@ -397,7 +402,7 @@ impl Ashell {
}
}
}
if self.hovered_url != hovered_url || self.cmd_ctrl_pressed != cmd_ctrl_pressed {
self.hovered_url = hovered_url;
self.cmd_ctrl_pressed = cmd_ctrl_pressed;
+72 -13
View File
@@ -1,7 +1,7 @@
pub mod custom_blocks;
pub mod element;
pub mod input;
pub mod highlight;
pub mod input;
use std::sync::mpsc::Sender;
@@ -23,6 +23,7 @@ use crate::system::SystemSnapshot;
pub enum TabKind {
Local,
Ssh,
Serial,
}
#[derive(Debug)]
@@ -98,6 +99,7 @@ pub enum BackendEvent {
pub enum BackendTx {
Local(Sender<BackendCommand>),
Ssh(tokio::sync::mpsc::UnboundedSender<BackendCommand>),
Serial(tokio::sync::mpsc::UnboundedSender<BackendCommand>),
}
impl BackendTx {
@@ -109,6 +111,9 @@ impl BackendTx {
Self::Ssh(tx) => {
let _ = tx.send(command);
}
Self::Serial(tx) => {
let _ = tx.send(command);
}
}
}
}
@@ -116,6 +121,7 @@ impl BackendTx {
pub struct TerminalTab {
pub id: String,
pub title: String,
pub dynamic_title: String,
pub kind: TabKind,
pub status: String,
pub connected: bool,
@@ -134,7 +140,12 @@ pub struct TerminalTab {
pub rows: u16,
pub backend: std::sync::Arc<std::sync::Mutex<BackendTx>>,
pub scroll_pixel_y: f32,
pub(crate) highlight_cache: std::cell::RefCell<Option<(Vec<RenderCell>, std::collections::HashMap<(i32, i32), gpui::Hsla>)>>,
pub(crate) highlight_cache: std::cell::RefCell<
Option<(
Vec<RenderCell>,
std::collections::HashMap<(i32, i32), gpui::Hsla>,
)>,
>,
}
#[derive(Clone, Copy)]
@@ -222,6 +233,25 @@ impl TerminalTab {
tab
}
pub fn new_serial(
id: String,
session: &Session,
backend: BackendTx,
events: std::sync::mpsc::Sender<BackendEvent>,
) -> Self {
let mut tab = Self::new(
id,
session.name.clone(),
TabKind::Serial,
format!("connecting serial://{}@{}", session.host, session.baud_rate),
backend,
events,
);
tab.session = Some(session.clone());
tab.connected = false;
tab
}
fn new(
id: String,
title: String,
@@ -233,7 +263,8 @@ impl TerminalTab {
let shared_backend = std::sync::Arc::new(std::sync::Mutex::new(backend));
Self {
id: id.clone(),
title,
title: title.clone(),
dynamic_title: title,
kind,
status,
connected: matches!(kind, TabKind::Local),
@@ -242,7 +273,7 @@ impl TerminalTab {
backend_initialized: true,
session: None,
processor: Processor::new(),
term: new_term(100, 30, shared_backend.clone(), id, events),
term: new_term(100, 30, shared_backend.clone(), id, events.clone()),
cols: 100,
rows: 30,
backend: shared_backend,
@@ -312,7 +343,7 @@ impl TerminalTab {
self.term.mode().contains(TermMode::APP_CURSOR)
}
pub fn render_snapshot(&self) -> RenderSnapshot {
pub fn render_snapshot(&self, keyword_highlight: bool) -> RenderSnapshot {
let rows = self.rows;
let cols = self.cols;
let content = self.term.renderable_content();
@@ -342,15 +373,13 @@ impl TerminalTab {
}
// Get highlights from cache or recompute, only if keyword_highlight is enabled.
let is_enabled = crate::session::config::ConfigStore::load()
.map(|c| c.keyword_highlight())
.unwrap_or(false);
let is_enabled = keyword_highlight;
let highlights = if is_enabled {
let mut cache = self.highlight_cache.borrow_mut();
let cache_valid = cache.as_ref().is_some_and(|(cached_cells, _)| {
cached_cells == &cells
});
let cache_valid = cache
.as_ref()
.is_some_and(|(cached_cells, _)| cached_cells == &cells);
if cache_valid {
cache.as_ref().unwrap().1.clone()
} else {
@@ -503,7 +532,10 @@ fn viewport_selection_from_range(
} = selection.as_ref().copied()?;
let top_point = viewport_to_point(display_offset, Point::new(0, Column(0)));
let bottom_point = viewport_to_point(display_offset, Point::new(rows.saturating_sub(1), Column(0)));
let bottom_point = viewport_to_point(
display_offset,
Point::new(rows.saturating_sub(1), Column(0)),
);
let top_line = top_point.line;
let bottom_line = bottom_point.line;
@@ -521,7 +553,10 @@ fn viewport_selection_from_range(
} else if end.line > bottom_line {
Point::new(rows.saturating_sub(1), Column(cols.saturating_sub(1)))
} else {
point_to_viewport(display_offset, end).unwrap_or(Point::new(rows.saturating_sub(1), Column(cols.saturating_sub(1))))
point_to_viewport(display_offset, end).unwrap_or(Point::new(
rows.saturating_sub(1),
Column(cols.saturating_sub(1)),
))
};
Some(ViewportSelection {
@@ -729,6 +764,18 @@ fn zed_like_to_esc_str(
("^", TerminalModifiers::Ctrl) => Some("\x1e"),
("_", TerminalModifiers::Ctrl) => Some("\x1f"),
("?", TerminalModifiers::Ctrl) => Some("\x7f"),
("f1", TerminalModifiers::None) => Some("\x1bOP"),
("f2", TerminalModifiers::None) => Some("\x1bOQ"),
("f3", TerminalModifiers::None) => Some("\x1bOR"),
("f4", TerminalModifiers::None) => Some("\x1bOS"),
("f5", TerminalModifiers::None) => Some("\x1b[15~"),
("f6", TerminalModifiers::None) => Some("\x1b[17~"),
("f7", TerminalModifiers::None) => Some("\x1b[18~"),
("f8", TerminalModifiers::None) => Some("\x1b[19~"),
("f9", TerminalModifiers::None) => Some("\x1b[20~"),
("f10", TerminalModifiers::None) => Some("\x1b[21~"),
("f11", TerminalModifiers::None) => Some("\x1b[23~"),
("f12", TerminalModifiers::None) => Some("\x1b[24~"),
_ => None,
};
if let Some(esc) = manual_esc_str {
@@ -747,6 +794,18 @@ fn zed_like_to_esc_str(
"pagedown" => Some(format!("\x1b[6;{}~", modifier_code)),
"end" => Some(format!("\x1b[1;{}F", modifier_code)),
"home" => Some(format!("\x1b[1;{}H", modifier_code)),
"f1" => Some(format!("\x1b[1;{}P", modifier_code)),
"f2" => Some(format!("\x1b[1;{}Q", modifier_code)),
"f3" => Some(format!("\x1b[1;{}R", modifier_code)),
"f4" => Some(format!("\x1b[1;{}S", modifier_code)),
"f5" => Some(format!("\x1b[15;{}~", modifier_code)),
"f6" => Some(format!("\x1b[17;{}~", modifier_code)),
"f7" => Some(format!("\x1b[18;{}~", modifier_code)),
"f8" => Some(format!("\x1b[19;{}~", modifier_code)),
"f9" => Some(format!("\x1b[20;{}~", modifier_code)),
"f10" => Some(format!("\x1b[21;{}~", modifier_code)),
"f11" => Some(format!("\x1b[23;{}~", modifier_code)),
"f12" => Some(format!("\x1b[24;{}~", modifier_code)),
_ => None,
};
if let Some(esc) = modified {