Watch ~/.zed/bindings.json file for custom key bindings

Max Brunsfeld and Keith Simmons created

Co-authored-by: Keith Simmons <keith@zed.dev>

Change summary

crates/gpui/src/app.rs             |  4 +
crates/gpui/src/keymap.rs          |  9 +++
crates/settings/src/keymap_file.rs | 83 +++++++++++++++++--------------
crates/settings/src/settings.rs    |  4 +
crates/vim/src/vim_test_context.rs |  2 
crates/zed/src/main.rs             | 32 ++++++++---
crates/zed/src/settings_file.rs    | 33 +++++++++---
crates/zed/src/zed.rs              |  3 
8 files changed, 110 insertions(+), 60 deletions(-)

Detailed changes

crates/gpui/src/app.rs 🔗

@@ -1358,6 +1358,10 @@ impl MutableAppContext {
         self.keystroke_matcher.add_bindings(bindings);
     }
 
+    pub fn clear_bindings(&mut self) {
+        self.keystroke_matcher.clear_bindings();
+    }
+
     pub fn dispatch_keystroke(
         &mut self,
         window_id: usize,

crates/gpui/src/keymap.rs 🔗

@@ -106,6 +106,11 @@ impl Matcher {
         self.keymap.add_bindings(bindings);
     }
 
+    pub fn clear_bindings(&mut self) {
+        self.pending.clear();
+        self.keymap.clear();
+    }
+
     pub fn clear_pending(&mut self) {
         self.pending.clear();
     }
@@ -164,6 +169,10 @@ impl Keymap {
     fn add_bindings<T: IntoIterator<Item = Binding>>(&mut self, bindings: T) {
         self.0.extend(bindings.into_iter());
     }
+
+    fn clear(&mut self) {
+        self.0.clear();
+    }
 }
 
 impl Binding {

crates/settings/src/keymap_file.rs 🔗

@@ -5,50 +5,57 @@ use gpui::{keymap::Binding, MutableAppContext};
 use serde::Deserialize;
 use serde_json::value::RawValue;
 
+#[derive(Deserialize, Default, Clone)]
+#[serde(transparent)]
+pub struct KeyMapFile(BTreeMap<String, ActionsByKeystroke>);
+
+type ActionsByKeystroke = BTreeMap<String, Box<RawValue>>;
+
 #[derive(Deserialize)]
 struct ActionWithData<'a>(#[serde(borrow)] &'a str, #[serde(borrow)] &'a RawValue);
-type ActionSetsByContext<'a> = BTreeMap<&'a str, ActionsByKeystroke<'a>>;
-type ActionsByKeystroke<'a> = BTreeMap<&'a str, &'a RawValue>;
 
-pub fn load_built_in_keymaps(cx: &mut MutableAppContext) {
-    for path in ["keymaps/default.json", "keymaps/vim.json"] {
-        load_keymap(
-            cx,
-            std::str::from_utf8(Assets::get(path).unwrap().data.as_ref()).unwrap(),
-        )
-        .unwrap();
+impl KeyMapFile {
+    pub fn load_defaults(cx: &mut MutableAppContext) {
+        for path in ["keymaps/default.json", "keymaps/vim.json"] {
+            Self::load(path, cx).unwrap();
+        }
+    }
+
+    pub fn load(asset_path: &str, cx: &mut MutableAppContext) -> Result<()> {
+        let content = Assets::get(asset_path).unwrap().data;
+        let content_str = std::str::from_utf8(content.as_ref()).unwrap();
+        Ok(serde_json::from_str::<Self>(content_str)?.add(cx)?)
     }
-}
 
-pub fn load_keymap(cx: &mut MutableAppContext, content: &str) -> Result<()> {
-    let actions: ActionSetsByContext = serde_json::from_str(content)?;
-    for (context, actions) in actions {
-        let context = if context.is_empty() {
-            None
-        } else {
-            Some(context)
-        };
-        cx.add_bindings(
-            actions
-                .into_iter()
-                .map(|(keystroke, action)| {
-                    let action = action.get();
-                    let action = if action.starts_with('[') {
-                        let ActionWithData(name, data) = serde_json::from_str(action)?;
-                        cx.deserialize_action(name, Some(data.get()))
-                    } else {
-                        let name = serde_json::from_str(action)?;
-                        cx.deserialize_action(name, None)
-                    }
-                    .with_context(|| {
-                        format!(
+    pub fn add(self, cx: &mut MutableAppContext) -> Result<()> {
+        for (context, actions) in self.0 {
+            let context = if context.is_empty() {
+                None
+            } else {
+                Some(context)
+            };
+            cx.add_bindings(
+                actions
+                    .into_iter()
+                    .map(|(keystroke, action)| {
+                        let action = action.get();
+                        let action = if action.starts_with('[') {
+                            let ActionWithData(name, data) = serde_json::from_str(action)?;
+                            cx.deserialize_action(name, Some(data.get()))
+                        } else {
+                            let name = serde_json::from_str(action)?;
+                            cx.deserialize_action(name, None)
+                        }
+                        .with_context(|| {
+                            format!(
                             "invalid binding value for keystroke {keystroke}, context {context:?}"
                         )
-                    })?;
-                    Binding::load(keystroke, action, context)
-                })
-                .collect::<Result<Vec<_>>>()?,
-        )
+                        })?;
+                        Binding::load(&keystroke, action, context.as_deref())
+                    })
+                    .collect::<Result<Vec<_>>>()?,
+            )
+        }
+        Ok(())
     }
-    Ok(())
 }

crates/settings/src/settings.rs 🔗

@@ -1,4 +1,4 @@
-pub mod keymap_file;
+mod keymap_file;
 
 use anyhow::Result;
 use gpui::font_cache::{FamilyId, FontCache};
@@ -15,6 +15,8 @@ use std::{collections::HashMap, sync::Arc};
 use theme::{Theme, ThemeRegistry};
 use util::ResultExt as _;
 
+pub use keymap_file::KeyMapFile;
+
 #[derive(Clone)]
 pub struct Settings {
     pub buffer_font_family: FamilyId,

crates/vim/src/vim_test_context.rs 🔗

@@ -24,7 +24,7 @@ impl<'a> VimTestContext<'a> {
             editor::init(cx);
             crate::init(cx);
 
-            settings::keymap_file::load_built_in_keymaps(cx);
+            settings::KeyMapFile::load("keymaps/vim.json", cx).unwrap();
         });
 
         let params = cx.update(WorkspaceParams::test);

crates/zed/src/main.rs 🔗

@@ -2,6 +2,7 @@
 #![allow(non_snake_case)]
 
 use anyhow::{anyhow, Context, Result};
+use assets::Assets;
 use client::{self, http, ChannelList, UserStore};
 use fs::OpenOptions;
 use futures::{channel::oneshot, StreamExt};
@@ -9,19 +10,17 @@ use gpui::{App, AssetSource, Task};
 use log::LevelFilter;
 use parking_lot::Mutex;
 use project::Fs;
-use settings::{self, Settings};
+use settings::{self, KeyMapFile, Settings, SettingsFileContent};
 use smol::process::Command;
 use std::{env, fs, path::PathBuf, sync::Arc};
 use theme::{ThemeRegistry, DEFAULT_THEME_NAME};
 use util::ResultExt;
 use workspace::{self, AppState, OpenNew, OpenPaths};
-use assets::Assets;
 use zed::{
-    self,
-    build_window_options, build_workspace,
+    self, build_window_options, build_workspace,
     fs::RealFs,
     languages, menus,
-    settings_file::{settings_from_files, SettingsFile},
+    settings_file::{settings_from_files, watch_keymap_file, WatchedJsonFile},
 };
 
 fn main() {
@@ -63,7 +62,8 @@ fn main() {
                 ..Default::default()
             },
         );
-    let settings_file = load_settings_file(&app, fs.clone());
+
+    let config_files = load_config_files(&app, fs.clone());
 
     let login_shell_env_loaded = if stdout_is_a_pty() {
         Task::ready(())
@@ -112,13 +112,16 @@ fn main() {
         })
         .detach_and_log_err(cx);
 
-        let settings_file = cx.background().block(settings_file).unwrap();
+        let (settings_file, bindings_file) = cx.background().block(config_files).unwrap();
         let mut settings_rx = settings_from_files(
             default_settings,
             vec![settings_file],
             themes.clone(),
             cx.font_cache().clone(),
         );
+
+        cx.spawn(|cx| watch_keymap_file(bindings_file, cx)).detach();
+
         let settings = cx.background().block(settings_rx.next()).unwrap();
         cx.spawn(|mut cx| async move {
             while let Some(settings) = settings_rx.next().await {
@@ -254,14 +257,23 @@ fn load_embedded_fonts(app: &App) {
         .unwrap();
 }
 
-fn load_settings_file(app: &App, fs: Arc<dyn Fs>) -> oneshot::Receiver<SettingsFile> {
+fn load_config_files(
+    app: &App,
+    fs: Arc<dyn Fs>,
+) -> oneshot::Receiver<(
+    WatchedJsonFile<SettingsFileContent>,
+    WatchedJsonFile<KeyMapFile>,
+)> {
     let executor = app.background();
     let (tx, rx) = oneshot::channel();
     executor
         .clone()
         .spawn(async move {
-            let file = SettingsFile::new(fs, &executor, zed::SETTINGS_PATH.clone()).await;
-            tx.send(file).ok()
+            let settings_file =
+                WatchedJsonFile::new(fs.clone(), &executor, zed::SETTINGS_PATH.clone()).await;
+            let bindings_file =
+                WatchedJsonFile::new(fs, &executor, zed::BINDINGS_PATH.clone()).await;
+            tx.send((settings_file, bindings_file)).ok()
         })
         .detach();
     rx

crates/zed/src/settings_file.rs 🔗

@@ -1,17 +1,22 @@
 use futures::{stream, StreamExt};
-use gpui::{executor, FontCache};
+use gpui::{executor, AsyncAppContext, FontCache};
 use postage::sink::Sink as _;
 use postage::{prelude::Stream, watch};
 use project::Fs;
+use serde::Deserialize;
+use settings::KeyMapFile;
 use settings::{Settings, SettingsFileContent};
 use std::{path::Path, sync::Arc, time::Duration};
 use theme::ThemeRegistry;
 use util::ResultExt;
 
 #[derive(Clone)]
-pub struct SettingsFile(watch::Receiver<SettingsFileContent>);
+pub struct WatchedJsonFile<T>(watch::Receiver<T>);
 
-impl SettingsFile {
+impl<T> WatchedJsonFile<T>
+where
+    T: 'static + for<'de> Deserialize<'de> + Clone + Default + Send + Sync,
+{
     pub async fn new(
         fs: Arc<dyn Fs>,
         executor: &executor::Background,
@@ -35,21 +40,21 @@ impl SettingsFile {
         Self(rx)
     }
 
-    async fn load(fs: Arc<dyn Fs>, path: &Path) -> Option<SettingsFileContent> {
+    async fn load(fs: Arc<dyn Fs>, path: &Path) -> Option<T> {
         if fs.is_file(&path).await {
             fs.load(&path)
                 .await
                 .log_err()
                 .and_then(|data| serde_json::from_str(&data).log_err())
         } else {
-            Some(SettingsFileContent::default())
+            Some(T::default())
         }
     }
 }
 
 pub fn settings_from_files(
     defaults: Settings,
-    sources: Vec<SettingsFile>,
+    sources: Vec<WatchedJsonFile<SettingsFileContent>>,
     theme_registry: Arc<ThemeRegistry>,
     font_cache: Arc<FontCache>,
 ) -> impl futures::stream::Stream<Item = Settings> {
@@ -72,6 +77,16 @@ pub fn settings_from_files(
     })
 }
 
+pub async fn watch_keymap_file(mut file: WatchedJsonFile<KeyMapFile>, mut cx: AsyncAppContext) {
+    while let Some(content) = file.0.recv().await {
+        cx.update(|cx| {
+            cx.clear_bindings();
+            settings::KeyMapFile::load_defaults(cx);
+            content.add(cx).log_err();
+        });
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -102,9 +117,9 @@ mod tests {
         .await
         .unwrap();
 
-        let source1 = SettingsFile::new(fs.clone(), &executor, "/settings1.json".as_ref()).await;
-        let source2 = SettingsFile::new(fs.clone(), &executor, "/settings2.json".as_ref()).await;
-        let source3 = SettingsFile::new(fs.clone(), &executor, "/settings3.json".as_ref()).await;
+        let source1 = WatchedJsonFile::new(fs.clone(), &executor, "/settings1.json".as_ref()).await;
+        let source2 = WatchedJsonFile::new(fs.clone(), &executor, "/settings2.json".as_ref()).await;
+        let source3 = WatchedJsonFile::new(fs.clone(), &executor, "/settings3.json".as_ref()).await;
 
         let mut settings_rx = settings_from_files(
             cx.read(Settings::test),

crates/zed/src/zed.rs 🔗

@@ -45,6 +45,7 @@ lazy_static! {
         .expect("failed to determine home directory")
         .join(".zed");
     pub static ref SETTINGS_PATH: PathBuf = ROOT_PATH.join("settings.json");
+    pub static ref BINDINGS_PATH: PathBuf = ROOT_PATH.join("bindings.json");
 }
 
 pub fn init(app_state: &Arc<AppState>, cx: &mut gpui::MutableAppContext) {
@@ -102,7 +103,7 @@ pub fn init(app_state: &Arc<AppState>, cx: &mut gpui::MutableAppContext) {
 
     workspace::lsp_status::init(cx);
 
-    settings::keymap_file::load_built_in_keymaps(cx);
+    settings::KeyMapFile::load_defaults(cx);
 }
 
 pub fn build_workspace(