io-v2-terminal-view.ts (4930B)
1 import { Terminal, type Reply } from './io-v2-terminal.js'; 2 import type { Event } from './io-v2-events.js'; 3 // One identity-bound UI per run. Callbacks enqueue cloneable replies/stop requests; 4 // they must not synchronously re-enter this widget or throw. 5 export function terminalView(parent: HTMLElement, run: string, reply: (value: Reply) => void, stop: () => void) { 6 const terminal = new Terminal(run), doc = parent.ownerDocument; 7 const root = doc.createElement('section'), status = doc.createElement('p'); 8 const outputs = ['output', 'error'].map(name => { 9 const section = doc.createElement('section'), title = doc.createElement('h4'); title.textContent = name === 'output' ? 'Standard output' : 'Standard error'; 10 const text = doc.createElement('pre'), details = doc.createElement('details'), summary = doc.createElement('summary'), hex = doc.createElement('pre'); 11 summary.textContent = 'Exact bytes (hexadecimal)'; details.append(summary, hex); section.append(title, text, details); root.append(section); 12 text.dataset.stream = name; hex.dataset.hex = name; 13 return { text, hex }; 14 }); 15 let form = doc.createElement('form'), clearInput = () => {}, waitingId: number | undefined; 16 const stopping = doc.createElement('button'); 17 stopping.textContent = 'Stop (cancel and wait for cleanup)'; stopping.type = 'button'; 18 root.append(form, stopping, status); parent.append(root); 19 let stopped = false, finished = false; 20 const onStop = () => { if (stopped || finished) return; stopped = true; stopping.disabled = true; status.textContent = 'Cancelling; waiting for cleanup…'; stop(); }; 21 stopping.addEventListener('click', onStop); 22 function inputView(): void { 23 const wait = terminal.waiting; 24 if (wait?.id === waitingId) { form.hidden = !wait; return; } 25 clearInput(); 26 const fresh = doc.createElement('form'); form.replaceWith(fresh); form = fresh; form.hidden = !wait; waitingId = wait?.id; 27 if (!wait) return; 28 const label = doc.createElement('label'), input = doc.createElement('input'), send = doc.createElement('button'), eof = doc.createElement('button'); 29 label.textContent = 'One line, at most 256 Unicode scalars (Send appends LF)'; input.maxLength = 512; label.append(input); 30 send.textContent = 'Send'; send.type = 'submit'; eof.textContent = 'End input (EOF)'; eof.type = 'button'; 31 form.append(label, send, eof); 32 function submit(end: boolean): void { 33 const result = end ? terminal.endInput(wait!.run, wait!.id) : terminal.line(wait!.run, wait!.id, input.value); 34 if (!result) { status.textContent = 'Enter at most 256 Unicode scalars without CR or LF.'; return; } 35 render(); reply(result); 36 } 37 const onSubmit = (event: SubmitEvent) => { event.preventDefault(); submit(false); }, onEOF = () => submit(true); 38 form.addEventListener('submit', onSubmit); eof.addEventListener('click', onEOF); 39 const owned = form; 40 clearInput = () => { owned.removeEventListener('submit', onSubmit); eof.removeEventListener('click', onEOF); input.value = ''; }; 41 } 42 function render(): void { 43 const snapshot = terminal.output.snapshot(); 44 [snapshot.output, snapshot.error].forEach((view, index) => { 45 const target = outputs[index]!; target.text.replaceChildren(); target.hex.textContent = view.hex; 46 for (const token of view.tokens) { 47 if (token.kind === 'text') target.text.append(doc.createTextNode(token.text)); 48 else { 49 const span = doc.createElement('mark'); span.dataset.byteReason = token.reason; 50 span.title = `${token.reason} UTF-8 bytes at stream offset ${token.offset}; see exact hex view`; 51 span.textContent = token.bytes.map(b => `\\x${b.toString(16).padStart(2, '0')}`).join(''); target.text.append(span); 52 } 53 } 54 }); 55 inputView(); 56 if (!stopped) status.textContent = terminal.waiting ? 'Waiting for a line or explicit EOF.' : 'Running…'; 57 } 58 render(); 59 return { 60 root, 61 handle(event: Event): void { 62 if (finished) throw new Error('terminal view retired'); 63 const result = terminal.handle(event); 64 // Physically hide/clear an abandoned input BEFORE acknowledging cancel. 65 render(); if (result) reply(result); 66 }, 67 finish(): void { 68 terminal.finish(); finished = true; render(); 69 clearInput(); stopping.removeEventListener('click', onStop); 70 form.remove(); stopping.disabled = true; status.textContent = 'Settled.'; 71 }, 72 // Fatal runtime loss/navigation is NOT a physical cleanup acknowledgement. 73 // Retire DOM callbacks without fabricating a settled interpreter outcome. 74 abandon(): void { 75 if (finished) return; 76 finished = true; clearInput(); form.remove(); 77 stopping.removeEventListener('click', onStop); stopping.disabled = true; 78 status.textContent = 'Runtime lost; cleanup was not acknowledged.'; 79 }, 80 snapshot: () => terminal.output.snapshot() 81 }; 82 }