From 2d20e5a2034bd7593d20c32399e7b7c6726bf605 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Thu, 5 Nov 2026 02:26:28 +0800 Subject: [PATCH] fix(lsp): update TypeScript imports when files move (#1346) vtsls has no workspace/willRenameFiles: after didRenameFiles it updates imports itself, behind a prompt sent as window/showMessageRequest. The IDE shows that request as a message without choices, so moving a TypeScript or JavaScript file never updated the imports of the files that use it. For a tsserver-backed server without willRename filters, the IDE now asks tsserver for the edits up front (getEditsForFileRename through typescript.tsserverRequest). The edits join the move's preview and undo like any server's. The server's own update is turned off (updateImportsOnFileMove.enabled = "never"), so it no longer prompts. --- crates/ide-lsp/src/lib.rs | 26 ++++++++- crates/ide-lsp/src/ts_file_moves.rs | 89 +++++++++++++++++++++++++++++ crates/ide-lsp/tests/mock_server.rs | 35 ++++++++++++ languages/javascript.toml | 8 +++ languages/javascriptreact.toml | 8 +++ languages/typescript.toml | 8 +++ languages/typescriptreact.toml | 8 +++ languages/vue.toml | 8 +++ 8 files changed, 189 insertions(+), 1 deletion(-) create mode 100644 crates/ide-lsp/src/ts_file_moves.rs diff --git a/crates/ide-lsp/src/lib.rs b/crates/ide-lsp/src/lib.rs index dfa2ef9b..2ace34ad 100644 --- a/crates/ide-lsp/src/lib.rs +++ b/crates/ide-lsp/src/lib.rs @@ -23,6 +23,7 @@ mod scope; mod server; mod tcp; mod text; +mod ts_file_moves; mod tsserver_bridge; pub mod types; mod uri; @@ -2010,7 +2011,30 @@ impl LspManager { servers.iter().zip(ready).filter_map(|(s, run)| Some(Target { server: s.clone(), run: run? })).collect(); let mut out = FileRenameEdits::default(); for t in targets { - let Some(params) = Self::file_op_params(&t, "willRename", &moves) else { continue }; + let Some(params) = Self::file_op_params(&t, "willRename", &moves) else { + if t.server.config.tsserver_project && t.caps().commands().iter().any(|c| c == ts_file_moves::COMMAND) { + out.servers.push(t.server.name.clone()); + let opened = self.open_for_file_op(&t, &moves); + for (from, to, _) in &moves { + let r: IdeResult = self + .call( + &t, + "workspace/executeCommand", + ts_file_moves::request(from, to), + Some(&cancel), + LONG_TIMEOUT, + ) + .await; + match r { + Ok(result) => out.edit.files.extend(ts_file_moves::edit(&result).files), + Err(e) if e.kind == ErrorKind::Cancelled && cancel.is_cancelled() => return Err(e), + Err(e) => out.errors.push(e.message), + } + } + drop(opened); + } + continue; + }; out.servers.push(t.server.name.clone()); let opened = self.open_for_file_op(&t, &moves); let r: IdeResult> = diff --git a/crates/ide-lsp/src/ts_file_moves.rs b/crates/ide-lsp/src/ts_file_moves.rs new file mode 100644 index 00000000..d46ed7c3 --- /dev/null +++ b/crates/ide-lsp/src/ts_file_moves.rs @@ -0,0 +1,89 @@ +//! Reference updates for moved files from vtsls, which has no `workspace/willRenameFiles`: +//! it updates imports itself after `didRenameFiles`, behind a prompt the client answers. The +//! IDE asks tsserver for the same edits up front (`getEditsForFileRename`, through +//! `typescript.tsserverRequest`), so they join the move's preview and undo, and the server's +//! own update is off (`updateImportsOnFileMove.enabled = "never"` in `languages/*.toml`). + +use ide_core::{FileEdit, Pos, Range, TextEdit, WorkspaceEdit}; +use serde::Deserialize; +use serde_json::{Value, json}; +use std::path::PathBuf; + +pub(crate) const COMMAND: &str = "typescript.tsserverRequest"; + +/// The `typescript.tsserverRequest` arguments asking for the edits of moving `from` to `to`. +pub(crate) fn request(from: &std::path::Path, to: &std::path::Path) -> Value { + json!({ + "command": COMMAND, + "arguments": ["getEditsForFileRename", { "oldFilePath": from, "newFilePath": to }], + }) +} + +#[derive(Deserialize)] +struct Location { + line: u32, + offset: u32, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct Change { + start: Location, + end: Location, + new_text: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct FileChanges { + file_name: PathBuf, + text_changes: Vec, +} + +/// tsserver's `FileCodeEdits[]` (the command's `body`; 1-based lines and UTF-16 offsets) as an edit. +pub(crate) fn edit(result: &Value) -> WorkspaceEdit { + let body = result.get("body").unwrap_or(result); + let files: Vec = serde_json::from_value(body.clone()).unwrap_or_default(); + let pos = |l: &Location| Pos::new(l.line.saturating_sub(1), l.offset.saturating_sub(1)); + WorkspaceEdit { + files: files + .into_iter() + .filter(|f| !f.text_changes.is_empty()) + .map(|f| FileEdit::Edit { + path: f.file_name.into(), + edits: f + .text_changes + .into_iter() + .map(|c| TextEdit { range: Range::new(pos(&c.start), pos(&c.end)), new_text: c.new_text }) + .collect(), + version: None, + }) + .collect(), + preconditions: Vec::new(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn converts_tsserver_file_code_edits() { + let result = json!({ "success": true, "body": [ + { "fileName": "/p/src/index.ts", "textChanges": [ + { "start": { "line": 2, "offset": 31 }, "end": { "line": 2, "offset": 41 }, "newText": "./lib/money.ts" } + ] }, + { "fileName": "/p/src/unchanged.ts", "textChanges": [] } + ] }); + let edit = edit(&result); + assert_eq!(edit.files.len(), 1); + let FileEdit::Edit { path, edits, version } = &edit.files[0] else { panic!("{edit:?}") }; + assert_eq!(**path, *std::path::Path::new("/p/src/index.ts")); + assert_eq!( + edits, + &[TextEdit { range: Range::new(Pos::new(1, 30), Pos::new(1, 40)), new_text: "./lib/money.ts".into() }] + ); + assert!(version.is_none()); + assert!(super::edit(&json!({ "success": false })).files.is_empty()); + } +} diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index 6475c872..274e1ba7 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -2336,6 +2336,41 @@ async fn will_and_did_rename_files_honour_filters() { ); } +#[tokio::test(flavor = "multi_thread")] +async fn will_rename_files_asks_tsserver_when_the_server_has_no_file_operations() { + // vtsls: no `willRename` filters, but tsserver's `getEditsForFileRename` through its command. + let mut caps = incremental_caps(); + caps["executeCommandProvider"] = json!({ "commands": ["typescript.tsserverRequest"] }); + let h = harness_configured("typescript", "ts", &[("vtsls", caps)], |cfg| cfg.tsserver_project = true); + let money = h.root.join("money.ts"); + let index = h.root.join("index.ts"); + std::fs::write(&money, "export const one = 1;\n").unwrap(); + std::fs::write(&index, "import { one } from \"./money\";\n").unwrap(); + let s = h.launcher.state("vtsls"); + *s.responder.lock() = Some(Arc::new(move |_, method, p| { + (method == "workspace/executeCommand" && p["arguments"][0] == "getEditsForFileRename").then(|| { + json!({ "success": true, "body": [{ "fileName": p["arguments"][1]["oldFilePath"].as_str().unwrap().replace("money.ts", "index.ts"), + "textChanges": [{ "start": { "line": 1, "offset": 22 }, "end": { "line": 1, "offset": 29 }, "newText": "./lib/money" }] }] }) + }) + })); + let to = h.root.join("lib/money.ts"); + let result = + h.mgr.will_rename_files(&[(money.clone(), to.clone())], ide_core::CancellationToken::new()).await.unwrap(); + assert_eq!(result.servers, vec!["vtsls".to_owned()]); + assert!(result.errors.is_empty(), "{:?}", result.errors); + match &result.edit.files[..] { + [FileEdit::Edit { path, edits, version: None }] => { + assert_eq!(path, &index); + assert_eq!(edits[0].range, rng(0, 21, 0, 28)); + assert_eq!(edits[0].new_text, "./lib/money"); + } + other => panic!("unexpected edit {other:?}"), + } + let asked = s.requested("workspace/executeCommand"); + let asked = asked.iter().find(|r| r["arguments"][0] == "getEditsForFileRename").unwrap(); + assert_eq!(asked["arguments"][1], json!({ "oldFilePath": money, "newFilePath": to })); +} + // ------------------------------------------------------------------ inspect code struct InspectTestHost { diff --git a/languages/javascript.toml b/languages/javascript.toml index 8d31fefe..70954b50 100644 --- a/languages/javascript.toml +++ b/languages/javascript.toml @@ -24,6 +24,14 @@ useAliasesForRenames = false [language-servers.initialization-options.javascript.preferences] useAliasesForRenames = false +# Moving a file: the IDE asks for the import updates up front (preview, undo); the server +# doesn't prompt after the move. +[language-servers.initialization-options.typescript.updateImportsOnFileMove] +enabled = "never" + +[language-servers.initialization-options.javascript.updateImportsOnFileMove] +enabled = "never" + [[language-servers]] name = "typescript-language-server" tsserver-project = true diff --git a/languages/javascriptreact.toml b/languages/javascriptreact.toml index d3c6c57d..cdab8c31 100644 --- a/languages/javascriptreact.toml +++ b/languages/javascriptreact.toml @@ -23,6 +23,14 @@ useAliasesForRenames = false [language-servers.initialization-options.javascript.preferences] useAliasesForRenames = false +# Moving a file: the IDE asks for the import updates up front (preview, undo); the server +# doesn't prompt after the move. +[language-servers.initialization-options.typescript.updateImportsOnFileMove] +enabled = "never" + +[language-servers.initialization-options.javascript.updateImportsOnFileMove] +enabled = "never" + [[language-servers]] name = "typescript-language-server" tsserver-project = true diff --git a/languages/typescript.toml b/languages/typescript.toml index c0da4595..951c0922 100644 --- a/languages/typescript.toml +++ b/languages/typescript.toml @@ -23,6 +23,14 @@ useAliasesForRenames = false [language-servers.initialization-options.javascript.preferences] useAliasesForRenames = false +# Moving a file: the IDE asks for the import updates up front (preview, undo); the server +# doesn't prompt after the move. +[language-servers.initialization-options.typescript.updateImportsOnFileMove] +enabled = "never" + +[language-servers.initialization-options.javascript.updateImportsOnFileMove] +enabled = "never" + [[language-servers]] name = "typescript-language-server" tsserver-project = true diff --git a/languages/typescriptreact.toml b/languages/typescriptreact.toml index 82a1a5bc..3d4b1377 100644 --- a/languages/typescriptreact.toml +++ b/languages/typescriptreact.toml @@ -23,6 +23,14 @@ useAliasesForRenames = false [language-servers.initialization-options.javascript.preferences] useAliasesForRenames = false +# Moving a file: the IDE asks for the import updates up front (preview, undo); the server +# doesn't prompt after the move. +[language-servers.initialization-options.typescript.updateImportsOnFileMove] +enabled = "never" + +[language-servers.initialization-options.javascript.updateImportsOnFileMove] +enabled = "never" + [[language-servers]] name = "typescript-language-server" tsserver-project = true diff --git a/languages/vue.toml b/languages/vue.toml index 2f1b2d73..e47d985a 100644 --- a/languages/vue.toml +++ b/languages/vue.toml @@ -30,6 +30,14 @@ useAliasesForRenames = false [language-servers.initialization-options.javascript.preferences] useAliasesForRenames = false +# Moving a file: the IDE asks for the import updates up front (preview, undo); the server +# doesn't prompt after the move. +[language-servers.initialization-options.typescript.updateImportsOnFileMove] +enabled = "never" + +[language-servers.initialization-options.javascript.updateImportsOnFileMove] +enabled = "never" + [[language-servers]] name = "oxlint" command = "oxlint"