Sync config with ssh remotes (#17349)

Conrad Irwin and Mikayla created

Release Notes:

- N/A

---------

Co-authored-by: Mikayla <mikayla@zed.dev>

Change summary

crates/collab/src/tests/remote_editing_collaboration_tests.rs |  18 
crates/project/src/environment.rs                             |  46 
crates/project/src/lsp_store.rs                               |  46 
crates/project/src/project.rs                                 | 240 +--
crates/project/src/project_settings.rs                        | 293 ++++
crates/proto/proto/zed.proto                                  |   8 
crates/proto/src/proto.rs                                     |   6 
crates/remote_server/Cargo.toml                               |   1 
crates/remote_server/src/headless_project.rs                  |  45 
crates/remote_server/src/main.rs                              |   1 
crates/remote_server/src/remote_editing_tests.rs              |  88 +
crates/settings/src/settings_store.rs                         |   6 
crates/worktree/src/worktree.rs                               |   4 
13 files changed, 609 insertions(+), 193 deletions(-)

Detailed changes

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

@@ -2,6 +2,7 @@ use crate::tests::TestServer;
 use call::ActiveCall;
 use fs::{FakeFs, Fs as _};
 use gpui::{Context as _, TestAppContext};
+use language::language_settings::all_language_settings;
 use remote::SshSession;
 use remote_server::HeadlessProject;
 use serde_json::json;
@@ -29,6 +30,9 @@ async fn test_sharing_an_ssh_remote_project(
             "/code",
             json!({
                 "project1": {
+                    ".zed": {
+                        "settings.json": r#"{"languages":{"Rust":{"language_servers":["override-rust-analyzer"]}}}"#
+                    },
                     "README.md": "# project 1",
                     "src": {
                         "lib.rs": "fn one() -> usize { 1 }"
@@ -68,6 +72,8 @@ async fn test_sharing_an_ssh_remote_project(
         assert_eq!(
             worktree.paths().map(Arc::as_ref).collect::<Vec<_>>(),
             vec![
+                Path::new(".zed"),
+                Path::new(".zed/settings.json"),
                 Path::new("README.md"),
                 Path::new("src"),
                 Path::new("src/lib.rs"),
@@ -88,6 +94,18 @@ async fn test_sharing_an_ssh_remote_project(
         buffer.edit([(ix..ix + 1, "100")], None, cx);
     });
 
+    executor.run_until_parked();
+
+    cx_b.read(|cx| {
+        let file = buffer_b.read(cx).file();
+        assert_eq!(
+            all_language_settings(file, cx)
+                .language(Some("Rust"))
+                .language_servers,
+            ["override-rust-analyzer".into()]
+        )
+    });
+
     project_b
         .update(cx_b, |project, cx| project.save_buffer(buffer_b, cx))
         .await

crates/project/src/environment.rs 🔗

@@ -11,39 +11,49 @@ use gpui::{AppContext, Context, Model, ModelContext, Task};
 use settings::Settings as _;
 use worktree::WorktreeId;
 
-use crate::project_settings::{DirenvSettings, ProjectSettings};
+use crate::{
+    project_settings::{DirenvSettings, ProjectSettings},
+    worktree_store::{WorktreeStore, WorktreeStoreEvent},
+};
 
-pub(crate) struct ProjectEnvironment {
+pub struct ProjectEnvironment {
     cli_environment: Option<HashMap<String, String>>,
     get_environment_task: Option<Shared<Task<Option<HashMap<String, String>>>>>,
     cached_shell_environments: HashMap<WorktreeId, HashMap<String, String>>,
 }
 
 impl ProjectEnvironment {
-    pub(crate) fn new(
+    pub fn new(
+        worktree_store: &Model<WorktreeStore>,
         cli_environment: Option<HashMap<String, String>>,
         cx: &mut AppContext,
     ) -> Model<Self> {
-        cx.new_model(|_| Self {
-            cli_environment,
-            get_environment_task: None,
-            cached_shell_environments: Default::default(),
+        cx.new_model(|cx| {
+            cx.subscribe(worktree_store, |this: &mut Self, _, event, _| match event {
+                WorktreeStoreEvent::WorktreeRemoved(_, id) => {
+                    this.remove_worktree_environment(*id);
+                }
+                _ => {}
+            })
+            .detach();
+
+            Self {
+                cli_environment,
+                get_environment_task: None,
+                cached_shell_environments: Default::default(),
+            }
         })
     }
 
     #[cfg(any(test, feature = "test-support"))]
-    pub(crate) fn test(
+    pub(crate) fn set_cached(
+        &mut self,
         shell_environments: &[(WorktreeId, HashMap<String, String>)],
-        cx: &mut AppContext,
-    ) -> Model<Self> {
-        cx.new_model(|_| Self {
-            cli_environment: None,
-            get_environment_task: None,
-            cached_shell_environments: shell_environments
-                .iter()
-                .cloned()
-                .collect::<HashMap<_, _>>(),
-        })
+    ) {
+        self.cached_shell_environments = shell_environments
+            .iter()
+            .cloned()
+            .collect::<HashMap<_, _>>();
     }
 
     pub(crate) fn remove_worktree_environment(&mut self, worktree_id: WorktreeId) {

crates/project/src/lsp_store.rs 🔗

@@ -5,7 +5,7 @@ use crate::{
     lsp_ext_command,
     project_settings::ProjectSettings,
     relativize_path, resolve_path,
-    worktree_store::WorktreeStore,
+    worktree_store::{WorktreeStore, WorktreeStoreEvent},
     yarn::YarnPathStore,
     CodeAction, Completion, CoreCompletion, Hover, InlayHint, Item as _, ProjectPath,
     ProjectTransaction, ResolveState, Symbol,
@@ -89,7 +89,7 @@ pub struct LspStore {
     downstream_client: Option<AnyProtoClient>,
     upstream_client: Option<AnyProtoClient>,
     project_id: u64,
-    http_client: Arc<dyn HttpClient>,
+    http_client: Option<Arc<dyn HttpClient>>,
     fs: Arc<dyn Fs>,
     nonce: u128,
     buffer_store: Model<BufferStore>,
@@ -210,12 +210,12 @@ impl LspStore {
     }
 
     #[allow(clippy::too_many_arguments)]
-    pub(crate) fn new(
+    pub fn new(
         buffer_store: Model<BufferStore>,
         worktree_store: Model<WorktreeStore>,
         environment: Option<Model<ProjectEnvironment>>,
         languages: Arc<LanguageRegistry>,
-        http_client: Arc<dyn HttpClient>,
+        http_client: Option<Arc<dyn HttpClient>>,
         fs: Arc<dyn Fs>,
         downstream_client: Option<AnyProtoClient>,
         upstream_client: Option<AnyProtoClient>,
@@ -225,6 +225,8 @@ impl LspStore {
         let yarn = YarnPathStore::new(fs.clone(), cx);
         cx.subscribe(&buffer_store, Self::on_buffer_store_event)
             .detach();
+        cx.subscribe(&worktree_store, Self::on_worktree_store_event)
+            .detach();
 
         Self {
             downstream_client,
@@ -278,6 +280,31 @@ impl LspStore {
         }
     }
 
+    fn on_worktree_store_event(
+        &mut self,
+        _: Model<WorktreeStore>,
+        event: &WorktreeStoreEvent,
+        cx: &mut ModelContext<Self>,
+    ) {
+        match event {
+            WorktreeStoreEvent::WorktreeAdded(worktree) => {
+                if !worktree.read(cx).is_local() {
+                    return;
+                }
+                cx.subscribe(worktree, |this, worktree, event, cx| match event {
+                    worktree::Event::UpdatedEntries(changes) => {
+                        this.update_local_worktree_language_servers(&worktree, changes, cx);
+                    }
+                    worktree::Event::UpdatedGitRepositories(_)
+                    | worktree::Event::DeletedEntry(_) => {}
+                })
+                .detach()
+            }
+            WorktreeStoreEvent::WorktreeRemoved(_, id) => self.remove_worktree(*id, cx),
+            WorktreeStoreEvent::WorktreeOrderChanged => {}
+        }
+    }
+
     fn on_buffer_event(
         &mut self,
         buffer: Model<Buffer>,
@@ -463,11 +490,6 @@ impl LspStore {
         self.buffer_store.clone()
     }
 
-    #[cfg(any(test, feature = "test-support"))]
-    pub(crate) fn set_environment(&mut self, environment: Model<ProjectEnvironment>) {
-        self.environment = Some(environment);
-    }
-
     pub fn set_active_entry(&mut self, active_entry: Option<ProjectEntryId>) {
         self.active_entry = active_entry;
     }
@@ -6105,11 +6127,15 @@ impl ProjectLspAdapterDelegate {
             Task::ready(None).shared()
         };
 
+        let Some(http_client) = lsp_store.http_client.clone() else {
+            panic!("ProjectLspAdapterDelegate cannot be constructedd on an ssh-remote yet")
+        };
+
         Arc::new(Self {
             lsp_store: cx.weak_model(),
             worktree: worktree.read(cx).snapshot(),
             fs: lsp_store.fs.clone(),
-            http_client: lsp_store.http_client.clone(),
+            http_client,
             language_registry: lsp_store.languages.clone(),
             load_shell_env_task,
         })

crates/project/src/project.rs 🔗

@@ -27,7 +27,7 @@ use client::{
 use clock::ReplicaId;
 use collections::{BTreeSet, HashMap, HashSet};
 use debounced_delay::DebouncedDelay;
-use environment::ProjectEnvironment;
+pub use environment::ProjectEnvironment;
 use futures::{
     channel::mpsc::{self, UnboundedReceiver},
     future::try_join_all,
@@ -58,12 +58,9 @@ use lsp::{CompletionContext, DocumentHighlightKind, LanguageServer, LanguageServ
 use lsp_command::*;
 use node_runtime::NodeRuntime;
 use parking_lot::{Mutex, RwLock};
-use paths::{
-    local_settings_file_relative_path, local_tasks_file_relative_path,
-    local_vscode_tasks_file_relative_path,
-};
+use paths::{local_tasks_file_relative_path, local_vscode_tasks_file_relative_path};
 use prettier_support::{DefaultPrettier, PrettierInstance};
-use project_settings::{LspSettings, ProjectSettings};
+use project_settings::{LspSettings, ProjectSettings, SettingsObserver};
 use remote::SshSession;
 use rpc::{
     proto::{AnyProtoClient, SSH_PROJECT_ID},
@@ -174,6 +171,7 @@ pub struct Project {
     last_formatting_failure: Option<String>,
     buffers_being_formatted: HashSet<BufferId>,
     environment: Model<ProjectEnvironment>,
+    settings_observer: Model<SettingsObserver>,
 }
 
 #[derive(Default)]
@@ -505,6 +503,14 @@ impl FormatTrigger {
     }
 }
 
+enum EntitySubscription {
+    Project(PendingEntitySubscription<Project>),
+    BufferStore(PendingEntitySubscription<BufferStore>),
+    WorktreeStore(PendingEntitySubscription<WorktreeStore>),
+    LspStore(PendingEntitySubscription<LspStore>),
+    SettingsObserver(PendingEntitySubscription<SettingsObserver>),
+}
+
 #[derive(Clone)]
 pub enum DirectoryLister {
     Project(Model<Project>),
@@ -584,7 +590,6 @@ impl Project {
         client.add_model_message_handler(Self::handle_unshare_project);
         client.add_model_request_handler(Self::handle_update_buffer);
         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_reload_buffers);
         client.add_model_request_handler(Self::handle_synchronize_buffers);
         client.add_model_request_handler(Self::handle_format_buffers);
@@ -600,6 +605,7 @@ impl Project {
         WorktreeStore::init(&client);
         BufferStore::init(&client);
         LspStore::init(&client);
+        SettingsObserver::init(&client);
     }
 
     pub fn local(
@@ -629,14 +635,18 @@ impl Project {
             cx.subscribe(&buffer_store, Self::on_buffer_store_event)
                 .detach();
 
-            let environment = ProjectEnvironment::new(env, cx);
+            let settings_observer = cx.new_model(|cx| {
+                SettingsObserver::new_local(fs.clone(), worktree_store.clone(), cx)
+            });
+
+            let environment = ProjectEnvironment::new(&worktree_store, env, cx);
             let lsp_store = cx.new_model(|cx| {
                 LspStore::new(
                     buffer_store.clone(),
                     worktree_store.clone(),
                     Some(environment.clone()),
                     languages.clone(),
-                    client.http_client(),
+                    Some(client.http_client()),
                     fs.clone(),
                     None,
                     None,
@@ -665,6 +675,7 @@ impl Project {
                 languages,
                 client,
                 user_store,
+                settings_observer,
                 fs,
                 ssh_session: None,
                 buffers_needing_diff: Default::default(),
@@ -704,14 +715,21 @@ impl Project {
             this.worktree_store.update(cx, |store, _cx| {
                 store.set_upstream_client(client.clone());
             });
+            this.settings_observer = cx.new_model(|cx| {
+                SettingsObserver::new_ssh(ssh.clone().into(), this.worktree_store.clone(), cx)
+            });
 
             ssh.subscribe_to_entity(SSH_PROJECT_ID, &cx.handle());
             ssh.subscribe_to_entity(SSH_PROJECT_ID, &this.buffer_store);
             ssh.subscribe_to_entity(SSH_PROJECT_ID, &this.worktree_store);
+            ssh.subscribe_to_entity(SSH_PROJECT_ID, &this.lsp_store);
+            ssh.subscribe_to_entity(SSH_PROJECT_ID, &this.settings_observer);
             client.add_model_message_handler(Self::handle_update_worktree);
             client.add_model_message_handler(Self::handle_create_buffer_for_peer);
             client.add_model_message_handler(BufferStore::handle_update_buffer_file);
             client.add_model_message_handler(BufferStore::handle_update_diff_base);
+            LspStore::init(&client);
+            SettingsObserver::init(&client);
 
             this.ssh_session = Some(ssh);
         });
@@ -746,12 +764,17 @@ impl Project {
     ) -> Result<Model<Self>> {
         client.authenticate_and_connect(true, &cx).await?;
 
-        let subscriptions = (
-            client.subscribe_to_entity::<Self>(remote_id)?,
-            client.subscribe_to_entity::<BufferStore>(remote_id)?,
-            client.subscribe_to_entity::<WorktreeStore>(remote_id)?,
-            client.subscribe_to_entity::<LspStore>(remote_id)?,
-        );
+        let subscriptions = [
+            EntitySubscription::Project(client.subscribe_to_entity::<Self>(remote_id)?),
+            EntitySubscription::BufferStore(client.subscribe_to_entity::<BufferStore>(remote_id)?),
+            EntitySubscription::WorktreeStore(
+                client.subscribe_to_entity::<WorktreeStore>(remote_id)?,
+            ),
+            EntitySubscription::LspStore(client.subscribe_to_entity::<LspStore>(remote_id)?),
+            EntitySubscription::SettingsObserver(
+                client.subscribe_to_entity::<SettingsObserver>(remote_id)?,
+            ),
+        ];
         let response = client
             .request_envelope(proto::JoinProject {
                 project_id: remote_id,
@@ -771,12 +794,7 @@ impl Project {
 
     async fn from_join_project_response(
         response: TypedEnvelope<proto::JoinProjectResponse>,
-        subscription: (
-            PendingEntitySubscription<Project>,
-            PendingEntitySubscription<BufferStore>,
-            PendingEntitySubscription<WorktreeStore>,
-            PendingEntitySubscription<LspStore>,
-        ),
+        subscriptions: [EntitySubscription; 5],
         client: Arc<Client>,
         user_store: Model<UserStore>,
         languages: Arc<LanguageRegistry>,
@@ -803,7 +821,7 @@ impl Project {
                 worktree_store.clone(),
                 None,
                 languages.clone(),
-                client.http_client(),
+                Some(client.http_client()),
                 fs.clone(),
                 None,
                 Some(client.clone().into()),
@@ -814,6 +832,9 @@ impl Project {
             lsp_store
         })?;
 
+        let settings_observer =
+            cx.new_model(|cx| SettingsObserver::new_remote(worktree_store.clone(), cx))?;
+
         let this = cx.new_model(|cx| {
             let replica_id = response.payload.replica_id as ReplicaId;
             let tasks = Inventory::new(cx);
@@ -850,6 +871,7 @@ impl Project {
                 snippets,
                 fs,
                 ssh_session: None,
+                settings_observer: settings_observer.clone(),
                 client_subscriptions: Default::default(),
                 _subscriptions: vec![cx.on_release(Self::release)],
                 client: client.clone(),
@@ -876,7 +898,7 @@ impl Project {
                     .dev_server_project_id
                     .map(|dev_server_project_id| DevServerProjectId(dev_server_project_id)),
                 search_history: Self::new_search_history(),
-                environment: ProjectEnvironment::new(None, cx),
+                environment: ProjectEnvironment::new(&worktree_store, None, cx),
                 remotely_created_buffers: Arc::new(Mutex::new(RemotelyCreatedBuffers::default())),
                 last_formatting_failure: None,
                 buffers_being_formatted: Default::default(),
@@ -888,12 +910,24 @@ impl Project {
             this
         })?;
 
-        let subscriptions = [
-            subscription.0.set_model(&this, &mut cx),
-            subscription.1.set_model(&buffer_store, &mut cx),
-            subscription.2.set_model(&worktree_store, &mut cx),
-            subscription.3.set_model(&lsp_store, &mut cx),
-        ];
+        let subscriptions = subscriptions
+            .into_iter()
+            .map(|s| match s {
+                EntitySubscription::BufferStore(subscription) => {
+                    subscription.set_model(&buffer_store, &mut cx)
+                }
+                EntitySubscription::WorktreeStore(subscription) => {
+                    subscription.set_model(&worktree_store, &mut cx)
+                }
+                EntitySubscription::SettingsObserver(subscription) => {
+                    subscription.set_model(&settings_observer, &mut cx)
+                }
+                EntitySubscription::Project(subscription) => subscription.set_model(&this, &mut cx),
+                EntitySubscription::LspStore(subscription) => {
+                    subscription.set_model(&lsp_store, &mut cx)
+                }
+            })
+            .collect::<Vec<_>>();
 
         let user_ids = response
             .payload
@@ -924,12 +958,19 @@ impl Project {
     ) -> Result<Model<Self>> {
         client.authenticate_and_connect(true, &cx).await?;
 
-        let subscriptions = (
-            client.subscribe_to_entity::<Self>(remote_id.0)?,
-            client.subscribe_to_entity::<BufferStore>(remote_id.0)?,
-            client.subscribe_to_entity::<WorktreeStore>(remote_id.0)?,
-            client.subscribe_to_entity::<LspStore>(remote_id.0)?,
-        );
+        let subscriptions = [
+            EntitySubscription::Project(client.subscribe_to_entity::<Self>(remote_id.0)?),
+            EntitySubscription::BufferStore(
+                client.subscribe_to_entity::<BufferStore>(remote_id.0)?,
+            ),
+            EntitySubscription::WorktreeStore(
+                client.subscribe_to_entity::<WorktreeStore>(remote_id.0)?,
+            ),
+            EntitySubscription::LspStore(client.subscribe_to_entity::<LspStore>(remote_id.0)?),
+            EntitySubscription::SettingsObserver(
+                client.subscribe_to_entity::<SettingsObserver>(remote_id.0)?,
+            ),
+        ];
         let response = client
             .request_envelope(proto::JoinHostedProject {
                 project_id: remote_id.0,
@@ -1047,13 +1088,10 @@ impl Project {
                 .unwrap();
 
             project.update(cx, |project, cx| {
-                // In tests we always populate the environment to be empty so we don't run the shell
                 let tree_id = tree.read(cx).id();
-                let environment = ProjectEnvironment::test(&[(tree_id, HashMap::default())], cx);
-                project.environment = environment.clone();
-                project
-                    .lsp_store
-                    .update(cx, |lsp_store, _| lsp_store.set_environment(environment));
+                project.environment.update(cx, |environment, _| {
+                    environment.set_cached(&[(tree_id, HashMap::default())])
+                });
             });
 
             tree.update(cx, |tree, _| tree.as_local().unwrap().scan_complete())
@@ -1066,6 +1104,10 @@ impl Project {
         self.lsp_store.clone()
     }
 
+    pub fn worktree_store(&self) -> Model<WorktreeStore> {
+        self.worktree_store.clone()
+    }
+
     fn on_settings_changed(&mut self, cx: &mut ModelContext<Self>) {
         let mut language_servers_to_start = Vec::new();
         let mut language_formatters_to_check = Vec::new();
@@ -1499,6 +1541,9 @@ impl Project {
             self.client
                 .subscribe_to_entity(project_id)?
                 .set_model(&self.lsp_store, &mut cx.to_async()),
+            self.client
+                .subscribe_to_entity(project_id)?
+                .set_model(&self.settings_observer, &mut cx.to_async()),
         ]);
 
         self.buffer_store.update(cx, |buffer_store, cx| {
@@ -1510,21 +1555,9 @@ impl Project {
         self.lsp_store.update(cx, |lsp_store, cx| {
             lsp_store.shared(project_id, self.client.clone().into(), cx)
         });
-
-        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.entity_id().as_u64() as usize) {
-                self.client
-                    .send(proto::UpdateWorktreeSettings {
-                        project_id,
-                        worktree_id,
-                        path: path.to_string_lossy().into(),
-                        content: Some(content),
-                    })
-                    .log_err();
-            }
-        }
+        self.settings_observer.update(cx, |settings_observer, cx| {
+            settings_observer.shared(project_id, self.client.clone().into(), cx)
+        });
 
         self.client_state = ProjectClientState::Shared {
             remote_id: project_id,
@@ -1608,6 +1641,9 @@ impl Project {
                 buffer_store.forget_shared_buffers();
                 buffer_store.unshared(cx)
             });
+            self.settings_observer.update(cx, |settings_observer, cx| {
+                settings_observer.unshared(cx);
+            });
             self.client
                 .send(proto::UnshareProject {
                     project_id: remote_id,
@@ -2147,10 +2183,6 @@ impl Project {
             match event {
                 worktree::Event::UpdatedEntries(changes) => {
                     if is_local {
-                        this.lsp_store.update(cx, |lsp_store, cx| {
-                            lsp_store
-                                .update_local_worktree_language_servers(&worktree, changes, cx);
-                        });
                         this.update_local_worktree_settings(&worktree, changes, cx);
                         this.update_prettier_settings(&worktree, changes, cx);
                     }
@@ -2198,12 +2230,6 @@ impl Project {
             }
             return;
         }
-        self.environment.update(cx, |environment, _| {
-            environment.remove_worktree_environment(id_to_remove);
-        });
-        self.lsp_store.update(cx, |lsp_store, cx| {
-            lsp_store.remove_worktree(id_to_remove, cx);
-        });
 
         let mut prettier_instances_to_clean = FuturesUnordered::new();
         if let Some(prettier_paths) = self.prettiers_per_worktree.remove(&id_to_remove) {
@@ -3818,11 +3844,8 @@ impl Project {
         if worktree.read(cx).is_remote() {
             return;
         }
-        let project_id = self.remote_id();
-        let worktree_id = worktree.entity_id();
         let remote_worktree_id = worktree.read(cx).id();
 
-        let mut settings_contents = Vec::new();
         for (path, _, change) in changes.iter() {
             let removed = change == &PathChange::Removed;
             let abs_path = match worktree.read(cx).absolutize(path) {
@@ -3833,24 +3856,7 @@ impl Project {
                 }
             };
 
-            if path.ends_with(local_settings_file_relative_path()) {
-                let settings_dir = Arc::from(
-                    path.ancestors()
-                        .nth(local_settings_file_relative_path().components().count())
-                        .unwrap(),
-                );
-                let fs = self.fs.clone();
-                settings_contents.push(async move {
-                    (
-                        settings_dir,
-                        if removed {
-                            None
-                        } else {
-                            Some(async move { fs.load(&abs_path).await }.await)
-                        },
-                    )
-                });
-            } else if path.ends_with(local_tasks_file_relative_path()) {
+            if path.ends_with(local_tasks_file_relative_path()) {
                 self.task_inventory().update(cx, |task_inventory, cx| {
                     if removed {
                         task_inventory.remove_local_static_source(&abs_path);
@@ -3898,43 +3904,6 @@ impl Project {
                 })
             }
         }
-
-        if settings_contents.is_empty() {
-            return;
-        }
-
-        let client = self.client.clone();
-        cx.spawn(move |_, 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.as_u64() as usize,
-                                directory.clone(),
-                                file_content.as_deref(),
-                                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();
-                        }
-                    }
-                });
-            })
-            .ok();
-        })
-        .detach();
     }
 
     pub fn set_active_path(&mut self, entry: Option<ProjectPath>, cx: &mut ModelContext<Self>) {
@@ -4236,29 +4205,6 @@ impl Project {
         })?
     }
 
-    async fn handle_update_worktree_settings(
-        this: Model<Self>,
-        envelope: TypedEnvelope<proto::UpdateWorktreeSettings>,
-        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.entity_id().as_u64() as usize,
-                            PathBuf::from(&envelope.payload.path).into(),
-                            envelope.payload.content.as_deref(),
-                            cx,
-                        )
-                        .log_err();
-                });
-            }
-            Ok(())
-        })?
-    }
-
     async fn handle_update_buffer(
         this: Model<Self>,
         envelope: TypedEnvelope<proto::UpdateBuffer>,

crates/project/src/project_settings.rs 🔗

@@ -1,9 +1,23 @@
 use collections::HashMap;
-use gpui::AppContext;
+use fs::Fs;
+use gpui::{AppContext, AsyncAppContext, BorrowAppContext, Model, ModelContext};
+use paths::local_settings_file_relative_path;
+use rpc::{
+    proto::{self, AnyProtoClient},
+    TypedEnvelope,
+};
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
-use settings::{Settings, SettingsSources};
-use std::{sync::Arc, time::Duration};
+use settings::{Settings, SettingsSources, SettingsStore};
+use std::{
+    path::{Path, PathBuf},
+    sync::Arc,
+    time::Duration,
+};
+use util::ResultExt;
+use worktree::{PathChange, UpdatedEntriesSet, Worktree, WorktreeId};
+
+use crate::worktree_store::{WorktreeStore, WorktreeStoreEvent};
 
 #[derive(Clone, Default, Serialize, Deserialize, JsonSchema)]
 pub struct ProjectSettings {
@@ -157,3 +171,276 @@ impl Settings for ProjectSettings {
         sources.json_merge()
     }
 }
+
+pub enum SettingsObserverMode {
+    Local(Arc<dyn Fs>),
+    Ssh(AnyProtoClient),
+    Remote,
+}
+
+pub struct SettingsObserver {
+    mode: SettingsObserverMode,
+    downstream_client: Option<AnyProtoClient>,
+    worktree_store: Model<WorktreeStore>,
+    project_id: u64,
+}
+
+/// SettingsObserver observers changes to .zed/settings.json files in local worktrees
+/// (or the equivalent protobuf messages from upstream) and updates local settings
+/// and sends notifications downstream.
+/// In ssh mode it also monitors ~/.config/zed/settings.json and sends the content
+/// upstream.
+impl SettingsObserver {
+    pub fn init(client: &AnyProtoClient) {
+        client.add_model_message_handler(Self::handle_update_worktree_settings);
+        client.add_model_message_handler(Self::handle_update_user_settings)
+    }
+
+    pub fn new_local(
+        fs: Arc<dyn Fs>,
+        worktree_store: Model<WorktreeStore>,
+        cx: &mut ModelContext<Self>,
+    ) -> Self {
+        cx.subscribe(&worktree_store, Self::on_worktree_store_event)
+            .detach();
+
+        Self {
+            worktree_store,
+            mode: SettingsObserverMode::Local(fs),
+            downstream_client: None,
+            project_id: 0,
+        }
+    }
+
+    pub fn new_ssh(
+        client: AnyProtoClient,
+        worktree_store: Model<WorktreeStore>,
+        cx: &mut ModelContext<Self>,
+    ) -> Self {
+        let this = Self {
+            worktree_store,
+            mode: SettingsObserverMode::Ssh(client.clone()),
+            downstream_client: None,
+            project_id: 0,
+        };
+        this.maintain_ssh_settings(client, cx);
+        this
+    }
+
+    pub fn new_remote(worktree_store: Model<WorktreeStore>, _: &mut ModelContext<Self>) -> Self {
+        Self {
+            worktree_store,
+            mode: SettingsObserverMode::Remote,
+            downstream_client: None,
+            project_id: 0,
+        }
+    }
+
+    pub fn shared(
+        &mut self,
+        project_id: u64,
+        downstream_client: AnyProtoClient,
+        cx: &mut ModelContext<Self>,
+    ) {
+        self.project_id = project_id;
+        self.downstream_client = Some(downstream_client.clone());
+
+        let store = cx.global::<SettingsStore>();
+        for worktree in self.worktree_store.read(cx).worktrees() {
+            let worktree_id = worktree.read(cx).id().to_proto();
+            for (path, content) in store.local_settings(worktree.entity_id().as_u64() as usize) {
+                downstream_client
+                    .send(proto::UpdateWorktreeSettings {
+                        project_id,
+                        worktree_id,
+                        path: path.to_string_lossy().into(),
+                        content: Some(content),
+                    })
+                    .log_err();
+            }
+        }
+    }
+
+    pub fn unshared(&mut self, _: &mut ModelContext<Self>) {
+        self.downstream_client = None;
+    }
+
+    async fn handle_update_worktree_settings(
+        this: Model<Self>,
+        envelope: TypedEnvelope<proto::UpdateWorktreeSettings>,
+        mut cx: AsyncAppContext,
+    ) -> anyhow::Result<()> {
+        this.update(&mut cx, |this, cx| {
+            let worktree_id = WorktreeId::from_proto(envelope.payload.worktree_id);
+            let Some(worktree) = this
+                .worktree_store
+                .read(cx)
+                .worktree_for_id(worktree_id, cx)
+            else {
+                return;
+            };
+            this.update_settings(
+                worktree,
+                [(
+                    PathBuf::from(&envelope.payload.path).into(),
+                    envelope.payload.content,
+                )],
+                cx,
+            );
+        })?;
+        Ok(())
+    }
+
+    pub async fn handle_update_user_settings(
+        _: Model<Self>,
+        envelope: TypedEnvelope<proto::UpdateUserSettings>,
+        mut cx: AsyncAppContext,
+    ) -> anyhow::Result<()> {
+        cx.update_global(move |settings_store: &mut SettingsStore, cx| {
+            settings_store.set_user_settings(&envelope.payload.content, cx)
+        })??;
+
+        Ok(())
+    }
+
+    pub fn maintain_ssh_settings(&self, ssh: AnyProtoClient, cx: &mut ModelContext<Self>) {
+        let mut settings = cx.global::<SettingsStore>().raw_user_settings().clone();
+        if let Some(content) = serde_json::to_string(&settings).log_err() {
+            ssh.send(proto::UpdateUserSettings {
+                project_id: 0,
+                content,
+            })
+            .log_err();
+        }
+
+        cx.observe_global::<SettingsStore>(move |_, cx| {
+            let new_settings = cx.global::<SettingsStore>().raw_user_settings();
+            if &settings != new_settings {
+                settings = new_settings.clone()
+            }
+            if let Some(content) = serde_json::to_string(&settings).log_err() {
+                ssh.send(proto::UpdateUserSettings {
+                    project_id: 0,
+                    content,
+                })
+                .log_err();
+            }
+        })
+        .detach();
+    }
+
+    fn on_worktree_store_event(
+        &mut self,
+        _: Model<WorktreeStore>,
+        event: &WorktreeStoreEvent,
+        cx: &mut ModelContext<Self>,
+    ) {
+        match event {
+            WorktreeStoreEvent::WorktreeAdded(worktree) => cx
+                .subscribe(worktree, |this, worktree, event, cx| match event {
+                    worktree::Event::UpdatedEntries(changes) => {
+                        this.update_local_worktree_settings(&worktree, changes, cx)
+                    }
+                    _ => {}
+                })
+                .detach(),
+            _ => {}
+        }
+    }
+
+    fn update_local_worktree_settings(
+        &mut self,
+        worktree: &Model<Worktree>,
+        changes: &UpdatedEntriesSet,
+        cx: &mut ModelContext<Self>,
+    ) {
+        let SettingsObserverMode::Local(fs) = &self.mode else {
+            return;
+        };
+
+        let mut settings_contents = Vec::new();
+        for (path, _, change) in changes.iter() {
+            let removed = change == &PathChange::Removed;
+            let abs_path = match worktree.read(cx).absolutize(path) {
+                Ok(abs_path) => abs_path,
+                Err(e) => {
+                    log::warn!("Cannot absolutize {path:?} received as {change:?} FS change: {e}");
+                    continue;
+                }
+            };
+
+            if path.ends_with(local_settings_file_relative_path()) {
+                let settings_dir = Arc::from(
+                    path.ancestors()
+                        .nth(local_settings_file_relative_path().components().count())
+                        .unwrap(),
+                );
+                let fs = fs.clone();
+                settings_contents.push(async move {
+                    (
+                        settings_dir,
+                        if removed {
+                            None
+                        } else {
+                            Some(async move { fs.load(&abs_path).await }.await)
+                        },
+                    )
+                });
+            }
+        }
+
+        if settings_contents.is_empty() {
+            return;
+        }
+
+        let worktree = worktree.clone();
+        cx.spawn(move |this, cx| async move {
+            let settings_contents: Vec<(Arc<Path>, _)> =
+                futures::future::join_all(settings_contents).await;
+            cx.update(|cx| {
+                this.update(cx, |this, cx| {
+                    this.update_settings(
+                        worktree,
+                        settings_contents
+                            .into_iter()
+                            .map(|(path, content)| (path, content.and_then(|c| c.log_err()))),
+                        cx,
+                    )
+                })
+            })
+        })
+        .detach();
+    }
+
+    fn update_settings(
+        &mut self,
+        worktree: Model<Worktree>,
+        settings_contents: impl IntoIterator<Item = (Arc<Path>, Option<String>)>,
+        cx: &mut ModelContext<Self>,
+    ) {
+        let worktree_id = worktree.entity_id();
+        let remote_worktree_id = worktree.read(cx).id();
+        cx.update_global::<SettingsStore, _>(|store, cx| {
+            for (directory, file_content) in settings_contents {
+                store
+                    .set_local_settings(
+                        worktree_id.as_u64() as usize,
+                        directory.clone(),
+                        file_content.as_deref(),
+                        cx,
+                    )
+                    .log_err();
+                if let Some(downstream_client) = &self.downstream_client {
+                    downstream_client
+                        .send(proto::UpdateWorktreeSettings {
+                            project_id: self.project_id,
+                            worktree_id: remote_worktree_id.to_proto(),
+                            path: directory.to_string_lossy().into_owned(),
+                            content: file_content,
+                        })
+                        .log_err();
+                }
+            }
+        })
+    }
+}

crates/proto/proto/zed.proto 🔗

@@ -280,7 +280,8 @@ message Envelope {
         FindSearchCandidates find_search_candidates = 243;
         FindSearchCandidatesResponse find_search_candidates_response = 244;
 
-        CloseBuffer close_buffer = 245; // current max
+        CloseBuffer close_buffer = 245;
+        UpdateUserSettings update_user_settings = 246; // current max
     }
 
     reserved 158 to 161;
@@ -2491,3 +2492,8 @@ message AddWorktree {
 message AddWorktreeResponse {
     uint64 worktree_id = 1;
 }
+
+message UpdateUserSettings {
+    uint64 project_id = 1;
+    string content = 2;
+}

crates/proto/src/proto.rs 🔗

@@ -365,7 +365,8 @@ messages!(
     (AddWorktreeResponse, Foreground),
     (FindSearchCandidates, Background),
     (FindSearchCandidatesResponse, Background),
-    (CloseBuffer, Foreground)
+    (CloseBuffer, Foreground),
+    (UpdateUserSettings, Foreground)
 );
 
 request_messages!(
@@ -560,7 +561,8 @@ entity_messages!(
     CreateContext,
     UpdateContext,
     SynchronizeContexts,
-    LspExtSwitchSourceHeader
+    LspExtSwitchSourceHeader,
+    UpdateUserSettings
 );
 
 entity_messages!(

crates/remote_server/Cargo.toml 🔗

@@ -36,6 +36,7 @@ serde_json.workspace = true
 shellexpand.workspace = true
 smol.workspace = true
 worktree.workspace = true
+language.workspace = true
 
 [dev-dependencies]
 client = { workspace = true, features = ["test-support"] }

crates/remote_server/src/headless_project.rs 🔗

@@ -1,16 +1,17 @@
 use anyhow::{anyhow, Result};
 use fs::Fs;
-use gpui::{AppContext, AsyncAppContext, Context, Model, ModelContext};
+use gpui::{AppContext, AsyncAppContext, Context, Model, ModelContext, Task};
+use language::LanguageRegistry;
 use project::{
-    buffer_store::BufferStore, search::SearchQuery, worktree_store::WorktreeStore, ProjectPath,
-    WorktreeId, WorktreeSettings,
+    buffer_store::BufferStore, project_settings::SettingsObserver, search::SearchQuery,
+    worktree_store::WorktreeStore, LspStore, ProjectPath, WorktreeId, WorktreeSettings,
 };
 use remote::SshSession;
 use rpc::{
     proto::{self, AnyProtoClient, SSH_PEER_ID, SSH_PROJECT_ID},
     TypedEnvelope,
 };
-use settings::{Settings as _, SettingsStore};
+use settings::Settings as _;
 use smol::stream::StreamExt;
 use std::{
     path::{Path, PathBuf},
@@ -23,16 +24,25 @@ pub struct HeadlessProject {
     pub session: AnyProtoClient,
     pub worktree_store: Model<WorktreeStore>,
     pub buffer_store: Model<BufferStore>,
+    pub lsp_store: Model<LspStore>,
+    pub settings_observer: Model<SettingsObserver>,
     pub next_entry_id: Arc<AtomicUsize>,
 }
 
 impl HeadlessProject {
     pub fn init(cx: &mut AppContext) {
-        cx.set_global(SettingsStore::new(cx));
+        settings::init(cx);
+        language::init(cx);
         WorktreeSettings::register(cx);
     }
 
     pub fn new(session: Arc<SshSession>, fs: Arc<dyn Fs>, cx: &mut ModelContext<Self>) -> Self {
+        // TODO: we should load the env correctly (as we do in login_shell_env_loaded when stdout is not a pty). Can we re-use the ProjectEnvironment for that?
+        let languages = Arc::new(LanguageRegistry::new(
+            Task::ready(()),
+            cx.background_executor().clone(),
+        ));
+
         let worktree_store = cx.new_model(|_| WorktreeStore::new(true, fs.clone()));
         let buffer_store = cx.new_model(|cx| {
             let mut buffer_store =
@@ -40,12 +50,34 @@ impl HeadlessProject {
             buffer_store.shared(SSH_PROJECT_ID, session.clone().into(), cx);
             buffer_store
         });
+        let settings_observer = cx.new_model(|cx| {
+            let mut observer = SettingsObserver::new_local(fs.clone(), worktree_store.clone(), cx);
+            observer.shared(SSH_PROJECT_ID, session.clone().into(), cx);
+            observer
+        });
+        let environment = project::ProjectEnvironment::new(&worktree_store, None, cx);
+        let lsp_store = cx.new_model(|cx| {
+            LspStore::new(
+                buffer_store.clone(),
+                worktree_store.clone(),
+                Some(environment),
+                languages,
+                None,
+                fs.clone(),
+                Some(session.clone().into()),
+                None,
+                Some(0),
+                cx,
+            )
+        });
 
         let client: AnyProtoClient = session.clone().into();
 
         session.subscribe_to_entity(SSH_PROJECT_ID, &worktree_store);
         session.subscribe_to_entity(SSH_PROJECT_ID, &buffer_store);
         session.subscribe_to_entity(SSH_PROJECT_ID, &cx.handle());
+        session.subscribe_to_entity(SSH_PROJECT_ID, &lsp_store);
+        session.subscribe_to_entity(SSH_PROJECT_ID, &settings_observer);
 
         client.add_request_handler(cx.weak_model(), Self::handle_list_remote_directory);
 
@@ -58,12 +90,15 @@ impl HeadlessProject {
 
         BufferStore::init(&client);
         WorktreeStore::init(&client);
+        SettingsObserver::init(&client);
 
         HeadlessProject {
             session: client,
+            settings_observer,
             fs,
             worktree_store,
             buffer_store,
+            lsp_store,
             next_entry_id: Default::default(),
         }
     }

crates/remote_server/src/main.rs 🔗

@@ -47,6 +47,7 @@ fn main() {
     }
 
     gpui::App::headless().run(move |cx| {
+        settings::init(cx);
         HeadlessProject::init(cx);
 
         let (incoming_tx, incoming_rx) = mpsc::unbounded();

crates/remote_server/src/remote_editing_tests.rs 🔗

@@ -4,7 +4,10 @@ use clock::FakeSystemClock;
 use fs::{FakeFs, Fs};
 use gpui::{Context, Model, TestAppContext};
 use http_client::FakeHttpClient;
-use language::{Buffer, LanguageRegistry};
+use language::{
+    language_settings::{all_language_settings, AllLanguageSettings},
+    Buffer, LanguageRegistry,
+};
 use node_runtime::FakeNodeRuntime;
 use project::{
     search::{SearchQuery, SearchResult},
@@ -12,7 +15,7 @@ use project::{
 };
 use remote::SshSession;
 use serde_json::json;
-use settings::SettingsStore;
+use settings::{Settings, SettingsLocation, SettingsStore};
 use smol::stream::StreamExt;
 use std::{path::Path, sync::Arc};
 
@@ -33,7 +36,6 @@ async fn test_basic_remote_editing(cx: &mut TestAppContext, server_cx: &mut Test
         assert_eq!(
             worktree.paths().map(Arc::as_ref).collect::<Vec<_>>(),
             vec![
-                Path::new(".git"),
                 Path::new("README.md"),
                 Path::new("src"),
                 Path::new("src/lib.rs"),
@@ -84,7 +86,6 @@ async fn test_basic_remote_editing(cx: &mut TestAppContext, server_cx: &mut Test
         assert_eq!(
             worktree.paths().map(Arc::as_ref).collect::<Vec<_>>(),
             vec![
-                Path::new(".git"),
                 Path::new("README.md"),
                 Path::new("src"),
                 Path::new("src/lib.rs"),
@@ -184,6 +185,85 @@ async fn test_remote_project_search(cx: &mut TestAppContext, server_cx: &mut Tes
     do_search(&project, cx.clone()).await;
 }
 
+#[gpui::test]
+async fn test_remote_settings(cx: &mut TestAppContext, server_cx: &mut TestAppContext) {
+    let (project, headless, fs) = init_test(cx, server_cx).await;
+
+    cx.update_global(|settings_store: &mut SettingsStore, cx| {
+        settings_store.set_user_settings(
+            r#"{"languages":{"Rust":{"language_servers":["custom-rust-analyzer"]}}}"#,
+            cx,
+        )
+    })
+    .unwrap();
+
+    cx.run_until_parked();
+
+    server_cx.read(|cx| {
+        assert_eq!(
+            AllLanguageSettings::get_global(cx)
+                .language(Some("Rust"))
+                .language_servers,
+            ["custom-rust-analyzer".into()]
+        )
+    });
+
+    fs.insert_tree("/code/project1/.zed", json!({
+        "settings.json": r#"{"languages":{"Rust":{"language_servers":["override-rust-analyzer"]}}}"#
+    })).await;
+
+    let worktree_id = project
+        .update(cx, |project, cx| {
+            project.find_or_create_worktree("/code/project1", true, cx)
+        })
+        .await
+        .unwrap()
+        .0
+        .read_with(cx, |worktree, _| worktree.id());
+
+    let buffer = project
+        .update(cx, |project, cx| {
+            project.open_buffer((worktree_id, Path::new("src/lib.rs")), cx)
+        })
+        .await
+        .unwrap();
+    cx.run_until_parked();
+
+    server_cx.read(|cx| {
+        let worktree_id = headless
+            .read(cx)
+            .worktree_store
+            .read(cx)
+            .worktrees()
+            .next()
+            .unwrap()
+            .read(cx)
+            .id();
+        assert_eq!(
+            AllLanguageSettings::get(
+                Some(SettingsLocation {
+                    worktree_id: worktree_id.into(),
+                    path: Path::new("src/lib.rs")
+                }),
+                cx
+            )
+            .language(Some("Rust"))
+            .language_servers,
+            ["override-rust-analyzer".into()]
+        )
+    });
+
+    cx.read(|cx| {
+        let file = buffer.read(cx).file();
+        assert_eq!(
+            all_language_settings(file, cx)
+                .language(Some("Rust"))
+                .language_servers,
+            ["override-rust-analyzer".into()]
+        )
+    });
+}
+
 fn init_logger() {
     if std::env::var("RUST_LOG").is_ok() {
         env_logger::try_init().ok();

crates/settings/src/settings_store.rs 🔗

@@ -151,7 +151,7 @@ impl<'a, T: Serialize> SettingsSources<'a, T> {
     }
 }
 
-#[derive(Clone, Copy)]
+#[derive(Clone, Copy, Debug)]
 pub struct SettingsLocation<'a> {
     pub worktree_id: usize,
     pub path: &'a Path,
@@ -309,8 +309,8 @@ impl SettingsStore {
 
     /// Get the user's settings as a raw JSON value.
     ///
-    /// This is only for debugging and reporting. For user-facing functionality,
-    /// use the typed setting interface.
+    /// For user-facing functionality use the typed setting interface.
+    /// (e.g. ProjectSettings::get_global(cx))
     pub fn raw_user_settings(&self) -> &serde_json::Value {
         &self.raw_user_settings
     }

crates/worktree/src/worktree.rs 🔗

@@ -983,6 +983,10 @@ impl Worktree {
 }
 
 impl LocalWorktree {
+    pub fn fs(&self) -> &Arc<dyn Fs> {
+        &self.fs
+    }
+
     pub fn contains_abs_path(&self, path: &Path) -> bool {
         path.starts_with(&self.abs_path)
     }