From fc248e381f2e9145d047018417ecf926cdbaf193 Mon Sep 17 00:00:00 2001 From: volcanic <72688335+volcanicll@users.noreply.github.com> Date: Thu, 1 Oct 2026 18:46:56 +0800 Subject: [PATCH] plugins: the folder a plugin lives in is picked, not typed, and its page reads that folder's README (#411) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings → Plugins adds a plugin by npm package name or by a path, and the path had to be typed in full: the market's field was the only way in, with nothing to pick the folder from. And a plugin added from a folder had no page of its own — its README comes from plugin.Readme, which answers only for an npm package name ("%q isn't an npm package name"), so opening one said "npm didn't answer" and offered an npm link for a folder that npm has never heard of. The field now has the system's folder picker beside it, and a folder plugin's page shows the README that folder carries. - POST /api/plugins/choose asks Windows.ChooseFolder, which the app answers with the system's panel — the Library's "Choose a project" already used it, and a page served by another magpie forwards it to the magpie it is served by. pluginsState carries "picker" beside "bun", false for `magpie web`: a browser has no such panel (webHost.ChooseFolder refuses it), and the page is then left with the field to type into, as before. - plugin.Readme takes a path: folderReadme reads README.md whatever its case, else a markdown or a text one named beside it, and says so when the folder has none rather than answering with npm's silence. Updated is that file's mtime, and the text is capped at 200 KB as npm's answer is. - A folder plugin's page drops what only npm can say: no npm link, no version, downloads or license, and a failure names the folder rather than npm. Its row and its title name the folder it was added from (opencode-mine, not /Users/me/plugins/opencode-mine). TestReadmeFromFolder (internal/plugin) fails without the change: with a README.txt and an unsuffixed README beside it, the README.md is still the one read; ReadMe.MD is too, and through file:// and a trailing .; a folder with none is an error, as is one that isn't there. TestPluginsPickerWhereItCan (internal/gui) fails without the change: the picker is offered for the app, not for `magpie web` nor for no host at all. plugin-local.test.cjs (new; Chromium and WebKit, en and zh) fails without the change: the picker is beside the field and titled as such, a click asks for it and what it answers is what the field holds and takes the focus, what the field holds is what Install posts, the Installed row names the folder rather than its path, its page renders that folder's README with no npm link, and a market that offers no picker draws no button (english and Chinese). Not only the faked page: a sandbox `magpie web` in a HOME of its own, with a plugin added by path, read that folder's README.md — its mtime as Updated — and listed it by the folder's name. go vet ./internal/..., go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./internal/gui/ pass; plugin-market, plugin-signin and plugin-local pass in Chromium and WebKit, en and zh. TestPluginProxy (internal/plugin) and the hermes tests (internal/agent, internal/profile) fail on main on this machine too — its own proxy and a hermes install it finds — and are unrelated. Not done: the panel itself hasn't been clicked in the app. It is the system's own, and the call under it is the one the Library's folder chooser already uses, but the screenshots here are the page with a faked backend underneath (the app's own picker is offered, `magpie web` has none), plus the real `magpie web` for the README. --- internal/gui/assets/i18n.js | 3 + internal/gui/assets/plugins.css | 4 + internal/gui/assets/plugins.js | 48 ++++++- internal/gui/plugins.go | 27 ++-- internal/gui/plugins_picker_test.go | 31 +++++ internal/gui/tests/plugin-local.test.cjs | 154 +++++++++++++++++++++++ internal/plugin/market.go | 42 ++++++- internal/plugin/market_test.go | 69 ++++++++++ 8 files changed, 365 insertions(+), 13 deletions(-) create mode 100644 internal/gui/plugins_picker_test.go create mode 100644 internal/gui/tests/plugin-local.test.cjs diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index 6c2228e1..683f0971 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -2044,6 +2044,8 @@ const I18N = { "A plugin is someone else's code with your sign-in: install the ones you trust.": "插件是他人的代码,会拿到你的登录:只安装你信任的。", "Have one in mind?": "已有想装的?", "npm package or a folder on this computer": "npm 包名,或本机上的文件夹", + "Choose a folder on this computer": "选择本机上的一个文件夹", + "Choose a folder": "选择文件夹", "No plugins yet": "还没有插件", "Find a subscription in Discover and install it; it signs in from here.": "在「发现」中找到订阅并安装,然后在这里登录。", "Discover plugins": "发现插件", @@ -2067,6 +2069,7 @@ const I18N = { "Plugins are other people's code: they sign in and make the requests.": "插件是他人的代码:由它登录并发出请求。", "No README": "没有 README", "npm didn't answer: {error}": "npm 没有响应:{error}", + "Couldn't read the folder's README: {error}": "无法读取该文件夹的 README:{error}", "More in Plugins": "插件中还有更多", "Subscriptions magpie doesn't sign in to itself: install a plugin for one in the Plugins tab": "magpie 未内置的订阅:在「插件」页安装对应插件即可", "look for a plugin": "找找插件", diff --git a/internal/gui/assets/plugins.css b/internal/gui/assets/plugins.css index 8607512e..e1d2ecac 100644 --- a/internal/gui/assets/plugins.css +++ b/internal/gui/assets/plugins.css @@ -108,6 +108,10 @@ .pm-mlabel { font-size: 12px; color: var(--muted); } .pm-minput { flex: 1; min-width: 180px; height: 26px; padding: 0 9px; border-radius: 7px; border: 1px solid var(--line); background: var(--card); color: var(--fg); font: 12px var(--code); outline: none; } .pm-minput:focus { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent); } +/* the system's folder picker, beside the field it fills */ +.pm-browse { display: inline-flex; align-items: center; justify-content: center; width: 26px; height: 26px; flex: none; padding: 0; border-radius: 7px; border: 1px solid var(--line); background: var(--card); color: var(--muted); cursor: pointer; transition: color .14s var(--ease), border-color .14s var(--ease); } +.pm-browse:hover:not(:disabled) { color: var(--fg); border-color: var(--faint); } +.pm-browse:disabled { opacity: .5; cursor: default; } /* Installed */ .pm-list .row { min-height: 54px; padding: 8px 10px 8px 12px; gap: 11px; cursor: default; transition: background .14s var(--ease); } diff --git a/internal/gui/assets/plugins.js b/internal/gui/assets/plugins.js index 3eb99c21..e8b98b88 100644 --- a/internal/gui/assets/plugins.js +++ b/internal/gui/assets/plugins.js @@ -24,8 +24,13 @@ 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; }; 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); @@ -408,10 +413,40 @@ if (!s || busy.size) return; act("+", "add", { spec: s }, () => status(t("{name} is installed", { name: s }), "ok")); }; - box.append(spec, go); + 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; @@ -445,7 +480,7 @@ const r = el("div", "row pm-row" + (e.off ? " off" : "")); const who = el("div", "who"); const nm = el("div", "name"); - nm.append(el("span", "", l?.name || pkg)); + nm.append(el("span", "", l?.name || label(e.spec))); 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) { @@ -536,12 +571,14 @@ 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 || { package: pkg, name: pkg, npm: { version: e.latest } }); }; + r.onclick = (ev) => { if (!ev.target.closest("button")) detail(l || { 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 + const local = isPath(l.package); const ed = el("div", "editor pm-detail"); const hd = el("div", "ehead pm-dhead"); hd.append(logo(l.icon, true)); @@ -584,7 +621,8 @@ a.onclick = (ev) => { ev.preventDefault(); api("open", { url: href }).catch(() => {}); }; links.append(a); }; - link("npm", "https://www.npmjs.com/package/" + l.package); + // 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); @@ -609,7 +647,7 @@ readme.replaceChildren(markdown(p.readme || t("No README"))); } catch (e) { upd.textContent = "—"; - readme.replaceChildren(el("p", "pm-note", t("npm didn't answer: {error}", { error: e.message }))); + readme.replaceChildren(el("p", "pm-note", t(local ? "Couldn't read the folder's README: {error}" : "npm didn't answer: {error}", { error: e.message }))); } } diff --git a/internal/gui/plugins.go b/internal/gui/plugins.go index 7e40ca75..4b2d66f7 100644 --- a/internal/gui/plugins.go +++ b/internal/gui/plugins.go @@ -84,13 +84,16 @@ type pluginsJSON struct { Bun bool `json:"bun"` // Bun is here; adding the first plugin downloads it otherwise BunVer string `json:"bunVersion"` Error string `json:"error,omitempty"` // the plugins couldn't be asked + // Picker is whether this magpie can show the system's folder picker: + // `magpie web` has none, and the page then offers no button for it + Picker bool `json:"picker"` // Movable are the built-ins with accounts a plugin could run, which // its card and its row offer to move Movable []provider.MoveCandidate `json:"movable"` } -func pluginsState(ctx context.Context) pluginsJSON { - s := pluginsJSON{Plugins: []pluginEntryJSON{}, Bun: plugin.HasBun(), BunVer: plugin.BunVersion, Movable: provider.MoveCandidates()} +func pluginsState(ctx context.Context, w Windows) pluginsJSON { + s := pluginsJSON{Plugins: []pluginEntryJSON{}, Bun: plugin.HasBun(), BunVer: plugin.BunVersion, Movable: provider.MoveCandidates(), Picker: w != nil && !isWeb(w)} l := plugin.Load() errs := map[string]string{} names := map[string][]string{} @@ -137,11 +140,11 @@ type pluginListingJSON struct { NPM plugin.NPM `json:"npm"` } -func pluginMarketState(ctx context.Context) pluginMarketJSON { +func pluginMarketState(ctx context.Context, w Windows) pluginMarketJSON { var ls []plugin.Listing var st pluginsJSON done := make(chan struct{}) - go func() { st = pluginsState(ctx); close(done) }() + go func() { st = pluginsState(ctx, w); close(done) }() ls = plugin.Market(ctx) names := []string{} for _, l := range ls { @@ -172,7 +175,7 @@ func pluginRoutes(mux *http.ServeMux, w Windows) { mux.HandleFunc("GET /api/plugins/market", func(rw http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), time.Minute) defer cancel() - writeJSON(rw, pluginMarketState(ctx)) + writeJSON(rw, pluginMarketState(ctx, w)) }) mux.HandleFunc("GET /api/plugins/search", func(rw http.ResponseWriter, r *http.Request) { hits, err := plugin.Search(r.Context(), r.URL.Query().Get("q")) @@ -193,7 +196,17 @@ func pluginRoutes(mux *http.ServeMux, w Windows) { mux.HandleFunc("GET /api/plugins", func(rw http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), time.Minute) defer cancel() - writeJSON(rw, pluginsState(ctx)) + writeJSON(rw, pluginsState(ctx, w)) + }) + // a folder on this computer, from the system's picker; "" when the + // user cancels it + mux.HandleFunc("POST /api/plugins/choose", func(rw http.ResponseWriter, r *http.Request) { + dir, err := w.ChooseFolder("Choose a plugin folder") + if err != nil { + fail(rw, err) + return + } + writeJSON(rw, map[string]string{"dir": dir}) }) // add, remove, update, turn on or off: each answers with the list mux.HandleFunc("POST /api/plugins/{op}", func(rw http.ResponseWriter, r *http.Request) { @@ -228,7 +241,7 @@ func pluginRoutes(mux *http.ServeMux, w Windows) { fail(rw, err) return } - writeJSON(rw, pluginsState(ctx)) + writeJSON(rw, pluginsState(ctx, w)) }) // signing in to a plugin's provider: the method's questions one at a // time, then a browser (OAuth) or a key diff --git a/internal/gui/plugins_picker_test.go b/internal/gui/plugins_picker_test.go new file mode 100644 index 00000000..2130f7db --- /dev/null +++ b/internal/gui/plugins_picker_test.go @@ -0,0 +1,31 @@ +package gui + +import ( + "context" + "testing" +) + +// pickerWin is the app as a Windows: the picker is offered for it, and no +// other method is reached from a page this test doesn't run. +type pickerWin struct{ Windows } + +// The plugin market's folder picker is offered only where magpie can show +// one: the app has the system's, `magpie web` serves a browser and has +// none, and a page with no host at all is offered none either. +func TestPluginsPickerWhereItCan(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + for _, c := range []struct { + name string + w Windows + want bool + }{ + {"the app", &pickerWin{}, true}, + {"magpie web", webHost{}, false}, + {"no host", nil, false}, + } { + if got := pluginsState(context.Background(), c.w).Picker; got != c.want { + t.Errorf("%s: Picker = %v, want %v", c.name, got, c.want) + } + } +} diff --git a/internal/gui/tests/plugin-local.test.cjs b/internal/gui/tests/plugin-local.test.cjs new file mode 100644 index 00000000..b4dd4d7e --- /dev/null +++ b/internal/gui/tests/plugin-local.test.cjs @@ -0,0 +1,154 @@ +// Run with Node's test runner and Playwright on the module path; see README.md. +// Plugins kept on this computer: the market's field takes a folder, chosen +// with the system's picker rather than typed — and `magpie web`, which has +// no picker, offers no button for it. A folder plugin's page shows the +// README that folder carries, where npm knows nothing of it: no npm link, +// the version facts left out, and the folder's own name for the plugin. +// English and Chinese; the API is faked here. +const assert = require("node:assert/strict"); +const fs = require("node:fs/promises"); +const path = require("node:path"); +const { test } = require("node:test"); +const { chromium, webkit } = require("playwright"); + +const assets = path.resolve(__dirname, "../assets"); + +const FOLDER = "/Users/me/plugins/opencode-mine"; +const README = [ + "# opencode-mine", + "", + "A plugin kept in a folder, not on npm.", + "", + "## Install", + "", + "```sh", + "magpie plugin add " + FOLDER, + "```", +].join("\n"); + +function server(lang, asked, opts = {}) { + const picker = opts.picker !== false; + const installed = [{ spec: FOLDER, providers: [], version: "" }]; + const market = () => ({ listings: [], state: { bun: true, bunVersion: "1.3.0", plugins: installed, picker } }); + return async (route) => { + const url = new URL(route.request().url()); + const json = (data) => route.fulfill({ json: data }); + const body = () => route.request().postDataJSON(); + if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"light",web:false};` }); + if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" }); + if (url.pathname === "/api/state") return json({ agents: [], profiles: [], settings: { lang, theme: "light" } }); + if (url.pathname === "/api/providers") return json({ providers: [], presets: [], excluded: [], gateway: { running: true, window: true }, plugins: [] }); + if (url.pathname === "/api/groups") return json({ groups: [], pools: [] }); + if (url.pathname === "/api/gateway/trace") return json({ routes: [] }); + if (url.pathname === "/api/plugins/market") return json(market()); + if (url.pathname === "/api/plugins/search") return json({ hits: [] }); + if (url.pathname === "/api/plugins/page") { + asked.push(["page", url.searchParams.get("name")]); + return json({ readme: README, updated: "2026-09-30T10:00:00Z" }); + } + if (url.pathname === "/api/plugins/choose") { + asked.push(["choose", body()]); + return json({ dir: opts.chosen === undefined ? FOLDER : opts.chosen }); + } + if (url.pathname === "/api/plugins/add") { + const b = body(); + asked.push(["add", b]); + installed.push({ spec: b.spec, providers: [], version: "" }); + return json({}); + } + if (url.pathname === "/api/open") { asked.push(["open", body()]); return route.fulfill({ status: 204 }); } + if (url.pathname.startsWith("/api/")) return json({}); + if (!/^\/[\w./-]*$/.test(url.pathname) || url.host !== "magpie.test") { asked.push(["fetched", url.href]); return route.fulfill({ status: 404, body: "" }); } + const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname); + const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)]; + try { await route.fulfill({ body: await fs.readFile(file), contentType }); } catch { await route.fulfill({ status: 404, body: "" }); } + }; +} + +const L = { + en: { tab: "Plugins", pick: "Choose a folder on this computer", install: "Install", folderName: "opencode-mine", readme: "Install" }, + zh: { tab: "插件", pick: "选择本机上的一个文件夹", install: "安装", folderName: "opencode-mine", readme: "Install" }, +}; + +const shot = async (loc, name) => { + if (!process.env.ARTIFACT_DIR) return; + await fs.mkdir(process.env.ARTIFACT_DIR, { recursive: true }); + await loc.screenshot({ path: path.join(process.env.ARTIFACT_DIR, name + ".png") }); +}; + +for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) { + test(engine + ": a plugin kept on this computer", async (t) => { + const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL || "chromium" })); + t.after(() => browser.close()); + for (const lang of ["en", "zh"]) { + await t.test(lang, async (t2) => { + const w = L[lang]; + const asked = []; + const page = await (await browser.newContext({ viewport: { width: 980, height: 820 } })).newPage(); + page.setDefaultTimeout(5000); + const errors = []; + page.on("pageerror", (e) => errors.push(e.message)); + await page.route("**/*", server(lang, asked)); + + await page.goto("http://magpie.test/?view=plugins"); + const view = page.locator("#view-plugins"); + await view.waitFor({ state: "visible" }); + + // Discover: the folder picker sits beside the field, and fills it + const manual = view.locator(".pm-manual"); + await manual.waitFor(); + const browse = manual.locator(".pm-browse"); + assert.equal(await browse.count(), 1, "the picker is offered"); + assert.equal(await browse.getAttribute("title"), w.pick); + const field = manual.locator(".pm-minput"); + assert.equal(await field.inputValue(), ""); + await shot(manual, `plugins-picker-${engine}-${lang}`); + await browse.click(); + await page.waitForFunction((f) => document.querySelector("#view-plugins .pm-minput").value === f, FOLDER); + assert.deepEqual(asked.find(([k]) => k === "choose"), ["choose", {}]); + assert.equal(await page.evaluate(() => document.querySelector("#view-plugins .pm-minput") === document.activeElement), true, "the field takes the focus"); + assert.deepEqual(errors, []); + + // ...and what it filled is what Install sends + await manual.locator("button", { hasText: new RegExp("^" + w.install + "$") }).click(); + await page.waitForFunction(() => !!document.querySelector("#view-plugins .pm-minput")); + assert.deepEqual(asked.find(([k]) => k === "add")[1], { spec: FOLDER }); + + // Installed: the folder's own name, its page from the folder's README + await view.locator(".lib-tabs .opt", { hasText: w.tab === "Plugins" ? "Installed" : "已安装" }).click(); + const row = view.locator(".pm-row").first(); + await row.waitFor(); + assert.equal((await row.locator(".name span").first().innerText()).trim(), w.folderName, "the folder names it, not the whole path"); + assert.doesNotMatch(await row.innerText(), /\/Users\/me\/plugins/, "the path isn't the plugin's name"); + await shot(view, `plugins-installed-local-${engine}-${lang}`); + await row.click(); + const dlg = page.locator("#modal .pm-detail"); + await dlg.locator(".pm-md h4", { hasText: w.readme }).waitFor(); + assert.equal((await dlg.locator(".pm-md h3").innerText()).trim(), w.folderName); + assert.match(await dlg.locator(".pm-md").innerText(), /A plugin kept in a folder, not on npm\./); + assert.deepEqual(asked.filter(([k]) => k === "page").pop(), ["page", FOLDER], "the folder is what's asked for"); + assert.equal(await dlg.locator(".pm-links a", { hasText: "npm" }).count(), 0, "a folder has no npm page"); + assert.match(await dlg.locator(".pm-by").innerText(), /\/Users\/me\/plugins\/opencode-mine/); + await shot(page.locator("#modal .dialog"), `plugins-local-page-${engine}-${lang}`); + await page.locator("#modal .bar button").last().click().catch(() => {}); + assert.deepEqual(errors, []); + }); + } + + // `magpie web` has no picker: no button, and the field is typed into + await t.test("no picker where magpie can't show one", async () => { + const asked = []; + const page = await (await browser.newContext({ viewport: { width: 980, height: 820 } })).newPage(); + page.setDefaultTimeout(5000); + await page.route("**/*", server("en", asked, { picker: false })); + await page.goto("http://magpie.test/?view=plugins"); + const manual = page.locator("#view-plugins .pm-manual"); + await manual.waitFor(); + assert.equal(await manual.locator(".pm-browse").count(), 0, "no picker to offer"); + assert.equal(await manual.locator(".pm-minput").count(), 1, "the field is still there to type into"); + assert.equal(await manual.locator("button", { hasText: /^Install$/ }).count(), 1); + assert.deepEqual(asked.filter(([k]) => k === "choose"), []); + await shot(manual, `plugins-picker-web-${engine}`); + }); + }); +} diff --git a/internal/plugin/market.go b/internal/plugin/market.go index 3dc4415e..387d1508 100644 --- a/internal/plugin/market.go +++ b/internal/plugin/market.go @@ -357,8 +357,12 @@ type Page struct { Updated time.Time `json:"updated"` } -// Readme is the package's README, as npm has it. +// Readme is the package's README, as npm has it, or — for a plugin added +// from a folder on this computer — the one that folder carries. func Readme(ctx context.Context, name string) (Page, error) { + if IsPath(name) { + return folderReadme(Target(name)) + } if !pkgName.MatchString(name) { return Page{}, fmt.Errorf("%q isn't an npm package name", name) } @@ -382,6 +386,42 @@ func Readme(ctx context.Context, name string) (Page, error) { return Page{Readme: d.Readme, Updated: d.Time["modified"]}, nil } +// folderReadme is the README a plugin's own folder carries: README.md, +// whatever its case, else a markdown or text one named beside it. A folder +// without one says so, rather than answering with npm's silence. +func folderReadme(dir string) (Page, error) { + ents, err := os.ReadDir(dir) + if err != nil { + return Page{}, err + } + named := map[string]os.DirEntry{} + for _, e := range ents { + if !e.IsDir() { + named[strings.ToLower(e.Name())] = e + } + } + for _, want := range []string{"readme.md", "readme.markdown", "readme.txt", "readme"} { + e, ok := named[want] + if !ok { + continue + } + b, err := os.ReadFile(filepath.Join(dir, e.Name())) + if err != nil { + return Page{}, err + } + const most = 200 << 10 + if len(b) > most { + b = b[:most] + } + var at time.Time + if fi, err := e.Info(); err == nil { + at = fi.ModTime() + } + return Page{Readme: string(b), Updated: at}, nil + } + return Page{}, fmt.Errorf("%s has no README: add a README.md beside its package.json", dir) +} + // Installed is the version of the plugin spec installed, "" for a path or // one not installed. func Installed(spec string) string { diff --git a/internal/plugin/market_test.go b/internal/plugin/market_test.go index ec1c02ff..76c939af 100644 --- a/internal/plugin/market_test.go +++ b/internal/plugin/market_test.go @@ -4,6 +4,9 @@ import ( "context" "net/http" "net/http/httptest" + "os" + "path/filepath" + "strings" "testing" ) @@ -98,3 +101,69 @@ func TestMarketIcon(t *testing.T) { } } } + +// A plugin added from a folder is read from that folder, where npm has +// nothing to say of it: README.md whatever its case, else a markdown or +// text one beside it, and a folder with none says so. +func TestReadmeFromFolder(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + write := func(name, body string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + + if _, err := Readme(ctx, dir); err == nil { + t.Fatal("a folder with no README answered") + } + if _, err := Readme(ctx, filepath.Join(dir, "not-there")); err == nil { + t.Fatal("a folder that isn't there answered") + } + + // the lesser names are there first: the README.md is still the one read + write("README.txt", "a text one") + write("README", "an unsuffixed one") + write("readme.md", "# the plugin's own\n\nInstall it.\n") + p, err := Readme(ctx, dir) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(p.Readme, "# the plugin's own") { + t.Errorf("read %q, want the README.md", p.Readme) + } + if p.Updated.IsZero() { + t.Error("no modified time") + } + + // whatever case the folder spells it in, and through file:// + if err := os.Remove(filepath.Join(dir, "readme.md")); err != nil { + t.Fatal(err) + } + write("ReadMe.MD", "# spelled otherwise\n") + for _, spec := range []string{dir, "file://" + dir, filepath.Join(dir, ".")} { + p, err := Readme(ctx, spec) + if err != nil { + t.Fatalf("%s: %v", spec, err) + } + if !strings.HasPrefix(p.Readme, "# spelled otherwise") { + t.Errorf("%s: read %q, want the ReadMe.MD", spec, p.Readme) + } + } + + // the text one, once there is no markdown at all + if err := os.Remove(filepath.Join(dir, "ReadMe.MD")); err != nil { + t.Fatal(err) + } + if err := os.Remove(filepath.Join(dir, "README")); err != nil { + t.Fatal(err) + } + p, err = Readme(ctx, dir) + if err != nil { + t.Fatal(err) + } + if p.Readme != "a text one" { + t.Errorf("read %q, want the README.txt", p.Readme) + } +}