Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 0 additions & 19 deletions apps/desktop/src/command_palette/actions.rs

This file was deleted.

101 changes: 101 additions & 0 deletions apps/desktop/src/command_palette/entries.rs
Original file line number Diff line number Diff line change
@@ -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<CommandState>,
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()
}
16 changes: 16 additions & 0 deletions apps/desktop/src/command_palette/format.rs
Original file line number Diff line number Diff line change
@@ -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::<Vec<_>>().join(" ");
Expand Down Expand Up @@ -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");
}
}
110 changes: 30 additions & 80 deletions apps/desktop/src/command_palette/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ConnectionRegistry>,
connection_tree: Entity<ConnectionTree>,
search_input: Entity<InputState>,
results: Vec<PaletteResult>,
selected: usize,
command_state: Entity<CommandState>,
sections: Vec<PaletteSection>,
visible: bool,
focus_handle: FocusHandle,
results_scroll: ScrollHandle,
pending_scroll_to: Option<usize>,
_search_subscription: Subscription,
}

impl CommandPalette {
Expand All @@ -42,46 +37,30 @@ impl CommandPalette {
window: &mut Window,
cx: &mut Context<Self>,
) -> 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
}

pub fn toggle(&mut self, window: &mut Window, cx: &mut Context<Self>) {
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();
}
Expand All @@ -91,61 +70,32 @@ impl CommandPalette {
cx.notify();
}

fn open_selected(&mut self, secondary: bool, cx: &mut Context<Self>) {
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<InputState>,
event: &InputEvent,
_window: &mut Window,
cx: &mut Context<Self>,
) {
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>) {
self.replace_sections(query, cx);
}

fn select_prev(&mut self, cx: &mut Context<Self>) {
if self.results.is_empty() {
fn confirm(&mut self, index: IndexPath, cx: &mut Context<Self>) {
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<Self>) {
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<usize> {
self.pending_scroll_to.take()
fn refresh_results(&mut self, cx: &mut Context<Self>) {
let query = self.command_state.read(cx).query(cx);
self.replace_sections(query.trim(), cx);
}

fn refresh_results(&mut self, cx: &mut Context<Self>) {
self.results = search::collect_results(
fn replace_sections(&mut self, query: &str, cx: &mut Context<Self>) {
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();
}
}
Expand All @@ -155,7 +105,7 @@ impl EventEmitter<PaletteEvent> 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()
}
Expand Down
Loading