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

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.
This commit is contained in:
Piotr Osiewicz 2024-12-07 13:08:18 +01:00 committed by GitHub
parent 14ba4a9c94
commit f561a91daf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 537 additions and 106 deletions

View file

@ -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 {

View file

@ -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,11 +7756,9 @@ 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
@ -7556,11 +7771,10 @@ impl LanguageServerWatchedPathsBuilder {
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;
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| {
.update(&mut cx, |this, _| {
let Some(local) = this.as_local() else {
return ControlFlow::Break(());
};
@ -7570,16 +7784,7 @@ impl LanguageServerWatchedPathsBuilder {
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(
let (globs, _) = watcher.abs_paths.get(&abs_path).expect(
"Watched abs path is not registered with a watcher",
);
let matching_entries = update
@ -7610,7 +7815,6 @@ impl LanguageServerWatchedPathsBuilder {
worktree_paths: self.worktree_paths,
abs_paths,
}
})
}
}

View file

@ -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)
})
}

View file

@ -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

View file

@ -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(