Ovvi

Build a plugin

A CSV table plugin from the template: the package, its build script, Reload Plugin, debugging, agents, and File Explorer and Git in the same layout

This page makes a plugin that shows a CSV file as a table you can sort. It starts from the template, builds with the package's own script, and shows how to reload, debug and let an agent make one.

You need Node.js on This Mac: Ovvi installs and builds plugins with the npm, pnpm or bun on your login PATH. It ships no bundler.

Start from the template

The template is a plugin package with everything a build needs. It is plugins/builtin/template in the repository, and the app carries it in Ovvi.app/Contents/Resources/web/template/. The template and the API's types (@ovvi/plugin-api) are MIT, so plugins you write can use any license: change license in package.json.

  • From the app: New Plugin… on Settings ▸ Plugins asks for a name. It copies the template to ~/.config/ovvi/plugins/<id>/, with the id made from the name (lower case, letters and digits joined by -: "CSV Table" is csv-table), and puts the name and the id in package.json. Then it installs the dependencies, runs the build, and confirms the plugin for you, because you made it. Its row on the Plugins page shows its folder.
  • From an agent: ovvi plugins new <name> does the same (An agent makes a plugin).
  • By hand, or by an agent: copy the template folder to ~/.config/ovvi/plugins/<id>/, set name and the ovvi key's id and name, then run npm install and npm run build in it.
~/.config/ovvi/plugins/
├── node_modules/@ovvi/plugin-api/     the API's types (the app writes them)
└── csv-table/
    ├── package.json      the ovvi key, the scripts, esbuild and TypeScript
    ├── package-lock.json
    ├── tsconfig.json
    ├── README.md
    ├── node_modules/
    ├── src/
    │   ├── main.ts       the entry
    │   ├── parse.ts
    │   └── table.css
    └── dist/             the build: main.js, main.css, chunks, the source map

The template's package.json:

{
  "name": "my-plugin",
  "version": "0.1.0",
  "private": true,
  "description": "A text file with its line count.",
  "scripts": {
    "build": "esbuild src/main.ts --bundle --splitting --format=esm --minify --sourcemap --target=safari18 --log-level=warning --outdir=dist",
    "dev": "npm run build -- --watch",
    "check": "tsc -p ."
  },
  "devDependencies": { "esbuild": "0.28.2", "typescript": "7.0.2" },
  "peerDependencies": { "@ovvi/plugin-api": "*" },
  "peerDependenciesMeta": { "@ovvi/plugin-api": { "optional": true } },
  "ovvi": {
    "id": "my-plugin",
    "name": "My Plugin",
    "api": 3,
    "entry": "dist/main.js",
    "icon": "doc.plaintext",
    "opens": { "files": ["*.txt"], "priority": "option" },
    "header": [{ "id": "lines", "type": "text", "style": "dim" }],
    "settings": [{ "id": "wrap", "type": "toggle", "title": "Wrap long lines", "default": true }]
  }
}
  • build bundles src/main.ts into dist/ as ES modules, with shared chunks for dynamic imports and a linked source map.
  • dev builds again on every save. The card still loads the new build only on Reload Plugin.
  • check checks the types against the host's (Types).
  • The template opens .txt files as an option, so it never takes them from File Explorer: pick it in Open With. Its src/main.ts shows the file and its line count, and follows the file as it changes.

For CSV Table, change the ovvi key to the one in The manifest: the id csv-table, opens for *.csv, *.tsv and the CSV type, a row count, a First Row Is Header toggle and a Reload button in the header.

The code

src/parse.ts, plain TypeScript:

/** Rows of fields. Quotes may wrap a field; "" inside quotes is one quote. */
export function parse(text: string, separator: string): string[][] {
  const rows: string[][] = [[]]
  let field = "", quoted = false
  for (let i = 0; i < text.length; i++) {
    const c = text[i]
    if (quoted) {
      if (c === '"' && text[i + 1] === '"') { field += '"'; i++ }
      else if (c === '"') quoted = false
      else field += c
    } else if (c === '"') quoted = true
    else if (c === separator) { rows.at(-1)!.push(field); field = "" }
    else if (c === "\n") { rows.at(-1)!.push(field.replace(/\r$/, "")); rows.push([]); field = "" }
    else field += c
  }
  rows.at(-1)!.push(field)
  return rows.filter((row) => row.some((cell) => cell !== ""))
}

src/main.ts, the entry:

import type { Ovvi } from "@ovvi/plugin-api"
import { parse } from "./parse"
import "./table.css"

type State = { column?: number; descending?: boolean; scroll?: number }

export default async function main(ovvi: Ovvi) {
  const path = ovvi.card.arg
  let state = ovvi.state.get<State>() ?? {}
  let rows: string[][] = []
  const table = document.body.appendChild(document.createElement("table"))

  function save(change: State) {
    state = { ...state, ...change }
    ovvi.state.set(state)
  }

  function draw() {
    const header = ovvi.controls.get("first-row") === true
    const [head, ...body] = header ? rows : [rows[0]?.map((_, i) => `${i + 1}`) ?? [], ...rows]
    const { column, descending } = state
    if (column !== undefined) {
      body.sort((a, b) => (a[column] ?? "").localeCompare(b[column] ?? "", undefined, { numeric: true }))
      if (descending) body.reverse()
    }
    table.replaceChildren()
    const tr = table.createTHead().insertRow()
    head.forEach((title, i) => {
      const th = tr.appendChild(document.createElement("th"))
      th.textContent = title + (i === column ? (descending ? " ↓" : " ↑") : "")
      th.onclick = () => {
        save({ column: i, descending: i === column && !descending })
        draw()
      }
    })
    const tbody = table.createTBody()
    for (const row of body) {
      const line = tbody.insertRow()
      for (const cell of row) line.insertCell().textContent = cell
    }
    ovvi.controls.set("rows", { value: `${body.length} rows` })
  }

  async function load() {
    try {
      const { text } = await ovvi.files.read(path)
      rows = parse(text, path.endsWith(".tsv") ? "\t" : ",")
      draw()
    } catch (error) {
      table.replaceChildren()
      ovvi.controls.set("rows", { value: (error as Error).message })
    }
  }

  ovvi.on("control", ({ id }) => {
    if (id === "reload") load()
    if (id === "first-row") draw()
  })
  ovvi.files.watch(path, load)
  addEventListener("scroll", () => save({ scroll: scrollY }), { passive: true })

  await load()
  scrollTo(0, state.scroll ?? 0)
}

src/table.css uses the app's colors and text size:

table { border-collapse: collapse; font: var(--app-text-size) ui-monospace, monospace; color: var(--text) }
th { position: sticky; top: 0; text-align: left; cursor: default; backdrop-filter: blur(20px);
     background: color-mix(in srgb, var(--text) 6%, transparent) }
th, td { padding: 3px 10px; border-bottom: 0.5px solid var(--divider); white-space: nowrap }

What the code shows:

  • Text from the file goes in with textContent, never as HTML.
  • The sort and the scroll are in ovvi.state, so a drop, a reload or a relaunch comes back sorted and at the same row. The toggle's value is the app's, read with ovvi.controls.get.
  • The watch reloads the table when the file changes: on This Mac by FSEvents, on a remote machine by a check every 2 s.
  • The CSS that main.ts imports becomes dist/main.css. The app links it, because it has the entry's name.

What the build must write

  • The file that entry names (dist/main.js), as an ES module whose default export is main.
  • Everything the page loads beside it, in one flat folder: chunks, the entry's CSS (main.css), workers, fonts, WebAssembly and other assets. The scheme serves no subfolders and no hidden files (Security and trust).
  • An asset the page loads by name must be copied into that folder by the build script. File Explorer's script copies pdf.js's worker, decoders, fonts and character maps into dist/ before it bundles.
  • WebKit on macOS 26 runs it: --target=safari18 is enough.

Open it

  1. The plugin is in ~/.config/ovvi/plugins/csv-table/ and built. A new folder needs Reload Plugins (Reload All on Settings ▸ Plugins, or ⌘K) before the app reads it. New Plugin… and an install read it by themselves.
  2. Double-click a .csv file in a File Explorer's tree. CSV Table is its default plugin, so the card opens in a new tab. On any other card that shows a CSV file, Open With lists CSV Table.
  3. A plugin that you did not make in the app asks to install in its first card (The confirmation). Click Install.
  4. The page loads. The table shows.

The same plugin opens CSV files on a remote machine. The file comes over the relay, and the table draws on the Mac.

Reload

Nothing watches the plugin's folder. After an edit, press Reload Plugin: ⌥⌘R over a card of the plugin, the card's menu, View ▸ Pane, ⌘K, or the plugin's page in Settings. Reload Plugins (⌘K, Reload All in Settings) does it for every plugin with an open card.

  1. If a card of the plugin has unsaved edits, the card asks first: "‹name› has unsaved edits.", with Discard and Keep Editing. Discard covers every card of the plugin.
  2. The app reads every manifest again.
  3. It runs the package's build script, if it has one. Only a confirmed plugin builds.
  4. It loads each card's page again. main gets the same state and header values, so the card comes back where it was. A card whose manifest changed is made again from the new one, with its new header.
The buildWhat the open cards do
passeseach card loads its page again, with its state
failsthe cards keep their running page, and each shows a toast with the error's first line and Try Again. A card that never ran shows "‹name› did not build" (Errors and logs)
  • The build writes dist/ in place and never empties it first, so a failed build keeps the last good one.
  • A page that keeps running over a rebuild can miss its later chunks until it loads again.
  • An install that built the plugin, or a package that ships its dist/, loads its cards without a second build.

Debug it

  • No Web Inspector. A plugin card is not inspectable: a right-click on empty space in its page shows only Reload, and Safari's Develop menu does not list it. Only browser cards have the Web Inspector (Web Inspector).
  • The log. Show Log (the card's menu, its error card, the plugin's page in Settings) opens ~/Library/Logs/Ovvi/plugins/<id>.log. tail -f on it shows every build, console line and uncaught error (Errors and logs).
  • Types. esbuild removes types and does not check them. Run npm run check (tsc) in the package.
  • Tests. Use any test runner in the package's own node_modules.

An agent makes a plugin

An agent in a pane on This Mac makes a plugin with the ovvi command (CLI):

  1. ovvi --timeout 300 plugins new "CSV Table" does what New Plugin… does: it copies the template to ~/.config/ovvi/plugins/csv-table/, installs and builds it, and confirms it. It answers the plugin's id, its folder and its log.
  2. The agent edits the folder, following these pages and the types in @ovvi/plugin-api.
  3. ovvi open csv-table ./data.csv opens a card beside the agent's pane. ovvi plugins --for data.csv says which plugin opens a file by default.
  4. After each edit, ovvi plugins reload csv-table builds it and loads its cards again. It answers built, ms and cards. A failed build is build_failed, with the build's last 20 lines. A card with unsaved edits stops it with dirty: the agent asks the user to save.
  5. The agent reads the log for its console lines and errors (Errors and logs).

A plugin folder that the agent makes by hand loads with ovvi plugins reload, with no id (with its id, the command answers that the plugin is not confirmed yet). Its first card asks the user to confirm it. To install from npm, git or a folder, the agent runs ovvi plugins install <source>, and the user answers the confirmation on the Mac.

An agent on a remote machine cannot reach the Mac's plugins folder: plugins new, reload and install answer other_machine there. It can open cards of the plugins the Mac has.

File Explorer and Git in the same layout

The default plugins use the template's layout: src/ built into dist/ by the same build, dev and check scripts, a lockfile, their own node_modules, and only the public API.

PluginDependenciesBuild
File Explorer (plugins/builtin/explorer)CodeMirror 6 with its languages, @pierre/trees, markdown-it, pdfjs-distcopies pdf.js's files into dist/, then the template's esbuild line
Git (plugins/builtin/git)@pierre/diffs, @pierre/treesthe template's esbuild line
  • Each has one entry, src/main.ts, that routes the card's places and loads each heavy place with import() (One plugin, many places).
  • File Explorer's places.json lists the extensions of each place. The page and the app both read it: the app for the tab's icon, the page's accessibility identifier, and which places take ⌘S and ⌘F.

Git's manifest is the ovvi key of plugins/builtin/git/package.json: its settings, history, handles, and header controls that start hidden. The shape of its src/main.ts, which shows each place its own controls:

import type { Ovvi } from "@ovvi/plugin-api"
import "./rows.css"
import "./home.css"
import "./diff.css"
import { ovvi as manifest } from "../package.json"
import * as Home from "./home"
import { parse } from "./lib"

const DIFF = manifest.header.flatMap(({ id }) => (id?.startsWith("diff.") ? [id] : []))
const HOME = manifest.header.flatMap(({ id }) => (id && !id.startsWith("diff.") ? [id] : []))

export default async function main(ovvi: Ovvi) {
  await ovvi.pages.route(async (page) => {
    const { root, source } = parse(page.arg)       // "<root>/ <source>" is a diff, "<root>/" the home
    for (const id of HOME) page.ovvi.controls.set(id, { hidden: source !== undefined })
    for (const id of DIFF) page.ovvi.controls.set(id, { hidden: source === undefined })
    if (source === undefined) await Home.make(page, root)
    else await (await import("./diff")).make(page, root, source)   // @pierre/diffs loads on the first diff
  })
}

Choices

The decisions are D97 and D98 on the Decisions page.

On this page