Add initial support for search by keystroke to keybinding editor (#34274)

This PR adds preliminary support for searching keybindings by keystrokes
in the keybinding editor.

Release Notes:

- N/A
This commit is contained in:
Anthony Eid 2025-07-11 09:29:29 -04:00 committed by GitHub
parent b4cbea50bb
commit 7eb739d489
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 219 additions and 42 deletions

View file

@ -1112,7 +1112,10 @@
"context": "KeymapEditor",
"use_key_equivalents": true,
"bindings": {
"ctrl-f": "search::FocusSearch"
"ctrl-f": "search::FocusSearch",
"alt-find": "keymap_editor::ToggleKeystrokeSearch",
"alt-ctrl-f": "keymap_editor::ToggleKeystrokeSearch",
"alt-c": "keymap_editor::ToggleConflictFilter"
}
}
]

View file

@ -1211,7 +1211,8 @@
"context": "KeymapEditor",
"use_key_equivalents": true,
"bindings": {
"cmd-f": "search::FocusSearch"
"cmd-alt-f": "keymap_editor::ToggleKeystrokeSearch",
"cmd-alt-c": "keymap_editor::ToggleConflictFilter"
}
}
]

View file

@ -10,7 +10,7 @@ use feature_flags::FeatureFlagViewExt;
use fs::Fs;
use fuzzy::{StringMatch, StringMatchCandidate};
use gpui::{
AppContext as _, AsyncApp, ClickEvent, Context, DismissEvent, Entity, EventEmitter,
Action, AppContext as _, AsyncApp, ClickEvent, Context, DismissEvent, Entity, EventEmitter,
FocusHandle, Focusable, Global, KeyContext, Keystroke, ModifiersChangedEvent, ScrollStrategy,
StyledText, Subscription, WeakEntity, actions, div,
};
@ -57,7 +57,11 @@ actions!(
/// Copies the action name to clipboard.
CopyAction,
/// Copies the context predicate to clipboard.
CopyContext
CopyContext,
/// Toggles Conflict Filtering
ToggleConflictFilter,
/// Toggle Keystroke search
ToggleKeystrokeSearch,
]
);
@ -143,6 +147,22 @@ impl KeymapEventChannel {
}
#[derive(Default, PartialEq)]
enum SearchMode {
#[default]
Normal,
KeyStroke,
}
impl SearchMode {
fn invert(&self) -> Self {
match self {
SearchMode::Normal => SearchMode::KeyStroke,
SearchMode::KeyStroke => SearchMode::Normal,
}
}
}
#[derive(Default, PartialEq, Copy, Clone)]
enum FilterState {
#[default]
All,
@ -221,11 +241,13 @@ struct KeymapEditor {
keybindings: Vec<ProcessedKeybinding>,
keybinding_conflict_state: ConflictState,
filter_state: FilterState,
search_mode: SearchMode,
// corresponds 1 to 1 with keybindings
string_match_candidates: Arc<Vec<StringMatchCandidate>>,
matches: Vec<StringMatch>,
table_interaction_state: Entity<TableInteractionState>,
filter_editor: Entity<Editor>,
keystroke_editor: Entity<KeystrokeInput>,
selected_index: Option<usize>,
}
@ -245,6 +267,12 @@ impl KeymapEditor {
cx.observe_global::<KeymapEventChannel>(Self::update_keybindings);
let table_interaction_state = TableInteractionState::new(window, cx);
let keystroke_editor = cx.new(|cx| {
let mut keystroke_editor = KeystrokeInput::new(window, cx);
keystroke_editor.highlight_on_focus = false;
keystroke_editor
});
let filter_editor = cx.new(|cx| {
let mut editor = Editor::single_line(window, cx);
editor.set_placeholder_text("Filter action names…", cx);
@ -260,17 +288,28 @@ impl KeymapEditor {
})
.detach();
cx.subscribe(&keystroke_editor, |this, _, _, cx| {
if matches!(this.search_mode, SearchMode::Normal) {
return;
}
this.update_matches(cx);
})
.detach();
let mut this = Self {
workspace,
keybindings: vec![],
keybinding_conflict_state: ConflictState::default(),
filter_state: FilterState::default(),
search_mode: SearchMode::default(),
string_match_candidates: Arc::new(vec![]),
matches: vec![],
focus_handle: focus_handle.clone(),
_keymap_subscription,
table_interaction_state,
filter_editor,
keystroke_editor,
selected_index: None,
};
@ -279,30 +318,47 @@ impl KeymapEditor {
this
}
fn current_query(&self, cx: &mut Context<Self>) -> String {
fn current_action_query(&self, cx: &App) -> String {
self.filter_editor.read(cx).text(cx)
}
fn update_matches(&self, cx: &mut Context<Self>) {
let query = self.current_query(cx);
fn current_keystroke_query(&self, cx: &App) -> Vec<Keystroke> {
match self.search_mode {
SearchMode::KeyStroke => self
.keystroke_editor
.read(cx)
.keystrokes()
.iter()
.cloned()
.collect(),
SearchMode::Normal => Default::default(),
}
}
cx.spawn(async move |this, cx| Self::process_query(this, query, cx).await)
fn update_matches(&self, cx: &mut Context<Self>) {
let action_query = self.current_action_query(cx);
let keystroke_query = self.current_keystroke_query(cx);
cx.spawn(async move |this, cx| {
Self::process_query(this, action_query, keystroke_query, cx).await
})
.detach();
}
async fn process_query(
this: WeakEntity<Self>,
query: String,
action_query: String,
keystroke_query: Vec<Keystroke>,
cx: &mut AsyncApp,
) -> anyhow::Result<()> {
let query = command_palette::normalize_action_query(&query);
let action_query = command_palette::normalize_action_query(&action_query);
let (string_match_candidates, keybind_count) = this.read_with(cx, |this, _| {
(this.string_match_candidates.clone(), this.keybindings.len())
})?;
let executor = cx.background_executor().clone();
let mut matches = fuzzy::match_strings(
&string_match_candidates,
&query,
&action_query,
true,
true,
keybind_count,
@ -321,7 +377,26 @@ impl KeymapEditor {
FilterState::All => {}
}
if query.is_empty() {
match this.search_mode {
SearchMode::KeyStroke => {
matches.retain(|item| {
this.keybindings[item.candidate_id]
.ui_key_binding
.as_ref()
.is_some_and(|binding| {
keystroke_query.iter().all(|key| {
binding.keystrokes.iter().any(|keystroke| {
keystroke.key == key.key
&& keystroke.modifiers == key.modifiers
})
})
})
});
}
SearchMode::Normal => {}
}
if action_query.is_empty() {
// apply default sort
// sorts by source precedence, and alphabetically by action name within each source
matches.sort_by_key(|match_item| {
@ -432,7 +507,7 @@ impl KeymapEditor {
let json_language = load_json_language(workspace.clone(), cx).await;
let rust_language = load_rust_language(workspace.clone(), cx).await;
let query = this.update(cx, |this, cx| {
let (action_query, keystroke_query) = this.update(cx, |this, cx| {
let (key_bindings, string_match_candidates) =
Self::process_bindings(json_language, rust_language, cx);
@ -455,10 +530,13 @@ impl KeymapEditor {
string: candidate.string.clone(),
})
.collect();
this.current_query(cx)
(
this.current_action_query(cx),
this.current_keystroke_query(cx),
)
})?;
// calls cx.notify
Self::process_query(this, query, cx).await
Self::process_query(this, action_query, keystroke_query, cx).await
})
.detach_and_log_err(cx);
}
@ -664,6 +742,33 @@ impl KeymapEditor {
};
cx.write_to_clipboard(gpui::ClipboardItem::new_string(action.clone()));
}
fn toggle_conflict_filter(
&mut self,
_: &ToggleConflictFilter,
_: &mut Window,
cx: &mut Context<Self>,
) {
self.filter_state = self.filter_state.invert();
self.update_matches(cx);
}
fn toggle_keystroke_search(
&mut self,
_: &ToggleKeystrokeSearch,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.search_mode = self.search_mode.invert();
self.update_matches(cx);
match self.search_mode {
SearchMode::KeyStroke => {
window.focus(&self.keystroke_editor.focus_handle(cx));
}
SearchMode::Normal => {}
}
}
}
#[derive(Clone)]
@ -763,17 +868,24 @@ impl Render for KeymapEditor {
.on_action(cx.listener(Self::delete_binding))
.on_action(cx.listener(Self::copy_action_to_clipboard))
.on_action(cx.listener(Self::copy_context_to_clipboard))
.on_action(cx.listener(Self::toggle_conflict_filter))
.on_action(cx.listener(Self::toggle_keystroke_search))
.size_full()
.p_2()
.gap_1()
.bg(theme.colors().editor_background)
.child(
h_flex()
.p_2()
.gap_1()
.key_context({
let mut context = KeyContext::new_with_defaults();
context.add("BufferSearchBar");
context
})
.child(
div()
.size_full()
.h_8()
.pl_2()
.pr_1()
@ -781,23 +893,72 @@ impl Render for KeymapEditor {
.border_1()
.border_color(theme.colors().border)
.rounded_lg()
.child(self.filter_editor.clone())
.child(self.filter_editor.clone()),
)
.child(
// TODO: Ask Mikyala if there's a way to get have items be aligned by horizontally
// without embedding a h_flex in another h_flex
h_flex()
.when(self.keybinding_conflict_state.any_conflicts(), |this| {
this.child(
IconButton::new("KeymapEditorConflictIcon", IconName::Warning)
.tooltip(Tooltip::text(match self.filter_state {
.tooltip({
let filter_state = self.filter_state;
move |window, cx| {
Tooltip::for_action(
match filter_state {
FilterState::All => "Show conflicts",
FilterState::Conflicts => "Hide conflicts",
}))
.selected_icon_color(Color::Error)
.toggle_state(matches!(self.filter_state, FilterState::Conflicts))
.on_click(cx.listener(|this, _, _, cx| {
this.filter_state = this.filter_state.invert();
this.update_matches(cx);
})),
},
&ToggleConflictFilter,
window,
cx,
)
}
})
.selected_icon_color(Color::Error)
.toggle_state(matches!(
self.filter_state,
FilterState::Conflicts
))
.on_click(|_, window, cx| {
window.dispatch_action(
ToggleConflictFilter.boxed_clone(),
cx,
);
}),
)
})
.child(
IconButton::new("KeymapEditorToggleFiltersIcon", IconName::Filter)
.tooltip(|window, cx| {
Tooltip::for_action(
"Toggle Keystroke Search",
&ToggleKeystrokeSearch,
window,
cx,
)
})
.toggle_state(matches!(self.search_mode, SearchMode::KeyStroke))
.on_click(|_, window, cx| {
window.dispatch_action(
ToggleKeystrokeSearch.boxed_clone(),
cx,
);
}),
),
),
)
.when(matches!(self.search_mode, SearchMode::KeyStroke), |this| {
this.child(
div()
.child(self.keystroke_editor.clone())
.border_1()
.border_color(theme.colors().border)
.rounded_lg(),
)
})
.child(
Table::new()
.interactable(&self.table_interaction_state)
@ -1522,6 +1683,7 @@ async fn remove_keybinding(
struct KeystrokeInput {
keystrokes: Vec<Keystroke>,
highlight_on_focus: bool,
focus_handle: FocusHandle,
intercept_subscription: Option<Subscription>,
_focus_subscriptions: [Subscription; 2],
@ -1536,6 +1698,7 @@ impl KeystrokeInput {
];
Self {
keystrokes: Vec::new(),
highlight_on_focus: true,
focus_handle,
intercept_subscription: None,
_focus_subscriptions,
@ -1553,6 +1716,7 @@ impl KeystrokeInput {
{
if !event.modifiers.modified() {
self.keystrokes.pop();
cx.emit(());
} else {
last.modifiers = event.modifiers;
}
@ -1562,6 +1726,7 @@ impl KeystrokeInput {
key: "".to_string(),
key_char: None,
});
cx.emit(());
}
cx.stop_propagation();
cx.notify();
@ -1575,6 +1740,7 @@ impl KeystrokeInput {
} else if Some(keystroke) != self.keystrokes.last() {
self.keystrokes.push(keystroke.clone());
}
cx.emit(());
cx.stop_propagation();
cx.notify();
}
@ -1589,6 +1755,7 @@ impl KeystrokeInput {
&& !last.key.is_empty()
&& last.modifiers == event.keystroke.modifiers
{
cx.emit(());
self.keystrokes.push(Keystroke {
modifiers: event.keystroke.modifiers,
key: "".to_string(),
@ -1629,6 +1796,8 @@ impl KeystrokeInput {
}
}
impl EventEmitter<()> for KeystrokeInput {}
impl Focusable for KeystrokeInput {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
@ -1645,10 +1814,12 @@ impl Render for KeystrokeInput {
.track_focus(&self.focus_handle)
.on_modifiers_changed(cx.listener(Self::on_modifiers_changed))
.on_key_up(cx.listener(Self::on_key_up))
.focus(|mut style| {
.when(self.highlight_on_focus, |this| {
this.focus(|mut style| {
style.border_color = Some(colors.border_focused);
style
})
})
.py_2()
.px_3()
.gap_2()
@ -1688,6 +1859,7 @@ impl Render for KeystrokeInput {
.when(!is_focused, |this| this.icon_color(Color::Muted))
.on_click(cx.listener(|this, _event, _window, cx| {
this.keystrokes.pop();
cx.emit(());
cx.notify();
})),
)
@ -1697,6 +1869,7 @@ impl Render for KeystrokeInput {
.when(!is_focused, |this| this.icon_color(Color::Muted))
.on_click(cx.listener(|this, _event, _window, cx| {
this.keystrokes.clear();
cx.emit(());
cx.notify();
})),
),