summaryrefslogtreecommitdiffhomepage
path: root/src/features/workspaces/ui
diff options
context:
space:
mode:
Diffstat (limited to 'src/features/workspaces/ui')
-rw-r--r--src/features/workspaces/ui/WorkspaceCard.svelte407
-rw-r--r--src/features/workspaces/ui/WorkspaceCard.test.ts234
-rw-r--r--src/features/workspaces/ui/WorkspacesHome.svelte165
3 files changed, 402 insertions, 404 deletions
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 7de97d9..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: 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/);
- });
+ 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>