diff options
| author | Adam Malczewski <[email protected]> | 2026-09-07 11:47:18 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-09-07 11:47:18 +0900 |
| commit | 754450b539818f9ad94c9586888973b0edd74b68 (patch) | |
| tree | d852f9a8910f223e15a5e11ad04868b0d0a29804 /src/features/workspaces/ui/WorkspacesHome.test.ts | |
| parent | 719f0038dbb09f0fe9b073c3dc8e4f403815223d (diff) | |
| download | dispatch-web-754450b539818f9ad94c9586888973b0edd74b68.tar.gz dispatch-web-754450b539818f9ad94c9586888973b0edd74b68.zip | |
feat(workspaces): Win8 Start-style tile-grid home page
Rebuild the workspace home as a Windows 8 Start-screen-style tile grid,
replacing the max-w-3xl vertical card list.
Pure core (zero DOM/Svelte/fetch, fully unit-tested):
- logic/tile-layout.ts — geometryFor(width, height) resolves WIDE (4 fixed
~120px rows, column-wise flow, 4x2 span cap) or TALL (2 fixed columns,
row-wise flow, 2x2 cap); pack() is the ONLY order->coordinates transform
(greedy first-fit in fill order, hole-free, render-clamped per geometry);
firstEmptySlot/insertAtFirstEmptySlot/mergeLayout/resizeTile/moveTile/
sortByRecentActivity/sanitizeLayout. Persisted data is
orientation-independent {version:1, tiles:[{id,colSpan,rowSpan,order}]} —
order + spans only, never positions; the same data renders in either
geometry (spans clamp for the render, persisted values untouched).
- logic/drag-controller.ts — pure pointer/press state machine: mouse
threshold-drag + tap-open; touch long-press lifts + selects, pre-lift
movement cancels so vertical scrolling never fights the gesture.
Shell:
- tile-layout-store.svelte.ts — thin reactive wrapper persisting through
the injected createLocalStore adapter (dispatch.workspaces.tiles), ops:
sync/resize/move/placeFirstEmpty/sortByRecentActivity.
- ui/HomeTileGrid.svelte — CSS-grid tile canvas (wide: horizontal scroll +
snap + wheel pan + pan arrow + scroll indicator; tall: native vertical
scroll), FLIP-animated reflow, drag ghost, transient resize preview.
- ui/HomeHeader.svelte — fixed chrome: Dispatch display title, New
workspace popover (same slug validation; creates via create-on-miss and
does NOT navigate — the new tile lands in the first empty slot in fill
order and enters rename mode), global Plugins button, sort-by-recent
overflow; compact icon buttons + bottom sheet on tall screens.
- ui/WorkspaceTile.svelte — tile face (title, /slug, conversation count,
relative last-activity, live active dots, starred tint); inline rename
with the old card's semantics.
- ui/TileActionBar.svelte — slides up on select (right-click / long-press):
Open, Rename, Resize, Star/Unstar, Details, Plugins, Delete (old confirm
+ store call preserved).
- ui/ResizePicker.svelte — Word-style insert-table hover grid (wide 4x2 /
tall 2x2) with arrow/Enter/Escape support and ghost preview on commit.
- ui/WorkspaceDetails.svelte — flyout holding the old card's default-cwd
input + default-computer select (same store calls + error states).
- WorkspacesHome.svelte orchestrates; App.svelte untouched (same props).
- WorkspaceCard.svelte is removed (replaced by the tile system).
Manual tile order rules; starred workspaces get a tint (star is no longer
a sort key); workspaces unknown to the layout append at the end.
vitest: jsdom gains PointerEvent (MouseEvent-based) + Element.animate
stubs — the pointer logic is the outermost edge for the drag tests.
Verification: svelte-check 0/0, vitest 1655 passed (102 files, twice),
biome clean, vite build OK.
Diffstat (limited to 'src/features/workspaces/ui/WorkspacesHome.test.ts')
| -rw-r--r-- | src/features/workspaces/ui/WorkspacesHome.test.ts | 182 |
1 files changed, 182 insertions, 0 deletions
diff --git a/src/features/workspaces/ui/WorkspacesHome.test.ts b/src/features/workspaces/ui/WorkspacesHome.test.ts new file mode 100644 index 0000000..eb5b293 --- /dev/null +++ b/src/features/workspaces/ui/WorkspacesHome.test.ts @@ -0,0 +1,182 @@ +import type { Workspace, WorkspaceEntry } from "@dispatch/wire"; +import { render, screen, waitFor } from "@testing-library/svelte"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import type { LocalStore } from "../../../adapters/local-storage"; +import type { WorkspaceHttp, WorkspaceResult } from "../adapter/http"; +import type { TileLayoutData, TileLayoutEntry } from "../logic/tile-layout"; +import { 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, + 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()]): 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(), + 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; +} + +function memoryLayout(initial: TileLayoutData | null = null): LocalStore<TileLayoutData> & { + data: TileLayoutData | null; +} { + const store = { + data: initial, + load: () => store.data, + save: (value: TileLayoutData) => { + store.data = value; + }, + clear: () => { + store.data = null; + }, + }; + return store; +} + +describe("WorkspacesHome (tile canvas)", () => { + it("renders one tile per workspace, in the persisted tile order (not the list order)", async () => { + const list = [ws({ id: "a", title: "A" }), ws({ id: "b", title: "B" })]; + const layout = memoryLayout({ + version: 1, + 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(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("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(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(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("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(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: 1, + 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(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. + const entries = (layout.data?.tiles ?? []).map((tile: TileLayoutEntry) => tile.id); + expect(entries).toEqual(["b", "a"]); + }); +}); |
