project_panel: Add Sticky Scroll (#33994)

Smit Barmase created

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.

Change summary

assets/settings/default.json                       |   2 
crates/gpui/src/elements/uniform_list.rs           |  83 +
crates/project_panel/src/project_panel.rs          | 779 ++++++++++-----
crates/project_panel/src/project_panel_settings.rs |   5 
crates/ui/src/components.rs                        |   2 
crates/ui/src/components/sticky_items.rs           | 150 +++
6 files changed, 738 insertions(+), 283 deletions(-)

Detailed changes

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.

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<usize>, &'a mut Window, &'a mut App) -> SmallVec<[AnyElement; 64]>,
     >,
+    top_slot: Option<Box<dyn UniformListTopSlot>>,
     decorations: Vec<Box<dyn UniformListDecoration>>,
     interactivity: Interactivity,
     scroll_handle: Option<UniformListScrollHandle>,
@@ -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<usize>,
+        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<Pixels>,
+        item_height: Pixels,
+        scroll_offset: Point<Pixels>,
+        padding: crate::Edges<Pixels>,
+        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<usize>) -> 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<Pixels>,

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<StickyDetails>,
     filename_text_color: Color,
     diagnostic_severity: Option<DiagnosticSeverity>,
     git_status: GitSummary,
@@ -181,6 +182,11 @@ struct EntryDetails {
     canonical_path: Option<Arc<Path>>,
 }
 
+#[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::<PathBuf>()
-                            .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::<ExternalPaths>(cx.listener(
-                move |this, event: &DragMoveEvent<ExternalPaths>, _, 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
+            .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::<ExternalPaths>(cx.listener(
+                    move |this, event: &DragMoveEvent<ExternalPaths>, _, 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;
+                        }
+
                         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 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::<DraggedSelection>(cx.listener(
+                    move |this, event: &DragMoveEvent<DraggedSelection>, 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;
+                        }
 
-                    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::<DraggedSelection>(cx.listener(
-                move |this, event: &DragMoveEvent<DraggedSelection>, 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 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;
-                    };
+                        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();
+                        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;
-                    }
+                        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(),
-                    })
-                },
-            )
-            .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);
-                }),
-            )
+                        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,51 +4277,99 @@ 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);
-                                                        }
-                                                    }))
+                                                    .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<DraggedSelection>, _, _| {
+                                                                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()
+                                                            .color(filename_text_color)
+                                                    )
+                                                );
+                                        }
+                                        let id = SharedString::from(format!(
+                                            "project_panel_path_component_{}_{index}",
+                                            entry_id.to_usize()
+                                        ));
+                                        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<DraggedSelection>, _, _| {
-                                                            if event.bounds.contains(&event.event.position) {
+                                                        if event.bounds.contains(&event.event.position) {
                                                                 this.folded_directory_drag_target = Some(
                                                                     FoldedDirectoryDragTarget {
                                                                         entry_id,
-                                                                        index: delimiter_target_index,
-                                                                        is_delimiter_target: true,
+                                                                        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 == delimiter_target_index &&
-                                                                        target.is_delimiter_target
+                                                                        target.index == index &&
+                                                                        !target.is_delimiter_target
                                                                     );
                                                                 if is_current_target {
                                                                     this.folded_directory_drag_target = None;
                                                                 }
                                                             }
-
                                                         },
                                                     ))
-                                                    .child(
-                                                        Label::new(DELIMITER.clone())
-                                                            .single_line()
-                                                            .color(filename_text_color)
-                                                    )
-                                                );
-                                        }
-                                        let id = SharedString::from(format!(
-                                            "project_panel_path_component_{}_{index}",
-                                            entry_id.to_usize()
-                                        ));
-                                        let label = div()
-                                            .id(id)
+                                                    .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<DraggedSelection>, _, _| {
-                                                    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<Arc<Path>>,
+        git_status: GitSummary,
+        sticky: Option<StickyDetails>,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> 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::<PathBuf>()
+                .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<Self>) -> Option<Stateful<Div>> {
         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<usize>,
+        _window: &mut Window,
+        _cx: &mut Context<Self>,
+    ) -> Vec<StickyProjectPanelCandidate> {
+        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<Self>,
+    ) -> 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::<HashMap<_, _>>()
+        } 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()

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<bool>,
+    /// Whether to stick parent directories at top of the project panel.
+    ///
+    /// Default: true
+    pub sticky_scroll: Option<bool>,
 }
 
 impl Settings for ProjectPanelSettings {

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::*;

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<T> {
+    compute_fn: Box<dyn Fn(Range<usize>, &mut Window, &mut App) -> Vec<T>>,
+    render_fn: Box<dyn Fn(T, &mut Window, &mut App) -> SmallVec<[AnyElement; 8]>>,
+    last_item_is_drifting: bool,
+    anchor_index: Option<usize>,
+}
+
+pub fn sticky_items<V, T>(
+    entity: Entity<V>,
+    compute_fn: impl Fn(&mut V, Range<usize>, &mut Window, &mut Context<V>) -> Vec<T> + 'static,
+    render_fn: impl Fn(&mut V, T, &mut Window, &mut Context<V>) -> SmallVec<[AnyElement; 8]> + 'static,
+) -> StickyItems<T>
+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<usize>, window: &mut Window, cx: &mut App| -> Vec<T> {
+            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<T> UniformListTopSlot for StickyItems<T>
+where
+    T: StickyCandidate + Clone + 'static,
+{
+    fn compute(
+        &mut self,
+        visible_range: Range<usize>,
+        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<Pixels>,
+        item_height: Pixels,
+        scroll_offset: gpui::Point<Pixels>,
+        padding: gpui::Edges<Pixels>,
+        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);
+        }
+    }
+}