summaryrefslogtreecommitdiffhomepage
path: root/src/features/layout/logic
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-09-05 01:20:28 +0900
committerAdam Malczewski <[email protected]>2026-09-05 01:20:28 +0900
commit985116fdc8961e656cd9d3027debcd1a6237d6c2 (patch)
tree43aed23977ea354d23d8c54f27d45bf5e0635dfc /src/features/layout/logic
parenta104c094b588560f955ff52dc7f949cf3c355f94 (diff)
downloaddispatch-web-985116fdc8961e656cd9d3027debcd1a6237d6c2.tar.gz
dispatch-web-985116fdc8961e656cd9d3027debcd1a6237d6c2.zip
feat(layout): plan 02 — provider consumption + layout WS + localStorage migration
Diffstat (limited to 'src/features/layout/logic')
-rw-r--r--src/features/layout/logic/migration.test.ts72
-rw-r--r--src/features/layout/logic/migration.ts76
2 files changed, 148 insertions, 0 deletions
diff --git a/src/features/layout/logic/migration.test.ts b/src/features/layout/logic/migration.test.ts
new file mode 100644
index 0000000..0b2b402
--- /dev/null
+++ b/src/features/layout/logic/migration.test.ts
@@ -0,0 +1,72 @@
+import { describe, expect, it } from "vitest";
+import { DEFAULT_LAYOUT } from "@dispatch/layout-contract";
+import {
+ migrationTree,
+ sidePanelViews,
+ storedPanelViews,
+} from "./migration";
+import { overlaySidePanels } from "./seed";
+
+const KNOWN = new Set(["tabs", "model", "extensions", "vision", "tasks"]);
+
+describe("storedPanelViews", () => {
+ it("parses a stored array of view ids, filtered to known views, order kept", () => {
+ expect(storedPanelViews(["vision", "junk", "tasks"], KNOWN)).toEqual(["vision", "tasks"]);
+ });
+
+ it("returns null for absent/junk/non-array values", () => {
+ expect(storedPanelViews(null, KNOWN)).toBeNull();
+ expect(storedPanelViews(undefined, KNOWN)).toBeNull();
+ expect(storedPanelViews("extensions", KNOWN)).toBeNull();
+ expect(storedPanelViews({ 0: "tabs" }, KNOWN)).toBeNull();
+ });
+
+ it("returns null when nothing stored is a known view", () => {
+ expect(storedPanelViews(["deleted-view", "another-gone"], KNOWN)).toBeNull();
+ expect(storedPanelViews([], KNOWN)).toBeNull();
+ });
+});
+
+describe("sidePanelViews", () => {
+ it("reads the default tree's side panels", () => {
+ expect(sidePanelViews(DEFAULT_LAYOUT)).toEqual(["tabs", "model"]);
+ });
+
+ it("returns null for a tree without a 'side' panels node", () => {
+ expect(sidePanelViews({ version: 1, root: { kind: "leaf", view: "chat" } })).toBeNull();
+ });
+});
+
+describe("migrationTree (§2.7 — one-way, best-effort)", () => {
+ it("imports the stored selection when the fetched tree is the untouched default seed", () => {
+ const stored = JSON.stringify(["extensions", "tasks"]);
+ const migrated = migrationTree(JSON.parse(stored), KNOWN, DEFAULT_LAYOUT);
+ expect(migrated).toEqual(overlaySidePanels(DEFAULT_LAYOUT, ["extensions", "tasks"]));
+ // The rest of the tree is untouched — only the side panels changed.
+ expect(migrated?.root).toEqual(
+ overlaySidePanels(DEFAULT_LAYOUT, ["extensions", "tasks"])?.root,
+ );
+ });
+
+ it("returns null when the key is absent / junk / has no known views (nothing to import)", () => {
+ expect(migrationTree(null, KNOWN, DEFAULT_LAYOUT)).toBeNull();
+ expect(migrationTree("junk", KNOWN, DEFAULT_LAYOUT)).toBeNull();
+ expect(migrationTree(["gone-view"], KNOWN, DEFAULT_LAYOUT)).toBeNull();
+ });
+
+ it("returns null when the provider's tree is NOT the default seed (custom layout wins)", () => {
+ const custom = overlaySidePanels(DEFAULT_LAYOUT, ["vision"]);
+ expect(migrationTree(["extensions"], KNOWN, custom)).toBeNull();
+ });
+
+ it("returns null when the fetched tree has no side panels node at all", () => {
+ expect(
+ migrationTree(["extensions"], KNOWN, { version: 1, root: { kind: "leaf", view: "chat" } }),
+ ).toBeNull();
+ });
+
+ it("a stored selection identical to the default still migrates (a harmless idempotent PUT)", () => {
+ const migrated = migrationTree(["tabs", "model"], KNOWN, DEFAULT_LAYOUT);
+ expect(migrated).toEqual(DEFAULT_LAYOUT);
+ });
+});
diff --git a/src/features/layout/logic/migration.ts b/src/features/layout/logic/migration.ts
new file mode 100644
index 0000000..e0ed6fd
--- /dev/null
+++ b/src/features/layout/logic/migration.ts
@@ -0,0 +1,76 @@
+/**
+ * localStorage → provider migration (plan 02 §2.7, D-L4; one-way, best-effort).
+ *
+ * `dispatch.sidebar.views` is retired as a source of truth: on boot WITH a
+ * provider present, if the key exists AND the fetched tree's `side` panels
+ * equal DEFAULT_LAYOUT's (the workspace still has the untouched default
+ * seed), the stored panel selection is imported once via a PUT. When the
+ * provider's tree differs from the default (e.g. a calendar provider), the
+ * workspace already has a custom layout — importing the old sidebar override
+ * would be wrong. Regardless of outcome the key is never read again (the
+ * bytes stay, but nothing consumes them).
+ *
+ * Pure decision logic: the store owns the effects (the localStorage read +
+ * the PUT); this module decides IF and WHAT.
+ */
+
+import {
+ DEFAULT_LAYOUT,
+ findById,
+ type LayoutTree,
+ type PanelsNode,
+} from "@dispatch/layout-contract";
+import { overlaySidePanels } from "./seed";
+
+/**
+ * The stored selection's usable view ids: an array of strings filtered to the
+ * known (registered) views, in stored order. null when nothing usable is
+ * stored (absent key, junk, or no known ids) — nothing to import.
+ */
+export function storedPanelViews(
+ raw: unknown,
+ knownViews: ReadonlySet<string>,
+): readonly string[] | null {
+ if (!Array.isArray(raw)) return null;
+ const known = raw.filter((v): v is string => typeof v === "string" && knownViews.has(v));
+ return known.length > 0 ? known : null;
+}
+
+/**
+ * The view ids of a tree's `"side"` panels node (the default tree's swappable
+ * sidebar stack), or null when the tree has no such node.
+ */
+export function sidePanelViews(tree: LayoutTree): readonly string[] | null {
+ const found = findById(tree, "side");
+ if (found === null || found.node.kind !== "panels") return null;
+ return (found.node as PanelsNode).panels.map((p) => p.view);
+}
+
+function samePanels(a: readonly string[], b: readonly string[]): boolean {
+ return a.length === b.length && a.every((v, i) => v === b[i]);
+}
+
+/**
+ * The one-way migration decision (§2.7): given the RAW stored value, the
+ * known view ids, and the tree fetched from the provider, return the tree to
+ * PUT once — or null when there is nothing to migrate:
+ * - the stored key holds no usable selection, or
+ * - the provider's tree is NOT the untouched default seed (its side panels
+ * differ from DEFAULT_LAYOUT's — the workspace already has a custom layout).
+ */
+export function migrationTree(
+ storedRaw: unknown,
+ knownViews: ReadonlySet<string>,
+ fetched: LayoutTree,
+): LayoutTree | null {
+ const stored = storedPanelViews(storedRaw, knownViews);
+ if (stored === null) return null;
+ const fetchedPanels = sidePanelViews(fetched);
+ const defaultPanels = sidePanelViews(DEFAULT_LAYOUT);
+ if (fetchedPanels === null || defaultPanels === null) return null;
+ if (!samePanels(fetchedPanels, defaultPanels)) return null;
+ // Overlay the stored selection onto the fetched tree (identical to the
+ // default here by the guard above — the seed overlay keeps the rest of the
+ // tree shared, not copied).
+ return overlaySidePanels(fetched, stored);
+}