feat(gpui): open terminal tabs from the host list, with headless UI tests
Double-clicking a host (or clicking an already selected one) opens a terminal tab in the center dock. Three headless tests drive the real panel with real pointer events. They use gpui-base's test_support because gpui-kit's test-support feature drags in the gpui-fast dependency tree, which conflicts with gpui-pre 0.3.9 on Windows. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
73d1735f87
commit
5299ff513f
9 files changed
+661
-27
No files matched your search
Generated
+289
-6
@@ -547,6 +547,12 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atomic"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c59bdb34bc650a32731b31bd8f0829cc15d24a708ee31559e0bb34f2bc320cba"
|
||||
|
||||
[[package]]
|
||||
name = "atomic-waker"
|
||||
version = "1.1.2"
|
||||
@@ -783,6 +789,15 @@ dependencies = [
|
||||
"bit-vec 0.9.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "56d87354e4229f54a44f7bf2435906a4656dba36026ab6eaca629a2c436a691c"
|
||||
dependencies = [
|
||||
"bit-vec 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit-vec"
|
||||
version = "0.8.0"
|
||||
@@ -795,6 +810,16 @@ version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51"
|
||||
|
||||
[[package]]
|
||||
name = "bit-vec"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5727b15fa97d4f4fee0a3b7c3d550ed0269f54329207b86388de918604e31269"
|
||||
dependencies = [
|
||||
"borsh",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit_field"
|
||||
version = "0.10.3"
|
||||
@@ -918,6 +943,30 @@ dependencies = [
|
||||
"cipher 0.5.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "borsh"
|
||||
version = "1.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a"
|
||||
dependencies = [
|
||||
"borsh-derive",
|
||||
"bytes",
|
||||
"cfg_aliases 0.2.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "borsh-derive"
|
||||
version = "1.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"proc-macro-crate 3.5.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bstr"
|
||||
version = "1.13.1"
|
||||
@@ -1306,6 +1355,15 @@ dependencies = [
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49"
|
||||
dependencies = [
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cookie"
|
||||
version = "0.18.2"
|
||||
@@ -1528,6 +1586,15 @@ dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-queue"
|
||||
version = "0.3.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-utils"
|
||||
version = "0.8.23"
|
||||
@@ -1764,7 +1831,7 @@ version = "2.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
|
||||
dependencies = [
|
||||
"convert_case",
|
||||
"convert_case 0.10.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustc_version",
|
||||
@@ -2334,6 +2401,12 @@ version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
|
||||
|
||||
[[package]]
|
||||
name = "fixedbitset"
|
||||
version = "0.5.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
version = "1.1.10"
|
||||
@@ -2533,6 +2606,19 @@ dependencies = [
|
||||
"futures-sink",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-concurrency"
|
||||
version = "7.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "175cd8cca9e1d45b87f18ffa75088f2099e3c4fe5e2f83e42de112560bea8ea6"
|
||||
dependencies = [
|
||||
"fixedbitset",
|
||||
"futures-core",
|
||||
"futures-lite",
|
||||
"pin-project",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.34"
|
||||
@@ -3037,6 +3123,7 @@ dependencies = [
|
||||
"chrono",
|
||||
"data-url",
|
||||
"futures",
|
||||
"gpui-fast",
|
||||
"gpui-pre",
|
||||
"gpui-pre-macros",
|
||||
"gpui-pre-sum-tree",
|
||||
@@ -3077,6 +3164,7 @@ dependencies = [
|
||||
"enum-iterator",
|
||||
"gpui-base",
|
||||
"gpui-component-macros",
|
||||
"gpui-fast",
|
||||
"gpui-kit-assets",
|
||||
"gpui-pre",
|
||||
"gpui-pre-macros",
|
||||
@@ -3124,6 +3212,87 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gpui-fast"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06f57a8a598a3fc1b8594ff5a72c530241906806a3322d8be1bb9059c3fd3a11"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
"async-task",
|
||||
"backtrace",
|
||||
"bitflags 2.13.2",
|
||||
"chrono",
|
||||
"ctor",
|
||||
"derive_more",
|
||||
"embed-resource",
|
||||
"etagere",
|
||||
"futures",
|
||||
"futures-concurrency",
|
||||
"getrandom 0.3.4",
|
||||
"gpui-fast-macros",
|
||||
"gpui-pre-collections",
|
||||
"gpui-pre-http-client",
|
||||
"gpui-pre-refineable",
|
||||
"gpui-pre-scheduler",
|
||||
"gpui-pre-shared-string",
|
||||
"gpui-pre-sum-tree",
|
||||
"gpui-pre-util",
|
||||
"heapless",
|
||||
"image",
|
||||
"inventory",
|
||||
"itertools 0.14.0",
|
||||
"libc",
|
||||
"log",
|
||||
"lyon",
|
||||
"num_cpus",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-video",
|
||||
"parking",
|
||||
"parking_lot",
|
||||
"pin-project",
|
||||
"pollster 0.4.0",
|
||||
"postage",
|
||||
"profiling",
|
||||
"proptest",
|
||||
"rand 0.9.5",
|
||||
"raw-window-handle",
|
||||
"regex",
|
||||
"resvg 0.46.0",
|
||||
"schemars",
|
||||
"seahash",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"slotmap",
|
||||
"smallvec",
|
||||
"spin 0.10.1",
|
||||
"strum",
|
||||
"taffy 0.14.0",
|
||||
"thiserror 2.0.21",
|
||||
"ttf-parser",
|
||||
"url",
|
||||
"usvg 0.46.0",
|
||||
"uuid",
|
||||
"waker-fn",
|
||||
"web-time",
|
||||
"windows 0.62.2",
|
||||
"zed-scap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gpui-fast-macros"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fb603746606a49ea4562b309ddcd3f299e2e151f93356d7f1b3d867d1b9cd68e"
|
||||
dependencies = [
|
||||
"heck 0.5.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gpui-kit"
|
||||
version = "0.7.3"
|
||||
@@ -3172,6 +3341,7 @@ dependencies = [
|
||||
"embed-resource",
|
||||
"etagere",
|
||||
"futures",
|
||||
"futures-concurrency",
|
||||
"getrandom 0.3.4",
|
||||
"gpui-pre-collections",
|
||||
"gpui-pre-http-client",
|
||||
@@ -3194,8 +3364,10 @@ dependencies = [
|
||||
"parking",
|
||||
"parking_lot",
|
||||
"pin-project",
|
||||
"pollster",
|
||||
"pollster 0.4.0",
|
||||
"postage",
|
||||
"profiling",
|
||||
"proptest",
|
||||
"rand 0.9.5",
|
||||
"raw-window-handle",
|
||||
"regex",
|
||||
@@ -3208,13 +3380,14 @@ dependencies = [
|
||||
"smallvec",
|
||||
"spin 0.10.1",
|
||||
"strum",
|
||||
"taffy",
|
||||
"taffy 0.13.0",
|
||||
"thiserror 2.0.21",
|
||||
"ttf-parser",
|
||||
"url",
|
||||
"usvg 0.46.0",
|
||||
"uuid",
|
||||
"waker-fn",
|
||||
"web-time",
|
||||
"windows 0.62.2",
|
||||
"zed-scap",
|
||||
]
|
||||
@@ -4215,7 +4388,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
|
||||
dependencies = [
|
||||
"byteorder-lite",
|
||||
"quick-error",
|
||||
"quick-error 2.0.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6315,6 +6488,12 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pollster"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5da3b0203fd7ee5720aa0b5e790b591aa5d3f41c3ed2c34a3a393382198af2f7"
|
||||
|
||||
[[package]]
|
||||
name = "pollster"
|
||||
version = "0.4.0"
|
||||
@@ -6401,6 +6580,21 @@ dependencies = [
|
||||
"winreg 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "postage"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af3fb618632874fb76937c2361a7f22afd393c982a2165595407edc75b06d3c1"
|
||||
dependencies = [
|
||||
"atomic",
|
||||
"crossbeam-queue",
|
||||
"parking_lot",
|
||||
"pin-project",
|
||||
"pollster 0.2.5",
|
||||
"static_assertions",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.6"
|
||||
@@ -6544,6 +6738,39 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proptest"
|
||||
version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8530004ccb15eae51c7e40009fbe317f341f804db54dc033eec1c50be28cfa0"
|
||||
dependencies = [
|
||||
"bit-set 0.11.1",
|
||||
"bit-vec 0.10.1",
|
||||
"bitflags 2.13.2",
|
||||
"chacha20",
|
||||
"core_detect",
|
||||
"num-traits",
|
||||
"proptest-macro",
|
||||
"rand 0.10.3",
|
||||
"rand_xorshift",
|
||||
"regex-syntax",
|
||||
"rusty-fork",
|
||||
"tempfile",
|
||||
"unarray",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proptest-macro"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e99782ea31698c2206c6887c1220d2f594b291c26cb9ec5b6d7cf175320bc212"
|
||||
dependencies = [
|
||||
"convert_case 0.11.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pulp"
|
||||
version = "0.22.3"
|
||||
@@ -6582,6 +6809,12 @@ dependencies = [
|
||||
"bytemuck",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-error"
|
||||
version = "1.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
|
||||
|
||||
[[package]]
|
||||
name = "quick-error"
|
||||
version = "2.0.1"
|
||||
@@ -6759,6 +6992,15 @@ dependencies = [
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_xorshift"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "60aa6af80be32871323012e02e6e65f8a7cc7890931ae421d217ad8fe0df2ccf"
|
||||
dependencies = [
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "range-alloc"
|
||||
version = "0.1.5"
|
||||
@@ -6815,7 +7057,7 @@ dependencies = [
|
||||
"avif-serialize",
|
||||
"imgref",
|
||||
"loop9",
|
||||
"quick-error",
|
||||
"quick-error 2.0.1",
|
||||
"rav1e",
|
||||
"rayon",
|
||||
"rgb",
|
||||
@@ -7458,6 +7700,18 @@ version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||
|
||||
[[package]]
|
||||
name = "rusty-fork"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2"
|
||||
dependencies = [
|
||||
"fnv",
|
||||
"quick-error 1.2.3",
|
||||
"tempfile",
|
||||
"wait-timeout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustybuzz"
|
||||
version = "0.20.1"
|
||||
@@ -8511,6 +8765,18 @@ dependencies = [
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "taffy"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "639627c87f43b9181c811f40a6296409e093a17bc761214cba3c15df74f86b99"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"serde",
|
||||
"slotmap",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tao"
|
||||
version = "0.37.1"
|
||||
@@ -8691,7 +8957,7 @@ dependencies = [
|
||||
"fax",
|
||||
"flate2",
|
||||
"half",
|
||||
"quick-error",
|
||||
"quick-error 2.0.1",
|
||||
"weezl",
|
||||
"zune-jpeg 0.5.15",
|
||||
]
|
||||
@@ -9176,6 +9442,12 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unarray"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
|
||||
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
version = "2.10.0"
|
||||
@@ -9445,6 +9717,8 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
"gpui-base",
|
||||
"gpui-component",
|
||||
"gpui-kit",
|
||||
"reqwest",
|
||||
"serde",
|
||||
@@ -9486,6 +9760,15 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wait-timeout"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "waker-fn"
|
||||
version = "1.2.0"
|
||||
|
||||
@@ -78,6 +78,12 @@ fn main() {
|
||||
- **彩色 SVG 不能用 `Icon`**:`svg()` 走 `paint_svg`,整图当遮罩单色上色。彩色要 `img()`(光栅化成位图)。
|
||||
- **gpui-kit 的 Scrollable 不是就地在原元素加滚动条**:它重建外层 div。滚动区内先放一层 `v_flex().w_full()` 再写内容(否则子块按内容宽度收缩,宽度参差)。
|
||||
- **无头 UI 测试**:`gpui-kit` 的 `test-support` feature + `gpui_kit::test::TestWindowExt`(真实 Workspace 在无头窗口里渲染,`click`/`input`/`find` 驱动)。每个里程碑的验收都要有这类测试,别只靠肉眼看窗口。
|
||||
- ⚠️ **但 gpui-kit 0.7.3 的 `test-support` feature 在 Windows 上解析失败**:它会拉入 `gpui-fast` 系列(0.1.7),与 `gpui-pre 0.3.9` 的依赖树冲突(`cocoa` 要 `=0.26.0` vs `0.26.1`,随后 `unicode-properties` 也冲突)。
|
||||
- ✅ **替代方案(已在本仓库验证)**:dev-dependencies 用 `gpui-base = { version = "=0.7.3", features = ["test-support"] }` 与 `gpui-component = { version = "=0.7.3", features = ["test-support"] }`,不用 gpui-kit 的 test-support。TDD 辅助函数在 `gpui-client/tests/ui.rs` 里自实现(`render_frame` = `window.refresh(); window.draw(cx).clear(cx)`;`find` = `gpui_base::test_support::find(window, &[], &ElementId)`;点击 = `window.dispatch_event(MouseDownEvent{..}.to_platform_input(), cx)`)。
|
||||
- 测试元素必须调 `.test_support()`(`gpui_kit::TestSupportExt`)才会出现在查询里。
|
||||
- `TestAppContext::open_window(size, |window, cx| Root::new(view, window, cx))` **返回 `WindowHandle<V>` 而非 Result**,闭包返回 `V` 本身而不是 `Entity<V>`。
|
||||
- `App::subscribe` 的监听器是 **3 个参数** `(Entity<T>, &Event, &mut App)`;`Context::subscribe` 是 4 个参数。
|
||||
- 测试模块**不要 `use gpui_kit::*`**:glob 会把 gpui 的 `test` 宏带进来遮蔽 `#[test]`,宏展开递归直到超限。全部显式导入。
|
||||
- 尺寸:`px(..)` 只用于 API 要求 `Pixels` 的地方;其余用 `cx.theme()` token 与 rem 助手。
|
||||
|
||||
### 导入与 API 位置(2026-10-17 首次编译踩过的坑)
|
||||
|
||||
@@ -20,3 +20,11 @@ async-channel = "2"
|
||||
# 不碰 GPUI 自己的 executor。
|
||||
tokio = { version = "1", features = ["rt", "macros", "time"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "system-proxy"] }
|
||||
|
||||
[dev-dependencies]
|
||||
# 无头 UI 测试。用 gpui-base / gpui-component 的 test_support(只用公共
|
||||
# GPUI API)而不是 gpui-kit 的 test-support:后者的 feature 会拉入
|
||||
# gpui-fast 系列,与 gpui-pre 0.3.9 的依赖树版本冲突(cocoa /
|
||||
# unicode-properties),解析直接失败。
|
||||
gpui-base = { version = "=0.7.3", features = ["test-support"] }
|
||||
gpui-component = { version = "=0.7.3", features = ["test-support"] }
|
||||
@@ -1,18 +1,28 @@
|
||||
//! 左侧主机面板:Dock 里的一个面板,列出守护进程返回的主机。
|
||||
//! 单击选中;双击(或单击已选中项)让工作区在中央区打开终端标签。
|
||||
|
||||
use gpui_kit::StatefulInteractiveElement as _;
|
||||
use gpui_kit::TestSupportExt as _;
|
||||
use gpui_kit::component::*;
|
||||
use gpui_kit::component::dock::{BasePanel, Panel, PanelEvent};
|
||||
use gpui_kit::*;
|
||||
|
||||
use crate::api::HostSummary;
|
||||
|
||||
/// 面板向工作区发出的事件。
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum HostListEvent {
|
||||
/// 用户要在这台主机上打开终端。
|
||||
Open(HostSummary),
|
||||
}
|
||||
|
||||
pub struct HostList {
|
||||
focus_handle: FocusHandle,
|
||||
hosts: Vec<HostSummary>,
|
||||
/// 正在显示的错误(例如守护进程没起来);None 表示还没加载过。
|
||||
error: Option<String>,
|
||||
loaded: bool,
|
||||
selected: Option<String>,
|
||||
}
|
||||
|
||||
impl HostList {
|
||||
@@ -22,6 +32,7 @@ impl HostList {
|
||||
hosts: Vec::new(),
|
||||
error: None,
|
||||
loaded: false,
|
||||
selected: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +48,12 @@ impl HostList {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn render_host(&self, host: &HostSummary, cx: &App) -> impl IntoElement {
|
||||
fn render_host(
|
||||
&self,
|
||||
host: &HostSummary,
|
||||
this: &Entity<Self>,
|
||||
cx: &App,
|
||||
) -> impl IntoElement {
|
||||
let online = host.last_error.is_none() && host.latency_ms.is_some();
|
||||
let dot_color = if online {
|
||||
cx.theme().success
|
||||
@@ -51,7 +67,10 @@ impl HostList {
|
||||
} else {
|
||||
host.addr.clone()
|
||||
};
|
||||
let row = h_flex()
|
||||
let selected = self.selected.as_deref() == Some(host.id.as_str());
|
||||
let host_click = host.clone();
|
||||
let this_click = this.clone();
|
||||
let mut row = h_flex()
|
||||
.id(SharedString::from(format!("host-{}", host.id)))
|
||||
.px_3()
|
||||
.py_2()
|
||||
@@ -59,6 +78,18 @@ impl HostList {
|
||||
.w_full()
|
||||
.rounded_md()
|
||||
.hover(|style| style.bg(cx.theme().accent))
|
||||
.on_click(move |event, _window, cx| {
|
||||
let host = host_click.clone();
|
||||
let open = event.click_count() >= 2;
|
||||
let _ = this_click.update(cx, |this, cx| {
|
||||
let already = this.selected.as_deref() == Some(host.id.as_str());
|
||||
this.selected = Some(host.id.clone());
|
||||
cx.notify();
|
||||
if open || already {
|
||||
cx.emit(HostListEvent::Open(host.clone()));
|
||||
}
|
||||
});
|
||||
})
|
||||
.child(div().size(px(8.)).rounded_full().bg(dot_color).flex_shrink_0())
|
||||
.child(
|
||||
v_flex()
|
||||
@@ -74,16 +105,19 @@ impl HostList {
|
||||
.child(detail),
|
||||
),
|
||||
);
|
||||
if selected {
|
||||
row = row.bg(cx.theme().accent);
|
||||
}
|
||||
if host.pinned {
|
||||
row.child(
|
||||
row = row.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child("置顶"),
|
||||
)
|
||||
} else {
|
||||
row
|
||||
);
|
||||
}
|
||||
// 注册给无头测试的元素查询(生产构建里是恒等)。
|
||||
row.test_support()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +128,7 @@ impl Focusable for HostList {
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for HostList {}
|
||||
impl EventEmitter<HostListEvent> for HostList {}
|
||||
|
||||
impl BasePanel for HostList {
|
||||
fn panel_name(&self) -> &'static str {
|
||||
@@ -109,6 +144,7 @@ impl Panel for HostList {
|
||||
|
||||
impl Render for HostList {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let this = cx.entity();
|
||||
let body = if let Some(error) = &self.error {
|
||||
v_flex()
|
||||
.p_3()
|
||||
@@ -134,7 +170,11 @@ impl Render for HostList {
|
||||
v_flex()
|
||||
.p_2()
|
||||
.gap_0p5()
|
||||
.children(self.hosts.iter().map(|host| self.render_host(host, cx)))
|
||||
.children(
|
||||
self.hosts
|
||||
.iter()
|
||||
.map(|host| self.render_host(host, &this, cx)),
|
||||
)
|
||||
.into_any_element()
|
||||
};
|
||||
v_flex()
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
//! vps-switch 的 GPUI 桌面客户端库。
|
||||
//!
|
||||
//! 二进制入口在 `main.rs`;模块在这里公开,让 `tests/` 能驱动真实面板。
|
||||
|
||||
pub mod api;
|
||||
pub mod hosts;
|
||||
pub mod terminal_panel;
|
||||
pub mod workspace;
|
||||
@@ -5,13 +5,11 @@
|
||||
//! 只做一件事:把守护进程(`vps-switch.exe`,axum + SQLite + SSH + MCP)的
|
||||
//! HTTP/WS API 渲染成原生界面。所有业务在守护进程里,这里没有第二份实现。
|
||||
|
||||
mod api;
|
||||
mod hosts;
|
||||
mod workspace;
|
||||
|
||||
use gpui_kit::component::Root;
|
||||
use gpui_kit::*;
|
||||
|
||||
use vps_switch_gpui::{api, workspace};
|
||||
|
||||
fn main() {
|
||||
gpui_kit::application().run(|cx| {
|
||||
// 必须先于任何组件调用。
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
//! 中央区的终端标签页。M1 是占位:显示主机信息;M2 接入 alacritty_terminal
|
||||
//! 渲染与字节 WS。
|
||||
|
||||
use gpui_kit::component::*;
|
||||
use gpui_kit::component::dock::{BasePanel, Panel, PanelEvent};
|
||||
use gpui_kit::*;
|
||||
|
||||
use crate::api::HostSummary;
|
||||
|
||||
pub struct TerminalPanel {
|
||||
focus_handle: FocusHandle,
|
||||
host: HostSummary,
|
||||
}
|
||||
|
||||
impl TerminalPanel {
|
||||
pub fn new(host: HostSummary, cx: &mut Context<Self>) -> Self {
|
||||
Self {
|
||||
focus_handle: cx.focus_handle(),
|
||||
host,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn host_id(&self) -> &str {
|
||||
&self.host.id
|
||||
}
|
||||
}
|
||||
|
||||
impl Focusable for TerminalPanel {
|
||||
fn focus_handle(&self, _: &App) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for TerminalPanel {}
|
||||
|
||||
impl BasePanel for TerminalPanel {
|
||||
fn panel_name(&self) -> &'static str {
|
||||
"terminal"
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for TerminalPanel {
|
||||
fn tab_name(&self, _: &App) -> Option<SharedString> {
|
||||
Some(SharedString::from(self.host.name.clone()))
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for TerminalPanel {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let os = self
|
||||
.host
|
||||
.pretty_name
|
||||
.clone()
|
||||
.or_else(|| self.host.os.clone())
|
||||
.unwrap_or_else(|| "未知系统".into());
|
||||
v_flex()
|
||||
.size_full()
|
||||
.bg(cx.theme().background)
|
||||
.child(
|
||||
h_flex()
|
||||
.px_3()
|
||||
.py_2()
|
||||
.gap_2()
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().border)
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.font_weight(gpui_kit::FontWeight::SEMIBOLD)
|
||||
.child(self.host.name.clone()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(format!("{} · {}", self.host.addr, os)),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child("终端将在 M2 接入"),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,18 @@
|
||||
//! 工作区:Dock 布局的宿主,持有左侧主机面板与 API 事件轮询。
|
||||
//! 工作区:Dock 布局的宿主,持有左侧主机面板、中央终端标签与 API 事件轮询。
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use gpui_kit::component::dock::{
|
||||
DockArea, DockLayout, DockPlacement, DockSkin, panel_handle,
|
||||
};
|
||||
use gpui_kit::component::dock::{DockArea, DockLayout, DockPlacement, DockSkin, panel_handle};
|
||||
use gpui_kit::*;
|
||||
|
||||
use crate::api::{Api, ApiEvent};
|
||||
use crate::hosts::HostList;
|
||||
use crate::api::{Api, ApiEvent, HostSummary};
|
||||
use crate::hosts::{HostList, HostListEvent};
|
||||
use crate::terminal_panel::TerminalPanel;
|
||||
|
||||
pub struct Workspace {
|
||||
dock_area: Entity<DockArea>,
|
||||
host_list: Entity<HostList>,
|
||||
terminals: Vec<Entity<TerminalPanel>>,
|
||||
api: Api,
|
||||
}
|
||||
|
||||
@@ -39,13 +39,50 @@ impl Workspace {
|
||||
api.load_hosts();
|
||||
let this = Self {
|
||||
dock_area,
|
||||
host_list,
|
||||
host_list: host_list.clone(),
|
||||
terminals: Vec::new(),
|
||||
api,
|
||||
};
|
||||
cx.subscribe_in(
|
||||
&host_list,
|
||||
window,
|
||||
|this, _, event: &HostListEvent, window, cx| match event {
|
||||
HostListEvent::Open(host) => this.open_terminal(host.clone(), window, cx),
|
||||
},
|
||||
)
|
||||
.detach();
|
||||
Self::poll_events(cx);
|
||||
this
|
||||
}
|
||||
|
||||
/// 打开(或聚焦)一台主机的终端标签。
|
||||
fn open_terminal(&mut self, host: HostSummary, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self
|
||||
.terminals
|
||||
.iter()
|
||||
.any(|panel| panel.read(cx).host_id() == host.id)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let panel = cx.new(|cx| TerminalPanel::new(host, cx));
|
||||
let focus = panel.read(cx).focus_handle(cx);
|
||||
self.terminals.push(panel);
|
||||
self.rebuild_center(window, cx);
|
||||
window.focus(&focus, cx);
|
||||
}
|
||||
|
||||
/// 中央区按当前标签集合重建(与 shell-rs 同样的做法:Dock 的布局树由
|
||||
/// 我们自己的 Vec 派生,关闭标签时同样走这里)。
|
||||
fn rebuild_center(&self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let mut center = DockLayout::tabs();
|
||||
for panel in &self.terminals {
|
||||
center = center.panel_view(panel_handle(panel.clone()), cx);
|
||||
}
|
||||
self.dock_area.update(cx, |area, cx| {
|
||||
area.set_center(center, window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
/// 每 100ms drain 一次网络事件。后台线程不直接碰 GPUI executor。
|
||||
fn poll_events(cx: &mut Context<Self>) {
|
||||
cx.spawn(async move |this, cx| {
|
||||
@@ -54,9 +91,7 @@ impl Workspace {
|
||||
.timer(Duration::from_millis(100))
|
||||
.await;
|
||||
let drained = this.update(cx, |this, cx| {
|
||||
let mut changed = false;
|
||||
while let Some(event) = this.api.try_recv() {
|
||||
changed = true;
|
||||
match event {
|
||||
ApiEvent::Hosts(hosts) => {
|
||||
this.host_list.update(cx, |list, cx| list.apply_hosts(hosts, cx));
|
||||
@@ -66,7 +101,6 @@ impl Workspace {
|
||||
}
|
||||
}
|
||||
}
|
||||
changed
|
||||
});
|
||||
if drained.is_err() {
|
||||
// 窗口已关,轮询随之结束。
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
//! 无头 UI 测试:真实面板在测试窗口里渲染,用真实指针事件驱动。
|
||||
//!
|
||||
//! 注意:不要 `use gpui_kit::*`——glob 会把 gpui 的 `test` 宏带进作用域,
|
||||
//! 遮蔽 Rust 内置的 `#[test]` 属性(gpui-kit 文档明确提醒过)。这里全部
|
||||
//! 显式导入。
|
||||
//!
|
||||
//! 为什么不用 `gpui_kit::test::TestWindowExt`:gpui-kit 的 test-support
|
||||
//! feature 会拉入 gpui-fast 系列,与 gpui-pre 0.3.9 的依赖树版本冲突
|
||||
//! (cocoa / unicode-properties),解析直接失败。下面的 helper 是
|
||||
//! TestWindowExt 同一套底层调用的精简版(公共 GPUI API)。
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui_kit::component::Root;
|
||||
use gpui_kit::{
|
||||
App, AppContext as _, ElementId, Entity, InputEvent as _, MouseButton, MouseDownEvent,
|
||||
MouseMoveEvent, MouseUpEvent, Pixels, Point, SharedString, TestAppContext, Window,
|
||||
WindowHandle, px, size,
|
||||
};
|
||||
|
||||
use vps_switch_gpui::api::HostSummary;
|
||||
use vps_switch_gpui::hosts::{HostList, HostListEvent};
|
||||
|
||||
fn host(id: &str, name: &str, online: bool) -> HostSummary {
|
||||
HostSummary {
|
||||
id: id.into(),
|
||||
name: name.into(),
|
||||
addr: "10.0.0.1".into(),
|
||||
kind: "ssh".into(),
|
||||
os: Some("linux".into()),
|
||||
pretty_name: Some("Ubuntu 24.04".into()),
|
||||
pinned: false,
|
||||
favorited: false,
|
||||
latency_ms: online.then_some(42),
|
||||
last_error: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn render_frame(window: &mut Window, cx: &mut App) {
|
||||
window.refresh();
|
||||
window.draw(cx).clear(cx);
|
||||
}
|
||||
|
||||
fn element_center(window: &Window, id: &str) -> Point<Pixels> {
|
||||
let element_id = ElementId::Name(SharedString::from(id));
|
||||
gpui_base::test_support::find(window, &[], &element_id)
|
||||
.unwrap_or_else(|| panic!("element {id} not found in the last frame"))
|
||||
.bounds()
|
||||
.center()
|
||||
}
|
||||
|
||||
/// 在元素中心按下-抬起 `count` 次(2 次即双击)。
|
||||
fn click_element(window: &mut Window, id: &str, count: usize, cx: &mut App) {
|
||||
render_frame(window, cx);
|
||||
let position = element_center(window, id);
|
||||
window.dispatch_event(
|
||||
MouseMoveEvent {
|
||||
position,
|
||||
pressed_button: None,
|
||||
modifiers: Default::default(),
|
||||
}
|
||||
.to_platform_input(),
|
||||
cx,
|
||||
);
|
||||
for click_count in 1..=count {
|
||||
window.dispatch_event(
|
||||
MouseDownEvent {
|
||||
button: MouseButton::Left,
|
||||
position,
|
||||
modifiers: Default::default(),
|
||||
click_count,
|
||||
first_mouse: false,
|
||||
}
|
||||
.to_platform_input(),
|
||||
cx,
|
||||
);
|
||||
window.dispatch_event(
|
||||
MouseUpEvent {
|
||||
button: MouseButton::Left,
|
||||
position,
|
||||
modifiers: Default::default(),
|
||||
click_count,
|
||||
}
|
||||
.to_platform_input(),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
render_frame(window, cx);
|
||||
}
|
||||
|
||||
fn has_element(window: &Window, id: &str) -> bool {
|
||||
let element_id = ElementId::Name(SharedString::from(id));
|
||||
gpui_base::test_support::find(window, &[], &element_id).is_some()
|
||||
}
|
||||
|
||||
fn open_host_list(
|
||||
cx: &mut TestAppContext,
|
||||
) -> (
|
||||
WindowHandle<Root>,
|
||||
Entity<HostList>,
|
||||
Rc<RefCell<Vec<String>>>,
|
||||
) {
|
||||
cx.update(|cx| gpui_kit::init(cx));
|
||||
let host_list: Rc<RefCell<Option<Entity<HostList>>>> = Rc::new(RefCell::new(None));
|
||||
let seen = Rc::new(RefCell::new(Vec::<String>::new()));
|
||||
let handle = cx.open_window(size(px(800.), px(600.)), |window, cx| {
|
||||
let list = cx.new(HostList::new);
|
||||
list.update(cx, |list, cx| {
|
||||
list.apply_hosts(
|
||||
vec![host("local", "本机", true), host("vps1", "vps-1", false)],
|
||||
cx,
|
||||
);
|
||||
});
|
||||
*host_list.borrow_mut() = Some(list.clone());
|
||||
Root::new(list, window, cx)
|
||||
});
|
||||
let list = host_list.borrow().clone().unwrap();
|
||||
let sink = seen.clone();
|
||||
cx.update(|cx| {
|
||||
cx.subscribe(&list, move |_, event: &HostListEvent, _| {
|
||||
if let HostListEvent::Open(host) = event {
|
||||
sink.borrow_mut().push(host.id.clone());
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
});
|
||||
(handle, list, seen)
|
||||
}
|
||||
|
||||
#[gpui_kit::test]
|
||||
fn hosts_render_with_their_rows(cx: &mut TestAppContext) {
|
||||
let (handle, _, _) = open_host_list(cx);
|
||||
cx.update_window(handle.into(), |_, window, cx| {
|
||||
render_frame(window, cx);
|
||||
assert!(has_element(window, "host-local"));
|
||||
assert!(has_element(window, "host-vps1"));
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[gpui_kit::test]
|
||||
fn double_click_opens_the_host(cx: &mut TestAppContext) {
|
||||
let (handle, _, opened) = open_host_list(cx);
|
||||
cx.update_window(handle.into(), |_, window, cx| {
|
||||
click_element(window, "host-vps1", 2, cx);
|
||||
})
|
||||
.unwrap();
|
||||
cx.run_until_parked();
|
||||
assert_eq!(&*opened.borrow(), &["vps1".to_string()]);
|
||||
}
|
||||
|
||||
#[gpui_kit::test]
|
||||
fn clicking_an_already_selected_host_opens_it(cx: &mut TestAppContext) {
|
||||
let (handle, _, opened) = open_host_list(cx);
|
||||
cx.update_window(handle.into(), |_, window, cx| {
|
||||
// 第一次点击只选中;再点一次已选中的行即打开(与旧前端一致)。
|
||||
click_element(window, "host-local", 1, cx);
|
||||
click_element(window, "host-local", 1, cx);
|
||||
})
|
||||
.unwrap();
|
||||
cx.run_until_parked();
|
||||
assert_eq!(&*opened.borrow(), &["local".to_string()]);
|
||||
}
|
||||
Reference in new issue
Block a user