From 7f748531749318c725d30f2be891f87731fc4004 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 20 Jun 2026 22:26:17 +0700 Subject: [PATCH] Keep collapsed projects across sidebar refreshes Sidebar project lists can be partial while hosts or workspace data are changing. Preserve collapsed preferences instead of treating a temporarily missing project as a request to expand it. --- packages/app/src/components/left-sidebar.tsx | 2 +- .../hooks/use-sidebar-shortcut-model.test.tsx | 102 ++++++++++++++++++ .../src/hooks/use-sidebar-shortcut-model.ts | 22 +--- 3 files changed, 106 insertions(+), 20 deletions(-) create mode 100644 packages/app/src/hooks/use-sidebar-shortcut-model.test.tsx diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 759f0f991..55675e096 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -213,7 +213,7 @@ export const LeftSidebar = memo(function LeftSidebar({ enabled: isCompactLayout || isOpen, }); const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed } = - useSidebarShortcutModel({ projects, isInitialLoad }); + useSidebarShortcutModel({ projects }); const groupMode = useSidebarViewStore((state) => activeServerId ? state.getGroupMode(activeServerId) : "project", diff --git a/packages/app/src/hooks/use-sidebar-shortcut-model.test.tsx b/packages/app/src/hooks/use-sidebar-shortcut-model.test.tsx new file mode 100644 index 000000000..42af68b15 --- /dev/null +++ b/packages/app/src/hooks/use-sidebar-shortcut-model.test.tsx @@ -0,0 +1,102 @@ +/** + * @vitest-environment jsdom + */ +import React from "react"; +import { act } from "@testing-library/react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { + SidebarProjectEntry, + SidebarWorkspaceEntry, +} from "@/hooks/use-sidebar-workspaces-list"; +import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store"; +import { useSidebarShortcutModel } from "./use-sidebar-shortcut-model"; + +function workspace(projectKey: string, workspaceId: string): SidebarWorkspaceEntry { + return { + workspaceKey: `srv:${workspaceId}`, + serverId: "srv", + workspaceId, + projectKey, + projectRootPath: `/repo/${projectKey}`, + workspaceDirectory: `/repo/${projectKey}/${workspaceId}`, + projectKind: "git", + workspaceKind: "worktree", + name: workspaceId, + title: null, + currentBranch: null, + statusBucket: "done", + statusEnteredAt: null, + archivingAt: null, + diffStat: null, + prHint: null, + archiveHasUncommittedChanges: null, + archiveUnpushedCommitCount: null, + scripts: [], + hasRunningScripts: false, + }; +} + +function project(projectKey: string): SidebarProjectEntry { + return { + projectKey, + projectName: projectKey, + projectKind: "git", + iconWorkingDir: `/repo/${projectKey}`, + canCreateWorktree: true, + workspaces: [workspace(projectKey, `${projectKey}-main`)], + }; +} + +const PROJECTS_BOTH = [project("p1"), project("p2")]; +const PROJECTS_ONLY_SECOND = [project("p2")]; + +function Probe({ projectSet }: { projectSet: "both" | "onlySecond" }) { + const projects = projectSet === "both" ? PROJECTS_BOTH : PROJECTS_ONLY_SECOND; + useSidebarShortcutModel({ projects }); + return null; +} + +describe("useSidebarShortcutModel", () => { + let root: Root | null = null; + let container: HTMLElement | null = null; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + useSidebarCollapsedSectionsStore.setState({ + collapsedProjectKeys: new Set(), + collapsedStatusGroupKeys: new Set(), + }); + }); + + afterEach(() => { + if (root) { + act(() => { + root?.unmount(); + }); + } + root = null; + container?.remove(); + container = null; + }); + + it("keeps a collapsed project collapsed when the project list temporarily omits it", async () => { + useSidebarCollapsedSectionsStore.setState({ + collapsedProjectKeys: new Set(["p1"]), + }); + + await act(async () => { + root?.render(); + }); + await act(async () => { + root?.render(); + }); + await act(async () => { + root?.render(); + }); + + expect(useSidebarCollapsedSectionsStore.getState().collapsedProjectKeys.has("p1")).toBe(true); + }); +}); diff --git a/packages/app/src/hooks/use-sidebar-shortcut-model.ts b/packages/app/src/hooks/use-sidebar-shortcut-model.ts index 2d2bc617d..565b71f08 100644 --- a/packages/app/src/hooks/use-sidebar-shortcut-model.ts +++ b/packages/app/src/hooks/use-sidebar-shortcut-model.ts @@ -1,13 +1,10 @@ -import { useEffect, useMemo } from "react"; +import { useMemo } from "react"; import type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list"; import { buildSidebarShortcutModel } from "@/utils/sidebar-shortcuts"; import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store"; -export function useSidebarShortcutModel(input: { - projects: SidebarProjectEntry[]; - isInitialLoad: boolean; -}) { - const { projects, isInitialLoad } = input; +export function useSidebarShortcutModel(input: { projects: SidebarProjectEntry[] }) { + const { projects } = input; const collapsedProjectKeys = useSidebarCollapsedSectionsStore( (state) => state.collapsedProjectKeys, ); @@ -27,19 +24,6 @@ export function useSidebarShortcutModel(input: { [collapsedProjectKeys, projects], ); - useEffect(() => { - if (isInitialLoad || projects.length === 0) { - return; - } - - const projectKeys = new Set(projects.map((project) => project.projectKey)); - for (const key of collapsedProjectKeys) { - if (!projectKeys.has(key)) { - setProjectCollapsed(key, false); - } - } - }, [collapsedProjectKeys, isInitialLoad, projects, setProjectCollapsed]); - return { collapsedProjectKeys, shortcutIndexByWorkspaceKey: shortcutModel.shortcutIndexByWorkspaceKey,