feat: add workspace switcher menu
This commit is contained in:
@@ -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,test --allow-env=LOG,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/tasks.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-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/sidebar/repository-nav.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts",
|
||||
"test": "deno test --allow-read=src,test --allow-env=LOG,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/tasks.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/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts",
|
||||
"build": "deno run -A npm:vite@7.2.7 build",
|
||||
"preview": "deno run -A npm:vite@7.2.7 preview"
|
||||
},
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { projectRepositoryNav } from './repository-nav';
|
||||
import type { RepositoryListResponse } from './types';
|
||||
|
||||
type Props = {
|
||||
repositories: RepositoryListResponse | null;
|
||||
repositoriesError?: string | null;
|
||||
currentPath?: string;
|
||||
workspaceId: string;
|
||||
};
|
||||
|
||||
let { repositories, repositoriesError = null, currentPath = '/', workspaceId }: Props = $props();
|
||||
let navigation = $derived(projectRepositoryNav(repositories, currentPath, workspaceId));
|
||||
</script>
|
||||
|
||||
<section class="nav-section" aria-labelledby="repositories-heading">
|
||||
<div class="section-heading-row">
|
||||
<h2 id="repositories-heading">repositories</h2>
|
||||
<span class="section-count">{navigation.count}</span>
|
||||
</div>
|
||||
|
||||
{#if repositoriesError}
|
||||
<p class="nav-empty error">Repository registry unavailable.</p>
|
||||
{:else if !repositories}
|
||||
<p class="nav-empty">Loading repositories…</p>
|
||||
{:else if navigation.items.length === 0}
|
||||
<p class="nav-empty">No repositories configured.</p>
|
||||
{#if navigation.diagnostics.length > 0}
|
||||
<ul class="diagnostics" aria-label="Repository diagnostics">
|
||||
{#each navigation.diagnostics as diagnostic}
|
||||
<li><code>{diagnostic.code}</code>: {diagnostic.message}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{:else}
|
||||
<ul class="nav-list" aria-label="Repositories">
|
||||
{#each navigation.items as item (item.id)}
|
||||
<li>
|
||||
<a class="nav-item" class:active={item.active} href={item.href} aria-current={item.active ? 'page' : undefined}>
|
||||
<span class="item-title">{item.title}</span>
|
||||
<span class="item-meta">{item.meta}</span>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
@@ -1,86 +1,43 @@
|
||||
<script lang="ts">
|
||||
import { workspaceRoute } from '$lib/workspace/api/http';
|
||||
import './sidebar.css';
|
||||
import ObjectivesNavSection from './ObjectivesNavSection.svelte';
|
||||
import MemoryNavSection from './MemoryNavSection.svelte';
|
||||
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';
|
||||
import type { WorkspaceResponse } from './types';
|
||||
|
||||
type Props = {
|
||||
workspace: WorkspaceResponse | null;
|
||||
workspaceError?: string | null;
|
||||
repositories?: RepositoryListResponse | null;
|
||||
repositoriesError?: string | null;
|
||||
currentPath?: string;
|
||||
};
|
||||
|
||||
let {
|
||||
workspace,
|
||||
workspaceError = null,
|
||||
repositories = null,
|
||||
repositoriesError = null,
|
||||
currentPath = '/'
|
||||
}: Props = $props();
|
||||
let { workspace, workspaceError = null, currentPath = '/' }: Props = $props();
|
||||
|
||||
let workspaceId = $derived(workspace?.workspace_id ?? '');
|
||||
let homeHref = $derived(workspaceId ? workspaceRoute(workspaceId) : '/');
|
||||
let settingsHref = $derived(workspaceId ? workspaceRoute(workspaceId, '/settings') : '/settings');
|
||||
</script>
|
||||
|
||||
<div class="workspace-sidebar">
|
||||
<header class="sidebar-header">
|
||||
<div class="sidebar-title-row">
|
||||
<div class="workspace-label">
|
||||
{#if workspace}
|
||||
<div class="workspace-name">{workspace.display_name}</div>
|
||||
<WorkspaceSwitcher
|
||||
currentWorkspaceId={workspaceId}
|
||||
currentWorkspaceName={workspace.display_name}
|
||||
/>
|
||||
{:else}
|
||||
<div class="workspace-label">
|
||||
<div class="workspace-name">Yoi workspace</div>
|
||||
{#if workspaceError}
|
||||
<p class="workspace-status error">Workspace summary unavailable.</p>
|
||||
{:else}
|
||||
<p class="workspace-status">Loading workspace…</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-actions-row">
|
||||
<a
|
||||
class="sidebar-icon-button"
|
||||
href={homeHref}
|
||||
aria-label="Open workspace overview"
|
||||
title="Workspace overview"
|
||||
>
|
||||
<svg class="sidebar-icon" aria-hidden="true" viewBox="0 0 24 24">
|
||||
<path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8" />
|
||||
<path
|
||||
d="M3 10a2 2 0 0 1 .709-1.528l7-6a2 2 0 0 1 2.582 0l7 6A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
class="sidebar-icon-button"
|
||||
href={settingsHref}
|
||||
aria-label="Open Settings / Admin"
|
||||
title="Settings / Admin"
|
||||
>
|
||||
<svg class="sidebar-icon" aria-hidden="true" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"
|
||||
/>
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if workspaceId}<WorkspaceSwitcher currentWorkspaceId={workspaceId} />{/if}
|
||||
|
||||
<nav class="sidebar-sections" aria-label="Workspace sections">
|
||||
<RepositoriesNavSection {repositories} {repositoriesError} {currentPath} {workspaceId} />
|
||||
<TicketsNavSection {currentPath} {workspaceId} />
|
||||
<ObjectivesNavSection {currentPath} {workspaceId} />
|
||||
<MemoryNavSection {currentPath} {workspaceId} />
|
||||
|
||||
@@ -1,55 +1,196 @@
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import { onMount } from "svelte";
|
||||
import { onMount, tick } from "svelte";
|
||||
import {
|
||||
listWorkspaces,
|
||||
type WorkspaceCatalogRecord,
|
||||
} from "$lib/workspace/api/workspace-catalog";
|
||||
import "$lib/workspace/styles/workspace-catalog.css";
|
||||
|
||||
let { currentWorkspaceId } = $props<{ currentWorkspaceId: string }>();
|
||||
let {
|
||||
currentWorkspaceId,
|
||||
currentWorkspaceName,
|
||||
}: {
|
||||
currentWorkspaceId: string;
|
||||
currentWorkspaceName: string;
|
||||
} = $props();
|
||||
|
||||
let workspaces = $state<WorkspaceCatalogRecord[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
let error = $state("");
|
||||
let open = $state(false);
|
||||
let root = $state.raw<HTMLDivElement>();
|
||||
let trigger = $state.raw<HTMLButtonElement>();
|
||||
let menu = $state.raw<HTMLDivElement>();
|
||||
|
||||
onMount(async () => {
|
||||
const menuWorkspaces = $derived.by(() => {
|
||||
const entries = workspaces.map((workspace) => ({
|
||||
workspace_id: workspace.workspace_id,
|
||||
display_name: workspace.display_name,
|
||||
}));
|
||||
if (!entries.some((workspace) => workspace.workspace_id === currentWorkspaceId)) {
|
||||
entries.unshift({
|
||||
workspace_id: currentWorkspaceId,
|
||||
display_name: currentWorkspaceName,
|
||||
});
|
||||
}
|
||||
return entries;
|
||||
});
|
||||
|
||||
async function loadWorkspaces() {
|
||||
loading = true;
|
||||
error = "";
|
||||
try {
|
||||
workspaces = await listWorkspaces(fetch);
|
||||
} catch (cause) {
|
||||
error = cause instanceof Error ? cause.message : String(cause);
|
||||
error = cause instanceof Error ? cause.message : "Failed to load Workspaces.";
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
|
||||
async function switchWorkspace(event: Event) {
|
||||
const workspaceId = (event.currentTarget as HTMLSelectElement).value;
|
||||
if (!workspaceId || workspaceId === currentWorkspaceId) return;
|
||||
await goto(`/w/${encodeURIComponent(workspaceId)}`);
|
||||
}
|
||||
|
||||
function closeMenu() {
|
||||
open = false;
|
||||
}
|
||||
|
||||
async function openMenu(focus: "none" | "first" | "last" = "none") {
|
||||
open = true;
|
||||
if (focus === "none") return;
|
||||
await tick();
|
||||
const items = menu?.querySelectorAll<HTMLElement>("[role='menuitem']");
|
||||
if (!items?.length) return;
|
||||
items[focus === "first" ? 0 : items.length - 1]?.focus();
|
||||
}
|
||||
|
||||
function toggleMenu() {
|
||||
if (open) {
|
||||
closeMenu();
|
||||
} else {
|
||||
void openMenu();
|
||||
}
|
||||
}
|
||||
|
||||
function handleTriggerKeydown(event: KeyboardEvent) {
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
void openMenu("first");
|
||||
} else if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
void openMenu("last");
|
||||
}
|
||||
}
|
||||
|
||||
function handleMenuKeydown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
closeMenu();
|
||||
trigger?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key) || !menu) return;
|
||||
const items = [...menu.querySelectorAll<HTMLElement>("[role='menuitem']")];
|
||||
if (!items.length) return;
|
||||
event.preventDefault();
|
||||
const currentIndex = items.indexOf(document.activeElement as HTMLElement);
|
||||
let nextIndex = currentIndex;
|
||||
if (event.key === "Home") nextIndex = 0;
|
||||
if (event.key === "End") nextIndex = items.length - 1;
|
||||
if (event.key === "ArrowDown") nextIndex = (currentIndex + 1) % items.length;
|
||||
if (event.key === "ArrowUp") {
|
||||
nextIndex = (currentIndex - 1 + items.length) % items.length;
|
||||
}
|
||||
items[nextIndex]?.focus();
|
||||
}
|
||||
|
||||
function handleDocumentPointerDown(event: PointerEvent) {
|
||||
if (open && root && !root.contains(event.target as Node)) closeMenu();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
document.addEventListener("pointerdown", handleDocumentPointerDown);
|
||||
void loadWorkspaces();
|
||||
return () => document.removeEventListener("pointerdown", handleDocumentPointerDown);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="workspace-switcher">
|
||||
<label for="workspace-switcher-select">Workspace</label>
|
||||
<select
|
||||
id="workspace-switcher-select"
|
||||
value={currentWorkspaceId}
|
||||
onchange={switchWorkspace}
|
||||
disabled={loading}
|
||||
aria-label="Switch Workspace"
|
||||
<div class="workspace-menu" bind:this={root}>
|
||||
<button
|
||||
bind:this={trigger}
|
||||
type="button"
|
||||
class="workspace-menu-trigger"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
aria-controls="workspace-menu-popover"
|
||||
onclick={toggleMenu}
|
||||
onkeydown={handleTriggerKeydown}
|
||||
>
|
||||
{#if !workspaces.some((workspace) => workspace.workspace_id === currentWorkspaceId)}
|
||||
<option value={currentWorkspaceId}>
|
||||
{loading ? "Loading current Workspace…" : "Current Workspace unavailable"}
|
||||
</option>
|
||||
{/if}
|
||||
{#each workspaces as workspace (workspace.workspace_id)}
|
||||
<option value={workspace.workspace_id}>{workspace.display_name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<div class="workspace-switcher-actions">
|
||||
<a href="/">All Workspaces</a>
|
||||
<a href="/#workspace-create-title">Create</a>
|
||||
<span>{currentWorkspaceName}</span>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="m8 10 4 4 4-4" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if open}
|
||||
<div
|
||||
bind:this={menu}
|
||||
id="workspace-menu-popover"
|
||||
class="workspace-menu-popover"
|
||||
role="menu"
|
||||
tabindex="-1"
|
||||
aria-label="Workspace menu"
|
||||
onkeydown={handleMenuKeydown}
|
||||
>
|
||||
<a
|
||||
class="workspace-menu-item"
|
||||
href={`/w/${encodeURIComponent(currentWorkspaceId)}/settings`}
|
||||
role="menuitem"
|
||||
onclick={closeMenu}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12 15.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Z" />
|
||||
<path d="M19.4 15a1.8 1.8 0 0 0 .36 1.98l.06.06-2.12 2.12-.06-.06a1.8 1.8 0 0 0-1.98-.36 1.8 1.8 0 0 0-1.1 1.64v.12h-3v-.12a1.8 1.8 0 0 0-1.1-1.64 1.8 1.8 0 0 0-1.98.36l-.06.06-2.12-2.12.06-.06A1.8 1.8 0 0 0 6.6 15a1.8 1.8 0 0 0-1.64-1.1h-.12v-3h.12A1.8 1.8 0 0 0 6.6 9a1.8 1.8 0 0 0-.36-1.98l-.06-.06 2.12-2.12.06.06A1.8 1.8 0 0 0 10.34 5a1.8 1.8 0 0 0 1.1-1.64v-.12h3v.12A1.8 1.8 0 0 0 15.54 5a1.8 1.8 0 0 0 1.98-.36l.06-.06 2.12 2.12-.06.06A1.8 1.8 0 0 0 19.4 9a1.8 1.8 0 0 0 1.64 1.1h.12v3h-.12A1.8 1.8 0 0 0 19.4 15Z" />
|
||||
</svg>
|
||||
<span>Settings</span>
|
||||
</a>
|
||||
|
||||
<div class="workspace-menu-separator" role="separator"></div>
|
||||
|
||||
<div class="workspace-menu-heading">
|
||||
<span>Workspaces</span>
|
||||
<a
|
||||
class="workspace-menu-add"
|
||||
href="/#workspace-create-title"
|
||||
role="menuitem"
|
||||
aria-label="Create Workspace"
|
||||
title="Create Workspace"
|
||||
onclick={closeMenu}
|
||||
>+</a>
|
||||
</div>
|
||||
{#if error}<span class="workspace-switcher-error">Selector unavailable: {error}</span>{/if}
|
||||
|
||||
<div class="workspace-menu-list">
|
||||
{#each menuWorkspaces as workspace (workspace.workspace_id)}
|
||||
<a
|
||||
class:current={workspace.workspace_id === currentWorkspaceId}
|
||||
class="workspace-menu-item workspace-menu-workspace"
|
||||
href={`/w/${encodeURIComponent(workspace.workspace_id)}`}
|
||||
role="menuitem"
|
||||
aria-current={workspace.workspace_id === currentWorkspaceId ? "page" : undefined}
|
||||
onclick={closeMenu}
|
||||
>
|
||||
<span>{workspace.display_name}</span>
|
||||
{#if workspace.workspace_id === currentWorkspaceId}
|
||||
<svg class="workspace-menu-check" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="m6 12 4 4 8-8" />
|
||||
</svg>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<p class="workspace-menu-status">Loading Workspaces…</p>
|
||||
{:else if error}
|
||||
<p class="workspace-menu-status error">{error}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import { projectRepositoryNav } from "./repository-nav.ts";
|
||||
import type { RepositoryListResponse } from "./types.ts";
|
||||
|
||||
declare const Deno: {
|
||||
test(name: string, fn: () => void): void;
|
||||
};
|
||||
|
||||
function assertEquals<T>(actual: T, expected: T): void {
|
||||
const actualJson = JSON.stringify(actual);
|
||||
const expectedJson = JSON.stringify(expected);
|
||||
if (actualJson !== expectedJson) {
|
||||
throw new Error(`Expected ${expectedJson}, got ${actualJson}`);
|
||||
}
|
||||
}
|
||||
|
||||
function repositories(
|
||||
items: RepositoryListResponse["items"],
|
||||
): RepositoryListResponse {
|
||||
return {
|
||||
workspace_id: "workspace-1",
|
||||
items,
|
||||
source: "workspace_backend_config",
|
||||
diagnostics: [],
|
||||
};
|
||||
}
|
||||
|
||||
Deno.test("repository nav does not invent main for an empty registry", () => {
|
||||
const projection = projectRepositoryNav(
|
||||
{
|
||||
workspace_id: "workspace-1",
|
||||
items: [],
|
||||
source: "workspace_backend_config",
|
||||
diagnostics: [
|
||||
{
|
||||
code: "repository_config_empty",
|
||||
severity: "warning",
|
||||
message: "No repositories configured",
|
||||
},
|
||||
],
|
||||
},
|
||||
"/w/workspace-1",
|
||||
"workspace-1",
|
||||
);
|
||||
|
||||
assertEquals(projection.count, 0);
|
||||
assertEquals(projection.items, []);
|
||||
assertEquals(projection.diagnostics[0].code, "repository_config_empty");
|
||||
});
|
||||
|
||||
Deno.test("repository nav links configured non-main repository ids", () => {
|
||||
const projection = projectRepositoryNav(
|
||||
repositories([
|
||||
{
|
||||
id: "infra",
|
||||
display_name: "Infrastructure",
|
||||
kind: "git",
|
||||
provider: "git",
|
||||
record_authority: "workspace-backend-config",
|
||||
git: null,
|
||||
diagnostics: [],
|
||||
},
|
||||
]),
|
||||
"/w/workspace-1/repositories/infra",
|
||||
"workspace-1",
|
||||
);
|
||||
|
||||
assertEquals(projection.count, 1);
|
||||
assertEquals(projection.items[0], {
|
||||
id: "infra",
|
||||
title: "Infrastructure",
|
||||
href: "/w/workspace-1/repositories/infra",
|
||||
meta: "git repository · read-only",
|
||||
active: true,
|
||||
});
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
import { workspaceRoute } from "$lib/workspace/api/http";
|
||||
import type { Diagnostic, RepositoryListResponse } from "./types";
|
||||
|
||||
export type RepositoryNavItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
href: string;
|
||||
meta: string;
|
||||
active: boolean;
|
||||
};
|
||||
|
||||
export type RepositoryNavProjection = {
|
||||
count: number;
|
||||
items: RepositoryNavItem[];
|
||||
diagnostics: Diagnostic[];
|
||||
};
|
||||
|
||||
export function projectRepositoryNav(
|
||||
repositories: RepositoryListResponse | null,
|
||||
currentPath: string,
|
||||
workspaceId: string,
|
||||
): RepositoryNavProjection {
|
||||
const summaries = repositories?.items ?? [];
|
||||
return {
|
||||
count: summaries.length,
|
||||
diagnostics: repositories?.diagnostics ?? [],
|
||||
items: summaries.map((repository) => {
|
||||
const href = workspaceRoute(
|
||||
workspaceId,
|
||||
`/repositories/${encodeURIComponent(repository.id)}`,
|
||||
);
|
||||
return {
|
||||
id: repository.id,
|
||||
title: repository.display_name || repository.id,
|
||||
href,
|
||||
meta: `${repository.provider} repository · read-only`,
|
||||
active: currentPath === href || currentPath.startsWith(`${href}/`),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -42,8 +42,7 @@
|
||||
margin-bottom: var(--space-2);
|
||||
min-width: 0;
|
||||
}
|
||||
.sidebar-control-row,
|
||||
.sidebar-actions-row {
|
||||
.sidebar-control-row {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-1);
|
||||
@@ -57,12 +56,6 @@
|
||||
padding-inline: var(--space-2);
|
||||
padding-top: 0;
|
||||
}
|
||||
.sidebar-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-width: 0;
|
||||
}
|
||||
.workspace-label {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
@@ -80,6 +73,157 @@
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.workspace-menu {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
.workspace-menu-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
border-radius: var(--radius-soft);
|
||||
padding: 0.45rem 0.6rem;
|
||||
background: transparent;
|
||||
color: var(--text-strong);
|
||||
font: inherit;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 800;
|
||||
line-height: 1.25;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.workspace-menu-trigger > span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.workspace-menu-trigger > svg,
|
||||
.workspace-menu-item > svg {
|
||||
flex: 0 0 auto;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.8;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
.workspace-menu-trigger > svg {
|
||||
transition: transform 140ms ease;
|
||||
}
|
||||
.workspace-menu-trigger[aria-expanded="true"] > svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.workspace-menu-trigger:hover,
|
||||
.workspace-menu-trigger:focus-visible {
|
||||
background: var(--interactive-hover);
|
||||
}
|
||||
.workspace-menu-trigger:focus-visible,
|
||||
.workspace-menu-add:focus-visible,
|
||||
.workspace-menu-item:focus-visible {
|
||||
outline: 1px solid var(--accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.workspace-menu-popover {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
inset-inline: 0;
|
||||
top: calc(100% + var(--space-1));
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
min-width: 0;
|
||||
max-height: min(26rem, calc(100vh - 6rem));
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: calc(var(--radius-soft) + 0.2rem);
|
||||
padding: var(--space-2);
|
||||
background: var(--bg-raised);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
.workspace-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-width: 0;
|
||||
border-radius: var(--radius-soft);
|
||||
padding: var(--space-2);
|
||||
color: var(--text-strong);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 650;
|
||||
line-height: 1.3;
|
||||
text-decoration: none;
|
||||
}
|
||||
.workspace-menu-item > span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.workspace-menu-item:hover,
|
||||
.workspace-menu-item:focus-visible,
|
||||
.workspace-menu-item.current {
|
||||
background: var(--interactive-hover);
|
||||
}
|
||||
.workspace-menu-item.current {
|
||||
color: var(--accent);
|
||||
}
|
||||
.workspace-menu-separator {
|
||||
height: 1px;
|
||||
margin: var(--space-1) calc(-1 * var(--space-2));
|
||||
background: var(--line);
|
||||
}
|
||||
.workspace-menu-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-1) var(--space-2) 0;
|
||||
color: var(--text-faint);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.workspace-menu-add {
|
||||
display: grid;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
place-items: center;
|
||||
border-radius: var(--radius-soft);
|
||||
color: var(--text-muted);
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
text-decoration: none;
|
||||
}
|
||||
.workspace-menu-add:hover,
|
||||
.workspace-menu-add:focus-visible {
|
||||
background: var(--interactive-hover);
|
||||
color: var(--text-strong);
|
||||
}
|
||||
.workspace-menu-list {
|
||||
display: grid;
|
||||
gap: 1px;
|
||||
}
|
||||
.workspace-menu-workspace {
|
||||
justify-content: space-between;
|
||||
}
|
||||
.workspace-menu-check {
|
||||
margin-left: auto;
|
||||
}
|
||||
.workspace-menu-status {
|
||||
margin: var(--space-1) var(--space-2);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.workspace-menu-status.error {
|
||||
color: var(--danger);
|
||||
}
|
||||
.workspace-status {
|
||||
margin: var(--space-1) 0 0;
|
||||
color: var(--text-muted);
|
||||
@@ -91,7 +235,6 @@
|
||||
.error {
|
||||
color: var(--danger);
|
||||
}
|
||||
.sidebar-icon-button,
|
||||
.sidebar-fold-button {
|
||||
flex: 0 0 auto;
|
||||
display: grid;
|
||||
@@ -118,8 +261,6 @@
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
.sidebar-icon-button:hover,
|
||||
.sidebar-icon-button:focus-visible,
|
||||
.sidebar-fold-button:hover,
|
||||
.sidebar-fold-button:focus-visible {
|
||||
background: var(--interactive-hover);
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
declare const Deno: {
|
||||
test(name: string, fn: () => Promise<void> | void): void;
|
||||
readTextFile(path: string | URL): Promise<string>;
|
||||
};
|
||||
|
||||
function assert(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
const switcherSource = await Deno.readTextFile(
|
||||
new URL("./WorkspaceSwitcher.svelte", import.meta.url),
|
||||
);
|
||||
const sidebarSource = await Deno.readTextFile(
|
||||
new URL("./WorkspaceSidebar.svelte", import.meta.url),
|
||||
);
|
||||
|
||||
Deno.test("workspace name opens the settings and workspace menu", () => {
|
||||
assert(
|
||||
switcherSource.includes('class="workspace-menu-trigger"'),
|
||||
"missing name trigger",
|
||||
);
|
||||
assert(
|
||||
switcherSource.includes('aria-haspopup="menu"'),
|
||||
"trigger is not a menu button",
|
||||
);
|
||||
assert(
|
||||
switcherSource.includes("<span>Settings</span>"),
|
||||
"missing Settings action",
|
||||
);
|
||||
assert(
|
||||
switcherSource.includes("<span>Workspaces</span>"),
|
||||
"missing Workspaces heading",
|
||||
);
|
||||
assert(
|
||||
switcherSource.includes('aria-label="Create Workspace"'),
|
||||
"missing create action",
|
||||
);
|
||||
assert(
|
||||
switcherSource.includes("currentWorkspaceName"),
|
||||
"trigger does not use the name",
|
||||
);
|
||||
assert(!switcherSource.includes("<select"), "legacy select switcher remains");
|
||||
});
|
||||
|
||||
Deno.test("workspace menu lists catalog entries and marks the current workspace", () => {
|
||||
assert(
|
||||
switcherSource.includes("listWorkspaces(fetch)"),
|
||||
"catalog is not loaded",
|
||||
);
|
||||
assert(
|
||||
switcherSource.includes("{#each menuWorkspaces as workspace"),
|
||||
"Workspace catalog is not rendered",
|
||||
);
|
||||
assert(
|
||||
switcherSource.includes("aria-current="),
|
||||
"current Workspace is not identified",
|
||||
);
|
||||
assert(
|
||||
switcherSource.includes("workspace.display_name"),
|
||||
"Workspace name is not rendered",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("workspace sidebar uses the workspace name menu as its header", () => {
|
||||
assert(
|
||||
sidebarSource.includes("<WorkspaceSwitcher"),
|
||||
"sidebar omits the menu",
|
||||
);
|
||||
assert(
|
||||
sidebarSource.includes("currentWorkspaceName={workspace.display_name}"),
|
||||
"sidebar does not pass the current Workspace name",
|
||||
);
|
||||
assert(
|
||||
!sidebarSource.includes('class="sidebar-actions-row"'),
|
||||
"old action row remains",
|
||||
);
|
||||
assert(
|
||||
!sidebarSource.includes("RepositoriesNavSection"),
|
||||
"Repositories should not be rendered in the Workspace sidebar",
|
||||
);
|
||||
});
|
||||
@@ -26,8 +26,6 @@
|
||||
<WorkspaceSidebar
|
||||
workspace={data.workspace ?? null}
|
||||
workspaceError={data.workspaceError ?? null}
|
||||
repositories={data.repositories ?? null}
|
||||
repositoriesError={data.repositoriesError ?? null}
|
||||
currentPath={page.url.pathname}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
Reference in New Issue
Block a user