diff --git a/Cargo.lock b/Cargo.lock index 739fa5a..223b2f4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/docs/gpui-dev.md b/docs/gpui-dev.md index 4716227..6215b7a 100644 --- a/docs/gpui-dev.md +++ b/docs/gpui-dev.md @@ -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` 而非 Result**,闭包返回 `V` 本身而不是 `Entity`。 + - `App::subscribe` 的监听器是 **3 个参数** `(Entity, &Event, &mut App)`;`Context::subscribe` 是 4 个参数。 + - 测试模块**不要 `use gpui_kit::*`**:glob 会把 gpui 的 `test` 宏带进来遮蔽 `#[test]`,宏展开递归直到超限。全部显式导入。 - 尺寸:`px(..)` 只用于 API 要求 `Pixels` 的地方;其余用 `cx.theme()` token 与 rem 助手。 ### 导入与 API 位置(2026-10-17 首次编译踩过的坑) diff --git a/gpui-client/Cargo.toml b/gpui-client/Cargo.toml index cba7892..d7fe86a 100644 --- a/gpui-client/Cargo.toml +++ b/gpui-client/Cargo.toml @@ -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"] } diff --git a/gpui-client/src/hosts.rs b/gpui-client/src/hosts.rs index ced5b60..53dd77e 100644 --- a/gpui-client/src/hosts.rs +++ b/gpui-client/src/hosts.rs @@ -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, /// 正在显示的错误(例如守护进程没起来);None 表示还没加载过。 error: Option, loaded: bool, + selected: Option, } 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, + 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 for HostList {} +impl EventEmitter 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) -> 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() diff --git a/gpui-client/src/lib.rs b/gpui-client/src/lib.rs new file mode 100644 index 0000000..7b27b46 --- /dev/null +++ b/gpui-client/src/lib.rs @@ -0,0 +1,8 @@ +//! vps-switch 的 GPUI 桌面客户端库。 +//! +//! 二进制入口在 `main.rs`;模块在这里公开,让 `tests/` 能驱动真实面板。 + +pub mod api; +pub mod hosts; +pub mod terminal_panel; +pub mod workspace; diff --git a/gpui-client/src/main.rs b/gpui-client/src/main.rs index 233794e..99058f3 100644 --- a/gpui-client/src/main.rs +++ b/gpui-client/src/main.rs @@ -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| { // 必须先于任何组件调用。 diff --git a/gpui-client/src/terminal_panel.rs b/gpui-client/src/terminal_panel.rs new file mode 100644 index 0000000..57a27b3 --- /dev/null +++ b/gpui-client/src/terminal_panel.rs @@ -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 { + 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 for TerminalPanel {} + +impl BasePanel for TerminalPanel { + fn panel_name(&self) -> &'static str { + "terminal" + } +} + +impl Panel for TerminalPanel { + fn tab_name(&self, _: &App) -> Option { + Some(SharedString::from(self.host.name.clone())) + } +} + +impl Render for TerminalPanel { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> 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 接入"), + ), + ) + } +} diff --git a/gpui-client/src/workspace.rs b/gpui-client/src/workspace.rs index e5dc9a7..e823e42 100644 --- a/gpui-client/src/workspace.rs +++ b/gpui-client/src/workspace.rs @@ -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, host_list: Entity, + terminals: Vec>, 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) { + 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) { + 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) { 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() { // 窗口已关,轮询随之结束。 diff --git a/gpui-client/tests/ui.rs b/gpui-client/tests/ui.rs new file mode 100644 index 0000000..c0c83d6 --- /dev/null +++ b/gpui-client/tests/ui.rs @@ -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 { + 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, + Entity, + Rc>>, +) { + cx.update(|cx| gpui_kit::init(cx)); + let host_list: Rc>>> = Rc::new(RefCell::new(None)); + let seen = Rc::new(RefCell::new(Vec::::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()]); +}