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..1f65584bd 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, VisibleRowSpan, exceeds_drag_threshold, replace_with_span, 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}; @@ -289,6 +294,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 +315,101 @@ 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.drag_hover_row = None; + self.extend_selection_to(row_ix, cx); + return; + } + + 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(); + } + + /// 行悬停:超过拖选阈值后把锚点到当前位置的整段行纳入选择集。 + 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; + } + } + + // 先确立锚点,再判断是否需要重算区间:首个越过阈值的悬停行也要参与计算 + 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 visible_row_count = self.visible_row_count(); + self.drag_state + .apply_drag_to(row_ix, &mut self.selected_indices, 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); + } + + /// 结束拖选但不改变已确认的选择集。 + /// + /// 指针在列表外松开(拖出列表、切到别的面板)时,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(); + 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 +511,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 +657,48 @@ impl DatabaseObjects { } } + /// 可见行总数(已应用搜索过滤)。 + fn visible_row_count(&self) -> usize { + self.filtered_rows.len() + } + + /// 全选当前可见行(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(); + } + + /// 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) => { + 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::Nearest); + cx.notify(); + } + fn apply_filter(&mut self) { if self.search_query.is_empty() { self.filtered_rows = (0..self.rows.len()).collect(); @@ -1396,6 +1550,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 +1609,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 +1692,7 @@ impl Render for DatabaseObjects { }) .flex_grow_1() .size_full() + .track_scroll(&self.list_scroll_handle) .with_sizing_behavior(ListSizingBehavior::Auto), ), ), @@ -1538,6 +1718,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..68773e3d5 100644 --- a/crates/db_view/src/database_objects_tab_tests.rs +++ b/crates/db_view/src/database_objects_tab_tests.rs @@ -42,3 +42,26 @@ 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("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,面板根节点兜住“拖到行外松手” + 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(")); +} diff --git a/crates/db_view/src/db_tree_view.rs b/crates/db_view/src/db_tree_view.rs index 4bb8c4129..64cb09af6 100644 --- a/crates/db_view/src/db_tree_view.rs +++ b/crates/db_view/src/db_tree_view.rs @@ -1480,6 +1480,11 @@ impl DbTreeView { } } + /// 取已加载到树中的节点。 + pub fn node(&self, node_id: &str) -> Option { + self.db_nodes.get(node_id).cloned() + } + /// 刷新指定节点及其子节点 /// /// 这个方法会: 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..9d28901a3 --- /dev/null +++ b/crates/db_view/src/object_list_selection.rs @@ -0,0 +1,280 @@ +//! 数据库对象列表的批量选择模型。 +//! +//! 全选、Shift 区间选择和鼠标拖选都作用在**可见行**(过滤后的行)上, +//! 但删除等批量操作针对的是真实节点,所以选择集必须以“可见行序号”为基准, +//! 由调用方再用 `filtered_rows` 映射回原始行。 +//! +//! 拖选需要区分“尚未拖动”和“拖到同一行”,因此这里把按下行、拖选锚点和 +//! 加选基准快照显式建模,避免把单击误判成拖选。 +//! +//! 注意:gpui 的鼠标事件只发给指针命中的元素,指针在列表外松开时视图收不到 +//! `mouse_up`(详见 `DatabaseObjects::end_row_drag` 的说明),所以每次按下都 +//! 必须能重新起锚,模型不依赖“一定会收到释放事件”。 + +use std::collections::HashSet; + +/// 鼠标行选择的一次交互状态。 +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct RowDragState { + /// 本次按下的行;为 `None` 表示没有进行中的按下或拖选 + pressed: Option, + /// 拖选锚点行,进入拖选后固定为按下行 + anchor: Option, + /// Cmd/Ctrl 加选时按下瞬间的选择集快照,拖选区间叠加在它之上 + additive_base: Option>, +} + +impl RowDragState { + pub fn pressed(&self) -> Option { + self.pressed + } + + pub fn is_dragging(&self) -> bool { + self.anchor.is_some() + } + + /// 记录按下行,开始一次新的交互。 + /// + /// 这里无条件丢弃上一次交互的锚点:指针在列表外松开时拿不到 `mouse_up`, + /// 沿用旧锚点会让下一次拖选截出错误的区间。 + pub fn press(&mut self, row_ix: usize, additive_base: Option>) { + self.pressed = Some(row_ix); + self.anchor = None; + self.additive_base = additive_base; + } + + /// 进入拖选状态:把按下行确立为锚点,返回锚点行。 + pub fn start_drag(&mut self) -> Option { + if self.anchor.is_none() { + self.anchor = self.pressed; + } + 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 = Self::default(); + anchor + } + + /// 取消拖选(例如失去焦点),不改变已确认的选择集。 + pub fn cancel(&mut self) { + *self = Self::default(); + } +} + +/// 判定「点击」升级为「拖选」的最小像素位移,避免手抖把单击变成区间选择。 +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 +} + +/// 一段连续的可见行(闭区间),构造时已 clamp 到可见行范围。 +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct VisibleRowSpan { + start: usize, + end: usize, +} + +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), + }) + } +} + +/// 用区间替换选择集;`span` 为 `None`(列表为空)时清空选择。 +pub fn replace_with_span(selected: &mut HashSet, span: Option) { + selected.clear(); + if let Some(span) = span { + selected.extend(span.start..=span.end); + } +} + +/// 在 `base` 之上叠加区间(Cmd/Ctrl 加选拖选)。 +pub fn extend_with_span( + selected: &mut HashSet, + base: &HashSet, + span: Option, +) { + 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)] +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 replace_with_span_keeps_the_full_span_in_both_directions() { + let mut selected = HashSet::new(); + replace_with_span(&mut selected, VisibleRowSpan::new(4, 1, 10)); + assert_eq!(HashSet::from([1, 2, 3, 4]), selected); + + replace_with_span(&mut selected, VisibleRowSpan::new(1, 4, 10)); + assert_eq!(HashSet::from([1, 2, 3, 4]), selected); + } + + #[test] + fn replace_with_span_clamps_to_visible_rows() { + let mut selected = HashSet::new(); + 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))); + 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_starts_from_clean_state_until_drag_begins() { + let mut state = RowDragState::default(); + 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 press_re_anchors_after_a_missed_mouse_up() { + let mut state = RowDragState::default(); + state.press(1, None); + assert_eq!(Some(1), state.start_drag()); + + // 指针在列表外松开:视图收不到 mouse_up,随后直接在另一行按下 + state.press(5, None); + assert_eq!(Some(5), state.start_drag()); + } + + #[test] + fn drag_without_press_never_starts_dragging() { + let mut state = RowDragState::default(); + assert_eq!(None, state.start_drag()); + assert!(!state.is_dragging()); + } + + #[test] + 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, Some(HashSet::from([1]))); + state.start_drag(); + + assert_eq!(Some(1), state.release()); + assert!(!state.is_dragging()); + assert_eq!(None, state.pressed()); + assert_eq!(None, state.release()); + } +} 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");