From 2e44664ab89e3936e1709f37136078e90a9be680 Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Mon, 30 Mar 2026 21:16:35 -0700 Subject: [PATCH] Implement sidebar serialization (#52795) 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 --- 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(-) diff --git a/Cargo.lock b/Cargo.lock index 1f208e459a7eb0323aa378a168c3648052135200..66c7d8063072c8019bb8e5c09d884257b0545f44 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15981,6 +15981,7 @@ dependencies = [ "prompt_store", "recent_projects", "remote", + "serde", "serde_json", "settings", "theme", diff --git a/crates/sidebar/Cargo.toml b/crates/sidebar/Cargo.toml index e367d7dc676994cb7facfa1d43ecae6349a2b88e..860a7c0ab394de4f2f0863d7a7d9f1b990a39315 100644 --- a/crates/sidebar/Cargo.toml +++ b/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 diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 27dbe85b6c03378907e0abda766a08407b8bbe73..cd12dc852e4a25f244a5371b80aa2ce9fabad8f2 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/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, + #[serde(default)] + collapsed_groups: Vec, + #[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) { + cx.emit(workspace::SidebarEvent::SerializeNeeded); + } + fn is_active_workspace(&self, workspace: &Entity, 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 { + 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, + ) { + if let Some(serialized) = serde_json::from_str::(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 for Sidebar {} + impl Focusable for Sidebar { fn focus_handle(&self, _cx: &App) -> FocusHandle { self.focus_handle.clone() diff --git a/crates/sidebar/src/sidebar_tests.rs b/crates/sidebar/src/sidebar_tests.rs index 5433f8028a1deedd1d9046f6a79c0d0157fce417..53cac608c189449f28eb41e6166fbc2f5fcd568c 100644 --- a/crates/sidebar/src/sidebar_tests.rs +++ b/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 diff --git a/crates/workspace/src/multi_workspace.rs b/crates/workspace/src/multi_workspace.rs index 276b7a8ddd06f300f7e7aa228eb5ddfc7e04bbaa..862f7c7b267721833fa395e501b604d30745a1b7 100644 --- a/crates/workspace/src/multi_workspace.rs +++ b/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 + Sized { fn width(&self, cx: &App) -> Pixels; fn set_width(&mut self, width: Option, cx: &mut Context); fn has_notifications(&self, cx: &App) -> bool; @@ -109,6 +113,20 @@ pub trait Sidebar: Focusable + Render + Sized { _cx: &mut Context, ) { } + + /// Return an opaque JSON blob of sidebar-specific state to persist. + fn serialized_state(&self, _cx: &App) -> Option { + None + } + + /// Restore sidebar state from a previously-serialized blob. + fn restore_serialized_state( + &mut self, + _state: &str, + _window: &mut Window, + _cx: &mut Context, + ) { + } } 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; + fn restore_serialized_state(&self, state: &str, window: &mut Window, cx: &mut App); } #[derive(Clone)] @@ -186,6 +206,16 @@ impl SidebarHandle for Entity { fn side(&self, cx: &App) -> SidebarSide { self.read(cx).side(cx) } + + fn serialized_state(&self, cx: &App) -> Option { + 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._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(), diff --git a/crates/workspace/src/persistence.rs b/crates/workspace/src/persistence.rs index c8952dfecb137cce02998225a9346556a0fc2776..334ad0925fd62a2ea529ed0e755d605924be266c 100644 --- a/crates/workspace/src/persistence.rs +++ b/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; diff --git a/crates/workspace/src/persistence/model.rs b/crates/workspace/src/persistence/model.rs index 0971ebd0ddc9265ccf9ea10da7745ba59914db30..6b55d09ebbc2375f8cce3f2b81bc4f1aa9620e76 100644 --- a/crates/workspace/src/persistence/model.rs +++ b/crates/workspace/src/persistence/model.rs @@ -64,6 +64,8 @@ pub struct SessionWorkspace { pub struct MultiWorkspaceState { pub active_workspace_id: Option, pub sidebar_open: bool, + #[serde(default)] + pub sidebar_state: Option, } /// The serialized state of a single MultiWorkspace window from a previous session: diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index d4b1cebca6b6b71b9efd3394f639a5cb32384682..e7acf3615e11015eddb2287b67c7edddfbddd622 100644 --- a/crates/workspace/src/workspace.rs +++ b/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();