From 66ef31882341852229c74996867916fbd4a2fe2a Mon Sep 17 00:00:00 2001 From: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> Date: Mon, 9 Sep 2024 13:22:16 +0200 Subject: [PATCH] project panel: select autofolded entries (#17520) Closes #17252 Release Notes: - Intermediate auto-folded project entries can now be selected and acted upon (removed, renamed, cut, pasted). --- crates/project_panel/src/project_panel.rs | 238 +++++++++++++++--- .../src/components/label/highlighted_label.rs | 5 + crates/ui/src/components/label/label.rs | 5 + crates/ui/src/components/label/label_like.rs | 22 +- 4 files changed, 238 insertions(+), 32 deletions(-) diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 9d73557761343a82957dfee36778095151d68924..be945cde773bbc0c396b3f4ad9d8e5f2cf03288f 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -60,11 +60,15 @@ pub struct ProjectPanel { scroll_handle: UniformListScrollHandle, focus_handle: FocusHandle, visible_entries: Vec<(WorktreeId, Vec, OnceCell>>)>, + /// Maps from leaf project entry ID to the currently selected ancestor. + /// Relevant only for auto-fold dirs, where a single project panel entry may actually consist of several + /// project entries (and all non-leaf nodes are guaranteed to be directories). + ancestors: HashMap, last_worktree_root_id: Option, last_external_paths_drag_over_entry: Option, expanded_dir_ids: HashMap>, unfolded_dir_ids: HashSet, - // Currently selected entry in a file tree + // Currently selected leaf entry (see auto-folding for a definition of that) in a file tree selection: Option, marked_entries: BTreeSet, context_menu: Option<(View, Point, Subscription)>, @@ -96,7 +100,7 @@ enum ClipboardEntry { } #[derive(Debug, PartialEq, Eq, Clone)] -pub struct EntryDetails { +struct EntryDetails { filename: String, icon: Option, path: Arc, @@ -111,18 +115,19 @@ pub struct EntryDetails { is_cut: bool, git_status: Option, is_private: bool, + is_auto_folded: bool, worktree_id: WorktreeId, canonical_path: Option>, } #[derive(PartialEq, Clone, Default, Debug, Deserialize)] -pub struct Delete { +struct Delete { #[serde(default)] pub skip_prompt: bool, } #[derive(PartialEq, Clone, Default, Debug, Deserialize)] -pub struct Trash { +struct Trash { #[serde(default)] pub skip_prompt: bool, } @@ -155,6 +160,18 @@ actions!( ] ); +#[derive(Debug, Default)] +struct FoldedAncestors { + current_ancestor_depth: usize, + ancestors: Vec, +} + +impl FoldedAncestors { + fn max_ancestor_depth(&self) -> usize { + self.ancestors.len() + } +} + pub fn init_settings(cx: &mut AppContext) { ProjectPanelSettings::register(cx); } @@ -277,6 +294,7 @@ impl ProjectPanel { scroll_handle: UniformListScrollHandle::new(), focus_handle, visible_entries: Default::default(), + ancestors: Default::default(), last_worktree_root_id: Default::default(), last_external_paths_drag_over_entry: None, expanded_dir_ids: Default::default(), @@ -457,7 +475,7 @@ impl ProjectPanel { entry_id, }); - if let Some((worktree, entry)) = self.selected_entry(cx) { + if let Some((worktree, entry)) = self.selected_sub_entry(cx) { let auto_fold_dirs = ProjectPanelSettings::get_global(cx).auto_fold_dirs; let is_root = Some(entry) == worktree.root_entry(); let is_dir = entry.is_dir(); @@ -583,6 +601,13 @@ impl ProjectPanel { fn expand_selected_entry(&mut self, _: &ExpandSelectedEntry, cx: &mut ViewContext) { if let Some((worktree, entry)) = self.selected_entry(cx) { + if let Some(folded_ancestors) = self.ancestors.get_mut(&entry.id) { + if folded_ancestors.current_ancestor_depth > 0 { + folded_ancestors.current_ancestor_depth -= 1; + cx.notify(); + return; + } + } if entry.is_dir() { let worktree_id = worktree.id(); let entry_id = entry.id; @@ -611,6 +636,13 @@ impl ProjectPanel { fn collapse_selected_entry(&mut self, _: &CollapseSelectedEntry, cx: &mut ViewContext) { if let Some((worktree, mut entry)) = self.selected_entry(cx) { + if let Some(folded_ancestors) = self.ancestors.get_mut(&entry.id) { + if folded_ancestors.current_ancestor_depth < folded_ancestors.max_ancestor_depth() { + folded_ancestors.current_ancestor_depth += 1; + cx.notify(); + return; + } + } let worktree_id = worktree.id(); let expanded_dir_ids = if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) { @@ -943,6 +975,17 @@ impl ProjectPanel { } } + fn unflatten_entry_id(&self, leaf_entry_id: ProjectEntryId) -> ProjectEntryId { + if let Some(ancestors) = self.ancestors.get(&leaf_entry_id) { + ancestors + .ancestors + .get(ancestors.current_ancestor_depth) + .copied() + .unwrap_or(leaf_entry_id) + } else { + leaf_entry_id + } + } fn rename(&mut self, _: &Rename, cx: &mut ViewContext) { if let Some(SelectedEntry { worktree_id, @@ -950,6 +993,7 @@ impl ProjectPanel { }) = self.selection { if let Some(worktree) = self.project.read(cx).worktree_for_id(worktree_id, cx) { + let entry_id = self.unflatten_entry_id(entry_id); if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) { self.edit_state = Some(EditState { worktree_id, @@ -1161,7 +1205,7 @@ impl ProjectPanel { } fn select_parent(&mut self, _: &SelectParent, cx: &mut ViewContext) { - if let Some((worktree, entry)) = self.selected_entry(cx) { + if let Some((worktree, entry)) = self.selected_sub_entry(cx) { if let Some(parent) = entry.path.parent() { if let Some(parent_entry) = worktree.entry_for_path(parent) { self.selection = Some(SelectedEntry { @@ -1447,13 +1491,13 @@ impl ProjectPanel { } fn reveal_in_finder(&mut self, _: &RevealInFileManager, cx: &mut ViewContext) { - if let Some((worktree, entry)) = self.selected_entry(cx) { + if let Some((worktree, entry)) = self.selected_sub_entry(cx) { cx.reveal_path(&worktree.abs_path().join(&entry.path)); } } fn open_in_terminal(&mut self, _: &OpenInTerminal, cx: &mut ViewContext) { - if let Some((worktree, entry)) = self.selected_entry(cx) { + if let Some((worktree, entry)) = self.selected_sub_entry(cx) { let abs_path = worktree.abs_path().join(&entry.path); let working_directory = if entry.is_dir() { Some(abs_path) @@ -1476,7 +1520,7 @@ impl ProjectPanel { _: &NewSearchInDirectory, cx: &mut ViewContext, ) { - if let Some((worktree, entry)) = self.selected_entry(cx) { + if let Some((worktree, entry)) = self.selected_sub_entry(cx) { if entry.is_dir() { let include_root = self.project.read(cx).visible_worktrees(cx).count() > 1; let dir_path = if include_root { @@ -1596,15 +1640,36 @@ impl ProjectPanel { // 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 { + let Some(mut selection) = self.selection else { return Default::default(); }; if self.marked_entries.contains(&selection) { - self.marked_entries.clone() + self.marked_entries + .iter() + .copied() + .map(|mut entry| { + entry.entry_id = self.resolve_entry(entry.entry_id); + entry + }) + .collect() } else { + selection.entry_id = self.resolve_entry(selection.entry_id); BTreeSet::from_iter([selection]) } } + + fn resolve_entry(&self, id: ProjectEntryId) -> ProjectEntryId { + self.ancestors + .get(&id) + .and_then(|ancestors| { + if ancestors.current_ancestor_depth == 0 { + return None; + } + ancestors.ancestors.get(ancestors.current_ancestor_depth) + }) + .copied() + .unwrap_or(id) + } pub fn selected_entry<'a>( &self, cx: &'a AppContext, @@ -1613,6 +1678,21 @@ impl ProjectPanel { Some((worktree.read(cx), entry)) } + /// Compared to selected_entry, this function resolves to the currently + /// selected subentry if dir auto-folding is enabled. + fn selected_sub_entry<'a>( + &self, + cx: &'a AppContext, + ) -> Option<(&'a Worktree, &'a project::Entry)> { + let (worktree, mut entry) = self.selected_entry_handle(cx)?; + + let worktree = worktree.read(cx); + let resolved_id = self.resolve_entry(entry.id); + if resolved_id != entry.id { + entry = worktree.entry_for_id(resolved_id)?; + } + Some((worktree, entry)) + } fn selected_entry_handle<'a>( &self, cx: &'a AppContext, @@ -1655,6 +1735,7 @@ impl ProjectPanel { .and_then(|worktree| worktree.read(cx).root_entry()) .map(|entry| entry.id); + let old_ancestors = std::mem::take(&mut self.ancestors); self.visible_entries.clear(); for worktree in project.visible_worktrees(cx) { let snapshot = worktree.read(cx).snapshot(); @@ -1688,25 +1769,42 @@ impl ProjectPanel { let mut visible_worktree_entries = Vec::new(); let mut entry_iter = snapshot.entries(true, 0); + let mut auto_folded_ancestors = vec![]; while let Some(entry) = entry_iter.entry() { - if auto_collapse_dirs - && entry.kind.is_dir() - && !self.unfolded_dir_ids.contains(&entry.id) - { - if let Some(root_path) = snapshot.root_entry() { - let mut child_entries = snapshot.child_entries(&entry.path); - if let Some(child) = child_entries.next() { - if entry.path != root_path.path - && child_entries.next().is_none() - && child.kind.is_dir() - { - entry_iter.advance(); - continue; + if auto_collapse_dirs && entry.kind.is_dir() { + auto_folded_ancestors.push(entry.id); + if !self.unfolded_dir_ids.contains(&entry.id) { + if let Some(root_path) = snapshot.root_entry() { + let mut child_entries = snapshot.child_entries(&entry.path); + if let Some(child) = child_entries.next() { + if entry.path != root_path.path + && child_entries.next().is_none() + && child.kind.is_dir() + { + entry_iter.advance(); + + continue; + } } } } + let depth = old_ancestors + .get(&entry.id) + .map(|ancestor| ancestor.current_ancestor_depth) + .unwrap_or_default(); + let mut ancestors = std::mem::take(&mut auto_folded_ancestors); + if ancestors.len() > 1 { + ancestors.reverse(); + self.ancestors.insert( + entry.id, + FoldedAncestors { + current_ancestor_depth: depth, + ancestors, + }, + ); + } } - + auto_folded_ancestors.clear(); visible_worktree_entries.push(entry.clone()); if Some(entry.id) == new_entry_parent_id { visible_worktree_entries.push(Entry { @@ -1999,6 +2097,7 @@ impl ProjectPanel { .map_or(false, |e| e.is_cut() && e.items().contains(&selection)), git_status: status, is_private: entry.is_private, + is_auto_folded: difference > 1, worktree_id: *worktree_id, canonical_path: entry.canonical_path.clone(), }; @@ -2008,6 +2107,15 @@ impl ProjectPanel { entry.id == NEW_ENTRY_ID } else { entry.id == edit_state.entry_id + || self + .ancestors + .get(&entry.id) + .is_some_and(|auto_folded_dirs| { + auto_folded_dirs + .ancestors + .iter() + .any(|entry_id| *entry_id == edit_state.entry_id) + }) }; if is_edited_entry { @@ -2102,6 +2210,7 @@ impl ProjectPanel { active_selection: selection, marked_selections: selections, }; + let is_auto_folded = details.is_auto_folded; div() .id(entry_id.to_proto() as usize) .on_drag_move::(cx.listener( @@ -2202,11 +2311,78 @@ impl ProjectPanel { if let (Some(editor), true) = (Some(&self.filename_editor), show_editor) { h_flex().h_6().w_full().child(editor.clone()) } else { - h_flex().h_6().child( - Label::new(file_name) - .single_line() - .color(filename_text_color), - ) + h_flex().h_6().map(|this| { + if is_auto_folded && is_active { + let folded_ancestors = self.ancestors.get(&entry_id).unwrap(); + let Some(part_to_highlight) = Path::new(&file_name) + .ancestors() + .nth(folded_ancestors.current_ancestor_depth) + else { + return this; + }; + + let suffix = Path::new(&file_name) + .strip_prefix(part_to_highlight) + .ok() + .filter(|suffix| !suffix.as_os_str().is_empty()); + let prefix = part_to_highlight + .parent() + .filter(|prefix| !prefix.as_os_str().is_empty()); + let Some(part_to_highlight) = part_to_highlight + .file_name() + .and_then(|name| name.to_str().map(String::from)) + else { + return this; + }; + + this.children(prefix.and_then(|prefix| { + Some( + h_flex() + .child( + Label::new(prefix.to_str().map(String::from)?) + .single_line() + .color(filename_text_color), + ) + .child( + Label::new(std::path::MAIN_SEPARATOR_STR) + .single_line() + .color(filename_text_color), + ), + ) + })) + .child( + Label::new(part_to_highlight) + .single_line() + .color(filename_text_color) + .underline(true), + ) + .children( + suffix.and_then(|suffix| { + Some( + h_flex() + .child( + Label::new(std::path::MAIN_SEPARATOR_STR) + .single_line() + .color(filename_text_color), + ) + .child( + Label::new( + suffix.to_str().map(String::from)?, + ) + .single_line() + .color(filename_text_color), + ), + ) + }), + ) + } else { + this.child( + Label::new(file_name) + .single_line() + .color(filename_text_color), + ) + } + }) } .ml_1(), ) @@ -2551,7 +2727,7 @@ impl Render for ProjectPanel { .child( uniform_list(cx.view().clone(), "entries", items_count, { |this, range, cx| { - let mut items = Vec::new(); + let mut items = Vec::with_capacity(range.end - range.start); this.for_each_visible_entry(range, cx, |id, details, cx| { items.push(this.render_entry(id, details, cx)); }); diff --git a/crates/ui/src/components/label/highlighted_label.rs b/crates/ui/src/components/label/highlighted_label.rs index ab71ffdc4c72046770fb8c085fb55b41e10bde54..6b170bb810f42bea1bdfd110852f25c4ada2366b 100644 --- a/crates/ui/src/components/label/highlighted_label.rs +++ b/crates/ui/src/components/label/highlighted_label.rs @@ -58,6 +58,11 @@ impl LabelCommon for HighlightedLabel { self.base = self.base.alpha(alpha); self } + + fn underline(mut self, underline: bool) -> Self { + self.base = self.base.underline(underline); + self + } } pub fn highlight_ranges( diff --git a/crates/ui/src/components/label/label.rs b/crates/ui/src/components/label/label.rs index f29e4656e933ca40475a3161e30860a1833dfa35..898a59de775d9ec624be95b045db9ac2af5c0e56 100644 --- a/crates/ui/src/components/label/label.rs +++ b/crates/ui/src/components/label/label.rs @@ -170,6 +170,11 @@ impl LabelCommon for Label { self.base = self.base.alpha(alpha); self } + + fn underline(mut self, underline: bool) -> Self { + self.base = self.base.underline(underline); + self + } } impl RenderOnce for Label { diff --git a/crates/ui/src/components/label/label_like.rs b/crates/ui/src/components/label/label_like.rs index a59c93e31d6c5d51fbb849f84899c7aee857de14..bc2fae15a7fdc44244af984803137a37b7aa37ca 100644 --- a/crates/ui/src/components/label/label_like.rs +++ b/crates/ui/src/components/label/label_like.rs @@ -1,4 +1,4 @@ -use gpui::{relative, AnyElement, FontWeight, StyleRefinement, Styled}; +use gpui::{relative, AnyElement, FontWeight, StyleRefinement, Styled, UnderlineStyle}; use settings::Settings; use smallvec::SmallVec; use theme::ThemeSettings; @@ -42,6 +42,9 @@ pub trait LabelCommon { /// Sets the italic property of the label. fn italic(self, italic: bool) -> Self; + /// Sets the underline property of the label + fn underline(self, underline: bool) -> Self; + /// Sets the alpha property of the label, overwriting the alpha value of the color. fn alpha(self, alpha: f32) -> Self; } @@ -57,6 +60,7 @@ pub struct LabelLike { italic: bool, children: SmallVec<[AnyElement; 2]>, alpha: Option, + underline: bool, } impl Default for LabelLike { @@ -77,6 +81,7 @@ impl LabelLike { italic: false, children: SmallVec::new(), alpha: None, + underline: false, } } } @@ -123,6 +128,11 @@ impl LabelCommon for LabelLike { self } + fn underline(mut self, underline: bool) -> Self { + self.underline = underline; + self + } + fn alpha(mut self, alpha: f32) -> Self { self.alpha = Some(alpha); self @@ -165,6 +175,16 @@ impl RenderOnce for LabelLike { this.line_height(relative(1.)) }) .when(self.italic, |this| this.italic()) + .when(self.underline, |mut this| { + this.text_style() + .get_or_insert_with(Default::default) + .underline = Some(UnderlineStyle { + thickness: px(1.), + color: None, + wavy: false, + }); + this + }) .text_color(color) .font_weight(self.weight.unwrap_or(settings.ui_font.weight)) .children(self.children)