diff --git a/crates/ide-host/src/project.rs b/crates/ide-host/src/project.rs index 1fb6617a0..e63e8db8b 100644 --- a/crates/ide-host/src/project.rs +++ b/crates/ide-host/src/project.rs @@ -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 diff --git a/crates/ide-lsp/src/lib.rs b/crates/ide-lsp/src/lib.rs index 97a94fc2d..252b66bf3 100644 --- a/crates/ide-lsp/src/lib.rs +++ b/crates/ide-lsp/src/lib.rs @@ -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> = self.servers.lock().clone(); + for s in servers { + let Some(run) = s.running() else { continue }; + let languages = s.languages(); + let changes: Vec = [(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 { let snap = self.snapshot(doc)?; diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index c9889ae7c..93d9d3708 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -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())]);