commit eb0509a086b697d572f2a0b1444a31f1bd6b0551
parent 47e80a01d8bdaa2c6b86d3d91fe215a863e889df
Author: James Eversole <james@eversole.co>
Date: Mon, 26 Oct 2026 19:31:08 -0500
Make import folding cursor-driven and add inline module browsing
Diffstat:
16 files changed, 365 insertions(+), 172 deletions(-)
diff --git a/ext/demo-site/README.md b/ext/demo-site/README.md
@@ -73,9 +73,11 @@ input limit with diagnostics.
Import **hashes** display their first three and last three characters with literal
dots (`abc...xyz`), in muted teal with a dotted underline. Editing an import line
-reveals its full hash; a range selection reveals every affected line, leaving
-unrelated imports folded. Each hash also has an independent keyboard-accessible
-expand/collapse control. Copying, saving and compilation use complete canonical
+reveals its full hash. Only cursor lines (the head of each selection) expand,
+including when focus moves outside the editor. Clicking a folded hash places the
+cursor at its end. Clicking an expanded hash opens an inline, searchable list of
+the exact module’s definitions, using the same library index and export rendering
+as library search. Local modules use their registered exports. Copying, saving and compilation use complete canonical
source; folding and selection preserve the document and undo history.
The pinned editor sources, fixed-output hashes, licenses and declaration build
diff --git a/ext/demo-site/dist/import-folds.js b/ext/demo-site/dist/import-folds.js
@@ -1,6 +1,6 @@
// Hash decorations project canonical UTF-16 document ranges. Editing a revealed
// hash uses ordinary character-level selection and deletion.
-import { StateField, StateEffect, Transaction, EditorSelection, EditorView, Decoration, WidgetType } from './codemirror.js';
+import { StateField, EditorSelection, EditorView, Decoration, WidgetType } from './codemirror.js';
import { tokenize } from './syntax.js';
const cachedSites = new WeakMap();
export function importSites(state) {
@@ -21,89 +21,40 @@ export function importSites(state) {
cachedSites.set(state.doc, sites);
return sites;
}
-export const importFocus = StateEffect.define();
-const toggle = StateEffect.define();
const short = (hash) => hash.slice(0, 3) + '...' + hash.slice(-3);
class ImportHash extends WidgetType {
site;
- expanded;
- constructor(site, expanded) {
+ constructor(site) {
super();
this.site = site;
- this.expanded = expanded;
}
- eq(other) { return this.site.from === other.site.from && this.site.hash === other.site.hash && this.expanded === other.expanded; }
+ eq(other) { return this.site.from === other.site.from && this.site.hash === other.site.hash; }
toDOM(view) {
const button = document.createElement('button');
button.type = 'button';
button.className = 'import-hash';
- button.textContent = this.expanded ? this.site.hash : short(this.site.hash);
- button.dataset.hashFrom = String(this.site.from);
- button.setAttribute('aria-expanded', String(this.expanded));
- button.setAttribute('aria-label', `${this.expanded ? 'Collapse' : 'Expand'} import hash ${this.site.hash}`);
+ button.textContent = short(this.site.hash);
+ button.setAttribute('aria-label', `Place cursor at import ${this.site.hash}`);
button.onclick = () => {
- view.dispatch({ effects: toggle.of(this.site.from), annotations: Transaction.addToHistory.of(false) });
- view.dom.querySelector(`[data-hash-from="${this.site.from}"]`)?.focus({ preventScroll: true });
- };
- // Ctrl+A on a hash control selects the complete canonical program.
- button.onkeydown = event => {
- if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'a') {
- event.preventDefault();
- view.dispatch({ selection: EditorSelection.single(0, view.state.doc.length), effects: importFocus.of(true) });
- view.focus();
- }
+ view.dispatch({ selection: EditorSelection.single(this.site.to) });
+ view.focus();
};
return button;
}
ignoreEvent() { return true; }
}
-export function selectedImportLines(state, active) {
- const lines = new Set();
- for (const range of state.selection.ranges) {
- if (!active && range.empty)
- continue;
- const from = state.doc.lineAt(range.from).number, to = state.doc.lineAt(range.empty ? range.to : Math.max(range.from, range.to - 1)).number;
- for (let line = from; line <= to; line++)
- lines.add(line);
- }
- return lines;
+export function selectedImportLines(state) {
+ return new Set(state.selection.ranges.map(range => state.doc.lineAt(range.head).number));
}
-function decorate(state, open, active) {
- const sites = importSites(state), selected = selectedImportLines(state, active), ranges = [];
- for (const site of sites)
- ranges.push((selected.has(site.line) ? Decoration.mark({ class: 'import-hash-full' }) :
- Decoration.replace({ widget: new ImportHash(site, open.has(site.from)) })).range(site.from, site.to));
- return { open, active, sites, decorations: Decoration.set(ranges, true) };
+function decorate(state) {
+ const selected = selectedImportLines(state), ranges = [];
+ for (const site of importSites(state))
+ ranges.push((selected.has(site.line) ? Decoration.mark({ class: 'import-hash-full', attributes: { 'data-import-hash': site.hash, role: 'button', tabindex: '0', 'aria-label': 'Browse imported module ' + site.hash } }) :
+ Decoration.replace({ widget: new ImportHash(site) })).range(site.from, site.to));
+ return Decoration.set(ranges, true);
}
export const importFolds = StateField.define({
- create: state => decorate(state, new Set(), false),
- update: (old, tr) => {
- let active = old.active;
- const open = tr.docChanged ? new Set(old.sites.filter(site => old.open.has(site.from) && !tr.changes.touchesRange(site.from, site.to))
- .map(site => tr.changes.mapPos(site.from, 1))) : new Set(old.open);
- if (tr.selection && tr.isUserEvent('select'))
- active = true;
- for (const effect of tr.effects) {
- if (effect.is(toggle)) {
- if (open.has(effect.value))
- open.delete(effect.value);
- else
- open.add(effect.value);
- }
- if (effect.is(importFocus))
- active = effect.value;
- }
- if (!tr.docChanged && !tr.selection && active === old.active && open.size === old.open.size && [...open].every(pos => old.open.has(pos)))
- return old;
- return decorate(tr.state, open, active);
- },
- provide: field => EditorView.decorations.from(field, value => value.decorations)
+ create: decorate,
+ update: (old, tr) => tr.docChanged || tr.selection ? decorate(tr.state) : old,
+ provide: field => EditorView.decorations.from(field)
});
-export function syncImportFocus(view) {
- queueMicrotask(() => {
- const old = view.state.field(importFolds).active, element = view.dom.ownerDocument.activeElement;
- const active = element === view.contentDOM ? true : view.dom.contains(element) ? old : false;
- if (old !== active)
- view.dispatch({ effects: importFocus.of(active), annotations: Transaction.addToHistory.of(false) });
- });
-}
diff --git a/ext/demo-site/dist/import-picker.js b/ext/demo-site/dist/import-picker.js
@@ -1,7 +1,9 @@
-import { fetchBytes } from './bundle.js';
-import { libraryIndex, latestLibraries, matchesLibrary } from './library-index.js';
+import { loadLibraries, renderLibraryExports } from './library-browser.js';
+import { moduleInspector } from './module-inspector.js';
+import { latestLibraries, matchesLibrary } from './library-index.js';
import { packageReleases } from './packages.js';
export function importPicker(options) {
+ const inspector = moduleInspector(options.source, options.local);
const get = (id) => document.getElementById(id);
const dialog = get('import-dialog'), open = get('add-import');
const search = get('import-search'), results = get('import-results'), detail = get('import-detail');
@@ -112,18 +114,10 @@ export function importPicker(options) {
}
function renderExports() {
const query = get('import-export-search').value.toLowerCase();
- const list = get('import-exports');
- list.replaceChildren();
- for (const name of selected?.names.filter(n => n.toLowerCase().includes(query)) ?? []) {
- const button = document.createElement('button');
- button.type = 'button';
- button.textContent = name;
- button.onclick = () => { if (selected)
- show({ ...selected, alias: alias.value, definition: name }, false); };
- list.append(button);
- }
- if (!list.children.length)
- list.textContent = 'No matching exports.';
+ renderLibraryExports(get('import-exports'), selected?.names ?? [], query, name => {
+ if (selected)
+ show({ ...selected, alias: alias.value, definition: name }, false);
+ });
}
function browse() {
stop();
@@ -191,8 +185,8 @@ export function importPicker(options) {
search.focus();
if (!libraries) {
message('Loading library references…');
- void fetchBytes(new URL('../reference/libraries.json', import.meta.url), 2_097_152).then(bytes => {
- libraries = libraryIndex(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)));
+ void loadLibraries().then(rows => {
+ libraries = rows;
if (dialog.open && !selected)
browse();
}).catch(error => { if (dialog.open)
@@ -244,6 +238,6 @@ export function importPicker(options) {
options.source.select(0, checked.statement.length);
}
insert.onclick = () => applyPlan();
- return { cancel: () => { if (dialog.open)
+ return { cancel: () => { inspector.hide(); if (dialog.open)
finish(); } };
}
diff --git a/ext/demo-site/dist/library-browser.js b/ext/demo-site/dist/library-browser.js
@@ -0,0 +1,20 @@
+import { fetchBytes } from './bundle.js';
+import { libraryIndex } from './library-index.js';
+let pending;
+export function loadLibraries() {
+ return pending ??= fetchBytes(new URL('../reference/libraries.json', import.meta.url), 2_097_152)
+ .then(bytes => libraryIndex(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes))))
+ .catch(error => { pending = undefined; throw error; });
+}
+export function renderLibraryExports(list, names, query, choose) {
+ list.replaceChildren();
+ for (const name of names.filter(name => name.toLowerCase().includes(query.toLowerCase()))) {
+ const button = document.createElement('button');
+ button.type = 'button';
+ button.textContent = name;
+ button.onclick = () => choose(name);
+ list.append(button);
+ }
+ if (!list.children.length)
+ list.textContent = 'No matching exports.';
+}
diff --git a/ext/demo-site/dist/module-inspector.js b/ext/demo-site/dist/module-inspector.js
@@ -0,0 +1,102 @@
+import { importSites } from './import-folds.js';
+import { loadLibraries, renderLibraryExports } from './library-browser.js';
+export function moduleInspector(input, local) {
+ const card = document.createElement('section');
+ card.className = 'definition-card module-card';
+ card.hidden = true;
+ card.setAttribute('aria-label', 'Imported module');
+ const heading = document.createElement('div');
+ heading.className = 'definition-card-controls';
+ const title = document.createElement('strong'), close = document.createElement('button');
+ close.type = 'button';
+ close.textContent = '×';
+ close.className = 'definition-card-close';
+ close.setAttribute('aria-label', 'Close module inspection');
+ const search = document.createElement('input');
+ search.type = 'search';
+ search.placeholder = 'Search definitions';
+ search.setAttribute('aria-label', 'Search module definitions');
+ const list = document.createElement('div');
+ list.className = 'module-exports';
+ const status = document.createElement('p');
+ status.className = 'hint';
+ status.setAttribute('role', 'status');
+ heading.append(title, close);
+ card.append(heading, search, list, status);
+ input.dom.append(card);
+ let selected, names = [], revision = 0;
+ function hide() { revision++; selected = undefined; card.hidden = true; }
+ function position() {
+ if (!selected)
+ return;
+ const site = importSites(input.view.state).find(site => site.from === selected.from && site.hash === selected.hash);
+ if (!site || input.view.state.doc.lineAt(input.view.state.selection.main.head).number !== site.line) {
+ hide();
+ return;
+ }
+ const rect = input.view.coordsAtPos(site.to), height = input.dom.clientHeight;
+ card.style.maxHeight = `${Math.max(80, Math.min(height - 16, innerHeight * .75, 300))}px`;
+ const top = rect ? rect.bottom - input.dom.getBoundingClientRect().top + 4 : 8;
+ card.style.top = `${Math.max(8, Math.min(top, height - card.offsetHeight - 8))}px`;
+ }
+ function render() {
+ renderLibraryExports(list, names, search.value, name => {
+ void navigator.clipboard.writeText(name).then(() => { status.textContent = 'Definition name copied.'; }, () => { status.textContent = name; });
+ });
+ position();
+ }
+ async function show(target) {
+ const hash = target.dataset.importHash;
+ const site = importSites(input.view.state).find(site => site.hash === hash && site.line === input.view.state.doc.lineAt(input.view.state.selection.main.head).number);
+ if (!site)
+ return;
+ const current = ++revision;
+ selected = { hash: site.hash, from: site.from };
+ names = [];
+ search.value = '';
+ list.replaceChildren();
+ title.textContent = 'Imported module';
+ status.textContent = 'Loading module definitions…';
+ card.hidden = false;
+ position();
+ search.focus({ preventScroll: true });
+ try {
+ const nearby = local().find(row => row.manifest === hash);
+ const published = nearby ? undefined : (await loadLibraries()).find(row => row.manifest === hash);
+ if (current !== revision)
+ return;
+ title.textContent = nearby?.label ?? published?.title ?? 'Imported module';
+ names = nearby?.names ?? published?.names ?? [];
+ status.textContent = nearby || published ? `${names.length} definitions · Click a name to copy it.` : 'This module is not in the library index or local modules.';
+ render();
+ }
+ catch (error) {
+ if (current === revision)
+ status.textContent = `Could not load module definitions: ${String(error)}`;
+ }
+ }
+ input.dom.addEventListener('click', event => {
+ const target = event.target.closest('.import-hash-full');
+ if (target && event.detail <= 1)
+ void show(target);
+ });
+ input.dom.addEventListener('keydown', event => {
+ const target = event.target.closest('.import-hash-full');
+ if (target && (event.key === 'Enter' || event.key === ' ')) {
+ event.preventDefault();
+ event.stopPropagation();
+ void show(target);
+ }
+ }, true);
+ function dismiss() { hide(); input.focus(); }
+ close.onclick = dismiss;
+ card.addEventListener('keydown', event => { if (event.key === 'Escape') {
+ event.preventDefault();
+ dismiss();
+ } });
+ search.oninput = render;
+ input.onLayout(position);
+ input.onChange(hide);
+ new ResizeObserver(position).observe(card);
+ return { hide };
+}
diff --git a/ext/demo-site/dist/source-editor.js b/ext/demo-site/dist/source-editor.js
@@ -1,4 +1,4 @@
-import { importFolds, importFocus, importSites, syncImportFocus } from './import-folds.js';
+import { importFolds, importSites } from './import-folds.js';
import { EditorState, StateField, StateEffect, Transaction, EditorSelection, Compartment, EditorView, Decoration, keymap, drawSelection, history, historyKeymap, standardKeymap, isolateHistory, undo, redo, insertNewline } from './codemirror.js';
import { tokenize, bracketPair, definitionSites } from './syntax.js';
// Enter inserts a plain newline, preserving explicit source indentation.
@@ -78,7 +78,6 @@ export class SourceEditor {
definitionState, syntax, importFolds, history(), drawSelection(), EditorState.tabSize.of(2),
keymap.of([{ key: 'Enter', run: plainNewline, shift: plainNewline }, ...historyKeymap, ...standardKeymap]), EditorView.lineWrapping,
this.availability.of([EditorState.readOnly.of(false), EditorView.editable.of(true)]),
- EditorView.domEventHandlers({ focus: (_event, view) => syncImportFocus(view), blur: (_event, view) => syncImportFocus(view) }),
EditorView.contentAttributes.of({ 'aria-label': 'tricu source (up to 16,384 characters)', spellcheck: 'false', autocorrect: 'off', autocapitalize: 'off' }),
EditorView.updateListener.of(update => {
if (update.docChanged)
@@ -133,7 +132,7 @@ export class SourceEditor {
this.view.dispatch({ effects: EditorView.scrollIntoView(site.start, { y: 'center' }) });
return true;
}
- select(from, to = from) { this.view.dispatch({ selection: EditorSelection.single(from, to), effects: importFocus.of(true), scrollIntoView: true }); this.focus(); }
+ select(from, to = from) { this.view.dispatch({ selection: EditorSelection.single(from, to), scrollIntoView: true }); this.focus(); }
// Loading/restoring documents retires old history; import insertion is a
// separate, isolated undoable edit. Notification is explicit for controller edits.
replaceSource(source, notify = false) {
diff --git a/ext/demo-site/src/import-folds.ts b/ext/demo-site/src/import-folds.ts
@@ -1,6 +1,6 @@
// Hash decorations project canonical UTF-16 document ranges. Editing a revealed
// hash uses ordinary character-level selection and deletion.
-import {EditorState, StateField, StateEffect, Transaction, EditorSelection, EditorView, Decoration, WidgetType, type Range} from './codemirror.js';
+import {EditorState, StateField, EditorSelection, EditorView, Decoration, WidgetType, type Range} from './codemirror.js';
import {tokenize} from './syntax.js';
export interface ImportSite {from:number;to:number;hash:string;line:number}
const cachedSites=new WeakMap<EditorState['doc'],ImportSite[]>();
@@ -17,67 +17,33 @@ export function importSites(state:EditorState):ImportSite[] {
});
cachedSites.set(state.doc,sites);return sites;
}
-export const importFocus=StateEffect.define<boolean>();
-const toggle=StateEffect.define<number>();
const short=(hash:string)=>hash.slice(0,3)+'...'+hash.slice(-3);
class ImportHash extends WidgetType {
- constructor(readonly site:ImportSite,readonly expanded:boolean){super();}
- eq(other:ImportHash):boolean{return this.site.from===other.site.from&&this.site.hash===other.site.hash&&this.expanded===other.expanded;}
+ constructor(readonly site:ImportSite){super();}
+ eq(other:ImportHash):boolean{return this.site.from===other.site.from&&this.site.hash===other.site.hash;}
toDOM(view:EditorView):HTMLElement {
const button=document.createElement('button');button.type='button';button.className='import-hash';
- button.textContent=this.expanded?this.site.hash:short(this.site.hash);button.dataset.hashFrom=String(this.site.from);
- button.setAttribute('aria-expanded',String(this.expanded));
- button.setAttribute('aria-label',`${this.expanded?'Collapse':'Expand'} import hash ${this.site.hash}`);
+ button.textContent=short(this.site.hash);
+ button.setAttribute('aria-label',`Place cursor at import ${this.site.hash}`);
button.onclick=()=>{
- view.dispatch({effects:toggle.of(this.site.from),annotations:Transaction.addToHistory.of(false)});
- view.dom.querySelector<HTMLElement>(`[data-hash-from="${this.site.from}"]`)?.focus({preventScroll:true});
- };
- // Ctrl+A on a hash control selects the complete canonical program.
- button.onkeydown=event=>{
- if((event.ctrlKey||event.metaKey)&&event.key.toLowerCase()==='a'){
- event.preventDefault();view.dispatch({selection:EditorSelection.single(0,view.state.doc.length),effects:importFocus.of(true)});view.focus();
- }
+ view.dispatch({selection:EditorSelection.single(this.site.to)});
+ view.focus();
};
return button;
}
ignoreEvent():boolean{return true;}
}
-interface Folds {open:Set<number>;active:boolean;sites:ImportSite[];decorations:ReturnType<typeof Decoration.set>}
-export function selectedImportLines(state:EditorState,active:boolean):Set<number> {
- const lines=new Set<number>();
- for(const range of state.selection.ranges){
- if(!active&&range.empty)continue;
- const from=state.doc.lineAt(range.from).number,to=state.doc.lineAt(range.empty?range.to:Math.max(range.from,range.to-1)).number;
- for(let line=from;line<=to;line++)lines.add(line);
- }
- return lines;
+export function selectedImportLines(state:EditorState):Set<number> {
+ return new Set(state.selection.ranges.map(range=>state.doc.lineAt(range.head).number));
}
-function decorate(state:EditorState,open:Set<number>,active:boolean):Folds {
- const sites=importSites(state),selected=selectedImportLines(state,active),ranges:Range<Decoration>[]=[];
- for(const site of sites)ranges.push((selected.has(site.line)?Decoration.mark({class:'import-hash-full'}):
- Decoration.replace({widget:new ImportHash(site,open.has(site.from))})).range(site.from,site.to));
- return {open,active,sites,decorations:Decoration.set(ranges,true)};
+function decorate(state:EditorState):ReturnType<typeof Decoration.set> {
+ const selected=selectedImportLines(state),ranges:Range<Decoration>[]=[];
+ for(const site of importSites(state))ranges.push((selected.has(site.line)?Decoration.mark({class:'import-hash-full',attributes:{'data-import-hash':site.hash,role:'button',tabindex:'0','aria-label':'Browse imported module '+site.hash}}):
+ Decoration.replace({widget:new ImportHash(site)})).range(site.from,site.to));
+ return Decoration.set(ranges,true);
}
-export const importFolds=StateField.define<Folds>({
- create:state=>decorate(state,new Set(),false),
- update:(old,tr)=>{
- let active=old.active;
- const open=tr.docChanged?new Set(old.sites.filter(site=>old.open.has(site.from)&&!tr.changes.touchesRange(site.from,site.to))
- .map(site=>tr.changes.mapPos(site.from,1))):new Set(old.open);
- if(tr.selection&&tr.isUserEvent('select'))active=true;
- for(const effect of tr.effects){
- if(effect.is(toggle)){if(open.has(effect.value))open.delete(effect.value);else open.add(effect.value);}
- if(effect.is(importFocus))active=effect.value;
- }
- if(!tr.docChanged&&!tr.selection&&active===old.active&&open.size===old.open.size&&[...open].every(pos=>old.open.has(pos)))return old;
- return decorate(tr.state,open,active);
- },
- provide:field=>EditorView.decorations.from(field,value=>value.decorations)
+export const importFolds=StateField.define<ReturnType<typeof Decoration.set>>({
+ create:decorate,
+ update:(old,tr)=>tr.docChanged||tr.selection?decorate(tr.state):old,
+ provide:field=>EditorView.decorations.from(field)
});
-export function syncImportFocus(view:EditorView):void {
- queueMicrotask(()=>{
- const old=view.state.field(importFolds).active,element=view.dom.ownerDocument.activeElement;
- const active=element===view.contentDOM?true:view.dom.contains(element)?old:false;
- if(old!==active)view.dispatch({effects:importFocus.of(active),annotations:Transaction.addToHistory.of(false)});
- });
-}
diff --git a/ext/demo-site/src/import-picker.ts b/ext/demo-site/src/import-picker.ts
@@ -1,6 +1,7 @@
import type { SourceEditor } from './source-editor.js';
-import { fetchBytes } from './bundle.js';
-import { libraryIndex, latestLibraries, matchesLibrary, type Library } from './library-index.js';
+import { loadLibraries, renderLibraryExports } from './library-browser.js';
+import { moduleInspector } from './module-inspector.js';
+import { latestLibraries, matchesLibrary, type Library } from './library-index.js';
import { packageReleases } from './packages.js';
import type { ImportPlan } from './import-plan.js';
import type { LocalModuleInput } from './local-modules.js';
@@ -15,6 +16,7 @@ interface Options {
inputs(): LocalModuleInput[];
}
export function importPicker(options: Options): { cancel(): void } {
+ const inspector = moduleInspector(options.source, options.local);
const get = <T extends HTMLElement>(id: string): T => document.getElementById(id) as T;
const dialog = get<HTMLDialogElement>('import-dialog'), open = get<HTMLButtonElement>('add-import');
const search = get<HTMLInputElement>('import-search'), results = get('import-results'), detail = get('import-detail');
@@ -78,14 +80,11 @@ export function importPicker(options: Options): { cancel(): void } {
}
function renderExports(): void {
const query = get<HTMLInputElement>('import-export-search').value.toLowerCase();
- const list = get('import-exports'); list.replaceChildren();
- for (const name of selected?.names.filter(n => n.toLowerCase().includes(query)) ?? []) {
- const button = document.createElement('button'); button.type = 'button'; button.textContent = name;
- button.onclick = () => { if (selected) show({ ...selected, alias: alias.value, definition: name }, false); };
- list.append(button);
- }
- if (!list.children.length) list.textContent = 'No matching exports.';
+ renderLibraryExports(get('import-exports'), selected?.names ?? [], query, name => {
+ if (selected) show({ ...selected, alias: alias.value, definition: name }, false);
+ });
}
+
function browse(): void {
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();
const choices: Choice[] = [
@@ -120,8 +119,8 @@ export function importPicker(options: Options): { cancel(): void } {
snapshot = options.source.getSource(); backend = options.backend(); search.value = ''; dialog.showModal(); browse(); search.focus();
if (!libraries) {
message('Loading library references…');
- void fetchBytes(new URL('../reference/libraries.json', import.meta.url), 2_097_152).then(bytes => {
- libraries = libraryIndex(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)) as unknown);
+ void loadLibraries().then(rows => {
+ libraries = rows;
if (dialog.open && !selected) browse();
}).catch(error => { if (dialog.open) message(`Library references could not be loaded: ${String(error)}. Close and reopen to retry. Local modules remain available.`); });
}
@@ -153,5 +152,5 @@ export function importPicker(options: Options): { cancel(): void } {
}
insert.onclick = () => applyPlan();
- return { cancel: () => { if (dialog.open) finish(); } };
+ return { cancel: () => { inspector.hide(); if (dialog.open) finish(); } };
}
diff --git a/ext/demo-site/src/library-browser.ts b/ext/demo-site/src/library-browser.ts
@@ -0,0 +1,16 @@
+import { fetchBytes } from './bundle.js';
+import { libraryIndex, type Library } from './library-index.js';
+let pending: Promise<Library[]> | undefined;
+export function loadLibraries(): Promise<Library[]> {
+ return pending ??= fetchBytes(new URL('../reference/libraries.json', import.meta.url), 2_097_152)
+ .then(bytes => libraryIndex(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)) as unknown))
+ .catch(error => { pending = undefined; throw error; });
+}
+export function renderLibraryExports(list: HTMLElement, names: string[], query: string, choose: (name: string) => void): void {
+ list.replaceChildren();
+ for (const name of names.filter(name => name.toLowerCase().includes(query.toLowerCase()))) {
+ const button = document.createElement('button'); button.type = 'button'; button.textContent = name;
+ button.onclick = () => choose(name); list.append(button);
+ }
+ if (!list.children.length) list.textContent = 'No matching exports.';
+}
diff --git a/ext/demo-site/src/module-inspector.ts b/ext/demo-site/src/module-inspector.ts
@@ -0,0 +1,63 @@
+import type { SourceEditor } from './source-editor.js';
+import { importSites } from './import-folds.js';
+import { loadLibraries, renderLibraryExports } from './library-browser.js';
+
+export function moduleInspector(input: SourceEditor, local: () => { label: string; manifest: string; names: string[] }[]) {
+ const card = document.createElement('section'); card.className = 'definition-card module-card'; card.hidden = true;
+ card.setAttribute('aria-label', 'Imported module');
+ const heading = document.createElement('div'); heading.className = 'definition-card-controls';
+ const title = document.createElement('strong'), close = document.createElement('button');
+ close.type = 'button'; close.textContent = '×'; close.className = 'definition-card-close'; close.setAttribute('aria-label', 'Close module inspection');
+ const search = document.createElement('input'); search.type = 'search'; search.placeholder = 'Search definitions'; search.setAttribute('aria-label', 'Search module definitions');
+ const list = document.createElement('div'); list.className = 'module-exports';
+ const status = document.createElement('p'); status.className = 'hint'; status.setAttribute('role', 'status');
+ heading.append(title, close); card.append(heading, search, list, status); input.dom.append(card);
+ let selected: {hash: string; from: number} | undefined, names: string[] = [], revision = 0;
+ function hide(): void { revision++; selected = undefined; card.hidden = true; }
+ function position(): void {
+ if (!selected) return;
+ const site = importSites(input.view.state).find(site => site.from === selected!.from && site.hash === selected!.hash);
+ if (!site || input.view.state.doc.lineAt(input.view.state.selection.main.head).number !== site.line) { hide(); return; }
+ const rect = input.view.coordsAtPos(site.to), height = input.dom.clientHeight;
+ card.style.maxHeight = `${Math.max(80, Math.min(height - 16, innerHeight * .75, 300))}px`;
+ const top = rect ? rect.bottom - input.dom.getBoundingClientRect().top + 4 : 8;
+ card.style.top = `${Math.max(8, Math.min(top, height - card.offsetHeight - 8))}px`;
+ }
+ function render(): void {
+ renderLibraryExports(list, names, search.value, name => {
+ void navigator.clipboard.writeText(name).then(() => { status.textContent = 'Definition name copied.'; }, () => { status.textContent = name; });
+ });
+ position();
+ }
+ async function show(target: HTMLElement): Promise<void> {
+ const hash = target.dataset.importHash;
+ const site = importSites(input.view.state).find(site => site.hash === hash && site.line === input.view.state.doc.lineAt(input.view.state.selection.main.head).number);
+ if (!site) return;
+ const current = ++revision;
+ selected = { hash: site.hash, from: site.from }; names = []; search.value = ''; list.replaceChildren();
+ title.textContent = 'Imported module'; status.textContent = 'Loading module definitions…'; card.hidden = false; position(); search.focus({preventScroll:true});
+ try {
+ const nearby = local().find(row => row.manifest === hash);
+ const published = nearby ? undefined : (await loadLibraries()).find(row => row.manifest === hash);
+ if (current !== revision) return;
+ title.textContent = nearby?.label ?? published?.title ?? 'Imported module';
+ names = nearby?.names ?? published?.names ?? [];
+ status.textContent = nearby || published ? `${names.length} definitions · Click a name to copy it.` : 'This module is not in the library index or local modules.';
+ render();
+ } catch (error) { if (current === revision) status.textContent = `Could not load module definitions: ${String(error)}`; }
+ }
+ input.dom.addEventListener('click', event => {
+ const target = (event.target as Element).closest<HTMLElement>('.import-hash-full');
+ if (target && event.detail <= 1) void show(target);
+ });
+ input.dom.addEventListener('keydown', event => {
+ const target = (event.target as Element).closest<HTMLElement>('.import-hash-full');
+ if (target && (event.key === 'Enter' || event.key === ' ')) { event.preventDefault(); event.stopPropagation(); void show(target); }
+ }, true);
+ function dismiss(): void { hide(); input.focus(); }
+ close.onclick = dismiss;
+ card.addEventListener('keydown', event => { if (event.key === 'Escape') { event.preventDefault(); dismiss(); } });
+ search.oninput = render; input.onLayout(position); input.onChange(hide);
+ new ResizeObserver(position).observe(card);
+ return { hide };
+}
diff --git a/ext/demo-site/src/source-editor.ts b/ext/demo-site/src/source-editor.ts
@@ -1,4 +1,4 @@
-import { importFolds, importFocus, importSites, syncImportFocus } from './import-folds.js';
+import { importFolds, importSites } from './import-folds.js';
import {EditorState, StateField, StateEffect, Transaction, EditorSelection, Compartment, type Range, type Extension,
EditorView, Decoration, keymap, drawSelection, history, historyKeymap, standardKeymap, isolateHistory, undo, redo, insertNewline} from './codemirror.js';
import {tokenize, bracketPair, definitionSites} from './syntax.js';
@@ -69,7 +69,6 @@ export class SourceEditor {
definitionState,syntax,importFolds,history(),drawSelection(),EditorState.tabSize.of(2),
keymap.of([{key:'Enter',run:plainNewline,shift:plainNewline},...historyKeymap,...standardKeymap]),EditorView.lineWrapping,
this.availability.of([EditorState.readOnly.of(false),EditorView.editable.of(true)]),
- EditorView.domEventHandlers({focus:(_event,view)=>syncImportFocus(view),blur:(_event,view)=>syncImportFocus(view)}),
EditorView.contentAttributes.of({'aria-label':'tricu source (up to 16,384 characters)',spellcheck:'false',autocorrect:'off',autocapitalize:'off'}),
EditorView.updateListener.of(update=>{
if(update.docChanged)this.notify();
@@ -110,7 +109,7 @@ export class SourceEditor {
const site=this.declarations().find(site=>site.name===name);if(!site)return false;
this.view.dispatch({effects:EditorView.scrollIntoView(site.start,{y:'center'})});return true;
}
- select(from:number,to=from):void{this.view.dispatch({selection:EditorSelection.single(from,to),effects:importFocus.of(true),scrollIntoView:true});this.focus();}
+ select(from:number,to=from):void{this.view.dispatch({selection:EditorSelection.single(from,to),scrollIntoView:true});this.focus();}
// Loading/restoring documents retires old history; import insertion is a
// separate, isolated undoable edit. Notification is explicit for controller edits.
replaceSource(source:string,notify=false):void {
diff --git a/ext/demo-site/style.css b/ext/demo-site/style.css
@@ -88,3 +88,6 @@ header nav{align-items:center;gap:12px}#theme-separator{color:var(--muted)}
.source-editor .import-hash{display:inline;min-width:0;min-height:0;max-width:100%;margin:0;padding:0;border:0;border-radius:0;font:inherit;line-height:inherit;vertical-align:baseline;color:#8accc8;background:transparent;white-space:pre-wrap;overflow-wrap:anywhere;text-decoration:underline dotted;text-underline-offset:3px;cursor:pointer}
.source-editor .import-hash:hover{background:#8bd3ad24}.source-editor .import-hash:focus-visible{outline:2px solid #bfe2ba;outline-offset:2px}.source-editor .import-hash-full{color:#8accc8}
@media(forced-colors:active){.source-editor .import-hash{color:LinkText}.source-editor .import-hash-full{color:CanvasText}}
+.source-editor .import-hash-full{cursor:pointer;text-decoration:underline dotted;text-underline-offset:3px}
+.module-card input{width:100%;box-sizing:border-box;margin:.5rem 0}
+.module-exports{display:flex;flex-wrap:wrap;gap:.4rem}
diff --git a/ext/demo-site/test/import-fold-inputs.mjs b/ext/demo-site/test/import-fold-inputs.mjs
@@ -15,12 +15,12 @@ export async function importFoldInputs({send,evaluate,report,prefix}){
await send('Emulation.setDeviceMetricsOverride',{width,height:1000,deviceScaleFactor:1,mobile:width<741});
await evaluate(`${e}.replaceSource(${JSON.stringify(initial)},true)`);await wait("document.getElementById('status').textContent==='Compiled'");
assert.deepEqual(await evaluate(counts),{folded:3,full:0});
- await click(widget);await key('Enter','Enter',13);assert((await evaluate(`${widget}.textContent`)).includes('...'),'Keyboard toggles are presentation only');
+ await click(widget);assert.deepEqual(await evaluate(counts),{folded:2,full:1},'Folded click moves cursor to import');
await click(alias);assert.deepEqual(await evaluate(counts),{folded:2,full:1});
assert.equal(await evaluate(`${e}.getSelection().from`),initial.indexOf(' as N')+4,'Caret maps past hidden 64-character hash');
await send('Input.insertText',{text:'M'});await key('z','KeyZ',90,2);assert.equal(await evaluate(`${e}.getSource()`),initial,'Typing undo');
await evaluate(`${e}.select(${initial.indexOf('!import')},${initial.indexOf('!import '+report.math+' as P')})`);
- assert.deepEqual(await evaluate(counts),{folded:1,full:2});
+ assert.deepEqual(await evaluate(counts),{folded:2,full:1});
await key('c','KeyC',67,2);const selected=await evaluate('navigator.clipboard.readText()');assert(selected.includes(report.math)&&!selected.includes('...'),'Partial selection copies complete hashes');
await key('a','KeyA',65,2);await key('c','KeyC',67,2);assert.equal(await evaluate('navigator.clipboard.readText()'),initial,'Native full-source clipboard');
await evaluate(`${e}.select(0)`);const inserted=`!import ${report.math} as Q\n`;
diff --git a/ext/demo-site/test/import-folds-probe.mjs b/ext/demo-site/test/import-folds-probe.mjs
@@ -13,10 +13,10 @@ export async function run(_control,backend='haskell-wasm'){
assert(widgets().length===3&&widgets()[0].textContent===math.slice(0,3)+'...'+math.slice(-3),'Exact per-hash abbreviation');
assert(editor.getSource()===source,'Compilation sees canonical source');
let edits=0;const unsubscribe=editor.onChange(()=>edits++);
- widgets()[0].click();assert(widgets()[0].textContent===math&&widgets()[1].textContent.includes('...'),'Independent expansion');
- widgets()[0].click();assert(widgets()[0].textContent.includes('...'),'Independent collapse');
- editor.select(source.indexOf(' as N')+4);assert(full()===1&&widgets().length===2,'Only edited line reveals');
- editor.select(source.indexOf('!import'),source.indexOf('!import '+math+' as P'));assert(full()===2&&widgets().length===1,'Only selected lines reveal');
+ widgets()[0].click();assert(full()===1&&widgets().length===2,'Folded click places cursor on import');
+ editor.view.contentDOM.blur();await pause(0);assert(full()===1,'Focus changes preserve cursor-line expansion');
+ editor.select(source.indexOf(' as N')+4);assert(full()===1&&widgets().length===2,'Only cursor line reveals');
+ editor.select(source.indexOf('!import'),source.indexOf('!import '+math+' as P'));assert(full()===1&&widgets().length===2,'Only selection head line reveals');
editor.select(source.length);await pause(0);assert(widgets().length===3&&edits===0,'Presentation/selection preserves source and revision');
editor.edit(0,0,`!import ${math} as Q\n`);editor.select(editor.getSource().length);
assert(widgets().length===4,'New import receives decoration');assert(editor.undo(),'Insertion undo');assert(editor.getSource()===source,'One undo restores entire canonical document');
@@ -24,5 +24,5 @@ export async function run(_control,backend='haskell-wasm'){
await status('Compiled');assert(await inspect('answer','number')==='42','Actual compiler/runtime accepts full-hash imports');
document.querySelector('[aria-label="Close definition inspection"]').click();
editor.select(source.length);editor.view.contentDOM.blur();await pause(0);unsubscribe();
- return {checks:['per-line import hash folding; independent controls; selected ranges; immutable canonical source/revisions; dynamic import undo/redo; actual compiler/runtime'],source,math};
+ return {checks:['per-line import hash folding; cursor placement; selection head; focus independence; immutable canonical source/revisions; dynamic import undo/redo; actual compiler/runtime'],source,math};
}
diff --git a/ext/demo-site/test/import-folds.mjs b/ext/demo-site/test/import-folds.mjs
@@ -0,0 +1,79 @@
+// Cursor-driven import folding and inline module browsing; no runtime assets required.
+// nix shell nixpkgs#nodejs nixpkgs#chromium -c node ext/demo-site/test/import-folds.mjs
+import assert from 'node:assert/strict';
+import {createServer} from 'node:http';
+import {spawn} from 'node:child_process';
+import {mkdtemp,readFile,rm} from 'node:fs/promises';
+import {tmpdir} from 'node:os';
+import {join} from 'node:path';
+const dir=await mkdtemp(join(tmpdir(),'arboricx-import-folds-'));
+let browser,socket,server;
+try{
+ const root=new URL('../',import.meta.url);
+ browser=spawn(process.argv[2]||'chromium',['--headless','--no-sandbox','--disable-dev-shm-usage','--remote-debugging-port=0',`--user-data-dir=${join(dir,'profile')}`,'about:blank'],{stdio:['ignore','ignore','pipe']});
+ const endpoint=await new Promise((resolve,reject)=>{
+ let log='';const timer=setTimeout(()=>reject(Error(log)),15000);
+ browser.on('error',reject);
+ browser.stderr.on('data',chunk=>{log+=chunk;const m=log.match(/DevTools listening on (ws:\/\/\S+)/);if(m){clearTimeout(timer);resolve(new URL(m[1]));}});
+ });
+ const tab=await(await fetch(`http://${endpoint.host}/json/new?about:blank`,{method:'PUT'})).json();
+ socket=new WebSocket(tab.webSocketDebuggerUrl);
+ await new Promise((resolve,reject)=>{socket.onopen=resolve;socket.onerror=reject;});
+ let id=0;const pending=new Map();
+ socket.onmessage=({data})=>{const event=JSON.parse(data);if(event.id){const p=pending.get(event.id);pending.delete(event.id);event.error?p.reject(Error(JSON.stringify(event.error))):p.resolve(event.result);}};
+ const send=(method,params={})=>new Promise((resolve,reject)=>{pending.set(++id,{resolve,reject});socket.send(JSON.stringify({id,method,params}));});
+ const evaluate=async expression=>{const r=await send('Runtime.evaluate',{expression,returnByValue:true,awaitPromise:true});if(r.exceptionDetails)throw Error(JSON.stringify(r.exceptionDetails));return r.result.value;};
+
+ server=createServer(async(req,res)=>{
+ try {
+ if(req.url==='/'){res.setHeader('Content-Type','text/html');res.end('<link rel="stylesheet" href="/style.css"><div id="editor" style="height:500px;position:relative"></div>');return;}
+ res.setHeader('Content-Type',req.url.endsWith('.js')?'text/javascript':'text/css');
+ res.end(await readFile(new URL('.'+req.url,root)));
+ }catch{res.statusCode=404;res.end();}
+ });
+ await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve));
+ await send('Page.navigate',{url:`http://127.0.0.1:${server.address().port}/`});
+ for(let i=0;!await evaluate("!!document.getElementById('editor')");i++){assert(i<200);await new Promise(r=>setTimeout(r,25));}
+ const result=await evaluate(`(async()=>{
+ const {SourceEditor}=await import('/dist/source-editor.js');
+ const {moduleInspector}=await import('/dist/module-inspector.js');
+ const editor=new SourceEditor(document.getElementById('editor'));globalThis.editor=editor;
+ const hash='a'.repeat(64),other='b'.repeat(64);
+ const source='!import '+hash+' as A\\n!import '+other+' as B\\nanswer = 42';
+ editor.replaceSource(source);
+ moduleInspector(editor,()=>[{manifest:hash,label:'Local test',names:['one','two']}]);
+ const check=(ok,message)=>{if(!ok)throw Error(message);};
+ const full=()=>editor.dom.querySelectorAll('.import-hash-full');
+ const folded=()=>editor.dom.querySelectorAll('.import-hash');
+ check(full().length===1&&folded().length===1,'Initial cursor line expands');
+ editor.select(source.length);check(full().length===0,'Leaving folds all imports');
+ folded()[0].click();check(full().length===1,'Folded click selects line');
+ check(editor.view.state.selection.main.head===8+64,'Folded click selects end');
+ check(editor.dom.querySelector('.module-card').hidden,'Folded click does not open card');
+ full()[0].click();await Promise.resolve();
+ check(!editor.dom.querySelector('.module-card').hidden,'Expanded click opens card');
+ check(editor.dom.querySelectorAll('.module-exports button').length===2,'Exact module exports');
+ const search=editor.dom.querySelector('.module-card input');search.value='two';search.dispatchEvent(new Event('input'));
+ check(editor.dom.querySelectorAll('.module-exports button').length===1,'Shared export filtering');
+ check(full().length===1,'Card focus preserves expansion');
+ editor.select(0,source.indexOf('!import '+other));await Promise.resolve();
+ check(full().length===1&&full()[0].textContent===other,'Selection head alone expands');
+ check(editor.dom.querySelector('.module-card').hidden,'Moving cursor dismisses card');
+ check(editor.getSource()===source&&!editor.undo(),'Presentation preserves document/history');
+ return 'cursor-line folding and module card passed';
+ })()`);
+ // Exercise native hit testing as well as programmatic/keyboard cursor changes.
+ await evaluate('editor.select(editor.getSource().length)');
+ const click=async selector=>{
+ const point=await evaluate(`(()=>{const r=document.querySelector(${JSON.stringify(selector)}).getBoundingClientRect();return {x:r.left+r.width/2,y:r.top+r.height/2};})()`);
+ await send('Input.dispatchMouseEvent',{type:'mousePressed',...point,button:'left',clickCount:1});
+ await send('Input.dispatchMouseEvent',{type:'mouseReleased',...point,button:'left',clickCount:1});
+ };
+ await click('.import-hash');
+ assert.equal(await evaluate("document.querySelectorAll('.import-hash-full').length"),1);
+ assert.equal(await evaluate("document.querySelector('.module-card').hidden"),true);
+ await click('.import-hash-full');
+ assert.equal(await evaluate("document.querySelector('.module-card').hidden"),false);
+ assert.equal(await evaluate("document.querySelectorAll('.module-exports button').length"),2);
+ console.log(result+'; trusted mouse clicks passed');
+}finally{socket?.close();browser?.kill();server?.close();await rm(dir,{recursive:true,force:true,maxRetries:10,retryDelay:100});}
diff --git a/ext/demo-site/test/import-folds.test.mjs b/ext/demo-site/test/import-folds.test.mjs
@@ -1,6 +1,6 @@
import test from 'node:test';import assert from 'node:assert/strict';
import {EditorState,EditorSelection,EditorView} from '../dist/codemirror.js';
-import {importSites,selectedImportLines,importFolds,importFocus} from '../dist/import-folds.js';
+import {importSites,selectedImportLines,importFolds} from '../dist/import-folds.js';
const hash='0123456789abcdef'.repeat(4),other='abcdef0123456789'.repeat(4);
test('import projection recognizes only real, complete, line-head import hashes',()=>{
const source=`-- !import ${hash}\ntext = "!import ${hash}"\n|- !import ${hash} -|\nx = !import ${hash}\n!import ${hash.slice(1)} as short\n!import ${hash.toUpperCase()} as upper\n!import ${hash}x as extra\n!import ${hash} as A\n !import\t${other} as B`;
@@ -8,21 +8,21 @@ test('import projection recognizes only real, complete, line-head import hashes'
assert.equal(sites.length,2);assert.deepEqual(sites.map(site=>site.hash),[hash,other]);
for(const site of sites)assert.equal(source.slice(site.from,site.to),site.hash);
});
-test('selection exposes only affected import lines, including exclusive end boundaries',()=>{
+test('selection exposes only cursor head lines',()=>{
const doc=`-- λ🙂\n!import ${hash} as A\n!import ${other} as B\nanswer = 42`;
let state=EditorState.create({doc});
- assert.deepEqual([...selectedImportLines(state,false)],[]);
+ assert.deepEqual([...selectedImportLines(state)],[1]);
state=state.update({selection:EditorSelection.single(doc.indexOf(' as A')+4)}).state;
- assert.deepEqual([...selectedImportLines(state,true)],[2]);
+ assert.deepEqual([...selectedImportLines(state)],[2]);
state=state.update({selection:EditorSelection.single(state.doc.line(2).from,state.doc.line(4).from)}).state;
- assert.deepEqual([...selectedImportLines(state,false)],[2,3]);
+ assert.deepEqual([...selectedImportLines(state)],[4]);
});
test('projection transactions preserve canonical source and character-level ranges',()=>{
const doc=`!import ${hash} as A\n!import ${other} as B\nanswer = 42`;
let state=EditorState.create({doc,extensions:[importFolds]});
- assert.equal(state.field(importFolds).decorations.size,2);
- state=state.update({selection:EditorSelection.single(state.doc.line(3).from),effects:importFocus.of(true)}).state;
+ assert.equal(state.field(importFolds).size,2);
+ state=state.update({selection:EditorSelection.single(state.doc.line(3).from)}).state;
assert.equal(state.doc.toString(),doc);assert.deepEqual(state.facet(EditorView.atomicRanges),[]);
state=state.update({changes:{from:0,insert:`!import ${hash} as C\n`}}).state;
- assert.equal(state.field(importFolds).sites.length,3);assert.equal(state.doc.toString(),`!import ${hash} as C\n`+doc);
+ assert.equal(importSites(state).length,3);assert.equal(state.doc.toString(),`!import ${hash} as C\n`+doc);
});