feat: add pinned allocator lesson view

This commit is contained in:
user
2026-07-17 17:07:08 +02:00
parent e89f3f11ce
commit 759fdacd14
3 changed files with 516 additions and 19 deletions
+29 -1
View File
@@ -27,9 +27,15 @@ dlatego nie należy wystawiać go bezpośrednio poza zaufany host.
Hierarchia nawigacji jest stała:
```text
task → block → phase → step → snapshot
card → task → block → phase → step → snapshot
```
Ruch kursora przez `move` lub `PUT current` nie uruchamia symulatora.
Jawna aktywacja odbywa się przez `Enter` w WWW albo `F2` w WWW/Neovimie,
które wywołują `POST /api/navigation/sync`. Dla poziomu BLOCK backend wybiera
pierwszy STEP tego bloku. CARD i TASK są odrzucane, ponieważ nie wskazują
konkretnego checkpointu diagramu.
| Metoda i endpoint | Znaczenie |
| --- | --- |
| `GET /api/keyboard` | Skróty i obsługiwane poziomy nawigacji. |
@@ -37,6 +43,7 @@ task → block → phase → step → snapshot
| `GET /api/navigation/current` | Bieżąca pozycja oraz status checkpointu. |
| `PUT /api/navigation/current` | Ustawia dokładną pozycję i poziom fokusu. |
| `POST /api/navigation/move` | Przesuwa kursor o `delta` na wybranym poziomie. |
| `POST /api/navigation/sync` | Odtwarza checkpoint przypisany do bieżącej, zapisanej pozycji. |
Przykład ustawienia kroku — identyfikatory są danymi konkretnej karty:
@@ -81,6 +88,13 @@ Wywołanie orkiestratora przypina zarówno kontener, jak i rewizję karty:
}
```
Backend przechowuje bieżącą nawigację i stan viewera w SQLite. Domyślna baza
to `.stem/card-state.sqlite3`; ścieżkę można zmienić przez
`CARD_STATE_DATABASE`. Ta sama aplikacja wystawia HTTP również przez lokalny
socket `.stem/card-api.sock` (`CARD_API_SOCKET`). Repozytorium jest montowane w
kontenerze jako `/workspace`, dlatego Neovim wywołuje API przez
`/workspace/.stem/card-api.sock` bez otwierania dodatkowego portu sieciowego.
Replay kończy się powodzeniem wyłącznie ze statusem `ready`. Zmiana kontenera,
socketu MCP albo źródłowego JSON-a podczas operacji powoduje odrzucenie
wyniku; pozycja strony i `SYNC ON` nie są wtedy publikowane.
@@ -93,6 +107,8 @@ wyniku; pozycja strony i `SYNC ON` nie są wtedy publikowane.
| `GET /api/nvim/state` | Bieżący bufor, okno, kursor i tryb Neovima. |
| `PUT /api/nvim/cursor` | Ustawia `row`, `column` i opcjonalnie `window`. |
| `POST /api/nvim/input` | Przekazuje do 256 znaków przez `nvim_input`. |
| `POST /api/nvim/reset` | Resetuje cel i odświeża debugger (`F1`). |
| `POST /api/nvim/redraw` | Dopasowuje host pane i grid UI, a następnie wykonuje pełny redraw (`Ctrl+\``). |
| `WS /api/nvim-ui` | Strumień zewnętrznego UI Neovima do panelu WWW. |
Pole `viewer.scroll` zapisuje położenie dokumentu (`x`, `y`), numer strony
@@ -104,6 +120,10 @@ Wyłączenie `viewer.nvim.sync` automatycznie wyłącza również
`viewer.nvim.control`. Samo ponowne włączenie synchronizacji nie uzbraja
sterowania klawiaturą bez jawnego `control: true`.
`viewer.allocator.visible` przechowuje stan przypiętego modelu alokatora.
Model jest przełączany przez `Alt+6` i pokazuje fazę/krok, `allocp`, zajętość
areny oraz wartości związane z bieżącym checkpointem.
### Podział odpowiedzialności widoków
Interaktywny diagram UML pokazuje przepływ i służy do wyboru kroku. Nie
@@ -119,6 +139,14 @@ diagramem.
| `GET /api/progress` | Katalog, statusy i podsumowanie wykonania. |
| `PUT /api/progress/:itemId` | Ustawia `pending` albo `approved`, notatkę i aktora. |
| `GET /api/report/teams.md` | Generuje bieżący raport Markdown do Teams. |
| `GET/POST /api/events` | Odczytuje lub dopisuje chronologiczne zdarzenia z timestampem backendu. |
| `POST /api/evidence` | Zapisuje PNG/JPEG i przypina go do zdarzenia. |
| `GET /api/evidence/:name` | Udostępnia zapisany dowód. |
| `GET /api/report/lesson.html` | Renderuje chronologiczny raport HTML z dowodami. |
Zdarzenia są dopisywane do SQLite i obejmują nawigację, aktywacje
checkpointów, reset/redraw Neovima, zmiany viewera oraz postęp. Dzięki temu
raport pokazuje rzeczywistą kolejność pracy, a nie wyłącznie końcowy stan.
## Planowane strategie i adnotacje
+300 -18
View File
@@ -3,7 +3,7 @@ import { createRoot } from "react-dom/client";
import "./react.css";
type Status = "pending" | "approved";
type NavigationLevel = "task" | "block" | "phase" | "step" | "snapshot";
type NavigationLevel = "card" | "task" | "block" | "phase" | "step" | "snapshot";
type NavigationPosition = {
task_index: number;
block_index: number;
@@ -36,6 +36,7 @@ type ApiViewerState = {
sheet_scroll_top?: number;
};
viewport: { width: number; height: number };
allocator?: { visible: boolean };
nvim: {
visible: boolean;
sync: boolean;
@@ -465,6 +466,8 @@ function Topbar({
setNvimExpanded,
nvimFit,
setNvimFit,
allocatorVisible,
setAllocatorVisible,
nvimSummary,
}: {
toc: CardModel["toc"];
@@ -480,6 +483,8 @@ function Topbar({
setNvimExpanded: (value: boolean) => void;
nvimFit: boolean;
setNvimFit: (value: boolean) => void;
allocatorVisible: boolean;
setAllocatorVisible: (value: boolean) => void;
nvimSummary: NvimPanelSummary;
}) {
return (
@@ -549,6 +554,16 @@ function Topbar({
>
<kbd className="nvim-toolbar-key">A5</kbd><span className="nvim-toolbar-label">FIT</span>
</button>
<button
type="button"
className={`nvim-height-toggle nvim-toolbar-button${allocatorVisible ? " is-active" : ""}`}
aria-label={`Alt+6: model alokatora ${allocatorVisible ? "przypięty" : "ukryty"}`}
title={allocatorVisible ? "Alt+6 · Ukryj model alokatora" : "Alt+6 · Przypnij model alokatora"}
aria-pressed={allocatorVisible}
onClick={() => setAllocatorVisible(!allocatorVisible)}
>
<kbd className="nvim-toolbar-key">A6</kbd><span className="nvim-toolbar-label">MODEL</span>
</button>
</div>
<span className="nvim-toolbar-boundary" aria-hidden="true">|</span>
{nvimVisible && (
@@ -561,7 +576,7 @@ function Topbar({
{nvimSummary.detail && <small>{nvimSummary.detail}</small>}
</span>
<span className="nvim-shortcut-strip" aria-label="Skróty panelu Neovima">
<kbd>Alt+W</kbd> okna · <kbd>F12</kbd> skróty
<kbd>F1</kbd> reset · <kbd>F2</kbd> sync · <kbd>Alt+W</kbd> okna · <kbd>F12</kbd> skróty
</span>
</>
)}
@@ -608,6 +623,138 @@ type ActiveNvimStep = {
focusLevel?: NavigationLevel;
activate?: boolean;
};
function allocatorStepFromModel(
model: CardModel,
navigation?: ApiNavigation | null,
): ActiveNvimStep | null {
for (const section of model.sections) {
for (const asset of section.assets) {
const interactive = asset.interactive;
if (interactive?.kind !== "uml-sequence" || !interactive.phases?.length) continue;
if (navigation?.task.id && interactive.task?.id !== navigation.task.id) continue;
if (navigation?.block.id && interactive.block?.id !== navigation.block.id) continue;
for (const phase of interactive.phases) {
if (navigation?.phase.id && phase.id !== navigation.phase.id) continue;
const step = navigation?.step.id
? phase.steps.find(item => item.id === navigation.step.id)
: phase.steps[0];
if (!step) continue;
return {
task: interactive.task ?? null,
block: interactive.block ?? null,
phase: { id: phase.id, label: phase.label },
step,
focusLevel: navigation?.focus_level ?? "step",
activate: false,
};
}
}
}
return null;
}
function AllocatorPanel({ visible, model }: { visible: boolean; model: CardModel }) {
const [active, setActive] = useState<ActiveNvimStep | null>(null);
useEffect(() => {
const selected = (event: Event) => {
setActive((event as CustomEvent<ActiveNvimStep>).detail ?? null);
};
window.addEventListener("stem:nvim-step", selected);
setActive(allocatorStepFromModel(model));
void fetch("/api/navigation/current", { cache: "no-store" })
.then(response => response.ok ? response.json() : Promise.reject())
.then(payload => {
const resolved = allocatorStepFromModel(model, payload.current ?? null);
if (resolved) setActive(resolved);
})
.catch(() => undefined);
return () => window.removeEventListener("stem:nvim-step", selected);
}, [model]);
if (!visible) return null;
const focusLevel = active?.focusLevel ?? "card";
const arena = active?.step.snapshot.memory.arena;
const hasUmlCursor = Boolean(active && focusLevel !== "card" && focusLevel !== "task");
const size = Math.max(1, Number(arena?.size_bytes ?? 64));
const offset = arena?.offset == null
? null
: Math.max(0, Math.min(size, Number(arena.offset)));
const byteValues = arena?.bytes.trim().split(/\s+/).filter(Boolean) ?? [];
const cellCount = Math.min(size, 64);
const facts = active
? [
...active.step.snapshot.memory.items.map(item => ({
name: item.name,
value: item.value,
})),
...active.step.snapshot.frame.fields.map(field => ({
name: field.name,
value: field.value,
})),
].filter((fact, index, all) =>
all.findIndex(candidate => candidate.name === fact.name) === index,
).slice(0, 8)
: [];
return (
<section className="allocator-panel" aria-label="Przypięty model alokatora">
<header>
<span>ALLOCATOR · PINNED</span>
<strong>
{active
? `${active.phase.label} · ${String(active.step.number).padStart(2, "0")} ${active.step.label}`
: "Oczekiwanie na diagram UML"}
</strong>
<small>
{hasUmlCursor
? active!.step.snapshot.description
: "Wybierz co najmniej BLOCK. F2 wybierze jego pierwszy krok i wykona synchronizację."}
</small>
</header>
<div className="allocator-panel-body">
<dl className="allocator-metrics">
<div><dt>FOCUS</dt><dd>{focusLevel.toUpperCase()}</dd></div>
<div><dt>BASE</dt><dd>{arena?.base ?? "—"}</dd></div>
<div><dt>ALLOCP</dt><dd>{arena?.cursor ?? "—"}</dd></div>
<div><dt>USED / FREE</dt><dd>{offset == null ? "—" : `${offset} / ${size - offset} B`}</dd></div>
</dl>
<div className={`allocator-arena-view${hasUmlCursor ? "" : " is-inactive"}`}>
<div className="allocator-arena-labels">
<span>allocbuf[0]</span>
<strong>{offset == null ? "allocp jeszcze nieustawiony" : `offset ${offset}`}</strong>
<span>allocbuf[{size - 1}]</span>
</div>
<div
className="allocator-pinned-arena"
style={{ "--allocator-size": cellCount } as React.CSSProperties}
>
{Array.from({ length: cellCount }, (_, index) => (
<i
key={index}
className={offset != null && index < offset ? "is-allocated" : ""}
title={`allocbuf[${index}] = ${byteValues[index] ?? "??"}`}
/>
))}
{offset != null && (
<b
className="allocator-cursor"
style={{ left: `${offset / size * 100}%` }}
title={`allocp = ${arena?.cursor}`}
/>
)}
</div>
</div>
<div className="allocator-facts" aria-label="Wartości bieżącego kroku">
{facts.length ? facts.map(fact => (
<div key={fact.name}><span>{fact.name}</span><code>{fact.value}</code></div>
)) : <p>Brak snapshotu.</p>}
</div>
</div>
</section>
);
}
type NvimConnectionStatus = {
state: "hidden" | "connecting" | "connected" | "offline" | "unselected";
message?: string;
@@ -1253,6 +1400,25 @@ function NeovimPanel({
});
saveNvimGrid(snapshotRef, gridRef.current);
redraw();
await new Promise<void>(resolve => window.requestAnimationFrame(() => resolve()));
const canvas = canvasRef.current;
if (canvas && activeStepRef.current?.step.snapshot_ref === snapshotRef) {
try {
const dataUrl = canvas.toDataURL("image/png");
void fetch("/api/evidence", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
actor: document.documentElement.dataset.cardClientId ?? "browser",
snapshot_ref: snapshotRef,
caption: `${String(detail.step.number).padStart(2, "0")} ${detail.step.label}`,
data_url: dataUrl,
}),
}).catch(() => undefined);
} catch {
// Evidence is auxiliary; a failed screenshot must not invalidate replay.
}
}
})
.catch(error => {
if (controller.signal.aborted || checkpointGenerationRef.current !== generation) return;
@@ -2168,7 +2334,7 @@ function SnapshotInteractiveUmlSequence({
);
const [focusLevel, setFocusLevel] = useState<NavigationLevel>(() => {
const saved = localStorage.getItem(focusStorageKey);
return saved === "task" || saved === "block" || saved === "phase"
return saved === "card" || saved === "task" || saved === "block" || saved === "phase"
|| saved === "step" || saved === "snapshot"
? saved
: "step";
@@ -2304,7 +2470,7 @@ function SnapshotInteractiveUmlSequence({
);
hit.dataset.umlStep = String(index);
hit.classList.add("uml-step-hit");
hit.classList.toggle("uml-step-hit-active", index === stepIndex);
hit.classList.toggle("uml-step-hit-active", focusLevel !== "card" && index === stepIndex);
hit.classList.toggle("uml-step-hit-completed", completed);
svg.appendChild(hit);
});
@@ -2390,9 +2556,14 @@ function SnapshotInteractiveUmlSequence({
focusLevel: level,
activate: activate && document.documentElement.dataset.nvimSyncMode === "on",
};
window.dispatchEvent(new CustomEvent<ActiveNvimStep>("stem:nvim-step", { detail }));
if (!persist) return;
void fetch("/api/navigation/current", {
const dispatch = () => {
window.dispatchEvent(new CustomEvent<ActiveNvimStep>("stem:nvim-step", { detail }));
};
if (!persist) {
dispatch();
return;
}
const save = fetch("/api/navigation/current", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({
@@ -2409,11 +2580,19 @@ function SnapshotInteractiveUmlSequence({
sync_requested: Boolean(detail.activate),
actor: document.documentElement.dataset.cardClientId ?? "browser",
}),
}).catch(() => undefined);
});
if (detail.activate) {
// Activation must be audited against the newly selected step. In a static
// export the request fails, but the local interactive model still works.
void save.then(dispatch, dispatch);
} else {
dispatch();
void save.catch(() => undefined);
}
};
const selectIndex = (
next: number,
activate = true,
activate = false,
level: NavigationLevel = focusLevel,
persist = true,
) => {
@@ -2455,7 +2634,7 @@ function SnapshotInteractiveUmlSequence({
);
});
const next = Number(hits[0]?.dataset.umlStep);
if (Number.isInteger(next)) selectIndex(next, true, "step");
if (Number.isInteger(next)) selectIndex(next, false, "step");
};
const handleSvgKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key !== "Enter" && event.key !== " ") return;
@@ -2463,11 +2642,11 @@ function SnapshotInteractiveUmlSequence({
const next = Number(target.dataset?.umlStep);
if (!Number.isInteger(next)) return;
event.preventDefault();
selectIndex(next, true, "step");
selectIndex(next, event.key === "Enter", "step");
};
const selectPhase = (phase: UmlSequencePhase) => {
const next = entries.findIndex((entry) => entry.phase.id === phase.id);
if (next >= 0) selectIndex(next, true, "phase");
if (next >= 0) selectIndex(next, false, "phase");
};
const active = entries[stepIndex];
@@ -2494,7 +2673,9 @@ function SnapshotInteractiveUmlSequence({
entry.phase.id === command.phase.id && entry.step.id === command.step.id,
);
if (next < 0) return;
selectIndex(next, command.sync_requested, command.focus_level, false);
// Remote navigation only moves the teaching cursor. The producer that
// requested a replay owns it; reflecting state must never replay twice.
selectIndex(next, false, command.focus_level, false);
};
window.addEventListener("stem:navigation-command", applyCommand);
return () => window.removeEventListener("stem:navigation-command", applyCommand);
@@ -2536,7 +2717,7 @@ function SnapshotInteractiveUmlSequence({
return;
}
const hierarchy: NavigationLevel[] = ["task", "block", "phase", "step", "snapshot"];
const hierarchy: NavigationLevel[] = ["card", "task", "block", "phase", "step", "snapshot"];
if ((event.key === "ArrowLeft" || event.key === "ArrowRight" || event.key === "Escape")
&& !event.altKey && !event.ctrlKey && !event.shiftKey) {
const direction = event.key === "ArrowRight" ? 1 : -1;
@@ -2552,6 +2733,7 @@ function SnapshotInteractiveUmlSequence({
}
if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return;
if (focusLevel === "card" && !event.altKey && !event.ctrlKey && !event.shiftKey) return;
const direction = event.key === "ArrowUp" ? -1 : 1;
let level: NavigationLevel = focusLevel;
if (event.altKey && event.shiftKey && !event.ctrlKey) level = "snapshot";
@@ -2568,7 +2750,7 @@ function SnapshotInteractiveUmlSequence({
body: JSON.stringify({
level,
delta: direction,
sync_requested: document.documentElement.dataset.nvimSyncMode === "on",
sync_requested: false,
actor: document.documentElement.dataset.cardClientId ?? "browser",
}),
})
@@ -2628,7 +2810,7 @@ function SnapshotInteractiveUmlSequence({
<button
key={entry.step.id}
aria-label={`Krok ${entry.step.number}: ${entry.step.label}`}
aria-pressed={index === stepIndex}
aria-pressed={focusLevel !== "card" && index === stepIndex}
data-completed={isDone ? "true" : "false"}
onClick={() => selectIndex(index)}
>
@@ -2857,6 +3039,10 @@ function ProgressDock({
<a href="/api/report/teams.md" target="_blank" rel="noopener">
Otwórz raport do Teams
</a>
<br />
<a href="/api/report/lesson.html" target="_blank" rel="noopener">
Otwórz ślad lekcji ze zrzutami
</a>
</aside>
);
}
@@ -2873,12 +3059,16 @@ function ShortcutHelp({ open, onClose }: { open: boolean; onClose: () => void })
["→", "zejdź poziom niżej"],
["← / Esc", "wróć poziom wyżej"],
["Enter", "wybierz element; przy SYNC ON odtwórz stan"],
["F1", "reset aktualnego celu Neovima/kontenera"],
["F2", "synchronizuj cel z kursorem drzewa UML"],
["Ctrl + `", "przelicz siatkę i odśwież cały widok Neovima"],
["Ctrl + Enter", "zatwierdź lub cofnij zatwierdzenie kroku"],
["Alt + 1", "pokaż lub ukryj panel Neovima"],
["Alt + 2", "przełącz SYNC OFF / SYNC ON"],
["Alt + 3", "uzbrój lub rozbrój sterowanie Neovimem"],
["Alt + 4", "wysoki panel 90% / zapisana wysokość normalna"],
["Alt + 5", "dopasuj cały ekran Neovima; wyłącz ręczny suwak"],
["Alt + 6", "przypnij lub ukryj model alokatora"],
["Alt + 3, potem najedź", "przekaż klawiaturę i mysz do aktywnej sesji"],
["Alt + W", "prefiks poleceń okien Neovima (<C-W>)"],
["Alt + strzałka", "przejdź do sąsiedniego okna Neovima"],
@@ -2896,7 +3086,7 @@ function ShortcutHelp({ open, onClose }: { open: boolean; onClose: () => void })
<header>
<div>
<small>F12 · NAWIGACJA DYDAKTYCZNA</small>
<h2 id="shortcut-help-title">TASK BLOCK PHASE STEP SNAPSHOT</h2>
<h2 id="shortcut-help-title">CARD TASK BLOCK PHASE STEP SNAPSHOT</h2>
</div>
<button onClick={onClose} aria-label="Zamknij skorowidz">×</button>
</header>
@@ -2930,6 +3120,7 @@ function App({ model }: { model: CardModel }) {
const pendingRemoteNavigationReportRef = useRef(false);
const remoteScrollFrameRef = useRef(0);
const remoteScrollTokenRef = useRef(0);
const shortcutStatusTimerRef = useRef(0);
const [scale, setScaleState] = useState(2.18);
const [nvimVisible, setNvimVisibleState] = useState(
() => localStorage.getItem("stem-card-nvim-visible") !== "false",
@@ -2943,6 +3134,9 @@ function App({ model }: { model: CardModel }) {
);
const [nvimExpanded, setNvimExpandedState] = useState(false);
const [nvimFit, setNvimFitState] = useState(false);
const [allocatorVisible, setAllocatorVisibleState] = useState(
() => localStorage.getItem("stem-card-allocator-visible") !== "false",
);
const [nvimSummary, setNvimSummary] = useState<NvimPanelSummary>({
state: "hidden",
syncMode: false,
@@ -2953,6 +3147,60 @@ function App({ model }: { model: CardModel }) {
const [progress, setProgress] = useState<Progress | null>(null);
const [progressError, setProgressError] = useState("");
const [shortcutsOpen, setShortcutsOpen] = useState(false);
const [shortcutStatus, setShortcutStatus] = useState("");
const showShortcutStatus = useCallback((message: string) => {
window.clearTimeout(shortcutStatusTimerRef.current);
setShortcutStatus(message);
shortcutStatusTimerRef.current = window.setTimeout(() => setShortcutStatus(""), 4200);
}, []);
const runWebFunctionKey = useCallback(async (key: "F1" | "F2") => {
showShortcutStatus(`${key} · wykonywanie…`);
try {
const endpoint = key === "F1" ? "/api/nvim/reset" : "/api/navigation/sync";
const response = await fetch(endpoint, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ actor: clientIdRef.current }),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.message ?? payload.error ?? `HTTP ${response.status}`);
if (key === "F2" && payload.current) {
navigationRevisionRef.current = Math.max(
navigationRevisionRef.current,
Number(payload.current.revision ?? 0),
);
window.dispatchEvent(new CustomEvent<ApiNavigation>("stem:navigation-command", {
detail: payload.current,
}));
showShortcutStatus(
`F2 · ${String(payload.current.step.number).padStart(2, "0")} ${payload.current.step.label} · READY`,
);
} else {
showShortcutStatus("F1 · reset przyjęty");
}
window.setTimeout(() => window.dispatchEvent(new Event("stem:nvim-refresh")), 120);
} catch (error) {
showShortcutStatus(`${key} · ${error instanceof Error ? error.message : String(error)}`);
}
}, [showShortcutStatus]);
const runWebRedraw = useCallback(async () => {
showShortcutStatus("Ctrl+` · przeliczanie widoku…");
try {
const response = await fetch("/api/nvim/redraw", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ actor: clientIdRef.current }),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.message ?? payload.error ?? `HTTP ${response.status}`);
const grid = payload.external_ui?.grid;
const dimensions = grid ? ` · ${grid.width}×${grid.height}` : "";
showShortcutStatus(`Ctrl+\` · widok odświeżony${dimensions}`);
window.dispatchEvent(new Event("stem:nvim-refresh"));
} catch (error) {
showShortcutStatus(`Ctrl+\` · ${error instanceof Error ? error.message : String(error)}`);
}
}, [showShortcutStatus]);
const publishViewer = useCallback(async (viewer: Record<string, unknown>) => {
try {
const response = await fetch("/api/viewer/state", {
@@ -3030,6 +3278,11 @@ function App({ model }: { model: CardModel }) {
setNvimFitState(value);
void publishViewer({ nvim: { visible: value || nvimVisible, fit: value, expanded: false } });
};
const setAllocatorVisible = (value: boolean) => {
setAllocatorVisibleState(value);
localStorage.setItem("stem-card-allocator-visible", String(value));
void publishViewer({ allocator: { visible: value } });
};
useEffect(() => {
let second = 0;
const first = window.requestAnimationFrame(() => {
@@ -3069,6 +3322,7 @@ function App({ model }: { model: CardModel }) {
expanded: nvimExpanded,
fit: nvimFit,
},
allocator: { visible: allocatorVisible },
};
const applyViewer = (viewer: ApiViewerState) => {
const fit = viewer.nvim.visible && viewer.nvim.fit;
@@ -3079,12 +3333,15 @@ function App({ model }: { model: CardModel }) {
setNvimControlModeState(viewer.nvim.sync && viewer.nvim.control);
setNvimExpandedState(expanded);
setNvimFitState(fit);
const allocator = viewer.allocator?.visible ?? true;
setAllocatorVisibleState(allocator);
localStorage.setItem("stem-card-nvim-visible", String(viewer.nvim.visible));
localStorage.setItem("stem-card-nvim-sync-mode", String(viewer.nvim.sync));
localStorage.setItem(
"stem-card-nvim-control-mode",
String(viewer.nvim.sync && viewer.nvim.control),
);
localStorage.setItem("stem-card-allocator-visible", String(allocator));
const token = ++remoteScrollTokenRef.current;
applyingRemoteScrollRef.current = true;
window.cancelAnimationFrame(remoteScrollFrameRef.current);
@@ -3267,6 +3524,26 @@ function App({ model }: { model: CardModel }) {
setNvimFit(!nvimFit);
return;
}
if (browserCommand === "Digit6") {
event.preventDefault();
event.stopImmediatePropagation();
setAllocatorVisible(!allocatorVisible);
return;
}
if ((event.key === "F1" || event.key === "F2")
&& !event.altKey && !event.ctrlKey && !event.metaKey && !event.shiftKey) {
event.preventDefault();
event.stopImmediatePropagation();
void runWebFunctionKey(event.key);
return;
}
if (event.ctrlKey && !event.altKey && !event.metaKey && !event.shiftKey
&& (event.code === "Backquote" || event.key === "`")) {
event.preventDefault();
event.stopImmediatePropagation();
void runWebRedraw();
return;
}
if (event.key === "F12") {
event.preventDefault();
event.stopImmediatePropagation();
@@ -3281,7 +3558,8 @@ function App({ model }: { model: CardModel }) {
};
window.addEventListener("keydown", navigate, true);
return () => window.removeEventListener("keydown", navigate, true);
}, [nvimControlMode, nvimExpanded, nvimFit, nvimSyncMode, nvimVisible, shortcutsOpen]);
}, [allocatorVisible, nvimControlMode, nvimExpanded, nvimFit, nvimSyncMode, nvimVisible, runWebFunctionKey, runWebRedraw, shortcutsOpen]);
useEffect(() => () => window.clearTimeout(shortcutStatusTimerRef.current), []);
useEffect(() => {
const wheel = (event: WheelEvent) => {
if (!event.altKey || event.ctrlKey) return;
@@ -3346,8 +3624,11 @@ function App({ model }: { model: CardModel }) {
setNvimExpanded={setNvimExpanded}
nvimFit={nvimFit}
setNvimFit={setNvimFit}
allocatorVisible={allocatorVisible}
setAllocatorVisible={setAllocatorVisible}
nvimSummary={nvimSummary}
/>
<AllocatorPanel visible={allocatorVisible} model={model} />
<NeovimPanel
visible={nvimVisible}
syncMode={nvimSyncMode}
@@ -3357,6 +3638,7 @@ function App({ model }: { model: CardModel }) {
scale={scale}
onSummaryChange={setNvimSummary}
/>
{shortcutStatus && <output className="viewer-action-toast">{shortcutStatus}</output>}
</div>
<main className="paper">
<FrontPage front={model.front} />
+187
View File
@@ -24,6 +24,24 @@
z-index: 80;
width: 100%;
}
.viewer-action-toast {
position: absolute;
z-index: 12;
right: 9px;
bottom: 9px;
max-width: min(720px, calc(100vw - 18px));
box-sizing: border-box;
overflow: hidden;
border: 1px solid #42b8e7;
border-radius: 3px;
background: #102c38ee;
color: #dff7ff;
padding: 5px 9px;
box-shadow: 0 4px 14px #0007;
text-overflow: ellipsis;
white-space: nowrap;
font: 750 10px/1.25 ui-monospace, monospace;
}
.viewer-chrome .topbar {
position: relative;
top: auto;
@@ -41,6 +59,9 @@
.viewer-chrome.is-nvim-fit .topbar {
flex: none;
}
.viewer-chrome.is-nvim-fit .allocator-panel {
flex: none;
}
.viewer-chrome.is-nvim-fit .nvim-panel {
display: flex;
min-height: 0;
@@ -159,6 +180,159 @@
background: #eaf6fb;
color: #174d64;
}
.allocator-panel {
width: 100%;
box-sizing: border-box;
border-top: 1px solid #40515a;
border-bottom: 1px solid #40515a;
background: #f7f9f5;
color: #182127;
box-shadow: 0 4px 12px #0002;
font: 11px/1.25 system-ui, sans-serif;
}
.allocator-panel > header {
display: grid;
grid-template-columns: 125px minmax(260px, auto) 1fr;
gap: 8px;
align-items: baseline;
min-height: 22px;
padding: 3px 8px;
border-bottom: 1px solid #b7c1c4;
background: #e8eef0;
}
.allocator-panel > header > span {
color: #166987;
font: 800 9px/1.2 ui-monospace, monospace;
letter-spacing: .06em;
}
.allocator-panel > header > strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font: 750 11px/1.2 ui-monospace, monospace;
}
.allocator-panel > header > small {
overflow: hidden;
color: #536168;
text-overflow: ellipsis;
white-space: nowrap;
}
.allocator-panel-body {
display: grid;
grid-template-columns: 230px minmax(420px, 1fr) minmax(310px, .8fr);
gap: 9px;
align-items: stretch;
padding: 5px 8px 7px;
}
.allocator-metrics {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 3px 8px;
margin: 0;
}
.allocator-metrics > div {
min-width: 0;
border-left: 2px solid #8ea2ab;
padding-left: 5px;
}
.allocator-metrics dt {
color: #627077;
font: 750 7px/1.2 ui-monospace, monospace;
letter-spacing: .05em;
}
.allocator-metrics dd {
margin: 1px 0 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font: 700 10px/1.2 ui-monospace, monospace;
}
.allocator-arena-view {
min-width: 0;
}
.allocator-arena-view.is-inactive {
opacity: .42;
}
.allocator-arena-labels {
display: flex;
justify-content: space-between;
gap: 8px;
margin-bottom: 3px;
color: #536168;
font: 700 8px/1.2 ui-monospace, monospace;
}
.allocator-arena-labels strong {
color: #075f80;
}
.allocator-pinned-arena {
position: relative;
display: grid;
grid-template-columns: repeat(var(--allocator-size), minmax(2px, 1fr));
height: 32px;
border: 1px solid #28343a;
background: #fff;
}
.allocator-pinned-arena i {
min-width: 0;
border-right: 1px solid #e1e5e6;
background: #fbfcfc;
}
.allocator-pinned-arena i:nth-child(8n) {
border-right-color: #87969d;
}
.allocator-pinned-arena i.is-allocated {
border-right-color: #b2d8e6;
background: #43a8ce;
}
.allocator-cursor {
position: absolute;
z-index: 2;
top: -4px;
bottom: -4px;
width: 2px;
background: #d02d25;
transform: translateX(-1px);
box-shadow: 0 0 0 1px #fff9;
}
.allocator-cursor::before {
content: "allocp";
position: absolute;
top: 1px;
left: 4px;
color: #8d1d17;
font: 800 7px/1 ui-monospace, monospace;
}
.allocator-facts {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 2px 7px;
min-width: 0;
}
.allocator-facts > div {
display: flex;
min-width: 0;
justify-content: space-between;
gap: 5px;
border-bottom: 1px dotted #b4bec2;
}
.allocator-facts span {
overflow: hidden;
color: #46565e;
text-overflow: ellipsis;
white-space: nowrap;
}
.allocator-facts code {
overflow: hidden;
color: #142e39;
text-overflow: ellipsis;
white-space: nowrap;
font: 700 9px/1.3 ui-monospace, monospace;
}
.allocator-facts p {
grid-column: 1 / -1;
margin: 0;
color: #647278;
}
.nvim-panel {
width: 100%;
box-sizing: border-box;
@@ -1460,6 +1634,19 @@ text.uml-step-element-active {
.nvim-work-indicator {
display: none;
}
.allocator-panel > header {
grid-template-columns: auto 1fr;
}
.allocator-panel > header > small {
display: none;
}
.allocator-panel-body {
grid-template-columns: 1fr;
}
.allocator-metrics,
.allocator-facts {
display: none;
}
.memory-lanes {
grid-template-columns: 1fr 1fr;
}