feat(gpui): add local proxy routing and polish terminal, file browser, and UI interactions
This commit is contained in:
1 parent
e89fd63001
commit
9d35744dd0
17 files changed
+1844
-247
No files matched your search
+2
-2
@@ -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
@@ -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, "");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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,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
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user