Persist sidebar collapse state and stabilize Metro port
This commit is contained in:
@@ -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,
|
||||
};
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user