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", "context": "KeymapEditor",
"use_key_equivalents": true, "use_key_equivalents": true,
"bindings": { "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", "context": "KeymapEditor",
"use_key_equivalents": true, "use_key_equivalents": true,
"bindings": { "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 fs::Fs;
use fuzzy::{StringMatch, StringMatchCandidate}; use fuzzy::{StringMatch, StringMatchCandidate};
use gpui::{ 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, FocusHandle, Focusable, Global, KeyContext, Keystroke, ModifiersChangedEvent, ScrollStrategy,
StyledText, Subscription, WeakEntity, actions, div, StyledText, Subscription, WeakEntity, actions, div,
}; };
@ -57,7 +57,11 @@ actions!(
/// Copies the action name to clipboard. /// Copies the action name to clipboard.
CopyAction, CopyAction,
/// Copies the context predicate to clipboard. /// 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)] #[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 { enum FilterState {
#[default] #[default]
All, All,
@ -221,11 +241,13 @@ struct KeymapEditor {
keybindings: Vec<ProcessedKeybinding>, keybindings: Vec<ProcessedKeybinding>,
keybinding_conflict_state: ConflictState, keybinding_conflict_state: ConflictState,
filter_state: FilterState, filter_state: FilterState,
search_mode: SearchMode,
// corresponds 1 to 1 with keybindings // corresponds 1 to 1 with keybindings
string_match_candidates: Arc<Vec<StringMatchCandidate>>, string_match_candidates: Arc<Vec<StringMatchCandidate>>,
matches: Vec<StringMatch>, matches: Vec<StringMatch>,
table_interaction_state: Entity<TableInteractionState>, table_interaction_state: Entity<TableInteractionState>,
filter_editor: Entity<Editor>, filter_editor: Entity<Editor>,
keystroke_editor: Entity<KeystrokeInput>,
selected_index: Option<usize>, selected_index: Option<usize>,
} }
@ -245,6 +267,12 @@ impl KeymapEditor {
cx.observe_global::<KeymapEventChannel>(Self::update_keybindings); cx.observe_global::<KeymapEventChannel>(Self::update_keybindings);
let table_interaction_state = TableInteractionState::new(window, cx); 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 filter_editor = cx.new(|cx| {
let mut editor = Editor::single_line(window, cx); let mut editor = Editor::single_line(window, cx);
editor.set_placeholder_text("Filter action names…", cx); editor.set_placeholder_text("Filter action names…", cx);
@ -260,17 +288,28 @@ impl KeymapEditor {
}) })
.detach(); .detach();
cx.subscribe(&keystroke_editor, |this, _, _, cx| {
if matches!(this.search_mode, SearchMode::Normal) {
return;
}
this.update_matches(cx);
})
.detach();
let mut this = Self { let mut this = Self {
workspace, workspace,
keybindings: vec![], keybindings: vec![],
keybinding_conflict_state: ConflictState::default(), keybinding_conflict_state: ConflictState::default(),
filter_state: FilterState::default(), filter_state: FilterState::default(),
search_mode: SearchMode::default(),
string_match_candidates: Arc::new(vec![]), string_match_candidates: Arc::new(vec![]),
matches: vec![], matches: vec![],
focus_handle: focus_handle.clone(), focus_handle: focus_handle.clone(),
_keymap_subscription, _keymap_subscription,
table_interaction_state, table_interaction_state,
filter_editor, filter_editor,
keystroke_editor,
selected_index: None, selected_index: None,
}; };
@ -279,30 +318,47 @@ impl KeymapEditor {
this 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) self.filter_editor.read(cx).text(cx)
} }
fn update_matches(&self, cx: &mut Context<Self>) { fn current_keystroke_query(&self, cx: &App) -> Vec<Keystroke> {
let query = self.current_query(cx); 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>) {
.detach(); 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( async fn process_query(
this: WeakEntity<Self>, this: WeakEntity<Self>,
query: String, action_query: String,
keystroke_query: Vec<Keystroke>,
cx: &mut AsyncApp, cx: &mut AsyncApp,
) -> anyhow::Result<()> { ) -> 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, _| { let (string_match_candidates, keybind_count) = this.read_with(cx, |this, _| {
(this.string_match_candidates.clone(), this.keybindings.len()) (this.string_match_candidates.clone(), this.keybindings.len())
})?; })?;
let executor = cx.background_executor().clone(); let executor = cx.background_executor().clone();
let mut matches = fuzzy::match_strings( let mut matches = fuzzy::match_strings(
&string_match_candidates, &string_match_candidates,
&query, &action_query,
true, true,
true, true,
keybind_count, keybind_count,
@ -321,7 +377,26 @@ impl KeymapEditor {
FilterState::All => {} 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 // apply default sort
// sorts by source precedence, and alphabetically by action name within each source // sorts by source precedence, and alphabetically by action name within each source
matches.sort_by_key(|match_item| { matches.sort_by_key(|match_item| {
@ -432,7 +507,7 @@ impl KeymapEditor {
let json_language = load_json_language(workspace.clone(), cx).await; let json_language = load_json_language(workspace.clone(), cx).await;
let rust_language = load_rust_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) = let (key_bindings, string_match_candidates) =
Self::process_bindings(json_language, rust_language, cx); Self::process_bindings(json_language, rust_language, cx);
@ -455,10 +530,13 @@ impl KeymapEditor {
string: candidate.string.clone(), string: candidate.string.clone(),
}) })
.collect(); .collect();
this.current_query(cx) (
this.current_action_query(cx),
this.current_keystroke_query(cx),
)
})?; })?;
// calls cx.notify // 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); .detach_and_log_err(cx);
} }
@ -664,6 +742,33 @@ impl KeymapEditor {
}; };
cx.write_to_clipboard(gpui::ClipboardItem::new_string(action.clone())); 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)] #[derive(Clone)]
@ -763,41 +868,97 @@ impl Render for KeymapEditor {
.on_action(cx.listener(Self::delete_binding)) .on_action(cx.listener(Self::delete_binding))
.on_action(cx.listener(Self::copy_action_to_clipboard)) .on_action(cx.listener(Self::copy_action_to_clipboard))
.on_action(cx.listener(Self::copy_context_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() .size_full()
.p_2() .p_2()
.gap_1() .gap_1()
.bg(theme.colors().editor_background) .bg(theme.colors().editor_background)
.child( .child(
h_flex() h_flex()
.p_2()
.gap_1()
.key_context({ .key_context({
let mut context = KeyContext::new_with_defaults(); let mut context = KeyContext::new_with_defaults();
context.add("BufferSearchBar"); context.add("BufferSearchBar");
context context
}) })
.h_8() .child(
.pl_2() div()
.pr_1() .size_full()
.py_1() .h_8()
.border_1() .pl_2()
.border_color(theme.colors().border) .pr_1()
.rounded_lg() .py_1()
.child(self.filter_editor.clone()) .border_1()
.when(self.keybinding_conflict_state.any_conflicts(), |this| { .border_color(theme.colors().border)
this.child( .rounded_lg()
IconButton::new("KeymapEditorConflictIcon", IconName::Warning) .child(self.filter_editor.clone()),
.tooltip(Tooltip::text(match self.filter_state { )
FilterState::All => "Show conflicts", .child(
FilterState::Conflicts => "Hide conflicts", // 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
.selected_icon_color(Color::Error) h_flex()
.toggle_state(matches!(self.filter_state, FilterState::Conflicts)) .when(self.keybinding_conflict_state.any_conflicts(), |this| {
.on_click(cx.listener(|this, _, _, cx| { this.child(
this.filter_state = this.filter_state.invert(); IconButton::new("KeymapEditorConflictIcon", IconName::Warning)
this.update_matches(cx); .tooltip({
})), let filter_state = self.filter_state;
)
}), move |window, cx| {
Tooltip::for_action(
match filter_state {
FilterState::All => "Show conflicts",
FilterState::Conflicts => "Hide conflicts",
},
&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( .child(
Table::new() Table::new()
.interactable(&self.table_interaction_state) .interactable(&self.table_interaction_state)
@ -1522,6 +1683,7 @@ async fn remove_keybinding(
struct KeystrokeInput { struct KeystrokeInput {
keystrokes: Vec<Keystroke>, keystrokes: Vec<Keystroke>,
highlight_on_focus: bool,
focus_handle: FocusHandle, focus_handle: FocusHandle,
intercept_subscription: Option<Subscription>, intercept_subscription: Option<Subscription>,
_focus_subscriptions: [Subscription; 2], _focus_subscriptions: [Subscription; 2],
@ -1536,6 +1698,7 @@ impl KeystrokeInput {
]; ];
Self { Self {
keystrokes: Vec::new(), keystrokes: Vec::new(),
highlight_on_focus: true,
focus_handle, focus_handle,
intercept_subscription: None, intercept_subscription: None,
_focus_subscriptions, _focus_subscriptions,
@ -1553,6 +1716,7 @@ impl KeystrokeInput {
{ {
if !event.modifiers.modified() { if !event.modifiers.modified() {
self.keystrokes.pop(); self.keystrokes.pop();
cx.emit(());
} else { } else {
last.modifiers = event.modifiers; last.modifiers = event.modifiers;
} }
@ -1562,6 +1726,7 @@ impl KeystrokeInput {
key: "".to_string(), key: "".to_string(),
key_char: None, key_char: None,
}); });
cx.emit(());
} }
cx.stop_propagation(); cx.stop_propagation();
cx.notify(); cx.notify();
@ -1575,6 +1740,7 @@ impl KeystrokeInput {
} else if Some(keystroke) != self.keystrokes.last() { } else if Some(keystroke) != self.keystrokes.last() {
self.keystrokes.push(keystroke.clone()); self.keystrokes.push(keystroke.clone());
} }
cx.emit(());
cx.stop_propagation(); cx.stop_propagation();
cx.notify(); cx.notify();
} }
@ -1589,6 +1755,7 @@ impl KeystrokeInput {
&& !last.key.is_empty() && !last.key.is_empty()
&& last.modifiers == event.keystroke.modifiers && last.modifiers == event.keystroke.modifiers
{ {
cx.emit(());
self.keystrokes.push(Keystroke { self.keystrokes.push(Keystroke {
modifiers: event.keystroke.modifiers, modifiers: event.keystroke.modifiers,
key: "".to_string(), key: "".to_string(),
@ -1629,6 +1796,8 @@ impl KeystrokeInput {
} }
} }
impl EventEmitter<()> for KeystrokeInput {}
impl Focusable for KeystrokeInput { impl Focusable for KeystrokeInput {
fn focus_handle(&self, _cx: &App) -> FocusHandle { fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone() self.focus_handle.clone()
@ -1645,9 +1814,11 @@ impl Render for KeystrokeInput {
.track_focus(&self.focus_handle) .track_focus(&self.focus_handle)
.on_modifiers_changed(cx.listener(Self::on_modifiers_changed)) .on_modifiers_changed(cx.listener(Self::on_modifiers_changed))
.on_key_up(cx.listener(Self::on_key_up)) .on_key_up(cx.listener(Self::on_key_up))
.focus(|mut style| { .when(self.highlight_on_focus, |this| {
style.border_color = Some(colors.border_focused); this.focus(|mut style| {
style style.border_color = Some(colors.border_focused);
style
})
}) })
.py_2() .py_2()
.px_3() .px_3()
@ -1688,6 +1859,7 @@ impl Render for KeystrokeInput {
.when(!is_focused, |this| this.icon_color(Color::Muted)) .when(!is_focused, |this| this.icon_color(Color::Muted))
.on_click(cx.listener(|this, _event, _window, cx| { .on_click(cx.listener(|this, _event, _window, cx| {
this.keystrokes.pop(); this.keystrokes.pop();
cx.emit(());
cx.notify(); cx.notify();
})), })),
) )
@ -1697,6 +1869,7 @@ impl Render for KeystrokeInput {
.when(!is_focused, |this| this.icon_color(Color::Muted)) .when(!is_focused, |this| this.icon_color(Color::Muted))
.on_click(cx.listener(|this, _event, _window, cx| { .on_click(cx.listener(|this, _event, _window, cx| {
this.keystrokes.clear(); this.keystrokes.clear();
cx.emit(());
cx.notify(); cx.notify();
})), })),
), ),