lsp: Persist vtsls update imports on rename choice (#45105)

Gaauwe Rombouts created

Closes #35930

When a TypeScript file is renamed or moved, vtsls can automatically
update the imports in other files. It pops up a message with the option
to always automatically update imports. This choice would previously
only be remembered for the current session and would pop up again after
a restart.

Now we persist that choice to the vtsls LSP settings in Zed, so that it
remembers across editor sessions.

Release Notes:

- When renaming a TypeScript or JavaScript file, the selected option to
automatically update imports will now be remembered across editor
sessions.

Change summary

crates/language/src/language.rs | 20 ++++++++++++
crates/languages/src/vtsls.rs   | 57 ++++++++++++++++++++++++++++++++++
crates/project/src/lsp_store.rs | 11 ++++++
3 files changed, 87 insertions(+), 1 deletion(-)

Detailed changes

crates/language/src/language.rs 🔗

@@ -330,6 +330,10 @@ impl CachedLspAdapter {
             .cloned()
             .unwrap_or_else(|| language_name.lsp_id())
     }
+
+    pub fn process_prompt_response(&self, context: &PromptResponseContext, cx: &mut AsyncApp) {
+        self.adapter.process_prompt_response(context, cx)
+    }
 }
 
 /// [`LspAdapterDelegate`] allows [`LspAdapter]` implementations to interface with the application
@@ -355,6 +359,17 @@ pub trait LspAdapterDelegate: Send + Sync {
     async fn try_exec(&self, binary: LanguageServerBinary) -> Result<()>;
 }
 
+/// Context provided to LSP adapters when a user responds to a ShowMessageRequest prompt.
+/// This allows adapters to intercept preference selections (like "Always" or "Never")
+/// and potentially persist them to Zed's settings.
+#[derive(Debug, Clone)]
+pub struct PromptResponseContext {
+    /// The original message shown to the user
+    pub message: String,
+    /// The action (button) the user selected
+    pub selected_action: lsp::MessageActionItem,
+}
+
 #[async_trait(?Send)]
 pub trait LspAdapter: 'static + Send + Sync + DynLspInstaller {
     fn name(&self) -> LanguageServerName;
@@ -511,6 +526,11 @@ pub trait LspAdapter: 'static + Send + Sync + DynLspInstaller {
     fn is_extension(&self) -> bool {
         false
     }
+
+    /// Called when a user responds to a ShowMessageRequest from this language server.
+    /// This allows adapters to intercept preference selections (like "Always" or "Never")
+    /// for settings that should be persisted to Zed's settings file.
+    fn process_prompt_response(&self, _context: &PromptResponseContext, _cx: &mut AsyncApp) {}
 }
 
 pub trait LspInstaller {

crates/languages/src/vtsls.rs 🔗

@@ -2,13 +2,17 @@ use anyhow::Result;
 use async_trait::async_trait;
 use collections::HashMap;
 use gpui::AsyncApp;
-use language::{LanguageName, LspAdapter, LspAdapterDelegate, LspInstaller, Toolchain};
+use language::{
+    LanguageName, LspAdapter, LspAdapterDelegate, LspInstaller, PromptResponseContext, Toolchain,
+};
 use lsp::{CodeActionKind, LanguageServerBinary, LanguageServerName, Uri};
 use node_runtime::{NodeRuntime, VersionStrategy};
 use project::{Fs, lsp_store::language_server_settings};
 use regex::Regex;
 use semver::Version;
 use serde_json::Value;
+use serde_json::json;
+use settings::update_settings_file;
 use std::{
     ffi::OsString,
     path::{Path, PathBuf},
@@ -16,6 +20,11 @@ use std::{
 };
 use util::{ResultExt, maybe, merge_json_value_into};
 
+const ACTION_ALWAYS: &str = "Always";
+const ACTION_NEVER: &str = "Never";
+const UPDATE_IMPORTS_MESSAGE_PATTERN: &str = "Update imports for";
+const VTSLS_SERVER_NAME: &str = "vtsls";
+
 fn typescript_server_binary_arguments(server_path: &Path) -> Vec<OsString> {
     vec![server_path.into(), "--stdio".into()]
 }
@@ -302,6 +311,52 @@ impl LspAdapter for VtslsLspAdapter {
             (LanguageName::new_static("TSX"), "typescriptreact".into()),
         ])
     }
+
+    fn process_prompt_response(&self, context: &PromptResponseContext, cx: &mut AsyncApp) {
+        let selected_title = context.selected_action.title.as_str();
+        let is_preference_response =
+            selected_title == ACTION_ALWAYS || selected_title == ACTION_NEVER;
+        if !is_preference_response {
+            return;
+        }
+
+        if context.message.contains(UPDATE_IMPORTS_MESSAGE_PATTERN) {
+            let setting_value = match selected_title {
+                ACTION_ALWAYS => "always",
+                ACTION_NEVER => "never",
+                _ => return,
+            };
+
+            let settings = json!({
+                "typescript": {
+                    "updateImportsOnFileMove": {
+                        "enabled": setting_value
+                    }
+                },
+                "javascript": {
+                    "updateImportsOnFileMove": {
+                        "enabled": setting_value
+                    }
+                }
+            });
+
+            let _ = cx.update(|cx| {
+                update_settings_file(self.fs.clone(), cx, move |content, _| {
+                    let lsp_settings = content
+                        .project
+                        .lsp
+                        .entry(VTSLS_SERVER_NAME.into())
+                        .or_default();
+
+                    if let Some(existing) = &mut lsp_settings.settings {
+                        merge_json_value_into(settings, existing);
+                    } else {
+                        lsp_settings.settings = Some(settings);
+                    }
+                });
+            });
+        }
+    }
 }
 
 async fn get_cached_ts_server_binary(

crates/project/src/lsp_store.rs 🔗

@@ -1056,12 +1056,15 @@ impl LocalLspStore {
             .on_request::<lsp::request::ShowMessageRequest, _, _>({
                 let this = lsp_store.clone();
                 let name = name.to_string();
+                let adapter = adapter.clone();
                 move |params, cx| {
                     let this = this.clone();
                     let name = name.to_string();
+                    let adapter = adapter.clone();
                     let mut cx = cx.clone();
                     async move {
                         let actions = params.actions.unwrap_or_default();
+                        let message = params.message.clone();
                         let (tx, rx) = smol::channel::bounded(1);
                         let request = LanguageServerPromptRequest {
                             level: match params.typ {
@@ -1082,6 +1085,14 @@ impl LocalLspStore {
                             .is_ok();
                         if did_update {
                             let response = rx.recv().await.ok();
+                            if let Some(ref selected_action) = response {
+                                let context = language::PromptResponseContext {
+                                    message,
+                                    selected_action: selected_action.clone(),
+                                };
+                                adapter.process_prompt_response(&context, &mut cx)
+                            }
+
                             Ok(response)
                         } else {
                             Ok(None)