diff --git a/crates/collab/src/tests/remote_editing_collaboration_tests.rs b/crates/collab/src/tests/remote_editing_collaboration_tests.rs index 9410dc5b4c..21e7f9dd9e 100644 --- a/crates/collab/src/tests/remote_editing_collaboration_tests.rs +++ b/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![ + 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 diff --git a/crates/project/src/environment.rs b/crates/project/src/environment.rs index 3882491cd6..00794c8fd3 100644 --- a/crates/project/src/environment.rs +++ b/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>, get_environment_task: Option>>>>, cached_shell_environments: HashMap>, } impl ProjectEnvironment { - pub(crate) fn new( + pub fn new( + worktree_store: &Model, cli_environment: Option>, cx: &mut AppContext, ) -> Model { - 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)], - cx: &mut AppContext, - ) -> Model { - cx.new_model(|_| Self { - cli_environment: None, - get_environment_task: None, - cached_shell_environments: shell_environments - .iter() - .cloned() - .collect::>(), - }) + ) { + self.cached_shell_environments = shell_environments + .iter() + .cloned() + .collect::>(); } pub(crate) fn remove_worktree_environment(&mut self, worktree_id: WorktreeId) { diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index 6cb740f96e..cbbb569e25 100644 --- a/crates/project/src/lsp_store.rs +++ b/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, upstream_client: Option, project_id: u64, - http_client: Arc, + http_client: Option>, fs: Arc, nonce: u128, buffer_store: Model, @@ -210,12 +210,12 @@ impl LspStore { } #[allow(clippy::too_many_arguments)] - pub(crate) fn new( + pub fn new( buffer_store: Model, worktree_store: Model, environment: Option>, languages: Arc, - http_client: Arc, + http_client: Option>, fs: Arc, downstream_client: Option, upstream_client: Option, @@ -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, + event: &WorktreeStoreEvent, + cx: &mut ModelContext, + ) { + 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, @@ -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) { - self.environment = Some(environment); - } - pub fn set_active_entry(&mut self, active_entry: Option) { 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, }) diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index e67cf3e6f8..eedc0459a8 100644 --- a/crates/project/src/project.rs +++ b/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, buffers_being_formatted: HashSet, environment: Model, + settings_observer: Model, } #[derive(Default)] @@ -505,6 +503,14 @@ impl FormatTrigger { } } +enum EntitySubscription { + Project(PendingEntitySubscription), + BufferStore(PendingEntitySubscription), + WorktreeStore(PendingEntitySubscription), + LspStore(PendingEntitySubscription), + SettingsObserver(PendingEntitySubscription), +} + #[derive(Clone)] pub enum DirectoryLister { Project(Model), @@ -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> { client.authenticate_and_connect(true, &cx).await?; - let subscriptions = ( - client.subscribe_to_entity::(remote_id)?, - client.subscribe_to_entity::(remote_id)?, - client.subscribe_to_entity::(remote_id)?, - client.subscribe_to_entity::(remote_id)?, - ); + let subscriptions = [ + EntitySubscription::Project(client.subscribe_to_entity::(remote_id)?), + EntitySubscription::BufferStore(client.subscribe_to_entity::(remote_id)?), + EntitySubscription::WorktreeStore( + client.subscribe_to_entity::(remote_id)?, + ), + EntitySubscription::LspStore(client.subscribe_to_entity::(remote_id)?), + EntitySubscription::SettingsObserver( + client.subscribe_to_entity::(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, - subscription: ( - PendingEntitySubscription, - PendingEntitySubscription, - PendingEntitySubscription, - PendingEntitySubscription, - ), + subscriptions: [EntitySubscription; 5], client: Arc, user_store: Model, languages: Arc, @@ -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::>(); let user_ids = response .payload @@ -924,12 +958,19 @@ impl Project { ) -> Result> { client.authenticate_and_connect(true, &cx).await?; - let subscriptions = ( - client.subscribe_to_entity::(remote_id.0)?, - client.subscribe_to_entity::(remote_id.0)?, - client.subscribe_to_entity::(remote_id.0)?, - client.subscribe_to_entity::(remote_id.0)?, - ); + let subscriptions = [ + EntitySubscription::Project(client.subscribe_to_entity::(remote_id.0)?), + EntitySubscription::BufferStore( + client.subscribe_to_entity::(remote_id.0)?, + ), + EntitySubscription::WorktreeStore( + client.subscribe_to_entity::(remote_id.0)?, + ), + EntitySubscription::LspStore(client.subscribe_to_entity::(remote_id.0)?), + EntitySubscription::SettingsObserver( + client.subscribe_to_entity::(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 { + self.worktree_store.clone() + } + fn on_settings_changed(&mut self, cx: &mut ModelContext) { 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::(); - 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, _)> = - futures::future::join_all(settings_contents).await; - cx.update(|cx| { - cx.update_global::(|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, cx: &mut ModelContext) { @@ -4236,29 +4205,6 @@ impl Project { })? } - async fn handle_update_worktree_settings( - this: Model, - envelope: TypedEnvelope, - 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::(|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, envelope: TypedEnvelope, diff --git a/crates/project/src/project_settings.rs b/crates/project/src/project_settings.rs index 56d868fcf5..481aeb2d2a 100644 --- a/crates/project/src/project_settings.rs +++ b/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), + Ssh(AnyProtoClient), + Remote, +} + +pub struct SettingsObserver { + mode: SettingsObserverMode, + downstream_client: Option, + worktree_store: Model, + 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, + worktree_store: Model, + cx: &mut ModelContext, + ) -> 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, + cx: &mut ModelContext, + ) -> 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, _: &mut ModelContext) -> 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.project_id = project_id; + self.downstream_client = Some(downstream_client.clone()); + + let store = cx.global::(); + 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.downstream_client = None; + } + + async fn handle_update_worktree_settings( + this: Model, + envelope: TypedEnvelope, + 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, + envelope: TypedEnvelope, + 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) { + let mut settings = cx.global::().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::(move |_, cx| { + let new_settings = cx.global::().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, + event: &WorktreeStoreEvent, + cx: &mut ModelContext, + ) { + 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, + changes: &UpdatedEntriesSet, + cx: &mut ModelContext, + ) { + 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, _)> = + 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, + settings_contents: impl IntoIterator, Option)>, + cx: &mut ModelContext, + ) { + let worktree_id = worktree.entity_id(); + let remote_worktree_id = worktree.read(cx).id(); + cx.update_global::(|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(); + } + } + }) + } +} diff --git a/crates/proto/proto/zed.proto b/crates/proto/proto/zed.proto index 43278e6a51..3d464904b8 100644 --- a/crates/proto/proto/zed.proto +++ b/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; +} diff --git a/crates/proto/src/proto.rs b/crates/proto/src/proto.rs index b580338320..d8ebf66588 100644 --- a/crates/proto/src/proto.rs +++ b/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!( diff --git a/crates/remote_server/Cargo.toml b/crates/remote_server/Cargo.toml index 104743c05d..9e9a3fdc42 100644 --- a/crates/remote_server/Cargo.toml +++ b/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"] } diff --git a/crates/remote_server/src/headless_project.rs b/crates/remote_server/src/headless_project.rs index 36738f6694..60f29bb573 100644 --- a/crates/remote_server/src/headless_project.rs +++ b/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, pub buffer_store: Model, + pub lsp_store: Model, + pub settings_observer: Model, pub next_entry_id: Arc, } 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, fs: Arc, cx: &mut ModelContext) -> 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(), } } diff --git a/crates/remote_server/src/main.rs b/crates/remote_server/src/main.rs index 112e7cbeaf..4d9c043521 100644 --- a/crates/remote_server/src/main.rs +++ b/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(); diff --git a/crates/remote_server/src/remote_editing_tests.rs b/crates/remote_server/src/remote_editing_tests.rs index c92776f4a8..c7a4b7305e 100644 --- a/crates/remote_server/src/remote_editing_tests.rs +++ b/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![ - 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![ - 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(); diff --git a/crates/settings/src/settings_store.rs b/crates/settings/src/settings_store.rs index 90129abf2d..4d204b25ba 100644 --- a/crates/settings/src/settings_store.rs +++ b/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 } diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs index 372166e9ce..9545cd14a8 100644 --- a/crates/worktree/src/worktree.rs +++ b/crates/worktree/src/worktree.rs @@ -983,6 +983,10 @@ impl Worktree { } impl LocalWorktree { + pub fn fs(&self) -> &Arc { + &self.fs + } + pub fn contains_abs_path(&self, path: &Path) -> bool { path.starts_with(&self.abs_path) }