From 6efc5ecefe98487b7ede1f35536b7710b831c251 Mon Sep 17 00:00:00 2001 From: Smit Barmase Date: Mon, 7 Jul 2025 08:32:42 +0530 Subject: [PATCH] project_panel: Add Sticky Scroll (#33994) Closes #7243 - Adds `top_slot_items` to `uniform_list` component to offset list items. - Adds `ToPosition` scroll strategy to `uniform_list` to scroll list to specified index. - Adds `sticky_items` component which can be used along with `uniform_list` to add sticky functionality to any view that implements uniform list. https://github.com/user-attachments/assets/eb508fa4-167e-4595-911b-52651537284c Release Notes: - Added sticky scroll to the project panel, which keeps parent directories visible while scrolling. This feature is enabled by default. To disable it, toggle `sticky_scroll` in settings. --- assets/settings/default.json | 2 + crates/gpui/src/elements/uniform_list.rs | 83 +- crates/project_panel/src/project_panel.rs | 787 +++++++++++------- .../src/project_panel_settings.rs | 5 + crates/ui/src/components.rs | 2 + crates/ui/src/components/sticky_items.rs | 150 ++++ 6 files changed, 742 insertions(+), 287 deletions(-) create mode 100644 crates/ui/src/components/sticky_items.rs diff --git a/assets/settings/default.json b/assets/settings/default.json index 9d858b42a8..985e322cac 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -617,6 +617,8 @@ // 3. Mark files with errors and warnings: // "all" "show_diagnostics": "all", + // Whether to stick parent directories at top of the project panel. + "sticky_scroll": true, // Settings related to indent guides in the project panel. "indent_guides": { // When to show indent guides in the project panel. diff --git a/crates/gpui/src/elements/uniform_list.rs b/crates/gpui/src/elements/uniform_list.rs index c85f71eae8..f32ecfc20c 100644 --- a/crates/gpui/src/elements/uniform_list.rs +++ b/crates/gpui/src/elements/uniform_list.rs @@ -7,8 +7,8 @@ use crate::{ AnyElement, App, AvailableSpace, Bounds, ContentMask, Element, ElementId, GlobalElementId, Hitbox, InspectorElementId, InteractiveElement, Interactivity, IntoElement, IsZero, LayoutId, - ListSizingBehavior, Overflow, Pixels, ScrollHandle, Size, StyleRefinement, Styled, Window, - point, size, + ListSizingBehavior, Overflow, Pixels, Point, ScrollHandle, Size, StyleRefinement, Styled, + Window, point, size, }; use smallvec::SmallVec; use std::{cell::RefCell, cmp, ops::Range, rc::Rc}; @@ -42,6 +42,7 @@ where item_count, item_to_measure_index: 0, render_items: Box::new(render_range), + top_slot: None, decorations: Vec::new(), interactivity: Interactivity { element_id: Some(id), @@ -61,6 +62,7 @@ pub struct UniformList { render_items: Box< dyn for<'a> Fn(Range, &'a mut Window, &'a mut App) -> SmallVec<[AnyElement; 64]>, >, + top_slot: Option>, decorations: Vec>, interactivity: Interactivity, scroll_handle: Option, @@ -71,6 +73,7 @@ pub struct UniformList { /// Frame state used by the [UniformList]. pub struct UniformListFrameState { items: SmallVec<[AnyElement; 32]>, + top_slot_items: SmallVec<[AnyElement; 8]>, decorations: SmallVec<[AnyElement; 1]>, } @@ -88,6 +91,8 @@ pub enum ScrollStrategy { /// May not be possible if there's not enough list items above the item scrolled to: /// in this case, the element will be placed at the closest possible position. Center, + /// Scrolls the element to be at the given item index from the top of the viewport. + ToPosition(usize), } #[derive(Clone, Debug, Default)] @@ -212,6 +217,7 @@ impl Element for UniformList { UniformListFrameState { items: SmallVec::new(), decorations: SmallVec::new(), + top_slot_items: SmallVec::new(), }, ) } @@ -345,6 +351,15 @@ impl Element for UniformList { } } } + ScrollStrategy::ToPosition(sticky_index) => { + let target_y_in_viewport = item_height * sticky_index; + let target_scroll_top = item_top - target_y_in_viewport; + let max_scroll_top = + (content_height - list_height).max(Pixels::ZERO); + let new_scroll_top = + target_scroll_top.clamp(Pixels::ZERO, max_scroll_top); + updated_scroll_offset.y = -new_scroll_top; + } } scroll_offset = *updated_scroll_offset } @@ -354,7 +369,17 @@ impl Element for UniformList { let last_visible_element_ix = ((-scroll_offset.y + padded_bounds.size.height) / item_height) .ceil() as usize; - let visible_range = first_visible_element_ix + let initial_range = first_visible_element_ix + ..cmp::min(last_visible_element_ix, self.item_count); + + let mut top_slot_elements = if let Some(ref mut top_slot) = self.top_slot { + top_slot.compute(initial_range, window, cx) + } else { + SmallVec::new() + }; + let top_slot_offset = top_slot_elements.len(); + + let visible_range = (top_slot_offset + first_visible_element_ix) ..cmp::min(last_visible_element_ix, self.item_count); let items = if y_flipped { @@ -393,6 +418,20 @@ impl Element for UniformList { frame_state.items.push(item); } + if let Some(ref top_slot) = self.top_slot { + top_slot.prepaint( + &mut top_slot_elements, + padded_bounds, + item_height, + scroll_offset, + padding, + can_scroll_horizontally, + window, + cx, + ); + } + frame_state.top_slot_items = top_slot_elements; + let bounds = Bounds::new( padded_bounds.origin + point( @@ -454,6 +493,9 @@ impl Element for UniformList { for decoration in &mut request_layout.decorations { decoration.paint(window, cx); } + if let Some(ref top_slot) = self.top_slot { + top_slot.paint(&mut request_layout.top_slot_items, window, cx); + } }, ) } @@ -483,6 +525,35 @@ pub trait UniformListDecoration { ) -> AnyElement; } +/// A trait for implementing top slots in a [`UniformList`]. +/// Top slots are elements that appear at the top of the list and can adjust +/// the visible range of list items. +pub trait UniformListTopSlot { + /// Returns elements to render at the top slot for the given visible range. + fn compute( + &mut self, + visible_range: Range, + window: &mut Window, + cx: &mut App, + ) -> SmallVec<[AnyElement; 8]>; + + /// Layout and prepaint the top slot elements. + fn prepaint( + &self, + elements: &mut SmallVec<[AnyElement; 8]>, + bounds: Bounds, + item_height: Pixels, + scroll_offset: Point, + padding: crate::Edges, + can_scroll_horizontally: bool, + window: &mut Window, + cx: &mut App, + ); + + /// Paint the top slot elements. + fn paint(&self, elements: &mut SmallVec<[AnyElement; 8]>, window: &mut Window, cx: &mut App); +} + impl UniformList { /// Selects a specific list item for measurement. pub fn with_width_from_item(mut self, item_index: Option) -> Self { @@ -521,6 +592,12 @@ impl UniformList { self } + /// Sets a top slot for the list. + pub fn with_top_slot(mut self, top_slot: impl UniformListTopSlot + 'static) -> Self { + self.top_slot = Some(Box::new(top_slot)); + self + } + fn measure_item( &self, list_width: Option, diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index ded6e0e3f4..ca791869d9 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -56,7 +56,7 @@ use theme::ThemeSettings; use ui::{ Color, ContextMenu, DecoratedIcon, Icon, IconDecoration, IconDecorationKind, IndentGuideColors, IndentGuideLayout, KeyBinding, Label, LabelSize, ListItem, ListItemSpacing, Scrollbar, - ScrollbarState, Tooltip, prelude::*, v_flex, + ScrollbarState, StickyCandidate, Tooltip, prelude::*, v_flex, }; use util::{ResultExt, TakeUntilExt, TryFutureExt, maybe, paths::compare_paths}; use workspace::{ @@ -173,6 +173,7 @@ struct EntryDetails { is_editing: bool, is_processing: bool, is_cut: bool, + sticky: Option, filename_text_color: Color, diagnostic_severity: Option, git_status: GitSummary, @@ -181,6 +182,11 @@ struct EntryDetails { canonical_path: Option>, } +#[derive(Debug, PartialEq, Eq, Clone)] +struct StickyDetails { + sticky_index: usize, +} + /// Permanently deletes the selected file or directory. #[derive(PartialEq, Clone, Default, Debug, Deserialize, JsonSchema, Action)] #[action(namespace = project_panel)] @@ -3366,22 +3372,13 @@ impl ProjectPanel { } let end_ix = range.end.min(ix + visible_worktree_entries.len()); - let (git_status_setting, show_file_icons, show_folder_icons) = { + let git_status_setting = { let settings = ProjectPanelSettings::get_global(cx); - ( - settings.git_status, - settings.file_icons, - settings.folder_icons, - ) + settings.git_status }; if let Some(worktree) = self.project.read(cx).worktree_for_id(*worktree_id, cx) { let snapshot = worktree.read(cx).snapshot(); let root_name = OsStr::new(snapshot.root_name()); - let expanded_entry_ids = self - .expanded_dir_ids - .get(&snapshot.id()) - .map(Vec::as_slice) - .unwrap_or(&[]); let entry_range = range.start.saturating_sub(ix)..end_ix - ix; let entries = entries_paths.get_or_init(|| { @@ -3394,80 +3391,17 @@ impl ProjectPanel { let status = git_status_setting .then_some(entry.git_summary) .unwrap_or_default(); - let is_expanded = expanded_entry_ids.binary_search(&entry.id).is_ok(); - let icon = match entry.kind { - EntryKind::File => { - if show_file_icons { - FileIcons::get_icon(&entry.path, cx) - } else { - None - } - } - _ => { - if show_folder_icons { - FileIcons::get_folder_icon(is_expanded, cx) - } else { - FileIcons::get_chevron_icon(is_expanded, cx) - } - } - }; - let (depth, difference) = - ProjectPanel::calculate_depth_and_difference(&entry, entries); - - let filename = match difference { - diff if diff > 1 => entry - .path - .iter() - .skip(entry.path.components().count() - diff) - .collect::() - .to_str() - .unwrap_or_default() - .to_string(), - _ => entry - .path - .file_name() - .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 is_marked = self.marked_entries.contains(&selection); - - let diagnostic_severity = self - .diagnostics - .get(&(*worktree_id, entry.path.to_path_buf())) - .cloned(); - - let filename_text_color = - entry_git_aware_label_color(status, entry.is_ignored, is_marked); - - let mut details = EntryDetails { - filename, - icon, - path: entry.path.clone(), - depth, - kind: entry.kind, - is_ignored: entry.is_ignored, - is_expanded, - is_selected: self.selection == Some(selection), - is_marked, - is_editing: false, - is_processing: false, - is_cut: self - .clipboard - .as_ref() - .map_or(false, |e| e.is_cut() && e.items().contains(&selection)), - filename_text_color, - diagnostic_severity, - git_status: status, - is_private: entry.is_private, - worktree_id: *worktree_id, - canonical_path: entry.canonical_path.clone(), - }; + let mut details = self.details_for_entry( + entry, + *worktree_id, + root_name, + entries, + status, + None, + window, + cx, + ); if let Some(edit_state) = &self.edit_state { let is_edited_entry = if edit_state.is_new_entry() { @@ -3879,6 +3813,8 @@ impl ProjectPanel { const GROUP_NAME: &str = "project_entry"; let kind = details.kind; + let is_sticky = details.sticky.is_some(); + let sticky_index = details.sticky.as_ref().map(|this| this.sticky_index); let settings = ProjectPanelSettings::get_global(cx); let show_editor = details.is_editing && !details.is_processing; @@ -4002,141 +3938,144 @@ impl ProjectPanel { .border_r_2() .border_color(border_color) .hover(|style| style.bg(bg_hover_color).border_color(border_hover_color)) - .on_drag_move::(cx.listener( - move |this, event: &DragMoveEvent, _, cx| { - let is_current_target = this.drag_target_entry.as_ref() - .map(|entry| entry.entry_id) == Some(entry_id); + .when(!is_sticky, |this| { + this + .when(is_highlighted && folded_directory_drag_target.is_none(), |this| this.border_color(transparent_white()).bg(item_colors.drag_over)) + .on_drag_move::(cx.listener( + move |this, event: &DragMoveEvent, _, cx| { + let is_current_target = this.drag_target_entry.as_ref() + .map(|entry| entry.entry_id) == Some(entry_id); - if !event.bounds.contains(&event.event.position) { - // Entry responsible for setting drag target is also responsible to - // clear it up after drag is out of bounds - if is_current_target { - this.drag_target_entry = None; + if !event.bounds.contains(&event.event.position) { + // Entry responsible for setting drag target is also responsible to + // clear it up after drag is out of bounds + if is_current_target { + this.drag_target_entry = None; + } + return; } - return; - } - if is_current_target { - return; - } - - let Some((entry_id, highlight_entry_id)) = maybe!({ - let target_worktree = this.project.read(cx).worktree_for_id(selection.worktree_id, cx)?.read(cx); - let target_entry = target_worktree.entry_for_path(&path_for_external_paths)?; - let highlight_entry_id = this.highlight_entry_for_external_drag(target_entry, target_worktree); - Some((target_entry.id, highlight_entry_id)) - }) else { - return; - }; - - this.drag_target_entry = Some(DragTargetEntry { - entry_id, - highlight_entry_id, - }); - this.marked_entries.clear(); - }, - )) - .on_drop(cx.listener( - move |this, external_paths: &ExternalPaths, window, cx| { - this.drag_target_entry = None; - this.hover_scroll_task.take(); - this.drop_external_files(external_paths.paths(), entry_id, window, cx); - cx.stop_propagation(); - }, - )) - .on_drag_move::(cx.listener( - move |this, event: &DragMoveEvent, window, cx| { - let is_current_target = this.drag_target_entry.as_ref() - .map(|entry| entry.entry_id) == Some(entry_id); - - if !event.bounds.contains(&event.event.position) { - // Entry responsible for setting drag target is also responsible to - // clear it up after drag is out of bounds if is_current_target { - this.drag_target_entry = None; + return; } - return; - } - if is_current_target { - return; - } + let Some((entry_id, highlight_entry_id)) = maybe!({ + let target_worktree = this.project.read(cx).worktree_for_id(selection.worktree_id, cx)?.read(cx); + let target_entry = target_worktree.entry_for_path(&path_for_external_paths)?; + let highlight_entry_id = this.highlight_entry_for_external_drag(target_entry, target_worktree); + Some((target_entry.id, highlight_entry_id)) + }) else { + return; + }; - let drag_state = event.drag(cx); - let Some((entry_id, highlight_entry_id)) = maybe!({ - let target_worktree = this.project.read(cx).worktree_for_id(selection.worktree_id, cx)?.read(cx); - let target_entry = target_worktree.entry_for_path(&path_for_dragged_selection)?; - let highlight_entry_id = this.highlight_entry_for_selection_drag(target_entry, target_worktree, drag_state, cx); - Some((target_entry.id, highlight_entry_id)) - }) else { - return; - }; - - this.drag_target_entry = Some(DragTargetEntry { - entry_id, - highlight_entry_id, - }); - if drag_state.items().count() == 1 { + this.drag_target_entry = Some(DragTargetEntry { + entry_id, + highlight_entry_id, + }); this.marked_entries.clear(); - this.marked_entries.insert(drag_state.active_selection); - } - this.hover_expand_task.take(); + }, + )) + .on_drop(cx.listener( + move |this, external_paths: &ExternalPaths, window, cx| { + this.drag_target_entry = None; + this.hover_scroll_task.take(); + this.drop_external_files(external_paths.paths(), entry_id, window, cx); + cx.stop_propagation(); + }, + )) + .on_drag_move::(cx.listener( + move |this, event: &DragMoveEvent, window, cx| { + let is_current_target = this.drag_target_entry.as_ref() + .map(|entry| entry.entry_id) == Some(entry_id); - if !kind.is_dir() - || this - .expanded_dir_ids - .get(&details.worktree_id) - .map_or(false, |ids| ids.binary_search(&entry_id).is_ok()) - { - return; - } + if !event.bounds.contains(&event.event.position) { + // Entry responsible for setting drag target is also responsible to + // clear it up after drag is out of bounds + if is_current_target { + this.drag_target_entry = None; + } + return; + } - let bounds = event.bounds; - this.hover_expand_task = - Some(cx.spawn_in(window, async move |this, cx| { - cx.background_executor() - .timer(Duration::from_millis(500)) - .await; - this.update_in(cx, |this, window, cx| { - this.hover_expand_task.take(); - if this.drag_target_entry.as_ref().map(|entry| entry.entry_id) == Some(entry_id) - && bounds.contains(&window.mouse_position()) - { - this.expand_entry(worktree_id, entry_id, cx); - this.update_visible_entries( - Some((worktree_id, entry_id)), - cx, - ); - cx.notify(); - } - }) - .ok(); - })); - }, - )) - .on_drag( - dragged_selection, - move |selection, click_offset, _window, cx| { - cx.new(|_| DraggedProjectEntryView { - details: details.clone(), - click_offset, - selection: selection.active_selection, - selections: selection.marked_selections.clone(), - }) - }, - ) - .when(is_highlighted && folded_directory_drag_target.is_none(), |this| this.border_color(transparent_white()).bg(item_colors.drag_over)) - .on_drop( - cx.listener(move |this, selections: &DraggedSelection, window, cx| { - this.drag_target_entry = None; - this.hover_scroll_task.take(); - this.hover_expand_task.take(); - if folded_directory_drag_target.is_some() { - return; - } - this.drag_onto(selections, entry_id, kind.is_file(), window, cx); - }), - ) + if is_current_target { + return; + } + + let drag_state = event.drag(cx); + let Some((entry_id, highlight_entry_id)) = maybe!({ + let target_worktree = this.project.read(cx).worktree_for_id(selection.worktree_id, cx)?.read(cx); + let target_entry = target_worktree.entry_for_path(&path_for_dragged_selection)?; + let highlight_entry_id = this.highlight_entry_for_selection_drag(target_entry, target_worktree, drag_state, cx); + Some((target_entry.id, highlight_entry_id)) + }) else { + return; + }; + + this.drag_target_entry = Some(DragTargetEntry { + entry_id, + highlight_entry_id, + }); + if drag_state.items().count() == 1 { + this.marked_entries.clear(); + this.marked_entries.insert(drag_state.active_selection); + } + this.hover_expand_task.take(); + + if !kind.is_dir() + || this + .expanded_dir_ids + .get(&details.worktree_id) + .map_or(false, |ids| ids.binary_search(&entry_id).is_ok()) + { + return; + } + + let bounds = event.bounds; + this.hover_expand_task = + Some(cx.spawn_in(window, async move |this, cx| { + cx.background_executor() + .timer(Duration::from_millis(500)) + .await; + this.update_in(cx, |this, window, cx| { + this.hover_expand_task.take(); + if this.drag_target_entry.as_ref().map(|entry| entry.entry_id) == Some(entry_id) + && bounds.contains(&window.mouse_position()) + { + this.expand_entry(worktree_id, entry_id, cx); + this.update_visible_entries( + Some((worktree_id, entry_id)), + cx, + ); + cx.notify(); + } + }) + .ok(); + })); + }, + )) + .on_drag( + dragged_selection, + move |selection, click_offset, _window, cx| { + cx.new(|_| DraggedProjectEntryView { + details: details.clone(), + click_offset, + selection: selection.active_selection, + selections: selection.marked_selections.clone(), + }) + }, + ) + .on_drop( + cx.listener(move |this, selections: &DraggedSelection, window, cx| { + this.drag_target_entry = None; + this.hover_scroll_task.take(); + this.hover_expand_task.take(); + if folded_directory_drag_target.is_some() { + return; + } + this.drag_onto(selections, entry_id, kind.is_file(), window, cx); + }), + ) + }) .on_mouse_down( MouseButton::Left, cx.listener(move |this, _, _, cx| { @@ -4168,7 +4107,7 @@ impl ProjectPanel { 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 range_end = source_index.max(target_index) + 1; let mut new_selections = BTreeSet::new(); this.for_each_visible_entry( range_start..range_end, @@ -4214,6 +4153,16 @@ impl ProjectPanel { let allow_preview = preview_tabs_enabled && click_count == 1; this.open_entry(entry_id, focus_opened_item, allow_preview, cx); } + + if is_sticky { + if let Some((_, _, index)) = this.index_for_entry(entry_id, worktree_id) { + let strategy = sticky_index + .map(ScrollStrategy::ToPosition) + .unwrap_or(ScrollStrategy::Top); + this.scroll_handle.scroll_to_item(index, strategy); + cx.notify(); + } + } }), ) .child( @@ -4328,38 +4277,41 @@ impl ProjectPanel { let target_entry_id = folded_ancestors.ancestors.get(components_len - 1 - delimiter_target_index).cloned(); this = this.child( div() - .on_drop(cx.listener(move |this, selections: &DraggedSelection, window, cx| { - this.hover_scroll_task.take(); - this.drag_target_entry = None; - this.folded_directory_drag_target = None; - if let Some(target_entry_id) = target_entry_id { - this.drag_onto(selections, target_entry_id, kind.is_file(), window, cx); - } - })) - .on_drag_move(cx.listener( - move |this, event: &DragMoveEvent, _, _| { - if event.bounds.contains(&event.event.position) { - this.folded_directory_drag_target = Some( - FoldedDirectoryDragTarget { - entry_id, - index: delimiter_target_index, - is_delimiter_target: true, - } - ); - } else { - let is_current_target = this.folded_directory_drag_target - .map_or(false, |target| - target.entry_id == entry_id && - target.index == delimiter_target_index && - target.is_delimiter_target - ); - if is_current_target { - this.folded_directory_drag_target = None; - } + .when(!is_sticky, |div| { + div + .on_drop(cx.listener(move |this, selections: &DraggedSelection, window, cx| { + this.hover_scroll_task.take(); + this.drag_target_entry = None; + this.folded_directory_drag_target = None; + if let Some(target_entry_id) = target_entry_id { + this.drag_onto(selections, target_entry_id, kind.is_file(), window, cx); } + })) + .on_drag_move(cx.listener( + move |this, event: &DragMoveEvent, _, _| { + if event.bounds.contains(&event.event.position) { + this.folded_directory_drag_target = Some( + FoldedDirectoryDragTarget { + entry_id, + index: delimiter_target_index, + is_delimiter_target: true, + } + ); + } else { + let is_current_target = this.folded_directory_drag_target + .map_or(false, |target| + target.entry_id == entry_id && + target.index == delimiter_target_index && + target.is_delimiter_target + ); + if is_current_target { + this.folded_directory_drag_target = None; + } + } - }, - )) + }, + )) + }) .child( Label::new(DELIMITER.clone()) .single_line() @@ -4373,6 +4325,51 @@ impl ProjectPanel { )); let label = div() .id(id) + .when(!is_sticky,| div| { + div + .when(index != components_len - 1, |div|{ + let target_entry_id = folded_ancestors.ancestors.get(components_len - 1 - index).cloned(); + div + .on_drag_move(cx.listener( + move |this, event: &DragMoveEvent, _, _| { + if event.bounds.contains(&event.event.position) { + this.folded_directory_drag_target = Some( + FoldedDirectoryDragTarget { + entry_id, + index, + is_delimiter_target: false, + } + ); + } else { + let is_current_target = this.folded_directory_drag_target + .as_ref() + .map_or(false, |target| + target.entry_id == entry_id && + target.index == index && + !target.is_delimiter_target + ); + if is_current_target { + this.folded_directory_drag_target = None; + } + } + }, + )) + .on_drop(cx.listener(move |this, selections: &DraggedSelection, window,cx| { + this.hover_scroll_task.take(); + this.drag_target_entry = None; + this.folded_directory_drag_target = None; + if let Some(target_entry_id) = target_entry_id { + this.drag_onto(selections, target_entry_id, kind.is_file(), window, cx); + } + })) + .when(folded_directory_drag_target.map_or(false, |target| + target.entry_id == entry_id && + target.index == index + ), |this| { + this.bg(item_colors.drag_over) + }) + }) + }) .on_click(cx.listener(move |this, _, _, cx| { if index != active_index { if let Some(folds) = @@ -4384,48 +4381,6 @@ impl ProjectPanel { } } })) - .when(index != components_len - 1, |div|{ - let target_entry_id = folded_ancestors.ancestors.get(components_len - 1 - index).cloned(); - div - .on_drag_move(cx.listener( - move |this, event: &DragMoveEvent, _, _| { - if event.bounds.contains(&event.event.position) { - this.folded_directory_drag_target = Some( - FoldedDirectoryDragTarget { - entry_id, - index, - is_delimiter_target: false, - } - ); - } else { - let is_current_target = this.folded_directory_drag_target - .as_ref() - .map_or(false, |target| - target.entry_id == entry_id && - target.index == index && - !target.is_delimiter_target - ); - if is_current_target { - this.folded_directory_drag_target = None; - } - } - }, - )) - .on_drop(cx.listener(move |this, selections: &DraggedSelection, window,cx| { - this.hover_scroll_task.take(); - this.drag_target_entry = None; - this.folded_directory_drag_target = None; - if let Some(target_entry_id) = target_entry_id { - this.drag_onto(selections, target_entry_id, kind.is_file(), window, cx); - } - })) - .when(folded_directory_drag_target.map_or(false, |target| - target.entry_id == entry_id && - target.index == index - ), |this| { - this.bg(item_colors.drag_over) - }) - }) .child( Label::new(component) .single_line() @@ -4497,6 +4452,108 @@ impl ProjectPanel { ) } + fn details_for_entry( + &self, + entry: &Entry, + worktree_id: WorktreeId, + root_name: &OsStr, + entries_paths: &HashSet>, + git_status: GitSummary, + sticky: Option, + _window: &mut Window, + cx: &mut Context, + ) -> EntryDetails { + let (show_file_icons, show_folder_icons) = { + let settings = ProjectPanelSettings::get_global(cx); + (settings.file_icons, settings.folder_icons) + }; + + let expanded_entry_ids = self + .expanded_dir_ids + .get(&worktree_id) + .map(Vec::as_slice) + .unwrap_or(&[]); + let is_expanded = expanded_entry_ids.binary_search(&entry.id).is_ok(); + + let icon = match entry.kind { + EntryKind::File => { + if show_file_icons { + FileIcons::get_icon(&entry.path, cx) + } else { + None + } + } + _ => { + if show_folder_icons { + FileIcons::get_folder_icon(is_expanded, cx) + } else { + FileIcons::get_chevron_icon(is_expanded, cx) + } + } + }; + + let (depth, difference) = + ProjectPanel::calculate_depth_and_difference(&entry, entries_paths); + + let filename = match difference { + diff if diff > 1 => entry + .path + .iter() + .skip(entry.path.components().count() - diff) + .collect::() + .to_str() + .unwrap_or_default() + .to_string(), + _ => entry + .path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| root_name.to_string_lossy().to_string()), + }; + + let selection = SelectedEntry { + worktree_id, + entry_id: entry.id, + }; + let is_marked = self.marked_entries.contains(&selection); + let is_selected = self.selection == Some(selection); + + let diagnostic_severity = self + .diagnostics + .get(&(worktree_id, entry.path.to_path_buf())) + .cloned(); + + let filename_text_color = + entry_git_aware_label_color(git_status, entry.is_ignored, is_marked); + + let is_cut = self + .clipboard + .as_ref() + .map_or(false, |e| e.is_cut() && e.items().contains(&selection)); + + EntryDetails { + filename, + icon, + path: entry.path.clone(), + depth, + kind: entry.kind, + is_ignored: entry.is_ignored, + is_expanded, + is_selected, + is_marked, + is_editing: false, + is_processing: false, + is_cut, + sticky, + filename_text_color, + diagnostic_severity, + git_status, + is_private: entry.is_private, + worktree_id, + canonical_path: entry.canonical_path.clone(), + } + } + fn render_vertical_scrollbar(&self, cx: &mut Context) -> Option> { if !Self::should_show_scrollbar(cx) || !(self.show_scrollbar || self.vertical_scrollbar_state.is_dragging()) @@ -4751,6 +4808,156 @@ impl ProjectPanel { } None } + + fn candidate_entries_in_range_for_sticky( + &self, + range: Range, + _window: &mut Window, + _cx: &mut Context, + ) -> Vec { + let mut result = Vec::new(); + let mut current_offset = 0; + + for (_, visible_worktree_entries, entries_paths) in &self.visible_entries { + let worktree_len = visible_worktree_entries.len(); + let worktree_end_offset = current_offset + worktree_len; + + if current_offset >= range.end { + break; + } + + if worktree_end_offset > range.start { + let local_start = range.start.saturating_sub(current_offset); + let local_end = range.end.saturating_sub(current_offset).min(worktree_len); + + let paths = entries_paths.get_or_init(|| { + visible_worktree_entries + .iter() + .map(|e| e.path.clone()) + .collect() + }); + + let entries_from_this_worktree = visible_worktree_entries[local_start..local_end] + .iter() + .enumerate() + .map(|(i, entry)| { + let (depth, _) = Self::calculate_depth_and_difference(&entry.entry, paths); + StickyProjectPanelCandidate { + index: current_offset + local_start + i, + depth, + } + }); + + result.extend(entries_from_this_worktree); + } + + current_offset = worktree_end_offset; + } + + result + } + + fn render_sticky_entries( + &self, + child: StickyProjectPanelCandidate, + window: &mut Window, + cx: &mut Context, + ) -> SmallVec<[AnyElement; 8]> { + let project = self.project.read(cx); + + let Some((worktree_id, entry_ref)) = self.entry_at_index(child.index) else { + return SmallVec::new(); + }; + + let Some((_, visible_worktree_entries, entries_paths)) = self + .visible_entries + .iter() + .find(|(id, _, _)| *id == worktree_id) + else { + return SmallVec::new(); + }; + + let Some(worktree) = project.worktree_for_id(worktree_id, cx) else { + return SmallVec::new(); + }; + let worktree = worktree.read(cx).snapshot(); + + let paths = entries_paths.get_or_init(|| { + visible_worktree_entries + .iter() + .map(|e| e.path.clone()) + .collect() + }); + + let mut sticky_parents = Vec::new(); + let mut current_path = entry_ref.path.clone(); + + 'outer: loop { + if let Some(parent_path) = current_path.parent() { + for ancestor_path in parent_path.ancestors() { + if paths.contains(ancestor_path) { + if let Some(parent_entry) = worktree.entry_for_path(ancestor_path) { + sticky_parents.push(parent_entry.clone()); + current_path = parent_entry.path.clone(); + continue 'outer; + } + } + } + } + break 'outer; + } + + sticky_parents.reverse(); + + let git_status_enabled = ProjectPanelSettings::get_global(cx).git_status; + let root_name = OsStr::new(worktree.root_name()); + + let git_summaries_by_id = if git_status_enabled { + visible_worktree_entries + .iter() + .map(|e| (e.id, e.git_summary)) + .collect::>() + } else { + Default::default() + }; + + sticky_parents + .iter() + .enumerate() + .map(|(index, entry)| { + let git_status = git_summaries_by_id + .get(&entry.id) + .copied() + .unwrap_or_default(); + let sticky_details = Some(StickyDetails { + sticky_index: index, + }); + let details = self.details_for_entry( + entry, + worktree_id, + root_name, + paths, + git_status, + sticky_details, + window, + cx, + ); + self.render_entry(entry.id, details, window, cx).into_any() + }) + .collect() + } +} + +#[derive(Clone)] +struct StickyProjectPanelCandidate { + index: usize, + depth: usize, +} + +impl StickyCandidate for StickyProjectPanelCandidate { + fn depth(&self) -> usize { + self.depth + } } fn item_width_estimate(depth: usize, item_text_chars: usize, is_symlink: bool) -> usize { @@ -4769,6 +4976,7 @@ impl Render for ProjectPanel { let indent_size = ProjectPanelSettings::get_global(cx).indent_size; let show_indent_guides = ProjectPanelSettings::get_global(cx).indent_guides.show == ShowIndentGuides::Always; + let show_sticky_scroll = ProjectPanelSettings::get_global(cx).sticky_scroll; let is_local = project.is_local(); if has_worktree { @@ -4963,6 +5171,17 @@ impl Render for ProjectPanel { items }) }) + .when(show_sticky_scroll, |list| { + list.with_top_slot(ui::sticky_items( + cx.entity().clone(), + |this, range, window, cx| { + this.candidate_entries_in_range_for_sticky(range, window, cx) + }, + |this, marker_entry, window, cx| { + this.render_sticky_entries(marker_entry, window, cx) + }, + )) + }) .when(show_indent_guides, |list| { list.with_decoration( ui::indent_guides( @@ -5079,7 +5298,7 @@ impl Render for ProjectPanel { .anchor(gpui::Corner::TopLeft) .child(menu.clone()), ) - .with_priority(1) + .with_priority(3) })) } else { v_flex() diff --git a/crates/project_panel/src/project_panel_settings.rs b/crates/project_panel/src/project_panel_settings.rs index 31f4a21b09..9057480972 100644 --- a/crates/project_panel/src/project_panel_settings.rs +++ b/crates/project_panel/src/project_panel_settings.rs @@ -40,6 +40,7 @@ pub struct ProjectPanelSettings { pub git_status: bool, pub indent_size: f32, pub indent_guides: IndentGuidesSettings, + pub sticky_scroll: bool, pub auto_reveal_entries: bool, pub auto_fold_dirs: bool, pub scrollbar: ScrollbarSettings, @@ -150,6 +151,10 @@ pub struct ProjectPanelSettingsContent { /// /// Default: false pub hide_root: Option, + /// Whether to stick parent directories at top of the project panel. + /// + /// Default: true + pub sticky_scroll: Option, } impl Settings for ProjectPanelSettings { diff --git a/crates/ui/src/components.rs b/crates/ui/src/components.rs index 237403d4ba..88676e8a2b 100644 --- a/crates/ui/src/components.rs +++ b/crates/ui/src/components.rs @@ -30,6 +30,7 @@ mod scrollbar; mod settings_container; mod settings_group; mod stack; +mod sticky_items; mod tab; mod tab_bar; mod toggle; @@ -70,6 +71,7 @@ pub use scrollbar::*; pub use settings_container::*; pub use settings_group::*; pub use stack::*; +pub use sticky_items::*; pub use tab::*; pub use tab_bar::*; pub use toggle::*; diff --git a/crates/ui/src/components/sticky_items.rs b/crates/ui/src/components/sticky_items.rs new file mode 100644 index 0000000000..e5ef0cdf27 --- /dev/null +++ b/crates/ui/src/components/sticky_items.rs @@ -0,0 +1,150 @@ +use std::ops::Range; + +use gpui::{ + AnyElement, App, AvailableSpace, Bounds, Context, Entity, Pixels, Render, UniformListTopSlot, + Window, point, size, +}; +use smallvec::SmallVec; + +pub trait StickyCandidate { + fn depth(&self) -> usize; +} + +pub struct StickyItems { + compute_fn: Box, &mut Window, &mut App) -> Vec>, + render_fn: Box SmallVec<[AnyElement; 8]>>, + last_item_is_drifting: bool, + anchor_index: Option, +} + +pub fn sticky_items( + entity: Entity, + compute_fn: impl Fn(&mut V, Range, &mut Window, &mut Context) -> Vec + 'static, + render_fn: impl Fn(&mut V, T, &mut Window, &mut Context) -> SmallVec<[AnyElement; 8]> + 'static, +) -> StickyItems +where + V: Render, + T: StickyCandidate + Clone + 'static, +{ + let entity_compute = entity.clone(); + let entity_render = entity.clone(); + + let compute_fn = Box::new( + move |range: Range, window: &mut Window, cx: &mut App| -> Vec { + entity_compute.update(cx, |view, cx| compute_fn(view, range, window, cx)) + }, + ); + let render_fn = Box::new( + move |entry: T, window: &mut Window, cx: &mut App| -> SmallVec<[AnyElement; 8]> { + entity_render.update(cx, |view, cx| render_fn(view, entry, window, cx)) + }, + ); + StickyItems { + compute_fn, + render_fn, + last_item_is_drifting: false, + anchor_index: None, + } +} + +impl UniformListTopSlot for StickyItems +where + T: StickyCandidate + Clone + 'static, +{ + fn compute( + &mut self, + visible_range: Range, + window: &mut Window, + cx: &mut App, + ) -> SmallVec<[AnyElement; 8]> { + let entries = (self.compute_fn)(visible_range.clone(), window, cx); + + let mut anchor_entry = None; + + let mut iter = entries.iter().enumerate().peekable(); + while let Some((ix, current_entry)) = iter.next() { + let current_depth = current_entry.depth(); + let index_in_range = ix; + + if current_depth < index_in_range { + anchor_entry = Some(current_entry.clone()); + break; + } + + if let Some(&(_next_ix, next_entry)) = iter.peek() { + let next_depth = next_entry.depth(); + + if next_depth < current_depth && next_depth < index_in_range { + self.last_item_is_drifting = true; + self.anchor_index = Some(visible_range.start + ix); + anchor_entry = Some(current_entry.clone()); + break; + } + } + } + + if let Some(anchor_entry) = anchor_entry { + (self.render_fn)(anchor_entry, window, cx) + } else { + SmallVec::new() + } + } + + fn prepaint( + &self, + items: &mut SmallVec<[AnyElement; 8]>, + bounds: Bounds, + item_height: Pixels, + scroll_offset: gpui::Point, + padding: gpui::Edges, + can_scroll_horizontally: bool, + window: &mut Window, + cx: &mut App, + ) { + let items_count = items.len(); + + for (ix, item) in items.iter_mut().enumerate() { + let mut item_y_offset = None; + if ix == items_count - 1 && self.last_item_is_drifting { + if let Some(anchor_index) = self.anchor_index { + let scroll_top = -scroll_offset.y; + let anchor_top = item_height * anchor_index; + let sticky_area_height = item_height * items_count; + item_y_offset = + Some((anchor_top - scroll_top - sticky_area_height).min(Pixels::ZERO)); + }; + } + + let sticky_origin = bounds.origin + + point( + if can_scroll_horizontally { + scroll_offset.x + padding.left + } else { + scroll_offset.x + }, + item_height * ix + padding.top + item_y_offset.unwrap_or(Pixels::ZERO), + ); + + let available_width = if can_scroll_horizontally { + bounds.size.width + scroll_offset.x.abs() + } else { + bounds.size.width + }; + + let available_space = size( + AvailableSpace::Definite(available_width), + AvailableSpace::Definite(item_height), + ); + + item.layout_as_root(available_space, window, cx); + item.prepaint_at(sticky_origin, window, cx); + } + } + + fn paint(&self, items: &mut SmallVec<[AnyElement; 8]>, window: &mut Window, cx: &mut App) { + // reverse so that last item is bottom most among sticky items + for item in items.iter_mut().rev() { + item.paint(window, cx); + } + } +}