Replicate project-specific settings when collaborating

Max Brunsfeld created

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/project/src/project.rs                                           | 105 
crates/rpc/proto/zed.proto                                              |   9 
crates/rpc/src/proto.rs                                                 |   2 
crates/settings/src/settings_store.rs                                   |  15 
10 files changed, 432 insertions(+), 18 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/project/src/project.rs 🔗

@@ -462,6 +462,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);
@@ -1105,6 +1106,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 {
@@ -1217,6 +1233,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)?;
@@ -4888,8 +4912,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();
 
@@ -5174,14 +5202,16 @@ impl Project {
         .detach();
     }
 
-    pub fn update_local_worktree_settings(
+    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() {
@@ -5195,10 +5225,7 @@ impl Project {
                 let removed = *change == PathChange::Removed;
                 let abs_path = worktree.absolutize(path);
                 settings_contents.push(async move {
-                    anyhow::Ok((
-                        settings_dir,
-                        (!removed).then_some(fs.load(&abs_path).await?),
-                    ))
+                    (settings_dir, (!removed).then_some(fs.load(&abs_path).await))
                 });
             }
         }
@@ -5207,19 +5234,30 @@ impl Project {
             return;
         }
 
+        let client = self.client.clone();
         cx.spawn_weak(move |_, mut cx| async move {
-            let settings_contents = futures::future::join_all(settings_contents).await;
+            let settings_contents: Vec<(Arc<Path>, _)> =
+                futures::future::join_all(settings_contents).await;
             cx.update(|cx| {
                 cx.update_global::<SettingsStore, _, _>(|store, cx| {
-                    for entry in settings_contents {
-                        if let Some((directory, file_content)) = entry.log_err() {
-                            store
-                                .set_local_settings(
-                                    worktree_id,
-                                    directory,
-                                    file_content.as_ref().map(String::as_str),
-                                    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();
                         }
                     }
@@ -5467,6 +5505,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>,
@@ -6557,8 +6619,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(())
@@ -6928,6 +6990,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/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/settings/src/settings_store.rs 🔗

@@ -359,6 +359,21 @@ impl SettingsStore {
         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,