mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 09:27:29 +00:00
feat(plugins): status bar items plugins can update live (#37291)
* plugins: contributes.statusBarItems with worker updates (statusBar.update)
A plugin declares status bar items { id, text?, icon?, tone?, tooltip?,
command? } (declarative profile, engines >=1.4.247); pressing one runs
its own command. A trusted worker changes them with the new worker-only
host method statusBar.update; main keeps the session's changes and
pushes snapshots to desktop windows over plugins:statusBarChanged.
Regenerates schemas, references, typings and the authoring skill.
* plugins: statusBarItems ship in 1.4.248, beside contributes.hooks
* plugins: clear a plugin's status bar changes when it is disabled, removed or updated
* plugins: list status bar items in the plugin page's Adds section
* plugins: count statusBarItems' since in the host's newest readable version
* i18n: add the plugin page's status bar row label to en.json
* plugins: status bar items respect workspace commands, dev reloads and catalog summaries
- An item whose command needs a workspace is disabled with no open workspace.
- A dev code edit (new devRevision) drops the old worker's status bar changes.
- Catalog summaries list status bar items, and marketplace details show them.
* plugins: authoring skill lists status bar items as buildable; approval lists them
The skill no longer calls status bar items reserved and gains a row for them. The
dev-load/inspect approval already lists them via the contribution summary; a test pins
that and the fingerprint binding.
* chore(plugins): regenerate the testing bundle after rebasing on main
This commit is contained in:
@@ -627,7 +627,6 @@ arbitrary plugin UI:
|
||||
- `taskSources`: issue and task providers such as Asana, Linear or Jira, filled by a worker.
|
||||
- `connections`: account providers with a host-run sign-in.
|
||||
- `agentHarnesses`: new coding-agent harnesses described as data.
|
||||
- `statusBarItems`: declarative status bar widgets whose values a worker updates.
|
||||
- `sidebarCards`: declarative left-sidebar cards whose values a worker updates.
|
||||
- `mcpServers`: MCP server definitions offered to the agents Orca launches.
|
||||
- `skills`: agent skills shipped as files.
|
||||
|
||||
@@ -108,7 +108,7 @@ One thing the release adds to Orca.
|
||||
|
||||
| Field | Type | Required | Since | Description |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `kind` | `"panels"` \| `"commands"` \| `"events"` \| `"languagePacks"` \| `"keybindings"` \| `"vmRecipes"` \| `"themes"` \| `"terminalThemes"` \| `"iconThemes"` \| `"syntaxes"` \| `"customEditors"` \| `"newFiles"` \| `"settings"` | yes | 1.4.245 | Contribution point. Example: `"themes"` |
|
||||
| `kind` | `"panels"` \| `"commands"` \| `"events"` \| `"languagePacks"` \| `"keybindings"` \| `"vmRecipes"` \| `"themes"` \| `"terminalThemes"` \| `"iconThemes"` \| `"syntaxes"` \| `"customEditors"` \| `"newFiles"` \| `"settings"` \| `"statusBarItems"` | yes | 1.4.245 | Contribution point. Example: `"themes"` |
|
||||
| `name` | string | yes | 1.4.245 | What the item is called in Orca. Example: `"Dusk"` |
|
||||
| `detail` | string | no | 1.4.245 | Extra context, such as file extensions. |
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ Methods whose capability is `none` need no capability but still need the plugin
|
||||
| [`plugin.request`](#pluginrequest) | none | panel, editor | no | 1.2 | Asks the plugin's own worker to run the handler it registered with `orca.panels.handle(name)`, starting the worker if needed; resolves with what the handler returned. `unknown_method` when no handler has that name. |
|
||||
| [`agents.list`](#agentslist) | `agent:read` | worker, panel, editor | no | 1.2 | The agents in the focused workspace: their terminal, title, agent kind and status. `agent.status.changed` reports each change after. |
|
||||
| [`events.subscribe`](#eventssubscribe) | none | panel, editor | no | 1.2 | Delivers a workspace or agent event to this panel as `orca.on(event)`; needs the event's read capability. The frame APIs call it for you. |
|
||||
| [`statusBar.update`](#statusbarupdate) | none | worker | no | 1.2 | Changes one of the plugin's `contributes.statusBarItems` (named by `id`): fields given replace their current value, `visible: false` hides it. Desktop app only; lasts until Orca restarts, and is hidden while the plugin is disabled. |
|
||||
|
||||
### `workspace.readContext`
|
||||
|
||||
@@ -175,6 +176,14 @@ Delivers a workspace or agent event to this panel as `orca.on(event)`; needs the
|
||||
(params: { event: "workspace.created" | "workspace.removed" | "agent.status.changed" | "workspace.changed" }) => { ok: true }
|
||||
```
|
||||
|
||||
### `statusBar.update`
|
||||
|
||||
Changes one of the plugin's `contributes.statusBarItems` (named by `id`): fields given replace their current value, `visible: false` hides it. Desktop app only; lasts until Orca restarts, and is hidden while the plugin is disabled.
|
||||
|
||||
```ts
|
||||
(params: { id: string; text?: string; icon?: "activity" | "bar-chart-3" | "bell" | "blocks" | "book" | "bot" | "bug" | "calendar" | "cloud" | "code" | "database" | "file-text" | "flag" | "folder" | "gauge" | "globe" | "hammer" | "layers" | "lightbulb" | "package" | "plug" | "puzzle" | "rocket" | "star" | "terminal" | "wrench" | "zap" | "add" | "remove" | "delete" | "edit" | "refresh" | "search" | "settings" | "check" | "close" | "info" | "warning" | "error" | "open-external" | "copy" | "download" | "upload" | "play" | "pause" | "stop" | "more" | "expand" | "collapse" | "next" | "filter" | "branch" | "file" | "agent" | "link"; tone?: "neutral" | "accent" | "success" | "warning" | "danger"; tooltip?: string; visible?: boolean }) => { ok: true }
|
||||
```
|
||||
|
||||
## Events
|
||||
|
||||
A worker receives the events its manifest lists in `contributes.events` through `orca.events.on(event, handler)`, which returns `{ dispose() }`. Each needs `main`, the `trusted-worker` profile and its domain's capability (`workspace:read` or `agent:read`). `orca plugin types` writes the payloads as `PluginEvents` in `orca/host`.
|
||||
|
||||
@@ -43,13 +43,13 @@ Add `"$schema": "https://raw.githubusercontent.com/stablyai/orca/main/docs/schem
|
||||
| `contributes.customEditors` | [CustomEditorContribution](#customeditorcontribution)[] | no | 1.4.245 | Editors that open files of given types in the editor tab. At most 16. |
|
||||
| `contributes.newFiles` | [NewFileContribution](#newfilecontribution)[] | no | 1.4.245 | Untitled files the user can create from the + menu and the command palette. At most 16. |
|
||||
| `contributes.settings` | [SettingContribution](#settingcontribution)[] | no | 1.4.246 | Settings Orca shows under the plugin in Settings; code reads them with `settings.get` (`settings:own`). At most 64. |
|
||||
| `contributes.statusBarItems` | [StatusBarItemContribution](#statusbaritemcontribution)[] | no | 1.4.248 | Items in the status bar; pressing one runs its `command`, and a worker changes them with `statusBar.update`. At most 8. |
|
||||
| `contributes.agentHarnesses` | any[] | no | 1.4.245 | Reserved for coding-agent harness definitions; must be empty until their format is defined. |
|
||||
| `contributes.skills` | any[] | no | 1.4.245 | Reserved for agent skills; must be empty until their format is defined. |
|
||||
| `contributes.mcpServers` | any[] | no | 1.4.245 | Reserved for MCP server definitions; must be empty until their format is defined. |
|
||||
| `contributes.hooks` | any[] | no | 1.4.245 | Reserved for agent hooks; must be empty until their format is defined. |
|
||||
| `contributes.taskSources` | any[] | no | 1.4.245 | Reserved for task sources; must be empty until their format is defined. |
|
||||
| `contributes.connections` | any[] | no | 1.4.245 | Reserved for account connections; must be empty until their format is defined. |
|
||||
| `contributes.statusBarItems` | any[] | no | 1.4.245 | Reserved for status bar items; must be empty until their format is defined. |
|
||||
| `contributes.sidebarCards` | any[] | no | 1.4.245 | Reserved for sidebar cards; must be empty until their format is defined. |
|
||||
| `capabilities` | [Capability](#capability)[] | no | 1.4.160 | Host APIs the plugin asks the user to grant. At most 32. |
|
||||
| `cover` | object | no | 1.4.245 | Wide image at the top of the plugin page. |
|
||||
@@ -188,6 +188,19 @@ A setting shown under the plugin in Settings. Requires the `settings:own` capabi
|
||||
| `multiline` | boolean | no | 1.4.246 | Strings: a multi-line text box. |
|
||||
| `placeholder` | string | no | 1.4.246 | Strings and numbers: hint shown while empty. |
|
||||
|
||||
## StatusBarItemContribution
|
||||
|
||||
An item in the status bar. Requires the `declarative` profile; a `trusted-worker` can change it with `statusBar.update`.
|
||||
|
||||
| Field | Type | Required | Since | Description |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `id` | string | yes | 1.4.248 | Item id, unique among this plugin's status bar items; `statusBar.update` names it. Example: `"blocked-count"` |
|
||||
| `text` | string | no | 1.4.248 | Short label shown in the status bar. Example: `"3 blocked"` |
|
||||
| `icon` | `"activity"` \| `"bar-chart-3"` \| `"bell"` \| `"blocks"` \| `"book"` \| `"bot"` \| `"bug"` \| `"calendar"` \| `"cloud"` \| `"code"` \| `"database"` \| `"file-text"` \| `"flag"` \| `"folder"` \| `"gauge"` \| `"globe"` \| `"hammer"` \| `"layers"` \| `"lightbulb"` \| `"package"` \| `"plug"` \| `"puzzle"` \| `"rocket"` \| `"star"` \| `"terminal"` \| `"wrench"` \| `"zap"` \| `"add"` \| `"remove"` \| `"delete"` \| `"edit"` \| `"refresh"` \| `"search"` \| `"settings"` \| `"check"` \| `"close"` \| `"info"` \| `"warning"` \| `"error"` \| `"open-external"` \| `"copy"` \| `"download"` \| `"upload"` \| `"play"` \| `"pause"` \| `"stop"` \| `"more"` \| `"expand"` \| `"collapse"` \| `"next"` \| `"filter"` \| `"branch"` \| `"file"` \| `"agent"` \| `"link"` | no | 1.4.248 | Icon shown before the text, from the panel icon set. Example: `"bell"` |
|
||||
| `tone` | `"neutral"` \| `"accent"` \| `"success"` \| `"warning"` \| `"danger"` | no | 1.4.248 | Colour of the item. Defaults to `neutral`. Example: `"warning"` |
|
||||
| `tooltip` | string | no | 1.4.248 | Shown on hover. Defaults to the plugin name. |
|
||||
| `command` | string | no | 1.4.248 | Id of a command this plugin contributes in `contributes.commands`, run when the item is pressed. Example: `"guard.showLog"` |
|
||||
|
||||
## Capability
|
||||
|
||||
A host API the plugin may call once the user consents.
|
||||
@@ -215,7 +228,7 @@ One screenshot of the plugin in use.
|
||||
- **`terminal:send` needs `trusted-worker`.** Panels get no host method that types into a terminal, so Orca refuses the capability on any other profile instead of asking the user to grant something the plugin can never use.
|
||||
- **New fields, contribution points and values are additive.** They arrive with a `Since` version; nothing else changes. `manifestVersion` rises only if an existing field changes meaning.
|
||||
- **Deprecation.** A field to be removed is marked deprecated here and keeps working for at least one release; it is removed only together with a higher `engines.orca` requirement, and its name is never reused.
|
||||
- **Reserved contribution points.** `contributes.agentHarnesses`, `skills`, `mcpServers`, `hooks`, `taskSources`, `connections`, `statusBarItems` and `sidebarCards` are held for planned features and must stay empty until their format is defined. Each will arrive as an additive point with its own `Since`, without a new manifest format. `skills`, `mcpServers` and `hooks` keep the meaning agent-plugin formats such as Claude Code's give them; `agents` stays unclaimed because those formats use it for subagent files.
|
||||
- **Reserved contribution points.** `contributes.agentHarnesses`, `skills`, `mcpServers`, `hooks`, `taskSources`, `connections` and `sidebarCards` are held for planned features and must stay empty until their format is defined. Each will arrive as an additive point with its own `Since`, without a new manifest format. `skills`, `mcpServers` and `hooks` keep the meaning agent-plugin formats such as Claude Code's give them; `agents` stays unclaimed because those formats use it for subagent files.
|
||||
|
||||
## Planned contribution points
|
||||
|
||||
@@ -224,7 +237,6 @@ Each reserved point is planned as declared data that Orca renders and enforces,
|
||||
- `taskSources`: Issue and task providers such as Asana, Linear or Jira, declared as data that a worker fills with tasks.
|
||||
- `connections`: Account providers with a host-run sign-in, so a plugin never handles credentials itself.
|
||||
- `agentHarnesses`: New coding-agent harnesses described as data: how to launch them and read their status.
|
||||
- `statusBarItems`: Declarative status bar widgets (label, icon, tooltip) whose values a worker updates.
|
||||
- `sidebarCards`: Declarative left-sidebar cards with fixed layouts whose values a worker updates.
|
||||
- `mcpServers`: MCP server definitions that Orca offers to the agents it launches.
|
||||
- `skills`: Agent skills shipped as files that Orca installs for the agents it launches.
|
||||
|
||||
@@ -563,7 +563,8 @@
|
||||
"syntaxes",
|
||||
"customEditors",
|
||||
"newFiles",
|
||||
"settings"
|
||||
"settings",
|
||||
"statusBarItems"
|
||||
],
|
||||
"description": "Contribution point.",
|
||||
"x-orca-since": "1.4.245",
|
||||
|
||||
@@ -315,6 +315,16 @@
|
||||
"$ref": "#/$defs/SettingContribution"
|
||||
}
|
||||
},
|
||||
"statusBarItems": {
|
||||
"default": [],
|
||||
"description": "Items in the status bar; pressing one runs its `command`, and a worker changes them with `statusBar.update`. At most 8.",
|
||||
"x-orca-since": "1.4.248",
|
||||
"maxItems": 8,
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/StatusBarItemContribution"
|
||||
}
|
||||
},
|
||||
"agentHarnesses": {
|
||||
"description": "Reserved for coding-agent harness definitions; must be empty until their format is defined.",
|
||||
"x-orca-since": "1.4.245",
|
||||
@@ -357,13 +367,6 @@
|
||||
"type": "array",
|
||||
"items": {}
|
||||
},
|
||||
"statusBarItems": {
|
||||
"description": "Reserved for status bar items; must be empty until their format is defined.",
|
||||
"x-orca-since": "1.4.245",
|
||||
"maxItems": 0,
|
||||
"type": "array",
|
||||
"items": {}
|
||||
},
|
||||
"sidebarCards": {
|
||||
"description": "Reserved for sidebar cards; must be empty until their format is defined.",
|
||||
"x-orca-since": "1.4.245",
|
||||
@@ -1272,6 +1275,132 @@
|
||||
"additionalProperties": false,
|
||||
"description": "A setting shown under the plugin in Settings. Requires the `settings:own` capability, which is also how code reads it."
|
||||
},
|
||||
"StatusBarItemContribution": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"maxLength": 64,
|
||||
"pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$",
|
||||
"description": "Item id, unique among this plugin's status bar items; `statusBar.update` names it.",
|
||||
"x-orca-since": "1.4.248",
|
||||
"examples": [
|
||||
"blocked-count"
|
||||
]
|
||||
},
|
||||
"text": {
|
||||
"description": "Short label shown in the status bar.",
|
||||
"x-orca-since": "1.4.248",
|
||||
"examples": [
|
||||
"3 blocked"
|
||||
],
|
||||
"type": "string",
|
||||
"maxLength": 64
|
||||
},
|
||||
"icon": {
|
||||
"description": "Icon shown before the text, from the panel icon set.",
|
||||
"x-orca-since": "1.4.248",
|
||||
"examples": [
|
||||
"bell"
|
||||
],
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"activity",
|
||||
"bar-chart-3",
|
||||
"bell",
|
||||
"blocks",
|
||||
"book",
|
||||
"bot",
|
||||
"bug",
|
||||
"calendar",
|
||||
"cloud",
|
||||
"code",
|
||||
"database",
|
||||
"file-text",
|
||||
"flag",
|
||||
"folder",
|
||||
"gauge",
|
||||
"globe",
|
||||
"hammer",
|
||||
"layers",
|
||||
"lightbulb",
|
||||
"package",
|
||||
"plug",
|
||||
"puzzle",
|
||||
"rocket",
|
||||
"star",
|
||||
"terminal",
|
||||
"wrench",
|
||||
"zap",
|
||||
"add",
|
||||
"remove",
|
||||
"delete",
|
||||
"edit",
|
||||
"refresh",
|
||||
"search",
|
||||
"settings",
|
||||
"check",
|
||||
"close",
|
||||
"info",
|
||||
"warning",
|
||||
"error",
|
||||
"open-external",
|
||||
"copy",
|
||||
"download",
|
||||
"upload",
|
||||
"play",
|
||||
"pause",
|
||||
"stop",
|
||||
"more",
|
||||
"expand",
|
||||
"collapse",
|
||||
"next",
|
||||
"filter",
|
||||
"branch",
|
||||
"file",
|
||||
"agent",
|
||||
"link"
|
||||
]
|
||||
},
|
||||
"tone": {
|
||||
"description": "Colour of the item. Defaults to `neutral`.",
|
||||
"x-orca-since": "1.4.248",
|
||||
"examples": [
|
||||
"warning"
|
||||
],
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"neutral",
|
||||
"accent",
|
||||
"success",
|
||||
"warning",
|
||||
"danger"
|
||||
]
|
||||
},
|
||||
"tooltip": {
|
||||
"description": "Shown on hover. Defaults to the plugin name.",
|
||||
"x-orca-since": "1.4.248",
|
||||
"type": "string",
|
||||
"maxLength": 256
|
||||
},
|
||||
"command": {
|
||||
"description": "Id of a command this plugin contributes in `contributes.commands`, run when the item is pressed.",
|
||||
"x-orca-since": "1.4.248",
|
||||
"examples": [
|
||||
"guard.showLog"
|
||||
],
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 256,
|
||||
"pattern": "^[A-Za-z0-9]+(?:[._-][A-Za-z0-9]+)*$"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"description": "An item in the status bar. Requires the `declarative` profile; a `trusted-worker` can change it with `statusBar.update`."
|
||||
},
|
||||
"Capability": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
+11
-11
File diff suppressed because one or more lines are too long
+6
@@ -142,6 +142,12 @@ declare module 'orca/host' {
|
||||
result: { ok: true }
|
||||
surfaces: "panel" | "editor"
|
||||
}
|
||||
/** Changes one of the plugin's `contributes.statusBarItems` (named by `id`): fields given replace their current value, `visible: false` hides it. Desktop app only; lasts until Orca restarts, and is hidden while the plugin is disabled. Since host API 1.2. */
|
||||
"statusBar.update": {
|
||||
params: { id: string; text?: string; icon?: "activity" | "bar-chart-3" | "bell" | "blocks" | "book" | "bot" | "bug" | "calendar" | "cloud" | "code" | "database" | "file-text" | "flag" | "folder" | "gauge" | "globe" | "hammer" | "layers" | "lightbulb" | "package" | "plug" | "puzzle" | "rocket" | "star" | "terminal" | "wrench" | "zap" | "add" | "remove" | "delete" | "edit" | "refresh" | "search" | "settings" | "check" | "close" | "info" | "warning" | "error" | "open-external" | "copy" | "download" | "upload" | "play" | "pause" | "stop" | "more" | "expand" | "collapse" | "next" | "filter" | "branch" | "file" | "agent" | "link"; tone?: "neutral" | "accent" | "success" | "warning" | "danger"; tooltip?: string; visible?: boolean }
|
||||
result: { ok: true }
|
||||
surfaces: "worker"
|
||||
}
|
||||
}
|
||||
export type HostMethodName = keyof HostMethods
|
||||
/** Methods a surface may call; any other gets `not_permitted`. */
|
||||
|
||||
@@ -43,8 +43,9 @@ Use the lowest `execution` profile that covers the request; it decides what the
|
||||
| Settings the user edits in Orca's Settings | `contributes.settings` and `settings:own` | `references/manifest.md` |
|
||||
| Reacting to workspace or agent status changes | `trusted-worker`; `main`, `contributes.events`, `*:read` capability | example `hello-orca` |
|
||||
| A virtual machine environment recipe | `declarative`; `contributes.vmRecipes` | hand-written; fields in `references/manifest.md` |
|
||||
| A status bar item (a worker can update it) | `declarative`; `contributes.statusBarItems` | `references/recipes.md` |
|
||||
|
||||
Examples are in `examples/plugins/` of github.com/stablyai/orca. Status bar items, sidebar cards,
|
||||
Examples are in `examples/plugins/` of github.com/stablyai/orca. Sidebar cards,
|
||||
task sources, connections, MCP servers, skills and hooks are reserved and not buildable yet: say
|
||||
so instead of faking them. A panel cannot type into a terminal; only a worker
|
||||
with `terminal:send` can.
|
||||
|
||||
@@ -6,7 +6,7 @@ Host API 1.2 (`"pluginApi": 1`). Call `host.call(method, params, { signal })`; a
|
||||
|
||||
## Methods per surface
|
||||
|
||||
- Worker (`trusted-worker`, `main`): `orca` passed to `activate(orca)`: `workspace.readContext`, `terminal.sendText`, `notifications.show`, `storage.get`, `storage.set`, `storage.delete`, `storage.keys`, `secrets.get`, `secrets.set`, `secrets.delete`, `secrets.protection`, `settings.get`, `settings.set`, `agents.list`
|
||||
- Worker (`trusted-worker`, `main`): `orca` passed to `activate(orca)`: `workspace.readContext`, `terminal.sendText`, `notifications.show`, `storage.get`, `storage.set`, `storage.delete`, `storage.keys`, `secrets.get`, `secrets.set`, `secrets.delete`, `secrets.protection`, `settings.get`, `settings.set`, `agents.list`, `statusBar.update`
|
||||
- Panel: global `orca` in an HTML panel; `host` imported from `orca` in a components panel: `workspace.readContext`, `notifications.show`, `storage.get`, `storage.set`, `storage.delete`, `storage.keys`, `secrets.get`, `secrets.set`, `secrets.delete`, `secrets.protection`, `settings.get`, `settings.set`, `shell.openExternal`, `files.saveAs`, `plugin.request`, `agents.list`, `events.subscribe`
|
||||
- Custom editor: global `orca` in the editor frame: `workspace.readContext`, `notifications.show`, `storage.get`, `storage.set`, `storage.delete`, `storage.keys`, `secrets.get`, `secrets.set`, `secrets.delete`, `secrets.protection`, `settings.get`, `settings.set`, `shell.openExternal`, `files.saveAs`, `plugin.request`, `agents.list`, `events.subscribe`
|
||||
|
||||
@@ -79,6 +79,8 @@ Host API 1.2 (`"pluginApi": 1`). Call `host.call(method, params, { signal })`; a
|
||||
`(params?: {}) => { agents: { terminalId: string | null; title: string | null; agent: string | null; state: "working" | "blocked" | "waiting" | "done"; stateStartedAt: number }[] }`
|
||||
- `events.subscribe` (no capability; panel, editor; since 1.2): Delivers a workspace or agent event to this panel as `orca.on(event)`; needs the event's read capability. The frame APIs call it for you.
|
||||
`(params: { event: "workspace.created" | "workspace.removed" | "agent.status.changed" | "workspace.changed" }) => { ok: true }`
|
||||
- `statusBar.update` (no capability; worker; since 1.2): Changes one of the plugin's `contributes.statusBarItems` (named by `id`): fields given replace their current value, `visible: false` hides it. Desktop app only; lasts until Orca restarts, and is hidden while the plugin is disabled.
|
||||
`(params: { id: string; text?: string; icon?: "activity" | "bar-chart-3" | "bell" | "blocks" | "book" | "bot" | "bug" | "calendar" | "cloud" | "code" | "database" | "file-text" | "flag" | "folder" | "gauge" | "globe" | "hammer" | "layers" | "lightbulb" | "package" | "plug" | "puzzle" | "rocket" | "star" | "terminal" | "wrench" | "zap" | "add" | "remove" | "delete" | "edit" | "refresh" | "search" | "settings" | "check" | "close" | "info" | "warning" | "error" | "open-external" | "copy" | "download" | "upload" | "play" | "pause" | "stop" | "more" | "expand" | "collapse" | "next" | "filter" | "branch" | "file" | "agent" | "link"; tone?: "neutral" | "accent" | "success" | "warning" | "danger"; tooltip?: string; visible?: boolean }) => { ok: true }`
|
||||
|
||||
## Capabilities
|
||||
|
||||
|
||||
@@ -46,8 +46,9 @@
|
||||
- `customEditors` (CustomEditorContribution[], since 1.4.245): Editors that open files of given types in the editor tab. At most 16.
|
||||
- `newFiles` (NewFileContribution[], since 1.4.245): Untitled files the user can create from the + menu and the command palette. At most 16.
|
||||
- `settings` (SettingContribution[], since 1.4.246): Settings Orca shows under the plugin in Settings; code reads them with `settings.get` (`settings:own`). At most 64.
|
||||
- `statusBarItems` (StatusBarItemContribution[], since 1.4.248): Items in the status bar; pressing one runs its `command`, and a worker changes them with `statusBar.update`. At most 8.
|
||||
|
||||
Reserved (must stay empty arrays): `agentHarnesses`, `skills`, `mcpServers`, `hooks`, `taskSources`, `connections`, `statusBarItems`, `sidebarCards`.
|
||||
Reserved (must stay empty arrays): `agentHarnesses`, `skills`, `mcpServers`, `hooks`, `taskSources`, `connections`, `sidebarCards`.
|
||||
|
||||
## PanelContribution
|
||||
|
||||
@@ -157,6 +158,17 @@ A setting shown under the plugin in Settings. Requires the `settings:own` capabi
|
||||
- `multiline` (boolean, since 1.4.246): Strings: a multi-line text box.
|
||||
- `placeholder` (string, since 1.4.246): Strings and numbers: hint shown while empty.
|
||||
|
||||
## StatusBarItemContribution
|
||||
|
||||
An item in the status bar. Requires the `declarative` profile; a `trusted-worker` can change it with `statusBar.update`.
|
||||
|
||||
- `id` (string, required, since 1.4.248): Item id, unique among this plugin's status bar items; `statusBar.update` names it.
|
||||
- `text` (string, since 1.4.248): Short label shown in the status bar.
|
||||
- `icon` (one of activity, bar-chart-3, bell, blocks, book, bot, bug, calendar, cloud, code, database, file-text, flag, folder, gauge, globe, hammer, layers, lightbulb, package, plug, puzzle, rocket, star, terminal, wrench, zap, add, remove, delete, edit, refresh, search, settings, check, close, info, warning, error, open-external, copy, download, upload, play, pause, stop, more, expand, collapse, next, filter, branch, file, agent, link, since 1.4.248): Icon shown before the text, from the panel icon set.
|
||||
- `tone` ("neutral" | "accent" | "success" | "warning" | "danger", since 1.4.248): Colour of the item. Defaults to `neutral`.
|
||||
- `tooltip` (string, since 1.4.248): Shown on hover. Defaults to the plugin name.
|
||||
- `command` (string, since 1.4.248): Id of a command this plugin contributes in `contributes.commands`, run when the item is pressed.
|
||||
|
||||
## Capability
|
||||
|
||||
A host API the plugin may call once the user consents.
|
||||
|
||||
@@ -60,3 +60,12 @@ List each in `contributes.events` (`{ "on": "<event>" }`) with the capability `r
|
||||
shows, then `orca.events.on(event, handler)` in `activate(orca)`. Fire one at the dev plugin with
|
||||
`ORCA plugin dev emit . <event> --data '<json>'`, or unit-test it with `mountWorker` (see
|
||||
`references/workflow.md`).
|
||||
|
||||
## A status bar item a worker updates
|
||||
|
||||
Declare it in `contributes.statusBarItems` (`{ "id", "text"?, "icon"?, "tone"?, "tooltip"?,
|
||||
"command"? }`, `engines.orca` `>=1.4.248`); a static item needs only `declarative`, and pressing it
|
||||
runs `command`, one of the plugin's own `contributes.commands`. From a worker, change it with
|
||||
`orca.host.call('statusBar.update', { id, text, tone, tooltip, visible })`; fields you leave out
|
||||
keep their value and `visible: false` hides it. Bind a key to the same command with
|
||||
`contributes.keybindings` (`{ "command", "key": "Mod+Shift+K" }`).
|
||||
|
||||
@@ -2608,8 +2608,9 @@ const ORCA_PLUGIN_AUTHORING_MARKDOWN = [
|
||||
"| Settings the user edits in Orca's Settings | `contributes.settings` and `settings:own` | `references/manifest.md` |",
|
||||
"| Reacting to workspace or agent status changes | `trusted-worker`; `main`, `contributes.events`, `*:read` capability | example `hello-orca` |",
|
||||
"| A virtual machine environment recipe | `declarative`; `contributes.vmRecipes` | hand-written; fields in `references/manifest.md` |",
|
||||
"| A status bar item (a worker can update it) | `declarative`; `contributes.statusBarItems` | `references/recipes.md` |",
|
||||
"",
|
||||
"Examples are in `examples/plugins/` of github.com/stablyai/orca. Status bar items, sidebar cards,",
|
||||
"Examples are in `examples/plugins/` of github.com/stablyai/orca. Sidebar cards,",
|
||||
"task sources, connections, MCP servers, skills and hooks are reserved and not buildable yet: say",
|
||||
"so instead of faking them. A panel cannot type into a terminal; only a worker",
|
||||
"with `terminal:send` can.",
|
||||
@@ -2878,7 +2879,7 @@ const ORCA_PLUGIN_AUTHORING_HOST_API_REFERENCE_MARKDOWN = [
|
||||
"",
|
||||
"## Methods per surface",
|
||||
"",
|
||||
"- Worker (`trusted-worker`, `main`): `orca` passed to `activate(orca)`: `workspace.readContext`, `terminal.sendText`, `notifications.show`, `storage.get`, `storage.set`, `storage.delete`, `storage.keys`, `secrets.get`, `secrets.set`, `secrets.delete`, `secrets.protection`, `settings.get`, `settings.set`, `agents.list`",
|
||||
"- Worker (`trusted-worker`, `main`): `orca` passed to `activate(orca)`: `workspace.readContext`, `terminal.sendText`, `notifications.show`, `storage.get`, `storage.set`, `storage.delete`, `storage.keys`, `secrets.get`, `secrets.set`, `secrets.delete`, `secrets.protection`, `settings.get`, `settings.set`, `agents.list`, `statusBar.update`",
|
||||
"- Panel: global `orca` in an HTML panel; `host` imported from `orca` in a components panel: `workspace.readContext`, `notifications.show`, `storage.get`, `storage.set`, `storage.delete`, `storage.keys`, `secrets.get`, `secrets.set`, `secrets.delete`, `secrets.protection`, `settings.get`, `settings.set`, `shell.openExternal`, `files.saveAs`, `plugin.request`, `agents.list`, `events.subscribe`",
|
||||
"- Custom editor: global `orca` in the editor frame: `workspace.readContext`, `notifications.show`, `storage.get`, `storage.set`, `storage.delete`, `storage.keys`, `secrets.get`, `secrets.set`, `secrets.delete`, `secrets.protection`, `settings.get`, `settings.set`, `shell.openExternal`, `files.saveAs`, `plugin.request`, `agents.list`, `events.subscribe`",
|
||||
"",
|
||||
@@ -2951,6 +2952,8 @@ const ORCA_PLUGIN_AUTHORING_HOST_API_REFERENCE_MARKDOWN = [
|
||||
" `(params?: {}) => { agents: { terminalId: string | null; title: string | null; agent: string | null; state: \"working\" | \"blocked\" | \"waiting\" | \"done\"; stateStartedAt: number }[] }`",
|
||||
"- `events.subscribe` (no capability; panel, editor; since 1.2): Delivers a workspace or agent event to this panel as `orca.on(event)`; needs the event's read capability. The frame APIs call it for you.",
|
||||
" `(params: { event: \"workspace.created\" | \"workspace.removed\" | \"agent.status.changed\" | \"workspace.changed\" }) => { ok: true }`",
|
||||
"- `statusBar.update` (no capability; worker; since 1.2): Changes one of the plugin's `contributes.statusBarItems` (named by `id`): fields given replace their current value, `visible: false` hides it. Desktop app only; lasts until Orca restarts, and is hidden while the plugin is disabled.",
|
||||
" `(params: { id: string; text?: string; icon?: \"activity\" | \"bar-chart-3\" | \"bell\" | \"blocks\" | \"book\" | \"bot\" | \"bug\" | \"calendar\" | \"cloud\" | \"code\" | \"database\" | \"file-text\" | \"flag\" | \"folder\" | \"gauge\" | \"globe\" | \"hammer\" | \"layers\" | \"lightbulb\" | \"package\" | \"plug\" | \"puzzle\" | \"rocket\" | \"star\" | \"terminal\" | \"wrench\" | \"zap\" | \"add\" | \"remove\" | \"delete\" | \"edit\" | \"refresh\" | \"search\" | \"settings\" | \"check\" | \"close\" | \"info\" | \"warning\" | \"error\" | \"open-external\" | \"copy\" | \"download\" | \"upload\" | \"play\" | \"pause\" | \"stop\" | \"more\" | \"expand\" | \"collapse\" | \"next\" | \"filter\" | \"branch\" | \"file\" | \"agent\" | \"link\"; tone?: \"neutral\" | \"accent\" | \"success\" | \"warning\" | \"danger\"; tooltip?: string; visible?: boolean }) => { ok: true }`",
|
||||
"",
|
||||
"## Capabilities",
|
||||
"",
|
||||
@@ -3032,8 +3035,9 @@ const ORCA_PLUGIN_AUTHORING_MANIFEST_REFERENCE_MARKDOWN = [
|
||||
"- `customEditors` (CustomEditorContribution[], since 1.4.245): Editors that open files of given types in the editor tab. At most 16.",
|
||||
"- `newFiles` (NewFileContribution[], since 1.4.245): Untitled files the user can create from the + menu and the command palette. At most 16.",
|
||||
"- `settings` (SettingContribution[], since 1.4.246): Settings Orca shows under the plugin in Settings; code reads them with `settings.get` (`settings:own`). At most 64.",
|
||||
"- `statusBarItems` (StatusBarItemContribution[], since 1.4.248): Items in the status bar; pressing one runs its `command`, and a worker changes them with `statusBar.update`. At most 8.",
|
||||
"",
|
||||
"Reserved (must stay empty arrays): `agentHarnesses`, `skills`, `mcpServers`, `hooks`, `taskSources`, `connections`, `statusBarItems`, `sidebarCards`.",
|
||||
"Reserved (must stay empty arrays): `agentHarnesses`, `skills`, `mcpServers`, `hooks`, `taskSources`, `connections`, `sidebarCards`.",
|
||||
"",
|
||||
"## PanelContribution",
|
||||
"",
|
||||
@@ -3143,6 +3147,17 @@ const ORCA_PLUGIN_AUTHORING_MANIFEST_REFERENCE_MARKDOWN = [
|
||||
"- `multiline` (boolean, since 1.4.246): Strings: a multi-line text box.",
|
||||
"- `placeholder` (string, since 1.4.246): Strings and numbers: hint shown while empty.",
|
||||
"",
|
||||
"## StatusBarItemContribution",
|
||||
"",
|
||||
"An item in the status bar. Requires the `declarative` profile; a `trusted-worker` can change it with `statusBar.update`.",
|
||||
"",
|
||||
"- `id` (string, required, since 1.4.248): Item id, unique among this plugin's status bar items; `statusBar.update` names it.",
|
||||
"- `text` (string, since 1.4.248): Short label shown in the status bar.",
|
||||
"- `icon` (one of activity, bar-chart-3, bell, blocks, book, bot, bug, calendar, cloud, code, database, file-text, flag, folder, gauge, globe, hammer, layers, lightbulb, package, plug, puzzle, rocket, star, terminal, wrench, zap, add, remove, delete, edit, refresh, search, settings, check, close, info, warning, error, open-external, copy, download, upload, play, pause, stop, more, expand, collapse, next, filter, branch, file, agent, link, since 1.4.248): Icon shown before the text, from the panel icon set.",
|
||||
"- `tone` (\"neutral\" | \"accent\" | \"success\" | \"warning\" | \"danger\", since 1.4.248): Colour of the item. Defaults to `neutral`.",
|
||||
"- `tooltip` (string, since 1.4.248): Shown on hover. Defaults to the plugin name.",
|
||||
"- `command` (string, since 1.4.248): Id of a command this plugin contributes in `contributes.commands`, run when the item is pressed.",
|
||||
"",
|
||||
"## Capability",
|
||||
"",
|
||||
"A host API the plugin may call once the user consents.",
|
||||
@@ -3225,6 +3240,15 @@ const ORCA_PLUGIN_AUTHORING_RECIPES_REFERENCE_MARKDOWN = [
|
||||
"`ORCA plugin dev emit . <event> --data '<json>'`, or unit-test it with `mountWorker` (see",
|
||||
"`references/workflow.md`).",
|
||||
"",
|
||||
"## A status bar item a worker updates",
|
||||
"",
|
||||
"Declare it in `contributes.statusBarItems` (`{ \"id\", \"text\"?, \"icon\"?, \"tone\"?, \"tooltip\"?,",
|
||||
"\"command\"? }`, `engines.orca` `>=1.4.248`); a static item needs only `declarative`, and pressing it",
|
||||
"runs `command`, one of the plugin's own `contributes.commands`. From a worker, change it with",
|
||||
"`orca.host.call('statusBar.update', { id, text, tone, tooltip, visible })`; fields you leave out",
|
||||
"keep their value and `visible: false` hides it. Bind a key to the same command with",
|
||||
"`contributes.keybindings` (`{ \"command\", \"key\": \"Mod+Shift+K\" }`).",
|
||||
"",
|
||||
].join('\n')
|
||||
|
||||
// oxfmt-ignore
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const send = vi.fn()
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: { handle: vi.fn() },
|
||||
BrowserWindow: { getAllWindows: () => [{ isDestroyed: () => false, webContents: { send } }] }
|
||||
}))
|
||||
|
||||
import { registerPluginStatusBarHandlers } from './plugin-status-bar-ipc'
|
||||
import type { PluginService } from '../plugins/plugin-service'
|
||||
import {
|
||||
PluginStatusBarState,
|
||||
pluginStatusBarRevision
|
||||
} from '../plugins/status-bar/plugin-status-bar-state'
|
||||
|
||||
type Install = { rootDir: string; manifest: { version: string }; devRevision?: string }
|
||||
|
||||
function setup() {
|
||||
const statusBar = new PluginStatusBarState()
|
||||
const installs = new Map<string, Install>([
|
||||
['acme.guard', { rootDir: '/p/guard', manifest: { version: '1.0.0' } }],
|
||||
['acme.other', { rootDir: '/p/other', manifest: { version: '1.0.0' } }]
|
||||
])
|
||||
let changed: () => void = () => {}
|
||||
const service = {
|
||||
options: { statusBar },
|
||||
findApprovedPlugin: (key: string) => installs.get(key) ?? null,
|
||||
onChanged: (listener: () => void) => {
|
||||
changed = listener
|
||||
return () => {}
|
||||
}
|
||||
}
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handlers read only options, findApprovedPlugin and onChanged.
|
||||
registerPluginStatusBarHandlers(service as unknown as PluginService)
|
||||
const set = (key: string, text: string) => {
|
||||
const install = installs.get(key)
|
||||
statusBar.update(key, install ? pluginStatusBarRevision(install) : '', 'item', { text })
|
||||
}
|
||||
return { statusBar, installs, set, changed: () => changed() }
|
||||
}
|
||||
|
||||
describe('plugin status bar changes', () => {
|
||||
it('pushes every change to the windows', () => {
|
||||
const { set } = setup()
|
||||
set('acme.guard', '1 blocked')
|
||||
expect(send).toHaveBeenLastCalledWith('plugins:statusBarChanged', {
|
||||
'acme.guard': { item: { text: '1 blocked' } }
|
||||
})
|
||||
})
|
||||
|
||||
it('starts a disabled, removed or updated plugin over from its manifest values', () => {
|
||||
const { statusBar, installs, set, changed } = setup()
|
||||
set('acme.guard', '1 blocked')
|
||||
set('acme.other', 'kept')
|
||||
changed()
|
||||
expect(Object.keys(statusBar.snapshot())).toEqual(['acme.guard', 'acme.other'])
|
||||
|
||||
installs.set('acme.guard', { rootDir: '/p/guard', manifest: { version: '1.1.0' } })
|
||||
changed()
|
||||
expect(statusBar.snapshot()).toEqual({ 'acme.other': { item: { text: 'kept' } } })
|
||||
|
||||
installs.delete('acme.other')
|
||||
changed()
|
||||
expect(statusBar.snapshot()).toEqual({})
|
||||
})
|
||||
|
||||
it('starts over when a dev code edit restarts the worker', () => {
|
||||
const { statusBar, installs, set, changed } = setup()
|
||||
installs.set('acme.guard', {
|
||||
rootDir: '/p/guard',
|
||||
manifest: { version: '1.0.0' },
|
||||
devRevision: 'a'
|
||||
})
|
||||
set('acme.guard', 'hidden')
|
||||
installs.set('acme.guard', {
|
||||
rootDir: '/p/guard',
|
||||
manifest: { version: '1.0.0' },
|
||||
devRevision: 'b'
|
||||
})
|
||||
changed()
|
||||
expect(statusBar.snapshot()['acme.guard']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('drops older changes when a new install writes first', () => {
|
||||
const { statusBar, installs, set } = setup()
|
||||
set('acme.guard', 'old')
|
||||
statusBar.update(
|
||||
'acme.guard',
|
||||
pluginStatusBarRevision({ rootDir: '/p/guard', manifest: { version: '1.0.0' } }),
|
||||
'b',
|
||||
{
|
||||
tone: 'danger'
|
||||
}
|
||||
)
|
||||
installs.set('acme.guard', { rootDir: '/p/guard2', manifest: { version: '2.0.0' } })
|
||||
set('acme.guard', 'new')
|
||||
expect(statusBar.snapshot()).toEqual({ 'acme.guard': { item: { text: 'new' } } })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
import { BrowserWindow, ipcMain } from 'electron'
|
||||
import type { PluginService } from '../plugins/plugin-service'
|
||||
import { pluginStatusBarRevision } from '../plugins/status-bar/plugin-status-bar-state'
|
||||
import { PLUGIN_STATUS_BAR_CHANGED_CHANNEL } from '../../shared/plugins/contributions/plugin-status-bar-state'
|
||||
|
||||
/** Desktop windows read the workers' status bar changes, then hear each new snapshot. */
|
||||
export function registerPluginStatusBarHandlers(pluginService: PluginService): void {
|
||||
const { statusBar } = pluginService.options
|
||||
ipcMain.handle('plugins:statusBarState', () => statusBar?.snapshot() ?? {})
|
||||
if (!statusBar) {
|
||||
return
|
||||
}
|
||||
// Disable, removal and update all notify a change; a refresh in flight is not one of them.
|
||||
pluginService.onChanged(() =>
|
||||
statusBar.prune((pluginKey) => {
|
||||
const plugin = pluginService.findApprovedPlugin(pluginKey, true)
|
||||
return plugin ? pluginStatusBarRevision(plugin) : null
|
||||
})
|
||||
)
|
||||
statusBar.onChanged((overrides) => {
|
||||
for (const window of BrowserWindow.getAllWindows()) {
|
||||
if (!window.isDestroyed()) {
|
||||
window.webContents.send(PLUGIN_STATUS_BAR_CHANGED_CHANNEL, overrides)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -121,7 +121,8 @@ async function harness(
|
||||
panels,
|
||||
whenReady: options.whenReady ?? (async () => {}),
|
||||
findApprovedPlugin: () => null,
|
||||
getDiscovered: () => []
|
||||
getDiscovered: () => [],
|
||||
options: {}
|
||||
}
|
||||
registerPluginHandlers(
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: frame entry handlers read only getSettings for the isolation switch.
|
||||
|
||||
@@ -18,6 +18,7 @@ import type { PluginService } from '../plugins/plugin-service'
|
||||
import { registerPluginFrameEntryHandlers, rendererPanelOwner } from './plugin-frame-entry-ipc'
|
||||
import { registerPluginPanelPushHandlers } from './plugin-panel-push-ipc'
|
||||
import { registerPluginSettingsHandlers } from './plugin-settings-ipc'
|
||||
import { registerPluginStatusBarHandlers } from './plugin-status-bar-ipc'
|
||||
import { readPluginPanelImage } from '../plugins/panels/plugin-panel-image'
|
||||
import {
|
||||
bindPluginFrameGuests,
|
||||
@@ -153,6 +154,7 @@ export function registerPluginHandlers(
|
||||
bindPluginFrameGuests(pluginService)
|
||||
registerPluginFrameEntryHandlers(store, pluginService)
|
||||
registerPluginPanelPushHandlers(pluginService)
|
||||
registerPluginStatusBarHandlers(pluginService)
|
||||
registerPluginSettingsHandlers(pluginService)
|
||||
|
||||
ipcMain.handle('plugins:readNewFileTemplate', async (_event, args: unknown) => {
|
||||
|
||||
@@ -202,6 +202,7 @@ export async function buildPluginList(
|
||||
handler: command.handler,
|
||||
keybindings: command.keybindings
|
||||
})),
|
||||
statusBarItems: plugin.manifest.contributes.statusBarItems,
|
||||
hasWorker: Boolean(plugin.manifest.main),
|
||||
vmRecipes: service.contentPacks.vmRecipes.preview(plugin.pluginKey).map(({ recipe }) => ({
|
||||
id: recipe.id,
|
||||
|
||||
@@ -11,6 +11,10 @@ import { pluginHostCallRevoked, type PluginHostCallScope } from './workers/plugi
|
||||
import type { PluginPanelCaller } from '../../shared/plugins/workers/plugin-panel-bridge-protocol'
|
||||
import type { PluginPanelBridgeServices } from './workers/plugin-host-panel-method-bindings'
|
||||
import type { PluginPanelBridge } from './panels/plugin-panel-bridge'
|
||||
import {
|
||||
pluginStatusBarRevision,
|
||||
type PluginStatusBarState
|
||||
} from './status-bar/plugin-status-bar-state'
|
||||
import type {
|
||||
PluginHostCallOutcome,
|
||||
PluginSurface
|
||||
@@ -61,6 +65,7 @@ export function resolvePluginServiceHostCallPolicy(input: {
|
||||
pluginsDataDir: string
|
||||
desktop: PluginDesktopFrameServices | null | undefined
|
||||
panels: PluginPanelBridgeServices | null
|
||||
statusBar?: PluginStatusBarState | null
|
||||
audit: PluginAuditLog
|
||||
log?: (pluginKey: string) => (line: string) => void
|
||||
}): PluginHostCallPolicy {
|
||||
@@ -73,7 +78,12 @@ export function resolvePluginServiceHostCallPolicy(input: {
|
||||
pluginsDataDir: input.pluginsDataDir,
|
||||
desktop: input.desktop,
|
||||
panels: input.panels,
|
||||
declaredSettings: plugin?.manifest.contributes.settings
|
||||
declaredSettings: plugin?.manifest.contributes.settings,
|
||||
statusBar: input.statusBar,
|
||||
declaredStatusBarItemIds: plugin?.manifest.contributes.statusBarItems.map(
|
||||
(item) => item.id
|
||||
),
|
||||
statusBarRevision: plugin ? pluginStatusBarRevision(plugin) : undefined
|
||||
})
|
||||
: null,
|
||||
audit: input.audit,
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { PluginWorkerFactory } from './workers/plugin-worker-manager'
|
||||
import type { KeybindingOverrides } from '../../shared/keybindings'
|
||||
import type { PluginKillListEntry } from '../../shared/plugins/catalog/plugin-kill-list'
|
||||
import type { PluginDesktopFrameServices } from './workers/plugin-host-method-bindings'
|
||||
import type { PluginStatusBarState } from './status-bar/plugin-status-bar-state'
|
||||
|
||||
export type PluginServiceOptions = {
|
||||
userDataPath: string
|
||||
@@ -20,4 +21,6 @@ export type PluginServiceOptions = {
|
||||
idleReapMs?: number
|
||||
/** Desktop main only: the sinks behind `shell.openExternal` and `files.saveAs`. */
|
||||
getDesktopFrameServices?: () => PluginDesktopFrameServices | null
|
||||
/** Desktop main only: what `statusBar.update` writes for the windows' status bars. */
|
||||
statusBar?: PluginStatusBarState
|
||||
}
|
||||
|
||||
@@ -94,6 +94,7 @@ export class PluginService {
|
||||
pluginsDataDir: getPluginsDataDir(this.options.userDataPath),
|
||||
desktop: this.options.getDesktopFrameServices?.(),
|
||||
panels: pluginPanelBridgeServices(this.panelBridge),
|
||||
statusBar: this.options.statusBar,
|
||||
audit: this.audit,
|
||||
log: (pluginKey) => this.installed.captureLog(pluginKey, 'info')
|
||||
})
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { PluginStatusBarItemPatch } from '../../../shared/plugins/contributions/plugin-status-bar-item-contribution'
|
||||
import type { PluginStatusBarOverrides } from '../../../shared/plugins/contributions/plugin-status-bar-state'
|
||||
|
||||
/** Which install of a plugin set the changes: a new folder, manifest (an update) or dev code
|
||||
* edit (a worker restart) starts over. */
|
||||
export function pluginStatusBarRevision(plugin: {
|
||||
rootDir: string
|
||||
manifest: unknown
|
||||
devRevision?: string | null
|
||||
}): string {
|
||||
return `${plugin.rootDir}\0${JSON.stringify(plugin.manifest)}\0${plugin.devRevision ?? ''}`
|
||||
}
|
||||
|
||||
/** What workers set on their status bar items, layered over the manifest's values. Session-only. */
|
||||
export class PluginStatusBarState {
|
||||
private overrides: PluginStatusBarOverrides = {}
|
||||
private readonly revisions = new Map<string, string>()
|
||||
private readonly listeners = new Set<(overrides: PluginStatusBarOverrides) => void>()
|
||||
|
||||
update(
|
||||
pluginKey: string,
|
||||
revision: string,
|
||||
itemId: string,
|
||||
patch: PluginStatusBarItemPatch
|
||||
): void {
|
||||
const items = this.revisions.get(pluginKey) === revision ? this.overrides[pluginKey] : {}
|
||||
this.revisions.set(pluginKey, revision)
|
||||
const merged = { ...items?.[itemId], ...patch }
|
||||
// Why replace, not mutate: listeners and the renderer compare snapshots by identity.
|
||||
this.overrides = { ...this.overrides, [pluginKey]: { ...items, [itemId]: merged } }
|
||||
this.emit()
|
||||
}
|
||||
|
||||
/** Drops changes of plugins now disabled, removed or updated (`revisionOf` null or different),
|
||||
* so a re-enabled plugin starts from its manifest values. */
|
||||
prune(revisionOf: (pluginKey: string) => string | null): void {
|
||||
const stale = [...this.revisions].filter(([key, revision]) => revisionOf(key) !== revision)
|
||||
if (stale.length === 0) {
|
||||
return
|
||||
}
|
||||
const next = { ...this.overrides }
|
||||
for (const [key] of stale) {
|
||||
this.revisions.delete(key)
|
||||
delete next[key]
|
||||
}
|
||||
this.overrides = next
|
||||
this.emit()
|
||||
}
|
||||
|
||||
snapshot(): PluginStatusBarOverrides {
|
||||
return this.overrides
|
||||
}
|
||||
|
||||
onChanged(listener: (overrides: PluginStatusBarOverrides) => void): () => void {
|
||||
this.listeners.add(listener)
|
||||
return () => this.listeners.delete(listener)
|
||||
}
|
||||
|
||||
private emit(): void {
|
||||
for (const listener of this.listeners) {
|
||||
listener(this.overrides)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,10 @@ import {
|
||||
type PluginPanelBridgeServices
|
||||
} from './plugin-host-panel-method-bindings'
|
||||
import { bind, type BoundPluginHostMethod } from './plugin-host-method-bind'
|
||||
import {
|
||||
PLUGIN_STATUS_BAR_HANDLERS,
|
||||
type PluginStatusBarServices
|
||||
} from './plugin-host-status-bar-method-bindings'
|
||||
|
||||
export type { PluginHostCallScope } from './plugin-host-call-scope'
|
||||
|
||||
@@ -77,6 +81,8 @@ export type PluginHostServices = {
|
||||
desktop?: PluginDesktopFrameServices | null
|
||||
/** The plugin's open panels and its worker; absent where no panel host runs. */
|
||||
panels?: PluginPanelBridgeServices | null
|
||||
/** Absent where no Orca window draws a status bar. */
|
||||
statusBar?: PluginStatusBarServices | null
|
||||
settings: {
|
||||
getAll(pluginId: string): Record<string, PluginJsonValue>
|
||||
set(
|
||||
@@ -191,7 +197,8 @@ const HANDLERS: Record<PluginHostMethodName, BoundPluginHostMethod> = {
|
||||
const saved = await requireDesktop(services).saveFile(pluginId, params, scope)
|
||||
return { status: saved ? ('saved' as const) : ('cancelled' as const) }
|
||||
}),
|
||||
...PLUGIN_PANEL_BRIDGE_HANDLERS
|
||||
...PLUGIN_PANEL_BRIDGE_HANDLERS,
|
||||
...PLUGIN_STATUS_BAR_HANDLERS
|
||||
}
|
||||
|
||||
function requireDesktop(services: PluginHostServices): PluginDesktopFrameServices {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { PLUGIN_WORKSPACE_TERMINAL_LIMIT } from '../../../shared/plugins/workers
|
||||
import type { PluginHostServices } from './plugin-host-methods'
|
||||
import type { PluginDesktopFrameServices } from './plugin-host-method-bindings'
|
||||
import type { PluginPanelBridgeServices } from './plugin-host-panel-method-bindings'
|
||||
import type { PluginStatusBarState } from '../status-bar/plugin-status-bar-state'
|
||||
import {
|
||||
pluginSettingValueIssue,
|
||||
type PluginSettingContribution
|
||||
@@ -53,12 +54,19 @@ export function bindPluginHostServices(input: {
|
||||
panels?: PluginPanelBridgeServices | null
|
||||
/** The calling plugin's `contributes.settings`: defaults and value checks. */
|
||||
declaredSettings?: readonly PluginSettingContribution[]
|
||||
statusBar?: PluginStatusBarState | null
|
||||
/** Ids of the calling plugin's `contributes.statusBarItems`, and its install's revision. */
|
||||
declaredStatusBarItemIds?: readonly string[]
|
||||
statusBarRevision?: string
|
||||
}): PluginHostServices {
|
||||
const declared = new Map((input.declaredSettings ?? []).map((setting) => [setting.key, setting]))
|
||||
const { delegate, pluginsDataDir } = input
|
||||
return {
|
||||
desktop: input.desktop ?? null,
|
||||
panels: input.panels ?? null,
|
||||
statusBar: input.statusBar
|
||||
? statusBarServices(input.statusBar, input.declaredStatusBarItemIds, input.statusBarRevision)
|
||||
: null,
|
||||
resolveActiveWorktreeContext: async () => {
|
||||
const context = await delegate.resolveActiveWorktreeContext()
|
||||
if (!context) {
|
||||
@@ -120,3 +128,20 @@ export function bindPluginHostServices(input: {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function statusBarServices(
|
||||
state: PluginStatusBarState,
|
||||
declaredIds: readonly string[] = [],
|
||||
revision = ''
|
||||
): NonNullable<PluginHostServices['statusBar']> {
|
||||
const declared = new Set(declaredIds)
|
||||
return {
|
||||
update: (pluginKey, itemId, patch) => {
|
||||
if (!declared.has(itemId)) {
|
||||
return false
|
||||
}
|
||||
state.update(pluginKey, revision, itemId, patch)
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { PLUGIN_HOST_API } from '../../../shared/plugins/workers/plugin-host-api'
|
||||
import type { PluginStatusBarItemPatch } from '../../../shared/plugins/contributions/plugin-status-bar-item-contribution'
|
||||
import { bind, type BoundPluginHostMethod } from './plugin-host-method-bind'
|
||||
import { PluginHostCallError } from './plugin-host-call-scope'
|
||||
|
||||
/** The calling plugin's status bar items; false when it declares no item with that id. */
|
||||
export type PluginStatusBarServices = {
|
||||
update(pluginId: string, itemId: string, patch: PluginStatusBarItemPatch): boolean
|
||||
}
|
||||
|
||||
const api = PLUGIN_HOST_API
|
||||
|
||||
export const PLUGIN_STATUS_BAR_HANDLERS = {
|
||||
'statusBar.update': bind(
|
||||
api['statusBar.update'],
|
||||
async ({ id, ...patch }, { pluginId, services }) => {
|
||||
if (!services.statusBar) {
|
||||
throw new PluginHostCallError('unavailable', 'this Orca has no status bar')
|
||||
}
|
||||
if (!services.statusBar.update(pluginId, id, patch)) {
|
||||
throw new PluginHostCallError(
|
||||
'invalid_params',
|
||||
`id: no contributes.statusBarItems entry is named ${id}`
|
||||
)
|
||||
}
|
||||
return { ok: true as const }
|
||||
}
|
||||
)
|
||||
} satisfies Record<string, BoundPluginHostMethod>
|
||||
@@ -0,0 +1,66 @@
|
||||
import { mkdtempSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { executePluginHostCall } from './plugin-host-methods'
|
||||
import { bindPluginHostServices, type PluginRuntimeDelegate } from './plugin-host-service-bindings'
|
||||
import { PluginStatusBarState } from '../status-bar/plugin-status-bar-state'
|
||||
|
||||
function call(
|
||||
params: unknown,
|
||||
options: { state?: PluginStatusBarState | null; surface?: 'worker' | 'panel' } = {}
|
||||
) {
|
||||
return executePluginHostCall({
|
||||
pluginId: 'acme.guard',
|
||||
method: 'statusBar.update',
|
||||
params,
|
||||
surface: options.surface ?? 'worker',
|
||||
grantedCapabilities: [],
|
||||
services: bindPluginHostServices({
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: statusBar.update never reaches the runtime delegate.
|
||||
delegate: {} as PluginRuntimeDelegate,
|
||||
pluginsDataDir: mkdtempSync(join(tmpdir(), 'orca-status-bar-methods-')),
|
||||
statusBar: options.state === undefined ? new PluginStatusBarState() : options.state,
|
||||
declaredStatusBarItemIds: ['blocked']
|
||||
}),
|
||||
audit: { record: vi.fn().mockResolvedValue(undefined) },
|
||||
scope: { assertActive: () => undefined }
|
||||
})
|
||||
}
|
||||
|
||||
describe('statusBar.update', () => {
|
||||
it("layers a worker's changes over its declared item and tells listeners", async () => {
|
||||
const state = new PluginStatusBarState()
|
||||
const listener = vi.fn()
|
||||
state.onChanged(listener)
|
||||
await expect(
|
||||
call({ id: 'blocked', text: '1 blocked', tone: 'warning' }, { state })
|
||||
).resolves.toEqual({ ok: true, value: { ok: true } })
|
||||
await call({ id: 'blocked', tooltip: 'Blocked commands' }, { state })
|
||||
expect(state.snapshot()).toEqual({
|
||||
'acme.guard': {
|
||||
blocked: { text: '1 blocked', tone: 'warning', tooltip: 'Blocked commands' }
|
||||
}
|
||||
})
|
||||
expect(listener).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('refuses an undeclared item, a panel caller and a host without a status bar', async () => {
|
||||
await expect(call({ id: 'other', text: 'x' })).resolves.toMatchObject({
|
||||
ok: false,
|
||||
code: 'invalid_params'
|
||||
})
|
||||
await expect(call({ id: 'blocked', text: 'x' }, { surface: 'panel' })).resolves.toMatchObject({
|
||||
ok: false,
|
||||
code: 'not_permitted'
|
||||
})
|
||||
await expect(call({ id: 'blocked', text: 'x' }, { state: null })).resolves.toMatchObject({
|
||||
ok: false,
|
||||
code: 'unavailable'
|
||||
})
|
||||
await expect(call({ id: 'blocked', command: 'x' })).resolves.toMatchObject({
|
||||
ok: false,
|
||||
code: 'invalid_params'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
import { emitPluginWorktreeLifecycle } from './main-process-pty-startup'
|
||||
import { mainProcessState as state } from './main-process-state'
|
||||
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
|
||||
import { PluginStatusBarState } from '../plugins/status-bar/plugin-status-bar-state'
|
||||
|
||||
const PLUGIN_AUTO_UPDATE_STARTUP_DELAY_MS = 60_000
|
||||
// Why: the official catalog fetch must not compete with first paint; opening the marketplace seeds sooner.
|
||||
@@ -117,6 +118,7 @@ export async function initializeMainProcessPlugins(runtime: OrcaRuntimeService):
|
||||
getPluginKillListEntry: (pluginKey) => state.pluginKillListService?.find(pluginKey) ?? null,
|
||||
hostEntryPath: resolvePluginHostEntryPath(app.getAppPath(), app.isPackaged),
|
||||
getDesktopFrameServices: getPluginDesktopFrameServices,
|
||||
statusBar: new PluginStatusBarState(),
|
||||
bundledPluginRoot
|
||||
})
|
||||
const pluginService = state.pluginService
|
||||
|
||||
@@ -46,6 +46,7 @@ export type {
|
||||
PluginHostStatus
|
||||
} from '../../shared/plugins/plugin-list-entry'
|
||||
import type { PluginHostListEntry } from '../../shared/plugins/plugin-list-entry'
|
||||
import type { PluginStatusBarOverrides } from '../../shared/plugins/contributions/plugin-status-bar-state'
|
||||
import type { PluginDeclaredSettingsSnapshot } from '../../shared/plugins/contributions/plugin-settings-values'
|
||||
import type { PluginJsonValue } from '../../shared/plugins/workers/plugin-host-api'
|
||||
|
||||
@@ -260,6 +261,9 @@ export type PluginsApi = {
|
||||
onInstallCountsChanged: (callback: () => void) => () => void
|
||||
/** Fires after each automatic update pass, with the plugins it just updated. */
|
||||
onAutoUpdateChanged: (callback: (event: PluginAutoUpdateEvent) => void) => () => void
|
||||
/** What workers set with `statusBar.update`. Desktop only; older hosts reject. */
|
||||
statusBarState?: () => Promise<PluginStatusBarOverrides>
|
||||
onStatusBarChanged?: (callback: (overrides: PluginStatusBarOverrides) => void) => () => void
|
||||
}
|
||||
|
||||
export type PluginAutoUpdateEvent = {
|
||||
|
||||
@@ -7,6 +7,10 @@ import type {
|
||||
import type { PluginHostCallOutcome } from '../../shared/plugins/frames/plugin-protocol'
|
||||
import type { PluginConsentRequest } from '../../shared/plugins/approval/plugin-consent-request'
|
||||
import type { PluginChangeEvent } from '../../shared/plugins/events/plugin-change-event'
|
||||
import {
|
||||
PLUGIN_STATUS_BAR_CHANGED_CHANNEL,
|
||||
type PluginStatusBarOverrides
|
||||
} from '../../shared/plugins/contributions/plugin-status-bar-state'
|
||||
import {
|
||||
PLUGIN_VIEW_REPLY_CHANNEL,
|
||||
PLUGIN_VIEW_REQUEST_CHANNEL,
|
||||
@@ -108,6 +112,15 @@ export const pluginsApi = {
|
||||
ipcRenderer.removeListener('plugins:installCountsChanged', listener)
|
||||
}
|
||||
},
|
||||
statusBarState: () => ipcRenderer.invoke('plugins:statusBarState'),
|
||||
onStatusBarChanged: (callback): (() => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, overrides: PluginStatusBarOverrides) =>
|
||||
callback(overrides)
|
||||
ipcRenderer.on(PLUGIN_STATUS_BAR_CHANGED_CHANNEL, listener)
|
||||
return () => {
|
||||
ipcRenderer.removeListener(PLUGIN_STATUS_BAR_CHANGED_CHANNEL, listener)
|
||||
}
|
||||
},
|
||||
onAutoUpdateChanged: (callback): (() => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, change: PluginAutoUpdateEvent): void =>
|
||||
callback(change)
|
||||
|
||||
@@ -108,7 +108,8 @@ const preview: PluginMarketplaceHostInstallPreview = {
|
||||
syntaxes: [],
|
||||
customEditors: [],
|
||||
newFiles: [],
|
||||
settings: []
|
||||
settings: [],
|
||||
statusBarItems: []
|
||||
},
|
||||
capabilities: [{ kind: 'workspace:read' }]
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Highlighter,
|
||||
Languages,
|
||||
Palette,
|
||||
PanelBottom,
|
||||
PanelRight,
|
||||
PenTool,
|
||||
Server,
|
||||
@@ -103,7 +104,8 @@ const CONTRIBUTION_ICON: Record<PluginContributionKind, React.ReactNode> = {
|
||||
panels: <PanelRight />,
|
||||
customEditors: <PenTool />,
|
||||
vmRecipes: <Server />,
|
||||
newFiles: <FilePlus />
|
||||
newFiles: <FilePlus />,
|
||||
statusBarItems: <PanelBottom />
|
||||
}
|
||||
|
||||
type AddsCommand = {
|
||||
@@ -249,6 +251,7 @@ function useContributionRowActions(
|
||||
case 'terminalThemes':
|
||||
case 'customEditors':
|
||||
case 'vmRecipes':
|
||||
case 'statusBarItems':
|
||||
// Themes have "Use now" above the list; editors open from their new files; recipes have nothing to try here.
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { PluginContribution } from './plugin-contributions'
|
||||
|
||||
export function withMode(
|
||||
kindLabel: string,
|
||||
mode: 'light' | 'dark' | 'unknown' | undefined
|
||||
): string {
|
||||
if (mode === 'dark') {
|
||||
return `${kindLabel} · ${translate('auto.components.settings.pluginContributions.dark', 'Dark')}`
|
||||
}
|
||||
if (mode === 'light') {
|
||||
return `${kindLabel} · ${translate('auto.components.settings.pluginContributions.light', 'Light')}`
|
||||
}
|
||||
return kindLabel
|
||||
}
|
||||
|
||||
/** Each item's text, or its id when it shows only an icon. */
|
||||
export function statusBarItemTitles(
|
||||
items: readonly { id: string; text?: string }[] | undefined
|
||||
): string[] {
|
||||
return (items ?? []).map((item) => item.text || item.id)
|
||||
}
|
||||
|
||||
export function statusBarContributionRows(titles: readonly string[]): PluginContribution[] {
|
||||
const detail = translate('auto.components.settings.pluginContributions.statusBar', 'Status bar')
|
||||
return titles.map((title, index) => ({
|
||||
key: `statusBarItems:${index}`,
|
||||
kind: 'statusBarItems',
|
||||
title,
|
||||
detail,
|
||||
tone: 'default'
|
||||
}))
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { EMPTY_CONTRIBUTION_SOURCE, describePluginContributions } from './plugin-contributions'
|
||||
import {
|
||||
EMPTY_CONTRIBUTION_SOURCE,
|
||||
describePluginContributions,
|
||||
summaryContributionSource
|
||||
} from './plugin-contributions'
|
||||
import { statusBarItemTitles } from './plugin-contribution-row-labels'
|
||||
|
||||
vi.mock('@/i18n/i18n', () => ({
|
||||
getIntlLocale: () => 'en',
|
||||
@@ -37,3 +42,27 @@ describe('custom editor rows', () => {
|
||||
expect(editorRow({ extensions: ['.excalidraw'], priority: 'default' }).tone).toBe('default')
|
||||
})
|
||||
})
|
||||
|
||||
describe('status bar item rows', () => {
|
||||
it('lists each item by its text, or its id when it shows only an icon', () => {
|
||||
const source = {
|
||||
...EMPTY_CONTRIBUTION_SOURCE,
|
||||
statusBarItems: statusBarItemTitles([{ id: 'blocked', text: '0 blocked' }, { id: 'quiet' }])
|
||||
}
|
||||
expect(describePluginContributions(source)).toEqual([
|
||||
expect.objectContaining({
|
||||
key: 'statusBarItems:0',
|
||||
title: '0 blocked',
|
||||
detail: 'Status bar'
|
||||
}),
|
||||
expect.objectContaining({ key: 'statusBarItems:1', title: 'quiet', kind: 'statusBarItems' })
|
||||
])
|
||||
// Older hosts list no status bar items.
|
||||
expect(statusBarItemTitles(undefined)).toEqual([])
|
||||
})
|
||||
|
||||
it('lists the items a catalog summary names', () => {
|
||||
const source = summaryContributionSource([{ kind: 'statusBarItems', name: '0 blocked' }])
|
||||
expect(source.statusBarItems).toEqual(['0 blocked'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,6 +9,11 @@ import type { PluginManifest } from '../../../../../shared/plugins/manifest/plug
|
||||
import { getIntlLocale, translate } from '@/i18n/i18n'
|
||||
import { customEditorTakeover } from './plugin-custom-editor-takeover'
|
||||
import { fileList } from './plugin-contribution-file-list'
|
||||
import {
|
||||
statusBarContributionRows,
|
||||
statusBarItemTitles,
|
||||
withMode
|
||||
} from './plugin-contribution-row-labels'
|
||||
|
||||
/** The manifest points shown as named rows; commands, shortcuts, events and settings have their
|
||||
* own lists. */
|
||||
@@ -50,6 +55,7 @@ export type PluginContributionSource = {
|
||||
}[]
|
||||
vmRecipes: readonly string[]
|
||||
newFiles: readonly { title: string; extension: string }[]
|
||||
statusBarItems: readonly string[]
|
||||
}
|
||||
|
||||
export const EMPTY_CONTRIBUTION_SOURCE: PluginContributionSource = {
|
||||
@@ -61,7 +67,8 @@ export const EMPTY_CONTRIBUTION_SOURCE: PluginContributionSource = {
|
||||
panels: [],
|
||||
customEditors: [],
|
||||
vmRecipes: [],
|
||||
newFiles: []
|
||||
newFiles: [],
|
||||
statusBarItems: []
|
||||
}
|
||||
|
||||
/** "recipes/ubuntu-dev.json" → "Ubuntu dev"; manifests name these files by path only. */
|
||||
@@ -102,7 +109,8 @@ export function manifestContributionSource(manifest: PluginManifest): PluginCont
|
||||
priority: editor.priority ?? 'default'
|
||||
})),
|
||||
vmRecipes: contributes.vmRecipes.map((recipe) => nameFromContributionPath(recipe.path)),
|
||||
newFiles: contributes.newFiles
|
||||
newFiles: contributes.newFiles,
|
||||
statusBarItems: statusBarItemTitles(contributes.statusBarItems)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,20 +148,11 @@ export function summaryContributionSource(
|
||||
filenames: []
|
||||
})),
|
||||
vmRecipes: of('vmRecipes').map((item) => nameFromContributionPath(item.name)),
|
||||
newFiles: of('newFiles').map((item) => ({ title: item.name, extension: item.detail ?? '' }))
|
||||
newFiles: of('newFiles').map((item) => ({ title: item.name, extension: item.detail ?? '' })),
|
||||
statusBarItems: of('statusBarItems').map((item) => item.name)
|
||||
}
|
||||
}
|
||||
|
||||
function withMode(kindLabel: string, mode: 'light' | 'dark' | 'unknown' | undefined): string {
|
||||
if (mode === 'dark') {
|
||||
return `${kindLabel} · ${translate('auto.components.settings.pluginContributions.dark', 'Dark')}`
|
||||
}
|
||||
if (mode === 'light') {
|
||||
return `${kindLabel} · ${translate('auto.components.settings.pluginContributions.light', 'Light')}`
|
||||
}
|
||||
return kindLabel
|
||||
}
|
||||
|
||||
function languageDetail(coverage: number | undefined): Pick<PluginContribution, 'detail' | 'tone'> {
|
||||
const label = translate(
|
||||
'auto.components.settings.PluginDetailSections.typeLanguagePack',
|
||||
@@ -290,6 +289,7 @@ export function describePluginContributions(
|
||||
tone: 'default'
|
||||
})
|
||||
}
|
||||
rows.push(...statusBarContributionRows(source.statusBarItems))
|
||||
for (const [index, title] of source.vmRecipes.entries()) {
|
||||
rows.push({
|
||||
key: `vmRecipes:${index}`,
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
manifestContributionSource,
|
||||
type PluginContributionSource
|
||||
} from '../presentation/plugin-contributions'
|
||||
import { statusBarItemTitles } from '../presentation/plugin-contribution-row-labels'
|
||||
|
||||
/** What an installed plugin adds right now, from Orca's live registrations; empty for others. */
|
||||
function useInstalledPluginContributionSource(
|
||||
@@ -49,7 +50,8 @@ function useInstalledPluginContributionSource(
|
||||
panels: plugin.panels.map((panel) => panel.title),
|
||||
customEditors: plugin.customEditors ?? [],
|
||||
vmRecipes: plugin.vmRecipes?.map((recipe) => recipe.name) ?? [],
|
||||
newFiles: plugin.newFiles ?? []
|
||||
newFiles: plugin.newFiles ?? [],
|
||||
statusBarItems: statusBarItemTitles(plugin.statusBarItems)
|
||||
}
|
||||
}, [plugin, appThemes, terminalThemes, iconThemes, syntaxes, languagePacks, failures])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ActivePluginStatusBarItem } from '@/store/plugin-status-bar-items'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
|
||||
const items: ActivePluginStatusBarItem[] = []
|
||||
const executePluginCommand = vi.fn()
|
||||
|
||||
vi.mock('@/store/plugin-status-bar-items', () => ({ usePluginStatusBarItems: () => items }))
|
||||
vi.mock('@/lib/plugin-command-execution', () => ({
|
||||
executePluginCommand: (...args: unknown[]) => executePluginCommand(...args)
|
||||
}))
|
||||
const appState: { activeWorktreeId: string | null } = { activeWorktreeId: 'wt-1' }
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: (select: (state: typeof appState) => unknown) => select(appState)
|
||||
}))
|
||||
vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
|
||||
|
||||
import { PluginStatusBarItems } from './PluginStatusBarItems'
|
||||
|
||||
const command = {
|
||||
id: 'guard.log',
|
||||
title: 'Show log',
|
||||
context: 'global' as const,
|
||||
handler: { type: 'worker' as const },
|
||||
keybindings: [],
|
||||
pluginKey: 'acme.guard',
|
||||
pluginName: 'Guard'
|
||||
}
|
||||
|
||||
let container: HTMLDivElement
|
||||
let root: Root
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
executePluginCommand.mockReset().mockResolvedValue(undefined)
|
||||
items.length = 0
|
||||
appState.activeWorktreeId = 'wt-1'
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount())
|
||||
container.remove()
|
||||
})
|
||||
|
||||
async function render(iconOnly = false): Promise<void> {
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<TooltipProvider>
|
||||
<PluginStatusBarItems iconOnly={iconOnly} />
|
||||
</TooltipProvider>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
describe('PluginStatusBarItems', () => {
|
||||
it('runs the item command when pressed and shows its tone', async () => {
|
||||
items.push({
|
||||
key: 'acme.guard/blocked',
|
||||
pluginName: 'Guard',
|
||||
text: '2 blocked',
|
||||
icon: 'flag',
|
||||
tone: 'warning',
|
||||
tooltip: 'Blocked commands',
|
||||
command
|
||||
})
|
||||
await render()
|
||||
const button = container.querySelector('button')
|
||||
expect(button?.textContent).toBe('2 blocked')
|
||||
expect(button?.dataset.tone).toBe('warning')
|
||||
expect(button?.getAttribute('aria-label')).toBe('2 blocked, Blocked commands')
|
||||
await act(async () => button?.click())
|
||||
expect(executePluginCommand).toHaveBeenCalledWith(command, 'plugin-palette')
|
||||
})
|
||||
|
||||
it('does not run a workspace command with no open workspace', async () => {
|
||||
appState.activeWorktreeId = null
|
||||
items.push({
|
||||
key: 'acme.guard/blocked',
|
||||
pluginName: 'Guard',
|
||||
text: '2 blocked',
|
||||
tone: 'neutral',
|
||||
tooltip: 'Blocked commands',
|
||||
command: { ...command, context: 'workspace' }
|
||||
})
|
||||
await render()
|
||||
const button = container.querySelector('button')
|
||||
expect(button?.getAttribute('aria-disabled')).toBe('true')
|
||||
expect(button?.getAttribute('aria-label')).toBe(
|
||||
'2 blocked, Blocked commands · Needs an open workspace'
|
||||
)
|
||||
await act(async () => button?.click())
|
||||
expect(executePluginCommand).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('draws an item without a command as static text, icon only when the bar is narrow', async () => {
|
||||
items.push({
|
||||
key: 'acme.guard/quiet',
|
||||
pluginName: 'Guard',
|
||||
text: 'Quiet',
|
||||
icon: 'bell',
|
||||
tone: 'neutral',
|
||||
tooltip: 'Guard',
|
||||
command: null
|
||||
})
|
||||
await render(true)
|
||||
expect(container.querySelector('button')).toBeNull()
|
||||
expect(container.querySelector('svg')).not.toBeNull()
|
||||
expect(container.textContent).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,87 @@
|
||||
import { toast } from 'sonner'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { PluginDeclarativeIconGlyph } from '@/components/plugins/declarative/plugin-declarative-icons'
|
||||
import { executePluginCommand } from '@/lib/plugin-command-execution'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { useAppStore } from '@/store'
|
||||
import { pluginCommandUnavailableReason } from '@/components/plugins/page/plugin-contribution-action-availability'
|
||||
import {
|
||||
usePluginStatusBarItems,
|
||||
type ActivePluginStatusBarItem
|
||||
} from '@/store/plugin-status-bar-items'
|
||||
|
||||
const ITEM_CLASS =
|
||||
'inline-flex items-center gap-1 rounded px-1 py-0.5 text-muted-foreground data-[pressable=true]:cursor-pointer data-[pressable=true]:hover:bg-accent/70 data-[pressable=true]:hover:text-foreground data-[tone=accent]:text-primary data-[tone=danger]:text-destructive data-[tone=success]:text-status-success data-[tone=warning]:text-status-warning-text'
|
||||
|
||||
function runItemCommand(item: ActivePluginStatusBarItem): void {
|
||||
if (!item.command) {
|
||||
return
|
||||
}
|
||||
void executePluginCommand(item.command, 'plugin-palette').catch(() => {
|
||||
toast.error(translate('auto.App.pluginCommandFailed', 'Could not run the plugin command.'))
|
||||
})
|
||||
}
|
||||
|
||||
function PluginStatusBarItem({
|
||||
item,
|
||||
iconOnly
|
||||
}: {
|
||||
item: ActivePluginStatusBarItem
|
||||
iconOnly: boolean
|
||||
}): React.JSX.Element {
|
||||
const activeWorktreeId = useAppStore((state) => state.activeWorktreeId)
|
||||
// Why: palette and shortcuts skip workspace commands with no workspace; pressing must too.
|
||||
const unavailable = item.command
|
||||
? pluginCommandUnavailableReason(item.command, activeWorktreeId)
|
||||
: null
|
||||
const tooltip = unavailable ? `${item.tooltip} · ${unavailable}` : item.tooltip
|
||||
const showText = item.text !== undefined && (!iconOnly || !item.icon)
|
||||
const content = (
|
||||
<>
|
||||
{item.icon ? <PluginDeclarativeIconGlyph name={item.icon} className="size-3" /> : null}
|
||||
{showText ? <span className="text-[11px]">{item.text}</span> : null}
|
||||
</>
|
||||
)
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
{item.command ? (
|
||||
<button
|
||||
type="button"
|
||||
className={ITEM_CLASS}
|
||||
data-tone={item.tone}
|
||||
data-pressable={unavailable ? 'false' : 'true'}
|
||||
aria-disabled={unavailable ? true : undefined}
|
||||
aria-label={item.text ? `${item.text}, ${tooltip}` : tooltip}
|
||||
onClick={() => {
|
||||
if (!unavailable) {
|
||||
runItemCommand(item)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
) : (
|
||||
<span className={ITEM_CLASS} data-tone={item.tone} aria-label={item.tooltip}>
|
||||
{content}
|
||||
</span>
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}>
|
||||
{tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
/** Items enabled plugins declare in `contributes.statusBarItems`. */
|
||||
export function PluginStatusBarItems({ iconOnly }: { iconOnly: boolean }): React.JSX.Element {
|
||||
const items = usePluginStatusBarItems()
|
||||
return (
|
||||
<>
|
||||
{items.map((item) => (
|
||||
<PluginStatusBarItem key={item.key} item={item} iconOnly={iconOnly} />
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import { SkillUpdateStatusSegment } from './SkillUpdateStatusSegment'
|
||||
import { NativeChatResumeStatusSegment } from './NativeChatResumeStatusSegment'
|
||||
import { CaffeinateStatusSegment } from './CaffeinateStatusSegment'
|
||||
import { PluginsStatusSegment } from './PluginsStatusSegment'
|
||||
import { PluginStatusBarItems } from './PluginStatusBarItems'
|
||||
import { RemoteServerUpdateStatusSegment } from './RemoteServerUpdateStatusSegment'
|
||||
import { TOGGLE_FLOATING_TERMINAL_EVENT } from '@/lib/floating-terminal'
|
||||
import { FloatingTerminalIconContextMenu } from '@/components/floating-terminal/FloatingTerminalIconContextMenu'
|
||||
@@ -294,6 +295,7 @@ export function StatusBarSurface({
|
||||
{!isPairedWebClientWindow() ? (
|
||||
<>
|
||||
<PluginsStatusSegment />
|
||||
<PluginStatusBarItems iconOnly={segmentsIconOnly} />
|
||||
<CaffeinateStatusSegment iconOnly={segmentsIconOnly} />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
@@ -13332,7 +13332,8 @@
|
||||
"languageCoverage": "Interface language · {{value0}}% translated",
|
||||
"newFileMenu": "Adds to the + menu",
|
||||
"editorReplacesViewer": "Opens {{value0}} instead of Orca’s viewer",
|
||||
"editorMayReplaceViewer": "May open {{value0}} instead of Orca’s viewer"
|
||||
"editorMayReplaceViewer": "May open {{value0}} instead of Orca’s viewer",
|
||||
"statusBar": "Status bar"
|
||||
},
|
||||
"PluginCardAction": {
|
||||
"installed": "Installed",
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { PluginHostListEntry } from '../../../preload/api-types'
|
||||
import { collectPluginStatusBarItems } from './plugin-status-bar-items'
|
||||
|
||||
function plugin(overrides: Partial<PluginHostListEntry> = {}): PluginHostListEntry {
|
||||
return {
|
||||
pluginKey: 'acme.guard',
|
||||
consentFingerprint: null,
|
||||
name: 'Guard',
|
||||
version: '1.0.0',
|
||||
publisher: 'acme',
|
||||
status: 'running',
|
||||
needsReconsent: false,
|
||||
isDev: false,
|
||||
official: false,
|
||||
bundled: false,
|
||||
capabilities: [],
|
||||
panels: [],
|
||||
commands: [
|
||||
{
|
||||
id: 'guard.log',
|
||||
title: 'Show log',
|
||||
context: 'global',
|
||||
handler: { type: 'worker' },
|
||||
keybindings: []
|
||||
}
|
||||
],
|
||||
statusBarItems: [
|
||||
{ id: 'blocked', text: '0 blocked', icon: 'flag', command: 'guard.log' },
|
||||
{ id: 'quiet', icon: 'bell' }
|
||||
],
|
||||
hasWorker: true,
|
||||
restarts: 0,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('collectPluginStatusBarItems', () => {
|
||||
it("applies a worker's changes over the manifest and resolves the command", () => {
|
||||
const items = collectPluginStatusBarItems([plugin()], {
|
||||
'acme.guard': { blocked: { text: '2 blocked', tone: 'danger' } }
|
||||
})
|
||||
expect(items).toEqual([
|
||||
expect.objectContaining({
|
||||
key: 'acme.guard/blocked',
|
||||
text: '2 blocked',
|
||||
icon: 'flag',
|
||||
tone: 'danger',
|
||||
tooltip: 'Guard',
|
||||
command: expect.objectContaining({ id: 'guard.log', pluginKey: 'acme.guard' })
|
||||
}),
|
||||
expect.objectContaining({ key: 'acme.guard/quiet', tone: 'neutral', command: null })
|
||||
])
|
||||
})
|
||||
|
||||
it('hides items a worker hid, empty items and disabled plugins', () => {
|
||||
expect(
|
||||
collectPluginStatusBarItems([plugin()], {
|
||||
'acme.guard': { blocked: { visible: false } }
|
||||
}).map((item) => item.key)
|
||||
).toEqual(['acme.guard/quiet'])
|
||||
expect(
|
||||
collectPluginStatusBarItems([plugin({ statusBarItems: [{ id: 'empty' }] })], {})
|
||||
).toEqual([])
|
||||
expect(collectPluginStatusBarItems([plugin({ status: 'disabled' })], {})).toEqual([])
|
||||
// Older hosts list no status bar items.
|
||||
expect(collectPluginStatusBarItems([plugin({ statusBarItems: undefined })], {})).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { create } from 'zustand'
|
||||
import type { PluginHostListEntry } from '../../../preload/api-types'
|
||||
import type { PluginStatusBarOverrides } from '../../../shared/plugins/contributions/plugin-status-bar-state'
|
||||
import type { PluginDeclarativeTone } from '../../../shared/plugins/declarative/plugin-declarative-catalog'
|
||||
import {
|
||||
fromPlugin,
|
||||
isLivePlugin,
|
||||
usePluginPanelsStore,
|
||||
ensurePluginPanelsLoaded,
|
||||
type ActivePluginCommand
|
||||
} from './plugin-panels'
|
||||
|
||||
/** A status bar item of an enabled plugin, with its worker's changes applied. */
|
||||
export type ActivePluginStatusBarItem = {
|
||||
key: string
|
||||
pluginName: string
|
||||
text?: string
|
||||
icon?: string
|
||||
tone: PluginDeclarativeTone
|
||||
tooltip: string
|
||||
command: ActivePluginCommand | null
|
||||
}
|
||||
|
||||
/** Mirrors main's snapshot of `statusBar.update` changes. */
|
||||
export const usePluginStatusBarStore = create<{ overrides: PluginStatusBarOverrides }>(() => ({
|
||||
overrides: {}
|
||||
}))
|
||||
|
||||
let watching = false
|
||||
|
||||
function watchPluginStatusBar(): void {
|
||||
const plugins = window.api?.plugins
|
||||
if (watching || !plugins?.statusBarState || !plugins.onStatusBarChanged) {
|
||||
return
|
||||
}
|
||||
watching = true
|
||||
plugins.onStatusBarChanged((overrides) => usePluginStatusBarStore.setState({ overrides }))
|
||||
void plugins
|
||||
.statusBarState()
|
||||
.then((overrides) => usePluginStatusBarStore.setState({ overrides }))
|
||||
// Why ignored: an older host has no runtime changes; items keep their manifest values.
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
export function collectPluginStatusBarItems(
|
||||
plugins: readonly PluginHostListEntry[],
|
||||
overrides: PluginStatusBarOverrides
|
||||
): ActivePluginStatusBarItem[] {
|
||||
return plugins.filter(isLivePlugin).flatMap((plugin) =>
|
||||
(plugin.statusBarItems ?? []).flatMap((declared) => {
|
||||
const item = { ...declared, ...overrides[plugin.pluginKey]?.[declared.id] }
|
||||
if (item.visible === false || (!item.text && !item.icon)) {
|
||||
return []
|
||||
}
|
||||
const command = plugin.commands.find((candidate) => candidate.id === declared.command)
|
||||
return [
|
||||
{
|
||||
key: `${plugin.pluginKey}/${declared.id}`,
|
||||
pluginName: plugin.name,
|
||||
...(item.text ? { text: item.text } : {}),
|
||||
...(item.icon ? { icon: item.icon } : {}),
|
||||
tone: item.tone ?? 'neutral',
|
||||
tooltip: item.tooltip || plugin.name,
|
||||
command: command ? fromPlugin(plugin, command) : null
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export function usePluginStatusBarItems(): ActivePluginStatusBarItem[] {
|
||||
const plugins = usePluginPanelsStore((state) => state.plugins)
|
||||
const overrides = usePluginStatusBarStore((state) => state.overrides)
|
||||
useEffect(() => {
|
||||
ensurePluginPanelsLoaded()
|
||||
watchPluginStatusBar()
|
||||
}, [])
|
||||
return useMemo(() => collectPluginStatusBarItems(plugins, overrides), [plugins, overrides])
|
||||
}
|
||||
@@ -38,6 +38,11 @@ import {
|
||||
PLUGIN_PANEL_BRIDGE_SINCE,
|
||||
PLUGIN_SCHEMA_SINCE_V1
|
||||
} from '../manifest/plugin-schema-field-docs'
|
||||
import {
|
||||
PLUGIN_STATUS_BAR_ITEM_LIMIT,
|
||||
PLUGIN_STATUS_BAR_SINCE,
|
||||
pluginStatusBarItemContributionSchema
|
||||
} from './plugin-status-bar-item-contribution'
|
||||
import {
|
||||
PLUGIN_SETTING_LIMIT,
|
||||
pluginSettingContributionSchema
|
||||
@@ -57,7 +62,8 @@ export const PLUGIN_CONTRIBUTION_POINTS = [
|
||||
'syntaxes',
|
||||
'customEditors',
|
||||
'newFiles',
|
||||
'settings'
|
||||
'settings',
|
||||
'statusBarItems'
|
||||
] as const
|
||||
|
||||
export type PluginContributionPoint = (typeof PLUGIN_CONTRIBUTION_POINTS)[number]
|
||||
@@ -73,7 +79,6 @@ export const PLUGIN_RESERVED_CONTRIBUTION_POINTS = {
|
||||
hooks: 'agent hooks',
|
||||
taskSources: 'task sources',
|
||||
connections: 'account connections',
|
||||
statusBarItems: 'status bar items',
|
||||
sidebarCards: 'sidebar cards'
|
||||
} as const
|
||||
|
||||
@@ -89,8 +94,6 @@ export const PLUGIN_PLANNED_CONTRIBUTION_POINTS: Readonly<
|
||||
'Account providers with a host-run sign-in, so a plugin never handles credentials itself.',
|
||||
agentHarnesses:
|
||||
'New coding-agent harnesses described as data: how to launch them and read their status.',
|
||||
statusBarItems:
|
||||
'Declarative status bar widgets (label, icon, tooltip) whose values a worker updates.',
|
||||
sidebarCards: 'Declarative left-sidebar cards with fixed layouts whose values a worker updates.',
|
||||
mcpServers: 'MCP server definitions that Orca offers to the agents it launches.',
|
||||
skills: 'Agent skills shipped as files that Orca installs for the agents it launches.',
|
||||
@@ -207,6 +210,12 @@ export const pluginContributesFields = {
|
||||
PLUGIN_SETTING_LIMIT,
|
||||
'Settings Orca shows under the plugin in Settings; code reads them with `settings.get` (`settings:own`).',
|
||||
PLUGIN_PANEL_BRIDGE_SINCE
|
||||
),
|
||||
statusBarItems: contributionList(
|
||||
pluginStatusBarItemContributionSchema,
|
||||
PLUGIN_STATUS_BAR_ITEM_LIMIT,
|
||||
'Items in the status bar; pressing one runs its `command`, and a worker changes them with `statusBar.update`.',
|
||||
PLUGIN_STATUS_BAR_SINCE
|
||||
)
|
||||
} satisfies Record<PluginContributionPoint, z.ZodType>
|
||||
|
||||
@@ -217,7 +226,6 @@ export const pluginReservedContributesFields = {
|
||||
hooks: reservedList(PLUGIN_RESERVED_CONTRIBUTION_POINTS.hooks),
|
||||
taskSources: reservedList(PLUGIN_RESERVED_CONTRIBUTION_POINTS.taskSources),
|
||||
connections: reservedList(PLUGIN_RESERVED_CONTRIBUTION_POINTS.connections),
|
||||
statusBarItems: reservedList(PLUGIN_RESERVED_CONTRIBUTION_POINTS.statusBarItems),
|
||||
sidebarCards: reservedList(PLUGIN_RESERVED_CONTRIBUTION_POINTS.sidebarCards)
|
||||
} satisfies Record<PluginReservedContributionPoint, z.ZodType>
|
||||
|
||||
@@ -252,5 +260,7 @@ export const PLUGIN_CONTRIBUTION_POINT_POLICY: Record<
|
||||
// whatever opens that file (a syntax, an editor) carries its own type.
|
||||
newFiles: { execution: 'data', catalogType: null, bindsContent: false },
|
||||
// Why sandboxed-ui: only code reads a setting, and panels are the least code there is.
|
||||
settings: { execution: 'sandboxed-ui', catalogType: null, bindsContent: false }
|
||||
settings: { execution: 'sandboxed-ui', catalogType: null, bindsContent: false },
|
||||
// Why declarative: a static item only names its own command; updating it needs a worker.
|
||||
statusBarItems: { execution: 'declarative', catalogType: null, bindsContent: false }
|
||||
}
|
||||
|
||||
@@ -89,7 +89,9 @@ export function pluginContributionSummary(
|
||||
c.newFiles,
|
||||
(newFile) => newFile.title,
|
||||
(newFile) => newFile.extension
|
||||
)
|
||||
),
|
||||
// Each item's text, or its id when it shows only an icon.
|
||||
...named('statusBarItems', c.statusBarItems, (item) => item.text || item.id)
|
||||
].slice(0, PLUGIN_CONTRIBUTION_SUMMARY_LIMIT)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parsePluginManifest } from '../manifest/plugin-manifest'
|
||||
import { derivePluginExecutionProfile } from './plugin-execution-profile'
|
||||
import { pluginStatusBarItemPatchSchema } from './plugin-status-bar-item-contribution'
|
||||
import { pluginContributionSummary } from './plugin-contribution-summary'
|
||||
import {
|
||||
buildPluginApprovalRequest,
|
||||
formatPluginApprovalMessage
|
||||
} from '../approval/plugin-cli-approval'
|
||||
|
||||
function manifest(statusBarItems: unknown[], engines = '>=1.4.248') {
|
||||
return {
|
||||
manifestVersion: 2,
|
||||
execution: 'declarative',
|
||||
id: 'status-demo',
|
||||
name: 'Status demo',
|
||||
version: '1.0.0',
|
||||
engines: { orca: engines },
|
||||
contributes: {
|
||||
commands: [{ id: 'demo.tasks', title: 'Open tasks', action: 'view.tasks' }],
|
||||
statusBarItems
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function issues(raw: unknown): string {
|
||||
const parsed = parsePluginManifest(raw)
|
||||
return parsed.ok ? '' : parsed.error
|
||||
}
|
||||
|
||||
describe('contributes.statusBarItems', () => {
|
||||
it('accepts a static item that runs one of the plugin commands', () => {
|
||||
const parsed = parsePluginManifest(
|
||||
manifest([
|
||||
{ id: 'tasks', text: '3 open', icon: 'check', tone: 'warning', command: 'demo.tasks' }
|
||||
])
|
||||
)
|
||||
expect(parsed.ok && parsed.manifest.contributes.statusBarItems).toEqual([
|
||||
{ id: 'tasks', text: '3 open', icon: 'check', tone: 'warning', command: 'demo.tasks' }
|
||||
])
|
||||
})
|
||||
|
||||
it('refuses duplicate ids, unknown commands, icons and fields', () => {
|
||||
expect(
|
||||
issues(
|
||||
manifest([
|
||||
{ id: 'a', text: 'x' },
|
||||
{ id: 'a', text: 'y' }
|
||||
])
|
||||
)
|
||||
).toContain('duplicate statusBarItems id: a')
|
||||
expect(issues(manifest([{ id: 'a', command: 'demo.missing' }]))).toContain(
|
||||
'unknown contributed command: demo.missing'
|
||||
)
|
||||
expect(issues(manifest([{ id: 'a', icon: 'not-an-icon' }]))).not.toBe('')
|
||||
expect(issues(manifest([{ id: 'a', onPress: 'x' }]))).not.toBe('')
|
||||
})
|
||||
|
||||
it('lists items in the catalog summary by text, or id when icon-only', () => {
|
||||
const parsed = parsePluginManifest(
|
||||
manifest([
|
||||
{ id: 'tasks', text: '3 open' },
|
||||
{ id: 'quiet', icon: 'check' }
|
||||
])
|
||||
)
|
||||
expect(parsed.ok && pluginContributionSummary(parsed.manifest)).toEqual([
|
||||
{ kind: 'commands', name: 'Open tasks' },
|
||||
{ kind: 'statusBarItems', name: '3 open' },
|
||||
{ kind: 'statusBarItems', name: 'quiet' }
|
||||
])
|
||||
})
|
||||
|
||||
it('lists items in the dev-load and inspect approval, and binds the fingerprint to them', () => {
|
||||
const subject = (items: unknown[]) => {
|
||||
const parsed = parsePluginManifest(manifest(items))
|
||||
if (!parsed.ok) {
|
||||
throw new Error(parsed.error)
|
||||
}
|
||||
return {
|
||||
pluginKey: 'dev:status-demo',
|
||||
manifest: parsed.manifest,
|
||||
consentFingerprint: 'c',
|
||||
contentIdentity: 'h',
|
||||
isDev: true
|
||||
}
|
||||
}
|
||||
const request = buildPluginApprovalRequest(
|
||||
subject([{ id: 'tasks', text: '3 open' }]),
|
||||
'dev-load'
|
||||
)
|
||||
expect(formatPluginApprovalMessage(request)).toContain('statusBarItems "3 open"')
|
||||
expect(
|
||||
buildPluginApprovalRequest(subject([{ id: 'tasks', text: '4 open' }]), 'dev-load')
|
||||
.approvalFingerprint
|
||||
).not.toBe(request.approvalFingerprint)
|
||||
})
|
||||
|
||||
it('needs an engines floor that reads the field', () => {
|
||||
expect(issues(manifest([{ id: 'a', text: 'x' }], '>=1.4.247'))).toContain('1.4.248')
|
||||
})
|
||||
|
||||
it('needs only the declarative profile', () => {
|
||||
const parsed = parsePluginManifest(manifest([{ id: 'a', text: 'x' }]))
|
||||
expect(parsed.ok && derivePluginExecutionProfile(parsed.manifest)).toBe('declarative')
|
||||
expect(issues({ ...manifest([{ id: 'a', text: 'x' }]), execution: 'data' })).not.toBe('')
|
||||
})
|
||||
|
||||
it('patches only display fields', () => {
|
||||
expect(pluginStatusBarItemPatchSchema.safeParse({ text: '4', visible: false }).success).toBe(
|
||||
true
|
||||
)
|
||||
expect(pluginStatusBarItemPatchSchema.safeParse({ command: 'demo.tasks' }).success).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,96 @@
|
||||
import { z, type RefinementCtx } from 'zod'
|
||||
import { safePluginDisplayText } from '../manifest/plugin-display-text'
|
||||
import { pluginCommandIdSchema, pluginIdSchema } from '../manifest/plugin-manifest-fields'
|
||||
import { pluginFieldDoc } from '../manifest/plugin-schema-field-docs'
|
||||
import { PLUGIN_DECLARATIVE_TONES } from '../declarative/plugin-declarative-prop-schemas'
|
||||
import { PLUGIN_PANEL_ICONS } from './plugin-panel-contribution'
|
||||
|
||||
/** First release that reads `contributes.statusBarItems` and `statusBar.update`. */
|
||||
export const PLUGIN_STATUS_BAR_SINCE = '1.4.248'
|
||||
export const PLUGIN_STATUS_BAR_ITEM_LIMIT = 8
|
||||
export const PLUGIN_STATUS_BAR_TEXT_MAX_LENGTH = 64
|
||||
export const PLUGIN_STATUS_BAR_TOOLTIP_MAX_LENGTH = 256
|
||||
|
||||
const SINCE = PLUGIN_STATUS_BAR_SINCE
|
||||
|
||||
/** The fields a worker may change at runtime; the manifest declares their first values. */
|
||||
export const pluginStatusBarItemDisplayFields = {
|
||||
text: safePluginDisplayText(z.string().max(PLUGIN_STATUS_BAR_TEXT_MAX_LENGTH))
|
||||
.optional()
|
||||
.meta(pluginFieldDoc('Short label shown in the status bar.', SINCE, ['3 blocked'])),
|
||||
icon: z
|
||||
.enum(PLUGIN_PANEL_ICONS)
|
||||
.optional()
|
||||
.meta(pluginFieldDoc('Icon shown before the text, from the panel icon set.', SINCE, ['bell'])),
|
||||
tone: z
|
||||
.enum(PLUGIN_DECLARATIVE_TONES)
|
||||
.optional()
|
||||
.meta(pluginFieldDoc('Colour of the item. Defaults to `neutral`.', SINCE, ['warning'])),
|
||||
tooltip: safePluginDisplayText(z.string().max(PLUGIN_STATUS_BAR_TOOLTIP_MAX_LENGTH))
|
||||
.optional()
|
||||
.meta(pluginFieldDoc('Shown on hover. Defaults to the plugin name.', SINCE))
|
||||
}
|
||||
|
||||
export const pluginStatusBarItemContributionSchema = z
|
||||
.object({
|
||||
id: pluginIdSchema.meta(
|
||||
pluginFieldDoc(
|
||||
"Item id, unique among this plugin's status bar items; `statusBar.update` names it.",
|
||||
SINCE,
|
||||
['blocked-count']
|
||||
)
|
||||
),
|
||||
...pluginStatusBarItemDisplayFields,
|
||||
command: pluginCommandIdSchema
|
||||
.optional()
|
||||
.meta(
|
||||
pluginFieldDoc(
|
||||
'Id of a command this plugin contributes in `contributes.commands`, run when the item is pressed.',
|
||||
SINCE,
|
||||
['guard.showLog']
|
||||
)
|
||||
)
|
||||
})
|
||||
.strict()
|
||||
.meta({
|
||||
id: 'StatusBarItemContribution',
|
||||
description:
|
||||
'An item in the status bar. Requires the `declarative` profile; a `trusted-worker` can change it with `statusBar.update`.'
|
||||
})
|
||||
|
||||
export type PluginStatusBarItemContribution = z.infer<typeof pluginStatusBarItemContributionSchema>
|
||||
|
||||
/** What `statusBar.update` sets; an absent field keeps its value, `visible: false` hides it. */
|
||||
export const pluginStatusBarItemPatchSchema = z.strictObject({
|
||||
...pluginStatusBarItemDisplayFields,
|
||||
visible: z.boolean().optional()
|
||||
})
|
||||
export type PluginStatusBarItemPatch = z.infer<typeof pluginStatusBarItemPatchSchema>
|
||||
|
||||
export function validatePluginStatusBarItems(
|
||||
contributes: {
|
||||
commands: readonly { id: string }[]
|
||||
statusBarItems: readonly { id: string; command?: string }[]
|
||||
},
|
||||
ctx: RefinementCtx
|
||||
): void {
|
||||
const ids = new Set<string>()
|
||||
const commands = new Set(contributes.commands.map((command) => command.id))
|
||||
for (const [index, item] of contributes.statusBarItems.entries()) {
|
||||
if (ids.has(item.id)) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['contributes', 'statusBarItems', index],
|
||||
message: `duplicate statusBarItems id: ${item.id}`
|
||||
})
|
||||
}
|
||||
ids.add(item.id)
|
||||
if (item.command !== undefined && !commands.has(item.command)) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['contributes', 'statusBarItems', index, 'command'],
|
||||
message: `unknown contributed command: ${item.command}`
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { PluginStatusBarItemPatch } from './plugin-status-bar-item-contribution'
|
||||
|
||||
/** Runtime changes workers made, by plugin key then item id; main pushes it to the renderer. */
|
||||
export type PluginStatusBarOverrides = Readonly<
|
||||
Record<string, Readonly<Record<string, PluginStatusBarItemPatch>>>
|
||||
>
|
||||
|
||||
/** main → renderer: a new PluginStatusBarOverrides snapshot. */
|
||||
export const PLUGIN_STATUS_BAR_CHANGED_CHANNEL = 'plugins:statusBarChanged'
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
PLUGIN_PANEL_UI_SINCE,
|
||||
PLUGIN_SCHEMA_SINCE_V1
|
||||
} from '../manifest/plugin-schema-field-docs'
|
||||
import { PLUGIN_STATUS_BAR_SINCE } from '../contributions/plugin-status-bar-item-contribution'
|
||||
|
||||
/** The newest "since" this build's schemas cite: everything up to it is readable here. */
|
||||
export const PLUGIN_SCHEMA_NEWEST_SINCE = [
|
||||
@@ -15,7 +16,8 @@ export const PLUGIN_SCHEMA_NEWEST_SINCE = [
|
||||
PLUGIN_DERIVED_PUBLISHER_SINCE,
|
||||
PLUGIN_PANEL_UI_SINCE,
|
||||
PLUGIN_COMPONENTS_CATALOG_2_SINCE,
|
||||
PLUGIN_EDITOR_FRAME_SINCE
|
||||
PLUGIN_EDITOR_FRAME_SINCE,
|
||||
PLUGIN_STATUS_BAR_SINCE
|
||||
].reduce((newest, version) => (satisfiesOrcaEngineRange(version, `>=${newest}`) ? version : newest))
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type PluginEventName
|
||||
} from '../contributions/plugin-event-contribution'
|
||||
import type { PluginFileMatch } from '../contributions/plugin-file-match'
|
||||
import { validatePluginStatusBarItems } from '../contributions/plugin-status-bar-item-contribution'
|
||||
|
||||
type IdentifiedContribution = { id: string }
|
||||
type PathContribution = { path: string }
|
||||
@@ -26,6 +27,7 @@ type ContributionValidationManifest = {
|
||||
customEditors: (IdentifiedContribution & PluginFileMatch)[]
|
||||
newFiles: IdentifiedContribution[]
|
||||
settings: { key: string }[]
|
||||
statusBarItems: { id: string; command?: string }[]
|
||||
}
|
||||
capabilities: { kind: string }[]
|
||||
}
|
||||
@@ -149,6 +151,7 @@ export function validatePluginManifestContributions(
|
||||
identities.forEach((identity) => keybindingIdentities.add(identity))
|
||||
}
|
||||
|
||||
validatePluginStatusBarItems(manifest.contributes, ctx)
|
||||
const commands = new Map(manifest.contributes.commands.map((command) => [command.id, command]))
|
||||
for (const [index, command] of manifest.contributes.commands.entries()) {
|
||||
if (command.action !== undefined && !isPluginCommandAliasActionId(command.action)) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { PluginCatalogType } from './catalog/plugin-catalog-types'
|
||||
import type { PluginExecutionProfile } from './contributions/plugin-execution-profile'
|
||||
import type { PluginHostImages } from './images/plugin-image-url'
|
||||
import type { PluginStatusBarItemContribution } from './contributions/plugin-status-bar-item-contribution'
|
||||
|
||||
/** One installed plugin as main projects it for every client (preload IPC and the `plugins.list`
|
||||
* RPC). Optional fields are absent on older hosts, so paired clients must tolerate them. */
|
||||
@@ -87,6 +88,8 @@ export type PluginHostListEntry = {
|
||||
handler: { type: 'built-in'; action: string } | { type: 'worker' }
|
||||
keybindings: { key: string; when: 'global' | 'workspace' | 'worktree' }[]
|
||||
}[]
|
||||
/** The manifest's status bar items; a worker's changes arrive separately. Absent on older hosts. */
|
||||
statusBarItems?: PluginStatusBarItemContribution[]
|
||||
hasWorker: boolean
|
||||
vmRecipes?: {
|
||||
id: string
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { z } from 'zod'
|
||||
import { pluginIdSchema } from '../manifest/plugin-manifest-fields'
|
||||
import { pluginStatusBarItemPatchSchema } from '../contributions/plugin-status-bar-item-contribution'
|
||||
import { spec, WORKER_ONLY } from './plugin-host-method-spec'
|
||||
|
||||
const statusBarUpdateParams = z.strictObject({
|
||||
id: pluginIdSchema,
|
||||
...pluginStatusBarItemPatchSchema.shape
|
||||
})
|
||||
const statusBarUpdateResult = z.object({ ok: z.literal(true) })
|
||||
|
||||
/** The plugin's own status bar items; spread into PLUGIN_HOST_API. */
|
||||
export const PLUGIN_STATUS_BAR_API = {
|
||||
'statusBar.update': spec({
|
||||
summary:
|
||||
"Changes one of the plugin's `contributes.statusBarItems` (named by `id`): fields given replace their current value, `visible: false` hides it. Desktop app only; lasts until Orca restarts, and is hidden while the plugin is disabled.",
|
||||
since: 2,
|
||||
scope: 'plugin-private',
|
||||
capability: null,
|
||||
// Why not audited: it only redraws the plugin's own item, possibly many times a minute.
|
||||
mutation: false,
|
||||
surfaces: WORKER_ONLY,
|
||||
params: statusBarUpdateParams,
|
||||
result: statusBarUpdateResult
|
||||
})
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { z } from 'zod'
|
||||
import type { PluginSurface } from '../frames/plugin-protocol'
|
||||
import * as stored from './plugin-host-api-storage-schemas'
|
||||
import { PLUGIN_PANEL_BRIDGE_API } from './plugin-host-api-panel-methods'
|
||||
import { PLUGIN_STATUS_BAR_API } from './plugin-host-api-status-bar'
|
||||
import {
|
||||
ALL_SURFACES,
|
||||
FRAMES_ONLY,
|
||||
@@ -299,7 +300,8 @@ export const PLUGIN_HOST_API = {
|
||||
params: filesSaveAsParams,
|
||||
result: filesSaveAsResult
|
||||
}),
|
||||
...PLUGIN_PANEL_BRIDGE_API
|
||||
...PLUGIN_PANEL_BRIDGE_API,
|
||||
...PLUGIN_STATUS_BAR_API
|
||||
}
|
||||
|
||||
export type PluginHostApi = typeof PLUGIN_HOST_API
|
||||
|
||||
Reference in New Issue
Block a user