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

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