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
TomZz a8669738e6 fix: relocate window title bar dragging to tab bar container (resolves #72) 2026-07-03 19:55:22 +08:00
RabitLogic 2dad1fa5a1 fix: enable window title bar dragging on Linux via manual mouse tracking and deb packaging (#68) 2026-07-03 16:51:18 +08:00
TomZz e2bf76f7b2 bump version to v0.4.9 2026-07-03 04:56:05 +08:00
TomZz a654695d7b feat: support terminal URL highlighting on hover, click-to-open and multi-line wrapping 2026-07-03 04:36:42 +08:00
TomZz cb078ef572 feat: add setting to lock window layout (resolves #69) 2026-07-02 23:39:03 +08:00
TomZz c0aa21a994 fix: enable window title bar dragging in integrated mode (resolves #67) 2026-07-02 23:15:07 +08:00
TomZz c65354e8d6 fix(ui): resolve terminal split-screen sizing alignment and remainder margin gap 2026-06-29 17:13:35 +08:00
32 changed files with 4971 additions and 1322 deletions
Generated
+95 -4
View File
@@ -315,7 +315,7 @@ dependencies = [
[[package]]
name = "ashell"
version = "0.4.8"
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"
+20 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "ashell"
version = "0.4.8"
version = "0.4.12"
edition = "2024"
authors = ["ashell contributors"]
description = "A GPUI Component based SSH and local terminal client"
@@ -50,10 +50,29 @@ 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"]
extended-description = "A GPUI Component based SSH and local terminal client"
section = "terminal"
priority = "optional"
assets = [
["target/release/ashell", "usr/bin/", "755"],
["assets/ashell.desktop", "usr/share/applications/", "644"],
["assets/icons/ashell.png", "usr/share/icons/hicolor/256x256/apps/", "644"],
]
[profile.release]
opt-level = 3
lto = "thin"
+33
View File
@@ -82,6 +82,39 @@ open target/release/ashell.app
The packaging script creates a standard `.app` bundle. It does not attach an entitlements file, and after signing, it verifies that `com.apple.security.app-sandbox` is not present (meaning it runs non-sandboxed).
## Package Linux (Debian/Ubuntu)
### Prerequisites
```bash
sudo apt install pkg-config libfontconfig1-dev
cargo install cargo-deb
```
### Build .deb
```bash
cargo build --release
cargo deb
```
The `.deb` file will be generated at:
```
target/debian/ashell_0.4.8-1_amd64.deb
```
### Install
```bash
sudo dpkg -i target/debian/ashell_0.4.8-1_amd64.deb
```
After installation, you can launch from the application menu or by running `ashell` in the terminal. The `.deb` package includes:
- `/usr/bin/ashell` — Main binary
- `/usr/share/applications/ashell.desktop` — Desktop entry
- `/usr/share/icons/hicolor/256x256/apps/ashell.png` — Application icon
## License
This project is licensed under the [GPL-3.0-or-later License](LICENSE).
+33
View File
@@ -82,6 +82,39 @@ open target/release/ashell.app
该打包脚本会创建一个标准的 `.app` 应用程序包。它没有附加 entitlements 文件,并且在签名后会验证是否不存在 `com.apple.security.app-sandbox`(这意味着它在非沙盒模式下运行)。
## 打包 Linux (Debian/Ubuntu)
### 前置条件
```bash
sudo apt install pkg-config libfontconfig1-dev
cargo install cargo-deb
```
### 构建 .deb 包
```bash
cargo build --release
cargo deb
```
生成的 `.deb` 文件位于:
```
target/debian/ashell_0.4.9-1_amd64.deb
```
### 安装
```bash
sudo dpkg -i target/debian/ashell_0.4.9-1_amd64.deb
```
安装后可通过应用菜单或命令行 `ashell` 启动。`.deb` 包包含以下内容:
- `/usr/bin/ashell` — 主程序
- `/usr/share/applications/ashell.desktop` — 桌面入口
- `/usr/share/icons/hicolor/256x256/apps/ashell.png` — 应用图标
## 协议
本项目采用 [GPL-3.0-or-later 协议](LICENSE) 开源。
+9
View File
@@ -0,0 +1,9 @@
[Desktop Entry]
Name=Ashell
Comment=A GPUI Component based SSH and local terminal client
Exec=ashell
Icon=ashell
Terminal=false
Type=Application
Categories=System;TerminalEmulator;Utility;
StartupWMClass=ashell
+15
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"
@@ -125,11 +132,15 @@ software_builtin: "Built-in"
retry: "Retry"
right_click_copy_paste: "Right-click Copy/Paste"
keyword_highlight: "Terminal Keyword Highlighting"
lock_layout: "Lock Window Layout"
lock_layout_hint: "Lock the position of the sidebar and the SFTP panel."
copy_paste_hint: "Selected text can also be copied by left-clicking."
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"
@@ -209,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"
+15
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: "保存并连接"
@@ -126,11 +133,15 @@ software_builtin: "软件内置"
retry: "重试"
right_click_copy_paste: "右键复制/粘贴"
keyword_highlight: "终端关键词高亮"
lock_layout: "锁定窗口布局"
lock_layout_hint: "锁定侧边栏和 SFTP 面板的位置。"
copy_paste_hint: "选中后左键点击也可复制。"
settings_copy: "复制选区"
settings_paste: "粘贴剪贴板"
edit_file: "编辑文件"
edit_file_tooltip: "下载到临时目录,使用默认编辑器打开,并在保存后自动上传。"
sync_cwd_tooltip: "从当前终端同步路径"
sync_cwd: "同步路径"
auto_saved_and_uploaded: "已自动保存并上传: %{base}"
scroll: "滑动查看 ↓"
clone: "克隆"
@@ -211,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();
}
}
+5
View File
@@ -2,7 +2,12 @@ pub(crate) const DEFAULT_COLS: u16 = 100;
pub(crate) const DEFAULT_ROWS: u16 = 30;
pub(crate) const SIDEBAR_WIDTH: f32 = 306.0;
pub(crate) const COLLAPSED_SIDEBAR_WIDTH: f32 = 52.0;
#[allow(dead_code)]
pub(crate) const TAB_BAR_HEIGHT: f32 = 52.0;
#[allow(dead_code)]
pub(crate) const TERMINAL_PADDING_X: f32 = 32.0;
#[allow(dead_code)]
pub(crate) const TERMINAL_PADDING_Y: f32 = 32.0;
pub(crate) const TERMINAL_KEY_CONTEXT: &str = "AshellTerminal";
+786 -514
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",
+274 -49
View File
@@ -2,6 +2,7 @@ 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;
@@ -16,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,
@@ -23,7 +25,6 @@ use gpui::{
use gpui_component::{
Theme, ThemeMode, ThemeRegistry,
input::{InputEvent, InputState},
resizable::ResizableState,
scroll::ScrollbarHandle,
};
use rust_i18n::t;
@@ -31,6 +32,7 @@ use tokio::runtime::Runtime;
use crate::{
session::config::{AuthMethod, ConfigStore},
session::ssh_config::SshConfigEntry,
system::{SystemSampler, SystemSnapshot},
terminal::{self, BackendEvent, TabKind, TerminalTab},
};
@@ -210,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>,
@@ -235,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,
@@ -317,7 +323,19 @@ pub(crate) struct Ashell {
pub(crate) events_tx: mpsc::Sender<BackendEvent>,
pub(crate) last_window_size: Option<gpui::Size<Pixels>>,
pub(crate) last_sidebar_width: Option<Pixels>,
pub(crate) should_move_window: bool,
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)]
pub(crate) struct HoveredUrl {
pub(crate) url: String,
pub(crate) tab_id: String,
pub(crate) cells: Vec<(usize, usize)>,
}
#[derive(Clone)]
@@ -387,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()
@@ -409,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)
@@ -485,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),
@@ -563,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,
@@ -588,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,
@@ -681,7 +716,12 @@ impl Ashell {
events_tx,
last_window_size: None,
last_sidebar_width,
should_move_window: false,
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);
@@ -690,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>,
@@ -748,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()
@@ -767,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()
@@ -978,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) => {
@@ -1113,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));
}
}
@@ -1122,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);
@@ -1157,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();
}
}
}
}
@@ -1196,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.");
@@ -1209,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 {
@@ -1221,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);
}
_ => {}
@@ -1249,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()
+329
View File
@@ -0,0 +1,329 @@
#![allow(dead_code)]
use std::ops::Range;
use gpui::{
Along, App, Axis, Bounds, Context, ElementId, EventEmitter, IsZero, Pixels, Window, px,
};
mod panel;
mod resize_handle;
pub use panel::*;
pub(crate) use resize_handle::*;
pub(crate) const PANEL_MIN_SIZE: Pixels = px(100.);
pub enum ResizablePanelEvent {
Resized,
}
/// Create a [`ResizablePanelGroup`] with horizontal resizing
pub fn h_resizable(id: impl Into<ElementId>) -> ResizablePanelGroup {
ResizablePanelGroup::new(id).axis(Axis::Horizontal)
}
/// Create a [`ResizablePanelGroup`] with vertical resizing
pub fn v_resizable(id: impl Into<ElementId>) -> ResizablePanelGroup {
ResizablePanelGroup::new(id).axis(Axis::Vertical)
}
/// Create a [`ResizablePanel`].
pub fn resizable_panel() -> ResizablePanel {
ResizablePanel::new()
}
/// State for a [`ResizablePanel`]
#[derive(Debug, Clone)]
pub struct ResizableState {
/// The `axis` will sync to actual axis of the ResizablePanelGroup in use.
axis: Axis,
pub(crate) panels: Vec<ResizablePanelState>,
pub(crate) sizes: Vec<Pixels>,
pub(crate) resizing_panel_ix: Option<usize>,
pub(crate) bounds: Bounds<Pixels>,
}
impl Default for ResizableState {
fn default() -> Self {
Self {
axis: Axis::Horizontal,
panels: vec![],
sizes: vec![],
resizing_panel_ix: None,
bounds: Bounds::default(),
}
}
}
impl ResizableState {
/// Get the size of the panels.
pub fn sizes(&self) -> &Vec<Pixels> {
&self.sizes
}
/// Programmatically resize the panel at `ix` to `size`, redistributing
/// space among siblings using the same logic as a drag.
///
/// Sizes are clamped to the panel's `size_range` and to the container.
/// Emits `ResizablePanelEvent::Resized` so subscribers (e.g. preference
/// persistence) see the change just as if the user had dragged a handle.
///
/// Out-of-range indices are a no-op. For the last panel, space is taken
/// from the previous sibling (the last panel has no handle of its own).
pub fn resize_panel(
&mut self,
ix: usize,
size: Pixels,
window: &mut Window,
cx: &mut Context<Self>,
) {
if ix >= self.sizes.len() {
return;
}
if ix + 1 < self.sizes.len() {
self.resize_panel_at_handle(ix, size, window, cx);
} else if ix > 0 {
// Last panel: drive its size by resizing the previous sibling so
// the freed space lands here.
let delta = self.sizes[ix] - size;
let prev = self.sizes[ix - 1];
self.resize_panel_at_handle(ix - 1, prev + delta, window, cx);
}
self.done_resizing(cx);
}
pub(crate) fn insert_panel(
&mut self,
size: Option<Pixels>,
ix: Option<usize>,
cx: &mut Context<Self>,
) {
let panel_state = ResizablePanelState {
size,
..Default::default()
};
let size = size.unwrap_or(PANEL_MIN_SIZE);
// We make sure that the size always sums up to the container size
// by reducing the size of all other panels first.
let container_size = self.container_size().max(px(1.));
let total_leftover_size = (container_size - size).max(px(1.));
for (i, panel) in self.panels.iter_mut().enumerate() {
let ratio = self.sizes[i] / container_size;
self.sizes[i] = total_leftover_size * ratio;
panel.size = Some(self.sizes[i]);
}
if let Some(ix) = ix {
self.panels.insert(ix, panel_state);
self.sizes.insert(ix, size);
} else {
self.panels.push(panel_state);
self.sizes.push(size);
};
cx.notify();
}
pub(crate) fn sync_panels_count(
&mut self,
axis: Axis,
panels_count: usize,
cx: &mut Context<Self>,
) {
let mut changed = self.axis != axis;
self.axis = axis;
if panels_count > self.panels.len() {
let diff = panels_count - self.panels.len();
self.panels
.extend(vec![ResizablePanelState::default(); diff]);
self.sizes.extend(vec![PANEL_MIN_SIZE; diff]);
changed = true;
}
if panels_count < self.panels.len() {
self.panels.truncate(panels_count);
self.sizes.truncate(panels_count);
changed = true;
}
if changed {
// We need to make sure the total size is in line with the container size.
self.adjust_to_container_size(cx);
}
}
pub(crate) fn update_panel_size(
&mut self,
panel_ix: usize,
bounds: Bounds<Pixels>,
size_range: Range<Pixels>,
cx: &mut Context<Self>,
) {
let size = bounds.size.along(self.axis);
// This check is only necessary to stop the very first panel from resizing on its own
// it needs to be passed when the panel is freshly created so we get the initial size,
// but its also fine when it sometimes passes later.
if self.sizes[panel_ix].as_f32() == PANEL_MIN_SIZE.as_f32() {
self.sizes[panel_ix] = size;
self.panels[panel_ix].size = Some(size);
}
self.panels[panel_ix].bounds = bounds;
self.panels[panel_ix].size_range = size_range;
cx.notify();
}
pub(crate) fn remove_panel(&mut self, panel_ix: usize, cx: &mut Context<Self>) {
self.panels.remove(panel_ix);
self.sizes.remove(panel_ix);
if let Some(resizing_panel_ix) = self.resizing_panel_ix {
if resizing_panel_ix > panel_ix {
self.resizing_panel_ix = Some(resizing_panel_ix - 1);
}
}
self.adjust_to_container_size(cx);
}
pub(crate) fn replace_panel(
&mut self,
panel_ix: usize,
panel: ResizablePanelState,
cx: &mut Context<Self>,
) {
let old_size = self.sizes[panel_ix];
self.panels[panel_ix] = panel;
self.sizes[panel_ix] = old_size;
self.adjust_to_container_size(cx);
}
pub(crate) fn clear(&mut self) {
self.panels.clear();
self.sizes.clear();
}
#[inline]
pub(crate) fn container_size(&self) -> Pixels {
self.bounds.size.along(self.axis)
}
pub(crate) fn done_resizing(&mut self, cx: &mut Context<Self>) {
self.resizing_panel_ix = None;
cx.emit(ResizablePanelEvent::Resized);
}
fn panel_size_range(&self, ix: usize) -> Range<Pixels> {
let Some(panel) = self.panels.get(ix) else {
return PANEL_MIN_SIZE..Pixels::MAX;
};
panel.size_range.clone()
}
fn sync_real_panel_sizes(&mut self, _: &App) {
for (i, panel) in self.panels.iter().enumerate() {
self.sizes[i] = panel.bounds.size.along(self.axis);
}
}
pub(crate) fn resize_panel_at_handle(
&mut self,
ix: usize,
size: Pixels,
_: &mut Window,
cx: &mut Context<Self>,
) {
let old_sizes = self.sizes.clone();
let mut ix = ix;
if ix >= old_sizes.len() - 1 {
return;
}
let container_size = self.container_size();
self.sync_real_panel_sizes(cx);
let move_changed = size - old_sizes[ix];
if move_changed == px(0.) {
return;
}
let size_range = self.panel_size_range(ix);
let new_size = size.clamp(size_range.start, size_range.end);
let is_expand = move_changed > px(0.);
let main_ix = ix;
let mut new_sizes = old_sizes.clone();
if is_expand {
let mut changed = new_size - old_sizes[ix];
new_sizes[ix] = new_size;
while changed > px(0.) && ix < old_sizes.len() - 1 {
ix += 1;
let size_range = self.panel_size_range(ix);
let available_size = (new_sizes[ix] - size_range.start).max(px(0.));
let to_reduce = changed.min(available_size);
new_sizes[ix] -= to_reduce;
changed -= to_reduce;
}
} else {
let mut changed = new_size - size;
new_sizes[ix] = new_size;
while changed > px(0.) && ix > 0 {
ix -= 1;
let size_range = self.panel_size_range(ix);
let available_size = (new_sizes[ix] - size_range.start).max(px(0.));
let to_reduce = changed.min(available_size);
changed -= to_reduce;
new_sizes[ix] -= to_reduce;
}
new_sizes[main_ix + 1] += old_sizes[main_ix] - size - changed;
}
let total_size: Pixels = new_sizes.iter().map(|s| s.as_f32()).sum::<f32>().into();
if total_size > container_size {
let overflow = total_size - container_size;
new_sizes[main_ix] = (new_sizes[main_ix] - overflow).max(size_range.start);
}
for (i, _) in old_sizes.iter().enumerate() {
let size = new_sizes[i];
self.panels[i].size = Some(size);
}
self.sizes = new_sizes;
cx.notify();
}
pub(crate) fn adjust_to_container_size(&mut self, cx: &mut Context<Self>) {
if self.container_size().is_zero() {
return;
}
let container_size = self.container_size();
let total_size = px(self.sizes.iter().map(|s| s.as_f32()).sum::<f32>());
for i in 0..self.panels.len() {
let size = self.sizes[i];
let ratio = size / total_size;
let new_size = container_size * ratio;
self.sizes[i] = new_size;
self.panels[i].size = Some(new_size);
}
cx.notify();
}
}
impl EventEmitter<ResizablePanelEvent> for ResizableState {}
#[derive(Debug, Clone, Default)]
pub(crate) struct ResizablePanelState {
pub size: Option<Pixels>,
pub size_range: Range<Pixels>,
pub(crate) bounds: Bounds<Pixels>,
}
+425
View File
@@ -0,0 +1,425 @@
use std::{
ops::{Deref, Range},
rc::Rc,
};
use gpui::{
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, StyledExt, h_flex, v_flex};
use super::{PANEL_MIN_SIZE, ResizablePanelEvent, ResizableState, resizable_panel, resize_handle};
#[derive(Clone)]
pub(crate) struct DragPanel;
impl Render for DragPanel {
fn render(&mut self, _: &mut Window, _: &mut Context<'_, Self>) -> impl IntoElement {
Empty
}
}
/// A group of resizable panels.
#[derive(IntoElement)]
pub struct ResizablePanelGroup {
id: ElementId,
state: Option<Entity<ResizableState>>,
axis: Axis,
size: Option<Pixels>,
children: Vec<ResizablePanel>,
on_resize: Rc<dyn Fn(&Entity<ResizableState>, &mut Window, &mut App)>,
locked: bool,
}
impl ResizablePanelGroup {
/// Create a new resizable panel group.
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
axis: Axis::Horizontal,
children: vec![],
state: None,
size: None,
on_resize: Rc::new(|_, _, _| {}),
locked: false,
}
}
/// Bind yourself to a resizable state entity.
///
/// If not provided, it will handle its own state internally.
pub fn with_state(mut self, state: &Entity<ResizableState>) -> Self {
self.state = Some(state.clone());
self
}
/// Set the axis of the resizable panel group, default is horizontal.
pub fn axis(mut self, axis: Axis) -> Self {
self.axis = axis;
self
}
/// Set lock status of the resizable panel group.
pub fn lock(mut self, locked: bool) -> Self {
self.locked = locked;
self
}
/// Add a panel to the group.
///
/// - The `axis` will be set to the same axis as the group.
/// - The `initial_size` will be set to the average size of all panels if not provided.
/// - The `group` will be set to the group entity.
pub fn child(mut self, panel: impl Into<ResizablePanel>) -> Self {
self.children.push(panel.into());
self
}
/// Add multiple panels to the group.
pub fn children<I>(mut self, panels: impl IntoIterator<Item = I>) -> Self
where
I: Into<ResizablePanel>,
{
self.children = panels.into_iter().map(|panel| panel.into()).collect();
self
}
/// Set size of the resizable panel group
///
/// - When the axis is horizontal, the size is the height of the group.
/// - When the axis is vertical, the size is the width of the group.
pub fn size(mut self, size: Pixels) -> Self {
self.size = Some(size);
self
}
/// Set the callback to be called when the panels are resized.
///
/// ## Callback arguments
///
/// - Entity<ResizableState>: The state of the ResizablePanelGroup.
pub fn on_resize(
mut self,
on_resize: impl Fn(&Entity<ResizableState>, &mut Window, &mut App) + 'static,
) -> Self {
self.on_resize = Rc::new(on_resize);
self
}
}
impl<T> From<T> for ResizablePanel
where
T: Into<AnyElement>,
{
fn from(value: T) -> Self {
resizable_panel().child(value.into())
}
}
impl From<ResizablePanelGroup> for ResizablePanel {
fn from(value: ResizablePanelGroup) -> Self {
resizable_panel().child(value)
}
}
impl EventEmitter<ResizablePanelEvent> for ResizablePanelGroup {}
impl RenderOnce for ResizablePanelGroup {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let state = self.state.unwrap_or_else(|| {
window.use_keyed_state(self.id.clone(), cx, |_, _| ResizableState::default())
});
let container = if self.axis.is_horizontal() {
h_flex()
} else {
v_flex()
};
// Sync panels to the state
let panels_count = self.children.len();
state.update(cx, |state, cx| {
state.sync_panels_count(self.axis, panels_count, cx);
});
container
.id(self.id)
.size_full()
.children(
self.children
.into_iter()
.enumerate()
.map(|(ix, mut panel)| {
panel.panel_ix = ix;
panel.axis = self.axis;
panel.state = Some(state.clone());
panel.locked = self.locked;
panel
}),
)
.on_prepaint({
let state = state.clone();
move |bounds, _, cx| {
state.update(cx, |state, cx| {
let size_changed =
state.bounds.size.along(self.axis) != bounds.size.along(self.axis);
state.bounds = bounds;
if size_changed {
state.adjust_to_container_size(cx);
}
})
}
})
.child(ResizePanelGroupElement {
state: state.clone(),
axis: self.axis,
on_resize: self.on_resize.clone(),
})
}
}
/// A resizable panel inside a [`ResizablePanelGroup`].
#[derive(IntoElement)]
pub struct ResizablePanel {
axis: Axis,
panel_ix: usize,
state: Option<Entity<ResizableState>>,
/// Initial size is the size that the panel has when it is created.
initial_size: Option<Pixels>,
/// size range limit of this panel.
size_range: Range<Pixels>,
children: Vec<AnyElement>,
visible: bool,
style: StyleRefinement,
locked: bool,
}
impl ResizablePanel {
/// Create a new resizable panel.
pub(super) fn new() -> Self {
Self {
panel_ix: 0,
initial_size: None,
state: None,
size_range: (PANEL_MIN_SIZE..Pixels::MAX),
axis: Axis::Horizontal,
children: vec![],
visible: true,
style: StyleRefinement::default(),
locked: false,
}
}
/// Set the visibility of the panel, default is true.
pub fn visible(mut self, visible: bool) -> Self {
self.visible = visible;
self
}
/// Set the initial size of the panel.
pub fn size(mut self, size: impl Into<Pixels>) -> Self {
self.initial_size = Some(size.into());
self
}
/// Set the size range to limit panel resize.
///
/// Default is [`PANEL_MIN_SIZE`] to [`Pixels::MAX`].
pub fn size_range(mut self, range: impl Into<Range<Pixels>>) -> Self {
self.size_range = range.into();
self
}
}
impl Styled for ResizablePanel {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
impl ParentElement for ResizablePanel {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements);
}
}
impl RenderOnce for ResizablePanel {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
if !self.visible {
return div().id(("resizable-panel", self.panel_ix));
}
let state = self
.state
.expect("BUG: The `state` in ResizablePanel should be present.");
let panel_state = state
.read(cx)
.panels
.get(self.panel_ix)
.expect("BUG: The `index` of ResizablePanel should be one of in `state`.");
let size_range = self.size_range.clone();
div()
.id(("resizable-panel", self.panel_ix))
.flex()
.flex_grow_1()
.size_full()
.relative()
.refine_style(&self.style)
.when(self.axis.is_vertical(), |this| {
this.min_h(size_range.start).max_h(size_range.end)
})
.when(self.axis.is_horizontal(), |this| {
this.min_w(size_range.start).max_w(size_range.end)
})
.when(self.initial_size.is_none(), |this| this.flex_shrink_1())
.when_some(self.initial_size, |this, initial_size| {
this.when(
panel_state.size.is_none() && !initial_size.is_zero(),
|this| this.flex_none(),
)
.flex_basis(initial_size)
})
.map(|this| match panel_state.size {
Some(size) => this.flex_basis(size.min(size_range.end).max(size_range.start)),
None => this,
})
.on_prepaint({
let state = state.clone();
move |bounds, _, cx| {
state.update(cx, |state, cx| {
state.update_panel_size(self.panel_ix, bounds, self.size_range, cx)
})
}
})
.children(self.children)
.when(self.panel_ix > 0, |this| {
let ix = self.panel_ix - 1;
let handle = resize_handle(("resizable-handle", ix), self.axis);
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())
})
};
this.child(handle)
})
}
}
struct ResizePanelGroupElement {
state: Entity<ResizableState>,
on_resize: Rc<dyn Fn(&Entity<ResizableState>, &mut Window, &mut App)>,
axis: Axis,
}
impl IntoElement for ResizePanelGroupElement {
type Element = Self;
fn into_element(self) -> Self::Element {
self
}
}
impl Element for ResizePanelGroupElement {
type RequestLayoutState = ();
type PrepaintState = ();
fn id(&self) -> Option<gpui::ElementId> {
None
}
fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
None
}
fn request_layout(
&mut self,
_: Option<&gpui::GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
window: &mut Window,
cx: &mut App,
) -> (gpui::LayoutId, Self::RequestLayoutState) {
(window.request_layout(Style::default(), None, cx), ())
}
fn prepaint(
&mut self,
_: Option<&gpui::GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
_: Bounds<Pixels>,
_: &mut Self::RequestLayoutState,
_window: &mut Window,
_cx: &mut App,
) -> Self::PrepaintState {
()
}
fn paint(
&mut self,
_: Option<&gpui::GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
_: Bounds<Pixels>,
_: &mut Self::RequestLayoutState,
_: &mut Self::PrepaintState,
window: &mut Window,
cx: &mut App,
) {
window.on_mouse_event({
let state = self.state.clone();
let axis = self.axis;
let current_ix = state.read(cx).resizing_panel_ix;
move |e: &MouseMoveEvent, phase, window, cx| {
if !phase.bubble() {
return;
}
let Some(ix) = current_ix else { return };
state.update(cx, |state, cx| {
let panel = state.panels.get(ix).expect("BUG: invalid panel index");
match axis {
Axis::Horizontal => state.resize_panel_at_handle(
ix,
e.position.x - panel.bounds.left(),
window,
cx,
),
Axis::Vertical => state.resize_panel_at_handle(
ix,
e.position.y - panel.bounds.top(),
window,
cx,
),
}
cx.notify();
})
}
});
// When any mouse up, stop dragging
window.on_mouse_event({
let state = self.state.clone();
let current_ix = state.read(cx).resizing_panel_ix;
let on_resize = self.on_resize.clone();
move |_: &MouseUpEvent, phase, window, cx| {
if current_ix.is_none() {
return;
}
if phase.bubble() {
state.update(cx, |state, cx| state.done_resizing(cx));
on_resize(&state, window, cx);
}
}
})
}
}
+221
View File
@@ -0,0 +1,221 @@
use std::{cell::Cell, rc::Rc};
use gpui::{
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};
pub(crate) const HANDLE_PADDING: Pixels = px(4.);
pub(crate) const HANDLE_SIZE: Pixels = px(1.);
/// Create a resize handle for a resizable panel.
pub(crate) fn resize_handle<T: 'static, E: 'static + Render>(
id: impl Into<ElementId>,
axis: Axis,
) -> ResizeHandle<T, E> {
ResizeHandle::new(id, axis)
}
pub(crate) struct ResizeHandle<T: 'static, E: 'static + Render> {
id: ElementId,
axis: Axis,
drag_value: Option<Rc<T>>,
placement: Option<DockPlacement>,
on_drag: Option<Rc<dyn Fn(&Point<Pixels>, &mut Window, &mut App) -> Entity<E>>>,
}
impl<T: 'static, E: 'static + Render> ResizeHandle<T, E> {
fn new(id: impl Into<ElementId>, axis: Axis) -> Self {
let id = id.into();
Self {
id: id.clone(),
on_drag: None,
drag_value: None,
placement: None,
axis,
}
}
pub(crate) fn on_drag(
mut self,
value: T,
f: impl Fn(Rc<T>, &Point<Pixels>, &mut Window, &mut App) -> Entity<E> + 'static,
) -> Self {
let value = Rc::new(value);
self.drag_value = Some(value.clone());
self.on_drag = Some(Rc::new(move |p, window, cx| {
f(value.clone(), p, window, cx)
}));
self
}
pub(crate) fn placement(mut self, placement: DockPlacement) -> Self {
self.placement = Some(placement);
self
}
}
#[derive(Default, Debug, Clone)]
struct ResizeHandleState {
active: Cell<bool>,
}
impl ResizeHandleState {
fn set_active(&self, active: bool) {
self.active.set(active);
}
fn is_active(&self) -> bool {
self.active.get()
}
}
impl<T: 'static, E: 'static + Render> IntoElement for ResizeHandle<T, E> {
type Element = ResizeHandle<T, E>;
fn into_element(self) -> Self::Element {
self
}
}
impl<T: 'static, E: 'static + Render> Element for ResizeHandle<T, E> {
type RequestLayoutState = AnyElement;
type PrepaintState = ();
fn id(&self) -> Option<ElementId> {
Some(self.id.clone())
}
fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
None
}
fn request_layout(
&mut self,
id: Option<&GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
window: &mut Window,
cx: &mut App,
) -> (gpui::LayoutId, Self::RequestLayoutState) {
let neg_offset = -HANDLE_PADDING;
let axis = self.axis;
window.with_element_state(id.unwrap(), |state, window| {
let state: ResizeHandleState = state.unwrap_or_default();
let bg_color = if state.is_active() {
cx.theme().drag_border
} else {
cx.theme().border
};
let has_drag = self.on_drag.is_some();
let mut el = div()
.id(self.id.clone())
.occlude()
.absolute()
.flex_shrink_0()
.group("handle")
.when_some(self.on_drag.clone(), |this, on_drag| {
this.on_drag(
self.drag_value.clone().unwrap(),
move |_, position, window, cx| on_drag(&position, window, cx),
)
})
.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),
_ => this
.when(axis.is_horizontal(), |this| {
this.when(has_drag, |this| this.cursor_col_resize())
.top_0()
.left(neg_offset)
.h_full()
.w(HANDLE_SIZE)
.px(HANDLE_PADDING)
})
.when(axis.is_vertical(), |this| {
this.when(has_drag, |this| this.cursor_row_resize())
.top(neg_offset)
.left_0()
.w_full()
.h(HANDLE_SIZE)
.py(HANDLE_PADDING)
}),
})
.child(
div()
.bg(bg_color)
.group_hover("handle", |this| this.bg(bg_color))
.when(axis.is_horizontal(), |this| this.h_full().w(HANDLE_SIZE))
.when(axis.is_vertical(), |this| this.w_full().h(HANDLE_SIZE)),
)
.into_any_element();
let layout_id = el.request_layout(window, cx);
((layout_id, el), state)
})
}
fn prepaint(
&mut self,
_: Option<&GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
_: gpui::Bounds<Pixels>,
request_layout: &mut Self::RequestLayoutState,
window: &mut Window,
cx: &mut App,
) -> Self::PrepaintState {
request_layout.prepaint(window, cx);
}
fn paint(
&mut self,
id: Option<&GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
bounds: gpui::Bounds<Pixels>,
request_layout: &mut Self::RequestLayoutState,
_: &mut Self::PrepaintState,
window: &mut Window,
cx: &mut App,
) {
request_layout.paint(window, cx);
window.with_element_state(id.unwrap(), |state: Option<ResizeHandleState>, window| {
let state = state.unwrap_or_default();
if self.on_drag.is_some() {
window.on_mouse_event({
let state = state.clone();
move |ev: &MouseDownEvent, phase, window, _| {
if bounds.contains(&ev.position) && phase.bubble() {
state.set_active(true);
window.refresh();
}
}
});
window.on_mouse_event({
let state = state.clone();
move |_: &MouseUpEvent, _, window, _| {
if state.is_active() {
state.set_active(false);
window.refresh();
}
}
});
}
((), state)
});
}
}
+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();
}
}
+147 -80
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,
@@ -13,7 +14,6 @@ use gpui_component::{
input::Input,
menu::{ContextMenuExt as _, PopupMenuItem},
progress::Progress,
resizable::{h_resizable, resizable_panel, v_resizable},
scroll::{ScrollableElement as _, Scrollbar, ScrollbarShow},
tab::{Tab, TabBar},
v_flex,
@@ -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("+"),
),
)
})
@@ -2270,8 +2312,11 @@ impl Ashell {
div()
.size_full()
.on_prepaint(move |bounds, _window, cx| {
let _ = view.update(cx, |this, _| {
this.terminal_panel_bounds = Some(bounds);
let _ = view.update(cx, |this, cx| {
if this.terminal_panel_bounds != Some(bounds) {
this.terminal_panel_bounds = Some(bounds);
cx.notify();
}
});
})
.overflow_hidden()
@@ -2312,16 +2357,15 @@ 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();
};
let view = cx.entity();
let tab_id_clone = tab_id.clone();
let tab_id_clone2 = tab_id.clone();
let focus_handle = this.focus_handle.clone();
let marked_text = if is_focused {
@@ -2333,14 +2377,14 @@ 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 mut el = div()
.size_full()
.overflow_hidden()
.on_prepaint(move |bounds, _window, cx| {
let _ = view.update(cx, |this, _| {
this.terminal_bounds.insert(tab_id_clone.clone(), bounds);
});
})
.when(is_url_hovered, |d| d.cursor_pointer())
.on_mouse_down(
MouseButton::Left,
cx.listener(move |this, _, _, cx| {
@@ -2357,6 +2401,7 @@ impl Ashell {
font_size,
line_height,
cell_width,
tab_id.to_string(),
this.search_highlight_map(
tab_id,
cx.theme().danger.opacity(0.35),
@@ -2376,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
@@ -2433,10 +2472,10 @@ impl Ashell {
el = el.opacity(0.85);
}
let mut wrapper = div().size_full();
if has_multiple_panes {
if is_focused {
el = div()
.size_full()
wrapper = wrapper
.relative()
.child(
div()
@@ -2477,11 +2516,13 @@ impl Ashell {
.p(px(4.))
.child(el);
} else {
el = div().size_full().p(px(4.)).child(el);
wrapper = wrapper.p(px(4.)).child(el);
}
} else {
wrapper = wrapper.child(el);
}
el.into_any_element()
wrapper.into_any_element()
}
PaneLayout::Horizontal(children, ratio) => {
v_flex()
@@ -2596,7 +2637,7 @@ impl Render for Ashell {
self.active_tab = self.tabs.first().map(|tab| tab.id.clone());
}
self.sync_sftp_path_input(window, cx);
self.sync_terminal_size(window, cx);
if self.show_transfers_dialog {
self.show_transfers_dialog = false;
self.show_transfers_dialog(window, cx);
@@ -2605,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)
@@ -2648,6 +2689,7 @@ impl Render for Ashell {
};
let body_panel = v_resizable("ashell-body")
.lock(self.config.lock_layout())
.with_state(&self.body_panels)
.child(resizable_panel().child(self.render_terminal_panel(window, cx)))
.child(
@@ -2734,6 +2776,7 @@ impl Render for Ashell {
);
h_resizable("ashell-workspace")
.lock(self.config.lock_layout())
.with_state(&self.workspace_panels)
.child(sidebar_area)
.child(main_area)
@@ -2754,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)))
@@ -2809,18 +2852,42 @@ impl Render for Ashell {
.h(px(34.))
.w_full()
.bg(cx.theme().tab_bar)
.on_double_click(|_, window, _| {
#[cfg(target_os = "macos")]
window.titlebar_double_click();
#[cfg(not(target_os = "macos"))]
window.zoom_window();
})
.child(self.render_window_controls(window, cx))
.child(
div()
.id("tab-bar-drag")
.flex_1()
.min_w(px(0.))
.h_full()
.on_double_click(|_, window, _| {
#[cfg(target_os = "macos")]
window.titlebar_double_click();
#[cfg(not(target_os = "macos"))]
window.zoom_window();
})
.when(cfg!(target_os = "linux"), |this| {
this.on_mouse_down(
MouseButton::Left,
cx.listener(|this, _, _, _| {
this.should_move_window = true;
}),
)
.on_mouse_up(
MouseButton::Left,
cx.listener(|this, _, _, _| {
this.should_move_window = false;
}),
)
.on_mouse_down_out(cx.listener(|this, _, _, _| {
this.should_move_window = false;
}))
.on_mouse_move(cx.listener(|this, _, window, _| {
if this.should_move_window {
this.should_move_window = false;
window.start_window_move();
}
}))
})
.child(self.render_tab_bar(cx)),
),
)
+1
View File
@@ -1,2 +1,3 @@
pub mod local;
pub mod serial;
pub mod ssh;
+223
View File
@@ -0,0 +1,223 @@
use crate::session::config::Session;
use crate::terminal::{BackendCommand, BackendEvent};
use std::io::{Read, Write};
/// Spawn the serial port backend threads.
/// Returns a sender to send commands (like keyboard inputs) to the serial port.
pub fn spawn_serial_client(
_handle: &tokio::runtime::Handle,
tab_id: String,
session: Session,
events_tx: std::sync::mpsc::Sender<BackendEvent>,
) -> tokio::sync::mpsc::UnboundedSender<BackendCommand> {
let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel::<BackendCommand>();
let tab_id_clone = tab_id.clone();
let events_tx_clone = events_tx.clone();
std::thread::spawn(move || {
let _ = events_tx_clone.send(BackendEvent::Status {
tab_id: tab_id_clone.clone(),
text: rust_i18n::t!("starting_connection").to_string(),
});
let port_name = session.host;
let baud_rate = session.baud_rate;
tracing::info!(
"[serial] opening port {} at baud rate {}",
port_name,
baud_rate
);
let mut port_result = serialport::new(&port_name, baud_rate)
.timeout(std::time::Duration::from_millis(100))
.open();
if port_result.is_err() && baud_rate != 0 {
tracing::info!(
"[serial] failed to open port with baud rate {}, retrying with 0 (virtual port mode)",
baud_rate
);
port_result = serialport::new(&port_name, 0)
.timeout(std::time::Duration::from_millis(100))
.open();
}
let mut port = match port_result {
Ok(p) => p,
Err(e) => {
tracing::error!("[serial] failed to open port {}: {}", port_name, e);
let _ = events_tx_clone.send(BackendEvent::Closed {
tab_id: tab_id_clone,
reason: format!("Failed to open serial port {port_name}: {e}"),
});
return;
}
};
let mut port_write = match port.try_clone() {
Ok(pw) => pw,
Err(e) => {
tracing::error!("[serial] failed to clone port: {}", e);
let _ = events_tx_clone.send(BackendEvent::Closed {
tab_id: tab_id_clone,
reason: format!("Failed to clone serial port: {e}"),
});
return;
}
};
// Notify connected
let _ = events_tx_clone.send(BackendEvent::Connected {
tab_id: tab_id_clone.clone(),
});
// Spawn write thread
let tab_id_write = tab_id_clone.clone();
let events_tx_write = events_tx_clone.clone();
std::thread::spawn(move || {
while let Some(cmd) = cmd_rx.blocking_recv() {
match cmd {
BackendCommand::Input(bytes) => {
if let Err(e) = port_write.write_all(&bytes) {
tracing::error!("[serial] write error: {}", e);
let _ = events_tx_write.send(BackendEvent::Closed {
tab_id: tab_id_write.clone(),
reason: format!("Serial write error: {e}"),
});
break;
}
let _ = port_write.flush();
}
BackendCommand::Close => break,
_ => {}
}
}
});
// Read loop in current thread
let mut buf = [0u8; 1024];
let mut last_was_cr = false;
loop {
match port.read(&mut buf) {
Ok(n) if n > 0 => {
let mut processed = Vec::with_capacity(n * 2);
let read_bytes = &buf[..n];
for i in 0..n {
let b = read_bytes[i];
if b == b'\n' {
let prev_was_cr = if i > 0 {
read_bytes[i - 1] == b'\r'
} else {
last_was_cr
};
if !prev_was_cr {
processed.push(b'\r');
}
}
processed.push(b);
}
last_was_cr = read_bytes[n - 1] == b'\r';
let _ = events_tx_clone.send(BackendEvent::Output {
tab_id: tab_id_clone.clone(),
bytes: processed,
});
}
Ok(_) => {}
Err(ref e) if e.kind() == std::io::ErrorKind::TimedOut => {}
Err(e) => {
tracing::info!("[serial] port read error/closed: {}", e);
let _ = events_tx_clone.send(BackendEvent::Closed {
tab_id: tab_id_clone,
reason: format!("Serial read error: {e}"),
});
break;
}
}
}
});
cmd_tx
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
use portable_pty::{NativePtySystem, PtySize, PtySystem};
#[tokio::test]
async fn test_serial_read_write_simulation() {
// 1. Create a PTY pair using portable-pty to simulate a serial device
let pty_system = NativePtySystem::default();
let pair = pty_system
.openpty(PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
})
.unwrap();
// On macOS/Linux, the slave name path behaves like a TTY device.
let fd = pair.master.as_raw_fd().unwrap();
let slave_name = unsafe {
let ptr = libc::ptsname(fd);
assert!(!ptr.is_null());
std::ffi::CStr::from_ptr(ptr).to_string_lossy().into_owned()
};
println!("Simulating serial device on PTY slave path: {}", slave_name);
// 2. Spawn the serial backend targeting the PTY slave path
let (events_tx, events_rx) = std::sync::mpsc::channel();
let handle = tokio::runtime::Handle::current();
let session = Session::serial(slave_name, 0);
let cmd_tx = spawn_serial_client(&handle, "test-tab".to_string(), session, events_tx);
// Wait for the Status event
let status_event = events_rx.recv_timeout(std::time::Duration::from_secs(2));
assert!(status_event.is_ok(), "Failed to receive Status event");
if let Ok(BackendEvent::Status { tab_id, .. }) = status_event {
assert_eq!(tab_id, "test-tab");
} else {
panic!("Expected Status event, got: {:?}", status_event);
}
// Wait for the Connected event
let connected_event = events_rx.recv_timeout(std::time::Duration::from_secs(2));
assert!(connected_event.is_ok(), "Failed to receive Connected event");
if let Ok(BackendEvent::Connected { tab_id }) = connected_event {
assert_eq!(tab_id, "test-tab");
} else {
panic!("Expected Connected event, got: {:?}", connected_event);
}
// 3. Test Reading: Write to PTY master, verify serial backend outputs it to UI
let mut master_writer = pair.master.take_writer().unwrap();
master_writer.write_all(b"hello serial simulator").unwrap();
master_writer.flush().unwrap();
let output_event = events_rx.recv_timeout(std::time::Duration::from_secs(2));
assert!(output_event.is_ok(), "Failed to receive Output event");
if let Ok(BackendEvent::Output { tab_id, bytes }) = output_event {
assert_eq!(tab_id, "test-tab");
assert_eq!(bytes, b"hello serial simulator");
} else {
panic!("Expected Output event");
}
// 4. Test Writing: Send BackendCommand::Input to backend, verify PTY master reads it
cmd_tx
.send(BackendCommand::Input(b"world serial simulator".to_vec()))
.unwrap();
let mut master_reader = pair.master.try_clone_reader().unwrap();
let mut read_buf = [0u8; 128];
let bytes_read = master_reader.read(&mut read_buf).unwrap();
assert_eq!(&read_buf[..bytes_read], b"world serial simulator");
// 5. Clean up
cmd_tx.send(BackendCommand::Close).unwrap();
}
}
+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();
+518 -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,
}
}
}
@@ -176,6 +224,8 @@ pub struct ConfigFile {
pub transfers: Vec<crate::terminal::Transfer>,
#[serde(default)]
pub show_hidden_files: bool,
#[serde(default)]
pub lock_layout: bool,
#[serde(default = "default_monitoring_position")]
pub monitoring_position: String,
#[serde(default)]
@@ -288,6 +338,7 @@ impl Default for ConfigFile {
body_panels: None,
transfers: Vec::new(),
show_hidden_files: false,
lock_layout: false,
monitoring_position: default_monitoring_position(),
sidebar_collapsed: false,
sftp_panel_minimized: false,
@@ -313,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 {
@@ -340,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 {
@@ -435,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 {
@@ -443,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) {
@@ -702,6 +769,14 @@ impl ConfigStore {
self.cache.show_hidden_files = val;
}
pub fn lock_layout(&self) -> bool {
self.cache.lock_layout
}
pub fn set_lock_layout(&mut self, val: bool) {
self.cache.lock_layout = val;
}
pub fn sidebar_collapsed(&self) -> bool {
self.cache.sidebar_collapsed
}
@@ -738,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)]
@@ -755,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 {
@@ -772,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>)> {
@@ -889,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(),
@@ -907,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");
}
}
+299 -124
View File
@@ -1,8 +1,10 @@
pub mod config;
pub mod ssh_config;
pub mod ssh_keys;
use gpui::{
App, 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,10 +14,7 @@ use self::config::{AuthMethod, Session};
use crate::{
Ashell, PaneLayout, SelectorEntry, TabGroup,
app::constants::{
DEFAULT_COLS, DEFAULT_ROWS, SIDEBAR_WIDTH, TAB_BAR_HEIGHT, TERMINAL_PADDING_X,
TERMINAL_PADDING_Y,
},
app::constants::{DEFAULT_COLS, DEFAULT_ROWS},
backend::{local, ssh},
terminal::{BackendCommand, RenderSnapshot, TabKind, TerminalTab},
};
@@ -57,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();
@@ -103,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;
@@ -152,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);
@@ -160,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);
@@ -175,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);
@@ -198,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>) {
@@ -294,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();
}
@@ -304,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();
@@ -320,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();
@@ -331,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();
}
@@ -344,28 +413,79 @@ 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(|_| gpui_component::resizable::ResizableState::default());
self.body_panels = cx.new(|_| gpui_component::resizable::ResizableState::default());
self.workspace_panels = cx.new(|_| crate::app::resizable::ResizableState::default());
self.body_panels = cx.new(|_| crate::app::resizable::ResizableState::default());
cx.notify();
}
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();
@@ -381,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> {
@@ -546,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() {
@@ -579,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;
@@ -613,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();
}
}
}
}
@@ -716,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()
@@ -899,12 +1093,28 @@ impl Ashell {
}
}
if event.button == MouseButton::Left {
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,
) {
let _ = open::that(&url);
return;
}
}
}
}
if self.config.right_click_copy_paste() {
if let Some(text) = self.active_terminal_selection_text() {
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();
}
}
@@ -920,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> {
@@ -956,65 +1166,10 @@ impl Ashell {
}
pub(crate) fn session_detail(&self, session: &Session) -> String {
format!("{}@{}:{}", session.user, session.host, session.port)
}
pub(crate) fn sync_terminal_size(&mut self, window: &Window, cx: &App) {
let viewport = window.viewport_size();
let sidebar_width = self
.workspace_panels
.read(cx)
.sizes()
.first()
.map(|size| size.as_f32())
.unwrap_or(SIDEBAR_WIDTH);
// Use the whole terminal panel bounds for PTY sizing.
// Individual pane bounds are smaller when split, so using them here
// would shrink the root PTY dimensions a second time.
let (width, height) = if let Some(bounds) = self.terminal_panel_bounds {
(bounds.size.width.as_f32(), bounds.size.height.as_f32())
if session.protocol == "serial" {
format!("Serial: {}@{}", session.host, session.baud_rate)
} else {
let terminal_height = self
.body_panels
.read(cx)
.sizes()
.first()
.map(|size| size.as_f32())
.unwrap_or(viewport.height.as_f32() - TAB_BAR_HEIGHT - 248.0);
(
(viewport.width.as_f32() - sidebar_width - TERMINAL_PADDING_X - 8.0)
.max(self.terminal_cell_width()),
(terminal_height - TERMINAL_PADDING_Y).max(self.terminal_line_height()),
)
};
let total_cols = (width / self.terminal_cell_width()).floor().max(1.0) as u16;
let total_rows = (height / self.terminal_line_height()).floor().max(1.0) as u16;
Self::resize_pane_tree(&mut self.tabs, &self.pane_root, total_cols, total_rows);
}
fn resize_pane_tree(tabs: &mut [TerminalTab], layout: &PaneLayout, cols: u16, rows: u16) {
match layout {
PaneLayout::Single(id) => {
if let Some(tab) = tabs.iter_mut().find(|t| t.id == *id) {
tab.resize(cols.max(1), rows.max(1));
}
}
PaneLayout::Horizontal(children, _) => {
let n = children.len() as u16;
let each_rows = (rows / n).max(1);
for child in children {
Self::resize_pane_tree(tabs, child, cols, each_rows);
}
}
PaneLayout::Vertical(children, _) => {
let n = children.len() as u16;
let each_cols = (cols / n).max(1);
for child in children {
Self::resize_pane_tree(tabs, child, each_cols, rows);
}
}
format!("{}@{}:{}", session.user, session.host, session.port)
}
}
@@ -1081,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
@@ -1132,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;
}
@@ -1151,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;
+134 -15
View File
@@ -39,6 +39,26 @@ impl LayoutRect {
}
}
#[derive(Clone)]
struct LayoutUnderline {
row: i32,
col: i32,
cells: usize,
color: Hsla,
}
impl LayoutUnderline {
fn paint(&self, origin: Point<Pixels>, metrics: TerminalMetrics, window: &mut Window) {
let thickness = px(1.0);
let position = point(
origin.x + metrics.cell_width * self.col as f32,
origin.y + metrics.line_height * (self.row as f32 + 1.0) - thickness,
);
let size = gpui::size(metrics.cell_width * self.cells as f32, thickness);
window.paint_quad(fill(Bounds::new(position, size), self.color));
}
}
#[derive(Clone)]
struct BatchedTextRun {
row: i32,
@@ -132,6 +152,7 @@ pub struct TerminalElement {
font_size: Pixels,
line_height: Pixels,
cell_width: Pixels,
tab_id: String,
search_highlights: Option<std::collections::HashMap<(i32, i32), Hsla>>,
}
@@ -142,6 +163,7 @@ pub struct PrepaintState {
runs: Vec<BatchedTextRun>,
custom_blocks: Vec<LayoutCustomBlock>,
cursor: Option<CursorLayout>,
underlines: Vec<LayoutUnderline>,
}
#[derive(Clone)]
@@ -271,6 +293,7 @@ impl TerminalElement {
font_size: Pixels,
line_height: Pixels,
cell_width: Pixels,
tab_id: String,
search_highlights: Option<std::collections::HashMap<(i32, i32), Hsla>>,
) -> Self {
Self {
@@ -282,6 +305,7 @@ impl TerminalElement {
font_size,
line_height,
cell_width,
tab_id,
search_highlights,
}
}
@@ -351,10 +375,19 @@ impl TerminalElement {
fn layout_grid(
&self,
cx: &App,
) -> (Vec<LayoutRect>, Vec<BatchedTextRun>, Vec<LayoutCustomBlock>) {
) -> (
Vec<LayoutRect>,
Vec<BatchedTextRun>,
Vec<LayoutCustomBlock>,
Vec<LayoutUnderline>,
) {
let view_read = self.view.read(cx);
let hovered_url = view_read.hovered_url.clone();
let mut rects = Vec::new();
let mut runs = Vec::new();
let mut custom_blocks = Vec::new();
let mut underlines = Vec::new();
let mut current_run: Option<BatchedTextRun> = None;
// Retrieve cached keyword highlights and merge with search highlights
@@ -408,6 +441,26 @@ impl TerminalElement {
style.color = hl_color;
}
// 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))
{
underlines.push(LayoutUnderline {
row: render_cell.row,
col: render_cell.col,
cells: if cell.flags.contains(Flags::WIDE_CHAR) {
2
} else {
1
},
color: style.color,
});
}
}
// Box Drawing & Block Elements interception
let is_custom_block = is_custom_block_supported(cell.c);
@@ -460,7 +513,12 @@ impl TerminalElement {
runs.push(run);
}
(merge_rects(rects), runs, custom_blocks)
(
merge_rects(rects),
runs,
custom_blocks,
merge_underlines(underlines),
)
}
fn cursor_layout(&self, cx: &App) -> Option<CursorLayout> {
@@ -468,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
@@ -540,7 +600,33 @@ impl Element for TerminalElement {
cx: &mut App,
) -> Self::PrepaintState {
let _ = self.base_text_style(cx);
let (rects, runs, custom_blocks) = self.layout_grid(cx);
let (rects, runs, custom_blocks, underlines) = self.layout_grid(cx);
// Save the precise GPUI-rendered bounds of this terminal element.
// This is 100% accurate because it is recorded during layout prepaint.
let view = self.view.clone();
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();
let cell_width = this.terminal_cell_width();
let w = bounds.size.width.as_f32();
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();
}
});
PrepaintState {
bounds,
metrics: TerminalMetrics {
@@ -551,6 +637,7 @@ impl Element for TerminalElement {
runs,
custom_blocks,
cursor: self.cursor_layout(cx),
underlines,
}
}
@@ -564,19 +651,34 @@ impl Element for TerminalElement {
window: &mut Window,
cx: &mut App,
) {
// 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 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,
);
for rect in &prepaint.rects {
rect.paint(prepaint.bounds.origin, prepaint.metrics, window);
rect.paint(draw_origin, prepaint.metrics, window);
}
for run in &prepaint.runs {
run.paint(prepaint.bounds.origin, prepaint.metrics, window, cx);
run.paint(draw_origin, prepaint.metrics, window, cx);
}
for u in &prepaint.underlines {
u.paint(draw_origin, prepaint.metrics, window);
}
for block in &prepaint.custom_blocks {
let x = prepaint.bounds.origin.x.as_f32()
+ block.col as f32 * prepaint.metrics.cell_width.as_f32();
let y = prepaint.bounds.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,
@@ -592,7 +694,7 @@ impl Element for TerminalElement {
&self.focus_handle,
TerminalInputHandler {
view: self.view.clone(),
element_bounds: prepaint.bounds,
element_bounds: Bounds::new(draw_origin, prepaint.bounds.size),
cell_width: prepaint.metrics.cell_width.as_f32(),
line_height: prepaint.metrics.line_height.as_f32(),
},
@@ -602,8 +704,8 @@ impl Element for TerminalElement {
if let Some(marked_text) = self.marked_text.as_ref().filter(|text| !text.is_empty()) {
if let Some(cursor) = prepaint.cursor {
let pos = point(
prepaint.bounds.origin.x + prepaint.metrics.cell_width * cursor.col as f32,
prepaint.bounds.origin.y + prepaint.metrics.line_height * cursor.row as f32,
draw_origin.x + prepaint.metrics.cell_width * cursor.col as f32,
draw_origin.y + prepaint.metrics.line_height * cursor.row as f32,
);
let mut base_style = self.base_text_style(cx);
base_style.underline = Some(UnderlineStyle {
@@ -650,8 +752,8 @@ impl Element for TerminalElement {
{
return;
}
let x = prepaint.bounds.origin.x + prepaint.metrics.cell_width * cursor.col as f32;
let y = prepaint.bounds.origin.y + prepaint.metrics.line_height * cursor.row as f32;
let x = draw_origin.x + prepaint.metrics.cell_width * cursor.col as f32;
let y = draw_origin.y + prepaint.metrics.line_height * cursor.row as f32;
match cursor.shape {
CursorShape::Hidden => {}
CursorShape::Beam => {
@@ -711,6 +813,23 @@ fn merge_rects(mut rects: Vec<LayoutRect>) -> Vec<LayoutRect> {
merged
}
fn merge_underlines(mut underlines: Vec<LayoutUnderline>) -> Vec<LayoutUnderline> {
underlines.sort_by_key(|u| (u.row, u.col));
let mut merged: Vec<LayoutUnderline> = Vec::with_capacity(underlines.len());
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 {
last.cells += u.cells;
continue;
}
}
merged.push(u);
}
merged
}
fn selection_contains(selection: ViewportSelection, row: i32, col: i32) -> bool {
let row = row.max(0) as usize;
let col = col.max(0) as usize;
+582 -174
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,16 +785,6 @@ pub fn highlight_cells(
}
}
// ── 32. URLs ───────────────────────────────────────────
for m in find_urls(text) {
let url_len = find_url_len(&text[m..]);
let start_col = byte_to_col[m];
let end_col = byte_to_col[(m + url_len - 1).min(byte_to_col.len() - 1)];
for c in start_col..=end_col {
map.entry((row_i32, c)).or_insert(colors.url);
}
}
// ── 33. Port numbers ───────────────────────────────────
for m in find_ports(text) {
let port_len = find_port_len(&text[m..]);
@@ -497,6 +795,21 @@ pub fn highlight_cells(
}
}
}
// ── 32. URLs (Logical lines for wrapping support) ───────────
let logical_lines = build_logical_lines(cells, rows);
for line in &logical_lines {
let text = line.text.as_str();
for m in find_urls(text) {
let url_len = find_url_len(&text[m..]);
for i in 0..url_len {
let idx = m + i;
if idx < line.byte_to_cell.len() {
let (r, c) = line.byte_to_cell[idx];
map.entry((r as i32, c as i32)).or_insert(colors.url);
}
}
}
}
map
}
@@ -530,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> {
@@ -543,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);
}
@@ -630,3 +935,106 @@ fn find_port_len(text: &str) -> usize {
}
len
}
#[derive(Clone)]
pub struct LogicalLine<'a> {
pub text: String,
pub byte_to_cell: Vec<(usize, usize)>,
pub row_cells: Vec<&'a RenderCell>,
}
pub fn build_logical_lines<'a>(cells: &'a [RenderCell], rows: usize) -> Vec<LogicalLine<'a>> {
let mut row_chars: Vec<Vec<&RenderCell>> = vec![Vec::with_capacity(128); rows];
for rc in cells {
if rc.row < 0 || (rc.row as usize) >= rows {
continue;
}
row_chars[rc.row as usize].push(rc);
}
for row in row_chars.iter_mut() {
row.sort_by_key(|rc| rc.col);
}
let mut logical_lines = Vec::new();
let mut current_line: Option<LogicalLine> = None;
for (row_idx, row_cells) in row_chars.into_iter().enumerate() {
if row_cells.is_empty() {
if let Some(line) = current_line.take() {
logical_lines.push(line);
}
continue;
}
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)
})
})
};
if !wraps_from_prev {
if let Some(line) = current_line.take() {
logical_lines.push(line);
}
}
let mut line = current_line.take().unwrap_or_else(|| LogicalLine {
text: String::with_capacity(128),
byte_to_cell: Vec::with_capacity(128),
row_cells: Vec::new(),
});
for rc in row_cells {
line.text.push(rc.cell.c);
let end_len = line.text.len();
while line.byte_to_cell.len() < end_len {
line.byte_to_cell.push((rc.row as usize, rc.col as usize));
}
line.row_cells.push(rc);
}
current_line = Some(line);
}
if let Some(line) = current_line.take() {
logical_lines.push(line);
}
logical_lines
}
pub fn find_url_at_cell(
cells: &[RenderCell],
rows: usize,
row: usize,
col: usize,
) -> Option<(String, Vec<(usize, usize)>)> {
let logical_lines = build_logical_lines(cells, rows);
for line in logical_lines {
let text = line.text.as_str();
for m in find_urls(text) {
let url_len = find_url_len(&text[m..]);
let mut url_cells = Vec::with_capacity(url_len);
let mut matched = false;
for i in 0..url_len {
let idx = m + i;
if idx < line.byte_to_cell.len() {
let (r, c) = line.byte_to_cell[idx];
if r == row && c == col {
matched = true;
}
url_cells.push((r, c));
}
}
if matched {
let url_str = text[m..m + url_len].to_string();
return Some((url_str, url_cells));
}
}
}
None
}
+39 -9
View File
@@ -23,6 +23,7 @@ impl Ashell {
window: &mut Window,
cx: &mut Context<Self>,
) {
self.cmd_ctrl_pressed = event.keystroke.modifiers.platform;
// If the search input is focused, skip terminal key processing
// so the input can handle text entry, paste, etc. normally.
if self
@@ -41,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);
@@ -164,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();
@@ -203,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();
}
@@ -235,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();
@@ -290,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();
@@ -379,6 +380,35 @@ impl Ashell {
}
return;
}
// Track URL hover
let mut hovered_url = None;
let cmd_ctrl_pressed = 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(active_id) = &self.active_tab {
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(),
cells: url_cells,
});
}
}
}
}
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;
cx.notify();
}
if !self.terminal_selecting || event.pressed_button != Some(MouseButton::Left) {
return;
}
@@ -454,7 +484,7 @@ impl Ashell {
cx.notify();
}
fn terminal_grid_point_and_side(
pub(crate) fn terminal_grid_point_and_side(
&self,
position: Point<Pixels>,
) -> Option<(usize, usize, Side)> {
+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 {