local-modules-ui.ts (4988B)
1 import { BUNDLE_LIMIT } from './bundle.js'; 2 import { LOCAL_MODULE_BYTES, LOCAL_MODULE_COUNT, type LocalModule, type LocalModuleInput } from './local-modules.js'; 3 // Hold local module files in page memory until removal or page departure. 4 export function localModulesUI(changed: () => void): { inputs(): LocalModuleInput[]; entries(): { label: string; manifest: string; names: string[] }[]; disable(busy: boolean): void } { 5 const input = document.getElementById('local-module-file') as HTMLInputElement; 6 const list = document.getElementById('local-module-list')!; 7 const status = document.getElementById('local-module-status')!; 8 const cancel = document.getElementById('cancel-local-module') as HTMLButtonElement; 9 const modules = new Map<string, { label: string; module: LocalModule }>(); 10 let busy = false, checking = false, abort: (() => void) | undefined; 11 const render = (): void => { 12 input.disabled = busy || checking; cancel.hidden = !checking; 13 list.replaceChildren(); 14 for (const [manifest, { label, module }] of modules) { 15 const row = document.createElement('li'), title = document.createElement('p'); 16 title.textContent = `${label} · ${module.names.length} export(s) · ${module.bytes.byteLength.toLocaleString()} bytes`; 17 const code = document.createElement('code'); code.textContent = `!import ${manifest} as Local`; 18 const details = document.createElement('details'), summary = document.createElement('summary'), names = document.createElement('p'); 19 summary.textContent = 'Exports and bundle hash'; names.textContent = `${module.names.join(', ')}\nBundle SHA-256: ${module.hash}`; details.append(summary, names); 20 const copy = document.createElement('button'); copy.textContent = 'Copy import'; 21 copy.onclick = () => { void (async () => { try { await navigator.clipboard.writeText(code.textContent!); status.textContent = 'Import copied.'; } catch { status.textContent = code.textContent; } })(); }; 22 const remove = document.createElement('button'); remove.textContent = 'Remove'; remove.dataset['removeModule'] = manifest; remove.disabled = busy || checking; 23 remove.onclick = () => { modules.delete(manifest); render(); status.textContent = 'Local module removed. Draft imports will be resolved again.'; changed(); }; 24 row.append(title, code, details, copy, remove); list.append(row); 25 } 26 }; 27 cancel.onclick = () => abort?.(); 28 input.onchange = () => { 29 const file = input.files?.[0]; input.value = ''; if (!file || busy || checking) return; 30 if (!file.size || file.size > BUNDLE_LIMIT) { status.textContent = 'Choose a nonempty .arboricx file of at most 16 MiB.'; return; } 31 checking = true; render(); status.textContent = 'Checking module locally…'; 32 void (async () => { 33 let worker: Worker | undefined, active = true; 34 let timer: ReturnType<typeof setTimeout> | undefined; 35 try { 36 const current = new Worker(new URL('./local-module-worker.js', import.meta.url), { type: 'module' }); worker = current; 37 const module = await new Promise<LocalModule>((resolve, reject) => { 38 abort = () => reject(new Error('Local file check cancelled.')); 39 timer = setTimeout(() => reject(new Error('Local file check timed out.')), 60_000); 40 current.onmessage = ({ data }: MessageEvent<{ module?: LocalModule; error?: string }>) => data.module ? resolve(data.module) : reject(new Error(data.error ?? 'Invalid local module')); 41 current.onerror = event => { event.preventDefault(); reject(new Error(event.message || 'Local file check failed')); }; 42 void file.arrayBuffer().then(bytes => { if (active) current.postMessage(new Uint8Array(bytes)); }).catch(reject); 43 }); 44 if (modules.has(module.manifest)) { status.textContent = 'This module manifest is already available locally.'; return; } 45 const total = [...modules.values()].reduce((n, item) => n + item.module.bytes.byteLength, module.bytes.byteLength); 46 if (modules.size >= LOCAL_MODULE_COUNT || total > LOCAL_MODULE_BYTES) throw new Error('Local collection limit: 32 modules / 64 MiB. Remove a module first.'); 47 modules.set(module.manifest, { label: file.name.slice(0, 256), module }); 48 status.textContent = 'Available locally by manifest hash.'; 49 changed(); 50 } catch (error) { status.textContent = String(error); } 51 finally { active = false; clearTimeout(timer); worker?.terminate(); abort = undefined; checking = false; render(); } 52 })(); 53 }; 54 render(); 55 return { entries: () => [...modules.values()].map(({ label, module }) => ({ label, manifest: module.manifest, names: [...module.names] })), inputs: () => [...modules.values()].map(({ module }) => ({ manifest: module.manifest, bytes: module.bytes })), disable: value => { busy = value; input.disabled = busy || checking; for (const button of list.querySelectorAll<HTMLButtonElement>('[data-remove-module]')) button.disabled = busy || checking; } }; 56 }