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": "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",
|
"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",
|
"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",
|
"build": "deno run -A npm:vite@7.2.7 build",
|
||||||
"preview": "deno run -A npm:vite@7.2.7 preview"
|
"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">
|
<script lang="ts">
|
||||||
import { workspaceRoute } from '$lib/workspace/api/http';
|
|
||||||
import './sidebar.css';
|
import './sidebar.css';
|
||||||
import ObjectivesNavSection from './ObjectivesNavSection.svelte';
|
import ObjectivesNavSection from './ObjectivesNavSection.svelte';
|
||||||
import MemoryNavSection from './MemoryNavSection.svelte';
|
import MemoryNavSection from './MemoryNavSection.svelte';
|
||||||
import RepositoriesNavSection from './RepositoriesNavSection.svelte';
|
|
||||||
import TicketsNavSection from './TicketsNavSection.svelte';
|
import TicketsNavSection from './TicketsNavSection.svelte';
|
||||||
import WorkersNavSection from './WorkersNavSection.svelte';
|
import WorkersNavSection from './WorkersNavSection.svelte';
|
||||||
import WorkspaceSwitcher from './WorkspaceSwitcher.svelte';
|
import WorkspaceSwitcher from './WorkspaceSwitcher.svelte';
|
||||||
import type { RepositoryListResponse, WorkspaceResponse } from './types';
|
import type { WorkspaceResponse } from './types';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
workspace: WorkspaceResponse | null;
|
workspace: WorkspaceResponse | null;
|
||||||
workspaceError?: string | null;
|
workspaceError?: string | null;
|
||||||
repositories?: RepositoryListResponse | null;
|
|
||||||
repositoriesError?: string | null;
|
|
||||||
currentPath?: string;
|
currentPath?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
let {
|
let { workspace, workspaceError = null, currentPath = '/' }: Props = $props();
|
||||||
workspace,
|
|
||||||
workspaceError = null,
|
|
||||||
repositories = null,
|
|
||||||
repositoriesError = null,
|
|
||||||
currentPath = '/'
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
let workspaceId = $derived(workspace?.workspace_id ?? '');
|
let workspaceId = $derived(workspace?.workspace_id ?? '');
|
||||||
let homeHref = $derived(workspaceId ? workspaceRoute(workspaceId) : '/');
|
|
||||||
let settingsHref = $derived(workspaceId ? workspaceRoute(workspaceId, '/settings') : '/settings');
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="workspace-sidebar">
|
<div class="workspace-sidebar">
|
||||||
<header class="sidebar-header">
|
<header class="sidebar-header">
|
||||||
<div class="sidebar-title-row">
|
|
||||||
<div class="workspace-label">
|
|
||||||
{#if workspace}
|
{#if workspace}
|
||||||
<div class="workspace-name">{workspace.display_name}</div>
|
<WorkspaceSwitcher
|
||||||
|
currentWorkspaceId={workspaceId}
|
||||||
|
currentWorkspaceName={workspace.display_name}
|
||||||
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
|
<div class="workspace-label">
|
||||||
<div class="workspace-name">Yoi workspace</div>
|
<div class="workspace-name">Yoi workspace</div>
|
||||||
{#if workspaceError}
|
{#if workspaceError}
|
||||||
<p class="workspace-status error">Workspace summary unavailable.</p>
|
<p class="workspace-status error">Workspace summary unavailable.</p>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="workspace-status">Loading workspace…</p>
|
<p class="workspace-status">Loading workspace…</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
</div>
|
||||||
{/if}
|
{/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>
|
</header>
|
||||||
|
|
||||||
{#if workspaceId}<WorkspaceSwitcher currentWorkspaceId={workspaceId} />{/if}
|
|
||||||
|
|
||||||
<nav class="sidebar-sections" aria-label="Workspace sections">
|
<nav class="sidebar-sections" aria-label="Workspace sections">
|
||||||
<RepositoriesNavSection {repositories} {repositoriesError} {currentPath} {workspaceId} />
|
|
||||||
<TicketsNavSection {currentPath} {workspaceId} />
|
<TicketsNavSection {currentPath} {workspaceId} />
|
||||||
<ObjectivesNavSection {currentPath} {workspaceId} />
|
<ObjectivesNavSection {currentPath} {workspaceId} />
|
||||||
<MemoryNavSection {currentPath} {workspaceId} />
|
<MemoryNavSection {currentPath} {workspaceId} />
|
||||||
|
|||||||
@@ -1,55 +1,196 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from "$app/navigation";
|
import { onMount, tick } from "svelte";
|
||||||
import { onMount } from "svelte";
|
|
||||||
import {
|
import {
|
||||||
listWorkspaces,
|
listWorkspaces,
|
||||||
type WorkspaceCatalogRecord,
|
type WorkspaceCatalogRecord,
|
||||||
} from "$lib/workspace/api/workspace-catalog";
|
} 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 workspaces = $state<WorkspaceCatalogRecord[]>([]);
|
||||||
let loading = $state(true);
|
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 {
|
try {
|
||||||
workspaces = await listWorkspaces(fetch);
|
workspaces = await listWorkspaces(fetch);
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
error = cause instanceof Error ? cause.message : String(cause);
|
error = cause instanceof Error ? cause.message : "Failed to load Workspaces.";
|
||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="workspace-switcher">
|
<div class="workspace-menu" bind:this={root}>
|
||||||
<label for="workspace-switcher-select">Workspace</label>
|
<button
|
||||||
<select
|
bind:this={trigger}
|
||||||
id="workspace-switcher-select"
|
type="button"
|
||||||
value={currentWorkspaceId}
|
class="workspace-menu-trigger"
|
||||||
onchange={switchWorkspace}
|
aria-haspopup="menu"
|
||||||
disabled={loading}
|
aria-expanded={open}
|
||||||
aria-label="Switch Workspace"
|
aria-controls="workspace-menu-popover"
|
||||||
|
onclick={toggleMenu}
|
||||||
|
onkeydown={handleTriggerKeydown}
|
||||||
>
|
>
|
||||||
{#if !workspaces.some((workspace) => workspace.workspace_id === currentWorkspaceId)}
|
<span>{currentWorkspaceName}</span>
|
||||||
<option value={currentWorkspaceId}>
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
{loading ? "Loading current Workspace…" : "Current Workspace unavailable"}
|
<path d="m8 10 4 4 4-4" />
|
||||||
</option>
|
</svg>
|
||||||
{/if}
|
</button>
|
||||||
{#each workspaces as workspace (workspace.workspace_id)}
|
|
||||||
<option value={workspace.workspace_id}>{workspace.display_name}</option>
|
{#if open}
|
||||||
{/each}
|
<div
|
||||||
</select>
|
bind:this={menu}
|
||||||
<div class="workspace-switcher-actions">
|
id="workspace-menu-popover"
|
||||||
<a href="/">All Workspaces</a>
|
class="workspace-menu-popover"
|
||||||
<a href="/#workspace-create-title">Create</a>
|
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>
|
</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>
|
</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);
|
margin-bottom: var(--space-2);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.sidebar-control-row,
|
.sidebar-control-row {
|
||||||
.sidebar-actions-row {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: var(--space-1);
|
gap: var(--space-1);
|
||||||
@@ -57,12 +56,6 @@
|
|||||||
padding-inline: var(--space-2);
|
padding-inline: var(--space-2);
|
||||||
padding-top: 0;
|
padding-top: 0;
|
||||||
}
|
}
|
||||||
.sidebar-title-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-2);
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.workspace-label {
|
.workspace-label {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -80,6 +73,157 @@
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
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 {
|
.workspace-status {
|
||||||
margin: var(--space-1) 0 0;
|
margin: var(--space-1) 0 0;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
@@ -91,7 +235,6 @@
|
|||||||
.error {
|
.error {
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
.sidebar-icon-button,
|
|
||||||
.sidebar-fold-button {
|
.sidebar-fold-button {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -118,8 +261,6 @@
|
|||||||
stroke-linecap: round;
|
stroke-linecap: round;
|
||||||
stroke-linejoin: round;
|
stroke-linejoin: round;
|
||||||
}
|
}
|
||||||
.sidebar-icon-button:hover,
|
|
||||||
.sidebar-icon-button:focus-visible,
|
|
||||||
.sidebar-fold-button:hover,
|
.sidebar-fold-button:hover,
|
||||||
.sidebar-fold-button:focus-visible {
|
.sidebar-fold-button:focus-visible {
|
||||||
background: var(--interactive-hover);
|
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
|
<WorkspaceSidebar
|
||||||
workspace={data.workspace ?? null}
|
workspace={data.workspace ?? null}
|
||||||
workspaceError={data.workspaceError ?? null}
|
workspaceError={data.workspaceError ?? null}
|
||||||
repositories={data.repositories ?? null}
|
|
||||||
repositoriesError={data.repositoriesError ?? null}
|
|
||||||
currentPath={page.url.pathname}
|
currentPath={page.url.pathname}
|
||||||
/>
|
/>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|||||||
Reference in New Issue
Block a user