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:
weidu12123andClaude Fable 5 committed 2026-10-17 17:29:31 +08:00
1 parent 73d1735f87
commit 5299ff513f
9 files changed
+661 -27

No files matched your search

Generated
+289 -6
View File
@@ -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"
+6
View File
@@ -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 首次编译踩过的坑)
+8
View File
@@ -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"] }
+47 -7
View File
@@ -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()
+8
View File
@@ -0,0 +1,8 @@
//! vps-switch 的 GPUI 桌面客户端库。
//!
//! 二进制入口在 `main.rs`;模块在这里公开,让 `tests/` 能驱动真实面板。
pub mod api;
pub mod hosts;
pub mod terminal_panel;
pub mod workspace;
+2 -4
View File
@@ -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| {
// 必须先于任何组件调用。
+93
View File
@@ -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 接入"),
),
)
}
}
+44 -10
View File
@@ -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() {
// 窗口已关,轮询随之结束。
+164
View File
@@ -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()]);
}