1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
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);
}
|