diff --git a/web/workspace/src/app.css b/web/workspace/src/app.css index 9c529e28..9f9da487 100644 --- a/web/workspace/src/app.css +++ b/web/workspace/src/app.css @@ -214,7 +214,7 @@ grid-template-rows: auto auto 1fr; width: 100%; height: auto; - min-height: 0; + min-height: 100dvh; overflow: visible; } .workspace-layout.sidebar-collapsed { diff --git a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts index 4ebd0d24..f696d983 100644 --- a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts +++ b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts @@ -393,6 +393,15 @@ Deno.test("Account UI owns browser passkey session state without workspace autho const globalSidebar = await Deno.readTextFile( new URL("../sidebar/GlobalSidebar.svelte", import.meta.url), ); + const workspaceLayout = await Deno.readTextFile( + new URL("./../../../routes/w/[workspaceId]/+layout.svelte", import.meta.url), + ); + const workspaceLayoutLoad = await Deno.readTextFile( + new URL("./../../../routes/w/[workspaceId]/+layout.ts", import.meta.url), + ); + const sidebarOverride = await Deno.readTextFile( + new URL("../sidebar/SidebarOverride.svelte", import.meta.url), + ); const sidebar = await Deno.readTextFile( new URL("../sidebar/WorkspaceSidebar.svelte", import.meta.url), ); @@ -421,16 +430,17 @@ Deno.test("Account UI owns browser passkey session state without workspace autho "Auth model should stay on Backend auth APIs rather than workspace authorization APIs", ); assert( - rootLayout.includes("GlobalSidebar") && - rootLayout.includes("WorkspaceSidebar") && - rootLayout.includes("data.workspaceScoped") && + rootLayout.includes("SIDEBAR_CONTEXT") && + rootLayout.includes("GlobalSidebar") && + rootLayout.includes("{@render sidebar()}") && + !rootLayout.includes("WorkspaceSidebar") && rootLayout.includes("workspace-topbar") && rootLayout.includes("topbar-icon-button") && rootLayout.includes('href="/account"') && rootLayout.includes("Open Account") && !sidebar.includes("accountHref") && !sidebar.includes("Open Account"), - "Root layout chrome should choose GlobalSidebar or WorkspaceSidebar while account navigation stays in the header", + "Root layout chrome should render a registered sidebar snippet or default global sidebar while account navigation stays in the header", ); assert( globalSidebar.includes("Global") && @@ -441,10 +451,17 @@ Deno.test("Account UI owns browser passkey session state without workspace autho "Root default sidebar should contain only global navigation, not workspace-scoped sections", ); assert( - rootLayoutLoad.includes("params.workspaceId") && - rootLayoutLoad.includes("workspaceApiPath(workspaceId") && - rootLayoutLoad.includes("workspaceScoped: true"), - "Root layout load should provide workspace-scoped sidebar data when a workspace route is active", + workspaceLayout.includes("{#snippet workspaceSidebar()}") && + workspaceLayout.includes("WorkspaceSidebar") && + workspaceLayout.includes("") && + workspaceLayoutLoad.includes("params.workspaceId") && + workspaceLayoutLoad.includes("workspaceApiPath(workspaceId"), + "Workspace layout should load workspace data and register a WorkspaceSidebar snippet", + ); + assert( + sidebarOverride.includes("controller.setSidebar(sidebar)") && + sidebarOverride.includes("controller.clearSidebar(sidebar)"), + "SidebarOverride should register and clean up the child-provided sidebar snippet", ); assert( rootLayoutLoad.includes('"/account"') && rootLayoutLoad.includes('"/login/device"'), diff --git a/web/workspace/src/lib/workspace/sidebar/SidebarOverride.svelte b/web/workspace/src/lib/workspace/sidebar/SidebarOverride.svelte new file mode 100644 index 00000000..cfaeb2b7 --- /dev/null +++ b/web/workspace/src/lib/workspace/sidebar/SidebarOverride.svelte @@ -0,0 +1,15 @@ + diff --git a/web/workspace/src/lib/workspace/sidebar/context.ts b/web/workspace/src/lib/workspace/sidebar/context.ts new file mode 100644 index 00000000..1b9b3f66 --- /dev/null +++ b/web/workspace/src/lib/workspace/sidebar/context.ts @@ -0,0 +1,16 @@ +import { getContext } from "svelte"; +import type { Snippet } from "svelte"; + +export type SidebarSnippet = Snippet<[]>; + +export type SidebarController = { + setSidebar(snippet: SidebarSnippet): void; + clearSidebar(snippet: SidebarSnippet): void; + setCollapsed(collapsed: boolean): void; +}; + +export const SIDEBAR_CONTEXT = Symbol("yoi-sidebar-context"); + +export function getSidebarController(): SidebarController { + return getContext(SIDEBAR_CONTEXT); +} diff --git a/web/workspace/src/routes/+layout.svelte b/web/workspace/src/routes/+layout.svelte index 68a95623..706d596f 100644 --- a/web/workspace/src/routes/+layout.svelte +++ b/web/workspace/src/routes/+layout.svelte @@ -1,28 +1,35 @@ -
- {#if data.workspaceScoped} - (sidebarCollapsed = !sidebarCollapsed)} - /> +
+ {#if sidebar} + {@render sidebar()} {:else} {/if} diff --git a/web/workspace/src/routes/+layout.ts b/web/workspace/src/routes/+layout.ts index 77e13c9c..e965cb42 100644 --- a/web/workspace/src/routes/+layout.ts +++ b/web/workspace/src/routes/+layout.ts @@ -1,31 +1,19 @@ import { redirect } from "@sveltejs/kit"; -import { loadJson, workspaceApiPath, workspaceRoute } from "$lib/workspace/api/http"; -import type { RepositoryListResponse, WorkspaceResponse } from "$lib/workspace/sidebar/types"; +import { loadJson, workspaceRoute } from "$lib/workspace/api/http"; +import type { WorkspaceResponse } from "$lib/workspace/sidebar/types"; import type { LayoutLoad } from "./$types"; export const ssr = false; export const prerender = false; export const load: LayoutLoad = async ({ fetch, params, url }) => { - const workspaceId = params.workspaceId; - if (workspaceId) { - const apiPath = (path: string) => workspaceApiPath(workspaceId, path); - const [workspace, repositories] = await Promise.all([ - loadJson(fetch, apiPath("/workspace")), - loadJson(fetch, apiPath("/repositories")), - ]); - return { - workspaceScoped: true, - workspace: workspace.data, - workspaceError: workspace.error, - repositories: repositories.data, - repositoriesError: repositories.error, - }; + if (params.workspaceId) { + return {}; } const publicRoutes = new Set(["/account", "/login/device"]); if (publicRoutes.has(url.pathname)) { - return { workspaceScoped: false }; + return {}; } const workspace = await loadJson(fetch, "/api/workspace"); @@ -33,5 +21,5 @@ export const load: LayoutLoad = async ({ fetch, params, url }) => { const scopedPath = workspaceRoute(workspace.data.workspace_id); throw redirect(307, `${scopedPath}${url.search}`); } - return { workspaceScoped: false }; + return {}; }; diff --git a/web/workspace/src/routes/w/[workspaceId]/+layout.svelte b/web/workspace/src/routes/w/[workspaceId]/+layout.svelte new file mode 100644 index 00000000..d573d76a --- /dev/null +++ b/web/workspace/src/routes/w/[workspaceId]/+layout.svelte @@ -0,0 +1,35 @@ + + +{#snippet workspaceSidebar()} + +{/snippet} + + + +{@render children()} diff --git a/web/workspace/src/routes/w/[workspaceId]/+layout.ts b/web/workspace/src/routes/w/[workspaceId]/+layout.ts new file mode 100644 index 00000000..a90efd91 --- /dev/null +++ b/web/workspace/src/routes/w/[workspaceId]/+layout.ts @@ -0,0 +1,19 @@ +import { loadJson, workspaceApiPath } from "$lib/workspace/api/http"; +import type { RepositoryListResponse, WorkspaceResponse } from "$lib/workspace/sidebar/types"; +import type { LayoutLoad } from "./$types"; + +export const load: LayoutLoad = async ({ fetch, params }) => { + const workspaceId = params.workspaceId; + const apiPath = (path: string) => workspaceApiPath(workspaceId, path); + const [workspace, repositories] = await Promise.all([ + loadJson(fetch, apiPath("/workspace")), + loadJson(fetch, apiPath("/repositories")), + ]); + + return { + workspace: workspace.data, + workspaceError: workspace.error, + repositories: repositories.data, + repositoriesError: repositories.error, + }; +};