app.ts (34266B)
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, type Operation } from './execution.js'; 12 import { sourceText } from './compiler.js'; 13 import { packageIndex, packageReleases, type Release } from './packages.js'; 14 import { bundleHash, fetchBytes, type BundleHash } from './bundle.js'; 15 import { literals, outputType } from './codecs.js'; 16 import type { CatalogueEntry, WorkerMessage, RequestId } from './protocol.js'; 17 import type { CompilerMessage } from './compiler-protocol.js'; 18 function el<T extends HTMLElement = HTMLElement>(id: string): T { 19 const element = document.getElementById(id); if (!element) throw new Error(`Missing element ${id}`); return element as T; 20 } 21 const sourceEditor = new SourceEditor(el('source-editor')); 22 const compileButton = el<HTMLButtonElement>('compile'), restoreButton = el<HTMLButtonElement>('restore-source'); 23 const downloadButton = el<HTMLButtonElement>('download-bundle'), exportModuleButton = el<HTMLButtonElement>('export-compiled-module'); 24 const programPicker = el<HTMLSelectElement>('program-picker'); 25 const versionPicker = el<HTMLSelectElement>('package-version'), hashInput = el<HTMLInputElement>('bundle-hash'); 26 const stopButton = el<HTMLButtonElement>('stop'), shareButton = el<HTMLButtonElement>('share'); 27 const selectedBackend = 'haskell-wasm'; 28 const workerURL = () => new URL(backends[selectedBackend].worker, import.meta.url); 29 let draft = false, scratch = false, compiled = false, busy = false, loaded = false; 30 let originalSource: string | undefined, compiledSource: string | undefined, compiledRevision: number | undefined; 31 let sourceAvailable = false, editRevision = 0, composing = false, idleDue = 0, compilationStats = ''; 32 let entries: CatalogueEntry[] = [], releases: Release[] = [], selected: CatalogueEntry | undefined; 33 let selection: { hash: BundleHash; localId?: string } | undefined, versionPackage: string | undefined; 34 let preferredExport: string | undefined, currentName = '', publishedNames: string[] = []; 35 let worker: Worker | undefined, pending: { id: RequestId } | undefined; 36 let ioVersion: 0 | 2 | undefined, ioRun: string | undefined, ioWidget: ReturnType<typeof terminalView> | undefined, stoppingIO = false; 37 let operation: Operation | undefined, idleTimer: ReturnType<typeof setTimeout> | undefined; 38 let compilerTimer: ReturnType<typeof setInterval> | undefined; 39 const deadline = new ActiveClock(() => finish(operationTimeout(operation ?? 'loading'), true)); 40 function cancelIdleCompile(): void { clearTimeout(idleTimer); idleTimer = undefined; } 41 function stopCompilerActivity(): void { clearInterval(compilerTimer); compilerTimer = undefined; el('compile-activity').hidden = true; } 42 function queueCompile(delay = 650): void { 43 cancelIdleCompile(); if (composing) return; 44 idleDue = performance.now() + delay; 45 idleTimer = setTimeout(() => { idleTimer = undefined; if (!busy && draft) compileDraft(); }, delay); 46 stopButton.hidden = false; stopButton.disabled = false; 47 } 48 const localModules = localModulesUI(() => { 49 if (!draft || busy && operation !== 'compiling') return; 50 editRevision++; definitions.reset(); 51 if (!busy) { dispose(); el('status').textContent = 'Changes pending'; } 52 queueCompile(); 53 }); 54 const definitions = definitionInspector(sourceEditor, (id, name, output) => { 55 if (busy || !worker) return; 56 currentName = name; operation = 'running'; controls(true); deadline.reset(Infinity); deadline.start(); 57 el('status').textContent = `Inspecting ${name}`; diagnostic(); 58 worker.postMessage({ type: 'inspect', id, name, output }); 59 }, { 60 run(name, output, step) { 61 if (busy || !worker || draft && (!compiled || sourceEditor.getSource() !== compiledSource || editRevision !== compiledRevision)) return; 62 currentName = name; operation = 'running'; controls(true); deadline.reset(Infinity); deadline.start(); diagnostic(); 63 ioVersion = 0; stoppingIO = false; 64 el('status').textContent = `Running IO · ${name}`; 65 worker.postMessage({ type: 'run-io', name, output, step }); 66 }, 67 stop: () => stopButton.click(), 68 resume() { 69 if (!worker || !pending) return; 70 worker.postMessage({ type: 'resume', id: pending.id }); 71 pending = undefined; deadline.start(); 72 }, 73 }); 74 const imports = importPicker({ source: sourceEditor, backend: () => selectedBackend, 75 available: () => !busy, editable: () => !busy && !sourceEditor.dom.hidden && !sourceEditor.isReadOnly, 76 tryLibrary: (library, definition) => { preferredExport = definition; loadBundle(library); }, 77 local: () => localModules.entries(), inputs: () => localModules.inputs() }); 78 function diagnostic(message = ''): void { el('operation-diagnostic').textContent = message; el('operation-diagnostic').hidden = !message; } 79 function ready(): boolean { return !!worker && (draft ? compiled && sourceEditor.getSource() === compiledSource && editRevision === compiledRevision : loaded); } 80 function controls(working: boolean): void { 81 busy = working; localModules.disable(busy); 82 programPicker.disabled = busy || !entries.length; 83 versionPicker.disabled = hashInput.disabled = busy; 84 el<HTMLButtonElement>('load-release').disabled = busy || !programPackage(programPicker.value); 85 el<HTMLButtonElement>('load').disabled = busy; el<HTMLButtonElement>('add-import').disabled = busy; 86 compileButton.disabled = busy || !draft || ready(); compileButton.hidden = !draft; restoreButton.hidden = !draft; 87 restoreButton.disabled = busy || originalSource === undefined; 88 exportModuleButton.disabled = busy || !draft || !ready(); downloadButton.disabled = busy || draft || downloadHash() === undefined; 89 sourceEditor.setAvailability(busy && operation !== 'compiling', originalSource === undefined && !draft); 90 const editable = !sourceEditor.dom.hidden && (draft || originalSource !== undefined); 91 el('source-title').textContent = editable ? 'Source' : sourceAvailable ? 'Read-only source' : loaded ? 'Compiled package' : 'Package information'; 92 el('source-availability').hidden = editable || !loaded; 93 el('source-availability').textContent = 'Browse stored definitions below, or import this module into your own source. Inspection does not execute IO.'; 94 el('package-definitions').hidden = !loaded || !sourceEditor.dom.hidden; 95 el<HTMLButtonElement>('reload-package').hidden = draft || !selection || !!worker; 96 el<HTMLButtonElement>('stored-download').disabled = busy || draft || !loaded || !worker; 97 el<HTMLSelectElement>('stored-definition').disabled = el<HTMLSelectElement>('stored-format').disabled = busy || draft || !loaded; 98 el('authorship').textContent = draft ? 'Local draft · your authored code (not published)' : loaded ? 'Published package · verified executable' : 'Published selection · not yet verified'; 99 const modified = draft && !ready(); el('authorship').classList.toggle('modified', modified); sourceEditor.dom.classList.toggle('modified', modified); 100 for (const button of document.querySelectorAll<HTMLButtonElement>('[data-compiler-example]')) button.disabled = busy; 101 stopButton.disabled = !busy && !draft; stopButton.hidden = !busy; shareButton.disabled = busy || draft || !loaded; 102 el<HTMLButtonElement>('reset').disabled = busy; 103 sourceEditor.refresh(); definitions.update(!sourceEditor.dom.hidden && ready() || sourceEditor.dom.hidden && loaded && !!worker, busy, draft ? undefined : publishedNames); 104 for (const button of el('package-definition-list').querySelectorAll<HTMLButtonElement>('button')) button.disabled = busy || !loaded; 105 } 106 function renderPublishedNames(): void { 107 const query = el<HTMLInputElement>('package-definition-search').value.toLowerCase(); 108 el('package-definition-list').replaceChildren(...publishedNames.filter(name => name.toLowerCase().includes(query)).map(name => { 109 const button = document.createElement('button'); button.type = 'button'; button.textContent = name; button.disabled = busy || !loaded; 110 button.onclick = () => { currentName = name; definitions.open(name); }; return button; 111 })); 112 } 113 el('package-definition-search').oninput = renderPublishedNames; 114 el('reload-package').onclick = () => { if (!busy && !draft && selection) newWorker(true); }; 115 el('stored-download').onclick = () => { 116 if (busy || draft || !loaded || !worker) return; 117 operation = 'exporting'; controls(true); deadline.reset(120000); deadline.start(); 118 el('stored-status').textContent = 'Exporting original records…'; 119 worker.postMessage({type:'export',export:el<HTMLSelectElement>('stored-definition').value,format:el<HTMLSelectElement>('stored-format').value}); 120 }; 121 function dispose(): void { 122 ioWidget?.abandon(); ioWidget = undefined; ioRun = undefined; ioVersion = undefined; stoppingIO = false; 123 cancelIdleCompile(); stopCompilerActivity(); deadline.pause(); operation = undefined; worker?.terminate(); worker = undefined; 124 compiled = false; compiledSource = undefined; compiledRevision = undefined; pending = undefined; controls(false); 125 } 126 function idle(): void { ioVersion = undefined; stoppingIO = false; stopCompilerActivity(); deadline.pause(); operation = undefined; pending = undefined; controls(false); } 127 function finish(message: string, error = false, cancelled = false): void { 128 const previous = operation; 129 if (error) message = operationFailure(operation, message); 130 definitions.stopped(message, cancelled || error && previous === 'running'); dispose(); diagnostic(message); 131 el('status').textContent = previous === 'compiling' ? 'Compilation failed' : error ? 'Stopped' : 'Finished'; 132 if (previous === 'compiling') { el('compile-diagnostic').textContent = message; el('compile-diagnostic').hidden = false; } 133 if (previous === 'exporting') el('compiled-module-status').textContent = message; 134 if (previous === 'downloading') el('bundle-download-status').textContent = message; 135 if (!loaded && !draft) el('package-info').textContent = `Package not loaded. ${message}`; 136 } 137 stopButton.onclick = () => { 138 if (operation === 'running' && ioVersion !== undefined) { 139 if (!stoppingIO) { stoppingIO = true; worker?.postMessage({ type: 'io-stop' }); } 140 stopButton.disabled = true; el('status').textContent = 'Cancelling; waiting for cleanup…'; return; 141 } 142 imports.cancel(); 143 finish(draft ? 'Stopped. Compile the draft again to inspect or run it.' : 'Stopped. Reload the package to inspect or run it.', false, true); 144 el('status').textContent = 'Stopped'; 145 }; 146 el<HTMLButtonElement>('reload-playground').onclick = () => { 147 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.')) return; 148 location.reload(); 149 }; 150 el<HTMLButtonElement>('reset').onclick = () => { if (!busy) { definitions.reset(); diagnostic(); } }; 151 shareButton.onclick = async () => { 152 if (draft || !selection) return; 153 const url = new URL(location.href), fragment = new URLSearchParams({ bundle: selection.hash, export: currentName || selected?.export || publishedNames[0] || '' }); 154 if (selection.localId) fragment.set('demo', selection.localId); url.hash = fragment.toString(); 155 try { await navigator.clipboard.writeText(url.href); el('bundle-download-status').textContent = 'Copied a link to this package and definition.'; } 156 catch { el('bundle-download-status').textContent = url.href; } 157 }; 158 window.addEventListener('pagehide', () => dispose()); 159 function receive(data: CompilerMessage): void { 160 if (data.type === 'inspected' || data.type === 'inspection-error') { 161 if (!definitions.reply(data)) return; 162 if (data.type === 'inspection-error' && data.fatal) finish(data.message, true); 163 else { idle(); el('status').textContent = draft ? 'Compiled' : 'Ready'; } 164 return; 165 } 166 definitions.event(data); 167 switch (data.type) { 168 case 'io-version': ioVersion = data.version; break; 169 case 'io-open': { 170 if (ioWidget) throw new Error('Overlapping terminal runs'); 171 const current = worker; ioRun = data.run; 172 ioWidget = terminalView(el('terminal-io'), data.run, 173 reply => { if (worker === current && ioRun === reply.run) current?.postMessage({ type: 'io-command', command: { run: reply.run, kind: 'reply', reply } }); }, 174 () => stopButton.click()); 175 break; 176 } 177 case 'io-event': if (data.event.run === ioRun) ioWidget?.handle(data.event); break; 178 case 'io-close': 179 if (data.run === ioRun) { ioVersion = undefined; if (data.settled) ioWidget?.finish(); else ioWidget?.abandon(); ioWidget = undefined; ioRun = undefined; el('status').textContent = data.settled ? 'IO settled; interpreting returned value' : 'Runtime lost'; } 180 break; 181 case 'state': 182 if (data.state === 'running') { pending = undefined; deadline.start(); if (operation === 'running' && !stoppingIO) el('status').textContent = `Running IO · ${currentName}`; } 183 else { deadline.pause(); pending = {id:data.id}; el('status').textContent = 'Paused before action'; } 184 break; 185 case 'done': idle(); el('status').textContent = 'Finished'; el('compiler-stats').textContent = `${compilationStats} · IO · ${runtimeStats(data.stats)}`; break; 186 case 'error': 187 if (operation === 'running' && !data.fatal) { definitions.stopped(data.message); idle(); diagnostic(data.message); el('status').textContent = 'Stopped'; } 188 else finish(data.message, true); 189 break; 190 case 'exported': { 191 if (draft || data.hash !== selection?.hash || data.export !== el<HTMLSelectElement>('stored-definition').value || data.format !== el<HTMLSelectElement>('stored-format').value) return; 192 const url = URL.createObjectURL(new Blob([data.file.text], {type:'text/plain;charset=utf-8'})); 193 const anchor = document.createElement('a'); anchor.href = url; anchor.download = `${data.export.replace(/[^a-zA-Z0-9_-]/g,'-').slice(0,64)}.${data.file.extension}`; 194 document.body.append(anchor); anchor.click(); anchor.remove(); setTimeout(()=>URL.revokeObjectURL(url),1000); 195 idle(); el('stored-status').textContent = `Downloaded stored definition ${data.export} · SHA-256 ${data.hash}`; el('status').textContent = 'Ready'; break; 196 } 197 case 'export-error': idle(); el('stored-status').textContent = data.message; el('status').textContent = 'Export failed'; break; 198 case 'module-exported': { 199 idle(); const url = URL.createObjectURL(new Blob([new Uint8Array(data.bytes).buffer], {type:'application/vnd.arboricx.bundle'})); 200 const anchor = document.createElement('a'); anchor.href = url; anchor.download = `${data.hash}.arboricx`; document.body.append(anchor); anchor.click(); anchor.remove(); setTimeout(()=>URL.revokeObjectURL(url),1000); 201 el('compiled-module-status').textContent = `Exported compiled module. Manifest: ${data.manifest} · Bundle SHA-256: ${data.hash}.`; 202 el('status').textContent = 'Compiled'; break; 203 } 204 case 'module-export-error': idle(); el('compiled-module-status').textContent = data.message; el('status').textContent = 'Module export failed'; break; 205 } 206 } 207 function newWorker(preserveSource = false): void { 208 if (!selection) return; 209 dispose(); loaded = false; operation = 'loading'; diagnostic(); deadline.reset(PACKAGE_LOAD_MS); deadline.start(); 210 const current = new Worker(workerURL(), {type:'module'}); worker = current; 211 controls(true); el('status').textContent = 'Loading package'; el('package-info').textContent = 'Verifying package…'; 212 current.onmessage = ({data}: MessageEvent<WorkerMessage>) => { 213 if (worker !== current) return; 214 if (data.type !== 'loaded') { receive(data as CompilerMessage); return; } 215 loaded = true; publishedNames = data.exports.map(item=>item.name); 216 const requested = preferredExport; currentName = requested && publishedNames.includes(requested) ? requested : selected?.export ?? publishedNames[0] ?? ''; preferredExport = undefined; 217 el<HTMLSelectElement>('stored-definition').replaceChildren(...publishedNames.map(name=>new Option(name,name))); 218 if (publishedNames.includes(currentName)) el<HTMLSelectElement>('stored-definition').value = currentName; 219 el('compiler-stats').textContent = 'Published package · no local compilation.'; 220 el('package-info').textContent = `SHA-256 verified · ${data.nodes.toLocaleString()} stored DAG nodes · ${publishedNames.length} export(s) · ${data.hash}`; 221 if (!preserveSource || originalSource === undefined) { originalSource = selected?.environment !== undefined && data.source !== null ? data.source : undefined; sourceEditor.replaceSource(originalSource ?? ''); } 222 sourceEditor.dom.hidden = originalSource === undefined; el('source-notes').hidden = !sourceEditor.dom.hidden; 223 sourceAvailable = data.source !== null; el('source-label').textContent = sourceAvailable ? 'Curated tricu / stable' : 'Package metadata'; 224 el('source').textContent = data.source ?? `Verified package metadata\n${JSON.stringify(data.metadata,null,2)}`; 225 if (data.source !== null) paintSyntax(el('source'), tokenize(data.source)); 226 el<HTMLInputElement>('package-definition-search').value = sourceEditor.dom.hidden && requested && publishedNames.includes(requested) ? requested : ''; renderPublishedNames(); idle(); el('status').textContent = 'Ready'; 227 }; 228 current.onerror = event => { if (worker === current) { event.preventDefault(); loaded = false; finish(event.message || 'Worker failed.',true); } }; 229 current.postMessage({type:'load',...selection}); 230 } 231 function selectExample(entry: CatalogueEntry): void { 232 const name = examplePackage(entry), latest = name ? packageReleases(releases,name)[0] : undefined; 233 if (!latest || latest.demo === entry.id && latest.hash === entry.hash) select(entry,latest); else loadBundle(latest); 234 } 235 function select(entry: CatalogueEntry, release?: Release): void { 236 definitions.reset(); draft = scratch = false; originalSource = undefined; sourceAvailable = false; 237 dispose(); diagnostic(); loaded = false; selected = entry; publishedNames = []; 238 selection = {hash:entry.hash,localId:entry.id}; hashInput.value = entry.hash; sourceEditor.dom.hidden = true; el('source-notes').hidden = false; 239 const pinned = release ?? releases.find(r=>r.hash===entry.hash&&r.demo===entry.id); chooseProgram(`demo:${entry.id}`,pinned?.version); showRelease(pinned); newWorker(); 240 } 241 function invalidateProgram(): void { 242 definitions.reset(); draft = scratch = false; originalSource = undefined; sourceAvailable = false; 243 dispose(); diagnostic(); loaded = false; selection = undefined; selected = undefined; publishedNames = []; 244 sourceEditor.dom.hidden = true; el('source-notes').hidden = false; el('source').textContent = 'Load the selected program to inspect its source or package notes.'; 245 el('package-info').textContent = 'No bundle loaded.'; showRelease(); renderPublishedNames(); controls(false); el('status').textContent = 'Load package'; 246 } 247 function loadBundle(release?: Release): void { 248 const value = release?.hash ?? hashInput.value, requested = preferredExport; 249 invalidateProgram(); preferredExport = requested; chooseProgram(release ? releaseProgram(release) : 'hash',release?.version); hashInput.value = value; 250 try { selection = {hash:bundleHash(value)}; showRelease(release); newWorker(); } catch(error) { finish(String(error),true); } 251 } 252 el<HTMLFormElement>('program-hash').onsubmit = event => { event.preventDefault(); loadBundle(); }; 253 hashInput.oninput = () => { chooseProgram('hash'); invalidateProgram(); }; 254 // Associate an example with its published name only through the catalogue's exact pin. 255 function examplePackage(entry: CatalogueEntry): string | undefined { 256 return releases.find(r => r.demo === entry.id && r.hash === entry.hash)?.name; 257 } 258 function programPackage(value: string): string | undefined { 259 if (value.startsWith('package:')) return value.slice(8); 260 const entry = value.startsWith('demo:') ? entries.find(e => e.id === value.slice(5)) : undefined; 261 return entry ? examplePackage(entry) : undefined; 262 } 263 function releaseProgram(release: Release): string { 264 const entry = entries.find(e => e.id === release.demo && examplePackage(e) === release.name); 265 return entry ? `demo:${entry.id}` : `package:${release.name}`; 266 } 267 function chooseProgram(value: string, version?: string): void { 268 el('bundle-download-status').textContent = ''; 269 if (value.startsWith('package:') && ![...programPicker.options].some(option => option.value === value)) { 270 for (const group of programPicker.querySelectorAll('optgroup')) if (group.label === 'Exploring library') group.remove(); 271 const group = document.createElement('optgroup'); group.label = 'Exploring library'; 272 group.append(new Option(libraryLabel(value.slice(8)), value)); programPicker.append(group); 273 } 274 programPicker.value = value; 275 const name = programPackage(value); 276 el('program-release').hidden = name === undefined; 277 el('package-toolbar').hidden = name === undefined && value !== 'hash'; 278 el('bundle-hash-help').hidden = value !== 'hash'; 279 el('program-hash').hidden = value !== 'hash'; 280 if (name && versionPackage !== name) { 281 versionPackage = name; 282 versionPicker.replaceChildren(); 283 for (const release of packageReleases(releases, name)) { 284 const option = document.createElement('option'); option.value = release.version; option.textContent = release.version; versionPicker.append(option); 285 } 286 } 287 if (name) versionPicker.value = version ?? packageReleases(releases, name)[0]!.version; 288 } 289 function populatePrograms(): void { 290 programPicker.replaceChildren(); 291 const placeholder = document.createElement('option'); placeholder.value = ''; placeholder.disabled = true; placeholder.textContent = 'Choose a program'; programPicker.append(placeholder); 292 for (const category of exampleGroups) { 293 const group = document.createElement('optgroup'); group.label = category; 294 for (const entry of entries.filter(entry => exampleGroup(entry) === category)) { 295 const option = document.createElement('option'); option.value = `demo:${entry.id}`; option.textContent = entry.title; group.append(option); 296 } 297 if (group.children.length) programPicker.append(group); 298 } 299 const packages = document.createElement('optgroup'); packages.label = 'Exploring library'; 300 const currentName = selection && releases.find(r => r.hash === selection?.hash && !r.demo)?.name; 301 const currentRelease = releases.find(r => r.name === currentName); 302 if (currentRelease) { 303 const option = new Option(libraryLabel(currentRelease.name), `package:${currentRelease.name}`); packages.append(option); 304 } 305 const hash = document.createElement('option'); hash.value = 'hash'; hash.textContent = 'Load by bundle hash…'; 306 if (packages.children.length) programPicker.append(packages); 307 programPicker.append(hash); programPicker.disabled = false; 308 } 309 function showRelease(release?: Release): void { 310 el('module-import-help').hidden = release === undefined; 311 el('module-import-source').textContent = release ? `!import ${release.manifest} as Library` : ''; 312 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.'; 313 } 314 programPicker.onchange = () => { 315 const value = programPicker.value; 316 if (value.startsWith('demo:')) { 317 const entry = entries.find(e => e.id === value.slice(5)); if (entry) selectExample(entry); 318 } else { 319 chooseProgram(value); if (value === 'hash') hashInput.value = ''; invalidateProgram(); 320 if (value.startsWith('package:')) loadSelectedRelease(); 321 else if (value === 'hash') hashInput.focus(); 322 } 323 }; 324 versionPicker.onchange = invalidateProgram; 325 el<HTMLButtonElement>('load-release').onclick = loadSelectedRelease; 326 function loadSelectedRelease(): void { 327 const name = programPackage(programPicker.value); 328 const release = releases.find(r => r.name === name && r.version === versionPicker.value); if (!release) return; 329 const demo = entries.find(e => e.id === release.demo && e.hash === release.hash); 330 if (demo) select(demo, release); 331 else loadBundle(release); 332 } 333 function downloadHash(): BundleHash | undefined { 334 const name = programPackage(programPicker.value); 335 if (name) return releases.find(r => r.name === name && r.version === versionPicker.value)?.hash; 336 if (programPicker.value === 'hash') { 337 try { return bundleHash(hashInput.value); } catch { return undefined; } 338 } 339 return undefined; 340 } 341 downloadButton.onclick = () => { 342 const hash = downloadHash(); if (busy || draft || !hash) return; 343 // Download has its own verified target, not the loaded executable selection. 344 // It never changes source, definitions, arguments or the Run/Export target. 345 dispose(); operation = 'downloading'; 346 const current = new Worker(workerURL(), { type: 'module' }); worker = current; 347 controls(true); el('status').textContent = 'Downloading bundle'; 348 el('bundle-download-status').textContent = 'Verifying published bundle…'; el('bundle-download-status').classList.remove('error'); 349 deadline.reset(PACKAGE_LOAD_MS); deadline.start(); 350 current.onmessage = ({ data }: MessageEvent<WorkerMessage>): void => { 351 if (worker !== current) return; 352 if (data.type === 'loaded') { 353 if (data.hash !== hash) { finish('Bundle download target mismatch', true); return; } 354 current.postMessage({ type: 'download' }); 355 } else if (data.type === 'downloaded') { 356 if (data.hash !== hash) { finish('Bundle download target mismatch', true); return; } 357 const url = URL.createObjectURL(new Blob([new Uint8Array(data.bytes)], { type: 'application/vnd.arboricx.bundle' })); 358 const anchor = document.createElement('a'); anchor.href = url; anchor.download = `${hash}.arboricx`; 359 document.body.append(anchor); anchor.click(); anchor.remove(); setTimeout(() => URL.revokeObjectURL(url), 1000); 360 const message = `Downloaded original verified bundle ${hash}. No draft or applied result included.`; 361 dispose(); el('status').textContent = loaded ? 'Ready' : 'Load package'; 362 el('bundle-download-status').textContent = message; 363 } else if (data.type === 'error') finish(data.message, true); 364 }; 365 current.onerror = event => { if (worker === current) finish(event.message || 'Worker error', true); }; 366 current.postMessage({ type: 'load', hash }); 367 }; 368 369 sourceEditor.dom.addEventListener('compositionstart',()=>{composing=true;cancelIdleCompile();}); 370 sourceEditor.dom.addEventListener('compositionend',()=>{composing=false;if(draft)queueCompile();}); 371 sourceEditor.onChange(() => { 372 if (busy && operation !== 'compiling') return; 373 editRevision++; diagnostic(); el('compile-diagnostic').hidden=true; 374 if (operation === 'compiling') { draft=true;controls(true);queueCompile();return; } 375 if (!worker || !compiled || busy) dispose(); 376 draft = compiled || scratch || sourceEditor.getSource() !== originalSource; 377 controls(false); 378 if (!draft && selection) newWorker(true); else {el('status').textContent='Changes pending';queueCompile();} 379 }); 380 restoreButton.onclick=()=>{if(originalSource===undefined||busy)return;dispose();sourceEditor.replaceSource(originalSource,true);}; 381 compileButton.onclick=()=>compileDraft(); 382 function compileDraft(): void { 383 if(!draft||busy)return; 384 const snapshot={revision:editRevision,source:sourceEditor.getSource()};dispose();operation='compiling';diagnostic();el('compile-diagnostic').hidden=true; 385 try { 386 sourceText(snapshot.source);const current=new Worker(workerURL(),{type:'module'});worker=current; 387 controls(true);deadline.reset(Infinity);deadline.start();el('status').textContent='Compiling'; 388 el('compiler-stats').textContent='Loading pinned compiler and source dependencies…';const started=performance.now(); 389 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`;};activity();compilerTimer=setInterval(activity,1000); 390 current.onmessage=({data}:MessageEvent<CompilerMessage>)=>{ 391 if(worker!==current)return; 392 if((data.type==='compiled'||data.type==='error')&&operation==='compiling'&&snapshot.revision!==editRevision){dispose();el('status').textContent='Changes pending';queueCompile(Math.max(0,idleDue-performance.now()));return;} 393 if(data.type==='compiled') {compiledSource=snapshot.source;compiledRevision=snapshot.revision;compiled=true;idle();el('status').textContent='Compiled';compilationStats=`${data.names.length} definition(s) · ${Math.round(data.milliseconds)} ms · ${runtimeStats(data.stats)}`;el('compiler-stats').textContent=compilationStats;} 394 else receive(data); 395 }; 396 current.onerror=event=>{if(worker!==current)return;event.preventDefault();if(snapshot.revision!==editRevision&&operation==='compiling'){dispose();queueCompile(0);}else finish(event.message||'Compiler Worker failed.',true);}; 397 current.postMessage({type:'compile',source:snapshot.source,localModules:localModules.inputs(),...(!scratch&&selected?.environment!==undefined?{environment:selected.environment}:{})}); 398 }catch(error){finish(String(error),true);} 399 } 400 exportModuleButton.onclick=()=>{if(busy||!draft||!ready()||!worker)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'});}; 401 const examples={ 402 identity:'id = (x : x)\nanswer = id 42', 403 contracts:'id x =@nat? x\nanswer = id 42', 404 pair:'pair a b = t a b\nanswer = pair 7 "hi"', 405 definitions:'id = (x : x)\nanswer = id 42', 406 }; 407 for(const button of document.querySelectorAll<HTMLButtonElement>('[data-compiler-example]'))button.onclick=()=>{ 408 const math=packageReleases(releases,'tricu-lib-native-math')[0]; 409 const choice=button.dataset['compilerExample']; 410 const io=packageReleases(releases,'tricu-lib-io')[0],text=packageReleases(releases,'tricu-lib-io-text')[0]; 411 const source=choice==='input'&&io&&text?`!import ${io.manifest} as IO 412 !import ${text.manifest} as Text 413 !export io 414 io = IO.io <| IO.require IO.stdin (reader : 415 IO.require (Text.readLine (Text.lineCursor reader) 4096) (reply : case reply of 416 [["line" value _] _] = IO.pure value 417 [["eof"] _] = IO.pure "" 418 _ = IO.fail "Invalid line reply"))` 419 :choice==='module'&&math?`!import ${math.manifest} as N\n!export answer\nanswer = N.add 40 2`:examples[choice as keyof typeof examples]; 420 if(!source)return; 421 definitions.reset();dispose();diagnostic();chooseProgram('');scratch=draft=true;originalSource=undefined;loaded=false;selection=undefined;selected=undefined;editRevision++; 422 sourceEditor.dom.hidden=false;el('source-notes').hidden=true;sourceEditor.replaceSource(source);controls(false);el('package-info').textContent='Scratch program · no published bundle selected.';showRelease();el('source-label').textContent='Scratch program · explicit modules or contract helpers';el('status').textContent='Changes pending';queueCompile(); 423 }; 424 function loadFragment(): boolean { 425 const fragment=new URLSearchParams(location.hash.slice(1)),requested=fragment.get('bundle');if(!requested)return false; 426 const hash=bundleHash(requested),local=entries.find(e=>e.id===fragment.get('demo')&&e.hash===hash);preferredExport=fragment.get('export')??undefined; 427 if(local)select(local);else{hashInput.value=hash;loadBundle();}return true; 428 } 429 window.addEventListener('hashchange',()=>{if(!entries.length)return;try{if(new URLSearchParams(location.hash.slice(1)).has('bundle'))loadFragment();}catch(error){finish(String(error),true);}}); 430 let initialized = false; 431 async function initializePackages(): Promise<void> { 432 if (initialized) return; 433 initialized = true; operation = 'catalogue'; 434 try { 435 if (!globalThis.Worker || !globalThis.crypto?.subtle) throw new Error('Playground needs Workers and a secure context (HTTPS or localhost).'); 436 const bytes = await fetchBytes(new URL('../catalogue.json', import.meta.url), 64_000); 437 const input: unknown = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)); 438 if (!Array.isArray(input) || input.length > 32) throw new Error('Invalid catalogue'); 439 entries = input.map((raw: unknown): CatalogueEntry => { 440 if (!raw || typeof raw !== 'object') throw new Error('Invalid catalogue entry'); 441 const v = raw as Record<string, unknown>; 442 for (const key of ['id', 'title', 'source', 'sourceSha256', 'export']) if (typeof v[key] !== 'string') throw new Error('Invalid catalogue field'); 443 const hash = bundleHash(v['hash']); literals(v['arguments']); outputType(v['output']); 444 if (v['mode'] !== 'pure' && v['mode'] !== 'io') throw new Error('Invalid catalogue mode'); 445 if (v['category'] !== undefined && !exampleGroups.includes(v['category'] as typeof exampleGroups[number])) throw new Error('Invalid example category'); 446 if (v['environment'] !== undefined && (typeof v['environment'] !== 'string' || v['environment'].length > 128)) throw new Error('Invalid demo environment'); 447 return { ...v, hash } as unknown as CatalogueEntry; 448 }); 449 const indexBytes = await fetchBytes(new URL('../packages.json', import.meta.url), 1_048_576); 450 releases = packageIndex(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(indexBytes)) as unknown); 451 populatePrograms(); 452 if (selection || draft) { operation = undefined; return; } 453 if (!loadFragment()) { const initial = entries.find(e => e.id === 'names-values') ?? entries[0]; if (!initial) throw new Error('Empty catalogue'); selectExample(initial); } 454 } catch (error) { initialized = false; loaded = false; finish(error instanceof Error ? error.message : String(error), true); } 455 } 456 setupInspector(); 457 void initializePackages();