diff options
Diffstat (limited to 'src/features/workspaces')
| -rw-r--r-- | src/features/workspaces/adapter/http.test.ts | 224 | ||||
| -rw-r--r-- | src/features/workspaces/adapter/http.ts | 242 | ||||
| -rw-r--r-- | src/features/workspaces/index.ts | 14 | ||||
| -rw-r--r-- | src/features/workspaces/logic/route.test.ts | 112 | ||||
| -rw-r--r-- | src/features/workspaces/logic/route.ts | 26 | ||||
| -rw-r--r-- | src/features/workspaces/logic/view-model.test.ts | 98 | ||||
| -rw-r--r-- | src/features/workspaces/logic/view-model.ts | 32 | ||||
| -rw-r--r-- | src/features/workspaces/store.svelte.ts | 132 | ||||
| -rw-r--r-- | src/features/workspaces/ui/WorkspaceCard.svelte | 407 | ||||
| -rw-r--r-- | src/features/workspaces/ui/WorkspaceCard.test.ts | 234 | ||||
| -rw-r--r-- | src/features/workspaces/ui/WorkspacesHome.svelte | 165 |
11 files changed, 842 insertions, 844 deletions
diff --git a/src/features/workspaces/adapter/http.test.ts b/src/features/workspaces/adapter/http.test.ts index adaff00..19e53f8 100644 --- a/src/features/workspaces/adapter/http.test.ts +++ b/src/features/workspaces/adapter/http.test.ts @@ -3,131 +3,131 @@ import { createWorkspaceHttp } from "./http"; /** Build a fake `fetch` returning a canned Response. */ function fakeFetch(responses: Array<{ status?: number; body?: unknown } | Error>): typeof fetch { - let i = 0; - return vi.fn(async () => { - const next = responses[i++]; - if (next === undefined) throw new Error("fakeFetch: no more canned responses"); - if (next instanceof Error) throw next; - const status = next.status ?? 200; - const body = next.body; - return { - ok: status >= 200 && status < 300, - status, - async json() { - return body; - }, - } as Response; - }) as unknown as typeof fetch; + let i = 0; + return vi.fn(async () => { + const next = responses[i++]; + if (next === undefined) throw new Error("fakeFetch: no more canned responses"); + if (next instanceof Error) throw next; + const status = next.status ?? 200; + const body = next.body; + return { + ok: status >= 200 && status < 300, + status, + async json() { + return body; + }, + } as Response; + }) as unknown as typeof fetch; } const BASE = "http://x"; describe("createWorkspaceHttp", () => { - it("list returns the workspaces", async () => { - const fetchImpl = fakeFetch([ - { - body: { - workspaces: [ - { - id: "a", - title: "A", - defaultCwd: null, - createdAt: 1, - lastActivityAt: 2, - conversationCount: 3, - }, - ], - }, - }, - ]); - const http = createWorkspaceHttp(BASE, fetchImpl); - const list = await http.list(); - expect(list).toHaveLength(1); - expect(list[0]?.id).toBe("a"); - expect(list[0]?.conversationCount).toBe(3); - expect(fetchImpl).toHaveBeenCalledWith(`${BASE}/workspaces`); - }); + it("list returns the workspaces", async () => { + const fetchImpl = fakeFetch([ + { + body: { + workspaces: [ + { + id: "a", + title: "A", + defaultCwd: null, + createdAt: 1, + lastActivityAt: 2, + conversationCount: 3, + }, + ], + }, + }, + ]); + const http = createWorkspaceHttp(BASE, fetchImpl); + const list = await http.list(); + expect(list).toHaveLength(1); + expect(list[0]?.id).toBe("a"); + expect(list[0]?.conversationCount).toBe(3); + expect(fetchImpl).toHaveBeenCalledWith(`${BASE}/workspaces`); + }); - it("list returns [] on a failed response (non-fatal)", async () => { - const http = createWorkspaceHttp(BASE, fakeFetch([{ status: 500 }])); - expect(await http.list()).toEqual([]); - }); + it("list returns [] on a failed response (non-fatal)", async () => { + const http = createWorkspaceHttp(BASE, fakeFetch([{ status: 500 }])); + expect(await http.list()).toEqual([]); + }); - it("list returns [] on a network error", async () => { - const http = createWorkspaceHttp(BASE, fakeFetch([new Error("network")])); - expect(await http.list()).toEqual([]); - }); + it("list returns [] on a network error", async () => { + const http = createWorkspaceHttp(BASE, fakeFetch([new Error("network")])); + expect(await http.list()).toEqual([]); + }); - it("ensure PUTs the id + returns the workspace", async () => { - const ws = { id: "my-ws", title: "my-ws", defaultCwd: null, createdAt: 10, lastActivityAt: 10 }; - const fetchImpl = fakeFetch([{ body: ws }]); - const http = createWorkspaceHttp(BASE, fetchImpl); - const result = await http.ensure("my-ws"); - expect(result).toEqual({ ok: true, value: ws }); - expect(fetchImpl).toHaveBeenCalledWith( - `${BASE}/workspaces/my-ws`, - expect.objectContaining({ method: "PUT" }), - ); - }); + it("ensure PUTs the id + returns the workspace", async () => { + const ws = { id: "my-ws", title: "my-ws", defaultCwd: null, createdAt: 10, lastActivityAt: 10 }; + const fetchImpl = fakeFetch([{ body: ws }]); + const http = createWorkspaceHttp(BASE, fetchImpl); + const result = await http.ensure("my-ws"); + expect(result).toEqual({ ok: true, value: ws }); + expect(fetchImpl).toHaveBeenCalledWith( + `${BASE}/workspaces/my-ws`, + expect.objectContaining({ method: "PUT" }), + ); + }); - it("ensure surfaces the backend error on a 400 (invalid slug)", async () => { - const http = createWorkspaceHttp( - BASE, - fakeFetch([{ status: 400, body: { error: "invalid slug" } }]), - ); - const result = await http.ensure("UPPER"); - expect(result).toEqual({ ok: false, error: "invalid slug" }); - }); + it("ensure surfaces the backend error on a 400 (invalid slug)", async () => { + const http = createWorkspaceHttp( + BASE, + fakeFetch([{ status: 400, body: { error: "invalid slug" } }]), + ); + const result = await http.ensure("UPPER"); + expect(result).toEqual({ ok: false, error: "invalid slug" }); + }); - it("get returns null on 404", async () => { - const http = createWorkspaceHttp(BASE, fakeFetch([{ status: 404 }])); - expect(await http.get("nope")).toBeNull(); - }); + it("get returns null on 404", async () => { + const http = createWorkspaceHttp(BASE, fakeFetch([{ status: 404 }])); + expect(await http.get("nope")).toBeNull(); + }); - it("get returns the workspace on 200", async () => { - const ws = { id: "x", title: "X", defaultCwd: "/home", createdAt: 1, lastActivityAt: 2 }; - const http = createWorkspaceHttp(BASE, fakeFetch([{ body: ws }])); - expect(await http.get("x")).toEqual(ws); - }); + it("get returns the workspace on 200", async () => { + const ws = { id: "x", title: "X", defaultCwd: "/home", createdAt: 1, lastActivityAt: 2 }; + const http = createWorkspaceHttp(BASE, fakeFetch([{ body: ws }])); + expect(await http.get("x")).toEqual(ws); + }); - it("setTitle PUTs the title", async () => { - const ws = { id: "a", title: "Renamed", defaultCwd: null, createdAt: 1, lastActivityAt: 2 }; - const fetchImpl = fakeFetch([{ body: ws }]); - const http = createWorkspaceHttp(BASE, fetchImpl); - const result = await http.setTitle("a", "Renamed"); - expect(result).toEqual({ ok: true, value: ws }); - const call = (fetchImpl as unknown as ReturnType<typeof vi.fn>).mock.calls[0]; - expect(call?.[0]).toBe(`${BASE}/workspaces/a/title`); - expect(JSON.parse(call?.[1]?.body)).toEqual({ title: "Renamed" }); - }); + it("setTitle PUTs the title", async () => { + const ws = { id: "a", title: "Renamed", defaultCwd: null, createdAt: 1, lastActivityAt: 2 }; + const fetchImpl = fakeFetch([{ body: ws }]); + const http = createWorkspaceHttp(BASE, fetchImpl); + const result = await http.setTitle("a", "Renamed"); + expect(result).toEqual({ ok: true, value: ws }); + const call = (fetchImpl as unknown as ReturnType<typeof vi.fn>).mock.calls[0]; + expect(call?.[0]).toBe(`${BASE}/workspaces/a/title`); + expect(JSON.parse(call?.[1]?.body)).toEqual({ title: "Renamed" }); + }); - it("setDefaultCwd PUTs null to clear", async () => { - const ws = { id: "a", title: "A", defaultCwd: null, createdAt: 1, lastActivityAt: 2 }; - const fetchImpl = fakeFetch([{ body: ws }]); - const http = createWorkspaceHttp(BASE, fetchImpl); - await http.setDefaultCwd("a", null); - const call = (fetchImpl as unknown as ReturnType<typeof vi.fn>).mock.calls[0]; - expect(call?.[0]).toBe(`${BASE}/workspaces/a/default-cwd`); - expect(JSON.parse(call?.[1]?.body)).toEqual({ defaultCwd: null }); - }); + it("setDefaultCwd PUTs null to clear", async () => { + const ws = { id: "a", title: "A", defaultCwd: null, createdAt: 1, lastActivityAt: 2 }; + const fetchImpl = fakeFetch([{ body: ws }]); + const http = createWorkspaceHttp(BASE, fetchImpl); + await http.setDefaultCwd("a", null); + const call = (fetchImpl as unknown as ReturnType<typeof vi.fn>).mock.calls[0]; + expect(call?.[0]).toBe(`${BASE}/workspaces/a/default-cwd`); + expect(JSON.parse(call?.[1]?.body)).toEqual({ defaultCwd: null }); + }); - it("delete returns closedCount", async () => { - const fetchImpl = fakeFetch([{ body: { workspaceId: "a", closedCount: 4 } }]); - const http = createWorkspaceHttp(BASE, fetchImpl); - const result = await http.delete("a"); - expect(result).toEqual({ ok: true, value: { closedCount: 4 } }); - expect(fetchImpl).toHaveBeenCalledWith( - `${BASE}/workspaces/a`, - expect.objectContaining({ method: "DELETE" }), - ); - }); + it("delete returns closedCount", async () => { + const fetchImpl = fakeFetch([{ body: { workspaceId: "a", closedCount: 4 } }]); + const http = createWorkspaceHttp(BASE, fetchImpl); + const result = await http.delete("a"); + expect(result).toEqual({ ok: true, value: { closedCount: 4 } }); + expect(fetchImpl).toHaveBeenCalledWith( + `${BASE}/workspaces/a`, + expect.objectContaining({ method: "DELETE" }), + ); + }); - it("delete surfaces 409 for 'default'", async () => { - const http = createWorkspaceHttp( - BASE, - fakeFetch([{ status: 409, body: { error: "cannot delete default" } }]), - ); - const result = await http.delete("default"); - expect(result).toEqual({ ok: false, error: "cannot delete default" }); - }); + it("delete surfaces 409 for 'default'", async () => { + const http = createWorkspaceHttp( + BASE, + fakeFetch([{ status: 409, body: { error: "cannot delete default" } }]), + ); + const result = await http.delete("default"); + expect(result).toEqual({ ok: false, error: "cannot delete default" }); + }); }); diff --git a/src/features/workspaces/adapter/http.ts b/src/features/workspaces/adapter/http.ts index 6ebfee1..01fe677 100644 --- a/src/features/workspaces/adapter/http.ts +++ b/src/features/workspaces/adapter/http.ts @@ -1,13 +1,13 @@ import type { - DeleteWorkspaceResponse, - EnsureWorkspaceRequest, - SetWorkspaceDefaultComputerRequest, - SetWorkspaceDefaultCwdRequest, - SetWorkspaceTitleRequest, - Workspace, - WorkspaceEntry, - WorkspaceListResponse, - WorkspaceResponse, + DeleteWorkspaceResponse, + EnsureWorkspaceRequest, + SetWorkspaceDefaultComputerRequest, + SetWorkspaceDefaultCwdRequest, + SetWorkspaceTitleRequest, + Workspace, + WorkspaceEntry, + WorkspaceListResponse, + WorkspaceResponse, } from "@dispatch/transport-contract"; /** @@ -28,130 +28,130 @@ import type { * - `DELETE /workspaces/:id` (409 for "default") → delete */ export type WorkspaceResult<T> = - | { readonly ok: true; readonly value: T } - | { readonly ok: false; readonly error: string }; + | { readonly ok: true; readonly value: T } + | { readonly ok: false; readonly error: string }; export interface WorkspaceHttp { - list(): Promise<readonly WorkspaceEntry[]>; - ensure(id: string, body?: EnsureWorkspaceRequest): Promise<WorkspaceResult<Workspace>>; - get(id: string): Promise<Workspace | null>; - setTitle(id: string, title: string): Promise<WorkspaceResult<Workspace>>; - setDefaultCwd(id: string, defaultCwd: string | null): Promise<WorkspaceResult<Workspace>>; - setDefaultComputer(id: string, computerId: string | null): Promise<WorkspaceResult<Workspace>>; - delete(id: string): Promise<WorkspaceResult<{ closedCount: number }>>; + list(): Promise<readonly WorkspaceEntry[]>; + ensure(id: string, body?: EnsureWorkspaceRequest): Promise<WorkspaceResult<Workspace>>; + get(id: string): Promise<Workspace | null>; + setTitle(id: string, title: string): Promise<WorkspaceResult<Workspace>>; + setDefaultCwd(id: string, defaultCwd: string | null): Promise<WorkspaceResult<Workspace>>; + setDefaultComputer(id: string, computerId: string | null): Promise<WorkspaceResult<Workspace>>; + delete(id: string): Promise<WorkspaceResult<{ closedCount: number }>>; } async function errText(res: Response): Promise<string> { - try { - const body = (await res.json()) as { error?: string }; - return body.error ?? `HTTP ${res.status}`; - } catch { - return `HTTP ${res.status}`; - } + try { + const body = (await res.json()) as { error?: string }; + return body.error ?? `HTTP ${res.status}`; + } catch { + return `HTTP ${res.status}`; + } } export function createWorkspaceHttp(httpBase: string, fetchImpl: typeof fetch): WorkspaceHttp { - return { - async list(): Promise<readonly WorkspaceEntry[]> { - try { - const res = await fetchImpl(`${httpBase}/workspaces`); - if (!res.ok) return []; - const data = (await res.json()) as WorkspaceListResponse; - return data.workspaces; - } catch { - return []; - } - }, + return { + async list(): Promise<readonly WorkspaceEntry[]> { + try { + const res = await fetchImpl(`${httpBase}/workspaces`); + if (!res.ok) return []; + const data = (await res.json()) as WorkspaceListResponse; + return data.workspaces; + } catch { + return []; + } + }, - async ensure(id, body): Promise<WorkspaceResult<Workspace>> { - try { - const res = await fetchImpl(`${httpBase}/workspaces/${encodeURIComponent(id)}`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body ?? {}), - }); - if (!res.ok) return { ok: false, error: await errText(res) }; - return { ok: true, value: (await res.json()) as WorkspaceResponse }; - } catch (err) { - return { - ok: false, - error: err instanceof Error ? err.message : "Workspace request failed", - }; - } - }, + async ensure(id, body): Promise<WorkspaceResult<Workspace>> { + try { + const res = await fetchImpl(`${httpBase}/workspaces/${encodeURIComponent(id)}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body ?? {}), + }); + if (!res.ok) return { ok: false, error: await errText(res) }; + return { ok: true, value: (await res.json()) as WorkspaceResponse }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Workspace request failed", + }; + } + }, - async get(id): Promise<Workspace | null> { - try { - const res = await fetchImpl(`${httpBase}/workspaces/${encodeURIComponent(id)}`); - if (res.status === 404 || !res.ok) return null; - return (await res.json()) as WorkspaceResponse; - } catch { - return null; - } - }, + async get(id): Promise<Workspace | null> { + try { + const res = await fetchImpl(`${httpBase}/workspaces/${encodeURIComponent(id)}`); + if (res.status === 404 || !res.ok) return null; + return (await res.json()) as WorkspaceResponse; + } catch { + return null; + } + }, - async setTitle(id, title): Promise<WorkspaceResult<Workspace>> { - try { - const res = await fetchImpl(`${httpBase}/workspaces/${encodeURIComponent(id)}/title`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ title } satisfies SetWorkspaceTitleRequest), - }); - if (!res.ok) return { ok: false, error: await errText(res) }; - return { ok: true, value: (await res.json()) as WorkspaceResponse }; - } catch (err) { - return { ok: false, error: err instanceof Error ? err.message : "Rename failed" }; - } - }, + async setTitle(id, title): Promise<WorkspaceResult<Workspace>> { + try { + const res = await fetchImpl(`${httpBase}/workspaces/${encodeURIComponent(id)}/title`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title } satisfies SetWorkspaceTitleRequest), + }); + if (!res.ok) return { ok: false, error: await errText(res) }; + return { ok: true, value: (await res.json()) as WorkspaceResponse }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : "Rename failed" }; + } + }, - async setDefaultCwd(id, defaultCwd): Promise<WorkspaceResult<Workspace>> { - try { - const res = await fetchImpl( - `${httpBase}/workspaces/${encodeURIComponent(id)}/default-cwd`, - { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ defaultCwd } satisfies SetWorkspaceDefaultCwdRequest), - }, - ); - if (!res.ok) return { ok: false, error: await errText(res) }; - return { ok: true, value: (await res.json()) as WorkspaceResponse }; - } catch (err) { - return { ok: false, error: err instanceof Error ? err.message : "Set default cwd failed" }; - } - }, + async setDefaultCwd(id, defaultCwd): Promise<WorkspaceResult<Workspace>> { + try { + const res = await fetchImpl( + `${httpBase}/workspaces/${encodeURIComponent(id)}/default-cwd`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ defaultCwd } satisfies SetWorkspaceDefaultCwdRequest), + }, + ); + if (!res.ok) return { ok: false, error: await errText(res) }; + return { ok: true, value: (await res.json()) as WorkspaceResponse }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : "Set default cwd failed" }; + } + }, - async setDefaultComputer(id, computerId): Promise<WorkspaceResult<Workspace>> { - try { - const res = await fetchImpl( - `${httpBase}/workspaces/${encodeURIComponent(id)}/default-computer`, - { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ computerId } satisfies SetWorkspaceDefaultComputerRequest), - }, - ); - if (!res.ok) return { ok: false, error: await errText(res) }; - return { ok: true, value: (await res.json()) as WorkspaceResponse }; - } catch (err) { - return { - ok: false, - error: err instanceof Error ? err.message : "Set default computer failed", - }; - } - }, + async setDefaultComputer(id, computerId): Promise<WorkspaceResult<Workspace>> { + try { + const res = await fetchImpl( + `${httpBase}/workspaces/${encodeURIComponent(id)}/default-computer`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ computerId } satisfies SetWorkspaceDefaultComputerRequest), + }, + ); + if (!res.ok) return { ok: false, error: await errText(res) }; + return { ok: true, value: (await res.json()) as WorkspaceResponse }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Set default computer failed", + }; + } + }, - async delete(id): Promise<WorkspaceResult<{ closedCount: number }>> { - try { - const res = await fetchImpl(`${httpBase}/workspaces/${encodeURIComponent(id)}`, { - method: "DELETE", - }); - if (!res.ok) return { ok: false, error: await errText(res) }; - const data = (await res.json()) as DeleteWorkspaceResponse; - return { ok: true, value: { closedCount: data.closedCount } }; - } catch (err) { - return { ok: false, error: err instanceof Error ? err.message : "Delete failed" }; - } - }, - }; + async delete(id): Promise<WorkspaceResult<{ closedCount: number }>> { + try { + const res = await fetchImpl(`${httpBase}/workspaces/${encodeURIComponent(id)}`, { + method: "DELETE", + }); + if (!res.ok) return { ok: false, error: await errText(res) }; + const data = (await res.json()) as DeleteWorkspaceResponse; + return { ok: true, value: { closedCount: data.closedCount } }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : "Delete failed" }; + } + }, + }; } diff --git a/src/features/workspaces/index.ts b/src/features/workspaces/index.ts index 6cc86a3..177e49b 100644 --- a/src/features/workspaces/index.ts +++ b/src/features/workspaces/index.ts @@ -2,11 +2,11 @@ export type { WorkspaceHttp, WorkspaceResult } from "./adapter/http"; export { createWorkspaceHttp } from "./adapter/http"; export type { Route } from "./logic/route"; export { - DEFAULT_WORKSPACE_ID, - isValidSlug, - parsePath, - WORKSPACE_SLUG_RE, - workspacePath, + DEFAULT_WORKSPACE_ID, + isValidSlug, + parsePath, + WORKSPACE_SLUG_RE, + workspacePath, } from "./logic/route"; export { pageTitle, relativeTime } from "./logic/view-model"; export type { WorkspaceStore } from "./store.svelte"; @@ -16,6 +16,6 @@ export { default as WorkspacesHome } from "./ui/WorkspacesHome.svelte"; /** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ export const manifest = { - name: "workspaces", - description: "URL-driven conversation grouping with a backend-owned default cwd", + name: "workspaces", + description: "URL-driven conversation grouping with a backend-owned default cwd", } as const; diff --git a/src/features/workspaces/logic/route.test.ts b/src/features/workspaces/logic/route.test.ts index a6da3e1..96e0ff3 100644 --- a/src/features/workspaces/logic/route.test.ts +++ b/src/features/workspaces/logic/route.test.ts @@ -1,77 +1,77 @@ import { describe, expect, it } from "vitest"; import { - DEFAULT_WORKSPACE_ID, - isValidSlug, - parsePath, - WORKSPACE_SLUG_RE, - workspacePath, + DEFAULT_WORKSPACE_ID, + isValidSlug, + parsePath, + WORKSPACE_SLUG_RE, + workspacePath, } from "./route"; describe("parsePath", () => { - it("treats the root path as home", () => { - expect(parsePath("/")).toEqual({ kind: "home" }); - expect(parsePath("")).toEqual({ kind: "home" }); - }); + it("treats the root path as home", () => { + expect(parsePath("/")).toEqual({ kind: "home" }); + expect(parsePath("")).toEqual({ kind: "home" }); + }); - it("trims surrounding slashes", () => { - expect(parsePath("//")).toEqual({ kind: "home" }); - expect(parsePath("/my-ws/")).toEqual({ kind: "workspace", id: "my-ws" }); - }); + it("trims surrounding slashes", () => { + expect(parsePath("//")).toEqual({ kind: "home" }); + expect(parsePath("/my-ws/")).toEqual({ kind: "workspace", id: "my-ws" }); + }); - it("parses a single segment as a workspace id", () => { - expect(parsePath("/default")).toEqual({ kind: "workspace", id: "default" }); - expect(parsePath("/my-workspace")).toEqual({ kind: "workspace", id: "my-workspace" }); - expect(parsePath("/ws1")).toEqual({ kind: "workspace", id: "ws1" }); - }); + it("parses a single segment as a workspace id", () => { + expect(parsePath("/default")).toEqual({ kind: "workspace", id: "default" }); + expect(parsePath("/my-workspace")).toEqual({ kind: "workspace", id: "my-workspace" }); + expect(parsePath("/ws1")).toEqual({ kind: "workspace", id: "ws1" }); + }); - it("takes only the first segment of a deeper path", () => { - expect(parsePath("/foo/bar")).toEqual({ kind: "workspace", id: "foo" }); - expect(parsePath("/foo/bar/baz")).toEqual({ kind: "workspace", id: "foo" }); - }); + it("takes only the first segment of a deeper path", () => { + expect(parsePath("/foo/bar")).toEqual({ kind: "workspace", id: "foo" }); + expect(parsePath("/foo/bar/baz")).toEqual({ kind: "workspace", id: "foo" }); + }); - it("URL-decodes the segment", () => { - expect(parsePath("/my%20ws")).toEqual({ kind: "workspace", id: "my ws" }); - }); + it("URL-decodes the segment", () => { + expect(parsePath("/my%20ws")).toEqual({ kind: "workspace", id: "my ws" }); + }); - it("does not validate the slug — an invalid id is still a workspace route", () => { - expect(parsePath("/UPPER")).toEqual({ kind: "workspace", id: "UPPER" }); - expect(parsePath("/has space")).toEqual({ kind: "workspace", id: "has space" }); - }); + it("does not validate the slug — an invalid id is still a workspace route", () => { + expect(parsePath("/UPPER")).toEqual({ kind: "workspace", id: "UPPER" }); + expect(parsePath("/has space")).toEqual({ kind: "workspace", id: "has space" }); + }); }); describe("isValidSlug", () => { - it("accepts lowercase alphanumeric + internal hyphens", () => { - expect(isValidSlug("default")).toBe(true); - expect(isValidSlug("my-workspace")).toBe(true); - expect(isValidSlug("a")).toBe(true); - expect(isValidSlug("ws-1")).toBe(true); - }); + it("accepts lowercase alphanumeric + internal hyphens", () => { + expect(isValidSlug("default")).toBe(true); + expect(isValidSlug("my-workspace")).toBe(true); + expect(isValidSlug("a")).toBe(true); + expect(isValidSlug("ws-1")).toBe(true); + }); - it("accepts up to 40 chars", () => { - expect(isValidSlug("a".repeat(40))).toBe(true); - }); + it("accepts up to 40 chars", () => { + expect(isValidSlug("a".repeat(40))).toBe(true); + }); - it("rejects empty and too-long", () => { - expect(isValidSlug("")).toBe(false); - expect(isValidSlug("a".repeat(41))).toBe(false); - }); + it("rejects empty and too-long", () => { + expect(isValidSlug("")).toBe(false); + expect(isValidSlug("a".repeat(41))).toBe(false); + }); - it("rejects uppercase, spaces, and leading/trailing hyphens", () => { - expect(isValidSlug("MyWS")).toBe(false); - expect(isValidSlug("has space")).toBe(false); - expect(isValidSlug("-leading")).toBe(false); - expect(isValidSlug("trailing-")).toBe(false); - expect(isValidSlug("double--hyphen")).toBe(true); // internal doubles are allowed by the regex - }); + it("rejects uppercase, spaces, and leading/trailing hyphens", () => { + expect(isValidSlug("MyWS")).toBe(false); + expect(isValidSlug("has space")).toBe(false); + expect(isValidSlug("-leading")).toBe(false); + expect(isValidSlug("trailing-")).toBe(false); + expect(isValidSlug("double--hyphen")).toBe(true); // internal doubles are allowed by the regex + }); - it("WORKSPACE_SLUG_RE matches the default id", () => { - expect(WORKSPACE_SLUG_RE.test(DEFAULT_WORKSPACE_ID)).toBe(true); - }); + it("WORKSPACE_SLUG_RE matches the default id", () => { + expect(WORKSPACE_SLUG_RE.test(DEFAULT_WORKSPACE_ID)).toBe(true); + }); }); describe("workspacePath", () => { - it("builds the URL path for a workspace id", () => { - expect(workspacePath("default")).toBe("/default"); - expect(workspacePath("my-ws")).toBe("/my-ws"); - }); + it("builds the URL path for a workspace id", () => { + expect(workspacePath("default")).toBe("/default"); + expect(workspacePath("my-ws")).toBe("/my-ws"); + }); }); diff --git a/src/features/workspaces/logic/route.ts b/src/features/workspaces/logic/route.ts index e30dc16..015c6d6 100644 --- a/src/features/workspaces/logic/route.ts +++ b/src/features/workspaces/logic/route.ts @@ -30,12 +30,12 @@ export const DEFAULT_WORKSPACE_ID = "default"; * surfaces the error). */ export function parsePath(pathname: string): Route { - const trimmed = pathname.replace(/^\/+|\/+$/g, ""); - if (trimmed === "") return { kind: "home" }; - const first = trimmed.split("/")[0] ?? ""; - const id = safeDecode(first); - if (id === "") return { kind: "home" }; - return { kind: "workspace", id }; + const trimmed = pathname.replace(/^\/+|\/+$/g, ""); + if (trimmed === "") return { kind: "home" }; + const first = trimmed.split("/")[0] ?? ""; + const id = safeDecode(first); + if (id === "") return { kind: "home" }; + return { kind: "workspace", id }; } /** @@ -43,7 +43,7 @@ export function parsePath(pathname: string): Route { * Used by the home view's "new workspace" input before navigating. */ export function isValidSlug(slug: string): boolean { - return WORKSPACE_SLUG_RE.test(slug); + return WORKSPACE_SLUG_RE.test(slug); } /** @@ -51,13 +51,13 @@ export function isValidSlug(slug: string): boolean { * workspace. Pure: id in, path string out. */ export function workspacePath(id: string): string { - return `/${id}`; + return `/${id}`; } function safeDecode(segment: string): string { - try { - return decodeURIComponent(segment); - } catch { - return segment; - } + try { + return decodeURIComponent(segment); + } catch { + return segment; + } } diff --git a/src/features/workspaces/logic/view-model.test.ts b/src/features/workspaces/logic/view-model.test.ts index a4ed19c..86342e7 100644 --- a/src/features/workspaces/logic/view-model.test.ts +++ b/src/features/workspaces/logic/view-model.test.ts @@ -3,66 +3,66 @@ import { describe, expect, it } from "vitest"; import { pageTitle, relativeTime } from "./view-model"; describe("relativeTime", () => { - const now = 1_000_000_000_000; // 2001-09-09 + const now = 1_000_000_000_000; // 2001-09-09 - it("is 'now' within a minute", () => { - expect(relativeTime(now, now)).toBe("now"); - expect(relativeTime(now - 59_000, now)).toBe("now"); - }); + it("is 'now' within a minute", () => { + expect(relativeTime(now, now)).toBe("now"); + expect(relativeTime(now - 59_000, now)).toBe("now"); + }); - it("is minutes under an hour", () => { - expect(relativeTime(now - 5 * 60_000, now)).toBe("5m"); - expect(relativeTime(now - 59 * 60_000, now)).toBe("59m"); - }); + it("is minutes under an hour", () => { + expect(relativeTime(now - 5 * 60_000, now)).toBe("5m"); + expect(relativeTime(now - 59 * 60_000, now)).toBe("59m"); + }); - it("is hours under a day", () => { - expect(relativeTime(now - 2 * 60 * 60_000, now)).toBe("2h"); - }); + it("is hours under a day", () => { + expect(relativeTime(now - 2 * 60 * 60_000, now)).toBe("2h"); + }); - it("is days under a week", () => { - expect(relativeTime(now - 3 * 24 * 60 * 60_000, now)).toBe("3d"); - }); + it("is days under a week", () => { + expect(relativeTime(now - 3 * 24 * 60 * 60_000, now)).toBe("3d"); + }); - it("is a short date beyond a week", () => { - // 7+ days ago: just check it is a MM/DD string. - const s = relativeTime(now - 10 * 24 * 60 * 60_000, now); - expect(s).toMatch(/^\d{2}\/\d{2}$/); - }); + it("is a short date beyond a week", () => { + // 7+ days ago: just check it is a MM/DD string. + const s = relativeTime(now - 10 * 24 * 60 * 60_000, now); + expect(s).toMatch(/^\d{2}\/\d{2}$/); + }); }); describe("pageTitle", () => { - // Minimal valid WorkspaceEntry (the irrelevant metadata is zeroed). - const ws = (id: string, title: string): WorkspaceEntry => ({ - id, - title, - defaultCwd: null, - defaultComputerId: null, - createdAt: 0, - lastActivityAt: 0, - conversationCount: 0, - }); + // Minimal valid WorkspaceEntry (the irrelevant metadata is zeroed). + const ws = (id: string, title: string): WorkspaceEntry => ({ + id, + title, + defaultCwd: null, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + conversationCount: 0, + }); - it("is 'Dispatch' for the home route", () => { - expect(pageTitle({ kind: "home" }, [])).toBe("Dispatch"); - expect(pageTitle({ kind: "home" }, [ws("default", "Default")])).toBe("Dispatch"); - }); + it("is 'Dispatch' for the home route", () => { + expect(pageTitle({ kind: "home" }, [])).toBe("Dispatch"); + expect(pageTitle({ kind: "home" }, [ws("default", "Default")])).toBe("Dispatch"); + }); - it("is 'Dispatch: {title}' for a workspace with a display title", () => { - const list = [ws("default", "Default"), ws("my-ws", "My Workspace")]; - expect(pageTitle({ kind: "workspace", id: "my-ws" }, list)).toBe("Dispatch: My Workspace"); - }); + it("is 'Dispatch: {title}' for a workspace with a display title", () => { + const list = [ws("default", "Default"), ws("my-ws", "My Workspace")]; + expect(pageTitle({ kind: "workspace", id: "my-ws" }, list)).toBe("Dispatch: My Workspace"); + }); - it("falls back to the slug (id) until the list has loaded the workspace", () => { - expect(pageTitle({ kind: "workspace", id: "pending" }, [])).toBe("Dispatch: pending"); - }); + it("falls back to the slug (id) until the list has loaded the workspace", () => { + expect(pageTitle({ kind: "workspace", id: "pending" }, [])).toBe("Dispatch: pending"); + }); - it("uses the id as the title when it was never customized (defaults to id)", () => { - const list = [ws("default", "default")]; - expect(pageTitle({ kind: "workspace", id: "default" }, list)).toBe("Dispatch: default"); - }); + it("uses the id as the title when it was never customized (defaults to id)", () => { + const list = [ws("default", "default")]; + expect(pageTitle({ kind: "workspace", id: "default" }, list)).toBe("Dispatch: default"); + }); - it("matches by id, not title", () => { - const list = [ws("a", "shared-title"), ws("b", "shared-title")]; - expect(pageTitle({ kind: "workspace", id: "b" }, list)).toBe("Dispatch: shared-title"); - }); + it("matches by id, not title", () => { + const list = [ws("a", "shared-title"), ws("b", "shared-title")]; + expect(pageTitle({ kind: "workspace", id: "b" }, list)).toBe("Dispatch: shared-title"); + }); }); diff --git a/src/features/workspaces/logic/view-model.ts b/src/features/workspaces/logic/view-model.ts index e904514..6dd64c6 100644 --- a/src/features/workspaces/logic/view-model.ts +++ b/src/features/workspaces/logic/view-model.ts @@ -14,9 +14,9 @@ import type { Route } from "./route"; * workspaces in, string out. */ export function pageTitle(route: Route, workspaces: readonly WorkspaceEntry[]): string { - if (route.kind === "home") return "Dispatch"; - const ws = workspaces.find((w) => w.id === route.id); - return `Dispatch: ${ws?.title ?? route.id}`; + if (route.kind === "home") return "Dispatch"; + const ws = workspaces.find((w) => w.id === route.id); + return `Dispatch: ${ws?.title ?? route.id}`; } /** @@ -25,17 +25,17 @@ export function pageTitle(route: Route, workspaces: readonly WorkspaceEntry[]): * (a workspace just created) read as "now". */ export function relativeTime(then: number, now: number): string { - const diff = now - then; - if (diff < 60_000) return "now"; - const mins = Math.floor(diff / 60_000); - if (mins < 60) return `${mins}m`; - const hours = Math.floor(mins / 60); - if (hours < 24) return `${hours}h`; - const days = Math.floor(hours / 24); - if (days < 7) return `${days}d`; - // Beyond a week: a short date (MM/DD). Uses UTC parts for determinism in tests. - const d = new Date(then); - const month = String(d.getUTCMonth() + 1).padStart(2, "0"); - const day = String(d.getUTCDate()).padStart(2, "0"); - return `${month}/${day}`; + const diff = now - then; + if (diff < 60_000) return "now"; + const mins = Math.floor(diff / 60_000); + if (mins < 60) return `${mins}m`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}h`; + const days = Math.floor(hours / 24); + if (days < 7) return `${days}d`; + // Beyond a week: a short date (MM/DD). Uses UTC parts for determinism in tests. + const d = new Date(then); + const month = String(d.getUTCMonth() + 1).padStart(2, "0"); + const day = String(d.getUTCDate()).padStart(2, "0"); + return `${month}/${day}`; } diff --git a/src/features/workspaces/store.svelte.ts b/src/features/workspaces/store.svelte.ts index 0cf4ce2..046c235 100644 --- a/src/features/workspaces/store.svelte.ts +++ b/src/features/workspaces/store.svelte.ts @@ -9,80 +9,80 @@ import type { WorkspaceHttp, WorkspaceResult } from "./adapter/http"; * owned by the composition root. */ export interface WorkspaceStore { - /** All workspaces (sorted by lastActivityAt desc by the backend). */ - readonly list: readonly WorkspaceEntry[]; - readonly loading: boolean; - readonly error: string | null; - /** Refresh the list from the backend. */ - refresh(): Promise<void>; - /** `PUT /workspaces/:id` (create-on-miss). Returns the workspace or an error. */ - ensure(id: string, body?: EnsureWorkspaceRequest): Promise<WorkspaceResult<Workspace>>; - /** Rename a workspace (display only; id unchanged). */ - rename(id: string, title: string): Promise<WorkspaceResult<Workspace>>; - /** Set/clear a workspace's default cwd. */ - setDefaultCwd(id: string, defaultCwd: string | null): Promise<WorkspaceResult<Workspace>>; - /** Set/clear a workspace's default computer (SSH `Host` alias; null = local). */ - setDefaultComputer(id: string, computerId: string | null): Promise<WorkspaceResult<Workspace>>; - /** Delete a workspace (closes its conversations, reassigns to "default"). */ - remove(id: string): Promise<WorkspaceResult<{ closedCount: number }>>; + /** All workspaces (sorted by lastActivityAt desc by the backend). */ + readonly list: readonly WorkspaceEntry[]; + readonly loading: boolean; + readonly error: string | null; + /** Refresh the list from the backend. */ + refresh(): Promise<void>; + /** `PUT /workspaces/:id` (create-on-miss). Returns the workspace or an error. */ + ensure(id: string, body?: EnsureWorkspaceRequest): Promise<WorkspaceResult<Workspace>>; + /** Rename a workspace (display only; id unchanged). */ + rename(id: string, title: string): Promise<WorkspaceResult<Workspace>>; + /** Set/clear a workspace's default cwd. */ + setDefaultCwd(id: string, defaultCwd: string | null): Promise<WorkspaceResult<Workspace>>; + /** Set/clear a workspace's default computer (SSH `Host` alias; null = local). */ + setDefaultComputer(id: string, computerId: string | null): Promise<WorkspaceResult<Workspace>>; + /** Delete a workspace (closes its conversations, reassigns to "default"). */ + remove(id: string): Promise<WorkspaceResult<{ closedCount: number }>>; } export function createWorkspaceStore(http: WorkspaceHttp): WorkspaceStore { - let list = $state<readonly WorkspaceEntry[]>([]); - let loading = $state(false); - let error = $state<string | null>(null); + let list = $state<readonly WorkspaceEntry[]>([]); + let loading = $state(false); + let error = $state<string | null>(null); - return { - get list(): readonly WorkspaceEntry[] { - return list; - }, - get loading(): boolean { - return loading; - }, - get error(): string | null { - return error; - }, + return { + get list(): readonly WorkspaceEntry[] { + return list; + }, + get loading(): boolean { + return loading; + }, + get error(): string | null { + return error; + }, - async refresh(): Promise<void> { - loading = true; - error = null; - try { - list = await http.list(); - } catch (err) { - error = err instanceof Error ? err.message : "Failed to load workspaces"; - } finally { - loading = false; - } - }, + async refresh(): Promise<void> { + loading = true; + error = null; + try { + list = await http.list(); + } catch (err) { + error = err instanceof Error ? err.message : "Failed to load workspaces"; + } finally { + loading = false; + } + }, - async ensure(id, body): Promise<WorkspaceResult<Workspace>> { - const result = await http.ensure(id, body); - if (result.ok) void this.refresh(); - return result; - }, + async ensure(id, body): Promise<WorkspaceResult<Workspace>> { + const result = await http.ensure(id, body); + if (result.ok) void this.refresh(); + return result; + }, - async rename(id, title): Promise<WorkspaceResult<Workspace>> { - const result = await http.setTitle(id, title); - if (result.ok) void this.refresh(); - return result; - }, + async rename(id, title): Promise<WorkspaceResult<Workspace>> { + const result = await http.setTitle(id, title); + if (result.ok) void this.refresh(); + return result; + }, - async setDefaultCwd(id, defaultCwd): Promise<WorkspaceResult<Workspace>> { - const result = await http.setDefaultCwd(id, defaultCwd); - if (result.ok) void this.refresh(); - return result; - }, + async setDefaultCwd(id, defaultCwd): Promise<WorkspaceResult<Workspace>> { + const result = await http.setDefaultCwd(id, defaultCwd); + if (result.ok) void this.refresh(); + return result; + }, - async setDefaultComputer(id, computerId): Promise<WorkspaceResult<Workspace>> { - const result = await http.setDefaultComputer(id, computerId); - if (result.ok) void this.refresh(); - return result; - }, + async setDefaultComputer(id, computerId): Promise<WorkspaceResult<Workspace>> { + const result = await http.setDefaultComputer(id, computerId); + if (result.ok) void this.refresh(); + return result; + }, - async remove(id): Promise<WorkspaceResult<{ closedCount: number }>> { - const result = await http.delete(id); - if (result.ok) void this.refresh(); - return result; - }, - }; + async remove(id): Promise<WorkspaceResult<{ closedCount: number }>> { + const result = await http.delete(id); + if (result.ok) void this.refresh(); + return result; + }, + }; } diff --git a/src/features/workspaces/ui/WorkspaceCard.svelte b/src/features/workspaces/ui/WorkspaceCard.svelte index ff8a8ea..81b89e9 100644 --- a/src/features/workspaces/ui/WorkspaceCard.svelte +++ b/src/features/workspaces/ui/WorkspaceCard.svelte @@ -1,212 +1,205 @@ <script lang="ts"> - import type { ComputerEntry, 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"; - import ComputerSelect from "../../computer/ui/ComputerSelect.svelte"; - - let { - ws, - store, - onNavigate, - computers, - }: { - ws: WorkspaceEntry; - store: WorkspaceStore; - onNavigate: (path: string) => void; - /** Discovered computers (`GET /computers`), for the default-computer dropdown. */ - computers: readonly ComputerEntry[]; - } = $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; - } - - // ── Default computer: dropdown (Local / discovered SSH aliases) ──────────── - let savingComputer = $state(false); - let computerError = $state<string | null>(null); - - async function saveComputer(computerId: string | null): Promise<void> { - if (savingComputer) return; - // No-op when unchanged (the select only fires on a real change, but guard). - if (computerId === (ws.defaultComputerId ?? null)) return; - savingComputer = true; - computerError = null; - const result = await store.setDefaultComputer(ws.id, computerId); - savingComputer = false; - if (!result.ok) computerError = 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; - } + import type { ComputerEntry, 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"; + import ComputerSelect from "../../computer/ui/ComputerSelect.svelte"; + + let { + ws, + store, + onNavigate, + computers, + }: { + ws: WorkspaceEntry; + store: WorkspaceStore; + onNavigate: (path: string) => void; + /** Discovered computers (`GET /computers`), for the default-computer dropdown. */ + computers: readonly ComputerEntry[]; + } = $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; + } + + // ── Default computer: dropdown (Local / discovered SSH aliases) ──────────── + let savingComputer = $state(false); + let computerError = $state<string | null>(null); + + async function saveComputer(computerId: string | null): Promise<void> { + if (savingComputer) return; + // No-op when unchanged (the select only fires on a real change, but guard). + if (computerId === (ws.defaultComputerId ?? null)) return; + savingComputer = true; + computerError = null; + const result = await store.setDefaultComputer(ws.id, computerId); + savingComputer = false; + if (!result.ok) computerError = 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; + } </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 items-center gap-2"> - <span class="w-8 shrink-0 text-xs opacity-60">ssh</span> - <ComputerSelect - value={ws.defaultComputerId} - {computers} - disabled={savingComputer} - onSelect={saveComputer} - /> - {#if savingComputer} - <span class="loading loading-spinner loading-xs shrink-0"></span> - {/if} - </div> - - <div class="flex justify-start"> - <a - class="btn" - href={workspacePath(ws.id)} - target="_blank" - rel="noopener noreferrer" - > - 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} - - {#if computerError} - <p class="text-xs text-error">{computerError}</p> - {:else if !ws.defaultComputerId} - <p class="text-xs opacity-50">No default computer — conversations run locally (no SSH).</p> - {/if} + <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 items-center gap-2"> + <span class="w-8 shrink-0 text-xs opacity-60">ssh</span> + <ComputerSelect + value={ws.defaultComputerId} + {computers} + disabled={savingComputer} + onSelect={saveComputer} + /> + {#if savingComputer} + <span class="loading loading-spinner loading-xs shrink-0"></span> + {/if} + </div> + + <div class="flex justify-start"> + <a class="btn" href={workspacePath(ws.id)} target="_blank" rel="noopener noreferrer"> 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} + + {#if computerError} + <p class="text-xs text-error">{computerError}</p> + {:else if !ws.defaultComputerId} + <p class="text-xs opacity-50">No default computer — conversations run locally (no SSH).</p> + {/if} </li> diff --git a/src/features/workspaces/ui/WorkspaceCard.test.ts b/src/features/workspaces/ui/WorkspaceCard.test.ts index f6ea432..0736efb 100644 --- a/src/features/workspaces/ui/WorkspaceCard.test.ts +++ b/src/features/workspaces/ui/WorkspaceCard.test.ts @@ -7,128 +7,128 @@ 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, - defaultComputerId: null, - createdAt: 1, - lastActivityAt: 2, - conversationCount: 3, - ...overrides, - }; + return { + id: "my-ws", + title: "My Workspace", + defaultCwd: null, + defaultComputerId: 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 }), - }), - ), - setDefaultComputer: vi.fn( - async (id: string, computerId: string | null): Promise<WorkspaceResult<WorkspaceEntry>> => ({ - ok: true, - value: fakeEntry({ id, defaultComputerId: computerId }), - }), - ), - remove: vi.fn( - async (): Promise<WorkspaceResult<{ closedCount: number }>> => ({ - ok: true, - value: { closedCount: 0 }, - }), - ), - }; + 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 }), + }), + ), + setDefaultComputer: vi.fn( + async (id: string, computerId: string | null): Promise<WorkspaceResult<WorkspaceEntry>> => ({ + ok: true, + value: fakeEntry({ id, defaultComputerId: computerId }), + }), + ), + 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; - render(WorkspaceCard, { - props: { ws: fakeEntry(), store, onNavigate, 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 opens the workspace in a new browser tab (no same-tab navigation)", () => { - 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" }); - // Native new-tab link: href points at the workspace, and target="_blank" - // opens it in a new browser tab rather than client-side navigating. - expect(open).toHaveAttribute("href", "/my-ws"); - expect(open).toHaveAttribute("target", "_blank"); - expect(open.getAttribute("rel") ?? "").toMatch(/noopener/); - }); + 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 opens the workspace in a new browser tab (no same-tab navigation)", () => { + 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" }); + // Native new-tab link: href points at the workspace, and target="_blank" + // opens it in a new browser tab rather than client-side navigating. + expect(open).toHaveAttribute("href", "/my-ws"); + expect(open).toHaveAttribute("target", "_blank"); + expect(open.getAttribute("rel") ?? "").toMatch(/noopener/); + }); }); diff --git a/src/features/workspaces/ui/WorkspacesHome.svelte b/src/features/workspaces/ui/WorkspacesHome.svelte index bdfd023..02e92b4 100644 --- a/src/features/workspaces/ui/WorkspacesHome.svelte +++ b/src/features/workspaces/ui/WorkspacesHome.svelte @@ -1,92 +1,97 @@ <script lang="ts"> - import { onMount } from "svelte"; - import type { ComputerEntry } from "@dispatch/wire"; - import type { WorkspaceStore } from "../store.svelte"; - import { isValidSlug, workspacePath } from "../logic/route"; - import WorkspaceCard from "./WorkspaceCard.svelte"; + import { onMount } from "svelte"; + import type { ComputerEntry } from "@dispatch/wire"; + import type { WorkspaceStore } from "../store.svelte"; + import { isValidSlug, workspacePath } from "../logic/route"; + import WorkspaceCard from "./WorkspaceCard.svelte"; - let { - store, - onNavigate, - computers, - }: { store: WorkspaceStore; onNavigate: (path: string) => void; computers: readonly ComputerEntry[] } = - $props(); + let { + store, + onNavigate, + computers, + }: { + store: WorkspaceStore; + onNavigate: (path: string) => void; + computers: readonly ComputerEntry[]; + } = $props(); - onMount(() => { - void store.refresh(); - }); + onMount(() => { + void store.refresh(); + }); - let newSlug = $state(""); - let slugError = $state<string | null>(null); + let newSlug = $state(""); + let slugError = $state<string | null>(null); - const slugValid = $derived(newSlug.length > 0 && isValidSlug(newSlug)); + 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)); - } + 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> + <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} + <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} {computers} /> - {/each} - </ul> - {/if} - </div> + <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} {computers} /> + {/each} + </ul> + {/if} + </div> </div> |
