Add history to the command palette (#44517)

Conrad Irwin and Claude created

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>

Change summary

crates/command_palette/src/command_palette.rs | 399 ++++++++++++++++++++
crates/command_palette/src/persistence.rs     |  10 
crates/picker/src/picker.rs                   |  28 +
3 files changed, 434 insertions(+), 3 deletions(-)

Detailed changes

crates/command_palette/src/command_palette.rs 🔗

@@ -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), "");
+        });
+    }
 }

crates/command_palette/src/persistence.rs 🔗

@@ -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)]

crates/picker/src/picker.rs 🔗

@@ -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();