From c333fcec32b1f90bf0da6bb14d2609c20e38a74f Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Fri, 26 Jun 2026 22:21:55 +0900 Subject: style: switch from tabs to 2-space indentation (incl. svelte) --- src/features/workspaces/adapter/http.test.ts | 224 ++++++------- src/features/workspaces/adapter/http.ts | 242 +++++++------- src/features/workspaces/index.ts | 14 +- src/features/workspaces/logic/route.test.ts | 112 +++---- src/features/workspaces/logic/route.ts | 26 +- src/features/workspaces/logic/view-model.test.ts | 98 +++--- src/features/workspaces/logic/view-model.ts | 32 +- src/features/workspaces/store.svelte.ts | 132 ++++---- src/features/workspaces/ui/WorkspaceCard.svelte | 407 +++++++++++------------ src/features/workspaces/ui/WorkspaceCard.test.ts | 234 ++++++------- src/features/workspaces/ui/WorkspacesHome.svelte | 165 ++++----- 11 files changed, 842 insertions(+), 844 deletions(-) (limited to 'src/features/workspaces') 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).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).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).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).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 = - | { 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; - ensure(id: string, body?: EnsureWorkspaceRequest): Promise>; - get(id: string): Promise; - setTitle(id: string, title: string): Promise>; - setDefaultCwd(id: string, defaultCwd: string | null): Promise>; - setDefaultComputer(id: string, computerId: string | null): Promise>; - delete(id: string): Promise>; + list(): Promise; + ensure(id: string, body?: EnsureWorkspaceRequest): Promise>; + get(id: string): Promise; + setTitle(id: string, title: string): Promise>; + setDefaultCwd(id: string, defaultCwd: string | null): Promise>; + setDefaultComputer(id: string, computerId: string | null): Promise>; + delete(id: string): Promise>; } async function errText(res: Response): Promise { - 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 { - 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 { + 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> { - 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> { + 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 { - 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 { + 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> { - 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> { + 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> { - 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> { + 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> { - 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> { + 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> { - 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> { + 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; - /** `PUT /workspaces/:id` (create-on-miss). Returns the workspace or an error. */ - ensure(id: string, body?: EnsureWorkspaceRequest): Promise>; - /** Rename a workspace (display only; id unchanged). */ - rename(id: string, title: string): Promise>; - /** Set/clear a workspace's default cwd. */ - setDefaultCwd(id: string, defaultCwd: string | null): Promise>; - /** Set/clear a workspace's default computer (SSH `Host` alias; null = local). */ - setDefaultComputer(id: string, computerId: string | null): Promise>; - /** Delete a workspace (closes its conversations, reassigns to "default"). */ - remove(id: string): Promise>; + /** 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; + /** `PUT /workspaces/:id` (create-on-miss). Returns the workspace or an error. */ + ensure(id: string, body?: EnsureWorkspaceRequest): Promise>; + /** Rename a workspace (display only; id unchanged). */ + rename(id: string, title: string): Promise>; + /** Set/clear a workspace's default cwd. */ + setDefaultCwd(id: string, defaultCwd: string | null): Promise>; + /** Set/clear a workspace's default computer (SSH `Host` alias; null = local). */ + setDefaultComputer(id: string, computerId: string | null): Promise>; + /** Delete a workspace (closes its conversations, reassigns to "default"). */ + remove(id: string): Promise>; } export function createWorkspaceStore(http: WorkspaceHttp): WorkspaceStore { - let list = $state([]); - let loading = $state(false); - let error = $state(null); + let list = $state([]); + let loading = $state(false); + let error = $state(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 { - 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 { + 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> { - const result = await http.ensure(id, body); - if (result.ok) void this.refresh(); - return result; - }, + async ensure(id, body): Promise> { + const result = await http.ensure(id, body); + if (result.ok) void this.refresh(); + return result; + }, - async rename(id, title): Promise> { - const result = await http.setTitle(id, title); - if (result.ok) void this.refresh(); - return result; - }, + async rename(id, title): Promise> { + const result = await http.setTitle(id, title); + if (result.ok) void this.refresh(); + return result; + }, - async setDefaultCwd(id, defaultCwd): Promise> { - const result = await http.setDefaultCwd(id, defaultCwd); - if (result.ok) void this.refresh(); - return result; - }, + async setDefaultCwd(id, defaultCwd): Promise> { + const result = await http.setDefaultCwd(id, defaultCwd); + if (result.ok) void this.refresh(); + return result; + }, - async setDefaultComputer(id, computerId): Promise> { - const result = await http.setDefaultComputer(id, computerId); - if (result.ok) void this.refresh(); - return result; - }, + async setDefaultComputer(id, computerId): Promise> { + const result = await http.setDefaultComputer(id, computerId); + if (result.ok) void this.refresh(); + return result; + }, - async remove(id): Promise> { - const result = await http.delete(id); - if (result.ok) void this.refresh(); - return result; - }, - }; + async remove(id): Promise> { + 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 @@
  • -
    - {#if editingTitle} - { - if (e.key === "Enter") saveTitle(); - else if (e.key === "Escape") cancelTitle(); - }} - onblur={saveTitle} - /> - {:else} - - {ws.title} - {/if} - /{ws.id} - - {ws.conversationCount} - {ws.conversationCount === 1 ? "conversation" : "conversations"} - · {relativeTime(ws.lastActivityAt, Date.now())} - - -
    - - {#if titleError} -

    {titleError}

    - {/if} - -
    - cwd - { - if (e.key === "Enter") saveCwd(); - }} - /> - -
    - -
    - ssh - - {#if savingComputer} - - {/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 editingTitle} + { + if (e.key === "Enter") saveTitle(); + else if (e.key === "Escape") cancelTitle(); + }} + onblur={saveTitle} + /> + {:else} + + {ws.title} + {/if} + /{ws.id} + + {ws.conversationCount} + {ws.conversationCount === 1 ? "conversation" : "conversations"} + · {relativeTime(ws.lastActivityAt, Date.now())} + + +
    + + {#if titleError} +

    {titleError}

    + {/if} + +
    + cwd + { + if (e.key === "Enter") saveCwd(); + }} + /> + +
    + +
    + ssh + + {#if savingComputer} + + {/if} +
    + +
    + Open +
    + + {#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}
  • 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 { - 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> => ({ - 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 }), - }), - ), - remove: vi.fn( - async (): Promise> => ({ - ok: true, - value: { closedCount: 0 }, - }), - ), - }; + 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 }), + }), + ), + 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, 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 @@
    -
    -

    Workspaces

    - { - e.preventDefault(); - onNavigate("/default"); - }} - > - Default - -
    +
    +

    Workspaces

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

    {slugError}

    - {/if} +
    { + e.preventDefault(); + createWorkspace(); + }} + > +
    + + +
    + +
    + {#if slugError} +

    {slugError}

    + {/if} -
    - {#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. -

    - {:else} -
      - {#each store.list as ws (ws.id)} - - {/each} -
    - {/if} -
    +
    + {#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. +

    + {:else} +
      + {#each store.list as ws (ws.id)} + + {/each} +
    + {/if} +
    -- cgit v1.2.3