feat: implement CPP01 C++20 card
This commit is contained in:
@@ -0,0 +1,376 @@
|
||||
const NAVIGATION_LEVELS = ['card', 'task', 'block', 'phase', 'step', 'snapshot'];
|
||||
|
||||
export const KEYBOARD_SHORTCUTS = [
|
||||
{ keys: 'Alt+ArrowUp/Alt+ArrowDown', action: 'navigation.move', level: 'task', description: 'poprzedni/następny TASK' },
|
||||
{ keys: 'Ctrl+ArrowUp/Ctrl+ArrowDown', action: 'navigation.move', level: 'block', description: 'poprzedni/następny BLOCK' },
|
||||
{ keys: 'Shift+ArrowUp/Shift+ArrowDown', action: 'navigation.move', level: 'phase', description: 'poprzednia/następna PHASE' },
|
||||
{ keys: 'Ctrl+Shift+ArrowUp/Ctrl+Shift+ArrowDown', action: 'navigation.move', level: 'step', description: 'poprzedni/następny STEP' },
|
||||
{ keys: 'Alt+Shift+ArrowUp/Alt+Shift+ArrowDown', action: 'navigation.move', level: 'snapshot', description: 'poprzedni/następny unikalny SNAPSHOT' },
|
||||
{ keys: 'ArrowUp/ArrowDown', action: 'navigation.move', level: 'current', description: 'poprzedni/następny element aktywnego poziomu' },
|
||||
{ keys: 'ArrowRight', action: 'navigation.level', direction: 'child', description: 'zejdź poziom niżej' },
|
||||
{ keys: 'ArrowLeft', action: 'navigation.level', direction: 'parent', description: 'wróć poziom wyżej' },
|
||||
{ keys: 'Enter', action: 'navigation.activate', description: 'wybierz element i przy SYNC ON odtwórz jego stan' },
|
||||
{ keys: 'F1', action: 'nvim.reset', description: 'zresetuj aktualny cel i odśwież debugger' },
|
||||
{ keys: 'F2', action: 'navigation.activate', description: 'zsynchronizuj cel z kursorem UML; BLOCK wybiera pierwszy krok' },
|
||||
{ keys: 'Ctrl+Backquote', action: 'nvim.redraw', description: 'maksymalizuj pane, dopasuj siatkę i odśwież Neovima' },
|
||||
{ keys: 'Ctrl+Enter', action: 'progress.toggle', description: 'zatwierdź lub cofnij zatwierdzenie bieżącego kroku' },
|
||||
{ keys: 'Escape', action: 'navigation.level', direction: 'parent', description: 'anuluj albo wróć poziom wyżej' },
|
||||
{ keys: 'Alt+Digit1', action: 'viewer.nvim.visible.toggle', description: 'pokaż/ukryj panel Neovima' },
|
||||
{ keys: 'Alt+Digit2', action: 'viewer.nvim.sync.toggle', description: 'przełącz SYNC OFF/SYNC ON' },
|
||||
{ keys: 'Alt+Digit3', action: 'viewer.nvim.control.toggle', description: 'uzbrój/rozbrój sterowanie Neovimem' },
|
||||
{ keys: 'Alt+Digit4', action: 'viewer.nvim.expanded.toggle', description: 'przełącz normalną/wysoką wysokość panelu' },
|
||||
{ keys: 'Alt+Digit5', action: 'viewer.nvim.fit.toggle', description: 'dopasuj cały ekran Neovima' },
|
||||
{ keys: 'Alt+Digit6', action: 'viewer.allocator.visible.toggle', description: 'przypnij/ukryj model alokatora' },
|
||||
{ keys: 'F12', action: 'viewer.keyboard.toggle', description: 'pokaż/ukryj skorowidz klawiatury' }
|
||||
];
|
||||
|
||||
function text(value, fallback = '') {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : fallback;
|
||||
}
|
||||
|
||||
function node(value, fallbackId, fallbackLabel) {
|
||||
return {
|
||||
id: text(value?.id, fallbackId),
|
||||
label: text(value?.label, fallbackLabel)
|
||||
};
|
||||
}
|
||||
|
||||
export function navigationCatalog(card) {
|
||||
const entries = [];
|
||||
const taskIds = [];
|
||||
const blockIds = new Map();
|
||||
const globalSnapshots = [];
|
||||
|
||||
for (const [sectionIndex, section] of (card.sections ?? []).entries()) {
|
||||
for (const [assetIndex, asset] of (section.assets ?? []).entries()) {
|
||||
const interactive = asset.interactive;
|
||||
if (!interactive?.phases?.length) continue;
|
||||
const task = node(
|
||||
interactive.task,
|
||||
`section-${sectionIndex + 1}`,
|
||||
text(section.title, `Sekcja ${sectionIndex + 1}`)
|
||||
);
|
||||
const block = node(
|
||||
interactive.block,
|
||||
text(asset.label, `asset-${assetIndex + 1}`).replace(/^fig:/, ''),
|
||||
text(interactive.title, text(asset.caption, `Blok ${assetIndex + 1}`))
|
||||
);
|
||||
if (!taskIds.includes(task.id)) taskIds.push(task.id);
|
||||
const taskBlocks = blockIds.get(task.id) ?? [];
|
||||
if (!taskBlocks.includes(block.id)) taskBlocks.push(block.id);
|
||||
blockIds.set(task.id, taskBlocks);
|
||||
const blockPhases = interactive.phases;
|
||||
const blockSnapshots = [];
|
||||
|
||||
for (const [phaseIndex, phaseValue] of blockPhases.entries()) {
|
||||
const phase = node(phaseValue, `phase-${phaseIndex + 1}`, `Faza ${phaseIndex + 1}`);
|
||||
const phaseSnapshots = [];
|
||||
for (const [stepIndex, stepValue] of (phaseValue.steps ?? []).entries()) {
|
||||
const step = node(stepValue, `step-${stepIndex + 1}`, `Krok ${stepIndex + 1}`);
|
||||
const snapshotRef = text(stepValue.snapshot_ref) || null;
|
||||
if (snapshotRef && !phaseSnapshots.includes(snapshotRef)) phaseSnapshots.push(snapshotRef);
|
||||
if (snapshotRef && !blockSnapshots.includes(snapshotRef)) blockSnapshots.push(snapshotRef);
|
||||
if (snapshotRef && !globalSnapshots.includes(snapshotRef)) globalSnapshots.push(snapshotRef);
|
||||
entries.push({
|
||||
task: { ...task, index: taskIds.indexOf(task.id) },
|
||||
block: { ...block, index: taskBlocks.indexOf(block.id) },
|
||||
phase: { ...phase, index: phaseIndex },
|
||||
step: {
|
||||
...step,
|
||||
number: Number.isSafeInteger(Number(stepValue.number)) ? Number(stepValue.number) : stepIndex + 1,
|
||||
index: stepIndex,
|
||||
global_index: entries.length
|
||||
},
|
||||
snapshot: snapshotRef ? {
|
||||
ref: snapshotRef,
|
||||
index: phaseSnapshots.indexOf(snapshotRef),
|
||||
block_index: blockSnapshots.indexOf(snapshotRef),
|
||||
global_index: globalSnapshots.indexOf(snapshotRef)
|
||||
} : null,
|
||||
asset: {
|
||||
label: text(asset.label),
|
||||
section_index: sectionIndex,
|
||||
asset_index: assetIndex
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
schema: 'stem-card-navigation-catalog.v1',
|
||||
levels: NAVIGATION_LEVELS,
|
||||
entries
|
||||
};
|
||||
}
|
||||
|
||||
function integer(value) {
|
||||
const parsed = Number(value);
|
||||
return Number.isSafeInteger(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function entryMatches(entry, request) {
|
||||
const ids = {
|
||||
task: text(request.task?.id, text(request.task_id)),
|
||||
block: text(request.block?.id, text(request.block_id)),
|
||||
phase: text(request.phase?.id, text(request.phase_id)),
|
||||
step: text(request.step?.id, text(request.step_id))
|
||||
};
|
||||
for (const [level, id] of Object.entries(ids)) {
|
||||
if (id && entry[level].id !== id) return false;
|
||||
}
|
||||
const snapshotRef = text(request.snapshot_ref, text(request.snapshot?.ref));
|
||||
return !snapshotRef || entry.snapshot?.ref === snapshotRef;
|
||||
}
|
||||
|
||||
function findNavigationEntry(catalog, request) {
|
||||
const globalStep = integer(request.position?.global_step_index);
|
||||
if (globalStep != null) return catalog.entries[globalStep] ?? null;
|
||||
const matches = catalog.entries.filter(entry => entryMatches(entry, request));
|
||||
if (!matches.length) return null;
|
||||
const phaseIndex = integer(request.position?.phase_index);
|
||||
const stepIndex = integer(request.position?.step_index);
|
||||
const snapshotIndex = integer(request.position?.snapshot_index);
|
||||
return matches.find(entry =>
|
||||
(phaseIndex == null || entry.phase.index === phaseIndex)
|
||||
&& (stepIndex == null || entry.step.index === stepIndex)
|
||||
&& (snapshotIndex == null || entry.snapshot?.index === snapshotIndex)
|
||||
) ?? matches[0];
|
||||
}
|
||||
|
||||
export function selectNavigation(catalog, request, revision = 1, now = new Date().toISOString()) {
|
||||
if (!request || typeof request !== 'object') throw new Error('Stan nawigacji musi być obiektem.');
|
||||
const entry = findNavigationEntry(catalog, request);
|
||||
if (!entry) throw new Error('Nie znaleziono wskazanej pozycji w katalogu karty.');
|
||||
const focusLevel = text(request.focus_level, 'step');
|
||||
if (!NAVIGATION_LEVELS.includes(focusLevel)) {
|
||||
throw new Error(`focus_level musi być jednym z: ${NAVIGATION_LEVELS.join(', ')}.`);
|
||||
}
|
||||
return {
|
||||
schema: 'stem-card-navigation.v2',
|
||||
revision,
|
||||
selected_at: now,
|
||||
actor: text(request.actor, 'browser'),
|
||||
focus_level: focusLevel,
|
||||
cursor_visible: focusLevel !== 'card',
|
||||
task: entry.task,
|
||||
block: entry.block,
|
||||
phase: entry.phase,
|
||||
step: entry.step,
|
||||
snapshot: entry.snapshot,
|
||||
snapshot_ref: entry.snapshot?.ref ?? null,
|
||||
position: {
|
||||
task_index: entry.task.index,
|
||||
block_index: entry.block.index,
|
||||
phase_index: entry.phase.index,
|
||||
step_index: entry.step.index,
|
||||
global_step_index: entry.step.global_index,
|
||||
snapshot_index: entry.snapshot?.index ?? null,
|
||||
global_snapshot_index: entry.snapshot?.global_index ?? null
|
||||
},
|
||||
sync_requested: request.sync_requested === true
|
||||
};
|
||||
}
|
||||
|
||||
function uniqueAtLevel(entries, level, current) {
|
||||
const seen = new Set();
|
||||
return entries.filter(entry => {
|
||||
let key;
|
||||
if (level === 'task') key = entry.task.id;
|
||||
else if (level === 'block') key = `${entry.task.id}/${entry.block.id}`;
|
||||
else if (level === 'phase') key = `${entry.task.id}/${entry.block.id}/${entry.phase.id}`;
|
||||
else if (level === 'snapshot') key = entry.snapshot?.ref;
|
||||
else key = `${entry.task.id}/${entry.block.id}/${entry.phase.id}/${entry.step.id}`;
|
||||
if (!key || seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
if (level === 'phase') return entry.task.id === current.task.id && entry.block.id === current.block.id;
|
||||
if (level === 'step' || level === 'snapshot') {
|
||||
return entry.task.id === current.task.id && entry.block.id === current.block.id;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function moveNavigation(catalog, current, command, revision = 1, now = new Date().toISOString()) {
|
||||
if (!current) throw new Error('Najpierw ustaw bieżącą pozycję nawigacji.');
|
||||
const requestedLevel = text(command?.level, current.focus_level);
|
||||
const level = requestedLevel === 'current' ? current.focus_level : requestedLevel;
|
||||
if (!NAVIGATION_LEVELS.includes(level)) throw new Error('Niepoprawny poziom nawigacji.');
|
||||
if (level === 'card') throw new Error('Poziom CARD nie ma elementów UML do przewijania.');
|
||||
const delta = integer(command?.delta);
|
||||
if (delta == null || delta === 0 || Math.abs(delta) > 100) throw new Error('delta musi być niezerową liczbą całkowitą.');
|
||||
const candidates = uniqueAtLevel(catalog.entries, level, current);
|
||||
const currentIndex = candidates.findIndex(entry => {
|
||||
if (level === 'task') return entry.task.id === current.task.id;
|
||||
if (level === 'block') return entry.block.id === current.block.id && entry.task.id === current.task.id;
|
||||
if (level === 'phase') return entry.phase.id === current.phase.id;
|
||||
if (level === 'snapshot') return entry.snapshot?.ref === current.snapshot_ref;
|
||||
return entry.step.id === current.step.id && entry.phase.id === current.phase.id;
|
||||
});
|
||||
const targetIndex = Math.max(0, Math.min(candidates.length - 1, Math.max(0, currentIndex) + delta));
|
||||
const target = candidates[targetIndex];
|
||||
if (!target) throw new Error('Brak elementu na wskazanym poziomie.');
|
||||
return selectNavigation(catalog, {
|
||||
task_id: target.task.id,
|
||||
block_id: target.block.id,
|
||||
phase_id: target.phase.id,
|
||||
step_id: target.step.id,
|
||||
snapshot_ref: target.snapshot?.ref,
|
||||
focus_level: level,
|
||||
// Moving the teaching cursor never mutates the debugger. Synchronisation
|
||||
// is a separate, explicit activation (Enter in WWW or F2 in Neovim).
|
||||
sync_requested: false,
|
||||
actor: text(command.actor, 'api')
|
||||
}, revision, now);
|
||||
}
|
||||
|
||||
export function activateNavigation(catalog, current, actor = 'nvim', revision = 1, now = new Date().toISOString()) {
|
||||
if (!current) throw new Error('Najpierw wybierz BLOCK zawierający diagram UML.');
|
||||
if (current.focus_level === 'card' || current.focus_level === 'task') {
|
||||
throw new Error('F2 wymaga zaznaczonego BLOCK, PHASE, STEP albo SNAPSHOT.');
|
||||
}
|
||||
|
||||
const target = catalog.entries.find(entry => {
|
||||
if (entry.task.id !== current.task.id || entry.block.id !== current.block.id) return false;
|
||||
if (current.focus_level === 'block') return true;
|
||||
if (entry.phase.id !== current.phase.id) return false;
|
||||
if (current.focus_level === 'phase') return true;
|
||||
if (current.focus_level === 'snapshot') return entry.snapshot?.ref === current.snapshot_ref;
|
||||
return entry.step.id === current.step.id;
|
||||
});
|
||||
if (!target) throw new Error('Nie znaleziono kroku UML, który można zsynchronizować.');
|
||||
if (!target.snapshot?.ref) throw new Error('Wybrany krok UML nie ma snapshotu do synchronizacji.');
|
||||
|
||||
return selectNavigation(catalog, {
|
||||
task_id: target.task.id,
|
||||
block_id: target.block.id,
|
||||
phase_id: target.phase.id,
|
||||
step_id: target.step.id,
|
||||
snapshot_ref: target.snapshot.ref,
|
||||
focus_level: 'step',
|
||||
sync_requested: true,
|
||||
actor: text(actor, 'nvim')
|
||||
}, revision, now);
|
||||
}
|
||||
|
||||
export function initialViewerState() {
|
||||
return {
|
||||
schema: 'stem-card-viewer-state.v1',
|
||||
revision: 0,
|
||||
updated_at: null,
|
||||
actor: null,
|
||||
scale: 2.18,
|
||||
scroll: { x: 0, y: 0, page_index: 0, sheet_scroll_top: 0 },
|
||||
viewport: { width: 0, height: 0 },
|
||||
allocator: { visible: true },
|
||||
nvim: {
|
||||
visible: true,
|
||||
sync: false,
|
||||
control: false,
|
||||
expanded: false,
|
||||
fit: false,
|
||||
connection: 'unknown',
|
||||
screen_cursor: null,
|
||||
grid: null
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function finite(value, minimum, maximum, name) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed < minimum || parsed > maximum) {
|
||||
throw new Error(`${name} jest poza zakresem ${minimum}..${maximum}.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function boolean(value, fallback) {
|
||||
return typeof value === 'boolean' ? value : fallback;
|
||||
}
|
||||
|
||||
export function patchViewerState(current, request, revision = current.revision + 1, now = new Date().toISOString()) {
|
||||
if (!request || typeof request !== 'object') throw new Error('Stan viewer musi być obiektem.');
|
||||
const patch = request.viewer && typeof request.viewer === 'object' ? request.viewer : request;
|
||||
const scroll = patch.scroll && typeof patch.scroll === 'object' ? patch.scroll : {};
|
||||
const viewport = patch.viewport && typeof patch.viewport === 'object' ? patch.viewport : {};
|
||||
const allocator = patch.allocator && typeof patch.allocator === 'object' ? patch.allocator : {};
|
||||
const nvim = patch.nvim && typeof patch.nvim === 'object' ? patch.nvim : {};
|
||||
const cursor = nvim.screen_cursor && typeof nvim.screen_cursor === 'object'
|
||||
? {
|
||||
row: Math.trunc(finite(nvim.screen_cursor.row, 0, 100000, 'nvim.screen_cursor.row')),
|
||||
column: Math.trunc(finite(nvim.screen_cursor.column, 0, 100000, 'nvim.screen_cursor.column'))
|
||||
}
|
||||
: current.nvim.screen_cursor;
|
||||
const grid = nvim.grid && typeof nvim.grid === 'object'
|
||||
? {
|
||||
columns: Math.trunc(finite(nvim.grid.columns, 1, 10000, 'nvim.grid.columns')),
|
||||
rows: Math.trunc(finite(nvim.grid.rows, 1, 10000, 'nvim.grid.rows'))
|
||||
}
|
||||
: current.nvim.grid;
|
||||
const sync = boolean(nvim.sync, current.nvim.sync);
|
||||
const control = sync && boolean(nvim.control, current.nvim.control);
|
||||
const visible = boolean(nvim.visible, current.nvim.visible);
|
||||
const fit = visible && boolean(nvim.fit, current.nvim.fit);
|
||||
const expanded = visible && !fit && boolean(nvim.expanded, current.nvim.expanded);
|
||||
const pageIndex = scroll.page_index == null
|
||||
? current.scroll.page_index
|
||||
: Math.trunc(finite(scroll.page_index, 0, 100000, 'scroll.page_index'));
|
||||
const pageChanged = scroll.page_index != null && pageIndex !== current.scroll.page_index;
|
||||
return {
|
||||
schema: 'stem-card-viewer-state.v1',
|
||||
revision,
|
||||
updated_at: now,
|
||||
actor: text(request.actor, 'browser'),
|
||||
scale: patch.scale == null ? current.scale : finite(patch.scale, 0.25, 3, 'scale'),
|
||||
scroll: {
|
||||
x: scroll.x == null ? current.scroll.x : finite(scroll.x, 0, 10000000, 'scroll.x'),
|
||||
y: scroll.y == null ? current.scroll.y : finite(scroll.y, 0, 10000000, 'scroll.y'),
|
||||
page_index: pageIndex,
|
||||
sheet_scroll_top: scroll.sheet_scroll_top == null
|
||||
? (pageChanged ? 0 : current.scroll.sheet_scroll_top ?? 0)
|
||||
: finite(scroll.sheet_scroll_top, 0, 10000000, 'scroll.sheet_scroll_top')
|
||||
},
|
||||
viewport: {
|
||||
width: viewport.width == null ? current.viewport.width : finite(viewport.width, 0, 100000, 'viewport.width'),
|
||||
height: viewport.height == null ? current.viewport.height : finite(viewport.height, 0, 100000, 'viewport.height')
|
||||
},
|
||||
allocator: {
|
||||
visible: boolean(allocator.visible, current.allocator?.visible ?? true)
|
||||
},
|
||||
nvim: {
|
||||
visible,
|
||||
sync,
|
||||
control,
|
||||
expanded,
|
||||
fit,
|
||||
connection: text(nvim.connection, current.nvim.connection),
|
||||
screen_cursor: cursor,
|
||||
grid
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeViewerState(value) {
|
||||
const initial = initialViewerState();
|
||||
if (!value || typeof value !== 'object') return initial;
|
||||
const revisionValue = Number(value.revision);
|
||||
const revision = Number.isSafeInteger(revisionValue) && revisionValue >= 0
|
||||
? revisionValue
|
||||
: 0;
|
||||
const updatedAt = typeof value.updated_at === 'string' ? value.updated_at : null;
|
||||
const actor = typeof value.actor === 'string' && value.actor.trim()
|
||||
? value.actor.trim()
|
||||
: null;
|
||||
try {
|
||||
const normalized = patchViewerState(
|
||||
initial,
|
||||
{ actor: actor ?? 'browser', viewer: value },
|
||||
revision,
|
||||
updatedAt
|
||||
);
|
||||
return { ...normalized, actor };
|
||||
} catch {
|
||||
return { ...initial, revision, updated_at: updatedAt, actor };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
export const STATUSES = ['pending', 'approved'];
|
||||
|
||||
export const statusLabel = {
|
||||
pending: 'niezatwierdzony',
|
||||
approved: 'zatwierdzony'
|
||||
};
|
||||
|
||||
function normaliseStatus(status) {
|
||||
if (status === 'approved' || status === 'completed' || status === 'discussed') return 'approved';
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
export async function readJson(file) {
|
||||
return JSON.parse(await readFile(file, 'utf8'));
|
||||
}
|
||||
|
||||
export async function writeJsonAtomically(file, value) {
|
||||
await mkdir(path.dirname(file), { recursive: true });
|
||||
const temporary = `${file}.${process.pid}.${Date.now()}.tmp`;
|
||||
await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
||||
await rename(temporary, file);
|
||||
}
|
||||
|
||||
export function catalogFromCard(card) {
|
||||
return card.sections.flatMap((section, sectionIndex) => {
|
||||
const sectionSteps = (section.steps ?? []).map((step, stepIndex) => ({
|
||||
id: step.id,
|
||||
title: step.title,
|
||||
kind: 'section-step',
|
||||
section_index: sectionIndex + 1,
|
||||
section_title: section.title,
|
||||
step_index: stepIndex + 1
|
||||
}));
|
||||
const interactiveItems = (section.assets ?? []).flatMap(asset => {
|
||||
const phases = asset.interactive?.phases ?? [];
|
||||
if (phases.length) {
|
||||
return phases.flatMap(phase =>
|
||||
(phase.steps ?? [])
|
||||
.filter(step => typeof step.progress_id === 'string' && step.progress_id)
|
||||
.map(step => ({
|
||||
id: step.progress_id,
|
||||
title: `${String(asset.label || 'interaktywny UML').replace(/^fig:/, '')}: ${phase.label} / ${String(step.number).padStart(2, '0')} ${step.label}`,
|
||||
kind: 'interactive-step',
|
||||
section_index: sectionIndex + 1,
|
||||
section_title: section.title,
|
||||
asset_label: asset.label,
|
||||
phase_id: phase.id,
|
||||
step_id: step.id
|
||||
}))
|
||||
);
|
||||
}
|
||||
return (asset.interactive?.stages ?? [])
|
||||
.filter(stage => typeof stage.progress_id === 'string' && stage.progress_id)
|
||||
.map(stage => ({
|
||||
id: stage.progress_id,
|
||||
title: `${String(asset.label || 'interaktywny UML').replace(/^fig:/, '')}: ${stage.label}`,
|
||||
kind: 'interactive-stage',
|
||||
section_index: sectionIndex + 1,
|
||||
section_title: section.title,
|
||||
asset_label: asset.label,
|
||||
stage_id: stage.id
|
||||
}));
|
||||
}).map((item, itemIndex) => ({
|
||||
...item,
|
||||
step_index: sectionSteps.length + itemIndex + 1
|
||||
}));
|
||||
return [...sectionSteps, ...interactiveItems];
|
||||
});
|
||||
}
|
||||
|
||||
export function normaliseProgress(card, progress, material = {}) {
|
||||
const catalog = catalogFromCard(card);
|
||||
const knownIds = new Set(catalog.map(item => item.id));
|
||||
const items = {};
|
||||
|
||||
for (const item of catalog) {
|
||||
const previous = progress.items?.[item.id] ?? {};
|
||||
items[item.id] = {
|
||||
status: normaliseStatus(previous.status),
|
||||
updated_at: previous.updated_at ?? null,
|
||||
note: typeof previous.note === 'string' ? previous.note : ''
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
schema: 'stem-card-progress.v2',
|
||||
card: {
|
||||
id: card.card.id,
|
||||
source: progress.card?.source || 'json/card_source.json',
|
||||
version: card.card.version,
|
||||
repository: progress.card?.repository || material.repository || '',
|
||||
revision: progress.card?.revision || material.revision || ''
|
||||
},
|
||||
session: {
|
||||
id: progress.session?.id || `${new Date().toISOString().slice(0, 10)}-${card.card.slug}`,
|
||||
title: progress.session?.title || card.card.title,
|
||||
teacher: progress.session?.teacher || '',
|
||||
started_at: progress.session?.started_at ?? null
|
||||
},
|
||||
items,
|
||||
events: Array.isArray(progress.events)
|
||||
? progress.events
|
||||
.filter(event => knownIds.has(event.item_id))
|
||||
.map(event => ({
|
||||
...event,
|
||||
from: normaliseStatus(event.from),
|
||||
to: normaliseStatus(event.to)
|
||||
}))
|
||||
: []
|
||||
};
|
||||
}
|
||||
|
||||
export function progressSummary(progress) {
|
||||
const counts = Object.fromEntries(STATUSES.map(status => [status, 0]));
|
||||
for (const item of Object.values(progress.items)) counts[item.status] += 1;
|
||||
return { total: Object.keys(progress.items).length, counts };
|
||||
}
|
||||
|
||||
export function updateItem(progress, itemId, { status, note, actor = 'teacher', at = new Date().toISOString() }) {
|
||||
if (!STATUSES.includes(status)) throw new Error(`Nieznany status: ${status}`);
|
||||
const item = progress.items[itemId];
|
||||
if (!item) throw new Error(`Nieznany element karty: ${itemId}`);
|
||||
|
||||
const nextNote = typeof note === 'string' ? note.trim() : item.note;
|
||||
const changed = item.status !== status || item.note !== nextNote;
|
||||
if (!changed) return false;
|
||||
|
||||
const from = item.status;
|
||||
item.status = status;
|
||||
item.updated_at = at;
|
||||
item.note = nextNote;
|
||||
if (!progress.session.started_at) progress.session.started_at = at;
|
||||
progress.events.push({ at, item_id: itemId, from, to: status, actor, note: nextNote });
|
||||
return true;
|
||||
}
|
||||
|
||||
function escapeMarkdown(value) {
|
||||
return String(value ?? '').replaceAll('|', '\\|').replaceAll('\n', '<br>');
|
||||
}
|
||||
|
||||
export function teamsMarkdown(card, progress) {
|
||||
const catalog = catalogFromCard(card);
|
||||
const byStatus = status => catalog.filter(item => progress.items[item.id]?.status === status);
|
||||
const summary = progressSummary(progress);
|
||||
const heading = `# ${card.card.title} — zapis zajęć`;
|
||||
const metadata = [
|
||||
`- Karta: \`${card.card.id}\` (${card.card.version})`,
|
||||
`- Materiał Edu: ${progress.card.repository || 'nieuzupełniono'}`,
|
||||
`- Rewizja materiału: \`${progress.card.revision || 'nieuzupełniono'}\``,
|
||||
`- Sesja: \`${progress.session.id}\``,
|
||||
`- Prowadzący: ${progress.session.teacher || 'nieuzupełniono'}`,
|
||||
`- Rozpoczęcie: ${progress.session.started_at || 'brak'}`,
|
||||
`- Stan: ${summary.counts.approved}/${summary.total} zatwierdzone`
|
||||
];
|
||||
const list = (title, status) => {
|
||||
const rows = byStatus(status);
|
||||
const body = rows.length
|
||||
? rows.map(item => {
|
||||
const state = progress.items[item.id];
|
||||
const timestamp = state.updated_at ? ` — ${state.updated_at}` : '';
|
||||
const note = state.note ? ` — ${state.note}` : '';
|
||||
return `- [${status === 'approved' ? 'x' : ' '}] \`${item.id}\` ${item.title}${timestamp}${note}`;
|
||||
}).join('\n')
|
||||
: '- Brak.';
|
||||
return `## ${title}\n\n${body}`;
|
||||
};
|
||||
const timeline = progress.events.length
|
||||
? progress.events.map(event => {
|
||||
const item = catalog.find(candidate => candidate.id === event.item_id);
|
||||
return `| ${event.at} | \`${event.item_id}\` | ${statusLabel[event.from]} → ${statusLabel[event.to]} | ${escapeMarkdown(event.note)} |`;
|
||||
}).join('\n')
|
||||
: '| — | — | Brak zmian | — |';
|
||||
|
||||
return [
|
||||
heading,
|
||||
'',
|
||||
...metadata,
|
||||
'',
|
||||
list('Zatwierdzone', 'approved'),
|
||||
'',
|
||||
list('Pozostało do zatwierdzenia', 'pending'),
|
||||
'',
|
||||
'## Historia zatwierdzeń',
|
||||
'',
|
||||
'| Czas | Element | Zmiana | Notatka |',
|
||||
'| --- | --- | --- | --- |',
|
||||
timeline,
|
||||
''
|
||||
].join('\n');
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
const STATE_SCHEMA = 'stem-card-backend-state.v1';
|
||||
|
||||
export class CardStateDatabase {
|
||||
constructor(file) {
|
||||
this.file = path.resolve(file);
|
||||
mkdirSync(path.dirname(this.file), { recursive: true });
|
||||
this.database = new DatabaseSync(this.file);
|
||||
this.database.exec(`
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA synchronous = NORMAL;
|
||||
CREATE TABLE IF NOT EXISTS card_state (
|
||||
key TEXT PRIMARY KEY,
|
||||
schema_name TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
value_json TEXT NOT NULL
|
||||
) STRICT;
|
||||
CREATE TABLE IF NOT EXISTS card_event (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
occurred_at TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
actor TEXT NOT NULL,
|
||||
task_id TEXT,
|
||||
block_id TEXT,
|
||||
phase_id TEXT,
|
||||
step_id TEXT,
|
||||
snapshot_ref TEXT,
|
||||
evidence_ref TEXT,
|
||||
payload_json TEXT NOT NULL
|
||||
) STRICT;
|
||||
CREATE INDEX IF NOT EXISTS card_event_occurred_at
|
||||
ON card_event(occurred_at, id);
|
||||
`);
|
||||
this.readStatement = this.database.prepare(`
|
||||
SELECT value_json
|
||||
FROM card_state
|
||||
WHERE key = ?
|
||||
`);
|
||||
this.writeStatement = this.database.prepare(`
|
||||
INSERT INTO card_state (key, schema_name, revision, updated_at, value_json)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET
|
||||
schema_name = excluded.schema_name,
|
||||
revision = excluded.revision,
|
||||
updated_at = excluded.updated_at,
|
||||
value_json = excluded.value_json
|
||||
`);
|
||||
this.appendEventStatement = this.database.prepare(`
|
||||
INSERT INTO card_event (
|
||||
occurred_at, event_type, actor,
|
||||
task_id, block_id, phase_id, step_id, snapshot_ref,
|
||||
evidence_ref, payload_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
RETURNING id
|
||||
`);
|
||||
this.listEventsStatement = this.database.prepare(`
|
||||
SELECT
|
||||
id, occurred_at, event_type, actor,
|
||||
task_id, block_id, phase_id, step_id, snapshot_ref,
|
||||
evidence_ref, payload_json
|
||||
FROM card_event
|
||||
WHERE id > ?
|
||||
ORDER BY id ASC
|
||||
LIMIT ?
|
||||
`);
|
||||
}
|
||||
|
||||
read(key) {
|
||||
const row = this.readStatement.get(key);
|
||||
if (!row) return null;
|
||||
try {
|
||||
return JSON.parse(row.value_json);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
write(key, value) {
|
||||
const revision = Number.isSafeInteger(Number(value?.revision))
|
||||
? Number(value.revision)
|
||||
: 0;
|
||||
const updatedAt = String(value?.updated_at ?? value?.selected_at ?? new Date().toISOString());
|
||||
this.writeStatement.run(
|
||||
key,
|
||||
STATE_SCHEMA,
|
||||
revision,
|
||||
updatedAt,
|
||||
JSON.stringify(value)
|
||||
);
|
||||
return value;
|
||||
}
|
||||
|
||||
appendEvent({
|
||||
eventType,
|
||||
actor = 'system',
|
||||
navigation = null,
|
||||
evidenceRef = null,
|
||||
payload = {},
|
||||
occurredAt = new Date().toISOString()
|
||||
}) {
|
||||
const row = this.appendEventStatement.get(
|
||||
String(occurredAt),
|
||||
String(eventType),
|
||||
String(actor),
|
||||
navigation?.task?.id ?? null,
|
||||
navigation?.block?.id ?? null,
|
||||
navigation?.phase?.id ?? null,
|
||||
navigation?.step?.id ?? null,
|
||||
navigation?.snapshot_ref ?? null,
|
||||
evidenceRef == null ? null : String(evidenceRef),
|
||||
JSON.stringify(payload ?? {})
|
||||
);
|
||||
return {
|
||||
id: Number(row.id),
|
||||
occurred_at: String(occurredAt),
|
||||
event_type: String(eventType),
|
||||
actor: String(actor),
|
||||
task_id: navigation?.task?.id ?? null,
|
||||
block_id: navigation?.block?.id ?? null,
|
||||
phase_id: navigation?.phase?.id ?? null,
|
||||
step_id: navigation?.step?.id ?? null,
|
||||
snapshot_ref: navigation?.snapshot_ref ?? null,
|
||||
evidence_ref: evidenceRef == null ? null : String(evidenceRef),
|
||||
payload: payload ?? {}
|
||||
};
|
||||
}
|
||||
|
||||
listEvents({ afterId = 0, limit = 200 } = {}) {
|
||||
return this.listEventsStatement.all(Number(afterId), Number(limit)).map(row => {
|
||||
let payload = {};
|
||||
try {
|
||||
payload = JSON.parse(row.payload_json);
|
||||
} catch {
|
||||
// Preserve the audit row even if an old payload cannot be decoded.
|
||||
}
|
||||
return {
|
||||
id: Number(row.id),
|
||||
occurred_at: row.occurred_at,
|
||||
event_type: row.event_type,
|
||||
actor: row.actor,
|
||||
task_id: row.task_id,
|
||||
block_id: row.block_id,
|
||||
phase_id: row.phase_id,
|
||||
step_id: row.step_id,
|
||||
snapshot_ref: row.snapshot_ref,
|
||||
evidence_ref: row.evidence_ref,
|
||||
payload
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
close() {
|
||||
this.database.close();
|
||||
}
|
||||
}
|
||||
|
||||
export { STATE_SCHEMA };
|
||||
@@ -0,0 +1,423 @@
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { readFile, realpath } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { callSelectedNvim } from './nvim_ui_bridge.mjs';
|
||||
|
||||
const POLL_MS = 100;
|
||||
const state = {
|
||||
wantedGeneration: 0,
|
||||
active: null,
|
||||
activeControl: null,
|
||||
last: null,
|
||||
queue: Promise.resolve()
|
||||
};
|
||||
|
||||
function delay(milliseconds) {
|
||||
return new Promise(resolve => setTimeout(resolve, milliseconds));
|
||||
}
|
||||
|
||||
function errorText(error) {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
async function readJson(file) {
|
||||
return JSON.parse(await readFile(file, 'utf8'));
|
||||
}
|
||||
|
||||
function runtimeRoot() {
|
||||
return process.env.XDG_RUNTIME_DIR ?? '/run/user/1000';
|
||||
}
|
||||
|
||||
function selectionRoot() {
|
||||
return process.env.MCP_CONTAINER_SELECTION_ROOT
|
||||
?? path.join(runtimeRoot(), 'stem/mcp-selected');
|
||||
}
|
||||
|
||||
async function selectedBinding() {
|
||||
const selectedDirectory = await realpath(path.join(selectionRoot(), 'current'));
|
||||
const [metadata, socketPath] = await Promise.all([
|
||||
readJson(path.join(selectedDirectory, 'selection.json')),
|
||||
realpath(path.join(selectedDirectory, 'n.sock'))
|
||||
]);
|
||||
return {
|
||||
metadata,
|
||||
socketPath,
|
||||
epoch: `${metadata.container_id ?? 'unknown'}:${socketPath}`
|
||||
};
|
||||
}
|
||||
|
||||
async function assertBindingCurrent(expected) {
|
||||
const current = await selectedBinding();
|
||||
if (current.epoch !== expected.epoch) {
|
||||
throw new Error('Cel MCP zmienił się podczas replay; wynik starego kontenera został odrzucony.');
|
||||
}
|
||||
}
|
||||
|
||||
export function assertExpectedBinding(binding, expected) {
|
||||
// Browser activation follows the currently selected teaching session and
|
||||
// remains backward compatible. CLI/orchestrator callers pin all fields.
|
||||
if (expected == null) return;
|
||||
if (typeof expected !== 'object') throw new Error('expected_binding musi być obiektem.');
|
||||
for (const field of ['container_id', 'instance', 'profile', 'target']) {
|
||||
const wanted = typeof expected[field] === 'string' ? expected[field] : '';
|
||||
const actual = typeof binding.metadata[field] === 'string' ? binding.metadata[field] : '';
|
||||
if (!wanted || wanted !== actual) {
|
||||
throw new Error(`Cel MCP zmienił się przed replay (${field}: ${actual || '?'} != ${wanted || '?'}).`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function assertExpectedIdentity(actual, expected) {
|
||||
// Browser-only calls may omit the pin. Orchestrators send all fields so a
|
||||
// card hot reload cannot silently replay a recipe from another revision.
|
||||
if (expected == null) return;
|
||||
if (typeof expected !== 'object') throw new Error('expected_identity musi być obiektem.');
|
||||
for (const field of ['id', 'uuid', 'version', 'source_sha256']) {
|
||||
const wanted = typeof expected[field] === 'string' ? expected[field] : '';
|
||||
const observed = typeof actual[field] === 'string' ? actual[field] : '';
|
||||
if (!wanted || wanted !== observed) {
|
||||
throw new Error(`Karta zmieniła się przed replay (${field}: ${observed || '?'} != ${wanted || '?'}).`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function readCardSnapshot(cardFile) {
|
||||
const source = await readFile(cardFile);
|
||||
const document = JSON.parse(source.toString('utf8'));
|
||||
const card = document.card ?? {};
|
||||
return {
|
||||
document,
|
||||
identity: {
|
||||
id: String(card.id ?? ''),
|
||||
uuid: String(card.uuid ?? ''),
|
||||
version: String(card.version ?? ''),
|
||||
source_sha256: createHash('sha256').update(source).digest('hex')
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function sha256(file) {
|
||||
const hash = createHash('sha256');
|
||||
hash.update(await readFile(file));
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
async function gitBlobSha1(file) {
|
||||
const content = await readFile(file);
|
||||
const hash = createHash('sha1');
|
||||
hash.update(`blob ${content.length}\0`);
|
||||
hash.update(content);
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
function hostPath(repositoryRoot, containerPath) {
|
||||
if (!path.isAbsolute(containerPath)) {
|
||||
const resolved = path.resolve(repositoryRoot, containerPath);
|
||||
if (resolved === repositoryRoot || resolved.startsWith(`${repositoryRoot}${path.sep}`)) {
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
if (containerPath === '/workspace') return repositoryRoot;
|
||||
if (containerPath.startsWith('/workspace/')) {
|
||||
return path.join(repositoryRoot, containerPath.slice('/workspace/'.length));
|
||||
}
|
||||
throw new Error(`Ścieżka spoza /workspace nie jest dozwolona: ${containerPath}`);
|
||||
}
|
||||
|
||||
async function nvimLua(source, args = [], binding = null) {
|
||||
return callSelectedNvim(
|
||||
'nvim_exec_lua',
|
||||
[source, args],
|
||||
binding ? { socketPath: binding.socketPath } : {}
|
||||
);
|
||||
}
|
||||
|
||||
async function callPinnedNvim(binding, method, parameters = []) {
|
||||
return callSelectedNvim(method, parameters, { socketPath: binding.socketPath });
|
||||
}
|
||||
|
||||
async function sendGdb(command, binding) {
|
||||
const available = await callPinnedNvim(binding, 'nvim_call_function', [
|
||||
'exists',
|
||||
['*TermDebugSendCommand']
|
||||
]);
|
||||
if (available !== 1) {
|
||||
throw new Error('Termdebug nie udostępnia TermDebugSendCommand');
|
||||
}
|
||||
return callPinnedNvim(binding, 'nvim_call_function', [
|
||||
'TermDebugSendCommand',
|
||||
[command]
|
||||
]);
|
||||
}
|
||||
|
||||
async function interruptGdb(binding) {
|
||||
return nvimLua(`
|
||||
for _, buffer in ipairs(vim.api.nvim_list_bufs()) do
|
||||
local name = vim.api.nvim_buf_get_name(buffer)
|
||||
if vim.bo[buffer].buftype == 'terminal' and name:find('gdb%-multiarch') then
|
||||
local channel = vim.bo[buffer].channel
|
||||
if channel and channel > 0 then
|
||||
vim.api.nvim_chan_send(channel, string.char(3))
|
||||
return true
|
||||
end
|
||||
end
|
||||
end
|
||||
return false
|
||||
`, [], binding);
|
||||
}
|
||||
|
||||
async function gdbTerminalRunning(binding) {
|
||||
return nvimLua(`
|
||||
for _, buffer in ipairs(vim.api.nvim_list_bufs()) do
|
||||
local name = vim.api.nvim_buf_get_name(buffer)
|
||||
if vim.bo[buffer].buftype == 'terminal' and name:find('gdb%-multiarch') then
|
||||
local channel = vim.bo[buffer].channel
|
||||
if channel and channel > 0 and vim.fn.jobwait({ channel }, 0)[1] == -1 then
|
||||
return true
|
||||
end
|
||||
end
|
||||
end
|
||||
return false
|
||||
`, [], binding);
|
||||
}
|
||||
|
||||
async function cleanupDeadGdbBuffers(binding) {
|
||||
return nvimLua(`
|
||||
local dead = {}
|
||||
for _, buffer in ipairs(vim.api.nvim_list_bufs()) do
|
||||
local name = vim.api.nvim_buf_get_name(buffer)
|
||||
if vim.bo[buffer].buftype == 'terminal' and name:find('gdb%-multiarch') then
|
||||
local channel = vim.bo[buffer].channel
|
||||
if not channel or channel <= 0 or vim.fn.jobwait({ channel }, 0)[1] ~= -1 then
|
||||
table.insert(dead, buffer)
|
||||
end
|
||||
end
|
||||
end
|
||||
for _, buffer in ipairs(dead) do
|
||||
for _, window in ipairs(vim.fn.win_findbuf(buffer)) do
|
||||
if vim.api.nvim_win_is_valid(window) and #vim.api.nvim_list_wins() > 1 then
|
||||
pcall(vim.api.nvim_win_close, window, true)
|
||||
end
|
||||
end
|
||||
if vim.api.nvim_buf_is_valid(buffer) then
|
||||
pcall(vim.api.nvim_buf_delete, buffer, { force = true })
|
||||
end
|
||||
end
|
||||
return #dead
|
||||
`, [], binding);
|
||||
}
|
||||
|
||||
async function ensureTermdebug(binding) {
|
||||
const probe = gdbTerminalRunning(binding);
|
||||
let running;
|
||||
try {
|
||||
running = await Promise.race([
|
||||
probe,
|
||||
delay(500).then(() => { throw new Error('timeout'); })
|
||||
]);
|
||||
} catch {
|
||||
// Termdebug cleanup sometimes leaves a hit-enter prompt which blocks an
|
||||
// RPC expression while Neovim still accepts input events.
|
||||
await callPinnedNvim(binding, 'nvim_input', ['<CR>']).catch(() => undefined);
|
||||
running = await probe;
|
||||
}
|
||||
if (running) {
|
||||
await cleanupDeadGdbBuffers(binding);
|
||||
await callPinnedNvim(binding, 'nvim_command', ['silent! StudentLayoutFit']).catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
// A failed Termdebug job can leave Neovim at a hit-enter prompt. Feeding
|
||||
// Enter only in the dead-GDB branch clears it before the recovery command.
|
||||
await callPinnedNvim(binding, 'nvim_input', ['<CR>']).catch(() => undefined);
|
||||
await cleanupDeadGdbBuffers(binding);
|
||||
await callPinnedNvim(binding, 'nvim_command', ['silent! StudentTermdebug']);
|
||||
const deadline = Date.now() + 8000;
|
||||
while (Date.now() < deadline) {
|
||||
if (await gdbTerminalRunning(binding)) {
|
||||
// TermdebugStartPost queues target/directory/dashboard setup.
|
||||
await delay(1200);
|
||||
await cleanupDeadGdbBuffers(binding);
|
||||
await callPinnedNvim(binding, 'nvim_command', ['silent! StudentLayoutFit']).catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
await delay(100);
|
||||
}
|
||||
throw new Error('Nie udało się automatycznie odtworzyć procesu GDB w Termdebug.');
|
||||
}
|
||||
|
||||
async function assertEditorReady(repositoryRoot, artifact, binding) {
|
||||
const modified = await nvimLua(`
|
||||
local result = {}
|
||||
for _, buffer in ipairs(vim.api.nvim_list_bufs()) do
|
||||
if vim.api.nvim_buf_is_loaded(buffer) and vim.bo[buffer].modified then
|
||||
table.insert(result, vim.api.nvim_buf_get_name(buffer))
|
||||
end
|
||||
end
|
||||
return result
|
||||
`, [], binding);
|
||||
if (Array.isArray(modified) && modified.some(name => name.endsWith(artifact.source))) {
|
||||
throw new Error('Bufor Task04 ma niezapisane zmiany; zapis lub przebudowa są wymagane przed replay.');
|
||||
}
|
||||
const elf = await nvimLua('return vim.env.ELF_FILE or ""', [], binding);
|
||||
if (typeof elf !== 'string' || !elf) throw new Error('Neovim nie ma ELF_FILE bieżącej sesji.');
|
||||
const elfOnHost = hostPath(repositoryRoot, elf);
|
||||
if (artifact.source_git_blob) {
|
||||
const sourceOnHost = hostPath(repositoryRoot, artifact.source);
|
||||
const actualSourceBlob = await gitBlobSha1(sourceOnHost);
|
||||
if (actualSourceBlob !== artifact.source_git_blob) {
|
||||
throw new Error('Źródło Task04 różni się od wersji użytej do pomiaru karty; przebuduj kartę i snapshoty.');
|
||||
}
|
||||
}
|
||||
if (artifact.hazard3_image_sha256 && binding.metadata.profile === 'hazard3-sim') {
|
||||
const imageOnHost = hostPath(repositoryRoot, artifact.hazard3_image);
|
||||
const actual = await sha256(imageOnHost);
|
||||
if (actual !== artifact.hazard3_image_sha256) {
|
||||
throw new Error('Obraz wykonywalny Hazard3 różni się od obrazu użytego do pomiaru karty; zregeneruj snapshoty.');
|
||||
}
|
||||
}
|
||||
return { elf, elfOnHost };
|
||||
}
|
||||
|
||||
async function waitForResult(resultFile, operationId, timeoutMs, binding) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let latest = null;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const current = await readJson(resultFile);
|
||||
if (current.operation_id === operationId) {
|
||||
latest = current;
|
||||
state.active = current;
|
||||
if (current.status === 'ready' || current.status === 'failed') return current;
|
||||
}
|
||||
} catch {
|
||||
// GDB writes atomically; absence before the first phase is expected.
|
||||
}
|
||||
await delay(POLL_MS);
|
||||
}
|
||||
await interruptGdb(binding).catch(() => undefined);
|
||||
await delay(200);
|
||||
throw new Error(`Timeout replay; ostatni stan: ${latest?.status ?? 'brak odpowiedzi GDB'}`);
|
||||
}
|
||||
|
||||
async function runActivation({
|
||||
repositoryRoot,
|
||||
cardFile,
|
||||
snapshotRef,
|
||||
generation,
|
||||
expectedBinding,
|
||||
expectedIdentity
|
||||
}) {
|
||||
if (generation !== state.wantedGeneration) {
|
||||
return { status: 'cancelled', generation, snapshot_ref: snapshotRef, message: 'Zastąpione nowszym krokiem.' };
|
||||
}
|
||||
const [cardSnapshot, binding] = await Promise.all([readCardSnapshot(cardFile), selectedBinding()]);
|
||||
const card = cardSnapshot.document;
|
||||
assertExpectedIdentity(cardSnapshot.identity, expectedIdentity);
|
||||
assertExpectedBinding(binding, expectedBinding);
|
||||
const selection = binding.metadata;
|
||||
const registry = card.debug_checkpoints;
|
||||
const recipe = registry?.items?.[snapshotRef];
|
||||
if (!recipe) throw new Error(`Brak recepty checkpointu: ${snapshotRef}`);
|
||||
if (!registry.targets?.[selection.profile]) {
|
||||
throw new Error(`Karta nie ma adaptera replay dla profilu ${selection.profile ?? '?'}.`);
|
||||
}
|
||||
await assertBindingCurrent(binding);
|
||||
await ensureTermdebug(binding);
|
||||
await assertEditorReady(repositoryRoot, registry.artifact, binding);
|
||||
const operationId = randomUUID();
|
||||
const payload = {
|
||||
operation_id: operationId,
|
||||
generation,
|
||||
snapshot_ref: snapshotRef,
|
||||
profile: selection.profile,
|
||||
semantics: registry.semantics,
|
||||
stop: recipe.stop,
|
||||
verify: recipe.verify ?? { expressions: [] }
|
||||
};
|
||||
state.active = {
|
||||
status: 'replaying',
|
||||
phase: 'dispatch',
|
||||
operation_id: operationId,
|
||||
generation,
|
||||
snapshot_ref: snapshotRef,
|
||||
profile: selection.profile
|
||||
};
|
||||
state.activeControl = { generation, binding };
|
||||
await sendGdb('source /workspace/tools/gdb/stem_checkpoint.py', binding);
|
||||
const token = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url');
|
||||
await sendGdb(`stem-checkpoint-activate ${token}`, binding);
|
||||
const resultFile = path.join(
|
||||
repositoryRoot,
|
||||
'.stem/instances',
|
||||
selection.instance,
|
||||
'gdb-sync.json.checkpoint.json'
|
||||
);
|
||||
const result = await waitForResult(
|
||||
resultFile,
|
||||
operationId,
|
||||
selection.profile === 'rp2350' ? 45000 : 20000,
|
||||
binding
|
||||
);
|
||||
if (result.status !== 'ready') {
|
||||
throw new Error(result.message ?? `Replay ${snapshotRef} nie osiągnął stanu ready.`);
|
||||
}
|
||||
await assertBindingCurrent(binding);
|
||||
state.last = result;
|
||||
state.active = null;
|
||||
state.activeControl = null;
|
||||
await callPinnedNvim(binding, 'nvim_command', ['silent! StudentViewReset']).catch(() => undefined);
|
||||
await callPinnedNvim(binding, 'nvim_command', ['redraw!']).catch(() => undefined);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function checkpointStatus() {
|
||||
return { active: state.active, last: state.last, wanted_generation: state.wantedGeneration };
|
||||
}
|
||||
|
||||
export function activateCheckpoint({
|
||||
repositoryRoot,
|
||||
cardFile,
|
||||
snapshotRef,
|
||||
generation,
|
||||
expectedBinding,
|
||||
expectedIdentity
|
||||
}) {
|
||||
if (typeof snapshotRef !== 'string' || !/^task04\.[a-z0-9.-]+$/.test(snapshotRef)) {
|
||||
return Promise.reject(new Error('Niepoprawny snapshot_ref.'));
|
||||
}
|
||||
if (generation != null && (!Number.isSafeInteger(generation) || generation <= 0)) {
|
||||
return Promise.reject(new Error('generation musi być dodatnią, bezpieczną liczbą całkowitą.'));
|
||||
}
|
||||
const nextGeneration = Number.isSafeInteger(generation) && generation > 0
|
||||
? generation
|
||||
: state.wantedGeneration + 1;
|
||||
state.wantedGeneration = Math.max(state.wantedGeneration + 1, nextGeneration);
|
||||
const activeControl = state.activeControl;
|
||||
if (
|
||||
activeControl
|
||||
&& activeControl.generation < state.wantedGeneration
|
||||
&& state.active?.phase === 'run-to-stop'
|
||||
) {
|
||||
void interruptGdb(activeControl.binding).catch(() => undefined);
|
||||
}
|
||||
const request = {
|
||||
repositoryRoot,
|
||||
cardFile,
|
||||
snapshotRef,
|
||||
generation: state.wantedGeneration,
|
||||
expectedBinding,
|
||||
expectedIdentity
|
||||
};
|
||||
const operation = state.queue.then(() => runActivation(request));
|
||||
state.queue = operation.catch(error => {
|
||||
state.last = {
|
||||
status: 'failed',
|
||||
snapshot_ref: snapshotRef,
|
||||
generation: request.generation,
|
||||
message: errorText(error)
|
||||
};
|
||||
state.active = null;
|
||||
if (state.activeControl?.generation === request.generation) state.activeControl = null;
|
||||
});
|
||||
return operation;
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
import net from 'node:net';
|
||||
import path from 'node:path';
|
||||
import { readFile, realpath } from 'node:fs/promises';
|
||||
import { decodeMultiStream, encode } from '@msgpack/msgpack';
|
||||
|
||||
const OPEN = 1;
|
||||
const DEFAULT_COLUMNS = 190;
|
||||
const DEFAULT_ROWS = 50;
|
||||
const RETRY_DELAY_MS = 1200;
|
||||
const TARGET_POLL_MS = 750;
|
||||
const MAX_BUFFERED_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
function errorText(error) {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function delay(milliseconds) {
|
||||
return new Promise(resolve => setTimeout(resolve, milliseconds));
|
||||
}
|
||||
|
||||
async function selectedTarget(selectionRoot) {
|
||||
const current = path.join(selectionRoot, 'current');
|
||||
const socketLink = path.join(current, 'n.sock');
|
||||
const [socketPath, metadataText] = await Promise.all([
|
||||
realpath(socketLink),
|
||||
readFile(path.join(current, 'selection.json'), 'utf8')
|
||||
]);
|
||||
const metadata = JSON.parse(metadataText);
|
||||
return {
|
||||
socketPath,
|
||||
metadata,
|
||||
epoch: `${metadata.container_id ?? 'unknown'}:${socketPath}`
|
||||
};
|
||||
}
|
||||
|
||||
class NvimRpcClient {
|
||||
constructor(socketPath, onNotification) {
|
||||
this.socketPath = socketPath;
|
||||
this.onNotification = onNotification;
|
||||
this.socket = new net.Socket();
|
||||
this.nextMessageId = 1;
|
||||
this.pending = new Map();
|
||||
this.closed = false;
|
||||
this.readLoop = null;
|
||||
}
|
||||
|
||||
async connect() {
|
||||
await new Promise((resolve, reject) => {
|
||||
const connected = () => {
|
||||
this.socket.off('error', failed);
|
||||
resolve();
|
||||
};
|
||||
const failed = error => {
|
||||
this.socket.off('connect', connected);
|
||||
reject(error);
|
||||
};
|
||||
this.socket.once('connect', connected);
|
||||
this.socket.once('error', failed);
|
||||
this.socket.connect(this.socketPath);
|
||||
});
|
||||
this.readLoop = this.consume().catch(error => {
|
||||
this.rejectPending(error);
|
||||
if (!this.closed) this.socket.destroy(error);
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
async consume() {
|
||||
for await (const message of decodeMultiStream(this.socket)) {
|
||||
if (!Array.isArray(message)) continue;
|
||||
if (message[0] === 1) {
|
||||
const pending = this.pending.get(message[1]);
|
||||
if (!pending) continue;
|
||||
this.pending.delete(message[1]);
|
||||
if (message[2]) pending.reject(new Error(JSON.stringify(message[2])));
|
||||
else pending.resolve(message[3]);
|
||||
} else if (message[0] === 2) {
|
||||
this.onNotification(message[1], message[2]);
|
||||
}
|
||||
}
|
||||
throw new Error('Neovim zamknął połączenie RPC.');
|
||||
}
|
||||
|
||||
call(method, parameters = []) {
|
||||
if (this.closed) return Promise.reject(new Error('Połączenie RPC jest zamknięte.'));
|
||||
const id = this.nextMessageId++;
|
||||
return new Promise((resolve, reject) => {
|
||||
this.pending.set(id, { resolve, reject });
|
||||
this.socket.write(encode([0, id, method, parameters]), error => {
|
||||
if (!error) return;
|
||||
this.pending.delete(id);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
rejectPending(error) {
|
||||
for (const pending of this.pending.values()) pending.reject(error);
|
||||
this.pending.clear();
|
||||
}
|
||||
|
||||
close() {
|
||||
if (this.closed) return;
|
||||
this.closed = true;
|
||||
this.rejectPending(new Error('Połączenie RPC zostało zamknięte.'));
|
||||
this.socket.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
function safeSend(websocket, message) {
|
||||
if (websocket.readyState !== OPEN) return;
|
||||
if (websocket.bufferedAmount > MAX_BUFFERED_BYTES) {
|
||||
websocket.close(1013, 'Neovim UI client is too slow');
|
||||
return;
|
||||
}
|
||||
websocket.send(JSON.stringify(message));
|
||||
}
|
||||
|
||||
function existingUiSize(uis) {
|
||||
const valid = Array.isArray(uis)
|
||||
? uis.filter(item => Number.isInteger(item?.width) && Number.isInteger(item?.height))
|
||||
: [];
|
||||
const terminal = valid.find(item => item?.stdin_tty && item?.stdout_tty);
|
||||
if (terminal) {
|
||||
return { width: terminal.width, height: terminal.height };
|
||||
}
|
||||
return {
|
||||
// Without a terminal UI there is nothing to mirror, so retain a useful
|
||||
// external-UI fallback size until the selected terminal is attached again.
|
||||
width: Math.max(DEFAULT_COLUMNS, ...valid.map(item => item.width)),
|
||||
height: Math.max(DEFAULT_ROWS, ...valid.map(item => item.height))
|
||||
};
|
||||
}
|
||||
|
||||
class NvimUiHub {
|
||||
constructor(selectionRoot, onEmpty) {
|
||||
this.selectionRoot = selectionRoot;
|
||||
this.onEmpty = onEmpty;
|
||||
this.clients = new Set();
|
||||
this.rpc = null;
|
||||
this.stopped = false;
|
||||
this.started = false;
|
||||
this.status = { type: 'status', state: 'connecting' };
|
||||
this.pendingResize = null;
|
||||
this.resizeTimer = null;
|
||||
this.appliedUiSize = '';
|
||||
this.uiSize = { width: DEFAULT_COLUMNS, height: DEFAULT_ROWS };
|
||||
this.uiAttached = false;
|
||||
this.refreshingUi = null;
|
||||
}
|
||||
|
||||
broadcast(message) {
|
||||
this.status = message.type === 'status' ? message : this.status;
|
||||
for (const client of this.clients) safeSend(client, message);
|
||||
}
|
||||
|
||||
add(websocket) {
|
||||
this.clients.add(websocket);
|
||||
safeSend(websocket, this.status);
|
||||
const remove = () => this.remove(websocket);
|
||||
websocket.once('close', remove);
|
||||
websocket.once('error', remove);
|
||||
websocket.on('message', payload => this.handleMessage(payload));
|
||||
if (!this.started) {
|
||||
this.started = true;
|
||||
void this.run().catch(error => {
|
||||
this.broadcast({ type: 'status', state: 'offline', message: errorText(error) });
|
||||
});
|
||||
} else if (this.rpc && this.uiAttached) {
|
||||
// A newly opened browser has no copy of redraw events emitted before it
|
||||
// joined the shared hub. Reattaching only this external UI makes Neovim
|
||||
// send one complete line-grid frame. The real TTY UI stays attached.
|
||||
void this.refreshUi().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
refreshUi() {
|
||||
if (this.refreshingUi) return this.refreshingUi;
|
||||
const rpc = this.rpc;
|
||||
const size = this.uiSize;
|
||||
if (!rpc || !this.uiAttached) return Promise.resolve();
|
||||
this.refreshingUi = (async () => {
|
||||
await rpc.call('nvim_ui_detach');
|
||||
if (rpc !== this.rpc || this.stopped) return;
|
||||
this.uiAttached = false;
|
||||
await rpc.call('nvim_ui_attach', [size.width, size.height, {
|
||||
rgb: false,
|
||||
ext_linegrid: true,
|
||||
ext_multigrid: false,
|
||||
override: false
|
||||
}]);
|
||||
if (rpc === this.rpc) this.uiAttached = true;
|
||||
})().finally(() => {
|
||||
this.refreshingUi = null;
|
||||
});
|
||||
return this.refreshingUi;
|
||||
}
|
||||
|
||||
remove(websocket) {
|
||||
this.clients.delete(websocket);
|
||||
if (this.clients.size > 0) return;
|
||||
this.stopped = true;
|
||||
if (this.resizeTimer) clearTimeout(this.resizeTimer);
|
||||
this.rpc?.close();
|
||||
this.onEmpty();
|
||||
}
|
||||
|
||||
scheduleResize(columns, rows) {
|
||||
this.pendingResize = { columns, rows };
|
||||
if (this.resizeTimer) clearTimeout(this.resizeTimer);
|
||||
this.resizeTimer = setTimeout(() => {
|
||||
this.resizeTimer = null;
|
||||
void this.applyResize().catch(() => undefined);
|
||||
}, 90);
|
||||
}
|
||||
|
||||
async resizeToTerminal() {
|
||||
if (this.resizeTimer) {
|
||||
clearTimeout(this.resizeTimer);
|
||||
this.resizeTimer = null;
|
||||
}
|
||||
this.pendingResize = {
|
||||
columns: this.uiSize.width,
|
||||
rows: this.uiSize.height
|
||||
};
|
||||
await this.applyResize();
|
||||
return this.uiSize;
|
||||
}
|
||||
|
||||
async applyResize() {
|
||||
const requested = this.pendingResize;
|
||||
const rpc = this.rpc;
|
||||
this.pendingResize = null;
|
||||
if (!requested || !rpc || this.stopped) return;
|
||||
const uis = await rpc.call('nvim_list_uis');
|
||||
if (rpc !== this.rpc) return;
|
||||
const terminalUi = Array.isArray(uis)
|
||||
? uis.find(ui => ui?.stdin_tty && ui?.stdout_tty)
|
||||
: null;
|
||||
const columns = Number.isInteger(terminalUi?.width)
|
||||
? terminalUi.width
|
||||
: requested.columns;
|
||||
const rows = Number.isInteger(terminalUi?.height)
|
||||
? terminalUi.height
|
||||
: requested.rows;
|
||||
const size = `${columns}x${rows}`;
|
||||
if (size === this.appliedUiSize) return;
|
||||
await rpc.call('nvim_ui_try_resize', [columns, rows]);
|
||||
if (rpc !== this.rpc) return;
|
||||
await rpc.call('nvim_command', ['silent! StudentLayoutFit']);
|
||||
this.uiSize = { width: columns, height: rows };
|
||||
this.appliedUiSize = size;
|
||||
}
|
||||
|
||||
handleMessage(payload) {
|
||||
if (!this.rpc || this.stopped) return;
|
||||
try {
|
||||
const message = JSON.parse(payload.toString('utf8'));
|
||||
if (message?.type === 'refresh') {
|
||||
void this.resizeToTerminal()
|
||||
.then(() => this.refreshUi())
|
||||
.catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
if (message?.type === 'input' && typeof message.keys === 'string' && message.keys.length <= 256) {
|
||||
void this.rpc.call('nvim_input', [message.keys]).catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
if (message?.type !== 'mouse') return;
|
||||
const button = ['left', 'middle', 'right', 'wheel'].includes(message.button)
|
||||
? message.button
|
||||
: null;
|
||||
const action = ['press', 'drag', 'release', 'up', 'down'].includes(message.action)
|
||||
? message.action
|
||||
: null;
|
||||
const row = Number(message.row);
|
||||
const column = Number(message.column);
|
||||
if (!button || !action || !Number.isInteger(row) || !Number.isInteger(column)) return;
|
||||
void this.rpc.call('nvim_input_mouse', [
|
||||
button,
|
||||
action,
|
||||
typeof message.modifier === 'string' ? message.modifier.slice(0, 16) : '',
|
||||
0,
|
||||
Math.max(0, row),
|
||||
Math.max(0, column)
|
||||
]).catch(() => undefined);
|
||||
} catch {
|
||||
// Only the typed input/mouse/refresh capabilities above are exposed.
|
||||
}
|
||||
}
|
||||
|
||||
async run() {
|
||||
while (!this.stopped && this.clients.size > 0) {
|
||||
let selected;
|
||||
try {
|
||||
selected = await selectedTarget(this.selectionRoot);
|
||||
} catch (error) {
|
||||
this.broadcast({
|
||||
type: 'status',
|
||||
state: 'unselected',
|
||||
message: `Brak aktywnego celu MCP: ${errorText(error)}`
|
||||
});
|
||||
await delay(RETRY_DELAY_MS);
|
||||
continue;
|
||||
}
|
||||
|
||||
this.broadcast({
|
||||
type: 'status',
|
||||
state: 'connecting',
|
||||
epoch: selected.epoch,
|
||||
target: selected.metadata
|
||||
});
|
||||
|
||||
let targetTimer = null;
|
||||
try {
|
||||
this.rpc = new NvimRpcClient(selected.socketPath, (method, parameters) => {
|
||||
if (method !== 'redraw') return;
|
||||
this.broadcast({
|
||||
type: 'redraw',
|
||||
epoch: selected.epoch,
|
||||
events: parameters
|
||||
});
|
||||
});
|
||||
await this.rpc.connect();
|
||||
this.appliedUiSize = '';
|
||||
this.uiAttached = false;
|
||||
void this.rpc.readLoop.catch(() => undefined);
|
||||
const uis = await this.rpc.call('nvim_list_uis');
|
||||
const size = existingUiSize(uis);
|
||||
this.uiSize = size;
|
||||
await this.rpc.call('nvim_set_client_info', [
|
||||
'stem-card-browser',
|
||||
{ major: 0, minor: 1, patch: 0 },
|
||||
'ui',
|
||||
{},
|
||||
{ website: 'local://stem-card', license: 'private' }
|
||||
]);
|
||||
await this.rpc.call('nvim_ui_attach', [size.width, size.height, {
|
||||
rgb: false,
|
||||
ext_linegrid: true,
|
||||
ext_multigrid: false,
|
||||
override: false
|
||||
}]);
|
||||
this.uiAttached = true;
|
||||
this.broadcast({
|
||||
type: 'status',
|
||||
state: 'connected',
|
||||
epoch: selected.epoch,
|
||||
target: selected.metadata,
|
||||
grid: size,
|
||||
control: 'typed-input'
|
||||
});
|
||||
|
||||
const targetChanged = new Promise((_, reject) => {
|
||||
targetTimer = setInterval(async () => {
|
||||
try {
|
||||
const current = await selectedTarget(this.selectionRoot);
|
||||
if (current.epoch !== selected.epoch) {
|
||||
reject(new Error('Zmieniono cel MCP.'));
|
||||
}
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
}, TARGET_POLL_MS);
|
||||
});
|
||||
await Promise.race([this.rpc.readLoop, targetChanged]);
|
||||
} catch (error) {
|
||||
if (!this.stopped) {
|
||||
this.broadcast({
|
||||
type: 'status',
|
||||
state: 'offline',
|
||||
epoch: selected.epoch,
|
||||
target: selected.metadata,
|
||||
message: errorText(error)
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
if (targetTimer) clearInterval(targetTimer);
|
||||
this.uiAttached = false;
|
||||
this.rpc?.close();
|
||||
this.rpc = null;
|
||||
}
|
||||
if (!this.stopped) await delay(RETRY_DELAY_MS);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hubs = new Map();
|
||||
|
||||
function selectionRootFor(options = {}) {
|
||||
return options.selectionRoot
|
||||
?? process.env.MCP_CONTAINER_SELECTION_ROOT
|
||||
?? path.join(process.env.XDG_RUNTIME_DIR ?? '/run/user/1000', 'stem/mcp-selected');
|
||||
}
|
||||
|
||||
export async function callSelectedNvim(method, parameters = [], options = {}) {
|
||||
const selectionRoot = selectionRootFor(options);
|
||||
const hub = hubs.get(selectionRoot);
|
||||
const pinnedSocketPath = options.socketPath
|
||||
? await realpath(options.socketPath)
|
||||
: null;
|
||||
if (
|
||||
hub?.rpc
|
||||
&& !hub.stopped
|
||||
&& (!pinnedSocketPath || hub.rpc.socketPath === pinnedSocketPath)
|
||||
) {
|
||||
return hub.rpc.call(method, parameters);
|
||||
}
|
||||
const socketPath = pinnedSocketPath ?? (await selectedTarget(selectionRoot)).socketPath;
|
||||
const rpc = new NvimRpcClient(socketPath, () => undefined);
|
||||
await rpc.connect();
|
||||
void rpc.readLoop.catch(() => undefined);
|
||||
try {
|
||||
return await rpc.call(method, parameters);
|
||||
} finally {
|
||||
rpc.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function resizeSelectedNvimUi(options = {}) {
|
||||
const selectionRoot = selectionRootFor(options);
|
||||
const hub = hubs.get(selectionRoot);
|
||||
if (!hub?.rpc || hub.stopped || !hub.uiAttached) {
|
||||
return { attached: false, grid: null };
|
||||
}
|
||||
const grid = await hub.resizeToTerminal();
|
||||
await hub.refreshUi();
|
||||
return { attached: true, grid };
|
||||
}
|
||||
|
||||
export async function selectedNvimTarget(options = {}) {
|
||||
return selectedTarget(selectionRootFor(options));
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach a browser to the one shared external UI for the selected container.
|
||||
* The browser never receives the Unix socket and cannot issue arbitrary RPC.
|
||||
*/
|
||||
export function attachNvimUi(websocket, options = {}) {
|
||||
const selectionRoot = selectionRootFor(options);
|
||||
let hub = hubs.get(selectionRoot);
|
||||
if (!hub || hub.stopped) {
|
||||
hub = new NvimUiHub(selectionRoot, () => hubs.delete(selectionRoot));
|
||||
hubs.set(selectionRoot, hub);
|
||||
}
|
||||
hub.add(websocket);
|
||||
}
|
||||
Reference in New Issue
Block a user