keymap_ui: Add auto-complete for context in keybind editor (#34031)

Ben Kunkle created

Closes #ISSUE

Implements a very basic completion provider that is attached to the
context editor in the keybind editing modal.

The context identifiers used for completions are scraped from the
default, vim, and base keymaps on demand.

Release Notes:

- N/A *or* Added/Fixed/Improved ...

Change summary

Cargo.lock                                 |   1 
crates/settings/src/base_keymap_setting.rs |   4 
crates/settings/src/keymap_file.rs         |   2 
crates/settings/src/settings.rs            |   3 
crates/settings_ui/src/keybindings.rs      | 152 +++++++++++++++++++++++
crates/welcome/Cargo.toml                  |   1 
crates/welcome/src/base_keymap_picker.rs   |   3 
crates/welcome/src/welcome.rs              |   4 
crates/zed/src/main.rs                     |   4 
crates/zed/src/zed.rs                      |   9 
10 files changed, 163 insertions(+), 20 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -18359,7 +18359,6 @@ dependencies = [
  "language",
  "picker",
  "project",
- "schemars",
  "serde",
  "settings",
  "telemetry",

crates/welcome/src/base_keymap_setting.rs → crates/settings/src/base_keymap_setting.rs 🔗

@@ -1,8 +1,8 @@
 use std::fmt::{Display, Formatter};
 
+use crate::{Settings, SettingsSources, VsCodeSettings};
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
-use settings::{Settings, SettingsSources};
 
 /// Base key bindings scheme. Base keymaps can be overridden with user keymaps.
 ///
@@ -114,7 +114,7 @@ impl Settings for BaseKeymap {
         sources.default.ok_or_else(Self::missing_default)
     }
 
-    fn import_from_vscode(_vscode: &settings::VsCodeSettings, current: &mut Self::FileContent) {
+    fn import_from_vscode(_vscode: &VsCodeSettings, current: &mut Self::FileContent) {
         *current = Some(BaseKeymap::VSCode);
     }
 }

crates/settings/src/keymap_file.rs 🔗

@@ -63,7 +63,7 @@ pub struct KeymapSection {
     /// current file extension are also supported - see [the
     /// documentation](https://zed.dev/docs/key-bindings#contexts) for more details.
     #[serde(default)]
-    context: String,
+    pub context: String,
     /// This option enables specifying keys based on their position on a QWERTY keyboard, by using
     /// position-equivalent mappings for some non-QWERTY keyboards. This is currently only supported
     /// on macOS. See the documentation for more details.

crates/settings/src/settings.rs 🔗

@@ -1,3 +1,4 @@
+mod base_keymap_setting;
 mod editable_setting_control;
 mod key_equivalents;
 mod keymap_file;
@@ -11,6 +12,7 @@ use rust_embed::RustEmbed;
 use std::{borrow::Cow, fmt, str};
 use util::asset_str;
 
+pub use base_keymap_setting::*;
 pub use editable_setting_control::*;
 pub use key_equivalents::*;
 pub use keymap_file::{
@@ -71,6 +73,7 @@ pub fn init(cx: &mut App) {
         .set_default_settings(&default_settings(), cx)
         .unwrap();
     cx.set_global(settings);
+    BaseKeymap::register(cx);
 }
 
 pub fn default_settings() -> Cow<'static, str> {

crates/settings_ui/src/keybindings.rs 🔗

@@ -5,7 +5,7 @@ use std::{
 
 use anyhow::{Context as _, anyhow};
 use collections::HashSet;
-use editor::{Editor, EditorEvent};
+use editor::{CompletionProvider, Editor, EditorEvent};
 use feature_flags::FeatureFlagViewExt;
 use fs::Fs;
 use fuzzy::{StringMatch, StringMatchCandidate};
@@ -14,8 +14,8 @@ use gpui::{
     Global, KeyContext, Keystroke, ModifiersChangedEvent, ScrollStrategy, StyledText, Subscription,
     WeakEntity, actions, div, transparent_black,
 };
-use language::{Language, LanguageConfig};
-use settings::KeybindSource;
+use language::{Language, LanguageConfig, ToOffset as _};
+use settings::{BaseKeymap, KeybindSource, KeymapFile, SettingsAssets};
 
 use util::ResultExt;
 
@@ -850,8 +850,10 @@ impl KeybindingEditorModal {
         cx: &mut App,
     ) -> Self {
         let keybind_editor = cx.new(KeystrokeInput::new);
+
         let context_editor = cx.new(|cx| {
             let mut editor = Editor::single_line(window, cx);
+
             if let Some(context) = editing_keybind
                 .context
                 .as_ref()
@@ -862,6 +864,21 @@ impl KeybindingEditorModal {
                 editor.set_placeholder_text("Keybinding context", cx);
             }
 
+            cx.spawn(async |editor, cx| {
+                let contexts = cx
+                    .background_spawn(async { collect_contexts_from_assets() })
+                    .await;
+
+                editor
+                    .update(cx, |editor, _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);
+
             editor
         });
         Self {
@@ -1001,6 +1018,69 @@ impl Render for KeybindingEditorModal {
     }
 }
 
+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 save_keybinding_update(
     existing: ProcessedKeybinding,
     new_keystrokes: &[Keystroke],
@@ -1254,6 +1334,72 @@ fn build_keybind_context_menu(
     })
 }
 
+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::Child(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"

crates/welcome/Cargo.toml 🔗

@@ -26,7 +26,6 @@ install_cli.workspace = true
 language.workspace = true
 picker.workspace = true
 project.workspace = true
-schemars.workspace = true
 serde.workspace = true
 settings.workspace = true
 telemetry.workspace = true

crates/welcome/src/base_keymap_picker.rs 🔗

@@ -1,4 +1,3 @@
-use super::base_keymap_setting::BaseKeymap;
 use fuzzy::{StringMatch, StringMatchCandidate, match_strings};
 use gpui::{
     App, Context, DismissEvent, Entity, EventEmitter, Focusable, Render, Task, WeakEntity, Window,
@@ -6,7 +5,7 @@ use gpui::{
 };
 use picker::{Picker, PickerDelegate};
 use project::Fs;
-use settings::{Settings, update_settings_file};
+use settings::{BaseKeymap, Settings, update_settings_file};
 use std::sync::Arc;
 use ui::{ListItem, ListItemSpacing, prelude::*};
 use util::ResultExt;

crates/welcome/src/welcome.rs 🔗

@@ -17,11 +17,9 @@ use workspace::{
     open_new,
 };
 
-pub use base_keymap_setting::BaseKeymap;
 pub use multibuffer_hint::*;
 
 mod base_keymap_picker;
-mod base_keymap_setting;
 mod multibuffer_hint;
 mod welcome_ui;
 
@@ -37,8 +35,6 @@ pub const FIRST_OPEN: &str = "first_open";
 pub const DOCS_URL: &str = "https://zed.dev/docs/";
 
 pub fn init(cx: &mut App) {
-    BaseKeymap::register(cx);
-
     cx.observe_new(|workspace: &mut Workspace, _, _cx| {
         workspace.register_action(|workspace, _: &Welcome, window, cx| {
             let welcome_page = WelcomePage::new(workspace, cx);

crates/zed/src/main.rs 🔗

@@ -29,7 +29,7 @@ use project::project_settings::ProjectSettings;
 use recent_projects::{SshSettings, open_ssh_project};
 use release_channel::{AppCommitSha, AppVersion, ReleaseChannel};
 use session::{AppSession, Session};
-use settings::{Settings, SettingsStore, watch_config_file};
+use settings::{BaseKeymap, Settings, SettingsStore, watch_config_file};
 use std::{
     env,
     io::{self, IsTerminal},
@@ -43,7 +43,7 @@ use theme::{
 };
 use util::{ConnectionResult, ResultExt, TryFutureExt, maybe};
 use uuid::Uuid;
-use welcome::{BaseKeymap, FIRST_OPEN, show_welcome_view};
+use welcome::{FIRST_OPEN, show_welcome_view};
 use workspace::{
     AppState, SerializedWorkspaceLocation, Toast, Workspace, WorkspaceSettings, WorkspaceStore,
     notifications::NotificationId,

crates/zed/src/zed.rs 🔗

@@ -48,9 +48,10 @@ use release_channel::{AppCommitSha, ReleaseChannel};
 use rope::Rope;
 use search::project_search::ProjectSearchBar;
 use settings::{
-    DEFAULT_KEYMAP_PATH, InvalidSettingsError, KeybindSource, KeymapFile, KeymapFileLoadResult,
-    Settings, SettingsStore, VIM_KEYMAP_PATH, initial_local_debug_tasks_content,
-    initial_project_settings_content, initial_tasks_content, update_settings_file,
+    BaseKeymap, DEFAULT_KEYMAP_PATH, InvalidSettingsError, KeybindSource, KeymapFile,
+    KeymapFileLoadResult, Settings, SettingsStore, VIM_KEYMAP_PATH,
+    initial_local_debug_tasks_content, initial_project_settings_content, initial_tasks_content,
+    update_settings_file,
 };
 use std::path::PathBuf;
 use std::sync::atomic::{self, AtomicBool};
@@ -62,7 +63,7 @@ use util::markdown::MarkdownString;
 use util::{ResultExt, asset_str};
 use uuid::Uuid;
 use vim_mode_setting::VimModeSetting;
-use welcome::{BaseKeymap, DOCS_URL, MultibufferHint};
+use welcome::{DOCS_URL, MultibufferHint};
 use workspace::notifications::{NotificationId, dismiss_app_notification, show_app_notification};
 use workspace::{
     AppState, NewFile, NewWindow, OpenLog, Toast, Workspace, WorkspaceSettings,