Official
market
The plugin marketplace: browse, install through an agent's scan, update and publish plugins, and the marketplace tools of agents in the Plugins workspace.
The app opens the listing; nothing installs until an agent in your Plugins workspace has read the files and you enable the plugin. In a terminal: cvg install convergence/market@0.1.0
Permissions in 0.1.0
Files
views.ts40 KB
// The marketplace page's trees: browse, listing, updates and bug reports,
// plus the rail button. Rendering only reads state; view handlers run the
// controller's effects, and plain input handlers mutate state and redraw.
import { html, ui } from "convergence";
import type { Element, ValueEvent } from "convergence";
import type { EffectViewContext } from "convergence/effect";
import {
FIX_NOTE,
RISK_NAMES,
SORTS,
badgeCount,
effortChoices,
formatCount,
isOfficial,
languageOf,
optionOf,
permissionRows,
riskTone,
runsAnything,
} from "./logic.ts";
import { ROUTE } from "./state.ts";
import type { Menus } from "./state.ts";
import type { Controller } from "./controller.ts";
import type { BugAttachment, DraftPlugin, Listing, MarketComment, MarketUpdateItem, SentReport } from "./remote.ts";
// Layout for the templates below: a flex column, a row that centres its
// children across, a child that takes the free space, the whole parent, a
// centred reading column, and something clickable.
const column = { display: "flex", flexDirection: "column" };
const row = { display: "flex", flexDirection: "row", alignItems: "center" };
const fill = { flexGrow: 1, flexShrink: 1, flexBasis: "0%", minWidth: 0, minHeight: 0 };
const full = { width: "100%", height: "100%" };
const reading = { maxWidth: 880, width: "100%", marginX: "auto" };
const clickable = { group: "hover", cursor: "pointer" };
type Ctx = EffectViewContext<object>;
function badge(text: string, variant: "default" | "info" | "success" | "warning" | "danger"): Element {
return html`<badge variant=${variant}>${text}</badge>`;
}
function publisherLine(entry: Listing): Element | null {
const publisher = entry.publisher ?? {};
const official = publisher.verified || isOfficial(entry.id);
return html` <div ...${row} gap=${6}>
${official ? html`<icon name="badge-check" small tone="primary" />` : null}
<text style="body" size="xs" tone="muted"
>${publisher.displayName || publisher.name || entry.id.split("/")[0]}</text
>
${official ? badge("Official", "success") : null}
</div>`;
}
function resultRow(ctx: Ctx, market: Controller, result: Listing): Element | null {
const stats = [
result.latestVersion ? `v${result.latestVersion}` : null,
`${formatCount(result.downloads)} installs`,
result.rating ? `★ ${result.rating.average} (${result.rating.count})` : null,
]
.filter(Boolean)
.join(" · ");
return html` <div
key=${result.id}
...${column}
...${clickable}
gap=${4}
padding="medium"
borderRadius="md"
hover=${{ background: "hover" }}
onClick=${() => ctx.run(market.openListing(result.id))}
>
<div ...${row} gap="small">
<icon name="package" small tone="muted" />
<text style="body" size="sm" bold>${result.name ?? result.id}</text>
<text style="body" size="xs" tone="faint">${result.id}</text>
<spacer />
<text style="body" size="xs" tone="muted">${stats}</text>
</div>
${publisherLine(result)}
${result.description ? html`<text style="body" size="xs" tone="muted" lineClamp=${2}>${result.description}</text>` : null}
</div>`;
}
function browsePage(ctx: Ctx, market: Controller): Element | null {
const { state } = market;
const chips = [{ id: null, title: "All" }, ...state.categories].map(
(category) =>
html` <button
id=${`market-category-${category.id ?? "all"}`}
label=${category.title}
small
variant="chip"
active=${state.category === category.id}
onClick=${() => {
market.state.category = category.id;
ctx.run(market.search());
}}
/>`,
);
let body: Element | null;
if (state.searchError) body = html`<empty>${`The marketplace did not answer: ${state.searchError}`}</empty>`;
else if (!state.results)
body = html`<div ...${row} gap="small" padding="medium">
<spinner /><text style="muted">Loading plugins…</text>
</div>`;
else if (!state.results.length) body = html`<empty>No plugins found.</empty>`;
else body = html`<div ...${column} gap=${2}>${state.results.map((result) => resultRow(ctx, market, result))}</div>`;
const more =
state.results && state.results.length < state.total
? html`<div ...${row} padding="small">
<button
id="market-more"
label="More"
small
variant="outline"
loading=${state.searching}
onClick=${() => ctx.run(market.search(true))}
/>
</div>`
: null;
return html` <div ...${column} gap="medium">
<div ...${row} gap="small">
<div ...${row} ...${fill}>
<input
id="market-search"
value=${state.query}
placeholder="Search plugins"
submitOnEnter
onChange=${(e: ValueEvent<string>) => {
market.state.query = e.value;
}}
onSubmit=${(e: ValueEvent<string>) => {
market.state.query = e.value;
ctx.run(market.search());
}}
/>
</div>
<div ...${row} maxWidth=${170}>
<select
id="market-sort"
value=${state.sort}
small
options=${SORTS}
onChange=${(e: ValueEvent<string>) => {
market.state.sort = e.value;
ctx.run(market.search());
}}
/>
</div>
<button
id="market-search-go"
icon="search"
small
variant="outline"
tooltip="Search"
loading=${state.searching}
onClick=${() => ctx.run(market.search())}
/>
</div>
<div ...${row} gap=${6} flexWrap="wrap">${chips}</div>
${body} ${more}
</div>`;
}
function permissionList(ctx: Ctx, market: Controller, permissions: unknown, official: boolean): Element | null {
const { settings, fetch } = market.settingsOf(permissions);
if (fetch) ctx.run(fetch);
const rows = permissionRows(permissions, { official, describe: true, settings });
if (!rows.length) return html`<text style="body" size="xs" tone="muted">It asks for no permissions.</text>`;
return html` <div ...${column} gap=${2}>
${rows.map(
(entry) =>
html` <div key=${entry.permission} ...${row} gap="medium" padding="small" alignItems="start">
<div width=${8} height=${8} marginTop=${5} borderRadius="full" background=${riskTone(entry.risk)} />
<div ...${column} ...${fill} gap=${2}>
<div ...${row} gap="small" flexWrap="wrap">
<text style="body" size="sm" weight=${560}>${entry.label}</text>
${entry.scopes.length ? html`<text style="mono" size="xs" tone="muted">${entry.scopes.join(", ")}</text>` : null}
<text style="mono" size="xs" tone="faint">${entry.permission}</text>
</div>
<text style="body" size="xs" tone="muted"
>${entry.blocked ? "Only official plugins can have this; the app refuses it." : entry.reason}</text
>
</div>
<text style="body" size="xs" color=${riskTone(entry.risk)}>${RISK_NAMES[entry.risk]}</text>
</div>`,
)}
</div>`;
}
function dangerBox(text: string): Element | null {
return html` <div
...${column}
gap=${4}
padding="medium"
borderRadius="md"
background="danger/10"
border=${{ width: 1, color: "danger/35" }}
>
<div ...${row} gap="small">
<icon name="triangle-alert" small tone="danger" />
<text style="body" size="sm" bold tone="danger">This plugin can run any program with your permissions</text>
</div>
<text style="body" size="xs" tone="muted">${text}</text>
</div>`;
}
function menus(ctx: Ctx, market: Controller): Element | null {
const m: Menus | null = market.state.menus;
if (!m)
return html`<div ...${row} gap="small">
<spinner /><text style="body" size="xs" tone="muted">Loading agents…</text>
</div>`;
if (!m.agents.length)
return html`<text style="body" size="xs" tone="warning"
>${m.error ?? "Enable an agent in Settings > Agents to scan plugins."}</text
>`;
const models = optionOf(m.options, "model");
const efforts = effortChoices(m.options, m.choice.model);
const pick = (
id: string,
value: string | null,
options: readonly { readonly value: string; readonly label: string; readonly svg?: string }[],
onChange: (value: string) => void,
width: number,
): Element =>
html` <div ...${row} maxWidth=${width}>
<select
id=${id}
value=${value ?? ""}
small
options=${options}
onChange=${(e: ValueEvent<string>) => onChange(e.value)}
/>
</div>`;
return html` <div ...${row} gap="small" flexWrap="wrap">
${pick(
"market-agent",
m.choice.agentId,
m.agents.map((a) => ({ value: a.id, label: a.name, svg: a.icon })),
(value) => ctx.run(market.chooseAgent(value)),
190,
)}
${
models?.choices?.length
? pick(
"market-model",
m.choice.model,
models.choices.map((c) => ({ value: c.value, label: c.name, svg: c.icon })),
(value) => market.chooseModel(value),
220,
)
: null
}
${
efforts.length
? pick(
"market-effort",
m.choice.effort,
efforts.map((c) => ({ value: c.value, label: c.name })),
(value) => market.chooseEffort(value),
130,
)
: null
}
</div>`;
}
function installRow(ctx: Ctx, market: Controller, listing: Listing): Element | null {
const { state } = market;
const installed = listing.installed;
const update = state.updates.find((u) => u.id === listing.id && u.latest);
const versions = market.installableVersions(listing);
const chosen = listing.versions?.find((v) => v.version === state.version);
const permissions = chosen?.permissions ?? listing.permissions;
const busyKey = `install:${listing.id}`;
const versionPick = versions.length
? html`<div ...${row} maxWidth=${130}>
<select
id="market-version"
value=${state.version ?? ""}
small
options=${versions.map((v) => ({ value: v.version, label: v.version }))}
onChange=${(e: ValueEvent<string>) => {
market.state.version = e.value;
market.state.tree = null;
market.state.file = null;
market.redraw();
}}
/>
</div>`
: null;
if (installed) {
return html` <div ...${row} gap="small" flexWrap="wrap">
${badge(`Installed ${installed.version}`, installed.enabled ? "success" : "default")}
<text style="body" size="xs" tone="muted"
>${installed.enabled ? `as ${installed.folder}` : `as ${installed.folder}, not enabled`}</text
>
<spacer />
${update ? menus(ctx, market) : null}
${
update
? html`<button
id="market-update-listing"
label=${`Update to ${update.latest}`}
small
variant="primary"
loading=${!!state.busy[`update:${update.folder}`]}
onClick=${() => ctx.run(market.runUpdate(update))}
/>`
: null
}
</div>`;
}
const direct = state.direct && !runsAnything(permissions);
return html` <div ...${column} gap="small">
<div ...${row} gap="small" flexWrap="wrap">
${versionPick} ${direct ? null : menus(ctx, market)}
<spacer />
<button
id="market-install"
label=${direct ? "Install" : "Install…"}
small
variant="primary"
loading=${!!state.busy[busyKey]}
disabled=${!versions.length || (!direct && !state.menus?.agents?.length)}
tooltip=${direct ? "Download it, not enabled, and show its enable card" : "An agent reads it first, in the Plugins workspace"}
onClick=${() => ctx.run(market.install())}
/>
</div>
<text style="body" size="xs" tone="muted"
>${
direct
? "Installs directly (Updates > Install directly): the download is not enabled until you enable it on its card."
: "A chat opens in the Plugins workspace: the agent reads the plugin's files first, installs it only if it looks safe, and then asks you to enable it."
}</text
>
</div>`;
}
function fileTree(ctx: Ctx, market: Controller): Element | null {
const { state } = market;
if (state.treeError) return html`<empty>${`Could not read the release: ${state.treeError}`}</empty>`;
if (!state.tree) {
ctx.run(market.loadTree());
return html`<spinner />`;
}
if (state.tree === "loading")
return html`<div ...${row} gap="small">
<spinner /><text style="muted">Verifying and reading the package…</text>
</div>`;
const files = state.tree.files ?? [];
const list = html` <div ...${column} gap=${1} width=${260} maxHeight=${520} overflowY="scroll">
${files.map(
(file) =>
html` <div
key=${file.path}
...${row}
...${clickable}
gap="small"
padding="small"
paddingY=${3}
borderRadius="sm"
background=${state.file?.path === file.path ? "accent" : undefined}
hover=${{ background: "hover" }}
onClick=${() => ctx.run(market.openFile(file.path))}
>
<icon
name=${file.binary ? "file-lock" : "file-text"}
small
tone=${file.binary || file.executable ? "warning" : "muted"}
/>
<text style="mono" size="xs" truncate>${file.path}</text>
<spacer />
<text style="body" size="xs" tone="faint">${formatCount(file.size)}B</text>
</div>`,
)}
</div>`;
let content: Element | null;
const file = state.file;
if (!file) content = html`<empty>Choose a file to read it.</empty>`;
else if (file.loading) content = html`<spinner />`;
else if (file.error) content = html`<empty>${`Could not read ${file.path}: ${file.error}`}</empty>`;
else if (file.binary)
content = html`<empty>${`${file.path} is a binary file (${formatCount(file.size)}B); it is not shown.`}</empty>`;
else {
content = html` <div ...${column} ...${fill} gap="small">
${file.truncated ? html`<text style="body" size="xs" tone="warning">Only the start of this file is shown.</text>` : null}
${ui.editor({ id: `market-file-${state.listingId}-${state.version}-${file.path}`, text: file.text ?? "", language: languageOf(file.path), readOnly: true })}
</div>`;
}
return html`<div ...${row} gap="medium" alignItems="start">
${list}
<div ...${column} ...${fill} minHeight=${320}>${content}</div>
</div>`;
}
function comment(node: MarketComment, depth = 0): Element {
const stars = node.stars ? "★".repeat(node.stars) + "☆".repeat(5 - node.stars) : null;
return html` <div
key=${node.id}
...${column}
gap=${4}
paddingLeft=${depth ? 14 : 0}
borderLeft=${depth ? { width: 2, color: "border" } : undefined}
>
<div ...${row} gap="small">
<text style="body" size="xs" bold>${node.user?.name ?? "[deleted]"}</text>
${stars ? html`<text style="body" size="xs" tone="warning">${stars}</text>` : null}
<text style="body" size="xs" tone="faint"
>${String(node.createdAt ?? "").slice(0, 10)}${node.pluginVersion ? ` · v${node.pluginVersion}` : ""}</text
>
</div>
<text style="body" size="sm">${node.body}</text>
${(node.replies ?? []).map((reply) => comment(reply, depth + 1))}
</div>`;
}
function commentsTab(ctx: Ctx, market: Controller): Element | null {
const { state } = market;
const onWebsite = html` <div ...${row} gap="small">
<button
id="market-comment-web"
label="Write a comment on the website"
icon="external-link"
small
variant="outline"
onClick=${() => ctx.run(market.openListingSite())}
/>
</div>`;
if (!state.comments) {
ctx.run(market.loadComments());
return html`<spinner />`;
}
if (state.comments === "loading") return html`<spinner />`;
if ("error" in state.comments)
return html`<div ...${column} gap="small">
<text style="body" size="xs" tone="muted">${`Comments are on the website (${state.comments.error}).`}</text
>${onWebsite}
</div>`;
const threads = state.comments.threads ?? [];
return html` <div ...${column} gap="medium">
${onWebsite} ${threads.length ? threads.map((thread) => comment(thread)) : html`<empty>No comments yet.</empty>`}
</div>`;
}
function versionsTab(listing: Listing): Element | null {
return html` <div ...${column} gap=${2}>
${(listing.versions ?? []).map(
(v) =>
html` <div key=${v.version} ...${row} gap="small" padding="small">
<text style="mono" size="sm">${v.version}</text>
${v.malicious ? badge("Malicious", "danger") : v.yanked ? badge("Yanked", "warning") : null}
<spacer />
<text style="body" size="xs" tone="muted">${String(v.publishedAt ?? "").slice(0, 10)}</text>
</div>`,
)}
</div>`;
}
function reportForm(ctx: Ctx, market: Controller): Element | null {
const { state } = market;
if (!state.report) {
return html`<button
id="market-report-open"
label="Report…"
icon="flag"
small
variant="ghost"
onClick=${() => {
market.state.report = { reason: "", files: "" };
market.redraw();
}}
/>`;
}
return html` <card ...${column} gap="small">
<text style="body" size="sm" bold>Report this plugin to the moderators</text>
<input
id="market-report-reason"
value=${state.report.reason}
placeholder="What is wrong, and how you know"
multiline
onChange=${(e: ValueEvent<string>) => {
if (market.state.report) market.state.report.reason = e.value;
}}
/>
<input
id="market-report-files"
value=${state.report.files}
placeholder="Files and lines, for example main.js:42 (optional)"
onChange=${(e: ValueEvent<string>) => {
if (market.state.report) market.state.report.files = e.value;
}}
/>
<div ...${row} gap="small">
<spacer />
<button
id="market-report-cancel"
label="Cancel"
small
variant="ghost"
onClick=${() => {
market.state.report = null;
market.redraw();
}}
/>
<button
id="market-report-next"
label="Review report…"
small
variant="danger"
loading=${!!state.busy.report}
onClick=${() => ctx.run(market.sendReport())}
/>
</div>
<text style="body" size="xs" tone="muted">The app shows exactly what is sent before anything leaves.</text>
</card>`;
}
function listingPage(ctx: Ctx, market: Controller): Element | null {
const { state } = market;
const back = html`<button
id="market-back"
label="All plugins"
icon="arrow-left"
small
variant="ghost"
onClick=${() => ctx.run(market.backToBrowse())}
/>`;
if (state.listingError)
return html`<div ...${column} gap="medium">
${back}<empty>${`Could not load ${state.listingId}: ${state.listingError}`}</empty>
</div>`;
const listing = state.listing;
if (!listing)
return html`<div ...${column} gap="medium">
${back}
<div ...${row} gap="small"><spinner /><text style="muted">${`Loading ${state.listingId}…`}</text></div>
</div>`;
const official = isOfficial(listing.id);
const chosen = listing.versions?.find((v) => v.version === state.version);
const permissions = chosen?.permissions ?? listing.permissions;
const tabs = [
{ id: "permissions", label: "Permissions" },
{ id: "files", label: "Files" },
{ id: "comments", label: "Comments" },
{ id: "versions", label: "Versions" },
...(listing.mods?.length ? [{ id: "mods", label: `Mods (${listing.mods.length})` }] : []),
];
const modsView = html`<div ...${column} gap=${2}>
${listing.mods?.map(
(mod) =>
html` <div
key=${mod.id}
...${row}
...${clickable}
gap="small"
padding="small"
borderRadius="sm"
hover=${{ background: "hover" }}
onClick=${() => ctx.run(market.openListing(mod.id))}
>
<icon name="git-fork" small tone="muted" /><text style="body" size="sm">${mod.id}</text>
</div>`,
)}
</div>`;
const body =
state.tab === "files"
? fileTree(ctx, market)
: state.tab === "comments"
? commentsTab(ctx, market)
: state.tab === "versions"
? versionsTab(listing)
: state.tab === "mods"
? modsView
: permissionList(ctx, market, permissions, official);
return html` <div ...${column} gap="medium">
${back}
<div ...${column} gap="small">
<div ...${row} gap="small" flexWrap="wrap">
<text style="title">${listing.name}</text>
<text style="mono" size="xs" tone="muted">${listing.id}</text>
${listing.visibility && listing.visibility !== "public" ? badge(listing.visibility, "info") : null}
</div>
${publisherLine(listing)}
${
listing.basedOn
? html`<div ...${row} gap="small">
<icon name="git-fork" small tone="muted" />
<button
id="market-mod-of"
label=${`Mod of ${listing.basedOn.id}@${listing.basedOn.version}`}
small
variant="chip"
onClick=${() => ctx.run(market.openListing(listing.basedOn?.id ?? "", listing.basedOn?.version ?? null))}
/>
</div>`
: null
}
${listing.description ? html`<text style="body" size="sm">${listing.description}</text>` : null}
<text style="body" size="xs" tone="muted"
>${[
`${formatCount(listing.downloads)} installs`,
listing.rating ? `★ ${listing.rating.average} from ${listing.rating.count} reviews` : null,
listing.createdAt ? `listed ${String(listing.createdAt).slice(0, 10)}` : null,
]
.filter(Boolean)
.join(" · ")}</text
>
</div>
${runsAnything(permissions) ? dangerBox("It can read and change your files, use your accounts and reach the network. It installs only after an agent scanned it, and enabling it needs a two-second hold.") : null}
<card ...${column}>${installRow(ctx, market, listing)}</card>
<tabs
id="market-listing-tabs"
active=${state.tab}
tabs=${tabs}
onChange=${(e: ValueEvent<string>) => market.setListingTab(e.value)}
/>
<div ...${column} padding="small">${body}</div>
<div ...${row}>${reportForm(ctx, market)}</div>
</div>`;
}
function updateRow(ctx: Ctx, market: Controller, item: MarketUpdateItem): Element | null {
const { state } = market;
const key = `update:${item.folder}`;
return html` <div key=${item.folder} ...${row} gap="medium" padding="medium">
<icon name=${item.malicious ? "shield-alert" : "package"} small tone=${item.malicious ? "danger" : "muted"} />
<div ...${column} ...${fill} gap=${2}>
<div ...${row} gap="small" flexWrap="wrap">
<text style="body" size="sm" bold>${item.folder}</text>
<text style="mono" size="xs" tone="muted">${item.id}</text>
${item.official ? badge("Official", "success") : null}
${item.malicious ? badge("Malicious", "danger") : item.yanked ? badge("Yanked", "warning") : null}
</div>
<text style="body" size="xs" tone=${item.malicious ? "danger" : "muted"}
>${
item.malicious
? `${item.installed} was marked malicious${item.reason ? ` (${item.reason})` : ""}. It is off; remove it in Settings > Plugins.`
: item.latest
? `${item.installed} → ${item.latest}${item.direct ? " · replaced directly" : " · an agent merges it in the Plugins workspace"}`
: `${item.installed} was yanked${item.reason ? `: ${item.reason}` : ""}.`
}</text
>
</div>
<button
id=${`market-open-${item.folder}`}
label="Listing"
small
variant="ghost"
onClick=${() => ctx.run(market.openListing(item.id))}
/>
${
item.latest && !item.malicious
? html`<button
id=${`market-update-${item.folder}`}
label="Update"
small
variant="primary"
loading=${!!state.busy[key]}
onClick=${() => ctx.run(market.runUpdate(item))}
/>`
: null
}
</div>`;
}
function updatesPage(ctx: Ctx, market: Controller): Element | null {
const { state } = market;
const list = state.updates.length
? html`<card ...${column}>
${state.updates.map((item, i) => [i ? html`<divider />` : null, updateRow(ctx, market, item)])}
</card>`
: html`<empty>Every installed plugin is up to date.</empty>`;
const needsAgent = state.updates.some((u) => u.latest && !u.direct && !u.malicious);
return html` <div ...${column} gap="large">
<div ...${column} gap="small">
<div ...${row} gap="small">
<text style="section">Updates</text>
<spacer />
<button
id="market-check"
label="Check now"
icon="refresh-cw"
small
variant="outline"
loading=${!!state.busy.check}
onClick=${() => ctx.run(market.busy("check", market.loadUpdates(true)))}
/>
</div>
<text style="body" size="xs" tone="muted"
>Nothing updates by itself. An unchanged official plugin is replaced after its signature is checked; any other
update opens a chat where an agent reads the new version, scans the difference and merges it into your
copy.</text
>
${needsAgent ? menus(ctx, market) : null} ${list}
</div>
<div ...${column} gap="small">
<text style="section">Installing</text>
<card ...${row} gap="large">
<div ...${column} ...${fill} gap=${2}>
<text style="body" size="sm" bold>Install directly</text>
<text style="body" size="xs" tone="muted"
>Skip the agent's scan: the download lands not enabled, and its enable card opens in a small chat. Plugins
that can run any program are always scanned first.</text
>
</div>
<switch
id="market-direct"
checked=${state.direct}
onChange=${(e: ValueEvent<boolean>) => ctx.run(market.setDirect(e.value))}
/>
</card>
</div>
</div>`;
}
function accountArea(ctx: Ctx, market: Controller): Element | null {
const { state } = market;
const account = state.account;
if (!account) return html`<spinner />`;
if (account.signedIn) {
const record = "account" in account ? account.account : undefined;
const name = record?.publisher?.name ? `@${record.publisher.name}` : (record?.user?.email ?? "Signed in");
return html` <div ...${row} gap="small">
<icon name="circle-user" small tone="muted" />
<text style="body" size="xs" tone="muted">${name}</text>
<button
id="market-sign-out"
label="Sign out"
small
variant="ghost"
loading=${!!state.busy["sign-out"]}
onClick=${() => ctx.run(market.signOut())}
/>
</div>`;
}
if (state.signIn) {
return html` <div ...${row} gap="small">
<text style="body" size="xs" tone="muted">Code</text>
<text style="mono" size="sm" bold>${state.signIn.code}</text>
<button
id="market-sign-in-open"
label="Open sign-in page"
icon="external-link"
small
variant="primary"
onClick=${() => ctx.run(market.openSignInPage())}
/>
<button
id="market-sign-in-cancel"
icon="x"
small
variant="ghost"
tooltip="Cancel"
onClick=${() => {
market.state.signIn = null;
market.redraw();
}}
/>
</div>`;
}
return html` <div ...${row} gap="small">
<text style="body" size="xs" tone="muted"
>${account.expired ? "Your sign-in expired." : "Sign in to publish and comment."}</text
>
<button
id="market-sign-in"
label="Sign in"
icon="log-in"
small
variant="outline"
loading=${!!state.busy["sign-in"]}
onClick=${() => ctx.run(market.signIn())}
/>
</div>`;
}
function bugPluginRow(ctx: Ctx, market: Controller, plugin: DraftPlugin): Element | null {
const bug = market.state.bug;
if (!bug) return null;
const chosen = bug.chosen.has(plugin.name);
const thirdParty = !plugin.official;
return html` <div key=${plugin.name} ...${row} gap="small" padding="small" paddingY=${3}>
<div ...${row} ...${fill}>
<checkbox
id=${`bug-plugin-${plugin.name}`}
label=${`${plugin.name} · ${plugin.id} ${plugin.version ?? ""}`}
checked=${chosen}
onChange=${(e: ValueEvent<boolean>) => ctx.run(market.togglePlugin(plugin.name, e.value))}
/>
</div>
${plugin.official ? badge("Official", "success") : null} ${plugin.changed ? badge("Changed", "warning") : null}
${plugin.reasons?.length ? html`<text style="body" size="xs" tone="muted">${plugin.reasons.join(", ")}</text>` : null}
${
chosen && thirdParty
? html`<checkbox
id=${`bug-source-${plugin.name}`}
label="Full source"
checked=${bug.fullSource.has(plugin.name)}
onChange=${(e: ValueEvent<boolean>) => ctx.run(market.toggleSource(plugin.name, e.value))}
/>`
: null
}
</div>`;
}
function attachmentRow(ctx: Ctx, market: Controller, attachment: BugAttachment): Element | null {
const bug = market.state.bug;
if (!bug) return null;
const on = !bug.unchecked.has(attachment.id);
const open = bug.open.has(attachment.id);
const removed = attachment.removed ?? [];
const shown = open
? attachment.kind === "screenshot"
? html`<image path=${attachment.path} maxHeight=${240} radius=${8} />`
: attachment.kind === "diff"
? ui.diff(attachment.text ?? "")
: ui.code(attachment.text ?? "", "text")
: null;
return html` <card key=${attachment.id} ...${column} gap=${4}>
<div ...${row} gap="small">
<div ...${row} ...${fill}>
<checkbox
id=${`bug-attach-${attachment.id}`}
label=${attachment.title}
checked=${on}
onChange=${(e: ValueEvent<boolean>) => {
if (!market.state.bug) return;
if (e.value) market.state.bug.unchecked.delete(attachment.id);
else market.state.bug.unchecked.add(attachment.id);
market.redraw();
}}
/>
</div>
<text style="body" size="xs" tone="faint">${`${attachment.name} · ${formatCount(attachment.size)}B`}</text>
<button
id=${`bug-show-${attachment.id}`}
label=${open ? "Hide" : "Show"}
small
variant="ghost"
onClick=${() => {
if (!market.state.bug) return;
if (open) market.state.bug.open.delete(attachment.id);
else market.state.bug.open.add(attachment.id);
market.redraw();
}}
/>
</div>
${
removed.length
? html`<text style="body" size="xs" tone="warning"
>${`Secrets removed: ${removed.map((r) => `line ${r.line} (${r.kind})`).join(", ")}. Check that nothing else is left.`}</text
>`
: null
}
${shown}
</card>`;
}
function reportsList(ctx: Ctx, market: Controller): Element | null {
const { state } = market;
if (!state.reports.length) return null;
return html` <div ...${column} gap="small">
<text style="section">Your reports</text>
<card ...${column}>
${state.reports.map((report, i) => [
i ? html`<divider />` : null,
html` <div key=${report.id} ...${row} gap="medium" padding="small">
<text style="mono" size="sm">${`#${report.number}`}</text>
<div ...${column} ...${fill} gap=${2}>
<text style="body" size="sm" lineClamp=${1}>${report.text}</text>
<text style="body" size="xs" tone="muted">${(report.plugins ?? []).map((p) => p.id).join(", ")}</text>
</div>
<button
id=${`bug-fix-${report.id}`}
label="Fix with an agent"
small
variant="outline"
loading=${!!state.busy[`fix:${report.id}`]}
onClick=${() => ctx.run(market.fixReport(report))}
/>
<button
id=${`bug-attach-fix-${report.id}`}
label="Attach fix…"
small
variant="ghost"
loading=${!!state.busy[`attach:${report.id}`]}
onClick=${() => ctx.run(market.attachFix(report))}
/>
</div>`,
])}
</card>
</div>`;
}
function bugPage(ctx: Ctx, market: Controller): Element | null {
const { state } = market;
const bug = state.bug;
if (!bug) {
return html` <div ...${column} gap="large">
<div ...${column} gap="small">
<text style="section">Report a bug</text>
<text style="body" size="sm">${FIX_NOTE}</text>
<div ...${row}>
<button
id="bug-new"
label="Report a bug…"
icon="bug"
small
variant="primary"
onClick=${() => ctx.run(market.openBugForm())}
/>
</div>
</div>
${reportsList(ctx, market)}
</div>`;
}
if (bug.sent) {
const report: SentReport = bug.sent;
return html` <div ...${column} gap="large">
<card ...${column} gap="small">
<text style="section">${`Bug report #${report.number} is sent`}</text>
<text style="body" size="sm">${FIX_NOTE}</text>
<text style="body" size="xs" tone="muted"
>There is no status page: the maintainers email you when they have a question or a result.</text
>
<div ...${row} gap="small" flexWrap="wrap">
${menus(ctx, market)}
<spacer />
<button
id="bug-fix-now"
label="Try to fix it with an agent"
small
variant="primary"
loading=${!!state.busy[`fix:${report.id}`]}
disabled=${!state.menus?.agents?.length}
onClick=${() => ctx.run(market.fixReport(report))}
/>
<button
id="bug-another"
label="Report another bug"
small
variant="ghost"
onClick=${() => ctx.run(market.openBugForm())}
/>
</div>
</card>
${reportsList(ctx, market)}
</div>`;
}
if (bug.loading)
return html`<div ...${row} gap="small"><spinner /><text style="muted">Reading your plugins…</text></div>`;
if (bug.error) return html`<empty>${`Could not read your plugins: ${bug.error}`}</empty>`;
const plugins = bug.draft?.plugins ?? [];
const attachments = bug.preview?.attachments ?? [];
const waiting = !!bug.requestId;
return html` <div ...${column} gap="large">
<div ...${column} gap="small">
<text style="section">Report a bug</text>
<div ...${column} gap=${4} padding="medium" borderRadius="md" background="primary/8">
<text style="body" size="sm">${FIX_NOTE}</text>
</div>
</div>
<div ...${column} gap="small">
<text style="body" size="sm" bold>Related plugins</text>
<text style="body" size="xs" tone="muted"
>The suggested ones are first: plugins you changed, and plugins the log names in an error.</text
>
<card ...${column} maxHeight=${280} overflowY="scroll">
${plugins.map((plugin) => bugPluginRow(ctx, market, plugin))}
</card>
</div>
<div ...${column} gap="small">
<text style="body" size="sm" bold>What happened, and what you expected</text>
<input
id="bug-text"
value=${bug.text}
multiline
placeholder="The steps, what happened, and what you expected"
onChange=${(e: ValueEvent<string>) => {
if (market.state.bug) market.state.bug.text = e.value;
}}
/>
</div>
${
bug.draft?.signedIn
? null
: html`<div ...${column} gap="small">
<text style="body" size="sm" bold>Contact email (optional)</text>
<input
id="bug-email"
value=${bug.email}
placeholder="Only the maintainers see it"
onChange=${(e: ValueEvent<string>) => {
if (market.state.bug) market.state.bug.email = e.value;
}}
/>
</div>`
}
<div ...${column} gap="small">
<div ...${row} gap="small">
<text style="body" size="sm" bold>Attachments</text>
${bug.previewing ? html`<spinner />` : null}
<spacer />
<button
id="bug-screenshots"
label="Add screenshots…"
icon="image"
small
variant="outline"
onClick=${() => ctx.run(market.addScreenshots())}
/>
</div>
<text style="body" size="xs" tone="muted"
>Everything checked is sent. Open each one to see exactly what it holds. Config and diagnostics go to the
Divergence maintainers only.</text
>
${bug.previewError ? html`<text style="body" size="xs" tone="danger">${bug.previewError}</text>` : null}
<div ...${column} gap="small">${attachments.map((attachment) => attachmentRow(ctx, market, attachment))}</div>
</div>
<card ...${column} gap="small">
<div ...${row} gap="small" flexWrap="wrap">
<button
id="bug-review"
label="Review and send…"
small
variant="primary"
loading=${!!state.busy["bug-review"]}
disabled=${waiting}
onClick=${() => ctx.run(market.reviewReport(false))}
/>
<button
id="bug-review-fix"
label="Send, then try to fix it with an agent…"
small
variant="outline"
disabled=${waiting || !state.menus?.agents?.length}
onClick=${() => ctx.run(market.reviewReport(true))}
/>
<spacer />
${menus(ctx, market)}
</div>
<text style="body" size="xs" tone="muted"
>${
waiting
? "The card over the window shows everything that is sent. Send it there, or cancel."
: "Nothing is sent from here: a card shows exactly what is sent, and you send it there."
}</text
>
</card>
${reportsList(ctx, market)}
</div>`;
}
export function renderCenter(ctx: Ctx, market: Controller): Element | null {
if (ctx.app.route !== ROUTE) return null;
const { state } = market;
const count = badgeCount(state.updates);
const tabs = [
{ id: "browse", label: "Browse" },
{ id: "updates", label: count ? `Updates (${count})` : "Updates" },
{ id: "bugs", label: "Bug reports" },
];
const active = state.page === "updates" || state.page === "bugs" ? state.page : "browse";
const body =
state.page === "updates"
? updatesPage(ctx, market)
: state.page === "bugs"
? bugPage(ctx, market)
: state.page === "listing"
? listingPage(ctx, market)
: browsePage(ctx, market);
return html` <div ...${column} ...${fill} ...${full} padding="small">
<div ...${column} ...${reading} padding="small" paddingX="large">
<div ...${row} gap="small" padding="small" paddingY="medium">
<button
id="market-close"
icon="arrow-left"
variant="ghost"
small
tooltip="Back to chat"
onClick=${() => ctx.run(market.toggleRoute(true))}
/>
<icon name="store" small tone="muted" />
<text style="title">Marketplace</text>
<spacer />
${accountArea(ctx, market)}
</div>
<tabs
id="market-tabs"
active=${active}
tabs=${tabs}
onChange=${(e: ValueEvent<string>) => {
const page = e.value === "updates" || e.value === "bugs" ? e.value : "browse";
ctx.run(market.selectPage(page));
}}
/>
</div>
<div ...${column} ...${fill} padding="small" overflowY="scroll">
<div ...${column} ...${reading} gap="large" padding="small" paddingX="large" paddingY="large">${body}</div>
</div>
</div>`;
}
export function renderRail(ctx: Ctx, market: Controller): Element | null {
const count = badgeCount(market.state.updates);
const open = ctx.app.route === ROUTE;
return html`<button
id="rail-market"
icon="store"
variant="ghost"
active=${open}
tooltip=${count ? `Marketplace: ${count} plugin updates` : "Marketplace"}
badge=${count ? String(count) : undefined}
onClick=${() => ctx.run(market.toggleRoute(open))}
/>`;
}Versions
| Version | Published | Plugin API | Size | Permissions | Status |
|---|---|---|---|---|---|
| 0.1.0latest | Oct 5, 2026 | >=2 <3 | 44.9 KB | 6 permissions | Listed |
No comments yet.