feat(editor): add identifier autocomplete popup

Vocab loaded lazily from /static/data/<lang>-vocab.json on first
mount, cached in memory. Popup appears when the word fragment before
the caret has >=2 word characters and matches the vocabulary. Prefix
matches rank ahead of substring matches; popup shows up to 8 with
scroll. Up/Down navigate, Tab/Enter accept, Esc dismisses.

acceptCompletion uses instance.jar (not the captured closure) so
runtime jar reassignment via setLanguage stays consistent.
This commit is contained in:
mwiegand 2026-05-16 20:42:03 +02:00
parent e6fe701718
commit 3d3629f592
No known key found for this signature in database

View file

@ -17,6 +17,32 @@
bash: "bash", bash: "bash",
}; };
const VOCAB_URLS = {
srccfg: "/static/data/srccfg-vocab.json",
};
const vocabCache = {};
async function loadVocab(lang) {
if (vocabCache[lang]) return vocabCache[lang];
const url = VOCAB_URLS[lang];
if (!url) return [];
try {
const r = await fetch(url);
if (!r.ok) return [];
const data = await r.json();
const merged = []
.concat(data.cvars || [])
.concat(data.commands || []);
vocabCache[lang] = merged;
return merged;
} catch (err) {
console.warn("[editor] vocab load failed for " + lang, err);
vocabCache[lang] = [];
return [];
}
}
function resolveAutoLanguage(filename) { function resolveAutoLanguage(filename) {
if (!filename) return "plain"; if (!filename) return "plain";
const m = /\.([a-zA-Z0-9]+)$/.exec(filename); const m = /\.([a-zA-Z0-9]+)$/.exec(filename);
@ -37,6 +63,65 @@
}; };
} }
const WORD_BEFORE_CARET = /[A-Za-z0-9_]{2,}$/;
function getCaretContext(codeEl) {
const sel = window.getSelection();
if (!sel || !sel.rangeCount) return null;
const range = sel.getRangeAt(0).cloneRange();
if (!codeEl.contains(range.endContainer)) return null;
// Build the text from start of the editor up to the caret.
const pre = range.cloneRange();
pre.selectNodeContents(codeEl);
pre.setEnd(range.endContainer, range.endOffset);
const textBefore = pre.toString();
const m = WORD_BEFORE_CARET.exec(textBefore);
if (!m) return null;
return {
fragment: m[0],
rect: range.getBoundingClientRect(),
};
}
function filterVocab(vocab, fragment) {
const lower = fragment.toLowerCase();
const prefix = [];
const substr = [];
for (const entry of vocab) {
const name = entry.name.toLowerCase();
if (name.startsWith(lower)) prefix.push(entry);
else if (name.includes(lower)) substr.push(entry);
if (prefix.length + substr.length >= 50) break;
}
return prefix.concat(substr).slice(0, 50);
}
function renderPopup(popup, items, activeIndex) {
popup.innerHTML = "";
const visible = items.slice(0, 8);
visible.forEach((entry, i) => {
const li = document.createElement("li");
li.className = "editor-popup-item" + (i === activeIndex ? " is-active" : "");
li.dataset.index = String(i);
const name = document.createElement("span");
name.className = "name";
name.textContent = entry.name;
li.appendChild(name);
if (entry.desc) {
const desc = document.createElement("span");
desc.className = "desc";
desc.textContent = "— " + entry.desc;
li.appendChild(desc);
}
popup.appendChild(li);
});
}
function positionPopup(popup, rect) {
popup.style.left = (window.scrollX + rect.left) + "px";
popup.style.top = (window.scrollY + rect.bottom + 2) + "px";
}
// For "auto" language: look for a filename input inside the nearest // For "auto" language: look for a filename input inside the nearest
// enclosing dialog or .modal. Returns the <input> or null. // enclosing dialog or .modal. Returns the <input> or null.
// Intentionally does NOT walk up to <body>: an "auto" textarea // Intentionally does NOT walk up to <body>: an "auto" textarea
@ -84,6 +169,109 @@
attachOnUpdate(jar); attachOnUpdate(jar);
let popup = null;
let popupItems = [];
let popupActive = 0;
function ensurePopup() {
if (popup) return popup;
popup = document.createElement("ul");
popup.className = "editor-popup";
popup.style.display = "none";
document.body.appendChild(popup);
popup.addEventListener("mousedown", function (e) {
e.preventDefault(); // keep caret in editor
const li = e.target.closest(".editor-popup-item");
if (!li) return;
acceptCompletion(popupItems[parseInt(li.dataset.index, 10)]);
});
return popup;
}
function hidePopup() {
if (popup) popup.style.display = "none";
popupItems = [];
}
function acceptCompletion(entry) {
if (!entry) return;
const ctx = getCaretContext(code);
if (!ctx) {
hidePopup();
return;
}
// Replace the trailing word fragment with the chosen identifier.
const sel = window.getSelection();
const range = sel.getRangeAt(0);
range.setStart(range.endContainer, range.endOffset - ctx.fragment.length);
range.deleteContents();
range.insertNode(document.createTextNode(entry.name));
range.collapse(false);
sel.removeAllRanges();
sel.addRange(range);
// Force CodeJar to re-highlight + emit onUpdate.
// Use instance.jar (not bare `jar` closure) for consistency with
// setLanguage's tear-down-and-remount — the jar reference can be
// swapped at runtime.
instance.jar.updateCode(instance.jar.toString());
hidePopup();
}
async function refreshPopup() {
if (instance.language === "plain") {
hidePopup();
return;
}
const ctx = getCaretContext(code);
if (!ctx) {
hidePopup();
return;
}
const vocab = await loadVocab(instance.language);
if (!vocab.length) {
hidePopup();
return;
}
const filtered = filterVocab(vocab, ctx.fragment);
if (!filtered.length) {
hidePopup();
return;
}
popupItems = filtered;
popupActive = 0;
ensurePopup();
renderPopup(popup, popupItems, popupActive);
positionPopup(popup, ctx.rect);
popup.style.display = "";
}
code.addEventListener("input", refreshPopup);
code.addEventListener("blur", function () {
// Defer hide so a popup click can still register.
setTimeout(hidePopup, 100);
});
code.addEventListener("keydown", function (e) {
if (!popup || popup.style.display === "none" || !popupItems.length) return;
if (e.key === "ArrowDown") {
popupActive = (popupActive + 1) % Math.min(popupItems.length, 8);
renderPopup(popup, popupItems, popupActive);
e.preventDefault();
} else if (e.key === "ArrowUp") {
popupActive =
(popupActive - 1 + Math.min(popupItems.length, 8)) %
Math.min(popupItems.length, 8);
renderPopup(popup, popupItems, popupActive);
e.preventDefault();
} else if (e.key === "Tab" || e.key === "Enter") {
acceptCompletion(popupItems[popupActive]);
e.preventDefault();
} else if (e.key === "Escape") {
hidePopup();
e.preventDefault();
}
});
const instance = { const instance = {
textarea, textarea,
shell, shell,