mirror of
https://github.com/rust-kotlin/ashell.git
synced 2026-10-03 16:00:18 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d224506fd5 | ||
|
|
d175135529 | ||
|
|
c769b982b4 | ||
|
|
ee68835972 | ||
|
|
8a535fad5a | ||
|
|
90c07e16e1 | ||
|
|
33a3d624b7 | ||
|
|
78f8626217 | ||
|
|
743a5f9fe7 | ||
|
|
99a954b0b5 | ||
|
|
0bd04a98f0 | ||
|
|
df44d17ade | ||
|
|
0e4c4cdc69 | ||
|
|
227966319c | ||
|
|
205ceba20c | ||
|
|
0ccbf9573b | ||
|
|
8193223dc2 | ||
|
|
805cec5000 | ||
|
|
3151757c4f | ||
|
|
57c55e358c | ||
|
|
81b160285d | ||
|
|
77206bd126 | ||
|
|
bb22718d3d |
Generated
+299
@@ -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"
|
||||
@@ -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"
|
||||
|
||||
+4
-2
@@ -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
|
||||
|
||||
@@ -113,3 +113,5 @@ copy_paste_hint: "Tip: Regardless of this switch, you can always use %{key} + C/
|
||||
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"
|
||||
|
||||
@@ -115,3 +115,5 @@ copy_paste_hint: "提示:不管是否开启此开关,都可以使用 %{key}
|
||||
edit_file: "编辑文件"
|
||||
edit_file_tooltip: "下载到临时目录,使用默认编辑器打开,并在保存后自动上传。"
|
||||
auto_saved_and_uploaded: "已自动保存并上传: %{base}"
|
||||
scroll: "滑动查看 ↓"
|
||||
clone: "克隆"
|
||||
|
||||
@@ -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";
|
||||
@@ -19,7 +19,7 @@ use rust_i18n::t;
|
||||
|
||||
use crate::{
|
||||
Ashell,
|
||||
config::AuthMethod,
|
||||
session::config::AuthMethod,
|
||||
system::format_bytes,
|
||||
};
|
||||
|
||||
@@ -101,7 +101,31 @@ impl Ashell {
|
||||
)
|
||||
})
|
||||
.when(!is_password, |this| {
|
||||
this.child(Input::new(&key_path_input).tab_index(5)).child(
|
||||
this.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.cursor_pointer()
|
||||
.on_mouse_down(
|
||||
MouseButton::Left,
|
||||
window.listener_for(&view, |this, _, window, cx| {
|
||||
this.pick_ssh_key_path(window, cx);
|
||||
}),
|
||||
)
|
||||
.child(Input::new(&key_path_input).tab_index(5)),
|
||||
)
|
||||
.child(
|
||||
Button::new("clear-key-path")
|
||||
.ghost()
|
||||
.icon(IconName::Close)
|
||||
.on_click(window.listener_for(&view, |this, _, window, cx| {
|
||||
Self::set_input_value(&this.key_path_input, "", window, cx);
|
||||
}))
|
||||
),
|
||||
)
|
||||
.child(
|
||||
Input::new(&key_inline_input).h(px(128.)).tab_index(6),
|
||||
)
|
||||
})
|
||||
@@ -434,9 +458,8 @@ impl Ashell {
|
||||
.icon(IconName::Pause)
|
||||
.on_click(window.listener_for(&view, {
|
||||
let id = t.info.id.clone();
|
||||
let tab_id = t.tab_id.clone();
|
||||
move |this, _, _, _| {
|
||||
if let Some(handle) = this.sftp_handles.get(&tab_id) {
|
||||
if let Some(handle) = this.active_sftp_handle() {
|
||||
handle.pause_transfer(id.clone());
|
||||
}
|
||||
}
|
||||
@@ -450,9 +473,8 @@ impl Ashell {
|
||||
.icon(IconName::Close)
|
||||
.on_click(window.listener_for(&view, {
|
||||
let id = t.info.id.clone();
|
||||
let tab_id = t.tab_id.clone();
|
||||
move |this, _, _, _| {
|
||||
if let Some(handle) = this.sftp_handles.get(&tab_id) {
|
||||
if let Some(handle) = this.active_sftp_handle() {
|
||||
handle.cancel_transfer(id.clone());
|
||||
}
|
||||
}
|
||||
@@ -470,9 +492,8 @@ impl Ashell {
|
||||
.icon(IconName::Play)
|
||||
.on_click(window.listener_for(&view, {
|
||||
let id = t.info.id.clone();
|
||||
let tab_id = t.tab_id.clone();
|
||||
move |this, _, _, _| {
|
||||
if let Some(handle) = this.sftp_handles.get(&tab_id) {
|
||||
if let Some(handle) = this.active_sftp_handle() {
|
||||
handle.resume_transfer(id.clone());
|
||||
}
|
||||
}
|
||||
@@ -486,9 +507,8 @@ impl Ashell {
|
||||
.icon(IconName::Close)
|
||||
.on_click(window.listener_for(&view, {
|
||||
let id = t.info.id.clone();
|
||||
let tab_id = t.tab_id.clone();
|
||||
move |this, _, _, _| {
|
||||
if let Some(handle) = this.sftp_handles.get(&tab_id) {
|
||||
if let Some(handle) = this.active_sftp_handle() {
|
||||
handle.cancel_transfer(id.clone());
|
||||
}
|
||||
}
|
||||
@@ -698,19 +718,15 @@ impl Ashell {
|
||||
selected_entries.clone().into_iter().collect();
|
||||
move |_, window, cx| {
|
||||
view.update(cx, |this, cx| {
|
||||
if let Some(id) = this.active_tab.clone() {
|
||||
if let Some(handle) = this.sftp_handles.get(&id) {
|
||||
let _ = handle.commands.send(
|
||||
crate::sftp::SftpCommand::DeletePaths(
|
||||
paths_to_delete.clone(),
|
||||
),
|
||||
);
|
||||
}
|
||||
if let Some(tab) = this.tabs.iter_mut().find(|t| t.id == id) {
|
||||
if let Some(sftp) = tab.sftp.as_mut() {
|
||||
sftp.selected_entries.clear();
|
||||
}
|
||||
}
|
||||
if let Some(handle) = this.active_sftp_handle() {
|
||||
let _ = handle.commands.send(
|
||||
crate::sftp::SftpCommand::DeletePaths(
|
||||
paths_to_delete.clone(),
|
||||
),
|
||||
);
|
||||
}
|
||||
if let Some(sftp) = this.active_sftp_mut() {
|
||||
sftp.selected_entries.clear();
|
||||
}
|
||||
cx.notify();
|
||||
});
|
||||
@@ -827,21 +843,15 @@ impl Ashell {
|
||||
let view = view.clone();
|
||||
move |_, window, cx| {
|
||||
view.update(cx, |this, cx| {
|
||||
if let Some(id) = this.active_tab.clone() {
|
||||
if let Some(handle) = this.sftp_handles.get(&id) {
|
||||
let _ = handle.commands.send(
|
||||
crate::sftp::SftpCommand::DeletePaths(
|
||||
paths_to_delete.clone(),
|
||||
),
|
||||
);
|
||||
}
|
||||
if let Some(tab) =
|
||||
this.tabs.iter_mut().find(|t| t.id == id)
|
||||
{
|
||||
if let Some(sftp) = tab.sftp.as_mut() {
|
||||
sftp.selected_entries.clear();
|
||||
}
|
||||
}
|
||||
if let Some(handle) = this.active_sftp_handle() {
|
||||
let _ = handle.commands.send(
|
||||
crate::sftp::SftpCommand::DeletePaths(
|
||||
paths_to_delete.clone(),
|
||||
),
|
||||
);
|
||||
}
|
||||
if let Some(sftp) = this.active_sftp_mut() {
|
||||
sftp.selected_entries.clear();
|
||||
}
|
||||
cx.notify();
|
||||
});
|
||||
+217
-69
@@ -1,3 +1,9 @@
|
||||
pub mod constants;
|
||||
pub mod dialogs;
|
||||
pub mod startup;
|
||||
pub mod theme;
|
||||
pub mod ui;
|
||||
|
||||
use std::{
|
||||
cell::{Cell, RefCell},
|
||||
collections::HashMap,
|
||||
@@ -22,13 +28,104 @@ use rust_i18n::t;
|
||||
use tokio::runtime::Runtime;
|
||||
|
||||
use crate::{
|
||||
config::{AuthMethod, ConfigStore},
|
||||
sftp::SftpHandle,
|
||||
session::config::{AuthMethod, ConfigStore},
|
||||
system::{SystemSampler, SystemSnapshot},
|
||||
terminal::{self, BackendEvent, TabKind, TerminalTab},
|
||||
ssh_terminal,
|
||||
|
||||
};
|
||||
|
||||
#[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,
|
||||
@@ -114,17 +211,19 @@ pub(crate) struct Ashell {
|
||||
pub(crate) ui_font_family: SharedString,
|
||||
pub(crate) terminal_font_family: SharedString,
|
||||
pub(crate) tabs: Vec<TerminalTab>,
|
||||
pub(crate) sftp_handles: HashMap<String, SftpHandle>,
|
||||
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) terminal_scrollbar: TerminalScrollbarHandle,
|
||||
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>,
|
||||
@@ -135,8 +234,12 @@ pub(crate) struct Ashell {
|
||||
pub(crate) transfers: Vec<crate::terminal::Transfer>,
|
||||
pub(crate) show_transfers_dialog: bool,
|
||||
pub(crate) system_status: Option<SharedString>,
|
||||
pub(crate) terminal_bounds: Option<Bounds<Pixels>>,
|
||||
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>,
|
||||
@@ -150,6 +253,9 @@ pub(crate) struct Ashell {
|
||||
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>,
|
||||
@@ -284,17 +390,21 @@ impl Ashell {
|
||||
ui_font_family,
|
||||
terminal_font_family,
|
||||
tabs: Vec::new(),
|
||||
sftp_handles: HashMap::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,
|
||||
terminal_scrollbar: TerminalScrollbarHandle::default(),
|
||||
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,
|
||||
@@ -305,9 +415,11 @@ impl Ashell {
|
||||
transfers: config.transfers(),
|
||||
show_transfers_dialog: false,
|
||||
system_status: None,
|
||||
terminal_bounds: 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(),
|
||||
@@ -320,6 +432,9 @@ impl Ashell {
|
||||
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,
|
||||
@@ -360,10 +475,8 @@ impl Ashell {
|
||||
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(id) = self.active_tab.clone() {
|
||||
if let Some(handle) = self.sftp_handles.get(&id) {
|
||||
let _ = handle.commands.send(crate::sftp::SftpCommand::CreateDir(path));
|
||||
}
|
||||
if let Some(handle) = self.active_sftp_handle() {
|
||||
let _ = handle.commands.send(crate::sftp::SftpCommand::CreateDir(path));
|
||||
}
|
||||
}
|
||||
self.sftp_creating_folder = false;
|
||||
@@ -381,16 +494,26 @@ impl Ashell {
|
||||
|
||||
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| {
|
||||
this.drain_backend_events();
|
||||
this.sample_system_if_due();
|
||||
let changed = this.drain_backend_events();
|
||||
let system_sampled = this.sample_system_if_due();
|
||||
this.sync_theme_if_due(cx);
|
||||
cx.notify();
|
||||
if changed || system_sampled {
|
||||
cx.notify();
|
||||
idle_frames = 0;
|
||||
} else {
|
||||
idle_frames += 1;
|
||||
if idle_frames >= 60 {
|
||||
cx.notify();
|
||||
idle_frames = 0;
|
||||
}
|
||||
}
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
@@ -401,9 +524,11 @@ impl Ashell {
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub(crate) fn drain_backend_events(&mut self) {
|
||||
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) {
|
||||
@@ -417,6 +542,8 @@ impl Ashell {
|
||||
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();
|
||||
@@ -425,11 +552,12 @@ impl Ashell {
|
||||
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)
|
||||
.is_some_and(|progress| progress.tab_id == tab_id && !progress.failed)
|
||||
{
|
||||
self.connection_progress = None;
|
||||
}
|
||||
@@ -439,35 +567,35 @@ impl Ashell {
|
||||
path,
|
||||
entries,
|
||||
} => {
|
||||
if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == tab_id) {
|
||||
if let Some(sftp) = tab.sftp.as_mut() {
|
||||
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;
|
||||
if self.active_tab.as_deref() == Some(tab_id.as_str()) {
|
||||
self.pending_sftp_path_sync = Some(sftp.current_path.clone());
|
||||
}
|
||||
self.pending_sftp_path_sync = Some(sftp.current_path.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
BackendEvent::SftpPreview { tab_id, preview } => {
|
||||
if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == tab_id) {
|
||||
if let Some(sftp) = tab.sftp.as_mut() {
|
||||
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(tab) = self.tabs.iter_mut().find(|t| t.id == tab_id) {
|
||||
if let Some(sftp) = tab.sftp.as_mut() {
|
||||
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();
|
||||
}
|
||||
}
|
||||
self.status = text.into();
|
||||
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.active_tab.as_deref() == Some(tab_id.as_str()) {
|
||||
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);
|
||||
@@ -486,7 +614,7 @@ impl Ashell {
|
||||
}
|
||||
BackendEvent::RemoteSystemUnavailable { tab_id, reason } => {
|
||||
self.remote_sample_in_flight = false;
|
||||
if self.active_tab.as_deref() == Some(tab_id.as_str()) {
|
||||
if self.system_tab_id.as_deref() == Some(tab_id.as_str()) {
|
||||
self.system_status = Some(reason.clone().into());
|
||||
self.status = reason.into();
|
||||
}
|
||||
@@ -503,28 +631,54 @@ impl Ashell {
|
||||
format!("{}@{}:{}", session.user, session.host, session.port)
|
||||
});
|
||||
}
|
||||
if self.active_tab.as_deref() == Some(tab_id.as_str()) {
|
||||
if self.system_tab_id.as_deref() == Some(tab_id.as_str()) {
|
||||
self.system_status = Some(reason.clone().into());
|
||||
}
|
||||
let is_graceful_exit = reason == "Terminal process exited"
|
||||
|| reason == "SSH session exited";
|
||||
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 {
|
||||
if !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,
|
||||
});
|
||||
}
|
||||
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();
|
||||
}
|
||||
@@ -565,8 +719,8 @@ impl Ashell {
|
||||
transfers_changed = true;
|
||||
}
|
||||
BackendEvent::SftpHome { tab_id, home } => {
|
||||
if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == tab_id) {
|
||||
if let Some(sftp) = tab.sftp.as_mut() {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -581,17 +735,18 @@ impl Ashell {
|
||||
if transfers_changed {
|
||||
self.config.set_transfers(self.transfers.clone());
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
pub(crate) fn sample_system_if_due(&mut self) {
|
||||
pub(crate) fn sample_system_if_due(&mut self) -> bool {
|
||||
if self.last_system_sample.elapsed() >= SystemSampler::interval() {
|
||||
self.last_system_sample = Instant::now();
|
||||
// When an SSH tab is active with remote data flowing, don't push
|
||||
// local machine data into history. Instead trigger a remote fetch
|
||||
// so both the current values and history reflect the remote server.
|
||||
if matches!(self.active_kind(), Some(TabKind::Ssh)) && self.system_status.is_none() {
|
||||
self.request_active_system_snapshot();
|
||||
return;
|
||||
// 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;
|
||||
@@ -608,7 +763,9 @@ impl Ashell {
|
||||
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>) {
|
||||
@@ -620,26 +777,17 @@ impl Ashell {
|
||||
}
|
||||
|
||||
pub(crate) fn request_active_system_snapshot(&mut self) {
|
||||
if let Some((tab_id, session)) = self.active_ssh_session() {
|
||||
if self.remote_sample_in_flight {
|
||||
return;
|
||||
}
|
||||
self.remote_sample_in_flight = true;
|
||||
let events = self.events_tx.clone();
|
||||
self.runtime.spawn(async move {
|
||||
match ssh_terminal::sample_remote_system(session).await {
|
||||
Ok(snapshot) => {
|
||||
let _ = events.send(BackendEvent::RemoteSystem { tab_id, snapshot });
|
||||
}
|
||||
Err(err) => {
|
||||
let _ = events.send(BackendEvent::RemoteSystemUnavailable {
|
||||
tab_id,
|
||||
reason: format!("remote metrics unavailable: {err:#}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
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(
|
||||
@@ -0,0 +1,269 @@
|
||||
use gpui::{App, AppContext as _, Bounds, WindowOptions, point, px, size};
|
||||
use gpui_component::Root;
|
||||
|
||||
use crate::session::config::ConfigStore;
|
||||
use crate::Ashell;
|
||||
|
||||
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();
|
||||
window.on_window_should_close(cx, move |window: &mut gpui::Window, cx: &mut gpui::App| {
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use gpui::{Anchor, Context, IntoElement, SharedString, Window, px};
|
||||
use anyhow::{Context as _, Result};
|
||||
use gpui::{Anchor, App, Context, IntoElement, SharedString, Window, px};
|
||||
use gpui_component::{
|
||||
ActiveTheme as _, IconName, Sizable as _, Theme, ThemeMode, ThemeRegistry,
|
||||
button::{Button, ButtonVariants as _},
|
||||
@@ -8,6 +9,33 @@ use rust_i18n::t;
|
||||
|
||||
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();
|
||||
+440
-145
@@ -22,12 +22,12 @@ use gpui_component::{
|
||||
use rust_i18n::t;
|
||||
|
||||
use crate::{
|
||||
Ashell, SIDEBAR_WIDTH, TERMINAL_KEY_CONTEXT,
|
||||
sftp_ops::is_editable_text_file,
|
||||
Ashell, PaneLayout,
|
||||
app::constants::{SIDEBAR_WIDTH, TERMINAL_KEY_CONTEXT},
|
||||
sftp::ops::is_editable_text_file,
|
||||
sftp::format_mtime,
|
||||
system::format_bytes,
|
||||
terminal::{self, TabKind},
|
||||
terminal_element,
|
||||
terminal::{self, TabKind, TerminalTab},
|
||||
};
|
||||
|
||||
impl Ashell {
|
||||
@@ -431,7 +431,8 @@ impl Ashell {
|
||||
.w_full()
|
||||
.h(px(28.))
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.px_3()
|
||||
.gap_2()
|
||||
.bg(bg)
|
||||
.hover(|style| style.bg(theme.muted.opacity(0.8)))
|
||||
.border_b_1()
|
||||
@@ -1034,15 +1035,6 @@ impl Ashell {
|
||||
let cpu_pct = self.system.cpu_percent;
|
||||
let mem_pct = self.system.mem_percent;
|
||||
let swap_pct = self.system.swap_percent;
|
||||
let disk_pct = if let Some(root_disk) = self.system.disks.iter().find(|d| d.mount == "/") {
|
||||
if root_disk.total_bytes > 0 {
|
||||
(root_disk.total_bytes - root_disk.available_bytes) as f64 / root_disk.total_bytes as f64
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
} else {
|
||||
0.0
|
||||
} as f32;
|
||||
|
||||
let cpu_color = cx.theme().chart_1;
|
||||
let mem_color = cx.theme().chart_2;
|
||||
@@ -1094,10 +1086,57 @@ impl Ashell {
|
||||
.child(
|
||||
h_flex()
|
||||
.justify_between()
|
||||
.items_center()
|
||||
.child(div().text_size(rems(0.85)).text_color(disk_color).child(t!("disk").to_string()))
|
||||
.child(div().text_size(rems(0.85)).text_color(muted_fg).child(format!("{:.1}%", disk_pct * 100.0))),
|
||||
.children(if self.system.disks.len() > 3 {
|
||||
Some(div().text_size(rems(0.65)).text_color(muted_fg).child(t!("scroll").to_string()))
|
||||
} else {
|
||||
None
|
||||
})
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.relative()
|
||||
.w_full()
|
||||
.child(
|
||||
v_flex()
|
||||
.id("sidebar-disk-scroll")
|
||||
.track_scroll(&self.disk_scroll_handle)
|
||||
.overflow_y_scroll()
|
||||
.max_h(px(90.))
|
||||
.gap_2()
|
||||
.children(self.system.disks.iter().map(|disk| {
|
||||
let pct = if disk.total_bytes > 0 {
|
||||
(disk.total_bytes - disk.available_bytes) as f64 / disk.total_bytes as f64 * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let mount_short = disk.mount.clone();
|
||||
let mount_id = format!("sidebar-disk-{}", mount_short);
|
||||
v_flex()
|
||||
.gap_0p5()
|
||||
.child(
|
||||
h_flex()
|
||||
.justify_between()
|
||||
.child(div().text_size(rems(0.75)).text_color(muted_fg).child(mount_short))
|
||||
.child(div().text_size(rems(0.75)).text_color(muted_fg).child(format!("{:.1}%", pct))),
|
||||
)
|
||||
.child(Progress::new(mount_id).value(pct as f32).color(disk_color).with_size(px(4.)).w_full())
|
||||
}))
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.top_0()
|
||||
.right_0()
|
||||
.bottom_0()
|
||||
.w(px(8.))
|
||||
.child(
|
||||
Scrollbar::vertical(&self.disk_scroll_handle)
|
||||
.scrollbar_show(ScrollbarShow::Scrolling)
|
||||
)
|
||||
)
|
||||
)
|
||||
.child(Progress::new("sidebar-disk").value(disk_pct * 100.0).color(disk_color).with_size(px(4.)).w_full())
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
@@ -1194,6 +1233,9 @@ impl Ashell {
|
||||
}),
|
||||
),
|
||||
)
|
||||
.when(self.config.monitoring_position() == "Sidebar", |this| {
|
||||
this.child(self.render_sidebar_monitoring_panel(cx))
|
||||
})
|
||||
.child(
|
||||
Button::new("open-ssh-panel")
|
||||
.primary()
|
||||
@@ -1202,9 +1244,6 @@ impl Ashell {
|
||||
cx.listener(|this, _, window, cx| this.open_new_ssh_dialog(window, cx)),
|
||||
),
|
||||
)
|
||||
.when(self.config.monitoring_position() == "Sidebar", |this| {
|
||||
this.child(self.render_sidebar_monitoring_panel(cx))
|
||||
})
|
||||
.child(
|
||||
v_flex()
|
||||
.flex_1()
|
||||
@@ -1270,9 +1309,24 @@ impl Ashell {
|
||||
let view = cx.entity();
|
||||
move |menu, window, _| {
|
||||
let edit_value = edit_id.clone();
|
||||
let clone_value = edit_id.clone();
|
||||
let delete_value = delete_id.clone();
|
||||
menu.item(
|
||||
PopupMenuItem::new("Edit").on_click(
|
||||
PopupMenuItem::new(t!("clone").to_string()).on_click(
|
||||
window.listener_for(
|
||||
&view,
|
||||
move |this, _, window, cx| {
|
||||
this.clone_saved_session(
|
||||
clone_value.clone(),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
.item(
|
||||
PopupMenuItem::new(t!("edit").to_string()).on_click(
|
||||
window.listener_for(
|
||||
&view,
|
||||
move |this, _, window, cx| {
|
||||
@@ -1286,7 +1340,7 @@ impl Ashell {
|
||||
),
|
||||
)
|
||||
.item(
|
||||
PopupMenuItem::new("Delete").on_click(
|
||||
PopupMenuItem::new(t!("delete").to_string()).on_click(
|
||||
window.listener_for(
|
||||
&view,
|
||||
move |this, _, _, cx| {
|
||||
@@ -1348,6 +1402,20 @@ impl Ashell {
|
||||
.active_tab
|
||||
.as_ref()
|
||||
.and_then(|active_id| self.tabs.iter().position(|tab| &tab.id == active_id));
|
||||
let active_group_index = self
|
||||
.active_group
|
||||
.as_ref()
|
||||
.and_then(|gid| self.tab_groups.iter().position(|g| g.id == *gid));
|
||||
let selected = active_group_index.unwrap_or(active_tab_index.unwrap_or(0));
|
||||
let groups_data: Vec<(String, String, Vec<String>)> = self
|
||||
.tab_groups
|
||||
.iter()
|
||||
.map(|g| {
|
||||
let pane_ids: Vec<String> =
|
||||
g.pane_root.tab_ids().iter().map(|s| s.to_string()).collect();
|
||||
(g.id.clone(), g.title.clone(), pane_ids)
|
||||
})
|
||||
.collect();
|
||||
v_flex()
|
||||
.h(px(32.))
|
||||
.w_full()
|
||||
@@ -1365,54 +1433,86 @@ impl Ashell {
|
||||
.min_w(px(0.))
|
||||
.h_full()
|
||||
.overflow_x_hidden()
|
||||
.child(
|
||||
.child({
|
||||
TabBar::new("ashell-tab-bar")
|
||||
.track_scroll(&self.tabs_scroll_handle)
|
||||
.selected_index(active_tab_index.unwrap_or(0))
|
||||
.children(self.tabs.iter().enumerate().map(|(ix, tab)| {
|
||||
let id = tab.id.clone();
|
||||
let close_id = tab.id.clone();
|
||||
let dot_color = if tab.connected {
|
||||
cx.theme().success
|
||||
} else {
|
||||
cx.theme().danger
|
||||
};
|
||||
Tab::new()
|
||||
.prefix(
|
||||
div()
|
||||
.w(px(5.))
|
||||
.h(px(32.))
|
||||
.bg(dot_color),
|
||||
)
|
||||
.label(tab.title.clone())
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.activate_tab(id.clone(), window, cx)
|
||||
}))
|
||||
.suffix(
|
||||
Button::new(("tab-close", ix))
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.icon(IconName::Close)
|
||||
.on_mouse_down(
|
||||
MouseButton::Left,
|
||||
|_, window, cx| {
|
||||
window.prevent_default();
|
||||
cx.stop_propagation();
|
||||
},
|
||||
)
|
||||
.on_click(cx.listener(
|
||||
move |this, _, window, cx| {
|
||||
window.prevent_default();
|
||||
cx.stop_propagation();
|
||||
this.close_tab(close_id.clone(), cx)
|
||||
},
|
||||
)),
|
||||
)
|
||||
}))
|
||||
.selected_index(selected)
|
||||
.children(groups_data.iter().enumerate().map(
|
||||
|(ix, (group_id, title, pane_ids))| {
|
||||
let gid = group_id.clone();
|
||||
let label = if pane_ids.len() > 1 {
|
||||
format!("{} ({})", title, pane_ids.len())
|
||||
} else {
|
||||
title.clone()
|
||||
};
|
||||
let close_id = if self.active_group.as_ref() == Some(&gid) {
|
||||
self.active_tab.clone().unwrap_or_else(|| pane_ids.first().cloned().unwrap_or_default())
|
||||
} else {
|
||||
pane_ids.first().cloned().unwrap_or_default()
|
||||
};
|
||||
let dot_color = pane_ids
|
||||
.first()
|
||||
.and_then(|id| {
|
||||
self.tabs.iter().find(|t| t.id == *id)
|
||||
})
|
||||
.map(|tab| {
|
||||
if tab.connected {
|
||||
cx.theme().success
|
||||
} else {
|
||||
cx.theme().danger
|
||||
}
|
||||
})
|
||||
.unwrap_or(cx.theme().success);
|
||||
Tab::new()
|
||||
.min_w(px(80.))
|
||||
.prefix(
|
||||
div()
|
||||
.w(px(5.))
|
||||
.h(px(32.))
|
||||
.bg(dot_color),
|
||||
)
|
||||
.label(label)
|
||||
.on_click(cx.listener(
|
||||
move |this, _, window, cx| {
|
||||
this.activate_group(
|
||||
gid.clone(),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
},
|
||||
))
|
||||
.suffix(
|
||||
Button::new(("tab-close", ix))
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.icon(IconName::Close)
|
||||
.mr(px(5.))
|
||||
.on_mouse_down(
|
||||
MouseButton::Left,
|
||||
|_, window, cx| {
|
||||
window.prevent_default();
|
||||
cx.stop_propagation();
|
||||
},
|
||||
)
|
||||
.on_click(cx.listener(
|
||||
move |this, _, window, cx| {
|
||||
window.prevent_default();
|
||||
cx.stop_propagation();
|
||||
if !close_id.is_empty() {
|
||||
this.close_tab(
|
||||
close_id.clone(),
|
||||
cx,
|
||||
)
|
||||
}
|
||||
},
|
||||
)),
|
||||
)
|
||||
},
|
||||
))
|
||||
.last_empty_space(div().w_3())
|
||||
.w_full()
|
||||
.h_full(),
|
||||
),
|
||||
.h_full()
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
@@ -1430,31 +1530,41 @@ impl Ashell {
|
||||
this.show_selector_dialog(window, cx)
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
Button::new("split-horizontal")
|
||||
.secondary()
|
||||
.small()
|
||||
.rounded(px(999.))
|
||||
.icon(IconName::PanelBottom)
|
||||
.on_click(cx.listener(|_, _, _, _| {}))
|
||||
)
|
||||
.child(
|
||||
Button::new("split-vertical")
|
||||
.secondary()
|
||||
.small()
|
||||
.rounded(px(999.))
|
||||
.icon(IconName::PanelRight)
|
||||
.on_click(cx.listener(|_, _, _, _| {}))
|
||||
),
|
||||
.child(
|
||||
Button::new("split-horizontal")
|
||||
.secondary()
|
||||
.small()
|
||||
.rounded(px(999.))
|
||||
.icon(IconName::PanelBottom)
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
window.prevent_default();
|
||||
cx.stop_propagation();
|
||||
this.split_current_pane("down", cx);
|
||||
}))
|
||||
)
|
||||
.child(
|
||||
Button::new("split-vertical")
|
||||
.secondary()
|
||||
.small()
|
||||
.rounded(px(999.))
|
||||
.icon(IconName::PanelRight)
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
window.prevent_default();
|
||||
cx.stop_propagation();
|
||||
this.split_current_pane("right", cx);
|
||||
}))
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_terminal_panel(
|
||||
&mut self,
|
||||
terminal_snapshot: Option<terminal::RenderSnapshot>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
let has_active = self.active_tab.is_some();
|
||||
let pane_tree = self.pane_root.clone();
|
||||
|
||||
v_flex().size_full().child(
|
||||
div()
|
||||
.size_full()
|
||||
@@ -1472,36 +1582,197 @@ impl Ashell {
|
||||
.on_action(cx.listener(Self::on_terminal_tab_action))
|
||||
.on_action(cx.listener(Self::on_terminal_backtab_action))
|
||||
.on_scroll_wheel(cx.listener(Self::on_terminal_scroll))
|
||||
.child(match terminal_snapshot.clone() {
|
||||
None => self.render_home_page(cx).into_any_element(),
|
||||
Some(snapshot) => {
|
||||
let view = cx.entity();
|
||||
div()
|
||||
.size_full()
|
||||
.on_prepaint({
|
||||
let view = view.clone();
|
||||
move |bounds, _window, cx| {
|
||||
let _ = view.update(cx, |this, _| {
|
||||
this.terminal_bounds = Some(bounds);
|
||||
});
|
||||
}
|
||||
})
|
||||
.child(terminal_element::TerminalElement::new(
|
||||
cx.entity(),
|
||||
self.focus_handle.clone(),
|
||||
snapshot,
|
||||
self.terminal_marked_text.clone(),
|
||||
self.terminal_font_family.clone(),
|
||||
px(self.terminal_font_size),
|
||||
px(self.terminal_line_height()),
|
||||
px(self.terminal_cell_width()),
|
||||
))
|
||||
.vertical_scrollbar(&self.terminal_scrollbar)
|
||||
.into_any_element()
|
||||
}
|
||||
.child(if has_active {
|
||||
Self::render_pane_tree(self, &pane_tree, &[], cx).into_any_element()
|
||||
} else {
|
||||
self.render_home_page(cx).into_any_element()
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_pane_tree(
|
||||
this: &mut Ashell,
|
||||
layout: &PaneLayout,
|
||||
path: &[usize],
|
||||
cx: &mut Context<Ashell>,
|
||||
) -> impl IntoElement {
|
||||
match layout {
|
||||
PaneLayout::Single(tab_id) => {
|
||||
if tab_id.is_empty() {
|
||||
return this.render_home_page(cx).into_any_element();
|
||||
}
|
||||
let is_focused = path == this.focused_pane_path.as_slice();
|
||||
let snapshot = this
|
||||
.tabs
|
||||
.iter()
|
||||
.find(|t| &t.id == tab_id)
|
||||
.map(TerminalTab::render_snapshot);
|
||||
let Some(snapshot) = snapshot else {
|
||||
return div().into_any_element();
|
||||
};
|
||||
let view = cx.entity();
|
||||
let tab_id_clone = tab_id.clone();
|
||||
let tab_id_clone2 = tab_id.clone();
|
||||
let focus_handle = this.focus_handle.clone();
|
||||
let marked_text = if is_focused {
|
||||
this.terminal_marked_text.clone()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let font_family = this.terminal_font_family.clone();
|
||||
let font_size = px(this.terminal_font_size);
|
||||
let line_height = px(this.terminal_line_height());
|
||||
let cell_width = px(this.terminal_cell_width());
|
||||
let mut el = div()
|
||||
.size_full()
|
||||
.overflow_hidden()
|
||||
.on_prepaint(move |bounds, _window, cx| {
|
||||
let _ = view.update(cx, |this, _| {
|
||||
this.terminal_bounds.insert(tab_id_clone.clone(), bounds);
|
||||
});
|
||||
})
|
||||
.on_mouse_down(MouseButton::Left, cx.listener(move |this, _, _, cx| {
|
||||
this.focus_pane_with_id(tab_id_clone2.clone());
|
||||
cx.notify();
|
||||
}))
|
||||
.child(terminal::element::TerminalElement::new(
|
||||
cx.entity(),
|
||||
focus_handle,
|
||||
snapshot,
|
||||
marked_text,
|
||||
font_family,
|
||||
font_size,
|
||||
line_height,
|
||||
cell_width,
|
||||
));
|
||||
let scrollbar = this.terminal_scrollbars.entry(tab_id.clone()).or_default();
|
||||
el = el.vertical_scrollbar(scrollbar);
|
||||
let indicator_color = this
|
||||
.tabs
|
||||
.iter()
|
||||
.find(|t| t.id == *tab_id)
|
||||
.map(|tab| {
|
||||
if tab.connected {
|
||||
cx.theme().success
|
||||
} else {
|
||||
cx.theme().danger
|
||||
}
|
||||
})
|
||||
.unwrap_or(cx.theme().success);
|
||||
let has_multiple_panes = this.pane_root.tab_ids().len() > 1;
|
||||
|
||||
if !is_focused {
|
||||
el = el.opacity(0.85);
|
||||
}
|
||||
|
||||
if has_multiple_panes {
|
||||
if is_focused {
|
||||
el = div()
|
||||
.size_full()
|
||||
.relative()
|
||||
.child(div().absolute().top(px(1.)).left(px(1.)).right(px(1.)).h(px(1.)).bg(indicator_color))
|
||||
.child(div().absolute().bottom(px(1.)).left(px(1.)).right(px(1.)).h(px(1.)).bg(indicator_color))
|
||||
.child(div().absolute().left(px(1.)).top(px(1.)).bottom(px(1.)).w(px(1.)).bg(indicator_color))
|
||||
.child(div().absolute().right(px(1.)).top(px(1.)).bottom(px(1.)).w(px(1.)).bg(indicator_color))
|
||||
.p(px(4.))
|
||||
.child(el);
|
||||
} else {
|
||||
el = div()
|
||||
.size_full()
|
||||
.p(px(4.))
|
||||
.child(el);
|
||||
}
|
||||
}
|
||||
|
||||
el.into_any_element()
|
||||
}
|
||||
PaneLayout::Horizontal(children, ratio) => {
|
||||
v_flex()
|
||||
.size_full()
|
||||
.children(children.iter().enumerate().flat_map(|(i, child)| {
|
||||
let mut items: Vec<gpui::AnyElement> = Vec::new();
|
||||
if i > 0 {
|
||||
let mut splitter_path = path.to_vec();
|
||||
splitter_path.push(i - 1); // path to the pane BEFORE this splitter
|
||||
items.push(
|
||||
div()
|
||||
.h(px(4.))
|
||||
.w_full()
|
||||
.flex_none()
|
||||
.cursor_row_resize()
|
||||
.bg(cx.theme().border)
|
||||
.hover(|s| s.bg(cx.theme().accent))
|
||||
.on_mouse_down(MouseButton::Left, cx.listener(move |this, event, window, cx| {
|
||||
window.prevent_default();
|
||||
cx.stop_propagation();
|
||||
this.start_drag_split(splitter_path.clone(), i, event, window, cx);
|
||||
}))
|
||||
.into_any_element()
|
||||
);
|
||||
}
|
||||
let mut child_path = path.to_vec();
|
||||
child_path.push(i);
|
||||
items.push(
|
||||
div()
|
||||
.flex_grow(if children.len() == 2 {
|
||||
if i == 0 { *ratio } else { 1.0 - *ratio }
|
||||
} else {
|
||||
1.0
|
||||
})
|
||||
.min_h(px(0.))
|
||||
.overflow_hidden()
|
||||
.child(Self::render_pane_tree(this, child, &child_path, cx))
|
||||
.into_any_element()
|
||||
);
|
||||
items
|
||||
}))
|
||||
.into_any_element()
|
||||
}
|
||||
PaneLayout::Vertical(children, ratio) => {
|
||||
h_flex()
|
||||
.items_stretch()
|
||||
.size_full()
|
||||
.children(children.iter().enumerate().flat_map(|(i, child)| {
|
||||
let mut items: Vec<gpui::AnyElement> = Vec::new();
|
||||
if i > 0 {
|
||||
let mut splitter_path = path.to_vec();
|
||||
splitter_path.push(i - 1);
|
||||
items.push(
|
||||
div()
|
||||
.w(px(4.))
|
||||
.h_full()
|
||||
.flex_none()
|
||||
.cursor_col_resize()
|
||||
.bg(cx.theme().border)
|
||||
.hover(|s| s.bg(cx.theme().accent))
|
||||
.on_mouse_down(MouseButton::Left, cx.listener(move |this, event, window, cx| {
|
||||
window.prevent_default();
|
||||
cx.stop_propagation();
|
||||
this.start_drag_split(splitter_path.clone(), i, event, window, cx);
|
||||
}))
|
||||
.into_any_element()
|
||||
);
|
||||
}
|
||||
let mut child_path = path.to_vec();
|
||||
child_path.push(i);
|
||||
items.push(
|
||||
div()
|
||||
.flex_grow(if children.len() == 2 {
|
||||
if i == 0 { *ratio } else { 1.0 - *ratio }
|
||||
} else {
|
||||
1.0
|
||||
})
|
||||
.min_w(px(0.))
|
||||
.overflow_hidden()
|
||||
.child(Self::render_pane_tree(this, child, &child_path, cx))
|
||||
.into_any_element()
|
||||
);
|
||||
items
|
||||
}))
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Ashell {
|
||||
@@ -1512,11 +1783,6 @@ impl Render for Ashell {
|
||||
.is_some_and(|active_id| !self.tabs.iter().any(|tab| &tab.id == active_id))
|
||||
{
|
||||
self.active_tab = self.tabs.first().map(|tab| tab.id.clone());
|
||||
self.cpu_history.clear();
|
||||
self.net_rx_history.clear();
|
||||
self.net_tx_history.clear();
|
||||
self.remote_sample_in_flight = false;
|
||||
self.request_active_system_snapshot();
|
||||
}
|
||||
self.sync_sftp_path_input(window, cx);
|
||||
self.sync_terminal_size(window, cx);
|
||||
@@ -1524,26 +1790,29 @@ impl Render for Ashell {
|
||||
self.show_transfers_dialog = false;
|
||||
self.show_transfers_dialog(window, cx);
|
||||
}
|
||||
if let Some(new_display_offset) = self.terminal_scrollbar.future_display_offset.take() {
|
||||
if let Some(active_id) = self.active_tab.clone() {
|
||||
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == active_id) {
|
||||
let current = tab.render_snapshot().display_offset;
|
||||
match new_display_offset.cmp(¤t) {
|
||||
std::cmp::Ordering::Greater => {
|
||||
tab.scroll_up_by(new_display_offset - current)
|
||||
if let Some(active_id) = self.active_tab.clone() {
|
||||
if let Some(scrollbar) = self.terminal_scrollbars.get(&active_id) {
|
||||
if let Some(new_display_offset) = scrollbar.future_display_offset.take() {
|
||||
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == active_id) {
|
||||
let current = tab.render_snapshot().display_offset;
|
||||
match new_display_offset.cmp(¤t) {
|
||||
std::cmp::Ordering::Greater => {
|
||||
tab.scroll_up_by(new_display_offset - current)
|
||||
}
|
||||
std::cmp::Ordering::Less => {
|
||||
tab.scroll_down_by(current - new_display_offset)
|
||||
}
|
||||
std::cmp::Ordering::Equal => {}
|
||||
}
|
||||
std::cmp::Ordering::Less => {
|
||||
tab.scroll_down_by(current - new_display_offset)
|
||||
}
|
||||
std::cmp::Ordering::Equal => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let terminal_snapshot = self.active_snapshot();
|
||||
if let Some(snapshot) = terminal_snapshot.as_ref() {
|
||||
self.terminal_scrollbar
|
||||
.update(snapshot, px(self.terminal_line_height()));
|
||||
if let Some(snapshot) = self.active_snapshot().as_ref() {
|
||||
if let Some(scrollbar) = self.terminal_scrollbars.get(&active_id) {
|
||||
scrollbar
|
||||
.update(snapshot, px(self.terminal_line_height()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let sidebar_area = resizable_panel()
|
||||
@@ -1575,7 +1844,7 @@ impl Render for Ashell {
|
||||
div().flex_1().min_h(px(0.)).child(
|
||||
v_resizable("ashell-body")
|
||||
.with_state(&self.body_panels)
|
||||
.child(resizable_panel().child(self.render_terminal_panel(terminal_snapshot, cx)))
|
||||
.child(resizable_panel().child(self.render_terminal_panel(cx)))
|
||||
)
|
||||
)
|
||||
.child(
|
||||
@@ -1591,7 +1860,7 @@ impl Render for Ashell {
|
||||
} else {
|
||||
v_resizable("ashell-body")
|
||||
.with_state(&self.body_panels)
|
||||
.child(resizable_panel().child(self.render_terminal_panel(terminal_snapshot, cx)))
|
||||
.child(resizable_panel().child(self.render_terminal_panel(cx)))
|
||||
.child(
|
||||
resizable_panel()
|
||||
.size(px(self
|
||||
@@ -1743,20 +2012,46 @@ impl Render for Ashell {
|
||||
.loading(!progress.failed)
|
||||
.label(progress.title.clone()),
|
||||
)
|
||||
.child(div().max_h(px(220.)).overflow_y_scrollbar().child(
|
||||
v_flex().gap_2().children(
|
||||
progress.lines.iter().cloned().map(|line| {
|
||||
.child(
|
||||
div()
|
||||
.relative()
|
||||
.min_h(px(0.))
|
||||
.max_h(px(220.))
|
||||
.child(
|
||||
div()
|
||||
.text_size(rems(1.0))
|
||||
.text_color(if progress.failed {
|
||||
cx.theme().danger
|
||||
} else {
|
||||
cx.theme().muted_foreground
|
||||
})
|
||||
.child(line)
|
||||
}),
|
||||
),
|
||||
))
|
||||
.id("connection-progress-scroll")
|
||||
.max_h(px(220.))
|
||||
.overflow_hidden()
|
||||
.overflow_y_scroll()
|
||||
.track_scroll(&self.connection_scroll_handle)
|
||||
.child(
|
||||
v_flex().gap_2().children(
|
||||
progress.lines.iter().cloned().map(|line| {
|
||||
div()
|
||||
.text_size(rems(1.0))
|
||||
.text_color(if progress.failed {
|
||||
cx.theme().danger
|
||||
} else {
|
||||
cx.theme().muted_foreground
|
||||
})
|
||||
.child(line)
|
||||
}),
|
||||
),
|
||||
)
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.top_0()
|
||||
.right_0()
|
||||
.bottom_0()
|
||||
.w(px(16.))
|
||||
.child(
|
||||
Scrollbar::vertical(&self.connection_scroll_handle)
|
||||
.scrollbar_show(ScrollbarShow::Scrolling)
|
||||
)
|
||||
)
|
||||
)
|
||||
.when(progress.failed, |this| {
|
||||
this.child(
|
||||
h_flex()
|
||||
@@ -115,6 +115,7 @@ pub fn spawn_local_terminal(
|
||||
});
|
||||
}
|
||||
BackendCommand::Close => break,
|
||||
BackendCommand::SampleMetrics => {}
|
||||
}
|
||||
}
|
||||
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,11 @@ 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 +116,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 +170,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 +200,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 +218,12 @@ 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}"),
|
||||
@@ -179,9 +231,12 @@ async fn connect_and_authenticate(
|
||||
let mut handle = client::connect(config, addr.as_str(), ClientHandler)
|
||||
.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 +251,7 @@ 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 +262,36 @@ 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 +313,8 @@ 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 +355,31 @@ 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 {
|
||||
@@ -417,6 +506,7 @@ echo "NET_RX=0"
|
||||
echo "NET_TX=0"
|
||||
'"#;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ClientHandler;
|
||||
|
||||
#[async_trait]
|
||||
+13
-229
@@ -1,241 +1,26 @@
|
||||
#![windows_subsystem = "windows"]
|
||||
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
use gpui::{App, AppContext as _, Bounds, KeyBinding, WindowOptions, point, px, size};
|
||||
use gpui_component::{Root, Theme, ThemeRegistry};
|
||||
use gpui::{ KeyBinding };
|
||||
use gpui_component_assets::Assets;
|
||||
|
||||
mod config;
|
||||
mod local_terminal;
|
||||
mod app;
|
||||
mod backend;
|
||||
mod session;
|
||||
mod sftp;
|
||||
mod ssh_terminal;
|
||||
mod system;
|
||||
mod terminal;
|
||||
mod terminal_element;
|
||||
mod terminal_input;
|
||||
mod app;
|
||||
mod session;
|
||||
mod sftp_ops;
|
||||
mod theme;
|
||||
mod dialogs;
|
||||
mod ui;
|
||||
|
||||
use config::ConfigStore;
|
||||
|
||||
rust_i18n::i18n!("locales", fallback = "en");
|
||||
|
||||
const DEFAULT_COLS: u16 = 100;
|
||||
const DEFAULT_ROWS: u16 = 30;
|
||||
|
||||
const SIDEBAR_WIDTH: f32 = 306.0;
|
||||
const TAB_BAR_HEIGHT: f32 = 52.0;
|
||||
const TERMINAL_PADDING_X: f32 = 32.0;
|
||||
const TERMINAL_PADDING_Y: f32 = 32.0;
|
||||
const TERMINAL_KEY_CONTEXT: &str = "AshellTerminal";
|
||||
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"),
|
||||
];
|
||||
|
||||
gpui::actions!(ashell_terminal, [TerminalTabKey, TerminalBacktabKey]);
|
||||
|
||||
pub(crate) use app::{
|
||||
Ashell, ConnectionProgress, SelectorEntry, SftpContextMenuState,
|
||||
Ashell, ConnectionProgress, PaneLayout, SelectorEntry, SftpContextMenuState, TabGroup,
|
||||
};
|
||||
|
||||
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")?;
|
||||
// At startup we don't have a config yet, so pass the default UI font family.
|
||||
// It will be reapplied in Ashell::new() -> apply_theme_preferences.
|
||||
theme::set_theme_font_names(cx.global_mut::<Theme>(), ".SystemUIFont");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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:#}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
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"))]
|
||||
fn sync_macos_launch_environment() {}
|
||||
|
||||
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::config::SavedWindowBounds::Fullscreen {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
} => gpui::WindowBounds::Fullscreen(Bounds::new(
|
||||
point(px(*x), px(*y)),
|
||||
size(px(*width), px(*height)),
|
||||
)),
|
||||
crate::config::SavedWindowBounds::Maximized {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
} => gpui::WindowBounds::Maximized(Bounds::new(
|
||||
point(px(*x), px(*y)),
|
||||
size(px(*width), px(*height)),
|
||||
)),
|
||||
crate::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");
|
||||
Theme::sync_system_appearance(Some(window), cx);
|
||||
let view = cx.new(|cx| Ashell::new(window, cx));
|
||||
|
||||
let workspace_panels_clone = view.read(cx).workspace_panels.clone();
|
||||
let body_panels_clone = view.read(cx).body_panels.clone();
|
||||
window.on_window_should_close(cx, move |window: &mut gpui::Window, cx: &mut gpui::App| {
|
||||
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::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::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::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");
|
||||
}
|
||||
|
||||
fn main() {
|
||||
sync_macos_launch_environment();
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
|
||||
)
|
||||
.init();
|
||||
app::startup::sync_macos_launch_environment();
|
||||
app::startup::init_logging();
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
let app = gpui_platform::application()
|
||||
@@ -246,20 +31,19 @@ fn main() {
|
||||
let app = gpui_platform::application().with_assets(Assets);
|
||||
app.on_reopen(|cx| {
|
||||
if cx.windows().is_empty() {
|
||||
open_main_window(cx);
|
||||
app::startup::open_main_window(cx);
|
||||
}
|
||||
});
|
||||
app.run(move |cx| {
|
||||
gpui_component::init(cx);
|
||||
cx.bind_keys([
|
||||
KeyBinding::new("tab", TerminalTabKey, Some(TERMINAL_KEY_CONTEXT)),
|
||||
KeyBinding::new("shift-tab", TerminalBacktabKey, Some(TERMINAL_KEY_CONTEXT)),
|
||||
KeyBinding::new("tab", TerminalTabKey, Some(app::constants::TERMINAL_KEY_CONTEXT)),
|
||||
KeyBinding::new("shift-tab", TerminalBacktabKey, Some(app::constants::TERMINAL_KEY_CONTEXT)),
|
||||
]);
|
||||
load_embedded_themes(cx);
|
||||
if let Err(err) = load_fonts(cx) {
|
||||
app::theme::load_embedded_themes(cx);
|
||||
if let Err(err) = app::theme::load_fonts(cx) {
|
||||
tracing::warn!("failed to load embedded fonts: {err:#}");
|
||||
}
|
||||
open_main_window(cx);
|
||||
app::startup::open_main_window(cx);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
-570
@@ -1,570 +0,0 @@
|
||||
use gpui::{
|
||||
App, AppContext as _, Context, Entity, KeyDownEvent, MouseButton,
|
||||
MouseDownEvent, SharedString, Window, px,
|
||||
};
|
||||
use gpui_component::{
|
||||
Theme, WindowExt as _,
|
||||
input::InputState,
|
||||
};
|
||||
use rust_i18n::t;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
Ashell, ConnectionProgress, SelectorEntry,
|
||||
config::{AuthMethod, Session},
|
||||
local_terminal,
|
||||
sftp,
|
||||
ssh_terminal,
|
||||
terminal::{BackendCommand, RenderSnapshot, TabKind, TerminalTab},
|
||||
DEFAULT_COLS, DEFAULT_ROWS, SIDEBAR_WIDTH, TAB_BAR_HEIGHT, TERMINAL_PADDING_X,
|
||||
TERMINAL_PADDING_Y,
|
||||
};
|
||||
|
||||
impl Ashell {
|
||||
pub(crate) fn open_local(&mut self, cx: &mut Context<Self>) {
|
||||
let id = Uuid::new_v4().to_string();
|
||||
match local_terminal::spawn_local_terminal(
|
||||
id.clone(),
|
||||
DEFAULT_COLS,
|
||||
DEFAULT_ROWS,
|
||||
self.events_tx.clone(),
|
||||
) {
|
||||
Ok(backend) => {
|
||||
let title = if cfg!(windows) { "PowerShell" } else { "Local" }.to_string();
|
||||
let mut tab =
|
||||
TerminalTab::new_local(id.clone(), title, backend, self.events_tx.clone());
|
||||
tab.resize(DEFAULT_COLS, DEFAULT_ROWS);
|
||||
self.tabs.push(tab);
|
||||
self.active_tab = Some(id);
|
||||
self.tabs_scroll_handle.scroll_to_item(self.tabs.len() - 1);
|
||||
self.status = "local terminal opened".into();
|
||||
}
|
||||
Err(err) => {
|
||||
self.status = format!("failed to open local terminal: {err:#}").into();
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn connect_ssh(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let session_name = self.session_name_input.read(cx).value().trim().to_string();
|
||||
let host = self.host_input.read(cx).value().trim().to_string();
|
||||
let port = self
|
||||
.port_input
|
||||
.read(cx)
|
||||
.value()
|
||||
.trim()
|
||||
.parse::<u16>()
|
||||
.unwrap_or(22);
|
||||
let user = self.user_input.read(cx).value().trim().to_string();
|
||||
let password = self.password_input.read(cx).value().to_string();
|
||||
let key_path = self.key_path_input.read(cx).value().trim().to_string();
|
||||
let key_inline = self.key_inline_input.read(cx).value().to_string();
|
||||
|
||||
if host.is_empty() || user.is_empty() {
|
||||
self.status = t!("host_and_user_required").into();
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
|
||||
let name = if session_name.is_empty() {
|
||||
host.clone()
|
||||
} else {
|
||||
session_name
|
||||
};
|
||||
let existing_id = self.editing_session_id.clone();
|
||||
let existing_last_used = existing_id
|
||||
.as_deref()
|
||||
.and_then(|id| self.config.get(id))
|
||||
.and_then(|session| session.last_used.clone());
|
||||
|
||||
let mut session = match self.ssh_auth_method {
|
||||
AuthMethod::Password => Session::password(host, port, user, password),
|
||||
AuthMethod::Key => {
|
||||
if key_path.is_empty() && key_inline.trim().is_empty() {
|
||||
self.status = "private key path or content is required".into();
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
Session::key(host, port, user, key_path, key_inline)
|
||||
}
|
||||
};
|
||||
session.name = name;
|
||||
if let Some(id) = existing_id {
|
||||
session.id = id;
|
||||
}
|
||||
session.last_used = existing_last_used;
|
||||
self.config.upsert(session.clone());
|
||||
if let Err(err) = self.config.save() {
|
||||
tracing::warn!("failed to save config: {err:#}");
|
||||
}
|
||||
|
||||
self.open_ssh_session(session, cx);
|
||||
self.editing_session_id = None;
|
||||
window.close_dialog(cx);
|
||||
}
|
||||
|
||||
pub(crate) fn set_input_value(
|
||||
input: &Entity<InputState>,
|
||||
value: impl Into<SharedString>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
input.update(cx, |state, cx| state.set_value(value, window, cx));
|
||||
}
|
||||
|
||||
pub(crate) fn reset_ssh_form(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.editing_session_id = None;
|
||||
self.ssh_auth_method = AuthMethod::Password;
|
||||
Self::set_input_value(&self.session_name_input, "", window, cx);
|
||||
Self::set_input_value(&self.host_input, "", window, cx);
|
||||
Self::set_input_value(&self.port_input, "22", window, cx);
|
||||
Self::set_input_value(&self.user_input, "root", window, cx);
|
||||
Self::set_input_value(&self.password_input, "", window, cx);
|
||||
Self::set_input_value(&self.key_path_input, "", window, cx);
|
||||
Self::set_input_value(&self.key_inline_input, "", window, cx);
|
||||
}
|
||||
|
||||
pub(crate) fn load_session_into_form(
|
||||
&mut self,
|
||||
session: &Session,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.editing_session_id = Some(session.id.clone());
|
||||
self.ssh_auth_method = session.auth;
|
||||
Self::set_input_value(&self.session_name_input, session.name.clone(), window, cx);
|
||||
Self::set_input_value(&self.host_input, session.host.clone(), window, cx);
|
||||
Self::set_input_value(&self.port_input, session.port.to_string(), window, cx);
|
||||
Self::set_input_value(&self.user_input, session.user.clone(), window, cx);
|
||||
Self::set_input_value(&self.password_input, session.password.clone(), window, cx);
|
||||
Self::set_input_value(
|
||||
&self.key_path_input,
|
||||
session.private_key_path.clone(),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
Self::set_input_value(
|
||||
&self.key_inline_input,
|
||||
session.private_key_inline.clone(),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn open_new_ssh_dialog(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.reset_ssh_form(window, cx);
|
||||
self.show_ssh_dialog(window, cx);
|
||||
}
|
||||
|
||||
pub(crate) fn edit_saved_session(
|
||||
&mut self,
|
||||
session_id: String,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(session) = self.config.get(&session_id).cloned() else {
|
||||
self.status = "saved session not found".into();
|
||||
cx.notify();
|
||||
return;
|
||||
};
|
||||
self.load_session_into_form(&session, window, cx);
|
||||
self.show_ssh_dialog(window, cx);
|
||||
}
|
||||
|
||||
pub(crate) fn terminal_cell_width(&self) -> f32 {
|
||||
(self.terminal_font_size * 0.646).max(6.0)
|
||||
}
|
||||
|
||||
pub(crate) fn terminal_line_height(&self) -> f32 {
|
||||
(self.terminal_font_size * 1.385).max(self.terminal_font_size + 2.0)
|
||||
}
|
||||
|
||||
pub(crate) fn change_terminal_font_size(&mut self, delta: f32, cx: &mut Context<Self>) {
|
||||
self.terminal_font_size = (self.terminal_font_size + delta).clamp(10.0, 24.0);
|
||||
self.config.set_terminal_font_size(self.terminal_font_size);
|
||||
if let Err(err) = self.config.save() {
|
||||
tracing::warn!("failed to save terminal font size: {err:#}");
|
||||
}
|
||||
self.status = format!("terminal font size: {:.0}px", self.terminal_font_size).into();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn change_ui_font_size(&mut self, delta: f32, cx: &mut Context<Self>) {
|
||||
self.ui_font_size = (self.ui_font_size + delta).clamp(8.0, 24.0);
|
||||
self.config.set_ui_font_size(self.ui_font_size);
|
||||
if let Err(err) = self.config.save() {
|
||||
tracing::warn!("failed to save UI font size: {err:#}");
|
||||
}
|
||||
Theme::global_mut(cx).font_size = px(self.ui_font_size);
|
||||
self.status = format!("UI font size: {:.0}px", self.ui_font_size).into();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn change_ui_font_family(
|
||||
&mut self,
|
||||
family: &str,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.ui_font_family = family.into();
|
||||
self.config.set_ui_font_family(family);
|
||||
if let Err(err) = self.config.save() {
|
||||
tracing::warn!("failed to save UI font family: {err:#}");
|
||||
}
|
||||
crate::theme::set_theme_font_names(Theme::global_mut(cx), &self.ui_font_family);
|
||||
cx.notify();
|
||||
window.refresh();
|
||||
}
|
||||
|
||||
pub(crate) fn change_terminal_font_family(&mut self, family: &str, cx: &mut Context<Self>) {
|
||||
self.terminal_font_family = family.into();
|
||||
self.config.set_terminal_font_family(family);
|
||||
if let Err(err) = self.config.save() {
|
||||
tracing::warn!("failed to save terminal font family: {err:#}");
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn reset_layout(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.config.set_layout_state(None, None, None);
|
||||
let _ = self.config.save();
|
||||
|
||||
self.workspace_panels = cx.new(|_| gpui_component::resizable::ResizableState::default());
|
||||
self.body_panels = cx.new(|_| gpui_component::resizable::ResizableState::default());
|
||||
|
||||
self.status = t!("reset_layout_success").into();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn set_ssh_auth_method(&mut self, method: AuthMethod, cx: &mut Context<Self>) {
|
||||
self.ssh_auth_method = method;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn connect_saved_session(&mut self, session_id: String, cx: &mut Context<Self>) {
|
||||
let Some(session) = self.config.get(&session_id).cloned() else {
|
||||
self.status = "saved session not found".into();
|
||||
cx.notify();
|
||||
return;
|
||||
};
|
||||
self.open_ssh_session(session, cx);
|
||||
}
|
||||
|
||||
pub(crate) fn selector_entries(&self) -> Vec<SelectorEntry> {
|
||||
let mut entries = vec![SelectorEntry::Local, SelectorEntry::NewSsh];
|
||||
entries.extend(
|
||||
self.config
|
||||
.sessions()
|
||||
.iter()
|
||||
.map(|session| SelectorEntry::Saved(session.id.clone())),
|
||||
);
|
||||
entries
|
||||
}
|
||||
|
||||
pub(crate) fn default_selector_index(&self) -> usize {
|
||||
if self.config.sessions().is_empty() {
|
||||
0
|
||||
} else {
|
||||
2
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn move_selector_selection(&mut self, delta: i32, cx: &mut Context<Self>) {
|
||||
let entries = self.selector_entries();
|
||||
if entries.is_empty() {
|
||||
return;
|
||||
}
|
||||
let current = self.selector_selection.min(entries.len().saturating_sub(1)) as i32;
|
||||
let next = (current + delta).clamp(0, entries.len() as i32 - 1) as usize;
|
||||
if next != self.selector_selection {
|
||||
self.selector_selection = next;
|
||||
if next >= 2 {
|
||||
self.selector_scroll_handle.scroll_to_item(next - 2);
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn activate_selector_selection(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let entries = self.selector_entries();
|
||||
let Some(entry) = entries.get(self.selector_selection).cloned() else {
|
||||
return;
|
||||
};
|
||||
|
||||
match entry {
|
||||
SelectorEntry::Local => {
|
||||
self.open_local(cx);
|
||||
window.close_dialog(cx);
|
||||
}
|
||||
SelectorEntry::NewSsh => {
|
||||
window.close_dialog(cx);
|
||||
self.open_new_ssh_dialog(window, cx);
|
||||
}
|
||||
SelectorEntry::Saved(session_id) => {
|
||||
self.connect_saved_session(session_id, cx);
|
||||
window.close_dialog(cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn on_selector_key_down(
|
||||
&mut self,
|
||||
event: &KeyDownEvent,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let key = event.keystroke.key.to_ascii_lowercase();
|
||||
match key.as_str() {
|
||||
"up" | "arrowup" => {
|
||||
self.move_selector_selection(-1, cx);
|
||||
window.prevent_default();
|
||||
cx.stop_propagation();
|
||||
}
|
||||
"down" | "arrowdown" => {
|
||||
self.move_selector_selection(1, cx);
|
||||
window.prevent_default();
|
||||
cx.stop_propagation();
|
||||
}
|
||||
"enter" | "return" => {
|
||||
self.activate_selector_selection(window, cx);
|
||||
window.prevent_default();
|
||||
cx.stop_propagation();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn open_ssh_session(&mut self, session: Session, cx: &mut Context<Self>) {
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let backend = ssh_terminal::spawn_ssh_terminal(
|
||||
self.runtime.handle(),
|
||||
id.clone(),
|
||||
session.clone(),
|
||||
DEFAULT_COLS,
|
||||
DEFAULT_ROWS,
|
||||
self.events_tx.clone(),
|
||||
);
|
||||
self.tabs.push(TerminalTab::new_ssh(
|
||||
id.clone(),
|
||||
&session,
|
||||
backend,
|
||||
self.events_tx.clone(),
|
||||
));
|
||||
self.active_tab = Some(id.clone());
|
||||
self.tabs_scroll_handle.scroll_to_item(self.tabs.len() - 1);
|
||||
if let Some(session_id) = self.active_session_id() {
|
||||
if let Some(index) = self.config.sessions().iter().position(|s| s.id == session_id) {
|
||||
self.saved_scroll_handle.scroll_to_item(index);
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
let sftp_handle = sftp::spawn_sftp(
|
||||
self.runtime.handle(),
|
||||
id.clone(),
|
||||
session,
|
||||
self.events_tx.clone(),
|
||||
);
|
||||
self.sftp_handles.insert(id.clone(), sftp_handle);
|
||||
self.active_tab = Some(id.clone());
|
||||
self.pending_sftp_path_sync = Some("/".into());
|
||||
self.connection_progress = Some(ConnectionProgress {
|
||||
tab_id: id,
|
||||
title: t!("connecting").into(),
|
||||
lines: vec![t!("starting_connection").into()],
|
||||
failed: false,
|
||||
});
|
||||
self.status = "ssh tab opened".into();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn remove_saved_session(&mut self, session_id: String, cx: &mut Context<Self>) {
|
||||
self.config.remove(&session_id);
|
||||
if let Err(err) = self.config.save() {
|
||||
tracing::warn!("failed to save config: {err:#}");
|
||||
}
|
||||
self.status = "session removed".into();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn retry_connection_progress(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(progress) = self.connection_progress.clone() else {
|
||||
return;
|
||||
};
|
||||
let Some(ix) = self.tabs.iter().position(|tab| tab.id == progress.tab_id) else {
|
||||
self.connection_progress = None;
|
||||
cx.notify();
|
||||
return;
|
||||
};
|
||||
let Some(session) = self.tabs[ix].session.clone() else {
|
||||
self.connection_progress = None;
|
||||
cx.notify();
|
||||
return;
|
||||
};
|
||||
|
||||
self.tabs[ix].backend.send(BackendCommand::Close);
|
||||
if let Some(handle) = self.sftp_handles.remove(&progress.tab_id) {
|
||||
handle.close();
|
||||
}
|
||||
self.tabs.remove(ix);
|
||||
if self.active_tab.as_deref() == Some(progress.tab_id.as_str()) {
|
||||
self.active_tab = self
|
||||
.tabs
|
||||
.get(ix)
|
||||
.or_else(|| self.tabs.get(ix.saturating_sub(1)))
|
||||
.map(|tab| tab.id.clone());
|
||||
}
|
||||
self.connection_progress = None;
|
||||
self.open_ssh_session(session, cx);
|
||||
}
|
||||
|
||||
pub(crate) fn cancel_connection_progress(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(progress) = self.connection_progress.clone() else {
|
||||
return;
|
||||
};
|
||||
self.connection_progress = None;
|
||||
self.close_tab(progress.tab_id, cx);
|
||||
}
|
||||
|
||||
pub(crate) fn activate_tab(&mut self, id: String, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if let Some(path) = self
|
||||
.tabs
|
||||
.iter()
|
||||
.find(|tab| tab.id == id)
|
||||
.and_then(|tab| tab.sftp.as_ref())
|
||||
.map(|sftp| sftp.current_path.clone())
|
||||
{
|
||||
self.pending_sftp_path_sync = Some(path);
|
||||
}
|
||||
self.active_tab = Some(id.clone());
|
||||
self.cpu_history.clear();
|
||||
self.net_rx_history.clear();
|
||||
self.net_tx_history.clear();
|
||||
if let Some(index) = self.tabs.iter().position(|t| t.id == id) {
|
||||
self.tabs_scroll_handle.scroll_to_item(index);
|
||||
}
|
||||
if self.tabs.iter().any(|t| t.id == id) {
|
||||
if let Some(session_id) = self.active_session_id() {
|
||||
if let Some(index) = self.config.sessions().iter().position(|s| s.id == session_id) {
|
||||
self.saved_scroll_handle.scroll_to_item(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.remote_sample_in_flight = false;
|
||||
self.request_active_system_snapshot();
|
||||
self.focus_handle.focus(window, cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn close_tab(&mut self, id: String, cx: &mut Context<Self>) {
|
||||
if let Some(ix) = self.tabs.iter().position(|tab| tab.id == id) {
|
||||
let was_active = self.active_tab.as_deref() == Some(id.as_str());
|
||||
self.tabs[ix].backend.send(BackendCommand::Close);
|
||||
if let Some(handle) = self.sftp_handles.remove(&id) {
|
||||
handle.close();
|
||||
}
|
||||
self.tabs.remove(ix);
|
||||
if was_active
|
||||
|| self
|
||||
.active_tab
|
||||
.as_ref()
|
||||
.is_some_and(|active_id| !self.tabs.iter().any(|tab| &tab.id == active_id))
|
||||
{
|
||||
self.active_tab = self
|
||||
.tabs
|
||||
.get(ix)
|
||||
.or_else(|| self.tabs.get(ix.saturating_sub(1)))
|
||||
.map(|tab| tab.id.clone());
|
||||
self.cpu_history.clear();
|
||||
self.net_rx_history.clear();
|
||||
self.net_tx_history.clear();
|
||||
self.remote_sample_in_flight = false;
|
||||
self.request_active_system_snapshot();
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn focus_terminal(
|
||||
&mut self,
|
||||
event: &MouseDownEvent,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.focus_handle.focus(window, cx);
|
||||
if event.button == MouseButton::Left {
|
||||
self.begin_terminal_selection(event, cx);
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn active_snapshot(&self) -> Option<RenderSnapshot> {
|
||||
self.active_tab
|
||||
.as_ref()
|
||||
.and_then(|id| self.tabs.iter().find(|t| &t.id == id))
|
||||
.map(TerminalTab::render_snapshot)
|
||||
}
|
||||
|
||||
pub(crate) fn active_kind(&self) -> Option<TabKind> {
|
||||
self.active_tab
|
||||
.as_ref()
|
||||
.and_then(|id| self.tabs.iter().find(|t| &t.id == id))
|
||||
.map(|tab| tab.kind)
|
||||
}
|
||||
|
||||
pub(crate) fn active_title(&self) -> String {
|
||||
self.active_tab
|
||||
.as_ref()
|
||||
.and_then(|id| self.tabs.iter().find(|t| &t.id == id))
|
||||
.map(|t| t.title.clone())
|
||||
.unwrap_or_else(|| t!("idle_no_session").into())
|
||||
}
|
||||
|
||||
pub(crate) fn active_ssh_session(&self) -> Option<(String, Session)> {
|
||||
let active_id = self.active_tab.as_ref()?;
|
||||
let tab = self.tabs.iter().find(|tab| &tab.id == active_id)?;
|
||||
if !tab.connected {
|
||||
return None;
|
||||
}
|
||||
Some((tab.id.clone(), tab.session.clone()?))
|
||||
}
|
||||
|
||||
pub(crate) fn active_session_id(&self) -> Option<&str> {
|
||||
self.active_tab
|
||||
.as_ref()
|
||||
.and_then(|id| self.tabs.iter().find(|tab| &tab.id == id))
|
||||
.and_then(|tab| tab.session.as_ref())
|
||||
.map(|session| session.id.as_str())
|
||||
}
|
||||
|
||||
pub(crate) fn session_detail(&self, session: &Session) -> String {
|
||||
format!("{}@{}:{}", session.user, session.host, session.port)
|
||||
}
|
||||
|
||||
pub(crate) fn sync_terminal_size(&mut self, window: &Window, cx: &App) {
|
||||
let viewport = window.viewport_size();
|
||||
let sidebar_width = self
|
||||
.workspace_panels
|
||||
.read(cx)
|
||||
.sizes()
|
||||
.first()
|
||||
.map(|size| size.as_f32())
|
||||
.unwrap_or(SIDEBAR_WIDTH);
|
||||
let terminal_height = self
|
||||
.body_panels
|
||||
.read(cx)
|
||||
.sizes()
|
||||
.first()
|
||||
.map(|size| size.as_f32())
|
||||
.unwrap_or(viewport.height.as_f32() - TAB_BAR_HEIGHT - 248.0);
|
||||
let width = (viewport.width.as_f32() - sidebar_width - TERMINAL_PADDING_X - 8.0)
|
||||
.max(self.terminal_cell_width());
|
||||
let height = (terminal_height - TERMINAL_PADDING_Y).max(self.terminal_line_height());
|
||||
let cols = (width / self.terminal_cell_width()).floor().max(1.0) as u16;
|
||||
let rows = (height / self.terminal_line_height()).floor().max(1.0) as u16;
|
||||
|
||||
for tab in &mut self.tabs {
|
||||
tab.resize(cols, rows);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -140,11 +140,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 {
|
||||
@@ -168,6 +168,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);
|
||||
@@ -237,7 +247,11 @@ impl ConfigStore {
|
||||
}
|
||||
|
||||
pub fn monitoring_position(&self) -> &str {
|
||||
&self.cache.monitoring_position
|
||||
if self.cache.monitoring_position.is_empty() {
|
||||
"Sidebar"
|
||||
} else {
|
||||
&self.cache.monitoring_position
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_monitoring_position(&mut self, pos: &str) {
|
||||
@@ -378,6 +392,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(())
|
||||
}
|
||||
}
|
||||
+1233
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
};
|
||||
|
||||
@@ -496,7 +498,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));
|
||||
@@ -640,6 +642,8 @@ async fn run_sftp(
|
||||
} else {
|
||||
path.clone()
|
||||
};
|
||||
|
||||
tracing::info!("[sftp] creating directory: '{}'", actual_path);
|
||||
|
||||
match sftp.create_dir(&actual_path).await {
|
||||
Ok(_) => {
|
||||
@@ -664,6 +668,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(),
|
||||
@@ -795,11 +800,28 @@ 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 +873,31 @@ 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 {
|
||||
@@ -22,28 +22,25 @@ pub(crate) fn is_editable_text_file(filename: &str) -> bool {
|
||||
|
||||
impl Ashell {
|
||||
pub(crate) fn active_sftp(&self) -> Option<&terminal::SftpUiState> {
|
||||
self.active_tab
|
||||
.as_ref()
|
||||
.and_then(|id| self.tabs.iter().find(|tab| &tab.id == id))
|
||||
.and_then(|tab| tab.sftp.as_ref())
|
||||
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_tab.clone()?;
|
||||
self.tabs
|
||||
.iter_mut()
|
||||
.find(|tab| tab.id == active_id)
|
||||
.and_then(|tab| tab.sftp.as_mut())
|
||||
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_tab
|
||||
.as_ref()
|
||||
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;
|
||||
@@ -137,10 +134,9 @@ impl Ashell {
|
||||
let Some(menu) = self.sftp_context_menu.take() else {
|
||||
return;
|
||||
};
|
||||
if let Some(id) = self.active_tab.clone() {
|
||||
if let Some(handle) = self.sftp_handles.get(&id) {
|
||||
handle.edit_file(menu.remote_path);
|
||||
}
|
||||
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();
|
||||
}
|
||||
@@ -164,7 +160,9 @@ impl Ashell {
|
||||
match path_prompt.await {
|
||||
Ok(Ok(Some(mut paths))) => {
|
||||
if let Some(folder) = paths.pop() {
|
||||
handle.download(remote_path, folder.to_string_lossy().to_string());
|
||||
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);
|
||||
}
|
||||
}
|
||||
Ok(Err(err)) => {
|
||||
@@ -198,7 +196,9 @@ impl Ashell {
|
||||
match path_prompt.await {
|
||||
Ok(Ok(Some(mut paths))) => {
|
||||
if let Some(file) = paths.pop() {
|
||||
handle.upload_paths(vec![file.to_string_lossy().to_string()], remote_dir);
|
||||
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);
|
||||
}
|
||||
}
|
||||
Ok(Err(err)) => {
|
||||
@@ -232,7 +232,9 @@ impl Ashell {
|
||||
match path_prompt.await {
|
||||
Ok(Ok(Some(mut paths))) => {
|
||||
if let Some(folder) = paths.pop() {
|
||||
handle.upload_paths(vec![folder.to_string_lossy().to_string()], remote_dir);
|
||||
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);
|
||||
}
|
||||
}
|
||||
Ok(Err(err)) => {
|
||||
@@ -297,6 +299,7 @@ impl Ashell {
|
||||
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,
|
||||
@@ -323,6 +326,7 @@ impl Ashell {
|
||||
}
|
||||
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(),
|
||||
@@ -20,6 +20,52 @@ impl Ashell {
|
||||
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();
|
||||
@@ -265,9 +311,20 @@ impl Ashell {
|
||||
pub(crate) fn on_terminal_mouse_move(
|
||||
&mut self,
|
||||
event: &MouseMoveEvent,
|
||||
_window: &mut Window,
|
||||
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;
|
||||
}
|
||||
@@ -289,6 +346,9 @@ impl Ashell {
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if self.dragging_splitter.is_some() {
|
||||
self.end_drag_split();
|
||||
}
|
||||
self.terminal_selecting = false;
|
||||
cx.notify();
|
||||
}
|
||||
@@ -297,8 +357,17 @@ impl Ashell {
|
||||
&self,
|
||||
position: Point<Pixels>,
|
||||
) -> Option<(usize, usize, Side)> {
|
||||
let bounds = self.terminal_bounds?;
|
||||
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.));
|
||||
@@ -325,19 +394,85 @@ impl Ashell {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let delta_lines = match event.delta {
|
||||
ScrollDelta::Lines(point) => point.y.round() as i32,
|
||||
ScrollDelta::Pixels(point) => {
|
||||
(point.y.as_f32() / self.terminal_line_height()).round() as i32
|
||||
}
|
||||
};
|
||||
if delta_lines == 0 {
|
||||
return;
|
||||
}
|
||||
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();
|
||||
@@ -1,3 +1,6 @@
|
||||
pub mod element;
|
||||
pub mod input;
|
||||
|
||||
use std::sync::mpsc::Sender;
|
||||
|
||||
use alacritty_terminal::{
|
||||
@@ -10,7 +13,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 +27,7 @@ pub enum TabKind {
|
||||
pub enum BackendCommand {
|
||||
Input(Vec<u8>),
|
||||
Resize { cols: u16, rows: u16 },
|
||||
SampleMetrics,
|
||||
Close,
|
||||
}
|
||||
|
||||
@@ -113,12 +117,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 +206,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 +224,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 +238,15 @@ 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 +443,6 @@ impl EventListener for TerminalListener {
|
||||
}
|
||||
}
|
||||
|
||||
use rust_i18n::t;
|
||||
fn new_term(
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
Reference in New Issue
Block a user