From f944ebc4cbc6deb55cfbd6b0466631ea8d3e56a7 Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Tue, 15 Oct 2024 23:32:44 -0700 Subject: [PATCH] Add settings to remote servers, use XDG paths on remote, and enable node LSPs (#19176) Supersedes https://github.com/zed-industries/zed/pull/19166 TODO: - [x] Update basic zed paths - [x] update create_state_directory - [x] Use this with `NodeRuntime` - [x] Add server settings - [x] Add an 'open server settings command' - [x] Make sure it all works Release Notes: - Updated the actions `zed::OpenLocalSettings` and `zed::OpenLocalTasks` to `zed::OpenProjectSettings` and `zed::OpenProjectTasks`. --------- Co-authored-by: Conrad Co-authored-by: Richard --- Cargo.lock | 4 + assets/settings/initial_server_settings.json | 7 + crates/assistant/src/inline_assistant.rs | 2 +- .../assistant/src/slash_command_settings.rs | 5 +- .../src/terminal_inline_assistant.rs | 2 +- crates/auto_update/src/auto_update.rs | 2 +- crates/client/src/client.rs | 19 +- .../remote_editing_collaboration_tests.rs | 24 +- crates/editor/src/git/blame.rs | 13 +- crates/extension/src/extension_settings.rs | 5 +- crates/extensions_ui/src/extension_suggest.rs | 2 +- crates/gpui/src/app/entity_map.rs | 6 + crates/http_client/src/http_client.rs | 6 + crates/language/src/language.rs | 2 +- crates/paths/src/paths.rs | 11 + crates/project/src/lsp_store.rs | 11 +- crates/project/src/project.rs | 124 +++++++--- crates/project/src/project_settings.rs | 67 +++--- crates/project/src/task_store.rs | 7 +- crates/project/src/terminals.rs | 6 +- crates/project/src/worktree_store.rs | 18 +- crates/project_panel/src/project_panel.rs | 7 +- crates/proto/proto/zed.proto | 22 +- crates/proto/src/proto.rs | 10 +- crates/recent_projects/Cargo.toml | 1 + crates/recent_projects/src/dev_servers.rs | 4 +- crates/recent_projects/src/ssh_connections.rs | 16 +- crates/remote/src/ssh_session.rs | 10 +- crates/remote_server/Cargo.toml | 6 +- crates/remote_server/src/headless_project.rs | 91 +++++++- .../remote_server/src/remote_editing_tests.rs | 62 ++++- crates/remote_server/src/remote_server.rs | 2 +- crates/remote_server/src/unix.rs | 219 +++++++++++++++--- crates/settings/src/settings.rs | 6 +- crates/settings/src/settings_store.rs | 86 +++++-- crates/theme/src/settings.rs | 7 +- crates/vim/src/vim.rs | 11 +- crates/welcome/src/base_keymap_setting.rs | 3 + crates/workspace/src/notifications.rs | 27 +-- crates/workspace/src/workspace.rs | 39 ++-- crates/zed/src/zed.rs | 45 +++- crates/zed/src/zed/app_menus.rs | 2 +- crates/zed_actions/src/lib.rs | 1 + docs/src/configuring-zed.md | 2 +- 44 files changed, 804 insertions(+), 218 deletions(-) create mode 100644 assets/settings/initial_server_settings.json diff --git a/Cargo.lock b/Cargo.lock index f83feb4689db3..6f1d7b2c9ca06 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8977,6 +8977,7 @@ dependencies = [ "log", "menu", "ordered-float 2.10.1", + "paths", "picker", "project", "release_channel", @@ -9136,6 +9137,7 @@ name = "remote_server" version = "0.1.0" dependencies = [ "anyhow", + "async-watch", "backtrace", "cargo_toml", "clap", @@ -9151,8 +9153,10 @@ dependencies = [ "log", "lsp", "node_runtime", + "paths", "project", "remote", + "reqwest_client", "rpc", "rust-embed", "serde", diff --git a/assets/settings/initial_server_settings.json b/assets/settings/initial_server_settings.json new file mode 100644 index 0000000000000..d6ec33e601283 --- /dev/null +++ b/assets/settings/initial_server_settings.json @@ -0,0 +1,7 @@ +// Server-specific settings +// +// For a full list of overridable settings, and general information on settings, +// see the documentation: https://zed.dev/docs/configuring-zed#settings-files +{ + "lsp": {} +} diff --git a/crates/assistant/src/inline_assistant.rs b/crates/assistant/src/inline_assistant.rs index 4ecf8dd37e5e0..ce01e63b513db 100644 --- a/crates/assistant/src/inline_assistant.rs +++ b/crates/assistant/src/inline_assistant.rs @@ -2278,7 +2278,7 @@ impl InlineAssist { struct InlineAssistantError; let id = - NotificationId::identified::( + NotificationId::composite::( assist_id.0, ); diff --git a/crates/assistant/src/slash_command_settings.rs b/crates/assistant/src/slash_command_settings.rs index c524b37803ede..5918769d711c3 100644 --- a/crates/assistant/src/slash_command_settings.rs +++ b/crates/assistant/src/slash_command_settings.rs @@ -38,7 +38,10 @@ impl Settings for SlashCommandSettings { fn load(sources: SettingsSources, _cx: &mut AppContext) -> Result { SettingsSources::::json_merge_with( - [sources.default].into_iter().chain(sources.user), + [sources.default] + .into_iter() + .chain(sources.user) + .chain(sources.server), ) } } diff --git a/crates/assistant/src/terminal_inline_assistant.rs b/crates/assistant/src/terminal_inline_assistant.rs index d30ec2df11a79..41b8d9eb88ac2 100644 --- a/crates/assistant/src/terminal_inline_assistant.rs +++ b/crates/assistant/src/terminal_inline_assistant.rs @@ -414,7 +414,7 @@ impl TerminalInlineAssist { struct InlineAssistantError; let id = - NotificationId::identified::( + NotificationId::composite::( assist_id.0, ); diff --git a/crates/auto_update/src/auto_update.rs b/crates/auto_update/src/auto_update.rs index bb952990fccc8..d501e6d93fdc1 100644 --- a/crates/auto_update/src/auto_update.rs +++ b/crates/auto_update/src/auto_update.rs @@ -130,7 +130,7 @@ impl Settings for AutoUpdateSetting { type FileContent = Option; fn load(sources: SettingsSources, _: &mut AppContext) -> Result { - let auto_update = [sources.release_channel, sources.user] + let auto_update = [sources.server, sources.release_channel, sources.user] .into_iter() .find_map(|value| value.copied().flatten()) .unwrap_or(sources.default.ok_or_else(Self::missing_default)?); diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index 49f3ff4b146f4..33e2bff6fbc50 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -141,6 +141,7 @@ impl Settings for ProxySettings { Ok(Self { proxy: sources .user + .or(sources.server) .and_then(|value| value.proxy.clone()) .or(sources.default.proxy.clone()), }) @@ -472,15 +473,21 @@ impl settings::Settings for TelemetrySettings { fn load(sources: SettingsSources, _: &mut AppContext) -> Result { Ok(Self { - diagnostics: sources.user.as_ref().and_then(|v| v.diagnostics).unwrap_or( - sources - .default - .diagnostics - .ok_or_else(Self::missing_default)?, - ), + diagnostics: sources + .user + .as_ref() + .or(sources.server.as_ref()) + .and_then(|v| v.diagnostics) + .unwrap_or( + sources + .default + .diagnostics + .ok_or_else(Self::missing_default)?, + ), metrics: sources .user .as_ref() + .or(sources.server.as_ref()) .and_then(|v| v.metrics) .unwrap_or(sources.default.metrics.ok_or_else(Self::missing_default)?), }) diff --git a/crates/collab/src/tests/remote_editing_collaboration_tests.rs b/crates/collab/src/tests/remote_editing_collaboration_tests.rs index 7de50511ea276..dae33457555ec 100644 --- a/crates/collab/src/tests/remote_editing_collaboration_tests.rs +++ b/crates/collab/src/tests/remote_editing_collaboration_tests.rs @@ -2,10 +2,12 @@ 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 http_client::BlockedHttpClient; +use language::{language_settings::all_language_settings, LanguageRegistry}; +use node_runtime::NodeRuntime; use project::ProjectPath; use remote::SshRemoteClient; -use remote_server::HeadlessProject; +use remote_server::{HeadlessAppState, HeadlessProject}; use serde_json::json; use std::{path::Path, sync::Arc}; @@ -48,8 +50,22 @@ async fn test_sharing_an_ssh_remote_project( // User A connects to the remote project via SSH. server_cx.update(HeadlessProject::init); - let _headless_project = - server_cx.new_model(|cx| HeadlessProject::new(server_ssh, remote_fs.clone(), cx)); + let remote_http_client = Arc::new(BlockedHttpClient); + let node = NodeRuntime::unavailable(); + let languages = Arc::new(LanguageRegistry::new(server_cx.executor())); + let _headless_project = server_cx.new_model(|cx| { + client::init_settings(cx); + HeadlessProject::new( + HeadlessAppState { + session: server_ssh, + fs: remote_fs.clone(), + http_client: remote_http_client, + node_runtime: node, + languages, + }, + cx, + ) + }); let (project_a, worktree_id) = client_a .build_ssh_project("/code/project1", client_ssh, cx_a) diff --git a/crates/editor/src/git/blame.rs b/crates/editor/src/git/blame.rs index 303ead16b2231..1ac134530532c 100644 --- a/crates/editor/src/git/blame.rs +++ b/crates/editor/src/git/blame.rs @@ -403,7 +403,10 @@ impl GitBlame { if this.user_triggered { log::error!("failed to get git blame data: {error:?}"); let notification = format!("{:#}", error).trim().to_string(); - cx.emit(project::Event::Notification(notification)); + cx.emit(project::Event::Toast { + notification_id: "git-blame".into(), + message: notification, + }); } else { // If we weren't triggered by a user, we just log errors in the background, instead of sending // notifications. @@ -619,9 +622,11 @@ mod tests { let event = project.next_event(cx).await; assert_eq!( event, - project::Event::Notification( - "Failed to blame \"file.txt\": failed to get blame for \"file.txt\"".to_string() - ) + project::Event::Toast { + notification_id: "git-blame".into(), + message: "Failed to blame \"file.txt\": failed to get blame for \"file.txt\"" + .to_string() + } ); blame.update(cx, |blame, cx| { diff --git a/crates/extension/src/extension_settings.rs b/crates/extension/src/extension_settings.rs index a2ab7ac9cca73..cfae4482c90ec 100644 --- a/crates/extension/src/extension_settings.rs +++ b/crates/extension/src/extension_settings.rs @@ -42,7 +42,10 @@ impl Settings for ExtensionSettings { fn load(sources: SettingsSources, _cx: &mut AppContext) -> Result { SettingsSources::::json_merge_with( - [sources.default].into_iter().chain(sources.user), + [sources.default] + .into_iter() + .chain(sources.user) + .chain(sources.server), ) } } diff --git a/crates/extensions_ui/src/extension_suggest.rs b/crates/extensions_ui/src/extension_suggest.rs index ed003f25b7f23..b21621537fdf1 100644 --- a/crates/extensions_ui/src/extension_suggest.rs +++ b/crates/extensions_ui/src/extension_suggest.rs @@ -163,7 +163,7 @@ pub(crate) fn suggest(buffer: Model, cx: &mut ViewContext) { struct ExtensionSuggestionNotification; - let notification_id = NotificationId::identified::( + let notification_id = NotificationId::composite::( SharedString::from(extension_id.clone()), ); diff --git a/crates/gpui/src/app/entity_map.rs b/crates/gpui/src/app/entity_map.rs index e4da2a3010ae3..5f65cebdb2828 100644 --- a/crates/gpui/src/app/entity_map.rs +++ b/crates/gpui/src/app/entity_map.rs @@ -567,6 +567,12 @@ pub struct WeakModel { entity_type: PhantomData, } +impl std::fmt::Debug for WeakModel { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct(type_name::>()).finish() + } +} + unsafe impl Send for WeakModel {} unsafe impl Sync for WeakModel {} diff --git a/crates/http_client/src/http_client.rs b/crates/http_client/src/http_client.rs index 3d4a41f4a61df..3aadf7496f5f6 100644 --- a/crates/http_client/src/http_client.rs +++ b/crates/http_client/src/http_client.rs @@ -317,6 +317,12 @@ pub fn read_proxy_from_env() -> Option { pub struct BlockedHttpClient; +impl BlockedHttpClient { + pub fn new() -> Self { + BlockedHttpClient + } +} + impl HttpClient for BlockedHttpClient { fn send( &self, diff --git a/crates/language/src/language.rs b/crates/language/src/language.rs index fad799da19898..c1c9cfebbead5 100644 --- a/crates/language/src/language.rs +++ b/crates/language/src/language.rs @@ -367,7 +367,7 @@ pub trait LspAdapter: 'static + Send + Sync { } let Some(container_dir) = delegate.language_server_download_dir(&self.name()).await else { - anyhow::bail!("cannot download language servers for remotes (yet)") + anyhow::bail!("no language server download dir defined") }; let mut binary = try_fetch_server_binary(self.as_ref(), &delegate, container_dir.to_path_buf(), cx).await; diff --git a/crates/paths/src/paths.rs b/crates/paths/src/paths.rs index 7f662d0325d1b..1649ccb475de9 100644 --- a/crates/paths/src/paths.rs +++ b/crates/paths/src/paths.rs @@ -5,6 +5,11 @@ use std::sync::OnceLock; pub use util::paths::home_dir; +/// Returns the relative path to the zed_server directory on the ssh host. +pub fn remote_server_dir_relative() -> &'static Path { + Path::new(".zed_server") +} + /// Returns the path to the configuration directory used by Zed. pub fn config_dir() -> &'static PathBuf { static CONFIG_DIR: OnceLock = OnceLock::new(); @@ -96,6 +101,12 @@ pub fn logs_dir() -> &'static PathBuf { }) } +/// Returns the path to the zed server directory on this ssh host. +pub fn remote_server_state_dir() -> &'static PathBuf { + static REMOTE_SERVER_STATE: OnceLock = OnceLock::new(); + REMOTE_SERVER_STATE.get_or_init(|| return support_dir().join("server_state")) +} + /// Returns the path to the `Zed.log` file. pub fn log_file() -> &'static PathBuf { static LOG_FILE: OnceLock = OnceLock::new(); diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index 58d6b07e565c4..6b0042638dc7a 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -27,7 +27,7 @@ use gpui::{ AppContext, AsyncAppContext, Context, Entity, EventEmitter, Model, ModelContext, PromptLevel, Task, WeakModel, }; -use http_client::{BlockedHttpClient, HttpClient}; +use http_client::HttpClient; use language::{ language_settings::{ all_language_settings, language_settings, AllLanguageSettings, FormatOnSave, Formatter, @@ -116,7 +116,7 @@ impl FormatTrigger { } pub struct LocalLspStore { - http_client: Option>, + http_client: Arc, environment: Model, fs: Arc, yarn: Model, @@ -839,7 +839,7 @@ impl LspStore { prettier_store: Model, environment: Model, languages: Arc, - http_client: Option>, + http_client: Arc, fs: Arc, cx: &mut ModelContext, ) -> Self { @@ -7579,10 +7579,7 @@ impl LocalLspAdapterDelegate { .as_local() .expect("LocalLspAdapterDelegate cannot be constructed on a remote"); - let http_client = local - .http_client - .clone() - .unwrap_or_else(|| Arc::new(BlockedHttpClient)); + let http_client = local.http_client.clone(); Self::new(lsp_store, worktree, http_client, local.fs.clone(), cx) } diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index f4040a3d12c6e..6e731e9cbadd0 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -222,8 +222,13 @@ pub enum Event { LanguageServerAdded(LanguageServerId, LanguageServerName, Option), LanguageServerRemoved(LanguageServerId), LanguageServerLog(LanguageServerId, LanguageServerLogType, String), - Notification(String), - LocalSettingsUpdated(Result<(), InvalidSettingsError>), + Toast { + notification_id: SharedString, + message: String, + }, + HideToast { + notification_id: SharedString, + }, LanguageServerPrompt(LanguageServerPromptRequest), LanguageNotFound(Model), ActiveEntryChanged(Option), @@ -633,7 +638,7 @@ impl Project { prettier_store.clone(), environment.clone(), languages.clone(), - Some(client.http_client()), + client.http_client(), fs.clone(), cx, ) @@ -694,7 +699,7 @@ impl Project { let snippets = SnippetProvider::new(fs.clone(), BTreeSet::from_iter([global_snippets_dir]), cx); - let ssh_proto = ssh.read(cx).to_proto_client(); + let ssh_proto = ssh.read(cx).proto_client(); let worktree_store = cx.new_model(|_| WorktreeStore::remote(false, ssh_proto.clone(), 0, None)); cx.subscribe(&worktree_store, Self::on_worktree_store_event) @@ -703,7 +708,7 @@ impl Project { let buffer_store = cx.new_model(|cx| { BufferStore::remote( worktree_store.clone(), - ssh.read(cx).to_proto_client(), + ssh.read(cx).proto_client(), SSH_PROJECT_ID, cx, ) @@ -716,7 +721,7 @@ impl Project { fs.clone(), buffer_store.downgrade(), worktree_store.clone(), - ssh.read(cx).to_proto_client(), + ssh.read(cx).proto_client(), SSH_PROJECT_ID, cx, ) @@ -809,6 +814,8 @@ impl Project { ssh_proto.add_model_message_handler(Self::handle_create_buffer_for_peer); ssh_proto.add_model_message_handler(Self::handle_update_worktree); ssh_proto.add_model_message_handler(Self::handle_update_project); + ssh_proto.add_model_message_handler(Self::handle_toast); + ssh_proto.add_model_message_handler(Self::handle_hide_toast); ssh_proto.add_model_request_handler(BufferStore::handle_update_buffer); BufferStore::init(&ssh_proto); LspStore::init(&ssh_proto); @@ -2065,7 +2072,7 @@ impl Project { if let Some(ref ssh_client) = self.ssh_client { ssh_client .read(cx) - .to_proto_client() + .proto_client() .send(proto::CloseBuffer { project_id: 0, buffer_id: buffer_id.to_proto(), @@ -2136,7 +2143,10 @@ impl Project { .ok(); } } - LspStoreEvent::Notification(message) => cx.emit(Event::Notification(message.clone())), + LspStoreEvent::Notification(message) => cx.emit(Event::Toast { + notification_id: "lsp".into(), + message: message.clone(), + }), LspStoreEvent::SnippetEdit { buffer_id, edits, @@ -2180,9 +2190,20 @@ impl Project { cx: &mut ModelContext, ) { match event { - SettingsObserverEvent::LocalSettingsUpdated(error) => { - cx.emit(Event::LocalSettingsUpdated(error.clone())) - } + SettingsObserverEvent::LocalSettingsUpdated(result) => match result { + Err(InvalidSettingsError::LocalSettings { message, path }) => { + let message = + format!("Failed to set local settings in {:?}:\n{}", path, message); + cx.emit(Event::Toast { + notification_id: "local-settings".into(), + message, + }); + } + Ok(_) => cx.emit(Event::HideToast { + notification_id: "local-settings".into(), + }), + Err(_) => {} + }, } } @@ -2262,7 +2283,7 @@ impl Project { if let Some(ssh) = &self.ssh_client { ssh.read(cx) - .to_proto_client() + .proto_client() .send(proto::RemoveWorktree { worktree_id: id_to_remove.to_proto(), }) @@ -2295,7 +2316,7 @@ impl Project { if let Some(ssh) = &self.ssh_client { ssh.read(cx) - .to_proto_client() + .proto_client() .send(proto::UpdateBuffer { project_id: 0, buffer_id: buffer_id.to_proto(), @@ -2632,6 +2653,35 @@ impl Project { }) } + pub fn open_server_settings( + &mut self, + cx: &mut ModelContext, + ) -> Task>> { + let guard = self.retain_remotely_created_models(cx); + let Some(ssh_client) = self.ssh_client.as_ref() else { + return Task::ready(Err(anyhow!("not an ssh project"))); + }; + + let proto_client = ssh_client.read(cx).proto_client(); + + cx.spawn(|this, mut cx| async move { + let buffer = proto_client + .request(proto::OpenServerSettings { + project_id: SSH_PROJECT_ID, + }) + .await?; + + let buffer = this + .update(&mut cx, |this, cx| { + anyhow::Ok(this.wait_for_remote_buffer(BufferId::new(buffer.buffer_id)?, cx)) + })?? + .await; + + drop(guard); + buffer + }) + } + pub fn open_local_buffer_via_lsp( &mut self, abs_path: lsp::Url, @@ -2982,7 +3032,7 @@ impl Project { let (tx, rx) = smol::channel::unbounded(); let (client, remote_id): (AnyProtoClient, _) = if let Some(ssh_client) = &self.ssh_client { - (ssh_client.read(cx).to_proto_client(), 0) + (ssh_client.read(cx).proto_client(), 0) } else if let Some(remote_id) = self.remote_id() { (self.client.clone().into(), remote_id) } else { @@ -3069,14 +3119,9 @@ impl Project { visible: bool, cx: &mut ModelContext, ) -> Task, PathBuf)>> { - let abs_path = abs_path.as_ref(); - if let Some((tree, relative_path)) = self.find_worktree(abs_path, cx) { - Task::ready(Ok((tree, relative_path))) - } else { - let worktree = self.create_worktree(abs_path, visible, cx); - cx.background_executor() - .spawn(async move { Ok((worktree.await?, PathBuf::new())) }) - } + self.worktree_store.update(cx, |worktree_store, cx| { + worktree_store.find_or_create_worktree(abs_path, visible, cx) + }) } pub fn find_worktree( @@ -3138,7 +3183,7 @@ impl Project { } else if let Some(ssh_client) = self.ssh_client.as_ref() { let request = ssh_client .read(cx) - .to_proto_client() + .proto_client() .request(proto::CheckFileExists { project_id: SSH_PROJECT_ID, path: path.to_string(), @@ -3215,7 +3260,7 @@ impl Project { path: query, }; - let response = session.read(cx).to_proto_client().request(request); + let response = session.read(cx).proto_client().request(request); cx.background_executor().spawn(async move { let response = response.await?; Ok(response.entries.into_iter().map(PathBuf::from).collect()) @@ -3239,7 +3284,7 @@ impl Project { } } - fn create_worktree( + pub fn create_worktree( &mut self, abs_path: impl AsRef, visible: bool, @@ -3544,6 +3589,33 @@ impl Project { })? } + async fn handle_toast( + this: Model, + envelope: TypedEnvelope, + mut cx: AsyncAppContext, + ) -> Result<()> { + this.update(&mut cx, |_, cx| { + cx.emit(Event::Toast { + notification_id: envelope.payload.notification_id.into(), + message: envelope.payload.message, + }); + Ok(()) + })? + } + + async fn handle_hide_toast( + this: Model, + envelope: TypedEnvelope, + mut cx: AsyncAppContext, + ) -> Result<()> { + this.update(&mut cx, |_, cx| { + cx.emit(Event::HideToast { + notification_id: envelope.payload.notification_id.into(), + }); + Ok(()) + })? + } + // Collab sends UpdateWorktree protos as messages async fn handle_update_worktree( this: Model, @@ -3572,7 +3644,7 @@ impl Project { let mut payload = envelope.payload.clone(); payload.project_id = SSH_PROJECT_ID; cx.background_executor() - .spawn(ssh.read(cx).to_proto_client().request(payload)) + .spawn(ssh.read(cx).proto_client().request(payload)) .detach_and_log_err(cx); } this.buffer_store.clone() diff --git a/crates/project/src/project_settings.rs b/crates/project/src/project_settings.rs index 49378fbc1dc7e..3ecf0e300878c 100644 --- a/crates/project/src/project_settings.rs +++ b/crates/project/src/project_settings.rs @@ -538,26 +538,47 @@ impl SettingsObserver { let task_store = self.task_store.clone(); for (directory, kind, file_content) in settings_contents { - let result = match kind { + match kind { LocalSettingsKind::Settings | LocalSettingsKind::Editorconfig => cx - .update_global::>(|store, cx| { - store.set_local_settings( + .update_global::(|store, cx| { + let result = store.set_local_settings( worktree_id, directory.clone(), kind, file_content.as_deref(), cx, - ) + ); + + match result { + Err(InvalidSettingsError::LocalSettings { path, message }) => { + log::error!( + "Failed to set local settings in {:?}: {:?}", + path, + message + ); + cx.emit(SettingsObserverEvent::LocalSettingsUpdated(Err( + InvalidSettingsError::LocalSettings { path, message }, + ))); + } + Err(e) => { + log::error!("Failed to set local settings: {e}"); + } + Ok(_) => { + cx.emit(SettingsObserverEvent::LocalSettingsUpdated(Ok(()))); + } + } }), LocalSettingsKind::Tasks => task_store.update(cx, |task_store, cx| { - task_store.update_user_tasks( - Some(SettingsLocation { - worktree_id, - path: directory.as_ref(), - }), - file_content.as_deref(), - cx, - ) + task_store + .update_user_tasks( + Some(SettingsLocation { + worktree_id, + path: directory.as_ref(), + }), + file_content.as_deref(), + cx, + ) + .log_err(); }), }; @@ -572,28 +593,6 @@ impl SettingsObserver { }) .log_err(); } - - match result { - Err(error) => { - if let Ok(error) = error.downcast::() { - if let InvalidSettingsError::LocalSettings { - ref path, - ref message, - } = error - { - log::error!( - "Failed to set local settings in {:?}: {:?}", - path, - message - ); - cx.emit(SettingsObserverEvent::LocalSettingsUpdated(Err(error))); - } - } - } - Ok(()) => { - cx.emit(SettingsObserverEvent::LocalSettingsUpdated(Ok(()))); - } - } } } } diff --git a/crates/project/src/task_store.rs b/crates/project/src/task_store.rs index 55ee780fc56e9..45b62697c3df3 100644 --- a/crates/project/src/task_store.rs +++ b/crates/project/src/task_store.rs @@ -298,9 +298,10 @@ impl TaskStore { let result = task_store.update_user_tasks(None, Some(&user_tasks_content), cx); if let Err(err) = &result { log::error!("Failed to load user tasks: {err}"); - cx.emit(crate::Event::Notification(format!( - "Invalid global tasks file\n{err}" - ))); + cx.emit(crate::Event::Toast { + notification_id: "load-user-tasks".into(), + message: format!("Invalid global tasks file\n{err}"), + }); } cx.refresh(); }) else { diff --git a/crates/project/src/terminals.rs b/crates/project/src/terminals.rs index 11b3152f0cf66..ac4e890aaddfa 100644 --- a/crates/project/src/terminals.rs +++ b/crates/project/src/terminals.rs @@ -367,7 +367,11 @@ pub fn wrap_for_ssh( // replace ith with something that works let tilde_prefix = "~/"; if path.starts_with(tilde_prefix) { - let trimmed_path = &path_string[tilde_prefix.len()..]; + let trimmed_path = path_string + .trim_start_matches("/") + .trim_start_matches("~") + .trim_start_matches("/"); + format!("cd \"$HOME/{trimmed_path}\"; {env_changes} {to_run}") } else { format!("cd {path:?}; {env_changes} {to_run}") diff --git a/crates/project/src/worktree_store.rs b/crates/project/src/worktree_store.rs index 6e04a884b9187..55eb0108e2109 100644 --- a/crates/project/src/worktree_store.rs +++ b/crates/project/src/worktree_store.rs @@ -153,6 +153,22 @@ impl WorktreeStore { None } + pub fn find_or_create_worktree( + &mut self, + abs_path: impl AsRef, + visible: bool, + cx: &mut ModelContext, + ) -> Task, PathBuf)>> { + let abs_path = abs_path.as_ref(); + if let Some((tree, relative_path)) = self.find_worktree(abs_path, cx) { + Task::ready(Ok((tree, relative_path))) + } else { + let worktree = self.create_worktree(abs_path, visible, cx); + cx.background_executor() + .spawn(async move { Ok((worktree.await?, PathBuf::new())) }) + } + } + pub fn entry_for_id<'a>( &'a self, entry_id: ProjectEntryId, @@ -957,7 +973,7 @@ impl WorktreeStore { } } -#[derive(Clone)] +#[derive(Clone, Debug)] enum WorktreeHandle { Strong(Model), Weak(WeakModel), diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index b4f07c66d3065..08a0ef4b4060b 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -880,9 +880,10 @@ impl ProjectPanel { if is_dir { project_panel.project.update(cx, |_, cx| { - cx.emit(project::Event::Notification(format!( - "Created an excluded directory at {abs_path:?}.\nAlter `file_scan_exclusions` in the settings to show it in the panel" - ))) + cx.emit(project::Event::Toast { + notification_id: "excluded-directory".into(), + message: format!("Created an excluded directory at {abs_path:?}.\nAlter `file_scan_exclusions` in the settings to show it in the panel") + }) }); None } else { diff --git a/crates/proto/proto/zed.proto b/crates/proto/proto/zed.proto index 52a73cd7f1dc8..fbd1cf4215134 100644 --- a/crates/proto/proto/zed.proto +++ b/crates/proto/proto/zed.proto @@ -287,7 +287,12 @@ message Envelope { RemoveWorktree remove_worktree = 258; - LanguageServerLog language_server_log = 260; // current max + LanguageServerLog language_server_log = 260; + + Toast toast = 261; + HideToast hide_toast = 262; + + OpenServerSettings open_server_settings = 263; // current max } reserved 87 to 88; @@ -2487,3 +2492,18 @@ message ShutdownRemoteServer {} message RemoveWorktree { uint64 worktree_id = 1; } + +message Toast { + uint64 project_id = 1; + string notification_id = 2; + string message = 3; +} + +message HideToast { + uint64 project_id = 1; + string notification_id = 2; +} + +message OpenServerSettings { + uint64 project_id = 1; +} diff --git a/crates/proto/src/proto.rs b/crates/proto/src/proto.rs index 15841a7077092..8455439980165 100644 --- a/crates/proto/src/proto.rs +++ b/crates/proto/src/proto.rs @@ -367,6 +367,9 @@ messages!( (ShutdownRemoteServer, Foreground), (RemoveWorktree, Foreground), (LanguageServerLog, Foreground), + (Toast, Background), + (HideToast, Background), + (OpenServerSettings, Foreground), ); request_messages!( @@ -490,7 +493,8 @@ request_messages!( (AddWorktree, AddWorktreeResponse), (CheckFileExists, CheckFileExistsResponse), (ShutdownRemoteServer, Ack), - (RemoveWorktree, Ack) + (RemoveWorktree, Ack), + (OpenServerSettings, OpenBufferResponse) ); entity_messages!( @@ -564,6 +568,10 @@ entity_messages!( UpdateUserSettings, CheckFileExists, LanguageServerLog, + Toast, + HideToast, + OpenServerSettings, + ); entity_messages!( diff --git a/crates/recent_projects/Cargo.toml b/crates/recent_projects/Cargo.toml index 2b428009c4ae9..3dadcbef37509 100644 --- a/crates/recent_projects/Cargo.toml +++ b/crates/recent_projects/Cargo.toml @@ -39,6 +39,7 @@ terminal_view.workspace = true ui.workspace = true util.workspace = true workspace.workspace = true +paths.workspace = true [dev-dependencies] editor = { workspace = true, features = ["test-support"] } diff --git a/crates/recent_projects/src/dev_servers.rs b/crates/recent_projects/src/dev_servers.rs index 8fe501258a0bf..89772a1fe81f9 100644 --- a/crates/recent_projects/src/dev_servers.rs +++ b/crates/recent_projects/src/dev_servers.rs @@ -948,7 +948,7 @@ impl DevServerProjects { this.show_toast( Toast::new( - NotificationId::identified::< + NotificationId::composite::< SshServerAddressCopiedToClipboard, >( connection_string.clone() @@ -1002,7 +1002,7 @@ impl DevServerProjects { ); this.show_toast( Toast::new( - NotificationId::identified::( + NotificationId::composite::( connection_string.clone(), ), notification, diff --git a/crates/recent_projects/src/ssh_connections.rs b/crates/recent_projects/src/ssh_connections.rs index d3752a7b123f9..06fb08b3fe493 100644 --- a/crates/recent_projects/src/ssh_connections.rs +++ b/crates/recent_projects/src/ssh_connections.rs @@ -10,6 +10,7 @@ use gpui::{ Transformation, View, }; use gpui::{AppContext, Model}; + use release_channel::{AppVersion, ReleaseChannel}; use remote::{SshConnectionOptions, SshPlatform, SshRemoteClient}; use schemars::JsonSchema; @@ -377,9 +378,18 @@ impl remote::SshClientDelegate for SshClientDelegate { rx } - fn remote_server_binary_path(&self, cx: &mut AsyncAppContext) -> Result { + fn remote_server_binary_path( + &self, + platform: SshPlatform, + cx: &mut AsyncAppContext, + ) -> Result { let release_channel = cx.update(|cx| ReleaseChannel::global(cx))?; - Ok(format!(".local/zed-remote-server-{}", release_channel.dev_name()).into()) + Ok(paths::remote_server_dir_relative().join(format!( + "zed-remote-server-{}-{}-{}", + release_channel.dev_name(), + platform.os, + platform.arch + ))) } } @@ -487,7 +497,7 @@ impl SshClientDelegate { let path = std::env::current_dir()?.join("target/remote_server/debug/remote_server.gz"); return Ok(Some((path, version))); } else if let Some(triple) = platform.triple() { - smol::fs::create_dir_all("target/remote-server").await?; + smol::fs::create_dir_all("target/remote_server").await?; self.update_status(Some("Installing cross.rs for cross-compilation"), cx); log::info!("installing cross"); diff --git a/crates/remote/src/ssh_session.rs b/crates/remote/src/ssh_session.rs index fbe6e3e0fa89a..4d131425492da 100644 --- a/crates/remote/src/ssh_session.rs +++ b/crates/remote/src/ssh_session.rs @@ -137,7 +137,11 @@ pub trait SshClientDelegate: Send + Sync { prompt: String, cx: &mut AsyncAppContext, ) -> oneshot::Receiver>; - fn remote_server_binary_path(&self, cx: &mut AsyncAppContext) -> Result; + fn remote_server_binary_path( + &self, + platform: SshPlatform, + cx: &mut AsyncAppContext, + ) -> Result; fn get_server_binary( &self, platform: SshPlatform, @@ -972,7 +976,7 @@ impl SshRemoteClient { let platform = ssh_connection.query_platform().await?; let (local_binary_path, version) = delegate.get_server_binary(platform, cx).await??; - let remote_binary_path = delegate.remote_server_binary_path(cx)?; + let remote_binary_path = delegate.remote_server_binary_path(platform, cx)?; ssh_connection .ensure_server_binary( &delegate, @@ -1021,7 +1025,7 @@ impl SshRemoteClient { .map(|ssh_connection| ssh_connection.socket.ssh_args()) } - pub fn to_proto_client(&self) -> AnyProtoClient { + pub fn proto_client(&self) -> AnyProtoClient { self.client.clone().into() } diff --git a/crates/remote_server/Cargo.toml b/crates/remote_server/Cargo.toml index d822721c1eb8a..6ba2c5c7c97ca 100644 --- a/crates/remote_server/Cargo.toml +++ b/crates/remote_server/Cargo.toml @@ -22,6 +22,7 @@ debug-embed = ["dep:rust-embed"] test-support = ["fs/test-support"] [dependencies] +async-watch.workspace = true anyhow.workspace = true backtrace = "0.3" clap.workspace = true @@ -30,13 +31,16 @@ env_logger.workspace = true fs.workspace = true futures.workspace = true gpui.workspace = true +http_client.workspace = true language.workspace = true languages.workspace = true log.workspace = true lsp.workspace = true node_runtime.workspace = true project.workspace = true +paths = { workspace = true } remote.workspace = true +reqwest_client.workspace = true rpc.workspace = true rust-embed = { workspace = true, optional = true, features = ["debug-embed"] } serde.workspace = true @@ -66,4 +70,4 @@ cargo_toml.workspace = true toml.workspace = true [package.metadata.cargo-machete] -ignored = ["rust-embed"] +ignored = ["rust-embed", "paths"] diff --git a/crates/remote_server/src/headless_project.rs b/crates/remote_server/src/headless_project.rs index 701b80842b8ce..f17c27a03cad4 100644 --- a/crates/remote_server/src/headless_project.rs +++ b/crates/remote_server/src/headless_project.rs @@ -1,6 +1,7 @@ use anyhow::{anyhow, Result}; use fs::Fs; use gpui::{AppContext, AsyncAppContext, Context, Model, ModelContext}; +use http_client::HttpClient; use language::{proto::serialize_operation, Buffer, BufferEvent, LanguageRegistry}; use node_runtime::NodeRuntime; use project::{ @@ -16,6 +17,8 @@ use rpc::{ proto::{self, SSH_PEER_ID, SSH_PROJECT_ID}, AnyProtoClient, TypedEnvelope, }; + +use settings::initial_server_settings_content; use smol::stream::StreamExt; use std::{ path::{Path, PathBuf}, @@ -36,6 +39,14 @@ pub struct HeadlessProject { pub languages: Arc, } +pub struct HeadlessAppState { + pub session: Arc, + pub fs: Arc, + pub http_client: Arc, + pub node_runtime: NodeRuntime, + pub languages: Arc, +} + impl HeadlessProject { pub fn init(cx: &mut AppContext) { settings::init(cx); @@ -43,11 +54,16 @@ impl HeadlessProject { project::Project::init_settings(cx); } - pub fn new(session: Arc, fs: Arc, cx: &mut ModelContext) -> Self { - let languages = Arc::new(LanguageRegistry::new(cx.background_executor().clone())); - - let node_runtime = NodeRuntime::unavailable(); - + pub fn new( + HeadlessAppState { + session, + fs, + http_client, + node_runtime, + languages, + }: HeadlessAppState, + cx: &mut ModelContext, + ) -> Self { languages::init(languages.clone(), node_runtime.clone(), cx); let worktree_store = cx.new_model(|cx| { @@ -99,7 +115,7 @@ impl HeadlessProject { prettier_store.clone(), environment, languages.clone(), - None, + http_client, fs.clone(), cx, ); @@ -139,6 +155,7 @@ impl HeadlessProject { client.add_model_request_handler(Self::handle_open_buffer_by_path); client.add_model_request_handler(Self::handle_find_search_candidates); + client.add_model_request_handler(Self::handle_open_server_settings); client.add_model_request_handler(BufferStore::handle_update_buffer); client.add_model_message_handler(BufferStore::handle_close_buffer); @@ -203,6 +220,15 @@ impl HeadlessProject { }) .log_err(); } + LspStoreEvent::Notification(message) => { + self.session + .send(proto::Toast { + project_id: SSH_PROJECT_ID, + notification_id: "lsp".to_string(), + message: message.clone(), + }) + .log_err(); + } LspStoreEvent::LanguageServerLog(language_server_id, log_type, message) => { self.session .send(proto::LanguageServerLog { @@ -336,6 +362,59 @@ impl HeadlessProject { }) } + pub async fn handle_open_server_settings( + this: Model, + _: TypedEnvelope, + mut cx: AsyncAppContext, + ) -> Result { + let settings_path = paths::settings_file(); + let (worktree, path) = this + .update(&mut cx, |this, cx| { + this.worktree_store.update(cx, |worktree_store, cx| { + worktree_store.find_or_create_worktree(settings_path, false, cx) + }) + })? + .await?; + + let (buffer, buffer_store) = this.update(&mut cx, |this, cx| { + let buffer = this.buffer_store.update(cx, |buffer_store, cx| { + buffer_store.open_buffer( + ProjectPath { + worktree_id: worktree.read(cx).id(), + path: path.into(), + }, + cx, + ) + }); + + (buffer, this.buffer_store.clone()) + })?; + + let buffer = buffer.await?; + + let buffer_id = cx.update(|cx| { + if buffer.read(cx).is_empty() { + buffer.update(cx, |buffer, cx| { + buffer.edit([(0..0, initial_server_settings_content())], None, cx) + }); + } + + let buffer_id = buffer.read_with(cx, |b, _| b.remote_id()); + + buffer_store.update(cx, |buffer_store, cx| { + buffer_store + .create_buffer_for_peer(&buffer, SSH_PEER_ID, cx) + .detach_and_log_err(cx); + }); + + buffer_id + })?; + + Ok(proto::OpenBufferResponse { + buffer_id: buffer_id.to_proto(), + }) + } + pub async fn handle_find_search_candidates( this: Model, envelope: TypedEnvelope, diff --git a/crates/remote_server/src/remote_editing_tests.rs b/crates/remote_server/src/remote_editing_tests.rs index 10cfb517abb80..41065ad550831 100644 --- a/crates/remote_server/src/remote_editing_tests.rs +++ b/crates/remote_server/src/remote_editing_tests.rs @@ -3,7 +3,7 @@ use client::{Client, UserStore}; use clock::FakeSystemClock; use fs::{FakeFs, Fs}; use gpui::{Context, Model, TestAppContext}; -use http_client::FakeHttpClient; +use http_client::{BlockedHttpClient, FakeHttpClient}; use language::{ language_settings::{all_language_settings, AllLanguageSettings}, Buffer, FakeLspAdapter, LanguageConfig, LanguageMatcher, LanguageRegistry, LanguageServerName, @@ -17,7 +17,7 @@ use project::{ }; use remote::SshRemoteClient; use serde_json::json; -use settings::{Settings, SettingsLocation, SettingsStore}; +use settings::{initial_server_settings_content, Settings, SettingsLocation, SettingsStore}; use smol::stream::StreamExt; use std::{ path::{Path, PathBuf}, @@ -197,7 +197,7 @@ async fn test_remote_settings(cx: &mut TestAppContext, server_cx: &mut TestAppCo cx.update_global(|settings_store: &mut SettingsStore, cx| { settings_store.set_user_settings( - r#"{"languages":{"Rust":{"language_servers":["custom-rust-analyzer"]}}}"#, + r#"{"languages":{"Rust":{"language_servers":["from-local-settings"]}}}"#, cx, ) }) @@ -210,7 +210,27 @@ async fn test_remote_settings(cx: &mut TestAppContext, server_cx: &mut TestAppCo AllLanguageSettings::get_global(cx) .language(Some(&"Rust".into())) .language_servers, - ["custom-rust-analyzer".to_string()] + ["from-local-settings".to_string()] + ) + }); + + server_cx + .update_global(|settings_store: &mut SettingsStore, cx| { + settings_store.set_server_settings( + r#"{"languages":{"Rust":{"language_servers":["from-server-settings"]}}}"#, + cx, + ) + }) + .unwrap(); + + cx.run_until_parked(); + + server_cx.read(|cx| { + assert_eq!( + AllLanguageSettings::get_global(cx) + .language(Some(&"Rust".into())) + .language_servers, + ["from-server-settings".to_string()] ) }); @@ -606,6 +626,21 @@ async fn test_adding_then_removing_then_adding_worktrees( }) } +#[gpui::test] +async fn test_open_server_settings(cx: &mut TestAppContext, server_cx: &mut TestAppContext) { + let (project, _headless, _fs) = init_test(cx, server_cx).await; + let buffer = project.update(cx, |project, cx| project.open_server_settings(cx)); + cx.executor().run_until_parked(); + let buffer = buffer.await.unwrap(); + + cx.update(|cx| { + assert_eq!( + buffer.read(cx).text(), + initial_server_settings_content().to_string() + ) + }) +} + fn init_logger() { if std::env::var("RUST_LOG").is_ok() { env_logger::try_init().ok(); @@ -642,8 +677,23 @@ async fn init_test( ); server_cx.update(HeadlessProject::init); - let headless = - server_cx.new_model(|cx| HeadlessProject::new(ssh_server_client, fs.clone(), cx)); + let http_client = Arc::new(BlockedHttpClient); + let node_runtime = NodeRuntime::unavailable(); + let languages = Arc::new(LanguageRegistry::new(cx.executor())); + let headless = server_cx.new_model(|cx| { + client::init_settings(cx); + + HeadlessProject::new( + crate::HeadlessAppState { + session: ssh_server_client, + fs: fs.clone(), + http_client, + node_runtime, + languages, + }, + cx, + ) + }); let project = build_project(ssh_remote_client, cx); project diff --git a/crates/remote_server/src/remote_server.rs b/crates/remote_server/src/remote_server.rs index 2321ee1c6eb52..52003969af4df 100644 --- a/crates/remote_server/src/remote_server.rs +++ b/crates/remote_server/src/remote_server.rs @@ -6,4 +6,4 @@ pub mod unix; #[cfg(test)] mod remote_editing_tests; -pub use headless_project::HeadlessProject; +pub use headless_project::{HeadlessAppState, HeadlessProject}; diff --git a/crates/remote_server/src/unix.rs b/crates/remote_server/src/unix.rs index 2b487803fbe67..2972991b7bc64 100644 --- a/crates/remote_server/src/unix.rs +++ b/crates/remote_server/src/unix.rs @@ -1,27 +1,37 @@ +use crate::headless_project::HeadlessAppState; use crate::HeadlessProject; use anyhow::{anyhow, Context, Result}; -use fs::RealFs; +use client::ProxySettings; +use fs::{Fs, RealFs}; use futures::channel::mpsc; use futures::{select, select_biased, AsyncRead, AsyncWrite, AsyncWriteExt, FutureExt, SinkExt}; -use gpui::{AppContext, Context as _}; +use gpui::{AppContext, Context as _, ModelContext, UpdateGlobal as _}; +use http_client::{read_proxy_from_env, Uri}; +use language::LanguageRegistry; +use node_runtime::{NodeBinaryOptions, NodeRuntime}; +use paths::logs_dir; +use project::project_settings::ProjectSettings; use remote::proxy::ProxyLaunchError; use remote::ssh_session::ChannelClient; use remote::{ json_log::LogRecord, protocol::{read_message, write_message}, }; -use rpc::proto::Envelope; +use reqwest_client::ReqwestClient; +use rpc::proto::{self, Envelope, SSH_PROJECT_ID}; +use settings::{watch_config_file, Settings, SettingsStore}; use smol::channel::{Receiver, Sender}; use smol::io::AsyncReadExt; + use smol::Async; use smol::{net::unix::UnixListener, stream::StreamExt as _}; use std::{ - env, io::Write, mem, path::{Path, PathBuf}, sync::Arc, }; +use util::ResultExt; fn init_logging_proxy() { env_logger::builder() @@ -266,6 +276,22 @@ fn start_server( ChannelClient::new(incoming_rx, outgoing_tx, cx) } +fn init_paths() -> anyhow::Result<()> { + for path in [ + paths::config_dir(), + paths::extensions_dir(), + paths::languages_dir(), + paths::logs_dir(), + paths::temp_dir(), + ] + .iter() + { + std::fs::create_dir_all(path) + .map_err(|e| anyhow!("Could not create directory {:?}: {}", path, e))?; + } + Ok(()) +} + pub fn execute_run( log_file: PathBuf, pid_file: PathBuf, @@ -275,6 +301,7 @@ pub fn execute_run( ) -> Result<()> { let log_rx = init_logging_server(log_file)?; init_panic_hook(); + init_paths()?; log::info!( "starting up. pid_file: {:?}, stdin_socket: {:?}, stdout_socket: {:?}, stderr_socket: {:?}", @@ -297,8 +324,43 @@ pub fn execute_run( log::info!("gpui app started, initializing server"); let session = start_server(listeners, log_rx, cx); + client::init_settings(cx); + let project = cx.new_model(|cx| { - HeadlessProject::new(session, Arc::new(RealFs::new(Default::default(), None)), cx) + let fs = Arc::new(RealFs::new(Default::default(), None)); + let node_settings_rx = initialize_settings(session.clone(), fs.clone(), cx); + + let proxy_url = read_proxy_settings(cx); + + let http_client = Arc::new( + ReqwestClient::proxy_and_user_agent( + proxy_url, + &format!( + "Zed-Server/{} ({}; {})", + env!("CARGO_PKG_VERSION"), + std::env::consts::OS, + std::env::consts::ARCH + ), + ) + .expect("Could not start HTTP client"), + ); + + let node_runtime = NodeRuntime::new(http_client.clone(), node_settings_rx); + + let mut languages = LanguageRegistry::new(cx.background_executor().clone()); + languages.set_language_server_download_dir(paths::languages_dir().clone()); + let languages = Arc::new(languages); + + HeadlessProject::new( + HeadlessAppState { + session, + fs, + http_client, + node_runtime, + languages, + }, + cx, + ) }); mem::forget(project); @@ -318,13 +380,15 @@ struct ServerPaths { impl ServerPaths { fn new(identifier: &str) -> Result { - let project_dir = create_state_directory(identifier)?; + let server_dir = paths::remote_server_state_dir().join(identifier); + std::fs::create_dir_all(&server_dir)?; + std::fs::create_dir_all(&logs_dir())?; - let pid_file = project_dir.join("server.pid"); - let stdin_socket = project_dir.join("stdin.sock"); - let stdout_socket = project_dir.join("stdout.sock"); - let stderr_socket = project_dir.join("stderr.sock"); - let log_file = project_dir.join("server.log"); + let pid_file = server_dir.join("server.pid"); + let stdin_socket = server_dir.join("stdin.sock"); + let stdout_socket = server_dir.join("stdout.sock"); + let stderr_socket = server_dir.join("stderr.sock"); + let log_file = logs_dir().join(format!("server-{}.log", identifier)); Ok(Self { pid_file, @@ -358,7 +422,7 @@ pub fn execute_proxy(identifier: String, is_reconnecting: bool) -> Result<()> { } spawn_server(&server_paths)?; - } + }; let stdin_task = smol::spawn(async move { let stdin = Async::new(std::io::stdin())?; @@ -409,19 +473,6 @@ pub fn execute_proxy(identifier: String, is_reconnecting: bool) -> Result<()> { Ok(()) } -fn create_state_directory(identifier: &str) -> Result { - let home_dir = env::var("HOME").unwrap_or_else(|_| ".".to_string()); - let server_dir = PathBuf::from(home_dir) - .join(".local") - .join("state") - .join("zed-remote-server") - .join(identifier); - - std::fs::create_dir_all(&server_dir)?; - - Ok(server_dir) -} - fn kill_running_server(pid: u32, paths: &ServerPaths) -> Result<()> { log::info!("killing existing server with PID {}", pid); std::process::Command::new("kill") @@ -453,7 +504,7 @@ fn spawn_server(paths: &ServerPaths) -> Result<()> { } let binary_name = std::env::current_exe()?; - let server_process = std::process::Command::new(binary_name) + let server_process = smol::process::Command::new(binary_name) .arg("run") .arg("--log-file") .arg(&paths.log_file) @@ -484,6 +535,7 @@ fn spawn_server(paths: &ServerPaths) -> Result<()> { "server ready to accept connections. total time waited: {:?}", total_time_waited ); + Ok(()) } @@ -556,3 +608,118 @@ async fn write_size_prefixed_buffer( stream.write_all(buffer).await?; Ok(()) } + +fn initialize_settings( + session: Arc, + fs: Arc, + cx: &mut AppContext, +) -> async_watch::Receiver> { + let user_settings_file_rx = watch_config_file( + &cx.background_executor(), + fs, + paths::settings_file().clone(), + ); + + handle_settings_file_changes(user_settings_file_rx, cx, { + let session = session.clone(); + move |err, _cx| { + if let Some(e) = err { + log::info!("Server settings failed to change: {}", e); + + session + .send(proto::Toast { + project_id: SSH_PROJECT_ID, + notification_id: "server-settings-failed".to_string(), + message: format!( + "Error in settings on remote host {:?}: {}", + paths::settings_file(), + e + ), + }) + .log_err(); + } else { + session + .send(proto::HideToast { + project_id: SSH_PROJECT_ID, + notification_id: "server-settings-failed".to_string(), + }) + .log_err(); + } + } + }); + + let (tx, rx) = async_watch::channel(None); + cx.observe_global::(move |cx| { + let settings = &ProjectSettings::get_global(cx).node; + log::info!("Got new node settings: {:?}", settings); + let options = NodeBinaryOptions { + allow_path_lookup: !settings.ignore_system_version.unwrap_or_default(), + // TODO: Implement this setting + allow_binary_download: true, + use_paths: settings.path.as_ref().map(|node_path| { + let node_path = PathBuf::from(shellexpand::tilde(node_path).as_ref()); + let npm_path = settings + .npm_path + .as_ref() + .map(|path| PathBuf::from(shellexpand::tilde(&path).as_ref())); + ( + node_path.clone(), + npm_path.unwrap_or_else(|| { + let base_path = PathBuf::new(); + node_path.parent().unwrap_or(&base_path).join("npm") + }), + ) + }), + }; + tx.send(Some(options)).log_err(); + }) + .detach(); + + rx +} + +pub fn handle_settings_file_changes( + mut server_settings_file: mpsc::UnboundedReceiver, + cx: &mut AppContext, + settings_changed: impl Fn(Option, &mut AppContext) + 'static, +) { + let server_settings_content = cx + .background_executor() + .block(server_settings_file.next()) + .unwrap(); + SettingsStore::update_global(cx, |store, cx| { + store + .set_server_settings(&server_settings_content, cx) + .log_err(); + }); + cx.spawn(move |cx| async move { + while let Some(server_settings_content) = server_settings_file.next().await { + let result = cx.update_global(|store: &mut SettingsStore, cx| { + let result = store.set_server_settings(&server_settings_content, cx); + if let Err(err) = &result { + log::error!("Failed to load server settings: {err}"); + } + settings_changed(result.err(), cx); + cx.refresh(); + }); + if result.is_err() { + break; // App dropped + } + } + }) + .detach(); +} + +fn read_proxy_settings(cx: &mut ModelContext<'_, HeadlessProject>) -> Option { + let proxy_str = ProxySettings::get_global(cx).proxy.to_owned(); + let proxy_url = proxy_str + .as_ref() + .and_then(|input: &String| { + input + .parse::() + .inspect_err(|e| log::error!("Error parsing proxy settings: {}", e)) + .ok() + }) + .or_else(read_proxy_from_env); + proxy_url +} diff --git a/crates/settings/src/settings.rs b/crates/settings/src/settings.rs index 1092a9bd4d64f..40c371d9951ff 100644 --- a/crates/settings/src/settings.rs +++ b/crates/settings/src/settings.rs @@ -88,7 +88,11 @@ pub fn initial_user_settings_content() -> Cow<'static, str> { asset_str::("settings/initial_user_settings.json") } -pub fn initial_local_settings_content() -> Cow<'static, str> { +pub fn initial_server_settings_content() -> Cow<'static, str> { + asset_str::("settings/initial_server_settings.json") +} + +pub fn initial_project_settings_content() -> Cow<'static, str> { asset_str::("settings/initial_local_settings.json") } diff --git a/crates/settings/src/settings_store.rs b/crates/settings/src/settings_store.rs index c8768f007db35..60494a6aee2e9 100644 --- a/crates/settings/src/settings_store.rs +++ b/crates/settings/src/settings_store.rs @@ -110,6 +110,8 @@ pub struct SettingsSources<'a, T> { pub user: Option<&'a T>, /// The user settings for the current release channel. pub release_channel: Option<&'a T>, + /// The server's settings. + pub server: Option<&'a T>, /// The project settings, ordered from least specific to most specific. pub project: &'a [&'a T], } @@ -126,6 +128,7 @@ impl<'a, T: Serialize> SettingsSources<'a, T> { .into_iter() .chain(self.user) .chain(self.release_channel) + .chain(self.server) .chain(self.project.iter().copied()) } @@ -162,6 +165,7 @@ pub struct SettingsStore { setting_values: HashMap>, raw_default_settings: serde_json::Value, raw_user_settings: serde_json::Value, + raw_server_settings: Option, raw_extension_settings: serde_json::Value, raw_local_settings: BTreeMap<(WorktreeId, Arc), HashMap>, @@ -219,6 +223,7 @@ impl SettingsStore { setting_values: Default::default(), raw_default_settings: serde_json::json!({}), raw_user_settings: serde_json::json!({}), + raw_server_settings: None, raw_extension_settings: serde_json::json!({}), raw_local_settings: Default::default(), tab_size_callback: Default::default(), @@ -269,6 +274,13 @@ impl SettingsStore { .log_err(); } + let server_value = self + .raw_server_settings + .as_ref() + .and_then(|server_setting| { + setting_value.deserialize_setting(server_setting).log_err() + }); + let extension_value = setting_value .deserialize_setting(&self.raw_extension_settings) .log_err(); @@ -277,9 +289,10 @@ impl SettingsStore { .load_setting( SettingsSources { default: &default_settings, - release_channel: release_channel_value.as_ref(), extensions: extension_value.as_ref(), user: user_value.as_ref(), + release_channel: release_channel_value.as_ref(), + server: server_value.as_ref(), project: &[], }, cx, @@ -522,6 +535,29 @@ impl SettingsStore { Ok(()) } + pub fn set_server_settings( + &mut self, + server_settings_content: &str, + cx: &mut AppContext, + ) -> Result<()> { + let settings: Option = if server_settings_content.is_empty() { + None + } else { + parse_json_with_comments(server_settings_content)? + }; + + anyhow::ensure!( + settings + .as_ref() + .map(|value| value.is_object()) + .unwrap_or(true), + "settings must be an object" + ); + self.raw_server_settings = settings; + self.recompute_values(None, cx)?; + Ok(()) + } + /// Add or remove a set of local settings via a JSON string. pub fn set_local_settings( &mut self, @@ -530,8 +566,8 @@ impl SettingsStore { kind: LocalSettingsKind, settings_content: Option<&str>, cx: &mut AppContext, - ) -> Result<()> { - anyhow::ensure!( + ) -> std::result::Result<(), InvalidSettingsError> { + debug_assert!( kind != LocalSettingsKind::Tasks, "Attempted to submit tasks into the settings store" ); @@ -541,7 +577,13 @@ impl SettingsStore { .entry((root_id, directory_path.clone())) .or_default(); let changed = if settings_content.is_some_and(|content| !content.is_empty()) { - let new_contents = parse_json_with_comments(settings_content.unwrap())?; + let new_contents = + parse_json_with_comments(settings_content.unwrap()).map_err(|e| { + InvalidSettingsError::LocalSettings { + path: directory_path.join(local_settings_file_relative_path()), + message: e.to_string(), + } + })?; if Some(&new_contents) == raw_local_settings.get(&kind) { false } else { @@ -711,12 +753,16 @@ impl SettingsStore { &mut self, changed_local_path: Option<(WorktreeId, &Path)>, cx: &mut AppContext, - ) -> Result<()> { + ) -> Result<(), InvalidSettingsError> { // Reload the global and local values for every setting. let mut project_settings_stack = Vec::::new(); let mut paths_stack = Vec::>::new(); for setting_value in self.setting_values.values_mut() { - let default_settings = setting_value.deserialize_setting(&self.raw_default_settings)?; + let default_settings = setting_value + .deserialize_setting(&self.raw_default_settings) + .map_err(|e| InvalidSettingsError::DefaultSettings { + message: e.to_string(), + })?; let extension_settings = setting_value .deserialize_setting(&self.raw_extension_settings) @@ -725,12 +771,17 @@ impl SettingsStore { let user_settings = match setting_value.deserialize_setting(&self.raw_user_settings) { Ok(settings) => Some(settings), Err(error) => { - return Err(anyhow!(InvalidSettingsError::UserSettings { - message: error.to_string() - })); + return Err(InvalidSettingsError::UserSettings { + message: error.to_string(), + }); } }; + let server_settings = self + .raw_server_settings + .as_ref() + .and_then(|setting| setting_value.deserialize_setting(setting).log_err()); + let mut release_channel_settings = None; if let Some(release_settings) = &self .raw_user_settings @@ -753,6 +804,7 @@ impl SettingsStore { extensions: extension_settings.as_ref(), user: user_settings.as_ref(), release_channel: release_channel_settings.as_ref(), + server: server_settings.as_ref(), project: &[], }, cx, @@ -804,6 +856,7 @@ impl SettingsStore { extensions: extension_settings.as_ref(), user: user_settings.as_ref(), release_channel: release_channel_settings.as_ref(), + server: server_settings.as_ref(), project: &project_settings_stack.iter().collect::>(), }, cx, @@ -818,10 +871,10 @@ impl SettingsStore { } } Err(error) => { - return Err(anyhow!(InvalidSettingsError::LocalSettings { + return Err(InvalidSettingsError::LocalSettings { path: directory_path.join(local_settings_file_relative_path()), - message: error.to_string() - })); + message: error.to_string(), + }); } } } @@ -835,13 +888,17 @@ impl SettingsStore { pub enum InvalidSettingsError { LocalSettings { path: PathBuf, message: String }, UserSettings { message: String }, + ServerSettings { message: String }, + DefaultSettings { message: String }, } impl std::fmt::Display for InvalidSettingsError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { InvalidSettingsError::LocalSettings { message, .. } - | InvalidSettingsError::UserSettings { message } => { + | InvalidSettingsError::UserSettings { message } + | InvalidSettingsError::ServerSettings { message } + | InvalidSettingsError::DefaultSettings { message } => { write!(f, "{}", message) } } @@ -893,6 +950,9 @@ impl AnySettingValue for SettingValue { release_channel: values .release_channel .map(|value| value.0.downcast_ref::().unwrap()), + server: values + .server + .map(|value| value.0.downcast_ref::().unwrap()), project: values .project .iter() diff --git a/crates/theme/src/settings.rs b/crates/theme/src/settings.rs index e4be957a1b055..49f7ba3c83676 100644 --- a/crates/theme/src/settings.rs +++ b/crates/theme/src/settings.rs @@ -636,7 +636,12 @@ impl settings::Settings for ThemeSettings { unnecessary_code_fade: defaults.unnecessary_code_fade.unwrap_or(0.0), }; - for value in sources.user.into_iter().chain(sources.release_channel) { + for value in sources + .user + .into_iter() + .chain(sources.release_channel) + .chain(sources.server) + { if let Some(value) = value.ui_density { this.ui_density = value; } diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index 024379e6acf28..86a52aca255e5 100644 --- a/crates/vim/src/vim.rs +++ b/crates/vim/src/vim.rs @@ -1080,9 +1080,14 @@ impl Settings for VimModeSetting { type FileContent = Option; fn load(sources: SettingsSources, _: &mut AppContext) -> Result { - Ok(Self(sources.user.copied().flatten().unwrap_or( - sources.default.ok_or_else(Self::missing_default)?, - ))) + Ok(Self( + sources + .user + .or(sources.server) + .copied() + .flatten() + .unwrap_or(sources.default.ok_or_else(Self::missing_default)?), + )) } } diff --git a/crates/welcome/src/base_keymap_setting.rs b/crates/welcome/src/base_keymap_setting.rs index 1b52bbc9f94fb..d212dd41703c8 100644 --- a/crates/welcome/src/base_keymap_setting.rs +++ b/crates/welcome/src/base_keymap_setting.rs @@ -96,6 +96,9 @@ impl Settings for BaseKeymap { if let Some(Some(user_value)) = sources.user.copied() { return Ok(user_value); } + if let Some(Some(server_value)) = sources.server.copied() { + return Ok(server_value); + } sources.default.ok_or_else(Self::missing_default) } } diff --git a/crates/workspace/src/notifications.rs b/crates/workspace/src/notifications.rs index ffab276dd1aed..eee3d16a4ab33 100644 --- a/crates/workspace/src/notifications.rs +++ b/crates/workspace/src/notifications.rs @@ -16,30 +16,27 @@ pub fn init(cx: &mut AppContext) { } #[derive(Debug, PartialEq, Clone)] -pub struct NotificationId { - /// A [`TypeId`] used to uniquely identify this notification. - type_id: TypeId, - /// A supplementary ID used to distinguish between multiple - /// notifications that have the same [`type_id`](Self::type_id); - id: Option, +pub enum NotificationId { + Unique(TypeId), + Composite(TypeId, ElementId), + Named(SharedString), } impl NotificationId { /// Returns a unique [`NotificationId`] for the given type. pub fn unique() -> Self { - Self { - type_id: TypeId::of::(), - id: None, - } + Self::Unique(TypeId::of::()) } /// Returns a [`NotificationId`] for the given type that is also identified /// by the provided ID. - pub fn identified(id: impl Into) -> Self { - Self { - type_id: TypeId::of::(), - id: Some(id.into()), - } + pub fn composite(id: impl Into) -> Self { + Self::Composite(TypeId::of::(), id.into()) + } + + /// Builds a `NotificationId` out of the given string. + pub fn named(id: SharedString) -> Self { + Self::Named(id) } } diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 3c3b26b4c137d..ec4079ba9f8af 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -65,7 +65,7 @@ use release_channel::ReleaseChannel; use remote::{SshClientDelegate, SshConnectionOptions}; use serde::Deserialize; use session::AppSession; -use settings::{InvalidSettingsError, Settings}; +use settings::Settings; use shared_screen::SharedScreen; use sqlez::{ bindable::{Bind, Column, StaticColumnCount}, @@ -839,31 +839,17 @@ impl Workspace { } } - project::Event::LocalSettingsUpdated(result) => { - struct LocalSettingsUpdated; - let id = NotificationId::unique::(); - - match result { - Err(InvalidSettingsError::LocalSettings { message, path }) => { - let full_message = - format!("Failed to set local settings in {:?}:\n{}", path, message); - this.show_notification(id, cx, |cx| { - cx.new_view(|_| MessageNotification::new(full_message.clone())) - }) - } - Err(_) => {} - Ok(_) => this.dismiss_notification(&id, cx), - } - } - - project::Event::Notification(message) => { - struct ProjectNotification; + project::Event::Toast { + notification_id, + message, + } => this.show_notification( + NotificationId::named(notification_id.clone()), + cx, + |cx| cx.new_view(|_| MessageNotification::new(message.clone())), + ), - this.show_notification( - NotificationId::unique::(), - cx, - |cx| cx.new_view(|_| MessageNotification::new(message.clone())), - ) + project::Event::HideToast { notification_id } => { + this.dismiss_notification(&NotificationId::named(notification_id.clone()), cx) } project::Event::LanguageServerPrompt(request) => { @@ -874,7 +860,7 @@ impl Workspace { let id = hasher.finish(); this.show_notification( - NotificationId::identified::(id as usize), + NotificationId::composite::(id as usize), cx, |cx| { cx.new_view(|_| { @@ -1808,6 +1794,7 @@ impl Workspace { .flat_map(|pane| { pane.read(cx).items().filter_map(|item| { if item.is_dirty(cx) { + item.tab_description(0, cx); Some((pane.downgrade(), item.boxed_clone())) } else { None diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index f5bc3a1847c6d..c33cef4a4b255 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -27,13 +27,14 @@ use anyhow::Context as _; use assets::Assets; use futures::{channel::mpsc, select_biased, StreamExt}; use outline_panel::OutlinePanel; +use project::Item; use project_panel::ProjectPanel; use quick_action_bar::QuickActionBar; use release_channel::{AppCommitSha, ReleaseChannel}; use rope::Rope; use search::project_search::ProjectSearchBar; use settings::{ - initial_local_settings_content, initial_tasks_content, KeymapFile, Settings, SettingsStore, + initial_project_settings_content, initial_tasks_content, KeymapFile, Settings, SettingsStore, DEFAULT_KEYMAP_PATH, }; use std::any::TypeId; @@ -53,7 +54,9 @@ use workspace::{ open_new, AppState, NewFile, NewWindow, OpenLog, Toast, Workspace, WorkspaceSettings, }; use workspace::{notifications::DetachAndPromptErr, Pane}; -use zed_actions::{OpenAccountSettings, OpenBrowser, OpenSettings, OpenZedUrl, Quit}; +use zed_actions::{ + OpenAccountSettings, OpenBrowser, OpenServerSettings, OpenSettings, OpenZedUrl, Quit, +}; actions!( zed, @@ -64,8 +67,8 @@ actions!( Minimize, OpenDefaultKeymap, OpenDefaultSettings, - OpenLocalSettings, - OpenLocalTasks, + OpenProjectSettings, + OpenProjectTasks, OpenTasks, ResetDatabase, ShowAll, @@ -218,6 +221,7 @@ pub fn initialize_workspace( let handle = cx.view().downgrade(); cx.on_window_should_close(move |cx| { + handle .update(cx, |workspace, cx| { // We'll handle closing asynchronously @@ -428,8 +432,8 @@ pub fn initialize_workspace( ); }, ) - .register_action(open_local_settings_file) - .register_action(open_local_tasks_file) + .register_action(open_project_settings_file) + .register_action(open_project_tasks_file) .register_action( move |workspace: &mut Workspace, _: &OpenDefaultKeymap, @@ -521,6 +525,25 @@ pub fn initialize_workspace( } } }); + if workspace.project().read(cx).is_via_ssh() { + workspace.register_action({ + move |workspace, _: &OpenServerSettings, cx| { + let open_server_settings = workspace.project().update(cx, |project, cx| { + project.open_server_settings(cx) + }); + + cx.spawn(|workspace, mut cx| async move { + let buffer = open_server_settings.await?; + + workspace.update(&mut cx, |workspace, cx| { + workspace.open_path(buffer.read(cx).project_path(cx).expect("Settings file must have a location"), None, true, cx) + })?.await?; + + anyhow::Ok(()) + }).detach_and_log_err(cx); + } + }); + } workspace.focus_handle(cx).focus(cx); }) @@ -813,22 +836,22 @@ pub fn load_default_keymap(cx: &mut AppContext) { } } -fn open_local_settings_file( +fn open_project_settings_file( workspace: &mut Workspace, - _: &OpenLocalSettings, + _: &OpenProjectSettings, cx: &mut ViewContext, ) { open_local_file( workspace, local_settings_file_relative_path(), - initial_local_settings_content(), + initial_project_settings_content(), cx, ) } -fn open_local_tasks_file( +fn open_project_tasks_file( workspace: &mut Workspace, - _: &OpenLocalTasks, + _: &OpenProjectTasks, cx: &mut ViewContext, ) { open_local_file( diff --git a/crates/zed/src/zed/app_menus.rs b/crates/zed/src/zed/app_menus.rs index 2995595589d9c..52e0eab3e3560 100644 --- a/crates/zed/src/zed/app_menus.rs +++ b/crates/zed/src/zed/app_menus.rs @@ -19,7 +19,7 @@ pub fn app_menus() -> Vec { MenuItem::action("Open Key Bindings", zed_actions::OpenKeymap), MenuItem::action("Open Default Settings", super::OpenDefaultSettings), MenuItem::action("Open Default Key Bindings", super::OpenDefaultKeymap), - MenuItem::action("Open Local Settings", super::OpenLocalSettings), + MenuItem::action("Open Project Settings", super::OpenProjectSettings), MenuItem::action("Select Theme...", theme_selector::Toggle::default()), ], }), diff --git a/crates/zed_actions/src/lib.rs b/crates/zed_actions/src/lib.rs index 4bd4796091b98..cedacb6d8495c 100644 --- a/crates/zed_actions/src/lib.rs +++ b/crates/zed_actions/src/lib.rs @@ -27,6 +27,7 @@ actions!( [ OpenSettings, OpenAccountSettings, + OpenServerSettings, Quit, OpenKeymap, About, diff --git a/docs/src/configuring-zed.md b/docs/src/configuring-zed.md index 121e63c233e50..37d86e6481978 100644 --- a/docs/src/configuring-zed.md +++ b/docs/src/configuring-zed.md @@ -15,7 +15,7 @@ TBD: Add settings documentation about how settings are merged as overlays. E.g. Your settings file can be opened with {#kb zed::OpenSettings}. By default it is located at `~/.config/zed/settings.json`, though if you have XDG_CONFIG_HOME in your environment on Linux it will be at `$XDG_CONFIG_HOME/zed/settings.json` instead. -This configuration is merged with any local configuration inside your projects. You can open the project settings by running {#action zed::OpenLocalSettings} from the command palette. This will create a `.zed` directory containing`.zed/settings.json`. +This configuration is merged with any local configuration inside your projects. You can open the project settings by running {#action zed::OpenProjectSettings} from the command palette. This will create a `.zed` directory containing`.zed/settings.json`. Although most projects will only need one settings file at the root, you can add more local settings files for subdirectories as needed. Not all settings can be set in local files, just those that impact the behavior of the editor and language tooling. For example you can set `tab_size`, `formatter` etc. but not `theme`, `vim_mode` and similar.