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" iscsv-table), and puts the name and the id inpackage.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>/, setnameand theovvikey'sidandname, then runnpm installandnpm run buildin 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 mapThe 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 }]
}
}buildbundlessrc/main.tsintodist/as ES modules, with shared chunks for dynamic imports and a linked source map.devbuilds again on every save. The card still loads the new build only on Reload Plugin.checkchecks the types against the host's (Types).- The template opens
.txtfiles as anoption, so it never takes them from File Explorer: pick it in Open With. Itssrc/main.tsshows 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 withovvi.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.tsimports becomesdist/main.css. The app links it, because it has the entry's name.
What the build must write
- The file that
entrynames (dist/main.js), as an ES module whose default export ismain. - 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=safari18is enough.
Open it
- 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. - Double-click a
.csvfile 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. - A plugin that you did not make in the app asks to install in its first card (The confirmation). Click Install.
- 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.
- 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.
- The app reads every manifest again.
- It runs the package's
buildscript, if it has one. Only a confirmed plugin builds. - It loads each card's page again.
maingets 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 build | What the open cards do |
|---|---|
| passes | each card loads its page again, with its state |
| fails | the 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 -fon it shows every build,consoleline 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):
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'sid, itsfolderand itslog.- The agent edits the folder, following these pages and the types in
@ovvi/plugin-api. ovvi open csv-table ./data.csvopens a card beside the agent's pane.ovvi plugins --for data.csvsays which plugin opens a file by default.- After each edit,
ovvi plugins reload csv-tablebuilds it and loads its cards again. It answersbuilt,msandcards. A failed build isbuild_failed, with the build's last 20 lines. A card with unsaved edits stops it withdirty: the agent asks the user to save. - The agent reads the log for its
consolelines 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.
| Plugin | Dependencies | Build |
|---|---|---|
File Explorer (plugins/builtin/explorer) | CodeMirror 6 with its languages, @pierre/trees, markdown-it, pdfjs-dist | copies pdf.js's files into dist/, then the template's esbuild line |
Git (plugins/builtin/git) | @pierre/diffs, @pierre/trees | the template's esbuild line |
- Each has one entry,
src/main.ts, that routes the card's places and loads each heavy place withimport()(One plugin, many places). - File Explorer's
places.jsonlists 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.