Persist sidebar collapsed state

Richard Feldman created

Change summary

Cargo.lock                    |   2 
crates/sidebar/Cargo.toml     |   7 +
crates/sidebar/src/sidebar.rs | 152 ++++++++++++++++++++++++++++++++----
3 files changed, 144 insertions(+), 17 deletions(-)

Detailed changes

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",

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

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<ThreadsArchiveView>),
 }
 
+#[derive(Debug, Serialize, Deserialize)]
+struct SerializedSidebarState {
+    #[serde(default)]
+    collapsed_groups: Vec<SerializedPathList>,
+}
+
 #[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<PathList> {
+    kvp.scoped(SIDEBAR_COLLAPSED_GROUPS_NAMESPACE)
+        .read(SIDEBAR_COLLAPSED_GROUPS_KEY)
+        .log_err()
+        .flatten()
+        .and_then(|json| serde_json::from_str::<SerializedSidebarState>(&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<Editor>,
     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<usize>,
     collapsed_groups: HashSet<PathList>,
     expanded_groups: HashMap<PathList, usize>,
+    pending_serialization: Task<Option<()>>,
+
     view: SidebarView,
     recent_projects_popover_handle: PopoverMenuHandle<SidebarRecentProjects>,
     project_header_menu_ix: Option<usize>,
@@ -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<Self>,
     ) {
-        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<Self>) {
+        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<Self>) {
+        let collapsed_groups = self
+            .collapsed_groups
+            .iter()
+            .map(PathList::serialize)
+            .collect::<Vec<_>>();
+        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>,
+    ) {
+        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<Self>,
     ) {
+        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>,
     ) {
-        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;