diff options
Diffstat (limited to 'src/features/workspaces')
| -rw-r--r-- | src/features/workspaces/adapter/http.test.ts | 30 | ||||
| -rw-r--r-- | src/features/workspaces/adapter/http.ts | 24 | ||||
| -rw-r--r-- | src/features/workspaces/index.ts | 6 | ||||
| -rw-r--r-- | src/features/workspaces/logic/create.test.ts | 50 | ||||
| -rw-r--r-- | src/features/workspaces/logic/create.ts | 64 | ||||
| -rw-r--r-- | src/features/workspaces/store.svelte.ts | 15 | ||||
| -rw-r--r-- | src/features/workspaces/store.test.ts | 61 | ||||
| -rw-r--r-- | src/features/workspaces/tile-layout-store.svelte.ts | 34 | ||||
| -rw-r--r-- | src/features/workspaces/tile-layout-store.test.ts | 116 | ||||
| -rw-r--r-- | src/features/workspaces/ui/WorkspacesHome.svelte | 23 | ||||
| -rw-r--r-- | src/features/workspaces/ui/WorkspacesHome.test.ts | 144 |
11 files changed, 535 insertions, 32 deletions
diff --git a/src/features/workspaces/adapter/http.test.ts b/src/features/workspaces/adapter/http.test.ts index 2e34822..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 () => { diff --git a/src/features/workspaces/adapter/http.ts b/src/features/workspaces/adapter/http.ts index de95a61..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 @@ -21,7 +22,7 @@ import type { * Endpoints ([email protected]): * - `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) @@ -41,7 +42,12 @@ export type WorkspaceArchivedFilter = "true" | "false" | "all"; export interface WorkspaceHttp { 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>>; @@ -96,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", + }; } }, diff --git a/src/features/workspaces/index.ts b/src/features/workspaces/index.ts index 1a60bb7..c7f57d5 100644 --- a/src/features/workspaces/index.ts +++ b/src/features/workspaces/index.ts @@ -1,6 +1,12 @@ 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, 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/store.svelte.ts b/src/features/workspaces/store.svelte.ts index 57e34b2..b9b646b 100644 --- a/src/features/workspaces/store.svelte.ts +++ b/src/features/workspaces/store.svelte.ts @@ -1,6 +1,7 @@ import type { EnsureWorkspaceRequest } from "@dispatch/transport-contract"; import type { Workspace, WorkspaceEntry } from "@dispatch/wire"; import type { WorkspaceHttp, WorkspaceResult } from "./adapter/http"; +import { gateCreate } from "./logic/create"; import { applyArchived, applyStarred, sortWorkspaces } from "./logic/view-model"; /** @@ -27,6 +28,14 @@ export interface WorkspaceStore { 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. */ @@ -102,6 +111,12 @@ export function createWorkspaceStore(http: WorkspaceHttp): WorkspaceStore { } }, + 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(); diff --git a/src/features/workspaces/store.test.ts b/src/features/workspaces/store.test.ts index a44d8a6..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 { @@ -25,14 +26,23 @@ function fakeHttp(opts: { 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 (_archived?: "true" | "false" | "all"): Promise<readonly WorkspaceEntry[]> => opts.list ?? [], ), - ensure: vi.fn(), - get: vi.fn(), + 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(), @@ -224,6 +234,53 @@ describe("createWorkspaceStore — setArchived", () => { }); }); +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 })] }); diff --git a/src/features/workspaces/tile-layout-store.svelte.ts b/src/features/workspaces/tile-layout-store.svelte.ts index a910c73..98a5c33 100644 --- a/src/features/workspaces/tile-layout-store.svelte.ts +++ b/src/features/workspaces/tile-layout-store.svelte.ts @@ -2,6 +2,7 @@ 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, @@ -25,7 +26,7 @@ import { * * - **Orientation selection** — `setMode(mode)` (the home page's effect on * the resolved grid geometry) is the boot + orientation-change trigger: - * an orientation CHANGE resets to the local empty default and probes that + * 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; @@ -38,7 +39,7 @@ import { * clients; the client filters by mode). * - **Quiet degrade** — any probe/write failure (backend unreachable, routes * not served) keeps the local state with EPHEMERAL edits (in-memory only — - * the retired localStorage write path is gone); logged once, no error UI. + * 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 @@ -61,7 +62,7 @@ export interface TileLayoutStore { /** * Select the active orientation and probe its document. Same-mode calls * are a no-op (the boot probe fires on creation for the initial mode); - * an orientation CHANGE resets to the local default + probes fresh. + * an orientation CHANGE seeds from its cache + probes fresh. */ setMode(mode: TileGeometryMode): void; /** Re-probe the ACTIVE orientation's document (reconnect catch-up). */ @@ -96,6 +97,10 @@ export interface TileLayoutStore { 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). */ @@ -109,11 +114,16 @@ export function createTileLayoutStore(opts: TileLayoutStoreOptions): TileLayoutS const debounceMs = opts.debounceMs ?? DEFAULT_DEBOUNCE_MS; const warn = opts.warn ?? ((message: string) => console.warn(message)); - let mode = $state<TileGeometryMode>("wide"); + 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 - // empty default until then. - let data = $state<TileLayoutData>(sanitizeLayout(null)); + // 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. */ @@ -141,6 +151,7 @@ export function createTileLayoutStore(opts: TileLayoutStoreOptions): TileLayoutS function applyServerLayout(next: TileLayoutData): void { lastServerLayout = next; data = next; + cache[mode].save(next); } function clearPutTimer(): void { @@ -154,7 +165,10 @@ export function createTileLayoutStore(opts: TileLayoutStoreOptions): TileLayoutS putTimer = null; // Read the CURRENT document at fire time: a newer edit supersedes the // debounced snapshot (one in-flight write per window, last wins). - const result = await http.putLayout(mode, { version: 2, tiles: data.tiles }); + 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. @@ -227,12 +241,12 @@ export function createTileLayoutStore(opts: TileLayoutStoreOptions): TileLayoutS setMode(next) { if (next === mode) return; mode = next; - // The document belongs to the OLD orientation now: reset to the local - // default while the probe resolves (never flash the other + // 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(null); + data = sanitizeLayout(cache[mode].load()); providerState = "unknown"; void runProbe(); }, diff --git a/src/features/workspaces/tile-layout-store.test.ts b/src/features/workspaces/tile-layout-store.test.ts index 15f7a74..b5af8b6 100644 --- a/src/features/workspaces/tile-layout-store.test.ts +++ b/src/features/workspaces/tile-layout-store.test.ts @@ -72,9 +72,11 @@ async function flushOnly(): Promise<void> { 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 () => { @@ -86,6 +88,118 @@ describe("createTileLayoutStore", () => { 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); @@ -120,6 +234,8 @@ describe("createTileLayoutStore", () => { 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"]); diff --git a/src/features/workspaces/ui/WorkspacesHome.svelte b/src/features/workspaces/ui/WorkspacesHome.svelte index 630c67f..196e831 100644 --- a/src/features/workspaces/ui/WorkspacesHome.svelte +++ b/src/features/workspaces/ui/WorkspacesHome.svelte @@ -12,6 +12,7 @@ import { DEFAULT_VIEWPORT, geometryFor, + mergeLayout, pack, type GridGeometry, type Span, @@ -86,11 +87,15 @@ }); // ── The tile canvas' size decides the geometry (ONE pure function — no - // breakpoint classes). Unmeasured (tests) → the default landscape viewport. + // 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 ?? DEFAULT_VIEWPORT.width, size?.height ?? DEFAULT_VIEWPORT.height), + geometryFor(size?.width ?? initialViewport.width, size?.height ?? initialViewport.height), ); $effect(() => { @@ -115,7 +120,12 @@ // The grid view: the SAME persisted data packs into either geometry — // positions re-derive here, spans clamp for the RENDER only. - const placements = $derived(pack(tiles.entries, geometry)); + // 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 ───────────────────────────────────────────────── @@ -215,12 +225,13 @@ if (result.ok) deselect(); } - // ── Create (header popover): create on the backend — do NOT navigate — - // place the new tile in the first empty slot, and go straight into rename. + // ── 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.ensure(slug); + 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 diff --git a/src/features/workspaces/ui/WorkspacesHome.test.ts b/src/features/workspaces/ui/WorkspacesHome.test.ts index 6316930..5a66a42 100644 --- a/src/features/workspaces/ui/WorkspacesHome.test.ts +++ b/src/features/workspaces/ui/WorkspacesHome.test.ts @@ -4,7 +4,8 @@ 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 } from "../adapter/tile-layout-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"; @@ -24,7 +25,10 @@ function ws(overrides: Partial<WorkspaceEntry> = {}): WorkspaceEntry { } /** A fake WorkspaceHttp edge with a controllable list (no mocks of our modules). */ -function fakeHttp(list: WorkspaceEntry[] = [ws()]): WorkspaceHttp { +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 @@ -33,7 +37,7 @@ function fakeHttp(list: WorkspaceEntry[] = [ws()]): WorkspaceHttp { if (!list.some((w) => w.id === id)) list.push(ws({ id, title: id })); return { ok: true, value: ws({ id, title: id }) }; }), - get: vi.fn(), + get: vi.fn(lookup), setTitle: vi.fn(), setDefaultCwd: vi.fn(), setDefaultComputer: vi.fn(), @@ -97,6 +101,110 @@ describe("WorkspacesHome (tile canvas)", () => { 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(); @@ -140,6 +248,36 @@ describe("WorkspacesHome (tile canvas)", () => { 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); |
