Files
oak-gpui/crates/command_palette/src/command_palette.rs
T
233a1eb46e Open keymap editor from command palette entry (#40825)
Release Notes:

- Adds footer to the command palette with buttons to add or change the
selected actions keybinding. Both open the keymap editor though the add
button takes you directly to the modal for recording a new keybind.


https://github.com/user-attachments/assets/0ee6b91e-b1dd-4d7f-ad64-cc79689ceeb2

---------

Co-authored-by: Joseph T. Lyons <JosephTLyons@gmail.com>
Co-authored-by: Julia Ryan <juliaryan3.14@gmail.com>
Co-authored-by: Danilo Leal <daniloleal09@gmail.com>
2025-10-28 15:14:58 +00:00

815 lines
25 KiB
Rust

mod persistence;
use std::{
cmp::{self, Reverse},
collections::HashMap,
sync::Arc,
time::Duration,
};
use client::parse_zed_link;
use command_palette_hooks::{
CommandInterceptItem, CommandInterceptResult, CommandPaletteFilter,
GlobalCommandPaletteInterceptor,
};
use fuzzy::{StringMatch, StringMatchCandidate};
use gpui::{
Action, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
ParentElement, Render, Styled, Task, WeakEntity, Window,
};
use persistence::COMMAND_PALETTE_HISTORY;
use picker::{Picker, PickerDelegate};
use postage::{sink::Sink, stream::Stream};
use settings::Settings;
use ui::{HighlightedLabel, KeyBinding, ListItem, ListItemSpacing, prelude::*};
use util::ResultExt;
use workspace::{ModalView, Workspace, WorkspaceSettings};
use zed_actions::{OpenZedUrl, command_palette::Toggle};
pub fn init(cx: &mut App) {
client::init_settings(cx);
command_palette_hooks::init(cx);
cx.observe_new(CommandPalette::register).detach();
}
impl ModalView for CommandPalette {}
pub struct CommandPalette {
picker: Entity<Picker<CommandPaletteDelegate>>,
}
/// Removes subsequent whitespace characters and double colons from the query.
///
/// This improves the likelihood of a match by either humanized name or keymap-style name.
pub fn normalize_action_query(input: &str) -> String {
let mut result = String::with_capacity(input.len());
let mut last_char = None;
for char in input.trim().chars() {
match (last_char, char) {
(Some(':'), ':') => continue,
(Some(last_char), char) if last_char.is_whitespace() && char.is_whitespace() => {
continue;
}
_ => {
last_char = Some(char);
}
}
result.push(char);
}
result
}
impl CommandPalette {
fn register(
workspace: &mut Workspace,
_window: Option<&mut Window>,
_: &mut Context<Workspace>,
) {
workspace.register_action(|workspace, _: &Toggle, window, cx| {
Self::toggle(workspace, "", window, cx)
});
}
pub fn toggle(
workspace: &mut Workspace,
query: &str,
window: &mut Window,
cx: &mut Context<Workspace>,
) {
let Some(previous_focus_handle) = window.focused(cx) else {
return;
};
let entity = cx.weak_entity();
workspace.toggle_modal(window, cx, move |window, cx| {
CommandPalette::new(previous_focus_handle, query, entity, window, cx)
});
}
fn new(
previous_focus_handle: FocusHandle,
query: &str,
entity: WeakEntity<Workspace>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let filter = CommandPaletteFilter::try_global(cx);
let commands = window
.available_actions(cx)
.into_iter()
.filter_map(|action| {
if filter.is_some_and(|filter| filter.is_hidden(&*action)) {
return None;
}
Some(Command {
name: humanize_action_name(action.name()),
action,
})
})
.collect();
let delegate = CommandPaletteDelegate::new(
cx.entity().downgrade(),
entity,
commands,
previous_focus_handle,
);
let picker = cx.new(|cx| {
let picker = Picker::uniform_list(delegate, window, cx);
picker.set_query(query, window, cx);
picker
});
Self { picker }
}
pub fn set_query(&mut self, query: &str, window: &mut Window, cx: &mut Context<Self>) {
self.picker
.update(cx, |picker, cx| picker.set_query(query, window, cx))
}
}
impl EventEmitter<DismissEvent> for CommandPalette {}
impl Focusable for CommandPalette {
fn focus_handle(&self, cx: &App) -> FocusHandle {
self.picker.focus_handle(cx)
}
}
impl Render for CommandPalette {
fn render(&mut self, _window: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
v_flex()
.key_context("CommandPalette")
.w(rems(34.))
.child(self.picker.clone())
}
}
pub struct CommandPaletteDelegate {
latest_query: String,
command_palette: WeakEntity<CommandPalette>,
workspace: WeakEntity<Workspace>,
all_commands: Vec<Command>,
commands: Vec<Command>,
matches: Vec<StringMatch>,
selected_ix: usize,
previous_focus_handle: FocusHandle,
updating_matches: Option<(
Task<()>,
postage::dispatch::Receiver<(Vec<Command>, Vec<StringMatch>, CommandInterceptResult)>,
)>,
}
struct Command {
name: String,
action: Box<dyn Action>,
}
impl Clone for Command {
fn clone(&self) -> Self {
Self {
name: self.name.clone(),
action: self.action.boxed_clone(),
}
}
}
impl CommandPaletteDelegate {
fn new(
command_palette: WeakEntity<CommandPalette>,
workspace: WeakEntity<Workspace>,
commands: Vec<Command>,
previous_focus_handle: FocusHandle,
) -> Self {
Self {
command_palette,
workspace,
all_commands: commands.clone(),
matches: vec![],
commands,
selected_ix: 0,
previous_focus_handle,
latest_query: String::new(),
updating_matches: None,
}
}
fn matches_updated(
&mut self,
query: String,
mut commands: Vec<Command>,
mut matches: Vec<StringMatch>,
intercept_result: CommandInterceptResult,
_: &mut Context<Picker<Self>>,
) {
self.updating_matches.take();
self.latest_query = query;
let mut new_matches = Vec::new();
for CommandInterceptItem {
action,
string,
positions,
} in intercept_result.results
{
if let Some(idx) = matches
.iter()
.position(|m| commands[m.candidate_id].action.partial_eq(&*action))
{
matches.remove(idx);
}
commands.push(Command {
name: string.clone(),
action,
});
new_matches.push(StringMatch {
candidate_id: commands.len() - 1,
string,
positions,
score: 0.0,
})
}
if !intercept_result.exclusive {
new_matches.append(&mut matches);
}
self.commands = commands;
self.matches = new_matches;
if self.matches.is_empty() {
self.selected_ix = 0;
} else {
self.selected_ix = cmp::min(self.selected_ix, self.matches.len() - 1);
}
}
/// Hit count for each command in the palette.
/// We only account for commands triggered directly via command palette and not by e.g. keystrokes because
/// if a user already knows a keystroke for a command, they are unlikely to use a command palette to look for it.
fn hit_counts(&self) -> HashMap<String, u16> {
if let Ok(commands) = COMMAND_PALETTE_HISTORY.list_commands_used() {
commands
.into_iter()
.map(|command| (command.command_name, command.invocations))
.collect()
} else {
HashMap::new()
}
}
fn selected_command(&self) -> Option<&Command> {
let action_ix = self
.matches
.get(self.selected_ix)
.map(|m| m.candidate_id)
.unwrap_or(self.selected_ix);
// this gets called in headless tests where there are no commands loaded
// so we need to return an Option here
self.commands.get(action_ix)
}
}
impl PickerDelegate for CommandPaletteDelegate {
type ListItem = ListItem;
fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
"Execute a command...".into()
}
fn match_count(&self) -> usize {
self.matches.len()
}
fn selected_index(&self) -> usize {
self.selected_ix
}
fn set_selected_index(
&mut self,
ix: usize,
_window: &mut Window,
_: &mut Context<Picker<Self>>,
) {
self.selected_ix = ix;
}
fn update_matches(
&mut self,
mut query: String,
window: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> gpui::Task<()> {
let settings = WorkspaceSettings::get_global(cx);
if let Some(alias) = settings.command_aliases.get(&query) {
query = alias.to_string();
}
let workspace = self.workspace.clone();
let intercept_task = GlobalCommandPaletteInterceptor::intercept(&query, workspace, cx);
let (mut tx, mut rx) = postage::dispatch::channel(1);
let query_str = query.as_str();
let is_zed_link = parse_zed_link(query_str, cx).is_some();
let task = cx.background_spawn({
let mut commands = self.all_commands.clone();
let hit_counts = self.hit_counts();
let executor = cx.background_executor().clone();
let query = normalize_action_query(query_str);
let query_for_link = query_str.to_string();
async move {
commands.sort_by_key(|action| {
(
Reverse(hit_counts.get(&action.name).cloned()),
action.name.clone(),
)
});
let candidates = commands
.iter()
.enumerate()
.map(|(ix, command)| StringMatchCandidate::new(ix, &command.name))
.collect::<Vec<_>>();
let matches = fuzzy::match_strings(
&candidates,
&query,
true,
true,
10000,
&Default::default(),
executor,
)
.await;
let intercept_result = if is_zed_link {
CommandInterceptResult {
results: vec![CommandInterceptItem {
action: OpenZedUrl {
url: query_for_link.clone(),
}
.boxed_clone(),
string: query_for_link,
positions: vec![],
}],
exclusive: false,
}
} else if let Some(task) = intercept_task {
task.await
} else {
CommandInterceptResult::default()
};
tx.send((commands, matches, intercept_result))
.await
.log_err();
}
});
self.updating_matches = Some((task, rx.clone()));
cx.spawn_in(window, async move |picker, cx| {
let Some((commands, matches, intercept_result)) = rx.recv().await else {
return;
};
picker
.update(cx, |picker, cx| {
picker
.delegate
.matches_updated(query, commands, matches, intercept_result, cx)
})
.log_err();
})
}
fn finalize_update_matches(
&mut self,
query: String,
duration: Duration,
_: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> bool {
let Some((task, rx)) = self.updating_matches.take() else {
return true;
};
match cx
.background_executor()
.block_with_timeout(duration, rx.clone().recv())
{
Ok(Some((commands, matches, interceptor_result))) => {
self.matches_updated(query, commands, matches, interceptor_result, cx);
true
}
_ => {
self.updating_matches = Some((task, rx));
false
}
}
}
fn dismissed(&mut self, _window: &mut Window, cx: &mut Context<Picker<Self>>) {
self.command_palette
.update(cx, |_, cx| cx.emit(DismissEvent))
.log_err();
}
fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
if secondary {
let Some(selected_command) = self.selected_command() else {
return;
};
let action_name = selected_command.action.name();
let open_keymap = Box::new(zed_actions::ChangeKeybinding {
action: action_name.to_string(),
});
window.dispatch_action(open_keymap, cx);
self.dismissed(window, cx);
return;
}
if self.matches.is_empty() {
self.dismissed(window, cx);
return;
}
let action_ix = self.matches[self.selected_ix].candidate_id;
let command = self.commands.swap_remove(action_ix);
telemetry::event!(
"Action Invoked",
source = "command palette",
action = command.name
);
self.matches.clear();
self.commands.clear();
let command_name = command.name.clone();
let latest_query = self.latest_query.clone();
cx.background_spawn(async move {
COMMAND_PALETTE_HISTORY
.write_command_invocation(command_name, latest_query)
.await
})
.detach_and_log_err(cx);
let action = command.action;
window.focus(&self.previous_focus_handle);
self.dismissed(window, cx);
window.dispatch_action(action, cx);
}
fn render_match(
&self,
ix: usize,
selected: bool,
_: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> Option<Self::ListItem> {
let matching_command = self.matches.get(ix)?;
let command = self.commands.get(matching_command.candidate_id)?;
Some(
ListItem::new(ix)
.inset(true)
.spacing(ListItemSpacing::Sparse)
.toggle_state(selected)
.child(
h_flex()
.w_full()
.py_px()
.justify_between()
.child(HighlightedLabel::new(
command.name.clone(),
matching_command.positions.clone(),
))
.child(KeyBinding::for_action_in(
&*command.action,
&self.previous_focus_handle,
cx,
)),
),
)
}
fn render_footer(
&self,
window: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> Option<AnyElement> {
let selected_command = self.selected_command()?;
let keybind =
KeyBinding::for_action_in(&*selected_command.action, &self.previous_focus_handle, cx);
let focus_handle = &self.previous_focus_handle;
let keybinding_buttons = if keybind.has_binding(window) {
Button::new("change", "Change Keybinding…")
.key_binding(
KeyBinding::for_action_in(&menu::SecondaryConfirm, focus_handle, cx)
.map(|kb| kb.size(rems_from_px(12.))),
)
.on_click(move |_, window, cx| {
window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx);
})
} else {
Button::new("add", "Add Keybinding…")
.key_binding(
KeyBinding::for_action_in(&menu::SecondaryConfirm, focus_handle, cx)
.map(|kb| kb.size(rems_from_px(12.))),
)
.on_click(move |_, window, cx| {
window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx);
})
};
Some(
h_flex()
.w_full()
.p_1p5()
.gap_1()
.justify_between()
.border_t_1()
.border_color(cx.theme().colors().border_variant)
.child(keybinding_buttons)
.child(
Button::new("run-action", "Run")
.key_binding(
KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx)
.map(|kb| kb.size(rems_from_px(12.))),
)
.on_click(|_, window, cx| {
window.dispatch_action(menu::Confirm.boxed_clone(), cx)
}),
)
.into_any(),
)
}
}
pub fn humanize_action_name(name: &str) -> String {
let capacity = name.len() + name.chars().filter(|c| c.is_uppercase()).count();
let mut result = String::with_capacity(capacity);
for char in name.chars() {
if char == ':' {
if result.ends_with(':') {
result.push(' ');
} else {
result.push(':');
}
} else if char == '_' {
result.push(' ');
} else if char.is_uppercase() {
if !result.ends_with(' ') {
result.push(' ');
}
result.extend(char.to_lowercase());
} else {
result.push(char);
}
}
result
}
impl std::fmt::Debug for Command {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Command")
.field("name", &self.name)
.finish_non_exhaustive()
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use super::*;
use editor::Editor;
use go_to_line::GoToLine;
use gpui::TestAppContext;
use language::Point;
use project::Project;
use settings::KeymapFile;
use workspace::{AppState, Workspace};
#[test]
fn test_humanize_action_name() {
assert_eq!(
humanize_action_name("editor::GoToDefinition"),
"editor: go to definition"
);
assert_eq!(
humanize_action_name("editor::Backspace"),
"editor: backspace"
);
assert_eq!(
humanize_action_name("go_to_line::Deploy"),
"go to line: deploy"
);
}
#[test]
fn test_normalize_query() {
assert_eq!(
normalize_action_query("editor: backspace"),
"editor: backspace"
);
assert_eq!(
normalize_action_query("editor: backspace"),
"editor: backspace"
);
assert_eq!(
normalize_action_query("editor: backspace"),
"editor: backspace"
);
assert_eq!(
normalize_action_query("editor::GoToDefinition"),
"editor:GoToDefinition"
);
assert_eq!(
normalize_action_query("editor::::GoToDefinition"),
"editor:GoToDefinition"
);
assert_eq!(
normalize_action_query("editor: :GoToDefinition"),
"editor: :GoToDefinition"
);
}
#[gpui::test]
async fn test_command_palette(cx: &mut TestAppContext) {
let app_state = init_test(cx);
let project = Project::test(app_state.fs.clone(), [], cx).await;
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
let editor = cx.new_window_entity(|window, cx| {
let mut editor = Editor::single_line(window, cx);
editor.set_text("abc", window, cx);
editor
});
workspace.update_in(cx, |workspace, window, cx| {
workspace.add_item_to_active_pane(Box::new(editor.clone()), None, true, window, cx);
editor.update(cx, |editor, cx| window.focus(&editor.focus_handle(cx)))
});
cx.simulate_keystrokes("cmd-shift-p");
let palette = workspace.update(cx, |workspace, cx| {
workspace
.active_modal::<CommandPalette>(cx)
.unwrap()
.read(cx)
.picker
.clone()
});
palette.read_with(cx, |palette, _| {
assert!(palette.delegate.commands.len() > 5);
let is_sorted =
|actions: &[Command]| actions.windows(2).all(|pair| pair[0].name <= pair[1].name);
assert!(is_sorted(&palette.delegate.commands));
});
cx.simulate_input("bcksp");
palette.read_with(cx, |palette, _| {
assert_eq!(palette.delegate.matches[0].string, "editor: backspace");
});
cx.simulate_keystrokes("enter");
workspace.update(cx, |workspace, cx| {
assert!(workspace.active_modal::<CommandPalette>(cx).is_none());
assert_eq!(editor.read(cx).text(cx), "ab")
});
// Add namespace filter, and redeploy the palette
cx.update(|_window, cx| {
CommandPaletteFilter::update_global(cx, |filter, _| {
filter.hide_namespace("editor");
});
});
cx.simulate_keystrokes("cmd-shift-p");
cx.simulate_input("bcksp");
let palette = workspace.update(cx, |workspace, cx| {
workspace
.active_modal::<CommandPalette>(cx)
.unwrap()
.read(cx)
.picker
.clone()
});
palette.read_with(cx, |palette, _| {
assert!(palette.delegate.matches.is_empty())
});
}
#[gpui::test]
async fn test_normalized_matches(cx: &mut TestAppContext) {
let app_state = init_test(cx);
let project = Project::test(app_state.fs.clone(), [], cx).await;
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
let editor = cx.new_window_entity(|window, cx| {
let mut editor = Editor::single_line(window, cx);
editor.set_text("abc", window, cx);
editor
});
workspace.update_in(cx, |workspace, window, cx| {
workspace.add_item_to_active_pane(Box::new(editor.clone()), None, true, window, cx);
editor.update(cx, |editor, cx| window.focus(&editor.focus_handle(cx)))
});
// Test normalize (trimming whitespace and double colons)
cx.simulate_keystrokes("cmd-shift-p");
let palette = workspace.update(cx, |workspace, cx| {
workspace
.active_modal::<CommandPalette>(cx)
.unwrap()
.read(cx)
.picker
.clone()
});
cx.simulate_input("Editor:: Backspace");
palette.read_with(cx, |palette, _| {
assert_eq!(palette.delegate.matches[0].string, "editor: backspace");
});
}
#[gpui::test]
async fn test_go_to_line(cx: &mut TestAppContext) {
let app_state = init_test(cx);
let project = Project::test(app_state.fs.clone(), [], cx).await;
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
cx.simulate_keystrokes("cmd-n");
let editor = workspace.update(cx, |workspace, cx| {
workspace.active_item_as::<Editor>(cx).unwrap()
});
editor.update_in(cx, |editor, window, cx| {
editor.set_text("1\n2\n3\n4\n5\n6\n", window, cx)
});
cx.simulate_keystrokes("cmd-shift-p");
cx.simulate_input("go to line: Toggle");
cx.simulate_keystrokes("enter");
workspace.update(cx, |workspace, cx| {
assert!(workspace.active_modal::<GoToLine>(cx).is_some())
});
cx.simulate_keystrokes("3 enter");
editor.update_in(cx, |editor, window, cx| {
assert!(editor.focus_handle(cx).is_focused(window));
assert_eq!(
editor
.selections
.last::<Point>(&editor.display_snapshot(cx))
.range()
.start,
Point::new(2, 0)
);
});
}
fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
cx.update(|cx| {
let app_state = AppState::test(cx);
theme::init(theme::LoadThemes::JustBase, cx);
language::init(cx);
editor::init(cx);
menu::init();
go_to_line::init(cx);
workspace::init(app_state.clone(), cx);
init(cx);
Project::init_settings(cx);
cx.bind_keys(KeymapFile::load_panic_on_failure(
r#"[
{
"bindings": {
"cmd-n": "workspace::NewFile",
"enter": "menu::Confirm",
"cmd-shift-p": "command_palette::Toggle"
}
}
]"#,
cx,
));
app_state
})
}
}