Cargo.lock 🔗
@@ -15994,7 +15994,6 @@ dependencies = [
"project",
"prompt_store",
"recent_projects",
- "serde",
"serde_json",
"settings",
"theme",
Richard Feldman created
- Fix broken doc comment on Sidebar struct (blank line split the comment)
- Move collapsed groups persistence from global KVP to per-window
MultiWorkspaceState, eliminating cross-window race conditions and
enabling proper per-window isolation with cross-session restore
- Prune stale collapsed groups during serialization by filtering
against currently known project headers
- Add tests for mixed collapsed/expanded state across multiple groups
and for empty state after uncollapsing all groups
Cargo.lock | 1
crates/sidebar/Cargo.toml | 3
crates/sidebar/src/sidebar.rs | 173 ++++++++++++++++++------
crates/util/src/path_list.rs | 2
crates/workspace/src/multi_workspace.rs | 20 ++
crates/workspace/src/persistence.rs | 2
crates/workspace/src/persistence/model.rs | 2
crates/workspace/src/workspace.rs | 9 +
8 files changed, 158 insertions(+), 54 deletions(-)
@@ -15994,7 +15994,6 @@ dependencies = [
"project",
"prompt_store",
"recent_projects",
- "serde",
"serde_json",
"settings",
"theme",
@@ -23,7 +23,6 @@ agent_settings.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
@@ -32,8 +31,6 @@ 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
@@ -10,12 +10,12 @@ 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, KeyContext, ListState,
- Pixels, Render, SharedString, Task, WeakEntity, Window, WindowHandle, list, prelude::*, px,
+ Pixels, Render, SharedString, WeakEntity, Window, WindowHandle, list, prelude::*, px,
};
use menu::{
@@ -25,7 +25,6 @@ use project::{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;
@@ -35,12 +34,12 @@ 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,
- SerializedPathList, Sidebar as WorkspaceSidebar, SidebarSide, ToggleWorkspaceSidebar,
- Workspace, WorkspaceId, sidebar_side_context_menu,
+ Sidebar as WorkspaceSidebar, SidebarSide, ToggleWorkspaceSidebar, Workspace, WorkspaceId,
+ sidebar_side_context_menu,
};
use zed_actions::OpenRecent;
@@ -66,8 +65,6 @@ 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 {
@@ -76,12 +73,6 @@ 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,
@@ -240,24 +231,7 @@ fn workspace_path_list(workspace: &Entity<Workspace>, cx: &App) -> PathList {
PathList::new(&workspace.read(cx).root_paths(cx))
}
-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.
@@ -282,7 +256,6 @@ 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>,
@@ -310,7 +283,7 @@ impl Sidebar {
cx.subscribe_in(
&multi_workspace,
window,
- |this, _multi_workspace, event: &MultiWorkspaceEvent, window, cx| match event {
+ |this, multi_workspace, event: &MultiWorkspaceEvent, window, cx| match event {
MultiWorkspaceEvent::ActiveWorkspaceChanged => {
this.observe_draft_editor(cx);
this.update_entries(cx);
@@ -322,6 +295,18 @@ impl Sidebar {
MultiWorkspaceEvent::WorkspaceRemoved(_) => {
this.update_entries(cx);
}
+ MultiWorkspaceEvent::SidebarCollapsedGroupsChanged => {
+ let groups: HashSet<PathList> = multi_workspace
+ .read(cx)
+ .sidebar_collapsed_groups()
+ .iter()
+ .map(|s| PathList::deserialize(s))
+ .collect();
+ if groups != this.collapsed_groups {
+ this.collapsed_groups = groups;
+ this.update_entries(cx);
+ }
+ }
},
)
.detach();
@@ -353,7 +338,12 @@ impl Sidebar {
})
.detach();
- let collapsed_groups = load_collapsed_groups(&KeyValueStore::global(cx));
+ let collapsed_groups: HashSet<PathList> = multi_workspace
+ .read(cx)
+ .sidebar_collapsed_groups()
+ .iter()
+ .map(|s| PathList::deserialize(s))
+ .collect();
let workspaces = multi_workspace.read(cx).workspaces().to_vec();
cx.defer_in(window, move |this, window, cx| {
@@ -377,7 +367,6 @@ impl Sidebar {
hovered_thread_index: None,
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,
@@ -1719,24 +1708,27 @@ impl Sidebar {
}
fn serialize_collapsed_groups(&mut self, cx: &mut Context<Self>) {
+ let known_path_lists: HashSet<&PathList> = self
+ .contents
+ .entries
+ .iter()
+ .filter_map(|entry| match entry {
+ ListEntry::ProjectHeader { path_list, .. } => Some(path_list),
+ _ => None,
+ })
+ .collect();
let collapsed_groups = self
.collapsed_groups
.iter()
+ .filter(|path_list| known_path_lists.contains(path_list))
.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(),
- );
+ if let Some(multi_workspace) = self.multi_workspace.upgrade() {
+ multi_workspace.update(cx, |mw, cx| {
+ mw.set_sidebar_collapsed_groups(collapsed_groups, cx);
+ mw.serialize(cx);
+ });
+ }
}
fn toggle_collapse(
@@ -3843,6 +3835,91 @@ mod tests {
);
}
+ #[gpui::test]
+ async fn test_collapsed_groups_mixed_state_restore(cx: &mut TestAppContext) {
+ init_test(cx);
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
+ .await;
+ fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
+ .await;
+ cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
+
+ let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
+ let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
+
+ let (multi_workspace, cx) =
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
+ multi_workspace.update_in(cx, |mw, window, cx| {
+ mw.test_add_workspace(project_b, window, cx);
+ });
+ let sidebar = setup_sidebar(&multi_workspace, cx);
+
+ let path_a = PathList::new(&[std::path::PathBuf::from("/project-a")]);
+ let path_b = PathList::new(&[std::path::PathBuf::from("/project-b")]);
+ save_n_test_threads(1, &path_a, cx).await;
+ save_named_thread_metadata("thread-b", "Thread B", &path_b, cx).await;
+
+ multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
+ cx.run_until_parked();
+
+ // Collapse only project-a, leave project-b expanded
+ sidebar.update_in(cx, |s, window, cx| {
+ s.toggle_collapse(&path_a, window, cx);
+ });
+ cx.run_until_parked();
+
+ assert_eq!(
+ visible_entries_as_strings(&sidebar, cx),
+ vec!["> [project-a]", "v [project-b]", " Thread B"]
+ );
+
+ // Recreate sidebar to simulate restart
+ let restored_sidebar = setup_sidebar(&multi_workspace, cx);
+ multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
+ cx.run_until_parked();
+
+ // project-a should still be collapsed, project-b should still be expanded
+ assert_eq!(
+ visible_entries_as_strings(&restored_sidebar, cx),
+ vec!["> [project-a]", "v [project-b]", " Thread B"]
+ );
+ }
+
+ #[gpui::test]
+ async fn test_collapsed_groups_empty_after_uncollapse_all(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();
+
+ // Collapse, then uncollapse
+ sidebar.update_in(cx, |s, window, cx| {
+ s.toggle_collapse(&path_list, window, cx);
+ });
+ cx.run_until_parked();
+ sidebar.update_in(cx, |s, window, cx| {
+ s.toggle_collapse(&path_list, window, cx);
+ });
+ cx.run_until_parked();
+
+ // Recreate sidebar — everything should be expanded
+ 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!["v [my-project]", " Thread 1"]
+ );
+ }
+
#[gpui::test]
async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
let project = init_test_project("/my-project", cx).await;
@@ -38,7 +38,7 @@ impl Hash for PathList {
}
}
-#[derive(Debug, Serialize, Deserialize)]
+#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SerializedPathList {
pub paths: String,
pub order: String,
@@ -15,6 +15,7 @@ use std::path::PathBuf;
use std::sync::Arc;
use ui::prelude::*;
use util::ResultExt;
+use util::path_list::SerializedPathList;
use zed_actions::agents_sidebar::MoveWorkspaceToNewWindow;
use agent_settings::AgentSettings;
@@ -89,6 +90,7 @@ pub enum MultiWorkspaceEvent {
ActiveWorkspaceChanged,
WorkspaceAdded(Entity<Workspace>),
WorkspaceRemoved(EntityId),
+ SidebarCollapsedGroupsChanged,
}
pub trait Sidebar: Focusable + Render + Sized {
@@ -177,6 +179,7 @@ pub struct MultiWorkspace {
active_workspace_index: usize,
sidebar: Option<Box<dyn SidebarHandle>>,
sidebar_open: bool,
+ sidebar_collapsed_groups: Vec<SerializedPathList>,
pending_removal_tasks: Vec<Task<()>>,
_serialize_task: Option<Task<()>>,
_subscriptions: Vec<Subscription>,
@@ -225,6 +228,7 @@ impl MultiWorkspace {
active_workspace_index: 0,
sidebar: None,
sidebar_open: false,
+ sidebar_collapsed_groups: Vec::new(),
pending_removal_tasks: Vec::new(),
_serialize_task: None,
_subscriptions: vec![
@@ -251,6 +255,19 @@ impl MultiWorkspace {
self.sidebar_open
}
+ pub fn sidebar_collapsed_groups(&self) -> &[SerializedPathList] {
+ &self.sidebar_collapsed_groups
+ }
+
+ pub fn set_sidebar_collapsed_groups(
+ &mut self,
+ groups: Vec<SerializedPathList>,
+ cx: &mut Context<Self>,
+ ) {
+ self.sidebar_collapsed_groups = groups;
+ cx.emit(MultiWorkspaceEvent::SidebarCollapsedGroupsChanged);
+ }
+
pub fn sidebar_has_notifications(&self, cx: &App) -> bool {
self.sidebar
.as_ref()
@@ -487,11 +504,12 @@ impl MultiWorkspace {
self.cycle_workspace(-1, window, cx);
}
- fn serialize(&mut self, cx: &mut App) {
+ pub 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,
+ collapsed_sidebar_groups: self.sidebar_collapsed_groups.clone(),
};
let kvp = db::kvp::KeyValueStore::global(cx);
self._serialize_task = Some(cx.background_spawn(async move {
@@ -3978,6 +3978,7 @@ mod tests {
MultiWorkspaceState {
active_workspace_id: Some(WorkspaceId(2)),
sidebar_open: true,
+ collapsed_sidebar_groups: Vec::new(),
},
)
.await;
@@ -3988,6 +3989,7 @@ mod tests {
MultiWorkspaceState {
active_workspace_id: Some(WorkspaceId(3)),
sidebar_open: false,
+ collapsed_sidebar_groups: Vec::new(),
},
)
.await;
@@ -64,6 +64,8 @@ pub struct SessionWorkspace {
pub struct MultiWorkspaceState {
pub active_workspace_id: Option<WorkspaceId>,
pub sidebar_open: bool,
+ #[serde(default)]
+ pub collapsed_sidebar_groups: Vec<util::path_list::SerializedPathList>,
}
/// The serialized state of a single MultiWorkspace window from a previous session:
@@ -8610,6 +8610,15 @@ pub async fn restore_multiworkspace(
.ok();
}
+ if !state.collapsed_sidebar_groups.is_empty() {
+ let collapsed_groups = state.collapsed_sidebar_groups;
+ window_handle
+ .update(cx, |multi_workspace, _, cx| {
+ multi_workspace.set_sidebar_collapsed_groups(collapsed_groups, cx);
+ })
+ .ok();
+ }
+
window_handle
.update(cx, |_, window, _cx| {
window.activate_window();