From 515ba62be60c85c1b7e6ffbd55c1c06ef0feb944 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 21 Apr 2026 14:01:16 +0700 Subject: [PATCH] Persist sidebar collapse state and stabilize Metro port --- packages/app/src/components/left-sidebar.tsx | 2 +- .../src/hooks/use-sidebar-shortcut-model.ts | 12 +- .../sidebar-collapsed-sections-store.test.ts | 63 +++++++++++ .../sidebar-collapsed-sections-store.ts | 104 ++++++++++++++---- packages/desktop/scripts/dev.ps1 | 5 +- packages/desktop/scripts/dev.sh | 5 +- 6 files changed, 162 insertions(+), 29 deletions(-) create mode 100644 packages/app/src/stores/sidebar-collapsed-sections-store.test.ts diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 75c2b4528..db222e1e7 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -185,7 +185,7 @@ export const LeftSidebar = memo(function LeftSidebar({ enabled: isCompactLayout || isOpen, }); const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed } = - useSidebarShortcutModel(projects); + useSidebarShortcutModel({ projects, isInitialLoad }); const [isManualRefresh, setIsManualRefresh] = useState(false); diff --git a/packages/app/src/hooks/use-sidebar-shortcut-model.ts b/packages/app/src/hooks/use-sidebar-shortcut-model.ts index ec06085f4..80cfe7a4b 100644 --- a/packages/app/src/hooks/use-sidebar-shortcut-model.ts +++ b/packages/app/src/hooks/use-sidebar-shortcut-model.ts @@ -4,7 +4,11 @@ import { buildSidebarShortcutModel } from "@/utils/sidebar-shortcuts"; import { isSidebarProjectFlattened } from "@/utils/sidebar-project-row-model"; import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store"; -export function useSidebarShortcutModel(projects: SidebarProjectEntry[]) { +export function useSidebarShortcutModel(input: { + projects: SidebarProjectEntry[]; + isInitialLoad: boolean; +}) { + const { projects, isInitialLoad } = input; const collapsedProjectKeys = useSidebarCollapsedSectionsStore( (state) => state.collapsedProjectKeys, ); @@ -25,6 +29,10 @@ export function useSidebarShortcutModel(projects: SidebarProjectEntry[]) { ); useEffect(() => { + if (isInitialLoad || projects.length === 0) { + return; + } + const collapsibleProjectKeys = new Set( projects .filter((project) => !isSidebarProjectFlattened(project)) @@ -35,7 +43,7 @@ export function useSidebarShortcutModel(projects: SidebarProjectEntry[]) { setProjectCollapsed(key, false); } } - }, [collapsedProjectKeys, projects, setProjectCollapsed]); + }, [collapsedProjectKeys, isInitialLoad, projects, setProjectCollapsed]); return { collapsedProjectKeys, diff --git a/packages/app/src/stores/sidebar-collapsed-sections-store.test.ts b/packages/app/src/stores/sidebar-collapsed-sections-store.test.ts new file mode 100644 index 000000000..7fca91bde --- /dev/null +++ b/packages/app/src/stores/sidebar-collapsed-sections-store.test.ts @@ -0,0 +1,63 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@react-native-async-storage/async-storage", () => ({ + default: { + getItem: vi.fn(async () => null), + setItem: vi.fn(), + removeItem: vi.fn(), + }, +})); + +import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store"; + +describe("sidebar-collapsed-sections-store", () => { + beforeEach(() => { + useSidebarCollapsedSectionsStore.setState({ + collapsedProjectKeys: new Set(), + }); + }); + + it("tracks collapsed project keys as a Set", () => { + const store = useSidebarCollapsedSectionsStore.getState(); + + store.setProjectCollapsed("project-a", true); + store.toggleProjectCollapsed("project-b"); + store.toggleProjectCollapsed("project-a"); + + expect(Array.from(useSidebarCollapsedSectionsStore.getState().collapsedProjectKeys)).toEqual([ + "project-b", + ]); + }); + + it("serializes collapsed project keys for preference storage", () => { + useSidebarCollapsedSectionsStore.setState({ + collapsedProjectKeys: new Set(["project-a", "project-b"]), + }); + + const partialize = useSidebarCollapsedSectionsStore.persist.getOptions().partialize; + + expect(partialize?.(useSidebarCollapsedSectionsStore.getState())).toEqual({ + collapsedProjectKeys: ["project-a", "project-b"], + }); + }); + + it("restores collapsed project keys from persisted preferences", () => { + const merge = useSidebarCollapsedSectionsStore.persist.getOptions().merge; + const restored = merge?.( + { + collapsedProjectKeys: ["project-a", "project-b", 42], + }, + useSidebarCollapsedSectionsStore.getState(), + ); + + expect(Array.from(restored?.collapsedProjectKeys ?? [])).toEqual(["project-a", "project-b"]); + }); + + it("keeps the existing state object when persisted preferences do not change collapsed keys", () => { + const merge = useSidebarCollapsedSectionsStore.persist.getOptions().merge; + const currentState = useSidebarCollapsedSectionsStore.getState(); + + expect(merge?.({}, currentState)).toBe(currentState); + expect(merge?.({ collapsedProjectKeys: [] }, currentState)).toBe(currentState); + }); +}); diff --git a/packages/app/src/stores/sidebar-collapsed-sections-store.ts b/packages/app/src/stores/sidebar-collapsed-sections-store.ts index 88e37ba21..d2f61348e 100644 --- a/packages/app/src/stores/sidebar-collapsed-sections-store.ts +++ b/packages/app/src/stores/sidebar-collapsed-sections-store.ts @@ -1,4 +1,6 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; interface SidebarCollapsedSectionsState { collapsedProjectKeys: Set; @@ -6,26 +8,84 @@ interface SidebarCollapsedSectionsState { setProjectCollapsed: (projectKey: string, collapsed: boolean) => void; } -export const useSidebarCollapsedSectionsStore = create((set) => ({ - collapsedProjectKeys: new Set(), - toggleProjectCollapsed: (projectKey) => - set((state) => { - const next = new Set(state.collapsedProjectKeys); - if (next.has(projectKey)) { - next.delete(projectKey); - } else { - next.add(projectKey); - } - return { collapsedProjectKeys: next }; +type PersistedSidebarCollapsedSectionsState = { + collapsedProjectKeys?: string[]; +}; + +function serializeCollapsedProjectKeys(keys: Set): string[] { + return Array.from(keys); +} + +function deserializeCollapsedProjectKeys(value: unknown): Set { + if (!Array.isArray(value)) { + return new Set(); + } + + return new Set(value.filter((key): key is string => typeof key === "string")); +} + +function areSetsEqual(left: Set, right: Set): boolean { + if (left.size !== right.size) { + return false; + } + + for (const key of left) { + if (!right.has(key)) { + return false; + } + } + + return true; +} + +export const useSidebarCollapsedSectionsStore = create()( + persist( + (set) => ({ + collapsedProjectKeys: new Set(), + toggleProjectCollapsed: (projectKey) => + set((state) => { + const next = new Set(state.collapsedProjectKeys); + if (next.has(projectKey)) { + next.delete(projectKey); + } else { + next.add(projectKey); + } + return { collapsedProjectKeys: next }; + }), + setProjectCollapsed: (projectKey, collapsed) => + set((state) => { + const next = new Set(state.collapsedProjectKeys); + if (collapsed) { + next.add(projectKey); + } else { + next.delete(projectKey); + } + return { collapsedProjectKeys: next }; + }), }), - setProjectCollapsed: (projectKey, collapsed) => - set((state) => { - const next = new Set(state.collapsedProjectKeys); - if (collapsed) { - next.add(projectKey); - } else { - next.delete(projectKey); - } - return { collapsedProjectKeys: next }; - }), -})); + { + name: "sidebar-collapsed-sections", + storage: createJSONStorage(() => AsyncStorage), + partialize: (state) => ({ + collapsedProjectKeys: serializeCollapsedProjectKeys(state.collapsedProjectKeys), + }), + merge: (persistedState, currentState) => { + const persisted = persistedState as PersistedSidebarCollapsedSectionsState | undefined; + if (!persisted?.collapsedProjectKeys) { + return currentState; + } + const collapsedProjectKeys = deserializeCollapsedProjectKeys( + persisted.collapsedProjectKeys, + ); + if (areSetsEqual(currentState.collapsedProjectKeys, collapsedProjectKeys)) { + return currentState; + } + + return { + ...currentState, + collapsedProjectKeys, + }; + }, + }, + ), +); diff --git a/packages/desktop/scripts/dev.ps1 b/packages/desktop/scripts/dev.ps1 index 609704eac..580aabd9f 100644 --- a/packages/desktop/scripts/dev.ps1 +++ b/packages/desktop/scripts/dev.ps1 @@ -8,8 +8,9 @@ $RootDir = (Resolve-Path "$DesktopDir\..\..").Path # Build the Electron main process npm run build:main -# Get a random available port for Metro -$env:EXPO_PORT = (npx get-port-cli).Trim() +# Prefer Metro's stable default port so dev browser storage keeps the same +# localhost origin across restarts. Fall back only when earlier ports are busy. +$env:EXPO_PORT = (npx get-port-cli 8081 8082 8083 8084 8085).Trim() # Set EXPO_DEV_URL in the environment so Electron inherits it $env:EXPO_DEV_URL = "http://localhost:$($env:EXPO_PORT)" diff --git a/packages/desktop/scripts/dev.sh b/packages/desktop/scripts/dev.sh index a3b6a0ecc..8683d2e7b 100755 --- a/packages/desktop/scripts/dev.sh +++ b/packages/desktop/scripts/dev.sh @@ -9,8 +9,9 @@ ROOT_DIR="$(cd "$DESKTOP_DIR/../.." && pwd)" # Build the Electron main process npm run build:main -# Get a random available port for Metro -EXPO_PORT=$("$ROOT_DIR/node_modules/.bin/get-port") +# Prefer Metro's stable default port so dev browser storage keeps the same +# localhost origin across restarts. Fall back only when earlier ports are busy. +EXPO_PORT=$("$ROOT_DIR/node_modules/.bin/get-port" 8081 8082 8083 8084 8085) export EXPO_PORT # Allow any origin in dev so Electron on random localhost ports can reach