дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
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.
Цей коміт міститься в:
1 джерело
7c60737b69
коміт
2d20e5a203
8 files changed
+189
-1
No files matched your search
@@ -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<Value> = 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<Option<lsp::WorkspaceEdit>> =
|
||||
|
||||
@@ -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<Change>,
|
||||
}
|
||||
|
||||
/// 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<FileChanges> = 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());
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Заблокувати користувача