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:
@@ -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), "");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user