diff options
Diffstat (limited to 'src/features/workspaces')
33 files changed, 6216 insertions, 688 deletions
diff --git a/src/features/workspaces/adapter/http.test.ts b/src/features/workspaces/adapter/http.test.ts index 18d8939..d6409ca 100644 --- a/src/features/workspaces/adapter/http.test.ts +++ b/src/features/workspaces/adapter/http.test.ts @@ -79,15 +79,37 @@ describe("createWorkspaceHttp", () => { expect(result).toEqual({ ok: false, error: "invalid slug" }); }); - it("get returns null on 404", async () => { + it("get reports a missing lookup on 404", async () => { const http = createWorkspaceHttp(BASE, fakeFetch([{ status: 404 }])); - expect(await http.get("nope")).toBeNull(); + expect(await http.get("nope")).toEqual({ kind: "missing" }); }); it("get returns the workspace on 200", async () => { - const ws = { id: "x", title: "X", defaultCwd: "/home", createdAt: 1, lastActivityAt: 2 }; + const ws = { + id: "x", + title: "X", + defaultCwd: "/home", + defaultComputerId: null, + starred: false, + archived: false, + createdAt: 1, + lastActivityAt: 2, + }; const http = createWorkspaceHttp(BASE, fakeFetch([{ body: ws }])); - expect(await http.get("x")).toEqual(ws); + expect(await http.get("x")).toEqual({ kind: "found", workspace: ws }); + }); + + it("get distinguishes a failed response from 404 (not 'missing')", async () => { + const http = createWorkspaceHttp( + BASE, + fakeFetch([{ status: 500, body: { error: "db down" } }]), + ); + expect(await http.get("x")).toEqual({ kind: "failed", error: "db down" }); + }); + + it("get reports a network error as failed (not 'missing')", async () => { + const http = createWorkspaceHttp(BASE, fakeFetch([new Error("network")])); + expect(await http.get("x")).toEqual({ kind: "failed", error: "network" }); }); it("setTitle PUTs the title", async () => { @@ -186,4 +208,67 @@ describe("createWorkspaceHttp", () => { const result = await http.unstar("a"); expect(result).toEqual({ ok: false, error: "Failed to unstar workspace" }); }); + + it("list passes the archived filter as a query param", async () => { + const fetchImpl = fakeFetch([{ body: { workspaces: [] } }]); + const http = createWorkspaceHttp(BASE, fetchImpl); + await http.list("true"); + expect(fetchImpl).toHaveBeenCalledWith(`${BASE}/workspaces?archived=true`); + }); + + it("list omits the query param when no filter is given", async () => { + const fetchImpl = fakeFetch([{ body: { workspaces: [] } }]); + const http = createWorkspaceHttp(BASE, fetchImpl); + await http.list(); + expect(fetchImpl).toHaveBeenCalledWith(`${BASE}/workspaces`); + }); + + it("archive PUTs /archive with no body and returns the updated workspace", async () => { + const ws = { + id: "a", + title: "A", + defaultCwd: null, + defaultComputerId: null, + starred: false, + archived: true, + createdAt: 1, + lastActivityAt: 2, + }; + const fetchImpl = fakeFetch([{ body: ws }]); + const http = createWorkspaceHttp(BASE, fetchImpl); + const result = await http.archive("a"); + expect(result).toEqual({ ok: true, value: ws }); + const call = (fetchImpl as unknown as ReturnType<typeof vi.fn>).mock.calls[0]; + expect(call?.[0]).toBe(`${BASE}/workspaces/a/archive`); + expect(call?.[1]).toEqual({ method: "PUT" }); + }); + + it("unarchive DELETEs /archive with no body and returns the updated workspace", async () => { + const ws = { + id: "a", + title: "A", + defaultCwd: null, + defaultComputerId: null, + starred: false, + archived: false, + createdAt: 1, + lastActivityAt: 2, + }; + const fetchImpl = fakeFetch([{ body: ws }]); + const http = createWorkspaceHttp(BASE, fetchImpl); + const result = await http.unarchive("a"); + expect(result).toEqual({ ok: true, value: ws }); + const call = (fetchImpl as unknown as ReturnType<typeof vi.fn>).mock.calls[0]; + expect(call?.[0]).toBe(`${BASE}/workspaces/a/archive`); + expect(call?.[1]).toEqual({ method: "DELETE" }); + }); + + it("archive surfaces the backend error on a 409 (default workspace)", async () => { + const http = createWorkspaceHttp( + BASE, + fakeFetch([{ status: 409, body: { error: 'The "default" workspace cannot be archived' } }]), + ); + const result = await http.archive("default"); + expect(result).toEqual({ ok: false, error: 'The "default" workspace cannot be archived' }); + }); }); diff --git a/src/features/workspaces/adapter/http.ts b/src/features/workspaces/adapter/http.ts index 5673881..3f7f058 100644 --- a/src/features/workspaces/adapter/http.ts +++ b/src/features/workspaces/adapter/http.ts @@ -9,6 +9,7 @@ import type { WorkspaceListResponse, WorkspaceResponse, } from "@dispatch/transport-contract"; +import type { WorkspaceLookup } from "../logic/create"; /** * Workspace HTTP effects — the injected edge that talks to the backend's @@ -19,24 +20,34 @@ import type { * safe empty/null on failure (non-fatal — the UI falls back gracefully). * * Endpoints ([email protected]): - * - `GET /workspaces` → list + * - `GET /workspaces` (optional `?archived=true|false|all`, default `false` = active only) → list * - `PUT /workspaces/:id` (create-on-miss, idempotent) → ensure - * - `GET /workspaces/:id` (404 → null) → get + * - `GET /workspaces/:id` (found | 404-missing | failed) → get * - `PUT /workspaces/:id/title` → rename * - `PUT /workspaces/:id/default-cwd` → set/clear default cwd * - `PUT /workspaces/:id/default-computer` → set/clear default computer (SSH handoff #2) * - `PUT /workspaces/:id/star` (create-on-miss) → star (concurrency priority) * - `DELETE /workspaces/:id/star` (create-on-miss) → unstar + * - `PUT /workspaces/:id/archive` (create-on-miss; 409 for "default") → archive (hide from home grid) + * - `DELETE /workspaces/:id/archive` (create-on-miss; 409 for "default") → unarchive * - `DELETE /workspaces/:id` (409 for "default") → delete */ export type WorkspaceResult<T> = | { readonly ok: true; readonly value: T } | { readonly ok: false; readonly error: string }; +/** Which workspaces `GET /workspaces` returns: active-only (default), archived-only, or all. */ +export type WorkspaceArchivedFilter = "true" | "false" | "all"; + export interface WorkspaceHttp { - list(): Promise<readonly WorkspaceEntry[]>; + list(archived?: WorkspaceArchivedFilter): Promise<readonly WorkspaceEntry[]>; ensure(id: string, body?: EnsureWorkspaceRequest): Promise<WorkspaceResult<Workspace>>; - get(id: string): Promise<Workspace | null>; + /** + * Authoritative existence probe. 404 → `missing`, a network/server failure → + * `failed` (distinct on purpose: the create gate must not treat a broken + * check as "doesn't exist"), 200 → the workspace (`found`). + */ + get(id: string): Promise<WorkspaceLookup>; setTitle(id: string, title: string): Promise<WorkspaceResult<Workspace>>; setDefaultCwd(id: string, defaultCwd: string | null): Promise<WorkspaceResult<Workspace>>; setDefaultComputer(id: string, computerId: string | null): Promise<WorkspaceResult<Workspace>>; @@ -44,6 +55,10 @@ export interface WorkspaceHttp { star(id: string): Promise<WorkspaceResult<Workspace>>; /** Unstar a workspace. Create-on-miss; no body. */ unstar(id: string): Promise<WorkspaceResult<Workspace>>; + /** Archive a workspace (hide from the home grid; data untouched). Create-on-miss; no body. */ + archive(id: string): Promise<WorkspaceResult<Workspace>>; + /** Unarchive a workspace (restore to the home grid). Create-on-miss; no body. */ + unarchive(id: string): Promise<WorkspaceResult<Workspace>>; delete(id: string): Promise<WorkspaceResult<{ closedCount: number }>>; } @@ -58,9 +73,10 @@ async function errText(res: Response): Promise<string> { export function createWorkspaceHttp(httpBase: string, fetchImpl: typeof fetch): WorkspaceHttp { return { - async list(): Promise<readonly WorkspaceEntry[]> { + async list(archived?: WorkspaceArchivedFilter): Promise<readonly WorkspaceEntry[]> { try { - const res = await fetchImpl(`${httpBase}/workspaces`); + const qs = archived !== undefined ? `?archived=${archived}` : ""; + const res = await fetchImpl(`${httpBase}/workspaces${qs}`); if (!res.ok) return []; const data = (await res.json()) as WorkspaceListResponse; return data.workspaces; @@ -86,13 +102,17 @@ export function createWorkspaceHttp(httpBase: string, fetchImpl: typeof fetch): } }, - async get(id): Promise<Workspace | null> { + async get(id): Promise<WorkspaceLookup> { try { const res = await fetchImpl(`${httpBase}/workspaces/${encodeURIComponent(id)}`); - if (res.status === 404 || !res.ok) return null; - return (await res.json()) as WorkspaceResponse; - } catch { - return null; + if (res.status === 404) return { kind: "missing" }; + if (!res.ok) return { kind: "failed", error: await errText(res) }; + return { kind: "found", workspace: (await res.json()) as WorkspaceResponse }; + } catch (err) { + return { + kind: "failed", + error: err instanceof Error ? err.message : "Workspace lookup failed", + }; } }, @@ -171,6 +191,30 @@ export function createWorkspaceHttp(httpBase: string, fetchImpl: typeof fetch): } }, + async archive(id): Promise<WorkspaceResult<Workspace>> { + try { + const res = await fetchImpl(`${httpBase}/workspaces/${encodeURIComponent(id)}/archive`, { + method: "PUT", + }); + if (!res.ok) return { ok: false, error: await errText(res) }; + return { ok: true, value: (await res.json()) as WorkspaceResponse }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : "Archive failed" }; + } + }, + + async unarchive(id): Promise<WorkspaceResult<Workspace>> { + try { + const res = await fetchImpl(`${httpBase}/workspaces/${encodeURIComponent(id)}/archive`, { + method: "DELETE", + }); + if (!res.ok) return { ok: false, error: await errText(res) }; + return { ok: true, value: (await res.json()) as WorkspaceResponse }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : "Unarchive failed" }; + } + }, + async delete(id): Promise<WorkspaceResult<{ closedCount: number }>> { try { const res = await fetchImpl(`${httpBase}/workspaces/${encodeURIComponent(id)}`, { diff --git a/src/features/workspaces/adapter/tile-layout-http.ts b/src/features/workspaces/adapter/tile-layout-http.ts new file mode 100644 index 0000000..58af9d8 --- /dev/null +++ b/src/features/workspaces/adapter/tile-layout-http.ts @@ -0,0 +1,117 @@ +import type { TileGeometryMode, TileLayoutData } from "@dispatch/tile-layout-contract"; + +/** + * Home tile-layout HTTP effects — the injected edge that talks to the + * `home-tile-layout` plugin's well-known routes: + * - `GET /home/tile-layout/:mode` → `{ layout }` (a fresh orientation reads + * as the seeded empty document) + * - `PUT /home/tile-layout/:mode` body `{ layout }` → 200 `{ layout }` / + * 400 `{ error }` + * - `DELETE /home/tile-layout/:mode` → the fresh empty document (reset) + * + * Mirrors the layout adapter's pattern: `httpBase` + an injected `fetchImpl` + * (testable without the network). A 404 is NOT a "no document" signal (the + * backend never 404s a fresh orientation — it seeds): any non-2xx / network + * failure means "the routes aren't served" (an old backend) or "backend + * unreachable" — both degrade the store to its local state with ephemeral + * edits, never an error UI. + */ + +/** Outcome of a tile-layout read/write. */ +export type TileLayoutHttpResult = + | { readonly ok: true; readonly layout: TileLayoutData } + | { readonly ok: false; readonly notFound: boolean; readonly error: string }; + +export interface TileLayoutHttp { + getLayout(mode: TileGeometryMode): Promise<TileLayoutHttpResult>; + putLayout(mode: TileGeometryMode, layout: TileLayoutData): Promise<TileLayoutHttpResult>; + deleteLayout(mode: TileGeometryMode): Promise<TileLayoutHttpResult>; +} + +/** Read an error message from a non-2xx body (`{ error }`), tolerating junk. */ +async function errorOf(res: Response): Promise<string> { + try { + const body = (await res.json()) as { error?: unknown }; + if (typeof body.error === "string" && body.error.length > 0) return body.error; + } catch { + // fall through + } + return `HTTP ${res.status}`; +} + +/** A malformed 200 (e.g. an SPA-fallback page) degrades like a failure — never poisons the store. */ +function malformed(): TileLayoutHttpResult { + return { ok: false, notFound: false, error: "Malformed tile-layout response" }; +} + +function route(httpBase: string, mode: TileGeometryMode): string { + return `${httpBase}/home/tile-layout/${encodeURIComponent(mode)}`; +} + +export function createTileLayoutHttp(httpBase: string, fetchImpl: typeof fetch): TileLayoutHttp { + return { + async getLayout(mode): Promise<TileLayoutHttpResult> { + try { + const res = await fetchImpl(route(httpBase, mode)); + if (res.status === 404) { + return { ok: false, notFound: true, error: "Tile-layout routes are not served" }; + } + if (!res.ok) return { ok: false, notFound: false, error: await errorOf(res) }; + const body = (await res.json()) as { layout?: unknown }; + if (body === null || typeof body !== "object" || body.layout === undefined) + return malformed(); + return { ok: true, layout: body.layout as TileLayoutData }; + } catch (err) { + return { + ok: false, + notFound: false, + error: err instanceof Error ? err.message : "Tile-layout request failed", + }; + } + }, + + async putLayout(mode, layout): Promise<TileLayoutHttpResult> { + try { + const res = await fetchImpl(route(httpBase, mode), { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ layout }), + }); + if (res.status === 404) { + return { ok: false, notFound: true, error: "Tile-layout routes are not served" }; + } + if (!res.ok) return { ok: false, notFound: false, error: await errorOf(res) }; + const body = (await res.json()) as { layout?: unknown }; + if (body === null || typeof body !== "object" || body.layout === undefined) + return malformed(); + return { ok: true, layout: body.layout as TileLayoutData }; + } catch (err) { + return { + ok: false, + notFound: false, + error: err instanceof Error ? err.message : "Tile-layout request failed", + }; + } + }, + + async deleteLayout(mode): Promise<TileLayoutHttpResult> { + try { + const res = await fetchImpl(route(httpBase, mode), { method: "DELETE" }); + if (res.status === 404) { + return { ok: false, notFound: true, error: "Tile-layout routes are not served" }; + } + if (!res.ok) return { ok: false, notFound: false, error: await errorOf(res) }; + const body = (await res.json()) as { layout?: unknown }; + if (body === null || typeof body !== "object" || body.layout === undefined) + return malformed(); + return { ok: true, layout: body.layout as TileLayoutData }; + } catch (err) { + return { + ok: false, + notFound: false, + error: err instanceof Error ? err.message : "Tile-layout request failed", + }; + } + }, + }; +} diff --git a/src/features/workspaces/index.ts b/src/features/workspaces/index.ts index dab1dec..c7f57d5 100644 --- a/src/features/workspaces/index.ts +++ b/src/features/workspaces/index.ts @@ -1,5 +1,18 @@ export type { WorkspaceHttp, WorkspaceResult } from "./adapter/http"; export { createWorkspaceHttp } from "./adapter/http"; +export { createTileLayoutHttp, type TileLayoutHttp } from "./adapter/tile-layout-http"; +export type { CreateGate, WorkspaceLookup } from "./logic/create"; +export { + archivedCreateMessage, + checkFailedCreateMessage, + gateCreate, +} from "./logic/create"; +export type { + DragEmit, + DragEvent as DragControllerEvent, + DragPhase, +} from "./logic/drag-controller"; +export { nextDragPhase } from "./logic/drag-controller"; export type { Route } from "./logic/route"; export { DEFAULT_WORKSPACE_ID, @@ -8,10 +21,36 @@ export { WORKSPACE_SLUG_RE, workspacePath, } from "./logic/route"; +export type { + GridGeometry, + Placement, + TileGeometryMode, + TileLayoutData, + TileLayoutEntry, +} from "./logic/tile-layout"; +export { + clampSpan, + displacePlace, + dragAppendLimit, + fillOrderKey, + firstEmptySlot, + geometryFor, + mergeLayout, + pack, + pinAtFirstEmpty, + placeAtCell, + resizeTile, + sanitizeLayout, + sortByRecentActivity, + sortTiles, + toRenderCell, + toUniversalCell, +} from "./logic/tile-layout"; export { applyStarred, pageTitle, relativeTime, sortWorkspaces } from "./logic/view-model"; export type { WorkspaceStore } from "./store.svelte"; export { createWorkspaceStore } from "./store.svelte"; -export { default as WorkspaceCard } from "./ui/WorkspaceCard.svelte"; +export type { TileLayoutProviderState, TileLayoutStore } from "./tile-layout-store.svelte"; +export { createTileLayoutStore } from "./tile-layout-store.svelte"; export { default as WorkspacesHome } from "./ui/WorkspacesHome.svelte"; /** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ diff --git a/src/features/workspaces/logic/create.test.ts b/src/features/workspaces/logic/create.test.ts new file mode 100644 index 0000000..278bcd6 --- /dev/null +++ b/src/features/workspaces/logic/create.test.ts @@ -0,0 +1,50 @@ +import type { Workspace } from "@dispatch/wire"; +import { describe, expect, it } from "vitest"; +import { + archivedCreateMessage, + checkFailedCreateMessage, + gateCreate, + type WorkspaceLookup, +} from "./create"; + +function workspace(overrides: Partial<Workspace> = {}): Workspace { + return { + id: "old", + title: "Old", + defaultCwd: null, + defaultComputerId: null, + starred: false, + archived: false, + createdAt: 1, + lastActivityAt: 2, + ...overrides, + }; +} + +describe("gateCreate", () => { + it("blocks a slug whose workspace is archived, with the can't-create message", () => { + const lookup: WorkspaceLookup = { kind: "found", workspace: workspace({ archived: true }) }; + const gate = gateCreate("old", lookup); + expect(gate).toEqual({ ok: false, error: archivedCreateMessage("old") }); + expect(gate.ok === false && gate.error).toContain( + 'A workspace named "old" already exists and is archived — you can\'t create it.', + ); + }); + + it("proceeds for an existing ACTIVE workspace (ensure is idempotent)", () => { + const lookup: WorkspaceLookup = { kind: "found", workspace: workspace({ archived: false }) }; + expect(gateCreate("old", lookup)).toEqual({ ok: true }); + }); + + it("proceeds for a missing slug", () => { + expect(gateCreate("fresh", { kind: "missing" })).toEqual({ ok: true }); + }); + + it("blocks when the existence probe failed (never guesses)", () => { + const gate = gateCreate("maybe", { kind: "failed", error: "db down" }); + expect(gate).toEqual({ ok: false, error: checkFailedCreateMessage("maybe", "db down") }); + expect(gate.ok === false && gate.error).toContain( + 'Couldn\'t check whether a workspace named "maybe"', + ); + }); +}); diff --git a/src/features/workspaces/logic/create.ts b/src/features/workspaces/logic/create.ts new file mode 100644 index 0000000..c9385e1 --- /dev/null +++ b/src/features/workspaces/logic/create.ts @@ -0,0 +1,64 @@ +/** + * Pure create-gate logic for the New-workspace form — zero DOM, zero effects. + * + * `PUT /workspaces/:id` (ensure) is idempotent, so creating a slug that belongs + * to an ARCHIVED workspace would silently "succeed": the backend returns the + * archived workspace, the active list (which excludes archived) never shows it, + * and the home grid would still place a tile for something invisible. The + * frontend therefore probes `GET /workspaces/:id` first and refuses the create + * when the slug exists and is archived. + */ + +import type { Workspace } from "@dispatch/wire"; + +/** + * The authoritative existence probe's outcome (`GET /workspaces/:id`). The + * adapter maps HTTP status to these kinds; `failed` is deliberately distinct + * from `missing` (404) so a broken check is never mistaken for "no such + * workspace". + */ +export type WorkspaceLookup = + | { readonly kind: "found"; readonly workspace: Workspace } + | { readonly kind: "missing" } + | { readonly kind: "failed"; readonly error: string }; + +/** Whether a create may proceed: `ok` or a user-facing error message. */ +export type CreateGate = { readonly ok: true } | { readonly ok: false; readonly error: string }; + +/** + * The user-facing message when a slug belongs to an archived workspace. It + * states plainly that the create can't happen and points at the existing + * recovery affordance (the Archived workspaces list) without adding UI. + */ +export function archivedCreateMessage(id: string): string { + return `A workspace named "${id}" already exists and is archived — you can't create it. Unarchive it from Archived workspaces instead.`; +} + +/** + * The user-facing message when the existence probe itself failed. Proceeding + * blindly could recreate the silent archived-tile bug, so the create is + * refused with a retryable message instead. + */ +export function checkFailedCreateMessage(id: string, error: string): string { + return `Couldn't check whether a workspace named "${id}" already exists (${error}). Try again.`; +} + +/** + * Decide whether a create may proceed, given the probe outcome. Pure: + * `(id, lookup) → gate`. + * + * - archived slug → blocked with the "can't create" message; + * - missing slug or an ACTIVE existing slug → proceed (`ensure` is idempotent, + * so an active slug keeps today's behavior); + * - a failed probe → blocked too (plan §3.4): never guess, because proceeding + * could ensure an archived workspace's tile. + */ +export function gateCreate(id: string, lookup: WorkspaceLookup): CreateGate { + if (lookup.kind === "failed") { + return { ok: false, error: checkFailedCreateMessage(id, lookup.error) }; + } + if (lookup.kind === "found" && lookup.workspace.archived) { + return { ok: false, error: archivedCreateMessage(id) }; + } + return { ok: true }; +} diff --git a/src/features/workspaces/logic/drag-controller.test.ts b/src/features/workspaces/logic/drag-controller.test.ts new file mode 100644 index 0000000..459063a --- /dev/null +++ b/src/features/workspaces/logic/drag-controller.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from "vitest"; +import { type DragPhase, type DragTransition, nextDragPhase } from "./drag-controller"; + +const OPTS = { dragThresholdPx: 6 }; + +function down(over: Partial<Parameters<typeof nextDragPhase>[1] & { type: "pointer-down" }> = {}) { + return { + type: "pointer-down" as const, + id: "t1", + x: 10, + y: 10, + touch: false, + ...over, + }; +} + +function press(touch = false): DragPhase { + return nextDragPhase({ kind: "idle" }, down({ touch }), OPTS).phase; +} + +describe("nextDragPhase — mouse", () => { + it("a pointer-down enters the undecided pressing state", () => { + expect(press()).toEqual({ kind: "pressing", id: "t1", x: 10, y: 10, touch: false }); + }); + + it("movement past the threshold starts a drag", () => { + const transition = nextDragPhase( + press(), + { type: "pointer-move", x: 20, y: 12, targetCell: { col: 4, row: 1 } }, + OPTS, + ); + expect(transition.phase).toEqual({ + kind: "dragging", + id: "t1", + x: 20, + y: 12, + targetCell: { col: 4, row: 1 }, + touch: false, + }); + expect(transition.emit).toBeUndefined(); + }); + + it("movement within the threshold stays pressing", () => { + const transition = nextDragPhase( + press(), + { type: "pointer-move", x: 12, y: 14, targetCell: { col: 1, row: 1 } }, + OPTS, + ); + expect(transition.phase).toEqual({ kind: "pressing", id: "t1", x: 10, y: 10, touch: false }); + }); + + it("release without moving emits a tap (open the workspace)", () => { + const transition = nextDragPhase(press(), { type: "pointer-up" }, OPTS); + expect(transition.phase).toEqual({ kind: "idle" }); + expect(transition.emit).toEqual({ kind: "tap", id: "t1" }); + }); + + it("release while dragging emits a drop with the last tracked index", () => { + let transition: DragTransition = nextDragPhase( + press(), + { type: "pointer-move", x: 30, y: 30, targetCell: { col: 3, row: 1 } }, + OPTS, + ); + transition = nextDragPhase( + transition.phase, + { type: "pointer-move", x: 31, y: 31, targetCell: { col: 6, row: 1 } }, + OPTS, + ); + transition = nextDragPhase(transition.phase, { type: "pointer-up" }, OPTS); + expect(transition.emit).toEqual({ kind: "drop", id: "t1", targetCell: { col: 6, row: 1 } }); + expect(transition.phase).toEqual({ kind: "idle" }); + }); +}); + +describe("nextDragPhase — touch", () => { + it("a long-press lifts the tile and emits lift (arm the drag + select)", () => { + const transition = nextDragPhase(press(true), { type: "long-press" }, OPTS); + expect(transition.emit).toEqual({ kind: "lift", id: "t1" }); + expect(transition.phase).toEqual({ + kind: "dragging", + id: "t1", + x: 10, + y: 10, + targetCell: null, + touch: true, + }); + }); + + it("movement past the threshold BEFORE the lift cancels (vertical scrolling wins)", () => { + const transition = nextDragPhase( + press(true), + { type: "pointer-move", x: 10, y: 80, targetCell: { col: 1, row: 1 } }, + OPTS, + ); + expect(transition.phase).toEqual({ kind: "idle" }); + expect(transition.emit).toBeUndefined(); + }); + + it("movement within the threshold before the lift keeps pressing", () => { + const transition = nextDragPhase( + press(true), + { type: "pointer-move", x: 13, y: 12, targetCell: { col: 1, row: 1 } }, + OPTS, + ); + expect(transition.phase?.kind).toBe("pressing"); + }); + + it("after the lift, movement drags (never cancels)", () => { + let transition = nextDragPhase(press(true), { type: "long-press" }, OPTS); + transition = nextDragPhase( + transition.phase, + { type: "pointer-move", x: 10, y: 90, targetCell: { col: 5, row: 1 } }, + OPTS, + ); + expect(transition.phase).toEqual({ + kind: "dragging", + id: "t1", + x: 10, + y: 90, + targetCell: { col: 5, row: 1 }, + touch: true, + }); + }); + + it("a long-press never lifts a mouse press (desktop selects via right-click)", () => { + const transition = nextDragPhase(press(false), { type: "long-press" }, OPTS); + expect(transition.emit).toBeUndefined(); + expect(transition.phase?.kind).toBe("pressing"); + }); + + it("release while lifted (no movement) drops at the lifted tile's own slot", () => { + let transition = nextDragPhase(press(true), { type: "long-press" }, OPTS); + transition = nextDragPhase(transition.phase, { type: "pointer-up" }, OPTS); + expect(transition.emit).toEqual({ kind: "drop", id: "t1", targetCell: null }); + }); +}); + +describe("nextDragPhase — cancellations", () => { + it("cancel returns to idle from any phase without emitting", () => { + for (const phase of [ + { kind: "idle" as const }, + press(), + nextDragPhase( + press(), + { type: "pointer-move", x: 40, y: 40, targetCell: { col: 2, row: 1 } }, + OPTS, + ).phase, + ]) { + const transition = nextDragPhase(phase, { type: "cancel" }, OPTS); + expect(transition.phase).toEqual({ kind: "idle" }); + expect(transition.emit).toBeUndefined(); + } + }); + + it("events in idle are inert", () => { + expect(nextDragPhase({ kind: "idle" }, { type: "pointer-up" }, OPTS).phase).toEqual({ + kind: "idle", + }); + expect( + nextDragPhase( + { kind: "idle" }, + { type: "pointer-move", x: 1, y: 1, targetCell: { col: 1, row: 1 } }, + OPTS, + ).phase, + ).toEqual({ kind: "idle" }); + expect(nextDragPhase({ kind: "idle" }, { type: "long-press" }, OPTS).phase).toEqual({ + kind: "idle", + }); + }); + + it("a second pointer-down restarts the gesture with the new tile", () => { + const dragging = nextDragPhase( + press(), + { type: "pointer-move", x: 40, y: 40, targetCell: { col: 2, row: 1 } }, + OPTS, + ).phase; + const transition = nextDragPhase(dragging, down({ id: "t2" }), OPTS); + expect(transition.phase).toEqual({ kind: "pressing", id: "t2", x: 10, y: 10, touch: false }); + }); +}); diff --git a/src/features/workspaces/logic/drag-controller.ts b/src/features/workspaces/logic/drag-controller.ts new file mode 100644 index 0000000..a1877aa --- /dev/null +++ b/src/features/workspaces/logic/drag-controller.ts @@ -0,0 +1,162 @@ +/** + * Pure drag/press state machine for the home tile grid — zero DOM, zero + * timers (the component schedules the long-press timer and dispatches its + * firing as an event), zero Svelte. + * + * Pointer semantics: + * - **Mouse**: press → move past the threshold = DRAG; release without + * moving = a TAP (open the workspace). Selection is right-click. + * - **Touch**: press → (no movement past the threshold) long-press = LIFT + * (arm the drag + select the tile) → further moves drag. Moving past the + * threshold BEFORE the lift cancels the press — vertical scrolling wins, + * so the two gestures never fight. Release while lifted = DROP. + * + * Pure: `(phase, event, options) → { phase, emit }`. The component owns the + * reactive `$state` and performs the emits (navigate / select / place). + */ + +import type { Cell } from "./tile-layout"; + +/** Where the interaction currently stands. */ +export type DragPhase = + | { readonly kind: "idle" } + /** Pointer is down; not yet decided between tap / drag / long-press. */ + | { + readonly kind: "pressing"; + readonly id: string; + readonly x: number; + readonly y: number; + readonly touch: boolean; + } + /** The tile is lifted and following the pointer (touch: after the long-press). */ + | { + readonly kind: "dragging"; + readonly id: string; + readonly x: number; + readonly y: number; + /** The grid CELL under the pointer (null = not over the canvas yet). */ + readonly targetCell: Cell | null; + readonly touch: boolean; + }; + +/** An action the component should perform as a result of a transition. */ +export type DragEmit = + | { readonly kind: "tap"; readonly id: string } + | { readonly kind: "lift"; readonly id: string } + | { readonly kind: "drop"; readonly id: string; readonly targetCell: Cell | null }; + +/** Events the component dispatches (DOM events + the long-press timer firing). */ +export type DragEvent = + | { + readonly type: "pointer-down"; + readonly id: string; + readonly x: number; + readonly y: number; + readonly touch: boolean; + } + | { + readonly type: "pointer-move"; + readonly x: number; + readonly y: number; + readonly targetCell: Cell | null; + } + | { readonly type: "pointer-up" } + | { readonly type: "long-press" } + /** pointercancel / blur / the tile unmounting / Escape. */ + | { readonly type: "cancel" }; + +export interface DragOptions { + /** Pointer travel (px) that turns a press into a drag (or cancels a touch press). */ + readonly dragThresholdPx: number; +} + +export interface DragTransition { + readonly phase: DragPhase; + readonly emit?: DragEmit; +} + +const IDLE: DragPhase = { kind: "idle" }; + +/** Advance the state machine. Pure: phase + event in, NEXT phase + emit out. */ +export function nextDragPhase( + phase: DragPhase, + event: DragEvent, + options: DragOptions, +): DragTransition { + switch (event.type) { + case "pointer-down": + // A press always restarts (a pointerdown while dragging is a new gesture). + return { + phase: { + kind: "pressing", + id: event.id, + x: event.x, + y: event.y, + touch: event.touch, + }, + }; + case "pointer-move": { + if (phase.kind === "pressing") { + const travelled = Math.hypot(event.x - phase.x, event.y - phase.y); + if (travelled < options.dragThresholdPx) return { phase }; + // Touch: a big movement before the long-press lift is a SCROLL, not a + // drag — abandon the press so the canvas scrolls natively. + if (phase.touch) return { phase: IDLE }; + return { + phase: { + kind: "dragging", + id: phase.id, + x: event.x, + y: event.y, + targetCell: event.targetCell, + touch: phase.touch, + }, + }; + } + if (phase.kind === "dragging") { + return { + phase: { + ...phase, + x: event.x, + y: event.y, + targetCell: event.targetCell, + }, + }; + } + return { phase }; + } + case "pointer-up": { + if (phase.kind === "pressing") { + // No drag, no lift → a tap. + return { phase: IDLE, emit: { kind: "tap", id: phase.id } }; + } + if (phase.kind === "dragging") { + return { + phase: IDLE, + emit: { kind: "drop", id: phase.id, targetCell: phase.targetCell }, + }; + } + return { phase }; + } + case "long-press": { + if (phase.kind === "pressing" && phase.touch) { + // Touch lift: arm the drag at the press point (the pointer will move + // from here) + tell the component to select the tile. + return { + phase: { + kind: "dragging", + id: phase.id, + x: phase.x, + y: phase.y, + targetCell: null, + touch: true, + }, + emit: { kind: "lift", id: phase.id }, + }; + } + return { phase }; + } + case "cancel": + return { phase: IDLE }; + } +} diff --git a/src/features/workspaces/logic/tile-layout.test.ts b/src/features/workspaces/logic/tile-layout.test.ts new file mode 100644 index 0000000..b538373 --- /dev/null +++ b/src/features/workspaces/logic/tile-layout.test.ts @@ -0,0 +1,857 @@ +import { describe, expect, it } from "vitest"; +import { + clampSpan, + displacePlace, + dragAppendLimit, + fillOrderKey, + firstEmptySlot, + type GridGeometry, + geometryFor, + mergeLayout, + type Placement, + pack, + pinAtFirstEmpty, + placeAtCell, + resizeTile, + sanitizeLayout, + sortByRecentActivity, + type TileLayoutEntry, + toRenderCell, + toUniversalCell, + UNIVERSAL_ROWS, +} from "./tile-layout"; + +const WIDE = geometryFor(1920, 1080); +const TALL = geometryFor(390, 844); + +function tile(id: string, order: number, colSpan = 1, rowSpan = 1): TileLayoutEntry { + return { id, order, colSpan, rowSpan }; +} + +describe("geometryFor", () => { + it("resolves landscape/desktop viewports to the WIDE geometry", () => { + const geometry = geometryFor(1920, 1080); + expect(geometry.mode).toBe("wide"); + expect(geometry.flowRows).toBe(4); + expect(geometry.flowCols).toBe(0); + expect(geometry.maxColSpan).toBe(4); + expect(geometry.maxRowSpan).toBe(2); + expect(geometry.unit.height).toBeGreaterThan(0); + expect(geometry.unit.width).toBeGreaterThan(0); + }); + + it("resolves portrait/mobile viewports to the TALL geometry", () => { + const geometry = geometryFor(390, 844); + expect(geometry.mode).toBe("tall"); + expect(geometry.flowCols).toBe(2); + expect(geometry.flowRows).toBe(0); + expect(geometry.maxColSpan).toBe(2); + expect(geometry.maxRowSpan).toBe(2); + }); + + it("picks the picker dimensions from the span caps (4×2 wide, 2×2 tall)", () => { + // The resize picker IS the span cap: cols × rows of small squares. + expect([WIDE.maxColSpan, WIDE.maxRowSpan]).toEqual([4, 2]); + expect([TALL.maxColSpan, TALL.maxRowSpan]).toEqual([2, 2]); + }); + + it("resolves degenerate (unmeasured) sizes to wide", () => { + expect(geometryFor(0, 0).mode).toBe("wide"); + }); + + it("derives the wide unit from the canvas height so the 4 rows fill it", () => { + // 4·unit + 3·gap + 2·margin ≈ height: an 800px canvas gets ~160px tiles, + // NOT the old fixed 120px (the "squares are so small" complaint). + const geometry = geometryFor(1440, 800); + const filled = 4 * geometry.unit.height + 3 * geometry.unit.gap + 2 * geometry.margin; + expect(geometry.unit.height).toBeGreaterThanOrEqual(140); + expect(Math.abs(filled - 800)).toBeLessThanOrEqual(8); + expect(geometry.unit.width).toBe(geometry.unit.height); + }); + + it("clamps the wide unit to its band on very short and very tall canvases", () => { + const short = geometryFor(1200, 300); + const huge = geometryFor(5000, 4000); // wide (width ≥ height) with a huge height + expect(short.unit.height).toBe(112); + expect(huge.unit.height).toBe(184); + // The margin never collapses (the "no margins" complaint) nor dominates. + expect(short.margin).toBeGreaterThanOrEqual(24); + expect(huge.margin).toBeLessThanOrEqual(64); + }); + + it("always carries a positive margin (the tile field never hugs the edge)", () => { + const sizes: [number, number][] = [ + [1920, 1080], + [390, 844], + [0, 0], + [1200, 300], + ]; + for (const [w, h] of sizes) { + expect(geometryFor(w, h).margin).toBeGreaterThan(0); + } + }); + + it("keeps the tall unit fixed with a gap (its columns are fluid)", () => { + expect(TALL.unit.width).toBe(0); + expect(TALL.unit.height).toBeGreaterThanOrEqual(120); + expect(TALL.unit.gap).toBeGreaterThan(8); + }); +}); + +describe("clampSpan", () => { + it("clamps spans to the geometry caps", () => { + expect(clampSpan({ colSpan: 4, rowSpan: 2 }, WIDE)).toEqual({ colSpan: 4, rowSpan: 2 }); + expect(clampSpan({ colSpan: 9, rowSpan: 9 }, WIDE)).toEqual({ colSpan: 4, rowSpan: 2 }); + expect(clampSpan({ colSpan: 3, rowSpan: 1 }, TALL)).toEqual({ colSpan: 2, rowSpan: 1 }); + }); + + it("coerces garbage spans to 1×1", () => { + expect(clampSpan({ colSpan: 0, rowSpan: -2 }, WIDE)).toEqual({ colSpan: 1, rowSpan: 1 }); + expect(clampSpan({ colSpan: Number.NaN, rowSpan: 1.9 }, WIDE)).toEqual({ + colSpan: 1, + rowSpan: 1, + }); + }); +}); + +describe("pack", () => { + it("places tiles in order at the first fitting slot (wide, column-wise flow)", () => { + const placements = pack([tile("a", 0), tile("b", 1), tile("c", 2), tile("d", 3)], WIDE); + expect(placements).toEqual([ + { id: "a", col: 1, row: 1, colSpan: 1, rowSpan: 1 }, + { id: "b", col: 1, row: 2, colSpan: 1, rowSpan: 1 }, + { id: "c", col: 1, row: 3, colSpan: 1, rowSpan: 1 }, + { id: "d", col: 1, row: 4, colSpan: 1, rowSpan: 1 }, + ]); + }); + + it("flows to the next column when the current one is full (wide)", () => { + const placements = pack( + [tile("a", 0), tile("b", 1), tile("c", 2), tile("d", 3), tile("e", 4)], + WIDE, + ); + expect(placements.map((p) => [p.col, p.row])).toEqual([ + [1, 1], + [1, 2], + [1, 3], + [1, 4], + [2, 1], + ]); + }); + + it("respects the persisted order over ids", () => { + const placements = pack([tile("z", 0), tile("a", 1)], WIDE); + expect(placements.map((p) => p.id)).toEqual(["z", "a"]); + }); + + it("places a double-height tile at the first slot with room (wide)", () => { + const placements = pack([tile("a", 0, 1, 2), tile("b", 1), tile("c", 2)], WIDE); + expect(placements.map((p) => [p.id, p.col, p.row, p.rowSpan])).toEqual([ + ["a", 1, 1, 2], + ["b", 1, 3, 1], + ["c", 1, 4, 1], + ]); + }); + + it("a tile too tall for the remaining rows moves to the next column (wide)", () => { + // a,b,c fill rows 1-3; a 2-row tile does not fit the single remaining row → col 2. + const placements = pack( + [tile("a", 0), tile("b", 1), tile("c", 2), tile("tall", 3, 1, 2)], + WIDE, + ); + expect(placements[3]).toEqual({ id: "tall", col: 2, row: 1, colSpan: 1, rowSpan: 2 }); + }); + + it("flows row-wise across two fixed columns in tall mode", () => { + const placements = pack([tile("a", 0), tile("b", 1), tile("c", 2)], TALL); + expect(placements.map((p) => [p.col, p.row])).toEqual([ + [1, 1], + [2, 1], + [1, 2], + ]); + }); + + it("clamps spans per geometry when packing (persisted spans stay raw)", () => { + const tiles = [tile("wide", 0, 3, 1), tile("b", 1, 4, 2)]; + const placements = pack(tiles, TALL); + expect(placements).toEqual([ + { id: "wide", col: 1, row: 1, colSpan: 2, rowSpan: 1 }, + { id: "b", col: 1, row: 2, colSpan: 2, rowSpan: 2 }, + ]); + // The input is untouched. + expect(tiles[0]).toEqual({ id: "wide", order: 0, colSpan: 3, rowSpan: 1 }); + }); + + it("never overlaps tiles", () => { + const tiles = [ + tile("a", 0, 2, 2), + tile("b", 1, 2, 2), + tile("c", 2, 4, 1), + tile("d", 3), + tile("e", 4, 1, 2), + tile("f", 5), + ]; + const placements = pack(tiles, WIDE); + const seen = new Set<string>(); + for (const p of placements) { + for (let r = p.row; r < p.row + p.rowSpan; r++) { + for (let c = p.col; c < p.col + p.colSpan; c++) { + const key = `${r}:${c}`; + expect(seen.has(key)).toBe(false); + seen.add(key); + } + } + } + }); +}); + +describe("fillOrderKey", () => { + it("ranks wide slots column-wise (top-to-bottom, then next column)", () => { + expect(fillOrderKey(1, 1, WIDE)).toBeLessThan(fillOrderKey(1, 2, WIDE)); + expect(fillOrderKey(1, 4, WIDE)).toBeLessThan(fillOrderKey(2, 1, WIDE)); + }); + + it("ranks tall slots row-wise (left-to-right, then next row)", () => { + expect(fillOrderKey(1, 1, TALL)).toBeLessThan(fillOrderKey(2, 1, TALL)); + expect(fillOrderKey(2, 1, TALL)).toBeLessThan(fillOrderKey(1, 2, TALL)); + }); +}); + +describe("firstEmptySlot", () => { + it("finds the first fitting empty slot in fill order", () => { + // Wide, rows 1-2 of col 1 occupied by a double-height tile → first empty is (col1,row3). + const tiles = [tile("a", 0, 1, 2)]; + expect(firstEmptySlot(tiles, { colSpan: 1, rowSpan: 1 }, WIDE)).toEqual({ col: 1, row: 3 }); + }); + + it("skips slots too small for the requested footprint", () => { + // (col1,row4) is the first empty 1×1 slot after three singles, but a + // 2-row tile needs rows 4-5 of a 4-row grid — impossible → next column. + const tiles = [tile("a", 0), tile("b", 1), tile("c", 2)]; + expect(firstEmptySlot(tiles, { colSpan: 1, rowSpan: 1 }, WIDE)).toEqual({ col: 1, row: 4 }); + expect(firstEmptySlot(tiles, { colSpan: 1, rowSpan: 2 }, WIDE)).toEqual({ col: 2, row: 1 }); + }); + + it("returns the origin for an empty layout", () => { + expect(firstEmptySlot([], { colSpan: 1, rowSpan: 1 }, WIDE)).toEqual({ col: 1, row: 1 }); + }); + + it("lands in a brand-new column/row beyond the occupied extent", () => { + // Two 4×2 tiles fill columns 1-4 across all 4 rows → the next slot is col 5. + const tiles = [tile("a", 0, 4, 2), tile("b", 1, 4, 2)]; + expect(firstEmptySlot(tiles, { colSpan: 1, rowSpan: 1 }, WIDE)).toEqual({ col: 5, row: 1 }); + }); +}); + +describe("pinAtFirstEmpty", () => { + it("pins a new tile at the first empty slot (holes count)", () => { + // A double-height tile fills rows 1-2, a single fills row 3 → the first + // empty slot is (col1,row4); the new tile PINS exactly there. + const tiles = [tile("big", 0, 1, 2), tile("c", 1)]; + const next = pinAtFirstEmpty(tiles, "new", WIDE); + expect(next.find((t) => t.id === "new")).toMatchObject({ col: 1, row: 4 }); + const placements = pack(next, WIDE); + expect(placements.map((p) => [p.id, p.col, p.row])).toEqual([ + ["big", 1, 1], + ["c", 1, 3], + ["new", 1, 4], + ]); + }); + + it("pins into a GAP between pinned tiles", () => { + // big is pinned out of the way; the flow left row 3 free for a 1×1. + const tiles = [ + { ...tile("big", 0, 1, 2), col: 1, row: 1 }, + { ...tile("z", 1), col: 1, row: 4 }, + ]; + const next = pinAtFirstEmpty(tiles, "new", WIDE); + expect(next.find((t) => t.id === "new")).toMatchObject({ col: 1, row: 3 }); + }); + + it("re-pins an already-present tile instead of duplicating (refresh-race idempotency)", () => { + const tiles = [tile("a", 0), tile("x", 1), tile("b", 2, 1, 2)]; + const first = pinAtFirstEmpty(tiles, "x", WIDE); + const second = pinAtFirstEmpty(first, "x", WIDE); + expect(second).toEqual(first); + expect(second.filter((t) => t.id === "x")).toHaveLength(1); + }); + + it("appends (pins beyond the current extent) when the grid is full", () => { + const tiles = [tile("a", 0), tile("b", 1), tile("c", 2), tile("d", 3)]; + const next = pinAtFirstEmpty(tiles, "e", WIDE); + expect(next.find((t) => t.id === "e")).toMatchObject({ col: 2, row: 1 }); + }); + + it("preserves the spans of a re-pinned existing tile", () => { + const tiles = [tile("x", 0, 2, 2), tile("a", 1), tile("b", 2), tile("c", 3)]; + const next = pinAtFirstEmpty(tiles, "x", WIDE); + expect(next.find((t) => t.id === "x")).toMatchObject({ colSpan: 2, rowSpan: 2 }); + }); + + it("saves the pin in the universal frame (tall)", () => { + const tiles = [{ ...tile("a", 0), col: 1, row: 1 }]; + const next = pinAtFirstEmpty(tiles, "new", TALL); + // a renders at tall (1,1); the first empty slot is tall (2,1) → universal + // (col 1, row 2). + expect(next.find((t) => t.id === "new")).toMatchObject({ col: 1, row: 2 }); + }); +}); + +describe("resizeTile", () => { + it("sets the persisted spans of one tile", () => { + const next = resizeTile([tile("a", 0), tile("b", 1)], "b", 4, 2); + expect(next.find((t) => t.id === "b")).toMatchObject({ colSpan: 4, rowSpan: 2 }); + expect(next.find((t) => t.id === "a")).toMatchObject({ colSpan: 1, rowSpan: 1 }); + }); + + it("sanitizes garbage spans to 1 and preserves order", () => { + const next = resizeTile([tile("a", 5, 2, 2)], "a", Number.NaN, -3); + expect(next).toEqual([{ id: "a", order: 5, colSpan: 1, rowSpan: 1 }]); + }); + + it("ignores unknown ids", () => { + const tiles = [tile("a", 0)]; + expect(resizeTile(tiles, "nope", 2, 2)).toEqual(tiles); + }); + + it("does NOT clamp to the current geometry (render-time concern)", () => { + // A 4-wide desktop tile resized while wide stays 4-wide in the document; + // tall rendering clamps its VIEW. + const next = resizeTile([tile("a", 0)], "a", 4, 2); + expect(next[0]).toMatchObject({ colSpan: 4, rowSpan: 2 }); + }); +}); + +describe("displacePlace", () => { + /** Render placements as the column-1 stream (cells 1..n, `_` = empty, + * one character per tile — the id's first char). */ + const stream = (placements: Placement[], geometry: GridGeometry): string => { + const cells = new Map<number, string>(); + for (const p of placements) { + for (let r = p.row; r < p.row + p.rowSpan; r++) { + for (let c = p.col; c < p.col + p.colSpan; c++) { + cells.set(fillOrderKey(c, r, geometry), p.id.charAt(0)); + } + } + } + const maxKey = Math.max(0, ...cells.keys()); + const out: string[] = []; + for (let key = 0; key <= maxKey; key++) { + out.push(cells.get(key) ?? "_"); + } + return out.join(""); + }; + + it("USER EXAMPLE 1: X_X + Y placed in slot 1 → YXX (the gap absorbs the shift)", () => { + // X at cells 1,3 (wide: (1,1) and (1,3)); Y sits at cell 5 (dragged in + // from outside the shown range — its origin is beyond the gap). + const placements: Placement[] = [ + { id: "X", col: 1, row: 1, colSpan: 1, rowSpan: 1 }, + { id: "X2", col: 1, row: 3, colSpan: 1, rowSpan: 1 }, + { id: "Y", col: 2, row: 1, colSpan: 1, rowSpan: 1 }, + ]; + const next = displacePlace(placements, "Y", { col: 1, row: 1 }, WIDE); + // Y@1; X (was@1) pushed into the gap at 2; X2 (was@3) stays. The array + // keeps the INPUT render order (only coordinates change). + expect(next.map((p) => [p.id, p.col, p.row])).toEqual([ + ["X", 1, 2], + ["X2", 1, 3], + ["Y", 1, 1], + ]); + }); + + it("USER EXAMPLE 2: _XZ_ + Y placed in slot 2 → _YXZ (leading gap preserved)", () => { + // X@(1,2), Z@(1,3); Y dragged in from cell 5. + const placements: Placement[] = [ + { id: "X", col: 1, row: 2, colSpan: 1, rowSpan: 1 }, + { id: "Z", col: 1, row: 3, colSpan: 1, rowSpan: 1 }, + { id: "Y", col: 2, row: 1, colSpan: 1, rowSpan: 1 }, + ]; + const next = displacePlace(placements, "Y", { col: 1, row: 2 }, WIDE); + // Same coordinates, input render order: X, Z, then the dragged Y. + expect(next.map((p) => [p.id, p.col, p.row])).toEqual([ + ["X", 1, 3], + ["Z", 1, 4], + ["Y", 1, 2], + ]); + }); + + it("dragging a tile OUT opens a gap at its old anchor (nothing slides back)", () => { + const placements: Placement[] = [ + { id: "a", col: 1, row: 1, colSpan: 1, rowSpan: 1 }, + { id: "b", col: 1, row: 2, colSpan: 1, rowSpan: 1 }, + ]; + // Drag a (cell 1) to cell 3 — b stays at 2; a lands at 3. + const next = displacePlace(placements, "a", { col: 1, row: 3 }, WIDE); + // The array keeps the input render order (a first) — only a's coords move. + expect(next.map((p) => [p.id, p.col, p.row])).toEqual([ + ["a", 1, 3], + ["b", 1, 2], + ]); + }); + + it("internal move rightward over a full run pushes the tail forward", () => { + // a,b,c in cells 1-3; drag a to cell 2: b→3 pushes c→4. + const placements: Placement[] = [ + { id: "a", col: 1, row: 1, colSpan: 1, rowSpan: 1 }, + { id: "b", col: 1, row: 2, colSpan: 1, rowSpan: 1 }, + { id: "c", col: 1, row: 3, colSpan: 1, rowSpan: 1 }, + ]; + const next = displacePlace(placements, "a", { col: 1, row: 2 }, WIDE); + expect(next.map((p) => [p.id, p.col, p.row])).toEqual([ + ["a", 1, 2], + ["b", 1, 3], + ["c", 1, 4], + ]); + }); + + it("an unknown dragged id changes nothing (a copy of the input)", () => { + const placements: Placement[] = [{ id: "a", col: 1, row: 1, colSpan: 1, rowSpan: 1 }]; + const next = displacePlace(placements, "nope", { col: 1, row: 2 }, WIDE); + expect(next).toEqual(placements); + }); + + it("a spanned displaced tile keeps its spans and re-anchors forward as a rect", () => { + // big (2-tall) at rows 1-2; tail (2-tall) at rows 3-4. Drag big to (1,2): + // its own old body is free-ish but (1,2)+(1,3) collides with tail's + // (1,3)+(1,4) → tail re-anchors FORWARD: no 2-tall slot fits in column 1 + // anymore → column 2. + const placements: Placement[] = [ + { id: "big", col: 1, row: 1, colSpan: 1, rowSpan: 2 }, + { id: "tail", col: 1, row: 3, colSpan: 1, rowSpan: 2 }, + ]; + const next = displacePlace(placements, "big", { col: 1, row: 2 }, WIDE); + expect(next.map((p) => [p.id, p.col, p.row, p.rowSpan])).toEqual([ + ["big", 1, 2, 2], + ["tail", 2, 1, 2], + ]); + }); + + it("displacing into the NEXT COLUMN when the column is full", () => { + // 4 tiles fill column 1; drag the first to the LAST cell of column 1. + const placements: Placement[] = [ + { id: "a", col: 1, row: 1, colSpan: 1, rowSpan: 1 }, + { id: "b", col: 1, row: 2, colSpan: 1, rowSpan: 1 }, + { id: "c", col: 1, row: 3, colSpan: 1, rowSpan: 1 }, + { id: "d", col: 1, row: 4, colSpan: 1, rowSpan: 1 }, + ]; + const next = displacePlace(placements, "a", { col: 1, row: 4 }, WIDE); + // d is pushed past the column's end → column 2 row 1. The array keeps the + // INPUT render order (a,b,c,d) — only coordinates change. + expect(next.map((p) => [p.id, p.col, p.row])).toEqual([ + ["a", 1, 4], + ["b", 1, 2], + ["c", 1, 3], + ["d", 2, 1], + ]); + }); + + it("works in TALL mode along the row-major stream", () => { + // Tall: 2 columns, row-major. a@(1,1), b@(2,1); drag a to cell (2,1). + const placements: Placement[] = [ + { id: "a", col: 1, row: 1, colSpan: 1, rowSpan: 1 }, + { id: "b", col: 2, row: 1, colSpan: 1, rowSpan: 1 }, + ]; + const next = displacePlace(placements, "a", { col: 2, row: 1 }, TALL); + // b pushed forward past the row's end → next row. Stream order: a(1) b(2). + expect(next.map((p) => [p.id, p.col, p.row])).toEqual([ + ["a", 2, 1], + ["b", 1, 2], + ]); + }); + + it("the stream helper renders the documented examples (sanity)", () => { + const placements: Placement[] = [ + { id: "X", col: 1, row: 1, colSpan: 1, rowSpan: 1 }, + { id: "X2", col: 1, row: 3, colSpan: 1, rowSpan: 1 }, + { id: "Y", col: 2, row: 1, colSpan: 1, rowSpan: 1 }, + ]; + const next = displacePlace(placements, "Y", { col: 1, row: 1 }, WIDE); + expect(stream(next, WIDE)).toBe("YXX"); + const placements2: Placement[] = [ + { id: "X", col: 1, row: 2, colSpan: 1, rowSpan: 1 }, + { id: "Z", col: 1, row: 3, colSpan: 1, rowSpan: 1 }, + { id: "Y", col: 2, row: 1, colSpan: 1, rowSpan: 1 }, + ]; + const next2 = displacePlace(placements2, "Y", { col: 1, row: 2 }, WIDE); + expect(stream(next2, WIDE)).toBe("_YXZ"); + }); +}); + +describe("placeAtCell", () => { + it("pins every tile at its displaced anchor in the universal frame", () => { + // Wide: a@(1,1), b@(1,2); drag a to (2,1) — a pins there, b KEEPS its + // cell (the gap at (1,1) opens and stays). + const tiles = [tile("a", 0), tile("b", 1)]; + const next = placeAtCell(tiles, "a", { col: 2, row: 1 }, WIDE); + expect(next.find((t) => t.id === "a")).toMatchObject({ col: 2, row: 1 }); + expect(next.find((t) => t.id === "b")).toMatchObject({ col: 1, row: 2 }); + // And the pinned layout packs exactly as placed (b first — stream order). + expect(pack(next, WIDE).map((p) => [p.id, p.col, p.row])).toEqual([ + ["b", 1, 2], + ["a", 2, 1], + ]); + }); + + it("keeps persisted spans raw (never clamped) through the commit", () => { + // A 4-wide desktop tile dropped in TALL commits the tall cell but keeps + // its raw 4-wide span. + const tiles = [{ ...tile("big", 0, 4, 1) }, tile("b", 1)]; + const next = placeAtCell(tiles, "big", { col: 1, row: 3 }, TALL); + expect(next.find((t) => t.id === "big")).toMatchObject({ + colSpan: 4, + rowSpan: 1, + col: 1, + row: 5, + }); + }); + + it("renumbers orders densely in stream order", () => { + const tiles = [tile("a", 0), tile("b", 5), tile("c", 9)]; + const next = placeAtCell(tiles, "c", { col: 1, row: 1 }, WIDE); + expect(next.every((t, i) => t.order === i)).toBe(true); + }); + + it("maps tall commits to the universal frame", () => { + // Tall (col 2, row 1) → universal (col 1, row 2). + const tiles = [tile("a", 0)]; + const next = placeAtCell(tiles, "a", { col: 2, row: 1 }, TALL); + expect(next.find((t) => t.id === "a")).toMatchObject({ col: 1, row: 2 }); + }); + + it("an unknown id changes nothing (no-op)", () => { + const tiles = [tile("a", 0)]; + expect(placeAtCell(tiles, "nope", { col: 1, row: 1 }, WIDE)).toEqual(tiles); + }); +}); + +describe("dragAppendLimit", () => { + it("caps a wide drag at 3 columns past the right-most content edge (span-aware)", () => { + // b spans cols 3-4 → the right-most occupied column is 4; +3 = 7. + const placements: Placement[] = [ + { id: "a", col: 1, row: 1, colSpan: 1, rowSpan: 1 }, + { id: "b", col: 3, row: 1, colSpan: 2, rowSpan: 1 }, + ]; + expect(dragAppendLimit(placements, WIDE)).toBe(7); + }); + + it("caps a tall drag at 3 rows past the bottom-most content edge (span-aware)", () => { + // b spans rows 3-4 → the bottom-most occupied row is 4; +3 = 7. + const placements: Placement[] = [ + { id: "a", col: 1, row: 1, colSpan: 1, rowSpan: 2 }, + { id: "b", col: 2, row: 3, colSpan: 1, rowSpan: 2 }, + ]; + expect(dragAppendLimit(placements, TALL)).toBe(7); + }); + + it("an empty grid still allows the first tiles within the first 3 columns", () => { + expect(dragAppendLimit([], WIDE)).toBe(3); + expect(dragAppendLimit([], TALL)).toBe(3); + }); + + it("honors a custom extra", () => { + const placements: Placement[] = [{ id: "a", col: 1, row: 1, colSpan: 1, rowSpan: 1 }]; + expect(dragAppendLimit(placements, WIDE, 5)).toBe(6); + }); +}); + +describe("sortByRecentActivity", () => { + it("re-applies the recent-activity order (descending)", () => { + const tiles = [tile("a", 0), tile("b", 1), tile("c", 2)]; + const next = sortByRecentActivity( + tiles, + new Map([ + ["a", 100], + ["b", 300], + ["c", 200], + ]), + ); + expect(next.map((t) => t.id)).toEqual(["b", "c", "a"]); + }); + + it("treats unknown ids as least-recent and renumbers", () => { + const tiles = [tile("a", 0), tile("b", 1)]; + // `a` has recent activity (50); `b` is unknown → least-recent → last. + const next = sortByRecentActivity(tiles, new Map([["a", 50]])); + expect(next.map((t) => [t.id, t.order])).toEqual([ + ["a", 0], + ["b", 1], + ]); + }); + + it("CLEARs every pin (an explicit arrangement reset — gaps gone)", () => { + const tiles: TileLayoutEntry[] = [ + { ...tile("a", 0), col: 1, row: 1 }, + { ...tile("b", 1), col: 2, row: 3 }, + ]; + const next = sortByRecentActivity( + tiles, + new Map([ + ["a", 100], + ["b", 300], + ]), + ); + expect(next.every((t) => t.col === undefined && t.row === undefined)).toBe(true); + // And the now-unpinned layout packs compactly. + expect(pack(next, WIDE).map((p) => [p.id, p.col, p.row])).toEqual([ + ["b", 1, 1], + ["a", 1, 2], + ]); + }); +}); + +describe("mergeLayout", () => { + it("starts workspaces unknown to the layout (appends after the max order)", () => { + const merged = mergeLayout({ version: 1, tiles: [tile("a", 0)] }, ["a", "b", "c"]); + expect(merged.map((t) => [t.id, t.order])).toEqual([ + ["a", 0], + ["b", 1], + ["c", 2], + ]); + }); + + it("appends unknowns in the live list's order", () => { + const merged = mergeLayout({ version: 1, tiles: [tile("z", 0)] }, ["z", "y", "x"]); + expect(merged.map((t) => t.id)).toEqual(["z", "y", "x"]); + }); + + it("drops entries for deleted workspaces", () => { + const merged = mergeLayout( + { version: 1, tiles: [tile("a", 0), tile("gone", 1), tile("b", 2)] }, + ["a", "b"], + ); + expect(merged.map((t) => t.id)).toEqual(["a", "b"]); + }); + + it("keeps manual order (not the list order) for known workspaces", () => { + const merged = mergeLayout({ version: 1, tiles: [tile("c", 0), tile("a", 1)] }, ["a", "c"]); + expect(merged.map((t) => t.id)).toEqual(["c", "a"]); + }); + + it("clamps spans in the view when a geometry is given (persisted data unchanged)", () => { + const merged = mergeLayout({ version: 1, tiles: [tile("a", 0, 3, 1)] }, ["a"], TALL); + expect(merged[0]).toMatchObject({ colSpan: 2, rowSpan: 1 }); + expect(mergeLayout({ version: 1, tiles: [tile("a", 0, 3, 1)] }, ["a"])).toEqual([ + { id: "a", order: 0, colSpan: 3, rowSpan: 1 }, + ]); + }); + + it("deduplicates corrupted duplicate ids (first by order wins)", () => { + const merged = mergeLayout({ version: 1, tiles: [tile("a", 1, 2, 2), tile("a", 0)] }, [ + "a", + "b", + ]); + expect(merged).toEqual([ + { id: "a", order: 0, colSpan: 1, rowSpan: 1 }, + { id: "b", order: 1, colSpan: 1, rowSpan: 1 }, + ]); + }); + + it("handles a null/undefined persisted document", () => { + expect(mergeLayout(null, ["a"])).toEqual([{ id: "a", colSpan: 1, rowSpan: 1, order: 1 }]); + expect(mergeLayout(undefined, [])).toEqual([]); + }); +}); + +describe("sanitizeLayout", () => { + it("accepts a valid v2 document (anchors kept)", () => { + expect( + sanitizeLayout({ + version: 2, + tiles: [{ id: "a", colSpan: 2, rowSpan: 1, order: 0, col: 3, row: 2 }], + }), + ).toEqual({ + version: 2, + tiles: [{ id: "a", colSpan: 2, rowSpan: 1, order: 0, col: 3, row: 2 }], + }); + }); + + it("MIGRATES a v1 document to v2 in memory (all tiles unpinned)", () => { + expect( + sanitizeLayout({ version: 1, tiles: [{ id: "a", colSpan: 2, rowSpan: 1, order: 0 }] }), + ).toEqual({ + version: 2, + tiles: [{ id: "a", colSpan: 2, rowSpan: 1, order: 0 }], + }); + }); + + it("rejects foreign versions and malformed documents", () => { + expect(sanitizeLayout(null)).toEqual({ version: 2, tiles: [] }); + expect(sanitizeLayout("nope")).toEqual({ version: 2, tiles: [] }); + expect(sanitizeLayout({ version: 3, tiles: [] })).toEqual({ version: 2, tiles: [] }); + expect(sanitizeLayout({ version: 1, tiles: "x" })).toEqual({ version: 2, tiles: [] }); + }); + + it("coerces malformed entries and drops anonymous/duplicate ones", () => { + const data = sanitizeLayout({ + version: 1, + tiles: [ + { id: "a", colSpan: "x", rowSpan: 0, order: "y" }, + null, + { colSpan: 2 }, + { id: "a", order: 9 }, + { id: "b", colSpan: 3.9, order: 2.5 }, + ], + }); + expect(data.tiles).toEqual([ + { id: "a", colSpan: 1, rowSpan: 1, order: 0 }, + { id: "b", colSpan: 3, rowSpan: 1, order: 2 }, + ]); + }); + + it("drops garbage anchors (non-positive / fractional / half a pair)", () => { + const data = sanitizeLayout({ + version: 2, + tiles: [ + { id: "ok", colSpan: 1, rowSpan: 1, order: 0, col: 2, row: 3 }, + { id: "frac", colSpan: 1, rowSpan: 1, order: 1, col: 2.5, row: 3 }, + { id: "zero", colSpan: 1, rowSpan: 1, order: 2, col: 0, row: 1 }, + { id: "lone", colSpan: 1, rowSpan: 1, order: 3, col: 2 }, + { id: "neg", colSpan: 1, rowSpan: 1, order: 4, col: -1, row: -1 }, + ], + }); + expect(data.tiles).toEqual([ + { id: "ok", colSpan: 1, rowSpan: 1, order: 0, col: 2, row: 3 }, + { id: "frac", colSpan: 1, rowSpan: 1, order: 1, col: 2, row: 3 }, + { id: "zero", colSpan: 1, rowSpan: 1, order: 2 }, + { id: "lone", colSpan: 1, rowSpan: 1, order: 3 }, + { id: "neg", colSpan: 1, rowSpan: 1, order: 4 }, + ]); + }); +}); + +describe("geometry-agnostic persistence", () => { + it("the SAME persisted data renders in either geometry (unpinned flow re-derives)", () => { + const tiles: TileLayoutEntry[] = [ + tile("a", 0, 4, 2), + tile("b", 1), + tile("c", 2), + tile("d", 3, 2, 2), + tile("e", 4), + ]; + const widePlacements = pack(tiles, WIDE); + const tallPlacements = pack(tiles, TALL); + // Wide flows column-wise across 4 rows; tall flows row-wise across 2 columns. + expect(widePlacements[0]).toMatchObject({ id: "a", col: 1, row: 1, colSpan: 4, rowSpan: 2 }); + expect(widePlacements.at(-1)).toMatchObject({ id: "e" }); + expect(tallPlacements[0]).toMatchObject({ id: "a", col: 1, row: 1, colSpan: 2, rowSpan: 2 }); + // An unpinned entry carries NO anchor — only id/spans/order. + expect(Object.keys(tiles[0] ?? {}).sort()).toEqual(["colSpan", "id", "order", "rowSpan"]); + }); +}); + +describe("pinned pack (v2)", () => { + it("pinned tiles land at their anchors; unpinned flow into the remaining free cells", () => { + const tiles: TileLayoutEntry[] = [ + { ...tile("a", 0), col: 1, row: 3 }, + { ...tile("b", 1), col: 1, row: 1 }, + tile("c", 2), // unpinned → first free = (1,2) + tile("d", 3), // unpinned → next free = (1,4) + ]; + // pack returns ENTRY order (a, b, c, d); positions are what matters. + const placements = pack(tiles, WIDE); + expect(placements.map((p) => [p.id, p.col, p.row])).toEqual([ + ["a", 1, 3], + ["b", 1, 1], + ["c", 1, 2], + ["d", 1, 4], + ]); + }); + + it("keeps the GAP between two pins (flow does not compact it away)", () => { + const tiles: TileLayoutEntry[] = [ + { ...tile("a", 0), col: 1, row: 1 }, + { ...tile("z", 1), col: 1, row: 4 }, + ]; + expect(pack(tiles, WIDE).map((p) => [p.id, p.row])).toEqual([ + ["a", 1], + ["z", 4], + ]); + }); + + it("a pin whose span no longer fits its anchor reads as UNPINNED (flows)", () => { + // A 2-tall tile pinned at row 4 cannot fit the 4-row cross-axis at that + // anchor → it flows to the first fitting slot instead: (1,2). + const tiles: TileLayoutEntry[] = [ + { ...tile("big", 0, 1, 2), col: 1, row: 4 }, + { ...tile("a", 1), col: 1, row: 1 }, + ]; + const placements = pack(tiles, WIDE); + expect(placements.find((p) => p.id === "big")).toMatchObject({ col: 1, row: 2 }); + }); + + it("overlapping pins resolve first-wins by entry order (the loser flows)", () => { + const tiles: TileLayoutEntry[] = [ + { ...tile("a", 0), col: 1, row: 2 }, + { ...tile("b", 1), col: 1, row: 2 }, + ]; + const placements = pack(tiles, WIDE); + expect(placements.find((p) => p.id === "a")).toMatchObject({ col: 1, row: 2 }); + expect(placements.find((p) => p.id === "b")).toMatchObject({ col: 1, row: 1 }); + }); + + it("a wide pin at col > 1 reads as UNPINNED in tall (documented lossy case)", () => { + const tiles: TileLayoutEntry[] = [{ ...tile("a", 0), col: 3, row: 1 }]; + expect(pack(tiles, TALL).find((p) => p.id === "a")).toMatchObject({ col: 1, row: 1 }); + }); + + it("tall pins map through the universal frame in both directions", () => { + // Universal column 1 folds onto tall's 2 columns in reading order: + // u1→(1,1) u2→(2,1) u3→(1,2) u4→(2,2). + expect(toRenderCell({ col: 1, row: 1 }, TALL)).toEqual({ col: 1, row: 1 }); + expect(toRenderCell({ col: 1, row: 2 }, TALL)).toEqual({ col: 2, row: 1 }); + expect(toRenderCell({ col: 1, row: 3 }, TALL)).toEqual({ col: 1, row: 2 }); + expect(toRenderCell({ col: 1, row: 4 }, TALL)).toEqual({ col: 2, row: 2 }); + expect(toUniversalCell({ col: 2, row: 2 }, TALL)).toEqual({ col: 1, row: 4 }); + // Wide is the identity. + expect(toRenderCell({ col: 2, row: 2 }, WIDE)).toEqual({ col: 2, row: 2 }); + expect(toUniversalCell({ col: 2, row: 2 }, WIDE)).toEqual({ col: 2, row: 2 }); + expect(UNIVERSAL_ROWS).toBe(4); + }); + + it("a layout dragged in TALL renders its pins when back in WIDE", () => { + // Committed in tall: a at tall (1,1) → universal (1,1); b at tall (2,1) → + // universal (1,2). Back in wide they render exactly there (col 1). + const tiles: TileLayoutEntry[] = [ + { ...tile("a", 0), col: 1, row: 1 }, + { ...tile("b", 1), col: 1, row: 2 }, + ]; + expect(pack(tiles, WIDE).map((p) => [p.id, p.col, p.row])).toEqual([ + ["a", 1, 1], + ["b", 1, 2], + ]); + }); + + it("deleting a workspace leaves its cells EMPTY (mergeLayout keeps other pins)", () => { + const persisted = { + version: 2 as const, + tiles: [ + { ...tile("a", 0), col: 1, row: 1 }, + { ...tile("gone", 1), col: 1, row: 2 }, + { ...tile("b", 2), col: 1, row: 3 }, + ] as TileLayoutEntry[], + }; + const merged = mergeLayout(persisted, ["a", "b"]); + expect(merged.map((t) => [t.id, t.col, t.row])).toEqual([ + ["a", 1, 1], + ["b", 1, 3], + ]); + // The gap at (1,2) stays: a new workspace flows/pins INTO it. + expect(firstEmptySlot(merged, { colSpan: 1, rowSpan: 1 }, WIDE)).toEqual({ col: 1, row: 2 }); + }); +}); + +describe("type surface", () => { + it("exposes the geometry fields the picker and grid need", () => { + // A 500px-tall canvas: the raw unit (100) clamps UP to the 112px minimum, + // so the gap is 12px and the margin 37px (never the old fixed 8px gap). + const geometry: GridGeometry = geometryFor(1000, 500); + expect([ + geometry.mode, + geometry.flowRows, + geometry.flowCols, + geometry.unit.gap, + geometry.margin, + ]).toEqual(["wide", 4, 0, 12, 37]); + }); +}); diff --git a/src/features/workspaces/logic/tile-layout.ts b/src/features/workspaces/logic/tile-layout.ts new file mode 100644 index 0000000..6f7c281 --- /dev/null +++ b/src/features/workspaces/logic/tile-layout.ts @@ -0,0 +1,709 @@ +/** + * Pure tile-layout logic for the Win8-Start-style workspace home grid — zero + * DOM, zero Svelte, zero effects. + * + * The PERSISTED data (v2): `{ version, tiles: [{ id, colSpan, rowSpan, order, + * col?, row? }] }` — each tile's spans, arrival order, and OPTIONAL pinned + * anchor. Anchors live in a UNIVERSAL 4-row frame (wide's frame; tall folds + * its 2 columns onto frame column 1), so the same document renders in either + * geometry with no orientation conversion step: an anchor that doesn't map + * into the current geometry (or whose clamped span can't fit there) reads as + * UNPINNED, and unpinned tiles flow into the first free cells — holes between + * pins included. Empty cells are real: nothing ever slides backward, so gaps + * only close when a drop's displacement pushes a run of filled cells through + * them (see {@link displacePlace}). + * + * Terminology (distinct from the layout feature's nested split-tree "tile"): + * a HOME TILE is one workspace's cell in the flat start-screen grid. + */ + +/** The grid geometry mode: landscape/desktop ("wide") or portrait/mobile ("tall"). */ +export type TileGeometryMode = "wide" | "tall"; + +/** The px size of one tile unit (one grid cell) + the grid gap. */ +export interface TileUnitSize { + /** + * The unit's px width. `0` means "fluid" (the tall grid sizes its fixed + * columns in `1fr` so two tiles fill a phone's width). + */ + readonly width: number; + readonly height: number; + readonly gap: number; +} + +/** + * The full grid geometry a viewport resolves to. `flowRows`/`flowCols` name + * the FIXED track counts across the fill direction's cross axis: + * wide mode fixes ROWS (tiles flow column-wise, columns grow unbounded); + * tall mode fixes COLS (tiles flow row-wise, rows grow unbounded). `0` = + * unbounded. The picker (resize) dimensions equal the span caps. + */ +export interface GridGeometry { + readonly mode: TileGeometryMode; + /** Fixed row tracks (wide: 4; tall: 0 = rows grow unbounded). */ + readonly flowRows: number; + /** Fixed column tracks (tall: 2; wide: 0 = columns grow unbounded). */ + readonly flowCols: number; + /** Maximum horizontal span a tile may occupy in this geometry. */ + readonly maxColSpan: number; + /** Maximum vertical span a tile may occupy in this geometry. */ + readonly maxRowSpan: number; + readonly unit: TileUnitSize; + /** + * The breathing room around the whole tile field — the grid never renders + * flush against the canvas edge. In wide mode the scroller pads all four + * sides by this amount (the pan arrow/scroll indicator offset inward by it + * too); in tall mode it insets the two fluid columns horizontally. + */ + readonly margin: number; +} + +/** One persisted tile: which workspace it pins, how big it wants to be, its manual order, and its anchor. */ +export interface TileLayoutEntry { + /** The workspace id (the slug). */ + readonly id: string; + readonly colSpan: number; + readonly rowSpan: number; + /** Lower sorts earlier. Arrival order for unpinned flow + first-wins tie-breaks. */ + readonly order: number; + /** + * The pinned anchor cell (1-based) in the UNIVERSAL 4-row frame, or absent = + * unpinned (the tile flows into the first free cells at render time). Tall + * mode normalizes its 2-column grid into this frame: render { c, r } ↔ + * universal { col: 1, row: 2(r−1)+c }. A pin outside the current geometry's + * fixed cross-axis reads as UNPINNED (render-time clamp — like spans). + */ + readonly col?: number; + readonly row?: number; +} + +/** The persisted document (localStorage via the injected `LocalStore` adapter). */ +export interface TileLayoutData { + readonly version: 1 | 2; + readonly tiles: readonly TileLayoutEntry[]; +} + +/** A tile's resolved footprint on the grid (1-based grid coordinates). */ +export interface Placement { + readonly id: string; + readonly col: number; + readonly row: number; + readonly colSpan: number; + readonly rowSpan: number; +} + +/** A requested span (pre-clamping). */ +export interface Span { + readonly colSpan: number; + readonly rowSpan: number; +} + +/** The viewport assumed before the canvas has been measured (a landscape desktop). */ +export const DEFAULT_VIEWPORT = { width: 1280, height: 800 } as const; + +/** + * The wide unit's clamp band (px). Tiles FILL the canvas' height across the 4 + * fixed rows (the Start-screen feel), but never shrink below a legible small + * tile nor grow past a comfortable large one. + */ +const WIDE_UNIT_MIN = 112; +const WIDE_UNIT_MAX = 184; + +/** The wide canvas' outer margin band (px) — proportional to the unit size. */ +const WIDE_MARGIN_MIN = 24; +const WIDE_MARGIN_MAX = 64; + +/** The tall unit: the two fluid columns size in `1fr` (width `0` = fluid). */ +const TALL_UNIT: TileUnitSize = { width: 0, height: 148, gap: 12 }; +const TALL_MARGIN = 16; + +/** Round to whole pixels (fractional track sizes blur tile edges). */ +function px(value: number): number { + return Math.round(value); +} + +function clampNumber(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +/** + * Resolve the grid geometry from the tile canvas' size. Landscape/desktop → + * WIDE (4 fixed rows, column-wise flow, 4×2 span cap); portrait/mobile → TALL + * (2 fixed columns, row-wise flow, 2×2 span cap). Degenerate sizes (not yet + * measured) resolve to wide. + * + * The wide unit is DERIVED from the canvas height so the 4 rows exactly fill + * it (unit + margin + gap all proportional, clamped to their bands); the tall + * unit is fixed (its columns are fluid instead). Pure: numbers in, geometry + * out. + */ +export function geometryFor(width: number, height: number): GridGeometry { + const tall = width > 0 && height > 0 && height > width; + if (tall) { + return { + mode: "tall", + flowRows: 0, + flowCols: 2, + maxColSpan: 2, + maxRowSpan: 2, + unit: TALL_UNIT, + margin: TALL_MARGIN, + }; + } + // Solve 4·unit + 3·gap + 2·margin = height with gap = unit/9 and margin = + // unit/3: 4u + u/3 + 2u/3 = 5u → u = height/5. Unmeasured (height ≤ 0) → + // the band's middle, not a tiny tile. + const raw = height > 0 ? height / 5 : (WIDE_UNIT_MIN + WIDE_UNIT_MAX) / 2; + const unit = px(clampNumber(raw, WIDE_UNIT_MIN, WIDE_UNIT_MAX)); + const gap = Math.max(10, px(unit / 9)); + const margin = px(clampNumber(unit / 3, WIDE_MARGIN_MIN, WIDE_MARGIN_MAX)); + return { + mode: "wide", + flowRows: 4, + flowCols: 0, + maxColSpan: 4, + maxRowSpan: 2, + unit: { width: unit, height: unit, gap }, + margin, + }; +} + +/** Sort entries by their persisted order (stable — ties keep their relative order). */ +export function sortTiles<T extends TileLayoutEntry>(tiles: readonly T[]): T[] { + return [...tiles].sort((a, b) => a.order - b.order); +} + +/** Clamp a requested span to a geometry's caps, coercing garbage (non-positive/NaN) to 1. */ +export function clampSpan<T extends Span>(span: T, geometry: GridGeometry): Span { + return { + colSpan: clampUnit(span.colSpan, geometry.maxColSpan), + rowSpan: clampUnit(span.rowSpan, geometry.maxRowSpan), + }; +} + +function clampUnit(value: number, max: number): number { + if (!Number.isFinite(value)) return 1; + const int = Math.floor(value); + if (int < 1) return 1; + return Math.min(int, max); +} + +/** + * A position's rank in the geometry's FILL ORDER — the order slots are visited + * when packing: wide fills column-wise (top-to-bottom, then the next column); + * tall fills row-wise (left-to-right, then the next row). Pure; only used for + * comparisons within one geometry. + */ +export function fillOrderKey(col: number, row: number, geometry: GridGeometry): number { + if (geometry.mode === "wide") return (col - 1) * geometry.flowRows + (row - 1); + return (row - 1) * geometry.flowCols + (col - 1); +} + +/** + * The universal frame's fixed row count (the wide grid's). Tall render cells + * map onto column 1 of this frame; wide is the identity. + */ +export const UNIVERSAL_ROWS = 4; + +/** A tile's anchor cell (1-based). */ +export interface Cell { + readonly col: number; + readonly row: number; +} + +/** + * Universal-frame anchor → render-geometry cell. Wide: identity. Tall: column + * 1 of the universal frame folds onto the 2 render columns (universal row u → + * render { col: (u−1)%2 + 1, row: ⌈u/2⌉ }); any universal col > 1 cannot be + * represented in tall → `null` (the tile reads as UNPINNED in tall). + * Pure. + */ +export function toRenderCell(anchor: Cell, geometry: GridGeometry): Cell | null { + if (geometry.mode === "wide") return anchor; + if (anchor.col !== 1) return null; + return { col: ((anchor.row - 1) % 2) + 1, row: Math.floor((anchor.row - 1) / 2) + 1 }; +} + +/** Render-geometry cell → universal-frame anchor (the commit direction). Pure. */ +export function toUniversalCell(cell: Cell, geometry: GridGeometry): Cell { + if (geometry.mode === "wide") return cell; + return { col: 1, row: 2 * (cell.row - 1) + cell.col }; +} + +function cellsFree( + occupied: ReadonlySet<string>, + col: number, + row: number, + colSpan: number, + rowSpan: number, +): boolean { + for (let r = row; r < row + rowSpan; r++) { + for (let c = col; c < col + colSpan; c++) { + if (occupied.has(`${r}:${c}`)) return false; + } + } + return true; +} + +function occupy( + occupied: Set<string>, + col: number, + row: number, + colSpan: number, + rowSpan: number, +): void { + for (let r = row; r < row + rowSpan; r++) { + for (let c = col; c < col + colSpan; c++) { + occupied.add(`${r}:${c}`); + } + } +} + +function maxOccupied(occupied: ReadonlySet<string>, axis: "col" | "row"): number { + let max = 0; + for (const cell of occupied) { + const value = Number.parseInt( + axis === "row" ? (cell.split(":")[0] ?? "") : (cell.split(":")[1] ?? ""), + 10, + ); + if (Number.isFinite(value) && value > max) max = value; + } + return max; +} + +/** + * The first slot (in fill order) where a span-sized footprint fits without + * overlapping or exceeding the geometry's fixed cross-axis. Beyond the + * occupied region every cell is free, so the scan is bounded by the occupied + * extent — the result always exists (the flow is unbounded along its main + * axis). Pure: occupancy + span + geometry in, slot out. + */ +function firstFit( + occupied: ReadonlySet<string>, + span: Span, + geometry: GridGeometry, +): { col: number; row: number } { + const { colSpan, rowSpan } = clampSpan(span, geometry); + if (geometry.mode === "wide") { + const maxCol = maxOccupied(occupied, "col"); + for (let col = 1; col <= maxCol; col++) { + for (let row = 1; row <= geometry.flowRows - rowSpan + 1; row++) { + if (cellsFree(occupied, col, row, colSpan, rowSpan)) return { col, row }; + } + } + return { col: maxCol + 1, row: 1 }; + } + const maxRow = maxOccupied(occupied, "row"); + for (let row = 1; row <= maxRow; row++) { + for (let col = 1; col <= geometry.flowCols - colSpan + 1; col++) { + if (cellsFree(occupied, col, row, colSpan, rowSpan)) return { col, row }; + } + } + return { col: 1, row: maxRow + 1 }; +} + +/** + * THE entries → coordinates transform, v2. Two passes, one scan each: + * + * 1. **Pinned pass** (in entry order — first-pinned-wins on overlap, strictly + * one-ply: a displaced pin never bumps another pin): a pinned tile lands at + * its anchor (universal frame → render cell) when the anchor maps into this + * geometry, its clamped footprint fits the fixed cross-axis there, and no + * already-placed tile overlaps. Otherwise the tile reads as UNPINNED. + * 2. **Flow pass**: unpinned tiles (never pinned, out-of-range, clashing) take + * the first fitting FREE slot in fill order — holes between pins count. + * + * Requested spans clamp to the geometry (render-time only; the persisted spans + * stay raw). Pure: entries + geometry in, a NEW placements array out (entry + * order — the render order). + */ +export function pack(tiles: readonly TileLayoutEntry[], geometry: GridGeometry): Placement[] { + const occupied = new Set<string>(); + const placements: (Placement | undefined)[] = []; + const flowing: { index: number; tile: TileLayoutEntry }[] = []; + const ordered = sortTiles(tiles); + for (let index = 0; index < ordered.length; index++) { + const tile = ordered[index]; + if (!tile) continue; + const { colSpan, rowSpan } = clampSpan(tile, geometry); + const anchor = pinnedAnchor(tile, geometry); + if ( + anchor !== null && + fitsCrossAxis(anchor, colSpan, rowSpan, geometry) && + cellsFree(occupied, anchor.col, anchor.row, colSpan, rowSpan) + ) { + occupy(occupied, anchor.col, anchor.row, colSpan, rowSpan); + placements[index] = { id: tile.id, col: anchor.col, row: anchor.row, colSpan, rowSpan }; + } else { + flowing.push({ index, tile }); + } + } + for (const { index, tile } of flowing) { + const { colSpan, rowSpan } = clampSpan(tile, geometry); + const slot = firstFit(occupied, { colSpan, rowSpan }, geometry); + occupy(occupied, slot.col, slot.row, colSpan, rowSpan); + placements[index] = { id: tile.id, col: slot.col, row: slot.row, colSpan, rowSpan }; + } + return placements.filter((placement) => placement !== undefined); +} + +/** + * A pinned tile's render-geometry anchor, or `null` when the tile is unpinned + * (no anchor) or its anchor cannot be represented in this geometry (a wide + * column > 1 read in tall). Pure. + */ +function pinnedAnchor(tile: TileLayoutEntry, geometry: GridGeometry): Cell | null { + if (tile.col === undefined || tile.row === undefined) return null; + return toRenderCell({ col: tile.col, row: tile.row }, geometry); +} + +/** The footprint fits the geometry's FIXED cross-axis at this anchor. */ +function fitsCrossAxis( + anchor: Cell, + colSpan: number, + rowSpan: number, + geometry: GridGeometry, +): boolean { + if (geometry.mode === "wide") return anchor.row + rowSpan - 1 <= geometry.flowRows; + return anchor.col + colSpan - 1 <= geometry.flowCols; +} + +/** + * The first fitting slot at or after a minimum stream key (the displacement + * re-anchor scan — pushed tiles only move FORWARD). Pure. + */ +function firstFitFrom( + occupied: ReadonlySet<string>, + span: Span, + geometry: GridGeometry, + minKey: number, +): Cell { + const { colSpan, rowSpan } = clampSpan(span, geometry); + if (geometry.mode === "wide") { + const maxCol = maxOccupied(occupied, "col") + 1; + for (let col = 1; col <= maxCol + 1; col++) { + for (let row = 1; row <= geometry.flowRows - rowSpan + 1; row++) { + if (fillOrderKey(col, row, geometry) < minKey) continue; + if (cellsFree(occupied, col, row, colSpan, rowSpan)) return { col, row }; + } + } + return { col: maxCol + 2, row: 1 }; + } + const maxRow = maxOccupied(occupied, "row") + 1; + for (let row = 1; row <= maxRow + 1; row++) { + for (let col = 1; col <= geometry.flowCols - colSpan + 1; col++) { + if (fillOrderKey(col, row, geometry) < minKey) continue; + if (cellsFree(occupied, col, row, colSpan, rowSpan)) return { col, row }; + } + } + return { col: 1, row: maxRow + 2 }; +} + +/** + * THE displacement op — drop `draggedId` at `cell` over the CURRENT RENDER + * placements (the squares exactly as the user sees them). Semantics (user- + * specified): the dragged tile leaves its slot (the gap OPENS — nothing slides + * backward, ever), anchors exactly at `cell`, and the contiguous run of filled + * cells pushes FORWARD in fill order until the first empty cell absorbs it: + * + * `X _ X` + Y at slot 1 → `Y X X` + * `_ X Z _` + Y at slot 2 → `_ Y X Z` + * + * Tiles keep their positions unless their footprint overlaps an already-placed + * one; the displaced re-anchor at the next fitting slot forward (spans ride + * along; a span that can't fit the fixed cross-axis anywhere forward wraps to + * the first fitting slot — it must land somewhere). Pure: placements + drop + * in, a NEW placements array out — in the INPUT array order (only coordinates + * change). A keyed `{#each}` over the result therefore never moves DOM nodes; + * moving a captured element drops its pointer capture — the "stuck tile" bug. + */ +export function displacePlace( + placements: readonly Placement[], + draggedId: string, + cell: Cell, + geometry: GridGeometry, +): Placement[] { + const dragged = placements.find((placement) => placement.id === draggedId); + if (!dragged) return [...placements]; + const occupied = new Set<string>(); + occupy(occupied, cell.col, cell.row, dragged.colSpan, dragged.rowSpan); + const placed: Placement[] = [{ ...dragged, col: cell.col, row: cell.row }]; + const others = placements + .filter((placement) => placement.id !== draggedId) + .map((placement) => ({ + placement, + key: fillOrderKey(placement.col, placement.row, geometry), + })) + .sort((a, b) => a.key - b.key); + for (const { placement, key } of others) { + const { colSpan, rowSpan } = placement; + if (cellsFree(occupied, placement.col, placement.row, colSpan, rowSpan)) { + occupy(occupied, placement.col, placement.row, colSpan, rowSpan); + placed.push(placement); + continue; + } + let slot = firstFitFrom(occupied, placement, geometry, key); + if (!fitsCrossAxis(slot, colSpan, rowSpan, geometry)) { + slot = firstFit(occupied, placement, geometry); + } + occupy(occupied, slot.col, slot.row, colSpan, rowSpan); + placed.push({ ...placement, col: slot.col, row: slot.row }); + } + // Return in the INPUT render order (coordinates change only): the live drag + // preview feeds a keyed `{#each}`, and a reordered array moves the DOM nodes + // — a moved captured element silently loses pointer capture (the stuck-tile + // bug). The commit path re-sorts to stream order itself (`placeAtCell`). + const byId = new Map(placed.map((placement) => [placement.id, placement])); + return placements.map((placement) => byId.get(placement.id) ?? placement); +} + +/** + * The commit path: pack the persisted entries, displace at `cell`, and pin + * EVERY tile back to the entries at its resulting anchor (universal frame — + * the persisted spans stay raw, only anchors + order change; order renumbers + * in stream order). This is the drag-drop write. Pure: entries in, NEW + * entries out. + */ +export function placeAtCell( + tiles: readonly TileLayoutEntry[], + id: string, + cell: Cell, + geometry: GridGeometry, +): TileLayoutEntry[] { + // An unknown dragged id changes nothing (order-sorted copy — the op's + // canonical no-op shape, like `moveTile` before it). + if (!tiles.some((tile) => tile.id === id)) return sortTiles(tiles); + // displacePlace preserves the input RENDER order for the live preview; the + // commit pins + renumbers in STREAM order, so re-sort before pinning. + const displaced = displacePlace(pack(tiles, geometry), id, cell, geometry) + .slice() + .sort((a, b) => fillOrderKey(a.col, a.row, geometry) - fillOrderKey(b.col, b.row, geometry)); + const result: TileLayoutEntry[] = []; + for (const placement of displaced) { + const entry = tiles.find((tile) => tile.id === placement.id); + if (!entry) continue; + const anchor = toUniversalCell(placement, geometry); + result.push({ ...entry, col: anchor.col, row: anchor.row }); + } + return result.map((entry, index) => (entry.order === index ? entry : { ...entry, order: index })); +} + +/** + * The drag target's clamp on the geometry's UNBOUNDED axis — a drop may aim + * at most `extra` columns (wide) / rows (tall) past the current content edge, + * so a drag can never create an endlessly growing canvas ("hard to place if + * it just grows"): wide bound = the right-most occupied column (span-aware) + + * `extra`; tall = the bottom-most occupied row + `extra`. Empty content → the + * first tiles may land within the first `extra` columns. The bound is measured + * on the COMMITTED placements — a drag always compares against the layout it + * started from, never the live preview, so the limit cannot ratchet mid-drag. + * Pure: placements + geometry in, the max allowed column (wide) / row (tall) + * out. + */ +export function dragAppendLimit( + placements: readonly Placement[], + geometry: GridGeometry, + extra = 3, +): number { + let extent = 0; + for (const placement of placements) { + extent = Math.max( + extent, + geometry.mode === "wide" + ? placement.col + placement.colSpan - 1 + : placement.row + placement.rowSpan - 1, + ); + } + return extent + extra; +} + +/** + * The first footprint-fitting EMPTY slot in fill order — where a NEW tile of + * the given span would go (the header's create flow). Pure: entries + span + + * geometry in, a slot placement out (always exists; may be a new column/row + * beyond the current extent). + */ +export function firstEmptySlot( + tiles: readonly TileLayoutEntry[], + span: Span, + geometry: GridGeometry, +): { col: number; row: number } { + return firstFit(packOccupancy(tiles, geometry), span, geometry); +} + +/** Build the occupancy set of the tiles' packed positions. */ +function packOccupancy(tiles: readonly TileLayoutEntry[], geometry: GridGeometry): Set<string> { + const occupied = new Set<string>(); + for (const placement of pack(tiles, geometry)) { + occupy(occupied, placement.col, placement.row, placement.colSpan, placement.rowSpan); + } + return occupied; +} + +/** Re-assign orders 0..n-1 by array position (the canonical order encoding after structural edits). */ +function renumber(tiles: readonly TileLayoutEntry[]): TileLayoutEntry[] { + return tiles.map((tile, index) => (tile.order === index ? tile : { ...tile, order: index })); +} + +/** + * Pin a tile at the first EMPTY slot in fill order — the header-create + * placement (holes between pins count). Idempotent: an id already in the + * layout is RE-PINNED there (spans preserved), not duplicated — this closes + * the race where the list-refresh sync appended the id first. Unknown ids + * append as a new 1×1 pinned entry. Pure: entries in, a NEW entries array out + * (order preserved — anchors, not order, place tiles now). + */ +export function pinAtFirstEmpty( + tiles: readonly TileLayoutEntry[], + id: string, + geometry: GridGeometry, +): TileLayoutEntry[] { + const without = sortTiles(tiles).filter((tile) => tile.id !== id); + const slot = firstEmptySlot(without, { colSpan: 1, rowSpan: 1 }, geometry); + const anchor = toUniversalCell(slot, geometry); + const existing = tiles.find((tile) => tile.id === id); + const entry: TileLayoutEntry = existing + ? { ...existing, col: anchor.col, row: anchor.row } + : { + id, + colSpan: 1, + rowSpan: 1, + order: without.reduce((max, tile) => Math.max(max, tile.order + 1), 0), + col: anchor.col, + row: anchor.row, + }; + return [...without, entry]; +} + +/** + * Resize one tile: set its persisted spans (sanitized to positive integers — + * NOT clamped to the current geometry; clamping is a render-time concern so a + * wide-sized tile survives an orientation switch). Unknown ids are ignored. + * Pure: entries in, a NEW entries array out. + */ +export function resizeTile( + tiles: readonly TileLayoutEntry[], + id: string, + colSpan: number, + rowSpan: number, +): TileLayoutEntry[] { + const sanitized: Span = { + colSpan: clampUnit(colSpan, Number.MAX_SAFE_INTEGER), + rowSpan: clampUnit(rowSpan, Number.MAX_SAFE_INTEGER), + }; + return sortTiles(tiles).map((tile) => + tile.id === id ? { ...tile, colSpan: sanitized.colSpan, rowSpan: sanitized.rowSpan } : tile, + ); +} + +/** + * Re-apply the recent-activity order (the header overflow "Sort" action) AND + * clear every pin — an explicit ARRANGEMENT RESET: tiles sort by their + * workspace's `lastActivityAt` descending (unknown ids last, ties stable), + * orders renumber, and the now-unpinned tiles first-fit with no gaps. Pure. + */ +export function sortByRecentActivity( + tiles: readonly TileLayoutEntry[], + lastActivityById: ReadonlyMap<string, number>, +): TileLayoutEntry[] { + const sorted = sortTiles(tiles).sort( + (a, b) => (lastActivityById.get(b.id) ?? 0) - (lastActivityById.get(a.id) ?? 0), + ); + return renumber( + sorted.map((tile) => { + const { col: _col, row: _row, ...unpinned } = tile; + return unpinned; + }), + ); +} + +/** + * Merge the persisted layout with the live workspace list: workspaces unknown + * to the layout (e.g. auto-created via deep-link) APPEND at the end (default + * 1×1, order after the current max); entries for deleted workspaces DROP; + * duplicate ids dedup (first wins); the result sorts by order. When a + * geometry is given, the returned VIEW clamps spans to it (the persisted + * document keeps the raw spans — clamping is transient per geometry). Pure: + * both inputs in, a NEW entries array out. + */ +export function mergeLayout( + persisted: TileLayoutData | null | undefined, + workspaceIds: readonly string[], + geometry?: GridGeometry, +): TileLayoutEntry[] { + const known = new Set(workspaceIds); + const kept: TileLayoutEntry[] = []; + const seen = new Set<string>(); + let maxOrder = 0; + for (const tile of sortTiles(persisted?.tiles ?? [])) { + if (!known.has(tile.id) || seen.has(tile.id)) continue; + seen.add(tile.id); + kept.push(tile); + if (tile.order > maxOrder) maxOrder = tile.order; + } + const appended: TileLayoutEntry[] = []; + for (const id of workspaceIds) { + if (seen.has(id)) continue; + seen.add(id); + maxOrder += 1; + appended.push({ id, colSpan: 1, rowSpan: 1, order: maxOrder }); + } + const merged = [...kept, ...appended]; + if (!geometry) return merged; + return merged.map((tile) => ({ ...tile, ...clampSpan(tile, geometry) })); +} + +/** + * Coerce an unknown parsed-JSON value into layout data (the `LocalStore` load + * is untyped at the boundary). v1 documents (order + spans only) MIGRATE to + * v2 in memory: their tiles become unpinned, which packs identically to v1's + * first-fit — the layout only starts carrying anchors after the user's first + * drop. v2 entries keep their anchors; malformed entries coerce (missing + * spans → 1, garbage anchors → unpinned, garbage entries → dropped). Anything + * else yields the empty layout. Pure: unknown in, v2 data out. + */ +export function sanitizeLayout(raw: unknown): TileLayoutData { + if (typeof raw !== "object" || raw === null) return EMPTY_LAYOUT; + const version = (raw as { version?: unknown }).version; + if (version !== 1 && version !== 2) return EMPTY_LAYOUT; + const rawTiles = (raw as { tiles?: unknown }).tiles; + if (!Array.isArray(rawTiles)) return EMPTY_LAYOUT; + const tiles: TileLayoutEntry[] = []; + const seen = new Set<string>(); + for (let index = 0; index < rawTiles.length; index++) { + const rawTile = rawTiles[index]; + if (typeof rawTile !== "object" || rawTile === null) continue; + const id = (rawTile as { id?: unknown }).id; + if (typeof id !== "string" || id === "" || seen.has(id)) continue; + seen.add(id); + const col = coerceAnchor((rawTile as { col?: unknown }).col); + const row = coerceAnchor((rawTile as { row?: unknown }).row); + tiles.push({ + id, + colSpan: coerceSpan((rawTile as { colSpan?: unknown }).colSpan), + rowSpan: coerceSpan((rawTile as { rowSpan?: unknown }).rowSpan), + order: coerceOrder((rawTile as { order?: unknown }).order, index), + // An anchor is only meaningful as a PAIR — a lone coordinate drops both. + ...(version === 2 && col !== undefined && row !== undefined ? { col, row } : {}), + }); + } + return { version: 2, tiles }; +} + +const EMPTY_LAYOUT: TileLayoutData = { version: 2, tiles: [] }; + +function coerceSpan(value: unknown): number { + if (typeof value !== "number" || !Number.isFinite(value) || value < 1) return 1; + return Math.floor(value); +} + +function coerceOrder(value: unknown, fallback: number): number { + if (typeof value !== "number" || !Number.isFinite(value)) return fallback; + return Math.floor(value); +} + +/** A valid anchor coordinate (1-based positive integer) or undefined. */ +function coerceAnchor(value: unknown): number | undefined { + if (typeof value !== "number" || !Number.isFinite(value) || value < 1) return undefined; + return Math.floor(value); +} diff --git a/src/features/workspaces/logic/view-model.test.ts b/src/features/workspaces/logic/view-model.test.ts index 44d2f31..31366ba 100644 --- a/src/features/workspaces/logic/view-model.test.ts +++ b/src/features/workspaces/logic/view-model.test.ts @@ -1,6 +1,39 @@ import type { WorkspaceEntry } from "@dispatch/wire"; import { describe, expect, it } from "vitest"; -import { applyStarred, pageTitle, relativeTime, sortWorkspaces } from "./view-model"; +import { + applyArchived, + applyStarred, + pageTitle, + relativeTime, + sortWorkspaces, + tileTone, +} from "./view-model"; + +describe("tileTone", () => { + const TONES = ["primary", "secondary", "accent", "info", "success", "warning"]; + + it("is deterministic — the same id always gets the same tone", () => { + expect(tileTone("my-workspace")).toBe(tileTone("my-workspace")); + }); + + it("only returns known tones", () => { + for (const id of ["default", "a", "my-workspace", "x-1", "project-phoenix", "zzz"]) { + expect(TONES).toContain(tileTone(id)); + } + }); + + it("spreads distinct ids across more than one tone", () => { + const tones = new Set( + ["default", "alpha", "beta", "gamma", "delta", "epsilon", "zeta", "eta"].map(tileTone), + ); + expect(tones.size).toBeGreaterThan(1); + }); + + it("is case-sensitive (ids are slugs, but the hash makes no normalization promise)", () => { + // Not a correctness requirement — just documents the hash reads raw chars. + expect(typeof tileTone("ABC")).toBe("string"); + }); +}); describe("relativeTime", () => { const now = 1_000_000_000_000; // 2001-09-09 @@ -23,10 +56,15 @@ describe("relativeTime", () => { expect(relativeTime(now - 3 * 24 * 60 * 60_000, now)).toBe("3d"); }); - it("is a short date beyond a week", () => { - // 7+ days ago: just check it is a MM/DD string. - const s = relativeTime(now - 10 * 24 * 60 * 60_000, now); - expect(s).toMatch(/^\d{2}\/\d{2}$/); + it("keeps days past a week (no short date) up to a month", () => { + expect(relativeTime(now - 10 * 24 * 60 * 60_000, now)).toBe("10d"); + expect(relativeTime(now - 29 * 24 * 60 * 60_000, now)).toBe("29d"); + }); + + it("is months past a month", () => { + expect(relativeTime(now - 30 * 24 * 60 * 60_000, now)).toBe("1mo"); + expect(relativeTime(now - 75 * 24 * 60 * 60_000, now)).toBe("2mo"); + expect(relativeTime(now - 365 * 24 * 60 * 60_000, now)).toBe("12mo"); }); }); @@ -38,6 +76,7 @@ describe("pageTitle", () => { defaultCwd: null, defaultComputerId: null, starred: false, + archived: false, createdAt: 0, lastActivityAt: 0, conversationCount: 0, @@ -75,6 +114,7 @@ describe("sortWorkspaces", () => { defaultCwd: null, defaultComputerId: null, starred, + archived: false, createdAt: 0, lastActivityAt, conversationCount: 0, @@ -133,6 +173,7 @@ describe("applyStarred", () => { defaultCwd: null, defaultComputerId: null, starred, + archived: false, createdAt: 0, lastActivityAt: 0, conversationCount: 0, @@ -177,3 +218,50 @@ describe("applyStarred", () => { expect(reverted[0]?.starred).toBe(false); }); }); + +describe("applyArchived", () => { + const entry = (id: string, archived: boolean): WorkspaceEntry => ({ + id, + title: id, + defaultCwd: null, + defaultComputerId: null, + starred: false, + archived, + createdAt: 0, + lastActivityAt: 0, + conversationCount: 0, + }); + + it("sets the named workspace's archived flag", () => { + const list = [entry("a", false), entry("b", false)]; + const next = applyArchived(list, "b", true); + expect(next.map((w) => [w.id, w.archived])).toEqual([ + ["a", false], + ["b", true], + ]); + }); + + it("returns a new array (does not mutate the input)", () => { + const list = [entry("a", false)]; + const next = applyArchived(list, "a", true); + expect(next).not.toBe(list); + expect(list[0]?.archived).toBe(false); + expect(next[0]?.archived).toBe(true); + }); + + it("leaves other entries referentially unchanged (only the target is replaced)", () => { + const a = entry("a", false); + const b = entry("b", false); + const next = applyArchived([a, b], "b", true); + expect(next[0]).toBe(a); + expect(next[1]).not.toBe(b); + }); + + it("can revert by re-applying the previous value (unarchive)", () => { + const list = [entry("a", false)]; + const optimistic = applyArchived(list, "a", true); + expect(optimistic[0]?.archived).toBe(true); + const reverted = applyArchived(optimistic, "a", false); + expect(reverted[0]?.archived).toBe(false); + }); +}); diff --git a/src/features/workspaces/logic/view-model.ts b/src/features/workspaces/logic/view-model.ts index b994b7c..cdf99c8 100644 --- a/src/features/workspaces/logic/view-model.ts +++ b/src/features/workspaces/logic/view-model.ts @@ -6,6 +6,40 @@ import type { WorkspaceEntry } from "@dispatch/wire"; import type { Route } from "./route"; /** + * A home tile's color tone. Windows 8's Start screen gave every tile its own + * saturated color; here the tile's workspace id DETERMINISTICALLY picks one of + * the theme's six semantic tones, so the same workspace keeps its color across + * sessions and themes (the tone name maps to a DaisyUI color, which adapts to + * the active theme). Starred stays a separate visual concern (a badge), NOT a + * tone — any tile may be starred regardless of its color. + */ +export type TileTone = "primary" | "secondary" | "accent" | "info" | "success" | "warning"; + +/** The tones a tile may take, in hash order. */ +const TILE_TONES: readonly TileTone[] = [ + "primary", + "secondary", + "accent", + "info", + "success", + "warning", +]; + +/** + * Map a workspace id to its tile tone. Deterministic (FNV-1a hash of the id) + * and uniformly distributed across the six tones — pure: id in, tone out. + */ +export function tileTone(id: string): TileTone { + let hash = 0x811c9dc5; + for (let index = 0; index < id.length; index++) { + hash ^= id.charCodeAt(index); + // FNV prime (16777619), kept in 32-bit unsigned range. + hash = Math.imul(hash, 0x01000193) >>> 0; + } + return TILE_TONES[hash % TILE_TONES.length] ?? "primary"; +} + +/** * The browser tab / page (`document.title`) text for a route. The home route * (`/`) is "Dispatch"; a workspace route (`/<id>`) is "Dispatch: {title}", * using the workspace's display title and falling back to the URL slug (`id`) @@ -50,6 +84,20 @@ export function applyStarred<T extends WorkspaceEntry>( } /** + * Return a NEW list with the one workspace's `archived` flag set (immutably — + * the entry is replaced, the rest keep their identity). Pure: the optimistic + * archive/unarchive transformation shared by the apply + the error revert. A + * missing `id` leaves the list unchanged; a subsequent refresh reconciles. + */ +export function applyArchived<T extends WorkspaceEntry>( + workspaces: readonly T[], + id: string, + archived: boolean, +): T[] { + return workspaces.map((w) => (w.id === id ? { ...w, archived } : w)); +} + +/** * Format an epoch-ms timestamp as a short relative string ("now", "3m", "2h", * "5d", or a date). Pure: `now` + `then` in, string out. Future timestamps * (a workspace just created) read as "now". @@ -62,10 +110,8 @@ export function relativeTime(then: number, now: number): string { const hours = Math.floor(mins / 60); if (hours < 24) return `${hours}h`; const days = Math.floor(hours / 24); - if (days < 7) return `${days}d`; - // Beyond a week: a short date (MM/DD). Uses UTC parts for determinism in tests. - const d = new Date(then); - const month = String(d.getUTCMonth() + 1).padStart(2, "0"); - const day = String(d.getUTCDate()).padStart(2, "0"); - return `${month}/${day}`; + if (days < 30) return `${days}d`; + // Past a month: months (a month ≈ 30 days). No short date — the label + // stays relative forever. + return `${Math.floor(days / 30)}mo`; } diff --git a/src/features/workspaces/store.svelte.ts b/src/features/workspaces/store.svelte.ts index 22d73b5..b9b646b 100644 --- a/src/features/workspaces/store.svelte.ts +++ b/src/features/workspaces/store.svelte.ts @@ -1,7 +1,8 @@ import type { EnsureWorkspaceRequest } from "@dispatch/transport-contract"; import type { Workspace, WorkspaceEntry } from "@dispatch/wire"; import type { WorkspaceHttp, WorkspaceResult } from "./adapter/http"; -import { applyStarred, sortWorkspaces } from "./logic/view-model"; +import { gateCreate } from "./logic/create"; +import { applyArchived, applyStarred, sortWorkspaces } from "./logic/view-model"; /** * Workspace store — a thin reactive wrapper over the pure HTTP edge. Owns the @@ -16,12 +17,25 @@ export interface WorkspaceStore { * backend's `lastActivityAt`-desc order; this getter re-sorts reactively. */ readonly list: readonly WorkspaceEntry[]; + /** Archived workspaces, for the archived view. Loaded via `refreshArchived()`. */ + readonly archivedList: readonly WorkspaceEntry[]; readonly loading: boolean; + readonly archivedLoading: boolean; readonly error: string | null; /** Refresh the list from the backend. */ refresh(): Promise<void>; + /** Refresh the archived list (`GET /workspaces?archived=true`). */ + refreshArchived(): Promise<void>; /** `PUT /workspaces/:id` (create-on-miss). Returns the workspace or an error. */ ensure(id: string, body?: EnsureWorkspaceRequest): Promise<WorkspaceResult<Workspace>>; + /** + * Create a workspace via the New-workspace flow. Probes `GET /workspaces/:id` + * first and refuses a slug that belongs to an ARCHIVED workspace (the + * idempotent `ensure` would otherwise silently resurrect an invisible tile). + * A failed probe is refused too (see `gateCreate`). On success it falls + * through to `ensure`, unchanged. + */ + create(id: string, body?: EnsureWorkspaceRequest): Promise<WorkspaceResult<Workspace>>; /** Rename a workspace (display only; id unchanged). */ rename(id: string, title: string): Promise<WorkspaceResult<Workspace>>; /** Set/clear a workspace's default cwd. */ @@ -34,25 +48,41 @@ export interface WorkspaceStore { * reverts to the prior value. No full refresh on success (avoids flicker). */ setStarred(id: string, starred: boolean): Promise<WorkspaceResult<Workspace>>; + /** + * Archive or unarchive a workspace. Optimistic: the local `archived` flag + * flips immediately (archiving drops the tile from the home grid); on error + * it reverts to the prior value. A successful archive/unarchive is followed + * by a `refresh()` so the active list reflects the backend (the archived + * workspace leaves/enters the default-filtered list). + */ + setArchived(id: string, archived: boolean): Promise<WorkspaceResult<Workspace>>; /** Delete a workspace (closes its conversations, reassigns to "default"). */ remove(id: string): Promise<WorkspaceResult<{ closedCount: number }>>; } export function createWorkspaceStore(http: WorkspaceHttp): WorkspaceStore { let list = $state<readonly WorkspaceEntry[]>([]); + let archivedList = $state<readonly WorkspaceEntry[]>([]); // Sorted view (starred first, then most-recent) — derived so it recomputes // only when the backing list changes, not on every read. let sorted = $derived(sortWorkspaces(list)); let loading = $state(false); + let archivedLoading = $state(false); let error = $state<string | null>(null); return { get list(): readonly WorkspaceEntry[] { return sorted; }, + get archivedList(): readonly WorkspaceEntry[] { + return archivedList; + }, get loading(): boolean { return loading; }, + get archivedLoading(): boolean { + return archivedLoading; + }, get error(): string | null { return error; }, @@ -69,6 +99,24 @@ export function createWorkspaceStore(http: WorkspaceHttp): WorkspaceStore { } }, + async refreshArchived(): Promise<void> { + archivedLoading = true; + error = null; + try { + archivedList = await http.list("true"); + } catch (err) { + error = err instanceof Error ? err.message : "Failed to load archived workspaces"; + } finally { + archivedLoading = false; + } + }, + + async create(id, body): Promise<WorkspaceResult<Workspace>> { + const gate = gateCreate(id, await http.get(id)); + if (!gate.ok) return { ok: false, error: gate.error }; + return this.ensure(id, body); + }, + async ensure(id, body): Promise<WorkspaceResult<Workspace>> { const result = await http.ensure(id, body); if (result.ok) void this.refresh(); @@ -105,6 +153,22 @@ export function createWorkspaceStore(http: WorkspaceHttp): WorkspaceStore { return result; }, + async setArchived(id, archived): Promise<WorkspaceResult<Workspace>> { + const prev = list.find((w) => w.id === id)?.archived ?? false; + // Optimistic: flip immediately so the tile leaves/enters the home grid. + list = applyArchived(list, id, archived); + const result = archived ? await http.archive(id) : await http.unarchive(id); + if (!result.ok) { + // Revert the optimistic flip on failure. + list = applyArchived(list, id, prev); + return result; + } + // Reconcile with the backend's default (active-only) filter so the + // archived tile is dropped / the unarchived tile is restored. + void this.refresh(); + return result; + }, + async remove(id): Promise<WorkspaceResult<{ closedCount: number }>> { const result = await http.delete(id); if (result.ok) void this.refresh(); diff --git a/src/features/workspaces/store.test.ts b/src/features/workspaces/store.test.ts index 4caac9f..faec5bf 100644 --- a/src/features/workspaces/store.test.ts +++ b/src/features/workspaces/store.test.ts @@ -1,6 +1,7 @@ import type { Workspace, WorkspaceEntry } from "@dispatch/wire"; import { describe, expect, it, vi } from "vitest"; import type { WorkspaceResult } from "./adapter/http"; +import { archivedCreateMessage, type WorkspaceLookup } from "./logic/create"; import { createWorkspaceStore } from "./store.svelte"; function entry(overrides: Partial<WorkspaceEntry> = {}): WorkspaceEntry { @@ -10,6 +11,7 @@ function entry(overrides: Partial<WorkspaceEntry> = {}): WorkspaceEntry { defaultCwd: null, defaultComputerId: null, starred: false, + archived: false, createdAt: 1, lastActivityAt: 2, conversationCount: 0, @@ -22,11 +24,25 @@ function fakeHttp(opts: { list?: readonly WorkspaceEntry[]; star?: (id: string) => Promise<WorkspaceResult<Workspace>>; unstar?: (id: string) => Promise<WorkspaceResult<Workspace>>; + archive?: (id: string) => Promise<WorkspaceResult<Workspace>>; + unarchive?: (id: string) => Promise<WorkspaceResult<Workspace>>; + get?: (id: string) => Promise<WorkspaceLookup>; + ensure?: (id: string) => Promise<WorkspaceResult<Workspace>>; }) { return { - list: vi.fn(async (): Promise<readonly WorkspaceEntry[]> => opts.list ?? []), - ensure: vi.fn(), - get: vi.fn(), + list: vi.fn( + async (_archived?: "true" | "false" | "all"): Promise<readonly WorkspaceEntry[]> => + opts.list ?? [], + ), + ensure: + opts.ensure ?? + vi.fn( + async (id: string): Promise<WorkspaceResult<Workspace>> => ({ + ok: true, + value: entry({ id }), + }), + ), + get: vi.fn(opts.get ?? (async (): Promise<WorkspaceLookup> => ({ kind: "missing" }))), setTitle: vi.fn(), setDefaultCwd: vi.fn(), setDefaultComputer: vi.fn(), @@ -46,6 +62,22 @@ function fakeHttp(opts: { value: entry({ id, starred: false }), }), ), + archive: + opts.archive ?? + vi.fn( + async (id: string): Promise<WorkspaceResult<Workspace>> => ({ + ok: true, + value: entry({ id, archived: true }), + }), + ), + unarchive: + opts.unarchive ?? + vi.fn( + async (id: string): Promise<WorkspaceResult<Workspace>> => ({ + ok: true, + value: entry({ id, archived: false }), + }), + ), delete: vi.fn(), }; } @@ -143,3 +175,131 @@ describe("createWorkspaceStore — setStarred", () => { expect(store.list).toHaveLength(0); }); }); + +describe("createWorkspaceStore — setArchived", () => { + it("optimistically flips archived to true before the request resolves", async () => { + const http = fakeHttp({ list: [entry({ id: "a", archived: false })] }); + const store = createWorkspaceStore(http); + await store.refresh(); + + let observedDuringCall = false; + http.archive = vi.fn(async (_id: string): Promise<WorkspaceResult<Workspace>> => { + // While the request is in flight, the store already shows the new state. + observedDuringCall = store.list[0]?.archived === true; + return { ok: true, value: entry({ id: "a", archived: true }) }; + }); + + await store.setArchived("a", true); + + expect(observedDuringCall).toBe(true); + expect(http.archive).toHaveBeenCalledWith("a"); + }); + + it("calls unarchive (DELETE) when archiving false", async () => { + const http = fakeHttp({ list: [entry({ id: "a", archived: true })] }); + const store = createWorkspaceStore(http); + await store.refresh(); + + await store.setArchived("a", false); + + expect(http.unarchive).toHaveBeenCalledWith("a"); + expect(http.archive).not.toHaveBeenCalled(); + }); + + it("reverts the optimistic flip on error", async () => { + const http = fakeHttp({ list: [entry({ id: "a", archived: false })] }); + const store = createWorkspaceStore(http); + await store.refresh(); + + http.archive = vi.fn( + async (): Promise<WorkspaceResult<Workspace>> => ({ ok: false, error: "boom" }), + ); + + const result = await store.setArchived("a", true); + + expect(result).toEqual({ ok: false, error: "boom" }); + expect(store.list[0]?.archived).toBe(false); + }); + + it("refreshes the active list after a successful archive", async () => { + const http = fakeHttp({ list: [entry({ id: "a", archived: false })] }); + const store = createWorkspaceStore(http); + await store.refresh(); + const listCallsBefore = http.list.mock.calls.length; + + await store.setArchived("a", true); + + // setArchived triggers a refresh() to reconcile the active-only list. + expect(http.list.mock.calls.length).toBeGreaterThan(listCallsBefore); + }); +}); + +describe("createWorkspaceStore — create (archived-slug gate)", () => { + it("refuses an archived slug with the can't-create message and never calls ensure", async () => { + const http = fakeHttp({ + get: async () => ({ kind: "found", workspace: entry({ id: "old", archived: true }) }), + }); + const store = createWorkspaceStore(http); + + const result = await store.create("old"); + + expect(result).toEqual({ ok: false, error: archivedCreateMessage("old") }); + expect(http.ensure).not.toHaveBeenCalled(); + }); + + it("proceeds for an existing non-archived slug (ensure is called as before)", async () => { + const http = fakeHttp({ + get: async () => ({ kind: "found", workspace: entry({ id: "taken", archived: false }) }), + }); + const store = createWorkspaceStore(http); + + const result = await store.create("taken"); + + expect(result.ok).toBe(true); + expect(http.ensure).toHaveBeenCalledTimes(1); + }); + + it("proceeds for a brand-new slug (404 → missing)", async () => { + const http = fakeHttp({ get: async () => ({ kind: "missing" }) }); + const store = createWorkspaceStore(http); + + const result = await store.create("fresh"); + + expect(result.ok).toBe(true); + expect(http.ensure).toHaveBeenCalledTimes(1); + }); + + it("refuses when the existence check itself failed, without calling ensure (§3.4)", async () => { + const http = fakeHttp({ get: async () => ({ kind: "failed", error: "db down" }) }); + const store = createWorkspaceStore(http); + + const result = await store.create("maybe"); + + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain("Couldn't check whether"); + expect(http.ensure).not.toHaveBeenCalled(); + }); +}); + +describe("createWorkspaceStore — refreshArchived", () => { + it("loads the archived list via list('true') without touching the main list", async () => { + const http = fakeHttp({ list: [entry({ id: "active", archived: false })] }); + const store = createWorkspaceStore(http); + await store.refresh(); + + // Point the archived call at a different payload. + http.list = vi.fn( + async (archived?: "true" | "false" | "all"): Promise<readonly WorkspaceEntry[]> => + archived === "true" + ? [entry({ id: "done", archived: true })] + : [entry({ id: "active", archived: false })], + ); + + await store.refreshArchived(); + + expect(http.list).toHaveBeenCalledWith("true"); + expect(store.archivedList.map((w) => w.id)).toEqual(["done"]); + // The main (active) list is untouched. + expect(store.list.map((w) => w.id)).toEqual(["active"]); + }); +}); diff --git a/src/features/workspaces/tile-layout-store.svelte.ts b/src/features/workspaces/tile-layout-store.svelte.ts new file mode 100644 index 0000000..98a5c33 --- /dev/null +++ b/src/features/workspaces/tile-layout-store.svelte.ts @@ -0,0 +1,308 @@ +import type { + TileLayoutData as ContractTileLayoutData, + TileGeometryMode, +} from "@dispatch/tile-layout-contract"; +import { createLocalStore } from "../../adapters/local-storage"; +import type { TileLayoutHttp } from "./adapter/tile-layout-http"; +import { + type Cell, + type GridGeometry, + mergeLayout, + pinAtFirstEmpty, + placeAtCell, + resizeTile, + sanitizeLayout, + sortByRecentActivity, + type TileLayoutData, + type TileLayoutEntry, +} from "./logic/tile-layout"; + +/** + * Tile-layout store — the synced arrangement of the home grid, ONE shared + * document PER device orientation (`wide` = landscape/desktop, `tall` = + * portrait/mobile), persisted through the INJECTED `TileLayoutHttp` edge + * (the `home-tile-layout` plugin's well-known routes). Mirrors the layout + * feature's store semantics: + * + * - **Orientation selection** — `setMode(mode)` (the home page's effect on + * the resolved grid geometry) is the boot + orientation-change trigger: + * an orientation CHANGE seeds from its cached server layout and probes that + * orientation's document fresh. Same-mode calls are a no-op. The store + * probes its INITIAL mode on creation (the boot probe). + * - **Server-backed** — a successful probe applies the server document; + * edits apply locally IMMEDIATELY, then PUT the whole document (decision 7 + * — no patch protocol) lightly debounced; a failed PUT on a present + * backend rolls back to the last server document with a quiet console + * warning (never an error UI). + * - **WS push** — `onServerTileLayout(mode, data)` applies a `tile-layout` + * broadcast, ignoring the OTHER orientation (the server fans out to all + * clients; the client filters by mode). + * - **Quiet degrade** — any probe/write failure (backend unreachable, routes + * not served) keeps the local state with EPHEMERAL edits (in-memory only — + * only server-confirmed layouts are cached); logged once, no error UI. + * - **Sync gating** — `sync(ids)` no-ops while the first probe for an + * orientation is in flight (`providerState === "unknown"`): merging the + * live workspace list into an unprobed empty document would PUT a wiped + * arrangement. The caller's effect re-runs when the state settles. + * + * No ambient state: one instance per home page, created by the page entry + * (or injected in tests). + */ + +/** Whether the backend is serving the active orientation's document. */ +export type TileLayoutProviderState = "unknown" | "present" | "absent"; + +export interface TileLayoutStore { + /** The persisted entries of the ACTIVE orientation, order-sorted. */ + readonly entries: readonly TileLayoutEntry[]; + /** The active orientation (the home page's resolved grid geometry mode). */ + readonly mode: TileGeometryMode; + /** The backend's serving state for the ACTIVE orientation. */ + readonly providerState: TileLayoutProviderState; + /** + * Select the active orientation and probe its document. Same-mode calls + * are a no-op (the boot probe fires on creation for the initial mode); + * an orientation CHANGE seeds from its cache + probes fresh. + */ + setMode(mode: TileGeometryMode): void; + /** Re-probe the ACTIVE orientation's document (reconnect catch-up). */ + probe(): Promise<void>; + /** Server `tile-layout` broadcast. Other orientations are ignored. */ + onServerTileLayout(mode: TileGeometryMode, data: ContractTileLayoutData): void; + /** + * Merge the live workspace ids into the layout: workspaces unknown to the + * layout append (unpinned — they flow to the first free cells), entries for + * deleted workspaces drop (their cells become gaps). A no-op (no write) + * when the merge changes nothing — so an effect that tracks both the + * workspace list and this store settles instead of looping. + */ + sync(workspaceIds: readonly string[]): void; + /** Resize a tile (persisted spans; render-time clamping is separate). */ + resize(id: string, colSpan: number, rowSpan: number): void; + /** + * Drop a tile at a specific CELL (the drag commit): the contiguous filled + * run pushes forward from that cell (gaps absorb it), every resulting + * position pins. See `placeAtCell`. + */ + placeAtCell(id: string, cell: Cell, geometry: GridGeometry): void; + /** + * Pin (or re-pin) the tile at the FIRST EMPTY slot — the header-create + * flow. Idempotent (see `pinAtFirstEmpty`). + */ + placeFirstEmpty(id: string, geometry: GridGeometry): void; + /** Re-apply the recent-activity order AND clear all pins (the reset). */ + sortByRecentActivity(lastActivityById: ReadonlyMap<string, number>): void; +} + +export interface TileLayoutStoreOptions { + /** The injected HTTP edge (the `home-tile-layout` plugin's routes). */ + readonly http: TileLayoutHttp; + /** Initial viewport orientation (default wide). */ + readonly initialMode?: TileGeometryMode; + /** Injected localStorage for server-layout snapshots; absent → no cache. */ + readonly storage?: Storage | undefined; + /** Edit-PUT debounce window in ms (default 300; tests shrink it). */ + readonly debounceMs?: number; + /** The quiet-failure hook (default `console.warn` — never an error UI). */ + readonly warn?: (message: string) => void; +} + +const DEFAULT_DEBOUNCE_MS = 300; + +export function createTileLayoutStore(opts: TileLayoutStoreOptions): TileLayoutStore { + const http = opts.http; + const debounceMs = opts.debounceMs ?? DEFAULT_DEBOUNCE_MS; + const warn = opts.warn ?? ((message: string) => console.warn(message)); + + const cache = { + wide: createLocalStore<TileLayoutData>("dispatch.tile-layout.wide", { storage: opts.storage }), + tall: createLocalStore<TileLayoutData>("dispatch.tile-layout.tall", { storage: opts.storage }), + }; + const initialMode = opts.initialMode ?? "wide"; + let mode = $state<TileGeometryMode>(initialMode); + // The ACTIVE orientation's document — the server's copy once a probe has + // landed (sanitized defensively; the backend validates strictly), the + // cached server snapshot (or empty default) until then. Never PUT the seed. + let data = $state<TileLayoutData>(sanitizeLayout(cache[initialMode].load())); + let providerState = $state<TileLayoutProviderState>("unknown"); + + /** The last document known to come from the server (GET / WS push / PUT echo) — the rollback target. */ + let lastServerLayout: TileLayoutData | null = null; + /** The pending edit-PUT timer (the "lightly debounced" write). */ + let putTimer: ReturnType<typeof setTimeout> | null = null; + /** Logged-once guard for the degrade log (reset when the backend returns). */ + let loggedAbsent = false; + + function noteAbsent(reason: string): void { + providerState = "absent"; + if (!loggedAbsent) { + loggedAbsent = true; + warn( + `tile-layout: backend not serving the layout (${reason}) — rendering the local default; edits are ephemeral`, + ); + } + } + + function notePresent(): void { + providerState = "present"; + loggedAbsent = false; + } + + function applyServerLayout(next: TileLayoutData): void { + lastServerLayout = next; + data = next; + cache[mode].save(next); + } + + function clearPutTimer(): void { + if (putTimer !== null) { + clearTimeout(putTimer); + putTimer = null; + } + } + + async function flushPut(): Promise<void> { + putTimer = null; + // Read the CURRENT document at fire time: a newer edit supersedes the + // debounced snapshot (one in-flight write per window, last wins). + const writtenMode = mode; + const result = await http.putLayout(writtenMode, { version: 2, tiles: data.tiles }); + if (result.ok) cache[writtenMode].save(sanitizeLayout(result.layout)); + if (writtenMode !== mode) return; + if (result.ok) { + notePresent(); + // The server's validated echo is the new rollback target. + lastServerLayout = sanitizeLayout(result.layout); + return; + } + if (providerState !== "present") { + // The backend is gone (or never was): keep the local edit (ephemeral), + // no rollback — there is no server document to roll back TO. + noteAbsent(result.error); + return; + } + // A failed write on a PRESENT backend: roll back to the last server + // document, quiet console warning (never an error UI). + warn( + `tile-layout: persisting the layout failed (${result.error}) — rolled back to the last server document`, + ); + if (lastServerLayout !== null) data = lastServerLayout; + } + + async function runProbe(): Promise<void> { + // Capture the mode AT START: a setMode may happen while the fetch is in + // flight — only the CURRENT mode's result may land (the reset in + // setMode already staged the fresh default; a stale response must never + // overwrite it). + const probed = mode; + const result = await http.getLayout(mode); + if (probed !== mode) return; + if (result.ok) { + notePresent(); + applyServerLayout(sanitizeLayout(result.layout)); + } else { + // A real failure (5xx, 404 = routes not served, malformed, network) + // degrades quietly — the local default keeps rendering. + noteAbsent(result.error); + } + } + + function commit(tiles: readonly TileLayoutEntry[]): void { + // A pure op that changed nothing (e.g. a no-op place) doesn't write. + if (sameEntries(tiles, data.tiles)) return; + data = { version: 2, tiles }; + if (providerState === "unknown") return; // first probe still in flight + if (providerState === "absent") return; // ephemeral — no write, no warning + clearPutTimer(); + putTimer = setTimeout(() => { + void flushPut().catch(() => { + // Network-grade failure: roll back to the last server document, quietly. + warn("tile-layout: persisting the layout failed — rolled back to the last server document"); + if (lastServerLayout !== null) data = lastServerLayout; + }); + }, debounceMs); + } + + // Boot probe for the initial mode — the home page's setMode effect either + // confirms it (no-op) or switches it (fresh probe). + void runProbe(); + + return { + get entries(): readonly TileLayoutEntry[] { + return data.tiles; + }, + get mode(): TileGeometryMode { + return mode; + }, + get providerState(): TileLayoutProviderState { + return providerState; + }, + + setMode(next) { + if (next === mode) return; + mode = next; + // The document belongs to the OLD orientation now: seed from this + // mode's cache while the probe resolves (never flash the other + // orientation's arrangement), and drop any queued edit. + clearPutTimer(); + lastServerLayout = null; + data = sanitizeLayout(cache[mode].load()); + providerState = "unknown"; + void runProbe(); + }, + + async probe() { + await runProbe(); + }, + + onServerTileLayout(pushMode, next) { + if (pushMode !== mode) return; // clients filter by orientation + notePresent(); + clearPutTimer(); // the server's copy supersedes any queued edit + applyServerLayout(sanitizeLayout(next)); + }, + + sync(workspaceIds): void { + // Never merge into an UNPROBED empty document: before the first probe + // resolves, "empty" is our placeholder, not a real arrangement — and + // merging live ids into it could PUT a wiped layout. The caller's + // effect re-runs when the state settles (it tracks providerState). + if (providerState === "unknown") return; + const merged = mergeLayout(data, workspaceIds); + if (!sameEntries(merged, data.tiles)) commit(merged); + }, + + resize(id, colSpan, rowSpan): void { + commit(resizeTile(data.tiles, id, colSpan, rowSpan)); + }, + + placeAtCell(id, cell, geometry): void { + commit(placeAtCell(data.tiles, id, cell, geometry)); + }, + + placeFirstEmpty(id, geometry): void { + commit(pinAtFirstEmpty(data.tiles, id, geometry)); + }, + + sortByRecentActivity(lastActivityById): void { + commit(sortByRecentActivity(data.tiles, lastActivityById)); + }, + }; +} + +/** Structural equality over entries (ids, spans, orders, anchors). */ +function sameEntries(a: readonly TileLayoutEntry[], b: readonly TileLayoutEntry[]): boolean { + if (a.length !== b.length) return false; + return a.every((entry, index) => { + const other = b[index]; + return ( + other !== undefined && + entry.id === other.id && + entry.colSpan === other.colSpan && + entry.rowSpan === other.rowSpan && + entry.order === other.order && + entry.col === other.col && + entry.row === other.row + ); + }); +} diff --git a/src/features/workspaces/tile-layout-store.test.ts b/src/features/workspaces/tile-layout-store.test.ts new file mode 100644 index 0000000..b5af8b6 --- /dev/null +++ b/src/features/workspaces/tile-layout-store.test.ts @@ -0,0 +1,449 @@ +import type { TileGeometryMode, TileLayoutData } from "@dispatch/tile-layout-contract"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { TileLayoutHttp, TileLayoutHttpResult } from "./adapter/tile-layout-http"; +import { geometryFor, type TileLayoutEntry } from "./logic/tile-layout"; +import { createTileLayoutStore } from "./tile-layout-store.svelte"; + +const WIDE = geometryFor(1920, 1080); + +function tile(id: string, order: number, colSpan = 1, rowSpan = 1): TileLayoutEntry { + return { id, order, colSpan, rowSpan }; +} + +function doc(...tiles: TileLayoutEntry[]): TileLayoutData { + return { version: 2, tiles }; +} + +/** A controllable fake HTTP edge: per-mode documents + a call log (no mocks of our modules). */ +function fakeHttp( + initial: Partial<Record<TileGeometryMode, TileLayoutData>> = {}, +): TileLayoutHttp & { + docs: Map<TileGeometryMode, TileLayoutData>; + puts: Array<{ mode: TileGeometryMode; layout: TileLayoutData }>; + failNext: { get: string | null; put: string | null }; +} { + const docs = new Map<TileGeometryMode, TileLayoutData>( + Object.entries(initial) as Array<[TileGeometryMode, TileLayoutData]>, + ); + const puts: Array<{ mode: TileGeometryMode; layout: TileLayoutData }> = []; + const failNext = { get: null as string | null, put: null as string | null }; + const ok = (layout: TileLayoutData): TileLayoutHttpResult => ({ ok: true, layout }); + return { + docs, + puts, + failNext, + async getLayout(mode) { + if (failNext.get !== null) { + const error = failNext.get; + failNext.get = null; + return { ok: false, notFound: false, error }; + } + return ok(docs.get(mode) ?? doc()); + }, + async putLayout(mode, layout) { + if (failNext.put !== null) { + const error = failNext.put; + failNext.put = null; + return { ok: false, notFound: false, error }; + } + puts.push({ mode, layout }); + docs.set(mode, layout); + return ok(layout); + }, + async deleteLayout(mode) { + docs.set(mode, doc()); + return ok(docs.get(mode) as TileLayoutData); + }, + }; +} + +/** Flush the boot probe + debounced timers (microtasks first, then the clock). */ +async function settle(): Promise<void> { + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(300); + await Promise.resolve(); +} + +async function flushOnly(): Promise<void> { + await vi.advanceTimersByTimeAsync(0); + await Promise.resolve(); +} + +describe("createTileLayoutStore", () => { + beforeEach(() => { + vi.useFakeTimers(); + localStorage.clear(); + }); + afterEach(() => { + vi.useRealTimers(); + localStorage.clear(); + }); + + it("the boot probe applies the server document for the initial mode", async () => { + const http = fakeHttp({ wide: doc(tile("a", 0, 2, 2)) }); + const store = createTileLayoutStore({ http }); + await flushOnly(); + expect(store.entries).toEqual([tile("a", 0, 2, 2)]); + expect(store.providerState).toBe("present"); + expect(store.mode).toBe("wide"); + }); + + it("honors the initial tall mode with exactly one probe and no same-mode reset", async () => { + const http = fakeHttp({ tall: doc(tile("mobile", 0)) }); + const get = vi.spyOn(http, "getLayout"); + const store = createTileLayoutStore({ http, initialMode: "tall" }); + store.setMode("tall"); + await flushOnly(); + expect(get.mock.calls).toEqual([["tall"]]); + expect(store.mode).toBe("tall"); + expect(store.entries).toEqual([tile("mobile", 0)]); + }); + + it("seeds from cache immediately without PUT or caching edits before revalidation", async () => { + const cached = doc(tile("cached", 0, 2, 2)); + localStorage.setItem("dispatch.tile-layout.tall", JSON.stringify(cached)); + const http = fakeHttp(); + const gate: { release?: (result: TileLayoutHttpResult) => void } = {}; + http.getLayout = () => + new Promise((resolve) => { + gate.release = resolve; + }); + const store = createTileLayoutStore({ http, initialMode: "tall", storage: localStorage }); + expect(store.entries).toEqual(cached.tiles); + expect(store.providerState).toBe("unknown"); + + store.sync(["cached", "new"]); + store.resize("cached", 1, 1); + await settle(); + expect(http.puts).toEqual([]); + expect(JSON.parse(localStorage.getItem("dispatch.tile-layout.tall") ?? "null")).toEqual(cached); + + const fresh = doc(tile("server", 0)); + gate.release?.({ ok: true, layout: fresh }); + await settle(); + expect(store.entries).toEqual(fresh.tiles); + expect(store.providerState).toBe("present"); + expect(http.puts).toEqual([]); + expect(JSON.parse(localStorage.getItem("dispatch.tile-layout.tall") ?? "null")).toEqual(fresh); + }); + + it("resets to the new mode's cache and re-probes on orientation change", async () => { + const cached = doc(tile("cached-mobile", 0)); + localStorage.setItem("dispatch.tile-layout.tall", JSON.stringify(cached)); + const http = fakeHttp({ wide: doc(tile("desktop", 0)) }); + const get = vi.spyOn(http, "getLayout"); + const store = createTileLayoutStore({ http, storage: localStorage }); + await flushOnly(); + + store.setMode("tall"); + expect(store.providerState).toBe("unknown"); + expect(store.entries).toEqual(cached.tiles); + await flushOnly(); + expect(store.entries).toEqual([]); + expect(get.mock.calls).toEqual([["wide"], ["tall"]]); + }); + + it("caches server GET, WS push, and successful PUT echoes, never unconfirmed edits", async () => { + const http = fakeHttp({ wide: doc(tile("a", 0)) }); + const store = createTileLayoutStore({ http, storage: localStorage }); + const cached = () => JSON.parse(localStorage.getItem("dispatch.tile-layout.wide") ?? "null"); + await flushOnly(); + expect(cached()).toEqual(doc(tile("a", 0))); + + store.onServerTileLayout("tall", doc(tile("ignored", 0))); + expect(localStorage.getItem("dispatch.tile-layout.tall")).toBeNull(); + store.onServerTileLayout("wide", doc(tile("push", 0))); + expect(cached()).toEqual(doc(tile("push", 0))); + + store.resize("push", 2, 2); + expect(cached()).toEqual(doc(tile("push", 0))); + await settle(); + expect(cached()).toEqual(doc(tile("push", 0, 2, 2))); + + http.failNext.put = "network down"; + store.resize("push", 1, 1); + await settle(); + expect(cached()).toEqual(doc(tile("push", 0, 2, 2))); + }); + + it("caches an in-flight PUT echo under its written mode after an orientation change", async () => { + const http = fakeHttp({ wide: doc(tile("desktop", 0)), tall: doc(tile("mobile", 0)) }); + const gate: { release?: (result: TileLayoutHttpResult) => void } = {}; + http.putLayout = () => + new Promise((resolve) => { + gate.release = resolve; + }); + const store = createTileLayoutStore({ http, storage: localStorage }); + await flushOnly(); + store.resize("desktop", 2, 2); + await settle(); + store.setMode("tall"); + await flushOnly(); + gate.release?.({ ok: true, layout: doc(tile("desktop", 0, 2, 2)) }); + await flushOnly(); + expect(store.entries).toEqual([tile("mobile", 0)]); + expect(JSON.parse(localStorage.getItem("dispatch.tile-layout.tall") ?? "null")).toEqual( + doc(tile("mobile", 0)), + ); + expect(JSON.parse(localStorage.getItem("dispatch.tile-layout.wide") ?? "null")).toEqual( + doc(tile("desktop", 0, 2, 2)), + ); + }); + + it.each([ + "invalid JSON", + '{"version":2,"tiles":"bad"}', + ])("ignores malformed cache (%s) and supports storageless contexts", (raw) => { + localStorage.setItem("dispatch.tile-layout.wide", raw); + const store = createTileLayoutStore({ http: fakeHttp(), storage: localStorage }); + expect(store.entries).toEqual([]); + expect(createTileLayoutStore({ http: fakeHttp() }).entries).toEqual([]); + }); + + it("sanitizes the server document defensively (a junk shape degrades to empty)", async () => { + const http = fakeHttp(); + http.docs.set("wide", { junk: true } as unknown as TileLayoutData); + const store = createTileLayoutStore({ http }); + await flushOnly(); + expect(store.entries).toEqual([]); + }); + + it("a probe failure degrades to absent (ephemeral edits, no PUT)", async () => { + const warn = vi.fn(); + const http = fakeHttp(); + http.failNext.get = "network down"; + const store = createTileLayoutStore({ http, warn }); + await flushOnly(); + expect(store.providerState).toBe("absent"); + + store.resize("a", 3, 3); // applied locally… + store.sync(["a"]); + await settle(); + expect(store.entries.map((t) => t.id)).toEqual(["a"]); + expect(http.puts).toHaveLength(0); // …but never written + expect(warn).toHaveBeenCalledTimes(1); // logged ONCE, no error UI + }); + + it("setMode switches orientation: reset + fresh probe (documents are independent)", async () => { + const http = fakeHttp({ + wide: doc(tile("desktop", 0)), + tall: doc(tile("mobile", 0)), + }); + const store = createTileLayoutStore({ http }); + await flushOnly(); + expect(store.entries.map((t) => t.id)).toEqual(["desktop"]); + + store.setMode("tall"); + expect(store.entries).toEqual([]); + expect(store.providerState).toBe("unknown"); + await flushOnly(); + expect(store.mode).toBe("tall"); + expect(store.entries.map((t) => t.id)).toEqual(["mobile"]); + + store.setMode("tall"); // same-mode call: a no-op + store.setMode("wide"); + await flushOnly(); + expect(store.entries.map((t) => t.id)).toEqual(["desktop"]); + }); + + it("an in-flight probe for a stale mode never lands", async () => { + const http = fakeHttp({ wide: doc(tile("desktop", 0)) }); + // A holder object defeats TS's closure narrowing (a bare `let` read after + // the callback-assigned write narrows to `null` and the call sites to + // `never`). + const gate: { release: ((value: TileLayoutHttpResult) => void) | null } = { release: null }; + const slow = http.getLayout.bind(http); + http.getLayout = (mode) => + mode === "tall" + ? new Promise<TileLayoutHttpResult>((resolve) => { + gate.release = resolve; + }) + : slow(mode); + const store = createTileLayoutStore({ http }); + await flushOnly(); + + store.setMode("tall"); // probe in flight… + store.setMode("wide"); // …superseded before it resolves + gate.release?.({ ok: true, layout: doc(tile("mobile", 0)) }); + await flushOnly(); + + // The stale tall response must NOT overwrite the active wide document. + expect(store.mode).toBe("wide"); + expect(store.entries.map((t) => t.id)).toEqual(["desktop"]); + }); + + it("a WS push applies for the ACTIVE mode and is IGNORED for the other", async () => { + const http = fakeHttp({ wide: doc(tile("a", 0)) }); + const store = createTileLayoutStore({ http }); + await flushOnly(); + + store.onServerTileLayout("tall", doc(tile("mobile-push", 0))); + expect(store.entries.map((t) => t.id)).toEqual(["a"]); // filtered + + store.onServerTileLayout("wide", doc(tile("push", 0))); + expect(store.entries.map((t) => t.id)).toEqual(["push"]); // applied + }); + + it("edits apply locally IMMEDIATELY, then PUT the whole document (debounced)", async () => { + const http = fakeHttp({ wide: doc(tile("a", 0)) }); + const store = createTileLayoutStore({ http, debounceMs: 300 }); + await flushOnly(); + + store.resize("a", 4, 2); + expect(store.entries).toEqual([tile("a", 0, 4, 2)]); // local, no wait + expect(http.puts).toHaveLength(0); + + await vi.advanceTimersByTimeAsync(300); + expect(http.puts).toEqual([{ mode: "wide", layout: doc(tile("a", 0, 4, 2)) }]); + }); + + it("edits ride the ACTIVE mode's document (the tall PUT names tall)", async () => { + const http = fakeHttp({ tall: doc(tile("m", 0)) }); + const store = createTileLayoutStore({ http }); + store.setMode("tall"); + await flushOnly(); + + store.resize("m", 2, 2); + await settle(); + expect(http.puts).toEqual([{ mode: "tall", layout: doc(tile("m", 0, 2, 2)) }]); + }); + + it("a failed PUT on a present backend rolls back to the last server document", async () => { + const warn = vi.fn(); + const http = fakeHttp({ wide: doc(tile("a", 0)) }); + const store = createTileLayoutStore({ http, warn, debounceMs: 300 }); + await flushOnly(); + + http.failNext.put = "500 gone"; + store.resize("a", 4, 2); + await settle(); + + expect(store.entries).toEqual([tile("a", 0)]); // rolled back + expect(warn).toHaveBeenCalledTimes(1); + }); + + it("sync merges the live workspace list (gated while the first probe is in flight)", async () => { + const http = fakeHttp({ wide: doc(tile("a", 0)) }); + const store = createTileLayoutStore({ http }); + // NO flush — the boot probe is still in flight (providerState unknown). + + store.sync(["a", "b"]); // would merge into the empty placeholder… + expect(store.providerState).toBe("unknown"); + expect(store.entries).toEqual([]); // …so it is a no-op until the probe settles + + await flushOnly(); + store.sync(["a", "b"]); + expect(store.entries.map((t) => t.id)).toEqual(["a", "b"]); + }); + + it("sync appends unknown ids, drops deleted ones, and persists once", async () => { + const http = fakeHttp({ wide: doc(tile("a", 0), tile("gone", 1)) }); + const store = createTileLayoutStore({ http }); + await flushOnly(); + + store.sync(["a", "b"]); + await settle(); + + expect(store.entries.map((t) => t.id)).toEqual(["a", "b"]); + // The max order counts KEPT entries (the dropped one doesn't reserve a slot). + expect(http.puts.at(-1)?.layout).toEqual( + doc(tile("a", 0), { id: "b", colSpan: 1, rowSpan: 1, order: 1 }), + ); + }); + + it("sync is a no-op (no write) when the merge changes nothing", async () => { + const http = fakeHttp({ wide: doc(tile("a", 0)) }); + const store = createTileLayoutStore({ http }); + await flushOnly(); + + store.sync(["a"]); + store.sync(["a"]); + await settle(); + + expect(http.puts).toHaveLength(0); + }); + + it("placeAtCell commits the displacement and pins EVERY tile (persisted to the server)", async () => { + const http = fakeHttp({ wide: doc(tile("a", 0), tile("b", 1)) }); + const store = createTileLayoutStore({ http }); + await flushOnly(); + + // a@(1,1), b@(1,2) → drag a to (2,1): a pins there, b keeps its cell. + store.placeAtCell("a", { col: 2, row: 1 }, WIDE); + await settle(); + + const entries = store.entries; + expect(entries.find((t) => t.id === "a")).toMatchObject({ col: 2, row: 1 }); + expect(entries.find((t) => t.id === "b")).toMatchObject({ col: 1, row: 2 }); + expect(http.puts.at(-1)?.layout.version).toBe(2); + }); + + it("placeAtCell with an unknown id is a no-op (no write)", async () => { + const http = fakeHttp({ wide: doc(tile("a", 0)) }); + const store = createTileLayoutStore({ http }); + await flushOnly(); + + store.placeAtCell("nope", { col: 1, row: 1 }, WIDE); + await settle(); + + expect(http.puts).toHaveLength(0); + expect(store.entries).toEqual([tile("a", 0)]); + }); + + it("placeFirstEmpty PINS the new id at the first empty slot (idempotent on re-call)", async () => { + const http = fakeHttp({ wide: doc(tile("a", 0, 1, 2), tile("b", 1)) }); + const store = createTileLayoutStore({ http }); + await flushOnly(); + + store.placeFirstEmpty("new", WIDE); + await settle(); + + // a packs rows 1-2, b row 3 → the first empty slot is (col1,row4) → pinned. + expect(store.entries.find((t) => t.id === "new")).toMatchObject({ col: 1, row: 4 }); + expect(http.puts.at(-1)?.layout.tiles.map((t) => t.id)).toEqual(["a", "b", "new"]); + + store.placeFirstEmpty("new", WIDE); // the create flow's re-call must be stable + expect(store.entries.find((t) => t.id === "new")).toMatchObject({ col: 1, row: 4 }); + }); + + it("sortByRecentActivity re-applies recent-activity order and CLEARs pins", async () => { + const http = fakeHttp({ + wide: doc({ ...tile("a", 0), col: 1, row: 1 }, { ...tile("b", 1), col: 2, row: 3 }), + }); + const store = createTileLayoutStore({ http }); + await flushOnly(); + + store.sortByRecentActivity( + new Map([ + ["a", 10], + ["b", 99], + ]), + ); + + expect(store.entries.map((t) => t.id)).toEqual(["b", "a"]); + expect(store.entries.every((t) => t.col === undefined && t.row === undefined)).toBe(true); + }); + + it("a WS push supersedes a queued edit (the server's copy wins)", async () => { + const http = fakeHttp({ wide: doc(tile("a", 0)) }); + const store = createTileLayoutStore({ http, debounceMs: 300 }); + await flushOnly(); + + store.resize("a", 4, 2); // edit queued… + store.onServerTileLayout("wide", doc(tile("server-wins", 0))); // …server lands first + await settle(); + + expect(store.entries.map((t) => t.id)).toEqual(["server-wins"]); + expect(http.puts).toHaveLength(0); // the queued edit was dropped, not PUT + }); + + it("probe() re-reads the active orientation (the reconnect catch-up)", async () => { + const http = fakeHttp({ wide: doc() }); + const store = createTileLayoutStore({ http }); + await flushOnly(); + + http.docs.set("wide", doc(tile("fresh", 0))); + await store.probe(); + expect(store.entries.map((t) => t.id)).toEqual(["fresh"]); + }); +}); diff --git a/src/features/workspaces/ui/ArchivedWorkspaces.svelte b/src/features/workspaces/ui/ArchivedWorkspaces.svelte new file mode 100644 index 0000000..584fd68 --- /dev/null +++ b/src/features/workspaces/ui/ArchivedWorkspaces.svelte @@ -0,0 +1,119 @@ +<script lang="ts"> + import type { WorkspaceEntry } from "@dispatch/wire"; + import type { WorkspaceStore } from "../store.svelte"; + import { relativeTime } from "../logic/view-model"; + + let { + store, + onOpen, + onClose, + }: { + store: WorkspaceStore; + /** Open (view) a workspace — navigates to `/<id>`. */ + onOpen: (id: string) => void; + onClose: () => void; + } = $props(); + + // Load the archived list when the modal mounts. + $effect(() => { + void store.refreshArchived(); + }); + + let busyId = $state<string | null>(null); + let actionError = $state<string | null>(null); + + async function unarchive(ws: WorkspaceEntry): Promise<void> { + if (busyId !== null) return; + busyId = ws.id; + actionError = null; + const result = await store.setArchived(ws.id, false); + busyId = null; + if (!result.ok) { + actionError = result.error; + return; + } + // Refresh the archived list so the unarchived workspace leaves it. + void store.refreshArchived(); + } + + function open(ws: WorkspaceEntry): void { + onOpen(ws.id); + onClose(); + } +</script> + +<div class="modal modal-open" role="dialog" aria-label="Archived workspaces"> + <div class="modal-box max-w-lg"> + <div class="flex items-start justify-between gap-2"> + <div> + <h2 class="text-lg font-bold">Archived workspaces</h2> + <p class="text-xs opacity-50">Hidden from the home grid. Unarchive to restore.</p> + </div> + <button + type="button" + class="btn btn-circle btn-ghost btn-sm" + aria-label="Close archived workspaces" + onclick={onClose}>✕</button + > + </div> + + {#if actionError} + <p class="mt-3 text-xs text-error">{actionError}</p> + {/if} + + <div class="mt-4"> + {#if store.archivedLoading && store.archivedList.length === 0} + <div class="flex justify-center py-8"> + <span class="loading loading-spinner loading-md opacity-60"></span> + </div> + {:else if store.archivedList.length === 0} + <p class="py-8 text-center text-sm opacity-50">No archived workspaces.</p> + {:else} + <ul class="flex flex-col gap-2"> + {#each store.archivedList as ws (ws.id)} + <li + class="flex items-center justify-between gap-3 rounded-box border border-base-content/10 bg-base-200/40 px-3 py-2" + > + <div class="min-w-0 flex-1"> + <p class="truncate font-semibold">{ws.title}</p> + <p class="font-mono text-xs opacity-50"> + /{ws.id} · {ws.conversationCount} conv · {relativeTime( + ws.lastActivityAt, + Date.now(), + )} ago + </p> + </div> + <div class="flex shrink-0 items-center gap-1"> + <button + type="button" + class="btn btn-ghost btn-xs" + onclick={() => open(ws)} + aria-label={`Open ${ws.title}`}>View</button + > + <button + type="button" + class="btn btn-outline btn-xs" + disabled={busyId === ws.id} + onclick={() => void unarchive(ws)} + aria-label={`Unarchive ${ws.title}`} + > + {#if busyId === ws.id} + <span class="loading loading-spinner loading-xs"></span> + {:else} + Unarchive + {/if} + </button> + </div> + </li> + {/each} + </ul> + {/if} + </div> + </div> + <!-- Click outside closes. --> + <button + type="button" + class="modal-backdrop" + aria-label="Close archived workspaces backdrop" + onclick={onClose}></button> +</div> diff --git a/src/features/workspaces/ui/HomeHeader.svelte b/src/features/workspaces/ui/HomeHeader.svelte new file mode 100644 index 0000000..5329b2d --- /dev/null +++ b/src/features/workspaces/ui/HomeHeader.svelte @@ -0,0 +1,198 @@ +<script lang="ts"> + import ArchiveIcon from "phosphor-svelte/lib/ArchiveIcon"; + import DotsThreeIcon from "phosphor-svelte/lib/DotsThreeIcon"; + import PlusIcon from "phosphor-svelte/lib/PlusIcon"; + import PuzzlePieceIcon from "phosphor-svelte/lib/PuzzlePieceIcon"; + import { isValidSlug } from "../logic/route"; + + /** The create outcome the page reports back to the popover. */ + export type CreateResult = { readonly ok: true } | { readonly ok: false; readonly error: string }; + + let { + compact = false, + withPlugins = false, + withSort = false, + onCreate, + onOpenPlugins, + onSort, + onOpenArchived, + }: { + /** Tall geometry: the controls collapse to compact icon buttons and the + * new-workspace popover becomes a bottom sheet. */ + compact?: boolean; + /** The plugins port is present → the Plugins button renders. */ + withPlugins?: boolean; + /** The Sort overflow action renders (recent-activity re-apply). */ + withSort?: boolean; + /** Create the workspace on the backend (does NOT navigate); the page + * places the new tile + enters rename mode. */ + onCreate: (slug: string) => Promise<CreateResult>; + onOpenPlugins: () => void; + onSort: () => void; + /** Open the archived-workspaces view. */ + onOpenArchived: () => void; + } = $props(); + + // ── New workspace popover (today's inline form logic, preserved) ────────── + let newSlug = $state(""); + let slugError = $state<string | null>(null); + let popoverOpen = $state(false); + let submitting = $state(false); + let slugInput = $state<HTMLInputElement | undefined>(); + + const slugValid = $derived(newSlug.length > 0 && isValidSlug(newSlug)); + + function openPopover(): void { + popoverOpen = true; + slugError = null; + queueMicrotask(() => slugInput?.focus()); + } + + function closePopover(): void { + popoverOpen = false; + newSlug = ""; + slugError = null; + } + + async function submit(): Promise<void> { + const slug = newSlug.trim(); + if (!isValidSlug(slug)) { + slugError = "Lowercase letters, digits, and hyphens (1–40 chars)."; + return; + } + slugError = null; + submitting = true; + const result = await onCreate(slug); + submitting = false; + if (!result.ok) { + slugError = result.error; + return; + } + closePopover(); + } +</script> + +<!-- `.app-navbar` (app.css): the same Artix-wiki navbar look as the workspace + top bar — gradient, drop shadow, engraved primary-blue title. --> +<header class="app-navbar flex shrink-0 items-end justify-between gap-2 px-8 pt-6 pb-4 md:px-12"> + <div class="flex items-baseline gap-3"> + <h1 class="app-navbar-engraved text-4xl font-black tracking-tight text-primary">Dispatch</h1> + <span class="hidden text-sm font-medium tracking-widest uppercase text-primary/70 sm:inline"> + Workspaces + </span> + </div> + + <!-- Navbar actions (app.css): "New workspace" is the one raised button + (`.navbar-btn`, primary-tinted); the secondary actions are plain text + links (`.navbar-link`) — the light-blue colour + spacing say clickable + without button chrome. Compact: labels collapse to Phosphor icons. --> + <div class="flex items-center gap-1" class:gap-3={!compact}> + <!-- New workspace --> + <button + type="button" + class="navbar-btn navbar-btn-primary" + class:navbar-btn-icon={compact} + aria-label="New workspace" + title="New workspace" + onclick={() => (popoverOpen ? closePopover() : openPopover())} + > + <PlusIcon size={16} weight="bold" aria-hidden="true" /> + {#if !compact}<span>New workspace</span>{/if} + </button> + + {#if withPlugins} + <button + type="button" + class="navbar-link" + aria-label="Plugins" + title="Open the plugin manager (load/unload plugins globally)" + onclick={onOpenPlugins} + > + {#if compact}<PuzzlePieceIcon size={18} aria-hidden="true" />{:else}<span>Plugins</span>{/if} + </button> + {/if} + + <!-- Archived workspaces --> + <button + type="button" + class="navbar-link" + aria-label="Archived workspaces" + title="View archived workspaces" + onclick={onOpenArchived} + > + {#if compact}<ArchiveIcon size={18} aria-hidden="true" />{:else}<span>Archived</span>{/if} + </button> + + {#if withSort} + <!-- Overflow last, like a navbar's trailing menu. --> + <div class="dropdown dropdown-end"> + <button + type="button" + class="navbar-link" + aria-label="More actions" + title="More" + > + <DotsThreeIcon size={20} weight="bold" aria-hidden="true" /> + </button> + <ul class="dropdown-content menu z-50 mt-1 w-52 rounded-box border border-base-content/15 bg-base-100 p-2 shadow-xl"> + <li> + <button type="button" onclick={onSort}>Reset arrangement by recent activity</button> + </li> + </ul> + </div> + {/if} + </div> +</header> + +{#if popoverOpen} + <!-- Backdrop closes the popover. --> + <button + type="button" + class="fixed inset-0 z-40 cursor-default" + aria-label="Close new workspace" + onclick={closePopover}></button> + + <!-- One popover body; the geometry only changes its positioning (bottom + sheet on tall screens, a corner card on wide ones). --> + <div + class="fixed z-50 border border-base-content/15 bg-base-100 p-4 shadow-2xl" + class:inset-x-0={compact} + class:bottom-0={compact} + class:rounded-t-box={compact} + class:top-14={!compact} + class:right-6={!compact} + class:w-80={!compact} + class:rounded-box={!compact} + > + <form + class="flex flex-col gap-2" + onsubmit={(e) => { + e.preventDefault(); + void submit(); + }} + > + <label for="new-ws" class="text-xs font-semibold uppercase opacity-60">New workspace</label> + <input + id="new-ws" + bind:this={slugInput} + class="input input-bordered w-full" + placeholder="my-workspace" + bind:value={newSlug} + autocomplete="off" + spellcheck="false" + /> + {#if slugError}<p class="text-xs text-error">{slugError}</p>{/if} + <div class="flex justify-end gap-2"> + {#if compact} + <button type="button" class="btn btn-ghost btn-sm" onclick={closePopover}>Cancel</button> + {/if} + <button type="submit" class="btn btn-primary btn-sm" disabled={!slugValid || submitting}> + {#if submitting} + <span class="loading loading-spinner loading-xs"></span> + {/if} + Create + </button> + </div> + </form> + </div> +{/if} diff --git a/src/features/workspaces/ui/HomeHeader.test.ts b/src/features/workspaces/ui/HomeHeader.test.ts new file mode 100644 index 0000000..4db8b04 --- /dev/null +++ b/src/features/workspaces/ui/HomeHeader.test.ts @@ -0,0 +1,94 @@ +import { render, screen } from "@testing-library/svelte"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import HomeHeader from "./HomeHeader.svelte"; + +function props() { + return { + onCreate: vi.fn(async (_slug: string): Promise<{ ok: true }> => ({ ok: true })), + onOpenPlugins: vi.fn(), + onSort: vi.fn(), + onOpenArchived: vi.fn(), + }; +} + +describe("HomeHeader", () => { + it("renders the display title and the primary New workspace button", () => { + const p = props(); + render(HomeHeader, { props: p }); + expect(screen.getByRole("heading", { name: "Dispatch" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "New workspace" })).toBeInTheDocument(); + }); + + it("the Plugins button renders only with the plugins port", () => { + const p = props(); + const without = render(HomeHeader, { props: p }); + expect(without.queryByRole("button", { name: "Plugins" })).toBeNull(); + without.unmount(); + + render(HomeHeader, { props: { ...p, withPlugins: true } }); + screen.getByRole("button", { name: "Plugins" }).click(); + expect(p.onOpenPlugins).toHaveBeenCalledTimes(1); + }); + + it("the Sort overflow renders only when asked, and dispatches", async () => { + const user = userEvent.setup(); + const p = props(); + render(HomeHeader, { props: { ...p, withSort: true } }); + + await user.click(screen.getByRole("button", { name: "More actions" })); + await user.click(screen.getByRole("button", { name: "Reset arrangement by recent activity" })); + expect(p.onSort).toHaveBeenCalledTimes(1); + }); + + it("the Create button disables for an invalid slug (validation before submit)", async () => { + const user = userEvent.setup(); + const p = props(); + render(HomeHeader, { props: p }); + + await user.click(screen.getByRole("button", { name: "New workspace" })); + const input = screen.getByRole("textbox", { name: "New workspace" }); + await user.type(input, "Invalid Slug!"); + expect(screen.getByRole("button", { name: "Create" })).toBeDisabled(); + expect(p.onCreate).not.toHaveBeenCalled(); + + // A valid slug enables it. + await user.clear(input); + await user.type(input, "my-workspace"); + expect(screen.getByRole("button", { name: "Create" })).toBeEnabled(); + }); + + it("submitting the form with Enter creates via the callback and closes the popover", async () => { + const user = userEvent.setup(); + const p = props(); + render(HomeHeader, { props: p }); + + await user.click(screen.getByRole("button", { name: "New workspace" })); + await user.type(screen.getByRole("textbox", { name: "New workspace" }), "my-workspace{Enter}"); + + expect(p.onCreate).toHaveBeenCalledWith("my-workspace"); + }); + + it("a backend error keeps the form open with the message", async () => { + const user = userEvent.setup(); + const onCreate = vi.fn( + async (): Promise<{ ok: false; error: string }> => ({ ok: false, error: "taken" }), + ); + render(HomeHeader, { props: { ...props(), onCreate } }); + + await user.click(screen.getByRole("button", { name: "New workspace" })); + await user.type(screen.getByRole("textbox", { name: "New workspace" }), "my-workspace"); + await user.click(screen.getByRole("button", { name: "Create" })); + + expect(await screen.findByText("taken")).toBeInTheDocument(); + }); + + it("compact mode collapses the primary button to an icon", () => { + const p = props(); + render(HomeHeader, { props: { ...p, compact: true } }); + const button = screen.getByRole("button", { name: "New workspace" }); + expect(button).toHaveClass("navbar-btn-icon"); + expect(button).toHaveTextContent(""); // icon only — the label collapses + expect(button.querySelector("svg")).not.toBeNull(); + }); +}); diff --git a/src/features/workspaces/ui/HomeTileGrid.svelte b/src/features/workspaces/ui/HomeTileGrid.svelte new file mode 100644 index 0000000..40a46c5 --- /dev/null +++ b/src/features/workspaces/ui/HomeTileGrid.svelte @@ -0,0 +1,568 @@ +<script lang="ts"> + import type { WorkspaceEntry } from "@dispatch/wire"; + import { gsap } from "gsap"; + import { Flip } from "gsap/Flip"; + import { tick } from "svelte"; + import { nextDragPhase, type DragEvent, type DragPhase } from "../logic/drag-controller"; + import { + displacePlace, + dragAppendLimit, + type Cell, + type GridGeometry, + type Placement, + type Span, + } from "../logic/tile-layout"; + import { tileTone, type TileTone } from "../logic/view-model"; + import type { WorkspaceStore } from "../store.svelte"; + import WorkspaceTile from "./WorkspaceTile.svelte"; + + gsap.registerPlugin(Flip); + + let { + geometry, + placements, + workspacesById, + store, + selectedId = null, + renamingId = null, + preview = null, + hasActive, + onOpen, + onSelect, + onPlace, + onRenameEnd, + }: { + geometry: GridGeometry; + /** Packed placements of the COMMITTED layout (the drag preview derives). */ + placements: readonly Placement[]; + workspacesById: ReadonlyMap<string, WorkspaceEntry>; + /** Forwarded to tiles for the inline rename (semantics preserved). */ + store: WorkspaceStore; + selectedId?: string | null; + renamingId?: string | null; + /** The resize picker's transient ghost footprint for this tile. */ + preview?: { readonly id: string; readonly colSpan: number; readonly rowSpan: number } | null; + /** Optional port: does the workspace have an active (generating) conversation. */ + hasActive?: (workspaceId: string) => boolean; + onOpen: (id: string) => void; + onSelect: (id: string) => void; + /** The drag dropped `id` at `cell` (null = the pointer never resolved to a cell). */ + onPlace: (id: string, cell: Cell | null) => void; + onRenameEnd: () => void; + } = $props(); + + // ── Drag/press state machine (pure logic, wired to pointer events) ──────── + const DRAG_THRESHOLD_PX = 6; + const LONG_PRESS_MS = 500; + let phase = $state<DragPhase>({ kind: "idle" }); + let longPressTimer: ReturnType<typeof setTimeout> | undefined; + /** Pointer position where the current press began (the follow transform base). */ + let pressOrigin = $state<{ x: number; y: number } | null>(null); + /** Where within the pressed tile the pointer grabbed (its follow-transform base). */ + let grabOffset = $state<{ x: number; y: number } | null>(null); + /** The tile mid-settle after a drop (excluded from FLIP until it lands). */ + let settling = $state<string | null>(null); + + function armLongPress(): void { + disarmLongPress(); + longPressTimer = setTimeout(() => { + longPressTimer = undefined; + dispatch({ type: "long-press" }); + }, LONG_PRESS_MS); + } + + function disarmLongPress(): void { + if (longPressTimer !== undefined) { + clearTimeout(longPressTimer); + longPressTimer = undefined; + } + } + + function dispatch(event: DragEvent): void { + const wasDragging = phase.kind === "dragging" ? phase.id : null; + const transition = nextDragPhase(phase, event, { dragThresholdPx: DRAG_THRESHOLD_PX }); + phase = transition.phase; + const emit = transition.emit; + if (emit) { + if (emit.kind === "tap") onOpen(emit.id); + else if (emit.kind === "lift") onSelect(emit.id); + else if (emit.kind === "drop") drop(emit.id, emit.targetCell); + } + if (phase.kind === "idle") { + // The gesture ended (tap/drop/cancel). A DROP settles via `drop` above; + // a CANCEL mid-drag flies the tile back to its committed slot. + if (wasDragging && emit?.kind !== "drop") flyBack(wasDragging); + pressOrigin = null; + grabOffset = null; + } + // Keep the long-press armed exactly while a TOUCH press is undecided. + if (phase.kind === "pressing" && phase.touch) armLongPress(); + else disarmLongPress(); + } + + /** The tile being dragged (or settling after a drop) follows no FLIP. */ + const excludedFromFlip = $derived( + new Set( + [phase.kind === "dragging" ? phase.id : null, settling].filter( + (id): id is string => id !== null, + ), + ), + ); + + /** Commit the drop, then settle the tile from the pointer onto its new cell. */ + function drop(id: string, cell: Cell | null): void { + if (cell !== null) onPlace(id, cell); + flyBack(id); + } + + /** + * Animate the tile's follow transform to identity at its (possibly new) + * committed slot — the drop settle AND the cancel/Escape fly-back. The tile + * is excluded from Flip until the animation completes so the two never + * fight over the same transform. + */ + function flyBack(id: string): void { + const el = tileEls.get(id); + if (!el) return; + settling = id; + void tick().then(() => { + gsap.to(el, { + x: 0, + y: 0, + scale: 1, + duration: 0.2, + ease: "power2.out", + onComplete: () => { + gsap.set(el, { clearProps: "transform" }); + settling = null; + }, + }); + }); + } + + /** Interactive targets inside a tile (the rename input) own their gestures. */ + function insideInteractive(target: EventTarget | null): boolean { + const el = target instanceof HTMLElement ? target : null; + return el?.closest("input, select, textarea, button, a") !== null; + } + + /** + * Drag gestures live on the GRID CONTAINER, not the tiles: the container is + * never re-parented, never toggles `pointer-events`, and never unmounts, so + * pointer capture set here survives the whole gesture. (A captured element + * whose DOM node MOVES loses its capture — the "stuck tile" bug — and an + * uncaptured pointer can't stream events over empty cells — the empty-cell + * bug.) Capture also keeps move/up flowing when the pointer leaves the grid. + */ + function gridPointerDown(e: PointerEvent): void { + if (e.button !== 0) return; + const tileEl = (e.target as Element | null)?.closest("[data-tile-id]"); + if (!tileEl || insideInteractive(e.target)) return; + const id = tileEl.getAttribute("data-tile-id"); + if (id === null) return; + // Capture mouse/pen so fast drags keep streaming to this container. TOUCH + // is purposely NOT captured here: an early touch capture can suppress + // native scrolling (WebKit), and touch already gets the browser's implicit + // capture — after the long-press lift the moved events bubble up to this + // container anyway. + if (e.pointerType !== "touch") gridEl?.setPointerCapture?.(e.pointerId); + // Where within the tile the pointer grabbed — the follow transform keeps + // THIS point under the pointer (not the tile's top-left origin). + const rect = tileEl.getBoundingClientRect(); + grabOffset = { x: e.clientX - rect.left, y: e.clientY - rect.top }; + pressOrigin = { x: e.clientX, y: e.clientY }; + dispatch({ + type: "pointer-down", + id, + x: e.clientX, + y: e.clientY, + touch: e.pointerType === "touch", + }); + } + + function gridPointerMove(e: PointerEvent): void { + if (phase.kind === "idle") return; + dispatch({ + type: "pointer-move", + x: e.clientX, + y: e.clientY, + targetCell: cellAt(e.clientX, e.clientY), + }); + } + + function gridPointerUp(): void { + if (phase.kind === "idle") return; + dispatch({ type: "pointer-up" }); + } + + // Escape mid-drag: cancel BEFORE the page-level Escape unwinds the + // selection — the committed layout is untouched and the tile flies home. + function windowKeydown(e: KeyboardEvent): void { + if (e.key !== "Escape" || phase.kind === "idle") return; + e.stopPropagation(); + dispatch({ type: "cancel" }); + } + + // ── Cell hit math (pointer → grid cell; empty cells are drop targets) ───── + let gridEl = $state<HTMLDivElement | undefined>(); + + const pitchX = $derived(geometry.unit.width + geometry.unit.gap); + const pitchY = $derived(geometry.unit.height + geometry.unit.gap); + + /** + * The drag target's clamp on the growth axis — at most 3 columns (wide) / + * rows (tall) past the current content edge. Measured on the COMMITTED + * placements (the drag preview never ratchets the limit mid-drag), so the + * canvas can't endlessly grow when dragging far right / down. + */ + const appendLimit = $derived(dragAppendLimit(placements, geometry)); + + function cellAt(clientX: number, clientY: number): Cell | null { + const el = gridEl; + if (!el) return null; + const rect = el.getBoundingClientRect(); + const wide = geometry.mode === "wide"; + // Wide: fixed-width columns at the pitch. Tall: fluid 1fr columns. + const cellW = wide ? pitchX : rect.width / geometry.flowCols; + const col = Math.max(1, Math.floor((clientX - rect.left) / cellW) + 1); + const row = Math.max(1, Math.floor((clientY - rect.top) / pitchY) + 1); + return { + col: wide ? Math.min(col, appendLimit) : Math.min(col, geometry.flowCols), + row: wide ? Math.min(row, geometry.flowRows) : Math.min(row, appendLimit), + }; + } + + // ── The drag preview: COMMITTED placements + displacement, fresh per cell ─ + /** The dragged tile's id while a drag is in flight (else null). */ + const draggedId = $derived<string | null>( + phase.kind === "dragging" ? phase.id : null, + ); + const draggedPlacement = $derived( + draggedId !== null + ? (placements.find((placement) => placement.id === draggedId) ?? null) + : null, + ); + + /** The pointer's hovered cell while dragging (else null). */ + const hoveredCell = $derived<Cell | null>( + phase.kind === "dragging" ? phase.targetCell : null, + ); + + /** The landing cell: where the pointer is, else the tile's committed slot. */ + const targetCell = $derived<Cell | null>( + hoveredCell ?? + (draggedPlacement ? { col: draggedPlacement.col, row: draggedPlacement.row } : null), + ); + + /** + * What to render: while dragging, the displacement of the COMMITTED layout + * at the target cell — recomputed from committed on EVERY hover change (as + * if the user moved straight from the original to here); the FLIP below + * animates from wherever the tiles currently are. Otherwise the committed + * placements. + */ + const rendered = $derived( + draggedPlacement && targetCell && draggedId !== null + ? displacePlace(placements, draggedId, targetCell, geometry) + : placements, + ); + + // ── FLIP reflow (GSAP Flip — re-entrant: captures mid-flight positions) ─── + let tileEls = new Map<string, HTMLElement>(); + + function trackTile(el: HTMLElement, id: string): { destroy: () => void } { + tileEls.set(id, el); + return { + destroy() { + tileEls.delete(id); + }, + }; + } + + let flipState: ReturnType<typeof Flip.getState> | null = null; + + $effect.pre(() => { + // Track the layout inputs; the DOM read below is intentionally untracked. + void rendered + .map((p) => `${p.id}:${p.col},${p.row},${p.colSpan}x${p.rowSpan}`) + .join("|"); + void geometry.mode; + flipState = Flip.getState( + [...tileEls].filter(([id]) => !excludedFromFlip.has(id)).map(([, el]) => el), + ); + }); + + $effect(() => { + void rendered; + void geometry.mode; + const state = flipState; + flipState = null; + if (!state) return; + Flip.from(state, { + duration: 0.25, + // Fast start, decelerating arrival — the reflow sweeps out and settles + // into the destination (an inOut curve would ease INTO the motion too). + ease: "power3.out", + nested: true, + onEnter: (elements) => gsap.fromTo(elements, { opacity: 0 }, { opacity: 1, duration: 0.2 }), + }); + }); + + // ── Wide-mode horizontal panning: wheel + pan arrow + scroll indicator ──── + let scrollerEl = $state<HTMLDivElement | undefined>(); + let scrollProgress = $state(0); + let canPan = $state(false); + + function updateScroll(): void { + const el = scrollerEl; + if (!el) return; + const max = el.scrollWidth - el.clientWidth; + canPan = geometry.mode === "wide" && max > 1; + scrollProgress = max > 0 ? el.scrollLeft / max : 0; + } + + $effect(() => { + // Re-measure when the geometry flips, the content changes, or a drag + // starts/ends (the canvas grows with the preview and shrinks back after). + void geometry.mode; + void placements.length; + void dragging; + updateScroll(); + }); + + function handleWheel(e: WheelEvent): void { + // Vertical wheel pans the wide canvas horizontally. + if (geometry.mode !== "wide" || Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return; + e.preventDefault(); + scrollerEl?.scrollBy({ left: e.deltaY }); + } + + function pan(): void { + const el = scrollerEl; + if (!el) return; + const max = el.scrollWidth - el.clientWidth; + const atEnd = el.scrollLeft >= max - 1; + // One viewport sideways; past the end, pan back to the start. + el.scrollBy({ left: atEnd ? -max - el.clientWidth : el.clientWidth, behavior: "smooth" }); + } + + // ── Grid + tile styles (the geometry decides; no breakpoint classes) ────── + const dragging = $derived(phase.kind === "dragging"); + + const gridStyle = $derived.by(() => { + const wide = geometry.mode === "wide"; + // Content-driven canvas: the grid hugs its content (max-content / auto), + // so the scrollbar GROWS as the preview reaches right/down and SHRINKS when + // the gesture ends — the drag sees the scrollbar move as it should. The + // drop-target clamp (`cellAt` → dragAppendLimit) bounds how far the preview + // can extend, so the canvas never grows past the committed edge + 3. + const base = + (wide + ? `grid-template-rows: repeat(${geometry.flowRows}, ${geometry.unit.height}px);` + + ` grid-auto-flow: column; grid-auto-columns: ${geometry.unit.width}px;` + + ` gap: ${geometry.unit.gap}px; width: max-content;` + : `grid-template-columns: repeat(${geometry.flowCols}, minmax(0, 1fr));` + + ` grid-auto-flow: row; grid-auto-rows: ${geometry.unit.height}px;` + + ` gap: ${geometry.unit.gap}px;`); + if (!dragging) return base; + // The drop-target dots: one faint dot at each unit cell's CENTER, painted + // as a background whose tile is the cell pitch. One extra pitch of padding + // past the content end gives the append-slot a dot too. + const dotSize = wide ? `${pitchX}px` : `calc(100% / ${geometry.flowCols})`; + const dotX = wide ? geometry.unit.width / 2 : 0; + const dot = + ` background-image: radial-gradient(circle 2.5px,` + + ` color-mix(in oklab, var(--color-base-content) 22%, transparent) 98%, transparent 100%);` + + ` background-size: ${dotSize} ${pitchY}px;` + + ` background-position: ${dotX}px ${geometry.unit.height / 2}px;` + + (wide ? ` padding-right: ${pitchX}px;` : ` padding-bottom: ${pitchY}px;`); + return base + dot; + }); + + function spanFor(placement: Placement): Span { + if (preview && preview.id === placement.id) { + return { colSpan: preview.colSpan, rowSpan: preview.rowSpan }; + } + return { colSpan: placement.colSpan, rowSpan: placement.rowSpan }; + } + + function wrapperStyle(placement: Placement): string { + const span = spanFor(placement); + return `grid-column: ${placement.col} / span ${span.colSpan}; grid-row: ${placement.row} / span ${span.rowSpan};`; + } + + /** + * The landing cell's top-left in CLIENT coordinates — where the CSS grid + * actually places a tile anchored at `targetCell` (the true column pitch: + * fixed in wide; the fluid column width PLUS the gap in tall). The follow + * transform cancels exactly this, so the grab point tracks the pointer. + */ + const targetCellOrigin = $derived.by<{ x: number; y: number } | null>(() => { + const el = gridEl; + if (!el || !targetCell) return null; + const rect = el.getBoundingClientRect(); + const wide = geometry.mode === "wide"; + const colPitch = wide ? pitchX : (rect.width + geometry.unit.gap) / geometry.flowCols; + return { + x: rect.left + (targetCell.col - 1) * colPitch, + y: rect.top + (targetCell.row - 1) * pitchY, + }; + }); + + /** The dragged tile's follow transform (it keeps its committed slot). */ + function followStyle(placement: Placement): string { + if (draggedId !== placement.id || !pressOrigin || !grabOffset || phase.kind !== "dragging") { + return ""; + } + const origin = targetCellOrigin; + if (!origin) return ""; + // Pointer minus the tile's GRID anchor minus the grab offset — the grab + // point the user pressed tracks the pointer. A raw pointer-displacement + // translate would ride the tile's origin instead and jump by the grab + // offset (a center grab snaps the tile's top-left under the pointer). + const dx = phase.x - origin.x - grabOffset.x; + const dy = phase.y - origin.y - grabOffset.y; + return `transform: translate(${dx}px, ${dy}px) scale(1.05);`; + } + + // ── The drop ghost: a dashed footprint at the target anchor ─────────────── + const ghostStyle = $derived.by(() => { + if (!dragging || !targetCell || !draggedPlacement) return null; + const span = spanFor(draggedPlacement); + const wide = geometry.mode === "wide"; + const h = span.rowSpan * pitchY - geometry.unit.gap; + const x = wide + ? `${(targetCell.col - 1) * pitchX}px` + : `calc(${((targetCell.col - 1) / geometry.flowCols) * 100}%)`; + const width = wide + ? `${span.colSpan * pitchX - geometry.unit.gap}px` + : `calc(${(span.colSpan / geometry.flowCols) * 100}% - ${(geometry.unit.gap * (geometry.flowCols - span.colSpan)) / geometry.flowCols}px)`; + return `left: ${x}; top: ${(targetCell.row - 1) * pitchY}px; width: ${width}; height: ${h}px;`; + }); + + /** + * The Start-screen tile tones (static classes — Tailwind/DaisyUI must see + * the literal `bg-*`/`text-*-content` names to emit them). A tile is its + * tone's SATURATED color with the tone's `-content` text, like Win8's + * colored tiles on the dark Start canvas. + */ + const TONE_CLASSES: Record<TileTone, string> = { + primary: "bg-primary text-primary-content border-primary", + secondary: "bg-secondary text-secondary-content border-secondary", + accent: "bg-accent text-accent-content border-accent", + info: "bg-info text-info-content border-info", + success: "bg-success text-success-content border-success", + warning: "bg-warning text-warning-content border-warning", + }; +</script> + +<svelte:window onkeydowncapture={windowKeydown} /> + +<div class="relative h-full"> + <div + bind:this={scrollerEl} + class="h-full overscroll-contain" + class:overflow-x-auto={geometry.mode === "wide"} + class:overflow-y-auto={geometry.mode === "tall"} + style={`padding: ${geometry.margin}px; scroll-snap-type: ${geometry.mode === "wide" ? "x" : "y"} proximity;`} + onscroll={updateScroll} + onwheel={handleWheel} + > + <!-- svelte-ignore a11y_no_static_element_interactions -- the grid owns the drag gestures (capture must live on a never-reparented element) --> + <div + bind:this={gridEl} + class="relative grid" + style={gridStyle} + onpointerdown={gridPointerDown} + onpointermove={gridPointerMove} + onpointerup={gridPointerUp} + onpointercancel={() => dispatch({ type: "cancel" })} + > + {#if ghostStyle && draggedPlacement} + <!-- The landing footprint (the dragged tile's tone, dashed). --> + <div + aria-hidden="true" + class="pointer-events-none absolute z-0 rounded-box border-2 border-dashed opacity-70" + class:border-primary={tileTone(draggedPlacement.id) === "primary"} + class:border-secondary={tileTone(draggedPlacement.id) === "secondary"} + class:border-accent={tileTone(draggedPlacement.id) === "accent"} + class:border-info={tileTone(draggedPlacement.id) === "info"} + class:border-success={tileTone(draggedPlacement.id) === "success"} + class:border-warning={tileTone(draggedPlacement.id) === "warning"} + style={ghostStyle} + ></div> + {/if} + {#each rendered as placement (placement.id)} + {@const ws = workspacesById.get(placement.id)} + {#if ws} + {@const isDragged = draggedId === placement.id} + <!-- svelte-ignore a11y_no_static_element_interactions, a11y_click_events_have_key_events --> + <div + data-tile-id={placement.id} + class={`raised flex select-none flex-col overflow-hidden rounded-box border transition-[box-shadow] duration-150 ${ + TONE_CLASSES[tileTone(placement.id)] + } ${ + selectedId === placement.id + ? "ring-4 ring-base-content/80 ring-offset-2 ring-offset-base-100" + : "border-transparent" + } ${ + isDragged + ? "pointer-events-none relative z-40 shadow-2xl" + : "hover:-translate-y-0.5 hover:shadow-xl" + }`} + style={`${wrapperStyle(placement)} ${isDragged ? followStyle(placement) : ""} scroll-snap-align: start;`} + role="button" + tabindex="0" + aria-label={`Open workspace ${ws.title}`} + use:trackTile={placement.id} + oncontextmenu={(e) => { + e.preventDefault(); + onSelect(placement.id); + }} + onkeydown={(e) => { + if (insideInteractive(e.target)) return; + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onOpen(placement.id); + } + }} + > + <WorkspaceTile + {ws} + {store} + {placement} + selected={selectedId === placement.id} + renaming={renamingId === placement.id} + active={hasActive?.(ws.id) ?? false} + {onRenameEnd} + /> + </div> + {/if} + {/each} + </div> + </div> + + {#if canPan} + <!-- Pan arrow + thin scroll-position indicator (wide geometry only). + Both sit INSIDE the canvas' outer margin. --> + <button + type="button" + class="btn btn-circle absolute z-10 border-none bg-base-100 text-base-content shadow-xl" + style={`right: ${Math.max(12, geometry.margin / 2)}px; bottom: ${Math.max(12, geometry.margin / 2)}px;`} + aria-label="Pan right" + title="Pan one screen sideways" + onclick={pan} + > + → + </button> + <div + class="absolute right-0 bottom-0 left-0 z-10 h-0.5 bg-base-content/10" + style={`margin: 0 ${geometry.margin}px;`} + > + <div + class="h-full bg-primary transition-[width] duration-100" + style={`width: ${Math.round(scrollProgress * 100)}%;`} + ></div> + </div> + {/if} +</div> diff --git a/src/features/workspaces/ui/HomeTileGrid.test.ts b/src/features/workspaces/ui/HomeTileGrid.test.ts new file mode 100644 index 0000000..a8365d5 --- /dev/null +++ b/src/features/workspaces/ui/HomeTileGrid.test.ts @@ -0,0 +1,200 @@ +import type { WorkspaceEntry } from "@dispatch/wire"; +import { fireEvent, render, screen } from "@testing-library/svelte"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { geometryFor, type Placement } from "../logic/tile-layout"; +import type { WorkspaceStore } from "../store.svelte"; +import HomeTileGrid from "./HomeTileGrid.svelte"; + +const WIDE = geometryFor(1920, 1080); + +function ws(overrides: Partial<WorkspaceEntry> = {}): WorkspaceEntry { + return { + id: "a", + title: "Workspace A", + defaultCwd: null, + defaultComputerId: null, + starred: false, + archived: false, + createdAt: 1, + lastActivityAt: 2, + conversationCount: 1, + ...overrides, + }; +} + +function placement(id: string, col: number, row: number): Placement { + return { id, col, row, colSpan: 1, rowSpan: 1 }; +} + +function fakeStore(): WorkspaceStore { + return { + rename: vi.fn(), + } as unknown as WorkspaceStore; +} + +function baseProps() { + return { + geometry: WIDE, + workspacesById: new Map([ + ["a", ws()], + ["b", ws({ id: "b", title: "Workspace B" })], + ]), + store: fakeStore(), + onOpen: vi.fn(), + onSelect: vi.fn(), + onPlace: vi.fn(), + onRenameEnd: vi.fn(), + }; +} + +describe("HomeTileGrid", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it("renders one tile per placement, positioned by the packed spans", () => { + const props = baseProps(); + const { container } = render(HomeTileGrid, { + props: { + ...props, + placements: [placement("a", 1, 1), placement("b", 2, 1)], + }, + }); + + expect(screen.getByRole("button", { name: "Open workspace Workspace A" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Open workspace Workspace B" })).toBeInTheDocument(); + const wrappers = container.querySelectorAll("[data-tile-id]"); + expect(wrappers).toHaveLength(2); + expect(wrappers[0]?.getAttribute("style")).toContain("grid-column: 1 / span 1"); + expect(wrappers[1]?.getAttribute("style")).toContain("grid-column: 2 / span 1"); + }); + + it("a tap (press + release without moving) opens the workspace", async () => { + const props = baseProps(); + render(HomeTileGrid, { + props: { ...props, placements: [placement("a", 1, 1)] }, + }); + + const wrapper = screen.getByRole("button", { name: "Open workspace Workspace A" }); + fireEvent.pointerDown(wrapper, { button: 0, clientX: 10, clientY: 10, pointerType: "mouse" }); + fireEvent.pointerUp(wrapper); + + expect(props.onOpen).toHaveBeenCalledWith("a"); + expect(props.onSelect).not.toHaveBeenCalled(); + }); + + it("right-click selects the tile", async () => { + const props = baseProps(); + render(HomeTileGrid, { + props: { ...props, placements: [placement("a", 1, 1)] }, + }); + + const wrapper = screen.getByRole("button", { name: "Open workspace Workspace A" }); + fireEvent.contextMenu(wrapper); + + expect(props.onSelect).toHaveBeenCalledWith("a"); + expect(props.onOpen).not.toHaveBeenCalled(); + }); + + it("dragging shows the ghost + follow transform, and dropping PLACES at the hovered cell", async () => { + const props = baseProps(); + const { container } = render(HomeTileGrid, { + props: { ...props, placements: [placement("a", 1, 1), placement("b", 2, 1)] }, + }); + + const wrapper = screen.getByRole("button", { name: "Open workspace Workspace A" }); + // The cell math resolves against the grid element's rect (jsdom: 0s). + fireEvent.pointerDown(wrapper, { button: 0, clientX: 10, clientY: 10, pointerType: "mouse" }); + fireEvent.pointerMove(wrapper, { clientX: 80, clientY: 40 }); + + // The dashed landing ghost appears… + const ghost = container.querySelector("[aria-hidden='true'].border-dashed"); + expect(ghost).not.toBeNull(); + // …and the dragged tile itself follows the pointer (transform on the tile). + const tile = container.querySelector("[data-tile-id='a']"); + expect(tile?.getAttribute("style")).toContain("translate("); + + fireEvent.pointerUp(wrapper); + // The ghost + follow disappear on drop… + expect(container.querySelector("[aria-hidden='true'].border-dashed")).toBeNull(); + // …and the drop committed a PLACE at the hovered cell (a lands where it + // already was — the pointer hovered its own slot; a no-move drop). + expect(props.onPlace).toHaveBeenCalledWith("a", { col: 1, row: 1 }); + // And no navigation either (a drag is not a tap). + expect(props.onOpen).not.toHaveBeenCalled(); + }); + + it("Escape mid-drag CANCELS: no place commits and the tile un-lifts", async () => { + const props = baseProps(); + const { container } = render(HomeTileGrid, { + props: { ...props, placements: [placement("a", 1, 1)] }, + }); + + const wrapper = screen.getByRole("button", { name: "Open workspace Workspace A" }); + fireEvent.pointerDown(wrapper, { button: 0, clientX: 10, clientY: 10, pointerType: "mouse" }); + fireEvent.pointerMove(wrapper, { clientX: 80, clientY: 40 }); + + fireEvent.keyDown(window, { key: "Escape" }); + + expect(props.onPlace).not.toHaveBeenCalled(); + // The drag visuals are gone (the tile is no longer lifted). + expect(container.querySelector("[aria-hidden='true'].border-dashed")).toBeNull(); + }); + + it("a touch long-press lifts the tile and selects it (never opens)", async () => { + const props = baseProps(); + render(HomeTileGrid, { + props: { ...props, placements: [placement("a", 1, 1)] }, + }); + + const wrapper = screen.getByRole("button", { name: "Open workspace Workspace A" }); + fireEvent.pointerDown(wrapper, { + button: 0, + clientX: 10, + clientY: 10, + pointerType: "touch", + }); + await vi.advanceTimersByTimeAsync(500); + + expect(props.onSelect).toHaveBeenCalledWith("a"); + expect(props.onOpen).not.toHaveBeenCalled(); + }); + + it("a touch drag that moves before the long-press cancels (scroll wins)", async () => { + const props = baseProps(); + render(HomeTileGrid, { + props: { ...props, placements: [placement("a", 1, 1)] }, + }); + + const wrapper = screen.getByRole("button", { name: "Open workspace Workspace A" }); + fireEvent.pointerDown(wrapper, { + button: 0, + clientX: 10, + clientY: 10, + pointerType: "touch", + }); + fireEvent.pointerMove(wrapper, { clientX: 10, clientY: 120 }); + await vi.advanceTimersByTimeAsync(500); + + expect(props.onSelect).not.toHaveBeenCalled(); + expect(props.onOpen).not.toHaveBeenCalled(); + }); + + it("previews a transient span for the resizing tile", () => { + const props = baseProps(); + const { container } = render(HomeTileGrid, { + props: { + ...props, + placements: [placement("a", 1, 1)], + preview: { id: "a", colSpan: 3, rowSpan: 2 }, + }, + }); + + const wrapper = container.querySelector("[data-tile-id='a']"); + expect(wrapper?.getAttribute("style")).toContain("span 3"); + expect(wrapper?.getAttribute("style")).toContain("span 2"); + }); +}); diff --git a/src/features/workspaces/ui/ResizePicker.svelte b/src/features/workspaces/ui/ResizePicker.svelte new file mode 100644 index 0000000..e066519 --- /dev/null +++ b/src/features/workspaces/ui/ResizePicker.svelte @@ -0,0 +1,107 @@ +<script lang="ts"> + import { onMount, untrack } from "svelte"; + import type { GridGeometry, Span } from "../logic/tile-layout"; + + let { + geometry, + initial, + onPreview, + onCommit, + onCancel, + }: { + geometry: GridGeometry; + /** The tile's current (clamped) spans — the picker's starting cell. */ + initial: Span; + /** Transiently ghost-preview the hovered footprint on the real grid. */ + onPreview: (span: Span | null) => void; + onCommit: (colSpan: number, rowSpan: number) => void; + onCancel: () => void; + } = $props(); + + // The picker IS the span cap: wide = 4×2 small squares, tall = 2×2. + const cols = $derived(geometry.maxColSpan); + const rows = $derived(geometry.maxRowSpan); + + // The currently hovered cell (c, r) — the highlighted rectangle spans + // 1..c × 1..r from the picker's top-left (insert-table style). The picker + // mounts fresh per resize session, so seeding from the prop is intended. + let hovered = $state({ + colSpan: untrack(() => Math.min(initial.colSpan, cols)), + rowSpan: untrack(() => Math.min(initial.rowSpan, rows)), + }); + + function hover(colSpan: number, rowSpan: number): void { + hovered = { colSpan, rowSpan }; + onPreview({ colSpan, rowSpan }); + } + + function commit(): void { + onCommit(hovered.colSpan, hovered.rowSpan); + } + + function keydown(e: KeyboardEvent): void { + if (e.key === "Escape") { + e.preventDefault(); + onCancel(); + return; + } + if (e.key === "Enter") { + e.preventDefault(); + commit(); + return; + } + let dc = 0; + let dr = 0; + if (e.key === "ArrowRight") dc = 1; + else if (e.key === "ArrowLeft") dc = -1; + else if (e.key === "ArrowDown") dr = 1; + else if (e.key === "ArrowUp") dr = -1; + else return; + e.preventDefault(); + hover( + Math.max(1, Math.min(cols, hovered.colSpan + dc)), + Math.max(1, Math.min(rows, hovered.rowSpan + dr)), + ); + } + + let root = $state<HTMLDivElement | undefined>(); + onMount(() => root?.focus()); +</script> + +<!-- svelte-ignore a11y_no_noninteractive_element_interactions, a11y_no_noninteractive_tabindex --> +<div + bind:this={root} + class="fixed inset-x-0 bottom-20 z-50 mx-auto flex w-max flex-col gap-2 rounded-box border border-base-content/20 bg-base-100 p-3 shadow-2xl outline-none" + role="group" + aria-label="Resize tile" + tabindex="0" + onkeydown={keydown} + onmouseleave={() => onPreview(null)} +> + <p class="text-xs font-semibold uppercase opacity-60">Resize — click a size</p> + <div class="grid gap-1" style={`grid-template-columns: repeat(${cols}, 1.5rem);`}> + {#each Array.from({ length: rows }) as _, row (row)} + {#each Array.from({ length: cols }) as __, col (col)} + <button + type="button" + class={`size-6 border border-base-content/20 ${ + col < hovered.colSpan && row < hovered.rowSpan + ? "bg-primary/60" + : col === hovered.colSpan - 1 && row === hovered.rowSpan - 1 + ? "bg-primary/20" + : "" + }`} + aria-label={`Resize to ${col + 1} by ${row + 1}`} + onmouseenter={() => hover(col + 1, row + 1)} + onfocus={() => hover(col + 1, row + 1)} + onclick={() => { + hover(col + 1, row + 1); + commit(); + }} + ></button> + {/each} + {/each} + </div> + <p class="text-center font-mono text-xs opacity-70">{hovered.colSpan} × {hovered.rowSpan}</p> + <p class="text-center text-[10px] opacity-50">Enter commits · Escape cancels</p> +</div> diff --git a/src/features/workspaces/ui/ResizePicker.test.ts b/src/features/workspaces/ui/ResizePicker.test.ts new file mode 100644 index 0000000..796a556 --- /dev/null +++ b/src/features/workspaces/ui/ResizePicker.test.ts @@ -0,0 +1,88 @@ +import { render, screen } from "@testing-library/svelte"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { geometryFor } from "../logic/tile-layout"; +import ResizePicker from "./ResizePicker.svelte"; + +const WIDE = geometryFor(1920, 1080); +const TALL = geometryFor(390, 844); + +function props() { + return { + onPreview: vi.fn(), + onCommit: vi.fn(), + onCancel: vi.fn(), + }; +} + +describe("ResizePicker", () => { + it("renders one small square per cap (4×2 wide, 2×2 tall)", () => { + const p = props(); + const wide = render(ResizePicker, { + props: { geometry: WIDE, initial: { colSpan: 1, rowSpan: 1 }, ...p }, + }); + expect(screen.getAllByRole("button")).toHaveLength(8); + wide.unmount(); + + render(ResizePicker, { + props: { geometry: TALL, initial: { colSpan: 1, rowSpan: 1 }, ...p }, + }); + expect(screen.getAllByRole("button")).toHaveLength(4); + }); + + it("hovering square (r, c) previews that rectangle", async () => { + const user = userEvent.setup(); + const p = props(); + render(ResizePicker, { + props: { geometry: WIDE, initial: { colSpan: 1, rowSpan: 1 }, ...p }, + }); + + await user.hover(screen.getByRole("button", { name: "Resize to 3 by 2" })); + expect(p.onPreview).toHaveBeenCalledWith({ colSpan: 3, rowSpan: 2 }); + }); + + it("clicking a square commits that size", async () => { + const user = userEvent.setup(); + const p = props(); + render(ResizePicker, { + props: { geometry: WIDE, initial: { colSpan: 1, rowSpan: 1 }, ...p }, + }); + + await user.click(screen.getByRole("button", { name: "Resize to 2 by 1" })); + expect(p.onCommit).toHaveBeenCalledWith(2, 1); + }); + + it("arrow keys move within the picker, Enter commits, Escape cancels", async () => { + const user = userEvent.setup(); + const p = props(); + render(ResizePicker, { + props: { geometry: WIDE, initial: { colSpan: 2, rowSpan: 1 }, ...p }, + }); + + await user.keyboard("{ArrowRight}"); + expect(p.onPreview).toHaveBeenLastCalledWith({ colSpan: 3, rowSpan: 1 }); + await user.keyboard("{ArrowDown}"); + expect(p.onPreview).toHaveBeenLastCalledWith({ colSpan: 3, rowSpan: 2 }); + await user.keyboard("{ArrowLeft}"); + expect(p.onPreview).toHaveBeenLastCalledWith({ colSpan: 2, rowSpan: 2 }); + + await user.keyboard("{Enter}"); + expect(p.onCommit).toHaveBeenCalledWith(2, 2); + expect(p.onCancel).not.toHaveBeenCalled(); + + await user.keyboard("{Escape}"); + expect(p.onCancel).toHaveBeenCalledTimes(1); + }); + + it("leaving the picker clears the preview", async () => { + const user = userEvent.setup(); + const p = props(); + render(ResizePicker, { + props: { geometry: WIDE, initial: { colSpan: 1, rowSpan: 1 }, ...p }, + }); + + await user.hover(screen.getByRole("button", { name: "Resize to 4 by 2" })); + await user.unhover(screen.getByRole("button", { name: "Resize to 4 by 2" })); + expect(p.onPreview).toHaveBeenLastCalledWith(null); + }); +}); diff --git a/src/features/workspaces/ui/TileActionBar.svelte b/src/features/workspaces/ui/TileActionBar.svelte new file mode 100644 index 0000000..ffb8f1d --- /dev/null +++ b/src/features/workspaces/ui/TileActionBar.svelte @@ -0,0 +1,73 @@ +<script lang="ts"> + import { fly } from "svelte/transition"; + import type { WorkspaceEntry } from "@dispatch/wire"; + + let { + ws, + withPlugins = false, + onOpen, + onRename, + onResize, + onToggleStar, + onDetails, + onPlugins, + onArchive, + onDelete, + onClose, + }: { + ws: WorkspaceEntry; + /** The plugins port is present → the Plugins action renders. */ + withPlugins?: boolean; + onOpen: () => void; + onRename: () => void; + onResize: () => void; + onToggleStar: () => void; + /** The details flyout (default cwd + default computer). */ + onDetails: () => void; + onPlugins: () => void; + /** Archive the workspace (hide from the home grid; reversible). */ + onArchive: () => void; + onDelete: () => void; + onClose: () => void; + } = $props(); +</script> + +<!-- svelte-ignore a11y_no_static_element_interactions, a11y_click_events_have_key_events --> +<div + class="fixed inset-x-0 bottom-0 z-40 flex justify-center p-4" + transition:fly={{ y: 80, duration: 150 }} + onclick={(e) => { + // The bar owns its clicks — don't let them deselect via the backdrop. + e.stopPropagation(); + }} +> + <div + class="flex flex-wrap items-center justify-center gap-1 rounded-box border border-base-content/15 bg-base-100 p-2 shadow-2xl" + role="toolbar" + aria-label={`Actions for ${ws.title}`} + > + <button type="button" class="btn btn-primary btn-sm" onclick={onOpen}>Open</button> + <button type="button" class="btn btn-ghost btn-sm" onclick={onRename}>Rename</button> + <button type="button" class="btn btn-ghost btn-sm" onclick={onResize}>Resize</button> + <button type="button" class="btn btn-ghost btn-sm" onclick={onDetails}>Details</button> + <button type="button" class="btn btn-ghost btn-sm" onclick={onToggleStar}> + {ws.starred ? "Unstar" : "Star"} + </button> + {#if withPlugins} + <button type="button" class="btn btn-ghost btn-sm" onclick={onPlugins}>Plugins</button> + {/if} + <button type="button" class="btn btn-ghost btn-sm" onclick={onArchive}>Archive</button> + <button + type="button" + class="btn btn-ghost btn-sm text-error" + onclick={onDelete}>Delete</button + > + <button + type="button" + class="btn btn-circle btn-ghost btn-sm" + aria-label="Close actions" + title="Close" + onclick={onClose}>✕</button + > + </div> +</div> diff --git a/src/features/workspaces/ui/TileActionBar.test.ts b/src/features/workspaces/ui/TileActionBar.test.ts new file mode 100644 index 0000000..c00de19 --- /dev/null +++ b/src/features/workspaces/ui/TileActionBar.test.ts @@ -0,0 +1,93 @@ +import type { WorkspaceEntry } from "@dispatch/wire"; +import { render, screen } from "@testing-library/svelte"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import TileActionBar from "./TileActionBar.svelte"; + +function ws(overrides: Partial<WorkspaceEntry> = {}): WorkspaceEntry { + return { + id: "my-ws", + title: "My Workspace", + defaultCwd: null, + defaultComputerId: null, + starred: false, + archived: false, + createdAt: 1, + lastActivityAt: 2, + conversationCount: 0, + ...overrides, + }; +} + +function handlers() { + return { + onOpen: vi.fn(), + onRename: vi.fn(), + onResize: vi.fn(), + onToggleStar: vi.fn(), + onDetails: vi.fn(), + onPlugins: vi.fn(), + onArchive: vi.fn(), + onDelete: vi.fn(), + onClose: vi.fn(), + }; +} + +describe("TileActionBar", () => { + it("renders the Open · Rename · Resize · Star · Details · Delete actions", () => { + const h = handlers(); + render(TileActionBar, { props: { ws: ws(), ...h } }); + expect(screen.getByRole("button", { name: "Open" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Rename" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Resize" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Star" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Details" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Archive" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Delete" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Close actions" })).toBeInTheDocument(); + }); + + it("omits Plugins when no plugins port is present, renders it when one is", () => { + const h = handlers(); + const without = render(TileActionBar, { props: { ws: ws(), ...h } }); + expect(without.queryByRole("button", { name: "Plugins" })).toBeNull(); + without.unmount(); + + render(TileActionBar, { props: { ws: ws(), withPlugins: true, ...h } }); + expect(screen.getByRole("button", { name: "Plugins" })).toBeInTheDocument(); + }); + + it("labels the star action by the current state (Star ↔ Unstar)", () => { + const h = handlers(); + const unstarred = render(TileActionBar, { props: { ws: ws({ starred: false }), ...h } }); + expect(screen.getByRole("button", { name: "Star" })).toBeInTheDocument(); + unstarred.unmount(); + + render(TileActionBar, { props: { ws: ws({ starred: true }), ...h } }); + expect(screen.getByRole("button", { name: "Unstar" })).toBeInTheDocument(); + }); + + it("dispatches each action", async () => { + const user = userEvent.setup(); + const h = handlers(); + render(TileActionBar, { props: { ws: ws(), ...h } }); + + await user.click(screen.getByRole("button", { name: "Open" })); + await user.click(screen.getByRole("button", { name: "Rename" })); + await user.click(screen.getByRole("button", { name: "Resize" })); + await user.click(screen.getByRole("button", { name: "Star" })); + await user.click(screen.getByRole("button", { name: "Details" })); + await user.click(screen.getByRole("button", { name: "Archive" })); + await user.click(screen.getByRole("button", { name: "Delete" })); + await user.click(screen.getByRole("button", { name: "Close actions" })); + + expect(h.onOpen).toHaveBeenCalledTimes(1); + expect(h.onRename).toHaveBeenCalledTimes(1); + expect(h.onResize).toHaveBeenCalledTimes(1); + expect(h.onToggleStar).toHaveBeenCalledTimes(1); + expect(h.onDetails).toHaveBeenCalledTimes(1); + expect(h.onArchive).toHaveBeenCalledTimes(1); + expect(h.onDelete).toHaveBeenCalledTimes(1); + expect(h.onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/features/workspaces/ui/WorkspaceCard.svelte b/src/features/workspaces/ui/WorkspaceCard.svelte deleted file mode 100644 index 6de4109..0000000 --- a/src/features/workspaces/ui/WorkspaceCard.svelte +++ /dev/null @@ -1,279 +0,0 @@ -<script lang="ts"> - import type { ComputerEntry, WorkspaceEntry } from "@dispatch/wire"; - import { untrack } from "svelte"; - import type { WorkspaceStore } from "../store.svelte"; - import { relativeTime } from "../logic/view-model"; - import { workspacePath } from "../logic/route"; - import ComputerSelect from "../../computer/ui/ComputerSelect.svelte"; - - let { - ws, - store, - onNavigate, - computers, - hasActive, - }: { - ws: WorkspaceEntry; - store: WorkspaceStore; - onNavigate: (path: string) => void; - /** Discovered computers (`GET /computers`), for the default-computer dropdown. */ - computers: readonly ComputerEntry[]; - /** - * Optional port: returns whether the workspace has at least one active - * (generating / queued) conversation — drives a loading-dots indicator on - * the card. Wired by the composition root to the app store's - * `workspaceHasActiveConversations`. Absent → no indicator (e.g. tests). - */ - hasActive?: (workspaceId: string) => boolean; - } = $props(); - - // Whether at least one conversation in this workspace is currently active - // (generating). Reactive: the composition-root port reads the app store's - // reactive tab set + lifecycle statuses, so this re-derives on change. - const active = $derived(hasActive?.(ws.id) ?? false); - - // ── Title: double-click to rename inline ────────────────────────────────── - let editingTitle = $state(false); - let titleDraft = $state(""); - let titleInput = $state<HTMLInputElement | undefined>(); - let titleError = $state<string | null>(null); - - function startEditTitle(): void { - titleDraft = ws.title; - titleError = null; - editingTitle = true; - queueMicrotask(() => titleInput?.focus()); - } - - async function saveTitle(): Promise<void> { - if (!editingTitle) return; - const title = titleDraft.trim(); - editingTitle = false; - if (title === "" || title === ws.title) return; - const result = await store.rename(ws.id, title); - if (!result.ok) titleError = result.error; - } - - function cancelTitle(): void { - editingTitle = false; - titleError = null; - } - - // ── Default cwd: inline input ───────────────────────────────────────────── - let cwdDraft = $state(untrack(() => ws.defaultCwd ?? "")); - // Reseed when the backend value changes (e.g., after a save or an external - // refresh). Mid-edit (same value) does NOT re-run, so typing is never clobbered. - $effect(() => { - cwdDraft = ws.defaultCwd ?? ""; - }); - - const cwdDirty = $derived(cwdDraft.trim() !== (ws.defaultCwd ?? "")); - let savingCwd = $state(false); - let cwdError = $state<string | null>(null); - - async function saveCwd(): Promise<void> { - if (!cwdDirty || savingCwd) return; - savingCwd = true; - cwdError = null; - const cwd = cwdDraft.trim(); - const result = await store.setDefaultCwd(ws.id, cwd === "" ? null : cwd); - savingCwd = false; - if (!result.ok) cwdError = result.error; - } - - // ── Default computer: dropdown (Local / discovered SSH aliases) ──────────── - let savingComputer = $state(false); - let computerError = $state<string | null>(null); - - async function saveComputer(computerId: string | null): Promise<void> { - if (savingComputer) return; - // No-op when unchanged (the select only fires on a real change, but guard). - if (computerId === (ws.defaultComputerId ?? null)) return; - savingComputer = true; - computerError = null; - const result = await store.setDefaultComputer(ws.id, computerId); - savingComputer = false; - if (!result.ok) computerError = result.error; - } - - // ── Star (concurrency priority) ────────────────────────────────────────────── - let savingStar = $state(false); - let starError = $state<string | null>(null); - - async function toggleStar(): Promise<void> { - if (savingStar) return; - savingStar = true; - starError = null; - try { - // Optimistic: the store flips `starred` immediately and re-sorts; revert - // on error is handled there. We read `ws.starred` for the target value. - const result = await store.setStarred(ws.id, !ws.starred); - if (!result.ok) starError = result.error; - } catch (err) { - // A throw (e.g. a rejected effect) — surface it; the store already - // reverted the optimistic flip if it got far enough to apply it. - starError = err instanceof Error ? err.message : "Star toggle failed"; - } finally { - savingStar = false; - } - } - - // ── Delete ───────────────────────────────────────────────────────────────── - let deleting = $state(false); - - async function handleDelete(): Promise<void> { - if ( - !window.confirm( - `Delete workspace "${ws.title}"? Its conversations will be closed and moved to "default".`, - ) - ) { - return; - } - deleting = true; - await store.remove(ws.id); - deleting = false; - } -</script> - -<li class="flex flex-col gap-2 rounded-box border border-primary bg-primary/10 p-3"> - <div class="flex items-center gap-2"> - {#if editingTitle} - <input - bind:this={titleInput} - bind:value={titleDraft} - class="input input-bordered input-sm flex-1" - aria-label="Workspace title" - onkeydown={(e) => { - if (e.key === "Enter") saveTitle(); - else if (e.key === "Escape") cancelTitle(); - }} - onblur={saveTitle} - /> - {:else} - <!-- svelte-ignore a11y_no_static_element_interactions, a11y_click_events_have_key_events --> - <span - class="flex-1 cursor-default truncate font-semibold" - title="Double-click to rename" - ondblclick={startEditTitle}>{ws.title}</span - > - {/if} - {#if active} - <span - class="loading loading-dots loading-xs shrink-0 text-primary" - aria-label="Workspace has active conversations" - title="A conversation in this workspace is generating"></span - > - {/if} - <span class="font-mono text-xs opacity-50">/{ws.id}</span> - <button - type="button" - class="btn btn-ghost btn-xs px-1" - disabled={savingStar} - aria-pressed={ws.starred} - aria-label={ws.starred ? "Unstar workspace" : "Star workspace"} - title={ws.starred - ? "Starred — its agents get concurrency priority. Click to unstar." - : "Star this workspace to give its agents concurrency priority."} - onclick={toggleStar} - > - {#if savingStar} - <span class="loading loading-spinner loading-xs"></span> - {:else if ws.starred} - <span class="text-warning" aria-hidden="true">★</span> - {:else} - <span class="opacity-40" aria-hidden="true">☆</span> - {/if} - </button> - <span class="ml-auto text-xs opacity-50"> - {ws.conversationCount} - {ws.conversationCount === 1 ? "conversation" : "conversations"} - · {relativeTime(ws.lastActivityAt, Date.now())} - </span> - <button - type="button" - class="btn btn-ghost btn-xs" - disabled={deleting} - title="Delete workspace" - aria-label="Delete workspace" - onclick={handleDelete} - > - {#if deleting} - <span class="loading loading-spinner loading-xs"></span> - {:else} - ✕ - {/if} - </button> - </div> - - {#if titleError} - <p class="text-xs text-error">{titleError}</p> - {/if} - - {#if starError} - <p class="text-xs text-error">{starError}</p> - {/if} - - <div class="flex items-center gap-2"> - <span class="w-8 shrink-0 text-xs opacity-60">cwd</span> - <input - type="text" - class="input input-bordered input-sm flex-1 font-mono text-xs" - placeholder="inherits the server default" - bind:value={cwdDraft} - aria-label="Default working directory" - onkeydown={(e) => { - if (e.key === "Enter") saveCwd(); - }} - /> - <button - type="button" - class="btn btn-primary btn-xs" - disabled={!cwdDirty || savingCwd} - onclick={saveCwd} - > - {#if savingCwd} - <span class="loading loading-spinner loading-xs"></span> - {:else} - Set - {/if} - </button> - </div> - - <div class="flex items-center gap-2"> - <span class="w-8 shrink-0 text-xs opacity-60">ssh</span> - <ComputerSelect - value={ws.defaultComputerId} - {computers} - disabled={savingComputer} - onSelect={saveComputer} - /> - {#if savingComputer} - <span class="loading loading-spinner loading-xs shrink-0"></span> - {/if} - </div> - - <div class="flex justify-start"> - <a - class="btn" - href={workspacePath(ws.id)} - onclick={(e) => { - e.preventDefault(); - onNavigate(workspacePath(ws.id)); - }} - > - Open - </a> - </div> - - {#if cwdError} - <p class="text-xs text-error">{cwdError}</p> - {:else if !cwdDirty && !ws.defaultCwd} - <p class="text-xs opacity-50">No default cwd set — conversations inherit the server default.</p> - {/if} - - {#if computerError} - <p class="text-xs text-error">{computerError}</p> - {:else if !ws.defaultComputerId} - <p class="text-xs opacity-50">No default computer — conversations run locally (no SSH).</p> - {/if} -</li> diff --git a/src/features/workspaces/ui/WorkspaceCard.test.ts b/src/features/workspaces/ui/WorkspaceCard.test.ts deleted file mode 100644 index 72f4e60..0000000 --- a/src/features/workspaces/ui/WorkspaceCard.test.ts +++ /dev/null @@ -1,305 +0,0 @@ -import type { WorkspaceEntry } from "@dispatch/wire"; -import { render, screen } from "@testing-library/svelte"; -import userEvent from "@testing-library/user-event"; -import { describe, expect, it, vi } from "vitest"; -import type { WorkspaceResult } from "../adapter/http"; -import type { WorkspaceStore } from "../store.svelte"; -import WorkspaceCard from "./WorkspaceCard.svelte"; - -function fakeEntry(overrides: Partial<WorkspaceEntry> = {}): WorkspaceEntry { - return { - id: "my-ws", - title: "My Workspace", - defaultCwd: null, - defaultComputerId: null, - starred: false, - createdAt: 1, - lastActivityAt: 2, - conversationCount: 3, - ...overrides, - }; -} - -/** A fake store that records calls + resolves ok. */ -function fakeStore() { - return { - rename: vi.fn( - async (id: string, _title: string): Promise<WorkspaceResult<WorkspaceEntry>> => ({ - ok: true, - value: fakeEntry({ id, title: _title }), - }), - ), - setDefaultCwd: vi.fn( - async (id: string, defaultCwd: string | null): Promise<WorkspaceResult<WorkspaceEntry>> => ({ - ok: true, - value: fakeEntry({ id, defaultCwd }), - }), - ), - setDefaultComputer: vi.fn( - async (id: string, computerId: string | null): Promise<WorkspaceResult<WorkspaceEntry>> => ({ - ok: true, - value: fakeEntry({ id, defaultComputerId: computerId }), - }), - ), - setStarred: vi.fn( - async (id: string, starred: boolean): Promise<WorkspaceResult<WorkspaceEntry>> => ({ - ok: true, - value: fakeEntry({ id, starred }), - }), - ), - remove: vi.fn( - async (): Promise<WorkspaceResult<{ closedCount: number }>> => ({ - ok: true, - value: { closedCount: 0 }, - }), - ), - }; -} - -describe("WorkspaceCard", () => { - it("renders the title, slug, and an Open link", () => { - const store = fakeStore() as unknown as WorkspaceStore; - render(WorkspaceCard, { - props: { ws: fakeEntry(), store, onNavigate: vi.fn(), computers: [] }, - }); - expect(screen.getByText("My Workspace")).toBeInTheDocument(); - expect(screen.getByText("/my-ws")).toBeInTheDocument(); - expect(screen.getByRole("link", { name: "Open" })).toHaveAttribute("href", "/my-ws"); - }); - - it("double-clicking the title reveals an edit input", async () => { - const user = userEvent.setup(); - const store = fakeStore() as unknown as WorkspaceStore; - render(WorkspaceCard, { - props: { ws: fakeEntry(), store, onNavigate: vi.fn(), computers: [] }, - }); - - await user.dblClick(screen.getByText("My Workspace")); - expect(screen.getByLabelText("Workspace title")).toHaveValue("My Workspace"); - }); - - it("renames via the store on Enter", async () => { - const user = userEvent.setup(); - const store = fakeStore() as unknown as WorkspaceStore; - render(WorkspaceCard, { - props: { ws: fakeEntry(), store, onNavigate: vi.fn(), computers: [] }, - }); - - await user.dblClick(screen.getByText("My Workspace")); - const input = screen.getByLabelText("Workspace title"); - await user.clear(input); - await user.type(input, "Renamed{Enter}"); - - expect(store.rename).toHaveBeenCalledWith("my-ws", "Renamed"); - }); - - it("enables Set only when the cwd differs, then saves it", async () => { - const user = userEvent.setup(); - const store = fakeStore() as unknown as WorkspaceStore; - render(WorkspaceCard, { - props: { ws: fakeEntry({ defaultCwd: "/old" }), store, onNavigate: vi.fn(), computers: [] }, - }); - - const input = screen.getByLabelText("Default working directory"); - expect(input).toHaveValue("/old"); - expect(screen.getByRole("button", { name: "Set" })).toBeDisabled(); - - await user.clear(input); - await user.type(input, "/new/path"); - expect(screen.getByRole("button", { name: "Set" })).toBeEnabled(); - - await user.click(screen.getByRole("button", { name: "Set" })); - expect(store.setDefaultCwd).toHaveBeenCalledWith("my-ws", "/new/path"); - }); - - it("clears the cwd to null when saved empty (inherits the server default)", async () => { - const user = userEvent.setup(); - const store = fakeStore() as unknown as WorkspaceStore; - render(WorkspaceCard, { - props: { ws: fakeEntry({ defaultCwd: "/old" }), store, onNavigate: vi.fn(), computers: [] }, - }); - - const input = screen.getByLabelText("Default working directory"); - await user.clear(input); - await user.click(screen.getByRole("button", { name: "Set" })); - - expect(store.setDefaultCwd).toHaveBeenCalledWith("my-ws", null); - }); - - it("the Open link navigates to the workspace in the same tab (SPA navigation, no new tab)", async () => { - const user = userEvent.setup(); - const store = fakeStore() as unknown as WorkspaceStore; - const onNavigate = vi.fn(); - render(WorkspaceCard, { props: { ws: fakeEntry(), store, onNavigate, computers: [] } }); - - const open = screen.getByRole("link", { name: "Open" }); - // Still a real link (progressive enhancement): href points at the workspace. - expect(open).toHaveAttribute("href", "/my-ws"); - // But it no longer opens a new browser tab. - expect(open).not.toHaveAttribute("target", "_blank"); - // Clicking navigates in-place via the SPA callback. - await user.click(open); - expect(onNavigate).toHaveBeenCalledTimes(1); - expect(onNavigate).toHaveBeenCalledWith("/my-ws"); - }); - - // ── Active indicator (loading dots) ────────────────────────────────────── - - it("shows no loading-dots when no hasActive port is given", () => { - const store = fakeStore() as unknown as WorkspaceStore; - const { container } = render(WorkspaceCard, { - props: { ws: fakeEntry(), store, onNavigate: vi.fn(), computers: [] }, - }); - expect(container.querySelector(".loading-dots")).toBeNull(); - }); - - it("shows no loading-dots when hasActive returns false", () => { - const store = fakeStore() as unknown as WorkspaceStore; - const { container } = render(WorkspaceCard, { - props: { - ws: fakeEntry(), - store, - onNavigate: vi.fn(), - computers: [], - hasActive: () => false, - }, - }); - expect(container.querySelector(".loading-dots")).toBeNull(); - }); - - it("shows loading-dots when hasActive returns true", () => { - const store = fakeStore() as unknown as WorkspaceStore; - const { container } = render(WorkspaceCard, { - props: { - ws: fakeEntry(), - store, - onNavigate: vi.fn(), - computers: [], - hasActive: () => true, - }, - }); - const dots = container.querySelector(".loading-dots"); - expect(dots).not.toBeNull(); - // Accessible label ties the indicator to the workspace-active concept. - expect(dots?.getAttribute("aria-label")).toBe("Workspace has active conversations"); - }); - - it("forwards the workspace id to hasActive", () => { - const store = fakeStore() as unknown as WorkspaceStore; - const seen: string[] = []; - render(WorkspaceCard, { - props: { - ws: fakeEntry({ id: "proj-x" }), - store, - onNavigate: vi.fn(), - computers: [], - hasActive: (id: string) => { - seen.push(id); - return false; - }, - }, - }); - expect(seen).toEqual(["proj-x"]); - }); - - it("renders an outline star button for an unstarred workspace", () => { - const store = fakeStore() as unknown as WorkspaceStore; - render(WorkspaceCard, { - props: { ws: fakeEntry({ starred: false }), store, onNavigate: vi.fn(), computers: [] }, - }); - const star = screen.getByRole("button", { name: "Star workspace" }); - expect(star).toHaveAttribute("aria-pressed", "false"); - }); - - it("renders a filled star button for a starred workspace", () => { - const store = fakeStore() as unknown as WorkspaceStore; - render(WorkspaceCard, { - props: { ws: fakeEntry({ starred: true }), store, onNavigate: vi.fn(), computers: [] }, - }); - const star = screen.getByRole("button", { name: "Unstar workspace" }); - expect(star).toHaveAttribute("aria-pressed", "true"); - }); - - it("toggles the star via the store on click", async () => { - const user = userEvent.setup(); - const store = fakeStore() as unknown as WorkspaceStore; - render(WorkspaceCard, { - props: { ws: fakeEntry({ starred: false }), store, onNavigate: vi.fn(), computers: [] }, - }); - - await user.click(screen.getByRole("button", { name: "Star workspace" })); - expect(store.setStarred).toHaveBeenCalledWith("my-ws", true); - }); - - it("clicking a starred workspace's star calls setStarred(id, false)", async () => { - const user = userEvent.setup(); - const store = fakeStore() as unknown as WorkspaceStore; - render(WorkspaceCard, { - props: { ws: fakeEntry({ starred: true }), store, onNavigate: vi.fn(), computers: [] }, - }); - - await user.click(screen.getByRole("button", { name: "Unstar workspace" })); - expect(store.setStarred).toHaveBeenCalledWith("my-ws", false); - }); - - it("renders no star error on a successful toggle", async () => { - const user = userEvent.setup(); - const store = fakeStore() as unknown as WorkspaceStore; - render(WorkspaceCard, { - props: { ws: fakeEntry({ starred: false }), store, onNavigate: vi.fn(), computers: [] }, - }); - - await user.click(screen.getByRole("button", { name: "Star workspace" })); - expect(screen.queryByText(/Star toggle failed/i)).not.toBeInTheDocument(); - }); - - it("shows an inline error when setStarred fails (result.ok false)", async () => { - const user = userEvent.setup(); - const store = fakeStore() as unknown as WorkspaceStore; - // The store reverts the optimistic flip on failure, so the entry's - // `starred` stays false — fake the revert by returning ok:false unchanged. - store.setStarred = vi.fn( - async (): Promise<WorkspaceResult<WorkspaceEntry>> => ({ ok: false, error: "boom" }), - ); - render(WorkspaceCard, { - props: { ws: fakeEntry({ starred: false }), store, onNavigate: vi.fn(), computers: [] }, - }); - - await user.click(screen.getByRole("button", { name: "Star workspace" })); - - expect(screen.getByText("boom")).toBeInTheDocument(); - }); - - it("re-enables the star button after a failure (savingStar resets)", async () => { - const user = userEvent.setup(); - const store = fakeStore() as unknown as WorkspaceStore; - store.setStarred = vi.fn( - async (): Promise<WorkspaceResult<WorkspaceEntry>> => ({ ok: false, error: "boom" }), - ); - render(WorkspaceCard, { - props: { ws: fakeEntry({ starred: false }), store, onNavigate: vi.fn(), computers: [] }, - }); - - const star = screen.getByRole("button", { name: "Star workspace" }); - await user.click(star); - // After the failed toggle, the button is NOT disabled (savingStar reset). - expect(star).not.toBeDisabled(); - }); - - it("re-enables the star button even when setStarred throws", async () => { - const user = userEvent.setup(); - const store = fakeStore() as unknown as WorkspaceStore; - store.setStarred = vi.fn(async (): Promise<WorkspaceResult<WorkspaceEntry>> => { - throw new Error("network"); - }); - render(WorkspaceCard, { - props: { ws: fakeEntry({ starred: false }), store, onNavigate: vi.fn(), computers: [] }, - }); - - const star = screen.getByRole("button", { name: "Star workspace" }); - await user.click(star); - // savingStar must reset via try/finally even on a throw. - expect(star).not.toBeDisabled(); - expect(screen.getByText("network")).toBeInTheDocument(); - }); -}); diff --git a/src/features/workspaces/ui/WorkspaceDetails.svelte b/src/features/workspaces/ui/WorkspaceDetails.svelte new file mode 100644 index 0000000..6595725 --- /dev/null +++ b/src/features/workspaces/ui/WorkspaceDetails.svelte @@ -0,0 +1,132 @@ +<script lang="ts"> + import { untrack } from "svelte"; + import type { ComputerEntry, WorkspaceEntry } from "@dispatch/wire"; + import type { WorkspaceStore } from "../store.svelte"; + import ComputerSelect from "../../computer/ui/ComputerSelect.svelte"; + + let { + ws, + store, + computers, + onClose, + }: { + ws: WorkspaceEntry; + store: WorkspaceStore; + /** Discovered computers (`GET /computers`), for the default-computer dropdown. */ + computers: readonly ComputerEntry[]; + onClose: () => void; + } = $props(); + + // ── Default cwd: input + Set (semantics preserved from the WorkspaceCard) ── + let cwdDraft = $state(untrack(() => ws.defaultCwd ?? "")); + // Reseed when the backend value changes (e.g., after a save or an external + // refresh). Mid-edit (same value) does NOT re-run, so typing is never clobbered. + $effect(() => { + cwdDraft = ws.defaultCwd ?? ""; + }); + + const cwdDirty = $derived(cwdDraft.trim() !== (ws.defaultCwd ?? "")); + let savingCwd = $state(false); + let cwdError = $state<string | null>(null); + + async function saveCwd(): Promise<void> { + if (!cwdDirty || savingCwd) return; + savingCwd = true; + cwdError = null; + const cwd = cwdDraft.trim(); + const result = await store.setDefaultCwd(ws.id, cwd === "" ? null : cwd); + savingCwd = false; + if (!result.ok) cwdError = result.error; + } + + // ── Default computer: dropdown (Local / discovered SSH aliases) ──────────── + let savingComputer = $state(false); + let computerError = $state<string | null>(null); + + async function saveComputer(computerId: string | null): Promise<void> { + if (savingComputer) return; + // No-op when unchanged (the select only fires on a real change, but guard). + if (computerId === (ws.defaultComputerId ?? null)) return; + savingComputer = true; + computerError = null; + const result = await store.setDefaultComputer(ws.id, computerId); + savingComputer = false; + if (!result.ok) computerError = result.error; + } +</script> + +<div class="modal modal-open" role="dialog" aria-label={`Workspace details: ${ws.title}`}> + <div class="modal-box"> + <div class="flex items-start justify-between gap-2"> + <div> + <h2 class="text-lg font-bold">{ws.title}</h2> + <p class="font-mono text-xs opacity-50">/{ws.id}</p> + </div> + <button + type="button" + class="btn btn-circle btn-ghost btn-sm" + aria-label="Close details" + onclick={onClose}>✕</button + > + </div> + + <div class="mt-4 flex flex-col gap-3"> + <div class="flex items-center gap-2"> + <span class="w-8 shrink-0 text-xs opacity-60">cwd</span> + <input + type="text" + class="input input-bordered input-sm flex-1 font-mono text-xs" + placeholder="inherits the server default" + bind:value={cwdDraft} + aria-label="Default working directory" + onkeydown={(e) => { + if (e.key === "Enter") void saveCwd(); + }} + /> + <button + type="button" + class="btn btn-primary btn-xs" + disabled={!cwdDirty || savingCwd} + onclick={() => void saveCwd()} + > + {#if savingCwd} + <span class="loading loading-spinner loading-xs"></span> + {:else} + Set + {/if} + </button> + </div> + + {#if cwdError} + <p class="text-xs text-error">{cwdError}</p> + {:else if !cwdDirty && !ws.defaultCwd} + <p class="text-xs opacity-50">No default cwd set — conversations inherit the server default.</p> + {/if} + + <div class="flex items-center gap-2"> + <span class="w-8 shrink-0 text-xs opacity-60">ssh</span> + <ComputerSelect + value={ws.defaultComputerId} + {computers} + disabled={savingComputer} + onSelect={(id) => void saveComputer(id)} + /> + {#if savingComputer} + <span class="loading loading-spinner loading-xs shrink-0"></span> + {/if} + </div> + + {#if computerError} + <p class="text-xs text-error">{computerError}</p> + {:else if !ws.defaultComputerId} + <p class="text-xs opacity-50">No default computer — conversations run locally (no SSH).</p> + {/if} + </div> + </div> + <!-- Click outside closes. --> + <button + type="button" + class="modal-backdrop" + aria-label="Close details backdrop" + onclick={onClose}></button> +</div> diff --git a/src/features/workspaces/ui/WorkspaceDetails.test.ts b/src/features/workspaces/ui/WorkspaceDetails.test.ts new file mode 100644 index 0000000..9138742 --- /dev/null +++ b/src/features/workspaces/ui/WorkspaceDetails.test.ts @@ -0,0 +1,115 @@ +import type { ComputerEntry, WorkspaceEntry } from "@dispatch/wire"; +import { render, screen } from "@testing-library/svelte"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import type { WorkspaceResult } from "../adapter/http"; +import type { WorkspaceStore } from "../store.svelte"; +import WorkspaceDetails from "./WorkspaceDetails.svelte"; + +function fakeEntry(overrides: Partial<WorkspaceEntry> = {}): WorkspaceEntry { + return { + id: "my-ws", + title: "My Workspace", + defaultCwd: null, + defaultComputerId: null, + starred: false, + archived: false, + createdAt: 1, + lastActivityAt: 2, + conversationCount: 0, + ...overrides, + }; +} + +function fakeStore(): WorkspaceStore { + return { + setDefaultCwd: vi.fn( + async (id: string, defaultCwd: string | null): Promise<WorkspaceResult<WorkspaceEntry>> => ({ + ok: true, + value: fakeEntry({ id, defaultCwd }), + }), + ), + setDefaultComputer: vi.fn( + async ( + id: string, + defaultComputerId: string | null, + ): Promise<WorkspaceResult<WorkspaceEntry>> => ({ + ok: true, + value: fakeEntry({ id, defaultComputerId }), + }), + ), + } as unknown as WorkspaceStore; +} + +describe("WorkspaceDetails", () => { + it("renders the title, slug, cwd input, and computer select", () => { + render(WorkspaceDetails, { + props: { ws: fakeEntry(), store: fakeStore(), computers: [], onClose: () => {} }, + }); + expect(screen.getByText("My Workspace")).toBeInTheDocument(); + expect(screen.getByText("/my-ws")).toBeInTheDocument(); + expect(screen.getByLabelText("Default working directory")).toBeInTheDocument(); + expect(screen.getByLabelText("Computer")).toBeInTheDocument(); + }); + + it("saves the cwd via the store; an empty value clears to null", async () => { + const user = userEvent.setup(); + const store = fakeStore(); + const onClose = vi.fn(); + render(WorkspaceDetails, { + props: { ws: fakeEntry({ defaultCwd: "/old" }), store, computers: [], onClose }, + }); + + const input = screen.getByLabelText("Default working directory"); + await user.clear(input); + await user.click(screen.getByRole("button", { name: "Set" })); + expect(store.setDefaultCwd).toHaveBeenCalledWith("my-ws", null); + }); + + it("a failed cwd save surfaces the error", async () => { + const user = userEvent.setup(); + const store = fakeStore(); + store.setDefaultCwd = vi.fn( + async (): Promise<WorkspaceResult<WorkspaceEntry>> => ({ ok: false, error: "nope" }), + ); + render(WorkspaceDetails, { + props: { ws: fakeEntry({ defaultCwd: "/old" }), store, computers: [], onClose: () => {} }, + }); + + await user.type(screen.getByLabelText("Default working directory"), "!{Enter}"); + expect(await screen.findByText("nope")).toBeInTheDocument(); + }); + + it("picking a computer saves it via the store", async () => { + const user = userEvent.setup(); + const store = fakeStore(); + const computers: ComputerEntry[] = [ + { + alias: "lab", + hostName: "lab.example", + port: 22, + user: "me", + identityFile: null, + knownHost: true, + usageCount: 0, + }, + ]; + render(WorkspaceDetails, { + props: { ws: fakeEntry(), store, computers, onClose: () => {} }, + }); + + await user.selectOptions(screen.getByLabelText("Computer"), "lab"); + expect(store.setDefaultComputer).toHaveBeenCalledWith("my-ws", "lab"); + }); + + it("the close button dismisses the dialog", async () => { + const user = userEvent.setup(); + const onClose = vi.fn(); + render(WorkspaceDetails, { + props: { ws: fakeEntry(), store: fakeStore(), computers: [], onClose }, + }); + + await user.click(screen.getByRole("button", { name: "Close details" })); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/features/workspaces/ui/WorkspaceTile.svelte b/src/features/workspaces/ui/WorkspaceTile.svelte new file mode 100644 index 0000000..b101192 --- /dev/null +++ b/src/features/workspaces/ui/WorkspaceTile.svelte @@ -0,0 +1,146 @@ +<script lang="ts"> + import type { WorkspaceEntry } from "@dispatch/wire"; + import type { WorkspaceStore } from "../store.svelte"; + import { relativeTime } from "../logic/view-model"; + import type { Placement } from "../logic/tile-layout"; + + let { + ws, + store, + placement, + selected = false, + renaming = false, + active = false, + onRenameEnd, + }: { + ws: WorkspaceEntry; + store: WorkspaceStore; + /** The tile's resolved footprint (already preview-adjusted by the grid). */ + placement: Placement; + selected?: boolean; + /** The parent-driven rename mode (the action bar's Rename action). */ + renaming?: boolean; + /** At least one conversation in this workspace is generating/queued. */ + active?: boolean; + /** The tile finished its rename session (saved or cancelled). */ + onRenameEnd: () => void; + } = $props(); + + // ── Title: double-click to rename inline (semantics preserved from the + // WorkspaceCard), or enter via the action bar's Rename action (`renaming`). + let editing = $state(false); + let titleDraft = $state(""); + let titleInput = $state<HTMLInputElement | undefined>(); + let titleError = $state<string | null>(null); + + function startEdit(): void { + titleDraft = ws.title; + titleError = null; + editing = true; + queueMicrotask(() => titleInput?.focus()); + } + + // Seed (only) when the parent-driven rename mode turns on. + $effect(() => { + if (renaming) startEdit(); + }); + + async function saveTitle(): Promise<void> { + if (!editing) return; + const title = titleDraft.trim(); + editing = false; + onRenameEnd(); + if (title === "" || title === ws.title) return; + const result = await store.rename(ws.id, title); + if (!result.ok) titleError = result.error; + } + + function cancelTitle(): void { + editing = false; + titleError = null; + onRenameEnd(); + } + + // The face's semantic key (tests read it). + const faceKey = $derived(`tile-${placement.id}`); + + // Span-aware face: bigger footprints get more air + bigger type (all styles + // static so Tailwind sees them). + const wide = $derived(placement.colSpan > 1); + const tall = $derived(placement.rowSpan > 1); + const facePadding = $derived(wide || tall ? "p-5" : "p-4"); + const titleClass = $derived( + wide && tall + ? "text-3xl leading-tight line-clamp-3" + : wide + ? "text-2xl leading-snug line-clamp-2" + : tall + ? "text-xl leading-snug line-clamp-4" + : "text-lg leading-snug line-clamp-3", + ); + /** 1×1 tiles are tight: "20 conv." — wider tiles spell "conversations" out. */ + const countLabel = $derived( + ws.conversationCount === 1 ? (wide ? "conversation" : "conv.") : wide ? "conversations" : "conv.", + ); +</script> + +<div data-tile-face={faceKey} class={`relative flex h-full w-full flex-col ${facePadding}`}> + <!-- The slug label at the very top — the Win8 tile's small "app" caption. --> + <div class="flex items-center gap-1.5 text-[11px] font-semibold tracking-wider uppercase"> + <span class="truncate opacity-70">/{ws.id}</span> + {#if active} + <span + class="loading loading-dots loading-xs shrink-0" + aria-label="Workspace has active conversations" + title="A conversation in this workspace is generating"></span> + {/if} + </div> + + {#if ws.starred} + <!-- Starred is a badge, not a tint — the tone stays the tile's identity. --> + <span + class="absolute top-2.5 right-3 text-lg leading-none opacity-90" + aria-label="Starred workspace" + title="Starred — priority in the agent queue">★</span + > + {/if} + + {#if editing} + <input + bind:this={titleInput} + bind:value={titleDraft} + class="input input-sm mt-2 w-full border-current/40 bg-black/25 text-inherit placeholder:text-current/50" + aria-label="Workspace title" + onkeydown={(e) => { + if (e.key === "Enter") void saveTitle(); + else if (e.key === "Escape") cancelTitle(); + }} + onblur={() => void saveTitle()} + /> + {:else} + <!-- svelte-ignore a11y_no_static_element_interactions, a11y_click_events_have_key_events --> + <span + class={`mt-1 cursor-default font-bold ${titleClass}`} + title="Double-click to rename" + ondblclick={startEdit}>{ws.title}</span + > + {/if} + + {#if titleError} + <p class="mt-1 text-xs font-semibold">{titleError}</p> + {/if} + + <!-- The stat line pinned to the tile's bottom edge, like a live tile's + status row. Single-line, truncation-safe (narrow 1×1 tiles stay clean). --> + <div + class="mt-auto flex items-center gap-2 overflow-hidden border-t border-current/20 pt-2 text-xs font-medium whitespace-nowrap" + > + <span class="min-w-0 truncate"> + {ws.conversationCount} + {countLabel} + </span> + <span class="ml-auto shrink-0 font-mono opacity-70"> + {relativeTime(ws.lastActivityAt, Date.now())} + </span> + </div> +</div> diff --git a/src/features/workspaces/ui/WorkspaceTile.test.ts b/src/features/workspaces/ui/WorkspaceTile.test.ts new file mode 100644 index 0000000..420791a --- /dev/null +++ b/src/features/workspaces/ui/WorkspaceTile.test.ts @@ -0,0 +1,171 @@ +import type { WorkspaceEntry } from "@dispatch/wire"; +import { render, screen } from "@testing-library/svelte"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import type { WorkspaceResult } from "../adapter/http"; +import type { Placement } from "../logic/tile-layout"; +import type { WorkspaceStore } from "../store.svelte"; +import WorkspaceTile from "./WorkspaceTile.svelte"; + +function fakeEntry(overrides: Partial<WorkspaceEntry> = {}): WorkspaceEntry { + return { + id: "my-ws", + title: "My Workspace", + defaultCwd: null, + defaultComputerId: null, + starred: false, + archived: false, + createdAt: 1, + lastActivityAt: Date.now() - 90_000, // "1m" relative time + conversationCount: 3, + ...overrides, + }; +} + +function fakeStore(): WorkspaceStore { + return { + rename: vi.fn( + async (id: string, title: string): Promise<WorkspaceResult<WorkspaceEntry>> => ({ + ok: true, + value: fakeEntry({ id, title }), + }), + ), + } as unknown as WorkspaceStore; +} + +function fakePlacement(overrides: Partial<Placement> = {}): Placement { + return { id: "my-ws", col: 1, row: 1, colSpan: 1, rowSpan: 1, ...overrides }; +} + +describe("WorkspaceTile", () => { + it("renders the title, slug, conversation count, and relative last-activity", () => { + render(WorkspaceTile, { + props: { + ws: fakeEntry(), + store: fakeStore(), + placement: fakePlacement(), + onRenameEnd: () => {}, + }, + }); + expect(screen.getByText("My Workspace")).toBeInTheDocument(); + expect(screen.getByText("/my-ws")).toBeInTheDocument(); + // A 1×1 tile is tight: the count compacts to "conv." (wide tiles spell it out). + expect(screen.getByText(/3 conv\./)).toBeInTheDocument(); + expect(screen.getByText("1m")).toBeInTheDocument(); + }); + + it("spells out 'conversations' on a wide tile", () => { + render(WorkspaceTile, { + props: { + ws: fakeEntry(), + store: fakeStore(), + placement: fakePlacement({ colSpan: 2, rowSpan: 1 }), + onRenameEnd: () => {}, + }, + }); + expect(screen.getByText(/3 conversations/)).toBeInTheDocument(); + }); + + it("shows the active indicator only when the active port says so", async () => { + const { container, rerender } = render(WorkspaceTile, { + props: { + ws: fakeEntry(), + store: fakeStore(), + placement: fakePlacement(), + active: true, + onRenameEnd: () => {}, + }, + }); + expect( + container.querySelector('[aria-label="Workspace has active conversations"]'), + ).not.toBeNull(); + + await rerender({ + ws: fakeEntry(), + store: fakeStore(), + placement: fakePlacement(), + active: false, + onRenameEnd: () => {}, + }); + expect(container.querySelector('[aria-label="Workspace has active conversations"]')).toBeNull(); + }); + + it("double-clicking the title reveals the rename input (semantics preserved)", async () => { + const user = userEvent.setup(); + const store = fakeStore(); + render(WorkspaceTile, { + props: { + ws: fakeEntry(), + store, + placement: fakePlacement(), + onRenameEnd: () => {}, + }, + }); + + await user.dblClick(screen.getByText("My Workspace")); + expect(screen.getByLabelText("Workspace title")).toHaveValue("My Workspace"); + }); + + it("the parent-driven rename mode (action bar) opens the same inline editor", async () => { + const user = userEvent.setup(); + const store = fakeStore(); + render(WorkspaceTile, { + props: { + ws: fakeEntry(), + store, + placement: fakePlacement(), + renaming: true, + onRenameEnd: () => {}, + }, + }); + const input = screen.getByLabelText("Workspace title"); + expect(input).toHaveValue("My Workspace"); + + await user.clear(input); + await user.type(input, "Renamed{Enter}"); + expect(store.rename).toHaveBeenCalledWith("my-ws", "Renamed"); + }); + + it("a failed rename surfaces the error on the face", async () => { + const user = userEvent.setup(); + const store = { + rename: vi.fn( + async (): Promise<WorkspaceResult<WorkspaceEntry>> => ({ + ok: false, + error: "boom", + }), + ), + } as unknown as WorkspaceStore; + render(WorkspaceTile, { + props: { + ws: fakeEntry(), + store, + placement: fakePlacement(), + renaming: true, + onRenameEnd: () => {}, + }, + }); + + await user.type(screen.getByLabelText("Workspace title"), "x{Enter}"); + expect(screen.getByText("boom")).toBeInTheDocument(); + }); + + it("an unchanged title does not hit the store and still ends the session", async () => { + const user = userEvent.setup(); + const store = fakeStore(); + const onRenameEnd = vi.fn(); + render(WorkspaceTile, { + props: { + ws: fakeEntry(), + store, + placement: fakePlacement(), + renaming: true, + onRenameEnd, + }, + }); + + await user.type(screen.getByLabelText("Workspace title"), "{Enter}"); + expect(store.rename).not.toHaveBeenCalled(); + expect(onRenameEnd).toHaveBeenCalled(); + }); +}); diff --git a/src/features/workspaces/ui/WorkspacesHome.svelte b/src/features/workspaces/ui/WorkspacesHome.svelte index d97eab7..196e831 100644 --- a/src/features/workspaces/ui/WorkspacesHome.svelte +++ b/src/features/workspaces/ui/WorkspacesHome.svelte @@ -1,110 +1,363 @@ <script lang="ts"> - import { onMount } from "svelte"; + import { onMount, untrack } from "svelte"; import type { ComputerEntry } from "@dispatch/wire"; + import { + PluginManager, + WorkspacePluginModal, + type PluginsStore, + type WorkspaceLayoutPort, + } from "../../plugins"; import type { WorkspaceStore } from "../store.svelte"; - import { isValidSlug, workspacePath } from "../logic/route"; - import WorkspaceCard from "./WorkspaceCard.svelte"; + import { workspacePath } from "../logic/route"; + import { + DEFAULT_VIEWPORT, + geometryFor, + mergeLayout, + pack, + type GridGeometry, + type Span, + } from "../logic/tile-layout"; + import { type TileLayoutStore } from "../tile-layout-store.svelte"; + import HomeHeader from "./HomeHeader.svelte"; + import HomeTileGrid from "./HomeTileGrid.svelte"; + import TileActionBar from "./TileActionBar.svelte"; + import ResizePicker from "./ResizePicker.svelte"; + import WorkspaceDetails from "./WorkspaceDetails.svelte"; + import ArchivedWorkspaces from "./ArchivedWorkspaces.svelte"; let { store, onNavigate, computers, hasActive, + plugins, + layout, + tiles: tilesOverride, }: { store: WorkspaceStore; onNavigate: (path: string) => void; computers: readonly ComputerEntry[]; /** - * Optional port forwarded to each {@link WorkspaceCard}: whether the - * workspace has at least one active (generating / queued) conversation. + * Optional port: whether the workspace has at least one active + * (generating / queued) conversation — the tile's live indicator. * Wired by the composition root to the app store. Absent → no indicator. */ hasActive?: (workspaceId: string) => boolean; + /** + * The plugin manager store (plan 07): drives the header's global + * "Plugins" button + the action bar's per-workspace Plugins action. + * Optional (absent in tests that don't exercise plugins). + */ + plugins?: PluginsStore; + /** + * The layout port (plan 04 §4.4): forwarded to the per-workspace plugins + * modal's "Reset layout to default" affordance. Absent → the modal's + * layout section doesn't render. + */ + layout?: WorkspaceLayoutPort; + /** + * The synced tile layout store (ONE shared document per device + * orientation, persisted by the backend's home-tile-layout plugin). + * Created by the page entry (App.svelte — the composition root that owns + * the httpBase); tests inject one. + */ + tiles: TileLayoutStore; } = $props(); onMount(() => { void store.refresh(); }); - let newSlug = $state(""); - let slugError = $state<string | null>(null); + // The store arrives fully wired (the page entry created it over the real + // HTTP edge). The `untrack` capture (the repo's initial-value pattern, cf. + // App.svelte) dealiases the prop once — the component instance is the + // store's single owner from here on. + const tiles: TileLayoutStore = untrack(() => tilesOverride); + + // Keep the layout merged with the live workspace list: unknown workspaces + // (deep-link auto-creates) append at the end; deleted ones drop. The + // no-op guards inside sync keep this effect from looping. Skipped until + // the first non-empty list arrives — `sync([])` before the initial + // `refresh()` resolves would WIPE the persisted order (the backend always + // serves "default", so an empty list only ever means "not loaded yet"). + $effect(() => { + const ids = store.list.map((ws) => ws.id); + if (ids.length === 0) return; + tiles.sync(ids); + }); + + // ── The tile canvas' size decides the geometry (ONE pure function — no + // breakpoint classes). Start from the viewport, matching the boot probe; + // the canvas measurement then refines it (no transient wide reset on phones). + const initialViewport = typeof window === "undefined" + ? DEFAULT_VIEWPORT + : { width: window.innerWidth, height: window.innerHeight }; + let regionEl = $state<HTMLDivElement | undefined>(); + let size = $state<{ width: number; height: number } | undefined>(); + const geometry: GridGeometry = $derived( + geometryFor(size?.width ?? initialViewport.width, size?.height ?? initialViewport.height), + ); + + $effect(() => { + const el = regionEl; + if (!el || typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver((entries) => { + const rect = entries[0]?.contentRect; + if (rect) size = { width: rect.width, height: rect.height }; + }); + observer.observe(el); + return () => observer.disconnect(); + }); + + // Lock the store's active orientation to the resolved grid geometry: the + // landscape/portrait split IS the device-type split — every landscape + // device shares the `wide` document, every portrait device the `tall` one. + // Same-mode calls are a no-op inside the store; an orientation change + // resets + re-probes (never flashing the other orientation's arrangement). + $effect(() => { + tiles.setMode(geometry.mode); + }); + + // The grid view: the SAME persisted data packs into either geometry — + // positions re-derive here, spans clamp for the RENDER only. + // Render-only fallback: workspace discovery must not wait for layout GET. + // Never sync/commit this provisional arrangement while the probe is unknown. + const entries = $derived(tiles.entries.length > 0 + ? tiles.entries + : mergeLayout(null, store.list.map((ws) => ws.id))); + const placements = $derived(pack(entries, geometry)); + const workspacesById = $derived(new Map(store.list.map((ws) => [ws.id, ws]))); + + // ── Selection + action bar ───────────────────────────────────────────────── + let selectedId = $state<string | null>(null); + const selected = $derived(selectedId !== null ? (workspacesById.get(selectedId) ?? null) : null); + + function select(id: string): void { + selectedId = id; + } + + function deselect(): void { + selectedId = null; + } + + // ── Rename mode (the create flow + the action bar's Rename action) ──────── + let renamingId = $state<string | null>(null); + + // ── Resize picker (transient ghost preview; commit persists) ────────────── + let resizing = $state<{ id: string; span: Span | null } | null>(null); + const resizingEntry = $derived( + resizing ? (tiles.entries.find((tile) => tile.id === resizing?.id) ?? null) : null, + ); + + function startResize(): void { + if (!selected) return; + const entry = tiles.entries.find((tile) => tile.id === selected.id); + resizing = { + id: selected.id, + span: entry + ? { colSpan: entry.colSpan, rowSpan: entry.rowSpan } + : { colSpan: 1, rowSpan: 1 }, + }; + } + + function commitResize(colSpan: number, rowSpan: number): void { + if (!resizing) return; + tiles.resize(resizing.id, colSpan, rowSpan); + resizing = null; + } + + function cancelResize(): void { + resizing = null; + } + + // ── Details flyout (the dense per-card settings). Id-based + gated on the + // selection, so deselecting (Escape) also dismisses it. ───────────────────── + let detailsForId = $state<string | null>(null); + const detailsFor = $derived( + selectedId !== null && detailsForId === selectedId + ? (workspacesById.get(selectedId) ?? null) + : null, + ); - const slugValid = $derived(newSlug.length > 0 && isValidSlug(newSlug)); + // ── Per-workspace plugin modal ───────────────────────────────────────────── + let pluginsModalId = $state<string | null>(null); + const pluginsModalFor = $derived( + pluginsModalId !== null ? (workspacesById.get(pluginsModalId) ?? null) : null, + ); - function createWorkspace(): void { - const slug = newSlug.trim(); - if (!isValidSlug(slug)) { - slugError = "Lowercase letters, digits, and hyphens (1–40 chars)."; + // Global plugin manager modal (plan 07 §7.2): the ONLY surface that + // loads/unloads code (strict layering). + let managerOpen = $state(false); + + // ── Archived workspaces modal ────────────────────────────────────────────── + let archivedOpen = $state(false); + + // ── Actions (the action bar) ─────────────────────────────────────────────── + function openWorkspace(id: string): void { + deselect(); + onNavigate(workspacePath(id)); + } + + async function deleteSelected(): Promise<void> { + if (!selected) return; + const ws = selected; + if ( + !window.confirm( + `Delete workspace "${ws.title}"? Its conversations will be closed and moved to "default".`, + ) + ) { return; } - slugError = null; - newSlug = ""; - onNavigate(workspacePath(slug)); + const result = await store.remove(ws.id); + if (result.ok) deselect(); + } + + async function toggleStar(): Promise<void> { + if (!selected) return; + await store.setStarred(selected.id, !selected.starred); + } + + async function archiveSelected(): Promise<void> { + if (!selected) return; + const ws = selected; + // Reversible without a confirm dialog — the workspace is hidden, not deleted. + const result = await store.setArchived(ws.id, true); + if (result.ok) deselect(); + } + + // ── Create (header popover): check the slug resolves to no archived + // workspace, create on the backend — do NOT navigate — place the new tile in + // the first empty slot, and go straight into rename. + async function createWorkspace( + slug: string, + ): Promise<{ ok: true } | { ok: false; error: string }> { + const result = await store.create(slug); + if (!result.ok) return { ok: false, error: result.error }; + await store.refresh(); + // Idempotent: if the list refresh's sync already appended the id, this + // MOVES it into the first empty slot instead of duplicating. + tiles.placeFirstEmpty(slug, geometry); + renamingId = slug; + return { ok: true }; + } + + // ── Sort overflow: re-apply the recent-activity order ────────────────────── + function sortByRecent(): void { + const activity = new Map(store.list.map((ws) => [ws.id, ws.lastActivityAt])); + tiles.sortByRecentActivity(activity); + } + + // Escape unwinds the overlays: resize → selection. + function handleKeydown(e: KeyboardEvent): void { + if (e.key !== "Escape") return; + if (resizing) { + cancelResize(); + } else if (selectedId !== null) { + deselect(); + } } </script> -<div class="mx-auto flex h-screen w-full max-w-3xl flex-col gap-4 p-6"> - <header class="flex items-center justify-between"> - <h1 class="text-2xl font-bold">Workspaces</h1> - <a - href="/default" - class="btn btn-ghost btn-sm" - onclick={(e) => { - e.preventDefault(); - onNavigate("/default"); - }} - > - Default - </a> - </header> - - <form - class="flex items-end gap-2" - onsubmit={(e) => { - e.preventDefault(); - createWorkspace(); - }} - > - <div class="flex-1"> - <label for="new-ws" class="mb-1 block text-xs font-semibold uppercase opacity-60" - >New workspace</label - > - <input - id="new-ws" - class="input input-bordered w-full" - placeholder="my-workspace" - bind:value={newSlug} - autocomplete="off" - spellcheck="false" - /> - </div> - <button type="submit" class="btn btn-primary btn-sm" disabled={!slugValid}>Create</button> - </form> - {#if slugError} - <p class="text-xs text-error">{slugError}</p> - {/if} - - <div class="flex-1 overflow-y-auto"> +<svelte:window onkeydown={handleKeydown} /> + +<div class="h-app flex w-full flex-col"> + <HomeHeader + compact={geometry.mode === "tall"} + withPlugins={plugins !== undefined} + withSort={store.list.length > 1} + onCreate={createWorkspace} + onOpenPlugins={() => (managerOpen = true)} + onSort={() => void sortByRecent()} + onOpenArchived={() => (archivedOpen = true)} + /> + + <div bind:this={regionEl} class="min-h-0 w-full flex-1"> {#if store.loading && store.list.length === 0} - <div class="flex h-32 items-center justify-center"> - <span class="loading loading-spinner loading-sm opacity-60"></span> + <div class="flex h-full items-center justify-center"> + <span class="loading loading-spinner loading-lg opacity-60"></span> </div> {:else if store.list.length === 0} - <p class="py-8 text-center text-sm opacity-60"> - No workspaces yet. Create one above or visit <code>/your-name</code> in the URL. - </p> + <div class="flex h-full flex-col items-center justify-center gap-3 px-6 text-center"> + <p class="text-2xl font-black tracking-tight opacity-80">No workspaces yet</p> + <p class="max-w-md text-sm opacity-60"> + Create one with <strong>New workspace</strong> above, or visit + <code class="rounded bg-base-200 px-1.5 py-0.5">/your-name</code> in the URL — the first + tile drops onto the canvas ready to rename. + </p> + </div> {:else} - <ul class="flex flex-col gap-2"> - {#each store.list as ws (ws.id)} - <WorkspaceCard - {ws} - {store} - {onNavigate} - {computers} - {...(hasActive ? { hasActive } : {})} - /> - {/each} - </ul> + <HomeTileGrid + {geometry} + {placements} + {workspacesById} + {store} + {selectedId} + {renamingId} + preview={ + resizing?.id !== undefined && resizing.span !== null + ? { id: resizing.id, ...resizing.span } + : null + } + {...(hasActive ? { hasActive } : {})} + onOpen={openWorkspace} + onSelect={select} + onPlace={(id, cell) => cell !== null && tiles.placeAtCell(id, cell, geometry)} + onRenameEnd={() => (renamingId = null)} + /> {/if} </div> </div> + +{#if selected && !renamingId} + <TileActionBar + ws={selected} + withPlugins={plugins !== undefined} + onOpen={() => openWorkspace(selected.id)} + onRename={() => (renamingId = selected.id)} + onResize={startResize} + onToggleStar={() => void toggleStar()} + onDetails={() => (detailsForId = selected.id)} + onPlugins={() => (pluginsModalId = selected.id)} + onArchive={() => void archiveSelected()} + onDelete={() => void deleteSelected()} + onClose={deselect} + /> +{/if} + +{#if resizing && resizingEntry} + <ResizePicker + {geometry} + initial={{ colSpan: resizingEntry.colSpan, rowSpan: resizingEntry.rowSpan }} + onPreview={(span) => { + if (resizing) resizing = { id: resizing.id, span }; + }} + onCommit={commitResize} + onCancel={cancelResize} + /> +{/if} + +{#if detailsFor} + <WorkspaceDetails ws={detailsFor} {store} {computers} onClose={() => (detailsForId = null)} /> +{/if} + +{#if pluginsModalFor && plugins} + <WorkspacePluginModal + workspaceId={pluginsModalFor.id} + workspaceTitle={pluginsModalFor.title} + {plugins} + {...(layout ? { layout } : {})} + onClose={() => (pluginsModalId = null)} + /> +{/if} + +{#if managerOpen && plugins} + <PluginManager {plugins} onClose={() => (managerOpen = false)} /> +{/if} + +{#if archivedOpen} + <ArchivedWorkspaces + {store} + onOpen={(id) => openWorkspace(id)} + onClose={() => (archivedOpen = false)} + /> +{/if} diff --git a/src/features/workspaces/ui/WorkspacesHome.test.ts b/src/features/workspaces/ui/WorkspacesHome.test.ts new file mode 100644 index 0000000..5a66a42 --- /dev/null +++ b/src/features/workspaces/ui/WorkspacesHome.test.ts @@ -0,0 +1,333 @@ +import type { TileGeometryMode, TileLayoutData } from "@dispatch/tile-layout-contract"; +import type { Workspace, WorkspaceEntry } from "@dispatch/wire"; +import { render, screen, waitFor } from "@testing-library/svelte"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import type { WorkspaceHttp, WorkspaceResult } from "../adapter/http"; +import type { TileLayoutHttp, TileLayoutHttpResult } from "../adapter/tile-layout-http"; +import type { WorkspaceLookup } from "../logic/create"; +import { createTileLayoutStore } from "../tile-layout-store.svelte"; +import WorkspacesHome from "./WorkspacesHome.svelte"; + +function ws(overrides: Partial<WorkspaceEntry> = {}): WorkspaceEntry { + return { + id: "a", + title: "A", + defaultCwd: null, + defaultComputerId: null, + starred: false, + archived: false, + createdAt: 1, + lastActivityAt: 2, + conversationCount: 1, + ...overrides, + }; +} + +/** A fake WorkspaceHttp edge with a controllable list (no mocks of our modules). */ +function fakeHttp( + list: WorkspaceEntry[] = [ws()], + lookup: (id: string) => Promise<WorkspaceLookup> = async () => ({ kind: "missing" }), +): WorkspaceHttp { + return { + list: vi.fn(async (): Promise<WorkspaceEntry[]> => list), + // Create-on-miss: the backend persists the new workspace, so the next + // `list` serves it too. + ensure: vi.fn(async (id: string): Promise<WorkspaceResult<Workspace>> => { + if (!list.some((w) => w.id === id)) list.push(ws({ id, title: id })); + return { ok: true, value: ws({ id, title: id }) }; + }), + get: vi.fn(lookup), + setTitle: vi.fn(), + setDefaultCwd: vi.fn(), + setDefaultComputer: vi.fn(), + star: vi.fn(), + unstar: vi.fn(), + delete: vi.fn(async (): Promise<WorkspaceResult<{ closedCount: number }>> => { + // Mirrors the backend: the workspace leaves the list. + list.splice(0); + return { ok: true, value: { closedCount: 0 } }; + }), + } as unknown as WorkspaceHttp; +} + +/** + * A fake tile-layout HTTP edge serving ONE controllable document per mode + * (no mocks of our modules). `docs` is the live store — assertions read it + * after `waitFor` (the boot probe + debounced sync writes land there). + */ +function memoryLayout( + initial: TileLayoutData | null = null, +): TileLayoutHttp & { docs: Map<TileGeometryMode, TileLayoutData> } { + const docs = new Map<TileGeometryMode, TileLayoutData>(); + if (initial !== null) docs.set("wide", initial); + return { + docs, + async getLayout(mode) { + return { ok: true, layout: docs.get(mode) ?? { version: 2, tiles: [] } }; + }, + async putLayout(mode, layout) { + docs.set(mode, layout); + return { ok: true, layout }; + }, + async deleteLayout(mode) { + docs.set(mode, { version: 2, tiles: [] }); + return { ok: true, layout: docs.get(mode) as TileLayoutData }; + }, + }; +} + +describe("WorkspacesHome (tile canvas)", () => { + it("renders one tile per workspace, in the persisted tile order (not the list order)", async () => { + const list = [ws({ id: "a", title: "A" }), ws({ id: "b", title: "B" })]; + const layout = memoryLayout({ + version: 2, + tiles: [ + { id: "b", order: 0, colSpan: 1, rowSpan: 1 }, + { id: "a", order: 1, colSpan: 1, rowSpan: 1 }, + ], + }); + render(WorkspacesHome, { + props: { + store: (await import("../store.svelte")).createWorkspaceStore(fakeHttp(list)), + onNavigate: vi.fn(), + computers: [], + tiles: createTileLayoutStore({ http: layout }), + }, + }); + + await screen.findByRole("button", { name: "Open workspace B" }); + const wrappers = document.querySelectorAll("[data-tile-id]"); + expect([...wrappers].map((el) => el.getAttribute("data-tile-id"))).toEqual(["b", "a"]); + }); + + it.each([ + false, + true, + ])("renders before the layout probe resolves (cached: %s)", async (cached) => { + const layout = memoryLayout(); + const gate: { release?: (result: TileLayoutHttpResult) => void } = {}; + layout.getLayout = vi.fn( + () => + new Promise<TileLayoutHttpResult>((resolve) => { + gate.release = resolve; + }), + ); + const put = vi.spyOn(layout, "putLayout"); + const stored = { + version: 2, + tiles: [ + { id: "b", order: 0, colSpan: 2, rowSpan: 2 }, + { id: "a", order: 1, colSpan: 1, rowSpan: 1 }, + ], + }; + const storage = cached ? localStorage : undefined; + storage?.setItem("dispatch.tile-layout.tall", JSON.stringify(stored)); + const width = vi.spyOn(window, "innerWidth", "get").mockReturnValue(390); + const height = vi.spyOn(window, "innerHeight", "get").mockReturnValue(844); + const tiles = createTileLayoutStore({ http: layout, initialMode: "tall", storage }); + try { + render(WorkspacesHome, { + props: { + store: (await import("../store.svelte")).createWorkspaceStore( + fakeHttp([ws(), ws({ id: "b", title: "B" })]), + ), + onNavigate: vi.fn(), + computers: [], + tiles, + }, + }); + await screen.findByRole("button", { name: "Open workspace A" }); + await screen.findByRole("button", { name: "Open workspace B" }); + expect( + [...document.querySelectorAll("[data-tile-id]")].map((el) => + el.getAttribute("data-tile-id"), + ), + ).toEqual(cached ? ["b", "a"] : ["a", "b"]); + expect(tiles.providerState).toBe("unknown"); + expect(tiles.entries).toEqual(cached ? stored.tiles : []); + expect(layout.getLayout).toHaveBeenCalledExactlyOnceWith("tall"); + expect(put).not.toHaveBeenCalled(); + gate.release?.({ + ok: true, + layout: { + version: 2, + tiles: stored.tiles + .slice() + .reverse() + .map((tile, order) => ({ ...tile, order })), + }, + }); + await waitFor(() => { + expect(tiles.providerState).toBe("present"); + expect(tiles.entries.map((tile) => tile.id)).toEqual(["a", "b"]); + // CSS-grid coordinates determine visual order; keyed DOM nodes may stay put. + expect(document.querySelector('[data-tile-id="a"]')).toHaveStyle("grid-row: 1 / span 1"); + expect(document.querySelector('[data-tile-id="b"]')).toHaveStyle("grid-row: 2 / span 2"); + }); + } finally { + width.mockRestore(); + height.mockRestore(); + storage?.removeItem("dispatch.tile-layout.tall"); + } + }); + + it("re-probes when the canvas changes orientation", async () => { + let measure: ResizeObserverCallback = () => {}; + const observer = vi.spyOn(globalThis, "ResizeObserver").mockImplementation((callback) => { + measure = callback; + return { observe() {}, unobserve() {}, disconnect() {} }; + }); + const layout = memoryLayout(); + const get = vi.spyOn(layout, "getLayout"); + const tiles = createTileLayoutStore({ http: layout }); + try { + render(WorkspacesHome, { + props: { + store: (await import("../store.svelte")).createWorkspaceStore(fakeHttp()), + onNavigate: vi.fn(), + computers: [], + tiles, + }, + }); + await screen.findByRole("button", { name: "Open workspace A" }); + measure( + [{ contentRect: { width: 390, height: 700 } }] as ResizeObserverEntry[], + {} as ResizeObserver, + ); + await waitFor(() => { + expect(tiles.mode).toBe("tall"); + expect(get.mock.calls).toEqual([["wide"], ["tall"]]); + }); + await screen.findByRole("button", { name: "Open workspace A" }); + } finally { + observer.mockRestore(); + } + }); + + it("clicking a tile navigates to the workspace path", async () => { + const user = userEvent.setup(); + const onNavigate = vi.fn(); + render(WorkspacesHome, { + props: { + store: (await import("../store.svelte")).createWorkspaceStore(fakeHttp()), + onNavigate, + computers: [], + tiles: createTileLayoutStore({ http: memoryLayout() }), + }, + }); + + const tile = await screen.findByRole("button", { name: "Open workspace A" }); + await user.click(tile); + expect(onNavigate).toHaveBeenCalledWith("/a"); + }); + + it("creating a workspace from the header does NOT navigate; the new tile enters rename mode", async () => { + const user = userEvent.setup(); + const list = [ws({ id: "a", title: "A" })]; + const onNavigate = vi.fn(); + const store = (await import("../store.svelte")).createWorkspaceStore(fakeHttp(list)); + render(WorkspacesHome, { + props: { + store, + onNavigate, + computers: [], + tiles: createTileLayoutStore({ http: memoryLayout() }), + }, + }); + + await screen.findByRole("button", { name: "Open workspace A" }); + await user.click(screen.getByRole("button", { name: "New workspace" })); + await user.type(screen.getByRole("textbox", { name: "New workspace" }), "fresh-ws{Enter}"); + + await waitFor(() => { + expect(screen.getByLabelText("Workspace title", { selector: "input" })).toBeInTheDocument(); + }); + expect(onNavigate).not.toHaveBeenCalled(); + // The backend was asked to create it (create-on-miss). + expect(store.list.map((w) => w.id)).toContain("fresh-ws"); + }); + + it("refuses to create an archived slug: message in the popover, form stays open, no tile", async () => { + const user = userEvent.setup(); + const list = [ws({ id: "a", title: "A" })]; + const http = fakeHttp(list, async (id) => ({ + kind: "found", + workspace: ws({ id, title: id, archived: true }), + })); + const store = (await import("../store.svelte")).createWorkspaceStore(http); + render(WorkspacesHome, { + props: { + store, + onNavigate: vi.fn(), + computers: [], + tiles: createTileLayoutStore({ http: memoryLayout() }), + }, + }); + + await screen.findByRole("button", { name: "Open workspace A" }); + await user.click(screen.getByRole("button", { name: "New workspace" })); + await user.type(screen.getByRole("textbox", { name: "New workspace" }), "old-ws{Enter}"); + + expect(await screen.findByText(/already exists and is archived/)).toBeInTheDocument(); + // The popover stays open (the form/input is still mounted). + expect(screen.getByRole("textbox", { name: "New workspace" })).toBeInTheDocument(); + // Nothing was ensured, no tile appeared, and no rename mode was entered. + expect(http.ensure).not.toHaveBeenCalled(); + expect(store.list.map((w) => w.id)).toEqual(["a"]); + expect(screen.queryByLabelText("Workspace title")).toBeNull(); + }); + + it("right-clicking a tile opens the action bar; Delete keeps the confirm + store call", async () => { + const user = userEvent.setup(); + const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true); + const list = [ws({ id: "a", title: "A" })]; + const store = (await import("../store.svelte")).createWorkspaceStore(fakeHttp(list)); + render(WorkspacesHome, { + props: { + store, + onNavigate: vi.fn(), + computers: [], + tiles: createTileLayoutStore({ http: memoryLayout() }), + }, + }); + + const tile = await screen.findByRole("button", { name: "Open workspace A" }); + await user.pointer({ keys: "[MouseRight]", target: tile }); + + const deleteButton = await screen.findByRole("button", { name: "Delete" }); + await user.click(deleteButton); + + expect(confirmSpy).toHaveBeenCalledWith(expect.stringContaining('Delete workspace "A"?')); + await waitFor(() => { + expect(store.list.length).toBe(0); + }); + confirmSpy.mockRestore(); + }); + + it("workspaces unknown to the layout append at the end (deep-link auto-create)", async () => { + const list = [ws({ id: "a", title: "A" }), ws({ id: "b", title: "B" })]; + const layout = memoryLayout({ + version: 2, + tiles: [{ id: "b", order: 0, colSpan: 1, rowSpan: 1 }], + }); + render(WorkspacesHome, { + props: { + store: (await import("../store.svelte")).createWorkspaceStore(fakeHttp(list)), + onNavigate: vi.fn(), + computers: [], + tiles: createTileLayoutStore({ http: layout }), + }, + }); + + await screen.findByRole("button", { name: "Open workspace B" }); + await screen.findByRole("button", { name: "Open workspace A" }); + const wrappers = document.querySelectorAll("[data-tile-id]"); + expect([...wrappers].map((el) => el.getAttribute("data-tile-id"))).toEqual(["b", "a"]); + // And the sync persisted the appended entry. + await waitFor(() => { + const tiles = layout.docs.get("wide")?.tiles ?? []; + expect(tiles.map((tile) => tile.id)).toEqual(["b", "a"]); + }); + }); +}); |
