From 5beab5375790345a39d5db3428ee9fc49b0b3a6b Mon Sep 17 00:00:00 2001 From: "cnb.cIvqO7MMwEA" Date: Sun, 20 Sep 2026 21:29:04 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(db=5Fview):=20=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E5=BA=93=E5=AF=B9=E8=B1=A1=E5=88=97=E8=A1=A8=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E5=85=A8=E9=80=89=E4=B8=8E=E6=8B=96=E9=80=89=E5=A4=9A=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/core/src/keybindings.rs | 1 + crates/db_view/src/database_objects_tab.rs | 260 +++++++++++++++++- .../db_view/src/database_objects_tab_tests.rs | 32 +++ crates/db_view/src/db_tree_event.rs | 65 ++++- crates/db_view/src/db_tree_view.rs | 29 ++ crates/db_view/src/lib.rs | 1 + crates/db_view/src/object_list_selection.rs | 218 +++++++++++++++ crates/db_view/src/search_shortcut.rs | 51 +++- main/locales/main.yml | 4 + main/src/setting_tab.rs | 34 ++- 10 files changed, 675 insertions(+), 20 deletions(-) create mode 100644 crates/db_view/src/object_list_selection.rs diff --git a/crates/core/src/keybindings.rs b/crates/core/src/keybindings.rs index fbcc42e42..83db71179 100644 --- a/crates/core/src/keybindings.rs +++ b/crates/core/src/keybindings.rs @@ -24,6 +24,7 @@ pub mod action_id { pub const DB_FOCUS_SEARCH: &str = "db.focus_search"; pub const DB_OPEN_TABLE_QUERY: &str = "db.open_table_query"; pub const DB_OPEN_TABLE_DESIGNER: &str = "db.open_table_designer"; + pub const DB_SELECT_ALL_OBJECTS: &str = "db.select_all_objects"; pub const SQL_RUN_QUERY: &str = "sql.run_query"; pub const SQL_RUN_ALL_QUERY: &str = "sql.run_all_query"; pub const TERMINAL_SEND_TAB: &str = "terminal.send_tab"; diff --git a/crates/db_view/src/database_objects_tab.rs b/crates/db_view/src/database_objects_tab.rs index 72a58b37d..55452f2f5 100644 --- a/crates/db_view/src/database_objects_tab.rs +++ b/crates/db_view/src/database_objects_tab.rs @@ -10,8 +10,12 @@ use crate::extension_menu::{ DbTreeExtensionActionContext, DbTreeExtensionMenuContext, DbTreeExtensionMenuItem, DbTreeExtensionMenuRegistry, GlobalDbTreeExtensionActionHandler, }; +use crate::object_list_selection::{ + RowDragState, apply_drag_selection, apply_row_range, exceeds_drag_threshold, select_all_rows, +}; use crate::search_shortcut::{ - DB_SEARCH_CONTEXT, FocusSearchInput, OpenSelectedTableQuery, focus_search_input, + DB_SEARCH_CONTEXT, FocusSearchInput, OpenSelectedTableQuery, SelectAllObjects, + focus_search_input, }; use crate::table_copy_menu::append_table_copy_items; use db::DbNodeType::QueryFolder; @@ -25,8 +29,9 @@ use gpui::prelude::FluentBuilder; use gpui::{ AnyElement, App, AppContext, AsyncApp, Context, Entity, EventEmitter, FocusHandle, Focusable, HighlightStyle, InteractiveElement, IntoElement, ListSizingBehavior, MouseButton, - MouseDownEvent, ParentElement, Render, SharedString, StatefulInteractiveElement, Styled, - StyledText, Subscription, WeakEntity, Window, div, px, uniform_list, + MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement, Pixels, Point, Render, + ScrollStrategy, SharedString, StatefulInteractiveElement, Styled, StyledText, Subscription, + UniformListScrollHandle, WeakEntity, Window, div, px, uniform_list, }; use gpui_component::button::Button; use gpui_component::input::{Input, InputEvent, InputState}; @@ -261,6 +266,24 @@ pub enum DatabaseObjectsEvent { action: DatabaseObjectsBatchAction, nodes: Vec, }, + + /// 全选当前对象列表:请求宿主补全“未打开对象页签”的其他节点 + SelectAllInScope { + scope: DatabaseObjectsSelectionScope, + }, +} + +/// 对象列表当前展示的节点范围。 +/// +/// 部分列表(如数据库下的表清单)只对应树中的目录节点,而这些节点未展开时 +/// 没有 `children_loaded`,因此无法从行数据反推出真实节点。这里把范围显式传给 +/// 宿主,由宿主负责加载并回填。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DatabaseObjectsSelectionScope { + /// 数据库/schema 容器节点 ID,缺失表示当前列表不是可枚举的对象清单 + pub container_node_id: Option, + /// 列表对应的对象类型 + pub node_type: DbNodeType, } #[derive(Clone, Debug)] @@ -289,6 +312,14 @@ pub struct DatabaseObjects { current_node: Option, selected_indices: HashSet, context_menu_row: Option, + /// 鼠标按下但尚未判定为拖选的行 + drag_state: RowDragState, + /// 按下点在窗口中的位置,用于判定拖选阈值 + drag_origin: Option>, + /// 列表滚动句柄,支持拖出可视区或按 Shift 时滚动到目标行 + list_scroll_handle: UniformListScrollHandle, + /// 拖选过程中最近悬停的可见行 + drag_hover_row: Option, _subscriptions: Vec, } @@ -302,6 +333,107 @@ impl DatabaseObjects { focus_search_input(&self.search_input, window, cx); } + fn on_action_select_all_objects( + &mut self, + _: &SelectAllObjects, + _window: &mut Window, + cx: &mut Context, + ) { + self.select_all_visible_rows(cx); + } + + /// 行按下:区分单选、多选(Cmd/Ctrl)与 Shift 区间选择,并开启拖选待判定状态。 + fn on_row_mouse_down(&mut self, row_ix: usize, event: &MouseDownEvent, cx: &mut Context) { + let additive = event.modifiers.secondary(); + if event.modifiers.shift { + self.drag_state.cancel(); + self.drag_origin = None; + self.extend_selection_to(row_ix, cx); + return; + } + + if !self + .drag_state + .pressed() + .is_some_and(|pressed| pressed == row_ix) + { + self.toggle_selection(row_ix, additive); + } + self.drag_state.press(row_ix, additive); + self.drag_origin = Some(event.position); + self.drag_hover_row = Some(row_ix); + cx.notify(); + } + + /// 行悬停:超过拖选阈值后把锚点到当前位置的整段行纳入选择集。 + fn on_row_mouse_move(&mut self, row_ix: usize, event: &MouseMoveEvent, cx: &mut Context) { + if self.drag_state.pressed().is_none() { + return; + } + + if !self.drag_state.is_dragging() { + let Some(origin) = self.drag_origin else { + return; + }; + let origin = (f32::from(origin.x), f32::from(origin.y)); + let position = (f32::from(event.position.x), f32::from(event.position.y)); + if !exceeds_drag_threshold(origin, position) { + return; + } + } + + self.drag_state.hover(row_ix); + if self.drag_hover_row == Some(row_ix) { + return; + } + self.drag_hover_row = Some(row_ix); + + let Some(anchor) = self.drag_state.pressed() else { + return; + }; + let visible_row_count = self.visible_row_count(); + apply_drag_selection( + &mut self.selected_indices, + anchor, + row_ix, + visible_row_count, + ); + cx.notify(); + } + + /// 行释放:未发生拖选时保持按下时的单击/多选结果。 + fn on_row_mouse_up(&mut self, _: &MouseUpEvent, _window: &mut Window, cx: &mut Context) { + if self.drag_state.release().is_some() { + cx.notify(); + } + self.drag_origin = None; + self.drag_hover_row = None; + } + + /// 面板根节点释放:兜住“按下后拖到行外才松手”的情况。 + fn on_panel_mouse_up( + &mut self, + _: &MouseUpEvent, + _window: &mut Window, + cx: &mut Context, + ) { + self.end_row_drag(cx); + } + + /// 结束拖选但不改变已确认的选择集。 + /// + /// 指针在行外释放(拖出列表、切到别的面板)时不会触发行级 `on_mouse_up`, + /// 由面板根节点的释放事件收尾。 + fn end_row_drag(&mut self, cx: &mut Context) { + let was_active = self.drag_state.pressed().is_some() || self.drag_state.is_dragging(); + self.drag_state.cancel(); + self.drag_origin = None; + self.drag_hover_row = None; + if was_active { + cx.notify(); + } + } + fn on_action_open_selected_table_query( &mut self, _: &OpenSelectedTableQuery, @@ -403,6 +535,10 @@ impl DatabaseObjects { current_node: None, selected_indices: HashSet::new(), context_menu_row: None, + drag_state: RowDragState::default(), + drag_origin: None, + list_scroll_handle: UniformListScrollHandle::new(), + drag_hover_row: None, _subscriptions: vec![search_sub], } } @@ -545,6 +681,80 @@ impl DatabaseObjects { } } + /// 可见行总数(已应用搜索过滤)。 + fn visible_row_count(&self) -> usize { + self.filtered_rows.len() + } + + /// 全选当前可见行(Ctrl/Cmd + A)。 + fn select_all_visible_rows(&mut self, cx: &mut Context) { + let visible_row_count = self.visible_row_count(); + select_all_rows(&mut self.selected_indices, visible_row_count); + self.context_menu_row = None; + cx.notify(); + cx.emit(DatabaseObjectsEvent::SelectAllInScope { + scope: self.selection_scope(), + }); + } + + /// 把指定节点对应的可见行纳入选择集(全选补全后回填)。 + pub fn select_rows_matching_nodes(&mut self, node_ids: &[String], cx: &mut Context) { + if node_ids.is_empty() { + return; + } + let wanted: HashSet<&str> = node_ids.iter().map(String::as_str).collect(); + let matched: Vec = (0..self.filtered_rows.len()) + .filter(|row_ix| { + self.build_node_for_row(*row_ix) + .is_some_and(|node| wanted.contains(node.id.as_str())) + }) + .collect(); + if matched.is_empty() { + return; + } + self.selected_indices.extend(matched); + self.context_menu_row = None; + cx.notify(); + } + + /// 当前列表可枚举的对象范围;不可枚举时返回 `None` 容器。 + pub(crate) fn selection_scope(&self) -> DatabaseObjectsSelectionScope { + // 行数据本身已带完整身份(连接/库/schema/表)时才可以直接枚举 + let enumerable = matches!( + self.db_node_type, + DbNodeType::Table | DbNodeType::View | DbNodeType::NamedQuery + ); + DatabaseObjectsSelectionScope { + container_node_id: enumerable + .then(|| self.current_node.as_ref().map(|node| node.id.clone())) + .flatten(), + node_type: self.db_node_type, + } + } + + /// Shift 点击:从当前锚点行扩展到目标行。 + /// + /// 锚点优先取首个已选行;没有已选行时退化为单选目标行。 + fn extend_selection_to(&mut self, row_ix: usize, cx: &mut Context) { + self.context_menu_row = None; + let anchor = self + .selected_indices + .iter() + .copied() + .min() + .filter(|_| !self.selected_indices.is_empty()); + match anchor { + Some(anchor) => apply_row_range(&mut self.selected_indices, anchor, row_ix), + None => { + self.selected_indices.clear(); + self.selected_indices.insert(row_ix); + } + } + self.list_scroll_handle + .scroll_to_item(row_ix, ScrollStrategy::Top); + cx.notify(); + } + fn apply_filter(&mut self) { if self.search_query.is_empty() { self.filtered_rows = (0..self.rows.len()).collect(); @@ -1396,6 +1606,8 @@ impl Render for DatabaseObjects { .key_context(DB_SEARCH_CONTEXT) .on_action(cx.listener(Self::on_action_focus_search)) .on_action(cx.listener(Self::on_action_open_selected_table_query)) + .on_action(cx.listener(Self::on_action_select_all_objects)) + .on_mouse_up(MouseButton::Left, cx.listener(Self::on_panel_mouse_up)) .child( h_flex() .gap_1() @@ -1453,15 +1665,38 @@ impl Render for DatabaseObjects { cx.listener( move |this, event: &MouseDownEvent, - _window, + window, cx| { - let multi_select = - event.modifiers.secondary(); - this.toggle_selection( - row_ix, - multi_select, + window.focus( + &this.focus_handle, + cx, + ); + this.on_row_mouse_down( + row_ix, event, cx, + ); + }, + ), + ) + .on_mouse_move(cx.listener( + move |this, + event: &MouseMoveEvent, + _window, + cx| { + this.on_row_mouse_move( + row_ix, event, cx, + ); + }, + )) + .on_mouse_up( + MouseButton::Left, + cx.listener( + move |this, + event: &MouseUpEvent, + window, + cx| { + this.on_row_mouse_up( + event, window, cx, ); - cx.notify(); }, ), ) @@ -1513,6 +1748,7 @@ impl Render for DatabaseObjects { }) .flex_grow_1() .size_full() + .track_scroll(&self.list_scroll_handle) .with_sizing_behavior(ListSizingBehavior::Auto), ), ), @@ -1538,6 +1774,10 @@ impl Clone for DatabaseObjects { current_node: self.current_node.clone(), selected_indices: self.selected_indices.clone(), context_menu_row: self.context_menu_row, + drag_state: RowDragState::default(), + drag_origin: None, + list_scroll_handle: UniformListScrollHandle::new(), + drag_hover_row: None, _subscriptions: vec![], } } diff --git a/crates/db_view/src/database_objects_tab_tests.rs b/crates/db_view/src/database_objects_tab_tests.rs index 3e4feaff1..62df2d6bf 100644 --- a/crates/db_view/src/database_objects_tab_tests.rs +++ b/crates/db_view/src/database_objects_tab_tests.rs @@ -42,3 +42,35 @@ fn object_name_highlights_preserve_identifier_boundaries() { object_name_highlight_ranges(text, "") ); } + +#[test] +fn object_rows_wire_keyboard_and_mouse_range_selection() { + let source = include_str!("database_objects_tab.rs"); + + // Ctrl/Cmd+A 走 key context action,保证列表聚焦时即可全选可见行 + assert!(source.contains(".key_context(DB_SEARCH_CONTEXT)")); + assert!(source.contains(".on_action(cx.listener(Self::on_action_select_all_objects))")); + assert!(source.contains("select_all_rows(&mut self.selected_indices")); + + // Shift 点击扩到区间,拖选走 mouse_move/mouse_up + assert!(source.contains("event.modifiers.shift")); + assert!(source.contains("apply_row_range(&mut self.selected_indices")); + assert!(source.contains("apply_drag_selection(")); + assert!(source.contains("exceeds_drag_threshold(")); + assert!(source.contains(".on_mouse_move(cx.listener(")); + // 行内释放走 on_row_mouse_up,面板根节点兜住“拖到行外松手” + assert!( + source.contains(".on_mouse_up(MouseButton::Left, cx.listener(Self::on_panel_mouse_up))") + ); + assert!(source.contains("fn on_panel_mouse_up(")); + assert!(source.contains("fn end_row_drag(")); +} + +#[test] +fn select_all_asks_host_to_load_rows_that_have_no_open_tab() { + let source = include_str!("database_objects_tab.rs"); + + assert!(source.contains("DatabaseObjectsEvent::SelectAllInScope")); + assert!(source.contains("fn selection_scope(&self)")); + assert!(source.contains("fn select_rows_matching_nodes(")); +} diff --git a/crates/db_view/src/db_tree_event.rs b/crates/db_view/src/db_tree_event.rs index 6021ec1d2..ee4ab65e9 100644 --- a/crates/db_view/src/db_tree_event.rs +++ b/crates/db_view/src/db_tree_event.rs @@ -1,4 +1,6 @@ -use crate::database_objects_tab::{DatabaseObjectsBatchAction, DatabaseObjectsEvent}; +use crate::database_objects_tab::{ + DatabaseObjectsBatchAction, DatabaseObjectsEvent, DatabaseObjectsSelectionScope, +}; use crate::db_tree_view::SqlDumpMode; use crate::{ database_objects_tab::DatabaseObjectsPanel, @@ -558,6 +560,7 @@ impl DatabaseEventHandler { let global_state = global_state_for_objects.clone(); let tree_view = tree_view_for_objects.clone(); let execution_history = execution_history_for_objects.clone(); + let db_objects_for_scope = _db_objects.clone(); match event { DatabaseObjectsEvent::TreeEvent { event } => { @@ -773,6 +776,14 @@ impl DatabaseEventHandler { cx, ); } + DatabaseObjectsEvent::SelectAllInScope { scope } => { + Self::handle_select_all_in_scope( + scope.clone(), + tree_view, + db_objects_for_scope.clone(), + cx, + ); + } } }, ); @@ -2624,6 +2635,58 @@ impl DatabaseEventHandler { }); } + /// 全选当前对象列表时,补全树中“尚未展开”的同级对象节点。 + /// + /// 对象页签只在树节点已加载子节点后才展示完整清单,因此用户刚切到某个 + /// 数据库/schema 时可能只看到部分对象。这里按范围重新加载容器子节点, + /// 让 Ctrl+A 覆盖到全部对象。 + fn handle_select_all_in_scope( + scope: DatabaseObjectsSelectionScope, + tree_view: Entity, + database_objects: Entity, + cx: &mut App, + ) { + let Some(container_node_id) = scope.container_node_id else { + return; + }; + let Some(container) = tree_view.read(cx).node(&container_node_id) else { + return; + }; + if container.children_loaded { + return; + } + + let expected_type = scope.node_type; + let connection_id = container.connection_id.clone(); + let global_state = cx.global::().clone(); + + cx.spawn(async move |cx: &mut AsyncApp| { + let children = global_state + .load_node_children(cx, connection_id, container) + .await; + let Ok(children) = children else { + return; + }; + let node_ids: Vec = children + .iter() + .filter(|child| child.node_type == expected_type) + .map(|child| child.id.clone()) + .collect(); + if node_ids.is_empty() { + return; + } + + // 树节点缓存复用同一份 children,避免重复请求 + let _ = tree_view.update(cx, |tree, _cx| { + tree.store_loaded_children(&container_node_id, children); + }); + let _ = database_objects.update(cx, |objects, cx| { + objects.select_rows_matching_nodes(&node_ids, cx); + }); + }) + .detach(); + } + fn handle_batch_action( action: DatabaseObjectsBatchAction, nodes: Vec, diff --git a/crates/db_view/src/db_tree_view.rs b/crates/db_view/src/db_tree_view.rs index 4bb8c4129..8686427ca 100644 --- a/crates/db_view/src/db_tree_view.rs +++ b/crates/db_view/src/db_tree_view.rs @@ -1480,6 +1480,35 @@ impl DbTreeView { } } + /// 取已加载到树中的节点。 + pub fn node(&self, node_id: &str) -> Option { + self.db_nodes.get(node_id).cloned() + } + + /// 把外部加载到的子节点写回树缓存。 + /// + /// 用于对象页签全选时复用同一份加载结果,避免重复请求数据库。 + pub fn store_loaded_children(&mut self, node_id: &str, children: Vec) { + self.loaded_children.insert(node_id.to_string()); + self.loading_nodes.remove(node_id); + self.error_nodes.remove(node_id); + + fn insert_nodes_recursive(db_nodes: &mut HashMap, node: &DbNode) { + db_nodes.insert(node.id.clone(), node.clone()); + for child in &node.children { + insert_nodes_recursive(db_nodes, child); + } + } + for child in &children { + insert_nodes_recursive(&mut self.db_nodes, child); + } + + if let Some(parent) = self.db_nodes.get_mut(node_id) { + parent.children = children; + parent.children_loaded = true; + } + } + /// 刷新指定节点及其子节点 /// /// 这个方法会: diff --git a/crates/db_view/src/lib.rs b/crates/db_view/src/lib.rs index 6285b5a6e..d0dd1757b 100644 --- a/crates/db_view/src/lib.rs +++ b/crates/db_view/src/lib.rs @@ -22,6 +22,7 @@ pub mod extension_menu; #[cfg(test)] mod extension_menu_contract_tests; mod import_export; +mod object_list_selection; pub mod search_shortcut; pub mod settings; mod sidebar; diff --git a/crates/db_view/src/object_list_selection.rs b/crates/db_view/src/object_list_selection.rs new file mode 100644 index 000000000..693126056 --- /dev/null +++ b/crates/db_view/src/object_list_selection.rs @@ -0,0 +1,218 @@ +//! 数据库对象列表的批量选择模型。 +//! +//! 全选、Shift 区间选择和鼠标拖选都作用在**可见行**(过滤后的行)上, +//! 但删除等批量操作针对的是真实节点,所以选择集必须以“可见行序号”为基准, +//! 由调用方再用 `filtered_rows` 映射回原始行。 +//! +//! 拖选需要区分“尚未拖动”和“拖到同一行”,因此这里把按下点、拖选锚点 +//! 和最近一次悬停行显式建模,避免把单击误判成拖选。 + +use std::collections::HashSet; + +/// 鼠标行选择的一次交互状态。 +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct RowDragState { + /// 已按下但尚未判定是否为拖选 + pressed: Option, + /// 拖选锚点行 + anchor: Option, + /// 最近一次悬停行 + last_hovered: Option, +} + +impl RowDragState { + pub fn pressed(&self) -> Option { + self.pressed + } + + pub fn is_dragging(&self) -> bool { + self.anchor.is_some() + } + + /// 记录按下行。已处于拖选中时忽略,避免拖拽过程中重新起锚。 + pub fn press(&mut self, row_ix: usize, additive: bool) -> PressOutcome { + if self.is_dragging() { + return PressOutcome::Ignored; + } + self.pressed = Some(row_ix); + self.last_hovered = Some(row_ix); + PressOutcome::Pressed { additive } + } + + /// 悬停到某行。返回是否首次进入拖选状态。 + pub fn hover(&mut self, row_ix: usize) -> bool { + if self.pressed.is_none() || self.last_hovered == Some(row_ix) { + return false; + } + self.last_hovered = Some(row_ix); + if self.anchor.is_none() { + self.anchor = self.pressed; + return true; + } + false + } + + /// 结束交互,返回拖选锚点(若有)。 + pub fn release(&mut self) -> Option { + let anchor = self.anchor.take(); + self.pressed = None; + self.last_hovered = None; + anchor + } + + /// 取消拖选(例如失去焦点),不改变已确认的选择集。 + pub fn cancel(&mut self) { + self.pressed = None; + self.anchor = None; + self.last_hovered = None; + } +} + +/// 判定「点击」升级为「拖选」的最小像素位移,避免手抖把单击变成区间选择。 +pub const ROW_DRAG_THRESHOLD_PX: f32 = 3.0; + +/// 指针相对按下点的位移是否已达到拖选阈值。 +pub fn exceeds_drag_threshold(origin: (f32, f32), position: (f32, f32)) -> bool { + let moved = (position.0 - origin.0) + .abs() + .max((position.1 - origin.1).abs()); + moved >= ROW_DRAG_THRESHOLD_PX +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PressOutcome { + /// 已记录按下点,但尚未确定是单击还是拖选 + Pressed { additive: bool }, + /// 拖选中,忽略这次按下 + Ignored, +} + +/// 用锚点到目标行的整段区间替换选择集。 +pub fn apply_row_range(selected: &mut HashSet, anchor: usize, target: usize) { + selected.clear(); + let (start, end) = if anchor <= target { + (anchor, target) + } else { + (target, anchor) + }; + selected.extend(start..=end); +} + +/// 全选当前可见行。 +pub fn select_all_rows(selected: &mut HashSet, visible_row_count: usize) { + selected.clear(); + selected.extend(0..visible_row_count); +} + +/// 拖选结束时的最终选择集:从锚点到目标行的整段。 +pub fn apply_drag_selection( + selected: &mut HashSet, + anchor: usize, + target: usize, + visible_row_count: usize, +) { + if visible_row_count == 0 { + selected.clear(); + return; + } + apply_row_range( + selected, + anchor.min(visible_row_count - 1), + target.min(visible_row_count - 1), + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn select_all_rows_covers_visible_rows_only() { + let mut selected = HashSet::from([7]); + select_all_rows(&mut selected, 3); + assert_eq!(HashSet::from([0, 1, 2]), selected); + } + + #[test] + fn select_all_rows_clears_selection_when_list_is_empty() { + let mut selected = HashSet::from([1, 2]); + select_all_rows(&mut selected, 0); + assert!(selected.is_empty()); + } + + #[test] + fn apply_drag_selection_keeps_the_full_span_in_both_directions() { + let mut selected = HashSet::new(); + apply_drag_selection(&mut selected, 4, 1, 10); + assert_eq!(HashSet::from([1, 2, 3, 4]), selected); + + apply_drag_selection(&mut selected, 1, 4, 10); + assert_eq!(HashSet::from([1, 2, 3, 4]), selected); + } + + #[test] + fn apply_drag_selection_clamps_to_visible_rows() { + let mut selected = HashSet::new(); + apply_drag_selection(&mut selected, 0, 99, 4); + assert_eq!(HashSet::from([0, 1, 2, 3]), selected); + } + + #[test] + fn drag_threshold_ignores_tiny_pointer_wobble() { + assert!(!exceeds_drag_threshold((10.0, 10.0), (10.5, 10.5))); + assert!(!exceeds_drag_threshold((10.0, 10.0), (12.9, 10.0))); + } + + #[test] + fn drag_threshold_triggers_on_either_axis() { + assert!(exceeds_drag_threshold((10.0, 10.0), (13.0, 10.0))); + assert!(exceeds_drag_threshold((10.0, 10.0), (10.0, 20.0))); + assert!(!exceeds_drag_threshold((10.0, 10.0), (10.0, 12.9))); + } + + #[test] + fn press_outcome_records_additive_modifier() { + let mut state = RowDragState::default(); + assert_eq!( + PressOutcome::Pressed { additive: true }, + state.press(2, true) + ); + assert_eq!(Some(2), state.pressed()); + assert!(!state.is_dragging()); + } + + #[test] + fn hover_starts_dragging_once_and_keeps_the_original_anchor() { + let mut state = RowDragState::default(); + state.press(3, false); + + assert!(state.hover(4)); + assert!(!state.hover(6)); + // 锚点保持按下行,悬停行持续跟随,拖选才能覆盖锚点到当前位置的整段 + assert_eq!(Some(3), state.pressed()); + assert_eq!(Some(6), state.last_hovered); + } + + #[test] + fn hover_without_press_never_starts_dragging() { + let mut state = RowDragState::default(); + assert!(!state.hover(5)); + assert!(!state.is_dragging()); + } + + #[test] + fn drag_ignores_a_second_press_until_release() { + let mut state = RowDragState::default(); + state.press(1, false); + state.hover(2); + + assert_eq!(PressOutcome::Ignored, state.press(5, false)); + assert_eq!(Some(1), state.pressed()); + assert_eq!(Some(1), state.release()); + assert!(!state.is_dragging()); + assert_eq!( + PressOutcome::Pressed { additive: false }, + state.press(9, false) + ); + } +} diff --git a/crates/db_view/src/search_shortcut.rs b/crates/db_view/src/search_shortcut.rs index 9e84393f0..eb6ebb35b 100644 --- a/crates/db_view/src/search_shortcut.rs +++ b/crates/db_view/src/search_shortcut.rs @@ -9,10 +9,17 @@ const MACOS_TABLE_QUERY_SHORTCUT: &str = "cmd-shift-enter"; const OTHER_TABLE_QUERY_SHORTCUT: &str = "ctrl-shift-enter"; const MACOS_TABLE_DESIGNER_SHORTCUT: &str = "cmd-shift-d"; const OTHER_TABLE_DESIGNER_SHORTCUT: &str = "ctrl-shift-d"; +const MACOS_SELECT_ALL_SHORTCUT: &str = "cmd-a"; +const OTHER_SELECT_ALL_SHORTCUT: &str = "ctrl-a"; gpui::actions!( db_search, - [FocusSearchInput, OpenSelectedTableQuery, OpenTableDesigner] + [ + FocusSearchInput, + OpenSelectedTableQuery, + OpenTableDesigner, + SelectAllObjects + ] ); pub fn init(cx: &mut App) { @@ -41,6 +48,10 @@ fn default_table_designer_shortcuts() -> [&'static str; 1] { default_table_designer_shortcuts_for_platform(cfg!(target_os = "macos")) } +fn default_select_all_shortcuts() -> [&'static str; 1] { + default_select_all_shortcuts_for_platform(cfg!(target_os = "macos")) +} + fn default_search_shortcuts_for_platform(is_macos: bool) -> [&'static str; 1] { if is_macos { [MACOS_SEARCH_SHORTCUT] @@ -65,6 +76,14 @@ fn default_table_designer_shortcuts_for_platform(is_macos: bool) -> [&'static st } } +fn default_select_all_shortcuts_for_platform(is_macos: bool) -> [&'static str; 1] { + if is_macos { + [MACOS_SELECT_ALL_SHORTCUT] + } else { + [OTHER_SELECT_ALL_SHORTCUT] + } +} + fn init_keybindings(cx: &App) -> Vec { let mut keybindings: Vec = shortcuts_for(cx, action_id::DB_FOCUS_SEARCH, &default_search_shortcuts()) @@ -90,6 +109,15 @@ fn init_keybindings(cx: &App) -> Vec { .into_iter() .map(|key| KeyBinding::new(&key, OpenTableDesigner, Some(DB_SEARCH_CONTEXT))), ); + keybindings.extend( + shortcuts_for( + cx, + action_id::DB_SELECT_ALL_OBJECTS, + &default_select_all_shortcuts(), + ) + .into_iter() + .map(|key| KeyBinding::new(&key, SelectAllObjects, Some(DB_SEARCH_CONTEXT))), + ); keybindings } @@ -115,14 +143,21 @@ fn refreshable_keybindings(cx: &App) -> Vec { Some(DB_SEARCH_CONTEXT), OpenTableDesigner, )); + keybindings.extend(rebind_keybindings( + cx, + action_id::DB_SELECT_ALL_OBJECTS, + &default_select_all_shortcuts(), + Some(DB_SEARCH_CONTEXT), + SelectAllObjects, + )); keybindings } #[cfg(test)] mod tests { use super::{ - default_search_shortcuts_for_platform, default_table_designer_shortcuts_for_platform, - default_table_query_shortcuts_for_platform, + default_search_shortcuts_for_platform, default_select_all_shortcuts_for_platform, + default_table_designer_shortcuts_for_platform, default_table_query_shortcuts_for_platform, }; #[test] @@ -147,6 +182,16 @@ mod tests { ); } + #[test] + fn object_list_select_all_uses_cmd_a_on_macos() { + assert_eq!(["cmd-a"], default_select_all_shortcuts_for_platform(true)); + } + + #[test] + fn object_list_select_all_uses_ctrl_a_on_windows_and_linux() { + assert_eq!(["ctrl-a"], default_select_all_shortcuts_for_platform(false)); + } + #[test] fn table_designer_uses_shift_d_shortcuts() { assert_eq!( diff --git a/main/locales/main.yml b/main/locales/main.yml index 8ded8e41c..bd0905cb2 100644 --- a/main/locales/main.yml +++ b/main/locales/main.yml @@ -4081,6 +4081,10 @@ Settings: en: Design Current Table zh-CN: 设计当前表 zh-HK: 設計當前表 + database_select_all_objects: + en: Select All Objects + zh-CN: 全选对象 + zh-HK: 全選物件 sql_run_query: en: Run Current Query zh-CN: 执行当前查询 diff --git a/main/src/setting_tab.rs b/main/src/setting_tab.rs index cfd8b3bbe..c16192e6c 100644 --- a/main/src/setting_tab.rs +++ b/main/src/setting_tab.rs @@ -3040,6 +3040,13 @@ const DATABASE_SHORTCUTS: &[ShortcutEntry] = &[ action_id: Some(action_id::DB_OPEN_TABLE_DESIGNER), system_hotkey: false, }, + ShortcutEntry { + keys_macos: &["cmd-a"], + keys_other: &["ctrl-a"], + label_key: "Settings.Shortcuts.database_select_all_objects", + action_id: Some(action_id::DB_SELECT_ALL_OBJECTS), + system_hotkey: false, + }, ShortcutEntry { keys_macos: &["cmd-enter", "ctrl-enter"], keys_other: &["cmd-enter", "ctrl-enter"], @@ -3622,12 +3629,13 @@ mod tests { use rust_i18n::t; use super::{ - AppSettings, CustomFont, FontFamilyKind, GlobalProxySettings, ProxyType, WINDOW_SHORTCUTS, - app_font_options, build_app_http_client, builtin_monospace_font_options, - emit_team_key_change_event, is_supported_font_file, master_key_setting_enabled, - merge_font_options_with_custom_fonts, monospace_font_options, parse_font_families, - personal_sync_backend_options, personal_sync_status_label, personal_sync_status_view_model, - team_key_refresh_success_message, team_key_rotation_inputs_valid, + AppSettings, CustomFont, DATABASE_SHORTCUTS, FontFamilyKind, GlobalProxySettings, + ProxyType, WINDOW_SHORTCUTS, app_font_options, build_app_http_client, + builtin_monospace_font_options, emit_team_key_change_event, is_supported_font_file, + master_key_setting_enabled, merge_font_options_with_custom_fonts, monospace_font_options, + parse_font_families, personal_sync_backend_options, personal_sync_status_label, + personal_sync_status_view_model, team_key_refresh_success_message, + team_key_rotation_inputs_valid, }; use crate::local_terminal_profiles::setting_options as local_terminal_profile_options; use crate::personal_sync_status::PersonalSyncRuntimeStatus; @@ -3651,6 +3659,20 @@ mod tests { assert!(!shortcut.keys_other.contains(&"ctrl-d")); } + #[test] + fn object_list_select_all_entry_matches_runtime_defaults() { + use one_core::keybindings::action_id; + + let entry = DATABASE_SHORTCUTS + .iter() + .find(|entry| entry.action_id == Some(action_id::DB_SELECT_ALL_OBJECTS)) + .expect("select all objects shortcut entry"); + + assert_eq!(entry.keys_macos, &["cmd-a"]); + assert_eq!(entry.keys_other, &["ctrl-a"]); + assert!(!entry.system_hotkey); + } + #[test] fn file_log_layer_disables_ansi_escapes() { let source = include_str!("setting_tab.rs"); From 2b20f1eaa2616bfd2e9768b4b2ec9a09917292d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=83=A1=E9=A3=9E?= <1835698775@qq.com> Date: Mon, 21 Sep 2026 10:41:38 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(db=5Fview):=20=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=E5=AF=B9=E8=B1=A1=E5=88=97=E8=A1=A8=E5=85=A8=E9=80=89=E7=9A=84?= =?UTF-8?q?=E6=97=A0=E6=95=88=E8=A1=A5=E5=85=A8=E9=93=BE=E8=B7=AF=E4=B8=8E?= =?UTF-8?q?=E6=8B=96=E9=80=89=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除 SelectAllInScope 补偿链:对象页签只在容器节点 children_loaded 时才展示清单, 宿主侧该判断必然命中,且列表本身不做截断,可见行即全部对象;一并移除只服务该链路的 DbTreeView::store_loaded_children(它绕过了 lazy_load_children 的重建与事件收尾) 和对应的字符串契约测试 - 拖选锚点改为每次按下无条件重置:gpui 只把 mouse_up 发给命中路径上的元素,指针在列表外 松开时视图收不到释放事件,旧实现会沿用旧锚点截出错区间 - Shift 点击改用 ScrollStrategy::Nearest,不再把可见行顶到视口顶部 - Cmd/Ctrl 拖选支持加选:按下时快照选择集,拖选区间叠加其上,来回拖动可正常收缩 - 清理 PressOutcome、RowDragState::last_hovered 等死状态与重复状态 --- crates/db_view/src/database_objects_tab.rs | 108 ++------ .../db_view/src/database_objects_tab_tests.rs | 13 +- crates/db_view/src/db_tree_event.rs | 65 +---- crates/db_view/src/db_tree_view.rs | 24 -- crates/db_view/src/object_list_selection.rs | 234 +++++++++++------- 5 files changed, 177 insertions(+), 267 deletions(-) diff --git a/crates/db_view/src/database_objects_tab.rs b/crates/db_view/src/database_objects_tab.rs index 55452f2f5..1f65584bd 100644 --- a/crates/db_view/src/database_objects_tab.rs +++ b/crates/db_view/src/database_objects_tab.rs @@ -11,7 +11,7 @@ use crate::extension_menu::{ DbTreeExtensionMenuRegistry, GlobalDbTreeExtensionActionHandler, }; use crate::object_list_selection::{ - RowDragState, apply_drag_selection, apply_row_range, exceeds_drag_threshold, select_all_rows, + RowDragState, VisibleRowSpan, exceeds_drag_threshold, replace_with_span, select_all_rows, }; use crate::search_shortcut::{ DB_SEARCH_CONTEXT, FocusSearchInput, OpenSelectedTableQuery, SelectAllObjects, @@ -266,24 +266,6 @@ pub enum DatabaseObjectsEvent { action: DatabaseObjectsBatchAction, nodes: Vec, }, - - /// 全选当前对象列表:请求宿主补全“未打开对象页签”的其他节点 - SelectAllInScope { - scope: DatabaseObjectsSelectionScope, - }, -} - -/// 对象列表当前展示的节点范围。 -/// -/// 部分列表(如数据库下的表清单)只对应树中的目录节点,而这些节点未展开时 -/// 没有 `children_loaded`,因此无法从行数据反推出真实节点。这里把范围显式传给 -/// 宿主,由宿主负责加载并回填。 -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct DatabaseObjectsSelectionScope { - /// 数据库/schema 容器节点 ID,缺失表示当前列表不是可枚举的对象清单 - pub container_node_id: Option, - /// 列表对应的对象类型 - pub node_type: DbNodeType, } #[derive(Clone, Debug)] @@ -312,13 +294,13 @@ pub struct DatabaseObjects { current_node: Option, selected_indices: HashSet, context_menu_row: Option, - /// 鼠标按下但尚未判定为拖选的行 + /// 鼠标行选择的交互状态(按下行、拖选锚点、加选快照) drag_state: RowDragState, /// 按下点在窗口中的位置,用于判定拖选阈值 drag_origin: Option>, - /// 列表滚动句柄,支持拖出可视区或按 Shift 时滚动到目标行 + /// 列表滚动句柄:Shift 点击时把目标行滚入可视区 list_scroll_handle: UniformListScrollHandle, - /// 拖选过程中最近悬停的可见行 + /// 拖选过程中最近悬停的可见行,用来避免同一行重复重算选择集 drag_hover_row: Option, _subscriptions: Vec, } @@ -348,18 +330,15 @@ impl DatabaseObjects { if event.modifiers.shift { self.drag_state.cancel(); self.drag_origin = None; + self.drag_hover_row = None; self.extend_selection_to(row_ix, cx); return; } - if !self - .drag_state - .pressed() - .is_some_and(|pressed| pressed == row_ix) - { - self.toggle_selection(row_ix, additive); - } - self.drag_state.press(row_ix, additive); + self.toggle_selection(row_ix, additive); + // Cmd/Ctrl 拖选在按下快照之上叠加区间,普通拖选直接替换选择集 + let additive_base = additive.then(|| self.selected_indices.clone()); + self.drag_state.press(row_ix, additive_base); self.drag_origin = Some(event.position); self.drag_hover_row = Some(row_ix); cx.notify(); @@ -382,22 +361,18 @@ impl DatabaseObjects { } } - self.drag_state.hover(row_ix); + // 先确立锚点,再判断是否需要重算区间:首个越过阈值的悬停行也要参与计算 + if self.drag_state.start_drag().is_none() { + return; + } if self.drag_hover_row == Some(row_ix) { return; } self.drag_hover_row = Some(row_ix); - let Some(anchor) = self.drag_state.pressed() else { - return; - }; let visible_row_count = self.visible_row_count(); - apply_drag_selection( - &mut self.selected_indices, - anchor, - row_ix, - visible_row_count, - ); + self.drag_state + .apply_drag_to(row_ix, &mut self.selected_indices, visible_row_count); cx.notify(); } @@ -422,8 +397,9 @@ impl DatabaseObjects { /// 结束拖选但不改变已确认的选择集。 /// - /// 指针在行外释放(拖出列表、切到别的面板)时不会触发行级 `on_mouse_up`, - /// 由面板根节点的释放事件收尾。 + /// 指针在列表外松开(拖出列表、切到别的面板)时,gpui 只把 `mouse_up` 发给 + /// 命中路径上的元素,本视图收不到,因此 `RowDragState::press` 会自己丢弃旧锚点, + /// 这里的收尾只负责清掉拖选过程中的临时状态。 fn end_row_drag(&mut self, cx: &mut Context) { let was_active = self.drag_state.pressed().is_some() || self.drag_state.is_dragging(); self.drag_state.cancel(); @@ -687,49 +663,14 @@ impl DatabaseObjects { } /// 全选当前可见行(Ctrl/Cmd + A)。 + /// + /// 列表本身已是当前范围的全量数据(`ObjectView` 不做截断),可见行即全部对象, + /// 因此全选不需要再去请求宿主补全。 fn select_all_visible_rows(&mut self, cx: &mut Context) { let visible_row_count = self.visible_row_count(); select_all_rows(&mut self.selected_indices, visible_row_count); self.context_menu_row = None; cx.notify(); - cx.emit(DatabaseObjectsEvent::SelectAllInScope { - scope: self.selection_scope(), - }); - } - - /// 把指定节点对应的可见行纳入选择集(全选补全后回填)。 - pub fn select_rows_matching_nodes(&mut self, node_ids: &[String], cx: &mut Context) { - if node_ids.is_empty() { - return; - } - let wanted: HashSet<&str> = node_ids.iter().map(String::as_str).collect(); - let matched: Vec = (0..self.filtered_rows.len()) - .filter(|row_ix| { - self.build_node_for_row(*row_ix) - .is_some_and(|node| wanted.contains(node.id.as_str())) - }) - .collect(); - if matched.is_empty() { - return; - } - self.selected_indices.extend(matched); - self.context_menu_row = None; - cx.notify(); - } - - /// 当前列表可枚举的对象范围;不可枚举时返回 `None` 容器。 - pub(crate) fn selection_scope(&self) -> DatabaseObjectsSelectionScope { - // 行数据本身已带完整身份(连接/库/schema/表)时才可以直接枚举 - let enumerable = matches!( - self.db_node_type, - DbNodeType::Table | DbNodeType::View | DbNodeType::NamedQuery - ); - DatabaseObjectsSelectionScope { - container_node_id: enumerable - .then(|| self.current_node.as_ref().map(|node| node.id.clone())) - .flatten(), - node_type: self.db_node_type, - } } /// Shift 点击:从当前锚点行扩展到目标行。 @@ -744,14 +685,17 @@ impl DatabaseObjects { .min() .filter(|_| !self.selected_indices.is_empty()); match anchor { - Some(anchor) => apply_row_range(&mut self.selected_indices, anchor, row_ix), + Some(anchor) => { + let span = VisibleRowSpan::new(anchor, row_ix, self.visible_row_count()); + replace_with_span(&mut self.selected_indices, span); + } None => { self.selected_indices.clear(); self.selected_indices.insert(row_ix); } } self.list_scroll_handle - .scroll_to_item(row_ix, ScrollStrategy::Top); + .scroll_to_item(row_ix, ScrollStrategy::Nearest); cx.notify(); } diff --git a/crates/db_view/src/database_objects_tab_tests.rs b/crates/db_view/src/database_objects_tab_tests.rs index 62df2d6bf..68773e3d5 100644 --- a/crates/db_view/src/database_objects_tab_tests.rs +++ b/crates/db_view/src/database_objects_tab_tests.rs @@ -54,8 +54,8 @@ fn object_rows_wire_keyboard_and_mouse_range_selection() { // Shift 点击扩到区间,拖选走 mouse_move/mouse_up assert!(source.contains("event.modifiers.shift")); - assert!(source.contains("apply_row_range(&mut self.selected_indices")); - assert!(source.contains("apply_drag_selection(")); + assert!(source.contains("replace_with_span(&mut self.selected_indices")); + assert!(source.contains(".apply_drag_to(row_ix, &mut self.selected_indices")); assert!(source.contains("exceeds_drag_threshold(")); assert!(source.contains(".on_mouse_move(cx.listener(")); // 行内释放走 on_row_mouse_up,面板根节点兜住“拖到行外松手” @@ -65,12 +65,3 @@ fn object_rows_wire_keyboard_and_mouse_range_selection() { assert!(source.contains("fn on_panel_mouse_up(")); assert!(source.contains("fn end_row_drag(")); } - -#[test] -fn select_all_asks_host_to_load_rows_that_have_no_open_tab() { - let source = include_str!("database_objects_tab.rs"); - - assert!(source.contains("DatabaseObjectsEvent::SelectAllInScope")); - assert!(source.contains("fn selection_scope(&self)")); - assert!(source.contains("fn select_rows_matching_nodes(")); -} diff --git a/crates/db_view/src/db_tree_event.rs b/crates/db_view/src/db_tree_event.rs index ee4ab65e9..6021ec1d2 100644 --- a/crates/db_view/src/db_tree_event.rs +++ b/crates/db_view/src/db_tree_event.rs @@ -1,6 +1,4 @@ -use crate::database_objects_tab::{ - DatabaseObjectsBatchAction, DatabaseObjectsEvent, DatabaseObjectsSelectionScope, -}; +use crate::database_objects_tab::{DatabaseObjectsBatchAction, DatabaseObjectsEvent}; use crate::db_tree_view::SqlDumpMode; use crate::{ database_objects_tab::DatabaseObjectsPanel, @@ -560,7 +558,6 @@ impl DatabaseEventHandler { let global_state = global_state_for_objects.clone(); let tree_view = tree_view_for_objects.clone(); let execution_history = execution_history_for_objects.clone(); - let db_objects_for_scope = _db_objects.clone(); match event { DatabaseObjectsEvent::TreeEvent { event } => { @@ -776,14 +773,6 @@ impl DatabaseEventHandler { cx, ); } - DatabaseObjectsEvent::SelectAllInScope { scope } => { - Self::handle_select_all_in_scope( - scope.clone(), - tree_view, - db_objects_for_scope.clone(), - cx, - ); - } } }, ); @@ -2635,58 +2624,6 @@ impl DatabaseEventHandler { }); } - /// 全选当前对象列表时,补全树中“尚未展开”的同级对象节点。 - /// - /// 对象页签只在树节点已加载子节点后才展示完整清单,因此用户刚切到某个 - /// 数据库/schema 时可能只看到部分对象。这里按范围重新加载容器子节点, - /// 让 Ctrl+A 覆盖到全部对象。 - fn handle_select_all_in_scope( - scope: DatabaseObjectsSelectionScope, - tree_view: Entity, - database_objects: Entity, - cx: &mut App, - ) { - let Some(container_node_id) = scope.container_node_id else { - return; - }; - let Some(container) = tree_view.read(cx).node(&container_node_id) else { - return; - }; - if container.children_loaded { - return; - } - - let expected_type = scope.node_type; - let connection_id = container.connection_id.clone(); - let global_state = cx.global::().clone(); - - cx.spawn(async move |cx: &mut AsyncApp| { - let children = global_state - .load_node_children(cx, connection_id, container) - .await; - let Ok(children) = children else { - return; - }; - let node_ids: Vec = children - .iter() - .filter(|child| child.node_type == expected_type) - .map(|child| child.id.clone()) - .collect(); - if node_ids.is_empty() { - return; - } - - // 树节点缓存复用同一份 children,避免重复请求 - let _ = tree_view.update(cx, |tree, _cx| { - tree.store_loaded_children(&container_node_id, children); - }); - let _ = database_objects.update(cx, |objects, cx| { - objects.select_rows_matching_nodes(&node_ids, cx); - }); - }) - .detach(); - } - fn handle_batch_action( action: DatabaseObjectsBatchAction, nodes: Vec, diff --git a/crates/db_view/src/db_tree_view.rs b/crates/db_view/src/db_tree_view.rs index 8686427ca..64cb09af6 100644 --- a/crates/db_view/src/db_tree_view.rs +++ b/crates/db_view/src/db_tree_view.rs @@ -1485,30 +1485,6 @@ impl DbTreeView { self.db_nodes.get(node_id).cloned() } - /// 把外部加载到的子节点写回树缓存。 - /// - /// 用于对象页签全选时复用同一份加载结果,避免重复请求数据库。 - pub fn store_loaded_children(&mut self, node_id: &str, children: Vec) { - self.loaded_children.insert(node_id.to_string()); - self.loading_nodes.remove(node_id); - self.error_nodes.remove(node_id); - - fn insert_nodes_recursive(db_nodes: &mut HashMap, node: &DbNode) { - db_nodes.insert(node.id.clone(), node.clone()); - for child in &node.children { - insert_nodes_recursive(db_nodes, child); - } - } - for child in &children { - insert_nodes_recursive(&mut self.db_nodes, child); - } - - if let Some(parent) = self.db_nodes.get_mut(node_id) { - parent.children = children; - parent.children_loaded = true; - } - } - /// 刷新指定节点及其子节点 /// /// 这个方法会: diff --git a/crates/db_view/src/object_list_selection.rs b/crates/db_view/src/object_list_selection.rs index 693126056..9d28901a3 100644 --- a/crates/db_view/src/object_list_selection.rs +++ b/crates/db_view/src/object_list_selection.rs @@ -4,20 +4,24 @@ //! 但删除等批量操作针对的是真实节点,所以选择集必须以“可见行序号”为基准, //! 由调用方再用 `filtered_rows` 映射回原始行。 //! -//! 拖选需要区分“尚未拖动”和“拖到同一行”,因此这里把按下点、拖选锚点 -//! 和最近一次悬停行显式建模,避免把单击误判成拖选。 +//! 拖选需要区分“尚未拖动”和“拖到同一行”,因此这里把按下行、拖选锚点和 +//! 加选基准快照显式建模,避免把单击误判成拖选。 +//! +//! 注意:gpui 的鼠标事件只发给指针命中的元素,指针在列表外松开时视图收不到 +//! `mouse_up`(详见 `DatabaseObjects::end_row_drag` 的说明),所以每次按下都 +//! 必须能重新起锚,模型不依赖“一定会收到释放事件”。 use std::collections::HashSet; /// 鼠标行选择的一次交互状态。 -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct RowDragState { - /// 已按下但尚未判定是否为拖选 + /// 本次按下的行;为 `None` 表示没有进行中的按下或拖选 pressed: Option, - /// 拖选锚点行 + /// 拖选锚点行,进入拖选后固定为按下行 anchor: Option, - /// 最近一次悬停行 - last_hovered: Option, + /// Cmd/Ctrl 加选时按下瞬间的选择集快照,拖选区间叠加在它之上 + additive_base: Option>, } impl RowDragState { @@ -29,42 +33,54 @@ impl RowDragState { self.anchor.is_some() } - /// 记录按下行。已处于拖选中时忽略,避免拖拽过程中重新起锚。 - pub fn press(&mut self, row_ix: usize, additive: bool) -> PressOutcome { - if self.is_dragging() { - return PressOutcome::Ignored; - } + /// 记录按下行,开始一次新的交互。 + /// + /// 这里无条件丢弃上一次交互的锚点:指针在列表外松开时拿不到 `mouse_up`, + /// 沿用旧锚点会让下一次拖选截出错误的区间。 + pub fn press(&mut self, row_ix: usize, additive_base: Option>) { self.pressed = Some(row_ix); - self.last_hovered = Some(row_ix); - PressOutcome::Pressed { additive } + self.anchor = None; + self.additive_base = additive_base; } - /// 悬停到某行。返回是否首次进入拖选状态。 - pub fn hover(&mut self, row_ix: usize) -> bool { - if self.pressed.is_none() || self.last_hovered == Some(row_ix) { - return false; - } - self.last_hovered = Some(row_ix); + /// 进入拖选状态:把按下行确立为锚点,返回锚点行。 + pub fn start_drag(&mut self) -> Option { if self.anchor.is_none() { self.anchor = self.pressed; - return true; } - false + self.anchor + } + + /// 拖选到 `target` 行时重算选择集。 + /// + /// 普通拖选替换整个选择集;Cmd/Ctrl 加选时在按下快照之上叠加区间, + /// 这样来回拖动时区间能正常收缩,而不是只增不减。 + pub fn apply_drag_to( + &self, + target: usize, + selected: &mut HashSet, + visible_row_count: usize, + ) { + let Some(anchor) = self.anchor else { + return; + }; + let span = VisibleRowSpan::new(anchor, target, visible_row_count); + match self.additive_base.as_ref() { + Some(base) => extend_with_span(selected, base, span), + None => replace_with_span(selected, span), + } } /// 结束交互,返回拖选锚点(若有)。 pub fn release(&mut self) -> Option { let anchor = self.anchor.take(); - self.pressed = None; - self.last_hovered = None; + *self = Self::default(); anchor } /// 取消拖选(例如失去焦点),不改变已确认的选择集。 pub fn cancel(&mut self) { - self.pressed = None; - self.anchor = None; - self.last_hovered = None; + *self = Self::default(); } } @@ -79,47 +95,50 @@ pub fn exceeds_drag_threshold(origin: (f32, f32), position: (f32, f32)) -> bool moved >= ROW_DRAG_THRESHOLD_PX } +/// 一段连续的可见行(闭区间),构造时已 clamp 到可见行范围。 #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum PressOutcome { - /// 已记录按下点,但尚未确定是单击还是拖选 - Pressed { additive: bool }, - /// 拖选中,忽略这次按下 - Ignored, +pub struct VisibleRowSpan { + start: usize, + end: usize, } -/// 用锚点到目标行的整段区间替换选择集。 -pub fn apply_row_range(selected: &mut HashSet, anchor: usize, target: usize) { - selected.clear(); - let (start, end) = if anchor <= target { - (anchor, target) - } else { - (target, anchor) - }; - selected.extend(start..=end); +impl VisibleRowSpan { + /// 由锚点行与目标行构造;列表为空时返回 `None`。 + pub fn new(anchor: usize, target: usize, visible_row_count: usize) -> Option { + let last = visible_row_count.checked_sub(1)?; + let anchor = anchor.min(last); + let target = target.min(last); + Some(Self { + start: anchor.min(target), + end: anchor.max(target), + }) + } } -/// 全选当前可见行。 -pub fn select_all_rows(selected: &mut HashSet, visible_row_count: usize) { +/// 用区间替换选择集;`span` 为 `None`(列表为空)时清空选择。 +pub fn replace_with_span(selected: &mut HashSet, span: Option) { selected.clear(); - selected.extend(0..visible_row_count); + if let Some(span) = span { + selected.extend(span.start..=span.end); + } } -/// 拖选结束时的最终选择集:从锚点到目标行的整段。 -pub fn apply_drag_selection( +/// 在 `base` 之上叠加区间(Cmd/Ctrl 加选拖选)。 +pub fn extend_with_span( selected: &mut HashSet, - anchor: usize, - target: usize, - visible_row_count: usize, + base: &HashSet, + span: Option, ) { - if visible_row_count == 0 { - selected.clear(); - return; - } - apply_row_range( - selected, - anchor.min(visible_row_count - 1), - target.min(visible_row_count - 1), - ); + selected.clone_from(base); + if let Some(span) = span { + selected.extend(span.start..=span.end); + } +} + +/// 全选当前可见行。 +pub fn select_all_rows(selected: &mut HashSet, visible_row_count: usize) { + selected.clear(); + selected.extend(0..visible_row_count); } #[cfg(test)] @@ -141,22 +160,34 @@ mod tests { } #[test] - fn apply_drag_selection_keeps_the_full_span_in_both_directions() { + fn replace_with_span_keeps_the_full_span_in_both_directions() { let mut selected = HashSet::new(); - apply_drag_selection(&mut selected, 4, 1, 10); + replace_with_span(&mut selected, VisibleRowSpan::new(4, 1, 10)); assert_eq!(HashSet::from([1, 2, 3, 4]), selected); - apply_drag_selection(&mut selected, 1, 4, 10); + replace_with_span(&mut selected, VisibleRowSpan::new(1, 4, 10)); assert_eq!(HashSet::from([1, 2, 3, 4]), selected); } #[test] - fn apply_drag_selection_clamps_to_visible_rows() { + fn replace_with_span_clamps_to_visible_rows() { let mut selected = HashSet::new(); - apply_drag_selection(&mut selected, 0, 99, 4); + replace_with_span(&mut selected, VisibleRowSpan::new(0, 99, 4)); assert_eq!(HashSet::from([0, 1, 2, 3]), selected); } + #[test] + fn visible_row_span_requires_a_non_empty_list() { + assert_eq!(None, VisibleRowSpan::new(0, 3, 0)); + } + + #[test] + fn replace_with_span_clears_selection_when_list_is_empty() { + let mut selected = HashSet::from([1, 2]); + replace_with_span(&mut selected, None); + assert!(selected.is_empty()); + } + #[test] fn drag_threshold_ignores_tiny_pointer_wobble() { assert!(!exceeds_drag_threshold((10.0, 10.0), (10.5, 10.5))); @@ -171,48 +202,79 @@ mod tests { } #[test] - fn press_outcome_records_additive_modifier() { + fn press_starts_from_clean_state_until_drag_begins() { let mut state = RowDragState::default(); - assert_eq!( - PressOutcome::Pressed { additive: true }, - state.press(2, true) - ); + state.press(2, None); assert_eq!(Some(2), state.pressed()); assert!(!state.is_dragging()); + assert_eq!(Some(2), state.start_drag()); + assert!(state.is_dragging()); } #[test] - fn hover_starts_dragging_once_and_keeps_the_original_anchor() { + fn press_re_anchors_after_a_missed_mouse_up() { let mut state = RowDragState::default(); - state.press(3, false); + state.press(1, None); + assert_eq!(Some(1), state.start_drag()); - assert!(state.hover(4)); - assert!(!state.hover(6)); - // 锚点保持按下行,悬停行持续跟随,拖选才能覆盖锚点到当前位置的整段 - assert_eq!(Some(3), state.pressed()); - assert_eq!(Some(6), state.last_hovered); + // 指针在列表外松开:视图收不到 mouse_up,随后直接在另一行按下 + state.press(5, None); + assert_eq!(Some(5), state.start_drag()); } #[test] - fn hover_without_press_never_starts_dragging() { + fn drag_without_press_never_starts_dragging() { let mut state = RowDragState::default(); - assert!(!state.hover(5)); + assert_eq!(None, state.start_drag()); assert!(!state.is_dragging()); } #[test] - fn drag_ignores_a_second_press_until_release() { + fn plain_drag_replaces_the_selection() { + let mut selected = HashSet::from([7]); + let mut state = RowDragState::default(); + state.press(4, None); + state.start_drag(); + + state.apply_drag_to(1, &mut selected, 10); + assert_eq!(HashSet::from([1, 2, 3, 4]), selected); + } + + #[test] + fn additive_drag_keeps_the_press_time_snapshot() { + let mut selected = HashSet::from([7]); + let mut state = RowDragState::default(); + state.press(1, Some(selected.clone())); + state.start_drag(); + + state.apply_drag_to(3, &mut selected, 10); + assert_eq!(HashSet::from([1, 2, 3, 7]), selected); + + // 往回拖时区间收缩,但快照里的第 7 行不会被丢掉 + state.apply_drag_to(2, &mut selected, 10); + assert_eq!(HashSet::from([1, 2, 7]), selected); + } + + #[test] + fn drag_on_an_empty_list_clears_the_selection() { + let mut selected = HashSet::from([3]); + let mut state = RowDragState::default(); + state.press(0, None); + state.start_drag(); + + state.apply_drag_to(0, &mut selected, 0); + assert!(selected.is_empty()); + } + + #[test] + fn release_clears_the_interaction_and_returns_the_anchor() { let mut state = RowDragState::default(); - state.press(1, false); - state.hover(2); + state.press(1, Some(HashSet::from([1]))); + state.start_drag(); - assert_eq!(PressOutcome::Ignored, state.press(5, false)); - assert_eq!(Some(1), state.pressed()); assert_eq!(Some(1), state.release()); assert!(!state.is_dragging()); - assert_eq!( - PressOutcome::Pressed { additive: false }, - state.press(9, false) - ); + assert_eq!(None, state.pressed()); + assert_eq!(None, state.release()); } }