Add SearchHistory to command palette

Conrad Irwin created

This makes vim mode significantly nicer to use

Change summary

crates/command_palette/Cargo.toml              |  1 
crates/command_palette/src/command_palette.rs  | 59 +++++++++++++++++++
crates/picker/src/picker.rs                    | 18 ++++++
crates/vim/src/command.rs                      | 42 ++++++++++++++
crates/vim/test_data/test_command_history.json | 18 ++++++
5 files changed, 136 insertions(+), 2 deletions(-)

Detailed changes

crates/command_palette/Cargo.toml 🔗

@@ -23,6 +23,7 @@ gpui.workspace = true
 log.workspace = true
 picker.workspace = true
 postage.workspace = true
+project.workspace = true
 serde.workspace = true
 settings.workspace = true
 theme.workspace = true

crates/command_palette/src/command_palette.rs 🔗

@@ -14,12 +14,13 @@ use command_palette_hooks::{
 
 use fuzzy::{StringMatch, StringMatchCandidate};
 use gpui::{
-    Action, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
+    Action, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Global,
     ParentElement, Render, Styled, Task, WeakEntity, Window,
 };
 use persistence::COMMAND_PALETTE_HISTORY;
-use picker::{Picker, PickerDelegate};
+use picker::{Direction, Picker, PickerDelegate};
 use postage::{sink::Sink, stream::Stream};
+use project::search_history::{QueryInsertionBehavior, SearchHistory, SearchHistoryCursor};
 use settings::Settings;
 use ui::{HighlightedLabel, KeyBinding, ListItem, ListItemSpacing, h_flex, prelude::*, v_flex};
 use util::ResultExt;
@@ -38,6 +39,21 @@ pub struct CommandPalette {
     picker: Entity<Picker<CommandPaletteDelegate>>,
 }
 
+struct CommandPaletteSearchHistory {
+    history: SearchHistory,
+}
+impl Default for CommandPaletteSearchHistory {
+    fn default() -> Self {
+        Self {
+            history: SearchHistory::new(
+                Some(500),
+                QueryInsertionBehavior::ReplacePreviousIfContains,
+            ),
+        }
+    }
+}
+impl Global for CommandPaletteSearchHistory {}
+
 /// 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.
@@ -145,6 +161,7 @@ impl Render for CommandPalette {
 
 pub struct CommandPaletteDelegate {
     latest_query: String,
+    history_cursor: SearchHistoryCursor,
     command_palette: WeakEntity<CommandPalette>,
     all_commands: Vec<Command>,
     commands: Vec<Command>,
@@ -182,6 +199,7 @@ impl CommandPaletteDelegate {
             all_commands: commands.clone(),
             matches: vec![],
             commands,
+            history_cursor: SearchHistoryCursor::default(),
             selected_ix: 0,
             previous_focus_handle,
             latest_query: String::new(),
@@ -378,11 +396,48 @@ impl PickerDelegate for CommandPaletteDelegate {
             .log_err();
     }
 
+    fn handle_history(
+        &mut self,
+        direction: Direction,
+        _window: &mut Window,
+        cx: &mut Context<Picker<Self>>,
+    ) -> Option<String> {
+        dbg!(self.selected_ix);
+        if self.selected_ix != 0 {
+            return None;
+        }
+        match direction {
+            Direction::Up => {
+                cx.update_default_global(|history: &mut CommandPaletteSearchHistory, _| {
+                    history
+                        .history
+                        .previous(&mut self.history_cursor)
+                        .map(|s| s.to_owned())
+                        .or(Some("".to_owned()))
+                })
+            }
+            Direction::Down => {
+                cx.update_default_global(|history: &mut CommandPaletteSearchHistory, _| {
+                    history
+                        .history
+                        .previous(&mut self.history_cursor)
+                        .map(|s| s.to_owned())
+                })
+            }
+        }
+    }
+
     fn confirm(&mut self, _: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
         if self.matches.is_empty() {
             self.dismissed(window, cx);
             return;
         }
+
+        cx.update_default_global(|history: &mut CommandPaletteSearchHistory, _| {
+            history
+                .history
+                .add(&mut self.history_cursor, self.latest_query.clone())
+        });
         let action_ix = self.matches[self.selected_ix].candidate_id;
         let command = self.commands.swap_remove(action_ix);
         telemetry::event!(

crates/picker/src/picker.rs 🔗

@@ -144,6 +144,16 @@ pub trait PickerDelegate: Sized + 'static {
         false
     }
 
+    // Allow intercepting up and down for history navigation in the command palette.
+    fn handle_history(
+        &mut self,
+        _direction: Direction,
+        _window: &mut Window,
+        _cx: &mut Context<Picker<Self>>,
+    ) -> Option<String> {
+        None
+    }
+
     /// Override if you want to have <enter> update the query instead of confirming.
     fn confirm_update_query(
         &mut self,
@@ -436,6 +446,10 @@ impl<D: PickerDelegate> Picker<D> {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
+        if let Some(query) = self.delegate.handle_history(Direction::Down, window, cx) {
+            self.set_query(query, window, cx);
+            return;
+        }
         let count = self.delegate.match_count();
         if count > 0 {
             let index = self.delegate.selected_index();
@@ -455,6 +469,10 @@ impl<D: PickerDelegate> Picker<D> {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
+        if let Some(query) = self.delegate.handle_history(Direction::Down, window, cx) {
+            self.set_query(query, window, cx);
+            return;
+        }
         let count = self.delegate.match_count();
         if count > 0 {
             let index = self.delegate.selected_index();

crates/vim/src/command.rs 🔗

@@ -2584,4 +2584,46 @@ mod test {
             assert_active_item(workspace, path!("/root/dir/file_3.rs"), "", cx);
         });
     }
+
+    #[gpui::test]
+    async fn test_command_history(cx: &mut TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+
+        cx.set_shared_state(indoc! {"
+            The quick
+            brown fox
+            ˇjumps over
+            the lazy dog
+        "})
+            .await;
+
+        cx.simulate_shared_keystrokes(": s / o / a enter").await;
+
+        cx.shared_state().await.assert_eq(indoc! {"
+            The quick
+            brown fox
+            ˇjumps aver
+            the lazy dog
+        "});
+
+        // n.b ^ fixes a selection mismatch after u. should be removable eventually
+        cx.simulate_shared_keystrokes("u ^").await;
+
+        cx.shared_state().await.assert_eq(indoc! {"
+            The quick
+            brown fox
+            ˇjumps over
+            the lazy dog
+        "});
+
+        cx.simulate_shared_keystrokes(": up backspace e enter")
+            .await;
+
+        cx.shared_state().await.assert_eq(indoc! {"
+            The quick
+            brown fox
+            ˇjumps ever
+            the lazy dog
+        "});
+    }
 }

crates/vim/test_data/test_command_history.json 🔗

@@ -0,0 +1,18 @@
+{"Put":{"state":"The quick\nbrown fox\nˇjumps over\nthe lazy dog\n"}}
+{"Key":":"}
+{"Key":"s"}
+{"Key":"/"}
+{"Key":"o"}
+{"Key":"/"}
+{"Key":"a"}
+{"Key":"enter"}
+{"Get":{"state":"The quick\nbrown fox\nˇjumps aver\nthe lazy dog\n","mode":"Normal"}}
+{"Key":"u"}
+{"Key":"^"}
+{"Get":{"state":"The quick\nbrown fox\nˇjumps over\nthe lazy dog\n","mode":"Normal"}}
+{"Key":":"}
+{"Key":"up"}
+{"Key":"backspace"}
+{"Key":"e"}
+{"Key":"enter"}
+{"Get":{"state":"The quick\nbrown fox\nˇjumps ever\nthe lazy dog\n","mode":"Normal"}}