tricu

An interpreted language for exploring Tree Calculus
Log | Files | Refs | README | LICENSE

import-picker.ts (9901B)


      1 import { fetchBytes } from './bundle.js';
      2 import { libraryIndex, latestLibraries, matchesLibrary, type Library } from './library-index.js';
      3 import { packageReleases } from './packages.js';
      4 import type { ImportPlan } from './import-plan.js';
      5 import type { LocalModuleInput } from './local-modules.js';
      6 interface Choice { title: string; description: string; manifest: string; names: string[]; alias: string; library?: Library; }
      7 interface Options {
      8   source: HTMLTextAreaElement;
      9   backend(): string;
     10   available(): boolean;
     11   local(): { label: string; manifest: string; names: string[] }[];
     12   inputs(): LocalModuleInput[];
     13 }
     14 export function importPicker(options: Options): { cancel(): void } {
     15   const get = <T extends HTMLElement>(id: string): T => document.getElementById(id) as T;
     16   const dialog = get<HTMLDialogElement>('import-dialog'), open = get<HTMLButtonElement>('add-import');
     17   const search = get<HTMLInputElement>('import-search'), results = get('import-results'), detail = get('import-detail');
     18   const alias = get<HTMLInputElement>('import-alias'), versions = get<HTMLSelectElement>('import-version');
     19   const status = get('import-status'), preview = get('import-preview'), insert = get<HTMLButtonElement>('insert-import');
     20   let libraries: Library[] | undefined, selected: Choice | undefined, plan: ImportPlan | undefined;
     21   let worker: Worker | undefined, timer: ReturnType<typeof setTimeout> | undefined, debounce: ReturnType<typeof setTimeout> | undefined;
     22   let revision = 0, snapshot = '', backend = '';
     23   const stop = (): void => { revision++; worker?.terminate(); worker = undefined; clearTimeout(timer); clearTimeout(debounce); };
     24   const finish = (): void => { stop(); dialog.close(); open.focus(); };
     25   const message = (text: string): void => { status.textContent = text; };
     26   const statement = (): string => selected ? `!import ${selected.manifest} as ${alias.value}` : '';
     27   function check(): void {
     28     stop(); plan = undefined; insert.disabled = true; insert.textContent = 'Insert import'; preview.textContent = statement();
     29     if (!selected) return;
     30     if (!/^[A-Za-z_][A-Za-z0-9_]{0,127}$/u.test(alias.value)) { message('Choose a namespace using letters, digits and underscores, starting with a letter or underscore.'); return; }
     31     message('Checking with the native compiler…');
     32     const current = revision;
     33     debounce = setTimeout(() => {
     34       if (!dialog.open || current !== revision) return;
     35       const url = new URL('./wasm-worker.js', import.meta.url); url.searchParams.set('backend', backend);
     36       const next = new Worker(url, { type: 'module' }); worker = next;
     37       const fail = (text: string): void => { if (current !== revision) return; stop(); message(`${text} You can copy the import and finish editing the source.`); };
     38       timer = setTimeout(() => fail('Import check timed out.'), 60_000);
     39       next.onerror = event => { event.preventDefault(); fail(event.message || 'Import check failed.'); };
     40       next.onmessage = ({ data }: MessageEvent<{ type: string; plan?: ImportPlan; message?: string }>) => {
     41         if (current !== revision || !dialog.open) return;
     42         if (data.type === 'error') { fail(data.message ?? 'Import check failed.'); return; }
     43         if (data.type !== 'import-checked' || !data.plan) return;
     44         plan = data.plan; stop(); insert.disabled = false;
     45         insert.textContent = plan.existing ? 'Show existing import' : 'Insert import';
     46         message(plan.existing ? `Already imported ${plan.existing.alias === '*' ? 'openly' : 'as ' + plan.existing.alias}.` : 'Ready to insert.');
     47       };
     48       next.postMessage({ type: 'check-import', source: snapshot, manifest: selected!.manifest, alias: alias.value, localModules: options.inputs() });
     49     }, 250);
     50   }
     51   function show(choice: Choice, focus = true): void {
     52     selected = choice; get('import-actions').hidden = false; results.hidden = true; get('import-browse').hidden = true; detail.hidden = false;
     53     get('import-title').textContent = choice.title; get('import-description').textContent = choice.description;
     54     get('import-usage').replaceChildren(...(choice.library?.usage ?? '').split('`').map((text, i) => {
     55       if (i % 2 === 0) return document.createTextNode(text);
     56       const code = document.createElement('code'); code.textContent = text; return code;
     57     })); get('import-usage-details').hidden = !choice.library?.usage;
     58     get('import-example').textContent = choice.library?.example ?? ''; get('import-example-details').hidden = !choice.library?.example;
     59     (get('import-usage-details') as HTMLDetailsElement).open = false; (get('import-example-details') as HTMLDetailsElement).open = false;
     60     alias.value = choice.alias; versions.replaceChildren(); versions.hidden = !choice.library;
     61     get('import-version-label').hidden = !choice.library;
     62     if (choice.library) for (const release of packageReleases(libraries!, choice.library.name)) {
     63       const option = new Option(release.version, release.version); option.selected = release.version === choice.library.version; versions.add(option);
     64     }
     65     const reference = get<HTMLAnchorElement>('import-reference'); reference.hidden = !choice.library;
     66     if (choice.library) reference.href = new URL('../reference/' + choice.library.reference, import.meta.url).href;
     67     get<HTMLInputElement>('import-export-search').value = ''; renderExports(); check();
     68     if (focus) { get('import-content').scrollTop = 0; get('import-title').focus({ preventScroll: true }); }
     69   }
     70   function renderExports(): void {
     71     const query = get<HTMLInputElement>('import-export-search').value.toLowerCase();
     72     get('import-exports').textContent = selected?.names.filter(n => n.toLowerCase().includes(query)).join('\n') || 'No matching exports.';
     73   }
     74   function browse(): void {
     75     stop(); selected = undefined; plan = undefined; detail.hidden = true; get('import-actions').hidden = true; get('import-content').scrollTop = 0; get('import-browse').hidden = false; results.hidden = false; message(''); results.replaceChildren();
     76     const choices: Choice[] = [
     77       ...options.local().map(row => ({ title: row.label, description: 'Local file', manifest: row.manifest, names: row.names, alias: 'Local' })),
     78       ...latestLibraries(libraries ?? []).map(row => ({ title: row.title, description: row.description, manifest: row.manifest, names: row.names, alias: row.alias, library: row })),
     79     ].filter(row => matchesLibrary(row, search.value));
     80     for (const choice of choices) {
     81       const button = document.createElement('button'); button.type = 'button'; button.dataset['manifest'] = choice.manifest;
     82       const title = document.createElement('strong'); title.textContent = choice.title + (choice.library ? ' · ' + choice.library.version : '');
     83       const description = document.createElement('span'); description.textContent = choice.description;
     84       const label = document.createElement('small');
     85       const match = choice.names.filter(n => search.value.trim() && n.toLowerCase().includes(search.value.toLowerCase().trim())).slice(0, 5);
     86       label.textContent = `${choice.library ? 'Published library' : 'Local file'} · ${choice.names.length} exports${match.length ? ' · ' + match.join(', ') : ''}`;
     87       button.append(title, description, label); button.onclick = () => show(choice); results.append(button);
     88     }
     89     if (!choices.length) results.textContent = 'No matching libraries. Try an exported name or a broader search.';
     90   }
     91   open.onclick = () => {
     92     if (!options.available()) return;
     93     snapshot = options.source.value; backend = options.backend(); search.value = ''; dialog.showModal(); browse(); search.focus();
     94     if (!libraries) {
     95       message('Loading library references…');
     96       void fetchBytes(new URL('../reference/libraries.json', import.meta.url), 2_097_152).then(bytes => {
     97         libraries = libraryIndex(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)) as unknown);
     98         if (dialog.open && !selected) browse();
     99       }).catch(error => { if (dialog.open) message(`Library references could not be loaded: ${String(error)}. Close and reopen to retry. Local modules remain available.`); });
    100     }
    101   };
    102   search.oninput = browse; alias.oninput = check; get<HTMLInputElement>('import-export-search').oninput = renderExports;
    103   versions.onchange = () => { const row = libraries?.find(r => r.name === selected?.library?.name && r.version === versions.value); if (row) show({ title: row.title, description: row.description, manifest: row.manifest, names: row.names, alias: alias.value, library: row }, false); };
    104   get('import-back').onclick = () => { browse(); search.focus(); };
    105   get('import-cancel').onclick = finish;
    106   dialog.addEventListener('cancel', () => stop()); dialog.addEventListener('close', () => { if (!dialog.open) stop(); });
    107   get('copy-import').onclick = () => { void navigator.clipboard.writeText(statement()).then(() => message('Import copied.'), () => message('Select and copy the import preview.')); };
    108   insert.onclick = () => {
    109     if (!plan || !options.available() || options.source.value !== snapshot || options.backend() !== backend) { stop(); insert.disabled = true; message('The editor changed. Close and reopen the picker to use the current source.'); return; }
    110     const checked = plan; finish(); options.source.focus();
    111     if (checked.existing) { options.source.setSelectionRange(checked.existing.start, checked.existing.end); return; }
    112     // Native textarea editing supplies one undo transaction on supported browsers.
    113     options.source.setSelectionRange(0, 0);
    114     if (!document.execCommand('insertText', false, checked.statement)) {
    115       message('Your browser could not insert an undoable edit. Copy the import from the picker.');
    116       dialog.showModal(); return;
    117     }
    118     options.source.setSelectionRange(0, checked.statement.length);
    119   };
    120   return { cancel: () => { if (dialog.open) finish(); } };
    121 }