The Course pane lists the 21 lessons, shows a lesson's text and exercise, loads its starter, and checks the program in the editor with the engine (engine_course_check in the page, the same commands in the desktop app): each case's verdict, each rule, What went wrong? from the diagnostics, the verdict and the critique, the hints one at a time with the engine's pointer, and a hint from the assistant when a model is connected, any program in its answer held back. Progress is kept only in this browser and says so. The interface's words are data, locales/<locale>/<area>.json for every part, English the source and Simplified Chinese marked machine-assisted; the Studio follows the browser's language until the reader picks one with the switch. test/locales.test.mjs holds every locale to every key and placeholder. The e2e checks the course in English and in Chinese. wai-web's engine tests pin 23 native and 16 wasm32, a course check the same bytes in both. Co-Authored-By: Claude Opus 5.5 <[email protected]>
305 lines
18 KiB
JavaScript
305 lines
18 KiB
JavaScript
// The program's history, attributed (the engine's versions): every version,
|
||
// who made it and how, and whose hand each line and gate is in. The Studio
|
||
// records each change as it sees it made: what is typed is the reader's, a
|
||
// proposal applied is its proposer's, and a fix is the engine's. A proposal is
|
||
// reviewed change by change before it touches the program, and a version
|
||
// opened from the past and edited starts a line of work of its own. The
|
||
// history stays in this browser; a run's request carries its account, which
|
||
// the receipt binds.
|
||
import { el } from './dom.js';
|
||
import { t } from './i18n.js';
|
||
|
||
const SAVED = 'wai.quantum-studio.versions';
|
||
const IDLE_MS = 4000;
|
||
const ROW = 30, LANE = 14;
|
||
const SVG = 'http://www.w3.org/2000/svg';
|
||
|
||
// Whose hand, as the reader is told.
|
||
export const HANDS = {
|
||
person: t('versions.hand_person'), assistant: t('versions.hand_assistant'), agent: t('versions.hand_agent'), engine: t('versions.hand_engine'), origin: t('versions.hand_origin'),
|
||
assistant_then_person: t('versions.hand_assistant_then_person'), agent_then_person: t('versions.hand_agent_then_person'),
|
||
};
|
||
const KINDS = { person: HANDS.person, assistant: HANDS.assistant, agent: HANDS.agent, engine: HANDS.engine };
|
||
const HOW = { start: t('versions.how_start'), edit: t('versions.how_edit'), proposal: t('versions.how_proposal'), merge: t('versions.how_merge') };
|
||
// Who, as the reader is told: a program from elsewhere is named for where it came from.
|
||
export const whoName = (by) => (by?.kind === 'origin' ? by.name || HANDS.origin : `${KINDS[by?.kind] || by?.kind || '?'}${by?.name ? ` (${by.name})` : ''}`);
|
||
// How a version was made, and by whom: “edited by you”, “started from the example Bell pair”.
|
||
const made = (v) => (v.by?.kind === 'origin'
|
||
? (v.how === 'start' ? t('versions.started_from', { who: whoName(v.by) }) : t('versions.opened_from', { who: whoName(v.by) }))
|
||
: t('versions.made_by', { how: HOW[v.how] || v.how, who: whoName(v.by) }));
|
||
const nChanges = (n) => (n === 1 ? t('versions.change_one', { n }) : t('versions.changes', { n }));
|
||
const nGates = (n) => (n === 1 ? t('versions.gate_one', { n }) : t('versions.gates', { n }));
|
||
const nLines = (n) => (n === 1 ? t('versions.line_one', { n }) : t('versions.lines', { n }));
|
||
|
||
const svg = (tag, attrs = {}) => {
|
||
const e = document.createElementNS(SVG, tag);
|
||
for (const [k, v] of Object.entries(attrs)) e.setAttribute(k, v);
|
||
return e;
|
||
};
|
||
|
||
// Each version's lane: a version continues its first parent's lane unless
|
||
// another child already has, and then opens one of its own.
|
||
function lanes(versions) {
|
||
const lane = new Map(), continued = new Set();
|
||
let next = 0;
|
||
for (const v of versions) {
|
||
const p = v.parents?.[0];
|
||
if (p != null && !continued.has(p) && lane.has(p)) { lane.set(v.id, lane.get(p)); continued.add(p); }
|
||
else lane.set(v.id, next++);
|
||
}
|
||
return { lane, count: Math.max(next, 1) };
|
||
}
|
||
|
||
// The pane. `engine.history(name, args)` calls the engine; `text()` and
|
||
// `setText(s)` read and write the editor; `inputs()` gives a generator's
|
||
// values (or {} for a program with none); `lock(on)` makes the editor wait
|
||
// while a proposal is reviewed in `reviewHost`; `onHands(blame)` hears whose
|
||
// hand each line and gate is in; `save(name, body)` keeps a file; `rules()`
|
||
// gives the reader's rules for proposals (the engine's locks), and `taken()`
|
||
// hears that a proposal was decided.
|
||
// `editNote()` notes the reader's own edits (a while on their own, say);
|
||
// `onRender()` hears each time the history is shown again.
|
||
export function versionsPane({ engine, text, setText, inputs, lock, reviewHost, onHands, save, status, rules = () => ({}), taken = () => {}, editNote = () => null, onRender = () => {} }) {
|
||
let history = null;
|
||
let current = null;
|
||
let selected = null;
|
||
let timer = null;
|
||
let queue = Promise.resolve();
|
||
let reviewing = false;
|
||
let showing = false;
|
||
|
||
const call = (name, args) => engine.history(name, args);
|
||
const source = (id) => history?.versions.find((v) => v.id === id)?.source;
|
||
const keep = () => { try { localStorage.setItem(SAVED, JSON.stringify({ history, current })); } catch (e) {} };
|
||
// One change at a time, in the order made.
|
||
const serial = (f) => (queue = queue.then(f, f));
|
||
|
||
const handsBox = el('input', { type: 'checkbox', id: 'qs-vs-hands' });
|
||
const handsLabel = el('label', { class: 'qs-check', for: 'qs-vs-hands', title: t('versions.hands_title') }, handsBox, el('span', { text: t('versions.hands') }));
|
||
const download = el('button', { class: 'qs-btn qs-small', id: 'qs-vs-download', type: 'button', text: t('versions.download'), title: t('versions.download_title') });
|
||
const share = el('div', { class: 'qs-vs-share', id: 'qs-vs-share' });
|
||
const graph = el('div', { class: 'qs-vs-graph' });
|
||
const list = el('ol', { class: 'qs-vs-list', id: 'qs-vs-list' });
|
||
const detail = el('div', { class: 'qs-vs-detail', id: 'qs-vs-detail' });
|
||
const node = el('section', { class: 'qs-pane qs-vs', 'aria-labelledby': 'qs-vs-h' },
|
||
el('div', { class: 'qs-head' }, el('h2', { id: 'qs-vs-h', text: t('versions.title') }), el('span', { class: 'qs-grow' }), handsLabel, download),
|
||
el('p', { class: 'qs-note qs-vs-intro', text: t('versions.intro') }),
|
||
share, el('div', { class: 'qs-vs-lines' }, graph, list), detail);
|
||
|
||
// ---- recording ----
|
||
// The program as the editor holds it, recorded as `by`'s edit if it changed.
|
||
function record(by = { kind: 'person' }, note = by.kind === 'person' ? editNote() ?? undefined : undefined) {
|
||
clearTimeout(timer);
|
||
return serial(async () => {
|
||
if (!history || reviewing) return current;
|
||
const src = text();
|
||
if (src === source(current)) return current;
|
||
const r = await call('commit', { history, parent: current, source: src, by, note, at: Date.now() });
|
||
history = r.history; current = r.version; selected = null; keep();
|
||
await render();
|
||
return current;
|
||
});
|
||
}
|
||
|
||
// The editor changed: the reader's edit is recorded once they pause.
|
||
function edited() {
|
||
if (!history || reviewing) return;
|
||
clearTimeout(timer);
|
||
if (text() !== source(current)) timer = setTimeout(() => record(), IDLE_MS);
|
||
}
|
||
|
||
// The history this browser kept goes on, a program that differs from its
|
||
// current version recorded as `changed`'s; with none kept, a history starts
|
||
// with the program as `fresh`'s (or `changed`'s).
|
||
async function begin(changed, fresh = null) {
|
||
let was = null;
|
||
try { was = JSON.parse(localStorage.getItem(SAVED) || 'null'); } catch (e) {}
|
||
await serial(async () => {
|
||
if (was?.history?.versions?.length && was.history.versions.some((v) => v.id === was.current)) {
|
||
history = was.history; current = was.current;
|
||
const log = await call('log', { history }).catch(() => null);
|
||
if (!log?.check?.ok) { history = null; current = null; }
|
||
}
|
||
// A program that is a version's already is that version, opened.
|
||
const same = history && text() !== source(current) ? [...history.versions].reverse().find((v) => v.source === text()) : null;
|
||
if (same) current = same.id;
|
||
else if (history && text() !== source(current)) {
|
||
const r = await call('commit', { history, parent: current, source: text(), by: changed, at: Date.now() });
|
||
history = r.history; current = r.version;
|
||
} else if (!history) {
|
||
const r = await call('start', { source: text(), by: fresh || changed, at: Date.now() });
|
||
history = r.history; current = r.version;
|
||
}
|
||
keep();
|
||
});
|
||
await render();
|
||
}
|
||
|
||
// ---- reviewing ----
|
||
// A proposal (or, with `merge`, another line of work) shown change by
|
||
// change; only the changes taken reach the program. `by` is its proposer.
|
||
async function review(proposed, by, note, { merge = null, all = false } = {}) {
|
||
if (reviewing) return null;
|
||
await record();
|
||
const base = source(current);
|
||
const { changes } = await call('changes', { base, proposed });
|
||
if (!changes.length) { status?.(merge ? t('versions.merge_nothing', { id: merge }) : t('versions.proposal_same')); return null; }
|
||
// The reader's rules bind a proposal, not their own merge.
|
||
const breaches = merge ? changes.map(() => []) : (await engine.locks('check', { base, proposed, locks: rules(), inputs: inputs() })).changes.map((c) => c.breaches);
|
||
const blocked = breaches.flatMap((b, i) => (b.length ? [i] : []));
|
||
const finish = async (picked) => {
|
||
const accepted = picked.filter((i) => !blocked.includes(i));
|
||
await serial(async () => {
|
||
if (merge) {
|
||
const { source: merged } = await call('apply', { base, proposed, accepted });
|
||
const r = await call('merge', { history, a: current, b: merge, source: merged, by: { kind: 'person' }, note: note || `${merge} brought in`, at: Date.now() });
|
||
history = r.history; current = r.version;
|
||
} else {
|
||
const r = await call('propose', { history, parent: current, proposed, accepted, blocked, by, note, at: Date.now() });
|
||
history = r.history; current = r.version;
|
||
}
|
||
selected = null; keep();
|
||
});
|
||
reviewing = false; lock(false); reviewHost.replaceChildren(); reviewHost.hidden = true;
|
||
setText(source(current));
|
||
if (!merge) taken();
|
||
await render();
|
||
return { offered: changes.length, accepted: accepted.length, blocked: blocked.length, version: current };
|
||
};
|
||
if (all) return finish(changes.map((_, i) => i));
|
||
reviewing = true; lock(true);
|
||
return new Promise((resolve) => {
|
||
const boxes = changes.map((c, i) => el('input', { type: 'checkbox', id: `qs-review-${i}`, checked: !blocked.includes(i), disabled: blocked.includes(i) }));
|
||
const take = el('button', { class: 'qs-btn qs-primary qs-small', id: 'qs-review-apply', type: 'button' });
|
||
const open = changes.length - blocked.length;
|
||
const count = () => { const n = boxes.filter((b) => b.checked).length; take.textContent = n === changes.length ? t('versions.take_all', { n: changes.length }) : t('versions.take_some', { n, of: open }); take.disabled = n === 0 && open === 0; };
|
||
boxes.forEach((b) => b.addEventListener('change', count)); count();
|
||
const who = merge ? `${merge} (${whoName(history.versions.find((v) => v.id === merge)?.by)})` : whoName(by);
|
||
reviewHost.replaceChildren(
|
||
el('p', { class: 'qs-review-head' }, el('b', { text: merge ? t('versions.bring_in', { id: merge }) : t('versions.proposal_from', { who }) }),
|
||
el('span', { class: 'qs-note', text: t('versions.review_sum', { changes: nChanges(changes.length), blocked: blocked.length ? t('versions.blocked_by_your_rules', { n: blocked.length }) : '' }) })),
|
||
note ? el('p', { class: 'qs-note qs-review-note', text: note }) : null,
|
||
el('ol', { class: 'qs-review-list' }, ...changes.map((c, i) => el('li', { class: blocked.includes(i) ? 'qs-blocked' : '' },
|
||
el('label', { class: 'qs-check', for: `qs-review-${i}` }, boxes[i], el('span', { text: t('versions.at_line', { line: c.line }) })),
|
||
breaches[i].length ? el('p', { class: 'qs-blocked-why', text: t('versions.blocked_why', { why: breaches[i].map((b) => b.why).join('; ') }) }) : null,
|
||
el('pre', { class: 'qs-diff' }, ...c.removed.map((l) => el('span', { class: 'qs-del', text: `− ${l}\n` })), ...c.added.map((l) => el('span', { class: 'qs-add', text: `+ ${l}\n` })))))),
|
||
el('div', { class: 'qs-actions' }, take,
|
||
el('button', { class: 'qs-btn qs-small', id: 'qs-review-reject', type: 'button', text: merge ? t('versions.not_now') : t('versions.reject_all'), onclick: async () => {
|
||
if (merge) { reviewing = false; lock(false); reviewHost.replaceChildren(); reviewHost.hidden = true; resolve(null); return; }
|
||
resolve(await finish([]));
|
||
} })));
|
||
reviewHost.hidden = false;
|
||
take.addEventListener('click', async () => { take.disabled = true; resolve(await finish(boxes.flatMap((b, i) => (b.checked ? [i] : [])))); });
|
||
});
|
||
}
|
||
|
||
// ---- the account a run carries ----
|
||
async function attest() {
|
||
await record();
|
||
return queue.then(() => call('attest', { history, version: current, inputs: inputs() }));
|
||
}
|
||
const checkJob = (job) => call('check_job', { history, job });
|
||
|
||
// ---- showing ----
|
||
async function blame() {
|
||
if (!history) return null;
|
||
const b = await call('blame', { history, version: current, inputs: inputs() });
|
||
b.source = source(current);
|
||
return b;
|
||
}
|
||
|
||
function shareOf(a) {
|
||
if (!a) return [el('p', { class: 'qs-note', text: t('versions.unread') })];
|
||
const total = a.of.gates;
|
||
const hand = (c) => `${HANDS[c.hand]}${c.name ? ` (${c.name})` : ''}`;
|
||
const bar = el('div', { class: 'qs-vs-bar', role: 'img', 'aria-label': a.contributors.map((c) => t('versions.bar_part', { n: c.gates, of: total, who: HANDS[c.hand] })).join(', ') },
|
||
...a.contributors.filter((c) => c.gates).map((c) => el('span', { class: `qs-vs-seg qs-hand-${c.hand}`, style: `flex-grow:${c.gates}`, title: t('versions.hand_gates', { gates: nGates(c.gates), who: hand(c) }) })));
|
||
return [
|
||
el('p', { class: 'qs-vs-sum', id: 'qs-vs-sum', text: t('versions.sum', { version: current, gates: nGates(total), lines: nLines(a.of.lines) }) }),
|
||
total ? bar : null,
|
||
el('ul', { class: 'qs-vs-who' }, ...a.contributors.map((c) => el('li', {},
|
||
el('span', { class: `qs-vs-key qs-hand-${c.hand}`, 'aria-hidden': 'true' }),
|
||
el('span', { text: t('versions.hand_share', { gates: nGates(c.gates), lines: nLines(c.lines), who: hand(c) }) })))),
|
||
];
|
||
}
|
||
|
||
function drawGraph(versions, { lane, count }) {
|
||
const order = [...versions].reverse();
|
||
const row = new Map(order.map((v, i) => [v.id, i]));
|
||
const x = (id) => LANE / 2 + LANE * lane.get(id);
|
||
const y = (id) => ROW / 2 + ROW * row.get(id);
|
||
const g = svg('svg', { width: LANE * count, height: ROW * order.length, 'aria-hidden': 'true', class: 'qs-vs-svg' });
|
||
for (const v of order) for (const p of v.parents || []) {
|
||
if (!row.has(p)) continue;
|
||
const [x1, y1, x2, y2] = [x(v.id), y(v.id), x(p), y(p)];
|
||
g.append(svg('path', { class: 'qs-vs-edge', d: x1 === x2 ? `M${x1} ${y1}V${y2}` : `M${x1} ${y1}C${x1} ${y1 + ROW * 0.8} ${x2} ${y2 - ROW * 0.8} ${x2} ${y2}` }));
|
||
}
|
||
for (const v of order) g.append(svg('circle', { class: `qs-vs-dot qs-by-${v.by.kind}${v.id === current ? ' qs-vs-here' : ''}`, cx: x(v.id), cy: y(v.id), r: v.id === current ? 5 : 4 }));
|
||
return g;
|
||
}
|
||
|
||
function showDetail(v, log) {
|
||
selected = v.id;
|
||
for (const li of list.children) li.classList.toggle('on', li.dataset.id === v.id);
|
||
const e = log.find((x) => x.id === v.id) || {};
|
||
const parent = v.parents?.[0];
|
||
const heads = log.filter((x) => x.head).map((x) => x.id);
|
||
const actions = [];
|
||
if (v.id !== current) actions.push(el('button', { class: 'qs-btn qs-small', id: 'qs-vs-open', type: 'button', text: t('versions.open', { id: v.id }), title: t('versions.open_title'), onclick: () => openVersion(v.id) }));
|
||
if (heads.includes(v.id) && v.id !== current) actions.push(el('button', { class: 'qs-btn qs-small', id: 'qs-vs-merge', type: 'button', text: t('versions.bring', { id: v.id }), title: t('versions.bring_title'), onclick: () => review(v.source, { kind: 'person' }, null, { merge: v.id }) }));
|
||
const r = v.review;
|
||
detail.replaceChildren(
|
||
el('p', { class: 'qs-vs-what' }, el('b', { text: v.id }), el('span', { text: t('versions.what', { when: new Date(v.at).toLocaleString(), from: v.parents?.length ? t('versions.from', { parents: v.parents.join(t('versions.and')) }) : '', made: made(v) }) })),
|
||
v.note ? el('p', { class: 'qs-note', text: v.note }) : null,
|
||
r ? el('p', { class: 'qs-note', id: 'qs-vs-review', text: t('versions.review_taken', { n: r.accepted.length, changes: nChanges(r.offered), blocked: r.blocked?.length ? t('versions.blocked_by_the_rules', { n: r.blocked.length }) : '', rest: r.accepted.length < r.offered ? t('versions.rest_kept') : '' }) }) : null,
|
||
r && r.accepted.length < r.offered ? el('details', {}, el('summary', { text: t('versions.whole_proposal') }), el('pre', { class: 'qs-ai-code', text: r.proposed })) : null,
|
||
parent ? el('p', { class: 'qs-note', text: t('versions.against', { added: e.added ?? 0, removed: e.removed ?? 0, parent, sealed: e.sealed ? t('versions.sealed') : '' }) }) : null,
|
||
actions.length ? el('div', { class: 'qs-actions' }, ...actions) : null);
|
||
}
|
||
|
||
async function openVersion(id) {
|
||
await record();
|
||
current = id; keep();
|
||
setText(source(id));
|
||
status?.(t('versions.opened', { id }));
|
||
await render();
|
||
}
|
||
|
||
async function render() {
|
||
if (!history) return;
|
||
const log = (await call('log', { history })).versions;
|
||
const ls = lanes(history.versions);
|
||
graph.replaceChildren(drawGraph(history.versions, ls));
|
||
list.replaceChildren(...[...history.versions].reverse().map((v) => {
|
||
const e = log.find((x) => x.id === v.id) || {};
|
||
const tags = [v.id === current ? t('versions.in_editor') : null, e.head && v.id !== current ? t('versions.a_head') : null].filter(Boolean);
|
||
return el('li', { 'data-id': v.id, class: v.id === current ? 'here' : '' },
|
||
el('button', { type: 'button', class: 'qs-vs-item', onclick: () => showDetail(v, log) },
|
||
el('span', { class: 'qs-vs-id', text: v.id }),
|
||
el('span', { class: 'qs-vs-by', text: made(v) }),
|
||
el('span', { class: 'qs-note', text: `${e.review ? t('versions.item_review', { n: e.review.accepted, of: e.review.offered, blocked: e.review.blocked ? t('versions.item_blocked', { n: e.review.blocked }) : '' }) : ''}+${e.added ?? 0} −${e.removed ?? 0}${tags.length ? ` · ${tags.join(', ')}` : ''}` })));
|
||
}));
|
||
const b = await blame().catch(() => null);
|
||
share.replaceChildren(...shareOf(b?.authorship));
|
||
if (showing) onHands(b);
|
||
const v = history.versions.find((x) => x.id === (selected && source(selected) != null ? selected : current));
|
||
if (v) showDetail(v, log);
|
||
onRender();
|
||
}
|
||
|
||
handsBox.addEventListener('change', async () => {
|
||
showing = handsBox.checked;
|
||
onHands(showing ? await blame().catch(() => null) : null);
|
||
});
|
||
download.addEventListener('click', async () => {
|
||
await record();
|
||
await save('program.qasm', source(current), 'text/plain');
|
||
await save('program.history.json', JSON.stringify(history, null, 2) + '\n', 'application/json');
|
||
status?.(t('versions.saved'), 'ok');
|
||
});
|
||
|
||
return {
|
||
node, begin, edited, record, review, attest, checkJob,
|
||
current: () => current, history: () => history, showing: () => showing, busy: () => reviewing, blame,
|
||
};
|
||
}
|