Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
192 changes: 96 additions & 96 deletions client/src/components/features/examples-menu.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from "react";
import { useState, useEffect, useRef, useCallback } from "react";
import {
DropdownMenu,
DropdownMenuContent,
Expand All @@ -14,8 +14,8 @@ import { type ExampleBoardPrompt, exampleBoardPrompt } from "@/lib/example-board
import { ExampleBoardPromptDialog } from "@/components/features/example-board-prompt";
import { useToast } from "@/hooks/use-toast";
import {
ExternalExamplesError,
refreshExternalExamplesCatalog,
loadBuiltInExamplesCatalog,
setActiveExternalExampleContext,
useExternalExamples,
} from "@/lib/external-examples";
Expand All @@ -33,6 +33,15 @@ interface Example {
revision?: string;
}

interface CatalogGroup {
items: Example[];
loading: boolean;
error: boolean;
loaded: boolean;
}

const emptyGroup = (): CatalogGroup => ({ items: [], loading: false, error: false, loaded: false });

interface ExampleDetail {
files: Array<{ name: string; path?: string; content: string }>;
}
Expand All @@ -49,80 +58,61 @@ export function ExamplesMenu({
onLoadExample,
capabilities = getServerCapabilities(true),
}: ExamplesMenuProps) {
const [examples, setExamples] = useState<Example[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [groups, setGroups] = useState<Record<ExampleSource, CatalogGroup>>({ builtin: emptyGroup(), external: emptyGroup() });
const examples = [...groups.builtin.items, ...groups.external.items];
const [loadingExampleId, setLoadingExampleId] = useState<string | null>(null);
const [open, setOpen] = useState(false);
const [boardQuestion, setBoardQuestion] = useState<{ example: Example; prompt: ExampleBoardPrompt } | undefined>();
const [keyboardNavActive, setKeyboardNavActive] = useState(false);
const focusedIndexRef = useRef<number>(-1);
const { toast } = useToast();
const { override } = useExternalExamples();
const selectionRef = useRef(override);
selectionRef.current = override;
const loadGeneration = useRef(0);
const capabilitiesRef = useRef(capabilities);
const previousOpenRef = useRef(false);
const previousOverrideRef = useRef(override);
const controllers = useRef<Partial<Record<ExampleSource, AbortController>>>({});
capabilitiesRef.current = capabilities;

useEffect(() => {
const opened = open && !previousOpenRef.current;
const overrideChanged = previousOverrideRef.current !== override;
previousOpenRef.current = open;
previousOverrideRef.current = override;
if (!open || (!opened && !overrideChanged)) return;
const loadGroup = useCallback(async (source: ExampleSource) => {
if (!capabilitiesRef.current.canUseServerExamples) return;
const generation = ++loadGeneration.current;
let cancelled = false;
const loadExamples = async () => {
try {
setIsLoading(true);
const payload = await refreshExternalExamplesCatalog();
if (
cancelled ||
generation !== loadGeneration.current ||
!capabilitiesRef.current.canUseServerExamples
) return;
const loadedExamples = payload.examples
.map((example) => ({
...example,
...(example.source === "external" &&
payload.source.mode === "repository-ref"
? {
repository: payload.source.repository!,
ref: payload.source.ref!,
revision: payload.source.revision!,
}
: {}),
}))
.toSorted((a, b) =>
`${a.source}/${a.category}/${a.title}`.localeCompare(
`${b.source}/${b.category}/${b.title}`,
),
);
setExamples(loadedExamples);
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError")
return;
console.error("Failed to load examples:", error);
toast({
title: "Failed to Load Examples",
description:
error instanceof ExternalExamplesError
? error.message
: "Could not load example files",
variant: "destructive",
});
} finally {
if (!cancelled && generation === loadGeneration.current)
setIsLoading(false);
}
};
controllers.current[source]?.abort();
const controller = new AbortController();
controllers.current[source] = controller;
const generation = loadGeneration.current;
const isCurrent = () => !controller.signal.aborted && generation === loadGeneration.current
&& capabilitiesRef.current.canUseServerExamples;
setGroups(current => ({ ...current, [source]: { ...emptyGroup(), loading: true } }));
try {
const payload = await (source === "builtin"
? loadBuiltInExamplesCatalog(controller.signal)
: refreshExternalExamplesCatalog(controller.signal));
if (!isCurrent()) return;
const items = payload.examples.filter(example => example.source === source).map(example => ({
...example,
...(source === "external" && payload.source.mode === "repository-ref" ? {
repository: payload.source.repository!, ref: payload.source.ref!, revision: payload.source.revision!,
} : {}),
}));
setGroups(current => ({ ...current, [source]: { items, loading: false, error: false, loaded: true } }));
} catch {
// Our own cancellation is handled by isCurrent; another shared-catalog consumer
// may cancel its transport while this menu is still open, which needs Retry.
if (!isCurrent()) return;
setGroups(current => ({ ...current, [source]: { ...emptyGroup(), error: true } }));
}
}, []);

loadExamples();
useEffect(() => {
if (!open || !capabilitiesRef.current.canUseServerExamples) return;
++loadGeneration.current;
void loadGroup("builtin");
void loadGroup("external");
return () => {
cancelled = true;
controllers.current.builtin?.abort();
controllers.current.external?.abort();
};
}, [open, override, toast]);
}, [open, override, loadGroup]);

// Global shortcut Meta+E to toggle examples menu
useEffect(() => {
Expand Down Expand Up @@ -157,7 +147,7 @@ export function ExamplesMenu({
const getVisibleItems = () => {
const all = Array.from<HTMLElement>(
document.querySelectorAll<HTMLElement>(
'[data-role="example-source"], [data-role="example-folder"], [data-role="example-item"]',
'[data-role="example-source"], [data-role="example-folder"], [data-role="example-item"], [data-role="example-retry"]',
),
);
return all.filter(
Expand All @@ -169,7 +159,7 @@ export function ExamplesMenu({
const clearHighlight = () => {
// Clear from all items including those not currently visible
const allItems = document.querySelectorAll<HTMLElement>(
'[data-role="example-source"], [data-role="example-folder"], [data-role="example-item"]',
'[data-role="example-source"], [data-role="example-folder"], [data-role="example-item"], [data-role="example-retry"]',
);
allItems.forEach((it) => {
it.classList.remove(
Expand Down Expand Up @@ -198,7 +188,7 @@ export function ExamplesMenu({
// Handle mouse movement - clear keyboard highlight and re-enable hover
const onMouseMove = (e: MouseEvent) => {
const target = (e.target as HTMLElement).closest<HTMLElement>(
'[data-role="example-source"], [data-role="example-folder"], [data-role="example-item"]',
'[data-role="example-source"], [data-role="example-folder"], [data-role="example-item"], [data-role="example-retry"]',
);
if (target?.dataset.keyboardFocused === "true") {
clearHighlight();
Expand Down Expand Up @@ -266,11 +256,13 @@ export function ExamplesMenu({
}, [open]);

const handleLoadExample = async (example: Example, boardConfirmed = false) => {
const selection = selectionRef.current;
if (!capabilitiesRef.current.canUseServerExamples) return;
if (loadingExampleId) return;
// An example for another board asks first: switch the board, load anyway, or cancel (plan P4.6).
if (!boardConfirmed && example.board && example.board !== getActiveBoard()) {
const { boards } = await loadRuntimeConfig();
if (selection !== selectionRef.current) return;
const prompt = exampleBoardPrompt(example.board, boards.find((board) => board.id === getActiveBoard()), boards);
if (prompt) {
setBoardQuestion({ example, prompt });
Expand All @@ -279,22 +271,8 @@ export function ExamplesMenu({
}
setLoadingExampleId(example.id);
try {
let query = "";
if (example.source === "external") {
if (!example.repository || !example.revision) {
throw new Error("Example catalog has no revision context");
}
query = `?repository=${encodeURIComponent(example.repository)}&revision=${encodeURIComponent(example.revision)}`;
}
const response = await fetch(
`/api/examples/${encodeURIComponent(example.id)}${query}`,
);
if (!response.ok) throw new Error("Failed to fetch example");
const detail = (await response.json()) as ExampleDetail;
if (!capabilitiesRef.current.canUseServerExamples) return;
if (!Array.isArray(detail.files) || detail.files.length === 0) {
throw new Error("Example contains no files");
}
const detail = await loadExampleDetail(example);
if (!capabilitiesRef.current.canUseServerExamples || selection !== selectionRef.current) return;
const displayName = getExampleDisplayName(example);
setActiveExternalExampleContext(example.source === "external" && example.repository && example.ref && example.revision
? { repository: example.repository, ref: example.ref, revision: example.revision, exampleId: example.id }
Expand All @@ -307,6 +285,7 @@ export function ExamplesMenu({

setOpen(false);
} catch (error) {
if (selection !== selectionRef.current) return;
console.error(`Failed to load example ${example.id}:`, error);
toast({
title: "Failed to Load Example",
Expand Down Expand Up @@ -367,24 +346,28 @@ export function ExamplesMenu({
</div>
<div className="border-t" />

{examples.length === 0 && !isLoading && (
<div className="px-2 py-1.5 ui-type-menu-item text-muted-foreground">
No examples available
</div>
)}

{isLoading && (
<div className="px-2 py-1.5 ui-type-menu-item text-muted-foreground">
Loading examples...
{(["builtin", "external"] as const).map(source => (
<div key={source}>
{groups[source].loading && (
<output className="block px-2 py-1.5 ui-type-menu-item text-muted-foreground">
Loading {source === "builtin" ? "built-in" : "external"} examples...
</output>
)}
{groups[source].error && (
<div className="px-2 py-1.5 ui-type-menu-item text-muted-foreground">
<output className="block">{source === "builtin" ? "Built-in" : "External"} examples are currently unavailable.
{source === "external" && groups.builtin.items.length > 0 ? " Built-in examples are still available." : ""}
</output>
<Button variant="ghost" size="sm" data-role="example-retry" onClick={() => void loadGroup(source)}>Retry</Button>
</div>
)}
</div>
))}
{examples.length === 0 && groups.builtin.loaded && groups.external.loaded && (
<div className="px-2 py-1.5 ui-type-menu-item text-muted-foreground">No examples available</div>
)}

{!isLoading && examples.length > 0 && (
<ExamplesTree
examples={examples}
onLoadExample={handleLoadExample}
canUseServerExamples={capabilities.canUseServerExamples}
/>
{examples.length > 0 && (
<ExamplesTree examples={examples} onLoadExample={handleLoadExample} canUseServerExamples={capabilities.canUseServerExamples} />
)}
</DropdownMenuContent>
</DropdownMenu>
Expand Down Expand Up @@ -431,6 +414,23 @@ function getExampleDisplayName(example: Example): string {
);
}

async function loadExampleDetail(example: Example): Promise<ExampleDetail> {
let query = "";
if (example.source === "external") {
if (!example.repository || !example.revision) {
throw new Error("Example catalog has no revision context");
}
query = `?repository=${encodeURIComponent(example.repository)}&revision=${encodeURIComponent(example.revision)}`;
}
const response = await fetch(`/api/examples/${encodeURIComponent(example.id)}${query}`);
if (!response.ok) throw new Error("Failed to fetch example");
const detail = (await response.json()) as ExampleDetail;
if (!Array.isArray(detail.files) || detail.files.length === 0) {
throw new Error("Example contains no files");
}
return detail;
}

interface ExampleItemProps {
readonly example: Example;
readonly onLoadExample: (example: Example) => void;
Expand Down
14 changes: 14 additions & 0 deletions client/src/lib/external-examples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,20 @@ export async function validateExternalExamplesSelection(
return payload.source;
}

/** Loads local examples independently of selection, external request generation and Tutor state. */
export async function loadBuiltInExamplesCatalog(signal?: AbortSignal): Promise<ExternalExamplesCatalog> {
const response = await fetch("/api/examples/builtin/catalog", { signal });
if (!response.ok) throw await parseApiError(response);
const payload = await parseJson(response);
if (!isCatalog(payload) || payload.source.selection !== "default" || payload.source.mode !== "builtin"
|| payload.source.status !== "builtin" || payload.source.repository !== null || payload.source.ref !== null
|| payload.source.revision !== null || payload.source.stale
|| !payload.examples.every(example => example && typeof example === "object" && example.source === "builtin")) {
throw new ExternalExamplesError("UNKNOWN", "The server returned an invalid built-in examples catalog.");
}
return payload;
}

export async function refreshExternalExamplesCatalog(
signal?: AbortSignal,
): Promise<ExternalExamplesCatalog> {
Expand Down
11 changes: 11 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -455,3 +455,14 @@ Diese Metriken sind über `/api/status` und WebSocket-Events verfügbar. `/api/h
- [`../ssot/ssot_function_definition_ExternalExamples.md`](../ssot/ssot_function_definition_ExternalExamples.md) – Fachlicher Examples-Vertrag
- [`CAPACITY_HISTORICAL_PHASE_3_4.md`](CAPACITY_HISTORICAL_PHASE_3_4.md) – historische Kapazitätsmessung; aktuelle Werte in [`CAPACITY_MODEL.md`](CAPACITY_MODEL.md)
- [`TESTING_STANDARDS.md`](TESTING_STANDARDS.md) – Teststrategie und -konventionen

### Unabhängige Katalogwege für Beispiele

Das Beispiele-Menü lädt parallel `/api/examples/builtin/catalog` (lokale
Dateien aus `public/examples`, BuiltInProvider) und `/api/examples` (bisheriger
gemeinsamer Katalog mit externer Source-Auflösung). Es übernimmt aus dem zweiten
Ergebnis nur externe Einträge. Jede Gruppe hat eigene Lade-, Fehler- und
Retry-Zustände; lokale Beispiele warten nicht auf GitHub. Die lokale Antwort
verändert weder die externe Auswahl noch den Tutor-Kontext. Externe
Ref-Auflösung verwendet die begrenzte SHA-Repräsentation von GitHub, nicht
die vollständige Commit-Antwort.
20 changes: 20 additions & 0 deletions docs/INSTALL_LOCAL.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,3 +118,23 @@ Docker-Sandbox-Tests sind ein getrenntes Gate und werden unter
- Port belegt: `lsof -nP -iTCP:3000 -sTCP:LISTEN` prüfen.
- Docker-Fehler im lokalen Profil weisen auf eine falsche Konfiguration hin;
lokale Entwicklung verwendet Docker nicht.

### Diagnose: Beispiele und GitHub-Commit-Auflösung

Die Ref-Auflösung fordert mit `Accept: application/vnd.github.sha` nur den
Commit-SHA an (eigenes Antwortlimit: 256 Bytes). Große Commit-Diffs beeinflussen
diesen Abruf nicht. `UNOSIM_EXAMPLES_MAX_MANIFEST_BYTES` begrenzt das eigentliche
Manifest, standardmäßig auf 262.144 Bytes. Eine nur wegen großer Commit-Antworten
gesetzte Erhöhung dieser Einstellung kann nach diesem Update entfernt werden;
eine für tatsächlich größere Manifeste benötigte Einstellung bleibt bestehen.
Konfigurationsänderungen erfordern einen Serverneustart bzw. die Neuerzeugung
des Backend-Containers. Bei Quellfehlern die sichere `resolve-ref`-Diagnose im
Backend-Log und die Erreichbarkeit von GitHub prüfen.

Der Diagnoseabruf `GET /api/examples/builtin/catalog` muss bei erreichbarem
Backend auch dann erfolgreich sein, wenn `/api/examples` wegen der externen
Quelle fehlschlägt. Er liest `public/examples` und akzeptiert keine Query-Parameter.
Das Menü lädt beide Gruppen getrennt und bietet bei Fehlern **Retry** an.
Das Hinzufügen eines Beispiel-Repositories zum IDE-Workspace stellt die Quelle
des laufenden Servers nicht um; maßgeblich bleiben Serverkonfiguration und
validierter Browser-Override.
20 changes: 20 additions & 0 deletions docs/INSTALL_SERVER.md
Original file line number Diff line number Diff line change
Expand Up @@ -423,3 +423,23 @@ Datei neu (geprüft am 23.10.2026). Nach dem Rollback laufen alle Sketches im
Host-Mock, Nano und Mega 2560 sind nicht mehr wählbar.

Die vollständigen Gates stehen in [`RELEASE_RUNBOOK.md`](RELEASE_RUNBOOK.md).

### Diagnose: Beispiele und GitHub-Commit-Auflösung

Die Ref-Auflösung fordert mit `Accept: application/vnd.github.sha` nur den
Commit-SHA an (eigenes Antwortlimit: 256 Bytes). Große Commit-Diffs beeinflussen
diesen Abruf nicht. `UNOSIM_EXAMPLES_MAX_MANIFEST_BYTES` begrenzt das eigentliche
Manifest, standardmäßig auf 262.144 Bytes. Eine nur wegen großer Commit-Antworten
gesetzte Erhöhung dieser Einstellung kann nach diesem Update entfernt werden;
eine für tatsächlich größere Manifeste benötigte Einstellung bleibt bestehen.
Konfigurationsänderungen erfordern einen Serverneustart bzw. die Neuerzeugung
des Backend-Containers. Bei Quellfehlern die sichere `resolve-ref`-Diagnose im
Backend-Log und die Erreichbarkeit von GitHub prüfen.

Der Diagnoseabruf `GET /api/examples/builtin/catalog` muss bei erreichbarem
Backend auch dann erfolgreich sein, wenn `/api/examples` wegen der externen
Quelle fehlschlägt. Er liest `public/examples` und akzeptiert keine Query-Parameter.
Das Menü lädt beide Gruppen getrennt und bietet bei Fehlern **Retry** an.
Das Hinzufügen eines Beispiel-Repositories zum IDE-Workspace stellt die Quelle
des laufenden Servers nicht um; maßgeblich bleiben Serverkonfiguration und
validierter Browser-Override.
Loading
Loading