Compare commits

...
59 Commits
Author SHA1 Message Date
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
TomZz fcfbefe3ab fix: do not label Maple Mono as built-in if loaded from system 2026-06-27 18:32:50 +08:00
TomZz 39d369aaaf fix: conditionally load built-in fonts if not present in system (resolves #65) 2026-06-27 18:28:25 +08:00
TomZz 5c0c0cc701 fix: default empty or missing session proxy type to 'none' 2026-06-27 18:23:46 +08:00
TomZz 99b7fe30e4 fix: restrict terminal copy/paste actions to terminal focus 2026-06-27 18:21:28 +08:00
TomZz 24f817f9e0 revert: remove manual keyboard-interactive authentication settings and dialog 2026-06-27 18:13:37 +08:00
TomZz 1a3f638004 fix: support bracketed paste mode (fixes #62) 2026-06-27 11:48:17 +08:00
TomZz 5f318f66c8 fix: draw background rectangle for cells with INVERSE flag (fixes #66) 2026-06-27 11:44:29 +08:00
TomZz 8854ee9afb fix: restore connection progress dialog and support Enter to retry 2026-06-27 00:37:33 +08:00
TomZz 8cc64acdc1 fix: smooth terminal font zooming using scroll accumulator 2026-06-27 00:23:20 +08:00
peace-young 77a0380ba7 feat: add Ctrl+scroll to zoom terminal font size (#64) 2026-06-27 00:08:23 +08:00
TomZz 3770826764 bump version to v0.4.8 2026-06-26 11:41:23 +08:00
TomZz cae6323888 fix: reduce keepalive interval and max attempts for faster network drop detection 2026-06-26 11:35:13 +08:00
TomZz 54bcc97fe1 fix: restore graceful terminal exit tab closure 2026-06-26 11:29:50 +08:00
phygerr 273bc2fe0c feature:1、支持断开重连功能;2、支持更多高亮关键词 (#59) 2026-06-26 11:07:25 +08:00
TomZz f0e02e0ce7 fix: resolve sftp connection timeout and disconnect handling (resolves #57) 2026-06-25 03:32:31 +08:00
TomZz f6d9cd00e7 feat: add global proxy settings and startup env reader (resolves #53) 2026-06-25 03:20:33 +08:00
TomZz bfc7c9ac83 feat: support keybinding configuration for terminal copy/paste and update hint text 2026-06-25 02:33:34 +08:00
TomZz fd19daedad feat: allow left-click to copy terminal selection text 2026-06-25 02:28:26 +08:00
TomZz 042834c7d5 feat: support custom terminal cursor style and blinking setting 2026-06-25 02:25:32 +08:00
TomZz 3c6b9e3b46 fix: correct active window check logic to fix window controls greying out when app loses focus 2026-06-24 18:06:52 +08:00
TomZz a88379b01c fix: prevent sidebar from flashing and resizing on window resize / fullscreen 2026-06-24 18:01:40 +08:00
TomZz f322b2c717 fix: beautify window control traffic lights on Windows/Linux 2026-06-24 17:46:57 +08:00
TomZz 45d0c64c06 fix: resolve gpui window not found on exit and restore layout saving on Windows 2026-06-24 17:25:25 +08:00
TomZz 6312a482cb fix: preserve original windowed bounds when saving maximized state to fix restore size on startup 2026-06-24 17:12:33 +08:00
TomZz 8bb4cbc655 fix: register WindowControlArea for window controls on non-macos to support restore toggling 2026-06-24 16:58:22 +08:00
TomZz 847f0626f4 refactor: use titlebar_double_click to toggle maximize/restore correctly 2026-06-24 16:24:15 +08:00
TomZz 7cfe5c64d5 refactor: manually implement Default for ConfigFile to respect default settings 2026-06-23 23:57:30 +08:00
TomZz 6d23d27fea feat: implement throttled auto-scrolling when dragging selection near viewport boundaries 2026-06-23 15:39:01 +08:00
TomZz da856ac9d7 fix: clamp selection coordinates when they scroll out of viewport to prevent selection from breaking 2026-06-23 15:28:46 +08:00
TomZz 7653c66a6f feat: add setting switch for terminal keyword highlighting under copy/paste option 2026-06-23 10:44:13 +08:00
TomZz 56fe508122 refactor: optimize keyword highlighting by caching results and moving logic out of custom_blocks 2026-06-23 10:44:11 +08:00
phygerr 3b7165a4bd feature:1、新增Phyger主题;2、支持terminal搜索 (#52) 2026-06-23 10:43:26 +08:00
TomZz 5ae44c9e44 bump version to 0.4.7 2026-06-23 00:23:11 +08:00
TomZz 73938aef8a feat: support keyboard-interactive auth and interactive passphrase prompts (resolves #33) 2026-06-23 00:16:48 +08:00
33 changed files with 7939 additions and 1363 deletions
Generated
+96 -4
View File
@@ -315,7 +315,7 @@ dependencies = [
[[package]]
name = "ashell"
version = "0.4.6"
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",
@@ -355,11 +357,13 @@ dependencies = [
"thiserror 1.0.69",
"time",
"tokio",
"tokio-socks",
"tracing",
"tracing-appender",
"tracing-subscriber",
"uuid",
"walkdir",
"winreg 0.52.0",
"winres",
"zip",
]
@@ -2759,7 +2763,7 @@ dependencies = [
"itertools 0.14.0",
"log",
"lyon",
"mach2",
"mach2 0.5.0",
"media",
"metal",
"num_cpus",
@@ -2949,7 +2953,7 @@ dependencies = [
"itertools 0.14.0",
"libc",
"log",
"mach2",
"mach2 0.5.0",
"media",
"metal",
"objc",
@@ -3682,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"
@@ -3951,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"
@@ -4098,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"
@@ -4396,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"
@@ -6896,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"
@@ -8132,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"
@@ -8309,7 +8391,7 @@ dependencies = [
"itertools 0.14.0",
"libc",
"log",
"mach2",
"mach2 0.5.0",
"nix 0.29.0",
"percent-encoding",
"regex",
@@ -9562,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"
+21 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "ashell"
version = "0.4.6"
version = "0.4.12"
edition = "2024"
authors = ["ashell contributors"]
description = "A GPUI Component based SSH and local terminal client"
@@ -9,6 +9,7 @@ rust-version = "1.85.0"
[dependencies]
anyhow = "1"
tokio-socks = "0.5"
alacritty_terminal = { git = "https://github.com/zed-industries/alacritty", rev = "fcf32feacb367b75ec84dd40f041e4fd411d3cc1" }
async-trait = "0.1"
chrono = { version = "0.4", features = ["serde"] }
@@ -49,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
+308
View File
@@ -0,0 +1,308 @@
{
"$schema": "https://github.com/longbridge/gpui-component/raw/refs/heads/main/.theme-schema.json",
"name": "Phyger",
"author": "phygerr",
"url": "https://github.com/phygerr",
"themes": [
{
"name": "Phyger Light",
"mode": "light",
"colors": {
"accent.background": "#D6E4F0",
"accent.foreground": "#1B3A5C",
"background": "#F8F9FC",
"border": "#D0D5DD",
"danger.background": "#D94848",
"danger.active.background": "#B53535",
"danger.foreground": "#FFFFFF",
"foreground": "#2B2D30",
"input.border": "#C0C7D0",
"link.active.foreground": "#2563EB",
"link.foreground": "#2563EB",
"link.hover.foreground": "#1D4ED8",
"list.active.background": "#2563EB18",
"list.hover.background": "#E8EDF4",
"list.active.border": "#2563EB",
"list.even.background": "#F0F2F7",
"muted.background": "#E2E6EE88",
"muted.foreground": "#7A7F88",
"panel.background": "#ECEEF3",
"popover.background": "#F8F9FC",
"popover.foreground": "#2B2D30",
"primary.active.background": "#1D4ED8",
"primary.background": "#2563EB",
"primary.foreground": "#FFFFFF",
"primary.hover.background": "#3B72F0",
"ring": "#2563EB",
"scrollbar.background": "#F8F9FC00",
"scrollbar.thumb.background": "#C0C7D0",
"secondary.background": "#E8EDF4",
"secondary.active.background": "#D0D5DD",
"secondary.foreground": "#2B2D30",
"secondary.hover.background": "#DCE2EB",
"tab.active.background": "#F8F9FC",
"tab.active.foreground": "#2B2D30",
"tab.background": "#ECEEF300",
"tab.foreground": "#7A7F88",
"tab_bar.background": "#ECEEF3",
"title_bar.background": "#ECEEF3",
"title_bar.border": "#D0D5DD",
"base.magenta.light": "#C2185B44",
"base.red": "#D32F2F",
"base.green": "#2E7D32",
"base.yellow": "#BF8C00",
"base.blue": "#1565C0",
"base.magenta": "#8E24AA",
"base.cyan": "#00838F"
},
"highlight": {
"editor.foreground": "#2B2D30",
"editor.background": "#F8F9FC",
"editor.active_line.background": "#E8EDF4",
"editor.line_number": "#A0A5AE",
"editor.active_line_number": "#2B2D30",
"editor.invisible": "#A0A5AE55",
"conflict": "#D32F2F",
"created": "#2E7D32",
"deleted": "#C62828",
"error": "#D32F2F",
"hidden": "#A0A5AE",
"hint": "#1565C0",
"ignored": "#A0A5AE",
"info": "#00838F",
"modified": "#BF8C00",
"predictive": "#A0A5AE",
"renamed": "#BF8C00",
"success": "#2E7D32",
"unreachable": "#A0A5AE",
"warning": "#E65100",
"syntax": {
"attribute": {
"color": "#7B1FA2"
},
"boolean": {
"color": "#00838F"
},
"comment": {
"color": "#6A737D",
"font_style": "italic"
},
"comment.doc": {
"color": "#6A737D",
"font_style": "italic"
},
"constant": {
"color": "#00838F"
},
"constructor": {
"color": "#1565C0"
},
"embedded": {
"color": "#2B2D30"
},
"function": {
"color": "#6A1B9A"
},
"keyword": {
"color": "#1565C0"
},
"link_text": {
"color": "#1565C0",
"font_style": "normal"
},
"link_uri": {
"color": "#00838F",
"font_style": "italic"
},
"number": {
"color": "#1B5E20"
},
"string": {
"color": "#BF360C"
},
"string.escape": {
"color": "#BF360C"
},
"string.regex": {
"color": "#BF360C"
},
"string.special": {
"color": "#00838F"
},
"string.special.symbol": {
"color": "#00838F"
},
"tag": {
"color": "#1565C0"
},
"text.literal": {
"color": "#00838F"
},
"title": {
"color": "#6A1B9A",
"font_weight": 600
},
"type": {
"color": "#2E7D32"
},
"property": {
"color": "#2B2D30"
},
"variable.special": {
"color": "#D32F2F"
}
}
}
},
{
"name": "Phyger Dark",
"mode": "dark",
"colors": {
"accent.background": "#2A3040",
"accent.foreground": "#C8D3E0",
"background": "#1A1D23",
"border": "#2E3340",
"danger.background": "#E05555",
"danger.active.background": "#CC3D3D",
"danger.foreground": "#FFFFFF",
"foreground": "#C8CED8",
"input.border": "#3A4050",
"link.active.foreground": "#6CB4EE",
"link.foreground": "#6CB4EE",
"link.hover.foreground": "#8FC8F6",
"list.active.background": "#6CB4EE15",
"list.hover.background": "#252A34",
"list.active.border": "#6CB4EE",
"list.even.background": "#1E2229",
"muted.background": "#252A3466",
"muted.foreground": "#6B7280",
"panel.background": "#1E2229",
"popover.background": "#1A1D23",
"popover.foreground": "#C8CED8",
"primary.active.background": "#4A90D9",
"primary.background": "#5BA0E0",
"primary.foreground": "#1A1D23",
"primary.hover.background": "#6CB4EE",
"ring": "#5BA0E0",
"scrollbar.background": "#1A1D2300",
"scrollbar.thumb.background": "#3A4050",
"secondary.active.background": "#333A48",
"secondary.background": "#252A34",
"secondary.foreground": "#C8CED8",
"secondary.hover.background": "#2E3542",
"tab.active.background": "#1A1D23",
"tab.active.foreground": "#C8CED8",
"tab.background": "#252A3400",
"tab.foreground": "#6B7280",
"tab_bar.background": "#1E2229DD",
"title_bar.background": "#1E2229",
"title_bar.border": "#2E3340",
"base.magenta.light": "#C792EA44",
"base.red": "#E06060",
"base.green": "#7EC699",
"base.yellow": "#E8C97A",
"base.blue": "#6CB4EE",
"base.magenta": "#C792EA",
"base.cyan": "#56D4C7"
},
"highlight": {
"editor.foreground": "#C8CED8",
"editor.background": "#1A1D23",
"editor.active_line.background": "#222730",
"editor.line_number": "#5A6170",
"editor.active_line_number": "#C8CED8",
"editor.invisible": "#5A617055",
"conflict": "#E06060",
"created": "#7EC699",
"deleted": "#CC4444",
"error": "#E06060",
"hidden": "#6B7280",
"hint": "#C792EA",
"ignored": "#5A6170",
"info": "#56D4C7",
"modified": "#E8C97A",
"predictive": "#4A5568",
"renamed": "#E8C97A",
"success": "#7EC699",
"unreachable": "#5A6170",
"warning": "#E8C97A",
"syntax": {
"attribute": {
"color": "#C792EA"
},
"boolean": {
"color": "#56D4C7"
},
"comment": {
"color": "#5A6A7A",
"font_style": "italic"
},
"comment.doc": {
"color": "#5A6A7A",
"font_style": "italic"
},
"constant": {
"color": "#56D4C7"
},
"constructor": {
"color": "#6CB4EE"
},
"embedded": {
"color": "#C8CED8"
},
"function": {
"color": "#E2C08D"
},
"keyword": {
"color": "#6CB4EE"
},
"link_text": {
"color": "#6CB4EE",
"font_style": "normal"
},
"link_uri": {
"color": "#56D4C7",
"font_style": "italic"
},
"number": {
"color": "#7EC699"
},
"string": {
"color": "#E8A87C"
},
"string.escape": {
"color": "#E8A87C"
},
"string.regex": {
"color": "#E8A87C"
},
"string.special": {
"color": "#56D4C7"
},
"string.special.symbol": {
"color": "#56D4C7"
},
"tag": {
"color": "#6CB4EE"
},
"text.literal": {
"color": "#56D4C7"
},
"title": {
"color": "#E2C08D",
"font_weight": 600
},
"type": {
"color": "#7EC699"
},
"property": {
"color": "#C8CED8"
},
"variable.special": {
"color": "#E06060"
}
}
}
}
]
}
+46 -1
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"
@@ -62,11 +69,22 @@ add_ssh: "+ ssh"
open_local_or_ssh: "Open a local terminal or pick an SSH session."
open_session: "Open Session"
settings: "Settings"
proxy: "Proxy"
proxy_none: "Follow Global"
proxy_host: "Proxy Host"
proxy_port: "Proxy Port"
proxy_user: "Proxy Username (optional)"
proxy_password: "Proxy Password (optional)"
ui_font_size: "UI Font Size"
terminal_font_size: "Terminal Font Size"
system_default: "System Default"
ui_font_family: "UI Font"
terminal_font_family: "Terminal Font"
cursor_style: "Cursor Style"
cursor_style_default: "Default"
cursor_style_blink: "Blink"
cursor_style_beam: "Line"
cursor_style_beam_blink: "Line Blink"
monitoring_position: "Monitoring Position"
position_bottom: "Bottom"
position_sidebar: "Sidebar"
@@ -76,6 +94,8 @@ local_terminal_opened: "local terminal opened"
connecting: "Connecting"
starting_connection: "starting connection..."
connection_failed: "Connection Failed"
session_disconnected: "Session disconnected: %{reason}"
press_enter_to_reconnect: "Press Enter to reconnect"
preview_failed: "Preview failed: %{err}"
downloading_file: "Downloading %{base}..."
@@ -111,9 +131,16 @@ zombie_reason: "This transfer was interrupted because the application exited. Th
software_builtin: "Built-in"
retry: "Retry"
right_click_copy_paste: "Right-click Copy/Paste"
copy_paste_hint: "Regardless of this switch, you can always use %{key} + C/V to 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"
@@ -142,6 +169,9 @@ files_uploading: "%{count} files uploading"
panel_minimize: "Minimize Panel"
panel_expand: "Expand Panel"
settings_new_ssh: "New SSH"
settings_open_search: "Search"
search: "Search"
no_results: "No results"
settings_toggle_sidebar: "Toggle Sidebar"
settings_toggle_sftp_zoom: "Toggle SFTP Zoom"
settings_focus_pane_left: "Focus Pane Left"
@@ -179,3 +209,18 @@ sync_downloading: "Downloading and decrypting configuration..."
sync_upload_complete: "Configuration uploaded"
sync_download_complete: "Configuration downloaded"
sync_failed: "Synchronization failed"
settings_proxy: "Proxy"
enable_proxy: "Enable Proxy"
read_env_proxy: "Read environment variables on startup"
read_env_proxy_desc: "If enabled, the global proxy settings below will be overridden by environment variables on startup."
global_proxy_settings: "Global Proxy Configuration"
global_proxy_type: "Proxy Type"
global_proxy_host: "Proxy Host"
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"
+46 -1
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: "保存并连接"
@@ -63,11 +70,22 @@ add_ssh: "+ SSH"
open_local_or_ssh: "打开本地终端或选择一个 SSH 会话。"
open_session: "打开会话"
settings: "设置"
proxy: "代理"
proxy_none: "跟随全局"
proxy_host: "代理服务器地址"
proxy_port: "端口"
proxy_user: "用户名 (可选)"
proxy_password: "密码 (可选)"
ui_font_size: "界面字体大小"
terminal_font_size: "终端字体大小"
system_default: "系统默认"
ui_font_family: "界面字体"
terminal_font_family: "终端字体"
cursor_style: "光标样式"
cursor_style_default: "默认"
cursor_style_blink: "闪烁"
cursor_style_beam: "细线"
cursor_style_beam_blink: "细线闪烁"
monitoring_position: "系统监控显示位置"
position_bottom: "底部"
position_sidebar: "侧边栏"
@@ -77,6 +95,8 @@ local_terminal_opened: "已打开本地终端"
connecting: "正在连接"
starting_connection: "开始连接..."
connection_failed: "连接失败"
session_disconnected: "会话已断开: %{reason}"
press_enter_to_reconnect: "按 Enter 重新连接"
preview_failed: "预览失败: %{err}"
downloading_file: "正在下载 %{base}..."
@@ -112,9 +132,16 @@ zombie_reason: "此传输任务因程序退出而中断,传输进程已不存
software_builtin: "软件内置"
retry: "重试"
right_click_copy_paste: "右键复制/粘贴"
copy_paste_hint: "不管是否开启此开关,都可以使用 %{key} + C/V 快捷键进行复制和粘贴。"
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: "克隆"
@@ -144,6 +171,9 @@ files_uploading: "%{count} 个文件正在上传"
panel_minimize: "折叠面板"
panel_expand: "展开面板"
settings_new_ssh: "新建 SSH"
settings_open_search: "搜索"
search: "搜索"
no_results: "无结果"
settings_toggle_sidebar: "缩放侧边栏"
settings_toggle_sftp_zoom: "缩放 SFTP 面板"
settings_focus_pane_left: "向左切换焦点"
@@ -181,3 +211,18 @@ sync_downloading: "正在下载并解密配置..."
sync_upload_complete: "配置上传完成"
sync_download_complete: "配置下载完成"
sync_failed: "同步失败"
settings_proxy: "代理设置"
enable_proxy: "启用代理功能"
read_env_proxy: "启动时读取环境变量"
read_env_proxy_desc: "若开启此项,启动时将读取环境变量,下方配置的全局代理设置将失效。"
global_proxy_settings: "全局代理配置"
global_proxy_type: "代理类型"
global_proxy_host: "代理地址"
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";
+987 -463
View File
File diff suppressed because it is too large Load Diff
+47 -5
View File
@@ -18,6 +18,7 @@ gpui::actions!(
OpenSession,
OpenTransfers,
NewSsh,
OpenSearch,
ToggleSidebar,
ToggleSftpZoom,
FocusPaneLeft,
@@ -28,7 +29,9 @@ gpui::actions!(
SplitPaneRight,
SplitPaneUp,
SplitPaneDown,
ClosePane
ClosePane,
Copy,
Paste
]
);
@@ -62,6 +65,11 @@ pub(crate) const WORKSPACE_ACTIONS: &[WorkspaceAction] = &[
label_key: "settings_new_ssh",
default_suffix: "n",
},
WorkspaceAction {
id: "OpenSearch",
label_key: "settings_open_search",
default_suffix: "f",
},
WorkspaceAction {
id: "ToggleSidebar",
label_key: "settings_toggle_sidebar",
@@ -117,6 +125,24 @@ pub(crate) const WORKSPACE_ACTIONS: &[WorkspaceAction] = &[
label_key: "settings_close_pane",
default_suffix: "w",
},
WorkspaceAction {
id: "Copy",
label_key: "settings_copy",
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"
},
},
];
pub(crate) fn default_modifier() -> &'static str {
@@ -192,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()),
@@ -206,6 +232,7 @@ pub(crate) fn unbind_all_workspace_keys(cx: &mut App, config: &ConfigStore) {
unbind_action!("OpenSession", crate::OpenSession);
unbind_action!("OpenTransfers", crate::OpenTransfers);
unbind_action!("NewSsh", crate::NewSsh);
unbind_action!("OpenSearch", crate::OpenSearch);
unbind_action!("ToggleSidebar", crate::ToggleSidebar);
unbind_action!("ToggleSftpZoom", crate::ToggleSftpZoom);
unbind_action!("FocusPaneLeft", crate::FocusPaneLeft);
@@ -217,6 +244,8 @@ pub(crate) fn unbind_all_workspace_keys(cx: &mut App, config: &ConfigStore) {
unbind_action!("SplitPaneUp", crate::SplitPaneUp);
unbind_action!("SplitPaneDown", crate::SplitPaneDown);
unbind_action!("ClosePane", crate::ClosePane);
unbind_action!("Copy", crate::Copy);
unbind_action!("Paste", crate::Paste);
cx.bind_keys(bindings);
}
@@ -233,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()));
}
}
@@ -253,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));
}
};
}
@@ -261,6 +292,7 @@ fn bind_workspace_actions(cx: &mut App, config: &ConfigStore) {
bind_action!("OpenSession", crate::OpenSession);
bind_action!("OpenTransfers", crate::OpenTransfers);
bind_action!("NewSsh", crate::NewSsh);
bind_action!("OpenSearch", crate::OpenSearch);
bind_action!("ToggleSidebar", crate::ToggleSidebar);
bind_action!("ToggleSftpZoom", crate::ToggleSftpZoom);
bind_action!("FocusPaneLeft", crate::FocusPaneLeft);
@@ -272,6 +304,8 @@ fn bind_workspace_actions(cx: &mut App, config: &ConfigStore) {
bind_action!("SplitPaneUp", crate::SplitPaneUp);
bind_action!("SplitPaneDown", crate::SplitPaneDown);
bind_action!("ClosePane", crate::ClosePane);
bind_action!("Copy", crate::Copy);
bind_action!("Paste", crate::Paste);
cx.bind_keys(bindings);
}
@@ -281,7 +315,15 @@ impl KeybindingsPage {
let groups = [
(
"settings_group_keybind_general",
vec!["OpenSettings", "OpenSession", "OpenTransfers", "NewSsh"],
vec![
"OpenSettings",
"OpenSession",
"OpenTransfers",
"NewSsh",
"OpenSearch",
"Copy",
"Paste",
],
),
(
"settings_group_keybind_zoom",
+549 -58
View File
@@ -2,6 +2,8 @@ pub mod config_sync;
pub mod constants;
pub mod dialogs;
pub mod keybinding_recorder;
pub mod resizable;
pub mod search;
pub mod startup;
pub mod theme;
pub mod ui;
@@ -15,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,
@@ -22,7 +25,6 @@ use gpui::{
use gpui_component::{
Theme, ThemeMode, ThemeRegistry,
input::{InputEvent, InputState},
resizable::ResizableState,
scroll::ScrollbarHandle,
};
use rust_i18n::t;
@@ -30,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},
};
@@ -209,6 +212,18 @@ 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>,
pub(crate) proxy_user_input: Entity<InputState>,
pub(crate) proxy_password_input: Entity<InputState>,
pub(crate) global_proxy_type: String,
pub(crate) global_proxy_host_input: Entity<InputState>,
pub(crate) global_proxy_port_input: Entity<InputState>,
pub(crate) global_proxy_user_input: Entity<InputState>,
pub(crate) global_proxy_password_input: Entity<InputState>,
pub(crate) sync_endpoint_input: Entity<InputState>,
pub(crate) sync_username_input: Entity<InputState>,
pub(crate) sync_webdav_password_input: Entity<InputState>,
@@ -224,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,
@@ -231,6 +248,7 @@ pub(crate) struct Ashell {
pub(crate) dark_theme_name: SharedString,
pub(crate) ui_font_size: f32,
pub(crate) terminal_font_size: f32,
pub(crate) terminal_zoom_accumulator: f32,
pub(crate) ui_font_family: SharedString,
pub(crate) terminal_font_family: SharedString,
pub(crate) tabs: Vec<TerminalTab>,
@@ -273,6 +291,7 @@ pub(crate) struct Ashell {
pub(crate) status: SharedString,
pub(crate) config: ConfigStore,
pub(crate) active_title_bar_style: crate::session::config::TitleBarStyle,
pub(crate) cursor_style: crate::session::config::CursorStyle,
pub(crate) system_sampler: SystemSampler,
pub(crate) recording_action: Option<String>,
pub(crate) active_dialog: Option<DialogKind>,
@@ -287,6 +306,14 @@ pub(crate) struct Ashell {
pub(crate) last_system_sample: Instant,
pub(crate) last_theme_sync: Instant,
pub(crate) search_input: Entity<InputState>,
pub(crate) search_active: bool,
pub(crate) search_query: String,
pub(crate) search_matches: Vec<(i32, i32)>,
pub(crate) search_current: usize,
pub(crate) search_target_tab: Option<String>,
pub(crate) search_bar_bounds: Option<Bounds<Pixels>>,
pub(crate) system_tab_id: Option<String>,
pub(crate) sftp_handles: std::collections::HashMap<String, crate::sftp::SftpHandle>,
@@ -294,7 +321,21 @@ pub(crate) struct Ashell {
pub(crate) runtime: Runtime,
pub(crate) events_rx: mpsc::Receiver<BackendEvent>,
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)]
@@ -364,13 +405,53 @@ impl Ashell {
.placeholder("SSH private key passphrase (optional)")
.masked(true)
});
let baud_rate_input = cx.new(|cx| InputState::new(window, cx).default_value("115200"));
let proxy_host_input =
cx.new(|cx| InputState::new(window, cx).placeholder(t!("proxy_host").to_string()));
let proxy_port_input =
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 config = ConfigStore::load().unwrap_or_else(|err| {
tracing::warn!("failed to load config: {err:#}");
ConfigStore::in_memory()
});
let global_proxy_host_input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder(t!("proxy_host").to_string())
.default_value(config.global_proxy_host())
});
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(),
)
});
let global_proxy_user_input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder(t!("proxy_user").to_string())
.default_value(config.global_proxy_user())
});
let global_proxy_password_input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder(t!("proxy_password").to_string())
.masked(true)
.default_value(config.global_proxy_password())
});
let sync_endpoint_input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder("https://dav.example.com/ashell/")
@@ -434,8 +515,14 @@ 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),
cx.subscribe_in(&proxy_password_input, window, Self::on_input_event),
cx.subscribe_in(&sftp_path_input, window, Self::on_input_event),
cx.subscribe_in(&sftp_new_folder_input, window, Self::on_input_event),
cx.subscribe_in(&search_input, window, Self::on_input_event),
cx.subscribe_in(&sync_endpoint_input, window, Self::on_input_event),
cx.subscribe_in(&sync_username_input, window, Self::on_input_event),
cx.subscribe_in(&sync_webdav_password_input, window, Self::on_input_event),
@@ -492,6 +579,10 @@ impl Ashell {
gpui_component::set_locale(&active_locale);
let ui_font_family: SharedString = config.ui_font_family().into();
let terminal_font_family: SharedString = config.terminal_font_family().into();
let last_sidebar_width = Some(px(config
.workspace_panels()
.and_then(|s| s.first().copied())
.unwrap_or(constants::SIDEBAR_WIDTH)));
let mut this = Self {
focus_handle: cx.focus_handle(),
selector_focus_handle: cx.focus_handle(),
@@ -503,6 +594,18 @@ 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,
proxy_user_input,
proxy_password_input,
global_proxy_type: config.global_proxy_type().to_string(),
global_proxy_host_input,
global_proxy_port_input,
global_proxy_user_input,
global_proxy_password_input,
sync_endpoint_input,
sync_username_input,
sync_webdav_password_input,
@@ -518,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,
@@ -525,6 +630,8 @@ impl Ashell {
dark_theme_name,
ui_font_size: config.ui_font_size(),
terminal_font_size: config.terminal_font_size(),
terminal_zoom_accumulator: 0.0,
cursor_style: config.cursor_style(),
ui_font_family,
terminal_font_family,
tabs: Vec::new(),
@@ -592,6 +699,14 @@ impl Ashell {
last_system_sample: Instant::now(),
last_theme_sync: Instant::now(),
search_input,
search_active: false,
search_query: String::new(),
search_matches: Vec::new(),
search_current: 0,
search_target_tab: None,
search_bar_bounds: None,
system_tab_id: None,
sftp_handles: std::collections::HashMap::new(),
@@ -599,7 +714,14 @@ impl Ashell {
runtime: Runtime::new().expect("create tokio runtime"),
events_rx,
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);
@@ -608,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>,
@@ -650,13 +840,46 @@ impl Ashell {
}
_ => {}
}
} else if input == &self.search_input {
if let InputEvent::PressEnter { .. } = event {
if self.search_query.is_empty()
|| self.search_input.read(cx).text().to_string() != self.search_query
{
self.perform_search(window, cx);
} else {
self.search_goto_next(cx);
}
window.prevent_default();
cx.stop_propagation();
}
}
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()
.timer(Duration::from_millis(16))
@@ -666,14 +889,19 @@ impl Ashell {
let changed = this.drain_backend_events();
let system_sampled = this.sample_system_if_due();
this.sync_theme_if_due(cx);
if changed || system_sampled {
let is_blinking = matches!(
this.cursor_style,
crate::session::config::CursorStyle::Blink
| 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);
if changed || system_sampled || blink_due {
cx.notify();
idle_frames = 0;
} else {
idle_frames += 1;
if idle_frames >= 60 {
cx.notify();
idle_frames = 0;
if blink_due {
last_blink_time = now;
}
}
})
@@ -694,11 +922,13 @@ impl Ashell {
match event {
BackendEvent::Output { tab_id, bytes } => {
if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == tab_id) {
tab.backend_initialized = true;
tab.feed(&bytes);
}
}
BackendEvent::Status { tab_id, text } => {
if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == tab_id) {
tab.backend_initialized = true;
tab.status = text.clone();
}
if let Some(progress) = self.connection_progress.as_mut() {
@@ -706,14 +936,16 @@ impl Ashell {
progress.lines.push(text.clone().into());
let _idx = progress.lines.len().saturating_sub(1);
self.connection_scroll_handle
.set_offset(point(px(0.), px(-99999.0)));
.set_offset(gpui::point(px(0.), px(-99999.0)));
}
}
self.status = text.into();
}
BackendEvent::Connected { tab_id } => {
if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == tab_id) {
tab.backend_initialized = true;
tab.connected = true;
tab.disconnected_reason = None;
}
self.sync_system_tab_to_active_group();
self.request_active_system_snapshot();
@@ -784,67 +1016,45 @@ impl Ashell {
}
BackendEvent::Closed { tab_id, reason } => {
self.remote_sample_in_flight = false;
let mut tab_title = None;
let mut session_label = None;
let is_stale = self
.tabs
.iter()
.find(|t| t.id == tab_id)
.is_some_and(|tab| {
// After retry_disconnected_tab, the old backend's threads
// may still send Closed events. Skip those — they arrive
// before the new backend sends its first Output/Connected.
// Once backend_initialized is set, any Closed is from the
// current backend and should be processed.
tab.backend_generation > 0 && !tab.backend_initialized
});
if is_stale {
continue;
}
let is_graceful_exit =
reason == "local shell closed" || reason == "ssh session closed";
if is_graceful_exit {
self.handle_tab_close(tab_id.clone());
self.status = reason.into();
continue;
}
if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == tab_id) {
tab.connected = false;
tab.status = reason.clone();
tab_title = Some(tab.title.clone());
session_label = tab.session.as_ref().map(|session| {
format!("{}@{}:{}", session.user, session.host, session.port)
});
tab.disconnected_reason = Some(reason.clone());
}
if self.system_tab_id.as_deref() == Some(tab_id.as_str()) {
self.system_status = Some(reason.clone().into());
}
let is_graceful_exit =
reason == "local shell closed" || reason == "ssh session closed";
// Auto-close the pane on graceful exit (e.g. user typed exit)
if is_graceful_exit {
self.handle_tab_close(tab_id.clone());
self.status = reason.into();
self.remote_sample_in_flight = false;
return changed;
}
let mut needs_new_progress = false;
if let Some(progress) = self.connection_progress.as_mut() {
if progress.tab_id == tab_id {
progress.lines.push(reason.clone().into());
let _idx = progress.lines.len().saturating_sub(1);
self.connection_scroll_handle
.set_offset(point(px(0.), px(-99999.0)));
let _ = session_label;
let _ = tab_title;
.set_offset(gpui::point(px(0.), px(-99999.0)));
progress.title = t!("connection_failed").into();
progress.failed = true;
} else if !progress.failed {
// We were showing connecting progress, but another tab dropped!
// Switch to failed state so the user can see it and retry.
progress.tab_id = tab_id.clone();
let msg = format!("{}: {}", tab_title.unwrap_or_default(), reason);
progress.lines.push(msg.into());
self.connection_scroll_handle
.set_offset(point(px(0.), px(-99999.0)));
progress.title = t!("connection_failed").into();
progress.failed = true;
} else {
// Already showing a failure dialog, just append the new failure
let msg = format!("{}: {}", tab_title.unwrap_or_default(), reason);
progress.lines.push(msg.into());
self.connection_scroll_handle
.set_offset(point(px(0.), px(-99999.0)));
}
} else if let Some(_) = session_label {
needs_new_progress = true;
}
if needs_new_progress && !is_graceful_exit {
self.connection_progress = Some(ConnectionProgress {
tab_id: tab_id.clone(),
title: t!("connection_failed").into(),
lines: vec![reason.clone().into()],
failed: true,
});
}
self.status = reason.into();
}
@@ -891,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) => {
@@ -988,7 +1198,9 @@ impl Ashell {
return;
}
self.remote_sample_in_flight = true;
backend.send(crate::terminal::BackendCommand::SampleMetrics);
if let Ok(backend) = backend.lock() {
backend.send(crate::terminal::BackendCommand::SampleMetrics);
}
}
pub(crate) fn terminal_ime_bounds_for_range(
@@ -1015,4 +1227,283 @@ impl Ashell {
self.config.set_transfers(self.transfers.clone());
cx.notify();
}
pub(crate) fn retry_connection_progress(&mut self, cx: &mut Context<Self>) {
let Some(progress) = self.connection_progress.clone() else {
return;
};
self.connection_progress = None;
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(), tab.kind));
}
}
if retry_tabs.is_empty() {
cx.notify();
return;
}
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 = 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);
self.tabs[ix].connected = false;
self.tabs[ix].status = "connecting".into();
self.tabs[ix].disconnected_reason = None;
self.tabs[ix].backend_initialized = false;
// Restart SFTP for the group containing this tab
if let Some(group) = self
.tab_groups
.iter()
.find(|g| g.pane_root.contains(&tab_id))
{
let group_id = group.id.clone();
let group_session = self
.tabs
.iter()
.find(|t| group.pane_root.contains(&t.id) && t.session.is_some())
.and_then(|t| t.session.clone());
if let Some(session) = group_session {
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();
}
}
}
}
}
}
self.connection_progress = Some(ConnectionProgress {
tab_id: progress.tab_id.clone(),
title: t!("connecting").into(),
lines: vec![t!("starting_connection").into()],
failed: false,
});
self.status = "ssh tabs retrying".into();
cx.notify();
}
pub(crate) fn cancel_connection_progress(&mut self, cx: &mut Context<Self>) {
if let Some(progress) = &self.connection_progress {
let tab_id = progress.tab_id.clone();
self.connection_progress = None;
self.handle_tab_close(tab_id);
}
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.");
return;
}
let current_bounds = window.window_bounds();
let bounds = match current_bounds {
gpui::WindowBounds::Fullscreen(b) => b,
gpui::WindowBounds::Maximized(b) => b,
gpui::WindowBounds::Windowed(b) => b,
};
let size = bounds.size;
if size.width.as_f32() > 400.0 && size.height.as_f32() > 300.0 {
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 {
x: b.origin.x.into(),
y: b.origin.y.into(),
width: b.size.width.into(),
height: b.size.height.into(),
}
}
gpui::WindowBounds::Maximized(b) => {
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,
} => {
restore_bounds = (*x, *y, *width, *height);
}
crate::session::config::SavedWindowBounds::Maximized {
x,
y,
width,
height,
} => {
restore_bounds = (*x, *y, *width, *height);
}
_ => {}
}
}
crate::session::config::SavedWindowBounds::Maximized {
x: restore_bounds.0,
y: restore_bounds.1,
width: restore_bounds.2,
height: restore_bounds.3,
}
}
gpui::WindowBounds::Windowed(b) => {
crate::session::config::SavedWindowBounds::Windowed {
x: b.origin.x.into(),
y: b.origin.y.into(),
width: b.size.width.into(),
height: b.size.height.into(),
}
}
};
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
.read(cx)
.sizes()
.iter()
.map(|s| s.into())
.collect();
if self.sftp_panel_minimized {
if let Some(prev) = self.prev_monitoring_size {
if body_sizes.len() > 1 {
body_sizes[1] = prev.into();
}
}
}
config.set_layout_state(Some(saved_bounds), Some(workspace_sizes), Some(body_sizes));
config.set_sidebar_collapsed(self.sidebar_collapsed);
config.set_sftp_panel_minimized(self.sftp_panel_minimized);
let _ = config.save();
} else {
tracing::warn!(
"[ui] window size is too small ({:?}), skipping save layout state to prevent corrupting saved bounds.",
size
);
}
}
}
+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)
});
}
}
+488
View File
@@ -0,0 +1,488 @@
use std::collections::HashMap;
use gpui::{
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,
};
use rust_i18n::t;
use crate::Ashell;
impl Ashell {
pub(crate) fn toggle_search(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.search_active {
self.close_search(window, cx);
} else {
self.open_search(window, cx);
}
}
pub(crate) fn open_search(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.search_active = true;
// Focus the search input on the next frame so it happens after the
// current render cycle completes, avoiding focus being stolen back
// by the terminal panel's track_focus.
let search_input = self.search_input.clone();
cx.on_next_frame(window, move |_this, window, cx| {
search_input.update(cx, |state, cx| {
state.focus_handle(cx).focus(window, cx);
});
});
cx.notify();
}
pub(crate) fn close_search(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.search_active = false;
self.search_query.clear();
self.search_matches.clear();
self.search_current = 0;
self.search_target_tab = None;
self.search_bar_bounds = None;
self.focus_handle.focus(window, cx);
cx.notify();
}
/// Move keyboard focus back to the search input so the user can keep typing.
/// Deferred to the next frame so it happens after the current render cycle,
/// preventing the terminal panel's track_focus from stealing focus back.
fn refocus_search_input(&self, window: &mut Window, cx: &mut Context<Self>) {
let search_input = self.search_input.clone();
cx.on_next_frame(window, move |_this, window, cx| {
search_input.update(cx, |state, cx| {
state.focus_handle(cx).focus(window, cx);
});
});
}
pub(crate) fn perform_search(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let query = self.search_input.read(cx).text().to_string();
if query.is_empty() {
self.search_query.clear();
self.search_matches.clear();
self.search_current = 0;
self.refocus_search_input(window, cx);
cx.notify();
return;
}
// Find the active tab — try active_tab first, then fall back to the
// first tab in the active group, then any tab.
let tab = self
.active_tab
.as_ref()
.and_then(|id| self.tabs.iter().find(|t| &t.id == id));
let tab = tab.or_else(|| {
let first_id = self
.active_group
.as_ref()
.and_then(|gid| self.tab_groups.iter().find(|g| &g.id == gid))
.and_then(|g| g.pane_root.tab_ids().into_iter().next())
.map(|s| s.to_string());
first_id
.as_deref()
.and_then(|id| self.tabs.iter().find(|t| t.id == id))
});
let tab = tab.or_else(|| self.tabs.first());
let Some(tab) = tab else {
self.status = t!("no_results").into();
self.refocus_search_input(window, cx);
cx.notify();
return;
};
// Remember which tab was searched so highlights only appear in that pane.
self.search_target_tab = Some(tab.id.clone());
let query_lower = query.to_lowercase();
let query_byte_len = query.len();
// Search the ENTIRE terminal buffer (scrollback + visible screen).
// full_grid_rows returns grid line indices; the first row is at
// `grid_start` (typically -history_size).
let (grid_start, all_rows) = tab.full_grid_rows();
let mut matches: Vec<(i32, i32)> = Vec::new();
for (row_idx, row) in all_rows.iter().enumerate() {
if row.is_empty() {
continue;
}
// Build text string and byte→column index mapping.
let mut text = String::with_capacity(row.len());
let mut byte_to_col: Vec<i32> = Vec::new();
for &(col, c) in row {
text.push(c);
while byte_to_col.len() < text.len() {
byte_to_col.push(col);
}
}
let text_lower = text.to_lowercase();
// Grid line index for this row.
let abs_row = grid_start + row_idx as i32;
let mut search_start = 0;
while let Some(pos) = text_lower[search_start..].find(&query_lower) {
let abs = search_start + pos;
let start_col = byte_to_col[abs];
let end_byte = (abs + query_byte_len).min(byte_to_col.len());
let end_col = byte_to_col[end_byte - 1];
for c in start_col..=end_col {
matches.push((abs_row, c));
}
search_start = abs + query_byte_len;
}
}
let match_count = count_match_groups(&matches);
self.search_query = query;
self.search_matches = matches;
if match_count > 0 {
self.search_current = 0;
self.jump_to_current_match(cx);
}
self.status = format!(
"{}: {} ({})",
t!("search"),
self.search_query,
if match_count == 0 {
t!("no_results").to_string()
} else {
format!("{}/{}", self.search_current + 1, match_count)
}
)
.into();
// Keep focus on the search input so the user can continue typing.
self.refocus_search_input(window, cx);
cx.notify();
}
pub(crate) fn search_goto_next(&mut self, cx: &mut Context<Self>) {
let match_count = count_match_groups(&self.search_matches);
if match_count == 0 {
return;
}
self.search_current = (self.search_current + 1) % match_count;
self.jump_to_current_match(cx);
cx.notify();
}
pub(crate) fn search_goto_prev(&mut self, cx: &mut Context<Self>) {
let match_count = count_match_groups(&self.search_matches);
if match_count == 0 {
return;
}
self.search_current = (self.search_current + match_count - 1) % match_count;
self.jump_to_current_match(cx);
cx.notify();
}
fn jump_to_current_match(&mut self, _cx: &mut Context<Self>) {
// target_grid_line is the grid line index (negative = history).
let Some((target_grid_line, _)) =
find_nth_match_start(&self.search_matches, self.search_current)
else {
return;
};
// Find the tab ID first (immutable borrow), then look up mutably.
let tab_id = self.active_tab.clone().or_else(|| {
self.active_group
.as_ref()
.and_then(|gid| self.tab_groups.iter().find(|g| &g.id == gid))
.and_then(|g| g.pane_root.tab_ids().into_iter().next())
.map(|s| s.to_string())
});
let tab = if let Some(id) = tab_id.as_deref() {
self.tabs.iter_mut().find(|t| t.id == id)
} else {
self.tabs.first_mut()
};
if let Some(tab) = tab {
let snapshot = tab.render_snapshot(false);
let display_offset = snapshot.display_offset as i32;
let rows = snapshot.rows as i32;
// viewport row = grid_line + display_offset
let vp_row = target_grid_line + display_offset;
let visible = vp_row >= 0 && vp_row < rows;
if !visible {
// Scroll so the target grid line appears near the top.
// display_offset = -grid_line puts it at viewport row 0.
let new_offset = (-target_grid_line).max(0) as usize;
if new_offset > snapshot.display_offset {
tab.scroll_up_by(new_offset - snapshot.display_offset);
} else if new_offset < snapshot.display_offset {
tab.scroll_down_by(snapshot.display_offset - new_offset);
}
}
}
}
/// Build a highlight map for search matches, converting grid line indices
/// to the current viewport coordinates. Only returns highlights for the
/// pane that was actually searched (`search_target_tab`).
pub(crate) fn search_highlight_map(
&self,
tab_id: &str,
match_color: Hsla,
current_color: Hsla,
) -> Option<HashMap<(i32, i32), Hsla>> {
if self.search_matches.is_empty()
|| self.search_query.is_empty()
|| self.search_target_tab.as_deref() != Some(tab_id)
{
return None;
}
// Get current display_offset to convert grid line → viewport row.
let tab = self
.active_tab
.as_ref()
.and_then(|id| self.tabs.iter().find(|t| &t.id == id));
let tab = tab.or_else(|| {
let first_id = self
.active_group
.as_ref()
.and_then(|gid| self.tab_groups.iter().find(|g| &g.id == gid))
.and_then(|g| g.pane_root.tab_ids().into_iter().next())
.map(|s| s.to_string());
first_id
.as_deref()
.and_then(|id| self.tabs.iter().find(|t| t.id == id))
});
let tab = tab.or_else(|| self.tabs.first());
let Some(tab) = tab else {
return None;
};
let snapshot = tab.render_snapshot(false);
let display_offset = snapshot.display_offset as i32;
let rows = snapshot.rows as i32;
let mut map = HashMap::new();
let mut sorted: Vec<(i32, i32)> = self.search_matches.clone();
sorted.sort();
let mut group_idx = 0;
let mut i = 0;
while i < sorted.len() {
let is_current = group_idx == self.search_current;
let color = if is_current {
current_color
} else {
match_color
};
// grid_line → viewport row: vp_row = grid_line + display_offset
let (grid_line, _) = sorted[i];
let vp_row = grid_line + display_offset;
let mut j = i;
if vp_row >= 0 && vp_row < rows {
while j < sorted.len() && sorted[j].0 == grid_line {
if j > i && sorted[j].1 != sorted[j - 1].1 + 1 {
break;
}
map.insert((vp_row, sorted[j].1), color);
j += 1;
}
} else {
// Outside current viewport — skip.
while j < sorted.len() && sorted[j].0 == grid_line {
if j > i && sorted[j].1 != sorted[j - 1].1 + 1 {
break;
}
j += 1;
}
}
group_idx += 1;
i = j;
}
Some(map)
}
/// Render the search button (used in the tab bar).
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);
})),
)
}
/// Render the expanded search bar overlay (when search is active).
pub(crate) fn render_search_bar(
&mut self,
_window: &mut Window,
cx: &mut Context<Self>,
) -> impl gpui::IntoElement {
let match_count = count_match_groups(&self.search_matches);
let has_query = !self.search_query.is_empty();
let has_matches = match_count > 0;
let current_display = if has_matches {
format!("{}/{}", self.search_current + 1, match_count)
} else if has_query {
"0".to_string()
} else {
String::new()
};
let view = cx.entity();
div()
.absolute()
.top(px(8.))
.right(px(24.))
.on_prepaint(move |bounds, _window, cx| {
let _ = view.update(cx, |this, _| {
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();
}),
)
.child(
h_flex()
.gap_1()
.items_center()
.p_1()
.rounded(px(6.))
.bg(cx.theme().popover)
.border_1()
.border_color(cx.theme().border)
.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();
}
},
))
.child(Input::new(&self.search_input).small()),
)
.when(!current_display.is_empty(), |this| {
this.child(
div()
.text_size(rems(0.75))
.text_color(cx.theme().muted_foreground)
.min_w(px(36.))
.text_center()
.child(current_display),
)
})
.child(
Button::new("search-prev")
.ghost()
.xsmall()
.icon(IconName::ChevronUp)
.disabled(!has_matches)
.on_click(cx.listener(|this, _, _, cx| {
this.search_goto_prev(cx);
})),
)
.child(
Button::new("search-next")
.ghost()
.xsmall()
.icon(IconName::ChevronDown)
.disabled(!has_matches)
.on_click(cx.listener(|this, _, _, cx| {
this.search_goto_next(cx);
})),
)
.child(
Button::new("search-close")
.ghost()
.xsmall()
.icon(IconName::Close)
.on_click(cx.listener(|this, _, window, cx| {
this.close_search(window, cx);
})),
),
)
.into_any_element()
}
}
// ── Helpers ──────────────────────────────────────────────────────────────
/// Count distinct match groups in a sorted list of (row, col) positions.
/// A group is a run of consecutive columns in the same row.
fn count_match_groups(matches: &[(i32, i32)]) -> usize {
if matches.is_empty() {
return 0;
}
let mut sorted: Vec<(i32, i32)> = matches.to_vec();
sorted.sort();
let mut count = 0;
let mut i = 0;
while i < sorted.len() {
count += 1;
let (r, _) = sorted[i];
i += 1;
// Skip consecutive columns in the same row.
while i < sorted.len() && sorted[i].0 == r && sorted[i].1 == sorted[i - 1].1 + 1 {
i += 1;
}
}
count
}
/// Find the (row, col) start of the Nth distinct match group.
fn find_nth_match_start(matches: &[(i32, i32)], n: usize) -> Option<(i32, i32)> {
if matches.is_empty() {
return None;
}
let mut sorted: Vec<(i32, i32)> = matches.to_vec();
sorted.sort();
let mut group_idx = 0;
let mut i = 0;
while i < sorted.len() {
if group_idx == n {
return Some(sorted[i]);
}
group_idx += 1;
let (r, _) = sorted[i];
i += 1;
while i < sorted.len() && sorted[i].0 == r && sorted[i].1 == sorted[i - 1].1 + 1 {
i += 1;
}
}
None
}
+63 -73
View File
@@ -162,6 +162,12 @@ pub(crate) fn sync_macos_launch_environment() {
| "HOMEBREW_PREFIX"
| "HOMEBREW_CELLAR"
| "HOMEBREW_REPOSITORY"
| "HTTP_PROXY"
| "HTTPS_PROXY"
| "ALL_PROXY"
| "http_proxy"
| "https_proxy"
| "all_proxy"
) || key.starts_with("LC_");
if should_import {
@@ -172,12 +178,63 @@ 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",
];
for var in vars {
if let Ok(val) = std::env::var(var) {
if val.is_empty() {
continue;
}
if let Ok(url) = reqwest::Url::parse(&val) {
let scheme = url.scheme();
let proxy_type = match scheme {
"socks5" | "socks5h" => "socks5".to_string(),
"http" | "https" => "http".to_string(),
_ => "socks5".to_string(),
};
let host = url.host_str().unwrap_or("").to_string();
let port = url.port();
let user = url.username().to_string();
let password = url.password().unwrap_or("").to_string();
return Some((proxy_type, host, port, user, password));
}
}
}
None
}
#[cfg(not(target_os = "macos"))]
pub(crate) fn sync_macos_launch_environment() {}
pub(crate) fn open_main_window(cx: &mut App) {
let config = ConfigStore::load().unwrap_or_else(|_| ConfigStore::in_memory());
let _ = crate::session::config::ENV_PROXY.get_or_init(|| {
read_proxy_from_env().map(|(proxy_type, host, port, user, password)| {
tracing::info!(
"[proxy] Loaded proxy configuration from environment: type={}, host={}, port={:?}, user={}",
proxy_type,
host,
port,
user
);
crate::session::config::EnvProxy {
proxy_type,
host,
port,
user,
pass: password,
}
})
});
let mut window_options = WindowOptions::default();
if config.title_bar_style() == crate::session::config::TitleBarStyle::Integrated {
@@ -251,83 +308,16 @@ pub(crate) fn open_main_window(cx: &mut App) {
let focus_handle = view.read(cx).focus_handle.clone();
window.focus(&focus_handle, cx);
let workspace_panels_clone = view.read(cx).workspace_panels.clone();
let body_panels_clone = view.read(cx).body_panels.clone();
let view_clone = view.clone();
window.on_window_should_close(cx, move |window: &mut gpui::Window, cx: &mut gpui::App| {
if view_clone.read(cx).is_layout_reset {
tracing::info!("[ui] layout was reset, skipping save layout state.");
let handle = window.window_handle();
if !cx.windows().contains(&handle) {
tracing::warn!(
"[ui] window not found in app during close, skipping save layout state."
);
return true;
}
let current_bounds = window.window_bounds();
let bounds = match current_bounds {
gpui::WindowBounds::Fullscreen(b) => b,
gpui::WindowBounds::Maximized(b) => b,
gpui::WindowBounds::Windowed(b) => b,
};
let size = bounds.size;
if size.width.as_f32() > 400.0 && size.height.as_f32() > 300.0 {
tracing::info!("[ui] main application window closed, saving layout state...");
let mut config = ConfigStore::load().unwrap_or_else(|_| ConfigStore::in_memory());
let saved_bounds = match current_bounds {
gpui::WindowBounds::Fullscreen(b) => {
crate::session::config::SavedWindowBounds::Fullscreen {
x: b.origin.x.into(),
y: b.origin.y.into(),
width: b.size.width.into(),
height: b.size.height.into(),
}
}
gpui::WindowBounds::Maximized(b) => {
crate::session::config::SavedWindowBounds::Maximized {
x: b.origin.x.into(),
y: b.origin.y.into(),
width: b.size.width.into(),
height: b.size.height.into(),
}
}
gpui::WindowBounds::Windowed(b) => {
crate::session::config::SavedWindowBounds::Windowed {
x: b.origin.x.into(),
y: b.origin.y.into(),
width: b.size.width.into(),
height: b.size.height.into(),
}
}
};
let workspace_sizes: Vec<f32> = workspace_panels_clone
.read(cx)
.sizes()
.iter()
.map(|s| s.into())
.collect();
let mut body_sizes: Vec<f32> = body_panels_clone
.read(cx)
.sizes()
.iter()
.map(|s| s.into())
.collect();
if view_clone.read(cx).sftp_panel_minimized {
if let Some(prev) = view_clone.read(cx).prev_monitoring_size {
if body_sizes.len() > 1 {
body_sizes[1] = prev.into();
}
}
}
config.set_layout_state(Some(saved_bounds), Some(workspace_sizes), Some(body_sizes));
config.set_sidebar_collapsed(view_clone.read(cx).sidebar_collapsed);
config.set_sftp_panel_minimized(view_clone.read(cx).sftp_panel_minimized);
let _ = config.save();
} else {
tracing::warn!(
"[ui] window size is too small ({:?}), skipping save layout state to prevent corrupting saved bounds.",
size
);
}
view_clone.read(cx).save_layout_state(window, cx);
true
});
+24 -15
View File
@@ -9,18 +9,31 @@ pub(crate) const EMBEDDED_THEME_JSONS: &[&str] = &[
include_str!("../../assets/themes/tokyonight.json"),
include_str!("../../assets/themes/gruvbox.json"),
include_str!("../../assets/themes/solarized.json"),
include_str!("../../assets/themes/phygerr.json"),
];
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 regular = std::borrow::Cow::Borrowed(
include_bytes!("../../assets/fonts/MapleMono-NF-CN-Regular.ttf").as_slice(),
);
let bold = std::borrow::Cow::Borrowed(
include_bytes!("../../assets/fonts/MapleMono-NF-CN-Bold.ttf").as_slice(),
);
cx.text_system()
.add_fonts(vec![regular, bold])
.context("load Maple Mono NF CN fonts")?;
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 {
let regular = std::borrow::Cow::Borrowed(
include_bytes!("../../assets/fonts/MapleMono-NF-CN-Regular.ttf").as_slice(),
);
let bold = std::borrow::Cow::Borrowed(
include_bytes!("../../assets/fonts/MapleMono-NF-CN-Bold.ttf").as_slice(),
);
cx.text_system()
.add_fonts(vec![regular, bold])
.context("load Maple Mono NF CN fonts")?;
}
set_theme_font_names(cx.global_mut::<Theme>(), ".SystemUIFont");
Ok(())
}
@@ -113,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();
}
@@ -155,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();
}
}
+591 -315
View File
File diff suppressed because it is too large Load Diff
+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();
}
}
+167 -81
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,8 +225,8 @@ 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(10)),
inactivity_timeout: None,
keepalive_interval: Some(std::time::Duration::from_secs(5)),
keepalive_max: 3,
..Default::default()
});
@@ -230,11 +236,24 @@ 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 _ = events.send(BackendEvent::Status {
tab_id: tab_id.to_string(),
text: format!("opening tcp connection to {addr}"),
text: status_text,
});
let mut handle = client::connect(config, addr.as_str(), ClientHandler)
let stream = crate::session::config::connect_proxy(session).await?;
let mut handle = client::connect_stream(config, stream, ClientHandler)
.await
.with_context(|| format!("connect {addr} failed"))?;
@@ -260,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,
@@ -269,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 {
@@ -328,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
),
}
));
}
@@ -380,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, OpenSession,
OpenSettings, OpenTransfers, SplitPaneDown, SplitPaneLeft, SplitPaneRight, SplitPaneUp,
ToggleSftpZoom, ToggleSidebar,
ClosePane, Copy, FocusPaneDown, FocusPaneLeft, FocusPaneRight, FocusPaneUp, NewSsh, OpenSearch,
OpenSession, OpenSettings, OpenTransfers, Paste, SplitPaneDown, SplitPaneLeft, SplitPaneRight,
SplitPaneUp, ToggleSftpZoom, ToggleSidebar,
};
pub(crate) use app::{
Ashell, ConnectionProgress, PaneLayout, SelectorEntry, SftpContextMenuState, TabGroup,
};
pub(crate) use app::{Ashell, PaneLayout, SelectorEntry, SftpContextMenuState, TabGroup};
fn main() {
app::startup::sync_macos_launch_environment();
+743 -26
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)]
@@ -30,6 +46,20 @@ pub struct Session {
pub passphrase: String,
#[serde(default)]
pub last_used: Option<String>,
#[serde(default = "default_global_proxy_type")]
pub proxy_type: String, // "none", "socks5", "http"
#[serde(default)]
pub proxy_host: String,
#[serde(default)]
pub proxy_port: Option<u16>,
#[serde(default)]
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 {
@@ -47,6 +77,13 @@ impl Session {
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: "ssh".to_string(),
baud_rate: 115200,
}
}
@@ -71,6 +108,37 @@ impl Session {
private_key_inline,
passphrase,
last_used: None,
proxy_type: "none".to_string(),
proxy_host: String::new(),
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,
}
}
}
@@ -106,7 +174,17 @@ pub enum TitleBarStyle {
Integrated,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "kebab-case")]
pub enum CursorStyle {
#[default]
Default,
Blink,
Beam,
BeamBlink,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfigFile {
#[serde(default = "default_follow_system_theme")]
pub follow_system_theme: bool,
@@ -124,6 +202,8 @@ pub struct ConfigFile {
pub ui_font_size: f32,
#[serde(default)]
pub right_click_copy_paste: bool,
#[serde(default)]
pub keyword_highlight: bool,
#[serde(default = "default_ui_font_family")]
pub ui_font_family: String,
#[serde(default = "default_terminal_font_family")]
@@ -131,6 +211,8 @@ pub struct ConfigFile {
#[serde(default)]
pub title_bar_style: TitleBarStyle,
#[serde(default)]
pub cursor_style: CursorStyle,
#[serde(default)]
pub sessions: Vec<Session>,
#[serde(default)]
pub window_bounds: Option<SavedWindowBounds>,
@@ -142,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)]
@@ -170,6 +254,28 @@ pub struct ConfigFile {
pub sync_s3_bucket: String,
#[serde(default = "default_s3_object_key")]
pub sync_s3_object_key: String,
#[serde(default)]
pub use_proxy: bool,
#[serde(default = "default_read_env_proxy")]
pub read_env_proxy: bool,
#[serde(default = "default_global_proxy_type")]
pub global_proxy_type: String,
#[serde(default)]
pub global_proxy_host: String,
#[serde(default)]
pub global_proxy_port: Option<u16>,
#[serde(default)]
pub global_proxy_user: String,
#[serde(default)]
pub global_proxy_password: String,
}
fn default_read_env_proxy() -> bool {
true
}
fn default_global_proxy_type() -> String {
"socks5".to_string()
}
fn default_monitoring_position() -> String {
@@ -210,9 +316,58 @@ fn default_terminal_font_family() -> String {
"Maple Mono NF CN".to_string()
}
impl Default for ConfigFile {
fn default() -> Self {
Self {
follow_system_theme: default_follow_system_theme(),
theme_mode: String::new(),
light_theme_name: String::new(),
dark_theme_name: String::new(),
locale: default_locale(),
terminal_font_size: default_terminal_font_size(),
ui_font_size: default_ui_font_size(),
right_click_copy_paste: false,
keyword_highlight: false,
ui_font_family: default_ui_font_family(),
terminal_font_family: default_terminal_font_family(),
title_bar_style: TitleBarStyle::default(),
cursor_style: CursorStyle::default(),
sessions: Vec::new(),
window_bounds: None,
workspace_panels: None,
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,
key_bindings: std::collections::HashMap::new(),
sync_endpoint: String::new(),
sync_username: String::new(),
sync_etag: None,
sync_device_id: String::new(),
sync_backend: String::new(),
sync_etag_backend: String::new(),
sync_s3_endpoint: String::new(),
sync_s3_region: default_s3_region(),
sync_s3_bucket: String::new(),
sync_s3_object_key: default_s3_object_key(),
use_proxy: false,
read_env_proxy: true,
global_proxy_type: default_global_proxy_type(),
global_proxy_host: String::new(),
global_proxy_port: None,
global_proxy_user: String::new(),
global_proxy_password: String::new(),
}
}
}
#[derive(Clone)]
pub struct ConfigStore {
path: PathBuf,
cache: ConfigFile,
pub(crate) path: PathBuf,
pub(crate) cache: ConfigFile,
}
impl ConfigStore {
@@ -237,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 {
@@ -332,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 {
@@ -340,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) {
@@ -512,6 +682,14 @@ impl ConfigStore {
self.cache.right_click_copy_paste = val;
}
pub fn keyword_highlight(&self) -> bool {
self.cache.keyword_highlight
}
pub fn set_keyword_highlight(&mut self, val: bool) {
self.cache.keyword_highlight = val;
}
pub fn terminal_font_family(&self) -> &str {
if self.cache.terminal_font_family.is_empty() {
"Maple Mono NF CN"
@@ -532,6 +710,57 @@ impl ConfigStore {
self.cache.title_bar_style = style;
}
pub fn cursor_style(&self) -> CursorStyle {
self.cache.cursor_style
}
pub fn set_cursor_style(&mut self, style: CursorStyle) {
self.cache.cursor_style = style;
}
pub fn use_proxy(&self) -> bool {
self.cache.use_proxy
}
pub fn set_use_proxy(&mut self, val: bool) {
self.cache.use_proxy = val;
}
pub fn read_env_proxy(&self) -> bool {
self.cache.read_env_proxy
}
pub fn set_read_env_proxy(&mut self, val: bool) {
self.cache.read_env_proxy = val;
}
pub fn global_proxy_type(&self) -> &str {
&self.cache.global_proxy_type
}
pub fn set_global_proxy_type(&mut self, val: String) {
self.cache.global_proxy_type = val;
}
pub fn global_proxy_host(&self) -> &str {
&self.cache.global_proxy_host
}
pub fn set_global_proxy_host(&mut self, val: String) {
self.cache.global_proxy_host = val;
}
pub fn global_proxy_port(&self) -> Option<u16> {
self.cache.global_proxy_port
}
pub fn set_global_proxy_port(&mut self, val: Option<u16>) {
self.cache.global_proxy_port = val;
}
pub fn global_proxy_user(&self) -> &str {
&self.cache.global_proxy_user
}
pub fn set_global_proxy_user(&mut self, val: String) {
self.cache.global_proxy_user = val;
}
pub fn global_proxy_password(&self) -> &str {
&self.cache.global_proxy_password
}
pub fn set_global_proxy_password(&mut self, val: String) {
self.cache.global_proxy_password = val;
}
pub fn show_hidden_files(&self) -> bool {
self.cache.show_hidden_files
}
@@ -540,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
}
@@ -576,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)]
@@ -592,3 +889,423 @@ impl ConfigStore {
Ok(())
}
}
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 {
pub proxy_type: String,
pub host: String,
pub port: Option<u16>,
pub user: String,
pub pass: String,
}
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.clone();
let target_port = session.port;
let session = session.clone();
let connect_fut = async move {
let target_host = &target_host;
let config = ConfigStore::load().unwrap_or_else(|_| ConfigStore::in_memory());
let (proxy_type, proxy_host, proxy_port, proxy_user, proxy_password) = {
if !session.proxy_type.is_empty() && session.proxy_type != "none" {
(
session.proxy_type.clone(),
session.proxy_host.clone(),
session.proxy_port,
session.proxy_user.clone(),
session.proxy_password.clone(),
)
} else if config.cache.read_env_proxy
&& ENV_PROXY.get().and_then(|opt| opt.as_ref()).is_some()
{
let env_p = ENV_PROXY.get().and_then(|opt| opt.as_ref()).unwrap();
(
env_p.proxy_type.clone(),
env_p.host.clone(),
env_p.port,
env_p.user.clone(),
env_p.pass.clone(),
)
} else if config.cache.use_proxy {
(
config.cache.global_proxy_type.clone(),
config.cache.global_proxy_host.clone(),
config.cache.global_proxy_port,
config.cache.global_proxy_user.clone(),
config.cache.global_proxy_password.clone(),
)
} else {
(
"none".to_string(),
String::new(),
None,
String::new(),
String::new(),
)
}
};
if proxy_type != "none" && (proxy_host.is_empty() || proxy_port.is_none()) {
let addr = format!("{}:{}", target_host, target_port);
let stream = tokio::net::TcpStream::connect(&addr).await?;
return Ok(Box::new(stream) as Box<dyn ProxyStream>);
}
match proxy_type.as_str() {
"socks5" | "socks5h" => {
let proxy_port = proxy_port.unwrap_or(1080);
let proxy_addr = format!("{}:{}", proxy_host, proxy_port);
if !proxy_user.is_empty() {
let stream = tokio_socks::tcp::Socks5Stream::connect_with_password(
proxy_addr.as_str(),
(target_host.as_str(), target_port),
&proxy_user,
&proxy_password,
)
.await
.map_err(|e| anyhow::anyhow!("SOCKS5 proxy connection failed: {}", e))?;
Ok(Box::new(stream) as Box<dyn ProxyStream>)
} else {
let stream = tokio_socks::tcp::Socks5Stream::connect(
proxy_addr.as_str(),
(target_host.as_str(), target_port),
)
.await
.map_err(|e| anyhow::anyhow!("SOCKS5 proxy connection failed: {}", e))?;
Ok(Box::new(stream) as Box<dyn ProxyStream>)
}
}
"http" => {
let proxy_port = proxy_port.unwrap_or(8080);
let proxy_addr = format!("{}:{}", proxy_host, proxy_port);
use tokio::io::AsyncWriteExt;
let mut stream = tokio::net::TcpStream::connect(&proxy_addr)
.await
.map_err(|e| anyhow::anyhow!("HTTP proxy connection failed: {}", e))?;
let mut request = format!(
"CONNECT {}:{} HTTP/1.1\r\nHost: {}:{}\r\n",
target_host, target_port, target_host, target_port
);
if !proxy_user.is_empty() {
use base64::Engine as _;
let auth = format!("{}:{}", proxy_user, proxy_password);
let encoded = base64::engine::general_purpose::STANDARD.encode(auth);
request.push_str(&format!("Proxy-Authorization: Basic {}\r\n", encoded));
}
request.push_str("\r\n");
stream.write_all(request.as_bytes()).await?;
let mut response = [0u8; 1024];
let n = tokio::io::AsyncReadExt::read(&mut stream, &mut response).await?;
let resp_str = String::from_utf8_lossy(&response[..n]);
if !resp_str.contains("200") && !resp_str.contains("established") {
return Err(anyhow::anyhow!("HTTP proxy CONNECT failed: {}", resp_str));
}
Ok(Box::new(stream) as Box<dyn ProxyStream>)
}
_ => {
let addr = format!("{}:{}", target_host, target_port);
let stream = tokio::net::TcpStream::connect(&addr).await?;
Ok(Box::new(stream) as Box<dyn ProxyStream>)
}
}
};
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>)> {
let config = ConfigStore::load().unwrap_or_else(|_| ConfigStore::in_memory());
let (proxy_type, proxy_host, proxy_port, _, _) = {
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_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");
}
}
+455 -173
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;
@@ -11,11 +13,8 @@ use uuid::Uuid;
use self::config::{AuthMethod, Session};
use crate::{
Ashell, ConnectionProgress, PaneLayout, SelectorEntry, TabGroup,
app::constants::{
DEFAULT_COLS, DEFAULT_ROWS, SIDEBAR_WIDTH, TAB_BAR_HEIGHT, TERMINAL_PADDING_X,
TERMINAL_PADDING_Y,
},
Ashell, PaneLayout, SelectorEntry, TabGroup,
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();
@@ -79,6 +127,17 @@ impl Ashell {
return;
}
if self.ssh_proxy_type != "none" {
let proxy_host = self.proxy_host_input.read(cx).value().trim().to_string();
let proxy_port_str = self.proxy_port_input.read(cx).value().trim().to_string();
let proxy_port = proxy_port_str.parse::<u16>().ok();
if proxy_host.is_empty() || proxy_port.is_none() {
self.status = "Proxy host and port are required".into();
cx.notify();
return;
}
}
let name = if session_name.is_empty() {
host.clone()
} else {
@@ -92,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;
@@ -106,6 +166,17 @@ impl Ashell {
session.id = id;
}
session.last_used = existing_last_used;
session.proxy_type = self.ssh_proxy_type.clone();
session.proxy_host = self.proxy_host_input.read(cx).value().trim().to_string();
session.proxy_port = self
.proxy_port_input
.read(cx)
.value()
.trim()
.parse::<u16>()
.ok();
session.proxy_user = self.proxy_user_input.read(cx).value().trim().to_string();
session.proxy_password = self.proxy_password_input.read(cx).value().to_string();
self.config.upsert(session.clone());
if let Err(err) = self.config.save() {
tracing::warn!("failed to save config: {err:#}");
@@ -130,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);
@@ -138,6 +211,12 @@ 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);
Self::set_input_value(&self.proxy_user_input, "", window, cx);
Self::set_input_value(&self.proxy_password_input, "", window, cx);
}
pub(crate) fn load_session_into_form(
@@ -148,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);
@@ -171,6 +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_port_input,
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,
);
}
pub(crate) fn pick_ssh_key_path(&mut self, window: &mut Window, cx: &mut Context<Self>) {
@@ -253,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();
}
@@ -263,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();
@@ -279,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();
@@ -290,25 +402,92 @@ 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();
}
pub(crate) fn change_cursor_style(
&mut self,
style: crate::session::config::CursorStyle,
cx: &mut Context<Self>,
) {
self.cursor_style = style;
self.config.set_cursor_style(style);
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();
}
@@ -322,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> {
@@ -438,6 +621,12 @@ impl Ashell {
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();
@@ -477,13 +666,58 @@ impl Ashell {
self.sftp_handles.insert(group_id.clone(), sftp_handle);
self.active_tab = Some(id.clone());
self.pending_sftp_path_sync = Some("/".into());
self.connection_progress = Some(ConnectionProgress {
tab_id: id,
title: t!("connecting").into(),
lines: vec![t!("starting_connection").into()],
self.status = "ssh tab opened".into();
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.status = "ssh tab opened".into();
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();
}
@@ -496,57 +730,67 @@ impl Ashell {
cx.notify();
}
pub(crate) fn retry_connection_progress(&mut self, cx: &mut Context<Self>) {
let Some(progress) = self.connection_progress.clone() else {
/// Retry a single disconnected tab by its ID.
/// For SSH tabs: spawns a new SSH connection and restarts SFTP.
/// For local tabs: spawns a new local shell.
///
/// The existing `TerminalTab` (including its `term` scrollback history)
/// is preserved — only the backend is swapped via `set_backend()`.
pub(crate) fn retry_disconnected_tab(&mut self, tab_id: &str, cx: &mut Context<Self>) {
let Some(ix) = self.tabs.iter().position(|t| t.id == tab_id) else {
return;
};
self.connection_progress = None;
let mut groups_to_restart_sftp = std::collections::HashSet::new();
let mut retry_tabs = Vec::new();
for (ix, tab) in self.tabs.iter().enumerate() {
if !tab.connected && tab.session.is_some() {
retry_tabs.push((ix, tab.id.clone(), tab.session.clone().unwrap()));
}
}
if retry_tabs.is_empty() {
cx.notify();
if self.tabs[ix].connected || self.tabs[ix].disconnected_reason.is_none() {
return;
}
for (ix, tab_id, session) in retry_tabs {
// Close old backend
self.tabs[ix].backend.send(BackendCommand::Close);
let is_ssh = self.tabs[ix].session.is_some();
let session = self.tabs[ix].session.clone();
let new_generation = self.tabs[ix].backend_generation + 1;
let cols = self.tabs[ix].cols;
let rows = self.tabs[ix].rows;
// Spawn new backend
let backend = ssh::spawn_ssh_terminal(
self.runtime.handle(),
tab_id.clone(),
session.clone(),
DEFAULT_COLS,
DEFAULT_ROWS,
self.events_tx.clone(),
);
// Close old backend (sends Close through the shared Arc<Mutex>)
self.tabs[ix].send_backend(BackendCommand::Close);
// Replace tab state in-place to reuse the UI component
self.tabs[ix] =
TerminalTab::new_ssh(tab_id.clone(), &session, backend, self.events_tx.clone());
if let Some(session) = session {
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;
self.tabs[ix].backend_generation = new_generation;
self.tabs[ix].backend_initialized = false;
// Find group to restart SFTP
// Restart SFTP for the group containing this tab
if let Some(group) = self
.tab_groups
.iter()
.find(|g| g.pane_root.contains(&tab_id))
.find(|g| g.pane_root.contains(tab_id))
{
groups_to_restart_sftp.insert(group.id.clone());
}
}
// Restart SFTP for affected groups
for group_id in groups_to_restart_sftp {
if let Some(group) = self.tab_groups.iter_mut().find(|g| g.id == group_id) {
// Use the session of any tab in that group
let group_id = group.id.clone();
let group_session = self
.tabs
.iter()
@@ -554,48 +798,59 @@ 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(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();
}
}
}
}
}
} else {
// Local tab: spawn new local shell
match local::spawn_local_terminal(
tab_id.to_string(),
cols,
rows,
self.events_tx.clone(),
) {
Ok(backend) => {
// Swap the backend — preserves terminal history.
self.tabs[ix].set_backend(backend);
self.tabs[ix].connected = true;
self.tabs[ix].status = "local shell".into();
self.tabs[ix].disconnected_reason = None;
self.tabs[ix].backend_generation = new_generation;
self.tabs[ix].backend_initialized = false;
// Resize the new PTY to match the pane dimensions.
self.tabs[ix].send_backend(BackendCommand::Resize { cols, rows });
}
Err(err) => {
self.status = format!("failed to reopen local terminal: {err:#}").into();
cx.notify();
return;
}
}
}
self.connection_progress = Some(ConnectionProgress {
tab_id: progress.tab_id.clone(),
title: t!("connecting").into(),
lines: vec![t!("starting_connection").into()],
failed: false,
});
self.status = "ssh tabs retrying".into();
cx.notify();
}
pub(crate) fn cancel_connection_progress(&mut self, cx: &mut Context<Self>) {
if self.connection_progress.is_none() {
return;
}
self.connection_progress = None;
let tabs_to_close: Vec<_> = self
.tabs
.iter()
.filter(|tab| !tab.connected && tab.session.is_some())
.map(|tab| tab.id.clone())
.collect();
for id in tabs_to_close {
self.handle_tab_close(id);
self.status = if is_ssh {
"ssh tab retrying"
} else {
"local tab reopened"
}
.into();
cx.notify();
}
@@ -648,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()
@@ -659,7 +921,7 @@ impl Ashell {
id
);
if let Some(ix) = self.tabs.iter().position(|tab| tab.id == id) {
self.tabs[ix].backend.send(BackendCommand::Close);
self.tabs[ix].send_backend(BackendCommand::Close);
self.tabs.remove(ix);
}
return;
@@ -718,7 +980,7 @@ impl Ashell {
.collect();
for tab_id in &tab_ids {
if let Some(ix) = self.tabs.iter().position(|tab| tab.id == *tab_id) {
self.tabs[ix].backend.send(BackendCommand::Close);
self.tabs[ix].send_backend(BackendCommand::Close);
self.tabs.retain(|t| t.id != *tab_id);
}
}
@@ -730,7 +992,7 @@ impl Ashell {
} else {
// Just remove this tab from the group
if let Some(ix) = self.tabs.iter().position(|tab| tab.id == id) {
self.tabs[ix].backend.send(BackendCommand::Close);
self.tabs[ix].send_backend(BackendCommand::Close);
self.tabs.retain(|t| t.id != id);
}
if let Some(g) = self
@@ -804,6 +1066,15 @@ impl Ashell {
window: &mut Window,
cx: &mut Context<Self>,
) {
// If the search bar is visible and the click is inside it, let the
// search bar handle the event instead of switching pane focus.
if self.search_active {
if let Some(bounds) = self.search_bar_bounds {
if bounds.contains(&event.position) {
return;
}
}
}
self.focus_handle.focus(window, cx);
// Check if click is in a different pane and focus it
let click_pos = event.position;
@@ -822,6 +1093,34 @@ 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)
{
tab.clear_selection();
}
}
}
}
}
self.begin_terminal_selection(event, cx);
}
cx.notify();
@@ -831,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> {
@@ -867,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)
}
}
@@ -992,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
@@ -1043,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;
}
@@ -1051,8 +1314,18 @@ impl Ashell {
if let Some(new_path) = Self::find_adjacent_pane(&self.pane_root, &path, direction) {
self.focused_pane_path = new_path;
if let Some(id) = self.pane_root.focused_tab_id(&self.focused_pane_path) {
self.active_tab = Some(id.to_string());
let id_owned = id.to_string();
let changed = self.active_tab.as_deref() != Some(id_owned.as_str());
self.active_tab = Some(id_owned);
// Clear stale search state when switching to a different pane.
if changed && self.search_active {
self.search_query.clear();
self.search_matches.clear();
self.search_current = 0;
self.search_target_tab = None;
}
}
cx.notify();
}
}
@@ -1334,8 +1607,17 @@ impl Ashell {
}
let mut path = Vec::new();
if find_path(&self.pane_root, &tab_id, &mut path) {
let changed = self.active_tab.as_deref() != Some(tab_id.as_str());
self.focused_pane_path = path;
self.active_tab = Some(tab_id);
// Clear stale search state when switching to a different pane.
// The user can press Enter to re-search in the new pane.
if changed && self.search_active {
self.search_query.clear();
self.search_matches.clear();
self.search_current = 0;
self.search_target_tab = None;
}
}
}
}
+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)
}
+116 -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,
};
@@ -218,9 +224,13 @@ pub fn spawn_sftp(
.await
{
let _ = events.send(BackendEvent::SftpStatus {
tab_id,
tab_id: tab_id.clone(),
text: format!("sftp error: {err:#}"),
});
let _ = events.send(BackendEvent::Closed {
tab_id,
reason: format!("sftp error: {err:#}"),
});
}
});
SftpHandle {
@@ -823,11 +833,14 @@ 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)),
inactivity_timeout: None,
keepalive_interval: Some(std::time::Duration::from_secs(5)),
keepalive_max: 3,
..Default::default()
});
let addr = format!("{}:{}", session.host, session.port);
let mut handle = client::connect(config, addr.as_str(), SftpClientHandler)
let stream = crate::session::config::connect_proxy(session).await?;
let mut handle = client::connect_stream(config, stream, SftpClientHandler)
.await
.with_context(|| format!("connect {addr} failed"))?;
@@ -837,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 {
@@ -879,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,
@@ -920,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;
+178 -21
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,8 @@ pub struct TerminalElement {
font_size: Pixels,
line_height: Pixels,
cell_width: Pixels,
tab_id: String,
search_highlights: Option<std::collections::HashMap<(i32, i32), Hsla>>,
}
pub struct PrepaintState {
@@ -141,6 +163,7 @@ pub struct PrepaintState {
runs: Vec<BatchedTextRun>,
custom_blocks: Vec<LayoutCustomBlock>,
cursor: Option<CursorLayout>,
underlines: Vec<LayoutUnderline>,
}
#[derive(Clone)]
@@ -270,6 +293,8 @@ 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 {
view,
@@ -280,6 +305,8 @@ impl TerminalElement {
font_size,
line_height,
cell_width,
tab_id,
search_highlights,
}
}
@@ -348,12 +375,27 @@ 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
let mut highlights = self.snapshot.highlights.clone();
if let Some(sm) = self.search_highlights.as_ref() {
highlights.extend(sm.iter().map(|(k, v)| (*k, *v)));
}
for render_cell in &self.snapshot.cells {
let cell = &render_cell.cell;
if cell.flags.intersects(
@@ -366,7 +408,7 @@ impl TerminalElement {
selection_contains(selection, render_cell.row, render_cell.col)
});
let bg = color_to_hsla(cell.bg, false, cx);
if selected || !is_default_bg(cell.bg) {
if selected || !is_default_bg(cell.bg) || cell.flags.contains(Flags::INVERSE) {
rects.push(LayoutRect {
row: render_cell.row,
col: render_cell.col,
@@ -392,7 +434,32 @@ impl TerminalElement {
continue;
}
let style = self.cell_run_style(cell, cx);
let mut style = self.cell_run_style(cell, cx);
// Apply keyword highlight color if this cell was matched.
if let Some(&hl_color) = highlights.get(&(render_cell.row, render_cell.col)) {
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);
@@ -446,15 +513,46 @@ 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> {
self.snapshot.cursor.map(|cursor| CursorLayout {
row: cursor.row,
col: cursor.col,
shape: cursor.shape,
color: cx.theme().primary,
use crate::session::config::CursorStyle;
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)
{
(duration.as_millis() / 600) % 2 == 0
} else {
true
}
}
_ => true,
};
self.snapshot.cursor.map(|cursor| {
let mut shape = match cursor_style {
CursorStyle::Default => cursor.shape,
CursorStyle::Blink => CursorShape::Block,
CursorStyle::Beam => CursorShape::Beam,
CursorStyle::BeamBlink => CursorShape::Beam,
};
if !show_cursor {
shape = CursorShape::Hidden;
}
CursorLayout {
row: cursor.row,
col: cursor.col,
shape,
color: cx.theme().primary,
}
})
}
}
@@ -502,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 {
@@ -513,6 +637,7 @@ impl Element for TerminalElement {
runs,
custom_blocks,
cursor: self.cursor_layout(cx),
underlines,
}
}
@@ -526,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,
@@ -554,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(),
},
@@ -564,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 {
@@ -612,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 => {
@@ -673,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;
File diff suppressed because it is too large Load Diff
+165 -21
View File
@@ -3,8 +3,8 @@ use std::ops::Range;
use alacritty_terminal::index::Side;
use alacritty_terminal::selection::SelectionType;
use gpui::{
ClipboardItem, Context, KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent,
MouseUpEvent, Pixels, Point, ScrollDelta, ScrollWheelEvent, Window, px,
ClipboardItem, Context, Focusable as _, KeyDownEvent, MouseButton, MouseDownEvent,
MouseMoveEvent, MouseUpEvent, Pixels, Point, ScrollDelta, ScrollWheelEvent, Window, px,
};
use crate::{
@@ -12,6 +12,10 @@ use crate::{
terminal::{BackendCommand, encode_key},
};
thread_local! {
static LAST_DRAG_SCROLL: std::cell::Cell<Option<std::time::Instant>> = std::cell::Cell::new(None);
}
impl Ashell {
pub(crate) fn on_terminal_key_down(
&mut self,
@@ -19,6 +23,18 @@ 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
.search_input
.read(cx)
.focus_handle(cx)
.is_focused(window)
{
return;
}
// Pane navigation: Alt + h/j/k/l
if event.keystroke.modifiers.alt
&& !event.keystroke.modifiers.shift
@@ -26,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);
@@ -97,6 +113,38 @@ impl Ashell {
}
}
// If the active tab is disconnected and user presses Enter, reconnect
if event.keystroke.key == "enter"
&& !event.keystroke.modifiers.shift
&& !event.keystroke.modifiers.control
&& !event.keystroke.modifiers.alt
&& !event.keystroke.modifiers.platform
{
if let Some(progress) = &self.connection_progress {
if progress.failed {
self.retry_connection_progress(cx);
window.prevent_default();
cx.stop_propagation();
return;
}
}
let active_id = self.active_tab.clone();
if let Some(active_id) = active_id {
let is_disconnected = self
.tabs
.iter()
.find(|t| t.id == active_id)
.is_some_and(|tab| tab.disconnected_reason.is_some());
if is_disconnected {
self.retry_disconnected_tab(&active_id, cx);
window.prevent_default();
cx.stop_propagation();
return;
}
}
}
if event.prefer_character_input {
if let Some(text) = event.keystroke.key_char.as_deref() {
if !text.is_empty()
@@ -117,13 +165,13 @@ 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();
if let Some(bytes) = encode_key(&event.keystroke, tab.app_cursor_mode(), false) {
tab.backend.send(BackendCommand::Input(bytes));
tab.send_backend(BackendCommand::Input(bytes));
window.prevent_default();
cx.stop_propagation();
cx.notify();
@@ -156,18 +204,18 @@ 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();
tab.backend.send(BackendCommand::Input(bytes));
tab.send_backend(BackendCommand::Input(bytes));
window.prevent_default();
cx.stop_propagation();
cx.notify();
}
fn active_terminal_selection_text(&self) -> Option<String> {
pub(crate) fn active_terminal_selection_text(&self) -> Option<String> {
let active_id = self.active_tab.as_ref()?;
self.tabs
.iter()
@@ -175,7 +223,12 @@ impl Ashell {
.and_then(|tab| tab.selection_text())
}
fn paste_into_terminal(&mut self, text: &str, window: &mut Window, cx: &mut Context<Self>) {
pub(crate) fn paste_into_terminal(
&mut self,
text: &str,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(active_id) = self.active_tab.clone() else {
return;
};
@@ -183,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();
@@ -238,13 +291,12 @@ 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();
self.terminal_marked_text = None;
tab.backend
.send(BackendCommand::Input(text.as_bytes().to_vec()));
tab.send_backend(BackendCommand::Input(text.as_bytes().to_vec()));
window.invalidate_character_coordinates();
cx.notify();
}
@@ -328,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;
}
@@ -337,7 +418,54 @@ impl Ashell {
let Some(active_id) = self.active_tab.clone() else {
return;
};
let snapshot = match self.active_snapshot() {
Some(s) => s,
None => return,
};
let max_row = snapshot.rows.saturating_sub(1);
let mut scroll_delta = 0i32;
if max_row >= 6 {
if row <= 2 || row >= max_row.saturating_sub(2) {
let now = std::time::Instant::now();
let should_scroll = LAST_DRAG_SCROLL.with(|last| {
if let Some(last_time) = last.get() {
if now.duration_since(last_time) >= std::time::Duration::from_millis(80) {
last.set(Some(now));
true
} else {
false
}
} else {
last.set(Some(now));
true
}
});
if should_scroll {
if row == 0 {
scroll_delta = 2;
} else if row == 1 {
scroll_delta = 1;
} else if row == 2 {
scroll_delta = 1;
} else if row == max_row {
scroll_delta = -2;
} else if row == max_row.saturating_sub(1) {
scroll_delta = -1;
} else if row == max_row.saturating_sub(2) {
scroll_delta = -1;
}
}
} else {
LAST_DRAG_SCROLL.with(|last| last.set(None));
}
}
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == active_id) {
if scroll_delta != 0 {
tab.scroll_history(scroll_delta);
}
tab.update_selection(row, col, side);
cx.notify();
}
@@ -356,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)> {
@@ -397,6 +525,24 @@ impl Ashell {
window: &mut Window,
cx: &mut Context<Self>,
) {
// Platform modifier (Cmd on macOS, Ctrl on Windows/Linux) + scroll → zoom terminal font size
if event.modifiers.platform {
let delta = match event.delta {
ScrollDelta::Lines(point) => point.y as f32 * 20.0,
ScrollDelta::Pixels(point) => point.y.as_f32(),
};
self.terminal_zoom_accumulator += delta;
let step = 20.0;
if self.terminal_zoom_accumulator.abs() >= step {
let zoom_steps = (self.terminal_zoom_accumulator / step).trunc();
self.terminal_zoom_accumulator -= zoom_steps * step;
self.change_terminal_font_size(zoom_steps * 0.5, cx);
}
window.prevent_default();
cx.stop_propagation();
return;
}
let Some(active_id) = self.active_tab.clone() else {
return;
};
@@ -455,8 +601,7 @@ impl Ashell {
}
}
if !bytes.is_empty() {
tab.backend
.send(crate::terminal::BackendCommand::Input(bytes));
tab.send_backend(crate::terminal::BackendCommand::Input(bytes));
}
}
window.prevent_default();
@@ -470,8 +615,7 @@ impl Ashell {
bytes.extend_from_slice(&[b'\x1b', b'O', code]);
}
if !bytes.is_empty() {
tab.backend
.send(crate::terminal::BackendCommand::Input(bytes));
tab.send_backend(crate::terminal::BackendCommand::Input(bytes));
}
window.prevent_default();
cx.stop_propagation();
+182 -26
View File
@@ -1,5 +1,6 @@
pub mod custom_blocks;
pub mod element;
pub mod highlight;
pub mod input;
use std::sync::mpsc::Sender;
@@ -7,7 +8,7 @@ use std::sync::mpsc::Sender;
use alacritty_terminal::{
event::{Event, EventListener},
grid::{Dimensions, Scroll},
index::{Column, Point, Side},
index::{Column, Line, Point, Side},
selection::{Selection, SelectionRange, SelectionType},
term::{Config, Term, TermMode, cell::Cell, point_to_viewport, viewport_to_point},
vte::ansi::{CursorShape, Processor},
@@ -22,6 +23,7 @@ use crate::system::SystemSnapshot;
pub enum TabKind {
Local,
Ssh,
Serial,
}
#[derive(Debug)]
@@ -97,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 {
@@ -108,6 +111,9 @@ impl BackendTx {
Self::Ssh(tx) => {
let _ = tx.send(command);
}
Self::Serial(tx) => {
let _ = tx.send(command);
}
}
}
}
@@ -115,16 +121,31 @@ impl BackendTx {
pub struct TerminalTab {
pub id: String,
pub title: String,
pub dynamic_title: String,
pub kind: TabKind,
pub status: String,
pub connected: bool,
pub disconnected_reason: Option<String>,
/// Incremented each time the tab is reconnected. Used to ignore stale
/// `BackendEvent::Closed` from the previous backend after a retry.
pub backend_generation: u32,
/// Set to `true` when the current backend sends its first `Output` or
/// `Connected` event. Used to skip stale `Closed` events that arrive
/// before the new backend has started producing output.
pub backend_initialized: bool,
pub session: Option<Session>,
processor: Processor,
term: Term<TerminalListener>,
cols: u16,
rows: u16,
pub backend: BackendTx,
pub cols: u16,
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>,
)>,
>,
}
#[derive(Clone, Copy)]
@@ -134,7 +155,7 @@ pub struct CursorState {
pub shape: CursorShape,
}
#[derive(Clone)]
#[derive(Clone, PartialEq)]
pub struct RenderCell {
pub row: i32,
pub col: i32,
@@ -150,6 +171,7 @@ pub struct RenderSnapshot {
pub history_size: usize,
pub rows: usize,
pub cols: usize,
pub highlights: std::collections::HashMap<(i32, i32), gpui::Hsla>,
}
#[derive(Clone, Copy)]
@@ -211,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,
@@ -219,19 +260,25 @@ impl TerminalTab {
backend: BackendTx,
events: std::sync::mpsc::Sender<BackendEvent>,
) -> Self {
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),
disconnected_reason: None,
backend_generation: 0,
backend_initialized: true,
session: None,
processor: Processor::new(),
term: new_term(100, 30, backend.clone(), id, events),
term: new_term(100, 30, shared_backend.clone(), id, events.clone()),
cols: 100,
rows: 30,
backend,
backend: shared_backend,
scroll_pixel_y: 0.0,
highlight_cache: std::cell::RefCell::new(None),
}
}
@@ -239,6 +286,22 @@ impl TerminalTab {
self.processor.advance(&mut self.term, bytes);
}
/// Send a command to the backend. Thread-safe via the shared Arc<Mutex>.
pub fn send_backend(&self, command: BackendCommand) {
if let Ok(backend) = self.backend.lock() {
backend.send(command);
}
}
/// Replace the backend with a new one. The `Term`'s internal listener
/// shares the same `Arc`, so user input is automatically routed to the
/// new backend. The old backend must be closed by the caller.
pub fn set_backend(&self, new_backend: BackendTx) {
if let Ok(mut backend) = self.backend.lock() {
*backend = new_backend;
}
}
pub fn resize(&mut self, cols: u16, rows: u16) {
let new_cols = cols.max(1);
let new_rows = rows.max(1);
@@ -251,7 +314,7 @@ impl TerminalTab {
self.rows
);
self.term.resize(TerminalSize::new(self.cols, self.rows));
self.backend.send(BackendCommand::Resize { cols, rows });
self.send_backend(BackendCommand::Resize { cols, rows });
}
}
@@ -280,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();
@@ -309,17 +372,70 @@ impl TerminalTab {
});
}
// Get highlights from cache or recompute, only if keyword_highlight is enabled.
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);
if cache_valid {
cache.as_ref().unwrap().1.clone()
} else {
let computed = self::highlight::highlight_cells(&cells, rows as usize);
*cache = Some((cells.clone(), computed.clone()));
computed
}
} else {
std::collections::HashMap::new()
};
RenderSnapshot {
cells,
cursor: self.cursor_state(),
selection: viewport_selection_from_range(content.display_offset, &content.selection),
selection: viewport_selection_from_range(
content.display_offset,
self.rows as usize,
self.cols as usize,
&content.selection,
),
display_offset: content.display_offset,
history_size: self.term.grid().history_size(),
rows: self.rows as usize,
cols: self.cols as usize,
highlights,
}
}
/// Return `(grid_line_base, rows_data)` for the **entire** terminal buffer
/// including scrollback history. `grid_line_base` is the grid line index of
/// the first row (typically `-history_size`). Each entry in `rows_data` is
/// a sorted `Vec<(col, char)>` for that row.
pub fn full_grid_rows(&self) -> (i32, Vec<Vec<(i32, char)>>) {
let grid = self.term.grid();
let history = grid.history_size() as i32;
let screen = grid.screen_lines() as i32;
let total = history + screen;
let cols = self.cols as i32;
let start_line = -history;
let mut rows_data: Vec<Vec<(i32, char)>> = Vec::with_capacity(total as usize);
for line_idx in start_line..(start_line + total) {
let line = Line(line_idx);
let mut cells: Vec<(i32, char)> = Vec::new();
for col_idx in 0..cols {
let point = Point::new(line, Column(col_idx as usize));
let c = grid[point].c;
if c != ' ' && c != '\0' {
cells.push((col_idx, c));
}
}
rows_data.push(cells);
}
(start_line, rows_data)
}
pub fn scroll_history(&mut self, delta: i32) {
if delta != 0 {
self.term.scroll_display(Scroll::Delta(delta));
@@ -384,18 +500,29 @@ impl TerminalTab {
}
pub fn paste_text(&mut self, text: &str) {
let bracketed = self.term.mode().contains(TermMode::BRACKETED_PASTE);
let paste_text = text
.replace('\x1b', "")
.replace("\r\n", "\r")
.replace('\n', "\r");
self.backend
.send(BackendCommand::Input(paste_text.into_bytes()));
let mut bytes = Vec::new();
if bracketed {
bytes.extend_from_slice(b"\x1b[200~");
}
bytes.extend_from_slice(paste_text.as_bytes());
if bracketed {
bytes.extend_from_slice(b"\x1b[201~");
}
self.send_backend(BackendCommand::Input(bytes));
}
}
fn viewport_selection_from_range(
display_offset: usize,
rows: usize,
cols: usize,
selection: &Option<SelectionRange>,
) -> Option<ViewportSelection> {
let SelectionRange {
@@ -403,14 +530,40 @@ fn viewport_selection_from_range(
end,
is_block,
} = selection.as_ref().copied()?;
let start = point_to_viewport(display_offset, start)?;
let end = point_to_viewport(display_offset, end)?;
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 top_line = top_point.line;
let bottom_line = bottom_point.line;
let start_vp = if start.line < top_line {
Point::new(0, Column(0))
} else if start.line > bottom_line {
Point::new(rows.saturating_sub(1), Column(cols.saturating_sub(1)))
} else {
point_to_viewport(display_offset, start).unwrap_or(Point::new(0, Column(0)))
};
let end_vp = if end.line < top_line {
Point::new(0, Column(0))
} 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)),
))
};
Some(ViewportSelection {
start_row: start.line,
start_col: start.column.0,
end_row: end.line,
end_col: end.column.0,
start_row: start_vp.line,
start_col: start_vp.column.0,
end_row: end_vp.line,
end_col: end_vp.column.0,
is_block,
})
}
@@ -418,16 +571,18 @@ fn viewport_selection_from_range(
#[derive(Clone)]
struct TerminalListener {
tab_id: String,
backend: BackendTx,
backend: std::sync::Arc<std::sync::Mutex<BackendTx>>,
events: std::sync::mpsc::Sender<BackendEvent>,
}
impl EventListener for TerminalListener {
fn send_event(&self, event: Event) {
match event {
Event::PtyWrite(output) => self
.backend
.send(BackendCommand::Input(output.into_bytes())),
Event::PtyWrite(output) => {
if let Ok(backend) = self.backend.lock() {
backend.send(BackendCommand::Input(output.into_bytes()));
}
}
Event::TextAreaSizeRequest(format) => {
let size = alacritty_terminal::event::WindowSize {
num_lines: 30,
@@ -435,8 +590,9 @@ impl EventListener for TerminalListener {
cell_width: 8,
cell_height: 16,
};
self.backend
.send(BackendCommand::Input(format(size).into_bytes()));
if let Ok(backend) = self.backend.lock() {
backend.send(BackendCommand::Input(format(size).into_bytes()));
}
}
Event::Title(title) => {
let _ = self.events.send(BackendEvent::TerminalTitleChanged {
@@ -452,7 +608,7 @@ impl EventListener for TerminalListener {
fn new_term(
cols: u16,
rows: u16,
backend: BackendTx,
backend: std::sync::Arc<std::sync::Mutex<BackendTx>>,
tab_id: String,
events: std::sync::mpsc::Sender<BackendEvent>,
) -> Term<TerminalListener> {