Add persistence to command palette history (#26948)

Closes #20391

### Summary
This adds a persistence layer to the command palette so that usages can
persist after Zed is closed and re-opened.

The current "usage" algorithm is unchanged, e.g.:
- Sorts by number of usages descending (no recency preference)
- Once a user's query is active, removes these suggestions in favor of
fuzzy matching

There are some additional considerations in order to keep the DB from
growing uncontrollably (and to make long-term use ergonomic):
- The "invocations" count handles max values (though at u16, it seems
unlikely a user will deal with this)
- If a command is un-invoked for more than a month, it stops being
considered a recent usage, and its next update will update its usages
back to 1

### Future Considerations
- Could make the "command expiry" configurable in settings, so the user
can decide how long to hold onto recent usages
- Could make a more sophisticated algorithm which balances recency and
total invocations - e.g. if I've used COMMAND_A 100 times in the last
month, but COMMAND_B 10 times today, should COMMAND_B actually be
preferred?
- Could do preferential fuzzy-matching against these matches once the
user starts a query.

Release Notes:

- Added persistent history of command palette usages.

---------

Co-authored-by: Peter Finn <mastion11@gmail.com>
Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>
This commit is contained in:
KyleBarton 2025-04-01 14:46:35 -07:00 committed by GitHub
parent 9bc4697a33
commit 16f625bd07
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 251 additions and 17 deletions

3
Cargo.lock generated
View file

@ -3113,10 +3113,12 @@ dependencies = [
name = "command_palette"
version = "0.1.0"
dependencies = [
"anyhow",
"client",
"collections",
"command_palette_hooks",
"ctor",
"db",
"editor",
"env_logger 0.11.7",
"fuzzy",
@ -3132,6 +3134,7 @@ dependencies = [
"settings",
"telemetry",
"theme",
"time",
"ui",
"util",
"workspace",

View file

@ -13,9 +13,11 @@ path = "src/command_palette.rs"
doctest = false
[dependencies]
anyhow.workspace = true
client.workspace = true
collections.workspace = true
command_palette_hooks.workspace = true
db.workspace = true
fuzzy.workspace = true
gpui.workspace = true
picker.workspace = true
@ -23,6 +25,7 @@ postage.workspace = true
serde.workspace = true
settings.workspace = true
theme.workspace = true
time.workspace = true
ui.workspace = true
util.workspace = true
telemetry.workspace = true
@ -31,6 +34,7 @@ zed_actions.workspace = true
[dev-dependencies]
ctor.workspace = true
db = { workspace = true, features = ["test-support"] }
editor = { workspace = true, features = ["test-support"] }
env_logger.workspace = true
go_to_line.workspace = true

View file

@ -1,19 +1,23 @@
mod persistence;
use std::{
cmp::{self, Reverse},
collections::HashMap,
sync::Arc,
time::Duration,
};
use client::parse_zed_link;
use collections::HashMap;
use command_palette_hooks::{
CommandInterceptResult, CommandPaletteFilter, CommandPaletteInterceptor,
};
use fuzzy::{StringMatch, StringMatchCandidate};
use gpui::{
Action, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Global,
ParentElement, Render, Styled, Task, UpdateGlobal, WeakEntity, Window,
Action, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
ParentElement, Render, Styled, Task, WeakEntity, Window,
};
use persistence::COMMAND_PALETTE_HISTORY;
use picker::{Picker, PickerDelegate};
use postage::{sink::Sink, stream::Stream};
use settings::Settings;
@ -24,7 +28,6 @@ use zed_actions::{OpenZedUrl, command_palette::Toggle};
pub fn init(cx: &mut App) {
client::init_settings(cx);
cx.set_global(HitCounts::default());
command_palette_hooks::init(cx);
cx.observe_new(CommandPalette::register).detach();
}
@ -138,6 +141,7 @@ impl Render for CommandPalette {
}
pub struct CommandPaletteDelegate {
latest_query: String,
command_palette: WeakEntity<CommandPalette>,
all_commands: Vec<Command>,
commands: Vec<Command>,
@ -164,14 +168,6 @@ impl Clone for Command {
}
}
/// Hit count for each command in the palette.
/// We only account for commands triggered directly via command palette and not by e.g. keystrokes because
/// if a user already knows a keystroke for a command, they are unlikely to use a command palette to look for it.
#[derive(Default, Clone)]
struct HitCounts(HashMap<String, usize>);
impl Global for HitCounts {}
impl CommandPaletteDelegate {
fn new(
command_palette: WeakEntity<CommandPalette>,
@ -185,6 +181,7 @@ impl CommandPaletteDelegate {
commands,
selected_ix: 0,
previous_focus_handle,
latest_query: String::new(),
updating_matches: None,
}
}
@ -197,6 +194,7 @@ impl CommandPaletteDelegate {
cx: &mut Context<Picker<Self>>,
) {
self.updating_matches.take();
self.latest_query = query.clone();
let mut intercept_results = CommandPaletteInterceptor::try_global(cx)
.map(|interceptor| interceptor.intercept(&query, cx))
@ -244,6 +242,20 @@ impl CommandPaletteDelegate {
self.selected_ix = cmp::min(self.selected_ix, self.matches.len() - 1);
}
}
///
/// Hit count for each command in the palette.
/// We only account for commands triggered directly via command palette and not by e.g. keystrokes because
/// if a user already knows a keystroke for a command, they are unlikely to use a command palette to look for it.
fn hit_counts(&self) -> HashMap<String, u16> {
if let Ok(commands) = COMMAND_PALETTE_HISTORY.list_commands_used() {
commands
.into_iter()
.map(|command| (command.command_name, command.invocations))
.collect()
} else {
HashMap::new()
}
}
}
impl PickerDelegate for CommandPaletteDelegate {
@ -283,13 +295,13 @@ impl PickerDelegate for CommandPaletteDelegate {
let (mut tx, mut rx) = postage::dispatch::channel(1);
let task = cx.background_spawn({
let mut commands = self.all_commands.clone();
let hit_counts = cx.global::<HitCounts>().clone();
let hit_counts = self.hit_counts();
let executor = cx.background_executor().clone();
let query = normalize_query(query.as_str());
async move {
commands.sort_by_key(|action| {
(
Reverse(hit_counts.0.get(&action.name).cloned()),
Reverse(hit_counts.get(&action.name).cloned()),
action.name.clone(),
)
});
@ -388,9 +400,14 @@ impl PickerDelegate for CommandPaletteDelegate {
);
self.matches.clear();
self.commands.clear();
HitCounts::update_global(cx, |hit_counts, _cx| {
*hit_counts.0.entry(command.name).or_default() += 1;
});
let command_name = command.name.clone();
let latest_query = self.latest_query.clone();
cx.background_spawn(async move {
COMMAND_PALETTE_HISTORY
.write_command_invocation(command_name, latest_query)
.await
})
.detach_and_log_err(cx);
let action = command.action;
window.focus(&self.previous_focus_handle);
self.dismissed(window, cx);

View file

@ -0,0 +1,210 @@
use anyhow::Result;
use db::{
define_connection, query,
sqlez::{bindable::Column, statement::Statement},
sqlez_macros::sql,
};
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
pub(crate) struct SerializedCommandInvocation {
pub(crate) command_name: String,
pub(crate) user_query: String,
pub(crate) last_invoked: OffsetDateTime,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
pub(crate) struct SerializedCommandUsage {
pub(crate) command_name: String,
pub(crate) invocations: u16,
pub(crate) last_invoked: OffsetDateTime,
}
impl Column for SerializedCommandUsage {
fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> {
let (command_name, next_index): (String, i32) = Column::column(statement, start_index)?;
let (invocations, next_index): (u16, i32) = Column::column(statement, next_index)?;
let (last_invoked_raw, next_index): (i64, i32) = Column::column(statement, next_index)?;
let usage = Self {
command_name,
invocations,
last_invoked: OffsetDateTime::from_unix_timestamp(last_invoked_raw)?,
};
Ok((usage, next_index))
}
}
impl Column for SerializedCommandInvocation {
fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> {
let (command_name, next_index): (String, i32) = Column::column(statement, start_index)?;
let (user_query, next_index): (String, i32) = Column::column(statement, next_index)?;
let (last_invoked_raw, next_index): (i64, i32) = Column::column(statement, next_index)?;
let command_invocation = Self {
command_name,
user_query,
last_invoked: OffsetDateTime::from_unix_timestamp(last_invoked_raw)?,
};
Ok((command_invocation, next_index))
}
}
define_connection!(pub static ref COMMAND_PALETTE_HISTORY: CommandPaletteDB<()> =
&[sql!(
CREATE TABLE IF NOT EXISTS command_invocations(
id INTEGER PRIMARY KEY AUTOINCREMENT,
command_name TEXT NOT NULL,
user_query TEXT NOT NULL,
last_invoked INTEGER DEFAULT (unixepoch()) NOT NULL
) STRICT;
)];
);
impl CommandPaletteDB {
pub async fn write_command_invocation(
&self,
command_name: impl Into<String>,
user_query: impl Into<String>,
) -> Result<()> {
self.write_command_invocation_internal(command_name.into(), user_query.into())
.await
}
query! {
pub fn get_last_invoked(command: &str) -> Result<Option<SerializedCommandInvocation>> {
SELECT
command_name,
user_query,
last_invoked FROM command_invocations
WHERE command_name=(?)
ORDER BY last_invoked DESC
LIMIT 1
}
}
query! {
pub fn get_command_usage(command: &str) -> Result<Option<SerializedCommandUsage>> {
SELECT command_name, COUNT(1), MAX(last_invoked)
FROM command_invocations
WHERE command_name=(?)
GROUP BY command_name
}
}
query! {
async fn write_command_invocation_internal(command_name: String, user_query: String) -> Result<()> {
INSERT INTO command_invocations (command_name, user_query) VALUES ((?), (?));
DELETE FROM command_invocations WHERE id IN (SELECT MIN(id) FROM command_invocations HAVING COUNT(1) > 1000);
}
}
query! {
pub fn list_commands_used() -> Result<Vec<SerializedCommandUsage>> {
SELECT command_name, COUNT(1), MAX(last_invoked)
FROM command_invocations
GROUP BY command_name
ORDER BY COUNT(1) DESC
}
}
}
#[cfg(test)]
mod tests {
use crate::persistence::{CommandPaletteDB, SerializedCommandUsage};
#[gpui::test]
async fn test_saves_and_retrieves_command_invocation() {
let db =
CommandPaletteDB(db::open_test_db("test_saves_and_retrieves_command_invocation").await);
let retrieved_cmd = db.get_last_invoked("editor: backspace").unwrap();
assert!(retrieved_cmd.is_none());
db.write_command_invocation("editor: backspace", "")
.await
.unwrap();
let retrieved_cmd = db.get_last_invoked("editor: backspace").unwrap();
assert!(retrieved_cmd.is_some());
let retrieved_cmd = retrieved_cmd.expect("is some");
assert_eq!(retrieved_cmd.command_name, "editor: backspace".to_string());
assert_eq!(retrieved_cmd.user_query, "".to_string());
}
#[gpui::test]
async fn test_gets_usage_history() {
let db = CommandPaletteDB(db::open_test_db("test_gets_usage_history").await);
db.write_command_invocation("go to line: toggle", "200")
.await
.unwrap();
db.write_command_invocation("go to line: toggle", "201")
.await
.unwrap();
let retrieved_cmd = db.get_last_invoked("go to line: toggle").unwrap();
assert!(retrieved_cmd.is_some());
let retrieved_cmd = retrieved_cmd.expect("is some");
let command_usage = db.get_command_usage("go to line: toggle").unwrap();
assert!(command_usage.is_some());
let command_usage: SerializedCommandUsage = command_usage.expect("is some");
assert_eq!(command_usage.command_name, "go to line: toggle");
assert_eq!(command_usage.invocations, 2);
assert_eq!(command_usage.last_invoked, retrieved_cmd.last_invoked);
}
#[gpui::test]
async fn test_lists_ordered_by_usage() {
let db = CommandPaletteDB(db::open_test_db("test_lists_ordered_by_usage").await);
let empty_commands = db.list_commands_used();
match &empty_commands {
Ok(_) => (),
Err(e) => println!("Error: {:?}", e),
}
assert!(empty_commands.is_ok());
assert_eq!(empty_commands.expect("is ok").len(), 0);
db.write_command_invocation("go to line: toggle", "200")
.await
.unwrap();
db.write_command_invocation("editor: backspace", "")
.await
.unwrap();
db.write_command_invocation("editor: backspace", "")
.await
.unwrap();
let commands = db.list_commands_used();
assert!(commands.is_ok());
let commands = commands.expect("is ok");
assert_eq!(commands.len(), 2);
assert_eq!(commands.as_slice()[0].command_name, "editor: backspace");
assert_eq!(commands.as_slice()[0].invocations, 2);
assert_eq!(commands.as_slice()[1].command_name, "go to line: toggle");
assert_eq!(commands.as_slice()[1].invocations, 1);
}
#[gpui::test]
async fn test_handles_max_invocation_entries() {
let db = CommandPaletteDB(db::open_test_db("test_handles_max_invocation_entries").await);
for i in 1..=1001 {
db.write_command_invocation("some-command", &i.to_string())
.await
.unwrap();
}
let some_command = db.get_command_usage("some-command").unwrap();
assert!(some_command.is_some());
assert_eq!(some_command.expect("is some").invocations, 1000);
}
}