Files
orca/docs/reference/plugin-declarative-ui.md
T

220 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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](./plugin-isolated-frames.md).
## Manifest
```json
{
"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
```tsx
/** @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 `orca` by name. They are JSX tags only, and lowercase tags such as
`<div>` do not exist.
- Function components take props and `children` as 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`, a `Map`, a class instance) is reported as an error and the
render is not shown.
- `key` works as in React. Give items of lists that reorder a `key`; 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, bump `version`; `migrate(stored, fromVersion)` turns
an older value into the new shape, and without it the atom starts from `initial`.
- `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:
```ts
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](./plugin-host-api.md)); rejections carry a `code` such as `timeout` or
`capability_denied`. `host.has(method)` says whether this Orca offers a method to the panel.
- `api` is `{ major, minor }` of the host API.
- `env.locale` is Orca's UI language, `env.theme` is `{ colorScheme }` (reading it while rendering
re-renders on a switch) and `env.client` is `'desktop'`.
- `shell.openExternal(url)` and `files.saveAs({ suggestedName, mimeType, data })` are shorthands for
`host.call('shell.openExternal' | 'files.saveAs', ...)` and resolve with the same result. They follow
the user-gesture rule below; the hidden module has no `window.orca.shell` or `window.orca.files`.
- `on(event, handler)` returns a `Disposable`. Events are typed (`OrcaEvents`); Orca's own panel messages
are not events and `on()` refuses them.
- `ui.catalog` is 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`](../../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:
```sh
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`](../../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:
```tsx
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.
- `defaultValue` seeds an uncontrolled field.
- `value` controls it. Once your panel has handled the person's latest edit, the `value` you 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.
- `value` without `onChange` makes the field read-only. `Switch` (`checked`/`defaultChecked`) and `Select`
(`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.