From d88d2c236adef79cb4e2ada6c7fd5cb672e4080d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=83=A1=E9=A3=9E?= <1835698775@qq.com> Date: Wed, 4 Nov 2026 15:30:07 +0800 Subject: [PATCH] =?UTF-8?q?feat(app=5Fkit):=20=E6=96=B0=E5=BB=BA=E8=BF=9E?= =?UTF-8?q?=E6=8E=A5=E6=81=A2=E5=A4=8D=E4=B8=A4=E6=AD=A5=E5=90=91=E5=AF=BC?= =?UTF-8?q?=EF=BC=88=E7=B1=BB=E7=9B=AE=20+=20=E5=8D=A1=E7=89=87=E9=80=89?= =?UTF-8?q?=E6=8B=A9=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 第 1 步整页选择连接类型:左侧类目(全部/数据库/终端与文件/远程桌面与转发/扩展)加右侧卡片网格,卡片带彩色图标、名称与描述 - 第 2 步渲染连接表单,左下角提供「上一步」返回;编辑/复制仍直达表单 - 支持上下左右 + Enter 键盘导航,双击卡片可直接进入表单 - 类型清单继续由领域 registry 提供,保留多产品/多版本构建能力 - 文案迁入 app_kit 语言包(中英繁三语),新增 one-assets 依赖提供类型图标 --- Cargo.lock | 1 + crates/app_kit/Cargo.toml | 1 + crates/app_kit/locales/app_kit.yml | 123 +++++ crates/app_kit/src/new_connection.rs | 789 ++++++++++++++++++++++----- 4 files changed, 791 insertions(+), 123 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 54f19d858..cdf3b87a0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -443,6 +443,7 @@ dependencies = [ "gpui-fast", "image", "mcp_host", + "one-assets", "one-core", "public_mcp", "rusqlite", diff --git a/crates/app_kit/Cargo.toml b/crates/app_kit/Cargo.toml index 3d04d8fcf..ab9f1d33a 100644 --- a/crates/app_kit/Cargo.toml +++ b/crates/app_kit/Cargo.toml @@ -19,6 +19,7 @@ gpui-component.workspace = true gpui.workspace = true image.workspace = true mcp_host.workspace = true +one-assets.workspace = true one-core.workspace = true public_mcp.workspace = true rusqlite.workspace = true diff --git a/crates/app_kit/locales/app_kit.yml b/crates/app_kit/locales/app_kit.yml index bae57439c..d80048cea 100644 --- a/crates/app_kit/locales/app_kit.yml +++ b/crates/app_kit/locales/app_kit.yml @@ -230,3 +230,126 @@ Settings: en: timed out zh-CN: 检查超时 zh-HK: 檢查超時 + +# 新建连接向导(app_kit 提供,三产品共用) +NewConnection: + title_new: + en: New Connection + zh-CN: 新建连接 + zh-HK: 新建連線 + title_edit: + en: Edit Connection + zh-CN: 编辑连接 + zh-HK: 編輯連線 + title_duplicate: + en: Duplicate Connection + zh-CN: 复制连接 + zh-HK: 複製連線 + window_title: + en: Connection + zh-CN: 连接 + zh-HK: 連線 + close: + en: Close + zh-CN: 关闭 + zh-HK: 關閉 + cancel: + en: Cancel + zh-CN: 取消 + zh-HK: 取消 + next: + en: Next + zh-CN: 下一步 + zh-HK: 下一步 + previous: + en: Back + zh-CN: 上一步 + zh-HK: 上一步 + step_indicator: + en: "Step %{current} of 2" + zh-CN: "第 %{current} 步 / 共 2 步" + zh-HK: "第 %{current} 步 / 共 2 步" + select_type_title: + en: Select Connection Type + zh-CN: 选择连接类型 + zh-HK: 選擇連線類型 + select_type_hint: + en: Click to select, then click Next; or double-click to open the form directly. + zh-CN: 单击选择后点击下一步,或双击直接进入连接表单。 + zh-HK: 單擊選擇後點擊下一步,或雙擊直接進入連線表單。 + empty: + en: No connection types are available in this product. + zh-CN: 当前产品没有可用的连接类型。 + zh-HK: 當前產品沒有可用的連線類型。 + category_all: + en: All + zh-CN: 全部 + zh-HK: 全部 + category_database: + en: Databases + zh-CN: 数据库 + zh-HK: 資料庫 + category_terminal: + en: Terminal & Files + zh-CN: 终端与文件 + zh-HK: 終端與檔案 + category_remote: + en: Remote Desktop & Forwarding + zh-CN: 远程桌面与转发 + zh-HK: 遠端桌面與轉發 + category_extension: + en: Extensions + zh-CN: 扩展 + zh-HK: 擴展 + description_database: + en: Relational database connection + zh-CN: 关系型数据库连接 + zh-HK: 關聯式資料庫連線 + description_redis: + en: Redis standalone, sentinel or cluster connection + zh-CN: Redis 单机、哨兵或集群连接 + zh-HK: Redis 單機、哨兵或叢集連線 + description_mongodb: + en: MongoDB database connection + zh-CN: MongoDB 数据库连接 + zh-HK: MongoDB 資料庫連線 + description_ssh: + en: Remote server terminal and file connection + zh-CN: 远程服务器终端与文件连接 + zh-HK: 遠端伺服器終端與檔案連線 + description_ftp: + en: Standalone FTP / FTPS file transfer connection + zh-CN: 独立 FTP / FTPS 文件传输连接 + zh-HK: 獨立 FTP / FTPS 檔案傳輸連線 + description_serial: + en: Serial device connection + zh-CN: 串口设备连接 + zh-HK: 串口裝置連線 + description_telnet: + en: Telnet network device connection + zh-CN: Telnet 网络设备连接 + zh-HK: Telnet 網絡設備連線 + description_port_forwarding: + en: SSH local or remote port forwarding and SOCKS tunnels + zh-CN: SSH 本地、远程端口转发或 SOCKS 隧道 + zh-HK: SSH 本機、遠端連接埠轉發或 SOCKS 隧道 + description_rdp: + en: Remote Desktop Protocol connection + zh-CN: 远程桌面协议连接 + zh-HK: 遠端桌面協議連線 + description_vnc: + en: VNC remote desktop connection + zh-CN: VNC 远程桌面连接 + zh-HK: VNC 遠端桌面連線 + description_terminal: + en: Terminal and file connection + zh-CN: 终端与文件类连接 + zh-HK: 終端與檔案類連線 + description_remote: + en: Remote desktop and port forwarding connection + zh-CN: 远程桌面与端口转发连接 + zh-HK: 遠端桌面與連接埠轉發連線 + description_extension: + en: Connection type provided by an extension + zh-CN: 由扩展提供的连接类型 + zh-HK: 由擴展提供的連線類型 diff --git a/crates/app_kit/src/new_connection.rs b/crates/app_kit/src/new_connection.rs index 4734f3fbf..6d24f126d 100644 --- a/crates/app_kit/src/new_connection.rs +++ b/crates/app_kit/src/new_connection.rs @@ -1,26 +1,63 @@ //! 独立产品的连接表单宿主(新建 / 编辑 / 复制)。 //! -//! 弹窗左栏列出当前产品已装配的连接种类(由领域通过 -//! [`domain_api::ConnectionFormProvider`] 注册),右栏渲染对应表单。 +//! 「新建连接」是两步向导,交互沿用产品分离前的旧版样式: +//! 第 1 步整页选择连接类型(左侧类目 + 右侧卡片网格),第 2 步渲染 +//! 对应表单,表单左下角提供「上一步」返回类型选择。 +//! 类型清单由领域通过 [`domain_api::ConnectionFormProvider`] 注册,因此 +//! 多产品构建(全量 / Data / Shell)各自只展示已装配的类型,不写死任何一种。 //! 表单保存后自身写仓库并广播 `ConnectionDataEvent`,宿主列表据此刷新。 use domain_api::{ConnectionFormRequest, ConnectionKindDescriptor, ConnectionKindGroup}; use gpui::{ - AnyView, App, AppContext as _, Context, FocusHandle, Focusable, IntoElement, Render, - SharedString, Styled, Window, div, prelude::*, px, + AnyView, App, AppContext as _, Context, FocusHandle, Focusable, FontWeight, IntoElement, + KeyBinding, Render, SharedString, Styled, Window, div, prelude::*, px, }; use gpui_component::WindowExt as _; use gpui_component::{ - ActiveTheme as _, Sizable as _, button::Button, h_flex, notification::Notification, v_flex, + ActiveTheme as _, Disableable as _, Icon, InteractiveElementExt as _, Sizable as _, + button::{Button, ButtonVariants as _}, + h_flex, + notification::Notification, + scroll::ScrollableElement as _, + v_flex, }; +use one_assets::IconName; use one_core::popup_window::{PopupWindowOptions, open_reusable_popup_window}; use one_core::storage::StoredConnection; +use rust_i18n::t; const DIALOG_WIDTH: f32 = 980.0; const DIALOG_HEIGHT: f32 = 660.0; -const KIND_ROW_HEIGHT: f32 = 28.0; const DIALOG_REUSE_KEY: &str = "app-kit-connection-form"; +/// 类型选择页的键盘上下文。 +/// +/// 只挂在第 1 步的容器上:第 2 步表单里的输入框回车不会被向导拦截。 +const WIZARD_KEY_CONTEXT: &str = "ConnectionKindWizard"; + +const CATEGORY_SIDEBAR_WIDTH: f32 = 180.0; +const KIND_CARD_WIDTH: f32 = 280.0; +const KIND_CARD_HEIGHT: f32 = 112.0; +const KIND_ICON_SIZE: f32 = 40.0; +const KIND_ICON_BOX: f32 = 48.0; + +/// 领域注册的种类 id 常量(与 data_domain / shell_domain 的注册保持一致)。 +const DB_KIND_PREFIX: &str = "db:"; +const KIND_REDIS: &str = "redis"; +const KIND_MONGODB: &str = "mongodb"; +const KIND_SSH: &str = "ssh"; +const KIND_FTP: &str = "ftp"; +const KIND_SERIAL: &str = "serial"; +const KIND_TELNET: &str = "telnet"; +const KIND_PORT_FORWARDING: &str = "port_forwarding"; +const KIND_RDP: &str = "rdp"; +const KIND_VNC: &str = "vnc"; + +gpui::actions!( + connection_kind_wizard, + [WizardSelectPrevious, WizardSelectNext, WizardOpenSelected] +); + /// 表单用途:决定标题与请求构造方式。 #[derive(Clone, Copy, PartialEq, Eq)] pub enum ConnectionFormMode { @@ -30,11 +67,11 @@ pub enum ConnectionFormMode { } impl ConnectionFormMode { - fn title(self) -> &'static str { + fn title(self) -> SharedString { match self { - Self::New => "新建连接", - Self::Edit => "编辑连接", - Self::Duplicate => "复制连接", + Self::New => t!("NewConnection.title_new").to_string().into(), + Self::Edit => t!("NewConnection.title_edit").to_string().into(), + Self::Duplicate => t!("NewConnection.title_duplicate").to_string().into(), } } @@ -117,12 +154,51 @@ fn open_dialog( ); } +/// 向导当前所处步骤。 +#[derive(Clone, Copy, PartialEq, Eq)] +enum WizardStep { + /// 第 1 步:选择连接类型。 + SelectKind, + /// 第 2 步:连接表单。 + Form, +} + +/// 类型选择页左侧的类目。 +#[derive(Clone, Copy, PartialEq, Eq)] +enum KindCategory { + All, + Group(ConnectionKindGroup), +} + +impl KindCategory { + fn key(self) -> &'static str { + match self { + Self::All => "all", + Self::Group(ConnectionKindGroup::Database) => "database", + Self::Group(ConnectionKindGroup::Terminal) => "terminal", + Self::Group(ConnectionKindGroup::Remote) => "remote", + Self::Group(ConnectionKindGroup::Extension) => "extension", + } + } + + fn matches(self, group: ConnectionKindGroup) -> bool { + match self { + Self::All => true, + Self::Group(selected) => selected == group, + } + } +} + /// 表单弹窗视图。 pub struct ConnectionFormDialog { focus_handle: FocusHandle, kinds: Vec, + step: WizardStep, + category: KindCategory, selected_kind: Option, + /// 第 2 步的表单视图;与 `form_kind` 一起缓存,返回上一步时不丢已填内容。 form: Option, + form_kind: Option, mode: ConnectionFormMode, preset: Option<(SharedString, StoredConnection)>, workspace_id: Option, @@ -137,94 +213,492 @@ impl ConnectionFormDialog { cx: &mut Context, ) -> Self { let kinds = crate::registry().kinds(cx); - // 新建时默认选中第一个种类,避免右栏空着让用户以为没有可用表单。 - let selected_kind = preset - .as_ref() - .map(|(kind_id, _)| kind_id.clone()) - .or_else(|| kinds.first().map(|kind| kind.id.clone())); - let form = selected_kind.as_ref().and_then(|kind_id| { - build_form( - kind_id.clone(), - mode, - preset.as_ref().map(|(_, connection)| connection.clone()), - workspace_id, - window, - cx, - ) - }); - let focus_handle = cx.focus_handle(); - focus_handle.focus(window, cx); - Self { - focus_handle, + let mut dialog = Self { + focus_handle: cx.focus_handle(), kinds, - selected_kind, - form, + step: WizardStep::SelectKind, + category: KindCategory::All, + selected_kind: None, + form: None, + form_kind: None, mode, preset, workspace_id, + }; + if dialog.mode == ConnectionFormMode::New { + // 与旧版一致:默认选中第一个种类,让「下一步」立即可用。 + dialog.selected_kind = dialog.kinds.first().map(|kind| kind.id.clone()); + } else if let Some((kind_id, _)) = dialog.preset.clone() { + // 编辑 / 复制跳过类型选择,直接进表单。 + dialog.selected_kind = Some(kind_id.clone()); + dialog.open_kind(kind_id, window, cx); } + // 上下左右 + 回车:第 1 步选择类型,与旧版向导一致。 + cx.bind_keys([ + KeyBinding::new("up", WizardSelectPrevious, Some(WIZARD_KEY_CONTEXT)), + KeyBinding::new("left", WizardSelectPrevious, Some(WIZARD_KEY_CONTEXT)), + KeyBinding::new("down", WizardSelectNext, Some(WIZARD_KEY_CONTEXT)), + KeyBinding::new("right", WizardSelectNext, Some(WIZARD_KEY_CONTEXT)), + KeyBinding::new("enter", WizardOpenSelected, Some(WIZARD_KEY_CONTEXT)), + ]); + dialog.focus_handle.focus(window, cx); + dialog } - fn select_kind(&mut self, kind_id: SharedString, window: &mut Window, cx: &mut Context) { + /// 是否走两步向导(只有「新建」需要先选类型)。 + fn is_wizard(&self) -> bool { + self.mode == ConnectionFormMode::New + } + + /// 左侧类目:全部 + 当前产品实际装配了类型的分组。 + fn categories(&self) -> Vec { + let mut categories = vec![KindCategory::All]; + for group in [ + ConnectionKindGroup::Database, + ConnectionKindGroup::Terminal, + ConnectionKindGroup::Remote, + ConnectionKindGroup::Extension, + ] { + if self.kinds.iter().any(|kind| kind.group == group) { + categories.push(KindCategory::Group(group)); + } + } + categories + } + + fn kinds_in(&self, category: KindCategory) -> Vec { + let mut kinds: Vec<_> = self + .kinds + .iter() + .filter(|kind| category.matches(kind.group)) + .cloned() + .collect(); + kinds.sort_by_key(|kind| (kind.group.order(), kind.order)); + kinds + } + + fn select_category(&mut self, category: KindCategory, cx: &mut Context) { + if self.category == category { + return; + } + self.category = category; + let kinds = self.kinds_in(category); + if !kinds + .iter() + .any(|kind| Some(&kind.id) == self.selected_kind.as_ref()) + { + self.selected_kind = kinds.first().map(|kind| kind.id.clone()); + } + cx.notify(); + } + + fn select_kind(&mut self, kind_id: SharedString, cx: &mut Context) { if self.selected_kind.as_ref() == Some(&kind_id) { return; } - let connection = self.preset.as_ref().map(|(_, conn)| conn.clone()); - self.form = build_form( - kind_id.clone(), - self.mode, - connection, - self.workspace_id, - window, - cx, - ); self.selected_kind = Some(kind_id); cx.notify(); } - fn render_kind_list(&self, cx: &mut Context) -> impl IntoElement { - let mut list = v_flex() - .id("connection-kind-list") - .w(px(200.0)) + /// 键盘上下移动选中项(在「全部」类目里跨分组移动,循环)。 + fn move_selection(&mut self, delta: isize, cx: &mut Context) { + if self.step != WizardStep::SelectKind { + return; + } + let kinds = self.kinds_in(self.category); + if kinds.is_empty() { + return; + } + let current = self + .selected_kind + .as_ref() + .and_then(|selected| kinds.iter().position(|kind| &kind.id == selected)); + let next = match current { + Some(index) => (index as isize + delta).rem_euclid(kinds.len() as isize) as usize, + None if delta < 0 => kinds.len() - 1, + None => 0, + }; + self.selected_kind = Some(kinds[next].id.clone()); + cx.notify(); + } + + fn open_selected(&mut self, window: &mut Window, cx: &mut Context) { + let Some(kind_id) = self.selected_kind.clone() else { + return; + }; + self.open_kind(kind_id, window, cx); + } + + fn open_kind(&mut self, kind_id: SharedString, window: &mut Window, cx: &mut Context) { + if self.form_kind.as_ref() != Some(&kind_id) { + let connection = self + .preset + .as_ref() + .map(|(_, connection)| connection.clone()); + let Some(form) = build_form( + kind_id.clone(), + self.mode, + connection, + self.workspace_id, + window, + cx, + ) else { + window.push_notification( + Notification::error(format!("当前产品不支持该连接类型:{kind_id}")), + cx, + ); + return; + }; + self.form = Some(form); + self.form_kind = Some(kind_id.clone()); + } + self.selected_kind = Some(kind_id); + self.step = WizardStep::Form; + cx.notify(); + } + + fn go_back(&mut self, window: &mut Window, cx: &mut Context) { + self.step = WizardStep::SelectKind; + self.focus_handle.focus(window, cx); + cx.notify(); + } + + fn on_select_previous( + &mut self, + _: &WizardSelectPrevious, + _: &mut Window, + cx: &mut Context, + ) { + self.move_selection(-1, cx); + } + + fn on_select_next(&mut self, _: &WizardSelectNext, _: &mut Window, cx: &mut Context) { + self.move_selection(1, cx); + } + + fn on_open_selected( + &mut self, + _: &WizardOpenSelected, + window: &mut Window, + cx: &mut Context, + ) { + self.open_selected(window, cx); + } + + fn render_header(&self, cx: &mut Context) -> impl IntoElement { + h_flex() + .h(px(44.0)) + .px_3() + .items_center() + .justify_between() + .border_b_1() + .border_color(cx.theme().border) + .child( + h_flex() + .items_center() + .gap_2() + .child( + div() + .font_weight(FontWeight::SEMIBOLD) + .child(self.mode.title()), + ) + .when(self.is_wizard(), |this| { + this.child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(step_indicator(self.step)), + ) + }), + ) + .child( + Button::new("connection-form-close") + .small() + .label(t!("NewConnection.close").to_string()) + .on_click(cx.listener(|_, _, window, cx| { + let _ = one_core::window_close::close_window_for_reuse(window, cx); + })), + ) + } + + /// 第 1 步:左侧类目 + 右侧卡片网格 + 底部「取消 / 下一步」。 + fn render_selection_page(&self, cx: &mut Context) -> impl IntoElement { + v_flex() + .key_context(WIZARD_KEY_CONTEXT) + .size_full() + .track_focus(&self.focus_handle) + .on_action(cx.listener(Self::on_select_previous)) + .on_action(cx.listener(Self::on_select_next)) + .on_action(cx.listener(Self::on_open_selected)) + .bg(cx.theme().background) + .child(self.render_header(cx)) + .child( + h_flex() + .flex_1() + .min_h_0() + .min_w_0() + .child(self.render_category_sidebar(cx)) + .child( + v_flex() + .flex_1() + .min_w_0() + .min_h_0() + .child(self.render_kind_grid(cx)) + .child(self.render_selection_footer(cx)), + ), + ) + } + + fn render_category_sidebar(&self, cx: &mut Context) -> impl IntoElement { + let mut sidebar = v_flex() + .id("connection-kind-categories") + .w(px(CATEGORY_SIDEBAR_WIDTH)) .h_full() + .min_h_0() .p_2() .gap_0p5() - .overflow_y_scroll() + .bg(cx.theme().sidebar) .border_r_1() - .border_color(cx.theme().border); - let mut last_group: Option = None; - for kind in self.kinds.clone() { - if last_group != Some(kind.group) { - list = list.child( - div() - .mt_2() - .mb_1() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(group_title(kind.group)), - ); - last_group = Some(kind.group); - } - let selected = self.selected_kind.as_ref() == Some(&kind.id); - let id = kind.id.clone(); - list = list.child( + .border_color(cx.theme().border) + .overflow_y_scrollbar(); + for category in self.categories() { + let selected = self.category == category; + let theme = cx.theme(); + sidebar = sidebar.child( div() - .id(SharedString::from(format!("connection-kind-{}", kind.id))) - .h(px(KIND_ROW_HEIGHT)) - .px_2() - .rounded_md() + .id(SharedString::from(format!( + "connection-kind-category-{}", + category.key() + ))) .flex() .items_center() - .text_sm() - .when(selected, |row| row.bg(cx.theme().muted)) - .hover(|row| row.bg(cx.theme().muted)) - .on_click(cx.listener(move |this, _, window, cx| { - this.select_kind(id.clone(), window, cx); + .gap_2() + .w_full() + .px_3() + .py_2() + .rounded_lg() + .cursor_pointer() + .when(selected, |item| { + item.bg(theme.list_active) + .border_l_3() + .border_color(theme.list_active_border) + }) + .when(!selected, |item| { + item.hover(|item| item.bg(theme.sidebar_accent)) + }) + .on_click(cx.listener(move |this, _, _, cx| { + this.select_category(category, cx); })) - .child(kind.display_label(cx)), + .child( + Icon::new(category_icon(category)) + .mono() + .with_size(px(16.0)), + ) + .child( + div() + .text_sm() + .text_color(theme.foreground) + .when(selected, |label| label.font_weight(FontWeight::MEDIUM)) + .child(category_title(category)), + ), ); } - list + sidebar + } + + fn render_kind_grid(&self, cx: &mut Context) -> impl IntoElement { + let kinds = self.kinds_in(self.category); + let content = v_flex() + .flex_1() + .min_h_0() + .min_w_0() + .overflow_y_scrollbar() + .bg(cx.theme().muted) + .p_6() + .gap_4() + .child( + v_flex() + .gap_1() + .child( + div() + .text_lg() + .font_weight(FontWeight::SEMIBOLD) + .text_color(cx.theme().foreground) + .child(t!("NewConnection.select_type_title").to_string()), + ) + .child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(t!("NewConnection.select_type_hint").to_string()), + ), + ); + if kinds.is_empty() { + return content.child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(t!("NewConnection.empty").to_string()), + ); + } + let mut grid = div().flex().flex_wrap().w_full().gap_3(); + for kind in &kinds { + grid = grid.child( + div() + .w(px(KIND_CARD_WIDTH)) + .flex_shrink_0() + .child(self.render_kind_card(kind, cx)), + ); + } + content.child(grid) + } + + fn render_kind_card( + &self, + kind: &ConnectionKindDescriptor, + cx: &mut Context, + ) -> impl IntoElement { + let selected = self.selected_kind.as_ref() == Some(&kind.id); + let click_id = kind.id.clone(); + let double_click_id = kind.id.clone(); + let label = kind.display_label(cx); + let description = kind_description(&kind.id, kind.group); + let icon = kind_icon(&kind.id, kind.group); + let theme = cx.theme(); + v_flex() + .id(SharedString::from(format!( + "connection-kind-card-{}", + kind.id + ))) + .justify_center() + .w_full() + .h(px(KIND_CARD_HEIGHT)) + .p_3() + .rounded_lg() + .border_1() + .bg(theme.background) + .border_color(if selected { + theme.list_active_border + } else { + theme.border + }) + .when(selected, |card| card.shadow_sm()) + .hover(|card| card.shadow_sm().border_color(theme.list_active_border)) + .cursor_pointer() + .overflow_hidden() + .on_click(cx.listener(move |this, _, _, cx| { + this.select_kind(click_id.clone(), cx); + })) + .on_double_click(cx.listener(move |this, _, window, cx| { + this.open_kind(double_click_id.clone(), window, cx); + })) + .child( + h_flex() + .items_center() + .gap_3() + .w_full() + .child( + div() + .w(px(KIND_ICON_BOX)) + .h(px(KIND_ICON_BOX)) + .rounded(px(8.0)) + .flex() + .items_center() + .justify_center() + .child(icon), + ) + .child( + v_flex() + .flex_1() + .min_w_0() + .gap_1() + .child( + div() + .text_sm() + .font_weight(FontWeight::SEMIBOLD) + .text_color(theme.foreground) + .overflow_hidden() + .text_ellipsis() + .whitespace_nowrap() + .child(label), + ) + .child( + div() + .text_xs() + .text_color(theme.muted_foreground) + .overflow_hidden() + .text_ellipsis() + .whitespace_nowrap() + .child(description), + ), + ), + ) + } + + fn render_selection_footer(&self, cx: &mut Context) -> impl IntoElement { + h_flex() + .flex_none() + .w_full() + .justify_end() + .gap_2() + .p_4() + .border_t_1() + .border_color(cx.theme().border) + .bg(cx.theme().background) + .child( + Button::new("connection-kind-cancel") + .small() + .label(t!("NewConnection.cancel").to_string()) + .on_click(cx.listener(|_, _, window, cx| { + let _ = one_core::window_close::close_window_for_reuse(window, cx); + })), + ) + .child( + Button::new("connection-kind-next") + .small() + .primary() + .label(t!("NewConnection.next").to_string()) + .disabled(self.selected_kind.is_none()) + .on_click(cx.listener(|this, _, window, cx| { + this.open_selected(window, cx); + })), + ) + } + + /// 第 2 步:表单页,左下角浮「上一步」返回类型选择。 + fn render_form_page(&self, cx: &mut Context) -> impl IntoElement { + let mut body = v_flex().flex_1().min_h_0().min_w_0().relative(); + body = match self.form.clone() { + Some(form) => body.child(div().flex_1().min_h_0().min_w_0().child(form)), + None => body.child( + v_flex() + .flex_1() + .items_center() + .justify_center() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(t!("NewConnection.empty").to_string()), + ), + }; + if self.is_wizard() { + body = body.child( + div().absolute().left(px(16.0)).bottom(px(16.0)).child( + Button::new("connection-kind-back") + .small() + .outline() + .label(t!("NewConnection.previous").to_string()) + .on_click(cx.listener(|this, _, window, cx| { + this.go_back(window, cx); + })), + ), + ); + } + v_flex() + .size_full() + .track_focus(&self.focus_handle) + .bg(cx.theme().background) + .child(self.render_header(cx)) + .child(body) } } @@ -244,8 +718,9 @@ pub fn open_form(request: ConnectionFormRequest, window: &mut Window, cx: &mut A return false; }; let reuse_key = format!("{DIALOG_REUSE_KEY}:{kind_id}"); - let _ = open_reusable_popup_window( - PopupWindowOptions::new("连接").size(DIALOG_WIDTH, DIALOG_HEIGHT), + open_reusable_popup_window( + PopupWindowOptions::new(t!("NewConnection.window_title").to_string()) + .size(DIALOG_WIDTH, DIALOG_HEIGHT), reuse_key, move |_window, _cx| form.clone(), Some(window), @@ -268,12 +743,118 @@ fn build_form( crate::registry().build_connection_form(request, window, cx) } -fn group_title(group: ConnectionKindGroup) -> &'static str { +fn step_indicator(step: WizardStep) -> String { + let current = match step { + WizardStep::SelectKind => 1, + WizardStep::Form => 2, + }; + t!("NewConnection.step_indicator", current = current).to_string() +} + +fn category_title(category: KindCategory) -> SharedString { + let title = match category { + KindCategory::All => t!("NewConnection.category_all").to_string(), + KindCategory::Group(ConnectionKindGroup::Database) => { + t!("NewConnection.category_database").to_string() + } + KindCategory::Group(ConnectionKindGroup::Terminal) => { + t!("NewConnection.category_terminal").to_string() + } + KindCategory::Group(ConnectionKindGroup::Remote) => { + t!("NewConnection.category_remote").to_string() + } + KindCategory::Group(ConnectionKindGroup::Extension) => { + t!("NewConnection.category_extension").to_string() + } + }; + title.into() +} + +fn category_icon(category: KindCategory) -> IconName { + match category { + KindCategory::All => IconName::LayoutDashboard, + KindCategory::Group(ConnectionKindGroup::Database) => IconName::DatabaseLine, + KindCategory::Group(ConnectionKindGroup::Terminal) => IconName::Terminal, + KindCategory::Group(ConnectionKindGroup::Remote) => IconName::Monitor, + KindCategory::Group(ConnectionKindGroup::Extension) => IconName::ExtensionsLine, + } +} + +fn group_icon(group: ConnectionKindGroup) -> IconName { match group { - ConnectionKindGroup::Database => "数据库", - ConnectionKindGroup::Terminal => "终端与文件", - ConnectionKindGroup::Remote => "远程桌面与转发", - ConnectionKindGroup::Extension => "扩展", + ConnectionKindGroup::Database => IconName::Database, + ConnectionKindGroup::Terminal => IconName::Terminal, + ConnectionKindGroup::Remote => IconName::Monitor, + ConnectionKindGroup::Extension => IconName::ExtensionsColor, + } +} + +fn kind_icon(kind_id: &str, group: ConnectionKindGroup) -> Icon { + if let Some(db_type) = kind_id.strip_prefix(DB_KIND_PREFIX) { + return database_kind_icon(db_type); + } + let icon = match kind_id { + KIND_REDIS => IconName::RedisColor, + KIND_MONGODB => IconName::MongoDB, + KIND_SSH => IconName::TerminalColor, + KIND_FTP => IconName::FtpColor, + KIND_SERIAL => IconName::SerialPort, + KIND_TELNET => IconName::SquareTerminalColor, + KIND_PORT_FORWARDING => IconName::PortForwardingColor, + KIND_RDP => IconName::Rdp, + KIND_VNC => IconName::Vnc, + _ => group_icon(group), + }; + icon.color().with_size(px(KIND_ICON_SIZE)) +} + +fn database_kind_icon(db_type: &str) -> Icon { + // TDengine 的品牌图标由全量产品的 AppAssets 按路径内嵌提供。 + if db_type == "TDengine" { + return Icon::default() + .path(one_core::storage::NAVOP_TDENGINE_COLOR_ICON) + .color() + .with_size(px(KIND_ICON_SIZE)); + } + let icon = match db_type { + "MySQL" => IconName::MySQLColor, + "PostgreSQL" => IconName::PostgreSQLColor, + "SQLite" => IconName::SQLiteColor, + "DuckDB" => IconName::DuckDB, + "MSSQL" => IconName::MSSQLColor, + "Oracle" => IconName::OracleColor, + "ClickHouse" => IconName::ClickHouseColor, + _ => IconName::Database, + }; + icon.color().with_size(px(KIND_ICON_SIZE)) +} + +fn kind_description(kind_id: &str, group: ConnectionKindGroup) -> SharedString { + let description = if kind_id.starts_with(DB_KIND_PREFIX) { + t!("NewConnection.description_database").to_string() + } else { + match kind_id { + KIND_REDIS => t!("NewConnection.description_redis").to_string(), + KIND_MONGODB => t!("NewConnection.description_mongodb").to_string(), + KIND_SSH => t!("NewConnection.description_ssh").to_string(), + KIND_FTP => t!("NewConnection.description_ftp").to_string(), + KIND_SERIAL => t!("NewConnection.description_serial").to_string(), + KIND_TELNET => t!("NewConnection.description_telnet").to_string(), + KIND_PORT_FORWARDING => t!("NewConnection.description_port_forwarding").to_string(), + KIND_RDP => t!("NewConnection.description_rdp").to_string(), + KIND_VNC => t!("NewConnection.description_vnc").to_string(), + _ => group_description(group), + } + }; + description.into() +} + +fn group_description(group: ConnectionKindGroup) -> String { + match group { + ConnectionKindGroup::Database => t!("NewConnection.description_database").to_string(), + ConnectionKindGroup::Terminal => t!("NewConnection.description_terminal").to_string(), + ConnectionKindGroup::Remote => t!("NewConnection.description_remote").to_string(), + ConnectionKindGroup::Extension => t!("NewConnection.description_extension").to_string(), } } @@ -285,47 +866,9 @@ impl Focusable for ConnectionFormDialog { impl Render for ConnectionFormDialog { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let content = match self.form.clone() { - Some(form) => div().flex_1().min_h_0().min_w_0().child(form), - None => v_flex() - .flex_1() - .items_center() - .justify_center() - .text_sm() - .text_color(cx.theme().muted_foreground) - .child("当前产品没有可新建的连接类型"), - }; - let header = h_flex() - .h(px(44.0)) - .px_3() - .items_center() - .justify_between() - .border_b_1() - .border_color(cx.theme().border) - .child( - div() - .font_weight(gpui::FontWeight::SEMIBOLD) - .child(self.mode.title()), - ) - .child( - Button::new("connection-form-close") - .small() - .label("关闭") - .on_click(cx.listener(|_, _, window, cx| { - let _ = one_core::window_close::close_window_for_reuse(window, cx); - })), - ); - v_flex() - .size_full() - .track_focus(&self.focus_handle) - .bg(cx.theme().background) - .child(header) - .child( - h_flex() - .flex_1() - .min_h_0() - .child(self.render_kind_list(cx)) - .child(content), - ) + match self.step { + WizardStep::SelectKind => self.render_selection_page(cx).into_any_element(), + WizardStep::Form => self.render_form_page(cx).into_any_element(), + } } }