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