diff --git a/assets/icons/list_collapse.svg b/assets/icons/list_collapse.svg
index f18bc550b90228c2f689848b86cfc5bea3d6ff50..dbdb2aaa4537c25ba1867d4957c23819af425835 100644
--- a/assets/icons/list_collapse.svg
+++ b/assets/icons/list_collapse.svg
@@ -1 +1,7 @@
-
+
diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs
index 45a56f7af203e8ffe01b8590f916b439a57c52fb..d8bfae85bcd40654086c05d52d0004c618055c31 100644
--- a/crates/sidebar/src/sidebar.rs
+++ b/crates/sidebar/src/sidebar.rs
@@ -89,6 +89,7 @@ enum ListEntry {
ViewMore {
path_list: PathList,
remaining_count: usize,
+ is_fully_expanded: bool,
},
NewThread {
path_list: PathList,
@@ -174,7 +175,7 @@ pub struct Sidebar {
focused_thread: Option,
active_entry_index: Option,
collapsed_groups: HashSet,
- expanded_groups: HashSet,
+ expanded_groups: HashMap,
}
impl EventEmitter for Sidebar {}
@@ -269,7 +270,7 @@ impl Sidebar {
focused_thread: None,
active_entry_index: None,
collapsed_groups: HashSet::new(),
- expanded_groups: HashSet::new(),
+ expanded_groups: HashMap::new(),
}
}
@@ -579,21 +580,20 @@ impl Sidebar {
}
let total = threads.len();
- let show_view_more =
- total > DEFAULT_THREADS_SHOWN && !self.expanded_groups.contains(&path_list);
- let count = if show_view_more {
- DEFAULT_THREADS_SHOWN
- } else {
- total
- };
+ let extra_batches = self.expanded_groups.get(&path_list).copied().unwrap_or(0);
+ let threads_to_show =
+ DEFAULT_THREADS_SHOWN + (extra_batches * DEFAULT_THREADS_SHOWN);
+ let count = threads_to_show.min(total);
+ let is_fully_expanded = count >= total;
entries.extend(threads.into_iter().take(count));
- if show_view_more {
+ if total > DEFAULT_THREADS_SHOWN {
entries.push(ListEntry::ViewMore {
path_list: path_list.clone(),
- remaining_count: total - DEFAULT_THREADS_SHOWN,
+ remaining_count: total.saturating_sub(count),
+ is_fully_expanded,
});
}
@@ -632,10 +632,13 @@ impl Sidebar {
let had_notifications = self.has_notifications(cx);
+ let scroll_position = self.list_state.logical_scroll_top();
+
self.rebuild_contents(cx);
self.recompute_active_entry_index(cx);
self.list_state.reset(self.contents.entries.len());
+ self.list_state.scroll_to(scroll_position);
if had_notifications != self.has_notifications(cx) {
multi_workspace.update(cx, |_, cx| {
@@ -720,7 +723,15 @@ impl Sidebar {
ListEntry::ViewMore {
path_list,
remaining_count,
- } => self.render_view_more(ix, path_list, *remaining_count, is_selected, cx),
+ is_fully_expanded,
+ } => self.render_view_more(
+ ix,
+ path_list,
+ *remaining_count,
+ *is_fully_expanded,
+ is_selected,
+ cx,
+ ),
ListEntry::NewThread {
path_list,
workspace,
@@ -765,7 +776,11 @@ impl Sidebar {
let workspace_for_new_thread = workspace.clone();
let workspace_for_remove = workspace.clone();
// let workspace_for_activate = workspace.clone();
+
let path_list_for_toggle = path_list.clone();
+ let path_list_for_collapse = path_list.clone();
+ let view_more_expanded = self.expanded_groups.contains_key(path_list);
+
let multi_workspace = self.multi_workspace.upgrade();
let workspace_count = multi_workspace
.as_ref()
@@ -853,6 +868,25 @@ impl Sidebar {
)),
)
})
+ .when(view_more_expanded && !is_collapsed, |this| {
+ this.child(
+ IconButton::new(
+ SharedString::from(format!("project-header-collapse-{}", ix)),
+ IconName::ListCollapse,
+ )
+ .icon_size(IconSize::Small)
+ .icon_color(Color::Muted)
+ .tooltip(Tooltip::text("Collapse Displayed Threads"))
+ .on_click(cx.listener({
+ let path_list_for_collapse = path_list_for_collapse.clone();
+ move |this, _, _window, cx| {
+ this.selection = None;
+ this.expanded_groups.remove(&path_list_for_collapse);
+ this.update_entries(cx);
+ }
+ })),
+ )
+ })
.when(has_threads, |this| {
this.child(
IconButton::new(ib_id, IconName::NewThread)
@@ -1031,9 +1065,18 @@ impl Sidebar {
let workspace = workspace.clone();
self.activate_thread(session_info, &workspace, window, cx);
}
- ListEntry::ViewMore { path_list, .. } => {
+ ListEntry::ViewMore {
+ path_list,
+ is_fully_expanded,
+ ..
+ } => {
let path_list = path_list.clone();
- self.expanded_groups.insert(path_list);
+ if *is_fully_expanded {
+ self.expanded_groups.remove(&path_list);
+ } else {
+ let current = self.expanded_groups.get(&path_list).copied().unwrap_or(0);
+ self.expanded_groups.insert(path_list, current + 1);
+ }
self.update_entries(cx);
}
ListEntry::NewThread { workspace, .. } => {
@@ -1202,32 +1245,42 @@ impl Sidebar {
ix: usize,
path_list: &PathList,
remaining_count: usize,
+ is_fully_expanded: bool,
is_selected: bool,
cx: &mut Context,
) -> AnyElement {
let path_list = path_list.clone();
let id = SharedString::from(format!("view-more-{}", ix));
- let count = format!("({})", remaining_count);
+ let (icon, label) = if is_fully_expanded {
+ (IconName::ListCollapse, "Collapse List")
+ } else {
+ (IconName::Plus, "View More")
+ };
ListItem::new(id)
.focused(is_selected)
.child(
h_flex()
- .px_1()
- .py_1p5()
+ .p_1()
.gap_1p5()
- .child(
- Icon::new(IconName::Plus)
- .size(IconSize::Small)
- .color(Color::Muted),
- )
- .child(Label::new("View More").color(Color::Muted))
- .child(Label::new(count).color(Color::Muted).size(LabelSize::Small)),
+ .child(Icon::new(icon).size(IconSize::Small).color(Color::Muted))
+ .child(Label::new(label).color(Color::Muted))
+ .when(!is_fully_expanded, |this| {
+ this.child(
+ Label::new(format!("({})", remaining_count))
+ .color(Color::Custom(cx.theme().colors().text_muted.opacity(0.5))),
+ )
+ }),
)
.on_click(cx.listener(move |this, _, _window, cx| {
this.selection = None;
- this.expanded_groups.insert(path_list.clone());
+ if is_fully_expanded {
+ this.expanded_groups.remove(&path_list);
+ } else {
+ let current = this.expanded_groups.get(&path_list).copied().unwrap_or(0);
+ this.expanded_groups.insert(path_list.clone(), current + 1);
+ }
this.update_entries(cx);
}))
.into_any_element()
@@ -1660,9 +1713,15 @@ mod tests {
)
}
ListEntry::ViewMore {
- remaining_count, ..
+ remaining_count,
+ is_fully_expanded,
+ ..
} => {
- format!(" + View More ({}){}", remaining_count, selected)
+ if *is_fully_expanded {
+ format!(" - Collapse{}", selected)
+ } else {
+ format!(" + View More ({}){}", remaining_count, selected)
+ }
}
ListEntry::NewThread { .. } => {
format!(" [+ New Thread]{}", selected)
@@ -1824,6 +1883,78 @@ mod tests {
);
}
+ #[gpui::test]
+ async fn test_view_more_batched_expansion(cx: &mut TestAppContext) {
+ let project = init_test_project("/my-project", cx).await;
+ let (multi_workspace, cx) =
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+ let sidebar = setup_sidebar(&multi_workspace, cx);
+
+ let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
+ // Create 17 threads: initially shows 5, then 10, then 15, then all 17 with Collapse
+ save_n_test_threads(17, &path_list, cx).await;
+
+ multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
+ cx.run_until_parked();
+
+ // Initially shows 5 threads + View More (12 remaining)
+ let entries = visible_entries_as_strings(&sidebar, cx);
+ assert_eq!(entries.len(), 7); // header + 5 threads + View More
+ assert!(entries.iter().any(|e| e.contains("View More (12)")));
+
+ // Focus and navigate to View More, then confirm to expand by one batch
+ open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
+ for _ in 0..7 {
+ cx.dispatch_action(SelectNext);
+ }
+ cx.dispatch_action(Confirm);
+ cx.run_until_parked();
+
+ // Now shows 10 threads + View More (7 remaining)
+ let entries = visible_entries_as_strings(&sidebar, cx);
+ assert_eq!(entries.len(), 12); // header + 10 threads + View More
+ assert!(entries.iter().any(|e| e.contains("View More (7)")));
+
+ // Expand again by one batch
+ sidebar.update_in(cx, |s, _window, cx| {
+ let current = s.expanded_groups.get(&path_list).copied().unwrap_or(0);
+ s.expanded_groups.insert(path_list.clone(), current + 1);
+ s.update_entries(cx);
+ });
+ cx.run_until_parked();
+
+ // Now shows 15 threads + View More (2 remaining)
+ let entries = visible_entries_as_strings(&sidebar, cx);
+ assert_eq!(entries.len(), 17); // header + 15 threads + View More
+ assert!(entries.iter().any(|e| e.contains("View More (2)")));
+
+ // Expand one more time - should show all 17 threads with Collapse button
+ sidebar.update_in(cx, |s, _window, cx| {
+ let current = s.expanded_groups.get(&path_list).copied().unwrap_or(0);
+ s.expanded_groups.insert(path_list.clone(), current + 1);
+ s.update_entries(cx);
+ });
+ cx.run_until_parked();
+
+ // All 17 threads shown with Collapse button
+ let entries = visible_entries_as_strings(&sidebar, cx);
+ assert_eq!(entries.len(), 19); // header + 17 threads + Collapse
+ assert!(!entries.iter().any(|e| e.contains("View More")));
+ assert!(entries.iter().any(|e| e.contains("Collapse")));
+
+ // Click collapse - should go back to showing 5 threads
+ sidebar.update_in(cx, |s, _window, cx| {
+ s.expanded_groups.remove(&path_list);
+ s.update_entries(cx);
+ });
+ cx.run_until_parked();
+
+ // Back to initial state: 5 threads + View More (12 remaining)
+ let entries = visible_entries_as_strings(&sidebar, cx);
+ assert_eq!(entries.len(), 7); // header + 5 threads + View More
+ assert!(entries.iter().any(|e| e.contains("View More (12)")));
+ }
+
#[gpui::test]
async fn test_collapse_and_expand_group(cx: &mut TestAppContext) {
let project = init_test_project("/my-project", cx).await;
@@ -1984,6 +2115,7 @@ mod tests {
ListEntry::ViewMore {
path_list: expanded_path.clone(),
remaining_count: 42,
+ is_fully_expanded: false,
},
// Collapsed project header
ListEntry::ProjectHeader {
@@ -2237,10 +2369,11 @@ mod tests {
cx.dispatch_action(Confirm);
cx.run_until_parked();
- // All 8 threads should now be visible, no "View More"
+ // All 8 threads should now be visible with a "Collapse" button
let entries = visible_entries_as_strings(&sidebar, cx);
- assert_eq!(entries.len(), 9); // header + 8 threads
+ assert_eq!(entries.len(), 10); // header + 8 threads + Collapse button
assert!(!entries.iter().any(|e| e.contains("View More")));
+ assert!(entries.iter().any(|e| e.contains("Collapse")));
}
#[gpui::test]