ssh: Lookup language servers in env on SSH host (#17658)

Thorsten Ball and Bennet created

Release Notes:

- ssh remoting: Lookup language server binaries in environment on SSH
host

---------

Co-authored-by: Bennet <bennet@zed.dev>

Change summary

crates/assistant/src/assistant_panel.rs      |   2 
crates/language/src/language.rs              |   8 +
crates/project/src/lsp_store.rs              | 159 ++++++++++++++++++---
crates/proto/proto/zed.proto                 |  28 +++
crates/proto/src/proto.rs                    |  14 +
crates/remote_server/src/headless_project.rs |   2 
6 files changed, 181 insertions(+), 32 deletions(-)

Detailed changes

crates/assistant/src/assistant_panel.rs 🔗

@@ -5349,7 +5349,7 @@ fn make_lsp_adapter_delegate(
         let http_client = project.client().http_client().clone();
         project.lsp_store().update(cx, |lsp_store, cx| {
             Ok(
-                ProjectLspAdapterDelegate::new(lsp_store, &worktree, http_client, fs, cx)
+                ProjectLspAdapterDelegate::new(lsp_store, &worktree, http_client, fs, None, cx)
                     as Arc<dyn LspAdapterDelegate>,
             )
         })

crates/language/src/language.rs 🔗

@@ -1651,6 +1651,14 @@ impl LspAdapter for FakeLspAdapter {
         LanguageServerName(self.name.into())
     }
 
+    async fn check_if_user_installed(
+        &self,
+        _: &dyn LspAdapterDelegate,
+        _: &AsyncAppContext,
+    ) -> Option<LanguageServerBinary> {
+        Some(self.language_server_binary.clone())
+    }
+
     fn get_language_server_command<'a>(
         self: Arc<Self>,
         _: Arc<Path>,

crates/project/src/lsp_store.rs 🔗

@@ -442,6 +442,17 @@ impl LspStore {
         }
     }
 
+    fn worktree_for_id(
+        &self,
+        worktree_id: WorktreeId,
+        cx: &ModelContext<Self>,
+    ) -> Result<Model<Worktree>> {
+        self.worktree_store
+            .read(cx)
+            .worktree_for_id(worktree_id, cx)
+            .ok_or_else(|| anyhow!("worktree not found"))
+    }
+
     fn on_buffer_store_event(
         &mut self,
         _: Model<BufferStore>,
@@ -4287,6 +4298,7 @@ impl LspStore {
             .ok_or_else(|| anyhow!("missing language"))?;
         let language_name = LanguageName::from_proto(language.name);
         let matcher: LanguageMatcher = serde_json::from_str(&language.matcher)?;
+
         this.update(&mut cx, |this, cx| {
             this.languages
                 .register_language(language_name.clone(), None, matcher.clone(), {
@@ -4334,6 +4346,47 @@ impl LspStore {
         Ok(proto::Ack {})
     }
 
+    pub async fn handle_which_command(
+        this: Model<Self>,
+        envelope: TypedEnvelope<proto::WhichCommand>,
+        mut cx: AsyncAppContext,
+    ) -> Result<proto::WhichCommandResponse> {
+        let worktree_id = WorktreeId::from_proto(envelope.payload.worktree_id);
+        let command = PathBuf::from(envelope.payload.command);
+        let response = this
+            .update(&mut cx, |this, cx| {
+                let worktree = this.worktree_for_id(worktree_id, cx)?;
+                let delegate = ProjectLspAdapterDelegate::for_local(this, &worktree, cx);
+                anyhow::Ok(
+                    cx.spawn(|_, _| async move { delegate.which(command.as_os_str()).await }),
+                )
+            })??
+            .await;
+
+        Ok(proto::WhichCommandResponse {
+            path: response.map(|path| path.to_string_lossy().to_string()),
+        })
+    }
+
+    pub async fn handle_shell_env(
+        this: Model<Self>,
+        envelope: TypedEnvelope<proto::ShellEnv>,
+        mut cx: AsyncAppContext,
+    ) -> Result<proto::ShellEnvResponse> {
+        let worktree_id = WorktreeId::from_proto(envelope.payload.worktree_id);
+        let response = this
+            .update(&mut cx, |this, cx| {
+                let worktree = this.worktree_for_id(worktree_id, cx)?;
+                let delegate = ProjectLspAdapterDelegate::for_local(this, &worktree, cx);
+                anyhow::Ok(cx.spawn(|_, _| async move { delegate.shell_env().await }))
+            })??
+            .await;
+
+        Ok(proto::ShellEnvResponse {
+            env: response.into_iter().collect(),
+        })
+    }
+
     async fn handle_apply_additional_edits_for_completion(
         this: Model<Self>,
         envelope: TypedEnvelope<proto::ApplyCompletionAdditionalEdits>,
@@ -4478,39 +4531,34 @@ impl LspStore {
     ) {
         let ssh = self.as_ssh().unwrap();
 
-        let configured_binary = ProjectSettings::get(
-            Some(worktree.update(cx, |worktree, cx| worktree.settings_location(cx))),
-            cx,
-        )
-        .lsp
-        .get(&adapter.name())
-        .and_then(|c| c.binary.as_ref())
-        .and_then(|config| {
-            if let Some(path) = &config.path {
-                Some((path.clone(), config.arguments.clone().unwrap_or_default()))
-            } else {
-                None
-            }
-        });
         let delegate =
-            ProjectLspAdapterDelegate::for_ssh(self, worktree, cx) as Arc<dyn LspAdapterDelegate>;
+            ProjectLspAdapterDelegate::for_ssh(self, worktree, ssh.upstream_client.clone(), cx)
+                as Arc<dyn LspAdapterDelegate>;
+
+        // TODO: We should use `adapter` here instead of reaching through the `CachedLspAdapter`.
+        let lsp_adapter = adapter.adapter.clone();
+
         let project_id = self.project_id;
         let worktree_id = worktree.read(cx).id().to_proto();
         let upstream_client = ssh.upstream_client.clone();
         let name = adapter.name().to_string();
-        let Some((path, arguments)) = configured_binary else {
-            cx.emit(LspStoreEvent::Notification(format!(
-                "ssh-remoting currently requires manually configuring {} in your settings",
-                adapter.name()
-            )));
-            return;
-        };
+
         let Some(available_language) = self.languages.available_language_for_name(&language) else {
             log::error!("failed to find available language {language}");
             return;
         };
-        let task = cx.spawn(|_, _| async move {
-            let delegate = delegate;
+
+        let task = cx.spawn(|_, cx| async move {
+            let user_binary_task = lsp_adapter.check_if_user_installed(delegate.as_ref(), &cx);
+            let binary = match user_binary_task.await {
+                Some(binary) => binary,
+                None => {
+                    return Err(anyhow!(
+                        "Downloading language server for ssh host is not supported yet"
+                    ))
+                }
+            };
+
             let name = adapter.name().to_string();
             let code_action_kinds = adapter
                 .adapter
@@ -4523,12 +4571,22 @@ impl LspStore {
                 .map(|options| serde_json::to_string(&options))
                 .transpose()?;
 
+            let language_server_command = proto::LanguageServerCommand {
+                path: binary.path.to_string_lossy().to_string(),
+                arguments: binary
+                    .arguments
+                    .iter()
+                    .map(|args| args.to_string_lossy().to_string())
+                    .collect(),
+                env: binary.env.unwrap_or_default().into_iter().collect(),
+            };
+
             upstream_client
                 .request(proto::CreateLanguageServer {
                     project_id,
                     worktree_id,
                     name,
-                    binary: Some(proto::LanguageServerCommand { path, arguments }),
+                    binary: Some(language_server_command),
                     initialization_options,
                     code_action_kinds,
                     language: Some(proto::AvailableLanguage {
@@ -6890,6 +6948,7 @@ pub struct ProjectLspAdapterDelegate {
     http_client: Arc<dyn HttpClient>,
     language_registry: Arc<LanguageRegistry>,
     load_shell_env_task: Shared<Task<Option<HashMap<String, String>>>>,
+    upstream_client: Option<AnyProtoClient>,
 }
 
 impl ProjectLspAdapterDelegate {
@@ -6907,15 +6966,30 @@ impl ProjectLspAdapterDelegate {
             .clone()
             .unwrap_or_else(|| Arc::new(BlockedHttpClient));
 
-        Self::new(lsp_store, worktree, http_client, Some(local.fs.clone()), cx)
+        Self::new(
+            lsp_store,
+            worktree,
+            http_client,
+            Some(local.fs.clone()),
+            None,
+            cx,
+        )
     }
 
     fn for_ssh(
         lsp_store: &LspStore,
         worktree: &Model<Worktree>,
+        upstream_client: AnyProtoClient,
         cx: &mut ModelContext<LspStore>,
     ) -> Arc<Self> {
-        Self::new(lsp_store, worktree, Arc::new(BlockedHttpClient), None, cx)
+        Self::new(
+            lsp_store,
+            worktree,
+            Arc::new(BlockedHttpClient),
+            None,
+            Some(upstream_client),
+            cx,
+        )
     }
 
     pub fn new(
@@ -6923,6 +6997,7 @@ impl ProjectLspAdapterDelegate {
         worktree: &Model<Worktree>,
         http_client: Arc<dyn HttpClient>,
         fs: Option<Arc<dyn Fs>>,
+        upstream_client: Option<AnyProtoClient>,
         cx: &mut ModelContext<LspStore>,
     ) -> Arc<Self> {
         let worktree_id = worktree.read(cx).id();
@@ -6942,6 +7017,7 @@ impl ProjectLspAdapterDelegate {
             worktree: worktree.read(cx).snapshot(),
             fs,
             http_client,
+            upstream_client,
             language_registry: lsp_store.languages.clone(),
             load_shell_env_task,
         })
@@ -6991,13 +7067,42 @@ impl LspAdapterDelegate for ProjectLspAdapterDelegate {
     }
 
     async fn shell_env(&self) -> HashMap<String, String> {
+        if let Some(upstream_client) = &self.upstream_client {
+            use rpc::proto::SSH_PROJECT_ID;
+
+            return upstream_client
+                .request(proto::ShellEnv {
+                    project_id: SSH_PROJECT_ID,
+                    worktree_id: self.worktree_id().to_proto(),
+                })
+                .await
+                .map(|response| response.env.into_iter().collect())
+                .unwrap_or_default();
+        }
+
         let task = self.load_shell_env_task.clone();
         task.await.unwrap_or_default()
     }
 
     #[cfg(not(target_os = "windows"))]
     async fn which(&self, command: &OsStr) -> Option<PathBuf> {
+        if let Some(upstream_client) = &self.upstream_client {
+            use rpc::proto::SSH_PROJECT_ID;
+
+            return upstream_client
+                .request(proto::WhichCommand {
+                    project_id: SSH_PROJECT_ID,
+                    worktree_id: self.worktree_id().to_proto(),
+                    command: command.to_string_lossy().to_string(),
+                })
+                .await
+                .log_err()
+                .and_then(|response| response.path)
+                .map(PathBuf::from);
+        }
+
         self.fs.as_ref()?;
+
         let worktree_abs_path = self.worktree.abs_path();
         let shell_path = self.shell_env().await.get("PATH").cloned();
         which::which_in(command, shell_path.as_ref(), worktree_abs_path).ok()

crates/proto/proto/zed.proto 🔗

@@ -283,7 +283,13 @@ message Envelope {
         CloseBuffer close_buffer = 245;
         UpdateUserSettings update_user_settings = 246;
 
-        CreateLanguageServer create_language_server = 247; // current max
+        CreateLanguageServer create_language_server = 247;
+
+        WhichCommand which_command = 248;
+        WhichCommandResponse which_command_response = 249;
+
+        ShellEnv shell_env = 250;
+        ShellEnvResponse shell_env_response = 251; // current max
     }
 
     reserved 158 to 161;
@@ -2503,6 +2509,7 @@ message UpdateUserSettings {
 message LanguageServerCommand {
    string path = 1;
    repeated string arguments = 2;
+   map<string, string> env = 3;
 }
 
 message AvailableLanguage {
@@ -2522,6 +2529,25 @@ message CreateLanguageServer {
     AvailableLanguage language = 7;
 }
 
+message WhichCommand {
+    uint64 project_id = 1;
+    uint64 worktree_id = 2;
+    string command = 3;
+}
+
+message WhichCommandResponse {
+    optional string path = 1;
+}
+
+message ShellEnv {
+    uint64 project_id = 1;
+    uint64 worktree_id = 2;
+}
+
+message ShellEnvResponse {
+    map<string, string> env = 1;
+}
+
 // message RestartLanguageServer {
 
 // }

crates/proto/src/proto.rs 🔗

@@ -367,7 +367,11 @@ messages!(
     (FindSearchCandidatesResponse, Background),
     (CloseBuffer, Foreground),
     (UpdateUserSettings, Foreground),
-    (CreateLanguageServer, Foreground)
+    (CreateLanguageServer, Foreground),
+    (WhichCommand, Foreground),
+    (WhichCommandResponse, Foreground),
+    (ShellEnv, Foreground),
+    (ShellEnvResponse, Foreground),
 );
 
 request_messages!(
@@ -491,7 +495,9 @@ request_messages!(
     (SynchronizeContexts, SynchronizeContextsResponse),
     (LspExtSwitchSourceHeader, LspExtSwitchSourceHeaderResponse),
     (AddWorktree, AddWorktreeResponse),
-    (CreateLanguageServer, Ack)
+    (CreateLanguageServer, Ack),
+    (WhichCommand, WhichCommandResponse),
+    (ShellEnv, ShellEnvResponse)
 );
 
 entity_messages!(
@@ -565,7 +571,9 @@ entity_messages!(
     SynchronizeContexts,
     LspExtSwitchSourceHeader,
     UpdateUserSettings,
-    CreateLanguageServer
+    CreateLanguageServer,
+    WhichCommand,
+    ShellEnv
 );
 
 entity_messages!(

crates/remote_server/src/headless_project.rs 🔗

@@ -89,6 +89,8 @@ impl HeadlessProject {
         client.add_model_message_handler(BufferStore::handle_close_buffer);
 
         client.add_model_request_handler(LspStore::handle_create_language_server);
+        client.add_model_request_handler(LspStore::handle_which_command);
+        client.add_model_request_handler(LspStore::handle_shell_env);
 
         BufferStore::init(&client);
         WorktreeStore::init(&client);