812 lines
38 KiB
JavaScript
812 lines
38 KiB
JavaScript
// Plugins: the market of OpenCode provider plugins — subscriptions magpie
|
|
// signs in to through someone else's code. Discover lists the plugins
|
|
// magpie suggests (the community repo's list, with what npm says of each
|
|
// now) and finds the rest on npm; Installed is what was added, to sign in
|
|
// with, update, switch off or remove. Everything comes from
|
|
// /api/plugins/market; a sign-in happens in the Providers add sheet, as
|
|
// every other subscription's does.
|
|
(() => {
|
|
const page = $("#view-plugins");
|
|
if (!page) return;
|
|
|
|
let market = null; // /api/plugins/market
|
|
let failed = ""; // why the market couldn't be loaded
|
|
let tab = "discover";
|
|
try { tab = localStorage.getItem("magpie.pluginTab") || tab; } catch {}
|
|
let query = "";
|
|
let hits = null; // npm's answer for query: { q, list } | { q, loading } | { q, error }
|
|
let searchTimer = 0;
|
|
const busy = new Map(); // package → "add" | "remove" | "upgrade" | "off"
|
|
let asking = null; // { pkg, op }: a remove or switch-off waiting on the user's yes
|
|
let shown = "";
|
|
|
|
const SEARCH = "M7 3a4 4 0 1 0 0 8 4 4 0 0 0 0-8z M10 10l3 3";
|
|
const DOWN = "M8 3v7.5 M4.8 7.6 8 10.8l3.2-3.2 M3.5 13h9";
|
|
const SHIELD = "M8 2.3 13 4v3.8c0 3-2.1 5.1-5 5.9-2.9-.8-5-2.9-5-5.9V4z M5.8 8l1.6 1.6 2.9-3";
|
|
const OUTL = "M9.5 3.5h3v3 M12.5 3.5 7.5 8.5 M11 9.5v3H3.5V5h3";
|
|
const FOLDER = "M14.7 12.7a1.3 1.3 0 0 1-1.3 1.3H2.6a1.3 1.3 0 0 1-1.3-1.3V3.3A1.3 1.3 0 0 1 2.6 2h3.3l1.3 2h6.2a1.3 1.3 0 0 1 1.3 1.3z";
|
|
|
|
const name = (spec) => { const i = spec.lastIndexOf("@"); return i > 0 && !spec.startsWith(".") && !spec.includes("/", i) ? spec.slice(0, i) : spec; };
|
|
// a plugin added from a folder is a folder's path, not a package name
|
|
const isPath = (p) => /^file:\/\//.test(p) || /^\./.test(p) || /^\//.test(p) || /^[A-Za-z]:[\\/]/.test(p);
|
|
// ...and is known by the folder's name, not its whole path
|
|
const label = (spec) => { const n = name(spec); return isPath(n) ? n.replace(/[\\/]+$/, "").split(/[\\/]/).pop() || n : n; };
|
|
// one added from a git repository (github:owner/repo, git+https://…), as
|
|
// the plugin store tells them apart: known by the package it installed
|
|
const isGit = (p) => !isPath(p) && (/^(?:(?:github|gitlab|bitbucket):|git\+[a-z]+:\/\/|git:\/\/)\S+$/.test(p) || /^https?:\/\/(?:www\.)?(?:github\.com|gitlab\.com|bitbucket\.org)\/\S+$/.test(p) || /^[A-Za-z0-9][A-Za-z0-9_.-]*\/[A-Za-z0-9_.-]+(?:#\S+)?$/.test(p));
|
|
// ...and its page on the web, for the plugin's Source link
|
|
const gitWeb = (p) => {
|
|
const s = p.replace(/#.*$/, "");
|
|
const m = /^(github|gitlab|bitbucket):(.+)$/.exec(s) || (/^[^:]+\/[^:]+$/.test(s) && ["", "github", s]);
|
|
if (m) return "https://" + { github: "github.com", gitlab: "gitlab.com", bitbucket: "bitbucket.org" }[m[1]] + "/" + m[2];
|
|
const u = s.replace(/^git\+/, "").replace(/\.git$/, "");
|
|
return /^https?:\/\//.test(u) ? u : "";
|
|
};
|
|
const lang = () => (document.documentElement.lang || "").startsWith("zh") ? "zh" : "en";
|
|
const summary = (l) => l.summary?.[lang()] || l.summary?.en || l.npm?.description || "";
|
|
const count = (n) => n >= 1e6 ? (n / 1e6).toFixed(n >= 1e7 ? 0 : 1) + "M" : n >= 1e3 ? (n / 1e3).toFixed(n >= 1e4 ? 0 : 1) + "k" : String(n || 0);
|
|
const entryOf = (pkg) => market?.state.plugins.find((e) => name(e.spec) === pkg);
|
|
// the providers a plugin signs in to, as the add sheet knows them
|
|
const subsOf = (pkg) => (providers?.plugins || []).filter((x) => name(x.spec) === pkg);
|
|
const newer = (a, b) => {
|
|
const p = (v) => (v || "").split(/[.+-]/).slice(0, 3).map((x) => parseInt(x, 10) || 0);
|
|
const [x, y] = [p(a), p(b)];
|
|
for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] > y[i];
|
|
return false;
|
|
};
|
|
|
|
function glyph(d, size = 14, stroke = 1.5) { return svg(d, size, stroke); }
|
|
function logo(ic, big) {
|
|
const box = el("span", "pm-logo" + (big ? " big" : ""));
|
|
if (ic) box.append(icon(ic));
|
|
else box.append(glyph(PUZZLE, big ? 22 : 17, 1.4));
|
|
return box;
|
|
}
|
|
|
|
async function load() {
|
|
const first = !market;
|
|
if (first) draw();
|
|
try {
|
|
const [m] = await Promise.all([api("plugins/market"), providers ? null : loadProviders().catch(() => {})]);
|
|
market = m;
|
|
failed = "";
|
|
} catch (e) {
|
|
failed = e.message;
|
|
}
|
|
draw();
|
|
window.renderPluginDot?.();
|
|
}
|
|
window.loadPlugins = load;
|
|
// pluginQuery: Discover, looking for q (the add sheet found nothing by it)
|
|
window.pluginQuery = (q) => {
|
|
if (q === query && !q) return;
|
|
query = q;
|
|
tab = "discover";
|
|
clearTimeout(searchTimer);
|
|
hits = q.trim().length >= 2 ? { q: q.trim(), loading: true } : null;
|
|
if (hits) searchNPM(hits.q);
|
|
draw();
|
|
};
|
|
|
|
async function refresh() {
|
|
try {
|
|
const [m, p] = await Promise.all([api("plugins/market"), api("providers")]);
|
|
market = m;
|
|
providers = p;
|
|
// the Providers page owns the dialog as it draws: only drawn when shown
|
|
if (view === "providers") renderProviders();
|
|
} catch (e) { status(e.message, "err"); }
|
|
draw();
|
|
window.renderPluginDot?.();
|
|
}
|
|
|
|
async function act(pkg, op, body, done) {
|
|
if (busy.size && op !== "off") return;
|
|
busy.set(pkg, op);
|
|
draw();
|
|
try {
|
|
await api("plugins/" + op, body);
|
|
busy.delete(pkg);
|
|
await refresh();
|
|
if (done) done();
|
|
} catch (e) {
|
|
busy.delete(pkg);
|
|
draw();
|
|
status(e.message, "err");
|
|
}
|
|
}
|
|
// the built-ins with accounts this plugin could run in their place
|
|
const movableOf = (pkg) => (market?.state.movable || []).filter((c) => c.package === pkg);
|
|
// move: a built-in's accounts onto its plugin, installing it if need be,
|
|
// as its editor's Runs on does
|
|
async function move(c) {
|
|
if (busy.size) return;
|
|
busy.set(c.package, "move");
|
|
asking = null;
|
|
draw();
|
|
try {
|
|
providers = await api("provider/move", { id: c.id });
|
|
busy.delete(c.package);
|
|
await refresh();
|
|
const now = providers.providers.find((x) => x.id === c.id);
|
|
const accts = now?.account?.logins?.length || c.accounts, models = (now?.models || []).filter((x) => x.on).length;
|
|
status(t("{name} now runs on its plugin — {accounts}, {models}. Its editor in Providers moves it back.", {
|
|
name: c.name,
|
|
accounts: t(accts === 1 ? "{n} account" : "{n} accounts", { n: accts }),
|
|
models: t(models === 1 ? "{n} model" : "{n} models", { n: models }),
|
|
}), "ok", 9000);
|
|
} catch (e) {
|
|
busy.delete(c.package);
|
|
draw();
|
|
// no editor here to sign in above: say where
|
|
const why = e.why?.code === "lapsed"
|
|
? t("every {name} account needs signing in again. Sign one in under Providers, then move.", { name: c.name })
|
|
: moveWhy({ why: e.why, error: e.message }, { name: c.name });
|
|
status(t("It stays built-in: {error}", { error: why }), "err", 12000);
|
|
}
|
|
}
|
|
const moveButton = (b, c, here) => {
|
|
b.classList.add("go", "move");
|
|
b.textContent = here ? t("Move {name} here", { name: c.name }) : t(c.accounts === 1 ? "Move my {n} {name} account" : "Move my {n} {name} accounts", { name: c.name, n: c.accounts });
|
|
b.title = t("Runs {name} on this plugin in place of the built-in, with the same accounts; nothing changes if one doesn't work through it", { name: c.name });
|
|
b.disabled = busy.size > 0;
|
|
b.onclick = (ev) => { ev.stopPropagation(); move(c); };
|
|
return b;
|
|
};
|
|
const install = (pkg, nm) => act(pkg, "add", { spec: pkg }, () => {
|
|
const subs = subsOf(pkg);
|
|
status(subs.length ? t("{name} is installed — sign in to use it", { name: nm }) : t("{name} is installed", { name: nm }), "ok");
|
|
});
|
|
|
|
// the one button a plugin shows: what to do with it next
|
|
function actionFor(pkg, nm, listed) {
|
|
const b = el("button", "pm-act");
|
|
const e = entryOf(pkg);
|
|
const op = busy.get(pkg);
|
|
if (op === "add" || op === "upgrade" || op === "move") {
|
|
b.classList.add("busy");
|
|
if (op === "move") b.classList.add("move");
|
|
b.append(el("span", "spin"), el("span", "", op === "move" ? t("Moving…") : op === "upgrade" ? t("Updating…") : market?.state.bun ? t("Installing…") : t("Getting Bun…")));
|
|
b.disabled = true;
|
|
if (!market?.state.bun) b.title = t("Plugins run on Bun {v}, downloaded once", { v: market?.state.bunVersion || "" });
|
|
return b;
|
|
}
|
|
if (!e) {
|
|
if (listed && !listed.npm?.version) {
|
|
b.textContent = t("Coming soon");
|
|
b.disabled = true;
|
|
b.title = t("Not on npm yet");
|
|
return b;
|
|
}
|
|
const c = movableOf(pkg)[0];
|
|
if (c) return moveButton(b, c);
|
|
b.classList.add("get");
|
|
b.textContent = t("Install");
|
|
b.disabled = busy.size > 0;
|
|
b.onclick = (ev) => { ev.stopPropagation(); install(pkg, nm); };
|
|
return b;
|
|
}
|
|
if (e.off) {
|
|
b.textContent = t("Switch on");
|
|
b.onclick = (ev) => { ev.stopPropagation(); act(pkg, "off", { spec: e.spec, off: false }); };
|
|
return b;
|
|
}
|
|
const subs = subsOf(pkg);
|
|
const out = subs.find((x) => !x.signedIn);
|
|
// signed in to nothing yet, with a built-in's accounts to bring
|
|
const c = !subs.some((x) => x.signedIn) && movableOf(pkg)[0];
|
|
if (c) return moveButton(b, c, true);
|
|
if (out) {
|
|
b.classList.add("go");
|
|
b.textContent = t("Sign in");
|
|
b.title = subs.length > 1 ? t("Sign in to {name}", { name: out.name }) : "";
|
|
b.onclick = (ev) => { ev.stopPropagation(); pluginSignIn(out.id); };
|
|
return b;
|
|
}
|
|
if (subs.length) {
|
|
b.classList.add("done");
|
|
b.append(glyph(CHECK, 11, 2), el("span", "", t("Signed in")));
|
|
b.title = t("Open {name} in Providers", { name: subs[0].name });
|
|
b.onclick = (ev) => { ev.stopPropagation(); openProvider(subs[0].id); };
|
|
return b;
|
|
}
|
|
b.classList.add("done");
|
|
b.append(glyph(CHECK, 11, 2), el("span", "", t("Installed")));
|
|
b.disabled = true;
|
|
return b;
|
|
}
|
|
|
|
function card(l) {
|
|
const c = el("div", "pm-card");
|
|
c.tabIndex = 0;
|
|
c.setAttribute("role", "button");
|
|
c.dataset.pkg = l.package;
|
|
const e = entryOf(l.package);
|
|
if (e) c.classList.add("have");
|
|
const top = el("div", "pm-top");
|
|
const who = el("div", "pm-who");
|
|
const nm = el("div", "pm-name");
|
|
nm.append(el("b", "", l.name));
|
|
who.append(nm);
|
|
const by = el("div", "pm-by");
|
|
if (l.community) {
|
|
const v = el("span", "pm-verified");
|
|
v.append(glyph(SHIELD, 11, 1.5), el("span", "", t("magpie community")));
|
|
v.title = t("Written for magpie by its community, in github.com/magpie-community/plugins");
|
|
by.append(v);
|
|
} else by.append(el("span", "", l.npm?.publisher || l.package));
|
|
who.append(by);
|
|
const act = actionFor(l.package, l.name, l);
|
|
// a move says whose accounts: too long for the top row, it has one of its own
|
|
const own = act.classList.contains("move");
|
|
top.append(logo(l.icon), who);
|
|
if (!own) top.append(act);
|
|
const sum = el("p", "pm-sum", summary(l));
|
|
const meta = el("div", "pm-meta");
|
|
if (l.npm?.weekly) {
|
|
const d = el("span", "pm-dl");
|
|
d.append(glyph(DOWN, 11, 1.5), el("span", "", t("{n}/week", { n: count(l.npm.weekly) })));
|
|
d.title = t("{n} downloads on npm last week", { n: l.npm.weekly.toLocaleString() });
|
|
meta.append(d);
|
|
}
|
|
if (l.npm?.version) meta.append(el("span", "pm-ver", "v" + (e?.version || l.npm.version)));
|
|
if (e && e.latest && e.version && newer(e.latest, e.version)) meta.append(el("span", "pm-chip up", t("Update")));
|
|
if (e?.error && !e.off) meta.append(el("span", "pm-chip bad", t("Didn't load")));
|
|
else if (e?.off) meta.append(el("span", "pm-chip", t("Off")));
|
|
if (l.replaces) {
|
|
const b = el("span", "pm-chip soft", t("Also built in"));
|
|
b.title = t("magpie also signs in to this itself, for now; the plugin keeps it working if the built-in one is retired");
|
|
meta.append(b);
|
|
}
|
|
c.append(top, sum, meta);
|
|
if (own) c.append(act);
|
|
c.onclick = () => detail(l);
|
|
c.onkeydown = (ev) => { if (ev.key === "Enter" || ev.key === " ") { ev.preventDefault(); detail(l); } };
|
|
return c;
|
|
}
|
|
|
|
function skeleton(n) {
|
|
const g = el("div", "pm-grid");
|
|
for (let i = 0; i < n; i++) {
|
|
const c = el("div", "pm-card ghost");
|
|
c.append(el("div", "g1"), el("div", "g2"), el("div", "g3"));
|
|
g.append(c);
|
|
}
|
|
return g;
|
|
}
|
|
|
|
function section(title, hint, list) {
|
|
const box = el("section", "pm-sec");
|
|
const h = el("div", "pm-sechead");
|
|
h.append(el("h3", "", title));
|
|
if (hint) h.append(el("span", "", hint));
|
|
const g = el("div", "pm-grid");
|
|
for (const l of list) g.append(card(l));
|
|
box.append(h, g);
|
|
return box;
|
|
}
|
|
|
|
function head() {
|
|
const h = el("div", "lib-head pm-head");
|
|
const n = market?.state.plugins.length || 0;
|
|
const tabs = segs([["discover", t("Discover")], ["installed", t("Installed") + (n ? " · " + n : "")]], tab, (id) => {
|
|
tab = id;
|
|
try { localStorage.setItem("magpie.pluginTab", id); } catch {}
|
|
drawBody();
|
|
});
|
|
tabs.classList.add("lib-tabs");
|
|
const find = el("label", "pm-find");
|
|
find.append(glyph(SEARCH, 13, 1.6));
|
|
const q = el("input");
|
|
q.type = "search";
|
|
q.placeholder = t("Search plugins and npm…");
|
|
q.value = query;
|
|
q.spellcheck = false;
|
|
q.autocomplete = "off";
|
|
q.setAttribute("aria-label", t("Search plugins"));
|
|
q.oninput = () => {
|
|
query = q.value;
|
|
if (tab !== "discover") { tab = "discover"; draw(); page.querySelector(".pm-find input")?.focus(); }
|
|
clearTimeout(searchTimer);
|
|
const s = query.trim();
|
|
if (s.length < 2) hits = null;
|
|
else {
|
|
hits = { q: s, loading: true };
|
|
searchTimer = setTimeout(() => searchNPM(s), 350);
|
|
}
|
|
drawBody();
|
|
};
|
|
q.onkeydown = (ev) => { ev.stopPropagation(); if (ev.key === "Escape" && q.value) { q.value = ""; q.oninput(); } };
|
|
find.append(q);
|
|
h.append(tabs, el("span", "grow"), find);
|
|
h.classList.toggle("stuck", page.scrollTop > 0);
|
|
return h;
|
|
}
|
|
|
|
async function searchNPM(s) {
|
|
try {
|
|
const r = await api("plugins/search?q=" + encodeURIComponent(s));
|
|
if (hits?.q === s) hits = { q: s, list: r.hits };
|
|
} catch (e) {
|
|
if (hits?.q === s) hits = { q: s, error: e.message };
|
|
}
|
|
if (hits?.q === s) drawBody();
|
|
}
|
|
|
|
page.addEventListener("scroll", () => page.querySelector(".pm-head")?.classList.toggle("stuck", page.scrollTop > 0), { passive: true });
|
|
|
|
let body = null;
|
|
function draw() {
|
|
const scroll = page.scrollTop;
|
|
page.replaceChildren(head());
|
|
body = el("div", "lib-body pm-body");
|
|
page.append(body);
|
|
drawBody();
|
|
page.scrollTop = scroll;
|
|
}
|
|
|
|
function drawBody() {
|
|
if (!body) return;
|
|
const key = tab + (query ? ":q" : "");
|
|
body.classList.toggle("enter", shown !== key);
|
|
shown = key;
|
|
body.replaceChildren();
|
|
// the search field keeps its focus: only the body is redrawn while typing
|
|
if (failed && !market) {
|
|
body.append(el("p", "pm-empty", t("Couldn't load the plugins: {error}", { error: failed })));
|
|
return;
|
|
}
|
|
if (tab === "installed") return drawInstalled();
|
|
if (!market) {
|
|
body.append(intro(), skeleton(6));
|
|
return;
|
|
}
|
|
const f = query.trim().toLowerCase();
|
|
const ls = market.listings.filter((l) => !f || [l.name, l.package, summary(l), (l.providers || []).join(" "), l.npm?.publisher || ""].join(" ").toLowerCase().includes(f));
|
|
if (!f) {
|
|
body.append(intro());
|
|
// magpie's community's alone: others' plugins are found by a search
|
|
const ours = ls.filter((l) => l.community);
|
|
if (ours.length) body.append(section(t("magpie community"), t("written for magpie, checked against its own sign-ins"), ours));
|
|
body.append(manual());
|
|
return;
|
|
}
|
|
if (ls.length) body.append(section(t("Suggested"), "", ls));
|
|
const known = new Set(market.listings.map((l) => l.package));
|
|
const box = el("section", "pm-sec");
|
|
const h = el("div", "pm-sechead");
|
|
h.append(el("h3", "", t("On npm")), el("span", "", t("OpenCode plugins anyone published — read what one does before you install it")));
|
|
box.append(h);
|
|
if (!hits) box.append(el("p", "pm-note", t("Type two letters or more to search npm")));
|
|
else if (hits.loading) box.append(skeleton(2));
|
|
else if (hits.error) box.append(el("p", "pm-note bad", hits.error));
|
|
else {
|
|
const list = hits.list.filter((x) => !known.has(x.package));
|
|
if (!list.length) box.append(el("p", "pm-note", ls.length ? t("Nothing else on npm") : t("No plugin called “{q}”", { q: query.trim() })));
|
|
const g = el("div", "pm-grid");
|
|
for (const x of list) g.append(card({ package: x.package, name: x.package, npm: x }));
|
|
box.append(g);
|
|
}
|
|
body.append(box);
|
|
}
|
|
|
|
function intro() {
|
|
const box = el("div", "pm-intro");
|
|
const text = el("div", "pm-introtext");
|
|
text.append(el("h2", "", t("Subscriptions, as plugins")));
|
|
text.append(el("p", "", t("Plugins sign in to coding plans and make their requests; the models then work in every agent, like any provider's. They're OpenCode's provider plugins, run on Bun.")));
|
|
const trust = el("p", "pm-trust");
|
|
trust.append(glyph(SHIELD, 12, 1.5), el("span", "", t("A plugin is someone else's code with your sign-in: install the ones you trust.")));
|
|
text.append(trust);
|
|
const art = el("div", "pm-art");
|
|
for (const ic of ["zcode", "githubcopilot", "gemini-color", "zed", "kiro-color"]) art.append(logo(ic));
|
|
box.append(text, art);
|
|
return box;
|
|
}
|
|
|
|
// a package or a folder the market doesn't list
|
|
function manual() {
|
|
const box = el("form", "pm-manual");
|
|
box.append(el("span", "pm-mlabel", t("Have one in mind?")));
|
|
const spec = el("input", "pm-minput");
|
|
spec.placeholder = t("npm package, GitHub repo (github:owner/repo) or a folder");
|
|
spec.spellcheck = false;
|
|
spec.autocomplete = "off";
|
|
spec.setAttribute("aria-label", t("Plugin"));
|
|
spec.onkeydown = (ev) => ev.stopPropagation();
|
|
const go = el("button", "text primary", t("Install"));
|
|
go.type = "submit";
|
|
go.disabled = busy.size > 0;
|
|
if (busy.has("+")) { go.textContent = t("Installing…"); spec.disabled = true; }
|
|
box.onsubmit = (ev) => {
|
|
ev.preventDefault();
|
|
const s = spec.value.trim();
|
|
if (!s || busy.size) return;
|
|
act("+", "add", { spec: s }, () => status(t("{name} is installed", { name: s }), "ok"));
|
|
};
|
|
box.append(spec);
|
|
// a folder is picked, not typed, where magpie can show the system's
|
|
// picker (`magpie web` can't, and the market says so)
|
|
if (market?.state.picker) box.append(browse(spec, go));
|
|
box.append(go);
|
|
return box;
|
|
}
|
|
|
|
// the system's folder picker: what it answers goes in the field, for the
|
|
// Install beside it
|
|
function browse(spec, go) {
|
|
const b = el("button", "pm-browse");
|
|
b.type = "button";
|
|
b.append(glyph(FOLDER, 14, 1.5));
|
|
b.title = t("Choose a folder on this computer");
|
|
b.setAttribute("aria-label", t("Choose a folder"));
|
|
b.disabled = busy.size > 0 || spec.disabled;
|
|
b.onclick = async () => {
|
|
b.disabled = true;
|
|
try {
|
|
const r = await api("plugins/choose", {});
|
|
if (r.dir) {
|
|
spec.value = r.dir;
|
|
spec.focus();
|
|
}
|
|
} catch (e) {
|
|
status(e.message, "err");
|
|
} finally {
|
|
b.disabled = busy.size > 0 || spec.disabled;
|
|
}
|
|
};
|
|
return b;
|
|
}
|
|
|
|
function drawInstalled() {
|
|
if (!market) { body.append(skeleton(2)); return; }
|
|
const es = market.state.plugins;
|
|
if (!es.length) {
|
|
const none = el("div", "pm-none");
|
|
none.append(logo("", true), el("b", "", t("No plugins yet")), el("p", "", t("Find a subscription in Discover and install it; it signs in from here.")));
|
|
const b = el("button", "pm-act get", t("Discover plugins"));
|
|
b.onclick = () => { tab = "discover"; draw(); };
|
|
none.append(b);
|
|
body.append(none);
|
|
return;
|
|
}
|
|
// by name, in the order the reader picked (#481), not the order they
|
|
// were installed in
|
|
const list = el("div", "list pm-list");
|
|
const fill = () => list.replaceChildren(...[...es].sort(byName(sortOf("plugins"), shownName)).map(installedRow));
|
|
if (es.length > 1) {
|
|
const h = el("div", "row-head pm-listhead");
|
|
h.append(el("span", "grow"), sortBy("plugins", NAME_SORTS, fill));
|
|
body.append(h);
|
|
}
|
|
fill();
|
|
body.append(list);
|
|
const foot = el("div", "pm-foot");
|
|
const outdated = es.filter((e) => e.latest && e.version && newer(e.latest, e.version));
|
|
foot.append(el("span", "", market.state.bun ? t("Plugins run on Bun {v}", { v: market.state.bunVersion }) : ""), el("span", "grow"));
|
|
if (outdated.length) {
|
|
const up = el("button", "text", busy.has("*") ? t("Updating…") : t("Update all ({n})", { n: outdated.length }));
|
|
up.disabled = busy.size > 0;
|
|
up.onclick = () => act("*", "update", {}, () => status(t("Plugins updated"), "ok"));
|
|
foot.append(up);
|
|
}
|
|
body.append(foot);
|
|
}
|
|
|
|
// an installed plugin's name as its row shows it: the market's, else the
|
|
// package's or the folder's
|
|
function shownName(e) {
|
|
const pkg = name(e.spec);
|
|
return market.listings.find((x) => x.package === pkg)?.name || (isGit(e.spec) && e.package) || label(e.spec);
|
|
}
|
|
|
|
function installedRow(e) {
|
|
const pkg = name(e.spec);
|
|
const l = market.listings.find((x) => x.package === pkg);
|
|
const r = el("div", "row pm-row" + (e.off ? " off" : ""));
|
|
const who = el("div", "who");
|
|
const nm = el("div", "name");
|
|
nm.append(el("span", "", shownName(e)));
|
|
if (e.version) nm.append(el("span", "pm-ver", "v" + e.version));
|
|
if (e.latest && e.version && newer(e.latest, e.version)) nm.append(el("span", "pm-chip up", t("v{v} out", { v: e.latest })));
|
|
else if (e.autoUpdated && e.autoUpdated.to === e.version) {
|
|
// magpie updated it by itself lately: the row says so, quietly
|
|
const c = el("span", "pm-chip soft", t("Auto-updated"));
|
|
c.title = t("magpie updated it from v{from} to v{to} on {date}", { from: e.autoUpdated.from, to: e.autoUpdated.to, date: new Date(e.autoUpdated.at).toLocaleDateString(document.documentElement.lang || undefined, { month: "short", day: "numeric" }) });
|
|
nm.append(c);
|
|
}
|
|
// the built-in subscriptions moved onto it: taking it away moves them
|
|
// back, so the row says it carries them
|
|
const moved = (e.moved || []).map((id) => SUBS.find((x) => x.agent === id)?.name || id);
|
|
const names = moved.join(t(", "));
|
|
if (moved.length && !e.off) {
|
|
const c = el("span", "pm-chip moved");
|
|
c.append(el("span", "dot"), el("span", "", t("Serves {names}", { names })));
|
|
c.title = t("{names} runs on this plugin in place of the built-in", { names });
|
|
nm.append(c);
|
|
}
|
|
who.append(nm);
|
|
const subs = subsOf(pkg);
|
|
const sub = el("div", "sub");
|
|
const cands = e.off ? [] : movableOf(pkg);
|
|
const ask = asking?.pkg === pkg && !busy.size && (asking.op === "move" ? cands.length : moved.length) ? asking.op : "";
|
|
if (ask === "move") {
|
|
sub.textContent = t("{names} can run on it again, with the same accounts.", { names: cands.map((c) => c.name).join(t(", ")) });
|
|
sub.classList.add("ask");
|
|
} else if (ask) {
|
|
sub.textContent = t(ask === "remove" ? "{names} goes back to the built-in, with its accounts, before the plugin is removed." : "{names} goes back to the built-in, with its accounts, before the plugin is switched off.", { names });
|
|
sub.classList.add("ask");
|
|
} else if (e.off) sub.textContent = t("Off");
|
|
else if (e.error) { sub.textContent = t("Didn't load: {error}", { error: e.error }); sub.classList.add("bad"); sub.title = e.error; }
|
|
else if (subs.length) {
|
|
for (const [i, x] of subs.entries()) {
|
|
if (i) sub.append(el("span", "sep", " · "));
|
|
const s = el("span", "pm-sub" + (x.signedIn ? " in" : ""));
|
|
// its built-in runs it, with its accounts: nothing to fix here
|
|
const builtin = !x.signedIn && cands.some((c) => c.id === x.pid);
|
|
s.append(el("span", "dot"), el("span", "", x.signedIn ? t("{name}: signed in", { name: x.name }) : builtin ? t("{name}: on magpie's built-in", { name: x.name }) : t("{name}: not signed in", { name: x.name })));
|
|
if (builtin) s.title = t("{name} runs on magpie's built-in, with your accounts; Move {name} here runs it on this plugin", { name: x.name });
|
|
sub.append(s);
|
|
}
|
|
} else sub.textContent = e.providers.length ? t("Signs in to {names}", { names: e.providers.join(t(", ")) }) : t("Signs in to nothing magpie can use");
|
|
who.append(sub);
|
|
r.append(logo(l?.icon || subs[0]?.icon), who);
|
|
const val = el("div", "val");
|
|
const b = busy.get(pkg) || busy.get(e.spec);
|
|
if (e.latest && e.version && newer(e.latest, e.version) && !e.off) {
|
|
const up = el("button", "text", b === "upgrade" ? t("Updating…") : t("Update"));
|
|
up.disabled = busy.size > 0;
|
|
up.onclick = () => act(pkg, "upgrade", { spec: e.spec }, () => status(t("{name} updated to v{v}", { name: l?.name || pkg, v: e.latest }), "ok"));
|
|
val.append(up);
|
|
} else if (isGit(e.spec) && !e.off) {
|
|
// npm has no newer version of a git one to offer: Update fetches
|
|
// its repository again
|
|
const up = el("button", "text", b === "upgrade" ? t("Updating…") : t("Update"));
|
|
up.title = t("Fetches it from {repo} again", { repo: e.spec });
|
|
up.disabled = busy.size > 0;
|
|
up.onclick = () => act(pkg, "upgrade", { spec: e.spec }, () => status(t("{name} is up to date", { name: e.package || e.spec }), "ok"));
|
|
val.append(up);
|
|
}
|
|
const here = !e.off && !e.error && !subs.some((x) => x.signedIn) && cands[0];
|
|
if (here && !ask) {
|
|
const mv = el("button", "text primary", b === "move" ? t("Moving…") : t("Move {name} here", { name: here.name }));
|
|
mv.title = t("Runs {name} on this plugin in place of the built-in, with the same accounts; nothing changes if one doesn't work through it", { name: here.name });
|
|
mv.disabled = busy.size > 0;
|
|
mv.onclick = () => move(here);
|
|
val.append(mv);
|
|
}
|
|
if (!e.off && !e.error && subs.some((x) => !x.signedIn)) {
|
|
const s = el("button", "text" + (here ? "" : " primary"), t("Sign in"));
|
|
s.onclick = () => pluginSignIn(subs.find((x) => !x.signedIn).id);
|
|
val.append(s);
|
|
}
|
|
const back = () => { if (moved.length) status(t("{names} is back on the built-in", { names }), "ok"); };
|
|
// switched on again: the built-ins that went back when it went off
|
|
// are offered its way again, in the row
|
|
const again = () => { if (movableOf(pkg).length) { asking = { pkg, op: "move" }; draw(); } };
|
|
const off = () => act(pkg, "off", { spec: e.spec, off: !e.off }, e.off ? again : back);
|
|
const remove = () => act(pkg, "remove", { spec: e.spec }, () => { status(t("{name} removed", { name: l?.name || pkg }), "ok"); back(); });
|
|
if (ask) {
|
|
// asked in the row, not a dialog: the user sees which go back
|
|
const yes = el("button", "text primary", t(ask === "move" ? "Move" : ask === "remove" ? "Remove" : "Switch off"));
|
|
yes.onclick = () => { asking = null; ask === "move" ? move(cands[0]) : ask === "remove" ? remove() : off(); };
|
|
const no = el("button", "text", t(ask === "move" ? "Not now" : "Cancel"));
|
|
no.onclick = () => { asking = null; draw(); };
|
|
val.append(no, yes);
|
|
r.append(val);
|
|
return r;
|
|
}
|
|
const onoff = el("button", "text", b === "off" && !e.off && moved.length ? t("Moving back…") : t(e.off ? "Switch on" : "Switch off"));
|
|
onoff.disabled = !!b;
|
|
onoff.onclick = () => { if (!e.off && moved.length) { asking = { pkg, op: "off" }; draw(); } else off(); };
|
|
const rm = el("button", "text quiet", b === "remove" ? t(moved.length ? "Moving back…" : "Removing…") : t("Remove"));
|
|
rm.title = moved.length ? t("{names} goes back to the built-in first, then the plugin is removed", { names }) : t("Removes the plugin and what it installed; its sign-ins are kept until you sign out");
|
|
rm.disabled = busy.size > 0;
|
|
rm.onclick = () => { if (moved.length) { asking = { pkg, op: "remove" }; draw(); } else remove(); };
|
|
val.append(onoff, rm);
|
|
r.append(val);
|
|
r.onclick = (ev) => { if (!ev.target.closest("button")) detail(l || (isGit(e.spec) ? { package: e.spec, name: e.package || e.spec, npm: { version: e.version, repository: gitWeb(e.spec) } } : { package: pkg, name: label(e.spec), npm: { version: e.latest } })); };
|
|
return r;
|
|
}
|
|
|
|
// the plugin's page: what it is, what npm says of it, and its README
|
|
async function detail(l) {
|
|
// a folder plugin: what it is comes from the folder, not from npm; a
|
|
// git one's from the folder it was installed to
|
|
const local = isPath(l.package) || isGit(l.package);
|
|
const ed = el("div", "editor pm-detail");
|
|
const hd = el("div", "ehead pm-dhead");
|
|
hd.append(logo(l.icon, true));
|
|
const who = el("div", "pm-who");
|
|
const nm = el("div", "pm-name");
|
|
nm.append(el("b", "", l.name));
|
|
const by = el("div", "pm-by"); // the words in a span of their own, or a flex row cuts them without the ellipsis
|
|
by.append(el("span", "", l.community ? t("magpie community") + " · " + l.package : [l.npm?.publisher, l.package].filter(Boolean).join(" · ")));
|
|
who.append(nm, by);
|
|
const actBox = el("div", "pm-dact");
|
|
// redrawn only when what it would say changes: a button made afresh
|
|
// loses the pointer's hover
|
|
let said = "";
|
|
const redrawAct = () => {
|
|
const b = actionFor(l.package, l.name, l.npm ? l : null);
|
|
const now = b.className + b.textContent + b.disabled;
|
|
if (now !== said) { said = now; actBox.replaceChildren(b); }
|
|
};
|
|
redrawAct();
|
|
hd.append(who, el("span", "grow"), actBox);
|
|
ed.append(hd);
|
|
const main = el("div", "pm-dbody");
|
|
const sum = summary(l);
|
|
if (sum) main.append(el("p", "pm-dsum", sum));
|
|
const facts = el("div", "pm-facts");
|
|
const fact = (k, v) => { if (!v) return; const f = el("div", "pm-fact"); f.append(el("span", "k", k), typeof v === "string" ? el("span", "v", v) : v); facts.append(f); };
|
|
fact(t("Version"), l.npm?.version && "v" + l.npm.version);
|
|
fact(t("Downloads"), l.npm?.weekly ? t("{n}/week", { n: count(l.npm.weekly) }) : "");
|
|
fact(t("License"), l.npm?.license);
|
|
const upd = el("span", "v", "…");
|
|
fact(t("Updated"), upd);
|
|
if (l.providers?.length) fact(t("Signs in to"), l.providers.join(t(", ")));
|
|
main.append(facts);
|
|
const links = el("div", "pm-links");
|
|
const link = (label, href) => {
|
|
if (!href) return;
|
|
const a = el("a", "pm-link");
|
|
a.href = href;
|
|
a.append(el("span", "", label), glyph(OUTL, 11, 1.5));
|
|
a.onclick = (ev) => { ev.preventDefault(); api("open", { url: href }).catch(() => {}); };
|
|
links.append(a);
|
|
};
|
|
// npm is where a package's page is; a folder on this computer has none
|
|
if (!local) link("npm", "https://www.npmjs.com/package/" + l.package);
|
|
link(t("Source"), l.npm?.repository);
|
|
if (l.npm?.homepage && l.npm.homepage !== l.npm.repository && !l.npm.homepage.startsWith(l.npm.repository + "#")) link(t("Homepage"), l.npm.homepage);
|
|
main.append(links);
|
|
if (l.replaces) main.append(el("p", "pm-note", t("magpie also signs in to this itself, for now; the plugin keeps it working if the built-in one is retired.")));
|
|
const readme = el("div", "pm-readme");
|
|
readme.append(el("div", "pm-rskel"), el("div", "pm-rskel short"), el("div", "pm-rskel"));
|
|
main.append(readme);
|
|
ed.append(main);
|
|
const bar = el("div", "bar");
|
|
bar.append(el("span", "note", t("Plugins are other people's code: they sign in and make the requests.")), el("span", "grow"));
|
|
const close = el("button", "text", t("Close"));
|
|
close.onclick = () => { stop(); closeModal(); };
|
|
bar.append(close);
|
|
ed.append(bar);
|
|
openModal(ed);
|
|
// the button follows an install or a sign-in made while it's open
|
|
const tick = setInterval(() => { if (!ed.isConnected) return stop(); redrawAct(); }, 600);
|
|
function stop() { clearInterval(tick); }
|
|
try {
|
|
const p = await api("plugins/page?name=" + encodeURIComponent(l.package));
|
|
upd.textContent = p.updated && !p.updated.startsWith("0001") ? new Date(p.updated).toLocaleDateString(document.documentElement.lang || undefined, { year: "numeric", month: "short", day: "numeric" }) : "—";
|
|
readme.replaceChildren(markdown(p.readme || t("No README")));
|
|
} catch (e) {
|
|
upd.textContent = "—";
|
|
readme.replaceChildren(el("p", "pm-note", t(local ? "Couldn't read the folder's README: {error}" : "npm didn't answer: {error}", { error: e.message })));
|
|
}
|
|
}
|
|
|
|
// markdown: a README as text, links and code — no HTML of its own, no
|
|
// pictures (a README's badges and screenshots come from anywhere)
|
|
function markdown(src) {
|
|
const root = el("div", "pm-md");
|
|
const lines = src.replace(/\r\n?/g, "\n").replace(/<!--[\s\S]*?-->|<(script|style)\b[\s\S]*?<\/\1\s*>/gi, "")
|
|
.replace(/\[\s*!\[[^\]]*\]\([^)]*\)\s*\]\([^)]*\)/g, "") // a badge: a picture in a link
|
|
.split("\n");
|
|
// a link: to the web, opened outside; to a heading of the README
|
|
// ("#-quick-start"), there, as the reader asked
|
|
const link = (href, text, into) => {
|
|
const a = el("a", "", "");
|
|
a.href = href;
|
|
inline(text.replace(/!\[[^\]]*\]\([^)]*\)/g, "").trim() || href, a);
|
|
a.onclick = (ev) => {
|
|
ev.preventDefault();
|
|
if (href[0] !== "#") return api("open", { url: href }).catch(() => {});
|
|
const to = root.querySelector(`[data-slug="${CSS.escape(decodeURIComponent(href.slice(1)).toLowerCase())}"]`);
|
|
if (to && window.scrollOnPurpose?.(ev)) to.scrollIntoView({ block: "start", behavior: "smooth" });
|
|
};
|
|
into.append(a);
|
|
};
|
|
const inline = (text, into) => {
|
|
const re = /(`[^`]+`)|\*\*(.+?)\*\*|__(.+?)__|\*([^*\s][^*]*)\*|!\[[^\]]*\]\([^)]*\)|\[([^\]]+)\]\(([^)\s]+)[^)]*\)|<(https?:\/\/[^>\s]+)>|<a\s[^>]*?href\s*=\s*["']([^"']+)["'][^>]*>([\s\S]*?)<\/a\s*>|<\/?[a-zA-Z][^>]*>/g;
|
|
let at = 0, m;
|
|
while ((m = re.exec(text))) {
|
|
if (m.index > at) into.append(text.slice(at, m.index));
|
|
if (m[1]) into.append(el("code", "", m[1].slice(1, -1)));
|
|
else if (m[2] || m[3]) { const b = el("strong"); inline(m[2] || m[3], b); into.append(b); }
|
|
else if (m[4]) { const e = el("em"); inline(m[4], e); into.append(e); }
|
|
else if (m[5] || m[8]) {
|
|
const href = m[6] || m[8], text = m[5] || m[9].replace(/<[^>]*>/g, "");
|
|
if (/^https?:\/\//.test(href) || /^#./.test(href)) link(href, text, into);
|
|
else inline(text, into);
|
|
} else if (m[7]) link(m[7], m[7], into);
|
|
at = re.lastIndex;
|
|
}
|
|
if (at < text.length) into.append(text.slice(at));
|
|
};
|
|
// GitHub's anchor for a heading: lower case, its punctuation and
|
|
// emoji gone, spaces as dashes ("💡 Philosophy" is "-philosophy")
|
|
const slug = (s) => s.trim().toLowerCase().replace(/[^\p{L}\p{N}\s_-]/gu, "").replace(/\s/g, "-");
|
|
let i = 0, list = null, para = null;
|
|
const flush = () => { list = null; para = null; };
|
|
while (i < lines.length) {
|
|
const line = lines[i];
|
|
const fence = line.match(/^\s*(```|~~~)\s*([\w+-]*)/);
|
|
if (fence) {
|
|
flush();
|
|
const code = [];
|
|
i++;
|
|
while (i < lines.length && !lines[i].trimStart().startsWith(fence[1])) code.push(lines[i++]);
|
|
i++;
|
|
const pre = el("pre", "", "");
|
|
pre.append(el("code", "", code.join("\n")));
|
|
root.append(pre);
|
|
continue;
|
|
}
|
|
const h = line.match(/^(#{1,6})\s+(.*?)\s*#*\s*$/);
|
|
if (h) {
|
|
flush();
|
|
const e = el("h" + Math.min(6, h[1].length + 2));
|
|
inline(h[2], e);
|
|
e.dataset.slug = slug(e.textContent);
|
|
if (e.textContent.trim()) root.append(e);
|
|
i++;
|
|
continue;
|
|
}
|
|
if (/^\s*\|.*\|\s*$/.test(line) && /^\s*\|?\s*:?-{2,}/.test(lines[i + 1] || "")) {
|
|
flush();
|
|
const cells = (s) => s.trim().replace(/^\||\|$/g, "").split("|").map((c) => c.trim());
|
|
const wrap = el("div", "pm-table");
|
|
const table = el("table");
|
|
const tr = el("tr");
|
|
for (const c of cells(line)) { const th = el("th"); inline(c, th); tr.append(th); }
|
|
table.append(tr);
|
|
i += 2;
|
|
while (i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i])) {
|
|
const r = el("tr");
|
|
for (const c of cells(lines[i])) { const td = el("td"); inline(c, td); r.append(td); }
|
|
table.append(r);
|
|
i++;
|
|
}
|
|
wrap.append(table);
|
|
root.append(wrap);
|
|
continue;
|
|
}
|
|
const li = line.match(/^\s*([-*+]|\d+[.)])\s+(.*)$/);
|
|
if (li) {
|
|
para = null;
|
|
const ordered = /\d/.test(li[1]);
|
|
if (!list || list.tagName !== (ordered ? "OL" : "UL")) { list = el(ordered ? "ol" : "ul"); root.append(list); }
|
|
const item = el("li");
|
|
inline(li[2].replace(/^\[( |x)\]\s*/i, (s, x) => x.trim() ? "✓ " : "☐ "), item);
|
|
list.append(item);
|
|
i++;
|
|
continue;
|
|
}
|
|
const q = line.match(/^\s*>\s?(.*)$/);
|
|
if (q) {
|
|
flush();
|
|
const bq = el("blockquote");
|
|
const parts = [];
|
|
while (i < lines.length && /^\s*>/.test(lines[i])) parts.push(lines[i++].replace(/^\s*>\s?/, ""));
|
|
inline(parts.join(" ").replace(/^\[!(\w+)\]\s*/, (s, k) => k.toUpperCase() + ": "), bq);
|
|
root.append(bq);
|
|
continue;
|
|
}
|
|
if (/^\s*([-*_])\s*\1\s*\1[\s\1]*$/.test(line)) { flush(); root.append(el("hr")); i++; continue; }
|
|
if (!line.trim()) { flush(); i++; continue; }
|
|
if (list && /^\s{2,}\S/.test(line)) { const last = list.lastElementChild; last.append(" "); inline(line.trim(), last); i++; continue; }
|
|
if (!para) { para = el("p"); root.append(para); list = null; } else para.append(" ");
|
|
inline(line.trim(), para);
|
|
if (!para.textContent.trim() && !para.children.length) { para.remove(); para = null; }
|
|
i++;
|
|
}
|
|
for (const p of root.querySelectorAll("p")) if (!p.textContent.trim()) p.remove();
|
|
return root;
|
|
}
|
|
|
|
// opened on the Plugins tab (?view=plugins): app.js showed it before
|
|
// this file was here to load it
|
|
if (view === "plugins") load();
|
|
})();
|