mirror of
https://github.com/stablyai/orca.git
synced 2026-11-14 09:57:48 +00:00
feat(plugins): workers can open files and run built-in actions (#37323)
* feat(plugins): add editor.open and commands.run for full-access workers Both ride the existing plugin view request channel (the one plugin open and plugin shot use) to the one main window's renderer; headless serve and a missing window answer unavailable. editor.open takes a path that stays inside the workspace (Git worktrees and folders) and opens it as a tab without switching what the person sees. commands.run takes only the built-in contributes.commands action list and dispatches Orca's own handler, never a plugin replacement. Both need workspace:write. workspace.list now also lists project-group folder workspaces. * fix(plugins): open folder workspaces and host-naming ids through Orca's window workspace.open of a project-group folder asks the window to show it (setActiveFolderWorkspace), since the runtime activates Git worktrees only. editor.open resolves the identity: ids workspace.list now returns. commands.run documents that it must call the raw built-in, never a replacement wrapper. * plugins: regenerate the testing bundle and skill guides on the rebased host actions Generated files only (lint-staged's oxlint refuses a commit with no lintable files). Co-Authored-By: Claude <noreply@anthropic.com> * chore(plugins): regenerate the testing bundle on main --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -32,10 +32,12 @@ Methods whose capability is `none` need no capability but still need the plugin
|
||||
| [`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.list`](#workspacelist) | `workspace:read` | worker | no | 1.3 | Lists every workspace, Git worktrees and folders, on every host Orca reaches. |
|
||||
| [`workspace.open`](#workspaceopen) | `workspace:write` | worker | yes | 1.3 | Shows a workspace in Orca, as clicking it in the sidebar does. |
|
||||
| [`workspace.open`](#workspaceopen) | `workspace:write` | worker | yes | 1.3 | Shows a workspace in Orca, as clicking it in the sidebar does; a folder workspace needs the desktop app. |
|
||||
| [`terminal.create`](#terminalcreate) | `terminal:send` | worker | yes | 1.3 | Opens a terminal in a workspace, optionally running a command, in the background. |
|
||||
| [`terminal.read`](#terminalread) | `terminal:read` | worker | no | 1.3 | Reads a terminal's newest output as plain text, at most 2000 lines. |
|
||||
| [`agent.launch`](#agentlaunch) | `agent:launch` | worker | yes | 1.3 | Starts an agent's CLI in a new terminal of a workspace, in the background, and returns as soon as it starts; a `prompt` is sent once the agent is ready. |
|
||||
| [`editor.open`](#editoropen) | `workspace:write` | worker | yes | 1.3 | Opens a file of a workspace in an editor tab of Orca's window; desktop app only. `workspace.open` shows the workspace. |
|
||||
| [`commands.run`](#commandsrun) | `workspace:write` | worker | yes | 1.3 | Runs one of Orca's built-in actions in its window, never a plugin's replacement of it; desktop app only. |
|
||||
|
||||
### `workspace.readContext`
|
||||
|
||||
@@ -199,7 +201,7 @@ Lists every workspace, Git worktrees and folders, on every host Orca reaches.
|
||||
|
||||
### `workspace.open`
|
||||
|
||||
Shows a workspace in Orca, as clicking it in the sidebar does.
|
||||
Shows a workspace in Orca, as clicking it in the sidebar does; a folder workspace needs the desktop app.
|
||||
|
||||
```ts
|
||||
(params: { workspaceId: string }) => { opened: true }
|
||||
@@ -229,6 +231,22 @@ Starts an agent's CLI in a new terminal of a workspace, in the background, and r
|
||||
(params: { workspaceId: string; agent: string; prompt?: string }) => { terminalId: string }
|
||||
```
|
||||
|
||||
### `editor.open`
|
||||
|
||||
Opens a file of a workspace in an editor tab of Orca's window; desktop app only. `workspace.open` shows the workspace.
|
||||
|
||||
```ts
|
||||
(params: { workspaceId: string; path: string }) => { opened: true }
|
||||
```
|
||||
|
||||
### `commands.run`
|
||||
|
||||
Runs one of Orca's built-in actions in its window, never a plugin's replacement of it; desktop app only.
|
||||
|
||||
```ts
|
||||
(params: { action: "worktree.history.back" | "worktree.history.forward" | "sidebar.left.toggle" | "sidebar.sleepingWorkspaces.toggle" | "floatingWorkspace.maximize" | "tab.rename" | "workspace.rename" | "workspace.openBoard" | "view.tasks" | "sidebar.right.toggle" | "sidebar.explorer.toggle" | "sidebar.search.toggle" | "sidebar.sourceControl.toggle" | "sidebar.checks.toggle" | "sidebar.ports.toggle" }) => { ran: 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`.
|
||||
|
||||
+19
-19
File diff suppressed because one or more lines are too long
+13
-1
@@ -154,7 +154,7 @@ declare module 'orca/host' {
|
||||
result: { workspaces: { id: string; kind: "worktree" | "folder"; name: string; path: string; branch: string | null; hostId: string | null }[]; truncated: boolean }
|
||||
surfaces: "worker"
|
||||
}
|
||||
/** Shows a workspace in Orca, as clicking it in the sidebar does. Since host API 1.3. */
|
||||
/** Shows a workspace in Orca, as clicking it in the sidebar does; a folder workspace needs the desktop app. Since host API 1.3. */
|
||||
"workspace.open": {
|
||||
params: { workspaceId: string }
|
||||
result: { opened: true }
|
||||
@@ -178,6 +178,18 @@ declare module 'orca/host' {
|
||||
result: { terminalId: string }
|
||||
surfaces: "worker"
|
||||
}
|
||||
/** Opens a file of a workspace in an editor tab of Orca's window; desktop app only. `workspace.open` shows the workspace. Since host API 1.3. */
|
||||
"editor.open": {
|
||||
params: { workspaceId: string; path: string }
|
||||
result: { opened: true }
|
||||
surfaces: "worker"
|
||||
}
|
||||
/** Runs one of Orca's built-in actions in its window, never a plugin's replacement of it; desktop app only. Since host API 1.3. */
|
||||
"commands.run": {
|
||||
params: { action: "worktree.history.back" | "worktree.history.forward" | "sidebar.left.toggle" | "sidebar.sleepingWorkspaces.toggle" | "floatingWorkspace.maximize" | "tab.rename" | "workspace.rename" | "workspace.openBoard" | "view.tasks" | "sidebar.right.toggle" | "sidebar.explorer.toggle" | "sidebar.search.toggle" | "sidebar.sourceControl.toggle" | "sidebar.checks.toggle" | "sidebar.ports.toggle" }
|
||||
result: { ran: true }
|
||||
surfaces: "worker"
|
||||
}
|
||||
}
|
||||
export type HostMethodName = keyof HostMethods
|
||||
/** Methods a surface may call; any other gets `not_permitted`. */
|
||||
|
||||
@@ -6,7 +6,7 @@ Host API 1.3 (`"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`, `statusBar.update`, `workspace.list`, `workspace.open`, `terminal.create`, `terminal.read`, `agent.launch`
|
||||
- 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`, `workspace.list`, `workspace.open`, `terminal.create`, `terminal.read`, `agent.launch`, `editor.open`, `commands.run`
|
||||
- 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`
|
||||
|
||||
@@ -109,7 +109,7 @@ HTML panels and custom editors get these CSS variables from Orca's active theme,
|
||||
`(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 }`
|
||||
- `workspace.list` (capability `workspace:read`; worker; since 1.3): Lists every workspace, Git worktrees and folders, on every host Orca reaches.
|
||||
`(params?: {}) => { workspaces: { id: string; kind: "worktree" | "folder"; name: string; path: string; branch: string | null; hostId: string | null }[]; truncated: boolean }`
|
||||
- `workspace.open` (capability `workspace:write`; worker; since 1.3): Shows a workspace in Orca, as clicking it in the sidebar does.
|
||||
- `workspace.open` (capability `workspace:write`; worker; since 1.3): Shows a workspace in Orca, as clicking it in the sidebar does; a folder workspace needs the desktop app.
|
||||
`(params: { workspaceId: string }) => { opened: true }`
|
||||
- `terminal.create` (capability `terminal:send`; worker; since 1.3): Opens a terminal in a workspace, optionally running a command, in the background.
|
||||
`(params: { workspaceId: string; title?: string; command?: string }) => { terminalId: string }`
|
||||
@@ -117,6 +117,10 @@ HTML panels and custom editors get these CSS variables from Orca's active theme,
|
||||
`(params: { terminalId: string; maxLines?: number }) => { lines: string[]; truncated: boolean }`
|
||||
- `agent.launch` (capability `agent:launch`; worker; since 1.3): Starts an agent's CLI in a new terminal of a workspace, in the background, and returns as soon as it starts; a `prompt` is sent once the agent is ready.
|
||||
`(params: { workspaceId: string; agent: string; prompt?: string }) => { terminalId: string }`
|
||||
- `editor.open` (capability `workspace:write`; worker; since 1.3): Opens a file of a workspace in an editor tab of Orca's window; desktop app only. `workspace.open` shows the workspace.
|
||||
`(params: { workspaceId: string; path: string }) => { opened: true }`
|
||||
- `commands.run` (capability `workspace:write`; worker; since 1.3): Runs one of Orca's built-in actions in its window, never a plugin's replacement of it; desktop app only.
|
||||
`(params: { action: "worktree.history.back" | "worktree.history.forward" | "sidebar.left.toggle" | "sidebar.sleepingWorkspaces.toggle" | "floatingWorkspace.maximize" | "tab.rename" | "workspace.rename" | "workspace.openBoard" | "view.tasks" | "sidebar.right.toggle" | "sidebar.explorer.toggle" | "sidebar.search.toggle" | "sidebar.sourceControl.toggle" | "sidebar.checks.toggle" | "sidebar.ports.toggle" }) => { ran: true }`
|
||||
|
||||
## Hooks
|
||||
|
||||
@@ -140,7 +144,7 @@ List each in `contributes.hooks` (`{ "event", "onError"? }`; `main` and `trusted
|
||||
## Capabilities
|
||||
|
||||
- `workspace:read` (since 1.4.160): Read the name, branch, and terminal list of your focused workspace, and get notified when workspaces are created or removed
|
||||
- `workspace:write` (since 1.4.248): Open your workspaces in Orca
|
||||
- `workspace:write` (since 1.4.248): Open your workspaces and their files in Orca, and run its built-in commands
|
||||
- `agent:read` (since 1.4.245): Get notified when agent status changes
|
||||
- `agent:launch` (since 1.4.248): Start agents in your workspaces, local and SSH
|
||||
- `terminal:send` (since 1.4.160): Type and run commands in your terminals, local and SSH
|
||||
|
||||
@@ -148,4 +148,6 @@ A `trusted-worker` can act as the `orca` CLI does, on whichever host owns the wo
|
||||
`host.call('workspace.list')` (`workspace:read`) for ids, kinds, paths and branches;
|
||||
`terminal.create({ workspaceId, command })` (`terminal:send`) then `terminal.read({ terminalId,
|
||||
maxLines })` (`terminal:read`) for its output; `agent.launch({ workspaceId, agent: 'claude', prompt })`
|
||||
(`agent:launch`); `workspace.open({ workspaceId })` (`workspace:write`) to show one. Panels get none.
|
||||
(`agent:launch`); `workspace.open({ workspaceId })`, `editor.open({ workspaceId, path })` (a path
|
||||
inside it) and `commands.run({ action })` (a built-in `contributes.commands[].action`, never a plugin's
|
||||
replacement) need `workspace:write` and the desktop app. Panels get none.
|
||||
|
||||
@@ -2885,7 +2885,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`, `statusBar.update`, `workspace.list`, `workspace.open`, `terminal.create`, `terminal.read`, `agent.launch`",
|
||||
"- 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`, `workspace.list`, `workspace.open`, `terminal.create`, `terminal.read`, `agent.launch`, `editor.open`, `commands.run`",
|
||||
"- 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`",
|
||||
"",
|
||||
@@ -2988,7 +2988,7 @@ const ORCA_PLUGIN_AUTHORING_HOST_API_REFERENCE_MARKDOWN = [
|
||||
" `(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 }`",
|
||||
"- `workspace.list` (capability `workspace:read`; worker; since 1.3): Lists every workspace, Git worktrees and folders, on every host Orca reaches.",
|
||||
" `(params?: {}) => { workspaces: { id: string; kind: \"worktree\" | \"folder\"; name: string; path: string; branch: string | null; hostId: string | null }[]; truncated: boolean }`",
|
||||
"- `workspace.open` (capability `workspace:write`; worker; since 1.3): Shows a workspace in Orca, as clicking it in the sidebar does.",
|
||||
"- `workspace.open` (capability `workspace:write`; worker; since 1.3): Shows a workspace in Orca, as clicking it in the sidebar does; a folder workspace needs the desktop app.",
|
||||
" `(params: { workspaceId: string }) => { opened: true }`",
|
||||
"- `terminal.create` (capability `terminal:send`; worker; since 1.3): Opens a terminal in a workspace, optionally running a command, in the background.",
|
||||
" `(params: { workspaceId: string; title?: string; command?: string }) => { terminalId: string }`",
|
||||
@@ -2996,6 +2996,10 @@ const ORCA_PLUGIN_AUTHORING_HOST_API_REFERENCE_MARKDOWN = [
|
||||
" `(params: { terminalId: string; maxLines?: number }) => { lines: string[]; truncated: boolean }`",
|
||||
"- `agent.launch` (capability `agent:launch`; worker; since 1.3): Starts an agent's CLI in a new terminal of a workspace, in the background, and returns as soon as it starts; a `prompt` is sent once the agent is ready.",
|
||||
" `(params: { workspaceId: string; agent: string; prompt?: string }) => { terminalId: string }`",
|
||||
"- `editor.open` (capability `workspace:write`; worker; since 1.3): Opens a file of a workspace in an editor tab of Orca's window; desktop app only. `workspace.open` shows the workspace.",
|
||||
" `(params: { workspaceId: string; path: string }) => { opened: true }`",
|
||||
"- `commands.run` (capability `workspace:write`; worker; since 1.3): Runs one of Orca's built-in actions in its window, never a plugin's replacement of it; desktop app only.",
|
||||
" `(params: { action: \"worktree.history.back\" | \"worktree.history.forward\" | \"sidebar.left.toggle\" | \"sidebar.sleepingWorkspaces.toggle\" | \"floatingWorkspace.maximize\" | \"tab.rename\" | \"workspace.rename\" | \"workspace.openBoard\" | \"view.tasks\" | \"sidebar.right.toggle\" | \"sidebar.explorer.toggle\" | \"sidebar.search.toggle\" | \"sidebar.sourceControl.toggle\" | \"sidebar.checks.toggle\" | \"sidebar.ports.toggle\" }) => { ran: true }`",
|
||||
"",
|
||||
"## Hooks",
|
||||
"",
|
||||
@@ -3019,7 +3023,7 @@ const ORCA_PLUGIN_AUTHORING_HOST_API_REFERENCE_MARKDOWN = [
|
||||
"## Capabilities",
|
||||
"",
|
||||
"- `workspace:read` (since 1.4.160): Read the name, branch, and terminal list of your focused workspace, and get notified when workspaces are created or removed",
|
||||
"- `workspace:write` (since 1.4.248): Open your workspaces in Orca",
|
||||
"- `workspace:write` (since 1.4.248): Open your workspaces and their files in Orca, and run its built-in commands",
|
||||
"- `agent:read` (since 1.4.245): Get notified when agent status changes",
|
||||
"- `agent:launch` (since 1.4.248): Start agents in your workspaces, local and SSH",
|
||||
"- `terminal:send` (since 1.4.160): Type and run commands in your terminals, local and SSH",
|
||||
@@ -3399,7 +3403,9 @@ const ORCA_PLUGIN_AUTHORING_RECIPES_REFERENCE_MARKDOWN = [
|
||||
"`host.call('workspace.list')` (`workspace:read`) for ids, kinds, paths and branches;",
|
||||
"`terminal.create({ workspaceId, command })` (`terminal:send`) then `terminal.read({ terminalId,",
|
||||
"maxLines })` (`terminal:read`) for its output; `agent.launch({ workspaceId, agent: 'claude', prompt })`",
|
||||
"(`agent:launch`); `workspace.open({ workspaceId })` (`workspace:write`) to show one. Panels get none.",
|
||||
"(`agent:launch`); `workspace.open({ workspaceId })`, `editor.open({ workspaceId, path })` (a path",
|
||||
"inside it) and `commands.run({ action })` (a built-in `contributes.commands[].action`, never a plugin's",
|
||||
"replacement) need `workspace:write` and the desktop app. Panels get none.",
|
||||
"",
|
||||
].join('\n')
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ALL_RPC_METHODS } from '../runtime/rpc/methods'
|
||||
const DISPATCHED = [
|
||||
'worktree.list',
|
||||
'repo.list',
|
||||
'folderWorkspace.list',
|
||||
'worktree.activate',
|
||||
'terminal.create',
|
||||
'terminal.read',
|
||||
|
||||
@@ -20,6 +20,7 @@ import type {
|
||||
PluginSurface
|
||||
} from '../../shared/plugins/frames/plugin-protocol'
|
||||
import { executePluginHostCallRequest } from './workers/plugin-host-call-adapter'
|
||||
import { getPluginHostRendererActions } from './view/plugin-host-renderer-actions'
|
||||
|
||||
/** Who made a call: the open panel (frames only), and its host window's WebContents for desktop
|
||||
* IPC calls. */
|
||||
@@ -84,6 +85,7 @@ export function resolvePluginServiceHostCallPolicy(input: {
|
||||
pluginsDataDir: input.pluginsDataDir,
|
||||
desktop: input.desktop,
|
||||
panels: input.panels,
|
||||
renderer: getPluginHostRendererActions(),
|
||||
declaredSettings: plugin?.manifest.contributes.settings,
|
||||
statusBar: input.statusBar,
|
||||
declaredStatusBarItemIds: plugin?.manifest.contributes.statusBarItems.map(
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
getPluginHostRendererActions,
|
||||
setPluginHostRendererRequest
|
||||
} from './plugin-host-renderer-actions'
|
||||
|
||||
afterEach(() => setPluginHostRendererRequest(null))
|
||||
|
||||
describe('plugin host renderer actions', () => {
|
||||
it('are absent in headless serve, and unavailable with no window', async () => {
|
||||
expect(getPluginHostRendererActions()).toBeNull()
|
||||
setPluginHostRendererRequest(() => null)
|
||||
await expect(getPluginHostRendererActions()?.runAction('view.tasks')).rejects.toMatchObject({
|
||||
code: 'unavailable'
|
||||
})
|
||||
})
|
||||
|
||||
it('send open-file and run-action, and map the window refusals', async () => {
|
||||
const request = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ requestId: 'a', ok: true })
|
||||
.mockResolvedValueOnce({ requestId: 'b', ok: false, code: 'no_workspace', message: 'none' })
|
||||
.mockResolvedValueOnce({ requestId: 'c', ok: false, code: 'renderer_error', message: 'boom' })
|
||||
.mockRejectedValueOnce(new Error('renderer_unavailable'))
|
||||
setPluginHostRendererRequest(request)
|
||||
const actions = getPluginHostRendererActions()!
|
||||
await expect(actions.openFile('app', 'src/a.ts')).resolves.toBeUndefined()
|
||||
expect(request).toHaveBeenCalledWith({
|
||||
kind: 'open-file',
|
||||
worktreeId: 'app',
|
||||
relativePath: 'src/a.ts'
|
||||
})
|
||||
await expect(actions.runAction('view.tasks')).rejects.toMatchObject({ code: 'unavailable' })
|
||||
await expect(actions.runAction('view.tasks')).rejects.toMatchObject({ code: 'action_failed' })
|
||||
await expect(actions.runAction('view.tasks')).rejects.toMatchObject({ code: 'unavailable' })
|
||||
expect(request).toHaveBeenLastCalledWith({ kind: 'run-action', action: 'view.tasks' })
|
||||
request.mockResolvedValueOnce({ requestId: 'd', ok: true })
|
||||
await actions.openFolderWorkspace('f1')
|
||||
expect(request).toHaveBeenLastCalledWith({
|
||||
kind: 'open-folder-workspace',
|
||||
folderWorkspaceId: 'f1'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
PLUGIN_VIEW_NO_WORKSPACE_CODE,
|
||||
type PluginViewReply,
|
||||
type PluginViewRequestBody
|
||||
} from '../../../shared/plugins/view/plugin-view-protocol'
|
||||
import type { PluginCommandAliasActionId } from '../../../shared/plugins/contributions/plugin-command-actions'
|
||||
import { PluginHostCallError } from '../workers/plugin-host-call-scope'
|
||||
|
||||
/** What a worker's `editor.open` and `commands.run` ask of Orca's window. */
|
||||
export type PluginHostRendererActions = {
|
||||
openFile(worktreeId: string, relativePath: string): Promise<void>
|
||||
runAction(action: PluginCommandAliasActionId): Promise<void>
|
||||
openFolderWorkspace(folderWorkspaceId: string): Promise<void>
|
||||
}
|
||||
|
||||
/** Sends one request to the main window's renderer; null when there is no window to ask. */
|
||||
export type PluginHostRendererRequest = (
|
||||
body: PluginViewRequestBody
|
||||
) => Promise<PluginViewReply> | null
|
||||
|
||||
// Set by the desktop app's view binding (Electron stays out of this module); null in headless serve.
|
||||
let request: PluginHostRendererRequest | null = null
|
||||
|
||||
export function setPluginHostRendererRequest(next: PluginHostRendererRequest | null): void {
|
||||
request = next
|
||||
}
|
||||
|
||||
async function ask(body: PluginViewRequestBody): Promise<void> {
|
||||
const pending = request?.(body) ?? null
|
||||
if (!pending) {
|
||||
throw new PluginHostCallError('unavailable', 'Orca has no window open to do this in')
|
||||
}
|
||||
const reply = await pending.catch((error: unknown) => {
|
||||
throw new PluginHostCallError(
|
||||
'unavailable',
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)
|
||||
})
|
||||
if (!reply.ok) {
|
||||
const unavailable =
|
||||
reply.code === PLUGIN_VIEW_NO_WORKSPACE_CODE || reply.code === 'action_unavailable'
|
||||
throw new PluginHostCallError(unavailable ? 'unavailable' : 'action_failed', reply.message)
|
||||
}
|
||||
}
|
||||
|
||||
export function getPluginHostRendererActions(): PluginHostRendererActions | null {
|
||||
return request
|
||||
? {
|
||||
openFile: (worktreeId, relativePath) =>
|
||||
ask({ kind: 'open-file', worktreeId, relativePath }),
|
||||
runAction: (action) => ask({ kind: 'run-action', action }),
|
||||
openFolderWorkspace: (folderWorkspaceId) =>
|
||||
ask({ kind: 'open-folder-workspace', folderWorkspaceId })
|
||||
}
|
||||
: null
|
||||
}
|
||||
@@ -8,6 +8,10 @@ import {
|
||||
} from '../panels/plugin-frame-guest-diagnostics'
|
||||
import { createPluginViewDesktop } from './plugin-view-desktop'
|
||||
import { isPluginShotWindowShown } from './plugin-shot-policy'
|
||||
import { setPluginHostRendererRequest } from './plugin-host-renderer-actions'
|
||||
import { requestPluginViewFromRenderer } from './plugin-view-renderer-relay'
|
||||
|
||||
const HOST_ACTION_TIMEOUT_MS = 10_000
|
||||
|
||||
/** Guest console lines into plugin logs, and `plugins.open` / `plugins.shot` for the desktop
|
||||
* app; a headless serve leaves open and shot refusing. */
|
||||
@@ -19,8 +23,16 @@ export function bindPluginDesktopViews(
|
||||
setPluginFrameGuestLogSink((pluginKey) => service.captureLog(pluginKey))
|
||||
if (serveMode) {
|
||||
setPluginViewForRpc(null)
|
||||
setPluginHostRendererRequest(null)
|
||||
return
|
||||
}
|
||||
// The one main window, so a worker's editor.open and commands.run always reach the same one.
|
||||
setPluginHostRendererRequest((body) => {
|
||||
const window = getMainWindow()
|
||||
return window && !window.isDestroyed()
|
||||
? requestPluginViewFromRenderer(window.webContents, body, HOST_ACTION_TIMEOUT_MS)
|
||||
: null
|
||||
})
|
||||
setPluginViewForRpc(
|
||||
createPluginViewDesktop({
|
||||
rendererContents: () => {
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
type PluginPanelBridgeServices
|
||||
} from './plugin-host-panel-method-bindings'
|
||||
import { bind, type BoundPluginHostMethod } from './plugin-host-method-bind'
|
||||
import type { PluginHostRendererActions } from '../view/plugin-host-renderer-actions'
|
||||
import {
|
||||
PLUGIN_STATUS_BAR_HANDLERS,
|
||||
type PluginStatusBarServices
|
||||
@@ -89,6 +90,8 @@ export type PluginHostServices = {
|
||||
statusBar?: PluginStatusBarServices | null
|
||||
/** The runtime methods the `orca` CLI calls, for workspace actions; absent without a runtime. */
|
||||
runtimeRpc?: PluginRuntimeRpc | null
|
||||
/** Orca's window, for actions only it can take; absent in headless serve. */
|
||||
renderer?: PluginHostRendererActions | null
|
||||
settings: {
|
||||
getAll(pluginId: string): Record<string, PluginJsonValue>
|
||||
set(
|
||||
|
||||
@@ -4,6 +4,7 @@ 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 type { PluginRuntimeRpc } from './plugin-host-workspace-method-bindings'
|
||||
import type { PluginHostRendererActions } from '../view/plugin-host-renderer-actions'
|
||||
import {
|
||||
pluginSettingValueIssue,
|
||||
type PluginSettingContribution
|
||||
@@ -55,6 +56,7 @@ export function bindPluginHostServices(input: {
|
||||
pluginsDataDir: string
|
||||
desktop?: PluginDesktopFrameServices | null
|
||||
panels?: PluginPanelBridgeServices | null
|
||||
renderer?: PluginHostRendererActions | null
|
||||
/** The calling plugin's `contributes.settings`: defaults and value checks. */
|
||||
declaredSettings?: readonly PluginSettingContribution[]
|
||||
statusBar?: PluginStatusBarState | null
|
||||
@@ -71,6 +73,7 @@ export function bindPluginHostServices(input: {
|
||||
? statusBarServices(input.statusBar, input.declaredStatusBarItemIds, input.statusBarRevision)
|
||||
: null,
|
||||
runtimeRpc: delegate.callRuntime ?? null,
|
||||
renderer: input.renderer ?? null,
|
||||
resolveActiveWorktreeContext: async () => {
|
||||
const context = await delegate.resolveActiveWorktreeContext()
|
||||
if (!context) {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from './plugin-host-method-bind'
|
||||
import type { PluginHostServices } from './plugin-host-method-bindings'
|
||||
import { PluginHostCallError } from './plugin-host-call-scope'
|
||||
import type { PluginHostRendererActions } from '../view/plugin-host-renderer-actions'
|
||||
import { folderWorkspaceKey, parseWorkspaceKey } from '../../../shared/workspace-scope'
|
||||
|
||||
/** The runtime RPC the `orca` CLI calls, run in process; rejects with the runtime's error.
|
||||
@@ -65,6 +66,13 @@ const agentLaunchAnswer = z.object({
|
||||
outcome: z.object({ kind: z.enum(['structured', 'terminal']), handle: z.string() })
|
||||
})
|
||||
|
||||
function renderer(services: PluginHostServices): PluginHostRendererActions {
|
||||
if (!services.renderer) {
|
||||
throw new PluginHostCallError('unavailable', 'only the Orca desktop app can do this')
|
||||
}
|
||||
return services.renderer
|
||||
}
|
||||
|
||||
function rpc(services: PluginHostServices): PluginRuntimeRpc {
|
||||
if (!services.runtimeRpc) {
|
||||
throw new PluginHostCallError('unavailable', 'this Orca host cannot run workspace actions')
|
||||
@@ -141,9 +149,12 @@ export const PLUGIN_WORKSPACE_ACTION_HANDLERS = {
|
||||
}
|
||||
}),
|
||||
'workspace.open': bind(api['workspace.open'], async ({ workspaceId }, ctx) => {
|
||||
if (parseWorkspaceKey(workspaceId)?.type === 'folder') {
|
||||
// The runtime activates Git worktrees only; a folder is shown by Orca's window.
|
||||
throw new PluginHostCallError('unavailable', 'this Orca cannot open folder workspaces yet')
|
||||
const scope = parseWorkspaceKey(workspaceId)
|
||||
if (scope?.type === 'folder') {
|
||||
// The runtime activates Git worktrees only; Orca's window shows a folder workspace.
|
||||
ctx.scope.assertActive()
|
||||
await renderer(ctx.services).openFolderWorkspace(scope.folderWorkspaceId)
|
||||
return { opened: true as const }
|
||||
}
|
||||
await effect(ctx, 'worktree.activate', { worktree: workspaceSelector(workspaceId) })
|
||||
return { opened: true as const }
|
||||
@@ -200,5 +211,17 @@ export const PLUGIN_WORKSPACE_ACTION_HANDLERS = {
|
||||
})
|
||||
}
|
||||
return { terminalId }
|
||||
}),
|
||||
'editor.open': bind(api['editor.open'], async ({ workspaceId, path }, { services, scope }) => {
|
||||
scope.assertActive()
|
||||
await renderer(services).openFile(workspaceId, path)
|
||||
return { opened: true as const }
|
||||
}),
|
||||
'commands.run': bind(api['commands.run'], async ({ action }, { services, scope }) => {
|
||||
scope.assertActive()
|
||||
// Always Orca's own action, never a plugin command that replaces it: a replacement that
|
||||
// calls commands.run for the original would otherwise call itself.
|
||||
await renderer(services).runAction(action)
|
||||
return { ran: true as const }
|
||||
})
|
||||
} satisfies Record<string, BoundPluginHostMethod>
|
||||
|
||||
@@ -3,8 +3,12 @@ import type { PluginCapabilityKind } from '../../../shared/plugins/capabilities/
|
||||
import { executePluginHostCall, type PluginHostServices } from './plugin-host-methods'
|
||||
import { pluginHostCallRevoked, type PluginHostCallScope } from './plugin-host-call-scope'
|
||||
import type { PluginRuntimeRpc } from './plugin-host-workspace-method-bindings'
|
||||
import type { PluginHostRendererActions } from '../view/plugin-host-renderer-actions'
|
||||
|
||||
function services(runtimeRpc: PluginRuntimeRpc | null): PluginHostServices {
|
||||
function services(
|
||||
runtimeRpc: PluginRuntimeRpc | null,
|
||||
renderer: PluginHostRendererActions | null = null
|
||||
): PluginHostServices {
|
||||
return {
|
||||
resolveActiveWorktreeContext: vi.fn().mockResolvedValue(null),
|
||||
listWorktreeTerminals: vi.fn().mockResolvedValue([]),
|
||||
@@ -19,7 +23,8 @@ function services(runtimeRpc: PluginRuntimeRpc | null): PluginHostServices {
|
||||
protection: vi.fn().mockReturnValue('unavailable')
|
||||
},
|
||||
settings: { getAll: vi.fn().mockReturnValue({}), set: vi.fn() },
|
||||
runtimeRpc
|
||||
runtimeRpc,
|
||||
renderer
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +61,7 @@ function call(
|
||||
capabilities?: PluginCapabilityKind[]
|
||||
surface?: 'worker' | 'panel'
|
||||
scope?: PluginHostCallScope
|
||||
renderer?: PluginHostRendererActions | null
|
||||
} = {}
|
||||
) {
|
||||
return executePluginHostCall({
|
||||
@@ -64,7 +70,7 @@ function call(
|
||||
params,
|
||||
surface: options.surface ?? 'worker',
|
||||
grantedCapabilities: options.capabilities ?? ALL,
|
||||
services: services(rpc),
|
||||
services: services(rpc, options.renderer ?? null),
|
||||
audit: { record: vi.fn().mockResolvedValue(undefined) },
|
||||
...(options.scope ? { scope: options.scope } : {})
|
||||
})
|
||||
@@ -248,4 +254,54 @@ describe('workspace host actions', () => {
|
||||
await flush()
|
||||
await expect(call('workspace.list', undefined, null)).resolves.toMatchObject({ ok: false })
|
||||
})
|
||||
|
||||
it('editor.open asks the window to open a file inside the workspace', async () => {
|
||||
const renderer = {
|
||||
openFile: vi.fn().mockResolvedValue(undefined),
|
||||
runAction: vi.fn(),
|
||||
openFolderWorkspace: vi.fn()
|
||||
}
|
||||
await expect(
|
||||
call('editor.open', { workspaceId: 'app', path: 'src/main.ts' }, null, { renderer })
|
||||
).resolves.toEqual({ ok: true, value: { opened: true } })
|
||||
expect(renderer.openFile).toHaveBeenCalledWith('app', 'src/main.ts')
|
||||
for (const path of ['../secret', '/etc/passwd', 'C:\\x', 'a/../../b', '.']) {
|
||||
await expect(
|
||||
call('editor.open', { workspaceId: 'app', path }, null, { renderer })
|
||||
).resolves.toMatchObject({ ok: false, code: 'invalid_params' })
|
||||
}
|
||||
expect(renderer.openFile).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("commands.run runs only built-in actions, and needs Orca's window", async () => {
|
||||
const renderer = {
|
||||
openFile: vi.fn(),
|
||||
runAction: vi.fn().mockResolvedValue(undefined),
|
||||
openFolderWorkspace: vi.fn()
|
||||
}
|
||||
await expect(
|
||||
call('commands.run', { action: 'sidebar.left.toggle' }, null, { renderer })
|
||||
).resolves.toEqual({ ok: true, value: { ran: true } })
|
||||
expect(renderer.runAction).toHaveBeenCalledWith('sidebar.left.toggle')
|
||||
await expect(
|
||||
call('commands.run', { action: 'editor.save' }, null, { renderer })
|
||||
).resolves.toMatchObject({ ok: false, code: 'invalid_params' })
|
||||
await expect(
|
||||
call('commands.run', { action: 'sidebar.left.toggle' }, null)
|
||||
).resolves.toMatchObject({ ok: false, code: 'unavailable' })
|
||||
})
|
||||
|
||||
it("workspace.open shows a folder workspace through Orca's window", async () => {
|
||||
const renderer = {
|
||||
openFile: vi.fn(),
|
||||
runAction: vi.fn(),
|
||||
openFolderWorkspace: vi.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
const { rpc, calls } = runtime({})
|
||||
await expect(
|
||||
call('workspace.open', { workspaceId: 'folder:f1' }, rpc, { renderer })
|
||||
).resolves.toEqual({ ok: true, value: { opened: true } })
|
||||
expect(renderer.openFolderWorkspace).toHaveBeenCalledWith('f1')
|
||||
expect(calls).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -37,7 +37,7 @@ export function pluginCapabilityDescription(kind: string, fallback: string): str
|
||||
case 'workspace:write':
|
||||
return translate(
|
||||
'auto.components.settings.PluginConsentDialog.capability.workspaceWrite',
|
||||
'Open your workspaces'
|
||||
'Open your workspaces and files, and run built-in commands'
|
||||
)
|
||||
case 'agent:launch':
|
||||
return translate(
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAppStore } from '@/store'
|
||||
import { registerAppCommandDispatcher } from '@/lib/app-command-dispatch'
|
||||
import { handlePluginViewRequest } from './plugin-view-request-handler'
|
||||
|
||||
const initialState = useAppStore.getState()
|
||||
|
||||
afterEach(() => {
|
||||
useAppStore.setState(initialState, true)
|
||||
})
|
||||
|
||||
function seedWorkspaces(
|
||||
openFile: ReturnType<typeof vi.fn>,
|
||||
setActiveFolderWorkspace: ReturnType<typeof vi.fn> = vi.fn()
|
||||
): void {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler reads only id and path of worktrees and id and folderPath of folders.
|
||||
useAppStore.setState({
|
||||
openFile,
|
||||
worktreesByRepo: {
|
||||
r1: [{ id: 'r1::/repo', path: '/repo', identity: { key: 'wt2:local:i1' } }]
|
||||
},
|
||||
setActiveFolderWorkspace,
|
||||
folderWorkspaces: [{ id: 'f1', folderPath: 'C:\\notes' }]
|
||||
} as unknown as Partial<ReturnType<typeof useAppStore.getState>>)
|
||||
}
|
||||
|
||||
describe('open-file', () => {
|
||||
it('opens a workspace file by its path inside the workspace, folders included', async () => {
|
||||
const openFile = vi.fn().mockReturnValue('file-1')
|
||||
seedWorkspaces(openFile)
|
||||
await expect(
|
||||
handlePluginViewRequest({
|
||||
requestId: 'r',
|
||||
kind: 'open-file',
|
||||
worktreeId: 'r1::/repo',
|
||||
relativePath: 'src/main.ts'
|
||||
})
|
||||
).resolves.toEqual({ requestId: 'r', ok: true })
|
||||
expect(openFile).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
filePath: '/repo/src/main.ts',
|
||||
relativePath: 'src/main.ts',
|
||||
worktreeId: 'r1::/repo',
|
||||
mode: 'edit'
|
||||
}),
|
||||
{ preview: false }
|
||||
)
|
||||
await handlePluginViewRequest({
|
||||
requestId: 'f',
|
||||
kind: 'open-file',
|
||||
worktreeId: 'folder:f1',
|
||||
relativePath: 'todo/today.md'
|
||||
})
|
||||
expect(openFile).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ filePath: 'C:\\notes\\todo\\today.md', worktreeId: 'folder:f1' }),
|
||||
{ preview: false }
|
||||
)
|
||||
})
|
||||
|
||||
it('takes the host-naming identity id workspace.list returns', async () => {
|
||||
const openFile = vi.fn().mockReturnValue('file-1')
|
||||
seedWorkspaces(openFile)
|
||||
await handlePluginViewRequest({
|
||||
requestId: 'i',
|
||||
kind: 'open-file',
|
||||
worktreeId: 'identity:wt2:local:i1',
|
||||
relativePath: 'a.ts'
|
||||
})
|
||||
expect(openFile).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ filePath: '/repo/a.ts', worktreeId: 'r1::/repo' }),
|
||||
{ preview: false }
|
||||
)
|
||||
})
|
||||
|
||||
it('shows a folder workspace for workspace.open', async () => {
|
||||
const setActiveFolderWorkspace = vi.fn()
|
||||
seedWorkspaces(vi.fn(), setActiveFolderWorkspace)
|
||||
await expect(
|
||||
handlePluginViewRequest({
|
||||
requestId: 'o',
|
||||
kind: 'open-folder-workspace',
|
||||
folderWorkspaceId: 'f1'
|
||||
})
|
||||
).resolves.toEqual({ requestId: 'o', ok: true })
|
||||
expect(setActiveFolderWorkspace).toHaveBeenCalledWith('f1')
|
||||
await expect(
|
||||
handlePluginViewRequest({
|
||||
requestId: 'p',
|
||||
kind: 'open-folder-workspace',
|
||||
folderWorkspaceId: 'zz'
|
||||
})
|
||||
).resolves.toMatchObject({ ok: false, code: 'workspace_not_found' })
|
||||
})
|
||||
|
||||
it('refuses a workspace this window does not list', async () => {
|
||||
seedWorkspaces(vi.fn())
|
||||
await expect(
|
||||
handlePluginViewRequest({
|
||||
requestId: 'x',
|
||||
kind: 'open-file',
|
||||
worktreeId: 'gone',
|
||||
relativePath: 'a.ts'
|
||||
})
|
||||
).resolves.toMatchObject({ ok: false, code: 'workspace_not_found' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('run-action', () => {
|
||||
it("runs the window's own handler for the built-in action", async () => {
|
||||
const dispatcher = vi.fn().mockReturnValue(true)
|
||||
const unregister = registerAppCommandDispatcher(dispatcher)
|
||||
try {
|
||||
await expect(
|
||||
handlePluginViewRequest({
|
||||
requestId: 'a',
|
||||
kind: 'run-action',
|
||||
action: 'sidebar.left.toggle'
|
||||
})
|
||||
).resolves.toEqual({ requestId: 'a', ok: true })
|
||||
expect(dispatcher).toHaveBeenCalledWith('sidebar.left.toggle', 'plugin-host')
|
||||
dispatcher.mockReturnValue(false)
|
||||
await expect(
|
||||
handlePluginViewRequest({ requestId: 'b', kind: 'run-action', action: 'view.tasks' })
|
||||
).resolves.toMatchObject({ ok: false, code: 'action_unavailable' })
|
||||
} finally {
|
||||
unregister()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,92 @@
|
||||
import type {
|
||||
PluginViewReply,
|
||||
PluginViewRequest
|
||||
} from '../../../../../shared/plugins/view/plugin-view-protocol'
|
||||
import { detectLanguage } from '@/lib/language-detect'
|
||||
import { dispatchAppCommand } from '@/lib/app-command-dispatch'
|
||||
import { useAppStore } from '@/store'
|
||||
import { getIndexedWorktreeMap } from '@/store/worktree-repo-index'
|
||||
import { selectFolderWorkspaces, selectWorktreesByRepo } from '@/store/workspace-record-selectors'
|
||||
import { parseWorkspaceKey } from '../../../../../shared/workspace-scope'
|
||||
|
||||
/** A plugin worker's `editor.open` and `commands.run`, run in this window. */
|
||||
|
||||
const fail = (requestId: string, code: string, message: string): PluginViewReply => ({
|
||||
requestId,
|
||||
ok: false,
|
||||
code,
|
||||
message
|
||||
})
|
||||
|
||||
/** The workspace path and a relative one joined with the workspace's own separator. */
|
||||
function workspaceFilePath(worktreePath: string, relativePath: string): string {
|
||||
const separator = worktreePath.includes('\\') && !worktreePath.includes('/') ? '\\' : '/'
|
||||
const parts = relativePath.split(/[\\/]/).filter((part) => part !== '' && part !== '.')
|
||||
return [worktreePath.replace(/[\\/]+$/, ''), ...parts].join(separator)
|
||||
}
|
||||
|
||||
/** A `workspace.list` id (a worktree id, its host-naming `identity:` key, or a folder key) as
|
||||
* this window's workspace id and folder path. */
|
||||
function resolveWorkspace(workspaceId: string): { id: string; root: string } | null {
|
||||
const state = useAppStore.getState()
|
||||
const scope = parseWorkspaceKey(workspaceId)
|
||||
if (scope?.type === 'folder') {
|
||||
const folder = selectFolderWorkspaces(state)?.find(
|
||||
(entry) => entry.id === scope.folderWorkspaceId
|
||||
)
|
||||
return folder ? { id: workspaceId, root: folder.folderPath } : null
|
||||
}
|
||||
const worktrees = selectWorktreesByRepo(state)
|
||||
const identity = workspaceId.startsWith('identity:') ? workspaceId.slice(9) : null
|
||||
const worktree = identity
|
||||
? Object.values(worktrees)
|
||||
.flat()
|
||||
.find((entry) => entry.identity?.key === identity)
|
||||
: getIndexedWorktreeMap(worktrees).get(workspaceId)
|
||||
return worktree ? { id: worktree.id, root: worktree.path } : null
|
||||
}
|
||||
|
||||
export function openWorkspaceFile(
|
||||
request: Extract<PluginViewRequest, { kind: 'open-file' }>
|
||||
): PluginViewReply {
|
||||
const { requestId, worktreeId, relativePath } = request
|
||||
const workspace = resolveWorkspace(worktreeId)
|
||||
if (!workspace) {
|
||||
return fail(requestId, 'workspace_not_found', `This window does not list ${worktreeId}.`)
|
||||
}
|
||||
const filePath = workspaceFilePath(workspace.root, relativePath)
|
||||
useAppStore.getState().openFile(
|
||||
{
|
||||
filePath,
|
||||
relativePath: relativePath.split(/[\\/]/).filter(Boolean).join('/'),
|
||||
worktreeId: workspace.id,
|
||||
language: detectLanguage(filePath),
|
||||
mode: 'edit'
|
||||
},
|
||||
{ preview: false }
|
||||
)
|
||||
return { requestId, ok: true }
|
||||
}
|
||||
|
||||
/** `workspace.open` of a folder workspace: the runtime activates Git worktrees only. */
|
||||
export function openFolderWorkspace(
|
||||
request: Extract<PluginViewRequest, { kind: 'open-folder-workspace' }>
|
||||
): PluginViewReply {
|
||||
const { requestId, folderWorkspaceId } = request
|
||||
const state = useAppStore.getState()
|
||||
if (!selectFolderWorkspaces(state)?.some((folder) => folder.id === folderWorkspaceId)) {
|
||||
return fail(requestId, 'workspace_not_found', `This window does not list that folder.`)
|
||||
}
|
||||
state.setActiveFolderWorkspace(folderWorkspaceId)
|
||||
return { requestId, ok: true }
|
||||
}
|
||||
|
||||
// Rule: the raw built-in handler, never a wrapper that runs a plugin's replacement first, or a
|
||||
// replacement that calls commands.run for Orca's own version would call itself.
|
||||
export function runBuiltInAction(
|
||||
request: Extract<PluginViewRequest, { kind: 'run-action' }>
|
||||
): PluginViewReply {
|
||||
return dispatchAppCommand(request.action, 'plugin-host')
|
||||
? { requestId: request.requestId, ok: true }
|
||||
: fail(request.requestId, 'action_unavailable', `${request.action} cannot run in this view.`)
|
||||
}
|
||||
@@ -33,6 +33,11 @@ import { captureOpenEditor } from './plugin-view-editor-capture'
|
||||
import { planEditorShotCleanup, runEditorShotCleanup } from './plugin-view-editor-shot-cleanup'
|
||||
import { captureChrome } from './plugin-view-chrome-capture'
|
||||
import { pickWorkspaceToShow } from './plugin-view-workspace-pick'
|
||||
import {
|
||||
openFolderWorkspace,
|
||||
openWorkspaceFile,
|
||||
runBuiltInAction
|
||||
} from './plugin-view-host-actions'
|
||||
|
||||
type Failure = Extract<PluginViewReply, { ok: false }>
|
||||
|
||||
@@ -226,6 +231,12 @@ export async function handlePluginViewRequest(
|
||||
return await replayPluginPanel(request)
|
||||
case 'capture-chrome':
|
||||
return await captureChrome(request)
|
||||
case 'open-file':
|
||||
return openWorkspaceFile(request)
|
||||
case 'run-action':
|
||||
return runBuiltInAction(request)
|
||||
case 'open-folder-workspace':
|
||||
return openFolderWorkspace(request)
|
||||
case 'end-capture': {
|
||||
endPluginShotReplay()
|
||||
usePluginViewCaptureStore.getState().end(request.captureId)
|
||||
|
||||
@@ -12030,7 +12030,7 @@
|
||||
"agentsRead": "Get notified when agent status changes",
|
||||
"terminalRun": "Run commands in your terminals, including SSH",
|
||||
"terminalRead": "Read the output of your terminals, including SSH",
|
||||
"workspaceWrite": "Open your workspaces",
|
||||
"workspaceWrite": "Open your workspaces and files, and run built-in commands",
|
||||
"agentLaunch": "Start agents in your workspaces, including SSH"
|
||||
},
|
||||
"decisionFailed": "Could not save the permission decision. Try again.",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { isKeybindingActionId, type KeybindingActionId } from '../../../shared/keybindings'
|
||||
|
||||
export type AppCommandSource = 'plugin-keybinding' | 'plugin-palette'
|
||||
export type AppCommandSource = 'plugin-keybinding' | 'plugin-palette' | 'plugin-host'
|
||||
export type AppCommandDispatcher = (
|
||||
actionId: KeybindingActionId,
|
||||
source: AppCommandSource
|
||||
@@ -17,6 +17,7 @@ export function registerAppCommandDispatcher(dispatcher: AppCommandDispatcher):
|
||||
}
|
||||
}
|
||||
|
||||
/** Runs Orca's own handler for the action; plugin commands that replace it never run here. */
|
||||
export function dispatchAppCommand(actionId: string, source: AppCommandSource): boolean {
|
||||
return isKeybindingActionId(actionId) && currentDispatcher !== null
|
||||
? currentDispatcher(actionId, source)
|
||||
|
||||
@@ -65,7 +65,7 @@ export type PluginCapability = z.infer<typeof pluginCapabilitySchema>
|
||||
export const PLUGIN_CAPABILITY_DESCRIPTIONS: Record<PluginCapabilityKind, string> = {
|
||||
'workspace:read':
|
||||
'Read the name, branch, and terminal list of your focused workspace, and get notified when workspaces are created or removed',
|
||||
'workspace:write': 'Open your workspaces in Orca',
|
||||
'workspace:write': 'Open your workspaces and their files in Orca, and run its built-in commands',
|
||||
'agent:read': 'Get notified when agent status changes',
|
||||
'agent:launch': 'Start agents in your workspaces, local and SSH',
|
||||
'terminal:send': 'Type and run commands in your terminals, local and SSH',
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import type { PluginDeclarativeNode } from '../declarative/plugin-declarative-catalog'
|
||||
import type { PluginShotAct, PluginShotActResult } from './plugin-shot-acts'
|
||||
import type { PluginCommandAliasActionId } from '../contributions/plugin-command-actions'
|
||||
|
||||
export const PLUGIN_VIEW_REQUEST_CHANNEL = 'plugins:viewRequest'
|
||||
export const PLUGIN_VIEW_REPLY_CHANNEL = 'plugins:viewReply'
|
||||
@@ -70,6 +71,12 @@ export type PluginViewRequestBody =
|
||||
}
|
||||
/** Puts back the width and theme a capture changed, and undoes editor capture `captureId`. */
|
||||
| { kind: 'end-capture'; captureId?: string }
|
||||
/** A plugin worker's `editor.open`: a workspace file, by path inside the workspace. */
|
||||
| { kind: 'open-file'; worktreeId: string; relativePath: string }
|
||||
/** A plugin worker's `commands.run`: always the built-in action, never a plugin's override. */
|
||||
| { kind: 'run-action'; action: PluginCommandAliasActionId }
|
||||
/** A plugin worker's `workspace.open` of a folder workspace, which the runtime cannot activate. */
|
||||
| { kind: 'open-folder-workspace'; folderWorkspaceId: string }
|
||||
|
||||
export type PluginViewReplay = {
|
||||
tree: PluginDeclarativeNode | null
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { z } from 'zod'
|
||||
import { isTuiAgent } from '../../tui-agent-config'
|
||||
import { PLUGIN_COMMAND_ALIAS_ACTION_IDS } from '../contributions/plugin-command-actions'
|
||||
import { spec, WORKER_ONLY } from './plugin-host-method-spec'
|
||||
|
||||
/**
|
||||
@@ -74,6 +75,34 @@ const agentLaunchResult = z.object({
|
||||
terminalId
|
||||
})
|
||||
|
||||
/** A `/`-separated path inside the workspace: not absolute, no `..`, no drive letter. */
|
||||
export function isPluginWorkspaceRelativePath(path: string): boolean {
|
||||
const segments = path.split(/[\\/]/)
|
||||
return (
|
||||
!/^[a-z]:/i.test(path) &&
|
||||
segments[0] !== '' &&
|
||||
segments.every((segment) => segment !== '..') &&
|
||||
segments.some((segment) => segment !== '' && segment !== '.')
|
||||
)
|
||||
}
|
||||
|
||||
const editorOpenParams = z.strictObject({
|
||||
workspaceId,
|
||||
/** Relative to the workspace, `/`-separated; it must stay inside it. */
|
||||
path: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(ID_MAX)
|
||||
.refine(isPluginWorkspaceRelativePath, 'path must be relative and inside the workspace')
|
||||
})
|
||||
const editorOpenResult = z.object({ opened: z.literal(true) })
|
||||
|
||||
const commandsRunParams = z.strictObject({
|
||||
/** A built-in action, the same list `contributes.commands[].action` takes. */
|
||||
action: z.enum(PLUGIN_COMMAND_ALIAS_ACTION_IDS)
|
||||
})
|
||||
const commandsRunResult = z.object({ ran: z.literal(true) })
|
||||
|
||||
export const PLUGIN_WORKSPACE_ACTIONS_API = {
|
||||
'workspace.list': spec({
|
||||
summary: 'Lists every workspace, Git worktrees and folders, on every host Orca reaches.',
|
||||
@@ -86,7 +115,8 @@ export const PLUGIN_WORKSPACE_ACTIONS_API = {
|
||||
result: workspaceListResult
|
||||
}),
|
||||
'workspace.open': spec({
|
||||
summary: 'Shows a workspace in Orca, as clicking it in the sidebar does.',
|
||||
summary:
|
||||
'Shows a workspace in Orca, as clicking it in the sidebar does; a folder workspace needs the desktop app.',
|
||||
since: 3,
|
||||
scope: 'any-workspace',
|
||||
capability: 'workspace:write',
|
||||
@@ -125,5 +155,27 @@ export const PLUGIN_WORKSPACE_ACTIONS_API = {
|
||||
surfaces: WORKER_ONLY,
|
||||
params: agentLaunchParams,
|
||||
result: agentLaunchResult
|
||||
}),
|
||||
'editor.open': spec({
|
||||
summary:
|
||||
"Opens a file of a workspace in an editor tab of Orca's window; desktop app only. `workspace.open` shows the workspace.",
|
||||
since: 3,
|
||||
scope: 'any-workspace',
|
||||
capability: 'workspace:write',
|
||||
mutation: true,
|
||||
surfaces: WORKER_ONLY,
|
||||
params: editorOpenParams,
|
||||
result: editorOpenResult
|
||||
}),
|
||||
'commands.run': spec({
|
||||
summary:
|
||||
"Runs one of Orca's built-in actions in its window, never a plugin's replacement of it; desktop app only.",
|
||||
since: 3,
|
||||
scope: 'desktop',
|
||||
capability: 'workspace:write',
|
||||
mutation: true,
|
||||
surfaces: WORKER_ONLY,
|
||||
params: commandsRunParams,
|
||||
result: commandsRunResult
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user