Add history to the command palette (#44517)

Co-Authored-By: Claude <ai+claude@zed.dev>

Closes #ISSUE

Release Notes:

- Added history to the command palette (`up` will now show recently
executed
commands). This is particularly helpful in vim mode when you may mistype
a
complicated command and want to re-run a slightly different version
thereof.

---------

Co-authored-by: Claude <ai+claude@zed.dev>
This commit is contained in:
Conrad Irwin
2025-12-10 07:07:48 +00:00
committed by GitHub
co-authored by Claude
parent 7cbe25fda5
commit 22f1655f8f
3 changed files with 434 additions and 3 deletions
+396 -3
View File
@@ -2,7 +2,7 @@ mod persistence;
use std::{
cmp::{self, Reverse},
collections::HashMap,
collections::{HashMap, VecDeque},
sync::Arc,
time::Duration,
};
@@ -19,6 +19,7 @@ use gpui::{
ParentElement, Render, Styled, Task, WeakEntity, Window,
};
use persistence::COMMAND_PALETTE_HISTORY;
use picker::Direction;
use picker::{Picker, PickerDelegate};
use postage::{sink::Sink, stream::Stream};
use settings::Settings;
@@ -163,6 +164,7 @@ pub struct CommandPaletteDelegate {
Task<()>,
postage::dispatch::Receiver<(Vec<Command>, Vec<StringMatch>, CommandInterceptResult)>,
)>,
query_history: QueryHistory,
}
struct Command {
@@ -170,6 +172,91 @@ struct Command {
action: Box<dyn Action>,
}
#[derive(Default)]
struct QueryHistory {
history: Option<VecDeque<String>>,
cursor: Option<usize>,
prefix: Option<String>,
}
impl QueryHistory {
fn history(&mut self) -> &mut VecDeque<String> {
self.history.get_or_insert_with(|| {
COMMAND_PALETTE_HISTORY
.list_recent_queries()
.unwrap_or_default()
.into_iter()
.collect()
})
}
fn add(&mut self, query: String) {
if let Some(pos) = self.history().iter().position(|h| h == &query) {
self.history().remove(pos);
}
self.history().push_back(query);
self.cursor = None;
self.prefix = None;
}
fn validate_cursor(&mut self, current_query: &str) -> Option<usize> {
if let Some(pos) = self.cursor {
if self.history().get(pos).map(|s| s.as_str()) != Some(current_query) {
self.cursor = None;
self.prefix = None;
}
}
self.cursor
}
fn previous(&mut self, current_query: &str) -> Option<&str> {
if self.validate_cursor(current_query).is_none() {
self.prefix = Some(current_query.to_string());
}
let prefix = self.prefix.clone().unwrap_or_default();
let start_index = self.cursor.unwrap_or(self.history().len());
for i in (0..start_index).rev() {
if self
.history()
.get(i)
.is_some_and(|e| e.starts_with(&prefix))
{
self.cursor = Some(i);
return self.history().get(i).map(|s| s.as_str());
}
}
None
}
fn next(&mut self, current_query: &str) -> Option<&str> {
let selected = self.validate_cursor(current_query)?;
let prefix = self.prefix.clone().unwrap_or_default();
for i in (selected + 1)..self.history().len() {
if self
.history()
.get(i)
.is_some_and(|e| e.starts_with(&prefix))
{
self.cursor = Some(i);
return self.history().get(i).map(|s| s.as_str());
}
}
None
}
fn reset_cursor(&mut self) {
self.cursor = None;
self.prefix = None;
}
fn is_navigating(&self) -> bool {
self.cursor.is_some()
}
}
impl Clone for Command {
fn clone(&self) -> Self {
Self {
@@ -196,6 +283,7 @@ impl CommandPaletteDelegate {
previous_focus_handle,
latest_query: String::new(),
updating_matches: None,
query_history: Default::default(),
}
}
@@ -271,6 +359,11 @@ impl CommandPaletteDelegate {
// so we need to return an Option here
self.commands.get(action_ix)
}
#[cfg(any(test, feature = "test-support"))]
pub fn seed_history(&mut self, queries: &[&str]) {
self.query_history.history = Some(queries.iter().map(|s| s.to_string()).collect());
}
}
impl PickerDelegate for CommandPaletteDelegate {
@@ -280,6 +373,38 @@ impl PickerDelegate for CommandPaletteDelegate {
"Execute a command...".into()
}
fn select_history(
&mut self,
direction: Direction,
query: &str,
_window: &mut Window,
_cx: &mut App,
) -> Option<String> {
match direction {
Direction::Up => {
let should_use_history =
self.selected_ix == 0 || self.query_history.is_navigating();
if should_use_history {
if let Some(query) = self.query_history.previous(query).map(|s| s.to_string()) {
return Some(query);
}
}
}
Direction::Down => {
if self.query_history.is_navigating() {
if let Some(query) = self.query_history.next(query).map(|s| s.to_string()) {
return Some(query);
} else {
let prefix = self.query_history.prefix.take().unwrap_or_default();
self.query_history.reset_cursor();
return Some(prefix);
}
}
}
}
None
}
fn match_count(&self) -> usize {
self.matches.len()
}
@@ -439,6 +564,12 @@ impl PickerDelegate for CommandPaletteDelegate {
self.dismissed(window, cx);
return;
}
if !self.latest_query.is_empty() {
self.query_history.add(self.latest_query.clone());
self.query_history.reset_cursor();
}
let action_ix = self.matches[self.selected_ix].candidate_id;
let command = self.commands.swap_remove(action_ix);
telemetry::event!(
@@ -588,7 +719,7 @@ mod tests {
use super::*;
use editor::Editor;
use go_to_line::GoToLine;
use gpui::TestAppContext;
use gpui::{TestAppContext, VisualTestContext};
use language::Point;
use project::Project;
use settings::KeymapFile;
@@ -799,7 +930,9 @@ mod tests {
"bindings": {
"cmd-n": "workspace::NewFile",
"enter": "menu::Confirm",
"cmd-shift-p": "command_palette::Toggle"
"cmd-shift-p": "command_palette::Toggle",
"up": "menu::SelectPrevious",
"down": "menu::SelectNext"
}
}
]"#,
@@ -808,4 +941,264 @@ mod tests {
app_state
})
}
fn open_palette_with_history(
workspace: &Entity<Workspace>,
history: &[&str],
cx: &mut VisualTestContext,
) -> Entity<Picker<CommandPaletteDelegate>> {
cx.simulate_keystrokes("cmd-shift-p");
cx.run_until_parked();
let palette = workspace.update(cx, |workspace, cx| {
workspace
.active_modal::<CommandPalette>(cx)
.unwrap()
.read(cx)
.picker
.clone()
});
palette.update(cx, |palette, _cx| {
palette.delegate.seed_history(history);
});
palette
}
#[gpui::test]
async fn test_history_navigation_basic(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 palette = open_palette_with_history(&workspace, &["backspace", "select all"], cx);
// Query should be empty initially
palette.read_with(cx, |palette, cx| {
assert_eq!(palette.query(cx), "");
});
// Press up - should load most recent query "select all"
cx.simulate_keystrokes("up");
cx.background_executor.run_until_parked();
palette.read_with(cx, |palette, cx| {
assert_eq!(palette.query(cx), "select all");
});
// Press up again - should load "backspace"
cx.simulate_keystrokes("up");
cx.background_executor.run_until_parked();
palette.read_with(cx, |palette, cx| {
assert_eq!(palette.query(cx), "backspace");
});
// Press down - should go back to "select all"
cx.simulate_keystrokes("down");
cx.background_executor.run_until_parked();
palette.read_with(cx, |palette, cx| {
assert_eq!(palette.query(cx), "select all");
});
// Press down again - should clear query (exit history mode)
cx.simulate_keystrokes("down");
cx.background_executor.run_until_parked();
palette.read_with(cx, |palette, cx| {
assert_eq!(palette.query(cx), "");
});
}
#[gpui::test]
async fn test_history_mode_exit_on_typing(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 palette = open_palette_with_history(&workspace, &["backspace"], cx);
// Press up to enter history mode
cx.simulate_keystrokes("up");
cx.background_executor.run_until_parked();
palette.read_with(cx, |palette, cx| {
assert_eq!(palette.query(cx), "backspace");
});
// Type something - should append to the history query
cx.simulate_input("x");
cx.background_executor.run_until_parked();
palette.read_with(cx, |palette, cx| {
assert_eq!(palette.query(cx), "backspacex");
});
}
#[gpui::test]
async fn test_history_navigation_with_suggestions(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 palette = open_palette_with_history(&workspace, &["editor: close", "editor: open"], cx);
// Open palette with a query that has multiple matches
cx.simulate_input("editor");
cx.background_executor.run_until_parked();
// Should have multiple matches, selected_ix should be 0
palette.read_with(cx, |palette, _| {
assert!(palette.delegate.matches.len() > 1);
assert_eq!(palette.delegate.selected_ix, 0);
});
// Press down - should navigate to next suggestion (not history)
cx.simulate_keystrokes("down");
cx.background_executor.run_until_parked();
palette.read_with(cx, |palette, _| {
assert_eq!(palette.delegate.selected_ix, 1);
});
// Press up - should go back to first suggestion
cx.simulate_keystrokes("up");
cx.background_executor.run_until_parked();
palette.read_with(cx, |palette, _| {
assert_eq!(palette.delegate.selected_ix, 0);
});
// Press up again at top - should enter history mode and show previous query
// that matches the "editor" prefix
cx.simulate_keystrokes("up");
cx.background_executor.run_until_parked();
palette.read_with(cx, |palette, cx| {
assert_eq!(palette.query(cx), "editor: open");
});
}
#[gpui::test]
async fn test_history_prefix_search(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 palette = open_palette_with_history(
&workspace,
&["open file", "select all", "select line", "backspace"],
cx,
);
// Type "sel" as a prefix
cx.simulate_input("sel");
cx.background_executor.run_until_parked();
// Press up - should get "select line" (most recent matching "sel")
cx.simulate_keystrokes("up");
cx.background_executor.run_until_parked();
palette.read_with(cx, |palette, cx| {
assert_eq!(palette.query(cx), "select line");
});
// Press up again - should get "select all" (next matching "sel")
cx.simulate_keystrokes("up");
cx.background_executor.run_until_parked();
palette.read_with(cx, |palette, cx| {
assert_eq!(palette.query(cx), "select all");
});
// Press up again - should stay at "select all" (no more matches for "sel")
cx.simulate_keystrokes("up");
cx.background_executor.run_until_parked();
palette.read_with(cx, |palette, cx| {
assert_eq!(palette.query(cx), "select all");
});
// Press down - should go back to "select line"
cx.simulate_keystrokes("down");
cx.background_executor.run_until_parked();
palette.read_with(cx, |palette, cx| {
assert_eq!(palette.query(cx), "select line");
});
// Press down again - should return to original prefix "sel"
cx.simulate_keystrokes("down");
cx.background_executor.run_until_parked();
palette.read_with(cx, |palette, cx| {
assert_eq!(palette.query(cx), "sel");
});
}
#[gpui::test]
async fn test_history_prefix_search_no_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 palette =
open_palette_with_history(&workspace, &["open file", "backspace", "select all"], cx);
// Type "xyz" as a prefix that doesn't match anything
cx.simulate_input("xyz");
cx.background_executor.run_until_parked();
// Press up - should stay at "xyz" (no matches)
cx.simulate_keystrokes("up");
cx.background_executor.run_until_parked();
palette.read_with(cx, |palette, cx| {
assert_eq!(palette.query(cx), "xyz");
});
}
#[gpui::test]
async fn test_history_empty_prefix_searches_all(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 palette = open_palette_with_history(&workspace, &["alpha", "beta", "gamma"], cx);
// With empty query, press up - should get "gamma" (most recent)
cx.simulate_keystrokes("up");
cx.background_executor.run_until_parked();
palette.read_with(cx, |palette, cx| {
assert_eq!(palette.query(cx), "gamma");
});
// Press up - should get "beta"
cx.simulate_keystrokes("up");
cx.background_executor.run_until_parked();
palette.read_with(cx, |palette, cx| {
assert_eq!(palette.query(cx), "beta");
});
// Press up - should get "alpha"
cx.simulate_keystrokes("up");
cx.background_executor.run_until_parked();
palette.read_with(cx, |palette, cx| {
assert_eq!(palette.query(cx), "alpha");
});
// Press down - should get "beta"
cx.simulate_keystrokes("down");
cx.background_executor.run_until_parked();
palette.read_with(cx, |palette, cx| {
assert_eq!(palette.query(cx), "beta");
});
// Press down - should get "gamma"
cx.simulate_keystrokes("down");
cx.background_executor.run_until_parked();
palette.read_with(cx, |palette, cx| {
assert_eq!(palette.query(cx), "gamma");
});
// Press down - should return to empty string (exit history mode)
cx.simulate_keystrokes("down");
cx.background_executor.run_until_parked();
palette.read_with(cx, |palette, cx| {
assert_eq!(palette.query(cx), "");
});
}
}
+10
View File
@@ -123,6 +123,16 @@ impl CommandPaletteDB {
ORDER BY COUNT(1) DESC
}
}
query! {
pub fn list_recent_queries() -> Result<Vec<String>> {
SELECT user_query
FROM command_invocations
WHERE user_query != ""
GROUP BY user_query
ORDER BY MAX(last_invoked) ASC
}
}
}
#[cfg(test)]
+28
View File
@@ -97,6 +97,18 @@ pub trait PickerDelegate: Sized + 'static {
window: &mut Window,
cx: &mut Context<Picker<Self>>,
);
/// Called before the picker handles `SelectPrevious` or `SelectNext`. Return `Some(query)` to
/// set a new query and prevent the default selection behavior.
fn select_history(
&mut self,
_direction: Direction,
_query: &str,
_window: &mut Window,
_cx: &mut App,
) -> Option<String> {
None
}
fn can_select(
&mut self,
_ix: usize,
@@ -448,6 +460,14 @@ impl<D: PickerDelegate> Picker<D> {
window: &mut Window,
cx: &mut Context<Self>,
) {
let query = self.query(cx);
if let Some(query) = self
.delegate
.select_history(Direction::Down, &query, window, cx)
{
self.set_query(query, window, cx);
return;
}
let count = self.delegate.match_count();
if count > 0 {
let index = self.delegate.selected_index();
@@ -467,6 +487,14 @@ impl<D: PickerDelegate> Picker<D> {
window: &mut Window,
cx: &mut Context<Self>,
) {
let query = self.query(cx);
if let Some(query) = self
.delegate
.select_history(Direction::Up, &query, window, cx)
{
self.set_query(query, window, cx);
return;
}
let count = self.delegate.match_count();
if count > 0 {
let index = self.delegate.selected_index();