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.
This commit is contained in:
Smit Barmase 2025-07-07 08:32:42 +05:30 committed by GitHub
parent 2246b01c4b
commit 6efc5ecefe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 742 additions and 287 deletions

View file

@ -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);
.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;
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::<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 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::<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 !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<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;
}
.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()
@ -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<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)
})
})
})
.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()

View file

@ -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 {