tricu

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

import-picker.js (10152B)


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