diff --git a/web/workspace/src/lib/workspace/api/http.test.ts b/web/workspace/src/lib/workspace/api/http.test.ts index d4e9db62..93521aa6 100644 --- a/web/workspace/src/lib/workspace/api/http.test.ts +++ b/web/workspace/src/lib/workspace/api/http.test.ts @@ -38,25 +38,31 @@ Deno.test("workspace route helpers scope browser routes and API by immutable wor ); }); -Deno.test("root layout bootstraps only the scoped workspace entry", async () => { +Deno.test("root layout leaves Workspace selection explicit", async () => { const layout = await Deno.readTextFile( new URL("./../../../routes/+layout.ts", import.meta.url), ); assert( - layout.includes('loadJson(fetch, "/api/workspace")'), - "unscoped layout may use only the workspace-id bootstrap endpoint", + !layout.includes("/api/workspace") && + !layout.includes("redirect(") && + layout.includes("Workspace selection is explicit"), + "root layout must not infer or redirect to a singleton Workspace", ); +}); + +Deno.test("Workspace route changes dispose old multiplexed subscription state", async () => { + const [layout, multiplexer] = await Promise.all([ + Deno.readTextFile( + new URL("./../../../routes/w/[workspaceId]/+layout.svelte", import.meta.url), + ), + Deno.readTextFile(new URL("./../multiplexer.ts", import.meta.url)), + ]); assert( - layout.includes("throw redirect(307") && - layout.includes("workspaceRoute(workspace.data.workspace_id)") && - !layout.includes("scopedCompatibilityRoute") && - !layout.includes("workspaceRoute(workspaceId, pathname)"), - "root layout should redirect only to the scoped workspace entry", - ); - assert( - !layout.includes("`/api${path}`") && - !layout.includes('"/api/repositories"'), - "layout must not fall back to unscoped workspace-scoped API calls", + layout.includes("disposeWorkspaceMultiplexer(workspaceId)") && + multiplexer.includes("multiplexers.delete(workspaceId)") && + multiplexer.includes("this.#subscriptions.clear()") && + multiplexer.includes("this.#socket?.close()"), + "changing Workspace must dispose old subscriptions and transport state", ); }); diff --git a/web/workspace/src/lib/workspace/api/workspace-catalog.ts b/web/workspace/src/lib/workspace/api/workspace-catalog.ts new file mode 100644 index 00000000..44f70d1e --- /dev/null +++ b/web/workspace/src/lib/workspace/api/workspace-catalog.ts @@ -0,0 +1,164 @@ +export type WorkspaceCatalogRecord = { + workspace_id: string; + owner_account_id: string | null; + display_name: string; + state: string; + created_at: string; + updated_at: string; +}; + +export type WorkspaceRepositoryRecord = { + workspace_id: string; + repository_id: string; + name: string; + kind: string; + uri: string; + default_ref: string | null; +}; + +export type WorkspaceCatalogItem = WorkspaceCatalogRecord & { + repositories: WorkspaceRepositoryRecord[]; + repository_error?: string; +}; + +export type CreateWorkspaceRequest = { + operation_key: string; + display_name: string; + repository: { + uri: string; + display_name: string | null; + default_ref: string | null; + }; +}; + +export type CreateWorkspaceResponse = { + workspace: WorkspaceCatalogRecord; + repository: WorkspaceRepositoryRecord; + config_revision: number; + request_fingerprint: string; + replayed: boolean; +}; + +export class WorkspaceCatalogError extends Error { + constructor( + public readonly status: number | null, + message: string, + ) { + super(message); + this.name = "WorkspaceCatalogError"; + } +} + +type Fetch = typeof globalThis.fetch; + +export async function listWorkspaces( + fetcher: Fetch, +): Promise { + return await fetchJson( + fetcher, + "/api/workspaces?limit=200", + ); +} + +export async function listWorkspaceRepositories( + fetcher: Fetch, + workspaceId: string, +): Promise { + return await fetchJson( + fetcher, + `/api/w/${encodeURIComponent(workspaceId)}/repositories`, + ); +} + +export async function loadWorkspaceCatalog( + fetcher: Fetch, +): Promise { + const workspaces = await listWorkspaces(fetcher); + return await Promise.all( + workspaces.map(async (workspace) => { + try { + return { + ...workspace, + repositories: await listWorkspaceRepositories( + fetcher, + workspace.workspace_id, + ), + }; + } catch (error) { + return { + ...workspace, + repositories: [], + repository_error: errorMessage(error), + }; + } + }), + ); +} + +export async function createWorkspace( + fetcher: Fetch, + request: CreateWorkspaceRequest, +): Promise { + return await fetchJson(fetcher, "/api/workspaces", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(request), + }); +} + +export function creationErrorMessage(error: unknown): string { + if (!(error instanceof WorkspaceCatalogError)) { + return `Network error. The same operation can be retried safely. ${ + errorMessage(error) + }`; + } + switch (error.status) { + case 400: + return `Validation failed. ${error.message}`; + case 401: + case 403: + return `You are not authorized to create this Workspace. ${error.message}`; + case 409: + return `Creation conflicts with current Backend state. ${error.message}`; + default: + return `Workspace creation failed. The same operation can be retried safely. ${error.message}`; + } +} + +export function createOperationKey(): string { + if (typeof crypto !== "undefined" && "randomUUID" in crypto) { + return `web-workspace-create-${crypto.randomUUID()}`; + } + return `web-workspace-create-${Date.now()}-${ + Math.random().toString(16).slice(2) + }`; +} + +async function fetchJson( + fetcher: Fetch, + input: string, + init?: RequestInit, +): Promise { + let response: Response; + try { + response = await fetcher(input, init); + } catch (error) { + throw new WorkspaceCatalogError(null, errorMessage(error)); + } + if (!response.ok) { + let detail = `${response.status} ${response.statusText}`.trim(); + try { + const body = await response.json(); + if (typeof body?.message === "string") detail = body.message; + else if (typeof body?.error === "string") detail = body.error; + } catch { + // Preserve the bounded status text when the Backend did not return JSON. + } + throw new WorkspaceCatalogError(response.status, detail); + } + return await response.json() as T; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/web/workspace/src/lib/workspace/multiplexer.ts b/web/workspace/src/lib/workspace/multiplexer.ts index e155a1df..5436e161 100644 --- a/web/workspace/src/lib/workspace/multiplexer.ts +++ b/web/workspace/src/lib/workspace/multiplexer.ts @@ -42,6 +42,13 @@ export function workspaceMultiplexer(workspaceId: string): WorkspaceMultiplexer return multiplexer; } +export function disposeWorkspaceMultiplexer(workspaceId: string): void { + const multiplexer = multiplexers.get(workspaceId); + if (!multiplexer) return; + multiplexers.delete(workspaceId); + multiplexer.dispose(); +} + export class WorkspaceMultiplexer { readonly #workspaceId: string; readonly #subscriptions = new Map(); @@ -219,6 +226,22 @@ export class WorkspaceMultiplexer { } } + dispose(): void { + this.#closed = true; + if (this.#reconnectTimer) { + clearTimeout(this.#reconnectTimer); + this.#reconnectTimer = null; + } + for (const subscription of this.#subscriptions.values()) { + subscription.listener.onStatus?.('closed', 'Workspace selection changed'); + } + this.#subscriptions.clear(); + this.#requests.clear(); + this.#runtimeSubscriptions.clear(); + this.#socket?.close(); + this.#socket = null; + } + #send(frame: SubscriptionFrame): void { if (this.#socket?.readyState !== WebSocket.OPEN) return; this.#socket.send(JSON.stringify(frame)); diff --git a/web/workspace/src/lib/workspace/sidebar/GlobalSidebar.svelte b/web/workspace/src/lib/workspace/sidebar/GlobalSidebar.svelte index 9130b1a1..06a36ae2 100644 --- a/web/workspace/src/lib/workspace/sidebar/GlobalSidebar.svelte +++ b/web/workspace/src/lib/workspace/sidebar/GlobalSidebar.svelte @@ -8,6 +8,8 @@ const { currentPath }: Props = $props(); const items = [ + { href: '/', label: 'Workspaces' }, + { href: '/#workspace-create-title', label: 'Create Workspace' }, { href: '/account', label: 'Account' }, { href: '/login/device', label: 'Device Login' }, ]; diff --git a/web/workspace/src/lib/workspace/sidebar/WorkspaceSidebar.svelte b/web/workspace/src/lib/workspace/sidebar/WorkspaceSidebar.svelte index 6d18e81d..a83390eb 100644 --- a/web/workspace/src/lib/workspace/sidebar/WorkspaceSidebar.svelte +++ b/web/workspace/src/lib/workspace/sidebar/WorkspaceSidebar.svelte @@ -6,6 +6,7 @@ import RepositoriesNavSection from './RepositoriesNavSection.svelte'; import TicketsNavSection from './TicketsNavSection.svelte'; import WorkersNavSection from './WorkersNavSection.svelte'; + import WorkspaceSwitcher from './WorkspaceSwitcher.svelte'; import type { RepositoryListResponse, WorkspaceResponse } from './types'; type Props = { @@ -76,6 +77,8 @@ + {#if workspaceId}{/if} +