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
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
|
import { DEFAULT_LAYOUT, type LayoutTree } from "@dispatch/layout-contract";
import type { LayoutHttp, LayoutHttpResult } from "./adapter/http";
import { migrationTree } from "./logic/migration";
/**
* Layout store (plan 02 §2.6) — a thin reactive wrapper over the injected
* `LayoutHttp` edge (the plugins-store pattern). Owns the ACTIVE workspace's
* tree and the provider lifecycle:
*
* - **Provider probe triggers** — boot, every workspace switch, every
* `extensions-changed`, and every change to the ACTIVE workspace's
* per-workspace plugin state (a layout plugin enabled/disabled in the
* workspace plugins modal starts/stops serving; the probe picks it up
* without a reload).
* - **No-provider mode** — a 404 (or network failure) keeps the tree on its
* local default; edits still work but are EPHEMERAL (in-memory only —
* D-L4 retired the localStorage write path); logged once, no error UI.
* - **WS push** — `onServerTree(workspaceId, tree)` applies a `layout`
* broadcast, ignoring non-active workspaces (the server fans out to all
* clients; the client filters).
* - **User edits persist through the provider** — `onTreeChange` applies the
* edit locally immediately, then PUTs the WHOLE tree (decision 7 — no
* patch protocol) lightly debounced; on a failed PUT the tree rolls back
* to the last server-known tree with a quiet console warning.
* - **Collapse state stays local** (decision 3) — `dispatch.sidebar.open`
* is the collapse store's concern, untouched here.
* - **One-way localStorage migration** (§2.7) — on the FIRST successful
* provider read of the boot session, if `dispatch.sidebar.views` exists
* AND the fetched tree is the untouched default seed, the stored panel
* selection is imported with one PUT; the key is then never read again.
*
* No ambient state: one instance per composition root, passed explicitly;
* no module-level mutables.
*/
/** Whether an active provider is serving the active workspace. */
export type LayoutProviderState = "unknown" | "present" | "absent";
export interface LayoutStore {
/** The rendered tree for the ACTIVE workspace (starts at the local default). */
readonly tree: LayoutTree;
/** The active provider's state (absent = the no-provider degrade path). */
readonly providerState: LayoutProviderState;
/** The workspace the tree currently belongs to. */
readonly activeWorkspaceId: string;
/**
* Probe the provider for `workspaceId`'s tree. `boot: true` (the composition
* root's boot call) also arms the one-way localStorage migration (§2.7),
* which consumes itself on the first successful provider read regardless of
* outcome. A probe is the missed-push catch-up (§2.5).
*/
probe(workspaceId: string, opts?: { readonly boot?: boolean }): Promise<void>;
/** Workspace switch: reset to the local default for the new workspace + probe. */
onWorkspaceChanged(workspaceId: string): void;
/** Server `layout` broadcast (§2.5). Non-active workspaces are ignored. */
onServerTree(workspaceId: string, tree: LayoutTree): void;
/** A user edit (the panels' dropdowns / TileRoot ops). Persists via PUT. */
onTreeChange(next: LayoutTree): void;
/**
* Reset the workspace's layout to the default seed (plan 04 §4.4 — the
* plugins modal's "Reset layout to default" affordance:
* `DELETE /workspaces/:id/layout` → re-seed → broadcast). Resolves to
* `null` on success (the fresh seed is applied locally + pushed via WS to
* every client) or to the FAILURE REASON — the no-provider 404 resolves
* to the user-facing "no layout plugin enabled" (the button's gate renders
* the same reason before this runs).
*/
resetToDefault(workspaceId: string): Promise<string | null>;
/** Multi-client coherence (§11): re-probe on `extensions-changed`. */
onExtensionsChanged(): void;
/**
* The active workspace's PER-WORKSPACE PLUGIN STATE changed (the workspace
* plugins modal toggled a layout plugin's enablement — the per-workspace
* activation rule): re-probe so the newly-enabled provider's tree (or the
* no-provider degrade) lands without a reload. A NON-active workspace's
* toggle is ignored — its tree is fetched on its next probe (workspace
* switch); there is nothing to re-render here.
*/
onWorkspacePluginsChanged(workspaceId: string): void;
}
export interface LayoutStoreOptions {
readonly http: LayoutHttp;
/** The boot workspace (its URL slug); "default" if absent. */
readonly workspaceId?: string;
/**
* Injected localStorage — read ONLY by the one-way §2.7 migration (the
* write path is retired; collapse state has its own store). Undefined →
* migration never applies (tests / storageless contexts).
*/
readonly storage?: Storage | undefined;
/** The known (registered) view ids, for the migration's unknown-id filter. */
readonly knownViews?: ReadonlySet<string> | readonly string[];
/**
* The localStorage key the §2.7 migration reads (default
* `dispatch.sidebar.views`; tests override).
*/
readonly sidebarViewsKey?: string;
/** Edit-PUT debounce window in ms (default 300; tests shrink it). */
readonly debounceMs?: number;
/** The quiet-failure hook (default `console.warn` — never an error UI). */
readonly warn?: (message: string) => void;
}
const DEFAULT_SIDEBAR_VIEWS_KEY = "dispatch.sidebar.views";
const DEFAULT_DEBOUNCE_MS = 300;
function toKnownSet(known: LayoutStoreOptions["knownViews"]): ReadonlySet<string> {
if (known === undefined) return new Set();
return known instanceof Set ? known : new Set(known);
}
export function createLayoutStore(opts: LayoutStoreOptions): LayoutStore {
const http = opts.http;
const storage = opts.storage;
const sidebarViewsKey = opts.sidebarViewsKey ?? DEFAULT_SIDEBAR_VIEWS_KEY;
const debounceMs = opts.debounceMs ?? DEFAULT_DEBOUNCE_MS;
const knownViews = toKnownSet(opts.knownViews);
const warn = opts.warn ?? ((message: string) => console.warn(message));
let tree = $state<LayoutTree>(DEFAULT_LAYOUT);
let providerState = $state<LayoutProviderState>("unknown");
let activeWorkspaceId = $state<string>(opts.workspaceId ?? "default");
/** The last tree known to come from the server (GET / WS push / PUT echo) — the rollback target. */
let lastServerTree: LayoutTree | null = null;
/** The pending edit-PUT timer (the "lightly debounced" write). */
let putTimer: ReturnType<typeof setTimeout> | null = null;
/** Logged-once guard for the no-provider log (reset when a provider appears). */
let loggedAbsent = false;
/**
* The §2.7 migration is armed by the BOOT probe and consumed by the first
* successful provider read — regardless of outcome (read-once semantics).
*/
let migrationArmed = false;
function noteAbsent(reason: string): void {
providerState = "absent";
if (!loggedAbsent) {
loggedAbsent = true;
warn(
`layout: no layout provider active (${reason}) — rendering the built-in default; edits are ephemeral`,
);
}
}
function notePresent(): void {
providerState = "present";
loggedAbsent = false;
}
function applyServerTree(next: LayoutTree): void {
lastServerTree = next;
tree = next;
}
/** The §2.7 one-way migration: at most one read + one PUT per session. */
async function maybeMigrate(): Promise<void> {
if (!migrationArmed || storage === undefined) return;
migrationArmed = false; // consumed regardless of outcome (stop reading the key)
let raw: string | null = null;
try {
raw = storage.getItem(sidebarViewsKey);
} catch {
return; // storage unavailable — best-effort, done
}
if (raw === null) return; // nothing stored — nothing to import
let parsed: unknown = null;
try {
parsed = JSON.parse(raw) as unknown;
} catch {
return; // junk — best-effort, done
}
const migrated = migrationTree(parsed, knownViews, tree);
if (migrated === null) return; // custom layout already / nothing usable
const result = await http.putTree(activeWorkspaceId, migrated);
if (result.ok) {
// The server's canonical copy (also echoed back via the WS broadcast).
applyServerTree(result.tree);
} else if (!result.notFound) {
warn(`layout: localStorage migration PUT failed — ${result.error}`);
}
}
async function runProbe(workspaceId: string, boot: boolean): Promise<void> {
if (boot) migrationArmed = true;
const result: LayoutHttpResult = await http.getTree(workspaceId);
// A switch may have happened while the fetch was in flight — only the
// CURRENT workspace's result lands (the refreshProviderStatus guard).
if (workspaceId !== activeWorkspaceId) return;
if (result.ok) {
notePresent();
applyServerTree(result.tree);
await maybeMigrate();
} else if (result.notFound) {
noteAbsent(result.error);
} else {
// A real failure (5xx, malformed) degrades like absent — quietly.
noteAbsent(result.error);
}
}
function clearPutTimer(): void {
if (putTimer !== null) {
clearTimeout(putTimer);
putTimer = null;
}
}
async function flushPut(workspaceId: string, pending: LayoutTree): Promise<void> {
putTimer = null;
// Read the CURRENT tree at fire time: a newer edit supersedes the
// debounced snapshot (one in-flight write per window, last wins).
const target = tree === pending ? pending : tree;
const result = await http.putTree(workspaceId, target);
if (result.ok) {
notePresent();
// The server's validated echo is the new rollback target.
lastServerTree = result.tree;
return;
}
if (result.notFound) {
// The provider vanished: keep the local edit (ephemeral), no rollback —
// there is no server tree to roll back TO.
noteAbsent(result.error);
return;
}
// A failed write on a PRESENT provider: roll back to the last server
// tree, quiet console warning (never an error UI).
warn(
`layout: persisting the tree failed (${result.error}) — rolled back to the last server tree`,
);
if (lastServerTree !== null) tree = lastServerTree;
}
return {
get tree() {
return tree;
},
get providerState() {
return providerState;
},
get activeWorkspaceId() {
return activeWorkspaceId;
},
async probe(workspaceId, probeOpts) {
await runProbe(workspaceId, probeOpts?.boot === true);
},
onWorkspaceChanged(workspaceId) {
if (workspaceId === activeWorkspaceId) return;
activeWorkspaceId = workspaceId;
// The tree belongs to the OLD workspace now: reset to the local default
// while the probe resolves (never flash the previous workspace's tree),
// and drop any queued edit — it was for the old workspace.
clearPutTimer();
lastServerTree = null;
tree = DEFAULT_LAYOUT;
providerState = "unknown";
void runProbe(workspaceId, false);
},
onServerTree(workspaceId, next) {
if (workspaceId !== activeWorkspaceId) return; // clients filter (§2.5)
notePresent();
clearPutTimer(); // the server's copy supersedes any queued edit
applyServerTree(next);
},
onTreeChange(next) {
tree = next; // the edit applies locally IMMEDIATELY (the UI never waits)
if (providerState === "absent") return; // ephemeral — no write, no warning
clearPutTimer();
putTimer = setTimeout(() => {
void flushPut(activeWorkspaceId, next).catch(() => {
// Network-grade failure: roll back to the last server tree, quietly.
warn("layout: persisting the tree failed — rolled back to the last server tree");
if (lastServerTree !== null) tree = lastServerTree;
});
}, debounceMs);
},
async resetToDefault(workspaceId): Promise<string | null> {
const result = await http.deleteTree(workspaceId);
if (result.ok) {
// The server re-seeded and broadcast (every client resets); this
// client applies the fresh seed immediately too (idempotent with the
// WS echo). Only the ACTIVE workspace's tree is ours to update.
notePresent();
if (workspaceId === activeWorkspaceId) applyServerTree(result.tree);
return null;
}
// The no-provider 404: the user-facing reason the plan specifies.
if (result.notFound) return "no layout plugin enabled";
return result.error;
},
onExtensionsChanged() {
void runProbe(activeWorkspaceId, false).catch(() => {
// runProbe never throws for HTTP failures; this guard is for the
// migration path. Staying silent keeps the degrade quiet.
});
},
onWorkspacePluginsChanged(workspaceId) {
// The per-workspace activation rule: only the ACTIVE workspace's tree
// can change what is rendered here — another workspace's toggle is
// picked up by its own probe (boot/workspace-switch/§2.6 triggers).
if (workspaceId !== activeWorkspaceId) return;
void runProbe(activeWorkspaceId, false).catch(() => {
// Same quiet-degrade guard as onExtensionsChanged.
});
},
};
}
|