lsp: Add support for didRename/willRename LSP messages (#21651)

Piotr Osiewicz created

Closes #21564

Notably, RA will now rename module references if you change the source
file name via our project panel.

This PR is a tad bigger than necessary as I torn out the Model<> from
didSave watchers (I tried to reuse that code for the same purpose).
Release Notes:

- Added support for language server actions being executed on file
rename.

Change summary

crates/lsp/src/lsp.rs                |   6 
crates/project/src/lsp_store.rs      | 394 ++++++++++++++++++++++-------
crates/project/src/project.rs        |  42 ++
crates/project/src/project_tests.rs  | 135 ++++++++++
crates/project/src/worktree_store.rs |  68 ++++
5 files changed, 538 insertions(+), 107 deletions(-)

Detailed changes

crates/lsp/src/lsp.rs 🔗

@@ -638,6 +638,12 @@ impl LanguageServer {
                         snippet_edit_support: Some(true),
                         ..WorkspaceEditClientCapabilities::default()
                     }),
+                    file_operations: Some(WorkspaceFileOperationsClientCapabilities {
+                        dynamic_registration: Some(false),
+                        did_rename: Some(true),
+                        will_rename: Some(true),
+                        ..Default::default()
+                    }),
                     ..Default::default()
                 }),
                 text_document: Some(TextDocumentClientCapabilities {

crates/project/src/lsp_store.rs 🔗

@@ -23,10 +23,10 @@ use futures::{
     stream::FuturesUnordered,
     AsyncWriteExt, Future, FutureExt, StreamExt,
 };
-use globset::{Glob, GlobSet, GlobSetBuilder};
+use globset::{Glob, GlobBuilder, GlobMatcher, GlobSet, GlobSetBuilder};
 use gpui::{
-    AppContext, AsyncAppContext, Context, Entity, EventEmitter, Model, ModelContext, PromptLevel,
-    Task, WeakModel,
+    AppContext, AsyncAppContext, Entity, EventEmitter, Model, ModelContext, PromptLevel, Task,
+    WeakModel,
 };
 use http_client::HttpClient;
 use itertools::Itertools as _;
@@ -43,12 +43,13 @@ use language::{
     Unclipped,
 };
 use lsp::{
-    CodeActionKind, CompletionContext, DiagnosticSeverity, DiagnosticTag,
-    DidChangeWatchedFilesRegistrationOptions, Edit, FileSystemWatcher, InsertTextFormat,
-    LanguageServer, LanguageServerBinary, LanguageServerBinaryOptions, LanguageServerId,
-    LanguageServerName, LspRequestFuture, MessageActionItem, MessageType, OneOf,
-    ServerHealthStatus, ServerStatus, SymbolKind, TextEdit, Url, WorkDoneProgressCancelParams,
-    WorkspaceFolder,
+    notification::DidRenameFiles, CodeActionKind, CompletionContext, DiagnosticSeverity,
+    DiagnosticTag, DidChangeWatchedFilesRegistrationOptions, Edit, FileOperationFilter,
+    FileOperationPatternKind, FileOperationRegistrationOptions, FileRename, FileSystemWatcher,
+    InsertTextFormat, LanguageServer, LanguageServerBinary, LanguageServerBinaryOptions,
+    LanguageServerId, LanguageServerName, LspRequestFuture, MessageActionItem, MessageType, OneOf,
+    RenameFilesParams, ServerHealthStatus, ServerStatus, SymbolKind, TextEdit, Url,
+    WillRenameFiles, WorkDoneProgressCancelParams, WorkspaceFolder,
 };
 use node_runtime::read_package_installed_version;
 use parking_lot::{Mutex, RwLock};
@@ -139,7 +140,9 @@ pub struct LocalLspStore {
     pub language_servers: HashMap<LanguageServerId, LanguageServerState>,
     buffers_being_formatted: HashSet<BufferId>,
     last_workspace_edits_by_language_server: HashMap<LanguageServerId, ProjectTransaction>,
-    language_server_watched_paths: HashMap<LanguageServerId, Model<LanguageServerWatchedPaths>>,
+    language_server_watched_paths: HashMap<LanguageServerId, LanguageServerWatchedPaths>,
+    language_server_paths_watched_for_rename:
+        HashMap<LanguageServerId, RenamePathsWatchedForServer>,
     language_server_watcher_registrations:
         HashMap<LanguageServerId, HashMap<String, Vec<FileSystemWatcher>>>,
     supplementary_language_servers:
@@ -899,6 +902,7 @@ impl LspStore {
                 language_servers: Default::default(),
                 last_workspace_edits_by_language_server: Default::default(),
                 language_server_watched_paths: Default::default(),
+                language_server_paths_watched_for_rename: Default::default(),
                 language_server_watcher_registrations: Default::default(),
                 current_lsp_settings: ProjectSettings::get_global(cx).lsp.clone(),
                 buffers_being_formatted: Default::default(),
@@ -4332,6 +4336,112 @@ impl LspStore {
             .map(|(key, value)| (*key, value))
     }
 
+    pub(super) fn did_rename_entry(
+        &self,
+        worktree_id: WorktreeId,
+        old_path: &Path,
+        new_path: &Path,
+        is_dir: bool,
+    ) {
+        maybe!({
+            let local_store = self.as_local()?;
+
+            let old_uri = lsp::Url::from_file_path(old_path).ok().map(String::from)?;
+            let new_uri = lsp::Url::from_file_path(new_path).ok().map(String::from)?;
+
+            for language_server in self.language_servers_for_worktree(worktree_id) {
+                let Some(filter) = local_store
+                    .language_server_paths_watched_for_rename
+                    .get(&language_server.server_id())
+                else {
+                    continue;
+                };
+
+                if filter.should_send_did_rename(&old_uri, is_dir) {
+                    language_server
+                        .notify::<DidRenameFiles>(RenameFilesParams {
+                            files: vec![FileRename {
+                                old_uri: old_uri.clone(),
+                                new_uri: new_uri.clone(),
+                            }],
+                        })
+                        .log_err();
+                }
+            }
+            Some(())
+        });
+    }
+
+    pub(super) fn will_rename_entry(
+        this: WeakModel<Self>,
+        worktree_id: WorktreeId,
+        old_path: &Path,
+        new_path: &Path,
+        is_dir: bool,
+        cx: AsyncAppContext,
+    ) -> Task<()> {
+        let old_uri = lsp::Url::from_file_path(old_path).ok().map(String::from);
+        let new_uri = lsp::Url::from_file_path(new_path).ok().map(String::from);
+        cx.spawn(move |mut cx| async move {
+            let mut tasks = vec![];
+            this.update(&mut cx, |this, cx| {
+                let local_store = this.as_local()?;
+                let old_uri = old_uri?;
+                let new_uri = new_uri?;
+                for language_server in this.language_servers_for_worktree(worktree_id) {
+                    let Some(filter) = local_store
+                        .language_server_paths_watched_for_rename
+                        .get(&language_server.server_id())
+                    else {
+                        continue;
+                    };
+                    let Some(adapter) =
+                        this.language_server_adapter_for_id(language_server.server_id())
+                    else {
+                        continue;
+                    };
+                    if filter.should_send_will_rename(&old_uri, is_dir) {
+                        let apply_edit = cx.spawn({
+                            let old_uri = old_uri.clone();
+                            let new_uri = new_uri.clone();
+                            let language_server = language_server.clone();
+                            |this, mut cx| async move {
+                                let edit = language_server
+                                    .request::<WillRenameFiles>(RenameFilesParams {
+                                        files: vec![FileRename { old_uri, new_uri }],
+                                    })
+                                    .log_err()
+                                    .await
+                                    .flatten()?;
+
+                                Self::deserialize_workspace_edit(
+                                    this.upgrade()?,
+                                    edit,
+                                    false,
+                                    adapter.clone(),
+                                    language_server.clone(),
+                                    &mut cx,
+                                )
+                                .await
+                                .ok();
+                                Some(())
+                            }
+                        });
+                        tasks.push(apply_edit);
+                    }
+                }
+                Some(())
+            })
+            .ok()
+            .flatten();
+            for task in tasks {
+                // Await on tasks sequentially so that the order of application of edits is deterministic
+                // (at least with regards to the order of registration of language servers)
+                task.await;
+            }
+        })
+    }
+
     fn lsp_notify_abs_paths_changed(
         &mut self,
         server_id: LanguageServerId,
@@ -4369,6 +4479,32 @@ impl LspStore {
         language_server_id: LanguageServerId,
         cx: &mut ModelContext<Self>,
     ) {
+        let Some(watchers) = self.as_local().and_then(|local| {
+            local
+                .language_server_watcher_registrations
+                .get(&language_server_id)
+        }) else {
+            return;
+        };
+
+        let watch_builder =
+            self.rebuild_watched_paths_inner(language_server_id, watchers.values().flatten(), cx);
+        let Some(local_lsp_store) = self.as_local_mut() else {
+            return;
+        };
+        let watcher = watch_builder.build(local_lsp_store.fs.clone(), language_server_id, cx);
+        local_lsp_store
+            .language_server_watched_paths
+            .insert(language_server_id, watcher);
+
+        cx.notify();
+    }
+    fn rebuild_watched_paths_inner<'a>(
+        &'a self,
+        language_server_id: LanguageServerId,
+        watchers: impl Iterator<Item = &'a FileSystemWatcher>,
+        cx: &mut ModelContext<Self>,
+    ) -> LanguageServerWatchedPathsBuilder {
         let worktrees = self
             .worktree_store
             .read(cx)
@@ -4380,15 +4516,6 @@ impl LspStore {
             })
             .collect::<Vec<_>>();
 
-        let local_lsp_store = self.as_local_mut().unwrap();
-
-        let Some(watchers) = local_lsp_store
-            .language_server_watcher_registrations
-            .get(&language_server_id)
-        else {
-            return;
-        };
-
         let mut worktree_globs = HashMap::default();
         let mut abs_globs = HashMap::default();
         log::trace!(
@@ -4406,7 +4533,7 @@ impl LspStore {
                 pattern: String,
             },
         }
-        for watcher in watchers.values().flatten() {
+        for watcher in watchers {
             let mut found_host = false;
             for worktree in &worktrees {
                 let glob_is_inside_worktree = worktree.update(cx, |tree, _| {
@@ -4545,12 +4672,7 @@ impl LspStore {
                 watch_builder.watch_abs_path(abs_path, globset);
             }
         }
-        let watcher = watch_builder.build(local_lsp_store.fs.clone(), language_server_id, cx);
-        local_lsp_store
-            .language_server_watched_paths
-            .insert(language_server_id, watcher);
-
-        cx.notify();
+        watch_builder
     }
 
     pub fn language_server_for_id(&self, id: LanguageServerId) -> Option<Arc<LanguageServer>> {
@@ -6650,6 +6772,23 @@ impl LspStore {
                     simulate_disk_based_diagnostics_completion: None,
                 },
             );
+            if let Some(file_ops_caps) = language_server
+                .capabilities()
+                .workspace
+                .as_ref()
+                .and_then(|ws| ws.file_operations.as_ref())
+            {
+                let did_rename_caps = file_ops_caps.did_rename.as_ref();
+                let will_rename_caps = file_ops_caps.will_rename.as_ref();
+                if did_rename_caps.or(will_rename_caps).is_some() {
+                    let watcher = RenamePathsWatchedForServer::default()
+                        .with_did_rename_patterns(did_rename_caps)
+                        .with_will_rename_patterns(will_rename_caps);
+                    local
+                        .language_server_paths_watched_for_rename
+                        .insert(server_id, watcher);
+                }
+            }
         }
 
         self.language_server_statuses.insert(
@@ -7010,7 +7149,7 @@ impl LspStore {
                 if let Some(watched_paths) = local
                     .language_server_watched_paths
                     .get(server_id)
-                    .and_then(|paths| paths.read(cx).worktree_paths.get(&worktree_id))
+                    .and_then(|paths| paths.worktree_paths.get(&worktree_id))
                 {
                     let params = lsp::DidChangeWatchedFilesParams {
                         changes: changes
@@ -7115,7 +7254,7 @@ impl LspStore {
         Ok(transaction)
     }
 
-    pub async fn deserialize_workspace_edit(
+    pub(crate) async fn deserialize_workspace_edit(
         this: Model<Self>,
         edit: lsp::WorkspaceEdit,
         push_to_history: bool,
@@ -7515,6 +7654,84 @@ pub enum LanguageServerToQuery {
     Other(LanguageServerId),
 }
 
+#[derive(Default)]
+struct RenamePathsWatchedForServer {
+    did_rename: Vec<RenameActionPredicate>,
+    will_rename: Vec<RenameActionPredicate>,
+}
+
+impl RenamePathsWatchedForServer {
+    fn with_did_rename_patterns(
+        mut self,
+        did_rename: Option<&FileOperationRegistrationOptions>,
+    ) -> Self {
+        if let Some(did_rename) = did_rename {
+            self.did_rename = did_rename
+                .filters
+                .iter()
+                .filter_map(|filter| filter.try_into().log_err())
+                .collect();
+        }
+        self
+    }
+    fn with_will_rename_patterns(
+        mut self,
+        will_rename: Option<&FileOperationRegistrationOptions>,
+    ) -> Self {
+        if let Some(will_rename) = will_rename {
+            self.will_rename = will_rename
+                .filters
+                .iter()
+                .filter_map(|filter| filter.try_into().log_err())
+                .collect();
+        }
+        self
+    }
+
+    fn should_send_did_rename(&self, path: &str, is_dir: bool) -> bool {
+        self.did_rename.iter().any(|pred| pred.eval(path, is_dir))
+    }
+    fn should_send_will_rename(&self, path: &str, is_dir: bool) -> bool {
+        self.will_rename.iter().any(|pred| pred.eval(path, is_dir))
+    }
+}
+
+impl TryFrom<&FileOperationFilter> for RenameActionPredicate {
+    type Error = globset::Error;
+    fn try_from(ops: &FileOperationFilter) -> Result<Self, globset::Error> {
+        Ok(Self {
+            kind: ops.pattern.matches.clone(),
+            glob: GlobBuilder::new(&ops.pattern.glob)
+                .case_insensitive(
+                    ops.pattern
+                        .options
+                        .as_ref()
+                        .map_or(false, |ops| ops.ignore_case.unwrap_or(false)),
+                )
+                .build()?
+                .compile_matcher(),
+        })
+    }
+}
+struct RenameActionPredicate {
+    glob: GlobMatcher,
+    kind: Option<FileOperationPatternKind>,
+}
+
+impl RenameActionPredicate {
+    // Returns true if language server should be notified
+    fn eval(&self, path: &str, is_dir: bool) -> bool {
+        self.kind.as_ref().map_or(true, |kind| {
+            let expected_kind = if is_dir {
+                FileOperationPatternKind::Folder
+            } else {
+                FileOperationPatternKind::File
+            };
+            kind == &expected_kind
+        }) && self.glob.is_match(path)
+    }
+}
+
 #[derive(Default)]
 struct LanguageServerWatchedPaths {
     worktree_paths: HashMap<WorktreeId, GlobSet>,
@@ -7539,78 +7756,65 @@ impl LanguageServerWatchedPathsBuilder {
         fs: Arc<dyn Fs>,
         language_server_id: LanguageServerId,
         cx: &mut ModelContext<LspStore>,
-    ) -> Model<LanguageServerWatchedPaths> {
+    ) -> LanguageServerWatchedPaths {
         let project = cx.weak_model();
 
-        cx.new_model(|cx| {
-                let this_id = cx.entity_id();
-                const LSP_ABS_PATH_OBSERVE: Duration = Duration::from_millis(100);
-                let abs_paths = self
-                    .abs_paths
-                    .into_iter()
-                    .map(|(abs_path, globset)| {
-                        let task = cx.spawn({
-                            let abs_path = abs_path.clone();
-                            let fs = fs.clone();
-
-                            let lsp_store = project.clone();
-                            |_, mut cx| async move {
-                                maybe!(async move {
-                                    let mut push_updates =
-                                        fs.watch(&abs_path, LSP_ABS_PATH_OBSERVE).await;
-                                    while let Some(update) = push_updates.0.next().await {
-                                        let action = lsp_store
-                                            .update(&mut cx, |this, cx| {
-                                                let Some(local) = this.as_local() else {
-                                                    return ControlFlow::Break(());
-                                                };
-                                                let Some(watcher) = local
-                                                    .language_server_watched_paths
-                                                    .get(&language_server_id)
-                                                else {
-                                                    return ControlFlow::Break(());
-                                                };
-                                                if watcher.entity_id() != this_id {
-                                                    // This watcher is no longer registered on the project, which means that we should
-                                                    // cease operations.
-                                                    return ControlFlow::Break(());
-                                                }
-                                                let (globs, _) = watcher
-                                                    .read(cx)
-                                                    .abs_paths
-                                                    .get(&abs_path)
-                                                    .expect(
-                                                    "Watched abs path is not registered with a watcher",
-                                                );
-                                                let matching_entries = update
-                                                    .into_iter()
-                                                    .filter(|event| globs.is_match(&event.path))
-                                                    .collect::<Vec<_>>();
-                                                this.lsp_notify_abs_paths_changed(
-                                                    language_server_id,
-                                                    matching_entries,
-                                                );
-                                                ControlFlow::Continue(())
-                                            })
-                                            .ok()?;
+        const LSP_ABS_PATH_OBSERVE: Duration = Duration::from_millis(100);
+        let abs_paths = self
+            .abs_paths
+            .into_iter()
+            .map(|(abs_path, globset)| {
+                let task = cx.spawn({
+                    let abs_path = abs_path.clone();
+                    let fs = fs.clone();
+
+                    let lsp_store = project.clone();
+                    |_, mut cx| async move {
+                        maybe!(async move {
+                            let mut push_updates = fs.watch(&abs_path, LSP_ABS_PATH_OBSERVE).await;
+                            while let Some(update) = push_updates.0.next().await {
+                                let action = lsp_store
+                                    .update(&mut cx, |this, _| {
+                                        let Some(local) = this.as_local() else {
+                                            return ControlFlow::Break(());
+                                        };
+                                        let Some(watcher) = local
+                                            .language_server_watched_paths
+                                            .get(&language_server_id)
+                                        else {
+                                            return ControlFlow::Break(());
+                                        };
+                                        let (globs, _) = watcher.abs_paths.get(&abs_path).expect(
+                                            "Watched abs path is not registered with a watcher",
+                                        );
+                                        let matching_entries = update
+                                            .into_iter()
+                                            .filter(|event| globs.is_match(&event.path))
+                                            .collect::<Vec<_>>();
+                                        this.lsp_notify_abs_paths_changed(
+                                            language_server_id,
+                                            matching_entries,
+                                        );
+                                        ControlFlow::Continue(())
+                                    })
+                                    .ok()?;
 
-                                        if action.is_break() {
-                                            break;
-                                        }
-                                    }
-                                    Some(())
-                                })
-                                    .await;
+                                if action.is_break() {
+                                    break;
+                                }
                             }
-                        });
-                        (abs_path, (globset, task))
-                    })
-                    .collect();
-                LanguageServerWatchedPaths {
-                    worktree_paths: self.worktree_paths,
-                    abs_paths,
-                }
+                            Some(())
+                        })
+                        .await;
+                    }
+                });
+                (abs_path, (globset, task))
             })
+            .collect();
+        LanguageServerWatchedPaths {
+            worktree_paths: self.worktree_paths,
+            abs_paths,
+        }
     }
 }
 

crates/project/src/project.rs 🔗

@@ -583,6 +583,8 @@ impl Project {
         client.add_model_request_handler(Self::handle_open_new_buffer);
         client.add_model_message_handler(Self::handle_create_buffer_for_peer);
 
+        client.add_model_request_handler(WorktreeStore::handle_rename_project_entry);
+
         WorktreeStore::init(&client);
         BufferStore::init(&client);
         LspStore::init(&client);
@@ -1489,11 +1491,45 @@ impl Project {
         new_path: impl Into<Arc<Path>>,
         cx: &mut ModelContext<Self>,
     ) -> Task<Result<CreatedEntry>> {
-        let Some(worktree) = self.worktree_for_entry(entry_id, cx) else {
+        let worktree_store = self.worktree_store.read(cx);
+        let new_path = new_path.into();
+        let Some((worktree, old_path, is_dir)) = worktree_store
+            .worktree_and_entry_for_id(entry_id, cx)
+            .map(|(worktree, entry)| (worktree, entry.path.clone(), entry.is_dir()))
+        else {
             return Task::ready(Err(anyhow!(format!("No worktree for entry {entry_id:?}"))));
         };
-        worktree.update(cx, |worktree, cx| {
-            worktree.rename_entry(entry_id, new_path, cx)
+
+        let worktree_id = worktree.read(cx).id();
+
+        let lsp_store = self.lsp_store().downgrade();
+        cx.spawn(|_, mut cx| async move {
+            let (old_abs_path, new_abs_path) = {
+                let root_path = worktree.update(&mut cx, |this, _| this.abs_path())?;
+                (root_path.join(&old_path), root_path.join(&new_path))
+            };
+            LspStore::will_rename_entry(
+                lsp_store.clone(),
+                worktree_id,
+                &old_abs_path,
+                &new_abs_path,
+                is_dir,
+                cx.clone(),
+            )
+            .await;
+
+            let entry = worktree
+                .update(&mut cx, |worktree, cx| {
+                    worktree.rename_entry(entry_id, new_path.clone(), cx)
+                })?
+                .await?;
+
+            lsp_store
+                .update(&mut cx, |this, _| {
+                    this.did_rename_entry(worktree_id, &old_abs_path, &new_abs_path, is_dir);
+                })
+                .ok();
+            Ok(entry)
         })
     }
 

crates/project/src/project_tests.rs 🔗

@@ -9,12 +9,16 @@ use language::{
     tree_sitter_rust, tree_sitter_typescript, Diagnostic, DiagnosticSet, DiskState, FakeLspAdapter,
     LanguageConfig, LanguageMatcher, LanguageName, LineEnding, OffsetRangeExt, Point, ToPoint,
 };
-use lsp::{DiagnosticSeverity, NumberOrString};
+use lsp::{
+    notification::DidRenameFiles, DiagnosticSeverity, DocumentChanges, FileOperationFilter,
+    NumberOrString, TextDocumentEdit, WillRenameFiles,
+};
 use parking_lot::Mutex;
 use pretty_assertions::{assert_eq, assert_matches};
 use serde_json::json;
 #[cfg(not(windows))]
 use std::os;
+use std::{str::FromStr, sync::OnceLock};
 
 use std::{mem, num::NonZeroU32, ops::Range, task::Poll};
 use task::{ResolvedTask, TaskContext};
@@ -3915,6 +3919,135 @@ async fn test_grouped_diagnostics(cx: &mut gpui::TestAppContext) {
     );
 }
 
+#[gpui::test]
+async fn test_lsp_rename_notifications(cx: &mut gpui::TestAppContext) {
+    init_test(cx);
+
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        "/dir",
+        json!({
+            "one.rs": "const ONE: usize = 1;",
+            "two": {
+                "two.rs": "const TWO: usize = one::ONE + one::ONE;"
+            }
+
+        }),
+    )
+    .await;
+    let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
+
+    let language_registry = project.read_with(cx, |project, _| project.languages().clone());
+    language_registry.add(rust_lang());
+    let watched_paths = lsp::FileOperationRegistrationOptions {
+        filters: vec![
+            FileOperationFilter {
+                scheme: Some("file".to_owned()),
+                pattern: lsp::FileOperationPattern {
+                    glob: "**/*.rs".to_owned(),
+                    matches: Some(lsp::FileOperationPatternKind::File),
+                    options: None,
+                },
+            },
+            FileOperationFilter {
+                scheme: Some("file".to_owned()),
+                pattern: lsp::FileOperationPattern {
+                    glob: "**/**".to_owned(),
+                    matches: Some(lsp::FileOperationPatternKind::Folder),
+                    options: None,
+                },
+            },
+        ],
+    };
+    let mut fake_servers = language_registry.register_fake_lsp(
+        "Rust",
+        FakeLspAdapter {
+            capabilities: lsp::ServerCapabilities {
+                workspace: Some(lsp::WorkspaceServerCapabilities {
+                    workspace_folders: None,
+                    file_operations: Some(lsp::WorkspaceFileOperationsServerCapabilities {
+                        did_rename: Some(watched_paths.clone()),
+                        will_rename: Some(watched_paths),
+                        ..Default::default()
+                    }),
+                }),
+                ..Default::default()
+            },
+            ..Default::default()
+        },
+    );
+
+    let _ = project
+        .update(cx, |project, cx| {
+            project.open_local_buffer("/dir/one.rs", cx)
+        })
+        .await
+        .unwrap();
+
+    let fake_server = fake_servers.next().await.unwrap();
+    let response = project.update(cx, |project, cx| {
+        let worktree = project.worktrees(cx).next().unwrap();
+        let entry = worktree.read(cx).entry_for_path("one.rs").unwrap();
+        project.rename_entry(entry.id, "three.rs".as_ref(), cx)
+    });
+    let expected_edit = lsp::WorkspaceEdit {
+        changes: None,
+        document_changes: Some(DocumentChanges::Edits({
+            vec![TextDocumentEdit {
+                edits: vec![lsp::Edit::Plain(lsp::TextEdit {
+                    range: lsp::Range {
+                        start: lsp::Position {
+                            line: 0,
+                            character: 1,
+                        },
+                        end: lsp::Position {
+                            line: 0,
+                            character: 3,
+                        },
+                    },
+                    new_text: "This is not a drill".to_owned(),
+                })],
+                text_document: lsp::OptionalVersionedTextDocumentIdentifier {
+                    uri: Url::from_str("file:///dir/two/two.rs").unwrap(),
+                    version: Some(1337),
+                },
+            }]
+        })),
+        change_annotations: None,
+    };
+    let resolved_workspace_edit = Arc::new(OnceLock::new());
+    fake_server
+        .handle_request::<WillRenameFiles, _, _>({
+            let resolved_workspace_edit = resolved_workspace_edit.clone();
+            let expected_edit = expected_edit.clone();
+            move |params, _| {
+                let resolved_workspace_edit = resolved_workspace_edit.clone();
+                let expected_edit = expected_edit.clone();
+                async move {
+                    assert_eq!(params.files.len(), 1);
+                    assert_eq!(params.files[0].old_uri, "file:///dir/one.rs");
+                    assert_eq!(params.files[0].new_uri, "file:///dir/three.rs");
+                    resolved_workspace_edit.set(expected_edit.clone()).unwrap();
+                    Ok(Some(expected_edit))
+                }
+            }
+        })
+        .next()
+        .await
+        .unwrap();
+    let _ = response.await.unwrap();
+    fake_server
+        .handle_notification::<DidRenameFiles, _>(|params, _| {
+            assert_eq!(params.files.len(), 1);
+            assert_eq!(params.files[0].old_uri, "file:///dir/one.rs");
+            assert_eq!(params.files[0].new_uri, "file:///dir/three.rs");
+        })
+        .next()
+        .await
+        .unwrap();
+    assert_eq!(resolved_workspace_edit.get(), Some(&expected_edit));
+}
+
 #[gpui::test]
 async fn test_rename(cx: &mut gpui::TestAppContext) {
     // hi

crates/project/src/worktree_store.rs 🔗

@@ -26,7 +26,7 @@ use text::ReplicaId;
 use util::{paths::SanitizedPath, ResultExt};
 use worktree::{Entry, ProjectEntryId, Worktree, WorktreeId, WorktreeSettings};
 
-use crate::{search::SearchQuery, ProjectPath};
+use crate::{search::SearchQuery, LspStore, ProjectPath};
 
 struct MatchingEntry {
     worktree_path: Arc<Path>,
@@ -69,7 +69,6 @@ impl EventEmitter<WorktreeStoreEvent> for WorktreeStore {}
 impl WorktreeStore {
     pub fn init(client: &AnyProtoClient) {
         client.add_model_request_handler(Self::handle_create_project_entry);
-        client.add_model_request_handler(Self::handle_rename_project_entry);
         client.add_model_request_handler(Self::handle_copy_project_entry);
         client.add_model_request_handler(Self::handle_delete_project_entry);
         client.add_model_request_handler(Self::handle_expand_project_entry);
@@ -184,6 +183,19 @@ impl WorktreeStore {
             .find_map(|worktree| worktree.read(cx).entry_for_id(entry_id))
     }
 
+    pub fn worktree_and_entry_for_id<'a>(
+        &'a self,
+        entry_id: ProjectEntryId,
+        cx: &'a AppContext,
+    ) -> Option<(Model<Worktree>, &'a Entry)> {
+        self.worktrees().find_map(|worktree| {
+            worktree
+                .read(cx)
+                .entry_for_id(entry_id)
+                .map(|e| (worktree.clone(), e))
+        })
+    }
+
     pub fn entry_for_path(&self, path: &ProjectPath, cx: &AppContext) -> Option<Entry> {
         self.worktree_for_id(path.worktree_id, cx)?
             .read(cx)
@@ -1004,16 +1016,56 @@ impl WorktreeStore {
     }
 
     pub async fn handle_rename_project_entry(
-        this: Model<Self>,
+        this: Model<super::Project>,
         envelope: TypedEnvelope<proto::RenameProjectEntry>,
         mut cx: AsyncAppContext,
     ) -> Result<proto::ProjectEntryResponse> {
         let entry_id = ProjectEntryId::from_proto(envelope.payload.entry_id);
-        let worktree = this.update(&mut cx, |this, cx| {
-            this.worktree_for_entry(entry_id, cx)
-                .ok_or_else(|| anyhow!("worktree not found"))
-        })??;
-        Worktree::handle_rename_entry(worktree, envelope.payload, cx).await
+        let (worktree_id, worktree, old_path, is_dir) = this
+            .update(&mut cx, |this, cx| {
+                this.worktree_store
+                    .read(cx)
+                    .worktree_and_entry_for_id(entry_id, cx)
+                    .map(|(worktree, entry)| {
+                        (
+                            worktree.read(cx).id(),
+                            worktree,
+                            entry.path.clone(),
+                            entry.is_dir(),
+                        )
+                    })
+            })?
+            .ok_or_else(|| anyhow!("worktree not found"))?;
+        let (old_abs_path, new_abs_path) = {
+            let root_path = worktree.update(&mut cx, |this, _| this.abs_path())?;
+            (
+                root_path.join(&old_path),
+                root_path.join(&envelope.payload.new_path),
+            )
+        };
+        let lsp_store = this
+            .update(&mut cx, |this, _| this.lsp_store())?
+            .downgrade();
+        LspStore::will_rename_entry(
+            lsp_store,
+            worktree_id,
+            &old_abs_path,
+            &new_abs_path,
+            is_dir,
+            cx.clone(),
+        )
+        .await;
+        let response = Worktree::handle_rename_entry(worktree, envelope.payload, cx.clone()).await;
+        this.update(&mut cx, |this, cx| {
+            this.lsp_store().read(cx).did_rename_entry(
+                worktree_id,
+                &old_abs_path,
+                &new_abs_path,
+                is_dir,
+            );
+        })
+        .ok();
+        response
     }
 
     pub async fn handle_copy_project_entry(