From 754450b539818f9ad94c9586888973b0edd74b68 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Mon, 7 Sep 2026 11:47:18 +0900 Subject: feat(workspaces): Win8 Start-style tile-grid home page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/features/workspaces/index.ts | 30 +- .../workspaces/logic/drag-controller.test.ts | 173 ++++++++ src/features/workspaces/logic/drag-controller.ts | 159 ++++++++ src/features/workspaces/logic/tile-layout.test.ts | 440 +++++++++++++++++++++ src/features/workspaces/logic/tile-layout.ts | 427 ++++++++++++++++++++ .../workspaces/tile-layout-store.svelte.ts | 103 +++++ src/features/workspaces/tile-layout-store.test.ts | 140 +++++++ src/features/workspaces/ui/HomeHeader.svelte | 171 ++++++++ src/features/workspaces/ui/HomeHeader.test.ts | 90 +++++ src/features/workspaces/ui/HomeTileGrid.svelte | 307 ++++++++++++++ src/features/workspaces/ui/HomeTileGrid.test.ts | 180 +++++++++ src/features/workspaces/ui/ResizePicker.svelte | 107 +++++ src/features/workspaces/ui/ResizePicker.test.ts | 88 +++++ src/features/workspaces/ui/TileActionBar.svelte | 69 ++++ src/features/workspaces/ui/TileActionBar.test.ts | 88 +++++ src/features/workspaces/ui/WorkspaceCard.svelte | 319 --------------- src/features/workspaces/ui/WorkspaceCard.test.ts | 305 -------------- src/features/workspaces/ui/WorkspaceDetails.svelte | 132 +++++++ .../workspaces/ui/WorkspaceDetails.test.ts | 114 ++++++ src/features/workspaces/ui/WorkspaceTile.svelte | 109 +++++ src/features/workspaces/ui/WorkspaceTile.test.ts | 157 ++++++++ src/features/workspaces/ui/WorkspacesHome.svelte | 363 ++++++++++++----- src/features/workspaces/ui/WorkspacesHome.test.ts | 182 +++++++++ 23 files changed, 3535 insertions(+), 718 deletions(-) create mode 100644 src/features/workspaces/logic/drag-controller.test.ts create mode 100644 src/features/workspaces/logic/drag-controller.ts create mode 100644 src/features/workspaces/logic/tile-layout.test.ts create mode 100644 src/features/workspaces/logic/tile-layout.ts create mode 100644 src/features/workspaces/tile-layout-store.svelte.ts create mode 100644 src/features/workspaces/tile-layout-store.test.ts create mode 100644 src/features/workspaces/ui/HomeHeader.svelte create mode 100644 src/features/workspaces/ui/HomeHeader.test.ts create mode 100644 src/features/workspaces/ui/HomeTileGrid.svelte create mode 100644 src/features/workspaces/ui/HomeTileGrid.test.ts create mode 100644 src/features/workspaces/ui/ResizePicker.svelte create mode 100644 src/features/workspaces/ui/ResizePicker.test.ts create mode 100644 src/features/workspaces/ui/TileActionBar.svelte create mode 100644 src/features/workspaces/ui/TileActionBar.test.ts delete mode 100644 src/features/workspaces/ui/WorkspaceCard.svelte delete mode 100644 src/features/workspaces/ui/WorkspaceCard.test.ts create mode 100644 src/features/workspaces/ui/WorkspaceDetails.svelte create mode 100644 src/features/workspaces/ui/WorkspaceDetails.test.ts create mode 100644 src/features/workspaces/ui/WorkspaceTile.svelte create mode 100644 src/features/workspaces/ui/WorkspaceTile.test.ts create mode 100644 src/features/workspaces/ui/WorkspacesHome.test.ts (limited to 'src') diff --git a/src/features/workspaces/index.ts b/src/features/workspaces/index.ts index dab1dec..0aa7fca 100644 --- a/src/features/workspaces/index.ts +++ b/src/features/workspaces/index.ts @@ -1,5 +1,11 @@ export type { WorkspaceHttp, WorkspaceResult } from "./adapter/http"; export { createWorkspaceHttp } from "./adapter/http"; +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 +14,32 @@ export { WORKSPACE_SLUG_RE, workspacePath, } from "./logic/route"; +export type { + GridGeometry, + Placement, + TileGeometryMode, + TileLayoutData, + TileLayoutEntry, +} from "./logic/tile-layout"; +export { + clampSpan, + fillOrderKey, + firstEmptySlot, + geometryFor, + insertAtFirstEmptySlot, + mergeLayout, + moveTile, + pack, + resizeTile, + sanitizeLayout, + sortByRecentActivity, + sortTiles, +} 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 { TileLayoutStore } from "./tile-layout-store.svelte"; +export { createTileLayoutStore, TILE_LAYOUT_STORAGE_KEY } 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/drag-controller.test.ts b/src/features/workspaces/logic/drag-controller.test.ts new file mode 100644 index 0000000..d6dd760 --- /dev/null +++ b/src/features/workspaces/logic/drag-controller.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it } from "vitest"; +import { type DragPhase, type DragTransition, nextDragPhase } from "./drag-controller"; + +const OPTS = { dragThresholdPx: 6 }; + +function down(over: Partial[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, targetIndex: 3 }, + OPTS, + ); + expect(transition.phase).toEqual({ + kind: "dragging", + id: "t1", + x: 20, + y: 12, + targetIndex: 3, + touch: false, + }); + expect(transition.emit).toBeUndefined(); + }); + + it("movement within the threshold stays pressing", () => { + const transition = nextDragPhase( + press(), + { type: "pointer-move", x: 12, y: 14, targetIndex: 0 }, + 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, targetIndex: 2 }, + OPTS, + ); + transition = nextDragPhase( + transition.phase, + { type: "pointer-move", x: 31, y: 31, targetIndex: 5 }, + OPTS, + ); + transition = nextDragPhase(transition.phase, { type: "pointer-up" }, OPTS); + expect(transition.emit).toEqual({ kind: "drop", id: "t1", targetIndex: 5 }); + 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, + targetIndex: -1, + 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, targetIndex: 0 }, + 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, targetIndex: 0 }, + 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, targetIndex: 4 }, + OPTS, + ); + expect(transition.phase).toEqual({ + kind: "dragging", + id: "t1", + x: 10, + y: 90, + targetIndex: 4, + 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", targetIndex: -1 }); + }); +}); + +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, targetIndex: 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, targetIndex: 0 }, 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, targetIndex: 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..03dcade --- /dev/null +++ b/src/features/workspaces/logic/drag-controller.ts @@ -0,0 +1,159 @@ +/** + * 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 / reorder). + */ + +/** 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; + readonly targetIndex: number; + 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 targetIndex: number }; + +/** 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 targetIndex: number; + } + | { 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, + targetIndex: event.targetIndex, + touch: phase.touch, + }, + }; + } + if (phase.kind === "dragging") { + return { + phase: { + ...phase, + x: event.x, + y: event.y, + targetIndex: event.targetIndex, + }, + }; + } + 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, targetIndex: phase.targetIndex }, + }; + } + 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, + targetIndex: -1, + 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..f2fbc5b --- /dev/null +++ b/src/features/workspaces/logic/tile-layout.test.ts @@ -0,0 +1,440 @@ +import { describe, expect, it } from "vitest"; +import { + clampSpan, + fillOrderKey, + firstEmptySlot, + type GridGeometry, + geometryFor, + insertAtFirstEmptySlot, + mergeLayout, + moveTile, + pack, + resizeTile, + sanitizeLayout, + sortByRecentActivity, + type TileLayoutEntry, +} 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"); + }); +}); + +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(); + 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("insertAtFirstEmptySlot", () => { + it("inserts a new tile so it packs into the first empty slot", () => { + // A double-height tile fills rows 1-2, a single fills row 3 → the first + // empty slot is (col1,row4); the new tile must land exactly there. + const tiles = [tile("big", 0, 1, 2), tile("c", 1)]; + const next = insertAtFirstEmptySlot(tiles, "new", WIDE); + expect(next.map((t) => t.id)).toEqual(["big", "c", "new"]); + 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("renumbers orders densely after the insert", () => { + const next = insertAtFirstEmptySlot([tile("a", 0, 1, 2), tile("b", 7)], "new", WIDE); + expect(next.map((t) => t.order)).toEqual([0, 1, 2]); + }); + + it("moves 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 = insertAtFirstEmptySlot(tiles, "x", WIDE); + const second = insertAtFirstEmptySlot(first, "x", WIDE); + expect(second).toEqual(first); + expect(second.filter((t) => t.id === "x")).toHaveLength(1); + }); + + it("appends when the empty slot is beyond the current extent", () => { + const tiles = [tile("a", 0), tile("b", 1), tile("c", 2), tile("d", 3)]; + const next = insertAtFirstEmptySlot(tiles, "e", WIDE); + expect(next.map((t) => t.id)).toEqual(["a", "b", "c", "d", "e"]); + expect(pack(next, WIDE).at(-1)).toMatchObject({ id: "e", col: 2, row: 1 }); + }); + + it("preserves the spans of a moved existing tile", () => { + const tiles = [tile("x", 0, 2, 2), tile("a", 1), tile("b", 2), tile("c", 3)]; + const next = insertAtFirstEmptySlot(tiles, "x", WIDE); + expect(next.find((t) => t.id === "x")).toMatchObject({ colSpan: 2, rowSpan: 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("moveTile", () => { + it("moves a tile forward, landing after the hovered tile", () => { + const next = moveTile([tile("a", 0), tile("b", 1), tile("c", 2), tile("d", 3)], "a", 2); + expect(next.map((t) => t.id)).toEqual(["b", "c", "a", "d"]); + }); + + it("moves a tile backward, landing before the hovered tile", () => { + const next = moveTile([tile("a", 0), tile("b", 1), tile("c", 2), tile("d", 3)], "d", 1); + expect(next.map((t) => t.id)).toEqual(["a", "d", "b", "c"]); + }); + + it("renumbers orders densely", () => { + const next = moveTile([tile("a", 0), tile("b", 3), tile("c", 9)], "c", 0); + expect(next.map((t) => t.order)).toEqual([0, 1, 2]); + }); + + it("is a no-op for the same index or an unknown id", () => { + const tiles = [tile("a", 0), tile("b", 1)]; + expect(moveTile(tiles, "a", 0)).toEqual(tiles); + expect(moveTile(tiles, "x", 1)).toEqual(tiles); + }); + + it("clamps out-of-range indices", () => { + const next = moveTile([tile("a", 0), tile("b", 1)], "a", 99); + expect(next.map((t) => t.id)).toEqual(["b", "a"]); + }); +}); + +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], + ]); + }); +}); + +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 document", () => { + expect( + sanitizeLayout({ version: 1, tiles: [{ id: "a", colSpan: 2, rowSpan: 1, order: 0 }] }), + ).toEqual({ + version: 1, + tiles: [{ id: "a", colSpan: 2, rowSpan: 1, order: 0 }], + }); + }); + + it("rejects foreign versions and malformed documents", () => { + expect(sanitizeLayout(null)).toEqual({ version: 1, tiles: [] }); + expect(sanitizeLayout("nope")).toEqual({ version: 1, tiles: [] }); + expect(sanitizeLayout({ version: 2, tiles: [] })).toEqual({ version: 1, tiles: [] }); + expect(sanitizeLayout({ version: 1, tiles: "x" })).toEqual({ version: 1, 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 }, + ]); + }); +}); + +describe("geometry-agnostic persistence", () => { + it("the SAME persisted data renders in either geometry (positions re-derived, never stored)", () => { + 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 }); + // The document carries NO orientation/position field — only id/spans/order. + expect(Object.keys(tiles[0] ?? {}).sort()).toEqual(["colSpan", "id", "order", "rowSpan"]); + }); +}); + +describe("type surface", () => { + it("exposes the geometry fields the picker and grid need", () => { + const geometry: GridGeometry = geometryFor(1000, 500); + expect([geometry.mode, geometry.flowRows, geometry.flowCols, geometry.unit.gap]).toEqual([ + "wide", + 4, + 0, + 8, + ]); + }); +}); diff --git a/src/features/workspaces/logic/tile-layout.ts b/src/features/workspaces/logic/tile-layout.ts new file mode 100644 index 0000000..17923c0 --- /dev/null +++ b/src/features/workspaces/logic/tile-layout.ts @@ -0,0 +1,427 @@ +/** + * Pure tile-layout logic for the Win8-Start-style workspace home grid — zero + * DOM, zero Svelte, zero effects. + * + * The PERSISTED data is orientation-independent: `{ version, tiles: [{ id, + * colSpan, rowSpan, order }] }` — the manual tile order + requested spans + * ONLY, never positions. Positions are re-derived by {@link pack} from the + * CURRENT geometry, so the same data renders in either geometry (wide desktop + * / tall mobile) with no orientation conversion step: a tile whose persisted + * span exceeds the geometry's cap simply renders clamped (the persisted span + * is untouched). + * + * 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; +} + +/** One persisted tile: which workspace it pins, how big it wants to be, and its manual position in the order. */ +export interface TileLayoutEntry { + /** The workspace id (the slug). */ + readonly id: string; + readonly colSpan: number; + readonly rowSpan: number; + /** Lower sorts earlier. Positions are NEVER persisted — only this order. */ + readonly order: number; +} + +/** The persisted document (localStorage via the injected `LocalStore` adapter). */ +export interface TileLayoutData { + readonly version: 1; + 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; + +const WIDE_UNIT: TileUnitSize = { width: 128, height: 120, gap: 8 }; +const TALL_UNIT: TileUnitSize = { width: 0, height: 120, gap: 8 }; + +/** + * Resolve the grid geometry from the tile canvas' size. Landscape/desktop → + * WIDE (4 fixed ~120px 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. 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, + }; + } + return { + mode: "wide", + flowRows: 4, + flowCols: 0, + maxColSpan: 4, + maxRowSpan: 2, + unit: WIDE_UNIT, + }; +} + +/** Sort entries by their persisted order (stable — ties keep their relative order). */ +export function sortTiles(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(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); +} + +function cellsFree( + occupied: ReadonlySet, + 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, + 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, 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, + 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 order → coordinates transform: place the tiles (in their persisted + * order) into the first fitting slot in the geometry's fill order — greedy + * first-fit, so every fitting slot is used before the flow advances (no + * intentional holes). Requested spans clamp to the geometry (a 3×1 desktop + * tile in tall mode packs as 2×1 — the persisted span is untouched). Pure: + * entries + geometry in, a NEW placements array out (in pack order, which + * respects the persisted order). + */ +export function pack(tiles: readonly TileLayoutEntry[], geometry: GridGeometry): Placement[] { + const occupied = new Set(); + const placements: Placement[] = []; + for (const tile of sortTiles(tiles)) { + const { colSpan, rowSpan } = clampSpan(tile, geometry); + const slot = firstFit(occupied, { colSpan, rowSpan }, geometry); + occupy(occupied, slot.col, slot.row, colSpan, rowSpan); + placements.push({ id: tile.id, col: slot.col, row: slot.row, colSpan, rowSpan }); + } + return placements; +} + +/** + * 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 { + const occupied = new Set(); + 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 })); +} + +/** + * Insert (or MOVE an already-present tile to) the first empty slot in fill + * order — the header-create placement. The new tile is 1×1, and inserting it + * before the first placement that starts AFTER the empty slot makes greedy + * packing drop it exactly into that slot. Idempotent: an id already in the + * layout is moved (spans preserved), not duplicated — this closes the race + * where the list-refresh sync appended the id first. Pure: entries in, a NEW + * entries array out (renumbered). + */ +export function insertAtFirstEmptySlot( + 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 slotKey = fillOrderKey(slot.col, slot.row, geometry); + const placements = pack(without, geometry); + let index = placements.findIndex((p) => fillOrderKey(p.col, p.row, geometry) > slotKey); + if (index === -1) index = without.length; + const existing = tiles.find((tile) => tile.id === id); + const entry: TileLayoutEntry = existing ?? { id, colSpan: 1, rowSpan: 1, order: 0 }; + return renumber([...without.slice(0, index), entry, ...without.slice(index)]); +} + +/** + * 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, + ); +} + +/** + * Move one tile to a position in the manual ORDER (the drag-reorder op). The + * target index is in the CURRENT order-sorted array; the tile is removed and + * re-inserted at the index, so dragging forward lands after the hovered tile + * and dragging backward lands before it (the dragged tile takes the hovered + * slot). Positions re-derive from the new order — never stored. Pure. + */ +export function moveTile( + tiles: readonly TileLayoutEntry[], + id: string, + toIndex: number, +): TileLayoutEntry[] { + const ordered = sortTiles(tiles); + const from = ordered.findIndex((tile) => tile.id === id); + if (from === -1 || toIndex === from) return ordered; + const without = ordered.filter((tile) => tile.id !== id); + const index = Math.max(0, Math.min(Math.floor(toIndex), without.length)); + const moved = ordered[from]; + if (!moved) return ordered; + return renumber([...without.slice(0, index), moved, ...without.slice(index)]); +} + +/** + * Re-apply the recent-activity order (the header overflow "Sort" action): + * tiles sort by their workspace's `lastActivityAt` descending (unknown ids + * last, ties stable), then orders renumber. Pure. + */ +export function sortByRecentActivity( + tiles: readonly TileLayoutEntry[], + lastActivityById: ReadonlyMap, +): TileLayoutEntry[] { + const sorted = sortTiles(tiles).sort( + (a, b) => (lastActivityById.get(b.id) ?? 0) - (lastActivityById.get(a.id) ?? 0), + ); + return renumber(sorted); +} + +/** + * 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(); + 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). Anything but `{ version: 1, tiles: [...] }` + * yields the empty layout; malformed entries coerce (missing spans → 1, + * garbage → dropped). Pure: unknown in, 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) return EMPTY_LAYOUT; + const rawTiles = (raw as { tiles?: unknown }).tiles; + if (!Array.isArray(rawTiles)) return EMPTY_LAYOUT; + const tiles: TileLayoutEntry[] = []; + const seen = new Set(); + 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); + 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), + }); + } + return { version: 1, tiles }; +} + +const EMPTY_LAYOUT: TileLayoutData = { version: 1, 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); +} 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..ce01748 --- /dev/null +++ b/src/features/workspaces/tile-layout-store.svelte.ts @@ -0,0 +1,103 @@ +import type { LocalStore } from "../../adapters/local-storage"; +import { + type GridGeometry, + insertAtFirstEmptySlot, + mergeLayout, + moveTile, + resizeTile, + sanitizeLayout, + sortByRecentActivity, + type TileLayoutData, + type TileLayoutEntry, +} from "./logic/tile-layout"; + +/** + * The localStorage key for the home tile layout (order + spans only — never + * positions; positions re-derive from the geometry on every render). + */ +export const TILE_LAYOUT_STORAGE_KEY = "dispatch.workspaces.tiles"; + +/** + * Tile-layout store — a thin reactive wrapper over the persisted + * orientation-independent layout document, persisted through the INJECTED + * `LocalStore` adapter (the repo's local-storage effect). All mutations go + * through the pure `logic/tile-layout` ops and write through to persistence. + * No ambient state: one instance per home page, created by the page entry + * (or injected in tests). + */ +export interface TileLayoutStore { + /** The persisted entries, order-sorted (raw spans — NOT geometry-clamped). */ + readonly entries: readonly TileLayoutEntry[]; + /** + * Merge the live workspace ids into the layout: workspaces unknown to the + * layout append at the end, entries for deleted workspaces drop. 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; + /** Move a tile to a position in the manual order (drag reorder). */ + move(id: string, toIndex: number): void; + /** + * Place (or move an already-present) tile into the FIRST EMPTY SLOT in the + * grid's fill order — the header-create flow. Idempotent (see + * `insertAtFirstEmptySlot`), closing the refresh race where `sync` appended + * the id first. + */ + placeFirstEmpty(id: string, geometry: GridGeometry): void; + /** Re-apply the recent-activity order (the header overflow "Sort" action). */ + sortByRecentActivity(lastActivityById: ReadonlyMap): void; +} + +export function createTileLayoutStore(persistence: LocalStore): TileLayoutStore { + // Load once at creation; a corrupt/foreign document sanitizes to empty. + let data = $state(sanitizeLayout(persistence.load())); + + function commit(tiles: readonly TileLayoutEntry[]): void { + data = { version: 1, tiles }; + persistence.save(data); + } + + return { + get entries(): readonly TileLayoutEntry[] { + return data.tiles; + }, + + sync(workspaceIds): void { + const merged = mergeLayout(data, workspaceIds); + if (!sameEntries(merged, data.tiles)) commit(merged); + }, + + resize(id, colSpan, rowSpan): void { + commit(resizeTile(data.tiles, id, colSpan, rowSpan)); + }, + + move(id, toIndex): void { + commit(moveTile(data.tiles, id, toIndex)); + }, + + placeFirstEmpty(id, geometry): void { + commit(insertAtFirstEmptySlot(data.tiles, id, geometry)); + }, + + sortByRecentActivity(lastActivityById): void { + commit(sortByRecentActivity(data.tiles, lastActivityById)); + }, + }; +} + +/** Structural equality over entries (ids, spans, orders). */ +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 + ); + }); +} 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..f8b30f7 --- /dev/null +++ b/src/features/workspaces/tile-layout-store.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from "vitest"; +import type { LocalStore } from "../../adapters/local-storage"; +import { geometryFor, type TileLayoutData, type TileLayoutEntry } from "./logic/tile-layout"; +import { createTileLayoutStore } from "./tile-layout-store.svelte"; + +const WIDE = geometryFor(1920, 1080); + +/** An in-memory `LocalStore` (the injected outermost edge — no mocks of our own modules). */ +function memoryStore(initial: TileLayoutData | null = null): LocalStore & { + saved: TileLayoutData[]; +} { + let value = initial; + const saved: TileLayoutData[] = []; + return { + saved, + load: () => value, + save(next: TileLayoutData) { + saved.push(next); + value = next; + }, + clear() { + value = null; + }, + }; +} + +function tile(id: string, order: number, colSpan = 1, rowSpan = 1): TileLayoutEntry { + return { id, order, colSpan, rowSpan }; +} + +describe("createTileLayoutStore", () => { + it("loads the persisted document at creation", () => { + const persistence = memoryStore({ version: 1, tiles: [tile("a", 0, 2, 2)] }); + const store = createTileLayoutStore(persistence); + expect(store.entries).toEqual([tile("a", 0, 2, 2)]); + }); + + it("sanitizes a corrupt persisted document to empty", () => { + const persistence = memoryStore(null); + // The adapter's load is untyped at the boundary — simulate garbage. + const store = createTileLayoutStore({ + load: () => ({ junk: true }) as unknown as TileLayoutData, + save: () => {}, + clear: () => {}, + }); + expect(store.entries).toEqual([]); + expect(persistence.saved).toEqual([]); + }); + + it("sync appends unknown workspaces and drops deleted ones, persisting once", () => { + const persistence = memoryStore({ version: 1, tiles: [tile("a", 0), tile("gone", 1)] }); + const store = createTileLayoutStore(persistence); + + store.sync(["a", "b"]); + + expect(store.entries.map((t) => t.id)).toEqual(["a", "b"]); + expect(persistence.saved).toHaveLength(1); + // The max order counts KEPT entries (the dropped one doesn't reserve a slot). + expect(persistence.saved[0]).toEqual({ + version: 1, + tiles: [tile("a", 0), { id: "b", colSpan: 1, rowSpan: 1, order: 1 }], + }); + }); + + it("sync is a no-op (no write) when the merge changes nothing", () => { + const persistence = memoryStore({ version: 1, tiles: [tile("a", 0)] }); + const store = createTileLayoutStore(persistence); + + store.sync(["a"]); + store.sync(["a"]); + + expect(persistence.saved).toHaveLength(0); + }); + + it("resize persists the new spans without clamping", () => { + const persistence = memoryStore({ version: 1, tiles: [tile("a", 0)] }); + const store = createTileLayoutStore(persistence); + + store.resize("a", 4, 2); + + expect(store.entries).toEqual([tile("a", 0, 4, 2)]); + expect(persistence.saved.at(-1)?.tiles).toEqual([tile("a", 0, 4, 2)]); + }); + + it("move persists the reordered layout", () => { + const persistence = memoryStore({ + version: 1, + tiles: [tile("a", 0), tile("b", 1), tile("c", 2)], + }); + const store = createTileLayoutStore(persistence); + + store.move("c", 0); + + expect(store.entries.map((t) => t.id)).toEqual(["c", "a", "b"]); + expect(store.entries.map((t) => t.order)).toEqual([0, 1, 2]); + }); + + it("placeFirstEmpty puts a new id in the first empty slot's fill position", () => { + const persistence = memoryStore({ + version: 1, + tiles: [tile("a", 0, 1, 2), tile("b", 1)], + }); + const store = createTileLayoutStore(persistence); + + store.placeFirstEmpty("new", WIDE); + + // a packs rows 1-2, b row 3 → the first empty slot is (col1,row4) → appended. + expect(store.entries.map((t) => t.id)).toEqual(["a", "b", "new"]); + expect(persistence.saved.at(-1)?.tiles.map((t) => t.id)).toEqual(["a", "b", "new"]); + }); + + it("placeFirstEmpty is idempotent for an id sync already appended (the refresh race)", () => { + const persistence = memoryStore({ version: 1, tiles: [tile("a", 0)] }); + const store = createTileLayoutStore(persistence); + store.sync(["a", "x"]); // the refresh appended the new workspace at the end + const before = store.entries; + + store.placeFirstEmpty("x", WIDE); + + expect(store.entries).toEqual(before); + expect(store.entries.filter((t) => t.id === "x")).toHaveLength(1); + }); + + it("sortByRecentActivity re-applies recent-activity order and persists", () => { + const persistence = memoryStore({ + version: 1, + tiles: [tile("a", 0), tile("b", 1)], + }); + const store = createTileLayoutStore(persistence); + + store.sortByRecentActivity( + new Map([ + ["a", 10], + ["b", 99], + ]), + ); + + expect(store.entries.map((t) => t.id)).toEqual(["b", "a"]); + }); +}); diff --git a/src/features/workspaces/ui/HomeHeader.svelte b/src/features/workspaces/ui/HomeHeader.svelte new file mode 100644 index 0000000..01ac63f --- /dev/null +++ b/src/features/workspaces/ui/HomeHeader.svelte @@ -0,0 +1,171 @@ + + +
+

Dispatch

+ +
+ + + + {#if withPlugins} + + {/if} + + {#if withSort} + + {/if} +
+
+ +{#if popoverOpen} + + + + +
+
{ + e.preventDefault(); + void submit(); + }} + > + + + {#if slugError}

{slugError}

{/if} +
+ {#if compact} + + {/if} + +
+
+
+{/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..a0afc52 --- /dev/null +++ b/src/features/workspaces/ui/HomeHeader.test.ts @@ -0,0 +1,90 @@ +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(), + }; +} + +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: "Sort 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 } }); + expect(screen.getByRole("button", { name: "New workspace" })).toHaveTextContent("+"); + }); +}); diff --git a/src/features/workspaces/ui/HomeTileGrid.svelte b/src/features/workspaces/ui/HomeTileGrid.svelte new file mode 100644 index 0000000..e29b9b1 --- /dev/null +++ b/src/features/workspaces/ui/HomeTileGrid.svelte @@ -0,0 +1,307 @@ + + +
+
+
+ {#each placements as placement (placement.id)} + {@const ws = workspacesById.get(placement.id)} + {#if ws} + +
tilePointerDown(e, placement.id)} + onpointermove={tilePointerMove} + onpointerup={tilePointerUp} + onpointercancel={() => dispatch({ type: "cancel" })} + 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); + } + }} + > + +
+ {/if} + {/each} +
+
+ + {#if canPan} + + +
+
+
+ {/if} +
+ +{#if phase.kind === "dragging"} + +
+ {workspacesById.get(phase.id)?.title ?? phase.id} +
+{/if} diff --git a/src/features/workspaces/ui/HomeTileGrid.test.ts b/src/features/workspaces/ui/HomeTileGrid.test.ts new file mode 100644 index 0000000..3229d4e --- /dev/null +++ b/src/features/workspaces/ui/HomeTileGrid.test.ts @@ -0,0 +1,180 @@ +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 { + return { + id: "a", + title: "Workspace A", + defaultCwd: null, + defaultComputerId: null, + starred: 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(), + onReorder: 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 transient ghost, and dropping reorders (when over a target)", 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" }); + // jsdom has no elementFromPoint hit-testing → targetIndex stays -1 (no-op), + // but the GHOST must appear while dragging and disappear on drop. + Object.defineProperty(document, "elementFromPoint", { + configurable: true, + value: () => null, + }); + fireEvent.pointerDown(wrapper, { button: 0, clientX: 10, clientY: 10, pointerType: "mouse" }); + fireEvent.pointerMove(wrapper, { clientX: 80, clientY: 40 }); + + expect(container.querySelector(".pointer-events-none.fixed")).not.toBeNull(); + + fireEvent.pointerUp(wrapper); + expect(container.querySelector(".pointer-events-none.fixed")).toBeNull(); + // No hit target → no reorder call. + expect(props.onReorder).not.toHaveBeenCalled(); + // And no navigation either (a drag is not a tap). + expect(props.onOpen).not.toHaveBeenCalled(); + }); + + 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 @@ + + + +
onPreview(null)} +> +

Resize — click a size

+
+ {#each Array.from({ length: rows }) as _, row (row)} + {#each Array.from({ length: cols }) as __, col (col)} + + {/each} + {/each} +
+

{hovered.colSpan} × {hovered.rowSpan}

+

Enter commits · Escape cancels

+
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..0d8bef5 --- /dev/null +++ b/src/features/workspaces/ui/TileActionBar.svelte @@ -0,0 +1,69 @@ + + + +
{ + // The bar owns its clicks — don't let them deselect via the backdrop. + e.stopPropagation(); + }} +> + +
diff --git a/src/features/workspaces/ui/TileActionBar.test.ts b/src/features/workspaces/ui/TileActionBar.test.ts new file mode 100644 index 0000000..4b0377e --- /dev/null +++ b/src/features/workspaces/ui/TileActionBar.test.ts @@ -0,0 +1,88 @@ +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 { + return { + id: "my-ws", + title: "My Workspace", + defaultCwd: null, + defaultComputerId: null, + starred: 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(), + 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: "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: "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.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 8c004aa..0000000 --- a/src/features/workspaces/ui/WorkspaceCard.svelte +++ /dev/null @@ -1,319 +0,0 @@ - - -
  • -
    - {#if editingTitle} - { - if (e.key === "Enter") saveTitle(); - else if (e.key === "Escape") cancelTitle(); - }} - onblur={saveTitle} - /> - {:else} - - {ws.title} - {/if} - {#if active} - - {/if} - /{ws.id} - - - {ws.conversationCount} - {ws.conversationCount === 1 ? "conversation" : "conversations"} - · {relativeTime(ws.lastActivityAt, Date.now())} - - -
    - - {#if titleError} -

    {titleError}

    - {/if} - - {#if starError} -

    {starError}

    - {/if} - -
    - cwd - { - if (e.key === "Enter") saveCwd(); - }} - /> - -
    - -
    - ssh - - {#if savingComputer} - - {/if} -
    - -
    - { - e.preventDefault(); - onNavigate(workspacePath(ws.id)); - }} - > - Open - - {#if plugins} - - {/if} -
    - - {#if cwdError} -

    {cwdError}

    - {:else if !cwdDirty && !ws.defaultCwd} -

    No default cwd set — conversations inherit the server default.

    - {/if} - - {#if computerError} -

    {computerError}

    - {:else if !ws.defaultComputerId} -

    No default computer — conversations run locally (no SSH).

    - {/if} -
  • - -{#if pluginsOpen && plugins} - (pluginsOpen = false)} - /> -{/if} 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 { - 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> => ({ - ok: true, - value: fakeEntry({ id, title: _title }), - }), - ), - setDefaultCwd: vi.fn( - async (id: string, defaultCwd: string | null): Promise> => ({ - ok: true, - value: fakeEntry({ id, defaultCwd }), - }), - ), - setDefaultComputer: vi.fn( - async (id: string, computerId: string | null): Promise> => ({ - ok: true, - value: fakeEntry({ id, defaultComputerId: computerId }), - }), - ), - setStarred: vi.fn( - async (id: string, starred: boolean): Promise> => ({ - ok: true, - value: fakeEntry({ id, starred }), - }), - ), - remove: vi.fn( - async (): Promise> => ({ - 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> => ({ 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> => ({ 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> => { - 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 @@ + + + diff --git a/src/features/workspaces/ui/WorkspaceDetails.test.ts b/src/features/workspaces/ui/WorkspaceDetails.test.ts new file mode 100644 index 0000000..c903ac0 --- /dev/null +++ b/src/features/workspaces/ui/WorkspaceDetails.test.ts @@ -0,0 +1,114 @@ +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 { + return { + id: "my-ws", + title: "My Workspace", + defaultCwd: null, + defaultComputerId: null, + starred: false, + createdAt: 1, + lastActivityAt: 2, + conversationCount: 0, + ...overrides, + }; +} + +function fakeStore(): WorkspaceStore { + return { + setDefaultCwd: vi.fn( + async (id: string, defaultCwd: string | null): Promise> => ({ + ok: true, + value: fakeEntry({ id, defaultCwd }), + }), + ), + setDefaultComputer: vi.fn( + async ( + id: string, + defaultComputerId: string | null, + ): Promise> => ({ + 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> => ({ 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..4059690 --- /dev/null +++ b/src/features/workspaces/ui/WorkspaceTile.svelte @@ -0,0 +1,109 @@ + + +
    + {#if editing} + { + if (e.key === "Enter") void saveTitle(); + else if (e.key === "Escape") cancelTitle(); + }} + onblur={() => void saveTitle()} + /> + {:else} + + {ws.title} + {/if} + + /{ws.id} + + {#if titleError} +

    {titleError}

    + {/if} + +
    + {#if active} + + {/if} + + {ws.conversationCount} + {ws.conversationCount === 1 ? "conversation" : "conversations"} + + {relativeTime(ws.lastActivityAt, Date.now())} +
    +
    diff --git a/src/features/workspaces/ui/WorkspaceTile.test.ts b/src/features/workspaces/ui/WorkspaceTile.test.ts new file mode 100644 index 0000000..918aa1c --- /dev/null +++ b/src/features/workspaces/ui/WorkspaceTile.test.ts @@ -0,0 +1,157 @@ +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 { + return { + id: "my-ws", + title: "My Workspace", + defaultCwd: null, + defaultComputerId: null, + starred: 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> => ({ + ok: true, + value: fakeEntry({ id, title }), + }), + ), + } as unknown as WorkspaceStore; +} + +function fakePlacement(overrides: Partial = {}): 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(); + expect(screen.getByText(/3 conversations/)).toBeInTheDocument(); + expect(screen.getByText("1m")).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> => ({ + 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 5a4d88f..276a811 100644 --- a/src/features/workspaces/ui/WorkspacesHome.svelte +++ b/src/features/workspaces/ui/WorkspacesHome.svelte @@ -1,10 +1,32 @@ -
    -
    -

    Workspaces

    -
    - { - e.preventDefault(); - onNavigate("/default"); - }} - > - Default - - {#if plugins} - - {/if} -
    -
    - -
    { - e.preventDefault(); - createWorkspace(); - }} - > -
    - - -
    - -
    - {#if slugError} -

    {slugError}

    - {/if} - -
    + + +
    + 1} + onCreate={createWorkspace} + onOpenPlugins={() => (managerOpen = true)} + onSort={() => void sortByRecent()} + /> + +
    {#if store.loading && store.list.length === 0}
    {:else if store.list.length === 0}

    - No workspaces yet. Create one above or visit /your-name in the URL. + No workspaces yet. Create one with New workspace above, or visit + /your-name in the URL.

    {:else} -
      - {#each store.list as ws (ws.id)} - - {/each} -
    + tiles.move(id, toIndex)} + onRenameEnd={() => (renamingId = null)} + /> {/if}
    +{#if selected && !renamingId} + openWorkspace(selected.id)} + onRename={() => (renamingId = selected.id)} + onResize={startResize} + onToggleStar={() => void toggleStar()} + onDetails={() => (detailsForId = selected.id)} + onPlugins={() => (pluginsModalId = selected.id)} + onDelete={() => void deleteSelected()} + onClose={deselect} + /> +{/if} + +{#if resizing && resizingEntry} + { + if (resizing) resizing = { id: resizing.id, span }; + }} + onCommit={commitResize} + onCancel={cancelResize} + /> +{/if} + +{#if detailsFor} + (detailsForId = null)} /> +{/if} + +{#if pluginsModalFor && plugins} + (pluginsModalId = null)} + /> +{/if} + {#if managerOpen && plugins} (managerOpen = 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..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 { + 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 => list), + // Create-on-miss: the backend persists the new workspace, so the next + // `list` serves it too. + ensure: vi.fn(async (id: string): Promise> => { + 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> => { + // 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 & { + 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"]); + }); +}); -- cgit v1.2.3