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

  • Marketplace marketOfficial onlyUses the marketplace APIs. Only official plugins can have it.Search, read, install, update and publish marketplace plugins, and sign in to the marketplace
  • Manage plugins plugins.manageOfficial onlyReloads and enables plugins. Only official plugins can have it.Ask for the enable card of a plugin an agent installed
  • Control chats chats.controlHighCreates chats, sends prompts and cancels runs.Open the chat in the Plugins workspace where an agent scans, installs or updates a plugin
  • Provide agent tools tools.provideMediumGives tools to agents.Give agents in the Plugins workspace the marketplace tools
  • Read chats chats.readMediumReads your transcripts and chat lists.Find the agent, model and effort last used in the Plugins workspace
  • Show panels ui.slotsLowShows views in the listed parts of the window.Show the marketplace button, with its update count, and the marketplace pageSlots: railcenter

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

VersionPublishedPlugin APISizePermissionsStatus
0.1.0latestOct 5, 2026>=2 <344.9 KB6 permissionsListed

Reviews and comments

0 threads · 0 reviews

No comments yet.