Add support for folder-specific settings (#2537)

Max Brunsfeld created

This PR allows you to customize Zed's settings within a particular
folder by creating a `.zed/settings.json` file within that folder.

Todo

* [x] respect folder-specific settings for local projects
* [x] respect folder-specific settings in remote projects
* [x] pass a path when retrieving editor/language settings
* [x] pass a path when retrieving copilot settings
* [ ] update the `Setting` trait to make it clear which types of
settings are locally overridable

Release Notes:

* Added support for folder-specific settings. You can customize Zed's
settings within a particular folder by creating a `.zed` directory and a
`.zed/settings.json` file within that folder.

Change summary

crates/collab/migrations.sqlite/20221109000000_test_schema.sql          |  10 
crates/collab/migrations/20230529164700_add_worktree_settings_files.sql |  10 
crates/collab/src/db.rs                                                 | 101 
crates/collab/src/db/worktree_settings_file.rs                          |  19 
crates/collab/src/rpc.rs                                                |  50 
crates/collab/src/tests/integration_tests.rs                            | 129 
crates/copilot/src/copilot.rs                                           |  11 
crates/copilot_button/src/copilot_button.rs                             |  49 
crates/editor/src/display_map.rs                                        |   7 
crates/editor/src/editor.rs                                             |  18 
crates/editor/src/items.rs                                              |   4 
crates/editor/src/multi_buffer.rs                                       |  20 
crates/language/src/buffer.rs                                           |  11 
crates/language/src/language_settings.rs                                |  23 
crates/project/src/lsp_command.rs                                       |   3 
crates/project/src/project.rs                                           | 242 
crates/project/src/project_tests.rs                                     |  60 
crates/project/src/worktree.rs                                          |  38 
crates/rpc/proto/zed.proto                                              |   9 
crates/rpc/src/proto.rs                                                 |   2 
crates/rpc/src/rpc.rs                                                   |   2 
crates/settings/src/settings_file.rs                                    |  13 
crates/settings/src/settings_store.rs                                   | 106 
crates/terminal_view/src/terminal_view.rs                               |   5 
crates/util/src/paths.rs                                                |   1 
crates/zed/src/languages/json.rs                                        |   5 
crates/zed/src/languages/yaml.rs                                        |   7 
27 files changed, 797 insertions(+), 158 deletions(-)

Detailed changes

crates/collab/migrations.sqlite/20221109000000_test_schema.sql 🔗

@@ -112,6 +112,16 @@ CREATE INDEX "index_worktree_repository_statuses_on_project_id" ON "worktree_rep
 CREATE INDEX "index_worktree_repository_statuses_on_project_id_and_worktree_id" ON "worktree_repository_statuses" ("project_id", "worktree_id");
 CREATE INDEX "index_worktree_repository_statuses_on_project_id_and_worktree_id_and_work_directory_id" ON "worktree_repository_statuses" ("project_id", "worktree_id", "work_directory_id");
 
+CREATE TABLE "worktree_settings_files" (
+    "project_id" INTEGER NOT NULL,
+    "worktree_id" INTEGER NOT NULL,
+    "path" VARCHAR NOT NULL,
+    "content" TEXT,
+    PRIMARY KEY(project_id, worktree_id, path),
+    FOREIGN KEY(project_id, worktree_id) REFERENCES worktrees (project_id, id) ON DELETE CASCADE
+);
+CREATE INDEX "index_worktree_settings_files_on_project_id" ON "worktree_settings_files" ("project_id");
+CREATE INDEX "index_worktree_settings_files_on_project_id_and_worktree_id" ON "worktree_settings_files" ("project_id", "worktree_id");
 
 CREATE TABLE "worktree_diagnostic_summaries" (
     "project_id" INTEGER NOT NULL,

crates/collab/migrations/20230529164700_add_worktree_settings_files.sql 🔗

@@ -0,0 +1,10 @@
+CREATE TABLE "worktree_settings_files" (
+    "project_id" INTEGER NOT NULL,
+    "worktree_id" INT8 NOT NULL,
+    "path" VARCHAR NOT NULL,
+    "content" TEXT NOT NULL,
+    PRIMARY KEY(project_id, worktree_id, path),
+    FOREIGN KEY(project_id, worktree_id) REFERENCES worktrees (project_id, id) ON DELETE CASCADE
+);
+CREATE INDEX "index_settings_files_on_project_id" ON "worktree_settings_files" ("project_id");
+CREATE INDEX "index_settings_files_on_project_id_and_wt_id" ON "worktree_settings_files" ("project_id", "worktree_id");

crates/collab/src/db.rs 🔗

@@ -16,6 +16,7 @@ mod worktree_diagnostic_summary;
 mod worktree_entry;
 mod worktree_repository;
 mod worktree_repository_statuses;
+mod worktree_settings_file;
 
 use crate::executor::Executor;
 use crate::{Error, Result};
@@ -1494,6 +1495,7 @@ impl Database {
                         updated_repositories: Default::default(),
                         removed_repositories: Default::default(),
                         diagnostic_summaries: Default::default(),
+                        settings_files: Default::default(),
                         scan_id: db_worktree.scan_id as u64,
                         completed_scan_id: db_worktree.completed_scan_id as u64,
                     };
@@ -1638,6 +1640,25 @@ impl Database {
                     })
                     .collect::<Vec<_>>();
 
+                {
+                    let mut db_settings_files = worktree_settings_file::Entity::find()
+                        .filter(worktree_settings_file::Column::ProjectId.eq(project_id))
+                        .stream(&*tx)
+                        .await?;
+                    while let Some(db_settings_file) = db_settings_files.next().await {
+                        let db_settings_file = db_settings_file?;
+                        if let Some(worktree) = worktrees
+                            .iter_mut()
+                            .find(|w| w.id == db_settings_file.worktree_id as u64)
+                        {
+                            worktree.settings_files.push(WorktreeSettingsFile {
+                                path: db_settings_file.path,
+                                content: db_settings_file.content,
+                            });
+                        }
+                    }
+                }
+
                 let mut collaborators = project
                     .find_related(project_collaborator::Entity)
                     .all(&*tx)
@@ -2637,6 +2658,58 @@ impl Database {
         .await
     }
 
+    pub async fn update_worktree_settings(
+        &self,
+        update: &proto::UpdateWorktreeSettings,
+        connection: ConnectionId,
+    ) -> Result<RoomGuard<Vec<ConnectionId>>> {
+        let project_id = ProjectId::from_proto(update.project_id);
+        let room_id = self.room_id_for_project(project_id).await?;
+        self.room_transaction(room_id, |tx| async move {
+            // Ensure the update comes from the host.
+            let project = project::Entity::find_by_id(project_id)
+                .one(&*tx)
+                .await?
+                .ok_or_else(|| anyhow!("no such project"))?;
+            if project.host_connection()? != connection {
+                return Err(anyhow!("can't update a project hosted by someone else"))?;
+            }
+
+            if let Some(content) = &update.content {
+                worktree_settings_file::Entity::insert(worktree_settings_file::ActiveModel {
+                    project_id: ActiveValue::Set(project_id),
+                    worktree_id: ActiveValue::Set(update.worktree_id as i64),
+                    path: ActiveValue::Set(update.path.clone()),
+                    content: ActiveValue::Set(content.clone()),
+                })
+                .on_conflict(
+                    OnConflict::columns([
+                        worktree_settings_file::Column::ProjectId,
+                        worktree_settings_file::Column::WorktreeId,
+                        worktree_settings_file::Column::Path,
+                    ])
+                    .update_column(worktree_settings_file::Column::Content)
+                    .to_owned(),
+                )
+                .exec(&*tx)
+                .await?;
+            } else {
+                worktree_settings_file::Entity::delete(worktree_settings_file::ActiveModel {
+                    project_id: ActiveValue::Set(project_id),
+                    worktree_id: ActiveValue::Set(update.worktree_id as i64),
+                    path: ActiveValue::Set(update.path.clone()),
+                    ..Default::default()
+                })
+                .exec(&*tx)
+                .await?;
+            }
+
+            let connection_ids = self.project_guest_connection_ids(project_id, &tx).await?;
+            Ok(connection_ids)
+        })
+        .await
+    }
+
     pub async fn join_project(
         &self,
         project_id: ProjectId,
@@ -2707,6 +2780,7 @@ impl Database {
                             entries: Default::default(),
                             repository_entries: Default::default(),
                             diagnostic_summaries: Default::default(),
+                            settings_files: Default::default(),
                             scan_id: db_worktree.scan_id as u64,
                             completed_scan_id: db_worktree.completed_scan_id as u64,
                         },
@@ -2819,6 +2893,25 @@ impl Database {
                 }
             }
 
+            // Populate worktree settings files
+            {
+                let mut db_settings_files = worktree_settings_file::Entity::find()
+                    .filter(worktree_settings_file::Column::ProjectId.eq(project_id))
+                    .stream(&*tx)
+                    .await?;
+                while let Some(db_settings_file) = db_settings_files.next().await {
+                    let db_settings_file = db_settings_file?;
+                    if let Some(worktree) =
+                        worktrees.get_mut(&(db_settings_file.worktree_id as u64))
+                    {
+                        worktree.settings_files.push(WorktreeSettingsFile {
+                            path: db_settings_file.path,
+                            content: db_settings_file.content,
+                        });
+                    }
+                }
+            }
+
             // Populate language servers.
             let language_servers = project
                 .find_related(language_server::Entity)
@@ -3482,6 +3575,7 @@ pub struct RejoinedWorktree {
     pub updated_repositories: Vec<proto::RepositoryEntry>,
     pub removed_repositories: Vec<u64>,
     pub diagnostic_summaries: Vec<proto::DiagnosticSummary>,
+    pub settings_files: Vec<WorktreeSettingsFile>,
     pub scan_id: u64,
     pub completed_scan_id: u64,
 }
@@ -3537,10 +3631,17 @@ pub struct Worktree {
     pub entries: Vec<proto::Entry>,
     pub repository_entries: BTreeMap<u64, proto::RepositoryEntry>,
     pub diagnostic_summaries: Vec<proto::DiagnosticSummary>,
+    pub settings_files: Vec<WorktreeSettingsFile>,
     pub scan_id: u64,
     pub completed_scan_id: u64,
 }
 
+#[derive(Debug)]
+pub struct WorktreeSettingsFile {
+    pub path: String,
+    pub content: String,
+}
+
 #[cfg(test)]
 pub use test::*;
 

crates/collab/src/db/worktree_settings_file.rs 🔗

@@ -0,0 +1,19 @@
+use super::ProjectId;
+use sea_orm::entity::prelude::*;
+
+#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
+#[sea_orm(table_name = "worktree_settings_files")]
+pub struct Model {
+    #[sea_orm(primary_key)]
+    pub project_id: ProjectId,
+    #[sea_orm(primary_key)]
+    pub worktree_id: i64,
+    #[sea_orm(primary_key)]
+    pub path: String,
+    pub content: String,
+}
+
+#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
+pub enum Relation {}
+
+impl ActiveModelBehavior for ActiveModel {}

crates/collab/src/rpc.rs 🔗

@@ -200,6 +200,7 @@ impl Server {
             .add_message_handler(start_language_server)
             .add_message_handler(update_language_server)
             .add_message_handler(update_diagnostic_summary)
+            .add_message_handler(update_worktree_settings)
             .add_request_handler(forward_project_request::<proto::GetHover>)
             .add_request_handler(forward_project_request::<proto::GetDefinition>)
             .add_request_handler(forward_project_request::<proto::GetTypeDefinition>)
@@ -1088,6 +1089,18 @@ async fn rejoin_room(
                         },
                     )?;
                 }
+
+                for settings_file in worktree.settings_files {
+                    session.peer.send(
+                        session.connection_id,
+                        proto::UpdateWorktreeSettings {
+                            project_id: project.id.to_proto(),
+                            worktree_id: worktree.id,
+                            path: settings_file.path,
+                            content: Some(settings_file.content),
+                        },
+                    )?;
+                }
             }
 
             for language_server in &project.language_servers {
@@ -1410,6 +1423,18 @@ async fn join_project(
                 },
             )?;
         }
+
+        for settings_file in dbg!(worktree.settings_files) {
+            session.peer.send(
+                session.connection_id,
+                proto::UpdateWorktreeSettings {
+                    project_id: project_id.to_proto(),
+                    worktree_id: worktree.id,
+                    path: settings_file.path,
+                    content: Some(settings_file.content),
+                },
+            )?;
+        }
     }
 
     for language_server in &project.language_servers {
@@ -1525,6 +1550,31 @@ async fn update_diagnostic_summary(
     Ok(())
 }
 
+async fn update_worktree_settings(
+    message: proto::UpdateWorktreeSettings,
+    session: Session,
+) -> Result<()> {
+    dbg!(&message);
+
+    let guest_connection_ids = session
+        .db()
+        .await
+        .update_worktree_settings(&message, session.connection_id)
+        .await?;
+
+    broadcast(
+        Some(session.connection_id),
+        guest_connection_ids.iter().copied(),
+        |connection_id| {
+            session
+                .peer
+                .forward_send(session.connection_id, connection_id, message.clone())
+        },
+    );
+
+    Ok(())
+}
+
 async fn start_language_server(
     request: proto::StartLanguageServer,
     session: Session,

crates/collab/src/tests/integration_tests.rs 🔗

@@ -3114,6 +3114,135 @@ async fn test_fs_operations(
     });
 }
 
+#[gpui::test(iterations = 10)]
+async fn test_local_settings(
+    deterministic: Arc<Deterministic>,
+    cx_a: &mut TestAppContext,
+    cx_b: &mut TestAppContext,
+) {
+    deterministic.forbid_parking();
+    let mut server = TestServer::start(&deterministic).await;
+    let client_a = server.create_client(cx_a, "user_a").await;
+    let client_b = server.create_client(cx_b, "user_b").await;
+    server
+        .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
+        .await;
+    let active_call_a = cx_a.read(ActiveCall::global);
+
+    // As client A, open a project that contains some local settings files
+    client_a
+        .fs
+        .insert_tree(
+            "/dir",
+            json!({
+                ".zed": {
+                    "settings.json": r#"{ "tab_size": 2 }"#
+                },
+                "a": {
+                    ".zed": {
+                        "settings.json": r#"{ "tab_size": 8 }"#
+                    },
+                    "a.txt": "a-contents",
+                },
+                "b": {
+                    "b.txt": "b-contents",
+                }
+            }),
+        )
+        .await;
+    let (project_a, _) = client_a.build_local_project("/dir", cx_a).await;
+    let project_id = active_call_a
+        .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
+        .await
+        .unwrap();
+
+    // As client B, join that project and observe the local settings.
+    let project_b = client_b.build_remote_project(project_id, cx_b).await;
+    let worktree_b = project_b.read_with(cx_b, |project, cx| project.worktrees(cx).next().unwrap());
+    deterministic.run_until_parked();
+    cx_b.read(|cx| {
+        let store = cx.global::<SettingsStore>();
+        assert_eq!(
+            store.local_settings(worktree_b.id()).collect::<Vec<_>>(),
+            &[
+                (Path::new("").into(), r#"{"tab_size":2}"#.to_string()),
+                (Path::new("a").into(), r#"{"tab_size":8}"#.to_string()),
+            ]
+        )
+    });
+
+    // As client A, update a settings file. As Client B, see the changed settings.
+    client_a
+        .fs
+        .insert_file("/dir/.zed/settings.json", r#"{}"#.into())
+        .await;
+    deterministic.run_until_parked();
+    cx_b.read(|cx| {
+        let store = cx.global::<SettingsStore>();
+        assert_eq!(
+            store.local_settings(worktree_b.id()).collect::<Vec<_>>(),
+            &[
+                (Path::new("").into(), r#"{}"#.to_string()),
+                (Path::new("a").into(), r#"{"tab_size":8}"#.to_string()),
+            ]
+        )
+    });
+
+    // As client A, create and remove some settings files. As client B, see the changed settings.
+    client_a
+        .fs
+        .remove_file("/dir/.zed/settings.json".as_ref(), Default::default())
+        .await
+        .unwrap();
+    client_a
+        .fs
+        .create_dir("/dir/b/.zed".as_ref())
+        .await
+        .unwrap();
+    client_a
+        .fs
+        .insert_file("/dir/b/.zed/settings.json", r#"{"tab_size": 4}"#.into())
+        .await;
+    deterministic.run_until_parked();
+    cx_b.read(|cx| {
+        let store = cx.global::<SettingsStore>();
+        assert_eq!(
+            store.local_settings(worktree_b.id()).collect::<Vec<_>>(),
+            &[
+                (Path::new("a").into(), r#"{"tab_size":8}"#.to_string()),
+                (Path::new("b").into(), r#"{"tab_size":4}"#.to_string()),
+            ]
+        )
+    });
+
+    // As client B, disconnect.
+    server.forbid_connections();
+    server.disconnect_client(client_b.peer_id().unwrap());
+
+    // As client A, change and remove settings files while client B is disconnected.
+    client_a
+        .fs
+        .insert_file("/dir/a/.zed/settings.json", r#"{"hard_tabs":true}"#.into())
+        .await;
+    client_a
+        .fs
+        .remove_file("/dir/b/.zed/settings.json".as_ref(), Default::default())
+        .await
+        .unwrap();
+    deterministic.run_until_parked();
+
+    // As client B, reconnect and see the changed settings.
+    server.allow_connections();
+    deterministic.advance_clock(RECEIVE_TIMEOUT);
+    cx_b.read(|cx| {
+        let store = cx.global::<SettingsStore>();
+        assert_eq!(
+            store.local_settings(worktree_b.id()).collect::<Vec<_>>(),
+            &[(Path::new("a").into(), r#"{"hard_tabs":true}"#.to_string()),]
+        )
+    });
+}
+
 #[gpui::test(iterations = 10)]
 async fn test_buffer_conflict_after_save(
     deterministic: Arc<Deterministic>,

crates/copilot/src/copilot.rs 🔗

@@ -318,7 +318,7 @@ impl Copilot {
     fn enable_or_disable_copilot(&mut self, cx: &mut ModelContext<Copilot>) {
         let http = self.http.clone();
         let node_runtime = self.node_runtime.clone();
-        if all_language_settings(cx).copilot_enabled(None, None) {
+        if all_language_settings(None, cx).copilot_enabled(None, None) {
             if matches!(self.server, CopilotServer::Disabled) {
                 let start_task = cx
                     .spawn({
@@ -785,10 +785,7 @@ impl Copilot {
         let buffer = buffer.read(cx);
         let uri = registered_buffer.uri.clone();
         let position = position.to_point_utf16(buffer);
-        let settings = language_settings(
-            buffer.language_at(position).map(|l| l.name()).as_deref(),
-            cx,
-        );
+        let settings = language_settings(buffer.language_at(position).as_ref(), buffer.file(), cx);
         let tab_size = settings.tab_size;
         let hard_tabs = settings.hard_tabs;
         let relative_path = buffer
@@ -1175,6 +1172,10 @@ mod tests {
         fn to_proto(&self) -> rpc::proto::File {
             unimplemented!()
         }
+
+        fn worktree_id(&self) -> usize {
+            0
+        }
     }
 
     impl language::LocalFile for File {

crates/copilot_button/src/copilot_button.rs 🔗

@@ -9,7 +9,10 @@ use gpui::{
     AnyElement, AppContext, AsyncAppContext, Element, Entity, MouseState, Subscription, View,
     ViewContext, ViewHandle, WeakViewHandle, WindowContext,
 };
-use language::language_settings::{self, all_language_settings, AllLanguageSettings};
+use language::{
+    language_settings::{self, all_language_settings, AllLanguageSettings},
+    File, Language,
+};
 use settings::{update_settings_file, SettingsStore};
 use std::{path::Path, sync::Arc};
 use util::{paths, ResultExt};
@@ -26,8 +29,8 @@ pub struct CopilotButton {
     popup_menu: ViewHandle<ContextMenu>,
     editor_subscription: Option<(Subscription, usize)>,
     editor_enabled: Option<bool>,
-    language: Option<Arc<str>>,
-    path: Option<Arc<Path>>,
+    language: Option<Arc<Language>>,
+    file: Option<Arc<dyn File>>,
     fs: Arc<dyn Fs>,
 }
 
@@ -41,7 +44,7 @@ impl View for CopilotButton {
     }
 
     fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
-        let all_language_settings = &all_language_settings(cx);
+        let all_language_settings = all_language_settings(None, cx);
         if !all_language_settings.copilot.feature_enabled {
             return Empty::new().into_any();
         }
@@ -165,7 +168,7 @@ impl CopilotButton {
             editor_subscription: None,
             editor_enabled: None,
             language: None,
-            path: None,
+            file: None,
             fs,
         }
     }
@@ -197,14 +200,13 @@ impl CopilotButton {
 
         if let Some(language) = self.language.clone() {
             let fs = fs.clone();
-            let language_enabled =
-                language_settings::language_settings(Some(language.as_ref()), cx)
-                    .show_copilot_suggestions;
+            let language_enabled = language_settings::language_settings(Some(&language), None, cx)
+                .show_copilot_suggestions;
             menu_options.push(ContextMenuItem::handler(
                 format!(
                     "{} Suggestions for {}",
                     if language_enabled { "Hide" } else { "Show" },
-                    language
+                    language.name()
                 ),
                 move |cx| toggle_copilot_for_language(language.clone(), fs.clone(), cx),
             ));
@@ -212,9 +214,9 @@ impl CopilotButton {
 
         let settings = settings::get::<AllLanguageSettings>(cx);
 
-        if let Some(path) = self.path.as_ref() {
-            let path_enabled = settings.copilot_enabled_for_path(path);
-            let path = path.clone();
+        if let Some(file) = &self.file {
+            let path = file.path().clone();
+            let path_enabled = settings.copilot_enabled_for_path(&path);
             menu_options.push(ContextMenuItem::handler(
                 format!(
                     "{} Suggestions for This Path",
@@ -276,17 +278,15 @@ impl CopilotButton {
         let editor = editor.read(cx);
         let snapshot = editor.buffer().read(cx).snapshot(cx);
         let suggestion_anchor = editor.selections.newest_anchor().start;
-        let language_name = snapshot
-            .language_at(suggestion_anchor)
-            .map(|language| language.name());
-        let path = snapshot.file_at(suggestion_anchor).map(|file| file.path());
+        let language = snapshot.language_at(suggestion_anchor);
+        let file = snapshot.file_at(suggestion_anchor).cloned();
 
         self.editor_enabled = Some(
-            all_language_settings(cx)
-                .copilot_enabled(language_name.as_deref(), path.map(|p| p.as_ref())),
+            all_language_settings(self.file.as_ref(), cx)
+                .copilot_enabled(language, file.as_ref().map(|file| file.path().as_ref())),
         );
-        self.language = language_name;
-        self.path = path.cloned();
+        self.language = language.cloned();
+        self.file = file;
 
         cx.notify()
     }
@@ -363,17 +363,18 @@ async fn configure_disabled_globs(
 }
 
 fn toggle_copilot_globally(fs: Arc<dyn Fs>, cx: &mut AppContext) {
-    let show_copilot_suggestions = all_language_settings(cx).copilot_enabled(None, None);
+    let show_copilot_suggestions = all_language_settings(None, cx).copilot_enabled(None, None);
     update_settings_file::<AllLanguageSettings>(fs, cx, move |file| {
         file.defaults.show_copilot_suggestions = Some((!show_copilot_suggestions).into())
     });
 }
 
-fn toggle_copilot_for_language(language: Arc<str>, fs: Arc<dyn Fs>, cx: &mut AppContext) {
-    let show_copilot_suggestions = all_language_settings(cx).copilot_enabled(Some(&language), None);
+fn toggle_copilot_for_language(language: Arc<Language>, fs: Arc<dyn Fs>, cx: &mut AppContext) {
+    let show_copilot_suggestions =
+        all_language_settings(None, cx).copilot_enabled(Some(&language), None);
     update_settings_file::<AllLanguageSettings>(fs, cx, move |file| {
         file.languages
-            .entry(language)
+            .entry(language.name())
             .or_default()
             .show_copilot_suggestions = Some(!show_copilot_suggestions);
     });

crates/editor/src/display_map.rs 🔗

@@ -272,12 +272,11 @@ impl DisplayMap {
     }
 
     fn tab_size(buffer: &ModelHandle<MultiBuffer>, cx: &mut ModelContext<Self>) -> NonZeroU32 {
-        let language_name = buffer
+        let language = buffer
             .read(cx)
             .as_singleton()
-            .and_then(|buffer| buffer.read(cx).language())
-            .map(|language| language.name());
-        language_settings(language_name.as_deref(), cx).tab_size
+            .and_then(|buffer| buffer.read(cx).language());
+        language_settings(language.as_deref(), None, cx).tab_size
     }
 
     #[cfg(test)]

crates/editor/src/editor.rs 🔗

@@ -3207,12 +3207,10 @@ impl Editor {
         snapshot: &MultiBufferSnapshot,
         cx: &mut ViewContext<Self>,
     ) -> bool {
-        let path = snapshot.file_at(location).map(|file| file.path().as_ref());
-        let language_name = snapshot
-            .language_at(location)
-            .map(|language| language.name());
-        let settings = all_language_settings(cx);
-        settings.copilot_enabled(language_name.as_deref(), path)
+        let file = snapshot.file_at(location);
+        let language = snapshot.language_at(location);
+        let settings = all_language_settings(file, cx);
+        settings.copilot_enabled(language, file.map(|f| f.path().as_ref()))
     }
 
     fn has_active_copilot_suggestion(&self, cx: &AppContext) -> bool {
@@ -7076,11 +7074,13 @@ impl Editor {
         };
 
         // If None, we are in a file without an extension
-        let file_extension = file_extension.or(self
+        let file = self
             .buffer
             .read(cx)
             .as_singleton()
-            .and_then(|b| b.read(cx).file())
+            .and_then(|b| b.read(cx).file());
+        let file_extension = file_extension.or(file
+            .as_ref()
             .and_then(|file| Path::new(file.file_name(cx)).extension())
             .and_then(|e| e.to_str())
             .map(|a| a.to_string()));
@@ -7091,7 +7091,7 @@ impl Editor {
             .get("vim_mode")
             == Some(&serde_json::Value::Bool(true));
         let telemetry_settings = *settings::get::<TelemetrySettings>(cx);
-        let copilot_enabled = all_language_settings(cx).copilot_enabled(None, None);
+        let copilot_enabled = all_language_settings(file, cx).copilot_enabled(None, None);
         let copilot_enabled_for_language = self
             .buffer
             .read(cx)

crates/editor/src/items.rs 🔗

@@ -1231,6 +1231,10 @@ mod tests {
             unimplemented!()
         }
 
+        fn worktree_id(&self) -> usize {
+            0
+        }
+
         fn is_deleted(&self) -> bool {
             unimplemented!()
         }

crates/editor/src/multi_buffer.rs 🔗

@@ -1377,8 +1377,14 @@ impl MultiBuffer {
         point: T,
         cx: &'a AppContext,
     ) -> &'a LanguageSettings {
-        let language = self.language_at(point, cx);
-        language_settings(language.map(|l| l.name()).as_deref(), cx)
+        let mut language = None;
+        let mut file = None;
+        if let Some((buffer, offset)) = self.point_to_buffer_offset(point, cx) {
+            let buffer = buffer.read(cx);
+            language = buffer.language_at(offset);
+            file = buffer.file();
+        }
+        language_settings(language.as_ref(), file, cx)
     }
 
     pub fn for_each_buffer(&self, mut f: impl FnMut(&ModelHandle<Buffer>)) {
@@ -2785,9 +2791,13 @@ impl MultiBufferSnapshot {
         point: T,
         cx: &'a AppContext,
     ) -> &'a LanguageSettings {
-        self.point_to_buffer_offset(point)
-            .map(|(buffer, offset)| buffer.settings_at(offset, cx))
-            .unwrap_or_else(|| language_settings(None, cx))
+        let mut language = None;
+        let mut file = None;
+        if let Some((buffer, offset)) = self.point_to_buffer_offset(point) {
+            language = buffer.language_at(offset);
+            file = buffer.file();
+        }
+        language_settings(language, file, cx)
     }
 
     pub fn language_scope_at<'a, T: ToOffset>(&'a self, point: T) -> Option<LanguageScope> {

crates/language/src/buffer.rs 🔗

@@ -216,6 +216,11 @@ pub trait File: Send + Sync {
     /// of its worktree, then this method will return the name of the worktree itself.
     fn file_name<'a>(&'a self, cx: &'a AppContext) -> &'a OsStr;
 
+    /// Returns the id of the worktree to which this file belongs.
+    ///
+    /// This is needed for looking up project-specific settings.
+    fn worktree_id(&self) -> usize;
+
     fn is_deleted(&self) -> bool;
 
     fn as_any(&self) -> &dyn Any;
@@ -1802,8 +1807,7 @@ impl BufferSnapshot {
     }
 
     pub fn language_indent_size_at<T: ToOffset>(&self, position: T, cx: &AppContext) -> IndentSize {
-        let language_name = self.language_at(position).map(|language| language.name());
-        let settings = language_settings(language_name.as_deref(), cx);
+        let settings = language_settings(self.language_at(position), self.file(), cx);
         if settings.hard_tabs {
             IndentSize::tab()
         } else {
@@ -2127,8 +2131,7 @@ impl BufferSnapshot {
         position: D,
         cx: &'a AppContext,
     ) -> &'a LanguageSettings {
-        let language = self.language_at(position);
-        language_settings(language.map(|l| l.name()).as_deref(), cx)
+        language_settings(self.language_at(position), self.file.as_ref(), cx)
     }
 
     pub fn language_scope_at<D: ToOffset>(&self, position: D) -> Option<LanguageScope> {

crates/language/src/language_settings.rs 🔗

@@ -1,3 +1,4 @@
+use crate::{File, Language};
 use anyhow::Result;
 use collections::HashMap;
 use globset::GlobMatcher;
@@ -13,12 +14,21 @@ pub fn init(cx: &mut AppContext) {
     settings::register::<AllLanguageSettings>(cx);
 }
 
-pub fn language_settings<'a>(language: Option<&str>, cx: &'a AppContext) -> &'a LanguageSettings {
-    settings::get::<AllLanguageSettings>(cx).language(language)
+pub fn language_settings<'a>(
+    language: Option<&Arc<Language>>,
+    file: Option<&Arc<dyn File>>,
+    cx: &'a AppContext,
+) -> &'a LanguageSettings {
+    let language_name = language.map(|l| l.name());
+    all_language_settings(file, cx).language(language_name.as_deref())
 }
 
-pub fn all_language_settings<'a>(cx: &'a AppContext) -> &'a AllLanguageSettings {
-    settings::get::<AllLanguageSettings>(cx)
+pub fn all_language_settings<'a>(
+    file: Option<&Arc<dyn File>>,
+    cx: &'a AppContext,
+) -> &'a AllLanguageSettings {
+    let location = file.map(|f| (f.worktree_id(), f.path().as_ref()));
+    settings::get_local(location, cx)
 }
 
 #[derive(Debug, Clone)]
@@ -155,7 +165,7 @@ impl AllLanguageSettings {
             .any(|glob| glob.is_match(path))
     }
 
-    pub fn copilot_enabled(&self, language_name: Option<&str>, path: Option<&Path>) -> bool {
+    pub fn copilot_enabled(&self, language: Option<&Arc<Language>>, path: Option<&Path>) -> bool {
         if !self.copilot.feature_enabled {
             return false;
         }
@@ -166,7 +176,8 @@ impl AllLanguageSettings {
             }
         }
 
-        self.language(language_name).show_copilot_suggestions
+        self.language(language.map(|l| l.name()).as_deref())
+            .show_copilot_suggestions
     }
 }
 

crates/project/src/lsp_command.rs 🔗

@@ -1717,8 +1717,7 @@ impl LspCommand for OnTypeFormatting {
             .await?;
 
         let tab_size = buffer.read_with(&cx, |buffer, cx| {
-            let language_name = buffer.language().map(|language| language.name());
-            language_settings(language_name.as_deref(), cx).tab_size
+            language_settings(buffer.language(), buffer.file(), cx).tab_size
         });
 
         Ok(Self {

crates/project/src/project.rs 🔗

@@ -28,7 +28,7 @@ use gpui::{
     ModelHandle, Task, WeakModelHandle,
 };
 use language::{
-    language_settings::{all_language_settings, language_settings, FormatOnSave, Formatter},
+    language_settings::{language_settings, FormatOnSave, Formatter},
     point_to_lsp,
     proto::{
         deserialize_anchor, deserialize_fingerprint, deserialize_line_ending, deserialize_version,
@@ -72,7 +72,10 @@ use std::{
     time::{Duration, Instant, SystemTime},
 };
 use terminals::Terminals;
-use util::{debug_panic, defer, merge_json_value_into, post_inc, ResultExt, TryFutureExt as _};
+use util::{
+    debug_panic, defer, merge_json_value_into, paths::LOCAL_SETTINGS_RELATIVE_PATH, post_inc,
+    ResultExt, TryFutureExt as _,
+};
 
 pub use fs::*;
 pub use worktree::*;
@@ -460,6 +463,7 @@ impl Project {
         client.add_model_request_handler(Self::handle_update_buffer);
         client.add_model_message_handler(Self::handle_update_diagnostic_summary);
         client.add_model_message_handler(Self::handle_update_worktree);
+        client.add_model_message_handler(Self::handle_update_worktree_settings);
         client.add_model_request_handler(Self::handle_create_project_entry);
         client.add_model_request_handler(Self::handle_rename_project_entry);
         client.add_model_request_handler(Self::handle_copy_project_entry);
@@ -686,42 +690,37 @@ impl Project {
     }
 
     fn on_settings_changed(&mut self, cx: &mut ModelContext<Self>) {
-        let settings = all_language_settings(cx);
-
         let mut language_servers_to_start = Vec::new();
         for buffer in self.opened_buffers.values() {
             if let Some(buffer) = buffer.upgrade(cx) {
                 let buffer = buffer.read(cx);
-                if let Some((file, language)) = File::from_dyn(buffer.file()).zip(buffer.language())
-                {
-                    if settings
-                        .language(Some(&language.name()))
-                        .enable_language_server
-                    {
-                        let worktree = file.worktree.read(cx);
-                        language_servers_to_start.push((
-                            worktree.id(),
-                            worktree.as_local().unwrap().abs_path().clone(),
-                            language.clone(),
-                        ));
+                if let Some((file, language)) = buffer.file().zip(buffer.language()) {
+                    let settings = language_settings(Some(language), Some(file), cx);
+                    if settings.enable_language_server {
+                        if let Some(file) = File::from_dyn(Some(file)) {
+                            language_servers_to_start
+                                .push((file.worktree.clone(), language.clone()));
+                        }
                     }
                 }
             }
         }
 
         let mut language_servers_to_stop = Vec::new();
-        for language in self.languages.to_vec() {
-            for lsp_adapter in language.lsp_adapters() {
-                if !settings
-                    .language(Some(&language.name()))
-                    .enable_language_server
-                {
-                    let lsp_name = &lsp_adapter.name;
-                    for (worktree_id, started_lsp_name) in self.language_server_ids.keys() {
-                        if lsp_name == started_lsp_name {
-                            language_servers_to_stop.push((*worktree_id, started_lsp_name.clone()));
-                        }
-                    }
+        let languages = self.languages.to_vec();
+        for (worktree_id, started_lsp_name) in self.language_server_ids.keys() {
+            let language = languages.iter().find(|l| {
+                l.lsp_adapters()
+                    .iter()
+                    .any(|adapter| &adapter.name == started_lsp_name)
+            });
+            if let Some(language) = language {
+                let worktree = self.worktree_for_id(*worktree_id, cx);
+                let file = worktree.and_then(|tree| {
+                    tree.update(cx, |tree, cx| tree.root_file(cx).map(|f| f as _))
+                });
+                if !language_settings(Some(language), file.as_ref(), cx).enable_language_server {
+                    language_servers_to_stop.push((*worktree_id, started_lsp_name.clone()));
                 }
             }
         }
@@ -733,8 +732,9 @@ impl Project {
         }
 
         // Start all the newly-enabled language servers.
-        for (worktree_id, worktree_path, language) in language_servers_to_start {
-            self.start_language_servers(worktree_id, worktree_path, language, cx);
+        for (worktree, language) in language_servers_to_start {
+            let worktree_path = worktree.read(cx).abs_path();
+            self.start_language_servers(&worktree, worktree_path, language, cx);
         }
 
         if !self.copilot_enabled && Copilot::global(cx).is_some() {
@@ -1107,6 +1107,21 @@ impl Project {
                 .log_err();
         }
 
+        let store = cx.global::<SettingsStore>();
+        for worktree in self.worktrees(cx) {
+            let worktree_id = worktree.read(cx).id().to_proto();
+            for (path, content) in store.local_settings(worktree.id()) {
+                self.client
+                    .send(proto::UpdateWorktreeSettings {
+                        project_id,
+                        worktree_id,
+                        path: path.to_string_lossy().into(),
+                        content: Some(content),
+                    })
+                    .log_err();
+            }
+        }
+
         let (updates_tx, mut updates_rx) = mpsc::unbounded();
         let client = self.client.clone();
         self.client_state = Some(ProjectClientState::Local {
@@ -1219,6 +1234,14 @@ impl Project {
         message_id: u32,
         cx: &mut ModelContext<Self>,
     ) -> Result<()> {
+        cx.update_global::<SettingsStore, _, _>(|store, cx| {
+            for worktree in &self.worktrees {
+                store
+                    .clear_local_settings(worktree.handle_id(), cx)
+                    .log_err();
+            }
+        });
+
         self.join_project_response_message_id = message_id;
         self.set_worktrees_from_proto(message.worktrees, cx)?;
         self.set_collaborators_from_proto(message.collaborators, cx)?;
@@ -2321,25 +2344,34 @@ impl Project {
         });
 
         if let Some(file) = File::from_dyn(buffer.read(cx).file()) {
-            if let Some(worktree) = file.worktree.read(cx).as_local() {
-                let worktree_id = worktree.id();
-                let worktree_abs_path = worktree.abs_path().clone();
-                self.start_language_servers(worktree_id, worktree_abs_path, new_language, cx);
+            let worktree = file.worktree.clone();
+            if let Some(tree) = worktree.read(cx).as_local() {
+                self.start_language_servers(&worktree, tree.abs_path().clone(), new_language, cx);
             }
         }
     }
 
     fn start_language_servers(
         &mut self,
-        worktree_id: WorktreeId,
+        worktree: &ModelHandle<Worktree>,
         worktree_path: Arc<Path>,
         language: Arc<Language>,
         cx: &mut ModelContext<Self>,
     ) {
-        if !language_settings(Some(&language.name()), cx).enable_language_server {
+        if !language_settings(
+            Some(&language),
+            worktree
+                .update(cx, |tree, cx| tree.root_file(cx))
+                .map(|f| f as _)
+                .as_ref(),
+            cx,
+        )
+        .enable_language_server
+        {
             return;
         }
 
+        let worktree_id = worktree.read(cx).id();
         for adapter in language.lsp_adapters() {
             let key = (worktree_id, adapter.name.clone());
             if self.language_server_ids.contains_key(&key) {
@@ -2748,23 +2780,22 @@ impl Project {
         buffers: impl IntoIterator<Item = ModelHandle<Buffer>>,
         cx: &mut ModelContext<Self>,
     ) -> Option<()> {
-        let language_server_lookup_info: HashSet<(WorktreeId, Arc<Path>, Arc<Language>)> = buffers
+        let language_server_lookup_info: HashSet<(ModelHandle<Worktree>, Arc<Language>)> = buffers
             .into_iter()
             .filter_map(|buffer| {
                 let buffer = buffer.read(cx);
                 let file = File::from_dyn(buffer.file())?;
-                let worktree = file.worktree.read(cx).as_local()?;
                 let full_path = file.full_path(cx);
                 let language = self
                     .languages
                     .language_for_file(&full_path, Some(buffer.as_rope()))
                     .now_or_never()?
                     .ok()?;
-                Some((worktree.id(), worktree.abs_path().clone(), language))
+                Some((file.worktree.clone(), language))
             })
             .collect();
-        for (worktree_id, worktree_abs_path, language) in language_server_lookup_info {
-            self.restart_language_servers(worktree_id, worktree_abs_path, language, cx);
+        for (worktree, language) in language_server_lookup_info {
+            self.restart_language_servers(worktree, language, cx);
         }
 
         None
@@ -2773,11 +2804,13 @@ impl Project {
     // TODO This will break in the case where the adapter's root paths and worktrees are not equal
     fn restart_language_servers(
         &mut self,
-        worktree_id: WorktreeId,
-        fallback_path: Arc<Path>,
+        worktree: ModelHandle<Worktree>,
         language: Arc<Language>,
         cx: &mut ModelContext<Self>,
     ) {
+        let worktree_id = worktree.read(cx).id();
+        let fallback_path = worktree.read(cx).abs_path();
+
         let mut stops = Vec::new();
         for adapter in language.lsp_adapters() {
             stops.push(self.stop_language_server(worktree_id, adapter.name.clone(), cx));
@@ -2807,7 +2840,7 @@ impl Project {
                     .map(|path_buf| Arc::from(path_buf.as_path()))
                     .unwrap_or(fallback_path);
 
-                this.start_language_servers(worktree_id, root_path, language.clone(), cx);
+                this.start_language_servers(&worktree, root_path, language.clone(), cx);
 
                 // Lookup new server ids and set them for each of the orphaned worktrees
                 for adapter in language.lsp_adapters() {
@@ -3432,8 +3465,7 @@ impl Project {
                 let mut project_transaction = ProjectTransaction::default();
                 for (buffer, buffer_abs_path, language_server) in &buffers_with_paths_and_servers {
                     let settings = buffer.read_with(&cx, |buffer, cx| {
-                        let language_name = buffer.language().map(|language| language.name());
-                        language_settings(language_name.as_deref(), cx).clone()
+                        language_settings(buffer.language(), buffer.file(), cx).clone()
                     });
 
                     let remove_trailing_whitespace = settings.remove_trailing_whitespace_on_save;
@@ -4463,11 +4495,14 @@ impl Project {
         push_to_history: bool,
         cx: &mut ModelContext<Self>,
     ) -> Task<Result<Option<Transaction>>> {
-        let tab_size = buffer.read_with(cx, |buffer, cx| {
-            let language_name = buffer.language().map(|language| language.name());
-            language_settings(language_name.as_deref(), cx).tab_size
+        let (position, tab_size) = buffer.read_with(cx, |buffer, cx| {
+            let position = position.to_point_utf16(buffer);
+            (
+                position,
+                language_settings(buffer.language_at(position).as_ref(), buffer.file(), cx)
+                    .tab_size,
+            )
         });
-        let position = position.to_point_utf16(buffer.read(cx));
         self.request_lsp(
             buffer.clone(),
             OnTypeFormatting {
@@ -4873,6 +4908,7 @@ impl Project {
                 worktree::Event::UpdatedEntries(changes) => {
                     this.update_local_worktree_buffers(&worktree, changes, cx);
                     this.update_local_worktree_language_servers(&worktree, changes, cx);
+                    this.update_local_worktree_settings(&worktree, changes, cx);
                 }
                 worktree::Event::UpdatedGitRepositories(updated_repos) => {
                     this.update_local_worktree_buffers_git_repos(worktree, updated_repos, cx)
@@ -4893,8 +4929,12 @@ impl Project {
                 .push(WorktreeHandle::Weak(worktree.downgrade()));
         }
 
-        cx.observe_release(worktree, |this, worktree, cx| {
+        let handle_id = worktree.id();
+        cx.observe_release(worktree, move |this, worktree, cx| {
             let _ = this.remove_worktree(worktree.id(), cx);
+            cx.update_global::<SettingsStore, _, _>(|store, cx| {
+                store.clear_local_settings(handle_id, cx).log_err()
+            });
         })
         .detach();
 
@@ -5179,6 +5219,71 @@ impl Project {
         .detach();
     }
 
+    fn update_local_worktree_settings(
+        &mut self,
+        worktree: &ModelHandle<Worktree>,
+        changes: &UpdatedEntriesSet,
+        cx: &mut ModelContext<Self>,
+    ) {
+        let project_id = self.remote_id();
+        let worktree_id = worktree.id();
+        let worktree = worktree.read(cx).as_local().unwrap();
+        let remote_worktree_id = worktree.id();
+
+        let mut settings_contents = Vec::new();
+        for (path, _, change) in changes.iter() {
+            if path.ends_with(&*LOCAL_SETTINGS_RELATIVE_PATH) {
+                let settings_dir = Arc::from(
+                    path.ancestors()
+                        .nth(LOCAL_SETTINGS_RELATIVE_PATH.components().count())
+                        .unwrap(),
+                );
+                let fs = self.fs.clone();
+                let removed = *change == PathChange::Removed;
+                let abs_path = worktree.absolutize(path);
+                settings_contents.push(async move {
+                    (settings_dir, (!removed).then_some(fs.load(&abs_path).await))
+                });
+            }
+        }
+
+        if settings_contents.is_empty() {
+            return;
+        }
+
+        let client = self.client.clone();
+        cx.spawn_weak(move |_, mut cx| async move {
+            let settings_contents: Vec<(Arc<Path>, _)> =
+                futures::future::join_all(settings_contents).await;
+            cx.update(|cx| {
+                cx.update_global::<SettingsStore, _, _>(|store, cx| {
+                    for (directory, file_content) in settings_contents {
+                        let file_content = file_content.and_then(|content| content.log_err());
+                        store
+                            .set_local_settings(
+                                worktree_id,
+                                directory.clone(),
+                                file_content.as_ref().map(String::as_str),
+                                cx,
+                            )
+                            .log_err();
+                        if let Some(remote_id) = project_id {
+                            client
+                                .send(proto::UpdateWorktreeSettings {
+                                    project_id: remote_id,
+                                    worktree_id: remote_worktree_id.to_proto(),
+                                    path: directory.to_string_lossy().into_owned(),
+                                    content: file_content,
+                                })
+                                .log_err();
+                        }
+                    }
+                });
+            });
+        })
+        .detach();
+    }
+
     pub fn set_active_path(&mut self, entry: Option<ProjectPath>, cx: &mut ModelContext<Self>) {
         let new_active_entry = entry.and_then(|project_path| {
             let worktree = self.worktree_for_id(project_path.worktree_id, cx)?;
@@ -5431,6 +5536,30 @@ impl Project {
         })
     }
 
+    async fn handle_update_worktree_settings(
+        this: ModelHandle<Self>,
+        envelope: TypedEnvelope<proto::UpdateWorktreeSettings>,
+        _: Arc<Client>,
+        mut cx: AsyncAppContext,
+    ) -> Result<()> {
+        this.update(&mut cx, |this, cx| {
+            let worktree_id = WorktreeId::from_proto(envelope.payload.worktree_id);
+            if let Some(worktree) = this.worktree_for_id(worktree_id, cx) {
+                cx.update_global::<SettingsStore, _, _>(|store, cx| {
+                    store
+                        .set_local_settings(
+                            worktree.id(),
+                            PathBuf::from(&envelope.payload.path).into(),
+                            envelope.payload.content.as_ref().map(String::as_str),
+                            cx,
+                        )
+                        .log_err();
+                });
+            }
+            Ok(())
+        })
+    }
+
     async fn handle_create_project_entry(
         this: ModelHandle<Self>,
         envelope: TypedEnvelope<proto::CreateProjectEntry>,
@@ -6521,8 +6650,8 @@ impl Project {
         }
 
         self.metadata_changed(cx);
-        for (id, _) in old_worktrees_by_id {
-            cx.emit(Event::WorktreeRemoved(id));
+        for id in old_worktrees_by_id.keys() {
+            cx.emit(Event::WorktreeRemoved(*id));
         }
 
         Ok(())
@@ -6892,6 +7021,13 @@ impl WorktreeHandle {
             WorktreeHandle::Weak(handle) => handle.upgrade(cx),
         }
     }
+
+    pub fn handle_id(&self) -> usize {
+        match self {
+            WorktreeHandle::Strong(handle) => handle.id(),
+            WorktreeHandle::Weak(handle) => handle.id(),
+        }
+    }
 }
 
 impl OpenBuffer {

crates/project/src/project_tests.rs 🔗

@@ -63,6 +63,66 @@ async fn test_symlinks(cx: &mut gpui::TestAppContext) {
     });
 }
 
+#[gpui::test]
+async fn test_managing_project_specific_settings(
+    deterministic: Arc<Deterministic>,
+    cx: &mut gpui::TestAppContext,
+) {
+    init_test(cx);
+
+    let fs = FakeFs::new(cx.background());
+    fs.insert_tree(
+        "/the-root",
+        json!({
+            ".zed": {
+                "settings.json": r#"{ "tab_size": 8 }"#
+            },
+            "a": {
+                "a.rs": "fn a() {\n    A\n}"
+            },
+            "b": {
+                ".zed": {
+                    "settings.json": r#"{ "tab_size": 2 }"#
+                },
+                "b.rs": "fn b() {\n  B\n}"
+            }
+        }),
+    )
+    .await;
+
+    let project = Project::test(fs.clone(), ["/the-root".as_ref()], cx).await;
+    let worktree = project.read_with(cx, |project, cx| project.worktrees(cx).next().unwrap());
+
+    deterministic.run_until_parked();
+    cx.read(|cx| {
+        let tree = worktree.read(cx);
+
+        let settings_a = language_settings(
+            None,
+            Some(
+                &(File::for_entry(
+                    tree.entry_for_path("a/a.rs").unwrap().clone(),
+                    worktree.clone(),
+                ) as _),
+            ),
+            cx,
+        );
+        let settings_b = language_settings(
+            None,
+            Some(
+                &(File::for_entry(
+                    tree.entry_for_path("b/b.rs").unwrap().clone(),
+                    worktree.clone(),
+                ) as _),
+            ),
+            cx,
+        );
+
+        assert_eq!(settings_a.tab_size.get(), 8);
+        assert_eq!(settings_b.tab_size.get(), 2);
+    });
+}
+
 #[gpui::test]
 async fn test_managing_language_servers(
     deterministic: Arc<Deterministic>,

crates/project/src/worktree.rs 🔗

@@ -677,6 +677,11 @@ impl Worktree {
             Worktree::Remote(worktree) => worktree.abs_path.clone(),
         }
     }
+
+    pub fn root_file(&self, cx: &mut ModelContext<Self>) -> Option<Arc<File>> {
+        let entry = self.root_entry()?;
+        Some(File::for_entry(entry.clone(), cx.handle()))
+    }
 }
 
 impl LocalWorktree {
@@ -684,14 +689,6 @@ impl LocalWorktree {
         path.starts_with(&self.abs_path)
     }
 
-    fn absolutize(&self, path: &Path) -> PathBuf {
-        if path.file_name().is_some() {
-            self.abs_path.join(path)
-        } else {
-            self.abs_path.to_path_buf()
-        }
-    }
-
     pub(crate) fn load_buffer(
         &mut self,
         id: u64,
@@ -1544,6 +1541,14 @@ impl Snapshot {
         &self.abs_path
     }
 
+    pub fn absolutize(&self, path: &Path) -> PathBuf {
+        if path.file_name().is_some() {
+            self.abs_path.join(path)
+        } else {
+            self.abs_path.to_path_buf()
+        }
+    }
+
     pub fn contains_entry(&self, entry_id: ProjectEntryId) -> bool {
         self.entries_by_id.get(&entry_id, &()).is_some()
     }
@@ -2383,6 +2388,10 @@ impl language::File for File {
             .unwrap_or_else(|| OsStr::new(&self.worktree.read(cx).root_name))
     }
 
+    fn worktree_id(&self) -> usize {
+        self.worktree.id()
+    }
+
     fn is_deleted(&self) -> bool {
         self.is_deleted
     }
@@ -2447,6 +2456,17 @@ impl language::LocalFile for File {
 }
 
 impl File {
+    pub fn for_entry(entry: Entry, worktree: ModelHandle<Worktree>) -> Arc<Self> {
+        Arc::new(Self {
+            worktree,
+            path: entry.path.clone(),
+            mtime: entry.mtime,
+            entry_id: entry.id,
+            is_local: true,
+            is_deleted: false,
+        })
+    }
+
     pub fn from_proto(
         proto: rpc::proto::File,
         worktree: ModelHandle<Worktree>,
@@ -2507,7 +2527,7 @@ pub enum EntryKind {
     File(CharBag),
 }
 
-#[derive(Clone, Copy, Debug)]
+#[derive(Clone, Copy, Debug, PartialEq)]
 pub enum PathChange {
     /// A filesystem entry was was created.
     Added,

crates/rpc/proto/zed.proto 🔗

@@ -132,6 +132,8 @@ message Envelope {
 
         OnTypeFormatting on_type_formatting = 111;
         OnTypeFormattingResponse on_type_formatting_response = 112;
+
+        UpdateWorktreeSettings update_worktree_settings = 113;
     }
 }
 
@@ -339,6 +341,13 @@ message UpdateWorktree {
     string abs_path = 10;
 }
 
+message UpdateWorktreeSettings {
+    uint64 project_id = 1;
+    uint64 worktree_id = 2;
+    string path = 3;
+    optional string content = 4;
+}
+
 message CreateProjectEntry {
     uint64 project_id = 1;
     uint64 worktree_id = 2;

crates/rpc/src/proto.rs 🔗

@@ -236,6 +236,7 @@ messages!(
     (UpdateProject, Foreground),
     (UpdateProjectCollaborator, Foreground),
     (UpdateWorktree, Foreground),
+    (UpdateWorktreeSettings, Foreground),
     (UpdateDiffBase, Foreground),
     (GetPrivateUserInfo, Foreground),
     (GetPrivateUserInfoResponse, Foreground),
@@ -345,6 +346,7 @@ entity_messages!(
     UpdateProject,
     UpdateProjectCollaborator,
     UpdateWorktree,
+    UpdateWorktreeSettings,
     UpdateDiffBase
 );
 

crates/rpc/src/rpc.rs 🔗

@@ -6,4 +6,4 @@ pub use conn::Connection;
 pub use peer::*;
 mod macros;
 
-pub const PROTOCOL_VERSION: u32 = 56;
+pub const PROTOCOL_VERSION: u32 = 57;

crates/settings/src/settings_file.rs 🔗

@@ -4,7 +4,14 @@ use assets::Assets;
 use fs::Fs;
 use futures::{channel::mpsc, StreamExt};
 use gpui::{executor::Background, AppContext, AssetSource};
-use std::{borrow::Cow, io::ErrorKind, path::PathBuf, str, sync::Arc, time::Duration};
+use std::{
+    borrow::Cow,
+    io::ErrorKind,
+    path::{Path, PathBuf},
+    str,
+    sync::Arc,
+    time::Duration,
+};
 use util::{paths, ResultExt};
 
 pub fn register<T: Setting>(cx: &mut AppContext) {
@@ -17,6 +24,10 @@ pub fn get<'a, T: Setting>(cx: &'a AppContext) -> &'a T {
     cx.global::<SettingsStore>().get(None)
 }
 
+pub fn get_local<'a, T: Setting>(location: Option<(usize, &Path)>, cx: &'a AppContext) -> &'a T {
+    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()),

crates/settings/src/settings_store.rs 🔗

@@ -89,14 +89,14 @@ pub struct SettingsStore {
     setting_values: HashMap<TypeId, Box<dyn AnySettingValue>>,
     default_deserialized_settings: Option<serde_json::Value>,
     user_deserialized_settings: Option<serde_json::Value>,
-    local_deserialized_settings: BTreeMap<Arc<Path>, serde_json::Value>,
+    local_deserialized_settings: BTreeMap<(usize, Arc<Path>), serde_json::Value>,
     tab_size_callback: Option<(TypeId, Box<dyn Fn(&dyn Any) -> Option<usize>>)>,
 }
 
 #[derive(Debug)]
 struct SettingValue<T> {
     global_value: Option<T>,
-    local_values: Vec<(Arc<Path>, T)>,
+    local_values: Vec<(usize, Arc<Path>, T)>,
 }
 
 trait AnySettingValue {
@@ -109,9 +109,9 @@ trait AnySettingValue {
         custom: &[DeserializedSetting],
         cx: &AppContext,
     ) -> Result<Box<dyn Any>>;
-    fn value_for_path(&self, path: Option<&Path>) -> &dyn Any;
+    fn value_for_path(&self, path: Option<(usize, &Path)>) -> &dyn Any;
     fn set_global_value(&mut self, value: Box<dyn Any>);
-    fn set_local_value(&mut self, path: Arc<Path>, value: Box<dyn Any>);
+    fn set_local_value(&mut self, root_id: usize, path: Arc<Path>, value: Box<dyn Any>);
     fn json_schema(
         &self,
         generator: &mut SchemaGenerator,
@@ -165,7 +165,7 @@ impl SettingsStore {
     ///
     /// Panics if the given setting type has not been registered, or if there is no
     /// value for this setting.
-    pub fn get<T: Setting>(&self, path: Option<&Path>) -> &T {
+    pub fn get<T: Setting>(&self, path: Option<(usize, &Path)>) -> &T {
         self.setting_values
             .get(&TypeId::of::<T>())
             .unwrap_or_else(|| panic!("unregistered setting type {}", type_name::<T>()))
@@ -343,20 +343,37 @@ impl SettingsStore {
     /// Add or remove a set of local settings via a JSON string.
     pub fn set_local_settings(
         &mut self,
+        root_id: usize,
         path: Arc<Path>,
         settings_content: Option<&str>,
         cx: &AppContext,
     ) -> Result<()> {
         if let Some(content) = settings_content {
             self.local_deserialized_settings
-                .insert(path.clone(), parse_json_with_comments(content)?);
+                .insert((root_id, path.clone()), parse_json_with_comments(content)?);
         } else {
-            self.local_deserialized_settings.remove(&path);
+            self.local_deserialized_settings
+                .remove(&(root_id, path.clone()));
         }
-        self.recompute_values(Some(&path), cx)?;
+        self.recompute_values(Some((root_id, &path)), cx)?;
+        Ok(())
+    }
+
+    /// Add or remove a set of local settings via a JSON string.
+    pub fn clear_local_settings(&mut self, root_id: usize, cx: &AppContext) -> Result<()> {
+        eprintln!("clearing local settings {root_id}");
+        self.local_deserialized_settings
+            .retain(|k, _| k.0 != root_id);
+        self.recompute_values(Some((root_id, "".as_ref())), cx)?;
         Ok(())
     }
 
+    pub fn local_settings(&self, root_id: usize) -> impl '_ + Iterator<Item = (Arc<Path>, String)> {
+        self.local_deserialized_settings
+            .range((root_id, Path::new("").into())..(root_id + 1, Path::new("").into()))
+            .map(|((_, path), content)| (path.clone(), serde_json::to_string(content).unwrap()))
+    }
+
     pub fn json_schema(
         &self,
         schema_params: &SettingsJsonSchemaParams,
@@ -436,12 +453,12 @@ impl SettingsStore {
 
     fn recompute_values(
         &mut self,
-        changed_local_path: Option<&Path>,
+        changed_local_path: Option<(usize, &Path)>,
         cx: &AppContext,
     ) -> Result<()> {
         // Reload the global and local values for every setting.
         let mut user_settings_stack = Vec::<DeserializedSetting>::new();
-        let mut paths_stack = Vec::<Option<&Path>>::new();
+        let mut paths_stack = Vec::<Option<(usize, &Path)>>::new();
         for setting_value in self.setting_values.values_mut() {
             if let Some(default_settings) = &self.default_deserialized_settings {
                 let default_settings = setting_value.deserialize_setting(default_settings)?;
@@ -469,11 +486,11 @@ impl SettingsStore {
                 }
 
                 // Reload the local values for the setting.
-                for (path, local_settings) in &self.local_deserialized_settings {
+                for ((root_id, path), local_settings) in &self.local_deserialized_settings {
                     // Build a stack of all of the local values for that setting.
-                    while let Some(prev_path) = paths_stack.last() {
-                        if let Some(prev_path) = prev_path {
-                            if !path.starts_with(prev_path) {
+                    while let Some(prev_entry) = paths_stack.last() {
+                        if let Some((prev_root_id, prev_path)) = prev_entry {
+                            if root_id != prev_root_id || !path.starts_with(prev_path) {
                                 paths_stack.pop();
                                 user_settings_stack.pop();
                                 continue;
@@ -485,14 +502,17 @@ impl SettingsStore {
                     if let Some(local_settings) =
                         setting_value.deserialize_setting(&local_settings).log_err()
                     {
-                        paths_stack.push(Some(path.as_ref()));
+                        paths_stack.push(Some((*root_id, path.as_ref())));
                         user_settings_stack.push(local_settings);
 
                         // If a local settings file changed, then avoid recomputing local
                         // settings for any path outside of that directory.
-                        if changed_local_path.map_or(false, |changed_local_path| {
-                            !path.starts_with(changed_local_path)
-                        }) {
+                        if changed_local_path.map_or(
+                            false,
+                            |(changed_root_id, changed_local_path)| {
+                                *root_id != changed_root_id || !path.starts_with(changed_local_path)
+                            },
+                        ) {
                             continue;
                         }
 
@@ -500,7 +520,7 @@ impl SettingsStore {
                             .load_setting(&default_settings, &user_settings_stack, cx)
                             .log_err()
                         {
-                            setting_value.set_local_value(path.clone(), value);
+                            setting_value.set_local_value(*root_id, path.clone(), value);
                         }
                     }
                 }
@@ -510,6 +530,24 @@ impl SettingsStore {
     }
 }
 
+impl Debug for SettingsStore {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.debug_struct("SettingsStore")
+            .field(
+                "types",
+                &self
+                    .setting_values
+                    .values()
+                    .map(|value| value.setting_type_name())
+                    .collect::<Vec<_>>(),
+            )
+            .field("default_settings", &self.default_deserialized_settings)
+            .field("user_settings", &self.user_deserialized_settings)
+            .field("local_settings", &self.local_deserialized_settings)
+            .finish_non_exhaustive()
+    }
+}
+
 impl<T: Setting> AnySettingValue for SettingValue<T> {
     fn key(&self) -> Option<&'static str> {
         T::KEY
@@ -546,10 +584,10 @@ impl<T: Setting> AnySettingValue for SettingValue<T> {
         Ok(DeserializedSetting(Box::new(value)))
     }
 
-    fn value_for_path(&self, path: Option<&Path>) -> &dyn Any {
-        if let Some(path) = path {
-            for (settings_path, value) in self.local_values.iter().rev() {
-                if path.starts_with(&settings_path) {
+    fn value_for_path(&self, path: Option<(usize, &Path)>) -> &dyn Any {
+        if let Some((root_id, path)) = path {
+            for (settings_root_id, settings_path, value) in self.local_values.iter().rev() {
+                if root_id == *settings_root_id && path.starts_with(&settings_path) {
                     return value;
                 }
             }
@@ -563,11 +601,14 @@ impl<T: Setting> AnySettingValue for SettingValue<T> {
         self.global_value = Some(*value.downcast().unwrap());
     }
 
-    fn set_local_value(&mut self, path: Arc<Path>, value: Box<dyn Any>) {
+    fn set_local_value(&mut self, root_id: usize, path: Arc<Path>, value: Box<dyn Any>) {
         let value = *value.downcast().unwrap();
-        match self.local_values.binary_search_by_key(&&path, |e| &e.0) {
-            Ok(ix) => self.local_values[ix].1 = value,
-            Err(ix) => self.local_values.insert(ix, (path, value)),
+        match self
+            .local_values
+            .binary_search_by_key(&(root_id, &path), |e| (e.0, &e.1))
+        {
+            Ok(ix) => self.local_values[ix].2 = value,
+            Err(ix) => self.local_values.insert(ix, (root_id, path, value)),
         }
     }
 
@@ -884,6 +925,7 @@ mod tests {
 
         store
             .set_local_settings(
+                1,
                 Path::new("/root1").into(),
                 Some(r#"{ "user": { "staff": true } }"#),
                 cx,
@@ -891,6 +933,7 @@ mod tests {
             .unwrap();
         store
             .set_local_settings(
+                1,
                 Path::new("/root1/subdir").into(),
                 Some(r#"{ "user": { "name": "Jane Doe" } }"#),
                 cx,
@@ -899,6 +942,7 @@ mod tests {
 
         store
             .set_local_settings(
+                1,
                 Path::new("/root2").into(),
                 Some(r#"{ "user": { "age": 42 }, "key2": "b" }"#),
                 cx,
@@ -906,7 +950,7 @@ mod tests {
             .unwrap();
 
         assert_eq!(
-            store.get::<UserSettings>(Some(Path::new("/root1/something"))),
+            store.get::<UserSettings>(Some((1, Path::new("/root1/something")))),
             &UserSettings {
                 name: "John Doe".to_string(),
                 age: 31,
@@ -914,7 +958,7 @@ mod tests {
             }
         );
         assert_eq!(
-            store.get::<UserSettings>(Some(Path::new("/root1/subdir/something"))),
+            store.get::<UserSettings>(Some((1, Path::new("/root1/subdir/something")))),
             &UserSettings {
                 name: "Jane Doe".to_string(),
                 age: 31,
@@ -922,7 +966,7 @@ mod tests {
             }
         );
         assert_eq!(
-            store.get::<UserSettings>(Some(Path::new("/root2/something"))),
+            store.get::<UserSettings>(Some((1, Path::new("/root2/something")))),
             &UserSettings {
                 name: "John Doe".to_string(),
                 age: 42,
@@ -930,7 +974,7 @@ mod tests {
             }
         );
         assert_eq!(
-            store.get::<MultiKeySettings>(Some(Path::new("/root2/something"))),
+            store.get::<MultiKeySettings>(Some((1, Path::new("/root2/something")))),
             &MultiKeySettings {
                 key1: "a".to_string(),
                 key2: "b".to_string(),

crates/terminal_view/src/terminal_view.rs 🔗

@@ -905,7 +905,10 @@ mod tests {
         cx: &mut TestAppContext,
     ) -> (ModelHandle<Project>, ViewHandle<Workspace>) {
         let params = cx.update(AppState::test);
-        cx.update(|cx| theme::init((), cx));
+        cx.update(|cx| {
+            theme::init((), cx);
+            language::init(cx);
+        });
 
         let project = Project::test(params.fs.clone(), [], cx).await;
         let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));

crates/util/src/paths.rs 🔗

@@ -15,6 +15,7 @@ lazy_static::lazy_static! {
     pub static ref LAST_USERNAME: PathBuf = CONFIG_DIR.join("last-username.txt");
     pub static ref LOG: PathBuf = LOGS_DIR.join("Zed.log");
     pub static ref OLD_LOG: PathBuf = LOGS_DIR.join("Zed.log.old");
+    pub static ref LOCAL_SETTINGS_RELATIVE_PATH: &'static Path = Path::new(".zed/settings.json");
 }
 
 pub mod legacy {

crates/zed/src/languages/json.rs 🔗

@@ -135,7 +135,10 @@ impl LspAdapter for JsonLspAdapter {
                     },
                     "schemas": [
                         {
-                            "fileMatch": [schema_file_match(&paths::SETTINGS)],
+                            "fileMatch": [
+                                schema_file_match(&paths::SETTINGS),
+                                &*paths::LOCAL_SETTINGS_RELATIVE_PATH,
+                            ],
                             "schema": settings_schema,
                         },
                         {

crates/zed/src/languages/yaml.rs 🔗

@@ -3,7 +3,7 @@ use async_trait::async_trait;
 use futures::{future::BoxFuture, FutureExt, StreamExt};
 use gpui::AppContext;
 use language::{
-    language_settings::language_settings, LanguageServerBinary, LanguageServerName, LspAdapter,
+    language_settings::all_language_settings, LanguageServerBinary, LanguageServerName, LspAdapter,
 };
 use node_runtime::NodeRuntime;
 use serde_json::Value;
@@ -101,13 +101,16 @@ impl LspAdapter for YamlLspAdapter {
     }
 
     fn workspace_configuration(&self, cx: &mut AppContext) -> Option<BoxFuture<'static, Value>> {
+        let tab_size = all_language_settings(None, cx)
+            .language(Some("YAML"))
+            .tab_size;
         Some(
             future::ready(serde_json::json!({
                 "yaml": {
                     "keyOrdering": false
                 },
                 "[yaml]": {
-                    "editor.tabSize": language_settings(Some("YAML"), cx).tab_size,
+                    "editor.tabSize": tab_size,
                 }
             }))
             .boxed(),