diff options
Diffstat (limited to 'src/features')
| -rw-r--r-- | src/features/layout/adapter/abi-port.test.ts | 96 | ||||
| -rw-r--r-- | src/features/layout/adapter/abi-port.ts | 60 | ||||
| -rw-r--r-- | src/features/layout/adapter/http.test.ts | 45 | ||||
| -rw-r--r-- | src/features/layout/adapter/http.ts | 62 | ||||
| -rw-r--r-- | src/features/layout/index.ts | 6 | ||||
| -rw-r--r-- | src/features/layout/store.svelte.ts | 25 | ||||
| -rw-r--r-- | src/features/layout/store.test.ts | 71 | ||||
| -rw-r--r-- | src/features/plugins/index.ts | 1 | ||||
| -rw-r--r-- | src/features/plugins/logic/types.ts | 22 | ||||
| -rw-r--r-- | src/features/plugins/ui/ResetLayoutConfirm.svelte | 30 | ||||
| -rw-r--r-- | src/features/plugins/ui/WorkspacePluginModal.svelte | 56 | ||||
| -rw-r--r-- | src/features/plugins/ui/WorkspacePluginModal.test.ts | 81 | ||||
| -rw-r--r-- | src/features/workspaces/ui/WorkspaceCard.svelte | 10 | ||||
| -rw-r--r-- | src/features/workspaces/ui/WorkspacesHome.svelte | 10 |
14 files changed, 568 insertions, 7 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..d11bf9e --- /dev/null +++ b/src/features/layout/adapter/abi-port.test.ts @@ -0,0 +1,96 @@ +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(), + ...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 index 1801ca6..cc7d7e9 100644 --- a/src/features/layout/adapter/http.test.ts +++ b/src/features/layout/adapter/http.test.ts @@ -135,3 +135,48 @@ describe("createLayoutHttp — deleteTree", () => { 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 index 2f91e43..ff15a70 100644 --- a/src/features/layout/adapter/http.ts +++ b/src/features/layout/adapter/http.ts @@ -1,11 +1,15 @@ -import type { LayoutTree } from "@dispatch/layout-contract"; +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; plan 04 adds the UI) + * - `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 @@ -22,6 +26,13 @@ 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. */ @@ -113,5 +124,52 @@ export function createLayoutHttp(httpBase: string, fetchImpl: typeof fetch): Lay }; } }, + + 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 index 6a17589..8a464fe 100644 --- a/src/features/layout/index.ts +++ b/src/features/layout/index.ts @@ -7,6 +7,12 @@ * 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, diff --git a/src/features/layout/store.svelte.ts b/src/features/layout/store.svelte.ts index 3d7de3f..5fdf100 100644 --- a/src/features/layout/store.svelte.ts +++ b/src/features/layout/store.svelte.ts @@ -54,6 +54,16 @@ export interface LayoutStore { 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; } @@ -260,6 +270,21 @@ export function createLayoutStore(opts: LayoutStoreOptions): LayoutStore { }, 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 diff --git a/src/features/layout/store.test.ts b/src/features/layout/store.test.ts index 1d4cdf0..73850ed 100644 --- a/src/features/layout/store.test.ts +++ b/src/features/layout/store.test.ts @@ -1,4 +1,9 @@ -import { DEFAULT_LAYOUT, type LayoutTree } from "@dispatch/layout-contract"; +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"; @@ -15,8 +20,11 @@ import { createLayoutStore } from "./store.svelte"; 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( @@ -32,11 +40,18 @@ function fakeHttp( ): 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) { @@ -47,8 +62,12 @@ function fakeHttp( putCalls.push({ workspaceId, tree }); return fake.putResult(workspaceId, tree); }, - async deleteTree() { - return { ok: false, notFound: true, error: "unused" }; + async deleteTree(workspaceId) { + deleteCalls.push(workspaceId); + return fake.deleteResult(workspaceId); + }, + async postOps(workspaceId, ops) { + return fake.opsResult(workspaceId, ops); }, }; return fake; @@ -424,3 +443,49 @@ describe("layout store — one-way localStorage migration (§2.7)", () => { ]); }); }); + +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/plugins/index.ts b/src/features/plugins/index.ts index 7a9d1a0..f79ed98 100644 --- a/src/features/plugins/index.ts +++ b/src/features/plugins/index.ts @@ -13,6 +13,7 @@ export { autoCheckBackendPrereqs } from "./logic/prereqs"; export type { PluginNode, PluginSection, + WorkspaceLayoutPort, } from "./logic/types"; export { TIER_ORDER, tierLabel } from "./logic/types"; export { groupByTier, pluginNodes, splitSections, withFrontendStates } from "./logic/view-model"; diff --git a/src/features/plugins/logic/types.ts b/src/features/plugins/logic/types.ts index d5fa4bb..ec80c9b 100644 --- a/src/features/plugins/logic/types.ts +++ b/src/features/plugins/logic/types.ts @@ -51,6 +51,28 @@ export function tierLabel(tier: PluginTier): string { export const TIER_ORDER: readonly PluginTier[] = ["core", "providers", "standard"]; /** + * The layout port (plan 04 §4.4) the workspace plugins modal needs for the + * "Reset layout to default" affordance — the modal is the natural home (it + * is where users manage the layout plugins themselves). Wired by the + * composition root over the layout store; absent → the section doesn't + * render (tests). + */ +export interface WorkspaceLayoutPort { + /** + * Whether a layout provider is active (the reset button's gate). False → + * the button is DISABLED with the reason shown ("no layout plugin + * enabled") — there is nothing to reset and no persistence to re-seed. + */ + readonly providerActive: boolean; + /** + * Reset the workspace's layout to the default seed (`DELETE + * /workspaces/:id/layout` → re-seed → broadcast). Resolves to `null` on + * success or to the failure reason to display. + */ + readonly reset: (workspaceId: string) => Promise<string | null>; +} + +/** * Runtime-normalize a tier value to the canonical THREE: anything that is not * exactly `core` | `providers` | `standard` — a missing tier, or a value * outside the contract ("kernel" is NOT a tier; the kernel hosts plugins, it diff --git a/src/features/plugins/ui/ResetLayoutConfirm.svelte b/src/features/plugins/ui/ResetLayoutConfirm.svelte new file mode 100644 index 0000000..1f26601 --- /dev/null +++ b/src/features/plugins/ui/ResetLayoutConfirm.svelte @@ -0,0 +1,30 @@ +<script lang="ts"> + /** + * The "Reset layout to default?" confirm (plan 04 §4.4) — the dialog copy + * names the consequence exactly: this workspace's layout returns to the + * default; plugin and dropdown customizations are lost (there is NO + * undo — the no-revert semantics make the reset the recovery path). + */ + let { + onConfirm, + onCancel, + }: { + onConfirm: () => void; + onCancel: () => void; + } = $props(); +</script> + +<div class="modal modal-open" role="dialog" aria-label="Reset layout to default?"> + <div class="modal-box max-w-md"> + <h3 class="text-lg font-bold text-warning">Reset layout to default?</h3> + <p class="py-3 text-sm"> + This workspace's layout returns to the default; plugin and dropdown customizations are lost. + </p> + <div class="modal-action"> + <button type="button" class="btn btn-ghost btn-sm" onclick={onCancel}>Cancel</button> + <button type="button" class="btn btn-warning btn-sm" onclick={onConfirm}>Reset</button> + </div> + </div> + <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions --> + <div class="modal-backdrop" onclick={onCancel}></div> +</div> diff --git a/src/features/plugins/ui/WorkspacePluginModal.svelte b/src/features/plugins/ui/WorkspacePluginModal.svelte index bce885b..d1bb27a 100644 --- a/src/features/plugins/ui/WorkspacePluginModal.svelte +++ b/src/features/plugins/ui/WorkspacePluginModal.svelte @@ -2,17 +2,25 @@ import { onMount } from "svelte"; import type { PluginsStore } from "../store.svelte"; import { rowBlockReason, rowChecked, rowSelectable, stateLabel } from "../logic/workspace-row"; + import type { WorkspaceLayoutPort } from "../logic/types"; + import ResetLayoutConfirm from "./ResetLayoutConfirm.svelte"; let { workspaceId, workspaceTitle, plugins, + layout, onClose, }: { workspaceId: string; /** The workspace's display title (modal heading). */ workspaceTitle: string; plugins: PluginsStore; + /** + * The layout port (plan 04 §4.4) — gates + powers the "Reset layout to + * default" affordance. Absent → the section doesn't render (tests). + */ + layout?: WorkspaceLayoutPort; onClose: () => void; } = $props(); @@ -31,6 +39,21 @@ async function toggle(pluginId: string, checked: boolean): Promise<void> { await plugins.setWorkspaceState(workspaceId, pluginId, checked ? "enabled" : "disabled"); } + + // ── The reset affordance (plan 04 §4.4) ───────────────────────────────── + let resetOpen = $state(false); + let resetting = $state(false); + let resetError = $state<string | null>(null); + + async function confirmReset(): Promise<void> { + if (layout === undefined || resetting) return; + resetting = true; + resetError = null; + const failure = await layout.reset(workspaceId); + resetting = false; + resetOpen = false; + if (failure !== null) resetError = failure; + } </script> <div class="modal modal-open" role="dialog" aria-label={`Plugins for ${workspaceTitle}`}> @@ -101,6 +124,35 @@ </table> {/if} + {#if layout} + <div class="mt-3 border-t border-base-300 pt-3"> + <div class="flex items-center justify-between gap-3"> + <div> + <p class="text-sm font-medium">Layout</p> + {#if layout.providerActive} + <p class="text-xs opacity-60">Managed by the active layout plugin.</p> + {:else} + <p class="text-xs opacity-60">No layout plugin enabled — no layout to reset.</p> + {/if} + </div> + <button + type="button" + class="btn btn-warning btn-sm" + disabled={!layout.providerActive || resetting} + title={layout.providerActive + ? "Restore this workspace's default layout" + : "No layout plugin enabled"} + onclick={() => (resetOpen = true)} + > + Reset layout to default + </button> + </div> + {#if resetError} + <p class="mt-1 text-xs text-error" role="alert">Resetting the layout failed: {resetError}</p> + {/if} + </div> + {/if} + <div class="modal-action"> <button type="button" class="btn btn-sm" onclick={onClose}>Close</button> </div> @@ -108,3 +160,7 @@ <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions --> <div class="modal-backdrop" onclick={onClose}></div> </div> + +{#if resetOpen && layout} + <ResetLayoutConfirm onConfirm={() => void confirmReset()} onCancel={() => (resetOpen = false)} /> +{/if} diff --git a/src/features/plugins/ui/WorkspacePluginModal.test.ts b/src/features/plugins/ui/WorkspacePluginModal.test.ts index 32e88d7..6bc7179 100644 --- a/src/features/plugins/ui/WorkspacePluginModal.test.ts +++ b/src/features/plugins/ui/WorkspacePluginModal.test.ts @@ -3,6 +3,7 @@ import { render, screen } from "@testing-library/svelte"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import type { PluginsHttp } from "../adapter/http"; +import type { WorkspaceLayoutPort } from "../logic/types"; import { createPluginsStore } from "../store.svelte"; import WorkspacePluginModal from "./WorkspacePluginModal.svelte"; @@ -46,6 +47,14 @@ async function renderModal(http: PluginsHttp = fakeHttp()) { return { store, http }; } +/** A layout port (plan 04 §4.4) scripted per test. */ +function fakeLayoutPort( + providerActive = true, + reset: WorkspaceLayoutPort["reset"] = vi.fn(async () => null), +): WorkspaceLayoutPort { + return { providerActive, reset }; +} + describe("WorkspacePluginModal", () => { it("renders the workspace's plugin rows", async () => { await renderModal(); @@ -122,3 +131,75 @@ describe("WorkspacePluginModal", () => { expect(screen.getByText(/other workspaces inherit these states/i)).toBeInTheDocument(); }); }); + +describe("WorkspacePluginModal — the reset-layout affordance (plan 04 §4.4)", () => { + async function renderWithLayout(layout?: ReturnType<typeof fakeLayoutPort>) { + const store = createPluginsStore(fakeHttp()); + await store.openWorkspace("ws"); + render(WorkspacePluginModal, { + props: { + workspaceId: "ws", + workspaceTitle: "My Workspace", + plugins: store, + ...(layout ? { layout } : {}), + onClose: vi.fn(), + }, + }); + return layout; + } + + it("renders an ENABLED reset button when a layout provider is active", async () => { + await renderWithLayout(fakeLayoutPort(true)); + const button = screen.getByRole("button", { name: /reset layout to default/i }); + expect(button).toBeEnabled(); + }); + + it("the button is DISABLED with the 'no layout plugin enabled' reason when no provider is active", async () => { + await renderWithLayout(fakeLayoutPort(false)); + const button = screen.getByRole("button", { name: /reset layout to default/i }); + expect(button).toBeDisabled(); + expect(button.getAttribute("title")).toMatch(/no layout plugin enabled/i); + expect(screen.getByText(/no layout plugin enabled/i)).toBeInTheDocument(); + }); + + it("no layout port → the layout section doesn't render at all (tests / no wiring)", async () => { + await renderWithLayout(undefined); + expect(screen.queryByRole("button", { name: /reset layout to default/i })).toBeNull(); + }); + + it("confirming the dialog resets the layout (and the dialog copy names the consequence)", async () => { + const user = userEvent.setup(); + const reset = vi.fn(async () => null); + await renderWithLayout(fakeLayoutPort(true, reset)); + + await user.click(screen.getByRole("button", { name: /reset layout to default/i })); + // The confirm dialog names the consequence exactly (§4.4 copy). + expect( + screen.getByText( + /layout returns to the default; plugin and dropdown customizations are lost/i, + ), + ).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: /^reset$/i })); + expect(reset).toHaveBeenCalledWith("ws"); + }); + + it("cancelling the dialog never resets", async () => { + const user = userEvent.setup(); + const reset = vi.fn(async () => null); + await renderWithLayout(fakeLayoutPort(true, reset)); + + await user.click(screen.getByRole("button", { name: /reset layout to default/i })); + await user.click(screen.getByRole("button", { name: /cancel/i })); + expect(reset).not.toHaveBeenCalled(); + }); + + it("a failed reset surfaces the reason as an alert", async () => { + const user = userEvent.setup(); + const reset = vi.fn(async () => "no layout plugin enabled"); + await renderWithLayout(fakeLayoutPort(true, reset)); + + await user.click(screen.getByRole("button", { name: /reset layout to default/i })); + await user.click(screen.getByRole("button", { name: /^reset$/i })); + expect(await screen.findByRole("alert")).toHaveTextContent(/resetting the layout failed/i); + }); +}); diff --git a/src/features/workspaces/ui/WorkspaceCard.svelte b/src/features/workspaces/ui/WorkspaceCard.svelte index 70cfda2..8c004aa 100644 --- a/src/features/workspaces/ui/WorkspaceCard.svelte +++ b/src/features/workspaces/ui/WorkspaceCard.svelte @@ -1,7 +1,7 @@ <script lang="ts"> import type { ComputerEntry, WorkspaceEntry } from "@dispatch/wire"; import { untrack } from "svelte"; - import { WorkspacePluginModal, type PluginsStore } from "../../plugins"; + import { WorkspacePluginModal, type PluginsStore, type WorkspaceLayoutPort } from "../../plugins"; import type { WorkspaceStore } from "../store.svelte"; import { relativeTime } from "../logic/view-model"; import { workspacePath } from "../logic/route"; @@ -14,6 +14,7 @@ computers, hasActive, plugins, + layout, }: { ws: WorkspaceEntry; store: WorkspaceStore; @@ -33,6 +34,12 @@ * (tests) → no button. */ plugins?: PluginsStore; + /** + * Optional layout port (plan 04 §4.4): forwarded to the plugins modal + * for the "Reset layout to default" affordance. Absent → the modal's + * layout section doesn't render. + */ + layout?: WorkspaceLayoutPort; } = $props(); // Whether at least one conversation in this workspace is currently active @@ -306,6 +313,7 @@ workspaceId={ws.id} workspaceTitle={ws.title} {plugins} + {...(layout ? { layout } : {})} onClose={() => (pluginsOpen = false)} /> {/if} diff --git a/src/features/workspaces/ui/WorkspacesHome.svelte b/src/features/workspaces/ui/WorkspacesHome.svelte index 548d733..5a4d88f 100644 --- a/src/features/workspaces/ui/WorkspacesHome.svelte +++ b/src/features/workspaces/ui/WorkspacesHome.svelte @@ -1,7 +1,7 @@ <script lang="ts"> import { onMount } from "svelte"; import type { ComputerEntry } from "@dispatch/wire"; - import { PluginManager, type PluginsStore } from "../../plugins"; + import { PluginManager, type PluginsStore, type WorkspaceLayoutPort } from "../../plugins"; import type { WorkspaceStore } from "../store.svelte"; import { isValidSlug, workspacePath } from "../logic/route"; import WorkspaceCard from "./WorkspaceCard.svelte"; @@ -12,6 +12,7 @@ computers, hasActive, plugins, + layout, }: { store: WorkspaceStore; onNavigate: (path: string) => void; @@ -29,6 +30,12 @@ * plugins) → the buttons don't render. */ plugins?: PluginsStore; + /** + * The layout port (plan 04 §4.4): forwarded to each {@link WorkspaceCard} + * for the plugins modal's "Reset layout to default" affordance. Absent → + * the modal's layout section doesn't render. + */ + layout?: WorkspaceLayoutPort; } = $props(); onMount(() => { @@ -128,6 +135,7 @@ {computers} {...(hasActive ? { hasActive } : {})} {...(plugins ? { plugins } : {})} + {...(layout ? { layout } : {})} /> {/each} </ul> |
