ZIm/crates/settings_ui/src/keybindings.rs
gcp-cherry-pick-bot[bot] 4031bedee5
keymap_ui: Fix bug introduced in #35208 (cherry-pick #35237) (#35240)
Cherry-picked keymap_ui: Fix bug introduced in #35208 (#35237)

Closes #ISSUE

Fixes a bug that was cherry picked onto stable and preview branches
introduced in #35208 whereby modifier keys would show up and not be
removable when editing a keybind

Release Notes:

- (preview only) Keymap Editor: Fixed an issue introduced in v0.197.2
whereby modifier keys would show up and not be removable while recording
keystrokes in the keybind edit modal

Co-authored-by: Ben Kunkle <ben@zed.dev>
2025-07-28 18:02:00 -04:00

3662 lines
136 KiB
Rust

use std::{
cmp::{self},
ops::{Not as _, Range},
sync::Arc,
time::Duration,
};
use anyhow::{Context as _, anyhow};
use collections::{HashMap, HashSet};
use editor::{CompletionProvider, Editor, EditorEvent};
use fs::Fs;
use fuzzy::{StringMatch, StringMatchCandidate};
use gpui::{
Action, Animation, AnimationExt, AppContext as _, AsyncApp, Axis, ClickEvent, Context,
DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, FontWeight, Global, IsZero,
KeyContext, Keystroke, Modifiers, ModifiersChangedEvent, MouseButton, Point, ScrollStrategy,
ScrollWheelEvent, Stateful, StyledText, Subscription, Task, TextStyleRefinement, WeakEntity,
actions, anchored, deferred, div,
};
use language::{Language, LanguageConfig, ToOffset as _};
use notifications::status_toast::{StatusToast, ToastIcon};
use project::Project;
use settings::{BaseKeymap, KeybindSource, KeymapFile, Settings as _, SettingsAssets};
use ui::{
ActiveTheme as _, App, Banner, BorrowAppContext, ContextMenu, IconButtonShape, Indicator,
Modal, ModalFooter, ModalHeader, ParentElement as _, Render, Section, SharedString,
Styled as _, Tooltip, Window, prelude::*,
};
use ui_input::SingleLineInput;
use util::ResultExt;
use workspace::{
Item, ModalView, SerializableItem, Workspace, notifications::NotifyTaskExt as _,
register_serializable_item,
};
use crate::{
keybindings::persistence::KEYBINDING_EDITORS,
ui_components::table::{ColumnWidths, ResizeBehavior, Table, TableInteractionState},
};
const NO_ACTION_ARGUMENTS_TEXT: SharedString = SharedString::new_static("<no arguments>");
actions!(
zed,
[
/// Opens the keymap editor.
OpenKeymapEditor
]
);
actions!(
keymap_editor,
[
/// Edits the selected key binding.
EditBinding,
/// Creates a new key binding for the selected action.
CreateBinding,
/// Deletes the selected key binding.
DeleteBinding,
/// Copies the action name to clipboard.
CopyAction,
/// Copies the context predicate to clipboard.
CopyContext,
/// Toggles Conflict Filtering
ToggleConflictFilter,
/// Toggle Keystroke search
ToggleKeystrokeSearch,
/// Toggles exact matching for keystroke search
ToggleExactKeystrokeMatching,
/// Shows matching keystrokes for the currently selected binding
ShowMatchingKeybinds
]
);
actions!(
keystroke_input,
[
/// Starts recording keystrokes
StartRecording,
/// Stops recording keystrokes
StopRecording,
/// Clears the recorded keystrokes
ClearKeystrokes,
]
);
pub fn init(cx: &mut App) {
let keymap_event_channel = KeymapEventChannel::new();
cx.set_global(keymap_event_channel);
cx.on_action(|_: &OpenKeymapEditor, cx| {
workspace::with_active_or_new_workspace(cx, move |workspace, window, cx| {
workspace
.with_local_workspace(window, cx, |workspace, window, cx| {
let existing = workspace
.active_pane()
.read(cx)
.items()
.find_map(|item| item.downcast::<KeymapEditor>());
if let Some(existing) = existing {
workspace.activate_item(&existing, true, true, window, cx);
} else {
let keymap_editor =
cx.new(|cx| KeymapEditor::new(workspace.weak_handle(), window, cx));
workspace.add_item_to_active_pane(
Box::new(keymap_editor),
None,
true,
window,
cx,
);
}
})
.detach();
})
});
register_serializable_item::<KeymapEditor>(cx);
}
pub struct KeymapEventChannel {}
impl Global for KeymapEventChannel {}
impl KeymapEventChannel {
fn new() -> Self {
Self {}
}
pub fn trigger_keymap_changed(cx: &mut App) {
let Some(_event_channel) = cx.try_global::<Self>() else {
// don't panic if no global defined. This usually happens in tests
return;
};
cx.update_global(|_event_channel: &mut Self, _| {
/* triggers observers in KeymapEditors */
});
}
}
#[derive(Default, PartialEq)]
enum SearchMode {
#[default]
Normal,
KeyStroke {
exact_match: bool,
},
}
impl SearchMode {
fn invert(&self) -> Self {
match self {
SearchMode::Normal => SearchMode::KeyStroke { exact_match: false },
SearchMode::KeyStroke { .. } => SearchMode::Normal,
}
}
fn exact_match(&self) -> bool {
match self {
SearchMode::Normal => false,
SearchMode::KeyStroke { exact_match } => *exact_match,
}
}
}
#[derive(Default, PartialEq, Copy, Clone)]
enum FilterState {
#[default]
All,
Conflicts,
}
impl FilterState {
fn invert(&self) -> Self {
match self {
FilterState::All => FilterState::Conflicts,
FilterState::Conflicts => FilterState::All,
}
}
}
#[derive(Debug, Default, PartialEq, Eq, Clone, Hash)]
struct ActionMapping {
keystrokes: Vec<Keystroke>,
context: Option<SharedString>,
}
#[derive(Debug)]
struct KeybindConflict {
first_conflict_index: usize,
remaining_conflict_amount: usize,
}
impl KeybindConflict {
fn from_iter<'a>(mut indices: impl Iterator<Item = &'a ConflictOrigin>) -> Option<Self> {
indices.next().map(|origin| Self {
first_conflict_index: origin.index,
remaining_conflict_amount: indices.count(),
})
}
}
#[derive(Clone, Copy, PartialEq)]
struct ConflictOrigin {
override_source: KeybindSource,
overridden_source: Option<KeybindSource>,
index: usize,
}
impl ConflictOrigin {
fn new(source: KeybindSource, index: usize) -> Self {
Self {
override_source: source,
index,
overridden_source: None,
}
}
fn with_overridden_source(self, source: KeybindSource) -> Self {
Self {
overridden_source: Some(source),
..self
}
}
fn get_conflict_with(&self, other: &Self) -> Option<Self> {
if self.override_source == KeybindSource::User
&& other.override_source == KeybindSource::User
{
Some(
Self::new(KeybindSource::User, other.index)
.with_overridden_source(self.override_source),
)
} else if self.override_source > other.override_source {
Some(other.with_overridden_source(self.override_source))
} else {
None
}
}
fn is_user_keybind_conflict(&self) -> bool {
self.override_source == KeybindSource::User
&& self.overridden_source == Some(KeybindSource::User)
}
}
#[derive(Default)]
struct ConflictState {
conflicts: Vec<Option<ConflictOrigin>>,
keybind_mapping: HashMap<ActionMapping, Vec<ConflictOrigin>>,
has_user_conflicts: bool,
}
impl ConflictState {
fn new(key_bindings: &[ProcessedBinding]) -> Self {
let mut action_keybind_mapping: HashMap<_, Vec<ConflictOrigin>> = HashMap::default();
let mut largest_index = 0;
for (index, binding) in key_bindings
.iter()
.enumerate()
.flat_map(|(index, binding)| Some(index).zip(binding.keybind_information()))
{
action_keybind_mapping
.entry(binding.get_action_mapping())
.or_default()
.push(ConflictOrigin::new(binding.source, index));
largest_index = index;
}
let mut conflicts = vec![None; largest_index + 1];
let mut has_user_conflicts = false;
for indices in action_keybind_mapping.values_mut() {
indices.sort_unstable_by_key(|origin| origin.override_source);
let Some((fst, snd)) = indices.get(0).zip(indices.get(1)) else {
continue;
};
for origin in indices.iter() {
conflicts[origin.index] =
origin.get_conflict_with(if origin == fst { &snd } else { &fst })
}
has_user_conflicts |= fst.override_source == KeybindSource::User
&& snd.override_source == KeybindSource::User;
}
Self {
conflicts,
keybind_mapping: action_keybind_mapping,
has_user_conflicts,
}
}
fn conflicting_indices_for_mapping(
&self,
action_mapping: &ActionMapping,
keybind_idx: Option<usize>,
) -> Option<KeybindConflict> {
self.keybind_mapping
.get(action_mapping)
.and_then(|indices| {
KeybindConflict::from_iter(
indices
.iter()
.filter(|&conflict| Some(conflict.index) != keybind_idx),
)
})
}
fn conflict_for_idx(&self, idx: usize) -> Option<ConflictOrigin> {
self.conflicts.get(idx).copied().flatten()
}
fn has_user_conflict(&self, candidate_idx: usize) -> bool {
self.conflict_for_idx(candidate_idx)
.is_some_and(|conflict| conflict.is_user_keybind_conflict())
}
fn any_user_binding_conflicts(&self) -> bool {
self.has_user_conflicts
}
}
struct KeymapEditor {
workspace: WeakEntity<Workspace>,
focus_handle: FocusHandle,
_keymap_subscription: Subscription,
keybindings: Vec<ProcessedBinding>,
keybinding_conflict_state: ConflictState,
filter_state: FilterState,
search_mode: SearchMode,
search_query_debounce: Option<Task<()>>,
// 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>,
context_menu: Option<(Entity<ContextMenu>, Point<Pixels>, Subscription)>,
previous_edit: Option<PreviousEdit>,
humanized_action_names: HumanizedActionNameCache,
current_widths: Entity<ColumnWidths<6>>,
show_hover_menus: bool,
/// In order for the JSON LSP to run in the actions arguments editor, we
/// require a backing file In order to avoid issues (primarily log spam)
/// with drop order between the buffer, file, worktree, etc, we create a
/// temporary directory for these backing files in the keymap editor struct
/// instead of here. This has the added benefit of only having to create a
/// worktree and directory once, although the perf improvement is negligible.
action_args_temp_dir_worktree: Option<Entity<project::Worktree>>,
action_args_temp_dir: Option<tempfile::TempDir>,
}
enum PreviousEdit {
/// When deleting, we want to maintain the same scroll position
ScrollBarOffset(Point<Pixels>),
/// When editing or creating, because the new keybinding could be in a different position in the sort order
/// we store metadata about the new binding (either the modified version or newly created one)
/// and upon reload, we search for this binding in the list of keybindings, and if we find the one that matches
/// this metadata, we set the selected index to it and scroll to it,
/// and if we don't find it, we scroll to 0 and don't set a selected index
Keybinding {
action_mapping: ActionMapping,
action_name: &'static str,
/// The scrollbar position to fallback to if we don't find the keybinding during a refresh
/// this can happen if there's a filter applied to the search and the keybinding modification
/// filters the binding from the search results
fallback: Point<Pixels>,
},
}
impl EventEmitter<()> for KeymapEditor {}
impl Focusable for KeymapEditor {
fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
if self.selected_index.is_some() {
self.focus_handle.clone()
} else {
self.filter_editor.focus_handle(cx)
}
}
}
impl KeymapEditor {
fn new(workspace: WeakEntity<Workspace>, window: &mut Window, cx: &mut Context<Self>) -> Self {
let _keymap_subscription =
cx.observe_global_in::<KeymapEventChannel>(window, Self::on_keymap_changed);
let table_interaction_state = TableInteractionState::new(window, cx);
let keystroke_editor = cx.new(|cx| {
let mut keystroke_editor = KeystrokeInput::new(None, window, cx);
keystroke_editor.search = true;
keystroke_editor
});
let filter_editor = cx.new(|cx| {
let mut editor = Editor::single_line(window, cx);
editor.set_placeholder_text("Filter action names…", cx);
editor
});
cx.subscribe(&filter_editor, |this, _, e: &EditorEvent, cx| {
if !matches!(e, EditorEvent::BufferEdited) {
return;
}
this.on_query_changed(cx);
})
.detach();
cx.subscribe(&keystroke_editor, |this, _, _, cx| {
if matches!(this.search_mode, SearchMode::Normal) {
return;
}
this.on_query_changed(cx);
})
.detach();
cx.spawn({
let workspace = workspace.clone();
async move |this, cx| {
let temp_dir = tempfile::tempdir_in(paths::temp_dir())?;
let worktree = workspace
.update(cx, |ws, cx| {
ws.project()
.update(cx, |p, cx| p.create_worktree(temp_dir.path(), false, cx))
})?
.await?;
this.update(cx, |this, _| {
this.action_args_temp_dir = Some(temp_dir);
this.action_args_temp_dir_worktree = Some(worktree);
})
}
})
.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: cx.focus_handle(),
_keymap_subscription,
table_interaction_state,
filter_editor,
keystroke_editor,
selected_index: None,
context_menu: None,
previous_edit: None,
search_query_debounce: None,
humanized_action_names: HumanizedActionNameCache::new(cx),
show_hover_menus: true,
action_args_temp_dir: None,
action_args_temp_dir_worktree: None,
current_widths: cx.new(|cx| ColumnWidths::new(cx)),
};
this.on_keymap_changed(window, cx);
this
}
fn current_action_query(&self, cx: &App) -> String {
self.filter_editor.read(cx).text(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(),
}
}
fn on_query_changed(&mut self, cx: &mut Context<Self>) {
let action_query = self.current_action_query(cx);
let keystroke_query = self.current_keystroke_query(cx);
let exact_match = self.search_mode.exact_match();
let timer = cx.background_executor().timer(Duration::from_secs(1));
self.search_query_debounce = Some(cx.background_spawn({
let action_query = action_query.clone();
let keystroke_query = keystroke_query.clone();
async move {
timer.await;
let keystroke_query = keystroke_query
.into_iter()
.map(|keystroke| keystroke.unparse())
.collect::<Vec<String>>()
.join(" ");
telemetry::event!(
"Keystroke Search Completed",
action_query = action_query,
keystroke_query = keystroke_query,
keystroke_exact_match = exact_match
)
}
}));
cx.spawn(async move |this, cx| {
Self::update_matches(this.clone(), action_query, keystroke_query, cx).await?;
this.update(cx, |this, cx| {
this.scroll_to_item(0, ScrollStrategy::Top, cx)
})
})
.detach();
}
async fn update_matches(
this: WeakEntity<Self>,
action_query: String,
keystroke_query: Vec<Keystroke>,
cx: &mut AsyncApp,
) -> anyhow::Result<()> {
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,
&action_query,
true,
true,
keybind_count,
&Default::default(),
executor,
)
.await;
this.update(cx, |this, cx| {
match this.filter_state {
FilterState::Conflicts => {
matches.retain(|candidate| {
this.keybinding_conflict_state
.has_user_conflict(candidate.candidate_id)
});
}
FilterState::All => {}
}
match this.search_mode {
SearchMode::KeyStroke { exact_match } => {
matches.retain(|item| {
this.keybindings[item.candidate_id]
.keystrokes()
.is_some_and(|keystrokes| {
if exact_match {
keystroke_query.len() == keystrokes.len()
&& keystroke_query.iter().zip(keystrokes).all(
|(query, keystroke)| {
query.key == keystroke.key
&& query.modifiers == keystroke.modifiers
},
)
} else if keystroke_query.len() > keystrokes.len() {
return false;
} else {
for keystroke_offset in 0..keystrokes.len() {
let mut found_count = 0;
let mut query_cursor = 0;
let mut keystroke_cursor = keystroke_offset;
while query_cursor < keystroke_query.len()
&& keystroke_cursor < keystrokes.len()
{
let query = &keystroke_query[query_cursor];
let keystroke = &keystrokes[keystroke_cursor];
let matches =
query.modifiers.is_subset_of(&keystroke.modifiers)
&& ((query.key.is_empty()
|| query.key == keystroke.key)
&& query
.key_char
.as_ref()
.map_or(true, |q_kc| {
q_kc == &keystroke.key
}));
if matches {
found_count += 1;
query_cursor += 1;
}
keystroke_cursor += 1;
}
if found_count == keystroke_query.len() {
return true;
}
}
return false;
}
})
});
}
SearchMode::Normal => {}
}
if action_query.is_empty() {
matches.sort_by(|item1, item2| {
let binding1 = &this.keybindings[item1.candidate_id];
let binding2 = &this.keybindings[item2.candidate_id];
binding1.cmp(binding2)
});
}
this.selected_index.take();
this.matches = matches;
cx.notify();
})
}
fn get_conflict(&self, row_index: usize) -> Option<ConflictOrigin> {
self.matches.get(row_index).and_then(|candidate| {
self.keybinding_conflict_state
.conflict_for_idx(candidate.candidate_id)
})
}
fn process_bindings(
json_language: Arc<Language>,
zed_keybind_context_language: Arc<Language>,
humanized_action_names: &HumanizedActionNameCache,
cx: &mut App,
) -> (Vec<ProcessedBinding>, Vec<StringMatchCandidate>) {
let key_bindings_ptr = cx.key_bindings();
let lock = key_bindings_ptr.borrow();
let key_bindings = lock.bindings();
let mut unmapped_action_names =
HashSet::from_iter(cx.all_action_names().into_iter().copied());
let action_documentation = cx.action_documentation();
let mut generator = KeymapFile::action_schema_generator();
let actions_with_schemas = HashSet::from_iter(
cx.action_schemas(&mut generator)
.into_iter()
.filter_map(|(name, schema)| schema.is_some().then_some(name)),
);
let mut processed_bindings = Vec::new();
let mut string_match_candidates = Vec::new();
for key_binding in key_bindings {
let source = key_binding
.meta()
.map(KeybindSource::from_meta)
.unwrap_or(KeybindSource::Unknown);
let keystroke_text = ui::text_for_keystrokes(key_binding.keystrokes(), cx);
let ui_key_binding = ui::KeyBinding::new_from_gpui(key_binding.clone(), cx)
.vim_mode(source == KeybindSource::Vim);
let context = key_binding
.predicate()
.map(|predicate| {
KeybindContextString::Local(
predicate.to_string().into(),
zed_keybind_context_language.clone(),
)
})
.unwrap_or(KeybindContextString::Global);
let action_name = key_binding.action().name();
unmapped_action_names.remove(&action_name);
let action_arguments = key_binding
.action_input()
.map(|arguments| SyntaxHighlightedText::new(arguments, json_language.clone()));
let action_information = ActionInformation::new(
action_name,
action_arguments,
&actions_with_schemas,
&action_documentation,
&humanized_action_names,
);
let index = processed_bindings.len();
let string_match_candidate =
StringMatchCandidate::new(index, &action_information.humanized_name);
processed_bindings.push(ProcessedBinding::new_mapped(
keystroke_text,
ui_key_binding,
context,
source,
action_information,
));
string_match_candidates.push(string_match_candidate);
}
for action_name in unmapped_action_names.into_iter() {
let index = processed_bindings.len();
let action_information = ActionInformation::new(
action_name,
None,
&actions_with_schemas,
&action_documentation,
&humanized_action_names,
);
let string_match_candidate =
StringMatchCandidate::new(index, &action_information.humanized_name);
processed_bindings.push(ProcessedBinding::Unmapped(action_information));
string_match_candidates.push(string_match_candidate);
}
(processed_bindings, string_match_candidates)
}
fn on_keymap_changed(&mut self, window: &mut Window, cx: &mut Context<KeymapEditor>) {
let workspace = self.workspace.clone();
cx.spawn_in(window, async move |this, cx| {
let json_language = load_json_language(workspace.clone(), cx).await;
let zed_keybind_context_language =
load_keybind_context_language(workspace.clone(), cx).await;
let (action_query, keystroke_query) = this.update(cx, |this, cx| {
let (key_bindings, string_match_candidates) = Self::process_bindings(
json_language,
zed_keybind_context_language,
&this.humanized_action_names,
cx,
);
this.keybinding_conflict_state = ConflictState::new(&key_bindings);
this.keybindings = key_bindings;
this.string_match_candidates = Arc::new(string_match_candidates);
this.matches = this
.string_match_candidates
.iter()
.enumerate()
.map(|(ix, candidate)| StringMatch {
candidate_id: ix,
score: 0.0,
positions: vec![],
string: candidate.string.clone(),
})
.collect();
(
this.current_action_query(cx),
this.current_keystroke_query(cx),
)
})?;
// calls cx.notify
Self::update_matches(this.clone(), action_query, keystroke_query, cx).await?;
this.update_in(cx, |this, window, cx| {
if let Some(previous_edit) = this.previous_edit.take() {
match previous_edit {
// should remove scroll from process_query
PreviousEdit::ScrollBarOffset(offset) => {
this.table_interaction_state.update(cx, |table, _| {
table.set_scrollbar_offset(Axis::Vertical, offset)
})
// set selected index and scroll
}
PreviousEdit::Keybinding {
action_mapping,
action_name,
fallback,
} => {
let scroll_position =
this.matches.iter().enumerate().find_map(|(index, item)| {
let binding = &this.keybindings[item.candidate_id];
if binding.get_action_mapping().is_some_and(|binding_mapping| {
binding_mapping == action_mapping
}) && binding.action().name == action_name
{
Some(index)
} else {
None
}
});
if let Some(scroll_position) = scroll_position {
this.select_index(
scroll_position,
Some(ScrollStrategy::Top),
window,
cx,
);
} else {
this.table_interaction_state.update(cx, |table, _| {
table.set_scrollbar_offset(Axis::Vertical, fallback)
});
}
cx.notify();
}
}
}
})
})
.detach_and_log_err(cx);
}
fn key_context(&self) -> KeyContext {
let mut dispatch_context = KeyContext::new_with_defaults();
dispatch_context.add("KeymapEditor");
dispatch_context.add("menu");
dispatch_context
}
fn scroll_to_item(&self, index: usize, strategy: ScrollStrategy, cx: &mut App) {
let index = usize::min(index, self.matches.len().saturating_sub(1));
self.table_interaction_state.update(cx, |this, _cx| {
this.scroll_handle.scroll_to_item(index, strategy);
});
}
fn focus_search(
&mut self,
_: &search::FocusSearch,
window: &mut Window,
cx: &mut Context<Self>,
) {
if !self
.filter_editor
.focus_handle(cx)
.contains_focused(window, cx)
{
window.focus(&self.filter_editor.focus_handle(cx));
} else {
self.filter_editor.update(cx, |editor, cx| {
editor.select_all(&Default::default(), window, cx);
});
}
self.selected_index.take();
}
fn selected_keybind_index(&self) -> Option<usize> {
self.selected_index
.and_then(|match_index| self.matches.get(match_index))
.map(|r#match| r#match.candidate_id)
}
fn selected_keybind_and_index(&self) -> Option<(&ProcessedBinding, usize)> {
self.selected_keybind_index()
.map(|keybind_index| (&self.keybindings[keybind_index], keybind_index))
}
fn selected_binding(&self) -> Option<&ProcessedBinding> {
self.selected_keybind_index()
.and_then(|keybind_index| self.keybindings.get(keybind_index))
}
fn select_index(
&mut self,
index: usize,
scroll: Option<ScrollStrategy>,
window: &mut Window,
cx: &mut Context<Self>,
) {
if self.selected_index != Some(index) {
self.selected_index = Some(index);
if let Some(scroll_strategy) = scroll {
self.scroll_to_item(index, scroll_strategy, cx);
}
window.focus(&self.focus_handle);
cx.notify();
}
}
fn create_context_menu(
&mut self,
position: Point<Pixels>,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.context_menu = self.selected_binding().map(|selected_binding| {
let selected_binding_has_no_context = selected_binding
.context()
.and_then(KeybindContextString::local)
.is_none();
let selected_binding_is_unbound = selected_binding.is_unbound();
let context_menu = ContextMenu::build(window, cx, |menu, _window, _cx| {
menu.context(self.focus_handle.clone())
.action_disabled_when(
selected_binding_is_unbound,
"Edit",
Box::new(EditBinding),
)
.action("Create", Box::new(CreateBinding))
.action_disabled_when(
selected_binding_is_unbound,
"Delete",
Box::new(DeleteBinding),
)
.separator()
.action("Copy Action", Box::new(CopyAction))
.action_disabled_when(
selected_binding_has_no_context,
"Copy Context",
Box::new(CopyContext),
)
.separator()
.action_disabled_when(
selected_binding_has_no_context,
"Show Matching Keybindings",
Box::new(ShowMatchingKeybinds),
)
});
let context_menu_handle = context_menu.focus_handle(cx);
window.defer(cx, move |window, _cx| window.focus(&context_menu_handle));
let subscription = cx.subscribe_in(
&context_menu,
window,
|this, _, _: &DismissEvent, window, cx| {
this.dismiss_context_menu(window, cx);
},
);
(context_menu, position, subscription)
});
cx.notify();
}
fn dismiss_context_menu(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.context_menu.take();
window.focus(&self.focus_handle);
cx.notify();
}
fn context_menu_deployed(&self) -> bool {
self.context_menu.is_some()
}
fn create_row_button(
&self,
index: usize,
conflict: Option<ConflictOrigin>,
cx: &mut Context<Self>,
) -> IconButton {
if self.filter_state != FilterState::Conflicts
&& let Some(conflict) = conflict
{
if conflict.is_user_keybind_conflict() {
base_button_style(index, IconName::Warning)
.icon_color(Color::Warning)
.tooltip(|window, cx| {
Tooltip::with_meta(
"View conflicts",
Some(&ToggleConflictFilter),
"Use alt+click to show all conflicts",
window,
cx,
)
})
.on_click(cx.listener(move |this, click: &ClickEvent, window, cx| {
if click.modifiers().alt {
this.set_filter_state(FilterState::Conflicts, cx);
} else {
this.select_index(index, None, window, cx);
this.open_edit_keybinding_modal(false, window, cx);
cx.stop_propagation();
}
}))
} else if self.search_mode.exact_match() {
base_button_style(index, IconName::Info)
.tooltip(|window, cx| {
Tooltip::with_meta(
"Edit this binding",
Some(&ShowMatchingKeybinds),
"This binding is overridden by other bindings.",
window,
cx,
)
})
.on_click(cx.listener(move |this, _: &ClickEvent, window, cx| {
this.select_index(index, None, window, cx);
this.open_edit_keybinding_modal(false, window, cx);
cx.stop_propagation();
}))
} else {
base_button_style(index, IconName::Info)
.tooltip(|window, cx| {
Tooltip::with_meta(
"Show matching keybinds",
Some(&ShowMatchingKeybinds),
"This binding is overridden by other bindings.\nUse alt+click to edit this binding",
window,
cx,
)
})
.on_click(cx.listener(move |this, click: &ClickEvent, window, cx| {
if click.modifiers().alt {
this.select_index(index, None, window, cx);
this.open_edit_keybinding_modal(false, window, cx);
cx.stop_propagation();
} else {
this.show_matching_keystrokes(&Default::default(), window, cx);
}
}))
}
} else {
base_button_style(index, IconName::Pencil)
.visible_on_hover(if self.selected_index == Some(index) {
"".into()
} else if self.show_hover_menus {
row_group_id(index)
} else {
"never-show".into()
})
.when(
self.show_hover_menus && !self.context_menu_deployed(),
|this| this.tooltip(Tooltip::for_action_title("Edit Keybinding", &EditBinding)),
)
.on_click(cx.listener(move |this, _, window, cx| {
this.select_index(index, None, window, cx);
this.open_edit_keybinding_modal(false, window, cx);
cx.stop_propagation();
}))
}
}
fn render_no_matches_hint(&self, _window: &mut Window, _cx: &App) -> AnyElement {
let hint = match (self.filter_state, &self.search_mode) {
(FilterState::Conflicts, _) => {
if self.keybinding_conflict_state.any_user_binding_conflicts() {
"No conflicting keybinds found that match the provided query"
} else {
"No conflicting keybinds found"
}
}
(FilterState::All, SearchMode::KeyStroke { .. }) => {
"No keybinds found matching the entered keystrokes"
}
(FilterState::All, SearchMode::Normal) => "No matches found for the provided query",
};
Label::new(hint).color(Color::Muted).into_any_element()
}
fn select_next(&mut self, _: &menu::SelectNext, window: &mut Window, cx: &mut Context<Self>) {
self.show_hover_menus = false;
if let Some(selected) = self.selected_index {
let selected = selected + 1;
if selected >= self.matches.len() {
self.select_last(&Default::default(), window, cx);
} else {
self.select_index(selected, Some(ScrollStrategy::Center), window, cx);
}
} else {
self.select_first(&Default::default(), window, cx);
}
}
fn select_previous(
&mut self,
_: &menu::SelectPrevious,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.show_hover_menus = false;
if let Some(selected) = self.selected_index {
if selected == 0 {
return;
}
let selected = selected - 1;
if selected >= self.matches.len() {
self.select_last(&Default::default(), window, cx);
} else {
self.select_index(selected, Some(ScrollStrategy::Center), window, cx);
}
} else {
self.select_last(&Default::default(), window, cx);
}
}
fn select_first(&mut self, _: &menu::SelectFirst, window: &mut Window, cx: &mut Context<Self>) {
self.show_hover_menus = false;
if self.matches.get(0).is_some() {
self.select_index(0, Some(ScrollStrategy::Center), window, cx);
}
}
fn select_last(&mut self, _: &menu::SelectLast, window: &mut Window, cx: &mut Context<Self>) {
self.show_hover_menus = false;
if self.matches.last().is_some() {
let index = self.matches.len() - 1;
self.select_index(index, Some(ScrollStrategy::Center), window, cx);
}
}
fn open_edit_keybinding_modal(
&mut self,
create: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.show_hover_menus = false;
let Some((keybind, keybind_index)) = self.selected_keybind_and_index() else {
return;
};
let keybind = keybind.clone();
let keymap_editor = cx.entity();
let keystroke = keybind.keystroke_text().cloned().unwrap_or_default();
let arguments = keybind
.action()
.arguments
.as_ref()
.map(|arguments| arguments.text.clone());
let context = keybind
.context()
.map(|context| context.local_str().unwrap_or("global"));
let action = keybind.action().name;
let source = keybind.keybind_source().map(|source| source.name());
telemetry::event!(
"Edit Keybinding Modal Opened",
keystroke = keystroke,
action = action,
source = source,
context = context,
arguments = arguments,
);
let temp_dir = self.action_args_temp_dir.as_ref().map(|dir| dir.path());
self.workspace
.update(cx, |workspace, cx| {
let fs = workspace.app_state().fs.clone();
let workspace_weak = cx.weak_entity();
workspace.toggle_modal(window, cx, |window, cx| {
let modal = KeybindingEditorModal::new(
create,
keybind,
keybind_index,
keymap_editor,
temp_dir,
workspace_weak,
fs,
window,
cx,
);
window.focus(&modal.focus_handle(cx));
modal
});
})
.log_err();
}
fn edit_binding(&mut self, _: &EditBinding, window: &mut Window, cx: &mut Context<Self>) {
self.open_edit_keybinding_modal(false, window, cx);
}
fn create_binding(&mut self, _: &CreateBinding, window: &mut Window, cx: &mut Context<Self>) {
self.open_edit_keybinding_modal(true, window, cx);
}
fn delete_binding(&mut self, _: &DeleteBinding, window: &mut Window, cx: &mut Context<Self>) {
let Some(to_remove) = self.selected_binding().cloned() else {
return;
};
let std::result::Result::Ok(fs) = self
.workspace
.read_with(cx, |workspace, _| workspace.app_state().fs.clone())
else {
return;
};
let tab_size = cx.global::<settings::SettingsStore>().json_tab_size();
self.previous_edit = Some(PreviousEdit::ScrollBarOffset(
self.table_interaction_state
.read(cx)
.get_scrollbar_offset(Axis::Vertical),
));
cx.spawn(async move |_, _| remove_keybinding(to_remove, &fs, tab_size).await)
.detach_and_notify_err(window, cx);
}
fn copy_context_to_clipboard(
&mut self,
_: &CopyContext,
_window: &mut Window,
cx: &mut Context<Self>,
) {
let context = self
.selected_binding()
.and_then(|binding| binding.context())
.and_then(KeybindContextString::local_str)
.map(|context| context.to_string());
let Some(context) = context else {
return;
};
telemetry::event!("Keybinding Context Copied", context = context.clone());
cx.write_to_clipboard(gpui::ClipboardItem::new_string(context.clone()));
}
fn copy_action_to_clipboard(
&mut self,
_: &CopyAction,
_window: &mut Window,
cx: &mut Context<Self>,
) {
let action = self
.selected_binding()
.map(|binding| binding.action().name.to_string());
let Some(action) = action else {
return;
};
telemetry::event!("Keybinding Action Copied", action = 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.set_filter_state(self.filter_state.invert(), cx);
}
fn set_filter_state(&mut self, filter_state: FilterState, cx: &mut Context<Self>) {
if self.filter_state != filter_state {
self.filter_state = filter_state;
self.on_query_changed(cx);
}
}
fn toggle_keystroke_search(
&mut self,
_: &ToggleKeystrokeSearch,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.search_mode = self.search_mode.invert();
self.on_query_changed(cx);
match self.search_mode {
SearchMode::KeyStroke { .. } => {
self.keystroke_editor.update(cx, |editor, cx| {
editor.start_recording(&StartRecording, window, cx);
});
}
SearchMode::Normal => {
self.keystroke_editor.update(cx, |editor, cx| {
editor.stop_recording(&StopRecording, window, cx);
editor.clear_keystrokes(&ClearKeystrokes, window, cx);
});
window.focus(&self.filter_editor.focus_handle(cx));
}
}
}
fn toggle_exact_keystroke_matching(
&mut self,
_: &ToggleExactKeystrokeMatching,
_: &mut Window,
cx: &mut Context<Self>,
) {
let SearchMode::KeyStroke { exact_match } = &mut self.search_mode else {
return;
};
*exact_match = !(*exact_match);
self.on_query_changed(cx);
}
fn show_matching_keystrokes(
&mut self,
_: &ShowMatchingKeybinds,
_: &mut Window,
cx: &mut Context<Self>,
) {
let Some(selected_binding) = self.selected_binding() else {
return;
};
let keystrokes = selected_binding
.keystrokes()
.map(Vec::from)
.unwrap_or_default();
self.filter_state = FilterState::All;
self.search_mode = SearchMode::KeyStroke { exact_match: true };
self.keystroke_editor.update(cx, |editor, cx| {
editor.set_keystrokes(keystrokes, cx);
});
}
}
struct HumanizedActionNameCache {
cache: HashMap<&'static str, SharedString>,
}
impl HumanizedActionNameCache {
fn new(cx: &App) -> Self {
let cache = HashMap::from_iter(cx.all_action_names().into_iter().map(|&action_name| {
(
action_name,
command_palette::humanize_action_name(action_name).into(),
)
}));
Self { cache }
}
fn get(&self, action_name: &'static str) -> SharedString {
match self.cache.get(action_name) {
Some(name) => name.clone(),
None => action_name.into(),
}
}
}
#[derive(Clone)]
struct KeybindInformation {
keystroke_text: SharedString,
ui_binding: ui::KeyBinding,
context: KeybindContextString,
source: KeybindSource,
}
impl KeybindInformation {
fn get_action_mapping(&self) -> ActionMapping {
ActionMapping {
keystrokes: self.ui_binding.keystrokes.clone(),
context: self.context.local().cloned(),
}
}
}
#[derive(Clone)]
struct ActionInformation {
name: &'static str,
humanized_name: SharedString,
arguments: Option<SyntaxHighlightedText>,
documentation: Option<&'static str>,
has_schema: bool,
}
impl ActionInformation {
fn new(
action_name: &'static str,
action_arguments: Option<SyntaxHighlightedText>,
actions_with_schemas: &HashSet<&'static str>,
action_documentation: &HashMap<&'static str, &'static str>,
action_name_cache: &HumanizedActionNameCache,
) -> Self {
Self {
humanized_name: action_name_cache.get(action_name),
has_schema: actions_with_schemas.contains(action_name),
arguments: action_arguments,
documentation: action_documentation.get(action_name).copied(),
name: action_name,
}
}
}
#[derive(Clone)]
enum ProcessedBinding {
Mapped(KeybindInformation, ActionInformation),
Unmapped(ActionInformation),
}
impl ProcessedBinding {
fn new_mapped(
keystroke_text: impl Into<SharedString>,
ui_key_binding: ui::KeyBinding,
context: KeybindContextString,
source: KeybindSource,
action_information: ActionInformation,
) -> Self {
Self::Mapped(
KeybindInformation {
keystroke_text: keystroke_text.into(),
ui_binding: ui_key_binding,
context,
source,
},
action_information,
)
}
fn is_unbound(&self) -> bool {
matches!(self, Self::Unmapped(_))
}
fn get_action_mapping(&self) -> Option<ActionMapping> {
self.keybind_information()
.map(|keybind| keybind.get_action_mapping())
}
fn keystrokes(&self) -> Option<&[Keystroke]> {
self.ui_key_binding()
.map(|binding| binding.keystrokes.as_slice())
}
fn keybind_information(&self) -> Option<&KeybindInformation> {
match self {
Self::Mapped(keybind_information, _) => Some(keybind_information),
Self::Unmapped(_) => None,
}
}
fn keybind_source(&self) -> Option<KeybindSource> {
self.keybind_information().map(|keybind| keybind.source)
}
fn context(&self) -> Option<&KeybindContextString> {
self.keybind_information().map(|keybind| &keybind.context)
}
fn ui_key_binding(&self) -> Option<&ui::KeyBinding> {
self.keybind_information()
.map(|keybind| &keybind.ui_binding)
}
fn keystroke_text(&self) -> Option<&SharedString> {
self.keybind_information()
.map(|binding| &binding.keystroke_text)
}
fn action(&self) -> &ActionInformation {
match self {
Self::Mapped(_, action) | Self::Unmapped(action) => action,
}
}
fn cmp(&self, other: &Self) -> cmp::Ordering {
match (self, other) {
(Self::Mapped(keybind1, action1), Self::Mapped(keybind2, action2)) => {
match keybind1.source.cmp(&keybind2.source) {
cmp::Ordering::Equal => action1.humanized_name.cmp(&action2.humanized_name),
ordering => ordering,
}
}
(Self::Mapped(_, _), Self::Unmapped(_)) => cmp::Ordering::Less,
(Self::Unmapped(_), Self::Mapped(_, _)) => cmp::Ordering::Greater,
(Self::Unmapped(action1), Self::Unmapped(action2)) => {
action1.humanized_name.cmp(&action2.humanized_name)
}
}
}
}
#[derive(Clone, Debug, IntoElement, PartialEq, Eq, Hash)]
enum KeybindContextString {
Global,
Local(SharedString, Arc<Language>),
}
impl KeybindContextString {
const GLOBAL: SharedString = SharedString::new_static("<global>");
pub fn local(&self) -> Option<&SharedString> {
match self {
KeybindContextString::Global => None,
KeybindContextString::Local(name, _) => Some(name),
}
}
pub fn local_str(&self) -> Option<&str> {
match self {
KeybindContextString::Global => None,
KeybindContextString::Local(name, _) => Some(name),
}
}
}
impl RenderOnce for KeybindContextString {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
match self {
KeybindContextString::Global => {
muted_styled_text(KeybindContextString::GLOBAL.clone(), cx).into_any_element()
}
KeybindContextString::Local(name, language) => {
SyntaxHighlightedText::new(name, language).into_any_element()
}
}
}
}
fn muted_styled_text(text: SharedString, cx: &App) -> StyledText {
let len = text.len();
StyledText::new(text).with_highlights([(
0..len,
gpui::HighlightStyle::color(cx.theme().colors().text_muted),
)])
}
impl Item for KeymapEditor {
type Event = ();
fn tab_content_text(&self, _detail: usize, _cx: &App) -> ui::SharedString {
"Keymap Editor".into()
}
}
impl Render for KeymapEditor {
fn render(&mut self, _window: &mut Window, cx: &mut ui::Context<Self>) -> impl ui::IntoElement {
let row_count = self.matches.len();
let theme = cx.theme();
let focus_handle = &self.focus_handle;
v_flex()
.id("keymap-editor")
.track_focus(focus_handle)
.key_context(self.key_context())
.on_action(cx.listener(Self::select_next))
.on_action(cx.listener(Self::select_previous))
.on_action(cx.listener(Self::select_first))
.on_action(cx.listener(Self::select_last))
.on_action(cx.listener(Self::focus_search))
.on_action(cx.listener(Self::edit_binding))
.on_action(cx.listener(Self::create_binding))
.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))
.on_action(cx.listener(Self::toggle_exact_keystroke_matching))
.on_action(cx.listener(Self::show_matching_keystrokes))
.on_mouse_move(cx.listener(|this, _, _window, _cx| {
this.show_hover_menus = true;
}))
.size_full()
.p_2()
.gap_1()
.bg(theme.colors().editor_background)
.child(
v_flex()
.gap_2()
.child(
h_flex()
.gap_2()
.child(
div()
.key_context({
let mut context = KeyContext::new_with_defaults();
context.add("BufferSearchBar");
context
})
.size_full()
.h_8()
.pl_2()
.pr_1()
.py_1()
.border_1()
.border_color(theme.colors().border)
.rounded_lg()
.child(self.filter_editor.clone()),
)
.child(
IconButton::new(
"KeymapEditorToggleFiltersIcon",
IconName::Keyboard,
)
.shape(ui::IconButtonShape::Square)
.tooltip({
let focus_handle = focus_handle.clone();
move |window, cx| {
Tooltip::for_action_in(
"Search by Keystroke",
&ToggleKeystrokeSearch,
&focus_handle.clone(),
window,
cx,
)
}
})
.toggle_state(matches!(
self.search_mode,
SearchMode::KeyStroke { .. }
))
.on_click(|_, window, cx| {
window.dispatch_action(ToggleKeystrokeSearch.boxed_clone(), cx);
}),
)
.child(
IconButton::new("KeymapEditorConflictIcon", IconName::Warning)
.shape(ui::IconButtonShape::Square)
.when(
self.keybinding_conflict_state.any_user_binding_conflicts(),
|this| {
this.indicator(Indicator::dot().color(Color::Warning))
},
)
.tooltip({
let filter_state = self.filter_state;
let focus_handle = focus_handle.clone();
move |window, cx| {
Tooltip::for_action_in(
match filter_state {
FilterState::All => "Show Conflicts",
FilterState::Conflicts => "Hide Conflicts",
},
&ToggleConflictFilter,
&focus_handle.clone(),
window,
cx,
)
}
})
.selected_icon_color(Color::Warning)
.toggle_state(matches!(
self.filter_state,
FilterState::Conflicts
))
.on_click(|_, window, cx| {
window.dispatch_action(
ToggleConflictFilter.boxed_clone(),
cx,
);
}),
),
)
.when_some(
match self.search_mode {
SearchMode::Normal => None,
SearchMode::KeyStroke { exact_match } => Some(exact_match),
},
|this, exact_match| {
this.child(
h_flex()
.map(|this| {
if self
.keybinding_conflict_state
.any_user_binding_conflicts()
{
this.pr(rems_from_px(54.))
} else {
this.pr_7()
}
})
.gap_2()
.child(self.keystroke_editor.clone())
.child(
IconButton::new(
"keystrokes-exact-match",
IconName::CaseSensitive,
)
.tooltip({
let keystroke_focus_handle =
self.keystroke_editor.read(cx).focus_handle(cx);
move |window, cx| {
Tooltip::for_action_in(
"Toggle Exact Match Mode",
&ToggleExactKeystrokeMatching,
&keystroke_focus_handle,
window,
cx,
)
}
})
.shape(IconButtonShape::Square)
.toggle_state(exact_match)
.on_click(
cx.listener(|_, _, window, cx| {
window.dispatch_action(
ToggleExactKeystrokeMatching.boxed_clone(),
cx,
);
}),
),
),
)
},
),
)
.child(
Table::new()
.interactable(&self.table_interaction_state)
.striped()
.empty_table_callback({
let this = cx.entity();
move |window, cx| this.read(cx).render_no_matches_hint(window, cx)
})
.column_widths([
DefiniteLength::Absolute(AbsoluteLength::Pixels(px(40.))),
DefiniteLength::Fraction(0.25),
DefiniteLength::Fraction(0.20),
DefiniteLength::Fraction(0.14),
DefiniteLength::Fraction(0.45),
DefiniteLength::Fraction(0.08),
])
.resizable_columns(
[
ResizeBehavior::None,
ResizeBehavior::Resizable,
ResizeBehavior::Resizable,
ResizeBehavior::Resizable,
ResizeBehavior::Resizable,
ResizeBehavior::Resizable, // this column doesn't matter
],
&self.current_widths,
cx,
)
.header(["", "Action", "Arguments", "Keystrokes", "Context", "Source"])
.uniform_list(
"keymap-editor-table",
row_count,
cx.processor(move |this, range: Range<usize>, _window, cx| {
let context_menu_deployed = this.context_menu_deployed();
range
.filter_map(|index| {
let candidate_id = this.matches.get(index)?.candidate_id;
let binding = &this.keybindings[candidate_id];
let action_name = binding.action().name;
let conflict = this.get_conflict(index);
let is_overridden = conflict.is_some_and(|conflict| {
!conflict.is_user_keybind_conflict()
});
let icon = this.create_row_button(index, conflict, cx);
let action = div()
.id(("keymap action", index))
.child({
if action_name != gpui::NoAction.name() {
binding
.action()
.humanized_name
.clone()
.into_any_element()
} else {
const NULL: SharedString =
SharedString::new_static("<null>");
muted_styled_text(NULL.clone(), cx)
.into_any_element()
}
})
.when(
!context_menu_deployed
&& this.show_hover_menus
&& !is_overridden,
|this| {
this.tooltip({
let action_name = binding.action().name;
let action_docs =
binding.action().documentation;
move |_, cx| {
let action_tooltip =
Tooltip::new(action_name);
let action_tooltip = match action_docs {
Some(docs) => action_tooltip.meta(docs),
None => action_tooltip,
};
cx.new(|_| action_tooltip).into()
}
})
},
)
.into_any_element();
let keystrokes = binding.ui_key_binding().cloned().map_or(
binding
.keystroke_text()
.cloned()
.unwrap_or_default()
.into_any_element(),
IntoElement::into_any_element,
);
let action_arguments = match binding.action().arguments.clone()
{
Some(arguments) => arguments.into_any_element(),
None => {
if binding.action().has_schema {
muted_styled_text(NO_ACTION_ARGUMENTS_TEXT, cx)
.into_any_element()
} else {
gpui::Empty.into_any_element()
}
}
};
let context = binding.context().cloned().map_or(
gpui::Empty.into_any_element(),
|context| {
let is_local = context.local().is_some();
div()
.id(("keymap context", index))
.child(context.clone())
.when(
is_local
&& !context_menu_deployed
&& !is_overridden
&& this.show_hover_menus,
|this| {
this.tooltip(Tooltip::element({
move |_, _| {
context.clone().into_any_element()
}
}))
},
)
.into_any_element()
},
);
let source = binding
.keybind_source()
.map(|source| source.name())
.unwrap_or_default()
.into_any_element();
Some([
icon.into_any_element(),
action,
action_arguments,
keystrokes,
context,
source,
])
})
.collect()
}),
)
.map_row(cx.processor(
|this, (row_index, row): (usize, Stateful<Div>), _window, cx| {
let conflict = this.get_conflict(row_index);
let is_selected = this.selected_index == Some(row_index);
let row_id = row_group_id(row_index);
div()
.id(("keymap-row-wrapper", row_index))
.child(
row.id(row_id.clone())
.on_any_mouse_down(cx.listener(
move |this,
mouse_down_event: &gpui::MouseDownEvent,
window,
cx| {
match mouse_down_event.button {
MouseButton::Right => {
this.select_index(
row_index, None, window, cx,
);
this.create_context_menu(
mouse_down_event.position,
window,
cx,
);
}
_ => {}
}
},
))
.on_click(cx.listener(
move |this, event: &ClickEvent, window, cx| {
this.select_index(row_index, None, window, cx);
if event.up.click_count == 2 {
this.open_edit_keybinding_modal(
false, window, cx,
);
}
},
))
.group(row_id)
.when(
conflict.is_some_and(|conflict| {
!conflict.is_user_keybind_conflict()
}),
|row| {
const OVERRIDDEN_OPACITY: f32 = 0.5;
row.opacity(OVERRIDDEN_OPACITY)
},
)
.when_some(
conflict.filter(|conflict| {
!this.context_menu_deployed() &&
!conflict.is_user_keybind_conflict()
}),
|row, conflict| {
let overriding_binding = this.keybindings.get(conflict.index);
let context = overriding_binding.and_then(|binding| {
match conflict.override_source {
KeybindSource::User => Some("your keymap"),
KeybindSource::Vim => Some("the vim keymap"),
KeybindSource::Base => Some("your base keymap"),
_ => {
log::error!("Unexpected override from the {} keymap", conflict.override_source.name());
None
}
}.map(|source| format!("This keybinding is overridden by the '{}' binding from {}.", binding.action().humanized_name, source))
}).unwrap_or_else(|| "This binding is overridden.".to_string());
row.tooltip(Tooltip::text(context))},
),
)
.border_2()
.when(
conflict.is_some_and(|conflict| {
conflict.is_user_keybind_conflict()
}),
|row| row.bg(cx.theme().status().error_background),
)
.when(is_selected, |row| {
row.border_color(cx.theme().colors().panel_focused_border)
})
.into_any_element()
}),
),
)
.on_scroll_wheel(cx.listener(|this, event: &ScrollWheelEvent, _, cx| {
// This ensures that the menu is not dismissed in cases where scroll events
// with a delta of zero are emitted
if !event.delta.pixel_delta(px(1.)).y.is_zero() {
this.context_menu.take();
cx.notify();
}
}))
.children(self.context_menu.as_ref().map(|(menu, position, _)| {
deferred(
anchored()
.position(*position)
.anchor(gpui::Corner::TopLeft)
.child(menu.clone()),
)
.with_priority(1)
}))
}
}
fn row_group_id(row_index: usize) -> SharedString {
SharedString::new(format!("keymap-table-row-{}", row_index))
}
fn base_button_style(row_index: usize, icon: IconName) -> IconButton {
IconButton::new(("keymap-icon", row_index), icon)
.shape(IconButtonShape::Square)
.size(ButtonSize::Compact)
}
#[derive(Debug, Clone, IntoElement)]
struct SyntaxHighlightedText {
text: SharedString,
language: Arc<Language>,
}
impl SyntaxHighlightedText {
pub fn new(text: impl Into<SharedString>, language: Arc<Language>) -> Self {
Self {
text: text.into(),
language,
}
}
}
impl RenderOnce for SyntaxHighlightedText {
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));
}
StyledText::new(text).with_runs(runs)
}
}
#[derive(PartialEq)]
struct InputError {
severity: ui::Severity,
content: SharedString,
}
impl InputError {
fn warning(message: impl Into<SharedString>) -> Self {
Self {
severity: ui::Severity::Warning,
content: message.into(),
}
}
fn error(message: anyhow::Error) -> Self {
Self {
severity: ui::Severity::Error,
content: message.to_string().into(),
}
}
}
struct KeybindingEditorModal {
creating: bool,
editing_keybind: ProcessedBinding,
editing_keybind_idx: usize,
keybind_editor: Entity<KeystrokeInput>,
context_editor: Entity<SingleLineInput>,
action_arguments_editor: Option<Entity<ActionArgumentsEditor>>,
fs: Arc<dyn Fs>,
error: Option<InputError>,
keymap_editor: Entity<KeymapEditor>,
workspace: WeakEntity<Workspace>,
focus_state: KeybindingEditorModalFocusState,
}
impl ModalView for KeybindingEditorModal {}
impl EventEmitter<DismissEvent> for KeybindingEditorModal {}
impl Focusable for KeybindingEditorModal {
fn focus_handle(&self, cx: &App) -> FocusHandle {
self.keybind_editor.focus_handle(cx)
}
}
impl KeybindingEditorModal {
pub fn new(
create: bool,
editing_keybind: ProcessedBinding,
editing_keybind_idx: usize,
keymap_editor: Entity<KeymapEditor>,
action_args_temp_dir: Option<&std::path::Path>,
workspace: WeakEntity<Workspace>,
fs: Arc<dyn Fs>,
window: &mut Window,
cx: &mut App,
) -> Self {
let keybind_editor = cx
.new(|cx| KeystrokeInput::new(editing_keybind.keystrokes().map(Vec::from), window, cx));
let context_editor: Entity<SingleLineInput> = cx.new(|cx| {
let input = SingleLineInput::new(window, cx, "Keybinding Context")
.label("Edit Context")
.label_size(LabelSize::Default);
if let Some(context) = editing_keybind
.context()
.and_then(KeybindContextString::local)
{
input.editor().update(cx, |editor, cx| {
editor.set_text(context.clone(), window, cx);
});
}
let editor_entity = input.editor().clone();
let workspace = workspace.clone();
cx.spawn(async move |_input_handle, cx| {
let contexts = cx
.background_spawn(async { collect_contexts_from_assets() })
.await;
let language = load_keybind_context_language(workspace, cx).await;
editor_entity
.update(cx, |editor, cx| {
if let Some(buffer) = editor.buffer().read(cx).as_singleton() {
buffer.update(cx, |buffer, cx| {
buffer.set_language(Some(language), cx);
});
}
editor.set_completion_provider(Some(std::rc::Rc::new(
KeyContextCompletionProvider { contexts },
)));
})
.context("Failed to load completions for keybinding context")
})
.detach_and_log_err(cx);
input
});
let action_arguments_editor = editing_keybind.action().has_schema.then(|| {
let arguments = editing_keybind
.action()
.arguments
.as_ref()
.map(|args| args.text.clone());
cx.new(|cx| {
ActionArgumentsEditor::new(
editing_keybind.action().name,
arguments,
action_args_temp_dir,
workspace.clone(),
window,
cx,
)
})
});
let focus_state = KeybindingEditorModalFocusState::new(
keybind_editor.focus_handle(cx),
action_arguments_editor
.as_ref()
.map(|args_editor| args_editor.focus_handle(cx)),
context_editor.focus_handle(cx),
);
Self {
creating: create,
editing_keybind,
editing_keybind_idx,
fs,
keybind_editor,
context_editor,
action_arguments_editor,
error: None,
keymap_editor,
workspace,
focus_state,
}
}
fn set_error(&mut self, error: InputError, cx: &mut Context<Self>) -> bool {
if self.error.as_ref().is_some_and(|old_error| {
old_error.severity == ui::Severity::Warning && *old_error == error
}) {
false
} else {
self.error = Some(error);
cx.notify();
true
}
}
fn validate_action_arguments(&self, cx: &App) -> anyhow::Result<Option<String>> {
let action_arguments = self
.action_arguments_editor
.as_ref()
.map(|editor| editor.read(cx).editor.read(cx).text(cx));
let value = action_arguments
.as_ref()
.map(|args| {
serde_json::from_str(args).context("Failed to parse action arguments as JSON")
})
.transpose()?;
cx.build_action(&self.editing_keybind.action().name, value)
.context("Failed to validate action arguments")?;
Ok(action_arguments)
}
fn validate_keystrokes(&self, cx: &App) -> anyhow::Result<Vec<Keystroke>> {
let new_keystrokes = self
.keybind_editor
.read_with(cx, |editor, _| editor.keystrokes().to_vec());
anyhow::ensure!(!new_keystrokes.is_empty(), "Keystrokes cannot be empty");
Ok(new_keystrokes)
}
fn validate_context(&self, cx: &App) -> anyhow::Result<Option<String>> {
let new_context = self
.context_editor
.read_with(cx, |input, cx| input.editor().read(cx).text(cx));
let Some(context) = new_context.is_empty().not().then_some(new_context) else {
return Ok(None);
};
gpui::KeyBindingContextPredicate::parse(&context).context("Failed to parse key context")?;
Ok(Some(context))
}
fn save_or_display_error(&mut self, cx: &mut Context<Self>) {
self.save(cx).map_err(|err| self.set_error(err, cx)).ok();
}
fn save(&mut self, cx: &mut Context<Self>) -> Result<(), InputError> {
let existing_keybind = self.editing_keybind.clone();
let fs = self.fs.clone();
let tab_size = cx.global::<settings::SettingsStore>().json_tab_size();
let new_keystrokes = self
.validate_keystrokes(cx)
.map_err(InputError::error)?
.into_iter()
.map(remove_key_char)
.collect::<Vec<_>>();
let new_context = self.validate_context(cx).map_err(InputError::error)?;
let new_action_args = self
.validate_action_arguments(cx)
.map_err(InputError::error)?;
let action_mapping = ActionMapping {
keystrokes: new_keystrokes,
context: new_context.map(SharedString::from),
};
let conflicting_indices = self
.keymap_editor
.read(cx)
.keybinding_conflict_state
.conflicting_indices_for_mapping(
&action_mapping,
self.creating.not().then_some(self.editing_keybind_idx),
);
conflicting_indices.map(|KeybindConflict {
first_conflict_index,
remaining_conflict_amount,
}|
{
let conflicting_action_name = self
.keymap_editor
.read(cx)
.keybindings
.get(first_conflict_index)
.map(|keybind| keybind.action().name);
let warning_message = match conflicting_action_name {
Some(name) => {
if remaining_conflict_amount > 0 {
format!(
"Your keybind would conflict with the \"{}\" action and {} other bindings",
name, remaining_conflict_amount
)
} else {
format!("Your keybind would conflict with the \"{}\" action", name)
}
}
None => {
log::info!(
"Could not find action in keybindings with index {}",
first_conflict_index
);
"Your keybind would conflict with other actions".to_string()
}
};
let warning = InputError::warning(warning_message);
if self.error.as_ref().is_some_and(|old_error| *old_error == warning) {
Ok(())
} else {
Err(warning)
}
}).unwrap_or(Ok(()))?;
let create = self.creating;
let status_toast = StatusToast::new(
format!(
"Saved edits to the {} action.",
&self.editing_keybind.action().humanized_name
),
cx,
move |this, _cx| {
this.icon(ToastIcon::new(IconName::Check).color(Color::Success))
.dismiss_button(true)
// .action("Undo", f) todo: wire the undo functionality
},
);
self.workspace
.update(cx, |workspace, cx| {
workspace.toggle_status_toast(status_toast, cx);
})
.log_err();
cx.spawn(async move |this, cx| {
let action_name = existing_keybind.action().name;
if let Err(err) = save_keybinding_update(
create,
existing_keybind,
&action_mapping,
new_action_args.as_deref(),
&fs,
tab_size,
)
.await
{
this.update(cx, |this, cx| {
this.set_error(InputError::error(err), cx);
})
.log_err();
} else {
this.update(cx, |this, cx| {
this.keymap_editor.update(cx, |keymap, cx| {
keymap.previous_edit = Some(PreviousEdit::Keybinding {
action_mapping,
action_name,
fallback: keymap
.table_interaction_state
.read(cx)
.get_scrollbar_offset(Axis::Vertical),
})
});
cx.emit(DismissEvent);
})
.ok();
}
})
.detach();
Ok(())
}
fn key_context(&self) -> KeyContext {
let mut key_context = KeyContext::new_with_defaults();
key_context.add("KeybindEditorModal");
key_context
}
fn focus_next(&mut self, _: &menu::SelectNext, window: &mut Window, cx: &mut Context<Self>) {
self.focus_state.focus_next(window, cx);
}
fn focus_prev(
&mut self,
_: &menu::SelectPrevious,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.focus_state.focus_previous(window, cx);
}
fn confirm(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context<Self>) {
self.save_or_display_error(cx);
}
fn cancel(&mut self, _: &menu::Cancel, _window: &mut Window, cx: &mut Context<Self>) {
cx.emit(DismissEvent)
}
}
fn remove_key_char(Keystroke { modifiers, key, .. }: Keystroke) -> Keystroke {
Keystroke {
modifiers,
key,
..Default::default()
}
}
impl Render for KeybindingEditorModal {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let theme = cx.theme().colors();
v_flex()
.w(rems(34.))
.elevation_3(cx)
.key_context(self.key_context())
.on_action(cx.listener(Self::focus_next))
.on_action(cx.listener(Self::focus_prev))
.on_action(cx.listener(Self::confirm))
.on_action(cx.listener(Self::cancel))
.child(
Modal::new("keybinding_editor_modal", None)
.header(
ModalHeader::new().child(
v_flex()
.pb_1p5()
.mb_1()
.gap_0p5()
.border_b_1()
.border_color(theme.border_variant)
.child(Label::new(
self.editing_keybind.action().humanized_name.clone(),
))
.when_some(
self.editing_keybind.action().documentation,
|this, docs| {
this.child(
Label::new(docs)
.size(LabelSize::Small)
.color(Color::Muted),
)
},
),
),
)
.section(
Section::new().child(
v_flex()
.gap_2()
.child(
v_flex()
.child(Label::new("Edit Keystroke"))
.gap_1()
.child(self.keybind_editor.clone()),
)
.when_some(self.action_arguments_editor.clone(), |this, editor| {
this.child(
v_flex()
.mt_1p5()
.gap_1()
.child(Label::new("Edit Arguments"))
.child(editor),
)
})
.child(self.context_editor.clone())
.when_some(self.error.as_ref(), |this, error| {
this.child(
Banner::new()
.severity(error.severity)
// For some reason, the div overflows its container to the
//right. The padding accounts for that.
.child(
div()
.size_full()
.pr_2()
.child(Label::new(error.content.clone())),
),
)
}),
),
)
.footer(
ModalFooter::new().end_slot(
h_flex()
.gap_1()
.child(
Button::new("cancel", "Cancel")
.on_click(cx.listener(|_, _, _, cx| cx.emit(DismissEvent))),
)
.child(Button::new("save-btn", "Save").on_click(cx.listener(
|this, _event, _window, cx| {
this.save_or_display_error(cx);
},
))),
),
),
)
}
}
struct KeybindingEditorModalFocusState {
handles: Vec<FocusHandle>,
}
impl KeybindingEditorModalFocusState {
fn new(
keystrokes: FocusHandle,
action_input: Option<FocusHandle>,
context: FocusHandle,
) -> Self {
Self {
handles: Vec::from_iter(
[Some(keystrokes), action_input, Some(context)]
.into_iter()
.flatten(),
),
}
}
fn focused_index(&self, window: &Window, cx: &App) -> Option<i32> {
self.handles
.iter()
.position(|handle| handle.contains_focused(window, cx))
.map(|i| i as i32)
}
fn focus_index(&self, mut index: i32, window: &mut Window) {
if index < 0 {
index = self.handles.len() as i32 - 1;
}
if index >= self.handles.len() as i32 {
index = 0;
}
window.focus(&self.handles[index as usize]);
}
fn focus_next(&self, window: &mut Window, cx: &App) {
let index_to_focus = if let Some(index) = self.focused_index(window, cx) {
index + 1
} else {
0
};
self.focus_index(index_to_focus, window);
}
fn focus_previous(&self, window: &mut Window, cx: &App) {
let index_to_focus = if let Some(index) = self.focused_index(window, cx) {
index - 1
} else {
self.handles.len() as i32 - 1
};
self.focus_index(index_to_focus, window);
}
}
struct ActionArgumentsEditor {
editor: Entity<Editor>,
focus_handle: FocusHandle,
is_loading: bool,
/// See documentation in `KeymapEditor` for why a temp dir is needed.
/// This field exists because the keymap editor temp dir creation may fail,
/// and rather than implement a complicated retry mechanism, we simply
/// fallback to trying to create a temporary directory in this editor on
/// demand. Of note is that the TempDir struct will remove the directory
/// when dropped.
backup_temp_dir: Option<tempfile::TempDir>,
}
impl Focusable for ActionArgumentsEditor {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl ActionArgumentsEditor {
fn new(
action_name: &'static str,
arguments: Option<SharedString>,
temp_dir: Option<&std::path::Path>,
workspace: WeakEntity<Workspace>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let focus_handle = cx.focus_handle();
cx.on_focus_in(&focus_handle, window, |this, window, cx| {
this.editor.focus_handle(cx).focus(window);
})
.detach();
let editor = cx.new(|cx| {
let mut editor = Editor::auto_height_unbounded(1, window, cx);
Self::set_editor_text(&mut editor, arguments.clone(), window, cx);
editor.set_read_only(true);
editor
});
let temp_dir = temp_dir.map(|path| path.to_owned());
cx.spawn_in(window, async move |this, cx| {
let result = async {
let (project, fs) = workspace.read_with(cx, |workspace, _cx| {
(
workspace.project().downgrade(),
workspace.app_state().fs.clone(),
)
})?;
let file_name =
project::lsp_store::json_language_server_ext::normalized_action_file_name(
action_name,
);
let (buffer, backup_temp_dir) =
Self::create_temp_buffer(temp_dir, file_name.clone(), project.clone(), fs, cx)
.await
.context(concat!(
"Failed to create temporary buffer for action arguments. ",
"Auto-complete will not work"
))?;
let editor = cx.new_window_entity(|window, cx| {
let multi_buffer = cx.new(|cx| editor::MultiBuffer::singleton(buffer, cx));
let mut editor = Editor::new(
editor::EditorMode::Full {
scale_ui_elements_with_buffer_font_size: true,
show_active_line_background: false,
sized_by_content: true,
},
multi_buffer,
project.upgrade(),
window,
cx,
);
editor.set_searchable(false);
editor.disable_scrollbars_and_minimap(window, cx);
editor.set_show_edit_predictions(Some(false), window, cx);
editor.set_show_gutter(false, cx);
Self::set_editor_text(&mut editor, arguments, window, cx);
editor
})?;
this.update_in(cx, |this, window, cx| {
if this.editor.focus_handle(cx).is_focused(window) {
editor.focus_handle(cx).focus(window);
}
this.editor = editor;
this.backup_temp_dir = backup_temp_dir;
this.is_loading = false;
})?;
anyhow::Ok(())
}
.await;
if result.is_err() {
let json_language = load_json_language(workspace.clone(), cx).await;
this.update(cx, |this, cx| {
this.editor.update(cx, |editor, cx| {
if let Some(buffer) = editor.buffer().read(cx).as_singleton() {
buffer.update(cx, |buffer, cx| {
buffer.set_language(Some(json_language.clone()), cx)
});
}
})
// .context("Failed to load JSON language for editing keybinding action arguments input")
})
.ok();
this.update(cx, |this, _cx| {
this.is_loading = false;
})
.ok();
}
return result;
})
.detach_and_log_err(cx);
Self {
editor,
focus_handle,
is_loading: true,
backup_temp_dir: None,
}
}
fn set_editor_text(
editor: &mut Editor,
arguments: Option<SharedString>,
window: &mut Window,
cx: &mut Context<Editor>,
) {
if let Some(arguments) = arguments {
editor.set_text(arguments, window, cx);
} else {
// TODO: default value from schema?
editor.set_placeholder_text("Action Arguments", cx);
}
}
async fn create_temp_buffer(
temp_dir: Option<std::path::PathBuf>,
file_name: String,
project: WeakEntity<Project>,
fs: Arc<dyn Fs>,
cx: &mut AsyncApp,
) -> anyhow::Result<(Entity<language::Buffer>, Option<tempfile::TempDir>)> {
let (temp_file_path, temp_dir) = {
let file_name = file_name.clone();
async move {
let temp_dir_backup = match temp_dir.as_ref() {
Some(_) => None,
None => {
let temp_dir = paths::temp_dir();
let sub_temp_dir = tempfile::Builder::new()
.tempdir_in(temp_dir)
.context("Failed to create temporary directory")?;
Some(sub_temp_dir)
}
};
let dir_path = temp_dir.as_deref().unwrap_or_else(|| {
temp_dir_backup
.as_ref()
.expect("created backup tempdir")
.path()
});
let path = dir_path.join(file_name);
fs.create_file(
&path,
fs::CreateOptions {
ignore_if_exists: true,
overwrite: true,
},
)
.await
.context("Failed to create temporary file")?;
anyhow::Ok((path, temp_dir_backup))
}
}
.await
.context("Failed to create backing file")?;
project
.update(cx, |project, cx| {
project.open_local_buffer(temp_file_path, cx)
})?
.await
.context("Failed to create buffer")
.map(|buffer| (buffer, temp_dir))
}
}
impl Render for ActionArgumentsEditor {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let background_color;
let border_color;
let text_style = {
let colors = cx.theme().colors();
let settings = theme::ThemeSettings::get_global(cx);
background_color = colors.editor_background;
border_color = if self.is_loading {
colors.border_disabled
} else {
colors.border_variant
};
TextStyleRefinement {
font_size: Some(rems(0.875).into()),
font_weight: Some(settings.buffer_font.weight),
line_height: Some(relative(1.2)),
font_style: Some(gpui::FontStyle::Normal),
color: self.is_loading.then_some(colors.text_disabled),
..Default::default()
}
};
self.editor
.update(cx, |editor, _| editor.set_text_style_refinement(text_style));
return v_flex().w_full().child(
h_flex()
.min_h_8()
.min_w_48()
.px_2()
.py_1p5()
.flex_grow()
.rounded_lg()
.bg(background_color)
.border_1()
.border_color(border_color)
.track_focus(&self.focus_handle)
.child(self.editor.clone()),
);
}
}
struct KeyContextCompletionProvider {
contexts: Vec<SharedString>,
}
impl CompletionProvider for KeyContextCompletionProvider {
fn completions(
&self,
_excerpt_id: editor::ExcerptId,
buffer: &Entity<language::Buffer>,
buffer_position: language::Anchor,
_trigger: editor::CompletionContext,
_window: &mut Window,
cx: &mut Context<Editor>,
) -> gpui::Task<anyhow::Result<Vec<project::CompletionResponse>>> {
let buffer = buffer.read(cx);
let mut count_back = 0;
for char in buffer.reversed_chars_at(buffer_position) {
if char.is_ascii_alphanumeric() || char == '_' {
count_back += 1;
} else {
break;
}
}
let start_anchor = buffer.anchor_before(
buffer_position
.to_offset(&buffer)
.saturating_sub(count_back),
);
let replace_range = start_anchor..buffer_position;
gpui::Task::ready(Ok(vec![project::CompletionResponse {
completions: self
.contexts
.iter()
.map(|context| project::Completion {
replace_range: replace_range.clone(),
label: language::CodeLabel::plain(context.to_string(), None),
new_text: context.to_string(),
documentation: None,
source: project::CompletionSource::Custom,
icon_path: None,
insert_text_mode: None,
confirm: None,
})
.collect(),
is_incomplete: false,
}]))
}
fn is_completion_trigger(
&self,
_buffer: &Entity<language::Buffer>,
_position: language::Anchor,
text: &str,
_trigger_in_words: bool,
_menu_is_open: bool,
_cx: &mut Context<Editor>,
) -> bool {
text.chars().last().map_or(false, |last_char| {
last_char.is_ascii_alphanumeric() || last_char == '_'
})
}
}
async fn load_json_language(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()),
))
});
}
async fn load_keybind_context_language(
workspace: WeakEntity<Workspace>,
cx: &mut AsyncApp,
) -> Arc<Language> {
let language_task = workspace
.read_with(cx, |workspace, cx| {
workspace
.project()
.read(cx)
.languages()
.language_for_name("Zed Keybind Context")
})
.context("Failed to load Zed Keybind Context language")
.log_err();
let language = match language_task {
Some(task) => task
.await
.context("Failed to load Zed Keybind Context language")
.log_err(),
None => None,
};
return language.unwrap_or_else(|| {
Arc::new(Language::new(
LanguageConfig {
name: "Zed Keybind Context".into(),
..Default::default()
},
Some(tree_sitter_rust::LANGUAGE.into()),
))
});
}
async fn save_keybinding_update(
create: bool,
existing: ProcessedBinding,
action_mapping: &ActionMapping,
new_args: Option<&str>,
fs: &Arc<dyn Fs>,
tab_size: usize,
) -> anyhow::Result<()> {
let keymap_contents = settings::KeymapFile::load_keymap_file(fs)
.await
.context("Failed to load keymap file")?;
let existing_keystrokes = existing.keystrokes().unwrap_or_default();
let existing_context = existing.context().and_then(KeybindContextString::local_str);
let existing_args = existing
.action()
.arguments
.as_ref()
.map(|args| args.text.as_ref());
let target = settings::KeybindUpdateTarget {
context: existing_context,
keystrokes: existing_keystrokes,
action_name: &existing.action().name,
action_arguments: existing_args,
};
let source = settings::KeybindUpdateTarget {
context: action_mapping.context.as_ref().map(|a| &***a),
keystrokes: &action_mapping.keystrokes,
action_name: &existing.action().name,
action_arguments: new_args,
};
let operation = if !create {
settings::KeybindUpdateOperation::Replace {
target,
target_keybind_source: existing.keybind_source().unwrap_or(KeybindSource::User),
source,
}
} else {
settings::KeybindUpdateOperation::Add {
source,
from: Some(target),
}
};
let (new_keybinding, removed_keybinding, source) = operation.generate_telemetry();
let updated_keymap_contents =
settings::KeymapFile::update_keybinding(operation, keymap_contents, tab_size)
.context("Failed to update keybinding")?;
fs.write(
paths::keymap_file().as_path(),
updated_keymap_contents.as_bytes(),
)
.await
.context("Failed to write keymap file")?;
telemetry::event!(
"Keybinding Updated",
new_keybinding = new_keybinding,
removed_keybinding = removed_keybinding,
source = source
);
Ok(())
}
async fn remove_keybinding(
existing: ProcessedBinding,
fs: &Arc<dyn Fs>,
tab_size: usize,
) -> anyhow::Result<()> {
let Some(keystrokes) = existing.keystrokes() else {
anyhow::bail!("Cannot remove a keybinding that does not exist");
};
let keymap_contents = settings::KeymapFile::load_keymap_file(fs)
.await
.context("Failed to load keymap file")?;
let operation = settings::KeybindUpdateOperation::Remove {
target: settings::KeybindUpdateTarget {
context: existing.context().and_then(KeybindContextString::local_str),
keystrokes,
action_name: &existing.action().name,
action_arguments: existing
.action()
.arguments
.as_ref()
.map(|arguments| arguments.text.as_ref()),
},
target_keybind_source: existing.keybind_source().unwrap_or(KeybindSource::User),
};
let (new_keybinding, removed_keybinding, source) = operation.generate_telemetry();
let updated_keymap_contents =
settings::KeymapFile::update_keybinding(operation, keymap_contents, tab_size)
.context("Failed to update keybinding")?;
fs.write(
paths::keymap_file().as_path(),
updated_keymap_contents.as_bytes(),
)
.await
.context("Failed to write keymap file")?;
telemetry::event!(
"Keybinding Removed",
new_keybinding = new_keybinding,
removed_keybinding = removed_keybinding,
source = source
);
Ok(())
}
#[derive(PartialEq, Eq, Debug, Copy, Clone)]
enum CloseKeystrokeResult {
Partial,
Close,
None,
}
struct KeystrokeInput {
keystrokes: Vec<Keystroke>,
placeholder_keystrokes: Option<Vec<Keystroke>>,
outer_focus_handle: FocusHandle,
inner_focus_handle: FocusHandle,
intercept_subscription: Option<Subscription>,
_focus_subscriptions: [Subscription; 2],
search: bool,
/// Handles tripe escape to stop recording
close_keystrokes: Option<Vec<Keystroke>>,
close_keystrokes_start: Option<usize>,
previous_modifiers: Modifiers,
}
impl KeystrokeInput {
const KEYSTROKE_COUNT_MAX: usize = 3;
fn new(
placeholder_keystrokes: Option<Vec<Keystroke>>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let outer_focus_handle = cx.focus_handle();
let inner_focus_handle = cx.focus_handle();
let _focus_subscriptions = [
cx.on_focus_in(&inner_focus_handle, window, Self::on_inner_focus_in),
cx.on_focus_out(&inner_focus_handle, window, Self::on_inner_focus_out),
];
Self {
keystrokes: Vec::new(),
placeholder_keystrokes,
inner_focus_handle,
outer_focus_handle,
intercept_subscription: None,
_focus_subscriptions,
search: false,
close_keystrokes: None,
close_keystrokes_start: None,
previous_modifiers: Modifiers::default(),
}
}
fn set_keystrokes(&mut self, keystrokes: Vec<Keystroke>, cx: &mut Context<Self>) {
self.keystrokes = keystrokes;
self.keystrokes_changed(cx);
}
fn dummy(modifiers: Modifiers) -> Keystroke {
return Keystroke {
modifiers,
key: "".to_string(),
key_char: None,
};
}
fn keystrokes_changed(&self, cx: &mut Context<Self>) {
cx.emit(());
cx.notify();
}
fn key_context() -> KeyContext {
let mut key_context = KeyContext::default();
key_context.add("KeystrokeInput");
key_context
}
fn handle_possible_close_keystroke(
&mut self,
keystroke: &Keystroke,
window: &mut Window,
cx: &mut Context<Self>,
) -> CloseKeystrokeResult {
let Some(keybind_for_close_action) = window
.highest_precedence_binding_for_action_in_context(&StopRecording, Self::key_context())
else {
log::trace!("No keybinding to stop recording keystrokes in keystroke input");
self.close_keystrokes.take();
self.close_keystrokes_start.take();
return CloseKeystrokeResult::None;
};
let action_keystrokes = keybind_for_close_action.keystrokes();
if let Some(mut close_keystrokes) = self.close_keystrokes.take() {
let mut index = 0;
while index < action_keystrokes.len() && index < close_keystrokes.len() {
if !close_keystrokes[index].should_match(&action_keystrokes[index]) {
break;
}
index += 1;
}
if index == close_keystrokes.len() {
if index >= action_keystrokes.len() {
self.close_keystrokes_start.take();
return CloseKeystrokeResult::None;
}
if keystroke.should_match(&action_keystrokes[index]) {
if action_keystrokes.len() >= 1 && index == action_keystrokes.len() - 1 {
self.stop_recording(&StopRecording, window, cx);
return CloseKeystrokeResult::Close;
} else {
close_keystrokes.push(keystroke.clone());
self.close_keystrokes = Some(close_keystrokes);
return CloseKeystrokeResult::Partial;
}
} else {
self.close_keystrokes_start.take();
return CloseKeystrokeResult::None;
}
}
} else if let Some(first_action_keystroke) = action_keystrokes.first()
&& keystroke.should_match(first_action_keystroke)
{
self.close_keystrokes = Some(vec![keystroke.clone()]);
return CloseKeystrokeResult::Partial;
}
self.close_keystrokes_start.take();
return CloseKeystrokeResult::None;
}
fn on_modifiers_changed(
&mut self,
event: &ModifiersChangedEvent,
_window: &mut Window,
cx: &mut Context<Self>,
) {
let keystrokes_len = self.keystrokes.len();
if self.previous_modifiers.modified()
&& event.modifiers.is_subset_of(&self.previous_modifiers)
{
self.previous_modifiers &= event.modifiers;
cx.stop_propagation();
return;
}
if let Some(last) = self.keystrokes.last_mut()
&& last.key.is_empty()
&& keystrokes_len <= Self::KEYSTROKE_COUNT_MAX
{
if self.search {
if self.previous_modifiers.modified() {
last.modifiers |= event.modifiers;
self.previous_modifiers |= event.modifiers;
} else {
self.keystrokes.push(Self::dummy(event.modifiers));
self.previous_modifiers |= event.modifiers;
}
} else if !event.modifiers.modified() {
self.keystrokes.pop();
} else {
last.modifiers = event.modifiers;
}
self.keystrokes_changed(cx);
} else if keystrokes_len < Self::KEYSTROKE_COUNT_MAX {
self.keystrokes.push(Self::dummy(event.modifiers));
if self.search {
self.previous_modifiers |= event.modifiers;
}
self.keystrokes_changed(cx);
}
cx.stop_propagation();
}
fn handle_keystroke(
&mut self,
keystroke: &Keystroke,
window: &mut Window,
cx: &mut Context<Self>,
) {
let close_keystroke_result = self.handle_possible_close_keystroke(keystroke, window, cx);
if close_keystroke_result != CloseKeystrokeResult::Close {
let key_len = self.keystrokes.len();
if let Some(last) = self.keystrokes.last_mut()
&& last.key.is_empty()
&& key_len <= Self::KEYSTROKE_COUNT_MAX
{
if self.search {
last.key = keystroke.key.clone();
if close_keystroke_result == CloseKeystrokeResult::Partial
&& self.close_keystrokes_start.is_none()
{
self.close_keystrokes_start = Some(self.keystrokes.len() - 1);
}
if self.search {
self.previous_modifiers = keystroke.modifiers;
}
self.keystrokes_changed(cx);
cx.stop_propagation();
return;
} else {
self.keystrokes.pop();
}
}
if self.keystrokes.len() < Self::KEYSTROKE_COUNT_MAX {
if close_keystroke_result == CloseKeystrokeResult::Partial
&& self.close_keystrokes_start.is_none()
{
self.close_keystrokes_start = Some(self.keystrokes.len());
}
self.keystrokes.push(keystroke.clone());
if self.search {
self.previous_modifiers = keystroke.modifiers;
} else if self.keystrokes.len() < Self::KEYSTROKE_COUNT_MAX {
self.keystrokes.push(Self::dummy(keystroke.modifiers));
}
} else if close_keystroke_result != CloseKeystrokeResult::Partial {
self.clear_keystrokes(&ClearKeystrokes, window, cx);
}
}
self.keystrokes_changed(cx);
cx.stop_propagation();
}
fn on_inner_focus_in(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
if self.intercept_subscription.is_none() {
let listener = cx.listener(|this, event: &gpui::KeystrokeEvent, window, cx| {
this.handle_keystroke(&event.keystroke, window, cx);
});
self.intercept_subscription = Some(cx.intercept_keystrokes(listener))
}
}
fn on_inner_focus_out(
&mut self,
_event: gpui::FocusOutEvent,
_window: &mut Window,
cx: &mut Context<Self>,
) {
self.intercept_subscription.take();
cx.notify();
}
fn keystrokes(&self) -> &[Keystroke] {
if let Some(placeholders) = self.placeholder_keystrokes.as_ref()
&& self.keystrokes.is_empty()
{
return placeholders;
}
if !self.search
&& self
.keystrokes
.last()
.map_or(false, |last| last.key.is_empty())
{
return &self.keystrokes[..self.keystrokes.len() - 1];
}
return &self.keystrokes;
}
fn render_keystrokes(&self, is_recording: bool) -> impl Iterator<Item = Div> {
let keystrokes = if let Some(placeholders) = self.placeholder_keystrokes.as_ref()
&& self.keystrokes.is_empty()
{
if is_recording {
&[]
} else {
placeholders.as_slice()
}
} else {
&self.keystrokes
};
keystrokes.iter().map(move |keystroke| {
h_flex().children(ui::render_keystroke(
keystroke,
Some(Color::Default),
Some(rems(0.875).into()),
ui::PlatformStyle::platform(),
false,
))
})
}
fn start_recording(&mut self, _: &StartRecording, window: &mut Window, cx: &mut Context<Self>) {
window.focus(&self.inner_focus_handle);
self.clear_keystrokes(&ClearKeystrokes, window, cx);
self.previous_modifiers = window.modifiers();
cx.stop_propagation();
}
fn stop_recording(&mut self, _: &StopRecording, window: &mut Window, cx: &mut Context<Self>) {
if !self.inner_focus_handle.is_focused(window) {
return;
}
window.focus(&self.outer_focus_handle);
if let Some(close_keystrokes_start) = self.close_keystrokes_start.take()
&& close_keystrokes_start < self.keystrokes.len()
{
self.keystrokes.drain(close_keystrokes_start..);
}
self.close_keystrokes.take();
cx.notify();
}
fn clear_keystrokes(
&mut self,
_: &ClearKeystrokes,
_window: &mut Window,
cx: &mut Context<Self>,
) {
self.keystrokes.clear();
self.keystrokes_changed(cx);
}
}
impl EventEmitter<()> for KeystrokeInput {}
impl Focusable for KeystrokeInput {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.outer_focus_handle.clone()
}
}
impl Render for KeystrokeInput {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let colors = cx.theme().colors();
let is_focused = self.outer_focus_handle.contains_focused(window, cx);
let is_recording = self.inner_focus_handle.is_focused(window);
let horizontal_padding = rems_from_px(64.);
let recording_bg_color = colors
.editor_background
.blend(colors.text_accent.opacity(0.1));
let recording_pulse = |color: Color| {
Icon::new(IconName::Circle)
.size(IconSize::Small)
.color(Color::Error)
.with_animation(
"recording-pulse",
Animation::new(std::time::Duration::from_secs(2))
.repeat()
.with_easing(gpui::pulsating_between(0.4, 0.8)),
{
let color = color.color(cx);
move |this, delta| this.color(Color::Custom(color.opacity(delta)))
},
)
};
let recording_indicator = h_flex()
.h_4()
.pr_1()
.gap_0p5()
.border_1()
.border_color(colors.border)
.bg(colors
.editor_background
.blend(colors.text_accent.opacity(0.1)))
.rounded_sm()
.child(recording_pulse(Color::Error))
.child(
Label::new("REC")
.size(LabelSize::XSmall)
.weight(FontWeight::SEMIBOLD)
.color(Color::Error),
);
let search_indicator = h_flex()
.h_4()
.pr_1()
.gap_0p5()
.border_1()
.border_color(colors.border)
.bg(colors
.editor_background
.blend(colors.text_accent.opacity(0.1)))
.rounded_sm()
.child(recording_pulse(Color::Accent))
.child(
Label::new("SEARCH")
.size(LabelSize::XSmall)
.weight(FontWeight::SEMIBOLD)
.color(Color::Accent),
);
let record_icon = if self.search {
IconName::MagnifyingGlass
} else {
IconName::PlayFilled
};
h_flex()
.id("keystroke-input")
.track_focus(&self.outer_focus_handle)
.py_2()
.px_3()
.gap_2()
.min_h_10()
.w_full()
.flex_1()
.justify_between()
.rounded_lg()
.overflow_hidden()
.map(|this| {
if is_recording {
this.bg(recording_bg_color)
} else {
this.bg(colors.editor_background)
}
})
.border_1()
.border_color(colors.border_variant)
.when(is_focused, |parent| {
parent.border_color(colors.border_focused)
})
.key_context(Self::key_context())
.on_action(cx.listener(Self::start_recording))
.on_action(cx.listener(Self::clear_keystrokes))
.child(
h_flex()
.w(horizontal_padding)
.gap_0p5()
.justify_start()
.flex_none()
.when(is_recording, |this| {
this.map(|this| {
if self.search {
this.child(search_indicator)
} else {
this.child(recording_indicator)
}
})
}),
)
.child(
h_flex()
.id("keystroke-input-inner")
.track_focus(&self.inner_focus_handle)
.on_modifiers_changed(cx.listener(Self::on_modifiers_changed))
.size_full()
.when(!self.search, |this| {
this.focus(|mut style| {
style.border_color = Some(colors.border_focused);
style
})
})
.w_full()
.min_w_0()
.justify_center()
.flex_wrap()
.gap(ui::DynamicSpacing::Base04.rems(cx))
.children(self.render_keystrokes(is_recording)),
)
.child(
h_flex()
.w(horizontal_padding)
.gap_0p5()
.justify_end()
.flex_none()
.map(|this| {
if is_recording {
this.child(
IconButton::new("stop-record-btn", IconName::StopFilled)
.shape(ui::IconButtonShape::Square)
.map(|this| {
this.tooltip(Tooltip::for_action_title(
if self.search {
"Stop Searching"
} else {
"Stop Recording"
},
&StopRecording,
))
})
.icon_color(Color::Error)
.on_click(cx.listener(|this, _event, window, cx| {
this.stop_recording(&StopRecording, window, cx);
})),
)
} else {
this.child(
IconButton::new("record-btn", record_icon)
.shape(ui::IconButtonShape::Square)
.map(|this| {
this.tooltip(Tooltip::for_action_title(
if self.search {
"Start Searching"
} else {
"Start Recording"
},
&StartRecording,
))
})
.when(!is_focused, |this| this.icon_color(Color::Muted))
.on_click(cx.listener(|this, _event, window, cx| {
this.start_recording(&StartRecording, window, cx);
})),
)
}
})
.child(
IconButton::new("clear-btn", IconName::Delete)
.shape(ui::IconButtonShape::Square)
.tooltip(Tooltip::for_action_title(
"Clear Keystrokes",
&ClearKeystrokes,
))
.when(!is_recording || !is_focused, |this| {
this.icon_color(Color::Muted)
})
.on_click(cx.listener(|this, _event, window, cx| {
this.clear_keystrokes(&ClearKeystrokes, window, cx);
})),
),
)
}
}
fn collect_contexts_from_assets() -> Vec<SharedString> {
let mut keymap_assets = vec![
util::asset_str::<SettingsAssets>(settings::DEFAULT_KEYMAP_PATH),
util::asset_str::<SettingsAssets>(settings::VIM_KEYMAP_PATH),
];
keymap_assets.extend(
BaseKeymap::OPTIONS
.iter()
.filter_map(|(_, base_keymap)| base_keymap.asset_path())
.map(util::asset_str::<SettingsAssets>),
);
let mut contexts = HashSet::default();
for keymap_asset in keymap_assets {
let Ok(keymap) = KeymapFile::parse(&keymap_asset) else {
continue;
};
for section in keymap.sections() {
let context_expr = &section.context;
let mut queue = Vec::new();
let Ok(root_context) = gpui::KeyBindingContextPredicate::parse(context_expr) else {
continue;
};
queue.push(root_context);
while let Some(context) = queue.pop() {
match context {
gpui::KeyBindingContextPredicate::Identifier(ident) => {
contexts.insert(ident);
}
gpui::KeyBindingContextPredicate::Equal(ident_a, ident_b) => {
contexts.insert(ident_a);
contexts.insert(ident_b);
}
gpui::KeyBindingContextPredicate::NotEqual(ident_a, ident_b) => {
contexts.insert(ident_a);
contexts.insert(ident_b);
}
gpui::KeyBindingContextPredicate::Descendant(ctx_a, ctx_b) => {
queue.push(*ctx_a);
queue.push(*ctx_b);
}
gpui::KeyBindingContextPredicate::Not(ctx) => {
queue.push(*ctx);
}
gpui::KeyBindingContextPredicate::And(ctx_a, ctx_b) => {
queue.push(*ctx_a);
queue.push(*ctx_b);
}
gpui::KeyBindingContextPredicate::Or(ctx_a, ctx_b) => {
queue.push(*ctx_a);
queue.push(*ctx_b);
}
}
}
}
}
let mut contexts = contexts.into_iter().collect::<Vec<_>>();
contexts.sort();
return contexts;
}
impl SerializableItem for KeymapEditor {
fn serialized_item_kind() -> &'static str {
"KeymapEditor"
}
fn cleanup(
workspace_id: workspace::WorkspaceId,
alive_items: Vec<workspace::ItemId>,
_window: &mut Window,
cx: &mut App,
) -> gpui::Task<gpui::Result<()>> {
workspace::delete_unloaded_items(
alive_items,
workspace_id,
"keybinding_editors",
&KEYBINDING_EDITORS,
cx,
)
}
fn deserialize(
_project: Entity<project::Project>,
workspace: WeakEntity<Workspace>,
workspace_id: workspace::WorkspaceId,
item_id: workspace::ItemId,
window: &mut Window,
cx: &mut App,
) -> gpui::Task<gpui::Result<Entity<Self>>> {
window.spawn(cx, async move |cx| {
if KEYBINDING_EDITORS
.get_keybinding_editor(item_id, workspace_id)?
.is_some()
{
cx.update(|window, cx| cx.new(|cx| KeymapEditor::new(workspace, window, cx)))
} else {
Err(anyhow!("No keybinding editor to deserialize"))
}
})
}
fn serialize(
&mut self,
workspace: &mut Workspace,
item_id: workspace::ItemId,
_closing: bool,
_window: &mut Window,
cx: &mut ui::Context<Self>,
) -> Option<gpui::Task<gpui::Result<()>>> {
let workspace_id = workspace.database_id()?;
Some(cx.background_spawn(async move {
KEYBINDING_EDITORS
.save_keybinding_editor(item_id, workspace_id)
.await
}))
}
fn should_serialize(&self, _event: &Self::Event) -> bool {
false
}
}
mod persistence {
use db::{define_connection, query, sqlez_macros::sql};
use workspace::WorkspaceDb;
define_connection! {
pub static ref KEYBINDING_EDITORS: KeybindingEditorDb<WorkspaceDb> =
&[sql!(
CREATE TABLE keybinding_editors (
workspace_id INTEGER,
item_id INTEGER UNIQUE,
PRIMARY KEY(workspace_id, item_id),
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
ON DELETE CASCADE
) STRICT;
)];
}
impl KeybindingEditorDb {
query! {
pub async fn save_keybinding_editor(
item_id: workspace::ItemId,
workspace_id: workspace::WorkspaceId
) -> Result<()> {
INSERT OR REPLACE INTO keybinding_editors(item_id, workspace_id)
VALUES (?, ?)
}
}
query! {
pub fn get_keybinding_editor(
item_id: workspace::ItemId,
workspace_id: workspace::WorkspaceId
) -> Result<Option<workspace::ItemId>> {
SELECT item_id
FROM keybinding_editors
WHERE item_id = ? AND workspace_id = ?
}
}
}
}