diff --git a/docs/plan.md b/docs/plan.md index a9464ca..e1aaab2 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -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 技术栈) diff --git a/gpui-client/src/api.rs b/gpui-client/src/api.rs index a3cec7d..6665f16 100644 --- a/gpui-client/src/api.rs +++ b/gpui-client/src/api.rs @@ -9,14 +9,56 @@ use std::time::Duration; use serde::{Deserialize, Serialize}; +pub(crate) fn deserialize_null_string<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let opt = Option::::deserialize(deserializer)?; + Ok(opt.unwrap_or_default()) +} + +pub(crate) fn deserialize_null_bool<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let opt = Option::::deserialize(deserializer)?; + Ok(opt.unwrap_or(false)) +} + +pub(crate) fn deserialize_null_u16<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let opt = Option::::deserialize(deserializer)?; + Ok(opt.unwrap_or(0)) +} + +pub(crate) fn deserialize_null_u64<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let opt = Option::::deserialize(deserializer)?; + Ok(opt.unwrap_or(0)) +} + +pub(crate) fn deserialize_null_i64<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let opt = Option::::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, } #[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, + #[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, #[serde(default)] + pub error: Option, + #[serde(default, deserialize_with = "deserialize_null_u64")] pub resumed_from: u64, + #[serde(default)] pub sha256: Option, + #[serde(default)] pub verified: Option, } #[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 = 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::(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 = 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::(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 = 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::(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 Option= 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 { + 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 = 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::(item.clone()).ok() + }) + .collect() + }) .unwrap_or_default(); let hosts: Vec = 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 = 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 { }; let links: Vec = 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::(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 = + 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, ""); + } } diff --git a/gpui-client/src/file_browser.rs b/gpui-client/src/file_browser.rs index 434b097..8854c0c 100644 --- a/gpui-client/src/file_browser.rs +++ b/gpui-client/src/file_browser.rs @@ -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), + ) } diff --git a/gpui-client/src/host_card.rs b/gpui-client/src/host_card.rs index cc55598..3e34b74 100644 --- a/gpui-client/src/host_card.rs +++ b/gpui-client/src/host_card.rs @@ -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, 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)) diff --git a/gpui-client/src/host_modal.rs b/gpui-client/src/host_modal.rs index b29055a..8c3729c 100644 --- a/gpui-client/src/host_modal.rs +++ b/gpui-client/src/host_modal.rs @@ -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, pub passphrase: Entity, pub jump_host_id: String, + pub jump_dropdown_open: bool, pub role: String, // "" | "primary" | "proxy" | "test" | "archive" pub tags: Entity, pub pinned: bool, pub archived: bool, pub links: Vec, + pub saving: bool, pub error_msg: Option, } 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) -> 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() } diff --git a/gpui-client/src/palette.rs b/gpui-client/src/palette.rs index 24b983a..b72f038 100644 --- a/gpui-client/src/palette.rs +++ b/gpui-client/src/palette.rs @@ -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 { - 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 { diff --git a/gpui-client/src/settings_page.rs b/gpui-client/src/settings_page.rs index ca02f7f..615b246 100644 --- a/gpui-client/src/settings_page.rs +++ b/gpui-client/src/settings_page.rs @@ -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, + pub pending_proxy_port_sync: Option, // 访问安全 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, 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.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) { + let port = self + .settings + .proxy_port_input + .read(cx) + .value() + .trim() + .parse::() + .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) { + let raw = self.settings.proxy_port_input.read(cx).value().trim().to_owned(); + let Some(port) = raw.parse::().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) { 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::()), @@ -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::>().join(" "); + if joined.is_empty() { + fallback.to_owned() + } else { + joined + } +} + diff --git a/gpui-client/src/terminal/session.rs b/gpui-client/src/terminal/session.rs index d8abc2b..6562562 100644 --- a/gpui-client/src/terminal/session.rs +++ b/gpui-client/src/terminal/session.rs @@ -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, + #[serde(default)] pub title: Option, - #[serde(default)] + #[serde(default, deserialize_with = "deserialize_null_bool")] pub running: bool, + #[serde(default)] pub last_exit_code: Option, - #[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::() + .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"); + } +} + diff --git a/gpui-client/src/terminal/view.rs b/gpui-client/src/terminal/view.rs index 240f410..a97db6d 100644 --- a/gpui-client/src/terminal/view.rs +++ b/gpui-client/src/terminal/view.rs @@ -44,6 +44,7 @@ pub struct TerminalView { dragging_selection: bool, last_cell_origin: Point, last_cell_size: gpui_kit::Size, + last_viewport_size: gpui_kit::Size, // 中文 IME 预编辑状态 pub marked_text: String, @@ -52,6 +53,7 @@ pub struct TerminalView { // 终端右键菜单坐标(相对于终端视图左上角) pub context_menu_pos: Option>, + 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, cell_size: gpui_kit::Size, + viewport_size: gpui_kit::Size, ime_bounds: Option>, ) { 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 { 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, backgrounds: Vec, cursor: Option, + scrollbar_thumb: Option, preedit_line: Option<(Point, ShapedLine, PaintQuad)>, ime_cursor_bounds: Option>, 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> { +fn encode_special_key(keystroke: &Keystroke, app_cursor: bool) -> Option> { let key = keystroke.key.as_str(); let ctrl = keystroke.modifiers.control; let alt = keystroke.modifiers.alt; + let shift = keystroke.modifiers.shift; let sequence: Vec = 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(), diff --git a/gpui-client/src/top_bar.rs b/gpui-client/src/top_bar.rs index 53f81da..6888265 100644 --- a/gpui-client/src/top_bar.rs +++ b/gpui-client/src/top_bar.rs @@ -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, 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), + ), ); } diff --git a/gpui-client/src/workspace.rs b/gpui-client/src/workspace.rs index a5c7921..e1c5c39 100644 --- a/gpui-client/src/workspace.rs +++ b/gpui-client/src/workspace.rs @@ -80,6 +80,7 @@ pub struct Workspace { pub api: Api, pub main_window: AnyWindowHandle, pub standalone_windows: HashMap, + pub standalone_panes: HashMap>, pub focus_handle: FocusHandle, pub hosts: Vec, 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.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.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> = 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.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.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.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.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) { 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 = 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)); } diff --git a/gpui-client/tests/ui.rs b/gpui-client/tests/ui.rs index 9169872..1e8a391 100644 --- a/gpui-client/tests/ui.rs +++ b/gpui-client/tests/ui.rs @@ -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 { +fn element_bounds(window: &Window, id: &str) -> gpui_kit::Bounds { 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 { + 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(), diff --git a/src/api.rs b/src/api.rs index dda590a..4acb733 100644 --- a/src/api.rs +++ b/src/api.rs @@ -172,6 +172,11 @@ async fn create_host( State(state): State>, Json(payload): Json, ) -> 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, Json(payload): Json, ) -> 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>) -> 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>) -> impl IntoRespo struct SetAutostartPayload { autostart: Option, close_action: Option, + proxy_mode: Option, + proxy_port: Option, } 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>) -> impl IntoResponse ) } -async fn api_check_update() -> impl IntoResponse { +async fn api_check_update(State(state): State>) -> 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>) -> 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>) -> impl IntoRespon "cliSync": cli_sync, "autostart": autostart, "closeAction": close_action, + "proxyMode": proxy_mode, + "proxyPort": proxy_port, })), ) .into_response() diff --git a/src/db.rs b/src/db.rs index 18db331..fa64c82 100644 --- a/src/db.rs +++ b/src/db.rs @@ -317,6 +317,7 @@ impl Database { pub fn list_public_hosts(&self) -> Result> { let records = self.list_records()?; + let (_, proxy_port) = self.get_proxy_settings(); let name_map: HashMap = 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::().ok()) + .filter(|p| *p >= 1) + .unwrap_or(7890); + (mode, port) + } + + /// 解析某台 SSH 主机的连接路由:返回 `(SSH 跳板机记录, 本机代理端口)`。 + pub fn resolve_ssh_route( + &self, + record: &HostRecord, + ) -> Result<(Option, Option)> { + 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) -> PublicHost { PublicHost { id: r.id, diff --git a/src/inspector.rs b/src/inspector.rs index f5a2994..b2a4ad7 100644 --- a/src/inspector.rs +++ b/src/inspector.rs @@ -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, diff --git a/src/models.rs b/src/models.rs index 52437a5..3e563b8 100644 --- a/src/models.rs +++ b/src/models.rs @@ -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")] diff --git a/src/ssh.rs b/src/ssh.rs index df55ca4..5ed2d59 100644 --- a/src/ssh.rs +++ b/src/ssh.rs @@ -164,6 +164,7 @@ impl SshPool { &self, host: &HostRecord, jump_host: Option<&HostRecord>, + local_proxy_port: Option, master_key: &MasterKey, ) -> Result>> { { @@ -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, master_key: &MasterKey, ) -> Result>> { { @@ -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, master_key: &MasterKey, ) -> Result> { - 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 { + 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::() { + req.push(0x01); + req.extend_from_slice(&ipv4.octets()); + } else if let Ok(ipv6) = target_host.parse::() { + 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, + target_host: &str, + target_port: u16, + local_proxy_port: Option, +) -> Result> { + 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, master_key: &MasterKey, ) -> Result> { 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"); + } +} +