project panel: Allow selecting multiple entries & add support for multiselect actions (#12363)

Piotr Osiewicz created

Fixes #4983 
TODO:
- [x] Improve release note.
- [x] Tests

Release Notes:

- Project panel now supports selecting multiple entries via cmd-click
and shift-click/shift-up/shift-down.
- Added support for handling multiple selected project panel entries to
Cut, Copy, Trash and Delete actions.

Change summary

assets/keymaps/default-macos.json         |   5 
crates/project_panel/src/project_panel.rs | 566 +++++++++++++++++-------
crates/worktree/src/worktree.rs           |   1 
3 files changed, 400 insertions(+), 172 deletions(-)

Detailed changes

assets/keymaps/default-macos.json 🔗

@@ -585,7 +585,10 @@
       "cmd-backspace": ["project_panel::Delete", { "skip_prompt": false }],
       "cmd-delete": ["project_panel::Delete", { "skip_prompt": false }],
       "alt-cmd-r": "project_panel::RevealInFinder",
-      "alt-shift-f": "project_panel::NewSearchInDirectory"
+      "alt-shift-f": "project_panel::NewSearchInDirectory",
+      "shift-down": "menu::SelectNext",
+      "shift-up": "menu::SelectPrev",
+      "escape": "menu::Cancel"
     }
   },
   {

crates/project_panel/src/project_panel.rs 🔗

@@ -3,11 +3,11 @@ use client::{ErrorCode, ErrorExt};
 use settings::{Settings, SettingsStore};
 
 use db::kvp::KEY_VALUE_STORE;
-use editor::{actions::Cancel, items::entry_git_aware_label_color, scroll::Autoscroll, Editor};
+use editor::{items::entry_git_aware_label_color, scroll::Autoscroll, Editor};
 use file_icons::FileIcons;
 
 use anyhow::{anyhow, Result};
-use collections::{hash_map, HashMap};
+use collections::{hash_map, BTreeSet, HashMap};
 use git::repository::GitFileStatus;
 use gpui::{
     actions, anchored, deferred, div, impl_actions, px, uniform_list, Action, AppContext,
@@ -50,23 +50,30 @@ pub struct ProjectPanel {
     last_worktree_root_id: Option<ProjectEntryId>,
     expanded_dir_ids: HashMap<WorktreeId, Vec<ProjectEntryId>>,
     unfolded_dir_ids: HashSet<ProjectEntryId>,
-    selection: Option<Selection>,
+    // Currently selected entry in a file tree
+    selection: Option<SelectedEntry>,
+    marked_entries: BTreeSet<SelectedEntry>,
     context_menu: Option<(View<ContextMenu>, Point<Pixels>, Subscription)>,
     edit_state: Option<EditState>,
     filename_editor: View<Editor>,
-    clipboard_entry: Option<ClipboardEntry>,
+    clipboard: Option<ClipboardEntry>,
     _dragged_entry_destination: Option<Arc<Path>>,
     workspace: WeakView<Workspace>,
     width: Option<Pixels>,
     pending_serialization: Task<Option<()>>,
 }
 
-#[derive(Copy, Clone, Debug)]
-struct Selection {
+#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
+struct SelectedEntry {
     worktree_id: WorktreeId,
     entry_id: ProjectEntryId,
 }
 
+struct DraggedSelection {
+    active_selection: SelectedEntry,
+    marked_selections: Arc<BTreeSet<SelectedEntry>>,
+}
+
 #[derive(Clone, Debug)]
 struct EditState {
     worktree_id: WorktreeId,
@@ -76,16 +83,10 @@ 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(Clone, Debug)]
+enum ClipboardEntry {
+    Copied(BTreeSet<SelectedEntry>),
+    Cut(BTreeSet<SelectedEntry>),
 }
 
 #[derive(Debug, PartialEq, Eq, Clone)]
@@ -98,11 +99,13 @@ pub struct EntryDetails {
     is_ignored: bool,
     is_expanded: bool,
     is_selected: bool,
+    is_marked: bool,
     is_editing: bool,
     is_processing: bool,
     is_cut: bool,
     git_status: Option<GitFileStatus>,
     is_private: bool,
+    worktree_id: WorktreeId,
     canonical_path: Option<PathBuf>,
 }
 
@@ -168,6 +171,7 @@ pub enum Event {
         entry_id: ProjectEntryId,
         focus_opened_item: bool,
         allow_preview: bool,
+        mark_selected: bool,
     },
     SplitEntry {
         entry_id: ProjectEntryId,
@@ -181,9 +185,10 @@ struct SerializedProjectPanel {
 }
 
 struct DraggedProjectEntryView {
-    entry_id: ProjectEntryId,
+    selection: SelectedEntry,
     details: EntryDetails,
     width: Pixels,
+    selections: Arc<BTreeSet<SelectedEntry>>,
 }
 
 impl ProjectPanel {
@@ -267,10 +272,11 @@ impl ProjectPanel {
                 expanded_dir_ids: Default::default(),
                 unfolded_dir_ids: Default::default(),
                 selection: None,
+                marked_entries: Default::default(),
                 edit_state: None,
                 context_menu: None,
                 filename_editor,
-                clipboard_entry: None,
+                clipboard: None,
                 _dragged_entry_destination: None,
                 workspace: workspace.weak_handle(),
                 width: None,
@@ -288,6 +294,7 @@ impl ProjectPanel {
                     entry_id,
                     focus_opened_item,
                     allow_preview,
+                    mark_selected
                 } => {
                     if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
                         if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
@@ -295,6 +302,17 @@ impl ProjectPanel {
                             let worktree_id = worktree.read(cx).id();
                             let entry_id = entry.id;
 
+                                project_panel.update(cx, |this, _| {
+                                    if !mark_selected {
+                                        this.marked_entries.clear();
+                                    }
+                                    this.marked_entries.insert(SelectedEntry {
+                                        worktree_id,
+                                        entry_id
+                                    });
+                                }).ok();
+
+
                             workspace
                                 .open_path_preview(
                                     ProjectPath {
@@ -319,7 +337,7 @@ impl ProjectPanel {
                             if let Some(project_panel) = project_panel.upgrade() {
                                 // Always select the entry, regardless of whether it is opened or not.
                                 project_panel.update(cx, |project_panel, _| {
-                                    project_panel.selection = Some(Selection {
+                                    project_panel.selection = Some(SelectedEntry {
                                         worktree_id,
                                         entry_id
                                     });
@@ -420,7 +438,7 @@ impl ProjectPanel {
             return;
         };
 
-        self.selection = Some(Selection {
+        self.selection = Some(SelectedEntry {
             worktree_id,
             entry_id,
         });
@@ -465,11 +483,24 @@ impl ProjectPanel {
                             .action("Cut", Box::new(Cut))
                             .action("Copy", Box::new(Copy))
                             .action("Duplicate", Box::new(Duplicate))
-                            // TODO: Paste should always be visible, but disabled when clipboard is empty
-                            .when_some(self.clipboard_entry, |menu, entry| {
-                                menu.when(entry.worktree_id() == worktree_id, |menu| {
-                                    menu.action("Paste", Box::new(Paste))
+                            // TODO: Paste should always be visible, cbut disabled when clipboard is empty
+                            .when_some(self.clipboard.as_ref(), |menu, entry| {
+                                let entries_for_worktree_id = (SelectedEntry {
+                                    worktree_id,
+                                    entry_id: ProjectEntryId::MIN,
                                 })
+                                    ..(SelectedEntry {
+                                        worktree_id,
+                                        entry_id: ProjectEntryId::MAX,
+                                    });
+                                menu.when(
+                                    entry
+                                        .items()
+                                        .range(entries_for_worktree_id)
+                                        .next()
+                                        .is_some(),
+                                    |menu| menu.action("Paste", Box::new(Paste)),
+                                )
                             })
                             .separator()
                             .action("Copy Path", Box::new(CopyPath))
@@ -653,10 +684,14 @@ impl ProjectPanel {
             }
 
             let (worktree_id, worktree_entries) = &self.visible_entries[worktree_ix];
-            self.selection = Some(Selection {
+            let selection = SelectedEntry {
                 worktree_id: *worktree_id,
                 entry_id: worktree_entries[entry_ix].id,
-            });
+            };
+            self.selection = Some(selection);
+            if cx.modifiers().shift {
+                self.marked_entries.insert(selection);
+            }
             self.autoscroll(cx);
             cx.notify();
         } else {
@@ -671,22 +706,29 @@ impl ProjectPanel {
     }
 
     fn open(&mut self, _: &Open, cx: &mut ViewContext<Self>) {
-        self.open_internal(true, false, cx);
+        self.open_internal(false, true, false, cx);
     }
 
     fn open_permanent(&mut self, _: &OpenPermanent, cx: &mut ViewContext<Self>) {
-        self.open_internal(false, true, cx);
+        self.open_internal(true, false, true, cx);
     }
 
     fn open_internal(
         &mut self,
+        mark_selected: bool,
         allow_preview: bool,
         focus_opened_item: bool,
         cx: &mut ViewContext<Self>,
     ) {
         if let Some((_, entry)) = self.selected_entry(cx) {
             if entry.is_file() {
-                self.open_entry(entry.id, focus_opened_item, allow_preview, cx);
+                self.open_entry(
+                    entry.id,
+                    mark_selected,
+                    focus_opened_item,
+                    allow_preview,
+                    cx,
+                );
             } else {
                 self.toggle_expanded(entry.id, cx);
             }
@@ -710,7 +752,7 @@ impl ProjectPanel {
         let edit_task;
         let edited_entry_id;
         if is_new_entry {
-            self.selection = Some(Selection {
+            self.selection = Some(SelectedEntry {
                 worktree_id,
                 entry_id: NEW_ENTRY_ID,
             });
@@ -755,12 +797,13 @@ impl ProjectPanel {
                         if selection.entry_id == edited_entry_id {
                             selection.worktree_id = worktree_id;
                             selection.entry_id = new_entry.id;
+                            this.marked_entries.clear();
                             this.expand_to_selection(cx);
                         }
                     }
                     this.update_visible_entries(None, cx);
                     if is_new_entry && !is_dir {
-                        this.open_entry(new_entry.id, true, false, cx);
+                        this.open_entry(new_entry.id, false, true, false, cx);
                     }
                     cx.notify();
                 })?;
@@ -769,9 +812,10 @@ impl ProjectPanel {
         }))
     }
 
-    fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
+    fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
         self.edit_state = None;
         self.update_visible_entries(None, cx);
+        self.marked_entries.clear();
         cx.focus(&self.focus_handle);
         cx.notify();
     }
@@ -779,6 +823,7 @@ impl ProjectPanel {
     fn open_entry(
         &mut self,
         entry_id: ProjectEntryId,
+        mark_selected: bool,
         focus_opened_item: bool,
         allow_preview: bool,
         cx: &mut ViewContext<Self>,
@@ -787,6 +832,7 @@ impl ProjectPanel {
             entry_id,
             focus_opened_item,
             allow_preview,
+            mark_selected,
         });
     }
 
@@ -803,7 +849,7 @@ impl ProjectPanel {
     }
 
     fn add_entry(&mut self, is_dir: bool, cx: &mut ViewContext<Self>) {
-        if let Some(Selection {
+        if let Some(SelectedEntry {
             worktree_id,
             entry_id,
         }) = self.selection
@@ -840,7 +886,7 @@ impl ProjectPanel {
             } else {
                 return;
             };
-
+            self.marked_entries.clear();
             self.edit_state = Some(EditState {
                 worktree_id,
                 entry_id: directory_id,
@@ -859,7 +905,7 @@ impl ProjectPanel {
     }
 
     fn rename(&mut self, _: &Rename, cx: &mut ViewContext<Self>) {
-        if let Some(Selection {
+        if let Some(SelectedEntry {
             worktree_id,
             entry_id,
         }) = self.selection
@@ -907,32 +953,81 @@ impl ProjectPanel {
 
     fn remove(&mut self, trash: bool, skip_prompt: bool, cx: &mut ViewContext<'_, ProjectPanel>) {
         maybe!({
-            let Selection { entry_id, .. } = self.selection?;
-            let path = self.project.read(cx).path_for_entry(entry_id, cx)?.path;
-            let file_name = path.file_name()?;
-
-            let operation = if trash { "Trash" } else { "Delete" };
-            let answer = (!skip_prompt).then(|| {
-                cx.prompt(
-                    PromptLevel::Info,
-                    &format!("{operation} {file_name:?}?",),
-                    None,
-                    &[operation, "Cancel"],
-                )
-            });
+            if self.marked_entries.is_empty() && self.selection.is_none() {
+                return None;
+            }
+            let project = self.project.read(cx);
+            let items_to_delete = self.marked_entries();
+            let file_paths = items_to_delete
+                .into_iter()
+                .filter_map(|selection| {
+                    Some((
+                        selection.entry_id,
+                        project
+                            .path_for_entry(selection.entry_id, cx)?
+                            .path
+                            .file_name()?
+                            .to_string_lossy()
+                            .into_owned(),
+                    ))
+                })
+                .collect::<Vec<_>>();
+            if file_paths.is_empty() {
+                return None;
+            }
+            let answer = if !skip_prompt {
+                let operation = if trash { "Trash" } else { "Delete" };
+
+                let prompt =
+                    if let Some((_, path)) = file_paths.first().filter(|_| file_paths.len() == 1) {
+                        format!("{operation} {path}?")
+                    } else {
+                        const CUTOFF_POINT: usize = 10;
+                        let names = if file_paths.len() > CUTOFF_POINT {
+                            let truncated_path_counts = file_paths.len() - CUTOFF_POINT;
+                            let mut paths = file_paths
+                                .iter()
+                                .map(|(_, path)| path.clone())
+                                .take(CUTOFF_POINT)
+                                .collect::<Vec<_>>();
+                            paths.truncate(CUTOFF_POINT);
+                            if truncated_path_counts == 1 {
+                                paths.push(".. 1 file not shown".into());
+                            } else {
+                                paths.push(format!(".. {} files not shown", truncated_path_counts));
+                            }
+                            paths
+                        } else {
+                            file_paths.iter().map(|(_, path)| path.clone()).collect()
+                        };
+
+                        format!(
+                            "Do you want to {} the following {} files?\n{}",
+                            operation.to_lowercase(),
+                            file_paths.len(),
+                            names.join("\n")
+                        )
+                    };
+                Some(cx.prompt(PromptLevel::Info, &prompt, None, &[operation, "Cancel"]))
+            } else {
+                None
+            };
 
             cx.spawn(|this, mut cx| async move {
                 if let Some(answer) = answer {
                     if answer.await != Ok(0) {
-                        return Ok(());
+                        return Result::<(), anyhow::Error>::Ok(());
                     }
                 }
-                this.update(&mut cx, |this, cx| {
-                    this.project
-                        .update(cx, |project, cx| project.delete_entry(entry_id, trash, cx))
-                        .ok_or_else(|| anyhow!("no such entry"))
-                })??
-                .await
+                for (entry_id, _) in file_paths {
+                    this.update(&mut cx, |this, cx| {
+                        this.project
+                            .update(cx, |project, cx| project.delete_entry(entry_id, trash, cx))
+                            .ok_or_else(|| anyhow!("no such entry"))
+                    })??
+                    .await?;
+                }
+                Result::<(), anyhow::Error>::Ok(())
             })
             .detach_and_log_err(cx);
             Some(())
@@ -1007,10 +1102,15 @@ impl ProjectPanel {
 
             if let Some((worktree_id, worktree_entries)) = self.visible_entries.get(worktree_ix) {
                 if let Some(entry) = worktree_entries.get(entry_ix) {
-                    self.selection = Some(Selection {
+                    let selection = SelectedEntry {
                         worktree_id: *worktree_id,
                         entry_id: entry.id,
-                    });
+                    };
+                    self.selection = Some(selection);
+                    if cx.modifiers().shift {
+                        self.marked_entries.insert(selection);
+                    }
+
                     self.autoscroll(cx);
                     cx.notify();
                 }
@@ -1024,7 +1124,7 @@ impl ProjectPanel {
         if let Some((worktree, entry)) = self.selected_entry(cx) {
             if let Some(parent) = entry.path.parent() {
                 if let Some(parent_entry) = worktree.entry_for_path(parent) {
-                    self.selection = Some(Selection {
+                    self.selection = Some(SelectedEntry {
                         worktree_id: worktree.id(),
                         entry_id: parent_entry.id,
                     });
@@ -1046,10 +1146,14 @@ impl ProjectPanel {
             let worktree = worktree.read(cx);
             let worktree_id = worktree.id();
             if let Some(root_entry) = worktree.root_entry() {
-                self.selection = Some(Selection {
+                let selection = SelectedEntry {
                     worktree_id,
                     entry_id: root_entry.id,
-                });
+                };
+                self.selection = Some(selection);
+                if cx.modifiers().shift {
+                    self.marked_entries.insert(selection);
+                }
                 self.autoscroll(cx);
                 cx.notify();
             }
@@ -1065,7 +1169,7 @@ impl ProjectPanel {
             let worktree = worktree.read(cx);
             let worktree_id = worktree.id();
             if let Some(last_entry) = worktree.entries(true).last() {
-                self.selection = Some(Selection {
+                self.selection = Some(SelectedEntry {
                     worktree_id,
                     entry_id: last_entry.id,
                 });
@@ -1083,82 +1187,90 @@ impl ProjectPanel {
     }
 
     fn cut(&mut self, _: &Cut, cx: &mut ViewContext<Self>) {
-        if let Some((worktree, entry)) = self.selected_entry(cx) {
-            self.clipboard_entry = Some(ClipboardEntry::Cut {
-                worktree_id: worktree.id(),
-                entry_id: entry.id,
-            });
+        let entries = self.marked_entries();
+        if !entries.is_empty() {
+            self.clipboard = Some(ClipboardEntry::Cut(entries));
             cx.notify();
         }
     }
 
     fn copy(&mut self, _: &Copy, cx: &mut ViewContext<Self>) {
-        if let Some((worktree, entry)) = self.selected_entry(cx) {
-            self.clipboard_entry = Some(ClipboardEntry::Copied {
-                worktree_id: worktree.id(),
-                entry_id: entry.id,
-            });
+        let entries = self.marked_entries();
+        if !entries.is_empty() {
+            self.clipboard = Some(ClipboardEntry::Copied(entries));
             cx.notify();
         }
     }
 
     fn paste(&mut self, _: &Paste, cx: &mut ViewContext<Self>) {
         maybe!({
-            let (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 we're pasting into a file, or a directory into itself, go up one level.
-            if entry.is_file() || (entry.is_dir() && entry.id == clipboard_entry.entry_id()) {
-                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));
+            let (worktree, entry) = self.selected_entry_handle(cx)?;
+            let entry = entry.clone();
+            let worktree_id = worktree.read(cx).id();
+            let clipboard_entries = self
+                .clipboard
+                .as_ref()
+                .filter(|clipboard| !clipboard.items().is_empty())?;
+
+            for clipboard_entry in clipboard_entries.items() {
+                if clipboard_entry.worktree_id != worktree_id {
+                    return None;
                 }
-                if let Some(extension) = extension.as_ref() {
-                    new_file_name.push(".");
-                    new_file_name.push(extension);
+
+                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 we're pasting into a file, or a directory into itself, go up one level.
+                if entry.is_file() || (entry.is_dir() && entry.id == clipboard_entry.entry_id) {
+                    new_path.pop();
                 }
 
-                new_path.push(new_file_name);
-                ix += 1;
-            }
+                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;
+                {
+                    let worktree = worktree.read(cx);
+                    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));
+                        }
+                        if let Some(extension) = extension.as_ref() {
+                            new_file_name.push(".");
+                            new_file_name.push(extension);
+                        }
 
-            if clipboard_entry.is_cut() {
-                self.project
-                    .update(cx, |project, cx| {
-                        project.rename_entry(clipboard_entry.entry_id(), new_path, cx)
-                    })
-                    .detach_and_log_err(cx)
-            } else {
-                self.project
-                    .update(cx, |project, cx| {
-                        project.copy_entry(clipboard_entry.entry_id(), new_path, cx)
-                    })
-                    .detach_and_log_err(cx)
-            }
+                        new_path.push(new_file_name);
+                        ix += 1;
+                    }
+                }
 
+                if clipboard_entries.is_cut() {
+                    self.project
+                        .update(cx, |project, cx| {
+                            project.rename_entry(clipboard_entry.entry_id, new_path, cx)
+                        })
+                        .detach_and_log_err(cx)
+                } else {
+                    self.project
+                        .update(cx, |project, cx| {
+                            project.copy_entry(clipboard_entry.entry_id, new_path, cx)
+                        })
+                        .detach_and_log_err(cx)
+                }
+            }
+            self.expand_entry(worktree_id, entry.id, cx);
             Some(())
         });
     }
@@ -1311,7 +1423,7 @@ impl ProjectPanel {
         }
     }
 
-    fn index_for_selection(&self, selection: Selection) -> Option<(usize, usize, usize)> {
+    fn index_for_selection(&self, selection: SelectedEntry) -> Option<(usize, usize, usize)> {
         let mut entry_index = 0;
         let mut visible_entries_index = 0;
         for (worktree_index, (worktree_id, worktree_entries)) in
@@ -1334,6 +1446,18 @@ impl ProjectPanel {
         None
     }
 
+    // Returns list of entries that should be affected by an operation.
+    // When currently selected entry is not marked, it's treated as the only marked entry.
+    fn marked_entries(&self) -> BTreeSet<SelectedEntry> {
+        let Some(selection) = self.selection else {
+            return Default::default();
+        };
+        if self.marked_entries.contains(&selection) {
+            self.marked_entries.clone()
+        } else {
+            BTreeSet::from_iter([selection])
+        }
+    }
     pub fn selected_entry<'a>(
         &self,
         cx: &'a AppContext,
@@ -1513,10 +1637,16 @@ impl ProjectPanel {
         }
 
         if let Some((worktree_id, entry_id)) = new_selected_entry {
-            self.selection = Some(Selection {
+            self.selection = Some(SelectedEntry {
                 worktree_id,
                 entry_id,
             });
+            if cx.modifiers().shift {
+                self.marked_entries.insert(SelectedEntry {
+                    worktree_id,
+                    entry_id,
+                });
+            }
         }
     }
 
@@ -1553,6 +1683,30 @@ impl ProjectPanel {
         });
     }
 
+    fn drag_onto(
+        &mut self,
+        selections: &DraggedSelection,
+        dragged_entry_id: ProjectEntryId,
+        is_file: bool,
+        cx: &mut ViewContext<Self>,
+    ) {
+        if selections
+            .marked_selections
+            .contains(&selections.active_selection)
+        {
+            for selection in selections.marked_selections.iter() {
+                self.move_entry(selection.entry_id, dragged_entry_id, is_file, cx);
+            }
+        } else {
+            self.move_entry(
+                selections.active_selection.entry_id,
+                dragged_entry_id,
+                is_file,
+                cx,
+            );
+        }
+    }
+
     fn for_each_visible_entry(
         &self,
         range: Range<usize>,
@@ -1629,7 +1783,10 @@ impl ProjectPanel {
                             .map(|name| name.to_string_lossy().into_owned())
                             .unwrap_or_else(|| root_name.to_string_lossy().to_string()),
                     };
-
+                    let selection = SelectedEntry {
+                        worktree_id: snapshot.id(),
+                        entry_id: entry.id,
+                    };
                     let mut details = EntryDetails {
                         filename,
                         icon,
@@ -1638,16 +1795,17 @@ impl ProjectPanel {
                         kind: entry.kind,
                         is_ignored: entry.is_ignored,
                         is_expanded,
-                        is_selected: self.selection.map_or(false, |e| {
-                            e.worktree_id == snapshot.id() && e.entry_id == entry.id
-                        }),
+                        is_selected: self.selection == Some(selection),
+                        is_marked: self.marked_entries.contains(&selection),
                         is_editing: false,
                         is_processing: false,
                         is_cut: self
-                            .clipboard_entry
-                            .map_or(false, |e| e.is_cut() && e.entry_id() == entry.id),
+                            .clipboard
+                            .as_ref()
+                            .map_or(false, |e| e.is_cut() && e.items().contains(&selection)),
                         git_status: status,
                         is_private: entry.is_private,
+                        worktree_id: *worktree_id,
                         canonical_path: entry.canonical_path.clone(),
                     };
 
@@ -1727,7 +1885,12 @@ impl ProjectPanel {
         let kind = details.kind;
         let settings = ProjectPanelSettings::get_global(cx);
         let show_editor = details.is_editing && !details.is_processing;
-        let is_selected = self
+        let selection = SelectedEntry {
+            worktree_id: details.worktree_id,
+            entry_id,
+        };
+        let is_selected = self.marked_entries.contains(&selection);
+        let is_active = self
             .selection
             .map_or(false, |selection| selection.entry_id == entry_id);
         let width = self.size(cx);
@@ -1748,20 +1911,28 @@ impl ProjectPanel {
             .map(|f| f.to_string_lossy().to_string());
 
         let depth = details.depth;
+        let worktree_id = details.worktree_id;
+        let selections = Arc::new(self.marked_entries.clone());
+
+        let dragged_selection = DraggedSelection {
+            active_selection: selection,
+            marked_selections: selections,
+        };
         div()
             .id(entry_id.to_proto() as usize)
-            .on_drag(entry_id, move |entry_id, cx| {
+            .on_drag(dragged_selection, move |selection, cx| {
                 cx.new_view(|_| DraggedProjectEntryView {
                     details: details.clone(),
                     width,
-                    entry_id: *entry_id,
+                    selection: selection.active_selection,
+                    selections: selection.marked_selections.clone(),
                 })
             })
-            .drag_over::<ProjectEntryId>(|style, _, cx| {
+            .drag_over::<DraggedSelection>(|style, _, cx| {
                 style.bg(cx.theme().colors().drop_target_background)
             })
-            .on_drop(cx.listener(move |this, dragged_id: &ProjectEntryId, cx| {
-                this.move_entry(*dragged_id, entry_id, kind.is_file(), cx);
+            .on_drop(cx.listener(move |this, selections: &DraggedSelection, cx| {
+                this.drag_onto(selections, entry_id, kind.is_file(), cx);
             }))
             .child(
                 ListItem::new(entry_id.to_proto() as usize)
@@ -1801,15 +1972,61 @@ impl ProjectPanel {
                             return;
                         }
                         if !show_editor {
-                            if kind.is_dir() {
+                            if let Some(selection) =
+                                this.selection.filter(|_| event.down.modifiers.shift)
+                            {
+                                let current_selection = this.index_for_selection(selection);
+                                let target_selection = this.index_for_selection(SelectedEntry {
+                                    entry_id,
+                                    worktree_id,
+                                });
+                                if let Some(((_, _, source_index), (_, _, target_index))) =
+                                    current_selection.zip(target_selection)
+                                {
+                                    let range_start = source_index.min(target_index);
+                                    let range_end = source_index.max(target_index) + 1; // Make the range inclusive.
+                                    let mut new_selections = BTreeSet::new();
+                                    this.for_each_visible_entry(
+                                        range_start..range_end,
+                                        cx,
+                                        |entry_id, details, _| {
+                                            new_selections.insert(SelectedEntry {
+                                                entry_id,
+                                                worktree_id: details.worktree_id,
+                                            });
+                                        },
+                                    );
+
+                                    this.marked_entries = this
+                                        .marked_entries
+                                        .union(&new_selections)
+                                        .cloned()
+                                        .collect();
+
+                                    this.selection = Some(SelectedEntry {
+                                        entry_id,
+                                        worktree_id,
+                                    });
+                                    // Ensure that the current entry is selected.
+                                    this.marked_entries.insert(SelectedEntry {
+                                        entry_id,
+                                        worktree_id,
+                                    });
+                                }
+                            } else if event.down.modifiers.secondary() {
+                                if !this.marked_entries.insert(selection) {
+                                    this.marked_entries.remove(&selection);
+                                }
+                            } else if kind.is_dir() {
                                 this.toggle_expanded(entry_id, cx);
                             } else {
-                                if event.down.modifiers.secondary() {
+                                let click_count = event.up.click_count;
+                                if click_count > 1 && event.down.modifiers.secondary() {
                                     this.split_entry(entry_id, cx);
                                 } else {
-                                    let click_count = event.up.click_count;
                                     this.open_entry(
                                         entry_id,
+                                        cx.modifiers().secondary(),
                                         click_count > 1,
                                         click_count == 1,
                                         cx,
@@ -1827,6 +2044,15 @@ impl ProjectPanel {
                         },
                     )),
             )
+            .border_1()
+            .rounded_none()
+            .hover(|style| style.bg(cx.theme().colors().ghost_element_hover))
+            .when(is_selected, |this| {
+                this.border_color(cx.theme().colors().ghost_element_selected)
+            })
+            .when(is_active, |this| {
+                this.border_color(Color::Selected.color(cx))
+            })
     }
 
     fn dispatch_context(&self, cx: &ViewContext<Self>) -> KeyContext {
@@ -1862,6 +2088,7 @@ impl ProjectPanel {
             }
 
             let worktree_id = worktree.id();
+            self.marked_entries.clear();
             self.expand_entry(worktree_id, entry_id, cx);
             self.update_visible_entries(Some((worktree_id, entry_id)), cx);
             self.autoscroll(cx);
@@ -1876,7 +2103,7 @@ impl Render for ProjectPanel {
         let project = self.project.read(cx);
 
         if has_worktree {
-            div()
+            h_flex()
                 .id("project-panel")
                 .size_full()
                 .relative()
@@ -1979,21 +2206,27 @@ impl Render for DraggedProjectEntryView {
     fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
         let settings = ProjectPanelSettings::get_global(cx);
         let ui_font = ThemeSettings::get_global(cx).ui_font.clone();
-        h_flex()
-            .font(ui_font)
-            .bg(cx.theme().colors().background)
-            .w(self.width)
-            .child(
-                ListItem::new(self.entry_id.to_proto() as usize)
-                    .indent_level(self.details.depth)
-                    .indent_step_size(px(settings.indent_size))
-                    .child(if let Some(icon) = &self.details.icon {
-                        div().child(Icon::from_path(icon.to_string()))
-                    } else {
-                        div()
-                    })
-                    .child(Label::new(self.details.filename.clone())),
-            )
+        h_flex().font(ui_font).map(|this| {
+            if self.selections.contains(&self.selection) {
+                this.flex_shrink()
+                    .p_1()
+                    .items_end()
+                    .rounded_md()
+                    .child(self.selections.len().to_string())
+            } else {
+                this.bg(cx.theme().colors().background).w(self.width).child(
+                    ListItem::new(self.selection.entry_id.to_proto() as usize)
+                        .indent_level(self.details.depth)
+                        .indent_step_size(px(settings.indent_size))
+                        .child(if let Some(icon) = &self.details.icon {
+                            div().child(Icon::from_path(icon.to_string()))
+                        } else {
+                            div()
+                        })
+                        .child(Label::new(self.details.filename.clone())),
+                )
+            }
+        })
     }
 }
 
@@ -2076,18 +2309,9 @@ impl ClipboardEntry {
         matches!(self, Self::Cut { .. })
     }
 
-    fn entry_id(&self) -> ProjectEntryId {
+    fn items(&self) -> &BTreeSet<SelectedEntry> {
         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,
+            ClipboardEntry::Copied(entries) | ClipboardEntry::Cut(entries) => entries,
         }
     }
 }
@@ -2554,7 +2778,7 @@ mod tests {
                 "    > b",
                 "    > C",
                 "      .dockerignore",
-                "      the-new-filename  <== selected",
+                "      the-new-filename  <== selected  <== marked",
                 "v root2",
                 "    > d",
                 "    > e",
@@ -2597,7 +2821,7 @@ mod tests {
                 "    v b",
                 "        > 3",
                 "        > 4",
-                "          another-filename.txt  <== selected",
+                "          another-filename.txt  <== selected  <== marked",
                 "    > C",
                 "      .dockerignore",
                 "      the-new-filename",
@@ -2615,7 +2839,7 @@ mod tests {
                 "    v b",
                 "        > 3",
                 "        > 4",
-                "          [EDITOR: 'another-filename.txt']  <== selected",
+                "          [EDITOR: 'another-filename.txt']  <== selected  <== marked",
                 "    > C",
                 "      .dockerignore",
                 "      the-new-filename",
@@ -2643,7 +2867,7 @@ mod tests {
                 "    v b",
                 "        > 3",
                 "        > 4",
-                "          [PROCESSING: 'a-different-filename.tar.gz']  <== selected",
+                "          [PROCESSING: 'a-different-filename.tar.gz']  <== selected  <== marked",
                 "    > C",
                 "      .dockerignore",
                 "      the-new-filename",
@@ -2693,7 +2917,7 @@ mod tests {
                 assert_eq!(file_name_selection.end, "a-different-filename.tar".len(), "Should not select file extension, but still may select anything up to the last dot..");
 
             });
-            panel.cancel(&Cancel, cx)
+            panel.cancel(&menu::Cancel, cx)
         });
 
         panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
@@ -2912,7 +3136,7 @@ mod tests {
                 "    > b",
                 "    v bdir1",
                 "        v dir2",
-                "              the-new-filename  <== selected",
+                "              the-new-filename  <== selected  <== marked",
                 "    > C",
                 "      .dockerignore",
                 "v root2",

crates/worktree/src/worktree.rs 🔗

@@ -5113,6 +5113,7 @@ pub struct ProjectEntryId(usize);
 
 impl ProjectEntryId {
     pub const MAX: Self = Self(usize::MAX);
+    pub const MIN: Self = Self(usize::MIN);
 
     pub fn new(counter: &AtomicUsize) -> Self {
         Self(counter.fetch_add(1, SeqCst))