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 }