diff --git a/crates/core/migrations/20260623000001_connection_sort_order.sql b/crates/core/migrations/20260623000001_connection_sort_order.sql new file mode 100644 index 0000000000..dff9140ea6 --- /dev/null +++ b/crates/core/migrations/20260623000001_connection_sort_order.sql @@ -0,0 +1 @@ +ALTER TABLE connections ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0; diff --git a/crates/core/src/cloud_sync/conflict.rs b/crates/core/src/cloud_sync/conflict.rs index 475610b3e1..9b23ac89b1 100644 --- a/crates/core/src/cloud_sync/conflict.rs +++ b/crates/core/src/cloud_sync/conflict.rs @@ -335,6 +335,7 @@ mod tests { cloud_id: Some("cloud-123".to_string()), last_synced_at: Some(100), last_used_at: None, + sort_order: None, created_at: None, updated_at: Some(200), team_id: None, diff --git a/crates/core/src/cloud_sync/connection_sync.rs b/crates/core/src/cloud_sync/connection_sync.rs index cc726cba68..85411723ac 100644 --- a/crates/core/src/cloud_sync/connection_sync.rs +++ b/crates/core/src/cloud_sync/connection_sync.rs @@ -1130,6 +1130,7 @@ mod tests { cloud_id: Some("cloud-1".to_string()), last_synced_at: Some(updated_at - 10), last_used_at: None, + sort_order: None, created_at: Some(updated_at - 20), updated_at: Some(updated_at), team_id: None, diff --git a/crates/core/src/cloud_sync/service.rs b/crates/core/src/cloud_sync/service.rs index 4b05dbce1a..6d3271f988 100644 --- a/crates/core/src/cloud_sync/service.rs +++ b/crates/core/src/cloud_sync/service.rs @@ -465,6 +465,7 @@ impl CloudSyncService { cloud_id: Some(cloud_data.id.clone()), last_synced_at: Some(cloud_data.updated_at), last_used_at: None, + sort_order: None, created_at: None, updated_at: None, team_id: cloud_data.team_id.clone(), @@ -592,6 +593,7 @@ mod tests { cloud_id: None, last_synced_at: None, last_used_at: None, + sort_order: None, created_at: None, updated_at: None, team_id: None, diff --git a/crates/core/src/storage/migration.rs b/crates/core/src/storage/migration.rs index a7f0a7965c..3ff186646a 100644 --- a/crates/core/src/storage/migration.rs +++ b/crates/core/src/storage/migration.rs @@ -22,6 +22,10 @@ const MIGRATIONS: &[(&str, &str)] = &[ "20260618000001", include_str!("../../migrations/20260618000001_connection_last_used.sql"), ), + ( + "20260623000001", + include_str!("../../migrations/20260623000001_connection_sort_order.sql"), + ), ]; pub fn run_migrations(conn: &Connection) -> Result<()> { diff --git a/crates/core/src/storage/models.rs b/crates/core/src/storage/models.rs index 8ef74641fa..52f43ff891 100644 --- a/crates/core/src/storage/models.rs +++ b/crates/core/src/storage/models.rs @@ -77,8 +77,8 @@ impl ConnectionType { pub fn all() -> Vec { vec![ ConnectionType::All, - ConnectionType::Database, ConnectionType::SshSftp, + ConnectionType::Database, ConnectionType::Redis, ConnectionType::MongoDB, ConnectionType::ChatDB, @@ -804,6 +804,9 @@ pub struct StoredConnection { /// 最近使用时间戳,仅用于本地列表排序,不参与云同步。 #[serde(skip_serializing_if = "Option::is_none")] pub last_used_at: Option, + /// 手动排序位序,仅用于本地列表排序,不参与云同步。 + #[serde(skip_serializing_if = "Option::is_none")] + pub sort_order: Option, #[serde(skip_serializing_if = "Option::is_none")] pub created_at: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -896,6 +899,7 @@ impl StoredConnection { cloud_id: None, last_synced_at: None, last_used_at: None, + sort_order: None, created_at: None, updated_at: None, team_id: None, @@ -916,6 +920,7 @@ impl StoredConnection { cloud_id: None, last_synced_at: None, last_used_at: None, + sort_order: None, created_at: None, updated_at: None, team_id: None, @@ -940,6 +945,7 @@ impl StoredConnection { cloud_id: None, last_synced_at: None, last_used_at: None, + sort_order: None, created_at: None, updated_at: None, team_id: None, @@ -960,6 +966,7 @@ impl StoredConnection { cloud_id: None, last_synced_at: None, last_used_at: None, + sort_order: None, created_at: None, updated_at: None, team_id: None, @@ -980,6 +987,7 @@ impl StoredConnection { cloud_id: None, last_synced_at: None, last_used_at: None, + sort_order: None, created_at: None, updated_at: None, team_id: None, @@ -1016,6 +1024,7 @@ impl StoredConnection { cloud_id: None, last_synced_at: None, last_used_at: None, + sort_order: None, created_at: None, updated_at: None, team_id: None, @@ -1040,6 +1049,7 @@ impl StoredConnection { cloud_id: None, last_synced_at: None, last_used_at: None, + sort_order: None, created_at: None, updated_at: None, team_id: None, diff --git a/crates/core/src/storage/repository.rs b/crates/core/src/storage/repository.rs index b3715944ec..ed2e72ca52 100644 --- a/crates/core/src/storage/repository.rs +++ b/crates/core/src/storage/repository.rs @@ -23,6 +23,7 @@ struct ConnectionRow { cloud_id: Option, last_synced_at: Option, last_used_at: Option, + sort_order: Option, created_at: i64, updated_at: i64, team_id: Option, @@ -46,6 +47,7 @@ impl FromSqliteRow for ConnectionRow { cloud_id: row.get("cloud_id")?, last_synced_at: row.get("last_synced_at")?, last_used_at: row.get("last_used_at")?, + sort_order: row.get("sort_order").unwrap_or(Some(0)), created_at: row.get("created_at")?, updated_at: row.get("updated_at")?, team_id: row.get("team_id").unwrap_or(None), @@ -68,6 +70,7 @@ impl From for StoredConnection { cloud_id: row.cloud_id, last_synced_at: row.last_synced_at, last_used_at: row.last_used_at, + sort_order: row.sort_order, created_at: Some(row.created_at), updated_at: Some(row.updated_at), team_id: row.team_id, @@ -201,7 +204,7 @@ impl Repository for ConnectionRepository { fn get(&self, id: i64) -> Result> { self.conn.with_connection(|conn| { let mut stmt = conn.prepare( - "SELECT id, name, connection_type, params, workspace_id, selected_databases, remark, sync_enabled, cloud_id, last_synced_at, last_used_at, created_at, updated_at, team_id, owner_id FROM connections WHERE id = ?1", + "SELECT id, name, connection_type, params, workspace_id, selected_databases, remark, sync_enabled, cloud_id, last_synced_at, last_used_at, sort_order, created_at, updated_at, team_id, owner_id FROM connections WHERE id = ?1", )?; let mut rows = stmt.query(params![id])?; if let Some(row) = rows.next()? { @@ -215,7 +218,7 @@ impl Repository for ConnectionRepository { fn list(&self) -> Result> { self.conn.with_connection(|conn| { let mut stmt = conn.prepare( - "SELECT id, name, connection_type, params, workspace_id, selected_databases, remark, sync_enabled, cloud_id, last_synced_at, last_used_at, created_at, updated_at, team_id, owner_id FROM connections ORDER BY COALESCE(last_used_at, updated_at, created_at) DESC, id DESC", + "SELECT id, name, connection_type, params, workspace_id, selected_databases, remark, sync_enabled, cloud_id, last_synced_at, last_used_at, sort_order, created_at, updated_at, team_id, owner_id FROM connections ORDER BY sort_order ASC, COALESCE(last_used_at, updated_at, created_at) DESC, id DESC", )?; let rows = stmt.query_map([], |row| ConnectionRow::from_row(row))?; let mut results = Vec::new(); @@ -250,9 +253,9 @@ impl ConnectionRepository { pub fn list_by_workspace(&self, workspace_id: Option) -> Result> { self.conn.with_connection(|conn| { let sql = if workspace_id.is_some() { - "SELECT id, name, connection_type, params, workspace_id, selected_databases, remark, sync_enabled, cloud_id, last_synced_at, last_used_at, created_at, updated_at, team_id, owner_id FROM connections WHERE workspace_id = ?1 ORDER BY COALESCE(last_used_at, updated_at, created_at) DESC, id DESC" + "SELECT id, name, connection_type, params, workspace_id, selected_databases, remark, sync_enabled, cloud_id, last_synced_at, last_used_at, sort_order, created_at, updated_at, team_id, owner_id FROM connections WHERE workspace_id = ?1 ORDER BY sort_order ASC, COALESCE(last_used_at, updated_at, created_at) DESC, id DESC" } else { - "SELECT id, name, connection_type, params, workspace_id, selected_databases, remark, sync_enabled, cloud_id, last_synced_at, last_used_at, created_at, updated_at, team_id, owner_id FROM connections WHERE workspace_id IS NULL ORDER BY COALESCE(last_used_at, updated_at, created_at) DESC, id DESC" + "SELECT id, name, connection_type, params, workspace_id, selected_databases, remark, sync_enabled, cloud_id, last_synced_at, last_used_at, sort_order, created_at, updated_at, team_id, owner_id FROM connections WHERE workspace_id IS NULL ORDER BY sort_order ASC, COALESCE(last_used_at, updated_at, created_at) DESC, id DESC" }; let mut stmt = conn.prepare(sql)?; @@ -318,11 +321,24 @@ impl ConnectionRepository { }) } + /// 批量更新连接的 sort_order(拖拽排序后持久化) + pub fn update_sort_orders(&self, orders: &[(i64, i32)]) -> Result<()> { + self.conn.with_connection(|conn| { + for (id, sort_order) in orders { + conn.execute( + "UPDATE connections SET sort_order = ?1 WHERE id = ?2", + params![sort_order, id], + )?; + } + Ok(()) + }) + } + /// 查询需要同步的连接(sync_enabled=true 且 cloud_id 为空或 updated_at > last_synced_at) pub fn list_pending_sync(&self) -> Result> { self.conn.with_connection(|conn| { let mut stmt = conn.prepare( - "SELECT id, name, connection_type, params, workspace_id, selected_databases, remark, sync_enabled, cloud_id, last_synced_at, last_used_at, created_at, updated_at, team_id, owner_id + "SELECT id, name, connection_type, params, workspace_id, selected_databases, remark, sync_enabled, cloud_id, last_synced_at, last_used_at, sort_order, created_at, updated_at, team_id, owner_id FROM connections WHERE sync_enabled = 1 AND (cloud_id IS NULL OR updated_at > COALESCE(last_synced_at, 0)) ORDER BY updated_at DESC", @@ -340,7 +356,7 @@ impl ConnectionRepository { pub fn get_by_cloud_id(&self, cloud_id: &str) -> Result> { self.conn.with_connection(|conn| { let mut stmt = conn.prepare( - "SELECT id, name, connection_type, params, workspace_id, selected_databases, remark, sync_enabled, cloud_id, last_synced_at, last_used_at, created_at, updated_at, team_id, owner_id + "SELECT id, name, connection_type, params, workspace_id, selected_databases, remark, sync_enabled, cloud_id, last_synced_at, last_used_at, sort_order, created_at, updated_at, team_id, owner_id FROM connections WHERE cloud_id = ?1", )?; let mut rows = stmt.query(params![cloud_id])?; @@ -382,7 +398,7 @@ impl ConnectionRepository { pub fn list_by_team(&self, team_id: &str) -> Result> { self.conn.with_connection(|conn| { let mut stmt = conn.prepare( - "SELECT id, name, connection_type, params, workspace_id, selected_databases, remark, sync_enabled, cloud_id, last_synced_at, last_used_at, created_at, updated_at, team_id, owner_id FROM connections WHERE team_id = ?1 ORDER BY COALESCE(last_used_at, updated_at, created_at) DESC, id DESC", + "SELECT id, name, connection_type, params, workspace_id, selected_databases, remark, sync_enabled, cloud_id, last_synced_at, last_used_at, sort_order, created_at, updated_at, team_id, owner_id FROM connections WHERE team_id = ?1 ORDER BY sort_order ASC, COALESCE(last_used_at, updated_at, created_at) DESC, id DESC", )?; let rows = stmt.query_map(params![team_id], |row| ConnectionRow::from_row(row))?; let mut results = Vec::new(); @@ -397,7 +413,7 @@ impl ConnectionRepository { pub fn list_personal(&self) -> Result> { self.conn.with_connection(|conn| { let mut stmt = conn.prepare( - "SELECT id, name, connection_type, params, workspace_id, selected_databases, remark, sync_enabled, cloud_id, last_synced_at, last_used_at, created_at, updated_at, team_id, owner_id FROM connections WHERE team_id IS NULL ORDER BY COALESCE(last_used_at, updated_at, created_at) DESC, id DESC", + "SELECT id, name, connection_type, params, workspace_id, selected_databases, remark, sync_enabled, cloud_id, last_synced_at, last_used_at, sort_order, created_at, updated_at, team_id, owner_id FROM connections WHERE team_id IS NULL ORDER BY sort_order ASC, COALESCE(last_used_at, updated_at, created_at) DESC, id DESC", )?; let rows = stmt.query_map([], |row| ConnectionRow::from_row(row))?; let mut results = Vec::new(); diff --git a/crates/core/src/storage/sftp_favorite_path.rs b/crates/core/src/storage/sftp_favorite_path.rs index cc906beff0..9a7f150850 100644 --- a/crates/core/src/storage/sftp_favorite_path.rs +++ b/crates/core/src/storage/sftp_favorite_path.rs @@ -297,6 +297,7 @@ mod tests { cloud_id: None, last_synced_at: None, last_used_at: None, + sort_order: None, created_at: None, updated_at: None, team_id: None, diff --git a/main/locales/main.yml b/main/locales/main.yml index de204e3a0a..d9a22cdc83 100644 --- a/main/locales/main.yml +++ b/main/locales/main.yml @@ -97,6 +97,14 @@ Home: en: Import zh-CN: 导入 zh-HK: 導入 + card_view: + en: Card View + zh-CN: 卡片视图 + zh-HK: 卡片視圖 + list_view: + en: List View + zh-CN: 列表视图 + zh-HK: 列表視圖 refresh: en: Refresh zh-CN: 刷新 diff --git a/main/src/home_tab.rs b/main/src/home_tab.rs index 98dbb6727c..5ed65ea739 100644 --- a/main/src/home_tab.rs +++ b/main/src/home_tab.rs @@ -131,9 +131,57 @@ fn home_default_shortcut(macos: &'static str, other: &'static str) -> &'static s // HomePage Entity - 管理 home 页面的所有状态 +/// 连接列表布局模式 +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum ConnectionLayout { + /// 卡片网格视图 + Card, + /// 长条列表视图 + List, +} + +impl ConnectionLayout { + fn toggle(self) -> Self { + match self { + ConnectionLayout::Card => ConnectionLayout::List, + ConnectionLayout::List => ConnectionLayout::Card, + } + } +} + +/// 拖拽排序的荷载,在渲染连接列表时由 on_drag 创建, +/// 拖入另一个连接条目时由 on_drop handler 读取。 +#[derive(Clone)] +struct DragConnection { + source_index: usize, +} + +impl Render for DragConnection { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + div() + .id("drag-connection") + .cursor_grabbing() + .py_1() + .px_3() + .min_w(px(120.0)) + .overflow_hidden() + .whitespace_nowrap() + .text_ellipsis() + .border_1() + .border_color(cx.theme().border) + .rounded(px(6.0)) + .text_color(cx.theme().foreground) + .bg(cx.theme().background) + .opacity(0.85) + .shadow_md() + .text_sm() + } +} + pub struct HomePage { focus_handle: FocusHandle, selected_filter: ConnectionType, + connection_layout: ConnectionLayout, pub(crate) workspaces: Vec, pub(crate) connections: Vec, pub(crate) tab_container: Entity, @@ -292,6 +340,7 @@ impl HomePage { let mut page = Self { focus_handle: cx.focus_handle(), selected_filter: ConnectionType::All, + connection_layout: ConnectionLayout::Card, workspaces: Vec::new(), connections: Vec::new(), tab_container, @@ -469,6 +518,31 @@ impl HomePage { .detach(); } + /// 拖拽排序:将 `from` 位置的连接移动到 `to` 位置,并异步持久化 sort_order。 + fn reorder_connections(&mut self, from: usize, to: usize, cx: &mut Context) { + if from >= self.connections.len() || to >= self.connections.len() || from == to { + return; + } + let conn = self.connections.remove(from); + self.connections.insert(to, conn); + + let orders: Vec<(i64, i32)> = self + .connections + .iter() + .enumerate() + .filter_map(|(i, c)| c.id.map(|id| (id, i as i32))) + .collect(); + + let storage = cx.global::().storage.clone(); + cx.spawn(async move |_, _| { + if let Some(repo) = storage.get::() { + let _ = repo.update_sort_orders(&orders); + } + }) + .detach(); + cx.notify(); + } + fn refresh_local_home_data(&mut self, cx: &mut Context) { self.load_workspaces(cx); self.load_connections(cx); @@ -2206,6 +2280,27 @@ impl HomePage { .w(px(240.0)) .bg(cx.theme().muted), ) + // 布局切换按钮 + .child({ + let is_card = self.connection_layout == ConnectionLayout::Card; + let tooltip = if is_card { + t!("Home.list_view").to_string() + } else { + t!("Home.card_view").to_string() + }; + Button::new("layout-toggle") + .icon(if is_card { + IconName::LayoutDashboard + } else { + IconName::Menu + }) + .ghost() + .tooltip(tooltip) + .on_click(cx.listener(|this, _, _, cx| { + this.connection_layout = this.connection_layout.toggle(); + cx.notify(); + })) + }) // 刷新按钮 .child( Button::new("refresh-button") @@ -2653,7 +2748,8 @@ impl HomePage { fn render_content_area(&mut self, cx: &mut Context) -> AnyElement { let search_query = self.search_query.read(cx).to_lowercase(); let selected_id = self.selected_connection_id; - self.render_workspace_view(&search_query, selected_id, cx) + let layout = self.connection_layout; + self.render_workspace_view(&search_query, selected_id, layout, cx) .into_any_element() } @@ -2661,6 +2757,7 @@ impl HomePage { &self, search_query: &str, selected_id: Option, + layout: ConnectionLayout, cx: &mut Context, ) -> impl IntoElement { let workspaces_with_connections: Vec<_> = self @@ -2714,6 +2811,7 @@ impl HomePage { workspace, connections, selected_id, + layout, cx, )); } @@ -2725,6 +2823,7 @@ impl HomePage { container = container.child(self.render_unassigned_section( unassigned_connections, selected_id, + layout, cx, )); } else { @@ -2732,6 +2831,7 @@ impl HomePage { container = container.child(self.render_connections_grid( unassigned_connections, selected_id, + layout, cx, )); } @@ -2746,6 +2846,7 @@ impl HomePage { workspace: Workspace, connections: Vec, selected_id: Option, + layout: ConnectionLayout, cx: &mut Context, ) -> impl IntoElement { let workspace_id = workspace.id; @@ -2784,16 +2885,23 @@ impl HomePage { .child(div().flex_1()), ) .when(!connections.is_empty(), |this| { - // 使用 flex 布局实现响应式卡片网格 - let mut container = div().flex().flex_wrap().w_full().gap_3(); - - for conn in connections { - container = container.child( - div() - .w(px(320.0)) // 固定宽度,不增长 - .flex_shrink_0() // 不收缩 - .child(self.render_connection_card(conn, selected_id, cx)), - ); + let mut container = match layout { + ConnectionLayout::List => v_flex().w_full().gap_1(), + ConnectionLayout::Card => div().flex().flex_wrap().w_full().gap_3(), + }; + + for (idx, conn) in connections.iter().enumerate() { + container = match layout { + ConnectionLayout::List => { + container.child(self.render_connection_list_item(conn.clone(), selected_id, idx, cx)) + } + ConnectionLayout::Card => container.child( + div() + .w(px(320.0)) + .flex_shrink_0() + .child(self.render_connection_card(conn.clone(), selected_id, idx, cx)), + ), + }; } this.child(container) @@ -2804,17 +2912,26 @@ impl HomePage { &self, connections: Vec, selected_id: Option, + layout: ConnectionLayout, cx: &mut Context, ) -> impl IntoElement { - let mut container = div().flex().flex_wrap().w_full().gap_3(); + let mut container = match layout { + ConnectionLayout::List => v_flex().w_full().gap_1(), + ConnectionLayout::Card => div().flex().flex_wrap().w_full().gap_3(), + }; - for conn in connections { - container = container.child( - div() - .w(px(320.0)) - .flex_shrink_0() - .child(self.render_connection_card(conn, selected_id, cx)), - ); + for (idx, conn) in connections.into_iter().enumerate() { + container = match layout { + ConnectionLayout::List => { + container.child(self.render_connection_list_item(conn, selected_id, idx, cx)) + } + ConnectionLayout::Card => container.child( + div() + .w(px(320.0)) + .flex_shrink_0() + .child(self.render_connection_card(conn, selected_id, idx, cx)), + ), + }; } container } @@ -2823,6 +2940,7 @@ impl HomePage { &self, connections: Vec, selected_id: Option, + layout: ConnectionLayout, cx: &mut Context, ) -> impl IntoElement { v_flex() @@ -2854,25 +2972,421 @@ impl HomePage { ), ) .child({ - // 使用 flex 布局实现响应式卡片网格 - let mut container = div().flex().flex_wrap().w_full().gap_3(); - - for conn in connections { - container = container.child( - div() - .w(px(320.0)) // 固定宽度,不增长 - .flex_shrink_0() // 不收缩 - .child(self.render_connection_card(conn, selected_id, cx)), - ); + let mut container = match layout { + ConnectionLayout::List => v_flex().w_full().gap_1(), + ConnectionLayout::Card => div().flex().flex_wrap().w_full().gap_3(), + }; + + for (idx, conn) in connections.into_iter().enumerate() { + container = match layout { + ConnectionLayout::List => { + container.child(self.render_connection_list_item(conn, selected_id, idx, cx)) + } + ConnectionLayout::Card => container.child( + div() + .w(px(320.0)) + .flex_shrink_0() + .child(self.render_connection_card(conn, selected_id, idx, cx)), + ), + }; } container }) } + fn render_connection_list_item( + &self, + conn: StoredConnection, + selected_id: Option, + index: usize, + cx: &mut Context, + ) -> impl IntoElement { + let conn_id = conn.id; + let clone_conn = conn.clone(); + let sftp_hover_conn = conn.clone(); + let edit_conn = conn.clone(); + let edit_conn_type = conn.connection_type; + let edit_conn_name = conn.name.clone(); + let duplicate_conn = conn.clone(); + let delete_conn_id = conn.id; + let delete_conn_name = conn.name.clone(); + let is_selected = selected_id == conn.id; + let is_active = conn + .id + .map_or(false, |id| cx.global::().is_active(id)); + let can_edit = can_edit_connection(&conn, cx); + + let accent = cx.theme().accent; + + h_flex() + .id(SharedString::from(format!( + "conn-list-item-{}", + conn.id.unwrap_or(0) + ))) + .w_full() + .h(px(48.0)) + .rounded(px(6.0)) + .bg(cx.theme().background) + .px_3() + .border_1() + .items_center() + .gap_3() + .relative() + .group("") + .when(is_selected, |this| { + this.border_color(cx.theme().list_active_border) + .border_l_3() + }) + .when(!is_selected, |this| this.border_color(cx.theme().border)) + .cursor_pointer() + .hover(|style| style.bg(cx.theme().muted)) + .on_double_click(cx.listener(move |this, _, w, cx| { + this.open_connection_from_quick(&clone_conn, w, cx); + cx.notify() + })) + .on_click(cx.listener(move |this, _, _, cx| { + this.selected_connection_id = conn_id; + cx.notify(); + })) + .on_drag( + DragConnection { source_index: index }, + |drag, _, window, cx| { + window.prevent_default(); + cx.stop_propagation(); + cx.new(|_| drag.clone()) + }, + ) + .drag_over::(move |el, _, _, _cx| { + el.border_t_2().border_color(accent) + }) + .on_drop(cx.listener( + move |this, drag: &DragConnection, _window, cx| { + let from = drag.source_index; + let to = index; + if from != to { + this.reorder_connections(from, to, cx); + } + }, + )) + // 激活指示灯 + .when(is_active, |this| { + // list_item version + this.child( + div() + .flex_shrink_0() + .w(px(8.0)) + .h(px(8.0)) + .rounded_full() + .bg(cx.theme().success), + ) + }) + // 类型图标 + .child( + match conn.connection_type { + ConnectionType::Database => { + let icon = conn + .to_db_connection() + .map(|c| { + external_driver_icon_for_config(&c, px(24.0)) + .unwrap_or_else(|| c.database_type.as_icon()) + }) + .unwrap_or_else(|_| IconName::Database.color()); + icon.with_size(px(24.0)).flex_shrink_0() + } + ConnectionType::SshSftp => IconName::TerminalColor + .color() + .with_size(px(24.0)) + .text_color(gpui::rgb(0x8b5cf6)) + .flex_shrink_0(), + ConnectionType::Redis => IconName::Redis + .color() + .with_size(px(24.0)) + .text_color(gpui::white()) + .flex_shrink_0(), + ConnectionType::MongoDB => IconName::MongoDB + .color() + .with_size(px(24.0)) + .text_color(gpui::white()) + .flex_shrink_0(), + ConnectionType::Serial => IconName::SerialPort + .color() + .with_size(px(24.0)) + .text_color(gpui::white()) + .flex_shrink_0(), + ConnectionType::PortForwarding => IconName::PortForwardingColor + .color() + .with_size(px(24.0)) + .text_color(gpui::white()) + .flex_shrink_0(), + ConnectionType::Rdp => IconName::Rdp + .color() + .with_size(px(24.0)) + .text_color(gpui::white()) + .flex_shrink_0(), + ConnectionType::Vnc => IconName::Vnc + .color() + .with_size(px(24.0)) + .text_color(gpui::white()) + .flex_shrink_0(), + _ => IconName::Server + .color() + .with_size(px(24.0)) + .text_color(gpui::white()) + .flex_shrink_0(), + }, + ) + // 连接名称 + .child( + div() + .text_sm() + .font_weight(FontWeight::SEMIBOLD) + .text_color(cx.theme().foreground) + .overflow_hidden() + .text_ellipsis() + .whitespace_nowrap() + .flex_shrink() + .min_w_0() + .child(conn.name.clone()), + ) + // 连接信息 + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .overflow_hidden() + .text_ellipsis() + .whitespace_nowrap() + .flex_shrink() + .w(px(240.0)) + .child(self.connection_info_text(&conn)), + ) + // 右侧弹性空间 + .child(div().flex_1()) + // hover 时显示的操作按钮 + .child({ + h_flex() + .gap_1() + .flex_shrink_0() + .group_hover("", |style| style.opacity(1.0)) + .opacity(0.0) + .when(conn.connection_type == ConnectionType::SshSftp, |this| { + this.child({ + let sftp_conn = sftp_hover_conn.clone(); + Button::new(SharedString::from(format!( + "sftp-list-conn-{}", + conn.id.unwrap_or(0) + ))) + .icon(IconName::Folder1.color()) + .with_size(Size::Small) + .primary() + .tooltip(t!("Home.open_sftp")) + .on_click(cx.listener( + move |this, _, window, cx| { + cx.stop_propagation(); + this.open_sftp_view(sftp_conn.clone(), window, cx); + }, + )) + }) + }) + .when(can_edit, |this| { + this.child({ + let dup_conn = duplicate_conn.clone(); + Button::new(SharedString::from(format!( + "duplicate-list-conn-{}", + conn.id.unwrap_or(0) + ))) + .icon(IconName::Copy) + .with_size(Size::Small) + .primary() + .tooltip(t!("Home.duplicate_connection")) + .on_click(cx.listener( + move |this, _, window, cx| { + cx.stop_propagation(); + this.duplicate_connection(dup_conn.clone(), window, cx); + }, + )) + }) + .child({ + let edit_id = edit_conn.id; + let edit_name = edit_conn_name.clone(); + let edit_type = edit_conn_type; + let edit_data = edit_conn.clone(); + Button::new(SharedString::from(format!( + "edit-list-conn-{}", + conn.id.unwrap_or(0) + ))) + .icon(IconName::Edit) + .with_size(Size::Small) + .primary() + .tooltip(t!("Home.edit_connection")) + .on_click(cx.listener( + move |this, _, window, cx| { + cx.stop_propagation(); + if let Some(conn_id) = edit_id { + let conn_name = edit_name.clone(); + match edit_type { + ConnectionType::SshSftp => { + this.editing_connection_id = Some(conn_id); + this.show_ssh_form(window, cx); + } + ConnectionType::Database => { + let db_type = edit_data + .to_db_connection() + .ok() + .map(|p| p.database_type); + this.confirm_edit_connection( + conn_id, conn_name, db_type, window, cx, + ); + } + ConnectionType::Redis => { + this.editing_connection_id = Some(conn_id); + this.show_redis_form(window, cx); + } + ConnectionType::MongoDB => { + this.editing_connection_id = Some(conn_id); + this.show_mongodb_form(window, cx); + } + ConnectionType::Serial => { + this.editing_connection_id = Some(conn_id); + this.show_serial_form(window, cx); + } + ConnectionType::PortForwarding => { + this.editing_connection_id = Some(conn_id); + this.show_port_forwarding_form(window, cx); + } + ConnectionType::Rdp => { + this.editing_connection_id = Some(conn_id); + this.show_remote_desktop_form( + StoredRemoteDesktopProtocol::Rdp, + window, + cx, + ); + } + ConnectionType::Vnc => { + this.editing_connection_id = Some(conn_id); + this.show_remote_desktop_form( + StoredRemoteDesktopProtocol::Vnc, + window, + cx, + ); + } + _ => {} + } + } + }, + )) + }) + .child({ + let del_id = delete_conn_id; + let del_name = delete_conn_name.clone(); + Button::new(SharedString::from(format!( + "delete-list-conn-{}", + conn.id.unwrap_or(0) + ))) + .icon(IconName::Remove) + .with_size(Size::Small) + .danger() + .tooltip(t!("Home.delete_connection")) + .on_click(cx.listener( + move |this, _, window, cx| { + cx.stop_propagation(); + if let Some(conn_id) = del_id { + let conn_name = del_name.clone(); + this.confirm_delete_connection( + conn_id, conn_name, window, cx, + ); + } + }, + )) + }) + }) + }) + } + + fn connection_info_text(&self, conn: &StoredConnection) -> String { + match conn.connection_type { + ConnectionType::Database => conn + .to_db_connection() + .map(|params| { + if matches!(params.database_type, DatabaseType::SQLite | DatabaseType::DuckDB) { + params.host.clone() + } else { + let database = params + .database + .map(|db| format!("/{}", db)) + .unwrap_or_default(); + format!("{}@{}:{}{}", params.username, params.host, params.port, database) + } + }) + .unwrap_or_default(), + ConnectionType::SshSftp => conn + .to_ssh_params() + .map(|params| format!("{}@{}:{}", params.username, params.host, params.port)) + .unwrap_or_default(), + ConnectionType::Redis => conn + .to_redis_params() + .map(|params| format!("{}:{}/{}", params.host, params.port, params.db_index)) + .unwrap_or_default(), + ConnectionType::MongoDB => conn + .to_mongodb_params() + .map(|params| { + if !params.host.is_empty() { + if let Some(port) = params.port { + format!("{}:{}", params.host, port) + } else { + params.host + } + } else if !params.connection_string.is_empty() { + params.connection_string + } else { + "MongoDB".to_string() + } + }) + .unwrap_or_default(), + ConnectionType::Serial => conn + .to_serial_params() + .map(|params| { + let parity_char = match params.parity { + one_core::storage::models::SerialParity::None => 'N', + one_core::storage::models::SerialParity::Odd => 'O', + one_core::storage::models::SerialParity::Even => 'E', + }; + format!( + "{} ({}, {}{}{})", + params.port_name, params.baud_rate, params.data_bits, parity_char, params.stop_bits + ) + }) + .unwrap_or_default(), + ConnectionType::Rdp | ConnectionType::Vnc => conn + .to_remote_desktop_params() + .map(|params| { + match params.username.as_deref() { + Some(username) => format!("{}@{}:{}", username, params.host, params.port), + None => format!("{}:{}", params.host, params.port), + } + }) + .unwrap_or_default(), + ConnectionType::PortForwarding => conn + .to_port_forwarding_params() + .map(|params| match params.kind { + one_core::storage::PortForwardingKind::Local => format!( + "{}:{} -> {}:{}", + params.bind_host, params.bind_port, params.target_host, params.target_port + ), + one_core::storage::PortForwardingKind::Dynamic => { + format!("SOCKS {}:{}", params.bind_host, params.bind_port) + } + }) + .unwrap_or_default(), + _ => String::new(), + } + } + fn render_connection_card( &self, conn: StoredConnection, selected_id: Option, + index: usize, cx: &mut Context, ) -> AnyElement { let conn_id = conn.id; @@ -2892,6 +3406,7 @@ impl HomePage { let can_edit = can_edit_connection(&conn, cx); let has_team = conn.team_id.is_some(); + let accent = cx.theme().accent; let card = v_flex() .justify_center() @@ -2930,7 +3445,27 @@ impl HomePage { this.selected_connection_id = conn_id; cx.notify(); })) - .when(is_active, |this| { + .on_drag( + DragConnection { source_index: index }, + |drag, _, window, cx| { + window.prevent_default(); + cx.stop_propagation(); + cx.new(|_| drag.clone()) + }, + ) + .drag_over::(move |el, _, _, _cx| { + el.border_l_2().border_color(accent) + }) + .on_drop(cx.listener( + move |this, drag: &DragConnection, _window, cx| { + let from = drag.source_index; + let to = index; + if from != to { + this.reorder_connections(from, to, cx); + } + }, + )) + .when(is_active, |this| { // card version this.child( div() .absolute()