tricu

A lazy functional language based on Tree Calculus
Log | Files | Refs | README | LICENSE

app.js (37210B)


      1 import { terminalView } from './io-terminal-view.js';
      2 import { SourceEditor } from './source-editor.js';
      3 import { definitionInspector } from './definition-inspector.js';
      4 import { exampleGroups, exampleGroup, libraryLabel } from './program-catalogue.js';
      5 import { importPicker } from './import-picker.js';
      6 import { localModulesUI } from './local-modules-ui.js';
      7 import { tokenize, paintSyntax } from './syntax.js';
      8 import { backends, runtimeStats } from './runtime-backends.js';
      9 import { setupInspector } from './inspector.js';
     10 import { ActiveClock } from './clock.js';
     11 import { PACKAGE_LOAD_MS, operationFailure, operationTimeout } from './execution.js';
     12 import { sourceText } from './compiler.js';
     13 import { packageIndex, packageReleases } from './packages.js';
     14 import { bundleHash, fetchBytes } from './bundle.js';
     15 import { literals, outputType } from './codecs.js';
     16 function el(id) {
     17     const element = document.getElementById(id);
     18     if (!element)
     19         throw new Error(`Missing element ${id}`);
     20     return element;
     21 }
     22 const sourceEditor = new SourceEditor(el('source-editor'));
     23 const compileButton = el('compile'), restoreButton = el('restore-source');
     24 const downloadButton = el('download-bundle'), exportModuleButton = el('export-compiled-module');
     25 const programPicker = el('program-picker');
     26 const versionPicker = el('package-version'), hashInput = el('bundle-hash');
     27 const stopButton = el('stop'), shareButton = el('share');
     28 const selectedBackend = 'haskell-wasm';
     29 const workerURL = () => new URL(backends[selectedBackend].worker, import.meta.url);
     30 let draft = false, scratch = false, compiled = false, busy = false, loaded = false;
     31 let originalSource, compiledSource, compiledRevision;
     32 let sourceAvailable = false, editRevision = 0, composing = false, idleDue = 0, compilationStats = '';
     33 let entries = [], releases = [], selected;
     34 let selection, versionPackage;
     35 let preferredExport, currentName = '', publishedNames = [];
     36 let worker, pending;
     37 let ioVersion, ioRun, ioWidget, stoppingIO = false;
     38 let operation, idleTimer;
     39 let compilerTimer;
     40 const deadline = new ActiveClock(() => finish(operationTimeout(operation ?? 'loading'), true));
     41 function cancelIdleCompile() { clearTimeout(idleTimer); idleTimer = undefined; }
     42 function stopCompilerActivity() { clearInterval(compilerTimer); compilerTimer = undefined; el('compile-activity').hidden = true; }
     43 function queueCompile(delay = 650) {
     44     cancelIdleCompile();
     45     if (composing)
     46         return;
     47     idleDue = performance.now() + delay;
     48     idleTimer = setTimeout(() => { idleTimer = undefined; if (!busy && draft)
     49         compileDraft(); }, delay);
     50     stopButton.hidden = false;
     51     stopButton.disabled = false;
     52 }
     53 const localModules = localModulesUI(() => {
     54     if (!draft || busy && operation !== 'compiling')
     55         return;
     56     editRevision++;
     57     definitions.reset();
     58     if (!busy) {
     59         dispose();
     60         el('status').textContent = 'Changes pending';
     61     }
     62     queueCompile();
     63 });
     64 const definitions = definitionInspector(sourceEditor, (id, name, output) => {
     65     if (busy || !worker)
     66         return;
     67     currentName = name;
     68     operation = 'running';
     69     controls(true);
     70     deadline.reset(Infinity);
     71     deadline.start();
     72     el('status').textContent = `Inspecting ${name}`;
     73     diagnostic();
     74     worker.postMessage({ type: 'inspect', id, name, output });
     75 }, {
     76     run(name, output, step) {
     77         if (busy || !worker || draft && (!compiled || sourceEditor.getSource() !== compiledSource || editRevision !== compiledRevision))
     78             return;
     79         currentName = name;
     80         operation = 'running';
     81         controls(true);
     82         deadline.reset(Infinity);
     83         deadline.start();
     84         diagnostic();
     85         ioVersion = 0;
     86         stoppingIO = false;
     87         el('status').textContent = `Running IO · ${name}`;
     88         worker.postMessage({ type: 'run-io', name, output, step });
     89     },
     90     stop: () => stopButton.click(),
     91     resume() {
     92         if (!worker || !pending)
     93             return;
     94         worker.postMessage({ type: 'resume', id: pending.id });
     95         pending = undefined;
     96         deadline.start();
     97     },
     98 });
     99 const imports = importPicker({ source: sourceEditor, backend: () => selectedBackend,
    100     available: () => !busy, editable: () => !busy && !sourceEditor.dom.hidden && !sourceEditor.isReadOnly,
    101     tryLibrary: (library, definition) => { preferredExport = definition; loadBundle(library); },
    102     local: () => localModules.entries(), inputs: () => localModules.inputs() });
    103 function diagnostic(message = '') { el('operation-diagnostic').textContent = message; el('operation-diagnostic').hidden = !message; }
    104 function ready() { return !!worker && (draft ? compiled && sourceEditor.getSource() === compiledSource && editRevision === compiledRevision : loaded); }
    105 function controls(working) {
    106     busy = working;
    107     localModules.disable(busy);
    108     programPicker.disabled = busy || !entries.length;
    109     versionPicker.disabled = hashInput.disabled = busy;
    110     el('load-release').disabled = busy || !programPackage(programPicker.value);
    111     el('load').disabled = busy;
    112     el('add-import').disabled = busy;
    113     compileButton.disabled = busy || !draft || ready();
    114     compileButton.hidden = !draft;
    115     restoreButton.hidden = !draft;
    116     restoreButton.disabled = busy || originalSource === undefined;
    117     exportModuleButton.disabled = busy || !draft || !ready();
    118     downloadButton.disabled = busy || draft || downloadHash() === undefined;
    119     sourceEditor.setAvailability(busy && operation !== 'compiling', originalSource === undefined && !draft);
    120     const editable = !sourceEditor.dom.hidden && (draft || originalSource !== undefined);
    121     el('source-title').textContent = editable ? 'Source' : sourceAvailable ? 'Read-only source' : loaded ? 'Compiled package' : 'Package information';
    122     el('source-availability').hidden = editable || !loaded;
    123     el('source-availability').textContent = 'Browse stored definitions below, or import this module into your own source. Inspection does not execute IO.';
    124     el('package-definitions').hidden = !loaded || !sourceEditor.dom.hidden;
    125     el('reload-package').hidden = draft || !selection || !!worker;
    126     el('stored-download').disabled = busy || draft || !loaded || !worker;
    127     el('stored-definition').disabled = el('stored-format').disabled = busy || draft || !loaded;
    128     el('authorship').textContent = draft ? 'Local draft · your authored code (not published)' : loaded ? 'Published package · verified executable' : 'Published selection · not yet verified';
    129     const modified = draft && !ready();
    130     el('authorship').classList.toggle('modified', modified);
    131     sourceEditor.dom.classList.toggle('modified', modified);
    132     for (const button of document.querySelectorAll('[data-compiler-example]'))
    133         button.disabled = busy;
    134     stopButton.disabled = !busy && !draft;
    135     stopButton.hidden = !busy;
    136     shareButton.disabled = busy || draft || !loaded;
    137     el('reset').disabled = busy;
    138     sourceEditor.refresh();
    139     definitions.update(!sourceEditor.dom.hidden && ready() || sourceEditor.dom.hidden && loaded && !!worker, busy, draft ? undefined : publishedNames);
    140     for (const button of el('package-definition-list').querySelectorAll('button'))
    141         button.disabled = busy || !loaded;
    142 }
    143 function renderPublishedNames() {
    144     const query = el('package-definition-search').value.toLowerCase();
    145     el('package-definition-list').replaceChildren(...publishedNames.filter(name => name.toLowerCase().includes(query)).map(name => {
    146         const button = document.createElement('button');
    147         button.type = 'button';
    148         button.textContent = name;
    149         button.disabled = busy || !loaded;
    150         button.onclick = () => { currentName = name; definitions.open(name); };
    151         return button;
    152     }));
    153 }
    154 el('package-definition-search').oninput = renderPublishedNames;
    155 el('reload-package').onclick = () => { if (!busy && !draft && selection)
    156     newWorker(true); };
    157 el('stored-download').onclick = () => {
    158     if (busy || draft || !loaded || !worker)
    159         return;
    160     operation = 'exporting';
    161     controls(true);
    162     deadline.reset(120000);
    163     deadline.start();
    164     el('stored-status').textContent = 'Exporting original records…';
    165     worker.postMessage({ type: 'export', export: el('stored-definition').value, format: el('stored-format').value });
    166 };
    167 function dispose() {
    168     ioWidget?.abandon();
    169     ioWidget = undefined;
    170     ioRun = undefined;
    171     ioVersion = undefined;
    172     stoppingIO = false;
    173     cancelIdleCompile();
    174     stopCompilerActivity();
    175     deadline.pause();
    176     operation = undefined;
    177     worker?.terminate();
    178     worker = undefined;
    179     compiled = false;
    180     compiledSource = undefined;
    181     compiledRevision = undefined;
    182     pending = undefined;
    183     controls(false);
    184 }
    185 function idle() { ioVersion = undefined; stoppingIO = false; stopCompilerActivity(); deadline.pause(); operation = undefined; pending = undefined; controls(false); }
    186 function finish(message, error = false, cancelled = false) {
    187     const previous = operation;
    188     if (error)
    189         message = operationFailure(operation, message);
    190     definitions.stopped(message, cancelled || error && previous === 'running');
    191     dispose();
    192     diagnostic(message);
    193     el('status').textContent = previous === 'compiling' ? 'Compilation failed' : error ? 'Stopped' : 'Finished';
    194     if (previous === 'compiling') {
    195         el('compile-diagnostic').textContent = message;
    196         el('compile-diagnostic').hidden = false;
    197     }
    198     if (previous === 'exporting')
    199         el('compiled-module-status').textContent = message;
    200     if (previous === 'downloading')
    201         el('bundle-download-status').textContent = message;
    202     if (!loaded && !draft)
    203         el('package-info').textContent = `Package not loaded. ${message}`;
    204 }
    205 stopButton.onclick = () => {
    206     if (operation === 'running' && ioVersion !== undefined) {
    207         if (!stoppingIO) {
    208             stoppingIO = true;
    209             worker?.postMessage({ type: 'io-stop' });
    210         }
    211         stopButton.disabled = true;
    212         el('status').textContent = 'Cancelling; waiting for cleanup…';
    213         return;
    214     }
    215     imports.cancel();
    216     finish(draft ? 'Stopped. Compile the draft again to inspect or run it.' : 'Stopped. Reload the package to inspect or run it.', false, true);
    217     el('status').textContent = 'Stopped';
    218 };
    219 el('reload-playground').onclick = () => {
    220     if ((draft || localModules.entries().length > 0) && !window.confirm('Reload the Playground and restart all runtimes? Local source edits, results and opened local modules will be cleared. Copy any work you want to keep before continuing.'))
    221         return;
    222     location.reload();
    223 };
    224 el('reset').onclick = () => { if (!busy) {
    225     definitions.reset();
    226     diagnostic();
    227 } };
    228 shareButton.onclick = async () => {
    229     if (draft || !selection)
    230         return;
    231     const url = new URL(location.href), fragment = new URLSearchParams({ bundle: selection.hash, export: currentName || selected?.export || publishedNames[0] || '' });
    232     if (selection.localId)
    233         fragment.set('demo', selection.localId);
    234     url.hash = fragment.toString();
    235     try {
    236         await navigator.clipboard.writeText(url.href);
    237         el('bundle-download-status').textContent = 'Copied a link to this package and definition.';
    238     }
    239     catch {
    240         el('bundle-download-status').textContent = url.href;
    241     }
    242 };
    243 window.addEventListener('pagehide', () => dispose());
    244 function receive(data) {
    245     if (data.type === 'inspected' || data.type === 'inspection-error') {
    246         if (!definitions.reply(data))
    247             return;
    248         if (data.type === 'inspection-error' && data.fatal)
    249             finish(data.message, true);
    250         else {
    251             idle();
    252             el('status').textContent = draft ? 'Compiled' : 'Ready';
    253         }
    254         return;
    255     }
    256     definitions.event(data);
    257     switch (data.type) {
    258         case 'io-version':
    259             ioVersion = data.version;
    260             break;
    261         case 'io-open': {
    262             if (ioWidget)
    263                 throw new Error('Overlapping terminal runs');
    264             const current = worker;
    265             ioRun = data.run;
    266             ioWidget = terminalView(el('terminal-io'), data.run, reply => { if (worker === current && ioRun === reply.run)
    267                 current?.postMessage({ type: 'io-command', command: { run: reply.run, kind: 'reply', reply } }); }, () => stopButton.click());
    268             break;
    269         }
    270         case 'io-event':
    271             if (data.event.run === ioRun)
    272                 ioWidget?.handle(data.event);
    273             break;
    274         case 'io-close':
    275             if (data.run === ioRun) {
    276                 ioVersion = undefined;
    277                 if (data.settled)
    278                     ioWidget?.finish();
    279                 else
    280                     ioWidget?.abandon();
    281                 ioWidget = undefined;
    282                 ioRun = undefined;
    283                 el('status').textContent = data.settled ? 'IO settled; interpreting returned value' : 'Runtime lost';
    284             }
    285             break;
    286         case 'state':
    287             if (data.state === 'running') {
    288                 pending = undefined;
    289                 deadline.start();
    290                 if (operation === 'running' && !stoppingIO)
    291                     el('status').textContent = `Running IO · ${currentName}`;
    292             }
    293             else {
    294                 deadline.pause();
    295                 pending = { id: data.id };
    296                 el('status').textContent = 'Paused before action';
    297             }
    298             break;
    299         case 'done':
    300             idle();
    301             el('status').textContent = 'Finished';
    302             el('compiler-stats').textContent = `${compilationStats} · IO · ${runtimeStats(data.stats)}`;
    303             break;
    304         case 'error':
    305             if (operation === 'running' && !data.fatal) {
    306                 definitions.stopped(data.message);
    307                 idle();
    308                 diagnostic(data.message);
    309                 el('status').textContent = 'Stopped';
    310             }
    311             else
    312                 finish(data.message, true);
    313             break;
    314         case 'exported': {
    315             if (draft || data.hash !== selection?.hash || data.export !== el('stored-definition').value || data.format !== el('stored-format').value)
    316                 return;
    317             const url = URL.createObjectURL(new Blob([data.file.text], { type: 'text/plain;charset=utf-8' }));
    318             const anchor = document.createElement('a');
    319             anchor.href = url;
    320             anchor.download = `${data.export.replace(/[^a-zA-Z0-9_-]/g, '-').slice(0, 64)}.${data.file.extension}`;
    321             document.body.append(anchor);
    322             anchor.click();
    323             anchor.remove();
    324             setTimeout(() => URL.revokeObjectURL(url), 1000);
    325             idle();
    326             el('stored-status').textContent = `Downloaded stored definition ${data.export} · SHA-256 ${data.hash}`;
    327             el('status').textContent = 'Ready';
    328             break;
    329         }
    330         case 'export-error':
    331             idle();
    332             el('stored-status').textContent = data.message;
    333             el('status').textContent = 'Export failed';
    334             break;
    335         case 'module-exported': {
    336             idle();
    337             const url = URL.createObjectURL(new Blob([new Uint8Array(data.bytes).buffer], { type: 'application/vnd.arboricx.bundle' }));
    338             const anchor = document.createElement('a');
    339             anchor.href = url;
    340             anchor.download = `${data.hash}.arboricx`;
    341             document.body.append(anchor);
    342             anchor.click();
    343             anchor.remove();
    344             setTimeout(() => URL.revokeObjectURL(url), 1000);
    345             el('compiled-module-status').textContent = `Exported compiled module. Manifest: ${data.manifest} · Bundle SHA-256: ${data.hash}.`;
    346             el('status').textContent = 'Compiled';
    347             break;
    348         }
    349         case 'module-export-error':
    350             idle();
    351             el('compiled-module-status').textContent = data.message;
    352             el('status').textContent = 'Module export failed';
    353             break;
    354     }
    355 }
    356 function newWorker(preserveSource = false) {
    357     if (!selection)
    358         return;
    359     dispose();
    360     loaded = false;
    361     operation = 'loading';
    362     diagnostic();
    363     deadline.reset(PACKAGE_LOAD_MS);
    364     deadline.start();
    365     const current = new Worker(workerURL(), { type: 'module' });
    366     worker = current;
    367     controls(true);
    368     el('status').textContent = 'Loading package';
    369     el('package-info').textContent = 'Verifying package…';
    370     current.onmessage = ({ data }) => {
    371         if (worker !== current)
    372             return;
    373         if (data.type !== 'loaded') {
    374             receive(data);
    375             return;
    376         }
    377         loaded = true;
    378         publishedNames = data.exports.map(item => item.name);
    379         const requested = preferredExport;
    380         currentName = requested && publishedNames.includes(requested) ? requested : selected?.export ?? publishedNames[0] ?? '';
    381         preferredExport = undefined;
    382         el('stored-definition').replaceChildren(...publishedNames.map(name => new Option(name, name)));
    383         if (publishedNames.includes(currentName))
    384             el('stored-definition').value = currentName;
    385         el('compiler-stats').textContent = 'Published package · no local compilation.';
    386         el('package-info').textContent = `SHA-256 verified · ${data.nodes.toLocaleString()} stored DAG nodes · ${publishedNames.length} export(s) · ${data.hash}`;
    387         if (!preserveSource || originalSource === undefined) {
    388             originalSource = selected?.environment !== undefined && data.source !== null ? data.source : undefined;
    389             sourceEditor.replaceSource(originalSource ?? '');
    390         }
    391         sourceEditor.dom.hidden = originalSource === undefined;
    392         el('source-notes').hidden = !sourceEditor.dom.hidden;
    393         sourceAvailable = data.source !== null;
    394         el('source-label').textContent = sourceAvailable ? 'Curated tricu / stable' : 'Package metadata';
    395         el('source').textContent = data.source ?? `Verified package metadata\n${JSON.stringify(data.metadata, null, 2)}`;
    396         if (data.source !== null)
    397             paintSyntax(el('source'), tokenize(data.source));
    398         el('package-definition-search').value = sourceEditor.dom.hidden && requested && publishedNames.includes(requested) ? requested : '';
    399         renderPublishedNames();
    400         idle();
    401         el('status').textContent = 'Ready';
    402     };
    403     current.onerror = event => { if (worker === current) {
    404         event.preventDefault();
    405         loaded = false;
    406         finish(event.message || 'Worker failed.', true);
    407     } };
    408     current.postMessage({ type: 'load', ...selection });
    409 }
    410 function selectExample(entry) {
    411     const name = examplePackage(entry), latest = name ? packageReleases(releases, name)[0] : undefined;
    412     if (!latest || latest.demo === entry.id && latest.hash === entry.hash)
    413         select(entry, latest);
    414     else
    415         loadBundle(latest);
    416 }
    417 function select(entry, release) {
    418     definitions.reset();
    419     draft = scratch = false;
    420     originalSource = undefined;
    421     sourceAvailable = false;
    422     dispose();
    423     diagnostic();
    424     loaded = false;
    425     selected = entry;
    426     publishedNames = [];
    427     selection = { hash: entry.hash, localId: entry.id };
    428     hashInput.value = entry.hash;
    429     sourceEditor.dom.hidden = true;
    430     el('source-notes').hidden = false;
    431     const pinned = release ?? releases.find(r => r.hash === entry.hash && r.demo === entry.id);
    432     chooseProgram(`demo:${entry.id}`, pinned?.version);
    433     showRelease(pinned);
    434     newWorker();
    435 }
    436 function invalidateProgram() {
    437     definitions.reset();
    438     draft = scratch = false;
    439     originalSource = undefined;
    440     sourceAvailable = false;
    441     dispose();
    442     diagnostic();
    443     loaded = false;
    444     selection = undefined;
    445     selected = undefined;
    446     publishedNames = [];
    447     sourceEditor.dom.hidden = true;
    448     el('source-notes').hidden = false;
    449     el('source').textContent = 'Load the selected program to inspect its source or package notes.';
    450     el('package-info').textContent = 'No bundle loaded.';
    451     showRelease();
    452     renderPublishedNames();
    453     controls(false);
    454     el('status').textContent = 'Load package';
    455 }
    456 function loadBundle(release) {
    457     const value = release?.hash ?? hashInput.value, requested = preferredExport;
    458     invalidateProgram();
    459     preferredExport = requested;
    460     chooseProgram(release ? releaseProgram(release) : 'hash', release?.version);
    461     hashInput.value = value;
    462     try {
    463         selection = { hash: bundleHash(value) };
    464         showRelease(release);
    465         newWorker();
    466     }
    467     catch (error) {
    468         finish(String(error), true);
    469     }
    470 }
    471 el('program-hash').onsubmit = event => { event.preventDefault(); loadBundle(); };
    472 hashInput.oninput = () => { chooseProgram('hash'); invalidateProgram(); };
    473 // Associate an example with its published name only through the catalogue's exact pin.
    474 function examplePackage(entry) {
    475     return releases.find(r => r.demo === entry.id && r.hash === entry.hash)?.name;
    476 }
    477 function programPackage(value) {
    478     if (value.startsWith('package:'))
    479         return value.slice(8);
    480     const entry = value.startsWith('demo:') ? entries.find(e => e.id === value.slice(5)) : undefined;
    481     return entry ? examplePackage(entry) : undefined;
    482 }
    483 function releaseProgram(release) {
    484     const entry = entries.find(e => e.id === release.demo && examplePackage(e) === release.name);
    485     return entry ? `demo:${entry.id}` : `package:${release.name}`;
    486 }
    487 function chooseProgram(value, version) {
    488     el('bundle-download-status').textContent = '';
    489     if (value.startsWith('package:') && ![...programPicker.options].some(option => option.value === value)) {
    490         for (const group of programPicker.querySelectorAll('optgroup'))
    491             if (group.label === 'Exploring library')
    492                 group.remove();
    493         const group = document.createElement('optgroup');
    494         group.label = 'Exploring library';
    495         group.append(new Option(libraryLabel(value.slice(8)), value));
    496         programPicker.append(group);
    497     }
    498     programPicker.value = value;
    499     const name = programPackage(value);
    500     el('program-release').hidden = name === undefined;
    501     el('package-toolbar').hidden = name === undefined && value !== 'hash';
    502     el('bundle-hash-help').hidden = value !== 'hash';
    503     el('program-hash').hidden = value !== 'hash';
    504     if (name && versionPackage !== name) {
    505         versionPackage = name;
    506         versionPicker.replaceChildren();
    507         for (const release of packageReleases(releases, name)) {
    508             const option = document.createElement('option');
    509             option.value = release.version;
    510             option.textContent = release.version;
    511             versionPicker.append(option);
    512         }
    513     }
    514     if (name)
    515         versionPicker.value = version ?? packageReleases(releases, name)[0].version;
    516 }
    517 function populatePrograms() {
    518     programPicker.replaceChildren();
    519     const placeholder = document.createElement('option');
    520     placeholder.value = '';
    521     placeholder.disabled = true;
    522     placeholder.textContent = 'Choose a program';
    523     programPicker.append(placeholder);
    524     for (const category of exampleGroups) {
    525         const group = document.createElement('optgroup');
    526         group.label = category;
    527         for (const entry of entries.filter(entry => exampleGroup(entry) === category)) {
    528             const option = document.createElement('option');
    529             option.value = `demo:${entry.id}`;
    530             option.textContent = entry.title;
    531             group.append(option);
    532         }
    533         if (group.children.length)
    534             programPicker.append(group);
    535     }
    536     const packages = document.createElement('optgroup');
    537     packages.label = 'Exploring library';
    538     const currentName = selection && releases.find(r => r.hash === selection?.hash && !r.demo)?.name;
    539     const currentRelease = releases.find(r => r.name === currentName);
    540     if (currentRelease) {
    541         const option = new Option(libraryLabel(currentRelease.name), `package:${currentRelease.name}`);
    542         packages.append(option);
    543     }
    544     const hash = document.createElement('option');
    545     hash.value = 'hash';
    546     hash.textContent = 'Load by bundle hash…';
    547     if (packages.children.length)
    548         programPicker.append(packages);
    549     programPicker.append(hash);
    550     programPicker.disabled = false;
    551 }
    552 function showRelease(release) {
    553     el('module-import-help').hidden = release === undefined;
    554     el('module-import-source').textContent = release ? `!import ${release.manifest} as Library` : '';
    555     el('release-info').textContent = release ? `${release.title} · ${release.name}@${release.version} · module ${release.manifest}` : 'No named release selected. Direct bundle hashes pin the whole file.';
    556 }
    557 programPicker.onchange = () => {
    558     const value = programPicker.value;
    559     if (value.startsWith('demo:')) {
    560         const entry = entries.find(e => e.id === value.slice(5));
    561         if (entry)
    562             selectExample(entry);
    563     }
    564     else {
    565         chooseProgram(value);
    566         if (value === 'hash')
    567             hashInput.value = '';
    568         invalidateProgram();
    569         if (value.startsWith('package:'))
    570             loadSelectedRelease();
    571         else if (value === 'hash')
    572             hashInput.focus();
    573     }
    574 };
    575 versionPicker.onchange = invalidateProgram;
    576 el('load-release').onclick = loadSelectedRelease;
    577 function loadSelectedRelease() {
    578     const name = programPackage(programPicker.value);
    579     const release = releases.find(r => r.name === name && r.version === versionPicker.value);
    580     if (!release)
    581         return;
    582     const demo = entries.find(e => e.id === release.demo && e.hash === release.hash);
    583     if (demo)
    584         select(demo, release);
    585     else
    586         loadBundle(release);
    587 }
    588 function downloadHash() {
    589     const name = programPackage(programPicker.value);
    590     if (name)
    591         return releases.find(r => r.name === name && r.version === versionPicker.value)?.hash;
    592     if (programPicker.value === 'hash') {
    593         try {
    594             return bundleHash(hashInput.value);
    595         }
    596         catch {
    597             return undefined;
    598         }
    599     }
    600     return undefined;
    601 }
    602 downloadButton.onclick = () => {
    603     const hash = downloadHash();
    604     if (busy || draft || !hash)
    605         return;
    606     // Download has its own verified target, not the loaded executable selection.
    607     // It never changes source, definitions, arguments or the Run/Export target.
    608     dispose();
    609     operation = 'downloading';
    610     const current = new Worker(workerURL(), { type: 'module' });
    611     worker = current;
    612     controls(true);
    613     el('status').textContent = 'Downloading bundle';
    614     el('bundle-download-status').textContent = 'Verifying published bundle…';
    615     el('bundle-download-status').classList.remove('error');
    616     deadline.reset(PACKAGE_LOAD_MS);
    617     deadline.start();
    618     current.onmessage = ({ data }) => {
    619         if (worker !== current)
    620             return;
    621         if (data.type === 'loaded') {
    622             if (data.hash !== hash) {
    623                 finish('Bundle download target mismatch', true);
    624                 return;
    625             }
    626             current.postMessage({ type: 'download' });
    627         }
    628         else if (data.type === 'downloaded') {
    629             if (data.hash !== hash) {
    630                 finish('Bundle download target mismatch', true);
    631                 return;
    632             }
    633             const url = URL.createObjectURL(new Blob([new Uint8Array(data.bytes)], { type: 'application/vnd.arboricx.bundle' }));
    634             const anchor = document.createElement('a');
    635             anchor.href = url;
    636             anchor.download = `${hash}.arboricx`;
    637             document.body.append(anchor);
    638             anchor.click();
    639             anchor.remove();
    640             setTimeout(() => URL.revokeObjectURL(url), 1000);
    641             const message = `Downloaded original verified bundle ${hash}. No draft or applied result included.`;
    642             dispose();
    643             el('status').textContent = loaded ? 'Ready' : 'Load package';
    644             el('bundle-download-status').textContent = message;
    645         }
    646         else if (data.type === 'error')
    647             finish(data.message, true);
    648     };
    649     current.onerror = event => { if (worker === current)
    650         finish(event.message || 'Worker error', true); };
    651     current.postMessage({ type: 'load', hash });
    652 };
    653 sourceEditor.dom.addEventListener('compositionstart', () => { composing = true; cancelIdleCompile(); });
    654 sourceEditor.dom.addEventListener('compositionend', () => { composing = false; if (draft)
    655     queueCompile(); });
    656 sourceEditor.onChange(() => {
    657     if (busy && operation !== 'compiling')
    658         return;
    659     editRevision++;
    660     diagnostic();
    661     el('compile-diagnostic').hidden = true;
    662     if (operation === 'compiling') {
    663         draft = true;
    664         controls(true);
    665         queueCompile();
    666         return;
    667     }
    668     if (!worker || !compiled || busy)
    669         dispose();
    670     draft = compiled || scratch || sourceEditor.getSource() !== originalSource;
    671     controls(false);
    672     if (!draft && selection)
    673         newWorker(true);
    674     else {
    675         el('status').textContent = 'Changes pending';
    676         queueCompile();
    677     }
    678 });
    679 restoreButton.onclick = () => { if (originalSource === undefined || busy)
    680     return; dispose(); sourceEditor.replaceSource(originalSource, true); };
    681 compileButton.onclick = () => compileDraft();
    682 function compileDraft() {
    683     if (!draft || busy)
    684         return;
    685     const snapshot = { revision: editRevision, source: sourceEditor.getSource() };
    686     dispose();
    687     operation = 'compiling';
    688     diagnostic();
    689     el('compile-diagnostic').hidden = true;
    690     try {
    691         sourceText(snapshot.source);
    692         const current = new Worker(workerURL(), { type: 'module' });
    693         worker = current;
    694         controls(true);
    695         deadline.reset(Infinity);
    696         deadline.start();
    697         el('status').textContent = 'Compiling';
    698         el('compiler-stats').textContent = 'Loading pinned compiler and source dependencies…';
    699         const started = performance.now();
    700         const activity = () => { el('compile-activity').hidden = false; el('compile-activity').textContent = `Compiling trees locally · ${Math.floor((performance.now() - started) / 1000)} seconds elapsed · Stop is available`; };
    701         activity();
    702         compilerTimer = setInterval(activity, 1000);
    703         current.onmessage = ({ data }) => {
    704             if (worker !== current)
    705                 return;
    706             if ((data.type === 'compiled' || data.type === 'error') && operation === 'compiling' && snapshot.revision !== editRevision) {
    707                 dispose();
    708                 el('status').textContent = 'Changes pending';
    709                 queueCompile(Math.max(0, idleDue - performance.now()));
    710                 return;
    711             }
    712             if (data.type === 'compiled') {
    713                 compiledSource = snapshot.source;
    714                 compiledRevision = snapshot.revision;
    715                 compiled = true;
    716                 idle();
    717                 el('status').textContent = 'Compiled';
    718                 compilationStats = `${data.names.length} definition(s) · ${Math.round(data.milliseconds)} ms · ${runtimeStats(data.stats)}`;
    719                 el('compiler-stats').textContent = compilationStats;
    720             }
    721             else
    722                 receive(data);
    723         };
    724         current.onerror = event => { if (worker !== current)
    725             return; event.preventDefault(); if (snapshot.revision !== editRevision && operation === 'compiling') {
    726             dispose();
    727             queueCompile(0);
    728         }
    729         else
    730             finish(event.message || 'Compiler Worker failed.', true); };
    731         current.postMessage({ type: 'compile', source: snapshot.source, localModules: localModules.inputs(), ...(!scratch && selected?.environment !== undefined ? { environment: selected.environment } : {}) });
    732     }
    733     catch (error) {
    734         finish(String(error), true);
    735     }
    736 }
    737 exportModuleButton.onclick = () => { if (busy || !draft || !ready() || !worker)
    738     return; operation = 'exporting'; controls(true); deadline.reset(Infinity); deadline.start(); el('status').textContent = 'Exporting module'; el('compiled-module-status').textContent = 'Observing public trees; Stop is available…'; worker.postMessage({ type: 'export-module' }); };
    739 const examples = {
    740     identity: 'id = (x : x)\nanswer = id 42',
    741     contracts: 'id x =@nat? x\nanswer = id 42',
    742     pair: 'pair a b = t a b\nanswer = pair 7 "hi"',
    743     definitions: 'id = (x : x)\nanswer = id 42',
    744 };
    745 for (const button of document.querySelectorAll('[data-compiler-example]'))
    746     button.onclick = () => {
    747         const math = packageReleases(releases, 'tricu-lib-native-math')[0];
    748         const choice = button.dataset['compilerExample'];
    749         const io = packageReleases(releases, 'tricu-lib-io')[0], text = packageReleases(releases, 'tricu-lib-io-text')[0];
    750         const source = choice === 'input' && io && text ? `!import ${io.manifest} as IO
    751 !import ${text.manifest} as Text
    752 !export io
    753 io = IO.io <| IO.require IO.stdin (reader :
    754   IO.require (Text.readLine (Text.lineCursor reader) 4096) (reply : case reply of
    755     [["line" value _] _] = IO.pure value
    756     [["eof"] _] = IO.pure ""
    757     _ = IO.fail "Invalid line reply"))`
    758             : choice === 'module' && math ? `!import ${math.manifest} as N\n!export answer\nanswer = N.add 40 2` : examples[choice];
    759         if (!source)
    760             return;
    761         definitions.reset();
    762         dispose();
    763         diagnostic();
    764         chooseProgram('');
    765         scratch = draft = true;
    766         originalSource = undefined;
    767         loaded = false;
    768         selection = undefined;
    769         selected = undefined;
    770         editRevision++;
    771         sourceEditor.dom.hidden = false;
    772         el('source-notes').hidden = true;
    773         sourceEditor.replaceSource(source);
    774         controls(false);
    775         el('package-info').textContent = 'Scratch program · no published bundle selected.';
    776         showRelease();
    777         el('source-label').textContent = 'Scratch program · explicit modules or contract helpers';
    778         el('status').textContent = 'Changes pending';
    779         queueCompile();
    780     };
    781 function loadFragment() {
    782     const fragment = new URLSearchParams(location.hash.slice(1)), requested = fragment.get('bundle');
    783     if (!requested)
    784         return false;
    785     const hash = bundleHash(requested), local = entries.find(e => e.id === fragment.get('demo') && e.hash === hash);
    786     preferredExport = fragment.get('export') ?? undefined;
    787     if (local)
    788         select(local);
    789     else {
    790         hashInput.value = hash;
    791         loadBundle();
    792     }
    793     return true;
    794 }
    795 window.addEventListener('hashchange', () => { if (!entries.length)
    796     return; try {
    797     if (new URLSearchParams(location.hash.slice(1)).has('bundle'))
    798         loadFragment();
    799 }
    800 catch (error) {
    801     finish(String(error), true);
    802 } });
    803 let initialized = false;
    804 async function initializePackages() {
    805     if (initialized)
    806         return;
    807     initialized = true;
    808     operation = 'catalogue';
    809     try {
    810         if (!globalThis.Worker || !globalThis.crypto?.subtle)
    811             throw new Error('Playground needs Workers and a secure context (HTTPS or localhost).');
    812         const bytes = await fetchBytes(new URL('../catalogue.json', import.meta.url), 64_000);
    813         const input = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
    814         if (!Array.isArray(input) || input.length > 32)
    815             throw new Error('Invalid catalogue');
    816         entries = input.map((raw) => {
    817             if (!raw || typeof raw !== 'object')
    818                 throw new Error('Invalid catalogue entry');
    819             const v = raw;
    820             for (const key of ['id', 'title', 'source', 'sourceSha256', 'export'])
    821                 if (typeof v[key] !== 'string')
    822                     throw new Error('Invalid catalogue field');
    823             const hash = bundleHash(v['hash']);
    824             literals(v['arguments']);
    825             outputType(v['output']);
    826             if (v['mode'] !== 'pure' && v['mode'] !== 'io')
    827                 throw new Error('Invalid catalogue mode');
    828             if (v['category'] !== undefined && !exampleGroups.includes(v['category']))
    829                 throw new Error('Invalid example category');
    830             if (v['environment'] !== undefined && (typeof v['environment'] !== 'string' || v['environment'].length > 128))
    831                 throw new Error('Invalid demo environment');
    832             return { ...v, hash };
    833         });
    834         const indexBytes = await fetchBytes(new URL('../packages.json', import.meta.url), 1_048_576);
    835         releases = packageIndex(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(indexBytes)));
    836         populatePrograms();
    837         if (selection || draft) {
    838             operation = undefined;
    839             return;
    840         }
    841         if (!loadFragment()) {
    842             const initial = entries.find(e => e.id === 'names-values') ?? entries[0];
    843             if (!initial)
    844                 throw new Error('Empty catalogue');
    845             selectExample(initial);
    846         }
    847     }
    848     catch (error) {
    849         initialized = false;
    850         loaded = false;
    851         finish(error instanceof Error ? error.message : String(error), true);
    852     }
    853 }
    854 setupInspector();
    855 void initializePackages();