app.ts (45451B)
1 import { exampleGroups, exampleGroup, selectorLibraries, libraryLabel } from './program-catalogue.js'; 2 import { importPicker } from './import-picker.js'; 3 import { localModulesUI } from './local-modules-ui.js'; 4 import { outputReveal } from './output-reveal.js'; 5 import { syntaxEditor, tokenize, paintSyntax } from './syntax.js'; 6 import { printLiteral } from './literal-input.js'; 7 import { backend, backends, runtimeStats, type Backend } from './runtime-backends.js'; 8 import { demoInputLabels } from './demo-inputs.js'; 9 import { argumentEditor } from './arguments-ui.js'; 10 import { createOutput } from './output-app.js'; 11 import { setupInspector } from './inspector.js'; 12 import { isTreeResult } from './tree-result.js'; 13 import { treeFormat } from './tree-export.js'; 14 import { ActiveClock } from './clock.js'; 15 import { PACKAGE_LOAD_MS, EXPORT_ACTIVE_MS, operationFailure, operationTimeout, type Operation } from './execution.js'; 16 import { sourceText } from './compiler.js'; 17 import { packageIndex, packageReleases, type Release } from './packages.js'; 18 import type { CompilerMessage } from './compiler-protocol.js'; 19 import { bundleHash, fetchBytes, type BundleHash } from './bundle.js'; 20 import { Graph } from './graph.js'; 21 import { line, literals, outputType, type OutputType } from './codecs.js'; 22 import type { CatalogueEntry, ClientMessage, WorkerMessage, RequestId } from './protocol.js'; 23 function el<T extends HTMLElement = HTMLElement>(id: string): T { 24 const element = document.getElementById(id); if (!element) throw new Error(`Missing element ${id}`); return element as T; 25 } 26 const sourceEditor = el<HTMLTextAreaElement>('source-editor'), compileButton = el<HTMLButtonElement>('compile'); 27 const restoreButton = el<HTMLButtonElement>('restore-source'), downloadButton = el<HTMLButtonElement>('download-bundle'); 28 let sourceAvailable = false; 29 const expressionEditor = el<HTMLTextAreaElement>('expression-editor'); 30 const refreshSourceSyntax = syntaxEditor(sourceEditor), refreshExpressionSyntax = syntaxEditor(expressionEditor); 31 const revealOutput = outputReveal(document.querySelector<HTMLElement>('.editor-toolbar')!); 32 const guidedInputs = el<HTMLInputElement>('use-guided-inputs'); 33 let editRevision = 0, composing = false, idleDue = 0; 34 let idleTimer: ReturnType<typeof setTimeout> | undefined; 35 function cancelIdleCompile(): void { clearTimeout(idleTimer); idleTimer = undefined; } 36 function queueCompile(delay = 650): void { 37 cancelIdleCompile(); 38 if (composing) return; 39 idleDue = performance.now() + delay; 40 idleTimer = setTimeout(() => { 41 idleTimer = undefined; 42 if (!busy && draft) compileDraft(); 43 }, delay); 44 } 45 function markPreviousResult(): void { 46 revealOutput.cancel(); 47 if (el('result').textContent || el('terminal').textContent) el('result-stale').hidden = false; 48 } 49 function presetExpression(name = exportsInput.value || preferredName): void { 50 expressionEditor.value = [name, ...argumentsUI.read().map(printLiteral)].join(' '); 51 refreshExpressionSyntax(); 52 } 53 function expressionInput(): { expression?: string } { 54 return guidedInputs.checked ? {} : { expression: sourceText(expressionEditor.value) }; 55 } 56 57 const backendPicker = el<HTMLSelectElement>('runtime-backend'); 58 let selectedBackend: Backend = backend(backendPicker.value); 59 const workerURL = () => new URL(backends[selectedBackend].worker, import.meta.url); 60 let draft = false, scratch = false, compiled = false, busy = false, originalSource: string | undefined; 61 let compiledSource: string | undefined, compiledRevision: number | undefined; 62 let compilationStats = '', preferredName = 'id'; 63 let compilerTimer: ReturnType<typeof setInterval> | undefined; 64 function stopCompilerActivity(): void { clearInterval(compilerTimer); compilerTimer = undefined; el('compile-activity').hidden = true; } 65 let releases: Release[] = []; 66 const programPicker = el<HTMLSelectElement>('program-picker'); 67 const versionPicker = el<HTMLSelectElement>('package-version'); 68 let versionPackage: string | undefined; 69 const hashInput = el<HTMLInputElement>('bundle-hash'), exportsInput = el<HTMLSelectElement>('export'); 70 const argsInput = el<HTMLTextAreaElement>('arguments'), resultCodec = el<HTMLSelectElement>('output-type'); 71 const argumentsUI = argumentEditor(argsInput); 72 const mode = el<HTMLSelectElement>('mode'), steps = el<HTMLInputElement>('step-mode'); 73 const runButton = el<HTMLButtonElement>('run'), stopButton = el<HTMLButtonElement>('stop'), nextButton = el<HTMLButtonElement>('next'); 74 let operation: Operation | undefined; 75 const deadline = new ActiveClock(() => finish(operationTimeout(operation ?? 'loading'), true, 'timeout')); 76 const shareButton = el<HTMLButtonElement>('share'); 77 const outputUI = createOutput(); 78 const outputTarget = el<HTMLSelectElement>('output-target'); 79 const storedOutput = () => outputTarget.value === 'definition'; 80 const visibleCodec = (value: string): string => value === 'tree' || value === 'list' ? 'tricu' : value; 81 let resultInterpretation = 'tricu'; 82 function clearTreeExport(): void { if (storedOutput()) outputUI.clear(); } 83 function syncOutputTarget(): void { 84 for (const option of resultCodec.options) option.disabled = storedOutput() && !isTreeResult(option.value); 85 if (storedOutput() && !isTreeResult(resultCodec.value)) resultCodec.value = 'tricu'; 86 controls(busy); 87 } 88 outputTarget.onchange = () => { 89 if (busy) return; 90 revealOutput.cancel(); 91 if (storedOutput()) resultInterpretation = resultCodec.value; 92 else resultCodec.value = resultInterpretation; 93 clearOutput(); syncOutputTarget(); 94 }; 95 const inputForm = el<HTMLFormElement>('input-form'), lineInput = el<HTMLInputElement>('line-input'); 96 let entries: CatalogueEntry[] = [], selected: CatalogueEntry | undefined; 97 let selection: { hash: BundleHash; localId?: string } | undefined; 98 let loaded = false, worker: Worker | undefined, pending: { kind: 'input' | 'step'; id: RequestId } | undefined; 99 let automaticRun = false; 100 let savedRun: Extract<ClientMessage, { type: 'run' }> | undefined; 101 let preferredExport: string | undefined; 102 const traceRows = new Map<number, HTMLLIElement>(); 103 const localModules = localModulesUI(() => { 104 if (!draft) return; 105 if (busy && operation !== 'compiling') return; 106 editRevision++; markPreviousResult(); 107 if (!busy) { dispose(); el('status').textContent = 'Changes pending'; } 108 queueCompile(); 109 }); 110 const imports = importPicker({ source: sourceEditor, backend: () => backendPicker.value, 111 available: () => !busy && !sourceEditor.hidden && !sourceEditor.readOnly, 112 local: () => localModules.entries(), inputs: () => localModules.inputs() }); 113 const exportModuleButton = el<HTMLButtonElement>('export-compiled-module'); 114 exportModuleButton.onclick = () => { 115 if (busy || !compiled || !draft || sourceEditor.value !== compiledSource || editRevision !== compiledRevision || !worker) return; 116 cancelIdleCompile(); operation = 'exporting'; controls(true); deadline.reset(Infinity); clock(); 117 el('status').textContent = 'Exporting module'; 118 el('compiled-module-status').textContent = 'Observing public trees; Stop is available…'; 119 worker.postMessage({ type: 'export-module' }); 120 }; 121 function pauseClock(): void { deadline.pause(); } 122 function clock(): void { deadline.start(); } 123 function runClock(): void { operation = 'running'; deadline.reset(Infinity); clock(); } 124 function controls(working: boolean): void { 125 busy = working; 126 localModules.disable(busy); 127 exportModuleButton.disabled = busy || !draft || !compiled || sourceEditor.value !== compiledSource || editRevision !== compiledRevision; 128 backendPicker.disabled = busy; 129 programPicker.disabled = busy || entries.length === 0; 130 versionPicker.disabled = busy; hashInput.disabled = busy; 131 el<HTMLButtonElement>('load-release').disabled = busy || !programPackage(programPicker.value); 132 el<HTMLButtonElement>('load').disabled = busy; 133 runButton.disabled = busy || (storedOutput() ? draft || !loaded : draft ? !compiled || sourceEditor.value !== compiledSource || editRevision !== compiledRevision : !loaded); 134 runButton.textContent = storedOutput() ? 'Show definition' : 'Evaluate'; 135 el('draft-run-note').hidden = !draft || (compiled && sourceEditor.value === compiledSource && editRevision === compiledRevision); 136 el('draft-run-note').textContent = compiled 137 ? 'Source changed. Evaluation waits for the current revision to compile.' 138 : 'Waiting for compilation. Published programs are never used as a fallback.'; 139 argumentsUI.labels(!draft && !scratch && selected ? demoInputLabels(selected.id, exportsInput.value || preferredName) : []); 140 el('io-console').hidden = storedOutput() || mode.value !== 'io' && !el('terminal').textContent; 141 nextButton.hidden = storedOutput() || !steps.checked; 142 runButton.classList.toggle('draft', draft); 143 runButton.classList.toggle('primary', !draft || compiled); 144 compileButton.classList.remove('primary'); 145 compileButton.disabled = busy || !draft || (compiled && sourceEditor.value === compiledSource && editRevision === compiledRevision); compileButton.hidden = !draft; restoreButton.hidden = !draft; 146 sourceEditor.disabled = busy && operation !== 'compiling'; sourceEditor.readOnly = originalSource === undefined && !draft; 147 const editableSource = !sourceEditor.hidden && (draft || originalSource !== undefined); 148 el<HTMLButtonElement>('add-import').hidden = !editableSource; 149 el<HTMLButtonElement>('add-import').disabled = busy; 150 el('source-title').textContent = editableSource ? 'Source' : sourceAvailable ? 'Read-only source' : loaded ? 'Compiled package (no editable source)' : 'Package information'; 151 el('source-availability').hidden = editableSource || !loaded; 152 el('source-availability').textContent = sourceAvailable 153 ? 'Source is shown for reference only; the published implementation cannot be edited here. Evaluate expressions against its exports below.' 154 : 'This is a compiled package. Editable source is not included; you can evaluate expressions against its exports or export its stored definitions.'; 155 restoreButton.disabled = busy || originalSource === undefined || !draft; 156 downloadButton.disabled = busy || draft || downloadHash() === undefined; 157 el('authorship').textContent = draft ? 'Local draft · your authored code (not published)' : loaded ? 'Published package · verified executable' : 'Published selection · not yet verified'; 158 const sourceModified = draft && (!compiled || sourceEditor.value !== compiledSource || editRevision !== compiledRevision); 159 el('authorship').classList.toggle('modified', sourceModified); 160 sourceEditor.classList.toggle('modified', sourceModified); 161 if (draft && storedOutput()) { el('draft-run-note').hidden = false; el('draft-run-note').textContent = 'Stored definitions require a published package. Choose Evaluated result or restore the published source.'; } 162 for (const button of document.querySelectorAll<HTMLButtonElement>('[data-compiler-example]')) button.disabled = busy; 163 stopButton.disabled = !busy && !draft; stopButton.hidden = !busy; 164 el<HTMLButtonElement>('stop-run').disabled = stopButton.disabled; 165 exportsInput.disabled = busy || (draft ? !compiled : !loaded); argsInput.disabled = busy; argumentsUI.disable(busy); 166 expressionEditor.disabled = busy && operation !== 'compiling'; guidedInputs.disabled = busy; 167 el('expression-inputs').hidden = storedOutput() || guidedInputs.checked; el('guided-inputs').hidden = storedOutput() || !guidedInputs.checked; 168 mode.disabled = busy || storedOutput(); outputTarget.disabled = busy; resultCodec.disabled = busy; steps.disabled = busy; 169 shareButton.disabled = busy || draft || !loaded; el<HTMLButtonElement>('reset').disabled = busy; 170 outputUI.controls(busy); 171 refreshSourceSyntax(); refreshExpressionSyntax(); 172 } 173 function dispose(): void { 174 cancelIdleCompile(); stopCompilerActivity(); pauseClock(); operation = undefined; worker?.terminate(); worker = undefined; 175 compiledSource = undefined; compiledRevision = undefined; compiled = false; pending = undefined; 176 inputForm.hidden = true; nextButton.disabled = true; controls(false); 177 } 178 function clearOutput(): void { 179 el('result-stale').hidden = true; el('compile-diagnostic').hidden = true; 180 el('terminal').textContent = ''; el('result').textContent = ''; el('result').className = ''; 181 outputUI.clear(); 182 el('trace').replaceChildren(); traceRows.clear(); el('trace-empty').hidden = false; lineInput.value = ''; 183 } 184 function finish(message: string, error = false, reason: 'failure' | 'timeout' | 'cancelled' = 'failure'): void { 185 if (error && reason === 'failure') message = operationFailure(operation, message); 186 if (operation === 'exporting' && draft) el('compiled-module-status').textContent = message; 187 if (operation === 'compiling' && reason === 'failure') { 188 dispose(); el('status').textContent = 'Compilation failed'; 189 el('compiler-stats').textContent = 'Compilation failed; runtime discarded.'; 190 el('compile-diagnostic').textContent = message; el('compile-diagnostic').hidden = false; 191 return; 192 } 193 if (!draft && !loaded) el('package-info').textContent = `Package not loaded. ${message}`; 194 if (error && operation !== 'downloading') outputUI.clear(); 195 if (operation === 'downloading') { el('bundle-download-status').textContent = message; el('bundle-download-status').classList.toggle('error', error); } 196 const downloadOnly = operation === 'downloading'; 197 dispose(); el('status').textContent = error ? 'Stopped' : 'Finished'; 198 if (!downloadOnly) { el('result').textContent = message; el('result').className = error ? 'error' : ''; revealOutput.reveal(el('result')); } 199 for (const row of traceRows.values()) row.classList.remove('pending'); 200 } 201 function setPreset(entry: CatalogueEntry, name = entry.export): void { 202 const preset = entry.presets?.[name] ?? entry; 203 argumentsUI.labels(demoInputLabels(entry.id, name)); 204 argsInput.value = JSON.stringify(preset.arguments, null, 2); argumentsUI.refresh(true); resultCodec.value = visibleCodec(preset.output); mode.value = preset.mode; presetExpression(name); 205 } 206 function startExport(message: Extract<ClientMessage, { type: 'export' }>, send: (message: ClientMessage) => void): void { 207 operation = 'exporting'; clearOutput(); controls(true); deadline.reset(EXPORT_ACTIVE_MS); clock(); 208 el('status').textContent = 'Exporting definition'; send(message); 209 } 210 function newWorker(runAfterLoad: boolean, preserveInputs = false, exportAfterLoad?: Extract<ClientMessage, { type: 'export' }>): void { 211 if (!selection) return; 212 dispose(); operation = 'loading'; deadline.reset(PACKAGE_LOAD_MS); automaticRun = runAfterLoad; 213 const current = new Worker(workerURL(), { type: 'module' }); worker = current; 214 let currentVerified = false; 215 controls(true); el('status').textContent = 'Loading package'; el('package-info').textContent = 'Verifying package…'; clock(); 216 const send = (message: ClientMessage): void => current.postMessage(message); 217 current.onmessage = ({ data }: MessageEvent<WorkerMessage>): void => { 218 if (worker !== current) return; 219 switch (data.type) { 220 case 'state': 221 if (data.state === 'running') { pending = undefined; clock(); el('status').textContent = operation === 'loading' ? 'Loading package' : 'Running'; } 222 else { 223 pauseClock(); pending = { kind: data.state, id: data.id }; 224 el('status').textContent = data.state === 'input' ? 'Waiting for input' : 'Paused before action'; 225 inputForm.hidden = data.state !== 'input'; nextButton.disabled = data.state !== 'step'; 226 revealOutput.reveal(data.state === 'input' ? inputForm : nextButton); 227 } 228 break; 229 case 'loaded': { 230 currentVerified = true; loaded = true; 231 el('compiler-stats').textContent = 'Published package · no local compilation.'; 232 const oldExport = exportAfterLoad?.export ?? (automaticRun ? savedRun?.export : preferredExport ?? selected?.export); 233 exportsInput.replaceChildren(); 234 for (const item of data.exports) { 235 const option = document.createElement('option'); option.value = item.name; option.textContent = item.name; exportsInput.append(option); 236 } 237 if (oldExport && data.exports.some(e => e.name === oldExport)) exportsInput.value = oldExport; 238 preferredExport = undefined; 239 el('package-info').textContent = `SHA-256 verified · ${data.nodes.toLocaleString()} stored DAG nodes · ${data.exports.length} export(s) · ${data.hash}`; 240 if (!preserveInputs && !automaticRun && !exportAfterLoad) { 241 originalSource = selected?.environment !== undefined && data.source !== null ? data.source : undefined; 242 sourceEditor.value = originalSource ?? ''; sourceEditor.hidden = originalSource === undefined; 243 el('source-notes').hidden = originalSource !== undefined; 244 } 245 if (!preserveInputs && !automaticRun && !exportAfterLoad) presetExpression(); 246 if (storedOutput() && !isTreeResult(resultCodec.value)) resultCodec.value = 'tricu'; 247 sourceAvailable = data.source !== null; 248 el('source-label').textContent = data.source === null ? 'Package metadata' : 'Curated tricu / stable'; 249 el('source').textContent = data.source ?? `Verified package metadata\n${JSON.stringify(data.metadata, null, 2)}\n\nChoose an export, its arguments and a result interpretation to apply this tree.`; 250 if (data.source !== null) paintSyntax(el('source'), tokenize(data.source)); 251 if (exportAfterLoad) { pauseClock(); startExport(exportAfterLoad, send); } 252 else if (automaticRun && savedRun) { pauseClock(); runClock(); automaticRun = false; send(savedRun); } 253 else { if (!preserveInputs && selected?.presets?.[exportsInput.value]) setPreset(selected, exportsInput.value); pauseClock(); operation = undefined; controls(false); el('status').textContent = 'Ready'; } 254 break; 255 } 256 case 'exported': { 257 pauseClock(); 258 if (data.hash !== selection?.hash || data.export !== exportsInput.value || data.format !== resultCodec.value || !storedOutput()) break; 259 outputUI.file(data.file, data.export, `Stored definition ${data.export} · SHA-256 ${data.hash} · ${data.format}`); 260 revealOutput.reveal(el('result')); 261 operation = undefined; controls(false); el('status').textContent = 'Ready'; 262 break; 263 } 264 case 'export-error': finish(data.message, true); break; 265 case 'trace': { 266 el('trace-empty').hidden = true; const event = data.event; 267 if (data.phase === 'before') { 268 const row = document.createElement('li'); row.className = 'pending'; 269 row.textContent = `${String(event.step).padStart(2, '0')} ${event.name} ${JSON.stringify(data.event.detail)} [stack ${data.event.depth}]`; 270 traceRows.set(event.step, row); el('trace').append(row); 271 } else { 272 const row = traceRows.get(event.step); 273 if (row) { row.textContent += `\n → ${JSON.stringify(data.event.value)}`; row.classList.remove('pending'); } 274 } 275 el('trace').scrollTop = el('trace').scrollHeight; break; 276 } 277 case 'output': el('terminal').textContent += data.text; revealOutput.reveal(el('io-console')); el('terminal').scrollTop = el('terminal').scrollHeight; break; 278 case 'done': { 279 idleDraft(); 280 el('status').textContent = 'Finished'; 281 outputUI.result(data.result, `Evaluated result · ${backends[selectedBackend].label}`); 282 revealOutput.reveal(el('result')); 283 el('compiler-stats').textContent = `Evaluation · ${runtimeStats(data.stats)}`; 284 break; 285 } 286 case 'error': if (!currentVerified) loaded = false; finish(data.message, true); break; 287 } 288 }; 289 current.onerror = event => { if (worker === current) { event.preventDefault(); if (!currentVerified) loaded = false; finish(event.message || 'Worker failed.', true); } }; 290 send({ type: 'load', ...selection }); 291 } 292 function selectExample(entry: CatalogueEntry): void { 293 const name = examplePackage(entry); 294 const latest = name ? packageReleases(releases, name)[0] : undefined; 295 if (!latest || (latest.demo === entry.id && latest.hash === entry.hash)) select(entry, latest); 296 else loadBundle(latest); 297 } 298 function select(entry: CatalogueEntry, release?: Release): void { 299 sourceAvailable = false; 300 draft = false; scratch = false; originalSource = undefined; 301 revealOutput.cancel(); dispose(); clearOutput(); clearTreeExport(); selected = entry; loaded = false; 302 selection = { hash: entry.hash, localId: entry.id }; hashInput.value = entry.hash; 303 sourceEditor.hidden = true; el('source-notes').hidden = false; 304 const pinned = release ?? releases.find(r => r.hash === entry.hash && r.demo === entry.id); 305 chooseProgram(`demo:${entry.id}`, pinned?.version); 306 showRelease(pinned); 307 setPreset(entry); el('source').textContent = 'Verifying package…'; 308 newWorker(false); 309 } 310 function invalidateProgram(): void { 311 el('bundle-download-status').textContent = ''; 312 sourceAvailable = false; 313 draft = false; scratch = false; originalSource = undefined; 314 revealOutput.cancel(); dispose(); clearOutput(); clearTreeExport(); loaded = false; selection = undefined; selected = undefined; preferredExport = undefined; 315 exportsInput.replaceChildren(); 316 sourceEditor.hidden = true; el('source-notes').hidden = false; 317 el('source').textContent = 'Load the selected program to inspect its source or package notes.'; 318 el('package-info').textContent = 'No bundle loaded.'; showRelease(); 319 controls(false); el('status').textContent = 'Load package'; 320 } 321 function loadBundle(release?: Release): void { 322 const value = release?.hash ?? hashInput.value; 323 invalidateProgram(); 324 chooseProgram(release ? releaseProgram(release) : 'hash', release?.version); 325 hashInput.value = value; 326 try { 327 const hash = bundleHash(value); selection = { hash }; 328 argsInput.value = '[]'; argumentsUI.refresh(true); resultCodec.value = 'tricu'; mode.value = 'pure'; 329 showRelease(release); el('source').textContent = 'Verifying registry package…'; newWorker(false); 330 } catch (error) { operation = 'loading'; finish(error instanceof Error ? error.message : String(error), true); } 331 } 332 el<HTMLFormElement>('program-hash').onsubmit = event => { event.preventDefault(); loadBundle(); }; 333 hashInput.oninput = () => { chooseProgram('hash'); invalidateProgram(); }; 334 exportsInput.onchange = () => { markPreviousResult(); preferredName = exportsInput.value; clearTreeExport(); if (storedOutput()) return; if (!scratch && selected?.presets?.[exportsInput.value]) setPreset(selected, exportsInput.value); else presetExpression(); }; 335 runButton.onclick = () => { 336 if (busy || runButton.disabled) return; 337 revealOutput.arm(); 338 if (storedOutput()) { 339 if (draft || !loaded || !selection) return; 340 const request: Extract<ClientMessage, { type: 'export' }> = { type: 'export', export: exportsInput.value, format: treeFormat(resultCodec.value) }; 341 if (worker) startExport(request, value => worker?.postMessage(value)); 342 else newWorker(false, true, request); 343 return; 344 } 345 if (draft) { runDraft(); return; } 346 if (!loaded || !selection) return; 347 operation = 'running'; clearOutput(); 348 try { 349 const args = guidedInputs.checked ? argumentsUI.read() : [], output: OutputType = outputType(resultCodec.value); 350 savedRun = { type: 'run', ...expressionInput(), export: exportsInput.value, arguments: args, output, mode: mode.value === 'io' ? 'io' : 'pure', step: steps.checked }; 351 if (worker) { controls(true); runClock(); el('status').textContent = 'Running'; worker.postMessage(savedRun); } 352 else newWorker(true); 353 } catch (error) { finish(error instanceof Error ? error.message : String(error), true); } 354 }; 355 function resume(value?: string): void { 356 if (!worker || !pending) return; 357 const message: ClientMessage = value === undefined ? { type: 'resume', id: pending.id } : { type: 'resume', id: pending.id, value }; 358 pending = undefined; inputForm.hidden = true; nextButton.disabled = true; el('status').textContent = 'Running'; clock(); worker.postMessage(message); 359 } 360 inputForm.onsubmit = event => { 361 event.preventDefault(); if (pending?.kind !== 'input') return; 362 try { line(new Graph({ nodes: 16_000, fuel: 100_000 }), lineInput.value); el('result').textContent = ''; el('result').className = ''; resume(lineInput.value); } 363 catch (error) { el('result').textContent = error instanceof Error ? error.message : String(error); el('result').className = 'error'; } 364 }; 365 nextButton.onclick = () => { if (pending?.kind === 'step') resume(); }; 366 stopButton.onclick = () => { 367 imports.cancel(); revealOutput.cancel(); finish(draft ? 'Stopped. Runtime session discarded; compile the draft again to run it.' : 'Stopped. Session discarded.', true, 'cancelled'); }; 368 el<HTMLButtonElement>('stop-run').onclick = () => stopButton.click(); 369 el<HTMLButtonElement>('reset').onclick = () => { if (busy) return; revealOutput.cancel(); clearOutput(); el('status').textContent = draft ? compiled ? 'Compiled' : 'Ready to compile' : loaded ? 'Ready' : 'Load package'; }; 370 shareButton.onclick = async () => { 371 if (draft || !selection) return; 372 const url = new URL(location.href), fragment = new URLSearchParams({ bundle: selection.hash, export: exportsInput.value }); 373 if (selection.localId) fragment.set('demo', selection.localId); 374 url.hash = fragment.toString(); 375 try { await navigator.clipboard.writeText(url.href); el('bundle-download-status').textContent = 'Copied a link to this package and export.'; } 376 catch { el('bundle-download-status').textContent = url.href; } 377 }; 378 window.addEventListener('pagehide', () => dispose()); 379 // Associate an example with its published name only through the catalogue's exact pin. 380 function examplePackage(entry: CatalogueEntry): string | undefined { 381 return releases.find(r => r.demo === entry.id && r.hash === entry.hash)?.name; 382 } 383 function programPackage(value: string): string | undefined { 384 if (value.startsWith('package:')) return value.slice(8); 385 const entry = value.startsWith('demo:') ? entries.find(e => e.id === value.slice(5)) : undefined; 386 return entry ? examplePackage(entry) : undefined; 387 } 388 function releaseProgram(release: Release): string { 389 const entry = entries.find(e => e.id === release.demo && examplePackage(e) === release.name); 390 return entry ? `demo:${entry.id}` : `package:${release.name}`; 391 } 392 function chooseProgram(value: string, version?: string): void { 393 el('bundle-download-status').textContent = ''; 394 programPicker.value = value; 395 const name = programPackage(value); 396 el('program-release').hidden = name === undefined; 397 el('package-toolbar').hidden = name === undefined && value !== 'hash'; 398 el('bundle-hash-help').hidden = value !== 'hash'; 399 el('program-hash').hidden = value !== 'hash'; 400 if (name && versionPackage !== name) { 401 versionPackage = name; 402 versionPicker.replaceChildren(); 403 for (const release of packageReleases(releases, name)) { 404 const option = document.createElement('option'); option.value = release.version; option.textContent = release.version; versionPicker.append(option); 405 } 406 } 407 if (name) versionPicker.value = version ?? packageReleases(releases, name)[0]!.version; 408 } 409 function populatePrograms(): void { 410 programPicker.replaceChildren(); 411 const placeholder = document.createElement('option'); placeholder.value = ''; placeholder.disabled = true; placeholder.textContent = 'Choose a program'; programPicker.append(placeholder); 412 for (const category of exampleGroups) { 413 const group = document.createElement('optgroup'); group.label = category; 414 for (const entry of entries.filter(entry => exampleGroup(entry) === category)) { 415 const option = document.createElement('option'); option.value = `demo:${entry.id}`; option.textContent = entry.title; group.append(option); 416 } 417 if (group.children.length) programPicker.append(group); 418 } 419 const packages = document.createElement('optgroup'); packages.label = 'Libraries'; 420 for (const release of selectorLibraries(releases)) { 421 const option = document.createElement('option'); option.value = `package:${release.name}`; 422 option.textContent = libraryLabel(release.name); option.title = release.title; packages.append(option); 423 } 424 const hash = document.createElement('option'); hash.value = 'hash'; hash.textContent = 'Load by bundle hash…'; 425 programPicker.append(packages, hash); programPicker.disabled = false; 426 } 427 function showRelease(release?: Release): void { 428 el('module-import-help').hidden = release === undefined; 429 el('module-import-source').textContent = release ? `!import ${release.manifest} as Library` : ''; 430 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.'; 431 } 432 programPicker.onchange = () => { 433 const value = programPicker.value; 434 if (value.startsWith('demo:')) { 435 const entry = entries.find(e => e.id === value.slice(5)); if (entry) selectExample(entry); 436 } else { 437 chooseProgram(value); if (value === 'hash') hashInput.value = ''; invalidateProgram(); 438 if (value.startsWith('package:')) loadSelectedRelease(); 439 else if (value === 'hash') hashInput.focus(); 440 } 441 }; 442 versionPicker.onchange = invalidateProgram; 443 el<HTMLButtonElement>('load-release').onclick = loadSelectedRelease; 444 function loadSelectedRelease(): void { 445 const name = programPackage(programPicker.value); 446 const release = releases.find(r => r.name === name && r.version === versionPicker.value); if (!release) return; 447 const demo = entries.find(e => e.id === release.demo && e.hash === release.hash); 448 if (demo) select(demo, release); 449 else loadBundle(release); 450 } 451 function downloadHash(): BundleHash | undefined { 452 const name = programPackage(programPicker.value); 453 if (name) return releases.find(r => r.name === name && r.version === versionPicker.value)?.hash; 454 if (programPicker.value === 'hash') { 455 try { return bundleHash(hashInput.value); } catch { return undefined; } 456 } 457 return undefined; 458 } 459 downloadButton.onclick = () => { 460 const hash = downloadHash(); if (busy || draft || !hash) return; 461 // Download has its own verified target, not the loaded executable selection. 462 // It never changes source, definitions, arguments or the Run/Export target. 463 dispose(); operation = 'downloading'; 464 const current = new Worker(workerURL(), { type: 'module' }); worker = current; 465 controls(true); el('status').textContent = 'Downloading bundle'; 466 el('bundle-download-status').textContent = 'Verifying published bundle…'; el('bundle-download-status').classList.remove('error'); 467 deadline.reset(PACKAGE_LOAD_MS); clock(); 468 current.onmessage = ({ data }: MessageEvent<WorkerMessage>): void => { 469 if (worker !== current) return; 470 if (data.type === 'loaded') { 471 if (data.hash !== hash) { finish('Bundle download target mismatch', true); return; } 472 current.postMessage({ type: 'download' }); 473 } else if (data.type === 'downloaded') { 474 if (data.hash !== hash) { finish('Bundle download target mismatch', true); return; } 475 const url = URL.createObjectURL(new Blob([new Uint8Array(data.bytes)], { type: 'application/vnd.arboricx.bundle' })); 476 const anchor = document.createElement('a'); anchor.href = url; anchor.download = `${hash}.arboricx`; 477 document.body.append(anchor); anchor.click(); anchor.remove(); setTimeout(() => URL.revokeObjectURL(url), 1000); 478 const message = `Downloaded original verified bundle ${hash}. No draft or applied result included.`; 479 dispose(); el('status').textContent = loaded ? 'Ready' : 'Load package'; 480 el('bundle-download-status').textContent = message; 481 } else if (data.type === 'error') finish(data.message, true); 482 }; 483 current.onerror = event => { if (worker === current) finish(event.message || 'Worker error', true); }; 484 current.postMessage({ type: 'load', hash }); 485 }; 486 resultCodec.onchange = markPreviousResult; 487 argsInput.addEventListener('input', markPreviousResult); 488 el('argument-fields').addEventListener('input', markPreviousResult); 489 mode.onchange = () => { markPreviousResult(); el('io-console').hidden = storedOutput() || mode.value !== 'io' && !el('terminal').textContent; }; 490 expressionEditor.oninput = markPreviousResult; 491 guidedInputs.onchange = () => { 492 if (!guidedInputs.checked) { 493 try { presetExpression(); } 494 catch (error) { guidedInputs.checked = true; outputUI.clear(); el('result').textContent = String(error); } 495 } 496 markPreviousResult(); controls(busy); 497 }; 498 sourceEditor.addEventListener('compositionstart', () => { composing = true; cancelIdleCompile(); }); 499 sourceEditor.addEventListener('compositionend', () => { composing = false; if (draft) queueCompile(); }); 500 sourceEditor.oninput = () => { 501 if (busy && operation !== 'compiling') return; 502 editRevision++; markPreviousResult(); el('compile-diagnostic').hidden = true; 503 if (operation === 'compiling') { draft = true; controls(true); queueCompile(); return; } 504 preferredName = exportsInput.value || preferredName; 505 keepDraft(); clearTreeExport(); 506 if (!compiled) exportsInput.replaceChildren(); 507 draft = compiled || scratch || sourceEditor.value !== originalSource; 508 controls(false); 509 if (!draft && selection) { preferredExport = preferredName; newWorker(false, true); } 510 else { el('status').textContent = 'Changes pending'; queueCompile(); } 511 }; 512 restoreButton.onclick = () => { 513 if (originalSource === undefined) return; 514 dispose(); sourceEditor.value = originalSource; sourceEditor.dispatchEvent(new Event('input')); controls(busy); 515 }; 516 compileButton.onclick = () => compileDraft(); 517 function compileDraft(): void { 518 if (!draft || busy) return; 519 const snapshot = { revision: editRevision, source: sourceEditor.value }; 520 dispose(); operation = 'compiling'; markPreviousResult(); clearTreeExport(); 521 el('compile-diagnostic').hidden = true; 522 try { 523 sourceText(sourceEditor.value); 524 const current = draftWorker(snapshot); 525 controls(true); deadline.reset(Infinity); clock(); el('status').textContent = 'Compiling'; 526 el('compiler-stats').textContent = 'Loading pinned compiler and source dependencies…'; 527 const started = performance.now(); 528 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`; }; 529 activity(); compilerTimer = setInterval(activity, 1000); 530 current.postMessage({ type: 'compile', source: snapshot.source, localModules: localModules.inputs(), 531 ...(!scratch && selected?.environment !== undefined ? { environment: selected.environment } : {}) }); 532 } catch (error) { finish(error instanceof Error ? error.message : String(error), true); } 533 } 534 function draftWorker(snapshot: { revision: number; source: string }): Worker { 535 const current = new Worker(workerURL(), { type: 'module' }); worker = current; 536 const discardStale = (): boolean => { 537 if (operation !== 'compiling' || snapshot.revision === editRevision) return false; 538 dispose(); el('status').textContent = 'Changes pending'; queueCompile(Math.max(0, idleDue - performance.now())); return true; 539 }; 540 current.onmessage = ({ data }: MessageEvent<CompilerMessage>): void => { 541 if (worker !== current) return; 542 if ((data.type === 'compiled' || data.type === 'error') && discardStale()) return; 543 switch (data.type) { 544 case 'module-exported': { 545 idleDraft(); 546 const url = URL.createObjectURL(new Blob([new Uint8Array(data.bytes).buffer], { type: 'application/octet-stream' })); 547 const anchor = document.createElement('a'); anchor.href = url; anchor.download = `${data.hash}.arboricx`; 548 anchor.click(); URL.revokeObjectURL(url); 549 el('compiled-module-status').textContent = `Exported compiled module. Manifest: ${data.manifest} · Bundle SHA-256: ${data.hash}. Open the downloaded file under Local modules to import it.`; 550 el('status').textContent = 'Compiled'; break; 551 } 552 case 'module-export-error': 553 idleDraft(); el('compiled-module-status').textContent = data.message; el('status').textContent = 'Module export failed'; break; 554 case 'compiled': 555 stopCompilerActivity(); pauseClock(); operation = undefined; compiledSource = snapshot.source; compiledRevision = snapshot.revision; compiled = true; 556 exportsInput.replaceChildren(); 557 for (const name of data.names) { const option = document.createElement('option'); option.value = name; option.textContent = name; exportsInput.append(option); } 558 if (data.names.includes(preferredName)) exportsInput.value = preferredName; 559 idleDraft(); el('status').textContent = 'Compiled'; 560 compilationStats = `${data.names.length} definition(s) · ${Math.round(data.milliseconds)} ms · ${runtimeStats(data.stats)}`; 561 el('compiler-stats').textContent = compilationStats; 562 break; 563 case 'state': 564 if (data.state === 'running') { clock(); pending = undefined; el('status').textContent = operation === 'compiling' ? 'Compiling' : operation === 'exporting' ? 'Exporting module' : 'Running'; } 565 else { pauseClock(); pending = { id: data.id, kind: data.state }; inputForm.hidden = data.state !== 'input'; nextButton.disabled = data.state !== 'step'; el('status').textContent = data.state === 'input' ? 'Waiting for input' : 'Paused before action'; revealOutput.reveal(data.state === 'input' ? inputForm : nextButton); } 566 break; 567 case 'trace': { 568 const row = document.createElement('li'); row.textContent = data.phase === 'before' ? `${data.event.step} ${data.event.name}: ${data.event.detail}` : `→ ${data.event.value}`; el('trace').append(row); el('trace-empty').hidden = true; break; 569 } 570 case 'output': el('terminal').textContent += data.text; revealOutput.reveal(el('io-console')); break; 571 case 'done': 572 idleDraft(); el('status').textContent = 'Finished'; 573 outputUI.result(data.result, `Evaluated local draft · ${backends[selectedBackend].label}`); 574 revealOutput.reveal(el('result')); 575 el('compiler-stats').textContent = `${compilationStats} · ${runtimeStats(data.stats)}`; 576 break; 577 case 'error': 578 if (operation === 'running' && !data.fatal) { 579 idleDraft(); outputUI.clear(); el('status').textContent = 'Stopped'; 580 el('result').textContent = operationFailure('running', data.message); el('result').className = 'error'; revealOutput.reveal(el('result')); 581 } else finish(data.message, true); 582 break; 583 } 584 }; 585 current.onerror = event => { if (worker === current) { event.preventDefault(); if (!discardStale()) finish(event.message || 'Compiler Worker failed.', true); } }; 586 return current; 587 } 588 // Definitions exist only in the owning live Worker; stopping discards them. 589 function idleDraft(): void { 590 stopCompilerActivity(); pauseClock(); operation = undefined; pending = undefined; 591 inputForm.hidden = true; nextButton.disabled = true; controls(false); 592 } 593 function keepDraft(): void { 594 if (worker && compiled && !busy) idleDraft(); 595 else dispose(); 596 } 597 function runDraft(): void { 598 if (!compiled || busy || sourceEditor.value !== compiledSource || editRevision !== compiledRevision) return; 599 try { 600 const args = guidedInputs.checked ? argumentsUI.read() : [], output = outputType(resultCodec.value); 601 const expression = expressionInput(); 602 if (!worker || busy) return; 603 operation = 'running'; clearOutput(); controls(true); runClock(); el('status').textContent = 'Running'; 604 const current = worker; 605 current.postMessage({ type: 'run', ...expression, export: exportsInput.value, arguments: args, output, mode: mode.value, step: steps.checked }); 606 } catch (error) { 607 outputUI.clear(); el('status').textContent = 'Stopped'; 608 el('result').textContent = operationFailure('running', error instanceof Error ? error.message : String(error)); el('result').className = 'error'; revealOutput.reveal(el('result')); 609 keepDraft(); 610 } 611 } 612 backendPicker.onchange = () => { 613 selectedBackend = backend(backendPicker.value); editRevision++; dispose(); markPreviousResult(); 614 if (draft) { controls(false); el('status').textContent = 'Changes pending'; queueCompile(0); } 615 else if (selection) newWorker(false, true); 616 }; 617 const examples = { 618 identity: { name: 'id', source: 'id = (x : x)', args: [{ type: 'number', value: '42' }], output: 'number', mode: 'pure' }, 619 contracts: { name: 'id', source: 'id x =@nat? x', args: [{ type: 'number', value: '42' }], output: 'number', mode: 'pure' }, 620 pair: { name: 'pair', source: 'pair a b = t a b', args: [{ type: 'number', value: '7' }, { type: 'string', value: 'hi' }], output: 'tree', mode: 'pure' }, 621 definitions: { name: 'answer', source: 'id = (x : x)\nanswer = id 42', args: [], output: 'number', mode: 'pure' }, 622 input: { name: 'io', source: 'io=t "tricuIO" (t 1 (t 11 t))', args: [], output: 'string', mode: 'io' }, 623 }; 624 for (const button of document.querySelectorAll<HTMLButtonElement>('[data-compiler-example]')) button.onclick = () => { 625 const math = packageReleases(releases, 'tricu-lib-native-math')[0]; 626 const example = button.dataset['compilerExample'] === 'module' && math 627 ? { name: 'answer', source: `!import ${math.manifest} as N\n!export answer\n\nanswer = N.add 40 2`, args: [], output: 'number', mode: 'pure' } 628 : examples[button.dataset['compilerExample'] as keyof typeof examples]; 629 if (!example) return; 630 chooseProgram(''); 631 revealOutput.cancel(); keepDraft(); clearOutput(); clearTreeExport(); scratch = true; draft = true; originalSource = undefined; 632 if (!compiled) exportsInput.replaceChildren(); 633 editRevision++; preferredName = example.name; 634 sourceEditor.hidden = false; el('source-notes').hidden = true; sourceEditor.value = example.source; 635 argsInput.value = JSON.stringify(example.args, null, 2); argumentsUI.refresh(true); resultCodec.value = visibleCodec(example.output); mode.value = example.mode; presetExpression(example.name); 636 controls(false); el('package-info').textContent = 'Scratch program · no published bundle selected.'; showRelease(); el('source-label').textContent = 'Scratch program · explicit modules or contract helpers'; 637 el('status').textContent = 'Changes pending'; queueCompile(); 638 }; 639 function loadFragment(): boolean { 640 const fragment = new URLSearchParams(location.hash.slice(1)); 641 const requested = fragment.get('bundle'); 642 if (!requested) return false; 643 const hash = bundleHash(requested), local = entries.find(e => e.id === fragment.get('demo') && e.hash === hash); 644 preferredExport = fragment.get('export') ?? undefined; 645 if (local) select(local); 646 else { 647 chooseProgram('hash'); 648 showRelease(); draft = false; scratch = false; originalSource = undefined; sourceAvailable = false; 649 revealOutput.cancel(); dispose(); clearOutput(); clearTreeExport(); loaded = false; selected = undefined; 650 argsInput.value = '[]'; argumentsUI.refresh(true); resultCodec.value = 'tricu'; mode.value = 'pure'; 651 selection = { hash }; hashInput.value = hash; newWorker(false); 652 } 653 return true; 654 } 655 window.addEventListener('hashchange', () => { 656 if (!entries.length) return; 657 try { if (new URLSearchParams(location.hash.slice(1)).has('bundle')) loadFragment(); } 658 catch (error) { loaded = false; finish(error instanceof Error ? error.message : String(error), true); } 659 }); 660 let initialized = false; 661 async function initializePackages(): Promise<void> { 662 if (initialized) return; 663 initialized = true; operation = 'catalogue'; 664 try { 665 if (!globalThis.Worker || !globalThis.crypto?.subtle) throw new Error('Playground needs Workers and a secure context (HTTPS or localhost).'); 666 const bytes = await fetchBytes(new URL('../catalogue.json', import.meta.url), 64_000); 667 const input: unknown = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)); 668 if (!Array.isArray(input) || input.length > 32) throw new Error('Invalid catalogue'); 669 entries = input.map((raw: unknown): CatalogueEntry => { 670 if (!raw || typeof raw !== 'object') throw new Error('Invalid catalogue entry'); 671 const v = raw as Record<string, unknown>; 672 for (const key of ['id', 'title', 'source', 'sourceSha256', 'export']) if (typeof v[key] !== 'string') throw new Error('Invalid catalogue field'); 673 const hash = bundleHash(v['hash']); literals(v['arguments']); outputType(v['output']); 674 if (v['mode'] !== 'pure' && v['mode'] !== 'io') throw new Error('Invalid catalogue mode'); 675 if (v['category'] !== undefined && !exampleGroups.includes(v['category'] as typeof exampleGroups[number])) throw new Error('Invalid example category'); 676 if (v['environment'] !== undefined && (typeof v['environment'] !== 'string' || v['environment'].length > 128)) throw new Error('Invalid demo environment'); 677 return { ...v, hash } as unknown as CatalogueEntry; 678 }); 679 const indexBytes = await fetchBytes(new URL('../packages.json', import.meta.url), 1_048_576); 680 releases = packageIndex(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(indexBytes)) as unknown); 681 populatePrograms(); 682 if (selection || draft) { operation = undefined; return; } 683 if (!loadFragment()) { const initial = entries.find(e => e.id === 'names-values') ?? entries[0]; if (!initial) throw new Error('Empty catalogue'); selectExample(initial); } 684 } catch (error) { initialized = false; loaded = false; finish(error instanceof Error ? error.message : String(error), true); } 685 } 686 setupInspector(); 687 void initializePackages();