diff options
Diffstat (limited to 'src/features/workspaces/ui')
| -rw-r--r-- | src/features/workspaces/ui/WorkspaceCard.svelte | 180 | ||||
| -rw-r--r-- | src/features/workspaces/ui/WorkspaceCard.test.ts | 121 | ||||
| -rw-r--r-- | src/features/workspaces/ui/WorkspacesHome.svelte | 86 |
3 files changed, 387 insertions, 0 deletions
diff --git a/src/features/workspaces/ui/WorkspaceCard.svelte b/src/features/workspaces/ui/WorkspaceCard.svelte new file mode 100644 index 0000000..6348bb9 --- /dev/null +++ b/src/features/workspaces/ui/WorkspaceCard.svelte @@ -0,0 +1,180 @@ +<script lang="ts"> + import type { WorkspaceEntry } from "@dispatch/wire"; + import { untrack } from "svelte"; + import type { WorkspaceStore } from "../store.svelte"; + import { relativeTime } from "../logic/view-model"; + import { workspacePath } from "../logic/route"; + + let { + ws, + store, + onNavigate, + }: { + ws: WorkspaceEntry; + store: WorkspaceStore; + onNavigate: (path: string) => void; + } = $props(); + + // ── Title: double-click to rename inline ────────────────────────────────── + let editingTitle = $state(false); + let titleDraft = $state(""); + let titleInput = $state<HTMLInputElement | undefined>(); + let titleError = $state<string | null>(null); + + function startEditTitle(): void { + titleDraft = ws.title; + titleError = null; + editingTitle = true; + queueMicrotask(() => titleInput?.focus()); + } + + async function saveTitle(): Promise<void> { + if (!editingTitle) return; + const title = titleDraft.trim(); + editingTitle = false; + if (title === "" || title === ws.title) return; + const result = await store.rename(ws.id, title); + if (!result.ok) titleError = result.error; + } + + function cancelTitle(): void { + editingTitle = false; + titleError = null; + } + + // ── Default cwd: inline input ───────────────────────────────────────────── + let cwdDraft = $state(untrack(() => ws.defaultCwd ?? "")); + // Reseed when the backend value changes (e.g., after a save or an external + // refresh). Mid-edit (same value) does NOT re-run, so typing is never clobbered. + $effect(() => { + cwdDraft = ws.defaultCwd ?? ""; + }); + + const cwdDirty = $derived(cwdDraft.trim() !== (ws.defaultCwd ?? "")); + let savingCwd = $state(false); + let cwdError = $state<string | null>(null); + + async function saveCwd(): Promise<void> { + if (!cwdDirty || savingCwd) return; + savingCwd = true; + cwdError = null; + const cwd = cwdDraft.trim(); + const result = await store.setDefaultCwd(ws.id, cwd === "" ? null : cwd); + savingCwd = false; + if (!result.ok) cwdError = result.error; + } + + // ── Delete ───────────────────────────────────────────────────────────────── + let deleting = $state(false); + + async function handleDelete(): Promise<void> { + if ( + !window.confirm( + `Delete workspace "${ws.title}"? Its conversations will be closed and moved to "default".`, + ) + ) { + return; + } + deleting = true; + await store.remove(ws.id); + deleting = false; + } + + function open(): void { + onNavigate(workspacePath(ws.id)); + } +</script> + +<li class="flex flex-col gap-2 rounded-box border border-primary bg-primary/10 p-3"> + <div class="flex items-center gap-2"> + {#if editingTitle} + <input + bind:this={titleInput} + bind:value={titleDraft} + class="input input-bordered input-sm flex-1" + aria-label="Workspace title" + onkeydown={(e) => { + if (e.key === "Enter") saveTitle(); + else if (e.key === "Escape") cancelTitle(); + }} + onblur={saveTitle} + /> + {:else} + <!-- svelte-ignore a11y_no_static_element_interactions, a11y_click_events_have_key_events --> + <span + class="flex-1 cursor-default truncate font-semibold" + title="Double-click to rename" + ondblclick={startEditTitle} + >{ws.title}</span> + {/if} + <span class="font-mono text-xs opacity-50">/{ws.id}</span> + <span class="ml-auto text-xs opacity-50"> + {ws.conversationCount} + {ws.conversationCount === 1 ? "conversation" : "conversations"} + · {relativeTime(ws.lastActivityAt, Date.now())} + </span> + <button + type="button" + class="btn btn-ghost btn-xs" + disabled={deleting} + title="Delete workspace" + aria-label="Delete workspace" + onclick={handleDelete} + > + {#if deleting} + <span class="loading loading-spinner loading-xs"></span> + {:else} + ✕ + {/if} + </button> + </div> + + {#if titleError} + <p class="text-xs text-error">{titleError}</p> + {/if} + + <div class="flex items-center gap-2"> + <span class="w-8 shrink-0 text-xs opacity-60">cwd</span> + <input + type="text" + class="input input-bordered input-sm flex-1 font-mono text-xs" + placeholder="inherits the server default" + bind:value={cwdDraft} + aria-label="Default working directory" + onkeydown={(e) => { + if (e.key === "Enter") saveCwd(); + }} + /> + <button + type="button" + class="btn btn-primary btn-xs" + disabled={!cwdDirty || savingCwd} + onclick={saveCwd} + > + {#if savingCwd} + <span class="loading loading-spinner loading-xs"></span> + {:else} + Set + {/if} + </button> + </div> + + <div class="flex justify-start"> + <a + class="btn" + href={workspacePath(ws.id)} + onclick={(e) => { + e.preventDefault(); + open(); + }} + > + Open + </a> + </div> + + {#if cwdError} + <p class="text-xs text-error">{cwdError}</p> + {:else if !cwdDirty && !ws.defaultCwd} + <p class="text-xs opacity-50">No default cwd set — conversations inherit the server default.</p> + {/if} +</li> diff --git a/src/features/workspaces/ui/WorkspaceCard.test.ts b/src/features/workspaces/ui/WorkspaceCard.test.ts new file mode 100644 index 0000000..385856d --- /dev/null +++ b/src/features/workspaces/ui/WorkspaceCard.test.ts @@ -0,0 +1,121 @@ +import type { WorkspaceEntry } from "@dispatch/wire"; +import { render, screen } from "@testing-library/svelte"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import type { WorkspaceResult } from "../adapter/http"; +import type { WorkspaceStore } from "../store.svelte"; +import WorkspaceCard from "./WorkspaceCard.svelte"; + +function fakeEntry(overrides: Partial<WorkspaceEntry> = {}): WorkspaceEntry { + return { + id: "my-ws", + title: "My Workspace", + defaultCwd: null, + createdAt: 1, + lastActivityAt: 2, + conversationCount: 3, + ...overrides, + }; +} + +/** A fake store that records calls + resolves ok. */ +function fakeStore() { + return { + rename: vi.fn( + async (id: string, _title: string): Promise<WorkspaceResult<WorkspaceEntry>> => ({ + ok: true, + value: fakeEntry({ id, title: _title }), + }), + ), + setDefaultCwd: vi.fn( + async (id: string, defaultCwd: string | null): Promise<WorkspaceResult<WorkspaceEntry>> => ({ + ok: true, + value: fakeEntry({ id, defaultCwd }), + }), + ), + remove: vi.fn( + async (): Promise<WorkspaceResult<{ closedCount: number }>> => ({ + ok: true, + value: { closedCount: 0 }, + }), + ), + }; +} + +describe("WorkspaceCard", () => { + it("renders the title, slug, and an Open link", () => { + const store = fakeStore() as unknown as WorkspaceStore; + const onNavigate = vi.fn(); + render(WorkspaceCard, { + props: { ws: fakeEntry(), store, onNavigate }, + }); + 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() } }); + + 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() } }); + + 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() }, + }); + + 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() }, + }); + + 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 calls onNavigate with the workspace path (no full-card nav)", async () => { + const user = userEvent.setup(); + const store = fakeStore() as unknown as WorkspaceStore; + const onNavigate = vi.fn(); + render(WorkspaceCard, { props: { ws: fakeEntry(), store, onNavigate } }); + + await user.click(screen.getByRole("link", { name: "Open" })); + expect(onNavigate).toHaveBeenCalledWith("/my-ws"); + }); +}); diff --git a/src/features/workspaces/ui/WorkspacesHome.svelte b/src/features/workspaces/ui/WorkspacesHome.svelte new file mode 100644 index 0000000..5894f0a --- /dev/null +++ b/src/features/workspaces/ui/WorkspacesHome.svelte @@ -0,0 +1,86 @@ +<script lang="ts"> + import { onMount } from "svelte"; + import type { WorkspaceStore } from "../store.svelte"; + import { isValidSlug, workspacePath } from "../logic/route"; + import WorkspaceCard from "./WorkspaceCard.svelte"; + + let { store, onNavigate }: { store: WorkspaceStore; onNavigate: (path: string) => void } = $props(); + + onMount(() => { + void store.refresh(); + }); + + let newSlug = $state(""); + let slugError = $state<string | null>(null); + + const slugValid = $derived(newSlug.length > 0 && isValidSlug(newSlug)); + + function createWorkspace(): void { + const slug = newSlug.trim(); + if (!isValidSlug(slug)) { + slugError = "Lowercase letters, digits, and hyphens (1–40 chars)."; + return; + } + slugError = null; + newSlug = ""; + onNavigate(workspacePath(slug)); + } +</script> + +<div class="mx-auto flex h-screen w-full max-w-3xl flex-col gap-4 p-6"> + <header class="flex items-center justify-between"> + <h1 class="text-2xl font-bold">Workspaces</h1> + <a + href="/default" + class="btn btn-ghost btn-sm" + onclick={(e) => { + e.preventDefault(); + onNavigate("/default"); + }} + > + Default + </a> + </header> + + <form + class="flex items-end gap-2" + onsubmit={(e) => { + e.preventDefault(); + createWorkspace(); + }} + > + <div class="flex-1"> + <label for="new-ws" class="mb-1 block text-xs font-semibold uppercase opacity-60">New workspace</label> + <input + id="new-ws" + class="input input-bordered w-full" + placeholder="my-workspace" + bind:value={newSlug} + autocomplete="off" + spellcheck="false" + /> + </div> + <button type="submit" class="btn btn-primary btn-sm" disabled={!slugValid}>Create</button> + </form> + {#if slugError} + <p class="text-xs text-error">{slugError}</p> + {/if} + + <div class="flex-1 overflow-y-auto"> + {#if store.loading && store.list.length === 0} + <div class="flex h-32 items-center justify-center"> + <span class="loading loading-spinner loading-sm opacity-60"></span> + </div> + {:else if store.list.length === 0} + <p class="py-8 text-center text-sm opacity-60"> + No workspaces yet. Create one above or visit <code>/your-name</code> in the URL. + </p> + {:else} + <ul class="flex flex-col gap-2"> + {#each store.list as ws (ws.id)} + <WorkspaceCard {ws} {store} {onNavigate} /> + {/each} + </ul> + {/if} + </div> +</div> |
