Persist sidebar collapse state and stabilize Metro port

This commit is contained in:
Mohamed Boudra
2026-04-21 14:01:16 +07:00
parent 32a20cbeea
commit 515ba62be6
6 changed files with 162 additions and 29 deletions
+1 -1
View File
@@ -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);
@@ -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,
@@ -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);
});
});
@@ -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<string>;
@@ -6,26 +8,84 @@ interface SidebarCollapsedSectionsState {
setProjectCollapsed: (projectKey: string, collapsed: boolean) => void;
}
export const useSidebarCollapsedSectionsStore = create<SidebarCollapsedSectionsState>((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>): string[] {
return Array.from(keys);
}
function deserializeCollapsedProjectKeys(value: unknown): Set<string> {
if (!Array.isArray(value)) {
return new Set();
}
return new Set(value.filter((key): key is string => typeof key === "string"));
}
function areSetsEqual(left: Set<string>, right: Set<string>): 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<SidebarCollapsedSectionsState>()(
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,
};
},
},
),
);
+3 -2
View File
@@ -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)"
+3 -2
View File
@@ -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