diff --git a/client/src/lib/__tests__/splitViewStorage.test.ts b/client/src/lib/__tests__/splitViewStorage.test.ts new file mode 100644 index 0000000..a299080 --- /dev/null +++ b/client/src/lib/__tests__/splitViewStorage.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + readSplitViewState, + writeSplitViewState, + defaultSplitViewState, +} from "../splitViewStorage"; + +describe("splitViewStorage", () => { + beforeEach(() => { + localStorage.clear(); + }); + + it("returns the default state when nothing is stored", () => { + expect(readSplitViewState()).toEqual(defaultSplitViewState()); + }); + + it("defaults to a single unselected pane", () => { + expect(defaultSplitViewState()).toEqual({ layout: 1, paneLaneIds: [null] }); + }); + + it("round-trips a written state", () => { + writeSplitViewState({ layout: 4, paneLaneIds: [1, 2, null, null] }); + expect(readSplitViewState()).toEqual({ layout: 4, paneLaneIds: [1, 2, null, null] }); + }); + + it("falls back to the default when stored JSON is malformed", () => { + localStorage.setItem("ccam.workspace.splitView", "{not json"); + expect(readSplitViewState()).toEqual(defaultSplitViewState()); + }); + + it("falls back to the default when the stored layout is not 1, 2, or 4", () => { + localStorage.setItem( + "ccam.workspace.splitView", + JSON.stringify({ layout: 3, paneLaneIds: [] }) + ); + expect(readSplitViewState()).toEqual(defaultSplitViewState()); + }); +}); diff --git a/client/src/lib/splitViewStorage.ts b/client/src/lib/splitViewStorage.ts new file mode 100644 index 0000000..2de64c9 --- /dev/null +++ b/client/src/lib/splitViewStorage.ts @@ -0,0 +1,54 @@ +/** + * @file splitViewStorage.ts + * @description Persists the Workspace page's split-terminal layout (1/2/4 + * panes) and each pane's chosen lane id to localStorage, so the layout + * survives a page reload. Follows the same read/write-with-fallback + * convention as useTheme.ts. + * @author Nguyễn Ngọc Trí Vĩ + */ + +export type SplitLayout = 1 | 2 | 4; + +export interface SplitViewState { + layout: SplitLayout; + paneLaneIds: (number | null)[]; +} + +const STORAGE_KEY = "ccam.workspace.splitView"; + +export function defaultSplitViewState(): SplitViewState { + return { layout: 1, paneLaneIds: [null] }; +} + +function isValidLayout(value: unknown): value is SplitLayout { + return value === 1 || value === 2 || value === 4; +} + +function isValidState(value: unknown): value is SplitViewState { + if (!value || typeof value !== "object") return false; + const v = value as Record; + return ( + isValidLayout(v.layout) && + Array.isArray(v.paneLaneIds) && + v.paneLaneIds.every((id) => id === null || typeof id === "number") + ); +} + +export function readSplitViewState(): SplitViewState { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return defaultSplitViewState(); + const parsed: unknown = JSON.parse(raw); + return isValidState(parsed) ? parsed : defaultSplitViewState(); + } catch { + return defaultSplitViewState(); + } +} + +export function writeSplitViewState(state: SplitViewState): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); + } catch { + /* ignore quota / disabled storage */ + } +}