@@ -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>,
@@ -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()
@@ -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);
+ }
+ }
+}