diff --git a/CHANGELOG.md b/CHANGELOG.md
index 944f558..5d26ee6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,36 @@ Every release entry is provided in English and Simplified Chinese.
每个版本条目均同时提供英文和简体中文说明。
+## Unreleased
+
+### English
+
+#### Added
+
+- **Five SSH sign-in modes** — each saved host can now use Automatic, Password, Private Key, Agent, or Keyboard-interactive authentication. Automatic mode follows Tabby's practical order, while an explicitly selected mode stays strict and does not silently fall back to another method.
+- **Private-key sign-in** — choose one or more OpenSSH, PEM, or PPK private keys from the host editor. Key paths stay on the local machine, encrypted-key passphrases are stored in Windows Credential Manager by key fingerprint, and public-key files are rejected with a clear message.
+- **SFTP file transfer** — open SFTP from an SSH host's right-click menu and reuse the same authenticated connection. The remote drawer supports editable paths, filtering, refresh, file and folder uploads, file and folder downloads, new folders, rename, recursive delete, symlink targets, progress, cancellation, and visible errors.
+- **Pull from the Git drawer** — the right-side Git view now has a Pull button beside Stage, Commit, and Push. Pulls use fast-forward-only mode so Nebula never creates an unexpected merge commit.
+
+#### Improved
+
+- **A cleaner portable archive** — the ZIP root now contains only `nebula.exe` and `README.md`. Runtime helpers, fonts, documentation, and licenses are grouped under `runtime`, `fonts`, `docs`, and `licenses`, and Nebula resolves the new runtime layout automatically.
+- **Safer transfers** — uploads and downloads stream in 256 KiB chunks instead of loading whole files into memory. Files are written to temporary paths first and moved into place only after the transfer succeeds, while failed or cancelled transfers clean up their partial files.
+
+### 简体中文
+
+#### 新增
+
+- **五种 SSH 登录方式** — 每个已保存主机都可以选择自动、密码、密钥、Agent 或交互式认证。自动模式会按照 Tabby 的实用顺序逐项尝试;手动选择某种方式后则只使用该方式,不会失败后悄悄换成其他登录方式。
+- **私钥登录** — 主机编辑器现在可以选择一把或多把 OpenSSH、PEM 或 PPK 私钥。Nebula 只保存本机密钥路径,加密私钥的口令会按密钥指纹存入 Windows 凭据管理器;如果误选 `.pub` 公钥,也会直接提示重新选择私钥。
+- **SFTP 文件传输** — 在 SSH 主机右键菜单中即可打开 SFTP,并复用同一条已认证连接。远端抽屉支持手动路径、筛选、刷新、上传文件和文件夹、下载文件和文件夹、新建文件夹、重命名、递归删除、符号链接目标、传输进度、取消和可见错误提示。
+- **Git 抽屉可以拉取了** — 右侧 Git 页面在暂存、提交和推送旁新增“拉取”按钮。拉取只接受快进更新,不会在用户不知情时自动生成合并提交。
+
+#### 改进
+
+- **便携包不再乱糟糟** — ZIP 根目录现在只保留 `nebula.exe` 和 `README.md`;运行组件、字体、文档和许可证分别放进 `runtime`、`fonts`、`docs` 与 `licenses`,Nebula 会自动识别新的运行组件位置。
+- **传文件更稳妥** — 上传和下载按 256 KiB 分块流式传输,不会把整个大文件一次性塞进内存。文件会先写入临时位置,完整成功后才替换正式文件;失败或取消时也会清理未完成的临时文件。
+
## 0.4.0 - 2026-07-14
### Terminal Rendering And Interaction / 终端渲染与交互
diff --git a/Cargo.lock b/Cargo.lock
index d148661..5e896f8 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -74,7 +74,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef6978589202a00cd7e118380c448a08b6ed394c3a8df3a430d0898e3a42d046"
dependencies = [
"android-properties",
- "bitflags 2.9.4",
+ "bitflags 2.13.0",
"cc",
"cesu8",
"jni",
@@ -232,11 +232,11 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
-version = "2.9.4"
+version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394"
+checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
dependencies = [
- "serde",
+ "serde_core",
]
[[package]]
@@ -316,7 +316,7 @@ version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec"
dependencies = [
- "bitflags 2.9.4",
+ "bitflags 2.13.0",
"log",
"polling",
"rustix 0.38.44",
@@ -738,6 +738,20 @@ dependencies = [
"syn",
]
+[[package]]
+name = "dashmap"
+version = "6.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf"
+dependencies = [
+ "cfg-if",
+ "crossbeam-utils",
+ "hashbrown 0.14.5",
+ "lock_api",
+ "once_cell",
+ "parking_lot_core",
+]
+
[[package]]
name = "data-encoding"
version = "2.11.0"
@@ -820,7 +834,7 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec"
dependencies = [
- "bitflags 2.9.4",
+ "bitflags 2.13.0",
"objc2 0.6.3",
]
@@ -1053,7 +1067,7 @@ version = "0.36.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5442dee36ca09604133580dc0553780e867936bb3cbef3275859e889026d2b17"
dependencies = [
- "bitflags 2.9.4",
+ "bitflags 2.13.0",
"freetype-sys",
"libc",
]
@@ -1272,13 +1286,25 @@ dependencies = [
"xkeysym",
]
+[[package]]
+name = "gloo-timers"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "482ce8a491a501da4cd806bd190275363d674f2845005c6ddbd5d3e1dd54495d"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "js-sys",
+ "wasm-bindgen",
+]
+
[[package]]
name = "glutin"
version = "0.32.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325"
dependencies = [
- "bitflags 2.9.4",
+ "bitflags 2.13.0",
"cfg_aliases",
"cgl",
"dispatch2",
@@ -1337,6 +1363,12 @@ dependencies = [
"subtle",
]
+[[package]]
+name = "hashbrown"
+version = "0.14.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
+
[[package]]
name = "hashbrown"
version = "0.16.0"
@@ -1418,7 +1450,7 @@ dependencies = [
"js-sys",
"log",
"wasm-bindgen",
- "windows-core 0.61.2",
+ "windows-core 0.62.2",
]
[[package]]
@@ -1437,7 +1469,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5"
dependencies = [
"equivalent",
- "hashbrown",
+ "hashbrown 0.16.0",
]
[[package]]
@@ -1446,7 +1478,7 @@ version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f37dccff2791ab604f9babef0ba14fbe0be30bd368dc541e2b08d07c8aa908f3"
dependencies = [
- "bitflags 2.9.4",
+ "bitflags 2.13.0",
"inotify-sys",
"libc",
]
@@ -1528,11 +1560,12 @@ dependencies = [
[[package]]
name = "js-sys"
-version = "0.3.81"
+version = "0.3.103"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ec48937a97411dcb524a265206ccd4c90bb711fca92b2792c407f268825b9305"
+checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"
dependencies = [
- "once_cell",
+ "cfg-if",
+ "futures-util",
"wasm-bindgen",
]
@@ -1562,7 +1595,7 @@ version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a"
dependencies = [
- "bitflags 2.9.4",
+ "bitflags 2.13.0",
"serde",
"unicode-segmentation",
]
@@ -1621,7 +1654,7 @@ version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb"
dependencies = [
- "bitflags 2.9.4",
+ "bitflags 2.13.0",
"libc",
"redox_syscall 0.5.18",
]
@@ -1649,11 +1682,11 @@ dependencies = [
[[package]]
name = "log"
-version = "0.4.28"
+version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432"
+checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
dependencies = [
- "serde",
+ "serde_core",
]
[[package]]
@@ -1749,7 +1782,7 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
dependencies = [
- "bitflags 2.9.4",
+ "bitflags 2.13.0",
"jni-sys",
"log",
"ndk-sys",
@@ -1779,7 +1812,8 @@ version = "0.4.0"
dependencies = [
"ahash",
"base64",
- "bitflags 2.9.4",
+ "bitflags 2.13.0",
+ "chrono",
"clap",
"clap_complete",
"copypasta",
@@ -1806,9 +1840,11 @@ dependencies = [
"polling",
"pulldown-cmark",
"russh",
+ "russh-sftp",
"serde",
"serde_json",
"serde_yaml",
+ "sha2",
"signal-hook",
"tauri-winrt-notification",
"tempfile",
@@ -1865,7 +1901,7 @@ name = "nebula_terminal"
version = "0.4.0"
dependencies = [
"base64",
- "bitflags 2.9.4",
+ "bitflags 2.13.0",
"home",
"libc",
"log",
@@ -1890,7 +1926,7 @@ version = "0.31.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
dependencies = [
- "bitflags 2.9.4",
+ "bitflags 2.13.0",
"cfg-if",
"cfg_aliases",
"libc",
@@ -1902,7 +1938,7 @@ version = "8.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3"
dependencies = [
- "bitflags 2.9.4",
+ "bitflags 2.13.0",
"fsevent-sys",
"inotify",
"kqueue",
@@ -2020,7 +2056,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff"
dependencies = [
- "bitflags 2.9.4",
+ "bitflags 2.13.0",
"block2",
"libc",
"objc2 0.5.2",
@@ -2036,7 +2072,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
dependencies = [
- "bitflags 2.9.4",
+ "bitflags 2.13.0",
"objc2 0.6.3",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
@@ -2048,7 +2084,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009"
dependencies = [
- "bitflags 2.9.4",
+ "bitflags 2.13.0",
"block2",
"objc2 0.5.2",
"objc2-core-location",
@@ -2072,7 +2108,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef"
dependencies = [
- "bitflags 2.9.4",
+ "bitflags 2.13.0",
"block2",
"objc2 0.5.2",
"objc2-foundation 0.2.2",
@@ -2084,7 +2120,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [
- "bitflags 2.9.4",
+ "bitflags 2.13.0",
"dispatch2",
"objc2 0.6.3",
]
@@ -2125,7 +2161,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8"
dependencies = [
- "bitflags 2.9.4",
+ "bitflags 2.13.0",
"block2",
"dispatch",
"libc",
@@ -2138,7 +2174,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
- "bitflags 2.9.4",
+ "bitflags 2.13.0",
"objc2 0.6.3",
"objc2-core-foundation",
]
@@ -2161,7 +2197,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6"
dependencies = [
- "bitflags 2.9.4",
+ "bitflags 2.13.0",
"block2",
"objc2 0.5.2",
"objc2-foundation 0.2.2",
@@ -2173,7 +2209,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a"
dependencies = [
- "bitflags 2.9.4",
+ "bitflags 2.13.0",
"block2",
"objc2 0.5.2",
"objc2-foundation 0.2.2",
@@ -2196,7 +2232,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f"
dependencies = [
- "bitflags 2.9.4",
+ "bitflags 2.13.0",
"block2",
"objc2 0.5.2",
"objc2-cloud-kit",
@@ -2228,7 +2264,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3"
dependencies = [
- "bitflags 2.9.4",
+ "bitflags 2.13.0",
"block2",
"objc2 0.5.2",
"objc2-core-location",
@@ -2568,7 +2604,7 @@ version = "0.13.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e"
dependencies = [
- "bitflags 2.9.4",
+ "bitflags 2.13.0",
"memchr",
"unicase",
]
@@ -2641,7 +2677,7 @@ version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
- "bitflags 2.9.4",
+ "bitflags 2.13.0",
]
[[package]]
@@ -2703,7 +2739,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca6c3c1dd0a9ad3a9915a2f6e907d158f9a7e9ac32ddc772b003faafc027d9a8"
dependencies = [
"aes",
- "bitflags 2.9.4",
+ "bitflags 2.13.0",
"block-padding",
"byteorder",
"bytes",
@@ -2777,6 +2813,26 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "russh-sftp"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed8949eca4163c18a8f59ff96d32cf61e9c13b9735e21ef32b3907f4aafa1a9"
+dependencies = [
+ "bitflags 2.13.0",
+ "bytes",
+ "chrono",
+ "dashmap",
+ "gloo-timers",
+ "log",
+ "serde",
+ "serde_bytes",
+ "thiserror 2.0.18",
+ "tokio",
+ "tokio-util",
+ "wasm-bindgen-futures",
+]
+
[[package]]
name = "russh-util"
version = "0.52.0"
@@ -2804,7 +2860,7 @@ version = "0.38.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
dependencies = [
- "bitflags 2.9.4",
+ "bitflags 2.13.0",
"errno",
"libc",
"linux-raw-sys 0.4.15",
@@ -2817,7 +2873,7 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e"
dependencies = [
- "bitflags 2.9.4",
+ "bitflags 2.13.0",
"errno",
"libc",
"linux-raw-sys 0.11.0",
@@ -2932,6 +2988,16 @@ dependencies = [
"serde_derive",
]
+[[package]]
+name = "serde_bytes"
+version = "0.11.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8"
+dependencies = [
+ "serde",
+ "serde_core",
+]
+
[[package]]
name = "serde_core"
version = "1.0.228"
@@ -3089,7 +3155,7 @@ version = "0.19.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016"
dependencies = [
- "bitflags 2.9.4",
+ "bitflags 2.13.0",
"calloop",
"calloop-wayland-source",
"cursor-icon",
@@ -3349,6 +3415,20 @@ dependencies = [
"syn",
]
+[[package]]
+name = "tokio-util"
+version = "0.7.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "futures-sink",
+ "futures-util",
+ "pin-project-lite",
+ "tokio",
+]
+
[[package]]
name = "toml"
version = "0.9.11+spec-1.1.0"
@@ -3520,7 +3600,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd"
dependencies = [
"arrayvec",
- "bitflags 2.9.4",
+ "bitflags 2.13.0",
"cursor-icon",
"log",
"memchr",
@@ -3563,9 +3643,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen"
-version = "0.2.104"
+version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c1da10c01ae9f1ae40cbfac0bac3b1e724b320abfcf52229f80b547c0d250e2d"
+checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"
dependencies = [
"cfg-if",
"once_cell",
@@ -3574,38 +3654,21 @@ dependencies = [
"wasm-bindgen-shared",
]
-[[package]]
-name = "wasm-bindgen-backend"
-version = "0.2.104"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "671c9a5a66f49d8a47345ab942e2cb93c7d1d0339065d4f8139c486121b43b19"
-dependencies = [
- "bumpalo",
- "log",
- "proc-macro2",
- "quote",
- "syn",
- "wasm-bindgen-shared",
-]
-
[[package]]
name = "wasm-bindgen-futures"
-version = "0.4.54"
+version = "0.4.76"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7e038d41e478cc73bae0ff9b36c60cff1c98b8f38f8d7e8061e79ee63608ac5c"
+checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d"
dependencies = [
- "cfg-if",
"js-sys",
- "once_cell",
"wasm-bindgen",
- "web-sys",
]
[[package]]
name = "wasm-bindgen-macro"
-version = "0.2.104"
+version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7ca60477e4c59f5f2986c50191cd972e3a50d8a95603bc9434501cf156a9a119"
+checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -3613,22 +3676,22 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
-version = "0.2.104"
+version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9f07d2f20d4da7b26400c9f4a0511e6e0345b040694e8a75bd41d578fa4421d7"
+checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"
dependencies = [
+ "bumpalo",
"proc-macro2",
"quote",
"syn",
- "wasm-bindgen-backend",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
-version = "0.2.104"
+version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bad67dc8b2a1a6e5448428adec4c3e84c43e561d8c9ee8a9e5aabeb193ec41d1"
+checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"
dependencies = [
"unicode-ident",
]
@@ -3653,7 +3716,7 @@ version = "0.31.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c66a47e840dc20793f2264eb4b3e4ecb4b75d91c0dd4af04b456128e0bdd449d"
dependencies = [
- "bitflags 2.9.4",
+ "bitflags 2.13.0",
"rustix 1.1.2",
"wayland-backend",
"wayland-scanner",
@@ -3665,7 +3728,7 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e"
dependencies = [
- "bitflags 2.9.4",
+ "bitflags 2.13.0",
"cursor-icon",
"wayland-backend",
]
@@ -3687,7 +3750,7 @@ version = "0.32.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "efa790ed75fbfd71283bd2521a1cfdc022aabcc28bdcff00851f9e4ae88d9901"
dependencies = [
- "bitflags 2.9.4",
+ "bitflags 2.13.0",
"wayland-backend",
"wayland-client",
"wayland-scanner",
@@ -3699,7 +3762,7 @@ version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a07a14257c077ab3279987c4f8bb987851bf57081b93710381daea94f2c2c032"
dependencies = [
- "bitflags 2.9.4",
+ "bitflags 2.13.0",
"wayland-backend",
"wayland-client",
"wayland-protocols",
@@ -3712,7 +3775,7 @@ version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "efd94963ed43cf9938a090ca4f7da58eb55325ec8200c3848963e98dc25b78ec"
dependencies = [
- "bitflags 2.9.4",
+ "bitflags 2.13.0",
"wayland-backend",
"wayland-client",
"wayland-protocols",
@@ -3744,9 +3807,9 @@ dependencies = [
[[package]]
name = "web-sys"
-version = "0.3.81"
+version = "0.3.103"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9367c417a924a74cae129e6a2ae3b47fabb1f8995595ab474029da749a8be120"
+checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141"
dependencies = [
"js-sys",
"wasm-bindgen",
@@ -4241,7 +4304,7 @@ dependencies = [
"ahash",
"android-activity",
"atomic-waker",
- "bitflags 2.9.4",
+ "bitflags 2.13.0",
"block2",
"bytemuck",
"calloop",
@@ -4389,7 +4452,7 @@ version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5"
dependencies = [
- "bitflags 2.9.4",
+ "bitflags 2.13.0",
"dlib",
"log",
"once_cell",
diff --git a/INSTALL.md b/INSTALL.md
index 71f489f..e22856e 100644
--- a/INSTALL.md
+++ b/INSTALL.md
@@ -5,19 +5,23 @@
1. Download `NebulaTerminal-v0.4.0-windows-x64.zip` from the
[Releases](https://github.com/Kuddev/nebula/releases) page.
2. Unzip anywhere (no installer, no admin rights).
-3. **Install the font**: double-click `MapleMonoNormal-NF-CN-Regular.ttf` and press
+3. **Install the font**: open `fonts`, double-click
+ `MapleMonoNormal-NF-CN-Regular.ttf`, and press
*Install*. Nebula's powerline prompt and icons need this Nerd Font —
without it they render as `□` boxes.
4. Run `nebula.exe`.
-Keep these files together in one directory:
+Keep the extracted directory structure intact:
-| File | Purpose |
+| Path | Purpose |
| --- | --- |
| `nebula.exe` | the terminal |
-| `nebula-hook.exe` | AI turn-notification bridge (Claude Code / Codex) |
-| `conpty.dll` + `OpenConsole.exe` | modern ConPTY host (correct resize, fast tab spawn) |
-| `MapleMonoNormal-NF-CN-Regular.ttf` | Nerd Font for powerline/icons — install once (SIL OFL 1.1) |
+| `README.md` | overview and usage |
+| `runtime/nebula-hook.exe` | AI turn-notification bridge (Claude Code / Codex) |
+| `runtime/conpty.dll` + `runtime/OpenConsole.exe` | modern ConPTY host (correct resize, fast tab spawn) |
+| `fonts/MapleMonoNormal-NF-CN-Regular.ttf` | Nerd Font for powerline/icons — install once (SIL OFL 1.1) |
+| `docs/CHANGELOG.md` + `docs/INSTALL.md` | release changes and installation details |
+| `licenses/` | Nebula and third-party license notices |
## Build from source
@@ -29,10 +33,17 @@ cd nebula
cargo build --release
```
-Artifacts land in `target/release/`. Copy
-`assets/windows/conhost/{conpty.dll,OpenConsole.exe}` next to `nebula.exe`
-when distributing (the build runs fine without them, falling back to the
-in-box ConPTY, but resize behavior of full-screen TUIs is worse).
+Build and assemble the portable archive with:
+
+```powershell
+powershell -ExecutionPolicy Bypass -File scripts/package-release.ps1 `
+ -Version unreleased -Force
+```
+
+The script builds the release workspace, verifies every required input, stages
+the directory layout above, creates the ZIP, and prints its file count, packed
+and unpacked sizes, and SHA-256. Use `-SkipBuild` only when the release binaries
+have already been built and verified.
## First run
diff --git a/README.md b/README.md
index 6d05452..a77f7d1 100644
--- a/README.md
+++ b/README.md
@@ -98,6 +98,11 @@ experience that works without extra setup.
passwords, and keyboard-interactive/MFA. Accepted host keys use the standard
`known_hosts` store, and additional tabs reuse an already authenticated
connection to the same `user@host:port` for a faster second shell.
+- **Built-in SFTP transfers** — open a remote file drawer from any saved SSH
+ host and reuse its authenticated connection. Browse or type remote paths,
+ filter entries, upload and download files or folders, create and rename
+ folders, recursively delete, follow symlink targets, and cancel transfers
+ with visible progress and errors.
- **AI-aware over SSH** — remote Hook envelopes can return through a
per-channel, randomly authenticated private OSC bridge. Nebula validates the
channel token, replaces any remote pane identifier with the local pane, and
@@ -146,6 +151,9 @@ experience that works without extra setup.
persists across restarts.
- **Tabs & splits** — sidebar tabs with drag-to-reorder and drag-to-dock into
splits; unfocused panes dim instead of growing borders.
+- **Files, Git and SFTP drawers** — browse local or remote files without
+ leaving the terminal. The Git drawer can stage, commit, pull fast-forward
+ updates, and push.
- **Quick terminal** — global Ctrl+` drops a Quake-style
terminal from the top edge.
- **Floating settings panel & command palette** — themes, background
@@ -158,15 +166,17 @@ experience that works without extra setup.
> **⚠️ Install the bundled font first.** Nebula's powerline prompt, program
> icons and AI brand marks are drawn with **Maple Mono Normal NF CN** (a Nerd Font).
> Without it, those glyphs render as `□` boxes. The font ships in the release
-> zip and the repo at `assets/fonts/MapleMonoNormal-NF-CN-Regular.ttf` — double-click
+> zip under `fonts/` and the repo at
+> `assets/fonts/MapleMonoNormal-NF-CN-Regular.ttf` — double-click
> it and press **Install**, then launch Nebula. (Licensed under SIL OFL 1.1.)
**Release build (recommended)** — download
`NebulaTerminal-v0.4.0-windows-x64.zip` from
[Releases](https://github.com/Kuddev/nebula/releases), unzip
-anywhere, install `MapleMonoNormal-NF-CN-Regular.ttf`, then run `nebula.exe`. Keep
-the bundled files next to the exe (`nebula-hook.exe` powers AI notifications;
-`conpty.dll` + `OpenConsole.exe` provide the modern ConPTY host).
+anywhere, install `fonts/MapleMonoNormal-NF-CN-Regular.ttf`, then run
+`nebula.exe`. Keep the extracted folders in place: AI and ConPTY helpers live
+under `runtime/`, while fonts, documentation, and notices live under `fonts/`,
+`docs/`, and `licenses/`.
**From source**
@@ -245,6 +255,9 @@ Nebula 是一款 Windows 上的终端模拟器,以 Rust 编写,构建在 GPU
口令、Windows OpenSSH Agent、Pageant、Windows 凭据管理器密码以及
keyboard-interactive/MFA。主机密钥写入标准 `known_hosts`;同一
`user@host:port` 再开标签页会复用已认证连接,让第二个 Shell 更快出现。
+- **内置 SFTP 传输** — 从已保存 SSH 主机即可打开远端文件抽屉,并复用已认证
+ 连接。支持浏览或手动输入远端路径、筛选、上传/下载文件与文件夹、新建与重命名
+ 文件夹、递归删除、跟随符号链接目标,以及带进度、取消和错误提示的后台传输。
- **SSH 里也 AI 感知** — 远端 Hook 信封可通过每通道随机认证的私有 OSC 桥
返回。Nebula 会校验通道令牌,用本地 Pane 身份覆盖远端字段,再复用现有侧栏
状态和 Windows 通知链路。需要端口转发、配置查询或显式远程命令时,仍可使用
@@ -286,6 +299,8 @@ Nebula 是一款 Windows 上的终端模拟器,以 Rust 编写,构建在 GPU
系统驱动 chrome、提示符与弹窗,主题选择跨重启持久化。
- **标签与分屏** — 侧边栏标签支持拖拽排序、拖入终端区四方位分屏;非焦点
面板压暗而非描边。
+- **文件、Git 与 SFTP 抽屉** — 不离开终端即可浏览本地或远端文件;Git 抽屉
+ 支持暂存、提交、仅快进拉取和推送。
- **快速终端** — 全局 Ctrl+` 从屏幕顶部滑出
Quake 式终端。
- **悬浮设置面板与命令面板** — 主题、背景图/透明度、shell 选择、补全行为,
@@ -297,15 +312,16 @@ Nebula 是一款 Windows 上的终端模拟器,以 Rust 编写,构建在 GPU
> **⚠️ 请先安装随附字体。** Nebula 的 powerline 提示符、程序图标与 AI 品牌
> 标识都用 **Maple Mono Normal NF CN**(一款 Nerd Font)绘制。不装的话这些字形会显
> 示成 `□` 方框。字体在 release 包内、仓库里也有:
-> `assets/fonts/MapleMonoNormal-NF-CN-Regular.ttf` —— 双击它点**安装**,再启动
+> `assets/fonts/MapleMonoNormal-NF-CN-Regular.ttf`;压缩包内位于 `fonts/` ——
+> 双击它点**安装**,再启动
> Nebula。(SIL OFL 1.1 许可。)
**Release 包(推荐)** — 从
[Releases](https://github.com/Kuddev/nebula/releases) 下载
`NebulaTerminal-v0.4.0-windows-x64.zip`,解压到任意目录,先安装
-`MapleMonoNormal-NF-CN-Regular.ttf`,再运行 `nebula.exe`。请保持随包文件与 exe
-同目录(`nebula-hook.exe` 驱动 AI 通知;`conpty.dll` + `OpenConsole.exe`
-提供现代 ConPTY 宿主)。
+`fonts/MapleMonoNormal-NF-CN-Regular.ttf`,再运行 `nebula.exe`。请保持解压后的
+目录结构不变:AI 通知与 ConPTY 组件位于 `runtime/`,字体、文档和许可说明分别
+位于 `fonts/`、`docs/` 与 `licenses/`。
**从源码构建**
diff --git a/nebula_app/Cargo.toml b/nebula_app/Cargo.toml
index 706df82..90980b0 100644
--- a/nebula_app/Cargo.toml
+++ b/nebula_app/Cargo.toml
@@ -33,6 +33,7 @@ version = "0.4.0"
ahash = { version = "0.8.6", features = ["no-rng"] }
base64 = "0.22.0"
bitflags = "2.2.1"
+chrono = "0.4.45"
clap = { version = "4.2.7", features = ["derive", "env"] }
copypasta = { version = "0.10.1", default-features = false }
crossfont = "0.8.1"
@@ -47,16 +48,18 @@ polling = "3.11.0"
# event parser; `display::markdown_view` renders the folded block AST.
pulldown-cmark = { version = "0.13", default-features = false }
russh = { version = "0.62.2", default-features = false, features = ["ring"] }
+russh-sftp = "2.3.0"
# preserve_order: we rewrite the user's claude settings.json (ai_hook
# self-heal) and must not alphabetize their keys in the process.
serde_json = { version = "1", features = ["preserve_order"] }
serde = { version = "1", features = ["derive"] }
serde_yaml = "0.9.25"
+sha2 = "0.11"
signal-hook = { version = "0.4.3", default-features = false }
tempfile = "3.12.0"
toml.workspace = true
toml_edit.workspace = true
-tokio = { version = "1", features = ["rt-multi-thread", "sync", "time"] }
+tokio = { version = "1", features = ["fs", "io-util", "rt-multi-thread", "sync", "time"] }
unicode-width = "0.2.0"
winit = { version = "0.30.9", default-features = false, features = ["rwh_06", "serde"] }
global-hotkey = "0.8.0"
@@ -111,6 +114,7 @@ windows-sys = { version = "0.59", features = [
"Win32_System_Com_StructuredStorage",
"Win32_System_Ole",
"Win32_UI_Shell",
+ "Win32_UI_Shell_Common",
"Win32_UI_Shell_PropertiesSystem",
"Win32_System_Variant",
"Win32_System_Threading",
diff --git a/nebula_app/src/ai_hook.rs b/nebula_app/src/ai_hook.rs
index 710bdef..de6a641 100644
--- a/nebula_app/src/ai_hook.rs
+++ b/nebula_app/src/ai_hook.rs
@@ -224,8 +224,9 @@ mod win {
let p = helper.display().to_string().replace('\\', "/");
unsafe { std::env::set_var(HOOK_EXE_ENV, p) };
}
- if let Err(err) =
- std::thread::Builder::new().name("nebula-ai-pipe".into()).spawn(move || serve(&name, proxy))
+ if let Err(err) = std::thread::Builder::new()
+ .name("nebula-ai-pipe".into())
+ .spawn(move || serve(&name, proxy))
{
log::warn!("ai_hook: failed to spawn pipe server: {err}");
}
@@ -336,7 +337,10 @@ mod win {
let (claude_dir, codex_dir) = loop {
let claude = claude_config_dir().filter(|d| d.exists());
let codex = codex_config_dir().filter(|d| d.exists());
- if claude.is_some() || codex.is_some() || opencode_config_dir().is_some_and(|d| d.exists()) {
+ if claude.is_some()
+ || codex.is_some()
+ || opencode_config_dir().is_some_and(|d| d.exists())
+ {
break (claude, codex);
}
std::thread::sleep(Duration::from_secs(300));
@@ -373,9 +377,8 @@ mod win {
Ok(ev) => {
ev.paths.is_empty()
|| ev.paths.iter().any(|p| {
- p.file_name().is_some_and(|n| {
- n == "settings.json" || n == "config.toml"
- })
+ p.file_name()
+ .is_some_and(|n| n == "settings.json" || n == "config.toml")
})
},
Err(_) => true,
@@ -597,10 +600,7 @@ export const NebulaNotify = async ({ $ }) => {
// Mid-rewrite by a concurrent writer, or genuinely broken:
// never "repair" by clobbering. The watcher retries on the
// next change, the boot pass on the next start.
- log::warn!(
- "ai_hook: {} is not valid JSON ({err}); left alone",
- path.display()
- );
+ log::warn!("ai_hook: {} is not valid JSON ({err}); left alone", path.display());
return false;
},
},
@@ -757,7 +757,7 @@ export const NebulaNotify = async ({ $ }) => {
match helper_command() {
Some(command) => println!("hook 命令:{command}"),
None => {
- eprintln!("nebula-hook.exe 不在 nebula.exe 旁边,无法安装。");
+ eprintln!("runtime/ 和 nebula.exe 同目录中均未找到 nebula-hook.exe,无法安装。");
return 1;
},
}
@@ -783,7 +783,10 @@ export const NebulaNotify = async ({ $ }) => {
match opencode_config_dir() {
Some(cfg) if cfg.exists() => {
if ensure_opencode_plugin() {
- println!("opencode: 已安装 {}。", cfg.join("plugins").join("nebula.js").display());
+ println!(
+ "opencode: 已安装 {}。",
+ cfg.join("plugins").join("nebula.js").display()
+ );
} else {
println!("opencode: 插件已是最新。");
}
@@ -858,15 +861,24 @@ export const NebulaNotify = async ({ $ }) => {
Ok(false)
}
- /// Absolute path of the bridge exe (must sit next to nebula.exe).
+ /// Absolute path of the bridge exe.
fn helper_path() -> Option {
let exe = std::env::current_exe().ok()?;
- let helper = exe.parent()?.join("nebula-hook.exe");
- if !helper.exists() {
- log::warn!("ai_hook: {} missing; AI integrations not installed", helper.display());
- return None;
+ let helper = helper_path_from_exe(&exe);
+ if helper.is_none() {
+ log::warn!(
+ "ai_hook: nebula-hook.exe missing from runtime/ and executable directory; AI integrations not installed"
+ );
}
- Some(helper)
+ helper
+ }
+
+ fn helper_path_from_exe(exe: &Path) -> Option {
+ let exe_dir = exe.parent()?;
+ // 新包优先使用分类目录,旧同目录位置仅用于开发构建和兼容历史包。
+ [exe_dir.join("runtime").join("nebula-hook.exe"), exe_dir.join("nebula-hook.exe")]
+ .into_iter()
+ .find(|path| path.is_file())
}
/// The quoted claude hook command. Forward slashes on purpose: they
@@ -884,4 +896,31 @@ export const NebulaNotify = async ({ $ }) => {
std::fs::write(&tmp, data)?;
std::fs::rename(&tmp, path)
}
+
+ #[cfg(test)]
+ mod runtime_asset_tests {
+ use super::helper_path_from_exe;
+
+ #[test]
+ fn hook_helper_prefers_runtime_directory() {
+ let dir = tempfile::tempdir().unwrap();
+ let exe = dir.path().join("nebula.exe");
+ let runtime = dir.path().join("runtime");
+ std::fs::create_dir(&runtime).unwrap();
+ std::fs::write(dir.path().join("nebula-hook.exe"), b"legacy").unwrap();
+ std::fs::write(runtime.join("nebula-hook.exe"), b"structured").unwrap();
+
+ assert_eq!(helper_path_from_exe(&exe), Some(runtime.join("nebula-hook.exe")));
+ }
+
+ #[test]
+ fn hook_helper_falls_back_to_legacy_sibling() {
+ let dir = tempfile::tempdir().unwrap();
+ let exe = dir.path().join("nebula.exe");
+ let legacy = dir.path().join("nebula-hook.exe");
+ std::fs::write(&legacy, b"legacy").unwrap();
+
+ assert_eq!(helper_path_from_exe(&exe), Some(legacy));
+ }
+ }
}
diff --git a/nebula_app/src/display/chrome.rs b/nebula_app/src/display/chrome.rs
index aa85de5..2af92fb 100644
--- a/nebula_app/src/display/chrome.rs
+++ b/nebula_app/src/display/chrome.rs
@@ -992,11 +992,8 @@ pub(super) fn draw_chrome(d: &mut Display) {
ChromeHit::TabsSection | ChromeHit::NewTab | ChromeHit::NewTabMenu
);
if !d.nebula_settings_open && tabs_plus_visible {
- let plus_ink = if d.nebula_chrome_hover == ChromeHit::NewTab {
- sk.icon_hover
- } else {
- sk.icon
- };
+ let plus_ink =
+ if d.nebula_chrome_hover == ChromeHit::NewTab { sk.icon_hover } else { sk.icon };
push_centered_add_icon(&mut quads, tab_layout.plus, scale, plus_ink);
}
// The dropdown chevron beside "+": same hover-only lift.
@@ -1009,11 +1006,7 @@ pub(super) fn draw_chrome(d: &mut Display) {
&mut quads,
tab_layout.menu,
scale,
- if d.nebula_chrome_hover == ChromeHit::NewTabMenu {
- sk.icon_hover
- } else {
- sk.icon
- },
+ if d.nebula_chrome_hover == ChromeHit::NewTabMenu { sk.icon_hover } else { sk.icon },
);
}
@@ -1032,10 +1025,7 @@ pub(super) fn draw_chrome(d: &mut Display) {
let (ax, ay, aw, ah) = tab_layout.hosts_add;
quads.push(UiQuad::solid(ax, ay, aw, ah, s(6.0), HOVER_FILL_STRONG));
}
- if matches!(
- d.nebula_chrome_hover,
- ChromeHit::HostsSection | ChromeHit::AddSshHost
- ) {
+ if matches!(d.nebula_chrome_hover, ChromeHit::HostsSection | ChromeHit::AddSshHost) {
push_centered_add_icon(
&mut quads,
tab_layout.hosts_add,
@@ -1108,14 +1098,26 @@ pub(super) fn draw_chrome(d: &mut Display) {
// drawing through slide-out; animation stepping is centralized in Display.
d.step_chrome_anims();
if d.side_panel_visible() {
- side_panel::push_quads(
- &d.nebula_side_panel,
- &d.side_panel_layout(),
- &d.nebula_theme,
- &mut quads,
- scale,
- size.cell_width(),
- );
+ if let Some(panel) = d.nebula_sftp_panel.as_ref() {
+ let layout = sftp_panel::layout(&d.side_panel_layout(), scale);
+ sftp_panel::push_quads(
+ panel,
+ &layout,
+ &d.nebula_theme,
+ &mut quads,
+ scale,
+ size.cell_width(),
+ );
+ } else {
+ side_panel::push_quads(
+ &d.nebula_side_panel,
+ &d.side_panel_layout(),
+ &d.nebula_theme,
+ &mut quads,
+ scale,
+ size.cell_width(),
+ );
+ }
}
// No base pill behind the gear — just the icon (hover still fills).
@@ -1455,7 +1457,7 @@ pub(super) fn draw_chrome(d: &mut Display) {
// destination here once the connection confirms (`~/.ssh/config`
// aliases still appear automatically too). Styled as helper text,
// NOT as content: smaller and fainter than the caption above it,
- // and indented to the row-label depth (`tab_pad + s(14)`, where
+ // and indented to the row-label depth (`tab_pad + s(14)`, where
// tab/host row text starts) so it reads as a child of the section.
// The hint wraps to the sidebar width; drawing it as one line
// would bleed across the seam onto the terminal grid.
@@ -1578,23 +1580,36 @@ pub(super) fn draw_chrome(d: &mut Display) {
// Palette text (query + result rows) sits on top of every chrome label.
// Drawer text stays below settings; otherwise it pierces the modal glass.
if d.side_panel_visible() && !d.nebula_settings_open {
- // File-tree rows use the terminal's live ANSI palette so the drawer
- // matches `ls` colors exactly (dirs blue, executables green) and
- // follows theme switches with it.
- let ls_colors = side_panel::LsColors {
- dir: d.colors[nebula_terminal::vte::ansi::NamedColor::Blue],
- exec: d.colors[nebula_terminal::vte::ansi::NamedColor::Green],
- };
- side_panel::draw_text(
- &d.nebula_side_panel,
- &d.side_panel_layout(),
- &d.nebula_theme,
- ls_colors,
- &mut d.renderer,
- &mut d.glyph_cache,
- &d.size_info,
- d.window.scale_factor as f32,
- );
+ if let Some(panel) = d.nebula_sftp_panel.as_ref() {
+ let scale = d.window.scale_factor as f32;
+ let layout = sftp_panel::layout(&d.side_panel_layout(), scale);
+ sftp_panel::draw_text(
+ panel,
+ &layout,
+ &d.nebula_theme,
+ &mut d.renderer,
+ &mut d.glyph_cache,
+ &d.size_info,
+ scale,
+ );
+ } else {
+ // File-tree rows use the terminal's live ANSI palette so the drawer
+ // matches `ls` colors exactly (dirs blue, executables green).
+ let ls_colors = side_panel::LsColors {
+ dir: d.colors[nebula_terminal::vte::ansi::NamedColor::Blue],
+ exec: d.colors[nebula_terminal::vte::ansi::NamedColor::Green],
+ };
+ side_panel::draw_text(
+ &d.nebula_side_panel,
+ &d.side_panel_layout(),
+ &d.nebula_theme,
+ ls_colors,
+ &mut d.renderer,
+ &mut d.glyph_cache,
+ &d.size_info,
+ d.window.scale_factor as f32,
+ );
+ }
}
let shell_icon_draws = command_palette::draw_text(
&d.nebula_palette,
diff --git a/nebula_app/src/display/context_menu.rs b/nebula_app/src/display/context_menu.rs
index 3870085..3ff81c4 100644
--- a/nebula_app/src/display/context_menu.rs
+++ b/nebula_app/src/display/context_menu.rs
@@ -6,8 +6,8 @@
use std::time::Instant;
-use super::*;
use super::design_tokens::{control, elevation, motion, space};
+use super::*;
// Maple Mono Normal NF CN ships the stable Codicon block below. Keep menu
// symbols on that block: newer Codicon additions such as U+EC86 are absent
@@ -33,6 +33,7 @@ pub const TAB_COLORS: [Rgb; 7] = [
pub enum ContextMenuTarget {
Tab(usize),
Ssh(usize),
+ Sftp(usize),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -44,9 +45,13 @@ pub enum ContextMenuAction {
CloseTab(usize),
SetTabColor { index: usize, color: Option },
ConnectSsh(usize),
+ OpenSftp(usize),
CopySshAddress(usize),
EditSsh(usize),
DeleteSsh(usize),
+ DownloadSftp(usize),
+ RenameSftp(usize),
+ DeleteSftp(usize),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -149,14 +154,11 @@ fn layout(menu: &ContextMenu, size: SizeInfo, scale: f32, animated_y_offset: f32
let color_label_gap = s(space::XS);
let swatch = s(control::ICON_BUTTON);
let color_bottom_pad = s(10.0);
- let color_h = color_top_pad
- + size.cell_height()
- + color_label_gap
- + swatch
- + color_bottom_pad;
+ let color_h = color_top_pad + size.cell_height() + color_label_gap + swatch + color_bottom_pad;
let (row_count, separators, extra) = match menu.target {
ContextMenuTarget::Tab(_) => (5usize, 2usize, color_h),
- ContextMenuTarget::Ssh(_) => (4usize, 1usize, 0.0),
+ ContextMenuTarget::Ssh(_) => (5usize, 1usize, 0.0),
+ ContextMenuTarget::Sftp(_) => (3usize, 1usize, 0.0),
};
let height = pad * 2.0 + row_count as f32 * row_h + separators as f32 * sep_h + extra;
let margin = s(8.0);
@@ -268,6 +270,15 @@ fn layout(menu: &ContextMenu, size: SizeInfo, scale: f32, animated_y_offset: f32
false,
));
cursor_y += row_h;
+ rows.push(row(
+ ContextMenuAction::OpenSftp(index),
+ "\u{f0c7}",
+ "打开 SFTP",
+ "",
+ cursor_y,
+ false,
+ ));
+ cursor_y += row_h;
rows.push(row(
ContextMenuAction::CopySshAddress(index),
ICON_COPY,
@@ -296,6 +307,35 @@ fn layout(menu: &ContextMenu, size: SizeInfo, scale: f32, animated_y_offset: f32
true,
));
},
+ ContextMenuTarget::Sftp(index) => {
+ rows.push(row(
+ ContextMenuAction::DownloadSftp(index),
+ "\u{eb4a}",
+ "下载",
+ "",
+ cursor_y,
+ false,
+ ));
+ cursor_y += row_h;
+ rows.push(row(
+ ContextMenuAction::RenameSftp(index),
+ ICON_EDIT,
+ "重命名",
+ "F2",
+ cursor_y,
+ false,
+ ));
+ cursor_y += row_h;
+ separator(&mut cursor_y);
+ rows.push(row(
+ ContextMenuAction::DeleteSftp(index),
+ "\u{ea81}",
+ "删除",
+ "",
+ cursor_y,
+ true,
+ ));
+ },
}
MenuLayout { panel, rows, separators: separator_rects, color_label, colors }
@@ -509,20 +549,22 @@ mod tests {
let layout = layout(&menu, size(), 1.0, 0.0);
let first = layout.rows[0];
assert_eq!(
- hit_test(
- &menu,
- size(),
- 1.0,
- first.rect.0 + 2.0,
- first.rect.1 + 2.0,
- ),
+ hit_test(&menu, size(), 1.0, first.rect.0 + 2.0, first.rect.1 + 2.0,),
ContextMenuHit::Action(ContextMenuAction::DuplicateTab(3))
);
assert_eq!(layout.colors.len(), 8);
assert_eq!(layout.colors[0].0, None);
- assert_eq!(
- layout.colors[0].1,
- ContextMenuAction::SetTabColor { index: 3, color: None }
- );
+ assert_eq!(layout.colors[0].1, ContextMenuAction::SetTabColor { index: 3, color: None });
+ }
+
+ #[test]
+ fn sftp_row_menu_exposes_download_rename_and_delete() {
+ let menu = ContextMenu::new(ContextMenuTarget::Sftp(4), (100.0, 100.0), None);
+ let layout = layout(&menu, size(), 1.0, 0.0);
+
+ assert_eq!(layout.rows.len(), 3);
+ assert_eq!(layout.rows[0].action, ContextMenuAction::DownloadSftp(4));
+ assert_eq!(layout.rows[1].action, ContextMenuAction::RenameSftp(4));
+ assert_eq!(layout.rows[2].action, ContextMenuAction::DeleteSftp(4));
}
}
diff --git a/nebula_app/src/display/file_dialog.rs b/nebula_app/src/display/file_dialog.rs
new file mode 100644
index 0000000..afff6bd
--- /dev/null
+++ b/nebula_app/src/display/file_dialog.rs
@@ -0,0 +1,237 @@
+use std::path::PathBuf;
+
+use winit::raw_window_handle::RawWindowHandle;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(super) enum PrivateKeyFileKind {
+ PrivateKey,
+ PublicKey,
+ Unsupported,
+}
+
+pub(super) fn classify_private_key_contents(contents: &[u8]) -> PrivateKeyFileKind {
+ let Ok(text) = std::str::from_utf8(contents) else {
+ return PrivateKeyFileKind::Unsupported;
+ };
+ let trimmed = text.trim_start();
+ if trimmed.starts_with("ssh-")
+ || trimmed.starts_with("ecdsa-sha2-")
+ || trimmed.starts_with("sk-ssh-")
+ || trimmed.starts_with("sk-ecdsa-")
+ || russh::keys::ssh_key::PublicKey::from_openssh(trimmed).is_ok()
+ {
+ return PrivateKeyFileKind::PublicKey;
+ }
+ if [
+ "-----BEGIN OPENSSH PRIVATE KEY-----",
+ "-----BEGIN RSA PRIVATE KEY-----",
+ "-----BEGIN EC PRIVATE KEY-----",
+ "-----BEGIN ENCRYPTED PRIVATE KEY-----",
+ "-----BEGIN PRIVATE KEY-----",
+ "PuTTY-User-Key-File-",
+ ]
+ .iter()
+ .any(|header| trimmed.starts_with(header))
+ {
+ PrivateKeyFileKind::PrivateKey
+ } else {
+ PrivateKeyFileKind::Unsupported
+ }
+}
+
+pub(super) fn pick_image_file(owner: RawWindowHandle) -> Option {
+ pick_file(
+ owner,
+ "Images (*.png;*.jpg;*.jpeg;*.webp;*.bmp)\0*.png;*.jpg;*.jpeg;*.webp;*.bmp\0All files (*.*)\0*.*\0\0",
+ "Choose background image",
+ )
+ .map(|path| path.to_string_lossy().into_owned())
+}
+
+pub(super) fn pick_private_key_file(owner: RawWindowHandle) -> Option> {
+ let path = pick_file(
+ owner,
+ "SSH private keys (id_*;*.pem;*.key;*.ppk)\0id_*;*.pem;*.key;*.ppk\0All files (*.*)\0*.*\0\0",
+ "Choose SSH private key",
+ )?;
+ let contents = match std::fs::read(&path) {
+ Ok(contents) => contents,
+ Err(err) => return Some(Err(format!("无法读取私钥 {}: {err}", path.display()))),
+ };
+ Some(match classify_private_key_contents(&contents) {
+ PrivateKeyFileKind::PrivateKey => Ok(path),
+ PrivateKeyFileKind::PublicKey => Err("请选择私钥文件,不要选择 .pub 公钥".to_owned()),
+ PrivateKeyFileKind::Unsupported => {
+ Err("文件不是受支持的 OpenSSH、PEM 或 PPK 私钥".to_owned())
+ },
+ })
+}
+
+pub(super) fn pick_upload_files(owner: RawWindowHandle) -> Vec {
+ pick_files(owner, "All files (*.*)\0*.*\0\0", "选择要上传的文件", true)
+}
+
+pub(super) fn pick_upload_directory(owner: RawWindowHandle) -> Option {
+ pick_folder(owner, "选择要上传的文件夹")
+}
+
+pub(super) fn pick_download_directory(owner: RawWindowHandle) -> Option {
+ pick_folder(owner, "选择下载位置")
+}
+
+fn pick_file(owner: RawWindowHandle, filter: &str, title: &str) -> Option {
+ pick_files(owner, filter, title, false).into_iter().next()
+}
+
+fn pick_files(owner: RawWindowHandle, filter: &str, title: &str, multiple: bool) -> Vec {
+ use windows_sys::Win32::Foundation::HWND;
+ use windows_sys::Win32::UI::Controls::Dialogs::{
+ GetOpenFileNameW, OFN_ALLOWMULTISELECT, OFN_EXPLORER, OFN_FILEMUSTEXIST, OFN_HIDEREADONLY,
+ OFN_NOCHANGEDIR, OFN_PATHMUSTEXIST, OPENFILENAMEW,
+ };
+
+ let hwnd: HWND = match owner {
+ RawWindowHandle::Win32(handle) => handle.hwnd.get() as HWND,
+ _ => std::ptr::null_mut(),
+ };
+ let filter: Vec = filter.encode_utf16().collect();
+ let title: Vec = title.encode_utf16().chain(std::iter::once(0)).collect();
+ let mut file_buffer = vec![0u16; 32768];
+ let mut dialog: OPENFILENAMEW = unsafe { std::mem::zeroed() };
+ dialog.lStructSize = std::mem::size_of::() as u32;
+ dialog.hwndOwner = hwnd;
+ dialog.lpstrFilter = filter.as_ptr();
+ dialog.lpstrFile = file_buffer.as_mut_ptr();
+ dialog.nMaxFile = file_buffer.len() as u32;
+ dialog.lpstrTitle = title.as_ptr();
+ dialog.Flags = OFN_EXPLORER
+ | OFN_FILEMUSTEXIST
+ | OFN_PATHMUSTEXIST
+ | OFN_NOCHANGEDIR
+ | OFN_HIDEREADONLY
+ | if multiple { OFN_ALLOWMULTISELECT } else { 0 };
+
+ if unsafe { GetOpenFileNameW(&mut dialog) } == 0 {
+ return Vec::new();
+ }
+ parse_open_file_buffer(&file_buffer)
+}
+
+fn parse_open_file_buffer(buffer: &[u16]) -> Vec {
+ let parts = buffer
+ .split(|value| *value == 0)
+ .take_while(|part| !part.is_empty())
+ .map(|part| String::from_utf16_lossy(part))
+ .collect::>();
+ match parts.as_slice() {
+ [] => Vec::new(),
+ [path] => vec![PathBuf::from(path)],
+ [directory, names @ ..] => {
+ let directory = PathBuf::from(directory);
+ names.iter().map(|name| directory.join(name)).collect()
+ },
+ }
+}
+
+fn pick_folder(owner: RawWindowHandle, title: &str) -> Option {
+ use windows_sys::Win32::Foundation::HWND;
+ use windows_sys::Win32::System::Com::CoTaskMemFree;
+ use windows_sys::Win32::UI::Shell::{
+ BIF_NEWDIALOGSTYLE, BIF_RETURNONLYFSDIRS, BROWSEINFOW, SHBrowseForFolderW,
+ SHGetPathFromIDListW,
+ };
+
+ let hwnd: HWND = match owner {
+ RawWindowHandle::Win32(handle) => handle.hwnd.get() as HWND,
+ _ => std::ptr::null_mut(),
+ };
+ let title: Vec = title.encode_utf16().chain(std::iter::once(0)).collect();
+ let mut display_name = vec![0u16; 260];
+ let dialog = BROWSEINFOW {
+ hwndOwner: hwnd,
+ pidlRoot: std::ptr::null_mut(),
+ pszDisplayName: display_name.as_mut_ptr(),
+ lpszTitle: title.as_ptr(),
+ ulFlags: BIF_RETURNONLYFSDIRS | BIF_NEWDIALOGSTYLE,
+ lpfn: None,
+ lParam: 0,
+ iImage: 0,
+ };
+ let item = unsafe { SHBrowseForFolderW(&dialog) };
+ if item.is_null() {
+ return None;
+ }
+
+ let mut path = vec![0u16; 260];
+ let ok = unsafe { SHGetPathFromIDListW(item, path.as_mut_ptr()) } != 0;
+ unsafe { CoTaskMemFree(item.cast()) };
+ if !ok {
+ return None;
+ }
+ let length = path.iter().position(|value| *value == 0).unwrap_or(path.len());
+ (length > 0).then(|| PathBuf::from(String::from_utf16_lossy(&path[..length])))
+}
+
+#[cfg(test)]
+mod tests {
+ use std::path::PathBuf;
+
+ use super::{PrivateKeyFileKind, classify_private_key_contents, parse_open_file_buffer};
+
+ fn wide_parts(parts: &[&str]) -> Vec {
+ let mut buffer = Vec::new();
+ for part in parts {
+ buffer.extend(part.encode_utf16());
+ buffer.push(0);
+ }
+ buffer.push(0);
+ buffer
+ }
+
+ #[test]
+ fn public_key_text_is_rejected_as_private_key() {
+ assert_eq!(
+ classify_private_key_contents(b"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITest user@host"),
+ PrivateKeyFileKind::PublicKey
+ );
+ }
+
+ #[test]
+ fn encrypted_and_putty_private_key_headers_are_accepted_for_later_unlock() {
+ assert_eq!(
+ classify_private_key_contents(
+ b"-----BEGIN OPENSSH PRIVATE KEY-----\nnot-decoded-until-passphrase\n"
+ ),
+ PrivateKeyFileKind::PrivateKey
+ );
+ assert_eq!(
+ classify_private_key_contents(
+ b"PuTTY-User-Key-File-3: ssh-ed25519\nEncryption: aes256-cbc"
+ ),
+ PrivateKeyFileKind::PrivateKey
+ );
+ }
+
+ #[test]
+ fn unrelated_file_is_not_accepted_as_private_key() {
+ assert_eq!(
+ classify_private_key_contents(b"this is not an SSH key"),
+ PrivateKeyFileKind::Unsupported
+ );
+ }
+
+ #[test]
+ fn native_multi_select_buffer_joins_names_to_the_selected_directory() {
+ let buffer = wide_parts(&[r"D:\上传 文件", "alpha.txt", "测试.zip"]);
+ assert_eq!(
+ parse_open_file_buffer(&buffer),
+ vec![PathBuf::from(r"D:\上传 文件\alpha.txt"), PathBuf::from(r"D:\上传 文件\测试.zip"),]
+ );
+ }
+
+ #[test]
+ fn native_single_select_buffer_keeps_the_absolute_path() {
+ let buffer = wide_parts(&[r"D:\release\nebula.zip"]);
+ assert_eq!(parse_open_file_buffer(&buffer), vec![PathBuf::from(r"D:\release\nebula.zip")]);
+ }
+}
diff --git a/nebula_app/src/display/mod.rs b/nebula_app/src/display/mod.rs
index ba1aaac..3d50185 100644
--- a/nebula_app/src/display/mod.rs
+++ b/nebula_app/src/display/mod.rs
@@ -17,7 +17,7 @@ use glutin::error::ErrorKind;
use glutin::prelude::*;
use glutin::surface::{Surface, SwapInterval, WindowSurface};
-use log::{debug, info};
+use log::{debug, info, warn};
use parking_lot::MutexGuard;
use serde::{Deserialize, Serialize};
use winit::dpi::PhysicalSize;
@@ -72,27 +72,32 @@ pub mod hint;
pub mod window;
mod chrome;
+pub mod command_palette;
mod context_menu;
pub mod design_tokens;
-pub mod command_palette;
pub mod markdown_view;
+pub mod sftp_panel;
pub mod side_panel;
pub(crate) use chrome::chrome_settings_button_rect;
pub use chrome::{ChromeHit, TabDropAction, in_chrome_bar, resize_edge};
-pub use context_menu::{ContextMenuAction, ContextMenuHit, ContextMenuTarget};
use chrome::{
ChromeTabLayout, TabDrag, chrome_hit_with_tabs, chrome_tab_layout, contains_rect,
truncate_tab_label,
};
+pub use context_menu::{ContextMenuAction, ContextMenuHit, ContextMenuTarget};
+mod file_dialog;
mod settings;
+mod ssh_editor_render;
mod ssh_ui;
-mod theme;
mod text_input;
+mod theme;
-pub use ssh_ui::{SSH_DELETE_UNDO_DURATION, SshEditorField, SshEditorHit, SshEditorRects, SshHostEditor};
use ssh_ui::SshDeleteUndo;
+pub use ssh_ui::{
+ SSH_DELETE_UNDO_DURATION, SshEditorField, SshEditorHit, SshEditorRects, SshHostEditor,
+};
pub use theme::NebulaTheme;
pub(crate) use theme::write_nebula_prompt_theme;
@@ -310,60 +315,6 @@ struct NebulaPowerlineIcon {
point: Point,
}
-/// Open the native "open file" dialog so the user can pick a background image.
-///
-/// Returns the chosen path, or `None` if the dialog was cancelled. `owner` is
-/// the host window handle so the dialog is modal to Nebula; a non-Win32 handle
-/// falls back to an unowned dialog. `GetOpenFileNameW` runs its own modal
-/// message pump, so this blocks the caller until the user picks or cancels.
-#[cfg(windows)]
-fn nebula_pick_image_file(owner: RawWindowHandle) -> Option {
- use windows_sys::Win32::Foundation::HWND;
- use windows_sys::Win32::UI::Controls::Dialogs::{
- GetOpenFileNameW, OFN_EXPLORER, OFN_FILEMUSTEXIST, OFN_HIDEREADONLY, OFN_NOCHANGEDIR,
- OFN_PATHMUSTEXIST, OPENFILENAMEW,
- };
-
- let hwnd: HWND = match owner {
- RawWindowHandle::Win32(handle) => handle.hwnd.get() as HWND,
- _ => std::ptr::null_mut(),
- };
-
- // Filter is a flat list of NUL-terminated "label\0pattern\0" pairs ending in
- // an extra NUL. Keep the patterns in sync with the image loader.
- let filter: Vec = "Images (*.png;*.jpg;*.jpeg;*.webp;*.bmp)\0\
- *.png;*.jpg;*.jpeg;*.webp;*.bmp\0\
- All files (*.*)\0*.*\0\0"
- .encode_utf16()
- .collect();
- let title: Vec = "Choose background image\0".encode_utf16().collect();
-
- // The selected path is written back into this buffer in place.
- let mut file_buf = vec![0u16; 1024];
-
- let mut ofn: OPENFILENAMEW = unsafe { std::mem::zeroed() };
- ofn.lStructSize = std::mem::size_of::() as u32;
- ofn.hwndOwner = hwnd;
- ofn.lpstrFilter = filter.as_ptr();
- ofn.lpstrFile = file_buf.as_mut_ptr();
- ofn.nMaxFile = file_buf.len() as u32;
- ofn.lpstrTitle = title.as_ptr();
- // NOCHANGEDIR: the dialog must not move our process's working directory.
- ofn.Flags =
- OFN_EXPLORER | OFN_FILEMUSTEXIST | OFN_PATHMUSTEXIST | OFN_NOCHANGEDIR | OFN_HIDEREADONLY;
-
- let picked = unsafe { GetOpenFileNameW(&mut ofn) };
- if picked == 0 {
- return None;
- }
-
- let len = file_buf.iter().position(|&c| c == 0).unwrap_or(file_buf.len());
- if len == 0 {
- return None;
- }
- Some(String::from_utf16_lossy(&file_buf[..len]))
-}
-
/// Which key accepts an inline ghost suggestion.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum AcceptKey {
@@ -444,6 +395,8 @@ pub enum NebulaConfirm {
/// The source flag lets the dialog explain that Nebula never edits the
/// user's SSH config file.
DeleteSsh { host: String, from_config: bool },
+ /// Recursively remove one remote SFTP entry after explicit confirmation.
+ DeleteSftp { entry: crate::ssh_sftp::SftpEntry },
}
/// One OSC 1337 inline image, anchored to an absolute grid row (see
@@ -1432,6 +1385,8 @@ pub struct Display {
nebula_detected_shells: Option>,
/// Right-side drawer: directory tree / git status of the focused cwd.
pub nebula_side_panel: side_panel::SidePanel,
+ /// Remote file drawer opened from an SSH destination context menu.
+ pub nebula_sftp_panel: Option,
/// Shared chrome animation state. All sidebar/drawer transitions step here
/// so easing/timing does not get scattered across render code.
nebula_ui_anims: NebulaUiAnims,
@@ -1683,9 +1638,7 @@ impl Display {
let nebula_theme = if settings_init.follow_system_theme {
nebula_system_theme
.map(|theme| {
- settings_init
- .theme
- .for_system_appearance(matches!(theme, WinitTheme::Light))
+ settings_init.theme.for_system_appearance(matches!(theme, WinitTheme::Light))
})
.unwrap_or(settings_init.theme)
} else {
@@ -1813,6 +1766,7 @@ impl Display {
nebula_palette: command_palette::CommandPalette::new(),
nebula_detected_shells: None,
nebula_side_panel: side_panel::SidePanel::new(),
+ nebula_sftp_panel: None,
nebula_ui_anims: NebulaUiAnims::new(),
nebula_settings_section: NebulaSettingsSection::default(),
nebula_chrome_hover: ChromeHit::None,
@@ -1933,6 +1887,8 @@ impl Display {
password: String::new(),
save_password: true,
show_password: false,
+ auth: crate::ssh_profiles::SshAuthMode::Auto,
+ private_keys: Vec::new(),
field: SshEditorField::Destination,
});
self.nebula_ssh_editor_rects = None;
@@ -1945,6 +1901,13 @@ impl Display {
pub fn edit_ssh_host(&mut self, index: usize) {
let Some(destination) = self.nebula_ssh_hosts.get(index).cloned() else { return };
+ let profile =
+ crate::ssh_profiles::SshProfiles::load(&nebula_data_dir().join("ssh_profiles.json"))
+ .unwrap_or_else(|err| {
+ warn!("加载 SSH Profile 失败,编辑器使用自动认证: {err}");
+ crate::ssh_profiles::SshProfiles::default()
+ })
+ .for_destination(&destination);
self.nebula_ssh_editor = Some(SshHostEditor {
original_destination: Some(destination.clone()),
error: None,
@@ -1957,6 +1920,8 @@ impl Display {
password: String::new(),
save_password: true,
show_password: false,
+ auth: profile.auth,
+ private_keys: profile.private_keys,
field: SshEditorField::Destination,
});
self.nebula_ssh_editor_rects = None;
@@ -1981,10 +1946,19 @@ impl Display {
}
pub fn ssh_editor_hit(&self, x: f32, y: f32) -> SshEditorHit {
- let Some(rects) = self.nebula_ssh_editor_rects else { return SshEditorHit::None };
+ let Some(rects) = self.nebula_ssh_editor_rects.as_ref() else {
+ return SshEditorHit::None;
+ };
let hit = |r: (f32, f32, f32, f32)| x >= r.0 && x < r.0 + r.2 && y >= r.1 && y < r.1 + r.3;
if hit(rects.destination) {
SshEditorHit::Destination
+ } else if let Some((mode, _)) = rects.auth.iter().find(|(_, rect)| hit(*rect)) {
+ SshEditorHit::Auth(*mode)
+ } else if hit(rects.add_private_key) {
+ SshEditorHit::AddPrivateKey
+ } else if let Some((index, _)) = rects.private_key_rows.iter().find(|(_, rect)| hit(*rect))
+ {
+ SshEditorHit::RemovePrivateKey(*index)
} else if hit(rects.password_toggle) {
SshEditorHit::PasswordToggle
} else if hit(rects.password) {
@@ -2026,9 +2000,9 @@ impl Display {
pub fn ssh_editor_backspace(&mut self) {
if let Some(editor) = self.nebula_ssh_editor.as_mut() {
match editor.field {
- SshEditorField::Destination => editor
- .destination_selection
- .backspace(&mut editor.destination),
+ SshEditorField::Destination => {
+ editor.destination_selection.backspace(&mut editor.destination)
+ },
SshEditorField::Password => {
editor.password_selection.backspace(&mut editor.password)
},
@@ -2066,8 +2040,10 @@ impl Display {
if let Some(editor) = self.nebula_ssh_editor.as_mut() {
editor.destination_selection.clear();
editor.password_selection.clear();
+ let shows_password = ssh_ui::auth_sections(editor.auth).0;
editor.field = match editor.field {
- SshEditorField::Destination => SshEditorField::Password,
+ SshEditorField::Destination if shows_password => SshEditorField::Password,
+ SshEditorField::Destination => SshEditorField::Destination,
SshEditorField::Password => SshEditorField::Destination,
};
}
@@ -2080,42 +2056,64 @@ impl Display {
}
pub fn ssh_editor_click(&mut self, x: f32, y: f32) -> bool {
- let Some(rects) = self.nebula_ssh_editor_rects else { return false };
- let hit = |r: (f32, f32, f32, f32)| x >= r.0 && x < r.0 + r.2 && y >= r.1 && y < r.1 + r.3;
- if hit(rects.destination) {
- if let Some(e) = self.nebula_ssh_editor.as_mut() {
- e.destination_selection.clear();
- e.password_selection.clear();
- e.field = SshEditorField::Destination;
- }
- return true;
- }
- if hit(rects.password_toggle) {
- if let Some(e) = self.nebula_ssh_editor.as_mut() {
- e.show_password = !e.show_password;
- }
- return true;
- }
- if hit(rects.password) {
- if let Some(e) = self.nebula_ssh_editor.as_mut() {
- e.destination_selection.clear();
- e.password_selection.clear();
- e.field = SshEditorField::Password;
- }
- return true;
- }
- if hit(rects.save_toggle) {
- self.ssh_editor_toggle_save();
- return true;
- }
- if hit(rects.cancel) {
- self.close_ssh_editor();
- return true;
- }
- if hit(rects.primary) {
- self.save_ssh_editor();
- return true;
+ match self.ssh_editor_hit(x, y) {
+ SshEditorHit::Destination => {
+ if let Some(editor) = self.nebula_ssh_editor.as_mut() {
+ editor.destination_selection.clear();
+ editor.password_selection.clear();
+ editor.field = SshEditorField::Destination;
+ }
+ },
+ SshEditorHit::PasswordToggle => {
+ if let Some(editor) = self.nebula_ssh_editor.as_mut() {
+ editor.show_password = !editor.show_password;
+ }
+ },
+ SshEditorHit::Password => {
+ if let Some(editor) = self.nebula_ssh_editor.as_mut() {
+ editor.destination_selection.clear();
+ editor.password_selection.clear();
+ editor.field = SshEditorField::Password;
+ }
+ },
+ SshEditorHit::Auth(mode) => {
+ if let Some(editor) = self.nebula_ssh_editor.as_mut() {
+ editor.auth = mode;
+ editor.error = None;
+ if !ssh_ui::auth_sections(mode).0 {
+ editor.field = SshEditorField::Destination;
+ }
+ }
+ },
+ SshEditorHit::AddPrivateKey => {
+ if let Some(result) = file_dialog::pick_private_key_file(self.raw_window_handle) {
+ if let Some(editor) = self.nebula_ssh_editor.as_mut() {
+ match result {
+ Ok(path) => {
+ ssh_ui::push_private_key(&mut editor.private_keys, path);
+ editor.error = None;
+ },
+ Err(err) => editor.error = Some(err),
+ }
+ }
+ }
+ },
+ SshEditorHit::RemovePrivateKey(index) => {
+ if let Some(editor) = self.nebula_ssh_editor.as_mut() {
+ if index < editor.private_keys.len() {
+ editor.private_keys.remove(index);
+ }
+ }
+ },
+ SshEditorHit::SaveToggleBox | SshEditorHit::SaveToggleLabel => {
+ self.ssh_editor_toggle_save();
+ },
+ SshEditorHit::Cancel => self.close_ssh_editor(),
+ SshEditorHit::Primary => self.save_ssh_editor(),
+ SshEditorHit::None => return false,
}
+ self.pending_update.dirty = true;
+ self.window.request_redraw();
true
}
@@ -2162,7 +2160,33 @@ impl Display {
&self.nebula_pinned_hosts,
&self.nebula_hidden_hosts,
);
- if editor.save_password && !editor.password.is_empty() {
+ let profile_path = nebula_data_dir().join("ssh_profiles.json");
+ let mut profiles =
+ crate::ssh_profiles::SshProfiles::load(&profile_path).unwrap_or_else(|err| {
+ warn!("加载 SSH Profile 失败,将创建新文件: {err}");
+ crate::ssh_profiles::SshProfiles::default()
+ });
+ if let Some(original) = editor.original_destination.as_deref() {
+ if original != destination {
+ profiles.rename(original, &destination);
+ }
+ }
+ profiles.upsert(crate::ssh_profiles::SshProfileAuth {
+ destination: destination.clone(),
+ auth: editor.auth,
+ private_keys: editor.private_keys.clone(),
+ });
+ if let Err(err) = profiles.save(&profile_path) {
+ editor.error = Some(format!("保存 SSH Profile 失败: {err}"));
+ self.nebula_ssh_editor = Some(editor);
+ self.pending_update.dirty = true;
+ self.window.request_redraw();
+ return;
+ }
+ if ssh_ui::auth_sections(editor.auth).0
+ && editor.save_password
+ && !editor.password.is_empty()
+ {
#[cfg(windows)]
{
let _ = crate::ssh_credentials::store_password(
@@ -2336,6 +2360,7 @@ impl Display {
self.nebula_tab_rename.is_some()
|| self.nebula_side_panel.search_focus
|| self.nebula_side_panel.commit_focus
+ || self.nebula_sftp_panel.as_ref().is_some_and(sftp_panel::SftpPanel::editor_active)
}
/// Decoded (and theme-tinted) pixels for an AI brand logo, plus a stable
@@ -2581,11 +2606,8 @@ impl Display {
return;
}
let color = self.nebula_tab_colors.get(index).copied().flatten();
- self.nebula_context_menu = Some(context_menu::ContextMenu::new(
- ContextMenuTarget::Tab(index),
- (x, y),
- color,
- ));
+ self.nebula_context_menu =
+ Some(context_menu::ContextMenu::new(ContextMenuTarget::Tab(index), (x, y), color));
self.nebula_tab_drag = None;
self.pending_update.dirty = true;
self.window.request_redraw();
@@ -2595,24 +2617,26 @@ impl Display {
if index >= self.nebula_ssh_hosts.len() {
return;
}
- self.nebula_context_menu = Some(context_menu::ContextMenu::new(
- ContextMenuTarget::Ssh(index),
- (x, y),
- None,
- ));
+ self.nebula_context_menu =
+ Some(context_menu::ContextMenu::new(ContextMenuTarget::Ssh(index), (x, y), None));
+ self.pending_update.dirty = true;
+ self.window.request_redraw();
+ }
+
+ pub fn open_sftp_context_menu(&mut self, index: usize, x: f32, y: f32) {
+ let Some(panel) = self.nebula_sftp_panel.as_ref() else { return };
+ if panel.visible_entry(index).is_none() {
+ return;
+ }
+ self.nebula_context_menu =
+ Some(context_menu::ContextMenu::new(ContextMenuTarget::Sftp(index), (x, y), None));
self.pending_update.dirty = true;
self.window.request_redraw();
}
pub fn context_menu_hit(&self, x: f32, y: f32) -> ContextMenuHit {
self.nebula_context_menu.as_ref().map_or(ContextMenuHit::Outside, |menu| {
- context_menu::hit_test(
- menu,
- self.size_info,
- self.window.scale_factor as f32,
- x,
- y,
- )
+ context_menu::hit_test(menu, self.size_info, self.window.scale_factor as f32, x, y)
})
}
@@ -2874,10 +2898,8 @@ impl Display {
/// the saved list. Expired credentials intentionally remain deleted.
pub fn restore_hidden_ssh_host(&mut self, index: usize) {
let Some(host) = self.nebula_hidden_hosts.get(index).cloned() else { return };
- let pending_same_host = self
- .nebula_ssh_delete_undo
- .as_ref()
- .is_some_and(|undo| undo.host == host);
+ let pending_same_host =
+ self.nebula_ssh_delete_undo.as_ref().is_some_and(|undo| undo.host == host);
if pending_same_host && self.undo_delete_ssh_host() {
return;
}
@@ -2948,7 +2970,7 @@ impl Display {
pub fn pick_background_image(&mut self) {
#[cfg(windows)]
{
- if let Some(path) = nebula_pick_image_file(self.raw_window_handle) {
+ if let Some(path) = file_dialog::pick_image_file(self.raw_window_handle) {
self.nebula_background_image = Some(path);
self.persist_nebula_settings();
self.renderer.invalidate_background_image();
@@ -3091,7 +3113,12 @@ impl Display {
/// Toggle the right-side drawer (directory tree / git status).
pub fn toggle_side_panel(&mut self, view: side_panel::PanelView) {
let was_open = self.nebula_side_panel.open;
- self.nebula_side_panel.toggle(view);
+ if self.nebula_sftp_panel.take().is_some() {
+ self.nebula_side_panel.open = true;
+ self.nebula_side_panel.view = view;
+ } else {
+ self.nebula_side_panel.toggle(view);
+ }
// The drawer reserves real grid width, so opening/closing it (not
// just switching views) must reflow the grid like the left sidebar.
if self.nebula_side_panel.open != was_open {
@@ -3209,6 +3236,9 @@ impl Display {
/// Adopt the focused pane's cwd into the drawer (per drawn frame; cheap
/// no-op unless the drawer is open and something changed).
pub fn side_panel_sync(&mut self, cwd: Option) {
+ if self.nebula_sftp_panel.is_some() {
+ return;
+ }
if self.nebula_side_panel.sync(cwd) {
self.pending_update.dirty = true;
}
@@ -3229,6 +3259,163 @@ impl Display {
)
}
+ pub fn open_sftp_panel(
+ &mut self,
+ destination: String,
+ proxy: winit::event_loop::EventLoopProxy,
+ ) -> Result<(), String> {
+ let was_open = self.nebula_side_panel.open;
+ let controller = crate::ssh_sftp::SftpController::new(destination, proxy, self.window.id())
+ .map_err(|err| format!("无法打开 SFTP: {err}"))?;
+ self.nebula_side_panel.search_unfocus(false);
+ self.nebula_side_panel.commit_unfocus();
+ self.nebula_side_panel.open = true;
+ self.nebula_sftp_panel = Some(sftp_panel::SftpPanel::new(controller));
+ if !was_open {
+ let size =
+ PhysicalSize::new(self.size_info.width() as u32, self.size_info.height() as u32);
+ self.pending_update.set_dimensions(size);
+ }
+ self.pending_update.dirty = true;
+ self.window.request_redraw();
+ Ok(())
+ }
+
+ pub fn close_sftp_panel(&mut self) {
+ if let Some(panel) = self.nebula_sftp_panel.take() {
+ panel.cancel_transfer();
+ }
+ if self.nebula_side_panel.open {
+ self.nebula_side_panel.open = false;
+ let size =
+ PhysicalSize::new(self.size_info.width() as u32, self.size_info.height() as u32);
+ self.pending_update.set_dimensions(size);
+ }
+ self.pending_update.dirty = true;
+ self.window.request_redraw();
+ }
+
+ pub fn sftp_layout(&self) -> sftp_panel::SftpLayout {
+ sftp_panel::layout(&self.side_panel_layout(), self.window.scale_factor as f32)
+ }
+
+ pub fn sftp_hit(&self, x: f32, y: f32) -> sftp_panel::SftpHit {
+ let Some(panel) = self.nebula_sftp_panel.as_ref() else {
+ return sftp_panel::SftpHit::None;
+ };
+ let working = panel.snapshot().phase == crate::ssh_sftp::SftpPhase::Working;
+ sftp_panel::hit_test(&self.sftp_layout(), working, x, y)
+ }
+
+ pub fn sftp_set_hover(&mut self, hit: sftp_panel::SftpHit) -> bool {
+ self.nebula_sftp_panel.as_mut().is_some_and(|panel| panel.set_hover(hit))
+ }
+
+ pub fn sftp_click(&mut self, hit: sftp_panel::SftpHit) {
+ use sftp_panel::SftpHit;
+ match hit {
+ SftpHit::Close => self.close_sftp_panel(),
+ SftpHit::Path => {
+ if let Some(panel) = self.nebula_sftp_panel.as_mut() {
+ panel.begin_path();
+ }
+ },
+ SftpHit::Up => {
+ if let Some(panel) = self.nebula_sftp_panel.as_ref() {
+ panel.go_up();
+ }
+ },
+ SftpHit::Refresh => {
+ if let Some(panel) = self.nebula_sftp_panel.as_ref() {
+ panel.refresh();
+ }
+ },
+ SftpHit::UploadFiles => {
+ let paths = file_dialog::pick_upload_files(self.raw_window_handle);
+ if !paths.is_empty() {
+ if let Some(panel) = self.nebula_sftp_panel.as_ref() {
+ panel.upload_paths(paths);
+ }
+ }
+ },
+ SftpHit::UploadDirectory => {
+ if let Some(path) = file_dialog::pick_upload_directory(self.raw_window_handle) {
+ if let Some(panel) = self.nebula_sftp_panel.as_ref() {
+ panel.upload_paths(vec![path]);
+ }
+ }
+ },
+ SftpHit::NewDirectory => {
+ if let Some(panel) = self.nebula_sftp_panel.as_mut() {
+ panel.begin_create_directory();
+ }
+ },
+ SftpHit::Filter => {
+ if let Some(panel) = self.nebula_sftp_panel.as_mut() {
+ panel.begin_filter();
+ }
+ },
+ SftpHit::Row(index) => {
+ let selected =
+ self.nebula_sftp_panel.as_mut().and_then(|panel| panel.select_row(index));
+ if let Some((entry, true)) = selected {
+ let navigated =
+ self.nebula_sftp_panel.as_mut().is_some_and(|panel| panel.navigate(&entry));
+ if !navigated {
+ self.sftp_download_entry(entry);
+ }
+ }
+ },
+ SftpHit::Cancel => {
+ if let Some(panel) = self.nebula_sftp_panel.as_ref() {
+ panel.cancel_transfer();
+ }
+ },
+ SftpHit::None | SftpHit::Inside => {},
+ }
+ self.pending_update.dirty = true;
+ self.window.request_redraw();
+ }
+
+ pub fn sftp_download_row(&mut self, index: usize) {
+ if let Some(entry) =
+ self.nebula_sftp_panel.as_ref().and_then(|panel| panel.visible_entry(index))
+ {
+ self.sftp_download_entry(entry);
+ }
+ }
+
+ fn sftp_download_entry(&mut self, entry: crate::ssh_sftp::SftpEntry) {
+ let Some(directory) = file_dialog::pick_download_directory(self.raw_window_handle) else {
+ return;
+ };
+ if let Some(panel) = self.nebula_sftp_panel.as_ref() {
+ panel.download(entry, directory);
+ }
+ }
+
+ pub fn sftp_begin_rename_row(&mut self, index: usize) {
+ let entry = self.nebula_sftp_panel.as_ref().and_then(|panel| panel.visible_entry(index));
+ if let (Some(panel), Some(entry)) = (self.nebula_sftp_panel.as_mut(), entry) {
+ panel.begin_rename(entry);
+ }
+ }
+
+ pub fn sftp_request_delete_row(&mut self, index: usize) {
+ if let Some(entry) =
+ self.nebula_sftp_panel.as_ref().and_then(|panel| panel.visible_entry(index))
+ {
+ self.nebula_confirm = Some(NebulaConfirm::DeleteSftp { entry });
+ }
+ }
+
+ pub fn sftp_confirm_delete(&mut self, entry: crate::ssh_sftp::SftpEntry) {
+ self.nebula_confirm = None;
+ if let Some(panel) = self.nebula_sftp_panel.as_ref() {
+ panel.delete(entry);
+ }
+ }
+
pub fn step_chrome_anims(&mut self) {
self.nebula_ui_anims.step(!self.nebula_sidebar_collapsed, self.nebula_side_panel.open);
}
@@ -3412,7 +3599,9 @@ impl Display {
}
let active_theme = if settings.follow_system_theme {
self.nebula_system_theme
- .map(|system| settings.theme.for_system_appearance(matches!(system, WinitTheme::Light)))
+ .map(|system| {
+ settings.theme.for_system_appearance(matches!(system, WinitTheme::Light))
+ })
.unwrap_or(settings.theme)
} else {
settings.theme
@@ -4570,6 +4759,16 @@ impl Display {
)
}
},
+ NebulaConfirm::DeleteSftp { entry } => (
+ format!("删除远端项目 {}?", truncate_tab_label(&entry.name, 28)),
+ if entry.kind == crate::ssh_sftp::SftpEntryKind::Directory {
+ "文件夹及其全部远端内容会被递归删除,此操作无法撤销。".to_owned()
+ } else {
+ "远端文件会被永久删除,此操作无法撤销。".to_owned()
+ },
+ "删除 Enter",
+ true,
+ ),
};
let cancel_label = "取消 Esc";
@@ -4666,349 +4865,6 @@ impl Display {
);
}
- fn draw_ssh_editor_modal(&mut self) {
- self.nebula_ui_anims
- .ssh_editor
- .step_with_speed(if self.nebula_ssh_editor_open { 1.0 } else { 0.0 }, 26.0);
- let progress = self.nebula_ui_anims.ssh_editor.value().clamp(0.0, 1.0);
- if !self.nebula_ssh_editor_open && progress <= 0.004 {
- self.nebula_ssh_editor = None;
- self.nebula_ssh_editor_rects = None;
- self.nebula_ssh_editor_hover = SshEditorHit::None;
- return;
- }
- let Some(editor) = self.nebula_ssh_editor.clone() else {
- self.nebula_ssh_editor_rects = None;
- return;
- };
- let size = self.size_info;
- let scale = self.window.scale_factor as f32;
- let s = |v: f32| v * scale;
- let sk = self.nebula_theme.skin();
- let accent = Rgba::new(sk.accent.r, sk.accent.g, sk.accent.b, 255);
- let editor_caret = Rgba::new(sk.ink_strong.r, sk.ink_strong.g, sk.ink_strong.b, 235);
- let cell_h = size.cell_height();
- let cell_w = size.cell_width();
- let text_w = |text: &str| -> f32 {
- text.chars().map(|c| c.width().unwrap_or(1)).sum::() as f32 * cell_w
- };
- let primary_label = "保存 Enter";
- let cancel_label = "取消 Esc";
- let save_label = "保存密码到 Windows 凭据管理器";
- let box_w = s(560.0).min(size.width() - s(32.0));
- let box_h = s(330.0).min(size.height() - s(32.0));
- let bx = (size.width() - box_w) * 0.5;
- // A short downward settle gives the modal direction without making it
- // travel across the whole window. Closing reverses the same path upward.
- let resting_y = (size.height() - box_h) * 0.5;
- let by = resting_y - (1.0 - progress) * s(14.0);
- let pad = s(28.0);
- let field_h = s(42.0);
- let field_w = box_w - pad * 2.0;
- let destination = (bx + pad, by + s(86.0), field_w, field_h);
- let password = (bx + pad, by + s(162.0), field_w, field_h);
- let password_toggle =
- (password.0 + password.2 - s(38.0), password.1 + s(4.0), s(34.0), password.3 - s(8.0));
- // The whole label remains clickable, while the visual hover target is
- // deliberately limited to the checkbox itself.
- let save_toggle =
- (bx + pad, by + s(220.0), (s(28.0) + text_w(save_label)).min(field_w), s(28.0));
- let save_checkbox = (save_toggle.0, save_toggle.1 + s(5.0), s(18.0), s(18.0));
- let button_pad = s(16.0);
- let primary_w = s(112.0).max(text_w(primary_label) + button_pad * 2.0);
- let cancel_w = s(112.0).max(text_w(cancel_label) + button_pad * 2.0);
- let primary = (bx + box_w - pad - primary_w, by + box_h - s(58.0), primary_w, s(36.0));
- let cancel = (primary.0 - s(12.0) - cancel_w, primary.1, cancel_w, s(36.0));
- self.nebula_ssh_editor_rects = Some(SshEditorRects {
- destination,
- password,
- password_toggle,
- save_checkbox,
- save_toggle,
- primary,
- cancel,
- });
-
- let mut quads = vec![
- UiQuad::solid(
- 0.0,
- 0.0,
- size.width(),
- size.height(),
- 0.0,
- Rgba::new(0, 0, 0, (170.0 * progress).round() as u8),
- ),
- UiQuad::solid(
- bx - s(1.0),
- by - s(1.0),
- box_w + s(2.0),
- box_h + s(2.0),
- s(13.0),
- sk.hairline,
- ),
- UiQuad::solid(bx, by, box_w, box_h, s(12.0), sk.panel),
- ];
- for (rect, active) in [
- (destination, editor.field == SshEditorField::Destination),
- (password, editor.field == SshEditorField::Password),
- ] {
- let hovered = matches!(
- (rect == destination, self.nebula_ssh_editor_hover),
- (true, SshEditorHit::Destination) | (false, SshEditorHit::Password)
- );
- quads.push(UiQuad::solid(
- rect.0 - s(1.0),
- rect.1 - s(1.0),
- rect.2 + s(2.0),
- rect.3 + s(2.0),
- s(7.0),
- if active {
- accent
- } else if hovered {
- sk.hover_strong
- } else {
- sk.hairline
- },
- ));
- quads.push(UiQuad::solid(rect.0, rect.1, rect.2, rect.3, s(6.0), sk.input));
- if hovered && !active {
- quads.push(UiQuad::solid(rect.0, rect.1, rect.2, rect.3, s(6.0), sk.surface));
- }
- }
- if self.nebula_ssh_editor_hover == SshEditorHit::SaveToggleBox {
- quads.push(UiQuad::solid(
- save_checkbox.0 - s(2.0),
- save_checkbox.1 - s(2.0),
- save_checkbox.2 + s(4.0),
- save_checkbox.3 + s(4.0),
- s(6.0),
- sk.hover_strong,
- ));
- }
- if self.nebula_ssh_editor_hover == SshEditorHit::PasswordToggle {
- quads.push(UiQuad::solid(
- password_toggle.0,
- password_toggle.1,
- password_toggle.2,
- password_toggle.3,
- s(6.0),
- sk.hover,
- ));
- }
- // Keep the dark outline requested for the light theme, but use a clean
- // input-colored interior instead of a solid black status block.
- let checkbox_edge =
- if sk.is_light { Rgba::new(sk.ink.r, sk.ink.g, sk.ink.b, 210) } else { sk.hairline };
- quads.push(UiQuad::solid(
- save_checkbox.0 - s(1.0),
- save_checkbox.1 - s(1.0),
- save_checkbox.2 + s(2.0),
- save_checkbox.3 + s(2.0),
- s(5.0),
- checkbox_edge,
- ));
- quads.push(UiQuad::solid(
- save_checkbox.0,
- save_checkbox.1,
- save_checkbox.2,
- save_checkbox.3,
- s(4.0),
- sk.input,
- ));
- for (rect, edge) in
- [(cancel, sk.hairline), (primary, if sk.is_light { accent } else { sk.hairline })]
- {
- quads.push(UiQuad::solid(
- rect.0 - s(1.0),
- rect.1 - s(1.0),
- rect.2 + s(2.0),
- rect.3 + s(2.0),
- s(9.0),
- edge,
- ));
- }
- quads.push(UiQuad::solid(cancel.0, cancel.1, cancel.2, cancel.3, s(8.0), sk.input));
- quads.push(UiQuad::solid(
- primary.0,
- primary.1,
- primary.2,
- primary.3,
- s(8.0),
- if sk.is_light { sk.input } else { accent },
- ));
- if self.nebula_ssh_editor_hover == SshEditorHit::Cancel {
- quads.push(UiQuad::solid(cancel.0, cancel.1, cancel.2, cancel.3, s(8.0), sk.hover));
- }
- if self.nebula_ssh_editor_hover == SshEditorHit::Primary {
- quads.push(UiQuad::solid(
- primary.0,
- primary.1,
- primary.2,
- primary.3,
- s(8.0),
- if sk.is_light { sk.hover } else { sk.hover_strong },
- ));
- }
- let (caret_rect, caret_cols, all_selected) = match editor.field {
- SshEditorField::Destination => (
- destination,
- editor.destination.chars().count(),
- editor.destination_selection.is_selected(),
- ),
- SshEditorField::Password => (
- password,
- editor.password.chars().count(),
- editor.password_selection.is_selected(),
- ),
- };
- if all_selected && caret_cols > 0 {
- let right_pad = if editor.field == SshEditorField::Password { s(48.0) } else { s(10.0) };
- let selection_w = (caret_cols as f32 * cell_w)
- .min(caret_rect.2 - s(12.0) - right_pad);
- quads.push(UiQuad::solid(
- caret_rect.0 + s(10.0),
- caret_rect.1 + s(7.0),
- selection_w + s(4.0),
- caret_rect.3 - s(14.0),
- s(4.0),
- sk.accent_soft,
- ));
- } else {
- let caret_x = (caret_rect.0 + s(12.0) + caret_cols as f32 * cell_w).min(
- caret_rect.0 + caret_rect.2
- - if editor.field == SshEditorField::Password { s(48.0) } else { s(10.0) },
- );
- quads.push(UiQuad::solid(
- caret_x,
- caret_rect.1 + s(10.0),
- s(1.5).max(1.0),
- caret_rect.3 - s(20.0),
- 0.0,
- editor_caret,
- ));
- }
- self.renderer.draw_ui(&size, &quads);
-
- let gc = &mut self.glyph_cache;
- self.renderer.draw_doc_text(
- &size,
- bx + pad,
- by + s(24.0),
- 1.35,
- sk.ink_strong,
- nebula_terminal::term::cell::Flags::empty(),
- "添加 SSH 主机",
- gc,
- );
- self.renderer.draw_chrome_text(
- &size,
- destination.0,
- destination.1 - cell_h - s(6.0),
- if editor.error.is_some() {
- if sk.is_light { Rgb::new(207, 34, 46) } else { Rgb::new(248, 81, 73) }
- } else {
- sk.ink_dim
- },
- editor
- .error
- .as_deref()
- .unwrap_or("用户名@地址(非 22 端口可用 ssh://user@host:port)"),
- gc,
- );
- let destination_text =
- if editor.destination.is_empty() { "user@example.com" } else { &editor.destination };
- self.renderer.draw_chrome_text(
- &size,
- destination.0 + s(12.0),
- destination.1 + (field_h - cell_h) / 2.0,
- if editor.destination.is_empty() { sk.ink_faint } else { sk.ink },
- destination_text,
- gc,
- );
- self.renderer.draw_chrome_text(
- &size,
- password.0,
- password.1 - cell_h - s(6.0),
- sk.ink_dim,
- "密码",
- gc,
- );
- let masked = if editor.password.is_empty() {
- "连接时询问".to_owned()
- } else if editor.show_password {
- editor.password.clone()
- } else {
- "•".repeat(editor.password.chars().count())
- };
- self.renderer.draw_chrome_text(
- &size,
- password.0 + s(12.0),
- password.1 + (field_h - cell_h) / 2.0,
- if editor.password.is_empty() { sk.ink_faint } else { sk.ink },
- &masked,
- gc,
- );
- const ICON_EYE: &str = "\u{ea70}";
- const ICON_EYE_CLOSED: &str = "\u{eae7}";
- let eye_icon = if editor.show_password { ICON_EYE_CLOSED } else { ICON_EYE };
- self.renderer.draw_chrome_text(
- &size,
- password_toggle.0 + (password_toggle.2 - cell_w) * 0.5,
- password_toggle.1 + (password_toggle.3 - cell_h) * 0.5,
- if self.nebula_ssh_editor_hover == SshEditorHit::PasswordToggle {
- sk.icon_hover
- } else {
- sk.icon
- },
- eye_icon,
- gc,
- );
- self.renderer.draw_chrome_text(
- &size,
- save_toggle.0 + s(28.0),
- save_toggle.1 + (save_toggle.3 - cell_h) / 2.0,
- sk.ink,
- save_label,
- gc,
- );
- if editor.save_password {
- const ICON_CHECK: &str = "\u{eab2}";
- self.renderer.draw_chrome_text(
- &size,
- save_checkbox.0 + (save_checkbox.2 - cell_w) * 0.5,
- save_checkbox.1 + (save_checkbox.3 - cell_h) * 0.5,
- if sk.is_light { sk.ink_strong } else { sk.icon_hover },
- ICON_CHECK,
- gc,
- );
- }
- self.renderer.draw_chrome_text(
- &size,
- cancel.0 + (cancel.2 - text_w(cancel_label)) * 0.5,
- cancel.1 + (cancel.3 - cell_h) / 2.0,
- sk.ink,
- cancel_label,
- gc,
- );
- self.renderer.draw_chrome_text(
- &size,
- primary.0 + (primary.2 - text_w(primary_label)) * 0.5,
- primary.1 + (primary.3 - cell_h) / 2.0,
- if sk.is_light { sk.accent } else { sk.ink_on_accent },
- primary_label,
- gc,
- );
-
- if self.nebula_ui_anims.ssh_editor.animating_to(if self.nebula_ssh_editor_open {
- 1.0
- } else {
- 0.0
- }) {
- self.pending_update.dirty = true;
- self.window.request_redraw();
- }
- }
-
/// Bottom-center reversible-action bar for SSH deletion. Its action rect is
/// published to input after layout, keeping hover/click geometry identical
/// to the pixels on screen.
@@ -5042,26 +4898,20 @@ impl Display {
};
let hint = "Ctrl+Z";
let action = "撤销";
- let text_cols = |text: &str| -> usize {
- text.chars().map(|ch| ch.width().unwrap_or(1).max(1)).sum()
- };
+ let text_cols =
+ |text: &str| -> usize { text.chars().map(|ch| ch.width().unwrap_or(1).max(1)).sum() };
let pad = s(14.0);
let gap = s(12.0);
let action_w = s(76.0);
let bar_h = s(48.0).max(cell_h + s(12.0));
let content_w = (text_cols(&message) + text_cols(hint) + 2) as f32 * cell_w;
- let bar_w = (pad * 2.0 + content_w + gap + action_w)
- .max(s(360.0))
- .min(size.width() - s(24.0));
+ let bar_w =
+ (pad * 2.0 + content_w + gap + action_w).max(s(360.0)).min(size.width() - s(24.0));
let bar_x = (size.width() - bar_w) * 0.5;
let bar_y = size.height() - bar_h - s(18.0);
- let action_rect = (
- bar_x + bar_w - pad - action_w,
- bar_y + (bar_h - s(34.0)) * 0.5,
- action_w,
- s(34.0),
- );
+ let action_rect =
+ (bar_x + bar_w - pad - action_w, bar_y + (bar_h - s(34.0)) * 0.5, action_w, s(34.0));
self.nebula_ssh_delete_undo_rect = Some(action_rect);
let mut quads = vec![
@@ -5121,8 +4971,7 @@ impl Display {
hint,
&mut self.glyph_cache,
);
- let action_x =
- action_rect.0 + (action_rect.2 - text_cols(action) as f32 * cell_w) * 0.5;
+ let action_x = action_rect.0 + (action_rect.2 - text_cols(action) as f32 * cell_w) * 0.5;
let action_y = action_rect.1 + (action_rect.3 - cell_h) * 0.5;
self.renderer.draw_chrome_text_styled(
&size,
@@ -6403,8 +6252,7 @@ mod nebula_ux_tests {
let mut pinned = strings(&["target", "alpha"]);
let mut hidden = strings(&["already-hidden"]);
- let snapshot =
- remove_ssh_host_from_lists("target", &mut saved, &mut pinned, &mut hidden);
+ let snapshot = remove_ssh_host_from_lists("target", &mut saved, &mut pinned, &mut hidden);
assert_eq!(snapshot, (Some(1), Some(0), false));
assert_eq!(saved, strings(&["alpha", "omega"]));
assert_eq!(pinned, strings(&["alpha"]));
@@ -6451,16 +6299,12 @@ mod nebula_ux_tests {
#[test]
fn asymmetric_bottom_reserve_recovers_rows_hidden_by_top_chrome() {
- let size = SizeInfo::new_fully_asymmetric(
- 1000.0, 1000.0, 10.0, 20.0, 0.0, 0.0, 64.0, 16.0,
- );
+ let size = SizeInfo::new_fully_asymmetric(1000.0, 1000.0, 10.0, 20.0, 0.0, 0.0, 64.0, 16.0);
assert_eq!(size.screen_lines(), 46);
assert_eq!(size.padding_y(), 64.0);
assert_eq!(size.padding_bottom(), 16.0);
- let old_symmetric = SizeInfo::new_asymmetric(
- 1000.0, 1000.0, 10.0, 20.0, 0.0, 0.0, 64.0,
- );
+ let old_symmetric = SizeInfo::new_asymmetric(1000.0, 1000.0, 10.0, 20.0, 0.0, 0.0, 64.0);
assert_eq!(old_symmetric.screen_lines(), 43);
}
}
diff --git a/nebula_app/src/display/sftp_panel.rs b/nebula_app/src/display/sftp_panel.rs
new file mode 100644
index 0000000..30e6308
--- /dev/null
+++ b/nebula_app/src/display/sftp_panel.rs
@@ -0,0 +1,717 @@
+//! SFTP 右侧抽屉的状态、几何和绘制;传输协议留在 `ssh_sftp`。
+
+use std::time::Instant;
+
+use super::*;
+use crate::ssh_sftp::{SftpController, SftpEntry, SftpEntryKind, SftpPhase, SftpSnapshot};
+
+#[derive(Clone, Debug)]
+enum EditorKind {
+ Filter,
+ Path,
+ CreateDirectory,
+ Rename(SftpEntry),
+}
+
+#[derive(Clone, Debug)]
+struct Editor {
+ kind: EditorKind,
+ text: String,
+ selection: super::text_input::SelectAllState,
+}
+
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+pub enum SftpHit {
+ #[default]
+ None,
+ Close,
+ Path,
+ Up,
+ Refresh,
+ UploadFiles,
+ UploadDirectory,
+ NewDirectory,
+ Filter,
+ Row(usize),
+ Cancel,
+ Inside,
+}
+
+pub struct SftpPanel {
+ controller: SftpController,
+ pub filter: String,
+ pub selected: Option,
+ pub scroll: usize,
+ pub hover: SftpHit,
+ editor: Option,
+ editor_error: Option,
+ last_click: Option<(String, Instant)>,
+}
+
+impl SftpPanel {
+ pub fn new(controller: SftpController) -> Self {
+ Self {
+ controller,
+ filter: String::new(),
+ selected: None,
+ scroll: 0,
+ hover: SftpHit::None,
+ editor: None,
+ editor_error: None,
+ last_click: None,
+ }
+ }
+
+ pub fn snapshot(&self) -> SftpSnapshot {
+ self.controller.snapshot()
+ }
+
+ pub fn visible_entries(&self) -> Vec {
+ filtered_entries(&self.snapshot().entries, &self.filter)
+ }
+
+ pub fn visible_entry(&self, index: usize) -> Option {
+ self.visible_entries().into_iter().nth(self.scroll + index)
+ }
+
+ pub fn set_hover(&mut self, hit: SftpHit) -> bool {
+ if self.hover == hit {
+ return false;
+ }
+ self.hover = hit;
+ true
+ }
+
+ pub fn scroll_by(&mut self, delta: i32, visible_rows: usize) {
+ let max = self.visible_entries().len().saturating_sub(visible_rows);
+ self.scroll = (self.scroll as i64 + delta as i64).clamp(0, max as i64) as usize;
+ }
+
+ pub fn go_up(&self) {
+ let snapshot = self.snapshot();
+ self.controller
+ .refresh(super::super::ssh_sftp::normalize_remote_path(&snapshot.path, ".."));
+ }
+
+ pub fn refresh(&self) {
+ self.controller.refresh(self.snapshot().path);
+ }
+
+ pub fn navigate(&mut self, entry: &SftpEntry) -> bool {
+ if matches!(entry.kind, SftpEntryKind::Directory | SftpEntryKind::Symlink) {
+ self.selected = None;
+ self.scroll = 0;
+ self.controller.refresh(entry.path.clone());
+ true
+ } else {
+ false
+ }
+ }
+
+ pub fn select_row(&mut self, index: usize) -> Option<(SftpEntry, bool)> {
+ let entry = self.visible_entry(index)?;
+ self.selected = Some(entry.path.clone());
+ let now = Instant::now();
+ let double = self.last_click.as_ref().is_some_and(|(path, at)| {
+ *path == entry.path && at.elapsed() < std::time::Duration::from_millis(400)
+ });
+ self.last_click = if double { None } else { Some((entry.path.clone(), now)) };
+ Some((entry, double))
+ }
+
+ pub fn begin_filter(&mut self) {
+ self.begin_editor(EditorKind::Filter, self.filter.clone());
+ }
+
+ pub fn begin_path(&mut self) {
+ self.begin_editor(EditorKind::Path, self.snapshot().path);
+ }
+
+ pub fn begin_create_directory(&mut self) {
+ self.begin_editor(EditorKind::CreateDirectory, String::new());
+ }
+
+ pub fn begin_rename(&mut self, entry: SftpEntry) {
+ let name = entry.name.clone();
+ self.begin_editor(EditorKind::Rename(entry), name);
+ }
+
+ fn begin_editor(&mut self, kind: EditorKind, text: String) {
+ let mut selection = super::text_input::SelectAllState::default();
+ selection.select(&text);
+ self.editor = Some(Editor { kind, text, selection });
+ self.editor_error = None;
+ }
+
+ pub fn editor_active(&self) -> bool {
+ self.editor.is_some()
+ }
+
+ pub fn editor_insert(&mut self, text: &str) {
+ let Some(editor) = self.editor.as_mut() else { return };
+ let text: String = text.chars().filter(|character| !character.is_control()).collect();
+ editor.selection.insert(&mut editor.text, &text);
+ if matches!(editor.kind, EditorKind::Filter) {
+ self.filter.clone_from(&editor.text);
+ self.scroll = 0;
+ self.selected = None;
+ }
+ }
+
+ pub fn editor_backspace(&mut self) {
+ let Some(editor) = self.editor.as_mut() else { return };
+ editor.selection.backspace(&mut editor.text);
+ if matches!(editor.kind, EditorKind::Filter) {
+ self.filter.clone_from(&editor.text);
+ self.scroll = 0;
+ self.selected = None;
+ }
+ }
+
+ pub fn editor_select_all(&mut self) {
+ if let Some(editor) = self.editor.as_mut() {
+ editor.selection.select(&editor.text);
+ }
+ }
+
+ pub fn editor_selected_text(&self) -> Option {
+ self.editor.as_ref().and_then(|editor| editor.selection.selected_text(&editor.text))
+ }
+
+ pub fn editor_cancel(&mut self) {
+ if self.editor.as_ref().is_some_and(|editor| matches!(editor.kind, EditorKind::Filter)) {
+ self.filter.clear();
+ self.scroll = 0;
+ }
+ self.editor = None;
+ self.editor_error = None;
+ }
+
+ pub fn editor_unfocus(&mut self) {
+ if self.editor.as_ref().is_some_and(|editor| matches!(editor.kind, EditorKind::Filter)) {
+ self.editor = None;
+ } else {
+ self.editor_cancel();
+ }
+ }
+
+ pub fn editor_submit(&mut self) -> Result<(), String> {
+ let Some(editor) = self.editor.take() else { return Ok(()) };
+ let result = match editor.kind.clone() {
+ EditorKind::Filter => {
+ self.filter.clone_from(&editor.text);
+ Ok(())
+ },
+ EditorKind::Path => {
+ self.scroll = 0;
+ self.selected = None;
+ self.controller.refresh(editor.text.clone());
+ Ok(())
+ },
+ EditorKind::CreateDirectory => self.controller.create_directory(editor.text.trim()),
+ EditorKind::Rename(entry) => self.controller.rename(entry, editor.text.trim()),
+ };
+ if let Err(err) = result.as_ref() {
+ self.editor_error = Some(err.clone());
+ self.editor = Some(editor);
+ } else {
+ self.editor_error = None;
+ }
+ result
+ }
+
+ pub fn editor_view(&self) -> Option<(&str, bool, &'static str)> {
+ let editor = self.editor.as_ref()?;
+ let hint = match editor.kind {
+ EditorKind::Filter => "筛选远端文件",
+ EditorKind::Path => "输入远端路径",
+ EditorKind::CreateDirectory => "新文件夹名称",
+ EditorKind::Rename(_) => "输入新名称",
+ };
+ Some((&editor.text, editor.selection.is_selected(), hint))
+ }
+
+ pub fn upload_paths(&self, paths: Vec) {
+ self.controller.upload_paths(paths);
+ }
+
+ pub fn download(&self, entry: SftpEntry, directory: std::path::PathBuf) {
+ self.controller.download(entry, directory);
+ }
+
+ pub fn delete(&self, entry: SftpEntry) {
+ self.controller.delete(entry);
+ }
+
+ pub fn cancel_transfer(&self) {
+ self.controller.cancel();
+ }
+}
+
+fn filtered_entries(entries: &[SftpEntry], query: &str) -> Vec {
+ let query = query.trim().to_lowercase();
+ entries
+ .iter()
+ .filter(|entry| query.is_empty() || entry.name.to_lowercase().contains(&query))
+ .cloned()
+ .collect()
+}
+
+#[derive(Clone, Debug)]
+pub struct SftpLayout {
+ pub panel: (f32, f32, f32, f32),
+ pub close: (f32, f32, f32, f32),
+ pub path: (f32, f32, f32, f32),
+ pub toolbar: [(f32, f32, f32, f32); 5],
+ pub filter: (f32, f32, f32, f32),
+ pub list_y: f32,
+ pub row_h: f32,
+ pub max_rows: usize,
+ pub cancel: (f32, f32, f32, f32),
+}
+
+pub fn layout(base: &side_panel::PanelLayout, scale: f32) -> SftpLayout {
+ let s = |value: f32| value * scale;
+ let (x, y, width, height) = base.panel;
+ let close = (x + width - s(36.0), y + s(6.0), s(28.0), s(28.0));
+ let path = (x + s(12.0), y + s(46.0), width - s(24.0), s(34.0));
+ let toolbar_y = path.1 + path.3 + s(8.0);
+ let gap = s(5.0);
+ let button_w = (path.2 - gap * 4.0) / 5.0;
+ let toolbar = std::array::from_fn(|index| {
+ (path.0 + index as f32 * (button_w + gap), toolbar_y, button_w, s(30.0))
+ });
+ let filter = (path.0, toolbar_y + s(38.0), path.2, s(34.0));
+ let list_y = filter.1 + filter.3 + s(10.0);
+ let row_h = s(48.0);
+ let cancel = (x + s(12.0), y + height - s(38.0), width - s(24.0), s(28.0));
+ let max_rows = ((cancel.1 - s(8.0) - list_y) / row_h).max(0.0) as usize;
+ SftpLayout { panel: base.panel, close, path, toolbar, filter, list_y, row_h, max_rows, cancel }
+}
+
+fn contains(rect: (f32, f32, f32, f32), x: f32, y: f32) -> bool {
+ x >= rect.0 && x < rect.0 + rect.2 && y >= rect.1 && y < rect.1 + rect.3
+}
+
+pub fn hit_test(layout: &SftpLayout, working: bool, x: f32, y: f32) -> SftpHit {
+ if !contains(layout.panel, x, y) {
+ return SftpHit::None;
+ }
+ if contains(layout.close, x, y) {
+ return SftpHit::Close;
+ }
+ if contains(layout.path, x, y) {
+ return SftpHit::Path;
+ }
+ let toolbar_hits = [
+ SftpHit::Up,
+ SftpHit::Refresh,
+ SftpHit::UploadFiles,
+ SftpHit::UploadDirectory,
+ SftpHit::NewDirectory,
+ ];
+ for (rect, hit) in layout.toolbar.iter().zip(toolbar_hits) {
+ if contains(*rect, x, y) {
+ return hit;
+ }
+ }
+ if contains(layout.filter, x, y) {
+ return SftpHit::Filter;
+ }
+ if working && contains(layout.cancel, x, y) {
+ return SftpHit::Cancel;
+ }
+ if y >= layout.list_y {
+ let row = ((y - layout.list_y) / layout.row_h) as usize;
+ if row < layout.max_rows {
+ return SftpHit::Row(row);
+ }
+ }
+ SftpHit::Inside
+}
+
+pub(super) fn push_quads(
+ panel: &SftpPanel,
+ layout: &SftpLayout,
+ theme: &NebulaTheme,
+ quads: &mut Vec,
+ scale: f32,
+ cell_w: f32,
+) {
+ let s = |value: f32| value * scale;
+ let palette = theme.palette();
+ let skin = theme.skin();
+ let radius = s(UI_CORNER_RADIUS_LOGICAL);
+ let (x, y, width, height) = layout.panel;
+ quads.push(UiQuad::solid(x, y, width, height, radius, palette.panel));
+
+ for (index, rect) in layout.toolbar.iter().enumerate() {
+ let hit = [
+ SftpHit::Up,
+ SftpHit::Refresh,
+ SftpHit::UploadFiles,
+ SftpHit::UploadDirectory,
+ SftpHit::NewDirectory,
+ ][index];
+ quads.push(UiQuad::solid(rect.0, rect.1, rect.2, rect.3, s(6.0), skin.input));
+ if panel.hover == hit {
+ quads.push(UiQuad::solid(rect.0, rect.1, rect.2, rect.3, s(6.0), skin.hover));
+ }
+ }
+ for (rect, focused) in [
+ (
+ layout.path,
+ panel.editor.as_ref().is_some_and(|editor| matches!(editor.kind, EditorKind::Path)),
+ ),
+ (
+ layout.filter,
+ panel.editor.as_ref().is_some_and(|editor| !matches!(editor.kind, EditorKind::Path)),
+ ),
+ ] {
+ if focused {
+ quads.push(UiQuad::solid(
+ rect.0 - s(1.0),
+ rect.1 - s(1.0),
+ rect.2 + s(2.0),
+ rect.3 + s(2.0),
+ s(7.0),
+ Rgba::new(skin.accent.r, skin.accent.g, skin.accent.b, 190),
+ ));
+ }
+ quads.push(UiQuad::solid(rect.0, rect.1, rect.2, rect.3, s(6.0), skin.input));
+ }
+ if let Some((text, true, _)) = panel.editor_view() {
+ let rect = if panel
+ .editor
+ .as_ref()
+ .is_some_and(|editor| matches!(editor.kind, EditorKind::Path))
+ {
+ layout.path
+ } else {
+ layout.filter
+ };
+ let columns: usize = text.chars().map(|character| character.width().unwrap_or(0)).sum();
+ let width = (columns as f32 * cell_w).min(rect.2 - s(16.0));
+ quads.push(UiQuad::solid(
+ rect.0 + s(6.0),
+ rect.1 + s(6.0),
+ width + s(4.0),
+ rect.3 - s(12.0),
+ s(4.0),
+ skin.accent_soft,
+ ));
+ }
+ if panel.hover == SftpHit::Close {
+ quads.push(UiQuad::solid(
+ layout.close.0,
+ layout.close.1,
+ layout.close.2,
+ layout.close.3,
+ s(6.0),
+ skin.hover,
+ ));
+ }
+
+ if let SftpHit::Row(index) = panel.hover {
+ let row_y = layout.list_y + index as f32 * layout.row_h;
+ quads.push(UiQuad::solid(
+ x + s(8.0),
+ row_y,
+ width - s(16.0),
+ layout.row_h - s(3.0),
+ s(6.0),
+ skin.hover,
+ ));
+ }
+ if let Some(selected) = panel.selected.as_ref() {
+ if let Some(index) = panel
+ .visible_entries()
+ .iter()
+ .skip(panel.scroll)
+ .take(layout.max_rows)
+ .position(|entry| &entry.path == selected)
+ {
+ let row_y = layout.list_y + index as f32 * layout.row_h;
+ quads.push(UiQuad::solid(
+ x + s(8.0),
+ row_y,
+ width - s(16.0),
+ layout.row_h - s(3.0),
+ s(6.0),
+ skin.accent_soft,
+ ));
+ }
+ }
+
+ let snapshot = panel.snapshot();
+ if snapshot.phase == SftpPhase::Working {
+ quads.push(UiQuad::solid(
+ layout.cancel.0,
+ layout.cancel.1,
+ layout.cancel.2,
+ layout.cancel.3,
+ s(6.0),
+ skin.input,
+ ));
+ if let Some(progress) = snapshot.progress {
+ quads.push(UiQuad::solid(
+ layout.cancel.0,
+ layout.cancel.1,
+ layout.cancel.2 * progress.fraction(),
+ layout.cancel.3,
+ s(6.0),
+ skin.accent_soft,
+ ));
+ }
+ }
+}
+
+pub(super) fn draw_text(
+ panel: &SftpPanel,
+ layout: &SftpLayout,
+ theme: &NebulaTheme,
+ renderer: &mut Renderer,
+ glyph_cache: &mut GlyphCache,
+ size: &SizeInfo,
+ scale: f32,
+) {
+ let s = |value: f32| value * scale;
+ let skin = theme.skin();
+ let cell_w = size.cell_width();
+ let cell_h = size.cell_height();
+ let snapshot = panel.snapshot();
+ let text_y = |rect: (f32, f32, f32, f32)| rect.1 + (rect.3 - cell_h) * 0.5;
+
+ renderer.draw_chrome_text(
+ size,
+ layout.panel.0 + s(14.0),
+ layout.panel.1 + s(10.0),
+ skin.ink_strong,
+ "SFTP",
+ glyph_cache,
+ );
+ renderer.draw_chrome_text(
+ size,
+ layout.panel.0 + s(58.0),
+ layout.panel.1 + s(10.0),
+ skin.ink_dim,
+ &super::truncate_tab_label(&snapshot.destination, 19),
+ glyph_cache,
+ );
+ renderer.draw_chrome_text(
+ size,
+ layout.close.0 + (layout.close.2 - cell_w) * 0.5,
+ text_y(layout.close),
+ skin.ink_dim,
+ "×",
+ glyph_cache,
+ );
+
+ let path_editor =
+ panel.editor.as_ref().filter(|editor| matches!(editor.kind, EditorKind::Path));
+ let path_text = path_editor.map(|editor| editor.text.as_str()).unwrap_or(&snapshot.path);
+ let path_shown = if path_editor
+ .is_some_and(|editor| !editor.selection.is_selected() && super::caret_blink_on())
+ {
+ format!("{path_text}▏")
+ } else {
+ path_text.to_owned()
+ };
+ renderer.draw_chrome_text(
+ size,
+ layout.path.0 + s(8.0),
+ text_y(layout.path),
+ skin.ink_strong,
+ &super::truncate_tab_label(&path_shown, 30),
+ glyph_cache,
+ );
+
+ for (rect, label) in layout.toolbar.iter().zip(["上", "刷新", "传文", "传夹", "新建"])
+ {
+ let width = label.chars().count() as f32 * cell_w;
+ renderer.draw_chrome_text(
+ size,
+ rect.0 + (rect.2 - width) * 0.5,
+ text_y(*rect),
+ skin.ink_dim,
+ label,
+ glyph_cache,
+ );
+ }
+
+ let filter_editor =
+ panel.editor.as_ref().filter(|editor| !matches!(editor.kind, EditorKind::Path));
+ let filter_text = filter_editor.map(|editor| editor.text.as_str()).unwrap_or(&panel.filter);
+ let filter_hint = filter_editor.map_or("筛选文件", |editor| match editor.kind {
+ EditorKind::Filter => "筛选文件",
+ EditorKind::CreateDirectory => "新文件夹名称",
+ EditorKind::Rename(_) => "输入新名称",
+ EditorKind::Path => "远端路径",
+ });
+ let filter_shown = if filter_editor
+ .is_some_and(|editor| !editor.selection.is_selected() && super::caret_blink_on())
+ {
+ format!("{filter_text}▏")
+ } else {
+ filter_text.to_owned()
+ };
+ renderer.draw_chrome_text(
+ size,
+ layout.filter.0 + s(8.0),
+ text_y(layout.filter),
+ if filter_text.is_empty() { skin.ink_faint } else { skin.ink_strong },
+ if filter_shown.is_empty() { filter_hint } else { &filter_shown },
+ glyph_cache,
+ );
+
+ if let Some(error) = panel.editor_error.as_deref().or(snapshot.error.as_deref()) {
+ renderer.draw_chrome_text(
+ size,
+ layout.panel.0 + s(14.0),
+ layout.list_y,
+ Rgb::new(skin.danger.r, skin.danger.g, skin.danger.b),
+ &super::truncate_tab_label(error, 30),
+ glyph_cache,
+ );
+ } else if matches!(snapshot.phase, SftpPhase::Connecting | SftpPhase::Loading) {
+ renderer.draw_chrome_text(
+ size,
+ layout.panel.0 + s(14.0),
+ layout.list_y,
+ skin.ink_dim,
+ "正在读取远端目录…",
+ glyph_cache,
+ );
+ } else {
+ for (index, entry) in
+ panel.visible_entries().iter().skip(panel.scroll).take(layout.max_rows).enumerate()
+ {
+ let y = layout.list_y + index as f32 * layout.row_h + s(3.0);
+ let icon = match entry.kind {
+ SftpEntryKind::Directory => "▸",
+ SftpEntryKind::Symlink => "↗",
+ SftpEntryKind::File => "·",
+ };
+ renderer.draw_chrome_text(
+ size,
+ layout.panel.0 + s(14.0),
+ y,
+ skin.accent,
+ icon,
+ glyph_cache,
+ );
+ renderer.draw_chrome_text(
+ size,
+ layout.panel.0 + s(34.0),
+ y,
+ skin.ink_strong,
+ &super::truncate_tab_label(&entry.name, 27),
+ glyph_cache,
+ );
+ let details = format!(
+ "{} {} {}",
+ entry.permissions,
+ format_size(entry.size),
+ format_time(entry.modified)
+ );
+ renderer.draw_chrome_text(
+ size,
+ layout.panel.0 + s(34.0),
+ y + s(18.0),
+ skin.ink_faint,
+ &super::truncate_tab_label(&details, 31),
+ glyph_cache,
+ );
+ }
+ if panel.visible_entries().is_empty() {
+ renderer.draw_chrome_text(
+ size,
+ layout.panel.0 + s(14.0),
+ layout.list_y,
+ skin.ink_faint,
+ if panel.filter.is_empty() { "此目录为空" } else { "没有匹配文件" },
+ glyph_cache,
+ );
+ }
+ }
+
+ if snapshot.phase == SftpPhase::Working {
+ let label = snapshot
+ .progress
+ .as_ref()
+ .map_or("正在处理 · 点击取消", |progress| progress.label.as_str());
+ renderer.draw_chrome_text(
+ size,
+ layout.cancel.0 + s(8.0),
+ text_y(layout.cancel),
+ skin.ink_strong,
+ &super::truncate_tab_label(label, 24),
+ glyph_cache,
+ );
+ renderer.draw_chrome_text(
+ size,
+ layout.cancel.0 + layout.cancel.2 - s(42.0),
+ text_y(layout.cancel),
+ skin.ink_dim,
+ "取消",
+ glyph_cache,
+ );
+ }
+}
+
+fn format_size(bytes: u64) -> String {
+ const UNITS: [&str; 4] = ["B", "KB", "MB", "GB"];
+ let mut value = bytes as f64;
+ let mut unit = 0usize;
+ while value >= 1024.0 && unit + 1 < UNITS.len() {
+ value /= 1024.0;
+ unit += 1;
+ }
+ if unit == 0 { format!("{bytes} B") } else { format!("{value:.1} {}", UNITS[unit]) }
+}
+
+fn format_time(timestamp: u64) -> String {
+ if timestamp == 0 {
+ return "-".to_owned();
+ }
+ chrono::DateTime::from_timestamp(timestamp as i64, 0)
+ .map(|time| time.with_timezone(&chrono::Local).format("%Y-%m-%d %H:%M").to_string())
+ .unwrap_or_else(|| "-".to_owned())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn entry(name: &str) -> SftpEntry {
+ SftpEntry {
+ name: name.to_owned(),
+ path: format!("/{name}"),
+ kind: SftpEntryKind::File,
+ size: 0,
+ modified: 0,
+ permissions: "-rw-r--r--".to_owned(),
+ }
+ }
+
+ #[test]
+ fn filter_is_case_insensitive_without_reordering_entries() {
+ let entries = vec![entry("Alpha.txt"), entry("beta.log"), entry("alphabet.md")];
+ let filtered = filtered_entries(&entries, "ALPHA");
+ assert_eq!(
+ filtered.iter().map(|entry| entry.name.as_str()).collect::>(),
+ vec!["Alpha.txt", "alphabet.md"]
+ );
+ }
+
+ #[test]
+ fn toolbar_hit_targets_share_the_drawn_geometry() {
+ let base = side_panel::panel_layout(1000.0, 800.0, 0.0, 0.0, 1.0, 1.0);
+ let layout = layout(&base, 1.0);
+ let rect = layout.toolbar[2];
+ assert_eq!(
+ hit_test(&layout, false, rect.0 + rect.2 * 0.5, rect.1 + rect.3 * 0.5),
+ SftpHit::UploadFiles
+ );
+ }
+}
diff --git a/nebula_app/src/display/side_panel.rs b/nebula_app/src/display/side_panel.rs
index 9ae6f92..443d7c6 100644
--- a/nebula_app/src/display/side_panel.rs
+++ b/nebula_app/src/display/side_panel.rs
@@ -152,6 +152,10 @@ pub struct SidePanel {
needs_refresh: bool,
}
+fn git_pull_args() -> Vec {
+ vec!["pull".into(), "--ff-only".into()]
+}
+
impl SidePanel {
pub fn new() -> Self {
Self {
@@ -307,7 +311,7 @@ impl SidePanel {
}
}
- // ---- git mutations (add / commit / push) ----
+ // ---- git mutations (add / commit / pull / push) ----
/// Whether a git mutation is in flight (buttons gray out).
pub fn op_running(&self) -> bool {
@@ -426,6 +430,13 @@ impl SidePanel {
}
}
+ /// Pull only fast-forward updates, never creating an implicit merge commit.
+ pub fn git_pull(&mut self) {
+ if self.git.is_some() && !self.op_running() {
+ self.spawn_git(git_pull_args());
+ }
+ }
+
/// Depth-first flatten of `dir` into `rows`, following `expanded`.
fn flatten_dir(&mut self, dir: &Path, depth: usize) {
if self.rows.len() >= MAX_ROWS {
@@ -987,8 +998,7 @@ pub(super) fn push_quads(
));
quads.push(UiQuad::solid(sx, sy, sw, sh, radius, sk.input));
if panel.commit_all_selected() && !panel.commit_msg.is_empty() {
- let columns: usize =
- panel.commit_msg.chars().map(|c| c.width().unwrap_or(0)).sum();
+ let columns: usize = panel.commit_msg.chars().map(|c| c.width().unwrap_or(0)).sum();
let selection_w = (columns as f32 * cell_w).min(sw - s(16.0));
quads.push(UiQuad::solid(
sx + s(6.0),
@@ -1000,13 +1010,13 @@ pub(super) fn push_quads(
));
}
} else {
- for (bx, bw) in git_button_rects(sx, sw, s(8.0)) {
+ for (bx, bw) in git_button_rects(sx, sw, s(6.0)) {
quads.push(UiQuad::solid(bx, sy, bw, sh, radius, sk.input));
}
// Hovered action button brightens (hover wash over the pill).
if panel.hover == PanelHit::Search {
let (hx, _) = panel.hover_pos;
- for (bx, bw) in git_button_rects(sx, sw, s(8.0)) {
+ for (bx, bw) in git_button_rects(sx, sw, s(6.0)) {
if hx >= bx && hx < bx + bw {
quads.push(UiQuad::solid(bx, sy, bw, sh, radius, sk.hover));
}
@@ -1016,11 +1026,10 @@ pub(super) fn push_quads(
}
}
-/// The three git action buttons' `(x, w)` spans inside the strip at `sx..sx+sw`
-/// (暂存 / 提交 / 推送). Shared by quads, text and hit-testing.
-pub fn git_button_rects(sx: f32, sw: f32, gap: f32) -> [(f32, f32); 3] {
- let bw = (sw - 2.0 * gap) / 3.0;
- [(sx, bw), (sx + bw + gap, bw), (sx + 2.0 * (bw + gap), bw)]
+/// The four git action buttons' `(x, w)` spans inside `sx..sx+sw`.
+pub fn git_button_rects(sx: f32, sw: f32, gap: f32) -> [(f32, f32); 4] {
+ let bw = (sw - 3.0 * gap) / 4.0;
+ [(sx, bw), (sx + bw + gap, bw), (sx + 2.0 * (bw + gap), bw), (sx + 3.0 * (bw + gap), bw)]
}
/// Draw the drawer's text: header tabs, the summary line (cwd tail or the
@@ -1233,19 +1242,21 @@ pub(super) fn draw_text(
let busy = panel.op_running();
let stage_on = !busy && !git.unstaged.is_empty();
let commit_on = !busy && !git.staged.is_empty();
+ let pull_on = !busy;
let push_on = !busy && git.ahead > 0;
let push_label = if git.ahead > 0 {
format!("推送 ↑{}", git.ahead)
} else {
"推送".to_string()
};
- let labels: [(&str, bool); 3] = [
- (if busy { "…" } else { "暂存全部" }, stage_on),
+ let labels: [(&str, bool); 4] = [
+ (if busy { "…" } else { "暂存" }, stage_on),
("提交", commit_on),
+ ("拉取", pull_on),
(&push_label, push_on),
];
for ((bx, bw), (label, enabled)) in
- git_button_rects(sx, sw, s(8.0)).into_iter().zip(labels)
+ git_button_rects(sx, sw, s(6.0)).into_iter().zip(labels)
{
let hovered = panel.hover == PanelHit::Search
&& panel.hover_pos.0 >= bx
@@ -1395,4 +1406,18 @@ mod tests {
panel.commit_input("new commit");
assert_eq!(panel.commit_msg, "new commit");
}
+
+ #[test]
+ fn git_action_strip_has_four_equal_buttons() {
+ let rects = git_button_rects(10.0, 430.0, 10.0);
+ assert_eq!(rects.len(), 4);
+ assert!(rects.windows(2).all(|pair| (pair[0].1 - pair[1].1).abs() < f32::EPSILON));
+ let last = rects.last().expect("at least one git action");
+ assert!((last.0 + last.1 - 440.0).abs() < f32::EPSILON);
+ }
+
+ #[test]
+ fn git_pull_is_fast_forward_only() {
+ assert_eq!(git_pull_args(), vec!["pull", "--ff-only"]);
+ }
}
diff --git a/nebula_app/src/display/ssh_editor_render.rs b/nebula_app/src/display/ssh_editor_render.rs
new file mode 100644
index 0000000..1a1143d
--- /dev/null
+++ b/nebula_app/src/display/ssh_editor_render.rs
@@ -0,0 +1,696 @@
+use unicode_width::UnicodeWidthChar;
+
+use super::ssh_ui::auth_sections;
+use super::*;
+use crate::ssh_profiles::SshAuthMode;
+
+type Rect = (f32, f32, f32, f32);
+
+impl Display {
+ pub(super) fn draw_ssh_editor_modal(&mut self) {
+ self.nebula_ui_anims
+ .ssh_editor
+ .step_with_speed(if self.nebula_ssh_editor_open { 1.0 } else { 0.0 }, 26.0);
+ let progress = self.nebula_ui_anims.ssh_editor.value().clamp(0.0, 1.0);
+ if !self.nebula_ssh_editor_open && progress <= 0.004 {
+ self.nebula_ssh_editor = None;
+ self.nebula_ssh_editor_rects = None;
+ self.nebula_ssh_editor_hover = SshEditorHit::None;
+ return;
+ }
+ let Some(editor) = self.nebula_ssh_editor.clone() else {
+ self.nebula_ssh_editor_rects = None;
+ return;
+ };
+
+ let size = self.size_info;
+ let scale = self.window.scale_factor as f32;
+ let s = |value: f32| value * scale;
+ let skin = self.nebula_theme.skin();
+ let accent = Rgba::new(skin.accent.r, skin.accent.g, skin.accent.b, 255);
+ let cell_h = size.cell_height();
+ let cell_w = size.cell_width();
+ let text_width = |text: &str| -> f32 {
+ text.chars().map(|c| c.width().unwrap_or(1)).sum::() as f32 * cell_w
+ };
+ let (show_password, show_keys) = auth_sections(editor.auth);
+
+ let box_w = s(700.0).min(size.width() - s(32.0));
+ let desired_h = if show_password && show_keys {
+ 610.0
+ } else if show_keys {
+ 520.0
+ } else if show_password {
+ 440.0
+ } else {
+ 370.0
+ };
+ let box_h = s(desired_h).min(size.height() - s(32.0));
+ let bx = (size.width() - box_w) * 0.5;
+ let resting_y = (size.height() - box_h) * 0.5;
+ let by = resting_y - (1.0 - progress) * s(14.0);
+ let pad = s(28.0);
+ let field_h = s(42.0);
+ let field_w = box_w - pad * 2.0;
+ let destination = (bx + pad, by + s(76.0), field_w, field_h);
+ let auth_y = destination.1 + destination.3 + s(42.0);
+ let auth_gap = s(6.0);
+ let auth_w = (field_w - auth_gap * 4.0) / 5.0;
+ let auth_modes = [
+ SshAuthMode::Auto,
+ SshAuthMode::Password,
+ SshAuthMode::PublicKey,
+ SshAuthMode::Agent,
+ SshAuthMode::KeyboardInteractive,
+ ];
+ let auth = std::array::from_fn(|index| {
+ (
+ auth_modes[index],
+ (destination.0 + index as f32 * (auth_w + auth_gap), auth_y, auth_w, s(34.0)),
+ )
+ });
+ let content_y = auth_y + s(76.0);
+ let zero = (0.0, 0.0, 0.0, 0.0);
+ let password =
+ if show_password { (destination.0, content_y, field_w, field_h) } else { zero };
+ let password_toggle = if show_password {
+ (password.0 + password.2 - s(38.0), password.1 + s(4.0), s(34.0), password.3 - s(8.0))
+ } else {
+ zero
+ };
+ let save_label = "保存密码到 Windows 凭据管理器";
+ let save_toggle = if show_password {
+ (
+ destination.0,
+ password.1 + password.3 + s(12.0),
+ (s(28.0) + text_width(save_label)).min(field_w),
+ s(28.0),
+ )
+ } else {
+ zero
+ };
+ let save_checkbox = if show_password {
+ (save_toggle.0, save_toggle.1 + s(5.0), s(18.0), s(18.0))
+ } else {
+ zero
+ };
+
+ let key_header_y = if show_password { save_toggle.1 + s(56.0) } else { content_y };
+ let add_private_key = if show_keys {
+ (destination.0 + field_w - s(150.0), key_header_y - s(8.0), s(150.0), s(32.0))
+ } else {
+ zero
+ };
+ let key_rows_y = key_header_y + s(30.0);
+ let footer_y = by + box_h - s(58.0);
+ let available_rows =
+ (((footer_y - s(22.0) - key_rows_y) / s(36.0)).floor() as isize).max(1) as usize;
+ let visible_start = editor.private_keys.len().saturating_sub(available_rows);
+ let visible_keys = if show_keys {
+ editor
+ .private_keys
+ .iter()
+ .enumerate()
+ .skip(visible_start)
+ .take(available_rows)
+ .map(|(index, _)| {
+ let row = (
+ destination.0,
+ key_rows_y + (index - visible_start) as f32 * s(36.0),
+ field_w,
+ s(32.0),
+ );
+ let remove = (row.0 + row.2 - s(34.0), row.1, s(34.0), row.3);
+ (index, row, remove)
+ })
+ .collect::>()
+ } else {
+ Vec::new()
+ };
+
+ let primary_label = "保存 Enter";
+ let cancel_label = "取消 Esc";
+ let button_pad = s(16.0);
+ let primary_w = s(112.0).max(text_width(primary_label) + button_pad * 2.0);
+ let cancel_w = s(112.0).max(text_width(cancel_label) + button_pad * 2.0);
+ let primary = (bx + box_w - pad - primary_w, footer_y, primary_w, s(36.0));
+ let cancel = (primary.0 - s(12.0) - cancel_w, primary.1, cancel_w, s(36.0));
+
+ self.nebula_ssh_editor_rects = Some(SshEditorRects {
+ destination,
+ password,
+ password_toggle,
+ auth,
+ add_private_key,
+ private_key_rows: visible_keys
+ .iter()
+ .map(|(index, _, remove)| (*index, *remove))
+ .collect(),
+ save_checkbox,
+ save_toggle,
+ primary,
+ cancel,
+ });
+
+ let mut quads = vec![
+ UiQuad::solid(
+ 0.0,
+ 0.0,
+ size.width(),
+ size.height(),
+ 0.0,
+ Rgba::new(0, 0, 0, (170.0 * progress).round() as u8),
+ ),
+ UiQuad::solid(
+ bx - s(1.0),
+ by - s(1.0),
+ box_w + s(2.0),
+ box_h + s(2.0),
+ s(13.0),
+ skin.hairline,
+ ),
+ UiQuad::solid(bx, by, box_w, box_h, s(12.0), skin.panel),
+ ];
+ input_quads(
+ &mut quads,
+ destination,
+ editor.field == SshEditorField::Destination,
+ self.nebula_ssh_editor_hover == SshEditorHit::Destination,
+ accent,
+ &skin,
+ scale,
+ );
+ if show_password {
+ input_quads(
+ &mut quads,
+ password,
+ editor.field == SshEditorField::Password,
+ self.nebula_ssh_editor_hover == SshEditorHit::Password,
+ accent,
+ &skin,
+ scale,
+ );
+ }
+ for (mode, rect) in auth {
+ let active = editor.auth == mode;
+ let hovered = self.nebula_ssh_editor_hover == SshEditorHit::Auth(mode);
+ quads.push(UiQuad::solid(
+ rect.0,
+ rect.1,
+ rect.2,
+ rect.3,
+ s(7.0),
+ if active {
+ skin.accent_soft
+ } else if hovered {
+ skin.hover
+ } else {
+ skin.input
+ },
+ ));
+ if active {
+ quads.push(UiQuad::solid(
+ rect.0,
+ rect.1 + rect.3 - s(2.0),
+ rect.2,
+ s(2.0),
+ 0.0,
+ accent,
+ ));
+ }
+ }
+ if show_password {
+ if self.nebula_ssh_editor_hover == SshEditorHit::PasswordToggle {
+ quads.push(UiQuad::solid(
+ password_toggle.0,
+ password_toggle.1,
+ password_toggle.2,
+ password_toggle.3,
+ s(6.0),
+ skin.hover,
+ ));
+ }
+ quads.push(UiQuad::solid(
+ save_checkbox.0 - s(1.0),
+ save_checkbox.1 - s(1.0),
+ save_checkbox.2 + s(2.0),
+ save_checkbox.3 + s(2.0),
+ s(5.0),
+ skin.hairline,
+ ));
+ quads.push(UiQuad::solid(
+ save_checkbox.0,
+ save_checkbox.1,
+ save_checkbox.2,
+ save_checkbox.3,
+ s(4.0),
+ skin.input,
+ ));
+ }
+ if show_keys {
+ quads.push(UiQuad::solid(
+ add_private_key.0,
+ add_private_key.1,
+ add_private_key.2,
+ add_private_key.3,
+ s(7.0),
+ if self.nebula_ssh_editor_hover == SshEditorHit::AddPrivateKey {
+ skin.hover
+ } else {
+ skin.input
+ },
+ ));
+ for (index, row, remove) in &visible_keys {
+ quads.push(UiQuad::solid(row.0, row.1, row.2, row.3, s(6.0), skin.input));
+ if self.nebula_ssh_editor_hover == SshEditorHit::RemovePrivateKey(*index) {
+ quads.push(UiQuad::solid(
+ remove.0,
+ remove.1,
+ remove.2,
+ remove.3,
+ s(6.0),
+ skin.hover,
+ ));
+ }
+ }
+ }
+ button_quads(
+ &mut quads,
+ cancel,
+ primary,
+ self.nebula_ssh_editor_hover,
+ accent,
+ &skin,
+ scale,
+ );
+
+ let caret_field = if editor.field == SshEditorField::Password && show_password {
+ SshEditorField::Password
+ } else {
+ SshEditorField::Destination
+ };
+ let (caret_rect, caret_columns, selected) = match caret_field {
+ SshEditorField::Destination => (
+ destination,
+ editor.destination.chars().count(),
+ editor.destination_selection.is_selected(),
+ ),
+ SshEditorField::Password => {
+ (password, editor.password.chars().count(), editor.password_selection.is_selected())
+ },
+ };
+ draw_caret_quad(
+ &mut quads,
+ caret_rect,
+ caret_columns,
+ selected,
+ caret_field == SshEditorField::Password,
+ cell_w,
+ scale,
+ &skin,
+ );
+ self.renderer.draw_ui(&size, &quads);
+
+ let glyph_cache = &mut self.glyph_cache;
+ self.renderer.draw_doc_text(
+ &size,
+ bx + pad,
+ by + s(20.0),
+ 1.35,
+ skin.ink_strong,
+ Flags::empty(),
+ if editor.original_destination.is_some() {
+ "编辑 SSH 主机"
+ } else {
+ "添加 SSH 主机"
+ },
+ glyph_cache,
+ );
+ self.renderer.draw_chrome_text(
+ &size,
+ destination.0,
+ destination.1 - cell_h - s(5.0),
+ if editor.error.is_some() {
+ if skin.is_light { Rgb::new(207, 34, 46) } else { Rgb::new(248, 81, 73) }
+ } else {
+ skin.ink_dim
+ },
+ editor.error.as_deref().unwrap_or("用户名@地址(非 22 端口可用 ssh://user@host:port)"),
+ glyph_cache,
+ );
+ self.renderer.draw_chrome_text(
+ &size,
+ destination.0 + s(12.0),
+ destination.1 + (field_h - cell_h) / 2.0,
+ if editor.destination.is_empty() { skin.ink_faint } else { skin.ink },
+ if editor.destination.is_empty() { "user@example.com" } else { &editor.destination },
+ glyph_cache,
+ );
+ self.renderer.draw_chrome_text(
+ &size,
+ destination.0,
+ auth_y - cell_h - s(5.0),
+ skin.ink_dim,
+ "认证方式",
+ glyph_cache,
+ );
+ let auth_labels = ["自动", "密码", "密钥", "Agent", "交互式"];
+ for ((_, rect), label) in auth.iter().zip(auth_labels) {
+ self.renderer.draw_chrome_text(
+ &size,
+ rect.0 + (rect.2 - text_width(label)) * 0.5,
+ rect.1 + (rect.3 - cell_h) / 2.0,
+ if editor.auth == auth_mode_for_label(label) { skin.ink_strong } else { skin.ink },
+ label,
+ glyph_cache,
+ );
+ }
+
+ if show_password {
+ draw_password_text(
+ &mut self.renderer,
+ glyph_cache,
+ &size,
+ &editor,
+ password,
+ password_toggle,
+ save_toggle,
+ save_checkbox,
+ save_label,
+ field_h,
+ cell_h,
+ cell_w,
+ scale,
+ &skin,
+ self.nebula_ssh_editor_hover,
+ );
+ }
+ if show_keys {
+ self.renderer.draw_chrome_text(
+ &size,
+ destination.0,
+ key_header_y,
+ skin.ink_dim,
+ "私钥",
+ glyph_cache,
+ );
+ self.renderer.draw_chrome_text(
+ &size,
+ add_private_key.0 + (add_private_key.2 - text_width("+ 添加私钥")) * 0.5,
+ add_private_key.1 + (add_private_key.3 - cell_h) / 2.0,
+ skin.ink,
+ "+ 添加私钥",
+ glyph_cache,
+ );
+ if editor.private_keys.is_empty() {
+ self.renderer.draw_chrome_text(
+ &size,
+ destination.0,
+ key_rows_y + s(7.0),
+ skin.ink_faint,
+ "未指定;将使用 IdentityFile 和默认 id_* 私钥",
+ glyph_cache,
+ );
+ }
+ for (index, row, remove) in &visible_keys {
+ let label = path_tail(&editor.private_keys[*index], 64);
+ self.renderer.draw_chrome_text(
+ &size,
+ row.0 + s(10.0),
+ row.1 + (row.3 - cell_h) / 2.0,
+ skin.ink,
+ &label,
+ glyph_cache,
+ );
+ self.renderer.draw_chrome_text(
+ &size,
+ remove.0 + (remove.2 - text_width("×")) * 0.5,
+ remove.1 + (remove.3 - cell_h) / 2.0,
+ skin.icon,
+ "×",
+ glyph_cache,
+ );
+ }
+ } else if editor.auth == SshAuthMode::Agent {
+ self.renderer.draw_chrome_text(
+ &size,
+ destination.0,
+ content_y,
+ skin.ink_dim,
+ "仅使用 Windows OpenSSH Agent 与 Pageant,不回退密码。",
+ glyph_cache,
+ );
+ } else if editor.auth == SshAuthMode::KeyboardInteractive {
+ self.renderer.draw_chrome_text(
+ &size,
+ destination.0,
+ content_y,
+ skin.ink_dim,
+ "仅响应服务器的 keyboard-interactive / MFA 提示。",
+ glyph_cache,
+ );
+ }
+ draw_button_text(
+ &mut self.renderer,
+ glyph_cache,
+ &size,
+ cancel,
+ primary,
+ cancel_label,
+ primary_label,
+ cell_h,
+ &text_width,
+ &skin,
+ );
+
+ if self.nebula_ui_anims.ssh_editor.animating_to(if self.nebula_ssh_editor_open {
+ 1.0
+ } else {
+ 0.0
+ }) {
+ self.pending_update.dirty = true;
+ self.window.request_redraw();
+ }
+ }
+}
+
+fn auth_mode_for_label(label: &str) -> SshAuthMode {
+ match label {
+ "密码" => SshAuthMode::Password,
+ "密钥" => SshAuthMode::PublicKey,
+ "Agent" => SshAuthMode::Agent,
+ "交互式" => SshAuthMode::KeyboardInteractive,
+ _ => SshAuthMode::Auto,
+ }
+}
+
+fn input_quads(
+ quads: &mut Vec,
+ rect: Rect,
+ active: bool,
+ hovered: bool,
+ accent: Rgba,
+ skin: &theme::Skin,
+ scale: f32,
+) {
+ let s = |value: f32| value * scale;
+ quads.push(UiQuad::solid(
+ rect.0 - s(1.0),
+ rect.1 - s(1.0),
+ rect.2 + s(2.0),
+ rect.3 + s(2.0),
+ s(7.0),
+ if active {
+ accent
+ } else if hovered {
+ skin.hover_strong
+ } else {
+ skin.hairline
+ },
+ ));
+ quads.push(UiQuad::solid(rect.0, rect.1, rect.2, rect.3, s(6.0), skin.input));
+}
+
+fn button_quads(
+ quads: &mut Vec,
+ cancel: Rect,
+ primary: Rect,
+ hover: SshEditorHit,
+ accent: Rgba,
+ skin: &theme::Skin,
+ scale: f32,
+) {
+ let s = |value: f32| value * scale;
+ for (rect, edge) in [(cancel, skin.hairline), (primary, accent)] {
+ quads.push(UiQuad::solid(
+ rect.0 - s(1.0),
+ rect.1 - s(1.0),
+ rect.2 + s(2.0),
+ rect.3 + s(2.0),
+ s(9.0),
+ edge,
+ ));
+ }
+ quads.push(UiQuad::solid(cancel.0, cancel.1, cancel.2, cancel.3, s(8.0), skin.input));
+ quads.push(UiQuad::solid(primary.0, primary.1, primary.2, primary.3, s(8.0), accent));
+ if hover == SshEditorHit::Cancel {
+ quads.push(UiQuad::solid(cancel.0, cancel.1, cancel.2, cancel.3, s(8.0), skin.hover));
+ }
+ if hover == SshEditorHit::Primary {
+ quads.push(UiQuad::solid(
+ primary.0,
+ primary.1,
+ primary.2,
+ primary.3,
+ s(8.0),
+ skin.hover_strong,
+ ));
+ }
+}
+
+fn draw_caret_quad(
+ quads: &mut Vec,
+ rect: Rect,
+ columns: usize,
+ selected: bool,
+ password: bool,
+ cell_w: f32,
+ scale: f32,
+ skin: &theme::Skin,
+) {
+ let s = |value: f32| value * scale;
+ let right_pad = if password { s(48.0) } else { s(10.0) };
+ if selected && columns > 0 {
+ let width = (columns as f32 * cell_w).min(rect.2 - s(12.0) - right_pad);
+ quads.push(UiQuad::solid(
+ rect.0 + s(10.0),
+ rect.1 + s(7.0),
+ width + s(4.0),
+ rect.3 - s(14.0),
+ s(4.0),
+ skin.accent_soft,
+ ));
+ } else {
+ let x = (rect.0 + s(12.0) + columns as f32 * cell_w).min(rect.0 + rect.2 - right_pad);
+ quads.push(UiQuad::solid(
+ x,
+ rect.1 + s(10.0),
+ s(1.5).max(1.0),
+ rect.3 - s(20.0),
+ 0.0,
+ Rgba::new(skin.ink_strong.r, skin.ink_strong.g, skin.ink_strong.b, 235),
+ ));
+ }
+}
+
+#[allow(clippy::too_many_arguments)]
+fn draw_password_text(
+ renderer: &mut Renderer,
+ glyph_cache: &mut GlyphCache,
+ size: &SizeInfo,
+ editor: &SshHostEditor,
+ password: Rect,
+ password_toggle: Rect,
+ save_toggle: Rect,
+ save_checkbox: Rect,
+ save_label: &str,
+ field_h: f32,
+ cell_h: f32,
+ cell_w: f32,
+ scale: f32,
+ skin: &theme::Skin,
+ hover: SshEditorHit,
+) {
+ let s = |value: f32| value * scale;
+ renderer.draw_chrome_text(
+ size,
+ password.0,
+ password.1 - cell_h - s(5.0),
+ skin.ink_dim,
+ "密码",
+ glyph_cache,
+ );
+ let masked = if editor.password.is_empty() {
+ "连接时询问".to_owned()
+ } else if editor.show_password {
+ editor.password.clone()
+ } else {
+ "•".repeat(editor.password.chars().count())
+ };
+ renderer.draw_chrome_text(
+ size,
+ password.0 + s(12.0),
+ password.1 + (field_h - cell_h) / 2.0,
+ if editor.password.is_empty() { skin.ink_faint } else { skin.ink },
+ &masked,
+ glyph_cache,
+ );
+ let eye = if editor.show_password { "" } else { "" };
+ renderer.draw_chrome_text(
+ size,
+ password_toggle.0 + (password_toggle.2 - cell_w) * 0.5,
+ password_toggle.1 + (password_toggle.3 - cell_h) / 2.0,
+ if hover == SshEditorHit::PasswordToggle { skin.icon_hover } else { skin.icon },
+ eye,
+ glyph_cache,
+ );
+ renderer.draw_chrome_text(
+ size,
+ save_toggle.0 + s(28.0),
+ save_toggle.1 + (save_toggle.3 - cell_h) / 2.0,
+ skin.ink,
+ save_label,
+ glyph_cache,
+ );
+ if editor.save_password {
+ renderer.draw_chrome_text(
+ size,
+ save_checkbox.0 + (save_checkbox.2 - cell_w) * 0.5,
+ save_checkbox.1 + (save_checkbox.3 - cell_h) / 2.0,
+ skin.icon_hover,
+ "",
+ glyph_cache,
+ );
+ }
+}
+
+#[allow(clippy::too_many_arguments)]
+fn draw_button_text(
+ renderer: &mut Renderer,
+ glyph_cache: &mut GlyphCache,
+ size: &SizeInfo,
+ cancel: Rect,
+ primary: Rect,
+ cancel_label: &str,
+ primary_label: &str,
+ cell_h: f32,
+ text_width: &impl Fn(&str) -> f32,
+ skin: &theme::Skin,
+) {
+ renderer.draw_chrome_text(
+ size,
+ cancel.0 + (cancel.2 - text_width(cancel_label)) * 0.5,
+ cancel.1 + (cancel.3 - cell_h) / 2.0,
+ skin.ink,
+ cancel_label,
+ glyph_cache,
+ );
+ renderer.draw_chrome_text(
+ size,
+ primary.0 + (primary.2 - text_width(primary_label)) * 0.5,
+ primary.1 + (primary.3 - cell_h) / 2.0,
+ skin.ink_on_accent,
+ primary_label,
+ glyph_cache,
+ );
+}
+
+fn path_tail(path: &std::path::Path, max_chars: usize) -> String {
+ let value = path.to_string_lossy();
+ let count = value.chars().count();
+ if count <= max_chars {
+ value.into_owned()
+ } else {
+ format!("…{}", value.chars().skip(count - max_chars + 1).collect::())
+ }
+}
diff --git a/nebula_app/src/display/ssh_ui.rs b/nebula_app/src/display/ssh_ui.rs
index 9d88fce..27f23b3 100644
--- a/nebula_app/src/display/ssh_ui.rs
+++ b/nebula_app/src/display/ssh_ui.rs
@@ -5,10 +5,31 @@
//! disarm it.
use super::text_input::SelectAllState;
+use crate::ssh_profiles::SshAuthMode;
+use std::path::PathBuf;
/// How long a destructive SSH action stays reversible in the in-app bar.
pub const SSH_DELETE_UNDO_DURATION: std::time::Duration = std::time::Duration::from_secs(8);
+pub fn auth_sections(mode: SshAuthMode) -> (bool, bool) {
+ match mode {
+ SshAuthMode::Auto => (true, true),
+ SshAuthMode::Password => (true, false),
+ SshAuthMode::PublicKey => (false, true),
+ SshAuthMode::Agent | SshAuthMode::KeyboardInteractive => (false, false),
+ }
+}
+
+pub fn push_private_key(keys: &mut Vec, path: PathBuf) -> bool {
+ let normalized = path.to_string_lossy();
+ if keys.iter().any(|existing| existing.to_string_lossy().eq_ignore_ascii_case(&normalized)) {
+ false
+ } else {
+ keys.push(path);
+ true
+ }
+}
+
#[derive(Debug)]
pub(super) struct SshDeleteUndo {
pub(super) host: String,
@@ -25,6 +46,11 @@ impl Drop for SshDeleteUndo {
#[cfg(windows)]
if self.delete_credential_on_drop {
let _ = crate::ssh_credentials::forget_password(&self.host);
+ let path = super::nebula_data_dir().join("ssh_profiles.json");
+ if let Ok(mut profiles) = crate::ssh_profiles::SshProfiles::load(&path) {
+ profiles.remove(&self.host);
+ let _ = profiles.save(&path);
+ }
}
}
}
@@ -41,6 +67,9 @@ pub enum SshEditorHit {
Destination,
Password,
PasswordToggle,
+ Auth(SshAuthMode),
+ AddPrivateKey,
+ RemovePrivateKey(usize),
SaveToggleBox,
SaveToggleLabel,
Primary,
@@ -55,19 +84,49 @@ pub struct SshHostEditor {
pub password: String,
pub save_password: bool,
pub show_password: bool,
+ pub auth: SshAuthMode,
+ pub private_keys: Vec,
pub field: SshEditorField,
pub error: Option,
pub(super) destination_selection: SelectAllState,
pub(super) password_selection: SelectAllState,
}
-#[derive(Debug, Clone, Copy)]
+#[derive(Debug, Clone)]
pub struct SshEditorRects {
pub destination: (f32, f32, f32, f32),
pub password: (f32, f32, f32, f32),
pub password_toggle: (f32, f32, f32, f32),
+ pub auth: [(SshAuthMode, (f32, f32, f32, f32)); 5],
+ pub add_private_key: (f32, f32, f32, f32),
+ pub private_key_rows: Vec<(usize, (f32, f32, f32, f32))>,
pub save_checkbox: (f32, f32, f32, f32),
pub save_toggle: (f32, f32, f32, f32),
pub primary: (f32, f32, f32, f32),
pub cancel: (f32, f32, f32, f32),
}
+
+#[cfg(test)]
+mod tests {
+ use super::{auth_sections, push_private_key};
+ use crate::ssh_profiles::SshAuthMode;
+ use std::path::PathBuf;
+
+ #[test]
+ fn auth_modes_show_the_same_sections_as_tabby() {
+ assert_eq!(auth_sections(SshAuthMode::Auto), (true, true));
+ assert_eq!(auth_sections(SshAuthMode::Password), (true, false));
+ assert_eq!(auth_sections(SshAuthMode::PublicKey), (false, true));
+ assert_eq!(auth_sections(SshAuthMode::Agent), (false, false));
+ assert_eq!(auth_sections(SshAuthMode::KeyboardInteractive), (false, false));
+ }
+
+ #[test]
+ fn private_key_list_keeps_order_and_deduplicates_windows_paths() {
+ let mut keys = Vec::new();
+ assert!(push_private_key(&mut keys, PathBuf::from(r"C:\Keys\first")));
+ assert!(!push_private_key(&mut keys, PathBuf::from(r"c:\keys\FIRST")));
+ assert!(push_private_key(&mut keys, PathBuf::from(r"C:\Keys\second")));
+ assert_eq!(keys, vec![PathBuf::from(r"C:\Keys\first"), PathBuf::from(r"C:\Keys\second")]);
+ }
+}
diff --git a/nebula_app/src/event.rs b/nebula_app/src/event.rs
index 911ef8f..196125f 100644
--- a/nebula_app/src/event.rs
+++ b/nebula_app/src/event.rs
@@ -749,6 +749,12 @@ impl ApplicationHandler for Processor {
window_context.display.window.request_redraw();
}
},
+ (EventType::SftpUpdated, Some(window_id)) => {
+ if let Some(window_context) = self.windows.get_mut(window_id) {
+ window_context.dirty = true;
+ window_context.display.window.request_redraw();
+ }
+ },
(EventType::NebulaTab(request), Some(window_id)) => {
if let Some(window_context) = self.windows.get_mut(window_id) {
let close_window = window_context.handle_tab_request(request);
@@ -933,6 +939,8 @@ pub enum EventType {
NebulaResizeSettled,
/// The SSH deletion grace period ended; commit its delayed credential cleanup.
SshDeleteUndoExpired,
+ /// An SFTP background operation published a new local UI snapshot.
+ SftpUpdated,
/// Typed AI-CLI lifecycle event from the nebula-hook pipe (see `ai_hook`).
AiHook(crate::ai_hook::AiHookEvent),
/// A toast was clicked: focus the originating window and, when known,
@@ -1378,6 +1386,12 @@ impl<'a, N: Notify + 'a, T: EventListener> input::ActionContext for ActionCon
});
}
+ fn nebula_open_sftp(&mut self, destination: String) {
+ if let Err(err) = self.display.open_sftp_panel(destination, self.event_proxy.clone()) {
+ log::error!("{err}");
+ }
+ }
+
fn spawn_new_instance(&mut self) {
let mut env_args = env::args();
let nebula = env_args.next().unwrap();
@@ -2452,7 +2466,9 @@ impl input::Processor> {
// Clock ticks are handled at the window-context level.
EventType::NebulaTick | EventType::NebulaAttach => (),
// Resize settling is handled at the window-context level.
- EventType::NebulaResizeSettled | EventType::SshDeleteUndoExpired => (),
+ EventType::NebulaResizeSettled
+ | EventType::SshDeleteUndoExpired
+ | EventType::SftpUpdated => (),
// AI hook events are handled at the Processor level (they may
// target any window's pane); FocusWindow likewise.
EventType::AiHook(_) | EventType::FocusWindow { .. } => (),
@@ -2791,6 +2807,9 @@ impl input::Processor> {
let panel = &mut self.ctx.display.nebula_side_panel;
panel.search_unfocus(false);
panel.commit_unfocus();
+ if let Some(panel) = self.ctx.display.nebula_sftp_panel.as_mut() {
+ panel.editor_unfocus();
+ }
}
// Nebula: always redraw on focus change, and clear the
@@ -2837,54 +2856,63 @@ impl input::Processor> {
*self.ctx.dirty = true;
}
},
- WindowEvent::Ime(ime) => match ime {
- Ime::Commit(text) => {
- *self.ctx.dirty = true;
- // Tab rename owns committed text while editing: on
- // Windows (and any IME), printable characters are
- // delivered here, NOT through key_input — so the
- // rename buffer must consume them here or typing
- // silently pastes into the shell behind the box.
- if self.ctx.display.nebula_tab_rename.is_some() {
- // Caret-aware insert (type-to-overwrite on a
- // pending select-all) — same code path as the
- // non-IME keyboard fallback.
- self.ctx.display.tab_rename_insert(&text);
- } else if self.ctx.display.nebula_side_panel.search_focus {
- // Side-panel filter box: same IME contract as
- // tab rename — committed text must land in the
- // box, not paste into the shell behind it.
- self.ctx.display.nebula_side_panel.search_input(&text);
- } else if self.ctx.display.nebula_side_panel.commit_focus {
- self.ctx.display.nebula_side_panel.commit_input(&text);
- } else if self.ctx.display.nebula_ssh_editor.is_some() {
- self.ctx.display.ssh_editor_insert(&text);
- } else if self.ctx.display.command_palette_open() {
- self.ctx.display.palette_input_text(&text);
- } else {
- // Don't use bracketed paste for single char input.
- self.ctx.paste(&text, text.chars().count() > 1);
- }
- self.ctx.update_cursor_blinking();
- },
- Ime::Preedit(text, cursor_offset) => {
- let preedit =
- (!text.is_empty()).then(|| Preedit::new(text, cursor_offset));
-
- if self.ctx.display.ime.preedit() != preedit.as_ref() {
- self.ctx.display.ime.set_preedit(preedit);
- self.ctx.update_cursor_blinking();
+ WindowEvent::Ime(ime) => {
+ match ime {
+ Ime::Commit(text) => {
*self.ctx.dirty = true;
- }
- },
- Ime::Enabled => {
- self.ctx.display.ime.set_enabled(true);
- *self.ctx.dirty = true;
- },
- Ime::Disabled => {
- self.ctx.display.ime.set_enabled(false);
- *self.ctx.dirty = true;
- },
+ // Tab rename owns committed text while editing: on
+ // Windows (and any IME), printable characters are
+ // delivered here, NOT through key_input — so the
+ // rename buffer must consume them here or typing
+ // silently pastes into the shell behind the box.
+ if self.ctx.display.nebula_tab_rename.is_some() {
+ // Caret-aware insert (type-to-overwrite on a
+ // pending select-all) — same code path as the
+ // non-IME keyboard fallback.
+ self.ctx.display.tab_rename_insert(&text);
+ } else if self.ctx.display.nebula_sftp_panel.as_ref().is_some_and(
+ crate::display::sftp_panel::SftpPanel::editor_active,
+ ) {
+ if let Some(panel) = self.ctx.display.nebula_sftp_panel.as_mut()
+ {
+ panel.editor_insert(&text);
+ }
+ } else if self.ctx.display.nebula_side_panel.search_focus {
+ // Side-panel filter box: same IME contract as
+ // tab rename — committed text must land in the
+ // box, not paste into the shell behind it.
+ self.ctx.display.nebula_side_panel.search_input(&text);
+ } else if self.ctx.display.nebula_side_panel.commit_focus {
+ self.ctx.display.nebula_side_panel.commit_input(&text);
+ } else if self.ctx.display.nebula_ssh_editor.is_some() {
+ self.ctx.display.ssh_editor_insert(&text);
+ } else if self.ctx.display.command_palette_open() {
+ self.ctx.display.palette_input_text(&text);
+ } else {
+ // Don't use bracketed paste for single char input.
+ self.ctx.paste(&text, text.chars().count() > 1);
+ }
+ self.ctx.update_cursor_blinking();
+ },
+ Ime::Preedit(text, cursor_offset) => {
+ let preedit =
+ (!text.is_empty()).then(|| Preedit::new(text, cursor_offset));
+
+ if self.ctx.display.ime.preedit() != preedit.as_ref() {
+ self.ctx.display.ime.set_preedit(preedit);
+ self.ctx.update_cursor_blinking();
+ *self.ctx.dirty = true;
+ }
+ },
+ Ime::Enabled => {
+ self.ctx.display.ime.set_enabled(true);
+ *self.ctx.dirty = true;
+ },
+ Ime::Disabled => {
+ self.ctx.display.ime.set_enabled(false);
+ *self.ctx.dirty = true;
+ },
+ }
},
WindowEvent::ThemeChanged(theme) => {
self.ctx.display.system_theme_changed(theme);
diff --git a/nebula_app/src/input/keyboard.rs b/nebula_app/src/input/keyboard.rs
index b5dca27..2d30165 100644
--- a/nebula_app/src/input/keyboard.rs
+++ b/nebula_app/src/input/keyboard.rs
@@ -128,6 +128,69 @@ impl> Processor {
return;
}
+ // SFTP path/filter/create/rename fields share the same editing contract.
+ if self
+ .ctx
+ .display()
+ .nebula_sftp_panel
+ .as_ref()
+ .is_some_and(crate::display::sftp_panel::SftpPanel::editor_active)
+ {
+ match &key.logical_key {
+ Key::Named(NamedKey::Escape) => {
+ if let Some(panel) = self.ctx.display().nebula_sftp_panel.as_mut() {
+ panel.editor_cancel();
+ }
+ },
+ Key::Named(NamedKey::Enter) => {
+ if let Some(panel) = self.ctx.display().nebula_sftp_panel.as_mut() {
+ let _ = panel.editor_submit();
+ }
+ },
+ Key::Named(NamedKey::Backspace) => {
+ if let Some(panel) = self.ctx.display().nebula_sftp_panel.as_mut() {
+ panel.editor_backspace();
+ }
+ },
+ Key::Character(c)
+ if mods.control_key() && !mods.alt_key() && c.eq_ignore_ascii_case("a") =>
+ {
+ if let Some(panel) = self.ctx.display().nebula_sftp_panel.as_mut() {
+ panel.editor_select_all();
+ }
+ },
+ Key::Character(c)
+ if mods.control_key() && !mods.alt_key() && c.eq_ignore_ascii_case("c") =>
+ {
+ let text = self
+ .ctx
+ .display()
+ .nebula_sftp_panel
+ .as_ref()
+ .and_then(|panel| panel.editor_selected_text());
+ if let Some(text) = text {
+ self.ctx.clipboard_mut().store(ClipboardType::Clipboard, text);
+ }
+ },
+ Key::Character(c)
+ if mods.control_key() && !mods.alt_key() && c.eq_ignore_ascii_case("v") =>
+ {
+ let text = self.ctx.clipboard_mut().load(ClipboardType::Clipboard);
+ if let Some(panel) = self.ctx.display().nebula_sftp_panel.as_mut() {
+ panel.editor_insert(&text);
+ }
+ },
+ Key::Character(text) if mods.is_empty() || mods.shift_key() => {
+ if let Some(panel) = self.ctx.display().nebula_sftp_panel.as_mut() {
+ panel.editor_insert(text);
+ }
+ },
+ _ => {},
+ }
+ self.ctx.mark_dirty();
+ return;
+ }
+
// Side-panel filter box: consume keyboard while it has focus, same
// modal contract as tab rename. Printable text arrives via Ime::Commit
// on Windows/IME; the Character arm is the non-IME fallback.
@@ -220,25 +283,19 @@ impl> Processor {
Key::Named(NamedKey::Tab) => self.ctx.display().ssh_editor_next_field(),
Key::Named(NamedKey::Backspace) => self.ctx.display().ssh_editor_backspace(),
Key::Character(c)
- if mods.control_key()
- && !mods.alt_key()
- && c.eq_ignore_ascii_case("a") =>
+ if mods.control_key() && !mods.alt_key() && c.eq_ignore_ascii_case("a") =>
{
self.ctx.display().ssh_editor_select_all();
},
Key::Character(c)
- if mods.control_key()
- && !mods.alt_key()
- && c.eq_ignore_ascii_case("c") =>
+ if mods.control_key() && !mods.alt_key() && c.eq_ignore_ascii_case("c") =>
{
if let Some(text) = self.ctx.display().ssh_editor_selected_text() {
self.ctx.clipboard_mut().store(ClipboardType::Clipboard, text);
}
},
Key::Character(c)
- if mods.control_key()
- && !mods.alt_key()
- && c.eq_ignore_ascii_case("v") =>
+ if mods.control_key() && !mods.alt_key() && c.eq_ignore_ascii_case("v") =>
{
// SSH 编辑器是应用自有文本框。直接写入当前焦点字段,避免后续
// 终端粘贴绑定把地址或密码误发送到后台 PTY。
diff --git a/nebula_app/src/input/mod.rs b/nebula_app/src/input/mod.rs
index 5c92727..cb8c40a 100644
--- a/nebula_app/src/input/mod.rs
+++ b/nebula_app/src/input/mod.rs
@@ -149,6 +149,8 @@ pub trait ActionContext {
fn spawn_new_instance(&mut self) {}
/// Send a Nebula tab management request for this window.
fn nebula_tab(&self, _request: crate::event::TabRequest) {}
+ /// Open the SFTP drawer through this window's event proxy.
+ fn nebula_open_sftp(&mut self, _destination: String) {}
/// Open a filesystem path with the system handler (drawer double-click).
fn open_path(&mut self, _path: &std::path::Path) {}
/// The active tab's document view, when it is a viewer tab (no pane):
@@ -588,6 +590,9 @@ impl> Processor {
crate::display::SshEditorHit::SaveToggleBox
| crate::display::SshEditorHit::SaveToggleLabel
| crate::display::SshEditorHit::PasswordToggle
+ | crate::display::SshEditorHit::Auth(_)
+ | crate::display::SshEditorHit::AddPrivateKey
+ | crate::display::SshEditorHit::RemovePrivateKey(_)
| crate::display::SshEditorHit::Primary
| crate::display::SshEditorHit::Cancel => CursorIcon::Pointer,
crate::display::SshEditorHit::None => CursorIcon::Default,
@@ -646,14 +651,13 @@ impl> Processor {
if self.ctx.display().context_menu_interactive() {
self.ctx.display().set_ssh_delete_undo_hover(false);
let hit = self.ctx.display().context_menu_hover(x as f32, y as f32);
- self.ctx.window().set_mouse_cursor(if matches!(
- hit,
- crate::display::ContextMenuHit::Action(_)
- ) {
- CursorIcon::Pointer
- } else {
- CursorIcon::Default
- });
+ self.ctx.window().set_mouse_cursor(
+ if matches!(hit, crate::display::ContextMenuHit::Action(_)) {
+ CursorIcon::Pointer
+ } else {
+ CursorIcon::Default
+ },
+ );
return;
}
let undo_hover = self.ctx.display().nebula_confirm.is_none()
@@ -755,6 +759,32 @@ impl> Processor {
// no beam cursor bleeding through). Skipped while the left button is
// down — an in-progress text drag-selection sweeping across the drawer
// must keep updating, not freeze at its edge.
+ if self.ctx.display().nebula_side_panel.open
+ && self.ctx.display().nebula_sftp_panel.is_some()
+ && self.ctx.mouse().left_button_state != ElementState::Pressed
+ {
+ let hit = self.ctx.display().sftp_hit(x as f32, y as f32);
+ if self.ctx.display().sftp_set_hover(hit) {
+ self.ctx.mark_dirty();
+ }
+ if hit != crate::display::sftp_panel::SftpHit::None {
+ let cursor = match hit {
+ crate::display::sftp_panel::SftpHit::Path
+ | crate::display::sftp_panel::SftpHit::Filter => CursorIcon::Text,
+ crate::display::sftp_panel::SftpHit::Close
+ | crate::display::sftp_panel::SftpHit::Up
+ | crate::display::sftp_panel::SftpHit::Refresh
+ | crate::display::sftp_panel::SftpHit::UploadFiles
+ | crate::display::sftp_panel::SftpHit::UploadDirectory
+ | crate::display::sftp_panel::SftpHit::NewDirectory
+ | crate::display::sftp_panel::SftpHit::Row(_)
+ | crate::display::sftp_panel::SftpHit::Cancel => CursorIcon::Pointer,
+ _ => CursorIcon::Default,
+ };
+ self.ctx.window().set_mouse_cursor(cursor);
+ return;
+ }
+ }
if self.ctx.display().nebula_side_panel.open
&& self.ctx.mouse().left_button_state != ElementState::Pressed
{
@@ -1031,6 +1061,9 @@ impl> Processor {
self.schedule_ssh_delete_undo_expiry();
}
},
+ NebulaConfirm::DeleteSftp { entry } => {
+ self.ctx.display().sftp_confirm_delete(entry);
+ },
}
}
@@ -1043,21 +1076,14 @@ impl> Processor {
let event = Event::new(EventType::SshDeleteUndoExpired, window_id);
let scheduler = self.ctx.scheduler_mut();
scheduler.unschedule(timer_id);
- scheduler.schedule(
- event,
- crate::display::SSH_DELETE_UNDO_DURATION,
- false,
- timer_id,
- );
+ scheduler.schedule(event, crate::display::SSH_DELETE_UNDO_DURATION, false, timer_id);
}
/// Cancel expiry first so a late event cannot immediately dispose a host
/// that the user has just restored.
fn undo_ssh_delete(&mut self) -> bool {
let window_id = self.ctx.window().id();
- self.ctx
- .scheduler_mut()
- .unschedule(TimerId::new(Topic::SshDeleteUndo, window_id));
+ self.ctx.scheduler_mut().unschedule(TimerId::new(Topic::SshDeleteUndo, window_id));
self.ctx.display().undo_delete_ssh_host()
}
@@ -1126,6 +1152,12 @@ impl> Processor {
self.ctx.nebula_tab(crate::event::TabRequest::NewSsh(host));
}
},
+ OpenSftp(index) => {
+ let host = self.ctx.display().nebula_ssh_hosts.get(index).cloned();
+ if let Some(host) = host {
+ self.ctx.nebula_open_sftp(host);
+ }
+ },
CopySshAddress(index) => {
let host = self.ctx.display().nebula_ssh_hosts.get(index).cloned();
if let Some(host) = host {
@@ -1134,6 +1166,9 @@ impl> Processor {
},
EditSsh(index) => self.ctx.display().edit_ssh_host(index),
DeleteSsh(index) => self.ctx.display().request_delete_ssh_host(index),
+ DownloadSftp(index) => self.ctx.display().sftp_download_row(index),
+ RenameSftp(index) => self.ctx.display().sftp_begin_rename_row(index),
+ DeleteSftp(index) => self.ctx.display().sftp_request_delete_row(index),
}
self.ctx.mark_dirty();
}
@@ -1149,570 +1184,597 @@ impl> Processor {
// terminal's click-state machine is useful and testable without a GPU
// window; real application contexts keep the default `true` value.
if self.ctx.nebula_chrome_active() {
-
- // Non-primary presses also release lightweight menus. Right-click on
- // a context menu is handled below so it can naturally retarget.
- if button != MouseButton::Left {
- if button != MouseButton::Right && self.ctx.display().context_menu_interactive() {
- self.ctx.display().close_context_menu();
- self.ctx.mark_dirty();
- return;
- }
- if self.ctx.display().command_palette_picker_open() {
- self.ctx.display().close_command_palette();
- self.ctx.mark_dirty();
- return;
- }
- if self.ctx.display().nebula_shell_picker_open {
- self.ctx.display().close_shell_picker();
- self.ctx.mark_dirty();
- return;
- }
- }
-
- if button == MouseButton::Left && self.ctx.display().context_menu_interactive() {
- let x = self.ctx.mouse().x as f32;
- let y = self.ctx.mouse().y as f32;
- if let crate::display::ContextMenuHit::Action(action) =
- self.ctx.display().context_menu_click(x, y)
- {
- self.run_context_menu_action(action);
- }
- self.ctx.mark_dirty();
- return;
- }
-
- // A left press anywhere OUTSIDE the rename box ends the edit
- // (canceling, like Esc) — the click itself still lands wherever it
- // was aimed. Clicking inside the box is caret placement (below).
- if button == MouseButton::Left {
- if let Some((idx, _)) = self.ctx.display().nebula_tab_rename.clone() {
- let x = self.ctx.mouse().x as f32;
- let y = self.ctx.mouse().y as f32;
- if self.ctx.display().chrome_hit(x, y) != crate::display::ChromeHit::Tab(idx) {
- self.ctx.nebula_tab(crate::event::TabRequest::CancelRename);
- }
- }
- }
-
- if button == MouseButton::Left && self.ctx.display().nebula_ssh_editor.is_some() {
- if self.ctx.display().ssh_editor_active() {
- let x = self.ctx.mouse().x as f32;
- let y = self.ctx.mouse().y as f32;
- self.ctx.display().ssh_editor_click(x, y);
- }
- self.ctx.mark_dirty();
- return;
- }
-
- // Nebula command palette: clicking a row runs it, clicking outside
- // dismisses — same modal semantics as the keyboard path.
- if button == MouseButton::Left && self.ctx.display().command_palette_open() {
- let x = self.ctx.mouse().x as f32;
- let y = self.ctx.mouse().y as f32;
- let size = self.ctx.display().size_info;
- let scale = self.ctx.window().scale_factor as f32;
- let layout = crate::display::command_palette::palette_layout(
- size.width(),
- size.height(),
- scale,
- );
- let (px, py, pw, ph) = layout.panel;
- if x >= px && x < px + pw && y >= py && y < py + ph {
- if y >= layout.list_y {
- let row = ((y - layout.list_y) / layout.row_h) as usize;
- if let Some(action) = self.ctx.display().palette_click(row, layout.max_rows) {
- self.run_palette_action(action);
- }
- }
- } else {
- self.ctx.display().close_command_palette();
- }
- self.ctx.mark_dirty();
- return;
- }
-
- // The reversible-action bar floats above drawer/chrome content. True
- // modals still retain pointer ownership and therefore block this path.
- let undo_pointer = (self.ctx.mouse().x as f32, self.ctx.mouse().y as f32);
- if button == MouseButton::Left
- && self.ctx.display().nebula_confirm.is_none()
- && self
- .ctx
- .display()
- .ssh_delete_undo_hit(undo_pointer.0, undo_pointer.1)
- {
- self.undo_ssh_delete();
- self.ctx.mark_dirty();
- return;
- }
-
- // Right-side drawer (directory tree / git): header tabs switch views,
- // directory rows expand/collapse. Sits under the modal layers, so
- // only when no modal owns the pointer.
- if button == MouseButton::Left
- && self.ctx.display().nebula_side_panel.open
- && !self.ctx.display().settings_open()
- && self.ctx.display().nebula_confirm.is_none()
- {
- use crate::display::side_panel::{PanelHit, PanelView, panel_hit};
- let x = self.ctx.mouse().x as f32;
- let y = self.ctx.mouse().y as f32;
- let layout = self.ctx.display().side_panel_layout();
- match panel_hit(&layout, x, y) {
- PanelHit::None => {
- // Clicking anywhere outside the drawer drops search focus
- // and the persistent file selection.
- let panel = &mut self.ctx.display().nebula_side_panel;
- if panel.search_focus || panel.selected.is_some() {
- panel.search_unfocus(false);
- panel.selected = None;
- self.ctx.mark_dirty();
- }
- },
- hit => {
- match hit {
- PanelHit::ViewFiles => {
- self.ctx.display().toggle_side_panel(PanelView::Files)
- },
- PanelHit::ViewGit => self.ctx.display().toggle_side_panel(PanelView::Git),
- PanelHit::Search => {
- let files =
- self.ctx.display().nebula_side_panel.view == PanelView::Files;
- if files {
- // The Files view's filter box takes focus.
- self.ctx.display().nebula_side_panel.search_focus = true;
- } else {
- // Git view: that strip is the 暂存/提交/推送
- // button row (or the commit-message input,
- // which the keyboard owns — clicks are inert).
- if !self.ctx.display().nebula_side_panel.commit_focus {
- let (sx, _, sw, _) = layout.search;
- let gap = 8.0 * self.ctx.window().scale_factor as f32;
- let rects =
- crate::display::side_panel::git_button_rects(sx, sw, gap);
- let panel = &mut self.ctx.display().nebula_side_panel;
- if x < rects[0].0 + rects[0].1 {
- panel.git_stage_all();
- } else if x < rects[1].0 + rects[1].1 {
- panel.git_begin_commit();
- } else {
- panel.git_push();
- }
- }
- }
- },
- PanelHit::Row(row) => {
- self.ctx.display().nebula_side_panel.search_unfocus(false);
- let info = self
- .ctx
- .display()
- .nebula_side_panel
- .visible_row(row)
- .map(|r| (r.path.clone(), r.is_dir));
- match info {
- // Directories expand/collapse on click.
- Some((_, true)) | None => {
- self.ctx.display().nebula_side_panel.click_row(row);
- },
- // Files: double-click opens with the system
- // handler; a single press arms a drag toward
- // the terminal (drop pastes the path).
- Some((path, false)) => {
- use crate::display::side_panel::FileDrag;
- let now = std::time::Instant::now();
- let dbl = {
- let panel = &mut self.ctx.display().nebula_side_panel;
- // Click = persistent selection (until
- // clicking off the panel / closing it).
- panel.selected = Some(path.clone());
- let dbl =
- panel.last_file_click.as_ref().is_some_and(|(p, t)| {
- *p == path
- && t.elapsed()
- < std::time::Duration::from_millis(400)
- });
- if dbl {
- panel.last_file_click = None;
- panel.drag_file = None;
- } else {
- panel.last_file_click = Some((path.clone(), now));
- let name = path
- .file_name()
- .map(|n| n.to_string_lossy().into_owned())
- .unwrap_or_default();
- panel.drag_file = Some(FileDrag {
- path: path.clone(),
- name,
- origin: (x, y),
- pos: (x, y),
- active: false,
- });
- }
- dbl
- };
- if dbl {
- // Readable text files open in an
- // in-app viewer tab; everything else
- // goes to the system handler.
- if crate::display::markdown_view::viewable_file(&path) {
- self.ctx.nebula_tab(crate::event::TabRequest::OpenDoc(
- path,
- ));
- } else {
- self.ctx.open_path(&path);
- }
- }
- },
- }
- },
- _ => {
- self.ctx.display().nebula_side_panel.search_unfocus(false);
- },
- }
- self.ctx.mark_dirty();
- return;
- },
- }
- }
-
- // Right-clicking the sidebar "+" opens the quick-launch profile menu
- // (Windows Terminal's profile dropdown); left-click keeps opening the
- // default shell. Tab and SSH context menus will replace the old
- // reorder/pin shortcuts; until that menu lands, SSH right-click is
- // consumed without changing the saved-host order.
- if button == MouseButton::Right {
- let x = self.ctx.mouse().x as f32;
- let y = self.ctx.mouse().y as f32;
- let menu_was_open = self.ctx.display().context_menu_interactive();
- if menu_was_open
- && !matches!(
- self.ctx.display().context_menu_hit(x, y),
- crate::display::ContextMenuHit::Outside
- )
- {
- return;
- }
- match self.ctx.display().chrome_hit(x, y) {
- crate::display::ChromeHit::NewTab => {
- let profiles: Vec =
- self.ctx.config().profiles.iter().map(|p| p.name.clone()).collect();
- // Detected shells fill the menu even with no config
- // profiles, so always open it.
- self.ctx.display().open_shell_menu(&profiles);
- self.ctx.mark_dirty();
- return;
- },
- crate::display::ChromeHit::Tab(index)
- | crate::display::ChromeHit::TabClose(index) => {
- self.ctx.display().open_tab_context_menu(index, x, y);
- self.ctx.mark_dirty();
- return;
- },
- crate::display::ChromeHit::Host(index) => {
- self.ctx.display().open_ssh_context_menu(index, x, y);
- self.ctx.mark_dirty();
- return;
- },
- _ if menu_was_open => {
+ // Non-primary presses also release lightweight menus. Right-click on
+ // a context menu is handled below so it can naturally retarget.
+ if button != MouseButton::Left {
+ if button != MouseButton::Right && self.ctx.display().context_menu_interactive() {
self.ctx.display().close_context_menu();
self.ctx.mark_dirty();
return;
- },
- _ => {},
+ }
+ if self.ctx.display().command_palette_picker_open() {
+ self.ctx.display().close_command_palette();
+ self.ctx.mark_dirty();
+ return;
+ }
+ if self.ctx.display().nebula_shell_picker_open {
+ self.ctx.display().close_shell_picker();
+ self.ctx.mark_dirty();
+ return;
+ }
}
- }
- // Nebula chrome: intercept clicks on the custom title bar and window
- // controls before any terminal handling.
- if button == MouseButton::Left {
- let x = self.ctx.mouse().x as f32;
- let y = self.ctx.mouse().y as f32;
- // Chrome geometry is window-relative; the pane view would misplace
- // every hit rect in split mode (unclickable gear, wrong tabs).
- let size = self.ctx.display().size_info;
- let scale = self.ctx.window().scale_factor as f32;
- // Window border resize takes priority over the chrome controls.
- if let Some(dir) = crate::display::resize_edge(&size, scale, x, y) {
- self.ctx.window().drag_resize(dir);
- return;
- }
- // The confirm modal owns the pointer while it shows: its two
- // buttons dispatch, any other click is swallowed (modal
- // semantics — nothing may reach the UI behind the veil).
- if let Some(confirm) = self.ctx.display().nebula_confirm.clone() {
- if let Some((primary, cancel)) = self.ctx.display().nebula_confirm_buttons {
- let hit = |(rx, ry, rw, rh): (f32, f32, f32, f32)| {
- x >= rx && x < rx + rw && y >= ry && y < ry + rh
- };
- if hit(primary) {
- self.nebula_confirm_accept(confirm);
- } else if hit(cancel) {
- self.ctx.display().nebula_confirm = None;
- }
+ if button == MouseButton::Left && self.ctx.display().context_menu_interactive() {
+ let x = self.ctx.mouse().x as f32;
+ let y = self.ctx.mouse().y as f32;
+ if let crate::display::ContextMenuHit::Action(action) =
+ self.ctx.display().context_menu_click(x, y)
+ {
+ self.run_context_menu_action(action);
}
self.ctx.mark_dirty();
return;
}
- let settings_open = self.ctx.display().settings_open();
- let settings_section = self.ctx.display().settings_section();
- let settings_scroll = self.ctx.display().settings_scroll();
- let shell_picker_open = self.ctx.display().nebula_shell_picker_open;
- let shell_picker_count = self.ctx.display().shell_picker_count();
- let hidden_host_count = self.ctx.display().hidden_ssh_host_count();
- let settings_hit = crate::display::settings_hit(
- &size,
- scale,
- x,
- y,
- settings_open,
- settings_section,
- settings_scroll,
- shell_picker_open,
- shell_picker_count,
- hidden_host_count,
- );
- if shell_picker_open
- && !matches!(
- settings_hit,
- crate::display::SettingsHit::ShellCycle
- | crate::display::SettingsHit::ShellPickerRow(_)
- )
- {
- // First outside click dismisses the picker without activating
- // an unrelated control hidden behind its temporary focus scope.
- self.ctx.display().close_shell_picker();
+
+ // A left press anywhere OUTSIDE the rename box ends the edit
+ // (canceling, like Esc) — the click itself still lands wherever it
+ // was aimed. Clicking inside the box is caret placement (below).
+ if button == MouseButton::Left {
+ if let Some((idx, _)) = self.ctx.display().nebula_tab_rename.clone() {
+ let x = self.ctx.mouse().x as f32;
+ let y = self.ctx.mouse().y as f32;
+ if self.ctx.display().chrome_hit(x, y) != crate::display::ChromeHit::Tab(idx) {
+ self.ctx.nebula_tab(crate::event::TabRequest::CancelRename);
+ }
+ }
+ }
+
+ if button == MouseButton::Left && self.ctx.display().nebula_ssh_editor.is_some() {
+ if self.ctx.display().ssh_editor_active() {
+ let x = self.ctx.mouse().x as f32;
+ let y = self.ctx.mouse().y as f32;
+ self.ctx.display().ssh_editor_click(x, y);
+ }
self.ctx.mark_dirty();
return;
}
- match settings_hit {
- crate::display::SettingsHit::Toggle => {
- self.ctx.display().toggle_settings();
- self.ctx.mark_dirty();
- return;
- },
- crate::display::SettingsHit::Nav(section) => {
- self.ctx.display().select_settings_section(section);
- self.ctx.mark_dirty();
- return;
- },
- crate::display::SettingsHit::Theme(theme) => {
- self.ctx.display().select_nebula_theme(theme);
- self.ctx.mark_dirty();
- return;
- },
- crate::display::SettingsHit::GhostToggle => {
- self.ctx.display().toggle_ghost();
- self.ctx.mark_dirty();
- return;
- },
- crate::display::SettingsHit::AcceptCycle => {
- self.ctx.display().cycle_accept();
- self.ctx.mark_dirty();
- return;
- },
- crate::display::SettingsHit::ShellCycle => {
- // Toggle inline shell picker (expand/collapse the list).
- self.ctx.display().toggle_shell_picker();
- self.ctx.mark_dirty();
- return;
- },
- crate::display::SettingsHit::ShellPickerRow(index) => {
- self.ctx.display().set_default_shell_by_index(index);
- self.ctx.mark_dirty();
- return;
- },
- crate::display::SettingsHit::SystemThemeToggle => {
- self.ctx.display().toggle_system_theme_following();
- self.ctx.mark_dirty();
- return;
- },
- crate::display::SettingsHit::RestoreHiddenSsh(index) => {
- self.ctx.display().restore_hidden_ssh_host(index);
- self.ctx.mark_dirty();
- return;
- },
- crate::display::SettingsHit::FetchToggle => {
- self.ctx.display().toggle_fetch();
- self.ctx.mark_dirty();
- return;
- },
- crate::display::SettingsHit::PowerlineToggle => {
- self.ctx.display().toggle_powerline();
- self.ctx.mark_dirty();
- return;
- },
- crate::display::SettingsHit::KeepSessionToggle => {
- self.ctx.display().toggle_keep_session();
- self.ctx.mark_dirty();
- return;
- },
- crate::display::SettingsHit::OpacityDown => {
- self.ctx.display().adjust_window_opacity(-0.05);
- self.ctx.mark_dirty();
- return;
- },
- crate::display::SettingsHit::OpacityUp => {
- self.ctx.display().adjust_window_opacity(0.05);
- self.ctx.mark_dirty();
- return;
- },
- crate::display::SettingsHit::BackgroundColor => {
- self.ctx.display().cycle_background_color();
- self.ctx.mark_dirty();
- return;
- },
- crate::display::SettingsHit::BackgroundImage => {
- self.ctx.display().pick_background_image();
- self.ctx.mark_dirty();
- return;
- },
- crate::display::SettingsHit::OpenConfigFile => {
- self.ctx.display().open_settings_file();
- self.ctx.mark_dirty();
- return;
- },
- crate::display::SettingsHit::Reset => {
- self.ctx.display().reset_appearance_settings();
- self.ctx.mark_dirty();
- return;
- },
- crate::display::SettingsHit::Panel => return,
- crate::display::SettingsHit::Dismiss => {
- self.ctx.display().dismiss_settings();
- self.ctx.mark_dirty();
- return;
- },
- crate::display::SettingsHit::None => {},
+
+ // Nebula command palette: clicking a row runs it, clicking outside
+ // dismisses — same modal semantics as the keyboard path.
+ if button == MouseButton::Left && self.ctx.display().command_palette_open() {
+ let x = self.ctx.mouse().x as f32;
+ let y = self.ctx.mouse().y as f32;
+ let size = self.ctx.display().size_info;
+ let scale = self.ctx.window().scale_factor as f32;
+ let layout = crate::display::command_palette::palette_layout(
+ size.width(),
+ size.height(),
+ scale,
+ );
+ let (px, py, pw, ph) = layout.panel;
+ if x >= px && x < px + pw && y >= py && y < py + ph {
+ if y >= layout.list_y {
+ let row = ((y - layout.list_y) / layout.row_h) as usize;
+ if let Some(action) = self.ctx.display().palette_click(row, layout.max_rows)
+ {
+ self.run_palette_action(action);
+ }
+ }
+ } else {
+ self.ctx.display().close_command_palette();
+ }
+ self.ctx.mark_dirty();
+ return;
}
- let chrome_hit = self.ctx.display().chrome_hit(x, y);
- // Multi-click state was advanced once at the top of this
- // function; read it for the tab double-click rename below.
- let state = self.ctx.mouse().click_state;
- match chrome_hit {
- crate::display::ChromeHit::NewTab => {
- self.ctx.nebula_tab(crate::event::TabRequest::New);
- return;
- },
- crate::display::ChromeHit::NewTabMenu => {
- // The chevron opens the shell dropdown (detected shells +
- // config profiles), like Windows Terminal's profile menu.
- let profiles: Vec =
- self.ctx.config().profiles.iter().map(|p| p.name.clone()).collect();
- self.ctx.display().open_shell_menu(&profiles);
+
+ // The reversible-action bar floats above drawer/chrome content. True
+ // modals still retain pointer ownership and therefore block this path.
+ let undo_pointer = (self.ctx.mouse().x as f32, self.ctx.mouse().y as f32);
+ if button == MouseButton::Left
+ && self.ctx.display().nebula_confirm.is_none()
+ && self.ctx.display().ssh_delete_undo_hit(undo_pointer.0, undo_pointer.1)
+ {
+ self.undo_ssh_delete();
+ self.ctx.mark_dirty();
+ return;
+ }
+
+ // Right-side drawer (directory tree / git): header tabs switch views,
+ // directory rows expand/collapse. Sits under the modal layers, so
+ // only when no modal owns the pointer.
+ if button == MouseButton::Left
+ && self.ctx.display().nebula_sftp_panel.is_some()
+ && !self.ctx.display().settings_open()
+ && self.ctx.display().nebula_confirm.is_none()
+ {
+ let x = self.ctx.mouse().x as f32;
+ let y = self.ctx.mouse().y as f32;
+ let hit = self.ctx.display().sftp_hit(x, y);
+ if hit != crate::display::sftp_panel::SftpHit::None {
+ self.ctx.display().sftp_click(hit);
self.ctx.mark_dirty();
return;
- },
- crate::display::ChromeHit::AddSshHost => {
- self.ctx.display().open_ssh_editor();
- self.ctx.mark_dirty();
- return;
- },
- crate::display::ChromeHit::TabClose(index) => {
- self.ctx.nebula_tab(crate::event::TabRequest::CloseIndex(index));
- return;
- },
- crate::display::ChromeHit::Tab(index) => {
- // Clicking inside the rename box places the caret there
- // (real text-field behaviour) instead of starting a drag.
- if self
- .ctx
- .display()
- .nebula_tab_rename
- .as_ref()
- .is_some_and(|(i, _)| *i == index)
- {
- self.ctx.display().tab_rename_click(x);
+ }
+ }
+ if button == MouseButton::Left
+ && self.ctx.display().nebula_side_panel.open
+ && self.ctx.display().nebula_sftp_panel.is_none()
+ && !self.ctx.display().settings_open()
+ && self.ctx.display().nebula_confirm.is_none()
+ {
+ use crate::display::side_panel::{PanelHit, PanelView, panel_hit};
+ let x = self.ctx.mouse().x as f32;
+ let y = self.ctx.mouse().y as f32;
+ let layout = self.ctx.display().side_panel_layout();
+ match panel_hit(&layout, x, y) {
+ PanelHit::None => {
+ // Clicking anywhere outside the drawer drops search focus
+ // and the persistent file selection.
+ let panel = &mut self.ctx.display().nebula_side_panel;
+ if panel.search_focus || panel.selected.is_some() {
+ panel.search_unfocus(false);
+ panel.selected = None;
+ self.ctx.mark_dirty();
+ }
+ },
+ hit => {
+ match hit {
+ PanelHit::ViewFiles => {
+ self.ctx.display().toggle_side_panel(PanelView::Files)
+ },
+ PanelHit::ViewGit => {
+ self.ctx.display().toggle_side_panel(PanelView::Git)
+ },
+ PanelHit::Search => {
+ let files =
+ self.ctx.display().nebula_side_panel.view == PanelView::Files;
+ if files {
+ // The Files view's filter box takes focus.
+ self.ctx.display().nebula_side_panel.search_focus = true;
+ } else {
+ // Git view: that strip is the 暂存/提交/推送
+ // button row (or the commit-message input,
+ // which the keyboard owns — clicks are inert).
+ if !self.ctx.display().nebula_side_panel.commit_focus {
+ let (sx, _, sw, _) = layout.search;
+ let gap = 6.0 * self.ctx.window().scale_factor as f32;
+ let rects = crate::display::side_panel::git_button_rects(
+ sx, sw, gap,
+ );
+ let panel = &mut self.ctx.display().nebula_side_panel;
+ let action =
+ rects.iter().position(|(button_x, button_w)| {
+ x >= *button_x && x < *button_x + *button_w
+ });
+ match action {
+ Some(0) => panel.git_stage_all(),
+ Some(1) => panel.git_begin_commit(),
+ Some(2) => panel.git_pull(),
+ Some(3) => panel.git_push(),
+ _ => {},
+ }
+ }
+ }
+ },
+ PanelHit::Row(row) => {
+ self.ctx.display().nebula_side_panel.search_unfocus(false);
+ let info = self
+ .ctx
+ .display()
+ .nebula_side_panel
+ .visible_row(row)
+ .map(|r| (r.path.clone(), r.is_dir));
+ match info {
+ // Directories expand/collapse on click.
+ Some((_, true)) | None => {
+ self.ctx.display().nebula_side_panel.click_row(row);
+ },
+ // Files: double-click opens with the system
+ // handler; a single press arms a drag toward
+ // the terminal (drop pastes the path).
+ Some((path, false)) => {
+ use crate::display::side_panel::FileDrag;
+ let now = std::time::Instant::now();
+ let dbl = {
+ let panel = &mut self.ctx.display().nebula_side_panel;
+ // Click = persistent selection (until
+ // clicking off the panel / closing it).
+ panel.selected = Some(path.clone());
+ let dbl = panel.last_file_click.as_ref().is_some_and(
+ |(p, t)| {
+ *p == path
+ && t.elapsed()
+ < std::time::Duration::from_millis(400)
+ },
+ );
+ if dbl {
+ panel.last_file_click = None;
+ panel.drag_file = None;
+ } else {
+ panel.last_file_click = Some((path.clone(), now));
+ let name = path
+ .file_name()
+ .map(|n| n.to_string_lossy().into_owned())
+ .unwrap_or_default();
+ panel.drag_file = Some(FileDrag {
+ path: path.clone(),
+ name,
+ origin: (x, y),
+ pos: (x, y),
+ active: false,
+ });
+ }
+ dbl
+ };
+ if dbl {
+ // Readable text files open in an
+ // in-app viewer tab; everything else
+ // goes to the system handler.
+ if crate::display::markdown_view::viewable_file(&path) {
+ self.ctx.nebula_tab(
+ crate::event::TabRequest::OpenDoc(path),
+ );
+ } else {
+ self.ctx.open_path(&path);
+ }
+ }
+ },
+ }
+ },
+ _ => {
+ self.ctx.display().nebula_side_panel.search_unfocus(false);
+ },
+ }
self.ctx.mark_dirty();
return;
- }
- // Double-click a tab to start renaming (Windows Terminal style).
- if state == ClickState::DoubleClick {
- self.ctx.nebula_tab(crate::event::TabRequest::BeginRename(index));
- return;
- }
- // Selection is deferred to release (a plain click becomes
- // TabDropAction::Click): the terminal area must keep
- // showing the ACTIVE tab while another tab is dragged over
- // it toward a dock zone.
- self.ctx.display().arm_tab_drag(index, x, y);
- return;
- },
- crate::display::ChromeHit::SidebarToggle => {
- self.ctx.display().toggle_sidebar();
- self.ctx.mark_dirty();
- return;
- },
- crate::display::ChromeHit::Host(index) => {
- // Open a new tab connected to this ~/.ssh/config host.
- let host = self.ctx.display().nebula_ssh_hosts.get(index).cloned();
- if let Some(host) = host {
- self.ctx.nebula_tab(crate::event::TabRequest::NewSsh(host));
- }
- return;
- },
- crate::display::ChromeHit::TabsSection => {
- self.ctx.display().toggle_sidebar_section(false);
- self.ctx.mark_dirty();
- return;
- },
- crate::display::ChromeHit::HostsSection => {
- self.ctx.display().toggle_sidebar_section(true);
- self.ctx.mark_dirty();
- return;
- },
- crate::display::ChromeHit::PanelFiles => {
- self.ctx
- .display()
- .toggle_side_panel(crate::display::side_panel::PanelView::Files);
- self.ctx.mark_dirty();
- return;
- },
- crate::display::ChromeHit::PanelGit => {
- self.ctx
- .display()
- .toggle_side_panel(crate::display::side_panel::PanelView::Git);
- self.ctx.mark_dirty();
- return;
- },
- crate::display::ChromeHit::Close => {
- self.ctx.nebula_tab(crate::event::TabRequest::CloseWindow);
- return;
- },
- crate::display::ChromeHit::Minimize => {
- self.ctx.window().set_minimized(true);
- return;
- },
- crate::display::ChromeHit::Maximize => {
- self.ctx.window().toggle_maximized();
- return;
- },
- crate::display::ChromeHit::TitleBar => {
- self.ctx.window().drag_window();
- return;
- },
- crate::display::ChromeHit::None => {},
+ },
+ }
}
- // Nebula: grab the scrollback thumb (or jump on a track press).
- // Only live while scrolled into history, since the bar auto-hides.
- let view = self.ctx.size_info();
- let display_offset = self.ctx.terminal().grid().display_offset();
- let total_lines = self.ctx.terminal().total_lines();
- if let Some(grab) =
- self.ctx.display().scrollbar_grab(&view, display_offset, total_lines, x, y)
- {
- self.ctx.display().nebula_scrollbar_drag = Some(grab);
- let target =
- self.ctx.display().scrollbar_target_offset(&view, total_lines, y, grab);
- let delta = target as i32 - display_offset as i32;
- if delta != 0 {
- self.ctx.scroll(Scroll::Delta(delta));
+ // Right-clicking the sidebar "+" opens the quick-launch profile menu
+ // (Windows Terminal's profile dropdown); left-click keeps opening the
+ // default shell. Tab and SSH context menus will replace the old
+ // reorder/pin shortcuts; until that menu lands, SSH right-click is
+ // consumed without changing the saved-host order.
+ if button == MouseButton::Right {
+ let x = self.ctx.mouse().x as f32;
+ let y = self.ctx.mouse().y as f32;
+ let menu_was_open = self.ctx.display().context_menu_interactive();
+ if menu_was_open
+ && !matches!(
+ self.ctx.display().context_menu_hit(x, y),
+ crate::display::ContextMenuHit::Outside
+ )
+ {
+ return;
+ }
+ if let crate::display::sftp_panel::SftpHit::Row(index) =
+ self.ctx.display().sftp_hit(x, y)
+ {
+ self.ctx.display().open_sftp_context_menu(index, x, y);
+ self.ctx.mark_dirty();
+ return;
+ }
+ match self.ctx.display().chrome_hit(x, y) {
+ crate::display::ChromeHit::NewTab => {
+ let profiles: Vec =
+ self.ctx.config().profiles.iter().map(|p| p.name.clone()).collect();
+ // Detected shells fill the menu even with no config
+ // profiles, so always open it.
+ self.ctx.display().open_shell_menu(&profiles);
+ self.ctx.mark_dirty();
+ return;
+ },
+ crate::display::ChromeHit::Tab(index)
+ | crate::display::ChromeHit::TabClose(index) => {
+ self.ctx.display().open_tab_context_menu(index, x, y);
+ self.ctx.mark_dirty();
+ return;
+ },
+ crate::display::ChromeHit::Host(index) => {
+ self.ctx.display().open_ssh_context_menu(index, x, y);
+ self.ctx.mark_dirty();
+ return;
+ },
+ _ if menu_was_open => {
+ self.ctx.display().close_context_menu();
+ self.ctx.mark_dirty();
+ return;
+ },
+ _ => {},
+ }
+ }
+
+ // Nebula chrome: intercept clicks on the custom title bar and window
+ // controls before any terminal handling.
+ if button == MouseButton::Left {
+ let x = self.ctx.mouse().x as f32;
+ let y = self.ctx.mouse().y as f32;
+ // Chrome geometry is window-relative; the pane view would misplace
+ // every hit rect in split mode (unclickable gear, wrong tabs).
+ let size = self.ctx.display().size_info;
+ let scale = self.ctx.window().scale_factor as f32;
+ // Window border resize takes priority over the chrome controls.
+ if let Some(dir) = crate::display::resize_edge(&size, scale, x, y) {
+ self.ctx.window().drag_resize(dir);
+ return;
+ }
+ // The confirm modal owns the pointer while it shows: its two
+ // buttons dispatch, any other click is swallowed (modal
+ // semantics — nothing may reach the UI behind the veil).
+ if let Some(confirm) = self.ctx.display().nebula_confirm.clone() {
+ if let Some((primary, cancel)) = self.ctx.display().nebula_confirm_buttons {
+ let hit = |(rx, ry, rw, rh): (f32, f32, f32, f32)| {
+ x >= rx && x < rx + rw && y >= ry && y < ry + rh
+ };
+ if hit(primary) {
+ self.nebula_confirm_accept(confirm);
+ } else if hit(cancel) {
+ self.ctx.display().nebula_confirm = None;
+ }
+ }
+ self.ctx.mark_dirty();
+ return;
+ }
+ let settings_open = self.ctx.display().settings_open();
+ let settings_section = self.ctx.display().settings_section();
+ let settings_scroll = self.ctx.display().settings_scroll();
+ let shell_picker_open = self.ctx.display().nebula_shell_picker_open;
+ let shell_picker_count = self.ctx.display().shell_picker_count();
+ let hidden_host_count = self.ctx.display().hidden_ssh_host_count();
+ let settings_hit = crate::display::settings_hit(
+ &size,
+ scale,
+ x,
+ y,
+ settings_open,
+ settings_section,
+ settings_scroll,
+ shell_picker_open,
+ shell_picker_count,
+ hidden_host_count,
+ );
+ if shell_picker_open
+ && !matches!(
+ settings_hit,
+ crate::display::SettingsHit::ShellCycle
+ | crate::display::SettingsHit::ShellPickerRow(_)
+ )
+ {
+ // First outside click dismisses the picker without activating
+ // an unrelated control hidden behind its temporary focus scope.
+ self.ctx.display().close_shell_picker();
+ self.ctx.mark_dirty();
+ return;
+ }
+ match settings_hit {
+ crate::display::SettingsHit::Toggle => {
+ self.ctx.display().toggle_settings();
+ self.ctx.mark_dirty();
+ return;
+ },
+ crate::display::SettingsHit::Nav(section) => {
+ self.ctx.display().select_settings_section(section);
+ self.ctx.mark_dirty();
+ return;
+ },
+ crate::display::SettingsHit::Theme(theme) => {
+ self.ctx.display().select_nebula_theme(theme);
+ self.ctx.mark_dirty();
+ return;
+ },
+ crate::display::SettingsHit::GhostToggle => {
+ self.ctx.display().toggle_ghost();
+ self.ctx.mark_dirty();
+ return;
+ },
+ crate::display::SettingsHit::AcceptCycle => {
+ self.ctx.display().cycle_accept();
+ self.ctx.mark_dirty();
+ return;
+ },
+ crate::display::SettingsHit::ShellCycle => {
+ // Toggle inline shell picker (expand/collapse the list).
+ self.ctx.display().toggle_shell_picker();
+ self.ctx.mark_dirty();
+ return;
+ },
+ crate::display::SettingsHit::ShellPickerRow(index) => {
+ self.ctx.display().set_default_shell_by_index(index);
+ self.ctx.mark_dirty();
+ return;
+ },
+ crate::display::SettingsHit::SystemThemeToggle => {
+ self.ctx.display().toggle_system_theme_following();
+ self.ctx.mark_dirty();
+ return;
+ },
+ crate::display::SettingsHit::RestoreHiddenSsh(index) => {
+ self.ctx.display().restore_hidden_ssh_host(index);
+ self.ctx.mark_dirty();
+ return;
+ },
+ crate::display::SettingsHit::FetchToggle => {
+ self.ctx.display().toggle_fetch();
+ self.ctx.mark_dirty();
+ return;
+ },
+ crate::display::SettingsHit::PowerlineToggle => {
+ self.ctx.display().toggle_powerline();
+ self.ctx.mark_dirty();
+ return;
+ },
+ crate::display::SettingsHit::KeepSessionToggle => {
+ self.ctx.display().toggle_keep_session();
+ self.ctx.mark_dirty();
+ return;
+ },
+ crate::display::SettingsHit::OpacityDown => {
+ self.ctx.display().adjust_window_opacity(-0.05);
+ self.ctx.mark_dirty();
+ return;
+ },
+ crate::display::SettingsHit::OpacityUp => {
+ self.ctx.display().adjust_window_opacity(0.05);
+ self.ctx.mark_dirty();
+ return;
+ },
+ crate::display::SettingsHit::BackgroundColor => {
+ self.ctx.display().cycle_background_color();
+ self.ctx.mark_dirty();
+ return;
+ },
+ crate::display::SettingsHit::BackgroundImage => {
+ self.ctx.display().pick_background_image();
+ self.ctx.mark_dirty();
+ return;
+ },
+ crate::display::SettingsHit::OpenConfigFile => {
+ self.ctx.display().open_settings_file();
+ self.ctx.mark_dirty();
+ return;
+ },
+ crate::display::SettingsHit::Reset => {
+ self.ctx.display().reset_appearance_settings();
+ self.ctx.mark_dirty();
+ return;
+ },
+ crate::display::SettingsHit::Panel => return,
+ crate::display::SettingsHit::Dismiss => {
+ self.ctx.display().dismiss_settings();
+ self.ctx.mark_dirty();
+ return;
+ },
+ crate::display::SettingsHit::None => {},
+ }
+ let chrome_hit = self.ctx.display().chrome_hit(x, y);
+ // Multi-click state was advanced once at the top of this
+ // function; read it for the tab double-click rename below.
+ let state = self.ctx.mouse().click_state;
+ match chrome_hit {
+ crate::display::ChromeHit::NewTab => {
+ self.ctx.nebula_tab(crate::event::TabRequest::New);
+ return;
+ },
+ crate::display::ChromeHit::NewTabMenu => {
+ // The chevron opens the shell dropdown (detected shells +
+ // config profiles), like Windows Terminal's profile menu.
+ let profiles: Vec =
+ self.ctx.config().profiles.iter().map(|p| p.name.clone()).collect();
+ self.ctx.display().open_shell_menu(&profiles);
+ self.ctx.mark_dirty();
+ return;
+ },
+ crate::display::ChromeHit::AddSshHost => {
+ self.ctx.display().open_ssh_editor();
+ self.ctx.mark_dirty();
+ return;
+ },
+ crate::display::ChromeHit::TabClose(index) => {
+ self.ctx.nebula_tab(crate::event::TabRequest::CloseIndex(index));
+ return;
+ },
+ crate::display::ChromeHit::Tab(index) => {
+ // Clicking inside the rename box places the caret there
+ // (real text-field behaviour) instead of starting a drag.
+ if self
+ .ctx
+ .display()
+ .nebula_tab_rename
+ .as_ref()
+ .is_some_and(|(i, _)| *i == index)
+ {
+ self.ctx.display().tab_rename_click(x);
+ self.ctx.mark_dirty();
+ return;
+ }
+ // Double-click a tab to start renaming (Windows Terminal style).
+ if state == ClickState::DoubleClick {
+ self.ctx.nebula_tab(crate::event::TabRequest::BeginRename(index));
+ return;
+ }
+ // Selection is deferred to release (a plain click becomes
+ // TabDropAction::Click): the terminal area must keep
+ // showing the ACTIVE tab while another tab is dragged over
+ // it toward a dock zone.
+ self.ctx.display().arm_tab_drag(index, x, y);
+ return;
+ },
+ crate::display::ChromeHit::SidebarToggle => {
+ self.ctx.display().toggle_sidebar();
+ self.ctx.mark_dirty();
+ return;
+ },
+ crate::display::ChromeHit::Host(index) => {
+ // Open a new tab connected to this ~/.ssh/config host.
+ let host = self.ctx.display().nebula_ssh_hosts.get(index).cloned();
+ if let Some(host) = host {
+ self.ctx.nebula_tab(crate::event::TabRequest::NewSsh(host));
+ }
+ return;
+ },
+ crate::display::ChromeHit::TabsSection => {
+ self.ctx.display().toggle_sidebar_section(false);
+ self.ctx.mark_dirty();
+ return;
+ },
+ crate::display::ChromeHit::HostsSection => {
+ self.ctx.display().toggle_sidebar_section(true);
+ self.ctx.mark_dirty();
+ return;
+ },
+ crate::display::ChromeHit::PanelFiles => {
+ self.ctx
+ .display()
+ .toggle_side_panel(crate::display::side_panel::PanelView::Files);
+ self.ctx.mark_dirty();
+ return;
+ },
+ crate::display::ChromeHit::PanelGit => {
+ self.ctx
+ .display()
+ .toggle_side_panel(crate::display::side_panel::PanelView::Git);
+ self.ctx.mark_dirty();
+ return;
+ },
+ crate::display::ChromeHit::Close => {
+ self.ctx.nebula_tab(crate::event::TabRequest::CloseWindow);
+ return;
+ },
+ crate::display::ChromeHit::Minimize => {
+ self.ctx.window().set_minimized(true);
+ return;
+ },
+ crate::display::ChromeHit::Maximize => {
+ self.ctx.window().toggle_maximized();
+ return;
+ },
+ crate::display::ChromeHit::TitleBar => {
+ self.ctx.window().drag_window();
+ return;
+ },
+ crate::display::ChromeHit::None => {},
+ }
+
+ // Nebula: grab the scrollback thumb (or jump on a track press).
+ // Only live while scrolled into history, since the bar auto-hides.
+ let view = self.ctx.size_info();
+ let display_offset = self.ctx.terminal().grid().display_offset();
+ let total_lines = self.ctx.terminal().total_lines();
+ if let Some(grab) =
+ self.ctx.display().scrollbar_grab(&view, display_offset, total_lines, x, y)
+ {
+ self.ctx.display().nebula_scrollbar_drag = Some(grab);
+ let target =
+ self.ctx.display().scrollbar_target_offset(&view, total_lines, y, grab);
+ let delta = target as i32 - display_offset as i32;
+ if delta != 0 {
+ self.ctx.scroll(Scroll::Delta(delta));
+ }
+ self.ctx.mark_dirty();
+ return;
}
- self.ctx.mark_dirty();
- return;
}
- }
}
// Nebula: right-click copies the selection, or pastes when there is
@@ -1968,9 +2030,14 @@ impl> Processor {
let x = self.ctx.mouse().x as f32;
let y = self.ctx.mouse().y as f32;
let layout = self.ctx.display().side_panel_layout();
- if crate::display::side_panel::panel_hit(&layout, x, y)
- != crate::display::side_panel::PanelHit::None
- {
+ let sftp_hit = self.ctx.display().sftp_hit(x, y);
+ let inside = if self.ctx.display().nebula_sftp_panel.is_some() {
+ sftp_hit != crate::display::sftp_panel::SftpHit::None
+ } else {
+ crate::display::side_panel::panel_hit(&layout, x, y)
+ != crate::display::side_panel::PanelHit::None
+ };
+ if inside {
let rows = match delta {
MouseScrollDelta::LineDelta(_, lines) => -lines as i32 * 3,
MouseScrollDelta::PixelDelta(pos) => {
@@ -1978,7 +2045,12 @@ impl> Processor {
},
};
if rows != 0 {
- self.ctx.display().nebula_side_panel.scroll_by(rows, layout.max_rows);
+ let sftp_max_rows = self.ctx.display().sftp_layout().max_rows;
+ if let Some(panel) = self.ctx.display().nebula_sftp_panel.as_mut() {
+ panel.scroll_by(rows, sftp_max_rows);
+ } else {
+ self.ctx.display().nebula_side_panel.scroll_by(rows, layout.max_rows);
+ }
self.ctx.mark_dirty();
}
return;
diff --git a/nebula_app/src/main.rs b/nebula_app/src/main.rs
index ef95cb4..66376c1 100644
--- a/nebula_app/src/main.rs
+++ b/nebula_app/src/main.rs
@@ -43,6 +43,8 @@ mod macos;
mod markdown;
mod message_bar;
mod migrate;
+#[cfg(windows)]
+mod mux;
mod nebula_history;
mod notify;
#[cfg(windows)]
@@ -55,13 +57,15 @@ mod scheduler;
mod session;
mod shell_detect;
#[cfg(windows)]
-mod ssh_credentials;
-#[cfg(windows)]
-mod mux;
-#[cfg(windows)]
mod ssh;
#[cfg(windows)]
+mod ssh_credentials;
+#[cfg(windows)]
+mod ssh_profiles;
+#[cfg(windows)]
mod ssh_session;
+#[cfg(windows)]
+mod ssh_sftp;
mod string;
mod window_context;
diff --git a/nebula_app/src/ssh_credentials.rs b/nebula_app/src/ssh_credentials.rs
index 49a9b5f..7f6a5ef 100644
--- a/nebula_app/src/ssh_credentials.rs
+++ b/nebula_app/src/ssh_credentials.rs
@@ -34,6 +34,18 @@ pub fn credential_target(destination: &str) -> String {
format!("Nebula/SSH/{destination}")
}
+pub fn private_key_credential_target(private_key: &[u8]) -> String {
+ use sha2::{Digest, Sha512};
+ use std::fmt::Write as _;
+
+ let digest = Sha512::digest(private_key);
+ let mut fingerprint = String::with_capacity(digest.len() * 2);
+ for byte in digest {
+ let _ = write!(fingerprint, "{byte:02x}");
+ }
+ format!("Nebula/SSH/KeyPassphrase/{fingerprint}")
+}
+
pub fn is_askpass_env(mut get: impl FnMut(&str) -> Option) -> bool {
get(ASKPASS_FLAG).as_deref() == Some("1")
&& get(DESTINATION_ENV).is_some_and(|destination| !destination.trim().is_empty())
@@ -66,8 +78,8 @@ mod windows_store {
value.encode_utf16().chain(std::iter::once(0)).collect()
}
- pub fn load_password(destination: &str) -> io::Result