import-picker.js (12846B)
1 import { loadLibraries, renderLibraryExports } from './library-browser.js'; 2 import { moduleInspector } from './module-inspector.js'; 3 import { latestLibraries, matchesLibrary } from './library-index.js'; 4 import { packageReleases } from './packages.js'; 5 export function importPicker(options) { 6 const inspector = moduleInspector(options.source, options.local); 7 const get = (id) => document.getElementById(id); 8 const dialog = get('import-dialog'), open = get('add-import'); 9 const search = get('import-search'), results = get('import-results'), detail = get('import-detail'); 10 const alias = get('import-alias'), versions = get('import-version'); 11 const tryButton = get('try-library'); 12 const status = get('import-status'), preview = get('import-preview'), insert = get('insert-import'); 13 let libraries, selected, plan; 14 let worker, timer, debounce; 15 let revision = 0, snapshot = '', backend = ''; 16 const stop = () => { revision++; worker?.terminate(); worker = undefined; clearTimeout(timer); clearTimeout(debounce); }; 17 const finish = () => { stop(); dialog.close(); open.focus(); }; 18 const message = (text) => { status.textContent = text; }; 19 const statement = () => selected ? `!import ${selected.manifest} as ${alias.value}` : ''; 20 function check() { 21 stop(); 22 plan = undefined; 23 insert.disabled = true; 24 tryButton.disabled = false; 25 insert.textContent = 'Insert import'; 26 preview.textContent = statement(); 27 if (!selected) 28 return; 29 if (!options.editable()) { 30 message('Browse references, copy an import, or open this library in the Playground.'); 31 return; 32 } 33 if (!/^[A-Za-z_][A-Za-z0-9_]{0,127}$/u.test(alias.value)) { 34 message('Choose a namespace using letters, digits and underscores, starting with a letter or underscore.'); 35 return; 36 } 37 message('Checking with the native compiler…'); 38 const current = revision; 39 debounce = setTimeout(() => { 40 if (!dialog.open || current !== revision) 41 return; 42 const url = new URL('./wasm-worker.js', import.meta.url); 43 url.searchParams.set('backend', backend); 44 const next = new Worker(url, { type: 'module' }); 45 worker = next; 46 const fail = (text) => { if (current !== revision) 47 return; stop(); message(`${text} You can copy the import and finish editing the source.`); }; 48 timer = setTimeout(() => fail('Import check timed out.'), 60_000); 49 next.onerror = event => { event.preventDefault(); fail(event.message || 'Import check failed.'); }; 50 next.onmessage = ({ data }) => { 51 if (current !== revision || !dialog.open) 52 return; 53 if (data.type === 'error') { 54 fail(data.message ?? 'Import check failed.'); 55 return; 56 } 57 if (data.type !== 'import-checked' || !data.plan) 58 return; 59 plan = data.plan; 60 stop(); 61 insert.disabled = false; 62 tryButton.disabled = false; 63 insert.textContent = plan.existing ? 'Show existing import' : 'Insert import'; 64 message(plan.existing ? `Already imported ${plan.existing.alias === '*' ? 'openly' : 'as ' + plan.existing.alias}.` : 'Ready to insert.'); 65 }; 66 next.postMessage({ type: 'check-import', source: snapshot, manifest: selected.manifest, alias: alias.value, localModules: options.inputs() }); 67 }, 250); 68 } 69 function show(choice, focus = true) { 70 selected = choice; 71 get('import-actions').hidden = false; 72 results.hidden = true; 73 get('import-browse').hidden = true; 74 detail.hidden = false; 75 get('import-title').textContent = choice.definition ? `${choice.definition} · ${choice.title}` : choice.title; 76 get('import-description').textContent = choice.description; 77 get('import-usage').replaceChildren(...(choice.library?.usage ?? '').split('`').map((text, i) => { 78 if (i % 2 === 0) 79 return document.createTextNode(text); 80 const code = document.createElement('code'); 81 code.textContent = text; 82 return code; 83 })); 84 get('import-usage-details').hidden = !choice.library?.usage; 85 get('import-example').textContent = choice.library?.example ?? ''; 86 get('import-example-details').hidden = !choice.library?.example; 87 get('import-usage-details').open = false; 88 get('import-example-details').open = false; 89 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.'; 90 tryButton.hidden = options.editable() || !choice.library; 91 tryButton.textContent = 'Open library'; 92 get('copy-qualified').hidden = !choice.definition; 93 alias.value = choice.alias; 94 versions.replaceChildren(); 95 versions.hidden = !choice.library; 96 get('import-version-label').hidden = !choice.library; 97 if (choice.library) 98 for (const release of packageReleases(libraries, choice.library.name)) { 99 const option = new Option(release.version, release.version); 100 option.selected = release.version === choice.library.version; 101 versions.add(option); 102 } 103 const reference = get('import-reference'); 104 reference.hidden = !choice.library; 105 if (choice.library) 106 reference.href = new URL('../reference/' + choice.library.reference, import.meta.url).href; 107 get('import-export-search').value = ''; 108 renderExports(); 109 check(); 110 if (focus) { 111 get('import-content').scrollTop = 0; 112 get('import-title').focus({ preventScroll: true }); 113 } 114 } 115 function renderExports() { 116 const query = get('import-export-search').value.toLowerCase(); 117 renderLibraryExports(get('import-exports'), selected?.names ?? [], query, name => { 118 if (selected) 119 show({ ...selected, alias: alias.value, definition: name }, false); 120 }); 121 } 122 function browse() { 123 stop(); 124 selected = undefined; 125 plan = undefined; 126 detail.hidden = true; 127 get('import-actions').hidden = true; 128 get('import-content').scrollTop = 0; 129 get('import-browse').hidden = false; 130 results.hidden = false; 131 message(''); 132 results.replaceChildren(); 133 const choices = [ 134 ...options.local().map(row => ({ title: row.label, description: 'Local file', manifest: row.manifest, names: row.names, alias: 'Local' })), 135 ...latestLibraries(libraries ?? []).map(row => ({ title: row.title, description: row.description, manifest: row.manifest, names: row.names, alias: row.alias, library: row })), 136 ].filter(row => matchesLibrary(row, search.value)); 137 const heading = (text) => { const h = document.createElement('h3'); h.textContent = text; results.append(h); }; 138 const addChoice = (choice) => { 139 const button = document.createElement('button'); 140 button.type = 'button'; 141 button.dataset['manifest'] = choice.manifest; 142 const title = document.createElement('strong'); 143 title.textContent = (choice.definition ? choice.definition + ' · ' : '') + choice.title + (choice.library ? ' · ' + choice.library.version : ''); 144 const description = document.createElement('span'); 145 description.textContent = choice.description; 146 const label = document.createElement('small'); 147 const match = choice.names.filter(n => search.value.trim() && n.toLowerCase().includes(search.value.toLowerCase().trim())).slice(0, 5); 148 label.textContent = `${choice.library ? 'Published library' : 'Local file'} · ${choice.names.length} exports${match.length ? ' · ' + match.join(', ') : ''}`; 149 button.append(title, description, label); 150 button.onclick = () => show(choice); 151 results.append(button); 152 }; 153 heading('Libraries'); 154 for (const choice of choices) 155 addChoice(choice); 156 if (search.value.trim()) { 157 heading('Definitions'); 158 const query = search.value.toLowerCase().trim(); 159 const matches = choices.flatMap(choice => choice.names.filter(name => name.toLowerCase().includes(query)).map(definition => ({ ...choice, definition }))); 160 matches.sort((a, b) => Number(b.definition.toLowerCase() === query) - Number(a.definition.toLowerCase() === query) || a.definition.localeCompare(b.definition)); 161 for (const match of matches.slice(0, 80)) 162 addChoice(match); 163 if (matches.length > 80) { 164 const note = document.createElement('p'); 165 note.textContent = 'Showing 80 definitions. Refine your search for more specific results.'; 166 results.append(note); 167 } 168 if (!matches.length) { 169 const note = document.createElement('p'); 170 note.textContent = 'No matching definitions.'; 171 results.append(note); 172 } 173 } 174 if (!choices.length) 175 results.textContent = 'No matching libraries. Try an exported name or a broader search.'; 176 } 177 open.onclick = () => { 178 if (!options.available()) 179 return; 180 snapshot = options.source.getSource(); 181 backend = options.backend(); 182 search.value = ''; 183 dialog.showModal(); 184 browse(); 185 search.focus(); 186 if (!libraries) { 187 message('Loading library references…'); 188 void loadLibraries().then(rows => { 189 libraries = rows; 190 if (dialog.open && !selected) 191 browse(); 192 }).catch(error => { if (dialog.open) 193 message(`Library references could not be loaded: ${String(error)}. Close and reopen to retry. Local modules remain available.`); }); 194 } 195 }; 196 search.oninput = browse; 197 alias.oninput = check; 198 get('import-export-search').oninput = renderExports; 199 versions.onchange = () => { const row = libraries?.find(r => r.name === selected?.library?.name && r.version === versions.value); if (row) 200 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); }; 201 get('import-back').onclick = () => { browse(); search.focus(); }; 202 get('import-cancel').onclick = finish; 203 dialog.addEventListener('cancel', () => stop()); 204 dialog.addEventListener('close', () => { if (!dialog.open) 205 stop(); }); 206 tryButton.onclick = () => { 207 if (selected?.library) { 208 const choice = selected; 209 finish(); 210 options.tryLibrary(choice.library, choice.definition); 211 } 212 }; 213 const qualifiedName = (name) => { 214 const namespace = plan?.existing?.alias ?? alias.value; 215 return namespace === '*' ? name : `${namespace}.${name}`; 216 }; 217 get('copy-qualified').onclick = () => { 218 if (!selected?.definition) 219 return; 220 void navigator.clipboard.writeText(qualifiedName(selected.definition)).then(() => message('Qualified name copied.'), () => message('Clipboard unavailable. Use the namespace and definition shown above.')); 221 }; 222 get('copy-import').onclick = () => { void navigator.clipboard.writeText(statement()).then(() => message('Import copied.'), () => message('Select and copy the import preview.')); }; 223 function applyPlan() { 224 if (!plan || !options.available() || options.source.getSource() !== snapshot || options.backend() !== backend) { 225 stop(); 226 insert.disabled = true; 227 message('The editor changed. Close and reopen the picker to use the current source.'); 228 return; 229 } 230 const checked = plan; 231 finish(); 232 options.source.focus(); 233 if (checked.existing) { 234 options.source.select(checked.existing.start, checked.existing.end); 235 return; 236 } 237 options.source.edit(0, 0, checked.statement); 238 options.source.select(0, checked.statement.length); 239 } 240 insert.onclick = () => applyPlan(); 241 return { cancel: () => { inspector.hide(); if (dialog.open) 242 finish(); } }; 243 }