Implement cut/paste for `ProjectPanel`

Antonio Scandurra created

Change summary

crates/project_panel/src/project_panel.rs | 124 +++++++++++++++++++++++-
crates/theme/src/theme.rs                 |   1 
styles/src/styleTree/projectPanel.ts      |   1 
3 files changed, 120 insertions(+), 6 deletions(-)

Detailed changes

crates/project_panel/src/project_panel.rs 🔗

@@ -22,7 +22,7 @@ use std::{
     collections::{hash_map, HashMap},
     ffi::OsStr,
     ops::Range,
-    path::PathBuf,
+    path::{Path, PathBuf},
 };
 use unicase::UniCase;
 use workspace::Workspace;
@@ -37,6 +37,7 @@ pub struct ProjectPanel {
     selection: Option<Selection>,
     edit_state: Option<EditState>,
     filename_editor: ViewHandle<Editor>,
+    clipboard_entry: Option<ClipboardEntry>,
     context_menu: ViewHandle<ContextMenu>,
 }
 
@@ -55,6 +56,18 @@ struct EditState {
     processing_filename: Option<String>,
 }
 
+#[derive(Copy, Clone)]
+pub enum ClipboardEntry {
+    Copied {
+        worktree_id: WorktreeId,
+        entry_id: ProjectEntryId,
+    },
+    Cut {
+        worktree_id: WorktreeId,
+        entry_id: ProjectEntryId,
+    },
+}
+
 #[derive(Debug, PartialEq, Eq)]
 struct EntryDetails {
     filename: String,
@@ -65,6 +78,7 @@ struct EntryDetails {
     is_selected: bool,
     is_editing: bool,
     is_processing: bool,
+    is_cut: bool,
 }
 
 #[derive(Clone)]
@@ -116,7 +130,11 @@ pub fn init(cx: &mut MutableAppContext) {
     cx.add_action(ProjectPanel::copy);
     cx.add_action(ProjectPanel::copy_path);
     cx.add_action(ProjectPanel::cut);
-    cx.add_action(ProjectPanel::paste);
+    cx.add_action(
+        |this: &mut ProjectPanel, action: &Paste, cx: &mut ViewContext<ProjectPanel>| {
+            this.paste(action, cx);
+        },
+    );
 }
 
 pub enum Event {
@@ -172,6 +190,7 @@ impl ProjectPanel {
                 selection: None,
                 edit_state: None,
                 filename_editor,
+                clipboard_entry: None,
                 context_menu: cx.add_view(|cx| ContextMenu::new(cx)),
             };
             this.update_visible_entries(None, cx);
@@ -239,6 +258,11 @@ impl ProjectPanel {
                     menu_entries.push(ContextMenuItem::item("Copy", Copy));
                     menu_entries.push(ContextMenuItem::item("Copy Path", CopyPath));
                     menu_entries.push(ContextMenuItem::item("Cut", Cut));
+                    if let Some(clipboard_entry) = self.clipboard_entry {
+                        if clipboard_entry.worktree_id() == worktree.id() {
+                            menu_entries.push(ContextMenuItem::item("Paste", Paste));
+                        }
+                    }
                     menu_entries.push(ContextMenuItem::Separator);
                     menu_entries.push(ContextMenuItem::item("Rename", Rename));
                     if !is_root {
@@ -608,15 +632,75 @@ impl ProjectPanel {
     }
 
     fn cut(&mut self, _: &Cut, cx: &mut ViewContext<Self>) {
-        todo!()
+        if let Some((worktree, entry)) = self.selected_entry(cx) {
+            self.clipboard_entry = Some(ClipboardEntry::Cut {
+                worktree_id: worktree.id(),
+                entry_id: entry.id,
+            });
+            cx.notify();
+        }
     }
 
     fn copy(&mut self, _: &Copy, cx: &mut ViewContext<Self>) {
-        todo!()
+        if let Some((worktree, entry)) = self.selected_entry(cx) {
+            self.clipboard_entry = Some(ClipboardEntry::Copied {
+                worktree_id: worktree.id(),
+                entry_id: entry.id,
+            });
+            cx.notify();
+        }
     }
 
-    fn paste(&mut self, _: &Paste, cx: &mut ViewContext<Self>) {
-        todo!()
+    fn paste(&mut self, _: &Paste, cx: &mut ViewContext<Self>) -> Option<()> {
+        if let Some((worktree, entry)) = self.selected_entry(cx) {
+            let clipboard_entry = self.clipboard_entry?;
+            if clipboard_entry.worktree_id() != worktree.id() {
+                return None;
+            }
+
+            let clipboard_entry_file_name = self
+                .project
+                .read(cx)
+                .path_for_entry(clipboard_entry.entry_id(), cx)?
+                .path
+                .file_name()?
+                .to_os_string();
+
+            let mut new_path = entry.path.to_path_buf();
+            if entry.is_file() {
+                new_path.pop();
+            }
+
+            new_path.push(&clipboard_entry_file_name);
+            let extension = new_path.extension().map(|e| e.to_os_string());
+            let file_name_without_extension = Path::new(&clipboard_entry_file_name).file_stem()?;
+            let mut ix = 0;
+            while worktree.entry_for_path(&new_path).is_some() {
+                new_path.pop();
+
+                let mut new_file_name = file_name_without_extension.to_os_string();
+                new_file_name.push(" copy");
+                if ix > 0 {
+                    new_file_name.push(format!(" {}", ix));
+                }
+                new_path.push(new_file_name);
+                if let Some(extension) = extension.as_ref() {
+                    new_path.set_extension(&extension);
+                }
+                ix += 1;
+            }
+
+            self.clipboard_entry.take();
+            if clipboard_entry.is_cut() {
+                self.project
+                    .update(cx, |project, cx| {
+                        project.rename_entry(clipboard_entry.entry_id(), new_path, cx)
+                    })
+                    .map(|task| task.detach_and_log_err(cx));
+            } else {
+            }
+        }
+        None
     }
 
     fn copy_path(&mut self, _: &CopyPath, cx: &mut ViewContext<Self>) {
@@ -834,6 +918,9 @@ impl ProjectPanel {
                         }),
                         is_editing: false,
                         is_processing: false,
+                        is_cut: self
+                            .clipboard_entry
+                            .map_or(false, |e| e.is_cut() && e.entry_id() == entry.id),
                     };
                     if let Some(edit_state) = &self.edit_state {
                         let is_edited_entry = if edit_state.is_new_entry {
@@ -878,6 +965,10 @@ impl ProjectPanel {
                 style.text.color.fade_out(theme.ignored_entry_fade);
                 style.icon_color.fade_out(theme.ignored_entry_fade);
             }
+            if details.is_cut {
+                style.text.color.fade_out(theme.cut_entry_fade);
+                style.icon_color.fade_out(theme.cut_entry_fade);
+            }
             let row_container_style = if show_editor {
                 theme.filename_editor.container
             } else {
@@ -1018,6 +1109,27 @@ impl workspace::sidebar::SidebarItem for ProjectPanel {
     }
 }
 
+impl ClipboardEntry {
+    fn is_cut(&self) -> bool {
+        matches!(self, Self::Cut { .. })
+    }
+
+    fn entry_id(&self) -> ProjectEntryId {
+        match self {
+            ClipboardEntry::Copied { entry_id, .. } | ClipboardEntry::Cut { entry_id, .. } => {
+                *entry_id
+            }
+        }
+    }
+
+    fn worktree_id(&self) -> WorktreeId {
+        match self {
+            ClipboardEntry::Copied { worktree_id, .. }
+            | ClipboardEntry::Cut { worktree_id, .. } => *worktree_id,
+        }
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;

crates/theme/src/theme.rs 🔗

@@ -224,6 +224,7 @@ pub struct ProjectPanel {
     #[serde(flatten)]
     pub container: ContainerStyle,
     pub entry: Interactive<ProjectPanelEntry>,
+    pub cut_entry_fade: f32,
     pub ignored_entry_fade: f32,
     pub filename_editor: FieldEditor,
     pub indent_width: f32,

styles/src/styleTree/projectPanel.ts 🔗

@@ -26,6 +26,7 @@ export default function projectPanel(theme: Theme) {
         text: text(theme, "mono", "active", { size: "sm" }),
       }
     },
+    cutEntryFade: 0.4,
     ignoredEntryFade: 0.6,
     filenameEditor: {
       background: backgroundColor(theme, 500, "active"),