recent_projects: Allow bulk deleting entries without scroll jumping (#54777)

Closes #52292

This PR preserves the scroll offset of the List used in Recent Projects
while deleting items. It does that by:
- Adding the `is_scrolled_to_end` method to the GPUI list, which helps
us determine where the new selection should be, since it depends on
whether items are taking the deleted slot from below or above.
- Adding `ScrollBehavior` to `update_matches`, which lets you preserve
the scroll offset even for `List` (not `UniformList`) after a reset.

Before:


https://github.com/user-attachments/assets/e3eb7092-59ec-4b54-b57a-503555addd27

After:


https://github.com/user-attachments/assets/6929f6a0-04d7-44f9-a9b2-f9e5c077b368

Release Notes:

- Fixed the recent projects list jumping to the top after deleting a
project, so you can now bulk-delete entries by repeatedly clicking the
delete icon or pressing the keybind.
This commit is contained in:
Smit Barmase 2026-05-06 19:36:48 +05:30 committed by GitHub
parent 6787fb13ad
commit 4e28b0a3fc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 388 additions and 23 deletions

View file

@ -271,6 +271,7 @@ struct ListItemSummary {
unrendered_count: usize,
height: Pixels,
has_focus_handles: bool,
has_unknown_height: bool,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
@ -399,6 +400,25 @@ impl ListState {
self.0.borrow().items.summary().count
}
/// Whether the list is scrolled to the end, or `None` if the list is
/// not scrollable or the total content height is not yet known.
pub fn is_scrolled_to_end(&self) -> Option<bool> {
let state = self.0.borrow();
let bounds = state.last_layout_bounds?;
let summary = state.items.summary();
if summary.has_unknown_height {
return None;
}
let padding = state.last_padding.unwrap_or_default();
let content_height = summary.height + padding.top + padding.bottom;
let scroll_max = (content_height - bounds.size.height).max(px(0.));
if scroll_max <= px(0.) {
return None;
}
let scroll_top = state.scroll_top(&state.logical_scroll_top());
Some(scroll_top >= scroll_max)
}
/// Inform the list state that the items in `old_range` have been replaced
/// by `count` new items that must be recalculated.
pub fn splice(&self, old_range: Range<usize>, count: usize) {
@ -1385,6 +1405,7 @@ impl sum_tree::Item for ListItem {
px(0.)
},
has_focus_handles: focus_handle.is_some(),
has_unknown_height: size_hint.is_none(),
},
ListItem::Measured {
size, focus_handle, ..
@ -1394,6 +1415,7 @@ impl sum_tree::Item for ListItem {
unrendered_count: 0,
height: size.height,
has_focus_handles: focus_handle.is_some(),
has_unknown_height: false,
},
}
}
@ -1410,6 +1432,7 @@ impl sum_tree::ContextLessSummary for ListItemSummary {
self.unrendered_count += summary.unrendered_count;
self.height += summary.height;
self.has_focus_handles |= summary.has_focus_handles;
self.has_unknown_height |= summary.has_unknown_height;
}
}

View file

@ -8,7 +8,7 @@ use crate::{
AnyElement, App, AvailableSpace, Bounds, ContentMask, Element, ElementId, Entity,
GlobalElementId, Hitbox, InspectorElementId, InteractiveElement, Interactivity, IntoElement,
IsZero, LayoutId, ListSizingBehavior, Overflow, Pixels, Point, ScrollHandle, Size,
StyleRefinement, Styled, Window, point, size,
StyleRefinement, Styled, Window, point, px, size,
};
use smallvec::SmallVec;
use std::{cell::RefCell, cmp, ops::Range, rc::Rc, usize};
@ -236,6 +236,18 @@ impl UniformListScrollHandle {
}
}
/// Whether the list is scrolled to the end, or `None` if the list is
/// not scrollable.
pub fn is_scrolled_to_end(&self) -> Option<bool> {
let state = self.0.borrow();
let max_offset = state.base_handle.max_offset();
if max_offset.y <= px(0.) {
return None;
}
let offset = state.base_handle.offset();
Some(-offset.y >= max_offset.y)
}
/// Scroll to the bottom of the list.
pub fn scroll_to_bottom(&self) {
self.scroll_to_item(usize::MAX, ScrollStrategy::Bottom);

View file

@ -35,6 +35,12 @@ pub enum Direction {
Down,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ScrollBehavior {
RevealSelected,
PreserveOffset,
}
actions!(
picker,
[
@ -687,9 +693,19 @@ impl<D: PickerDelegate> Picker<D> {
}
pub fn update_matches(&mut self, query: String, window: &mut Window, cx: &mut Context<Self>) {
self.update_matches_with_options(query, ScrollBehavior::RevealSelected, window, cx);
}
pub fn update_matches_with_options(
&mut self,
query: String,
scroll_behavior: ScrollBehavior,
window: &mut Window,
cx: &mut Context<Self>,
) {
let delegate_pending_update_matches = self.delegate.update_matches(query, window, cx);
self.matches_updated(window, cx);
self.matches_updated(scroll_behavior, window, cx);
// This struct ensures that we can synchronously drop the task returned by the
// delegate's `update_matches` method and the task that the picker is spawning.
// If we simply capture the delegate's task into the picker's task, when the picker's
@ -709,19 +725,40 @@ impl<D: PickerDelegate> Picker<D> {
})?;
delegate_pending_update_matches.await;
this.update_in(cx, |this, window, cx| {
this.matches_updated(window, cx);
this.matches_updated(scroll_behavior, window, cx);
})
}),
});
}
fn matches_updated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if let ElementContainer::List(state) = &mut self.element_container {
state.reset(self.delegate.match_count());
fn matches_updated(
&mut self,
scroll_behavior: ScrollBehavior,
window: &mut Window,
cx: &mut Context<Self>,
) {
let match_count = self.delegate.match_count();
match &mut self.element_container {
ElementContainer::List(state) => match scroll_behavior {
ScrollBehavior::RevealSelected => {
state.reset(match_count);
let index = self.delegate.selected_index();
self.scroll_to_item_index(index);
}
ScrollBehavior::PreserveOffset => {
let offset = state.logical_scroll_top();
state.reset(match_count);
state.scroll_to(offset);
}
},
ElementContainer::UniformList(_) => match scroll_behavior {
ScrollBehavior::RevealSelected => {
let index = self.delegate.selected_index();
self.scroll_to_item_index(index);
}
ScrollBehavior::PreserveOffset => {}
},
}
let index = self.delegate.selected_index();
self.scroll_to_item_index(index);
self.pending_update_matches = None;
if let Some(secondary) = self.confirm_on_update.take() {
self.do_confirm(secondary, window, cx);
@ -752,6 +789,13 @@ impl<D: PickerDelegate> Picker<D> {
}
}
pub fn is_scrolled_to_end(&self) -> Option<bool> {
match &self.element_container {
ElementContainer::List(state) => state.is_scrolled_to_end(),
ElementContainer::UniformList(scroll_handle) => scroll_handle.is_scrolled_to_end(),
}
}
fn render_element(
&self,
window: &mut Window,

View file

@ -64,6 +64,7 @@ fs.workspace = true
gpui = { workspace = true, features = ["test-support"] }
http_client.workspace = true
language = { workspace = true, features = ["test-support"] }
picker = { workspace = true, features = ["test-support"] }
project = { workspace = true, features = ["test-support"] }
release_channel.workspace = true
remote = { workspace = true, features = ["test-support"] }

View file

@ -29,7 +29,7 @@ use gpui::{
};
use picker::{
Picker, PickerDelegate,
Picker, PickerDelegate, ScrollBehavior,
highlighted_match_with_paths::{HighlightedMatch, HighlightedMatchWithPaths},
};
use project::{Worktree, git_store::Repository};
@ -83,6 +83,15 @@ enum ProjectPickerEntry {
RecentProject(StringMatch),
}
fn is_selectable_entry(entry: &ProjectPickerEntry) -> bool {
matches!(
entry,
ProjectPickerEntry::OpenFolder { .. }
| ProjectPickerEntry::ProjectGroup(_)
| ProjectPickerEntry::RecentProject(_)
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ProjectPickerStyle {
Modal,
@ -814,8 +823,7 @@ pub struct RecentProjectsDelegate {
selected_index: usize,
render_paths: bool,
create_new_window: bool,
// Flag to reset index when there is a new query vs not reset index when user delete an item
reset_selected_match_index: bool,
snap_selection_to_first_non_header_match: bool,
has_any_non_local_projects: bool,
project_connection_options: Option<RemoteConnectionOptions>,
focus_handle: FocusHandle,
@ -843,7 +851,7 @@ impl RecentProjectsDelegate {
selected_index: 0,
create_new_window,
render_paths,
reset_selected_match_index: true,
snap_selection_to_first_non_header_match: true,
has_any_non_local_projects: project_connection_options.is_some(),
project_connection_options,
focus_handle,
@ -1067,14 +1075,14 @@ impl PickerDelegate for RecentProjectsDelegate {
self.filtered_entries = entries;
if self.reset_selected_match_index {
if self.snap_selection_to_first_non_header_match {
self.selected_index = self
.filtered_entries
.iter()
.position(|e| !matches!(e, ProjectPickerEntry::Header(_)))
.unwrap_or(0);
}
self.reset_selected_match_index = true;
self.snap_selection_to_first_non_header_match = true;
Task::ready(())
}
@ -2106,6 +2114,69 @@ impl RecentProjectsDelegate {
.detach();
}
/// Returns the new selection index after the entry at `deleted_index`
/// is removed.
///
/// - Prefers the nearest entry matching `prefer_section` so the user
/// stays in the same section they were navigating.
/// - Falls back to any other selectable entry so the picker doesn't
/// land on a header.
fn replacement_index_after_deletion(
&self,
deleted_index: usize,
prefer_previous: bool,
prefer_section: fn(&ProjectPickerEntry) -> bool,
) -> Option<usize> {
let replacement_index = |matches_entry: fn(&ProjectPickerEntry) -> bool| {
let next_index = self
.filtered_entries
.iter()
.enumerate()
.skip(deleted_index)
.find_map(|(index, entry)| matches_entry(entry).then_some(index));
let previous_index = self
.filtered_entries
.iter()
.enumerate()
.take(deleted_index.min(self.filtered_entries.len()))
.rev()
.find_map(|(index, entry)| matches_entry(entry).then_some(index));
if prefer_previous {
previous_index.or(next_index)
} else {
next_index.or(previous_index)
}
};
replacement_index(prefer_section).or_else(|| replacement_index(is_selectable_entry))
}
fn update_picker_after_recent_project_deletion(
picker: &mut Picker<Self>,
deleted_index: usize,
workspaces: Vec<RecentWorkspace>,
window: &mut Window,
cx: &mut Context<Picker<Self>>,
) {
let prefer_previous = picker.is_scrolled_to_end() == Some(true);
picker.delegate.set_workspaces(workspaces);
picker.delegate.snap_selection_to_first_non_header_match = false;
picker.update_matches_with_options(
picker.query(cx),
ScrollBehavior::PreserveOffset,
window,
cx,
);
if let Some(replacement_index) = picker.delegate.replacement_index_after_deletion(
deleted_index,
prefer_previous,
|entry| matches!(entry, ProjectPickerEntry::RecentProject(_)),
) {
picker.set_selected_index(replacement_index, None, false, window, cx);
}
}
fn delete_recent_project(
&self,
ix: usize,
@ -2115,7 +2186,10 @@ impl RecentProjectsDelegate {
if let Some(ProjectPickerEntry::RecentProject(selected_match)) =
self.filtered_entries.get(ix)
{
let recent_workspace = self.workspaces[selected_match.candidate_id].clone();
let Some(recent_workspace) = self.workspaces.get(selected_match.candidate_id).cloned()
else {
return;
};
let fs = self
.workspace
.upgrade()
@ -2133,12 +2207,9 @@ impl RecentProjectsDelegate {
.await
.unwrap_or_default();
this.update_in(cx, move |picker, window, cx| {
picker.delegate.set_workspaces(workspaces);
picker
.delegate
.set_selected_index(ix.saturating_sub(1), window, cx);
picker.delegate.reset_selected_match_index = false;
picker.update_matches(picker.query(cx), window, cx);
Self::update_picker_after_recent_project_deletion(
picker, ix, workspaces, window, cx,
);
// After deleting a project, we want to update the history manager to reflect the change.
// But we do not emit a update event when user opens a project, because it's handled in `workspace::load_workspace`.
if let Some(history_manager) = HistoryManager::global(cx) {
@ -2234,7 +2305,7 @@ impl RecentProjectsDelegate {
#[cfg(test)]
mod tests {
use gpui::{TestAppContext, UpdateGlobal};
use gpui::{TestAppContext, UpdateGlobal, VisualTestContext};
use serde_json::json;
use settings::SettingsStore;
@ -2243,6 +2314,220 @@ mod tests {
use super::*;
// Test picker for the empty query:
//
// [0] Header("Current Folders")
// [1] OpenFolder(0)
// [2] OpenFolder(1)
// [3] Header("This Window")
// [4] ProjectGroup(0)
// [5] ProjectGroup(1)
// [6] Header("Recent Projects")
// [7..=26] RecentProject(0..=19)
//
const RECENT_PROJECT_COUNT: usize = 20;
const FIRST_RECENT_PROJECT: usize = 7;
const LAST_RECENT_PROJECT: usize = FIRST_RECENT_PROJECT + RECENT_PROJECT_COUNT - 1;
fn open_folder(index: usize) -> OpenFolderEntry {
OpenFolderEntry {
worktree_id: WorktreeId::from_usize(index),
name: format!("project-folder-{index}").into(),
path: PathBuf::from(format!("/current/project-folder-{index}")),
branch: None,
is_active: false,
}
}
fn project_group(index: usize) -> ProjectGroupKey {
ProjectGroupKey::new(
None,
PathList::new(&[PathBuf::from(format!("/this-window/project-{index}"))]),
)
}
fn recent_workspace(index: usize) -> RecentWorkspace {
let paths = PathList::new(&[PathBuf::from(format!("/recent/project-{index:02}"))]);
RecentWorkspace {
workspace_id: WorkspaceId::from_i64(index as i64),
location: SerializedWorkspaceLocation::Local,
paths: paths.clone(),
identity_paths: paths,
timestamp: Utc::now(),
}
}
fn recent_workspaces() -> Vec<RecentWorkspace> {
(0..RECENT_PROJECT_COUNT).map(recent_workspace).collect()
}
fn draw(cx: &mut VisualTestContext) {
cx.update(|window, cx| window.draw(cx).clear());
}
fn build_picker(
cx: &mut TestAppContext,
) -> (
Entity<Picker<RecentProjectsDelegate>>,
&mut VisualTestContext,
) {
init_test(cx);
let (picker, cx) = cx.add_window_view(|window, cx| {
let mut delegate = RecentProjectsDelegate::new(
WeakEntity::new_invalid(),
false,
cx.focus_handle(),
vec![open_folder(0), open_folder(1)],
vec![project_group(0), project_group(1)],
None,
ProjectPickerStyle::Modal,
);
delegate.set_workspaces(recent_workspaces());
Picker::list(delegate, window, cx)
.list_measure_all()
.show_scrollbar(true)
.max_height(Some(px(240.).into()))
});
draw(cx);
(picker, cx)
}
fn scroll_to_and_select(
picker: &Entity<Picker<RecentProjectsDelegate>>,
cx: &mut VisualTestContext,
index: usize,
) -> usize {
picker.update_in(cx, |picker, window, cx| {
picker.set_selected_index(index, None, true, window, cx);
});
draw(cx);
picker.update(cx, |picker, _| picker.logical_scroll_top_index())
}
fn delete_recent_project_in_picker(
picker: &Entity<Picker<RecentProjectsDelegate>>,
cx: &mut VisualTestContext,
index: usize,
) {
picker.update_in(cx, |picker, window, cx| {
let Some(ProjectPickerEntry::RecentProject(hit)) =
picker.delegate.filtered_entries.get(index)
else {
panic!("expected entry at {index} to be a recent project");
};
let mut workspaces = picker.delegate.workspaces.clone();
workspaces.remove(hit.candidate_id);
RecentProjectsDelegate::update_picker_after_recent_project_deletion(
picker, index, workspaces, window, cx,
);
});
}
#[track_caller]
fn assert_scroll_top_is(
picker: &Entity<Picker<RecentProjectsDelegate>>,
cx: &mut VisualTestContext,
expected: usize,
phase: &str,
) {
picker.update(cx, |picker, _| {
assert_eq!(
picker.logical_scroll_top_index(),
expected,
"scroll top should remain at {expected} ({phase})"
);
assert_selected_entry_is_recent_project(picker);
});
}
#[track_caller]
fn assert_pinned_to_bottom(
picker: &Entity<Picker<RecentProjectsDelegate>>,
cx: &mut VisualTestContext,
phase: &str,
) {
picker.update(cx, |picker, _| {
assert_eq!(
picker.is_scrolled_to_end(),
Some(true),
"picker should remain pinned to the bottom ({phase})"
);
assert!(
picker.logical_scroll_top_index() > 0,
"picker should not jump to the top while pinned to the bottom ({phase})"
);
assert_selected_entry_is_recent_project(picker);
});
}
#[track_caller]
fn assert_selected_entry_is_recent_project(picker: &Picker<RecentProjectsDelegate>) {
assert!(matches!(
picker
.delegate
.filtered_entries
.get(picker.delegate.selected_index),
Some(ProjectPickerEntry::RecentProject(_))
));
}
#[gpui::test]
fn deleting_top_recent_project_preserves_scroll_position(cx: &mut TestAppContext) {
let target = FIRST_RECENT_PROJECT;
let (picker, cx) = build_picker(cx);
let scroll_top = scroll_to_and_select(&picker, cx, target);
assert!(
scroll_top > 0,
"test should start scrolled away from the top"
);
delete_recent_project_in_picker(&picker, cx, target);
assert_scroll_top_is(&picker, cx, scroll_top, "after delete");
// The picker re-runs layout on the next frame; the scroll position
// must still be preserved after that redraw.
draw(cx);
assert_scroll_top_is(&picker, cx, scroll_top, "after redraw");
}
#[gpui::test]
fn deleting_middle_recent_project_preserves_scroll_position(cx: &mut TestAppContext) {
let target = FIRST_RECENT_PROJECT + RECENT_PROJECT_COUNT / 2;
let (picker, cx) = build_picker(cx);
let scroll_top = scroll_to_and_select(&picker, cx, target);
assert!(
scroll_top > 0,
"test should start scrolled away from the top"
);
delete_recent_project_in_picker(&picker, cx, target);
assert_scroll_top_is(&picker, cx, scroll_top, "after delete");
draw(cx);
assert_scroll_top_is(&picker, cx, scroll_top, "after redraw");
}
#[gpui::test]
fn deleting_last_recent_project_preserves_scroll_position(cx: &mut TestAppContext) {
let target = LAST_RECENT_PROJECT;
let (picker, cx) = build_picker(cx);
scroll_to_and_select(&picker, cx, target);
picker.update(cx, |picker, _| {
assert_eq!(
picker.is_scrolled_to_end(),
Some(true),
"selecting the last entry should leave the picker pinned to the bottom"
);
});
delete_recent_project_in_picker(&picker, cx, target);
assert_pinned_to_bottom(&picker, cx, "after delete");
draw(cx);
assert_pinned_to_bottom(&picker, cx, "after redraw");
}
#[gpui::test]
async fn test_open_dev_container_action_with_single_config(cx: &mut TestAppContext) {
let app_state = init_test(cx);