mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 16:02:55 +00:00
feat(plugins): add commands and keybindings
This commit is contained in:
@@ -1938,6 +1938,7 @@ app.whenReady().then(async () => {
|
||||
getDisabledPlugins: () => normalizePluginIdList(store?.getSettings().disabledPlugins),
|
||||
getPluginConsents: () => normalizePluginConsents(store?.getSettings().pluginConsents),
|
||||
getDevPluginPaths: () => normalizePluginIdList(store?.getSettings().devPluginPaths),
|
||||
getKeybindings: () => keybindings?.getOverrides() ?? {},
|
||||
hostEntryPath: resolvePluginHostEntryPath(app.getAppPath(), app.isPackaged)
|
||||
})
|
||||
// Why: headless `orca serve` clients reach plugins through the runtime RPC
|
||||
|
||||
@@ -75,6 +75,23 @@ describe('registerKeybindingHandlers', () => {
|
||||
expect(authorizeExternalPathMock).toHaveBeenCalledWith(snapshot.path)
|
||||
})
|
||||
|
||||
it('reconciles plugin command conflicts after a shortcut edit', () => {
|
||||
const onChanged = vi.fn()
|
||||
const setActionBindings = vi.fn(() => snapshot)
|
||||
registerKeybindingHandlers({ setActionBindings } as never, onChanged)
|
||||
|
||||
expect(
|
||||
getHandler('keybindings:setAction')(
|
||||
{},
|
||||
{
|
||||
actionId: 'plugin:orca-samples.tasks/open',
|
||||
bindings: ['Mod+Shift+T']
|
||||
}
|
||||
)
|
||||
).toBe(snapshot)
|
||||
expect(onChanged).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('authorizes the keybindings file before opening it outside Orca', async () => {
|
||||
openPathMock.mockResolvedValue('')
|
||||
registerKeybindingHandlers({ ensureFile: vi.fn(() => snapshot) } as never)
|
||||
|
||||
@@ -13,7 +13,10 @@ function broadcastKeybindingsChanged(snapshot: KeybindingFileSnapshot): void {
|
||||
rebuildAppMenu()
|
||||
}
|
||||
|
||||
export function registerKeybindingHandlers(service: KeybindingService): void {
|
||||
export function registerKeybindingHandlers(
|
||||
service: KeybindingService,
|
||||
onChanged?: () => void
|
||||
): void {
|
||||
ipcMain.handle('keybindings:get', () => service.getSnapshot())
|
||||
|
||||
ipcMain.handle('keybindings:ensureFile', () => {
|
||||
@@ -22,6 +25,7 @@ export function registerKeybindingHandlers(service: KeybindingService): void {
|
||||
// workspace. Opening it in the editor still needs normal fs IPC access.
|
||||
authorizeExternalPath(snapshot.path)
|
||||
broadcastKeybindingsChanged(snapshot)
|
||||
onChanged?.()
|
||||
return snapshot
|
||||
})
|
||||
|
||||
@@ -30,6 +34,7 @@ export function registerKeybindingHandlers(service: KeybindingService): void {
|
||||
(_event, args: { actionId: KeybindingActionId; bindings: string[] | null }) => {
|
||||
const snapshot = service.setActionBindings(args.actionId, args.bindings)
|
||||
broadcastKeybindingsChanged(snapshot)
|
||||
onChanged?.()
|
||||
return snapshot
|
||||
}
|
||||
)
|
||||
@@ -37,6 +42,7 @@ export function registerKeybindingHandlers(service: KeybindingService): void {
|
||||
ipcMain.handle('keybindings:reload', () => {
|
||||
const snapshot = service.reload()
|
||||
broadcastKeybindingsChanged(snapshot)
|
||||
onChanged?.()
|
||||
return snapshot
|
||||
})
|
||||
|
||||
|
||||
@@ -159,7 +159,9 @@ export function registerCoreHandlers(
|
||||
registerAutomationHandlers(store, automations)
|
||||
}
|
||||
if (keybindings) {
|
||||
registerKeybindingHandlers(keybindings)
|
||||
registerKeybindingHandlers(keybindings, () => {
|
||||
void pluginService?.reconcileActivationState()
|
||||
})
|
||||
}
|
||||
if (pluginService) {
|
||||
registerPluginHandlers(store, pluginService, runtime)
|
||||
|
||||
@@ -90,6 +90,32 @@ describe('keybinding-file', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves valid plugin overrides while rejecting malformed plugin action IDs', () => {
|
||||
writeFileSync(
|
||||
filePath,
|
||||
JSON.stringify({
|
||||
keybindings: {
|
||||
'plugin:orca-samples.tasks/open': 'Mod+Shift+T',
|
||||
'plugin:tasks/open': 'Mod+Alt+T'
|
||||
}
|
||||
}),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
const snapshot = readKeybindingFile(filePath, 'linux')
|
||||
expect(snapshot.overrides).toEqual({
|
||||
'plugin:orca-samples.tasks/open': ['Mod+Shift+T']
|
||||
})
|
||||
expect(snapshot.diagnostics).toMatchObject([
|
||||
{ severity: 'warning', actionId: 'plugin:tasks/open' }
|
||||
])
|
||||
|
||||
writeKeybindingOverride(filePath, 'linux', 'plugin:orca-samples.tasks/open', [])
|
||||
expect(readKeybindingFile(filePath, 'linux').overrides).toEqual({
|
||||
'plugin:orca-samples.tasks/open': []
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores invalid, unknown, and conflicting manual edits', () => {
|
||||
writeFileSync(
|
||||
filePath,
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { ValidDiscoveredPlugin } from './plugin-discovery'
|
||||
|
||||
export function assertPluginWorkerCommand(plugin: ValidDiscoveredPlugin, commandId: string): void {
|
||||
const command = plugin.manifest.contributes.commands.find((entry) => entry.id === commandId)
|
||||
if (!command) {
|
||||
throw new Error(`plugin ${plugin.pluginKey} does not contribute command ${commandId}`)
|
||||
}
|
||||
// Declarative aliases are renderer-owned and must never cross the worker
|
||||
// activation boundary, even if a compromised renderer invokes IPC directly.
|
||||
if (command.action !== undefined) {
|
||||
throw new Error(`plugin ${plugin.pluginKey} command ${commandId} is a built-in action alias`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { fingerprintPluginConsent } from '../../shared/plugins/plugin-consent-fingerprint'
|
||||
import { pluginManifestSchema } from '../../shared/plugins/plugin-manifest'
|
||||
import type { ValidDiscoveredPlugin } from './plugin-discovery'
|
||||
import { PluginCommandRegistry } from './plugin-command-registry'
|
||||
|
||||
function commandPlugin(
|
||||
id: string,
|
||||
contributes: {
|
||||
commands: Record<string, unknown>[]
|
||||
keybindings?: Record<string, unknown>[]
|
||||
}
|
||||
): ValidDiscoveredPlugin {
|
||||
const manifest = pluginManifestSchema.parse({
|
||||
manifestVersion: 1,
|
||||
id,
|
||||
publisher: 'orca-samples',
|
||||
name: id,
|
||||
version: '1.0.0',
|
||||
engines: { orca: '>=1.0.0' },
|
||||
pluginApi: 1,
|
||||
...(contributes.commands.some((command) => command.action === undefined)
|
||||
? { main: 'worker.js' }
|
||||
: {}),
|
||||
contributes,
|
||||
capabilities: []
|
||||
})
|
||||
return {
|
||||
pluginKey: `orca-samples.${id}`,
|
||||
rootDir: `/plugins/${id}`,
|
||||
manifest,
|
||||
consentFingerprint: fingerprintPluginConsent(manifest, `content-${id}`),
|
||||
consentContentHash: `content-${id}`,
|
||||
contentHash: `content-${id}`,
|
||||
isDev: false
|
||||
}
|
||||
}
|
||||
|
||||
describe('PluginCommandRegistry', () => {
|
||||
it('retains pending previews and exposes only approved commands', () => {
|
||||
const plugin = commandPlugin('aliases', {
|
||||
commands: [{ id: 'tasks', title: 'Open Tasks', action: 'view.tasks' }],
|
||||
keybindings: [{ command: 'tasks', key: 'mod+alt+t' }]
|
||||
})
|
||||
const registry = new PluginCommandRegistry()
|
||||
|
||||
registry.reconcile([plugin], () => false)
|
||||
expect(registry.list()).toEqual([])
|
||||
expect(registry.preview(plugin.pluginKey)).toEqual([
|
||||
{
|
||||
pluginKey: plugin.pluginKey,
|
||||
id: 'tasks',
|
||||
title: 'Open Tasks',
|
||||
context: 'global',
|
||||
handler: { type: 'built-in', action: 'view.tasks' },
|
||||
keybindings: [{ key: 'Mod+Alt+T', when: 'global' }]
|
||||
}
|
||||
])
|
||||
|
||||
registry.reconcile([plugin], () => true)
|
||||
expect(registry.list()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('projects worker commands and inherited worktree keybinding context', () => {
|
||||
const plugin = commandPlugin('worker', {
|
||||
commands: [{ id: 'create', title: 'Create Task', context: 'worktree' }],
|
||||
keybindings: [{ command: 'create', key: 'Mod+Shift+A' }]
|
||||
})
|
||||
const registry = new PluginCommandRegistry()
|
||||
|
||||
registry.reconcile([plugin], () => true)
|
||||
|
||||
expect(registry.list()).toMatchObject([
|
||||
{
|
||||
context: 'worktree',
|
||||
handler: { type: 'worker' },
|
||||
keybindings: [{ key: 'Mod+Shift+A', when: 'worktree' }]
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('errors approved plugins whose keybindings overlap', () => {
|
||||
const global = commandPlugin('global', {
|
||||
commands: [{ id: 'tasks', title: 'Tasks', action: 'view.tasks' }],
|
||||
keybindings: [{ command: 'tasks', key: 'Mod+Alt+T', when: 'global' }]
|
||||
})
|
||||
const worktree = commandPlugin('worktree', {
|
||||
commands: [{ id: 'tasks', title: 'Tasks', context: 'worktree', action: 'view.tasks' }],
|
||||
keybindings: [{ command: 'tasks', key: 'Mod+Alt+T', when: 'worktree' }]
|
||||
})
|
||||
const registry = new PluginCommandRegistry()
|
||||
|
||||
registry.reconcile([global, worktree], () => true)
|
||||
|
||||
expect(registry.list()).toEqual([])
|
||||
expect(registry.error(global.pluginKey)).toContain('conflicts')
|
||||
expect(registry.error(worktree.pluginKey)).toContain('conflicts')
|
||||
})
|
||||
|
||||
it('uses saved effective bindings to recover conflicting plugins', () => {
|
||||
const first = commandPlugin('first', {
|
||||
commands: [{ id: 'tasks', title: 'Tasks', action: 'view.tasks' }],
|
||||
keybindings: [{ command: 'tasks', key: 'Mod+Alt+T' }]
|
||||
})
|
||||
const second = commandPlugin('second', {
|
||||
commands: [{ id: 'tasks', title: 'Tasks', action: 'view.tasks' }],
|
||||
keybindings: [{ command: 'tasks', key: 'Mod+Alt+T' }]
|
||||
})
|
||||
const registry = new PluginCommandRegistry()
|
||||
|
||||
registry.reconcile(
|
||||
[first, second],
|
||||
() => true,
|
||||
{ 'plugin:orca-samples.first/tasks': ['Mod+Shift+T'] },
|
||||
'linux'
|
||||
)
|
||||
|
||||
expect(registry.list()).toHaveLength(2)
|
||||
expect(registry.error(first.pluginKey)).toBeNull()
|
||||
expect(registry.error(second.pluginKey)).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects conflicting saved bindings within one plugin', () => {
|
||||
const plugin = commandPlugin('aliases', {
|
||||
commands: [
|
||||
{ id: 'tasks', title: 'Tasks', action: 'view.tasks' },
|
||||
{ id: 'sidebar', title: 'Sidebar', action: 'sidebar.left.toggle' }
|
||||
]
|
||||
})
|
||||
const registry = new PluginCommandRegistry()
|
||||
|
||||
registry.reconcile(
|
||||
[plugin],
|
||||
() => true,
|
||||
{
|
||||
'plugin:orca-samples.aliases/tasks': ['Mod+Alt+T'],
|
||||
'plugin:orca-samples.aliases/sidebar': ['Mod+Alt+T']
|
||||
},
|
||||
'linux'
|
||||
)
|
||||
|
||||
expect(registry.list()).toEqual([])
|
||||
expect(registry.error(plugin.pluginKey)).toContain('conflicts')
|
||||
})
|
||||
|
||||
it('detects cross-platform Mod and physical Ctrl conflicts', () => {
|
||||
const portable = commandPlugin('portable', {
|
||||
commands: [{ id: 'tasks', title: 'Tasks', action: 'view.tasks' }],
|
||||
keybindings: [{ command: 'tasks', key: 'Mod+Alt+T' }]
|
||||
})
|
||||
const physical = commandPlugin('physical', {
|
||||
commands: [{ id: 'tasks', title: 'Tasks', action: 'view.tasks' }],
|
||||
keybindings: [{ command: 'tasks', key: 'Ctrl+Alt+T' }]
|
||||
})
|
||||
const registry = new PluginCommandRegistry()
|
||||
|
||||
registry.reconcile([portable, physical], () => true, {}, 'linux')
|
||||
|
||||
expect(registry.list()).toEqual([])
|
||||
expect(registry.error(portable.pluginKey)).toContain('conflicts')
|
||||
expect(registry.error(physical.pluginKey)).toContain('conflicts')
|
||||
})
|
||||
|
||||
it('allows the same worktree-only chord after one plugin is disabled', () => {
|
||||
const first = commandPlugin('first', {
|
||||
commands: [{ id: 'tasks', title: 'Tasks', context: 'worktree', action: 'view.tasks' }],
|
||||
keybindings: [{ command: 'tasks', key: 'Mod+Alt+T' }]
|
||||
})
|
||||
const second = commandPlugin('second', {
|
||||
commands: [{ id: 'tasks', title: 'Tasks', context: 'worktree', action: 'view.tasks' }],
|
||||
keybindings: [{ command: 'tasks', key: 'Mod+Alt+T' }]
|
||||
})
|
||||
const registry = new PluginCommandRegistry()
|
||||
|
||||
registry.reconcile([first, second], (plugin) => plugin === first)
|
||||
|
||||
expect(registry.list()).toHaveLength(1)
|
||||
expect(registry.error(first.pluginKey)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,167 @@
|
||||
import {
|
||||
pluginCommandKeybindingActionId,
|
||||
type PluginCommandAliasActionId
|
||||
} from '../../shared/plugins/plugin-command-actions'
|
||||
import { getKeybindingConflictIdentity, type KeybindingOverrides } from '../../shared/keybindings'
|
||||
import type {
|
||||
PluginCommandContribution,
|
||||
PluginManifest
|
||||
} from '../../shared/plugins/plugin-manifest'
|
||||
import type { PluginKeybindingContribution } from '../../shared/plugins/plugin-content-pack-contributions'
|
||||
import {
|
||||
isInvalidDiscoveredPlugin,
|
||||
type DiscoveredPlugin,
|
||||
type ValidDiscoveredPlugin
|
||||
} from './plugin-discovery'
|
||||
|
||||
export type PluginCommandKeybinding = {
|
||||
key: string
|
||||
when: 'global' | 'worktree'
|
||||
}
|
||||
|
||||
export type PluginCommandRegistration = {
|
||||
pluginKey: string
|
||||
id: string
|
||||
title: string
|
||||
context: 'global' | 'worktree'
|
||||
handler: { type: 'built-in'; action: PluginCommandAliasActionId } | { type: 'worker' }
|
||||
keybindings: PluginCommandKeybinding[]
|
||||
}
|
||||
|
||||
type CommandOwner = {
|
||||
pluginKey: string
|
||||
context: PluginCommandKeybinding['when']
|
||||
key: string
|
||||
}
|
||||
|
||||
export class PluginCommandRegistry {
|
||||
private active: PluginCommandRegistration[] = []
|
||||
private readonly previews = new Map<string, PluginCommandRegistration[]>()
|
||||
private readonly errors = new Map<string, string>()
|
||||
|
||||
list(): readonly PluginCommandRegistration[] {
|
||||
return this.active
|
||||
}
|
||||
|
||||
preview(pluginKey: string): readonly PluginCommandRegistration[] {
|
||||
return this.previews.get(pluginKey) ?? []
|
||||
}
|
||||
|
||||
error(pluginKey: string): string | null {
|
||||
return this.errors.get(pluginKey) ?? null
|
||||
}
|
||||
|
||||
reconcile(
|
||||
discovered: readonly DiscoveredPlugin[],
|
||||
isApproved: (plugin: ValidDiscoveredPlugin) => boolean,
|
||||
overrides: KeybindingOverrides = {},
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): void {
|
||||
const candidates = discovered.filter(
|
||||
(plugin): plugin is ValidDiscoveredPlugin =>
|
||||
!isInvalidDiscoveredPlugin(plugin) && plugin.manifest.contributes.commands.length > 0
|
||||
)
|
||||
const registrations = candidates.map((plugin) => ({
|
||||
pluginKey: plugin.pluginKey,
|
||||
approved: isApproved(plugin),
|
||||
commands: registrationsForManifest(plugin.pluginKey, plugin.manifest)
|
||||
}))
|
||||
|
||||
this.previews.clear()
|
||||
this.errors.clear()
|
||||
for (const plugin of registrations) {
|
||||
this.previews.set(plugin.pluginKey, plugin.commands)
|
||||
}
|
||||
|
||||
const approved = registrations.filter((plugin) => plugin.approved)
|
||||
const chordOwners = new Map<string, CommandOwner[]>()
|
||||
for (const plugin of approved) {
|
||||
for (const command of plugin.commands) {
|
||||
for (const keybinding of effectiveCommandKeybindings(command, overrides)) {
|
||||
const identity = getKeybindingConflictIdentity(keybinding.key, platform)
|
||||
const owners = chordOwners.get(identity) ?? []
|
||||
owners.push({
|
||||
pluginKey: plugin.pluginKey,
|
||||
context: keybinding.when,
|
||||
key: keybinding.key
|
||||
})
|
||||
chordOwners.set(identity, owners)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const conflicted = new Set<string>()
|
||||
for (const owners of chordOwners.values()) {
|
||||
for (let index = 0; index < owners.length; index += 1) {
|
||||
for (let compared = index + 1; compared < owners.length; compared += 1) {
|
||||
const first = owners[index]!
|
||||
const second = owners[compared]!
|
||||
if (!contextsOverlap(first.context, second.context)) {
|
||||
continue
|
||||
}
|
||||
conflicted.add(first.pluginKey)
|
||||
conflicted.add(second.pluginKey)
|
||||
this.errors.set(
|
||||
first.pluginKey,
|
||||
`plugin keybinding ${first.key} conflicts with another plugin`
|
||||
)
|
||||
this.errors.set(
|
||||
second.pluginKey,
|
||||
`plugin keybinding ${second.key} conflicts with another plugin`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.active = approved
|
||||
.filter((plugin) => !conflicted.has(plugin.pluginKey))
|
||||
.flatMap((plugin) => plugin.commands)
|
||||
}
|
||||
}
|
||||
|
||||
function effectiveCommandKeybindings(
|
||||
command: PluginCommandRegistration,
|
||||
overrides: KeybindingOverrides
|
||||
): PluginCommandKeybinding[] {
|
||||
const override = overrides[pluginCommandKeybindingActionId(command.pluginKey, command.id)]
|
||||
if (!Array.isArray(override)) {
|
||||
return command.keybindings
|
||||
}
|
||||
return override.map((key) => ({ key, when: command.context }))
|
||||
}
|
||||
|
||||
function registrationsForManifest(
|
||||
pluginKey: string,
|
||||
manifest: PluginManifest
|
||||
): PluginCommandRegistration[] {
|
||||
return manifest.contributes.commands.map((command) => ({
|
||||
pluginKey,
|
||||
id: command.id,
|
||||
title: command.title,
|
||||
context: command.context ?? 'global',
|
||||
handler:
|
||||
command.action === undefined
|
||||
? { type: 'worker' as const }
|
||||
: { type: 'built-in' as const, action: command.action as PluginCommandAliasActionId },
|
||||
keybindings: keybindingsForCommand(command, manifest.contributes.keybindings)
|
||||
}))
|
||||
}
|
||||
|
||||
function keybindingsForCommand(
|
||||
command: PluginCommandContribution,
|
||||
keybindings: readonly PluginKeybindingContribution[]
|
||||
): PluginCommandKeybinding[] {
|
||||
return keybindings
|
||||
.filter((keybinding) => keybinding.command === command.id)
|
||||
.map((keybinding) => ({
|
||||
key: keybinding.key,
|
||||
when: keybinding.when ?? command.context ?? 'global'
|
||||
}))
|
||||
}
|
||||
|
||||
function contextsOverlap(
|
||||
first: PluginCommandKeybinding['when'],
|
||||
second: PluginCommandKeybinding['when']
|
||||
): boolean {
|
||||
return first === 'global' || second === 'global' || first === second
|
||||
}
|
||||
@@ -10,7 +10,9 @@ import { PluginSkillRegistry } from './plugin-skill-registry'
|
||||
import { PluginThemeRegistry } from './plugin-theme-registry'
|
||||
import { PluginTerminalThemeRegistry } from './plugin-terminal-theme-registry'
|
||||
import { PluginVmRecipeRegistry } from './plugin-vm-recipe-registry'
|
||||
import { PluginCommandRegistry } from './plugin-command-registry'
|
||||
import { verifyInstructionalPluginContent } from './plugin-instructional-content-integrity'
|
||||
import type { KeybindingOverrides } from '../../shared/keybindings'
|
||||
|
||||
export class PluginContentPackRegistry {
|
||||
readonly themes: PluginThemeRegistry
|
||||
@@ -19,6 +21,7 @@ export class PluginContentPackRegistry {
|
||||
readonly terminalThemes: PluginTerminalThemeRegistry
|
||||
readonly skills: PluginSkillRegistry
|
||||
readonly vmRecipes: PluginVmRecipeRegistry
|
||||
readonly commands: PluginCommandRegistry
|
||||
private readonly activationErrors = new Map<string, string>()
|
||||
|
||||
constructor(
|
||||
@@ -35,11 +38,13 @@ export class PluginContentPackRegistry {
|
||||
options.homeDirectory
|
||||
)
|
||||
this.vmRecipes = new PluginVmRecipeRegistry()
|
||||
this.commands = new PluginCommandRegistry()
|
||||
}
|
||||
|
||||
async reconcile(
|
||||
discovered: readonly DiscoveredPlugin[],
|
||||
isApproved: (plugin: ValidDiscoveredPlugin) => boolean
|
||||
isApproved: (plugin: ValidDiscoveredPlugin) => boolean,
|
||||
keybindings: KeybindingOverrides = {}
|
||||
): Promise<void> {
|
||||
const approvedKeys = new Set(
|
||||
discovered
|
||||
@@ -80,7 +85,8 @@ export class PluginContentPackRegistry {
|
||||
this.iconThemes.reconcile(discovered, approveAtomically),
|
||||
this.terminalThemes.reconcile(discovered, approveAtomically),
|
||||
this.skills.reconcile(discovered, approveAtomically),
|
||||
this.vmRecipes.reconcile(discovered, approveAtomically)
|
||||
this.vmRecipes.reconcile(discovered, approveAtomically),
|
||||
this.commands.reconcile(discovered, approveAtomically, keybindings)
|
||||
])
|
||||
|
||||
let foundNewError = false
|
||||
@@ -109,7 +115,8 @@ export class PluginContentPackRegistry {
|
||||
this.iconThemes.error(pluginKey) ??
|
||||
this.terminalThemes.error(pluginKey) ??
|
||||
this.skills.error(pluginKey) ??
|
||||
this.vmRecipes.error(pluginKey)
|
||||
this.vmRecipes.error(pluginKey) ??
|
||||
this.commands.error(pluginKey)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { PluginEventName } from '../../shared/plugins/plugin-manifest'
|
||||
import {
|
||||
isInvalidDiscoveredPlugin,
|
||||
type DiscoveredPlugin,
|
||||
type ValidDiscoveredPlugin
|
||||
} from './plugin-discovery'
|
||||
import type { PluginEventBus } from './plugin-event-bus'
|
||||
import type { PluginWorkerController } from './plugin-worker-controller'
|
||||
|
||||
export function deliverPluginEvent(options: {
|
||||
event: PluginEventName
|
||||
payload: unknown
|
||||
plugins: readonly DiscoveredPlugin[]
|
||||
eventBus: PluginEventBus
|
||||
workerController: PluginWorkerController
|
||||
isRuntimeApproved: (plugin: ValidDiscoveredPlugin) => boolean
|
||||
logWarning: (pluginKey: string, line: string) => void
|
||||
}): void {
|
||||
const projected = options.eventBus.projectPayload(options.event, options.payload)
|
||||
if (!projected.ok) {
|
||||
return
|
||||
}
|
||||
for (const plugin of options.plugins) {
|
||||
if (isInvalidDiscoveredPlugin(plugin) || !options.isRuntimeApproved(plugin)) {
|
||||
continue
|
||||
}
|
||||
const manifestSubscribed = plugin.manifest.contributes.events.some(
|
||||
(subscription) => subscription.on === options.event
|
||||
)
|
||||
if (manifestSubscribed && plugin.manifest.main) {
|
||||
void options.workerController
|
||||
.ensure(plugin)
|
||||
.then((handle) => handle.deliverEvent(options.event, projected.payload))
|
||||
.catch((error) => {
|
||||
options.logWarning(
|
||||
plugin.pluginKey,
|
||||
`event ${options.event} dropped: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
})
|
||||
} else if (options.eventBus.isDynamicallySubscribed(plugin.pluginKey, options.event)) {
|
||||
options.workerController.deliverEventIfRunning(
|
||||
plugin.pluginKey,
|
||||
options.event,
|
||||
projected.payload
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ function serviceWith(
|
||||
activation?: ReturnType<PluginService['activationState']>
|
||||
worker?: ReturnType<PluginService['workerState']>
|
||||
vmRecipes?: ReturnType<PluginService['contentPacks']['vmRecipes']['preview']>
|
||||
commands?: ReturnType<PluginService['contentPacks']['commands']['preview']>
|
||||
} = {}
|
||||
): PluginService {
|
||||
return {
|
||||
@@ -37,7 +38,8 @@ function serviceWith(
|
||||
workerState: () => options.worker ?? { state: 'inactive', restarts: 0 },
|
||||
activationError: () => null,
|
||||
contentPacks: {
|
||||
vmRecipes: { preview: () => options.vmRecipes ?? [] }
|
||||
vmRecipes: { preview: () => options.vmRecipes ?? [] },
|
||||
commands: { preview: () => options.commands ?? [] }
|
||||
}
|
||||
} as unknown as PluginService
|
||||
}
|
||||
@@ -168,4 +170,49 @@ describe('buildPluginList consent identity', () => {
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('projects command handlers and normalized keybindings for consent and dispatch', () => {
|
||||
const commandManifest = pluginManifestSchema.parse({
|
||||
...manifest,
|
||||
contributes: {
|
||||
commands: [{ id: 'tasks', title: 'Open Tasks', context: 'worktree', action: 'view.tasks' }],
|
||||
keybindings: [{ command: 'tasks', key: 'mod+alt+t' }]
|
||||
}
|
||||
})
|
||||
const plugin: ValidDiscoveredPlugin = {
|
||||
pluginKey: 'orca-samples.demo',
|
||||
rootDir: join(tmpdir(), 'plugins', 'demo'),
|
||||
manifest: commandManifest,
|
||||
consentFingerprint: 'sha256-current',
|
||||
consentContentHash: 'a'.repeat(64),
|
||||
contentHash: null,
|
||||
isDev: true
|
||||
}
|
||||
|
||||
expect(
|
||||
buildPluginList(
|
||||
serviceWith(plugin, {
|
||||
commands: [
|
||||
{
|
||||
pluginKey: plugin.pluginKey,
|
||||
id: 'tasks',
|
||||
title: 'Open Tasks',
|
||||
context: 'worktree',
|
||||
handler: { type: 'built-in', action: 'view.tasks' },
|
||||
keybindings: [{ key: 'Mod+Alt+T', when: 'worktree' }]
|
||||
}
|
||||
]
|
||||
}),
|
||||
emptyPluginLockfile()
|
||||
)[0]?.commands
|
||||
).toEqual([
|
||||
{
|
||||
id: 'tasks',
|
||||
title: 'Open Tasks',
|
||||
context: 'worktree',
|
||||
handler: { type: 'built-in', action: 'view.tasks' },
|
||||
keybindings: [{ key: 'Mod+Alt+T', when: 'worktree' }]
|
||||
}
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { PluginLockfile } from '../../shared/plugins/plugin-install-lockfil
|
||||
import { isInvalidDiscoveredPlugin } from './plugin-discovery'
|
||||
import type { PluginService } from './plugin-service'
|
||||
import { listPluginVmRecipeCommands } from '../../shared/plugins/plugin-vm-recipe-artifact'
|
||||
import type { PluginCommandAliasActionId } from '../../shared/plugins/plugin-command-actions'
|
||||
|
||||
/**
|
||||
* Wire projection of installed plugins for the renderer and serve RPC.
|
||||
@@ -46,7 +47,13 @@ export type PluginListEntry = {
|
||||
isDev: boolean
|
||||
capabilities: { kind: PluginCapabilityKind; description: string }[]
|
||||
panels: PluginListPanelEntry[]
|
||||
commands: { id: string; title: string }[]
|
||||
commands: {
|
||||
id: string
|
||||
title: string
|
||||
context: 'global' | 'worktree'
|
||||
handler: { type: 'built-in'; action: PluginCommandAliasActionId } | { type: 'worker' }
|
||||
keybindings: { key: string; when: 'global' | 'worktree' }[]
|
||||
}[]
|
||||
hasWorker: boolean
|
||||
hasSkills: boolean
|
||||
vmRecipes: {
|
||||
@@ -142,9 +149,12 @@ export function buildPluginList(service: PluginService, lock: PluginLockfile): P
|
||||
...(panel.icon ? { icon: panel.icon } : {}),
|
||||
tabKey: pluginPanelTabKey(plugin.pluginKey, panel.id)
|
||||
})),
|
||||
commands: plugin.manifest.contributes.commands.map((command) => ({
|
||||
commands: service.contentPacks.commands.preview(plugin.pluginKey).map((command) => ({
|
||||
id: command.id,
|
||||
title: command.title
|
||||
title: command.title,
|
||||
context: command.context,
|
||||
handler: command.handler,
|
||||
keybindings: command.keybindings
|
||||
})),
|
||||
hasWorker: Boolean(plugin.manifest.main),
|
||||
hasSkills: plugin.manifest.contributes.skills.length > 0,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { PluginWorkerFactory } from './plugin-worker-manager'
|
||||
import type { KeybindingOverrides } from '../../shared/keybindings'
|
||||
|
||||
export type PluginServiceOptions = {
|
||||
userDataPath: string
|
||||
@@ -7,6 +8,7 @@ export type PluginServiceOptions = {
|
||||
getDisabledPlugins: () => string[]
|
||||
getPluginConsents: () => Record<string, string>
|
||||
getDevPluginPaths: () => string[]
|
||||
getKeybindings?: () => KeybindingOverrides
|
||||
hostEntryPath?: string
|
||||
workerFactory?: PluginWorkerFactory
|
||||
maxActiveWorkers?: number
|
||||
|
||||
@@ -2,11 +2,13 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { KeybindingOverrides } from '../../shared/keybindings'
|
||||
import { fingerprintPluginConsent } from '../../shared/plugins/plugin-consent-fingerprint'
|
||||
import { pluginManifestSchema, type PluginManifest } from '../../shared/plugins/plugin-manifest'
|
||||
import type { PluginWorkerHandle } from './plugin-host-process'
|
||||
import { PluginService } from './plugin-service'
|
||||
import type { PluginWorkerFactory } from './plugin-worker-manager'
|
||||
import { hashPluginTree } from './plugin-content-hash'
|
||||
|
||||
const roots: string[] = []
|
||||
const services: PluginService[] = []
|
||||
@@ -103,6 +105,111 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe('PluginService worker reconciliation', () => {
|
||||
it('blocks every runtime surface until a saved override resolves a content conflict', async () => {
|
||||
const conflictingManifest = (id: string): PluginManifest =>
|
||||
pluginManifestSchema.parse({
|
||||
manifestVersion: 1,
|
||||
id,
|
||||
publisher: 'orca-samples',
|
||||
name: id,
|
||||
version: '1.0.0',
|
||||
engines: { orca: '>=1.0.0' },
|
||||
pluginApi: 1,
|
||||
main: 'worker.js',
|
||||
contributes: {
|
||||
panels: [{ id: 'panel', title: 'Panel', entry: 'panel.html' }],
|
||||
commands: [{ id: 'run', title: 'Run' }],
|
||||
keybindings: [{ command: 'run', key: 'Mod+Alt+T' }],
|
||||
events: [{ on: 'worktree.created' }]
|
||||
},
|
||||
capabilities: [{ kind: 'events:subscribe' }]
|
||||
})
|
||||
const firstManifest = conflictingManifest('first')
|
||||
const secondManifest = conflictingManifest('second')
|
||||
const firstRoot = await pluginRoot(firstManifest)
|
||||
const secondRoot = await pluginRoot(secondManifest)
|
||||
const firstHash = await hashPluginTree(firstRoot)
|
||||
const secondHash = await hashPluginTree(secondRoot)
|
||||
if (!firstHash.ok || !secondHash.ok) {
|
||||
throw new Error('could not hash conflict fixtures')
|
||||
}
|
||||
let keybindings: KeybindingOverrides = {}
|
||||
const factory = vi.fn<PluginWorkerFactory>(async () => testWorker())
|
||||
const service = new PluginService({
|
||||
userDataPath: firstRoot,
|
||||
hostVersion: '1.4.0',
|
||||
isPluginSystemEnabled: () => true,
|
||||
getDisabledPlugins: () => [],
|
||||
getPluginConsents: () => ({
|
||||
'orca-samples.first': fingerprintPluginConsent(firstManifest, firstHash.hash),
|
||||
'orca-samples.second': fingerprintPluginConsent(secondManifest, secondHash.hash)
|
||||
}),
|
||||
getDevPluginPaths: () => [firstRoot, secondRoot],
|
||||
getKeybindings: () => keybindings,
|
||||
workerFactory: factory
|
||||
})
|
||||
services.push(service)
|
||||
|
||||
await service.initialize()
|
||||
|
||||
expect(service.activationError('orca-samples.first')).toContain('conflicts')
|
||||
expect(service.getGrantedCapabilities('orca-samples.first')).toBeNull()
|
||||
await expect(service.invokeCommand('orca-samples.first', 'run')).rejects.toThrow('not enabled')
|
||||
await expect(service.panels.readEntry('orca-samples.first', 'panel')).resolves.toBeNull()
|
||||
service.emitEvent('worktree.created', {
|
||||
worktreeId: 'worktree-1',
|
||||
path: '/repo',
|
||||
branch: 'feature'
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
expect(factory).not.toHaveBeenCalled()
|
||||
|
||||
keybindings = { 'plugin:orca-samples.first/run': ['Mod+Shift+T'] }
|
||||
await service.reconcileActivationState()
|
||||
|
||||
expect(service.activationError('orca-samples.first')).toBeNull()
|
||||
await expect(service.panels.readEntry('orca-samples.first', 'panel')).resolves.toMatchObject({
|
||||
html: expect.stringContaining('<h1>Panel</h1>')
|
||||
})
|
||||
await expect(service.invokeCommand('orca-samples.first', 'run')).resolves.toBeNull()
|
||||
expect(factory).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('rejects declarative aliases at the worker-command boundary without activating code', async () => {
|
||||
const aliasManifest = pluginManifestSchema.parse({
|
||||
manifestVersion: 1,
|
||||
id: 'demo',
|
||||
publisher: 'orca-samples',
|
||||
name: 'Demo',
|
||||
version: '1.0.0',
|
||||
engines: { orca: '>=1.0.0' },
|
||||
pluginApi: 1,
|
||||
contributes: {
|
||||
commands: [{ id: 'tasks', title: 'Tasks', action: 'view.tasks' }]
|
||||
},
|
||||
capabilities: []
|
||||
})
|
||||
const root = await pluginRoot(aliasManifest)
|
||||
const factory = vi.fn<PluginWorkerFactory>()
|
||||
const service = new PluginService({
|
||||
userDataPath: root,
|
||||
hostVersion: '1.4.0',
|
||||
isPluginSystemEnabled: () => true,
|
||||
getDisabledPlugins: () => [],
|
||||
getPluginConsents: () => ({ [pluginKey]: fingerprintPluginConsent(aliasManifest) }),
|
||||
getDevPluginPaths: () => [root],
|
||||
workerFactory: factory
|
||||
})
|
||||
services.push(service)
|
||||
|
||||
await service.initialize()
|
||||
|
||||
await expect(service.invokeCommand(pluginKey, 'tasks')).rejects.toThrow(
|
||||
'is a built-in action alias'
|
||||
)
|
||||
expect(factory).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('denies every authority boundary immediately when the feature flag turns off', async () => {
|
||||
const root = await pluginRoot()
|
||||
const harness = createHarness(root)
|
||||
|
||||
@@ -36,6 +36,8 @@ import { PluginContentPackRegistry } from './plugin-content-pack-registry'
|
||||
import type { PluginServiceOptions } from './plugin-service-options'
|
||||
import type { PluginChangeEvent } from '../../shared/plugins/plugin-change-event'
|
||||
import { waitForPluginRefreshSettlement } from './plugin-refresh-settlement'
|
||||
import { assertPluginWorkerCommand } from './plugin-command-invocation'
|
||||
import { deliverPluginEvent } from './plugin-event-delivery'
|
||||
|
||||
export type { PluginRuntimeDelegate } from './plugin-host-service-bindings'
|
||||
export type { PluginLogLine } from './plugin-log-buffer'
|
||||
@@ -57,6 +59,7 @@ export class PluginService {
|
||||
private runtimeDelegate: PluginRuntimeDelegate | null = null
|
||||
private initPromise: Promise<void> | null = null
|
||||
private refreshChain: Promise<void> = Promise.resolve()
|
||||
private contentPacksReady = false
|
||||
private disposed = false
|
||||
|
||||
constructor(options: PluginServiceOptions) {
|
||||
@@ -69,7 +72,7 @@ export class PluginService {
|
||||
this.panels = new PluginPanelController({
|
||||
resolveApprovedPlugin: (pluginKey) => {
|
||||
const plugin = this.findValidPlugin(pluginKey)
|
||||
return plugin && this.activationState(plugin) === 'approved' ? plugin : null
|
||||
return plugin && this.isRuntimeApproved(plugin) ? plugin : null
|
||||
},
|
||||
contentVerifier: this.contentVerifier,
|
||||
executeHostCall: (pluginKey, method, params) =>
|
||||
@@ -85,8 +88,7 @@ export class PluginService {
|
||||
contentVerifier: this.contentVerifier,
|
||||
capabilities: (pluginKey) => this.getGrantedCapabilities(pluginKey),
|
||||
isCurrentApproved: (plugin) =>
|
||||
this.findValidPlugin(plugin.pluginKey) === plugin &&
|
||||
this.activationState(plugin) === 'approved',
|
||||
this.findValidPlugin(plugin.pluginKey) === plugin && this.isRuntimeApproved(plugin),
|
||||
invokeCommand: (pluginKey, commandId, args) => this.invokeCommand(pluginKey, commandId, args),
|
||||
executeHostCall: (pluginKey, method, params) =>
|
||||
this.executeHostCall(pluginKey, method, params, { viaPanel: false }),
|
||||
@@ -144,6 +146,7 @@ export class PluginService {
|
||||
if (this.disposed) {
|
||||
return
|
||||
}
|
||||
this.contentPacksReady = false
|
||||
this.contentVerifier.clear()
|
||||
if (!enabled) {
|
||||
this.panels.revokeAll()
|
||||
@@ -158,14 +161,15 @@ export class PluginService {
|
||||
if (this.disposed) {
|
||||
return
|
||||
}
|
||||
const nextSpecs = collectApprovedWorkerSpecs(next, (plugin) =>
|
||||
isPluginApproved(enabled, plugin, consentLists)
|
||||
)
|
||||
// Publish identity before shutdown so triggers cannot restart old code.
|
||||
this.discovered = next
|
||||
await this.contentPacks.reconcile(next, (plugin) =>
|
||||
isPluginApproved(enabled, plugin, consentLists)
|
||||
await this.contentPacks.reconcile(
|
||||
next,
|
||||
(plugin) => isPluginApproved(enabled, plugin, consentLists),
|
||||
this.options.getKeybindings?.()
|
||||
)
|
||||
this.contentPacksReady = true
|
||||
const nextSpecs = collectApprovedWorkerSpecs(next, (plugin) => this.isRuntimeApproved(plugin))
|
||||
// Notify before slow shutdown so feature-off unmounts panels immediately.
|
||||
this.notifyChanged(true)
|
||||
await this.workerController.reconcile(nextSpecs)
|
||||
@@ -210,6 +214,14 @@ export class PluginService {
|
||||
})
|
||||
}
|
||||
|
||||
private isRuntimeApproved(plugin: ValidDiscoveredPlugin): boolean {
|
||||
return (
|
||||
this.contentPacksReady &&
|
||||
this.activationState(plugin) === 'approved' &&
|
||||
!this.contentPacks.error(plugin.pluginKey)
|
||||
)
|
||||
}
|
||||
|
||||
workerState(pluginKey: string): { state: PluginRunState; restarts: number } {
|
||||
return this.workerController.state(pluginKey)
|
||||
}
|
||||
@@ -222,7 +234,7 @@ export class PluginService {
|
||||
* callers deny uniformly (no probe-able distinction). */
|
||||
getGrantedCapabilities(pluginKey: string): PluginCapabilityKind[] | null {
|
||||
const plugin = this.findValidPlugin(pluginKey)
|
||||
if (!plugin || this.activationState(plugin) !== 'approved') {
|
||||
if (!plugin || !this.isRuntimeApproved(plugin)) {
|
||||
return null
|
||||
}
|
||||
return capabilityKinds(plugin.manifest.capabilities)
|
||||
@@ -256,12 +268,10 @@ export class PluginService {
|
||||
|
||||
async invokeCommand(pluginKey: string, commandId: string, args?: unknown): Promise<unknown> {
|
||||
const plugin = this.findValidPlugin(pluginKey)
|
||||
if (!plugin || this.activationState(plugin) !== 'approved') {
|
||||
if (!plugin || !this.isRuntimeApproved(plugin)) {
|
||||
throw new Error(`plugin ${pluginKey} is not enabled`)
|
||||
}
|
||||
if (!plugin.manifest.contributes.commands.some((command) => command.id === commandId)) {
|
||||
throw new Error(`plugin ${pluginKey} does not contribute command ${commandId}`)
|
||||
}
|
||||
assertPluginWorkerCommand(plugin, commandId)
|
||||
const handle = await this.workerController.ensure(plugin)
|
||||
if (!handle.commands.includes(commandId)) {
|
||||
throw new Error(`plugin ${pluginKey} registered no handler for ${commandId}`)
|
||||
@@ -273,32 +283,15 @@ export class PluginService {
|
||||
if (!this.options.isPluginSystemEnabled() || this.disposed) {
|
||||
return
|
||||
}
|
||||
const projected = this.eventBus.projectPayload(event, payload)
|
||||
if (!projected.ok) {
|
||||
return
|
||||
}
|
||||
for (const plugin of this.discovered) {
|
||||
if (isInvalidDiscoveredPlugin(plugin) || this.activationState(plugin) !== 'approved') {
|
||||
continue
|
||||
}
|
||||
const manifestSubscribed = plugin.manifest.contributes.events.some(
|
||||
(subscription) => subscription.on === event
|
||||
)
|
||||
if (manifestSubscribed && plugin.manifest.main) {
|
||||
void this.workerController
|
||||
.ensure(plugin)
|
||||
.then((handle) => handle.deliverEvent(event, projected.payload))
|
||||
.catch((error) => {
|
||||
this.logBuffer.append(
|
||||
plugin.pluginKey,
|
||||
'warn',
|
||||
`event ${event} dropped: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
})
|
||||
} else if (this.eventBus.isDynamicallySubscribed(plugin.pluginKey, event)) {
|
||||
this.workerController.deliverEventIfRunning(plugin.pluginKey, event, projected.payload)
|
||||
}
|
||||
}
|
||||
deliverPluginEvent({
|
||||
event,
|
||||
payload,
|
||||
plugins: this.discovered,
|
||||
eventBus: this.eventBus,
|
||||
workerController: this.workerController,
|
||||
isRuntimeApproved: (plugin) => this.isRuntimeApproved(plugin),
|
||||
logWarning: (pluginKey, line) => this.logBuffer.append(pluginKey, 'warn', line)
|
||||
})
|
||||
}
|
||||
|
||||
async deactivatePlugin(pluginKey: string): Promise<void> {
|
||||
@@ -315,12 +308,15 @@ export class PluginService {
|
||||
}
|
||||
|
||||
private async performActivationStateReconciliation(): Promise<void> {
|
||||
await this.contentPacks.reconcile(this.discovered, (plugin) => {
|
||||
return this.activationState(plugin) === 'approved'
|
||||
})
|
||||
const nextSpecs = collectApprovedWorkerSpecs(
|
||||
this.contentPacksReady = false
|
||||
await this.contentPacks.reconcile(
|
||||
this.discovered,
|
||||
(plugin) => this.activationState(plugin) === 'approved'
|
||||
(plugin) => this.activationState(plugin) === 'approved',
|
||||
this.options.getKeybindings?.()
|
||||
)
|
||||
this.contentPacksReady = true
|
||||
const nextSpecs = collectApprovedWorkerSpecs(this.discovered, (plugin) =>
|
||||
this.isRuntimeApproved(plugin)
|
||||
)
|
||||
await this.workerController.reconcile(nextSpecs)
|
||||
this.notifyChanged(true)
|
||||
|
||||
@@ -22,6 +22,7 @@ let markerPaths: string[] = []
|
||||
let consents: Record<string, string> = {}
|
||||
|
||||
function dummyManifest(index: number): PluginManifest {
|
||||
const key = String.fromCharCode('A'.charCodeAt(0) + index)
|
||||
return pluginManifestSchema.parse({
|
||||
manifestVersion: 1,
|
||||
id: `dummy-${index}`,
|
||||
@@ -30,8 +31,25 @@ function dummyManifest(index: number): PluginManifest {
|
||||
version: '1.0.0',
|
||||
engines: { orca: '>=1.0.0' },
|
||||
pluginApi: 1,
|
||||
main: 'main.mjs',
|
||||
contributes: { panels: [], commands: [], events: [] },
|
||||
contributes: {
|
||||
panels: [],
|
||||
commands: [
|
||||
{
|
||||
id: 'open',
|
||||
title: `Open Startup Dummy ${index}`,
|
||||
action: 'view.tasks'
|
||||
}
|
||||
],
|
||||
events: [],
|
||||
keybindings: [{ command: 'open', key: `Mod+Alt+${key}` }],
|
||||
themes: [
|
||||
{
|
||||
id: `startup-${index}`,
|
||||
label: `Startup Theme ${index}`,
|
||||
path: 'theme.json'
|
||||
}
|
||||
]
|
||||
},
|
||||
capabilities: []
|
||||
})
|
||||
}
|
||||
@@ -48,8 +66,11 @@ async function installDummy(index: number): Promise<{ pluginKey: string; markerP
|
||||
writeFile(join(pluginDir, 'current'), contentHash),
|
||||
writeFile(join(versionDir, PLUGIN_MANIFEST_FILENAME), JSON.stringify(manifest)),
|
||||
writeFile(
|
||||
join(versionDir, 'main.mjs'),
|
||||
`import { writeFileSync } from 'node:fs'; writeFileSync(${JSON.stringify(markerPath)}, 'executed'); export default async function activate() { return {}; }`
|
||||
join(versionDir, 'theme.json'),
|
||||
JSON.stringify({
|
||||
base: index % 2 === 0 ? 'dark' : 'light',
|
||||
tokens: { '--background': index % 2 === 0 ? '#111111' : '#eeeeee' }
|
||||
})
|
||||
)
|
||||
])
|
||||
consents[pluginKey] = fingerprintPluginConsent(manifest)
|
||||
@@ -74,7 +95,7 @@ describe('plugin startup budget', () => {
|
||||
await rm(userDataPath, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('stays below 50ms P95 with 20 approved plugins and executes no plugin code', async () => {
|
||||
it('stays below 50ms P95 with 20 approved content packs and executes no plugin code', async () => {
|
||||
const workerFactory = vi.fn<PluginWorkerFactory>(async () => {
|
||||
throw new Error('startup must not create a plugin worker')
|
||||
})
|
||||
|
||||
@@ -137,4 +137,30 @@ describe('PluginWorkerController activation authority', () => {
|
||||
expect(startedWorker.dispose).toHaveBeenCalledOnce()
|
||||
await subject.dispose()
|
||||
})
|
||||
|
||||
it('rejects workers that register declarative action aliases', async () => {
|
||||
const base = await plugin()
|
||||
const subjectPlugin: ValidDiscoveredPlugin = {
|
||||
...base,
|
||||
manifest: pluginManifestSchema.parse({
|
||||
...base.manifest,
|
||||
contributes: {
|
||||
...base.manifest.contributes,
|
||||
commands: [{ id: 'tasks', title: 'Tasks', action: 'view.tasks' }]
|
||||
}
|
||||
})
|
||||
}
|
||||
const startedWorker = worker(['tasks'])
|
||||
const subject = controller({
|
||||
factory: vi.fn<PluginWorkerFactory>().mockResolvedValue(startedWorker),
|
||||
verify: async () => undefined,
|
||||
isApproved: () => true
|
||||
})
|
||||
|
||||
await expect(subject.ensure(subjectPlugin)).rejects.toThrow(
|
||||
'registered undeclared command tasks'
|
||||
)
|
||||
expect(startedWorker.dispose).toHaveBeenCalledOnce()
|
||||
await subject.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -86,7 +86,9 @@ export class PluginWorkerController {
|
||||
throw new Error(`plugin ${plugin.pluginKey} changed or was disabled during activation`)
|
||||
}
|
||||
const declaredCommands = new Set(
|
||||
plugin.manifest.contributes.commands.map((command) => command.id)
|
||||
plugin.manifest.contributes.commands
|
||||
.filter((command) => command.action === undefined)
|
||||
.map((command) => command.id)
|
||||
)
|
||||
const undeclaredCommand = handle.commands.find((command) => !declaredCommands.has(command))
|
||||
if (undeclaredCommand) {
|
||||
|
||||
@@ -949,7 +949,13 @@ export type PluginHostListEntry = {
|
||||
isDev: boolean
|
||||
capabilities: { kind: string; description: string }[]
|
||||
panels: PluginHostPanel[]
|
||||
commands: { id: string; title: string }[]
|
||||
commands: {
|
||||
id: string
|
||||
title: string
|
||||
context: 'global' | 'worktree'
|
||||
handler: { type: 'built-in'; action: string } | { type: 'worker' }
|
||||
keybindings: { key: string; when: 'global' | 'worktree' }[]
|
||||
}[]
|
||||
hasWorker: boolean
|
||||
hasSkills?: boolean
|
||||
vmRecipes?: {
|
||||
|
||||
+220
-154
@@ -166,6 +166,11 @@ import {
|
||||
type KeybindingContext,
|
||||
type PhysicalModifierToken
|
||||
} from '../../shared/keybindings'
|
||||
import { PLUGIN_COMMAND_ALIAS_ACTION_IDS } from '../../shared/plugins/plugin-command-actions'
|
||||
import { registerAppCommandDispatcher } from '@/lib/app-command-dispatch'
|
||||
import { executePluginCommand } from '@/lib/plugin-command-execution'
|
||||
import { findPluginCommandForKeybinding } from '@/lib/plugin-command-keybindings'
|
||||
import { usePluginCommands } from '@/store/plugin-panels'
|
||||
import { toRuntimeExecutionHostId, type ExecutionHostId } from '../../shared/execution-host'
|
||||
import {
|
||||
ModifierDoubleTapDetector,
|
||||
@@ -502,6 +507,7 @@ function App(): React.JSX.Element {
|
||||
hasRequestedBackgroundTerminalWorktreeMount
|
||||
)
|
||||
const keybindings = useAppStore((s) => s.keybindings)
|
||||
const pluginCommands = usePluginCommands()
|
||||
const updateStatus = useAppStore((s) => s.updateStatus)
|
||||
const activeContextualTourId = useAppStore((s) => s.activeContextualTourId)
|
||||
const leftSidebarShortcutLabel = useShortcutLabel('sidebar.left.toggle')
|
||||
@@ -1594,6 +1600,7 @@ function App(): React.JSX.Element {
|
||||
floatingTerminalOpen,
|
||||
floatingVisibleTabCount,
|
||||
keybindings,
|
||||
pluginCommands,
|
||||
terminalShortcutPolicy: settings?.terminalShortcutPolicy,
|
||||
setFloatingTerminalOpenWithFocus,
|
||||
workspaceChromeActive,
|
||||
@@ -1609,6 +1616,7 @@ function App(): React.JSX.Element {
|
||||
floatingTerminalOpen,
|
||||
floatingVisibleTabCount,
|
||||
keybindings,
|
||||
pluginCommands,
|
||||
terminalShortcutPolicy: settings?.terminalShortcutPolicy,
|
||||
setFloatingTerminalOpenWithFocus,
|
||||
workspaceChromeActive,
|
||||
@@ -1618,6 +1626,196 @@ function App(): React.JSX.Element {
|
||||
useEffect(() => {
|
||||
const doubleTapDetector = new ModifierDoubleTapDetector()
|
||||
|
||||
const createRegisteredCommandHandlers = (
|
||||
input?: ShortcutDispatchInput,
|
||||
keybindingContext: KeybindingContext = 'app'
|
||||
): Map<KeybindingActionId, () => boolean> => {
|
||||
const {
|
||||
activeView,
|
||||
activeWorktreeId,
|
||||
actions,
|
||||
floatingTerminalEnabled,
|
||||
floatingTerminalOpen,
|
||||
terminalShortcutPolicy,
|
||||
keybindings,
|
||||
setFloatingTerminalOpenWithFocus,
|
||||
workspaceChromeActive,
|
||||
creationLayoutActive
|
||||
} = globalShortcutStateRef.current
|
||||
const floatingWorkspaceFocused = isFloatingWorkspacePanelFocused()
|
||||
const canRevealRightSidebar = !creationLayoutActive && canShowRightSidebarForView(activeView)
|
||||
const claim = (actionId: KeybindingActionId, run: () => void): boolean => {
|
||||
input?.preventDefault()
|
||||
if (
|
||||
input &&
|
||||
keybindingContext === 'terminal' &&
|
||||
(terminalShortcutPolicy ?? 'orca-first') === 'orca-first'
|
||||
) {
|
||||
showTerminalShortcutCaptureNotification({
|
||||
actionId,
|
||||
platform: shortcutPlatform,
|
||||
keybindings
|
||||
})
|
||||
}
|
||||
run()
|
||||
return true
|
||||
}
|
||||
|
||||
return new Map<KeybindingActionId, () => boolean>([
|
||||
[
|
||||
'worktree.history.back',
|
||||
() => {
|
||||
if (creationLayoutActive || !shouldShowWorktreeHistoryControls(activeView)) {
|
||||
return false
|
||||
}
|
||||
return claim('worktree.history.back', () => useAppStore.getState().goBackWorktree())
|
||||
}
|
||||
],
|
||||
[
|
||||
'worktree.history.forward',
|
||||
() => {
|
||||
if (creationLayoutActive || !shouldShowWorktreeHistoryControls(activeView)) {
|
||||
return false
|
||||
}
|
||||
return claim('worktree.history.forward', () =>
|
||||
useAppStore.getState().goForwardWorktree()
|
||||
)
|
||||
}
|
||||
],
|
||||
['sidebar.left.toggle', () => claim('sidebar.left.toggle', () => actions.toggleSidebar())],
|
||||
[
|
||||
'sidebar.sleepingWorkspaces.toggle',
|
||||
() =>
|
||||
claim('sidebar.sleepingWorkspaces.toggle', () => {
|
||||
const store = useAppStore.getState()
|
||||
const nextShowSleeping = !store.showSleepingWorkspaces
|
||||
store.setShowSleepingWorkspaces(nextShowSleeping)
|
||||
if (nextShowSleeping) {
|
||||
store.setSidebarOpen(true)
|
||||
}
|
||||
})
|
||||
],
|
||||
[
|
||||
'floatingWorkspace.maximize',
|
||||
() => {
|
||||
if (floatingTerminalOpen || !floatingTerminalEnabled) {
|
||||
return false
|
||||
}
|
||||
return claim('floatingWorkspace.maximize', () => {
|
||||
requestFloatingTerminalOpenMaximized()
|
||||
setFloatingTerminalOpenWithFocus(true)
|
||||
})
|
||||
}
|
||||
],
|
||||
[
|
||||
'tab.rename',
|
||||
() => {
|
||||
const store = useAppStore.getState()
|
||||
if (
|
||||
!workspaceChromeActive ||
|
||||
floatingWorkspaceFocused ||
|
||||
store.activeTabType !== 'terminal' ||
|
||||
!store.activeTabId
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return claim('tab.rename', () => store.setRenamingTabId(store.activeTabId!))
|
||||
}
|
||||
],
|
||||
[
|
||||
'workspace.rename',
|
||||
() => {
|
||||
if (!workspaceChromeActive || floatingWorkspaceFocused || !activeWorktreeId) {
|
||||
return false
|
||||
}
|
||||
return claim('workspace.rename', () => {
|
||||
useAppStore.getState().setSidebarOpen(true)
|
||||
requestScrollToCurrentWorkspaceRevealAndRename()
|
||||
})
|
||||
}
|
||||
],
|
||||
[
|
||||
'workspace.openBoard',
|
||||
() => {
|
||||
if (activeView === 'settings') {
|
||||
return false
|
||||
}
|
||||
return claim('workspace.openBoard', () => {
|
||||
useAppStore.getState().setSidebarOpen(true)
|
||||
window.dispatchEvent(new CustomEvent(OPEN_WORKSPACE_BOARD_EVENT))
|
||||
})
|
||||
}
|
||||
],
|
||||
[
|
||||
'view.tasks',
|
||||
() => {
|
||||
const store = useAppStore.getState()
|
||||
if (activeView === 'settings' || !store.repos.some((repo) => isGitRepoKind(repo))) {
|
||||
return false
|
||||
}
|
||||
return claim('view.tasks', () => store.openTaskPage())
|
||||
}
|
||||
],
|
||||
[
|
||||
'sidebar.right.toggle',
|
||||
() =>
|
||||
canRevealRightSidebar
|
||||
? claim('sidebar.right.toggle', () => actions.toggleRightSidebar())
|
||||
: false
|
||||
],
|
||||
[
|
||||
'sidebar.explorer.toggle',
|
||||
() =>
|
||||
canRevealRightSidebar
|
||||
? claim('sidebar.explorer.toggle', () => actions.showRightSidebarFiles())
|
||||
: false
|
||||
],
|
||||
[
|
||||
'sidebar.search.toggle',
|
||||
() =>
|
||||
canRevealRightSidebar
|
||||
? claim('sidebar.search.toggle', () => actions.showRightSidebarSearch())
|
||||
: false
|
||||
],
|
||||
[
|
||||
'sidebar.sourceControl.toggle',
|
||||
() => {
|
||||
if (!canRevealRightSidebar || document.querySelector('[data-terminal-search-root]')) {
|
||||
return false
|
||||
}
|
||||
return claim('sidebar.sourceControl.toggle', () => {
|
||||
actions.setRightSidebarTab('source-control')
|
||||
actions.setRightSidebarOpen(true)
|
||||
})
|
||||
}
|
||||
],
|
||||
[
|
||||
'sidebar.checks.toggle',
|
||||
() =>
|
||||
canRevealRightSidebar
|
||||
? claim('sidebar.checks.toggle', () => {
|
||||
actions.setRightSidebarTab('checks')
|
||||
actions.setRightSidebarOpen(true)
|
||||
})
|
||||
: false
|
||||
],
|
||||
[
|
||||
'sidebar.ports.toggle',
|
||||
() =>
|
||||
canRevealRightSidebar
|
||||
? claim('sidebar.ports.toggle', () => {
|
||||
actions.setRightSidebarTab('ports')
|
||||
actions.setRightSidebarOpen(true)
|
||||
})
|
||||
: false
|
||||
]
|
||||
])
|
||||
}
|
||||
|
||||
const unregisterAppCommandDispatcher = registerAppCommandDispatcher((actionId) =>
|
||||
(createRegisteredCommandHandlers().get(actionId) ?? (() => false))()
|
||||
)
|
||||
|
||||
const dispatchShortcutInput = (input: ShortcutDispatchInput): void => {
|
||||
const {
|
||||
activeView,
|
||||
@@ -1627,9 +1825,9 @@ function App(): React.JSX.Element {
|
||||
floatingTerminalOpen,
|
||||
floatingVisibleTabCount,
|
||||
keybindings,
|
||||
pluginCommands,
|
||||
terminalShortcutPolicy,
|
||||
setFloatingTerminalOpenWithFocus,
|
||||
workspaceChromeActive,
|
||||
creationLayoutActive
|
||||
} = globalShortcutStateRef.current
|
||||
|
||||
@@ -1752,25 +1950,6 @@ function App(): React.JSX.Element {
|
||||
return
|
||||
}
|
||||
|
||||
// Cmd/Ctrl+Alt+Arrow — worktree history back/forward. This stays before
|
||||
// right-sidebar shortcuts because it is navigation, not sidebar reveal.
|
||||
if (matchShortcut('worktree.history.back') || matchShortcut('worktree.history.forward')) {
|
||||
// Why: Back/Forward traverse mixed worktree + page visits, so the
|
||||
// shortcut is active wherever the titlebar button cluster is (terminal
|
||||
// or stack-backed pages). Still suppressed in Settings.
|
||||
if (creationLayoutActive || !shouldShowWorktreeHistoryControls(activeView)) {
|
||||
return
|
||||
}
|
||||
input.preventDefault()
|
||||
const store = useAppStore.getState()
|
||||
if (matchShortcut('worktree.history.back')) {
|
||||
store.goBackWorktree()
|
||||
} else {
|
||||
store.goForwardWorktree()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Why: only short-circuit chords the floating panel's own keydown
|
||||
// handler claims (Cmd/Ctrl+T, Cmd/Ctrl+W, Cmd/Ctrl+Shift+B/M). Other
|
||||
// app-level mod shortcuts (B, L, Shift+E/F/G) have no panel-level
|
||||
@@ -1788,147 +1967,33 @@ function App(): React.JSX.Element {
|
||||
}
|
||||
}
|
||||
|
||||
// Cmd/Ctrl+B — toggle left sidebar
|
||||
if (matchShortcut('sidebar.left.toggle')) {
|
||||
input.preventDefault()
|
||||
notifyTerminalCapture('sidebar.left.toggle')
|
||||
actions.toggleSidebar()
|
||||
return
|
||||
}
|
||||
|
||||
// Toggle the "show sleeping workspaces" sidebar filter without opening the
|
||||
// filters menu (issue #5209). When revealing them, open the left sidebar
|
||||
// so the now-visible sleeping worktrees are actually reachable.
|
||||
if (matchShortcut('sidebar.sleepingWorkspaces.toggle')) {
|
||||
input.preventDefault()
|
||||
notifyTerminalCapture('sidebar.sleepingWorkspaces.toggle')
|
||||
const store = useAppStore.getState()
|
||||
const nextShowSleeping = !store.showSleepingWorkspaces
|
||||
store.setShowSleepingWorkspaces(nextShowSleeping)
|
||||
if (nextShowSleeping) {
|
||||
store.setSidebarOpen(true)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Why: rename the active terminal tab. Cmd+R is free in the app/terminal
|
||||
// focus zone because the browser pane owns its own Cmd+R reload and that
|
||||
// focus never reaches this renderer-window handler. Only terminal tabs
|
||||
// have an inline title editor, so other active tab types fall through.
|
||||
if (workspaceChromeActive && !floatingWorkspaceFocused && matchShortcut('tab.rename')) {
|
||||
const store = useAppStore.getState()
|
||||
if (store.activeTabType === 'terminal' && store.activeTabId) {
|
||||
// Plugin chords are user-reviewed instructional content. They win over
|
||||
// built-in defaults only in app focus; terminal/editor/browser handlers
|
||||
// retain their own shortcut authority.
|
||||
if (context === 'app') {
|
||||
const pluginCommand = findPluginCommandForKeybinding(
|
||||
pluginCommands,
|
||||
input,
|
||||
shortcutPlatform,
|
||||
keybindings,
|
||||
Boolean(activeWorktreeId)
|
||||
)
|
||||
if (pluginCommand) {
|
||||
input.preventDefault()
|
||||
notifyTerminalCapture('tab.rename')
|
||||
store.setRenamingTabId(store.activeTabId)
|
||||
void executePluginCommand(pluginCommand, 'plugin-keybinding').catch(() => {
|
||||
toast.error(
|
||||
translate('auto.App.pluginCommandFailed', 'Could not run the plugin command.')
|
||||
)
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Why: open the active worktree's inline title editor. Open/reveal it
|
||||
// first so the card is mounted and visible even when sidebar filters or
|
||||
// collapse state would otherwise hide it.
|
||||
if (
|
||||
workspaceChromeActive &&
|
||||
!floatingWorkspaceFocused &&
|
||||
matchShortcut('workspace.rename') &&
|
||||
activeWorktreeId
|
||||
) {
|
||||
input.preventDefault()
|
||||
notifyTerminalCapture('workspace.rename')
|
||||
const store = useAppStore.getState()
|
||||
store.setSidebarOpen(true)
|
||||
requestScrollToCurrentWorkspaceRevealAndRename()
|
||||
return
|
||||
}
|
||||
|
||||
if (matchShortcut('workspace.openBoard') && activeView !== 'settings') {
|
||||
input.preventDefault()
|
||||
notifyTerminalCapture('workspace.openBoard')
|
||||
const store = useAppStore.getState()
|
||||
store.setSidebarOpen(true)
|
||||
window.dispatchEvent(new CustomEvent(OPEN_WORKSPACE_BOARD_EVENT))
|
||||
return
|
||||
}
|
||||
|
||||
// Why: Cmd/Ctrl+N is handled via the main-process before-input-event
|
||||
// allowlist (see window-shortcut-policy.ts / useIpcEvents.ts) so it works
|
||||
// globally — including when focus lives inside the markdown rich editor
|
||||
// (contentEditable) or a browser guest webContents, both of which bypass
|
||||
// this renderer-side window keydown listener.
|
||||
|
||||
// Why: full-page navigation surfaces should not reveal the right sidebar;
|
||||
// they are designed as distraction-free content areas.
|
||||
if (matchShortcut('view.tasks') && activeView !== 'settings') {
|
||||
const store = useAppStore.getState()
|
||||
if (store.repos.some((repo) => isGitRepoKind(repo))) {
|
||||
input.preventDefault()
|
||||
notifyTerminalCapture('view.tasks')
|
||||
store.openTaskPage()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!canRevealRightSidebar) {
|
||||
return
|
||||
}
|
||||
|
||||
// Cmd/Ctrl+L — toggle right sidebar
|
||||
if (matchShortcut('sidebar.right.toggle')) {
|
||||
input.preventDefault()
|
||||
notifyTerminalCapture('sidebar.right.toggle')
|
||||
actions.toggleRightSidebar()
|
||||
return
|
||||
}
|
||||
|
||||
// Cmd/Ctrl+Shift+E — toggle right sidebar / explorer tab
|
||||
if (matchShortcut('sidebar.explorer.toggle')) {
|
||||
input.preventDefault()
|
||||
notifyTerminalCapture('sidebar.explorer.toggle')
|
||||
actions.showRightSidebarFiles()
|
||||
return
|
||||
}
|
||||
|
||||
// Cmd/Ctrl+Shift+F — toggle right sidebar / search tab
|
||||
if (matchShortcut('sidebar.search.toggle')) {
|
||||
input.preventDefault()
|
||||
notifyTerminalCapture('sidebar.search.toggle')
|
||||
openSearchSidebar(null)
|
||||
return
|
||||
}
|
||||
|
||||
// Cmd/Ctrl+Shift+G — toggle right sidebar / source control tab.
|
||||
// Skip when terminal search is open — Cmd+Shift+G means "find previous"
|
||||
// in that context (handled by keyboard-handlers.ts). Both listeners share
|
||||
// the window capture phase and registration order can vary with React
|
||||
// effect re-runs, so a DOM check is the reliable coordination mechanism.
|
||||
if (matchShortcut('sidebar.sourceControl.toggle')) {
|
||||
if (document.querySelector('[data-terminal-search-root]')) {
|
||||
const handlers = createRegisteredCommandHandlers(input, context)
|
||||
for (const actionId of PLUGIN_COMMAND_ALIAS_ACTION_IDS) {
|
||||
if (matchShortcut(actionId) && handlers.get(actionId)?.()) {
|
||||
return
|
||||
}
|
||||
input.preventDefault()
|
||||
notifyTerminalCapture('sidebar.sourceControl.toggle')
|
||||
actions.setRightSidebarTab('source-control')
|
||||
actions.setRightSidebarOpen(true)
|
||||
return
|
||||
}
|
||||
|
||||
if (matchShortcut('sidebar.checks.toggle')) {
|
||||
input.preventDefault()
|
||||
notifyTerminalCapture('sidebar.checks.toggle')
|
||||
actions.setRightSidebarTab('checks')
|
||||
actions.setRightSidebarOpen(true)
|
||||
return
|
||||
}
|
||||
|
||||
// Cmd+Shift+I — toggle right sidebar / ports tab (macOS only).
|
||||
// Why: Ctrl+Shift+I is the built-in DevTools accelerator on Windows/Linux;
|
||||
// intercepting it would break an essential developer tool.
|
||||
if (matchShortcut('sidebar.ports.toggle')) {
|
||||
input.preventDefault()
|
||||
notifyTerminalCapture('sidebar.ports.toggle')
|
||||
actions.setRightSidebarTab('ports')
|
||||
actions.setRightSidebarOpen(true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1994,6 +2059,7 @@ function App(): React.JSX.Element {
|
||||
window.addEventListener('keyup', onKeyUp, { capture: true })
|
||||
window.addEventListener('blur', onBlur)
|
||||
return () => {
|
||||
unregisterAppCommandDispatcher()
|
||||
window.removeEventListener('keydown', onKeyDown, { capture: true })
|
||||
window.removeEventListener('keyup', onKeyUp, { capture: true })
|
||||
window.removeEventListener('blur', onBlur)
|
||||
|
||||
@@ -104,6 +104,8 @@ import {
|
||||
getCmdJQuickActions,
|
||||
CREATE_WORKSPACE_QUICK_ACTION_ID
|
||||
} from '@/components/cmd-j/quick-actions'
|
||||
import { buildPluginQuickActions } from '@/components/cmd-j/plugin-quick-actions'
|
||||
import { usePluginCommands } from '@/store/plugin-panels'
|
||||
import {
|
||||
getComposerEligibleRepos,
|
||||
resolveComposerGitRepoId
|
||||
@@ -343,6 +345,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
||||
const projectHostSetups = useAppStore((s) => s.projectHostSetups)
|
||||
const detectedWorktreesByRepo = useAppStore((s) => s.detectedWorktreesByRepo)
|
||||
const pendingWorktreeCreations = useAppStore((s) => s.pendingWorktreeCreations)
|
||||
const pluginCommands = usePluginCommands()
|
||||
// Why: keep the (very hot) status maps subscribed through the dialog's close
|
||||
// animation. `visible` flips false synchronously on close, but the CommandDialog
|
||||
// content stays mounted while it fades/zooms out — dropping the maps to empty
|
||||
@@ -820,7 +823,14 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
||||
() => buildCmdJSettingsResults(settingsSections),
|
||||
[settingsSections]
|
||||
)
|
||||
const actionResults = useMemo(() => buildCmdJActionResults(getCmdJQuickActions()), [])
|
||||
const actionResults = useMemo(
|
||||
() =>
|
||||
buildCmdJActionResults([
|
||||
...getCmdJQuickActions(),
|
||||
...buildPluginQuickActions(pluginCommands)
|
||||
]),
|
||||
[pluginCommands]
|
||||
)
|
||||
// Why: Cmd+J should only offer project jumps the sidebar can actually reveal;
|
||||
// archived-only repos are intentionally left out of this navigation surface.
|
||||
const renderableProjectRepoIds = useMemo(() => {
|
||||
@@ -1412,17 +1422,30 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
||||
closeModal()
|
||||
setSelectedItemId('')
|
||||
const ctx = buildQuickActionContext()
|
||||
void action.run(ctx).then((result) => {
|
||||
if (result.status === 'unavailable') {
|
||||
toast.error(getUnavailableQuickActionMessage(action.title, result.reason))
|
||||
return
|
||||
}
|
||||
if (action.id === 'create-workspace') {
|
||||
recordFeatureInteraction('cmd-j-create-workspace')
|
||||
return
|
||||
}
|
||||
recordFeatureInteraction('cmd-j-quick-action')
|
||||
})
|
||||
void action
|
||||
.run(ctx)
|
||||
.then((result) => {
|
||||
if (result.status === 'unavailable') {
|
||||
toast.error(getUnavailableQuickActionMessage(action.title, result.reason))
|
||||
return
|
||||
}
|
||||
if (action.id === 'create-workspace') {
|
||||
recordFeatureInteraction('cmd-j-create-workspace')
|
||||
return
|
||||
}
|
||||
recordFeatureInteraction('cmd-j-quick-action')
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!action.id.startsWith('plugin:')) {
|
||||
throw error
|
||||
}
|
||||
toast.error(
|
||||
translate(
|
||||
'auto.components.WorktreeJumpPalette.pluginCommandFailed',
|
||||
'Could not run the plugin command.'
|
||||
)
|
||||
)
|
||||
})
|
||||
},
|
||||
[buildQuickActionContext, closeModal, recordFeatureInteraction]
|
||||
)
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { ActivePluginCommand } from '@/store/plugin-panels'
|
||||
import { buildPluginQuickActions } from './plugin-quick-actions'
|
||||
|
||||
vi.mock('@/lib/plugin-command-execution', () => ({ executePluginCommand: vi.fn() }))
|
||||
|
||||
function command(context: 'global' | 'worktree'): ActivePluginCommand {
|
||||
return {
|
||||
pluginKey: 'orca-samples.tasks',
|
||||
pluginName: 'Tasks Pack',
|
||||
id: 'open',
|
||||
title: 'Open Tasks',
|
||||
context,
|
||||
handler: { type: 'built-in', action: 'view.tasks' },
|
||||
keybindings: []
|
||||
}
|
||||
}
|
||||
|
||||
describe('plugin Cmd+J actions', () => {
|
||||
it('adds enabled plugin commands with plugin attribution', () => {
|
||||
const action = buildPluginQuickActions([command('global')])[0]!
|
||||
|
||||
expect(action).toMatchObject({
|
||||
id: 'plugin:orca-samples.tasks/open',
|
||||
title: 'Open Tasks',
|
||||
description: 'Tasks Pack plugin command'
|
||||
})
|
||||
expect(action.isAvailable({ activeWorktreeId: null } as never)).toEqual({ available: true })
|
||||
})
|
||||
|
||||
it('requires an active workspace for worktree commands', () => {
|
||||
const action = buildPluginQuickActions([command('worktree')])[0]!
|
||||
|
||||
expect(action.isAvailable({ activeWorktreeId: null } as never)).toEqual({
|
||||
available: false,
|
||||
reason: 'no-active-workspace'
|
||||
})
|
||||
expect(action.isAvailable({ activeWorktreeId: 'worktree-1' } as never)).toEqual({
|
||||
available: true
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Blocks } from 'lucide-react'
|
||||
import type { ActivePluginCommand } from '@/store/plugin-panels'
|
||||
import { executePluginCommand } from '@/lib/plugin-command-execution'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { CmdJQuickAction } from './quick-actions'
|
||||
|
||||
export function buildPluginQuickActions(
|
||||
commands: readonly ActivePluginCommand[]
|
||||
): CmdJQuickAction[] {
|
||||
return commands.map((command) => ({
|
||||
id: `plugin:${command.pluginKey}/${command.id}`,
|
||||
kind: 'action',
|
||||
title: command.title,
|
||||
description: translate(
|
||||
'auto.components.cmd.j.pluginQuickActions.description',
|
||||
'{{value0}} plugin command',
|
||||
{ value0: command.pluginName }
|
||||
),
|
||||
icon: Blocks,
|
||||
verbKeywords: [
|
||||
command.title,
|
||||
command.pluginName,
|
||||
translate('auto.components.cmd.j.pluginQuickActions.keyword', 'plugin command')
|
||||
],
|
||||
isAvailable: (context) =>
|
||||
command.context === 'worktree' && !context.activeWorktreeId
|
||||
? { available: false, reason: 'no-active-workspace' }
|
||||
: { available: true },
|
||||
run: async () => {
|
||||
await executePluginCommand(command, 'plugin-palette')
|
||||
return { status: 'ok' }
|
||||
}
|
||||
}))
|
||||
}
|
||||
@@ -167,6 +167,31 @@ describe('PluginConsentDialog', () => {
|
||||
expect(commands[0]?.getAttribute('aria-label')).toBe('Cloud Sandbox · Create command')
|
||||
})
|
||||
|
||||
it('shows plugin shortcuts and names built-in chords they replace', async () => {
|
||||
await renderConsent(
|
||||
{
|
||||
...plugin,
|
||||
hasWorker: false,
|
||||
capabilities: [],
|
||||
commands: [
|
||||
{
|
||||
id: 'tasks',
|
||||
title: 'Open Tasks',
|
||||
context: 'global',
|
||||
handler: { type: 'built-in', action: 'view.tasks' },
|
||||
keybindings: [{ key: 'Mod+P', when: 'global' }]
|
||||
}
|
||||
]
|
||||
},
|
||||
vi.fn().mockResolvedValue(undefined)
|
||||
)
|
||||
|
||||
expect(document.body.textContent).toContain('Review plugin content')
|
||||
expect(document.body.textContent).toContain('Keyboard shortcuts')
|
||||
expect(document.body.textContent).toContain('Open Tasks')
|
||||
expect(document.body.textContent).toContain('Replaces: Go to File')
|
||||
})
|
||||
|
||||
it('records Keep Disabled when Escape dismisses the dialog', async () => {
|
||||
const onDecision = vi.fn().mockResolvedValue(undefined)
|
||||
await renderConsent(plugin, onDecision)
|
||||
|
||||
@@ -5,6 +5,7 @@ import { translate } from '@/i18n/i18n'
|
||||
import { pluginConsentErrorMessage } from './plugin-error-presentation'
|
||||
import { Button } from '../ui/button'
|
||||
import { PluginVmRecipeConsentPreview } from './PluginVmRecipeConsentPreview'
|
||||
import { PluginKeybindingConsentPreview } from './PluginKeybindingConsentPreview'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -30,7 +31,7 @@ function trustTier(plugin: PluginHostListEntry): string {
|
||||
'Background worker — runs its own process'
|
||||
)
|
||||
}
|
||||
if (plugin.hasSkills || (plugin.vmRecipes?.length ?? 0) > 0) {
|
||||
if (hasInstructionalContent(plugin)) {
|
||||
return translate(
|
||||
'auto.components.settings.PluginConsentDialog.instructionalTrust',
|
||||
'Instructional content — runs later under user or agent authority'
|
||||
@@ -85,7 +86,11 @@ function capabilityDescription(kind: string, fallback: string): string {
|
||||
}
|
||||
|
||||
function hasInstructionalContent(plugin: PluginHostListEntry): boolean {
|
||||
return plugin.hasSkills || (plugin.vmRecipes?.length ?? 0) > 0
|
||||
return (
|
||||
plugin.hasSkills ||
|
||||
(plugin.vmRecipes?.length ?? 0) > 0 ||
|
||||
plugin.commands.some((command) => command.keybindings.length > 0)
|
||||
)
|
||||
}
|
||||
|
||||
function consentTitle(plugin: PluginHostListEntry): string {
|
||||
@@ -250,6 +255,7 @@ export function PluginConsentDialog({
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
<PluginKeybindingConsentPreview commands={plugin.commands} />
|
||||
<PluginVmRecipeConsentPreview recipes={plugin.vmRecipes ?? []} />
|
||||
{error ? <p className="text-xs text-destructive">{error}</p> : null}
|
||||
<DialogFooter>
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { shadowedKeybindingTitles } from './PluginKeybindingConsentPreview'
|
||||
|
||||
describe('plugin keybinding consent preview', () => {
|
||||
it('names built-in shortcuts shadowed by plugin chords and user overrides', () => {
|
||||
expect(shadowedKeybindingTitles('Mod+P', 'darwin')).toContain('Go to File')
|
||||
expect(
|
||||
shadowedKeybindingTitles('Mod+Alt+T', 'linux', { 'view.tasks': ['Mod+Alt+T'] })
|
||||
).toContain('Open Tasks')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,108 @@
|
||||
import { AlertTriangle, Keyboard } from 'lucide-react'
|
||||
import type { PluginHostListEntry } from '../../../../preload/api-types'
|
||||
import {
|
||||
findKeybindingActionsForBinding,
|
||||
formatKeybindingList,
|
||||
getKeybindingDefinition,
|
||||
type KeybindingOverrides
|
||||
} from '../../../../shared/keybindings'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { useAppStore } from '@/store'
|
||||
|
||||
type CommandPreview = PluginHostListEntry['commands'][number]
|
||||
|
||||
function shortcutPlatform(): NodeJS.Platform {
|
||||
if (navigator.userAgent.includes('Mac')) {
|
||||
return 'darwin'
|
||||
}
|
||||
return navigator.userAgent.includes('Windows') ? 'win32' : 'linux'
|
||||
}
|
||||
|
||||
export function shadowedKeybindingTitles(
|
||||
binding: string,
|
||||
platform: NodeJS.Platform,
|
||||
overrides?: KeybindingOverrides
|
||||
): string[] {
|
||||
return findKeybindingActionsForBinding(binding, platform, overrides).map(
|
||||
(actionId) => getKeybindingDefinition(actionId)?.title ?? actionId
|
||||
)
|
||||
}
|
||||
|
||||
export function PluginKeybindingConsentPreview({
|
||||
commands
|
||||
}: {
|
||||
commands: readonly CommandPreview[]
|
||||
}): React.JSX.Element | null {
|
||||
const overrides = useAppStore((state) => state.keybindings)
|
||||
const platform = shortcutPlatform()
|
||||
const bindings = commands.flatMap((command) =>
|
||||
command.keybindings.map((keybinding) => ({ command, keybinding }))
|
||||
)
|
||||
if (bindings.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-3" aria-labelledby="plugin-keybinding-consent-heading">
|
||||
<div className="flex items-start gap-2 rounded-md border border-border bg-muted/50 px-3.5 py-3 text-sm leading-6">
|
||||
<Keyboard className="mt-1 size-4 shrink-0" />
|
||||
<span>
|
||||
{translate(
|
||||
'auto.components.settings.PluginKeybindingConsentPreview.warning',
|
||||
'This plugin adds keyboard shortcuts. Plugin shortcuts take priority over matching Orca shortcuts while the plugin is enabled.'
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<h3
|
||||
id="plugin-keybinding-consent-heading"
|
||||
className="text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground"
|
||||
>
|
||||
{translate(
|
||||
'auto.components.settings.PluginKeybindingConsentPreview.heading',
|
||||
'Keyboard shortcuts'
|
||||
)}
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{bindings.map(({ command, keybinding }) => {
|
||||
const shadowed = shadowedKeybindingTitles(keybinding.key, platform, overrides)
|
||||
return (
|
||||
<div
|
||||
key={`${command.id}:${keybinding.key}`}
|
||||
className="space-y-1 rounded-md border border-border p-3"
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="text-sm font-medium">{command.title}</span>
|
||||
<kbd className="rounded border border-border bg-muted px-2 py-0.5 font-mono text-xs text-foreground">
|
||||
{formatKeybindingList([keybinding.key], platform)}
|
||||
</kbd>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{keybinding.when === 'worktree'
|
||||
? translate(
|
||||
'auto.components.settings.PluginKeybindingConsentPreview.worktree',
|
||||
'Runs only while a workspace is active.'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.settings.PluginKeybindingConsentPreview.global',
|
||||
'Runs in the app without requiring an active workspace.'
|
||||
)}
|
||||
</p>
|
||||
{shadowed.length > 0 ? (
|
||||
<p className="flex items-start gap-1.5 text-xs leading-5 text-foreground">
|
||||
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
|
||||
<span>
|
||||
{translate(
|
||||
'auto.components.settings.PluginKeybindingConsentPreview.shadows',
|
||||
'Replaces: {{value0}}',
|
||||
{ value0: shadowed.join(', ') }
|
||||
)}
|
||||
</span>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,19 +1,16 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
findKeybindingConflicts,
|
||||
findKeybindingConflictsForDefinitions,
|
||||
formatKeybindingList,
|
||||
getEffectiveKeybindingsForAction,
|
||||
getEffectiveKeybindingsForDefinition,
|
||||
getKeybindingDefinition,
|
||||
keybindingFromInputForAction,
|
||||
normalizeKeybindingListForAction,
|
||||
type KeybindingActionId,
|
||||
type KeybindingDefinition,
|
||||
type KeybindingInput
|
||||
} from '../../../../shared/keybindings'
|
||||
import {
|
||||
EMPTY_DISABLED_TUI_AGENTS,
|
||||
disabledAgentTabActionIds,
|
||||
groupDefinitions
|
||||
} from './shortcut-groups'
|
||||
import { EMPTY_DISABLED_TUI_AGENTS } from './shortcut-groups'
|
||||
import { useAppStore } from '../../store'
|
||||
import { KeybindingsFileActions } from './KeybindingsFileActions'
|
||||
import { SettingsSubsectionHeader } from './SettingsFormControls'
|
||||
@@ -46,6 +43,8 @@ import {
|
||||
} from './shortcut-binding-list-mutations'
|
||||
import { useMountedRef } from '@/hooks/useMountedRef'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { useEditablePluginCommands } from '@/store/plugin-panels'
|
||||
import { buildShortcutDefinitionCatalog } from './shortcut-definition-catalog'
|
||||
|
||||
const isMac = navigator.userAgent.includes('Mac')
|
||||
const platform: NodeJS.Platform = isMac
|
||||
@@ -68,6 +67,7 @@ export function ShortcutsPane(): React.JSX.Element {
|
||||
const setKeybindingOverride = useAppStore((state) => state.setKeybindingOverride)
|
||||
const resetKeybindingOverride = useAppStore((state) => state.resetKeybindingOverride)
|
||||
const disableKeybindingAction = useAppStore((state) => state.disableKeybindingAction)
|
||||
const pluginCommands = useEditablePluginCommands()
|
||||
const mountedRef = useMountedRef()
|
||||
const [errors, setErrors] = useState<Partial<Record<KeybindingActionId, string>>>({})
|
||||
const [recordingActionId, setRecordingActionId] = useState<KeybindingActionId | null>(null)
|
||||
@@ -90,34 +90,32 @@ export function ShortcutsPane(): React.JSX.Element {
|
||||
return () => window.api.ui.setShortcutRecorderFocused(false)
|
||||
}, [recordingActionId])
|
||||
|
||||
const groups = useMemo(() => groupDefinitions(disabledTuiAgents), [disabledTuiAgents])
|
||||
const ignoredConflictActionIds = useMemo(
|
||||
() => disabledAgentTabActionIds(disabledTuiAgents),
|
||||
[disabledTuiAgents]
|
||||
)
|
||||
const conflictByAction = useMemo(() => {
|
||||
const result = new Map<KeybindingActionId, string[]>()
|
||||
for (const conflict of findKeybindingConflicts(platform, keybindings, {
|
||||
ignoredActionIds: ignoredConflictActionIds
|
||||
})) {
|
||||
const labels = conflict.actionIds
|
||||
.map((id) => getKeybindingDefinition(id)?.title ?? id)
|
||||
.join(', ')
|
||||
for (const actionId of conflict.actionIds) {
|
||||
result.set(actionId, [
|
||||
...(result.get(actionId) ?? []),
|
||||
`${formatKeybindingList([conflict.binding], platform)} conflicts with ${labels}.`
|
||||
])
|
||||
}
|
||||
}
|
||||
return result
|
||||
}, [ignoredConflictActionIds, keybindings])
|
||||
const { groups, definitions, definitionsByAction, ignoredConflictActionIds, conflictByAction } =
|
||||
useMemo(
|
||||
() =>
|
||||
buildShortcutDefinitionCatalog({
|
||||
disabledTuiAgents,
|
||||
pluginCommands,
|
||||
keybindings,
|
||||
platform
|
||||
}),
|
||||
[disabledTuiAgents, keybindings, pluginCommands]
|
||||
)
|
||||
const definitionForAction = (actionId: KeybindingActionId): KeybindingDefinition | null =>
|
||||
definitionsByAction.get(actionId) ?? getKeybindingDefinition(actionId)
|
||||
const effectiveBindingsForAction = (
|
||||
actionId: KeybindingActionId,
|
||||
overrides = keybindings
|
||||
): string[] => {
|
||||
const definition = definitionForAction(actionId)
|
||||
return definition ? getEffectiveKeybindingsForDefinition(definition, platform, overrides) : []
|
||||
}
|
||||
const shortcutGroups = useMemo<ShortcutRowsByGroup[]>(
|
||||
() =>
|
||||
groups.map((group) => ({
|
||||
title: group.title,
|
||||
rows: group.items.map((item) => {
|
||||
const effective = getEffectiveKeybindingsForAction(item.id, platform, keybindings)
|
||||
const effective = getEffectiveKeybindingsForDefinition(item, platform, keybindings)
|
||||
const modified = hasOwnBindingOverride(keybindings, item.id)
|
||||
const warnings = conflictByAction.get(item.id) ?? []
|
||||
return {
|
||||
@@ -175,19 +173,30 @@ export function ShortcutsPane(): React.JSX.Element {
|
||||
return false
|
||||
}
|
||||
|
||||
const defaults = getEffectiveKeybindingsForAction(actionId, platform, {})
|
||||
const definition = definitionForAction(actionId)
|
||||
if (!definition) {
|
||||
setErrors((prev) => ({
|
||||
...prev,
|
||||
[actionId]: translate(
|
||||
'auto.components.settings.ShortcutsPane.shortcutUnavailable',
|
||||
'Shortcut is no longer available.'
|
||||
)
|
||||
}))
|
||||
return false
|
||||
}
|
||||
const defaults = getEffectiveKeybindingsForDefinition(definition, platform, {})
|
||||
const next =
|
||||
sameBindings(normalizedResult, defaults) ||
|
||||
(normalizedResult.length === 0 && defaults.length === 0)
|
||||
? removeBindingOverride(keybindings, actionId)
|
||||
: { ...keybindings, [actionId]: normalizedResult }
|
||||
const blockingConflict = findKeybindingConflicts(platform, next, {
|
||||
const blockingConflict = findKeybindingConflictsForDefinitions(definitions, platform, next, {
|
||||
ignoredActionIds: ignoredConflictActionIds
|
||||
}).find((conflict) => conflict.actionIds.includes(actionId))
|
||||
if (blockingConflict) {
|
||||
const labels = blockingConflict.actionIds
|
||||
.filter((id) => id !== actionId)
|
||||
.map((id) => getKeybindingDefinition(id)?.title ?? id)
|
||||
.map((id) => definitionsByAction.get(id)?.title ?? id)
|
||||
.join(', ')
|
||||
setErrors((prev) => ({
|
||||
...prev,
|
||||
@@ -228,7 +237,7 @@ export function ShortcutsPane(): React.JSX.Element {
|
||||
|
||||
// Edit just the targeted binding (or append a new one) instead of replacing
|
||||
// the whole list, so an action's other bindings survive the capture.
|
||||
const current = getEffectiveKeybindingsForAction(actionId, platform, keybindings)
|
||||
const current = effectiveBindingsForAction(actionId)
|
||||
const next =
|
||||
recordingBindingIndex === null || recordingBindingIndex >= current.length
|
||||
? appendBinding(current, captured.value)
|
||||
@@ -241,7 +250,7 @@ export function ShortcutsPane(): React.JSX.Element {
|
||||
|
||||
const removeBinding = async (actionId: KeybindingActionId, index: number): Promise<void> => {
|
||||
setErrors((prev) => ({ ...prev, [actionId]: undefined }))
|
||||
const current = getEffectiveKeybindingsForAction(actionId, platform, keybindings)
|
||||
const current = effectiveBindingsForAction(actionId)
|
||||
await saveBindings(actionId, removeBindingAt(current, index))
|
||||
}
|
||||
|
||||
@@ -249,7 +258,7 @@ export function ShortcutsPane(): React.JSX.Element {
|
||||
setErrors((prev) => ({ ...prev, [actionId]: undefined }))
|
||||
try {
|
||||
await (hasCommonBindingOverride(keybindingSnapshot, actionId)
|
||||
? setKeybindingOverride(actionId, getEffectiveKeybindingsForAction(actionId, platform, {}))
|
||||
? setKeybindingOverride(actionId, effectiveBindingsForAction(actionId, {}))
|
||||
: resetKeybindingOverride(actionId))
|
||||
} catch (error) {
|
||||
if (mountedRef.current) {
|
||||
@@ -370,7 +379,7 @@ export function ShortcutsPane(): React.JSX.Element {
|
||||
clearError(actionId)
|
||||
}}
|
||||
onAppendBinding={(actionId) => {
|
||||
const current = getEffectiveKeybindingsForAction(actionId, platform, keybindings)
|
||||
const current = effectiveBindingsForAction(actionId)
|
||||
setRecordingActionId(actionId)
|
||||
setRecordingBindingIndex(current.length)
|
||||
clearError(actionId)
|
||||
@@ -399,7 +408,7 @@ export function ShortcutsPane(): React.JSX.Element {
|
||||
}}
|
||||
onDisableAction={(actionId) => {
|
||||
// Remember the current bindings first so "Enable" can restore them.
|
||||
const current = getEffectiveKeybindingsForAction(actionId, platform, keybindings)
|
||||
const current = effectiveBindingsForAction(actionId)
|
||||
setDisableMemory((memory) => ({ ...memory, [actionId]: current }))
|
||||
clearRecordingForAction(actionId)
|
||||
void disableBinding(actionId)
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
findKeybindingConflictsForDefinitions,
|
||||
formatKeybindingList,
|
||||
type KeybindingActionId,
|
||||
type KeybindingDefinition,
|
||||
type KeybindingOverrides
|
||||
} from '../../../../shared/keybindings'
|
||||
import type { TuiAgent } from '../../../../shared/types'
|
||||
import type { ActivePluginCommand } from '@/store/plugin-panels'
|
||||
import { buildPluginCommandKeybindingDefinitions } from '@/lib/plugin-command-keybindings'
|
||||
import { disabledAgentTabActionIds, groupDefinitions, type ShortcutGroup } from './shortcut-groups'
|
||||
|
||||
export type ShortcutDefinitionCatalog = {
|
||||
groups: ShortcutGroup[]
|
||||
definitions: KeybindingDefinition[]
|
||||
definitionsByAction: Map<KeybindingActionId, KeybindingDefinition>
|
||||
ignoredConflictActionIds: KeybindingActionId[]
|
||||
conflictByAction: Map<KeybindingActionId, string[]>
|
||||
}
|
||||
|
||||
export function buildShortcutDefinitionCatalog(options: {
|
||||
disabledTuiAgents: readonly TuiAgent[]
|
||||
pluginCommands: readonly ActivePluginCommand[]
|
||||
keybindings: KeybindingOverrides
|
||||
platform: NodeJS.Platform
|
||||
}): ShortcutDefinitionCatalog {
|
||||
const pluginDefinitions = buildPluginCommandKeybindingDefinitions(options.pluginCommands)
|
||||
const groups = groupDefinitions(options.disabledTuiAgents, pluginDefinitions)
|
||||
const definitions = groups.flatMap((group) => group.items)
|
||||
const definitionsByAction = new Map(definitions.map((definition) => [definition.id, definition]))
|
||||
const ignoredConflictActionIds = disabledAgentTabActionIds(options.disabledTuiAgents)
|
||||
const conflictByAction = new Map<KeybindingActionId, string[]>()
|
||||
const conflicts = findKeybindingConflictsForDefinitions(
|
||||
definitions,
|
||||
options.platform,
|
||||
options.keybindings,
|
||||
{
|
||||
ignoredActionIds: ignoredConflictActionIds,
|
||||
// Plugin defaults are external additions to Orca's conflict-free static
|
||||
// registry, so surface their collisions even before the user customizes one.
|
||||
relevantActionIds: pluginDefinitions.map((definition) => definition.id)
|
||||
}
|
||||
)
|
||||
for (const conflict of conflicts) {
|
||||
const labels = conflict.actionIds
|
||||
.map((id) => definitionsByAction.get(id)?.title ?? id)
|
||||
.join(', ')
|
||||
for (const actionId of conflict.actionIds) {
|
||||
conflictByAction.set(actionId, [
|
||||
...(conflictByAction.get(actionId) ?? []),
|
||||
`${formatKeybindingList([conflict.binding], options.platform)} conflicts with ${labels}.`
|
||||
])
|
||||
}
|
||||
}
|
||||
return {
|
||||
groups,
|
||||
definitions,
|
||||
definitionsByAction,
|
||||
ignoredConflictActionIds,
|
||||
conflictByAction
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { KeybindingDefinition } from '../../../../shared/keybindings'
|
||||
import type { ActivePluginCommand } from '@/store/plugin-panels'
|
||||
import { buildShortcutDefinitionCatalog } from './shortcut-definition-catalog'
|
||||
import { groupDefinitions } from './shortcut-groups'
|
||||
|
||||
const pluginDefinition: KeybindingDefinition = {
|
||||
id: 'plugin:orca-samples.tasks/open',
|
||||
title: 'Open Tasks — Tasks',
|
||||
group: 'Plugins',
|
||||
scope: 'global',
|
||||
searchKeywords: ['plugin', 'tasks'],
|
||||
defaultBindings: {
|
||||
darwin: ['Mod+Alt+T'],
|
||||
linux: ['Mod+Alt+T'],
|
||||
win32: ['Mod+Alt+T']
|
||||
}
|
||||
}
|
||||
|
||||
describe('shortcut groups', () => {
|
||||
it('includes dynamic plugin command definitions in Settings', () => {
|
||||
expect(groupDefinitions([], [pluginDefinition])).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
title: 'Plugins',
|
||||
items: [pluginDefinition]
|
||||
})
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('reports a plugin default that shadows a built-in shortcut', () => {
|
||||
const command: ActivePluginCommand = {
|
||||
pluginKey: 'orca-samples.tasks',
|
||||
pluginName: 'Tasks',
|
||||
id: 'open',
|
||||
title: 'Open Tasks',
|
||||
context: 'global',
|
||||
handler: { type: 'built-in', action: 'view.tasks' },
|
||||
keybindings: [{ key: 'Mod+P', when: 'global' }]
|
||||
}
|
||||
|
||||
const catalog = buildShortcutDefinitionCatalog({
|
||||
disabledTuiAgents: [],
|
||||
pluginCommands: [command],
|
||||
keybindings: {},
|
||||
platform: 'darwin'
|
||||
})
|
||||
|
||||
expect(catalog.conflictByAction.get('plugin:orca-samples.tasks/open')).toEqual([
|
||||
expect.stringContaining('Go to File')
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -20,7 +20,10 @@ export function disabledAgentTabActionIds(
|
||||
return normalizeDisabledTuiAgents(disabledTuiAgents).map((agent) => agentTabActionId(agent))
|
||||
}
|
||||
|
||||
export function groupDefinitions(disabledTuiAgents: readonly TuiAgent[]): ShortcutGroup[] {
|
||||
export function groupDefinitions(
|
||||
disabledTuiAgents: readonly TuiAgent[],
|
||||
additionalDefinitions: readonly KeybindingDefinition[] = []
|
||||
): ShortcutGroup[] {
|
||||
// Why: per-agent launch rows only make sense for agents the user keeps
|
||||
// enabled in Settings → Agents; hiding disabled ones keeps the Agents group
|
||||
// scoped to what the chord could actually launch.
|
||||
@@ -28,7 +31,7 @@ export function groupDefinitions(disabledTuiAgents: readonly TuiAgent[]): Shortc
|
||||
disabledAgentTabActionIds(disabledTuiAgents)
|
||||
)
|
||||
const groups = new Map<string, KeybindingDefinition[]>()
|
||||
for (const definition of KEYBINDING_DEFINITIONS) {
|
||||
for (const definition of [...KEYBINDING_DEFINITIONS, ...additionalDefinitions]) {
|
||||
if (hiddenAgentActionIds.has(definition.id)) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -125,7 +125,8 @@
|
||||
"fe21e8f6f5": "Go back ({{value0}})",
|
||||
"064bd07810": "Go back",
|
||||
"ce37cf5279": "Toggle sidebar ({{value0}})",
|
||||
"e4b9e7dff7": "Toggle sidebar"
|
||||
"e4b9e7dff7": "Toggle sidebar",
|
||||
"pluginCommandFailed": "Could not run the plugin command."
|
||||
},
|
||||
"web": {
|
||||
"WebConnect": {
|
||||
@@ -582,6 +583,9 @@
|
||||
"de50bef680": "macOS is blocking Orca notifications"
|
||||
}
|
||||
}
|
||||
},
|
||||
"pluginCommandKeybindings": {
|
||||
"group": "Plugins"
|
||||
}
|
||||
},
|
||||
"hooks": {
|
||||
@@ -1857,7 +1861,8 @@
|
||||
"workspaceTabMissing": "Tab no longer exists",
|
||||
"projectsGroupsHeader": "Projects & Groups",
|
||||
"projectBadge": "Project",
|
||||
"repoGroupBadge": "Repo group"
|
||||
"repoGroupBadge": "Repo group",
|
||||
"pluginCommandFailed": "Could not run the plugin command."
|
||||
},
|
||||
"github": {
|
||||
"pr": {
|
||||
@@ -6489,7 +6494,8 @@
|
||||
"25b0004fbf": "Terminal active",
|
||||
"781cb74d22": "Runs from terminal panes.",
|
||||
"cb02e00202": "Terminal",
|
||||
"d8c988dab4": "~/.orca/keybindings.json"
|
||||
"d8c988dab4": "~/.orca/keybindings.json",
|
||||
"shortcutUnavailable": "Shortcut is no longer available."
|
||||
},
|
||||
"SourceControlAiActionRecipeDefaults": {
|
||||
"2576299196": "args",
|
||||
@@ -9204,6 +9210,13 @@
|
||||
"warning": "This plugin installs VM recipes. Orca runs the commands below on your local desktop host when you use the matching lifecycle action.",
|
||||
"commandLabel": "{{value0}} · {{value1}} command",
|
||||
"heading": "VM recipe commands"
|
||||
},
|
||||
"PluginKeybindingConsentPreview": {
|
||||
"warning": "This plugin adds keyboard shortcuts. Plugin shortcuts take priority over matching Orca shortcuts while the plugin is enabled.",
|
||||
"heading": "Keyboard shortcuts",
|
||||
"worktree": "Runs only while a workspace is active.",
|
||||
"global": "Runs in the app without requiring an active workspace.",
|
||||
"shadows": "Replaces: {{value0}}"
|
||||
}
|
||||
},
|
||||
"right": {
|
||||
@@ -12262,6 +12275,10 @@
|
||||
"project": "Project"
|
||||
}
|
||||
}
|
||||
},
|
||||
"pluginQuickActions": {
|
||||
"description": "{{value0}} plugin command",
|
||||
"keyword": "plugin command"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -125,7 +125,8 @@
|
||||
"fe21e8f6f5": "Volver ({{value0}})",
|
||||
"064bd07810": "Volver",
|
||||
"ce37cf5279": "Alternar barra lateral ({{value0}})",
|
||||
"e4b9e7dff7": "Alternar barra lateral"
|
||||
"e4b9e7dff7": "Alternar barra lateral",
|
||||
"pluginCommandFailed": "No se pudo ejecutar el comando del plugin."
|
||||
},
|
||||
"web": {
|
||||
"WebConnect": {
|
||||
@@ -582,6 +583,9 @@
|
||||
"de50bef680": "macOS está bloqueando las notificaciones de Orca"
|
||||
}
|
||||
}
|
||||
},
|
||||
"pluginCommandKeybindings": {
|
||||
"group": "Plugins"
|
||||
}
|
||||
},
|
||||
"hooks": {
|
||||
@@ -1857,7 +1861,8 @@
|
||||
"workspaceTabMissing": "La pestaña ya no existe",
|
||||
"projectsGroupsHeader": "Proyectos y grupos",
|
||||
"projectBadge": "Proyecto",
|
||||
"repoGroupBadge": "Grupo de repositorios"
|
||||
"repoGroupBadge": "Grupo de repositorios",
|
||||
"pluginCommandFailed": "No se pudo ejecutar el comando del plugin."
|
||||
},
|
||||
"github": {
|
||||
"pr": {
|
||||
@@ -6452,7 +6457,8 @@
|
||||
"25b0004fbf": "Terminal activa",
|
||||
"781cb74d22": "Se ejecuta desde paneles de terminal.",
|
||||
"cb02e00202": "Terminal",
|
||||
"d8c988dab4": "~/.orca/keybindings.json"
|
||||
"d8c988dab4": "~/.orca/keybindings.json",
|
||||
"shortcutUnavailable": "El atajo ya no está disponible."
|
||||
},
|
||||
"SourceControlAiActionRecipeDefaults": {
|
||||
"2576299196": "argumentos",
|
||||
@@ -9204,6 +9210,13 @@
|
||||
"warning": "Este plugin instala recetas de VM. Orca ejecuta los comandos siguientes en el equipo local cuando usas la acción de ciclo de vida correspondiente.",
|
||||
"commandLabel": "{{value0}} · comando de {{value1}}",
|
||||
"heading": "Comandos de recetas de VM"
|
||||
},
|
||||
"PluginKeybindingConsentPreview": {
|
||||
"warning": "Este plugin añade atajos de teclado. Los atajos del plugin tienen prioridad sobre los atajos coincidentes de Orca mientras el plugin está activado.",
|
||||
"heading": "Atajos de teclado",
|
||||
"worktree": "Solo se ejecuta mientras hay un espacio de trabajo activo.",
|
||||
"global": "Se ejecuta en la aplicación sin requerir un espacio de trabajo activo.",
|
||||
"shadows": "Reemplaza: {{value0}}"
|
||||
}
|
||||
},
|
||||
"right": {
|
||||
@@ -12262,6 +12275,10 @@
|
||||
"project": "Proyecto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"pluginQuickActions": {
|
||||
"description": "Comando del plugin {{value0}}",
|
||||
"keyword": "comando del plugin"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -125,7 +125,8 @@
|
||||
"fe21e8f6f5": "戻る ({{value0}})",
|
||||
"064bd07810": "戻る",
|
||||
"ce37cf5279": "サイドバーの切り替え ({{value0}})",
|
||||
"e4b9e7dff7": "サイドバーの切り替え"
|
||||
"e4b9e7dff7": "サイドバーの切り替え",
|
||||
"pluginCommandFailed": "プラグインのコマンドを実行できませんでした。"
|
||||
},
|
||||
"web": {
|
||||
"WebConnect": {
|
||||
@@ -582,6 +583,9 @@
|
||||
"de50bef680": "macOS が Orca の通知をブロックしています"
|
||||
}
|
||||
}
|
||||
},
|
||||
"pluginCommandKeybindings": {
|
||||
"group": "Plugins"
|
||||
}
|
||||
},
|
||||
"hooks": {
|
||||
@@ -1857,7 +1861,8 @@
|
||||
"workspaceTabMissing": "タブはもう存在しません",
|
||||
"projectsGroupsHeader": "プロジェクトとグループ",
|
||||
"projectBadge": "プロジェクト",
|
||||
"repoGroupBadge": "リポジトリグループ"
|
||||
"repoGroupBadge": "リポジトリグループ",
|
||||
"pluginCommandFailed": "プラグインのコマンドを実行できませんでした。"
|
||||
},
|
||||
"github": {
|
||||
"pr": {
|
||||
@@ -6474,7 +6479,8 @@
|
||||
"25b0004fbf": "Terminal がアクティブです",
|
||||
"781cb74d22": "terminal ペインから実行します。",
|
||||
"cb02e00202": "ターミナル",
|
||||
"d8c988dab4": "~/.orca/keybindings.json"
|
||||
"d8c988dab4": "~/.orca/keybindings.json",
|
||||
"shortcutUnavailable": "ショートカットは利用できなくなりました。"
|
||||
},
|
||||
"SourceControlAiActionRecipeDefaults": {
|
||||
"2576299196": "引数",
|
||||
@@ -9204,6 +9210,13 @@
|
||||
"warning": "このプラグインは VM レシピをインストールします。対応するライフサイクル操作を使用すると、Orca は以下のコマンドをローカルのデスクトップホストで実行します。",
|
||||
"commandLabel": "{{value0}} · {{value1}}コマンド",
|
||||
"heading": "VM レシピのコマンド"
|
||||
},
|
||||
"PluginKeybindingConsentPreview": {
|
||||
"warning": "このプラグインはキーボードショートカットを追加します。有効な間は、一致する Orca のショートカットよりプラグインのショートカットが優先されます。",
|
||||
"heading": "キーボードショートカット",
|
||||
"worktree": "ワークスペースがアクティブな場合のみ実行されます。",
|
||||
"global": "アクティブなワークスペースがなくてもアプリで実行されます。",
|
||||
"shadows": "置き換える操作: {{value0}}"
|
||||
}
|
||||
},
|
||||
"right": {
|
||||
@@ -12262,6 +12275,10 @@
|
||||
"project": "プロジェクト"
|
||||
}
|
||||
}
|
||||
},
|
||||
"pluginQuickActions": {
|
||||
"description": "{{value0}} プラグインのコマンド",
|
||||
"keyword": "プラグインのコマンド"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -125,7 +125,8 @@
|
||||
"fe21e8f6f5": "돌아가기({{value0}})",
|
||||
"064bd07810": "돌아가기",
|
||||
"ce37cf5279": "사이드바 표시/숨기기({{value0}})",
|
||||
"e4b9e7dff7": "사이드바 표시/숨기기"
|
||||
"e4b9e7dff7": "사이드바 표시/숨기기",
|
||||
"pluginCommandFailed": "플러그인 명령을 실행할 수 없습니다."
|
||||
},
|
||||
"web": {
|
||||
"WebConnect": {
|
||||
@@ -582,6 +583,9 @@
|
||||
"de50bef680": "macOS가 Orca 알림을 차단하고 있습니다"
|
||||
}
|
||||
}
|
||||
},
|
||||
"pluginCommandKeybindings": {
|
||||
"group": "Plugins"
|
||||
}
|
||||
},
|
||||
"hooks": {
|
||||
@@ -1857,7 +1861,8 @@
|
||||
"workspaceTabMissing": "탭이 더 이상 존재하지 않습니다",
|
||||
"projectsGroupsHeader": "프로젝트 및 그룹",
|
||||
"projectBadge": "프로젝트",
|
||||
"repoGroupBadge": "리포지토리 그룹"
|
||||
"repoGroupBadge": "리포지토리 그룹",
|
||||
"pluginCommandFailed": "플러그인 명령을 실행할 수 없습니다."
|
||||
},
|
||||
"github": {
|
||||
"pr": {
|
||||
@@ -6437,7 +6442,8 @@
|
||||
"25b0004fbf": "Terminal 활성",
|
||||
"781cb74d22": "terminal 패널에서 실행됩니다.",
|
||||
"cb02e00202": "터미널",
|
||||
"d8c988dab4": "~/.orca/keybindings.json"
|
||||
"d8c988dab4": "~/.orca/keybindings.json",
|
||||
"shortcutUnavailable": "단축키를 더 이상 사용할 수 없습니다."
|
||||
},
|
||||
"SourceControlAiActionRecipeDefaults": {
|
||||
"2576299196": "인수",
|
||||
@@ -9204,6 +9210,13 @@
|
||||
"warning": "이 플러그인은 VM 레시피를 설치합니다. 해당 수명 주기 작업을 사용하면 Orca가 아래 명령을 로컬 데스크톱 호스트에서 실행합니다.",
|
||||
"commandLabel": "{{value0}} · {{value1}} 명령",
|
||||
"heading": "VM 레시피 명령"
|
||||
},
|
||||
"PluginKeybindingConsentPreview": {
|
||||
"warning": "이 플러그인은 키보드 단축키를 추가합니다. 플러그인이 활성화된 동안 일치하는 Orca 단축키보다 플러그인 단축키가 우선합니다.",
|
||||
"heading": "키보드 단축키",
|
||||
"worktree": "워크스페이스가 활성화된 동안에만 실행됩니다.",
|
||||
"global": "활성 워크스페이스 없이 앱에서 실행됩니다.",
|
||||
"shadows": "대체 항목: {{value0}}"
|
||||
}
|
||||
},
|
||||
"right": {
|
||||
@@ -12262,6 +12275,10 @@
|
||||
"project": "프로젝트"
|
||||
}
|
||||
}
|
||||
},
|
||||
"pluginQuickActions": {
|
||||
"description": "{{value0}} 플러그인 명령",
|
||||
"keyword": "플러그인 명령"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -125,7 +125,8 @@
|
||||
"fe21e8f6f5": "返回 ({{value0}})",
|
||||
"064bd07810": "返回",
|
||||
"ce37cf5279": "切换侧边栏 ({{value0}})",
|
||||
"e4b9e7dff7": "切换侧边栏"
|
||||
"e4b9e7dff7": "切换侧边栏",
|
||||
"pluginCommandFailed": "无法运行插件命令。"
|
||||
},
|
||||
"web": {
|
||||
"WebConnect": {
|
||||
@@ -582,6 +583,9 @@
|
||||
"de50bef680": "macOS 正在阻止 Orca 通知"
|
||||
}
|
||||
}
|
||||
},
|
||||
"pluginCommandKeybindings": {
|
||||
"group": "Plugins"
|
||||
}
|
||||
},
|
||||
"hooks": {
|
||||
@@ -1857,7 +1861,8 @@
|
||||
"workspaceTabMissing": "标签页不再存在",
|
||||
"projectsGroupsHeader": "项目和分组",
|
||||
"projectBadge": "项目",
|
||||
"repoGroupBadge": "仓库组"
|
||||
"repoGroupBadge": "仓库组",
|
||||
"pluginCommandFailed": "无法运行插件命令。"
|
||||
},
|
||||
"github": {
|
||||
"pr": {
|
||||
@@ -6437,7 +6442,8 @@
|
||||
"25b0004fbf": "终端活跃",
|
||||
"781cb74d22": "从终端窗格运行。",
|
||||
"cb02e00202": "终端",
|
||||
"d8c988dab4": "~/.orca/keybindings.json"
|
||||
"d8c988dab4": "~/.orca/keybindings.json",
|
||||
"shortcutUnavailable": "此快捷键已不再可用。"
|
||||
},
|
||||
"SourceControlAiActionRecipeDefaults": {
|
||||
"2576299196": "参数",
|
||||
@@ -9204,6 +9210,13 @@
|
||||
"warning": "此插件会安装 VM 配方。当你使用对应的生命周期操作时,Orca 会在本地桌面主机上运行以下命令。",
|
||||
"commandLabel": "{{value0}} · {{value1}}命令",
|
||||
"heading": "VM 配方命令"
|
||||
},
|
||||
"PluginKeybindingConsentPreview": {
|
||||
"warning": "此插件会添加键盘快捷键。插件启用期间,插件快捷键优先于相同的 Orca 快捷键。",
|
||||
"heading": "键盘快捷键",
|
||||
"worktree": "仅在有活动工作区时运行。",
|
||||
"global": "无需活动工作区即可在应用中运行。",
|
||||
"shadows": "替代:{{value0}}"
|
||||
}
|
||||
},
|
||||
"right": {
|
||||
@@ -12262,6 +12275,10 @@
|
||||
"project": "项目"
|
||||
}
|
||||
}
|
||||
},
|
||||
"pluginQuickActions": {
|
||||
"description": "{{value0}} 插件命令",
|
||||
"keyword": "插件命令"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { dispatchAppCommand, registerAppCommandDispatcher } from './app-command-dispatch'
|
||||
|
||||
let cleanup: (() => void) | null = null
|
||||
|
||||
afterEach(() => cleanup?.())
|
||||
|
||||
describe('app command dispatch', () => {
|
||||
it('routes known action ids through the current handler registry', () => {
|
||||
const dispatcher = vi.fn(() => true)
|
||||
cleanup = registerAppCommandDispatcher(dispatcher)
|
||||
|
||||
expect(dispatchAppCommand('view.tasks', 'plugin-palette')).toBe(true)
|
||||
expect(dispatcher).toHaveBeenCalledWith('view.tasks', 'plugin-palette')
|
||||
})
|
||||
|
||||
it('rejects unknown actions and unregisters only the current dispatcher', () => {
|
||||
const staleCleanup = registerAppCommandDispatcher(() => true)
|
||||
const current = vi.fn(() => true)
|
||||
cleanup = registerAppCommandDispatcher(current)
|
||||
staleCleanup()
|
||||
|
||||
expect(dispatchAppCommand('missing.action', 'plugin-keybinding')).toBe(false)
|
||||
expect(dispatchAppCommand('view.tasks', 'plugin-keybinding')).toBe(true)
|
||||
cleanup()
|
||||
cleanup = null
|
||||
expect(dispatchAppCommand('view.tasks', 'plugin-keybinding')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
import { isKeybindingActionId, type KeybindingActionId } from '../../../shared/keybindings'
|
||||
|
||||
export type AppCommandSource = 'plugin-keybinding' | 'plugin-palette'
|
||||
export type AppCommandDispatcher = (
|
||||
actionId: KeybindingActionId,
|
||||
source: AppCommandSource
|
||||
) => boolean
|
||||
|
||||
let currentDispatcher: AppCommandDispatcher | null = null
|
||||
|
||||
export function registerAppCommandDispatcher(dispatcher: AppCommandDispatcher): () => void {
|
||||
currentDispatcher = dispatcher
|
||||
return () => {
|
||||
if (currentDispatcher === dispatcher) {
|
||||
currentDispatcher = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function dispatchAppCommand(actionId: string, source: AppCommandSource): boolean {
|
||||
return isKeybindingActionId(actionId) && currentDispatcher !== null
|
||||
? currentDispatcher(actionId, source)
|
||||
: false
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ActivePluginCommand } from '@/store/plugin-panels'
|
||||
import { registerAppCommandDispatcher } from './app-command-dispatch'
|
||||
import { executePluginCommand } from './plugin-command-execution'
|
||||
|
||||
let unregister: (() => void) | null = null
|
||||
|
||||
afterEach(() => {
|
||||
unregister?.()
|
||||
unregister = null
|
||||
})
|
||||
|
||||
function command(handler: ActivePluginCommand['handler']): ActivePluginCommand {
|
||||
return {
|
||||
pluginKey: 'orca-samples.tasks',
|
||||
pluginName: 'Tasks',
|
||||
id: 'open',
|
||||
title: 'Open Tasks',
|
||||
context: 'global',
|
||||
handler,
|
||||
keybindings: []
|
||||
}
|
||||
}
|
||||
|
||||
describe('plugin command execution', () => {
|
||||
it('dispatches aliases without invoking a plugin worker', async () => {
|
||||
const dispatcher = vi.fn(() => true)
|
||||
const invokeCommand = vi.fn()
|
||||
unregister = registerAppCommandDispatcher(dispatcher)
|
||||
Object.assign(window, { api: { plugins: { invokeCommand } } })
|
||||
|
||||
await executePluginCommand(
|
||||
command({ type: 'built-in', action: 'view.tasks' }),
|
||||
'plugin-palette'
|
||||
)
|
||||
|
||||
expect(dispatcher).toHaveBeenCalledWith('view.tasks', 'plugin-palette')
|
||||
expect(invokeCommand).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes worker commands through the authenticated preload binding', async () => {
|
||||
const invokeCommand = vi.fn().mockResolvedValue({ ok: true })
|
||||
Object.assign(window, { api: { plugins: { invokeCommand } } })
|
||||
|
||||
await executePluginCommand(command({ type: 'worker' }), 'plugin-keybinding')
|
||||
|
||||
expect(invokeCommand).toHaveBeenCalledWith({
|
||||
pluginKey: 'orca-samples.tasks',
|
||||
commandId: 'open'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { ActivePluginCommand } from '@/store/plugin-panels'
|
||||
import { dispatchAppCommand, type AppCommandSource } from './app-command-dispatch'
|
||||
|
||||
export async function executePluginCommand(
|
||||
command: ActivePluginCommand,
|
||||
source: AppCommandSource
|
||||
): Promise<void> {
|
||||
if (command.handler.type === 'built-in') {
|
||||
if (!dispatchAppCommand(command.handler.action, source)) {
|
||||
throw new Error('built-in action is unavailable in the current context')
|
||||
}
|
||||
return
|
||||
}
|
||||
await window.api.plugins.invokeCommand({
|
||||
pluginKey: command.pluginKey,
|
||||
commandId: command.id
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
findKeybindingConflictsForDefinitions,
|
||||
KEYBINDING_DEFINITIONS
|
||||
} from '../../../shared/keybindings'
|
||||
import type { ActivePluginCommand } from '@/store/plugin-panels'
|
||||
import {
|
||||
buildPluginCommandKeybindingDefinitions,
|
||||
findPluginCommandForKeybinding,
|
||||
getEffectivePluginCommandKeybindings,
|
||||
pluginCommandKeybindingActionId,
|
||||
pluginCommandKeybindingDefinition
|
||||
} from './plugin-command-keybindings'
|
||||
|
||||
const command: ActivePluginCommand = {
|
||||
pluginKey: 'orca-samples.tasks',
|
||||
pluginName: 'Tasks',
|
||||
id: 'open',
|
||||
title: 'Open Tasks',
|
||||
context: 'global',
|
||||
handler: { type: 'built-in', action: 'view.tasks' },
|
||||
keybindings: [{ key: 'Mod+Alt+T', when: 'global' }]
|
||||
}
|
||||
|
||||
describe('plugin command keybindings', () => {
|
||||
it('builds a stable dynamic action definition', () => {
|
||||
expect(pluginCommandKeybindingActionId(command)).toBe('plugin:orca-samples.tasks/open')
|
||||
expect(pluginCommandKeybindingDefinition(command)).toMatchObject({
|
||||
title: 'Open Tasks — Tasks',
|
||||
group: 'Plugins',
|
||||
defaultBindings: { darwin: ['Mod+Alt+T'] }
|
||||
})
|
||||
})
|
||||
|
||||
it('lets user overrides replace or disable contributed defaults', () => {
|
||||
const actionId = pluginCommandKeybindingActionId(command)
|
||||
expect(getEffectivePluginCommandKeybindings(command, 'linux')).toEqual(['Mod+Alt+T'])
|
||||
expect(
|
||||
getEffectivePluginCommandKeybindings(command, 'linux', { [actionId]: ['Mod+Shift+T'] })
|
||||
).toEqual(['Mod+Shift+T'])
|
||||
expect(getEffectivePluginCommandKeybindings(command, 'linux', { [actionId]: [] })).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps removed-plugin overrides inert and restores them on reinstall', () => {
|
||||
const actionId = pluginCommandKeybindingActionId(command)
|
||||
const overrides = { [actionId]: ['Mod+Shift+T'] }
|
||||
|
||||
expect(buildPluginCommandKeybindingDefinitions([])).toEqual([])
|
||||
expect(getEffectivePluginCommandKeybindings(command, 'linux', overrides)).toEqual([
|
||||
'Mod+Shift+T'
|
||||
])
|
||||
})
|
||||
|
||||
it('matches effective overrides and honors explicit disablement', () => {
|
||||
const actionId = pluginCommandKeybindingActionId(command)
|
||||
const input = {
|
||||
key: 't',
|
||||
code: 'KeyT',
|
||||
control: true,
|
||||
meta: false,
|
||||
alt: false,
|
||||
shift: true
|
||||
}
|
||||
|
||||
expect(findPluginCommandForKeybinding([command], input, 'linux', undefined, true)).toBeNull()
|
||||
expect(
|
||||
findPluginCommandForKeybinding(
|
||||
[command],
|
||||
input,
|
||||
'linux',
|
||||
{ [actionId]: ['Mod+Shift+T'] },
|
||||
true
|
||||
)
|
||||
).toBe(command)
|
||||
expect(
|
||||
findPluginCommandForKeybinding([command], input, 'linux', { [actionId]: [] }, true)
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('reports conflicts between plugin and built-in definitions', () => {
|
||||
const actionId = pluginCommandKeybindingActionId(command)
|
||||
const definitions = [...KEYBINDING_DEFINITIONS, pluginCommandKeybindingDefinition(command)]
|
||||
|
||||
expect(
|
||||
findKeybindingConflictsForDefinitions(definitions, 'darwin', {
|
||||
[actionId]: ['Mod+P']
|
||||
})
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
actionIds: expect.arrayContaining([actionId, 'worktree.quickOpen'])
|
||||
})
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('does not match worktree commands without an active worktree', () => {
|
||||
const worktreeCommand = { ...command, context: 'worktree' as const }
|
||||
const input = {
|
||||
key: 't',
|
||||
code: 'KeyT',
|
||||
control: true,
|
||||
meta: false,
|
||||
alt: true,
|
||||
shift: false
|
||||
}
|
||||
|
||||
expect(
|
||||
findPluginCommandForKeybinding([worktreeCommand], input, 'linux', undefined, false)
|
||||
).toBeNull()
|
||||
expect(findPluginCommandForKeybinding([worktreeCommand], input, 'linux', undefined, true)).toBe(
|
||||
worktreeCommand
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { ActivePluginCommand } from '@/store/plugin-panels'
|
||||
import {
|
||||
getEffectiveKeybindingsForDefinition,
|
||||
keybindingMatchesInput,
|
||||
type KeybindingDefinition,
|
||||
type KeybindingInput,
|
||||
type KeybindingOverrides,
|
||||
type PluginKeybindingActionId
|
||||
} from '../../../shared/keybindings'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { pluginCommandKeybindingActionId as sharedPluginCommandKeybindingActionId } from '../../../shared/plugins/plugin-command-actions'
|
||||
|
||||
export function pluginCommandKeybindingActionId(
|
||||
command: Pick<ActivePluginCommand, 'pluginKey' | 'id'>
|
||||
): PluginKeybindingActionId {
|
||||
return sharedPluginCommandKeybindingActionId(command.pluginKey, command.id)
|
||||
}
|
||||
|
||||
export function pluginCommandKeybindingDefinition(
|
||||
command: ActivePluginCommand
|
||||
): KeybindingDefinition {
|
||||
const defaults = command.keybindings.map((keybinding) => keybinding.key)
|
||||
return {
|
||||
id: pluginCommandKeybindingActionId(command),
|
||||
title: `${command.title} — ${command.pluginName}`,
|
||||
group: translate('auto.lib.pluginCommandKeybindings.group', 'Plugins'),
|
||||
scope: 'global',
|
||||
searchKeywords: [
|
||||
'plugin',
|
||||
'shortcut',
|
||||
command.title.toLowerCase(),
|
||||
command.pluginName.toLowerCase()
|
||||
],
|
||||
defaultBindings: { darwin: defaults, linux: defaults, win32: defaults }
|
||||
}
|
||||
}
|
||||
|
||||
export function buildPluginCommandKeybindingDefinitions(
|
||||
commands: readonly ActivePluginCommand[]
|
||||
): KeybindingDefinition[] {
|
||||
return commands.map(pluginCommandKeybindingDefinition)
|
||||
}
|
||||
|
||||
export function getEffectivePluginCommandKeybindings(
|
||||
command: ActivePluginCommand,
|
||||
platform: NodeJS.Platform,
|
||||
overrides?: KeybindingOverrides
|
||||
): string[] {
|
||||
return getEffectiveKeybindingsForDefinition(
|
||||
pluginCommandKeybindingDefinition(command),
|
||||
platform,
|
||||
overrides
|
||||
)
|
||||
}
|
||||
|
||||
export function findPluginCommandForKeybinding(
|
||||
commands: readonly ActivePluginCommand[],
|
||||
input: KeybindingInput,
|
||||
platform: NodeJS.Platform,
|
||||
overrides: KeybindingOverrides | undefined,
|
||||
hasActiveWorktree: boolean
|
||||
): ActivePluginCommand | null {
|
||||
for (const command of commands) {
|
||||
if (command.context === 'worktree' && !hasActiveWorktree) {
|
||||
continue
|
||||
}
|
||||
const matches = getEffectivePluginCommandKeybindings(command, platform, overrides).some(
|
||||
(binding) => keybindingMatchesInput(binding, input, platform)
|
||||
)
|
||||
if (matches) {
|
||||
return command
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -2,7 +2,11 @@
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { PluginHostListEntry } from '../../../preload/api-types'
|
||||
import { usePluginPanelsStore } from './plugin-panels'
|
||||
import {
|
||||
collectActivePluginCommands,
|
||||
collectEditablePluginCommands,
|
||||
usePluginPanelsStore
|
||||
} from './plugin-panels'
|
||||
|
||||
function plugin(pluginKey: string): PluginHostListEntry {
|
||||
return {
|
||||
@@ -30,6 +34,37 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('plugin panel list loading', () => {
|
||||
it('collects commands only from enabled plugin states', () => {
|
||||
const enabled = {
|
||||
...plugin('orca-samples.enabled'),
|
||||
commands: [
|
||||
{
|
||||
id: 'tasks',
|
||||
title: 'Tasks',
|
||||
context: 'global' as const,
|
||||
handler: { type: 'built-in' as const, action: 'view.tasks' },
|
||||
keybindings: [{ key: 'Mod+Alt+T', when: 'global' as const }]
|
||||
}
|
||||
]
|
||||
}
|
||||
const pending = { ...enabled, pluginKey: 'orca-samples.pending', status: 'pending' as const }
|
||||
|
||||
expect(collectActivePluginCommands([enabled, pending])).toEqual([
|
||||
expect.objectContaining({
|
||||
pluginKey: enabled.pluginKey,
|
||||
pluginName: enabled.name,
|
||||
id: 'tasks'
|
||||
})
|
||||
])
|
||||
expect(
|
||||
collectEditablePluginCommands([
|
||||
{ ...enabled, status: 'errored' },
|
||||
pending,
|
||||
{ ...enabled, status: 'disabled' }
|
||||
])
|
||||
).toEqual([expect.objectContaining({ pluginKey: enabled.pluginKey, id: 'tasks' })])
|
||||
})
|
||||
|
||||
it('bounds watchdog errors to installed panels and clears them on recovery', () => {
|
||||
const installed = {
|
||||
...plugin('orca-samples.current'),
|
||||
|
||||
@@ -8,6 +8,11 @@ export type ActivePluginPanel = PluginHostPanel & {
|
||||
pluginName: string
|
||||
}
|
||||
|
||||
export type ActivePluginCommand = PluginHostListEntry['commands'][number] & {
|
||||
pluginKey: string
|
||||
pluginName: string
|
||||
}
|
||||
|
||||
export type PluginPanelsFetchStatus = 'idle' | 'loading' | 'ready' | 'error'
|
||||
export type PluginPanelHealth = 'healthy' | 'error'
|
||||
|
||||
@@ -157,6 +162,37 @@ export function collectInstalledPluginTabKeys(
|
||||
return new Set(plugins.flatMap((plugin) => plugin.panels.map((panel) => panel.tabKey)))
|
||||
}
|
||||
|
||||
export function collectActivePluginCommands(
|
||||
plugins: readonly PluginHostListEntry[]
|
||||
): ActivePluginCommand[] {
|
||||
return plugins
|
||||
.filter(
|
||||
(plugin) =>
|
||||
plugin.status === 'running' || plugin.status === 'restarting' || plugin.status === 'idle'
|
||||
)
|
||||
.flatMap((plugin) =>
|
||||
plugin.commands.map((command) => ({
|
||||
...command,
|
||||
pluginKey: plugin.pluginKey,
|
||||
pluginName: plugin.name
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
export function collectEditablePluginCommands(
|
||||
plugins: readonly PluginHostListEntry[]
|
||||
): ActivePluginCommand[] {
|
||||
return plugins
|
||||
.filter((plugin) => ['running', 'restarting', 'idle', 'errored'].includes(plugin.status))
|
||||
.flatMap((plugin) =>
|
||||
plugin.commands.map((command) => ({
|
||||
...command,
|
||||
pluginKey: plugin.pluginKey,
|
||||
pluginName: plugin.name
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
/** Panel contributions of enabled plugins, loading the list on first use. */
|
||||
export function usePluginPanels(): ActivePluginPanel[] {
|
||||
const plugins = usePluginPanelsStore((s) => s.plugins)
|
||||
@@ -167,3 +203,22 @@ export function usePluginPanels(): ActivePluginPanel[] {
|
||||
// referentially stable and doesn't retrigger useSyncExternalStore loops.
|
||||
return useMemo(() => collectActivePluginPanels(plugins), [plugins])
|
||||
}
|
||||
|
||||
/** Commands of enabled plugins, sharing the authoritative plugin-list refresh. */
|
||||
export function usePluginCommands(): ActivePluginCommand[] {
|
||||
const plugins = usePluginPanelsStore((state) => state.plugins)
|
||||
useEffect(() => {
|
||||
ensurePluginPanelsLoaded()
|
||||
}, [])
|
||||
return useMemo(() => collectActivePluginCommands(plugins), [plugins])
|
||||
}
|
||||
|
||||
/** Enabled commands remain editable while a chord conflict has errored the
|
||||
* plugin, so a saved override can recover both conflicting plugins. */
|
||||
export function useEditablePluginCommands(): ActivePluginCommand[] {
|
||||
const plugins = usePluginPanelsStore((state) => state.plugins)
|
||||
useEffect(() => {
|
||||
ensurePluginPanelsLoaded()
|
||||
}, [])
|
||||
return useMemo(() => collectEditablePluginCommands(plugins), [plugins])
|
||||
}
|
||||
|
||||
@@ -4,11 +4,13 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
agentTabActionId,
|
||||
findKeybindingActionsForBinding,
|
||||
getKeybindingDefinition,
|
||||
findKeybindingConflicts,
|
||||
formatKeybinding,
|
||||
formatKeybindingList,
|
||||
getEffectiveKeybindingsForAction,
|
||||
isKeybindingActionId,
|
||||
isDigitIndexActionId,
|
||||
isDoubleTapBinding,
|
||||
keybindingFromInput,
|
||||
@@ -25,6 +27,14 @@ import type { KeybindingActionId, KeybindingPlatform } from './keybindings'
|
||||
import { ALL_TUI_AGENTS } from './tui-agent-display-names'
|
||||
|
||||
describe('keybindings', () => {
|
||||
it('accepts bounded plugin command action IDs and rejects malformed variants', () => {
|
||||
expect(isKeybindingActionId('plugin:orca-samples.tasks/open')).toBe(true)
|
||||
expect(isKeybindingActionId('plugin:orca-samples.tasks/task.open-latest')).toBe(true)
|
||||
expect(isKeybindingActionId('plugin:tasks/open')).toBe(false)
|
||||
expect(isKeybindingActionId('plugin:orca-samples.tasks/../open')).toBe(false)
|
||||
expect(isKeybindingActionId(`plugin:orca-samples.tasks/${'a'.repeat(401)}`)).toBe(false)
|
||||
})
|
||||
|
||||
it('normalizes editable shortcut input and rejects unsafe bindings', () => {
|
||||
expect(normalizeKeybinding(' ctrl + shift + p ')).toEqual({
|
||||
ok: true,
|
||||
@@ -242,6 +252,16 @@ describe('keybindings', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('finds app-level owners of a prospective plugin chord with overrides', () => {
|
||||
expect(findKeybindingActionsForBinding('Mod+P', 'darwin')).toContain('worktree.quickOpen')
|
||||
expect(
|
||||
findKeybindingActionsForBinding('Mod+Alt+T', 'linux', {
|
||||
'view.tasks': ['Mod+Alt+T']
|
||||
})
|
||||
).toContain('view.tasks')
|
||||
expect(findKeybindingActionsForBinding('Mod+F', 'darwin')).not.toContain('editor.find')
|
||||
})
|
||||
|
||||
it('reports quick-command menu conflicts with global shortcuts and digit ranges', () => {
|
||||
expect(
|
||||
findKeybindingConflicts('darwin', {
|
||||
|
||||
@@ -26,6 +26,7 @@ export type KeybindingMatchOptions = {
|
||||
}
|
||||
|
||||
export type AgentTabActionId = `tab.newAgent.${TuiAgent}`
|
||||
export type PluginKeybindingActionId = `plugin:${string}`
|
||||
|
||||
export type KeybindingActionId =
|
||||
| 'worktree.quickOpen'
|
||||
@@ -111,6 +112,7 @@ export type KeybindingActionId =
|
||||
| 'terminal.closePane'
|
||||
| 'terminal.splitRight'
|
||||
| 'terminal.splitDown'
|
||||
| PluginKeybindingActionId
|
||||
|
||||
export type KeybindingOverrides = Partial<Record<KeybindingActionId, string[]>>
|
||||
|
||||
@@ -190,6 +192,7 @@ export type KeybindingConflict = {
|
||||
|
||||
export type FindKeybindingConflictOptions = {
|
||||
ignoredActionIds?: Iterable<KeybindingActionId>
|
||||
relevantActionIds?: Iterable<KeybindingActionId>
|
||||
}
|
||||
|
||||
export const KEYBINDING_DEFINITIONS: readonly KeybindingDefinition[] = [
|
||||
@@ -1092,7 +1095,16 @@ export function getKeybindingPlatform(platform: NodeJS.Platform): KeybindingPlat
|
||||
}
|
||||
|
||||
export function isKeybindingActionId(value: string): value is KeybindingActionId {
|
||||
return DEFINITION_IDS.has(value as KeybindingActionId)
|
||||
return DEFINITION_IDS.has(value as KeybindingActionId) || isPluginKeybindingActionId(value)
|
||||
}
|
||||
|
||||
export function isPluginKeybindingActionId(value: string): value is PluginKeybindingActionId {
|
||||
return (
|
||||
value.length <= 400 &&
|
||||
/^plugin:[a-z0-9]+(?:-[a-z0-9]+)*\.[a-z0-9]+(?:-[a-z0-9]+)*\/[A-Za-z0-9]+(?:[._-][A-Za-z0-9]+)*$/.test(
|
||||
value
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function hasModifier(
|
||||
@@ -1764,9 +1776,6 @@ export function getEffectiveKeybindingsForAction(
|
||||
overrides?: KeybindingOverrides
|
||||
): string[] {
|
||||
const definition = DEFINITIONS_BY_ID.get(actionId)
|
||||
if (!definition) {
|
||||
return []
|
||||
}
|
||||
const override = overrides?.[actionId]
|
||||
if (Array.isArray(override)) {
|
||||
// Why: digit-index overrides resolve to their canonical <mods>+1 representative
|
||||
@@ -1790,6 +1799,18 @@ export function getEffectiveKeybindingsForAction(
|
||||
return normalized.ok ? [normalized.value] : []
|
||||
})
|
||||
}
|
||||
return definition ? getDefaultBindings(definition, platform) : []
|
||||
}
|
||||
|
||||
export function getEffectiveKeybindingsForDefinition(
|
||||
definition: KeybindingDefinition,
|
||||
platform: NodeJS.Platform,
|
||||
overrides?: KeybindingOverrides
|
||||
): string[] {
|
||||
const override = overrides?.[definition.id]
|
||||
if (Array.isArray(override)) {
|
||||
return getEffectiveKeybindingsForAction(definition.id, platform, overrides)
|
||||
}
|
||||
return getDefaultBindings(definition, platform)
|
||||
}
|
||||
|
||||
@@ -2054,7 +2075,7 @@ function keybindingConflictIdentityForParsed(
|
||||
].join('+')
|
||||
}
|
||||
|
||||
function keybindingConflictIdentity(binding: string, platform: NodeJS.Platform): string {
|
||||
export function getKeybindingConflictIdentity(binding: string, platform: NodeJS.Platform): string {
|
||||
const parsed = parseKeybinding(binding)
|
||||
return parsed ? keybindingConflictIdentityForParsed(parsed, platform) : binding
|
||||
}
|
||||
@@ -2064,7 +2085,7 @@ function keybindingConflictIdentities(
|
||||
binding: string,
|
||||
platform: NodeJS.Platform
|
||||
): readonly string[] {
|
||||
const exact = keybindingConflictIdentity(binding, platform)
|
||||
const exact = getKeybindingConflictIdentity(binding, platform)
|
||||
if (!isDigitIndexActionId(actionId)) {
|
||||
return [exact]
|
||||
}
|
||||
@@ -2199,6 +2220,23 @@ export function formatKeybindingList(
|
||||
.join(', ')
|
||||
}
|
||||
|
||||
export function findKeybindingActionsForBinding(
|
||||
binding: string,
|
||||
platform: NodeJS.Platform,
|
||||
overrides?: KeybindingOverrides,
|
||||
scopes: readonly KeybindingScope[] = ['global', 'tabs']
|
||||
): KeybindingActionId[] {
|
||||
const identity = getKeybindingConflictIdentity(binding, platform)
|
||||
const allowedScopes = new Set(scopes)
|
||||
return KEYBINDING_DEFINITIONS.filter(
|
||||
(definition) =>
|
||||
allowedScopes.has(definition.scope) &&
|
||||
getEffectiveKeybindingsForAction(definition.id, platform, overrides).some((candidate) =>
|
||||
keybindingConflictIdentities(definition.id, candidate, platform).includes(identity)
|
||||
)
|
||||
).map((definition) => definition.id)
|
||||
}
|
||||
|
||||
function formatKeyToken(token: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
BracketLeft: '[',
|
||||
@@ -2237,6 +2275,15 @@ export function findKeybindingConflicts(
|
||||
platform: NodeJS.Platform,
|
||||
overrides?: KeybindingOverrides,
|
||||
options: FindKeybindingConflictOptions = {}
|
||||
): KeybindingConflict[] {
|
||||
return findKeybindingConflictsForDefinitions(KEYBINDING_DEFINITIONS, platform, overrides, options)
|
||||
}
|
||||
|
||||
export function findKeybindingConflictsForDefinitions(
|
||||
definitions: readonly KeybindingDefinition[],
|
||||
platform: NodeJS.Platform,
|
||||
overrides?: KeybindingOverrides,
|
||||
options: FindKeybindingConflictOptions = {}
|
||||
): KeybindingConflict[] {
|
||||
const owners = new Map<string, { binding: string; actionIds: Set<KeybindingActionId> }>()
|
||||
const ignoredActionIds = new Set(options.ignoredActionIds ?? [])
|
||||
@@ -2246,11 +2293,16 @@ export function findKeybindingConflicts(
|
||||
isKeybindingActionId(actionId) && !ignoredActionIds.has(actionId)
|
||||
)
|
||||
)
|
||||
for (const definition of KEYBINDING_DEFINITIONS) {
|
||||
for (const actionId of options.relevantActionIds ?? []) {
|
||||
if (!ignoredActionIds.has(actionId)) {
|
||||
customizedActions.add(actionId)
|
||||
}
|
||||
}
|
||||
for (const definition of definitions) {
|
||||
if (ignoredActionIds.has(definition.id)) {
|
||||
continue
|
||||
}
|
||||
for (const binding of getEffectiveKeybindingsForAction(definition.id, platform, overrides)) {
|
||||
for (const binding of getEffectiveKeybindingsForDefinition(definition, platform, overrides)) {
|
||||
const groups = new Set([definition.conflictGroup ?? definition.scope])
|
||||
if (definition.conflictGroup) {
|
||||
// Why: native menu accelerators can still consume global chords, so custom
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { KeybindingActionId, PluginKeybindingActionId } from '../keybindings'
|
||||
|
||||
/** Built-in actions with a renderer-owned command handler. Keep this closed so
|
||||
* declarative aliases cannot target component-private shortcut implementations. */
|
||||
export const PLUGIN_COMMAND_ALIAS_ACTION_IDS = [
|
||||
'worktree.history.back',
|
||||
'worktree.history.forward',
|
||||
'sidebar.left.toggle',
|
||||
'sidebar.sleepingWorkspaces.toggle',
|
||||
'floatingWorkspace.maximize',
|
||||
'tab.rename',
|
||||
'workspace.rename',
|
||||
'workspace.openBoard',
|
||||
'view.tasks',
|
||||
'sidebar.right.toggle',
|
||||
'sidebar.explorer.toggle',
|
||||
'sidebar.search.toggle',
|
||||
'sidebar.sourceControl.toggle',
|
||||
'sidebar.checks.toggle',
|
||||
'sidebar.ports.toggle'
|
||||
] as const satisfies readonly KeybindingActionId[]
|
||||
|
||||
export type PluginCommandAliasActionId = (typeof PLUGIN_COMMAND_ALIAS_ACTION_IDS)[number]
|
||||
|
||||
const PLUGIN_COMMAND_ALIAS_ACTION_ID_SET = new Set<string>(PLUGIN_COMMAND_ALIAS_ACTION_IDS)
|
||||
|
||||
export function isPluginCommandAliasActionId(value: string): value is PluginCommandAliasActionId {
|
||||
return PLUGIN_COMMAND_ALIAS_ACTION_ID_SET.has(value)
|
||||
}
|
||||
|
||||
export function pluginCommandKeybindingActionId(
|
||||
pluginKey: string,
|
||||
commandId: string
|
||||
): PluginKeybindingActionId {
|
||||
return `plugin:${pluginKey}/${commandId}`
|
||||
}
|
||||
@@ -43,6 +43,7 @@ describe('content-pack manifest contributions', () => {
|
||||
expect(parsed.main).toBeUndefined()
|
||||
expect(parsed.contributes.skills[0]?.path).toBe('skills')
|
||||
expect(parsed.contributes.languagePacks[0]?.locale).toBe('pt-BR')
|
||||
expect(parsed.contributes.keybindings[0]?.key).toBe('Mod+Alt+T')
|
||||
})
|
||||
|
||||
it('defaults every contribution registry to an empty array', () => {
|
||||
@@ -71,6 +72,56 @@ describe('content-pack manifest contributions', () => {
|
||||
).toMatchObject({ ok: false, error: expect.stringContaining('worker command') })
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
'unknown alias target',
|
||||
{
|
||||
commands: [{ id: 'open', title: 'Open', action: 'missing.action' }]
|
||||
},
|
||||
'unknown built-in action'
|
||||
],
|
||||
[
|
||||
'unknown command reference',
|
||||
{ keybindings: [{ command: 'missing', key: 'Mod+K' }] },
|
||||
'unknown contributed command'
|
||||
],
|
||||
[
|
||||
'invalid chord',
|
||||
{
|
||||
commands: [{ id: 'open', title: 'Open', action: 'view.tasks' }],
|
||||
keybindings: [{ command: 'open', key: 'Mod+NotAKey' }]
|
||||
},
|
||||
'key'
|
||||
],
|
||||
[
|
||||
'global binding for a worktree command',
|
||||
{
|
||||
commands: [{ id: 'open', title: 'Open', context: 'worktree', action: 'view.tasks' }],
|
||||
keybindings: [{ command: 'open', key: 'Mod+K', when: 'global' }]
|
||||
},
|
||||
'command context'
|
||||
],
|
||||
[
|
||||
'platform-equivalent duplicate chords',
|
||||
{
|
||||
commands: [
|
||||
{ id: 'first', title: 'First', action: 'view.tasks' },
|
||||
{ id: 'second', title: 'Second', action: 'sidebar.left.toggle' }
|
||||
],
|
||||
keybindings: [
|
||||
{ command: 'first', key: 'Mod+K' },
|
||||
{ command: 'second', key: 'Ctrl+K' }
|
||||
]
|
||||
},
|
||||
'duplicate keybinding'
|
||||
]
|
||||
])('rejects %s', (_label, contributes, error) => {
|
||||
expect(parsePluginManifest(manifest(contributes))).toMatchObject({
|
||||
ok: false,
|
||||
error: expect.stringContaining(error)
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['theme', { themes: [{ id: 'bad', label: 'Bad', path: '../outside.json' }] }],
|
||||
['icon theme', { iconThemes: [{ id: 'bad', path: '/tmp/icons.json' }] }],
|
||||
@@ -108,12 +159,10 @@ describe('content-pack manifest contributions', () => {
|
||||
expect.arrayContaining([
|
||||
'duplicate themes id: same',
|
||||
'duplicate language pack locale: pt-br',
|
||||
'duplicate skills path: skills'
|
||||
'duplicate skills path: skills',
|
||||
'duplicate keybinding: mod+t'
|
||||
])
|
||||
)
|
||||
expect(
|
||||
parsed.error.issues.some((issue) => issue.message.startsWith('duplicate keybinding:'))
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
pluginRelativeDirectorySchema,
|
||||
pluginRelativePathSchema
|
||||
} from './plugin-manifest-fields'
|
||||
import { normalizeKeybinding } from '../keybindings'
|
||||
|
||||
export const PLUGIN_THEME_LIMIT = 64
|
||||
export const PLUGIN_ICON_THEME_LIMIT = 32
|
||||
@@ -70,8 +71,19 @@ export const pluginSkillContributionSchema = z
|
||||
export const pluginKeybindingContributionSchema = z
|
||||
.object({
|
||||
command: pluginCommandIdSchema,
|
||||
key: z.string().min(1).max(128),
|
||||
when: z.string().min(1).max(256).optional()
|
||||
key: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(128)
|
||||
.transform((value, ctx) => {
|
||||
const normalized = normalizeKeybinding(value)
|
||||
if (!normalized.ok) {
|
||||
ctx.addIssue({ code: 'custom', message: normalized.error })
|
||||
return z.NEVER
|
||||
}
|
||||
return normalized.value
|
||||
}),
|
||||
when: z.enum(['global', 'worktree']).optional()
|
||||
})
|
||||
.strict()
|
||||
|
||||
|
||||
@@ -33,22 +33,23 @@ describe('plugin language-pack artifacts', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['PluginConsentDialog', 'PluginVmRecipeConsentPreview'])(
|
||||
'prevents language packs from rewriting %s security copy',
|
||||
(component) => {
|
||||
expect(
|
||||
parsePluginLanguagePackArtifact(
|
||||
JSON.stringify({
|
||||
auto: {
|
||||
components: {
|
||||
settings: { [component]: { disclaimer: 'This plugin is perfectly safe.' } }
|
||||
}
|
||||
it.each([
|
||||
'PluginConsentDialog',
|
||||
'PluginKeybindingConsentPreview',
|
||||
'PluginVmRecipeConsentPreview'
|
||||
])('prevents language packs from rewriting %s security copy', (component) => {
|
||||
expect(
|
||||
parsePluginLanguagePackArtifact(
|
||||
JSON.stringify({
|
||||
auto: {
|
||||
components: {
|
||||
settings: { [component]: { disclaimer: 'This plugin is perfectly safe.' } }
|
||||
}
|
||||
})
|
||||
)
|
||||
).toMatchObject({ ok: false, error: expect.stringContaining('protected security copy') })
|
||||
}
|
||||
)
|
||||
}
|
||||
})
|
||||
)
|
||||
).toMatchObject({ ok: false, error: expect.stringContaining('protected security copy') })
|
||||
})
|
||||
|
||||
it.each([
|
||||
['array leaf', { settings: { choices: ['one'] } }],
|
||||
|
||||
@@ -5,6 +5,7 @@ const DANGEROUS_CATALOG_KEYS = new Set(['__proto__', 'prototype', 'constructor']
|
||||
const PROTECTED_TRANSLATION_PREFIXES = [
|
||||
'auto.components.settings.PluginConsentDialog',
|
||||
'auto.components.settings.PluginInstallDialog',
|
||||
'auto.components.settings.PluginKeybindingConsentPreview',
|
||||
'auto.components.settings.PluginRemoveDialog',
|
||||
'auto.components.settings.PluginSettingsRow',
|
||||
'auto.components.settings.PluginVmRecipeConsentPreview',
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { RefinementCtx } from 'zod'
|
||||
import { isPluginCommandAliasActionId } from './plugin-command-actions'
|
||||
import { getKeybindingConflictIdentity } from '../keybindings'
|
||||
|
||||
type IdentifiedContribution = { id: string }
|
||||
type PathContribution = { path: string }
|
||||
@@ -7,14 +9,14 @@ type ContributionValidationManifest = {
|
||||
main?: string
|
||||
contributes: {
|
||||
panels: IdentifiedContribution[]
|
||||
commands: (IdentifiedContribution & { action?: string })[]
|
||||
commands: (IdentifiedContribution & { action?: string; context?: 'global' | 'worktree' })[]
|
||||
events: { on: string }[]
|
||||
themes: IdentifiedContribution[]
|
||||
iconThemes: IdentifiedContribution[]
|
||||
terminalThemes: IdentifiedContribution[]
|
||||
languagePacks: { locale: string }[]
|
||||
skills: PathContribution[]
|
||||
keybindings: { command: string; key: string }[]
|
||||
keybindings: { command: string; key: string; when?: 'global' | 'worktree' }[]
|
||||
vmRecipes: PathContribution[]
|
||||
agents: PathContribution[]
|
||||
}
|
||||
@@ -71,16 +73,50 @@ export function validatePluginManifestContributions(
|
||||
ctx
|
||||
)
|
||||
}
|
||||
rejectDuplicateValues(
|
||||
manifest.contributes.keybindings,
|
||||
(entry) => {
|
||||
const keybinding = entry as { command: string; key: string }
|
||||
return `${keybinding.command}\u0000${keybinding.key.toLowerCase()}`
|
||||
},
|
||||
'keybindings',
|
||||
'keybinding',
|
||||
ctx
|
||||
)
|
||||
const keybindingIdentities = new Set<string>()
|
||||
for (const [index, keybinding] of manifest.contributes.keybindings.entries()) {
|
||||
const identities = (['darwin', 'linux', 'win32'] as const).map((platform) =>
|
||||
getKeybindingConflictIdentity(keybinding.key, platform)
|
||||
)
|
||||
if (identities.some((identity) => keybindingIdentities.has(identity))) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['contributes', 'keybindings', index],
|
||||
message: `duplicate keybinding: ${keybinding.key.toLowerCase()}`
|
||||
})
|
||||
}
|
||||
identities.forEach((identity) => keybindingIdentities.add(identity))
|
||||
}
|
||||
|
||||
const commands = new Map(manifest.contributes.commands.map((command) => [command.id, command]))
|
||||
for (const [index, command] of manifest.contributes.commands.entries()) {
|
||||
if (command.action !== undefined && !isPluginCommandAliasActionId(command.action)) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['contributes', 'commands', index, 'action'],
|
||||
message: `unknown built-in action: ${command.action}`
|
||||
})
|
||||
}
|
||||
}
|
||||
for (const [index, keybinding] of manifest.contributes.keybindings.entries()) {
|
||||
const command = commands.get(keybinding.command)
|
||||
if (!command) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['contributes', 'keybindings', index, 'command'],
|
||||
message: `unknown contributed command: ${keybinding.command}`
|
||||
})
|
||||
continue
|
||||
}
|
||||
const commandContext = command.context ?? 'global'
|
||||
if (keybinding.when !== undefined && keybinding.when !== commandContext) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['contributes', 'keybindings', index, 'when'],
|
||||
message: 'keybinding context must match its command context'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!manifest.main &&
|
||||
|
||||
Reference in New Issue
Block a user