diff --git a/web/workspace/deno.json b/web/workspace/deno.json index 91c45a35..2ec7064a 100644 --- a/web/workspace/deno.json +++ b/web/workspace/deno.json @@ -6,7 +6,7 @@ "dev": "deno run -A npm:vite@7.2.7 dev", "dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787", "check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json", - "test": "deno test --allow-read=src --allow-env=VSCODE_TEXTMATE_DEBUG src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/sidebar/repository-nav.test.ts", + "test": "deno test --allow-read=src --allow-env=VSCODE_TEXTMATE_DEBUG src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/sidebar/repository-nav.test.ts", "build": "deno run -A npm:vite@7.2.7 build", "preview": "deno run -A npm:vite@7.2.7 preview" }, diff --git a/web/workspace/src/lib/workspace/header/HeaderOverride.svelte b/web/workspace/src/lib/workspace/header/HeaderOverride.svelte new file mode 100644 index 00000000..c33451da --- /dev/null +++ b/web/workspace/src/lib/workspace/header/HeaderOverride.svelte @@ -0,0 +1,14 @@ + diff --git a/web/workspace/src/lib/workspace/header/WorkspaceBreadcrumbs.svelte b/web/workspace/src/lib/workspace/header/WorkspaceBreadcrumbs.svelte new file mode 100644 index 00000000..ed8bb60c --- /dev/null +++ b/web/workspace/src/lib/workspace/header/WorkspaceBreadcrumbs.svelte @@ -0,0 +1,74 @@ + + + + + diff --git a/web/workspace/src/lib/workspace/header/breadcrumb-model.test.ts b/web/workspace/src/lib/workspace/header/breadcrumb-model.test.ts new file mode 100644 index 00000000..f4a12bfe --- /dev/null +++ b/web/workspace/src/lib/workspace/header/breadcrumb-model.test.ts @@ -0,0 +1,50 @@ +import { buildWorkspaceBreadcrumbs } from "./breadcrumb-model.ts"; + +declare const Deno: { + test(name: string, fn: () => Promise | void): void; +}; + +function assertEquals(actual: unknown, expected: unknown): void { + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error( + `Expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`, + ); + } +} + +Deno.test("Ticket detail breadcrumbs expose the Ticket list and current id", () => { + assertEquals( + buildWorkspaceBreadcrumbs("/w/workspace/tickets/TICKET-42", "workspace"), + [ + { label: "tickets", href: "/w/workspace/tickets" }, + { label: "TICKET-42" }, + ], + ); +}); + +Deno.test("Worker console breadcrumbs use the logical Workers route and display name", () => { + assertEquals( + buildWorkspaceBreadcrumbs( + "/w/workspace/runtimes/runtime-a/workers/worker-7/console", + "workspace", + { workerName: "Review Worker" }, + ), + [ + { label: "workers", href: "/w/workspace/workers" }, + { label: "Review Worker" }, + ], + ); +}); + +Deno.test("Worker console breadcrumbs fall back to Worker id", () => { + assertEquals( + buildWorkspaceBreadcrumbs( + "/w/workspace/runtimes/runtime-a/workers/worker-7/console", + "workspace", + ), + [ + { label: "workers", href: "/w/workspace/workers" }, + { label: "worker-7" }, + ], + ); +}); diff --git a/web/workspace/src/lib/workspace/header/breadcrumb-model.ts b/web/workspace/src/lib/workspace/header/breadcrumb-model.ts new file mode 100644 index 00000000..d9f1d2ec --- /dev/null +++ b/web/workspace/src/lib/workspace/header/breadcrumb-model.ts @@ -0,0 +1,74 @@ +export type WorkspaceBreadcrumb = { + label: string; + href?: string; +}; + +export type WorkspaceBreadcrumbContext = { + workerName?: string | null; +}; + +export function buildWorkspaceBreadcrumbs( + pathname: string, + workspaceId: string, + context: WorkspaceBreadcrumbContext = {}, +): WorkspaceBreadcrumb[] { + const workspaceRoot = `/w/${encodeURIComponent(workspaceId)}`; + const prefix = `${workspaceRoot}/`; + if (pathname === workspaceRoot || pathname === `${workspaceRoot}/`) return []; + if (!pathname.startsWith(prefix)) return []; + + const segments = pathname + .slice(prefix.length) + .split("/") + .filter(Boolean) + .map(decodeURIComponent); + + if ( + segments[0] === "runtimes" && + segments[2] === "workers" && + segments[3] + ) { + return [ + { label: "workers", href: `${workspaceRoot}/workers` }, + { label: context.workerName?.trim() || segments[3] }, + ]; + } + + if ( + segments[0] === "settings" && segments[1] === "profiles" && + segments[2] === "trees" + ) { + return [ + { label: "settings", href: `${workspaceRoot}/settings` }, + { label: "profiles", href: `${workspaceRoot}/settings/profiles` }, + { label: segments[3] || "trees" }, + ]; + } + + if ( + segments[0] === "settings" && + segments[1] === "runtimes" && + segments[2] && + segments[3] === "workdirs" + ) { + return [ + { label: "settings", href: `${workspaceRoot}/settings` }, + { label: "runtimes", href: `${workspaceRoot}/settings/runtimes` }, + { label: segments[2] }, + { label: "workdirs" }, + ]; + } + + return segments.map((segment, index) => { + const isCurrent = index === segments.length - 1; + const href = isCurrent || (segments[0] === "repositories" && index === 0) + ? undefined + : `${workspaceRoot}/${ + segments.slice(0, index + 1).map(encodeURIComponent).join("/") + }`; + return { + label: segment, + href, + }; + }); +} diff --git a/web/workspace/src/lib/workspace/header/context.ts b/web/workspace/src/lib/workspace/header/context.ts new file mode 100644 index 00000000..a5fbe8e7 --- /dev/null +++ b/web/workspace/src/lib/workspace/header/context.ts @@ -0,0 +1,17 @@ +import { getContext, setContext } from "svelte"; +import type { Snippet } from "svelte"; + +const HEADER_CONTEXT_KEY = Symbol("workspace-header"); + +export type HeaderContent = Snippet<[]> | null; +export type HeaderController = { + content: HeaderContent; +}; + +export function provideHeaderController(controller: HeaderController): void { + setContext(HEADER_CONTEXT_KEY, controller); +} + +export function getHeaderController(): HeaderController { + return getContext(HEADER_CONTEXT_KEY); +} diff --git a/web/workspace/src/routes/+layout.svelte b/web/workspace/src/routes/+layout.svelte index f77e2e83..b757d431 100644 --- a/web/workspace/src/routes/+layout.svelte +++ b/web/workspace/src/routes/+layout.svelte @@ -2,6 +2,7 @@ import { page } from '$app/state'; import { setContext } from 'svelte'; import WorkspaceAlerts from '$lib/workspace/alerts/WorkspaceAlerts.svelte'; + import { provideHeaderController, type HeaderController } from '$lib/workspace/header/context'; import GlobalSidebar from '$lib/workspace/sidebar/GlobalSidebar.svelte'; import SidebarFrame from '$lib/workspace/sidebar/SidebarFrame.svelte'; import { SIDEBAR_CONTEXT, type SidebarSnippet } from '$lib/workspace/sidebar/context'; @@ -10,7 +11,9 @@ let { children }: LayoutProps = $props(); let sidebar = $state(null); + const headerController = $state({ content: null }); + provideHeaderController(headerController); setContext(SIDEBAR_CONTEXT, { setSidebar(snippet: SidebarSnippet) { sidebar = snippet; @@ -32,6 +35,9 @@ {/if}
+
+ {#if headerController.content}{@render headerController.content()}{/if} +