tricu

A lazy functional language based on Tree Calculus
Log | Files | Refs | README | LICENSE

import-picker.ts (12458B)


      1 import type { SourceEditor } from './source-editor.js';
      2 import { loadLibraries, renderLibraryExports } from './library-browser.js';
      3 import { moduleInspector } from './module-inspector.js';
      4 import { latestLibraries, matchesLibrary, type Library } from './library-index.js';
      5 import { packageReleases } from './packages.js';
      6 import type { ImportPlan } from './import-plan.js';
      7 import type { LocalModuleInput } from './local-modules.js';
      8 interface Choice { title: string; description: string; manifest: string; names: string[]; alias: string; library?: Library; definition?: string; }
      9 interface Options {
     10   source: SourceEditor;
     11   backend(): string;
     12   available(): boolean;
     13   editable(): boolean;
     14   tryLibrary(library: Library, definition?: string): void;
     15   local(): { label: string; manifest: string; names: string[] }[];
     16   inputs(): LocalModuleInput[];
     17 }
     18 export function importPicker(options: Options): { cancel(): void } {
     19   const inspector = moduleInspector(options.source, options.local);
     20   const get = <T extends HTMLElement>(id: string): T => document.getElementById(id) as T;
     21   const dialog = get<HTMLDialogElement>('import-dialog'), open = get<HTMLButtonElement>('add-import');
     22   const search = get<HTMLInputElement>('import-search'), results = get('import-results'), detail = get('import-detail');
     23   const alias = get<HTMLInputElement>('import-alias'), versions = get<HTMLSelectElement>('import-version');
     24   const tryButton = get<HTMLButtonElement>('try-library');
     25   const status = get('import-status'), preview = get('import-preview'), insert = get<HTMLButtonElement>('insert-import');
     26   let libraries: Library[] | undefined, selected: Choice | undefined, plan: ImportPlan | undefined;
     27   let worker: Worker | undefined, timer: ReturnType<typeof setTimeout> | undefined, debounce: ReturnType<typeof setTimeout> | undefined;
     28   let revision = 0, snapshot = '', backend = '';
     29   const stop = (): void => { revision++; worker?.terminate(); worker = undefined; clearTimeout(timer); clearTimeout(debounce); };
     30   const finish = (): void => { stop(); dialog.close(); open.focus(); };
     31   const message = (text: string): void => { status.textContent = text; };
     32   const statement = (): string => selected ? `!import ${selected.manifest} as ${alias.value}` : '';
     33   function check(): void {
     34     stop(); plan = undefined; insert.disabled = true; tryButton.disabled = false; insert.textContent = 'Insert import'; preview.textContent = statement();
     35     if (!selected) return;
     36     if (!options.editable()) { message('Browse references, copy an import, or open this library in the Playground.'); return; }
     37     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; }
     38     message('Checking with the native compiler…');
     39     const current = revision;
     40     debounce = setTimeout(() => {
     41       if (!dialog.open || current !== revision) return;
     42       const url = new URL('./wasm-worker.js', import.meta.url); url.searchParams.set('backend', backend);
     43       const next = new Worker(url, { type: 'module' }); worker = next;
     44       const fail = (text: string): void => { if (current !== revision) return; stop(); message(`${text} You can copy the import and finish editing the source.`); };
     45       timer = setTimeout(() => fail('Import check timed out.'), 60_000);
     46       next.onerror = event => { event.preventDefault(); fail(event.message || 'Import check failed.'); };
     47       next.onmessage = ({ data }: MessageEvent<{ type: string; plan?: ImportPlan; message?: string }>) => {
     48         if (current !== revision || !dialog.open) return;
     49         if (data.type === 'error') { fail(data.message ?? 'Import check failed.'); return; }
     50         if (data.type !== 'import-checked' || !data.plan) return;
     51         plan = data.plan; stop(); insert.disabled = false; tryButton.disabled = false;
     52         insert.textContent = plan.existing ? 'Show existing import' : 'Insert import';
     53         message(plan.existing ? `Already imported ${plan.existing.alias === '*' ? 'openly' : 'as ' + plan.existing.alias}.` : 'Ready to insert.');
     54       };
     55       next.postMessage({ type: 'check-import', source: snapshot, manifest: selected!.manifest, alias: alias.value, localModules: options.inputs() });
     56     }, 250);
     57   }
     58   function show(choice: Choice, focus = true): void {
     59     selected = choice; get('import-actions').hidden = false; results.hidden = true; get('import-browse').hidden = true; detail.hidden = false;
     60     get('import-title').textContent = choice.definition ? `${choice.definition} · ${choice.title}` : choice.title; get('import-description').textContent = choice.description;
     61     get('import-usage').replaceChildren(...(choice.library?.usage ?? '').split('`').map((text, i) => {
     62       if (i % 2 === 0) return document.createTextNode(text);
     63       const code = document.createElement('code'); code.textContent = text; return code;
     64     })); get('import-usage-details').hidden = !choice.library?.usage;
     65     get('import-example').textContent = choice.library?.example ?? ''; get('import-example-details').hidden = !choice.library?.example;
     66     (get('import-usage-details') as HTMLDetailsElement).open = false; (get('import-example-details') as HTMLDetailsElement).open = false;
     67     get('import-definition').textContent = choice.definition ? `Definition: ${choice.definition} · from ${choice.title}. Adding an import makes the whole library available.` : 'Explore exports below. Imports make the whole library available.';
     68     tryButton.hidden = options.editable() || !choice.library;
     69     tryButton.textContent = 'Open library';
     70     get<HTMLButtonElement>('copy-qualified').hidden = !choice.definition;
     71     alias.value = choice.alias; versions.replaceChildren(); versions.hidden = !choice.library;
     72     get('import-version-label').hidden = !choice.library;
     73     if (choice.library) for (const release of packageReleases(libraries!, choice.library.name)) {
     74       const option = new Option(release.version, release.version); option.selected = release.version === choice.library.version; versions.add(option);
     75     }
     76     const reference = get<HTMLAnchorElement>('import-reference'); reference.hidden = !choice.library;
     77     if (choice.library) reference.href = new URL('../reference/' + choice.library.reference, import.meta.url).href;
     78     get<HTMLInputElement>('import-export-search').value = ''; renderExports(); check();
     79     if (focus) { get('import-content').scrollTop = 0; get('import-title').focus({ preventScroll: true }); }
     80   }
     81   function renderExports(): void {
     82     const query = get<HTMLInputElement>('import-export-search').value.toLowerCase();
     83     renderLibraryExports(get('import-exports'), selected?.names ?? [], query, name => {
     84       if (selected) show({ ...selected, alias: alias.value, definition: name }, false);
     85     });
     86   }
     87 
     88   function browse(): void {
     89     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();
     90     const choices: Choice[] = [
     91       ...options.local().map(row => ({ title: row.label, description: 'Local file', manifest: row.manifest, names: row.names, alias: 'Local' })),
     92       ...latestLibraries(libraries ?? []).map(row => ({ title: row.title, description: row.description, manifest: row.manifest, names: row.names, alias: row.alias, library: row })),
     93     ].filter(row => matchesLibrary(row, search.value));
     94     const heading = (text: string): void => { const h = document.createElement('h3'); h.textContent = text; results.append(h); };
     95     const addChoice = (choice: Choice): void => {
     96       const button = document.createElement('button'); button.type = 'button'; button.dataset['manifest'] = choice.manifest;
     97       const title = document.createElement('strong'); title.textContent = (choice.definition ? choice.definition + ' · ' : '') + choice.title + (choice.library ? ' · ' + choice.library.version : '');
     98       const description = document.createElement('span'); description.textContent = choice.description;
     99       const label = document.createElement('small');
    100       const match = choice.names.filter(n => search.value.trim() && n.toLowerCase().includes(search.value.toLowerCase().trim())).slice(0, 5);
    101       label.textContent = `${choice.library ? 'Published library' : 'Local file'} · ${choice.names.length} exports${match.length ? ' · ' + match.join(', ') : ''}`;
    102       button.append(title, description, label); button.onclick = () => show(choice); results.append(button);
    103     };
    104     heading('Libraries');
    105     for (const choice of choices) addChoice(choice);
    106     if (search.value.trim()) {
    107       heading('Definitions');
    108       const query = search.value.toLowerCase().trim();
    109       const matches = choices.flatMap(choice => choice.names.filter(name => name.toLowerCase().includes(query)).map(definition => ({ ...choice, definition })));
    110       matches.sort((a, b) => Number(b.definition.toLowerCase() === query) - Number(a.definition.toLowerCase() === query) || a.definition.localeCompare(b.definition));
    111       for (const match of matches.slice(0, 80)) addChoice(match);
    112       if (matches.length > 80) { const note = document.createElement('p'); note.textContent = 'Showing 80 definitions. Refine your search for more specific results.'; results.append(note); }
    113       if (!matches.length) { const note = document.createElement('p'); note.textContent = 'No matching definitions.'; results.append(note); }
    114     }
    115     if (!choices.length) results.textContent = 'No matching libraries. Try an exported name or a broader search.';
    116   }
    117   open.onclick = () => {
    118     if (!options.available()) return;
    119     snapshot = options.source.getSource(); backend = options.backend(); search.value = ''; dialog.showModal(); browse(); search.focus();
    120     if (!libraries) {
    121       message('Loading library references…');
    122       void loadLibraries().then(rows => {
    123         libraries = rows;
    124         if (dialog.open && !selected) browse();
    125       }).catch(error => { if (dialog.open) message(`Library references could not be loaded: ${String(error)}. Close and reopen to retry. Local modules remain available.`); });
    126     }
    127   };
    128   search.oninput = browse; alias.oninput = check; get<HTMLInputElement>('import-export-search').oninput = renderExports;
    129   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, ...(selected?.definition && row.names.includes(selected.definition) ? { definition: selected.definition } : {}) }, false); };
    130   get('import-back').onclick = () => { browse(); search.focus(); };
    131   get('import-cancel').onclick = finish;
    132   dialog.addEventListener('cancel', () => stop()); dialog.addEventListener('close', () => { if (!dialog.open) stop(); });
    133   tryButton.onclick = () => {
    134     if (selected?.library) { const choice = selected; finish(); options.tryLibrary(choice.library!, choice.definition); }
    135   };
    136   const qualifiedName = (name: string): string => {
    137     const namespace = plan?.existing?.alias ?? alias.value;
    138     return namespace === '*' ? name : `${namespace}.${name}`;
    139   };
    140   get('copy-qualified').onclick = () => {
    141     if (!selected?.definition) return;
    142     void navigator.clipboard.writeText(qualifiedName(selected.definition)).then(() => message('Qualified name copied.'), () => message('Clipboard unavailable. Use the namespace and definition shown above.'));
    143   };
    144   get('copy-import').onclick = () => { void navigator.clipboard.writeText(statement()).then(() => message('Import copied.'), () => message('Select and copy the import preview.')); };
    145   function applyPlan(): void {
    146     if (!plan || !options.available() || options.source.getSource() !== snapshot || options.backend() !== backend) { stop(); insert.disabled = true; message('The editor changed. Close and reopen the picker to use the current source.'); return; }
    147     const checked = plan;
    148     finish(); options.source.focus();
    149     if (checked.existing) { options.source.select(checked.existing.start, checked.existing.end); return; }
    150     options.source.edit(0,0,checked.statement);
    151     options.source.select(0,checked.statement.length);
    152 
    153   }
    154   insert.onclick = () => applyPlan();
    155   return { cancel: () => { inspector.hide(); if (dialog.open) finish(); } };
    156 }