From 0131b18aa965d4367168f9ec4243970fa98c6d55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=83=A1=E9=A3=9E?= <1835698775@qq.com> Date: Tue, 7 Jul 2026 09:14:56 +0800 Subject: [PATCH 01/12] feat: add tab switcher dialog --- crates/core/src/keybindings.rs | 1 + crates/core/src/lib.rs | 1 + crates/core/src/tab_container.rs | 372 +++---------------- crates/core/src/tab_switcher.rs | 297 +++++++++++++++ crates/mongodb_view/locales/mongodb_view.yml | 72 ++++ main/src/onetcli_app.rs | 52 +++ 6 files changed, 477 insertions(+), 318 deletions(-) create mode 100644 crates/core/src/tab_switcher.rs diff --git a/crates/core/src/keybindings.rs b/crates/core/src/keybindings.rs index a4b78f8667..e258a616d1 100644 --- a/crates/core/src/keybindings.rs +++ b/crates/core/src/keybindings.rs @@ -12,6 +12,7 @@ pub mod action_id { pub const WINDOW_TOGGLE_FULLSCREEN: &str = "window.toggle_fullscreen"; pub const WINDOW_TOGGLE_ALWAYS_ON_TOP: &str = "window.toggle_always_on_top"; pub const APP_DUPLICATE_TAB: &str = "app.duplicate_tab"; + pub const APP_OPEN_TAB_SWITCHER: &str = "app.open_tab_switcher"; pub const APP_SWITCH_NEXT_TAB: &str = "app.switch_next_tab"; pub const APP_SWITCH_PREVIOUS_TAB: &str = "app.switch_previous_tab"; pub const APP_QUIT: &str = "app.quit"; diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 52669f9a62..cb5a7509a4 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -22,6 +22,7 @@ pub mod storage; pub mod tab_actions; pub mod tab_container; pub mod tab_navigation; +pub mod tab_switcher; // pub mod tab_persistence; pub mod settings; pub mod themes; diff --git a/crates/core/src/tab_container.rs b/crates/core/src/tab_container.rs index d4a32e89aa..007aa00ced 100644 --- a/crates/core/src/tab_container.rs +++ b/crates/core/src/tab_container.rs @@ -7,23 +7,22 @@ use crate::tab_actions::{ TAB_TITLE_METADATA_KEY, clear_tab_activity, duplicate_tab_id, mark_tab_activity, normalize_title, resolve_tab_title, }; +use crate::tab_switcher::{TabSwitcherEntry, open_tab_switcher_dialog}; use gpui::prelude::FluentBuilder; use gpui::{ - Anchor, AnyElement, AnyView, App, AppContext as _, Bounds, Context, Decorations, DragMoveEvent, + AnyElement, AnyView, App, AppContext as _, Bounds, Context, Decorations, DragMoveEvent, Element, ElementId, Entity, EntityId, EventEmitter, FocusHandle, Focusable, GlobalElementId, InspectorElementId, InteractiveElement, IntoElement, LayoutId, MouseButton, MouseMoveEvent, - MouseUpEvent, ParentElement, Pixels, Point, Render, RenderOnce, SharedString, Style, Styled, - Subscription, Task, Window, WindowControlArea, div, px, relative, + MouseUpEvent, ParentElement, Pixels, Point, Render, SharedString, Style, Styled, Subscription, + Task, Window, WindowControlArea, div, px, relative, }; use gpui::{ScrollHandle, StatefulInteractiveElement as _}; use gpui_component::button::{Button, ButtonVariants as _}; use gpui_component::input::{Input, InputEvent, InputState}; -use gpui_component::list::{List, ListDelegate, ListState}; use gpui_component::menu::{ContextMenuExt, PopupMenuItem}; -use gpui_component::popover::Popover; use gpui_component::{ - ActiveTheme, Disableable, Icon, IconName, IndexPath, InteractiveElementExt as _, Placement, - Selectable, Sizable, Size, h_flex, v_flex, + ActiveTheme, Disableable, Icon, IconName, InteractiveElementExt as _, Placement, Sizable, Size, + h_flex, v_flex, }; use rust_i18n::t; use serde::{Deserialize, Serialize}; @@ -695,248 +694,6 @@ impl Render for DragTab { } } -// ============================================================================ -// TabListItem - Custom list item for tab dropdown -// ============================================================================ - -#[derive(IntoElement)] -pub struct TabListItem { - tab_index: usize, - title: SharedString, - icon: Option, - closeable: bool, - selected: bool, - container: Entity, -} - -impl TabListItem { - pub fn new( - tab_index: usize, - title: SharedString, - icon: Option, - closeable: bool, - selected: bool, - container: Entity, - ) -> Self { - Self { - tab_index, - title, - icon, - closeable, - selected, - container, - } - } -} - -impl Selectable for TabListItem { - fn selected(mut self, selected: bool) -> Self { - self.selected = selected; - self - } - - fn is_selected(&self) -> bool { - self.selected - } -} - -impl RenderOnce for TabListItem { - fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { - let container = self.container.clone(); - let tab_index = self.tab_index; - let selected = self.selected; - let drag_border_color = cx.theme().drag_border; - let drag_title = self.title.clone(); - - h_flex() - .id(SharedString::from(format!("tab-item-{}", tab_index))) - .w_full() - .px_2() - .py_1() - .rounded(px(4.0)) - .items_center() - .gap_2() - .cursor_pointer() - .when(selected, |el| el.bg(cx.theme().list_active)) - .when(!selected, |el| { - el.hover(|style| style.bg(cx.theme().list_hover)) - }) - .on_drag( - DragTab::new(tab_index, drag_title), - |drag, _, window, cx| { - window.prevent_default(); - cx.stop_propagation(); - cx.new(|_| drag.clone()) - }, - ) - .drag_over::(move |el, _, _, _cx| { - el.border_t_2().border_color(drag_border_color) - }) - .on_drop( - window.listener_for(&container, move |this, drag: &DragTab, window, cx| { - let from_index = drag.tab_index; - let to_index = tab_index; - if from_index == to_index { - return; - } - this.move_tab(from_index, to_index, cx); - this.set_active_index(to_index, window, cx); - if let Some(tab_list) = &this.tab_list { - let tabs_data: Vec<(usize, SharedString, Option, bool)> = this - .tabs - .iter() - .enumerate() - .map(|(idx, tab)| { - ( - idx, - tab.title(cx), - tab.content().icon(cx), - tab.content().closeable(cx), - ) - }) - .collect(); - tab_list.update(cx, |state, cx| { - let delegate = state.delegate_mut(); - delegate.tabs = tabs_data.clone(); - delegate.filtered_tabs = tabs_data; - cx.notify(); - }); - } - }), - ) - .when_some(self.icon, |el, icon| { - el.child( - Icon::new(icon) - .size_4() - .text_color(cx.theme().muted_foreground), - ) - }) - .child( - div() - .flex_1() - .overflow_hidden() - .whitespace_nowrap() - .text_ellipsis() - .child(self.title), - ) - .when(self.closeable, |el| { - let container = container.clone(); - el.child( - div() - .id(SharedString::from(format!("close-btn-{}", tab_index))) - .flex() - .items_center() - .justify_center() - .w(px(16.0)) - .h(px(16.0)) - .rounded(px(2.0)) - .cursor_pointer() - .text_color(cx.theme().muted_foreground) - .hover(|style| style.bg(cx.theme().muted).text_color(cx.theme().foreground)) - .on_mouse_down(MouseButton::Left, move |_event, window, cx| { - container.update(cx, |this, cx| { - this.close_tab(tab_index, window, cx).detach(); - }); - }) - .child("×"), - ) - }) - } -} - -// ============================================================================ -// TabListDelegate - List delegate for tab dropdown -// ============================================================================ - -pub struct TabListDelegate { - container: Entity, - tabs: Vec<(usize, SharedString, Option, bool)>, - filtered_tabs: Vec<(usize, SharedString, Option, bool)>, - selected_index: Option, -} - -impl ListDelegate for TabListDelegate { - type Item = TabListItem; - - fn perform_search( - &mut self, - query: &str, - _window: &mut Window, - cx: &mut Context>, - ) -> Task<()> { - if query.is_empty() { - self.filtered_tabs = self.tabs.clone(); - } else { - let query_lower = query.to_lowercase(); - self.filtered_tabs = self - .tabs - .iter() - .filter(|(_, title, _, _)| title.to_lowercase().contains(&query_lower)) - .cloned() - .collect(); - } - cx.notify(); - Task::ready(()) - } - - fn items_count(&self, _section: usize, _cx: &App) -> usize { - self.filtered_tabs.len() - } - - fn render_item( - &mut self, - ix: IndexPath, - _window: &mut Window, - cx: &mut Context>, - ) -> Option { - let (tab_index, title, icon, closeable) = self.filtered_tabs.get(ix.row)?.clone(); - let active_index = self.container.read(cx).active_index(); - let is_active = tab_index == active_index; - - Some(TabListItem::new( - tab_index, - title, - icon, - closeable, - is_active, - self.container.clone(), - )) - } - - fn set_selected_index( - &mut self, - ix: Option, - _window: &mut Window, - _cx: &mut Context>, - ) { - self.selected_index = ix; - } - - fn confirm( - &mut self, - _secondary: bool, - window: &mut Window, - cx: &mut Context>, - ) { - if let Some(ix) = self.selected_index { - if let Some((tab_index, _, _, _)) = self.filtered_tabs.get(ix.row) { - let tab_index = *tab_index; - self.container.update(cx, |this, cx| { - this.list_popover_open = false; - this.set_active_index(tab_index, window, cx); - }); - } - } - } - - fn cancel(&mut self, _window: &mut Window, cx: &mut Context>) { - self.container.update(cx, |this, cx| { - this.list_popover_open = false; - cx.notify(); - }); - } -} - // ============================================================================ // TabContainer - Main container component // ============================================================================ @@ -957,8 +714,6 @@ pub struct TabContainer { left_padding: Option, top_padding: Option, tab_bar_scroll_handle: ScrollHandle, - list_popover_open: bool, - tab_list: Option>>, closing_tabs: HashSet, activity_tabs: HashSet, tab_content_subscriptions: Vec, @@ -1004,8 +759,6 @@ impl TabContainer { left_padding: None, top_padding: None, tab_bar_scroll_handle: ScrollHandle::new(), - list_popover_open: false, - tab_list: None, closing_tabs: HashSet::new(), activity_tabs: HashSet::new(), tab_content_subscriptions: Vec::new(), @@ -3083,6 +2836,43 @@ impl TabContainer { } } + fn tab_switcher_entries(&self, cx: &App) -> Vec { + let mut entries = Vec::with_capacity(self.pinned_tabs.len() + self.tabs.len()); + entries.extend( + self.pinned_tabs + .iter() + .enumerate() + .map(|(index, tab)| TabSwitcherEntry { + index, + pinned: true, + title: tab.title(cx), + icon: tab.content().icon(cx), + active: self.active_pinned_index == Some(index), + }), + ); + entries.extend( + self.tabs + .iter() + .enumerate() + .map(|(index, tab)| TabSwitcherEntry { + index, + pinned: false, + title: tab.title(cx), + icon: tab.content().icon(cx), + active: self.active_pinned_index.is_none() && index == self.active_index, + }), + ); + entries + } + + pub fn open_tab_switcher(&mut self, window: &mut Window, cx: &mut Context) { + let entries = self.tab_switcher_entries(cx); + if entries.is_empty() { + return; + } + open_tab_switcher_dialog(cx.entity(), entries, window, cx); + } + pub fn render_tab_bar( &mut self, window: &mut Window, @@ -3107,8 +2897,6 @@ impl TabContainer { let left_padding = self.left_padding.unwrap_or(px(8.0)); let pinned_tab_count = self.pinned_tabs.len(); - let tab_list = self.tab_list.clone(); - // 窗口拖动状态管理(仅在 Windows/Linux 上需要,且启用窗口控件时) let is_linux = cfg!(target_os = "linux"); let is_macos = cfg!(target_os = "macos"); @@ -3630,70 +3418,18 @@ impl TabContainer { })), ) .child( - Popover::new("tab-list-popover") - .anchor(Anchor::TopRight) - .p_0() - .open(self.list_popover_open) - .on_open_change(cx.listener(move |this, open, window, cx| { - this.list_popover_open = *open; - if *open { - let tabs_data: Vec<(usize, SharedString, Option, bool)> = this - .tabs - .iter() - .enumerate() - .map(|(idx, tab)| { - ( - idx, - tab.title(cx), - tab.content().icon(cx), - tab.content().closeable(cx), - ) - }) - .collect(); - let container = cx.entity(); - - if let Some(tab_list) = &this.tab_list { - tab_list.update(cx, |state, _| { - let delegate = state.delegate_mut(); - delegate.tabs = tabs_data.clone(); - delegate.filtered_tabs = tabs_data; - }); - } else { - this.tab_list = Some(cx.new(|cx| { - ListState::new( - TabListDelegate { - container, - tabs: tabs_data.clone(), - filtered_tabs: tabs_data, - selected_index: None, - }, - window, - cx, - ) - .searchable(true) - })); - } + Button::new("tab-dropdown-btn") + .icon(IconName::ChevronDown) + .ghost() + .compact() + .disabled(self.pinned_tabs.is_empty() && self.tabs.is_empty()) + .on_click({ + let view = view.clone(); + move |_, window, cx| { + view.update(cx, |this, cx| { + this.open_tab_switcher(window, cx); + }); } - cx.notify(); - })) - .when_some(tab_list.as_ref(), |popover, list| { - popover.track_focus(&list.focus_handle(cx)) - }) - .trigger( - Button::new("tab-dropdown-btn") - .icon(IconName::ChevronDown) - .ghost() - .compact(), - ) - .when_some(tab_list, |popover, list| { - popover.child( - List::new(&list) - .w(px(280.0)) - .max_h(px(300.0)) - .border_1() - .border_color(cx.theme().border) - .rounded(cx.theme().radius), - ) }), ) .when( diff --git a/crates/core/src/tab_switcher.rs b/crates/core/src/tab_switcher.rs new file mode 100644 index 0000000000..e5ac936f19 --- /dev/null +++ b/crates/core/src/tab_switcher.rs @@ -0,0 +1,297 @@ +use crate::tab_container::TabContainer; +use gpui::prelude::FluentBuilder; +use gpui::{ + App, AppContext as _, Context, Entity, InteractiveElement, IntoElement, MouseButton, + ParentElement, RenderOnce, SharedString, Styled as _, Task, Window, div, px, +}; +use gpui_component::list::{List, ListDelegate, ListState}; +use gpui_component::{ + ActiveTheme, Icon, IconName, IndexPath, Selectable, Sizable, Size, WindowExt as _, h_flex, +}; + +const SWITCHER_WIDTH: f32 = 640.0; +const SWITCHER_MAX_HEIGHT: f32 = 420.0; + +#[derive(Clone)] +pub struct TabSwitcherEntry { + pub index: usize, + pub pinned: bool, + pub title: SharedString, + pub icon: Option, + pub active: bool, +} + +pub fn filter_tab_switcher_entries( + entries: &[TabSwitcherEntry], + query: &str, +) -> Vec { + let query = query.trim().to_lowercase(); + if query.is_empty() { + return entries.to_vec(); + } + entries + .iter() + .filter(|entry| entry.title.to_lowercase().contains(&query)) + .cloned() + .collect() +} + +pub fn open_tab_switcher_dialog( + container: Entity, + entries: Vec, + window: &mut Window, + cx: &mut App, +) { + let active_row = entries + .iter() + .position(|entry| entry.active) + .unwrap_or_default(); + let list = cx.new(|cx| { + let mut list = ListState::new(TabSwitcherDelegate::new(container, entries), window, cx) + .searchable(true); + list.set_selected_index(Some(IndexPath::new(active_row)), window, cx); + list + }); + let dialog_list = list.clone(); + window.open_dialog(cx, move |dialog, _window, _cx| { + dialog + .w(px(SWITCHER_WIDTH)) + .margin_top(px(72.0)) + .close_button(false) + .title("Tabs") + .content({ + let list = dialog_list.clone(); + move |content, _window, _cx| { + content.p_0().child( + div().id("tab-switcher-dialog").child( + List::new(&list) + .search_placeholder("Search tabs") + .with_size(Size::Large) + .max_h(px(SWITCHER_MAX_HEIGHT)), + ), + ) + } + }) + }); + list.update(cx, |list, cx| list.focus(window, cx)); +} + +pub struct TabSwitcherDelegate { + container: Entity, + entries: Vec, + filtered_entries: Vec, + selected_index: Option, +} + +impl TabSwitcherDelegate { + fn new(container: Entity, entries: Vec) -> Self { + Self { + container, + filtered_entries: entries.clone(), + entries, + selected_index: None, + } + } +} + +impl ListDelegate for TabSwitcherDelegate { + type Item = TabSwitcherItem; + + fn perform_search( + &mut self, + query: &str, + _window: &mut Window, + cx: &mut Context>, + ) -> Task<()> { + self.filtered_entries = filter_tab_switcher_entries(&self.entries, query); + cx.notify(); + Task::ready(()) + } + + fn items_count(&self, _section: usize, _cx: &App) -> usize { + self.filtered_entries.len() + } + + fn render_item( + &mut self, + ix: IndexPath, + _window: &mut Window, + _cx: &mut Context>, + ) -> Option { + let entry = self.filtered_entries.get(ix.row)?.clone(); + Some(TabSwitcherItem::new( + entry, + self.container.clone(), + self.selected_index == Some(ix), + )) + } + + fn set_selected_index( + &mut self, + ix: Option, + _window: &mut Window, + _cx: &mut Context>, + ) { + self.selected_index = ix; + } + + fn confirm( + &mut self, + _secondary: bool, + window: &mut Window, + cx: &mut Context>, + ) { + let Some(ix) = self.selected_index else { + return; + }; + let Some(entry) = self.filtered_entries.get(ix.row) else { + return; + }; + activate_entry(&self.container, entry, window, cx); + } + + fn cancel(&mut self, window: &mut Window, cx: &mut Context>) { + window.close_dialog(cx); + } +} + +#[derive(IntoElement)] +pub struct TabSwitcherItem { + entry: TabSwitcherEntry, + container: Entity, + selected: bool, +} + +impl TabSwitcherItem { + fn new(entry: TabSwitcherEntry, container: Entity, selected: bool) -> Self { + Self { + entry, + container, + selected, + } + } +} + +impl Selectable for TabSwitcherItem { + fn selected(mut self, selected: bool) -> Self { + self.selected = selected; + self + } + + fn is_selected(&self) -> bool { + self.selected + } +} + +impl RenderOnce for TabSwitcherItem { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let container = self.container.clone(); + let entry = self.entry.clone(); + let selected = self.selected || self.entry.active; + h_flex() + .id(SharedString::from(format!( + "tab-switcher-item-{}-{}", + entry.pinned, entry.index + ))) + .h(px(44.0)) + .mx_2() + .px_3() + .rounded(px(6.0)) + .items_center() + .gap_3() + .cursor_pointer() + .text_color(cx.theme().foreground) + .when(selected, |el| el.bg(cx.theme().list_active)) + .when(!selected, |el| { + el.text_color(cx.theme().muted_foreground) + .hover(|style| style.bg(cx.theme().list_hover)) + }) + .on_mouse_down(MouseButton::Left, move |_, window, cx| { + activate_entry(&container, &entry, window, cx); + }) + .child(render_entry_icon(self.entry.icon, selected, cx)) + .child( + div() + .flex_1() + .min_w_0() + .overflow_hidden() + .whitespace_nowrap() + .text_ellipsis() + .text_sm() + .child(self.entry.title), + ) + } +} + +fn render_entry_icon(icon: Option, selected: bool, cx: &App) -> impl IntoElement { + let color = if selected { + cx.theme().foreground + } else { + cx.theme().muted_foreground + }; + div() + .flex_shrink_0() + .flex() + .items_center() + .child(match icon { + Some(icon) => Icon::new(icon).with_size(Size::Small).text_color(color), + None => Icon::new(IconName::Plus) + .with_size(Size::Small) + .text_color(color), + }) +} + +fn activate_entry( + container: &Entity, + entry: &TabSwitcherEntry, + window: &mut Window, + cx: &mut App, +) { + container.update(cx, |container, cx| { + if entry.pinned { + container.activate_pinned_tab_at(entry.index, window, cx); + } else { + container.set_active_index(entry.index, window, cx); + } + }); + window.close_dialog(cx); +} + +#[cfg(test)] +mod tests { + use super::{TabSwitcherEntry, filter_tab_switcher_entries}; + use gpui::SharedString; + + fn entry(index: usize, title: &str) -> TabSwitcherEntry { + TabSwitcherEntry { + index, + pinned: false, + title: SharedString::from(title.to_string()), + icon: None, + active: false, + } + } + + #[test] + fn tab_switcher_filter_matches_case_insensitively_and_handles_blank_query() { + let entries = vec![ + entry(0, "Vaults"), + entry(1, "SFTP"), + entry(2, "V8生产CoMi"), + entry(3, "New Tab"), + ]; + + let filtered = filter_tab_switcher_entries(&entries, "tab"); + + assert_eq!( + vec![3], + filtered.iter().map(|entry| entry.index).collect::>() + ); + let filtered = filter_tab_switcher_entries(&entries, " "); + + assert_eq!( + vec![0, 1, 2, 3], + filtered.iter().map(|entry| entry.index).collect::>() + ); + } +} diff --git a/crates/mongodb_view/locales/mongodb_view.yml b/crates/mongodb_view/locales/mongodb_view.yml index 5844e2b11e..56b6c13e12 100644 --- a/crates/mongodb_view/locales/mongodb_view.yml +++ b/crates/mongodb_view/locales/mongodb_view.yml @@ -913,3 +913,75 @@ TeamSync: en: Team zh-CN: 团队归属 zh-HK: 團隊歸屬 + + +# 连接表单通用 +ConnectionForm: + ssh_tunnel_enabled: + en: Enable SSH Tunnel + zh-CN: 启用 SSH 隧道 + zh-HK: 啟用 SSH 通道 + ssh_connection_id: + en: Reuse SSH Connection + zh-CN: 引用 SSH 连接 + zh-HK: 引用 SSH 連線 + ssh_connection_manual: + en: Manual SSH Settings + zh-CN: 手动填写 SSH + zh-HK: 手動填寫 SSH + ssh_host: + en: SSH Host + zh-CN: SSH 主机 + zh-HK: SSH 主機 + ssh_port: + en: SSH Port + zh-CN: SSH 端口 + zh-HK: SSH 端口 + ssh_username: + en: SSH Username + zh-CN: SSH 用户名 + zh-HK: SSH 使用者名稱 + ssh_password: + en: SSH Password + zh-CN: SSH 密码 + zh-HK: SSH 密碼 + ssh_auth_type: + en: SSH Auth Type + zh-CN: SSH 认证类型 + zh-HK: SSH 驗證類型 + ssh_auth_password: + en: Password + zh-CN: 密码 + zh-HK: 密碼 + ssh_auth_private_key: + en: Private Key + zh-CN: 私钥 + zh-HK: 私鑰 + ssh_auth_agent: + en: SSH Agent + zh-CN: SSH Agent + zh-HK: SSH Agent + ssh_private_key_path: + en: Private Key Path + zh-CN: 私钥路径 + zh-HK: 私鑰路徑 + ssh_private_key_passphrase: + en: Key Passphrase + zh-CN: 私钥口令 + zh-HK: 私鑰口令 + ssh_target_host: + en: Tunnel Target Host + zh-CN: 隧道目标主机 + zh-HK: 通道目標主機 + ssh_target_port: + en: Tunnel Target Port + zh-CN: 隧道目标端口 + zh-HK: 通道目標端口 + ssh_tunnel_invalid: + en: Invalid SSH tunnel configuration + zh-CN: SSH 隧道配置无效 + zh-HK: SSH 通道設定無效 + ssh_missing_required: + en: "Missing required field: %{field}" + zh-CN: "缺少必填字段:%{field}" + zh-HK: "缺少必填欄位:%{field}" \ No newline at end of file diff --git a/main/src/onetcli_app.rs b/main/src/onetcli_app.rs index 64cc929675..d8574ac740 100644 --- a/main/src/onetcli_app.rs +++ b/main/src/onetcli_app.rs @@ -31,6 +31,7 @@ actions!( ToggleAlwaysOnTop, MinimizeWindow, DuplicateTab, + OpenTabSwitcher, SwitchNextTab, SwitchPreviousTab, QuitApp, @@ -160,6 +161,24 @@ fn switch_tab(direction: TabCycleDirection, cx: &mut App) { }); } +fn open_tab_switcher(cx: &mut App) { + let Some(active_window) = cx.active_window() else { + return; + }; + let Some(container) = cx.try_global::() else { + return; + }; + let container = container.primary_pane(); + + cx.defer(move |cx| { + _ = active_window.update(cx, |_, window, cx| { + container.update(cx, |tc, cx| { + tc.open_tab_switcher(window, cx); + }); + }); + }); +} + fn toggle_fullscreen(cx: &mut App) { let Some(active_window) = cx.active_window() else { return; @@ -180,11 +199,20 @@ fn toggle_always_on_top(cx: &mut App) { let next = !ALWAYS_ON_TOP.load(Ordering::Relaxed); if set_window_always_on_top(window, next).is_ok() { ALWAYS_ON_TOP.store(next, Ordering::Relaxed); + #[cfg(target_os = "macos")] + if should_activate_after_always_on_top_change(next) { + window.activate_window(); + } } }); }); } +#[cfg(target_os = "macos")] +fn should_activate_after_always_on_top_change(always_on_top: bool) -> bool { + always_on_top +} + fn set_window_always_on_top(window: &Window, _always_on_top: bool) -> anyhow::Result<()> { let handle = HasWindowHandle::window_handle(window) .map_err(|err| anyhow::anyhow!("获取窗口句柄失败: {err:?}"))? @@ -466,6 +494,15 @@ fn init_keybindings(cx: &App) -> Vec { .into_iter() .map(|key| KeyBinding::new(&key, DuplicateTab, None)), ); + keybindings.extend( + shortcuts_for( + cx, + action_id::APP_OPEN_TAB_SWITCHER, + &[default_shortcut("cmd-j", "ctrl-j")], + ) + .into_iter() + .map(|key| KeyBinding::new(&key, OpenTabSwitcher, None)), + ); keybindings.extend( shortcuts_for(cx, action_id::APP_SWITCH_NEXT_TAB, &["ctrl-tab"]) .into_iter() @@ -536,6 +573,13 @@ fn refreshable_keybindings(cx: &App) -> Vec { None, DuplicateTab, )); + keybindings.extend(rebind_keybindings( + cx, + action_id::APP_OPEN_TAB_SWITCHER, + &[default_shortcut("cmd-j", "ctrl-j")], + None, + OpenTabSwitcher, + )); keybindings.extend(rebind_keybindings( cx, action_id::APP_SWITCH_NEXT_TAB, @@ -573,6 +617,7 @@ fn init_action_handlers(cx: &mut App) { cx.on_action(|_: &ToggleFullscreen, cx| toggle_fullscreen(cx)); cx.on_action(|_: &ToggleAlwaysOnTop, cx| toggle_always_on_top(cx)); cx.on_action(|_: &DuplicateTab, cx| duplicate_tab(cx)); + cx.on_action(|_: &OpenTabSwitcher, cx| open_tab_switcher(cx)); cx.on_action(|_: &SwitchNextTab, cx| switch_tab(TabCycleDirection::Next, cx)); cx.on_action(|_: &SwitchPreviousTab, cx| switch_tab(TabCycleDirection::Previous, cx)); cx.on_action(|_: &QuitApp, cx| quit_app(cx)); @@ -756,6 +801,13 @@ mod tests { assert_eq!(1, ai_layout.active_pinned_index); } + #[cfg(target_os = "macos")] + #[test] + fn macos_always_on_top_activation_only_happens_when_enabled() { + assert!(super::should_activate_after_always_on_top_change(true)); + assert!(!super::should_activate_after_always_on_top_change(false)); + } + #[test] fn configured_log_file_path_uses_default_for_empty_value() { let default_path = default_log_file_path().expect("应返回默认日志路径"); From 44069f2360e4fe450818a80be591ac03c6ee7e9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=83=A1=E9=A3=9E?= <1835698775@qq.com> Date: Tue, 7 Jul 2026 09:29:36 +0800 Subject: [PATCH 02/12] feat: update open connection dialog --- main/src/home/home_connection_quick_open.rs | 19 ++++++++--- main/src/home_tab.rs | 35 ++++++++++----------- 2 files changed, 32 insertions(+), 22 deletions(-) diff --git a/main/src/home/home_connection_quick_open.rs b/main/src/home/home_connection_quick_open.rs index 0c6cd32bee..53060b9c10 100644 --- a/main/src/home/home_connection_quick_open.rs +++ b/main/src/home/home_connection_quick_open.rs @@ -1,7 +1,9 @@ use crate::home_tab::HomePage; -use gpui::{App, Context, Entity, ParentElement, SharedString, Styled, Task, Window, div, px}; +use gpui::{ + App, Context, Entity, FontWeight, ParentElement, SharedString, Styled, Task, Window, div, px, +}; use gpui_component::{ - ActiveTheme, IndexPath, WindowExt, h_flex, + ActiveTheme, Icon, IndexPath, Sizable, Size, WindowExt, h_flex, list::{ListDelegate, ListItem, ListState}, }; use one_core::storage::StoredConnection; @@ -81,8 +83,9 @@ impl ListDelegate for ConnectionQuickOpenDelegate { Some( ListItem::new(ix) + .mx_2() + .h(px(44.0)) .px_3() - .py_2() .rounded(px(6.0)) .on_click(move |_, window, cx| { parent.update(cx, |this, cx| { @@ -94,12 +97,20 @@ impl ListDelegate for ConnectionQuickOpenDelegate { h_flex() .w_full() .items_center() - .gap_2() + .gap_3() + .child( + div() + .flex_shrink_0() + .flex() + .items_center() + .child(Icon::new(connection_type.icon()).with_size(Size::Small)), + ) .child( div() .flex_1() .min_w_0() .text_sm() + .font_weight(FontWeight::MEDIUM) .text_ellipsis() .whitespace_nowrap() .child(SharedString::from(name)), diff --git a/main/src/home_tab.rs b/main/src/home_tab.rs index 21bcfcfe2e..ab7efb6355 100644 --- a/main/src/home_tab.rs +++ b/main/src/home_tab.rs @@ -1600,26 +1600,25 @@ impl HomePage { }); let list_for_focus = list.clone(); - window.open_dialog(cx, move |dialog, _window, cx| { + window.open_dialog(cx, move |dialog, _window, _cx| { dialog .title(t!("Home.open_connection").to_string()) - .w(px(520.0)) - .child( - v_flex().gap_2().child( - List::new(&list) - .w_full() - .max_h(px(360.0)) - .p(px(8.0)) - .border_1() - .border_color(cx.theme().border) - .rounded(cx.theme().radius), - ), - ) - .alert() - .button_props( - gpui_component::dialog::DialogButtonProps::default() - .ok_text(t!("Common.close")), - ) + .w(px(640.0)) + .margin_top(px(72.0)) + .close_button(false) + .content({ + let list = list.clone(); + move |content, _window, _cx| { + content.p_0().child( + div().id("connection-quick-open-dialog").child( + List::new(&list) + .search_placeholder(t!("Home.open_connection").to_string()) + .with_size(Size::Large) + .max_h(px(420.0)), + ), + ) + } + }) }); // 将焦点设置到 List 搜索框,使上下键和 Enter 键可用 list_for_focus.update(cx, |state, cx| { From ea3f13321f81412be0520bc1167f6f75d8701934 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=83=A1=E9=A3=9E?= <1835698775@qq.com> Date: Tue, 7 Jul 2026 09:36:26 +0800 Subject: [PATCH 03/12] fix: use color icons in open connection dialog --- main/src/home/home_connection_quick_open.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/main/src/home/home_connection_quick_open.rs b/main/src/home/home_connection_quick_open.rs index 53060b9c10..d96acb015c 100644 --- a/main/src/home/home_connection_quick_open.rs +++ b/main/src/home/home_connection_quick_open.rs @@ -99,11 +99,11 @@ impl ListDelegate for ConnectionQuickOpenDelegate { .items_center() .gap_3() .child( - div() - .flex_shrink_0() - .flex() - .items_center() - .child(Icon::new(connection_type.icon()).with_size(Size::Small)), + div().flex_shrink_0().flex().items_center().child( + Icon::new(connection_type.icon()) + .color() + .with_size(Size::Small), + ), ) .child( div() From a921a5ebe836c8470cde23404f8eb3e6dfb77abd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=83=A1=E9=A3=9E?= <1835698775@qq.com> Date: Tue, 7 Jul 2026 09:42:13 +0800 Subject: [PATCH 04/12] fix: show database type icons in open dialog --- main/src/home/home_connection_quick_open.rs | 24 ++++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/main/src/home/home_connection_quick_open.rs b/main/src/home/home_connection_quick_open.rs index d96acb015c..b590366cee 100644 --- a/main/src/home/home_connection_quick_open.rs +++ b/main/src/home/home_connection_quick_open.rs @@ -6,7 +6,7 @@ use gpui_component::{ ActiveTheme, Icon, IndexPath, Sizable, Size, WindowExt, h_flex, list::{ListDelegate, ListItem, ListState}, }; -use one_core::storage::StoredConnection; +use one_core::storage::{ConnectionType, StoredConnection}; pub(crate) struct ConnectionQuickOpenDelegate { parent: Entity, @@ -50,6 +50,19 @@ impl ConnectionQuickOpenDelegate { } } +fn connection_icon(connection: &StoredConnection) -> Icon { + match connection.connection_type { + ConnectionType::Database => connection + .to_db_connection() + .map(|config| config.database_type.as_icon()) + .unwrap_or_else(|_| Icon::new(ConnectionType::Database.icon()).color()) + .with_size(Size::Small), + _ => Icon::new(connection.connection_type.icon()) + .color() + .with_size(Size::Small), + } +} + impl ListDelegate for ConnectionQuickOpenDelegate { type Item = ListItem; @@ -79,6 +92,7 @@ impl ListDelegate for ConnectionQuickOpenDelegate { let parent = self.parent.clone(); let name = connection.name.clone(); let connection_type = connection.connection_type; + let icon = connection_icon(&connection); let connection_for_open = connection.clone(); Some( @@ -98,13 +112,7 @@ impl ListDelegate for ConnectionQuickOpenDelegate { .w_full() .items_center() .gap_3() - .child( - div().flex_shrink_0().flex().items_center().child( - Icon::new(connection_type.icon()) - .color() - .with_size(Size::Small), - ), - ) + .child(div().flex_shrink_0().flex().items_center().child(icon)) .child( div() .flex_1() From d797f9599d579521d14a39636fa80b1bb28ca06b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=83=A1=E9=A3=9E?= <1835698775@qq.com> Date: Tue, 7 Jul 2026 09:51:58 +0800 Subject: [PATCH 05/12] fix: load PostgreSQL table comments (#93) --- crates/db/src/postgresql/plugin.rs | 56 ++++++++++++++++++++++++++++-- 1 file changed, 53 insertions(+), 3 deletions(-) diff --git a/crates/db/src/postgresql/plugin.rs b/crates/db/src/postgresql/plugin.rs index ddaed2061b..c3431c21c1 100644 --- a/crates/db/src/postgresql/plugin.rs +++ b/crates/db/src/postgresql/plugin.rs @@ -1255,11 +1255,15 @@ impl DatabasePlugin for PostgresPlugin { c.relname AS tablename, n.nspname AS schemaname, pg_catalog.pg_get_userbyid(c.relowner) AS tableowner, - obj_description(c.oid, 'pg_class') AS table_comment, + d.description AS table_comment, c.reltuples::bigint AS row_count, pg_size_pretty(pg_total_relation_size(c.oid)) AS total_size FROM pg_class c JOIN pg_namespace n ON c.relnamespace = n.oid + LEFT JOIN pg_description d + ON d.objoid = c.oid + AND d.classoid = 'pg_class'::regclass + AND d.objsubid = 0 WHERE n.nspname = '{}' AND c.relkind IN ('r', 'p') ORDER BY c.relname", @@ -2645,7 +2649,7 @@ mod tests { } #[tokio::test] - async fn test_postgres_table_metadata_reads_comments_from_pg_class() { + async fn test_postgres_table_metadata_reads_comments_from_pg_description() { let plugin = create_plugin(); let connection = CommentMetadataConnection::new(); @@ -2660,7 +2664,53 @@ mod tests { .iter() .find(|query| query.contains("table_comment")) .expect("table metadata query"); - assert!(table_query.contains("obj_description(c.oid, 'pg_class')")); + assert!(table_query.contains("LEFT JOIN pg_description d")); + assert!(table_query.contains("d.objsubid = 0")); + } + + #[tokio::test] + async fn test_postgres_tables_view_includes_table_comments() { + let plugin = create_plugin(); + let connection = CommentMetadataConnection::new(); + + let view = plugin + .list_tables_view(&connection, "app", Some("public".to_string())) + .await + .expect("list tables view"); + + assert_eq!( + Some("comment"), + view.columns.last().map(|column| column.key.as_str()) + ); + assert_eq!("Application users", view.rows[0][4]); + } + + #[tokio::test] + async fn test_postgres_table_tree_nodes_include_table_comments() { + let plugin = create_plugin(); + let connection = CommentMetadataConnection::new(); + let mut metadata = HashMap::new(); + metadata.insert("database".to_string(), "app".to_string()); + metadata.insert("schema".to_string(), "public".to_string()); + let table_folder = DbNode::new( + "conn-1:app:public:table_folder", + "DbTree.Tables", + DbNodeType::TablesFolder, + "conn-1".to_string(), + DatabaseType::PostgreSQL, + ) + .with_metadata(metadata); + + let nodes = plugin + .load_schema_folder_children(&connection, &table_folder, &table_folder.id) + .await + .expect("load table tree nodes"); + + assert_eq!("users", nodes[0].name); + assert_eq!( + Some("Application users"), + nodes[0].metadata.get("comment").map(String::as_str) + ); } #[tokio::test] From 610d891aab3adc8cac91dd46f2eaa4e03ae90b70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=83=A1=E9=A3=9E?= <1835698775@qq.com> Date: Tue, 7 Jul 2026 09:59:05 +0800 Subject: [PATCH 06/12] Revert "fix: load PostgreSQL table comments (#93)" This reverts commit d797f9599d579521d14a39636fa80b1bb28ca06b. --- crates/db/src/postgresql/plugin.rs | 56 ++---------------------------- 1 file changed, 3 insertions(+), 53 deletions(-) diff --git a/crates/db/src/postgresql/plugin.rs b/crates/db/src/postgresql/plugin.rs index c3431c21c1..ddaed2061b 100644 --- a/crates/db/src/postgresql/plugin.rs +++ b/crates/db/src/postgresql/plugin.rs @@ -1255,15 +1255,11 @@ impl DatabasePlugin for PostgresPlugin { c.relname AS tablename, n.nspname AS schemaname, pg_catalog.pg_get_userbyid(c.relowner) AS tableowner, - d.description AS table_comment, + obj_description(c.oid, 'pg_class') AS table_comment, c.reltuples::bigint AS row_count, pg_size_pretty(pg_total_relation_size(c.oid)) AS total_size FROM pg_class c JOIN pg_namespace n ON c.relnamespace = n.oid - LEFT JOIN pg_description d - ON d.objoid = c.oid - AND d.classoid = 'pg_class'::regclass - AND d.objsubid = 0 WHERE n.nspname = '{}' AND c.relkind IN ('r', 'p') ORDER BY c.relname", @@ -2649,7 +2645,7 @@ mod tests { } #[tokio::test] - async fn test_postgres_table_metadata_reads_comments_from_pg_description() { + async fn test_postgres_table_metadata_reads_comments_from_pg_class() { let plugin = create_plugin(); let connection = CommentMetadataConnection::new(); @@ -2664,53 +2660,7 @@ mod tests { .iter() .find(|query| query.contains("table_comment")) .expect("table metadata query"); - assert!(table_query.contains("LEFT JOIN pg_description d")); - assert!(table_query.contains("d.objsubid = 0")); - } - - #[tokio::test] - async fn test_postgres_tables_view_includes_table_comments() { - let plugin = create_plugin(); - let connection = CommentMetadataConnection::new(); - - let view = plugin - .list_tables_view(&connection, "app", Some("public".to_string())) - .await - .expect("list tables view"); - - assert_eq!( - Some("comment"), - view.columns.last().map(|column| column.key.as_str()) - ); - assert_eq!("Application users", view.rows[0][4]); - } - - #[tokio::test] - async fn test_postgres_table_tree_nodes_include_table_comments() { - let plugin = create_plugin(); - let connection = CommentMetadataConnection::new(); - let mut metadata = HashMap::new(); - metadata.insert("database".to_string(), "app".to_string()); - metadata.insert("schema".to_string(), "public".to_string()); - let table_folder = DbNode::new( - "conn-1:app:public:table_folder", - "DbTree.Tables", - DbNodeType::TablesFolder, - "conn-1".to_string(), - DatabaseType::PostgreSQL, - ) - .with_metadata(metadata); - - let nodes = plugin - .load_schema_folder_children(&connection, &table_folder, &table_folder.id) - .await - .expect("load table tree nodes"); - - assert_eq!("users", nodes[0].name); - assert_eq!( - Some("Application users"), - nodes[0].metadata.get("comment").map(String::as_str) - ); + assert!(table_query.contains("obj_description(c.oid, 'pg_class')")); } #[tokio::test] From 4f5553b34360bc7dbefef7e8a0ca97b8e3282176 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=83=A1=E9=A3=9E?= <1835698775@qq.com> Date: Tue, 7 Jul 2026 10:16:45 +0800 Subject: [PATCH 07/12] =?UTF-8?q?feat(window):=20=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E7=AA=97=E5=8F=A3=E7=BD=AE=E9=A1=B6=E7=8A=B6=E6=80=81=E5=88=87?= =?UTF-8?q?=E6=8D=A2=E5=8F=8A=E9=80=9A=E7=9F=A5=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 AlwaysOnTopNotification 用于窗口置顶状态变更的通知管理 - 优化窗口置顶切换逻辑,支持 macOS 平台窗口级别的正确设置 - 显示成功与错误通知,提示用户当前置顶状态及对应快捷键操作 - 提取快捷键标签渲染,支持动态获取和格式化快捷键显示 - 增加多平台兼容性处理,macOS 使用 Obj-C Runtime 控制窗口层级 - 编写多项单元测试,验证置顶状态计算和通知消息生成正确性 - 改善错误处理并日志告警,实现失败时的用户反馈和重试提示 --- main/src/onetcli_app.rs | 284 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 256 insertions(+), 28 deletions(-) diff --git a/main/src/onetcli_app.rs b/main/src/onetcli_app.rs index d8574ac740..0772577c64 100644 --- a/main/src/onetcli_app.rs +++ b/main/src/onetcli_app.rs @@ -2,10 +2,10 @@ use crate::home_tab::{ HomePage, NewConnectionShortcut, OpenConnectionQuickOpen, OpenLocalTerminalShortcut, }; use gpui::{ - App, AppContext, Context, Entity, IntoElement, KeyBinding, ParentElement, Render, Styled, - Window, actions, div, + App, AppContext, Context, Entity, IntoElement, KeyBinding, Keystroke, ParentElement, Render, + Styled, Window, actions, div, }; -use gpui_component::WindowExt; +use gpui_component::{WindowExt, kbd::Kbd, notification::Notification}; use one_core::keybindings::{action_id, rebind_keybindings, shortcuts_for}; use raw_window_handle::HasWindowHandle; #[cfg(any(target_os = "macos", target_os = "windows"))] @@ -15,6 +15,8 @@ use std::sync::atomic::{AtomicBool, Ordering}; static ALWAYS_ON_TOP: AtomicBool = AtomicBool::new(false); +struct AlwaysOnTopNotification; + actions!( onetcli_app, [ @@ -195,19 +197,104 @@ fn toggle_always_on_top(cx: &mut App) { return; }; cx.defer(move |cx| { - _ = active_window.update(cx, |_, window, _| { - let next = !ALWAYS_ON_TOP.load(Ordering::Relaxed); - if set_window_always_on_top(window, next).is_ok() { - ALWAYS_ON_TOP.store(next, Ordering::Relaxed); - #[cfg(target_os = "macos")] - if should_activate_after_always_on_top_change(next) { - window.activate_window(); + _ = active_window.update(cx, |_, window, cx| { + let next = next_always_on_top_state(window); + let shortcut = always_on_top_shortcut_label(cx); + match set_window_always_on_top(window, next) { + Ok(()) => { + ALWAYS_ON_TOP.store(next, Ordering::Relaxed); + #[cfg(target_os = "macos")] + if should_activate_after_always_on_top_change(next) { + window.activate_window(); + } + show_always_on_top_notification(window, cx, next, &shortcut); + } + Err(err) => { + tracing::warn!("窗口置顶切换失败: {err:?}"); + show_always_on_top_error_notification(window, cx, &shortcut, &err); } } }); }); } +fn always_on_top_shortcut_label(cx: &App) -> String { + let shortcut = shortcuts_for( + cx, + action_id::WINDOW_TOGGLE_ALWAYS_ON_TOP, + &[default_shortcut("ctrl-cmd-t", "ctrl-alt-t")], + ) + .into_iter() + .next() + .unwrap_or_else(|| default_shortcut("ctrl-cmd-t", "ctrl-alt-t").to_string()); + shortcut_label(&shortcut) +} + +fn shortcut_label(shortcut: &str) -> String { + Keystroke::parse(shortcut) + .map(|keystroke| Kbd::format(&keystroke).to_string()) + .unwrap_or_else(|_| shortcut.to_string()) +} + +fn always_on_top_notification_message(enabled: bool, shortcut: &str) -> String { + if enabled { + format!("窗口已置顶。再次按 {shortcut} 可取消置顶。") + } else { + format!("窗口已取消置顶。按 {shortcut} 可重新置顶。") + } +} + +fn always_on_top_error_notification_message(shortcut: &str, error: &anyhow::Error) -> String { + format!("窗口置顶切换失败:{error:#}。可再次按 {shortcut} 重试。") +} + +fn show_always_on_top_notification( + window: &mut Window, + cx: &mut App, + enabled: bool, + shortcut: &str, +) { + let message = always_on_top_notification_message(enabled, shortcut); + let notification = if enabled { + Notification::success(message) + } else { + Notification::info(message) + }; + window.push_notification( + notification.id::().autohide(true), + cx, + ); +} + +fn show_always_on_top_error_notification( + window: &mut Window, + cx: &mut App, + shortcut: &str, + error: &anyhow::Error, +) { + window.push_notification( + Notification::error(always_on_top_error_notification_message(shortcut, error)) + .id::() + .autohide(true), + cx, + ); +} + +fn next_always_on_top_state(_window: &Window) -> bool { + let cached = ALWAYS_ON_TOP.load(Ordering::Relaxed); + + #[cfg(target_os = "macos")] + let observed = window_always_on_top(_window).ok(); + #[cfg(not(target_os = "macos"))] + let observed = None; + + next_always_on_top_from_state(cached, observed) +} + +fn next_always_on_top_from_state(cached: bool, observed: Option) -> bool { + !observed.unwrap_or(cached) +} + #[cfg(target_os = "macos")] fn should_activate_after_always_on_top_change(always_on_top: bool) -> bool { always_on_top @@ -231,14 +318,12 @@ fn set_window_always_on_top(window: &Window, _always_on_top: bool) -> anyhow::Re } #[cfg(target_os = "macos")] -fn set_macos_always_on_top( - ns_view: *mut std::ffi::c_void, - always_on_top: bool, -) -> anyhow::Result<()> { - if ns_view.is_null() { - return Err(anyhow::anyhow!("获取 NSView 失败")); - } +const NS_NORMAL_WINDOW_LEVEL: isize = 0; +#[cfg(target_os = "macos")] +const NS_FLOATING_WINDOW_LEVEL: isize = 3; +#[cfg(target_os = "macos")] +fn with_macos_objc(f: impl FnOnce(MacosObjcFns) -> R) -> R { type Id = *mut std::ffi::c_void; type Sel = *mut std::ffi::c_void; @@ -247,28 +332,126 @@ fn set_macos_always_on_top( #[link_name = "sel_registerName"] fn sel_register_name(name: *const std::ffi::c_char) -> Sel; #[link_name = "objc_msgSend"] - fn objc_msg_send(receiver: Id, selector: Sel, ...) -> Id; + fn objc_msg_send(); + } + + unsafe { + let objc_msg_send = objc_msg_send as *const (); + // objc_msgSend must be called with the exact Objective-C method ABI. + f(MacosObjcFns { + sel_register_name, + objc_msg_send_id: std::mem::transmute::<*const (), unsafe extern "C" fn(Id, Sel) -> Id>( + objc_msg_send, + ), + objc_msg_send_isize: std::mem::transmute::< + *const (), + unsafe extern "C" fn(Id, Sel) -> isize, + >(objc_msg_send), + objc_msg_send_void_isize: std::mem::transmute::< + *const (), + unsafe extern "C" fn(Id, Sel, isize), + >(objc_msg_send), + }) } +} + +#[cfg(target_os = "macos")] +struct MacosObjcFns { + sel_register_name: unsafe extern "C" fn(*const std::ffi::c_char) -> *mut std::ffi::c_void, + objc_msg_send_id: + unsafe extern "C" fn(*mut std::ffi::c_void, *mut std::ffi::c_void) -> *mut std::ffi::c_void, + objc_msg_send_isize: + unsafe extern "C" fn(*mut std::ffi::c_void, *mut std::ffi::c_void) -> isize, + objc_msg_send_void_isize: + unsafe extern "C" fn(*mut std::ffi::c_void, *mut std::ffi::c_void, isize), +} - const NS_NORMAL_WINDOW_LEVEL: isize = 0; - const NS_FLOATING_WINDOW_LEVEL: isize = 3; - let level = if always_on_top { +#[cfg(target_os = "macos")] +fn macos_window_level(always_on_top: bool) -> isize { + if always_on_top { NS_FLOATING_WINDOW_LEVEL } else { NS_NORMAL_WINDOW_LEVEL - }; + } +} + +#[cfg(target_os = "macos")] +fn is_macos_always_on_top_level(level: isize) -> bool { + level != NS_NORMAL_WINDOW_LEVEL +} + +#[cfg(target_os = "macos")] +fn macos_ns_window_from_view( + ns_view: *mut std::ffi::c_void, +) -> anyhow::Result<*mut std::ffi::c_void> { + if ns_view.is_null() { + return Err(anyhow::anyhow!("获取 NSView 失败")); + } + let window_selector = std::ffi::CString::new("window")?; - let set_level_selector = std::ffi::CString::new("setLevel:")?; - unsafe { - let ns_window = objc_msg_send(ns_view.cast(), sel_register_name(window_selector.as_ptr())); + with_macos_objc(|objc| unsafe { + let ns_window = (objc.objc_msg_send_id)( + ns_view.cast(), + (objc.sel_register_name)(window_selector.as_ptr()), + ); if ns_window.is_null() { - return Err(anyhow::anyhow!("获取 NSWindow 失败")); + Err(anyhow::anyhow!("获取 NSWindow 失败")) + } else { + Ok(ns_window) } - objc_msg_send( + }) +} + +#[cfg(target_os = "macos")] +fn macos_ns_window_level(ns_window: *mut std::ffi::c_void) -> anyhow::Result { + let level_selector = std::ffi::CString::new("level")?; + Ok(with_macos_objc(|objc| unsafe { + (objc.objc_msg_send_isize)(ns_window, (objc.sel_register_name)(level_selector.as_ptr())) + })) +} + +#[cfg(target_os = "macos")] +fn set_macos_ns_window_level(ns_window: *mut std::ffi::c_void, level: isize) -> anyhow::Result<()> { + let set_level_selector = std::ffi::CString::new("setLevel:")?; + with_macos_objc(|objc| unsafe { + (objc.objc_msg_send_void_isize)( ns_window, - sel_register_name(set_level_selector.as_ptr()), + (objc.sel_register_name)(set_level_selector.as_ptr()), level, ); + }); + Ok(()) +} + +#[cfg(target_os = "macos")] +fn window_always_on_top(window: &Window) -> anyhow::Result { + let handle = HasWindowHandle::window_handle(window) + .map_err(|err| anyhow::anyhow!("获取窗口句柄失败: {err:?}"))? + .as_raw(); + match handle { + RawWindowHandle::AppKit(handle) => { + let ns_window = macos_ns_window_from_view(handle.ns_view.as_ptr())?; + Ok(is_macos_always_on_top_level(macos_ns_window_level( + ns_window, + )?)) + } + _ => Err(anyhow::anyhow!("当前平台暂不支持读取窗口置顶状态")), + } +} + +#[cfg(target_os = "macos")] +fn set_macos_always_on_top( + ns_view: *mut std::ffi::c_void, + always_on_top: bool, +) -> anyhow::Result<()> { + let ns_window = macos_ns_window_from_view(ns_view)?; + let level = macos_window_level(always_on_top); + set_macos_ns_window_level(ns_window, level)?; + let actual = macos_ns_window_level(ns_window)?; + if actual != level { + return Err(anyhow::anyhow!( + "设置 NSWindow level 失败: expected {level}, actual {actual}" + )); } Ok(()) } @@ -801,6 +984,51 @@ mod tests { assert_eq!(1, ai_layout.active_pinned_index); } + #[test] + fn next_always_on_top_state_prefers_observed_window_state() { + assert!(super::next_always_on_top_from_state(false, None)); + assert!(!super::next_always_on_top_from_state(true, None)); + assert!(super::next_always_on_top_from_state(true, Some(false))); + assert!(!super::next_always_on_top_from_state(false, Some(true))); + } + + #[test] + fn always_on_top_notification_message_includes_shortcut() { + assert_eq!( + "窗口已置顶。再次按 ⌃⌘T 可取消置顶。", + super::always_on_top_notification_message(true, "⌃⌘T") + ); + assert_eq!( + "窗口已取消置顶。按 ⌃⌘T 可重新置顶。", + super::always_on_top_notification_message(false, "⌃⌘T") + ); + } + + #[test] + fn always_on_top_error_notification_message_includes_shortcut() { + let error = anyhow::anyhow!("NSWindow level 写入失败"); + + assert_eq!( + "窗口置顶切换失败:NSWindow level 写入失败。可再次按 ⌃⌘T 重试。", + super::always_on_top_error_notification_message("⌃⌘T", &error) + ); + } + + #[test] + fn shortcut_label_formats_configured_shortcut() { + assert_eq!("⌃⌘T", super::shortcut_label("ctrl-cmd-t")); + } + + #[cfg(target_os = "macos")] + #[test] + fn macos_window_level_maps_toggle_state() { + assert_eq!(0, super::macos_window_level(false)); + assert_eq!(3, super::macos_window_level(true)); + assert!(!super::is_macos_always_on_top_level(0)); + assert!(super::is_macos_always_on_top_level(3)); + assert!(super::is_macos_always_on_top_level(-1)); + } + #[cfg(target_os = "macos")] #[test] fn macos_always_on_top_activation_only_happens_when_enabled() { From 04d20c8e6fff1312408ad78b62920d11a27f75ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=83=A1=E9=A3=9E?= <1835698775@qq.com> Date: Tue, 7 Jul 2026 10:22:00 +0800 Subject: [PATCH 08/12] fix: use ipc driver icons in open dialog --- main/src/home/home_connection_quick_open.rs | 18 ++++++++++++++---- main/src/home_tab.rs | 7 ++++++- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/main/src/home/home_connection_quick_open.rs b/main/src/home/home_connection_quick_open.rs index b590366cee..6d30c60f30 100644 --- a/main/src/home/home_connection_quick_open.rs +++ b/main/src/home/home_connection_quick_open.rs @@ -1,4 +1,6 @@ +use crate::external_driver_display::external_driver_icon_for_config_with_registry; use crate::home_tab::HomePage; +use db::ipc::IpcDriverRegistry; use gpui::{ App, Context, Entity, FontWeight, ParentElement, SharedString, Styled, Task, Window, div, px, }; @@ -10,6 +12,7 @@ use one_core::storage::{ConnectionType, StoredConnection}; pub(crate) struct ConnectionQuickOpenDelegate { parent: Entity, + external_driver_registry: IpcDriverRegistry, items: Vec, filtered_items: Vec, selected_index: Option, @@ -17,9 +20,13 @@ pub(crate) struct ConnectionQuickOpenDelegate { } impl ConnectionQuickOpenDelegate { - pub(crate) fn new(parent: Entity) -> Self { + pub(crate) fn new( + parent: Entity, + external_driver_registry: IpcDriverRegistry, + ) -> Self { Self { parent, + external_driver_registry, items: Vec::new(), filtered_items: Vec::new(), selected_index: None, @@ -50,11 +57,14 @@ impl ConnectionQuickOpenDelegate { } } -fn connection_icon(connection: &StoredConnection) -> Icon { +fn connection_icon(connection: &StoredConnection, registry: &IpcDriverRegistry) -> Icon { match connection.connection_type { ConnectionType::Database => connection .to_db_connection() - .map(|config| config.database_type.as_icon()) + .map(|config| { + external_driver_icon_for_config_with_registry(&config, Size::Small, registry) + .unwrap_or_else(|| config.database_type.as_icon()) + }) .unwrap_or_else(|_| Icon::new(ConnectionType::Database.icon()).color()) .with_size(Size::Small), _ => Icon::new(connection.connection_type.icon()) @@ -92,7 +102,7 @@ impl ListDelegate for ConnectionQuickOpenDelegate { let parent = self.parent.clone(); let name = connection.name.clone(); let connection_type = connection.connection_type; - let icon = connection_icon(&connection); + let icon = connection_icon(&connection, &self.external_driver_registry); let connection_for_open = connection.clone(); Some( diff --git a/main/src/home_tab.rs b/main/src/home_tab.rs index ab7efb6355..ce027b76ee 100644 --- a/main/src/home_tab.rs +++ b/main/src/home_tab.rs @@ -589,6 +589,7 @@ mod external_driver_form_tests { #[test] fn home_render_uses_cached_external_driver_registry() { let source = include_str!("home_tab.rs"); + let quick_open = include_str!("home/home_connection_quick_open.rs"); let list_item = source .rsplit("fn render_connection_list_item(") .next() @@ -607,8 +608,11 @@ mod external_driver_form_tests { assert!(source.contains("external_driver_registry: IpcDriverRegistry")); assert!(list_item.contains("external_driver_icon_for_config_with_registry")); assert!(card.contains("external_driver_icon_for_config_with_registry")); + assert!(quick_open.contains("external_driver_icon_for_config_with_registry")); + assert!(quick_open.contains("external_driver_registry: IpcDriverRegistry")); assert!(!list_item.contains("IpcDriverRegistry::load_default()")); assert!(!card.contains("IpcDriverRegistry::load_default()")); + assert!(!quick_open.contains("IpcDriverRegistry::load_default()")); } fn sync_conflict(cloud_id: &str) -> SyncConflict { @@ -1593,8 +1597,9 @@ impl HomePage { let parent = cx.entity(); let connections = self.connections.clone(); + let external_driver_registry = self.external_driver_registry.clone(); let list = cx.new(|cx| { - let mut delegate = ConnectionQuickOpenDelegate::new(parent); + let mut delegate = ConnectionQuickOpenDelegate::new(parent, external_driver_registry); delegate.update_items(&connections); ListState::new(delegate, window, cx).searchable(true) }); From bdf259797eef0629846cb487705b9bafa72cf277 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=83=A1=E9=A3=9E?= <1835698775@qq.com> Date: Tue, 7 Jul 2026 10:23:15 +0800 Subject: [PATCH 09/12] =?UTF-8?q?feat(terminal=5Fview):=20=E4=BC=98?= =?UTF-8?q?=E5=8C=96SSH=E8=BF=9E=E6=8E=A5=E9=94=99=E8=AF=AF=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E5=92=8C=E6=8E=A7=E4=BB=B6=E6=BB=9A=E5=8A=A8=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 format_connection_error 函数,格式化连接错误信息,保留上下文链 - 修改连接测试错误处理,使用格式化错误信息替代简单字符串 - 在错误信息展示区增加滚动条和样式,支持多行错误详情显示 - 视图组件中错误消息区支持滚动显示,提升用户体验 - 增加单元测试,确保错误上下文信息完整输出 - 引入 ScrollableElement 以支持滚动相关功能 --- crates/terminal_view/src/ssh_form_window.rs | 37 ++++++++++++++++++--- crates/terminal_view/src/view.rs | 12 ++++--- 2 files changed, 41 insertions(+), 8 deletions(-) diff --git a/crates/terminal_view/src/ssh_form_window.rs b/crates/terminal_view/src/ssh_form_window.rs index 775e4a464a..56d11bf6ce 100644 --- a/crates/terminal_view/src/ssh_form_window.rs +++ b/crates/terminal_view/src/ssh_form_window.rs @@ -11,6 +11,7 @@ use gpui_component::{ h_flex, input::{Input, InputState}, radio::Radio, + scroll::ScrollableElement, select::{Select, SelectItem, SelectState}, tab::{Tab, TabBar}, v_flex, @@ -258,6 +259,10 @@ fn build_connection_test_signature(params: &SshParams) -> String { format!("{:?}", params) } +fn format_connection_error(error: &anyhow::Error) -> String { + format!("{error:#}") +} + fn validate_save_state( is_testing: bool, is_uninstalling_shell_integration: bool, @@ -998,7 +1003,7 @@ impl SshFormWindow { }; let test_result: Result<(), String> = match spawn_result { Ok(task) => Ok(task), - Err(e) => Err(e.to_string()), + Err(error) => Err(format_connection_error(&error)), }; let _ = cx.update_window(window_handle, |_, window, cx| { @@ -1659,13 +1664,22 @@ impl Render for SshFormWindow { div() .text_sm() .text_color(cx.theme().success) - .child(t!("SSH.test_success").to_string()), + .child(t!("SSH.test_success").to_string()) + .into_any_element(), ), Some(Err(e)) => Some( div() + .mx_6() + .px_3() + .py_2() + .rounded_md() + .bg(gpui::rgb(0xfee2e2)) .text_sm() .text_color(cx.theme().danger) - .child(e.clone()), + .max_h(px(120.0)) + .overflow_y_scrollbar() + .child(e.clone()) + .into_any_element(), ), None => None, }; @@ -1790,8 +1804,9 @@ impl Render for SshFormWindow { mod tests { use super::{ AuthMethodSelection, build_connection_test_signature, build_jump_auth_method, - validate_save_state, + format_connection_error, validate_save_state, }; + use anyhow::Context as _; use one_core::storage::{SshAuthMethod, SshParams, StoredConnection}; use std::sync::Arc; @@ -1858,6 +1873,20 @@ mod tests { assert_ne!(original, build_connection_test_signature(&changed_host)); } + #[test] + fn connection_test_error_keeps_context_chain() { + let error = Err::<(), _>(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "denied", + )) + .context("SSH connection failed") + .unwrap_err(); + let message = format_connection_error(&error); + + assert!(message.contains("SSH connection failed")); + assert!(message.contains("denied")); + } + #[test] fn save_gate_allows_saving_without_successful_connection_test() { assert_eq!(validate_save_state(false, false), Ok(())); diff --git a/crates/terminal_view/src/view.rs b/crates/terminal_view/src/view.rs index 02d0a4ddd4..b12d9f1498 100644 --- a/crates/terminal_view/src/view.rs +++ b/crates/terminal_view/src/view.rs @@ -10,7 +10,7 @@ use gpui_component::dialog::DialogButtonProps; use gpui_component::input::{Input, InputState}; use gpui_component::menu::{ContextMenuExt, PopupMenu, PopupMenuItem}; use gpui_component::notification::Notification; -use gpui_component::scroll::{Scrollbar, ScrollbarHandle, ScrollbarShow}; +use gpui_component::scroll::{ScrollableElement, Scrollbar, ScrollbarHandle, ScrollbarShow}; use gpui_component::{BlinkCursor, Icon, IconName, Sizable, WindowExt, h_flex, kbd::Kbd, v_flex}; use one_core::gpui_tokio::Tokio; use one_core::keybindings::{ @@ -3754,11 +3754,15 @@ impl TerminalView { .when_some(error_msg, |this, msg| { this.child( div() + .px_3() + .py_2() + .rounded_md() + .bg(rgb(0x1f1f1f)) .text_sm() .text_color(rgb(0xef4444)) - .max_w(px(350.0)) - .overflow_hidden() - .text_ellipsis() + .max_w(px(480.0)) + .max_h(px(160.0)) + .overflow_y_scrollbar() .child(msg), ) }) From 8b2962f5fd83d1f540b54d81574afadab744002d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=83=A1=E9=A3=9E?= <1835698775@qq.com> Date: Tue, 7 Jul 2026 11:02:04 +0800 Subject: [PATCH 10/12] fix(db): stream SQL dump data by page (#56) --- crates/db/src/import_export/formats/mod.rs | 1 + crates/db/src/import_export/formats/sql.rs | 47 ++--- .../src/import_export/formats/sql_export.rs | 183 ++++++++++++++++++ .../import_export/formats/sql_export_tests.rs | 159 +++++++++++++++ 4 files changed, 355 insertions(+), 35 deletions(-) create mode 100644 crates/db/src/import_export/formats/sql_export.rs create mode 100644 crates/db/src/import_export/formats/sql_export_tests.rs diff --git a/crates/db/src/import_export/formats/mod.rs b/crates/db/src/import_export/formats/mod.rs index 15e12e29ab..71a56ce1da 100644 --- a/crates/db/src/import_export/formats/mod.rs +++ b/crates/db/src/import_export/formats/mod.rs @@ -4,6 +4,7 @@ use crate::import_export::ImportConfig; pub mod csv; pub mod json; pub mod sql; +mod sql_export; pub mod txt; pub mod xml; diff --git a/crates/db/src/import_export/formats/sql.rs b/crates/db/src/import_export/formats/sql.rs index 860057357b..1ac065aae3 100644 --- a/crates/db/src/import_export/formats/sql.rs +++ b/crates/db/src/import_export/formats/sql.rs @@ -4,6 +4,7 @@ use anyhow::Result; use async_trait::async_trait; use super::format_import_table_reference; +use super::sql_export::export_table_data_in_pages; use crate::connection::DbConnection; use crate::executor::{ExecOptions, SqlResult}; use crate::import_export::{ @@ -282,43 +283,19 @@ impl FormatHandler for SqlFormatHandler { table: table.clone(), }); - match plugin - .export_table_data_sql( - connection, - &config.database, - config.schema.as_deref(), - table, - config.where_clause.as_deref(), - config.limit, - ) - .await + match export_table_data_in_pages( + plugin, + connection, + config, + table, + is_streaming, + &mut output, + &send_progress, + ) + .await { - Ok(data_sql) => { - let mut data_output = String::new(); - let rows_count = if !data_sql.is_empty() { - data_output.push_str("-- Data for table "); - data_output.push_str(table); - data_output.push('\n'); - data_output.push_str(&data_sql); - data_output.push('\n'); - data_sql.lines().filter(|l| l.starts_with("INSERT")).count() as u64 - } else { - 0 - }; + Ok(rows_count) => { total_rows += rows_count; - let progress_data = if is_streaming { - std::mem::take(&mut data_output) - } else { - data_output.clone() - }; - send_progress(ExportProgressEvent::DataExported { - table: table.clone(), - rows: rows_count, - data: progress_data, - }); - if !is_streaming { - output.push_str(&data_output); - } } Err(e) => { let error_output = diff --git a/crates/db/src/import_export/formats/sql_export.rs b/crates/db/src/import_export/formats/sql_export.rs new file mode 100644 index 0000000000..2c44b640b7 --- /dev/null +++ b/crates/db/src/import_export/formats/sql_export.rs @@ -0,0 +1,183 @@ +use anyhow::Result; + +use crate::DatabasePlugin; +use crate::connection::DbConnection; +use crate::executor::{QueryResult, SqlResult}; +use crate::import_export::{ExportConfig, ExportProgressEvent}; + +const SQL_EXPORT_PAGE_SIZE: usize = 1000; + +pub(super) async fn export_table_data_in_pages( + plugin: &dyn DatabasePlugin, + connection: &dyn DbConnection, + config: &ExportConfig, + table: &str, + is_streaming: bool, + output: &mut String, + send_progress: &(dyn Fn(ExportProgressEvent) + Sync), +) -> Result { + let table_ident = + plugin.format_export_table_reference(&config.database, config.schema.as_deref(), table); + let mut offset = 0usize; + let mut total_rows = 0u64; + let mut remaining = config.limit; + let mut wrote_header = false; + + loop { + let Some(page_limit) = next_export_page_limit(remaining) else { + break; + }; + let select_sql = export_page_select_sql(plugin, config, table, page_limit, offset); + let query_result = query_export_page(connection, &select_sql).await?; + let rows_count = query_result.rows.len() as u64; + let data_output = sql_dump_page( + plugin, + &table_ident, + table, + &query_result, + &mut wrote_header, + ); + + append_or_send_export_page( + output, + is_streaming, + send_progress, + table, + rows_count, + data_output, + ); + + total_rows += rows_count; + if rows_count < page_limit as u64 { + break; + } + offset += page_limit; + remaining = remaining.map(|limit| limit.saturating_sub(page_limit)); + } + + Ok(total_rows) +} + +fn next_export_page_limit(remaining: Option) -> Option { + let page_limit = remaining + .map(|limit| limit.min(SQL_EXPORT_PAGE_SIZE)) + .unwrap_or(SQL_EXPORT_PAGE_SIZE); + (page_limit > 0).then_some(page_limit) +} + +fn export_page_select_sql( + plugin: &dyn DatabasePlugin, + config: &ExportConfig, + table: &str, + page_limit: usize, + offset: usize, +) -> String { + let table_ref = + plugin.format_table_reference(&config.database, config.schema.as_deref(), table); + let mut select_sql = format!("SELECT * FROM {}", table_ref); + if let Some(where_c) = &config.where_clause { + select_sql.push_str(" WHERE "); + select_sql.push_str(where_c); + } + select_sql.push_str(&plugin.format_pagination(page_limit, offset, "")); + select_sql +} + +async fn query_export_page(connection: &dyn DbConnection, select_sql: &str) -> Result { + match connection + .query(select_sql) + .await + .map_err(|e| anyhow::anyhow!("Query failed: {}", e))? + { + SqlResult::Query(query_result) => Ok(query_result), + SqlResult::Exec(_) => Err(anyhow::anyhow!("Expected query result for SQL export")), + SqlResult::Error(error) => Err(anyhow::anyhow!(error.message)), + } +} + +fn sql_dump_page( + plugin: &dyn DatabasePlugin, + table_ident: &str, + table: &str, + query_result: &QueryResult, + wrote_header: &mut bool, +) -> String { + if query_result.rows.is_empty() { + return String::new(); + } + + let mut output = String::new(); + if !*wrote_header { + output.push_str("-- Data for table "); + output.push_str(table); + output.push('\n'); + *wrote_header = true; + } + for row in &query_result.rows { + push_insert_statement(plugin, &mut output, table_ident, &query_result.columns, row); + } + output +} + +fn push_insert_statement( + plugin: &dyn DatabasePlugin, + output: &mut String, + table_ident: &str, + columns: &[String], + row: &[Option], +) { + output.push_str("INSERT INTO "); + output.push_str(table_ident); + output.push_str(" ("); + output.push_str( + &columns + .iter() + .map(|column| plugin.quote_identifier(column)) + .collect::>() + .join(", "), + ); + output.push_str(") VALUES ("); + for (index, value) in row.iter().enumerate() { + if index > 0 { + output.push_str(", "); + } + push_sql_value(output, value.as_deref()); + } + output.push_str(");\n"); +} + +fn push_sql_value(output: &mut String, value: Option<&str>) { + match value { + Some(value) => { + output.push('\''); + output.push_str(&value.replace('\'', "''")); + output.push('\''); + } + None => output.push_str("NULL"), + } +} + +fn append_or_send_export_page( + output: &mut String, + is_streaming: bool, + send_progress: &(dyn Fn(ExportProgressEvent) + Sync), + table: &str, + rows: u64, + data_output: String, +) { + let progress_data = if is_streaming { + data_output + } else { + output.push_str(&data_output); + data_output.clone() + }; + send_progress(ExportProgressEvent::DataExported { + table: table.to_string(), + rows, + data: progress_data, + }); +} + +#[cfg(test)] +#[path = "sql_export_tests.rs"] +mod tests; diff --git a/crates/db/src/import_export/formats/sql_export_tests.rs b/crates/db/src/import_export/formats/sql_export_tests.rs new file mode 100644 index 0000000000..187b9fb6b0 --- /dev/null +++ b/crates/db/src/import_export/formats/sql_export_tests.rs @@ -0,0 +1,159 @@ +use super::*; +use crate::connection::{DbError, StreamingProgress}; +use crate::executor::{ExecOptions, QueryColumnMeta, SqlSource}; +use crate::mysql::MySqlPlugin; +use async_trait::async_trait; +use one_core::storage::{DatabaseType, DbConnectionConfig}; +use std::sync::{Arc, Mutex}; +use tokio::sync::mpsc; + +struct PagedConnection { + config: DbConnectionConfig, + queries: Arc>>, + pages: Arc>>>>>, +} + +impl PagedConnection { + fn new(pages: Vec>>>) -> Self { + Self { + config: test_config(), + queries: Arc::new(Mutex::new(Vec::new())), + pages: Arc::new(Mutex::new(pages)), + } + } + + fn queries(&self) -> Vec { + self.queries.lock().unwrap().clone() + } +} + +#[async_trait] +impl DbConnection for PagedConnection { + fn config(&self) -> &DbConnectionConfig { + &self.config + } + + fn set_config_database(&mut self, database: Option) { + self.config.database = database; + } + + async fn connect(&mut self) -> std::result::Result<(), DbError> { + Ok(()) + } + + async fn disconnect(&mut self) -> std::result::Result<(), DbError> { + Ok(()) + } + + async fn execute( + &self, + _plugin: &dyn DatabasePlugin, + _script: &str, + _options: ExecOptions, + ) -> std::result::Result, DbError> { + Ok(Vec::new()) + } + + async fn query(&self, query: &str) -> std::result::Result { + self.queries.lock().unwrap().push(query.to_string()); + let rows = self.pages.lock().unwrap().remove(0); + Ok(SqlResult::Query(QueryResult { + sql: query.to_string(), + columns: vec!["id".to_string(), "name".to_string()], + column_meta: vec![ + QueryColumnMeta::new("id", "BIGINT"), + QueryColumnMeta::new("name", "VARCHAR"), + ], + rows, + elapsed_ms: 1, + })) + } + + async fn current_database(&self) -> std::result::Result, DbError> { + Ok(Some("app".to_string())) + } + + async fn switch_database(&self, _database: &str) -> std::result::Result<(), DbError> { + Ok(()) + } + + async fn execute_streaming( + &self, + _plugin: &dyn DatabasePlugin, + _source: SqlSource, + _options: ExecOptions, + _sender: mpsc::Sender, + ) -> std::result::Result<(), DbError> { + Ok(()) + } +} + +fn row(id: usize) -> Vec> { + vec![Some(id.to_string()), Some(format!("user'{id}"))] +} + +fn test_config() -> DbConnectionConfig { + DbConnectionConfig { + id: "test".to_string(), + database_type: DatabaseType::MySQL, + name: "mysql".to_string(), + host: "localhost".to_string(), + port: 3306, + username: "root".to_string(), + password: String::new(), + database: Some("app".to_string()), + service_name: None, + sid: None, + workspace_id: None, + extra_params: Default::default(), + } +} + +#[tokio::test] +async fn sql_export_streams_table_data_in_pages() { + let first_page = (0..SQL_EXPORT_PAGE_SIZE).map(row).collect::>(); + let second_page = vec![row(SQL_EXPORT_PAGE_SIZE)]; + let connection = PagedConnection::new(vec![first_page, second_page]); + let plugin = MySqlPlugin::new(); + let config = ExportConfig { + database: "app".to_string(), + tables: vec!["users".to_string()], + ..ExportConfig::default() + }; + let mut output = String::new(); + let events = Mutex::new(Vec::new()); + + let rows = export_table_data_in_pages( + &plugin, + &connection, + &config, + "users", + true, + &mut output, + &|event| events.lock().unwrap().push(event), + ) + .await + .expect("paged export should succeed"); + + assert_eq!(1001, rows); + assert!(output.is_empty()); + assert_eq!( + vec![ + "SELECT * FROM `app`.`users` LIMIT 1000 OFFSET 0", + "SELECT * FROM `app`.`users` LIMIT 1000 OFFSET 1000", + ], + connection.queries() + ); + let events = events.lock().unwrap(); + assert_eq!(2, events.len()); + assert!(matches!( + &events[0], + ExportProgressEvent::DataExported { rows: 1000, data, .. } + if data.contains("-- Data for table users") && data.contains("'user''0'") + )); + assert!(matches!( + &events[1], + ExportProgressEvent::DataExported { rows: 1, data, .. } + if !data.contains("-- Data for table users") && data.contains("'user''1000'") + )); +} From 26fbd5566af0f820a1585fe35b87b9e6aef95cb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=83=A1=E9=A3=9E?= <1835698775@qq.com> Date: Tue, 7 Jul 2026 11:25:10 +0800 Subject: [PATCH 11/12] feat: support synced ssh private key content --- crates/connection_tunnel/src/lib.rs | 11 ++ crates/core/src/storage/models.rs | 87 +++++++- crates/db/locales/db.yml | 12 ++ crates/db/src/ssh_tunnel.rs | 2 + crates/db_view/locales/db_view.yml | 12 ++ .../db_view/src/common/db_connection_form.rs | 113 ++++++++++- crates/db_view/src/database_view_plugin.rs | 2 + crates/mongodb_view/locales/mongodb_view.yml | 14 +- crates/mongodb_view/src/mongo_form_window.rs | 45 ++++- crates/onetcli_runtime/src/sftp_tools.rs | 8 + crates/port_forwarding/src/runtime.rs | 8 + crates/redis_view/locales/redis_view.yml | 12 ++ crates/redis_view/src/connection.rs | 17 ++ crates/redis_view/src/redis_form_window.rs | 45 ++++- crates/sftp_view/src/lib.rs | 16 ++ crates/ssh/src/ssh.rs | 55 +++++- crates/terminal/src/terminal.rs | 16 ++ .../terminal_view/locales/terminal_view.yml | 18 +- crates/terminal_view/src/ssh_form_window.rs | 186 +++++++++++++++++- .../connection_import_draft_conversion.rs | 19 +- .../src/home/connection_import_draft_tests.rs | 23 +++ 21 files changed, 692 insertions(+), 29 deletions(-) diff --git a/crates/connection_tunnel/src/lib.rs b/crates/connection_tunnel/src/lib.rs index 3b8550105c..6da6a50fc6 100644 --- a/crates/connection_tunnel/src/lib.rs +++ b/crates/connection_tunnel/src/lib.rs @@ -49,6 +49,8 @@ pub struct SshTunnelConfig { #[serde(default)] pub private_key_path: Option, #[serde(default)] + pub private_key_content: Option, + #[serde(default)] pub private_key_passphrase: Option, #[serde(default)] pub target_host: Option, @@ -69,6 +71,7 @@ impl Default for SshTunnelConfig { auth_type: DEFAULT_SSH_AUTH_TYPE.to_string(), password: None, private_key_path: None, + private_key_content: None, private_key_passphrase: None, target_host: None, target_port: None, @@ -190,6 +193,14 @@ fn build_auth(config: &SshTunnelConfig) -> Result { passphrase: optional_value(&config.private_key_passphrase), certificate_path: None, }), + "private_key_content" | "private_key_material" => Ok(SshAuth::PrivateKeyContent { + private_key: required_value( + "private_key_content", + config.private_key_content.as_deref().unwrap_or(""), + )?, + passphrase: optional_value(&config.private_key_passphrase), + certificate_path: None, + }), _ => Ok(SshAuth::Password(required_value( "password", config.password.as_deref().unwrap_or(""), diff --git a/crates/core/src/storage/models.rs b/crates/core/src/storage/models.rs index 7813551c5a..223ef5bf26 100644 --- a/crates/core/src/storage/models.rs +++ b/crates/core/src/storage/models.rs @@ -360,6 +360,10 @@ pub enum SshAuthMethod { key_path: String, passphrase: Option, }, + PrivateKeyContent { + private_key: String, + passphrase: Option, + }, Agent, AutoPublicKey, } @@ -455,6 +459,7 @@ impl RedisParams { tunnel.auth_type = "password".to_string(); tunnel.password = Some(password); tunnel.private_key_path = None; + tunnel.private_key_content = None; tunnel.private_key_passphrase = None; } SshAuthMethod::PrivateKey { @@ -464,18 +469,31 @@ impl RedisParams { tunnel.auth_type = "private_key".to_string(); tunnel.password = None; tunnel.private_key_path = Some(key_path); + tunnel.private_key_content = None; + tunnel.private_key_passphrase = passphrase; + } + SshAuthMethod::PrivateKeyContent { + private_key, + passphrase, + } => { + tunnel.auth_type = "private_key_content".to_string(); + tunnel.password = None; + tunnel.private_key_path = None; + tunnel.private_key_content = Some(private_key); tunnel.private_key_passphrase = passphrase; } SshAuthMethod::Agent => { tunnel.auth_type = "agent".to_string(); tunnel.password = None; tunnel.private_key_path = None; + tunnel.private_key_content = None; tunnel.private_key_passphrase = None; } SshAuthMethod::AutoPublicKey => { tunnel.auth_type = "auto_publickey".to_string(); tunnel.password = None; tunnel.private_key_path = None; + tunnel.private_key_content = None; tunnel.private_key_passphrase = None; } } @@ -549,6 +567,7 @@ impl MongoDBParams { tunnel.auth_type = "password".to_string(); tunnel.password = Some(password); tunnel.private_key_path = None; + tunnel.private_key_content = None; tunnel.private_key_passphrase = None; } SshAuthMethod::PrivateKey { @@ -558,18 +577,31 @@ impl MongoDBParams { tunnel.auth_type = "private_key".to_string(); tunnel.password = None; tunnel.private_key_path = Some(key_path); + tunnel.private_key_content = None; + tunnel.private_key_passphrase = passphrase; + } + SshAuthMethod::PrivateKeyContent { + private_key, + passphrase, + } => { + tunnel.auth_type = "private_key_content".to_string(); + tunnel.password = None; + tunnel.private_key_path = None; + tunnel.private_key_content = Some(private_key); tunnel.private_key_passphrase = passphrase; } SshAuthMethod::Agent => { tunnel.auth_type = "agent".to_string(); tunnel.password = None; tunnel.private_key_path = None; + tunnel.private_key_content = None; tunnel.private_key_passphrase = None; } SshAuthMethod::AutoPublicKey => { tunnel.auth_type = "auto_publickey".to_string(); tunnel.password = None; tunnel.private_key_path = None; + tunnel.private_key_content = None; tunnel.private_key_passphrase = None; } } @@ -811,6 +843,21 @@ impl DbConnectionConfig { .insert("ssh_private_key_passphrase".to_string(), passphrase); } } + SshAuthMethod::PrivateKeyContent { + private_key, + passphrase, + } => { + self.extra_params.insert( + "ssh_auth_type".to_string(), + "private_key_content".to_string(), + ); + self.extra_params + .insert("ssh_private_key_content".to_string(), private_key); + if let Some(passphrase) = passphrase { + self.extra_params + .insert("ssh_private_key_passphrase".to_string(), passphrase); + } + } SshAuthMethod::Agent => { self.extra_params .insert("ssh_auth_type".to_string(), "agent".to_string()); @@ -1228,7 +1275,7 @@ impl StoredConnection { } /// 对 params 中的敏感字段进行加密,返回加密后的 params 字符串。 - /// 敏感字段包括:password、passphrase 以及嵌套结构中的同名字段。 + /// 敏感字段包括:password、passphrase、private_key、private_key_content 以及嵌套结构中的同类字段。 pub fn encrypt_params(&self) -> String { encrypt_json_passwords(&self.params) } @@ -1331,6 +1378,36 @@ mod tests { assert_eq!(Some(&"15".to_string()), db.extra_params.get("ssh_timeout")); } + #[test] + fn ssh_connection_round_trips_private_key_content() { + let connection = ssh_connection_with_id( + 42, + SshAuthMethod::PrivateKeyContent { + private_key: "-----BEGIN OPENSSH PRIVATE KEY-----\nfixture\n".to_string(), + passphrase: Some("secret".to_string()), + }, + ); + + let params = connection + .to_ssh_params() + .expect("ssh params should decode"); + + assert!(matches!( + params.auth_method, + SshAuthMethod::PrivateKeyContent { + private_key, + passphrase: Some(passphrase), + } if private_key.contains("OPENSSH PRIVATE KEY") && passphrase == "secret" + )); + } + + #[test] + fn private_key_content_fields_are_sensitive() { + assert!(is_sensitive_field("private_key")); + assert!(is_sensitive_field("private_key_content")); + assert!(is_sensitive_field("ssh_private_key_content")); + } + #[test] fn stored_db_connection_keeps_only_ssh_reference_before_runtime_resolution() { let db = database_config_with_ssh_ref(42); @@ -1513,7 +1590,7 @@ mod tests { } } -/// 递归加密 JSON 中所有名为 password 或 passphrase 的字符串字段 +/// 递归加密 JSON 中所有敏感字符串字段 fn encrypt_json_passwords(json_str: &str) -> String { match serde_json::from_str::(json_str) { Ok(mut value) => { @@ -1524,7 +1601,7 @@ fn encrypt_json_passwords(json_str: &str) -> String { } } -/// 递归解密 JSON 中所有名为 password 或 passphrase 的字符串字段 +/// 递归解密 JSON 中所有敏感字符串字段 fn decrypt_json_passwords(json_str: &str) -> String { match serde_json::from_str::(json_str) { Ok(mut value) => { @@ -1539,8 +1616,12 @@ fn decrypt_json_passwords(json_str: &str) -> String { fn is_sensitive_field(key: &str) -> bool { key == "password" || key == "passphrase" + || key == "private_key" + || key == "private_key_content" || key.ends_with("_password") || key.ends_with("_passphrase") + || key.ends_with("_private_key") + || key.ends_with("_private_key_content") } /// 递归遍历 JSON Value,加密敏感字段 diff --git a/crates/db/locales/db.yml b/crates/db/locales/db.yml index 3668f242df..30eb3d6ed5 100644 --- a/crates/db/locales/db.yml +++ b/crates/db/locales/db.yml @@ -471,6 +471,10 @@ ConnectionForm: en: Private Key zh-CN: 私钥 zh-HK: 私鑰 + ssh_auth_private_key_content: + en: Private Key Content + zh-CN: 私钥内容 + zh-HK: 私鑰內容 ssh_auth_agent: en: SSH Agent zh-CN: SSH Agent @@ -479,6 +483,14 @@ ConnectionForm: en: Private Key Path zh-CN: 私钥路径 zh-HK: 私鑰路徑 + ssh_private_key_content: + en: Private Key Content + zh-CN: 私钥内容 + zh-HK: 私鑰內容 + ssh_private_key_content_placeholder: + en: Paste OpenSSH or PuTTY private key content + zh-CN: 粘贴 OpenSSH 或 PuTTY 私钥内容 + zh-HK: 貼上 OpenSSH 或 PuTTY 私鑰內容 ssh_private_key_passphrase: en: Key Passphrase zh-CN: 私钥口令 diff --git a/crates/db/src/ssh_tunnel.rs b/crates/db/src/ssh_tunnel.rs index 8c085c8054..982c29538c 100644 --- a/crates/db/src/ssh_tunnel.rs +++ b/crates/db/src/ssh_tunnel.rs @@ -11,6 +11,7 @@ const SSH_USERNAME: &str = "ssh_username"; const SSH_AUTH_TYPE: &str = "ssh_auth_type"; const SSH_PASSWORD: &str = "ssh_password"; const SSH_PRIVATE_KEY_PATH: &str = "ssh_private_key_path"; +const SSH_PRIVATE_KEY_CONTENT: &str = "ssh_private_key_content"; const SSH_PRIVATE_KEY_PASSPHRASE: &str = "ssh_private_key_passphrase"; const SSH_TARGET_HOST: &str = "ssh_target_host"; const SSH_TARGET_PORT: &str = "ssh_target_port"; @@ -64,6 +65,7 @@ fn tunnel_config_from_db_config(config: &DbConnectionConfig) -> Option &str { match auth_type.trim().to_ascii_lowercase().as_str() { "private_key" => "private_key", + "private_key_content" | "private_key_material" => "private_key_content", "agent" => "agent", _ => "password", } @@ -1111,7 +1124,11 @@ fn ssh_auth_requires_private_key(auth_type: &str) -> bool { normalized_ssh_auth_type(auth_type) == "private_key" } -const HOST_SSH_FIELD_NAMES: &[&str] = &[ +fn ssh_auth_requires_private_key_content(auth_type: &str) -> bool { + normalized_ssh_auth_type(auth_type) == "private_key_content" +} + +const REQUIRED_HOST_SSH_FIELD_NAMES: &[&str] = &[ "ssh_tunnel_enabled", "ssh_connection_id", "ssh_host", @@ -1143,7 +1160,7 @@ fn has_all_fields(fields: &[FormField], field_names: &[&str]) -> bool { } fn should_use_custom_ssh_tab(db_type: &DatabaseType, fields: &[FormField]) -> bool { - !db_type.is_external() || has_all_fields(fields, HOST_SSH_FIELD_NAMES) + !db_type.is_external() || has_all_fields(fields, REQUIRED_HOST_SSH_FIELD_NAMES) } fn host_ssl_tab_kind(db_type: &DatabaseType, fields: &[FormField]) -> Option { @@ -1191,6 +1208,7 @@ fn missing_ssh_tunnel_required_field( ssh_username: &str, auth_type: &str, ssh_private_key_path: &str, + ssh_private_key_content: &str, ssh_password: &str, ) -> Option<&'static str> { if !enabled { @@ -1209,6 +1227,11 @@ fn missing_ssh_tunnel_required_field( return Some("ssh_private_key_path"); } + if ssh_auth_requires_private_key_content(auth_type) && ssh_private_key_content.trim().is_empty() + { + return Some("ssh_private_key_content"); + } + if ssh_auth_requires_password(auth_type) && ssh_password.trim().is_empty() { return Some("ssh_password"); } @@ -1795,6 +1818,9 @@ impl DbConnectionForm { &self .get_field_value("ssh_private_key_path", cx) .unwrap_or_default(), + &self + .get_field_value("ssh_private_key_content", cx) + .unwrap_or_default(), &self.get_field_value("ssh_password", cx).unwrap_or_default(), ); @@ -2236,17 +2262,20 @@ impl DbConnectionForm { let is_checkbox = field_info.field_type == FormFieldType::Checkbox; let is_file_path = field_info.field_type == FormFieldType::FilePath; let is_password = field_info.field_type == FormFieldType::Password; + let is_textarea = field_info.field_type == FormFieldType::TextArea; let field_name = field_info.name.clone(); field() .label(field_info.label.clone()) .required(field_info.required) - .items_center() + .when(!is_textarea, |field| field.items_center()) + .when(is_textarea, |field| field.items_start()) .label_justify_end() .child( h_flex() .w_full() .gap_2() + .when(is_textarea, |el| el.items_start()) .when(is_select, |el| { if let Some(select_state) = self.field_selects.get(&field_name) { el.child(Select::new(select_state).w_full()) @@ -2702,6 +2731,7 @@ impl DbConnectionForm { .child( h_flex() .w_full() + .flex_wrap() .gap_4() .child( Radio::new("db-ssh-auth-password") @@ -2734,6 +2764,22 @@ impl DbConnectionForm { ); })), ) + .child( + Radio::new("db-ssh-auth-private-key-content") + .label( + t!("ConnectionForm.ssh_auth_private_key_content") + .to_string(), + ) + .checked(ssh_auth_type == "private_key_content") + .on_click(cx.listener(|this, _, window, cx| { + this.set_field_value( + "ssh_auth_type", + "private_key_content", + window, + cx, + ); + })), + ) .child( Radio::new("db-ssh-auth-agent") .label(t!("ConnectionForm.ssh_auth_agent").to_string()) @@ -2756,6 +2802,10 @@ impl DbConnectionForm { form.child(self.render_field_by_name("ssh_private_key_path", cx)) .child(self.render_field_by_name("ssh_private_key_passphrase", cx)) }) + .when(ssh_auth_type == "private_key_content", |form| { + form.child(self.render_field_by_name("ssh_private_key_content", cx)) + .child(self.render_field_by_name("ssh_private_key_passphrase", cx)) + }) }) .child(self.render_field_by_name("ssh_target_host", cx)) .child(self.render_field_by_name("ssh_target_port", cx)) @@ -3041,6 +3091,7 @@ mod tests { "ssh_auth_type", "ssh_password", "ssh_private_key_path", + "ssh_private_key_content", "ssh_private_key_passphrase", "ssh_target_host", "ssh_target_port" @@ -3048,6 +3099,42 @@ mod tests { ); } + #[test] + fn private_key_content_auth_requires_pasted_key_body() { + assert_eq!( + Some("ssh_private_key_content"), + missing_ssh_tunnel_required_field( + true, + "jump.example.com", + "root", + "private_key_content", + "", + "", + "", + ) + ); + assert_eq!( + None, + missing_ssh_tunnel_required_field( + true, + "jump.example.com", + "root", + "private_key_content", + "", + "-----BEGIN OPENSSH PRIVATE KEY-----", + "", + ) + ); + } + + #[test] + fn private_key_material_alias_uses_private_key_content_auth() { + assert_eq!( + "private_key_content", + normalized_ssh_auth_type("private_key_material") + ); + } + #[test] fn custom_ssl_enabled_matches_database_semantics() { assert!(is_custom_ssl_enabled( @@ -3121,7 +3208,15 @@ mod tests { #[test] fn ssh_agent_auth_does_not_require_password() { assert_eq!( - missing_ssh_tunnel_required_field(true, "jump.example.com", "root", "agent", "", "",), + missing_ssh_tunnel_required_field( + true, + "jump.example.com", + "root", + "agent", + "", + "", + "" + ), None ); } @@ -3129,7 +3224,15 @@ mod tests { #[test] fn ssh_password_auth_still_requires_password() { assert_eq!( - missing_ssh_tunnel_required_field(true, "jump.example.com", "root", "password", "", "",), + missing_ssh_tunnel_required_field( + true, + "jump.example.com", + "root", + "password", + "", + "", + "", + ), Some("ssh_password") ); } diff --git a/crates/db_view/src/database_view_plugin.rs b/crates/db_view/src/database_view_plugin.rs index f599858710..03caa8a2ae 100644 --- a/crates/db_view/src/database_view_plugin.rs +++ b/crates/db_view/src/database_view_plugin.rs @@ -1667,6 +1667,7 @@ driver: "ssh_auth_type", "ssh_password", "ssh_private_key_path", + "ssh_private_key_content", "ssh_private_key_passphrase", "ssh_target_host", "ssh_target_port" @@ -1716,6 +1717,7 @@ driver: "ssh_auth_type", "ssh_password", "ssh_private_key_path", + "ssh_private_key_content", "ssh_private_key_passphrase", "ssh_target_host", "ssh_target_port" diff --git a/crates/mongodb_view/locales/mongodb_view.yml b/crates/mongodb_view/locales/mongodb_view.yml index 56b6c13e12..0a8e48fc19 100644 --- a/crates/mongodb_view/locales/mongodb_view.yml +++ b/crates/mongodb_view/locales/mongodb_view.yml @@ -957,6 +957,10 @@ ConnectionForm: en: Private Key zh-CN: 私钥 zh-HK: 私鑰 + ssh_auth_private_key_content: + en: Private Key Content + zh-CN: 私钥内容 + zh-HK: 私鑰內容 ssh_auth_agent: en: SSH Agent zh-CN: SSH Agent @@ -965,6 +969,14 @@ ConnectionForm: en: Private Key Path zh-CN: 私钥路径 zh-HK: 私鑰路徑 + ssh_private_key_content: + en: Private Key Content + zh-CN: 私钥内容 + zh-HK: 私鑰內容 + ssh_private_key_content_placeholder: + en: Paste OpenSSH or PuTTY private key content + zh-CN: 粘贴 OpenSSH 或 PuTTY 私钥内容 + zh-HK: 貼上 OpenSSH 或 PuTTY 私鑰內容 ssh_private_key_passphrase: en: Key Passphrase zh-CN: 私钥口令 @@ -984,4 +996,4 @@ ConnectionForm: ssh_missing_required: en: "Missing required field: %{field}" zh-CN: "缺少必填字段:%{field}" - zh-HK: "缺少必填欄位:%{field}" \ No newline at end of file + zh-HK: "缺少必填欄位:%{field}" diff --git a/crates/mongodb_view/src/mongo_form_window.rs b/crates/mongodb_view/src/mongo_form_window.rs index ab1848c42d..60c8f7e948 100644 --- a/crates/mongodb_view/src/mongo_form_window.rs +++ b/crates/mongodb_view/src/mongo_form_window.rs @@ -211,6 +211,7 @@ pub struct MongoFormWindow { ssh_auth_type: String, ssh_password_input: Entity, ssh_private_key_path_input: Entity, + ssh_private_key_content_input: Entity, ssh_private_key_passphrase_input: Entity, ssh_target_host_input: Entity, ssh_target_port_input: Entity, @@ -517,6 +518,17 @@ impl MongoFormWindow { state }); + let ssh_private_key_content_input = cx.new(|cx| { + let mut state = InputState::new(window, cx) + .placeholder(t!("ConnectionForm.ssh_private_key_content_placeholder")) + .auto_grow(5, 14); + if let Some(private_key) = existing_ssh.and_then(|ssh| ssh.private_key_content.as_ref()) + { + state.set_value(private_key.clone(), window, cx); + } + state + }); + let ssh_private_key_passphrase_input = cx.new(|cx| { let mut state = InputState::new(window, cx) .placeholder("Passphrase") @@ -586,6 +598,7 @@ impl MongoFormWindow { .unwrap_or_else(|| "password".to_string()), ssh_password_input, ssh_private_key_path_input, + ssh_private_key_content_input, ssh_private_key_passphrase_input, ssh_target_host_input, ssh_target_port_input, @@ -691,6 +704,10 @@ impl MongoFormWindow { auth_type: self.ssh_auth_type.clone(), password: Self::optional_input_value(&self.ssh_password_input, cx), private_key_path: Self::optional_input_value(&self.ssh_private_key_path_input, cx), + private_key_content: Self::optional_input_value( + &self.ssh_private_key_content_input, + cx, + ), private_key_passphrase: Self::optional_input_value( &self.ssh_private_key_passphrase_input, cx, @@ -1120,7 +1137,10 @@ impl MongoFormWindow { .read(cx) .selected_value() .is_some_and(|value| value.is_some()); - let auth_type = self.ssh_auth_type.as_str(); + let auth_type = match self.ssh_auth_type.as_str() { + "private_key_material" => "private_key_content", + value => value, + }; v_flex() .gap_2() @@ -1157,6 +1177,7 @@ impl MongoFormWindow { self.render_form_row( &t!("ConnectionForm.ssh_auth_type"), h_flex() + .flex_wrap() .gap_4() .child( Radio::new("mongo-ssh-auth-password") @@ -1178,6 +1199,18 @@ impl MongoFormWindow { cx.notify(); })), ) + .child( + Radio::new("mongo-ssh-auth-private-key-content") + .label( + t!("ConnectionForm.ssh_auth_private_key_content") + .to_string(), + ) + .checked(auth_type == "private_key_content") + .on_click(cx.listener(|this, _, _, cx| { + this.ssh_auth_type = "private_key_content".to_string(); + cx.notify(); + })), + ) .child( Radio::new("mongo-ssh-auth-agent") .label(t!("ConnectionForm.ssh_auth_agent").to_string()) @@ -1205,6 +1238,16 @@ impl MongoFormWindow { Input::new(&self.ssh_private_key_passphrase_input).mask_toggle(), )) }) + .when(auth_type == "private_key_content", |this| { + this.child(self.render_form_row( + &t!("ConnectionForm.ssh_private_key_content"), + Input::new(&self.ssh_private_key_content_input), + )) + .child(self.render_form_row( + &t!("ConnectionForm.ssh_private_key_passphrase"), + Input::new(&self.ssh_private_key_passphrase_input).mask_toggle(), + )) + }) }) .child(self.render_form_row( &t!("ConnectionForm.ssh_target_host"), diff --git a/crates/onetcli_runtime/src/sftp_tools.rs b/crates/onetcli_runtime/src/sftp_tools.rs index 2327311575..8d2ccb520d 100644 --- a/crates/onetcli_runtime/src/sftp_tools.rs +++ b/crates/onetcli_runtime/src/sftp_tools.rs @@ -268,6 +268,14 @@ fn auth_from_method(auth: &SshAuthMethod) -> SshAuth { passphrase: passphrase.clone(), certificate_path: None, }, + SshAuthMethod::PrivateKeyContent { + private_key, + passphrase, + } => SshAuth::PrivateKeyContent { + private_key: private_key.clone(), + passphrase: passphrase.clone(), + certificate_path: None, + }, SshAuthMethod::Agent => SshAuth::Agent, SshAuthMethod::AutoPublicKey => SshAuth::AutoPublicKey, } diff --git a/crates/port_forwarding/src/runtime.rs b/crates/port_forwarding/src/runtime.rs index e6eced4eec..06214ab494 100644 --- a/crates/port_forwarding/src/runtime.rs +++ b/crates/port_forwarding/src/runtime.rs @@ -197,6 +197,14 @@ fn build_ssh_auth(auth_method: &SshAuthMethod) -> SshAuth { passphrase: passphrase.clone(), certificate_path: None, }, + SshAuthMethod::PrivateKeyContent { + private_key, + passphrase, + } => SshAuth::PrivateKeyContent { + private_key: private_key.clone(), + passphrase: passphrase.clone(), + certificate_path: None, + }, SshAuthMethod::Agent => SshAuth::Agent, SshAuthMethod::AutoPublicKey => SshAuth::AutoPublicKey, } diff --git a/crates/redis_view/locales/redis_view.yml b/crates/redis_view/locales/redis_view.yml index 0b2793345e..b1ca8156a6 100644 --- a/crates/redis_view/locales/redis_view.yml +++ b/crates/redis_view/locales/redis_view.yml @@ -259,6 +259,10 @@ ConnectionForm: en: Private Key zh-CN: 私钥 zh-HK: 私鑰 + ssh_auth_private_key_content: + en: Private Key Content + zh-CN: 私钥内容 + zh-HK: 私鑰內容 ssh_auth_agent: en: SSH Agent zh-CN: SSH Agent @@ -267,6 +271,14 @@ ConnectionForm: en: Private Key Path zh-CN: 私钥路径 zh-HK: 私鑰路徑 + ssh_private_key_content: + en: Private Key Content + zh-CN: 私钥内容 + zh-HK: 私鑰內容 + ssh_private_key_content_placeholder: + en: Paste OpenSSH or PuTTY private key content + zh-CN: 粘贴 OpenSSH 或 PuTTY 私钥内容 + zh-HK: 貼上 OpenSSH 或 PuTTY 私鑰內容 ssh_private_key_passphrase: en: Key Passphrase zh-CN: 私钥口令 diff --git a/crates/redis_view/src/connection.rs b/crates/redis_view/src/connection.rs index 5063e319ba..078dea0e34 100644 --- a/crates/redis_view/src/connection.rs +++ b/crates/redis_view/src/connection.rs @@ -65,6 +65,23 @@ fn build_ssh_auth( certificate_path: None, }) } + "private_key_content" | "private_key_material" => { + let private_key = tunnel_config + .private_key_content + .as_deref() + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + RedisError::connection( + "ssh tunnel enabled but `ssh_private_key_content` is missing", + ) + })?; + Ok(SshAuth::PrivateKeyContent { + private_key: private_key.to_string(), + passphrase: tunnel_config.private_key_passphrase.clone(), + certificate_path: None, + }) + } _ => { let password = tunnel_config .password diff --git a/crates/redis_view/src/redis_form_window.rs b/crates/redis_view/src/redis_form_window.rs index afeb7db485..41376dc7f6 100644 --- a/crates/redis_view/src/redis_form_window.rs +++ b/crates/redis_view/src/redis_form_window.rs @@ -250,6 +250,7 @@ pub struct RedisFormWindow { ssh_auth_type: String, ssh_password_input: Entity, ssh_private_key_path_input: Entity, + ssh_private_key_content_input: Entity, ssh_private_key_passphrase_input: Entity, ssh_target_host_input: Entity, ssh_target_port_input: Entity, @@ -484,6 +485,17 @@ impl RedisFormWindow { state }); + let ssh_private_key_content_input = cx.new(|cx| { + let mut state = InputState::new(window, cx) + .placeholder(t!("ConnectionForm.ssh_private_key_content_placeholder")) + .auto_grow(5, 14); + if let Some(private_key) = existing_ssh.and_then(|ssh| ssh.private_key_content.as_ref()) + { + state.set_value(private_key.clone(), window, cx); + } + state + }); + let ssh_private_key_passphrase_input = cx.new(|cx| { let mut state = InputState::new(window, cx) .placeholder(t!("ConnectionForm.ssh_private_key_passphrase")) @@ -613,6 +625,7 @@ impl RedisFormWindow { .unwrap_or_else(|| "password".to_string()), ssh_password_input, ssh_private_key_path_input, + ssh_private_key_content_input, ssh_private_key_passphrase_input, ssh_target_host_input, ssh_target_port_input, @@ -720,6 +733,10 @@ impl RedisFormWindow { auth_type: self.ssh_auth_type.clone(), password: Self::optional_input_value(&self.ssh_password_input, cx), private_key_path: Self::optional_input_value(&self.ssh_private_key_path_input, cx), + private_key_content: Self::optional_input_value( + &self.ssh_private_key_content_input, + cx, + ), private_key_passphrase: Self::optional_input_value( &self.ssh_private_key_passphrase_input, cx, @@ -1146,7 +1163,10 @@ impl RedisFormWindow { .read(cx) .selected_value() .is_some_and(|value| value.is_some()); - let auth_type = self.ssh_auth_type.as_str(); + let auth_type = match self.ssh_auth_type.as_str() { + "private_key_material" => "private_key_content", + value => value, + }; v_flex() .gap_2() @@ -1183,6 +1203,7 @@ impl RedisFormWindow { self.render_form_row( &t!("ConnectionForm.ssh_auth_type"), h_flex() + .flex_wrap() .gap_4() .child( Radio::new("redis-ssh-auth-password") @@ -1204,6 +1225,18 @@ impl RedisFormWindow { cx.notify(); })), ) + .child( + Radio::new("redis-ssh-auth-private-key-content") + .label( + t!("ConnectionForm.ssh_auth_private_key_content") + .to_string(), + ) + .checked(auth_type == "private_key_content") + .on_click(cx.listener(|this, _, _, cx| { + this.ssh_auth_type = "private_key_content".to_string(); + cx.notify(); + })), + ) .child( Radio::new("redis-ssh-auth-agent") .label(t!("ConnectionForm.ssh_auth_agent").to_string()) @@ -1231,6 +1264,16 @@ impl RedisFormWindow { Input::new(&self.ssh_private_key_passphrase_input).mask_toggle(), )) }) + .when(auth_type == "private_key_content", |this| { + this.child(self.render_form_row( + &t!("ConnectionForm.ssh_private_key_content"), + Input::new(&self.ssh_private_key_content_input), + )) + .child(self.render_form_row( + &t!("ConnectionForm.ssh_private_key_passphrase"), + Input::new(&self.ssh_private_key_passphrase_input).mask_toggle(), + )) + }) }) .child(self.render_form_row( &t!("ConnectionForm.ssh_target_host"), diff --git a/crates/sftp_view/src/lib.rs b/crates/sftp_view/src/lib.rs index a7ef976cc5..a4c3d1db98 100644 --- a/crates/sftp_view/src/lib.rs +++ b/crates/sftp_view/src/lib.rs @@ -729,6 +729,14 @@ impl SftpView { passphrase, certificate_path: None, }, + SshAuthMethod::PrivateKeyContent { + private_key, + passphrase, + } => SshAuth::PrivateKeyContent { + private_key, + passphrase, + certificate_path: None, + }, SshAuthMethod::Agent => SshAuth::Agent, SshAuthMethod::AutoPublicKey => SshAuth::AutoPublicKey, }; @@ -752,6 +760,14 @@ impl SftpView { passphrase, certificate_path: None, }, + SshAuthMethod::PrivateKeyContent { + private_key, + passphrase, + } => SshAuth::PrivateKeyContent { + private_key, + passphrase, + certificate_path: None, + }, SshAuthMethod::Agent => SshAuth::Agent, SshAuthMethod::AutoPublicKey => SshAuth::AutoPublicKey, }; diff --git a/crates/ssh/src/ssh.rs b/crates/ssh/src/ssh.rs index b8192062f1..3fb81e2a4a 100644 --- a/crates/ssh/src/ssh.rs +++ b/crates/ssh/src/ssh.rs @@ -86,6 +86,11 @@ pub enum SshAuth { passphrase: Option, certificate_path: Option, }, + PrivateKeyContent { + private_key: String, + passphrase: Option, + certificate_path: Option, + }, Agent, AutoPublicKey, } @@ -314,11 +319,12 @@ where .await?; } SshAuth::PrivateKey { - key_path, - passphrase, - certificate_path, + certificate_path, .. + } + | SshAuth::PrivateKeyContent { + certificate_path, .. } => { - let key_pair = load_secret_key(key_path, passphrase.as_deref())?; + let key_pair = private_key_for_auth(auth)?; if let Some(cert_path) = certificate_path { let cert = load_openssh_certificate(cert_path)?; @@ -360,6 +366,22 @@ where Ok(()) } +fn private_key_for_auth(auth: &SshAuth) -> Result { + match auth { + SshAuth::PrivateKey { + key_path, + passphrase, + .. + } => Ok(load_secret_key(key_path, passphrase.as_deref())?), + SshAuth::PrivateKeyContent { + private_key, + passphrase, + .. + } => Ok(decode_secret_key(private_key, passphrase.as_deref())?), + _ => anyhow::bail!("authentication method does not contain a private key"), + } +} + async fn finish_auth_result_or_keyboard_interactive( session: &mut client::Handle, username: &str, @@ -513,9 +535,12 @@ where anyhow::bail!(messages.no_local_identity.clone()); } - let has_default_keys = filtered_candidates - .iter() - .any(|auth| matches!(auth, SshAuth::PrivateKey { .. })); + let has_default_keys = filtered_candidates.iter().any(|auth| { + matches!( + auth, + SshAuth::PrivateKey { .. } | SshAuth::PrivateKeyContent { .. } + ) + }); let mut errors = Vec::new(); for auth in filtered_candidates { @@ -880,6 +905,22 @@ mod tests { ))); } + #[test] + fn private_key_content_auth_is_decoded_from_memory() { + let error = private_key_for_auth(&SshAuth::PrivateKeyContent { + private_key: "not a private key".to_string(), + passphrase: None, + certificate_path: None, + }) + .expect_err("invalid inline private key should fail to decode"); + let message = error.to_string(); + + assert!( + !message.contains("No such file") && !message.contains("os error 2"), + "inline private key content should not be treated as a file path: {message}" + ); + } + #[test] fn build_auto_publickey_failure_message_mentions_missing_identity() { let messages = test_auth_failure_messages(); diff --git a/crates/terminal/src/terminal.rs b/crates/terminal/src/terminal.rs index 5f5ec393bc..705b357abd 100644 --- a/crates/terminal/src/terminal.rs +++ b/crates/terminal/src/terminal.rs @@ -1115,6 +1115,14 @@ impl Terminal { passphrase, certificate_path: None, }, + SshAuthMethod::PrivateKeyContent { + private_key, + passphrase, + } => SshAuth::PrivateKeyContent { + private_key, + passphrase, + certificate_path: None, + }, SshAuthMethod::Agent => SshAuth::Agent, SshAuthMethod::AutoPublicKey => SshAuth::AutoPublicKey, }; @@ -1146,6 +1154,14 @@ impl Terminal { passphrase, certificate_path: None, }, + SshAuthMethod::PrivateKeyContent { + private_key, + passphrase, + } => SshAuth::PrivateKeyContent { + private_key, + passphrase, + certificate_path: None, + }, SshAuthMethod::Agent => SshAuth::Agent, SshAuthMethod::AutoPublicKey => SshAuth::AutoPublicKey, }; diff --git a/crates/terminal_view/locales/terminal_view.yml b/crates/terminal_view/locales/terminal_view.yml index 276a5c8c41..9ddb9c6cfc 100644 --- a/crates/terminal_view/locales/terminal_view.yml +++ b/crates/terminal_view/locales/terminal_view.yml @@ -134,9 +134,13 @@ SSH: zh-CN: 请输入密码 zh-HK: 請輸入密碼 private_key: - en: Private Key - zh-CN: 私钥 - zh-HK: 私鑰 + en: Private Key File + zh-CN: 私钥文件 + zh-HK: 私鑰文件 + private_key_content: + en: Private Key Content + zh-CN: 私钥内容 + zh-HK: 私鑰內容 agent: en: SSH Agent zh-CN: SSH Agent @@ -153,6 +157,14 @@ SSH: en: Enter private key path zh-CN: 请输入私钥路径 zh-HK: 請輸入私鑰路徑 + private_key_content_placeholder: + en: Paste the full OpenSSH or PPK private key content + zh-CN: 粘贴完整的 OpenSSH 或 PPK 私钥内容 + zh-HK: 貼上完整的 OpenSSH 或 PPK 私鑰內容 + private_key_content_sync_hint: + en: The private key content is saved with this connection and can be encrypted and synced across devices. + zh-CN: 私钥内容会随此连接保存,可加密后跨设备同步。 + zh-HK: 私鑰內容會隨此連線保存,可加密後跨設備同步。 passphrase: en: Passphrase zh-CN: 密钥密码 diff --git a/crates/terminal_view/src/ssh_form_window.rs b/crates/terminal_view/src/ssh_form_window.rs index 56d11bf6ce..e48753251b 100644 --- a/crates/terminal_view/src/ssh_form_window.rs +++ b/crates/terminal_view/src/ssh_form_window.rs @@ -185,6 +185,7 @@ pub struct SshFormWindow { username_input: Entity, password_input: Entity, key_path_input: Entity, + private_key_content_input: Entity, passphrase_input: Entity, auth_method: AuthMethodSelection, @@ -199,6 +200,7 @@ pub struct SshFormWindow { jump_username_input: Entity, jump_password_input: Entity, jump_key_path_input: Entity, + jump_private_key_content_input: Entity, jump_passphrase_input: Entity, jump_mfa_request: Option, jump_mfa_inputs: Vec, @@ -251,12 +253,18 @@ pub enum AuthMethodSelection { #[default] Password, PrivateKey, + PrivateKeyContent, Agent, AutoPublicKey, } fn build_connection_test_signature(params: &SshParams) -> String { - format!("{:?}", params) + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash as _, Hasher as _}; + + let mut hasher = DefaultHasher::new(); + format!("{:?}", params).hash(&mut hasher); + format!("{:016x}", hasher.finish()) } fn format_connection_error(error: &anyhow::Error) -> String { @@ -290,6 +298,7 @@ fn build_jump_auth_method( auth_method: AuthMethodSelection, password: String, key_path: String, + private_key: String, passphrase: String, ) -> SshAuthMethod { match auth_method { @@ -302,6 +311,14 @@ fn build_jump_auth_method( Some(passphrase) }, }, + AuthMethodSelection::PrivateKeyContent => SshAuthMethod::PrivateKeyContent { + private_key, + passphrase: if passphrase.is_empty() { + None + } else { + Some(passphrase) + }, + }, AuthMethodSelection::Agent => SshAuthMethod::Agent, AuthMethodSelection::AutoPublicKey => SshAuthMethod::AutoPublicKey, } @@ -351,6 +368,11 @@ impl SshFormWindow { }); let key_path_input = cx.new(|cx| InputState::new(window, cx).placeholder(t!("SSH.key_path_placeholder"))); + let private_key_content_input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder(t!("SSH.private_key_content_placeholder")) + .auto_grow(6, 12) + }); let passphrase_input = cx.new(|cx| { InputState::new(window, cx) .placeholder(t!("SSH.passphrase_placeholder")) @@ -374,6 +396,11 @@ impl SshFormWindow { }); let jump_key_path_input = cx.new(|cx| InputState::new(window, cx).placeholder(t!("SSH.key_path_placeholder"))); + let jump_private_key_content_input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder(t!("SSH.private_key_content_placeholder")) + .auto_grow(6, 12) + }); let jump_passphrase_input = cx.new(|cx| { InputState::new(window, cx) .placeholder(t!("SSH.passphrase_placeholder")) @@ -481,6 +508,17 @@ impl SshFormWindow { passphrase_input.update(cx, |s, cx| s.set_value(pass, window, cx)); } } + SshAuthMethod::PrivateKeyContent { + ref private_key, + ref passphrase, + } => { + auth_method = AuthMethodSelection::PrivateKeyContent; + private_key_content_input + .update(cx, |s, cx| s.set_value(private_key, window, cx)); + if let Some(ref pass) = passphrase { + passphrase_input.update(cx, |s, cx| s.set_value(pass, window, cx)); + } + } SshAuthMethod::Agent => { auth_method = AuthMethodSelection::Agent; } @@ -537,6 +575,18 @@ impl SshFormWindow { .update(cx, |s, cx| s.set_value(pass, window, cx)); } } + SshAuthMethod::PrivateKeyContent { + ref private_key, + ref passphrase, + } => { + jump_auth_method = AuthMethodSelection::PrivateKeyContent; + jump_private_key_content_input + .update(cx, |s, cx| s.set_value(private_key, window, cx)); + if let Some(ref pass) = passphrase { + jump_passphrase_input + .update(cx, |s, cx| s.set_value(pass, window, cx)); + } + } SshAuthMethod::Agent => { jump_auth_method = AuthMethodSelection::Agent; } @@ -599,6 +649,7 @@ impl SshFormWindow { username_input, password_input, key_path_input, + private_key_content_input, passphrase_input, auth_method, workspace_select, @@ -610,6 +661,7 @@ impl SshFormWindow { jump_username_input, jump_password_input, jump_key_path_input, + jump_private_key_content_input, jump_passphrase_input, jump_mfa_request: None, jump_mfa_inputs: Vec::new(), @@ -704,6 +756,17 @@ impl SshFormWindow { passphrase, } } + AuthMethodSelection::PrivateKeyContent => { + let private_key = self.private_key_content_input.read(cx).text().to_string(); + let passphrase = { + let p = self.passphrase_input.read(cx).text().to_string(); + if p.is_empty() { None } else { Some(p) } + }; + SshAuthMethod::PrivateKeyContent { + private_key, + passphrase, + } + } AuthMethodSelection::Agent => SshAuthMethod::Agent, AuthMethodSelection::AutoPublicKey => SshAuthMethod::AutoPublicKey, }; @@ -755,6 +818,11 @@ impl SshFormWindow { .unwrap_or(22); let jump_password = self.jump_password_input.read(cx).text().to_string(); let jump_key_path = self.jump_key_path_input.read(cx).text().to_string(); + let jump_private_key = self + .jump_private_key_content_input + .read(cx) + .text() + .to_string(); let jump_passphrase = self.jump_passphrase_input.read(cx).text().to_string(); Some(JumpServerConfig { host: jump_host, @@ -764,6 +832,7 @@ impl SshFormWindow { self.jump_auth_method, jump_password, jump_key_path, + jump_private_key, jump_passphrase, ), }) @@ -842,6 +911,14 @@ impl SshFormWindow { passphrase: passphrase.clone(), certificate_path: None, }, + SshAuthMethod::PrivateKeyContent { + private_key, + passphrase, + } => SshAuth::PrivateKeyContent { + private_key: private_key.clone(), + passphrase: passphrase.clone(), + certificate_path: None, + }, SshAuthMethod::Agent => SshAuth::Agent, SshAuthMethod::AutoPublicKey => SshAuth::AutoPublicKey, }; @@ -858,6 +935,14 @@ impl SshFormWindow { passphrase: passphrase.clone(), certificate_path: None, }, + SshAuthMethod::PrivateKeyContent { + private_key, + passphrase, + } => SshAuth::PrivateKeyContent { + private_key: private_key.clone(), + passphrase: passphrase.clone(), + certificate_path: None, + }, SshAuthMethod::Agent => SshAuth::Agent, SshAuthMethod::AutoPublicKey => SshAuth::AutoPublicKey, }; @@ -1225,6 +1310,7 @@ impl SshFormWindow { &t!("SSH.auth_method"), h_flex() .gap_4() + .flex_wrap() .child( Radio::new("password") .label(t!("SSH.password").to_string()) @@ -1243,6 +1329,15 @@ impl SshFormWindow { cx.notify(); })), ) + .child( + Radio::new("private-key-content") + .label(t!("SSH.private_key_content").to_string()) + .checked(auth_method == AuthMethodSelection::PrivateKeyContent) + .on_click(cx.listener(|this, _, _, cx| { + this.auth_method = AuthMethodSelection::PrivateKeyContent; + cx.notify(); + })), + ) .child( Radio::new("agent") .label(t!("SSH.agent").to_string()) @@ -1278,6 +1373,27 @@ impl SshFormWindow { Input::new(&self.passphrase_input).mask_toggle(), )) }) + .when( + auth_method == AuthMethodSelection::PrivateKeyContent, + |this| { + this.child(self.render_form_row( + &t!("SSH.private_key_content"), + Input::new(&self.private_key_content_input), + )) + .child(self.render_form_row( + &t!("SSH.passphrase"), + Input::new(&self.passphrase_input).mask_toggle(), + )) + .child( + h_flex().justify_center().child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(t!("SSH.private_key_content_sync_hint").to_string()), + ), + ) + }, + ) .when(auth_method == AuthMethodSelection::AutoPublicKey, |this| { this.child( h_flex().justify_center().child( @@ -1436,6 +1552,7 @@ impl SshFormWindow { &t!("SSH.jump_auth_method"), h_flex() .gap_4() + .flex_wrap() .child( Radio::new("jump-password") .label(t!("SSH.password").to_string()) @@ -1454,6 +1571,18 @@ impl SshFormWindow { cx.notify(); })), ) + .child( + Radio::new("jump-private-key-content") + .label(t!("SSH.private_key_content").to_string()) + .checked( + jump_auth_method == AuthMethodSelection::PrivateKeyContent, + ) + .on_click(cx.listener(|this, _, _, cx| { + this.jump_auth_method = + AuthMethodSelection::PrivateKeyContent; + cx.notify(); + })), + ) .child( Radio::new("jump-agent") .label(t!("SSH.agent").to_string()) @@ -1493,6 +1622,27 @@ impl SshFormWindow { )) }, ) + .when( + jump_auth_method == AuthMethodSelection::PrivateKeyContent, + |this| { + this.child(self.render_form_row( + &t!("SSH.private_key_content"), + Input::new(&self.jump_private_key_content_input), + )) + .child(self.render_form_row( + &t!("SSH.jump_passphrase"), + Input::new(&self.jump_passphrase_input).mask_toggle(), + )) + .child( + h_flex().justify_center().child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(t!("SSH.private_key_content_sync_hint").to_string()), + ), + ) + }, + ) .when( jump_auth_method == AuthMethodSelection::AutoPublicKey, |this| { @@ -1873,6 +2023,19 @@ mod tests { assert_ne!(original, build_connection_test_signature(&changed_host)); } + #[test] + fn connection_test_signature_does_not_expose_private_key_content() { + let mut params = sample_params(); + params.auth_method = SshAuthMethod::PrivateKeyContent { + private_key: "-----BEGIN OPENSSH PRIVATE KEY-----\nsecret\n".to_string(), + passphrase: Some("secret-passphrase".to_string()), + }; + let signature = build_connection_test_signature(¶ms); + + assert!(!signature.contains("OPENSSH PRIVATE KEY")); + assert!(!signature.contains("secret-passphrase")); + } + #[test] fn connection_test_error_keeps_context_chain() { let error = Err::<(), _>(std::io::Error::new( @@ -1911,6 +2074,7 @@ mod tests { AuthMethodSelection::PrivateKey, "ignored".to_string(), "/home/me/.ssh/bastion".to_string(), + "ignored-key".to_string(), "secret".to_string(), ); @@ -1929,6 +2093,7 @@ mod tests { AuthMethodSelection::PrivateKey, "ignored".to_string(), "/home/me/.ssh/bastion".to_string(), + "ignored-key".to_string(), String::new(), ); @@ -1940,4 +2105,23 @@ mod tests { } if key_path == "/home/me/.ssh/bastion" )); } + + #[test] + fn jump_auth_builder_supports_private_key_content() { + let auth = build_jump_auth_method( + AuthMethodSelection::PrivateKeyContent, + "ignored".to_string(), + "ignored-path".to_string(), + "-----BEGIN OPENSSH PRIVATE KEY-----\nfixture\n".to_string(), + "secret".to_string(), + ); + + assert!(matches!( + auth, + SshAuthMethod::PrivateKeyContent { + private_key, + passphrase: Some(passphrase), + } if private_key.contains("OPENSSH PRIVATE KEY") && passphrase == "secret" + )); + } } diff --git a/main/src/home/connection_import_draft_conversion.rs b/main/src/home/connection_import_draft_conversion.rs index 8a303003ec..82e426e861 100644 --- a/main/src/home/connection_import_draft_conversion.rs +++ b/main/src/home/connection_import_draft_conversion.rs @@ -143,13 +143,18 @@ fn edited_ssh_auth_method( key_path: draft.private_key_path.trim().to_string(), passphrase: passphrase.clone(), }), - SshImportAuthMethod::PrivateKeyMaterial { passphrase, .. } => { - let key_path = draft.private_key_path.trim(); - if key_path.is_empty() { - return Err("私钥内容导入需要先编辑为私钥路径".to_string()); - } - Ok(SshAuthMethod::PrivateKey { - key_path: key_path.to_string(), + SshImportAuthMethod::PrivateKeyMaterial { + private_key, + passphrase, + .. + } => { + let private_key = private_key + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "SSH 导入记录缺少私钥内容".to_string())?; + Ok(SshAuthMethod::PrivateKeyContent { + private_key: private_key.to_string(), passphrase: passphrase.clone(), }) } diff --git a/main/src/home/connection_import_draft_tests.rs b/main/src/home/connection_import_draft_tests.rs index 251f14c8ff..22bf72bdcc 100644 --- a/main/src/home/connection_import_draft_tests.rs +++ b/main/src/home/connection_import_draft_tests.rs @@ -177,6 +177,29 @@ fn edited_ssh_private_key_path_is_converted_to_stored_connection() { )); } +#[test] +fn imported_ssh_private_key_material_is_converted_to_stored_connection() { + let mut record = ssh_import("inline-key"); + let ssh = record.ssh.as_mut().expect("ssh record should exist"); + ssh.auth_method = SshImportAuthMethod::PrivateKeyMaterial { + private_key: Some("-----BEGIN OPENSSH PRIVATE KEY-----\nfixture\n".to_string()), + passphrase: Some("secret".to_string()), + file_name_hint: Some("id_ed25519".to_string()), + }; + let draft = EditableImportDraft::new(record); + + let stored = selected_import_drafts_to_connections(&[draft]).unwrap(); + let params = stored[0].to_ssh_params().unwrap(); + + assert!(matches!( + params.auth_method, + SshAuthMethod::PrivateKeyContent { + ref private_key, + passphrase: Some(ref passphrase), + } if private_key.contains("OPENSSH PRIVATE KEY") && passphrase == "secret" + )); +} + #[test] fn database_duplicate_identity_uses_type_host_port_username_and_database() { let draft = EditableImportDraft::new(database_import("prod")); From c6f1fa6b5482d20a673ad431376c580b72f89bfa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=83=A1=E9=A3=9E?= <1835698775@qq.com> Date: Tue, 7 Jul 2026 12:43:36 +0800 Subject: [PATCH 12/12] fix: default unnamed connections to target address --- crates/core/src/storage/models.rs | 211 ++++++++++++++++++ crates/db/src/mysql/plugin.rs | 21 +- .../db_view/src/common/db_connection_form.rs | 29 ++- crates/mongodb_view/src/mongo_form_window.rs | 5 - 4 files changed, 256 insertions(+), 10 deletions(-) diff --git a/crates/core/src/storage/models.rs b/crates/core/src/storage/models.rs index 223ef5bf26..b69a42ef02 100644 --- a/crates/core/src/storage/models.rs +++ b/crates/core/src/storage/models.rs @@ -1059,12 +1059,87 @@ impl SyncableItem for StoredConnection { } } +fn trimmed_or_default(name: String, default_name: String) -> String { + if name.trim().is_empty() { + default_name + } else { + name + } +} + +fn host_port_name(host: &str, port: u16) -> String { + let host = host.trim(); + if host.is_empty() { + port.to_string() + } else { + format!("{host}:{port}") + } +} + +fn optional_host_port_name(host: &str, port: Option) -> String { + match port { + Some(port) => host_port_name(host, port), + None => host.trim().to_string(), + } +} + +fn default_database_name(name: String, params: &DbConnectionConfig) -> String { + trimmed_or_default(name, params.server_info()) +} + +fn default_ssh_name(name: String, params: &SshParams) -> String { + let username = params.username.trim(); + let destination = host_port_name(¶ms.host, params.port); + let default_name = if username.is_empty() { + destination + } else { + format!("{username}@{destination}") + }; + trimmed_or_default(name, default_name) +} + +fn default_remote_desktop_name(name: String, params: &RemoteDesktopParams) -> String { + trimmed_or_default(name, host_port_name(¶ms.host, params.port)) +} + +fn default_redis_name(name: String, params: &RedisParams) -> String { + trimmed_or_default(name, host_port_name(¶ms.host, params.port)) +} + +fn default_mongodb_name(name: String, params: &MongoDBParams) -> String { + let default_name = optional_host_port_name(¶ms.host, params.port); + let default_name = if default_name.is_empty() { + params.connection_string.trim().to_string() + } else { + default_name + }; + trimmed_or_default(name, default_name) +} + +fn default_serial_name(name: String, params: &SerialParams) -> String { + trimmed_or_default(name, params.port_name.trim().to_string()) +} + +fn default_port_forwarding_name(name: String, params: &PortForwardingParams) -> String { + let default_name = match params.kind { + PortForwardingKind::Local => format!( + "{}:{} -> {}:{}", + params.bind_host, params.bind_port, params.target_host, params.target_port + ), + PortForwardingKind::Dynamic => { + format!("SOCKS {}:{}", params.bind_host, params.bind_port) + } + }; + trimmed_or_default(name, default_name) +} + impl StoredConnection { pub fn new_database( name: String, params: DbConnectionConfig, workspace_id: Option, ) -> Self { + let name = default_database_name(name, ¶ms); Self { id: None, name, @@ -1090,6 +1165,7 @@ impl StoredConnection { } pub fn new_ssh(name: String, params: SshParams, workspace_id: Option) -> Self { + let name = default_ssh_name(name, ¶ms); Self { id: None, name, @@ -1115,6 +1191,7 @@ impl StoredConnection { params: RemoteDesktopParams, workspace_id: Option, ) -> Self { + let name = default_remote_desktop_name(name, ¶ms); Self { id: None, name, @@ -1136,6 +1213,7 @@ impl StoredConnection { } pub fn new_redis(name: String, params: RedisParams, workspace_id: Option) -> Self { + let name = default_redis_name(name, ¶ms); Self { id: None, name, @@ -1157,6 +1235,7 @@ impl StoredConnection { } pub fn new_mongodb(name: String, params: MongoDBParams, workspace_id: Option) -> Self { + let name = default_mongodb_name(name, ¶ms); Self { id: None, name, @@ -1194,6 +1273,7 @@ impl StoredConnection { } pub fn new_serial(name: String, params: SerialParams, workspace_id: Option) -> Self { + let name = default_serial_name(name, ¶ms); Self { id: None, name, @@ -1219,6 +1299,7 @@ impl StoredConnection { params: PortForwardingParams, workspace_id: Option, ) -> Self { + let name = default_port_forwarding_name(name, ¶ms); Self { id: None, name, @@ -1345,6 +1426,136 @@ mod tests { } } + #[test] + fn empty_connection_names_default_to_target_address() { + let db = DbConnectionConfig { + id: String::new(), + database_type: DatabaseType::MySQL, + name: String::new(), + host: "127.0.0.1".to_string(), + port: 3306, + username: "root".to_string(), + password: String::new(), + database: None, + service_name: None, + sid: None, + workspace_id: None, + extra_params: HashMap::new(), + }; + assert_eq!( + "127.0.0.1:3306", + StoredConnection::new_database(String::new(), db, None).name + ); + + let ssh = SshParams { + host: "localhost".to_string(), + port: 22, + username: "root".to_string(), + auth_method: SshAuthMethod::Agent, + connect_timeout: None, + keepalive_interval: None, + keepalive_max: None, + default_directory: None, + init_script: None, + disable_shell_integration: None, + jump_server: None, + proxy: None, + }; + assert_eq!( + "root@localhost:22", + StoredConnection::new_ssh(String::new(), ssh, None).name + ); + + let redis = RedisParams { + host: "10.0.0.5".to_string(), + port: 6379, + password: None, + username: None, + db_index: 0, + mode: RedisMode::Standalone, + use_tls: false, + connect_timeout: None, + sentinel: None, + cluster: None, + ssh_tunnel: None, + }; + assert_eq!( + "10.0.0.5:6379", + StoredConnection::new_redis(String::new(), redis, None).name + ); + + let mongo = MongoDBParams { + connection_string: String::new(), + host: "mongo.internal".to_string(), + port: Some(27017), + database: None, + username: None, + password: None, + auth_source: None, + replica_set: None, + read_preference: None, + use_srv_record: false, + direct_connection: false, + use_tls: false, + connect_timeout_seconds: None, + application_name: None, + ssh_tunnel: None, + }; + assert_eq!( + "mongo.internal:27017", + StoredConnection::new_mongodb(String::new(), mongo, None).name + ); + + let remote = RemoteDesktopParams { + protocol: RemoteDesktopProtocol::Rdp, + host: "winhost".to_string(), + port: 3389, + username: None, + password: None, + domain: None, + read_only: false, + }; + assert_eq!( + "winhost:3389", + StoredConnection::new_remote_desktop(String::new(), remote, None).name + ); + + let serial = SerialParams { + port_name: "/dev/tty.usbserial".to_string(), + baud_rate: 115200, + data_bits: 8, + stop_bits: 1, + parity: SerialParity::None, + flow_control: SerialFlowControl::None, + }; + assert_eq!( + "/dev/tty.usbserial", + StoredConnection::new_serial(String::new(), serial, None).name + ); + + let forward = PortForwardingParams { + ssh_connection_id: 42, + kind: PortForwardingKind::Local, + bind_host: "127.0.0.1".to_string(), + bind_port: 15432, + target_host: "db.internal".to_string(), + target_port: 5432, + }; + assert_eq!( + "127.0.0.1:15432 -> db.internal:5432", + StoredConnection::new_port_forwarding(String::new(), forward, None).name + ); + } + + #[test] + fn explicit_connection_names_are_preserved() { + let db = database_config_with_ssh_ref(42); + assert_eq!( + " keep spaces ", + StoredConnection::new_database(" keep spaces ".to_string(), db, None).name + ); + } + #[test] fn db_connection_can_apply_referenced_password_ssh_connection() { let ssh = ssh_connection_with_id( diff --git a/crates/db/src/mysql/plugin.rs b/crates/db/src/mysql/plugin.rs index 2dee073767..2e82368c93 100644 --- a/crates/db/src/mysql/plugin.rs +++ b/crates/db/src/mysql/plugin.rs @@ -438,8 +438,7 @@ fn mysql_connection_form() -> DatabaseFormManifest { DatabaseFormFieldType::Text, ) .optional() - .with_placeholder("database name (optional)") - .with_default("ai_app"), + .with_placeholder("database name (optional)"), ], ), tab( @@ -4161,6 +4160,24 @@ mod tests { vec!["general", "advanced", "ssl", "ssh", "notes"] ); + let general_tab = connection_form + .tabs + .iter() + .find(|tab| tab.id == "general") + .unwrap(); + let name_field = general_tab + .fields + .iter() + .find(|field| field.id == "name") + .unwrap(); + let database_field = general_tab + .fields + .iter() + .find(|field| field.id == "database") + .unwrap(); + assert_eq!(name_field.default_value.as_deref(), Some("Local MySQL")); + assert_eq!(database_field.default_value, None); + let ssh_host = connection_form .tabs .iter() diff --git a/crates/db_view/src/common/db_connection_form.rs b/crates/db_view/src/common/db_connection_form.rs index d82572d8ce..de6dc8dc71 100644 --- a/crates/db_view/src/common/db_connection_form.rs +++ b/crates/db_view/src/common/db_connection_form.rs @@ -666,8 +666,7 @@ impl DbFormConfig { FormFieldType::Text, ) .optional() - .placeholder("database name (optional)") - .default("ai_app"), + .placeholder("database name (optional)"), ]), TabGroup::new("advanced", t!("ConnectionForm.advanced")).fields(vec![ FormField::new( @@ -1992,7 +1991,7 @@ impl DbConnectionForm { let mut stored = match &self.editing_connection { Some(conn) => { let mut c = conn.clone(); - c.name = connection.name.clone(); + c.name = StoredConnection::from_db_connection(connection.clone()).name; c.workspace_id = connection.workspace_id; c.sync_enabled = sync_enabled; c.team_id = team_id; @@ -2950,6 +2949,14 @@ mod tests { .collect() } + fn field_by_name<'a>(tab_group: &'a TabGroup, field_name: &str) -> &'a FormField { + tab_group + .fields + .iter() + .find(|field| field.name == field_name) + .expect("field should exist") + } + fn stored_ssh_connection(id: i64, name: &str, host: &str) -> StoredConnection { let mut connection = StoredConnection::new_ssh( name.to_string(), @@ -3020,6 +3027,22 @@ mod tests { ); } + #[test] + fn mysql_form_keeps_connection_name_default_but_not_database_default() { + let config = DbFormConfig::mysql(); + let general_tab = config + .tab_groups + .iter() + .find(|group| group.name == "general") + .expect("MySQL should include the general tab"); + + assert_eq!( + "Local MySQL", + field_by_name(general_tab, "name").default_value + ); + assert_eq!("", field_by_name(general_tab, "database").default_value); + } + #[test] fn oracle_form_omits_empty_ssl_tab() { let config = DbFormConfig::oracle(); diff --git a/crates/mongodb_view/src/mongo_form_window.rs b/crates/mongodb_view/src/mongo_form_window.rs index 60c8f7e948..9f439efaaf 100644 --- a/crates/mongodb_view/src/mongo_form_window.rs +++ b/crates/mongodb_view/src/mongo_form_window.rs @@ -890,11 +890,6 @@ impl MongoFormWindow { } }; let name = self.name_input.read(cx).text().to_string(); - let name = if name.is_empty() { - t!("MongoForm.default_name").to_string() - } else { - name - }; let workspace_id = self.get_workspace_id(cx); let team_id = self.get_team_id(cx);