diff --git a/apps/desktop/src/command_palette/actions.rs b/apps/desktop/src/command_palette/actions.rs deleted file mode 100644 index 81a977b..0000000 --- a/apps/desktop/src/command_palette/actions.rs +++ /dev/null @@ -1,19 +0,0 @@ -use gpui::{App, KeyBinding, actions}; - -actions!(command_palette, [PaletteSelectUp, PaletteSelectDown]); - -#[derive(Clone, PartialEq, Eq, serde::Deserialize, gpui::Action)] -#[action(namespace = command_palette, no_json)] -pub(super) struct PaletteConfirm { - pub secondary: bool, -} - -pub fn init(cx: &mut App) { - let ctx = Some("CommandPalette"); - cx.bind_keys([ - KeyBinding::new("up", PaletteSelectUp, ctx), - KeyBinding::new("down", PaletteSelectDown, ctx), - KeyBinding::new("enter", PaletteConfirm { secondary: false }, ctx), - KeyBinding::new("secondary-enter", PaletteConfirm { secondary: true }, ctx), - ]); -} diff --git a/apps/desktop/src/command_palette/entries.rs b/apps/desktop/src/command_palette/entries.rs new file mode 100644 index 0000000..3da8fac --- /dev/null +++ b/apps/desktop/src/command_palette/entries.rs @@ -0,0 +1,101 @@ +use gpui::{AnyElement, App, Entity, SharedString, Window, div, prelude::*, px}; +use gpui_component::command::{Command, CommandGroup, CommandItem, CommandState}; +use gpui_component::{ActiveTheme, IndexPath, h_flex, v_flex}; + +use crate::widgets::kbd; + +use super::format; +use super::types::{PaletteResult, PaletteSection}; + +pub fn command_for_sections( + state: &Entity, + sections: &[PaletteSection], + on_query: impl Fn(&str, &mut Window, &mut App) + 'static, + on_confirm: impl Fn(IndexPath, &mut Window, &mut App) + 'static, + on_cancel: impl Fn(&mut Window, &mut App) + 'static, +) -> Command { + let mut command = Command::new(state) + .filterable(false) + .placeholder("Search tables, queries, history…") + .max_h(px(360.)) + .w(px(560.)) + .on_query(on_query) + .on_confirm(on_confirm) + .on_cancel(on_cancel) + .empty(|_, _, cx| { + v_flex() + .w_full() + .items_center() + .gap_2() + .py_6() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child("No results found.") + }) + .footer(|_, _, cx| command_footer(cx)); + for section in sections { + command = command.group( + CommandGroup::new() + .label(section.heading) + .items(section.items.iter().map(command_item)), + ); + } + command +} + +fn command_item(result: &PaletteResult) -> CommandItem { + let label: SharedString = format::palette_single_line(&result.label, 120).into(); + let meta: SharedString = format::palette_meta(&result.conn_label, &result.sublabel).into(); + CommandItem::new().label(label.clone()).child(move |_, cx| { + h_flex() + .w_full() + .gap_2() + .items_center() + .overflow_hidden() + .child( + div() + .flex_1() + .min_w_0() + .overflow_hidden() + .text_sm() + .truncate() + .child(label.clone()), + ) + .child( + div() + .flex_shrink_0() + .max_w(px(220.0)) + .overflow_hidden() + .text_xs() + .text_color(cx.theme().muted_foreground) + .truncate() + .child(meta.clone()), + ) + }) +} + +fn command_footer(cx: &mut App) -> AnyElement { + let muted = cx.theme().muted_foreground; + let hint = |stroke: &str| kbd(stroke).outline().text_color(cx.theme().foreground); + h_flex() + .flex_shrink_0() + .w_full() + .px_3() + .py_2() + .gap_2() + .items_center() + .border_t_1() + .border_color(cx.theme().border) + .text_xs() + .text_color(muted) + .child(hint("up")) + .child(hint("down")) + .child("navigate") + .child("·") + .child(hint("enter")) + .child("open") + .child("·") + .child(hint("escape")) + .child("dismiss") + .into_any_element() +} diff --git a/apps/desktop/src/command_palette/format.rs b/apps/desktop/src/command_palette/format.rs index 61f112a..40f8add 100644 --- a/apps/desktop/src/command_palette/format.rs +++ b/apps/desktop/src/command_palette/format.rs @@ -1,5 +1,14 @@ //! Single-line display helpers for palette result labels. +/// Trailing meta for a palette row (VS Code style). +pub fn palette_meta(conn_label: &str, sublabel: &str) -> String { + if conn_label.is_empty() || sublabel.contains(conn_label) { + sublabel.to_string() + } else { + format!("{conn_label} · {sublabel}") + } +} + /// Collapse whitespace (including newlines/tabs) and truncate for one-line display. pub fn palette_single_line(text: &str, max_chars: usize) -> String { let collapsed: String = text.split_whitespace().collect::>().join(" "); @@ -32,4 +41,11 @@ mod tests { assert!(palette_single_line(long, 20).ends_with('…')); assert!(palette_single_line(long, 20).chars().count() <= 20); } + + #[test] + fn meta_skips_duplicate_connection_label() { + assert_eq!(palette_meta("", "history · local"), "history · local"); + assert_eq!(palette_meta("local", "history · local"), "history · local"); + assert_eq!(palette_meta("prod", "table"), "prod · table"); + } } diff --git a/apps/desktop/src/command_palette/mod.rs b/apps/desktop/src/command_palette/mod.rs index 51fc991..fe36563 100644 --- a/apps/desktop/src/command_palette/mod.rs +++ b/apps/desktop/src/command_palette/mod.rs @@ -3,36 +3,31 @@ //! Dependency rule: may use `connection/`, `query_store/`, `workspace/{connection_tree, tab_spec, //! project_query}`, and `widgets/`. Must not depend on engine modules or dock internals. -mod actions; +mod entries; mod format; mod render; mod search; mod selection; mod types; -pub use actions::init; -pub use types::{PaletteEvent, PaletteResult, WorkspacePaletteAction}; +pub use types::{PaletteEvent, WorkspacePaletteAction}; -use gpui::{ - App, AppContext as _, Context, Entity, EventEmitter, FocusHandle, Focusable, ScrollHandle, - Subscription, Window, point, px, -}; -use gpui_component::input::{InputEvent, InputState}; +use gpui::{App, AppContext as _, Context, Entity, EventEmitter, FocusHandle, Focusable, Window}; +use gpui_component::IndexPath; +use gpui_component::command::CommandState; use crate::connection::registry::ConnectionRegistry; use crate::workspace::connection_tree::ConnectionTree; +use types::PaletteSection; + pub struct CommandPalette { registry: Entity, connection_tree: Entity, - search_input: Entity, - results: Vec, - selected: usize, + command_state: Entity, + sections: Vec, visible: bool, focus_handle: FocusHandle, - results_scroll: ScrollHandle, - pending_scroll_to: Option, - _search_subscription: Subscription, } impl CommandPalette { @@ -42,29 +37,17 @@ impl CommandPalette { window: &mut Window, cx: &mut Context, ) -> Self { - let search_input = cx - .new(|cx| InputState::new(window, cx).placeholder("Search tables, queries, history…")); - let _search_subscription = - cx.subscribe_in(&search_input, window, Self::on_search_input_event); - + let command_state = cx.new(|cx| CommandState::new(window, cx)); Self { registry, connection_tree, - search_input, - results: vec![], - selected: 0, + command_state, + sections: vec![], visible: false, focus_handle: cx.focus_handle(), - results_scroll: ScrollHandle::new(), - pending_scroll_to: None, - _search_subscription, } } - fn query(&self, cx: &App) -> String { - self.search_input.read(cx).value().trim().to_string() - } - pub fn is_visible(&self) -> bool { self.visible } @@ -72,16 +55,12 @@ impl CommandPalette { pub fn toggle(&mut self, window: &mut Window, cx: &mut Context) { self.visible = !self.visible; if self.visible { - self.search_input.update(cx, |input, cx| { - input.set_value("", window, cx); + self.command_state.update(cx, |state, cx| { + state.set_query("", window, cx); }); - self.selected = 0; - self.results_scroll.set_offset(point(px(0.0), px(0.0))); self.refresh_results(cx); - self.search_input - .read(cx) - .focus_handle(cx) - .focus(window, cx); + let focus = self.command_state.read(cx).focus_handle(cx); + focus.focus(window, cx); } cx.notify(); } @@ -91,61 +70,32 @@ impl CommandPalette { cx.notify(); } - fn open_selected(&mut self, secondary: bool, cx: &mut Context) { - let Some(entry) = self.results.get(self.selected).cloned() else { - return; - }; - selection::emit_selection(&entry, secondary, cx); - self.dismiss(cx); - } - - fn on_search_input_event( - &mut self, - _input: &Entity, - event: &InputEvent, - _window: &mut Window, - cx: &mut Context, - ) { - if let InputEvent::Change = event { - self.selected = 0; - self.results_scroll.set_offset(point(px(0.0), px(0.0))); - self.refresh_results(cx); - } + fn on_query(&mut self, query: &str, cx: &mut Context) { + self.replace_sections(query, cx); } - fn select_prev(&mut self, cx: &mut Context) { - if self.results.is_empty() { + fn confirm(&mut self, index: IndexPath, cx: &mut Context) { + let Some(entry) = search::item_at(&self.sections, index.section, index.row).cloned() else { return; - } - self.selected = self.selected.saturating_sub(1); - self.pending_scroll_to = Some(self.selected); - cx.notify(); - } - - fn select_next(&mut self, cx: &mut Context) { - if self.results.is_empty() { - return; - } - let max = self.results.len() - 1; - self.selected = (self.selected + 1).min(max); - self.pending_scroll_to = Some(self.selected); - cx.notify(); + }; + selection::emit_selection(&entry, cx); + self.dismiss(cx); } - pub(crate) fn take_pending_scroll(&mut self) -> Option { - self.pending_scroll_to.take() + fn refresh_results(&mut self, cx: &mut Context) { + let query = self.command_state.read(cx).query(cx); + self.replace_sections(query.trim(), cx); } - fn refresh_results(&mut self, cx: &mut Context) { - self.results = search::collect_results( + fn replace_sections(&mut self, query: &str, cx: &mut Context) { + self.sections = search::collect_sections( search::SearchContext { registry: &self.registry, connection_tree: &self.connection_tree, }, - &self.query(cx), + query, cx, ); - self.selected = self.selected.min(self.results.len().saturating_sub(1)); cx.notify(); } } @@ -155,7 +105,7 @@ impl EventEmitter for CommandPalette {} impl Focusable for CommandPalette { fn focus_handle(&self, cx: &App) -> FocusHandle { if self.visible { - self.search_input.read(cx).focus_handle(cx) + self.command_state.read(cx).focus_handle(cx) } else { self.focus_handle.clone() } diff --git a/apps/desktop/src/command_palette/render.rs b/apps/desktop/src/command_palette/render.rs index b869f3d..e8ced43 100644 --- a/apps/desktop/src/command_palette/render.rs +++ b/apps/desktop/src/command_palette/render.rs @@ -1,28 +1,18 @@ -use gpui::{Context, IntoElement, MouseButton, Render, SharedString, Window, div, prelude::*, px}; -use gpui_component::{ActiveTheme, Icon, IconName, input::Input, scroll::Scrollbar, v_flex}; - -use crate::widgets::list_row::palette_result_row; -use crate::widgets::palette_footer_hints; +use gpui::{Context, IntoElement, MouseButton, Render, Window, div, prelude::*, px}; +use gpui_component::v_flex; use super::CommandPalette; -use super::actions::{PaletteConfirm, PaletteSelectDown, PaletteSelectUp}; -use super::format; - -const PALETTE_LIST_H: f32 = 360.0; impl Render for CommandPalette { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { if !self.visible { return div().into_any_element(); } - if let Some(ix) = self.take_pending_scroll() { - self.results_scroll.scroll_to_item(ix); - } - - let theme = cx.theme(); - let muted = theme.muted_foreground; - let fg = theme.foreground; + let palette = cx.weak_entity(); + let query_owner = palette.clone(); + let confirm_owner = palette.clone(); + let cancel_owner = palette.clone(); div() .absolute() @@ -40,100 +30,20 @@ impl Render for CommandPalette { .top(px(120.0)) .left_1_2() .ml(px(-280.0)) - .w(px(560.0)) - .max_h(px(480.0)) - .overflow_hidden() - .track_focus(&self.focus_handle) - .key_context("CommandPalette") - .on_action(cx.listener(|this, _: &PaletteSelectUp, _, cx| { - this.select_prev(cx); - })) - .on_action(cx.listener(|this, _: &PaletteSelectDown, _, cx| { - this.select_next(cx); - })) - .on_action(cx.listener(|this, action: &PaletteConfirm, _, cx| { - this.open_selected(action.secondary, cx); - })) - .on_mouse_down( - MouseButton::Left, - cx.listener(|_, _, _, cx| { - cx.stop_propagation(); - }), - ) - .bg(theme.popover) - .border_1() - .border_color(theme.border) - .rounded_lg() - .shadow_lg() - .child( - div() - .flex_shrink_0() - .p_2() - .border_b_1() - .border_color(theme.border) - .child( - Input::new(&self.search_input) - .appearance(false) - .p_0() - .prefix( - Icon::new(IconName::Search) - .text_color(theme.muted_foreground), - ), - ), - ) - .child( - div() - .relative() - .h(px(PALETTE_LIST_H)) - .child( - div() - .id("palette-results-scroll") - .track_scroll(&self.results_scroll) - .overflow_y_scroll() - .size_full() - .children({ - let results: Vec<_> = self - .results - .iter() - .enumerate() - .map(|(i, r)| { - let is_sel = i == self.selected; - let conn_label: SharedString = - r.conn_label.clone().into(); - let label: SharedString = - format::palette_single_line(&r.label, 120) - .into(); - let sublabel: SharedString = - r.sublabel.clone().into(); - (i, is_sel, conn_label, label, sublabel) - }) - .collect(); - results.into_iter().map( - |(i, is_sel, conn_label, label, sublabel)| { - palette_result_row( - ("palette-result", i), - is_sel, - conn_label, - label, - sublabel, - muted, - fg, - ) - .on_mouse_down( - MouseButton::Left, - cx.listener(move |this, _, _, cx| { - cx.stop_propagation(); - this.selected = i; - this.open_selected(false, cx); - }), - ) - }, - ) - }), - ) - .child(Scrollbar::vertical(&self.results_scroll)), - ) - .child(palette_footer_hints(window, cx)), + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .child(super::entries::command_for_sections( + &self.command_state, + &self.sections, + move |query, _, cx| { + _ = query_owner.update(cx, |this, cx| this.on_query(query, cx)); + }, + move |index, _, cx| { + _ = confirm_owner.update(cx, |this, cx| this.confirm(index, cx)); + }, + move |_, cx| { + _ = cancel_owner.update(cx, |this, cx| this.dismiss(cx)); + }, + )), ) .into_any_element() } diff --git a/apps/desktop/src/command_palette/search.rs b/apps/desktop/src/command_palette/search.rs index 1caecd9..ffeb798 100644 --- a/apps/desktop/src/command_palette/search.rs +++ b/apps/desktop/src/command_palette/search.rs @@ -11,21 +11,46 @@ use crate::workspace::connection_tree::ConnectionTree; use crate::workspace::project_query::target_hint; use crate::workspace::{QueryEditorInit, TabSpec}; -use super::types::{PaletteResult, ResultKind, WorkspacePaletteAction}; +use super::types::{PaletteResult, PaletteSection, ResultKind, WorkspacePaletteAction}; pub struct SearchContext<'a> { pub registry: &'a Entity, pub connection_tree: &'a Entity, } -pub fn collect_results(ctx: SearchContext<'_>, query: &str, cx: &App) -> Vec { +pub fn collect_sections(ctx: SearchContext<'_>, query: &str, cx: &App) -> Vec { let q = query.to_lowercase(); - let mut results = vec![]; - push_workspace_commands(&mut results, &q, cx); - push_schema_objects(&mut results, ctx.connection_tree, &q, cx); - push_saved_queries(&mut results, &q, cx); - push_history(&mut results, ctx.registry, &q, cx); - results + let mut commands = vec![]; + let mut schema = vec![]; + let mut queries = vec![]; + let mut history = vec![]; + push_workspace_commands(&mut commands, &q, cx); + push_schema_objects(&mut schema, ctx.connection_tree, &q, cx); + push_saved_queries(&mut queries, &q, cx); + push_history(&mut history, ctx.registry, &q, cx); + sections_from_parts(commands, schema, queries, history) +} + +pub fn sections_from_parts( + commands: Vec, + schema: Vec, + queries: Vec, + history: Vec, +) -> Vec { + [ + ("Commands", commands), + ("Schema", schema), + ("Queries", queries), + ("History", history), + ] + .into_iter() + .filter(|(_, items)| !items.is_empty()) + .map(|(heading, items)| PaletteSection { heading, items }) + .collect() +} + +pub fn item_at(sections: &[PaletteSection], section: usize, row: usize) -> Option<&PaletteResult> { + sections.get(section)?.items.get(row) } fn blank_command(action: WorkspacePaletteAction, label: &str, sublabel: &str) -> PaletteResult { @@ -248,4 +273,42 @@ mod tests { assert!(!wants_open_logs_command("catalog")); assert!(!wants_open_logs_command("onboarding")); } + + fn stub_result(kind: ResultKind, label: &str) -> PaletteResult { + PaletteResult { + kind, + label: label.into(), + sublabel: String::new(), + conn_label: String::new(), + spec: TabSpec::Home, + project_query_path: None, + command_action: None, + } + } + + #[test] + fn sections_omit_empty_groups_and_keep_index_paths_stable() { + let sections = sections_from_parts( + vec![stub_result(ResultKind::Command, "Show Home")], + vec![], + vec![stub_result(ResultKind::SavedQuery, "active")], + vec![], + ); + assert_eq!( + sections + .iter() + .map(|section| section.heading) + .collect::>(), + ["Commands", "Queries"] + ); + assert_eq!( + item_at(§ions, 0, 0).map(|item| item.label.as_str()), + Some("Show Home") + ); + assert_eq!( + item_at(§ions, 1, 0).map(|item| item.label.as_str()), + Some("active") + ); + assert!(item_at(§ions, 2, 0).is_none()); + } } diff --git a/apps/desktop/src/command_palette/selection.rs b/apps/desktop/src/command_palette/selection.rs index 7b4edb0..321d4f1 100644 --- a/apps/desktop/src/command_palette/selection.rs +++ b/apps/desktop/src/command_palette/selection.rs @@ -5,58 +5,134 @@ use crate::workspace::{QueryEditorInit, TabSpec}; use super::CommandPalette; use super::types::{PaletteEvent, PaletteResult, ResultKind}; -pub fn emit_selection(entry: &PaletteResult, secondary: bool, cx: &mut Context) { - match (&entry.kind, secondary) { - (ResultKind::Command, _) => { - if let Some(action) = entry.command_action.clone() { - cx.emit(PaletteEvent::WorkspaceAction(action)); - } +pub fn event_for_selection(entry: &PaletteResult) -> Option { + match entry.kind { + ResultKind::Command => entry + .command_action + .clone() + .map(PaletteEvent::WorkspaceAction), + ResultKind::History => { + let sql = history_sql(entry); + entry + .spec + .conn_id() + .cloned() + .map(|conn_id| PaletteEvent::InjectSql { conn_id, sql }) } - (ResultKind::History, false) => { - let sql = match &entry.spec { - TabSpec::QueryEditor { - init: QueryEditorInit::Sql { sql: Some(s), .. }, - .. - } => s.clone(), - TabSpec::QueryEditor { - init: - QueryEditorInit::MongoPipeline { - pipeline: Some(p), .. - }, - .. - } => p.clone(), - _ => entry.label.clone(), - }; - if let Some(conn_id) = entry.spec.conn_id().cloned() { - cx.emit(PaletteEvent::InjectSql { conn_id, sql }); - } + ResultKind::SavedQuery => Some(entry.project_query_path.as_ref().map_or_else( + || PaletteEvent::OpenTab(entry.spec.clone()), + |path| PaletteEvent::OpenProjectQuery(path.clone()), + )), + ResultKind::SchemaObject => Some(PaletteEvent::OpenTab(entry.spec.clone())), + } +} + +pub fn emit_selection(entry: &PaletteResult, cx: &mut Context) { + if let Some(event) = event_for_selection(entry) { + cx.emit(event); + } +} + +fn history_sql(entry: &PaletteResult) -> String { + match &entry.spec { + TabSpec::QueryEditor { + init: QueryEditorInit::Sql { sql: Some(s), .. }, + .. + } => s.clone(), + TabSpec::QueryEditor { + init: + QueryEditorInit::MongoPipeline { + pipeline: Some(p), .. + }, + .. + } => p.clone(), + _ => entry.label.clone(), + } +} + +#[cfg(test)] +mod tests { + use crate::connection::ConnectionId; + use crate::workspace::{QueryEditorInit, TabSpec}; + + use super::super::types::{PaletteEvent, PaletteResult, ResultKind, WorkspacePaletteAction}; + use super::event_for_selection; + + fn conn(id: &str) -> ConnectionId { + ConnectionId(id.into()) + } + + fn result(kind: ResultKind) -> PaletteResult { + PaletteResult { + kind, + label: "users".into(), + sublabel: "table · local".into(), + conn_label: String::new(), + spec: TabSpec::DataViewer { + conn_id: conn("local"), + object: "users".into(), + }, + project_query_path: None, + command_action: None, } - (ResultKind::SavedQuery, _) => { - if let Some(path) = &entry.project_query_path { - cx.emit(PaletteEvent::OpenProjectQuery(path.clone())); - } else { - cx.emit(PaletteEvent::OpenTab(entry.spec.clone())); + } + + #[test] + fn schema_object_opens_the_data_viewer() { + let event = event_for_selection(&result(ResultKind::SchemaObject)).unwrap(); + assert_eq!( + event, + PaletteEvent::OpenTab(TabSpec::DataViewer { + conn_id: conn("local"), + object: "users".into(), + }) + ); + } + + #[test] + fn history_injects_sql_into_the_matching_connection() { + let entry = PaletteResult { + kind: ResultKind::History, + label: "SELECT 1".into(), + sublabel: "history · local".into(), + conn_label: String::new(), + spec: TabSpec::QueryEditor { + conn_id: conn("local"), + init: QueryEditorInit::Sql { + sql: Some("SELECT 1".into()), + auto_run: false, + }, + }, + project_query_path: None, + command_action: None, + }; + assert_eq!( + event_for_selection(&entry).unwrap(), + PaletteEvent::InjectSql { + conn_id: conn("local"), + sql: "SELECT 1".into(), } - } - _ => { - let spec = match (&entry.kind, secondary) { - (ResultKind::SchemaObject, true) => { - if let Some(conn_id) = entry.spec.conn_id().cloned() { - let table = entry.label.clone(); - TabSpec::QueryEditor { - conn_id, - init: QueryEditorInit::Sql { - sql: Some(format!("SELECT * FROM {table} LIMIT 100")), - auto_run: false, - }, - } - } else { - entry.spec.clone() - } - } - _ => entry.spec.clone(), - }; - cx.emit(PaletteEvent::OpenTab(spec)); - } + ); + } + + #[test] + fn saved_query_opens_the_project_path() { + let mut entry = result(ResultKind::SavedQuery); + entry.label = "active users".into(); + entry.project_query_path = Some("queries/active.sql".into()); + assert_eq!( + event_for_selection(&entry).unwrap(), + PaletteEvent::OpenProjectQuery("queries/active.sql".into()) + ); + } + + #[test] + fn command_emits_the_workspace_action() { + let mut entry = result(ResultKind::Command); + entry.command_action = Some(WorkspacePaletteAction::OpenHome); + assert_eq!( + event_for_selection(&entry).unwrap(), + PaletteEvent::WorkspaceAction(WorkspacePaletteAction::OpenHome) + ); } } diff --git a/apps/desktop/src/command_palette/types.rs b/apps/desktop/src/command_palette/types.rs index b7b3a0c..f32725e 100644 --- a/apps/desktop/src/command_palette/types.rs +++ b/apps/desktop/src/command_palette/types.rs @@ -2,7 +2,7 @@ use crate::connection::ConnectionId; use crate::workspace::TabSpec; /// Emitted when the user picks a palette row — workspace opens the tab. -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq)] pub enum PaletteEvent { OpenTab(TabSpec), OpenProjectQuery(String), @@ -14,7 +14,7 @@ pub enum PaletteEvent { WorkspaceAction(WorkspacePaletteAction), } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq)] pub enum WorkspacePaletteAction { NewLooseQuery, NewCollection, @@ -48,3 +48,10 @@ pub enum ResultKind { History, Command, } + +/// One heading plus its rows, used as a `Command` group / `IndexPath` section. +#[derive(Clone)] +pub struct PaletteSection { + pub heading: &'static str, + pub items: Vec, +} diff --git a/apps/desktop/src/main.rs b/apps/desktop/src/main.rs index ac14773..89dd25e 100644 --- a/apps/desktop/src/main.rs +++ b/apps/desktop/src/main.rs @@ -54,7 +54,6 @@ fn main() { } fonts::register_bundled_fonts(cx); bindings::init(cx); - command_palette::init(cx); app::shell::init(cx); app::prefs::install(cx); diff --git a/apps/desktop/src/widgets/kbd.rs b/apps/desktop/src/widgets/kbd.rs index 0601ea8..b27b26c 100644 --- a/apps/desktop/src/widgets/kbd.rs +++ b/apps/desktop/src/widgets/kbd.rs @@ -4,7 +4,7 @@ use gpui::{prelude::*, *}; use gpui_component::{ActiveTheme, StyledExt, h_flex, kbd::Kbd, v_flex}; use crate::app::prefs; -use crate::bindings::{DismissCommandPalette, ToggleCommandPalette, ToggleSidebarRail}; +use crate::bindings::{ToggleCommandPalette, ToggleSidebarRail}; /// Unbound / literal keys — `Kbd` formats symbols vs labels per platform. pub fn kbd(stroke: &str) -> Kbd { @@ -66,38 +66,3 @@ fn shortcut_row_styled(label: &'static str, kbd_el: Kbd, muted: Hsla) -> impl In .child(div().text_xs().text_color(muted).child(label)) .child(kbd_el) } - -fn palette_hint_kbd(k: Kbd, cx: &App) -> Kbd { - k.outline().text_color(cx.theme().foreground) -} - -/// Command palette footer key hints. -pub fn palette_footer_hints(window: &Window, cx: &mut App) -> impl IntoElement { - let theme = cx.theme(); - let muted = theme.muted_foreground; - let dismiss = kbd_for_action(&DismissCommandPalette, window).unwrap_or_else(|| kbd("escape")); - h_flex() - .flex_shrink_0() - .w_full() - .px_3() - .py_2() - .gap_2() - .items_center() - .border_t_1() - .border_color(theme.border) - .bg(theme.muted.opacity(0.12)) - .text_xs() - .text_color(muted) - .child(palette_hint_kbd(kbd("up"), cx)) - .child(palette_hint_kbd(kbd("down"), cx)) - .child("navigate") - .child("·") - .child(palette_hint_kbd(kbd("enter"), cx)) - .child("open") - .child("·") - .child(palette_hint_kbd(shortcut_run_kbd(), cx)) - .child("query") - .child("·") - .child(palette_hint_kbd(dismiss, cx)) - .child("dismiss") -} diff --git a/apps/desktop/src/widgets/list_row.rs b/apps/desktop/src/widgets/list_row.rs index 4c05079..1699e48 100644 --- a/apps/desktop/src/widgets/list_row.rs +++ b/apps/desktop/src/widgets/list_row.rs @@ -5,9 +5,6 @@ use gpui_component::{Icon, IconName, Sizable as _, h_flex, list::ListItem}; use crate::widgets::{SCHEMA_ROW_ICON_SIZE, SIDEBAR_INSET}; -/// Fixed row height for command palette list items. -const PALETTE_ROW_H: f32 = 28.0; - /// Typography and colors for schema browser list rows. pub struct SchemaRowStyle { pub muted: Hsla, @@ -18,58 +15,6 @@ pub struct SchemaRowStyle { pub row_gap: f32, } -/// Command palette result row — single-line label with trailing meta (VS Code style). -pub fn palette_result_row( - id: impl Into, - selected: bool, - conn_label: SharedString, - label: SharedString, - sublabel: SharedString, - muted: Hsla, - fg: Hsla, -) -> ListItem { - let meta: SharedString = if conn_label.is_empty() || sublabel.contains(conn_label.as_ref()) { - sublabel - } else { - format!("{conn_label} · {sublabel}").into() - }; - ListItem::new(id) - .selected(selected) - .h(px(PALETTE_ROW_H)) - .overflow_hidden() - .px(px(12.0)) - .py(px(0.0)) - .cursor_pointer() - .child( - h_flex() - .w_full() - .h_full() - .gap_2() - .items_center() - .overflow_hidden() - .child( - div() - .flex_1() - .min_w_0() - .overflow_hidden() - .text_sm() - .text_color(fg) - .truncate() - .child(label), - ) - .child( - div() - .flex_shrink_0() - .max_w(px(220.0)) - .overflow_hidden() - .text_xs() - .text_color(muted) - .truncate() - .child(meta), - ), - ) -} - fn schema_object_row_inner( kind_icon: IconName, label: SharedString,