The manifest
The ovvi key of a plugin's package.json: who it is, what it opens, how it shows, its header row, its settings and what it can do
A plugin's manifest is the ovvi key of its package.json. The app reads
it before any of the plugin's code runs. So the chooser, ⌘K, Open With, the
card's header row and the placeholder in herdr's TUI show at once, and the
page only fills them in. There is no separate manifest file.
An example
A CSV table's package.json. The scripts and the dependencies are the
template's (Build a plugin),
left out here:
{
"name": "csv-table",
"version": "0.1.0",
"description": "A CSV or TSV file as a table you can sort.",
"keywords": ["csv", "table"],
"ovvi": {
"id": "csv-table",
"name": "CSV Table",
"api": 3,
"entry": "dist/main.js",
"icon": "tablecells",
"opens": { "files": ["*.csv", "*.tsv", "public.comma-separated-values-text"] },
"header": [
{ "id": "rows", "type": "text", "style": "dim" },
{ "type": "spacer" },
{ "id": "first-row", "type": "toggle", "symbol": "tablecells.badge.ellipsis",
"title": "First Row Is Header", "value": true },
{ "id": "reload", "type": "button", "symbol": "arrow.clockwise", "title": "Reload" }
]
}
}Fields
From the package itself:
| Field | Required | Value |
|---|---|---|
version | yes | The plugin's version, shown on the Plugins page, the confirmation and the log. The defaults' version decides when an app update replaces them. |
description | no | One line: the chooser row's detail, and a line of the confirmation. |
keywords | no | More words that ⌘K finds the plugin's Open ‹chooser› Here by. |
In the ovvi key:
| Field | Required | Value |
|---|---|---|
id | yes | 1 to 64 of a-z, 0-9 and -, starting with a letter or digit. The label's plugin id (ovvi:<id>#…). It is the plugin's own, because a package name can be scoped (@ovvi/git). The first package of an id wins (Where the registry looks). |
name | yes | What the user reads: the tab's first part ("CSV Table data.csv"), the placeholder's title, Open With, ⌘K, the Plugins page. |
api | yes | The API version the plugin is written for, an integer, 1 or more (Versions). |
entry | yes | The built entry, relative to the package: a .js file inside it, for example dist/main.js. Its chunks, its CSS (main.css) and its assets sit beside it in one flat folder. |
icon | yes | An SF Symbol name, or one of the app's own symbols: source.branch (Material Design Icons' source-branch) or text.wrap (wrap): the card's, the tab's and the chooser's icon, on the neutral tile. A control's or a menu item's symbol can name it too. |
opens | no | What the card shows, and so what its label's argument is (What a plugin opens). No opens: the argument is empty, and the card uses the pane's folder. |
chooser | no | A row title for the New pane chooser and the command "Open ‹chooser› Here" (How it shows). |
header | no | The controls of the card's second header row (Header). |
history | no | true draws Back and Forward at the left of row 2, over the places the card went to (One plugin, many places). |
commands | no | Menu bar and ⌘K commands of the card, with keys (Commands). |
handles | no | The app's own commands that the card takes: save (⌘S), find (⌘F, ⌘G, ⇧⌘G). |
settings | no | Values the user sets on the plugin's page in Settings, which its cards read (Settings). |
connect | no | Servers on this Mac that the page reaches with fetch or a WebSocket, as origins: http or ws, the host 127.0.0.1, and a port, for example ["ws://127.0.0.1:57900"]. They join the page's Content Security Policy (What the app protects), and the confirmation names them (The confirmation). |
- The app reads the manifest leniently. An unknown field is ignored, so a manifest written for a newer app still loads.
- A missing required field or a wrong value leaves the plugin out. The log
and the plugin's cards name the field:
ovvi.entry must be a built .js file in the package,ovvi.header.1 has the wrong type(Errors and logs). - The checks beyond the types: the
id's letters, a non-emptynameandicon,apiof 1 or more,entrya.jspath with no/at the start and no..,opens.priority, achooseronly withfolders,gitor noopens, known header types with unique ids, agapof 0 or more,digitsonlymono, a value on every segmented option, commands with ids unique among the commands and the header's controls, afile,editorviewmenu and a key that reads as a chord, knownhandles,connectorigins as above, and the settings' rules (Settings).
What a plugin opens
opens has one or more of these keys, and priority. The key sets what the
label's argument holds, and the card's title.
| Key | The argument | The title | Example |
|---|---|---|---|
files | a file's absolute path | the file's name | { "files": ["*.csv", "public.comma-separated-values-text"] } |
folders | a folder's path, ending in /: the root of its git repository, else the folder | the folder's name | { "folders": true } |
git | a repository's root with its /, then optionally a space and uncommitted or a commit hash | the root's name, Working Tree or the short hash (the page's title, a commit's subject, once it loads) | { "git": true } |
urls | a URL | its last part | { "urls": ["https://github.com/*/*/pull/*"] } |
filesis a list of patterns. Each is a name glob (*.csv,Makefile), case blind, or a uniform type identifier (public.image). A type matches its subtypes, asUTType.conforms(to:)says. A name the system does not know (a dynamic type, such as.rs) matches no type, only a glob or*.filesandfolderscan go together: File Explorer opens both, with{ "folders": true, "files": ["*"] }.urlsis a list of globs for web addresses, case blind, where*also spans/. Anovvi.openof anhttporhttpsaddress with nopluginopens in adefaultplugin whoseurlsmatch it (a user's plugin before a default plugin), else in the browser.priorityis"default"(the default) or"option". An option plugin opens a file only when the user picks it in Open With.
Which plugin opens a file
A link in a card, a double-click in File Explorer's tree and ovvi.open
with no plugin open a file with its default plugin. The app picks it by the
file's name alone, so a remote file is not read first:
- Only
defaultplugins whose patterns match take part. - The most specific pattern wins: an exact name (
Makefile), then a glob (*.md), then a type, a subtype before its supertype (public.textbeforepublic.data), then*. - On a tie, a user's plugin wins over a default plugin. Then the lower id in alphabetical order wins.
File Explorer's * is the lowest rank. So a CSV plugin with *.csv takes
.csv files from File Explorer, and any plugin with a pattern for a file
wins over it. A path that ends in / opens in the first plugin with
folders. An http or https address opens in the first plugin whose
urls match it, else in the browser.
How it shows
| Place | What shows | From |
|---|---|---|
| New pane chooser | a row under Browser, with the icon, the chooser title and the description, the defaults first, then the user's plugins by id | chooser |
| ⌘K, the File menu, the card's menu | "Open ‹chooser› Here" | chooser, keywords |
Open With (an openWith control in row 2) | after the Mac's apps, a divider and every other plugin whose patterns match the file, option ones too, each with its icon and name; a pick turns this card into that plugin's card | opens.files |
| the card's first row, the tab, the floating window | "‹name› ‹title›", the icon | name, icon, opens |
| the placeholder in herdr's TUI | the name and the argument | name |
| Settings ▸ Plugins and the plugin's page | the icon, the name, the version, the description and the settings | icon, name, version, description, settings |
- Open With lists the Mac's apps only on This Mac. The plugins show on every machine.
- A pick in Open With renames the pane to the other plugin's label with the same iid and argument. The card is made again as that plugin.
Header
header lists the controls of row 2, left to right
(The card). The app draws them
natively: the row is 32 pt tall in a pane (36 pt in a floating window), buttons are 22 × 22 pt in the card buttons'
color, and the row ends where row 1 does. They show before the page loads,
in a pane and in a floating window. The page changes them with
ovvi.controls.set and hears them with ovvi.on("control")
(Types). A click on a button, a toggle, a segment
or a menu item gives the card the keyboard, as a click on its page does.
type | Fields | Draws | The page hears |
|---|---|---|---|
button | id, symbol, title, badge, busy, label, style | an icon button; title is its help; badge a count after the symbol; busy a spinner in place of the symbol. "style": "bordered" draws the system's small bordered button with the symbol and the title (Git's Working Tree, Fetch, Pull and Push), or, when the row has no room for every bordered button's title, each with its symbol alone | { id } on a click |
toggle | id, symbol, title, value (boolean), onSymbol, style | an icon button, in the accent while on; with onSymbol, that symbol while on, in the buttons' color (File Tree). "style": "segment" draws it as one of the pill segments, picked while on (Markdown's outline, beside the mode and the lock) | { id, value } |
segmented | id, options ([{ "value", "title", "symbol" }]), value | the app's pill segments. An option with a symbol shows it alone, with the title as its help, gray while not picked (Markdown's mode) | { id, value } |
menu | id, title, symbol, items, value, busy, style | a menu button showing the picked item's title and symbol, else title and symbol; busy a spinner in place of its symbol. "style": "icon" shows the symbol alone, with the title as its help. An item is { "value", "title", "symbol" }, { "title" } for a section heading ("Remote"), or "-" for a divider | { id, value } on a pick |
field | id, placeholder, value | a search field ("Filter") that takes the free width | { id, value } on each change, and { id, value, submit: true } on Return |
field with "style": "path" | id, symbol, label, value (a folder's path) | a path field, as the browser's address: at rest the path with the home as ~ (on This Mac), cut at the start when it is long; a click shows the whole path, selected, to edit. Esc puts the path back. symbol is a button before the path | { id, value, submit: true } on Return with the typed path, absolute (a relative one from the field's path; ., .. and // resolved, ~ expanded on This Mac), and { id } on a click of the symbol |
text | id, value, style (plain, dim or path), symbol, digits, links | text cut in the middle; path draws a list of parts with › between them, the first part dim; symbol draws before the last part (File's {} before the scope); links makes a path's first parts buttons (the File Explorer's folders), never the last part | { id, part } on a click of a linked part, part its index from 0 |
openWith | id, value (a file's path) | Open With: the default app's icon and name open the file; its menu lists the apps with icons, then the plugins. Not drawn with no path | nothing |
spacer | none | free space | nothing |
- Every control also takes
hidden,enabled,label(what VoiceOver reads when the title says too little: "Push, 1 ahead") andgap. - Spacing. Controls sit 6 pt apart. A control's
gapsets the points before it:0joins icon buttons into a group (Preview's zoom buttons, Diff's Previous and Next),2sets them close. - Width. A
textwith"digits": "mono"has monospaced digits, so a count (Preview's3 of 12) keeps its width as it changes. Free space goes to a spacer or a field. With neither, it goes after the last control, so the row starts at the left. - Ids. A control's
idis unique in the plugin. It is also the end of its accessibility identifier:pane.<paneID>.<id>. A segment adds its title in lower case:pane.<paneID>.mode.source. - Values. A toggle's value is a boolean, a
pathtext's a list of strings, the others' a string. The app keeps each value in the card's state file under"controls"(State). A card loaded again, or shown again after it was dropped, gets the same values. - A toggle, segments and a field change their value natively, then the page hears it. A menu pick only sends: an item can be an action, and the page sets the menu's value (Git's branch menu shows the branch once the switch ran). The same segment again, or a field set to its own text, sends nothing.
- Asks of the page.
controls.set(id, { focus: true })gives a plain field (not a path field) the keyboard (a page's ⌘F).controls.set(id, { refused: true })shakes a path field, which keeps the typed path: the path names no place to go. - One header for many places. A control that only some places use
starts
hidden: truein the manifest. The page shows its place's controls before it callscard.ready(). So a brand-new card draws only what the manifest shows until its page runs. - No row. A plugin with no
headerand nohistoryhas one header row, in a pane and floating.
Commands
commands puts a card's commands in the menu bar and ⌘K, with their keys,
only while that card has the keyboard. Elsewhere the item is gone, so it
swallows no key (D93). The app owns the keys: each command is a row in
Settings ▸ Keyboard Shortcuts, in a group named as the plugin, where the user
can change or clear its key (Keyboard Shortcuts).
The page never matches the key itself: it hears the pick.
"commands": [
{ "id": "lock-editing", "title": "Lock Editing", "menu": "view", "key": "cmd+e", "symbol": "lock",
"keywords": ["markdown", "read", "edit", "unlock"] }
]menuisfile,editorview.keywordsare more words that ⌘K finds the command by.keyis the default key, a chord withcmd,ctrloroption(alt), plusshiftif needed, and one key: a character orleft,right,up,down,return,tab,space,escape,delete,plus(cmd+e,cmd+shift+k,ctrl+option+left). The user's key wins over it. A key that an app command already has is left off, the log says so once, and Settings marks the row.- A pick sends
{ id }toovvi.on("control"), as a header button does.ovvi.controls.setchangestitle,checkedandenabled. A disabled command has no key, so ⌘E has its key only over Markdown. handles: ["save"]gives the card ⌘S (File ▸ Save).handles: ["find"]gives it ⌘F, ⌘G and ⇧⌘G. The page hearssave,find,findNextandfindPrevious. Git takes ⌘F only in a diff, not on its home.
Settings
settings lists up to 16 values that the user sets on the plugin's page in
Settings ▸ Plugins. The app draws them from the manifest, saves them in
settings.json (Plugin settings in settings.json),
and gives them to the plugin's cards: ovvi.settings.get reads one, and
the settings event brings each change at once
(Types). A setting is one value for all machines.
"settings": [
{ "id": "first-row", "type": "toggle", "title": "First row is the header", "default": true },
{ "id": "delimiter", "type": "choice", "title": "Delimiter", "default": "auto",
"options": [{ "value": "auto", "title": "Auto" }, { "value": "comma", "title": "Comma" },
{ "value": "tab", "title": "Tab" }] },
{ "id": "rows", "type": "number", "title": "Rows to read", "default": 10000,
"min": 1000, "max": 100000, "step": 1000, "description": "Larger files open the first rows only." }
]Every setting has these fields:
| Field | Required | Value |
|---|---|---|
id | yes | 1 to 64 of a-z, 0-9 and -, starting with a letter or digit, once in the plugin. |
type | yes | toggle, choice, number, text or path (the table below). |
title | yes | 1 to 60 characters: the row's title. |
default | yes | The value while the user has not set one, of the type. |
description | no | One line under the title, up to 200 characters. |
section | no | Up to 60 characters: a heading over this row and the rows after it. The first rows go under "Settings". |
| Type | Value | More fields | Drawn as |
|---|---|---|---|
toggle | true or false | none | a switch |
choice | one option's value | options: 2 to 12 of { "value", "title" }, each value once | a segmented control for up to 4 options whose titles have 24 characters in all, else a pop-up menu |
number | a number | min, max, step, unit (all optional; min under max, step over 0) | a field with a stepper, held in the range |
text | a string of up to 1 KB | placeholder (up to 1 KB) | a text field, saved on Return or when it loses the keyboard |
path | an absolute path (it starts with / or ~/, or is ~), or empty | kind: file or folder; placeholder | a path field, saved as a text field is |
- A setting is a value. There is no secret type: no plugin can ask for a password or a token in Settings.
- A wrong setting leaves the plugin out, as a wrong header does. The log
names the field:
ovvi.settings.2.default does not fit the type, options or range,ovvi.settings.0.title must be 1 to 60 characters,ovvi.settings.1.id is taken,ovvi.settings has over 16 settings,ovvi.settings.0.description must be 0 to 200 characters. - The page reads them, and never sets them: the user owns them. A card keeps
its own choices in
ovvi.state. The default plugins readovvi.state.get()?.layout ?? ovvi.settings.get("diff-layout"), so a setting starts new cards and a card's own choice in its header wins.
Choices
The decisions are D96, D99, D103, D107 and D108 on the Decisions page.