From 016a1444a7163e56e5a5d7c66ed03b42431b29f5 Mon Sep 17 00:00:00 2001 From: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> Date: Tue, 28 May 2024 15:51:23 +0200 Subject: [PATCH] project panel: Allow selecting multiple entries & add support for multiselect actions (#12363) 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. --- assets/keymaps/default-macos.json | 5 +- crates/project_panel/src/project_panel.rs | 749 +++++++++++++++++----- crates/worktree/src/worktree.rs | 1 + 3 files changed, 581 insertions(+), 174 deletions(-) diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 94e8998e5a..a1e8ea2f4f 100644 --- a/assets/keymaps/default-macos.json +++ b/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" } }, { diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index dd64447b97..84deefbb95 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/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, expanded_dir_ids: HashMap>, unfolded_dir_ids: HashSet, - selection: Option, + // Currently selected entry in a file tree + selection: Option, + marked_entries: BTreeSet, context_menu: Option<(View, Point, Subscription)>, edit_state: Option, filename_editor: View, - clipboard_entry: Option, + clipboard: Option, _dragged_entry_destination: Option>, workspace: WeakView, width: Option, pending_serialization: Task>, } -#[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>, +} + #[derive(Clone, Debug)] struct EditState { worktree_id: WorktreeId, @@ -76,16 +83,10 @@ struct EditState { processing_filename: Option, } -#[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), + Cut(BTreeSet), } #[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, is_private: bool, + worktree_id: WorktreeId, canonical_path: Option, } @@ -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>, } 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.open_internal(true, false, cx); + self.open_internal(false, true, false, cx); } fn open_permanent(&mut self, _: &OpenPermanent, cx: &mut ViewContext) { - 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, ) { 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) { + fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext) { 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, @@ -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) { - 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) { - 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()?; + 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::>(); + if file_paths.is_empty() { + return None; + } + let answer = if !skip_prompt { + let operation = if trash { "Trash" } else { "Delete" }; - let operation = if trash { "Trash" } else { "Delete" }; - let answer = (!skip_prompt).then(|| { - cx.prompt( - PromptLevel::Info, - &format!("{operation} {file_name:?}?",), - None, - &[operation, "Cancel"], - ) - }); + 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::>(); + 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) { - 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) { - 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) { maybe!({ - let (worktree, entry) = self.selected_entry(cx)?; - let clipboard_entry = self.clipboard_entry?; - if clipboard_entry.worktree_id() != worktree.id() { - return None; - } + 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())?; - 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)); - } - if let Some(extension) = extension.as_ref() { - new_file_name.push("."); - new_file_name.push(extension); + for clipboard_entry in clipboard_entries.items() { + if clipboard_entry.worktree_id != worktree_id { + return None; } - new_path.push(new_file_name); - ix += 1; - } + let clipboard_entry_file_name = self + .project + .read(cx) + .path_for_entry(clipboard_entry.entry_id, cx)? + .path + .file_name()? + .to_os_string(); - 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) - } + 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; + { + 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); + } + + 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 { + 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, + ) { + 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, @@ -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::(|style, _, cx| { + .drag_over::(|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) -> 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) -> 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 { 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", @@ -3124,7 +3348,6 @@ mod tests { "Directories inside pasted directory should have an entry" ); - toggle_expand_dir(&panel, "root/b", cx); toggle_expand_dir(&panel, "root/b/a", cx); toggle_expand_dir(&panel, "root/b/a/inner_dir", cx); @@ -3601,6 +3824,180 @@ mod tests { ); } + #[gpui::test] + async fn test_multiple_marked_entries(cx: &mut gpui::TestAppContext) { + init_test_with_editor(cx); + let fs = FakeFs::new(cx.executor().clone()); + fs.insert_tree( + "/project_root", + json!({ + "dir_1": { + "nested_dir": { + "file_a.py": "# File contents", + } + }, + "file_1.py": "# File contents", + }), + ) + .await; + + let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await; + let worktree_id = + cx.update(|cx| project.read(cx).worktrees().next().unwrap().read(cx).id()); + let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + let panel = workspace + .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx)) + .unwrap(); + cx.update(|cx| { + panel.update(cx, |this, cx| { + this.select_next(&Default::default(), cx); + this.expand_selected_entry(&Default::default(), cx); + this.expand_selected_entry(&Default::default(), cx); + this.select_next(&Default::default(), cx); + this.expand_selected_entry(&Default::default(), cx); + this.select_next(&Default::default(), cx); + }) + }); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v project_root", + " v dir_1", + " v nested_dir", + " file_a.py <== selected", + " file_1.py", + ] + ); + let modifiers_with_shift = gpui::Modifiers { + shift: true, + ..Default::default() + }; + cx.simulate_modifiers_change(modifiers_with_shift); + cx.update(|cx| { + panel.update(cx, |this, cx| { + this.select_next(&Default::default(), cx); + }) + }); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v project_root", + " v dir_1", + " v nested_dir", + " file_a.py", + " file_1.py <== selected <== marked", + ] + ); + cx.update(|cx| { + panel.update(cx, |this, cx| { + this.select_prev(&Default::default(), cx); + }) + }); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v project_root", + " v dir_1", + " v nested_dir", + " file_a.py <== selected <== marked", + " file_1.py <== marked", + ] + ); + cx.update(|cx| { + panel.update(cx, |this, cx| { + let drag = DraggedSelection { + active_selection: this.selection.unwrap(), + marked_selections: Arc::new(this.marked_entries.clone()), + }; + let target_entry = this + .project + .read(cx) + .entry_for_path(&(worktree_id, "").into(), cx) + .unwrap(); + this.drag_onto(&drag, target_entry.id, false, cx); + }); + }); + cx.run_until_parked(); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v project_root", + " v dir_1", + " v nested_dir", + " file_1.py <== marked", + " file_a.py <== selected <== marked", + ] + ); + // ESC clears out all marks + cx.update(|cx| { + panel.update(cx, |this, cx| { + this.cancel(&menu::Cancel, cx); + }) + }); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v project_root", + " v dir_1", + " v nested_dir", + " file_1.py", + " file_a.py <== selected", + ] + ); + // ESC clears out all marks + cx.update(|cx| { + panel.update(cx, |this, cx| { + this.select_prev(&SelectPrev, cx); + this.select_next(&SelectNext, cx); + }) + }); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v project_root", + " v dir_1", + " v nested_dir", + " file_1.py <== marked", + " file_a.py <== selected <== marked", + ] + ); + cx.simulate_modifiers_change(Default::default()); + cx.update(|cx| { + panel.update(cx, |this, cx| { + this.cut(&Cut, cx); + this.select_prev(&SelectPrev, cx); + this.select_prev(&SelectPrev, cx); + + this.paste(&Paste, cx); + // this.expand_selected_entry(&ExpandSelectedEntry, cx); + }) + }); + cx.run_until_parked(); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v project_root", + " v dir_1", + " v nested_dir <== selected", + " file_1.py <== marked", + " file_a.py <== marked", + ] + ); + cx.simulate_modifiers_change(modifiers_with_shift); + cx.update(|cx| { + panel.update(cx, |this, cx| { + this.expand_selected_entry(&Default::default(), cx); + this.select_next(&SelectNext, cx); + this.select_next(&SelectNext, cx); + }) + }); + submit_deletion(&panel, cx); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &["v project_root", " v dir_1", " v nested_dir",] + ); + } #[gpui::test] async fn test_autoreveal_and_gitignored_files(cx: &mut gpui::TestAppContext) { init_test_with_editor(cx); @@ -4068,7 +4465,7 @@ mod tests { let worktree = worktree.read(cx); if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) { let entry_id = worktree.entry_for_path(relative_path).unwrap().id; - panel.selection = Some(crate::Selection { + panel.selection = Some(crate::SelectedEntry { worktree_id: worktree.id(), entry_id, }); @@ -4141,7 +4538,13 @@ mod tests { } else { "" }; - result.push(format!("{indent}{icon}{name}{selected}")); + let marked = if details.is_marked { + " <== marked" + } else { + "" + }; + + result.push(format!("{indent}{icon}{name}{selected}{marked}")); }); }); diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs index 48384e6026..d5d3897670 100644 --- a/crates/worktree/src/worktree.rs +++ b/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))