keymap_ui: Improve conflict resolution for semantically equal contexts (#36204)

Closes #ISSUE

Creates a function named `normalized_ctx_eq` that compares
`gpui::KeybindContextPredicate`'s while taking into account the
associativity of the binary operators. This function is now used to
compare context predicates in the keymap editor, greatly improving the
number of cases caught by our overloading and conflict detection

Release Notes:

- N/A *or* Added/Fixed/Improved ...
This commit is contained in:
Ben Kunkle 2025-08-21 19:18:25 -05:00 committed by GitHub
parent eeaadc098f
commit ca139b701e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -12,9 +12,11 @@ use fs::Fs;
use fuzzy::{StringMatch, StringMatchCandidate};
use gpui::{
Action, AppContext as _, AsyncApp, Axis, ClickEvent, Context, DismissEvent, Entity,
EventEmitter, FocusHandle, Focusable, Global, IsZero, KeyContext, Keystroke, MouseButton,
Point, ScrollStrategy, ScrollWheelEvent, Stateful, StyledText, Subscription, Task,
TextStyleRefinement, WeakEntity, actions, anchored, deferred, div,
EventEmitter, FocusHandle, Focusable, Global, IsZero,
KeyBindingContextPredicate::{And, Descendant, Equal, Identifier, Not, NotEqual, Or},
KeyContext, Keystroke, 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};
@ -182,15 +184,6 @@ struct KeybindConflict {
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,
@ -238,13 +231,21 @@ impl ConflictOrigin {
#[derive(Default)]
struct ConflictState {
conflicts: Vec<Option<ConflictOrigin>>,
keybind_mapping: HashMap<ActionMapping, Vec<ConflictOrigin>>,
keybind_mapping: ConflictKeybindMapping,
has_user_conflicts: bool,
}
type ConflictKeybindMapping = HashMap<
Vec<Keystroke>,
Vec<(
Option<gpui::KeyBindingContextPredicate>,
Vec<ConflictOrigin>,
)>,
>;
impl ConflictState {
fn new(key_bindings: &[ProcessedBinding]) -> Self {
let mut action_keybind_mapping: HashMap<_, Vec<ConflictOrigin>> = HashMap::default();
let mut action_keybind_mapping = ConflictKeybindMapping::default();
let mut largest_index = 0;
for (index, binding) in key_bindings
@ -252,29 +253,48 @@ impl ConflictState {
.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));
let mapping = binding.get_action_mapping();
let predicate = mapping
.context
.and_then(|ctx| gpui::KeyBindingContextPredicate::parse(&ctx).ok());
let entry = action_keybind_mapping
.entry(mapping.keystrokes)
.or_default();
let origin = ConflictOrigin::new(binding.source, index);
if let Some((_, origins)) =
entry
.iter_mut()
.find(|(other_predicate, _)| match (&predicate, other_predicate) {
(None, None) => true,
(Some(a), Some(b)) => normalized_ctx_eq(a, b),
_ => false,
})
{
origins.push(origin);
} else {
entry.push((predicate, vec![origin]));
}
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 entries in action_keybind_mapping.values_mut() {
for (_, indices) in entries.iter_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 })
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;
}
has_user_conflicts |= fst.override_source == KeybindSource::User
&& snd.override_source == KeybindSource::User;
}
Self {
@ -289,15 +309,34 @@ impl ConflictState {
action_mapping: &ActionMapping,
keybind_idx: Option<usize>,
) -> Option<KeybindConflict> {
self.keybind_mapping
.get(action_mapping)
.and_then(|indices| {
KeybindConflict::from_iter(
indices
let ActionMapping {
keystrokes,
context,
} = action_mapping;
let predicate = context
.as_deref()
.and_then(|ctx| gpui::KeyBindingContextPredicate::parse(&ctx).ok());
self.keybind_mapping.get(keystrokes).and_then(|entries| {
entries
.iter()
.find_map(|(other_predicate, indices)| {
match (&predicate, other_predicate) {
(None, None) => true,
(Some(pred), Some(other)) => normalized_ctx_eq(pred, other),
_ => false,
}
.then_some(indices)
})
.and_then(|indices| {
let mut indices = indices
.iter()
.filter(|&conflict| Some(conflict.index) != keybind_idx),
)
})
.filter(|&conflict| Some(conflict.index) != keybind_idx);
indices.next().map(|origin| KeybindConflict {
first_conflict_index: origin.index,
remaining_conflict_amount: indices.count(),
})
})
})
}
fn conflict_for_idx(&self, idx: usize) -> Option<ConflictOrigin> {
@ -3089,29 +3128,29 @@ fn collect_contexts_from_assets() -> Vec<SharedString> {
queue.push(root_context);
while let Some(context) = queue.pop() {
match context {
gpui::KeyBindingContextPredicate::Identifier(ident) => {
Identifier(ident) => {
contexts.insert(ident);
}
gpui::KeyBindingContextPredicate::Equal(ident_a, ident_b) => {
Equal(ident_a, ident_b) => {
contexts.insert(ident_a);
contexts.insert(ident_b);
}
gpui::KeyBindingContextPredicate::NotEqual(ident_a, ident_b) => {
NotEqual(ident_a, ident_b) => {
contexts.insert(ident_a);
contexts.insert(ident_b);
}
gpui::KeyBindingContextPredicate::Descendant(ctx_a, ctx_b) => {
Descendant(ctx_a, ctx_b) => {
queue.push(*ctx_a);
queue.push(*ctx_b);
}
gpui::KeyBindingContextPredicate::Not(ctx) => {
Not(ctx) => {
queue.push(*ctx);
}
gpui::KeyBindingContextPredicate::And(ctx_a, ctx_b) => {
And(ctx_a, ctx_b) => {
queue.push(*ctx_a);
queue.push(*ctx_b);
}
gpui::KeyBindingContextPredicate::Or(ctx_a, ctx_b) => {
Or(ctx_a, ctx_b) => {
queue.push(*ctx_a);
queue.push(*ctx_b);
}
@ -3126,6 +3165,127 @@ fn collect_contexts_from_assets() -> Vec<SharedString> {
contexts
}
fn normalized_ctx_eq(
a: &gpui::KeyBindingContextPredicate,
b: &gpui::KeyBindingContextPredicate,
) -> bool {
use gpui::KeyBindingContextPredicate::*;
return match (a, b) {
(Identifier(_), Identifier(_)) => a == b,
(Equal(a_left, a_right), Equal(b_left, b_right)) => {
(a_left == b_left && a_right == b_right) || (a_left == b_right && a_right == b_left)
}
(NotEqual(a_left, a_right), NotEqual(b_left, b_right)) => {
(a_left == b_left && a_right == b_right) || (a_left == b_right && a_right == b_left)
}
(Descendant(a_parent, a_child), Descendant(b_parent, b_child)) => {
normalized_ctx_eq(a_parent, b_parent) && normalized_ctx_eq(a_child, b_child)
}
(Not(a_expr), Not(b_expr)) => normalized_ctx_eq(a_expr, b_expr),
// Handle double negation: !(!a) == a
(Not(a_expr), b) if matches!(a_expr.as_ref(), Not(_)) => {
let Not(a_inner) = a_expr.as_ref() else {
unreachable!();
};
normalized_ctx_eq(b, a_inner)
}
(a, Not(b_expr)) if matches!(b_expr.as_ref(), Not(_)) => {
let Not(b_inner) = b_expr.as_ref() else {
unreachable!();
};
normalized_ctx_eq(a, b_inner)
}
(And(a_left, a_right), And(b_left, b_right))
if matches!(a_left.as_ref(), And(_, _))
|| matches!(a_right.as_ref(), And(_, _))
|| matches!(b_left.as_ref(), And(_, _))
|| matches!(b_right.as_ref(), And(_, _)) =>
{
let mut a_operands = Vec::new();
flatten_and(a, &mut a_operands);
let mut b_operands = Vec::new();
flatten_and(b, &mut b_operands);
compare_operand_sets(&a_operands, &b_operands)
}
(And(a_left, a_right), And(b_left, b_right)) => {
(normalized_ctx_eq(a_left, b_left) && normalized_ctx_eq(a_right, b_right))
|| (normalized_ctx_eq(a_left, b_right) && normalized_ctx_eq(a_right, b_left))
}
(Or(a_left, a_right), Or(b_left, b_right))
if matches!(a_left.as_ref(), Or(_, _))
|| matches!(a_right.as_ref(), Or(_, _))
|| matches!(b_left.as_ref(), Or(_, _))
|| matches!(b_right.as_ref(), Or(_, _)) =>
{
let mut a_operands = Vec::new();
flatten_or(a, &mut a_operands);
let mut b_operands = Vec::new();
flatten_or(b, &mut b_operands);
compare_operand_sets(&a_operands, &b_operands)
}
(Or(a_left, a_right), Or(b_left, b_right)) => {
(normalized_ctx_eq(a_left, b_left) && normalized_ctx_eq(a_right, b_right))
|| (normalized_ctx_eq(a_left, b_right) && normalized_ctx_eq(a_right, b_left))
}
_ => false,
};
fn flatten_and<'a>(
pred: &'a gpui::KeyBindingContextPredicate,
operands: &mut Vec<&'a gpui::KeyBindingContextPredicate>,
) {
use gpui::KeyBindingContextPredicate::*;
match pred {
And(left, right) => {
flatten_and(left, operands);
flatten_and(right, operands);
}
_ => operands.push(pred),
}
}
fn flatten_or<'a>(
pred: &'a gpui::KeyBindingContextPredicate,
operands: &mut Vec<&'a gpui::KeyBindingContextPredicate>,
) {
use gpui::KeyBindingContextPredicate::*;
match pred {
Or(left, right) => {
flatten_or(left, operands);
flatten_or(right, operands);
}
_ => operands.push(pred),
}
}
fn compare_operand_sets(
a: &[&gpui::KeyBindingContextPredicate],
b: &[&gpui::KeyBindingContextPredicate],
) -> bool {
if a.len() != b.len() {
return false;
}
// For each operand in a, find a matching operand in b
let mut b_matched = vec![false; b.len()];
for a_operand in a {
let mut found = false;
for (b_idx, b_operand) in b.iter().enumerate() {
if !b_matched[b_idx] && normalized_ctx_eq(a_operand, b_operand) {
b_matched[b_idx] = true;
found = true;
break;
}
}
if !found {
return false;
}
}
true
}
}
impl SerializableItem for KeymapEditor {
fn serialized_item_kind() -> &'static str {
"KeymapEditor"
@ -3228,3 +3388,152 @@ mod persistence {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalized_ctx_cmp() {
#[track_caller]
fn cmp(a: &str, b: &str) -> bool {
let a = gpui::KeyBindingContextPredicate::parse(a)
.expect("Failed to parse keybinding context a");
let b = gpui::KeyBindingContextPredicate::parse(b)
.expect("Failed to parse keybinding context b");
normalized_ctx_eq(&a, &b)
}
// Basic equality - identical expressions
assert!(cmp("a && b", "a && b"));
assert!(cmp("a || b", "a || b"));
assert!(cmp("a == b", "a == b"));
assert!(cmp("a != b", "a != b"));
assert!(cmp("a > b", "a > b"));
assert!(cmp("!a", "!a"));
// AND operator - associative/commutative
assert!(cmp("a && b", "b && a"));
assert!(cmp("a && b && c", "c && b && a"));
assert!(cmp("a && b && c", "b && a && c"));
assert!(cmp("a && b && c && d", "d && c && b && a"));
// OR operator - associative/commutative
assert!(cmp("a || b", "b || a"));
assert!(cmp("a || b || c", "c || b || a"));
assert!(cmp("a || b || c", "b || a || c"));
assert!(cmp("a || b || c || d", "d || c || b || a"));
// Equality operator - associative/commutative
assert!(cmp("a == b", "b == a"));
assert!(cmp("x == y", "y == x"));
// Inequality operator - associative/commutative
assert!(cmp("a != b", "b != a"));
assert!(cmp("x != y", "y != x"));
// Complex nested expressions with associative operators
assert!(cmp("(a && b) || c", "c || (a && b)"));
assert!(cmp("(a && b) || c", "c || (b && a)"));
assert!(cmp("(a || b) && c", "c && (a || b)"));
assert!(cmp("(a || b) && c", "c && (b || a)"));
assert!(cmp("(a && b) || (c && d)", "(c && d) || (a && b)"));
assert!(cmp("(a && b) || (c && d)", "(d && c) || (b && a)"));
// Multiple levels of nesting
assert!(cmp("((a && b) || c) && d", "d && ((a && b) || c)"));
assert!(cmp("((a && b) || c) && d", "d && (c || (b && a))"));
assert!(cmp("a && (b || (c && d))", "(b || (c && d)) && a"));
assert!(cmp("a && (b || (c && d))", "(b || (d && c)) && a"));
// Negation with associative operators
assert!(cmp("!a && b", "b && !a"));
assert!(cmp("!a || b", "b || !a"));
assert!(cmp("!(a && b) || c", "c || !(a && b)"));
assert!(cmp("!(a && b) || c", "c || !(b && a)"));
// Descendant operator (>) - NOT associative/commutative
assert!(cmp("a > b", "a > b"));
assert!(!cmp("a > b", "b > a"));
assert!(!cmp("a > b > c", "c > b > a"));
assert!(!cmp("a > b > c", "a > c > b"));
// Mixed operators with descendant
assert!(cmp("(a > b) && c", "c && (a > b)"));
assert!(!cmp("(a > b) && c", "c && (b > a)"));
assert!(cmp("(a > b) || (c > d)", "(c > d) || (a > b)"));
assert!(!cmp("(a > b) || (c > d)", "(b > a) || (d > c)"));
// Negative cases - different operators
assert!(!cmp("a && b", "a || b"));
assert!(!cmp("a == b", "a != b"));
assert!(!cmp("a && b", "a > b"));
assert!(!cmp("a || b", "a > b"));
assert!(!cmp("a == b", "a && b"));
assert!(!cmp("a != b", "a || b"));
// Negative cases - different operands
assert!(!cmp("a && b", "a && c"));
assert!(!cmp("a && b", "c && d"));
assert!(!cmp("a || b", "a || c"));
assert!(!cmp("a || b", "c || d"));
assert!(!cmp("a == b", "a == c"));
assert!(!cmp("a != b", "a != c"));
assert!(!cmp("a > b", "a > c"));
assert!(!cmp("a > b", "c > b"));
// Negative cases - with negation
assert!(!cmp("!a", "a"));
assert!(!cmp("!a && b", "a && b"));
assert!(!cmp("!(a && b)", "a && b"));
assert!(!cmp("!a || b", "a || b"));
assert!(!cmp("!(a || b)", "a || b"));
// Negative cases - complex expressions
assert!(!cmp("(a && b) || c", "(a || b) && c"));
assert!(!cmp("a && (b || c)", "a || (b && c)"));
assert!(!cmp("(a && b) || (c && d)", "(a || b) && (c || d)"));
assert!(!cmp("a > b && c", "a && b > c"));
// Edge cases - multiple same operands
assert!(cmp("a && a", "a && a"));
assert!(cmp("a || a", "a || a"));
assert!(cmp("a && a && b", "b && a && a"));
assert!(cmp("a || a || b", "b || a || a"));
// Edge cases - deeply nested
assert!(cmp(
"((a && b) || (c && d)) && ((e || f) && g)",
"((e || f) && g) && ((c && d) || (a && b))"
));
assert!(cmp(
"((a && b) || (c && d)) && ((e || f) && g)",
"(g && (f || e)) && ((d && c) || (b && a))"
));
// Edge cases - repeated patterns
assert!(cmp("(a && b) || (a && b)", "(b && a) || (b && a)"));
assert!(cmp("(a || b) && (a || b)", "(b || a) && (b || a)"));
// Negative cases - subtle differences
assert!(!cmp("a && b && c", "a && b"));
assert!(!cmp("a || b || c", "a || b"));
assert!(!cmp("(a && b) || c", "a && (b || c)"));
// a > b > c is not the same as a > c, should not be equal
assert!(!cmp("a > b > c", "a > c"));
// Double negation with complex expressions
assert!(cmp("!(!(a && b))", "a && b"));
assert!(cmp("!(!(a || b))", "a || b"));
assert!(cmp("!(!(a > b))", "a > b"));
assert!(cmp("!(!a) && b", "a && b"));
assert!(cmp("!(!a) || b", "a || b"));
assert!(cmp("!(!(a && b)) || c", "(a && b) || c"));
assert!(cmp("!(!(a && b)) || c", "(b && a) || c"));
assert!(cmp("!(!a)", "a"));
assert!(cmp("a", "!(!a)"));
assert!(cmp("!(!(!a))", "!a"));
assert!(cmp("!(!(!(!a)))", "a"));
}
}