14 KiB
Components panels (TSX)
A components panel describes its UI as data; Orca draws it with its own controls, so it matches the app in light and dark, follows its keyboard and focus rules, and needs no HTML or CSS. Design contract: plugin-isolated-frames.md.
Manifest
{
"execution": "sandboxed-ui",
"engines": { "orca": ">=1.4.246" },
"contributes": {
"panels": [{ "id": "todos", "title": "Todos", "entry": "dist/panel.js", "ui": "components" }]
}
}
ui defaults to html (a web page in an isolated frame). With components, entry must be a .js or
.mjs ES module. ui arrived in Orca 1.4.246, so declare at least that in engines.orca; an older Orca
then says which release the plugin needs.
Writing a panel
/** @jsxImportSource orca */
import { atom, read, set, update, Stack, Text, Button } from 'orca'
const count = atom('count', 0, { persist: 'plugin' })
export default function Panel() {
return (
<Stack gap="sm">
<Text>Clicked {read(count)} times</Text>
<Button onPress={() => update(count, (n) => n + 1)}>Click</Button>
<Button variant="ghost" onPress={() => set(count, 0)}>
Reset
</Button>
</Stack>
)
}
- The default export must be a component function; it is mounted as the panel.
mount(Component)does the same explicitly. - Catalog components are imported from
orcaby name. They are JSX tags only, and lowercase tags such as<div>do not exist. - Function components take props and
childrenas in React. There are no hooks: keep state in atoms. - Props are plain JSON data (strings, finite numbers, booleans, null, arrays, plain objects) or event
handler functions. Anything else (a
Date, aMap, a class instance) is reported as an error and the render is not shown. keyworks as in React. Give items of lists that reorder akey; siblings must not share one.
State
atom(initial)holds state for this panel run.atom(key, initial, { persist, version, migrate })is named, unique per plugin, and may persist:persist: 'memory'(default),'plugin'(every run of the plugin) or'workspace'(per Orca workspace; switching workspaces swaps the values). Persisted values must be plain JSON, load before the first render, are saved shortly after they change, and live in the plugin's own data folder; no capability is needed. When the stored shape changes, bumpversion;migrate(stored, fromVersion)turns an older value into the new shape, and without it the atom starts frominitial.derived(() => ...)computes from other atoms; it is read-only.read(atom)reads; reading while rendering subscribes the panel to it.set(atom, value)stores a value as is, functions included;update(atom, (previous) => next)derives the next one. Both are batched (about one render per frame) and throw while rendering.
Lifetime
The module runs while its panel is the open right-sidebar tab, including while the sidebar is collapsed or Orca's window is hidden. Switching to another tab, closing the panel, disabling the plugin or reloading it ends the run; only persisted atoms carry over to the next one.
An optional setup export runs once, after persisted atoms load and before the first render:
import { on, read, type PanelContext } from 'orca'
export function setup(ctx: PanelContext) {
ctx.subscriptions.push(on('theme', ({ colorScheme }) => console.log(colorScheme)))
// ctx.signal aborts, and ctx.subscriptions are disposed, when the run ends.
// read(ctx.visible) is true while the person can see the panel.
return () => {
// Also runs when the run ends.
}
}
Cleanup runs when the panel reloads or navigates; when a panel is closed its process ends with it, so do not rely on cleanup to save anything: persist it in an atom.
Talking to Orca
The orca module has the same facade as frame panels:
host.call(method, params, { signal, timeoutMs }), typed from the host API (see plugin-host-api.md); rejections carry acodesuch astimeoutorcapability_denied.host.has(method)says whether this Orca offers a method to the panel.apiis{ major, minor }of the host API.env.localeis Orca's UI language,env.themeis{ colorScheme }(reading it while rendering re-renders on a switch) andenv.clientis'desktop'.shell.openExternal(url)andfiles.saveAs({ suggestedName, mimeType, data })are shorthands forhost.call('shell.openExternal' | 'files.saveAs', ...)and resolve with the same result. They follow the user-gesture rule below; the hidden module has nowindow.orca.shellorwindow.orca.files.on(event, handler)returns aDisposable. Events are typed (OrcaEvents); Orca's own panel messages are not events andon()refuses them.ui.catalogis a number that grows with the component catalog;ui.has(component, prop?)says whether this Orca draws a component or accepts a prop on it.
Types: copy resources/plugins/declarative/orca.d.ts into
your project and include it from tsconfig.json with "jsx": "react-jsx" and "jsxImportSource": "orca".
Building
orca is provided by Orca at runtime, so leave it out of the bundle:
esbuild src/panel.tsx --bundle --format=esm --jsx=automatic --jsx-import-source=orca \
--external:orca --external:'orca/*' --outfile=dist/panel.js
/** @jsx h */ with import { h, Fragment } from 'orca' (classic jsxFactory) also works. The sample in
examples/plugins/hello-components has a build script.
Catalog
Props are semantic: tones, sizes and gaps, never class names, styles or colours. Colour has one
vocabulary, tone: neutral accent success warning danger.
| Component | Props | Children |
|---|---|---|
Stack |
gap (none xs sm md lg), align (start center end stretch) |
any |
Row |
gap, align, justify (start center end between), wrap |
any |
Text |
tone, muted, size (xs sm md), weight (regular medium semibold), truncate |
text, Text Link Code Badge |
Heading |
level (1–3, below the panel title) |
as Text |
Button |
variant (solid outline ghost), tone, size, icon, disabled, onPress() |
text |
IconButton |
icon and label (required; the label is its name and tooltip), variant, tone, size, disabled, onPress() |
none |
Badge |
tone |
text |
Card |
title, description |
any |
List |
label |
ListItem only |
ListItem |
title, description, icon, current, disabled, onPress() |
any; no controls with onPress |
Input |
label (required), labelHidden, value, defaultValue, placeholder, type (text search password), maxLength, invalid, disabled, onChange(value), onSubmit(value) |
none |
Switch |
label (required), checked, defaultChecked, disabled, onChange(checked) |
none |
Select |
label (required), labelHidden, value, defaultValue, placeholder, options ({ value, label, disabled? }[]), disabled, onChange(value) |
none |
Separator |
orientation (horizontal vertical) |
none |
Progress |
label (required), labelHidden, value (0–100) |
none |
Spinner |
label (required), labelHidden |
none |
EmptyState |
title (required), description, icon |
actions |
Link |
href (https:, http:, mailto:; opened in the OS browser), onPress() |
text |
Code |
block |
text |
labelHidden keeps the label as the control's accessible name without showing it.
Icons are the same names a manifest panel's icon takes, for example add delete edit
refresh search settings check close info warning error open-external copy download
play stop more filter branch file folder terminal agent link rocket database; see
IconName in the typings. Any other name shows a neutral fallback glyph.
Forward compatibility: an unknown prop or enum value is dropped (Orca logs a warning once), and an
unknown component, or one missing a required prop, is drawn as a neutral placeholder, so a panel built for a
newer Orca still shows the rest of its UI. Only structural problems (a ListItem outside a List, a
Stack inside Text, duplicate keys, controls inside a pressable ListItem) and the limits below refuse a
render.
Import only the components your minimum supported Orca exports. orca is a real ES module, so a named
import it lacks (import { Carousel } from 'orca' on an older Orca) is a link error: the whole panel
module fails to load, rather than getting undefined. For a component newer than your minimum, check
ui.has() and create it by name with h; an Orca that does not know it draws the placeholder:
import { h, ui, Text } from 'orca'
const gallery = ui.has('Carousel') ? (
h('Carousel', { label: 'Shots' })
) : (
<Text muted>Update Orca to see the gallery.</Text>
)
Inputs
Orca owns what the person types, so a re-render never drops characters and Enter submits exactly what is on
screen. onChange(value) reports each edit; onSubmit(value) fires on Enter.
defaultValueseeds an uncontrolled field.valuecontrols it. Once your panel has handled the person's latest edit, thevalueyou render is what the field shows, including a reset to the same value it had before (''after submit). A render that your panel made before handling their latest keystrokes does not touch their text.valuewithoutonChangemakes the field read-only.Switch(checked/defaultChecked) andSelect(value/defaultValue) work the same way.
A handler that throws (or rejects) is reported in a banner attributed to your plugin; the panel keeps its last good render.
Limits
A render is refused, and the last good one kept, beyond 5000 nodes, depth 64, about 512 KiB of data,
16 KiB per text, 1 KiB per label prop, 32 props per node or 500 Select options.
User gestures
host.call('shell.openExternal' | 'files.saveAs') needs a user gesture, and only a press on Orca's own
controls counts (the hidden module never has one of its own). A press on a Button, IconButton,
ListItem or Link grants one call within 1 s, but only when that control has been stationary for 500 ms:
the same place in the tree, the same label and target, and the same position on screen. Anything that
moves it (including a sibling growing above it) restarts the wait, so a panel cannot slide a control under
the pointer just before a click. Scrolling the panel does not count as a move.
A Link follows the same rule before anything happens: a press on an unsettled Link neither opens its
href nor runs its onPress. When a Link has both, the href open uses the press, and onPress runs
without granting a second gesture.
Isolation
The module runs in a hidden, sandboxed guest of the plugin's own partition, with the same isolation as frame
panels: no network (requests outside the plugin's own files are cancelled; Electron 43 still resolves
dns-prefetch hostnames), no Node, no popups, no DOM the person sees. It reaches Orca only through
host.call, on, and the UI it renders. Components panels need the desktop app: browser clients
(orca serve in a web browser) show that instead of the panel.