From 8bed005d85aacd4c564fdbfafe7622768efd7995 Mon Sep 17 00:00:00 2001 From: weidu12123 Date: Sat, 17 Oct 2026 17:59:00 +0800 Subject: [PATCH] feat(gpui): rebuild the client against the 1:1 UI spec The workspace now mirrors the old front end: a top bar with the brand, repository button and session tabs, search, filter summary, check-all and theme/settings/add controls; a host card list with OS badges, status dots, role and pin chips plus per-card actions; a terminal stage with split, light/dark terminal, right-wing and popout controls; and the right-side probe drawer with nameplate, CPU, memory, disk, network and role/tag cards, sampled every 2.8s. The old DockArea scaffold is gone; layout and colours follow docs/ui-spec.md. Co-Authored-By: Claude Fable 5 --- gpui-client/assets/theme-light.json | 206 +++++++ gpui-client/src/api.rs | 361 +++++++++-- gpui-client/src/detail_drawer.rs | 910 ++++++++++++++++++++++++++++ gpui-client/src/host_card.rs | 346 +++++++++++ gpui-client/src/hosts.rs | 212 ------- gpui-client/src/lib.rs | 9 +- gpui-client/src/main.rs | 4 +- gpui-client/src/settings_page.rs | 101 +++ gpui-client/src/terminal/view.rs | 69 ++- gpui-client/src/terminal_panel.rs | 84 --- gpui-client/src/terminal_stage.rs | 328 ++++++++++ gpui-client/src/theme.rs | 19 + gpui-client/src/top_bar.rs | 394 ++++++++++++ gpui-client/src/util.rs | 41 ++ gpui-client/src/workspace.rs | 896 ++++++++++++++++++++++++--- 15 files changed, 3520 insertions(+), 460 deletions(-) create mode 100644 gpui-client/assets/theme-light.json create mode 100644 gpui-client/src/detail_drawer.rs create mode 100644 gpui-client/src/host_card.rs delete mode 100644 gpui-client/src/hosts.rs create mode 100644 gpui-client/src/settings_page.rs delete mode 100644 gpui-client/src/terminal_panel.rs create mode 100644 gpui-client/src/terminal_stage.rs create mode 100644 gpui-client/src/theme.rs create mode 100644 gpui-client/src/top_bar.rs create mode 100644 gpui-client/src/util.rs diff --git a/gpui-client/assets/theme-light.json b/gpui-client/assets/theme-light.json new file mode 100644 index 0000000..6fe2c04 --- /dev/null +++ b/gpui-client/assets/theme-light.json @@ -0,0 +1,206 @@ +{ + "is_default": true, + "name": "VPS Switch Light", + "mode": "light", + "colors": { + "accent.background": "#eef0f3", + "accent.foreground": "#1f2328", + "accordion.background": "white", + "background": "#f7f8fa", + "border": "#e5e7eb", + "group_box.background": "#ffffff", + "group_box.foreground": "#1f2328", + "caret": "#1f2328", + "chart.1": "#b7c0cb", + "chart.2": "#6b7280", + "chart.3": "#1f2328", + "chart.4": "#7ea88a", + "chart.5": "#c2a27a", + "chart.bullish": "green-600", + "chart.bearish": "red-600", + "chart.grid": "#e5e7eb99", + "danger.background": "red-500", + "danger.foreground": "neutral-50", + "description_list_label.foreground": "#171717", + "drag_border": "#1f2328", + "drop_target.background": "#1f232640", + "foreground": "#1f2328", + "info.background": "cyan-500", + "info.foreground": "neutral-50", + "input.border": "#e5e7eb", + "link.foreground": "#1f2328", + "link.active.foreground": "#1f2328", + "link.hover.foreground": "#6b7280", + "list.background": "#ffffff", + "list.active.background": "#eef0f3", + "list.active.border": "#d1d5db", + "list.even.background": "#ffffff", + "list.head.background": "#f7f8fa", + "list.hover.background": "#f3f4f6", + "muted.background": "#f1f2f4", + "muted.foreground": "#6b7280", + "popover.background": "#ffffff", + "popover.foreground": "#1f2328", + "primary.background": "neutral-900", + "primary.active.background": "neutral-950", + "primary.foreground": "neutral-50", + "primary.hover.background": "neutral-800", + "progress_bar.background": "#171717", + "ring": "#c9ced6", + "scrollbar.background": "#fafafa00", + "scrollbar.thumb.background": "#c9ced6e6", + "scrollbar.thumb.hover.background": "#b6bdc7", + "secondary.background": "neutral-200", + "secondary.active.background": "neutral-300", + "secondary.foreground": "neutral-900", + "secondary.hover.background": "neutral-200", + "selection.background": "#d7dbe2", + "sidebar.background": "#ffffff", + "sidebar.accent.background": "#f1f2f4", + "sidebar.accent.foreground": "#1f2328", + "sidebar.border": "#e5e7eb", + "sidebar.foreground": "#1f2328", + "sidebar.primary.background": "#1f2328", + "sidebar.primary.foreground": "#f7f8fa", + "skeleton.background": "#f1f2f4", + "slider.bar.background": "#171717", + "slider.thumb.background": "white", + "success.background": "green-500", + "success.foreground": "neutral-50", + "switch.background": "#d1d5db", + "tab.background": "#00000000", + "tab.active.background": "#ffffff", + "tab.active.foreground": "#1f2328", + "tab_bar.background": "#f7f8fa", + "tab_bar.segmented.background": "#f1f2f4", + "tab.foreground": "#6b7280", + "table.background": "#ffffff", + "table.active.background": "#eef0f3", + "table.active.border": "#d1d5db", + "table.even.background": "#ffffff", + "table.head.background": "#f7f8fa", + "table.head.foreground": "#737373", + "table.hover.background": "#f3f4f6", + "table.row.border": "#e5e7eb99", + "title_bar.background": "#f7f8fa", + "title_bar.border": "#e5e7eb", + "status_bar.background": "#f7f8fa", + "status_bar.border": "#e5e7eb", + "warning.background": "yellow-500", + "warning.foreground": "neutral-50", + "overlay": "#0000000d", + "window.border": "#e5e7eb", + "base.red": "red-600", + "base.red.light": "red-400", + "base.green": "green-600", + "base.green.light": "green-400", + "base.blue": "blue-600", + "base.blue.light": "blue-400", + "base.yellow": "yellow-600", + "base.yellow.light": "yellow-400", + "base.magenta": "purple-600", + "base.magenta.light": "purple-400", + "base.cyan": "cyan-600", + "base.cyan.light": "cyan-400" + }, + "highlight": { + "editor.foreground": "#000000", + "editor.background": "#ffffff", + "editor.active_line.background": "#F5F5F5", + "editor.line_number": "#929292", + "editor.active_line_number": "#000000", + "editor.invisible": "#73737366", + "conflict": "#C5060B", + "created": "#1642FF", + "hidden": "#6D6D6D", + "hint": "#9e5dff", + "modified": "#9e7008", + "predictive": "#A4ABB6", + "warning": "#C99401", + "syntax": { + "attribute": { + "color": "#957931" + }, + "boolean": { + "color": "#C5060B" + }, + "comment": { + "color": "#007fff" + }, + "comment.doc": { + "color": "#007fff" + }, + "constant": { + "color": "#C5060B" + }, + "constructor": { + "color": "#0433ff" + }, + "embedded": { + "color": "#333333" + }, + "emphasis": { + "font_style": "italic" + }, + "emphasis.strong": { + "font_weight": 700 + }, + "function": { + "color": "#0000A2" + }, + "keyword": { + "color": "#0433ff" + }, + "link_text": { + "color": "#0000A2", + "font_style": "normal" + }, + "link_uri": { + "color": "#6A7293", + "font_style": "italic" + }, + "number": { + "color": "#0433ff" + }, + "string": { + "color": "#036A07" + }, + "string.escape": { + "color": "#036A07" + }, + "string.regex": { + "color": "#036A07" + }, + "string.special": { + "color": "#d21f07" + }, + "string.special.symbol": { + "color": "#d21f07" + }, + "tag": { + "color": "#0433ff" + }, + "text.literal": { + "color": "#6F42C1" + }, + "text.code.span": { + "color": "#6F42C1" + }, + "title": { + "color": "#0433FF" + }, + "type": { + "color": "#6f42c1" + }, + "property": { + "color": "#333333" + }, + "variable": { + "color": "#333333" + }, + "variable.special": { + "color": "#C5060B" + } + } + } +} \ No newline at end of file diff --git a/gpui-client/src/api.rs b/gpui-client/src/api.rs index f5c48e1..380057e 100644 --- a/gpui-client/src/api.rs +++ b/gpui-client/src/api.rs @@ -1,40 +1,106 @@ -//! 守护进程的 HTTP 客户端。 +//! 守护进程的 HTTP 客户端与数据模型。 //! //! 网络跑在专用线程的 current_thread tokio 运行时上;UI 线程只通过两个 -//! 通道说话:请求用 `async_channel` 发进去,事件用 `std::sync::mpsc` 收回来, -//! 由 Workspace 的定时器批量 drain(与 shell-rs 的终端引擎同一种模式—— -//! 后台线程永远不直接唤醒 GPUI 的 executor)。 +//! 通道说话:命令用 `async_channel` 发进去,事件用 `std::sync::mpsc` 收回来, +//! 由 Workspace 的定时器批量 drain。 +use std::collections::HashMap; use std::sync::mpsc; -/// `/api/hosts` 里客户端需要的字段子集。 +/// `/api/hosts` 里的一台主机(字段与旧前端 `PublicHost` 对齐)。 #[derive(Clone, Debug)] -pub struct HostSummary { +pub struct Host { pub id: String, pub name: String, pub addr: String, - /// `local` 或 `ssh`。 + pub port: u16, + pub username: String, pub kind: String, + pub status: String, pub os: Option, pub pretty_name: Option, + pub distro_id: Option, + pub kernel: Option, + pub arch: Option, + pub cpu_cores: Option, + pub cpu_model: Option, pub pinned: bool, pub favorited: bool, + pub archived: bool, + pub role: Option, + pub tags: Vec, + pub jump_host_name: Option, pub latency_ms: Option, pub last_error: Option, } -/// 网络层交给 UI 的事件。 -pub enum ApiEvent { - Hosts(Vec), - Failed(String), +/// 探针快照。 +#[derive(Clone, Debug, Default)] +pub struct Inspection { + pub cpu_cores: Option, + pub cpu_usage_pct: f64, + pub cpu_iowait_pct: f64, + pub cpu_steal_pct: f64, + pub cpu_model: Option, + pub load1: f64, + pub load5: f64, + pub load15: f64, + pub mem_total_bytes: f64, + pub mem_used_bytes: f64, + pub mem_cached_bytes: f64, + pub swap_total_bytes: f64, + pub swap_used_bytes: f64, + pub disk_mount: Option, + pub disk_total_bytes: f64, + pub disk_used_bytes: f64, + pub disks: Vec, + pub net_iface: Option, + pub net_rx_bps: f64, + pub net_tx_bps: f64, + pub net_rx_bytes_total: f64, + pub net_tx_bytes_total: f64, + pub uptime_seconds: f64, + pub tcp_cc: Option, } -enum Request { +#[derive(Clone, Debug, Default)] +pub struct DiskUsage { + pub mount: String, + pub total_bytes: f64, + pub used_bytes: f64, +} + +/// 网络吞吐历史(每台主机保留 20 个采样)。 +pub type NetHistory = HashMap>; + +/// 网络/解析线程交给 UI 的事件。 +pub enum ApiEvent { + Hosts(Vec), + HostsFailed(String), + Inspection { + host_id: String, + inspection: Inspection, + host: Option, + }, + CheckedAll(Vec), + HostUpdated(Host), + Failed(String), + /// 通用 JSON 响应(元数据 PATCH 等)。 + Json(serde_json::Value), +} + +/// UI 交给网络线程的命令。 +pub enum ApiCommand { LoadHosts, + Inspect(String), + CheckAll, + CheckHost(String), + PatchMeta { host_id: String, patch: serde_json::Value }, + Post { path: String, body: serde_json::Value }, } pub struct Api { - requests: async_channel::Sender, + commands: async_channel::Sender, events: mpsc::Receiver, } @@ -49,7 +115,7 @@ pub fn port() -> String { impl Api { pub fn spawn() -> Self { - let (request_tx, request_rx) = async_channel::unbounded::(); + let (command_tx, command_rx) = async_channel::unbounded::(); let (event_tx, event_rx) = mpsc::channel::(); std::thread::Builder::new() .name("vps-switch-api".into()) @@ -60,11 +126,11 @@ impl Api { .expect("tokio runtime"); runtime.block_on(async move { let client = reqwest::Client::new(); - while let Ok(request) = request_rx.recv().await { - let event = match request { - Request::LoadHosts => fetch_hosts(&client).await, - }; - if event_tx.send(event).is_err() { + while let Ok(command) = command_rx.recv().await { + let event = handle(&client, command).await; + if let Some(event) = event + && event_tx.send(event).is_err() + { break; } } @@ -72,62 +138,247 @@ impl Api { }) .expect("api thread"); Self { - requests: request_tx, + commands: command_tx, events: event_rx, } } - pub fn load_hosts(&self) { - let _ = self.requests.send_blocking(Request::LoadHosts); + pub fn send(&self, command: ApiCommand) { + let _ = self.commands.send_blocking(command); } - pub fn try_recv(&self) -> Option { - self.events.try_recv().ok() + pub fn try_recv(&self) -> Vec { + self.events.try_iter().collect() } } -async fn fetch_hosts(client: &reqwest::Client) -> ApiEvent { - let url = format!("{}/api/hosts", base_url()); - let response = match client.get(&url).send().await { - Ok(response) => response, - Err(error) => return ApiEvent::Failed(format!("连接守护进程失败:{error}")), - }; - let response = match response.error_for_status() { - Ok(response) => response, - Err(error) => return ApiEvent::Failed(format!("守护进程返回错误:{error}")), - }; - let body: serde_json::Value = match response.json().await { - Ok(body) => body, - Err(error) => return ApiEvent::Failed(format!("解析响应失败:{error}")), - }; - let Some(items) = body.get("hosts").and_then(|hosts| hosts.as_array()) else { - return ApiEvent::Failed("响应缺少 hosts 字段".into()); - }; - ApiEvent::Hosts(items.iter().filter_map(host_from_json).collect()) +async fn handle(client: &reqwest::Client, command: ApiCommand) -> Option { + match command { + ApiCommand::LoadHosts => match get_json(client, "/api/hosts").await { + Ok(body) => { + let hosts = hosts_from_json(&body); + Some(ApiEvent::Hosts(hosts)) + } + Err(error) => Some(ApiEvent::HostsFailed(error)), + }, + ApiCommand::Inspect(host_id) => { + let path = format!("/api/hosts/{}/inspect", encode(&host_id)); + match get_json(client, &path).await { + Ok(body) => { + let inspection = body + .get("inspection") + .map(inspection_from_json) + .unwrap_or_default(); + let host = body.get("host").and_then(host_from_json); + Some(ApiEvent::Inspection { + host_id, + inspection, + host, + }) + } + Err(error) => Some(ApiEvent::Failed(error)), + } + } + ApiCommand::CheckAll => match post_json(client, "/api/hosts/check-all", None).await { + Ok(body) => Some(ApiEvent::CheckedAll(hosts_from_json(&body))), + Err(error) => Some(ApiEvent::Failed(error)), + }, + ApiCommand::CheckHost(host_id) => { + let path = format!("/api/hosts/{}/check", encode(&host_id)); + match post_json(client, &path, None).await { + Ok(body) => match body.get("host").and_then(host_from_json) { + Some(host) => Some(ApiEvent::HostUpdated(host)), + None => Some(ApiEvent::Failed("检测响应缺少主机".into())), + }, + Err(error) => Some(ApiEvent::Failed(error)), + } + } + ApiCommand::PatchMeta { host_id, patch } => { + let path = format!("/api/hosts/{}/meta", encode(&host_id)); + match patch_json(client, &path, &patch).await { + Ok(body) => match body.get("host").and_then(host_from_json) { + Some(host) => Some(ApiEvent::HostUpdated(host)), + None => Some(ApiEvent::Json(body)), + }, + Err(error) => Some(ApiEvent::Failed(error)), + } + } + ApiCommand::Post { path, body } => match post_json(client, &path, Some(&body)).await { + Ok(body) => Some(ApiEvent::Json(body)), + Err(error) => Some(ApiEvent::Failed(error)), + }, + } } -fn host_from_json(value: &serde_json::Value) -> Option { +async fn get_json(client: &reqwest::Client, path: &str) -> Result { + let response = client + .get(format!("{}{path}", base_url())) + .send() + .await + .map_err(|error| format!("连接守护进程失败:{error}"))?; + read_json(response).await +} + +async fn post_json( + client: &reqwest::Client, + path: &str, + body: Option<&serde_json::Value>, +) -> Result { + let mut request = client.post(format!("{}{path}", base_url())); + if let Some(body) = body { + request = request.json(body); + } + let response = request + .send() + .await + .map_err(|error| format!("连接守护进程失败:{error}"))?; + read_json(response).await +} + +async fn patch_json( + client: &reqwest::Client, + path: &str, + body: &serde_json::Value, +) -> Result { + let response = client + .patch(format!("{}{path}", base_url())) + .json(body) + .send() + .await + .map_err(|error| format!("连接守护进程失败:{error}"))?; + read_json(response).await +} + +async fn read_json(response: reqwest::Response) -> Result { + let status = response.status(); + let body: serde_json::Value = response + .json() + .await + .map_err(|error| format!("解析响应失败:{error}"))?; + if !status.is_success() { + let reason = body + .get("error") + .and_then(|error| error.as_str()) + .unwrap_or("请求失败"); + return Err(reason.to_owned()); + } + Ok(body) +} + +fn encode(value: &str) -> String { + // host id 只含字母数字与 -_,直接拼接即可。 + value.to_owned() +} + +pub fn hosts_from_json(body: &serde_json::Value) -> Vec { + body.get("hosts") + .and_then(|hosts| hosts.as_array()) + .map(|items| items.iter().filter_map(host_from_json).collect()) + .unwrap_or_default() +} + +pub fn host_from_json(value: &serde_json::Value) -> Option { let field = |name: &str| value.get(name).and_then(|item| item.as_str()); let os_info = value.get("osInfo"); - Some(HostSummary { + let os_field = |name: &str| { + os_info + .and_then(|info| info.get(name)) + .and_then(|item| item.as_str()) + }; + Some(Host { id: field("id")?.to_owned(), name: field("name")?.to_owned(), addr: field("addr").unwrap_or_default().to_owned(), + port: value.get("port").and_then(|v| v.as_u64()).unwrap_or(22) as u16, + username: field("username").unwrap_or_default().to_owned(), kind: field("kind").unwrap_or("ssh").to_owned(), - os: os_info - .and_then(|info| info.get("os")) - .and_then(|item| item.as_str()) - .map(str::to_owned), - pretty_name: os_info - .and_then(|info| info.get("prettyName")) - .and_then(|item| item.as_str()) - .map(str::to_owned), - pinned: value.get("pinned").and_then(|item| item.as_bool()).unwrap_or(false), + status: field("status").unwrap_or("unknown").to_owned(), + os: os_field("os").map(str::to_owned), + pretty_name: os_field("prettyName").map(str::to_owned), + distro_id: os_field("distroId").map(str::to_owned), + kernel: os_field("kernel").map(str::to_owned), + arch: os_field("arch").map(str::to_owned), + cpu_cores: os_info + .and_then(|info| info.get("cpuCores")) + .and_then(|v| v.as_u64()) + .map(|v| v as u32), + cpu_model: os_field("cpuModel").map(str::to_owned), + pinned: value.get("pinned").and_then(|v| v.as_bool()).unwrap_or(false), favorited: value .get("favorited") - .and_then(|item| item.as_bool()) + .and_then(|v| v.as_bool()) .unwrap_or(false), - latency_ms: value.get("latencyMs").and_then(|item| item.as_i64()), + archived: value + .get("archived") + .and_then(|v| v.as_bool()) + .unwrap_or(false), + role: field("role").map(str::to_owned), + tags: value + .get("tags") + .and_then(|v| v.as_array()) + .map(|items| { + items + .iter() + .filter_map(|item| item.as_str().map(str::to_owned)) + .collect() + }) + .unwrap_or_default(), + jump_host_name: field("jumpHostName").map(str::to_owned), + latency_ms: value.get("latencyMs").and_then(|v| v.as_i64()), last_error: field("lastError").map(str::to_owned), }) } + +fn inspection_from_json(value: &serde_json::Value) -> Inspection { + let num = |name: &str| value.get(name).and_then(|v| v.as_f64()).unwrap_or(0.0); + let text = |name: &str| { + value + .get(name) + .and_then(|v| v.as_str()) + .map(str::to_owned) + }; + Inspection { + cpu_cores: value + .get("cpuCores") + .and_then(|v| v.as_u64()) + .map(|v| v as u32), + cpu_usage_pct: num("cpuUsagePct"), + cpu_iowait_pct: num("cpuIowaitPct"), + cpu_steal_pct: num("cpuStealPct"), + cpu_model: text("cpuModel"), + load1: num("load1"), + load5: num("load5"), + load15: num("load15"), + mem_total_bytes: num("memTotalBytes"), + mem_used_bytes: num("memUsedBytes"), + mem_cached_bytes: num("memCachedBytes"), + swap_total_bytes: num("swapTotalBytes"), + swap_used_bytes: num("swapUsedBytes"), + disk_mount: text("diskMount"), + disk_total_bytes: num("diskTotalBytes"), + disk_used_bytes: num("diskUsedBytes"), + disks: value + .get("disks") + .and_then(|v| v.as_array()) + .map(|items| { + items + .iter() + .filter_map(|item| { + Some(DiskUsage { + mount: item.get("mount")?.as_str()?.to_owned(), + total_bytes: item.get("totalBytes")?.as_f64().unwrap_or(0.0), + used_bytes: item.get("usedBytes")?.as_f64().unwrap_or(0.0), + }) + }) + .collect() + }) + .unwrap_or_default(), + net_iface: text("netIface"), + net_rx_bps: num("netRxBps"), + net_tx_bps: num("netTxBps"), + net_rx_bytes_total: num("netRxBytesTotal"), + net_tx_bytes_total: num("netTxBytesTotal"), + uptime_seconds: num("uptimeSeconds"), + tcp_cc: text("tcpCc"), + } +} diff --git a/gpui-client/src/detail_drawer.rs b/gpui-client/src/detail_drawer.rs new file mode 100644 index 0000000..387979c --- /dev/null +++ b/gpui-client/src/detail_drawer.rs @@ -0,0 +1,910 @@ +//! 右侧详情抽屉:探针仪表盘(规格 §6)。 +//! +//! 已知差异:CPU 圆环暂用「大号百分比 + 横向进度条」呈现(GPUI 没有 +//! CSS 圆环,自绘弧线元素放到后续迭代);其余布局、字号、配色按规格。 + +use gpui_kit::StatefulInteractiveElement as _; +use gpui_kit::component::*; +use gpui_kit::*; + +use crate::api::{Host, Inspection}; +use crate::top_bar::{IconName, icon, status_dot_color}; +use crate::util::{format_bytes, format_rate, format_uptime}; +use crate::workspace::Workspace; + +pub fn render(workspace: &Workspace, this: &Entity, cx: &App) -> impl IntoElement { + let Some(host) = workspace.right_wing_host() else { + return div().into_any_element(); + }; + let theme = cx.theme(); + let inspection = workspace.inspections.get(&host.id); + let history: Vec<(f64, f64)> = workspace + .net_history + .get(&host.id) + .cloned() + .unwrap_or_default(); + + let this_close = this.clone(); + let this_refresh = this.clone(); + let refresh_id = host.id.clone(); + let this_term = this.clone(); + let term_id = host.id.clone(); + let this_files = this.clone(); + let files_id = host.id.clone(); + let this_star = this.clone(); + let star_id = host.id.clone(); + + let header = h_flex() + .items_center() + .justify_between() + .px(px(16.)) + .py(px(12.)) + .border_b_1() + .border_color(rgb(0xf1f2f4)) + .child( + h_flex() + .min_w_0() + .items_center() + .gap(px(8.)) + .child( + div() + .size(px(10.)) + .rounded_full() + .bg(status_dot_color(&host.status, cx)), + ) + .child( + div() + .text_size(px(14.)) + .font_weight(FontWeight::SEMIBOLD) + .truncate() + .child(host.name.clone()), + ), + ) + .child( + h_flex() + .items_center() + .gap(px(4.)) + .child( + div() + .id("drawer-refresh") + .px(px(8.)) + .py(px(4.)) + .rounded_lg() + .text_size(px(12.)) + .text_color(theme.muted_foreground) + .cursor_pointer() + .hover(|style| style.bg(rgb(0xf1f2f4))) + .on_click(move |_, _, cx| { + let id = refresh_id.clone(); + let _ = this_refresh.update(cx, |workspace, cx| { + workspace.inspect_now(&id, cx); + }); + }) + .child("刷新"), + ) + .child( + div() + .id("drawer-close") + .flex() + .items_center() + .justify_center() + .size(px(24.)) + .rounded_lg() + .text_color(theme.muted_foreground) + .cursor_pointer() + .hover(|style| style.bg(rgb(0xf1f2f4))) + .on_click(move |_, _, cx| { + let _ = this_close.update(cx, |workspace, cx| { + workspace.right_wing = None; + cx.notify(); + }); + }) + .child(icon(IconName::X, 14., cx)), + ), + ); + + let body = div() + .id("drawer-scroll") + .flex_1() + .min_h_0() + .overflow_y_scroll() + .p(px(14.)) + .child(v_flex().gap(px(10.)).child(nameplate(host, inspection, cx)).child(cpu_card(host, inspection, cx)).child(memory_card(inspection, cx)).child(disk_card(inspection, cx)).child(network_card(inspection, &history, cx)).child(role_card(host, workspace, this, cx))); + + let footer = h_flex() + .items_center() + .justify_between() + .px(px(14.)) + .py(px(10.)) + .border_t_1() + .border_color(rgb(0xf1f2f4)) + .bg(rgb(0xfafafa)) + .child( + h_flex() + .items_center() + .gap(px(6.)) + .child( + div() + .id("drawer-open-terminal") + .rounded_lg() + .bg(rgb(0x18181b)) + .text_color(rgb(0xffffff)) + .px(px(12.)) + .py(px(6.)) + .text_size(px(12.)) + .cursor_pointer() + .on_click(move |_, _, cx| { + let id = term_id.clone(); + let _ = this_term + .update(cx, |workspace, cx| workspace.open_terminal(&id, cx)); + }) + .child("打开终端"), + ) + .child( + div() + .id("drawer-open-files") + .rounded_lg() + .border_1() + .border_color(rgb(0xe4e4e7)) + .bg(rgb(0xffffff)) + .px(px(12.)) + .py(px(6.)) + .text_size(px(12.)) + .text_color(rgb(0x3f3f46)) + .cursor_pointer() + .on_click(move |_, _, cx| { + let id = files_id.clone(); + let _ = this_files + .update(cx, |workspace, cx| workspace.open_files(&id, cx)); + }) + .child("文件浏览"), + ), + ) + .child( + h_flex() + .items_center() + .gap(px(4.)) + .child( + div() + .id("drawer-star") + .flex() + .items_center() + .justify_center() + .size(px(28.)) + .rounded_lg() + .text_color(if host.favorited { + Hsla::from(rgb(0xf59e0b)) + } else { + theme.muted_foreground + }) + .cursor_pointer() + .hover(|style| style.bg(rgb(0xf1f2f4))) + .on_click(move |_, _, cx| { + let id = star_id.clone(); + let _ = this_star + .update(cx, |workspace, cx| workspace.toggle_favorite(&id, cx)); + }) + .child(icon(IconName::Star, 15., cx)), + ), + ); + + div() + .size_full() + .min_h_0() + .flex() + .flex_col() + .overflow_hidden() + .rounded_2xl() + .border_1() + .border_color(rgb(0xe4e4e7)) + .bg(Hsla::from(rgb(0xffffff)).opacity(0.88)) + .shadow_sm() + .child(header) + .child(body) + .child(footer) + .into_any_element() +} + +fn card_shell(cx: &App) -> Div { + let _ = cx; + div() + .rounded_xl() + .border_1() + .border_color(rgb(0xe4e4e7)) + .bg(rgb(0xffffff)) + .p(px(14.)) + .shadow_sm() +} + +fn os_label(host: &Host) -> String { + host.pretty_name + .clone() + .or_else(|| host.distro_id.clone()) + .or_else(|| host.os.clone()) + .unwrap_or_else(|| "正在探测系统...".into()) +} + +fn nameplate(host: &Host, inspection: Option<&Inspection>, cx: &App) -> impl IntoElement { + let theme = cx.theme(); + let uptime = inspection + .map(|i| format_uptime(i.uptime_seconds)) + .unwrap_or_else(|| "--".into()); + let kernel = host + .kernel + .as_deref() + .map(|k| k.split('-').next().unwrap_or(k).to_owned()) + .or_else(|| host.arch.clone()) + .unwrap_or_else(|| "--".into()); + let latency = host + .latency_ms + .map(|ms| format!("{ms} ms")) + .unwrap_or_else(|| "--".into()); + let cpu = inspection + .and_then(|i| i.cpu_model.clone()) + .or_else(|| host.cpu_model.clone()) + .or_else(|| host.arch.clone()) + .unwrap_or_else(|| "正在探测处理器型号...".into()); + let tcp = inspection.and_then(|i| i.tcp_cc.clone()); + + let mut shell = card_shell(cx); + let mut title = h_flex() + .items_center() + .justify_between() + .child( + div() + .text_size(px(12.)) + .font_weight(FontWeight::SEMIBOLD) + .truncate() + .child(os_label(host)), + ); + if let Some(tcp) = tcp { + let is_bbr = tcp.to_lowercase().contains("bbr"); + title = title.child( + div() + .rounded_md() + .px(px(6.)) + .py(px(1.)) + .text_size(px(10.)) + .font_weight(FontWeight::SEMIBOLD) + .bg(if is_bbr { rgb(0xd1fae5) } else { rgb(0xf1f2f4) }) + .text_color(if is_bbr { rgb(0x047857) } else { rgb(0x52525b) }) + .child(tcp.to_uppercase()), + ); + } + shell = shell + .child(title) + .child( + div() + .mt(px(2.)) + .text_size(px(11.)) + .text_color(theme.muted_foreground) + .truncate() + .child(cpu), + ) + .child( + h_flex() + .mt(px(10.)) + .pt(px(8.)) + .border_t_1() + .border_color(rgb(0xf1f2f4)) + .justify_between() + .child(stat_column("运行时间", uptime, cx)) + .child(stat_column("内核 / 架构", kernel, cx)) + .child(stat_column("SSH 响应", latency, cx)), + ); + shell +} + +fn stat_column(label: &'static str, value: String, cx: &App) -> impl IntoElement { + let _ = cx; + v_flex() + .items_center() + .gap(px(2.)) + .child( + div() + .text_size(px(10.)) + .text_color(rgb(0xa1a1aa)) + .child(label), + ) + .child( + div() + .text_size(px(11.)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(rgb(0x27272a)) + .child(value), + ) +} + +fn cpu_card(host: &Host, inspection: Option<&Inspection>, cx: &App) -> impl IntoElement { + let theme = cx.theme(); + let cores = inspection + .and_then(|i| i.cpu_cores) + .or(host.cpu_cores) + .unwrap_or(1); + let usage = inspection.map(|i| i.cpu_usage_pct).unwrap_or(0.0); + let steal = inspection.map(|i| i.cpu_steal_pct).unwrap_or(0.0); + let load1 = inspection.map(|i| i.load1).unwrap_or(0.0); + let load5 = inspection.map(|i| i.load5).unwrap_or(0.0); + let load15 = inspection.map(|i| i.load15).unwrap_or(0.0); + let iowait = inspection.map(|i| i.cpu_iowait_pct).unwrap_or(0.0); + let has_data = inspection.is_some(); + let usage_text = if has_data { + format!("{usage:.1}%") + } else { + "--".into() + }; + let number = |value: Option| match value { + Some(value) if has_data => format!("{value:.2}"), + _ => "--".into(), + }; + + card_shell(cx) + .child( + h_flex() + .items_center() + .justify_between() + .child( + h_flex() + .items_center() + .gap(px(8.)) + .child( + div() + .text_size(px(11.)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(theme.muted_foreground) + .child("CPU 算力与负载"), + ) + .child(badge(format!("{cores} 核"), cx)), + ) + .child( + div() + .text_size(px(10.)) + .text_color(if steal >= 5.0 { + rgb(0xc2410c) + } else { + rgb(0x71717a) + }) + .child(format!("超售 Steal {steal:.1}%")), + ), + ) + .child( + h_flex() + .mt(px(10.)) + .gap(px(14.)) + .child( + v_flex() + .items_center() + .gap(px(6.)) + .w(px(56.)) + .child( + div() + .text_size(px(18.)) + .font_weight(FontWeight::BOLD) + .text_color(if usage > 80.0 { + rgb(0xf97316) + } else { + rgb(0x18181b) + }) + .child(usage_text), + ) + .child( + div() + .w_full() + .h(px(4.)) + .rounded_full() + .bg(rgb(0xf1f2f4)) + .child( + div() + .h_full() + .rounded_full() + .bg(if usage > 80.0 { + rgb(0xf97316) + } else { + rgb(0x18181b) + }) + .w(relative(usage as f32 / 100.0)), + ), + ), + ) + .child( + v_flex() + .flex_1() + .gap(px(6.)) + .rounded_lg() + .bg(rgb(0xfafafa)) + .px(px(12.)) + .py(px(8.)) + .child(metric_row("1分负载", number(Some(load1)), cx)) + .child(metric_row("5分负载", number(Some(load5)), cx)) + .child(metric_row("15分负载", number(Some(load15)), cx)) + .child(metric_row("IOWait", format!("{iowait:.1}%"), cx)), + ), + ) +} + +fn metric_row(label: &'static str, value: String, cx: &App) -> impl IntoElement { + let _ = cx; + h_flex() + .items_center() + .justify_between() + .child( + div() + .text_size(px(11.)) + .text_color(rgb(0xa1a1aa)) + .child(label), + ) + .child( + div() + .text_size(px(11.)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(rgb(0x27272a)) + .child(value), + ) +} + +fn memory_card(inspection: Option<&Inspection>, cx: &App) -> impl IntoElement { + let theme = cx.theme(); + let (total, used, cached, swap_total, swap_used) = inspection + .map(|i| { + ( + i.mem_total_bytes, + i.mem_used_bytes, + i.mem_cached_bytes, + i.swap_total_bytes, + i.swap_used_bytes, + ) + }) + .unwrap_or((0.0, 0.0, 0.0, 0.0, 0.0)); + let used_pct = if total > 0.0 { + ((used * 100.0) / total).round().min(100.0) + } else { + 0.0 + }; + let cache_pct = if total > 0.0 { + ((cached * 100.0) / total).round().min(100.0 - used_pct).max(0.0) + } else { + 0.0 + }; + let summary = if total > 0.0 { + format!("{} / {}", format_bytes(used), format_bytes(total)) + } else { + "-- / --".into() + }; + let swap = if swap_total > 0.0 { + format!( + "Swap: {} / {} ({}%)", + format_bytes(swap_used), + format_bytes(swap_total), + ((swap_used * 100.0) / swap_total).round() + ) + } else { + "Swap: 未开启".into() + }; + + card_shell(cx) + .child( + h_flex() + .items_center() + .justify_between() + .child( + div() + .text_size(px(11.)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(theme.muted_foreground) + .child("内存结构 (RAM & Swap)"), + ) + .child( + div() + .text_size(px(11.)) + .font_weight(FontWeight::SEMIBOLD) + .child(summary), + ), + ) + .child( + h_flex() + .mt(px(8.)) + .h(px(8.)) + .w_full() + .overflow_hidden() + .rounded_full() + .bg(rgb(0xf1f2f4)) + .child( + div() + .h_full() + .bg(rgb(0x18181b)) + .w(relative(used_pct as f32 / 100.0)), + ) + .child( + div() + .h_full() + .bg(rgb(0xd4d4d8)) + .w(relative(cache_pct as f32 / 100.0)), + ), + ) + .child( + h_flex() + .mt(px(8.)) + .items_center() + .justify_between() + .child( + h_flex() + .items_center() + .gap(px(12.)) + .child( + h_flex() + .items_center() + .gap(px(4.)) + .child(div().size(px(8.)).rounded_sm().bg(rgb(0x18181b))) + .child(format!("实占 {used_pct:.0}%")), + ) + .child( + h_flex() + .items_center() + .gap(px(4.)) + .child(div().size(px(8.)).rounded_sm().bg(rgb(0xd4d4d8))) + .child(format!("缓存 {}", format_bytes(cached))), + ), + ) + .child( + div() + .text_size(px(10.)) + .text_color(rgb(0xa1a1aa)) + .child(swap), + ) + .text_size(px(11.)) + .text_color(theme.muted_foreground), + ) +} + +fn disk_card(inspection: Option<&Inspection>, cx: &App) -> impl IntoElement { + let theme = cx.theme(); + let disks: Vec<(String, f64, f64)> = match inspection { + Some(i) if !i.disks.is_empty() => i + .disks + .iter() + .map(|d| (d.mount.clone(), d.used_bytes, d.total_bytes)) + .collect(), + Some(i) if i.disk_total_bytes > 0.0 => vec![( + i.disk_mount.clone().unwrap_or_else(|| "/".into()), + i.disk_used_bytes, + i.disk_total_bytes, + )], + _ => Vec::new(), + }; + let summary = match disks.len() { + 0 => "--".to_owned(), + 1 => format!("{} / {}", format_bytes(disks[0].1), format_bytes(disks[0].2)), + count => format!("{count} 个挂载分区"), + }; + + let mut shell = card_shell(cx).child( + h_flex() + .items_center() + .justify_between() + .child( + div() + .text_size(px(11.)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(theme.muted_foreground) + .child("磁盘存储"), + ) + .child( + div() + .text_size(px(11.)) + .font_weight(FontWeight::SEMIBOLD) + .child(summary), + ), + ); + if disks.is_empty() { + shell = shell.child( + div() + .mt(px(8.)) + .text_size(px(12.)) + .text_color(theme.muted_foreground) + .child("正在探测磁盘容量..."), + ); + } + for (mount, used, total) in disks { + let pct = if total > 0.0 { + ((used * 100.0) / total).round().min(100.0) + } else { + 0.0 + }; + shell = shell.child( + v_flex() + .mt(px(10.)) + .gap(px(6.)) + .child( + h_flex() + .items_center() + .justify_between() + .child( + div() + .text_size(px(11.)) + .font_weight(FontWeight::SEMIBOLD) + .child(mount), + ) + .child( + div() + .text_size(px(11.)) + .text_color(theme.muted_foreground) + .child(format!( + "{} / {} ({}%)", + format_bytes(used), + format_bytes(total), + pct + )), + ), + ) + .child( + div() + .h(px(8.)) + .w_full() + .overflow_hidden() + .rounded_full() + .bg(rgb(0xf1f2f4)) + .child( + div() + .h_full() + .rounded_full() + .bg(if pct > 85.0 { + rgb(0xef4444) + } else { + rgb(0x18181b) + }) + .w(relative(pct as f32 / 100.0)), + ), + ), + ); + } + shell +} + +fn network_card( + inspection: Option<&Inspection>, + history: &[(f64, f64)], + cx: &App, +) -> impl IntoElement { + let theme = cx.theme(); + let rx = inspection.map(|i| i.net_rx_bps).unwrap_or(0.0); + let tx = inspection.map(|i| i.net_tx_bps).unwrap_or(0.0); + let rx_total = inspection.map(|i| i.net_rx_bytes_total).unwrap_or(0.0); + let tx_total = inspection.map(|i| i.net_tx_bytes_total).unwrap_or(0.0); + let iface = inspection + .and_then(|i| i.net_iface.clone()) + .unwrap_or_else(|| "eth0".into()); + + let max = history + .iter() + .map(|(rx, tx)| rx.max(*tx)) + .fold(1024f64, f64::max); + let mut bars: Vec<(f32, f32)> = vec![(0.0, 0.0); 20usize.saturating_sub(history.len())]; + bars.extend( + history + .iter() + .map(|(rx, tx)| { + ( + (rx / max * 100.0).max(6.0) as f32, + (tx / max * 100.0).max(6.0) as f32, + ) + }) + .collect::>(), + ); + + let mut chart = h_flex() + .mt(px(10.)) + .h(px(48.)) + .items_end() + .gap(px(3.)) + .rounded_lg() + .bg(rgb(0xfafafa)) + .px(px(8.)) + .py(px(6.)); + for (rx_h, tx_h) in bars { + chart = chart.child( + v_flex() + .flex_1() + .h_full() + .justify_end() + .gap(px(2.)) + .child( + div() + .w_full() + .rounded_sm() + .h(relative((tx_h * 0.45 / 100.0).max(0.08))) + .bg(rgb(0x3f3f46)), + ) + .child( + div() + .w_full() + .rounded_sm() + .h(relative((rx_h * 0.55 / 100.0).max(0.08))) + .bg(rgb(0x10b981)), + ), + ); + } + + card_shell(cx) + .child( + h_flex() + .items_center() + .justify_between() + .child( + h_flex() + .items_center() + .gap(px(6.)) + .child( + div() + .text_size(px(11.)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(theme.muted_foreground) + .child("网络吞吐"), + ) + .child(badge(iface, cx)), + ) + .child( + h_flex() + .items_center() + .gap(px(10.)) + .text_size(px(11.)) + .font_weight(FontWeight::SEMIBOLD) + .child(div().text_color(rgb(0x059669)).child(format!("↓ {}", format_rate(rx)))) + .child(div().child(format!("↑ {}", format_rate(tx)))), + ), + ) + .child(chart) + .child( + h_flex() + .mt(px(8.)) + .pt(px(8.)) + .border_t_1() + .border_color(rgb(0xf1f2f4)) + .items_center() + .justify_between() + .text_size(px(10.)) + .text_color(rgb(0xa1a1aa)) + .child(format!("累计下行: {}", format_bytes(rx_total))) + .child(format!("累计上行: {}", format_bytes(tx_total))), + ) +} + +fn role_card( + host: &Host, + _workspace: &Workspace, + this: &Entity, + cx: &App, +) -> impl IntoElement { + let theme = cx.theme(); + let roles: [(&str, &str); 4] = [ + ("primary", "主力"), + ("proxy", "代理"), + ("test", "测试"), + ("archive", "归档"), + ]; + let mut role_row = h_flex().flex_wrap().gap(px(4.)); + for (key, label) in roles { + let active = host.role.as_deref() == Some(key); + let this_role = this.clone(); + let host_id = host.id.clone(); + let role_key = key.to_owned(); + let base = div() + .id(SharedString::from(format!("role-{key}-{}", host.id))) + .rounded_lg() + .px(px(8.)) + .py(px(4.)) + .text_size(px(11.)) + .cursor_pointer() + .on_click(move |_, _, cx| { + let id = host_id.clone(); + let role = role_key.clone(); + let _ = this_role.update(cx, |workspace, cx| { + let next = if workspace.host_by_id(&id).and_then(|h| h.role.as_deref()) + == Some(role.as_str()) + { + serde_json::Value::Null + } else { + serde_json::Value::String(role) + }; + workspace.patch_meta(&id, serde_json::json!({ "role": next }), cx); + }); + }) + .child(label.to_owned()); + role_row = role_row.child(if active { + base.bg(rgb(0x18181b)).text_color(rgb(0xffffff)) + } else { + base.bg(rgb(0xf1f2f4)).text_color(theme.muted_foreground) + }); + } + + let mut tag_row = h_flex().flex_wrap().gap(px(6.)); + for tag in &host.tags { + let this_tag = this.clone(); + let host_id = host.id.clone(); + let tag_value = tag.clone(); + tag_row = tag_row.child( + h_flex() + .items_center() + .gap(px(4.)) + .rounded_md() + .border_1() + .border_color(rgb(0xe4e4e7)) + .bg(rgb(0xfafafa)) + .px(px(6.)) + .py(px(1.)) + .text_size(px(11.)) + .child(format!("#{tag}")) + .child( + div() + .id(SharedString::from(format!("tag-x-{tag}-{}", host.id))) + .text_color(rgb(0xa1a1aa)) + .cursor_pointer() + .on_click(move |_, _, cx| { + let id = host_id.clone(); + let value = tag_value.clone(); + let _ = this_tag.update(cx, |workspace, cx| { + if let Some(host) = workspace.host_by_id(&id) { + let tags: Vec = host + .tags + .iter() + .filter(|t| **t != value) + .cloned() + .collect(); + workspace.patch_meta( + &id, + serde_json::json!({ "tags": tags }), + cx, + ); + } + }); + }) + .child("×"), + ), + ); + } + + let mut shell = card_shell(cx) + .child( + h_flex() + .items_center() + .justify_between() + .child( + div() + .text_size(px(11.)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(theme.muted_foreground) + .child("角色分类"), + ) + .child(if host.jump_host_name.is_some() { + div() + .rounded_md() + .bg(rgb(0xf1f2f4)) + .px(px(6.)) + .py(px(2.)) + .text_size(px(11.)) + .text_color(rgb(0x52525b)) + .child(format!( + "跳板: {}", + host.jump_host_name.clone().unwrap_or_default() + )) + } else { + div() + }), + ) + .child(div().mt(px(8.)).child(role_row)); + if !host.tags.is_empty() { + shell = shell.child(div().mt(px(10.)).child(tag_row)); + } + shell +} + +fn badge(text: impl Into, cx: &App) -> impl IntoElement { + let _ = cx; + div() + .rounded_md() + .bg(rgb(0xf1f2f4)) + .px(px(6.)) + .py(px(1.)) + .text_size(px(10.)) + .text_color(rgb(0x52525b)) + .child(text.into()) +} diff --git a/gpui-client/src/host_card.rs b/gpui-client/src/host_card.rs new file mode 100644 index 0000000..450541a --- /dev/null +++ b/gpui-client/src/host_card.rs @@ -0,0 +1,346 @@ +//! 仓库视图:主机卡片列表(规格 §4)。 + +use gpui_kit::component::*; +use gpui_kit::*; + +use crate::api::Host; +use crate::top_bar::{IconName, icon, status_dot_color}; +use crate::workspace::{RightWing, Workspace}; + +pub fn render_list(workspace: &Workspace, this: &Entity, cx: &App) -> impl IntoElement { + let filtered = workspace.filtered_hosts(); + let show_controls = !workspace.has_stage(); + let empty = filtered.is_empty() && !workspace.loading; + let mut list = v_flex().gap(px(8.)); + for host in &filtered { + list = list.child(card(workspace, host, show_controls, this, cx)); + } + let mut scroll = div() + .id("host-scroll") + .size_full() + .min_h_0() + .overflow_y_scroll() + .child(list); + if empty { + scroll = scroll.child(empty_state(cx)); + } + scroll +} + +fn empty_state(cx: &App) -> impl IntoElement { + div() + .mt(px(8.)) + .rounded_xl() + .border_1() + .border_color(cx.theme().border) + .bg(rgb(0xffffff)) + .p(px(40.)) + .text_center() + .text_size(px(12.)) + .text_color(cx.theme().muted_foreground) + .child("没有匹配的 VPS 主机") +} + +fn card( + workspace: &Workspace, + host: &Host, + show_controls: bool, + this: &Entity, + cx: &App, +) -> impl IntoElement { + let theme = cx.theme(); + let selected = (workspace.has_stage() + && workspace.stage_host_id.as_deref() == Some(host.id.as_str())) + || (workspace.right_wing == Some(RightWing::Detail) + && workspace.detail_host_id.as_deref() == Some(host.id.as_str())); + + let address = if host.kind == "local" { + format!("{}@127.0.0.1", host.username) + } else { + format!("{}@{}:{}", host.username, host.addr, host.port) + }; + + let this_click = this.clone(); + let host_id = host.id.clone(); + let this_term = this.clone(); + let term_id = host.id.clone(); + let this_files = this.clone(); + let files_id = host.id.clone(); + let this_star = this.clone(); + let star_id = host.id.clone(); + let this_pin = this.clone(); + let pin_id = host.id.clone(); + + let terminal_active = workspace.has_stage() + && workspace.stage_host_id.as_deref() == Some(host.id.as_str()) + && workspace.center_stage == Some(crate::workspace::CenterStage::Terminal); + let files_active = workspace.has_stage() + && workspace.stage_host_id.as_deref() == Some(host.id.as_str()) + && workspace.center_stage == Some(crate::workspace::CenterStage::Files); + + let mut left = h_flex() + .min_w_0() + .items_center() + .gap(px(12.)) + .child(os_badge(host, if show_controls { 40. } else { 24. }, cx)); + let mut title_row = h_flex() + .items_center() + .gap(px(8.)) + .child( + div() + .size(px(10.)) + .rounded_full() + .bg(status_dot_color(&host.status, cx)) + .shadow_sm(), + ) + .child( + div() + .text_size(px(14.)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(theme.foreground) + .truncate() + .child(host.name.clone()), + ); + if show_controls && host.pinned && host.kind != "local" { + title_row = title_row.child(tag_chip("置顶", cx)); + } + if show_controls + && let Some(role) = &host.role + && let Some(label) = role_label(role) + { + title_row = title_row.child(tag_chip(label, cx)); + } + let mut detail_row = h_flex() + .gap(px(8.)) + .text_size(px(11.)) + .text_color(theme.muted_foreground) + .child(div().truncate().child(address)); + if show_controls + && let Some(jump) = &host.jump_host_name + { + detail_row = detail_row.child(div().truncate().child(format!("via {jump}"))); + } + left = left.child( + v_flex() + .min_w_0() + .gap(px(2.)) + .child(title_row) + .child(detail_row), + ); + + let mut right = h_flex().flex_shrink_0().items_center().gap(px(6.)); + right = right + .child( + small_button(terminal_active, cx) + .id(SharedString::from(format!("open-term-{}", host.id))) + .on_click(move |_, _, cx| { + let id = term_id.clone(); + let _ = this_term.update(cx, |workspace, cx| workspace.open_terminal(&id, cx)); + cx.stop_propagation(); + }) + .child("终端"), + ) + .child( + small_button(files_active, cx) + .id(SharedString::from(format!("open-files-{}", host.id))) + .on_click(move |_, _, cx| { + let id = files_id.clone(); + let _ = this_files.update(cx, |workspace, cx| workspace.open_files(&id, cx)); + cx.stop_propagation(); + }) + .child("文件"), + ); + if show_controls { + let star_color = if host.favorited { + Hsla::from(rgb(0xf59e0b)) + } else { + theme.muted_foreground + }; + right = right.child( + div() + .id(SharedString::from(format!("star-{}", host.id))) + .flex() + .items_center() + .justify_center() + .size(px(28.)) + .rounded_lg() + .text_color(star_color) + .hover(|style| style.bg(rgb(0xf1f2f4))) + .on_click(move |_, _, cx| { + let id = star_id.clone(); + let _ = this_star.update(cx, |workspace, cx| workspace.toggle_favorite(&id, cx)); + cx.stop_propagation(); + }) + .child(icon(IconName::Star, 16., cx)), + ); + if host.kind != "local" { + let pin_color = if host.pinned { + theme.foreground + } else { + theme.muted_foreground + }; + right = right.child( + div() + .id(SharedString::from(format!("pin-{}", host.id))) + .flex() + .items_center() + .justify_center() + .size(px(28.)) + .rounded_lg() + .text_color(pin_color) + .hover(|style| style.bg(rgb(0xf1f2f4))) + .on_click(move |_, _, cx| { + let id = pin_id.clone(); + let _ = + this_pin.update(cx, |workspace, cx| workspace.toggle_pinned(&id, cx)); + cx.stop_propagation(); + }) + .child(icon(IconName::ArrowUpToLine, 16., cx)), + ); + } + right = right.child( + div() + .flex() + .items_center() + .justify_center() + .size(px(28.)) + .rounded_lg() + .text_color(theme.muted_foreground) + .hover(|style| style.bg(rgb(0xf1f2f4))) + .child(icon(IconName::Pencil, 16., cx)), + ); + } + + let base = h_flex() + .id(SharedString::from(format!("host-card-{}", host.id))) + .items_center() + .justify_between() + .gap(px(12.)) + .rounded_2xl() + .border_1() + .px(px(16.)) + .py(px(14.)) + .cursor_pointer() + .on_click(move |event, _, cx| { + let id = host_id.clone(); + let double = event.click_count() >= 2; + let _ = this_click.update(cx, |workspace, cx| { + if double { + workspace.open_terminal(&id, cx); + } else { + workspace.toggle_detail(&id, cx); + } + }); + }) + .child(left) + .child(right); + + if selected { + base.border_color(rgb(0xd4d4d8)) + .bg(rgb(0xffffff)) + .shadow_md() + } else { + base.border_color(rgb(0xe4e4e7)) + .bg(Hsla::from(rgb(0xffffff)).opacity(0.82)) + .hover(|style| style.bg(rgb(0xffffff)).border_color(rgb(0xd4d4d8))) + } +} + +fn small_button(active: bool, _cx: &App) -> Div { + let base = div() + .flex() + .items_center() + .h(px(26.)) + .px(px(10.)) + .rounded_lg() + .border_1() + .text_size(px(12.)) + .cursor_pointer(); + if active { + base.border_color(rgb(0x18181b)) + .bg(rgb(0x18181b)) + .text_color(rgb(0xffffff)) + } else { + base.border_color(rgb(0xe4e4e7)) + .bg(rgb(0xffffff)) + .text_color(rgb(0x3f3f46)) + .hover(|style| style.bg(rgb(0xf1f2f4))) + } +} + +fn tag_chip(text: impl Into, cx: &App) -> impl IntoElement { + let _ = cx; + div() + .rounded_md() + .bg(rgb(0xf1f2f4)) + .px(px(6.)) + .py(px(1.)) + .text_size(px(10.)) + .text_color(rgb(0x52525b)) + .child(text.into()) +} + +fn role_label(role: &str) -> Option<&'static str> { + match role { + "primary" => Some("主力"), + "proxy" => Some("代理"), + "test" => Some("测试"), + "archive" => Some("归档"), + _ => None, + } +} + +/// OS 徽章:按旧 `OsBadge.vue` 的发行版判定与两字母回退。 +fn os_badge(host: &Host, size: f32, cx: &App) -> impl IntoElement { + let distro = distro_of(host); + let (glyph, color, font) = match distro { + "ubuntu" => ("◉".to_owned(), Hsla::from(rgb(0xe95420)), size * 0.42), + "debian" => ("◍".to_owned(), Hsla::from(rgb(0xe11d48)), size * 0.42), + "alpine" => ("▲".to_owned(), Hsla::from(rgb(0x3f3f46)), size * 0.34), + "windows" => ("⊞".to_owned(), Hsla::from(rgb(0x3f3f46)), size * 0.42), + _ => (two_letters(&host.name), cx.theme().foreground, size * 0.3), + }; + div() + .flex() + .items_center() + .justify_center() + .size(px(size)) + .rounded_full() + .border_1() + .border_color(rgb(0xe4e4e7)) + .bg(rgb(0xf4f4f5)) + .child( + div() + .text_size(px(font)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(color) + .child(glyph), + ) +} + +fn distro_of(host: &Host) -> &'static str { + let os = host.os.as_deref().unwrap_or("").to_lowercase(); + let distro = host.distro_id.as_deref().unwrap_or("").to_lowercase(); + let pretty = host.pretty_name.as_deref().unwrap_or("").to_lowercase(); + if os.contains("win") || distro.contains("win") || pretty.contains("windows") { + return "windows"; + } + if distro.contains("ubuntu") || pretty.contains("ubuntu") { + return "ubuntu"; + } + if distro.contains("debian") || pretty.contains("debian") { + return "debian"; + } + if distro.contains("alpine") || pretty.contains("alpine") { + return "alpine"; + } + "linux" +} + +fn two_letters(name: &str) -> String { + name.chars() + .filter(|ch| ch.is_alphanumeric()) + .take(2) + .collect::() + .to_uppercase() +} diff --git a/gpui-client/src/hosts.rs b/gpui-client/src/hosts.rs deleted file mode 100644 index 53dd77e..0000000 --- a/gpui-client/src/hosts.rs +++ /dev/null @@ -1,212 +0,0 @@ -//! 左侧主机面板: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 { - pub fn new(cx: &mut Context) -> Self { - Self { - focus_handle: cx.focus_handle(), - hosts: Vec::new(), - error: None, - loaded: false, - selected: None, - } - } - - pub fn apply_hosts(&mut self, hosts: Vec, cx: &mut Context) { - self.hosts = hosts; - self.error = None; - self.loaded = true; - cx.notify(); - } - - pub fn apply_error(&mut self, error: String, cx: &mut Context) { - self.error = Some(error); - cx.notify(); - } - - 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 - } else if host.last_error.is_some() { - cx.theme().danger - } else { - cx.theme().muted_foreground - }; - let detail = if host.kind == "local" { - format!("本机 · {}", host.pretty_name.as_deref().unwrap_or("")) - } else { - host.addr.clone() - }; - 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() - .gap_2() - .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() - .flex_1() - .min_w_0() - .gap_0p5() - .child(div().text_sm().truncate().child(host.name.clone())) - .child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .truncate() - .child(detail), - ), - ); - if selected { - row = row.bg(cx.theme().accent); - } - if host.pinned { - row = row.child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child("置顶"), - ); - } - // 注册给无头测试的元素查询(生产构建里是恒等)。 - row.test_support() - } -} - -impl Focusable for HostList { - fn focus_handle(&self, _: &App) -> FocusHandle { - self.focus_handle.clone() - } -} - -impl EventEmitter for HostList {} -impl EventEmitter for HostList {} - -impl BasePanel for HostList { - fn panel_name(&self) -> &'static str { - "hosts" - } -} - -impl Panel for HostList { - fn tab_name(&self, _: &App) -> Option { - Some("主机".into()) - } -} - -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() - .gap_2() - .child( - div() - .text_sm() - .text_color(cx.theme().danger) - .child(error.clone()), - ) - .into_any_element() - } else if !self.loaded { - v_flex() - .p_3() - .child( - div() - .text_sm() - .text_color(cx.theme().muted_foreground) - .child("正在连接守护进程…"), - ) - .into_any_element() - } else { - v_flex() - .p_2() - .gap_0p5() - .children( - self.hosts - .iter() - .map(|host| self.render_host(host, &this, cx)), - ) - .into_any_element() - }; - v_flex() - .size_full() - .bg(cx.theme().background) - .child( - h_flex() - .px_3() - .py_2() - .border_b_1() - .border_color(cx.theme().border) - .child( - div() - .text_sm() - .font_weight(gpui_kit::FontWeight::SEMIBOLD) - .child("VPS Switch"), - ) - .child(div().flex_1()) - .child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(format!("{} 台", self.hosts.len())), - ), - ) - .child( - div() - .id("host-scroll") - .flex_1() - .min_h_0() - .overflow_y_scroll() - .child(body), - ) - } -} diff --git a/gpui-client/src/lib.rs b/gpui-client/src/lib.rs index 502445e..0bea715 100644 --- a/gpui-client/src/lib.rs +++ b/gpui-client/src/lib.rs @@ -3,7 +3,12 @@ //! 二进制入口在 `main.rs`;模块在这里公开,让 `tests/` 能驱动真实面板。 pub mod api; -pub mod hosts; +pub mod detail_drawer; +pub mod host_card; +pub mod settings_page; pub mod terminal; -pub mod terminal_panel; +pub mod terminal_stage; +pub mod theme; +pub mod top_bar; +pub mod util; pub mod workspace; diff --git a/gpui-client/src/main.rs b/gpui-client/src/main.rs index 99058f3..bcb9187 100644 --- a/gpui-client/src/main.rs +++ b/gpui-client/src/main.rs @@ -4,16 +4,18 @@ //! //! 只做一件事:把守护进程(`vps-switch.exe`,axum + SQLite + SSH + MCP)的 //! HTTP/WS API 渲染成原生界面。所有业务在守护进程里,这里没有第二份实现。 +//! 界面按 `docs/ui-spec.md` 一比一复刻旧前端。 use gpui_kit::component::Root; use gpui_kit::*; -use vps_switch_gpui::{api, workspace}; +use vps_switch_gpui::{api, theme, workspace}; fn main() { gpui_kit::application().run(|cx| { // 必须先于任何组件调用。 gpui_kit::init(cx); + theme::install(cx); cx.activate(true); let api = api::Api::spawn(); gpui_kit::open_window(WindowOptions::default(), cx, move |window, cx| { diff --git a/gpui-client/src/settings_page.rs b/gpui-client/src/settings_page.rs new file mode 100644 index 0000000..5e1cbb7 --- /dev/null +++ b/gpui-client/src/settings_page.rs @@ -0,0 +1,101 @@ +//! 设置页(规格 §7)。本轮先立骨架:分类导航 + 返回仓库;各分类内容随后逐块补齐。 + +use gpui_kit::component::*; +use gpui_kit::*; + +use crate::workspace::{Page, Workspace}; + +pub fn render(_workspace: &Workspace, this: &Entity, cx: &App) -> impl IntoElement { + let theme = cx.theme(); + let this_home = this.clone(); + let tabs = ["常规与自启", "访问安全", "MCP Server", "调用记录", "关于与更新"]; + let mut nav = h_flex() + .items_center() + .gap(px(6.)) + .p(px(6.)) + .rounded_2xl() + .border_1() + .border_color(rgb(0xe4e4e7)) + .bg(Hsla::from(rgb(0xffffff)).opacity(0.75)); + for (index, label) in tabs.iter().enumerate() { + let active = index == 0; + let base = h_flex() + .items_center() + .gap(px(8.)) + .rounded_xl() + .px(px(16.)) + .py(px(8.)) + .text_size(px(12.)) + .child(*label); + nav = nav.child(if active { + base.bg(rgb(0x18181b)).text_color(rgb(0xffffff)) + } else { + base.text_color(theme.muted_foreground).bg(rgb(0xfafafa)) + }); + } + + div() + .id("settings-scroll") + .size_full() + .overflow_y_scroll() + .p(px(20.)) + .child( + v_flex() + .max_w(px(896.)) + .mx_auto() + .gap(px(16.)) + .child( + h_flex() + .items_center() + .justify_between() + .child( + v_flex() + .gap(px(2.)) + .child( + div() + .text_size(px(16.)) + .font_weight(FontWeight::BOLD) + .child("系统设置"), + ) + .child( + div() + .text_size(px(12.)) + .text_color(theme.muted_foreground) + .child("管理 MCP Server 远程/本机接入、调用记录与 Web 访问安全"), + ), + ) + .child( + div() + .id("settings-back") + .rounded_xl() + .border_1() + .border_color(rgb(0xe4e4e7)) + .bg(rgb(0xffffff)) + .px(px(14.)) + .py(px(6.)) + .text_size(px(12.)) + .text_color(rgb(0x3f3f46)) + .cursor_pointer() + .on_click(move |_, _, cx| { + let _ = this_home.update(cx, |workspace, cx| { + workspace.page = Page::Workspace; + cx.notify(); + }); + }) + .child("返回仓库"), + ), + ) + .child(nav) + .child( + div() + .rounded_2xl() + .border_1() + .border_color(rgb(0xe4e4e7)) + .bg(Hsla::from(rgb(0xffffff)).opacity(0.85)) + .p(px(20.)) + .text_size(px(12.)) + .text_color(theme.muted_foreground) + .child("设置页内容按 docs/ui-spec.md §7 逐块实现中。"), + ), + ) +} diff --git a/gpui-client/src/terminal/view.rs b/gpui-client/src/terminal/view.rs index b25ebe2..01a72bd 100644 --- a/gpui-client/src/terminal/view.rs +++ b/gpui-client/src/terminal/view.rs @@ -24,18 +24,21 @@ pub struct TerminalView { focus_handle: FocusHandle, /// 会话结束的原因;有值时在底部显示提示条。 status: Option, + /// 白底 / 黑底(与旧前端的 LIGHT_THEME / DARK_THEME 对应)。 + dark: bool, cols: u16, rows: u16, } impl TerminalView { - pub fn new(host_id: String, cx: &mut Context) -> Self { + pub fn new(host_id: String, dark: bool, cx: &mut Context) -> Self { let (cols, rows) = (100, 30); let this = Self { session: Session::spawn(host_id, cols, rows), snapshot: None, focus_handle: cx.focus_handle(), status: None, + dark, cols, rows, }; @@ -43,6 +46,13 @@ impl TerminalView { this } + pub fn set_dark(&mut self, dark: bool, cx: &mut Context) { + if self.dark != dark { + self.dark = dark; + cx.notify(); + } + } + fn poll(cx: &mut Context) { cx.spawn(async move |this, cx| loop { cx.background_executor() @@ -102,9 +112,10 @@ impl Render for TerminalView { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let this = cx.entity(); let focus = self.focus_handle.clone(); + let palette = palette(self.dark); let mut root = div() .size_full() - .bg(terminal_background()) + .bg(Hsla::from(rgb(palette.background))) .track_focus(&focus) .on_key_down(cx.listener(|this, event: &KeyDownEvent, _window, cx| { if let Some(bytes) = encode_key(&event.keystroke) { @@ -240,9 +251,10 @@ impl Element for TerminalElement { .update(cx, |view, _| view.request_size(clamped_cols, clamped_rows)); } - let default_fg: Hsla = terminal_foreground(); - let default_bg: Hsla = terminal_background(); - let palette = ansi_palette(); + let colors = palette(self.view.read(cx).dark); + let default_fg: Hsla = Hsla::from(rgb(colors.foreground)); + let default_bg: Hsla = Hsla::from(rgb(colors.background)); + let palette: [Hsla; 16] = colors.ansi.map(|hex| Hsla::from(rgb(hex))); let rows_to_draw = rows.min(snapshot.rows); let cols_to_draw = cols.min(snapshot.cols); @@ -368,9 +380,9 @@ impl Element for TerminalElement { return state; } CursorShape::Block | CursorShape::HollowBlock | CursorShape::Beam => { - Hsla::from(rgb(0xd8dee9)).opacity(0.7) + Hsla::from(rgb(colors.cursor)).opacity(0.7) } - CursorShape::Underline => Hsla::from(rgb(0xd8dee9)).opacity(0.5), + CursorShape::Underline => Hsla::from(rgb(colors.cursor)).opacity(0.5), }; state.cursor = Some(fill(rect, color)); } @@ -410,27 +422,44 @@ impl Element for TerminalElement { } } -/// 终端底色/前景:固定深色(后续接设置里的独立终端主题)。 -fn terminal_background() -> Hsla { - Hsla::from(rgb(0x16181d)) +/// 终端配色,与旧前端 `TerminalPaneView.vue` 的 LIGHT_THEME / DARK_THEME 对齐。 +struct Palette { + background: u32, + foreground: u32, + cursor: u32, + ansi: [u32; 16], } -fn terminal_foreground() -> Hsla { - Hsla::from(rgb(0xd8dee9)) +fn palette(dark: bool) -> Palette { + if dark { + Palette { + background: 0x0c0d10, + foreground: 0xe4e4e7, + cursor: 0xf4f4f5, + ansi: [ + 0x27272a, 0xf87171, 0x4ade80, 0xfacc15, 0x60a5fa, 0xc084fc, 0x22d3ee, + 0xe4e4e7, 0x71717a, 0xfca5a5, 0x86efac, 0xfde047, 0x93c5fd, 0xd8b4fe, + 0x67e8f9, 0xffffff, + ], + } + } else { + Palette { + background: 0xffffff, + foreground: 0x18181b, + cursor: 0x18181b, + ansi: [ + 0x18181b, 0xdc2626, 0x16a34a, 0xca8a04, 0x2563eb, 0x9333ea, 0x0891b2, + 0xe4e4e7, 0x71717a, 0xef4444, 0x22c55e, 0xeab308, 0x3b82f6, 0xa855f7, + 0x06b6d4, 0xfafafa, + ], + } + } } fn is_default_bg(color: Color) -> bool { matches!(color, Color::Named(NamedColor::Background)) } -fn ansi_palette() -> [Hsla; 16] { - [ - 0x1d1f21, 0xc65d5c, 0x8cc46b, 0xd4b96a, 0x7aa2f7, 0xb48ead, 0x62b3b2, 0xc4c9d1, - 0x585b70, 0xe06866, 0xa3d17a, 0xf0c167, 0x9ab8ff, 0xc79bda, 0x7fc9c8, 0xf2f4f8, - ] - .map(|hex| Hsla::from(rgb(hex))) -} - fn resolve_color(color: Color, default_fg: Hsla, default_bg: Hsla, palette: &[Hsla; 16]) -> Hsla { match color { Color::Named(named) => match named { diff --git a/gpui-client/src/terminal_panel.rs b/gpui-client/src/terminal_panel.rs deleted file mode 100644 index 8fab214..0000000 --- a/gpui-client/src/terminal_panel.rs +++ /dev/null @@ -1,84 +0,0 @@ -//! 中央区的终端标签页:顶栏显示主机,正文是真实的终端视图。 - -use gpui_kit::component::*; -use gpui_kit::component::dock::{BasePanel, Panel, PanelEvent}; -use gpui_kit::*; - -use crate::api::HostSummary; -use crate::terminal::TerminalView; - -pub struct TerminalPanel { - host: HostSummary, - terminal: Entity, -} - -impl TerminalPanel { - pub fn new(host: HostSummary, cx: &mut Context) -> Self { - let terminal = cx.new(|cx| TerminalView::new(host.id.clone(), cx)); - Self { host, terminal } - } - - pub fn host_id(&self) -> &str { - &self.host.id - } -} - -impl Focusable for TerminalPanel { - fn focus_handle(&self, cx: &App) -> FocusHandle { - self.terminal.read(cx).focus_handle(cx) - } -} - -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( - div() - .flex_1() - .min_h_0() - .child(self.terminal.clone()), - ) - } -} diff --git a/gpui-client/src/terminal_stage.rs b/gpui-client/src/terminal_stage.rs new file mode 100644 index 0000000..a74a237 --- /dev/null +++ b/gpui-client/src/terminal_stage.rs @@ -0,0 +1,328 @@ +//! 终端舞台:顶栏(主机信息 + 分屏/主题/右栏/独立窗口/收起)+ 分屏画布。 +//! 规格见 `docs/ui-spec.md` §5。 + +use gpui_kit::component::*; +use gpui_kit::*; + +use crate::top_bar::{IconName, icon, status_dot_color}; +use crate::workspace::{RightWing, SplitDirection, Workspace}; + +pub fn render(workspace: &Workspace, this: &Entity, cx: &App) -> AnyElement { + let Some(tab_index) = workspace.active_tab_index() else { + return div().into_any_element(); + }; + let tab = &workspace.open_tabs[tab_index]; + let Some(host) = workspace.host_by_id(&tab.host_id) else { + return div().into_any_element(); + }; + let theme = cx.theme(); + + // ---- 顶栏左侧 ---- + let address = if host.kind == "local" { + "PowerShell (ConPTY)".to_owned() + } else { + format!("{}@{}:{}", host.username, host.addr, host.port) + }; + let left = h_flex() + .min_w_0() + .items_center() + .gap(px(8.)) + .child( + div() + .size(px(10.)) + .rounded_full() + .bg(status_dot_color(&host.status, cx)), + ) + .child( + div() + .text_size(px(14.)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(theme.foreground) + .truncate() + .child(host.name.clone()), + ) + .child( + div() + .text_size(px(12.)) + .text_color(theme.muted_foreground) + .child(address), + ); + + // ---- 顶栏右侧工具条 ---- + let host_id = host.id.clone(); + let terminal_dark = workspace.terminal_dark; + + let this_split_row = this.clone(); + let this_split_col = this.clone(); + let this_dark = this.clone(); + let this_files = this.clone(); + let this_detail = this.clone(); + let this_popout = this.clone(); + let this_collapse = this.clone(); + let popout_id = host_id.clone(); + + let right = h_flex() + .flex_shrink_0() + .items_center() + .gap(px(6.)) + // 分屏组 + .child( + h_flex() + .items_center() + .gap(px(2.)) + .p(px(2.)) + .rounded_lg() + .border_1() + .border_color(rgb(0xe4e4e7)) + .bg(rgb(0xfafafa)) + .child( + tool_button(cx) + .id("split-row") + .on_click(move |_, _, cx| { + let _ = this_split_row.update(cx, |workspace, cx| { + workspace.split(SplitDirection::Row, cx) + }); + }) + .child(icon(IconName::Columns2, 14., cx)) + .child("左右分屏"), + ) + .child( + tool_button(cx) + .id("split-col") + .on_click(move |_, _, cx| { + let _ = this_split_col.update(cx, |workspace, cx| { + workspace.split(SplitDirection::Col, cx) + }); + }) + .child(icon(IconName::Rows2, 14., cx)) + .child("上下分屏"), + ), + ) + .child( + tool_button(cx) + .id("toggle-terminal-dark") + .on_click(move |_, _, cx| { + let _ = this_dark.update(cx, |workspace, cx| workspace.toggle_terminal_dark(cx)); + }) + .child(if terminal_dark { "白底" } else { "黑底" }), + ) + .child( + wing_button( + cx, + workspace.right_wing == Some(RightWing::Files), + "toggle-right-files", + "文件管理", + IconName::Folder, + move |this, cx| { + let _ = this.update(cx, |workspace, cx| { + workspace.toggle_right_wing(RightWing::Files, cx) + }); + }, + this_files, + ), + ) + .child( + wing_button( + cx, + workspace.right_wing == Some(RightWing::Detail), + "toggle-right-detail", + "详情图表", + IconName::ChartColumnBig, + move |this, cx| { + let _ = this.update(cx, |workspace, cx| { + workspace.toggle_right_wing(RightWing::Detail, cx) + }); + }, + this_detail, + ), + ) + .child( + tool_button(cx) + .id("popout-terminal") + .on_click(move |_, _, cx| { + let id = popout_id.clone(); + let _ = this_popout.update(cx, |workspace, cx| workspace.popout_terminal(&id, cx)); + }) + .child(icon(IconName::ExternalLink, 14., cx)) + .child("独立窗口"), + ) + .child( + div() + .id("collapse-stage") + .flex() + .items_center() + .justify_center() + .size(px(26.)) + .rounded_lg() + .text_color(theme.muted_foreground) + .cursor_pointer() + .hover(|style| style.bg(rgb(0xf1f2f4)).text_color(theme.foreground)) + .on_click(move |_, _, cx| { + let _ = this_collapse.update(cx, |workspace, cx| workspace.close_stage(cx)); + }) + .child(icon(IconName::X, 14., cx)), + ); + + let header = h_flex() + .h(px(42.)) + .flex_shrink_0() + .items_center() + .justify_between() + .gap(px(8.)) + .px(px(16.)) + .py(px(8.)) + .border_b_1() + .border_color(rgb(0xf1f2f4)) + .child(left) + .child(right); + + // ---- 画布 ---- + let canvas = if !tab.panes.is_empty() { + let mut panes = div().flex_1().min_h_0().flex().gap(px(1.)).bg(rgb(0xf1f2f4)); + panes = if tab.split == SplitDirection::Col { + panes.flex_col() + } else { + panes.flex_row() + }; + for (index, pane) in tab.panes.iter().enumerate() { + let focused = tab.focused_pane == Some(index) || tab.panes.len() == 1; + let this_focus = this.clone(); + let tab_index = tab_index; + let mut cell = div() + .id(SharedString::from(format!("pane-cell-{index}"))) + .flex_1() + .min_w_0() + .min_h_0() + .overflow_hidden() + .on_mouse_down(MouseButton::Left, move |_, _, cx| { + let _ = this_focus + .update(cx, |workspace, cx| workspace.focus_pane(tab_index, index, cx)); + }) + .child(pane.clone()); + if focused && tab.panes.len() > 1 { + cell = cell.border_1().border_color(rgb(0xd4d4d8)); + } + panes = panes.child(cell); + } + panes.into_any_element() + } else if let Some(error) = &tab.error { + let this_retry = this.clone(); + let tab_index_retry = tab_index; + v_flex() + .flex_1() + .items_center() + .justify_center() + .gap(px(12.)) + .p(px(24.)) + .child( + div() + .text_size(px(12.)) + .font_weight(FontWeight::MEDIUM) + .text_color(rgb(0xdc2626)) + .child(format!("连接 {} 终端失败", host.name)), + ) + .child( + div() + .max_w(px(460.)) + .text_size(px(12.)) + .text_color(theme.muted_foreground) + .child(error.clone()), + ) + .child( + div() + .id("retry-pane") + .rounded_lg() + .border_1() + .border_color(rgb(0xe4e4e7)) + .bg(rgb(0xffffff)) + .px(px(12.)) + .py(px(6.)) + .text_size(px(12.)) + .cursor_pointer() + .on_click(move |_, _, cx| { + let _ = this_retry.update(cx, |workspace, cx| { + workspace.open_tabs[tab_index_retry].error = None; + workspace.create_pane(tab_index_retry, cx); + }); + }) + .child("重试"), + ) + .into_any_element() + } else { + v_flex() + .flex_1() + .items_center() + .justify_center() + .gap(px(8.)) + .text_size(px(12.)) + .text_color(theme.muted_foreground) + .child(format!("正在连接 {} 终端...", host.name)) + .into_any_element() + }; + + div() + .size_full() + .min_h_0() + .flex() + .flex_col() + .overflow_hidden() + .rounded_2xl() + .border_1() + .border_color(rgb(0xe4e4e7)) + .bg(rgb(0xffffff)) + .shadow_md() + .child(header) + .child(canvas) + .into_any_element() +} + +fn tool_button(cx: &App) -> Div { + let _ = cx; + div() + .flex() + .items_center() + .gap(px(6.)) + .h(px(26.)) + .px(px(8.)) + .rounded_lg() + .text_size(px(11.)) + .text_color(rgb(0x52525b)) + .cursor_pointer() + .hover(|style| style.bg(rgb(0xf4f4f5)).text_color(rgb(0x18181b))) +} + +fn wing_button( + cx: &App, + active: bool, + id: &'static str, + label: &'static str, + icon_name: IconName, + on_click: impl Fn(&Entity, &mut App) + 'static, + this: Entity, +) -> impl IntoElement { + let theme = cx.theme(); + let base = div() + .id(id) + .flex() + .items_center() + .gap(px(6.)) + .h(px(26.)) + .px(px(8.)) + .rounded_lg() + .border_1() + .text_size(px(11.)) + .cursor_pointer() + .on_click(move |_, _, cx| on_click(&this, cx)) + .child(icon(icon_name, 14., cx)) + .child(label); + if active { + base.border_color(rgb(0x18181b)) + .bg(rgb(0x18181b)) + .text_color(rgb(0xffffff)) + } else { + base.border_color(rgb(0xe4e4e7)) + .bg(rgb(0xffffff)) + .text_color(theme.muted_foreground) + .hover(|style| style.bg(rgb(0xf4f4f5))) + } +} diff --git a/gpui-client/src/theme.rs b/gpui-client/src/theme.rs new file mode 100644 index 0000000..a2e9d38 --- /dev/null +++ b/gpui-client/src/theme.rs @@ -0,0 +1,19 @@ +//! 应用主题:把 gpui-kit 的浅色主题换成 vps-switch 的白色毛玻璃配色。 +//! +//! 色板在 `assets/theme-light.json`(由内置 Default Light 派生,配色见 +//! `docs/ui-spec.md` §1)。GPUI 没有背景模糊,毛玻璃观感靠半透明白 + 细边框 +//! + 阴影近似;真正的模糊留给后续增强。 + +use std::rc::Rc; + +use gpui_kit::component::{Theme, ThemeConfig, ThemeMode}; +use gpui_kit::*; + +pub fn install(cx: &mut App) { + // 首次调用会创建全局 Theme 并装载默认的亮/暗主题。 + Theme::change(ThemeMode::Light, None, cx); + let config: ThemeConfig = + serde_json::from_str(include_str!("../assets/theme-light.json")).expect("theme json"); + cx.global_mut::().light_theme = Rc::new(config); + Theme::change(ThemeMode::Light, None, cx); +} diff --git a/gpui-client/src/top_bar.rs b/gpui-client/src/top_bar.rs new file mode 100644 index 0000000..5d6d6df --- /dev/null +++ b/gpui-client/src/top_bar.rs @@ -0,0 +1,394 @@ +//! 顶栏:品牌 + 仓库/侧栏按钮 + 会话标签 + 搜索 + 筛选 + 检测 + 主题/设置/新增。 +//! 规格见 `docs/ui-spec.md` §3。 + +use gpui_kit::component::input::Input; +use gpui_kit::component::*; +use gpui_kit::*; + +pub use gpui_kit::assets::IconName; + +use crate::workspace::{OpenTab, Page, Workspace}; + +pub fn render(workspace: &Workspace, this: &Entity, cx: &App) -> impl IntoElement { + let has_stage = workspace.has_stage(); + let theme = cx.theme(); + + // ---- 左组 ---- + let mut left = h_flex() + .min_w_0() + .flex_1() + .items_center() + .gap(px(6.)); + + left = left.child( + h_flex() + .flex_shrink_0() + .items_center() + .gap(px(8.)) + .mr(px(4.)) + .child(brand_logo(cx)) + .child( + div() + .text_size(px(14.)) + .font_weight(FontWeight::BOLD) + .text_color(theme.foreground) + .child("VPS Switch"), + ), + ); + + // 「仓库」按钮 + let home_active = workspace.page == Page::Workspace && !has_stage; + let this_home = this.clone(); + left = left.child( + pill(home_active, cx) + .id("nav-home") + .on_click(move |_, _, cx| { + let _ = this_home.update(cx, |workspace, cx| workspace.close_stage(cx)); + }) + .child(icon(IconName::House, 14., cx)) + .child("仓库") + .child(count_badge( + format!( + "{}/{}", + workspace.online_count(), + workspace.total_active_count() + ), + cx, + )), + ); + + // 「侧栏」切换(仅有舞台时) + if has_stage { + let this_side = this.clone(); + left = left.child( + pill(workspace.show_sidebar_in_stage, cx) + .id("nav-sidebar") + .on_click(move |_, _, cx| { + let _ = this_side.update(cx, |workspace, cx| workspace.toggle_sidebar(cx)); + }) + .child(icon(IconName::PanelLeft, 14., cx)) + .child(if workspace.show_sidebar_in_stage { + "收起侧栏" + } else { + "展开侧栏" + }), + ); + } + + // 已打开的会话标签 + for tab in &workspace.open_tabs { + left = left.child(session_tab(workspace, tab, this, cx)); + } + + // ---- 右组 ---- + let mut right = h_flex() + .flex_shrink_0() + .items_center() + .gap(px(8.)) + // 搜索 + .child( + div() + .w(px(190.)) + .child(Input::new(&workspace.search)), + ) + // 筛选按钮 + .child({ + let active = workspace.has_active_filter(); + let this_filter = this.clone(); + let label = filter_summary(workspace); + pill(active, cx) + .id("nav-filter") + .on_click(move |_, _, cx| { + let _ = this_filter.update(cx, |workspace, cx| { + workspace.filter_open = !workspace.filter_open; + cx.notify(); + }); + }) + .child(icon(IconName::Funnel, 14., cx)) + .child(label) + }); + + if workspace.page == Page::Workspace { + let checking = workspace.checking_all; + let this_check = this.clone(); + right = right.child( + pill(false, cx) + .id("nav-check") + .on_click(move |_, _, cx| { + let _ = this_check.update(cx, |workspace, cx| workspace.check_all(cx)); + }) + .child(icon(IconName::RefreshCw, 14., cx)) + .child(if checking { "检测中" } else { "检测在线" }), + ); + } + + // 主题切换 + let this_theme = this.clone(); + let is_dark = workspace.is_dark; + right = right.child( + round_button(cx) + .id("nav-theme") + .on_click(move |_, _, cx| { + let _ = this_theme.update(cx, |workspace, cx| workspace.toggle_theme(cx)); + }) + .child(icon( + if is_dark { + IconName::Sun + } else { + IconName::Moon + }, + 16., + cx, + )), + ); + + // 设置 + let this_settings = this.clone(); + let settings_active = workspace.page == Page::Settings; + right = right.child( + if settings_active { + round_button_active(cx) + } else { + round_button(cx) + } + .id("nav-settings") + .on_click(move |_, _, cx| { + let _ = this_settings.update(cx, |workspace, cx| workspace.open_settings(cx)); + }) + .child(icon(IconName::Settings, 16., cx)), + ); + + // 新增 VPS(橙色) + right = right.child( + div() + .id("nav-add") + .flex() + .items_center() + .justify_center() + .size(px(32.)) + .rounded_full() + .bg(rgb(0xf97316)) + .text_color(rgb(0xffffff)) + .hover(|style| style.bg(rgb(0xea580c))) + .child(icon(IconName::Plus, 16., cx)), + ); + + h_flex() + .h(px(52.)) + .flex_shrink_0() + .items_center() + .justify_between() + .gap(px(12.)) + .px(px(16.)) + .py(px(8.)) + .border_b_1() + .border_color(rgb(0xe4e4e7)) + .bg(rgb(0xfafafa)) + .child(left) + .child(right) +} + +fn session_tab( + workspace: &Workspace, + tab: &OpenTab, + this: &Entity, + cx: &App, +) -> impl IntoElement { + let theme = cx.theme(); + let host = workspace.host_by_id(&tab.host_id); + let name = host.map(|h| h.name.clone()).unwrap_or_else(|| tab.host_id.clone()); + let status = host.map(|h| h.status.clone()).unwrap_or_else(|| "unknown".into()); + let active = workspace.page == Page::Workspace + && workspace.has_stage() + && workspace.stage_host_id.as_deref() == Some(tab.host_id.as_str()); + let pane_count = tab.panes.len(); + let host_id = tab.host_id.clone(); + + let this_activate = this.clone(); + let this_close = this.clone(); + let close_id = host_id.clone(); + + let base = h_flex() + .id(SharedString::from(format!("session-{}", host_id))) + .flex_shrink_0() + .items_center() + .gap(px(8.)) + .h(px(30.)) + .px(px(12.)) + .rounded_lg() + .border_1() + .text_size(px(12.)) + .cursor_pointer() + .on_click(move |_, _, cx| { + let id = host_id.clone(); + let _ = this_activate.update(cx, |workspace, cx| workspace.activate_tab(&id, cx)); + }) + .child( + div() + .size(px(8.)) + .rounded_full() + .bg(status_dot_color(&status, cx)), + ) + .child( + div() + .max_w(px(128.)) + .truncate() + .child(name), + ); + let base = if pane_count > 1 { + base.child(count_badge(format!("×{pane_count}"), cx)) + } else { + base + }; + let styled = if active { + base.border_color(theme.border) + .bg(rgb(0xffffff)) + .text_color(theme.foreground) + .shadow_sm() + } else { + base.border_color(Hsla::transparent_black()) + .bg(rgb(0xf1f2f4)) + .text_color(theme.muted_foreground) + }; + styled.child( + div() + .id(SharedString::from(format!("session-close-{}", close_id))) + .flex() + .items_center() + .justify_center() + .size(px(16.)) + .rounded_sm() + .text_color(theme.muted_foreground) + .hover(|style| style.bg(rgb(0xe4e4e7)).text_color(theme.foreground)) + .on_click(move |_, _, cx| { + let id = close_id.clone(); + let _ = this_close.update(cx, |workspace, cx| workspace.close_tab(&id, cx)); + cx.stop_propagation(); + }) + .child(icon(IconName::X, 12., cx)), + ) +} + +pub fn status_dot_color(status: &str, cx: &App) -> Hsla { + match status { + "online" => Hsla::from(rgb(0x10b981)), + "offline" => Hsla::from(rgb(0xef4444)), + _ => cx.theme().muted_foreground, + } +} + +/// 胶囊按钮的公共样式。 +pub fn pill(active: bool, cx: &App) -> Div { + let theme = cx.theme(); + let base = div() + .flex() + .items_center() + .gap(px(6.)) + .h(px(30.)) + .px(px(12.)) + .rounded_lg() + .border_1() + .text_size(px(12.)) + .cursor_pointer(); + if active { + base.border_color(theme.border) + .bg(rgb(0xffffff)) + .text_color(theme.foreground) + .shadow_sm() + } else { + base.border_color(Hsla::transparent_black()) + .text_color(theme.muted_foreground) + .hover(|style| style.bg(rgb(0xffffff))) + } +} + +fn round_button(cx: &App) -> Div { + let theme = cx.theme(); + div() + .flex() + .items_center() + .justify_center() + .size(px(32.)) + .rounded_full() + .border_1() + .border_color(theme.border) + .bg(rgb(0xffffff)) + .text_color(theme.muted_foreground) + .cursor_pointer() + .hover(|style| style.text_color(theme.foreground)) +} + +fn round_button_active(_cx: &App) -> Div { + div() + .flex() + .items_center() + .justify_center() + .size(px(32.)) + .rounded_full() + .border_1() + .border_color(rgb(0x18181b)) + .bg(rgb(0x18181b)) + .text_color(rgb(0xffffff)) + .cursor_pointer() +} + +fn count_badge(text: String, _cx: &App) -> impl IntoElement { + div() + .rounded_full() + .bg(rgb(0xf1f2f4)) + .px(px(6.)) + .py(px(1.)) + .text_size(px(10.)) + .text_color(rgb(0x71717a)) + .child(text) +} + +fn filter_summary(workspace: &Workspace) -> String { + let mut parts: Vec = Vec::new(); + if workspace.favorite_only { + parts.push("★ 收藏".into()); + } + match workspace.status_filter { + crate::workspace::StatusFilter::Online => parts.push("在线".into()), + crate::workspace::StatusFilter::Offline => parts.push("离线".into()), + crate::workspace::StatusFilter::All => {} + } + match workspace.role_filter { + crate::workspace::RoleFilter::Primary => parts.push("主力".into()), + crate::workspace::RoleFilter::Proxy => parts.push("代理".into()), + crate::workspace::RoleFilter::Test => parts.push("测试".into()), + crate::workspace::RoleFilter::Archived => parts.push("归档".into()), + crate::workspace::RoleFilter::All => {} + } + if !workspace.tag_filter.is_empty() { + parts.push(format!("#{}", workspace.tag_filter)); + } + if parts.is_empty() { + "筛选".into() + } else { + format!("筛选 · {}", parts.join(" · ")) + } +} + +/// 图标:颜色与字号继承外层文本样式。 +pub fn icon(name: IconName, size: f32, _cx: &App) -> impl IntoElement { + div().text_size(px(size)).child(Icon::new(name)) +} + +/// 品牌图标:纯黑圆角方块 + 白色滑块符号(旧 `BrandLogo` 的简化等价物)。 +fn brand_logo(_cx: &App) -> impl IntoElement { + div() + .flex() + .items_center() + .justify_center() + .size(px(24.)) + .rounded_md() + .bg(rgb(0x111113)) + .child( + div() + .text_size(px(10.)) + .font_weight(FontWeight::BOLD) + .text_color(rgb(0xffffff)) + .child("◧"), + ) +} diff --git a/gpui-client/src/util.rs b/gpui-client/src/util.rs new file mode 100644 index 0000000..6dcafc5 --- /dev/null +++ b/gpui-client/src/util.rs @@ -0,0 +1,41 @@ +//! 与旧前端一致的数值格式化。 + +pub fn format_bytes(bytes: f64) -> String { + if bytes <= 0.0 { + return "0 B".into(); + } + const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"]; + let mut index = (bytes.ln() / 1024f64.ln()).floor() as usize; + index = index.min(UNITS.len() - 1); + let value = bytes / 1024f64.powi(index as i32); + if value >= 100.0 || index == 0 { + format!("{value:.0} {}", UNITS[index]) + } else { + format!("{value:.1} {}", UNITS[index]) + } +} + +pub fn format_rate(bps: f64) -> String { + if bps <= 0.0 { + "0 B/s".into() + } else { + format!("{}/s", format_bytes(bps)) + } +} + +pub fn format_uptime(seconds: f64) -> String { + if seconds <= 0.0 { + return "--".into(); + } + let secs = seconds as u64; + let days = secs / 86400; + let hours = (secs % 86400) / 3600; + let minutes = (secs % 3600) / 60; + if days > 0 { + format!("{days}天 {hours}小时") + } else if hours > 0 { + format!("{hours}小时 {minutes}分") + } else { + format!("{minutes}分钟") + } +} diff --git a/gpui-client/src/workspace.rs b/gpui-client/src/workspace.rs index e823e42..73d5f19 100644 --- a/gpui-client/src/workspace.rs +++ b/gpui-client/src/workspace.rs @@ -1,119 +1,843 @@ -//! 工作区:Dock 布局的宿主,持有左侧主机面板、中央终端标签与 API 事件轮询。 +//! 工作区:旧前端 `App.vue` 的 GPUI 对应物。 +//! +//! 状态(主机、筛选、标签、舞台、右栏、主题、Toast、探针)全部集中在这里; +//! 各视觉组件(顶栏、卡片、舞台、抽屉)是读取本实体状态的渲染函数。 +//! 布局与视觉规格见 `docs/ui-spec.md`。 -use std::time::Duration; +use std::collections::{HashMap, HashSet}; +use std::time::{Duration, Instant}; -use gpui_kit::component::dock::{DockArea, DockLayout, DockPlacement, DockSkin, panel_handle}; +use gpui_kit::component::input::InputState; +use gpui_kit::component::*; use gpui_kit::*; -use crate::api::{Api, ApiEvent, HostSummary}; -use crate::hosts::{HostList, HostListEvent}; -use crate::terminal_panel::TerminalPanel; +use crate::api::{Api, ApiCommand, ApiEvent, Host, Inspection, NetHistory}; +use crate::terminal::TerminalView; +use crate::{detail_drawer, host_card, terminal_stage, top_bar}; + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum Page { + Workspace, + Settings, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum CenterStage { + Terminal, + Files, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum RightWing { + Detail, + Files, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum SplitDirection { + Row, + Col, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum StatusFilter { + All, + Online, + Offline, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum RoleFilter { + All, + Primary, + Proxy, + Test, + Archived, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum ToastKind { + Ok, + Err, +} + +pub struct Toast { + pub text: String, + pub kind: ToastKind, + pub at: Instant, +} + +pub struct OpenTab { + pub host_id: String, + pub mode: CenterStage, + pub right_wing: Option, + pub split: SplitDirection, + pub panes: Vec>, + pub focused_pane: Option, + pub creating: bool, + pub error: Option, +} pub struct Workspace { - dock_area: Entity, - host_list: Entity, - terminals: Vec>, api: Api, + pub hosts: Vec, + pub loading: bool, + pub checking_all: bool, + pub checking: HashSet, + pub open_tabs: Vec, + pub stage_host_id: Option, + pub center_stage: Option, + pub detail_host_id: Option, + pub right_wing: Option, + pub show_sidebar_in_stage: bool, + + pub search: Entity, + pub search_query: String, + pub filter_open: bool, + pub status_filter: StatusFilter, + pub favorite_only: bool, + pub role_filter: RoleFilter, + pub tag_filter: String, + + pub is_dark: bool, + pub terminal_dark: bool, + pub page: Page, + + pub toast: Option, + + pub inspections: HashMap, + pub net_history: NetHistory, + pub inspecting_host: Option, + last_inspect: Option, } impl Workspace { pub fn new(api: Api, window: &mut Window, cx: &mut Context) -> Self { - let host_list = cx.new(HostList::new); - let dock_area = cx.new(|cx| { - DockArea::new("main", Some(1), window, cx).with_renderer(DockSkin::new(cx)) + let search = cx.new(|cx| { + InputState::new(window, cx).placeholder("搜索名称 / IP / 标签") }); - dock_area.update(cx, |area, cx| { - area.set_dock( - DockPlacement::Left, - DockLayout::tabs().panel_view(panel_handle(host_list.clone()), cx), - window, - cx, - ); - area.set_dock_size(DockPlacement::Left, px(280.), window, cx); - area.set_dock_collapsible(DockPlacement::Left, true, window, cx); - }); - // 焦点先落在主机面板:窗口级 action 需要一条焦点路径。 - let focus = host_list.read(cx).focus_handle(cx); - window.focus(&focus, cx); - - api.load_hosts(); - let this = Self { - dock_area, - 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), - }, - ) + cx.observe(&search, |this, state, cx| { + this.search_query = state.read(cx).value().to_string(); + cx.notify(); + }) .detach(); - Self::poll_events(cx); - this + + api.send(ApiCommand::LoadHosts); + Self::poll(cx); + Self { + api, + hosts: Vec::new(), + loading: true, + checking_all: false, + checking: HashSet::new(), + open_tabs: Vec::new(), + stage_host_id: None, + center_stage: None, + detail_host_id: None, + right_wing: None, + show_sidebar_in_stage: false, + search, + search_query: String::new(), + filter_open: false, + status_filter: StatusFilter::All, + favorite_only: false, + role_filter: RoleFilter::All, + tag_filter: String::new(), + is_dark: false, + terminal_dark: false, + page: Page::Workspace, + toast: None, + inspections: HashMap::new(), + net_history: NetHistory::new(), + inspecting_host: None, + last_inspect: None, + } } - /// 打开(或聚焦)一台主机的终端标签。 - fn open_terminal(&mut self, host: HostSummary, window: &mut Window, cx: &mut Context) { - if self - .terminals + // ---------- 查询 ---------- + + pub fn host_by_id(&self, id: &str) -> Option<&Host> { + self.hosts.iter().find(|host| host.id == id) + } + + pub fn has_stage(&self) -> bool { + self.center_stage.is_some() && self.stage_host_id.is_some() + } + + pub fn stage_host(&self) -> Option<&Host> { + self.stage_host_id.as_deref().and_then(|id| self.host_by_id(id)) + } + + pub fn right_wing_host(&self) -> Option<&Host> { + self.detail_host_id + .as_deref() + .and_then(|id| self.host_by_id(id)) + .or_else(|| self.stage_host()) + } + + pub fn active_tab(&self) -> Option<&OpenTab> { + let id = self.stage_host_id.as_deref()?; + self.open_tabs.iter().find(|tab| tab.host_id == id) + } + + pub fn active_tab_index(&self) -> Option { + let id = self.stage_host_id.as_deref()?; + self.open_tabs.iter().position(|tab| tab.host_id == id) + } + + pub fn online_count(&self) -> usize { + self.hosts .iter() - .any(|panel| panel.read(cx).host_id() == host.id) + .filter(|host| !host.archived && host.status == "online") + .count() + } + + pub fn offline_count(&self) -> usize { + self.hosts + .iter() + .filter(|host| !host.archived && host.status == "offline") + .count() + } + + pub fn total_active_count(&self) -> usize { + self.hosts.iter().filter(|host| !host.archived).count() + } + + pub fn favorite_count(&self) -> usize { + self.hosts + .iter() + .filter(|host| !host.archived && host.favorited) + .count() + } + + pub fn all_tags(&self) -> Vec { + let mut tags: Vec = self + .hosts + .iter() + .filter(|host| !host.archived || self.role_filter == RoleFilter::Archived) + .flat_map(|host| host.tags.clone()) + .collect(); + tags.sort(); + tags.dedup(); + tags + } + + pub fn has_active_filter(&self) -> bool { + self.status_filter != StatusFilter::All + || self.favorite_only + || self.role_filter != RoleFilter::All + || !self.tag_filter.is_empty() + } + + pub fn filtered_hosts(&self) -> Vec<&Host> { + let query = self.search_query.trim().to_lowercase(); + self.hosts + .iter() + .filter(|host| { + if self.role_filter == RoleFilter::Archived { + if !host.archived { + return false; + } + } else { + if host.archived { + return false; + } + let role_ok = match self.role_filter { + RoleFilter::All => true, + RoleFilter::Primary => host.role.as_deref() == Some("primary"), + RoleFilter::Proxy => host.role.as_deref() == Some("proxy"), + RoleFilter::Test => host.role.as_deref() == Some("test"), + RoleFilter::Archived => true, + }; + if !role_ok { + return false; + } + } + if self.favorite_only && !host.favorited { + return false; + } + match self.status_filter { + StatusFilter::All => {} + StatusFilter::Online if host.status != "online" => return false, + StatusFilter::Offline if host.status != "offline" => return false, + _ => {} + } + if !self.tag_filter.is_empty() && !host.tags.contains(&self.tag_filter) { + return false; + } + if query.is_empty() { + return true; + } + let haystack = format!( + "{} {} {} {} {} {}", + host.name, + host.addr, + host.port, + host.username, + host.pretty_name.as_deref().unwrap_or(""), + host.tags.join(" ") + ) + .to_lowercase(); + haystack.contains(&query) + }) + .collect() + } + + // ---------- 交互 ---------- + + pub fn show_toast(&mut self, text: impl Into, kind: ToastKind, cx: &mut Context) { + self.toast = Some(Toast { + text: text.into(), + kind, + at: Instant::now(), + }); + cx.notify(); + } + + fn replace_host(&mut self, updated: Host) { + match self.hosts.iter_mut().find(|host| host.id == updated.id) { + Some(existing) => *existing = updated, + None => self.hosts.push(updated), + } + } + + pub fn open_terminal(&mut self, host_id: &str, cx: &mut Context) { + self.page = Page::Workspace; + self.sync_current_tab(cx); + let index = match self.open_tabs.iter().position(|tab| tab.host_id == host_id) { + Some(index) => { + self.open_tabs[index].mode = CenterStage::Terminal; + index + } + None => { + let restore_detail = self.right_wing == Some(RightWing::Detail) + && self.detail_host_id.as_deref() == Some(host_id); + self.open_tabs.push(OpenTab { + host_id: host_id.to_owned(), + mode: CenterStage::Terminal, + right_wing: restore_detail.then_some(RightWing::Detail), + split: SplitDirection::Row, + panes: Vec::new(), + focused_pane: None, + creating: false, + error: None, + }); + self.open_tabs.len() - 1 + } + }; + self.stage_host_id = Some(host_id.to_owned()); + self.center_stage = Some(CenterStage::Terminal); + self.detail_host_id = Some(host_id.to_owned()); + self.right_wing = self.open_tabs[index].right_wing; + if self.open_tabs[index].panes.is_empty() { + self.create_pane(index, cx); + } + cx.notify(); + } + + pub fn open_files(&mut self, host_id: &str, cx: &mut Context) { + self.page = Page::Workspace; + self.sync_current_tab(cx); + match self.open_tabs.iter().position(|tab| tab.host_id == host_id) { + Some(index) => { + self.open_tabs[index].mode = CenterStage::Files; + if self.open_tabs[index].right_wing == Some(RightWing::Files) { + self.open_tabs[index].right_wing = None; + } + } + None => { + self.open_tabs.push(OpenTab { + host_id: host_id.to_owned(), + mode: CenterStage::Files, + right_wing: None, + split: SplitDirection::Row, + panes: Vec::new(), + focused_pane: None, + creating: false, + error: None, + }); + } + } + self.stage_host_id = Some(host_id.to_owned()); + self.center_stage = Some(CenterStage::Files); + self.detail_host_id = Some(host_id.to_owned()); + if self.right_wing == Some(RightWing::Files) { + self.right_wing = None; + } + cx.notify(); + } + + pub fn toggle_detail(&mut self, host_id: &str, cx: &mut Context) { + if self.right_wing == Some(RightWing::Detail) + && self.detail_host_id.as_deref() == Some(host_id) + && !self.has_stage_card_selected(host_id) { + self.right_wing = None; + } else if self.right_wing == Some(RightWing::Detail) + && self.detail_host_id.as_deref() == Some(host_id) + { + self.right_wing = None; + } else { + self.detail_host_id = Some(host_id.to_owned()); + self.right_wing = Some(RightWing::Detail); + self.last_inspect = None; + } + self.sync_current_tab(cx); + cx.notify(); + } + + fn has_stage_card_selected(&self, host_id: &str) -> bool { + self.has_stage() && self.stage_host_id.as_deref() == Some(host_id) + } + + pub fn toggle_right_wing(&mut self, mode: RightWing, cx: &mut Context) { + let Some(stage_id) = self.stage_host_id.clone() else { + return; + }; + if self.right_wing == Some(mode) && self.detail_host_id.as_deref() == Some(&stage_id) { + self.right_wing = None; + } else { + self.detail_host_id = Some(stage_id.clone()); + self.right_wing = Some(mode); + if mode == RightWing::Detail { + self.last_inspect = None; + } + } + self.sync_current_tab(cx); + cx.notify(); + } + + fn sync_current_tab(&mut self, _cx: &mut Context) { + if let Some(tab) = self.active_tab_index() + && let Some(mode) = self.center_stage + { + self.open_tabs[tab].mode = mode; + self.open_tabs[tab].right_wing = self.right_wing; + } + } + + pub fn close_stage(&mut self, cx: &mut Context) { + self.sync_current_tab(cx); + self.center_stage = None; + self.stage_host_id = None; + if self.right_wing == Some(RightWing::Files) { + self.right_wing = None; + } + cx.notify(); + } + + pub fn activate_tab(&mut self, host_id: &str, cx: &mut Context) { + self.page = Page::Workspace; + self.sync_current_tab(cx); + let Some(tab) = self.open_tabs.iter().find(|tab| tab.host_id == host_id) else { + return; + }; + let mode = tab.mode; + let right_wing = tab.right_wing; + self.stage_host_id = Some(host_id.to_owned()); + self.center_stage = Some(mode); + self.detail_host_id = Some(host_id.to_owned()); + self.right_wing = right_wing; + if mode == CenterStage::Terminal { + if let Some(index) = self.active_tab_index() + && self.open_tabs[index].panes.is_empty() + { + self.create_pane(index, cx); + } + } + cx.notify(); + } + + pub fn close_tab(&mut self, host_id: &str, cx: &mut Context) { + let Some(index) = self + .open_tabs + .iter() + .position(|tab| tab.host_id == host_id) + else { + return; + }; + self.open_tabs.remove(index); + if self.stage_host_id.as_deref() == Some(host_id) { + match self.open_tabs.last() { + Some(last) => { + let last_id = last.host_id.clone(); + self.activate_tab(&last_id, cx); + } + None => self.close_stage(cx), + } + } + cx.notify(); + } + + pub fn toggle_sidebar(&mut self, cx: &mut Context) { + self.show_sidebar_in_stage = !self.show_sidebar_in_stage; + cx.notify(); + } + + /// 把当前主机的终端弹到独立系统窗口(旧前端的「独立窗口」按钮)。 + pub fn popout_terminal(&mut self, host_id: &str, cx: &mut Context) { + self.api.send(ApiCommand::Post { + path: "/api/window/open-terminal".into(), + body: serde_json::json!({ "hostId": host_id }), + }); + let name = self + .host_by_id(host_id) + .map(|host| host.name.clone()) + .unwrap_or_else(|| host_id.to_owned()); + self.show_toast( + format!("已在独立原生窗口打开「{name}」终端"), + ToastKind::Ok, + cx, + ); + } + + pub fn toggle_terminal_dark(&mut self, cx: &mut Context) { + self.terminal_dark = !self.terminal_dark; + let dark = self.terminal_dark; + for tab in &self.open_tabs { + for pane in &tab.panes { + pane.update(cx, |view, cx| view.set_dark(dark, cx)); + } + } + cx.notify(); + } + + pub fn toggle_theme(&mut self, cx: &mut Context) { + self.is_dark = !self.is_dark; + self.terminal_dark = self.is_dark; + let mode = if self.is_dark { + ThemeMode::Dark + } else { + ThemeMode::Light + }; + Theme::change(mode, None, cx); + let dark = self.terminal_dark; + for tab in &self.open_tabs { + for pane in &tab.panes { + pane.update(cx, |view, cx| view.set_dark(dark, cx)); + } + } + cx.notify(); + } + + pub fn open_settings(&mut self, cx: &mut Context) { + self.page = if self.page == Page::Settings { + Page::Workspace + } else { + Page::Settings + }; + cx.notify(); + } + + pub fn refresh_hosts(&mut self, cx: &mut Context) { + self.api.send(ApiCommand::LoadHosts); + cx.notify(); + } + + /// 立即采样一次探针(详情抽屉的「刷新」)。 + pub fn inspect_now(&mut self, host_id: &str, cx: &mut Context) { + self.inspecting_host = Some(host_id.to_owned()); + self.last_inspect = Some(Instant::now()); + self.api.send(ApiCommand::Inspect(host_id.to_owned())); + cx.notify(); + } + + pub fn check_all(&mut self, cx: &mut Context) { + if self.checking_all { 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); + self.checking_all = true; + self.show_toast("正在并发探索全部主机在线状态与实时硬件快照...", ToastKind::Ok, cx); + self.api.send(ApiCommand::CheckAll); + cx.notify(); } - /// 中央区按当前标签集合重建(与 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); + pub fn patch_meta(&mut self, host_id: &str, patch: serde_json::Value, cx: &mut Context) { + self.api.send(ApiCommand::PatchMeta { + host_id: host_id.to_owned(), + patch, }); + cx.notify(); } - /// 每 100ms drain 一次网络事件。后台线程不直接碰 GPUI executor。 - fn poll_events(cx: &mut Context) { - cx.spawn(async move |this, cx| { - loop { - cx.background_executor() - .timer(Duration::from_millis(100)) - .await; - let drained = this.update(cx, |this, cx| { - while let Some(event) = this.api.try_recv() { - match event { - ApiEvent::Hosts(hosts) => { - this.host_list.update(cx, |list, cx| list.apply_hosts(hosts, cx)); - } - ApiEvent::Failed(error) => { - this.host_list.update(cx, |list, cx| list.apply_error(error, cx)); - } - } - } - }); - if drained.is_err() { - // 窗口已关,轮询随之结束。 - break; - } + pub fn toggle_favorite(&mut self, host_id: &str, cx: &mut Context) { + let Some(host) = self.host_by_id(host_id) else { + return; + }; + let favorited = !host.favorited; + self.patch_meta(host_id, serde_json::json!({"favorited": favorited}), cx); + } + + pub fn toggle_pinned(&mut self, host_id: &str, cx: &mut Context) { + let Some(host) = self.host_by_id(host_id) else { + return; + }; + let pinned = !host.pinned; + self.patch_meta(host_id, serde_json::json!({"pinned": pinned}), cx); + } + + pub fn create_pane(&mut self, tab_index: usize, cx: &mut Context) { + let Some(tab) = self.open_tabs.get(tab_index) else { + return; + }; + if tab.creating { + return; + } + let host_id = tab.host_id.clone(); + let dark = self.terminal_dark; + let pane = cx.new(|cx| TerminalView::new(host_id, dark, cx)); + if let Some(tab) = self.open_tabs.get_mut(tab_index) { + tab.panes.push(pane); + tab.focused_pane = Some(tab.panes.len() - 1); + tab.creating = false; + tab.error = None; + } + cx.notify(); + } + + pub fn split(&mut self, direction: SplitDirection, cx: &mut Context) { + let Some(index) = self.active_tab_index() else { + return; + }; + self.open_tabs[index].split = direction; + self.create_pane(index, cx); + } + + pub fn close_pane(&mut self, tab_index: usize, pane_index: usize, cx: &mut Context) { + let Some(tab) = self.open_tabs.get_mut(tab_index) else { + return; + }; + if pane_index >= tab.panes.len() { + return; + } + tab.panes.remove(pane_index); + tab.focused_pane = if tab.panes.is_empty() { + None + } else { + Some(pane_index.min(tab.panes.len() - 1)) + }; + if tab.panes.is_empty() { + let host_id = tab.host_id.clone(); + self.close_tab(&host_id, cx); + } else { + cx.notify(); + } + } + + pub fn focus_pane(&mut self, tab_index: usize, pane_index: usize, cx: &mut Context) { + if let Some(tab) = self.open_tabs.get_mut(tab_index) { + tab.focused_pane = Some(pane_index); + cx.notify(); + } + } + + // ---------- 事件轮询 ---------- + + fn poll(cx: &mut Context) { + cx.spawn(async move |this, cx| loop { + cx.background_executor() + .timer(Duration::from_millis(100)) + .await; + let alive = this.update(cx, |this, cx| this.tick(cx)); + if alive.is_err() { + break; } }) .detach(); } + + fn tick(&mut self, cx: &mut Context) { + let mut changed = false; + for event in self.api.try_recv() { + changed = true; + match event { + ApiEvent::Hosts(hosts) => { + self.hosts = hosts; + self.loading = false; + } + ApiEvent::HostsFailed(error) => { + self.loading = false; + self.toast = Some(Toast { + text: format!("读取主机列表失败: {error}"), + kind: ToastKind::Err, + at: Instant::now(), + }); + } + ApiEvent::Inspection { + host_id, + inspection, + host, + } => { + let history = self.net_history.entry(host_id.clone()).or_default(); + history.push((inspection.net_rx_bps, inspection.net_tx_bps)); + if history.len() > 20 { + history.remove(0); + } + self.inspections.insert(host_id, inspection); + if let Some(host) = host { + self.replace_host(host); + } + self.inspecting_host = None; + } + ApiEvent::CheckedAll(hosts) => { + let online = hosts + .iter() + .filter(|host| !host.archived && host.status == "online") + .count(); + self.hosts = hosts; + self.checking_all = false; + self.toast = Some(Toast { + text: format!("机队快照同步完成:{online} 台主机在线"), + kind: ToastKind::Ok, + at: Instant::now(), + }); + } + ApiEvent::HostUpdated(host) => self.replace_host(host), + ApiEvent::Failed(error) => { + self.inspecting_host = None; + self.toast = Some(Toast { + text: error, + kind: ToastKind::Err, + at: Instant::now(), + }); + } + ApiEvent::Json(_) => {} + } + } + + // 详情抽屉打开时每 2.8s 采样一次。 + if self.right_wing == Some(RightWing::Detail) + && let Some(host_id) = self.detail_host_id.clone() + { + let due = self + .last_inspect + .map(|at| at.elapsed() >= Duration::from_millis(2800)) + .unwrap_or(true); + if due && self.inspecting_host.is_none() { + self.last_inspect = Some(Instant::now()); + self.inspecting_host = Some(host_id.clone()); + self.api.send(ApiCommand::Inspect(host_id)); + changed = true; + } + } + + // Toast 2.8s 自动消失。 + if let Some(toast) = &self.toast + && toast.at.elapsed() >= Duration::from_millis(2800) + { + self.toast = None; + changed = true; + } + + if changed { + cx.notify(); + } + } } impl Render for Workspace { - fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { - div().size_full().child(self.dock_area.clone()) + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let this = cx.entity(); + let has_stage = self.has_stage(); + let main = match self.page { + Page::Settings => div() + .flex_1() + .min_h_0() + .child(crate::settings_page::render(self, &this, cx)) + .into_any_element(), + Page::Workspace => { + let sidebar = if has_stage { + if self.show_sidebar_in_stage { + div() + .w(px(336.)) + .flex_shrink_0() + .h_full() + .min_h_0() + .child(host_card::render_list(self, &this, cx)) + .into_any_element() + } else { + div().w(px(0.)).into_any_element() + } + } else { + div() + .flex_1() + .min_w_0() + .max_w(px(1152.)) + .child(host_card::render_list(self, &this, cx)) + .into_any_element() + }; + let center = if has_stage { + div() + .flex_1() + .min_w_0() + .min_h_0() + .child(terminal_stage::render(self, &this, cx)) + .into_any_element() + } else { + div().w(px(0.)).into_any_element() + }; + let right = if self.right_wing.is_some() && self.right_wing_host().is_some() { + div() + .w(px(336.)) + .flex_shrink_0() + .h_full() + .min_h_0() + .child(detail_drawer::render(self, &this, cx)) + .into_any_element() + } else { + div().w(px(0.)).into_any_element() + }; + div() + .flex_1() + .min_h_0() + .flex() + .items_stretch() + .justify_center() + .gap(px(14.)) + .px(px(16.)) + .py(px(16.)) + .child(sidebar) + .child(center) + .child(right) + .into_any_element() + } + }; + + let mut root = div() + .v_flex() + .size_full() + .bg(page_background()) + .text_color(cx.theme().foreground) + .child(top_bar::render(self, &this, cx)) + .child(main); + + if let Some(toast) = &self.toast { + let (bg, fg, border) = match toast.kind { + ToastKind::Ok => (rgb(0x18181b), rgb(0xffffff), rgb(0x3f3f46)), + ToastKind::Err => (rgb(0xffffff), rgb(0xdc2626), rgb(0xfecaca)), + }; + root = root.child( + div() + .absolute() + .bottom(px(20.)) + .right(px(20.)) + .rounded_lg() + .border_1() + .border_color(border) + .bg(bg) + .text_color(fg) + .px(px(14.)) + .py(px(8.)) + .text_size(px(12.)) + .shadow_lg() + .child(toast.text.clone()), + ); + } + root } } + +/// 页面底色:旧前端的径向渐变用纯色近似。 +fn page_background() -> Hsla { + Hsla::from(rgb(0xf4f5f7)) +}