diff options
Diffstat (limited to 'src/features/workspaces')
| -rw-r--r-- | src/features/workspaces/adapter/tile-layout-http.ts | 117 | ||||
| -rw-r--r-- | src/features/workspaces/index.ts | 5 | ||||
| -rw-r--r-- | src/features/workspaces/tile-layout-store.svelte.ts | 222 | ||||
| -rw-r--r-- | src/features/workspaces/tile-layout-store.test.ts | 368 | ||||
| -rw-r--r-- | src/features/workspaces/ui/WorkspacesHome.svelte | 49 | ||||
| -rw-r--r-- | src/features/workspaces/ui/WorkspacesHome.test.ts | 56 |
6 files changed, 640 insertions, 177 deletions
diff --git a/src/features/workspaces/adapter/tile-layout-http.ts b/src/features/workspaces/adapter/tile-layout-http.ts new file mode 100644 index 0000000..58af9d8 --- /dev/null +++ b/src/features/workspaces/adapter/tile-layout-http.ts @@ -0,0 +1,117 @@ +import type { TileGeometryMode, TileLayoutData } from "@dispatch/tile-layout-contract"; + +/** + * Home tile-layout HTTP effects — the injected edge that talks to the + * `home-tile-layout` plugin's well-known routes: + * - `GET /home/tile-layout/:mode` → `{ layout }` (a fresh orientation reads + * as the seeded empty document) + * - `PUT /home/tile-layout/:mode` body `{ layout }` → 200 `{ layout }` / + * 400 `{ error }` + * - `DELETE /home/tile-layout/:mode` → the fresh empty document (reset) + * + * Mirrors the layout adapter's pattern: `httpBase` + an injected `fetchImpl` + * (testable without the network). A 404 is NOT a "no document" signal (the + * backend never 404s a fresh orientation — it seeds): any non-2xx / network + * failure means "the routes aren't served" (an old backend) or "backend + * unreachable" — both degrade the store to its local state with ephemeral + * edits, never an error UI. + */ + +/** Outcome of a tile-layout read/write. */ +export type TileLayoutHttpResult = + | { readonly ok: true; readonly layout: TileLayoutData } + | { readonly ok: false; readonly notFound: boolean; readonly error: string }; + +export interface TileLayoutHttp { + getLayout(mode: TileGeometryMode): Promise<TileLayoutHttpResult>; + putLayout(mode: TileGeometryMode, layout: TileLayoutData): Promise<TileLayoutHttpResult>; + deleteLayout(mode: TileGeometryMode): Promise<TileLayoutHttpResult>; +} + +/** Read an error message from a non-2xx body (`{ error }`), tolerating junk. */ +async function errorOf(res: Response): Promise<string> { + try { + const body = (await res.json()) as { error?: unknown }; + if (typeof body.error === "string" && body.error.length > 0) return body.error; + } catch { + // fall through + } + return `HTTP ${res.status}`; +} + +/** A malformed 200 (e.g. an SPA-fallback page) degrades like a failure — never poisons the store. */ +function malformed(): TileLayoutHttpResult { + return { ok: false, notFound: false, error: "Malformed tile-layout response" }; +} + +function route(httpBase: string, mode: TileGeometryMode): string { + return `${httpBase}/home/tile-layout/${encodeURIComponent(mode)}`; +} + +export function createTileLayoutHttp(httpBase: string, fetchImpl: typeof fetch): TileLayoutHttp { + return { + async getLayout(mode): Promise<TileLayoutHttpResult> { + try { + const res = await fetchImpl(route(httpBase, mode)); + if (res.status === 404) { + return { ok: false, notFound: true, error: "Tile-layout routes are not served" }; + } + if (!res.ok) return { ok: false, notFound: false, error: await errorOf(res) }; + const body = (await res.json()) as { layout?: unknown }; + if (body === null || typeof body !== "object" || body.layout === undefined) + return malformed(); + return { ok: true, layout: body.layout as TileLayoutData }; + } catch (err) { + return { + ok: false, + notFound: false, + error: err instanceof Error ? err.message : "Tile-layout request failed", + }; + } + }, + + async putLayout(mode, layout): Promise<TileLayoutHttpResult> { + try { + const res = await fetchImpl(route(httpBase, mode), { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ layout }), + }); + if (res.status === 404) { + return { ok: false, notFound: true, error: "Tile-layout routes are not served" }; + } + if (!res.ok) return { ok: false, notFound: false, error: await errorOf(res) }; + const body = (await res.json()) as { layout?: unknown }; + if (body === null || typeof body !== "object" || body.layout === undefined) + return malformed(); + return { ok: true, layout: body.layout as TileLayoutData }; + } catch (err) { + return { + ok: false, + notFound: false, + error: err instanceof Error ? err.message : "Tile-layout request failed", + }; + } + }, + + async deleteLayout(mode): Promise<TileLayoutHttpResult> { + try { + const res = await fetchImpl(route(httpBase, mode), { method: "DELETE" }); + if (res.status === 404) { + return { ok: false, notFound: true, error: "Tile-layout routes are not served" }; + } + if (!res.ok) return { ok: false, notFound: false, error: await errorOf(res) }; + const body = (await res.json()) as { layout?: unknown }; + if (body === null || typeof body !== "object" || body.layout === undefined) + return malformed(); + return { ok: true, layout: body.layout as TileLayoutData }; + } catch (err) { + return { + ok: false, + notFound: false, + error: err instanceof Error ? err.message : "Tile-layout request failed", + }; + } + }, + }; +} diff --git a/src/features/workspaces/index.ts b/src/features/workspaces/index.ts index a921617..1a60bb7 100644 --- a/src/features/workspaces/index.ts +++ b/src/features/workspaces/index.ts @@ -1,5 +1,6 @@ export type { WorkspaceHttp, WorkspaceResult } from "./adapter/http"; export { createWorkspaceHttp } from "./adapter/http"; +export { createTileLayoutHttp, type TileLayoutHttp } from "./adapter/tile-layout-http"; export type { DragEmit, DragEvent as DragControllerEvent, @@ -42,8 +43,8 @@ export { export { applyStarred, pageTitle, relativeTime, sortWorkspaces } from "./logic/view-model"; export type { WorkspaceStore } from "./store.svelte"; export { createWorkspaceStore } from "./store.svelte"; -export type { TileLayoutStore } from "./tile-layout-store.svelte"; -export { createTileLayoutStore, TILE_LAYOUT_STORAGE_KEY } from "./tile-layout-store.svelte"; +export type { TileLayoutProviderState, TileLayoutStore } from "./tile-layout-store.svelte"; +export { createTileLayoutStore } from "./tile-layout-store.svelte"; export { default as WorkspacesHome } from "./ui/WorkspacesHome.svelte"; /** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ diff --git a/src/features/workspaces/tile-layout-store.svelte.ts b/src/features/workspaces/tile-layout-store.svelte.ts index 2ca66a4..a910c73 100644 --- a/src/features/workspaces/tile-layout-store.svelte.ts +++ b/src/features/workspaces/tile-layout-store.svelte.ts @@ -1,4 +1,8 @@ -import type { LocalStore } from "../../adapters/local-storage"; +import type { + TileLayoutData as ContractTileLayoutData, + TileGeometryMode, +} from "@dispatch/tile-layout-contract"; +import type { TileLayoutHttp } from "./adapter/tile-layout-http"; import { type Cell, type GridGeometry, @@ -13,27 +17,63 @@ import { } from "./logic/tile-layout"; /** - * The localStorage key for the home tile layout (spans + optional pinned - * anchors in the universal 4-row frame — v2 documents; v1 loads migrate). + * Tile-layout store — the synced arrangement of the home grid, ONE shared + * document PER device orientation (`wide` = landscape/desktop, `tall` = + * portrait/mobile), persisted through the INJECTED `TileLayoutHttp` edge + * (the `home-tile-layout` plugin's well-known routes). Mirrors the layout + * feature's store semantics: + * + * - **Orientation selection** — `setMode(mode)` (the home page's effect on + * the resolved grid geometry) is the boot + orientation-change trigger: + * an orientation CHANGE resets to the local empty default and probes that + * orientation's document fresh. Same-mode calls are a no-op. The store + * probes its INITIAL mode on creation (the boot probe). + * - **Server-backed** — a successful probe applies the server document; + * edits apply locally IMMEDIATELY, then PUT the whole document (decision 7 + * — no patch protocol) lightly debounced; a failed PUT on a present + * backend rolls back to the last server document with a quiet console + * warning (never an error UI). + * - **WS push** — `onServerTileLayout(mode, data)` applies a `tile-layout` + * broadcast, ignoring the OTHER orientation (the server fans out to all + * clients; the client filters by mode). + * - **Quiet degrade** — any probe/write failure (backend unreachable, routes + * not served) keeps the local state with EPHEMERAL edits (in-memory only — + * the retired localStorage write path is gone); logged once, no error UI. + * - **Sync gating** — `sync(ids)` no-ops while the first probe for an + * orientation is in flight (`providerState === "unknown"`): merging the + * live workspace list into an unprobed empty document would PUT a wiped + * arrangement. The caller's effect re-runs when the state settles. + * + * No ambient state: one instance per home page, created by the page entry + * (or injected in tests). */ -export const TILE_LAYOUT_STORAGE_KEY = "dispatch.workspaces.tiles"; -/** - * Tile-layout store — a thin reactive wrapper over the persisted tile layout - * document, persisted through the INJECTED `LocalStore` adapter (the repo's - * local-storage effect). All mutations go through the pure `logic/tile-layout` - * ops and write through to persistence. No ambient state: one instance per - * home page, created by the page entry (or injected in tests). - */ +/** Whether the backend is serving the active orientation's document. */ +export type TileLayoutProviderState = "unknown" | "present" | "absent"; + export interface TileLayoutStore { - /** The persisted entries, order-sorted (raw spans + universal-frame anchors). */ + /** The persisted entries of the ACTIVE orientation, order-sorted. */ readonly entries: readonly TileLayoutEntry[]; + /** The active orientation (the home page's resolved grid geometry mode). */ + readonly mode: TileGeometryMode; + /** The backend's serving state for the ACTIVE orientation. */ + readonly providerState: TileLayoutProviderState; + /** + * Select the active orientation and probe its document. Same-mode calls + * are a no-op (the boot probe fires on creation for the initial mode); + * an orientation CHANGE resets to the local default + probes fresh. + */ + setMode(mode: TileGeometryMode): void; + /** Re-probe the ACTIVE orientation's document (reconnect catch-up). */ + probe(): Promise<void>; + /** Server `tile-layout` broadcast. Other orientations are ignored. */ + onServerTileLayout(mode: TileGeometryMode, data: ContractTileLayoutData): void; /** * Merge the live workspace ids into the layout: workspaces unknown to the * layout append (unpinned — they flow to the first free cells), entries for - * deleted workspaces drop (their cells become gaps). A no-op (no write) when - * the merge changes nothing — so an effect that tracks both the workspace - * list and this store settles instead of looping. + * deleted workspaces drop (their cells become gaps). A no-op (no write) + * when the merge changes nothing — so an effect that tracks both the + * workspace list and this store settles instead of looping. */ sync(workspaceIds: readonly string[]): void; /** Resize a tile (persisted spans; render-time clamping is separate). */ @@ -53,23 +93,167 @@ export interface TileLayoutStore { sortByRecentActivity(lastActivityById: ReadonlyMap<string, number>): void; } -export function createTileLayoutStore(persistence: LocalStore<TileLayoutData>): TileLayoutStore { - // Load once at creation; a corrupt/foreign document sanitizes to empty. - let data = $state<TileLayoutData>(sanitizeLayout(persistence.load())); +export interface TileLayoutStoreOptions { + /** The injected HTTP edge (the `home-tile-layout` plugin's routes). */ + readonly http: TileLayoutHttp; + /** 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_DEBOUNCE_MS = 300; + +export function createTileLayoutStore(opts: TileLayoutStoreOptions): TileLayoutStore { + const http = opts.http; + const debounceMs = opts.debounceMs ?? DEFAULT_DEBOUNCE_MS; + const warn = opts.warn ?? ((message: string) => console.warn(message)); + + let mode = $state<TileGeometryMode>("wide"); + // The ACTIVE orientation's document — the server's copy once a probe has + // landed (sanitized defensively; the backend validates strictly), the + // empty default until then. + let data = $state<TileLayoutData>(sanitizeLayout(null)); + let providerState = $state<TileLayoutProviderState>("unknown"); + + /** The last document known to come from the server (GET / WS push / PUT echo) — the rollback target. */ + let lastServerLayout: TileLayoutData | null = null; + /** The pending edit-PUT timer (the "lightly debounced" write). */ + let putTimer: ReturnType<typeof setTimeout> | null = null; + /** Logged-once guard for the degrade log (reset when the backend returns). */ + let loggedAbsent = false; + + function noteAbsent(reason: string): void { + providerState = "absent"; + if (!loggedAbsent) { + loggedAbsent = true; + warn( + `tile-layout: backend not serving the layout (${reason}) — rendering the local default; edits are ephemeral`, + ); + } + } + + function notePresent(): void { + providerState = "present"; + loggedAbsent = false; + } + + function applyServerLayout(next: TileLayoutData): void { + lastServerLayout = next; + data = next; + } + + function clearPutTimer(): void { + if (putTimer !== null) { + clearTimeout(putTimer); + putTimer = null; + } + } + + async function flushPut(): Promise<void> { + putTimer = null; + // Read the CURRENT document at fire time: a newer edit supersedes the + // debounced snapshot (one in-flight write per window, last wins). + const result = await http.putLayout(mode, { version: 2, tiles: data.tiles }); + if (result.ok) { + notePresent(); + // The server's validated echo is the new rollback target. + lastServerLayout = sanitizeLayout(result.layout); + return; + } + if (providerState !== "present") { + // The backend is gone (or never was): keep the local edit (ephemeral), + // no rollback — there is no server document to roll back TO. + noteAbsent(result.error); + return; + } + // A failed write on a PRESENT backend: roll back to the last server + // document, quiet console warning (never an error UI). + warn( + `tile-layout: persisting the layout failed (${result.error}) — rolled back to the last server document`, + ); + if (lastServerLayout !== null) data = lastServerLayout; + } + + async function runProbe(): Promise<void> { + // Capture the mode AT START: a setMode may happen while the fetch is in + // flight — only the CURRENT mode's result may land (the reset in + // setMode already staged the fresh default; a stale response must never + // overwrite it). + const probed = mode; + const result = await http.getLayout(mode); + if (probed !== mode) return; + if (result.ok) { + notePresent(); + applyServerLayout(sanitizeLayout(result.layout)); + } else { + // A real failure (5xx, 404 = routes not served, malformed, network) + // degrades quietly — the local default keeps rendering. + noteAbsent(result.error); + } + } function commit(tiles: readonly TileLayoutEntry[]): void { // A pure op that changed nothing (e.g. a no-op place) doesn't write. if (sameEntries(tiles, data.tiles)) return; data = { version: 2, tiles }; - persistence.save(data); + if (providerState === "unknown") return; // first probe still in flight + if (providerState === "absent") return; // ephemeral — no write, no warning + clearPutTimer(); + putTimer = setTimeout(() => { + void flushPut().catch(() => { + // Network-grade failure: roll back to the last server document, quietly. + warn("tile-layout: persisting the layout failed — rolled back to the last server document"); + if (lastServerLayout !== null) data = lastServerLayout; + }); + }, debounceMs); } + // Boot probe for the initial mode — the home page's setMode effect either + // confirms it (no-op) or switches it (fresh probe). + void runProbe(); + return { get entries(): readonly TileLayoutEntry[] { return data.tiles; }, + get mode(): TileGeometryMode { + return mode; + }, + get providerState(): TileLayoutProviderState { + return providerState; + }, + + setMode(next) { + if (next === mode) return; + mode = next; + // The document belongs to the OLD orientation now: reset to the local + // default while the probe resolves (never flash the other + // orientation's arrangement), and drop any queued edit. + clearPutTimer(); + lastServerLayout = null; + data = sanitizeLayout(null); + providerState = "unknown"; + void runProbe(); + }, + + async probe() { + await runProbe(); + }, + + onServerTileLayout(pushMode, next) { + if (pushMode !== mode) return; // clients filter by orientation + notePresent(); + clearPutTimer(); // the server's copy supersedes any queued edit + applyServerLayout(sanitizeLayout(next)); + }, sync(workspaceIds): void { + // Never merge into an UNPROBED empty document: before the first probe + // resolves, "empty" is our placeholder, not a real arrangement — and + // merging live ids into it could PUT a wiped layout. The caller's + // effect re-runs when the state settles (it tracks providerState). + if (providerState === "unknown") return; const merged = mergeLayout(data, workspaceIds); if (!sameEntries(merged, data.tiles)) commit(merged); }, diff --git a/src/features/workspaces/tile-layout-store.test.ts b/src/features/workspaces/tile-layout-store.test.ts index be6a14e..15f7a74 100644 --- a/src/features/workspaces/tile-layout-store.test.ts +++ b/src/features/workspaces/tile-layout-store.test.ts @@ -1,176 +1,301 @@ -import { describe, expect, it } from "vitest"; -import type { LocalStore } from "../../adapters/local-storage"; -import { geometryFor, type TileLayoutData, type TileLayoutEntry } from "./logic/tile-layout"; +import type { TileGeometryMode, TileLayoutData } from "@dispatch/tile-layout-contract"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { TileLayoutHttp, TileLayoutHttpResult } from "./adapter/tile-layout-http"; +import { geometryFor, type TileLayoutEntry } from "./logic/tile-layout"; import { createTileLayoutStore } from "./tile-layout-store.svelte"; const WIDE = geometryFor(1920, 1080); -/** An in-memory `LocalStore` (the injected outermost edge — no mocks of our own modules). */ -function memoryStore(initial: TileLayoutData | null = null): LocalStore<TileLayoutData> & { - saved: TileLayoutData[]; +function tile(id: string, order: number, colSpan = 1, rowSpan = 1): TileLayoutEntry { + return { id, order, colSpan, rowSpan }; +} + +function doc(...tiles: TileLayoutEntry[]): TileLayoutData { + return { version: 2, tiles }; +} + +/** A controllable fake HTTP edge: per-mode documents + a call log (no mocks of our modules). */ +function fakeHttp( + initial: Partial<Record<TileGeometryMode, TileLayoutData>> = {}, +): TileLayoutHttp & { + docs: Map<TileGeometryMode, TileLayoutData>; + puts: Array<{ mode: TileGeometryMode; layout: TileLayoutData }>; + failNext: { get: string | null; put: string | null }; } { - let value = initial; - const saved: TileLayoutData[] = []; + const docs = new Map<TileGeometryMode, TileLayoutData>( + Object.entries(initial) as Array<[TileGeometryMode, TileLayoutData]>, + ); + const puts: Array<{ mode: TileGeometryMode; layout: TileLayoutData }> = []; + const failNext = { get: null as string | null, put: null as string | null }; + const ok = (layout: TileLayoutData): TileLayoutHttpResult => ({ ok: true, layout }); return { - saved, - load: () => value, - save(next: TileLayoutData) { - saved.push(next); - value = next; + docs, + puts, + failNext, + async getLayout(mode) { + if (failNext.get !== null) { + const error = failNext.get; + failNext.get = null; + return { ok: false, notFound: false, error }; + } + return ok(docs.get(mode) ?? doc()); }, - clear() { - value = null; + async putLayout(mode, layout) { + if (failNext.put !== null) { + const error = failNext.put; + failNext.put = null; + return { ok: false, notFound: false, error }; + } + puts.push({ mode, layout }); + docs.set(mode, layout); + return ok(layout); + }, + async deleteLayout(mode) { + docs.set(mode, doc()); + return ok(docs.get(mode) as TileLayoutData); }, }; } -function tile(id: string, order: number, colSpan = 1, rowSpan = 1): TileLayoutEntry { - return { id, order, colSpan, rowSpan }; +/** Flush the boot probe + debounced timers (microtasks first, then the clock). */ +async function settle(): Promise<void> { + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(300); + await Promise.resolve(); +} + +async function flushOnly(): Promise<void> { + await vi.advanceTimersByTimeAsync(0); + await Promise.resolve(); } describe("createTileLayoutStore", () => { - it("loads the persisted document at creation (v1 migrates to v2 in memory)", () => { - const persistence = memoryStore({ version: 1, tiles: [tile("a", 0, 2, 2)] }); - const store = createTileLayoutStore(persistence); - expect(store.entries).toEqual([tile("a", 0, 2, 2)]); + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); }); - it("loads a v2 document with anchors intact", () => { - const persistence = memoryStore({ - version: 2, - tiles: [{ ...tile("a", 0), col: 2, row: 3 }], - }); - const store = createTileLayoutStore(persistence); - expect(store.entries).toEqual([{ ...tile("a", 0), col: 2, row: 3 }]); + it("the boot probe applies the server document for the initial mode", async () => { + const http = fakeHttp({ wide: doc(tile("a", 0, 2, 2)) }); + const store = createTileLayoutStore({ http }); + await flushOnly(); + expect(store.entries).toEqual([tile("a", 0, 2, 2)]); + expect(store.providerState).toBe("present"); + expect(store.mode).toBe("wide"); }); - it("sanitizes a corrupt persisted document to empty", () => { - const persistence = memoryStore(null); - // The adapter's load is untyped at the boundary — simulate garbage. - const store = createTileLayoutStore({ - load: () => ({ junk: true }) as unknown as TileLayoutData, - save: () => {}, - clear: () => {}, - }); + it("sanitizes the server document defensively (a junk shape degrades to empty)", async () => { + const http = fakeHttp(); + http.docs.set("wide", { junk: true } as unknown as TileLayoutData); + const store = createTileLayoutStore({ http }); + await flushOnly(); expect(store.entries).toEqual([]); - expect(persistence.saved).toEqual([]); }); - it("sync appends unknown workspaces and drops deleted ones, persisting once", () => { - const persistence = memoryStore({ version: 1, tiles: [tile("a", 0), tile("gone", 1)] }); - const store = createTileLayoutStore(persistence); + it("a probe failure degrades to absent (ephemeral edits, no PUT)", async () => { + const warn = vi.fn(); + const http = fakeHttp(); + http.failNext.get = "network down"; + const store = createTileLayoutStore({ http, warn }); + await flushOnly(); + expect(store.providerState).toBe("absent"); - store.sync(["a", "b"]); + store.resize("a", 3, 3); // applied locally… + store.sync(["a"]); + await settle(); + expect(store.entries.map((t) => t.id)).toEqual(["a"]); + expect(http.puts).toHaveLength(0); // …but never written + expect(warn).toHaveBeenCalledTimes(1); // logged ONCE, no error UI + }); - expect(store.entries.map((t) => t.id)).toEqual(["a", "b"]); - expect(persistence.saved).toHaveLength(1); - // The max order counts KEPT entries (the dropped one doesn't reserve a slot). - expect(persistence.saved[0]).toEqual({ - version: 2, - tiles: [tile("a", 0), { id: "b", colSpan: 1, rowSpan: 1, order: 1 }], + it("setMode switches orientation: reset + fresh probe (documents are independent)", async () => { + const http = fakeHttp({ + wide: doc(tile("desktop", 0)), + tall: doc(tile("mobile", 0)), }); + const store = createTileLayoutStore({ http }); + await flushOnly(); + expect(store.entries.map((t) => t.id)).toEqual(["desktop"]); + + store.setMode("tall"); + await flushOnly(); + expect(store.mode).toBe("tall"); + expect(store.entries.map((t) => t.id)).toEqual(["mobile"]); + + store.setMode("tall"); // same-mode call: a no-op + store.setMode("wide"); + await flushOnly(); + expect(store.entries.map((t) => t.id)).toEqual(["desktop"]); }); - it("sync keeps pinned anchors (a deleted tile's cells become gaps)", () => { - const persistence = memoryStore({ - version: 2, - tiles: [ - { ...tile("a", 0), col: 1, row: 1 }, - { ...tile("gone", 1), col: 1, row: 2 }, - ], - }); - const store = createTileLayoutStore(persistence); + it("an in-flight probe for a stale mode never lands", async () => { + const http = fakeHttp({ wide: doc(tile("desktop", 0)) }); + // A holder object defeats TS's closure narrowing (a bare `let` read after + // the callback-assigned write narrows to `null` and the call sites to + // `never`). + const gate: { release: ((value: TileLayoutHttpResult) => void) | null } = { release: null }; + const slow = http.getLayout.bind(http); + http.getLayout = (mode) => + mode === "tall" + ? new Promise<TileLayoutHttpResult>((resolve) => { + gate.release = resolve; + }) + : slow(mode); + const store = createTileLayoutStore({ http }); + await flushOnly(); + + store.setMode("tall"); // probe in flight… + store.setMode("wide"); // …superseded before it resolves + gate.release?.({ ok: true, layout: doc(tile("mobile", 0)) }); + await flushOnly(); + + // The stale tall response must NOT overwrite the active wide document. + expect(store.mode).toBe("wide"); + expect(store.entries.map((t) => t.id)).toEqual(["desktop"]); + }); - store.sync(["a"]); + it("a WS push applies for the ACTIVE mode and is IGNORED for the other", async () => { + const http = fakeHttp({ wide: doc(tile("a", 0)) }); + const store = createTileLayoutStore({ http }); + await flushOnly(); - expect(store.entries).toEqual([{ ...tile("a", 0), col: 1, row: 1 }]); + store.onServerTileLayout("tall", doc(tile("mobile-push", 0))); + expect(store.entries.map((t) => t.id)).toEqual(["a"]); // filtered + + store.onServerTileLayout("wide", doc(tile("push", 0))); + expect(store.entries.map((t) => t.id)).toEqual(["push"]); // applied }); - it("sync is a no-op (no write) when the merge changes nothing", () => { - const persistence = memoryStore({ version: 1, tiles: [tile("a", 0)] }); - const store = createTileLayoutStore(persistence); + it("edits apply locally IMMEDIATELY, then PUT the whole document (debounced)", async () => { + const http = fakeHttp({ wide: doc(tile("a", 0)) }); + const store = createTileLayoutStore({ http, debounceMs: 300 }); + await flushOnly(); - store.sync(["a"]); - store.sync(["a"]); + store.resize("a", 4, 2); + expect(store.entries).toEqual([tile("a", 0, 4, 2)]); // local, no wait + expect(http.puts).toHaveLength(0); - expect(persistence.saved).toHaveLength(0); + await vi.advanceTimersByTimeAsync(300); + expect(http.puts).toEqual([{ mode: "wide", layout: doc(tile("a", 0, 4, 2)) }]); }); - it("resize persists the new spans without clamping", () => { - const persistence = memoryStore({ version: 1, tiles: [tile("a", 0)] }); - const store = createTileLayoutStore(persistence); + it("edits ride the ACTIVE mode's document (the tall PUT names tall)", async () => { + const http = fakeHttp({ tall: doc(tile("m", 0)) }); + const store = createTileLayoutStore({ http }); + store.setMode("tall"); + await flushOnly(); + store.resize("m", 2, 2); + await settle(); + expect(http.puts).toEqual([{ mode: "tall", layout: doc(tile("m", 0, 2, 2)) }]); + }); + + it("a failed PUT on a present backend rolls back to the last server document", async () => { + const warn = vi.fn(); + const http = fakeHttp({ wide: doc(tile("a", 0)) }); + const store = createTileLayoutStore({ http, warn, debounceMs: 300 }); + await flushOnly(); + + http.failNext.put = "500 gone"; store.resize("a", 4, 2); + await settle(); - expect(store.entries).toEqual([tile("a", 0, 4, 2)]); - expect(persistence.saved.at(-1)?.tiles).toEqual([tile("a", 0, 4, 2)]); + expect(store.entries).toEqual([tile("a", 0)]); // rolled back + expect(warn).toHaveBeenCalledTimes(1); }); - it("placeAtCell commits the displacement and pins EVERY tile", () => { - const persistence = memoryStore({ - version: 1, - tiles: [tile("a", 0), tile("b", 1)], - }); - const store = createTileLayoutStore(persistence); + it("sync merges the live workspace list (gated while the first probe is in flight)", async () => { + const http = fakeHttp({ wide: doc(tile("a", 0)) }); + const store = createTileLayoutStore({ http }); + // NO flush — the boot probe is still in flight (providerState unknown). + + store.sync(["a", "b"]); // would merge into the empty placeholder… + expect(store.providerState).toBe("unknown"); + expect(store.entries).toEqual([]); // …so it is a no-op until the probe settles + + await flushOnly(); + store.sync(["a", "b"]); + expect(store.entries.map((t) => t.id)).toEqual(["a", "b"]); + }); + + it("sync appends unknown ids, drops deleted ones, and persists once", async () => { + const http = fakeHttp({ wide: doc(tile("a", 0), tile("gone", 1)) }); + const store = createTileLayoutStore({ http }); + await flushOnly(); + + store.sync(["a", "b"]); + await settle(); + + expect(store.entries.map((t) => t.id)).toEqual(["a", "b"]); + // The max order counts KEPT entries (the dropped one doesn't reserve a slot). + expect(http.puts.at(-1)?.layout).toEqual( + doc(tile("a", 0), { id: "b", colSpan: 1, rowSpan: 1, order: 1 }), + ); + }); + + it("sync is a no-op (no write) when the merge changes nothing", async () => { + const http = fakeHttp({ wide: doc(tile("a", 0)) }); + const store = createTileLayoutStore({ http }); + await flushOnly(); + + store.sync(["a"]); + store.sync(["a"]); + await settle(); + + expect(http.puts).toHaveLength(0); + }); + + it("placeAtCell commits the displacement and pins EVERY tile (persisted to the server)", async () => { + const http = fakeHttp({ wide: doc(tile("a", 0), tile("b", 1)) }); + const store = createTileLayoutStore({ http }); + await flushOnly(); // a@(1,1), b@(1,2) → drag a to (2,1): a pins there, b keeps its cell. store.placeAtCell("a", { col: 2, row: 1 }, WIDE); + await settle(); const entries = store.entries; expect(entries.find((t) => t.id === "a")).toMatchObject({ col: 2, row: 1 }); expect(entries.find((t) => t.id === "b")).toMatchObject({ col: 1, row: 2 }); - // Written as v2. - expect(persistence.saved.at(-1)?.version).toBe(2); + expect(http.puts.at(-1)?.layout.version).toBe(2); }); - it("placeAtCell with an unknown id is a no-op (no write)", () => { - const persistence = memoryStore({ version: 1, tiles: [tile("a", 0)] }); - const store = createTileLayoutStore(persistence); + it("placeAtCell with an unknown id is a no-op (no write)", async () => { + const http = fakeHttp({ wide: doc(tile("a", 0)) }); + const store = createTileLayoutStore({ http }); + await flushOnly(); store.placeAtCell("nope", { col: 1, row: 1 }, WIDE); + await settle(); - expect(persistence.saved).toHaveLength(0); + expect(http.puts).toHaveLength(0); expect(store.entries).toEqual([tile("a", 0)]); }); - it("placeFirstEmpty PINS the new id at the first empty slot", () => { - const persistence = memoryStore({ - version: 1, - tiles: [tile("a", 0, 1, 2), tile("b", 1)], - }); - const store = createTileLayoutStore(persistence); + it("placeFirstEmpty PINS the new id at the first empty slot (idempotent on re-call)", async () => { + const http = fakeHttp({ wide: doc(tile("a", 0, 1, 2), tile("b", 1)) }); + const store = createTileLayoutStore({ http }); + await flushOnly(); store.placeFirstEmpty("new", WIDE); + await settle(); // a packs rows 1-2, b row 3 → the first empty slot is (col1,row4) → pinned. expect(store.entries.find((t) => t.id === "new")).toMatchObject({ col: 1, row: 4 }); - expect(persistence.saved.at(-1)?.tiles.map((t) => t.id)).toEqual(["a", "b", "new"]); - }); - - it("placeFirstEmpty is idempotent for an id sync already appended (the refresh race)", () => { - const persistence = memoryStore({ version: 1, tiles: [tile("a", 0)] }); - const store = createTileLayoutStore(persistence); - store.sync(["a", "x"]); // the refresh appended the new workspace (unpinned) - - store.placeFirstEmpty("x", WIDE); - const pinned = store.entries; - store.placeFirstEmpty("x", WIDE); // the create flow's re-call must be stable + expect(http.puts.at(-1)?.layout.tiles.map((t) => t.id)).toEqual(["a", "b", "new"]); - expect(store.entries).toEqual(pinned); - expect(store.entries.filter((t) => t.id === "x")).toHaveLength(1); - // x is PINNED at the first empty slot: a@(1,1) → (1,2). - expect(store.entries.find((t) => t.id === "x")).toMatchObject({ col: 1, row: 2 }); + store.placeFirstEmpty("new", WIDE); // the create flow's re-call must be stable + expect(store.entries.find((t) => t.id === "new")).toMatchObject({ col: 1, row: 4 }); }); - it("sortByRecentActivity re-applies recent-activity order, CLEARs pins, persists", () => { - const persistence = memoryStore({ - version: 2, - tiles: [ - { ...tile("a", 0), col: 1, row: 1 }, - { ...tile("b", 1), col: 2, row: 3 }, - ], + it("sortByRecentActivity re-applies recent-activity order and CLEARs pins", async () => { + const http = fakeHttp({ + wide: doc({ ...tile("a", 0), col: 1, row: 1 }, { ...tile("b", 1), col: 2, row: 3 }), }); - const store = createTileLayoutStore(persistence); + const store = createTileLayoutStore({ http }); + await flushOnly(); store.sortByRecentActivity( new Map([ @@ -182,4 +307,27 @@ describe("createTileLayoutStore", () => { expect(store.entries.map((t) => t.id)).toEqual(["b", "a"]); expect(store.entries.every((t) => t.col === undefined && t.row === undefined)).toBe(true); }); + + it("a WS push supersedes a queued edit (the server's copy wins)", async () => { + const http = fakeHttp({ wide: doc(tile("a", 0)) }); + const store = createTileLayoutStore({ http, debounceMs: 300 }); + await flushOnly(); + + store.resize("a", 4, 2); // edit queued… + store.onServerTileLayout("wide", doc(tile("server-wins", 0))); // …server lands first + await settle(); + + expect(store.entries.map((t) => t.id)).toEqual(["server-wins"]); + expect(http.puts).toHaveLength(0); // the queued edit was dropped, not PUT + }); + + it("probe() re-reads the active orientation (the reconnect catch-up)", async () => { + const http = fakeHttp({ wide: doc() }); + const store = createTileLayoutStore({ http }); + await flushOnly(); + + http.docs.set("wide", doc(tile("fresh", 0))); + await store.probe(); + expect(store.entries.map((t) => t.id)).toEqual(["fresh"]); + }); }); diff --git a/src/features/workspaces/ui/WorkspacesHome.svelte b/src/features/workspaces/ui/WorkspacesHome.svelte index 7c0db85..798d1bb 100644 --- a/src/features/workspaces/ui/WorkspacesHome.svelte +++ b/src/features/workspaces/ui/WorkspacesHome.svelte @@ -7,7 +7,6 @@ type PluginsStore, type WorkspaceLayoutPort, } from "../../plugins"; - import { createLocalStore } from "../../../adapters/local-storage"; import type { WorkspaceStore } from "../store.svelte"; import { workspacePath } from "../logic/route"; import { @@ -17,11 +16,7 @@ type GridGeometry, type Span, } from "../logic/tile-layout"; - import { - createTileLayoutStore, - TILE_LAYOUT_STORAGE_KEY, - type TileLayoutStore, - } from "../tile-layout-store.svelte"; + import { type TileLayoutStore } from "../tile-layout-store.svelte"; import HomeHeader from "./HomeHeader.svelte"; import HomeTileGrid from "./HomeTileGrid.svelte"; import TileActionBar from "./TileActionBar.svelte"; @@ -59,33 +54,30 @@ */ layout?: WorkspaceLayoutPort; /** - * The persisted tile layout store. Absent → created here over the real - * localStorage adapter (the page is the effect edge); tests inject one. + * The synced tile layout store (ONE shared document per device + * orientation, persisted by the backend's home-tile-layout plugin). + * Created by the page entry (App.svelte — the composition root that owns + * the httpBase); tests inject one. */ - tiles?: TileLayoutStore; + tiles: TileLayoutStore; } = $props(); onMount(() => { void store.refresh(); }); - // ── The persisted tile layout (order + spans only — never positions). ───── - // The store is created once per page from the (inert) override prop — the - // repo's `untrack` pattern for initial-value captures (cf. App.svelte). - const tiles: TileLayoutStore = - untrack(() => tilesOverride) ?? - createTileLayoutStore( - createLocalStore(TILE_LAYOUT_STORAGE_KEY, { - storage: typeof localStorage !== "undefined" ? localStorage : undefined, - }), - ); + // The store arrives fully wired (the page entry created it over the real + // HTTP edge). The `untrack` capture (the repo's initial-value pattern, cf. + // App.svelte) dealiases the prop once — the component instance is the + // store's single owner from here on. + const tiles: TileLayoutStore = untrack(() => tilesOverride); // Keep the layout merged with the live workspace list: unknown workspaces - // (deep-link auto-creates) append at the end; deleted ones drop. The no-op - // guard inside sync keeps this effect from looping. Skipped until the first - // non-empty list arrives — `sync([])` before the initial `refresh()` resolves - // would WIPE the persisted order (the backend always serves "default", so an - // empty list only ever means "not loaded yet"). + // (deep-link auto-creates) append at the end; deleted ones drop. The + // no-op guards inside sync keep this effect from looping. Skipped until + // the first non-empty list arrives — `sync([])` before the initial + // `refresh()` resolves would WIPE the persisted order (the backend always + // serves "default", so an empty list only ever means "not loaded yet"). $effect(() => { const ids = store.list.map((ws) => ws.id); if (ids.length === 0) return; @@ -111,6 +103,15 @@ return () => observer.disconnect(); }); + // Lock the store's active orientation to the resolved grid geometry: the + // landscape/portrait split IS the device-type split — every landscape + // device shares the `wide` document, every portrait device the `tall` one. + // Same-mode calls are a no-op inside the store; an orientation change + // resets + re-probes (never flashing the other orientation's arrangement). + $effect(() => { + tiles.setMode(geometry.mode); + }); + // The grid view: the SAME persisted data packs into either geometry — // positions re-derive here, spans clamp for the RENDER only. const placements = $derived(pack(tiles.entries, geometry)); diff --git a/src/features/workspaces/ui/WorkspacesHome.test.ts b/src/features/workspaces/ui/WorkspacesHome.test.ts index eb5b293..9e8411a 100644 --- a/src/features/workspaces/ui/WorkspacesHome.test.ts +++ b/src/features/workspaces/ui/WorkspacesHome.test.ts @@ -1,10 +1,10 @@ +import type { TileGeometryMode, TileLayoutData } from "@dispatch/tile-layout-contract"; import type { Workspace, WorkspaceEntry } from "@dispatch/wire"; import { render, screen, waitFor } from "@testing-library/svelte"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; -import type { LocalStore } from "../../../adapters/local-storage"; import type { WorkspaceHttp, WorkspaceResult } from "../adapter/http"; -import type { TileLayoutData, TileLayoutEntry } from "../logic/tile-layout"; +import type { TileLayoutHttp } from "../adapter/tile-layout-http"; import { createTileLayoutStore } from "../tile-layout-store.svelte"; import WorkspacesHome from "./WorkspacesHome.svelte"; @@ -46,27 +46,37 @@ function fakeHttp(list: WorkspaceEntry[] = [ws()]): WorkspaceHttp { } as unknown as WorkspaceHttp; } -function memoryLayout(initial: TileLayoutData | null = null): LocalStore<TileLayoutData> & { - data: TileLayoutData | null; -} { - const store = { - data: initial, - load: () => store.data, - save: (value: TileLayoutData) => { - store.data = value; +/** + * A fake tile-layout HTTP edge serving ONE controllable document per mode + * (no mocks of our modules). `docs` is the live store — assertions read it + * after `waitFor` (the boot probe + debounced sync writes land there). + */ +function memoryLayout( + initial: TileLayoutData | null = null, +): TileLayoutHttp & { docs: Map<TileGeometryMode, TileLayoutData> } { + const docs = new Map<TileGeometryMode, TileLayoutData>(); + if (initial !== null) docs.set("wide", initial); + return { + docs, + async getLayout(mode) { + return { ok: true, layout: docs.get(mode) ?? { version: 2, tiles: [] } }; + }, + async putLayout(mode, layout) { + docs.set(mode, layout); + return { ok: true, layout }; }, - clear: () => { - store.data = null; + async deleteLayout(mode) { + docs.set(mode, { version: 2, tiles: [] }); + return { ok: true, layout: docs.get(mode) as TileLayoutData }; }, }; - return store; } describe("WorkspacesHome (tile canvas)", () => { it("renders one tile per workspace, in the persisted tile order (not the list order)", async () => { const list = [ws({ id: "a", title: "A" }), ws({ id: "b", title: "B" })]; const layout = memoryLayout({ - version: 1, + version: 2, tiles: [ { id: "b", order: 0, colSpan: 1, rowSpan: 1 }, { id: "a", order: 1, colSpan: 1, rowSpan: 1 }, @@ -77,7 +87,7 @@ describe("WorkspacesHome (tile canvas)", () => { store: (await import("../store.svelte")).createWorkspaceStore(fakeHttp(list)), onNavigate: vi.fn(), computers: [], - tiles: createTileLayoutStore(layout), + tiles: createTileLayoutStore({ http: layout }), }, }); @@ -94,7 +104,7 @@ describe("WorkspacesHome (tile canvas)", () => { store: (await import("../store.svelte")).createWorkspaceStore(fakeHttp()), onNavigate, computers: [], - tiles: createTileLayoutStore(memoryLayout()), + tiles: createTileLayoutStore({ http: memoryLayout() }), }, }); @@ -113,7 +123,7 @@ describe("WorkspacesHome (tile canvas)", () => { store, onNavigate, computers: [], - tiles: createTileLayoutStore(memoryLayout()), + tiles: createTileLayoutStore({ http: memoryLayout() }), }, }); @@ -139,7 +149,7 @@ describe("WorkspacesHome (tile canvas)", () => { store, onNavigate: vi.fn(), computers: [], - tiles: createTileLayoutStore(memoryLayout()), + tiles: createTileLayoutStore({ http: memoryLayout() }), }, }); @@ -159,7 +169,7 @@ describe("WorkspacesHome (tile canvas)", () => { it("workspaces unknown to the layout append at the end (deep-link auto-create)", async () => { const list = [ws({ id: "a", title: "A" }), ws({ id: "b", title: "B" })]; const layout = memoryLayout({ - version: 1, + version: 2, tiles: [{ id: "b", order: 0, colSpan: 1, rowSpan: 1 }], }); render(WorkspacesHome, { @@ -167,7 +177,7 @@ describe("WorkspacesHome (tile canvas)", () => { store: (await import("../store.svelte")).createWorkspaceStore(fakeHttp(list)), onNavigate: vi.fn(), computers: [], - tiles: createTileLayoutStore(layout), + tiles: createTileLayoutStore({ http: layout }), }, }); @@ -176,7 +186,9 @@ describe("WorkspacesHome (tile canvas)", () => { const wrappers = document.querySelectorAll("[data-tile-id]"); expect([...wrappers].map((el) => el.getAttribute("data-tile-id"))).toEqual(["b", "a"]); // And the sync persisted the appended entry. - const entries = (layout.data?.tiles ?? []).map((tile: TileLayoutEntry) => tile.id); - expect(entries).toEqual(["b", "a"]); + await waitFor(() => { + const tiles = layout.docs.get("wide")?.tiles ?? []; + expect(tiles.map((tile) => tile.id)).toEqual(["b", "a"]); + }); }); }); |
