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();