mirror of
https://github.com/rust-kotlin/ashell.git
synced 2026-10-03 08:00:20 +00:00
Compare commits
69
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84eccb5521 | ||
|
|
4da9949c84 | ||
|
|
85fb354f80 | ||
|
|
584f962298 | ||
|
|
a32fd9437d | ||
|
|
4c292c060c | ||
|
|
c0a954ea0c | ||
|
|
ab41ccbd6b | ||
|
|
43717e4378 | ||
|
|
31218f7a37 | ||
|
|
af5c2831f6 | ||
|
|
a8669738e6 | ||
|
|
2dad1fa5a1 | ||
|
|
e2bf76f7b2 | ||
|
|
a654695d7b | ||
|
|
cb078ef572 | ||
|
|
c0aa21a994 | ||
|
|
c65354e8d6 | ||
|
|
fcfbefe3ab | ||
|
|
39d369aaaf | ||
|
|
5c0c0cc701 | ||
|
|
99b7fe30e4 | ||
|
|
24f817f9e0 | ||
|
|
1a3f638004 | ||
|
|
5f318f66c8 | ||
|
|
8854ee9afb | ||
|
|
8cc64acdc1 | ||
|
|
77a0380ba7 | ||
|
|
3770826764 | ||
|
|
cae6323888 | ||
|
|
54bcc97fe1 | ||
|
|
273bc2fe0c | ||
|
|
f0e02e0ce7 | ||
|
|
f6d9cd00e7 | ||
|
|
bfc7c9ac83 | ||
|
|
fd19daedad | ||
|
|
042834c7d5 | ||
|
|
3c6b9e3b46 | ||
|
|
a88379b01c | ||
|
|
f322b2c717 | ||
|
|
45d0c64c06 | ||
|
|
6312a482cb | ||
|
|
8bb4cbc655 | ||
|
|
847f0626f4 | ||
|
|
7cfe5c64d5 | ||
|
|
6d23d27fea | ||
|
|
da856ac9d7 | ||
|
|
7653c66a6f | ||
|
|
56fe508122 | ||
|
|
3b7165a4bd | ||
|
|
5ae44c9e44 | ||
|
|
73938aef8a | ||
|
|
59df2b5a9a | ||
|
|
b225b0e56f | ||
|
|
2cdd6d3ded | ||
|
|
42c408f63f | ||
|
|
f4005bf2bb | ||
|
|
d5fab2e9ca | ||
|
|
1b7f625aea | ||
|
|
b6b4d3c35e | ||
|
|
ae763eae1c | ||
|
|
a89d15d255 | ||
|
|
b5a068541d | ||
|
|
7be0c420e1 | ||
|
|
62028c7921 | ||
|
|
56a6ef98b7 | ||
|
|
caa8a58561 | ||
|
|
6150bd410b | ||
|
|
f55c3a8470 |
@@ -12,7 +12,7 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write # needed to create / update the GitHub Release
|
||||
contents: write # needed to create / update the GitHub Release
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -26,15 +26,15 @@ jobs:
|
||||
target: x86_64-pc-windows-msvc
|
||||
name: windows-x86_64
|
||||
bin: ashell.exe
|
||||
- os: ubuntu-22.04 # build on the oldest supported runner for broader glibc compatibility
|
||||
- os: ubuntu-22.04 # build on the oldest supported runner for broader glibc compatibility
|
||||
target: x86_64-unknown-linux-gnu
|
||||
name: linux-x86_64
|
||||
bin: ashell
|
||||
- os: macos-14 # Apple Silicon
|
||||
- os: macos-14 # Apple Silicon
|
||||
target: aarch64-apple-darwin
|
||||
name: macos-aarch64
|
||||
bin: ashell
|
||||
- os: macos-14 # Intel (cross-compiled from Apple Silicon to avoid queue delays)
|
||||
- os: macos-14 # Intel (cross-compiled from Apple Silicon to avoid queue delays)
|
||||
target: x86_64-apple-darwin
|
||||
name: macos-x86_64
|
||||
bin: ashell
|
||||
@@ -80,7 +80,7 @@ jobs:
|
||||
# Note: We build the bundle manually here rather than running
|
||||
# scripts/package-macos-app.sh, because that script runs `cargo build`
|
||||
# without respecting the `--target` flag from the matrix.
|
||||
|
||||
|
||||
APP="ashell.app"
|
||||
CONTENTS="$APP/Contents"
|
||||
mkdir -p "$CONTENTS/MacOS" "$CONTENTS/Resources"
|
||||
@@ -193,14 +193,14 @@ jobs:
|
||||
VERSION=${GITHUB_REF#refs/tags/v}
|
||||
SHA_ARM=$(sha256sum dist/macos-aarch64/*.zip | cut -d' ' -f1)
|
||||
SHA_INTEL=$(sha256sum dist/macos-x86_64/*.zip | cut -d' ' -f1)
|
||||
|
||||
|
||||
mkdir -p tap/Casks
|
||||
CASK_FILE="tap/Casks/ashell.rb"
|
||||
|
||||
|
||||
cat > "$CASK_FILE" <<EOF
|
||||
cask "ashell" do
|
||||
version "${VERSION}"
|
||||
|
||||
|
||||
on_arm do
|
||||
sha256 "${SHA_ARM}"
|
||||
url "https://github.com/rust-kotlin/ashell/releases/download/v#{version}/ashell-v#{version}-macos-aarch64.zip"
|
||||
@@ -224,9 +224,12 @@ jobs:
|
||||
end
|
||||
|
||||
caveats <<~EOS
|
||||
ashell is not notarized. The installation includes a postflight script
|
||||
ashell uses ad-hoc signing. The installation includes a postflight script
|
||||
that removes the 'com.apple.quarantine' attribute from the application
|
||||
to allow it to run. This will require your administrator password.
|
||||
|
||||
ashell 采用本地签名。安装程序包含一个后置脚本,用于移除应用程序的
|
||||
'com.apple.quarantine' 属性以允许其运行。这需要您的管理员密码。
|
||||
EOS
|
||||
end
|
||||
EOF
|
||||
|
||||
Generated
+148
-27
@@ -265,6 +265,18 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "argon2"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"blake2",
|
||||
"cpufeatures",
|
||||
"password-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arrayref"
|
||||
version = "0.3.9"
|
||||
@@ -303,11 +315,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ashell"
|
||||
version = "0.4.4"
|
||||
version = "0.4.11"
|
||||
dependencies = [
|
||||
"alacritty_terminal",
|
||||
"anyhow",
|
||||
"argon2",
|
||||
"async-trait",
|
||||
"base64",
|
||||
"chacha20poly1305",
|
||||
"chrono",
|
||||
"directories",
|
||||
"flate2",
|
||||
@@ -316,11 +331,15 @@ dependencies = [
|
||||
"gpui-component",
|
||||
"gpui-component-assets",
|
||||
"gpui_platform",
|
||||
"hex",
|
||||
"hmac",
|
||||
"image",
|
||||
"menu",
|
||||
"notify 6.1.1",
|
||||
"open",
|
||||
"portable-pty",
|
||||
"rand 0.8.6",
|
||||
"reqwest",
|
||||
"rfd",
|
||||
"russh",
|
||||
"russh-keys",
|
||||
@@ -328,6 +347,7 @@ dependencies = [
|
||||
"rust-i18n 3.1.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"ssh-key",
|
||||
"sys-locale",
|
||||
"sysinfo 0.33.1",
|
||||
@@ -335,6 +355,7 @@ dependencies = [
|
||||
"thiserror 1.0.69",
|
||||
"time",
|
||||
"tokio",
|
||||
"tokio-socks",
|
||||
"tracing",
|
||||
"tracing-appender",
|
||||
"tracing-subscriber",
|
||||
@@ -842,6 +863,15 @@ dependencies = [
|
||||
"no_std_io2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blake2"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block"
|
||||
version = "0.1.6"
|
||||
@@ -1091,6 +1121,19 @@ dependencies = [
|
||||
"cpufeatures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chacha20poly1305"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
|
||||
dependencies = [
|
||||
"aead",
|
||||
"chacha20",
|
||||
"cipher",
|
||||
"poly1305",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.45"
|
||||
@@ -1201,9 +1244,8 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "collections"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/zed-industries/zed#dde98fa822c59028475850f242a4827ba770e24a"
|
||||
source = "git+https://github.com/zed-industries/zed#969a67fcfbf799e68ab00854028228274be847e8"
|
||||
dependencies = [
|
||||
"gpui_util",
|
||||
"indexmap",
|
||||
"rustc-hash 2.1.2",
|
||||
]
|
||||
@@ -1698,7 +1740,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "derive_refineable"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/zed-industries/zed#dde98fa822c59028475850f242a4827ba770e24a"
|
||||
source = "git+https://github.com/zed-industries/zed#969a67fcfbf799e68ab00854028228274be847e8"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -2682,7 +2724,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "gpui"
|
||||
version = "0.2.2"
|
||||
source = "git+https://github.com/zed-industries/zed#dde98fa822c59028475850f242a4827ba770e24a"
|
||||
source = "git+https://github.com/zed-industries/zed#969a67fcfbf799e68ab00854028228274be847e8"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"anyhow",
|
||||
@@ -2712,7 +2754,6 @@ dependencies = [
|
||||
"gpui_macros",
|
||||
"gpui_shared_string",
|
||||
"gpui_util",
|
||||
"heapless",
|
||||
"http_client",
|
||||
"image",
|
||||
"inventory",
|
||||
@@ -2832,7 +2873,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "gpui_linux"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/zed-industries/zed#dde98fa822c59028475850f242a4827ba770e24a"
|
||||
source = "git+https://github.com/zed-industries/zed#969a67fcfbf799e68ab00854028228274be847e8"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"accesskit_unix",
|
||||
@@ -2883,7 +2924,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "gpui_macos"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/zed-industries/zed#dde98fa822c59028475850f242a4827ba770e24a"
|
||||
source = "git+https://github.com/zed-industries/zed#969a67fcfbf799e68ab00854028228274be847e8"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"accesskit_macos",
|
||||
@@ -2928,7 +2969,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "gpui_macros"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/zed-industries/zed#dde98fa822c59028475850f242a4827ba770e24a"
|
||||
source = "git+https://github.com/zed-industries/zed#969a67fcfbf799e68ab00854028228274be847e8"
|
||||
dependencies = [
|
||||
"heck 0.5.0",
|
||||
"proc-macro2",
|
||||
@@ -2939,7 +2980,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "gpui_platform"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/zed-industries/zed#dde98fa822c59028475850f242a4827ba770e24a"
|
||||
source = "git+https://github.com/zed-industries/zed#969a67fcfbf799e68ab00854028228274be847e8"
|
||||
dependencies = [
|
||||
"console_error_panic_hook",
|
||||
"gpui",
|
||||
@@ -2952,7 +2993,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "gpui_shared_string"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/zed-industries/zed#dde98fa822c59028475850f242a4827ba770e24a"
|
||||
source = "git+https://github.com/zed-industries/zed#969a67fcfbf799e68ab00854028228274be847e8"
|
||||
dependencies = [
|
||||
"schemars",
|
||||
"serde",
|
||||
@@ -2962,7 +3003,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "gpui_util"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/zed-industries/zed#dde98fa822c59028475850f242a4827ba770e24a"
|
||||
source = "git+https://github.com/zed-industries/zed#969a67fcfbf799e68ab00854028228274be847e8"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"log",
|
||||
@@ -2971,7 +3012,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "gpui_web"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/zed-industries/zed#dde98fa822c59028475850f242a4827ba770e24a"
|
||||
source = "git+https://github.com/zed-industries/zed#969a67fcfbf799e68ab00854028228274be847e8"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"console_error_panic_hook",
|
||||
@@ -2995,7 +3036,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "gpui_wgpu"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/zed-industries/zed#dde98fa822c59028475850f242a4827ba770e24a"
|
||||
source = "git+https://github.com/zed-industries/zed#969a67fcfbf799e68ab00854028228274be847e8"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytemuck",
|
||||
@@ -3024,7 +3065,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "gpui_windows"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/zed-industries/zed#dde98fa822c59028475850f242a4827ba770e24a"
|
||||
source = "git+https://github.com/zed-industries/zed#969a67fcfbf799e68ab00854028228274be847e8"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"accesskit_windows",
|
||||
@@ -3273,7 +3314,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "http_client"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/zed-industries/zed#dde98fa822c59028475850f242a4827ba770e24a"
|
||||
source = "git+https://github.com/zed-industries/zed#969a67fcfbf799e68ab00854028228274be847e8"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-compression",
|
||||
@@ -3336,6 +3377,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3344,13 +3386,16 @@ version = "0.1.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"hyper",
|
||||
"ipnet",
|
||||
"libc",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"tokio",
|
||||
@@ -4146,7 +4191,7 @@ checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771"
|
||||
[[package]]
|
||||
name = "media"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/zed-industries/zed#dde98fa822c59028475850f242a4827ba770e24a"
|
||||
source = "git+https://github.com/zed-industries/zed#969a67fcfbf799e68ab00854028228274be847e8"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bindgen",
|
||||
@@ -4194,7 +4239,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "menu"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/zed-industries/zed#dde98fa822c59028475850f242a4827ba770e24a"
|
||||
source = "git+https://github.com/zed-industries/zed#969a67fcfbf799e68ab00854028228274be847e8"
|
||||
dependencies = [
|
||||
"gpui",
|
||||
]
|
||||
@@ -5014,6 +5059,17 @@ dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "password-hash"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "paste"
|
||||
version = "1.0.15"
|
||||
@@ -5079,7 +5135,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
[[package]]
|
||||
name = "perf"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/zed-industries/zed#dde98fa822c59028475850f242a4827ba770e24a"
|
||||
source = "git+https://github.com/zed-industries/zed#969a67fcfbf799e68ab00854028228274be847e8"
|
||||
dependencies = [
|
||||
"collections",
|
||||
"serde",
|
||||
@@ -5861,7 +5917,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "refineable"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/zed-industries/zed#dde98fa822c59028475850f242a4827ba770e24a"
|
||||
source = "git+https://github.com/zed-industries/zed#969a67fcfbf799e68ab00854028228274be847e8"
|
||||
dependencies = [
|
||||
"derive_refineable",
|
||||
]
|
||||
@@ -5901,6 +5957,44 @@ version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.12.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "resvg"
|
||||
version = "0.45.1"
|
||||
@@ -6477,7 +6571,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "scheduler"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/zed-industries/zed#dde98fa822c59028475850f242a4827ba770e24a"
|
||||
source = "git+https://github.com/zed-industries/zed#969a67fcfbf799e68ab00854028228274be847e8"
|
||||
dependencies = [
|
||||
"async-task",
|
||||
"backtrace",
|
||||
@@ -7216,7 +7310,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
[[package]]
|
||||
name = "sum_tree"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/zed-industries/zed#dde98fa822c59028475850f242a4827ba770e24a"
|
||||
source = "git+https://github.com/zed-industries/zed#969a67fcfbf799e68ab00854028228274be847e8"
|
||||
dependencies = [
|
||||
"heapless",
|
||||
"log",
|
||||
@@ -7854,6 +7948,24 @@ dependencies = [
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower-http"
|
||||
version = "0.6.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
|
||||
dependencies = [
|
||||
"bitflags 2.12.1",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"pin-project-lite",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower-layer"
|
||||
version = "0.3.3"
|
||||
@@ -8182,7 +8294,7 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||
[[package]]
|
||||
name = "util"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/zed-industries/zed#dde98fa822c59028475850f242a4827ba770e24a"
|
||||
source = "git+https://github.com/zed-industries/zed#969a67fcfbf799e68ab00854028228274be847e8"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-fs",
|
||||
@@ -8221,7 +8333,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "util_macros"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/zed-industries/zed#dde98fa822c59028475850f242a4827ba770e24a"
|
||||
source = "git+https://github.com/zed-industries/zed#969a67fcfbf799e68ab00854028228274be847e8"
|
||||
dependencies = [
|
||||
"perf",
|
||||
"quote",
|
||||
@@ -8615,6 +8727,15 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "weezl"
|
||||
version = "0.1.12"
|
||||
@@ -10098,7 +10219,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "zlog"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/zed-industries/zed#dde98fa822c59028475850f242a4827ba770e24a"
|
||||
source = "git+https://github.com/zed-industries/zed#969a67fcfbf799e68ab00854028228274be847e8"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -10127,7 +10248,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "ztracing"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/zed-industries/zed#dde98fa822c59028475850f242a4827ba770e24a"
|
||||
source = "git+https://github.com/zed-industries/zed#969a67fcfbf799e68ab00854028228274be847e8"
|
||||
dependencies = [
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
@@ -10138,7 +10259,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "ztracing_macro"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/zed-industries/zed#dde98fa822c59028475850f242a4827ba770e24a"
|
||||
source = "git+https://github.com/zed-industries/zed#969a67fcfbf799e68ab00854028228274be847e8"
|
||||
|
||||
[[package]]
|
||||
name = "zune-core"
|
||||
|
||||
+22
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ashell"
|
||||
version = "0.4.4"
|
||||
version = "0.4.11"
|
||||
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"] }
|
||||
@@ -44,6 +45,26 @@ notify = "6.1.1"
|
||||
open = "5.1"
|
||||
time = { version = "0.3.45", features = ["formatting", "local-offset"] }
|
||||
tracing-appender = "0.2.5"
|
||||
argon2 = "0.5"
|
||||
base64 = "0.22"
|
||||
chacha20poly1305 = "0.10"
|
||||
hmac = "0.12"
|
||||
hex = "0.4"
|
||||
rand = "0.8"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
|
||||
sha2 = "0.10"
|
||||
|
||||
[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
|
||||
|
||||
+55
-2
@@ -24,8 +24,28 @@ You can download the latest pre-compiled releases for macOS, Windows, and Linux
|
||||
|
||||
## Mac Installation Guide
|
||||
|
||||
After downloading, please unzip the file and move `ashell.app` to your **Applications** folder.
|
||||
Since the app uses ad-hoc signing, macOS may warn that the app is "damaged" upon first launch. If this happens, open Terminal and run the following command:
|
||||
### Method 1: Homebrew (Recommended)
|
||||
|
||||
If you use [Homebrew](https://brew.sh/), you can install it quickly with:
|
||||
|
||||
```bash
|
||||
brew install rust-kotlin/taps/ashell --cask
|
||||
```
|
||||
|
||||
To update the app:
|
||||
|
||||
```bash
|
||||
brew update
|
||||
brew upgrade ashell --cask
|
||||
```
|
||||
|
||||
> **Note**: Since the app uses ad-hoc signing, the Homebrew installation or update includes a postflight script to automatically handle the quarantine flag. This will require you to enter your administrator password for authorization.
|
||||
|
||||
### Method 2: Manual Download
|
||||
|
||||
1. Download and unzip from the [Releases page](https://github.com/rust-kotlin/ashell/releases/latest).
|
||||
2. Move `ashell.app` to your **Applications** folder.
|
||||
3. Since the app uses ad-hoc signing, macOS may warn that the app is "damaged" upon first launch. If this happens, open Terminal and run:
|
||||
|
||||
```bash
|
||||
sudo xattr -cr /Applications/ashell.app
|
||||
@@ -62,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).
|
||||
|
||||
@@ -24,8 +24,28 @@ v0.4 在 v0.3 打下的基础上,重点带来了更完整的工作区操作能
|
||||
|
||||
## Mac 安装指南
|
||||
|
||||
下载并解压后,请先将 `ashell.app` 拖入或移动到 **应用程序 (Applications)** 目录。
|
||||
由于应用采用本地签名,初次启动时如果系统提示“App 已损坏,无法打开”,请打开终端(Terminal)并执行以下命令:
|
||||
### 方法 1: Homebrew 安装 (推荐)
|
||||
|
||||
如果您使用 [Homebrew](https://brew.sh/),可以通过以下命令快速安装:
|
||||
|
||||
```bash
|
||||
brew install rust-kotlin/taps/ashell --cask
|
||||
```
|
||||
|
||||
更新应用:
|
||||
|
||||
```bash
|
||||
brew update
|
||||
brew upgrade ashell --cask
|
||||
```
|
||||
|
||||
> **注意**: 由于应用采用本地签名,Homebrew 在安装或更新过程中会执行一个后置脚本来自动处理隔离属性(quarantine flag),这需要您输入管理员密码以授权。
|
||||
|
||||
### 方法 2: 手动下载
|
||||
|
||||
1. 从 [Releases 页面](https://github.com/rust-kotlin/ashell/releases/latest) 下载并解压。
|
||||
2. 将 `ashell.app` 拖入或移动到 **应用程序 (Applications)** 目录。
|
||||
3. 由于应用采用本地签名,初次启动时如果系统提示“App 已损坏,无法打开”,请打开终端(Terminal)并执行以下命令:
|
||||
|
||||
```bash
|
||||
sudo xattr -cr /Applications/ashell.app
|
||||
@@ -62,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) 开源。
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+62
-1
@@ -33,6 +33,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 +64,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 +89,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 +126,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 +164,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"
|
||||
@@ -154,3 +179,39 @@ settings_split_pane_up: "Split Pane Up"
|
||||
settings_split_pane_down: "Split Pane Down"
|
||||
settings_close_pane: "Close Pane"
|
||||
keybind_conflict: '"%{key}" is already used by "%{action}", please choose another key'
|
||||
title_bar_style: "Title Bar Style"
|
||||
title_bar_native: "Native"
|
||||
title_bar_integrated: "Integrated"
|
||||
restart_hint: " (Restart required)"
|
||||
settings_sync: "Configuration Sync"
|
||||
sync_endpoint: "WebDAV Endpoint"
|
||||
sync_username: "WebDAV Username"
|
||||
sync_webdav_password: "WebDAV Password"
|
||||
sync_encryption_password: "Encryption Password (8+ characters)"
|
||||
sync_security_hint: "Only connection parameters are stored locally. WebDAV passwords, S3 secrets, and encryption passwords remain in memory for this process. Uploaded data is end-to-end encrypted. Downloading replaces locally saved SSH sessions."
|
||||
sync_s3_endpoint: "S3 Endpoint (optional for AWS S3)"
|
||||
sync_s3_region: "S3 Region"
|
||||
sync_s3_bucket: "S3 Bucket"
|
||||
sync_s3_object_key: "Object Key"
|
||||
sync_s3_access_key: "Access Key ID"
|
||||
sync_s3_secret_key: "Secret Access Key"
|
||||
sync_s3_session_token: "Session Token (optional)"
|
||||
sync_download: "Download Remote"
|
||||
sync_upload: "Upload Remote"
|
||||
sync_not_run: "Not synchronized yet"
|
||||
sync_uploading: "Encrypting and uploading configuration..."
|
||||
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"
|
||||
|
||||
+62
-1
@@ -34,6 +34,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 +65,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 +90,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 +127,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 +166,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: "向左切换焦点"
|
||||
@@ -156,3 +181,39 @@ settings_split_pane_up: "向上拆分面板"
|
||||
settings_split_pane_down: "向下拆分面板"
|
||||
settings_close_pane: "关闭当前面板"
|
||||
keybind_conflict: '"%{key}" 已被「%{action}」使用,请选择其他快捷键'
|
||||
title_bar_style: "标题栏样式"
|
||||
title_bar_native: "原生"
|
||||
title_bar_integrated: "融合"
|
||||
restart_hint: " (修改后重启生效)"
|
||||
settings_sync: "配置同步"
|
||||
sync_endpoint: "WebDAV 地址"
|
||||
sync_username: "WebDAV 用户名"
|
||||
sync_webdav_password: "WebDAV 密码"
|
||||
sync_encryption_password: "配置加密口令(至少 8 位)"
|
||||
sync_security_hint: "仅连接参数会保存到本机。WebDAV 密码、S3 密钥和加密口令只保留在当前进程内;上传内容使用端到端加密。下载会替换本机保存的 SSH 会话。"
|
||||
sync_s3_endpoint: "S3 Endpoint(AWS S3 可留空)"
|
||||
sync_s3_region: "S3 Region"
|
||||
sync_s3_bucket: "S3 Bucket"
|
||||
sync_s3_object_key: "对象路径"
|
||||
sync_s3_access_key: "Access Key ID"
|
||||
sync_s3_secret_key: "Secret Access Key"
|
||||
sync_s3_session_token: "Session Token(可选)"
|
||||
sync_download: "从远程下载"
|
||||
sync_upload: "上传到远程"
|
||||
sync_not_run: "尚未同步"
|
||||
sync_uploading: "正在加密并上传配置..."
|
||||
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: "保存代理设置"
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
use gpui::{Context, Entity, SharedString};
|
||||
use gpui_component::input::InputState;
|
||||
use rust_i18n::t;
|
||||
|
||||
use crate::{
|
||||
Ashell,
|
||||
sync::{self, SyncBackendCredentials, SyncCredentials, SyncPayload, SyncResult},
|
||||
terminal::BackendEvent,
|
||||
};
|
||||
|
||||
impl Ashell {
|
||||
fn sync_input_value(input: &Entity<InputState>, cx: &Context<Self>) -> String {
|
||||
input.read(cx).value().trim().to_string()
|
||||
}
|
||||
|
||||
fn sync_credentials(&self, cx: &Context<Self>) -> SyncCredentials {
|
||||
let backend = if self.config.sync_backend() == "s3" {
|
||||
SyncBackendCredentials::S3 {
|
||||
endpoint: Self::sync_input_value(&self.sync_s3_endpoint_input, cx),
|
||||
region: Self::sync_input_value(&self.sync_s3_region_input, cx),
|
||||
bucket: Self::sync_input_value(&self.sync_s3_bucket_input, cx),
|
||||
object_key: Self::sync_input_value(&self.sync_s3_object_key_input, cx),
|
||||
access_key: Self::sync_input_value(&self.sync_s3_access_key_input, cx),
|
||||
secret_key: self.sync_s3_secret_key_input.read(cx).value().to_string(),
|
||||
session_token: self
|
||||
.sync_s3_session_token_input
|
||||
.read(cx)
|
||||
.value()
|
||||
.to_string(),
|
||||
}
|
||||
} else {
|
||||
SyncBackendCredentials::WebDav {
|
||||
endpoint: Self::sync_input_value(&self.sync_endpoint_input, cx),
|
||||
username: Self::sync_input_value(&self.sync_username_input, cx),
|
||||
password: self.sync_webdav_password_input.read(cx).value().to_string(),
|
||||
}
|
||||
};
|
||||
SyncCredentials {
|
||||
backend,
|
||||
encryption_password: self
|
||||
.sync_encryption_password_input
|
||||
.read(cx)
|
||||
.value()
|
||||
.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn begin_sync(
|
||||
&mut self,
|
||||
status: SharedString,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Option<SyncCredentials> {
|
||||
if self.sync_in_progress {
|
||||
return None;
|
||||
}
|
||||
let credentials = self.sync_credentials(cx);
|
||||
match &credentials.backend {
|
||||
SyncBackendCredentials::WebDav {
|
||||
endpoint, username, ..
|
||||
} => {
|
||||
self.config
|
||||
.set_sync_connection(endpoint.clone(), username.clone());
|
||||
}
|
||||
SyncBackendCredentials::S3 {
|
||||
endpoint,
|
||||
region,
|
||||
bucket,
|
||||
object_key,
|
||||
..
|
||||
} => {
|
||||
self.config.set_sync_s3_connection(
|
||||
endpoint.clone(),
|
||||
region.clone(),
|
||||
bucket.clone(),
|
||||
object_key.clone(),
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Err(err) = self.config.save() {
|
||||
self.sync_status = format!("{}: {err:#}", t!("sync_failed")).into();
|
||||
cx.notify();
|
||||
return None;
|
||||
}
|
||||
self.sync_in_progress = true;
|
||||
self.sync_status = status;
|
||||
cx.notify();
|
||||
Some(credentials)
|
||||
}
|
||||
|
||||
pub(crate) fn set_sync_backend(&mut self, backend: &str, cx: &mut Context<Self>) {
|
||||
self.config.set_sync_backend(backend);
|
||||
let _ = self.config.save();
|
||||
self.sync_status = t!("sync_not_run").into();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn upload_sync_config(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(credentials) = self.begin_sync(t!("sync_uploading").into(), cx) else {
|
||||
return;
|
||||
};
|
||||
let payload = SyncPayload::new(
|
||||
self.config.sync_device_id().to_string(),
|
||||
self.config.sessions().to_vec(),
|
||||
);
|
||||
let expected_etag = self.config.sync_etag().map(str::to_string);
|
||||
let events = self.events_tx.clone();
|
||||
self.runtime.spawn(async move {
|
||||
let result = match sync::upload(credentials, payload, expected_etag).await {
|
||||
Ok(etag) => SyncResult::Uploaded { etag },
|
||||
Err(err) => SyncResult::Failed(format!("{err:#}")),
|
||||
};
|
||||
let _ = events.send(BackendEvent::SyncFinished(result));
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn download_sync_config(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(credentials) = self.begin_sync(t!("sync_downloading").into(), cx) else {
|
||||
return;
|
||||
};
|
||||
let events = self.events_tx.clone();
|
||||
self.runtime.spawn(async move {
|
||||
let result = match sync::download(credentials).await {
|
||||
Ok((payload, etag)) => SyncResult::Downloaded { payload, etag },
|
||||
Err(err) => SyncResult::Failed(format!("{err:#}")),
|
||||
};
|
||||
let _ = events.send(BackendEvent::SyncFinished(result));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
+939
-398
File diff suppressed because it is too large
Load Diff
@@ -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 {
|
||||
@@ -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);
|
||||
}
|
||||
@@ -261,6 +290,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 +302,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 +313,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",
|
||||
|
||||
+561
-56
@@ -1,6 +1,9 @@
|
||||
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;
|
||||
@@ -14,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,
|
||||
@@ -21,7 +25,6 @@ use gpui::{
|
||||
use gpui_component::{
|
||||
Theme, ThemeMode, ThemeRegistry,
|
||||
input::{InputEvent, InputState},
|
||||
resizable::ResizableState,
|
||||
scroll::ScrollbarHandle,
|
||||
};
|
||||
use rust_i18n::t;
|
||||
@@ -29,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},
|
||||
};
|
||||
@@ -208,8 +212,33 @@ 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) 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>,
|
||||
pub(crate) sync_s3_endpoint_input: Entity<InputState>,
|
||||
pub(crate) sync_s3_region_input: Entity<InputState>,
|
||||
pub(crate) sync_s3_bucket_input: Entity<InputState>,
|
||||
pub(crate) sync_s3_object_key_input: Entity<InputState>,
|
||||
pub(crate) sync_s3_access_key_input: Entity<InputState>,
|
||||
pub(crate) sync_s3_secret_key_input: Entity<InputState>,
|
||||
pub(crate) sync_s3_session_token_input: Entity<InputState>,
|
||||
pub(crate) sync_encryption_password_input: Entity<InputState>,
|
||||
pub(crate) sync_in_progress: bool,
|
||||
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,
|
||||
@@ -217,6 +246,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>,
|
||||
@@ -258,6 +288,8 @@ pub(crate) struct Ashell {
|
||||
pub(crate) prev_monitoring_size: Option<Pixels>,
|
||||
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>,
|
||||
@@ -272,6 +304,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>,
|
||||
|
||||
@@ -279,9 +319,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>,
|
||||
}
|
||||
|
||||
#[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)]
|
||||
pub(crate) enum SelectorEntry {
|
||||
Local,
|
||||
@@ -349,9 +401,105 @@ impl Ashell {
|
||||
.placeholder("SSH private key passphrase (optional)")
|
||||
.masked(true)
|
||||
});
|
||||
let proxy_host_input =
|
||||
cx.new(|cx| InputState::new(window, cx).placeholder(t!("proxy_host").to_string()));
|
||||
let proxy_port_input =
|
||||
cx.new(|cx| InputState::new(window, cx).placeholder(t!("proxy_port").to_string()));
|
||||
let proxy_user_input =
|
||||
cx.new(|cx| InputState::new(window, cx).placeholder(t!("proxy_user").to_string()));
|
||||
let proxy_password_input = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.placeholder(t!("proxy_password").to_string())
|
||||
.masked(true)
|
||||
});
|
||||
let 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/")
|
||||
.default_value(config.sync_endpoint())
|
||||
});
|
||||
let sync_username_input = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.placeholder(t!("sync_username").to_string())
|
||||
.default_value(config.sync_username())
|
||||
});
|
||||
let sync_webdav_password_input = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.placeholder(t!("sync_webdav_password").to_string())
|
||||
.masked(true)
|
||||
});
|
||||
let sync_s3_endpoint_input = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.placeholder("https://s3.example.com")
|
||||
.default_value(config.sync_s3_endpoint())
|
||||
});
|
||||
let sync_s3_region_input = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.placeholder("us-east-1")
|
||||
.default_value(config.sync_s3_region())
|
||||
});
|
||||
let sync_s3_bucket_input = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.placeholder(t!("sync_s3_bucket").to_string())
|
||||
.default_value(config.sync_s3_bucket())
|
||||
});
|
||||
let sync_s3_object_key_input = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.placeholder("ashell-sync.json")
|
||||
.default_value(config.sync_s3_object_key())
|
||||
});
|
||||
let sync_s3_access_key_input = cx.new(|cx| {
|
||||
InputState::new(window, cx).placeholder(t!("sync_s3_access_key").to_string())
|
||||
});
|
||||
let sync_s3_secret_key_input = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.placeholder(t!("sync_s3_secret_key").to_string())
|
||||
.masked(true)
|
||||
});
|
||||
let sync_s3_session_token_input = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.placeholder(t!("sync_s3_session_token").to_string())
|
||||
.masked(true)
|
||||
});
|
||||
let sync_encryption_password_input = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.placeholder(t!("sync_encryption_password").to_string())
|
||||
.masked(true)
|
||||
});
|
||||
|
||||
let _subscriptions = vec![
|
||||
cx.subscribe_in(&host_input, window, Self::on_input_event),
|
||||
@@ -362,8 +510,28 @@ 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(&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),
|
||||
cx.subscribe_in(&sync_s3_endpoint_input, window, Self::on_input_event),
|
||||
cx.subscribe_in(&sync_s3_region_input, window, Self::on_input_event),
|
||||
cx.subscribe_in(&sync_s3_bucket_input, window, Self::on_input_event),
|
||||
cx.subscribe_in(&sync_s3_object_key_input, window, Self::on_input_event),
|
||||
cx.subscribe_in(&sync_s3_access_key_input, window, Self::on_input_event),
|
||||
cx.subscribe_in(&sync_s3_secret_key_input, window, Self::on_input_event),
|
||||
cx.subscribe_in(&sync_s3_session_token_input, window, Self::on_input_event),
|
||||
cx.subscribe_in(
|
||||
&sync_encryption_password_input,
|
||||
window,
|
||||
Self::on_input_event,
|
||||
),
|
||||
];
|
||||
|
||||
let (events_tx, events_rx) = mpsc::channel();
|
||||
@@ -373,10 +541,6 @@ impl Ashell {
|
||||
let system = system_sampler.sample();
|
||||
let default_light_theme_name = ThemeRegistry::global(cx).default_light_theme().name.clone();
|
||||
let default_dark_theme_name = ThemeRegistry::global(cx).default_dark_theme().name.clone();
|
||||
let config = ConfigStore::load().unwrap_or_else(|err| {
|
||||
tracing::warn!("failed to load config: {err:#}");
|
||||
ConfigStore::in_memory()
|
||||
});
|
||||
let follow_system_theme = config.follow_system_theme();
|
||||
|
||||
let theme_mode = match config.theme_mode() {
|
||||
@@ -409,6 +573,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(),
|
||||
@@ -420,8 +588,33 @@ impl Ashell {
|
||||
key_path_input,
|
||||
key_inline_input,
|
||||
passphrase_input,
|
||||
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,
|
||||
sync_s3_endpoint_input,
|
||||
sync_s3_region_input,
|
||||
sync_s3_bucket_input,
|
||||
sync_s3_object_key_input,
|
||||
sync_s3_access_key_input,
|
||||
sync_s3_secret_key_input,
|
||||
sync_s3_session_token_input,
|
||||
sync_encryption_password_input,
|
||||
sync_in_progress: false,
|
||||
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,
|
||||
@@ -429,6 +622,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(),
|
||||
@@ -477,11 +672,12 @@ impl Ashell {
|
||||
terminal_marked_text: None,
|
||||
dragging_splitter: None,
|
||||
drag_split_origin: None,
|
||||
sftp_panel_minimized: false,
|
||||
sftp_panel_minimized: config.sftp_panel_minimized(),
|
||||
sidebar_collapsed: config.sidebar_collapsed(),
|
||||
collapsed_saved_scroll_handle: gpui::ScrollHandle::new(),
|
||||
prev_monitoring_size: None,
|
||||
status: "ready".into(),
|
||||
active_title_bar_style: config.title_bar_style(),
|
||||
config,
|
||||
system_sampler,
|
||||
recording_action: None,
|
||||
@@ -495,6 +691,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(),
|
||||
|
||||
@@ -502,6 +706,11 @@ 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,
|
||||
};
|
||||
|
||||
@@ -553,6 +762,18 @@ 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();
|
||||
}
|
||||
@@ -560,6 +781,7 @@ impl Ashell {
|
||||
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))
|
||||
@@ -569,9 +791,21 @@ 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;
|
||||
if blink_due {
|
||||
last_blink_time = now;
|
||||
}
|
||||
} else {
|
||||
idle_frames += 1;
|
||||
if idle_frames >= 60 {
|
||||
@@ -597,11 +831,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() {
|
||||
@@ -609,14 +845,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();
|
||||
@@ -687,67 +925,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();
|
||||
}
|
||||
@@ -794,7 +1010,33 @@ 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) => {
|
||||
self.sync_in_progress = false;
|
||||
match result {
|
||||
crate::sync::SyncResult::Uploaded { etag } => {
|
||||
if etag.is_some() {
|
||||
self.config.set_sync_etag(etag);
|
||||
}
|
||||
self.sync_status = t!("sync_upload_complete").into();
|
||||
let _ = self.config.save();
|
||||
}
|
||||
crate::sync::SyncResult::Downloaded { payload, etag } => {
|
||||
self.config.replace_sessions(payload.sessions);
|
||||
self.config.set_sync_etag(etag);
|
||||
match self.config.save() {
|
||||
Ok(()) => self.sync_status = t!("sync_download_complete").into(),
|
||||
Err(err) => {
|
||||
self.sync_status =
|
||||
format!("{}: {err:#}", t!("sync_failed")).into()
|
||||
}
|
||||
}
|
||||
}
|
||||
crate::sync::SyncResult::Failed(error) => {
|
||||
self.sync_status = format!("{}: {error}", t!("sync_failed")).into();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -865,7 +1107,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(
|
||||
@@ -892,4 +1136,265 @@ 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()));
|
||||
}
|
||||
}
|
||||
|
||||
if retry_tabs.is_empty() {
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
|
||||
for (ix, tab_id, session) in retry_tabs {
|
||||
// Close old backend
|
||||
self.tabs[ix].send_backend(crate::terminal::BackendCommand::Close);
|
||||
|
||||
// Spawn new backend
|
||||
let backend = crate::backend::ssh::spawn_ssh_terminal(
|
||||
self.runtime.handle(),
|
||||
tab_id.clone(),
|
||||
session.clone(),
|
||||
self.tabs[ix].cols,
|
||||
self.tabs[ix].rows,
|
||||
self.events_tx.clone(),
|
||||
);
|
||||
|
||||
// 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 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 {
|
||||
tracing::info!("[ui] 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) => {
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+73
-71
@@ -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,18 +178,78 @@ 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 {
|
||||
window_options.titlebar = Some(gpui::TitlebarOptions {
|
||||
title: None,
|
||||
appears_transparent: true,
|
||||
traffic_light_position: Some(gpui::point(px(9.0), px(9.0))),
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
if let Ok(img) = image::load_from_memory(include_bytes!("../../assets/icons/ashell.png")) {
|
||||
window_options.icon = Some(std::sync::Arc::new(img.into_rgba8()));
|
||||
}
|
||||
|
||||
let config = ConfigStore::load().unwrap_or_else(|_| ConfigStore::in_memory());
|
||||
if let Some(bounds) = config.window_bounds() {
|
||||
window_options.window_bounds = Some(match bounds {
|
||||
crate::session::config::SavedWindowBounds::Fullscreen {
|
||||
@@ -242,80 +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 sftp_minimized = view.read(cx).config.sftp_panel_minimized();
|
||||
if sftp_minimized {
|
||||
window.defer(cx, {
|
||||
let view = view.clone();
|
||||
move |window, cx| {
|
||||
view.update(cx, |this, cx| {
|
||||
this.toggle_sftp_minimized(window, 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;
|
||||
}
|
||||
tracing::info!("[ui] main application window closed, saving layout state...");
|
||||
let mut config = ConfigStore::load().unwrap_or_else(|_| ConfigStore::in_memory());
|
||||
let current_bounds = window.window_bounds();
|
||||
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();
|
||||
view_clone.read(cx).save_layout_state(window, cx);
|
||||
true
|
||||
});
|
||||
|
||||
|
||||
+22
-9
@@ -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(())
|
||||
}
|
||||
|
||||
+792
-369
File diff suppressed because it is too large
Load Diff
+167
-81
@@ -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},
|
||||
};
|
||||
@@ -220,8 +226,8 @@ async fn connect_and_authenticate(
|
||||
) -> 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)),
|
||||
keepalive_max: 3,
|
||||
keepalive_interval: Some(std::time::Duration::from_secs(3)),
|
||||
keepalive_max: 2,
|
||||
..Default::default()
|
||||
});
|
||||
let addr = format!("{}:{}", session.host, session.port);
|
||||
@@ -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;
|
||||
|
||||
+5
-6
@@ -7,6 +7,7 @@ mod app;
|
||||
mod backend;
|
||||
mod session;
|
||||
mod sftp;
|
||||
mod sync;
|
||||
mod system;
|
||||
mod terminal;
|
||||
|
||||
@@ -15,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();
|
||||
|
||||
+725
-23
@@ -1,7 +1,14 @@
|
||||
use std::{fs, path::PathBuf};
|
||||
|
||||
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,7 @@ use uuid::Uuid;
|
||||
pub enum AuthMethod {
|
||||
Password,
|
||||
Key,
|
||||
Config,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -30,6 +38,16 @@ 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,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
@@ -47,6 +65,11 @@ 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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +94,11 @@ 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -98,7 +126,25 @@ pub enum SavedWindowBounds {
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum TitleBarStyle {
|
||||
Native,
|
||||
#[default]
|
||||
Integrated,
|
||||
}
|
||||
|
||||
#[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,
|
||||
@@ -116,11 +162,17 @@ 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")]
|
||||
pub terminal_font_family: String,
|
||||
#[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>,
|
||||
@@ -132,6 +184,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)]
|
||||
@@ -140,12 +194,62 @@ pub struct ConfigFile {
|
||||
pub sftp_panel_minimized: bool,
|
||||
#[serde(default)]
|
||||
pub key_bindings: std::collections::HashMap<String, String>,
|
||||
#[serde(default)]
|
||||
pub sync_endpoint: String,
|
||||
#[serde(default)]
|
||||
pub sync_username: String,
|
||||
#[serde(default)]
|
||||
pub sync_etag: Option<String>,
|
||||
#[serde(default)]
|
||||
pub sync_device_id: String,
|
||||
#[serde(default)]
|
||||
pub sync_backend: String,
|
||||
#[serde(default)]
|
||||
pub sync_etag_backend: String,
|
||||
#[serde(default)]
|
||||
pub sync_s3_endpoint: String,
|
||||
#[serde(default = "default_s3_region")]
|
||||
pub sync_s3_region: String,
|
||||
#[serde(default)]
|
||||
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 {
|
||||
"Sidebar".to_string()
|
||||
}
|
||||
|
||||
fn default_s3_region() -> String {
|
||||
"us-east-1".to_string()
|
||||
}
|
||||
|
||||
fn default_s3_object_key() -> String {
|
||||
"ashell-sync.json".to_string()
|
||||
}
|
||||
|
||||
fn default_follow_system_theme() -> bool {
|
||||
true
|
||||
}
|
||||
@@ -172,6 +276,54 @@ 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ConfigStore {
|
||||
path: PathBuf,
|
||||
cache: ConfigFile,
|
||||
@@ -198,40 +350,54 @@ impl ConfigStore {
|
||||
let _ = fs::create_dir_all(&tmp_dir);
|
||||
}
|
||||
|
||||
let 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 mut cache = if path.exists() {
|
||||
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 {
|
||||
ConfigFile::default()
|
||||
};
|
||||
|
||||
if cache.sync_device_id.is_empty() {
|
||||
cache.sync_device_id = Uuid::new_v4().to_string();
|
||||
}
|
||||
Ok(Self { path, cache })
|
||||
}
|
||||
|
||||
pub fn in_memory() -> Self {
|
||||
let cache = ConfigFile {
|
||||
sync_device_id: Uuid::new_v4().to_string(),
|
||||
..ConfigFile::default()
|
||||
};
|
||||
Self {
|
||||
path: PathBuf::new(),
|
||||
cache: ConfigFile::default(),
|
||||
cache,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,6 +414,87 @@ impl ConfigStore {
|
||||
&self.cache.sessions
|
||||
}
|
||||
|
||||
pub fn replace_sessions(&mut self, sessions: Vec<Session>) {
|
||||
self.cache.sessions = sessions;
|
||||
}
|
||||
|
||||
pub fn sync_endpoint(&self) -> &str {
|
||||
&self.cache.sync_endpoint
|
||||
}
|
||||
|
||||
pub fn sync_username(&self) -> &str {
|
||||
&self.cache.sync_username
|
||||
}
|
||||
|
||||
pub fn sync_etag(&self) -> Option<&str> {
|
||||
(self.cache.sync_etag_backend == self.sync_backend())
|
||||
.then_some(self.cache.sync_etag.as_deref())
|
||||
.flatten()
|
||||
}
|
||||
|
||||
pub fn sync_device_id(&self) -> &str {
|
||||
&self.cache.sync_device_id
|
||||
}
|
||||
|
||||
pub fn sync_backend(&self) -> &str {
|
||||
if self.cache.sync_backend == "s3" {
|
||||
"s3"
|
||||
} else {
|
||||
"webdav"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_sync_backend(&mut self, backend: &str) {
|
||||
self.cache.sync_backend = if backend == "s3" { "s3" } else { "webdav" }.to_string();
|
||||
}
|
||||
|
||||
pub fn sync_s3_endpoint(&self) -> &str {
|
||||
&self.cache.sync_s3_endpoint
|
||||
}
|
||||
|
||||
pub fn sync_s3_region(&self) -> &str {
|
||||
if self.cache.sync_s3_region.is_empty() {
|
||||
"us-east-1"
|
||||
} else {
|
||||
&self.cache.sync_s3_region
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sync_s3_bucket(&self) -> &str {
|
||||
&self.cache.sync_s3_bucket
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_sync_connection(&mut self, endpoint: String, username: String) {
|
||||
self.cache.sync_endpoint = endpoint;
|
||||
self.cache.sync_username = username;
|
||||
}
|
||||
|
||||
pub fn set_sync_s3_connection(
|
||||
&mut self,
|
||||
endpoint: String,
|
||||
region: String,
|
||||
bucket: String,
|
||||
object_key: String,
|
||||
) {
|
||||
self.cache.sync_s3_endpoint = endpoint;
|
||||
self.cache.sync_s3_region = region;
|
||||
self.cache.sync_s3_bucket = bucket;
|
||||
self.cache.sync_s3_object_key = object_key;
|
||||
}
|
||||
|
||||
pub fn set_sync_etag(&mut self, etag: Option<String>) {
|
||||
self.cache.sync_etag = etag;
|
||||
self.cache.sync_etag_backend = self.sync_backend().to_string();
|
||||
}
|
||||
|
||||
pub fn tmp_dir(&self) -> Option<PathBuf> {
|
||||
self.path.parent().map(|p| p.join("tmp"))
|
||||
}
|
||||
@@ -394,6 +641,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"
|
||||
@@ -406,6 +661,65 @@ impl ConfigStore {
|
||||
self.cache.terminal_font_family = family.to_string();
|
||||
}
|
||||
|
||||
pub fn title_bar_style(&self) -> TitleBarStyle {
|
||||
self.cache.title_bar_style
|
||||
}
|
||||
|
||||
pub fn set_title_bar_style(&mut self, style: TitleBarStyle) {
|
||||
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
|
||||
}
|
||||
@@ -414,6 +728,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
|
||||
}
|
||||
@@ -450,8 +772,9 @@ 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)]
|
||||
@@ -466,3 +789,382 @@ 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
|
||||
{
|
||||
}
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
#[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,
|
||||
}
|
||||
|
||||
fn get_hardware_uuid() -> String {
|
||||
#[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")]
|
||||
{
|
||||
if let Ok(output) = std::process::Command::new("reg")
|
||||
.args(&[
|
||||
"query",
|
||||
"HKLM\\SOFTWARE\\Microsoft\\Cryptography",
|
||||
"/v",
|
||||
"MachineGuid",
|
||||
])
|
||||
.output()
|
||||
{
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
for line in stdout.lines() {
|
||||
if line.contains("MachineGuid") {
|
||||
if let Some(guid) = line.split_whitespace().last() {
|
||||
let guid = guid.trim().to_string();
|
||||
if !guid.is_empty() {
|
||||
return guid;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Ok(output) = std::process::Command::new("wmic")
|
||||
.args(&["csproduct", "get", "uuid"])
|
||||
.output()
|
||||
{
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let lines: Vec<&str> = stdout.lines().collect();
|
||||
if lines.len() >= 2 {
|
||||
let uuid = lines[1].trim().to_string();
|
||||
if !uuid.is_empty() {
|
||||
return uuid;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
"ashell-default-hardware-uuid-fallback".to_string()
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
+290
-148
@@ -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},
|
||||
};
|
||||
@@ -79,6 +78,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 +102,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 +117,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:#}");
|
||||
@@ -113,7 +135,9 @@ impl Ashell {
|
||||
|
||||
self.open_ssh_session(session, cx);
|
||||
self.editing_session_id = None;
|
||||
self.active_dialog = None;
|
||||
window.close_dialog(cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn set_input_value(
|
||||
@@ -128,6 +152,7 @@ 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::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);
|
||||
@@ -136,6 +161,11 @@ 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.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(
|
||||
@@ -169,6 +199,38 @@ impl Ashell {
|
||||
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>) {
|
||||
@@ -294,19 +356,85 @@ impl Ashell {
|
||||
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);
|
||||
if let Err(err) = self.config.save() {
|
||||
tracing::warn!("failed to save cursor style: {err:#}");
|
||||
}
|
||||
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.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 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();
|
||||
}
|
||||
|
||||
@@ -368,6 +496,7 @@ impl Ashell {
|
||||
return;
|
||||
};
|
||||
|
||||
self.active_dialog = None;
|
||||
match entry {
|
||||
SelectorEntry::Local => {
|
||||
self.open_local(cx);
|
||||
@@ -382,6 +511,7 @@ impl Ashell {
|
||||
window.close_dialog(cx);
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn on_selector_key_down(
|
||||
@@ -434,6 +564,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();
|
||||
@@ -473,12 +609,6 @@ 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()],
|
||||
failed: false,
|
||||
});
|
||||
self.status = "ssh tab opened".into();
|
||||
cx.notify();
|
||||
}
|
||||
@@ -492,57 +622,57 @@ 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
|
||||
// Close old backend (sends Close through the shared Arc<Mutex>)
|
||||
self.tabs[ix].send_backend(BackendCommand::Close);
|
||||
|
||||
if let Some(session) = session {
|
||||
// SSH tab: spawn new SSH connection
|
||||
let backend = ssh::spawn_ssh_terminal(
|
||||
self.runtime.handle(),
|
||||
tab_id.clone(),
|
||||
tab_id.to_string(),
|
||||
session.clone(),
|
||||
DEFAULT_COLS,
|
||||
DEFAULT_ROWS,
|
||||
cols,
|
||||
rows,
|
||||
self.events_tx.clone(),
|
||||
);
|
||||
|
||||
// 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());
|
||||
// Swap the backend — the Term's internal listener shares the
|
||||
// same Arc<Mutex<BackendTx>>, so user input is automatically
|
||||
// routed to the new backend. Terminal history is preserved.
|
||||
self.tabs[ix].set_backend(backend);
|
||||
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()
|
||||
@@ -550,48 +680,57 @@ 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) {
|
||||
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(),
|
||||
group_id.clone(),
|
||||
session,
|
||||
self.events_tx.clone(),
|
||||
);
|
||||
self.sftp_handles.insert(group.id.clone(), sftp_handle);
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -644,6 +783,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()
|
||||
@@ -655,7 +801,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;
|
||||
@@ -714,7 +860,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);
|
||||
}
|
||||
}
|
||||
@@ -726,7 +872,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
|
||||
@@ -800,6 +946,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;
|
||||
@@ -818,6 +973,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();
|
||||
@@ -827,7 +1010,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> {
|
||||
@@ -866,65 +1049,6 @@ impl Ashell {
|
||||
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())
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn split_current_pane(&mut self, direction: &str, cx: &mut Context<Self>) {
|
||||
tracing::info!(
|
||||
"[split] direction={} pane_root={:?} focused_path={:?} active_tab={:?} tabs={}",
|
||||
@@ -1047,7 +1171,16 @@ 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1330,8 +1463,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use directories::BaseDirs;
|
||||
|
||||
/// A parsed entry from ~/.ssh/config
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SshConfigEntry {
|
||||
/// The Host pattern (alias) from the config, e.g. "myserver"
|
||||
pub host_alias: String,
|
||||
/// The actual hostname (HostName), defaults to the host alias if not specified
|
||||
pub hostname: String,
|
||||
/// The user, defaults to empty (will use current OS user)
|
||||
pub user: String,
|
||||
/// The port, defaults to 22
|
||||
pub port: u16,
|
||||
/// Identity files specified for this host
|
||||
pub identity_files: Vec<String>,
|
||||
/// Whether this is a wildcard/pattern host (Host * or Host *)
|
||||
pub is_wildcard: bool,
|
||||
}
|
||||
|
||||
/// Parse ~/.ssh/config and return a list of concrete host entries.
|
||||
/// Wildcard patterns (Host *) are excluded.
|
||||
pub fn parse_ssh_config() -> Result<Vec<SshConfigEntry>> {
|
||||
let config_path = ssh_config_path()?;
|
||||
if !config_path.is_file() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(&config_path)
|
||||
.with_context(|| format!("failed to read {}", config_path.display()))?;
|
||||
|
||||
parse_ssh_config_content(&content)
|
||||
}
|
||||
|
||||
fn ssh_config_path() -> Result<PathBuf> {
|
||||
BaseDirs::new()
|
||||
.map(|dirs| dirs.home_dir().join(".ssh/config"))
|
||||
.context("failed to determine home directory")
|
||||
}
|
||||
|
||||
/// Parse the content of an ssh config file into entries.
|
||||
pub fn parse_ssh_config_content(content: &str) -> Result<Vec<SshConfigEntry>> {
|
||||
let mut entries: Vec<SshConfigEntry> = Vec::new();
|
||||
let mut current_host: Option<SshConfigEntry> = None;
|
||||
|
||||
for line in content.lines() {
|
||||
let line = line.trim();
|
||||
|
||||
// Skip empty lines and comments
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Split into keyword and value
|
||||
// OpenSSH supports both "keyword value" and "keyword=value" formats
|
||||
let (keyword, value) = if let Some(pos) = line.find('=') {
|
||||
(&line[..pos], line[pos + 1..].trim())
|
||||
} else if let Some(pos) = line.find(' ') {
|
||||
(&line[..pos], line[pos..].trim())
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let keyword_lower = keyword.trim().to_lowercase();
|
||||
let value = value.trim();
|
||||
|
||||
match keyword_lower.as_str() {
|
||||
"host" => {
|
||||
// Save previous entry if it exists and is not a wildcard
|
||||
if let Some(entry) = current_host.take() {
|
||||
if !entry.is_wildcard {
|
||||
entries.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
// Host line may contain multiple patterns (Host a b c)
|
||||
// Take the first non-wildcard pattern as the display alias
|
||||
let patterns: Vec<&str> = value.split_whitespace().collect();
|
||||
if patterns.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let host_alias = patterns
|
||||
.iter()
|
||||
.find(|p| !p.contains('*') && !p.contains('?'))
|
||||
.map(|p| p.to_string())
|
||||
.unwrap_or_else(|| patterns[0].to_string());
|
||||
|
||||
let is_wildcard = value.contains('*') || value.contains('?');
|
||||
|
||||
current_host = Some(SshConfigEntry {
|
||||
host_alias: host_alias.clone(),
|
||||
hostname: host_alias,
|
||||
user: String::new(),
|
||||
port: 22,
|
||||
identity_files: Vec::new(),
|
||||
is_wildcard,
|
||||
});
|
||||
}
|
||||
"hostname" => {
|
||||
if let Some(entry) = current_host.as_mut() {
|
||||
entry.hostname = value.to_string();
|
||||
}
|
||||
}
|
||||
"user" => {
|
||||
if let Some(entry) = current_host.as_mut() {
|
||||
entry.user = value.to_string();
|
||||
}
|
||||
}
|
||||
"port" => {
|
||||
if let Some(entry) = current_host.as_mut() {
|
||||
entry.port = value.parse::<u16>().unwrap_or(22);
|
||||
}
|
||||
}
|
||||
"identityfile" => {
|
||||
if let Some(entry) = current_host.as_mut() {
|
||||
entry.identity_files.push(value.to_string());
|
||||
}
|
||||
}
|
||||
// Skip Match blocks and Include directives — not supported yet
|
||||
"match" | "include" => {
|
||||
// If we encounter a Match block, flush the current Host entry
|
||||
// since Match blocks apply to all subsequent hosts until the next Match/Host
|
||||
if let Some(entry) = current_host.take() {
|
||||
if !entry.is_wildcard {
|
||||
entries.push(entry);
|
||||
}
|
||||
}
|
||||
// Don't create a new entry for Match/Include
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Save the last entry if it's not a wildcard
|
||||
if let Some(entry) = current_host.take() {
|
||||
if !entry.is_wildcard {
|
||||
entries.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_ssh_config_content() {
|
||||
let content = r#"
|
||||
Host myhost
|
||||
HostName 1.2.3.4
|
||||
User git
|
||||
Port 2222
|
||||
IdentityFile ~/.ssh/id_rsa
|
||||
|
||||
Host
|
||||
Host =
|
||||
|
||||
Host anotherhost
|
||||
HostName 5.6.7.8
|
||||
"#;
|
||||
|
||||
let entries = parse_ssh_config_content(content).unwrap();
|
||||
assert_eq!(entries.len(), 2);
|
||||
|
||||
assert_eq!(entries[0].host_alias, "myhost");
|
||||
assert_eq!(entries[0].hostname, "1.2.3.4");
|
||||
assert_eq!(entries[0].user, "git");
|
||||
assert_eq!(entries[0].port, 2222);
|
||||
assert_eq!(entries[0].identity_files, vec!["~/.ssh/id_rsa".to_string()]);
|
||||
|
||||
assert_eq!(entries[1].host_alias, "anotherhost");
|
||||
assert_eq!(entries[1].hostname, "5.6.7.8");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
use directories::BaseDirs;
|
||||
use russh::{
|
||||
client::{self, Handler},
|
||||
keys::{HashAlg, PrivateKey, key::PrivateKeyWithHashAlg, load_secret_key},
|
||||
};
|
||||
|
||||
use crate::session::config::Session;
|
||||
|
||||
pub const DEFAULT_KEY_NAMES: &[&str] = &["id_ed25519", "id_rsa", "id_ecdsa", "id_dsa"];
|
||||
|
||||
pub fn session_has_explicit_key(session: &Session) -> bool {
|
||||
!session.private_key_path.trim().is_empty()
|
||||
|| !normalize_inline_private_key(&session.private_key_inline).is_empty()
|
||||
}
|
||||
|
||||
pub fn normalize_inline_private_key(value: &str) -> String {
|
||||
let mut normalized = value
|
||||
.trim()
|
||||
.replace("\\r\\n", "\n")
|
||||
.replace("\\n", "\n")
|
||||
.replace("\r\n", "\n");
|
||||
if normalized.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
if !normalized.ends_with('\n') {
|
||||
normalized.push('\n');
|
||||
}
|
||||
normalized
|
||||
}
|
||||
|
||||
pub fn private_keys_with_algs(keypair: PrivateKey) -> Result<Vec<PrivateKeyWithHashAlg>> {
|
||||
let mut algs = Vec::new();
|
||||
let key_arc = Arc::new(keypair);
|
||||
|
||||
if key_arc.algorithm().is_rsa() {
|
||||
if let Ok(k) = PrivateKeyWithHashAlg::new(key_arc.clone(), Some(HashAlg::Sha512)) {
|
||||
algs.push(k);
|
||||
}
|
||||
if let Ok(k) = PrivateKeyWithHashAlg::new(key_arc.clone(), Some(HashAlg::Sha256)) {
|
||||
algs.push(k);
|
||||
}
|
||||
if let Ok(k) = PrivateKeyWithHashAlg::new(key_arc.clone(), None) {
|
||||
algs.push(k);
|
||||
}
|
||||
} else if let Ok(k) = PrivateKeyWithHashAlg::new(key_arc.clone(), None) {
|
||||
algs.push(k);
|
||||
}
|
||||
|
||||
if algs.is_empty() {
|
||||
return Err(anyhow!(
|
||||
"Failed to construct PrivateKeyWithHashAlg for any supported hash algorithm"
|
||||
));
|
||||
}
|
||||
|
||||
Ok(algs)
|
||||
}
|
||||
|
||||
pub async fn authenticate_with_default_keys<H>(
|
||||
handle: &mut client::Handle<H>,
|
||||
user: &str,
|
||||
passphrase: Option<&str>,
|
||||
) -> Result<bool>
|
||||
where
|
||||
H: Handler + Send + Sync,
|
||||
H::Error: Into<anyhow::Error>,
|
||||
{
|
||||
let Some(ssh_dir) = BaseDirs::new().map(|d| d.home_dir().join(".ssh")) else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
for key_name in DEFAULT_KEY_NAMES {
|
||||
let key_path = ssh_dir.join(key_name);
|
||||
if !key_path.exists() {
|
||||
continue;
|
||||
}
|
||||
tracing::debug!("[ssh] trying default key {}", key_path.display());
|
||||
match load_secret_key(&key_path, passphrase) {
|
||||
Ok(keypair) => {
|
||||
if let Ok(keys) = private_keys_with_algs(keypair) {
|
||||
for key in keys {
|
||||
match handle.authenticate_publickey(user, key).await {
|
||||
Ok(true) => return Ok(true),
|
||||
Ok(false) | Err(_) => continue,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
+115
-72
@@ -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 {
|
||||
@@ -824,10 +834,13 @@ async fn connect_and_authenticate(
|
||||
) -> Result<Arc<russh::client::Handle<SftpClientHandler>>> {
|
||||
let config = Arc::new(client::Config {
|
||||
inactivity_timeout: Some(std::time::Duration::from_secs(600)),
|
||||
keepalive_interval: Some(std::time::Duration::from_secs(3)),
|
||||
keepalive_max: 2,
|
||||
..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;
|
||||
|
||||
+577
@@ -0,0 +1,577 @@
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use argon2::Argon2;
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||
use chacha20poly1305::{
|
||||
XChaCha20Poly1305, XNonce,
|
||||
aead::{Aead, KeyInit},
|
||||
};
|
||||
use hmac::{Hmac, Mac};
|
||||
use rand::{RngCore, rngs::OsRng};
|
||||
use reqwest::{Client, StatusCode, header};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::session::config::Session;
|
||||
|
||||
const SYNC_FILE_NAME: &str = "ashell-sync.json";
|
||||
const FORMAT_VERSION: u32 = 1;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SyncPayload {
|
||||
pub schema_version: u32,
|
||||
pub revision: String,
|
||||
pub updated_at: String,
|
||||
pub device_id: String,
|
||||
pub sessions: Vec<Session>,
|
||||
}
|
||||
|
||||
impl SyncPayload {
|
||||
pub fn new(device_id: String, sessions: Vec<Session>) -> Self {
|
||||
Self {
|
||||
schema_version: FORMAT_VERSION,
|
||||
revision: Uuid::new_v4().to_string(),
|
||||
updated_at: chrono::Utc::now().to_rfc3339(),
|
||||
device_id,
|
||||
sessions,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct EncryptedEnvelope {
|
||||
format_version: u32,
|
||||
kdf: String,
|
||||
cipher: String,
|
||||
salt: String,
|
||||
nonce: String,
|
||||
payload: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SyncCredentials {
|
||||
pub backend: SyncBackendCredentials,
|
||||
pub encryption_password: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum SyncBackendCredentials {
|
||||
WebDav {
|
||||
endpoint: String,
|
||||
username: String,
|
||||
password: String,
|
||||
},
|
||||
S3 {
|
||||
endpoint: String,
|
||||
region: String,
|
||||
bucket: String,
|
||||
object_key: String,
|
||||
access_key: String,
|
||||
secret_key: String,
|
||||
session_token: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SyncResult {
|
||||
Uploaded {
|
||||
etag: Option<String>,
|
||||
},
|
||||
Downloaded {
|
||||
payload: SyncPayload,
|
||||
etag: Option<String>,
|
||||
},
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
pub async fn upload(
|
||||
credentials: SyncCredentials,
|
||||
payload: SyncPayload,
|
||||
expected_etag: Option<String>,
|
||||
) -> Result<Option<String>> {
|
||||
validate_credentials(&credentials)?;
|
||||
let body = encrypt_payload(&payload, &credentials.encryption_password)?;
|
||||
match credentials.backend {
|
||||
SyncBackendCredentials::WebDav {
|
||||
endpoint,
|
||||
username,
|
||||
password,
|
||||
} => upload_webdav(&endpoint, &username, &password, body, expected_etag).await,
|
||||
SyncBackendCredentials::S3 {
|
||||
endpoint,
|
||||
region,
|
||||
bucket,
|
||||
object_key,
|
||||
access_key,
|
||||
secret_key,
|
||||
session_token,
|
||||
} => {
|
||||
let config = S3Config {
|
||||
endpoint,
|
||||
region,
|
||||
bucket,
|
||||
object_key,
|
||||
access_key,
|
||||
secret_key,
|
||||
session_token,
|
||||
};
|
||||
upload_s3(&config, body, expected_etag).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn upload_webdav(
|
||||
endpoint: &str,
|
||||
username: &str,
|
||||
password: &str,
|
||||
body: Vec<u8>,
|
||||
expected_etag: Option<String>,
|
||||
) -> Result<Option<String>> {
|
||||
let client = Client::new();
|
||||
let mut request = client
|
||||
.put(sync_url(endpoint))
|
||||
.basic_auth(username, Some(password))
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(body);
|
||||
request = if let Some(etag) = expected_etag {
|
||||
request.header(header::IF_MATCH, etag)
|
||||
} else {
|
||||
// An uninitialized client may only create a new remote file. This keeps
|
||||
// it from silently replacing configuration uploaded by another device.
|
||||
request.header(header::IF_NONE_MATCH, "*")
|
||||
};
|
||||
let response = request.send().await.context("send WebDAV upload")?;
|
||||
if response.status() == StatusCode::PRECONDITION_FAILED
|
||||
|| response.status() == StatusCode::CONFLICT
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"remote configuration changed; download it before uploading"
|
||||
));
|
||||
}
|
||||
if !response.status().is_success() {
|
||||
return Err(anyhow!("WebDAV upload failed: HTTP {}", response.status()));
|
||||
}
|
||||
Ok(response
|
||||
.headers()
|
||||
.get(header::ETAG)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::to_string))
|
||||
}
|
||||
|
||||
pub async fn download(credentials: SyncCredentials) -> Result<(SyncPayload, Option<String>)> {
|
||||
validate_credentials(&credentials)?;
|
||||
let encryption_password = credentials.encryption_password;
|
||||
let (body, etag) = match credentials.backend {
|
||||
SyncBackendCredentials::WebDav {
|
||||
endpoint,
|
||||
username,
|
||||
password,
|
||||
} => download_webdav(&endpoint, &username, &password).await?,
|
||||
SyncBackendCredentials::S3 {
|
||||
endpoint,
|
||||
region,
|
||||
bucket,
|
||||
object_key,
|
||||
access_key,
|
||||
secret_key,
|
||||
session_token,
|
||||
} => {
|
||||
let config = S3Config {
|
||||
endpoint,
|
||||
region,
|
||||
bucket,
|
||||
object_key,
|
||||
access_key,
|
||||
secret_key,
|
||||
session_token,
|
||||
};
|
||||
download_s3(&config).await?
|
||||
}
|
||||
};
|
||||
let payload = decrypt_payload(&body, &encryption_password)?;
|
||||
Ok((payload, etag))
|
||||
}
|
||||
|
||||
async fn download_webdav(
|
||||
endpoint: &str,
|
||||
username: &str,
|
||||
password: &str,
|
||||
) -> Result<(Vec<u8>, Option<String>)> {
|
||||
let response = Client::new()
|
||||
.get(sync_url(endpoint))
|
||||
.basic_auth(username, Some(password))
|
||||
.send()
|
||||
.await
|
||||
.context("send WebDAV download")?;
|
||||
if response.status() == StatusCode::NOT_FOUND {
|
||||
return Err(anyhow!("no remote configuration exists yet"));
|
||||
}
|
||||
if !response.status().is_success() {
|
||||
return Err(anyhow!(
|
||||
"WebDAV download failed: HTTP {}",
|
||||
response.status()
|
||||
));
|
||||
}
|
||||
let etag = response
|
||||
.headers()
|
||||
.get(header::ETAG)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::to_string);
|
||||
let body = response
|
||||
.bytes()
|
||||
.await
|
||||
.context("read WebDAV response")?
|
||||
.to_vec();
|
||||
Ok((body, etag))
|
||||
}
|
||||
|
||||
fn validate_credentials(credentials: &SyncCredentials) -> Result<()> {
|
||||
if credentials.encryption_password.len() < 8 {
|
||||
return Err(anyhow!(
|
||||
"encryption password must contain at least 8 characters"
|
||||
));
|
||||
}
|
||||
match &credentials.backend {
|
||||
SyncBackendCredentials::WebDav { endpoint, .. } if endpoint.trim().is_empty() => {
|
||||
Err(anyhow!("WebDAV endpoint is required"))
|
||||
}
|
||||
SyncBackendCredentials::S3 {
|
||||
region,
|
||||
bucket,
|
||||
access_key,
|
||||
secret_key,
|
||||
..
|
||||
} if region.trim().is_empty()
|
||||
|| bucket.trim().is_empty()
|
||||
|| access_key.trim().is_empty()
|
||||
|| secret_key.is_empty() =>
|
||||
{
|
||||
Err(anyhow!(
|
||||
"S3 region, bucket, access key and secret key are required"
|
||||
))
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
struct S3Config {
|
||||
endpoint: String,
|
||||
region: String,
|
||||
bucket: String,
|
||||
object_key: String,
|
||||
access_key: String,
|
||||
secret_key: String,
|
||||
session_token: String,
|
||||
}
|
||||
|
||||
async fn upload_s3(
|
||||
config: &S3Config,
|
||||
body: Vec<u8>,
|
||||
expected_etag: Option<String>,
|
||||
) -> Result<Option<String>> {
|
||||
let url = s3_url(config)?;
|
||||
let mut headers = signed_s3_headers("PUT", &url, &body, config)?;
|
||||
headers.insert(
|
||||
header::CONTENT_TYPE,
|
||||
header::HeaderValue::from_static("application/json"),
|
||||
);
|
||||
if let Some(etag) = expected_etag {
|
||||
headers.insert(header::IF_MATCH, header_value(&etag, "S3 ETag")?);
|
||||
} else {
|
||||
headers.insert(header::IF_NONE_MATCH, header::HeaderValue::from_static("*"));
|
||||
}
|
||||
let response = Client::new()
|
||||
.put(url)
|
||||
.headers(headers)
|
||||
.body(body)
|
||||
.send()
|
||||
.await
|
||||
.context("send S3 upload")?;
|
||||
if response.status() == StatusCode::PRECONDITION_FAILED
|
||||
|| response.status() == StatusCode::CONFLICT
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"remote configuration changed; download it before uploading"
|
||||
));
|
||||
}
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let detail = response.text().await.unwrap_or_default();
|
||||
return Err(anyhow!("S3 upload failed: HTTP {status}: {detail}"));
|
||||
}
|
||||
Ok(response
|
||||
.headers()
|
||||
.get(header::ETAG)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::to_string))
|
||||
}
|
||||
|
||||
async fn download_s3(config: &S3Config) -> Result<(Vec<u8>, Option<String>)> {
|
||||
let url = s3_url(config)?;
|
||||
let headers = signed_s3_headers("GET", &url, &[], config)?;
|
||||
let response = Client::new()
|
||||
.get(url)
|
||||
.headers(headers)
|
||||
.send()
|
||||
.await
|
||||
.context("send S3 download")?;
|
||||
if response.status() == StatusCode::NOT_FOUND {
|
||||
return Err(anyhow!("no remote configuration exists yet"));
|
||||
}
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let detail = response.text().await.unwrap_or_default();
|
||||
return Err(anyhow!("S3 download failed: HTTP {status}: {detail}"));
|
||||
}
|
||||
let etag = response
|
||||
.headers()
|
||||
.get(header::ETAG)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::to_string);
|
||||
let body = response.bytes().await.context("read S3 response")?.to_vec();
|
||||
Ok((body, etag))
|
||||
}
|
||||
|
||||
fn s3_url(config: &S3Config) -> Result<reqwest::Url> {
|
||||
let endpoint = if config.endpoint.trim().is_empty() {
|
||||
format!("https://s3.{}.amazonaws.com", config.region.trim())
|
||||
} else {
|
||||
config.endpoint.trim().trim_end_matches('/').to_string()
|
||||
};
|
||||
let key = if config.object_key.trim().is_empty() {
|
||||
SYNC_FILE_NAME
|
||||
} else {
|
||||
config.object_key.trim().trim_start_matches('/')
|
||||
};
|
||||
let url = format!(
|
||||
"{}/{}/{}",
|
||||
endpoint,
|
||||
aws_uri_encode(config.bucket.trim(), true),
|
||||
aws_uri_encode(key, false)
|
||||
);
|
||||
reqwest::Url::parse(&url).context("parse S3 object URL")
|
||||
}
|
||||
|
||||
fn signed_s3_headers(
|
||||
method: &str,
|
||||
url: &reqwest::Url,
|
||||
body: &[u8],
|
||||
config: &S3Config,
|
||||
) -> Result<header::HeaderMap> {
|
||||
let now = chrono::Utc::now();
|
||||
let amz_date = now.format("%Y%m%dT%H%M%SZ").to_string();
|
||||
let date = now.format("%Y%m%d").to_string();
|
||||
let host = url
|
||||
.host_str()
|
||||
.ok_or_else(|| anyhow!("S3 endpoint has no host"))?;
|
||||
let host = match url.port() {
|
||||
Some(port) => format!("{host}:{port}"),
|
||||
None => host.to_string(),
|
||||
};
|
||||
let payload_hash = hex_sha256(body);
|
||||
let mut canonical_headers =
|
||||
format!("host:{host}\nx-amz-content-sha256:{payload_hash}\nx-amz-date:{amz_date}\n");
|
||||
let mut signed_headers = "host;x-amz-content-sha256;x-amz-date".to_string();
|
||||
if !config.session_token.is_empty() {
|
||||
canonical_headers.push_str(&format!(
|
||||
"x-amz-security-token:{}\n",
|
||||
config.session_token.trim()
|
||||
));
|
||||
signed_headers.push_str(";x-amz-security-token");
|
||||
}
|
||||
let canonical_request = format!(
|
||||
"{method}\n{}\n\n{canonical_headers}\n{signed_headers}\n{payload_hash}",
|
||||
url.path()
|
||||
);
|
||||
let scope = format!("{date}/{}/s3/aws4_request", config.region.trim());
|
||||
let string_to_sign = format!(
|
||||
"AWS4-HMAC-SHA256\n{amz_date}\n{scope}\n{}",
|
||||
hex_sha256(canonical_request.as_bytes())
|
||||
);
|
||||
let date_key = hmac_sha256(
|
||||
format!("AWS4{}", config.secret_key).as_bytes(),
|
||||
date.as_bytes(),
|
||||
)?;
|
||||
let region_key = hmac_sha256(&date_key, config.region.trim().as_bytes())?;
|
||||
let service_key = hmac_sha256(®ion_key, b"s3")?;
|
||||
let signing_key = hmac_sha256(&service_key, b"aws4_request")?;
|
||||
let signature = hex::encode(hmac_sha256(&signing_key, string_to_sign.as_bytes())?);
|
||||
let authorization = format!(
|
||||
"AWS4-HMAC-SHA256 Credential={}/{scope}, SignedHeaders={signed_headers}, Signature={signature}",
|
||||
config.access_key.trim()
|
||||
);
|
||||
let mut headers = header::HeaderMap::new();
|
||||
headers.insert("x-amz-date", header_value(&amz_date, "S3 date")?);
|
||||
headers.insert(
|
||||
"x-amz-content-sha256",
|
||||
header_value(&payload_hash, "S3 payload hash")?,
|
||||
);
|
||||
headers.insert(
|
||||
header::AUTHORIZATION,
|
||||
header_value(&authorization, "S3 authorization")?,
|
||||
);
|
||||
if !config.session_token.is_empty() {
|
||||
headers.insert(
|
||||
"x-amz-security-token",
|
||||
header_value(config.session_token.trim(), "S3 session token")?,
|
||||
);
|
||||
}
|
||||
Ok(headers)
|
||||
}
|
||||
|
||||
fn header_value(value: &str, name: &str) -> Result<header::HeaderValue> {
|
||||
header::HeaderValue::from_str(value).with_context(|| format!("invalid {name}"))
|
||||
}
|
||||
|
||||
fn hmac_sha256(key: &[u8], value: &[u8]) -> Result<Vec<u8>> {
|
||||
let mut mac =
|
||||
<Hmac<Sha256> as Mac>::new_from_slice(key).map_err(|_| anyhow!("initialize S3 signer"))?;
|
||||
mac.update(value);
|
||||
Ok(mac.finalize().into_bytes().to_vec())
|
||||
}
|
||||
|
||||
fn hex_sha256(value: &[u8]) -> String {
|
||||
hex::encode(Sha256::digest(value))
|
||||
}
|
||||
|
||||
fn aws_uri_encode(value: &str, encode_slash: bool) -> String {
|
||||
let mut encoded = String::new();
|
||||
for byte in value.bytes() {
|
||||
if byte.is_ascii_alphanumeric()
|
||||
|| matches!(byte, b'-' | b'_' | b'.' | b'~')
|
||||
|| (!encode_slash && byte == b'/')
|
||||
{
|
||||
encoded.push(byte as char);
|
||||
} else {
|
||||
encoded.push_str(&format!("%{byte:02X}"));
|
||||
}
|
||||
}
|
||||
encoded
|
||||
}
|
||||
|
||||
fn sync_url(endpoint: &str) -> String {
|
||||
let endpoint = endpoint.trim();
|
||||
if endpoint.ends_with('/') {
|
||||
format!("{endpoint}{SYNC_FILE_NAME}")
|
||||
} else if endpoint.ends_with(".json") {
|
||||
endpoint.to_string()
|
||||
} else {
|
||||
format!("{endpoint}/{SYNC_FILE_NAME}")
|
||||
}
|
||||
}
|
||||
|
||||
fn encrypt_payload(payload: &SyncPayload, 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 key = derive_key(password, &salt)?;
|
||||
let plaintext = serde_json::to_vec(payload).context("serialize sync payload")?;
|
||||
let ciphertext = XChaCha20Poly1305::new((&key).into())
|
||||
.encrypt(XNonce::from_slice(&nonce), plaintext.as_ref())
|
||||
.map_err(|_| anyhow!("encrypt sync payload"))?;
|
||||
serde_json::to_vec_pretty(&EncryptedEnvelope {
|
||||
format_version: FORMAT_VERSION,
|
||||
kdf: "argon2id".to_string(),
|
||||
cipher: "xchacha20poly1305".to_string(),
|
||||
salt: STANDARD.encode(salt),
|
||||
nonce: STANDARD.encode(nonce),
|
||||
payload: STANDARD.encode(ciphertext),
|
||||
})
|
||||
.context("serialize encrypted sync envelope")
|
||||
}
|
||||
|
||||
fn decrypt_payload(raw: &[u8], password: &str) -> Result<SyncPayload> {
|
||||
let envelope: EncryptedEnvelope =
|
||||
serde_json::from_slice(raw).context("parse encrypted sync envelope")?;
|
||||
if envelope.format_version != FORMAT_VERSION
|
||||
|| envelope.kdf != "argon2id"
|
||||
|| envelope.cipher != "xchacha20poly1305"
|
||||
{
|
||||
return Err(anyhow!("unsupported remote sync format"));
|
||||
}
|
||||
let salt = STANDARD.decode(envelope.salt).context("decode sync salt")?;
|
||||
let nonce = STANDARD
|
||||
.decode(envelope.nonce)
|
||||
.context("decode sync nonce")?;
|
||||
if nonce.len() != 24 {
|
||||
return Err(anyhow!("invalid sync nonce"));
|
||||
}
|
||||
let ciphertext = STANDARD
|
||||
.decode(envelope.payload)
|
||||
.context("decode encrypted sync payload")?;
|
||||
let key = derive_key(password, &salt)?;
|
||||
let plaintext = XChaCha20Poly1305::new((&key).into())
|
||||
.decrypt(XNonce::from_slice(&nonce), ciphertext.as_ref())
|
||||
.map_err(|_| anyhow!("cannot decrypt remote configuration; check the password"))?;
|
||||
let payload: SyncPayload =
|
||||
serde_json::from_slice(&plaintext).context("parse decrypted sync payload")?;
|
||||
if payload.schema_version != FORMAT_VERSION {
|
||||
return Err(anyhow!("unsupported synchronized configuration version"));
|
||||
}
|
||||
Ok(payload)
|
||||
}
|
||||
|
||||
fn derive_key(password: &str, salt: &[u8]) -> Result<[u8; 32]> {
|
||||
let mut key = [0u8; 32];
|
||||
Argon2::default()
|
||||
.hash_password_into(password.as_bytes(), salt, &mut key)
|
||||
.map_err(|err| anyhow!("derive encryption key: {err}"))?;
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn encrypted_payload_round_trip() {
|
||||
let payload = SyncPayload::new("test-device".into(), Vec::new());
|
||||
let encrypted = encrypt_payload(&payload, "correct horse battery staple").unwrap();
|
||||
assert!(!String::from_utf8_lossy(&encrypted).contains("test-device"));
|
||||
let decrypted = decrypt_payload(&encrypted, "correct horse battery staple").unwrap();
|
||||
assert_eq!(decrypted.revision, payload.revision);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_password_is_rejected() {
|
||||
let payload = SyncPayload::new("test-device".into(), Vec::new());
|
||||
let encrypted = encrypt_payload(&payload, "correct horse battery staple").unwrap();
|
||||
assert!(decrypt_payload(&encrypted, "incorrect password").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_can_be_a_collection_or_file() {
|
||||
assert_eq!(
|
||||
sync_url("https://example.test/dav/"),
|
||||
"https://example.test/dav/ashell-sync.json"
|
||||
);
|
||||
assert_eq!(
|
||||
sync_url("https://example.test/config.json"),
|
||||
"https://example.test/config.json"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn s3_url_uses_path_style_and_encodes_object_key() {
|
||||
let config = S3Config {
|
||||
endpoint: "https://s3.example.test".into(),
|
||||
region: "us-east-1".into(),
|
||||
bucket: "my-bucket".into(),
|
||||
object_key: "configs/my file.json".into(),
|
||||
access_key: "access".into(),
|
||||
secret_key: "secret".into(),
|
||||
session_token: String::new(),
|
||||
};
|
||||
assert_eq!(
|
||||
s3_url(&config).unwrap().as_str(),
|
||||
"https://s3.example.test/my-bucket/configs/my%20file.json"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aws_uri_encoding_preserves_only_object_key_slashes() {
|
||||
assert_eq!(aws_uri_encode("a b/c", false), "a%20b/c");
|
||||
assert_eq!(aws_uri_encode("a/b", true), "a%2Fb");
|
||||
}
|
||||
}
|
||||
+178
-21
@@ -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
+161
-17
@@ -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
|
||||
@@ -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();
|
||||
|
||||
+159
-26
@@ -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},
|
||||
@@ -90,6 +91,7 @@ pub enum BackendEvent {
|
||||
tab_id: String,
|
||||
title: String,
|
||||
},
|
||||
SyncFinished(crate::sync::SyncResult),
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -114,16 +116,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)]
|
||||
@@ -133,7 +150,7 @@ pub struct CursorState {
|
||||
pub shape: CursorShape,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct RenderCell {
|
||||
pub row: i32,
|
||||
pub col: i32,
|
||||
@@ -149,6 +166,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)]
|
||||
@@ -218,19 +236,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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,6 +262,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);
|
||||
@@ -250,7 +290,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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,7 +319,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();
|
||||
@@ -308,17 +348,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));
|
||||
@@ -383,18 +476,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 {
|
||||
@@ -402,14 +506,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,
|
||||
})
|
||||
}
|
||||
@@ -417,16 +547,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,
|
||||
@@ -434,8 +566,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 {
|
||||
@@ -451,7 +584,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> {
|
||||
|
||||
Reference in New Issue
Block a user