mirror of
https://github.com/rust-kotlin/ashell.git
synced 2026-10-03 16:00:18 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68bf152792 | ||
|
|
13b9433c90 | ||
|
|
445e474fe8 | ||
|
|
4c7c06f391 | ||
|
|
e4fbd65a31 | ||
|
|
bd376935f4 | ||
|
|
16761c1bf5 | ||
|
|
c3201e7c30 | ||
|
|
2d5062ed75 | ||
|
|
d7d6faeeb1 | ||
|
|
19a4274473 | ||
|
|
4bb3bee6c8 | ||
|
|
8ab4b43825 | ||
|
|
bbf843dde6 | ||
|
|
bc6af2e213 | ||
|
|
d224506fd5 | ||
|
|
d175135529 | ||
|
|
c769b982b4 | ||
|
|
ee68835972 | ||
|
|
8a535fad5a | ||
|
|
90c07e16e1 | ||
|
|
33a3d624b7 | ||
|
|
78f8626217 | ||
|
|
743a5f9fe7 | ||
|
|
99a954b0b5 | ||
|
|
0bd04a98f0 | ||
|
|
df44d17ade | ||
|
|
0e4c4cdc69 | ||
|
|
227966319c | ||
|
|
205ceba20c | ||
|
|
0ccbf9573b | ||
|
|
8193223dc2 | ||
|
|
805cec5000 | ||
|
|
3151757c4f | ||
|
|
57c55e358c | ||
|
|
81b160285d | ||
|
|
77206bd126 | ||
|
|
bb22718d3d | ||
|
|
4ba3000538 | ||
|
|
e7e2c67393 | ||
|
|
47b03510b2 | ||
|
|
54c50c871a | ||
|
|
6f887712e6 | ||
|
|
99071997d5 | ||
|
|
e35a2458bb | ||
|
|
690d7418f4 | ||
|
|
aca03c924f | ||
|
|
1847736d66 | ||
|
|
e163d05683 | ||
|
|
c3b1707014 | ||
|
|
8a82288c5e |
@@ -0,0 +1,58 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
pull_request:
|
||||
branches: ["main"]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: ${{ matrix.name }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
name: windows-x86_64
|
||||
- os: ubuntu-22.04 # build on the oldest supported runner for broader glibc compatibility
|
||||
target: x86_64-unknown-linux-gnu
|
||||
name: linux-x86_64
|
||||
- os: macos-14 # Apple Silicon
|
||||
target: aarch64-apple-darwin
|
||||
name: macos-aarch64
|
||||
- os: macos-14 # Intel (cross-compiled from Apple Silicon to avoid queue delays)
|
||||
target: x86_64-apple-darwin
|
||||
name: macos-x86_64
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# GPUI needs these system libs on Linux to compile.
|
||||
- name: Install Linux GUI dependencies
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
build-essential pkg-config cmake \
|
||||
libfontconfig1-dev libfreetype6-dev \
|
||||
libxcb1-dev libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev \
|
||||
libxkbcommon-dev libxkbcommon-x11-dev libwayland-dev \
|
||||
libgl1-mesa-dev libegl1-mesa-dev libgtk-3-dev \
|
||||
libudev-dev
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Cache cargo build
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: ${{ matrix.target }}
|
||||
|
||||
- name: Build (release)
|
||||
# We build in release mode so that the release workflow can reuse the exact artifacts
|
||||
run: cargo build --release --target ${{ matrix.target }}
|
||||
Generated
+300
-1
@@ -142,6 +142,19 @@ dependencies = [
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"const-random",
|
||||
"once_cell",
|
||||
"version_check",
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.4"
|
||||
@@ -264,6 +277,12 @@ version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
||||
|
||||
[[package]]
|
||||
name = "as-raw-xcb-connection"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b"
|
||||
|
||||
[[package]]
|
||||
name = "as-slice"
|
||||
version = "0.2.1"
|
||||
@@ -284,7 +303,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ashell"
|
||||
version = "0.1.0"
|
||||
version = "0.4.1"
|
||||
dependencies = [
|
||||
"alacritty_terminal",
|
||||
"anyhow",
|
||||
@@ -314,8 +333,10 @@ dependencies = [
|
||||
"sysinfo 0.33.1",
|
||||
"tar",
|
||||
"thiserror 1.0.69",
|
||||
"time",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-appender",
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
"walkdir",
|
||||
@@ -352,9 +373,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "340e0f6bf7f9ee78549c61454f1460a3ed97c011902ee76b58301bbc6d502a32"
|
||||
dependencies = [
|
||||
"enumflags2",
|
||||
"futures-channel",
|
||||
"futures-util",
|
||||
"getrandom 0.4.2",
|
||||
"serde",
|
||||
"serde_repr",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
@@ -973,6 +999,18 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "calloop-wayland-source"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa"
|
||||
dependencies = [
|
||||
"calloop",
|
||||
"rustix 1.1.4",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cbc"
|
||||
version = "0.1.2"
|
||||
@@ -1230,6 +1268,26 @@ version = "0.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||
|
||||
[[package]]
|
||||
name = "const-random"
|
||||
version = "0.1.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359"
|
||||
dependencies = [
|
||||
"const-random-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-random-macro"
|
||||
version = "0.1.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
"once_cell",
|
||||
"tiny-keccak",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.10.0"
|
||||
@@ -1594,6 +1652,15 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||
dependencies = [
|
||||
"powerfmt",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_arbitrary"
|
||||
version = "1.4.2"
|
||||
@@ -2406,6 +2473,16 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gethostname"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
|
||||
dependencies = [
|
||||
"rustix 1.1.4",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
@@ -2760,17 +2837,24 @@ dependencies = [
|
||||
"accesskit",
|
||||
"accesskit_unix",
|
||||
"anyhow",
|
||||
"as-raw-xcb-connection",
|
||||
"ashpd 0.13.11",
|
||||
"bitflags 2.12.1",
|
||||
"bytemuck",
|
||||
"calloop",
|
||||
"calloop-wayland-source",
|
||||
"collections",
|
||||
"filedescriptor",
|
||||
"futures",
|
||||
"gpui",
|
||||
"gpui_wgpu",
|
||||
"http_client",
|
||||
"image",
|
||||
"itertools 0.14.0",
|
||||
"libc",
|
||||
"log",
|
||||
"oo7",
|
||||
"open",
|
||||
"parking_lot",
|
||||
"pathfinder_geometry",
|
||||
"pollster 0.4.0",
|
||||
@@ -2783,6 +2867,17 @@ dependencies = [
|
||||
"url",
|
||||
"util",
|
||||
"uuid",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-cursor",
|
||||
"wayland-protocols",
|
||||
"wayland-protocols-plasma",
|
||||
"wayland-protocols-wlr",
|
||||
"x11-clipboard",
|
||||
"x11rb",
|
||||
"xkbcommon",
|
||||
"zed-scap",
|
||||
"zed-xim",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2923,6 +3018,7 @@ dependencies = [
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"wgpu",
|
||||
"zed-font-kit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4455,6 +4551,12 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
|
||||
|
||||
[[package]]
|
||||
name = "num-derive"
|
||||
version = "0.4.2"
|
||||
@@ -4517,6 +4619,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num_threads"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc"
|
||||
version = "0.2.7"
|
||||
@@ -5286,6 +5397,12 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "powerfmt"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
@@ -7213,6 +7330,12 @@ dependencies = [
|
||||
"zeno",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symlink"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.117"
|
||||
@@ -7448,6 +7571,48 @@ dependencies = [
|
||||
"zune-jpeg 0.5.15",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9e442fc33d7fdb45aa9bfeb312c095964abdf596f7567261062b2a7107aaabd"
|
||||
dependencies = [
|
||||
"deranged",
|
||||
"itoa",
|
||||
"libc",
|
||||
"num-conv",
|
||||
"num_threads",
|
||||
"powerfmt",
|
||||
"serde_core",
|
||||
"time-core",
|
||||
"time-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time-core"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b36ee98fd31ec7426d599183e8fe26932a8dc1fb76ddb6214d05493377d34ca"
|
||||
|
||||
[[package]]
|
||||
name = "time-macros"
|
||||
version = "0.2.25"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "71e552d1249bf61ac2a52db88179fd0673def1e1ad8243a00d9ec9ed71fee3dd"
|
||||
dependencies = [
|
||||
"num-conv",
|
||||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-keccak"
|
||||
version = "2.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237"
|
||||
dependencies = [
|
||||
"crunchy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-skia"
|
||||
version = "0.11.4"
|
||||
@@ -7713,6 +7878,19 @@ dependencies = [
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-appender"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c"
|
||||
dependencies = [
|
||||
"crossbeam-channel",
|
||||
"symlink",
|
||||
"thiserror 2.0.18",
|
||||
"time",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-attributes"
|
||||
version = "0.1.31"
|
||||
@@ -7758,6 +7936,7 @@ dependencies = [
|
||||
"sharded-slab",
|
||||
"smallvec",
|
||||
"thread_local",
|
||||
"time",
|
||||
"tracing",
|
||||
"tracing-core",
|
||||
"tracing-log",
|
||||
@@ -8344,6 +8523,17 @@ dependencies = [
|
||||
"wayland-scanner",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-cursor"
|
||||
version = "0.31.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d"
|
||||
dependencies = [
|
||||
"rustix 1.1.4",
|
||||
"wayland-client",
|
||||
"xcursor",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-protocols"
|
||||
version = "0.32.12"
|
||||
@@ -8356,6 +8546,32 @@ dependencies = [
|
||||
"wayland-scanner",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-protocols-plasma"
|
||||
version = "0.3.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91"
|
||||
dependencies = [
|
||||
"bitflags 2.12.1",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
"wayland-scanner",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-protocols-wlr"
|
||||
version = "0.3.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234"
|
||||
dependencies = [
|
||||
"bitflags 2.12.1",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
"wayland-scanner",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-scanner"
|
||||
version = "0.31.10"
|
||||
@@ -9376,6 +9592,36 @@ dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "x11-clipboard"
|
||||
version = "0.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "662d74b3d77e396b8e5beb00b9cad6a9eccf40b2ef68cc858784b14c41d535a3"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"x11rb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "x11rb"
|
||||
version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
|
||||
dependencies = [
|
||||
"as-raw-xcb-connection",
|
||||
"gethostname",
|
||||
"libc",
|
||||
"rustix 1.1.4",
|
||||
"x11rb-protocol",
|
||||
"xcursor",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "x11rb-protocol"
|
||||
version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
|
||||
|
||||
[[package]]
|
||||
name = "xattr"
|
||||
version = "0.2.3"
|
||||
@@ -9407,6 +9653,46 @@ dependencies = [
|
||||
"x11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xcursor"
|
||||
version = "0.3.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b"
|
||||
|
||||
[[package]]
|
||||
name = "xim-ctext"
|
||||
version = "0.3.0"
|
||||
source = "git+https://github.com/zed-industries/xim-rs.git?rev=16f35a2c881b815a2b6cdfd6687988e84f8447d8#16f35a2c881b815a2b6cdfd6687988e84f8447d8"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xim-parser"
|
||||
version = "0.2.1"
|
||||
source = "git+https://github.com/zed-industries/xim-rs.git?rev=16f35a2c881b815a2b6cdfd6687988e84f8447d8#16f35a2c881b815a2b6cdfd6687988e84f8447d8"
|
||||
dependencies = [
|
||||
"bitflags 2.12.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xkbcommon"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8d66ca9352cbd4eecbbc40871d8a11b4ac8107cfc528a6e14d7c19c69d0e1ac9"
|
||||
dependencies = [
|
||||
"as-raw-xcb-connection",
|
||||
"libc",
|
||||
"memmap2",
|
||||
"xkeysym",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xkeysym"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
|
||||
|
||||
[[package]]
|
||||
name = "xml-rs"
|
||||
version = "0.8.28"
|
||||
@@ -9679,6 +9965,19 @@ dependencies = [
|
||||
"workspace-hack",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zed-xim"
|
||||
version = "0.4.0-zed"
|
||||
source = "git+https://github.com/zed-industries/xim-rs.git?rev=16f35a2c881b815a2b6cdfd6687988e84f8447d8#16f35a2c881b815a2b6cdfd6687988e84f8447d8"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"hashbrown 0.14.5",
|
||||
"log",
|
||||
"x11rb",
|
||||
"xim-ctext",
|
||||
"xim-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeno"
|
||||
version = "0.3.3"
|
||||
|
||||
+5
-3
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ashell"
|
||||
version = "0.1.0"
|
||||
version = "0.4.1"
|
||||
edition = "2024"
|
||||
authors = ["ashell contributors"]
|
||||
description = "A GPUI Component based SSH and local terminal client"
|
||||
@@ -15,7 +15,7 @@ chrono = { version = "0.4", features = ["serde"] }
|
||||
directories = "5"
|
||||
flate2 = "1"
|
||||
gpui = { git = "https://github.com/zed-industries/zed" }
|
||||
gpui_platform = { git = "https://github.com/zed-industries/zed", features = ["font-kit", "runtime_shaders"] }
|
||||
gpui_platform = { git = "https://github.com/zed-industries/zed", features = ["font-kit", "runtime_shaders", "wayland", "x11"] }
|
||||
gpui-component = { git = "https://github.com/longbridge/gpui-component", rev = "ca85b5588f5feea021f1ee86cb13703dc6f30ec4" }
|
||||
gpui-component-assets = { git = "https://github.com/longbridge/gpui-component", rev = "ca85b5588f5feea021f1ee86cb13703dc6f30ec4" }
|
||||
menu = { git = "https://github.com/zed-industries/zed" }
|
||||
@@ -34,7 +34,7 @@ tar = "0.4"
|
||||
thiserror = "1"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "io-util", "net", "time", "fs"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "local-time", "time"] }
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
walkdir = "2"
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
@@ -42,6 +42,8 @@ image = { version = "0.25.9", default-features = false, features = ["png"] }
|
||||
futures = "0.3.32"
|
||||
notify = "6.1.1"
|
||||
open = "5.1"
|
||||
time = { version = "0.3.45", features = ["formatting", "local-offset"] }
|
||||
tracing-appender = "0.2.5"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
|
||||
+38
-3
@@ -67,7 +67,10 @@ terminal_font_size: "Terminal Font Size"
|
||||
system_default: "System Default"
|
||||
ui_font_family: "UI Font"
|
||||
terminal_font_family: "Terminal Font"
|
||||
theme_management_hint: "Theme mode and light/dark theme families are managed from the top-bar theme button."
|
||||
monitoring_position: "Monitoring Position"
|
||||
position_bottom: "Bottom"
|
||||
position_sidebar: "Sidebar"
|
||||
position_hidden: "Hidden"
|
||||
local_host: "local host"
|
||||
local_terminal_opened: "local terminal opened"
|
||||
connecting: "Connecting"
|
||||
@@ -91,7 +94,7 @@ n_folders: "%{folders} folders"
|
||||
n_files_and_folders: "%{files} files and %{folders} folders"
|
||||
reset: "Reset"
|
||||
reset_layout: "Reset Default Layout"
|
||||
reset_layout_success: "Layout reset. Window size takes effect on restart."
|
||||
reset_layout_hint: "Reset panel layout proportion. Window size will take effect on next startup."
|
||||
|
||||
transfers: "Transfers"
|
||||
no_transfers_yet: "No transfers yet"
|
||||
@@ -100,12 +103,44 @@ paused: "Paused"
|
||||
completed: "Completed"
|
||||
failed: "Failed"
|
||||
cancelled: "Cancelled"
|
||||
interrupted: "Interrupted"
|
||||
session: "Session"
|
||||
zombie: "Zombie"
|
||||
zombie_reason: "This transfer was interrupted because the application exited. The transfer task no longer exists."
|
||||
|
||||
software_builtin: "Built-in"
|
||||
retry: "Retry"
|
||||
right_click_copy_paste: "Right-click Copy/Paste"
|
||||
copy_paste_hint: "Tip: Regardless of this switch, you can always use %{key} + C/V to copy/paste."
|
||||
copy_paste_hint: "Regardless of this switch, you can always use %{key} + C/V to copy/paste."
|
||||
edit_file: "Edit File"
|
||||
edit_file_tooltip: "Download to temporary directory, open in default editor, and auto upload on save."
|
||||
auto_saved_and_uploaded: "Auto-saved & uploaded: %{base}"
|
||||
scroll: "Scroll ↓"
|
||||
clone: "Clone"
|
||||
settings_general: "General"
|
||||
settings_key_bindings: "Key Bindings"
|
||||
settings_help: "Help"
|
||||
settings_about: "About"
|
||||
settings_group_appearance: "Appearance"
|
||||
settings_group_font: "Font"
|
||||
settings_group_other: "Other"
|
||||
theme: "Theme"
|
||||
theme_mode: "Theme Mode"
|
||||
clear_transfers: "Clear History"
|
||||
transfers_limit: " (Max 100 records)"
|
||||
|
||||
press_new_key: "Press new key..."
|
||||
settings_open_settings: "Open Settings"
|
||||
settings_open_session: "Open Session"
|
||||
settings_new_ssh: "New SSH"
|
||||
settings_toggle_sftp_zoom: "Toggle SFTP Zoom"
|
||||
settings_focus_pane_left: "Focus Pane Left"
|
||||
settings_focus_pane_right: "Focus Pane Right"
|
||||
settings_focus_pane_up: "Focus Pane Up"
|
||||
settings_focus_pane_down: "Focus Pane Down"
|
||||
settings_split_pane_left: "Split Pane Left"
|
||||
settings_split_pane_right: "Split Pane Right"
|
||||
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'
|
||||
|
||||
+39
-3
@@ -69,7 +69,10 @@ terminal_font_size: "终端字体大小"
|
||||
system_default: "系统默认"
|
||||
ui_font_family: "界面字体"
|
||||
terminal_font_family: "终端字体"
|
||||
theme_management_hint: "主题模式和亮/暗主题系列可通过顶栏的主题按钮管理。"
|
||||
monitoring_position: "系统监控显示位置"
|
||||
position_bottom: "底部"
|
||||
position_sidebar: "侧边栏"
|
||||
position_hidden: "不显示"
|
||||
local_host: "本地主机"
|
||||
local_terminal_opened: "已打开本地终端"
|
||||
connecting: "正在连接"
|
||||
@@ -93,7 +96,7 @@ n_folders: "%{folders} 个文件夹"
|
||||
n_files_and_folders: "%{files} 个文件及 %{folders} 个文件夹"
|
||||
reset: "重置"
|
||||
reset_layout: "恢复默认布局"
|
||||
reset_layout_success: "布局占比已恢复默认。窗口大小将在下次启动时生效。"
|
||||
reset_layout_hint: "重置面板布局占比,窗口大小将在下次启动时生效"
|
||||
|
||||
transfers: "传输记录"
|
||||
no_transfers_yet: "暂无传输记录"
|
||||
@@ -102,12 +105,45 @@ paused: "已暂停"
|
||||
completed: "已完成"
|
||||
failed: "失败"
|
||||
cancelled: "已取消"
|
||||
interrupted: "已中断"
|
||||
session: "会话"
|
||||
zombie: "僵尸任务"
|
||||
zombie_reason: "此传输任务因程序退出而中断,传输进程已不存在。"
|
||||
|
||||
software_builtin: "软件内置"
|
||||
retry: "重试"
|
||||
right_click_copy_paste: "右键复制/粘贴"
|
||||
copy_paste_hint: "提示:不管是否开启此开关,都可以使用 %{key} + C/V 快捷键进行复制和粘贴。"
|
||||
copy_paste_hint: "不管是否开启此开关,都可以使用 %{key} + C/V 快捷键进行复制和粘贴。"
|
||||
edit_file: "编辑文件"
|
||||
edit_file_tooltip: "下载到临时目录,使用默认编辑器打开,并在保存后自动上传。"
|
||||
auto_saved_and_uploaded: "已自动保存并上传: %{base}"
|
||||
scroll: "滑动查看 ↓"
|
||||
clone: "克隆"
|
||||
about_feedback_hint: "如果遇到问题或者有什么建议,请前往我们的 GitHub 项目中反馈。"
|
||||
settings_general: "常规"
|
||||
settings_key_bindings: "快捷键"
|
||||
settings_help: "帮助"
|
||||
settings_about: "关于"
|
||||
settings_group_appearance: "外观"
|
||||
settings_group_font: "字体"
|
||||
settings_group_other: "其它"
|
||||
theme: "主题"
|
||||
theme_mode: "主题模式"
|
||||
clear_transfers: "清空记录"
|
||||
transfers_limit: " (最多保留 100 条)"
|
||||
|
||||
press_new_key: "请按下新的快捷键..."
|
||||
settings_open_settings: "打开设置"
|
||||
settings_open_session: "打开会话"
|
||||
settings_new_ssh: "新建 SSH"
|
||||
settings_toggle_sftp_zoom: "缩放 SFTP 面板"
|
||||
settings_focus_pane_left: "向左切换焦点"
|
||||
settings_focus_pane_right: "向右切换焦点"
|
||||
settings_focus_pane_up: "向上切换焦点"
|
||||
settings_focus_pane_down: "向下切换焦点"
|
||||
settings_split_pane_left: "向左拆分面板"
|
||||
settings_split_pane_right: "向右拆分面板"
|
||||
settings_split_pane_up: "向上拆分面板"
|
||||
settings_split_pane_down: "向下拆分面板"
|
||||
settings_close_pane: "关闭当前面板"
|
||||
keybind_conflict: "\"%{key}\" 已被「%{action}」使用,请选择其他快捷键"
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
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 TAB_BAR_HEIGHT: f32 = 52.0;
|
||||
pub(crate) const TERMINAL_PADDING_X: f32 = 32.0;
|
||||
pub(crate) const TERMINAL_PADDING_Y: f32 = 32.0;
|
||||
pub(crate) const TERMINAL_KEY_CONTEXT: &str = "AshellTerminal";
|
||||
+1590
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,334 @@
|
||||
use gpui::{
|
||||
Action as _, App, Entity, IntoElement, KeyBinding, KeyDownEvent, Keystroke, Unbind, prelude::*,
|
||||
};
|
||||
use gpui_component::{
|
||||
Sizable,
|
||||
button::{Button, ButtonVariants},
|
||||
kbd::Kbd,
|
||||
setting::{SettingField, SettingGroup, SettingItem},
|
||||
};
|
||||
use rust_i18n::t;
|
||||
|
||||
use crate::{Ashell, session::config::ConfigStore};
|
||||
|
||||
gpui::actions!(
|
||||
ashell_workspace,
|
||||
[
|
||||
OpenSettings,
|
||||
OpenSession,
|
||||
NewSsh,
|
||||
ToggleSftpZoom,
|
||||
FocusPaneLeft,
|
||||
FocusPaneRight,
|
||||
FocusPaneUp,
|
||||
FocusPaneDown,
|
||||
SplitPaneLeft,
|
||||
SplitPaneRight,
|
||||
SplitPaneUp,
|
||||
SplitPaneDown,
|
||||
ClosePane
|
||||
]
|
||||
);
|
||||
|
||||
pub struct KeybindingsPage;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct WorkspaceAction {
|
||||
id: &'static str,
|
||||
label_key: &'static str,
|
||||
default_suffix: &'static str,
|
||||
}
|
||||
|
||||
pub(crate) const WORKSPACE_ACTIONS: &[WorkspaceAction] = &[
|
||||
WorkspaceAction {
|
||||
id: "OpenSettings",
|
||||
label_key: "settings_open_settings",
|
||||
default_suffix: ",",
|
||||
},
|
||||
WorkspaceAction {
|
||||
id: "OpenSession",
|
||||
label_key: "settings_open_session",
|
||||
default_suffix: "o",
|
||||
},
|
||||
WorkspaceAction {
|
||||
id: "NewSsh",
|
||||
label_key: "settings_new_ssh",
|
||||
default_suffix: "n",
|
||||
},
|
||||
WorkspaceAction {
|
||||
id: "ToggleSftpZoom",
|
||||
label_key: "settings_toggle_sftp_zoom",
|
||||
default_suffix: "m",
|
||||
},
|
||||
WorkspaceAction {
|
||||
id: "FocusPaneLeft",
|
||||
label_key: "settings_focus_pane_left",
|
||||
default_suffix: "h",
|
||||
},
|
||||
WorkspaceAction {
|
||||
id: "FocusPaneRight",
|
||||
label_key: "settings_focus_pane_right",
|
||||
default_suffix: "l",
|
||||
},
|
||||
WorkspaceAction {
|
||||
id: "FocusPaneUp",
|
||||
label_key: "settings_focus_pane_up",
|
||||
default_suffix: "k",
|
||||
},
|
||||
WorkspaceAction {
|
||||
id: "FocusPaneDown",
|
||||
label_key: "settings_focus_pane_down",
|
||||
default_suffix: "j",
|
||||
},
|
||||
WorkspaceAction {
|
||||
id: "SplitPaneLeft",
|
||||
label_key: "settings_split_pane_left",
|
||||
default_suffix: "shift-h",
|
||||
},
|
||||
WorkspaceAction {
|
||||
id: "SplitPaneRight",
|
||||
label_key: "settings_split_pane_right",
|
||||
default_suffix: "shift-l",
|
||||
},
|
||||
WorkspaceAction {
|
||||
id: "SplitPaneUp",
|
||||
label_key: "settings_split_pane_up",
|
||||
default_suffix: "shift-k",
|
||||
},
|
||||
WorkspaceAction {
|
||||
id: "SplitPaneDown",
|
||||
label_key: "settings_split_pane_down",
|
||||
default_suffix: "shift-j",
|
||||
},
|
||||
WorkspaceAction {
|
||||
id: "ClosePane",
|
||||
label_key: "settings_close_pane",
|
||||
default_suffix: "w",
|
||||
},
|
||||
];
|
||||
|
||||
pub(crate) fn default_modifier() -> &'static str {
|
||||
if cfg!(target_os = "macos") {
|
||||
"cmd"
|
||||
} else {
|
||||
"ctrl"
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn default_keystroke(action_id: &str) -> Option<String> {
|
||||
WORKSPACE_ACTIONS
|
||||
.iter()
|
||||
.find(|action| action.id == action_id)
|
||||
.map(|action| format!("{}-{}", default_modifier(), action.default_suffix))
|
||||
}
|
||||
|
||||
pub(crate) fn configured_keystroke(config: &ConfigStore, action_id: &str) -> Option<String> {
|
||||
config
|
||||
.key_bindings()
|
||||
.get(action_id)
|
||||
.cloned()
|
||||
.or_else(|| default_keystroke(action_id))
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_recorded_keystroke(event: &KeyDownEvent) -> Option<String> {
|
||||
let key = event.keystroke.key.trim();
|
||||
if key.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut parts = Vec::new();
|
||||
if event.keystroke.modifiers.control {
|
||||
parts.push("ctrl".to_string());
|
||||
}
|
||||
if event.keystroke.modifiers.alt {
|
||||
parts.push("alt".to_string());
|
||||
}
|
||||
if event.keystroke.modifiers.shift {
|
||||
parts.push("shift".to_string());
|
||||
}
|
||||
if event.keystroke.modifiers.platform {
|
||||
parts.push("cmd".to_string());
|
||||
}
|
||||
if event.keystroke.modifiers.function {
|
||||
parts.push("fn".to_string());
|
||||
}
|
||||
|
||||
parts.push(key.to_ascii_lowercase());
|
||||
let keystroke = parts.join("-");
|
||||
Keystroke::parse(&keystroke).ok().map(|_| keystroke)
|
||||
}
|
||||
|
||||
pub(crate) fn format_keystroke(keystroke: &str) -> String {
|
||||
Keystroke::parse(keystroke)
|
||||
.map(|stroke| Kbd::format(&stroke))
|
||||
.unwrap_or_else(|_| keystroke.to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn bind_workspace_keys_from_config(cx: &mut App, config: &ConfigStore) {
|
||||
bind_workspace_actions(cx, config);
|
||||
}
|
||||
|
||||
/// Unbind all workspace keybindings (used when entering keybinding settings to prevent interference).
|
||||
pub(crate) fn unbind_all_workspace_keys(cx: &mut App, config: &ConfigStore) {
|
||||
let mut bindings = Vec::new();
|
||||
|
||||
macro_rules! unbind_action {
|
||||
($id:literal, $action:expr) => {
|
||||
let default = default_keystroke($id).expect("workspace action has default key");
|
||||
let configured = configured_keystroke(config, $id).unwrap_or_else(|| default.clone());
|
||||
let action_name = $action.name();
|
||||
|
||||
// Unbind both the default and configured keystroke
|
||||
bindings.push(KeyBinding::new(&default, Unbind(action_name.into()), None));
|
||||
if configured != default {
|
||||
bindings.push(KeyBinding::new(
|
||||
&configured,
|
||||
Unbind(action_name.into()),
|
||||
None,
|
||||
));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
unbind_action!("OpenSettings", crate::OpenSettings);
|
||||
unbind_action!("OpenSession", crate::OpenSession);
|
||||
unbind_action!("NewSsh", crate::NewSsh);
|
||||
unbind_action!("ToggleSftpZoom", crate::ToggleSftpZoom);
|
||||
unbind_action!("FocusPaneLeft", crate::FocusPaneLeft);
|
||||
unbind_action!("FocusPaneRight", crate::FocusPaneRight);
|
||||
unbind_action!("FocusPaneUp", crate::FocusPaneUp);
|
||||
unbind_action!("FocusPaneDown", crate::FocusPaneDown);
|
||||
unbind_action!("SplitPaneLeft", crate::SplitPaneLeft);
|
||||
unbind_action!("SplitPaneRight", crate::SplitPaneRight);
|
||||
unbind_action!("SplitPaneUp", crate::SplitPaneUp);
|
||||
unbind_action!("SplitPaneDown", crate::SplitPaneDown);
|
||||
unbind_action!("ClosePane", crate::ClosePane);
|
||||
|
||||
cx.bind_keys(bindings);
|
||||
}
|
||||
|
||||
/// Check if a keystroke conflicts with any other action's binding.
|
||||
/// Returns Some((conflicting_action_id, label)) if there is a conflict.
|
||||
pub(crate) fn find_conflict(
|
||||
config: &ConfigStore,
|
||||
current_action_id: &str,
|
||||
new_keystroke: &str,
|
||||
) -> Option<(String, String)> {
|
||||
for action in WORKSPACE_ACTIONS {
|
||||
if action.id == current_action_id {
|
||||
continue;
|
||||
}
|
||||
let existing = configured_keystroke(config, action.id).unwrap_or_default();
|
||||
if !existing.is_empty() && existing == new_keystroke {
|
||||
return Some((action.id.to_string(), t!(action.label_key).to_string()));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn bind_workspace_actions(cx: &mut App, config: &ConfigStore) {
|
||||
let mut bindings = Vec::new();
|
||||
|
||||
macro_rules! bind_action {
|
||||
($id:literal, $action:expr) => {
|
||||
let default = default_keystroke($id).expect("workspace action has default key");
|
||||
let configured = configured_keystroke(config, $id).unwrap_or_else(|| default.clone());
|
||||
let action_name = $action.name();
|
||||
|
||||
if configured != default {
|
||||
bindings.push(KeyBinding::new(&default, Unbind(action_name.into()), None));
|
||||
}
|
||||
|
||||
bindings.push(KeyBinding::new(&configured, $action, None));
|
||||
};
|
||||
}
|
||||
|
||||
bind_action!("OpenSettings", crate::OpenSettings);
|
||||
bind_action!("OpenSession", crate::OpenSession);
|
||||
bind_action!("NewSsh", crate::NewSsh);
|
||||
bind_action!("ToggleSftpZoom", crate::ToggleSftpZoom);
|
||||
bind_action!("FocusPaneLeft", crate::FocusPaneLeft);
|
||||
bind_action!("FocusPaneRight", crate::FocusPaneRight);
|
||||
bind_action!("FocusPaneUp", crate::FocusPaneUp);
|
||||
bind_action!("FocusPaneDown", crate::FocusPaneDown);
|
||||
bind_action!("SplitPaneLeft", crate::SplitPaneLeft);
|
||||
bind_action!("SplitPaneRight", crate::SplitPaneRight);
|
||||
bind_action!("SplitPaneUp", crate::SplitPaneUp);
|
||||
bind_action!("SplitPaneDown", crate::SplitPaneDown);
|
||||
bind_action!("ClosePane", crate::ClosePane);
|
||||
|
||||
cx.bind_keys(bindings);
|
||||
}
|
||||
|
||||
impl KeybindingsPage {
|
||||
pub fn render(view: &Entity<Ashell>, cx: &mut App) -> SettingGroup {
|
||||
let mut group = SettingGroup::new();
|
||||
|
||||
for action in WORKSPACE_ACTIONS {
|
||||
let recording = view.read(cx).recording_action.as_deref() == Some(action.id);
|
||||
let has_error = view
|
||||
.read(cx)
|
||||
.keybind_error
|
||||
.as_ref()
|
||||
.is_some_and(|(id, _)| id == action.id);
|
||||
let error_msg = if has_error {
|
||||
view.read(cx)
|
||||
.keybind_error
|
||||
.as_ref()
|
||||
.map(|(_, msg)| msg.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let keystroke = {
|
||||
let config = &view.read(cx).config;
|
||||
configured_keystroke(config, action.id).unwrap_or_default()
|
||||
};
|
||||
|
||||
let btn_label = if recording {
|
||||
t!("press_new_key").to_string()
|
||||
} else if keystroke.is_empty() {
|
||||
t!("none").to_string()
|
||||
} else {
|
||||
format_keystroke(&keystroke)
|
||||
};
|
||||
|
||||
let mut item = SettingItem::new(
|
||||
t!(action.label_key).to_string(),
|
||||
SettingField::render({
|
||||
let view = view.clone();
|
||||
let action_id = action.id.to_string();
|
||||
move |_, _window, _cx| {
|
||||
Button::new(gpui::SharedString::from(format!("keybind-{action_id}")))
|
||||
.label(btn_label.clone())
|
||||
.small()
|
||||
.when(recording, |this| this.primary())
|
||||
.when(has_error, |this| this.danger())
|
||||
.on_click({
|
||||
let view = view.clone();
|
||||
let action_id = action_id.clone();
|
||||
move |_event, window, cx| {
|
||||
view.update(cx, |this, cx| {
|
||||
// Clear any previous error when starting new recording
|
||||
this.keybind_error = None;
|
||||
this.recording_action = Some(action_id.clone());
|
||||
this.focus_handle.focus(window, cx);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
if let Some(msg) = error_msg {
|
||||
item = item.description(msg);
|
||||
}
|
||||
|
||||
group = group.item(item);
|
||||
}
|
||||
|
||||
group
|
||||
}
|
||||
}
|
||||
+873
@@ -0,0 +1,873 @@
|
||||
pub mod constants;
|
||||
pub mod dialogs;
|
||||
pub mod keybinding_recorder;
|
||||
pub mod startup;
|
||||
pub mod theme;
|
||||
pub mod ui;
|
||||
|
||||
use std::{
|
||||
cell::{Cell, RefCell},
|
||||
collections::HashMap,
|
||||
ops::Range,
|
||||
rc::Rc,
|
||||
sync::mpsc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use gpui::{
|
||||
AppContext as _, Bounds, Context, Entity, FocusHandle, Pixels, Point, SharedString, Size,
|
||||
UniformListScrollHandle, Window, point, px, size,
|
||||
};
|
||||
use gpui_component::{
|
||||
Theme, ThemeMode, ThemeRegistry,
|
||||
input::{InputEvent, InputState},
|
||||
resizable::ResizableState,
|
||||
scroll::ScrollbarHandle,
|
||||
};
|
||||
use rust_i18n::t;
|
||||
use tokio::runtime::Runtime;
|
||||
|
||||
use crate::{
|
||||
session::config::{AuthMethod, ConfigStore},
|
||||
system::{SystemSampler, SystemSnapshot},
|
||||
terminal::{self, BackendEvent, TabKind, TerminalTab},
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) enum PaneLayout {
|
||||
Single(String),
|
||||
Horizontal(Vec<PaneLayout>, f32), // children, split_ratio (0.0-1.0)
|
||||
Vertical(Vec<PaneLayout>, f32), // children, split_ratio (0.0-1.0)
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct TabGroup {
|
||||
pub(crate) id: String,
|
||||
pub(crate) title: String,
|
||||
pub(crate) pane_root: PaneLayout,
|
||||
pub(crate) sftp: Option<crate::terminal::SftpUiState>,
|
||||
}
|
||||
|
||||
impl PaneLayout {
|
||||
pub fn tab_ids(&self) -> Vec<&str> {
|
||||
match self {
|
||||
PaneLayout::Single(id) => vec![id.as_str()],
|
||||
PaneLayout::Horizontal(children, _) | PaneLayout::Vertical(children, _) => {
|
||||
children.iter().flat_map(|c| c.tab_ids()).collect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn contains(&self, tab_id: &str) -> bool {
|
||||
match self {
|
||||
PaneLayout::Single(id) => id == tab_id,
|
||||
PaneLayout::Horizontal(children, _) | PaneLayout::Vertical(children, _) => {
|
||||
children.iter().any(|c| c.contains(tab_id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn focused_tab_id(&self, path: &[usize]) -> Option<&str> {
|
||||
match self {
|
||||
PaneLayout::Single(id) if path.is_empty() => Some(id.as_str()),
|
||||
PaneLayout::Horizontal(children, _) | PaneLayout::Vertical(children, _) => {
|
||||
let (&first, rest) = path.split_first()?;
|
||||
children.get(first).and_then(|c| c.focused_tab_id(rest))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn replace_at(&mut self, path: &[usize], replacement: PaneLayout) {
|
||||
match (self, path) {
|
||||
(this @ PaneLayout::Single(_), []) => *this = replacement,
|
||||
(
|
||||
PaneLayout::Horizontal(children, _) | PaneLayout::Vertical(children, _),
|
||||
[first, rest @ ..],
|
||||
) => {
|
||||
if let Some(child) = children.get_mut(*first) {
|
||||
child.replace_at(rest, replacement);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove_tab(&mut self, tab_id: &str) -> bool {
|
||||
match self {
|
||||
PaneLayout::Single(id) if id == tab_id => {
|
||||
*self = PaneLayout::Single(String::new());
|
||||
true
|
||||
}
|
||||
PaneLayout::Single(_) => false,
|
||||
PaneLayout::Horizontal(children, _) | PaneLayout::Vertical(children, _) => {
|
||||
for child in children.iter_mut() {
|
||||
child.remove_tab(tab_id);
|
||||
}
|
||||
children.retain(|c| !matches!(c, PaneLayout::Single(id) if id.is_empty()));
|
||||
if children.is_empty() {
|
||||
*self = PaneLayout::Single(String::new());
|
||||
} else if children.len() == 1 {
|
||||
if let Some(replacement) = children.pop() {
|
||||
*self = replacement;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn total_panes(&self) -> usize {
|
||||
match self {
|
||||
PaneLayout::Single(_) => 1,
|
||||
PaneLayout::Horizontal(children, _) | PaneLayout::Vertical(children, _) => {
|
||||
children.iter().map(|c| c.total_panes()).sum()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct TerminalScrollbarState {
|
||||
line_height: Pixels,
|
||||
total_lines: usize,
|
||||
viewport_lines: usize,
|
||||
display_offset: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub(crate) struct TerminalScrollbarHandle {
|
||||
state: Rc<RefCell<Option<TerminalScrollbarState>>>,
|
||||
pub(crate) future_display_offset: Rc<Cell<Option<usize>>>,
|
||||
}
|
||||
|
||||
impl TerminalScrollbarHandle {
|
||||
pub(crate) fn update(&self, snapshot: &terminal::RenderSnapshot, line_height: Pixels) {
|
||||
self.state.replace(Some(TerminalScrollbarState {
|
||||
line_height,
|
||||
total_lines: snapshot.history_size + snapshot.rows,
|
||||
viewport_lines: snapshot.rows,
|
||||
display_offset: snapshot.display_offset,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
impl ScrollbarHandle for TerminalScrollbarHandle {
|
||||
fn offset(&self) -> Point<Pixels> {
|
||||
let state_ref = self.state.borrow();
|
||||
let Some(state) = state_ref.as_ref() else {
|
||||
return point(px(0.), px(0.));
|
||||
};
|
||||
let scroll_offset = state
|
||||
.total_lines
|
||||
.saturating_sub(state.viewport_lines)
|
||||
.saturating_sub(state.display_offset);
|
||||
point(px(0.), -(scroll_offset as f32 * state.line_height))
|
||||
}
|
||||
|
||||
fn set_offset(&self, offset: Point<Pixels>) {
|
||||
let state_ref = self.state.borrow();
|
||||
let Some(state) = state_ref.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let offset_delta = (offset.y / state.line_height).round() as i32;
|
||||
let max_offset = state.total_lines.saturating_sub(state.viewport_lines);
|
||||
let display_offset = (max_offset as i32 + offset_delta).clamp(0, max_offset as i32);
|
||||
self.future_display_offset
|
||||
.set(Some(display_offset as usize));
|
||||
}
|
||||
|
||||
fn content_size(&self) -> Size<Pixels> {
|
||||
let state_ref = self.state.borrow();
|
||||
let Some(state) = state_ref.as_ref() else {
|
||||
return size(px(0.), px(0.));
|
||||
};
|
||||
size(
|
||||
px(0.),
|
||||
state.total_lines.max(state.viewport_lines) as f32 * state.line_height,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct Ashell {
|
||||
pub(crate) focus_handle: FocusHandle,
|
||||
pub(crate) selector_focus_handle: FocusHandle,
|
||||
pub(crate) host_input: Entity<InputState>,
|
||||
pub(crate) session_name_input: Entity<InputState>,
|
||||
pub(crate) port_input: Entity<InputState>,
|
||||
pub(crate) user_input: Entity<InputState>,
|
||||
pub(crate) password_input: Entity<InputState>,
|
||||
pub(crate) key_path_input: Entity<InputState>,
|
||||
pub(crate) key_inline_input: Entity<InputState>,
|
||||
pub(crate) sftp_path_input: Entity<InputState>,
|
||||
pub(crate) ssh_auth_method: AuthMethod,
|
||||
pub(crate) editing_session_id: Option<String>,
|
||||
pub(crate) follow_system_theme: bool,
|
||||
pub(crate) theme_mode: ThemeMode,
|
||||
pub(crate) light_theme_name: SharedString,
|
||||
pub(crate) dark_theme_name: SharedString,
|
||||
pub(crate) ui_font_size: f32,
|
||||
pub(crate) terminal_font_size: f32,
|
||||
pub(crate) ui_font_family: SharedString,
|
||||
pub(crate) terminal_font_family: SharedString,
|
||||
pub(crate) tabs: Vec<TerminalTab>,
|
||||
pub(crate) active_tab: Option<String>,
|
||||
pub(crate) tab_groups: Vec<TabGroup>,
|
||||
pub(crate) active_group: Option<String>,
|
||||
pub(crate) selector_selection: usize,
|
||||
pub(crate) workspace_panels: Entity<ResizableState>,
|
||||
pub(crate) body_panels: Entity<ResizableState>,
|
||||
pub(crate) is_layout_reset: bool,
|
||||
pub(crate) terminal_scrollbars: HashMap<String, TerminalScrollbarHandle>,
|
||||
pub(crate) remote_files_scroll_handle: UniformListScrollHandle,
|
||||
pub(crate) disk_scroll_handle: gpui::ScrollHandle,
|
||||
pub(crate) tabs_scroll_handle: gpui::ScrollHandle,
|
||||
pub(crate) selector_scroll_handle: gpui::ScrollHandle,
|
||||
pub(crate) saved_scroll_handle: gpui::ScrollHandle,
|
||||
pub(crate) connection_scroll_handle: gpui::ScrollHandle,
|
||||
pub(crate) connection_progress: Option<ConnectionProgress>,
|
||||
pub(crate) pending_sftp_path_sync: Option<String>,
|
||||
pub(crate) sftp_context_menu: Option<SftpContextMenuState>,
|
||||
pub(crate) sftp_creating_folder: bool,
|
||||
pub(crate) sftp_new_folder_input: Entity<InputState>,
|
||||
pub(crate) sftp_delete_scroll_handle: gpui::ScrollHandle,
|
||||
pub(crate) show_hidden_files: bool,
|
||||
pub(crate) transfers: Vec<crate::terminal::Transfer>,
|
||||
pub(crate) show_transfers_dialog: bool,
|
||||
pub(crate) system_status: Option<SharedString>,
|
||||
pub(crate) pane_root: PaneLayout,
|
||||
pub(crate) focused_pane_path: Vec<usize>,
|
||||
pub(crate) terminal_bounds: HashMap<String, Bounds<Pixels>>,
|
||||
pub(crate) terminal_selecting: bool,
|
||||
pub(crate) dragging_splitter: Option<(Vec<usize>, usize)>, // (parent_path, child_index)
|
||||
pub(crate) drag_split_origin: Option<gpui::Point<Pixels>>,
|
||||
pub(crate) terminal_marked_text: Option<String>,
|
||||
pub(crate) sftp_panel_minimized: bool,
|
||||
pub(crate) prev_monitoring_size: Option<Pixels>,
|
||||
pub(crate) status: SharedString,
|
||||
pub(crate) config: ConfigStore,
|
||||
pub(crate) system_sampler: SystemSampler,
|
||||
pub(crate) recording_action: Option<String>,
|
||||
/// Error message when a recorded keybinding conflicts with another
|
||||
pub(crate) keybind_error: Option<(String, String)>, // (action_id, error_message)
|
||||
/// Whether workspace keybindings are currently suspended (during settings)
|
||||
pub(crate) keybinds_suspended: bool,
|
||||
pub(crate) system: SystemSnapshot,
|
||||
pub(crate) cpu_history: Vec<f32>,
|
||||
pub(crate) net_rx_history: Vec<f32>,
|
||||
pub(crate) net_tx_history: Vec<f32>,
|
||||
pub(crate) last_system_sample: Instant,
|
||||
pub(crate) last_theme_sync: Instant,
|
||||
|
||||
pub(crate) system_tab_id: Option<String>,
|
||||
pub(crate) sftp_handles: std::collections::HashMap<String, crate::sftp::SftpHandle>,
|
||||
|
||||
pub(crate) remote_sample_in_flight: bool,
|
||||
pub(crate) runtime: Runtime,
|
||||
pub(crate) events_rx: mpsc::Receiver<BackendEvent>,
|
||||
pub(crate) events_tx: mpsc::Sender<BackendEvent>,
|
||||
pub(crate) _subscriptions: Vec<gpui::Subscription>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) enum SelectorEntry {
|
||||
Local,
|
||||
NewSsh,
|
||||
Saved(String),
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct ConnectionProgress {
|
||||
pub(crate) tab_id: String,
|
||||
pub(crate) title: SharedString,
|
||||
pub(crate) lines: Vec<SharedString>,
|
||||
pub(crate) failed: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct SftpContextMenuState {
|
||||
pub(crate) remote_path: String,
|
||||
pub(crate) is_dir: bool,
|
||||
pub(crate) position: Point<Pixels>,
|
||||
}
|
||||
|
||||
impl Ashell {
|
||||
fn transfer_source_title(&self, tab_id: &str) -> String {
|
||||
self.tabs
|
||||
.iter()
|
||||
.find(|tab| tab.id == tab_id)
|
||||
.map(|tab| tab.title.clone())
|
||||
.or_else(|| {
|
||||
self.tab_groups
|
||||
.iter()
|
||||
.find(|group| group.id == tab_id)
|
||||
.map(|group| group.title.clone())
|
||||
})
|
||||
.or_else(|| {
|
||||
self.tab_groups
|
||||
.iter()
|
||||
.find(|group| group.pane_root.contains(tab_id))
|
||||
.map(|group| group.title.clone())
|
||||
})
|
||||
.unwrap_or_else(|| "Unknown".to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let host_input = cx.new(|cx| InputState::new(window, cx).placeholder(t!("host")));
|
||||
let session_name_input =
|
||||
cx.new(|cx| InputState::new(window, cx).placeholder("name (optional)"));
|
||||
let port_input = cx.new(|cx| InputState::new(window, cx).default_value("22"));
|
||||
let user_input = cx.new(|cx| InputState::new(window, cx).default_value("root"));
|
||||
let password_input = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.placeholder(t!("password"))
|
||||
.masked(true)
|
||||
});
|
||||
let key_path_input =
|
||||
cx.new(|cx| InputState::new(window, cx).placeholder("~/.ssh/id_ed25519"));
|
||||
let key_inline_input = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.multi_line(true)
|
||||
.rows(5)
|
||||
.placeholder("-----BEGIN OPENSSH PRIVATE KEY-----")
|
||||
});
|
||||
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 _subscriptions = vec![
|
||||
cx.subscribe_in(&host_input, window, Self::on_input_event),
|
||||
cx.subscribe_in(&session_name_input, window, Self::on_input_event),
|
||||
cx.subscribe_in(&port_input, window, Self::on_input_event),
|
||||
cx.subscribe_in(&user_input, window, Self::on_input_event),
|
||||
cx.subscribe_in(&password_input, window, Self::on_input_event),
|
||||
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(&sftp_path_input, window, Self::on_input_event),
|
||||
cx.subscribe_in(&sftp_new_folder_input, window, Self::on_input_event),
|
||||
];
|
||||
|
||||
let (events_tx, events_rx) = mpsc::channel();
|
||||
let workspace_panels = cx.new(|_| ResizableState::default());
|
||||
let body_panels = cx.new(|_| ResizableState::default());
|
||||
let mut system_sampler = SystemSampler::new();
|
||||
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 =
|
||||
if config.light_theme_name().is_empty() && config.dark_theme_name().is_empty() {
|
||||
true
|
||||
} else {
|
||||
config.follow_system_theme()
|
||||
};
|
||||
|
||||
let theme_mode = match config.theme_mode() {
|
||||
"light" => ThemeMode::Light,
|
||||
"dark" => ThemeMode::Dark,
|
||||
_ => ThemeMode::Light,
|
||||
};
|
||||
let light_theme_name = if config.light_theme_name().is_empty() {
|
||||
default_light_theme_name
|
||||
} else {
|
||||
config.light_theme_name().into()
|
||||
};
|
||||
let dark_theme_name = if config.dark_theme_name().is_empty() {
|
||||
default_dark_theme_name
|
||||
} else {
|
||||
config.dark_theme_name().into()
|
||||
};
|
||||
|
||||
let configured_locale = config.locale();
|
||||
let mut active_locale = configured_locale.to_string();
|
||||
if active_locale == "system" {
|
||||
active_locale = sys_locale::get_locale().unwrap_or_else(|| "en".to_string());
|
||||
if active_locale.starts_with("zh") {
|
||||
active_locale = "zh-CN".to_string();
|
||||
} else {
|
||||
active_locale = "en".to_string();
|
||||
}
|
||||
}
|
||||
rust_i18n::set_locale(&active_locale);
|
||||
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 mut this = Self {
|
||||
focus_handle: cx.focus_handle(),
|
||||
selector_focus_handle: cx.focus_handle(),
|
||||
host_input,
|
||||
session_name_input,
|
||||
port_input,
|
||||
user_input,
|
||||
password_input,
|
||||
key_path_input,
|
||||
key_inline_input,
|
||||
sftp_path_input,
|
||||
ssh_auth_method: AuthMethod::Password,
|
||||
editing_session_id: None,
|
||||
follow_system_theme,
|
||||
theme_mode,
|
||||
light_theme_name,
|
||||
dark_theme_name,
|
||||
ui_font_size: config.ui_font_size(),
|
||||
terminal_font_size: config.terminal_font_size(),
|
||||
ui_font_family,
|
||||
terminal_font_family,
|
||||
tabs: Vec::new(),
|
||||
active_tab: None,
|
||||
tab_groups: Vec::new(),
|
||||
active_group: None,
|
||||
pane_root: PaneLayout::Single(String::new()),
|
||||
focused_pane_path: Vec::new(),
|
||||
selector_selection: 0,
|
||||
workspace_panels,
|
||||
body_panels,
|
||||
is_layout_reset: false,
|
||||
terminal_scrollbars: HashMap::new(),
|
||||
remote_files_scroll_handle: UniformListScrollHandle::new(),
|
||||
disk_scroll_handle: gpui::ScrollHandle::new(),
|
||||
tabs_scroll_handle: gpui::ScrollHandle::new(),
|
||||
selector_scroll_handle: gpui::ScrollHandle::new(),
|
||||
saved_scroll_handle: gpui::ScrollHandle::new(),
|
||||
connection_scroll_handle: gpui::ScrollHandle::new(),
|
||||
connection_progress: None,
|
||||
pending_sftp_path_sync: Some("/".into()),
|
||||
sftp_context_menu: None,
|
||||
sftp_creating_folder: false,
|
||||
sftp_new_folder_input,
|
||||
sftp_delete_scroll_handle: gpui::ScrollHandle::new(),
|
||||
show_hidden_files: config.show_hidden_files(),
|
||||
transfers: {
|
||||
let mut transfers = config.transfers();
|
||||
for t in transfers.iter_mut() {
|
||||
if matches!(t.state, crate::terminal::TransferState::Running | crate::terminal::TransferState::Paused) {
|
||||
t.state = crate::terminal::TransferState::Zombie(t!("zombie_reason").to_string());
|
||||
}
|
||||
}
|
||||
transfers
|
||||
},
|
||||
show_transfers_dialog: false,
|
||||
system_status: None,
|
||||
terminal_bounds: HashMap::new(),
|
||||
terminal_selecting: false,
|
||||
terminal_marked_text: None,
|
||||
dragging_splitter: None,
|
||||
drag_split_origin: None,
|
||||
sftp_panel_minimized: false,
|
||||
prev_monitoring_size: None,
|
||||
status: "ready".into(),
|
||||
config,
|
||||
system_sampler,
|
||||
recording_action: None,
|
||||
keybind_error: None,
|
||||
keybinds_suspended: false,
|
||||
system,
|
||||
cpu_history: Vec::with_capacity(20),
|
||||
net_rx_history: Vec::with_capacity(20),
|
||||
net_tx_history: Vec::with_capacity(20),
|
||||
last_system_sample: Instant::now(),
|
||||
last_theme_sync: Instant::now(),
|
||||
|
||||
system_tab_id: None,
|
||||
sftp_handles: std::collections::HashMap::new(),
|
||||
|
||||
remote_sample_in_flight: false,
|
||||
runtime: Runtime::new().expect("create tokio runtime"),
|
||||
events_rx,
|
||||
events_tx,
|
||||
_subscriptions,
|
||||
};
|
||||
|
||||
this.apply_theme_preferences(window, cx);
|
||||
// this.open_local(cx);
|
||||
this.start_event_pump(cx);
|
||||
this
|
||||
}
|
||||
|
||||
pub(crate) fn on_input_event(
|
||||
&mut self,
|
||||
input: &Entity<InputState>,
|
||||
event: &InputEvent,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if input == &self.sftp_path_input {
|
||||
if let InputEvent::PressEnter { .. } = event {
|
||||
let path = self
|
||||
.sftp_path_input
|
||||
.read(cx)
|
||||
.text()
|
||||
.to_string()
|
||||
.trim()
|
||||
.to_string();
|
||||
self.navigate_sftp(if path.is_empty() { "/".into() } else { path }, cx);
|
||||
window.prevent_default();
|
||||
cx.stop_propagation();
|
||||
}
|
||||
} else if input == &self.sftp_new_folder_input {
|
||||
match event {
|
||||
InputEvent::PressEnter { .. } => {
|
||||
let name = self.sftp_new_folder_input.read(cx).text().to_string();
|
||||
if !name.is_empty() {
|
||||
let base_path = self.sftp_path_input.read(cx).text().to_string();
|
||||
let path = crate::sftp::join_remote(&base_path, &name);
|
||||
if let Some(handle) = self.active_sftp_handle() {
|
||||
let _ = handle
|
||||
.commands
|
||||
.send(crate::sftp::SftpCommand::CreateDir(path));
|
||||
}
|
||||
}
|
||||
self.sftp_creating_folder = false;
|
||||
window.prevent_default();
|
||||
cx.stop_propagation();
|
||||
}
|
||||
InputEvent::Blur => {
|
||||
self.sftp_creating_folder = false;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn start_event_pump(&self, cx: &mut Context<Self>) {
|
||||
cx.spawn(async move |this, cx| {
|
||||
let mut idle_frames = 0u32;
|
||||
loop {
|
||||
cx.background_executor()
|
||||
.timer(Duration::from_millis(16))
|
||||
.await;
|
||||
if this
|
||||
.update(cx, |this, cx| {
|
||||
let changed = this.drain_backend_events();
|
||||
let system_sampled = this.sample_system_if_due();
|
||||
this.sync_theme_if_due(cx);
|
||||
if changed || system_sampled {
|
||||
cx.notify();
|
||||
idle_frames = 0;
|
||||
} else {
|
||||
idle_frames += 1;
|
||||
if idle_frames >= 60 {
|
||||
cx.notify();
|
||||
idle_frames = 0;
|
||||
}
|
||||
}
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub(crate) fn drain_backend_events(&mut self) -> bool {
|
||||
let mut changed = false;
|
||||
let mut transfers_changed = false;
|
||||
while let Ok(event) = self.events_rx.try_recv() {
|
||||
changed = true;
|
||||
match event {
|
||||
BackendEvent::Output { tab_id, bytes } => {
|
||||
if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == tab_id) {
|
||||
tab.feed(&bytes);
|
||||
}
|
||||
}
|
||||
BackendEvent::Status { tab_id, text } => {
|
||||
if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == tab_id) {
|
||||
tab.status = text.clone();
|
||||
}
|
||||
if let Some(progress) = self.connection_progress.as_mut() {
|
||||
if progress.tab_id == tab_id {
|
||||
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)));
|
||||
}
|
||||
}
|
||||
self.status = text.into();
|
||||
}
|
||||
BackendEvent::Connected { tab_id } => {
|
||||
if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == tab_id) {
|
||||
tab.connected = true;
|
||||
}
|
||||
self.sync_system_tab_to_active_group();
|
||||
self.request_active_system_snapshot();
|
||||
if self
|
||||
.connection_progress
|
||||
.as_ref()
|
||||
.is_some_and(|progress| progress.tab_id == tab_id && !progress.failed)
|
||||
{
|
||||
self.connection_progress = None;
|
||||
}
|
||||
}
|
||||
BackendEvent::SftpEntries {
|
||||
tab_id,
|
||||
path,
|
||||
entries,
|
||||
} => {
|
||||
if let Some(group) = self.tab_groups.iter_mut().find(|g| g.id == tab_id) {
|
||||
if let Some(sftp) = group.sftp.as_mut() {
|
||||
sftp.current_path = path;
|
||||
sftp.entries = entries;
|
||||
self.pending_sftp_path_sync = Some(sftp.current_path.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
BackendEvent::SftpPreview { tab_id, preview } => {
|
||||
if let Some(group) = self.tab_groups.iter_mut().find(|g| g.id == tab_id) {
|
||||
if let Some(sftp) = group.sftp.as_mut() {
|
||||
sftp.selected_path = Some(preview.path.clone());
|
||||
sftp.preview = Some(preview);
|
||||
}
|
||||
}
|
||||
}
|
||||
BackendEvent::SftpStatus { tab_id, text } => {
|
||||
if let Some(group) = self.tab_groups.iter_mut().find(|g| g.id == tab_id) {
|
||||
if let Some(sftp) = group.sftp.as_mut() {
|
||||
sftp.status = text.clone();
|
||||
}
|
||||
}
|
||||
if self.active_group.as_ref() == Some(&tab_id) {
|
||||
self.status = text.into();
|
||||
}
|
||||
}
|
||||
BackendEvent::RemoteSystem { tab_id, snapshot } => {
|
||||
self.remote_sample_in_flight = false;
|
||||
if self.system_tab_id.as_deref() == Some(tab_id.as_str()) {
|
||||
self.system_status = None;
|
||||
self.system = snapshot.clone();
|
||||
self.cpu_history.push(snapshot.cpu_percent);
|
||||
if self.cpu_history.len() > 20 {
|
||||
self.cpu_history.remove(0);
|
||||
}
|
||||
self.net_rx_history.push(snapshot.net_rx_rate as f32);
|
||||
if self.net_rx_history.len() > 20 {
|
||||
self.net_rx_history.remove(0);
|
||||
}
|
||||
self.net_tx_history.push(snapshot.net_tx_rate as f32);
|
||||
if self.net_tx_history.len() > 20 {
|
||||
self.net_tx_history.remove(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
BackendEvent::RemoteSystemUnavailable { tab_id, reason } => {
|
||||
self.remote_sample_in_flight = false;
|
||||
if self.system_tab_id.as_deref() == Some(tab_id.as_str()) {
|
||||
self.system_status = Some(reason.clone().into());
|
||||
self.status = reason.into();
|
||||
}
|
||||
}
|
||||
BackendEvent::Closed { tab_id, reason } => {
|
||||
self.remote_sample_in_flight = false;
|
||||
let mut tab_title = None;
|
||||
let mut session_label = None;
|
||||
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)
|
||||
});
|
||||
}
|
||||
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;
|
||||
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();
|
||||
}
|
||||
BackendEvent::TransferProgress {
|
||||
tab_id: _,
|
||||
id,
|
||||
transferred,
|
||||
total,
|
||||
state,
|
||||
} => {
|
||||
if let Some(t) = self.transfers.iter_mut().find(|t| t.info.id == id) {
|
||||
t.transferred = transferred;
|
||||
if let Some(total) = total {
|
||||
t.total = Some(total);
|
||||
}
|
||||
t.state = state;
|
||||
transfers_changed = true;
|
||||
}
|
||||
}
|
||||
BackendEvent::TransferStarted { tab_id, info } => {
|
||||
let tab_title = self.transfer_source_title(&tab_id);
|
||||
self.transfers.insert(
|
||||
0,
|
||||
crate::terminal::Transfer {
|
||||
tab_id,
|
||||
tab_title,
|
||||
info,
|
||||
transferred: 0,
|
||||
total: None,
|
||||
state: crate::terminal::TransferState::Running,
|
||||
},
|
||||
);
|
||||
if self.transfers.len() > 100 {
|
||||
self.transfers.truncate(100);
|
||||
}
|
||||
transfers_changed = true;
|
||||
}
|
||||
BackendEvent::SftpHome { tab_id, home } => {
|
||||
if let Some(group) = self.tab_groups.iter_mut().find(|g| g.id == tab_id) {
|
||||
if let Some(sftp) = group.sftp.as_mut() {
|
||||
sftp.home_dir = home;
|
||||
}
|
||||
}
|
||||
}
|
||||
BackendEvent::TerminalTitleChanged { tab_id, title } => {
|
||||
if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == tab_id) {
|
||||
tab.title = title.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if transfers_changed {
|
||||
self.config.set_transfers(self.transfers.clone());
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
pub(crate) fn sample_system_if_due(&mut self) -> bool {
|
||||
if self.last_system_sample.elapsed() >= SystemSampler::interval() {
|
||||
self.last_system_sample = Instant::now();
|
||||
// Use system_tab_id (not active_tab) to decide remote vs local sampling
|
||||
if let Some(ref tab_id) = self.system_tab_id.clone() {
|
||||
if self
|
||||
.tabs
|
||||
.iter()
|
||||
.any(|t| t.id == *tab_id && t.kind == TabKind::Ssh && t.connected)
|
||||
&& self.system_status.is_none()
|
||||
{
|
||||
self.request_active_system_snapshot();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
let snapshot = self.system_sampler.sample();
|
||||
let cpu_usage = snapshot.cpu_percent;
|
||||
self.cpu_history.push(cpu_usage);
|
||||
if self.cpu_history.len() > 20 {
|
||||
self.cpu_history.remove(0);
|
||||
}
|
||||
self.net_rx_history.push(snapshot.net_rx_rate as f32);
|
||||
if self.net_rx_history.len() > 20 {
|
||||
self.net_rx_history.remove(0);
|
||||
}
|
||||
self.net_tx_history.push(snapshot.net_tx_rate as f32);
|
||||
if self.net_tx_history.len() > 20 {
|
||||
self.net_tx_history.remove(0);
|
||||
}
|
||||
self.system = snapshot;
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub(crate) fn sync_theme_if_due(&mut self, cx: &mut Context<Self>) {
|
||||
if self.follow_system_theme && self.last_theme_sync.elapsed() >= Duration::from_secs(1) {
|
||||
self.last_theme_sync = Instant::now();
|
||||
Theme::sync_system_appearance(None, cx);
|
||||
cx.refresh_windows();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn request_active_system_snapshot(&mut self) {
|
||||
let Some(ref tab_id) = self.system_tab_id.clone() else {
|
||||
return;
|
||||
};
|
||||
let Some(backend) = (|| {
|
||||
let tab = self.tabs.iter().find(|t| t.id == *tab_id)?;
|
||||
if !tab.connected {
|
||||
return None;
|
||||
}
|
||||
Some(tab.backend.clone())
|
||||
})() else {
|
||||
return;
|
||||
};
|
||||
if self.remote_sample_in_flight {
|
||||
return;
|
||||
}
|
||||
self.remote_sample_in_flight = true;
|
||||
backend.send(crate::terminal::BackendCommand::SampleMetrics);
|
||||
}
|
||||
|
||||
pub(crate) fn terminal_ime_bounds_for_range(
|
||||
&self,
|
||||
range_utf16: Range<usize>,
|
||||
element_bounds: Bounds<Pixels>,
|
||||
cell_width: f32,
|
||||
line_height: f32,
|
||||
) -> Option<Bounds<Pixels>> {
|
||||
let snapshot = self.active_snapshot()?;
|
||||
let cursor = snapshot.cursor?;
|
||||
let x = element_bounds.origin.x
|
||||
+ px(cell_width) * cursor.col as f32
|
||||
+ px(cell_width) * range_utf16.start as f32;
|
||||
let y = element_bounds.origin.y + px(line_height) * cursor.row as f32;
|
||||
Some(Bounds::new(
|
||||
point(x, y),
|
||||
size(px(cell_width), px(line_height)),
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn remove_transfer(&mut self, transfer_id: &str, cx: &mut Context<Self>) {
|
||||
self.transfers.retain(|t| t.info.id != transfer_id);
|
||||
self.config.set_transfers(self.transfers.clone());
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
use gpui::{App, AppContext as _, Bounds, WindowOptions, point, px, size};
|
||||
use gpui_component::Root;
|
||||
|
||||
use crate::Ashell;
|
||||
use crate::session::config::ConfigStore;
|
||||
|
||||
pub(crate) fn bind_workspace_keys(cx: &mut gpui::App) {
|
||||
let config = ConfigStore::load().unwrap_or_else(|_| ConfigStore::in_memory());
|
||||
crate::app::keybinding_recorder::bind_workspace_keys_from_config(cx, &config);
|
||||
}
|
||||
|
||||
struct LocalMinutelyRoller {
|
||||
dir: std::path::PathBuf,
|
||||
prefix: String,
|
||||
current_minute: u32,
|
||||
file: Option<std::fs::File>,
|
||||
}
|
||||
|
||||
impl LocalMinutelyRoller {
|
||||
fn new(dir: std::path::PathBuf, prefix: String) -> Self {
|
||||
Self {
|
||||
dir,
|
||||
prefix,
|
||||
current_minute: 60,
|
||||
file: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn rollover(&mut self, now: chrono::DateTime<chrono::Local>) -> std::io::Result<()> {
|
||||
use chrono::Timelike;
|
||||
let minute = now.minute();
|
||||
if self.current_minute != minute || self.file.is_none() {
|
||||
let filename = format!("{}-{}.log", self.prefix, now.format("%Y-%m-%d-%H-%M"));
|
||||
let path = self.dir.join(filename);
|
||||
let file = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&path)?;
|
||||
self.file = Some(file);
|
||||
self.current_minute = minute;
|
||||
|
||||
// Cleanup old files keeping last 6
|
||||
if let Ok(entries) = std::fs::read_dir(&self.dir) {
|
||||
let mut files: Vec<_> = entries
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.file_name().to_string_lossy().starts_with(&self.prefix))
|
||||
.collect();
|
||||
files.sort_by_key(|e| {
|
||||
e.metadata()
|
||||
.and_then(|m| m.modified())
|
||||
.unwrap_or(std::time::SystemTime::UNIX_EPOCH)
|
||||
});
|
||||
if files.len() > 6 {
|
||||
for file in files.iter().take(files.len() - 6) {
|
||||
let _ = std::fs::remove_file(file.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::io::Write for LocalMinutelyRoller {
|
||||
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||
let now = chrono::Local::now();
|
||||
let _ = self.rollover(now);
|
||||
if let Some(f) = &mut self.file {
|
||||
f.write(buf)
|
||||
} else {
|
||||
Ok(buf.len())
|
||||
}
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
if let Some(f) = &mut self.file {
|
||||
f.flush()
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn init_logging() {
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
let log_dir = directories::BaseDirs::new()
|
||||
.map(|dirs| dirs.home_dir().join(".config").join("ashell").join("log"))
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("."));
|
||||
|
||||
std::fs::create_dir_all(&log_dir).ok();
|
||||
|
||||
let roller = LocalMinutelyRoller::new(log_dir.clone(), "ashell".to_string());
|
||||
|
||||
let (non_blocking, _guard) = tracing_appender::non_blocking(roller);
|
||||
// Leak the guard so it lives for the entire duration of the app since GPUI's run might not return
|
||||
std::mem::forget(_guard);
|
||||
|
||||
let env_filter = tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"));
|
||||
|
||||
let stdout_layer = if cfg!(debug_assertions) {
|
||||
Some(
|
||||
tracing_subscriber::fmt::layer()
|
||||
.with_timer(tracing_subscriber::fmt::time::LocalTime::rfc_3339())
|
||||
.with_target(true),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let file_layer = tracing_subscriber::fmt::layer()
|
||||
.with_timer(tracing_subscriber::fmt::time::LocalTime::rfc_3339())
|
||||
.with_writer(non_blocking)
|
||||
.with_ansi(false)
|
||||
.with_target(true);
|
||||
|
||||
tracing_subscriber::registry()
|
||||
.with(env_filter)
|
||||
.with(stdout_layer)
|
||||
.with(file_layer)
|
||||
.init();
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(crate) fn sync_macos_launch_environment() {
|
||||
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string());
|
||||
let Ok(output) = std::process::Command::new(&shell)
|
||||
.args(["-l", "-c", "env -0"])
|
||||
.output()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if !output.status.success() {
|
||||
return;
|
||||
}
|
||||
|
||||
for entry in output.stdout.split(|b| *b == 0) {
|
||||
if entry.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some(eq) = entry.iter().position(|b| *b == b'=') else {
|
||||
continue;
|
||||
};
|
||||
let Ok(key) = std::str::from_utf8(&entry[..eq]) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(value) = std::str::from_utf8(&entry[eq + 1..]) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let should_import = matches!(
|
||||
key,
|
||||
"PATH"
|
||||
| "MANPATH"
|
||||
| "INFOPATH"
|
||||
| "LANG"
|
||||
| "LC_ALL"
|
||||
| "LC_CTYPE"
|
||||
| "SHELL"
|
||||
| "HOME"
|
||||
| "HOMEBREW_PREFIX"
|
||||
| "HOMEBREW_CELLAR"
|
||||
| "HOMEBREW_REPOSITORY"
|
||||
) || key.starts_with("LC_");
|
||||
|
||||
if should_import {
|
||||
unsafe {
|
||||
std::env::set_var(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub(crate) fn sync_macos_launch_environment() {}
|
||||
|
||||
pub(crate) fn open_main_window(cx: &mut App) {
|
||||
let mut window_options = WindowOptions::default();
|
||||
|
||||
#[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 {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
} => gpui::WindowBounds::Fullscreen(Bounds::new(
|
||||
point(px(*x), px(*y)),
|
||||
size(px(*width), px(*height)),
|
||||
)),
|
||||
crate::session::config::SavedWindowBounds::Maximized {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
} => gpui::WindowBounds::Maximized(Bounds::new(
|
||||
point(px(*x), px(*y)),
|
||||
size(px(*width), px(*height)),
|
||||
)),
|
||||
crate::session::config::SavedWindowBounds::Windowed {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
} => gpui::WindowBounds::Windowed(Bounds::new(
|
||||
point(px(*x), px(*y)),
|
||||
size(px(*width), px(*height)),
|
||||
)),
|
||||
});
|
||||
} else if let Some(display) = cx.displays().first().cloned() {
|
||||
let display_bounds = display.bounds();
|
||||
let width = display_bounds.size.width * 0.8;
|
||||
let height = display_bounds.size.height * 0.9;
|
||||
|
||||
let x = display_bounds.origin.x + (display_bounds.size.width - width) / 2.0;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
let y = display_bounds.origin.y;
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let y = display_bounds.origin.y + (display_bounds.size.height - height) / 2.0;
|
||||
|
||||
window_options.window_bounds = Some(gpui::WindowBounds::Windowed(Bounds::new(
|
||||
point(x, y),
|
||||
size(width, height),
|
||||
)));
|
||||
}
|
||||
|
||||
cx.open_window(window_options, |window, cx| {
|
||||
window.activate_window();
|
||||
window.set_window_title("ashell");
|
||||
gpui_component::Theme::sync_system_appearance(Some(window), cx);
|
||||
let view = cx.new(|cx| Ashell::new(window, cx));
|
||||
|
||||
tracing::info!("[ui] main application window opened");
|
||||
|
||||
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.");
|
||||
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 body_sizes: Vec<f32> = body_panels_clone
|
||||
.read(cx)
|
||||
.sizes()
|
||||
.iter()
|
||||
.map(|s| s.into())
|
||||
.collect();
|
||||
config.set_layout_state(Some(saved_bounds), Some(workspace_sizes), Some(body_sizes));
|
||||
let _ = config.save();
|
||||
true
|
||||
});
|
||||
|
||||
cx.new(|cx| Root::new(view, window, cx))
|
||||
})
|
||||
.expect("failed to open window");
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
use anyhow::{Context as _, Result};
|
||||
use gpui::{App, Context, SharedString, Window, px};
|
||||
use gpui_component::{ActiveTheme as _, Theme, ThemeMode, ThemeRegistry};
|
||||
|
||||
use crate::Ashell;
|
||||
|
||||
pub(crate) const EMBEDDED_THEME_JSONS: &[&str] = &[
|
||||
include_str!("../../assets/themes/matrix.json"),
|
||||
include_str!("../../assets/themes/tokyonight.json"),
|
||||
include_str!("../../assets/themes/gruvbox.json"),
|
||||
include_str!("../../assets/themes/solarized.json"),
|
||||
];
|
||||
|
||||
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")?;
|
||||
set_theme_font_names(cx.global_mut::<Theme>(), ".SystemUIFont");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn load_embedded_themes(cx: &mut App) {
|
||||
let registry = ThemeRegistry::global_mut(cx);
|
||||
for theme_json in EMBEDDED_THEME_JSONS {
|
||||
if let Err(err) = registry.load_themes_from_str(theme_json) {
|
||||
tracing::warn!("failed to load embedded theme: {err:#}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn set_theme_font_names(theme: &mut Theme, ui_font_family: &str) {
|
||||
theme.font_family = ui_font_family.into();
|
||||
theme.mono_font_family = ui_font_family.into();
|
||||
}
|
||||
|
||||
impl Ashell {
|
||||
pub(crate) fn switch_theme_mode(
|
||||
&mut self,
|
||||
mode: ThemeMode,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.follow_system_theme = false;
|
||||
self.theme_mode = mode;
|
||||
self.apply_theme_preferences(window, cx);
|
||||
self.status = format!("theme mode: {}", cx.theme().mode.name()).into();
|
||||
self.persist_theme_preferences();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn apply_theme(
|
||||
&mut self,
|
||||
name: SharedString,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(theme_config) = ThemeRegistry::global(cx).themes().get(&name).cloned() else {
|
||||
self.status = format!("theme not found: {name}").into();
|
||||
cx.notify();
|
||||
return;
|
||||
};
|
||||
|
||||
if theme_config.mode.is_dark() {
|
||||
self.dark_theme_name = name.clone();
|
||||
} else {
|
||||
self.light_theme_name = name.clone();
|
||||
}
|
||||
self.apply_theme_preferences(window, cx);
|
||||
self.status = format!("theme: {name}").into();
|
||||
self.persist_theme_preferences();
|
||||
window.refresh();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn set_follow_system_theme(
|
||||
&mut self,
|
||||
follow: bool,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.follow_system_theme = follow;
|
||||
if follow {
|
||||
self.status = "theme mode: system".into();
|
||||
} else {
|
||||
self.status = format!("theme mode: {}", cx.theme().mode.name()).into();
|
||||
}
|
||||
self.apply_theme_preferences(window, cx);
|
||||
self.persist_theme_preferences();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn set_display_language(
|
||||
&mut self,
|
||||
locale: &str,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.config.set_locale(locale);
|
||||
let mut active_locale = locale.to_string();
|
||||
if active_locale == "system" {
|
||||
active_locale = sys_locale::get_locale().unwrap_or_else(|| "en".to_string());
|
||||
if active_locale.starts_with("zh") {
|
||||
active_locale = "zh-CN".to_string();
|
||||
} else {
|
||||
active_locale = "en".to_string();
|
||||
}
|
||||
}
|
||||
rust_i18n::set_locale(&active_locale);
|
||||
gpui_component::set_locale(&active_locale);
|
||||
if let Err(err) = self.config.save() {
|
||||
tracing::warn!("failed to save language preferences: {err:#}");
|
||||
}
|
||||
window.refresh();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn apply_theme_preferences(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let light_theme = ThemeRegistry::global(cx)
|
||||
.themes()
|
||||
.get(&self.light_theme_name)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| ThemeRegistry::global(cx).default_light_theme().clone());
|
||||
let dark_theme = ThemeRegistry::global(cx)
|
||||
.themes()
|
||||
.get(&self.dark_theme_name)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| ThemeRegistry::global(cx).default_dark_theme().clone());
|
||||
let theme = Theme::global_mut(cx);
|
||||
theme.light_theme = light_theme;
|
||||
theme.dark_theme = dark_theme;
|
||||
theme.font_size = px(self.ui_font_size);
|
||||
set_theme_font_names(theme, &self.ui_font_family);
|
||||
|
||||
if self.follow_system_theme {
|
||||
Theme::sync_system_appearance(Some(window), cx);
|
||||
} else {
|
||||
Theme::change(self.theme_mode, Some(window), cx);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn persist_theme_preferences(&mut self) {
|
||||
let theme_mode_str = match self.theme_mode {
|
||||
ThemeMode::Light => "light",
|
||||
ThemeMode::Dark => "dark",
|
||||
};
|
||||
self.config.set_theme_preferences(
|
||||
self.follow_system_theme,
|
||||
theme_mode_str,
|
||||
self.light_theme_name.to_string(),
|
||||
self.dark_theme_name.to_string(),
|
||||
);
|
||||
if let Err(err) = self.config.save() {
|
||||
tracing::warn!("failed to save theme preferences: {err:#}");
|
||||
}
|
||||
}
|
||||
}
|
||||
+2285
File diff suppressed because it is too large
Load Diff
@@ -94,27 +94,40 @@ pub fn spawn_local_terminal(
|
||||
let write_tab = tab_id.clone();
|
||||
let write_events = events.clone();
|
||||
thread::spawn(move || {
|
||||
while let Ok(command) = cmd_rx.recv() {
|
||||
match command {
|
||||
BackendCommand::Input(bytes) => {
|
||||
if let Err(err) = writer.write_all(&bytes) {
|
||||
loop {
|
||||
match cmd_rx.recv_timeout(std::time::Duration::from_millis(100)) {
|
||||
Ok(command) => match command {
|
||||
BackendCommand::Input(bytes) => {
|
||||
if let Err(err) = writer.write_all(&bytes) {
|
||||
let _ = write_events.send(BackendEvent::Closed {
|
||||
tab_id: write_tab.clone(),
|
||||
reason: format!("local write error: {err}"),
|
||||
});
|
||||
break;
|
||||
}
|
||||
let _ = writer.flush();
|
||||
}
|
||||
BackendCommand::Resize { cols, rows } => {
|
||||
let _ = master.resize(PtySize {
|
||||
rows,
|
||||
cols,
|
||||
pixel_width: 0,
|
||||
pixel_height: 0,
|
||||
});
|
||||
}
|
||||
BackendCommand::Close => break,
|
||||
BackendCommand::SampleMetrics => {}
|
||||
},
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => {
|
||||
if let Ok(Some(status)) = child.try_wait() {
|
||||
let _ = write_events.send(BackendEvent::Closed {
|
||||
tab_id: write_tab.clone(),
|
||||
reason: format!("local write error: {err}"),
|
||||
tab_id: write_tab,
|
||||
reason: format!("local shell exited: {status}"),
|
||||
});
|
||||
break;
|
||||
return;
|
||||
}
|
||||
let _ = writer.flush();
|
||||
}
|
||||
BackendCommand::Resize { cols, rows } => {
|
||||
let _ = master.resize(PtySize {
|
||||
rows,
|
||||
cols,
|
||||
pixel_width: 0,
|
||||
pixel_height: 0,
|
||||
});
|
||||
}
|
||||
BackendCommand::Close => break,
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => break,
|
||||
}
|
||||
}
|
||||
let _ = child.kill();
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod local;
|
||||
pub mod ssh;
|
||||
@@ -14,7 +14,7 @@ use russh::{
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use crate::{
|
||||
config::{AuthMethod, Session},
|
||||
session::config::{AuthMethod, Session},
|
||||
system::{SystemSnapshot, remote_snapshot_from_kv},
|
||||
terminal::{BackendCommand, BackendEvent, BackendTx},
|
||||
};
|
||||
@@ -49,10 +49,12 @@ pub fn spawn_ssh_terminal(
|
||||
BackendTx::Ssh(cmd_tx)
|
||||
}
|
||||
|
||||
pub async fn sample_remote_system(session: Session) -> Result<SystemSnapshot> {
|
||||
let (events_tx, _events_rx) = std::sync::mpsc::channel();
|
||||
let handle = connect_and_authenticate("remote-metrics", &session, &events_tx).await?;
|
||||
async fn sample_remote_system_with_handle(
|
||||
handle: Arc<tokio::sync::Mutex<russh::client::Handle<ClientHandler>>>,
|
||||
) -> Result<SystemSnapshot> {
|
||||
let mut channel = handle
|
||||
.lock()
|
||||
.await
|
||||
.channel_open_session()
|
||||
.await
|
||||
.context("open metrics session")?;
|
||||
@@ -72,9 +74,6 @@ pub async fn sample_remote_system(session: Session) -> Result<SystemSnapshot> {
|
||||
}
|
||||
}
|
||||
|
||||
let _ = handle
|
||||
.disconnect(Disconnect::ByApplication, "metrics done", "")
|
||||
.await;
|
||||
let output = String::from_utf8_lossy(&stdout);
|
||||
remote_snapshot_from_kv(&output)
|
||||
}
|
||||
@@ -95,9 +94,13 @@ async fn run_ssh(
|
||||
),
|
||||
});
|
||||
|
||||
let handle = connect_and_authenticate(&tab_id, &session, &events).await?;
|
||||
let handle = Arc::new(tokio::sync::Mutex::new(
|
||||
connect_and_authenticate(&tab_id, &session, &events).await?,
|
||||
));
|
||||
|
||||
let mut channel = handle
|
||||
.lock()
|
||||
.await
|
||||
.channel_open_session()
|
||||
.await
|
||||
.context("open session")?;
|
||||
@@ -115,24 +118,48 @@ async fn run_ssh(
|
||||
tab_id: tab_id.clone(),
|
||||
});
|
||||
|
||||
let exit_reason;
|
||||
let mut is_graceful_close = false;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
command = commands.recv() => {
|
||||
match command {
|
||||
Some(BackendCommand::Input(bytes)) => {
|
||||
if let Err(err) = channel.data(bytes.as_slice()).await {
|
||||
let _ = events.send(BackendEvent::Closed {
|
||||
tab_id: tab_id.clone(),
|
||||
reason: format!("ssh write error: {err}"),
|
||||
});
|
||||
tracing::error!("[ssh] write error on tab {}: {}", tab_id, err);
|
||||
exit_reason = format!("ssh write error: {err}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
Some(BackendCommand::Resize { cols, rows }) => {
|
||||
let _ = channel.window_change(cols.into(), rows.into(), 0, 0).await;
|
||||
}
|
||||
Some(BackendCommand::SampleMetrics) => {
|
||||
let handle_clone = handle.clone();
|
||||
let tab_id_clone = tab_id.clone();
|
||||
let events_clone = events.clone();
|
||||
tokio::spawn(async move {
|
||||
match sample_remote_system_with_handle(handle_clone).await {
|
||||
Ok(snapshot) => {
|
||||
let _ = events_clone.send(BackendEvent::RemoteSystem {
|
||||
tab_id: tab_id_clone,
|
||||
snapshot,
|
||||
});
|
||||
}
|
||||
Err(err) => {
|
||||
let _ = events_clone.send(BackendEvent::RemoteSystemUnavailable {
|
||||
tab_id: tab_id_clone,
|
||||
reason: format!("remote metrics unavailable: {err:#}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Some(BackendCommand::Close) | None => {
|
||||
tracing::info!("[ssh] local client closed the session for tab {}", tab_id);
|
||||
let _ = channel.eof().await;
|
||||
exit_reason = "ssh session closed".to_string();
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -145,7 +172,29 @@ async fn run_ssh(
|
||||
bytes: data.to_vec(),
|
||||
});
|
||||
}
|
||||
Some(ChannelMsg::Close) | None => break,
|
||||
Some(ChannelMsg::ExitStatus { exit_status: _ }) | Some(ChannelMsg::Eof) => {
|
||||
is_graceful_close = true;
|
||||
}
|
||||
Some(ChannelMsg::Close) => {
|
||||
if is_graceful_close {
|
||||
tracing::info!("[ssh] session gracefully closed by server for tab {}", tab_id);
|
||||
exit_reason = "ssh session closed".to_string();
|
||||
} else {
|
||||
tracing::warn!("[ssh] connection abruptly closed by server for tab {}", tab_id);
|
||||
exit_reason = "ssh connection lost (abrupt close)".to_string();
|
||||
}
|
||||
break;
|
||||
}
|
||||
None => {
|
||||
if is_graceful_close {
|
||||
tracing::info!("[ssh] network stream ended gracefully for tab {}", tab_id);
|
||||
exit_reason = "ssh session closed".to_string();
|
||||
} else {
|
||||
tracing::warn!("[ssh] network drop detected for tab {}", tab_id);
|
||||
exit_reason = "ssh connection lost (network drop)".to_string();
|
||||
}
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -153,11 +202,13 @@ async fn run_ssh(
|
||||
}
|
||||
|
||||
let _ = handle
|
||||
.lock()
|
||||
.await
|
||||
.disconnect(Disconnect::ByApplication, "bye", "")
|
||||
.await;
|
||||
let _ = events.send(BackendEvent::Closed {
|
||||
tab_id,
|
||||
reason: "ssh session closed".into(),
|
||||
reason: exit_reason,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
@@ -169,9 +220,16 @@ 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,
|
||||
..Default::default()
|
||||
});
|
||||
let addr = format!("{}:{}", session.host, session.port);
|
||||
tracing::info!(
|
||||
"[ssh] initiating tcp connection to {} (user: {})",
|
||||
addr,
|
||||
session.user
|
||||
);
|
||||
let _ = events.send(BackendEvent::Status {
|
||||
tab_id: tab_id.to_string(),
|
||||
text: format!("opening tcp connection to {addr}"),
|
||||
@@ -180,8 +238,15 @@ async fn connect_and_authenticate(
|
||||
.await
|
||||
.with_context(|| format!("connect {addr} failed"))?;
|
||||
|
||||
tracing::debug!("[ssh] tcp connected to {}", addr);
|
||||
|
||||
let authed = match session.auth {
|
||||
AuthMethod::Password => {
|
||||
tracing::info!(
|
||||
"[ssh] sending password authentication for {}@{}",
|
||||
session.user,
|
||||
addr
|
||||
);
|
||||
let _ = events.send(BackendEvent::Status {
|
||||
tab_id: tab_id.to_string(),
|
||||
text: format!(
|
||||
@@ -196,6 +261,12 @@ async fn connect_and_authenticate(
|
||||
}
|
||||
AuthMethod::Key => {
|
||||
let source = key_source_label(session);
|
||||
tracing::info!(
|
||||
"[ssh] sending key 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}"),
|
||||
@@ -206,20 +277,40 @@ async fn connect_and_authenticate(
|
||||
tab_id: tab_id.to_string(),
|
||||
text: format!("private key loaded from {source}, algorithm {algorithm}, sending public key authentication for {}", session.user),
|
||||
});
|
||||
let key = private_key_with_alg(keypair).context("invalid private key")?;
|
||||
handle
|
||||
.authenticate_publickey(&session.user, key)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"public key authentication failed for {}@{}:{} using {} ({})",
|
||||
session.user, session.host, session.port, source, 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) => {
|
||||
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
|
||||
));
|
||||
}
|
||||
success
|
||||
}
|
||||
};
|
||||
|
||||
if !authed {
|
||||
tracing::warn!("[ssh] authentication failed for {}@{}", session.user, addr);
|
||||
let _ = handle
|
||||
.disconnect(Disconnect::ByApplication, "auth failed", "")
|
||||
.await;
|
||||
@@ -241,6 +332,12 @@ async fn connect_and_authenticate(
|
||||
));
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"[ssh] authentication successful for {}@{}",
|
||||
session.user,
|
||||
addr
|
||||
);
|
||||
|
||||
let _ = events.send(BackendEvent::Status {
|
||||
tab_id: tab_id.to_string(),
|
||||
text: format!(
|
||||
@@ -281,16 +378,33 @@ fn load_session_private_key(session: &Session) -> Result<PrivateKey> {
|
||||
Err(anyhow!(errors.join("; ")))
|
||||
}
|
||||
|
||||
fn private_key_with_alg(keypair: PrivateKey) -> Result<PrivateKeyWithHashAlg> {
|
||||
let hash_alg = if keypair.algorithm().is_rsa() {
|
||||
Some(HashAlg::Sha512)
|
||||
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 {
|
||||
None
|
||||
};
|
||||
Ok(
|
||||
PrivateKeyWithHashAlg::new(Arc::new(keypair.clone()), hash_alg)
|
||||
.or_else(|_| PrivateKeyWithHashAlg::new(Arc::new(keypair), Some(HashAlg::Sha256)))?,
|
||||
)
|
||||
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 {
|
||||
@@ -365,7 +479,7 @@ EOF
|
||||
echo "SWAP_USED=$(( ${swap_total:-0} - ${swap_free:-0} ))"
|
||||
echo "NET_RX=$(( ${net_rx_2:-0} - ${net_rx_1:-0} ))"
|
||||
echo "NET_TX=$(( ${net_tx_2:-0} - ${net_tx_1:-0} ))"
|
||||
df -kP 2>/dev/null | awk "NR > 1 { printf \"DISK=%s\t%s\t%s\n\", \$6, \$4 * 1024, \$2 * 1024 }" | head -n 4
|
||||
df -kP 2>/dev/null | awk "NR > 1 && \$1 !~ /^(tmpfs|devtmpfs|ramfs|overlay|aufs)\$/ { printf \"DISK=%s\t%s\t%s\n\", \$6, \$4 * 1024, \$2 * 1024 }" | head -n 6
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -404,7 +518,7 @@ EOF
|
||||
echo "SWAP_USED=${swap_used:-0}"
|
||||
echo "NET_RX=$(( ${net_rx_2:-0} - ${net_rx_1:-0} ))"
|
||||
echo "NET_TX=$(( ${net_tx_2:-0} - ${net_tx_1:-0} ))"
|
||||
df -kP 2>/dev/null | awk "NR > 1 { printf \"DISK=%s\t%s\t%s\n\", \$6, \$4 * 1024, \$2 * 1024 }" | head -n 4
|
||||
df -kP 2>/dev/null | awk "NR > 1 && \$1 !~ /^(devfs|tmpfs|devtmpfs|ramfs|overlay|aufs)\$/ { printf \"DISK=%s\t%s\t%s\n\", \$6, \$4 * 1024, \$2 * 1024 }" | head -n 6
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -417,6 +531,7 @@ echo "NET_RX=0"
|
||||
echo "NET_TX=0"
|
||||
'"#;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ClientHandler;
|
||||
|
||||
#[async_trait]
|
||||
+30
-4773
File diff suppressed because it is too large
Load Diff
@@ -125,6 +125,16 @@ pub struct ConfigFile {
|
||||
pub body_panels: Option<Vec<f32>>,
|
||||
#[serde(default)]
|
||||
pub transfers: Vec<crate::terminal::Transfer>,
|
||||
#[serde(default)]
|
||||
pub show_hidden_files: bool,
|
||||
#[serde(default = "default_monitoring_position")]
|
||||
pub monitoring_position: String,
|
||||
#[serde(default)]
|
||||
pub key_bindings: std::collections::HashMap<String, String>,
|
||||
}
|
||||
|
||||
fn default_monitoring_position() -> String {
|
||||
"Sidebar".to_string()
|
||||
}
|
||||
|
||||
fn default_locale() -> String {
|
||||
@@ -132,11 +142,11 @@ fn default_locale() -> String {
|
||||
}
|
||||
|
||||
fn default_terminal_font_size() -> f32 {
|
||||
13.0
|
||||
18.0
|
||||
}
|
||||
|
||||
fn default_ui_font_size() -> f32 {
|
||||
12.0
|
||||
14.0
|
||||
}
|
||||
|
||||
pub fn default_ui_font_family() -> String {
|
||||
@@ -160,6 +170,16 @@ impl ConfigStore {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.with_context(|| format!("failed to create config dir {}", parent.display()))?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
if let Ok(mut perms) = fs::metadata(parent).map(|m| m.permissions()) {
|
||||
perms.set_mode(0o700);
|
||||
let _ = fs::set_permissions(parent, perms);
|
||||
}
|
||||
}
|
||||
|
||||
let tmp_dir = parent.join("tmp");
|
||||
let _ = fs::remove_dir_all(&tmp_dir);
|
||||
let _ = fs::create_dir_all(&tmp_dir);
|
||||
@@ -228,6 +248,28 @@ impl ConfigStore {
|
||||
self.cache.locale = locale.to_string();
|
||||
}
|
||||
|
||||
pub fn key_bindings(&self) -> &std::collections::HashMap<String, String> {
|
||||
&self.cache.key_bindings
|
||||
}
|
||||
|
||||
pub fn set_key_binding(&mut self, action_name: &str, keystroke: &str) {
|
||||
self.cache
|
||||
.key_bindings
|
||||
.insert(action_name.to_string(), keystroke.to_string());
|
||||
}
|
||||
|
||||
pub fn monitoring_position(&self) -> &str {
|
||||
if self.cache.monitoring_position.is_empty() {
|
||||
"Sidebar"
|
||||
} else {
|
||||
&self.cache.monitoring_position
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_monitoring_position(&mut self, pos: &str) {
|
||||
self.cache.monitoring_position = pos.to_string();
|
||||
}
|
||||
|
||||
pub fn terminal_font_size(&self) -> f32 {
|
||||
if self.cache.terminal_font_size <= 0.0 {
|
||||
default_terminal_font_size()
|
||||
@@ -257,6 +299,7 @@ impl ConfigStore {
|
||||
self.cache.workspace_panels.as_ref()
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn body_panels(&self) -> Option<&Vec<f32>> {
|
||||
self.cache.body_panels.as_ref()
|
||||
}
|
||||
@@ -331,6 +374,14 @@ impl ConfigStore {
|
||||
self.cache.terminal_font_family = family.to_string();
|
||||
}
|
||||
|
||||
pub fn show_hidden_files(&self) -> bool {
|
||||
self.cache.show_hidden_files
|
||||
}
|
||||
|
||||
pub fn set_show_hidden_files(&mut self, val: bool) {
|
||||
self.cache.show_hidden_files = val;
|
||||
}
|
||||
|
||||
pub fn get(&self, id: &str) -> Option<&Session> {
|
||||
self.cache.sessions.iter().find(|s| s.id == id)
|
||||
}
|
||||
@@ -353,6 +404,17 @@ impl ConfigStore {
|
||||
}
|
||||
let raw = serde_json::to_string_pretty(&self.cache)?;
|
||||
fs::write(&self.path, raw)
|
||||
.with_context(|| format!("failed to write {}", self.path.display()))
|
||||
.with_context(|| format!("failed to write {}", self.path.display()))?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
if let Ok(mut perms) = fs::metadata(&self.path).map(|m| m.permissions()) {
|
||||
perms.set_mode(0o600);
|
||||
let _ = fs::set_permissions(&self.path, perms);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
+1319
File diff suppressed because it is too large
Load Diff
+170
-40
@@ -1,3 +1,5 @@
|
||||
pub mod ops;
|
||||
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
@@ -27,7 +29,7 @@ use zip::read::ZipArchive;
|
||||
use rust_i18n::t;
|
||||
|
||||
use crate::{
|
||||
config::{AuthMethod, Session},
|
||||
session::config::{AuthMethod, Session},
|
||||
terminal::BackendEvent,
|
||||
};
|
||||
|
||||
@@ -256,7 +258,7 @@ async fn run_sftp(
|
||||
.canonicalize(".")
|
||||
.await
|
||||
.unwrap_or_else(|_| "/".to_string());
|
||||
|
||||
|
||||
let _ = events.send(BackendEvent::SftpHome {
|
||||
tab_id: tab_id.clone(),
|
||||
home: home.clone(),
|
||||
@@ -377,16 +379,26 @@ async fn run_sftp(
|
||||
}
|
||||
Err(err) => {
|
||||
let err_msg = format!("{err:#}");
|
||||
let is_cancelled = err_msg.contains("transfer cancelled");
|
||||
let state = if is_cancelled {
|
||||
crate::terminal::TransferState::Interrupted("User cancelled".to_string())
|
||||
} else {
|
||||
crate::terminal::TransferState::Failed(err_msg.clone())
|
||||
};
|
||||
let _ = events_clone.send(BackendEvent::SftpStatus {
|
||||
tab_id: tab_id_clone.clone(),
|
||||
text: t!("download_failed", err = err_msg.clone()).to_string(),
|
||||
text: if is_cancelled {
|
||||
"Transmission cancelled".to_string()
|
||||
} else {
|
||||
t!("download_failed", err = err_msg.clone()).to_string()
|
||||
},
|
||||
});
|
||||
let _ = events_clone.send(BackendEvent::TransferProgress {
|
||||
tab_id: tab_id_clone,
|
||||
id: id.clone(),
|
||||
transferred: 0,
|
||||
total: None,
|
||||
state: crate::terminal::TransferState::Failed(err_msg),
|
||||
state,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -478,16 +490,26 @@ async fn run_sftp(
|
||||
}
|
||||
Err(err) => {
|
||||
let err_msg = format!("{err:#}");
|
||||
let is_cancelled = err_msg.contains("transfer cancelled");
|
||||
let state = if is_cancelled {
|
||||
crate::terminal::TransferState::Interrupted("User cancelled".to_string())
|
||||
} else {
|
||||
crate::terminal::TransferState::Failed(err_msg.clone())
|
||||
};
|
||||
let _ = events_clone.send(BackendEvent::SftpStatus {
|
||||
tab_id: tab_id_clone.clone(),
|
||||
text: t!("upload_failed", err = err_msg.clone()).to_string(),
|
||||
text: if is_cancelled {
|
||||
"Transmission cancelled".to_string()
|
||||
} else {
|
||||
t!("upload_failed", err = err_msg.clone()).to_string()
|
||||
},
|
||||
});
|
||||
let _ = events_clone.send(BackendEvent::TransferProgress {
|
||||
tab_id: tab_id_clone,
|
||||
id: id.clone(),
|
||||
transferred: 0,
|
||||
total: None,
|
||||
state: crate::terminal::TransferState::Failed(err_msg),
|
||||
state,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -496,7 +518,7 @@ async fn run_sftp(
|
||||
}
|
||||
SftpCommand::EditFile { remote_path } => {
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let config = crate::config::ConfigStore::load().unwrap();
|
||||
let config = crate::session::config::ConfigStore::load().unwrap();
|
||||
let tmp_dir = config.tmp_dir().unwrap_or_else(|| PathBuf::from("/tmp"));
|
||||
let base = base_name(&remote_path);
|
||||
let local_path = tmp_dir.join(format!("{}-{}", id, base));
|
||||
@@ -573,10 +595,13 @@ async fn run_sftp(
|
||||
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||
while let Ok(_) = rx.try_recv() {} // drain pending
|
||||
|
||||
if commands_tx_clone.send(SftpCommand::UploadEditedFile {
|
||||
local_path: local_path.to_string_lossy().to_string(),
|
||||
remote_path: remote_path.clone(),
|
||||
}).is_err() {
|
||||
if commands_tx_clone
|
||||
.send(SftpCommand::UploadEditedFile {
|
||||
local_path: local_path.to_string_lossy().to_string(),
|
||||
remote_path: remote_path.clone(),
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -620,7 +645,11 @@ async fn run_sftp(
|
||||
let now = chrono::Local::now().format("%H:%M:%S");
|
||||
let _ = events_clone.send(BackendEvent::SftpStatus {
|
||||
tab_id: tab_id_clone.clone(),
|
||||
text: format!("{} ({})", t!("auto_saved_and_uploaded", base = base_name(&remote_path)), now),
|
||||
text: format!(
|
||||
"{} ({})",
|
||||
t!("auto_saved_and_uploaded", base = base_name(&remote_path)),
|
||||
now
|
||||
),
|
||||
});
|
||||
}
|
||||
Err(err) => {
|
||||
@@ -641,13 +670,16 @@ async fn run_sftp(
|
||||
path.clone()
|
||||
};
|
||||
|
||||
tracing::info!("[sftp] creating directory: '{}'", actual_path);
|
||||
|
||||
match sftp.create_dir(&actual_path).await {
|
||||
Ok(_) => {
|
||||
let _ = events.send(BackendEvent::SftpStatus {
|
||||
tab_id: tab_id.clone(),
|
||||
text: t!("create_folder_success", name = base_name(&actual_path)).to_string(),
|
||||
text: t!("create_folder_success", name = base_name(&actual_path))
|
||||
.to_string(),
|
||||
});
|
||||
|
||||
|
||||
// Re-fetch the parent directory to show the newly created folder
|
||||
if let Some(parent) = parent_dir(&actual_path) {
|
||||
let _ = commands_tx.send(SftpCommand::ListDir(parent));
|
||||
@@ -664,6 +696,7 @@ async fn run_sftp(
|
||||
}
|
||||
}
|
||||
SftpCommand::DeletePaths(paths) => {
|
||||
tracing::info!("[sftp] batch deleting {} paths", paths.len());
|
||||
let _ = events.send(BackendEvent::SftpStatus {
|
||||
tab_id: tab_id.clone(),
|
||||
text: t!("deleting_paths", count = paths.len()).to_string(),
|
||||
@@ -736,21 +769,27 @@ fn recursive_delete<'a>(
|
||||
continue;
|
||||
}
|
||||
let child_path = crate::sftp::join_remote(&path, &name);
|
||||
|
||||
|
||||
let meta = entry.metadata();
|
||||
let permissions = meta.permissions.unwrap_or(0);
|
||||
let is_dir = (permissions & 0o170_000) == 0o040_000;
|
||||
|
||||
|
||||
if is_dir {
|
||||
recursive_delete(sftp, child_path).await?;
|
||||
} else {
|
||||
sftp.remove_file(&child_path).await.with_context(|| format!("Failed to delete file {child_path}"))?;
|
||||
sftp.remove_file(&child_path)
|
||||
.await
|
||||
.with_context(|| format!("Failed to delete file {child_path}"))?;
|
||||
}
|
||||
}
|
||||
sftp.remove_dir(&path).await.with_context(|| format!("Failed to delete dir {path}"))?;
|
||||
sftp.remove_dir(&path)
|
||||
.await
|
||||
.with_context(|| format!("Failed to delete dir {path}"))?;
|
||||
}
|
||||
Err(_) => {
|
||||
sftp.remove_file(&path).await.with_context(|| format!("Failed to delete {path}"))?;
|
||||
sftp.remove_file(&path)
|
||||
.await
|
||||
.with_context(|| format!("Failed to delete {path}"))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -795,11 +834,35 @@ async fn connect_and_authenticate(
|
||||
.context("password authentication failed")?,
|
||||
AuthMethod::Key => {
|
||||
let keypair = load_session_private_key(session)?;
|
||||
let key = private_key_with_alg(keypair).context("invalid private key")?;
|
||||
handle
|
||||
.authenticate_publickey(&session.user, key)
|
||||
.await
|
||||
.context("public key authentication failed")?
|
||||
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
|
||||
));
|
||||
}
|
||||
success
|
||||
}
|
||||
};
|
||||
|
||||
@@ -851,16 +914,33 @@ fn load_session_private_key(session: &Session) -> Result<PrivateKey> {
|
||||
Err(anyhow!(errors.join("; ")))
|
||||
}
|
||||
|
||||
fn private_key_with_alg(keypair: PrivateKey) -> Result<PrivateKeyWithHashAlg> {
|
||||
let hash_alg = if keypair.algorithm().is_rsa() {
|
||||
Some(HashAlg::Sha512)
|
||||
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 {
|
||||
None
|
||||
};
|
||||
Ok(
|
||||
PrivateKeyWithHashAlg::new(Arc::new(keypair.clone()), hash_alg)
|
||||
.or_else(|_| PrivateKeyWithHashAlg::new(Arc::new(keypair), Some(HashAlg::Sha256)))?,
|
||||
)
|
||||
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 {
|
||||
@@ -1063,6 +1143,12 @@ async fn download_path_impl(
|
||||
.await
|
||||
.with_context(|| format!("create {}", local_dir.display()))?;
|
||||
|
||||
// Check for cancellation after initial setup
|
||||
let state = flag.0.load(Ordering::SeqCst);
|
||||
if state == 2 {
|
||||
return Err(anyhow::anyhow!("transfer cancelled"));
|
||||
}
|
||||
|
||||
let metadata = sftp
|
||||
.metadata(remote)
|
||||
.await
|
||||
@@ -1156,7 +1242,15 @@ async fn download_remote_directory_archive(
|
||||
base_name(remote_dir),
|
||||
Uuid::new_v4()
|
||||
);
|
||||
|
||||
// Check for cancellation before creating remote archive
|
||||
let state = flag.0.load(Ordering::SeqCst);
|
||||
if state == 2 {
|
||||
return Err(anyhow::anyhow!("transfer cancelled"));
|
||||
}
|
||||
|
||||
create_remote_archive(handle, remote_dir, &remote_archive).await?;
|
||||
|
||||
let local_extract_root = local_archive
|
||||
.parent()
|
||||
.unwrap_or_else(|| Path::new("."))
|
||||
@@ -1262,6 +1356,12 @@ async fn upload_paths_impl(
|
||||
tab_id: &str,
|
||||
id: &str,
|
||||
) -> Result<String> {
|
||||
// Check for cancellation before starting
|
||||
let state = flag.0.load(Ordering::SeqCst);
|
||||
if state == 2 {
|
||||
return Err(anyhow::anyhow!("transfer cancelled"));
|
||||
}
|
||||
|
||||
create_remote_dir_all(sftp, remote_dir).await?;
|
||||
let mut file_count = 0usize;
|
||||
let mut folder_count = 0usize;
|
||||
@@ -1314,8 +1414,19 @@ async fn upload_paths_impl(
|
||||
}
|
||||
}
|
||||
|
||||
// Check for cancellation before creating directories
|
||||
let state = flag.0.load(Ordering::SeqCst);
|
||||
if state == 2 {
|
||||
return Err(anyhow::anyhow!("transfer cancelled"));
|
||||
}
|
||||
|
||||
// Create directories sequentially first
|
||||
for dir in dirs_to_create {
|
||||
// Check for cancellation between each directory creation
|
||||
let state = flag.0.load(Ordering::SeqCst);
|
||||
if state == 2 {
|
||||
return Err(anyhow::anyhow!("transfer cancelled"));
|
||||
}
|
||||
create_remote_dir_all(sftp, &dir).await?;
|
||||
}
|
||||
|
||||
@@ -1483,14 +1594,31 @@ async fn exec_remote_command(
|
||||
let mut stderr = Vec::new();
|
||||
let mut stdout = Vec::new();
|
||||
let mut exit_status = None;
|
||||
while let Some(msg) = channel.wait().await {
|
||||
match msg {
|
||||
russh::ChannelMsg::Data { data } => stdout.extend_from_slice(&data),
|
||||
russh::ChannelMsg::ExtendedData { data, .. } => stderr.extend_from_slice(&data),
|
||||
russh::ChannelMsg::ExitStatus { exit_status: code } => exit_status = Some(code),
|
||||
russh::ChannelMsg::Close => break,
|
||||
_ => {}
|
||||
|
||||
// Add timeout to prevent indefinite blocking (300 seconds = 5 minutes)
|
||||
let timeout = tokio::time::Duration::from_secs(300);
|
||||
let result = tokio::time::timeout(timeout, async {
|
||||
loop {
|
||||
// Yield to allow cancellation
|
||||
tokio::task::yield_now().await;
|
||||
|
||||
if let Some(msg) = channel.wait().await {
|
||||
match msg {
|
||||
russh::ChannelMsg::Data { data } => stdout.extend_from_slice(&data),
|
||||
russh::ChannelMsg::ExtendedData { data, .. } => stderr.extend_from_slice(&data),
|
||||
russh::ChannelMsg::ExitStatus { exit_status: code } => exit_status = Some(code),
|
||||
russh::ChannelMsg::Close => break,
|
||||
_ => {}
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
if result.is_err() {
|
||||
return Err(anyhow!("remote command timeout: {command}"));
|
||||
}
|
||||
|
||||
match exit_status.unwrap_or(0) {
|
||||
@@ -1654,6 +1782,8 @@ async fn extract_archive_to(path: &Path, target_dir: &Path) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
|
||||
#[derive(Clone)]
|
||||
struct SftpClientHandler;
|
||||
|
||||
+395
@@ -0,0 +1,395 @@
|
||||
use gpui::{Context, PathPromptOptions, Pixels, Point, Window};
|
||||
|
||||
use crate::{
|
||||
Ashell, SftpContextMenuState,
|
||||
sftp::{RemoteEntry, SftpHandle},
|
||||
terminal,
|
||||
};
|
||||
|
||||
pub(crate) fn is_editable_text_file(filename: &str) -> bool {
|
||||
let lower = filename.to_lowercase();
|
||||
let ext = std::path::Path::new(&lower)
|
||||
.extension()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("");
|
||||
let known_exts = [
|
||||
"txt", "conf", "json", "yaml", "yml", "xml", "ini", "sh", "py", "rs", "js", "ts", "html",
|
||||
"css", "md", "toml", "csv", "log", "cfg",
|
||||
];
|
||||
if known_exts.contains(&ext) {
|
||||
return true;
|
||||
}
|
||||
let known_names = ["dockerfile", "makefile", ".gitignore", ".env"];
|
||||
if known_names.contains(&lower.as_str()) {
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
impl Ashell {
|
||||
pub(crate) fn active_sftp(&self) -> Option<&terminal::SftpUiState> {
|
||||
self.active_group
|
||||
.as_ref()
|
||||
.and_then(|id| self.tab_groups.iter().find(|g| &g.id == id))
|
||||
.and_then(|g| g.sftp.as_ref())
|
||||
}
|
||||
|
||||
pub(crate) fn active_sftp_mut(&mut self) -> Option<&mut terminal::SftpUiState> {
|
||||
let active_id = self.active_group.clone()?;
|
||||
self.tab_groups
|
||||
.iter_mut()
|
||||
.find(|g| g.id == active_id)
|
||||
.and_then(|g| g.sftp.as_mut())
|
||||
}
|
||||
|
||||
pub(crate) fn active_sftp_handle(&self) -> Option<&SftpHandle> {
|
||||
self.active_group
|
||||
.as_ref()
|
||||
.and_then(|id| self.sftp_handles.get(id))
|
||||
}
|
||||
|
||||
pub(crate) fn navigate_sftp(&mut self, path: String, cx: &mut Context<Self>) {
|
||||
if let Some(handle) = self.active_sftp_handle() {
|
||||
tracing::info!("[sftp] navigating to directory: '{}'", path);
|
||||
handle.list_dir(path.clone());
|
||||
if let Some(sftp) = self.active_sftp_mut() {
|
||||
sftp.current_path = path;
|
||||
self.pending_sftp_path_sync = Some(sftp.current_path.clone());
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn select_sftp_entry(&mut self, entry: RemoteEntry, cx: &mut Context<Self>) {
|
||||
if entry.is_dir {
|
||||
self.navigate_sftp(entry.full_path, cx);
|
||||
return;
|
||||
}
|
||||
self.mark_sftp_entry_selected(&entry.full_path, cx);
|
||||
if let Some(sftp) = self.active_sftp_mut() {
|
||||
if !sftp.selected_entries.remove(&entry.full_path) {
|
||||
sftp.selected_entries.insert(entry.full_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn mark_sftp_entry_selected(&mut self, path: &str, cx: &mut Context<Self>) {
|
||||
if let Some(sftp) = self.active_sftp_mut() {
|
||||
sftp.selected_path = Some(path.to_string());
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn sftp_parent_path(path: &str) -> String {
|
||||
if path == "/" {
|
||||
return "/".to_string();
|
||||
}
|
||||
path.trim_end_matches('/')
|
||||
.rsplit_once('/')
|
||||
.map(|(parent, _)| {
|
||||
if parent.is_empty() {
|
||||
"/".to_string()
|
||||
} else {
|
||||
parent.to_string()
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| "/".to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn refresh_sftp(&mut self, cx: &mut Context<Self>) {
|
||||
if let Some(path) = self.active_sftp().map(|sftp| sftp.current_path.clone()) {
|
||||
self.navigate_sftp(path, cx);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn sync_sftp_path_input(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(path) = self.pending_sftp_path_sync.take() else {
|
||||
return;
|
||||
};
|
||||
self.sftp_path_input.update(cx, |state, cx| {
|
||||
state.set_value(path, window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn open_sftp_context_menu(
|
||||
&mut self,
|
||||
remote_path: String,
|
||||
is_dir: bool,
|
||||
position: Point<Pixels>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.sftp_context_menu = Some(SftpContextMenuState {
|
||||
remote_path,
|
||||
is_dir,
|
||||
position,
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn dismiss_sftp_context_menu(&mut self, cx: &mut Context<Self>) {
|
||||
if self.sftp_context_menu.take().is_some() {
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn trigger_sftp_context_download(
|
||||
&mut self,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(menu) = self.sftp_context_menu.take() else {
|
||||
return;
|
||||
};
|
||||
self.download_sftp_entry(menu.remote_path, window, cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn trigger_sftp_context_edit(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(menu) = self.sftp_context_menu.take() else {
|
||||
return;
|
||||
};
|
||||
if let Some(handle) = self.active_sftp_handle() {
|
||||
tracing::info!("[sftp] triggering edit for file: '{}'", menu.remote_path);
|
||||
handle.edit_file(menu.remote_path);
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn download_sftp_entry(
|
||||
&mut self,
|
||||
remote_path: String,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(handle) = self.active_sftp_handle().cloned() else {
|
||||
return;
|
||||
};
|
||||
let path_prompt = cx.prompt_for_paths(PathPromptOptions {
|
||||
files: false,
|
||||
directories: true,
|
||||
multiple: false,
|
||||
prompt: Some("Select Download Folder".into()),
|
||||
});
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
match path_prompt.await {
|
||||
Ok(Ok(Some(mut paths))) => {
|
||||
if let Some(folder) = paths.pop() {
|
||||
let local_path = folder.to_string_lossy().to_string();
|
||||
tracing::info!(
|
||||
"[sftp] initiating download of '{}' to '{}'",
|
||||
remote_path,
|
||||
local_path
|
||||
);
|
||||
handle.download(remote_path, local_path);
|
||||
this.update(cx, |this, cx| {
|
||||
this.show_transfers_dialog = true;
|
||||
cx.notify();
|
||||
})?;
|
||||
}
|
||||
}
|
||||
Ok(Err(err)) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.status = format!("download picker failed: {err}").into();
|
||||
cx.notify();
|
||||
})?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok::<(), anyhow::Error>(())
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub(crate) fn upload_sftp_files(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(handle) = self.active_sftp_handle().cloned() else {
|
||||
return;
|
||||
};
|
||||
let remote_dir = self
|
||||
.active_sftp()
|
||||
.map(|sftp| sftp.current_path.clone())
|
||||
.unwrap_or_else(|| "/".into());
|
||||
let path_prompt = cx.prompt_for_paths(PathPromptOptions {
|
||||
files: true,
|
||||
directories: false,
|
||||
multiple: false,
|
||||
prompt: Some("Select File to Upload".into()),
|
||||
});
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
match path_prompt.await {
|
||||
Ok(Ok(Some(mut paths))) => {
|
||||
if let Some(file) = paths.pop() {
|
||||
let local_path = file.to_string_lossy().to_string();
|
||||
tracing::info!(
|
||||
"[sftp] initiating upload of file '{}' to '{}'",
|
||||
local_path,
|
||||
remote_dir
|
||||
);
|
||||
handle.upload_paths(vec![local_path], remote_dir);
|
||||
this.update(cx, |this, cx| {
|
||||
this.show_transfers_dialog = true;
|
||||
cx.notify();
|
||||
})?;
|
||||
}
|
||||
}
|
||||
Ok(Err(err)) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.status = format!("upload picker failed: {err}").into();
|
||||
cx.notify();
|
||||
})?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok::<(), anyhow::Error>(())
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub(crate) fn upload_sftp_folder(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(handle) = self.active_sftp_handle().cloned() else {
|
||||
return;
|
||||
};
|
||||
let remote_dir = self
|
||||
.active_sftp()
|
||||
.map(|sftp| sftp.current_path.clone())
|
||||
.unwrap_or_else(|| "/".into());
|
||||
let path_prompt = cx.prompt_for_paths(PathPromptOptions {
|
||||
files: false,
|
||||
directories: true,
|
||||
multiple: false,
|
||||
prompt: Some("Select Folder to Upload".into()),
|
||||
});
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
match path_prompt.await {
|
||||
Ok(Ok(Some(mut paths))) => {
|
||||
if let Some(folder) = paths.pop() {
|
||||
let local_path = folder.to_string_lossy().to_string();
|
||||
tracing::info!(
|
||||
"[sftp] initiating upload of folder '{}' to '{}'",
|
||||
local_path,
|
||||
remote_dir
|
||||
);
|
||||
handle.upload_paths(vec![local_path], remote_dir);
|
||||
this.update(cx, |this, cx| {
|
||||
this.show_transfers_dialog = true;
|
||||
cx.notify();
|
||||
})?;
|
||||
}
|
||||
}
|
||||
Ok(Err(err)) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.status = format!("upload picker failed: {err}").into();
|
||||
cx.notify();
|
||||
})?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok::<(), anyhow::Error>(())
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub(crate) fn toggle_sftp_entry(
|
||||
&mut self,
|
||||
path: String,
|
||||
checked: bool,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if let Some(sftp) = self.active_sftp_mut() {
|
||||
if checked {
|
||||
sftp.selected_entries.insert(path);
|
||||
} else {
|
||||
sftp.selected_entries.remove(&path);
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn toggle_all_sftp_entries(&mut self, checked: bool, cx: &mut Context<Self>) {
|
||||
if let Some(sftp) = self.active_sftp_mut() {
|
||||
if checked {
|
||||
let paths: Vec<String> = sftp.entries.iter().map(|e| e.full_path.clone()).collect();
|
||||
for path in paths {
|
||||
sftp.selected_entries.insert(path);
|
||||
}
|
||||
} else {
|
||||
sftp.selected_entries.clear();
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn download_selected_sftp_entries(
|
||||
&mut self,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(sftp) = self.active_sftp() else {
|
||||
return;
|
||||
};
|
||||
let selected: Vec<String> = sftp.selected_entries.iter().cloned().collect();
|
||||
if selected.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(handle) = self.active_sftp_handle().cloned() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let path_prompt = cx.prompt_for_paths(PathPromptOptions {
|
||||
files: false,
|
||||
directories: true,
|
||||
multiple: false,
|
||||
prompt: Some("Select Download Folder".into()),
|
||||
});
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
if let Ok(Ok(Some(mut paths))) = path_prompt.await {
|
||||
if let Some(folder) = paths.pop() {
|
||||
let local_dir = folder.to_string_lossy().to_string();
|
||||
tracing::info!(
|
||||
"[sftp] initiating batch download of {} entries to '{}'",
|
||||
selected.len(),
|
||||
local_dir
|
||||
);
|
||||
for remote in selected {
|
||||
let _ = handle.commands.send(crate::sftp::SftpCommand::Download {
|
||||
remote,
|
||||
local_dir: local_dir.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
let _ = this.update(cx, |this, cx| {
|
||||
if let Some(sftp_mut) = this.active_sftp_mut() {
|
||||
sftp_mut.selected_entries.clear();
|
||||
}
|
||||
this.show_transfers_dialog = true;
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok::<(), anyhow::Error>(())
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub(crate) fn upload_sftp_files_batch(&mut self, paths: Vec<String>, cx: &mut Context<Self>) {
|
||||
if paths.is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Some(sftp) = self.active_sftp() {
|
||||
if let Some(handle) = self.active_sftp_handle() {
|
||||
tracing::info!(
|
||||
"[sftp] initiating batch upload of {} files to '{}'",
|
||||
paths.len(),
|
||||
sftp.current_path
|
||||
);
|
||||
let _ = handle.commands.send(crate::sftp::SftpCommand::UploadPaths {
|
||||
locals: paths,
|
||||
remote_dir: sftp.current_path.clone(),
|
||||
});
|
||||
self.show_transfers_dialog = true;
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,20 @@
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
ffi::OsStr,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
use sysinfo::{Disks, Networks, System};
|
||||
|
||||
/// Known virtual/ram filesystems to exclude from disk monitoring.
|
||||
fn is_real_filesystem(fs: &OsStr) -> bool {
|
||||
!matches!(
|
||||
fs.to_str(),
|
||||
Some("tmpfs" | "devtmpfs" | "ramfs" | "overlay" | "aufs")
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct DiskSample {
|
||||
pub mount: String,
|
||||
@@ -22,7 +31,10 @@ pub struct SystemSnapshot {
|
||||
pub swap_detail: String,
|
||||
pub net_rx: String,
|
||||
pub net_tx: String,
|
||||
pub net_rx_rate: u64,
|
||||
pub net_tx_rate: u64,
|
||||
pub disks: Vec<DiskSample>,
|
||||
pub total_swap: u64,
|
||||
}
|
||||
|
||||
pub struct SystemSampler {
|
||||
@@ -82,17 +94,25 @@ impl SystemSampler {
|
||||
self.last_tx_total = tx_total;
|
||||
self.last_instant = now;
|
||||
|
||||
let disks = self
|
||||
let mut disks: Vec<DiskSample> = self
|
||||
.disks
|
||||
.iter()
|
||||
.filter(|disk| disk.total_space() > 0)
|
||||
.take(4)
|
||||
.filter(|disk| disk.total_space() > 0 && is_real_filesystem(disk.file_system()))
|
||||
.map(|disk| DiskSample {
|
||||
mount: disk.mount_point().to_string_lossy().to_string(),
|
||||
available_bytes: disk.available_space(),
|
||||
total_bytes: disk.total_space(),
|
||||
})
|
||||
.collect();
|
||||
disks.sort_by(|a, b| {
|
||||
if a.mount == "/" {
|
||||
return std::cmp::Ordering::Less;
|
||||
}
|
||||
if b.mount == "/" {
|
||||
return std::cmp::Ordering::Greater;
|
||||
}
|
||||
a.mount.cmp(&b.mount)
|
||||
});
|
||||
|
||||
SystemSnapshot {
|
||||
cpu_percent,
|
||||
@@ -102,7 +122,10 @@ impl SystemSampler {
|
||||
swap_detail: format!("{}/{}", format_bytes(swap_used), format_bytes(swap_total)),
|
||||
net_rx: format!("{}/s", format_bytes(rx_rate)),
|
||||
net_tx: format!("{}/s", format_bytes(tx_rate)),
|
||||
net_rx_rate: rx_rate,
|
||||
net_tx_rate: tx_rate,
|
||||
disks,
|
||||
total_swap: swap_total,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -176,6 +199,20 @@ pub fn remote_snapshot_from_kv(raw: &str) -> Result<SystemSnapshot> {
|
||||
let rx_rate = parse_u64(&kv, "NET_RX");
|
||||
let tx_rate = parse_u64(&kv, "NET_TX");
|
||||
|
||||
// Safety filter: exclude entries with zero/negligible total size
|
||||
// (catches any virtual fs lines that slipped past the script filter)
|
||||
disks.retain(|d| d.total_bytes >= 1024 * 1024);
|
||||
|
||||
disks.sort_by(|a, b| {
|
||||
if a.mount == "/" {
|
||||
return std::cmp::Ordering::Less;
|
||||
}
|
||||
if b.mount == "/" {
|
||||
return std::cmp::Ordering::Greater;
|
||||
}
|
||||
a.mount.cmp(&b.mount)
|
||||
});
|
||||
|
||||
Ok(SystemSnapshot {
|
||||
cpu_percent: cpu_percent.clamp(0.0, 1.0),
|
||||
mem_percent: ratio(mem_used, mem_total),
|
||||
@@ -184,7 +221,10 @@ pub fn remote_snapshot_from_kv(raw: &str) -> Result<SystemSnapshot> {
|
||||
swap_detail: format!("{}/{}", format_bytes(swap_used), format_bytes(swap_total)),
|
||||
net_rx: format!("{}/s", format_bytes(rx_rate)),
|
||||
net_tx: format!("{}/s", format_bytes(tx_rate)),
|
||||
net_rx_rate: rx_rate,
|
||||
net_tx_rate: tx_rate,
|
||||
disks,
|
||||
total_swap: swap_total,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
use gpui::{Bounds, Hsla, Path, Pixels, Window, fill, point, px, size};
|
||||
|
||||
pub fn is_custom_block_supported(c: char) -> bool {
|
||||
match c as u32 {
|
||||
0x2580..=0x258F | 0x2590 | 0x2594..=0x259F => true, // Block Elements
|
||||
0x2500 | 0x2502 | 0x250C | 0x2510 | 0x2514 | 0x2518 | 0x251C | 0x2524 | 0x252C | 0x2534
|
||||
| 0x253C => true, // Light lines
|
||||
0x2501 | 0x2503 | 0x250F | 0x2513 | 0x2517 | 0x251B | 0x2523 | 0x252B | 0x2533 | 0x253B
|
||||
| 0x254B => true, // Heavy lines
|
||||
0xE0B0..=0xE0B6 => true, // Powerline
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn paint_custom_block(
|
||||
window: &mut Window,
|
||||
c: char,
|
||||
raw_x: f32,
|
||||
raw_y: f32,
|
||||
raw_w: f32,
|
||||
raw_h: f32,
|
||||
color: Hsla,
|
||||
) -> bool {
|
||||
let scale = window.scale_factor();
|
||||
|
||||
let x = px((raw_x * scale).round() / scale);
|
||||
let y = px((raw_y * scale).round() / scale);
|
||||
let w = px(((raw_x + raw_w) * scale).round() / scale) - x;
|
||||
let h = px(((raw_y + raw_h) * scale).round() / scale) - y;
|
||||
|
||||
let mut painted = false;
|
||||
|
||||
// Helper to paint a quad snapping its true physical boundaries to integer physical pixels
|
||||
let mut paint_quad = |qx: Pixels, qy: Pixels, qw: Pixels, qh: Pixels| {
|
||||
let l = px((qx.as_f32() * scale).round() / scale);
|
||||
let t = px((qy.as_f32() * scale).round() / scale);
|
||||
let r = px(((qx + qw).as_f32() * scale).round() / scale);
|
||||
let b = px(((qy + qh).as_f32() * scale).round() / scale);
|
||||
window.paint_quad(fill(Bounds::new(point(l, t), size(r - l, b - t)), color));
|
||||
painted = true;
|
||||
};
|
||||
|
||||
let px_1 = px(1.0);
|
||||
let px_2 = px(2.0);
|
||||
|
||||
// Box drawing thickness
|
||||
let light = (w * 0.1).max(px_1);
|
||||
let heavy = (w * 0.2).max(px_2);
|
||||
let cx = x + w / 2.0;
|
||||
let cy = y + h / 2.0;
|
||||
|
||||
match c as u32 {
|
||||
// --- Block Elements (U+2580..=U+259F) ---
|
||||
0x2580 => paint_quad(x, y, w, h / 2.0),
|
||||
0x2581 => paint_quad(x, y + h * 7.0 / 8.0, w, h / 8.0),
|
||||
0x2582 => paint_quad(x, y + h * 3.0 / 4.0, w, h / 4.0),
|
||||
0x2583 => paint_quad(x, y + h * 5.0 / 8.0, w, h * 3.0 / 8.0),
|
||||
0x2584 => paint_quad(x, y + h / 2.0, w, h / 2.0),
|
||||
0x2585 => paint_quad(x, y + h * 3.0 / 8.0, w, h * 5.0 / 8.0),
|
||||
0x2586 => paint_quad(x, y + h / 4.0, w, h * 3.0 / 4.0),
|
||||
0x2587 => paint_quad(x, y + h / 8.0, w, h * 7.0 / 8.0),
|
||||
0x2588 => paint_quad(x, y, w, h),
|
||||
0x2589 => paint_quad(x, y, w * 7.0 / 8.0, h),
|
||||
0x258A => paint_quad(x, y, w * 3.0 / 4.0, h),
|
||||
0x258B => paint_quad(x, y, w * 5.0 / 8.0, h),
|
||||
0x258C => paint_quad(x, y, w / 2.0, h),
|
||||
0x258D => paint_quad(x, y, w * 3.0 / 8.0, h),
|
||||
0x258E => paint_quad(x, y, w / 4.0, h),
|
||||
0x258F => paint_quad(x, y, w / 8.0, h),
|
||||
0x2590 => paint_quad(x + w / 2.0, y, w / 2.0, h),
|
||||
0x2594 => paint_quad(x, y, w, h / 8.0),
|
||||
0x2595 => paint_quad(x + w * 7.0 / 8.0, y, w / 8.0, h),
|
||||
0x2596 => paint_quad(x, y + h / 2.0, w / 2.0, h / 2.0),
|
||||
0x2597 => paint_quad(x + w / 2.0, y + h / 2.0, w / 2.0, h / 2.0),
|
||||
0x2598 => paint_quad(x, y, w / 2.0, h / 2.0),
|
||||
0x2599 => {
|
||||
paint_quad(x, y, w / 2.0, h);
|
||||
paint_quad(x + w / 2.0, y + h / 2.0, w / 2.0, h / 2.0);
|
||||
}
|
||||
0x259A => {
|
||||
paint_quad(x, y, w / 2.0, h / 2.0);
|
||||
paint_quad(x + w / 2.0, y + h / 2.0, w / 2.0, h / 2.0);
|
||||
}
|
||||
0x259B => {
|
||||
paint_quad(x, y, w, h / 2.0);
|
||||
paint_quad(x, y + h / 2.0, w / 2.0, h / 2.0);
|
||||
}
|
||||
0x259C => {
|
||||
paint_quad(x, y, w, h / 2.0);
|
||||
paint_quad(x + w / 2.0, y + h / 2.0, w / 2.0, h / 2.0);
|
||||
}
|
||||
0x259D => paint_quad(x + w / 2.0, y, w / 2.0, h / 2.0),
|
||||
0x259E => {
|
||||
paint_quad(x + w / 2.0, y, w / 2.0, h / 2.0);
|
||||
paint_quad(x, y + h / 2.0, w / 2.0, h / 2.0);
|
||||
}
|
||||
0x259F => {
|
||||
paint_quad(x + w / 2.0, y, w / 2.0, h);
|
||||
paint_quad(x, y + h / 2.0, w / 2.0, h / 2.0);
|
||||
}
|
||||
|
||||
// --- Basic Box Drawing (U+2500..=U+257F) ---
|
||||
// Light lines
|
||||
0x2500 => paint_quad(x, cy - light / 2.0, w, light), // Horizontal
|
||||
0x2502 => paint_quad(cx - light / 2.0, y, light, h), // Vertical
|
||||
0x250C => {
|
||||
// Down + Right
|
||||
paint_quad(
|
||||
cx - light / 2.0,
|
||||
cy - light / 2.0,
|
||||
light,
|
||||
h / 2.0 + light / 2.0,
|
||||
);
|
||||
paint_quad(
|
||||
cx - light / 2.0,
|
||||
cy - light / 2.0,
|
||||
w / 2.0 + light / 2.0,
|
||||
light,
|
||||
);
|
||||
}
|
||||
0x2510 => {
|
||||
// Down + Left
|
||||
paint_quad(
|
||||
cx - light / 2.0,
|
||||
cy - light / 2.0,
|
||||
light,
|
||||
h / 2.0 + light / 2.0,
|
||||
);
|
||||
paint_quad(x, cy - light / 2.0, w / 2.0 + light / 2.0, light);
|
||||
}
|
||||
0x2514 => {
|
||||
// Up + Right
|
||||
paint_quad(cx - light / 2.0, y, light, h / 2.0 + light / 2.0);
|
||||
paint_quad(
|
||||
cx - light / 2.0,
|
||||
cy - light / 2.0,
|
||||
w / 2.0 + light / 2.0,
|
||||
light,
|
||||
);
|
||||
}
|
||||
0x2518 => {
|
||||
// Up + Left
|
||||
paint_quad(cx - light / 2.0, y, light, h / 2.0 + light / 2.0);
|
||||
paint_quad(x, cy - light / 2.0, w / 2.0 + light / 2.0, light);
|
||||
}
|
||||
0x251C => {
|
||||
// Vertical + Right
|
||||
paint_quad(cx - light / 2.0, y, light, h);
|
||||
paint_quad(
|
||||
cx - light / 2.0,
|
||||
cy - light / 2.0,
|
||||
w / 2.0 + light / 2.0,
|
||||
light,
|
||||
);
|
||||
}
|
||||
0x2524 => {
|
||||
// Vertical + Left
|
||||
paint_quad(cx - light / 2.0, y, light, h);
|
||||
paint_quad(x, cy - light / 2.0, w / 2.0 + light / 2.0, light);
|
||||
}
|
||||
0x252C => {
|
||||
// Horizontal + Down
|
||||
paint_quad(x, cy - light / 2.0, w, light);
|
||||
paint_quad(
|
||||
cx - light / 2.0,
|
||||
cy - light / 2.0,
|
||||
light,
|
||||
h / 2.0 + light / 2.0,
|
||||
);
|
||||
}
|
||||
0x2534 => {
|
||||
// Horizontal + Up
|
||||
paint_quad(x, cy - light / 2.0, w, light);
|
||||
paint_quad(cx - light / 2.0, y, light, h / 2.0 + light / 2.0);
|
||||
}
|
||||
0x253C => {
|
||||
// Vertical + Horizontal (Cross)
|
||||
paint_quad(x, cy - light / 2.0, w, light);
|
||||
paint_quad(cx - light / 2.0, y, light, h);
|
||||
}
|
||||
|
||||
// Heavy lines
|
||||
0x2501 => paint_quad(x, cy - heavy / 2.0, w, heavy), // Heavy Horizontal
|
||||
0x2503 => paint_quad(cx - heavy / 2.0, y, heavy, h), // Heavy Vertical
|
||||
0x250F => {
|
||||
// Heavy Down + Right
|
||||
paint_quad(
|
||||
cx - heavy / 2.0,
|
||||
cy - heavy / 2.0,
|
||||
heavy,
|
||||
h / 2.0 + heavy / 2.0,
|
||||
);
|
||||
paint_quad(
|
||||
cx - heavy / 2.0,
|
||||
cy - heavy / 2.0,
|
||||
w / 2.0 + heavy / 2.0,
|
||||
heavy,
|
||||
);
|
||||
}
|
||||
0x2513 => {
|
||||
// Heavy Down + Left
|
||||
paint_quad(
|
||||
cx - heavy / 2.0,
|
||||
cy - heavy / 2.0,
|
||||
heavy,
|
||||
h / 2.0 + heavy / 2.0,
|
||||
);
|
||||
paint_quad(x, cy - heavy / 2.0, w / 2.0 + heavy / 2.0, heavy);
|
||||
}
|
||||
0x2517 => {
|
||||
// Heavy Up + Right
|
||||
paint_quad(cx - heavy / 2.0, y, heavy, h / 2.0 + heavy / 2.0);
|
||||
paint_quad(
|
||||
cx - heavy / 2.0,
|
||||
cy - heavy / 2.0,
|
||||
w / 2.0 + heavy / 2.0,
|
||||
heavy,
|
||||
);
|
||||
}
|
||||
0x251B => {
|
||||
// Heavy Up + Left
|
||||
paint_quad(cx - heavy / 2.0, y, heavy, h / 2.0 + heavy / 2.0);
|
||||
paint_quad(x, cy - heavy / 2.0, w / 2.0 + heavy / 2.0, heavy);
|
||||
}
|
||||
0x2523 => {
|
||||
// Heavy Vertical + Right
|
||||
paint_quad(cx - heavy / 2.0, y, heavy, h);
|
||||
paint_quad(
|
||||
cx - heavy / 2.0,
|
||||
cy - heavy / 2.0,
|
||||
w / 2.0 + heavy / 2.0,
|
||||
heavy,
|
||||
);
|
||||
}
|
||||
0x252B => {
|
||||
// Heavy Vertical + Left
|
||||
paint_quad(cx - heavy / 2.0, y, heavy, h);
|
||||
paint_quad(x, cy - heavy / 2.0, w / 2.0 + heavy / 2.0, heavy);
|
||||
}
|
||||
0x2533 => {
|
||||
// Heavy Horizontal + Down
|
||||
paint_quad(x, cy - heavy / 2.0, w, heavy);
|
||||
paint_quad(
|
||||
cx - heavy / 2.0,
|
||||
cy - heavy / 2.0,
|
||||
heavy,
|
||||
h / 2.0 + heavy / 2.0,
|
||||
);
|
||||
}
|
||||
0x253B => {
|
||||
// Heavy Horizontal + Up
|
||||
paint_quad(x, cy - heavy / 2.0, w, heavy);
|
||||
paint_quad(cx - heavy / 2.0, y, heavy, h / 2.0 + heavy / 2.0);
|
||||
}
|
||||
0x254B => {
|
||||
// Heavy Vertical + Horizontal (Cross)
|
||||
paint_quad(x, cy - heavy / 2.0, w, heavy);
|
||||
paint_quad(cx - heavy / 2.0, y, heavy, h);
|
||||
}
|
||||
|
||||
// --- Powerline ---
|
||||
0xE0B0 => {
|
||||
// Rightward Solid Arrow
|
||||
let mut path = Path::new(point(x, y));
|
||||
path.line_to(point(x + w, y + h / 2.0));
|
||||
path.line_to(point(x, y + h));
|
||||
path.line_to(point(x, y));
|
||||
window.paint_path(path, color);
|
||||
painted = true;
|
||||
}
|
||||
0xE0B2 => {
|
||||
// Leftward Solid Arrow
|
||||
let mut path = Path::new(point(x + w, y));
|
||||
path.line_to(point(x, y + h / 2.0));
|
||||
path.line_to(point(x + w, y + h));
|
||||
path.line_to(point(x + w, y));
|
||||
window.paint_path(path, color);
|
||||
painted = true;
|
||||
}
|
||||
0xE0B1 => {
|
||||
// Rightward Line Arrow
|
||||
let t = px(1.0);
|
||||
let mut p = Path::new(point(x, y));
|
||||
p.line_to(point(x + w, y + h / 2.0));
|
||||
p.line_to(point(x + w - t, y + h / 2.0));
|
||||
p.line_to(point(x, y + t));
|
||||
p.line_to(point(x, y));
|
||||
window.paint_path(p, color);
|
||||
let mut p2 = Path::new(point(x, y + h));
|
||||
p2.line_to(point(x + w, y + h / 2.0));
|
||||
p2.line_to(point(x + w - t, y + h / 2.0));
|
||||
p2.line_to(point(x, y + h - t));
|
||||
p2.line_to(point(x, y + h));
|
||||
window.paint_path(p2, color);
|
||||
painted = true;
|
||||
}
|
||||
0xE0B3 => {
|
||||
// Leftward Line Arrow
|
||||
let t = px(1.0);
|
||||
let mut p = Path::new(point(x + w, y));
|
||||
p.line_to(point(x, y + h / 2.0));
|
||||
p.line_to(point(x + t, y + h / 2.0));
|
||||
p.line_to(point(x + w, y + t));
|
||||
p.line_to(point(x + w, y));
|
||||
window.paint_path(p, color);
|
||||
let mut p2 = Path::new(point(x + w, y + h));
|
||||
p2.line_to(point(x, y + h / 2.0));
|
||||
p2.line_to(point(x + t, y + h / 2.0));
|
||||
p2.line_to(point(x + w, y + h - t));
|
||||
p2.line_to(point(x + w, y + h));
|
||||
window.paint_path(p2, color);
|
||||
painted = true;
|
||||
}
|
||||
|
||||
// Half arches (Powerline rounded)
|
||||
0xE0B4 => {
|
||||
// Rightward Solid Semicircle
|
||||
let mut path = Path::new(point(x, y));
|
||||
path.curve_to(point(x, y + h), point(x + w * 2.0, y + h / 2.0));
|
||||
path.line_to(point(x, y));
|
||||
window.paint_path(path, color);
|
||||
painted = true;
|
||||
}
|
||||
0xE0B6 => {
|
||||
// Leftward Solid Semicircle
|
||||
let mut path = Path::new(point(x + w, y));
|
||||
path.curve_to(point(x + w, y + h), point(x - w, y + h / 2.0));
|
||||
path.line_to(point(x + w, y));
|
||||
window.paint_path(path, color);
|
||||
painted = true;
|
||||
}
|
||||
|
||||
_ => {}
|
||||
}
|
||||
|
||||
painted
|
||||
}
|
||||
@@ -11,6 +11,7 @@ use gpui::{
|
||||
use gpui_component::ActiveTheme as _;
|
||||
|
||||
use crate::Ashell;
|
||||
use crate::terminal::custom_blocks::{is_custom_block_supported, paint_custom_block};
|
||||
use crate::terminal::{RenderSnapshot, ViewportSelection};
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -138,12 +139,24 @@ pub struct PrepaintState {
|
||||
metrics: TerminalMetrics,
|
||||
rects: Vec<LayoutRect>,
|
||||
runs: Vec<BatchedTextRun>,
|
||||
custom_blocks: Vec<LayoutCustomBlock>,
|
||||
cursor: Option<CursorLayout>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct LayoutCustomBlock {
|
||||
c: char,
|
||||
row: i32,
|
||||
col: i32,
|
||||
cells: usize,
|
||||
color: Hsla,
|
||||
}
|
||||
|
||||
struct TerminalInputHandler {
|
||||
view: Entity<Ashell>,
|
||||
element_bounds: Bounds<Pixels>,
|
||||
cell_width: f32,
|
||||
line_height: f32,
|
||||
}
|
||||
|
||||
impl InputHandler for TerminalInputHandler {
|
||||
@@ -217,9 +230,12 @@ impl InputHandler for TerminalInputHandler {
|
||||
_window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Option<Bounds<Pixels>> {
|
||||
self.view
|
||||
.read(cx)
|
||||
.terminal_ime_bounds_for_range(range_utf16, self.element_bounds, _window)
|
||||
self.view.read(cx).terminal_ime_bounds_for_range(
|
||||
range_utf16,
|
||||
self.element_bounds,
|
||||
self.cell_width,
|
||||
self.line_height,
|
||||
)
|
||||
}
|
||||
|
||||
fn character_index_for_point(
|
||||
@@ -329,9 +345,13 @@ impl TerminalElement {
|
||||
}
|
||||
}
|
||||
|
||||
fn layout_grid(&self, cx: &App) -> (Vec<LayoutRect>, Vec<BatchedTextRun>) {
|
||||
fn layout_grid(
|
||||
&self,
|
||||
cx: &App,
|
||||
) -> (Vec<LayoutRect>, Vec<BatchedTextRun>, Vec<LayoutCustomBlock>) {
|
||||
let mut rects = Vec::new();
|
||||
let mut runs = Vec::new();
|
||||
let mut custom_blocks = Vec::new();
|
||||
let mut current_run: Option<BatchedTextRun> = None;
|
||||
|
||||
for render_cell in &self.snapshot.cells {
|
||||
@@ -350,7 +370,11 @@ impl TerminalElement {
|
||||
rects.push(LayoutRect {
|
||||
row: render_cell.row,
|
||||
col: render_cell.col,
|
||||
cells: 1,
|
||||
cells: if cell.flags.contains(Flags::WIDE_CHAR) {
|
||||
2
|
||||
} else {
|
||||
1
|
||||
},
|
||||
color: if selected {
|
||||
cx.theme().selection
|
||||
} else if cell.flags.contains(Flags::INVERSE) {
|
||||
@@ -369,6 +393,28 @@ impl TerminalElement {
|
||||
}
|
||||
|
||||
let style = self.cell_run_style(cell, cx);
|
||||
|
||||
// Box Drawing & Block Elements interception
|
||||
let is_custom_block = is_custom_block_supported(cell.c);
|
||||
|
||||
if is_custom_block {
|
||||
if let Some(run) = current_run.take() {
|
||||
runs.push(run);
|
||||
}
|
||||
custom_blocks.push(LayoutCustomBlock {
|
||||
c: cell.c,
|
||||
row: render_cell.row,
|
||||
col: render_cell.col,
|
||||
cells: if cell.flags.contains(Flags::WIDE_CHAR) {
|
||||
2
|
||||
} else {
|
||||
1
|
||||
},
|
||||
color: style.color,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(run) = current_run.as_mut() {
|
||||
if run.can_append(&style, render_cell.row, render_cell.col) {
|
||||
run.append(cell.c, cell.zerowidth());
|
||||
@@ -400,7 +446,7 @@ impl TerminalElement {
|
||||
runs.push(run);
|
||||
}
|
||||
|
||||
(merge_rects(rects), runs)
|
||||
(merge_rects(rects), runs, custom_blocks)
|
||||
}
|
||||
|
||||
fn cursor_layout(&self, cx: &App) -> Option<CursorLayout> {
|
||||
@@ -456,7 +502,7 @@ impl Element for TerminalElement {
|
||||
cx: &mut App,
|
||||
) -> Self::PrepaintState {
|
||||
let _ = self.base_text_style(cx);
|
||||
let (rects, runs) = self.layout_grid(cx);
|
||||
let (rects, runs, custom_blocks) = self.layout_grid(cx);
|
||||
PrepaintState {
|
||||
bounds,
|
||||
metrics: TerminalMetrics {
|
||||
@@ -465,6 +511,7 @@ impl Element for TerminalElement {
|
||||
},
|
||||
rects,
|
||||
runs,
|
||||
custom_blocks,
|
||||
cursor: self.cursor_layout(cx),
|
||||
}
|
||||
}
|
||||
@@ -487,11 +534,29 @@ impl Element for TerminalElement {
|
||||
run.paint(prepaint.bounds.origin, prepaint.metrics, window, cx);
|
||||
}
|
||||
|
||||
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();
|
||||
paint_custom_block(
|
||||
window,
|
||||
block.c,
|
||||
x,
|
||||
y,
|
||||
prepaint.metrics.cell_width.as_f32() * block.cells as f32,
|
||||
prepaint.metrics.line_height.as_f32(),
|
||||
block.color,
|
||||
);
|
||||
}
|
||||
|
||||
window.handle_input(
|
||||
&self.focus_handle,
|
||||
TerminalInputHandler {
|
||||
view: self.view.clone(),
|
||||
element_bounds: prepaint.bounds,
|
||||
cell_width: prepaint.metrics.cell_width.as_f32(),
|
||||
line_height: prepaint.metrics.line_height.as_f32(),
|
||||
},
|
||||
cx,
|
||||
);
|
||||
@@ -0,0 +1,487 @@
|
||||
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,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
Ashell, TerminalBacktabKey, TerminalTabKey,
|
||||
terminal::{BackendCommand, encode_key},
|
||||
};
|
||||
|
||||
impl Ashell {
|
||||
pub(crate) fn on_terminal_key_down(
|
||||
&mut self,
|
||||
event: &KeyDownEvent,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
// Pane navigation: Alt + h/j/k/l
|
||||
if event.keystroke.modifiers.alt
|
||||
&& !event.keystroke.modifiers.shift
|
||||
&& !event.keystroke.modifiers.control
|
||||
&& !event.keystroke.modifiers.platform
|
||||
{
|
||||
match event.keystroke.key.to_ascii_lowercase().as_str() {
|
||||
"h" => self.focus_adjacent_pane("left"),
|
||||
"j" => self.focus_adjacent_pane("down"),
|
||||
"k" => self.focus_adjacent_pane("up"),
|
||||
"l" => self.focus_adjacent_pane("right"),
|
||||
"q" => {
|
||||
if let Some(active_id) = self.active_tab.clone() {
|
||||
self.close_tab(active_id, cx);
|
||||
}
|
||||
}
|
||||
_ => return,
|
||||
}
|
||||
window.prevent_default();
|
||||
cx.stop_propagation();
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
|
||||
// Pane split: Shift+Alt + h/j/k/l
|
||||
if event.keystroke.modifiers.shift
|
||||
&& event.keystroke.modifiers.alt
|
||||
&& !event.keystroke.modifiers.control
|
||||
&& !event.keystroke.modifiers.platform
|
||||
{
|
||||
let direction = match event.keystroke.key.to_ascii_lowercase().as_str() {
|
||||
"h" => Some("left"),
|
||||
"j" => Some("down"),
|
||||
"k" => Some("up"),
|
||||
"l" => Some("right"),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(dir) = direction {
|
||||
self.split_current_pane(dir, cx);
|
||||
window.prevent_default();
|
||||
cx.stop_propagation();
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if event.keystroke.modifiers.secondary() && event.keystroke.key == "," {
|
||||
self.show_settings_dialog(window, cx);
|
||||
window.prevent_default();
|
||||
cx.stop_propagation();
|
||||
return;
|
||||
}
|
||||
if event.keystroke.modifiers.shift
|
||||
&& event.keystroke.modifiers.secondary()
|
||||
&& event.keystroke.key == "o"
|
||||
{
|
||||
self.show_selector_dialog(window, cx);
|
||||
window.prevent_default();
|
||||
cx.stop_propagation();
|
||||
return;
|
||||
}
|
||||
if event.keystroke.modifiers.secondary() && event.keystroke.key.eq_ignore_ascii_case("c") {
|
||||
if let Some(text) = self.active_terminal_selection_text() {
|
||||
cx.write_to_clipboard(ClipboardItem::new_string(text));
|
||||
window.prevent_default();
|
||||
cx.stop_propagation();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if event.keystroke.modifiers.secondary() && event.keystroke.key.eq_ignore_ascii_case("v") {
|
||||
if let Some(clipboard) = cx.read_from_clipboard() {
|
||||
if let Some(text) = clipboard.text() {
|
||||
self.paste_into_terminal(&text, window, cx);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if event.prefer_character_input {
|
||||
if let Some(text) = event.keystroke.key_char.as_deref() {
|
||||
if !text.is_empty()
|
||||
&& !event.keystroke.modifiers.control
|
||||
&& !event.keystroke.modifiers.function
|
||||
&& !event.keystroke.modifiers.platform
|
||||
{
|
||||
self.send_terminal_input(text.as_bytes().to_vec(), window, cx);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(active_id) = self.active_tab.clone() else {
|
||||
return;
|
||||
};
|
||||
let Some(tab) = self.tabs.iter_mut().find(|t| t.id == active_id) else {
|
||||
return;
|
||||
};
|
||||
|
||||
if tab.render_snapshot().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));
|
||||
window.prevent_default();
|
||||
cx.stop_propagation();
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn on_terminal_tab_action(
|
||||
&mut self,
|
||||
_: &TerminalTabKey,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.send_terminal_input(vec![b'\t'], window, cx);
|
||||
}
|
||||
|
||||
pub(crate) fn on_terminal_backtab_action(
|
||||
&mut self,
|
||||
_: &TerminalBacktabKey,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.send_terminal_input(b"\x1b[Z".to_vec(), window, cx);
|
||||
}
|
||||
|
||||
fn send_terminal_input(&mut self, bytes: Vec<u8>, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(active_id) = self.active_tab.clone() else {
|
||||
return;
|
||||
};
|
||||
let Some(tab) = self.tabs.iter_mut().find(|t| t.id == active_id) else {
|
||||
return;
|
||||
};
|
||||
|
||||
if tab.render_snapshot().display_offset > 0 {
|
||||
tab.scroll_to_bottom();
|
||||
}
|
||||
|
||||
tab.clear_selection();
|
||||
tab.backend.send(BackendCommand::Input(bytes));
|
||||
window.prevent_default();
|
||||
cx.stop_propagation();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn active_terminal_selection_text(&self) -> Option<String> {
|
||||
let active_id = self.active_tab.as_ref()?;
|
||||
self.tabs
|
||||
.iter()
|
||||
.find(|tab| &tab.id == active_id)
|
||||
.and_then(|tab| tab.selection_text())
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == active_id) else {
|
||||
return;
|
||||
};
|
||||
|
||||
if tab.render_snapshot().display_offset > 0 {
|
||||
tab.scroll_to_bottom();
|
||||
}
|
||||
tab.clear_selection();
|
||||
tab.paste_text(text);
|
||||
window.prevent_default();
|
||||
cx.stop_propagation();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn terminal_accepts_text_input(&self) -> bool {
|
||||
self.active_tab.is_some()
|
||||
}
|
||||
|
||||
pub(crate) fn terminal_marked_text_range(&self) -> Option<Range<usize>> {
|
||||
self.terminal_marked_text
|
||||
.as_ref()
|
||||
.map(|text| 0..text.encode_utf16().count())
|
||||
}
|
||||
|
||||
pub(crate) fn set_terminal_marked_text(
|
||||
&mut self,
|
||||
text: String,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.terminal_marked_text = if text.is_empty() { None } else { Some(text) };
|
||||
window.invalidate_character_coordinates();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn clear_terminal_marked_text(
|
||||
&mut self,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if self.terminal_marked_text.take().is_some() {
|
||||
window.invalidate_character_coordinates();
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn commit_terminal_ime_text(
|
||||
&mut self,
|
||||
text: &str,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(active_id) = self.active_tab.clone() else {
|
||||
return;
|
||||
};
|
||||
let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == active_id) else {
|
||||
return;
|
||||
};
|
||||
|
||||
if tab.render_snapshot().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()));
|
||||
window.invalidate_character_coordinates();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn on_terminal_right_click(
|
||||
&mut self,
|
||||
_event: &MouseDownEvent,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if !self.config.right_click_copy_paste() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut handled = false;
|
||||
if let Some(text) = self.active_terminal_selection_text() {
|
||||
if !text.is_empty() {
|
||||
cx.write_to_clipboard(gpui::ClipboardItem::new_string(text));
|
||||
|
||||
let active_id = self.active_tab.clone();
|
||||
if let Some(active_id) = active_id {
|
||||
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == active_id) {
|
||||
tab.clear_selection();
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
if !handled {
|
||||
if let Some(clipboard_item) = cx.read_from_clipboard() {
|
||||
if let Some(text) = clipboard_item.text() {
|
||||
if !text.is_empty() {
|
||||
self.paste_into_terminal(&text, window, cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn begin_terminal_selection(
|
||||
&mut self,
|
||||
event: &MouseDownEvent,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let click_count = event.click_count.max(1);
|
||||
let selection_type = match click_count {
|
||||
1 => SelectionType::Simple,
|
||||
2 => SelectionType::Semantic,
|
||||
3 => SelectionType::Lines,
|
||||
_ => SelectionType::Simple,
|
||||
};
|
||||
let Some((row, col, side)) = self.terminal_grid_point_and_side(event.position) else {
|
||||
return;
|
||||
};
|
||||
let Some(active_id) = self.active_tab.clone() else {
|
||||
return;
|
||||
};
|
||||
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == active_id) {
|
||||
tab.begin_selection(row, col, side, selection_type);
|
||||
self.terminal_selecting = true;
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn on_terminal_mouse_move(
|
||||
&mut self,
|
||||
event: &MouseMoveEvent,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
// Handle split drag
|
||||
if self.dragging_splitter.is_some() {
|
||||
if event.pressed_button == Some(MouseButton::Left) {
|
||||
self.on_split_drag_move(event, window, cx);
|
||||
cx.notify();
|
||||
} else {
|
||||
self.end_drag_split();
|
||||
cx.notify();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if !self.terminal_selecting || event.pressed_button != Some(MouseButton::Left) {
|
||||
return;
|
||||
}
|
||||
let Some((row, col, side)) = self.terminal_grid_point_and_side(event.position) else {
|
||||
return;
|
||||
};
|
||||
let Some(active_id) = self.active_tab.clone() else {
|
||||
return;
|
||||
};
|
||||
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == active_id) {
|
||||
tab.update_selection(row, col, side);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn on_terminal_mouse_up(
|
||||
&mut self,
|
||||
_event: &MouseUpEvent,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if self.dragging_splitter.is_some() {
|
||||
self.end_drag_split();
|
||||
}
|
||||
self.terminal_selecting = false;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn terminal_grid_point_and_side(
|
||||
&self,
|
||||
position: Point<Pixels>,
|
||||
) -> Option<(usize, usize, Side)> {
|
||||
let active_id = self.active_tab.as_ref()?;
|
||||
let bounds = self.terminal_bounds.get(active_id)?;
|
||||
if !bounds.contains(&position) {
|
||||
// Try other pane bounds
|
||||
for (_, b) in &self.terminal_bounds {
|
||||
if b.contains(&position) {
|
||||
// Found a different pane - focus it
|
||||
// (this path is for click-to-focus; handled via focus_terminal)
|
||||
return None;
|
||||
}
|
||||
}
|
||||
return None;
|
||||
}
|
||||
let local_x = (position.x - bounds.origin.x).max(px(0.));
|
||||
let local_y = (position.y - bounds.origin.y).max(px(0.));
|
||||
let cell_width = px(self.terminal_cell_width());
|
||||
let line_height = px(self.terminal_line_height());
|
||||
let snapshot = self.active_snapshot()?;
|
||||
let max_col = snapshot.cols.saturating_sub(1);
|
||||
let max_row = snapshot.rows.saturating_sub(1);
|
||||
let col = ((local_x / cell_width).floor() as usize).min(max_col);
|
||||
let row = ((local_y / line_height).floor() as usize).min(max_row);
|
||||
let cell_offset_x = px(local_x.as_f32() % cell_width.as_f32());
|
||||
let side = if cell_offset_x >= (cell_width / 2.) {
|
||||
Side::Right
|
||||
} else {
|
||||
Side::Left
|
||||
};
|
||||
Some((row, col, side))
|
||||
}
|
||||
|
||||
pub(crate) fn on_terminal_scroll(
|
||||
&mut self,
|
||||
event: &ScrollWheelEvent,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(active_id) = self.active_tab.clone() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Get coordinates before mutably borrowing tabs
|
||||
let grid_point = self.terminal_grid_point_and_side(event.position);
|
||||
|
||||
let line_height = self.terminal_line_height();
|
||||
|
||||
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == active_id) {
|
||||
let delta_lines = match event.delta {
|
||||
ScrollDelta::Lines(point) => point.y.round() as i32,
|
||||
ScrollDelta::Pixels(point) => {
|
||||
tab.scroll_pixel_y += point.y.as_f32();
|
||||
let lines = (tab.scroll_pixel_y / line_height).trunc() as i32;
|
||||
tab.scroll_pixel_y -= (lines as f32) * line_height;
|
||||
lines
|
||||
}
|
||||
};
|
||||
|
||||
if delta_lines == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let mode = tab.term.mode();
|
||||
|
||||
let is_mouse_tracking = mode.intersects(
|
||||
alacritty_terminal::term::TermMode::MOUSE_REPORT_CLICK
|
||||
| alacritty_terminal::term::TermMode::MOUSE_MOTION
|
||||
| alacritty_terminal::term::TermMode::MOUSE_DRAG,
|
||||
);
|
||||
|
||||
let is_alternate_scroll = mode.contains(
|
||||
alacritty_terminal::term::TermMode::ALT_SCREEN
|
||||
| alacritty_terminal::term::TermMode::ALTERNATE_SCROLL,
|
||||
);
|
||||
|
||||
if is_mouse_tracking {
|
||||
if let Some((row, col, _)) = grid_point {
|
||||
let sgr = mode.contains(alacritty_terminal::term::TermMode::SGR_MOUSE);
|
||||
let button = if delta_lines > 0 { 64 } else { 65 };
|
||||
let times = delta_lines.abs();
|
||||
let mut bytes = Vec::new();
|
||||
for _ in 0..times {
|
||||
if sgr {
|
||||
bytes.extend_from_slice(
|
||||
format!("\x1b[<{};{};{}M", button, col + 1, row + 1).as_bytes(),
|
||||
);
|
||||
} else {
|
||||
if col < 223 && row < 223 {
|
||||
bytes.extend_from_slice(b"\x1b[M");
|
||||
bytes.push(button as u8 + 32);
|
||||
bytes.push(col as u8 + 33);
|
||||
bytes.push(row as u8 + 33);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !bytes.is_empty() {
|
||||
tab.backend
|
||||
.send(crate::terminal::BackendCommand::Input(bytes));
|
||||
}
|
||||
}
|
||||
window.prevent_default();
|
||||
cx.stop_propagation();
|
||||
return;
|
||||
} else if is_alternate_scroll {
|
||||
let times = delta_lines.abs();
|
||||
let code = if delta_lines > 0 { b'A' } else { b'B' };
|
||||
let mut bytes = Vec::with_capacity((times * 3) as usize);
|
||||
for _ in 0..times {
|
||||
bytes.extend_from_slice(&[b'\x1b', b'O', code]);
|
||||
}
|
||||
if !bytes.is_empty() {
|
||||
tab.backend
|
||||
.send(crate::terminal::BackendCommand::Input(bytes));
|
||||
}
|
||||
window.prevent_default();
|
||||
cx.stop_propagation();
|
||||
return;
|
||||
}
|
||||
|
||||
tab.scroll_history(delta_lines);
|
||||
window.prevent_default();
|
||||
cx.stop_propagation();
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
pub mod custom_blocks;
|
||||
pub mod element;
|
||||
pub mod input;
|
||||
|
||||
use std::sync::mpsc::Sender;
|
||||
|
||||
use alacritty_terminal::{
|
||||
@@ -10,7 +14,7 @@ use alacritty_terminal::{
|
||||
};
|
||||
use gpui::Keystroke;
|
||||
|
||||
use crate::config::Session;
|
||||
use crate::session::config::Session;
|
||||
use crate::sftp::{PreviewData, RemoteEntry};
|
||||
use crate::system::SystemSnapshot;
|
||||
|
||||
@@ -24,6 +28,7 @@ pub enum TabKind {
|
||||
pub enum BackendCommand {
|
||||
Input(Vec<u8>),
|
||||
Resize { cols: u16, rows: u16 },
|
||||
SampleMetrics,
|
||||
Close,
|
||||
}
|
||||
|
||||
@@ -113,12 +118,12 @@ pub struct TerminalTab {
|
||||
pub status: String,
|
||||
pub connected: bool,
|
||||
pub session: Option<Session>,
|
||||
pub sftp: Option<SftpUiState>,
|
||||
processor: Processor,
|
||||
term: Term<TerminalListener>,
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
pub backend: BackendTx,
|
||||
pub scroll_pixel_y: f32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -202,15 +207,6 @@ impl TerminalTab {
|
||||
);
|
||||
tab.session = Some(session.clone());
|
||||
tab.connected = false;
|
||||
tab.sftp = Some(SftpUiState {
|
||||
current_path: "/".into(),
|
||||
status: t!("sftp_connecting").to_string(),
|
||||
entries: Vec::new(),
|
||||
selected_path: None,
|
||||
preview: None,
|
||||
selected_entries: std::collections::HashSet::new(),
|
||||
home_dir: "/".into(),
|
||||
});
|
||||
tab
|
||||
}
|
||||
|
||||
@@ -229,12 +225,12 @@ impl TerminalTab {
|
||||
status,
|
||||
connected: matches!(kind, TabKind::Local),
|
||||
session: None,
|
||||
sftp: None,
|
||||
processor: Processor::new(),
|
||||
term: new_term(100, 30, backend.clone(), id, events),
|
||||
cols: 100,
|
||||
rows: 30,
|
||||
backend,
|
||||
scroll_pixel_y: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,10 +239,19 @@ impl TerminalTab {
|
||||
}
|
||||
|
||||
pub fn resize(&mut self, cols: u16, rows: u16) {
|
||||
self.cols = cols.max(1);
|
||||
self.rows = rows.max(1);
|
||||
self.term.resize(TerminalSize::new(self.cols, self.rows));
|
||||
self.backend.send(BackendCommand::Resize { cols, rows });
|
||||
let new_cols = cols.max(1);
|
||||
let new_rows = rows.max(1);
|
||||
if self.cols != new_cols || self.rows != new_rows {
|
||||
self.cols = new_cols;
|
||||
self.rows = new_rows;
|
||||
tracing::info!(
|
||||
"[ui] terminal resized to {}x{} (cols x rows)",
|
||||
self.cols,
|
||||
self.rows
|
||||
);
|
||||
self.term.resize(TerminalSize::new(self.cols, self.rows));
|
||||
self.backend.send(BackendCommand::Resize { cols, rows });
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cursor_state(&self) -> Option<CursorState> {
|
||||
@@ -443,7 +448,6 @@ impl EventListener for TerminalListener {
|
||||
}
|
||||
}
|
||||
|
||||
use rust_i18n::t;
|
||||
fn new_term(
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
@@ -680,7 +684,8 @@ pub enum TransferState {
|
||||
Paused,
|
||||
Completed,
|
||||
Failed(String),
|
||||
Cancelled,
|
||||
Interrupted(String), // 中断传输:包含原因(例如 "User cancelled", "Network timeout")
|
||||
Zombie(String), // 程序重启后残留的 Running/Paused 任务
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
Reference in New Issue
Block a user