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:
parent
2246b01c4b
commit
6efc5ecefe
6 changed files with 742 additions and 287 deletions
|
@ -617,6 +617,8 @@
|
||||||
// 3. Mark files with errors and warnings:
|
// 3. Mark files with errors and warnings:
|
||||||
// "all"
|
// "all"
|
||||||
"show_diagnostics": "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.
|
// Settings related to indent guides in the project panel.
|
||||||
"indent_guides": {
|
"indent_guides": {
|
||||||
// When to show indent guides in the project panel.
|
// When to show indent guides in the project panel.
|
||||||
|
|
|
@ -7,8 +7,8 @@
|
||||||
use crate::{
|
use crate::{
|
||||||
AnyElement, App, AvailableSpace, Bounds, ContentMask, Element, ElementId, GlobalElementId,
|
AnyElement, App, AvailableSpace, Bounds, ContentMask, Element, ElementId, GlobalElementId,
|
||||||
Hitbox, InspectorElementId, InteractiveElement, Interactivity, IntoElement, IsZero, LayoutId,
|
Hitbox, InspectorElementId, InteractiveElement, Interactivity, IntoElement, IsZero, LayoutId,
|
||||||
ListSizingBehavior, Overflow, Pixels, ScrollHandle, Size, StyleRefinement, Styled, Window,
|
ListSizingBehavior, Overflow, Pixels, Point, ScrollHandle, Size, StyleRefinement, Styled,
|
||||||
point, size,
|
Window, point, size,
|
||||||
};
|
};
|
||||||
use smallvec::SmallVec;
|
use smallvec::SmallVec;
|
||||||
use std::{cell::RefCell, cmp, ops::Range, rc::Rc};
|
use std::{cell::RefCell, cmp, ops::Range, rc::Rc};
|
||||||
|
@ -42,6 +42,7 @@ where
|
||||||
item_count,
|
item_count,
|
||||||
item_to_measure_index: 0,
|
item_to_measure_index: 0,
|
||||||
render_items: Box::new(render_range),
|
render_items: Box::new(render_range),
|
||||||
|
top_slot: None,
|
||||||
decorations: Vec::new(),
|
decorations: Vec::new(),
|
||||||
interactivity: Interactivity {
|
interactivity: Interactivity {
|
||||||
element_id: Some(id),
|
element_id: Some(id),
|
||||||
|
@ -61,6 +62,7 @@ pub struct UniformList {
|
||||||
render_items: Box<
|
render_items: Box<
|
||||||
dyn for<'a> Fn(Range<usize>, &'a mut Window, &'a mut App) -> SmallVec<[AnyElement; 64]>,
|
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>>,
|
decorations: Vec<Box<dyn UniformListDecoration>>,
|
||||||
interactivity: Interactivity,
|
interactivity: Interactivity,
|
||||||
scroll_handle: Option<UniformListScrollHandle>,
|
scroll_handle: Option<UniformListScrollHandle>,
|
||||||
|
@ -71,6 +73,7 @@ pub struct UniformList {
|
||||||
/// Frame state used by the [UniformList].
|
/// Frame state used by the [UniformList].
|
||||||
pub struct UniformListFrameState {
|
pub struct UniformListFrameState {
|
||||||
items: SmallVec<[AnyElement; 32]>,
|
items: SmallVec<[AnyElement; 32]>,
|
||||||
|
top_slot_items: SmallVec<[AnyElement; 8]>,
|
||||||
decorations: SmallVec<[AnyElement; 1]>,
|
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:
|
/// 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.
|
/// in this case, the element will be placed at the closest possible position.
|
||||||
Center,
|
Center,
|
||||||
|
/// Scrolls the element to be at the given item index from the top of the viewport.
|
||||||
|
ToPosition(usize),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default)]
|
#[derive(Clone, Debug, Default)]
|
||||||
|
@ -212,6 +217,7 @@ impl Element for UniformList {
|
||||||
UniformListFrameState {
|
UniformListFrameState {
|
||||||
items: SmallVec::new(),
|
items: SmallVec::new(),
|
||||||
decorations: 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
|
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)
|
let last_visible_element_ix = ((-scroll_offset.y + padded_bounds.size.height)
|
||||||
/ item_height)
|
/ item_height)
|
||||||
.ceil() as usize;
|
.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);
|
..cmp::min(last_visible_element_ix, self.item_count);
|
||||||
|
|
||||||
let items = if y_flipped {
|
let items = if y_flipped {
|
||||||
|
@ -393,6 +418,20 @@ impl Element for UniformList {
|
||||||
frame_state.items.push(item);
|
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(
|
let bounds = Bounds::new(
|
||||||
padded_bounds.origin
|
padded_bounds.origin
|
||||||
+ point(
|
+ point(
|
||||||
|
@ -454,6 +493,9 @@ impl Element for UniformList {
|
||||||
for decoration in &mut request_layout.decorations {
|
for decoration in &mut request_layout.decorations {
|
||||||
decoration.paint(window, cx);
|
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;
|
) -> 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 {
|
impl UniformList {
|
||||||
/// Selects a specific list item for measurement.
|
/// Selects a specific list item for measurement.
|
||||||
pub fn with_width_from_item(mut self, item_index: Option<usize>) -> Self {
|
pub fn with_width_from_item(mut self, item_index: Option<usize>) -> Self {
|
||||||
|
@ -521,6 +592,12 @@ impl UniformList {
|
||||||
self
|
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(
|
fn measure_item(
|
||||||
&self,
|
&self,
|
||||||
list_width: Option<Pixels>,
|
list_width: Option<Pixels>,
|
||||||
|
|
|
@ -56,7 +56,7 @@ use theme::ThemeSettings;
|
||||||
use ui::{
|
use ui::{
|
||||||
Color, ContextMenu, DecoratedIcon, Icon, IconDecoration, IconDecorationKind, IndentGuideColors,
|
Color, ContextMenu, DecoratedIcon, Icon, IconDecoration, IconDecorationKind, IndentGuideColors,
|
||||||
IndentGuideLayout, KeyBinding, Label, LabelSize, ListItem, ListItemSpacing, Scrollbar,
|
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 util::{ResultExt, TakeUntilExt, TryFutureExt, maybe, paths::compare_paths};
|
||||||
use workspace::{
|
use workspace::{
|
||||||
|
@ -173,6 +173,7 @@ struct EntryDetails {
|
||||||
is_editing: bool,
|
is_editing: bool,
|
||||||
is_processing: bool,
|
is_processing: bool,
|
||||||
is_cut: bool,
|
is_cut: bool,
|
||||||
|
sticky: Option<StickyDetails>,
|
||||||
filename_text_color: Color,
|
filename_text_color: Color,
|
||||||
diagnostic_severity: Option<DiagnosticSeverity>,
|
diagnostic_severity: Option<DiagnosticSeverity>,
|
||||||
git_status: GitSummary,
|
git_status: GitSummary,
|
||||||
|
@ -181,6 +182,11 @@ struct EntryDetails {
|
||||||
canonical_path: Option<Arc<Path>>,
|
canonical_path: Option<Arc<Path>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Eq, Clone)]
|
||||||
|
struct StickyDetails {
|
||||||
|
sticky_index: usize,
|
||||||
|
}
|
||||||
|
|
||||||
/// Permanently deletes the selected file or directory.
|
/// Permanently deletes the selected file or directory.
|
||||||
#[derive(PartialEq, Clone, Default, Debug, Deserialize, JsonSchema, Action)]
|
#[derive(PartialEq, Clone, Default, Debug, Deserialize, JsonSchema, Action)]
|
||||||
#[action(namespace = project_panel)]
|
#[action(namespace = project_panel)]
|
||||||
|
@ -3366,22 +3372,13 @@ impl ProjectPanel {
|
||||||
}
|
}
|
||||||
|
|
||||||
let end_ix = range.end.min(ix + visible_worktree_entries.len());
|
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);
|
let settings = ProjectPanelSettings::get_global(cx);
|
||||||
(
|
settings.git_status
|
||||||
settings.git_status,
|
|
||||||
settings.file_icons,
|
|
||||||
settings.folder_icons,
|
|
||||||
)
|
|
||||||
};
|
};
|
||||||
if let Some(worktree) = self.project.read(cx).worktree_for_id(*worktree_id, cx) {
|
if let Some(worktree) = self.project.read(cx).worktree_for_id(*worktree_id, cx) {
|
||||||
let snapshot = worktree.read(cx).snapshot();
|
let snapshot = worktree.read(cx).snapshot();
|
||||||
let root_name = OsStr::new(snapshot.root_name());
|
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 entry_range = range.start.saturating_sub(ix)..end_ix - ix;
|
||||||
let entries = entries_paths.get_or_init(|| {
|
let entries = entries_paths.get_or_init(|| {
|
||||||
|
@ -3394,80 +3391,17 @@ impl ProjectPanel {
|
||||||
let status = git_status_setting
|
let status = git_status_setting
|
||||||
.then_some(entry.git_summary)
|
.then_some(entry.git_summary)
|
||||||
.unwrap_or_default();
|
.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) =
|
let mut details = self.details_for_entry(
|
||||||
ProjectPanel::calculate_depth_and_difference(&entry, entries);
|
entry,
|
||||||
|
*worktree_id,
|
||||||
let filename = match difference {
|
root_name,
|
||||||
diff if diff > 1 => entry
|
entries,
|
||||||
.path
|
status,
|
||||||
.iter()
|
None,
|
||||||
.skip(entry.path.components().count() - diff)
|
window,
|
||||||
.collect::<PathBuf>()
|
cx,
|
||||||
.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(),
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(edit_state) = &self.edit_state {
|
if let Some(edit_state) = &self.edit_state {
|
||||||
let is_edited_entry = if edit_state.is_new_entry() {
|
let is_edited_entry = if edit_state.is_new_entry() {
|
||||||
|
@ -3879,6 +3813,8 @@ impl ProjectPanel {
|
||||||
const GROUP_NAME: &str = "project_entry";
|
const GROUP_NAME: &str = "project_entry";
|
||||||
|
|
||||||
let kind = details.kind;
|
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 settings = ProjectPanelSettings::get_global(cx);
|
||||||
let show_editor = details.is_editing && !details.is_processing;
|
let show_editor = details.is_editing && !details.is_processing;
|
||||||
|
|
||||||
|
@ -4002,141 +3938,144 @@ impl ProjectPanel {
|
||||||
.border_r_2()
|
.border_r_2()
|
||||||
.border_color(border_color)
|
.border_color(border_color)
|
||||||
.hover(|style| style.bg(bg_hover_color).border_color(border_hover_color))
|
.hover(|style| style.bg(bg_hover_color).border_color(border_hover_color))
|
||||||
.on_drag_move::<ExternalPaths>(cx.listener(
|
.when(!is_sticky, |this| {
|
||||||
move |this, event: &DragMoveEvent<ExternalPaths>, _, cx| {
|
this
|
||||||
let is_current_target = this.drag_target_entry.as_ref()
|
.when(is_highlighted && folded_directory_drag_target.is_none(), |this| this.border_color(transparent_white()).bg(item_colors.drag_over))
|
||||||
.map(|entry| entry.entry_id) == Some(entry_id);
|
.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) {
|
if !event.bounds.contains(&event.event.position) {
|
||||||
// Entry responsible for setting drag target is also responsible to
|
// Entry responsible for setting drag target is also responsible to
|
||||||
// clear it up after drag is out of bounds
|
// clear it up after drag is out of bounds
|
||||||
if is_current_target {
|
if is_current_target {
|
||||||
this.drag_target_entry = None;
|
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 {
|
if is_current_target {
|
||||||
this.drag_target_entry = None;
|
return;
|
||||||
}
|
}
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if is_current_target {
|
let Some((entry_id, highlight_entry_id)) = maybe!({
|
||||||
return;
|
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);
|
this.drag_target_entry = Some(DragTargetEntry {
|
||||||
let Some((entry_id, highlight_entry_id)) = maybe!({
|
entry_id,
|
||||||
let target_worktree = this.project.read(cx).worktree_for_id(selection.worktree_id, cx)?.read(cx);
|
highlight_entry_id,
|
||||||
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.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()
|
if !event.bounds.contains(&event.event.position) {
|
||||||
|| this
|
// Entry responsible for setting drag target is also responsible to
|
||||||
.expanded_dir_ids
|
// clear it up after drag is out of bounds
|
||||||
.get(&details.worktree_id)
|
if is_current_target {
|
||||||
.map_or(false, |ids| ids.binary_search(&entry_id).is_ok())
|
this.drag_target_entry = None;
|
||||||
{
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let bounds = event.bounds;
|
if is_current_target {
|
||||||
this.hover_expand_task =
|
return;
|
||||||
Some(cx.spawn_in(window, async move |this, cx| {
|
}
|
||||||
cx.background_executor()
|
|
||||||
.timer(Duration::from_millis(500))
|
let drag_state = event.drag(cx);
|
||||||
.await;
|
let Some((entry_id, highlight_entry_id)) = maybe!({
|
||||||
this.update_in(cx, |this, window, cx| {
|
let target_worktree = this.project.read(cx).worktree_for_id(selection.worktree_id, cx)?.read(cx);
|
||||||
this.hover_expand_task.take();
|
let target_entry = target_worktree.entry_for_path(&path_for_dragged_selection)?;
|
||||||
if this.drag_target_entry.as_ref().map(|entry| entry.entry_id) == Some(entry_id)
|
let highlight_entry_id = this.highlight_entry_for_selection_drag(target_entry, target_worktree, drag_state, cx);
|
||||||
&& bounds.contains(&window.mouse_position())
|
Some((target_entry.id, highlight_entry_id))
|
||||||
{
|
}) else {
|
||||||
this.expand_entry(worktree_id, entry_id, cx);
|
return;
|
||||||
this.update_visible_entries(
|
};
|
||||||
Some((worktree_id, entry_id)),
|
|
||||||
cx,
|
this.drag_target_entry = Some(DragTargetEntry {
|
||||||
);
|
entry_id,
|
||||||
cx.notify();
|
highlight_entry_id,
|
||||||
}
|
});
|
||||||
})
|
if drag_state.items().count() == 1 {
|
||||||
.ok();
|
this.marked_entries.clear();
|
||||||
}));
|
this.marked_entries.insert(drag_state.active_selection);
|
||||||
},
|
}
|
||||||
))
|
this.hover_expand_task.take();
|
||||||
.on_drag(
|
|
||||||
dragged_selection,
|
if !kind.is_dir()
|
||||||
move |selection, click_offset, _window, cx| {
|
|| this
|
||||||
cx.new(|_| DraggedProjectEntryView {
|
.expanded_dir_ids
|
||||||
details: details.clone(),
|
.get(&details.worktree_id)
|
||||||
click_offset,
|
.map_or(false, |ids| ids.binary_search(&entry_id).is_ok())
|
||||||
selection: selection.active_selection,
|
{
|
||||||
selections: selection.marked_selections.clone(),
|
return;
|
||||||
})
|
}
|
||||||
},
|
|
||||||
)
|
let bounds = event.bounds;
|
||||||
.when(is_highlighted && folded_directory_drag_target.is_none(), |this| this.border_color(transparent_white()).bg(item_colors.drag_over))
|
this.hover_expand_task =
|
||||||
.on_drop(
|
Some(cx.spawn_in(window, async move |this, cx| {
|
||||||
cx.listener(move |this, selections: &DraggedSelection, window, cx| {
|
cx.background_executor()
|
||||||
this.drag_target_entry = None;
|
.timer(Duration::from_millis(500))
|
||||||
this.hover_scroll_task.take();
|
.await;
|
||||||
this.hover_expand_task.take();
|
this.update_in(cx, |this, window, cx| {
|
||||||
if folded_directory_drag_target.is_some() {
|
this.hover_expand_task.take();
|
||||||
return;
|
if this.drag_target_entry.as_ref().map(|entry| entry.entry_id) == Some(entry_id)
|
||||||
}
|
&& bounds.contains(&window.mouse_position())
|
||||||
this.drag_onto(selections, entry_id, kind.is_file(), window, cx);
|
{
|
||||||
}),
|
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(
|
.on_mouse_down(
|
||||||
MouseButton::Left,
|
MouseButton::Left,
|
||||||
cx.listener(move |this, _, _, cx| {
|
cx.listener(move |this, _, _, cx| {
|
||||||
|
@ -4168,7 +4107,7 @@ impl ProjectPanel {
|
||||||
current_selection.zip(target_selection)
|
current_selection.zip(target_selection)
|
||||||
{
|
{
|
||||||
let range_start = source_index.min(target_index);
|
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();
|
let mut new_selections = BTreeSet::new();
|
||||||
this.for_each_visible_entry(
|
this.for_each_visible_entry(
|
||||||
range_start..range_end,
|
range_start..range_end,
|
||||||
|
@ -4214,6 +4153,16 @@ impl ProjectPanel {
|
||||||
let allow_preview = preview_tabs_enabled && click_count == 1;
|
let allow_preview = preview_tabs_enabled && click_count == 1;
|
||||||
this.open_entry(entry_id, focus_opened_item, allow_preview, cx);
|
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(
|
.child(
|
||||||
|
@ -4328,38 +4277,41 @@ impl ProjectPanel {
|
||||||
let target_entry_id = folded_ancestors.ancestors.get(components_len - 1 - delimiter_target_index).cloned();
|
let target_entry_id = folded_ancestors.ancestors.get(components_len - 1 - delimiter_target_index).cloned();
|
||||||
this = this.child(
|
this = this.child(
|
||||||
div()
|
div()
|
||||||
.on_drop(cx.listener(move |this, selections: &DraggedSelection, window, cx| {
|
.when(!is_sticky, |div| {
|
||||||
this.hover_scroll_task.take();
|
div
|
||||||
this.drag_target_entry = None;
|
.on_drop(cx.listener(move |this, selections: &DraggedSelection, window, cx| {
|
||||||
this.folded_directory_drag_target = None;
|
this.hover_scroll_task.take();
|
||||||
if let Some(target_entry_id) = target_entry_id {
|
this.drag_target_entry = None;
|
||||||
this.drag_onto(selections, target_entry_id, kind.is_file(), window, cx);
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}))
|
||||||
|
.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(
|
.child(
|
||||||
Label::new(DELIMITER.clone())
|
Label::new(DELIMITER.clone())
|
||||||
.single_line()
|
.single_line()
|
||||||
|
@ -4373,6 +4325,51 @@ impl ProjectPanel {
|
||||||
));
|
));
|
||||||
let label = div()
|
let label = div()
|
||||||
.id(id)
|
.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| {
|
.on_click(cx.listener(move |this, _, _, cx| {
|
||||||
if index != active_index {
|
if index != active_index {
|
||||||
if let Some(folds) =
|
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(
|
.child(
|
||||||
Label::new(component)
|
Label::new(component)
|
||||||
.single_line()
|
.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>> {
|
fn render_vertical_scrollbar(&self, cx: &mut Context<Self>) -> Option<Stateful<Div>> {
|
||||||
if !Self::should_show_scrollbar(cx)
|
if !Self::should_show_scrollbar(cx)
|
||||||
|| !(self.show_scrollbar || self.vertical_scrollbar_state.is_dragging())
|
|| !(self.show_scrollbar || self.vertical_scrollbar_state.is_dragging())
|
||||||
|
@ -4751,6 +4808,156 @@ impl ProjectPanel {
|
||||||
}
|
}
|
||||||
None
|
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 {
|
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 indent_size = ProjectPanelSettings::get_global(cx).indent_size;
|
||||||
let show_indent_guides =
|
let show_indent_guides =
|
||||||
ProjectPanelSettings::get_global(cx).indent_guides.show == ShowIndentGuides::Always;
|
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();
|
let is_local = project.is_local();
|
||||||
|
|
||||||
if has_worktree {
|
if has_worktree {
|
||||||
|
@ -4963,6 +5171,17 @@ impl Render for ProjectPanel {
|
||||||
items
|
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| {
|
.when(show_indent_guides, |list| {
|
||||||
list.with_decoration(
|
list.with_decoration(
|
||||||
ui::indent_guides(
|
ui::indent_guides(
|
||||||
|
@ -5079,7 +5298,7 @@ impl Render for ProjectPanel {
|
||||||
.anchor(gpui::Corner::TopLeft)
|
.anchor(gpui::Corner::TopLeft)
|
||||||
.child(menu.clone()),
|
.child(menu.clone()),
|
||||||
)
|
)
|
||||||
.with_priority(1)
|
.with_priority(3)
|
||||||
}))
|
}))
|
||||||
} else {
|
} else {
|
||||||
v_flex()
|
v_flex()
|
||||||
|
|
|
@ -40,6 +40,7 @@ pub struct ProjectPanelSettings {
|
||||||
pub git_status: bool,
|
pub git_status: bool,
|
||||||
pub indent_size: f32,
|
pub indent_size: f32,
|
||||||
pub indent_guides: IndentGuidesSettings,
|
pub indent_guides: IndentGuidesSettings,
|
||||||
|
pub sticky_scroll: bool,
|
||||||
pub auto_reveal_entries: bool,
|
pub auto_reveal_entries: bool,
|
||||||
pub auto_fold_dirs: bool,
|
pub auto_fold_dirs: bool,
|
||||||
pub scrollbar: ScrollbarSettings,
|
pub scrollbar: ScrollbarSettings,
|
||||||
|
@ -150,6 +151,10 @@ pub struct ProjectPanelSettingsContent {
|
||||||
///
|
///
|
||||||
/// Default: false
|
/// Default: false
|
||||||
pub hide_root: Option<bool>,
|
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 {
|
impl Settings for ProjectPanelSettings {
|
||||||
|
|
|
@ -30,6 +30,7 @@ mod scrollbar;
|
||||||
mod settings_container;
|
mod settings_container;
|
||||||
mod settings_group;
|
mod settings_group;
|
||||||
mod stack;
|
mod stack;
|
||||||
|
mod sticky_items;
|
||||||
mod tab;
|
mod tab;
|
||||||
mod tab_bar;
|
mod tab_bar;
|
||||||
mod toggle;
|
mod toggle;
|
||||||
|
@ -70,6 +71,7 @@ pub use scrollbar::*;
|
||||||
pub use settings_container::*;
|
pub use settings_container::*;
|
||||||
pub use settings_group::*;
|
pub use settings_group::*;
|
||||||
pub use stack::*;
|
pub use stack::*;
|
||||||
|
pub use sticky_items::*;
|
||||||
pub use tab::*;
|
pub use tab::*;
|
||||||
pub use tab_bar::*;
|
pub use tab_bar::*;
|
||||||
pub use toggle::*;
|
pub use toggle::*;
|
||||||
|
|
150
crates/ui/src/components/sticky_items.rs
Normal file
150
crates/ui/src/components/sticky_items.rs
Normal file
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Add table
Add a link
Reference in a new issue