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 <noreply@anthropic.com>
This commit is contained in:
weidu12123andClaude Fable 5 committed 2026-10-17 17:59:00 +08:00
1 parent 841a362c8f
commit 8bed005d85
15 files changed
+3520 -460

No files matched your search

+206
View File
@@ -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"
}
}
}
}
+306 -55
View File
@@ -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<String>,
pub pretty_name: Option<String>,
pub distro_id: Option<String>,
pub kernel: Option<String>,
pub arch: Option<String>,
pub cpu_cores: Option<u32>,
pub cpu_model: Option<String>,
pub pinned: bool,
pub favorited: bool,
pub archived: bool,
pub role: Option<String>,
pub tags: Vec<String>,
pub jump_host_name: Option<String>,
pub latency_ms: Option<i64>,
pub last_error: Option<String>,
}
/// 网络层交给 UI 的事件。
pub enum ApiEvent {
Hosts(Vec<HostSummary>),
Failed(String),
/// 探针快照。
#[derive(Clone, Debug, Default)]
pub struct Inspection {
pub cpu_cores: Option<u32>,
pub cpu_usage_pct: f64,
pub cpu_iowait_pct: f64,
pub cpu_steal_pct: f64,
pub cpu_model: Option<String>,
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<String>,
pub disk_total_bytes: f64,
pub disk_used_bytes: f64,
pub disks: Vec<DiskUsage>,
pub net_iface: Option<String>,
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<String>,
}
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<String, Vec<(f64, f64)>>;
/// 网络/解析线程交给 UI 的事件。
pub enum ApiEvent {
Hosts(Vec<Host>),
HostsFailed(String),
Inspection {
host_id: String,
inspection: Inspection,
host: Option<Host>,
},
CheckedAll(Vec<Host>),
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<Request>,
commands: async_channel::Sender<ApiCommand>,
events: mpsc::Receiver<ApiEvent>,
}
@@ -49,7 +115,7 @@ pub fn port() -> String {
impl Api {
pub fn spawn() -> Self {
let (request_tx, request_rx) = async_channel::unbounded::<Request>();
let (command_tx, command_rx) = async_channel::unbounded::<ApiCommand>();
let (event_tx, event_rx) = mpsc::channel::<ApiEvent>();
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<ApiEvent> {
self.events.try_recv().ok()
pub fn try_recv(&self) -> Vec<ApiEvent> {
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<ApiEvent> {
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<HostSummary> {
async fn get_json(client: &reqwest::Client, path: &str) -> Result<serde_json::Value, String> {
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<serde_json::Value, String> {
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<serde_json::Value, String> {
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<serde_json::Value, String> {
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<Host> {
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<Host> {
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"),
}
}
+910
View File
@@ -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<Workspace>, 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<f64>| 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::<Vec<_>>(),
);
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<Workspace>,
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<String> = 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<SharedString>, 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())
}
+346
View File
@@ -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<Workspace>, 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<Workspace>,
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<SharedString>, 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::<String>()
.to_uppercase()
}
-212
View File
@@ -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<HostSummary>,
/// 正在显示的错误(例如守护进程没起来);None 表示还没加载过。
error: Option<String>,
loaded: bool,
selected: Option<String>,
}
impl HostList {
pub fn new(cx: &mut Context<Self>) -> Self {
Self {
focus_handle: cx.focus_handle(),
hosts: Vec::new(),
error: None,
loaded: false,
selected: None,
}
}
pub fn apply_hosts(&mut self, hosts: Vec<HostSummary>, cx: &mut Context<Self>) {
self.hosts = hosts;
self.error = None;
self.loaded = true;
cx.notify();
}
pub fn apply_error(&mut self, error: String, cx: &mut Context<Self>) {
self.error = Some(error);
cx.notify();
}
fn render_host(
&self,
host: &HostSummary,
this: &Entity<Self>,
cx: &App,
) -> impl IntoElement {
let online = host.last_error.is_none() && host.latency_ms.is_some();
let dot_color = if online {
cx.theme().success
} 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<PanelEvent> for HostList {}
impl EventEmitter<HostListEvent> for HostList {}
impl BasePanel for HostList {
fn panel_name(&self) -> &'static str {
"hosts"
}
}
impl Panel for HostList {
fn tab_name(&self, _: &App) -> Option<SharedString> {
Some("主机".into())
}
}
impl Render for HostList {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let this = cx.entity();
let body = if let Some(error) = &self.error {
v_flex()
.p_3()
.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),
)
}
}
+7 -2
View File
@@ -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;
+3 -1
View File
@@ -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| {
+101
View File
@@ -0,0 +1,101 @@
//! 设置页(规格 §7)。本轮先立骨架:分类导航 + 返回仓库;各分类内容随后逐块补齐。
use gpui_kit::component::*;
use gpui_kit::*;
use crate::workspace::{Page, Workspace};
pub fn render(_workspace: &Workspace, this: &Entity<Workspace>, 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 逐块实现中。"),
),
)
}
+49 -20
View File
@@ -24,18 +24,21 @@ pub struct TerminalView {
focus_handle: FocusHandle,
/// 会话结束的原因;有值时在底部显示提示条。
status: Option<String>,
/// 白底 / 黑底(与旧前端的 LIGHT_THEME / DARK_THEME 对应)。
dark: bool,
cols: u16,
rows: u16,
}
impl TerminalView {
pub fn new(host_id: String, cx: &mut Context<Self>) -> Self {
pub fn new(host_id: String, dark: bool, cx: &mut Context<Self>) -> 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<Self>) {
if self.dark != dark {
self.dark = dark;
cx.notify();
}
}
fn poll(cx: &mut Context<Self>) {
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<Self>) -> 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 {
-84
View File
@@ -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<TerminalView>,
}
impl TerminalPanel {
pub fn new(host: HostSummary, cx: &mut Context<Self>) -> 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<PanelEvent> for TerminalPanel {}
impl BasePanel for TerminalPanel {
fn panel_name(&self) -> &'static str {
"terminal"
}
}
impl Panel for TerminalPanel {
fn tab_name(&self, _: &App) -> Option<SharedString> {
Some(SharedString::from(self.host.name.clone()))
}
}
impl Render for TerminalPanel {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let os = self
.host
.pretty_name
.clone()
.or_else(|| self.host.os.clone())
.unwrap_or_else(|| "未知系统".into());
v_flex()
.size_full()
.bg(cx.theme().background)
.child(
h_flex()
.px_3()
.py_2()
.gap_2()
.border_b_1()
.border_color(cx.theme().border)
.child(
div()
.text_sm()
.font_weight(gpui_kit::FontWeight::SEMIBOLD)
.child(self.host.name.clone()),
)
.child(
div()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(format!("{} · {}", self.host.addr, os)),
),
)
.child(
div()
.flex_1()
.min_h_0()
.child(self.terminal.clone()),
)
}
}
+328
View File
@@ -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<Workspace>, 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<Workspace>, &mut App) + 'static,
this: Entity<Workspace>,
) -> 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)))
}
}
+19
View File
@@ -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::<Theme>().light_theme = Rc::new(config);
Theme::change(ThemeMode::Light, None, cx);
}
+394
View File
@@ -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<Workspace>, 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<Workspace>,
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<String> = 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("◧"),
)
}
+41
View File
@@ -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}分钟")
}
}
+810 -86
View File
@@ -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<RightWing>,
pub split: SplitDirection,
pub panes: Vec<Entity<TerminalView>>,
pub focused_pane: Option<usize>,
pub creating: bool,
pub error: Option<String>,
}
pub struct Workspace {
dock_area: Entity<DockArea>,
host_list: Entity<HostList>,
terminals: Vec<Entity<TerminalPanel>>,
api: Api,
pub hosts: Vec<Host>,
pub loading: bool,
pub checking_all: bool,
pub checking: HashSet<String>,
pub open_tabs: Vec<OpenTab>,
pub stage_host_id: Option<String>,
pub center_stage: Option<CenterStage>,
pub detail_host_id: Option<String>,
pub right_wing: Option<RightWing>,
pub show_sidebar_in_stage: bool,
pub search: Entity<InputState>,
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<Toast>,
pub inspections: HashMap<String, Inspection>,
pub net_history: NetHistory,
pub inspecting_host: Option<String>,
last_inspect: Option<Instant>,
}
impl Workspace {
pub fn new(api: Api, window: &mut Window, cx: &mut Context<Self>) -> 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<Self>) {
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<usize> {
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<String> {
let mut tags: Vec<String> = 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<String>, kind: ToastKind, cx: &mut Context<Self>) {
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>) {
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>) {
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<Self>) {
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<Self>) {
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<Self>) {
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>) {
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>) {
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<Self>) {
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>) {
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>) {
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>) {
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>) {
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>) {
self.page = if self.page == Page::Settings {
Page::Workspace
} else {
Page::Settings
};
cx.notify();
}
pub fn refresh_hosts(&mut self, cx: &mut Context<Self>) {
self.api.send(ApiCommand::LoadHosts);
cx.notify();
}
/// 立即采样一次探针(详情抽屉的「刷新」)。
pub fn inspect_now(&mut self, host_id: &str, cx: &mut Context<Self>) {
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<Self>) {
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<Self>) {
let mut center = DockLayout::tabs();
for panel in &self.terminals {
center = center.panel_view(panel_handle(panel.clone()), cx);
}
self.dock_area.update(cx, |area, cx| {
area.set_center(center, window, cx);
pub fn patch_meta(&mut self, host_id: &str, patch: serde_json::Value, cx: &mut Context<Self>) {
self.api.send(ApiCommand::PatchMeta {
host_id: host_id.to_owned(),
patch,
});
cx.notify();
}
/// 每 100ms drain 一次网络事件。后台线程不直接碰 GPUI executor。
fn poll_events(cx: &mut Context<Self>) {
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<Self>) {
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<Self>) {
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<Self>) {
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<Self>) {
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<Self>) {
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<Self>) {
if let Some(tab) = self.open_tabs.get_mut(tab_index) {
tab.focused_pane = Some(pane_index);
cx.notify();
}
}
// ---------- 事件轮询 ----------
fn poll(cx: &mut Context<Self>) {
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<Self>) {
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<Self>) -> impl IntoElement {
div().size_full().child(self.dock_area.clone())
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> 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))
}