mirror of
https://github.com/stablyai/orca.git
synced 2026-10-25 16:03:53 +00:00
feat(plugins): add consent settings and demo workflow
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
// Sample Orca plugin worker entry. Runs inside the out-of-process plugin
|
||||
// worker (plain Node, no Electron), forked lazily on the first trigger. The
|
||||
// default export receives the `orca` API: command registration, event
|
||||
// handlers, and the capability-gated host API.
|
||||
export default function activate(orca) {
|
||||
orca.commands.register('hello-ping', async (args) => {
|
||||
const stored = await orca.host.call('storage.get', { key: 'pings' })
|
||||
const count = (typeof stored?.value === 'number' ? stored.value : 0) + 1
|
||||
await orca.host.call('storage.set', { key: 'pings', value: count })
|
||||
return { pong: true, count, args: args ?? null }
|
||||
})
|
||||
|
||||
orca.events.on('worktree.created', async (payload) => {
|
||||
orca.log(`worktree created: ${payload.worktreeId} at ${payload.path}`)
|
||||
await orca.host.call('notifications.show', {
|
||||
title: 'Worktree created',
|
||||
body: payload.path
|
||||
})
|
||||
})
|
||||
|
||||
orca.events.on('agent.status.changed', (payload) => {
|
||||
orca.log(`agent status: ${payload.state} in ${payload.worktreeId ?? 'unknown worktree'}`)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"manifestVersion": 1,
|
||||
"id": "hello-orca",
|
||||
"publisher": "orca-samples",
|
||||
"name": "Hello Orca",
|
||||
"version": "1.0.0",
|
||||
"description": "Sample plugin combining a sandboxed panel, a worker command, and event subscriptions.",
|
||||
"engines": { "orca": ">=1.4.0" },
|
||||
"pluginApi": 1,
|
||||
"main": "main.mjs",
|
||||
"contributes": {
|
||||
"panels": [{ "id": "hello", "title": "Hello Orca", "icon": "plug", "entry": "panel.html" }],
|
||||
"commands": [{ "id": "hello-ping", "title": "Hello: Ping" }],
|
||||
"events": [{ "on": "worktree.created" }, { "on": "agent.status.changed" }]
|
||||
},
|
||||
"capabilities": [
|
||||
{ "kind": "workspace:read" },
|
||||
{ "kind": "terminal:send" },
|
||||
{ "kind": "notifications:show" },
|
||||
{ "kind": "storage" },
|
||||
{ "kind": "events:subscribe" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<style>
|
||||
/* The host injects Orca design tokens as CSS custom properties, so
|
||||
panels can match the app without any access to it. */
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
font-family: system-ui, sans-serif;
|
||||
font-size: 13px;
|
||||
color: var(--foreground, #ddd);
|
||||
background: var(--background, transparent);
|
||||
}
|
||||
h1 {
|
||||
font-size: 14px;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
button {
|
||||
display: block;
|
||||
margin: 6px 0;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--border, #555);
|
||||
border-radius: 6px;
|
||||
background: var(--secondary, #2a2a2a);
|
||||
color: var(--foreground, #ddd);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
select {
|
||||
margin: 6px 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
.status {
|
||||
margin-top: 10px;
|
||||
min-height: 1.4em;
|
||||
color: var(--muted-foreground, #999);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Hello Orca 👋</h1>
|
||||
<p>Panel + worker command + events, gated by consent.</p>
|
||||
<button id="ctx">Read workspace context</button>
|
||||
<select id="terms" hidden></select>
|
||||
<button id="send" hidden>Type "echo hi from plugin" into selected terminal</button>
|
||||
<button id="notify">Show a notification</button>
|
||||
<p class="status" id="status"></p>
|
||||
<script>
|
||||
'use strict'
|
||||
var seq = 0
|
||||
var pending = {}
|
||||
var statusEl = document.getElementById('status')
|
||||
var termsEl = document.getElementById('terms')
|
||||
|
||||
function call(action, params) {
|
||||
return new Promise(function (resolve) {
|
||||
var requestId = 'req-' + ++seq
|
||||
pending[requestId] = resolve
|
||||
// The panel frame is an opaque origin, so '*' is the only usable
|
||||
// targetOrigin; the host verifies the sending window instead.
|
||||
window.parent.postMessage(
|
||||
{ type: 'orca-panel-action', requestId: requestId, action: action, params: params },
|
||||
'*'
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
window.addEventListener('message', function (event) {
|
||||
var data = event.data
|
||||
if (!data || data.type !== 'orca-panel-action-result') return
|
||||
var resolve = pending[data.requestId]
|
||||
if (!resolve) return
|
||||
delete pending[data.requestId]
|
||||
resolve(data)
|
||||
})
|
||||
|
||||
document.getElementById('ctx').addEventListener('click', function () {
|
||||
call('workspace.readContext').then(function (result) {
|
||||
if (!result.ok) {
|
||||
statusEl.textContent = 'context failed: ' + (result.error || result.errorCode)
|
||||
return
|
||||
}
|
||||
if (!result.value) {
|
||||
statusEl.textContent = 'no focused worktree'
|
||||
return
|
||||
}
|
||||
statusEl.textContent = result.value.displayName + ' on branch ' + result.value.branch
|
||||
termsEl.innerHTML = ''
|
||||
result.value.terminals.forEach(function (terminal) {
|
||||
var option = document.createElement('option')
|
||||
option.value = terminal.id
|
||||
option.textContent = terminal.id
|
||||
termsEl.appendChild(option)
|
||||
})
|
||||
termsEl.hidden = result.value.terminals.length === 0
|
||||
document.getElementById('send').hidden = result.value.terminals.length === 0
|
||||
})
|
||||
})
|
||||
|
||||
document.getElementById('send').addEventListener('click', function () {
|
||||
// Explicit terminal id — the API has no "active terminal" target.
|
||||
call('terminal.sendText', {
|
||||
terminalId: termsEl.value,
|
||||
text: 'echo hi from plugin',
|
||||
enter: true
|
||||
}).then(function (result) {
|
||||
statusEl.textContent = result.ok
|
||||
? 'typed (accepted=' + result.value.accepted + ')'
|
||||
: 'send failed: ' + (result.error || result.errorCode)
|
||||
})
|
||||
})
|
||||
|
||||
document.getElementById('notify').addEventListener('click', function () {
|
||||
call('notifications.show', { title: 'Hello from the panel' }).then(function (result) {
|
||||
statusEl.textContent = result.ok
|
||||
? 'notification delivered=' + result.value.delivered
|
||||
: 'notify failed: ' + (result.error || result.errorCode)
|
||||
})
|
||||
})
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,168 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act, useState } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { PluginHostListEntry } from '../../../../preload/api-types'
|
||||
import { PluginConsentDialog } from './PluginConsentDialog'
|
||||
|
||||
const plugin: PluginHostListEntry = {
|
||||
pluginKey: 'acme.worker',
|
||||
consentFingerprint: 'sha256-acme-worker',
|
||||
name: 'Acme Worker',
|
||||
version: '1.2.3',
|
||||
publisher: 'acme',
|
||||
status: 'pending',
|
||||
needsReconsent: false,
|
||||
isDev: false,
|
||||
capabilities: [{ kind: 'worker', description: 'Run a background worker process' }],
|
||||
panels: [],
|
||||
commands: [],
|
||||
hasWorker: true,
|
||||
restarts: 0,
|
||||
source: {
|
||||
kind: 'git',
|
||||
reference: 'https://gitlab.example/acme/worker#v1.2.3',
|
||||
resolvedCommit: '0123456789abcdef',
|
||||
contentHash: 'sha256'
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
async function renderConsent(
|
||||
entry: PluginHostListEntry,
|
||||
onDecision: (
|
||||
key: string,
|
||||
reviewedFingerprint: string,
|
||||
decision: 'approve' | 'keep-disabled'
|
||||
) => Promise<void>
|
||||
): Promise<void> {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const root = createRoot(container)
|
||||
function Harness(): React.JSX.Element {
|
||||
const [selected, setSelected] = useState<PluginHostListEntry | null>(entry)
|
||||
return (
|
||||
<PluginConsentDialog
|
||||
plugin={selected}
|
||||
onDecision={async (key, reviewedFingerprint, decision) => {
|
||||
await onDecision(key, reviewedFingerprint, decision)
|
||||
setSelected(null)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
await act(async () => root.render(<Harness />))
|
||||
}
|
||||
|
||||
describe('PluginConsentDialog', () => {
|
||||
it('keeps the displayed fingerprint immutable during a same-key update', async () => {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const root = createRoot(container)
|
||||
const onDecision = vi.fn().mockResolvedValue(undefined)
|
||||
await act(async () => {
|
||||
root.render(<PluginConsentDialog plugin={plugin} onDecision={onDecision} />)
|
||||
})
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<PluginConsentDialog
|
||||
plugin={{
|
||||
...plugin,
|
||||
consentFingerprint: 'sha256-unreviewed-update',
|
||||
capabilities: [{ kind: 'secrets:read', description: 'Read a newly added secret' }]
|
||||
}}
|
||||
onDecision={onDecision}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
expect(document.body.textContent).not.toContain('Read a newly added secret')
|
||||
const enable = Array.from(document.querySelectorAll('button')).find(
|
||||
(candidate) => candidate.textContent?.trim() === 'Enable plugin'
|
||||
)
|
||||
await act(async () => enable?.dispatchEvent(new MouseEvent('click', { bubbles: true })))
|
||||
|
||||
expect(onDecision).toHaveBeenCalledWith(plugin.pluginKey, plugin.consentFingerprint, 'approve')
|
||||
})
|
||||
|
||||
it('shows provenance, capabilities, worker warning, and focuses the safe default', async () => {
|
||||
await renderConsent(plugin, vi.fn().mockResolvedValue(undefined))
|
||||
|
||||
expect(document.body.textContent).toContain(plugin.source?.reference)
|
||||
expect(document.body.textContent).toContain(plugin.source?.resolvedCommit)
|
||||
expect(document.body.textContent).toContain('Run a background worker process')
|
||||
expect(document.body.textContent).toContain(
|
||||
'full access to your files, network, and other processes'
|
||||
)
|
||||
expect(document.activeElement?.textContent).toContain('Keep Disabled')
|
||||
})
|
||||
|
||||
it('explains that panel-only plugins have no worker process', async () => {
|
||||
await renderConsent(
|
||||
{
|
||||
...plugin,
|
||||
pluginKey: 'acme.panel',
|
||||
name: 'Acme Panel',
|
||||
hasWorker: false,
|
||||
capabilities: [{ kind: 'panels', description: 'Add an Acme panel' }]
|
||||
},
|
||||
vi.fn().mockResolvedValue(undefined)
|
||||
)
|
||||
|
||||
expect(document.body.textContent).toContain(
|
||||
"These permissions limit how the plugin uses Orca's API. This plugin has no worker process."
|
||||
)
|
||||
expect(document.body.textContent).not.toContain('full access to your files')
|
||||
})
|
||||
|
||||
it('records Keep Disabled when Escape dismisses the dialog', async () => {
|
||||
const onDecision = vi.fn().mockResolvedValue(undefined)
|
||||
await renderConsent(plugin, onDecision)
|
||||
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
||||
})
|
||||
|
||||
expect(onDecision).toHaveBeenCalledWith(
|
||||
plugin.pluginKey,
|
||||
plugin.consentFingerprint,
|
||||
'keep-disabled'
|
||||
)
|
||||
})
|
||||
|
||||
it('labels re-consent generically and enables only after an explicit action', async () => {
|
||||
const onDecision = vi.fn().mockResolvedValue(undefined)
|
||||
await renderConsent({ ...plugin, needsReconsent: true }, onDecision)
|
||||
expect(document.body.textContent).toContain('Permissions or the worker trust tier changed')
|
||||
const enable = Array.from(document.querySelectorAll('button')).find(
|
||||
(button) => button.textContent?.trim() === 'Enable plugin'
|
||||
)
|
||||
if (!enable) {
|
||||
throw new Error('missing enable action')
|
||||
}
|
||||
|
||||
await act(async () => enable.dispatchEvent(new MouseEvent('click', { bubbles: true })))
|
||||
|
||||
expect(onDecision).toHaveBeenCalledWith(plugin.pluginKey, plugin.consentFingerprint, 'approve')
|
||||
})
|
||||
|
||||
it('explains how to recover when the reviewed plugin changed', async () => {
|
||||
const onDecision = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('reviewed fingerprint is no longer current'))
|
||||
await renderConsent(plugin, onDecision)
|
||||
const enable = Array.from(document.querySelectorAll('button')).find(
|
||||
(button) => button.textContent?.trim() === 'Enable plugin'
|
||||
)
|
||||
|
||||
await act(async () => enable?.dispatchEvent(new MouseEvent('click', { bubbles: true })))
|
||||
|
||||
expect(document.body.textContent).toContain(
|
||||
'The plugin changed while you were reviewing it. Close this dialog and review the updated permissions.'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,241 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { AlertTriangle, Check, Loader2 } from 'lucide-react'
|
||||
import type { PluginHostListEntry } from '../../../../preload/api-types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { pluginConsentErrorMessage } from './plugin-error-presentation'
|
||||
import { Button } from '../ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '../ui/dialog'
|
||||
|
||||
type PluginConsentDialogProps = {
|
||||
plugin: PluginHostListEntry | null
|
||||
onDecision: (
|
||||
pluginKey: string,
|
||||
reviewedFingerprint: string,
|
||||
decision: 'approve' | 'keep-disabled'
|
||||
) => Promise<void>
|
||||
}
|
||||
|
||||
function trustTier(plugin: PluginHostListEntry): string {
|
||||
return plugin.hasWorker
|
||||
? translate(
|
||||
'auto.components.settings.PluginConsentDialog.workerTrust',
|
||||
'Background worker — runs its own process'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.settings.PluginConsentDialog.panelTrust',
|
||||
'Panel only — no worker process'
|
||||
)
|
||||
}
|
||||
|
||||
function capabilityDescription(kind: string, fallback: string): string {
|
||||
switch (kind) {
|
||||
case 'workspace:read':
|
||||
return translate(
|
||||
'auto.components.settings.PluginConsentDialog.capability.workspaceRead',
|
||||
'Read the name, branch, and terminal list of your focused worktree'
|
||||
)
|
||||
case 'terminal:send':
|
||||
return translate(
|
||||
'auto.components.settings.PluginConsentDialog.capability.terminalSend',
|
||||
'Type text into a terminal you can see (always a specific terminal)'
|
||||
)
|
||||
case 'notifications:show':
|
||||
return translate(
|
||||
'auto.components.settings.PluginConsentDialog.capability.notificationsShow',
|
||||
'Show desktop notifications labeled with the plugin name'
|
||||
)
|
||||
case 'storage':
|
||||
return translate(
|
||||
'auto.components.settings.PluginConsentDialog.capability.storage',
|
||||
"Store data in the plugin's own storage folder"
|
||||
)
|
||||
case 'secrets':
|
||||
return translate(
|
||||
'auto.components.settings.PluginConsentDialog.capability.secrets',
|
||||
"Store and read secrets in the plugin's own encrypted vault"
|
||||
)
|
||||
case 'events:subscribe':
|
||||
return translate(
|
||||
'auto.components.settings.PluginConsentDialog.capability.eventsSubscribe',
|
||||
'Get notified when worktrees are created or removed and when agent status changes'
|
||||
)
|
||||
case 'settings:own':
|
||||
return translate(
|
||||
'auto.components.settings.PluginConsentDialog.capability.settingsOwn',
|
||||
"Read and change the plugin's own settings"
|
||||
)
|
||||
default:
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
export function PluginConsentDialog({
|
||||
plugin: currentPlugin,
|
||||
onDecision
|
||||
}: PluginConsentDialogProps): React.JSX.Element {
|
||||
// Why: a same-key update must not replace the trust boundary while the user is reviewing it.
|
||||
const plugin = useRef(currentPlugin).current
|
||||
const keepDisabledRef = useRef<HTMLButtonElement>(null)
|
||||
const [busyDecision, setBusyDecision] = useState<'approve' | 'keep-disabled' | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const decide = async (decision: 'approve' | 'keep-disabled'): Promise<void> => {
|
||||
if (!plugin?.consentFingerprint || busyDecision) {
|
||||
return
|
||||
}
|
||||
setBusyDecision(decision)
|
||||
setError(null)
|
||||
try {
|
||||
// Why: consent is conditional on the exact trust boundary rendered by this dialog.
|
||||
await onDecision(plugin.pluginKey, plugin.consentFingerprint, decision)
|
||||
} catch (cause) {
|
||||
console.warn('[plugins] consent update failed:', cause)
|
||||
setError(pluginConsentErrorMessage(cause))
|
||||
} finally {
|
||||
setBusyDecision(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={Boolean(plugin)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
void decide('keep-disabled')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className="max-h-[calc(100vh-3rem)] overflow-y-auto scrollbar-sleek sm:max-w-lg"
|
||||
onOpenAutoFocus={(event) => {
|
||||
// Why: dismissal is the safety-preserving path, so it receives initial focus.
|
||||
event.preventDefault()
|
||||
keepDisabledRef.current?.focus()
|
||||
}}
|
||||
>
|
||||
{plugin ? (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{translate(
|
||||
'auto.components.settings.PluginConsentDialog.title',
|
||||
'Review permissions'
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{translate(
|
||||
'auto.components.settings.PluginConsentDialog.subtitle',
|
||||
'{{value0}} v{{value1}} · {{value2}}',
|
||||
{ value0: plugin.name, value1: plugin.version, value2: plugin.publisher }
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{plugin.needsReconsent ? (
|
||||
<p className="border-l-2 border-foreground/25 py-0.5 pl-3 text-sm leading-6">
|
||||
{translate(
|
||||
'auto.components.settings.PluginConsentDialog.reconsent',
|
||||
'Permissions or the worker trust tier changed since you last reviewed this plugin. Review them again before it can run.'
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
<dl className="grid grid-cols-[6rem_minmax(0,1fr)] gap-x-3 gap-y-2 text-sm">
|
||||
<dt className="text-xs text-muted-foreground">
|
||||
{translate('auto.components.settings.PluginConsentDialog.source', 'Source')}
|
||||
</dt>
|
||||
<dd className="truncate font-mono text-xs" title={plugin.source?.reference}>
|
||||
{plugin.source?.reference ??
|
||||
translate(
|
||||
'auto.components.settings.PluginConsentDialog.unknownSource',
|
||||
'Not available'
|
||||
)}
|
||||
</dd>
|
||||
<dt className="text-xs text-muted-foreground">
|
||||
{translate('auto.components.settings.PluginConsentDialog.commit', 'Commit')}
|
||||
</dt>
|
||||
<dd
|
||||
className={plugin.source?.resolvedCommit ? 'truncate font-mono text-xs' : 'text-sm'}
|
||||
title={plugin.source?.resolvedCommit ?? undefined}
|
||||
>
|
||||
{plugin.source?.resolvedCommit ??
|
||||
translate(
|
||||
'auto.components.settings.PluginConsentDialog.localCommit',
|
||||
'Not available (local folder)'
|
||||
)}
|
||||
</dd>
|
||||
<dt className="text-xs text-muted-foreground">
|
||||
{translate('auto.components.settings.PluginConsentDialog.trust', 'Trust tier')}
|
||||
</dt>
|
||||
<dd>{trustTier(plugin)}</dd>
|
||||
</dl>
|
||||
<div className="space-y-2">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.settings.PluginConsentDialog.capabilities',
|
||||
'This plugin can'
|
||||
)}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{plugin.capabilities.map((capability) => (
|
||||
<div key={capability.kind} className="flex items-start gap-2 text-sm leading-6">
|
||||
<Check className="mt-1 size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span>
|
||||
{capabilityDescription(capability.kind, capability.description)}{' '}
|
||||
<span className="font-mono text-[11px] text-muted-foreground">
|
||||
({capability.kind})
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-2 rounded-md border border-border bg-muted/50 px-3.5 py-3 text-sm leading-6">
|
||||
<AlertTriangle className="mt-1 size-4 shrink-0" />
|
||||
<span>
|
||||
{plugin.hasWorker
|
||||
? translate(
|
||||
'auto.components.settings.PluginConsentDialog.warning',
|
||||
"These permissions limit how the plugin uses Orca's API. Its worker still runs as a normal process on your computer with full access to your files, network, and other processes."
|
||||
)
|
||||
: translate(
|
||||
'auto.components.settings.PluginConsentDialog.panelWarning',
|
||||
"These permissions limit how the plugin uses Orca's API. This plugin has no worker process."
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{error ? <p className="text-xs text-destructive">{error}</p> : null}
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={Boolean(busyDecision)}
|
||||
onClick={() => void decide('approve')}
|
||||
>
|
||||
{busyDecision === 'approve' ? <Loader2 className="animate-spin" /> : null}
|
||||
{translate('auto.components.settings.PluginConsentDialog.enable', 'Enable plugin')}
|
||||
</Button>
|
||||
<Button
|
||||
ref={keepDisabledRef}
|
||||
size="sm"
|
||||
disabled={Boolean(busyDecision)}
|
||||
onClick={() => void decide('keep-disabled')}
|
||||
>
|
||||
{busyDecision === 'keep-disabled' ? <Loader2 className="animate-spin" /> : null}
|
||||
{translate(
|
||||
'auto.components.settings.PluginConsentDialog.keepDisabled',
|
||||
'Keep Disabled'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useState } from 'react'
|
||||
import { ChevronRight, Loader2 } from 'lucide-react'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { Button } from '../ui/button'
|
||||
import { Input } from '../ui/input'
|
||||
import { Label } from '../ui/label'
|
||||
|
||||
type PluginDevelopmentSectionProps = {
|
||||
paths: readonly string[]
|
||||
busy: boolean
|
||||
onChange: (paths: string[]) => Promise<void>
|
||||
}
|
||||
|
||||
function saveErrorMessage(cause: unknown): string {
|
||||
console.warn('[plugins] development path update failed:', cause)
|
||||
return translate(
|
||||
'auto.components.settings.PluginDevelopmentSection.saveFailed',
|
||||
'Could not save development plugin paths.'
|
||||
)
|
||||
}
|
||||
|
||||
export function PluginDevelopmentSection({
|
||||
paths,
|
||||
busy,
|
||||
onChange
|
||||
}: PluginDevelopmentSectionProps): React.JSX.Element {
|
||||
const [pathInput, setPathInput] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const addPath = async (): Promise<void> => {
|
||||
const path = pathInput.trim()
|
||||
if (!path) {
|
||||
setError(
|
||||
translate(
|
||||
'auto.components.settings.PluginDevelopmentSection.pathRequired',
|
||||
'Enter a plugin folder path.'
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
setError(null)
|
||||
try {
|
||||
await onChange([...paths, path])
|
||||
setPathInput('')
|
||||
} catch (cause) {
|
||||
setError(saveErrorMessage(cause))
|
||||
}
|
||||
}
|
||||
|
||||
const removePath = async (index: number): Promise<void> => {
|
||||
setError(null)
|
||||
try {
|
||||
await onChange(paths.filter((_, pathIndex) => pathIndex !== index))
|
||||
} catch (cause) {
|
||||
setError(saveErrorMessage(cause))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<details className="group">
|
||||
<summary className="flex w-fit cursor-pointer list-none items-center gap-1.5 rounded-md py-1.5 pr-2 text-[13px] font-medium outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 [&::-webkit-details-marker]:hidden">
|
||||
<ChevronRight className="size-3.5 text-muted-foreground transition-transform group-open:rotate-90" />
|
||||
{translate('auto.components.settings.PluginDevelopmentSection.title', 'Development')}
|
||||
</summary>
|
||||
<div className="space-y-3 pb-1 pl-5 pt-1">
|
||||
<p className="max-w-2xl text-xs leading-5 text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.settings.PluginDevelopmentSection.help',
|
||||
'Load plugins directly from folders on this computer while you develop them. Dev plugins still require permission review. Workers run on this desktop host; SSH workspace actions route through Orca, so paths here are desktop paths.'
|
||||
)}
|
||||
</p>
|
||||
{paths.map((path, index) => (
|
||||
<div key={`${path}-${index}`} className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate rounded-md border border-border bg-muted/30 px-2.5 py-1.5 font-mono text-xs"
|
||||
title={path}
|
||||
>
|
||||
{path}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
disabled={busy}
|
||||
onClick={() => void removePath(index)}
|
||||
>
|
||||
{translate('auto.components.settings.PluginDevelopmentSection.remove', 'Remove')}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<form
|
||||
className="flex min-w-0 items-center gap-2"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
void addPath()
|
||||
}}
|
||||
>
|
||||
<Label htmlFor="plugin-development-path" className="sr-only">
|
||||
{translate(
|
||||
'auto.components.settings.PluginDevelopmentSection.pathLabel',
|
||||
'Development plugin folder path'
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
id="plugin-development-path"
|
||||
value={pathInput}
|
||||
onChange={(event) => setPathInput(event.target.value)}
|
||||
className="h-8 min-w-0 font-mono text-xs"
|
||||
placeholder={translate(
|
||||
'auto.components.settings.PluginDevelopmentSection.placeholder',
|
||||
'/Users/you/plugins/my-plugin or C:\\Users\\you\\plugins\\my-plugin'
|
||||
)}
|
||||
spellCheck={false}
|
||||
aria-invalid={Boolean(error)}
|
||||
aria-describedby={error ? 'plugin-development-path-error' : undefined}
|
||||
/>
|
||||
<Button type="submit" variant="outline" size="sm" disabled={busy}>
|
||||
{busy ? <Loader2 className="animate-spin" /> : null}
|
||||
{translate('auto.components.settings.PluginDevelopmentSection.add', 'Add path')}
|
||||
</Button>
|
||||
</form>
|
||||
{error ? (
|
||||
<p id="plugin-development-path-error" className="text-xs text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</details>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { PluginHostInstallSource } from '../../../../preload/api-types'
|
||||
import { PluginInstallDialog } from './PluginInstallDialog'
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
async function renderDialog(
|
||||
onInstall: (source: PluginHostInstallSource) => Promise<void>
|
||||
): Promise<Root> {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const root = createRoot(container)
|
||||
await act(async () => {
|
||||
root.render(<PluginInstallDialog open onOpenChange={vi.fn()} onInstall={onInstall} />)
|
||||
})
|
||||
return root
|
||||
}
|
||||
|
||||
async function enter(input: HTMLInputElement, value: string): Promise<void> {
|
||||
await act(async () => {
|
||||
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(input, value)
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
})
|
||||
}
|
||||
|
||||
function button(label: string): HTMLButtonElement {
|
||||
const match = Array.from(document.querySelectorAll('button')).find(
|
||||
(candidate) => candidate.textContent?.trim() === label
|
||||
)
|
||||
if (!match) {
|
||||
throw new Error(`missing ${label} button`)
|
||||
}
|
||||
return match
|
||||
}
|
||||
|
||||
describe('PluginInstallDialog', () => {
|
||||
it('preserves local input and stays out of consent when installation fails', async () => {
|
||||
const onInstall = vi
|
||||
.fn<(source: PluginHostInstallSource) => Promise<void>>()
|
||||
.mockRejectedValue(new Error('Integrity check failed'))
|
||||
const root = await renderDialog(onInstall)
|
||||
const input = document.querySelector<HTMLInputElement>('#plugin-local-path')
|
||||
if (!input) {
|
||||
throw new Error('missing local path input')
|
||||
}
|
||||
await enter(input, 'C:\\plugins\\demo')
|
||||
|
||||
await act(async () => {
|
||||
button('Install').dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
})
|
||||
|
||||
expect(onInstall).toHaveBeenCalledWith({ kind: 'local-path', path: 'C:\\plugins\\demo' })
|
||||
expect(input.value).toBe('C:\\plugins\\demo')
|
||||
expect(document.body.textContent).toContain('Plugin installation failed.')
|
||||
expect(document.body.textContent).not.toContain('Review permissions')
|
||||
act(() => root.unmount())
|
||||
})
|
||||
|
||||
it('turns invalid-manifest failures into actionable localized guidance', async () => {
|
||||
const onInstall = vi
|
||||
.fn<(source: PluginHostInstallSource) => Promise<void>>()
|
||||
.mockRejectedValue(new Error('invalid manifest: contributes.panels.0.entry is required'))
|
||||
const root = await renderDialog(onInstall)
|
||||
const input = document.querySelector<HTMLInputElement>('#plugin-local-path')
|
||||
if (!input) {
|
||||
throw new Error('missing local path input')
|
||||
}
|
||||
await enter(input, '/plugins/demo')
|
||||
|
||||
await act(async () => {
|
||||
button('Install').dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
})
|
||||
|
||||
expect(document.body.textContent).toContain(
|
||||
'orca-plugin.json is invalid. Ask the plugin author to fix the manifest.'
|
||||
)
|
||||
act(() => root.unmount())
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,206 @@
|
||||
import { useState } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import type { PluginHostInstallSource } from '../../../../preload/api-types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { Button } from '../ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '../ui/dialog'
|
||||
import { Input } from '../ui/input'
|
||||
import { Label } from '../ui/label'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs'
|
||||
import { parsePluginInstallSource } from './plugin-install-source'
|
||||
import { pluginInstallErrorMessage } from './plugin-error-presentation'
|
||||
|
||||
type PluginInstallDialogProps = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onInstall: (source: PluginHostInstallSource) => Promise<void>
|
||||
}
|
||||
|
||||
function installValidationMessage(reason: string): string {
|
||||
switch (reason) {
|
||||
case 'missing-local-path':
|
||||
return translate(
|
||||
'auto.components.settings.PluginInstallDialog.localRequired',
|
||||
'Enter the plugin folder path.'
|
||||
)
|
||||
case 'missing-git-url':
|
||||
return translate(
|
||||
'auto.components.settings.PluginInstallDialog.gitUrlRequired',
|
||||
'Enter a repository URL.'
|
||||
)
|
||||
case 'invalid-git-url':
|
||||
return translate(
|
||||
'auto.components.settings.PluginInstallDialog.gitUrlInvalid',
|
||||
'Use an HTTPS or SSH Git URL. Executable Git helper protocols are not allowed.'
|
||||
)
|
||||
default:
|
||||
return translate(
|
||||
'auto.components.settings.PluginInstallDialog.gitRefRequired',
|
||||
'Add an explicit #ref (tag or commit) so the install is pinned — for example #v0.1.0.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function PluginInstallDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onInstall
|
||||
}: PluginInstallDialogProps): React.JSX.Element {
|
||||
const [kind, setKind] = useState<'local-path' | 'git'>('local-path')
|
||||
const [localPath, setLocalPath] = useState('')
|
||||
const [gitUrl, setGitUrl] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [installing, setInstalling] = useState(false)
|
||||
|
||||
const submit = async (): Promise<void> => {
|
||||
const parsed = parsePluginInstallSource(kind, kind === 'git' ? gitUrl : localPath)
|
||||
if (!parsed.ok) {
|
||||
setError(installValidationMessage(parsed.reason))
|
||||
return
|
||||
}
|
||||
setError(null)
|
||||
setInstalling(true)
|
||||
try {
|
||||
await onInstall(parsed.source)
|
||||
} catch (cause) {
|
||||
console.warn('[plugins] installation failed:', cause)
|
||||
setError(pluginInstallErrorMessage(cause))
|
||||
} finally {
|
||||
setInstalling(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(nextOpen) => !installing && onOpenChange(nextOpen)}>
|
||||
<DialogContent className="max-h-[calc(100vh-3rem)] overflow-y-auto scrollbar-sleek sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{translate('auto.components.settings.PluginInstallDialog.title', 'Install plugin')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{translate(
|
||||
'auto.components.settings.PluginInstallDialog.description',
|
||||
'Installing copies the plugin into Orca and shows its permissions for review. No plugin code runs until you enable it.'
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
className="contents"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
void submit()
|
||||
}}
|
||||
>
|
||||
<Tabs
|
||||
value={kind}
|
||||
onValueChange={(value) => {
|
||||
setKind(value as 'local-path' | 'git')
|
||||
setError(null)
|
||||
}}
|
||||
>
|
||||
<TabsList
|
||||
aria-label={translate(
|
||||
'auto.components.settings.PluginInstallDialog.source',
|
||||
'Install source'
|
||||
)}
|
||||
>
|
||||
<TabsTrigger value="local-path">
|
||||
{translate('auto.components.settings.PluginInstallDialog.localTab', 'Local folder')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="git">
|
||||
{translate('auto.components.settings.PluginInstallDialog.gitTab', 'Git URL')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="local-path" className="space-y-2 pt-2">
|
||||
<Label htmlFor="plugin-local-path">
|
||||
{translate(
|
||||
'auto.components.settings.PluginInstallDialog.localLabel',
|
||||
'Plugin folder path'
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
id="plugin-local-path"
|
||||
className="font-mono text-xs"
|
||||
value={localPath}
|
||||
onChange={(event) => setLocalPath(event.target.value)}
|
||||
placeholder={translate(
|
||||
'auto.components.settings.PluginInstallDialog.localPlaceholder',
|
||||
'/Users/you/plugins/my-plugin or C:\\Users\\you\\plugins\\my-plugin'
|
||||
)}
|
||||
spellCheck={false}
|
||||
aria-invalid={kind === 'local-path' && Boolean(error)}
|
||||
aria-describedby={error ? 'plugin-install-error' : undefined}
|
||||
autoFocus
|
||||
/>
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.settings.PluginInstallDialog.localHelp',
|
||||
'Full path to a folder containing orca-plugin.json on this computer. The path is used exactly as entered.'
|
||||
)}
|
||||
</p>
|
||||
</TabsContent>
|
||||
<TabsContent value="git" className="space-y-2 pt-2">
|
||||
<Label htmlFor="plugin-git-url">
|
||||
{translate(
|
||||
'auto.components.settings.PluginInstallDialog.gitLabel',
|
||||
'Repository URL with #ref'
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
id="plugin-git-url"
|
||||
className="font-mono text-xs"
|
||||
value={gitUrl}
|
||||
onChange={(event) => setGitUrl(event.target.value)}
|
||||
placeholder={translate(
|
||||
'auto.components.settings.PluginInstallDialog.gitPlaceholder',
|
||||
'https://git.example/acme/orca-notes#v0.1.0'
|
||||
)}
|
||||
spellCheck={false}
|
||||
aria-invalid={kind === 'git' && Boolean(error)}
|
||||
aria-describedby={error ? 'plugin-install-error' : undefined}
|
||||
/>
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.settings.PluginInstallDialog.gitHelp',
|
||||
'Append an explicit #ref — a tag or commit — so the install is pinned. Works with GitHub, GitLab, and any git host.'
|
||||
)}
|
||||
</p>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
{error ? (
|
||||
<p id="plugin-install-error" className="text-xs text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={installing}
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
{translate('auto.components.settings.PluginInstallDialog.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button type="submit" size="sm" className="w-31" disabled={installing}>
|
||||
{installing ? <Loader2 className="animate-spin" /> : null}
|
||||
{installing
|
||||
? translate(
|
||||
'auto.components.settings.PluginInstallDialog.installing',
|
||||
'Installing…'
|
||||
)
|
||||
: translate('auto.components.settings.PluginInstallDialog.install', 'Install')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useRef } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import type { PluginHostListEntry } from '../../../../preload/api-types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { Button } from '../ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '../ui/dialog'
|
||||
|
||||
type PluginRemoveDialogProps = {
|
||||
plugin: PluginHostListEntry | null
|
||||
busy: boolean
|
||||
onCancel: () => void
|
||||
onConfirm: (pluginKey: string) => void
|
||||
}
|
||||
|
||||
export function PluginRemoveDialog({
|
||||
plugin,
|
||||
busy,
|
||||
onCancel,
|
||||
onConfirm
|
||||
}: PluginRemoveDialogProps): React.JSX.Element {
|
||||
const cancelRef = useRef<HTMLButtonElement>(null)
|
||||
return (
|
||||
<Dialog open={Boolean(plugin)} onOpenChange={(open) => !open && !busy && onCancel()}>
|
||||
<DialogContent
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault()
|
||||
cancelRef.current?.focus()
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{translate('auto.components.settings.PluginRemoveDialog.title', 'Remove plugin?')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{translate(
|
||||
'auto.components.settings.PluginRemoveDialog.description',
|
||||
'This removes {{value0}} and its stored plugin data from this computer. You can install it again later.',
|
||||
{ value0: plugin?.name ?? '' }
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button ref={cancelRef} variant="ghost" disabled={busy} onClick={onCancel}>
|
||||
{translate('auto.components.settings.PluginRemoveDialog.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={busy || !plugin}
|
||||
onClick={() => plugin && onConfirm(plugin.pluginKey)}
|
||||
>
|
||||
{busy ? <Loader2 className="animate-spin" /> : null}
|
||||
{translate('auto.components.settings.PluginRemoveDialog.remove', 'Remove plugin')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { Loader2, RefreshCw } from 'lucide-react'
|
||||
import type { PluginHostListEntry } from '../../../../preload/api-types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { Button } from '../ui/button'
|
||||
import { PluginDevelopmentSection } from './PluginDevelopmentSection'
|
||||
import { PluginSettingsRow, type PluginLogsState } from './PluginSettingsRow'
|
||||
import { SettingsRow, SettingsSwitch } from './SettingsFormControls'
|
||||
|
||||
type PluginSettingsOverviewProps = {
|
||||
featureEnabled: boolean
|
||||
featureBusy: boolean
|
||||
settingsError: string | null
|
||||
loading: boolean
|
||||
refreshBusy: boolean
|
||||
error: string | null
|
||||
plugins: PluginHostListEntry[]
|
||||
busyPluginKeys: ReadonlySet<string>
|
||||
openLogs: ReadonlySet<string>
|
||||
logsByPlugin: Readonly<Record<string, PluginLogsState>>
|
||||
devPaths: readonly string[]
|
||||
devPathsBusy: boolean
|
||||
onToggleFeature: () => void
|
||||
onRefresh: () => void
|
||||
onReview: (pluginKey: string) => void
|
||||
onToggleEnabled: (plugin: PluginHostListEntry) => void
|
||||
onToggleLogs: (pluginKey: string) => void
|
||||
onRemoveRequest: (pluginKey: string) => void
|
||||
onUpdateDevPaths: (paths: string[]) => Promise<void>
|
||||
}
|
||||
|
||||
export function PluginSettingsOverview({
|
||||
featureEnabled,
|
||||
featureBusy,
|
||||
settingsError,
|
||||
loading,
|
||||
refreshBusy,
|
||||
error,
|
||||
plugins,
|
||||
busyPluginKeys,
|
||||
openLogs,
|
||||
logsByPlugin,
|
||||
devPaths,
|
||||
devPathsBusy,
|
||||
onToggleFeature,
|
||||
onRefresh,
|
||||
onReview,
|
||||
onToggleEnabled,
|
||||
onToggleLogs,
|
||||
onRemoveRequest,
|
||||
onUpdateDevPaths
|
||||
}: PluginSettingsOverviewProps): React.JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<SettingsRow
|
||||
label={translate(
|
||||
'auto.components.settings.PluginsSettingsSection.systemLabel',
|
||||
'Plugin system'
|
||||
)}
|
||||
labelId="plugin-system-label"
|
||||
description={translate(
|
||||
'auto.components.settings.PluginsSettingsSection.systemDescription',
|
||||
'Discovers installed plugins and lets you enable them individually. Nothing runs until you review and enable it. Workers always run on this computer; SSH workspace actions route through Orca.'
|
||||
)}
|
||||
alignTop
|
||||
control={
|
||||
<SettingsSwitch
|
||||
checked={featureEnabled}
|
||||
disabled={featureBusy}
|
||||
ariaLabelledBy="plugin-system-label"
|
||||
onChange={onToggleFeature}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{settingsError ? <p className="text-xs text-destructive">{settingsError}</p> : null}
|
||||
<div className="my-4 border-t border-border/60" />
|
||||
{!featureEnabled ? (
|
||||
<div className="rounded-lg border border-dashed border-border px-5 py-6 text-center text-[13px] leading-6 text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.settings.PluginsSettingsSection.featureOff',
|
||||
'Turn on the plugin system to see and manage installed plugins. Anything already installed stays on disk and stays disabled while the system is off.'
|
||||
)}
|
||||
</div>
|
||||
) : loading ? (
|
||||
<div className="flex items-center gap-2 px-4 py-5 text-[13px] text-muted-foreground">
|
||||
<Loader2 className="animate-spin" />
|
||||
{translate('auto.components.settings.PluginsSettingsSection.loading', 'Loading plugins…')}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
|
||||
{translate('auto.components.settings.PluginsSettingsSection.installed', 'Installed')}
|
||||
</span>
|
||||
<Button variant="ghost" size="xs" disabled={refreshBusy} onClick={onRefresh}>
|
||||
<RefreshCw className={refreshBusy ? 'animate-spin' : undefined} />
|
||||
{translate('auto.components.settings.PluginsSettingsSection.refresh', 'Refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
{error ? (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive">
|
||||
<p>{error}</p>
|
||||
<Button variant="outline" size="xs" className="mt-2" onClick={onRefresh}>
|
||||
{translate('auto.components.settings.PluginsSettingsSection.tryAgain', 'Try again')}
|
||||
</Button>
|
||||
</div>
|
||||
) : plugins.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-border px-5 py-6 text-center text-[13px] leading-6 text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.settings.PluginsSettingsSection.empty',
|
||||
'No plugins installed. Use Install plugin to add one from a local folder or a pinned git ref.'
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-lg border border-border/80">
|
||||
{plugins.map((plugin) => (
|
||||
<PluginSettingsRow
|
||||
key={plugin.pluginKey}
|
||||
plugin={plugin}
|
||||
busy={busyPluginKeys.has(plugin.pluginKey)}
|
||||
logsOpen={openLogs.has(plugin.pluginKey)}
|
||||
logsState={logsByPlugin[plugin.pluginKey]}
|
||||
onReview={onReview}
|
||||
onToggleEnabled={onToggleEnabled}
|
||||
onToggleLogs={onToggleLogs}
|
||||
onRemoveRequest={onRemoveRequest}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="my-4 border-t border-border/60" />
|
||||
<PluginDevelopmentSection
|
||||
paths={devPaths}
|
||||
busy={devPathsBusy}
|
||||
onChange={onUpdateDevPaths}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
import { AlertTriangle, FileText, Loader2, MoreHorizontal, Trash2 } from 'lucide-react'
|
||||
import type { PluginHostListEntry, PluginHostLogLine } from '../../../../preload/api-types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { invalidPluginErrorMessage } from './plugin-error-presentation'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '../ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from '../ui/dropdown-menu'
|
||||
import { SettingsSwitch } from './SettingsFormControls'
|
||||
|
||||
export type PluginLogsState = {
|
||||
loading: boolean
|
||||
lines?: PluginHostLogLine[]
|
||||
error?: string
|
||||
}
|
||||
|
||||
type PluginSettingsRowProps = {
|
||||
plugin: PluginHostListEntry
|
||||
busy: boolean
|
||||
logsOpen: boolean
|
||||
logsState?: PluginLogsState
|
||||
onReview: (pluginKey: string) => void
|
||||
onToggleEnabled: (plugin: PluginHostListEntry) => void
|
||||
onToggleLogs: (pluginKey: string) => void
|
||||
onRemoveRequest: (pluginKey: string) => void
|
||||
}
|
||||
|
||||
function statusPresentation(plugin: PluginHostListEntry): { label: string; className: string } {
|
||||
if (plugin.needsReconsent || plugin.status === 'pending') {
|
||||
return {
|
||||
label: translate('auto.components.settings.PluginSettingsRow.needsReview', 'Needs review'),
|
||||
className: 'border-foreground/20 bg-foreground/8 text-foreground'
|
||||
}
|
||||
}
|
||||
if (plugin.status === 'restarting') {
|
||||
return {
|
||||
label: translate('auto.components.settings.PluginSettingsRow.restarting', 'Restarting'),
|
||||
className: 'border-foreground/20 bg-foreground/8 text-foreground'
|
||||
}
|
||||
}
|
||||
if (plugin.status === 'errored' || plugin.status === 'invalid') {
|
||||
return {
|
||||
label:
|
||||
plugin.status === 'invalid'
|
||||
? translate('auto.components.settings.PluginSettingsRow.invalid', 'Invalid')
|
||||
: translate('auto.components.settings.PluginSettingsRow.error', 'Error'),
|
||||
className: 'border-destructive/25 bg-destructive/8 text-destructive'
|
||||
}
|
||||
}
|
||||
if (plugin.status === 'disabled') {
|
||||
return {
|
||||
label: translate('auto.components.settings.PluginSettingsRow.disabled', 'Disabled'),
|
||||
className: 'border-border bg-muted/40 text-muted-foreground'
|
||||
}
|
||||
}
|
||||
return {
|
||||
label:
|
||||
plugin.status === 'running'
|
||||
? translate('auto.components.settings.PluginSettingsRow.running', 'Running')
|
||||
: translate('auto.components.settings.PluginSettingsRow.enabled', 'Enabled'),
|
||||
className: 'border-status-success-border bg-status-success-background text-status-success'
|
||||
}
|
||||
}
|
||||
|
||||
function sourceLabel(plugin: PluginHostListEntry): string | null {
|
||||
if (!plugin.source) {
|
||||
return null
|
||||
}
|
||||
const commit = plugin.source.resolvedCommit?.slice(0, 10)
|
||||
return commit ? `${plugin.source.reference} @ ${commit}` : plugin.source.reference
|
||||
}
|
||||
|
||||
function trustTier(plugin: PluginHostListEntry): string {
|
||||
return plugin.hasWorker
|
||||
? translate('auto.components.settings.PluginSettingsRow.workerTier', 'background worker')
|
||||
: translate('auto.components.settings.PluginSettingsRow.panelTier', 'panel only')
|
||||
}
|
||||
|
||||
function PluginLogs({ pluginKey, state }: { pluginKey: string; state?: PluginLogsState }) {
|
||||
return (
|
||||
<div className="mt-3 overflow-hidden rounded-md border border-border bg-muted/40">
|
||||
{state?.loading ? (
|
||||
<div className="flex items-center gap-2 p-3 text-xs text-muted-foreground">
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
{translate('auto.components.settings.PluginSettingsRow.loadingLogs', 'Loading logs…')}
|
||||
</div>
|
||||
) : state?.error ? (
|
||||
<p className="p-3 text-xs text-destructive">{state.error}</p>
|
||||
) : (
|
||||
<>
|
||||
<pre
|
||||
tabIndex={0}
|
||||
className="max-h-44 overflow-auto p-3 font-mono text-[11px] leading-5 scrollbar-sleek"
|
||||
>
|
||||
{state?.lines?.length
|
||||
? state.lines
|
||||
.map(
|
||||
(entry) =>
|
||||
`${new Date(entry.ts).toLocaleTimeString()} ${entry.level.padEnd(5)} ${entry.line}`
|
||||
)
|
||||
.join('\n')
|
||||
: translate(
|
||||
'auto.components.settings.PluginSettingsRow.noLogs',
|
||||
'No log lines recorded.'
|
||||
)}
|
||||
</pre>
|
||||
<div className="flex flex-wrap justify-between gap-2 border-t border-border/50 px-3 py-1.5 text-[11px] text-muted-foreground">
|
||||
<span>
|
||||
{translate(
|
||||
'auto.components.settings.PluginSettingsRow.logCount',
|
||||
'Last {{value0}} of up to 200 retained lines',
|
||||
{ value0: state?.lines?.length ?? 0 }
|
||||
)}
|
||||
</span>
|
||||
<span className="font-mono">{pluginKey}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function PluginSettingsRow({
|
||||
plugin,
|
||||
busy,
|
||||
logsOpen,
|
||||
logsState,
|
||||
onReview,
|
||||
onToggleEnabled,
|
||||
onToggleLogs,
|
||||
onRemoveRequest
|
||||
}: PluginSettingsRowProps): React.JSX.Element {
|
||||
const status = statusPresentation(plugin)
|
||||
const source = sourceLabel(plugin)
|
||||
const needsReview = plugin.needsReconsent || plugin.status === 'pending'
|
||||
const enabled =
|
||||
plugin.status === 'running' ||
|
||||
plugin.status === 'restarting' ||
|
||||
plugin.status === 'idle' ||
|
||||
plugin.status === 'errored'
|
||||
const switchDisabled = busy || needsReview || plugin.status === 'invalid'
|
||||
|
||||
return (
|
||||
<div
|
||||
className="px-4 py-3 [&+&]:border-t [&+&]:border-border/60"
|
||||
data-plugin-key={plugin.pluginKey}
|
||||
>
|
||||
<div className="flex flex-wrap items-start gap-3">
|
||||
<div className="min-w-48 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-medium">{plugin.name}</span>
|
||||
<span className="font-mono text-xs text-muted-foreground">v{plugin.version}</span>
|
||||
{plugin.isDev ? (
|
||||
<span className="rounded-full border border-border px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||
{translate('auto.components.settings.PluginSettingsRow.dev', 'Dev')}
|
||||
</span>
|
||||
) : null}
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-[11px] font-medium',
|
||||
status.className
|
||||
)}
|
||||
>
|
||||
<span className="size-1.5 rounded-full bg-current" aria-hidden="true" />
|
||||
{status.label}
|
||||
</span>
|
||||
{busy ? <Loader2 className="size-3.5 animate-spin text-muted-foreground" /> : null}
|
||||
</div>
|
||||
<p className="mt-0.5 truncate text-xs text-muted-foreground">
|
||||
{plugin.publisher ? `${plugin.publisher} · ` : null}
|
||||
{plugin.pluginKey}
|
||||
</p>
|
||||
{source ? (
|
||||
<p className="mt-1 truncate font-mono text-xs text-muted-foreground" title={source}>
|
||||
{source}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{[...plugin.capabilities.map((capability) => capability.kind), trustTier(plugin)].join(
|
||||
' · '
|
||||
)}
|
||||
</p>
|
||||
{plugin.error ? (
|
||||
<p className="mt-1.5 flex items-start gap-1.5 text-xs leading-5 text-destructive">
|
||||
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
|
||||
<span>
|
||||
{plugin.status === 'invalid'
|
||||
? invalidPluginErrorMessage(plugin.error)
|
||||
: translate(
|
||||
'auto.components.settings.PluginSettingsRow.runtimeError',
|
||||
'The plugin stopped after an activation or worker error.'
|
||||
)}
|
||||
{plugin.restarts > 0
|
||||
? translate(
|
||||
'auto.components.settings.PluginSettingsRow.restartCount',
|
||||
' · {{value0}} restarts',
|
||||
{ value0: plugin.restarts }
|
||||
)
|
||||
: null}
|
||||
</span>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1">
|
||||
{needsReview ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
disabled={busy}
|
||||
onClick={() => onReview(plugin.pluginKey)}
|
||||
>
|
||||
{translate('auto.components.settings.PluginSettingsRow.review', 'Review permissions')}
|
||||
</Button>
|
||||
) : null}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
disabled={busy}
|
||||
aria-label={translate(
|
||||
'auto.components.settings.PluginSettingsRow.moreActions',
|
||||
'More actions for {{value0}}',
|
||||
{ value0: plugin.name }
|
||||
)}
|
||||
>
|
||||
<MoreHorizontal />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onSelect={() => onToggleLogs(plugin.pluginKey)}>
|
||||
<FileText />
|
||||
{logsOpen
|
||||
? translate('auto.components.settings.PluginSettingsRow.hideLogs', 'Hide logs')
|
||||
: translate('auto.components.settings.PluginSettingsRow.viewLogs', 'View logs')}
|
||||
</DropdownMenuItem>
|
||||
{!plugin.isDev ? (
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onSelect={() => onRemoveRequest(plugin.pluginKey)}
|
||||
>
|
||||
<Trash2 />
|
||||
{translate('auto.components.settings.PluginSettingsRow.remove', 'Remove')}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<SettingsSwitch
|
||||
checked={enabled && !needsReview}
|
||||
disabled={switchDisabled}
|
||||
onChange={() => onToggleEnabled(plugin)}
|
||||
ariaLabel={
|
||||
enabled && !needsReview
|
||||
? translate(
|
||||
'auto.components.settings.PluginSettingsRow.disableLabel',
|
||||
'Disable {{value0}}',
|
||||
{ value0: plugin.name }
|
||||
)
|
||||
: translate(
|
||||
'auto.components.settings.PluginSettingsRow.enableLabel',
|
||||
'Enable {{value0}}',
|
||||
{ value0: plugin.name }
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{logsOpen ? <PluginLogs pluginKey={plugin.pluginKey} state={logsState} /> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act, useState } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { PluginHostListEntry } from '../../../../preload/api-types'
|
||||
import { getDefaultSettings } from '../../../../shared/constants'
|
||||
import type { GlobalSettings } from '../../../../shared/types'
|
||||
import { PluginsSettingsSection } from './PluginsSettingsSection'
|
||||
|
||||
vi.mock('./SettingsSection', () => ({
|
||||
SettingsSection: ({
|
||||
children,
|
||||
headerAction
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
headerAction: React.ReactNode
|
||||
}) => (
|
||||
<section>
|
||||
{headerAction}
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('../ui/dropdown-menu', () => ({
|
||||
DropdownMenu: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
DropdownMenuItem: ({
|
||||
children,
|
||||
onSelect
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onSelect?: () => void
|
||||
}) => <button onClick={onSelect}>{children}</button>
|
||||
}))
|
||||
|
||||
vi.mock('./PluginInstallDialog', () => ({ PluginInstallDialog: () => null }))
|
||||
vi.mock('./PluginConsentDialog', () => ({
|
||||
PluginConsentDialog: ({
|
||||
plugin,
|
||||
onDecision
|
||||
}: {
|
||||
plugin: PluginHostListEntry | null
|
||||
onDecision: (key: string, reviewedFingerprint: string, decision: 'approve') => Promise<void>
|
||||
}) => {
|
||||
const [dirty, setDirty] = useState(false)
|
||||
return plugin ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() =>
|
||||
void onDecision(plugin.pluginKey, plugin.consentFingerprint ?? '', 'approve')
|
||||
}
|
||||
>
|
||||
Approve
|
||||
</button>
|
||||
<button onClick={() => setDirty(true)}>Mark consent dirty</button>
|
||||
{dirty ? <span>Consent local state</span> : null}
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
}))
|
||||
vi.mock('./PluginRemoveDialog', () => ({
|
||||
PluginRemoveDialog: ({
|
||||
plugin,
|
||||
onConfirm
|
||||
}: {
|
||||
plugin: PluginHostListEntry | null
|
||||
onConfirm: (key: string) => void
|
||||
}) =>
|
||||
plugin ? <button onClick={() => onConfirm(plugin.pluginKey)}>Confirm remove</button> : null
|
||||
}))
|
||||
|
||||
const plugin: PluginHostListEntry = {
|
||||
pluginKey: 'acme.notes',
|
||||
consentFingerprint: 'sha256-acme-notes',
|
||||
name: 'Notes',
|
||||
version: '1.0.0',
|
||||
publisher: 'acme',
|
||||
status: 'idle',
|
||||
needsReconsent: false,
|
||||
isDev: false,
|
||||
capabilities: [{ kind: 'panels', description: 'Add a Notes panel' }],
|
||||
panels: [],
|
||||
commands: [],
|
||||
hasWorker: false,
|
||||
restarts: 0
|
||||
}
|
||||
|
||||
const secondPlugin: PluginHostListEntry = {
|
||||
...plugin,
|
||||
pluginKey: 'acme.tasks',
|
||||
consentFingerprint: 'sha256-acme-tasks',
|
||||
name: 'Tasks'
|
||||
}
|
||||
|
||||
type Deferred<T> = {
|
||||
promise: Promise<T>
|
||||
resolve: (value: T) => void
|
||||
}
|
||||
|
||||
function deferred<T>(): Deferred<T> {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((nextResolve) => {
|
||||
resolve = nextResolve
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
function click(element: Element): void {
|
||||
element.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
}
|
||||
|
||||
async function renderSection(
|
||||
settings: GlobalSettings = { ...getDefaultSettings('/tmp'), pluginSystemEnabled: true },
|
||||
updateSettings = vi.fn().mockResolvedValue(undefined),
|
||||
mounted = true
|
||||
): Promise<{ root: Root; container: HTMLDivElement; updateSettings: typeof updateSettings }> {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const root = createRoot(container)
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<PluginsSettingsSection
|
||||
mounted={mounted}
|
||||
settings={settings}
|
||||
updateSettings={updateSettings}
|
||||
/>
|
||||
)
|
||||
})
|
||||
return { root, container, updateSettings }
|
||||
}
|
||||
|
||||
function installApi(overrides: Record<string, unknown> = {}): {
|
||||
unsubscribe: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
const unsubscribe = vi.fn()
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: {
|
||||
plugins: {
|
||||
list: vi.fn().mockResolvedValue([plugin]),
|
||||
onChanged: vi.fn().mockReturnValue(unsubscribe),
|
||||
refresh: vi.fn().mockResolvedValue([plugin]),
|
||||
setEnabled: vi.fn().mockResolvedValue([{ ...plugin, status: 'disabled' }]),
|
||||
remove: vi.fn().mockResolvedValue([]),
|
||||
getLogs: vi.fn().mockResolvedValue([]),
|
||||
consent: vi.fn().mockResolvedValue([plugin]),
|
||||
install: vi.fn(),
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
})
|
||||
return { unsubscribe }
|
||||
}
|
||||
|
||||
beforeEach(() => installApi())
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('PluginsSettingsSection lifecycle', () => {
|
||||
it('defers discovery and its change listener until the pane is mounted', async () => {
|
||||
const settings = { ...getDefaultSettings('/tmp'), pluginSystemEnabled: true }
|
||||
const updateSettings = vi.fn().mockResolvedValue(undefined)
|
||||
const { root } = await renderSection(settings, updateSettings, false)
|
||||
|
||||
expect(window.api.plugins.list).not.toHaveBeenCalled()
|
||||
expect(window.api.plugins.onChanged).not.toHaveBeenCalled()
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<PluginsSettingsSection mounted settings={settings} updateSettings={updateSettings} />
|
||||
)
|
||||
})
|
||||
|
||||
expect(window.api.plugins.list).toHaveBeenCalledOnce()
|
||||
expect(window.api.plugins.onChanged).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('suppresses stale lists and cleans up the change subscription', async () => {
|
||||
const first = deferred<PluginHostListEntry[]>()
|
||||
const second = deferred<PluginHostListEntry[]>()
|
||||
let onChanged: (() => void) | undefined
|
||||
const { unsubscribe } = installApi({
|
||||
list: vi.fn().mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise),
|
||||
onChanged: vi.fn((callback: () => void) => {
|
||||
onChanged = callback
|
||||
return unsubscribe
|
||||
})
|
||||
})
|
||||
const { root, container } = await renderSection()
|
||||
|
||||
act(() => onChanged?.())
|
||||
await act(async () => second.resolve([{ ...plugin, name: 'Newer Notes' }]))
|
||||
await act(async () => first.resolve([{ ...plugin, name: 'Older Notes' }]))
|
||||
|
||||
expect(container.textContent).toContain('Newer Notes')
|
||||
expect(container.textContent).not.toContain('Older Notes')
|
||||
act(() => root.unmount())
|
||||
expect(unsubscribe).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('persists the feature switch before refreshing discovery', async () => {
|
||||
const updateSettings = vi.fn().mockResolvedValue(undefined)
|
||||
const { container } = await renderSection(
|
||||
{ ...getDefaultSettings('/tmp'), pluginSystemEnabled: false },
|
||||
updateSettings
|
||||
)
|
||||
const featureSwitch = container.querySelector('[aria-labelledby="plugin-system-label"]')
|
||||
if (!featureSwitch) {
|
||||
throw new Error('missing feature switch')
|
||||
}
|
||||
expect(window.api.plugins.list).not.toHaveBeenCalled()
|
||||
|
||||
await act(async () => click(featureSwitch))
|
||||
|
||||
expect(updateSettings).toHaveBeenCalledWith({ pluginSystemEnabled: true })
|
||||
expect(window.api.plugins.refresh).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('routes enablement and confirmed removal through plugin IPC', async () => {
|
||||
const { container } = await renderSection()
|
||||
const pluginSwitch = container.querySelector('[aria-label="Disable Notes"]')
|
||||
if (!pluginSwitch) {
|
||||
throw new Error('missing plugin switch')
|
||||
}
|
||||
|
||||
await act(async () => click(pluginSwitch))
|
||||
expect(window.api.plugins.setEnabled).toHaveBeenCalledWith({
|
||||
pluginKey: plugin.pluginKey,
|
||||
enabled: false
|
||||
})
|
||||
await act(async () => click(pluginSwitch))
|
||||
expect(window.api.plugins.setEnabled).toHaveBeenLastCalledWith({
|
||||
pluginKey: plugin.pluginKey,
|
||||
enabled: true
|
||||
})
|
||||
|
||||
const removeAction = Array.from(container.querySelectorAll('button')).find(
|
||||
(button) => button.textContent === 'Remove'
|
||||
)
|
||||
if (!removeAction) {
|
||||
throw new Error('missing remove action')
|
||||
}
|
||||
await act(async () => click(removeAction))
|
||||
const confirm = Array.from(container.querySelectorAll('button')).find(
|
||||
(button) => button.textContent === 'Confirm remove'
|
||||
)
|
||||
if (!confirm) {
|
||||
throw new Error('missing remove confirmation')
|
||||
}
|
||||
await act(async () => click(confirm))
|
||||
expect(window.api.plugins.remove).toHaveBeenCalledWith({ pluginKey: plugin.pluginKey })
|
||||
})
|
||||
|
||||
it('accepts overlapping mutations in completion order without regressing final state', async () => {
|
||||
const firstToggle = deferred<PluginHostListEntry[]>()
|
||||
const secondToggle = deferred<PluginHostListEntry[]>()
|
||||
installApi({
|
||||
list: vi.fn().mockResolvedValue([plugin, secondPlugin]),
|
||||
setEnabled: vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(firstToggle.promise)
|
||||
.mockReturnValueOnce(secondToggle.promise)
|
||||
})
|
||||
const { container } = await renderSection()
|
||||
const notesSwitch = container.querySelector<HTMLButtonElement>('[aria-label="Disable Notes"]')
|
||||
const tasksSwitch = container.querySelector<HTMLButtonElement>('[aria-label="Disable Tasks"]')
|
||||
if (!notesSwitch || !tasksSwitch) {
|
||||
throw new Error('missing plugin switches')
|
||||
}
|
||||
|
||||
act(() => click(notesSwitch))
|
||||
act(() => click(tasksSwitch))
|
||||
expect(notesSwitch.disabled).toBe(true)
|
||||
expect(tasksSwitch.disabled).toBe(true)
|
||||
|
||||
await act(async () => secondToggle.resolve([plugin, { ...secondPlugin, status: 'disabled' }]))
|
||||
|
||||
expect(notesSwitch.disabled).toBe(true)
|
||||
expect(tasksSwitch.disabled).toBe(false)
|
||||
await act(async () =>
|
||||
firstToggle.resolve([
|
||||
{ ...plugin, status: 'disabled' },
|
||||
{ ...secondPlugin, status: 'disabled' }
|
||||
])
|
||||
)
|
||||
expect(notesSwitch.disabled).toBe(false)
|
||||
expect(notesSwitch.getAttribute('aria-checked')).toBe('false')
|
||||
expect(tasksSwitch.getAttribute('aria-checked')).toBe('false')
|
||||
})
|
||||
|
||||
it('fetches bounded logs only when their disclosure opens', async () => {
|
||||
const getLogs = vi.fn().mockResolvedValue([{ ts: 1, level: 'info', line: 'started' }])
|
||||
installApi({ getLogs })
|
||||
const { container } = await renderSection()
|
||||
expect(getLogs).not.toHaveBeenCalled()
|
||||
const viewLogs = Array.from(container.querySelectorAll('button')).find(
|
||||
(button) => button.textContent === 'View logs'
|
||||
)
|
||||
if (!viewLogs) {
|
||||
throw new Error('missing logs action')
|
||||
}
|
||||
|
||||
await act(async () => click(viewLogs))
|
||||
|
||||
expect(getLogs).toHaveBeenCalledWith({ pluginKey: plugin.pluginKey })
|
||||
expect(container.textContent).toContain('started')
|
||||
})
|
||||
|
||||
it('shows supervised backoff as an enabled restarting plugin', async () => {
|
||||
installApi({
|
||||
list: vi.fn().mockResolvedValue([{ ...plugin, status: 'restarting', restarts: 1 }])
|
||||
})
|
||||
const { container } = await renderSection()
|
||||
const pluginSwitch = container.querySelector<HTMLButtonElement>('[aria-label="Disable Notes"]')
|
||||
|
||||
expect(container.textContent).toContain('Restarting')
|
||||
expect(pluginSwitch?.getAttribute('aria-checked')).toBe('true')
|
||||
})
|
||||
|
||||
it('prunes dialogs and stale logs after external removal and same-key reinstall', async () => {
|
||||
const removedList = deferred<PluginHostListEntry[]>()
|
||||
const reinstalledList = deferred<PluginHostListEntry[]>()
|
||||
const oldLogs = deferred<Awaited<ReturnType<typeof window.api.plugins.getLogs>>>()
|
||||
const freshLogs = deferred<Awaited<ReturnType<typeof window.api.plugins.getLogs>>>()
|
||||
const pendingPlugin = { ...plugin, status: 'pending' as const }
|
||||
let onChanged: (() => void) | undefined
|
||||
const unsubscribe = vi.fn()
|
||||
installApi({
|
||||
list: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce([pendingPlugin])
|
||||
.mockReturnValueOnce(removedList.promise)
|
||||
.mockReturnValueOnce(reinstalledList.promise),
|
||||
onChanged: vi.fn((callback: () => void) => {
|
||||
onChanged = callback
|
||||
return unsubscribe
|
||||
}),
|
||||
getLogs: vi.fn().mockReturnValueOnce(oldLogs.promise).mockReturnValueOnce(freshLogs.promise)
|
||||
})
|
||||
const { container } = await renderSection()
|
||||
const findButton = (label: string): HTMLButtonElement | undefined =>
|
||||
Array.from(container.querySelectorAll('button')).find(
|
||||
(button) => button.textContent?.trim() === label
|
||||
)
|
||||
|
||||
act(() => click(findButton('Review permissions')!))
|
||||
act(() => click(findButton('Mark consent dirty')!))
|
||||
act(() => click(findButton('View logs')!))
|
||||
act(() => click(findButton('Remove')!))
|
||||
expect(container.textContent).toContain('Consent local state')
|
||||
expect(container.textContent).toContain('Confirm remove')
|
||||
|
||||
act(() => onChanged?.())
|
||||
await act(async () => removedList.resolve([]))
|
||||
|
||||
expect(container.textContent).not.toContain('Approve')
|
||||
expect(container.textContent).not.toContain('Confirm remove')
|
||||
act(() => onChanged?.())
|
||||
await act(async () => reinstalledList.resolve([pendingPlugin]))
|
||||
expect(container.textContent).not.toContain('Loading logs…')
|
||||
|
||||
act(() => click(findButton('Review permissions')!))
|
||||
expect(container.textContent).not.toContain('Consent local state')
|
||||
act(() => click(findButton('View logs')!))
|
||||
await act(async () => freshLogs.resolve([{ ts: 2, level: 'info', line: 'fresh installation' }]))
|
||||
await act(async () => oldLogs.resolve([{ ts: 1, level: 'info', line: 'stale installation' }]))
|
||||
|
||||
expect(container.textContent).toContain('fresh installation')
|
||||
expect(container.textContent).not.toContain('stale installation')
|
||||
})
|
||||
|
||||
it('clears feature busy state and skips refresh when the setting write fails', async () => {
|
||||
const updateSettings = vi.fn().mockRejectedValue(new Error('disk write failed'))
|
||||
const { container } = await renderSection(
|
||||
{ ...getDefaultSettings('/tmp'), pluginSystemEnabled: false },
|
||||
updateSettings
|
||||
)
|
||||
const featureSwitch = container.querySelector<HTMLButtonElement>(
|
||||
'[aria-labelledby="plugin-system-label"]'
|
||||
)
|
||||
if (!featureSwitch) {
|
||||
throw new Error('missing feature switch')
|
||||
}
|
||||
|
||||
await act(async () => click(featureSwitch))
|
||||
|
||||
expect(featureSwitch.disabled).toBe(false)
|
||||
expect(window.api.plugins.refresh).not.toHaveBeenCalled()
|
||||
expect(container.textContent).toContain('Could not save plugin settings.')
|
||||
})
|
||||
|
||||
it('persists verbatim development paths and refreshes discovery', async () => {
|
||||
const updateSettings = vi.fn().mockResolvedValue(undefined)
|
||||
const { container } = await renderSection(undefined, updateSettings)
|
||||
const input = container.querySelector<HTMLInputElement>('details input')
|
||||
if (!input) {
|
||||
throw new Error('missing development path input')
|
||||
}
|
||||
await act(async () => {
|
||||
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(
|
||||
input,
|
||||
'C:\\plugins\\demo'
|
||||
)
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
})
|
||||
const add = Array.from(container.querySelectorAll('button')).find(
|
||||
(button) => button.textContent === 'Add path'
|
||||
)
|
||||
if (!add) {
|
||||
throw new Error('missing add path action')
|
||||
}
|
||||
|
||||
await act(async () => click(add))
|
||||
|
||||
expect(updateSettings).toHaveBeenCalledWith({ devPluginPaths: ['C:\\plugins\\demo'] })
|
||||
expect(window.api.plugins.refresh).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves development input and clears busy when adding a path fails', async () => {
|
||||
const updateSettings = vi.fn().mockRejectedValue(new Error('disk write failed'))
|
||||
const { container } = await renderSection(undefined, updateSettings)
|
||||
const input = container.querySelector<HTMLInputElement>('details input')
|
||||
if (!input) {
|
||||
throw new Error('missing development path input')
|
||||
}
|
||||
await act(async () => {
|
||||
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(
|
||||
input,
|
||||
'/plugins/demo'
|
||||
)
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
})
|
||||
const add = Array.from(container.querySelectorAll('button')).find(
|
||||
(button) => button.textContent === 'Add path'
|
||||
)
|
||||
if (!add) {
|
||||
throw new Error('missing add path action')
|
||||
}
|
||||
|
||||
await act(async () => click(add))
|
||||
|
||||
expect(input.value).toBe('/plugins/demo')
|
||||
expect(add.disabled).toBe(false)
|
||||
expect(window.api.plugins.refresh).not.toHaveBeenCalled()
|
||||
expect(container.textContent).toContain('Could not save plugin settings.')
|
||||
})
|
||||
|
||||
it('keeps a development path and handles a failed removal without rejection', async () => {
|
||||
const updateSettings = vi.fn().mockRejectedValue(new Error('disk write failed'))
|
||||
const settings = {
|
||||
...getDefaultSettings('/tmp'),
|
||||
pluginSystemEnabled: true,
|
||||
devPluginPaths: ['/plugins/demo']
|
||||
}
|
||||
const { container } = await renderSection(settings, updateSettings)
|
||||
const remove = Array.from(container.querySelectorAll('button')).find(
|
||||
(button) => button.textContent === 'Remove' && button.closest('details')
|
||||
)
|
||||
if (!remove) {
|
||||
throw new Error('missing development path remove action')
|
||||
}
|
||||
|
||||
await act(async () => click(remove))
|
||||
|
||||
expect(container.textContent).toContain('/plugins/demo')
|
||||
expect(remove.disabled).toBe(false)
|
||||
expect(window.api.plugins.refresh).not.toHaveBeenCalled()
|
||||
expect(container.textContent).toContain('Could not save plugin settings.')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,426 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Plus } from 'lucide-react'
|
||||
import type {
|
||||
PluginHostInstallSource,
|
||||
PluginHostListEntry,
|
||||
PluginHostLogLine
|
||||
} from '../../../../preload/api-types'
|
||||
import type { GlobalSettings } from '../../../../shared/types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { Button } from '../ui/button'
|
||||
import { PluginConsentDialog } from './PluginConsentDialog'
|
||||
import { PluginInstallDialog } from './PluginInstallDialog'
|
||||
import { PluginRemoveDialog } from './PluginRemoveDialog'
|
||||
import { PluginSettingsOverview } from './PluginSettingsOverview'
|
||||
import type { PluginLogsState } from './PluginSettingsRow'
|
||||
import { getPluginsPaneSearchEntries } from './plugins-search'
|
||||
import { SettingsSection } from './SettingsSection'
|
||||
|
||||
type PluginsSettingsSectionProps = {
|
||||
mounted: boolean
|
||||
settings: GlobalSettings
|
||||
updateSettings: (updates: Partial<GlobalSettings>) => Promise<void>
|
||||
}
|
||||
|
||||
function errorMessage(cause: unknown, fallback: string): string {
|
||||
console.warn('[plugins] settings action failed:', cause)
|
||||
return fallback
|
||||
}
|
||||
|
||||
export function PluginsSettingsSection({
|
||||
mounted,
|
||||
settings,
|
||||
updateSettings
|
||||
}: PluginsSettingsSectionProps): React.JSX.Element {
|
||||
const [plugins, setPlugins] = useState<PluginHostListEntry[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [installOpen, setInstallOpen] = useState(false)
|
||||
const [consentPluginId, setConsentPluginId] = useState<string | null>(null)
|
||||
const [removePluginId, setRemovePluginId] = useState<string | null>(null)
|
||||
const [busyPluginKeys, setBusyPluginKeys] = useState<Set<string>>(() => new Set())
|
||||
const [featureBusy, setFeatureBusy] = useState(false)
|
||||
const [refreshBusy, setRefreshBusy] = useState(false)
|
||||
const [devPathsBusy, setDevPathsBusy] = useState(false)
|
||||
const [settingsError, setSettingsError] = useState<string | null>(null)
|
||||
const [openLogs, setOpenLogs] = useState<Set<string>>(() => new Set())
|
||||
const [logsByPlugin, setLogsByPlugin] = useState<Record<string, PluginLogsState>>({})
|
||||
const mountedRef = useRef(false)
|
||||
const listRequestRef = useRef(0)
|
||||
const nextLogsRequestRef = useRef(0)
|
||||
const logsRequestRef = useRef<Record<string, number>>({})
|
||||
|
||||
const applyPluginList = (nextPlugins: PluginHostListEntry[]): void => {
|
||||
const installedPluginKeys = new Set(nextPlugins.map((plugin) => plugin.pluginKey))
|
||||
setPlugins(nextPlugins)
|
||||
setError(null)
|
||||
// Why: accepted discovery results own installed-plugin identity and invalidate stale UI state.
|
||||
setConsentPluginId((current) => (current && installedPluginKeys.has(current) ? current : null))
|
||||
setRemovePluginId((current) => (current && installedPluginKeys.has(current) ? current : null))
|
||||
setBusyPluginKeys(
|
||||
(current) => new Set([...current].filter((pluginKey) => installedPluginKeys.has(pluginKey)))
|
||||
)
|
||||
setOpenLogs(
|
||||
(current) => new Set([...current].filter((pluginKey) => installedPluginKeys.has(pluginKey)))
|
||||
)
|
||||
setLogsByPlugin((current) =>
|
||||
Object.fromEntries(
|
||||
Object.entries(current).filter(([pluginKey]) => installedPluginKeys.has(pluginKey))
|
||||
)
|
||||
)
|
||||
for (const pluginKey of Object.keys(logsRequestRef.current)) {
|
||||
if (!installedPluginKeys.has(pluginKey)) {
|
||||
delete logsRequestRef.current[pluginKey]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const setPluginListError = (cause: unknown): void => {
|
||||
setError(
|
||||
errorMessage(
|
||||
cause,
|
||||
translate(
|
||||
'auto.components.settings.PluginsSettingsSection.loadFailed',
|
||||
'Could not load plugins.'
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const loadPluginList = async (
|
||||
request: Promise<PluginHostListEntry[]>
|
||||
): Promise<PluginHostListEntry[] | null> => {
|
||||
const requestId = ++listRequestRef.current
|
||||
try {
|
||||
const nextPlugins = await request
|
||||
if (!mountedRef.current || requestId !== listRequestRef.current) {
|
||||
return null
|
||||
}
|
||||
applyPluginList(nextPlugins)
|
||||
return nextPlugins
|
||||
} catch (cause) {
|
||||
if (mountedRef.current && requestId === listRequestRef.current) {
|
||||
setPluginListError(cause)
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const applyCompletedMutation = (nextPlugins: PluginHostListEntry[]): void => {
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
}
|
||||
// Why: a mutation result is authoritative at completion time, even when an earlier action
|
||||
// finishes after a later-started action or a passive refresh is still in flight.
|
||||
listRequestRef.current += 1
|
||||
applyPluginList(nextPlugins)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = mounted
|
||||
if (!mounted) {
|
||||
// Why: hidden lazy panes must not resurrect dialogs or unresolved async state when reopened.
|
||||
setPlugins([])
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setInstallOpen(false)
|
||||
setConsentPluginId(null)
|
||||
setRemovePluginId(null)
|
||||
setBusyPluginKeys(new Set())
|
||||
setFeatureBusy(false)
|
||||
setRefreshBusy(false)
|
||||
setDevPathsBusy(false)
|
||||
setSettingsError(null)
|
||||
setOpenLogs(new Set())
|
||||
setLogsByPlugin({})
|
||||
logsRequestRef.current = {}
|
||||
}
|
||||
return () => {
|
||||
mountedRef.current = false
|
||||
listRequestRef.current += 1
|
||||
}
|
||||
}, [mounted])
|
||||
|
||||
useEffect(() => {
|
||||
if (!mounted || !settings.pluginSystemEnabled) {
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
const load = async (): Promise<void> => {
|
||||
await loadPluginList(window.api.plugins.list())
|
||||
if (mountedRef.current) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
void load()
|
||||
const unsubscribe = window.api.plugins.onChanged(() => {
|
||||
void loadPluginList(window.api.plugins.list())
|
||||
})
|
||||
return () => {
|
||||
listRequestRef.current += 1
|
||||
unsubscribe()
|
||||
}
|
||||
}, [mounted, settings.pluginSystemEnabled])
|
||||
|
||||
const sectionPresentation = {
|
||||
title: translate('auto.components.settings.PluginsSettingsSection.title', 'Plugins'),
|
||||
badge: translate(
|
||||
'auto.components.settings.PluginsSettingsSection.experimental',
|
||||
'Experimental'
|
||||
),
|
||||
description: translate(
|
||||
'auto.components.settings.PluginsSettingsSection.description',
|
||||
'Install and manage Orca plugins. Plugins run on this computer, even for SSH workspaces.'
|
||||
),
|
||||
searchEntries: getPluginsPaneSearchEntries()
|
||||
}
|
||||
|
||||
if (!mounted) {
|
||||
return <SettingsSection id="plugins" {...sectionPresentation} />
|
||||
}
|
||||
|
||||
const selectedConsentPlugin =
|
||||
plugins.find((plugin) => plugin.pluginKey === consentPluginId) ?? null
|
||||
const consentPlugin = selectedConsentPlugin?.consentFingerprint ? selectedConsentPlugin : null
|
||||
const removePlugin = plugins.find((plugin) => plugin.pluginKey === removePluginId) ?? null
|
||||
|
||||
const toggleFeature = async (): Promise<void> => {
|
||||
setFeatureBusy(true)
|
||||
setSettingsError(null)
|
||||
setError(null)
|
||||
try {
|
||||
await updateSettings({ pluginSystemEnabled: !settings.pluginSystemEnabled })
|
||||
await loadPluginList(window.api.plugins.refresh())
|
||||
} catch {
|
||||
if (mountedRef.current) {
|
||||
setSettingsError(
|
||||
translate(
|
||||
'auto.components.settings.PluginsSettingsSection.settingsUpdateFailed',
|
||||
'Could not save plugin settings.'
|
||||
)
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
if (mountedRef.current) {
|
||||
setFeatureBusy(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const install = async (source: PluginHostInstallSource): Promise<void> => {
|
||||
const result = await window.api.plugins.install(source)
|
||||
if (!result.ok) {
|
||||
throw new Error(result.error)
|
||||
}
|
||||
// Why: installation is not consent; select by id and wait for the authoritative list.
|
||||
setInstallOpen(false)
|
||||
setConsentPluginId(result.pluginKey)
|
||||
await loadPluginList(window.api.plugins.refresh())
|
||||
}
|
||||
|
||||
const decideConsent = async (
|
||||
pluginKey: string,
|
||||
reviewedFingerprint: string,
|
||||
decision: 'approve' | 'keep-disabled'
|
||||
): Promise<void> => {
|
||||
setBusyPluginKeys((current) => new Set(current).add(pluginKey))
|
||||
try {
|
||||
const nextPlugins = await window.api.plugins.consent({
|
||||
pluginKey,
|
||||
reviewedFingerprint,
|
||||
decision
|
||||
})
|
||||
applyCompletedMutation(nextPlugins)
|
||||
if (mountedRef.current) {
|
||||
setConsentPluginId(null)
|
||||
}
|
||||
} finally {
|
||||
if (mountedRef.current) {
|
||||
setBusyPluginKeys((current) => {
|
||||
const next = new Set(current)
|
||||
next.delete(pluginKey)
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const toggleEnabled = async (plugin: PluginHostListEntry): Promise<void> => {
|
||||
const enabled =
|
||||
plugin.status === 'running' ||
|
||||
plugin.status === 'restarting' ||
|
||||
plugin.status === 'idle' ||
|
||||
plugin.status === 'errored'
|
||||
setBusyPluginKeys((current) => new Set(current).add(plugin.pluginKey))
|
||||
try {
|
||||
const nextPlugins = await window.api.plugins.setEnabled({
|
||||
pluginKey: plugin.pluginKey,
|
||||
enabled: !enabled
|
||||
})
|
||||
applyCompletedMutation(nextPlugins)
|
||||
} catch (cause) {
|
||||
if (mountedRef.current) {
|
||||
setPluginListError(cause)
|
||||
}
|
||||
} finally {
|
||||
if (mountedRef.current) {
|
||||
setBusyPluginKeys((current) => {
|
||||
const next = new Set(current)
|
||||
next.delete(plugin.pluginKey)
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const remove = async (pluginKey: string): Promise<void> => {
|
||||
setBusyPluginKeys((current) => new Set(current).add(pluginKey))
|
||||
try {
|
||||
const nextPlugins = await window.api.plugins.remove({ pluginKey })
|
||||
applyCompletedMutation(nextPlugins)
|
||||
if (mountedRef.current) {
|
||||
setRemovePluginId(null)
|
||||
}
|
||||
} catch (cause) {
|
||||
if (mountedRef.current) {
|
||||
setPluginListError(cause)
|
||||
}
|
||||
} finally {
|
||||
if (mountedRef.current) {
|
||||
setBusyPluginKeys((current) => {
|
||||
const next = new Set(current)
|
||||
next.delete(pluginKey)
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const refresh = async (): Promise<void> => {
|
||||
setRefreshBusy(true)
|
||||
await loadPluginList(window.api.plugins.refresh())
|
||||
if (mountedRef.current) {
|
||||
setRefreshBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleLogs = (pluginKey: string): void => {
|
||||
if (openLogs.has(pluginKey)) {
|
||||
setOpenLogs((current) => {
|
||||
const next = new Set(current)
|
||||
next.delete(pluginKey)
|
||||
return next
|
||||
})
|
||||
return
|
||||
}
|
||||
setOpenLogs((current) => new Set(current).add(pluginKey))
|
||||
if (logsByPlugin[pluginKey]?.lines) {
|
||||
return
|
||||
}
|
||||
const requestId = ++nextLogsRequestRef.current
|
||||
logsRequestRef.current[pluginKey] = requestId
|
||||
setLogsByPlugin((current) => ({ ...current, [pluginKey]: { loading: true } }))
|
||||
void window.api.plugins
|
||||
.getLogs({ pluginKey })
|
||||
.then((lines: PluginHostLogLine[]) => {
|
||||
if (mountedRef.current && logsRequestRef.current[pluginKey] === requestId) {
|
||||
setLogsByPlugin((current) => ({
|
||||
...current,
|
||||
[pluginKey]: { loading: false, lines }
|
||||
}))
|
||||
}
|
||||
})
|
||||
.catch((cause: unknown) => {
|
||||
if (mountedRef.current && logsRequestRef.current[pluginKey] === requestId) {
|
||||
setLogsByPlugin((current) => ({
|
||||
...current,
|
||||
[pluginKey]: {
|
||||
loading: false,
|
||||
error: errorMessage(
|
||||
cause,
|
||||
translate(
|
||||
'auto.components.settings.PluginsSettingsSection.logsFailed',
|
||||
'Could not load plugin logs.'
|
||||
)
|
||||
)
|
||||
}
|
||||
}))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const updateDevPaths = async (paths: string[]): Promise<void> => {
|
||||
setDevPathsBusy(true)
|
||||
setSettingsError(null)
|
||||
try {
|
||||
await updateSettings({ devPluginPaths: paths })
|
||||
await loadPluginList(window.api.plugins.refresh())
|
||||
} catch {
|
||||
const message = translate(
|
||||
'auto.components.settings.PluginsSettingsSection.settingsUpdateFailed',
|
||||
'Could not save plugin settings.'
|
||||
)
|
||||
if (mountedRef.current) {
|
||||
setSettingsError(message)
|
||||
}
|
||||
throw new Error(message)
|
||||
} finally {
|
||||
if (mountedRef.current) {
|
||||
setDevPathsBusy(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const featureEnabled = settings.pluginSystemEnabled
|
||||
return (
|
||||
<SettingsSection
|
||||
id="plugins"
|
||||
{...sectionPresentation}
|
||||
headerAction={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!featureEnabled || featureBusy}
|
||||
onClick={() => setInstallOpen(true)}
|
||||
>
|
||||
<Plus />
|
||||
{translate('auto.components.settings.PluginsSettingsSection.install', 'Install plugin')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<PluginSettingsOverview
|
||||
featureEnabled={featureEnabled}
|
||||
featureBusy={featureBusy}
|
||||
settingsError={settingsError}
|
||||
loading={loading}
|
||||
refreshBusy={refreshBusy}
|
||||
error={error}
|
||||
plugins={plugins}
|
||||
busyPluginKeys={busyPluginKeys}
|
||||
openLogs={openLogs}
|
||||
logsByPlugin={logsByPlugin}
|
||||
devPaths={settings.devPluginPaths}
|
||||
devPathsBusy={devPathsBusy}
|
||||
onToggleFeature={() => void toggleFeature()}
|
||||
onRefresh={() => void refresh()}
|
||||
onReview={setConsentPluginId}
|
||||
onToggleEnabled={(entry) => void toggleEnabled(entry)}
|
||||
onToggleLogs={toggleLogs}
|
||||
onRemoveRequest={setRemovePluginId}
|
||||
onUpdateDevPaths={updateDevPaths}
|
||||
/>
|
||||
<PluginInstallDialog open={installOpen} onOpenChange={setInstallOpen} onInstall={install} />
|
||||
<PluginConsentDialog
|
||||
key={consentPlugin?.pluginKey ?? 'closed'}
|
||||
plugin={consentPlugin}
|
||||
onDecision={decideConsent}
|
||||
/>
|
||||
<PluginRemoveDialog
|
||||
plugin={removePlugin}
|
||||
busy={Boolean(removePlugin && busyPluginKeys.has(removePlugin.pluginKey))}
|
||||
onCancel={() => setRemovePluginId(null)}
|
||||
onConfirm={(pluginKey) => void remove(pluginKey)}
|
||||
/>
|
||||
</SettingsSection>
|
||||
)
|
||||
}
|
||||
@@ -47,6 +47,7 @@ import { NotificationsPane } from './NotificationsPane'
|
||||
import { VoicePane } from './VoicePane'
|
||||
import { SshPane } from './SshPane'
|
||||
import { ExperimentalPane } from './ExperimentalPane'
|
||||
import { PluginsSettingsSection } from './PluginsSettingsSection'
|
||||
import { AgentsPane } from './AgentsPane'
|
||||
import { OrchestrationPane } from './OrchestrationPane'
|
||||
import { AccountsPane } from './AccountsPane'
|
||||
@@ -271,6 +272,7 @@ function Settings(): React.JSX.Element {
|
||||
const settings = useAppStore((s) => s.settings)
|
||||
const keybindings = useAppStore((s) => s.keybindings)
|
||||
const updateSettings = useAppStore((s) => s.updateSettings)
|
||||
const updateSettingsOrThrow = useAppStore((s) => s.updateSettingsOrThrow)
|
||||
const switchRuntimeEnvironment = useAppStore((s) => s.switchRuntimeEnvironment)
|
||||
const fetchSettings = useAppStore((s) => s.fetchSettings)
|
||||
const fetchKeybindings = useAppStore((s) => s.fetchKeybindings)
|
||||
@@ -1646,6 +1648,14 @@ function Settings(): React.JSX.Element {
|
||||
) : null}
|
||||
</SettingsSection>
|
||||
|
||||
{showDesktopOnlySettings ? (
|
||||
<PluginsSettingsSection
|
||||
mounted={isSectionMounted('plugins')}
|
||||
settings={settings}
|
||||
updateSettings={updateSettingsOrThrow}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{repos.map((repo) => {
|
||||
const repoSectionId = `repo-${repo.id}`
|
||||
const repoHooksState = repoHooksMap[repo.id]
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
function errorText(cause: unknown): string {
|
||||
return (cause instanceof Error ? cause.message : String(cause)).toLowerCase()
|
||||
}
|
||||
|
||||
export function pluginInstallErrorMessage(cause: unknown): string {
|
||||
const detail = errorText(cause)
|
||||
if (detail.includes('orca-plugin.json') && /(missing|unreadable|no )/.test(detail)) {
|
||||
return translate(
|
||||
'auto.components.settings.pluginError.installManifestMissing',
|
||||
"No readable orca-plugin.json was found. Choose the plugin's root folder."
|
||||
)
|
||||
}
|
||||
if (detail.includes('invalid manifest')) {
|
||||
return translate(
|
||||
'auto.components.settings.pluginError.installManifestInvalid',
|
||||
'orca-plugin.json is invalid. Ask the plugin author to fix the manifest.'
|
||||
)
|
||||
}
|
||||
if (detail.includes('requires orca')) {
|
||||
return translate(
|
||||
'auto.components.settings.pluginError.incompatible',
|
||||
'This plugin requires a different Orca version.'
|
||||
)
|
||||
}
|
||||
if (/(symlink|outside|absolute|path traversal|drive prefix)/.test(detail)) {
|
||||
return translate(
|
||||
'auto.components.settings.pluginError.installUnsafePath',
|
||||
'The plugin contains an unsafe file path or symlink and was not installed.'
|
||||
)
|
||||
}
|
||||
if (/(exceeds|too many)/.test(detail)) {
|
||||
return translate(
|
||||
'auto.components.settings.pluginError.installLimit',
|
||||
"The plugin exceeds Orca's install size or file-count limits."
|
||||
)
|
||||
}
|
||||
if (/(git|repository|fetch|clone|checkout|remote)/.test(detail)) {
|
||||
return translate(
|
||||
'auto.components.settings.pluginError.installGit',
|
||||
'Orca could not fetch the pinned Git revision. Check the URL, #ref, access, and system Git setup.'
|
||||
)
|
||||
}
|
||||
return translate(
|
||||
'auto.components.settings.PluginInstallDialog.installFailed',
|
||||
'Plugin installation failed. Check the source and try again.'
|
||||
)
|
||||
}
|
||||
|
||||
export function invalidPluginErrorMessage(detailValue: string): string {
|
||||
const detail = detailValue.toLowerCase()
|
||||
if (detail.includes('missing orca-plugin.json')) {
|
||||
return translate(
|
||||
'auto.components.settings.pluginError.invalidManifestMissing',
|
||||
'The plugin root is missing orca-plugin.json. Add it, then refresh plugins.'
|
||||
)
|
||||
}
|
||||
if (detail.includes('invalid manifest')) {
|
||||
return translate(
|
||||
'auto.components.settings.pluginError.invalidManifest',
|
||||
'orca-plugin.json is invalid. Fix it, then refresh plugins.'
|
||||
)
|
||||
}
|
||||
if (detail.includes('artifact')) {
|
||||
return translate(
|
||||
'auto.components.settings.pluginError.invalidArtifact',
|
||||
'A declared worker or panel file is missing or unsafe. Fix the plugin files, then refresh.'
|
||||
)
|
||||
}
|
||||
if (detail.includes('requires orca')) {
|
||||
return translate(
|
||||
'auto.components.settings.pluginError.incompatible',
|
||||
'This plugin requires a different Orca version.'
|
||||
)
|
||||
}
|
||||
return translate(
|
||||
'auto.components.settings.PluginSettingsRow.invalidPluginError',
|
||||
'The plugin manifest or installed files are invalid. Fix the plugin, then refresh.'
|
||||
)
|
||||
}
|
||||
|
||||
export function pluginConsentErrorMessage(cause: unknown): string {
|
||||
const detail = errorText(cause)
|
||||
if (/(changed|fingerprint|current|stale|review)/.test(detail)) {
|
||||
return translate(
|
||||
'auto.components.settings.pluginError.consentChanged',
|
||||
'The plugin changed while you were reviewing it. Close this dialog and review the updated permissions.'
|
||||
)
|
||||
}
|
||||
return translate(
|
||||
'auto.components.settings.PluginConsentDialog.decisionFailed',
|
||||
'Could not save the permission decision. Try again.'
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parsePluginInstallSource } from './plugin-install-source'
|
||||
|
||||
describe('parsePluginInstallSource', () => {
|
||||
it('requires an explicit git hash ref', () => {
|
||||
expect(parsePluginInstallSource('git', 'https://gitlab.example/acme/plugin')).toEqual({
|
||||
ok: false,
|
||||
reason: 'missing-git-ref'
|
||||
})
|
||||
expect(parsePluginInstallSource('git', 'https://gitlab.example/acme/plugin#')).toEqual({
|
||||
ok: false,
|
||||
reason: 'missing-git-ref'
|
||||
})
|
||||
})
|
||||
|
||||
it('separates the git URL and ref at the last hash', () => {
|
||||
expect(parsePluginInstallSource('git', 'https://example.test/plugin.git#v1.2.3')).toEqual({
|
||||
ok: true,
|
||||
source: { kind: 'git', url: 'https://example.test/plugin.git', ref: 'v1.2.3' }
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts HTTPS and SSH git URLs', () => {
|
||||
expect(parsePluginInstallSource('git', 'ssh://git@example.test/acme/plugin.git#main')).toEqual({
|
||||
ok: true,
|
||||
source: { kind: 'git', url: 'ssh://git@example.test/acme/plugin.git', ref: 'main' }
|
||||
})
|
||||
expect(parsePluginInstallSource('git', 'git@example.test:acme/plugin.git#main')).toEqual({
|
||||
ok: true,
|
||||
source: { kind: 'git', url: 'git@example.test:acme/plugin.git', ref: 'main' }
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects executable helpers and embedded HTTPS credentials', () => {
|
||||
expect(parsePluginInstallSource('git', 'ext::sh -c touch% /tmp/pwned#main')).toEqual({
|
||||
ok: false,
|
||||
reason: 'invalid-git-url'
|
||||
})
|
||||
expect(parsePluginInstallSource('git', 'https://user@example.test/plugin.git#main')).toEqual({
|
||||
ok: false,
|
||||
reason: 'invalid-git-url'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps local paths opaque', () => {
|
||||
expect(parsePluginInstallSource('local-path', ' C:\\plugins\\demo ')).toEqual({
|
||||
ok: true,
|
||||
source: { kind: 'local-path', path: 'C:\\plugins\\demo' }
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { PluginHostInstallSource } from '../../../../preload/api-types'
|
||||
import { isAllowedPluginGitUrl } from '../../../../shared/plugins/plugin-install-lockfile'
|
||||
|
||||
export type PluginInstallSourceParseResult =
|
||||
| { ok: true; source: PluginHostInstallSource }
|
||||
| {
|
||||
ok: false
|
||||
reason: 'missing-local-path' | 'missing-git-url' | 'missing-git-ref' | 'invalid-git-url'
|
||||
}
|
||||
|
||||
export function parsePluginInstallSource(
|
||||
kind: 'local-path' | 'git',
|
||||
input: string
|
||||
): PluginInstallSourceParseResult {
|
||||
const value = input.trim()
|
||||
if (kind === 'local-path') {
|
||||
return value
|
||||
? { ok: true, source: { kind: 'local-path', path: value } }
|
||||
: { ok: false, reason: 'missing-local-path' }
|
||||
}
|
||||
if (!value) {
|
||||
return { ok: false, reason: 'missing-git-url' }
|
||||
}
|
||||
const separatorIndex = value.lastIndexOf('#')
|
||||
if (separatorIndex <= 0 || separatorIndex === value.length - 1) {
|
||||
return { ok: false, reason: 'missing-git-ref' }
|
||||
}
|
||||
const url = value.slice(0, separatorIndex).trim()
|
||||
const ref = value.slice(separatorIndex + 1).trim()
|
||||
if (!url) {
|
||||
return { ok: false, reason: 'missing-git-url' }
|
||||
}
|
||||
if (!isAllowedPluginGitUrl(url)) {
|
||||
return { ok: false, reason: 'invalid-git-url' }
|
||||
}
|
||||
if (!ref) {
|
||||
return { ok: false, reason: 'missing-git-ref' }
|
||||
}
|
||||
return { ok: true, source: { kind: 'git', url, ref } }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { createLocalizedCatalog } from '@/i18n/localized-catalog'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { SettingsSearchEntry } from './settings-search'
|
||||
|
||||
export const getPluginsPaneSearchEntries = createLocalizedCatalog((): SettingsSearchEntry[] => [
|
||||
{
|
||||
title: translate('auto.components.settings.plugins.search.title', 'Plugins'),
|
||||
description: translate(
|
||||
'auto.components.settings.plugins.search.description',
|
||||
'Install and manage experimental Orca plugins.'
|
||||
),
|
||||
keywords: [
|
||||
translate('auto.components.settings.plugins.search.install', 'install plugin'),
|
||||
translate('auto.components.settings.plugins.search.permissions', 'plugin permissions'),
|
||||
translate('auto.components.settings.plugins.search.logs', 'plugin logs'),
|
||||
translate('auto.components.settings.plugins.search.development', 'development plugins')
|
||||
]
|
||||
}
|
||||
])
|
||||
@@ -105,6 +105,20 @@ describe('settings navigation metadata', () => {
|
||||
expect(entry?.targetSectionId).toBe('ephemeral-vms')
|
||||
})
|
||||
|
||||
it('places Plugins under Experimental on desktop and omits it on the web', () => {
|
||||
const desktopSections = buildSettingsNavigationMetadata({
|
||||
isMac: false,
|
||||
isWindows: false,
|
||||
isWebClient: false,
|
||||
repos: [repo]
|
||||
})
|
||||
const desktopIds = desktopSections.map((section) => section.id)
|
||||
|
||||
expect(desktopSections.find((section) => section.id === 'plugins')?.group).toBe('experimental')
|
||||
expect(desktopIds.indexOf('plugins')).toBe(desktopIds.indexOf('experimental') + 1)
|
||||
expect(ids({ isWebClient: true })).not.toContain('plugins')
|
||||
})
|
||||
|
||||
it('omits Windows project runtime search entries when the active host is unsupported', () => {
|
||||
const sections = buildSettingsNavigationMetadata({
|
||||
isMac: false,
|
||||
|
||||
@@ -70,6 +70,7 @@ import { getAdvancedPaneSearchEntries } from '@/components/settings/advanced-sea
|
||||
import { getShortcutsPaneSearchEntries } from '@/components/settings/shortcuts-search'
|
||||
import { getStatsPaneSearchEntries } from '@/components/stats/stats-search'
|
||||
import { getExperimentalPaneSearchEntries } from '@/components/settings/experimental-search'
|
||||
import { getPluginsPaneSearchEntries } from '@/components/settings/plugins-search'
|
||||
import { getRepositoryPaneSearchEntries } from '@/components/settings/repository-search'
|
||||
import { isWebClientLocation } from '@/lib/web-client-location'
|
||||
import {
|
||||
@@ -537,6 +538,21 @@ export function buildSettingsNavigationMetadata({
|
||||
searchEntries: getExperimentalPaneSearchEntries(),
|
||||
group: 'experimental'
|
||||
},
|
||||
...(showDesktopOnlySettings
|
||||
? [
|
||||
{
|
||||
id: 'plugins',
|
||||
title: translate('auto.hooks.useSettingsNavigationMetadata.pluginsTitle', 'Plugins'),
|
||||
description: translate(
|
||||
'auto.hooks.useSettingsNavigationMetadata.pluginsDescription',
|
||||
'Install and manage experimental Orca plugins.'
|
||||
),
|
||||
icon: Blocks,
|
||||
searchEntries: getPluginsPaneSearchEntries(),
|
||||
group: 'experimental'
|
||||
}
|
||||
]
|
||||
: []),
|
||||
...repos.map((repo) => ({
|
||||
id: `repo-${repo.id}`,
|
||||
title: repo.displayName,
|
||||
|
||||
@@ -9038,7 +9038,7 @@
|
||||
"PluginConsentDialog": {
|
||||
"workerTrust": "Background worker — runs its own process",
|
||||
"panelTrust": "Panel only — no worker process",
|
||||
"decisionFailed": "Could not save the permission decision.",
|
||||
"decisionFailed": "Could not save the permission decision. Try again.",
|
||||
"title": "Review permissions",
|
||||
"subtitle": "{{value0}} v{{value1}} · {{value2}}",
|
||||
"reconsent": "Permissions or the worker trust tier changed since you last reviewed this plugin. Review them again before it can run.",
|
||||
@@ -9069,13 +9069,14 @@
|
||||
"remove": "Remove",
|
||||
"placeholder": "/Users/you/plugins/my-plugin or C:\\Users\\you\\plugins\\my-plugin",
|
||||
"add": "Add path",
|
||||
"saveFailed": "Could not save development plugin paths."
|
||||
"saveFailed": "Could not save development plugin paths.",
|
||||
"pathLabel": "Development plugin folder path"
|
||||
},
|
||||
"PluginInstallDialog": {
|
||||
"localRequired": "Enter the plugin folder path.",
|
||||
"gitUrlRequired": "Enter a repository URL.",
|
||||
"gitRefRequired": "Add an explicit #ref (tag or commit) so the install is pinned — for example #v0.1.0.",
|
||||
"installFailed": "Plugin installation failed.",
|
||||
"installFailed": "Plugin installation failed. Check the source and try again.",
|
||||
"title": "Install plugin",
|
||||
"description": "Installing copies the plugin into Orca and shows its permissions for review. No plugin code runs until you enable it.",
|
||||
"source": "Install source",
|
||||
@@ -9118,7 +9119,10 @@
|
||||
"remove": "Remove",
|
||||
"enableLabel": "Enable {{value0}}",
|
||||
"restartCount": " · {{value0}} restarts",
|
||||
"restarting": "Restarting"
|
||||
"restarting": "Restarting",
|
||||
"invalidPluginError": "The plugin manifest or installed files are invalid. Fix the plugin, then refresh.",
|
||||
"runtimeError": "The plugin stopped after an activation or worker error.",
|
||||
"disableLabel": "Disable {{value0}}"
|
||||
},
|
||||
"PluginsSettingsSection": {
|
||||
"loadFailed": "Could not load plugins.",
|
||||
@@ -9146,6 +9150,18 @@
|
||||
"logs": "plugin logs",
|
||||
"development": "development plugins"
|
||||
}
|
||||
},
|
||||
"pluginError": {
|
||||
"installManifestMissing": "No readable orca-plugin.json was found. Choose the plugin's root folder.",
|
||||
"installManifestInvalid": "orca-plugin.json is invalid. Ask the plugin author to fix the manifest.",
|
||||
"incompatible": "This plugin requires a different Orca version.",
|
||||
"installUnsafePath": "The plugin contains an unsafe file path or symlink and was not installed.",
|
||||
"installLimit": "The plugin exceeds Orca's install size or file-count limits.",
|
||||
"installGit": "Orca could not fetch the pinned Git revision. Check the URL, #ref, access, and system Git setup.",
|
||||
"invalidManifestMissing": "The plugin root is missing orca-plugin.json. Add it, then refresh plugins.",
|
||||
"invalidManifest": "orca-plugin.json is invalid. Fix it, then refresh plugins.",
|
||||
"invalidArtifact": "A declared worker or panel file is missing or unsafe. Fix the plugin files, then refresh.",
|
||||
"consentChanged": "The plugin changed while you were reviewing it. Close this dialog and review the updated permissions."
|
||||
}
|
||||
},
|
||||
"right": {
|
||||
@@ -10259,6 +10275,9 @@
|
||||
"workspaceGone": "Couldn't open log — workspace is no longer available.",
|
||||
"notAuthorized": "Couldn't open log — path not authorized.",
|
||||
"alreadyEditable": "Log is already open for editing."
|
||||
},
|
||||
"activityBar": {
|
||||
"error": "Error"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -9038,7 +9038,7 @@
|
||||
"PluginConsentDialog": {
|
||||
"workerTrust": "Proceso en segundo plano — se ejecuta en su propio proceso",
|
||||
"panelTrust": "Solo panel — sin proceso en segundo plano",
|
||||
"decisionFailed": "No se pudo guardar la decisión sobre los permisos.",
|
||||
"decisionFailed": "No se pudo guardar la decisión sobre los permisos. Inténtalo de nuevo.",
|
||||
"title": "Revisar permisos",
|
||||
"subtitle": "{{value0}} v{{value1}} · {{value2}}",
|
||||
"reconsent": "Los permisos o el nivel de confianza del proceso cambiaron desde la última revisión de este plugin. Revísalos de nuevo antes de ejecutarlo.",
|
||||
@@ -9069,13 +9069,14 @@
|
||||
"remove": "Eliminar",
|
||||
"placeholder": "/Users/you/plugins/my-plugin or C:\\Users\\you\\plugins\\my-plugin",
|
||||
"add": "Añadir ruta",
|
||||
"saveFailed": "No se pudieron guardar las rutas de plugins de desarrollo."
|
||||
"saveFailed": "No se pudieron guardar las rutas de plugins de desarrollo.",
|
||||
"pathLabel": "Ruta de la carpeta del plugin de desarrollo"
|
||||
},
|
||||
"PluginInstallDialog": {
|
||||
"localRequired": "Introduce la ruta de la carpeta del plugin.",
|
||||
"gitUrlRequired": "Introduce una URL de repositorio.",
|
||||
"gitRefRequired": "Añade una #ref explícita (etiqueta o commit) para fijar la instalación; por ejemplo, #v0.1.0.",
|
||||
"installFailed": "No se pudo instalar el plugin.",
|
||||
"installFailed": "No se pudo instalar el plugin. Comprueba el origen e inténtalo de nuevo.",
|
||||
"title": "Instalar plugin",
|
||||
"description": "La instalación copia el plugin en Orca y muestra sus permisos para que los revises. No se ejecuta ningún código del plugin hasta que lo actives.",
|
||||
"source": "Origen de instalación",
|
||||
@@ -9118,7 +9119,10 @@
|
||||
"remove": "Eliminar",
|
||||
"enableLabel": "Activar {{value0}}",
|
||||
"restartCount": " · {{value0}} reinicios",
|
||||
"restarting": "Reiniciando"
|
||||
"restarting": "Reiniciando",
|
||||
"invalidPluginError": "El manifiesto o los archivos instalados del plugin no son válidos. Corrige el plugin y actualiza.",
|
||||
"runtimeError": "El plugin se detuvo tras un error de activación o del proceso.",
|
||||
"disableLabel": "Desactivar {{value0}}"
|
||||
},
|
||||
"PluginsSettingsSection": {
|
||||
"loadFailed": "No se pudieron cargar los plugins.",
|
||||
@@ -9146,6 +9150,18 @@
|
||||
"logs": "registros de plugins",
|
||||
"development": "plugins de desarrollo"
|
||||
}
|
||||
},
|
||||
"pluginError": {
|
||||
"installManifestMissing": "No se encontró un orca-plugin.json legible. Elige la carpeta raíz del plugin.",
|
||||
"installManifestInvalid": "orca-plugin.json no es válido. Pide al autor del plugin que corrija el manifiesto.",
|
||||
"incompatible": "Este plugin requiere otra versión de Orca.",
|
||||
"installUnsafePath": "El plugin contiene una ruta de archivo o un enlace simbólico no seguros y no se instaló.",
|
||||
"installLimit": "El plugin supera los límites de tamaño o cantidad de archivos de Orca.",
|
||||
"installGit": "Orca no pudo obtener la revisión Git fijada. Comprueba la URL, la #ref, el acceso y la configuración de Git del sistema.",
|
||||
"invalidManifestMissing": "A la raíz del plugin le falta orca-plugin.json. Añádelo y actualiza los plugins.",
|
||||
"invalidManifest": "orca-plugin.json no es válido. Corrígelo y actualiza los plugins.",
|
||||
"invalidArtifact": "Falta un archivo de proceso o panel declarado, o no es seguro. Corrige los archivos del plugin y actualiza.",
|
||||
"consentChanged": "El plugin cambió mientras lo revisabas. Cierra este diálogo y revisa los permisos actualizados."
|
||||
}
|
||||
},
|
||||
"right": {
|
||||
@@ -10259,6 +10275,9 @@
|
||||
"unavailable": "Este panel de plugin ya no está disponible.",
|
||||
"loadFailed": "No se pudo cargar el panel del plugin.",
|
||||
"unresponsive": "El panel del plugin dejó de responder y se suspendió."
|
||||
},
|
||||
"activityBar": {
|
||||
"error": "Error"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -9038,7 +9038,7 @@
|
||||
"PluginConsentDialog": {
|
||||
"workerTrust": "バックグラウンドワーカー — 独自のプロセスで実行",
|
||||
"panelTrust": "パネルのみ — ワーカープロセスなし",
|
||||
"decisionFailed": "権限の選択を保存できませんでした。",
|
||||
"decisionFailed": "権限の選択を保存できませんでした。再試行してください。",
|
||||
"title": "権限を確認",
|
||||
"subtitle": "{{value0}} v{{value1}} · {{value2}}",
|
||||
"reconsent": "前回このプラグインを確認してから、権限またはワーカーの信頼レベルが変更されました。実行する前にもう一度確認してください。",
|
||||
@@ -9069,13 +9069,14 @@
|
||||
"remove": "削除",
|
||||
"placeholder": "/Users/you/plugins/my-plugin or C:\\Users\\you\\plugins\\my-plugin",
|
||||
"add": "パスを追加",
|
||||
"saveFailed": "開発用プラグインのパスを保存できませんでした。"
|
||||
"saveFailed": "開発用プラグインのパスを保存できませんでした。",
|
||||
"pathLabel": "開発用プラグインのフォルダーパス"
|
||||
},
|
||||
"PluginInstallDialog": {
|
||||
"localRequired": "プラグインフォルダーのパスを入力してください。",
|
||||
"gitUrlRequired": "リポジトリ URL を入力してください。",
|
||||
"gitRefRequired": "インストールを固定するため、明示的な #ref(タグまたはコミット)を追加してください。例: #v0.1.0。",
|
||||
"installFailed": "プラグインのインストールに失敗しました。",
|
||||
"installFailed": "プラグインをインストールできませんでした。ソースを確認して再試行してください。",
|
||||
"title": "プラグインをインストール",
|
||||
"description": "インストールするとプラグインが Orca にコピーされ、確認する権限が表示されます。有効にするまでプラグインのコードは実行されません。",
|
||||
"source": "インストール元",
|
||||
@@ -9118,7 +9119,10 @@
|
||||
"remove": "削除",
|
||||
"enableLabel": "{{value0}} を有効化",
|
||||
"restartCount": " · {{value0}} 回再起動",
|
||||
"restarting": "再起動中"
|
||||
"restarting": "再起動中",
|
||||
"invalidPluginError": "プラグインのマニフェストまたはインストール済みファイルが無効です。修正してから更新してください。",
|
||||
"runtimeError": "有効化またはワーカーのエラー後にプラグインが停止しました。",
|
||||
"disableLabel": "{{value0}}を無効にする"
|
||||
},
|
||||
"PluginsSettingsSection": {
|
||||
"loadFailed": "プラグインを読み込めませんでした。",
|
||||
@@ -9146,6 +9150,18 @@
|
||||
"logs": "プラグインのログ",
|
||||
"development": "開発用プラグイン"
|
||||
}
|
||||
},
|
||||
"pluginError": {
|
||||
"installManifestMissing": "読み取り可能なorca-plugin.jsonが見つかりません。プラグインのルートフォルダーを選択してください。",
|
||||
"installManifestInvalid": "orca-plugin.jsonが無効です。プラグインの作成者にマニフェストの修正を依頼してください。",
|
||||
"incompatible": "このプラグインには別のバージョンのOrcaが必要です。",
|
||||
"installUnsafePath": "プラグインに安全でないファイルパスまたはシンボリックリンクが含まれているため、インストールされませんでした。",
|
||||
"installLimit": "プラグインがOrcaのインストールサイズまたはファイル数の上限を超えています。",
|
||||
"installGit": "固定されたGitリビジョンを取得できませんでした。URL、#ref、アクセス権、システムGitの設定を確認してください。",
|
||||
"invalidManifestMissing": "プラグインのルートにorca-plugin.jsonがありません。追加してからプラグインを更新してください。",
|
||||
"invalidManifest": "orca-plugin.jsonが無効です。修正してからプラグインを更新してください。",
|
||||
"invalidArtifact": "宣言されたワーカーまたはパネルのファイルがないか、安全ではありません。ファイルを修正してから更新してください。",
|
||||
"consentChanged": "確認中にプラグインが変更されました。このダイアログを閉じて、更新された権限を確認してください。"
|
||||
}
|
||||
},
|
||||
"right": {
|
||||
@@ -10259,6 +10275,9 @@
|
||||
"unavailable": "このプラグインパネルは利用できなくなりました。",
|
||||
"loadFailed": "プラグインパネルを読み込めませんでした。",
|
||||
"unresponsive": "プラグインパネルが応答しなくなったため停止しました。"
|
||||
},
|
||||
"activityBar": {
|
||||
"error": "エラー"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -9038,7 +9038,7 @@
|
||||
"PluginConsentDialog": {
|
||||
"workerTrust": "백그라운드 워커 — 별도 프로세스에서 실행",
|
||||
"panelTrust": "패널 전용 — 워커 프로세스 없음",
|
||||
"decisionFailed": "권한 결정을 저장할 수 없습니다.",
|
||||
"decisionFailed": "권한 결정을 저장할 수 없습니다. 다시 시도하세요.",
|
||||
"title": "권한 검토",
|
||||
"subtitle": "{{value0}} v{{value1}} · {{value2}}",
|
||||
"reconsent": "이 플러그인을 마지막으로 검토한 후 권한 또는 워커 신뢰 수준이 변경되었습니다. 실행하기 전에 다시 검토하세요.",
|
||||
@@ -9069,13 +9069,14 @@
|
||||
"remove": "제거",
|
||||
"placeholder": "/Users/you/plugins/my-plugin or C:\\Users\\you\\plugins\\my-plugin",
|
||||
"add": "경로 추가",
|
||||
"saveFailed": "개발용 플러그인 경로를 저장할 수 없습니다."
|
||||
"saveFailed": "개발용 플러그인 경로를 저장할 수 없습니다.",
|
||||
"pathLabel": "개발 플러그인 폴더 경로"
|
||||
},
|
||||
"PluginInstallDialog": {
|
||||
"localRequired": "플러그인 폴더 경로를 입력하세요.",
|
||||
"gitUrlRequired": "저장소 URL을 입력하세요.",
|
||||
"gitRefRequired": "설치를 고정하려면 명시적인 #ref(태그 또는 커밋)를 추가하세요. 예: #v0.1.0.",
|
||||
"installFailed": "플러그인 설치에 실패했습니다.",
|
||||
"installFailed": "플러그인을 설치하지 못했습니다. 소스를 확인하고 다시 시도하세요.",
|
||||
"title": "플러그인 설치",
|
||||
"description": "설치하면 플러그인이 Orca에 복사되고 검토할 권한이 표시됩니다. 활성화하기 전에는 플러그인 코드가 실행되지 않습니다.",
|
||||
"source": "설치 소스",
|
||||
@@ -9118,7 +9119,10 @@
|
||||
"remove": "제거",
|
||||
"enableLabel": "{{value0}} 활성화",
|
||||
"restartCount": " · {{value0}}회 다시 시작",
|
||||
"restarting": "다시 시작 중"
|
||||
"restarting": "다시 시작 중",
|
||||
"invalidPluginError": "플러그인 매니페스트 또는 설치된 파일이 올바르지 않습니다. 수정한 후 새로 고치세요.",
|
||||
"runtimeError": "활성화 또는 워커 오류 후 플러그인이 중지되었습니다.",
|
||||
"disableLabel": "{{value0}} 비활성화"
|
||||
},
|
||||
"PluginsSettingsSection": {
|
||||
"loadFailed": "플러그인을 불러올 수 없습니다.",
|
||||
@@ -9146,6 +9150,18 @@
|
||||
"logs": "플러그인 로그",
|
||||
"development": "개발용 플러그인"
|
||||
}
|
||||
},
|
||||
"pluginError": {
|
||||
"installManifestMissing": "읽을 수 있는 orca-plugin.json을 찾지 못했습니다. 플러그인의 루트 폴더를 선택하세요.",
|
||||
"installManifestInvalid": "orca-plugin.json이 올바르지 않습니다. 플러그인 작성자에게 매니페스트 수정을 요청하세요.",
|
||||
"incompatible": "이 플러그인에는 다른 버전의 Orca가 필요합니다.",
|
||||
"installUnsafePath": "플러그인에 안전하지 않은 파일 경로 또는 심볼릭 링크가 있어 설치하지 않았습니다.",
|
||||
"installLimit": "플러그인이 Orca의 설치 크기 또는 파일 수 제한을 초과합니다.",
|
||||
"installGit": "고정된 Git 리비전을 가져오지 못했습니다. URL, #ref, 접근 권한 및 시스템 Git 설정을 확인하세요.",
|
||||
"invalidManifestMissing": "플러그인 루트에 orca-plugin.json이 없습니다. 추가한 후 플러그인을 새로 고치세요.",
|
||||
"invalidManifest": "orca-plugin.json이 올바르지 않습니다. 수정한 후 플러그인을 새로 고치세요.",
|
||||
"invalidArtifact": "선언된 워커 또는 패널 파일이 없거나 안전하지 않습니다. 파일을 수정한 후 새로 고치세요.",
|
||||
"consentChanged": "검토하는 동안 플러그인이 변경되었습니다. 이 대화상자를 닫고 업데이트된 권한을 검토하세요."
|
||||
}
|
||||
},
|
||||
"right": {
|
||||
@@ -10259,6 +10275,9 @@
|
||||
"unavailable": "이 플러그인 패널은 더 이상 사용할 수 없습니다.",
|
||||
"loadFailed": "플러그인 패널을 불러올 수 없습니다.",
|
||||
"unresponsive": "플러그인 패널이 응답하지 않아 일시 중단되었습니다."
|
||||
},
|
||||
"activityBar": {
|
||||
"error": "오류"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -9038,7 +9038,7 @@
|
||||
"PluginConsentDialog": {
|
||||
"workerTrust": "后台工作进程 — 在独立进程中运行",
|
||||
"panelTrust": "仅面板 — 无工作进程",
|
||||
"decisionFailed": "无法保存权限决定。",
|
||||
"decisionFailed": "无法保存权限决定。请重试。",
|
||||
"title": "检查权限",
|
||||
"subtitle": "{{value0}} v{{value1}} · {{value2}}",
|
||||
"reconsent": "自你上次检查此插件以来,权限或工作进程信任级别已更改。请在运行前重新检查。",
|
||||
@@ -9069,13 +9069,14 @@
|
||||
"remove": "移除",
|
||||
"placeholder": "/Users/you/plugins/my-plugin or C:\\Users\\you\\plugins\\my-plugin",
|
||||
"add": "添加路径",
|
||||
"saveFailed": "无法保存开发插件路径。"
|
||||
"saveFailed": "无法保存开发插件路径。",
|
||||
"pathLabel": "开发插件文件夹路径"
|
||||
},
|
||||
"PluginInstallDialog": {
|
||||
"localRequired": "请输入插件文件夹路径。",
|
||||
"gitUrlRequired": "请输入仓库 URL。",
|
||||
"gitRefRequired": "请添加明确的 #ref(标签或提交)以固定安装版本,例如 #v0.1.0。",
|
||||
"installFailed": "插件安装失败。",
|
||||
"installFailed": "插件安装失败。请检查来源后重试。",
|
||||
"title": "安装插件",
|
||||
"description": "安装会将插件复制到 Orca,并显示其权限供你检查。启用插件之前,不会运行任何插件代码。",
|
||||
"source": "安装来源",
|
||||
@@ -9118,7 +9119,10 @@
|
||||
"remove": "移除",
|
||||
"enableLabel": "启用 {{value0}}",
|
||||
"restartCount": " · 重启 {{value0}} 次",
|
||||
"restarting": "正在重新启动"
|
||||
"restarting": "正在重新启动",
|
||||
"invalidPluginError": "插件清单或已安装文件无效。请修复插件后刷新。",
|
||||
"runtimeError": "插件在激活或工作进程出错后停止。",
|
||||
"disableLabel": "停用 {{value0}}"
|
||||
},
|
||||
"PluginsSettingsSection": {
|
||||
"loadFailed": "无法加载插件。",
|
||||
@@ -9146,6 +9150,18 @@
|
||||
"logs": "插件日志",
|
||||
"development": "开发插件"
|
||||
}
|
||||
},
|
||||
"pluginError": {
|
||||
"installManifestMissing": "未找到可读取的 orca-plugin.json。请选择插件的根文件夹。",
|
||||
"installManifestInvalid": "orca-plugin.json 无效。请让插件作者修复清单。",
|
||||
"incompatible": "此插件需要其他版本的 Orca。",
|
||||
"installUnsafePath": "插件包含不安全的文件路径或符号链接,因此未安装。",
|
||||
"installLimit": "插件超出 Orca 的安装大小或文件数量限制。",
|
||||
"installGit": "Orca 无法获取固定的 Git 修订。请检查 URL、#ref、访问权限和系统 Git 配置。",
|
||||
"invalidManifestMissing": "插件根目录缺少 orca-plugin.json。请添加后刷新插件。",
|
||||
"invalidManifest": "orca-plugin.json 无效。请修复后刷新插件。",
|
||||
"invalidArtifact": "声明的工作进程或面板文件缺失或不安全。请修复插件文件后刷新。",
|
||||
"consentChanged": "插件在你查看时发生了更改。请关闭此对话框并查看更新后的权限。"
|
||||
}
|
||||
},
|
||||
"right": {
|
||||
@@ -10259,6 +10275,9 @@
|
||||
"unavailable": "此插件面板已不可用。",
|
||||
"loadFailed": "无法加载插件面板。",
|
||||
"unresponsive": "插件面板停止响应,已被暂停。"
|
||||
},
|
||||
"activityBar": {
|
||||
"error": "错误"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -28,6 +28,7 @@ export type SettingsNavTarget =
|
||||
| 'stats'
|
||||
| 'ssh'
|
||||
| 'experimental'
|
||||
| 'plugins'
|
||||
| 'agents'
|
||||
| 'orchestration'
|
||||
| 'servers'
|
||||
|
||||
@@ -139,6 +139,70 @@ beforeEach(() => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('createSettingsSlice checked persistence', () => {
|
||||
it('stores the authoritative settings after a successful checked update', async () => {
|
||||
const authoritativeSettings = {
|
||||
pluginSystemEnabled: true,
|
||||
notifications: {}
|
||||
} as unknown as NonNullable<AppState['settings']>
|
||||
settingsSet.mockResolvedValueOnce(authoritativeSettings)
|
||||
const store = createTestStore()
|
||||
store.setState({
|
||||
settings: {
|
||||
pluginSystemEnabled: false,
|
||||
notifications: {}
|
||||
} as unknown as AppState['settings']
|
||||
})
|
||||
|
||||
await expect(
|
||||
store.getState().updateSettingsOrThrow({ pluginSystemEnabled: true })
|
||||
).resolves.toBeUndefined()
|
||||
|
||||
expect(settingsSet).toHaveBeenCalledWith({ pluginSystemEnabled: true })
|
||||
expect(store.getState().settings).toBe(authoritativeSettings)
|
||||
})
|
||||
|
||||
it('rejects a failed checked update without changing local settings', async () => {
|
||||
const persistenceError = new Error('settings IPC failed')
|
||||
const currentSettings = {
|
||||
pluginSystemEnabled: false,
|
||||
notifications: {}
|
||||
} as unknown as NonNullable<AppState['settings']>
|
||||
settingsSet.mockRejectedValueOnce(persistenceError)
|
||||
const store = createTestStore()
|
||||
store.setState({ settings: currentSettings })
|
||||
|
||||
await expect(
|
||||
store.getState().updateSettingsOrThrow({ pluginSystemEnabled: true })
|
||||
).rejects.toBe(persistenceError)
|
||||
|
||||
expect(store.getState().settings).toBe(currentSettings)
|
||||
})
|
||||
|
||||
it('keeps the existing update action best-effort and logs persistence failures', async () => {
|
||||
const persistenceError = new Error('settings IPC failed')
|
||||
const currentSettings = {
|
||||
pluginSystemEnabled: false,
|
||||
notifications: {}
|
||||
} as unknown as NonNullable<AppState['settings']>
|
||||
settingsSet.mockRejectedValueOnce(persistenceError)
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
const store = createTestStore()
|
||||
store.setState({ settings: currentSettings })
|
||||
|
||||
try {
|
||||
await expect(
|
||||
store.getState().updateSettings({ pluginSystemEnabled: true })
|
||||
).resolves.toBeUndefined()
|
||||
|
||||
expect(consoleError).toHaveBeenCalledWith('Failed to update settings:', persistenceError)
|
||||
expect(store.getState().settings).toBe(currentSettings)
|
||||
} finally {
|
||||
consoleError.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('createSettingsSlice runtime switching', () => {
|
||||
it('repairs drifted task provider settings before sending updates', async () => {
|
||||
settingsSet.mockResolvedValueOnce({
|
||||
|
||||
@@ -28,6 +28,7 @@ export type SettingsSlice = SettingsSearchState & {
|
||||
settings: GlobalSettings | null
|
||||
fetchSettings: () => Promise<void>
|
||||
updateSettings: (updates: Partial<GlobalSettings>) => Promise<void>
|
||||
updateSettingsOrThrow: (updates: Partial<GlobalSettings>) => Promise<void>
|
||||
switchRuntimeEnvironment: (environmentId: string | null) => Promise<boolean>
|
||||
}
|
||||
|
||||
@@ -35,6 +36,8 @@ type LegacyTerminalScrollbackSettingsUpdate = Partial<GlobalSettings> & {
|
||||
terminalScrollbackBytes?: unknown
|
||||
}
|
||||
|
||||
type SettingsStateSetter = Parameters<StateCreator<AppState, [], [], SettingsSlice>>[0]
|
||||
|
||||
function normalizeRuntimeEnvironmentId(value: string | null | undefined): string | null {
|
||||
const trimmed = value?.trim()
|
||||
return trimmed ? trimmed : null
|
||||
@@ -47,6 +50,77 @@ function createOpenInApplicationId(): string {
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeSettingsUpdates(
|
||||
updates: Partial<GlobalSettings>,
|
||||
currentSettings: GlobalSettings | null
|
||||
): Partial<GlobalSettings> {
|
||||
const { terminalScrollbackBytes: _legacyScrollbackBytes, ...sanitizedUpdates } =
|
||||
updates as LegacyTerminalScrollbackSettingsUpdate
|
||||
void _legacyScrollbackBytes
|
||||
if ('terminalQuickCommands' in updates) {
|
||||
sanitizedUpdates.terminalQuickCommands = normalizeTerminalQuickCommands(
|
||||
updates.terminalQuickCommands
|
||||
)
|
||||
}
|
||||
if ('terminalCustomThemes' in updates) {
|
||||
sanitizedUpdates.terminalCustomThemes = normalizeTerminalCustomThemes(
|
||||
updates.terminalCustomThemes
|
||||
)
|
||||
}
|
||||
if ('visibleTaskProviders' in updates || 'defaultTaskSource' in updates) {
|
||||
const taskProviderSettings = normalizeTaskProviderSettings({
|
||||
visibleTaskProviders:
|
||||
'visibleTaskProviders' in updates
|
||||
? updates.visibleTaskProviders
|
||||
: currentSettings?.visibleTaskProviders,
|
||||
defaultTaskSource:
|
||||
'defaultTaskSource' in updates
|
||||
? updates.defaultTaskSource
|
||||
: currentSettings?.defaultTaskSource
|
||||
})
|
||||
sanitizedUpdates.defaultTaskSource = taskProviderSettings.defaultTaskSource
|
||||
sanitizedUpdates.visibleTaskProviders = taskProviderSettings.visibleTaskProviders
|
||||
}
|
||||
if ('openInApplications' in updates) {
|
||||
sanitizedUpdates.openInApplications = normalizeOpenInApplications(updates.openInApplications, {
|
||||
createId: createOpenInApplicationId
|
||||
})
|
||||
}
|
||||
if ('disabledTuiAgents' in updates) {
|
||||
sanitizedUpdates.disabledTuiAgents = normalizeDisabledTuiAgents(updates.disabledTuiAgents)
|
||||
}
|
||||
if ('agentDefaultArgs' in updates) {
|
||||
sanitizedUpdates.agentDefaultArgs = normalizeTuiAgentArgsRecord(updates.agentDefaultArgs)
|
||||
sanitizedUpdates.agentYoloDefaultsMigrated = true
|
||||
}
|
||||
if ('agentDefaultEnv' in updates) {
|
||||
sanitizedUpdates.agentDefaultEnv = normalizeTuiAgentEnvRecord(updates.agentDefaultEnv)
|
||||
sanitizedUpdates.agentYoloDefaultsMigrated = true
|
||||
}
|
||||
if ('uiLanguage' in updates) {
|
||||
sanitizedUpdates.uiLanguage = normalizeUiLanguage(updates.uiLanguage)
|
||||
}
|
||||
if ('terminalScrollbackRows' in updates) {
|
||||
sanitizedUpdates.terminalScrollbackRows = normalizeDesktopTerminalScrollbackRows(
|
||||
updates.terminalScrollbackRows
|
||||
)
|
||||
}
|
||||
return sanitizedUpdates
|
||||
}
|
||||
|
||||
async function persistSettingsUpdates(
|
||||
set: SettingsStateSetter,
|
||||
updates: Partial<GlobalSettings>,
|
||||
currentSettings: GlobalSettings | null
|
||||
): Promise<void> {
|
||||
const nextSettings = await window.api.settings.set(
|
||||
normalizeSettingsUpdates(updates, currentSettings)
|
||||
)
|
||||
set((state) => ({
|
||||
settings: (nextSettings as GlobalSettings | undefined) ?? state.settings
|
||||
}))
|
||||
}
|
||||
|
||||
async function verifyRuntimeEnvironmentReachable(environmentId: string | null): Promise<void> {
|
||||
if (!environmentId) {
|
||||
return
|
||||
@@ -81,67 +155,16 @@ export const createSettingsSlice: StateCreator<AppState, [], [], SettingsSlice>
|
||||
|
||||
updateSettings: async (updates) => {
|
||||
try {
|
||||
const { terminalScrollbackBytes: _legacyScrollbackBytes, ...sanitizedUpdates } =
|
||||
updates as LegacyTerminalScrollbackSettingsUpdate
|
||||
void _legacyScrollbackBytes
|
||||
if ('terminalQuickCommands' in updates) {
|
||||
sanitizedUpdates.terminalQuickCommands = normalizeTerminalQuickCommands(
|
||||
updates.terminalQuickCommands
|
||||
)
|
||||
}
|
||||
if ('terminalCustomThemes' in updates) {
|
||||
sanitizedUpdates.terminalCustomThemes = normalizeTerminalCustomThemes(
|
||||
updates.terminalCustomThemes
|
||||
)
|
||||
}
|
||||
if ('visibleTaskProviders' in updates || 'defaultTaskSource' in updates) {
|
||||
const taskProviderSettings = normalizeTaskProviderSettings({
|
||||
visibleTaskProviders:
|
||||
'visibleTaskProviders' in updates
|
||||
? updates.visibleTaskProviders
|
||||
: get().settings?.visibleTaskProviders,
|
||||
defaultTaskSource:
|
||||
'defaultTaskSource' in updates
|
||||
? updates.defaultTaskSource
|
||||
: get().settings?.defaultTaskSource
|
||||
})
|
||||
sanitizedUpdates.defaultTaskSource = taskProviderSettings.defaultTaskSource
|
||||
sanitizedUpdates.visibleTaskProviders = taskProviderSettings.visibleTaskProviders
|
||||
}
|
||||
if ('openInApplications' in updates) {
|
||||
sanitizedUpdates.openInApplications = normalizeOpenInApplications(
|
||||
updates.openInApplications,
|
||||
{
|
||||
createId: createOpenInApplicationId
|
||||
}
|
||||
)
|
||||
}
|
||||
if ('disabledTuiAgents' in updates) {
|
||||
sanitizedUpdates.disabledTuiAgents = normalizeDisabledTuiAgents(updates.disabledTuiAgents)
|
||||
}
|
||||
if ('agentDefaultArgs' in updates) {
|
||||
sanitizedUpdates.agentDefaultArgs = normalizeTuiAgentArgsRecord(updates.agentDefaultArgs)
|
||||
sanitizedUpdates.agentYoloDefaultsMigrated = true
|
||||
}
|
||||
if ('agentDefaultEnv' in updates) {
|
||||
sanitizedUpdates.agentDefaultEnv = normalizeTuiAgentEnvRecord(updates.agentDefaultEnv)
|
||||
sanitizedUpdates.agentYoloDefaultsMigrated = true
|
||||
}
|
||||
if ('uiLanguage' in updates) {
|
||||
sanitizedUpdates.uiLanguage = normalizeUiLanguage(updates.uiLanguage)
|
||||
}
|
||||
if ('terminalScrollbackRows' in updates) {
|
||||
sanitizedUpdates.terminalScrollbackRows = normalizeDesktopTerminalScrollbackRows(
|
||||
updates.terminalScrollbackRows
|
||||
)
|
||||
}
|
||||
const nextSettings = await window.api.settings.set(sanitizedUpdates)
|
||||
set((s) => ({ settings: (nextSettings as GlobalSettings | undefined) ?? s.settings }))
|
||||
await persistSettingsUpdates(set, updates, get().settings)
|
||||
} catch (err) {
|
||||
console.error('Failed to update settings:', err)
|
||||
}
|
||||
},
|
||||
|
||||
updateSettingsOrThrow: async (updates) => {
|
||||
await persistSettingsUpdates(set, updates, get().settings)
|
||||
},
|
||||
|
||||
switchRuntimeEnvironment: async (environmentId) => {
|
||||
const nextId = normalizeRuntimeEnvironmentId(environmentId)
|
||||
const previousId = normalizeRuntimeEnvironmentId(get().settings?.activeRuntimeEnvironmentId)
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { pluginManifestSchema } from './plugin-manifest'
|
||||
|
||||
describe('hello Orca plugin fixture', () => {
|
||||
it('uses an ESM entry that remains loadable outside a type-module package', async () => {
|
||||
const root = join(process.cwd(), 'examples', 'plugins', 'hello-orca')
|
||||
const manifest = pluginManifestSchema.parse(
|
||||
JSON.parse(await readFile(join(root, 'orca-plugin.json'), 'utf8'))
|
||||
)
|
||||
|
||||
expect(manifest.main).toBe('main.mjs')
|
||||
const workerModule = (await import(pathToFileURL(join(root, manifest.main!)).href)) as {
|
||||
default?: unknown
|
||||
}
|
||||
expect(workerModule.default).toBeTypeOf('function')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Invariant: the documented hello-orca plugin stays inert before visible
|
||||
* consent, then its panel, worker command, and event subscription all work.
|
||||
*/
|
||||
|
||||
import { cp, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import type { Page } from '@stablyai/playwright-test'
|
||||
import { expect, test } from './helpers/orca-app'
|
||||
|
||||
async function openPluginSettings(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const state = window.__store?.getState()
|
||||
if (!state) {
|
||||
throw new Error('store unavailable')
|
||||
}
|
||||
state.openSettingsTarget({ pane: 'plugins', repoId: null })
|
||||
state.openSettingsPage()
|
||||
})
|
||||
await expect(page.locator('[data-settings-section="plugins"]')).toBeVisible()
|
||||
}
|
||||
|
||||
async function openDemoPanel(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const state = window.__store?.getState()
|
||||
if (!state) {
|
||||
throw new Error('store unavailable')
|
||||
}
|
||||
state.closeSettingsPage()
|
||||
if (!state.rightSidebarOpen) {
|
||||
state.toggleRightSidebar()
|
||||
}
|
||||
})
|
||||
const panelButton = page.getByRole('button', { name: 'Hello Orca', exact: true })
|
||||
await expect(panelButton).toBeVisible({ timeout: 15_000 })
|
||||
await panelButton.click()
|
||||
const frame = page.frameLocator('iframe[title="Hello Orca"]')
|
||||
await expect(frame.getByRole('heading', { name: 'Hello Orca 👋' })).toBeVisible()
|
||||
await expect(frame.locator('meta[http-equiv="Content-Security-Policy"]')).toHaveAttribute(
|
||||
'content',
|
||||
/default-src 'none'/
|
||||
)
|
||||
}
|
||||
|
||||
async function createWorktree(page: Page, name: string): Promise<string> {
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const state = window.__store?.getState()
|
||||
return Boolean(state && Object.values(state.worktreesByRepo).flat().length > 0)
|
||||
},
|
||||
undefined,
|
||||
{ timeout: 15_000 }
|
||||
)
|
||||
return page.evaluate(async (worktreeName) => {
|
||||
const state = window.__store?.getState()
|
||||
if (!state) {
|
||||
throw new Error('store unavailable')
|
||||
}
|
||||
const worktrees = Object.values(state.worktreesByRepo).flat()
|
||||
const active =
|
||||
worktrees.find((worktree) => worktree.id === state.activeWorktreeId) ?? worktrees[0]
|
||||
if (!active) {
|
||||
throw new Error('active worktree was not found')
|
||||
}
|
||||
const result = await state.createWorktree(active.repoId, worktreeName)
|
||||
return result.worktree.id
|
||||
}, name)
|
||||
}
|
||||
|
||||
test('runs hello-orca panel, command, and event behind visible consent', async ({ orcaPage }) => {
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), 'orca-hello-plugin-e2e-'))
|
||||
const pluginRoot = join(tempRoot, 'hello-orca')
|
||||
let createdWorktreeId: string | null = null
|
||||
await cp(join(process.cwd(), 'examples', 'plugins', 'hello-orca'), pluginRoot, {
|
||||
recursive: true
|
||||
})
|
||||
|
||||
try {
|
||||
const installed = await orcaPage.evaluate(async (sourcePath) => {
|
||||
const settings = await window.api.settings.set({ pluginSystemEnabled: true })
|
||||
window.__store?.setState({ settings })
|
||||
const result = await window.api.plugins.install({ kind: 'local-path', path: sourcePath })
|
||||
if (!result.ok) {
|
||||
throw new Error(result.error)
|
||||
}
|
||||
const pending = (await window.api.plugins.refresh()).find(
|
||||
(entry) => entry.pluginKey === result.pluginKey
|
||||
)
|
||||
if (!pending) {
|
||||
throw new Error('installed plugin was not listed')
|
||||
}
|
||||
let blocked = false
|
||||
try {
|
||||
await window.api.plugins.invokeCommand({
|
||||
pluginKey: result.pluginKey,
|
||||
commandId: 'hello-ping',
|
||||
args: { source: 'before-consent' }
|
||||
})
|
||||
} catch {
|
||||
blocked = true
|
||||
}
|
||||
return { pluginKey: result.pluginKey, status: pending.status, blocked }
|
||||
}, pluginRoot)
|
||||
|
||||
expect(installed.status).toBe('pending')
|
||||
expect(installed.blocked).toBe(true)
|
||||
|
||||
await openPluginSettings(orcaPage)
|
||||
const row = orcaPage.locator(`[data-plugin-key="${installed.pluginKey}"]`)
|
||||
await expect(row).toContainText('Needs review')
|
||||
await row.getByRole('button', { name: 'Review permissions' }).click()
|
||||
const consent = orcaPage.getByRole('dialog', { name: 'Review permissions' })
|
||||
await expect(consent).toBeVisible()
|
||||
await expect(consent).toContainText(pluginRoot)
|
||||
await expect(consent).toContainText('full access to your files, network, and other processes')
|
||||
await expect(consent.getByRole('button', { name: 'Keep Disabled' })).toBeFocused()
|
||||
await consent.getByRole('button', { name: 'Enable plugin' }).click()
|
||||
await expect(consent).toBeHidden()
|
||||
await expect(row).toContainText('Enabled')
|
||||
|
||||
const commandResults = await orcaPage.evaluate(async (pluginKey) => {
|
||||
const first = await window.api.plugins.invokeCommand({
|
||||
pluginKey,
|
||||
commandId: 'hello-ping',
|
||||
args: { source: 'e2e' }
|
||||
})
|
||||
const second = await window.api.plugins.invokeCommand({
|
||||
pluginKey,
|
||||
commandId: 'hello-ping',
|
||||
args: { source: 'e2e' }
|
||||
})
|
||||
return { first, second }
|
||||
}, installed.pluginKey)
|
||||
expect(commandResults.first).toEqual({ pong: true, count: 1, args: { source: 'e2e' } })
|
||||
expect(commandResults.second).toEqual({ pong: true, count: 2, args: { source: 'e2e' } })
|
||||
|
||||
await orcaPage.evaluate(async (sourcePath) => {
|
||||
const settings = await window.api.settings.set({ devPluginPaths: [sourcePath] })
|
||||
window.__store?.setState({ settings })
|
||||
await window.api.plugins.refresh()
|
||||
}, pluginRoot)
|
||||
|
||||
await openDemoPanel(orcaPage)
|
||||
|
||||
const panelPath = join(pluginRoot, 'panel.html')
|
||||
const panelHtml = await readFile(panelPath, 'utf8')
|
||||
await writeFile(panelPath, panelHtml.replace('Hello Orca 👋', 'Hello Orca reloaded'))
|
||||
await expect(
|
||||
orcaPage.frameLocator('iframe[title="Hello Orca"]').getByRole('heading', {
|
||||
name: 'Hello Orca reloaded'
|
||||
})
|
||||
).toBeVisible({ timeout: 15_000 })
|
||||
|
||||
createdWorktreeId = await createWorktree(orcaPage, `plugin-event-${Date.now()}`)
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
orcaPage.evaluate(
|
||||
async ({ pluginKey, worktreeId }) =>
|
||||
(await window.api.plugins.getLogs({ pluginKey })).some(
|
||||
(entry) =>
|
||||
entry.line.includes('worktree created:') && entry.line.includes(worktreeId)
|
||||
),
|
||||
{ pluginKey: installed.pluginKey, worktreeId: createdWorktreeId! }
|
||||
),
|
||||
{ timeout: 15_000 }
|
||||
)
|
||||
.toBe(true)
|
||||
} finally {
|
||||
if (createdWorktreeId) {
|
||||
await orcaPage
|
||||
.evaluate(async (worktreeId) => {
|
||||
await window.__store?.getState().removeWorktree(worktreeId, true)
|
||||
}, createdWorktreeId)
|
||||
.catch(() => undefined)
|
||||
}
|
||||
await rm(tempRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user