Implement sidebar serialization (#52795)

Mikayla Maki created

This PR implements basic sidebar deserialization. When the
multiworkspace decides to save itself, it causes the sidebar to
serialize as well. The sidebar can trigger this as well via an event.

Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

Release Notes:

- N/A

Change summary

Cargo.lock                                |  1 
crates/sidebar/Cargo.toml                 |  2 
crates/sidebar/src/sidebar.rs             | 82 ++++++++++++++++++++++++
crates/sidebar/src/sidebar_tests.rs       | 56 +++++++++++++++++
crates/workspace/src/multi_workspace.rs   | 68 +++++++++++++++++--
crates/workspace/src/persistence.rs       |  2 
crates/workspace/src/persistence/model.rs |  2 
crates/workspace/src/workspace.rs         | 14 +++
8 files changed, 216 insertions(+), 11 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -15981,6 +15981,7 @@ dependencies = [
  "prompt_store",
  "recent_projects",
  "remote",
+ "serde",
  "serde_json",
  "settings",
  "theme",

crates/sidebar/Cargo.toml 🔗

@@ -34,6 +34,8 @@ platform_title_bar.workspace = true
 project.workspace = true
 recent_projects.workspace = true
 remote.workspace = true
+serde.workspace = true
+serde_json.workspace = true
 settings.workspace = true
 theme.workspace = true
 theme_settings.workspace = true

crates/sidebar/src/sidebar.rs 🔗

@@ -26,6 +26,7 @@ use recent_projects::sidebar_recent_projects::SidebarRecentProjects;
 use remote::RemoteConnectionOptions;
 use ui::utils::platform_title_bar_height;
 
+use serde::{Deserialize, Serialize};
 use settings::Settings as _;
 use std::collections::{HashMap, HashSet};
 use std::mem;
@@ -37,7 +38,7 @@ use ui::{
     WithScrollbar, prelude::*,
 };
 use util::ResultExt as _;
-use util::path_list::PathList;
+use util::path_list::{PathList, SerializedPathList};
 use workspace::{
     AddFolderToProject, CloseWindow, FocusWorkspaceSidebar, MultiWorkspace, MultiWorkspaceEvent,
     Open, Sidebar as WorkspaceSidebar, SidebarSide, ToggleWorkspaceSidebar, Workspace, WorkspaceId,
@@ -81,6 +82,25 @@ const MIN_WIDTH: Pixels = px(200.0);
 const MAX_WIDTH: Pixels = px(800.0);
 const DEFAULT_THREADS_SHOWN: usize = 5;
 
+#[derive(Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+enum SerializedSidebarView {
+    #[default]
+    ThreadList,
+    Archive,
+}
+
+#[derive(Default, Serialize, Deserialize)]
+struct SerializedSidebar {
+    #[serde(default)]
+    width: Option<f32>,
+    #[serde(default)]
+    collapsed_groups: Vec<SerializedPathList>,
+    #[serde(default)]
+    expanded_groups: Vec<(SerializedPathList, usize)>,
+    #[serde(default)]
+    active_view: SerializedSidebarView,
+}
+
 #[derive(Debug, Default)]
 enum SidebarView {
     #[default]
@@ -419,6 +439,10 @@ impl Sidebar {
         }
     }
 
+    fn serialize(&mut self, cx: &mut Context<Self>) {
+        cx.emit(workspace::SidebarEvent::SerializeNeeded);
+    }
+
     fn is_active_workspace(&self, workspace: &Entity<Workspace>, cx: &App) -> bool {
         self.multi_workspace
             .upgrade()
@@ -1348,6 +1372,7 @@ impl Sidebar {
                                 move |this, _, _window, cx| {
                                     this.selection = None;
                                     this.expanded_groups.remove(&path_list_for_collapse);
+                                    this.serialize(cx);
                                     this.update_entries(cx);
                                 }
                             })),
@@ -1680,6 +1705,7 @@ impl Sidebar {
         } else {
             self.collapsed_groups.insert(path_list.clone());
         }
+        self.serialize(cx);
         self.update_entries(cx);
     }
 
@@ -1879,6 +1905,7 @@ impl Sidebar {
                     let current = self.expanded_groups.get(&path_list).copied().unwrap_or(0);
                     self.expanded_groups.insert(path_list, current + 1);
                 }
+                self.serialize(cx);
                 self.update_entries(cx);
             }
             ListEntry::NewThread { workspace, .. } => {
@@ -2867,6 +2894,7 @@ impl Sidebar {
                     let current = this.expanded_groups.get(&path_list).copied().unwrap_or(0);
                     this.expanded_groups.insert(path_list.clone(), current + 1);
                 }
+                this.serialize(cx);
                 this.update_entries(cx);
             }))
             .into_any_element()
@@ -3292,6 +3320,7 @@ impl Sidebar {
         self._subscriptions.push(subscription);
         self.view = SidebarView::Archive(archive_view.clone());
         archive_view.update(cx, |view, cx| view.focus_filter_editor(window, cx));
+        self.serialize(cx);
         cx.notify();
     }
 
@@ -3300,6 +3329,7 @@ impl Sidebar {
         self._subscriptions.clear();
         let handle = self.filter_editor.read(cx).focus_handle(cx);
         handle.focus(window, cx);
+        self.serialize(cx);
         cx.notify();
     }
 }
@@ -3339,8 +3369,58 @@ impl WorkspaceSidebar for Sidebar {
     ) {
         self.toggle_thread_switcher_impl(select_last, window, cx);
     }
+
+    fn serialized_state(&self, _cx: &App) -> Option<String> {
+        let serialized = SerializedSidebar {
+            width: Some(f32::from(self.width)),
+            collapsed_groups: self
+                .collapsed_groups
+                .iter()
+                .map(|pl| pl.serialize())
+                .collect(),
+            expanded_groups: self
+                .expanded_groups
+                .iter()
+                .map(|(pl, count)| (pl.serialize(), *count))
+                .collect(),
+            active_view: match self.view {
+                SidebarView::ThreadList => SerializedSidebarView::ThreadList,
+                SidebarView::Archive(_) => SerializedSidebarView::Archive,
+            },
+        };
+        serde_json::to_string(&serialized).ok()
+    }
+
+    fn restore_serialized_state(
+        &mut self,
+        state: &str,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        if let Some(serialized) = serde_json::from_str::<SerializedSidebar>(state).log_err() {
+            if let Some(width) = serialized.width {
+                self.width = px(width).clamp(MIN_WIDTH, MAX_WIDTH);
+            }
+            self.collapsed_groups = serialized
+                .collapsed_groups
+                .into_iter()
+                .map(|s| PathList::deserialize(&s))
+                .collect();
+            self.expanded_groups = serialized
+                .expanded_groups
+                .into_iter()
+                .map(|(s, count)| (PathList::deserialize(&s), count))
+                .collect();
+            if serialized.active_view == SerializedSidebarView::Archive {
+                self.show_archive(window, cx);
+            }
+        }
+        cx.notify();
+    }
 }
 
+impl gpui::EventEmitter<workspace::SidebarEvent> for Sidebar {}
+
 impl Focusable for Sidebar {
     fn focus_handle(&self, _cx: &App) -> FocusHandle {
         self.focus_handle.clone()

crates/sidebar/src/sidebar_tests.rs 🔗

@@ -244,6 +244,62 @@ fn visible_entries_as_strings(
     })
 }
 
+#[gpui::test]
+async fn test_serialization_round_trip(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(3, &path_list, cx).await;
+
+    // Set a custom width, collapse the group, and expand "View More".
+    sidebar.update_in(cx, |sidebar, window, cx| {
+        sidebar.set_width(Some(px(420.0)), cx);
+        sidebar.toggle_collapse(&path_list, window, cx);
+        sidebar.expanded_groups.insert(path_list.clone(), 2);
+    });
+    cx.run_until_parked();
+
+    // Capture the serialized state from the first sidebar.
+    let serialized = sidebar.read_with(cx, |sidebar, cx| sidebar.serialized_state(cx));
+    let serialized = serialized.expect("serialized_state should return Some");
+
+    // Create a fresh sidebar and restore into it.
+    let sidebar2 =
+        cx.update(|window, cx| cx.new(|cx| Sidebar::new(multi_workspace.clone(), window, cx)));
+    cx.run_until_parked();
+
+    sidebar2.update_in(cx, |sidebar, window, cx| {
+        sidebar.restore_serialized_state(&serialized, window, cx);
+    });
+    cx.run_until_parked();
+
+    // Assert all serialized fields match.
+    let (width1, collapsed1, expanded1) = sidebar.read_with(cx, |s, _| {
+        (
+            s.width,
+            s.collapsed_groups.clone(),
+            s.expanded_groups.clone(),
+        )
+    });
+    let (width2, collapsed2, expanded2) = sidebar2.read_with(cx, |s, _| {
+        (
+            s.width,
+            s.collapsed_groups.clone(),
+            s.expanded_groups.clone(),
+        )
+    });
+
+    assert_eq!(width1, width2);
+    assert_eq!(collapsed1, collapsed2);
+    assert_eq!(expanded1, expanded2);
+    assert_eq!(width1, px(420.0));
+    assert!(collapsed1.contains(&path_list));
+    assert_eq!(expanded1.get(&path_list), Some(&2));
+}
+
 #[test]
 fn test_clean_mention_links() {
     // Simple mention link

crates/workspace/src/multi_workspace.rs 🔗

@@ -90,7 +90,11 @@ pub enum MultiWorkspaceEvent {
     WorkspaceRemoved(EntityId),
 }
 
-pub trait Sidebar: Focusable + Render + Sized {
+pub enum SidebarEvent {
+    SerializeNeeded,
+}
+
+pub trait Sidebar: Focusable + Render + EventEmitter<SidebarEvent> + Sized {
     fn width(&self, cx: &App) -> Pixels;
     fn set_width(&mut self, width: Option<Pixels>, cx: &mut Context<Self>);
     fn has_notifications(&self, cx: &App) -> bool;
@@ -109,6 +113,20 @@ pub trait Sidebar: Focusable + Render + Sized {
         _cx: &mut Context<Self>,
     ) {
     }
+
+    /// Return an opaque JSON blob of sidebar-specific state to persist.
+    fn serialized_state(&self, _cx: &App) -> Option<String> {
+        None
+    }
+
+    /// Restore sidebar state from a previously-serialized blob.
+    fn restore_serialized_state(
+        &mut self,
+        _state: &str,
+        _window: &mut Window,
+        _cx: &mut Context<Self>,
+    ) {
+    }
 }
 
 pub trait SidebarHandle: 'static + Send + Sync {
@@ -125,6 +143,8 @@ pub trait SidebarHandle: 'static + Send + Sync {
     fn is_threads_list_view_active(&self, cx: &App) -> bool;
 
     fn side(&self, cx: &App) -> SidebarSide;
+    fn serialized_state(&self, cx: &App) -> Option<String>;
+    fn restore_serialized_state(&self, state: &str, window: &mut Window, cx: &mut App);
 }
 
 #[derive(Clone)]
@@ -186,6 +206,16 @@ impl<T: Sidebar> SidebarHandle for Entity<T> {
     fn side(&self, cx: &App) -> SidebarSide {
         self.read(cx).side(cx)
     }
+
+    fn serialized_state(&self, cx: &App) -> Option<String> {
+        self.read(cx).serialized_state(cx)
+    }
+
+    fn restore_serialized_state(&self, state: &str, window: &mut Window, cx: &mut App) {
+        self.update(cx, |this, cx| {
+            this.restore_serialized_state(state, window, cx)
+        })
+    }
 }
 
 pub struct MultiWorkspace {
@@ -259,6 +289,12 @@ impl MultiWorkspace {
             .push(cx.observe(&sidebar, |_this, _, cx| {
                 cx.notify();
             }));
+        self._subscriptions
+            .push(cx.subscribe(&sidebar, |this, _, event, cx| match event {
+                SidebarEvent::SerializeNeeded => {
+                    this.serialize(cx);
+                }
+            }));
         self.sidebar = Some(Box::new(sidebar));
     }
 
@@ -585,14 +621,22 @@ impl MultiWorkspace {
         self.cycle_workspace(-1, window, cx);
     }
 
-    fn serialize(&mut self, cx: &mut App) {
-        let window_id = self.window_id;
-        let state = crate::persistence::model::MultiWorkspaceState {
-            active_workspace_id: self.workspace().read(cx).database_id(),
-            sidebar_open: self.sidebar_open,
-        };
-        let kvp = db::kvp::KeyValueStore::global(cx);
-        self._serialize_task = Some(cx.background_spawn(async move {
+    pub(crate) fn serialize(&mut self, cx: &mut Context<Self>) {
+        self._serialize_task = Some(cx.spawn(async move |this, cx| {
+            let Some((window_id, state)) = this
+                .read_with(cx, |this, cx| {
+                    let state = crate::persistence::model::MultiWorkspaceState {
+                        active_workspace_id: this.workspace().read(cx).database_id(),
+                        sidebar_open: this.sidebar_open,
+                        sidebar_state: this.sidebar.as_ref().and_then(|s| s.serialized_state(cx)),
+                    };
+                    (this.window_id, state)
+                })
+                .ok()
+            else {
+                return;
+            };
+            let kvp = cx.update(|cx| db::kvp::KeyValueStore::global(cx));
             crate::persistence::write_multi_workspace_state(&kvp, window_id, state).await;
         }));
     }
@@ -940,9 +984,15 @@ impl Render for MultiWorkspace {
                                     if let Some(sidebar) = this.sidebar.as_mut() {
                                         sidebar.set_width(None, cx);
                                     }
+                                    this.serialize(cx);
                                 })
                                 .ok();
                                 cx.stop_propagation();
+                            } else {
+                                weak.update(cx, |this, cx| {
+                                    this.serialize(cx);
+                                })
+                                .ok();
                             }
                         })
                         .occlude(),

crates/workspace/src/persistence.rs 🔗

@@ -3979,6 +3979,7 @@ mod tests {
             MultiWorkspaceState {
                 active_workspace_id: Some(WorkspaceId(2)),
                 sidebar_open: true,
+                sidebar_state: None,
             },
         )
         .await;
@@ -3989,6 +3990,7 @@ mod tests {
             MultiWorkspaceState {
                 active_workspace_id: Some(WorkspaceId(3)),
                 sidebar_open: false,
+                sidebar_state: None,
             },
         )
         .await;

crates/workspace/src/persistence/model.rs 🔗

@@ -64,6 +64,8 @@ pub struct SessionWorkspace {
 pub struct MultiWorkspaceState {
     pub active_workspace_id: Option<WorkspaceId>,
     pub sidebar_open: bool,
+    #[serde(default)]
+    pub sidebar_state: Option<String>,
 }
 
 /// The serialized state of a single MultiWorkspace window from a previous session:

crates/workspace/src/workspace.rs 🔗

@@ -29,7 +29,7 @@ pub use crate::notifications::NotificationFrame;
 pub use dock::Panel;
 pub use multi_workspace::{
     CloseWorkspaceSidebar, DraggedSidebar, FocusWorkspaceSidebar, MultiWorkspace,
-    MultiWorkspaceEvent, NextWorkspace, PreviousWorkspace, Sidebar, SidebarHandle,
+    MultiWorkspaceEvent, NextWorkspace, PreviousWorkspace, Sidebar, SidebarEvent, SidebarHandle,
     SidebarRenderState, SidebarSide, ToggleWorkspaceSidebar, sidebar_side_context_menu,
 };
 pub use path_list::{PathList, SerializedPathList};
@@ -8687,6 +8687,18 @@ pub async fn restore_multiworkspace(
             .ok();
     }
 
+    if let Some(sidebar_state) = &state.sidebar_state {
+        let sidebar_state = sidebar_state.clone();
+        window_handle
+            .update(cx, |multi_workspace, window, cx| {
+                if let Some(sidebar) = multi_workspace.sidebar() {
+                    sidebar.restore_serialized_state(&sidebar_state, window, cx);
+                }
+                multi_workspace.serialize(cx);
+            })
+            .ok();
+    }
+
     window_handle
         .update(cx, |_, window, _cx| {
             window.activate_window();