keymap_ui: Separate action input into separate column and highlight as JSON (#33726)

Closes #ISSUE

Separates the action input in the Keymap UI into it's own column, and
wraps the input in an `impl RenderOnce` element that highlights it as
JSON.

Release Notes:

- N/A *or* Added/Fixed/Improved ...
This commit is contained in:
Ben Kunkle 2025-07-01 12:58:38 -05:00 committed by GitHub
parent f1f19a32fb
commit 0eee768e7b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 183 additions and 72 deletions

3
Cargo.lock generated
View file

@ -14567,6 +14567,7 @@ dependencies = [
name = "settings_ui" name = "settings_ui"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow",
"collections", "collections",
"command_palette", "command_palette",
"command_palette_hooks", "command_palette_hooks",
@ -14577,6 +14578,7 @@ dependencies = [
"fs", "fs",
"fuzzy", "fuzzy",
"gpui", "gpui",
"language",
"log", "log",
"menu", "menu",
"paths", "paths",
@ -14586,6 +14588,7 @@ dependencies = [
"serde", "serde",
"settings", "settings",
"theme", "theme",
"tree-sitter-json",
"ui", "ui",
"util", "util",
"workspace", "workspace",

View file

@ -12,25 +12,28 @@ workspace = true
path = "src/settings_ui.rs" path = "src/settings_ui.rs"
[dependencies] [dependencies]
anyhow.workspace = true
collections.workspace = true
command_palette.workspace = true command_palette.workspace = true
command_palette_hooks.workspace = true command_palette_hooks.workspace = true
component.workspace = true component.workspace = true
collections.workspace = true
db.workspace = true db.workspace = true
editor.workspace = true editor.workspace = true
feature_flags.workspace = true feature_flags.workspace = true
fs.workspace = true fs.workspace = true
fuzzy.workspace = true fuzzy.workspace = true
gpui.workspace = true gpui.workspace = true
language.workspace = true
log.workspace = true log.workspace = true
menu.workspace = true menu.workspace = true
paths.workspace = true paths.workspace = true
project.workspace = true project.workspace = true
search.workspace = true
schemars.workspace = true schemars.workspace = true
search.workspace = true
serde.workspace = true serde.workspace = true
settings.workspace = true settings.workspace = true
theme.workspace = true theme.workspace = true
tree-sitter-json.workspace = true
ui.workspace = true ui.workspace = true
util.workspace = true util.workspace = true
workspace-hack.workspace = true workspace-hack.workspace = true

View file

@ -1,17 +1,19 @@
use std::{ops::Range, sync::Arc}; use std::{ops::Range, sync::Arc};
use anyhow::{Context as _, anyhow};
use collections::HashSet; use collections::HashSet;
use db::anyhow::anyhow;
use editor::{Editor, EditorEvent}; use editor::{Editor, EditorEvent};
use feature_flags::FeatureFlagViewExt; use feature_flags::FeatureFlagViewExt;
use fs::Fs; use fs::Fs;
use fuzzy::{StringMatch, StringMatchCandidate}; use fuzzy::{StringMatch, StringMatchCandidate};
use gpui::{ use gpui::{
AppContext as _, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, AppContext as _, AsyncApp, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
FontWeight, Global, KeyContext, Keystroke, ModifiersChangedEvent, ScrollStrategy, Subscription, FontWeight, Global, KeyContext, Keystroke, ModifiersChangedEvent, ScrollStrategy, StyledText,
WeakEntity, actions, div, Subscription, WeakEntity, actions, div,
}; };
use language::{Language, LanguageConfig};
use settings::KeybindSource; use settings::KeybindSource;
use util::ResultExt; use util::ResultExt;
use ui::{ use ui::{
@ -167,38 +169,47 @@ impl KeymapEditor {
this this
} }
fn update_matches(&mut self, cx: &mut Context<Self>) { fn current_query(&self, cx: &mut Context<Self>) -> String {
let query = self.filter_editor.read(cx).text(cx); self.filter_editor.read(cx).text(cx)
let string_match_candidates = self.string_match_candidates.clone(); }
let executor = cx.background_executor().clone();
let keybind_count = self.keybindings.len();
let query = command_palette::normalize_action_query(&query);
let fuzzy_match = cx.background_spawn(async move {
fuzzy::match_strings(
&string_match_candidates,
&query,
true,
true,
keybind_count,
&Default::default(),
executor,
)
.await
});
cx.spawn(async move |this, cx| { fn update_matches(&self, cx: &mut Context<Self>) {
let matches = fuzzy_match.await; let query = self.current_query(cx);
this.update(cx, |this, cx| {
this.selected_index.take(); cx.spawn(async move |this, cx| Self::process_query(this, query, cx).await)
this.scroll_to_item(0, ScrollStrategy::Top, cx); .detach();
this.matches = matches; }
cx.notify();
}) async fn process_query(
this: WeakEntity<Self>,
query: String,
cx: &mut AsyncApp,
) -> Result<(), db::anyhow::Error> {
let query = command_palette::normalize_action_query(&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 matches = fuzzy::match_strings(
&string_match_candidates,
&query,
true,
true,
keybind_count,
&Default::default(),
executor,
)
.await;
this.update(cx, |this, cx| {
this.selected_index.take();
this.scroll_to_item(0, ScrollStrategy::Top, cx);
this.matches = matches;
cx.notify();
}) })
.detach();
} }
fn process_bindings( fn process_bindings(
json_language: Arc<Language>,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> (Vec<ProcessedKeybinding>, Vec<StringMatchCandidate>) { ) -> (Vec<ProcessedKeybinding>, Vec<StringMatchCandidate>) {
let key_bindings_ptr = cx.key_bindings(); let key_bindings_ptr = cx.key_bindings();
@ -227,6 +238,9 @@ impl KeymapEditor {
let action_name = key_binding.action().name(); let action_name = key_binding.action().name();
unmapped_action_names.remove(&action_name); unmapped_action_names.remove(&action_name);
let action_input = key_binding
.action_input()
.map(|input| TextWithSyntaxHighlighting::new(input, json_language.clone()));
let index = processed_bindings.len(); let index = processed_bindings.len();
let string_match_candidate = StringMatchCandidate::new(index, &action_name); let string_match_candidate = StringMatchCandidate::new(index, &action_name);
@ -234,7 +248,7 @@ impl KeymapEditor {
keystroke_text: keystroke_text.into(), keystroke_text: keystroke_text.into(),
ui_key_binding, ui_key_binding,
action: action_name.into(), action: action_name.into(),
action_input: key_binding.action_input(), action_input,
context: context.into(), context: context.into(),
source, source,
}); });
@ -259,24 +273,61 @@ impl KeymapEditor {
(processed_bindings, string_match_candidates) (processed_bindings, string_match_candidates)
} }
fn update_keybindings(self: &mut KeymapEditor, cx: &mut Context<KeymapEditor>) { fn update_keybindings(&mut self, cx: &mut Context<KeymapEditor>) {
let (key_bindings, string_match_candidates) = Self::process_bindings(cx); let workspace = self.workspace.clone();
self.keybindings = key_bindings; cx.spawn(async move |this, cx| {
self.string_match_candidates = Arc::new(string_match_candidates); let json_language = Self::load_json_language(workspace, cx).await;
self.matches = self let query = this.update(cx, |this, cx| {
.string_match_candidates let (key_bindings, string_match_candidates) =
.iter() Self::process_bindings(json_language.clone(), cx);
.enumerate() this.keybindings = key_bindings;
.map(|(ix, candidate)| StringMatch { this.string_match_candidates = Arc::new(string_match_candidates);
candidate_id: ix, this.matches = this
score: 0.0, .string_match_candidates
positions: vec![], .iter()
string: candidate.string.clone(), .enumerate()
}) .map(|(ix, candidate)| StringMatch {
.collect(); candidate_id: ix,
score: 0.0,
positions: vec![],
string: candidate.string.clone(),
})
.collect();
this.current_query(cx)
})?;
// calls cx.notify
Self::process_query(this, query, cx).await
})
.detach_and_log_err(cx);
}
self.update_matches(cx); async fn load_json_language(
cx.notify(); workspace: WeakEntity<Workspace>,
cx: &mut AsyncApp,
) -> Arc<Language> {
let json_language_task = workspace
.read_with(cx, |workspace, cx| {
workspace
.project()
.read(cx)
.languages()
.language_for_name("JSON")
})
.context("Failed to load JSON language")
.log_err();
let json_language = match json_language_task {
Some(task) => task.await.context("Failed to load JSON language").log_err(),
None => None,
};
return json_language.unwrap_or_else(|| {
Arc::new(Language::new(
LanguageConfig {
name: "JSON".into(),
..Default::default()
},
Some(tree_sitter_json::LANGUAGE.into()),
))
});
} }
fn dispatch_context(&self, _window: &Window, _cx: &Context<Self>) -> KeyContext { fn dispatch_context(&self, _window: &Window, _cx: &Context<Self>) -> KeyContext {
@ -409,7 +460,7 @@ struct ProcessedKeybinding {
keystroke_text: SharedString, keystroke_text: SharedString,
ui_key_binding: Option<ui::KeyBinding>, ui_key_binding: Option<ui::KeyBinding>,
action: SharedString, action: SharedString,
action_input: Option<SharedString>, action_input: Option<TextWithSyntaxHighlighting>,
context: SharedString, context: SharedString,
source: Option<(KeybindSource, SharedString)>, source: Option<(KeybindSource, SharedString)>,
} }
@ -461,8 +512,8 @@ impl Render for KeymapEditor {
Table::new() Table::new()
.interactable(&self.table_interaction_state) .interactable(&self.table_interaction_state)
.striped() .striped()
.column_widths([rems(24.), rems(16.), rems(32.), rems(8.)]) .column_widths([rems(16.), rems(16.), rems(16.), rems(32.), rems(8.)])
.header(["Command", "Keystrokes", "Context", "Source"]) .header(["Action", "Arguments", "Keystrokes", "Context", "Source"])
.selected_item_index(self.selected_index) .selected_item_index(self.selected_index)
.on_click_row(cx.processor(|this, row_index, _window, _cx| { .on_click_row(cx.processor(|this, row_index, _window, _cx| {
this.selected_index = Some(row_index); this.selected_index = Some(row_index);
@ -475,30 +526,26 @@ impl Render for KeymapEditor {
.filter_map(|index| { .filter_map(|index| {
let candidate_id = this.matches.get(index)?.candidate_id; let candidate_id = this.matches.get(index)?.candidate_id;
let binding = &this.keybindings[candidate_id]; let binding = &this.keybindings[candidate_id];
let action = h_flex()
.items_start() let action = binding.action.clone().into_any_element();
.gap_1()
.child(binding.action.clone())
.when_some(
binding.action_input.clone(),
|this, binding_input| this.child(binding_input),
);
let keystrokes = binding.ui_key_binding.clone().map_or( let keystrokes = binding.ui_key_binding.clone().map_or(
binding.keystroke_text.clone().into_any_element(), binding.keystroke_text.clone().into_any_element(),
IntoElement::into_any_element, IntoElement::into_any_element,
); );
let context = binding.context.clone(); let action_input = binding
.action_input
.clone()
.map_or(gpui::Empty.into_any_element(), |input| {
input.into_any_element()
});
let context = binding.context.clone().into_any_element();
let source = binding let source = binding
.source .source
.clone() .clone()
.map(|(_source, name)| name) .map(|(_source, name)| name)
.unwrap_or_default(); .unwrap_or_default()
Some([ .into_any_element();
action.into_any_element(), Some([action, action_input, keystrokes, context, source])
keystrokes,
context.into_any_element(),
source.into_any_element(),
])
}) })
.collect() .collect()
}), }),
@ -507,6 +554,58 @@ impl Render for KeymapEditor {
} }
} }
#[derive(Debug, Clone, IntoElement)]
struct TextWithSyntaxHighlighting {
text: SharedString,
language: Arc<Language>,
}
impl TextWithSyntaxHighlighting {
pub fn new(text: impl Into<SharedString>, language: Arc<Language>) -> Self {
Self {
text: text.into(),
language,
}
}
}
impl RenderOnce for TextWithSyntaxHighlighting {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let text_style = window.text_style();
let syntax_theme = cx.theme().syntax();
let text = self.text.clone();
let highlights = self
.language
.highlight_text(&text.as_ref().into(), 0..text.len());
let mut runs = Vec::with_capacity(highlights.len());
let mut offset = 0;
for (highlight_range, highlight_id) in highlights {
// Add un-highlighted text before the current highlight
if highlight_range.start > offset {
runs.push(text_style.to_run(highlight_range.start - offset));
}
let mut run_style = text_style.clone();
if let Some(highlight_style) = highlight_id.style(syntax_theme) {
run_style = run_style.highlight(highlight_style);
}
// add the highlighted range
runs.push(run_style.to_run(highlight_range.len()));
offset = highlight_range.end;
}
// Add any remaining un-highlighted text
if offset < text.len() {
runs.push(text_style.to_run(text.len() - offset));
}
return StyledText::new(text).with_runs(runs);
}
}
struct KeybindingEditorModal { struct KeybindingEditorModal {
editing_keybind: ProcessedKeybinding, editing_keybind: ProcessedKeybinding,
keybind_editor: Entity<KeybindInput>, keybind_editor: Entity<KeybindInput>,
@ -658,7 +757,10 @@ async fn save_keybinding_update(
keystrokes: existing_keystrokes, keystrokes: existing_keystrokes,
action_name: &existing.action, action_name: &existing.action,
use_key_equivalents: false, use_key_equivalents: false,
input: existing.action_input.as_ref().map(|input| input.as_ref()), input: existing
.action_input
.as_ref()
.map(|input| input.text.as_ref()),
}, },
target_source: existing target_source: existing
.source .source
@ -669,7 +771,10 @@ async fn save_keybinding_update(
keystrokes: new_keystrokes, keystrokes: new_keystrokes,
action_name: &existing.action, action_name: &existing.action,
use_key_equivalents: false, use_key_equivalents: false,
input: existing.action_input.as_ref().map(|input| input.as_ref()), input: existing
.action_input
.as_ref()
.map(|input| input.text.as_ref()),
}, },
} }
} else { } else {