Implement copy paste for ProjectPanel

Antonio Scandurra created

Change summary

crates/collab/src/rpc.rs                  |   1 
crates/project/src/fs.rs                  |  70 +++++++++++++
crates/project/src/project.rs             |  72 ++++++++++++++
crates/project/src/worktree.rs            |  40 +++++++
crates/project_panel/src/project_panel.rs |   5 
crates/rpc/proto/zed.proto                | 127 +++++++++++++-----------
crates/rpc/src/proto.rs                   |   5 
7 files changed, 259 insertions(+), 61 deletions(-)

Detailed changes

crates/collab/src/rpc.rs 🔗

@@ -172,6 +172,7 @@ impl Server {
             .add_request_handler(Server::forward_project_request::<proto::FormatBuffers>)
             .add_request_handler(Server::forward_project_request::<proto::CreateProjectEntry>)
             .add_request_handler(Server::forward_project_request::<proto::RenameProjectEntry>)
+            .add_request_handler(Server::forward_project_request::<proto::CopyProjectEntry>)
             .add_request_handler(Server::forward_project_request::<proto::DeleteProjectEntry>)
             .add_request_handler(Server::update_buffer)
             .add_message_handler(Server::update_buffer_file)

crates/project/src/fs.rs 🔗

@@ -15,6 +15,7 @@ use text::Rope;
 pub trait Fs: Send + Sync {
     async fn create_dir(&self, path: &Path) -> Result<()>;
     async fn create_file(&self, path: &Path, options: CreateOptions) -> Result<()>;
+    async fn copy(&self, source: &Path, target: &Path, options: CopyOptions) -> Result<()>;
     async fn rename(&self, source: &Path, target: &Path, options: RenameOptions) -> Result<()>;
     async fn remove_dir(&self, path: &Path, options: RemoveOptions) -> Result<()>;
     async fn remove_file(&self, path: &Path, options: RemoveOptions) -> Result<()>;
@@ -44,6 +45,12 @@ pub struct CreateOptions {
     pub ignore_if_exists: bool,
 }
 
+#[derive(Copy, Clone, Default)]
+pub struct CopyOptions {
+    pub overwrite: bool,
+    pub ignore_if_exists: bool,
+}
+
 #[derive(Copy, Clone, Default)]
 pub struct RenameOptions {
     pub overwrite: bool,
@@ -84,6 +91,35 @@ impl Fs for RealFs {
         Ok(())
     }
 
+    async fn copy(&self, source: &Path, target: &Path, options: CopyOptions) -> Result<()> {
+        if !options.overwrite && smol::fs::metadata(target).await.is_ok() {
+            if options.ignore_if_exists {
+                return Ok(());
+            } else {
+                return Err(anyhow!("{target:?} already exists"));
+            }
+        }
+
+        let metadata = smol::fs::metadata(source).await?;
+        let _ = smol::fs::remove_dir_all(target).await;
+        if metadata.is_dir() {
+            self.create_dir(target).await?;
+            let mut children = smol::fs::read_dir(source).await?;
+            while let Some(child) = children.next().await {
+                if let Ok(child) = child {
+                    let child_source_path = child.path();
+                    let child_target_path = target.join(child.file_name());
+                    self.copy(&child_source_path, &child_target_path, options)
+                        .await?;
+                }
+            }
+        } else {
+            smol::fs::copy(source, target).await?;
+        }
+
+        Ok(())
+    }
+
     async fn rename(&self, source: &Path, target: &Path, options: RenameOptions) -> Result<()> {
         if !options.overwrite && smol::fs::metadata(target).await.is_ok() {
             if options.ignore_if_exists {
@@ -511,6 +547,40 @@ impl Fs for FakeFs {
         Ok(())
     }
 
+    async fn copy(&self, source: &Path, target: &Path, options: CopyOptions) -> Result<()> {
+        let source = normalize_path(source);
+        let target = normalize_path(target);
+
+        let mut state = self.state.lock().await;
+        state.validate_path(&source)?;
+        state.validate_path(&target)?;
+
+        if !options.overwrite && state.entries.contains_key(&target) {
+            if options.ignore_if_exists {
+                return Ok(());
+            } else {
+                return Err(anyhow!("{target:?} already exists"));
+            }
+        }
+
+        let mut new_entries = Vec::new();
+        for (path, entry) in &state.entries {
+            if let Ok(relative_path) = path.strip_prefix(&source) {
+                new_entries.push((relative_path.to_path_buf(), entry.clone()));
+            }
+        }
+
+        let mut events = Vec::new();
+        for (relative_path, entry) in new_entries {
+            let new_path = normalize_path(&target.join(relative_path));
+            events.push(new_path.clone());
+            state.entries.insert(new_path, entry);
+        }
+
+        state.emit_event(&events).await;
+        Ok(())
+    }
+
     async fn remove_dir(&self, dir_path: &Path, options: RemoveOptions) -> Result<()> {
         let dir_path = normalize_path(dir_path);
         let mut state = self.state.lock().await;

crates/project/src/project.rs 🔗

@@ -281,6 +281,7 @@ impl Project {
         client.add_model_message_handler(Self::handle_update_worktree);
         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_apply_additional_edits_for_completion);
         client.add_model_request_handler(Self::handle_apply_code_action);
@@ -778,6 +779,49 @@ impl Project {
         }
     }
 
+    pub fn copy_entry(
+        &mut self,
+        entry_id: ProjectEntryId,
+        new_path: impl Into<Arc<Path>>,
+        cx: &mut ModelContext<Self>,
+    ) -> Option<Task<Result<Entry>>> {
+        let worktree = self.worktree_for_entry(entry_id, cx)?;
+        let new_path = new_path.into();
+        if self.is_local() {
+            worktree.update(cx, |worktree, cx| {
+                worktree
+                    .as_local_mut()
+                    .unwrap()
+                    .copy_entry(entry_id, new_path, cx)
+            })
+        } else {
+            let client = self.client.clone();
+            let project_id = self.remote_id().unwrap();
+
+            Some(cx.spawn_weak(|_, mut cx| async move {
+                let response = client
+                    .request(proto::CopyProjectEntry {
+                        project_id,
+                        entry_id: entry_id.to_proto(),
+                        new_path: new_path.as_os_str().as_bytes().to_vec(),
+                    })
+                    .await?;
+                let entry = response
+                    .entry
+                    .ok_or_else(|| anyhow!("missing entry in response"))?;
+                worktree
+                    .update(&mut cx, |worktree, cx| {
+                        worktree.as_remote().unwrap().insert_entry(
+                            entry,
+                            response.worktree_scan_id as usize,
+                            cx,
+                        )
+                    })
+                    .await
+            }))
+        }
+    }
+
     pub fn rename_entry(
         &mut self,
         entry_id: ProjectEntryId,
@@ -4027,6 +4071,34 @@ impl Project {
         })
     }
 
+    async fn handle_copy_project_entry(
+        this: ModelHandle<Self>,
+        envelope: TypedEnvelope<proto::CopyProjectEntry>,
+        _: Arc<Client>,
+        mut cx: AsyncAppContext,
+    ) -> Result<proto::ProjectEntryResponse> {
+        let entry_id = ProjectEntryId::from_proto(envelope.payload.entry_id);
+        let worktree = this.read_with(&cx, |this, cx| {
+            this.worktree_for_entry(entry_id, cx)
+                .ok_or_else(|| anyhow!("worktree not found"))
+        })?;
+        let worktree_scan_id = worktree.read_with(&cx, |worktree, _| worktree.scan_id());
+        let entry = worktree
+            .update(&mut cx, |worktree, cx| {
+                let new_path = PathBuf::from(OsString::from_vec(envelope.payload.new_path));
+                worktree
+                    .as_local_mut()
+                    .unwrap()
+                    .copy_entry(entry_id, new_path, cx)
+                    .ok_or_else(|| anyhow!("invalid entry"))
+            })?
+            .await?;
+        Ok(proto::ProjectEntryResponse {
+            entry: Some((&entry).into()),
+            worktree_scan_id: worktree_scan_id as u64,
+        })
+    }
+
     async fn handle_delete_project_entry(
         this: ModelHandle<Self>,
         envelope: TypedEnvelope<proto::DeleteProjectEntry>,

crates/project/src/worktree.rs 🔗

@@ -774,6 +774,46 @@ impl LocalWorktree {
         }))
     }
 
+    pub fn copy_entry(
+        &self,
+        entry_id: ProjectEntryId,
+        new_path: impl Into<Arc<Path>>,
+        cx: &mut ModelContext<Worktree>,
+    ) -> Option<Task<Result<Entry>>> {
+        let old_path = self.entry_for_id(entry_id)?.path.clone();
+        let new_path = new_path.into();
+        let abs_old_path = self.absolutize(&old_path);
+        let abs_new_path = self.absolutize(&new_path);
+        let copy = cx.background().spawn({
+            let fs = self.fs.clone();
+            let abs_new_path = abs_new_path.clone();
+            async move {
+                fs.copy(&abs_old_path, &abs_new_path, Default::default())
+                    .await
+            }
+        });
+
+        Some(cx.spawn(|this, mut cx| async move {
+            copy.await?;
+            let entry = this
+                .update(&mut cx, |this, cx| {
+                    this.as_local_mut().unwrap().refresh_entry(
+                        new_path.clone(),
+                        abs_new_path,
+                        None,
+                        cx,
+                    )
+                })
+                .await?;
+            this.update(&mut cx, |this, cx| {
+                this.poll_snapshot(cx);
+                this.as_local().unwrap().broadcast_snapshot()
+            })
+            .await;
+            Ok(entry)
+        }))
+    }
+
     fn write_entry_internal(
         &self,
         path: impl Into<Arc<Path>>,

crates/project_panel/src/project_panel.rs 🔗

@@ -698,6 +698,11 @@ impl ProjectPanel {
                     })
                     .map(|task| task.detach_and_log_err(cx));
             } else {
+                self.project
+                    .update(cx, |project, cx| {
+                        project.copy_entry(clipboard_entry.entry_id(), new_path, cx)
+                    })
+                    .map(|task| task.detach_and_log_err(cx));
             }
         }
         None

crates/rpc/proto/zed.proto 🔗

@@ -41,66 +41,67 @@ message Envelope {
 
         CreateProjectEntry create_project_entry = 33;
         RenameProjectEntry rename_project_entry = 34;
-        DeleteProjectEntry delete_project_entry = 35;
-        ProjectEntryResponse project_entry_response = 36;
-
-        UpdateDiagnosticSummary update_diagnostic_summary = 37;
-        StartLanguageServer start_language_server = 38;
-        UpdateLanguageServer update_language_server = 39;
-
-        OpenBufferById open_buffer_by_id = 40;
-        OpenBufferByPath open_buffer_by_path = 41;
-        OpenBufferResponse open_buffer_response = 42;
-        UpdateBuffer update_buffer = 43;
-        UpdateBufferFile update_buffer_file = 44;
-        SaveBuffer save_buffer = 45;
-        BufferSaved buffer_saved = 46;
-        BufferReloaded buffer_reloaded = 47;
-        ReloadBuffers reload_buffers = 48;
-        ReloadBuffersResponse reload_buffers_response = 49;
-        FormatBuffers format_buffers = 50;
-        FormatBuffersResponse format_buffers_response = 51;
-        GetCompletions get_completions = 52;
-        GetCompletionsResponse get_completions_response = 53;
-        ApplyCompletionAdditionalEdits apply_completion_additional_edits = 54;
-        ApplyCompletionAdditionalEditsResponse apply_completion_additional_edits_response = 55;
-        GetCodeActions get_code_actions = 56;
-        GetCodeActionsResponse get_code_actions_response = 57;
-        ApplyCodeAction apply_code_action = 58;
-        ApplyCodeActionResponse apply_code_action_response = 59;
-        PrepareRename prepare_rename = 60;
-        PrepareRenameResponse prepare_rename_response = 61;
-        PerformRename perform_rename = 62;
-        PerformRenameResponse perform_rename_response = 63;
-        SearchProject search_project = 64;
-        SearchProjectResponse search_project_response = 65;
-
-        GetChannels get_channels = 66;
-        GetChannelsResponse get_channels_response = 67;
-        JoinChannel join_channel = 68;
-        JoinChannelResponse join_channel_response = 69;
-        LeaveChannel leave_channel = 70;
-        SendChannelMessage send_channel_message = 71;
-        SendChannelMessageResponse send_channel_message_response = 72;
-        ChannelMessageSent channel_message_sent = 73;
-        GetChannelMessages get_channel_messages = 74;
-        GetChannelMessagesResponse get_channel_messages_response = 75;
-
-        UpdateContacts update_contacts = 76;
-        UpdateInviteInfo update_invite_info = 77;
-        ShowContacts show_contacts = 78;
-
-        GetUsers get_users = 79;
-        FuzzySearchUsers fuzzy_search_users = 80;
-        UsersResponse users_response = 81;
-        RequestContact request_contact = 82;
-        RespondToContactRequest respond_to_contact_request = 83;
-        RemoveContact remove_contact = 84;
-
-        Follow follow = 85;
-        FollowResponse follow_response = 86;
-        UpdateFollowers update_followers = 87;
-        Unfollow unfollow = 88;
+        CopyProjectEntry copy_project_entry = 35;
+        DeleteProjectEntry delete_project_entry = 36;
+        ProjectEntryResponse project_entry_response = 37;
+
+        UpdateDiagnosticSummary update_diagnostic_summary = 38;
+        StartLanguageServer start_language_server = 39;
+        UpdateLanguageServer update_language_server = 40;
+
+        OpenBufferById open_buffer_by_id = 41;
+        OpenBufferByPath open_buffer_by_path = 42;
+        OpenBufferResponse open_buffer_response = 43;
+        UpdateBuffer update_buffer = 44;
+        UpdateBufferFile update_buffer_file = 45;
+        SaveBuffer save_buffer = 46;
+        BufferSaved buffer_saved = 47;
+        BufferReloaded buffer_reloaded = 48;
+        ReloadBuffers reload_buffers = 49;
+        ReloadBuffersResponse reload_buffers_response = 50;
+        FormatBuffers format_buffers = 51;
+        FormatBuffersResponse format_buffers_response = 52;
+        GetCompletions get_completions = 53;
+        GetCompletionsResponse get_completions_response = 54;
+        ApplyCompletionAdditionalEdits apply_completion_additional_edits = 55;
+        ApplyCompletionAdditionalEditsResponse apply_completion_additional_edits_response = 56;
+        GetCodeActions get_code_actions = 57;
+        GetCodeActionsResponse get_code_actions_response = 58;
+        ApplyCodeAction apply_code_action = 59;
+        ApplyCodeActionResponse apply_code_action_response = 60;
+        PrepareRename prepare_rename = 61;
+        PrepareRenameResponse prepare_rename_response = 62;
+        PerformRename perform_rename = 63;
+        PerformRenameResponse perform_rename_response = 64;
+        SearchProject search_project = 65;
+        SearchProjectResponse search_project_response = 66;
+
+        GetChannels get_channels = 67;
+        GetChannelsResponse get_channels_response = 68;
+        JoinChannel join_channel = 69;
+        JoinChannelResponse join_channel_response = 70;
+        LeaveChannel leave_channel = 71;
+        SendChannelMessage send_channel_message = 72;
+        SendChannelMessageResponse send_channel_message_response = 73;
+        ChannelMessageSent channel_message_sent = 74;
+        GetChannelMessages get_channel_messages = 75;
+        GetChannelMessagesResponse get_channel_messages_response = 76;
+
+        UpdateContacts update_contacts = 77;
+        UpdateInviteInfo update_invite_info = 78;
+        ShowContacts show_contacts = 79;
+
+        GetUsers get_users = 80;
+        FuzzySearchUsers fuzzy_search_users = 81;
+        UsersResponse users_response = 82;
+        RequestContact request_contact = 83;
+        RespondToContactRequest respond_to_contact_request = 84;
+        RemoveContact remove_contact = 85;
+
+        Follow follow = 86;
+        FollowResponse follow_response = 87;
+        UpdateFollowers update_followers = 88;
+        Unfollow unfollow = 89;
     }
 }
 
@@ -210,6 +211,12 @@ message RenameProjectEntry {
     bytes new_path = 3;
 }
 
+message CopyProjectEntry {
+    uint64 project_id = 1;
+    uint64 entry_id = 2;
+    bytes new_path = 3;
+}
+
 message DeleteProjectEntry {
     uint64 project_id = 1;
     uint64 entry_id = 2;

crates/rpc/src/proto.rs 🔗

@@ -84,6 +84,7 @@ messages!(
     (BufferSaved, Foreground),
     (RemoveContact, Foreground),
     (ChannelMessageSent, Foreground),
+    (CopyProjectEntry, Foreground),
     (CreateProjectEntry, Foreground),
     (DeleteProjectEntry, Foreground),
     (Error, Foreground),
@@ -167,6 +168,7 @@ request_messages!(
         ApplyCompletionAdditionalEdits,
         ApplyCompletionAdditionalEditsResponse
     ),
+    (CopyProjectEntry, ProjectEntryResponse),
     (CreateProjectEntry, ProjectEntryResponse),
     (DeleteProjectEntry, ProjectEntryResponse),
     (Follow, FollowResponse),
@@ -211,8 +213,8 @@ entity_messages!(
     ApplyCompletionAdditionalEdits,
     BufferReloaded,
     BufferSaved,
+    CopyProjectEntry,
     CreateProjectEntry,
-    RenameProjectEntry,
     DeleteProjectEntry,
     Follow,
     FormatBuffers,
@@ -233,6 +235,7 @@ entity_messages!(
     ProjectUnshared,
     ReloadBuffers,
     RemoveProjectCollaborator,
+    RenameProjectEntry,
     RequestJoinProject,
     SaveBuffer,
     SearchProject,