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();