From 8862a99ec565fd58a4b600bdf60c3c79a0b0b867 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Wed, 25 Mar 2026 10:25:43 -0400 Subject: [PATCH] Persist sidebar collapsed state --- Cargo.lock | 2 + crates/sidebar/Cargo.toml | 7 ++ crates/sidebar/src/sidebar.rs | 152 ++++++++++++++++++++++++++++++---- 3 files changed, 144 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 07a058a4032ecea1d85c2571c246767a373e0193..07047a62fff48a168c8702f47f4f5ab0ee52a143 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15979,6 +15979,7 @@ dependencies = [ "anyhow", "assistant_text_thread", "chrono", + "db", "editor", "feature_flags", "fs", @@ -15990,6 +15991,7 @@ dependencies = [ "project", "prompt_store", "recent_projects", + "serde", "serde_json", "settings", "theme", diff --git a/crates/sidebar/Cargo.toml b/crates/sidebar/Cargo.toml index 6b4d93790236f32b0533374626e337f5c05ab75b..d76651e9f2440c4810e9615c3c57b519d4765995 100644 --- a/crates/sidebar/Cargo.toml +++ b/crates/sidebar/Cargo.toml @@ -22,6 +22,8 @@ agent-client-protocol.workspace = true agent_ui.workspace = true anyhow.workspace = true chrono.workspace = true +db.workspace = true + editor.workspace = true feature_flags.workspace = true fs.workspace = true @@ -30,8 +32,11 @@ gpui.workspace = true menu.workspace = true project.workspace = true recent_projects.workspace = true +serde.workspace = true +serde_json.workspace = true settings.workspace = true theme.workspace = true + ui.workspace = true util.workspace = true vim_mode_setting.workspace = true @@ -43,7 +48,9 @@ acp_thread = { workspace = true, features = ["test-support"] } agent = { workspace = true, features = ["test-support"] } agent_ui = { workspace = true, features = ["test-support"] } assistant_text_thread = { workspace = true, features = ["test-support"] } +db.workspace = true editor.workspace = true + language_model = { workspace = true, features = ["test-support"] } pretty_assertions.workspace = true prompt_store.workspace = true diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index da78c634764fedf0643a7b225190ae5cb28f8e58..25306c08e6c518bc56118bb1681ad975ce0f5f7e 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -9,12 +9,15 @@ use agent_ui::{ Agent, AgentPanel, AgentPanelEvent, DEFAULT_THREAD_TITLE, NewThread, RemoveSelectedThread, }; use chrono::Utc; +use db::kvp::KeyValueStore; use editor::Editor; use feature_flags::{AgentV2FeatureFlag, FeatureFlagViewExt as _}; + use gpui::{ Action as _, AnyElement, App, Context, Entity, FocusHandle, Focusable, ListState, Pixels, - Render, SharedString, WeakEntity, Window, WindowHandle, list, prelude::*, px, + Render, SharedString, Task, WeakEntity, Window, WindowHandle, list, prelude::*, px, }; + use menu::{ Cancel, Confirm, SelectChild, SelectFirst, SelectLast, SelectNext, SelectParent, SelectPrevious, }; @@ -22,8 +25,10 @@ use project::{AgentId, Event as ProjectEvent, linked_worktree_short_name}; use recent_projects::sidebar_recent_projects::SidebarRecentProjects; use ui::utils::platform_title_bar_height; +use serde::{Deserialize, Serialize}; use settings::Settings as _; use std::collections::{HashMap, HashSet}; + use std::mem; use std::path::Path; use std::rc::Rc; @@ -33,11 +38,13 @@ use ui::{ AgentThreadStatus, CommonAnimationExt, ContextMenu, Divider, HighlightedLabel, KeyBinding, PopoverMenu, PopoverMenuHandle, Tab, ThreadItem, TintColor, Tooltip, WithScrollbar, prelude::*, }; -use util::ResultExt as _; use util::path_list::PathList; +use util::{ResultExt as _, TryFutureExt as _}; + use workspace::{ AddFolderToProject, FocusWorkspaceSidebar, MultiWorkspace, MultiWorkspaceEvent, Open, - Sidebar as WorkspaceSidebar, ToggleWorkspaceSidebar, Workspace, WorkspaceId, + SerializedPathList, Sidebar as WorkspaceSidebar, ToggleWorkspaceSidebar, Workspace, + WorkspaceId, }; use zed_actions::OpenRecent; @@ -59,6 +66,8 @@ const DEFAULT_WIDTH: Pixels = px(300.0); const MIN_WIDTH: Pixels = px(200.0); const MAX_WIDTH: Pixels = px(800.0); const DEFAULT_THREADS_SHOWN: usize = 5; +const SIDEBAR_COLLAPSED_GROUPS_NAMESPACE: &str = "agents_sidebar_collapsed_groups"; +const SIDEBAR_COLLAPSED_GROUPS_KEY: &str = "global"; #[derive(Debug, Default)] enum SidebarView { @@ -67,6 +76,12 @@ enum SidebarView { Archive(Entity), } +#[derive(Debug, Serialize, Deserialize)] +struct SerializedSidebarState { + #[serde(default)] + collapsed_groups: Vec, +} + #[derive(Clone, Debug)] struct ActiveThreadInfo { session_id: acp::SessionId, @@ -226,7 +241,24 @@ fn workspace_label_from_path_list(path_list: &PathList) -> SharedString { } } +fn load_collapsed_groups(kvp: &KeyValueStore) -> HashSet { + kvp.scoped(SIDEBAR_COLLAPSED_GROUPS_NAMESPACE) + .read(SIDEBAR_COLLAPSED_GROUPS_KEY) + .log_err() + .flatten() + .and_then(|json| serde_json::from_str::(&json).log_err()) + .map(|state| { + state + .collapsed_groups + .into_iter() + .map(|path_list| PathList::deserialize(&path_list)) + .collect() + }) + .unwrap_or_default() +} + /// The sidebar re-derives its entire entry list from scratch on every + /// change via `update_entries` → `rebuild_contents`. Avoid adding /// incremental or inter-event coordination state — if something can /// be computed from the current world state, compute it in the rebuild. @@ -237,6 +269,7 @@ pub struct Sidebar { filter_editor: Entity, list_state: ListState, contents: SidebarContents, + /// The index of the list item that currently has the keyboard focus /// /// Note: This is NOT the same as the active item. @@ -251,6 +284,8 @@ pub struct Sidebar { hovered_thread_index: Option, collapsed_groups: HashSet, expanded_groups: HashMap, + pending_serialization: Task>, + view: SidebarView, recent_projects_popover_handle: PopoverMenuHandle, project_header_menu_ix: Option, @@ -321,6 +356,8 @@ impl Sidebar { }) .detach(); + let collapsed_groups = load_collapsed_groups(&KeyValueStore::global(cx)); + let workspaces = multi_workspace.read(cx).workspaces().to_vec(); cx.defer_in(window, move |this, window, cx| { for workspace in &workspaces { @@ -341,8 +378,10 @@ impl Sidebar { agent_panel_visible: false, active_thread_is_draft: false, hovered_thread_index: None, - collapsed_groups: HashSet::new(), + collapsed_groups, expanded_groups: HashMap::new(), + pending_serialization: Task::ready(None), + view: SidebarView::default(), recent_projects_popover_handle: PopoverMenuHandle::default(), project_header_menu_ix: None, @@ -1430,7 +1469,7 @@ impl Sidebar { move |this, _, window, cx| { // Uncollapse the group if collapsed so // the new-thread entry becomes visible. - this.collapsed_groups.remove(&path_list_for_new_thread); + this.set_group_collapsed(&path_list_for_new_thread, false, cx); this.selection = None; this.create_new_thread(&workspace_for_new_thread, window, cx); } @@ -1778,17 +1817,60 @@ impl Sidebar { }); } - fn toggle_collapse( + fn set_group_collapsed( &mut self, path_list: &PathList, - _window: &mut Window, + collapsed: bool, cx: &mut Context, ) { - if self.collapsed_groups.contains(path_list) { - self.collapsed_groups.remove(path_list); + let changed = if collapsed { + self.collapsed_groups.insert(path_list.clone()) } else { - self.collapsed_groups.insert(path_list.clone()); + self.collapsed_groups.remove(path_list) + }; + + if changed { + self.serialize_collapsed_groups(cx); } + } + + fn clear_collapsed_groups(&mut self, cx: &mut Context) { + if self.collapsed_groups.is_empty() { + return; + } + + self.collapsed_groups.clear(); + self.serialize_collapsed_groups(cx); + } + + fn serialize_collapsed_groups(&mut self, cx: &mut Context) { + let collapsed_groups = self + .collapsed_groups + .iter() + .map(PathList::serialize) + .collect::>(); + let kvp = KeyValueStore::global(cx); + self.pending_serialization = cx.background_spawn( + async move { + kvp.scoped(SIDEBAR_COLLAPSED_GROUPS_NAMESPACE) + .write( + SIDEBAR_COLLAPSED_GROUPS_KEY.to_string(), + serde_json::to_string(&SerializedSidebarState { collapsed_groups })?, + ) + .await?; + anyhow::Ok(()) + } + .log_err(), + ); + } + + fn toggle_collapse( + &mut self, + path_list: &PathList, + _window: &mut Window, + cx: &mut Context, + ) { + self.set_group_collapsed(path_list, !self.collapsed_groups.contains(path_list), cx); self.update_entries(cx); } @@ -2244,7 +2326,7 @@ impl Sidebar { Some(ListEntry::ProjectHeader { path_list, .. }) => { if self.collapsed_groups.contains(path_list) { let path_list = path_list.clone(); - self.collapsed_groups.remove(&path_list); + self.set_group_collapsed(&path_list, false, cx); self.update_entries(cx); } else if ix + 1 < self.contents.entries.len() { self.selection = Some(ix + 1); @@ -2268,7 +2350,7 @@ impl Sidebar { Some(ListEntry::ProjectHeader { path_list, .. }) => { if !self.collapsed_groups.contains(path_list) { let path_list = path_list.clone(); - self.collapsed_groups.insert(path_list); + self.set_group_collapsed(&path_list, true, cx); self.update_entries(cx); } } @@ -2281,7 +2363,7 @@ impl Sidebar { { let path_list = path_list.clone(); self.selection = Some(i); - self.collapsed_groups.insert(path_list); + self.set_group_collapsed(&path_list, true, cx); self.update_entries(cx); break; } @@ -2319,10 +2401,10 @@ impl Sidebar { { let path_list = path_list.clone(); if self.collapsed_groups.contains(&path_list) { - self.collapsed_groups.remove(&path_list); + self.set_group_collapsed(&path_list, false, cx); } else { self.selection = Some(header_ix); - self.collapsed_groups.insert(path_list); + self.set_group_collapsed(&path_list, true, cx); } self.update_entries(cx); } @@ -2335,11 +2417,15 @@ impl Sidebar { _window: &mut Window, cx: &mut Context, ) { + let mut did_change = false; for entry in &self.contents.entries { if let ListEntry::ProjectHeader { path_list, .. } = entry { - self.collapsed_groups.insert(path_list.clone()); + did_change |= self.collapsed_groups.insert(path_list.clone()); } } + if did_change { + self.serialize_collapsed_groups(cx); + } self.update_entries(cx); } @@ -2349,7 +2435,7 @@ impl Sidebar { _window: &mut Window, cx: &mut Context, ) { - self.collapsed_groups.clear(); + self.clear_collapsed_groups(cx); self.update_entries(cx); } @@ -3221,6 +3307,8 @@ impl Render for Sidebar { mod tests { use super::*; use acp_thread::StubAgentConnection; + use db::AppDatabase; + use agent::ThreadStore; use agent_ui::test_support::{active_session_id, open_thread_with_connection, send_message}; use assistant_text_thread::TextThreadStore; @@ -3237,7 +3325,9 @@ mod tests { cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); + cx.set_global(AppDatabase::test_new()); theme::init(theme::LoadThemes::JustBase, cx); + editor::init(cx); cx.update_flags(false, vec!["agent-v2".into()]); ThreadStore::init_global(cx); @@ -3750,6 +3840,34 @@ mod tests { ); } + #[gpui::test] + async fn test_collapsed_groups_restore_after_restart(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")]); + save_n_test_threads(1, &path_list, cx).await; + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + sidebar.update_in(cx, |s, window, cx| { + s.toggle_collapse(&path_list, window, cx); + }); + cx.run_until_parked(); + + let restored_sidebar = setup_sidebar(&multi_workspace, cx); + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&restored_sidebar, cx), + vec!["> [my-project]"] + ); + } + #[gpui::test] async fn test_visible_entries_as_strings(cx: &mut TestAppContext) { let project = init_test_project("/my-project", cx).await;