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:
1 parent
841a362c8f
commit
8bed005d85
15 files changed
+3520
-460
No files matched your search
@@ -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
@@ -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"),
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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| {
|
||||
|
||||
@@ -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 逐块实现中。"),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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("◧"),
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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))
|
||||
}
|
||||
Reference in new issue
Block a user