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,
|
enabled: isCompactLayout || isOpen,
|
||||||
});
|
});
|
||||||
const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed } =
|
const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed } =
|
||||||
useSidebarShortcutModel(projects);
|
useSidebarShortcutModel({ projects, isInitialLoad });
|
||||||
|
|
||||||
const [isManualRefresh, setIsManualRefresh] = useState(false);
|
const [isManualRefresh, setIsManualRefresh] = useState(false);
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ import { buildSidebarShortcutModel } from "@/utils/sidebar-shortcuts";
|
|||||||
import { isSidebarProjectFlattened } from "@/utils/sidebar-project-row-model";
|
import { isSidebarProjectFlattened } from "@/utils/sidebar-project-row-model";
|
||||||
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
|
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(
|
const collapsedProjectKeys = useSidebarCollapsedSectionsStore(
|
||||||
(state) => state.collapsedProjectKeys,
|
(state) => state.collapsedProjectKeys,
|
||||||
);
|
);
|
||||||
@@ -25,6 +29,10 @@ export function useSidebarShortcutModel(projects: SidebarProjectEntry[]) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (isInitialLoad || projects.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const collapsibleProjectKeys = new Set(
|
const collapsibleProjectKeys = new Set(
|
||||||
projects
|
projects
|
||||||
.filter((project) => !isSidebarProjectFlattened(project))
|
.filter((project) => !isSidebarProjectFlattened(project))
|
||||||
@@ -35,7 +43,7 @@ export function useSidebarShortcutModel(projects: SidebarProjectEntry[]) {
|
|||||||
setProjectCollapsed(key, false);
|
setProjectCollapsed(key, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [collapsedProjectKeys, projects, setProjectCollapsed]);
|
}, [collapsedProjectKeys, isInitialLoad, projects, setProjectCollapsed]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
collapsedProjectKeys,
|
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 { create } from "zustand";
|
||||||
|
import { createJSONStorage, persist } from "zustand/middleware";
|
||||||
|
|
||||||
interface SidebarCollapsedSectionsState {
|
interface SidebarCollapsedSectionsState {
|
||||||
collapsedProjectKeys: Set<string>;
|
collapsedProjectKeys: Set<string>;
|
||||||
@@ -6,26 +8,84 @@ interface SidebarCollapsedSectionsState {
|
|||||||
setProjectCollapsed: (projectKey: string, collapsed: boolean) => void;
|
setProjectCollapsed: (projectKey: string, collapsed: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useSidebarCollapsedSectionsStore = create<SidebarCollapsedSectionsState>((set) => ({
|
type PersistedSidebarCollapsedSectionsState = {
|
||||||
collapsedProjectKeys: new Set(),
|
collapsedProjectKeys?: string[];
|
||||||
toggleProjectCollapsed: (projectKey) =>
|
};
|
||||||
set((state) => {
|
|
||||||
const next = new Set(state.collapsedProjectKeys);
|
function serializeCollapsedProjectKeys(keys: Set<string>): string[] {
|
||||||
if (next.has(projectKey)) {
|
return Array.from(keys);
|
||||||
next.delete(projectKey);
|
}
|
||||||
} else {
|
|
||||||
next.add(projectKey);
|
function deserializeCollapsedProjectKeys(value: unknown): Set<string> {
|
||||||
}
|
if (!Array.isArray(value)) {
|
||||||
return { collapsedProjectKeys: next };
|
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) => {
|
name: "sidebar-collapsed-sections",
|
||||||
const next = new Set(state.collapsedProjectKeys);
|
storage: createJSONStorage(() => AsyncStorage),
|
||||||
if (collapsed) {
|
partialize: (state) => ({
|
||||||
next.add(projectKey);
|
collapsedProjectKeys: serializeCollapsedProjectKeys(state.collapsedProjectKeys),
|
||||||
} else {
|
}),
|
||||||
next.delete(projectKey);
|
merge: (persistedState, currentState) => {
|
||||||
}
|
const persisted = persistedState as PersistedSidebarCollapsedSectionsState | undefined;
|
||||||
return { collapsedProjectKeys: next };
|
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
|
# Build the Electron main process
|
||||||
npm run build:main
|
npm run build:main
|
||||||
|
|
||||||
# Get a random available port for Metro
|
# Prefer Metro's stable default port so dev browser storage keeps the same
|
||||||
$env:EXPO_PORT = (npx get-port-cli).Trim()
|
# 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
|
# Set EXPO_DEV_URL in the environment so Electron inherits it
|
||||||
$env:EXPO_DEV_URL = "http://localhost:$($env:EXPO_PORT)"
|
$env:EXPO_DEV_URL = "http://localhost:$($env:EXPO_PORT)"
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ ROOT_DIR="$(cd "$DESKTOP_DIR/../.." && pwd)"
|
|||||||
# Build the Electron main process
|
# Build the Electron main process
|
||||||
npm run build:main
|
npm run build:main
|
||||||
|
|
||||||
# Get a random available port for Metro
|
# Prefer Metro's stable default port so dev browser storage keeps the same
|
||||||
EXPO_PORT=$("$ROOT_DIR/node_modules/.bin/get-port")
|
# 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
|
export EXPO_PORT
|
||||||
|
|
||||||
# Allow any origin in dev so Electron on random localhost ports can reach
|
# Allow any origin in dev so Electron on random localhost ports can reach
|
||||||
|
|||||||
Reference in New Issue
Block a user