mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 08:03:22 +00:00
feat(plugins): add commands and keybindings
This commit is contained in:
@@ -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