diff options
| author | Adam Malczewski <[email protected]> | 2026-09-07 11:47:18 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-09-07 11:47:18 +0900 |
| commit | 754450b539818f9ad94c9586888973b0edd74b68 (patch) | |
| tree | d852f9a8910f223e15a5e11ad04868b0d0a29804 /src/features/workspaces/ui/WorkspaceDetails.test.ts | |
| parent | 719f0038dbb09f0fe9b073c3dc8e4f403815223d (diff) | |
| download | dispatch-web-754450b539818f9ad94c9586888973b0edd74b68.tar.gz dispatch-web-754450b539818f9ad94c9586888973b0edd74b68.zip | |
feat(workspaces): Win8 Start-style tile-grid home page
Rebuild the workspace home as a Windows 8 Start-screen-style tile grid,
replacing the max-w-3xl vertical card list.
Pure core (zero DOM/Svelte/fetch, fully unit-tested):
- logic/tile-layout.ts — geometryFor(width, height) resolves WIDE (4 fixed
~120px rows, column-wise flow, 4x2 span cap) or TALL (2 fixed columns,
row-wise flow, 2x2 cap); pack() is the ONLY order->coordinates transform
(greedy first-fit in fill order, hole-free, render-clamped per geometry);
firstEmptySlot/insertAtFirstEmptySlot/mergeLayout/resizeTile/moveTile/
sortByRecentActivity/sanitizeLayout. Persisted data is
orientation-independent {version:1, tiles:[{id,colSpan,rowSpan,order}]} —
order + spans only, never positions; the same data renders in either
geometry (spans clamp for the render, persisted values untouched).
- logic/drag-controller.ts — pure pointer/press state machine: mouse
threshold-drag + tap-open; touch long-press lifts + selects, pre-lift
movement cancels so vertical scrolling never fights the gesture.
Shell:
- tile-layout-store.svelte.ts — thin reactive wrapper persisting through
the injected createLocalStore adapter (dispatch.workspaces.tiles), ops:
sync/resize/move/placeFirstEmpty/sortByRecentActivity.
- ui/HomeTileGrid.svelte — CSS-grid tile canvas (wide: horizontal scroll +
snap + wheel pan + pan arrow + scroll indicator; tall: native vertical
scroll), FLIP-animated reflow, drag ghost, transient resize preview.
- ui/HomeHeader.svelte — fixed chrome: Dispatch display title, New
workspace popover (same slug validation; creates via create-on-miss and
does NOT navigate — the new tile lands in the first empty slot in fill
order and enters rename mode), global Plugins button, sort-by-recent
overflow; compact icon buttons + bottom sheet on tall screens.
- ui/WorkspaceTile.svelte — tile face (title, /slug, conversation count,
relative last-activity, live active dots, starred tint); inline rename
with the old card's semantics.
- ui/TileActionBar.svelte — slides up on select (right-click / long-press):
Open, Rename, Resize, Star/Unstar, Details, Plugins, Delete (old confirm
+ store call preserved).
- ui/ResizePicker.svelte — Word-style insert-table hover grid (wide 4x2 /
tall 2x2) with arrow/Enter/Escape support and ghost preview on commit.
- ui/WorkspaceDetails.svelte — flyout holding the old card's default-cwd
input + default-computer select (same store calls + error states).
- WorkspacesHome.svelte orchestrates; App.svelte untouched (same props).
- WorkspaceCard.svelte is removed (replaced by the tile system).
Manual tile order rules; starred workspaces get a tint (star is no longer
a sort key); workspaces unknown to the layout append at the end.
vitest: jsdom gains PointerEvent (MouseEvent-based) + Element.animate
stubs — the pointer logic is the outermost edge for the drag tests.
Verification: svelte-check 0/0, vitest 1655 passed (102 files, twice),
biome clean, vite build OK.
Diffstat (limited to 'src/features/workspaces/ui/WorkspaceDetails.test.ts')
| -rw-r--r-- | src/features/workspaces/ui/WorkspaceDetails.test.ts | 114 |
1 files changed, 114 insertions, 0 deletions
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> = {}): 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<WorkspaceResult<WorkspaceEntry>> => ({ + ok: true, + value: fakeEntry({ id, defaultCwd }), + }), + ), + setDefaultComputer: vi.fn( + async ( + id: string, + defaultComputerId: string | null, + ): Promise<WorkspaceResult<WorkspaceEntry>> => ({ + ok: true, + value: fakeEntry({ id, defaultComputerId }), + }), + ), + } as unknown as WorkspaceStore; +} + +describe("WorkspaceDetails", () => { + it("renders the title, slug, cwd input, and computer select", () => { + render(WorkspaceDetails, { + props: { ws: fakeEntry(), store: fakeStore(), computers: [], onClose: () => {} }, + }); + expect(screen.getByText("My Workspace")).toBeInTheDocument(); + expect(screen.getByText("/my-ws")).toBeInTheDocument(); + expect(screen.getByLabelText("Default working directory")).toBeInTheDocument(); + expect(screen.getByLabelText("Computer")).toBeInTheDocument(); + }); + + it("saves the cwd via the store; an empty value clears to null", async () => { + const user = userEvent.setup(); + const store = fakeStore(); + const onClose = vi.fn(); + render(WorkspaceDetails, { + props: { ws: fakeEntry({ defaultCwd: "/old" }), store, computers: [], onClose }, + }); + + const input = screen.getByLabelText("Default working directory"); + await user.clear(input); + await user.click(screen.getByRole("button", { name: "Set" })); + expect(store.setDefaultCwd).toHaveBeenCalledWith("my-ws", null); + }); + + it("a failed cwd save surfaces the error", async () => { + const user = userEvent.setup(); + const store = fakeStore(); + store.setDefaultCwd = vi.fn( + async (): Promise<WorkspaceResult<WorkspaceEntry>> => ({ ok: false, error: "nope" }), + ); + render(WorkspaceDetails, { + props: { ws: fakeEntry({ defaultCwd: "/old" }), store, computers: [], onClose: () => {} }, + }); + + await user.type(screen.getByLabelText("Default working directory"), "!{Enter}"); + expect(await screen.findByText("nope")).toBeInTheDocument(); + }); + + it("picking a computer saves it via the store", async () => { + const user = userEvent.setup(); + const store = fakeStore(); + const computers: ComputerEntry[] = [ + { + alias: "lab", + hostName: "lab.example", + port: 22, + user: "me", + identityFile: null, + knownHost: true, + usageCount: 0, + }, + ]; + render(WorkspaceDetails, { + props: { ws: fakeEntry(), store, computers, onClose: () => {} }, + }); + + await user.selectOptions(screen.getByLabelText("Computer"), "lab"); + expect(store.setDefaultComputer).toHaveBeenCalledWith("my-ws", "lab"); + }); + + it("the close button dismisses the dialog", async () => { + const user = userEvent.setup(); + const onClose = vi.fn(); + render(WorkspaceDetails, { + props: { ws: fakeEntry(), store: fakeStore(), computers: [], onClose }, + }); + + await user.click(screen.getByRole("button", { name: "Close details" })); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); |
