feat(app_kit): 新建连接恢复两步向导(类目 + 卡片选择)

- 第 1 步整页选择连接类型:左侧类目(全部/数据库/终端与文件/远程桌面与转发/扩展)加右侧卡片网格,卡片带彩色图标、名称与描述
- 第 2 步渲染连接表单,左下角提供「上一步」返回;编辑/复制仍直达表单
- 支持上下左右 + Enter 键盘导航,双击卡片可直接进入表单
- 类型清单继续由领域 registry 提供,保留多产品/多版本构建能力
- 文案迁入 app_kit 语言包(中英繁三语),新增 one-assets 依赖提供类型图标
This commit is contained in:
胡飞
2026-11-04 15:30:07 +08:00
parent da8e238376
commit d88d2c236a
4 changed files with 791 additions and 123 deletions
Generated
+1
View File
@@ -443,6 +443,7 @@ dependencies = [
"gpui-fast",
"image",
"mcp_host",
"one-assets",
"one-core",
"public_mcp",
"rusqlite",
+1
View File
@@ -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
+123
View File
@@ -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: 由擴展提供的連線類型
+666 -123
View File
@@ -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<ConnectionKindDescriptor>,
step: WizardStep,
category: KindCategory,
selected_kind: Option<SharedString>,
/// 第 2 步的表单视图;与 `form_kind` 一起缓存,返回上一步时不丢已填内容。
form: Option<AnyView>,
form_kind: Option<SharedString>,
mode: ConnectionFormMode,
preset: Option<(SharedString, StoredConnection)>,
workspace_id: Option<i64>,
@@ -137,94 +213,492 @@ impl ConnectionFormDialog {
cx: &mut Context<Self>,
) -> 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<Self>) {
/// 是否走两步向导(只有「新建」需要先选类型)。
fn is_wizard(&self) -> bool {
self.mode == ConnectionFormMode::New
}
/// 左侧类目:全部 + 当前产品实际装配了类型的分组。
fn categories(&self) -> Vec<KindCategory> {
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<ConnectionKindDescriptor> {
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<Self>) {
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<Self>) {
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<Self>) -> 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<Self>) {
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<Self>) {
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<Self>) {
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>) {
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>,
) {
self.move_selection(-1, cx);
}
fn on_select_next(&mut self, _: &WizardSelectNext, _: &mut Window, cx: &mut Context<Self>) {
self.move_selection(1, cx);
}
fn on_open_selected(
&mut self,
_: &WizardOpenSelected,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.open_selected(window, cx);
}
fn render_header(&self, cx: &mut Context<Self>) -> 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<Self>) -> 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<Self>) -> 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<ConnectionKindGroup> = 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<Self>) -> 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<Self>,
) -> 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<Self>) -> 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<Self>) -> 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<Self>) -> 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(),
}
}
}