Sketch in a table for the keybindings UI

Mikayla Maki created

Change summary

Cargo.lock                            |   4 
crates/gpui/src/app.rs                |   5 
crates/settings_ui/Cargo.toml         |   3 
crates/settings_ui/src/keybindings.rs | 196 +++++++++++++++++++++++++++++
crates/settings_ui/src/settings_ui.rs |   4 
crates/ui/Cargo.toml                  |   1 
crates/ui/src/components/table.rs     |   2 
7 files changed, 214 insertions(+), 1 deletion(-)

Detailed changes

Cargo.lock 🔗

@@ -14567,11 +14567,14 @@ name = "settings_ui"
 version = "0.1.0"
 dependencies = [
  "command_palette_hooks",
+ "db",
  "editor",
  "feature_flags",
  "fs",
  "gpui",
  "log",
+ "paths",
+ "project",
  "schemars",
  "serde",
  "settings",
@@ -17048,6 +17051,7 @@ dependencies = [
  "gpui_macros",
  "icons",
  "itertools 0.14.0",
+ "log",
  "menu",
  "serde",
  "settings",

crates/gpui/src/app.rs 🔗

@@ -1334,6 +1334,11 @@ impl App {
         self.pending_effects.push_back(Effect::RefreshWindows);
     }
 
+    /// Get all key bindings in the app.
+    pub fn key_bindings(&self) -> Rc<RefCell<Keymap>> {
+        self.keymap.clone()
+    }
+
     /// Register a global listener for actions invoked via the keyboard.
     pub fn on_action<A: Action>(&mut self, listener: impl Fn(&A, &mut Self) + 'static) {
         self.global_action_listeners

crates/settings_ui/Cargo.toml 🔗

@@ -13,11 +13,14 @@ path = "src/settings_ui.rs"
 
 [dependencies]
 command_palette_hooks.workspace = true
+db.workspace = true
 editor.workspace = true
 feature_flags.workspace = true
 fs.workspace = true
 gpui.workspace = true
 log.workspace = true
+paths.workspace = true
+project.workspace = true
 schemars.workspace = true
 serde.workspace = true
 settings.workspace = true

crates/settings_ui/src/keybindings.rs 🔗

@@ -0,0 +1,196 @@
+use std::fmt::Write as _;
+
+use db::anyhow::anyhow;
+use gpui::{AppContext as _, EventEmitter, FocusHandle, Focusable, actions};
+use ui::{
+    ActiveTheme as _, App, ParentElement as _, Render, Styled as _, Table, Window, div, string_cell,
+};
+use workspace::{Item, SerializableItem, Workspace, register_serializable_item};
+
+use crate::keybindings::persistence::KEYBINDING_EDITORS;
+
+actions!(zed, [OpenKeymapEditor]);
+
+pub fn init(cx: &mut App) {
+    cx.observe_new(|workspace: &mut Workspace, _window, _cx| {
+        workspace.register_action(|workspace, _: &OpenKeymapEditor, window, cx| {
+            let open_keymap_editor = cx.new(|cx| KeymapEditor::new(cx));
+            workspace.add_item_to_center(Box::new(open_keymap_editor), window, cx);
+        });
+    })
+    .detach();
+
+    register_serializable_item::<KeymapEditor>(cx);
+}
+
+struct KeymapEditor {
+    focus_handle: FocusHandle,
+}
+
+impl EventEmitter<()> for KeymapEditor {}
+
+impl Focusable for KeymapEditor {
+    fn focus_handle(&self, _cx: &App) -> gpui::FocusHandle {
+        self.focus_handle.clone()
+    }
+}
+
+impl KeymapEditor {
+    fn new(cx: &mut gpui::Context<Self>) -> Self {
+        Self {
+            focus_handle: cx.focus_handle(),
+        }
+    }
+}
+
+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: gpui::Entity<project::Project>,
+        _workspace: gpui::WeakEntity<Workspace>,
+        workspace_id: workspace::WorkspaceId,
+        item_id: workspace::ItemId,
+        _window: &mut Window,
+        cx: &mut App,
+    ) -> gpui::Task<gpui::Result<gpui::Entity<Self>>> {
+        cx.spawn(async move |cx| {
+            if KEYBINDING_EDITORS
+                .get_keybinding_editor(item_id, workspace_id)?
+                .is_some()
+            {
+                cx.new(|cx| KeymapEditor::new(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 Some(workspace_id) = workspace.database_id() else {
+            return None;
+        };
+        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
+    }
+}
+
+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 key_bindings_ptr = cx.key_bindings();
+        let lock = key_bindings_ptr.borrow();
+        let key_bindings = lock.bindings();
+
+        let mut table = Table::new(vec!["Command", "Keystrokes", "Context"]);
+        for key_binding in key_bindings {
+            let mut keystroke_text = String::new();
+            for keystroke in key_binding.keystrokes() {
+                write!(&mut keystroke_text, "{} ", keystroke.unparse()).ok();
+            }
+            let keystroke_text = keystroke_text.trim().to_string();
+
+            let context = key_binding
+                .predicate()
+                .map(|predicate| predicate.to_string())
+                .unwrap_or_else(|| "<global>".to_string());
+
+            // dbg!(key_binding.action().name(), &keystroke_text, &context);
+
+            table = table.row(vec![
+                string_cell(key_binding.action().name()),
+                string_cell(keystroke_text),
+                string_cell(context),
+                // TODO: Add a source field
+                // string_cell(keybinding.source().to_string()),
+            ]);
+        }
+
+        let theme = cx.theme();
+
+        div()
+            .size_full()
+            .bg(theme.colors().background)
+            .child(table.striped())
+    }
+}
+
+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 = ?
+            }
+        }
+    }
+}

crates/settings_ui/src/settings_ui.rs 🔗

@@ -20,6 +20,8 @@ use workspace::{Workspace, with_active_or_new_workspace};
 
 use crate::appearance_settings_controls::AppearanceSettingsControls;
 
+mod keybindings;
+
 pub struct SettingsUiFeatureFlag;
 
 impl FeatureFlag for SettingsUiFeatureFlag {
@@ -121,6 +123,8 @@ pub fn init(cx: &mut App) {
         .detach();
     })
     .detach();
+
+    keybindings::init(cx);
 }
 
 async fn handle_import_vscode_settings(

crates/ui/Cargo.toml 🔗

@@ -20,6 +20,7 @@ gpui.workspace = true
 gpui_macros.workspace = true
 icons.workspace = true
 itertools.workspace = true
+log.workspace = true
 menu.workspace = true
 serde.workspace = true
 settings.workspace = true

crates/ui/src/components/table.rs 🔗

@@ -33,7 +33,7 @@ impl Table {
         if items.len() == self.column_count {
             self.rows.push(items.into_iter().map(Into::into).collect());
         } else {
-            // TODO: Log error: Row length mismatch
+            log::error!("Row length mismatch");
         }
         self
     }