diff options
Diffstat (limited to 'src/features/layout')
34 files changed, 3606 insertions, 0 deletions
diff --git a/src/features/layout/adapter/abi-port.test.ts b/src/features/layout/adapter/abi-port.test.ts new file mode 100644 index 0000000..24f9c94 --- /dev/null +++ b/src/features/layout/adapter/abi-port.test.ts @@ -0,0 +1,97 @@ +import { DEFAULT_LAYOUT, type LayoutTree } from "@dispatch/layout-contract"; +import { describe, expect, it, vi } from "vitest"; +import type { LayoutProviderState, LayoutStore } from "../store.svelte"; +import { abiTree, createLayoutAbiPort } from "./abi-port"; + +/** + * The ABI `layout` port tests (plan 04 §4.5) — the port is pure composition + * over the layout store + the ops HTTP edge, so BOTH deps are fakes (the + * outermost edges; nothing internal is mocked — asymmetric testing). + */ + +function fakeStore( + overrides: Partial<Pick<LayoutStore, "tree" | "providerState" | "activeWorkspaceId">> = {}, +): LayoutStore { + return { + get tree(): LayoutTree { + return DEFAULT_LAYOUT; + }, + get providerState(): LayoutProviderState { + return "present"; + }, + get activeWorkspaceId(): string { + return "ws-a"; + }, + probe: vi.fn(async () => {}), + onWorkspaceChanged: vi.fn(), + onServerTree: vi.fn(), + onTreeChange: vi.fn(), + resetToDefault: vi.fn(async () => null), + onExtensionsChanged: vi.fn(), + onWorkspacePluginsChanged: vi.fn(), + ...overrides, + } as LayoutStore; +} + +describe("abiTree — the getTree decision (pure)", () => { + it("a PRESENT provider exposes the tree", () => { + expect(abiTree("present", DEFAULT_LAYOUT)).toBe(DEFAULT_LAYOUT); + }); + + it("no-provider mode (absent) exposes NULL — the local fallback is not shared state", () => { + expect(abiTree("absent", DEFAULT_LAYOUT)).toBeNull(); + }); + + it("an unknown (not-yet-probed) provider exposes NULL too", () => { + expect(abiTree("unknown", DEFAULT_LAYOUT)).toBeNull(); + }); +}); + +describe("createLayoutAbiPort — applyOps → the ops route", () => { + it("posts to the ACTIVE workspace and feeds the returned tree back through the store", async () => { + const ops = [{ type: "ensurePanel", panels: "side", view: "demo" }] as const; + const edited: LayoutTree = { ...DEFAULT_LAYOUT }; + const store = fakeStore(); + const seen: string[] = []; + const port = createLayoutAbiPort({ + store, + postOps: async (workspaceId, o) => { + seen.push(workspaceId); + return { ok: true, tree: edited, applied: o.length }; + }, + }); + + const result = await port.applyOps(ops); + expect(seen).toEqual(["ws-a"]); // the ACTIVE workspace at CALL time + expect(result).toEqual({ ok: true, tree: edited, applied: 1 }); + // The successful apply echoes the tree into the store (instant local + // re-render; the WS broadcast echo is idempotent with this). + expect(store.onServerTree).toHaveBeenCalledWith("ws-a", edited); + }); + + it("a failed batch (typed no-provider error) does NOT touch the store", async () => { + const store = fakeStore({ providerState: "absent" }); + const port = createLayoutAbiPort({ + store, + postOps: async () => ({ ok: false, error: "no layout provider active" }), + }); + + const result = await port.applyOps([{ type: "removeNode", target: "side" }]); + expect(result).toEqual({ ok: false, error: "no layout provider active" }); + expect(store.onServerTree).not.toHaveBeenCalled(); + }); + + it("getTree returns the active tree while the provider is present, null when absent", () => { + const port = createLayoutAbiPort({ + store: fakeStore({ providerState: "present" }), + postOps: async () => ({ ok: false, error: "unused" }), + }); + expect(port.getTree()).toBe(DEFAULT_LAYOUT); + + const absent = createLayoutAbiPort({ + store: fakeStore({ providerState: "absent" }), + postOps: async () => ({ ok: false, error: "no layout provider active" }), + }); + expect(absent.getTree()).toBeNull(); + }); +}); diff --git a/src/features/layout/adapter/abi-port.ts b/src/features/layout/adapter/abi-port.ts new file mode 100644 index 0000000..69576c6 --- /dev/null +++ b/src/features/layout/adapter/abi-port.ts @@ -0,0 +1,60 @@ +import type { ApplyOpsResult, LayoutOp, LayoutTree } from "@dispatch/layout-contract"; +import type { LayoutProviderState, LayoutStore } from "../store.svelte"; + +/** + * The port the frontend-plugin ABI exposes as `api.layout` (plan 04 §4.5). + * The composition root builds it with {@link createLayoutAbiPort} and the + * plugin host hands it to every `activate(api)` — plugins never see the + * store or the HTTP edge, only THIS. + */ +export interface LayoutAbiPort { + /** + * Apply op-vocabulary edits to the ACTIVE workspace's tree through the + * provider (`POST /workspaces/:id/layout/ops`). Atomic; the no-provider + * 404 maps to the typed `{ ok: false, error: "no layout provider active" }`. + */ + readonly applyOps: (ops: readonly LayoutOp[]) => Promise<ApplyOpsResult>; + /** The ACTIVE workspace's tree; `null` in no-provider mode. */ + readonly getTree: () => LayoutTree | null; +} + +export interface LayoutAbiPortDeps { + /** The layout store (the active workspace's tree + provider state). */ + readonly store: LayoutStore; + /** The ops HTTP edge — bound per CALL to the then-active workspace. */ + readonly postOps: (workspaceId: string, ops: readonly LayoutOp[]) => Promise<ApplyOpsResult>; +} + +/** + * Build the ABI's `layout` port over the layout store + the ops HTTP edge + * (pure composition — no ambient state; both deps injected). A successful + * apply feeds the returned tree back through `onServerTree` so the local + * client re-renders immediately (the server's WS broadcast is the + * authoritative echo for every client, this one included — applying it twice + * is idempotent). The workspace is read at CALL time, so a workspace switch + * between calls targets the right tree; a switch mid-flight is guarded by + * `onServerTree`'s own workspace filter. + */ +export function createLayoutAbiPort(deps: LayoutAbiPortDeps): LayoutAbiPort { + return { + async applyOps(ops): Promise<ApplyOpsResult> { + const workspaceId = deps.store.activeWorkspaceId; + const result = await deps.postOps(workspaceId, ops); + if (result.ok) { + deps.store.onServerTree(workspaceId, result.tree); + } + return result; + }, + getTree(): LayoutTree | null { + // `present` = an active provider is serving the active workspace. In + // no-provider (and not-yet-probed) mode the local tree is a fallback, + // not shared state — null tells the plugin there is nothing to read. + return abiTree(deps.store.providerState, deps.store.tree); + }, + }; +} + +/** The getTree decision, pure: only a PRESENT provider's tree is exposed. */ +export function abiTree(state: LayoutProviderState, tree: LayoutTree): LayoutTree | null { + return state === "present" ? tree : null; +} diff --git a/src/features/layout/adapter/http.test.ts b/src/features/layout/adapter/http.test.ts new file mode 100644 index 0000000..30e194a --- /dev/null +++ b/src/features/layout/adapter/http.test.ts @@ -0,0 +1,182 @@ +import { DEFAULT_LAYOUT } from "@dispatch/layout-contract"; +import { describe, expect, it } from "vitest"; +import { createLayoutHttp } from "./http"; + +/** + * Layout HTTP adapter tests — the OUTERMOST edge (fetch) is the only fake: + * a scripted `fetchImpl` returning canned Responses. No internal mocks. + */ + +const LAYOUT_URL = "http://backend.test/workspaces/ws-a/layout"; + +function fetchReturning( + status: number, + body: unknown, + capture?: { url?: string; init?: RequestInit | undefined }, +): typeof fetch { + return async (input: string | URL | Request, init?: RequestInit): Promise<Response> => { + if (capture !== undefined) { + capture.url = + typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + capture.init = init; + } + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); + }; +} + +describe("createLayoutHttp — getTree", () => { + it("GETs the well-known route and unwraps { tree }", async () => { + const capture: { url?: string; init?: RequestInit } = {}; + const http = createLayoutHttp( + "http://backend.test", + fetchReturning(200, { tree: DEFAULT_LAYOUT }, capture), + ); + const result = await http.getTree("ws-a"); + expect(result).toEqual({ ok: true, tree: DEFAULT_LAYOUT }); + expect(capture.url).toBe(LAYOUT_URL); + expect(capture.init?.method).toBeUndefined(); // GET is the default + }); + + it("maps a 404 to the no-provider signal (notFound: true)", async () => { + const http = createLayoutHttp( + "http://backend.test", + fetchReturning(404, { error: "No layout provider is active" }), + ); + const result = await http.getTree("ws-a"); + expect(result).toEqual({ + ok: false, + notFound: true, + error: "No layout provider is active", + }); + }); + + it("maps a 500 to a non-notFound failure carrying the body's error", async () => { + const http = createLayoutHttp( + "http://backend.test", + fetchReturning(500, { error: "Layout storage failure" }), + ); + const result = await http.getTree("ws-a"); + expect(result).toEqual({ ok: false, notFound: false, error: "Layout storage failure" }); + }); + + it("a network failure degrades to notFound (the no-provider mode)", async () => { + const http = createLayoutHttp("http://backend.test", async () => { + throw new TypeError("Failed to fetch"); + }); + const result = await http.getTree("ws-a"); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.notFound).toBe(true); + expect(result.error).toContain("Failed to fetch"); + } + }); + + it("a malformed 200 (SPA-fallback JSON without a tree) degrades to notFound", async () => { + const http = createLayoutHttp( + "http://backend.test", + fetchReturning(200, { chunks: [], historyEpoch: 0, latestSeq: 0 }), + ); + const result = await http.getTree("ws-a"); + expect(result).toMatchObject({ ok: false, notFound: true }); + }); +}); + +describe("createLayoutHttp — putTree", () => { + it("PUTs { tree } as JSON and unwraps the echo", async () => { + const capture: { url?: string; init?: RequestInit } = {}; + const http = createLayoutHttp( + "http://backend.test", + fetchReturning(200, { tree: DEFAULT_LAYOUT }, capture), + ); + const result = await http.putTree("ws-a", DEFAULT_LAYOUT); + expect(result).toEqual({ ok: true, tree: DEFAULT_LAYOUT }); + expect(capture.init?.method).toBe("PUT"); + expect(capture.init?.body).toBe(JSON.stringify({ tree: DEFAULT_LAYOUT })); + }); + + it("maps a 400 { error } to a validation failure (not notFound)", async () => { + const http = createLayoutHttp( + "http://backend.test", + fetchReturning(400, { error: "root: split has no children" }), + ); + const result = await http.putTree("ws-a", DEFAULT_LAYOUT); + expect(result).toEqual({ + ok: false, + notFound: false, + error: "root: split has no children", + }); + }); + + it("maps a 404 to the no-provider signal", async () => { + const http = createLayoutHttp("http://backend.test", fetchReturning(404, {})); + const result = await http.putTree("ws-a", DEFAULT_LAYOUT); + expect(result).toMatchObject({ ok: false, notFound: true }); + }); +}); + +describe("createLayoutHttp — deleteTree", () => { + it("DELETEs and unwraps the fresh seed", async () => { + const capture: { url?: string; init?: RequestInit } = {}; + const http = createLayoutHttp( + "http://backend.test", + fetchReturning(200, { tree: DEFAULT_LAYOUT }, capture), + ); + const result = await http.deleteTree("ws-a"); + expect(result).toEqual({ ok: true, tree: DEFAULT_LAYOUT }); + expect(capture.init?.method).toBe("DELETE"); + }); + + it("maps a 404 to the no-provider signal", async () => { + const http = createLayoutHttp("http://backend.test", fetchReturning(404, {})); + const result = await http.deleteTree("ws-a"); + expect(result).toMatchObject({ ok: false, notFound: true }); + }); +}); + +describe("createLayoutHttp — postOps (plan 04 §4.2)", () => { + it("POSTs { ops } to the ops route and unwraps { tree, applied }", async () => { + const capture: { url?: string; init?: RequestInit } = {}; + const ops = [{ type: "ensurePanel", panels: "side", view: "demo" }] as const; + const http = createLayoutHttp( + "http://backend.test", + fetchReturning(200, { tree: DEFAULT_LAYOUT, applied: 1 }, capture), + ); + const result = await http.postOps("ws-a", ops); + expect(result).toEqual({ ok: true, tree: DEFAULT_LAYOUT, applied: 1 }); + expect(capture.url).toBe(`${LAYOUT_URL}/ops`); + expect(capture.init?.method).toBe("POST"); + expect(capture.init?.body).toBe(JSON.stringify({ ops })); + }); + + it("maps a 404 to the typed no-provider error (the ABI's documented failure)", async () => { + const http = createLayoutHttp("http://backend.test", fetchReturning(404, {})); + const result = await http.postOps("ws-a", []); + expect(result).toEqual({ ok: false, error: "no layout provider active" }); + }); + + it("maps a 400 { error, failedOp } to the atomic-reject failure", async () => { + const http = createLayoutHttp( + "http://backend.test", + fetchReturning(400, { error: "op 1: no such node", failedOp: 1 }), + ); + const result = await http.postOps("ws-a", [{ type: "removeNode", target: "x" }]); + expect(result).toEqual({ ok: false, error: "op 1: no such node", failedOp: 1 }); + }); + + it("a malformed 200 (no tree in the body) is a failure, never a silent ok", async () => { + const http = createLayoutHttp("http://backend.test", fetchReturning(200, { applied: 2 })); + const result = await http.postOps("ws-a", []); + expect(result).toMatchObject({ ok: false }); + }); + + it("a network failure degrades to a typed failure (never throws)", async () => { + const http = createLayoutHttp("http://backend.test", async () => { + throw new TypeError("fetch failed"); + }); + const result = await http.postOps("ws-a", []); + expect(result).toEqual({ ok: false, error: "fetch failed" }); + }); +}); diff --git a/src/features/layout/adapter/http.ts b/src/features/layout/adapter/http.ts new file mode 100644 index 0000000..ff15a70 --- /dev/null +++ b/src/features/layout/adapter/http.ts @@ -0,0 +1,175 @@ +import type { ApplyOpsResult, LayoutOp, LayoutTree } from "@dispatch/layout-contract"; + +/** + * Layout HTTP effects (plan 02 §2.6) — the injected edge that talks to the + * ACTIVE layout provider's well-known routes: + * - `GET /workspaces/:id/layout` → `{ tree }` (read seeds a fresh workspace) + * - `PUT /workspaces/:id/layout` body `{ tree }` → 200 `{ tree }` / 400 `{ error }` + * - `DELETE /workspaces/:id/layout` → the fresh seed (reset; the plugins + * modal's "Reset layout to default" button — plan 04 §4.4) + * - `POST /workspaces/:id/layout/ops` body `{ ops }` → 200 `{ tree, applied }` + * / 400 `{ error, failedOp? }` / 404 no provider (plan 04 §4.2 — the FE + * ABI's `layout.applyOps` door) + * + * Mirrors the plugins adapter's pattern: `httpBase` + an injected `fetchImpl` + * (testable without the network). The 404 is a SIGNAL, not an error: it means + * no layout provider is active, and the store renders its local default with + * ephemeral edits (never an error UI). A network failure degrades the same way. + */ + +/** Outcome of a layout read/write. `notFound` distinguishes the no-provider 404. */ +export type LayoutHttpResult = + | { readonly ok: true; readonly tree: LayoutTree } + | { readonly ok: false; readonly notFound: boolean; readonly error: string }; + +export interface LayoutHttp { + getTree(workspaceId: string): Promise<LayoutHttpResult>; + putTree(workspaceId: string, tree: LayoutTree): Promise<LayoutHttpResult>; + deleteTree(workspaceId: string): Promise<LayoutHttpResult>; + /** + * The plan-04 ops door (§4.2): apply op-vocabulary edits through the + * provider. The result IS the contract's `ApplyOpsResult` — the 404 + * (no provider) maps to the typed `{ ok: false, error: "no layout + * provider active" }` failure the ABI documents. + */ + postOps(workspaceId: string, ops: readonly LayoutOp[]): Promise<ApplyOpsResult>; +} + +/** 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 404 — never poisons the store. */ +function malformed(): LayoutHttpResult { + return { ok: false, notFound: true, error: "Malformed layout response" }; +} + +export function createLayoutHttp(httpBase: string, fetchImpl: typeof fetch): LayoutHttp { + return { + async getTree(workspaceId): Promise<LayoutHttpResult> { + try { + const res = await fetchImpl( + `${httpBase}/workspaces/${encodeURIComponent(workspaceId)}/layout`, + ); + if (res.status === 404) { + return { ok: false, notFound: true, error: "No layout provider is active" }; + } + if (!res.ok) return { ok: false, notFound: false, error: await errorOf(res) }; + const body = (await res.json()) as { tree?: unknown }; + if (body === null || typeof body !== "object" || body.tree === undefined) + return malformed(); + return { ok: true, tree: body.tree as LayoutTree }; + } catch (err) { + return { + ok: false, + notFound: true, + error: err instanceof Error ? err.message : "Layout request failed", + }; + } + }, + + async putTree(workspaceId, tree): Promise<LayoutHttpResult> { + try { + const res = await fetchImpl( + `${httpBase}/workspaces/${encodeURIComponent(workspaceId)}/layout`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ tree }), + }, + ); + if (res.status === 404) { + return { ok: false, notFound: true, error: "No layout provider is active" }; + } + if (!res.ok) return { ok: false, notFound: false, error: await errorOf(res) }; + const body = (await res.json()) as { tree?: unknown }; + if (body === null || typeof body !== "object" || body.tree === undefined) + return malformed(); + return { ok: true, tree: body.tree as LayoutTree }; + } catch (err) { + return { + ok: false, + notFound: true, + error: err instanceof Error ? err.message : "Layout request failed", + }; + } + }, + + async deleteTree(workspaceId): Promise<LayoutHttpResult> { + try { + const res = await fetchImpl( + `${httpBase}/workspaces/${encodeURIComponent(workspaceId)}/layout`, + { method: "DELETE" }, + ); + if (res.status === 404) { + return { ok: false, notFound: true, error: "No layout provider is active" }; + } + if (!res.ok) return { ok: false, notFound: false, error: await errorOf(res) }; + const body = (await res.json()) as { tree?: unknown }; + if (body === null || typeof body !== "object" || body.tree === undefined) + return malformed(); + return { ok: true, tree: body.tree as LayoutTree }; + } catch (err) { + return { + ok: false, + notFound: true, + error: err instanceof Error ? err.message : "Layout request failed", + }; + } + }, + + async postOps(workspaceId, ops): Promise<ApplyOpsResult> { + try { + const res = await fetchImpl( + `${httpBase}/workspaces/${encodeURIComponent(workspaceId)}/layout/ops`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ops }), + }, + ); + // The no-provider 404 is the ABI's typed signal (plan 04 §4.5). + if (res.status === 404) { + return { ok: false, error: "no layout provider active" }; + } + if (!res.ok) return await opsError(res); + const body = (await res.json()) as { tree?: unknown; applied?: unknown }; + if (body === null || typeof body !== "object" || body.tree === undefined) + return { ok: false, error: "Malformed layout response" }; + return { + ok: true, + tree: body.tree as LayoutTree, + applied: typeof body.applied === "number" ? body.applied : ops.length, + }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Layout request failed", + }; + } + }, + }; +} + +/** Read an ops-failure body (`{ error, failedOp? }`), tolerating junk. */ +async function opsError(res: Response): Promise<ApplyOpsResult> { + try { + const body = (await res.json()) as { error?: unknown; failedOp?: unknown }; + const error = + typeof body.error === "string" && body.error.length > 0 ? body.error : `HTTP ${res.status}`; + return { + ok: false, + error, + ...(typeof body.failedOp === "number" ? { failedOp: body.failedOp } : {}), + }; + } catch { + return { ok: false, error: `HTTP ${res.status}` }; + } +} diff --git a/src/features/layout/index.ts b/src/features/layout/index.ts new file mode 100644 index 0000000..8a464fe --- /dev/null +++ b/src/features/layout/index.ts @@ -0,0 +1,51 @@ +/** + * The layout feature — a generic tile-tree renderer + the view registry. + * + * The frontend stops *having* a layout and starts *rendering* one: the tree is + * data (`@dispatch/layout-contract`), the renderer walks it, and every view a + * leaf mounts comes from the registry. Chrome (top bar, alerts, modals) stays + * OUTSIDE the tree — the tree owns the content region. + */ + +export { + abiTree, + createLayoutAbiPort, + type LayoutAbiPort, + type LayoutAbiPortDeps, +} from "./adapter/abi-port"; +export { createLayoutHttp, type LayoutHttp, type LayoutHttpResult } from "./adapter/http"; +export { + bootDefaultOpen, + type CollapseState, + type CollapseStore, + type CollapseStoreOptions, + createCollapseStore, + parsePersistedOpen, + SIDEBAR_OPEN_KEY, + WIDE_BREAKPOINT, +} from "./logic/collapse"; +export { migrationTree, sidePanelViews, storedPanelViews } from "./logic/migration"; +export { addPanelOp, firstPanelView, removePanelOp, swapPanelOp } from "./logic/panels-edit"; +export { + getView, + listPanelViews, + listViews, + type RegisteredView, + registerPluginView, + registerView, + unregisterView, + unregisterViewsByPlugin, + type ViewComponent, + type ViewRegistration, + viewRegistry, +} from "./logic/registry"; +export { DEFAULT_PANEL_VIEWS, overlaySidePanels, seedLayout } from "./logic/seed"; +export { firstCollapsibleNodeId, firstPanelsNode } from "./logic/tree-query"; +export { createLayoutStore, type LayoutProviderState, type LayoutStore } from "./store.svelte"; +export { default as TileRoot } from "./ui/TileRoot.svelte"; + +/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ +export const manifest = { + name: "layout", + description: "Tile-tree renderer + view registry", +} as const; diff --git a/src/features/layout/logic/collapse.test.ts b/src/features/layout/logic/collapse.test.ts new file mode 100644 index 0000000..77e9f3c --- /dev/null +++ b/src/features/layout/logic/collapse.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from "vitest"; +import { + bootDefaultOpen, + createCollapseStore, + parsePersistedOpen, + SIDEBAR_OPEN_KEY, + WIDE_BREAKPOINT, +} from "./collapse"; + +function fakeStorage(): { storage: Storage; raw(key: string): string | null } { + const map = new Map<string, string>(); + const storage: Storage = { + get length() { + return map.size; + }, + clear: () => map.clear(), + getItem: (key) => map.get(key) ?? null, + key: () => null, + removeItem: (key) => map.delete(key), + setItem: (key, value) => void map.set(key, value), + }; + return { storage, raw: (key) => map.get(key) ?? null }; +} + +describe("bootDefaultOpen", () => { + it("defaults to open on wide screens (>= 1024px) and closed on narrow ones", () => { + expect(bootDefaultOpen(WIDE_BREAKPOINT)).toBe(true); + expect(bootDefaultOpen(1440)).toBe(true); + expect(bootDefaultOpen(1023)).toBe(false); + expect(bootDefaultOpen(0)).toBe(false); + }); + + it("defaults to open when the viewport is unknown (SSR/tests)", () => { + expect(bootDefaultOpen(undefined)).toBe(true); + }); +}); + +describe("parsePersistedOpen", () => { + it("accepts today's bare JSON boolean format", () => { + expect(parsePersistedOpen("true")).toBe(true); + expect(parsePersistedOpen("false")).toBe(false); + }); + + it("ignores null, malformed JSON, and non-boolean values", () => { + expect(parsePersistedOpen(null)).toBeNull(); + expect(parsePersistedOpen("{")).toBeNull(); + expect(parsePersistedOpen('"true"')).toBeNull(); + expect(parsePersistedOpen("{}")).toBeNull(); + }); +}); + +describe("createCollapseStore", () => { + it("uses the viewport default when nothing is persisted", () => { + const store = createCollapseStore({ viewportWidth: 1024 }); + expect(store.isOpen("side")).toBe(true); + const narrow = createCollapseStore({ viewportWidth: 800 }); + expect(narrow.isOpen("side")).toBe(false); + }); + + it("restores the persisted boolean over the viewport default", () => { + const { storage } = fakeStorage(); + storage.setItem(SIDEBAR_OPEN_KEY, "false"); + const store = createCollapseStore({ storage, viewportWidth: 1440 }); + expect(store.isOpen("side")).toBe(false); + }); + + it("persists toggles as today's bare JSON boolean (same key, same bytes)", () => { + const { storage, raw } = fakeStorage(); + const store = createCollapseStore({ storage, viewportWidth: 1024 }); + store.toggle("side"); + expect(raw(SIDEBAR_OPEN_KEY)).toBe("false"); + store.toggle("side"); + expect(raw(SIDEBAR_OPEN_KEY)).toBe("true"); + }); + + it("tracks per-node state reactively", () => { + const store = createCollapseStore({ viewportWidth: 1024 }); + const seen: boolean[] = []; + const unsubscribe = store.subscribe((state) => + seen.push(state.openByNode.side ?? state.defaultOpen), + ); + try { + store.setOpen("side", false); + store.setOpen("side", true); + } finally { + unsubscribe(); + } + expect(seen).toEqual([true, false, true]); + expect(store.isOpen("side")).toBe(true); + }); + + it("an untracked node falls back to the default", () => { + const store = createCollapseStore({ viewportWidth: 800 }); + expect(store.isOpen("other")).toBe(false); + }); + + it("survives storage failures (quota errors are swallowed)", () => { + const broken: Storage = { + get length() { + return 0; + }, + clear: () => undefined, + getItem: () => null, + key: () => null, + removeItem: () => undefined, + setItem: () => { + throw new Error("quota exceeded"); + }, + }; + const store = createCollapseStore({ storage: broken, viewportWidth: 1024 }); + expect(() => store.toggle("side")).not.toThrow(); + expect(store.isOpen("side")).toBe(false); + }); + + it("works without storage (session-only chrome state)", () => { + const store = createCollapseStore({ viewportWidth: 1024 }); + store.toggle("side"); + expect(store.isOpen("side")).toBe(false); + }); +}); diff --git a/src/features/layout/logic/collapse.ts b/src/features/layout/logic/collapse.ts new file mode 100644 index 0000000..28e90b4 --- /dev/null +++ b/src/features/layout/logic/collapse.ts @@ -0,0 +1,95 @@ +/** + * Collapse chrome state — the open/closed state of the tree's collapsible + * split children (today: the sidebar). Renderer chrome, NOT tree data + * (layout-00 decision 3): tree nodes merely declare `collapsible`; this store + * owns the per-node state, persisted device-locally. + * + * Persistence is BYTE-COMPATIBLE with today: the same `dispatch.sidebar.open` + * key holding a JSON boolean, defaulting to open on wide screens (>= 1024px, + * Tailwind `lg`). Today's UI has exactly one collapsible node, and the + * persisted boolean tracks the toggled node's state — identical bytes and + * behavior. A future with several collapsible nodes migrates the format in + * plan 02, not here. + */ + +import { type Readable, writable } from "svelte/store"; + +/** The localStorage key — unchanged from today (no migration). */ +export const SIDEBAR_OPEN_KEY = "dispatch.sidebar.open"; +/** Tailwind `lg`: today's boot default is open on wide screens. */ +export const WIDE_BREAKPOINT = 1024; + +export interface CollapseState { + /** Explicit per-node open state (node id → open). Absent = the default. */ + readonly openByNode: Readonly<Record<string, boolean>>; + /** The boot default (persisted boolean, else viewport width >= 1024). */ + readonly defaultOpen: boolean; +} + +export interface CollapseStore extends Readable<CollapseState> { + /** Is the named node open? (explicit state, else the default) */ + isOpen(nodeId: string): boolean; + setOpen(nodeId: string, open: boolean): void; + toggle(nodeId: string): void; +} + +export interface CollapseStoreOptions { + /** Injected storage (the app store's `Storage`; undefined → session-only). */ + readonly storage?: Storage | undefined; + /** Viewport width at boot (today: `window.innerWidth`). Undefined → default open. */ + readonly viewportWidth?: number | undefined; + /** Storage key override (tests). Defaults to `dispatch.sidebar.open`. */ + readonly key?: string | undefined; +} + +/** The boot default: open on wide screens (narrow screens start closed). */ +export function bootDefaultOpen(viewportWidth: number | undefined): boolean { + if (viewportWidth === undefined) return true; + return viewportWidth >= WIDE_BREAKPOINT; +} + +/** Parse the persisted value. Today's format is a bare JSON boolean; anything else is ignored. */ +export function parsePersistedOpen(raw: string | null): boolean | null { + if (raw === null) return null; + try { + const parsed: unknown = JSON.parse(raw); + return typeof parsed === "boolean" ? parsed : null; + } catch { + return null; + } +} + +export function createCollapseStore(options?: CollapseStoreOptions): CollapseStore { + const key = options?.key ?? SIDEBAR_OPEN_KEY; + const storage = options?.storage; + const persisted = parsePersistedOpen(storage === undefined ? null : storage.getItem(key)); + const defaultOpen = persisted ?? bootDefaultOpen(options?.viewportWidth); + + let openByNode: Record<string, boolean> = {}; + const store = writable<CollapseState>({ openByNode, defaultOpen }); + + function set(nodeId: string, open: boolean): void { + openByNode = { ...openByNode, [nodeId]: open }; + store.set({ openByNode, defaultOpen }); + // Persist the toggled node's state as today's bare boolean. With one + // collapsible node (always, today) this is exactly today's behavior. + if (storage !== undefined) { + try { + storage.setItem(key, JSON.stringify(open)); + } catch { + // Swallow quota / write errors — persistence is best-effort. + } + } + } + + function isOpen(nodeId: string): boolean { + return openByNode[nodeId] ?? defaultOpen; + } + + return { + subscribe: store.subscribe, + isOpen, + setOpen: (nodeId, open) => set(nodeId, open), + toggle: (nodeId) => set(nodeId, !isOpen(nodeId)), + }; +} diff --git a/src/features/layout/logic/marker.test.ts b/src/features/layout/logic/marker.test.ts new file mode 100644 index 0000000..53b91b0 --- /dev/null +++ b/src/features/layout/logic/marker.test.ts @@ -0,0 +1,29 @@ +import type { TileMarker } from "@dispatch/layout-contract"; +import { describe, expect, it } from "vitest"; +import { markerOutline, markerStyle } from "./marker"; + +describe("markerOutline — the diagnostic tile-border mapping", () => { + it("main → a 4px solid red outline (the main tile's marker)", () => { + expect(markerOutline("main")).toBe("4px solid red"); + }); + + it("side → a 4px solid green outline (the side tile's marker)", () => { + expect(markerOutline("side")).toBe("4px solid green"); + }); + + it("no marker → no outline (existing trees render exactly as before)", () => { + expect(markerOutline(undefined)).toBeUndefined(); + }); +}); + +describe("markerStyle — the complete inline style (outline + inset offset)", () => { + it("insets the outline so it draws INSIDE the tile's box (never clipped at screen edges)", () => { + expect(markerStyle("main")).toBe("outline: 4px solid red; outline-offset: -4px"); + expect(markerStyle("side")).toBe("outline: 4px solid green; outline-offset: -4px"); + }); + + it("no/unknown marker → NO style at all (unmarked nodes carry zero outline CSS)", () => { + expect(markerStyle(undefined)).toBeUndefined(); + expect(markerStyle("purple" as unknown as TileMarker)).toBeUndefined(); + }); +}); diff --git a/src/features/layout/logic/marker.ts b/src/features/layout/logic/marker.ts new file mode 100644 index 0000000..46259e0 --- /dev/null +++ b/src/features/layout/logic/marker.ts @@ -0,0 +1,46 @@ +/** + * Diagnostic tile-border markers (the contract's additive `TileMarker` + * presentation field) → the inline outline the renderer draws. Pure + * decision logic — the .svelte tile components just apply the returned + * style value. + * + * INLINE STYLES, deliberately: a marker value arrives in TREE DATA at + * runtime, and Tailwind only generates classes it sees in source files — a + * dynamic class from tree data would silently render nothing. An outline + * (not a border) is used so the diagnostic draws WITHOUT changing any + * tile's layout/size (4px solid, clearly visible). + * + * The outline is INSET (`outline-offset: -4px`) so it draws INSIDE the + * tile's box: tiles span their containers edge-to-edge, so the default + * (outside) outline overflows the visible area and is clipped away at the + * screen edges. The views inside the tiles have no opaque full-bleed + * background, so the inset ring stays on top of the tile's content. + * + * The marker is purely presentational: unknown/absent → no outline + * (existing trees render exactly as before), and no other behavior is + * attached to it anywhere in the renderer. + */ + +import type { TileMarker } from "@dispatch/layout-contract"; + +/** The diagnostic outline style for a marker — `undefined` = draw nothing. */ +export function markerOutline(marker: TileMarker | undefined): string | undefined { + if (marker === "main") return "4px solid red"; + if (marker === "side") return "4px solid green"; + return undefined; +} + +/** The inset that keeps the outline INSIDE the tile's box (see the header note). */ +export const MARKER_OUTLINE_OFFSET = "-4px"; + +/** + * The COMPLETE inline style for a marked element — outline + inset offset in + * one declaration — or `undefined` when unmarked/unknown, so unmarked nodes + * carry no outline CSS at all (existing trees render exactly as before). + */ +export function markerStyle(marker: TileMarker | undefined): string | undefined { + const outline = markerOutline(marker); + return outline === undefined + ? undefined + : `outline: ${outline}; outline-offset: ${MARKER_OUTLINE_OFFSET}`; +} diff --git a/src/features/layout/logic/migration.test.ts b/src/features/layout/logic/migration.test.ts new file mode 100644 index 0000000..9eeeda0 --- /dev/null +++ b/src/features/layout/logic/migration.test.ts @@ -0,0 +1,68 @@ +import { DEFAULT_LAYOUT } from "@dispatch/layout-contract"; +import { describe, expect, it } from "vitest"; +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); +} diff --git a/src/features/layout/logic/panel-keys.test.ts b/src/features/layout/logic/panel-keys.test.ts new file mode 100644 index 0000000..b45671a --- /dev/null +++ b/src/features/layout/logic/panel-keys.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import { initialPanelKeys, reconcilePanelKeys, swappedPanels } from "./panel-keys"; + +describe("initialPanelKeys", () => { + it("numbers the panels in order and seeds past them", () => { + expect(initialPanelKeys(["tabs", "model"])).toEqual({ + views: ["tabs", "model"], + keys: [0, 1], + seed: 2, + }); + }); +}); + +describe("reconcilePanelKeys", () => { + const start = initialPanelKeys(["tabs", "model", "usage"]); // keys 0 1 2 + + it("closing the MIDDLE panel drops the middle key (not the last)", () => { + const next = reconcilePanelKeys(start, ["tabs", "usage"]); + expect(next.keys).toEqual([0, 2]); + expect(next.seed).toBe(3); + }); + + it("adding a panel gives only the new one a fresh key", () => { + const next = reconcilePanelKeys(start, ["tabs", "model", "usage", "lsp"]); + expect(next.keys).toEqual([0, 1, 2, 3]); + expect(next.seed).toBe(4); + }); + + it("a dropdown swap keeps every key positional", () => { + const next = reconcilePanelKeys(start, ["tabs", "lsp", "usage"]); + expect(next.keys).toEqual([0, 1, 2]); + }); + + it("handles duplicate views and never reuses a key", () => { + const dup = initialPanelKeys(["a", "b", "a"]); // 0 1 2 + const removed = reconcilePanelKeys(dup, ["a", "a"]); + expect(removed.keys).toEqual([0, 2]); + const added = reconcilePanelKeys(removed, ["a", "a", "a"]); + expect(added.keys).toEqual([0, 2, 3]); + expect(new Set(added.keys).size).toBe(3); + }); + + it("a ✕ hint picks the exact panel when identical views make it ambiguous", () => { + const twins = initialPanelKeys(["model", "usage", "usage"]); // 0 1 2 + expect(reconcilePanelKeys(twins, ["model", "usage"]).keys).toEqual([0, 1]); // no hint + expect(reconcilePanelKeys(twins, ["model", "usage"], 1).keys).toEqual([0, 2]); + }); + + it("ignores a stale hint that does not describe the change", () => { + // The tree changed some other way (e.g. a remote edit) — fall back to LCS. + expect(reconcilePanelKeys(start, ["tabs", "model"], 0).keys).toEqual([0, 1]); + expect(reconcilePanelKeys(start, ["tabs", "model", "usage"], 1).keys).toEqual([0, 1, 2]); + }); + + it("an unrelated replacement of different length gets all-fresh keys", () => { + const next = reconcilePanelKeys(start, ["x"]); + expect(next.keys).toEqual([3]); + }); +}); + +describe("swappedPanels", () => { + it("reports a surviving panel whose view changed", () => { + const prev = initialPanelKeys(["tabs", "model"]); + const next = reconcilePanelKeys(prev, ["tabs", "usage"]); + expect([...swappedPanels(prev, next)]).toEqual([[1, { from: "model", to: "usage" }]]); + }); + + it("does not report adds or removes", () => { + const prev = initialPanelKeys(["tabs", "model", "usage"]); + expect(swappedPanels(prev, reconcilePanelKeys(prev, ["tabs", "usage"])).size).toBe(0); + expect(swappedPanels(prev, reconcilePanelKeys(prev, [...prev.views, "lsp"])).size).toBe(0); + }); +}); diff --git a/src/features/layout/logic/panel-keys.ts b/src/features/layout/logic/panel-keys.ts new file mode 100644 index 0000000..76a053f --- /dev/null +++ b/src/features/layout/logic/panel-keys.ts @@ -0,0 +1,118 @@ +/** + * Stable panel keys — the PURE identity scheme behind the panel height + * animation. + * + * Panel leaves carry no required id, so the panels list can't be keyed by the + * tree. Keying by INDEX is wrong for removal: closing the middle of three + * panels would re-use the middle DOM node for the third view and animate the + * LAST panel out. This reconciles a stable numeric key per panel across tree + * updates, from the view ids alone: + * + * - same length → keys stay positional (a dropdown SWAP keeps its panel — + * the panel morphs its height to the new view instead of leaving). + * - length changed → the longest common subsequence of view ids keeps its + * keys; every unmatched new panel gets a fresh key (an ADD), every + * unmatched old key disappears (a REMOVE). + * + * Zero Svelte, zero DOM. + */ + +export interface PanelKeys { + /** View id per panel, in order. */ + readonly views: readonly string[]; + /** Stable key per panel, aligned with `views`. */ + readonly keys: readonly number[]; + /** The next fresh key. */ + readonly seed: number; +} + +/** Keys for a freshly mounted panels list. */ +export function initialPanelKeys(views: readonly string[]): PanelKeys { + return { views: [...views], keys: views.map((_, i) => i), seed: views.length }; +} + +/** + * Reconcile `prev` against the next view list (see the module doc). + * + * `removedIndex` is an optional HINT from a local ✕ click: when the next list + * is exactly `prev` minus that index, that exact panel is the one removed. + * Without it, closing one of two identical adjacent views is ambiguous to the + * subsequence match (it would drop the later one). + */ +export function reconcilePanelKeys( + prev: PanelKeys, + nextViews: readonly string[], + removedIndex?: number, +): PanelKeys { + if (removedIndex !== undefined && isRemovalAt(prev.views, nextViews, removedIndex)) { + return { + views: [...nextViews], + keys: prev.keys.filter((_, i) => i !== removedIndex), + seed: prev.seed, + }; + } + if (nextViews.length === prev.views.length) { + return { views: [...nextViews], keys: [...prev.keys], seed: prev.seed }; + } + const matched = lcsMatch(prev.views, nextViews); + let seed = prev.seed; + const keys = nextViews.map((_, j) => { + const i = matched.get(j); + if (i !== undefined) return prev.keys[i] ?? seed++; + return seed++; + }); + return { views: [...nextViews], keys, seed }; +} + +/** + * The views whose panel SURVIVED but now mounts a different view (a swap): + * key → { from, to }. Adds/removes are not swaps. + */ +export function swappedPanels( + prev: PanelKeys, + next: PanelKeys, +): Map<number, { from: string; to: string }> { + const before = new Map(prev.keys.map((k, i) => [k, prev.views[i] ?? ""])); + const out = new Map<number, { from: string; to: string }>(); + next.keys.forEach((k, i) => { + const from = before.get(k); + const to = next.views[i] ?? ""; + if (from !== undefined && from !== to) out.set(k, { from, to }); + }); + return out; +} + +function isRemovalAt(prev: readonly string[], next: readonly string[], index: number): boolean { + if (index < 0 || index >= prev.length || next.length !== prev.length - 1) return false; + return next.every((v, j) => v === prev[j < index ? j : j + 1]); +} + +/** LCS alignment: next index → prev index for every matched element. */ +function lcsMatch(a: readonly string[], b: readonly string[]): Map<number, number> { + const n = a.length; + const m = b.length; + // dp[i][j] = LCS length of a[i..] and b[j..] + const dp: number[][] = Array.from({ length: n + 1 }, () => new Array<number>(m + 1).fill(0)); + for (let i = n - 1; i >= 0; i--) { + for (let j = m - 1; j >= 0; j--) { + const row = dp[i] as number[]; + const below = dp[i + 1] as number[]; + row[j] = a[i] === b[j] ? (below[j + 1] ?? 0) + 1 : Math.max(below[j] ?? 0, row[j + 1] ?? 0); + } + } + const out = new Map<number, number>(); + let i = 0; + let j = 0; + while (i < n && j < m) { + if (a[i] === b[j]) { + out.set(j, i); + i++; + j++; + } else if ((dp[i + 1]?.[j] ?? 0) >= (dp[i]?.[j + 1] ?? 0)) { + i++; + } else { + j++; + } + } + return out; +} diff --git a/src/features/layout/logic/panels-edit.test.ts b/src/features/layout/logic/panels-edit.test.ts new file mode 100644 index 0000000..b4c3099 --- /dev/null +++ b/src/features/layout/logic/panels-edit.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { addPanelOp, firstPanelView, removePanelOp, swapPanelOp } from "./panels-edit"; + +describe("panel edit → op mapping", () => { + it("maps a dropdown swap to a replaceView on the panel leaf's path", () => { + expect(swapPanelOp([1], 2, "model")).toEqual({ + type: "replaceView", + target: [1, 2], + view: "model", + }); + }); + + it("maps the + button to an append insertPanel", () => { + expect(addPanelOp(["titlebar"], "tasks")).toEqual({ + type: "insertPanel", + panels: ["titlebar"], + view: "tasks", + }); + }); + + it("maps the ✕ button to a removeNode on the panel leaf's path", () => { + expect(removePanelOp([1], 0)).toEqual({ type: "removeNode", target: [1, 0] }); + }); + + it("the + button pre-selects the first eligible view (or null when none)", () => { + expect(firstPanelView([{ id: "tabs" }, { id: "model" }])).toBe("tabs"); + expect(firstPanelView([])).toBeNull(); + }); +}); diff --git a/src/features/layout/logic/panels-edit.ts b/src/features/layout/logic/panels-edit.ts new file mode 100644 index 0000000..8a56859 --- /dev/null +++ b/src/features/layout/logic/panels-edit.ts @@ -0,0 +1,33 @@ +/** + * Panel-edit → tree-op mapping: the pure decision logic behind TilePanels' + * dropdown swap / add / remove buttons. The component builds the ops; the + * renderer's owner (TileRoot → onTreeChange) applies them. Zero Svelte, zero + * DOM. + */ + +import type { LayoutOp, Path } from "@dispatch/layout-contract"; + +/** Swap the view a panel leaf mounts (panel `index` of the panels node at `path`). */ +export function swapPanelOp(path: Path, index: number, view: string): LayoutOp { + return { type: "replaceView", target: [...path, index], view }; +} + +/** Add a panel mounting `view` (appended to the panels node at `path`). */ +export function addPanelOp(path: Path, view: string): LayoutOp { + return { type: "insertPanel", panels: path, view }; +} + +/** Remove the panel at `index` from the panels node at `path`. */ +export function removePanelOp(path: Path, index: number): LayoutOp { + return { type: "removeNode", target: [...path, index] }; +} + +/** + * The view a new panel starts with. The tree schema has no "empty panel" + * state (a leaf's `view` is required — today's "Select a view" placeholder has + * no tree representation), so the + button pre-selects the first panel-eligible + * view. Returns null when nothing is eligible (the + button hides). + */ +export function firstPanelView(views: readonly { readonly id: string }[]): string | null { + return views[0]?.id ?? null; +} diff --git a/src/features/layout/logic/registry.test.ts b/src/features/layout/logic/registry.test.ts new file mode 100644 index 0000000..e5e947c --- /dev/null +++ b/src/features/layout/logic/registry.test.ts @@ -0,0 +1,148 @@ +import type { Component } from "svelte"; +import { afterEach, describe, expect, it } from "vitest"; +import { + getView, + listPanelViews, + listViews, + registerPluginView, + registerView, + unregisterView, + unregisterViewsByPlugin, + type ViewRegistration, + viewRegistry, +} from "./registry"; + +const fakeComponent: Component = () => { + throw new Error("fixture component should never be constructed"); +}; + +function registration(overrides?: Partial<ViewRegistration>): ViewRegistration { + return { id: "test-view", label: "Test View", component: fakeComponent, ...overrides }; +} + +const registeredIds = (): string[] => listViews().map((v) => v.id); + +afterEach(() => { + for (const id of registeredIds()) unregisterView(id); +}); + +describe("view registry", () => { + it("registers a view and exposes it via getView/listViews", () => { + registerView(registration()); + expect(getView("test-view")?.label).toBe("Test View"); + expect(registeredIds()).toContain("test-view"); + }); + + it("throws loudly on a duplicate id", () => { + registerView(registration()); + expect(() => registerView(registration({ label: "Other" }))).toThrow(/duplicate view id/); + }); + + it("normalizes panelEligible to true by default and respects false", () => { + registerView(registration({ id: "a" })); + registerView(registration({ id: "b", panelEligible: false })); + expect(getView("a")?.panelEligible).toBe(true); + expect(getView("b")?.panelEligible).toBe(false); + expect(listPanelViews().map((v) => v.id)).toContain("a"); + expect(listPanelViews().map((v) => v.id)).not.toContain("b"); + }); + + it("keeps pluginId when given", () => { + registerView(registration({ id: "plugin-view", pluginId: "my-plugin" })); + expect(getView("plugin-view")?.pluginId).toBe("my-plugin"); + }); + + it("unregisterView removes the view (idempotent for unknown ids)", () => { + registerView(registration({ id: "gone" })); + unregisterView("gone"); + expect(getView("gone")).toBeUndefined(); + expect(() => unregisterView("never-registered")).not.toThrow(); + }); + + it("is reactive: subscribers see registrations and unregistrations", () => { + const seen: string[][] = []; + const unsubscribe = viewRegistry.subscribe((views) => seen.push(views.map((v) => v.id))); + try { + registerView(registration({ id: "reactive-a" })); + registerView(registration({ id: "reactive-b" })); + unregisterView("reactive-a"); + } finally { + unsubscribe(); + } + expect(seen[0]).toEqual([]); + expect(seen.at(-1)).toEqual(["reactive-b"]); + }); + + it("preserves registration order in listViews", () => { + registerView(registration({ id: "z-first" })); + registerView(registration({ id: "a-second" })); + const ids = registeredIds(); + expect(ids.indexOf("z-first")).toBeLessThan(ids.indexOf("a-second")); + }); +}); + +// ── Plugin-view registration (plan 03) — forced attribution + the sweep ───── + +describe("registerPluginView / unregisterViewsByPlugin (plan 03)", () => { + it("forces the importing plugin's pluginId attribution", () => { + const dispose = registerPluginView( + { id: "abi-view", label: "ABI View", component: fakeComponent }, + "my-plugin", + ); + expect(getView("abi-view")?.pluginId).toBe("my-plugin"); + dispose(); + expect(getView("abi-view")).toBeUndefined(); + }); + + it("returns a working per-view disposer (the plugin deactivate contract)", () => { + const dispose = registerPluginView( + { id: "abi-view-2", label: "ABI View", component: fakeComponent }, + "my-plugin", + ); + dispose(); + dispose(); // idempotent + expect(getView("abi-view-2")).toBeUndefined(); + }); + + it("the sweep removes exactly that plugin's views (registry attribution)", () => { + registerPluginView({ id: "p1-a", label: "A", component: fakeComponent }, "plugin-1"); + registerPluginView({ id: "p1-b", label: "B", component: fakeComponent }, "plugin-1"); + registerPluginView({ id: "p2-a", label: "C", component: fakeComponent }, "plugin-2"); + registerView(registration({ id: "builtin" })); // no pluginId — never swept + + unregisterViewsByPlugin("plugin-1"); + expect(getView("p1-a")).toBeUndefined(); + expect(getView("p1-b")).toBeUndefined(); + expect(getView("p2-a")).not.toBeUndefined(); + expect(getView("builtin")).not.toBeUndefined(); + + unregisterViewsByPlugin("plugin-2"); + expect(getView("p2-a")).toBeUndefined(); + expect(getView("builtin")).not.toBeUndefined(); + }); + + it("the sweep is idempotent and a no-op for an unknown plugin", () => { + unregisterViewsByPlugin("nobody"); + registerPluginView({ id: "p3-a", label: "A", component: fakeComponent }, "plugin-3"); + unregisterViewsByPlugin("plugin-3"); + expect(() => unregisterViewsByPlugin("plugin-3")).not.toThrow(); + }); + + it("a duplicate id from a plugin throws LOUDLY (the driver marks it failed)", () => { + registerView(registration({ id: "taken" })); + expect(() => + registerPluginView({ id: "taken", label: "Taken", component: fakeComponent }, "my-plugin"), + ).toThrow(/duplicate view id/); + }); + + it("sweep removals are visible to registry subscribers (reactive)", () => { + const seen: string[][] = []; + const unsubscribe = viewRegistry.subscribe((views) => { + seen.push(views.map((v) => v.id)); + }); + registerPluginView({ id: "reactive", label: "R", component: fakeComponent }, "p"); + unregisterViewsByPlugin("p"); + unsubscribe(); + expect(seen.at(-1)).not.toContain("reactive"); + }); +}); diff --git a/src/features/layout/logic/registry.ts b/src/features/layout/logic/registry.ts new file mode 100644 index 0000000..bcd49e0 --- /dev/null +++ b/src/features/layout/logic/registry.ts @@ -0,0 +1,139 @@ +/** + * The view registry — the map from view id → component that tile leaves mount. + * + * Every view the renderer can place (the sidebar views + chat / composer / + * message-queue today; plugin views from plan 03 later) registers here. The + * registry is REACTIVE (a Svelte store): plan 03's late registrations re-render + * open dropdowns, and the tree renderer resolves leaves through it per render. + * + * An unknown view id in a leaf is NEVER a crash — backend-owned trees (plan 02) + * can name views a given client lacks; the renderer shows a fallback tile. + * + * This module holds a small amount of reactive state by design (the plan places + * the registry in `logic/` with Svelte-store reactivity). It is NOT decision + * logic — no DOM, no effects. + */ + +import type { Component } from "svelte"; +import { type Readable, writable } from "svelte/store"; + +/** + * The props type of a registered view component. The composition root hands + * every wrapper its `ports` through this bag (see `src/app/view-ports.ts`); + * fixture/generic components may ignore it. + * + * The registry is a HETEROGENEOUS component bag (the plan types it `Component` + * outright), so its props are opaque `Record<string, any>` — the wrappers + * re-narrow to their concrete props at the composition-root seam. + */ +export type ViewComponent = + // biome-ignore lint/suspicious/noExplicitAny: opaque props of a heterogeneous component bag + Component<Record<string, any>>; + +export interface ViewRegistration { + /** Registry id — the same id a leaf's `view` field names. */ + readonly id: string; + /** Human label shown in panel dropdowns. */ + readonly label: string; + /** Svelte 5 component, already props-wired (see `src/app/register-views.ts`). */ + readonly component: ViewComponent; + /** Whether the view may appear in panel dropdowns (default true). */ + readonly panelEligible?: boolean; + /** The plugin that contributed the view (plan 03 requires it for those). */ + readonly pluginId?: string; +} + +/** A registered view with `panelEligible` normalized (default true). */ +export interface RegisteredView { + readonly id: string; + readonly label: string; + readonly component: ViewComponent; + readonly panelEligible: boolean; + readonly pluginId: string | undefined; +} + +const byId = new Map<string, RegisteredView>(); +const store = writable<readonly RegisteredView[]>([]); + +function publish(): void { + store.set([...byId.values()]); +} + +/** + * Register a view. A duplicate id throws LOUDLY at dev time — two components + * claiming one id is a composition-root bug, never a runtime fallback case. + */ +export function registerView(registration: ViewRegistration): void { + if (byId.has(registration.id)) { + throw new Error(`registerView: duplicate view id "${registration.id}"`); + } + byId.set(registration.id, { + id: registration.id, + label: registration.label, + component: registration.component, + panelEligible: registration.panelEligible ?? true, + pluginId: registration.pluginId, + }); + publish(); +} + +/** Remove a view. Idempotent (removing an unknown id is a no-op). */ +export function unregisterView(id: string): void { + if (byId.delete(id)) publish(); +} + +/** + * Register a PLUGIN view (plan 03) — the ABI-facing registration: the + * `pluginId` attribution is FORCED to the importing plugin (plugins cannot + * attribute views to another plugin or to no plugin), which is what makes + * `unregisterViewsByPlugin`'s unload sweep exact. Returns the per-view + * disposer (the plugin's deactivate contract). A duplicate id throws + * LOUDLY — the driver catches it and marks the plugin's FE half failed. + */ +export function registerPluginView( + registration: Omit<ViewRegistration, "pluginId">, + pluginId: string, +): () => void { + registerView({ ...registration, pluginId }); + return () => unregisterView(registration.id); +} + +/** + * Remove every view attributed to one plugin (the plan-03 unload sweep). + * Idempotent; the reactive registry re-renders open dropdowns, and tiles + * holding removed views fall back to `FallbackTile` (never a crash). + */ +export function unregisterViewsByPlugin(pluginId: string): void { + let changed = false; + for (const [id, view] of byId) { + if (view.pluginId === pluginId) { + byId.delete(id); + changed = true; + } + } + if (changed) publish(); +} + +/** Look a view up by id (undefined → the renderer shows its fallback tile). */ +export function getView(id: string): RegisteredView | undefined { + return byId.get(id); +} + +/** All registered views, in registration order (snapshot). */ +export function listViews(): readonly RegisteredView[] { + return [...byId.values()]; +} + +/** The panel-eligible subset (`panelEligible: true`) — panel dropdown options. */ +export function listPanelViews(): readonly RegisteredView[] { + return listViews().filter((v) => v.panelEligible); +} + +/** + * Reactive registry contents (registration order). Subscribe in components — + * e.g. `$viewRegistry` in a panel dropdown — so late registrations (plan 03) + * re-render open dropdowns. + */ +export const viewRegistry: Readable<readonly RegisteredView[]> = { + subscribe: store.subscribe, +}; diff --git a/src/features/layout/logic/seed.test.ts b/src/features/layout/logic/seed.test.ts new file mode 100644 index 0000000..dfe166a --- /dev/null +++ b/src/features/layout/logic/seed.test.ts @@ -0,0 +1,99 @@ +import { DEFAULT_LAYOUT, type LayoutTree, type SplitNode } from "@dispatch/layout-contract"; +import { describe, expect, it } from "vitest"; +import { DEFAULT_PANEL_VIEWS, overlaySidePanels, seedLayout } from "./seed"; +import { firstCollapsibleNodeId, firstPanelsNode } from "./tree-query"; + +function sideViews(tree: LayoutTree): string[] { + const root = tree.root as SplitNode; + const side = root.children[1]?.node; + return side?.kind === "panels" ? side.panels.map((p) => p.view) : []; +} + +describe("seedLayout", () => { + it("uses the default panels when nothing is stored", () => { + const tree = seedLayout(null, ["tabs", "model", "settings"]); + expect(sideViews(tree)).toEqual(DEFAULT_PANEL_VIEWS); + }); + + it("overlays the stored selection, dropping unknown/removed kinds", () => { + const tree = seedLayout( + ["extensions", "ghost-view", "model"], + ["tabs", "model", "extensions", "settings"], + ); + expect(sideViews(tree)).toEqual(["extensions", "model"]); + }); + + it("falls back to the defaults when the stored selection is all-unknown or empty", () => { + expect(sideViews(seedLayout(["ghost"], ["tabs", "model"]))).toEqual(["tabs", "model"]); + expect(sideViews(seedLayout([], ["tabs", "model"]))).toEqual(["tabs", "model"]); + }); + + it("falls back to the defaults when the stored value is not a string array", () => { + expect(sideViews(seedLayout("junk", ["tabs", "model"]))).toEqual(["tabs", "model"]); + expect(sideViews(seedLayout([42], ["tabs", "model"]))).toEqual(["tabs", "model"]); + expect(sideViews(seedLayout({ a: 1 }, ["tabs", "model"]))).toEqual(["tabs", "model"]); + }); + + it("preserves the default tree's ids and the titlebar-unset invariant", () => { + const tree = seedLayout(["model"], ["tabs", "model"]); + expect((tree.root as SplitNode).id).toBe("root"); + expect(tree.titlebar).toBeUndefined(); + }); +}); + +describe("overlaySidePanels", () => { + it("replaces only the side panels of the given base tree", () => { + const tree = overlaySidePanels(DEFAULT_LAYOUT, ["settings", "tasks"]); + expect(sideViews(tree)).toEqual(["settings", "tasks"]); + // The rest of the tree is untouched. + expect((tree.root as SplitNode).children[0]?.node).toEqual( + (DEFAULT_LAYOUT.root as SplitNode).children[0]?.node, + ); + }); +}); + +describe("tree queries", () => { + it("firstPanelsNode finds the side panels with its path", () => { + const tree = seedLayout(["model"], ["tabs", "model"]); + const found = firstPanelsNode(tree); + expect(found?.path).toEqual([1]); + expect(found?.node.panels.map((p) => p.view)).toEqual(["model"]); + }); + + it("firstCollapsibleNodeId returns the default tree's side node id", () => { + expect(firstCollapsibleNodeId(DEFAULT_LAYOUT)).toBe("side"); + }); + + it("firstCollapsibleNodeId falls back to a path key when the node has no id", () => { + const tree: LayoutTree = { + version: 1, + root: { + kind: "split", + dir: "row", + children: [ + { size: 1, node: { kind: "leaf", view: "chat" } }, + { + size: 0, + basis: 320, + collapsible: true, + node: { kind: "panels", panels: [{ kind: "leaf", view: "tabs" }] }, + }, + ], + }, + }; + // The collapsible child sits at root path index 1 → key "1". + expect(firstCollapsibleNodeId(tree)).toBe("1"); + }); + + it("firstCollapsibleNodeId returns null when nothing is collapsible", () => { + const tree: LayoutTree = { + version: 1, + root: { + kind: "split", + dir: "row", + children: [{ size: 1, node: { kind: "leaf", view: "chat" } }], + }, + }; + expect(firstCollapsibleNodeId(tree)).toBeNull(); + }); +}); diff --git a/src/features/layout/logic/seed.ts b/src/features/layout/logic/seed.ts new file mode 100644 index 0000000..7cd0ba3 --- /dev/null +++ b/src/features/layout/logic/seed.ts @@ -0,0 +1,78 @@ +/** + * Boot-time tree seeding: the default layout with today's sidebar selection + * overlaid from localStorage. Byte-compatible with today's boot behavior + * (`dispatch.sidebar.views` read; unknown/removed kinds dropped — the tree + * schema has no "unknown view" panel state, so a stale id is skipped exactly + * like a view that no longer exists). + */ + +import { + DEFAULT_LAYOUT, + findById, + type LayoutNode, + type LayoutTree, + type LeafNode, + type PanelsNode, + type Path, +} from "@dispatch/layout-contract"; + +/** + * Today's default sidebar selection (the moved-from-top tab bar + the model + * controls) — the fallback when nothing (or nothing valid) is stored. + */ +export const DEFAULT_PANEL_VIEWS: readonly string[] = ["tabs", "model"]; + +/** + * Seed a tree from DEFAULT_LAYOUT with `storedViews` (the parsed + * `dispatch.sidebar.views` value, or null when absent) overlaid onto the + * default tree's panels node. + * + * - null / non-array / array without any known view → the defaults. + * - Known views are kept in the stored order; unknown ids are dropped. + */ +export function seedLayout(storedViews: unknown, panelViewIds: readonly string[]): LayoutTree { + const known = new Set(panelViewIds); + const stored = Array.isArray(storedViews) + ? storedViews.filter((v): v is string => typeof v === "string") + : []; + const panels = stored.filter((v) => known.has(v)); + const selected = panels.length > 0 ? panels : DEFAULT_PANEL_VIEWS.filter((v) => known.has(v)); + return overlaySidePanels(DEFAULT_LAYOUT, selected); +} + +/** + * A NEW tree: `base` with its `"side"` panels node replaced by leaves mounting + * `views` (in order). Targets the panels node by id (falls back to no-op when + * the base has none); the rest of the tree is shared, not copied. + */ +export function overlaySidePanels(base: LayoutTree, views: readonly string[]): LayoutTree { + const found = findById(base, "side"); + if (found === null || found.node.kind !== "panels") return base; + const panels: readonly LeafNode[] = views.map((view) => ({ kind: "leaf", view })); + const next: PanelsNode = { ...found.node, panels }; + return { version: 1, root: replaceChild(base.root, found.path, next) }; +} + +/** Rebuild the node at `path` (immutably); the replacement must be a panels node. */ +function replaceChild(node: LayoutNode, path: Path, replacement: PanelsNode): LayoutNode { + if (path.length === 0) return replacement; + const [head, ...rest] = path; + if (typeof head !== "number") return node; + if (node.kind === "split") { + return { + ...node, + children: node.children.map((child, i) => + i === head ? { ...child, node: replaceChild(child.node, rest, replacement) } : child, + ), + }; + } + if (node.kind === "stack") { + return { + ...node, + children: node.children.map((child, i) => + i === head ? { ...child, node: replaceChild(child.node, rest, replacement) } : child, + ), + }; + } + return node; +} diff --git a/src/features/layout/logic/tree-query.ts b/src/features/layout/logic/tree-query.ts new file mode 100644 index 0000000..aa8dfe9 --- /dev/null +++ b/src/features/layout/logic/tree-query.ts @@ -0,0 +1,60 @@ +/** + * Tree queries the chrome needs — pure walks over the layout tree. + */ + +import type { LayoutNode, LayoutTree, PanelsNode, Path } from "@dispatch/layout-contract"; + +/** The first panels node in the tree (the panel-selection owner), if any. */ +export function firstPanelsNode(tree: LayoutTree): { node: PanelsNode; path: Path } | null { + const seen: { node: PanelsNode; path: Path }[] = []; + const visit = (node: LayoutNode, path: Path): void => { + if (seen.length > 0) return; + if (node.kind === "panels") { + seen.push({ node, path }); + return; + } + const kids: readonly LayoutNode[] = + node.kind === "split" + ? node.children.map((c) => c.node) + : node.kind === "stack" + ? node.children.map((c) => c.node) + : []; + for (const [i, child] of kids.entries()) visit(child, [...path, i]); + }; + visit(tree.root, []); + return seen[0] ?? null; +} + +/** + * The node id of the tree's FIRST collapsible split child — the one the chrome + * top bar's toggle reads/writes (`findById("side")` on the default tree; + * generically the first collapsible child, root-first then titlebar). Falls + * back to a path-derived key when the child's node has no id. Null → the tree + * has nothing collapsible (the toggle button hides). + */ +export function firstCollapsibleNodeId(tree: LayoutTree): string | null { + const visit = (node: LayoutNode, path: Path): string | null => { + if (node.kind === "split") { + for (const [i, child] of node.children.entries()) { + if (!child.collapsible) continue; + if (child.node.id !== undefined) return child.node.id; + return [...path, i].map((s) => String(s)).join(":"); + } + } + const kids: readonly LayoutNode[] = + node.kind === "split" + ? node.children.map((c) => c.node) + : node.kind === "stack" + ? node.children.map((c) => c.node) + : []; + for (const [i, child] of kids.entries()) { + const found = visit(child, [...path, i]); + if (found !== null) return found; + } + return null; + }; + const inRoot = visit(tree.root, []); + if (inRoot !== null) return inRoot; + if (tree.titlebar !== undefined) return visit(tree.titlebar, ["titlebar"]); + return null; +} diff --git a/src/features/layout/store.svelte.ts b/src/features/layout/store.svelte.ts new file mode 100644 index 0000000..29e2fa1 --- /dev/null +++ b/src/features/layout/store.svelte.ts @@ -0,0 +1,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. + }); + }, + }; +} diff --git a/src/features/layout/store.test.ts b/src/features/layout/store.test.ts new file mode 100644 index 0000000..331af95 --- /dev/null +++ b/src/features/layout/store.test.ts @@ -0,0 +1,529 @@ +import { + type ApplyOpsResult, + DEFAULT_LAYOUT, + type LayoutOp, + type LayoutTree, +} from "@dispatch/layout-contract"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { LayoutHttp, LayoutHttpResult } from "./adapter/http"; +import { overlaySidePanels } from "./logic/seed"; +import { createLayoutStore } from "./store.svelte"; + +/** + * Layout store tests (plan 02 verification gate) — mock HTTP EDGES ONLY (the + * injected `LayoutHttp` is the store's outermost boundary; nothing internal + * is mocked — asymmetric testing per AGENTS.md). Fake timers drive the edit + * PUT debounce deterministically. + */ + +/** A recording fake of the LayoutHttp edge, scripted per test. */ +interface HttpFake extends LayoutHttp { + getCalls: string[]; + putCalls: Array<{ workspaceId: string; tree: LayoutTree }>; + deleteCalls: string[]; + getResult: (workspaceId: string) => LayoutHttpResult; + putResult: (workspaceId: string, tree: LayoutTree) => LayoutHttpResult; + deleteResult: (workspaceId: string) => LayoutHttpResult; + opsResult: (workspaceId: string, ops: readonly LayoutOp[]) => ApplyOpsResult; +} + +function fakeHttp( + getResult: (workspaceId: string) => LayoutHttpResult = () => ({ + ok: false, + notFound: true, + error: "No layout provider is active", + }), + putResult: (workspaceId: string, tree: LayoutTree) => LayoutHttpResult = (_ws, tree) => ({ + ok: true, + tree, + }), +): HttpFake { + const getCalls: string[] = []; + const putCalls: Array<{ workspaceId: string; tree: LayoutTree }> = []; + const deleteCalls: string[] = []; + const fake: HttpFake = { + getCalls, + putCalls, + deleteCalls, + getResult, + putResult, + // Re-scriptable outcome (default: the fresh seed a reset returns). + deleteResult: () => ({ ok: true, tree: DEFAULT_LAYOUT }), + // Re-scriptable outcome (default: the typed no-provider failure the ABI + // documents; tests re-assign mid-test like putResult). + opsResult: () => ({ ok: false, error: "no layout provider active" }), + // Delegate to the CURRENT properties so a test can re-script outcomes + // mid-test (http.putResult = …). + async getTree(workspaceId) { + getCalls.push(workspaceId); + return fake.getResult(workspaceId); + }, + async putTree(workspaceId, tree) { + putCalls.push({ workspaceId, tree }); + return fake.putResult(workspaceId, tree); + }, + async deleteTree(workspaceId) { + deleteCalls.push(workspaceId); + return fake.deleteResult(workspaceId); + }, + async postOps(workspaceId, ops) { + return fake.opsResult(workspaceId, ops); + }, + }; + return fake; +} + +function storageWith(views: string[] | null): Storage { + const map = new Map<string, string>(); + if (views !== null) map.set("test.sidebar.views", JSON.stringify(views)); + return { + get length() { + return map.size; + }, + clear() { + map.clear(); + }, + getItem: (k) => map.get(k) ?? null, + key: () => null, + removeItem: (k) => map.delete(k), + setItem: (k, v) => map.set(k, v), + }; +} + +const KNOWN = ["tabs", "model", "extensions", "vision"]; + +function makeStore(http: HttpFake, extra: Record<string, unknown> = {}) { + return createLayoutStore({ + http, + workspaceId: "ws-a", + knownViews: KNOWN, + sidebarViewsKey: "test.sidebar.views", + debounceMs: 100, + warn: (message) => warnings.push(message), + ...extra, + }); +} + +let warnings: string[] = []; + +beforeEach(() => { + vi.useFakeTimers(); + warnings = []; +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("layout store — no-provider fallback (§2.6)", () => { + it("a 404 probe keeps the default tree, marks the provider absent, and logs ONCE", async () => { + const http = fakeHttp(); + const store = makeStore(http); + + await store.probe("ws-a", { boot: true }); + expect(store.providerState).toBe("absent"); + expect(store.tree).toEqual(DEFAULT_LAYOUT); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("no layout provider"); + + // A re-probe (extensions-changed while still absent) does not re-warn. + await store.probe("ws-a"); + expect(warnings).toHaveLength(1); + }); + + it("a network failure degrades like a 404 (quiet, no error UI)", async () => { + const http = fakeHttp(() => ({ ok: false, notFound: true, error: "fetch blew up" })); + const store = makeStore(http); + await store.probe("ws-a"); + expect(store.providerState).toBe("absent"); + expect(store.tree).toEqual(DEFAULT_LAYOUT); + }); + + it("edits still apply in no-provider mode but are EPHEMERAL (no PUT, no rollback, no warning)", async () => { + const http = fakeHttp(); + const store = makeStore(http); + await store.probe("ws-a"); // absent now + expect(store.providerState).toBe("absent"); + + const edited = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]); + store.onTreeChange(edited); + expect(store.tree).toEqual(edited); + + vi.advanceTimersByTime(500); + expect(http.putCalls).toEqual([]); // ephemeral: no write attempted + // Quiet = the single absence log (from the probe), nothing more. + expect(warnings).toHaveLength(1); + }); + + it("an edit while the probe is still in flight is PUT optimistically; a 404 flips absent and KEEPS the edit", async () => { + const http = fakeHttp(); + // The PUT is a 404 (the probe will confirm: no provider). + http.putResult = () => ({ ok: false, notFound: true, error: "No layout provider is active" }); + const store = makeStore(http); + + void store.probe("ws-a"); // in flight (microtask pending) + const edited = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]); + store.onTreeChange(edited); // lands BEFORE the probe resolves + await vi.advanceTimersByTimeAsync(200); + + expect(http.putCalls).toHaveLength(1); // the optimistic write WAS attempted + expect(store.providerState).toBe("absent"); + expect(store.tree).toEqual(edited); // no rollback — there is no server tree + expect(warnings).toHaveLength(1); // the once-per-absence log, nothing more + }); +}); + +describe("layout store — provider present", () => { + it("a 200 probe replaces the tree and marks the provider present", async () => { + const served = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]); + const http = fakeHttp(() => ({ ok: true, tree: served })); + const store = makeStore(http); + + await store.probe("ws-a"); + expect(store.providerState).toBe("present"); + expect(store.tree).toEqual(served); + expect(warnings).toEqual([]); + }); +}); + +describe("layout store — WS push (§2.5)", () => { + it("a layout push for the ACTIVE workspace replaces the tree", async () => { + const served = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]); + const http = fakeHttp(() => ({ ok: true, tree: served })); + const store = makeStore(http); + await store.probe("ws-a"); + + const pushed = overlaySidePanels(DEFAULT_LAYOUT, ["vision"]); + store.onServerTree("ws-a", pushed); + expect(store.tree).toEqual(pushed); + }); + + it("a push for a NON-active workspace is ignored", async () => { + const served = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]); + const http = fakeHttp(() => ({ ok: true, tree: served })); + const store = makeStore(http); + await store.probe("ws-a"); + + const other = overlaySidePanels(DEFAULT_LAYOUT, ["vision"]); + store.onServerTree("ws-b", other); + expect(store.tree).toEqual(served); + }); + + it("after a workspace switch the OLD workspace's push is ignored (clients filter)", async () => { + const servedA = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]); + let served = servedA; + const http = fakeHttp(() => ({ ok: true, tree: served })); + const store = makeStore(http); + await store.probe("ws-a"); + + const servedB = overlaySidePanels(DEFAULT_LAYOUT, ["vision"]); + served = servedB; + store.onWorkspaceChanged("ws-b"); + await vi.advanceTimersByTimeAsync(0); // the probe's promise + expect(store.tree).toEqual(servedB); + + // A late push for the OLD workspace must not land. + const stale = overlaySidePanels(DEFAULT_LAYOUT, ["tasks"]); + store.onServerTree("ws-a", stale); + expect(store.tree).toEqual(servedB); + }); +}); + +describe("layout store — edits PUT the whole tree + rollback (§2.6)", () => { + it("an edit PUTs the WHOLE tree, debounced; a 200 echo becomes the rollback target", async () => { + const served = DEFAULT_LAYOUT; + const http = fakeHttp(() => ({ ok: true, tree: served })); + const store = makeStore(http); + await store.probe("ws-a"); + + const edited = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]); + store.onTreeChange(edited); + // Not yet — debounced. + expect(http.putCalls).toEqual([]); + await vi.advanceTimersByTimeAsync(150); + expect(http.putCalls).toEqual([{ workspaceId: "ws-a", tree: edited }]); + expect(warnings).toEqual([]); + }); + + it("rapid edits coalesce into ONE debounced PUT of the latest tree", async () => { + const http = fakeHttp(() => ({ ok: true, tree: DEFAULT_LAYOUT })); + const store = makeStore(http); + await store.probe("ws-a"); + + store.onTreeChange(overlaySidePanels(DEFAULT_LAYOUT, ["extensions"])); + await vi.advanceTimersByTimeAsync(50); + store.onTreeChange(overlaySidePanels(DEFAULT_LAYOUT, ["vision"])); + await vi.advanceTimersByTimeAsync(200); + + expect(http.putCalls).toHaveLength(1); + expect(http.putCalls[0]?.tree).toEqual(overlaySidePanels(DEFAULT_LAYOUT, ["vision"])); + }); + + it("a 400 rejection rolls back to the last server tree with a quiet warning", async () => { + const served = DEFAULT_LAYOUT; + const http = fakeHttp( + () => ({ ok: true, tree: served }), + () => ({ ok: false, notFound: false, error: "invalid tree" }), + ); + const store = makeStore(http); + await store.probe("ws-a"); + expect(store.providerState).toBe("present"); + + const bad = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]); + store.onTreeChange(bad); + await vi.advanceTimersByTimeAsync(200); + + expect(store.tree).toEqual(served); // rolled back + expect(store.providerState).toBe("present"); // the provider is still there + expect(warnings.some((w) => w.includes("rolled back"))).toBe(true); + }); + + it("a 5xx failure rolls back too (present provider, transient error)", async () => { + const served = DEFAULT_LAYOUT; + let failPut = false; + const http = fakeHttp( + () => ({ ok: true, tree: served }), + () => + failPut + ? { ok: false, notFound: false, error: "storage down" } + : { ok: true, tree: served }, + ); + const store = makeStore(http); + await store.probe("ws-a"); + + failPut = true; + store.onTreeChange(overlaySidePanels(DEFAULT_LAYOUT, ["extensions"])); + await vi.advanceTimersByTimeAsync(200); + expect(store.tree).toEqual(served); + }); + + it("a PUT 404 does NOT roll back — the provider vanished mid-session; the edit stays ephemeral", async () => { + const http = fakeHttp(() => ({ ok: true, tree: DEFAULT_LAYOUT })); + const store = makeStore(http); + await store.probe("ws-a"); + + // The provider unloads before the edit's PUT fires. + http.putResult = () => ({ ok: false, notFound: true, error: "No layout provider is active" }); + const edited = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]); + store.onTreeChange(edited); + await vi.advanceTimersByTimeAsync(200); + + expect(store.tree).toEqual(edited); // the edit STAYS (in-memory) + expect(store.providerState).toBe("absent"); + expect(warnings).toHaveLength(1); // the once-per-absence log, nothing more + }); +}); + +describe("layout store — probe triggers (§2.6)", () => { + it("a per-workspace plugin-state change on the ACTIVE workspace re-probes (the plugins-modal toggle)", async () => { + let absent = true; + const served = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]); + const http = fakeHttp(() => + absent + ? { ok: false, notFound: true, error: "No layout plugin is enabled in this workspace" } + : { ok: true, tree: served }, + ); + const store = makeStore(http); + await store.probe("ws-a"); + expect(store.providerState).toBe("absent"); + + absent = false; // a layout plugin got enabled in THIS workspace + store.onWorkspacePluginsChanged("ws-a"); + await vi.advanceTimersByTimeAsync(0); + expect(store.providerState).toBe("present"); + expect(store.tree).toEqual(served); + }); + + it("a per-workspace plugin-state change on ANOTHER workspace is ignored (its probe fetches on switch)", async () => { + let absent = true; + const served = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]); + const http = fakeHttp(() => + absent + ? { ok: false, notFound: true, error: "No layout plugin is enabled in this workspace" } + : { ok: true, tree: served }, + ); + const store = makeStore(http); + await store.probe("ws-a"); + expect(store.providerState).toBe("absent"); + + absent = false; // a DIFFERENT workspace's toggle — nothing re-renders here + store.onWorkspacePluginsChanged("ws-other"); + await vi.advanceTimersByTimeAsync(0); + expect(http.getCalls).toEqual(["ws-a"]); // no second probe + expect(store.providerState).toBe("absent"); + }); + + it("extensions-changed re-probes the active workspace", async () => { + let absent = true; + const served = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]); + const http = fakeHttp(() => + absent + ? { ok: false, notFound: true, error: "No layout provider is active" } + : { ok: true, tree: served }, + ); + const store = makeStore(http); + await store.probe("ws-a"); + expect(store.providerState).toBe("absent"); + + absent = false; // a layout plugin got enabled on the server + store.onExtensionsChanged(); + await vi.advanceTimersByTimeAsync(0); + expect(store.providerState).toBe("present"); + expect(store.tree).toEqual(served); + }); + + it("a workspace switch resets to the default, probes the NEW workspace, and drops queued edits", async () => { + const http = fakeHttp((ws) => + ws === "ws-b" + ? { ok: true, tree: overlaySidePanels(DEFAULT_LAYOUT, ["vision"]) } + : { ok: true, tree: DEFAULT_LAYOUT }, + ); + const store = makeStore(http); + await store.probe("ws-a"); + + // Queue an edit for ws-a, then switch before the debounce fires. + store.onTreeChange(overlaySidePanels(DEFAULT_LAYOUT, ["extensions"])); + store.onWorkspaceChanged("ws-b"); + await vi.advanceTimersByTimeAsync(300); + + expect(store.activeWorkspaceId).toBe("ws-b"); + expect(http.getCalls).toEqual(["ws-a", "ws-b"]); + expect(store.tree).toEqual(overlaySidePanels(DEFAULT_LAYOUT, ["vision"])); + // The queued edit never PUTs — it belonged to the old workspace. + expect(http.putCalls).toEqual([]); + }); + + it("switching to the SAME workspace is a no-op (no re-probe, no reset)", async () => { + const http = fakeHttp(() => ({ ok: true, tree: DEFAULT_LAYOUT })); + const store = makeStore(http); + await store.probe("ws-a"); + store.onWorkspaceChanged("ws-a"); + expect(http.getCalls).toEqual(["ws-a"]); + }); +}); + +describe("layout store — one-way localStorage migration (§2.7)", () => { + it("boot probe + equal panels + stored key → exactly ONE migration PUT, then the key is dead", async () => { + const storage = storageWith(["extensions", "vision"]); + const http = fakeHttp(() => ({ ok: true, tree: DEFAULT_LAYOUT })); + const store = makeStore(http, { storage }); + + await store.probe("ws-a", { boot: true }); + expect(http.putCalls).toEqual([ + { workspaceId: "ws-a", tree: overlaySidePanels(DEFAULT_LAYOUT, ["extensions", "vision"]) }, + ]); + // The migrated tree is now the rendered tree. + expect(store.tree).toEqual(overlaySidePanels(DEFAULT_LAYOUT, ["extensions", "vision"])); + + // The key is never read again: a later probe PUTs nothing. + await store.probe("ws-a"); + expect(http.putCalls).toHaveLength(1); + }); + + it("no stored key → no migration PUT (but the read-once flag is still consumed)", async () => { + const storage = storageWith(null); + const http = fakeHttp(() => ({ ok: true, tree: DEFAULT_LAYOUT })); + const store = makeStore(http, { storage }); + await store.probe("ws-a", { boot: true }); + expect(http.putCalls).toEqual([]); + }); + + it("a custom provider tree (panels differ) is NOT migrated over", async () => { + const storage = storageWith(["extensions"]); + const custom = overlaySidePanels(DEFAULT_LAYOUT, ["vision"]); + const http = fakeHttp(() => ({ ok: true, tree: custom })); + const store = makeStore(http, { storage }); + await store.probe("ws-a", { boot: true }); + expect(http.putCalls).toEqual([]); + expect(store.tree).toEqual(custom); + }); + + it("migration is boot-only: a non-boot probe never PUTs the stored selection", async () => { + const storage = storageWith(["extensions"]); + const http = fakeHttp(() => ({ ok: true, tree: DEFAULT_LAYOUT })); + const store = makeStore(http, { storage }); + await store.probe("ws-a"); + expect(http.putCalls).toEqual([]); + }); + + it("a failed migration PUT consumes the flag (best-effort — never retried, never fatal)", async () => { + const storage = storageWith(["extensions"]); + const http = fakeHttp( + () => ({ ok: true, tree: DEFAULT_LAYOUT }), + () => ({ ok: false, notFound: false, error: "invalid tree" }), + ); + const store = makeStore(http, { storage }); + await store.probe("ws-a", { boot: true }); + expect(http.putCalls).toHaveLength(1); + expect(warnings.some((w) => w.includes("migration"))).toBe(true); + // The rendered tree is untouched (the PUT failed). + expect(store.tree).toEqual(DEFAULT_LAYOUT); + await store.probe("ws-a"); + expect(http.putCalls).toHaveLength(1); + }); + + it("migration arms even when the boot probe 404s, and applies on the first LATER success", async () => { + const storage = storageWith(["extensions"]); + let absent = true; + const http = fakeHttp(() => + absent + ? { ok: false, notFound: true, error: "No layout provider is active" } + : { ok: true, tree: DEFAULT_LAYOUT }, + ); + const store = makeStore(http, { storage }); + await store.probe("ws-a", { boot: true }); + expect(http.putCalls).toEqual([]); + + absent = false; + await store.probe("ws-a"); // extensions-changed probe — first success + expect(http.putCalls).toEqual([ + { workspaceId: "ws-a", tree: overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]) }, + ]); + }); +}); + +describe("layout store — resetToDefault (plan 04 §4.4, the reset affordance)", () => { + it("DELETEs the workspace's layout, applies the fresh seed, and resolves null", async () => { + const custom = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]); + const http = fakeHttp(); + // The DELETE returns the FRESH SEED (the server re-seeded). + http.deleteResult = () => ({ ok: true, tree: DEFAULT_LAYOUT }); + const store = makeStore(http); + + // Get to a NON-default tree first: a present provider + a pushed edit. + http.getResult = () => ({ ok: true, tree: custom }); + await store.probe("ws-a"); + expect(store.tree).toEqual(custom); + + const failure = await store.resetToDefault("ws-a"); + expect(failure).toBeNull(); + expect(http.deleteCalls).toEqual(["ws-a"]); + // The fresh seed is applied locally (the WS broadcast echo is idempotent). + expect(store.tree).toEqual(DEFAULT_LAYOUT); + expect(store.providerState).toBe("present"); + }); + + it("a 404 (no provider) resolves to the user-facing 'no layout plugin enabled' reason", async () => { + const http = fakeHttp(); + http.deleteResult = () => ({ + ok: false, + notFound: true, + error: "No layout provider is active", + }); + const store = makeStore(http); + + const failure = await store.resetToDefault("ws-a"); + expect(failure).toBe("no layout plugin enabled"); + expect(store.tree).toEqual(DEFAULT_LAYOUT); // untouched local default + }); + + it("another failure (5xx on a present provider) resolves to its error message", async () => { + const http = fakeHttp(() => ({ ok: true, tree: DEFAULT_LAYOUT })); + http.deleteResult = () => ({ ok: false, notFound: false, error: "storage write failed" }); + const store = makeStore(http); + await store.probe("ws-a"); + + const failure = await store.resetToDefault("ws-a"); + expect(failure).toBe("storage write failed"); + }); +}); diff --git a/src/features/layout/ui/FallbackTile.svelte b/src/features/layout/ui/FallbackTile.svelte new file mode 100644 index 0000000..8fafa7f --- /dev/null +++ b/src/features/layout/ui/FallbackTile.svelte @@ -0,0 +1,13 @@ +<script lang="ts"> + /** + * Shown when a leaf names a view this client has not registered — the tree + * is data and can arrive from a backend (plan 02) that knows views this + * build lacks. A titled placeholder, never a crash. + */ + let { viewId }: { viewId: string } = $props(); +</script> + +<div class="flex flex-col gap-1 rounded-lg bg-base-200 p-3" data-testid="fallback-tile"> + <h3 class="text-xs font-semibold uppercase opacity-60">{viewId}</h3> + <p class="text-sm opacity-60">View not registered.</p> +</div> diff --git a/src/features/layout/ui/TileLeaf.svelte b/src/features/layout/ui/TileLeaf.svelte new file mode 100644 index 0000000..0fd6430 --- /dev/null +++ b/src/features/layout/ui/TileLeaf.svelte @@ -0,0 +1,44 @@ +<script lang="ts"> + /** + * Mounts a registered view for a leaf node. Resolution goes through the + * REACTIVE registry, so a late registration (plan 03) swaps the tile in + * without a tree change. An unknown view id renders a fallback tile — + * NEVER a crash (backend-owned trees can name views this client lacks). + * + * A diagnostic `marker` on the leaf draws an outline around it — the only + * node kind without a natural tile wrapper, so the wrapper exists only + * when marked (unmarked leaves render exactly as before). + */ + import type { TileMarker } from "@dispatch/layout-contract"; + import { viewRegistry, type RegisteredView } from "../logic/registry"; + import { MARKER_OUTLINE_OFFSET, markerOutline } from "../logic/marker"; + import FallbackTile from "./FallbackTile.svelte"; + + let { + node, + ports, + }: { + node: { readonly view: string; readonly marker?: TileMarker }; + ports?: unknown; + } = $props(); + + const entry: RegisteredView | undefined = $derived( + $viewRegistry.find((v) => v.id === node.view), + ); + const View = $derived(entry?.component); + const outline = $derived(markerOutline(node.marker)); +</script> + +{#snippet content()} + {#if View} + <View {ports} /> + {:else} + <FallbackTile viewId={node.view} /> + {/if} +{/snippet} + +{#if outline !== undefined} + <div style:outline={outline} style:outline-offset={MARKER_OUTLINE_OFFSET}>{@render content()}</div> +{:else} + {@render content()} +{/if} diff --git a/src/features/layout/ui/TileNode.svelte b/src/features/layout/ui/TileNode.svelte new file mode 100644 index 0000000..5ed8a89 --- /dev/null +++ b/src/features/layout/ui/TileNode.svelte @@ -0,0 +1,37 @@ +<script lang="ts"> + /** + * The recursive tile dispatcher: renders the node for its `kind` and recurses + * through the container components. Leaf nodes resolve through the view + * registry (TileLeaf). + */ + import type { LayoutNode, LayoutOp, Path } from "@dispatch/layout-contract"; + import type { CollapseStore } from "../logic/collapse"; + import TileLeaf from "./TileLeaf.svelte"; + import TilePanels from "./TilePanels.svelte"; + import TileSplit from "./TileSplit.svelte"; + import TileStack from "./TileStack.svelte"; + + let { + node, + path, + ports, + collapse, + onEdit, + }: { + node: LayoutNode; + path: Path; + ports?: unknown; + collapse: CollapseStore; + onEdit: (ops: readonly LayoutOp[]) => void; + } = $props(); +</script> + +{#if node.kind === "split"} + <TileSplit {node} {path} {ports} {collapse} {onEdit} /> +{:else if node.kind === "stack"} + <TileStack {node} {path} {ports} {collapse} {onEdit} /> +{:else if node.kind === "panels"} + <TilePanels {node} {path} {ports} {onEdit} /> +{:else if node.kind === "leaf"} + <TileLeaf {node} {ports} /> +{/if} diff --git a/src/features/layout/ui/TilePanels.svelte b/src/features/layout/ui/TilePanels.svelte new file mode 100644 index 0000000..cc5fa89 --- /dev/null +++ b/src/features/layout/ui/TilePanels.svelte @@ -0,0 +1,185 @@ +<script lang="ts"> + /** + * A scrolling stack of swappable panels — the generalized ViewSidebar. The + * panel list comes from the tree node; dropdown options from the (reactive) + * registry's panel-eligible views. Edits emit tree ops upward via `onEdit`; + * the OWNER (App today, the backend store in plan 02) applies them and + * decides persistence. + */ + import type { LayoutOp, PanelsNode, Path } from "@dispatch/layout-contract"; + import PlusIcon from "phosphor-svelte/lib/PlusIcon"; + import { untrack } from "svelte"; + import { viewRegistry } from "../logic/registry"; + import { addPanelOp, firstPanelView, removePanelOp, swapPanelOp } from "../logic/panels-edit"; + import { + initialPanelKeys, + type PanelKeys, + reconcilePanelKeys, + swappedPanels, + } from "../logic/panel-keys"; + import { markerStyle } from "../logic/marker"; + import { morphPanelHeight, panelGrowIn, panelShrinkOut } from "./panel-motion"; + import TileLeaf from "./TileLeaf.svelte"; + + let { + node, + path, + ports, + onEdit, + renderMarker = false, + }: { + node: PanelsNode; + path: Path; + ports?: unknown; + onEdit: (ops: readonly LayoutOp[]) => void; + /** + * Whether to draw the node's diagnostic marker on this root. Default NO: + * a panels node is the scrolled CONTENT of a split's collapsible child, + * so an outline here wraps the full scrollable region and scrolls with + * it — the split draws the marker on the aside (the fixed viewport) + * instead. Pass `true` only for a panels node used outside a split. + */ + renderMarker?: boolean; + } = $props(); + + // Panel-eligible views only (decision 5): chat/composer/message-queue never + // appear in the dropdowns. Reactive so late registrations re-render options. + const panelViews = $derived($viewRegistry.filter((v) => v.panelEligible)); + const addView = $derived(firstPanelView(panelViews)); + + // Stable per-panel keys (pure reconcile, memoised across tree updates) so a + // closed panel is the one that animates out, not whichever is last. + // A local ✕ click hints WHICH panel left (consumed by the next tree change; + // ignored if that change isn't exactly this removal, e.g. a remote edit). + let keyMemo: PanelKeys | null = null; + let removeHint: number | undefined; + const keyed = $derived.by(() => { + const views = node.panels.map((p) => p.view); + keyMemo = + keyMemo === null ? initialPanelKeys(views) : reconcilePanelKeys(keyMemo, views, removeHint); + removeHint = undefined; + return keyMemo; + }); + + function removePanel(idx: number): void { + removeHint = idx; + onEdit([removePanelOp(path, idx)]); + } + + // View SWAP: the panel BOX (the well) animates from the old view's height + // to the new one's. Measure before the DOM updates, animate after. (Adds/removes are + // the per-panel in:/out: transitions below.) + let columnEl = $state<HTMLDivElement | undefined>(); + /** The keys the DOM currently shows (set after each update). */ + let shownKeys: PanelKeys | null = null; + let before: { keys: PanelKeys; heights: Map<number, number> } | null = null; + $effect.pre(() => { + const next = keyed; + untrack(() => { + const heights = new Map<number, number>(); + for (const el of panelEls()) { + const well = wellOf(el); + if (well !== null) heights.set(Number(el.dataset.panelKey), well.offsetHeight); + } + before = { keys: shownKeys ?? next, heights }; + }); + }); + $effect(() => { + const now = keyed; + untrack(() => { + const prev = before; + before = null; + shownKeys = now; + if (prev === null) return; + for (const key of swappedPanels(prev.keys, now).keys()) { + const el = panelEls().find((e) => Number(e.dataset.panelKey) === key); + const well = el === undefined ? null : wellOf(el); + const from = prev.heights.get(key); + if (well !== null && from !== undefined) morphPanelHeight(well, from); + } + }); + }); + + function wellOf(panel: HTMLElement): HTMLElement | null { + return panel.querySelector<HTMLElement>(":scope > [data-panel-well]"); + } + + function panelEls(): HTMLElement[] { + return columnEl === undefined + ? [] + : [...columnEl.querySelectorAll<HTMLElement>(":scope > [data-panel-key]")]; + } +</script> + +<!-- `shrink-0`: this column is the sidebar's SCROLLED content, so it keeps its + natural height — if it shrank to the scroller (min-h-0 in a flex column), + its children overflowed it and the trailing margin was never scrollable. --> +<!-- No flex `gap`: each panel's wrapper carries the gap as `pb-2` instead, so + it grows/shrinks WITH the panel's height animation (a flex gap would jump). --> +<div + bind:this={columnEl} + class="flex min-h-0 shrink-0 flex-col" + style={renderMarker ? markerStyle(node.marker) : undefined} + data-panels +> + {#each node.panels as panel, idx (keyed.keys[idx])} + <!-- Add/close animate the WRAPPER's height (it carries the inter-panel gap, + so panels below slide smoothly); a view swap animates the WELL's + height. Either way only a height is tweened (clipped) — the contents + keep their natural size. `.inset-well` (app.css): recessed into the + sidebar, like the chat's thinking/tool blocks. --> + <div + class="shrink-0 pb-2" + data-panel-key={keyed.keys[idx]} + in:panelGrowIn + out:panelShrinkOut + > + <div class="inset-well flex flex-col p-3" data-panel-well> + <div class="flex items-center gap-1"> + <select + class="select select-bordered select-sm flex-1" + aria-label="Select a view" + value={panel.view} + onchange={(e) => onEdit([swapPanelOp(path, idx, e.currentTarget.value)])} + > + <option value="" disabled>Select a view</option> + {#each panelViews as view (view.id)} + <option value={view.id}>{view.label}</option> + {/each} + </select> + {#if idx > 0} + <button + type="button" + class="btn btn-square btn-ghost btn-sm shrink-0" + aria-label="Remove view" + onclick={() => removePanel(idx)} + > + ✕ + </button> + {/if} + </div> + + <div class="mt-2"> + <TileLeaf node={panel} {ports} /> + </div> + </div> + </div> + {/each} + + {#if addView !== null} + <!-- `.raised-btn` (app.css): the mirror of the view wells above it — + pops out where they sink in; pressed, it sinks in like them. Height = + DaisyUI's default button size (`--size-field` × 10), as the `btn` it + replaced; `shrink-0` so the column can never squash it. The last panel's + `pb-2` is the gap above it; `mb-2` = the same gap below, so it + doesn't sit tight to the end. --> + <button + type="button" + class="raised-btn mb-2 h-[calc(var(--size-field,0.25rem)*10)] w-full shrink-0" + aria-label="Add view" + onclick={() => addView !== null && onEdit([addPanelOp(path, addView)])} + > + <PlusIcon size={18} weight="bold" aria-hidden="true" /> + </button> + {/if} +</div> diff --git a/src/features/layout/ui/TileRoot.svelte b/src/features/layout/ui/TileRoot.svelte new file mode 100644 index 0000000..82ad430 --- /dev/null +++ b/src/features/layout/ui/TileRoot.svelte @@ -0,0 +1,39 @@ +<script lang="ts"> + /** + * The tile-tree root. Renders the tree (titlebar slot first — unset renders + * nothing, D-L5), applies panel edit ops to its `tree` prop, and forwards the + * new tree via `onTreeChange`. The OWNER decides what persistence means + * (App/localStorage today; the backend layout store in plan 02). + */ + import { applyOps, type LayoutOp, type LayoutTree } from "@dispatch/layout-contract"; + import type { CollapseStore } from "../logic/collapse"; + import TileNode from "./TileNode.svelte"; + + let { + tree, + onTreeChange, + ports, + collapse, + }: { + tree: LayoutTree; + onTreeChange?: (tree: LayoutTree) => void; + ports?: unknown; + collapse: CollapseStore; + } = $props(); + + function handleEdit(ops: readonly LayoutOp[]): void { + const result = applyOps(tree, ops); + if (result.ok) { + onTreeChange?.(result.tree); + } else { + // A panel edit that fails validation (e.g. removing the last panel) is + // refused loudly in dev — the tree keeps its previous state. + console.error(`TileRoot: layout edit refused: ${result.error}`); + } + } +</script> + +{#if tree.titlebar !== undefined} + <TileNode node={tree.titlebar} path={["titlebar"]} {ports} {collapse} onEdit={handleEdit} /> +{/if} +<TileNode node={tree.root} path={[]} {ports} {collapse} onEdit={handleEdit} /> diff --git a/src/features/layout/ui/TileRoot.test.ts b/src/features/layout/ui/TileRoot.test.ts new file mode 100644 index 0000000..4b404a1 --- /dev/null +++ b/src/features/layout/ui/TileRoot.test.ts @@ -0,0 +1,394 @@ +import type { LayoutTree, SplitNode } from "@dispatch/layout-contract"; +import { render, screen } from "@testing-library/svelte"; +import userEvent from "@testing-library/user-event"; +import { gsap } from "gsap"; +import { describe, expect, it, vi } from "vitest"; +import { createCollapseStore, registerView, unregisterView } from "../index"; +import FixtureA from "./fixtures/FixtureA.svelte"; +import FixtureB from "./fixtures/FixtureB.svelte"; +import FixtureC from "./fixtures/FixtureC.svelte"; +import TileRoot from "./TileRoot.svelte"; + +// Fixture registry: two panel-eligible views + one content-only view (a THIRD +// component, so every fixture testid is unique in the rendered tree). +registerView({ id: "fix-a", label: "Alpha View", component: FixtureA }); +registerView({ id: "fix-b", label: "Beta View", component: FixtureB }); +registerView({ id: "fix-content", label: "Content", component: FixtureC, panelEligible: false }); + +function leaf(view: string): { kind: "leaf"; view: string } { + return { kind: "leaf", view }; +} + +/** split[ stack[leaf a (grow), leaf content], collapsible panels[leaf b] ] */ +function defaultTestTree(): LayoutTree { + return { + version: 1, + root: { + kind: "split", + id: "root", + dir: "row", + children: [ + { + size: 1, + node: { + kind: "stack", + id: "main", + children: [{ grow: 1, node: leaf("fix-a") }, { node: leaf("fix-content") }], + }, + }, + { + size: 0, + basis: 320, + collapsible: true, + node: { kind: "panels", id: "side", panels: [leaf("fix-b")] }, + }, + ], + }, + }; +} + +/** A bare panels root — panel EDITS without the collapsible chrome in the way. */ +function panelsOnlyTree(...views: string[]): LayoutTree { + return { version: 1, root: { kind: "panels", id: "side", panels: views.map(leaf) } }; +} + +/** The default test tree WITH the diagnostic markers on its two root tiles. */ +function markedTestTree(): LayoutTree { + return { + version: 1, + root: { + kind: "split", + id: "root", + dir: "row", + children: [ + { + size: 1, + node: { + kind: "stack", + id: "main", + marker: "main", + children: [{ grow: 1, node: leaf("fix-a") }], + }, + }, + { + size: 0, + basis: 320, + collapsible: true, + node: { kind: "panels", id: "side", marker: "side", panels: [leaf("fix-b")] }, + }, + ], + }, + }; +} + +function sidePanelsOf(tree: LayoutTree | null): string[] { + if (tree === null) return []; + const side = (tree.root as SplitNode).children?.[1]?.node; + return side?.kind === "panels" + ? side.panels.map((p) => p.view) + : tree.root.kind === "panels" + ? tree.root.panels.map((p) => p.view) + : []; +} + +describe("TileRoot", () => { + it("renders every node kind: split, stack, panels, leaf", () => { + // A FRESH collapse store per test — the store is stateful chrome. + render(TileRoot, { + props: { tree: defaultTestTree(), collapse: createCollapseStore({ viewportWidth: 1024 }) }, + }); + // Stack children mount their registered components. + expect(screen.getByTestId("fixture-a")).toBeInTheDocument(); + expect(screen.getByTestId("fixture-c")).toBeInTheDocument(); + // The panels node renders its panel leaf + the dropdown. + expect(screen.getByTestId("fixture-b")).toBeInTheDocument(); + expect(screen.getByRole("combobox", { name: "Select a view" })).toBeInTheDocument(); + }); + + it("shows a fallback tile (never a crash) for an unknown view id", () => { + const tree: LayoutTree = { + version: 1, + root: { kind: "leaf", view: "not-registered-anywhere" }, + }; + render(TileRoot, { props: { tree, collapse: createCollapseStore() } }); + const fallback = screen.getByTestId("fallback-tile"); + expect(fallback).toHaveTextContent("not-registered-anywhere"); + expect(fallback).toHaveTextContent("View not registered."); + }); + + it("renders nothing for the titlebar slot while it is unset (D-L5)", () => { + const { container } = render(TileRoot, { + props: { tree: defaultTestTree(), collapse: createCollapseStore() }, + }); + expect(container.querySelectorAll("[data-testid='fallback-tile']")).toHaveLength(0); + }); + + it("a set titlebar renders its node (the slot works; nothing populates it today)", () => { + const tree: LayoutTree = { + version: 1, + titlebar: { kind: "leaf", view: "fix-a" }, + root: { kind: "leaf", view: "fix-b" }, + }; + render(TileRoot, { props: { tree, collapse: createCollapseStore() } }); + expect(screen.getByTestId("fixture-a")).toBeInTheDocument(); + expect(screen.getByTestId("fixture-b")).toBeInTheDocument(); + }); + + it("the collapsible child reflects the collapse store and toggles through it", async () => { + const collapse = createCollapseStore({ viewportWidth: 1024 }); + const { container } = render(TileRoot, { props: { tree: defaultTestTree(), collapse } }); + const aside = container.querySelector<HTMLElement>("[data-testid='collapsible-child']"); + expect(aside).not.toBeNull(); + // Wide viewport default: open → full basis width. + expect(aside).toHaveAttribute("data-open", "true"); + expect(aside?.style.width).toBe("320px"); + + await collapse.toggle("side"); + expect(aside).toHaveAttribute("data-open", "false"); + expect(aside?.style.width).toBe("0px"); + }); + + it("a dropdown swap emits replaceView and the owner receives the changed tree", async () => { + const user = userEvent.setup(); + let changed: LayoutTree | null = null; + render(TileRoot, { + props: { + tree: panelsOnlyTree("fix-b"), + collapse: createCollapseStore(), + onTreeChange: (t) => (changed = t), + }, + }); + + await user.selectOptions(screen.getByRole("combobox", { name: "Select a view" }), "fix-a"); + expect(sidePanelsOf(changed)).toEqual(["fix-a"]); + // TileRoot is a CONTROLLED component: applying the op is the owner's job + // (App re-renders with the new tree; the test doesn't feed it back). + }); + + it("the + button emits insertPanel pre-selecting the first eligible view", async () => { + const user = userEvent.setup(); + let changed: LayoutTree | null = null; + render(TileRoot, { + props: { + tree: panelsOnlyTree("fix-b"), + collapse: createCollapseStore(), + onTreeChange: (t) => (changed = t), + }, + }); + + await user.click(screen.getByRole("button", { name: "Add view" })); + expect(sidePanelsOf(changed)).toEqual(["fix-b", "fix-a"]); + }); + + it("the ✕ button emits removeNode for that panel (absent on the first panel)", async () => { + const user = userEvent.setup(); + let changed: LayoutTree | null = null; + render(TileRoot, { + props: { + tree: panelsOnlyTree("fix-b", "fix-a"), + collapse: createCollapseStore(), + onTreeChange: (t) => (changed = t), + }, + }); + + // Only panels after the first can be removed — like ViewSidebar today. + const removeButtons = screen.getAllByRole("button", { name: "Remove view" }); + expect(removeButtons).toHaveLength(1); + await user.click(removeButtons[0] as HTMLElement); + expect(sidePanelsOf(changed)).toEqual(["fix-b"]); + }); + + it("an invalid edit is refused: the first panel has no remove button", () => { + const tree = panelsOnlyTree("fix-b"); + let changed: LayoutTree | null = null; + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + render(TileRoot, { + props: { tree, collapse: createCollapseStore(), onTreeChange: (t) => (changed = t) }, + }); + expect(screen.queryByRole("button", { name: "Remove view" })).toBeNull(); + expect(changed).toBeNull(); + } finally { + consoleError.mockRestore(); + } + }); + + it("a stack child with grow renders in a flex-grown wrapper; others natural", () => { + const { container } = render(TileRoot, { + props: { tree: defaultTestTree(), collapse: createCollapseStore() }, + }); + const grown = container.querySelector("[data-testid='fixture-a']")?.closest(".min-h-0"); + expect(grown?.getAttribute("style")).toContain("flex: 1 1 0%"); + const natural = container.querySelector("[data-testid='fixture-c']")?.closest(".min-h-0"); + expect(natural?.getAttribute("style")).toContain("flex: 0 0 auto"); + }); + + it("the stack column is full-height so a grow child fills it even when empty", () => { + // jsdom can't compute flex layout — the honest seam is the CSS class. + // WITHOUT `h-full` the stack column's height is content-based, so the + // grow child's `flex: 1 1 0%` has no space to fill: an EMPTY chat leaf + // collapses to ~0 and the composer below it rides to the top (instead of + // staying pinned at the bottom of the column). + const { container } = render(TileRoot, { + props: { tree: defaultTestTree(), collapse: createCollapseStore() }, + }); + const stackColumn = container.querySelector("[data-testid='fixture-a']")?.closest(".flex-col"); + expect(stackColumn).toHaveClass("h-full"); + }); + + it("a marked stack/panels node draws its diagnostic outline (main red, side green)", () => { + // The inline outline IS the honest seam: marker values arrive in runtime + // tree data (Tailwind can't see them — dynamic classes would silently + // render nothing), so the renderer applies an inline style, and the test + // asserts exactly what lands on the tile wrapper. + const { container } = render(TileRoot, { + props: { tree: markedTestTree(), collapse: createCollapseStore({ viewportWidth: 1024 }) }, + }); + const stack = container.querySelector("[data-testid='fixture-a']")?.closest(".flex-col"); + expect(stack?.getAttribute("style")).toContain("outline: 4px solid red"); + // The SIDE marker frames the SCROLL CONTAINER — the sidebar's fixed, + // opaquely-painted viewport (the aside's bg is transparent; this div's + // bg-base-100 would cover an outline drawn on the aside). The panels + // root is its SCROLLED content, so an outline there would wrap the + // whole scrollable region and scroll along with it. + const viewport = container.querySelector("[data-testid='sidebar-scroll-viewport']"); + expect(viewport?.getAttribute("style")).toContain("outline: 4px solid green"); + }); + + it("outlines are INSET so they draw inside the tile's box (never clipped at screen edges)", () => { + // Tiles span their containers edge-to-edge, so a default (outside) + // outline overflows the visible area and is clipped away — the offset + // pulls the ring inside the box. Pin it on both marked tiles. + const { container } = render(TileRoot, { + props: { tree: markedTestTree(), collapse: createCollapseStore({ viewportWidth: 1024 }) }, + }); + const stack = container.querySelector("[data-testid='fixture-a']")?.closest(".flex-col"); + expect(stack?.getAttribute("style")).toContain("outline-offset: -4px"); + const viewport = container.querySelector("[data-testid='sidebar-scroll-viewport']"); + expect(viewport?.getAttribute("style")).toContain("outline-offset: -4px"); + }); + + it("the panels CONTENT carries no outline (it scrolls — the marker must not scroll with it)", () => { + const { container } = render(TileRoot, { + props: { tree: markedTestTree(), collapse: createCollapseStore({ viewportWidth: 1024 }) }, + }); + const panels = container.querySelector("[data-testid='fixture-b']")?.closest("[data-panels]"); + expect(panels?.getAttribute("style") ?? "").not.toContain("outline"); + }); + + it("an unmarked tree draws NO outlines (trees without the field render exactly as before)", () => { + const { container } = render(TileRoot, { + props: { tree: defaultTestTree(), collapse: createCollapseStore() }, + }); + expect(container.querySelectorAll("[style*='outline']")).toHaveLength(0); + }); + + it("an unknown marker value draws nothing (the renderer ignores what it doesn't know)", () => { + // Both root tiles marked with a value the renderer doesn't know. + const tree = markedTestTree(); + const root = tree.root; + if (root.kind !== "split") return; + const junked = root.children.map((child) => ({ + ...child, + node: { ...child.node, marker: "purple" } as unknown as typeof child.node, + })); + const marked: LayoutTree = { version: 1, root: { ...root, children: junked } }; + const { container } = render(TileRoot, { + props: { tree: marked, collapse: createCollapseStore({ viewportWidth: 1024 }) }, + }); + expect(container.querySelectorAll("[style*='outline']")).toHaveLength(0); + }); + + it("a marked LEAF draws its outline around a wrapper (the only wrapperless node kind)", () => { + const tree: LayoutTree = { + version: 1, + root: { kind: "leaf", view: "fix-a", marker: "side" }, + }; + const { container } = render(TileRoot, { props: { tree, collapse: createCollapseStore() } }); + const fixture = container.querySelector("[data-testid='fixture-a']"); + expect(fixture?.parentElement?.getAttribute("style")).toContain("outline: 4px solid green"); + }); + + it("the dropdown lists panel-eligible views only (decision 5)", () => { + render(TileRoot, { props: { tree: panelsOnlyTree("fix-b"), collapse: createCollapseStore() } }); + const select = screen.getByRole("combobox", { name: "Select a view" }); + const labels = [...select.querySelectorAll("option")].map((o) => o.textContent); + expect(labels).toContain("Alpha View"); + expect(labels).toContain("Beta View"); + expect(labels).not.toContain("Content"); + }); + + it("an unregistered view id inside a panel renders the fallback (not a crash)", () => { + render(TileRoot, { + props: { tree: panelsOnlyTree("ghost-view"), collapse: createCollapseStore() }, + }); + expect(screen.getByTestId("fallback-tile")).toBeInTheDocument(); + }); +}); + +describe("TileRoot registry cleanup", () => { + it("unregisters the fixture views (the registry is module-global per test file)", () => { + unregisterView("fix-a"); + unregisterView("fix-b"); + unregisterView("fix-content"); + const tree: LayoutTree = { version: 1, root: { kind: "leaf", view: "fix-a" } }; + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + render(TileRoot, { props: { tree, collapse: createCollapseStore() } }); + // Unregistered → the fallback tile, not a crash. + expect(screen.getByTestId("fallback-tile")).toBeInTheDocument(); + } finally { + consoleError.mockRestore(); + } + }); +}); + +describe("TilePanels — panel identity + height animation", () => { + const wrappers = (): HTMLElement[] => [ + ...document.querySelectorAll<HTMLElement>("[data-panels] > [data-panel-key]"), + ]; + + it("closing the MIDDLE panel removes that panel's DOM, keeping the others' nodes", async () => { + const { rerender } = render(TileRoot, { + props: { tree: panelsOnlyTree("fix-b", "fix-a", "fix-b"), collapse: createCollapseStore() }, + }); + const [first, , third] = wrappers(); + await rerender({ tree: panelsOnlyTree("fix-b", "fix-b"), collapse: createCollapseStore() }); + // Identity, not position: the third panel's node survived (index keying + // would have reused the middle node and dropped the last one). + expect(wrappers()).toEqual([first, third]); + expect(screen.queryByTestId("fixture-a")).toBeNull(); + }); + + it("✕ on one of two IDENTICAL views removes exactly that panel", async () => { + const user = userEvent.setup(); + const tree = panelsOnlyTree("fix-b", "fix-a", "fix-a"); + const { rerender } = render(TileRoot, { props: { tree, collapse: createCollapseStore() } }); + const [first, , third] = wrappers(); + // Click the MIDDLE panel's ✕ (the first panel has none), then the owner + // applies the edit — the two fix-a panels are indistinguishable by data. + const [middleClose] = screen.getAllByRole("button", { name: "Remove view" }); + await user.click(middleClose as HTMLElement); + await rerender({ tree: panelsOnlyTree("fix-b", "fix-a"), collapse: createCollapseStore() }); + expect(wrappers()).toEqual([first, third]); + }); + + it("with motion allowed, a closed panel stays in flow while it shrinks out", async () => { + vi.stubGlobal( + "matchMedia", + vi.fn(() => ({ matches: false }) as unknown as MediaQueryList), + ); + try { + const { rerender } = render(TileRoot, { + props: { tree: panelsOnlyTree("fix-b", "fix-a"), collapse: createCollapseStore() }, + }); + const closing = wrappers()[1]; + await rerender({ tree: panelsOnlyTree("fix-b"), collapse: createCollapseStore() }); + // jsdom's stubbed Element.animate never finishes, so the outro holds. + expect(closing?.isConnected).toBe(true); + expect(gsap.getTweensOf(closing as HTMLElement)).toHaveLength(1); + } finally { + vi.unstubAllGlobals(); + } + }); +}); diff --git a/src/features/layout/ui/TileSplit.svelte b/src/features/layout/ui/TileSplit.svelte new file mode 100644 index 0000000..84fa075 --- /dev/null +++ b/src/features/layout/ui/TileSplit.svelte @@ -0,0 +1,130 @@ +<script lang="ts"> + /** + * A flex row/column of child subtrees. Child sizing comes from the tree: + * `size` → flex-grow (+ optional `basis`), and `collapsible` children get + * today's EXACT sidebar behavior — width/translate transition, `lg:relative` + * in-flow push vs `max-lg:absolute` overlay with a backdrop. Collapse state + * is chrome (per-node-id), owned by the injected collapse store. + */ + import type { LayoutOp, Path, SplitNode, TileMarker } from "@dispatch/layout-contract"; + import type { CollapseStore } from "../logic/collapse"; + import { markerStyle } from "../logic/marker"; + import TileNode from "./TileNode.svelte"; + + let { + node, + path, + ports, + collapse, + onEdit, + }: { + node: SplitNode; + path: Path; + ports?: unknown; + collapse: CollapseStore; + onEdit: (ops: readonly LayoutOp[]) => void; + } = $props(); + + const state = $derived($collapse); + + /** Collapse state key: the node's id when present, else its path (stable enough for chrome). */ + function keyOf(i: number, id: string | undefined): string { + return id ?? [...path, i].map((s) => String(s)).join(":"); + } + + function isOpen(i: number, id: string | undefined): boolean { + const key = keyOf(i, id); + return state.openByNode[key] ?? state.defaultOpen; + } + + /** + * The scroll container's inline style: the fixed sidebar width + slide + * transform + any diagnostic marker (inset) — none when unmarked. + * + * The marker goes on THIS div, not the aside: the inner div paints an + * OPAQUE full-height background (bg-base-100) that would cover an outline + * drawn on the aside. This div's own box never moves — only its contents + * scroll — and overflow-y-auto clips descendants, never an element's own + * outline, so the marker frames exactly the visible sidebar. + */ + function scrollViewportStyle( + basis: number | undefined, + open: boolean, + marker: TileMarker | undefined, + ): string { + const parts = [`width: ${basis ?? 320}px`, `transform: translateX(${open ? "0" : "100%"})`]; + const markerCss = markerStyle(marker); + if (markerCss !== undefined) parts.push(markerCss); + return parts.join("; "); + } +</script> + +{#if node.dir === "col"} + <div class="flex min-h-0 w-full flex-col" style={markerStyle(node.marker)}> + {#each node.children as child, i (i)} + {@const key = keyOf(i, child.node.id)} + <div + class="min-h-0 min-w-0" + style={"flex: " + child.size + " 1 " + (child.basis === undefined ? "0%" : child.basis + "px")} + > + <TileNode node={child.node} path={[...path, i]} {ports} {collapse} {onEdit} /> + </div> + {/each} + </div> +{:else} + <div class="flex min-h-0 h-full flex-row" style={markerStyle(node.marker)}> + {#each node.children as child, i (i)} + {@const key = keyOf(i, child.node.id)} + {#if child.collapsible} + {@const open = isOpen(i, child.node.id)} + <!-- + A diagnostic marker on a collapsible child is drawn on the SCROLL + CONTAINER below — the sidebar's fixed, opaquely-painted viewport — + NOT on the child's own root element: that root renders INSIDE the + scroll container, so an outline there would wrap the full + scrollable content and scroll along with it instead of framing the + visible panel. + --> + <!-- svelte-ignore a11y_no_static_element_interactions --> + <!-- Viewport padding: top/right/bottom always; LEFT only in overlay mode + (< lg) — inline (≥ lg) the chat column's gutter already separates the + views, but the overlay's left edge is the panel's own edge. --> + <aside + class="flex shrink-0 flex-col overflow-x-hidden transition-[width] duration-300 ease-out max-lg:absolute max-lg:inset-y-0 max-lg:right-0 max-lg:z-30 lg:relative" + style="width: {open ? (child.basis ?? 320) : 0}px" + data-testid="collapsible-child" + data-open={open} + > + <div + class="flex h-full flex-col gap-2 overflow-y-auto bg-base-100 pt-3 pr-3 pb-3 max-lg:pl-3 transition-transform duration-300 ease-out" + style={scrollViewportStyle(child.basis, open, child.node.marker)} + data-testid="sidebar-scroll-viewport" + > + <TileNode node={child.node} path={[...path, i]} {ports} {collapse} {onEdit} /> + </div> + </aside> + {#if open} + <!-- Backdrop: only on narrow screens (overlay mode), click to close. --> + <!-- svelte-ignore a11y_no_static_element_interactions --> + <div + class="fixed inset-0 z-20 bg-black/30 lg:hidden" + role="button" + tabindex="0" + aria-label="Close sidebar" + onclick={() => collapse.setOpen(key, false)} + onkeydown={(e) => { + if (e.key === "Escape" || e.key === "Enter") collapse.setOpen(key, false); + }} + ></div> + {/if} + {:else} + <div + class="min-h-0 min-w-0" + style={"flex: " + child.size + " 1 " + (child.basis === undefined ? "0%" : child.basis + "px")} + > + <TileNode node={child.node} path={[...path, i]} {ports} {collapse} {onEdit} /> + </div> + {/if} + {/each} + </div> +{/if} diff --git a/src/features/layout/ui/TileStack.svelte b/src/features/layout/ui/TileStack.svelte new file mode 100644 index 0000000..c8f8cae --- /dev/null +++ b/src/features/layout/ui/TileStack.svelte @@ -0,0 +1,42 @@ +<script lang="ts"> + /** + * A plain vertical container (today's left column). `grow` children flex + * (`flex: grow 1 0%` — the chat region scrolls internally, exactly like + * today's `flex-1`); children without grow take natural height. + * + * `h-full` keeps the container's main size DEFINITE (the parent is the + * split-child wrapper, cross-stretched to the full-height split row). With + * an indefinite height, `flex: 1 1 0%` degenerates to content sizing — an + * EMPTY grow child collapses to ~0 and the siblings below it (composer) + * ride to the top instead of staying pinned at the bottom. + */ + import type { LayoutOp, Path, StackNode } from "@dispatch/layout-contract"; + import type { CollapseStore } from "../logic/collapse"; + import { markerStyle } from "../logic/marker"; + import TileNode from "./TileNode.svelte"; + + let { + node, + path, + ports, + collapse, + onEdit, + }: { + node: StackNode; + path: Path; + ports?: unknown; + collapse: CollapseStore; + onEdit: (ops: readonly LayoutOp[]) => void; + } = $props(); +</script> + +<div + class="flex h-full min-h-0 min-w-0 flex-col overflow-hidden" + style={markerStyle(node.marker)} +> + {#each node.children as child, i (i)} + <div class="min-h-0 min-w-0" style={"flex: " + (child.grow ?? 0) + (child.grow === undefined ? " 0 auto" : " 1 0%")}> + <TileNode node={child.node} path={[...path, i]} {ports} {collapse} {onEdit} /> + </div> + {/each} +</div> diff --git a/src/features/layout/ui/fixtures/FixtureA.svelte b/src/features/layout/ui/fixtures/FixtureA.svelte new file mode 100644 index 0000000..e55ba2a --- /dev/null +++ b/src/features/layout/ui/fixtures/FixtureA.svelte @@ -0,0 +1,6 @@ +<script lang="ts"> + // A fixture view for renderer tests — ignores any props it receives. + let { label = "Alpha" }: { label?: string } = $props(); +</script> + +<div data-testid="fixture-a">{label}</div> diff --git a/src/features/layout/ui/fixtures/FixtureB.svelte b/src/features/layout/ui/fixtures/FixtureB.svelte new file mode 100644 index 0000000..1c48584 --- /dev/null +++ b/src/features/layout/ui/fixtures/FixtureB.svelte @@ -0,0 +1,6 @@ +<script lang="ts"> + // A fixture view for renderer tests — ignores any props it receives. + let { label = "Beta" }: { label?: string } = $props(); +</script> + +<div data-testid="fixture-b">{label}</div> diff --git a/src/features/layout/ui/fixtures/FixtureC.svelte b/src/features/layout/ui/fixtures/FixtureC.svelte new file mode 100644 index 0000000..2bceb67 --- /dev/null +++ b/src/features/layout/ui/fixtures/FixtureC.svelte @@ -0,0 +1,6 @@ +<script lang="ts"> + // A fixture view for renderer tests — ignores any props it receives. + let { label = "Gamma" }: { label?: string } = $props(); +</script> + +<div data-testid="fixture-c">{label}</div> diff --git a/src/features/layout/ui/panel-motion.ts b/src/features/layout/ui/panel-motion.ts new file mode 100644 index 0000000..7fc5edf --- /dev/null +++ b/src/features/layout/ui/panel-motion.ts @@ -0,0 +1,83 @@ +import { gsap } from "gsap"; +import type { TransitionConfig } from "svelte/transition"; + +/** + * GSAP height animations for sidebar panels — the DOM EFFECT half (stable + * panel identity lives in `../logic/panel-keys.ts`). + * + * Every animation changes ONLY the panel wrapper's height (clipped with + * `overflow: hidden`); the panel's contents keep their natural size the whole + * time — the wrapper reveals / hides them like a drawer, it never scales + * them. The wrapper's bottom padding is the gap between panels, so it grows / + * shrinks with the panel and the panels below slide smoothly. + * + * - `panelGrowIn` — Svelte `in:` transition: an added panel grows 0 → auto. + * - `panelShrinkOut` — Svelte `out:` transition: a closed panel shrinks to 0 + * while staying in flow (Svelte keeps the node for the returned duration). + * - `morphPanelHeight` — a swapped view: the panel BOX (the well, so its + * edges move with it — no empty gap below a shrinking box) animates from + * the old view's height to the new one's. + * + * Svelte's `in:` does not run on first mount, so loading the page never + * animates. Disabled when the user prefers reduced motion (or the + * environment can't tell, e.g. jsdom) — then every change is instant. + */ + +const DURATION = 0.35; +const EASE = "power2.inOut"; + +export function panelMotionAllowed(): boolean { + return ( + typeof window !== "undefined" && + typeof window.matchMedia === "function" && + !window.matchMedia("(prefers-reduced-motion: reduce)").matches + ); +} + +export function panelGrowIn(node: HTMLElement): TransitionConfig { + if (!panelMotionAllowed()) return { duration: 0 }; + gsap.killTweensOf(node); + gsap.fromTo( + node, + { height: 0, overflow: "hidden" }, + { + height: "auto", + duration: DURATION, + ease: EASE, + clearProps: "height,overflow", + }, + ); + return { duration: DURATION * 1000 }; +} + +export function panelShrinkOut(node: HTMLElement): TransitionConfig { + if (!panelMotionAllowed()) return { duration: 0 }; + // e.g. closed mid-grow / mid-swap: shrink from wherever it is. + gsap.killTweensOf([node, ...node.querySelectorAll(":scope > *")]); + gsap.to(node, { + height: 0, + overflow: "hidden", + duration: DURATION, + ease: EASE, + }); + return { duration: DURATION * 1000 }; +} + +/** + * Animate `node` from `fromHeight` (its height before the view swapped) to + * its new natural height. Call AFTER the DOM shows the new view. + */ +export function morphPanelHeight(node: HTMLElement, fromHeight: number): void { + if (!panelMotionAllowed()) return; + gsap.killTweensOf(node); + gsap.fromTo( + node, + { height: fromHeight, overflow: "hidden" }, + { + height: "auto", + duration: DURATION, + ease: EASE, + clearProps: "height,overflow", + }, + ); +} |
