Add an application menu item for creating/opening a project-specific settings file (#2572)

Max Brunsfeld created

Previously, project-specific settings were not discoverable. This PR
adds a `Zed > Preferences > Local Settings` application menu command
that creates a `.zed/settings.json` at the root of your current
worktree. This command works in both local and remote projects.

Limitations:

* Currently, if you have an empty project open, the command just shows a
notification that there are no folders open.
* The JSON-schema-based autocomplete is the same in local settings files
as in your main settings, even though not all settings can be locally
customized.

Release Notes:

- Added an application menu command - `Zed > Preferences > Local
Settings` for creating a folder-specific settings file.

Change summary

assets/settings/initial_local_settings.json | 11 ++
crates/collab/src/rpc.rs                    |  4 
crates/settings/src/settings.rs             | 19 +++
crates/settings/src/settings_file.rs        | 14 --
crates/workspace/src/workspace.rs           | 16 ---
crates/zed/src/main.rs                      |  6 
crates/zed/src/menus.rs                     |  3 
crates/zed/src/zed.rs                       | 98 ++++++++++++++++++++++
8 files changed, 130 insertions(+), 41 deletions(-)

Detailed changes

assets/settings/initial_local_settings.json 🔗

@@ -0,0 +1,11 @@
+// Folder-specific Zed settings
+//
+// A subset of Zed's settings can be configured on a per-folder basis.
+//
+// For information on how to configure Zed, see the Zed
+// documentation: https://zed.dev/docs/configuring-zed
+//
+// To see all of Zed's default settings without changing your
+// custom settings, run the `open default settings` command
+// from the command palette or from `Zed` application menu.
+{}

crates/collab/src/rpc.rs 🔗

@@ -1424,7 +1424,7 @@ async fn join_project(
             )?;
         }
 
-        for settings_file in dbg!(worktree.settings_files) {
+        for settings_file in worktree.settings_files {
             session.peer.send(
                 session.connection_id,
                 proto::UpdateWorktreeSettings {
@@ -1554,8 +1554,6 @@ async fn update_worktree_settings(
     message: proto::UpdateWorktreeSettings,
     session: Session,
 ) -> Result<()> {
-    dbg!(&message);
-
     let guest_connection_ids = session
         .db()
         .await

crates/settings/src/settings.rs 🔗

@@ -9,10 +9,23 @@ pub use settings_store::{Setting, SettingsJsonSchemaParams, SettingsStore};
 use std::{borrow::Cow, str};
 
 pub const DEFAULT_SETTINGS_ASSET_PATH: &str = "settings/default.json";
-pub const INITIAL_USER_SETTINGS_ASSET_PATH: &str = "settings/initial_user_settings.json";
+const INITIAL_USER_SETTINGS_ASSET_PATH: &str = "settings/initial_user_settings.json";
+const INITIAL_LOCAL_SETTINGS_ASSET_PATH: &str = "settings/initial_local_settings.json";
 
-pub fn initial_user_settings_content(assets: &'static impl AssetSource) -> Cow<'static, str> {
-    match assets.load(INITIAL_USER_SETTINGS_ASSET_PATH).unwrap() {
+pub fn default_settings() -> Cow<'static, str> {
+    asset_str(&assets::Assets, DEFAULT_SETTINGS_ASSET_PATH)
+}
+
+pub fn initial_user_settings_content(assets: &dyn AssetSource) -> Cow<'_, str> {
+    asset_str(assets, INITIAL_USER_SETTINGS_ASSET_PATH)
+}
+
+pub fn initial_local_settings_content(assets: &dyn AssetSource) -> Cow<'_, str> {
+    asset_str(assets, INITIAL_LOCAL_SETTINGS_ASSET_PATH)
+}
+
+fn asset_str<'a>(assets: &'a dyn AssetSource, path: &str) -> Cow<'a, str> {
+    match assets.load(path).unwrap() {
         Cow::Borrowed(s) => Cow::Borrowed(str::from_utf8(s).unwrap()),
         Cow::Owned(s) => Cow::Owned(String::from_utf8(s).unwrap()),
     }

crates/settings/src/settings_file.rs 🔗

@@ -1,11 +1,10 @@
-use crate::{settings_store::SettingsStore, Setting, DEFAULT_SETTINGS_ASSET_PATH};
+use crate::{settings_store::SettingsStore, Setting};
 use anyhow::Result;
 use assets::Assets;
 use fs::Fs;
 use futures::{channel::mpsc, StreamExt};
-use gpui::{executor::Background, AppContext, AssetSource};
+use gpui::{executor::Background, AppContext};
 use std::{
-    borrow::Cow,
     io::ErrorKind,
     path::{Path, PathBuf},
     str,
@@ -28,19 +27,12 @@ pub fn get_local<'a, T: Setting>(location: Option<(usize, &Path)>, cx: &'a AppCo
     cx.global::<SettingsStore>().get(location)
 }
 
-pub fn default_settings() -> Cow<'static, str> {
-    match Assets.load(DEFAULT_SETTINGS_ASSET_PATH).unwrap() {
-        Cow::Borrowed(s) => Cow::Borrowed(str::from_utf8(s).unwrap()),
-        Cow::Owned(s) => Cow::Owned(String::from_utf8(s).unwrap()),
-    }
-}
-
 pub const EMPTY_THEME_NAME: &'static str = "empty-theme";
 
 #[cfg(any(test, feature = "test-support"))]
 pub fn test_settings() -> String {
     let mut value = crate::settings_store::parse_json_with_comments::<serde_json::Value>(
-        default_settings().as_ref(),
+        crate::default_settings().as_ref(),
     )
     .unwrap();
     util::merge_non_null_json_value_into(

crates/workspace/src/workspace.rs 🔗

@@ -15,7 +15,6 @@ mod toolbar;
 mod workspace_settings;
 
 use anyhow::{anyhow, Context, Result};
-use assets::Assets;
 use call::ActiveCall;
 use client::{
     proto::{self, PeerId},
@@ -83,7 +82,7 @@ use status_bar::StatusBar;
 pub use status_bar::StatusItemView;
 use theme::{Theme, ThemeSettings};
 pub use toolbar::{ToolbarItemLocation, ToolbarItemView};
-use util::{async_iife, paths, ResultExt};
+use util::{async_iife, ResultExt};
 pub use workspace_settings::{AutosaveSetting, GitGutterSetting, WorkspaceSettings};
 
 lazy_static! {
@@ -133,8 +132,6 @@ actions!(
     ]
 );
 
-actions!(zed, [OpenSettings]);
-
 #[derive(Clone, PartialEq)]
 pub struct OpenPaths {
     pub paths: Vec<PathBuf>,
@@ -295,17 +292,6 @@ pub fn init(app_state: Arc<AppState>, cx: &mut AppContext) {
         .detach();
     });
 
-    cx.add_action(
-        move |_: &mut Workspace, _: &OpenSettings, cx: &mut ViewContext<Workspace>| {
-            create_and_open_local_file(&paths::SETTINGS, cx, || {
-                settings::initial_user_settings_content(&Assets)
-                    .as_ref()
-                    .into()
-            })
-            .detach_and_log_err(cx);
-        },
-    );
-
     let client = &app_state.client;
     client.add_view_request_handler(Workspace::handle_follow);
     client.add_view_message_handler(Workspace::handle_unfollow);

crates/zed/src/main.rs 🔗

@@ -56,9 +56,7 @@ use fs::RealFs;
 #[cfg(debug_assertions)]
 use staff_mode::StaffMode;
 use util::{channel::RELEASE_CHANNEL, paths, ResultExt, TryFutureExt};
-use workspace::{
-    item::ItemHandle, notifications::NotifyResultExt, AppState, OpenSettings, Workspace,
-};
+use workspace::{item::ItemHandle, notifications::NotifyResultExt, AppState, Workspace};
 use zed::{
     self, build_window_options, handle_keymap_file_changes, initialize_workspace, languages, menus,
 };
@@ -877,6 +875,6 @@ pub fn background_actions() -> &'static [(&'static str, &'static dyn Action)] {
         ("Go to file", &file_finder::Toggle),
         ("Open command palette", &command_palette::Toggle),
         ("Open recent projects", &recent_projects::OpenRecent),
-        ("Change your settings", &OpenSettings),
+        ("Change your settings", &zed::OpenSettings),
     ]
 }

crates/zed/src/menus.rs 🔗

@@ -12,10 +12,11 @@ pub fn menus() -> Vec<Menu<'static>> {
                 MenuItem::submenu(Menu {
                     name: "Preferences",
                     items: vec![
-                        MenuItem::action("Open Settings", workspace::OpenSettings),
+                        MenuItem::action("Open Settings", super::OpenSettings),
                         MenuItem::action("Open Key Bindings", super::OpenKeymap),
                         MenuItem::action("Open Default Settings", super::OpenDefaultSettings),
                         MenuItem::action("Open Default Key Bindings", super::OpenDefaultKeymap),
+                        MenuItem::action("Open Local Settings", super::OpenLocalSettings),
                         MenuItem::action("Select Theme", theme_selector::Toggle),
                     ],
                 }),

crates/zed/src/zed.rs 🔗

@@ -30,16 +30,23 @@ use project_panel::ProjectPanel;
 use search::{BufferSearchBar, ProjectSearchBar};
 use serde::Deserialize;
 use serde_json::to_string_pretty;
-use settings::{KeymapFileContent, SettingsStore, DEFAULT_SETTINGS_ASSET_PATH};
+use settings::{
+    initial_local_settings_content, KeymapFileContent, SettingsStore, DEFAULT_SETTINGS_ASSET_PATH,
+};
 use std::{borrow::Cow, str, sync::Arc};
 use terminal_view::terminal_panel::{self, TerminalPanel};
-use util::{channel::ReleaseChannel, paths, ResultExt};
+use util::{
+    channel::ReleaseChannel,
+    paths::{self, LOCAL_SETTINGS_RELATIVE_PATH},
+    ResultExt,
+};
 use uuid::Uuid;
 use welcome::BaseKeymap;
 pub use workspace;
 use workspace::{
-    create_and_open_local_file, dock::PanelHandle, open_new, AppState, NewFile, NewWindow,
-    Workspace, WorkspaceSettings,
+    create_and_open_local_file, dock::PanelHandle,
+    notifications::simple_message_notification::MessageNotification, open_new, AppState, NewFile,
+    NewWindow, Workspace, WorkspaceSettings,
 };
 
 #[derive(Deserialize, Clone, PartialEq)]
@@ -65,6 +72,8 @@ actions!(
         OpenLicenses,
         OpenTelemetryLog,
         OpenKeymap,
+        OpenSettings,
+        OpenLocalSettings,
         OpenDefaultSettings,
         OpenDefaultKeymap,
         IncreaseBufferFontSize,
@@ -157,6 +166,17 @@ pub fn init(app_state: &Arc<AppState>, cx: &mut gpui::AppContext) {
             create_and_open_local_file(&paths::KEYMAP, cx, Default::default).detach_and_log_err(cx);
         },
     );
+    cx.add_action(
+        move |_: &mut Workspace, _: &OpenSettings, cx: &mut ViewContext<Workspace>| {
+            create_and_open_local_file(&paths::SETTINGS, cx, || {
+                settings::initial_user_settings_content(&Assets)
+                    .as_ref()
+                    .into()
+            })
+            .detach_and_log_err(cx);
+        },
+    );
+    cx.add_action(open_local_settings_file);
     cx.add_action(
         move |workspace: &mut Workspace, _: &OpenDefaultKeymap, cx: &mut ViewContext<Workspace>| {
             open_bundled_file(
@@ -544,6 +564,76 @@ pub fn handle_keymap_file_changes(
     .detach();
 }
 
+fn open_local_settings_file(
+    workspace: &mut Workspace,
+    _: &OpenLocalSettings,
+    cx: &mut ViewContext<Workspace>,
+) {
+    let project = workspace.project().clone();
+    let worktree = project
+        .read(cx)
+        .visible_worktrees(cx)
+        .find_map(|tree| tree.read(cx).root_entry()?.is_dir().then_some(tree));
+    if let Some(worktree) = worktree {
+        let tree_id = worktree.read(cx).id();
+        cx.spawn(|workspace, mut cx| async move {
+            let file_path = &*LOCAL_SETTINGS_RELATIVE_PATH;
+
+            if let Some(dir_path) = file_path.parent() {
+                if worktree.read_with(&cx, |tree, _| tree.entry_for_path(dir_path).is_none()) {
+                    project
+                        .update(&mut cx, |project, cx| {
+                            project.create_entry((tree_id, dir_path), true, cx)
+                        })
+                        .ok_or_else(|| anyhow!("worktree was removed"))?
+                        .await?;
+                }
+            }
+
+            if worktree.read_with(&cx, |tree, _| tree.entry_for_path(file_path).is_none()) {
+                project
+                    .update(&mut cx, |project, cx| {
+                        project.create_entry((tree_id, file_path), false, cx)
+                    })
+                    .ok_or_else(|| anyhow!("worktree was removed"))?
+                    .await?;
+            }
+
+            let editor = workspace
+                .update(&mut cx, |workspace, cx| {
+                    workspace.open_path((tree_id, file_path), None, true, cx)
+                })?
+                .await?
+                .downcast::<Editor>()
+                .ok_or_else(|| anyhow!("unexpected item type"))?;
+
+            editor
+                .downgrade()
+                .update(&mut cx, |editor, cx| {
+                    if let Some(buffer) = editor.buffer().read(cx).as_singleton() {
+                        if buffer.read(cx).is_empty() {
+                            buffer.update(cx, |buffer, cx| {
+                                buffer.edit(
+                                    [(0..0, initial_local_settings_content(&Assets))],
+                                    None,
+                                    cx,
+                                )
+                            });
+                        }
+                    }
+                })
+                .ok();
+
+            anyhow::Ok(())
+        })
+        .detach();
+    } else {
+        workspace.show_notification(0, cx, |cx| {
+            cx.add_view(|_| MessageNotification::new("This project has no folders open."))
+        })
+    }
+}
+
 fn open_telemetry_log_file(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) {
     workspace.with_local_workspace(cx, move |workspace, cx| {
         let app_state = workspace.app_state().clone();