fix(lsp): tell language servers about files changed outside the editor (#1135)

Language servers never got workspace/didChangeWatchedFiles, so files changed on disk outside the editor (checkout, pull, another tool) stayed unknown to them; jdtls's incremental build skipped them and Java / Spring Boot launches ran their old classes (e.g. another module of a multi-module project). The project's file watcher now forwards each batch to the running servers: created, changed and removed files of a server's languages under its root.

Refs #1015
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-11-04 09:16:32 +08:00
1 джерело 2f2d3a25ae
коміт 770178966f
3 files changed
+55

No files matched your search

+1
Переглянути файл
@@ -305,6 +305,7 @@ impl Host {
index.update(&changed, &batch.removed);
kotlin_bridge.files_changed(changed.iter().chain(&batch.removed));
lsp.project_files_changed(batch.created.iter().chain(&batch.removed));
lsp.watched_files_changed(&batch.created, &batch.modified, &batch.removed);
// Atomic saves by external tools surface as `created`.
documents.on_disk_changed(&changed);
// A framework's root marker (`angular.json`) created or deleted: its
+23
Переглянути файл
@@ -1973,6 +1973,29 @@ impl LspManager {
}
}
/// Files that changed on disk, outside the editor too (a checkout, another tool): each running
/// server hears of those of its languages under its root through `workspace/didChangeWatchedFiles`.
/// Without it jdtls's incremental build never sees them, so a launch runs stale classes.
pub fn watched_files_changed(&self, created: &[PathBuf], modified: &[PathBuf], removed: &[PathBuf]) {
let servers: Vec<Arc<Server>> = self.servers.lock().clone();
for s in servers {
let Some(run) = s.running() else { continue };
let languages = s.languages();
let changes: Vec<Value> = [(created, 1), (modified, 2), (removed, 3)]
.into_iter()
.flat_map(|(paths, kind)| paths.iter().map(move |p| (p, kind)))
.filter(|(p, _)| p.starts_with(&s.root) && languages.contains(&self.languages.detect(p, None)))
.map(|(p, kind)| json!({ "uri": uri::path_to_uri(p), "type": kind }))
.collect();
if changes.is_empty() {
continue;
}
if let Err(e) = run.peer.notify("workspace/didChangeWatchedFiles", json!({ "changes": changes })) {
tracing::debug!("{}: didChangeWatchedFiles: {e}", s.name);
}
}
}
/// Checks initialized capabilities, including dynamic registrations, before choosing a formatter.
pub async fn can_format(&self, doc: DocId) -> IdeResult<bool> {
let snap = self.snapshot(doc)?;
+31
Переглянути файл
@@ -405,6 +405,37 @@ async fn linked_projects_initialize_separate_language_server_roots() {
h.mgr.shutdown_all().await;
}
#[tokio::test(flavor = "multi_thread")]
async fn files_changed_on_disk_reach_the_servers_of_their_language() {
let h = harness(&[("mock", incremental_caps())]);
h.open("a.mk", "x");
h.wait_status("mock", ServerStatus::Ready).await;
let s = h.launcher.state("mock");
let (created, modified, removed) = (h.root.join("new.mk"), h.root.join("a.mk"), h.root.join("old.mk"));
// Other languages and files outside the server's root are left out.
let other = h.root.join("notes.txt");
let outside = tempfile::tempdir().unwrap().path().join("far.mk");
h.mgr.watched_files_changed(
&[created.clone(), other],
&[modified.clone(), outside],
std::slice::from_ref(&removed),
);
eventually(|| !s.notified("workspace/didChangeWatchedFiles").is_empty()).await;
assert_eq!(
s.notified("workspace/didChangeWatchedFiles")[0]["changes"],
json!([
{ "uri": uri_of(&created), "type": 1 },
{ "uri": uri_of(&modified), "type": 2 },
{ "uri": uri_of(&removed), "type": 3 },
])
);
// Nothing of its languages: no notification.
h.mgr.watched_files_changed(&[h.root.join("b.txt")], &[], &[]);
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
assert_eq!(s.notified("workspace/didChangeWatchedFiles").len(), 1);
h.mgr.shutdown_all().await;
}
#[tokio::test(flavor = "multi_thread")]
async fn initialize_and_incremental_sync() {
let h = harness(&[("mock", incremental_caps())]);