feat(gpui): add local proxy routing and polish terminal, file browser, and UI interactions

This commit is contained in:
weidu12123 committed 2026-10-19 00:26:34 +08:00
1 parent e89fd63001
commit 9d35744dd0
17 files changed
+1844 -247

No files matched your search

+2 -2
View File
@@ -40,9 +40,9 @@
先把轻量级工具的**前端整体骨架与视觉形态**打磨定型,同时把底层主机管理与 SSH 连接池做扎实:
- **主机模型(Host)**:支持本机(`local`)与远端 SSH 主机;字段涵盖地址、端口、用户名、认证方式(密码 / 私钥加密存储)、跳板机(`jump_host_id`)、分组/标签、置顶、排序、备注链接,以及连接时自动刷新的 `os` 信息(系统家族、发行版、内核、架构)。
- **主机模型(Host)**:支持本机(`local`)与远端 SSH 主机;字段涵盖地址、端口、用户名、认证方式(密码 / 私钥加密存储)、跳板机与本机代理出口(`jump_host_id` 支持指定另一台 SSH 跳板机或本机代理端口 `local-proxy`)、分组/标签、置顶、排序、备注链接,以及连接时自动刷新的 `os` 信息(系统家族、发行版、内核、架构)。
- **1Shell 一键导入**:直接读取并解密本机现有 1Shell 数据库里的 29 台 VPS,一键迁入,开发第一天起就能用真实机队测试。
- **SSH 连接底座(`russh`)**:单主机连接池与多 channel 复用(终端、执行、SFTP、探针共用连接,支持跳板机透明穿透),并在真机队(OpenSSH 8.4 ~ 10.2、Alpine、Windows OpenSSH 9.5)上验通。
- **SSH 连接底座(`russh`)**:单主机连接池与多 channel 复用(终端、执行、SFTP、探针共用连接,支持 SSH 跳板机透明穿透与本机代理端口 SOCKS5/HTTP CONNECT 出口路由),并在真机队(OpenSSH 8.4 ~ 10.2、Alpine、Windows OpenSSH 9.5)上验通。
- **前端打磨**:确定整个软件的导航布局、配色主题、主机列表/卡片视图、搜索与标签过滤、快速新建/编辑与连接测试交互。
### 板块二:终端(参考 Pebrel 技术栈)
+215 -27
View File
@@ -9,14 +9,56 @@ use std::time::Duration;
use serde::{Deserialize, Serialize};
pub(crate) fn deserialize_null_string<'de, D>(deserializer: D) -> Result<String, D::Error>
where
D: serde::Deserializer<'de>,
{
let opt = Option::<String>::deserialize(deserializer)?;
Ok(opt.unwrap_or_default())
}
pub(crate) fn deserialize_null_bool<'de, D>(deserializer: D) -> Result<bool, D::Error>
where
D: serde::Deserializer<'de>,
{
let opt = Option::<bool>::deserialize(deserializer)?;
Ok(opt.unwrap_or(false))
}
pub(crate) fn deserialize_null_u16<'de, D>(deserializer: D) -> Result<u16, D::Error>
where
D: serde::Deserializer<'de>,
{
let opt = Option::<u16>::deserialize(deserializer)?;
Ok(opt.unwrap_or(0))
}
pub(crate) fn deserialize_null_u64<'de, D>(deserializer: D) -> Result<u64, D::Error>
where
D: serde::Deserializer<'de>,
{
let opt = Option::<u64>::deserialize(deserializer)?;
Ok(opt.unwrap_or(0))
}
pub(crate) fn deserialize_null_i64<'de, D>(deserializer: D) -> Result<i64, D::Error>
where
D: serde::Deserializer<'de>,
{
let opt = Option::<i64>::deserialize(deserializer)?;
Ok(opt.unwrap_or(0))
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct HostLink {
#[serde(default)]
#[serde(default, deserialize_with = "deserialize_null_string")]
pub id: String,
#[serde(default, deserialize_with = "deserialize_null_string")]
pub name: String,
#[serde(default, deserialize_with = "deserialize_null_string")]
pub url: String,
#[serde(default)]
#[serde(default, deserialize_with = "deserialize_null_string")]
pub description: String,
}
@@ -90,56 +132,73 @@ pub struct DiskUsage {
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FileEntry {
#[serde(default, deserialize_with = "deserialize_null_string")]
pub name: String,
#[serde(default, deserialize_with = "deserialize_null_string")]
pub path: String,
#[serde(default)]
#[serde(default, deserialize_with = "deserialize_null_bool")]
pub is_dir: bool,
#[serde(default)]
#[serde(default, deserialize_with = "deserialize_null_u64")]
pub size: u64,
#[serde(default)]
#[serde(default, deserialize_with = "deserialize_null_i64")]
pub mtime: i64,
#[serde(default)]
pub permissions: Option<u32>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TransferTask {
#[serde(default, deserialize_with = "deserialize_null_string")]
pub id: String,
#[serde(default, deserialize_with = "deserialize_null_string")]
pub host_id: String,
#[serde(default, deserialize_with = "deserialize_null_string")]
pub host_name: String,
#[serde(default, deserialize_with = "deserialize_null_string")]
pub direction: String,
#[serde(default, deserialize_with = "deserialize_null_string")]
pub filename: String,
#[serde(default)]
pub local_path: Option<String>,
#[serde(default, deserialize_with = "deserialize_null_string")]
pub remote_path: String,
#[serde(default)]
#[serde(default, deserialize_with = "deserialize_null_u64")]
pub total_bytes: u64,
#[serde(default)]
#[serde(default, deserialize_with = "deserialize_null_u64")]
pub transferred_bytes: u64,
#[serde(default)]
#[serde(default, deserialize_with = "deserialize_null_u64")]
pub speed_bps: u64,
#[serde(default, deserialize_with = "deserialize_null_string")]
pub state: String,
pub error: Option<String>,
#[serde(default)]
pub error: Option<String>,
#[serde(default, deserialize_with = "deserialize_null_u64")]
pub resumed_from: u64,
#[serde(default)]
pub sha256: Option<String>,
#[serde(default)]
pub verified: Option<bool>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct McpCallRecord {
#[serde(default, deserialize_with = "deserialize_null_string")]
pub id: String,
#[serde(default, deserialize_with = "deserialize_null_string")]
pub ts: String,
#[serde(default, deserialize_with = "deserialize_null_string")]
pub tool: String,
#[serde(default)]
#[serde(default, deserialize_with = "deserialize_null_string")]
pub host: String,
#[serde(default)]
#[serde(default, deserialize_with = "deserialize_null_string")]
pub args: String,
#[serde(default)]
#[serde(default, deserialize_with = "deserialize_null_bool")]
pub ok: bool,
#[serde(default)]
#[serde(default, deserialize_with = "deserialize_null_i64")]
pub duration_ms: i64,
#[serde(default)]
#[serde(default, deserialize_with = "deserialize_null_string")]
pub summary: String,
}
@@ -153,6 +212,8 @@ pub struct SettingsBundle {
pub has_admin_password: bool,
pub autostart: bool,
pub close_action: String,
pub proxy_mode: String,
pub proxy_port: u16,
pub claude_code_synced: bool,
pub codex_synced: bool,
pub exe_path: String,
@@ -266,6 +327,10 @@ pub enum ApiEvent {
prev_close_action: String,
error: String,
},
ProxySaved {
proxy_mode: String,
proxy_port: u16,
},
McpSaved {
mcp_enabled: bool,
mcp_require_api_key: bool,
@@ -403,6 +468,10 @@ pub enum ApiCommand {
prev_autostart: bool,
prev_close_action: String,
},
SaveProxy {
proxy_mode: String,
proxy_port: u16,
},
SaveMcp {
mcp_enabled: bool,
mcp_require_api_key: bool,
@@ -516,12 +585,13 @@ impl Api {
sse_event_loop(sse_client, sse_tx).await;
});
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;
}
let req_client = client.clone();
let req_tx = event_tx.clone();
tokio::spawn(async move {
if let Some(event) = handle(&req_client, command).await {
let _ = req_tx.send(event);
}
});
}
});
})
@@ -693,7 +763,14 @@ async fn handle(client: &reqwest::Client, command: ApiCommand) -> Option<ApiEven
.map(str::to_owned);
let items: Vec<FileEntry> = body
.get("items")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|item| {
serde_json::from_value::<FileEntry>(item.clone()).ok()
})
.collect()
})
.unwrap_or_default();
Some(ApiEvent::FilesListed {
host_id,
@@ -994,7 +1071,14 @@ async fn handle(client: &reqwest::Client, command: ApiCommand) -> Option<ApiEven
{
let tasks: Vec<TransferTask> = body
.get("tasks")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|item| {
serde_json::from_value::<TransferTask>(item.clone()).ok()
})
.collect()
})
.unwrap_or_default();
return Some(ApiEvent::TransfersListed { host_id, tasks });
}
@@ -1012,7 +1096,14 @@ async fn handle(client: &reqwest::Client, command: ApiCommand) -> Option<ApiEven
{
let tasks: Vec<TransferTask> = body
.get("tasks")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|item| {
serde_json::from_value::<TransferTask>(item.clone()).ok()
})
.collect()
})
.unwrap_or_default();
return Some(ApiEvent::TransfersListed { host_id, tasks });
}
@@ -1022,6 +1113,8 @@ async fn handle(client: &reqwest::Client, command: ApiCommand) -> Option<ApiEven
let mut bundle = SettingsBundle {
admin_username: "admin".into(),
close_action: "tray".into(),
proxy_mode: "per_host".into(),
proxy_port: 7890,
port: port().parse().unwrap_or(18800),
version: "v0.1.0".into(),
..SettingsBundle::default()
@@ -1074,6 +1167,16 @@ async fn handle(client: &reqwest::Client, command: ApiCommand) -> Option<ApiEven
if let Some(ca) = body.get("closeAction").and_then(|v| v.as_str()) {
bundle.close_action = ca.to_owned();
}
if let Some(pm) = body.get("proxyMode").and_then(|v| v.as_str()) {
bundle.proxy_mode = pm.to_owned();
}
if let Some(pp) = body
.get("proxyPort")
.and_then(|v| v.as_u64())
.filter(|p| *p >= 1 && *p <= 65535)
{
bundle.proxy_port = pp as u16;
}
}
if let Ok(about) = get_json(client, "/api/system/about").await {
if let Some(v) = about.get("version").and_then(|v| v.as_str()) {
@@ -1134,6 +1237,35 @@ async fn handle(client: &reqwest::Client, command: ApiCommand) -> Option<ApiEven
}),
}
}
ApiCommand::SaveProxy {
proxy_mode,
proxy_port,
} => {
let res = post_json(
client,
"/api/system/autostart",
Some(&serde_json::json!({
"proxyMode": proxy_mode,
"proxyPort": proxy_port,
})),
)
.await;
match res {
Ok(body) => Some(ApiEvent::ProxySaved {
proxy_mode: body
.get("proxyMode")
.and_then(|v| v.as_str())
.unwrap_or(&proxy_mode)
.to_owned(),
proxy_port: body
.get("proxyPort")
.and_then(|v| v.as_u64())
.map(|p| p as u16)
.unwrap_or(proxy_port),
}),
Err(error) => Some(ApiEvent::Failed(format!("保存本机代理设置失败: {error}"))),
}
}
ApiCommand::SaveMcp {
mcp_enabled,
mcp_require_api_key,
@@ -1272,15 +1404,32 @@ async fn handle(client: &reqwest::Client, command: ApiCommand) -> Option<ApiEven
{
let calls: Vec<McpCallRecord> = body
.get("calls")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|item| {
serde_json::from_value::<McpCallRecord>(item.clone()).ok()
})
.collect()
})
.unwrap_or_default();
let hosts: Vec<String> = body
.get("hosts")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|item| item.as_str().map(str::to_owned))
.collect()
})
.unwrap_or_default();
let tools: Vec<String> = body
.get("tools")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|item| item.as_str().map(str::to_owned))
.collect()
})
.unwrap_or_default();
return Some(ApiEvent::McpCallsLoaded {
calls,
@@ -1500,7 +1649,13 @@ pub fn host_from_json(value: &serde_json::Value) -> Option<Host> {
};
let links: Vec<HostLink> = value
.get("links")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|item| serde_json::from_value::<HostLink>(item.clone()).ok())
.filter(|l| !l.name.is_empty() && !l.url.is_empty())
.collect()
})
.unwrap_or_default();
Some(Host {
id: field("id")?.to_owned(),
@@ -1733,5 +1888,38 @@ mod tests {
_ => panic!("expected RevealTerminal"),
}
}
#[test]
fn parses_mcp_call_records_with_null_host_and_optional_fields() {
let raw = serde_json::json!([
{
"id": "c-1",
"ts": "2026-10-18T07:23:38Z",
"tool": "vps_exec",
"host": "node-a",
"args": "{\"command\":\"ls\"}",
"ok": true,
"durationMs": 611,
"summary": "ok=true"
},
{
"id": "c-2",
"ts": "2026-10-18T07:20:00Z",
"tool": "vps_list",
"host": null,
"args": "{}",
"ok": true,
"durationMs": 4,
"summary": null
}
]);
let records: Vec<McpCallRecord> =
serde_json::from_value(raw).expect("records with null host must deserialize");
assert_eq!(records.len(), 2);
assert_eq!(records[0].host, "node-a");
assert_eq!(records[1].tool, "vps_list");
assert_eq!(records[1].host, "");
assert_eq!(records[1].summary, "");
}
}
+20 -3
View File
@@ -349,6 +349,8 @@ impl Workspace {
self.files.host_id = Some(host_id.to_owned());
self.files.editor_open = false;
self.files.inline_prompt = None;
self.files.current_path.clear();
self.files.parent_path = None;
self.files.items.clear();
let target = initial_path.unwrap_or("").to_owned();
self.load_files_dir(&target, false);
@@ -909,6 +911,7 @@ pub fn render(
.h(px(28.))
.items_center()
.gap(px(2.))
.overflow_hidden()
.rounded_lg()
.border_1()
.border_color(palette.border)
@@ -1199,7 +1202,13 @@ pub fn render(
// ---- 3. 主体区:文件列表 + 在线代码编辑器 ----
let show_list = !(compact && files.editor_open);
let mut main_area = h_flex().flex_1().min_h_0().overflow_hidden();
let mut main_area = div()
.flex_1()
.min_h_0()
.w_full()
.flex()
.items_stretch()
.overflow_hidden();
if show_list {
main_area = main_area.child(render_file_list(workspace, this, compact, palette));
@@ -1595,6 +1604,7 @@ fn render_file_list(
.id("files-list-scroll")
.test_support()
.flex_1()
.h_full()
.min_w_0()
.min_h_0()
.overflow_y_scroll()
@@ -1746,6 +1756,7 @@ fn render_editor_pane(
.id("files-editor-pane")
.test_support()
.flex_1()
.h_full()
.min_w_0()
.min_h_0()
.flex()
@@ -1771,7 +1782,7 @@ fn render_transfers_footer(
let this_clear = this.clone();
let mut list = v_flex().w_full().gap(px(6.));
for task in tasks.iter().take(6) {
for task in tasks.iter() {
let arrow = if task.direction == "upload" { "⬆" } else { "⬇" };
let pct = if task.total_bytes > 0 {
((task.transferred_bytes as f64 * 100.0) / task.total_bytes as f64)
@@ -1917,5 +1928,11 @@ fn render_transfers_footer(
.child("清理已完成"),
),
)
.child(list)
.child(
div()
.id("files-transfers-scroll")
.max_h(px(164.))
.overflow_y_scroll()
.child(list),
)
}
+48 -12
View File
@@ -34,7 +34,8 @@ pub fn render_list(
.size_full()
.min_h_0()
.overflow_y_scroll()
.pr(px(4.))
.px(px(4.))
.py(px(2.))
.child(list)
}
@@ -85,10 +86,11 @@ fn card(
this: &Entity<Workspace>,
palette: &Palette,
) -> impl IntoElement {
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 is_stage_host = workspace.has_stage()
&& workspace.stage_host_id.as_deref() == Some(host.id.as_str());
let is_detail_host = workspace.right_wing == Some(RightWing::Detail)
&& workspace.detail_host_id.as_deref() == Some(host.id.as_str());
let selected = is_stage_host || is_detail_host;
let address = if host.kind == "local" {
format!("{}@127.0.0.1", host.username)
@@ -122,8 +124,22 @@ fn card(
&& workspace.center_stage == Some(CenterStage::Files);
// ---- 左半部 ----
let mut left = h_flex().min_w_0().items_center().gap(px(12.));
if show_controls {
let mut left = h_flex().min_w_0().items_center().gap(px(10.));
if selected {
let bar_color = if is_stage_host {
palette.accent_orange
} else {
palette.primary_bg
};
left = left.child(
div()
.flex_shrink_0()
.w(px(4.))
.h(px(30.))
.rounded_full()
.bg(bar_color),
);
} else if show_controls {
let handle_color = if host.kind == "local" {
palette.text_faint.opacity(0.25)
} else {
@@ -167,6 +183,20 @@ fn card(
.truncate()
.child(host.name.clone()),
);
if is_detail_host && !is_stage_host {
title_row = title_row.child(
div()
.flex_shrink_0()
.rounded_md()
.bg(palette.primary_bg)
.px(px(6.))
.py(px(1.))
.text_size(px(10.))
.font_weight(FontWeight::MEDIUM)
.text_color(palette.primary_fg)
.child("详情"),
);
}
if show_controls && host.pinned && host.kind != "local" {
title_row = title_row.child(pin_chip(palette));
}
@@ -305,7 +335,6 @@ fn card(
.justify_between()
.gap(px(12.))
.rounded_2xl()
.border_1()
.px(px(16.))
.py(px(14.))
.cursor_pointer()
@@ -339,14 +368,21 @@ fn card(
.child(left)
.child(right);
if selected {
base.border_color(palette.border_selected)
if is_stage_host {
base.border_2()
.border_color(palette.accent_orange)
.bg(palette.card_selected_bg)
.shadow(palette.card_shadow(true))
} else if is_detail_host {
base.border_2()
.border_color(palette.border_selected)
.bg(palette.card_selected_bg)
.shadow(palette.card_shadow(true))
} else {
let hover_bg = palette.card_hover_bg;
let hover_border = palette.border_selected;
base.border_color(palette.border)
let hover_border = palette.text_faint.opacity(0.45);
base.border_1()
.border_color(palette.border)
.bg(palette.card_bg)
.shadow(palette.card_shadow(false))
.hover(move |s| s.bg(hover_bg).border_color(hover_border))
+147 -36
View File
@@ -1,7 +1,7 @@
//! 新建 / 编辑主机弹窗(`HostModal.vue`)与删除确认弹窗(`App.vue` 2987-3018 行)。
use gpui_kit::StatefulInteractiveElement as _;
use gpui_kit::component::input::{Input, InputState, Textarea, TextareaState};
use gpui_kit::component::input::{Input, InputEvent, InputState, Textarea, TextareaState};
use gpui_kit::component::*;
use gpui_kit::*;
@@ -35,16 +35,18 @@ pub struct HostModalState {
pub private_key: Entity<TextareaState>,
pub passphrase: Entity<InputState>,
pub jump_host_id: String,
pub jump_dropdown_open: bool,
pub role: String, // "" | "primary" | "proxy" | "test" | "archive"
pub tags: Entity<InputState>,
pub pinned: bool,
pub archived: bool,
pub links: Vec<LinkRowInput>,
pub saving: bool,
pub error_msg: Option<String>,
}
impl HostModalState {
pub fn new(editing: Option<&Host>, window: &mut Window, cx: &mut App) -> Self {
pub fn new(editing: Option<&Host>, window: &mut Window, cx: &mut Context<Workspace>) -> Self {
let is_local = editing.is_some_and(|h| h.kind == "local");
let name_val = editing.map(|h| h.name.clone()).unwrap_or_default();
let addr_val = editing.map(|h| h.addr.clone()).unwrap_or_default();
@@ -112,6 +114,15 @@ impl HostModalState {
.default_value(tags_val)
});
for field in [&name, &addr, &port, &username, &password, &passphrase, &tags] {
cx.subscribe_in(field, window, |ws, _, event: &InputEvent, _window, cx| {
if let InputEvent::PressEnter { .. } = event {
ws.submit_host_modal(cx);
}
})
.detach();
}
let source_links = editing
.map(|h| h.links.clone())
.filter(|l| !l.is_empty())
@@ -136,11 +147,13 @@ impl HostModalState {
private_key,
passphrase,
jump_host_id,
jump_dropdown_open: false,
role,
tags,
pinned,
archived,
links,
saving: false,
error_msg: None,
}
}
@@ -514,11 +527,16 @@ pub fn render_host_modal(
}
});
// 跳板机循环切换(直连 -> 各候选 SSH 主机)
let candidates: Vec<(String, String)> = std::iter::once((
String::new(),
"直连(不使用跳板)".to_owned(),
))
// 跳板机下拉展开选择(直连 + 本机代理端口 + 各候选 SSH 主机,可滚动点选)
let proxy_port = workspace.settings.proxy_port.max(1);
let candidates: Vec<(String, String)> = vec![
(String::new(), "直连(不使用跳板)".to_owned()),
(
"local-proxy".to_owned(),
format!("本机代理 (127.0.0.1:{proxy_port})"),
),
]
.into_iter()
.chain(
workspace
.hosts
@@ -532,8 +550,9 @@ pub fn render_host_modal(
.find(|(id, _)| *id == modal.jump_host_id)
.map(|(_, label)| label.clone())
.unwrap_or_else(|| "直连(不使用跳板)".to_owned());
let this_jump = this.clone();
let jump_selector = div()
let dropdown_open = modal.jump_dropdown_open;
let this_jump_toggle = this.clone();
let jump_trigger = div()
.id("modal-jump-cycle")
.test_support()
.mt(px(4.))
@@ -541,30 +560,115 @@ pub fn render_host_modal(
.px(px(12.))
.rounded_xl()
.border_1()
.border_color(palette.border)
.bg(palette.muted_surface_bg)
.border_color(if dropdown_open {
palette.text_secondary
} else {
palette.border
})
.bg(if dropdown_open {
palette.card_solid_bg
} else {
palette.muted_surface_bg
})
.flex()
.items_center()
.justify_between()
.text_size(px(12.))
.text_color(palette.text_primary)
.cursor_pointer()
.on_mouse_down(MouseButton::Left, |_, _, cx| {
cx.stop_propagation();
})
.on_click(move |_, _, cx| {
let list = candidates.clone();
let _ = this_jump.update(cx, |w, cx| {
let _ = this_jump_toggle.update(cx, |w, cx| {
if let Some(m) = &mut w.host_modal {
let pos = list
.iter()
.position(|(id, _)| *id == m.jump_host_id)
.unwrap_or(0);
let next = (pos + 1) % list.len();
m.jump_host_id = list[next].0.clone();
m.jump_dropdown_open = !m.jump_dropdown_open;
cx.notify();
}
});
})
.child(div().truncate().child(current_jump_label))
.child(lucide_icon(CatalogIcon::ChevronsUpDown, 13., palette.text_faint));
.child(lucide_icon(
if dropdown_open {
CatalogIcon::ChevronUp
} else {
CatalogIcon::ChevronDown
},
13.,
palette.text_faint,
));
let mut jump_col = v_flex()
.flex_1()
.gap(px(4.))
.child(field_label("跳板机(可选)", palette))
.child(jump_trigger);
if dropdown_open {
let mut list_inner = v_flex().w_full().gap(px(2.));
for (idx, (cand_id, cand_label)) in candidates.into_iter().enumerate() {
let selected = cand_id == modal.jump_host_id;
let this_pick = this.clone();
let picked_id = cand_id.clone();
let item = h_flex()
.id(SharedString::from(format!("modal-jump-opt-{idx}")))
.test_support()
.w_full()
.items_center()
.justify_between()
.px(px(10.))
.py(px(6.))
.rounded_lg()
.text_size(px(12.))
.cursor_pointer()
.hover(|s| s.bg(palette.subtle_bg))
.on_click(move |_, _, cx| {
let next_id = picked_id.clone();
let _ = this_pick.update(cx, |w, cx| {
if let Some(m) = &mut w.host_modal {
m.jump_host_id = next_id;
m.jump_dropdown_open = false;
cx.notify();
}
});
})
.child(div().truncate().child(cand_label))
.child(if selected {
div()
.text_size(px(11.))
.font_weight(FontWeight::SEMIBOLD)
.text_color(palette.text_primary)
.child("✓")
} else {
div()
});
list_inner = list_inner.child(if selected {
item.bg(palette.subtle_bg)
.font_weight(FontWeight::MEDIUM)
.text_color(palette.text_primary)
} else {
item.text_color(palette.text_secondary)
});
}
let dropdown_panel = div()
.id("modal-jump-dropdown")
.test_support()
.mt(px(2.))
.max_h(px(208.))
.overflow_y_scroll()
.rounded_xl()
.border_1()
.border_color(palette.border)
.bg(palette.card_solid_bg)
.p(px(4.))
.shadow_lg()
.on_mouse_down(MouseButton::Left, |_, _, cx| {
cx.stop_propagation();
})
.child(list_inner);
jump_col = jump_col.child(dropdown_panel);
}
form = form.child(
h_flex()
@@ -577,13 +681,7 @@ pub fn render_host_modal(
.child(field_label("认证方式", palette))
.child(auth_switch),
)
.child(
v_flex()
.flex_1()
.gap(px(4.))
.child(field_label("跳板机(点击切换)", palette))
.child(jump_selector),
),
.child(jump_col),
);
// 第四行:密码 或 私钥 + 口令
@@ -749,7 +847,7 @@ pub fn render_host_modal(
let body = div()
.id("modal-body-scroll")
.flex_1()
.max_h(px(468.))
.min_h_0()
.overflow_y_scroll()
.px(px(20.))
@@ -785,23 +883,27 @@ pub fn render_host_modal(
})
.child("取消"),
)
.child(
.child({
let saving = modal.saving;
div()
.id("modal-save")
.test_support()
.rounded_xl()
.bg(palette.primary_bg)
.text_color(palette.primary_fg)
.opacity(if saving { 0.65 } else { 1.0 })
.px(px(16.))
.py(px(6.))
.text_size(px(12.))
.font_weight(FontWeight::MEDIUM)
.cursor_pointer()
.on_click(move |_, _, cx| {
let _ = this_save.update(cx, |w, cx| w.submit_host_modal(cx));
if !saving {
let _ = this_save.update(cx, |w, cx| w.submit_host_modal(cx));
}
})
.child("保存"),
);
.child(if saving { "保存中..." } else { "保存" })
});
div()
.id("host-modal-backdrop")
@@ -816,7 +918,8 @@ pub fn render_host_modal(
.on_mouse_down(MouseButton::Left, move |_, _, cx| {
let _ = this_backdrop.update(cx, |w, cx| w.close_host_modal(cx));
})
.child(
.child({
let this_card = this.clone();
div()
.id("host-modal-card")
.test_support()
@@ -830,13 +933,21 @@ pub fn render_host_modal(
.border_color(palette.border)
.bg(palette.card_solid_bg)
.shadow_xl()
.on_mouse_down(MouseButton::Left, |_, _, cx| {
.on_mouse_down(MouseButton::Left, move |_, _, cx| {
let _ = this_card.update(cx, |w, cx| {
if let Some(m) = &mut w.host_modal
&& m.jump_dropdown_open
{
m.jump_dropdown_open = false;
cx.notify();
}
});
cx.stop_propagation();
})
.child(header)
.child(body)
.child(footer),
)
.child(footer)
})
.into_any_element()
}
+6 -6
View File
@@ -91,9 +91,9 @@ impl Palette {
card_selected_bg: Hsla::from(rgb(0xffffff)),
subtle_bg: Hsla::from(rgb(0xf4f4f5)),
muted_surface_bg: Hsla::from(rgb(0xfafafa)),
border: Hsla::from(rgb(0xe4e4e7)).opacity(0.8),
border: Hsla::from(rgb(0xe4e4e7)).opacity(0.85),
divider: Hsla::from(rgb(0xf1f2f4)),
border_selected: Hsla::from(rgb(0x18181b)).opacity(0.25),
border_selected: Hsla::from(rgb(0x18181b)).opacity(0.88),
text_primary: Hsla::from(rgb(0x18181b)),
text_secondary: Hsla::from(rgb(0x52525b)),
text_muted: Hsla::from(rgb(0x71717a)),
@@ -128,7 +128,7 @@ impl Palette {
muted_surface_bg: Hsla::from(rgb(0x141417)),
border: Hsla::from(rgb(0x27272a)),
divider: Hsla::from(rgb(0x27272a)).opacity(0.7),
border_selected: Hsla::from(rgb(0xf4f4f5)).opacity(0.28),
border_selected: Hsla::from(rgb(0xf4f4f5)).opacity(0.90),
text_primary: Hsla::from(rgb(0xf4f4f5)),
text_secondary: Hsla::from(rgb(0xd4d4d8)),
text_muted: Hsla::from(rgb(0xa1a1aa)),
@@ -158,14 +158,14 @@ impl Palette {
/// 卡片阴影(默认细阴影 vs 选中柔和外扩阴影)。
pub fn card_shadow(&self, selected: bool) -> Vec<BoxShadow> {
let alpha = if self.is_dark { 0.28 } else { 0.05 };
let alpha = if self.is_dark { 0.42 } else { 0.10 };
let base_alpha = if self.is_dark { 0.18 } else { 0.02 };
if selected {
vec![BoxShadow {
color: Hsla::from(rgb(0x000000)).opacity(alpha),
offset: point(px(0.), px(4.)),
blur_radius: px(20.),
spread_radius: px(0.),
blur_radius: px(18.),
spread_radius: px(1.),
inset: false,
}]
} else {
+538 -57
View File
@@ -36,9 +36,13 @@ pub enum McpConfigPreviewMode {
pub struct SettingsState {
pub active_tab: SettingsTab,
// 常规与自启
// 常规与自启 & 本机代理出口
pub autostart: bool,
pub close_action: String, // "tray" | "exit"
pub proxy_mode: String, // "off" | "per_host" | "global"
pub proxy_port: u16,
pub proxy_port_input: Entity<InputState>,
pub pending_proxy_port_sync: Option<String>,
// 访问安全
pub auth_enabled: bool,
@@ -68,6 +72,9 @@ pub struct SettingsState {
pub filter_host: String,
pub filter_tool: String,
pub filter_ok: String, // "" | "true" | "false"
pub host_dropdown_open: bool,
pub tool_dropdown_open: bool,
pub ok_dropdown_open: bool,
pub expanded_call_id: Option<String>,
pub expanded_detail: Option<(String, String)>,
pub loading_detail: bool,
@@ -87,11 +94,31 @@ impl SettingsState {
.placeholder("admin")
.default_value("admin")
});
cx.subscribe_in(
&admin_username,
window,
|ws, _, event: &InputEvent, window, cx| {
if let InputEvent::PressEnter { .. } = event {
ws.save_auth_settings(window, cx);
}
},
)
.detach();
let admin_password = cx.new(|cx| {
InputState::new(window, cx)
.placeholder("设置或修改登录密码")
.masked(true)
});
cx.subscribe_in(
&admin_password,
window,
|ws, _, event: &InputEvent, window, cx| {
if let InputEvent::PressEnter { .. } = event {
ws.save_auth_settings(window, cx);
}
},
)
.detach();
let guard_editor = cx.new(|cx| {
TextareaState::new(window, cx)
.rows(16)
@@ -122,10 +149,30 @@ impl SettingsState {
)
.detach();
let proxy_port_input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder("7890")
.default_value("7890")
});
cx.subscribe_in(
&proxy_port_input,
window,
|ws, _, event: &InputEvent, _window, cx| {
if let InputEvent::PressEnter { .. } = event {
ws.save_proxy_port_from_input(cx);
}
},
)
.detach();
Self {
active_tab: SettingsTab::General,
autostart: false,
close_action: "tray".into(),
proxy_mode: "per_host".into(),
proxy_port: 7890,
proxy_port_input,
pending_proxy_port_sync: None,
auth_enabled: false,
admin_username,
admin_password,
@@ -149,6 +196,9 @@ impl SettingsState {
filter_host: String::new(),
filter_tool: String::new(),
filter_ok: String::new(),
host_dropdown_open: false,
tool_dropdown_open: false,
ok_dropdown_open: false,
expanded_call_id: None,
expanded_detail: None,
loading_detail: false,
@@ -172,6 +222,13 @@ impl SettingsState {
} else {
"tray".into()
};
if matches!(bundle.proxy_mode.as_str(), "off" | "per_host" | "global") {
self.proxy_mode = bundle.proxy_mode;
}
if bundle.proxy_port > 0 {
self.proxy_port = bundle.proxy_port;
self.pending_proxy_port_sync = Some(bundle.proxy_port.to_string());
}
self.claude_code_synced = bundle.claude_code_synced;
self.codex_synced = bundle.codex_synced;
if !bundle.exe_path.is_empty() {
@@ -247,6 +304,9 @@ impl SettingsState {
impl Workspace {
pub fn switch_settings_tab(&mut self, tab: SettingsTab, cx: &mut Context<Self>) {
self.settings.active_tab = tab;
self.settings.host_dropdown_open = false;
self.settings.tool_dropdown_open = false;
self.settings.ok_dropdown_open = false;
if tab == SettingsTab::Calls {
self.settings.expanded_call_id = None;
self.settings.expanded_detail = None;
@@ -281,6 +341,40 @@ impl Workspace {
cx.notify();
}
pub fn set_proxy_mode(&mut self, mode: &str, cx: &mut Context<Self>) {
let port = self
.settings
.proxy_port_input
.read(cx)
.value()
.trim()
.parse::<u16>()
.ok()
.filter(|p| *p >= 1)
.unwrap_or(self.settings.proxy_port.max(1));
self.settings.proxy_mode = mode.to_owned();
self.settings.proxy_port = port;
self.api.send(ApiCommand::SaveProxy {
proxy_mode: self.settings.proxy_mode.clone(),
proxy_port: port,
});
cx.notify();
}
pub fn save_proxy_port_from_input(&mut self, cx: &mut Context<Self>) {
let raw = self.settings.proxy_port_input.read(cx).value().trim().to_owned();
let Some(port) = raw.parse::<u16>().ok().filter(|p| *p >= 1) else {
self.show_toast("请输入 1 ~ 65535 之间的有效本地代理端口", ToastKind::Err, cx);
return;
};
self.settings.proxy_port = port;
self.api.send(ApiCommand::SaveProxy {
proxy_mode: self.settings.proxy_mode.clone(),
proxy_port: port,
});
cx.notify();
}
pub fn toggle_mcp_enabled(&mut self, cx: &mut Context<Self>) {
let prev_en = self.settings.mcp_enabled;
let prev_req = self.settings.mcp_require_api_key;
@@ -378,6 +472,9 @@ impl Workspace {
self.settings.filter_host.clear();
self.settings.filter_tool.clear();
self.settings.filter_ok.clear();
self.settings.host_dropdown_open = false;
self.settings.tool_dropdown_open = false;
self.settings.ok_dropdown_open = false;
self.reload_mcp_calls(cx);
}
@@ -617,12 +714,17 @@ fn render_general_tab(
) -> AnyElement {
let autostart = workspace.settings.autostart;
let close_is_tray = workspace.settings.close_action != "exit";
let proxy_mode = workspace.settings.proxy_mode.as_str();
let is_dark = workspace.is_dark;
let term_dark = workspace.terminal_dark;
let this_auto = this.clone();
let this_tray = this.clone();
let this_exit = this.clone();
let this_proxy_off = this.clone();
let this_proxy_host = this.clone();
let this_proxy_global = this.clone();
let this_proxy_save = this.clone();
let this_light = this.clone();
let this_dark = this.clone();
let this_term_light = this.clone();
@@ -868,11 +970,162 @@ fn render_general_tab(
),
);
let proxy_card = section_shell(palette).child(
v_flex()
.gap(px(14.))
.child(
v_flex()
.pb(px(12.))
.border_b_1()
.border_color(palette.divider)
.gap(px(2.))
.child(
div()
.text_size(px(14.))
.font_weight(FontWeight::SEMIBOLD)
.text_color(palette.text_primary)
.child("本机代理出口路由 (SOCKS5 / HTTP)"),
)
.child(
div()
.text_size(px(12.))
.text_color(palette.text_faint)
.child("无需开启全局 TUN,直接连接本机代理软件监听端口(如 Clash 7890 / v2rayN 10808)转发 SSH、SFTP 与探针流量"),
),
)
.child(
inner_row_shell(palette).child(
h_flex()
.items_center()
.justify_between()
.gap(px(12.))
.child(
v_flex()
.gap(px(2.))
.child(
div()
.text_size(px(12.))
.font_weight(FontWeight::SEMIBOLD)
.text_color(palette.text_primary)
.child("代理出口模式"),
)
.child(
div()
.text_size(px(11.))
.text_color(palette.text_faint)
.child("「按主机选择」可在主机编辑弹窗的跳板机列表里单独指定走本机代理;「全局默认」让所有直连 VPS 默认走该端口"),
),
)
.child(
h_flex()
.flex_shrink_0()
.gap(px(4.))
.p(px(3.))
.rounded_xl()
.bg(palette.subtle_bg)
.child(seg_btn(
"settings-proxy-off",
"关闭",
proxy_mode == "off",
palette,
move |_, _, cx| {
let _ = this_proxy_off
.update(cx, |w, cx| w.set_proxy_mode("off", cx));
},
))
.child(seg_btn(
"settings-proxy-per-host",
"按主机选择",
proxy_mode == "per_host",
palette,
move |_, _, cx| {
let _ = this_proxy_host
.update(cx, |w, cx| w.set_proxy_mode("per_host", cx));
},
))
.child(seg_btn(
"settings-proxy-global",
"全局默认走代理",
proxy_mode == "global",
palette,
move |_, _, cx| {
let _ = this_proxy_global
.update(cx, |w, cx| w.set_proxy_mode("global", cx));
},
)),
),
),
)
.child(
inner_row_shell(palette).child(
h_flex()
.items_center()
.justify_between()
.gap(px(12.))
.child(
v_flex()
.gap(px(2.))
.child(
div()
.text_size(px(12.))
.font_weight(FontWeight::SEMIBOLD)
.text_color(palette.text_primary)
.child("本机代理端口 (127.0.0.1)"),
)
.child(
div()
.text_size(px(11.))
.text_color(palette.text_faint)
.child("自动兼容 SOCKS5、HTTP CONNECT 与 Mixed 混合端口(填写端口号回车或点击保存即可)"),
),
)
.child(
h_flex()
.flex_shrink_0()
.items_center()
.gap(px(8.))
.child(
div()
.text_size(px(12.))
.font_weight(FontWeight::MEDIUM)
.text_color(palette.text_muted)
.child("127.0.0.1 :"),
)
.child(
div()
.w(px(96.))
.child(Input::new(&workspace.settings.proxy_port_input)),
)
.child(
div()
.id("settings-proxy-save")
.test_support()
.rounded_lg()
.bg(palette.primary_bg)
.text_color(palette.primary_fg)
.px(px(12.))
.py(px(6.))
.text_size(px(12.))
.font_weight(FontWeight::MEDIUM)
.cursor_pointer()
.on_click(move |_, _, cx| {
let _ = this_proxy_save.update(cx, |w, cx| {
w.save_proxy_port_from_input(cx);
});
})
.child("保存端口"),
),
),
),
),
);
v_flex()
.id("settings-panel-general")
.test_support()
.gap(px(16.))
.child(card1)
.child(proxy_card)
.child(card2)
.into_any_element()
}
@@ -1638,14 +1891,14 @@ fn render_calls_tab(
)
.child(
v_flex()
.w(px(140.))
.w(px(148.))
.gap(px(4.))
.child(
div()
.text_size(px(11.))
.font_weight(FontWeight::MEDIUM)
.text_color(palette.text_muted)
.child("主机(点击切换)"),
.child("主机"),
)
.child(
div()
@@ -1655,7 +1908,11 @@ fn render_calls_tab(
.px(px(10.))
.rounded_xl()
.border_1()
.border_color(palette.border)
.border_color(if s.host_dropdown_open {
palette.text_secondary
} else {
palette.border
})
.bg(palette.card_solid_bg)
.flex()
.items_center()
@@ -1663,21 +1920,24 @@ fn render_calls_tab(
.text_size(px(11.))
.text_color(palette.text_primary)
.cursor_pointer()
.on_mouse_down(MouseButton::Left, |_, _, cx| {
cx.stop_propagation();
})
.on_click(move |_, _, cx| {
let _ = this_host_cycle.update(cx, |w, cx| {
let mut opts = vec![String::new()];
opts.extend(w.settings.call_hosts.clone());
let pos = opts
.iter()
.position(|h| *h == w.settings.filter_host)
.unwrap_or(0);
w.settings.filter_host = opts[(pos + 1) % opts.len()].clone();
w.reload_mcp_calls(cx);
w.settings.host_dropdown_open = !w.settings.host_dropdown_open;
w.settings.tool_dropdown_open = false;
w.settings.ok_dropdown_open = false;
cx.notify();
});
})
.child(div().truncate().child(host_label))
.child(lucide_icon(
CatalogIcon::ChevronsUpDown,
if s.host_dropdown_open {
CatalogIcon::ChevronUp
} else {
CatalogIcon::ChevronDown
},
12.,
palette.text_faint,
)),
@@ -1685,14 +1945,14 @@ fn render_calls_tab(
)
.child(
v_flex()
.w(px(140.))
.w(px(148.))
.gap(px(4.))
.child(
div()
.text_size(px(11.))
.font_weight(FontWeight::MEDIUM)
.text_color(palette.text_muted)
.child("工具(点击切换)"),
.child("工具"),
)
.child(
div()
@@ -1702,7 +1962,11 @@ fn render_calls_tab(
.px(px(10.))
.rounded_xl()
.border_1()
.border_color(palette.border)
.border_color(if s.tool_dropdown_open {
palette.text_secondary
} else {
palette.border
})
.bg(palette.card_solid_bg)
.flex()
.items_center()
@@ -1710,21 +1974,24 @@ fn render_calls_tab(
.text_size(px(11.))
.text_color(palette.text_primary)
.cursor_pointer()
.on_mouse_down(MouseButton::Left, |_, _, cx| {
cx.stop_propagation();
})
.on_click(move |_, _, cx| {
let _ = this_tool_cycle.update(cx, |w, cx| {
let mut opts = vec![String::new()];
opts.extend(w.settings.call_tools.clone());
let pos = opts
.iter()
.position(|t| *t == w.settings.filter_tool)
.unwrap_or(0);
w.settings.filter_tool = opts[(pos + 1) % opts.len()].clone();
w.reload_mcp_calls(cx);
w.settings.tool_dropdown_open = !w.settings.tool_dropdown_open;
w.settings.host_dropdown_open = false;
w.settings.ok_dropdown_open = false;
cx.notify();
});
})
.child(div().truncate().child(tool_label))
.child(lucide_icon(
CatalogIcon::ChevronsUpDown,
if s.tool_dropdown_open {
CatalogIcon::ChevronUp
} else {
CatalogIcon::ChevronDown
},
12.,
palette.text_faint,
)),
@@ -1749,7 +2016,11 @@ fn render_calls_tab(
.px(px(10.))
.rounded_xl()
.border_1()
.border_color(palette.border)
.border_color(if s.ok_dropdown_open {
palette.text_secondary
} else {
palette.border
})
.bg(palette.card_solid_bg)
.flex()
.items_center()
@@ -1757,19 +2028,24 @@ fn render_calls_tab(
.text_size(px(11.))
.text_color(palette.text_primary)
.cursor_pointer()
.on_mouse_down(MouseButton::Left, |_, _, cx| {
cx.stop_propagation();
})
.on_click(move |_, _, cx| {
let _ = this_ok_cycle.update(cx, |w, cx| {
w.settings.filter_ok = match w.settings.filter_ok.as_str() {
"" => "true".into(),
"true" => "false".into(),
_ => String::new(),
};
w.reload_mcp_calls(cx);
w.settings.ok_dropdown_open = !w.settings.ok_dropdown_open;
w.settings.host_dropdown_open = false;
w.settings.tool_dropdown_open = false;
cx.notify();
});
})
.child(ok_label)
.child(lucide_icon(
CatalogIcon::ChevronsUpDown,
if s.ok_dropdown_open {
CatalogIcon::ChevronUp
} else {
CatalogIcon::ChevronDown
},
12.,
palette.text_faint,
)),
@@ -1800,6 +2076,173 @@ fn render_calls_tab(
);
}
let mut filter_section = v_flex().w_full().gap(px(8.)).child(filter_bar);
if s.host_dropdown_open {
let mut opts = vec![(String::new(), "全部主机".to_owned())];
opts.extend(s.call_hosts.iter().map(|h| (h.clone(), h.clone())));
let mut list_inner = h_flex().flex_wrap().gap(px(6.));
for (idx, (val, label)) in opts.into_iter().enumerate() {
let selected = val == s.filter_host;
let this_pick = this.clone();
let picked = val.clone();
let chip = div()
.id(SharedString::from(format!("calls-host-opt-{idx}")))
.test_support()
.rounded_lg()
.border_1()
.px(px(10.))
.py(px(4.))
.text_size(px(11.))
.cursor_pointer()
.on_click(move |_, _, cx| {
let next = picked.clone();
let _ = this_pick.update(cx, |w, cx| {
w.settings.filter_host = next;
w.settings.host_dropdown_open = false;
w.reload_mcp_calls(cx);
});
})
.child(label);
list_inner = list_inner.child(if selected {
chip.border_color(palette.primary_bg)
.bg(palette.primary_bg)
.text_color(palette.primary_fg)
.font_weight(FontWeight::MEDIUM)
} else {
chip.border_color(palette.border)
.bg(palette.card_solid_bg)
.text_color(palette.text_secondary)
.hover(|s| s.bg(palette.subtle_bg))
});
}
filter_section = filter_section.child(
div()
.id("calls-host-dropdown")
.test_support()
.max_h(px(180.))
.overflow_y_scroll()
.rounded_xl()
.border_1()
.border_color(palette.border)
.bg(palette.muted_surface_bg)
.p(px(10.))
.on_mouse_down(MouseButton::Left, |_, _, cx| {
cx.stop_propagation();
})
.child(list_inner),
);
}
if s.tool_dropdown_open {
let mut opts = vec![(String::new(), "全部工具".to_owned())];
opts.extend(s.call_tools.iter().map(|t| (t.clone(), t.clone())));
let mut list_inner = h_flex().flex_wrap().gap(px(6.));
for (idx, (val, label)) in opts.into_iter().enumerate() {
let selected = val == s.filter_tool;
let this_pick = this.clone();
let picked = val.clone();
let chip = div()
.id(SharedString::from(format!("calls-tool-opt-{idx}")))
.test_support()
.rounded_lg()
.border_1()
.px(px(10.))
.py(px(4.))
.text_size(px(11.))
.cursor_pointer()
.on_click(move |_, _, cx| {
let next = picked.clone();
let _ = this_pick.update(cx, |w, cx| {
w.settings.filter_tool = next;
w.settings.tool_dropdown_open = false;
w.reload_mcp_calls(cx);
});
})
.child(label);
list_inner = list_inner.child(if selected {
chip.border_color(palette.primary_bg)
.bg(palette.primary_bg)
.text_color(palette.primary_fg)
.font_weight(FontWeight::MEDIUM)
} else {
chip.border_color(palette.border)
.bg(palette.card_solid_bg)
.text_color(palette.text_secondary)
.hover(|s| s.bg(palette.subtle_bg))
});
}
filter_section = filter_section.child(
div()
.id("calls-tool-dropdown")
.test_support()
.max_h(px(160.))
.overflow_y_scroll()
.rounded_xl()
.border_1()
.border_color(palette.border)
.bg(palette.muted_surface_bg)
.p(px(10.))
.on_mouse_down(MouseButton::Left, |_, _, cx| {
cx.stop_propagation();
})
.child(list_inner),
);
}
if s.ok_dropdown_open {
let opts: [(&str, &str); 3] = [("", "全部"), ("true", "成功"), ("false", "失败")];
let mut list_inner = h_flex().flex_wrap().gap(px(6.));
for (idx, (val, label)) in opts.into_iter().enumerate() {
let selected = val == s.filter_ok;
let this_pick = this.clone();
let picked = val.to_owned();
let chip = div()
.id(SharedString::from(format!("calls-ok-opt-{idx}")))
.test_support()
.rounded_lg()
.border_1()
.px(px(10.))
.py(px(4.))
.text_size(px(11.))
.cursor_pointer()
.on_click(move |_, _, cx| {
let next = picked.clone();
let _ = this_pick.update(cx, |w, cx| {
w.settings.filter_ok = next;
w.settings.ok_dropdown_open = false;
w.reload_mcp_calls(cx);
});
})
.child(label);
list_inner = list_inner.child(if selected {
chip.border_color(palette.primary_bg)
.bg(palette.primary_bg)
.text_color(palette.primary_fg)
.font_weight(FontWeight::MEDIUM)
} else {
chip.border_color(palette.border)
.bg(palette.card_solid_bg)
.text_color(palette.text_secondary)
.hover(|s| s.bg(palette.subtle_bg))
});
}
filter_section = filter_section.child(
div()
.id("calls-ok-dropdown")
.test_support()
.rounded_xl()
.border_1()
.border_color(palette.border)
.bg(palette.muted_surface_bg)
.p(px(10.))
.on_mouse_down(MouseButton::Left, |_, _, cx| {
cx.stop_propagation();
})
.child(list_inner),
);
}
let list_el = if s.calls.is_empty() {
div()
.rounded_xl()
@@ -1839,13 +2282,15 @@ fn render_calls_tab(
let call_id = call.id.clone();
let this_row = this.clone();
let subtle = palette.subtle_bg;
let one_line_args = single_line_preview(&call.args, "{}");
let one_line_summary = single_line_preview(&call.summary, "—");
table = table.child(
h_flex()
.id(SharedString::from(format!("mcp-call-row-{}", call.id)))
.test_support()
.items_start()
.py(px(8.))
.h(px(48.))
.items_center()
.px(px(8.))
.border_t_1()
.border_color(palette.divider)
@@ -1859,12 +2304,14 @@ fn render_calls_tab(
.child(
div()
.w(px(20.))
.flex_shrink_0()
.text_color(palette.text_faint)
.child(if expanded { "▾" } else { "▸" }),
)
.child(
div()
.w(px(148.))
.flex_shrink_0()
.text_color(palette.text_muted)
.truncate()
.child(call.ts.replace('T', " ").chars().take(19).collect::<String>()),
@@ -1872,6 +2319,7 @@ fn render_calls_tab(
.child(
div()
.w(px(110.))
.flex_shrink_0()
.font_weight(FontWeight::MEDIUM)
.text_color(palette.text_primary)
.truncate()
@@ -1880,6 +2328,7 @@ fn render_calls_tab(
.child(
div()
.w(px(96.))
.flex_shrink_0()
.text_color(palette.text_secondary)
.truncate()
.child(if call.host.is_empty() {
@@ -1889,48 +2338,61 @@ fn render_calls_tab(
}),
)
.child(
div().w(px(56.)).child(
div()
.rounded(px(4.))
.px(px(6.))
.py(px(1.))
.text_size(px(10.))
.font_weight(FontWeight::MEDIUM)
.bg(if call.ok {
Hsla::from(rgb(0xecfdf5))
} else {
Hsla::from(rgb(0xfef2f2))
})
.text_color(if call.ok {
Hsla::from(rgb(0x059669))
} else {
Hsla::from(rgb(0xdc2626))
})
.child(if call.ok { "成功" } else { "失败" }),
),
div()
.w(px(56.))
.flex_shrink_0()
.flex()
.items_center()
.child(
div()
.rounded(px(4.))
.px(px(6.))
.py(px(1.))
.text_size(px(10.))
.font_weight(FontWeight::MEDIUM)
.bg(if call.ok {
Hsla::from(rgb(0xecfdf5))
} else {
Hsla::from(rgb(0xfef2f2))
})
.text_color(if call.ok {
Hsla::from(rgb(0x059669))
} else {
Hsla::from(rgb(0xdc2626))
})
.child(if call.ok { "成功" } else { "失败" }),
),
)
.child(
div()
.w(px(64.))
.flex_shrink_0()
.text_color(palette.text_muted)
.truncate()
.child(format!("{}ms", call.duration_ms)),
)
.child(
v_flex()
.min_w_0()
.flex_1()
.justify_center()
.gap(px(2.))
.overflow_hidden()
.child(
div()
.h(px(16.))
.w_full()
.text_color(palette.text_secondary)
.truncate()
.child(call.args.clone()),
.child(one_line_args),
)
.child(
div()
.h(px(16.))
.w_full()
.text_color(palette.text_faint)
.truncate()
.child(call.summary.clone()),
.child(one_line_summary),
),
),
);
@@ -1944,6 +2406,8 @@ fn render_calls_tab(
let res_copy = full_res.clone();
let this_copy_a = this.clone();
let this_copy_r = this.clone();
let detail_args_id = SharedString::from(format!("call-detail-args-{}", call.id));
let detail_res_id = SharedString::from(format!("call-detail-res-{}", call.id));
let detail_box = if s.loading_detail && s.expanded_detail.is_none() {
div()
@@ -2003,6 +2467,9 @@ fn render_calls_tab(
)
.child(
div()
.id(detail_args_id)
.max_h(px(260.))
.overflow_y_scroll()
.rounded_xl()
.border_1()
.border_color(palette.border)
@@ -2062,6 +2529,9 @@ fn render_calls_tab(
)
.child(
div()
.id(detail_res_id)
.max_h(px(340.))
.overflow_y_scroll()
.rounded_xl()
.border_1()
.border_color(palette.border)
@@ -2133,7 +2603,7 @@ fn render_calls_tab(
.child("刷新"),
),
)
.child(filter_bar)
.child(filter_section)
.child(
div()
.text_size(px(11.))
@@ -2455,3 +2925,14 @@ fn render_about_tab(
.child(stack)
.into_any_element()
}
/// 将可能包含 `\r`/`\n`/`\t` 的多行参数或摘要压成单行,防止未展开行被换行符撑高或因空串坍缩。
pub fn single_line_preview(raw: &str, fallback: &str) -> String {
let joined: String = raw.split_whitespace().collect::<Vec<_>>().join(" ");
if joined.is_empty() {
fallback.to_owned()
} else {
joined
}
}
+101 -12
View File
@@ -15,12 +15,14 @@ use alacritty_terminal::event::VoidListener;
use alacritty_terminal::grid::{Dimensions, Scroll};
use alacritty_terminal::index::Point;
use alacritty_terminal::term::cell::Flags;
use alacritty_terminal::term::{Config as TermConfig, Term};
use alacritty_terminal::term::{Config as TermConfig, Term, TermMode};
use alacritty_terminal::vte::ansi::{Color, CursorShape, NamedColor, Processor};
use futures_util::{SinkExt, StreamExt};
use serde::{Deserialize, Serialize};
use tokio_tungstenite::tungstenite::Message;
use crate::api::{deserialize_null_bool, deserialize_null_string, deserialize_null_u16};
pub const MAX_RECONNECT_ATTEMPTS: u32 = 6;
#[derive(Clone, Copy)]
@@ -44,24 +46,27 @@ impl Dimensions for GridSize {
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PaneMeta {
#[serde(default)]
#[serde(default, deserialize_with = "deserialize_null_string")]
pub id: String,
#[serde(default)]
#[serde(default, deserialize_with = "deserialize_null_string")]
pub host_id: String,
#[serde(default)]
#[serde(default, deserialize_with = "deserialize_null_u16")]
pub cols: u16,
#[serde(default)]
#[serde(default, deserialize_with = "deserialize_null_u16")]
pub rows: u16,
#[serde(default)]
pub cwd: Option<String>,
#[serde(default)]
pub title: Option<String>,
#[serde(default)]
#[serde(default, deserialize_with = "deserialize_null_bool")]
pub running: bool,
#[serde(default)]
pub last_exit_code: Option<i32>,
#[serde(default)]
#[serde(default, deserialize_with = "deserialize_null_bool")]
pub bracketed_paste: bool,
#[serde(default)]
#[serde(default, deserialize_with = "deserialize_null_bool")]
pub headless: bool,
#[serde(default)]
#[serde(default, deserialize_with = "deserialize_null_bool")]
pub exited: bool,
}
@@ -93,6 +98,7 @@ pub struct Snapshot {
pub cursor_shape: CursorShape,
pub display_offset: usize,
pub history_size: usize,
pub app_cursor: bool,
}
pub enum SessionEvent {
@@ -181,6 +187,7 @@ impl Session {
cursor_shape: CursorShape::Block,
display_offset: 0,
history_size: 0,
app_cursor: false,
};
let _ = event_tx.send(SessionEvent::Created {
pane_id: "mock-pane-1".into(),
@@ -336,9 +343,11 @@ async fn worker_loop(
message = ws_rx.next() => {
match message {
Some(Ok(Message::Binary(data))) => {
let mut restore_offset = 0usize;
let payload: &[u8] = match data.first() {
Some(0x02) => {
// 整份历史字节环回放:先重置终端再写入,杜绝重复内容叠加
// 整份历史字节环回放:先记录当前历史滚动偏移,重置终端写入后再恢复偏移
restore_offset = term.grid().display_offset();
term = Term::new(TermConfig::default(), &size, VoidListener);
processor = Processor::new();
&data[1..]
@@ -349,6 +358,9 @@ async fn worker_loop(
if !payload.is_empty() {
processor.advance(&mut term, payload);
}
if restore_offset > 0 {
term.scroll_display(Scroll::Delta(restore_offset as i32));
}
store_snapshot(&snapshot, &term, size);
let _ = events.send(SessionEvent::Updated);
}
@@ -375,12 +387,22 @@ async fn worker_loop(
if is_auto_da_response(&bytes) {
continue;
}
if term.grid().display_offset() > 0 {
term.scroll_display(Scroll::Bottom);
store_snapshot(&snapshot, &term, size);
let _ = events.send(SessionEvent::Updated);
}
let mut frame = Vec::with_capacity(1 + bytes.len());
frame.push(0x01);
frame.extend_from_slice(&bytes);
let _ = ws_tx.send(Message::Binary(frame.into())).await;
}
Ok(SessionCommand::Paste(text)) => {
if term.grid().display_offset() > 0 {
term.scroll_display(Scroll::Bottom);
store_snapshot(&snapshot, &term, size);
let _ = events.send(SessionEvent::Updated);
}
let _ = ws_tx
.send(Message::Text(
serde_json::json!({
@@ -608,10 +630,11 @@ fn store_snapshot(
size: GridSize,
) {
let content = term.renderable_content();
let display_offset = content.display_offset as i32;
let mut cells = vec![CellSnapshot::default(); size.cols * size.lines];
for indexed in content.display_iter {
let Point { line, column } = indexed.point;
let row = line.0;
let row = line.0 + display_offset;
if row < 0 || row as usize >= size.lines || column.0 >= size.cols {
continue;
}
@@ -624,7 +647,7 @@ fn store_snapshot(
};
}
let cursor_point = content.cursor.point;
let cursor_row = cursor_point.line.0;
let cursor_row = cursor_point.line.0 + display_offset;
let cursor = if cursor_row >= 0 && (cursor_row as usize) < size.lines {
Some((cursor_row as usize, cursor_point.column.0))
} else {
@@ -638,8 +661,74 @@ fn store_snapshot(
cursor_shape: content.cursor.shape,
display_offset: content.display_offset,
history_size: term.grid().history_size(),
app_cursor: content.mode.contains(TermMode::APP_CURSOR),
};
if let Ok(mut guard) = snapshot.lock() {
*guard = Some(next);
}
}
#[cfg(test)]
mod tests {
use std::sync::{Arc, Mutex};
use alacritty_terminal::event::VoidListener;
use alacritty_terminal::grid::Scroll;
use alacritty_terminal::term::{Config as TermConfig, Term};
use alacritty_terminal::vte::ansi::Processor;
use super::{GridSize, Snapshot, store_snapshot};
fn row_text(snap: &Snapshot, row: usize) -> String {
let start = row * snap.cols;
snap.cells[start..start + snap.cols]
.iter()
.map(|c| c.c)
.collect::<String>()
.trim_end()
.to_owned()
}
#[test]
fn scrollback_history_is_visible_when_scrolled_up() {
let size = GridSize { cols: 40, lines: 5 };
let mut term = Term::new(TermConfig::default(), &size, VoidListener);
let mut processor: Processor = Processor::new();
let holder = Arc::new(Mutex::new(None));
// 连续输出 8 行(超出 5 行屏幕高度,前 3 行进入负数行号的 scrollback 历史区)
for i in 1..=8 {
let line = if i < 8 {
format!("LINE-{i:02}-IPV4-RESULT\r\n")
} else {
format!("LINE-{i:02}-BOTTOM")
};
processor.advance(&mut term, line.as_bytes());
}
store_snapshot(&holder, &term, size);
let bottom_snap = holder.lock().unwrap().clone().unwrap();
assert_eq!(bottom_snap.display_offset, 0);
assert_eq!(bottom_snap.history_size, 3);
assert_eq!(row_text(&bottom_snap, 0), "LINE-04-IPV4-RESULT");
assert_eq!(row_text(&bottom_snap, 4), "LINE-08-BOTTOM");
// 向上滚动 3 行回看历史:第 0 行必须显示滚入历史区的 LINE-01,不能消失为空白
term.scroll_display(Scroll::Delta(3));
store_snapshot(&holder, &term, size);
let top_snap = holder.lock().unwrap().clone().unwrap();
assert_eq!(top_snap.display_offset, 3);
assert_eq!(row_text(&top_snap, 0), "LINE-01-IPV4-RESULT");
assert_eq!(row_text(&top_snap, 1), "LINE-02-IPV4-RESULT");
assert_eq!(row_text(&top_snap, 2), "LINE-03-IPV4-RESULT");
assert_eq!(row_text(&top_snap, 4), "LINE-05-IPV4-RESULT");
// 再向下滚回底部
term.scroll_display(Scroll::Delta(-3));
store_snapshot(&holder, &term, size);
let restored = holder.lock().unwrap().clone().unwrap();
assert_eq!(restored.display_offset, 0);
assert_eq!(row_text(&restored, 4), "LINE-08-BOTTOM");
}
}
+167 -23
View File
@@ -44,6 +44,7 @@ pub struct TerminalView {
dragging_selection: bool,
last_cell_origin: Point<Pixels>,
last_cell_size: gpui_kit::Size<Pixels>,
last_viewport_size: gpui_kit::Size<Pixels>,
// 中文 IME 预编辑状态
pub marked_text: String,
@@ -52,6 +53,7 @@ pub struct TerminalView {
// 终端右键菜单坐标(相对于终端视图左上角)
pub context_menu_pos: Option<Point<Pixels>>,
scroll_remainder: f32,
// 刷新/重连计数与无头测试样本
pub refresh_count: u32,
@@ -133,10 +135,12 @@ impl TerminalView {
dragging_selection: false,
last_cell_origin: point(px(0.), px(0.)),
last_cell_size: size(px(8.), px(18.)),
last_viewport_size: size(px(800.), px(480.)),
marked_text: String::new(),
marked_selection: 0..0,
ime_cursor_bounds: None,
context_menu_pos: None,
scroll_remainder: 0.0,
refresh_count: 0,
mock_sample_line,
};
@@ -286,10 +290,12 @@ impl TerminalView {
rows: u16,
cell_origin: Point<Pixels>,
cell_size: gpui_kit::Size<Pixels>,
viewport_size: gpui_kit::Size<Pixels>,
ime_bounds: Option<Bounds<Pixels>>,
) {
self.last_cell_origin = cell_origin;
self.last_cell_size = cell_size;
self.last_viewport_size = viewport_size;
self.ime_cursor_bounds = ime_bounds;
if cols >= 2 && rows >= 1 && (cols != self.cols || rows != self.rows) {
self.cols = cols;
@@ -314,6 +320,21 @@ impl TerminalView {
self.session.send(SessionCommand::Scroll(lines));
}
fn scroll_by_delta(&mut self, delta_lines: f32) {
if delta_lines == 0.0 {
return;
}
if self.scroll_remainder != 0.0 && self.scroll_remainder.signum() != delta_lines.signum() {
self.scroll_remainder = 0.0;
}
let total = self.scroll_remainder + delta_lines;
let lines = total.trunc() as i32;
self.scroll_remainder = total - lines as f32;
if lines != 0 {
self.scroll(lines);
}
}
/// 提取当前鼠标选区内的纯文本。
pub fn selected_text(&self) -> Option<String> {
let ((r0, c0), (r1, c1)) = normalized_selection(self.selection)?;
@@ -393,9 +414,38 @@ impl TerminalView {
return;
}
// 2.5 Shift+PageUp / Shift+PageDown / Shift+Up / Shift+Down 翻看终端历史记录
if ks.modifiers.shift && !ctrl_or_cmd && !ks.modifiers.alt {
let page = (self.rows.saturating_sub(2)).max(1) as i32;
match key.as_str() {
"pageup" => {
self.scroll(page);
cx.stop_propagation();
return;
}
"pagedown" => {
self.scroll(-page);
cx.stop_propagation();
return;
}
"up" => {
self.scroll(1);
cx.stop_propagation();
return;
}
"down" => {
self.scroll(-1);
cx.stop_propagation();
return;
}
_ => {}
}
}
// 3. 特殊控制键与 Ctrl/Alt 组合键由这里直接编码发送并停止冒泡;
// 普通可打印字符与中文输入法留给 EntityInputHandler 处理,避免重复输入。
if let Some(bytes) = encode_special_key(ks) {
let app_cursor = self.snapshot.as_ref().is_some_and(|s| s.app_cursor);
if let Some(bytes) = encode_special_key(ks, app_cursor) {
self.selection = None;
self.context_menu_pos = None;
self.send_input(bytes);
@@ -537,8 +587,12 @@ impl Render for TerminalView {
MouseButton::Right,
cx.listener(|this, event: &MouseDownEvent, window, cx| {
window.focus(&this.focus_handle, cx);
let rel_x = (event.position.x - this.last_cell_origin.x).max(px(12.));
let rel_y = (event.position.y - this.last_cell_origin.y).max(px(12.));
let max_x = (this.last_viewport_size.width - px(164.)).max(px(8.));
let max_y = (this.last_viewport_size.height - px(140.)).max(px(8.));
let rel_x = (event.position.x - this.last_cell_origin.x + px(12.))
.clamp(px(8.), max_x);
let rel_y = (event.position.y - this.last_cell_origin.y + px(8.))
.clamp(px(8.), max_y);
this.context_menu_pos = Some(point(rel_x, rel_y));
cx.notify();
cx.stop_propagation();
@@ -590,14 +644,14 @@ impl Render for TerminalView {
.bg(Hsla::from(rgb(colors.background)))
.track_focus(&focus)
.on_key_down(cx.listener(Self::on_key_down))
.on_scroll_wheel(cx.listener(|this, event: &ScrollWheelEvent, _window, _cx| {
let dy = event.delta.pixel_delta(px(FONT_SIZE)).y;
if dy != px(0.) {
let lines = (dy.as_f32() / (FONT_SIZE * LINE_HEIGHT)).round() as i32;
if lines != 0 {
this.scroll(lines);
}
}
.on_scroll_wheel(cx.listener(|this, event: &ScrollWheelEvent, _window, cx| {
let line_px = (FONT_SIZE * LINE_HEIGHT).max(1.0);
let delta_lines = match event.delta {
ScrollDelta::Lines(p) => p.y * 3.0,
ScrollDelta::Pixels(p) => p.y.as_f32() / line_px,
};
this.scroll_by_delta(delta_lines);
cx.stop_propagation();
}))
.child(canvas_area);
@@ -970,6 +1024,7 @@ struct TerminalPaintState {
segments: Vec<Segment>,
backgrounds: Vec<PaintQuad>,
cursor: Option<PaintQuad>,
scrollbar_thumb: Option<PaintQuad>,
preedit_line: Option<(Point<Pixels>, ShapedLine, PaintQuad)>,
ime_cursor_bounds: Option<Bounds<Pixels>>,
cell_width: Pixels,
@@ -1041,6 +1096,7 @@ impl Element for TerminalElement {
segments: Vec::new(),
backgrounds: Vec::new(),
cursor: None,
scrollbar_thumb: None,
preedit_line: None,
ime_cursor_bounds: None,
cell_width,
@@ -1219,13 +1275,22 @@ impl Element for TerminalElement {
// 3. 光标与中文 IME 预编辑串
if let Some((row, col)) = snapshot.cursor.filter(|(r, _)| *r < rows_to_draw) {
let is_wide_cursor = col < snapshot.cols
&& snapshot.cells[row * snapshot.cols + col]
.flags
.contains(Flags::WIDE_CHAR);
let cursor_w = if is_wide_cursor {
cell_width * 2.0
} else {
cell_width
};
let cursor_origin = point(
bounds.left() + cell_width * col as f32,
bounds.top() + line_height * row as f32,
);
let cursor_rect = Bounds {
origin: cursor_origin,
size: size(cell_width, line_height),
size: size(cursor_w, line_height),
};
state.ime_cursor_bounds = Some(cursor_rect);
@@ -1250,7 +1315,7 @@ impl Element for TerminalElement {
);
let bg_rect = Bounds {
origin: cursor_origin,
size: size(shaped.width().max(cell_width), line_height),
size: size(shaped.width().max(cursor_w), line_height),
};
state.preedit_line = Some((cursor_origin, shaped, fill(bg_rect, default_bg)));
} else if snapshot.cursor_shape != CursorShape::Hidden {
@@ -1268,7 +1333,7 @@ impl Element for TerminalElement {
},
CursorShape::Underline => Bounds {
origin: point(cursor_origin.x, cursor_origin.y + line_height - px(2.)),
size: size(cell_width, px(2.)),
size: size(cursor_w, px(2.)),
},
_ => cursor_rect,
};
@@ -1276,6 +1341,29 @@ impl Element for TerminalElement {
}
}
// 4. 历史滚动条指示器(有 scrollback 历史时在右侧边缘绘制 4px 滑块)
if snapshot.history_size > 0 && bounds.size.height > px(16.) {
let total_lines = (snapshot.history_size + snapshot.rows).max(1) as f32;
let track_h = bounds.size.height.as_f32();
let ratio = (snapshot.rows as f32 / total_lines).clamp(0.05, 1.0);
let thumb_h = (track_h * ratio).max(20.0).min(track_h);
let top_lines = snapshot
.history_size
.saturating_sub(snapshot.display_offset) as f32;
let progress = (top_lines / (snapshot.history_size as f32)).clamp(0.0, 1.0);
let thumb_y = bounds.top().as_f32() + (track_h - thumb_h) * progress;
let thumb_rect = Bounds {
origin: point(bounds.right() - px(5.), px(thumb_y)),
size: size(px(4.), px(thumb_h)),
};
let thumb_color = if self.dark {
Hsla::from(rgb(0xffffff)).opacity(if snapshot.display_offset > 0 { 0.36 } else { 0.16 })
} else {
Hsla::from(rgb(0x18181b)).opacity(if snapshot.display_offset > 0 { 0.30 } else { 0.14 })
};
state.scrollbar_thumb = Some(fill(thumb_rect, thumb_color).corner_radii(px(2.)));
}
state
}
@@ -1301,9 +1389,17 @@ impl Element for TerminalElement {
let rows = state.computed_rows;
let cell_origin = bounds.origin;
let cell_size = size(state.cell_width, state.line_height);
let viewport_size = bounds.size;
let ime_bounds = state.ime_cursor_bounds;
self.view.update(cx, |view, _| {
view.sync_viewport(cols, rows, cell_origin, cell_size, ime_bounds);
view.sync_viewport(
cols,
rows,
cell_origin,
cell_size,
viewport_size,
ime_bounds,
);
});
let mask = ContentMask { bounds };
@@ -1335,6 +1431,9 @@ impl Element for TerminalElement {
if let Some(cursor) = state.cursor.take() {
window.paint_quad(cursor);
}
if let Some(thumb) = state.scrollbar_thumb.take() {
window.paint_quad(thumb);
}
});
}
}
@@ -1476,27 +1575,72 @@ fn styled_font(font: &Font, flags: Flags) -> Font {
}
/// 只编码特殊控制键与 Ctrl/Alt 组合键;普通字符与 IME 交给 `EntityInputHandler`。
fn encode_special_key(keystroke: &Keystroke) -> Option<Vec<u8>> {
fn encode_special_key(keystroke: &Keystroke, app_cursor: bool) -> Option<Vec<u8>> {
let key = keystroke.key.as_str();
let ctrl = keystroke.modifiers.control;
let alt = keystroke.modifiers.alt;
let shift = keystroke.modifiers.shift;
let sequence: Vec<u8> = match key {
"enter" => b"\r".to_vec(),
"backspace" => b"\x7f".to_vec(),
"tab" => {
if keystroke.modifiers.shift {
if shift {
b"\x1b[Z".to_vec()
} else {
b"\t".to_vec()
}
}
"escape" => b"\x1b".to_vec(),
"up" => b"\x1b[A".to_vec(),
"down" => b"\x1b[B".to_vec(),
"right" => b"\x1b[C".to_vec(),
"left" => b"\x1b[D".to_vec(),
"home" => b"\x1b[H".to_vec(),
"end" => b"\x1b[F".to_vec(),
"up" => {
if ctrl {
return Some(b"\x1b[1;5A".to_vec());
} else if app_cursor && !alt && !shift {
return Some(b"\x1bOA".to_vec());
} else {
b"\x1b[A".to_vec()
}
}
"down" => {
if ctrl {
return Some(b"\x1b[1;5B".to_vec());
} else if app_cursor && !alt && !shift {
return Some(b"\x1bOB".to_vec());
} else {
b"\x1b[B".to_vec()
}
}
"right" => {
if ctrl {
return Some(b"\x1b[1;5C".to_vec());
} else if app_cursor && !alt && !shift {
return Some(b"\x1bOC".to_vec());
} else {
b"\x1b[C".to_vec()
}
}
"left" => {
if ctrl {
return Some(b"\x1b[1;5D".to_vec());
} else if app_cursor && !alt && !shift {
return Some(b"\x1bOD".to_vec());
} else {
b"\x1b[D".to_vec()
}
}
"home" => {
if app_cursor && !ctrl && !alt && !shift {
return Some(b"\x1bOH".to_vec());
} else {
b"\x1b[H".to_vec()
}
}
"end" => {
if app_cursor && !ctrl && !alt && !shift {
return Some(b"\x1bOF".to_vec());
} else {
b"\x1b[F".to_vec()
}
}
"insert" => b"\x1b[2~".to_vec(),
"delete" => b"\x1b[3~".to_vec(),
"pageup" => b"\x1b[5~".to_vec(),
+27 -11
View File
@@ -96,9 +96,17 @@ pub fn render(
}
// 4. 已打开的会话标签页
let mut tabs_row = h_flex()
.id("nav-tabs-scroll")
.min_w_0()
.flex_1()
.items_center()
.gap(px(6.))
.overflow_x_scroll();
for tab in &workspace.open_tabs {
left = left.child(session_tab(workspace, tab, this, palette));
tabs_row = tabs_row.child(session_tab(workspace, tab, this, palette));
}
left = left.child(tabs_row);
// ---- 右组 ----
let mut right = h_flex().flex_shrink_0().items_center().gap(px(8.));
@@ -127,6 +135,9 @@ pub fn render(
let mut filter_btn = pill_button(active_filter, palette)
.id("nav-filter")
.test_support()
.on_mouse_down(MouseButton::Left, |_, _, cx| {
cx.stop_propagation();
})
.on_click(move |_, _, cx| {
let _ = this_filter.update(cx, |w, cx| {
w.filter_open = !w.filter_open;
@@ -144,6 +155,9 @@ pub fn render(
.ml(px(2.))
.px(px(4.))
.rounded_full()
.on_mouse_down(MouseButton::Left, |_, _, cx| {
cx.stop_propagation();
})
.on_click(move |_, _, cx| {
let _ = this_clear_filter.update(cx, |w, cx| {
w.reset_filters();
@@ -155,11 +169,7 @@ pub fn render(
);
}
let mut wrapper = div().relative().child(filter_btn);
if workspace.filter_open {
wrapper = wrapper.child(filter_panel(workspace, this, palette));
}
right = right.child(wrapper);
right = right.child(filter_btn);
// 检测在线按钮
let checking = workspace.checking_all;
@@ -455,7 +465,7 @@ fn filter_summary(workspace: &Workspace) -> String {
}
}
fn filter_panel(
pub fn render_filter_overlay(
workspace: &Workspace,
this: &Entity<Workspace>,
palette: &Palette,
@@ -464,9 +474,9 @@ fn filter_panel(
.id("filter-dropdown-panel")
.test_support()
.absolute()
.top(px(37.))
.right(px(0.))
.w(px(256.))
.top(px(48.))
.right(px(196.))
.w(px(268.))
.rounded_2xl()
.border_1()
.border_color(palette.border)
@@ -682,7 +692,13 @@ fn filter_panel(
.text_color(palette.text_faint)
.child("标签"),
)
.child(tag_chips),
.child(
div()
.id("filter-tags-scroll")
.max_h(px(128.))
.overflow_y_scroll()
.child(tag_chips),
),
);
}
+174 -16
View File
@@ -80,6 +80,7 @@ pub struct Workspace {
pub api: Api,
pub main_window: AnyWindowHandle,
pub standalone_windows: HashMap<String, AnyWindowHandle>,
pub standalone_panes: HashMap<String, Entity<TerminalView>>,
pub focus_handle: FocusHandle,
pub hosts: Vec<Host>,
pub loading: bool,
@@ -156,6 +157,7 @@ impl Workspace {
api,
main_window,
standalone_windows: HashMap::new(),
standalone_panes: HashMap::new(),
focus_handle,
hosts: Vec::new(),
loading: true,
@@ -230,16 +232,38 @@ impl Workspace {
},
],
);
self.settings.calls = vec![McpCallRecord {
id: "call-1".into(),
ts: "2026-10-17T12:00:00Z".into(),
tool: "vps_exec".into(),
host: "vip-hk".into(),
args: "{\"host\":\"vip-hk\",\"command\":\"uname -a\"}".into(),
ok: true,
duration_ms: 82,
summary: "Linux vip-hk 6.8.0 x86_64".into(),
}];
self.settings.calls = vec![
McpCallRecord {
id: "call-1".into(),
ts: "2026-10-17T12:00:00Z".into(),
tool: "vps_exec".into(),
host: "vip-hk".into(),
args: "{\n \"host\": \"vip-hk\",\n \"command\": \"uname -a &&\\ndf -h &&\\nfree -m\"\n}".into(),
ok: true,
duration_ms: 82,
summary: "Linux vip-hk 6.8.0 x86_64\nFilesystem Size Used Avail\n/dev/vda1 40G 12G 28G".into(),
},
McpCallRecord {
id: "call-2".into(),
ts: "2026-10-17T12:01:00Z".into(),
tool: "vps_list".into(),
host: String::new(),
args: "{}".into(),
ok: true,
duration_ms: 4,
summary: String::new(),
},
McpCallRecord {
id: "call-3".into(),
ts: "2026-10-17T12:02:00Z".into(),
tool: "vps_inspect".into(),
host: "us-node".into(),
args: "{\"host\":\"us-node\"}".into(),
ok: false,
duration_ms: 1500,
summary: "connection timeout".into(),
},
];
cx.notify();
}
@@ -400,11 +424,19 @@ impl Workspace {
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),
None => {
let insert_pos = self
.hosts
.iter()
.position(|host| host.kind != "local")
.unwrap_or(self.hosts.len());
self.hosts.insert(insert_pos, updated);
}
}
}
pub fn toggle_detail(&mut self, host_id: &str, cx: &mut Context<Self>) {
self.filter_open = false;
if self.right_wing == Some(RightWing::Detail)
&& self.detail_host_id.as_deref() == Some(host_id)
{
@@ -420,6 +452,7 @@ impl Workspace {
&& host.status != "offline"
{
self.api.send(ApiCommand::PrewarmFiles(host_id.to_owned()));
self.prefetch_files_dir(host_id, "");
}
}
self.sync_current_tab();
@@ -454,11 +487,13 @@ impl Workspace {
pub fn open_terminal(&mut self, host_id: &str, cx: &mut Context<Self>) {
self.page = Page::Workspace;
self.filter_open = false;
self.sync_current_tab();
if let Some(host) = self.host_by_id(host_id)
&& host.status != "offline"
{
self.api.send(ApiCommand::PrewarmFiles(host_id.to_owned()));
self.prefetch_files_dir(host_id, "");
}
let index = match self
.open_tabs
@@ -596,6 +631,9 @@ impl Workspace {
pane.update(cx, |view, cx| view.set_dark(dark, cx));
}
}
for pane in self.standalone_panes.values() {
pane.update(cx, |view, cx| view.set_dark(dark, cx));
}
cx.notify();
}
@@ -705,12 +743,16 @@ impl Workspace {
return;
}
self.standalone_windows.remove(host_id);
self.standalone_panes.remove(host_id);
}
let title = format!("{name} · 终端 · VPS Switch");
let hid = host_id.to_owned();
let hid_for_close = host_id.to_owned();
let dark = self.terminal_dark;
let is_mock = self.mock_terminal_for_test;
let ws_weak = cx.entity().downgrade();
let mut created_view: Option<Entity<TerminalView>> = None;
let options = WindowOptions {
window_bounds: Some(WindowBounds::Windowed(Bounds::centered(
None,
@@ -719,7 +761,7 @@ impl Workspace {
))),
..WindowOptions::default()
};
if let Ok(handle) = cx.open_window(options, move |window, cx| {
if let Ok(handle) = cx.open_window(options, |window, cx| {
window.set_window_title(&title);
let view = cx.new(|cx| {
if is_mock {
@@ -728,12 +770,32 @@ impl Workspace {
TerminalView::new(hid, dark, cx)
}
});
created_view = Some(view.clone());
let view_weak = view.downgrade();
window.on_window_should_close(cx, move |_window, cx| {
let pane_id = view_weak
.upgrade()
.and_then(|v| v.read(cx).pane_id.clone());
if let Some(ws) = ws_weak.upgrade() {
let _ = ws.update(cx, |w, _| {
w.standalone_windows.remove(&hid_for_close);
w.standalone_panes.remove(&hid_for_close);
if let Some(pid) = pane_id {
w.api.send(ApiCommand::DeletePane(pid));
}
});
}
true
});
let fh = view.read(cx).focus_handle.clone();
window.focus(&fh, cx);
cx.new(|cx| Root::new(view, window, cx))
}) {
self.standalone_windows
.insert(host_id.to_owned(), handle.into());
if let Some(v) = created_view {
self.standalone_panes.insert(host_id.to_owned(), v);
}
}
self.show_toast(
format!("已在独立原生窗口打开「{name}」终端"),
@@ -744,6 +806,7 @@ impl Workspace {
pub fn open_files(&mut self, host_id: &str, cx: &mut Context<Self>) {
self.page = Page::Workspace;
self.filter_open = false;
self.sync_current_tab();
match self
.open_tabs
@@ -789,6 +852,7 @@ impl Workspace {
pub fn close_stage(&mut self, cx: &mut Context<Self>) {
self.page = Page::Workspace;
self.filter_open = false;
self.sync_current_tab();
self.center_stage = None;
self.stage_host_id = None;
@@ -800,6 +864,7 @@ impl Workspace {
pub fn activate_tab(&mut self, host_id: &str, cx: &mut Context<Self>) {
self.page = Page::Workspace;
self.filter_open = false;
self.sync_current_tab();
let Some(tab) = self.open_tabs.iter().find(|tab| tab.host_id == host_id) else {
return;
@@ -869,15 +934,20 @@ impl Workspace {
pane.update(cx, |view, cx| view.set_dark(dark, cx));
}
}
for pane in self.standalone_panes.values() {
pane.update(cx, |view, cx| view.set_dark(dark, cx));
}
cx.notify();
}
pub fn open_settings(&mut self, cx: &mut Context<Self>) {
self.filter_open = false;
if self.page == Page::Settings {
self.page = Page::Workspace;
} else {
self.page = Page::Settings;
self.api.send(ApiCommand::LoadSettings);
self.reload_mcp_calls(cx);
}
cx.notify();
}
@@ -1053,8 +1123,12 @@ impl Workspace {
let Some(modal) = &mut self.host_modal else {
return;
};
if modal.saving {
return;
}
match modal.build_payload(cx) {
Ok((payload, editing_id)) => {
modal.saving = true;
match editing_id {
Some(host_id) => self.api.send(ApiCommand::UpdateHost { host_id, payload }),
None => self.api.send(ApiCommand::CreateHost(payload)),
@@ -1111,10 +1185,29 @@ impl Workspace {
self.filter_open = false;
cx.notify();
cx.stop_propagation();
} else if self.settings.host_dropdown_open
|| self.settings.tool_dropdown_open
|| self.settings.ok_dropdown_open
{
self.settings.host_dropdown_open = false;
self.settings.tool_dropdown_open = false;
self.settings.ok_dropdown_open = false;
cx.notify();
cx.stop_propagation();
} else if self.deleting_host.is_some() {
self.deleting_host = None;
cx.notify();
cx.stop_propagation();
} else if self
.host_modal
.as_ref()
.is_some_and(|m| m.jump_dropdown_open)
{
if let Some(m) = &mut self.host_modal {
m.jump_dropdown_open = false;
}
cx.notify();
cx.stop_propagation();
} else if self.host_modal.is_some() {
self.host_modal = None;
cx.notify();
@@ -1136,7 +1229,7 @@ impl Workspace {
fn poll(cx: &mut Context<Self>) {
cx.spawn(async move |this, cx| loop {
cx.background_executor()
.timer(Duration::from_millis(100))
.timer(Duration::from_millis(16))
.await;
let alive = this.update(cx, |this, cx| this.tick(cx));
if alive.is_err() {
@@ -1230,18 +1323,37 @@ impl Workspace {
}
ApiEvent::HostSaved { host, is_edit } => {
let host_id = host.id.clone();
let host_name = host.name.clone();
self.replace_host(host);
if !is_edit {
// 新建主机默认置顶到列表第一屏顶部(紧挨 local 本机之后),并同步保存排序
let ordered_ids: Vec<String> = self
.hosts
.iter()
.filter(|h| h.kind != "local")
.map(|h| h.id.clone())
.collect();
self.api.send(ApiCommand::Reorder { ordered_ids });
// 若被当前筛选条件遮挡,自动清除筛选以便立即可见
if !self.filtered_hosts().iter().any(|h| h.id == host_id) {
self.reset_filters();
}
// 自动触发一次在线与 OS 检测
self.checking.insert(host_id.clone());
self.api.send(ApiCommand::CheckHost(host_id.clone()));
} else {
self.api.send(ApiCommand::LoadHosts);
}
crate::tray::update_hosts(&self.hosts);
self.host_modal = None;
self.detail_host_id = Some(host_id);
self.right_wing = Some(RightWing::Detail);
self.last_inspect = None;
self.api.send(ApiCommand::LoadHosts);
self.toast = Some(Toast {
text: if is_edit {
"主机已更新".into()
format!("已更新主机「{host_name}」")
} else {
"主机已创建".into()
format!("已创建主机「{host_name}」(位于列表顶部,正在检测连接…)")
},
kind: ToastKind::Ok,
at: Instant::now(),
@@ -1249,6 +1361,7 @@ impl Workspace {
}
ApiEvent::HostSaveFailed(error) => {
if let Some(m) = &mut self.host_modal {
m.saving = false;
m.error_msg = Some(error.clone());
}
self.toast = Some(Toast {
@@ -1462,6 +1575,25 @@ impl Workspace {
at: Instant::now(),
});
}
ApiEvent::ProxySaved {
proxy_mode,
proxy_port,
} => {
self.settings.proxy_mode = proxy_mode.clone();
self.settings.proxy_port = proxy_port;
self.settings.pending_proxy_port_sync = Some(proxy_port.to_string());
self.api.send(ApiCommand::LoadHosts);
let mode_label = match proxy_mode.as_str() {
"off" => "已关闭本机代理路由".to_owned(),
"global" => format!("已开启全局默认走本机代理 (127.0.0.1:{proxy_port})"),
_ => format!("本机代理已就绪 (127.0.0.1:{proxy_port},按主机选择)"),
};
self.toast = Some(Toast {
text: mode_label,
kind: ToastKind::Ok,
at: Instant::now(),
});
}
ApiEvent::McpSaved {
mcp_enabled,
mcp_require_api_key,
@@ -1694,6 +1826,11 @@ impl Render for Workspace {
.guard_editor
.update(cx, |ta, cx| ta.set_value(guard_txt, window, cx));
}
if let Some(port_txt) = self.settings.pending_proxy_port_sync.take() {
self.settings
.proxy_port_input
.update(cx, |input, cx| input.set_value(port_txt, window, cx));
}
let this = cx.entity();
let palette = Palette::for_mode(self.is_dark);
@@ -1702,7 +1839,10 @@ impl Render for Workspace {
let main = match self.page {
Page::Settings => div()
.flex_1()
.w_full()
.h_full()
.min_h_0()
.overflow_hidden()
.child(settings_page::render(self, &this, &palette, cx))
.into_any_element(),
Page::Workspace => {
@@ -1775,6 +1915,7 @@ impl Render for Workspace {
let mut row = div()
.flex_1()
.min_h_0()
.overflow_hidden()
.flex()
.items_stretch()
.justify_center()
@@ -1806,8 +1947,21 @@ impl Render for Workspace {
.on_key_down(cx.listener(Self::on_key_down))
.on_mouse_down(MouseButton::Left, move |_, _, cx| {
let _ = this_dismiss_filter.update(cx, |w, cx| {
let mut closed = false;
if w.filter_open {
w.filter_open = false;
closed = true;
}
if w.settings.host_dropdown_open
|| w.settings.tool_dropdown_open
|| w.settings.ok_dropdown_open
{
w.settings.host_dropdown_open = false;
w.settings.tool_dropdown_open = false;
w.settings.ok_dropdown_open = false;
closed = true;
}
if closed {
cx.notify();
}
});
@@ -1817,6 +1971,10 @@ impl Render for Workspace {
.child(top_bar::render(self, &this, &palette, cx))
.child(main);
if self.page == Page::Workspace && self.filter_open {
root = root.child(top_bar::render_filter_overlay(self, &this, &palette));
}
if self.host_modal.is_some() {
root = root.child(render_host_modal(self, &this, &palette, cx));
}
+57 -3
View File
@@ -103,12 +103,15 @@ fn has_element(window: &Window, id: &str) -> bool {
gpui_base::test_support::find(window, &[], &element_id).is_some()
}
fn element_center(window: &Window, id: &str) -> Point<Pixels> {
fn element_bounds(window: &Window, id: &str) -> gpui_kit::Bounds<Pixels> {
let element_id = ElementId::Name(SharedString::from(id));
gpui_base::test_support::find(window, &[], &element_id)
.unwrap_or_else(|| panic!("element {id} not found in the last frame"))
.bounds()
.center()
}
fn element_center(window: &Window, id: &str) -> Point<Pixels> {
element_bounds(window, id).center()
}
fn click_element(window: &mut Window, id: &str, count: usize, cx: &mut App) {
@@ -209,6 +212,15 @@ fn filter_dropdown_and_modals_work_end_to_end(cx: &mut TestAppContext) {
click_element(window, "nav-filter", 1, cx);
assert!(has_element(window, "filter-dropdown-panel"));
// 再次点击筛选按钮:必须直接关闭(不能先被 workspace-root 关掉又被 on_click 重新打开)
click_element(window, "nav-filter", 1, cx);
assert!(!has_element(window, "filter-dropdown-panel"));
assert!(!ws.read(cx).filter_open);
// 重新展开筛选面板测试过滤与重置
click_element(window, "nav-filter", 1, cx);
assert!(has_element(window, "filter-dropdown-panel"));
click_element(window, "filter-status-online", 1, cx);
assert_eq!(ws.read(cx).status_filter, StatusFilter::Online);
assert!(has_element(window, "host-card-vip-hk"));
@@ -234,6 +246,32 @@ fn filter_dropdown_and_modals_work_end_to_end(cx: &mut TestAppContext) {
.and_then(|m| m.editing_id.as_deref()),
Some("vip-hk")
);
// 点击跳板机选择框:展开下拉列表(含「本机代理」与各 SSH 候选),点选后立即选中并收起
assert!(!has_element(window, "modal-jump-dropdown"));
click_element(window, "modal-jump-cycle", 1, cx);
assert!(has_element(window, "modal-jump-dropdown"));
assert!(has_element(window, "modal-jump-opt-1"));
assert!(has_element(window, "modal-jump-opt-2"));
click_element(window, "modal-jump-opt-1", 1, cx);
assert!(!has_element(window, "modal-jump-dropdown"));
assert_eq!(
ws.read(cx)
.host_modal
.as_ref()
.map(|m| m.jump_host_id.as_str()),
Some("local-proxy")
);
click_element(window, "modal-jump-cycle", 1, cx);
click_element(window, "modal-jump-opt-2", 1, cx);
assert_eq!(
ws.read(cx)
.host_modal
.as_ref()
.map(|m| m.jump_host_id.as_str()),
Some("us-node")
);
})
.unwrap();
}
@@ -324,6 +362,12 @@ fn settings_page_switches_all_five_tabs_and_expands_call_log(cx: &mut TestAppCon
click_element(window, "nav-settings", 1, cx);
assert!(has_element(window, "settings-panel-general"));
assert!(has_element(window, "settings-toggle-autostart"));
assert!(has_element(window, "settings-proxy-off"));
assert!(has_element(window, "settings-proxy-per-host"));
assert!(has_element(window, "settings-proxy-global"));
assert!(has_element(window, "settings-proxy-save"));
click_element(window, "settings-proxy-global", 1, cx);
assert_eq!(ws.read(cx).settings.proxy_mode, "global");
// 切换到「访问安全」
click_element(window, "settings-tab-security", 1, cx);
@@ -336,10 +380,20 @@ fn settings_page_switches_all_five_tabs_and_expands_call_log(cx: &mut TestAppCon
assert!(has_element(window, "settings-toggle-mcp"));
assert!(has_element(window, "settings-save-guard"));
// 切换到「调用记录」并点击展开第一条记录
// 切换到「调用记录」:未展开时多行参数、空摘要与单行记录的行高必须完全一致
click_element(window, "settings-tab-calls", 1, cx);
assert!(has_element(window, "settings-panel-calls"));
assert!(has_element(window, "mcp-call-row-call-1"));
assert!(has_element(window, "mcp-call-row-call-2"));
assert!(has_element(window, "mcp-call-row-call-3"));
let h1 = element_bounds(window, "mcp-call-row-call-1").size.height;
let h2 = element_bounds(window, "mcp-call-row-call-2").size.height;
let h3 = element_bounds(window, "mcp-call-row-call-3").size.height;
assert_eq!(h1, px(48.));
assert_eq!(h1, h2);
assert_eq!(h2, h3);
// 点击展开第一条记录
click_element(window, "mcp-call-row-call-1", 1, cx);
assert_eq!(
ws.read(cx).settings.expanded_call_id.as_deref(),
+45 -1
View File
@@ -172,6 +172,11 @@ async fn create_host(
State(state): State<Arc<AppState>>,
Json(payload): Json<HostUpsertPayload>,
) -> impl IntoResponse {
if payload.jump_host_id.as_deref() == Some(crate::models::LOCAL_PROXY_JUMP_ID)
&& state.db.get_proxy_settings().0 == "off"
{
let _ = state.db.set_setting("proxy_mode", "per_host");
}
match state.db.create_host(payload) {
Ok(host) => (
StatusCode::OK,
@@ -190,6 +195,11 @@ async fn update_host(
Path(id): Path<String>,
Json(payload): Json<HostUpsertPayload>,
) -> impl IntoResponse {
if payload.jump_host_id.as_deref() == Some(crate::models::LOCAL_PROXY_JUMP_ID)
&& state.db.get_proxy_settings().0 == "off"
{
let _ = state.db.set_setting("proxy_mode", "per_host");
}
state.ssh_pool.invalidate_host(&id).await;
match state.db.update_host(&id, payload) {
Ok(host) => (
@@ -1279,12 +1289,15 @@ async fn api_get_autostart(State(state): State<Arc<AppState>>) -> impl IntoRespo
.ok()
.flatten()
.unwrap_or_else(|| "tray".to_string());
let (proxy_mode, proxy_port) = state.db.get_proxy_settings();
(
StatusCode::OK,
Json(json!({
"ok": true,
"autostart": enabled,
"closeAction": close_action,
"proxyMode": proxy_mode,
"proxyPort": proxy_port,
})),
)
}
@@ -1294,6 +1307,8 @@ async fn api_get_autostart(State(state): State<Arc<AppState>>) -> impl IntoRespo
struct SetAutostartPayload {
autostart: Option<bool>,
close_action: Option<String>,
proxy_mode: Option<String>,
proxy_port: Option<u16>,
}
async fn api_set_autostart(
@@ -1309,6 +1324,23 @@ async fn api_set_autostart(
let valid_action = if action == "exit" { "exit" } else { "tray" };
let _ = state.db.set_setting("close_action", valid_action);
}
let mut proxy_changed = false;
if let Some(ref mode) = p.proxy_mode {
let valid_mode = match mode.as_str() {
"off" => "off",
"global" => "global",
_ => "per_host",
};
let _ = state.db.set_setting("proxy_mode", valid_mode);
proxy_changed = true;
}
if let Some(port) = p.proxy_port.filter(|port| *port >= 1) {
let _ = state.db.set_setting("proxy_port", &port.to_string());
proxy_changed = true;
}
if proxy_changed {
state.ssh_pool.invalidate_all().await;
}
let enabled = check_autostart_enabled();
let close_action = state
.db
@@ -1316,12 +1348,15 @@ async fn api_set_autostart(
.ok()
.flatten()
.unwrap_or_else(|| "tray".to_string());
let (proxy_mode, proxy_port) = state.db.get_proxy_settings();
(
StatusCode::OK,
Json(json!({
"ok": true,
"autostart": enabled,
"closeAction": close_action,
"proxyMode": proxy_mode,
"proxyPort": proxy_port,
})),
)
.into_response()
@@ -1344,9 +1379,10 @@ async fn api_get_about(State(state): State<Arc<AppState>>) -> impl IntoResponse
)
}
async fn api_check_update() -> impl IntoResponse {
async fn api_check_update(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let current_version = env!("CARGO_PKG_VERSION");
let started = Instant::now();
let (proxy_mode, proxy_port) = state.db.get_proxy_settings();
let mut command = tokio::process::Command::new("gh");
command.args([
"release",
@@ -1356,6 +1392,11 @@ async fn api_check_update() -> impl IntoResponse {
"--json",
"tagName,name,publishedAt,body,url",
]);
if proxy_mode != "off" && proxy_port > 0 {
let proxy_url = format!("http://127.0.0.1:{proxy_port}");
command.env("HTTPS_PROXY", &proxy_url);
command.env("HTTP_PROXY", &proxy_url);
}
#[cfg(windows)]
command.creation_flags(CREATE_NO_WINDOW);
let mut stdout = String::new();
@@ -1427,6 +1468,7 @@ async fn api_get_settings(State(state): State<Arc<AppState>>) -> impl IntoRespon
.ok()
.flatten()
.unwrap_or_else(|| "tray".to_string());
let (proxy_mode, proxy_port) = state.db.get_proxy_settings();
(
StatusCode::OK,
Json(json!({
@@ -1435,6 +1477,8 @@ async fn api_get_settings(State(state): State<Arc<AppState>>) -> impl IntoRespon
"cliSync": cli_sync,
"autostart": autostart,
"closeAction": close_action,
"proxyMode": proxy_mode,
"proxyPort": proxy_port,
})),
)
.into_response()
+65 -4
View File
@@ -317,6 +317,7 @@ impl Database {
pub fn list_public_hosts(&self) -> Result<Vec<PublicHost>> {
let records = self.list_records()?;
let (_, proxy_port) = self.get_proxy_settings();
let name_map: HashMap<String, String> = records
.iter()
.map(|r| (r.id.clone(), r.name.clone()))
@@ -325,10 +326,13 @@ impl Database {
Ok(records
.into_iter()
.map(|r| {
let jump_host_name = r
.jump_host_id
.as_ref()
.and_then(|jid| name_map.get(jid).cloned());
let jump_host_name = match r.jump_host_id.as_deref() {
Some(crate::models::LOCAL_PROXY_JUMP_ID) => {
Some(format!("本机代理 :{proxy_port}"))
}
Some(jid) => name_map.get(jid).cloned(),
None => None,
};
Self::to_public(r, jump_host_name)
})
.collect())
@@ -350,12 +354,69 @@ impl Database {
return Ok(None);
};
let jump_name = match rec.jump_host_id.as_deref() {
Some(crate::models::LOCAL_PROXY_JUMP_ID) => {
let (_, proxy_port) = self.get_proxy_settings();
Some(format!("本机代理 :{proxy_port}"))
}
Some(jid) => self.get_record(jid)?.map(|j| j.name),
None => None,
};
Ok(Some(Self::to_public(rec, jump_name)))
}
/// 读取本机代理配置 `(proxy_mode, proxy_port)`:
/// - `proxy_mode`: `"off"` | `"per_host"` | `"global"`(默认 `"per_host"`)
/// - `proxy_port`: 默认 `7890`
pub fn get_proxy_settings(&self) -> (String, u16) {
let mode = self
.get_setting("proxy_mode")
.ok()
.flatten()
.filter(|m| matches!(m.as_str(), "off" | "per_host" | "global"))
.unwrap_or_else(|| "per_host".to_string());
let port = self
.get_setting("proxy_port")
.ok()
.flatten()
.and_then(|s| s.trim().parse::<u16>().ok())
.filter(|p| *p >= 1)
.unwrap_or(7890);
(mode, port)
}
/// 解析某台 SSH 主机的连接路由:返回 `(SSH 跳板机记录, 本机代理端口)`。
pub fn resolve_ssh_route(
&self,
record: &HostRecord,
) -> Result<(Option<HostRecord>, Option<u16>)> {
let (mode, port) = self.get_proxy_settings();
match record.jump_host_id.as_deref() {
Some(crate::models::LOCAL_PROXY_JUMP_ID) => {
if mode == "off" || port == 0 {
Ok((None, None))
} else {
Ok((None, Some(port)))
}
}
Some(jid) if !jid.is_empty() => {
let jump = self.get_record(jid)?;
let proxy = if mode == "global" && port > 0 {
Some(port)
} else {
None
};
Ok((jump, proxy))
}
_ => {
if mode == "global" && port > 0 {
Ok((None, Some(port)))
} else {
Ok((None, None))
}
}
}
}
fn to_public(r: HostRecord, jump_host_name: Option<String>) -> PublicHost {
PublicHost {
id: r.id,
+2 -5
View File
@@ -248,14 +248,11 @@ impl InspectorEngine {
let record = db
.get_record(host_id)?
.ok_or_else(|| anyhow!("主机不存在: {host_id}"))?;
let jump = match record.jump_host_id.as_deref() {
Some(jid) => db.get_record(jid)?,
None => None,
};
let (jump, proxy_port) = db.resolve_ssh_route(&record)?;
let connect_started = Instant::now();
let session = match pool
.get_or_connect(&record, jump.as_ref(), db.master_key())
.get_or_connect(&record, jump.as_ref(), proxy_port, db.master_key())
.await
{
Ok(s) => s,
+1
View File
@@ -1,6 +1,7 @@
use serde::{Deserialize, Serialize};
pub const LOCAL_HOST_ID: &str = "local";
pub const LOCAL_PROXY_JUMP_ID: &str = "local-proxy";
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
+229 -29
View File
@@ -164,6 +164,7 @@ impl SshPool {
&self,
host: &HostRecord,
jump_host: Option<&HostRecord>,
local_proxy_port: Option<u16>,
master_key: &MasterKey,
) -> Result<Arc<Handle<SshClientHandler>>> {
{
@@ -177,7 +178,8 @@ impl SshPool {
// 旧 SSH 连接已断开或尚未建立,挂在旧连接上的 SFTP 会话必然已死,同步清除避免残留死句柄
self.sftps.lock().await.remove(&host.id);
let handle = connect_single_or_jump(host, jump_host, master_key).await?;
let handle =
connect_single_or_jump(host, jump_host, local_proxy_port, master_key).await?;
let arc_handle = Arc::new(handle);
{
let mut guard = self.sessions.lock().await;
@@ -191,6 +193,7 @@ impl SshPool {
&self,
host: &HostRecord,
jump_host: Option<&HostRecord>,
local_proxy_port: Option<u16>,
master_key: &MasterKey,
) -> Result<Arc<Handle<SshClientHandler>>> {
{
@@ -202,7 +205,8 @@ impl SshPool {
}
}
let handle = connect_single_or_jump(host, jump_host, master_key).await?;
let handle =
connect_single_or_jump(host, jump_host, local_proxy_port, master_key).await?;
let arc_handle = Arc::new(handle);
{
let mut guard = self.pane_sessions.lock().await;
@@ -225,14 +229,19 @@ impl SshPool {
&self,
host: &HostRecord,
jump_host: Option<&HostRecord>,
local_proxy_port: Option<u16>,
master_key: &MasterKey,
) -> Result<Channel<Msg>> {
let handle = self.get_or_connect(host, jump_host, master_key).await?;
let handle = self
.get_or_connect(host, jump_host, local_proxy_port, master_key)
.await?;
match handle.channel_open_session().await {
Ok(ch) => Ok(ch),
Err(_) => {
self.invalidate_host(&host.id).await;
let fresh = self.get_or_connect(host, jump_host, master_key).await?;
let fresh = self
.get_or_connect(host, jump_host, local_proxy_port, master_key)
.await?;
fresh
.channel_open_session()
.await
@@ -248,6 +257,13 @@ impl SshPool {
self.exec_gates.lock().await.remove(host_id);
}
/// 当全局代理路由或端口变更时,清空已缓存的连接句柄以便新路由立即生效。
pub async fn invalidate_all(&self) {
self.sftps.lock().await.clear();
self.sessions.lock().await.clear();
self.pane_sessions.lock().await.clear();
}
pub async fn invalidate_sftp(&self, host_id: &str) {
if self.sftps.lock().await.remove(host_id).is_some() {
tracing::warn!("SFTP 会话失效并清理缓存: host_id={host_id}");
@@ -396,9 +412,134 @@ async fn authenticate_session(
Err(anyhow!("SSH 认证未通过"))
}
/// 通过本机代理端口 (`127.0.0.1:proxy_port`) 建立到 `(target_host, target_port)` 的 TCP 隧道。
/// 自动兼容 SOCKS5(如 Clash 7890 / v2rayN 10808)与 HTTP CONNECT(如 v2rayN 10809)代理端口。
pub async fn connect_via_local_proxy(
proxy_port: u16,
target_host: &str,
target_port: u16,
) -> Result<tokio::net::TcpStream> {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let proxy_addr = format!("127.0.0.1:{proxy_port}");
// 1. 优先尝试 SOCKS5 握手(兼容 SOCKS5 端口与 Clash/Mihomo Mixed 混合端口)
let mut stream = tokio::net::TcpStream::connect(&proxy_addr)
.await
.with_context(|| format!("无法连接本机代理端口 {proxy_addr}(请确认代理软件已启动)"))?;
let _ = stream.set_nodelay(true);
stream.write_all(&[0x05, 0x01, 0x00]).await?;
let mut greet = [0u8; 2];
if stream.read_exact(&mut greet).await.is_ok() && greet == [0x05, 0x00] {
let mut req = vec![0x05, 0x01, 0x00];
if let Ok(ipv4) = target_host.parse::<std::net::Ipv4Addr>() {
req.push(0x01);
req.extend_from_slice(&ipv4.octets());
} else if let Ok(ipv6) = target_host.parse::<std::net::Ipv6Addr>() {
req.push(0x04);
req.extend_from_slice(&ipv6.octets());
} else {
let domain = target_host.as_bytes();
if domain.len() > 255 {
return Err(anyhow!("目标域名过长"));
}
req.push(0x03);
req.push(domain.len() as u8);
req.extend_from_slice(domain);
}
req.extend_from_slice(&target_port.to_be_bytes());
stream.write_all(&req).await?;
let mut head = [0u8; 4];
stream
.read_exact(&mut head)
.await
.context("读取本机 SOCKS5 代理响应失败")?;
if head[0] != 0x05 || head[1] != 0x00 {
return Err(anyhow!(
"本机 SOCKS5 代理拒绝连接 {}:{} (code={})",
target_host,
target_port,
head[1]
));
}
match head[3] {
0x01 => {
let mut skip = [0u8; 4 + 2];
stream.read_exact(&mut skip).await?;
}
0x03 => {
let mut len = [0u8; 1];
stream.read_exact(&mut len).await?;
let mut skip = vec![0u8; len[0] as usize + 2];
stream.read_exact(&mut skip).await?;
}
0x04 => {
let mut skip = [0u8; 16 + 2];
stream.read_exact(&mut skip).await?;
}
other => return Err(anyhow!("未知的 SOCKS5 地址类型: {other}")),
}
return Ok(stream);
}
// 2. 若代理端口不是 SOCKS5(例如纯 HTTP 代理端口),自动使用 HTTP CONNECT 握手
let mut http_stream = tokio::net::TcpStream::connect(&proxy_addr)
.await
.with_context(|| format!("无法连接本机 HTTP 代理端口 {proxy_addr}"))?;
let _ = http_stream.set_nodelay(true);
let connect_req = format!(
"CONNECT {target_host}:{target_port} HTTP/1.1\r\nHost: {target_host}:{target_port}\r\nProxy-Connection: Keep-Alive\r\n\r\n"
);
http_stream.write_all(connect_req.as_bytes()).await?;
let mut resp_buf = Vec::with_capacity(256);
let mut byte = [0u8; 1];
while resp_buf.len() < 4096 {
let n = http_stream.read(&mut byte).await?;
if n == 0 {
break;
}
resp_buf.push(byte[0]);
if resp_buf.ends_with(b"\r\n\r\n") {
break;
}
}
let header_str = String::from_utf8_lossy(&resp_buf);
let first_line = header_str.lines().next().unwrap_or("");
if first_line.contains(" 200 ") || first_line.ends_with(" 200") {
Ok(http_stream)
} else {
Err(anyhow!(
"本机代理 ({proxy_addr}) 隧道握手失败: {}",
first_line.trim()
))
}
}
async fn connect_raw_or_proxy(
config: Arc<Config>,
target_host: &str,
target_port: u16,
local_proxy_port: Option<u16>,
) -> Result<Handle<SshClientHandler>> {
if let Some(proxy_port) = local_proxy_port.filter(|p| *p > 0) {
let stream = connect_via_local_proxy(proxy_port, target_host, target_port).await?;
client::connect_stream(config, stream, SshClientHandler)
.await
.context("经本机代理进行 SSH 握手失败")
} else {
let addr = format!("{target_host}:{target_port}");
client::connect(config, addr.as_str(), SshClientHandler)
.await
.context("SSH 连接失败")
}
}
pub async fn connect_single_or_jump(
host: &HostRecord,
jump_host: Option<&HostRecord>,
local_proxy_port: Option<u16>,
master_key: &MasterKey,
) -> Result<Handle<SshClientHandler>> {
let mut cfg = Config::default();
@@ -413,14 +554,17 @@ pub async fn connect_single_or_jump(
let config = Arc::new(cfg);
if let Some(jump) = jump_host {
let jump_addr = format!("{}:{}", jump.addr.trim(), jump.port);
let mut jump_session = tokio::time::timeout(
Duration::from_secs(10),
client::connect(config.clone(), jump_addr.as_str(), SshClientHandler),
connect_raw_or_proxy(
config.clone(),
jump.addr.trim(),
jump.port,
local_proxy_port,
),
)
.await
.context("连接跳板机超时")?
.context("连接跳板机失败")?;
.context("连接跳板机超时")??;
authenticate_session(&mut jump_session, jump, master_key)
.await
@@ -451,14 +595,12 @@ pub async fn connect_single_or_jump(
authenticate_session(&mut target_session, host, master_key).await?;
Ok(target_session)
} else {
let addr = format!("{}:{}", host.addr.trim(), host.port);
let mut session = tokio::time::timeout(
Duration::from_secs(8),
client::connect(config, addr.as_str(), SshClientHandler),
Duration::from_secs(10),
connect_raw_or_proxy(config, host.addr.trim(), host.port, local_proxy_port),
)
.await
.context("SSH 连接超时")?
.context("SSH 连接失败")?;
.context("SSH 连接超时")??;
authenticate_session(&mut session, host, master_key).await?;
Ok(session)
@@ -537,12 +679,9 @@ pub async fn exec_host_capture(
let record = db
.get_record(host_id)?
.ok_or_else(|| anyhow!("主机不存在: {host_id}"))?;
let jump = match record.jump_host_id.as_deref() {
Some(jid) => db.get_record(jid)?,
None => None,
};
let (jump, proxy_port) = db.resolve_ssh_route(&record)?;
let handle = pool
.get_or_connect(&record, jump.as_ref(), db.master_key())
.get_or_connect(&record, jump.as_ref(), proxy_port, db.master_key())
.await?;
exec_capture(&handle, cmd, timeout).await
}
@@ -621,12 +760,9 @@ pub async fn open_host_channel(
let record = db
.get_record(host_id)?
.ok_or_else(|| anyhow!("主机不存在: {host_id}"))?;
let jump = match record.jump_host_id.as_deref() {
Some(jid) => db.get_record(jid)?,
None => None,
};
let (jump, proxy_port) = db.resolve_ssh_route(&record)?;
let channel = pool
.open_session_channel(&record, jump.as_ref(), db.master_key())
.open_session_channel(&record, jump.as_ref(), proxy_port, db.master_key())
.await?;
let _ = db.update_check_result(&record.id, "online", None, None, None);
Ok((record, channel))
@@ -641,19 +777,16 @@ pub async fn open_pane_channel(
let record = db
.get_record(host_id)?
.ok_or_else(|| anyhow!("主机不存在: {host_id}"))?;
let jump = match record.jump_host_id.as_deref() {
Some(jid) => db.get_record(jid)?,
None => None,
};
let (jump, proxy_port) = db.resolve_ssh_route(&record)?;
let handle = pool
.get_or_connect_pane(&record, jump.as_ref(), db.master_key())
.get_or_connect_pane(&record, jump.as_ref(), proxy_port, db.master_key())
.await?;
let channel = match handle.channel_open_session().await {
Ok(ch) => ch,
Err(_) => {
pool.drop_pane_session(host_id).await;
let fresh = pool
.get_or_connect_pane(&record, jump.as_ref(), db.master_key())
.get_or_connect_pane(&record, jump.as_ref(), proxy_port, db.master_key())
.await?;
fresh
.channel_open_session()
@@ -664,3 +797,70 @@ pub async fn open_pane_channel(
let _ = db.update_check_result(&record.id, "online", None, None, None);
Ok((record, channel))
}
#[cfg(test)]
mod tests {
use super::connect_via_local_proxy;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
#[tokio::test]
async fn local_proxy_tunnel_supports_both_socks5_and_http_connect() {
// 1. 验证 SOCKS5 隧道握手与后续 SSH Banner 字节流无损透传
let socks_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let socks_port = socks_listener.local_addr().unwrap().port();
tokio::spawn(async move {
let (mut sock, _) = socks_listener.accept().await.unwrap();
let mut greet = [0u8; 3];
sock.read_exact(&mut greet).await.unwrap();
assert_eq!(greet, [0x05, 0x01, 0x00]);
sock.write_all(&[0x05, 0x00]).await.unwrap();
let mut req = [0u8; 10];
sock.read_exact(&mut req).await.unwrap();
assert_eq!(&req[..4], &[0x05, 0x01, 0x00, 0x01]);
sock.write_all(&[0x05, 0x00, 0x00, 0x01, 127, 0, 0, 1, 0, 22])
.await
.unwrap();
sock.write_all(b"SSH-2.0-OpenSSH_9.6\r\n").await.unwrap();
});
let mut stream = connect_via_local_proxy(socks_port, "198.51.100.8", 22)
.await
.expect("SOCKS5 隧道应握手成功");
let mut banner = [0u8; 21];
stream.read_exact(&mut banner).await.unwrap();
assert_eq!(&banner, b"SSH-2.0-OpenSSH_9.6\r\n");
// 2. 验证纯 HTTP 代理端口(首个连接非 SOCKS5,自动回退 HTTP CONNECT)且不吞后续字节
let http_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let http_port = http_listener.local_addr().unwrap().port();
tokio::spawn(async move {
// 第一次连接(SOCKS5 探测):返回非 0x05 的 HTTP 400
let (mut first, _) = http_listener.accept().await.unwrap();
let mut buf = [0u8; 3];
let _ = first.read_exact(&mut buf).await;
let _ = first.write_all(b"HTTP/1.1 400 Bad Request\r\n\r\n").await;
drop(first);
// 第二次连接(HTTP CONNECT):返回 200 Connection Established 并紧跟 SSH banner
let (mut second, _) = http_listener.accept().await.unwrap();
let mut req_buf = [0u8; 128];
let n = second.read(&mut req_buf).await.unwrap();
let req_str = String::from_utf8_lossy(&req_buf[..n]);
assert!(req_str.starts_with("CONNECT example.com:2222 HTTP/1.1\r\n"));
second
.write_all(b"HTTP/1.1 200 Connection Established\r\n\r\nSSH-2.0-Test\r\n")
.await
.unwrap();
});
let mut http_tunnel = connect_via_local_proxy(http_port, "example.com", 2222)
.await
.expect("HTTP CONNECT 隧道应握手成功");
let mut ssh_banner = [0u8; 14];
http_tunnel.read_exact(&mut ssh_banner).await.unwrap();
assert_eq!(&ssh_banner, b"SSH-2.0-Test\r\n");
}
}