diff options
Diffstat (limited to 'src/features/workspaces')
| -rw-r--r-- | src/features/workspaces/adapter/http.ts | 23 | ||||
| -rw-r--r-- | src/features/workspaces/logic/view-model.test.ts | 1 | ||||
| -rw-r--r-- | src/features/workspaces/store.svelte.ts | 8 | ||||
| -rw-r--r-- | src/features/workspaces/ui/WorkspaceCard.svelte | 42 | ||||
| -rw-r--r-- | src/features/workspaces/ui/WorkspaceCard.test.ts | 24 | ||||
| -rw-r--r-- | src/features/workspaces/ui/WorkspacesHome.svelte | 10 |
6 files changed, 99 insertions, 9 deletions
diff --git a/src/features/workspaces/adapter/http.ts b/src/features/workspaces/adapter/http.ts index 97fc2e2..6ebfee1 100644 --- a/src/features/workspaces/adapter/http.ts +++ b/src/features/workspaces/adapter/http.ts @@ -1,6 +1,7 @@ import type { DeleteWorkspaceResponse, EnsureWorkspaceRequest, + SetWorkspaceDefaultComputerRequest, SetWorkspaceDefaultCwdRequest, SetWorkspaceTitleRequest, Workspace, @@ -23,6 +24,7 @@ import type { * - `GET /workspaces/:id` (404 → null) → get * - `PUT /workspaces/:id/title` → rename * - `PUT /workspaces/:id/default-cwd` → set/clear default cwd + * - `PUT /workspaces/:id/default-computer` → set/clear default computer (SSH handoff #2) * - `DELETE /workspaces/:id` (409 for "default") → delete */ export type WorkspaceResult<T> = @@ -35,6 +37,7 @@ export interface WorkspaceHttp { 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 }>>; } @@ -118,6 +121,26 @@ export function createWorkspaceHttp(httpBase: string, fetchImpl: typeof fetch): } }, + 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)}`, { diff --git a/src/features/workspaces/logic/view-model.test.ts b/src/features/workspaces/logic/view-model.test.ts index 52f7025..a4ed19c 100644 --- a/src/features/workspaces/logic/view-model.test.ts +++ b/src/features/workspaces/logic/view-model.test.ts @@ -36,6 +36,7 @@ describe("pageTitle", () => { id, title, defaultCwd: null, + defaultComputerId: null, createdAt: 0, lastActivityAt: 0, conversationCount: 0, diff --git a/src/features/workspaces/store.svelte.ts b/src/features/workspaces/store.svelte.ts index 0f662dc..0cf4ce2 100644 --- a/src/features/workspaces/store.svelte.ts +++ b/src/features/workspaces/store.svelte.ts @@ -21,6 +21,8 @@ export interface WorkspaceStore { 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 }>>; } @@ -71,6 +73,12 @@ export function createWorkspaceStore(http: WorkspaceHttp): WorkspaceStore { 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(); diff --git a/src/features/workspaces/ui/WorkspaceCard.svelte b/src/features/workspaces/ui/WorkspaceCard.svelte index 81d6fe3..ff8a8ea 100644 --- a/src/features/workspaces/ui/WorkspaceCard.svelte +++ b/src/features/workspaces/ui/WorkspaceCard.svelte @@ -1,16 +1,22 @@ <script lang="ts"> - import type { WorkspaceEntry } from "@dispatch/wire"; + 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 ────────────────────────────────── @@ -62,6 +68,21 @@ 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); @@ -153,6 +174,19 @@ </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" @@ -169,4 +203,10 @@ {: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 0264d5f..f6ea432 100644 --- a/src/features/workspaces/ui/WorkspaceCard.test.ts +++ b/src/features/workspaces/ui/WorkspaceCard.test.ts @@ -11,6 +11,7 @@ function fakeEntry(overrides: Partial<WorkspaceEntry> = {}): WorkspaceEntry { id: "my-ws", title: "My Workspace", defaultCwd: null, + defaultComputerId: null, createdAt: 1, lastActivityAt: 2, conversationCount: 3, @@ -33,6 +34,12 @@ function fakeStore() { 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, @@ -46,7 +53,7 @@ describe("WorkspaceCard", () => { it("renders the title, slug, and an Open link", () => { const store = fakeStore() as unknown as WorkspaceStore; render(WorkspaceCard, { - props: { ws: fakeEntry(), store }, + props: { ws: fakeEntry(), store, onNavigate, computers: [] }, }); expect(screen.getByText("My Workspace")).toBeInTheDocument(); expect(screen.getByText("/my-ws")).toBeInTheDocument(); @@ -56,7 +63,9 @@ describe("WorkspaceCard", () => { 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 } }); + 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"); @@ -65,7 +74,9 @@ describe("WorkspaceCard", () => { 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 } }); + render(WorkspaceCard, { + props: { ws: fakeEntry(), store, onNavigate: vi.fn(), computers: [] }, + }); await user.dblClick(screen.getByText("My Workspace")); const input = screen.getByLabelText("Workspace title"); @@ -79,7 +90,7 @@ describe("WorkspaceCard", () => { const user = userEvent.setup(); const store = fakeStore() as unknown as WorkspaceStore; render(WorkspaceCard, { - props: { ws: fakeEntry({ defaultCwd: "/old" }), store }, + props: { ws: fakeEntry({ defaultCwd: "/old" }), store, onNavigate: vi.fn(), computers: [] }, }); const input = screen.getByLabelText("Default working directory"); @@ -98,7 +109,7 @@ describe("WorkspaceCard", () => { const user = userEvent.setup(); const store = fakeStore() as unknown as WorkspaceStore; render(WorkspaceCard, { - props: { ws: fakeEntry({ defaultCwd: "/old" }), store }, + props: { ws: fakeEntry({ defaultCwd: "/old" }), store, onNavigate: vi.fn(), computers: [] }, }); const input = screen.getByLabelText("Default working directory"); @@ -110,7 +121,8 @@ describe("WorkspaceCard", () => { it("the Open link opens the workspace in a new browser tab (no same-tab navigation)", () => { const store = fakeStore() as unknown as WorkspaceStore; - render(WorkspaceCard, { props: { ws: fakeEntry(), store } }); + 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" diff --git a/src/features/workspaces/ui/WorkspacesHome.svelte b/src/features/workspaces/ui/WorkspacesHome.svelte index f71c3ba..bdfd023 100644 --- a/src/features/workspaces/ui/WorkspacesHome.svelte +++ b/src/features/workspaces/ui/WorkspacesHome.svelte @@ -1,10 +1,16 @@ <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"; - let { store, onNavigate }: { store: WorkspaceStore; onNavigate: (path: string) => void } = $props(); + let { + store, + onNavigate, + computers, + }: { store: WorkspaceStore; onNavigate: (path: string) => void; computers: readonly ComputerEntry[] } = + $props(); onMount(() => { void store.refresh(); @@ -78,7 +84,7 @@ {:else} <ul class="flex flex-col gap-2"> {#each store.list as ws (ws.id)} - <WorkspaceCard {ws} {store} /> + <WorkspaceCard {ws} {store} {onNavigate} {computers} /> {/each} </ul> {/if} |
