feat(plugins): add commands and keybindings

This commit is contained in:
Neil
2026-07-13 03:33:44 -07:00
parent 0b0bed03f0
commit 7eef2bd8ed
52 changed files with 2149 additions and 325 deletions
@@ -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 &&