diff --git a/src/main/index.ts b/src/main/index.ts index 655a1fae811..432e6ff16bb 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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 diff --git a/src/main/ipc/keybindings.test.ts b/src/main/ipc/keybindings.test.ts index 3f0aa37aa28..ec8016287d5 100644 --- a/src/main/ipc/keybindings.test.ts +++ b/src/main/ipc/keybindings.test.ts @@ -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) diff --git a/src/main/ipc/keybindings.ts b/src/main/ipc/keybindings.ts index ae73c2b15ac..7a71f3116c0 100644 --- a/src/main/ipc/keybindings.ts +++ b/src/main/ipc/keybindings.ts @@ -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 }) diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index 4b380b9e283..b08176da464 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -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) diff --git a/src/main/keybindings/keybinding-file.test.ts b/src/main/keybindings/keybinding-file.test.ts index cc7cb43c5c3..fc0648ea6ea 100644 --- a/src/main/keybindings/keybinding-file.test.ts +++ b/src/main/keybindings/keybinding-file.test.ts @@ -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, diff --git a/src/main/plugins/plugin-command-invocation.ts b/src/main/plugins/plugin-command-invocation.ts new file mode 100644 index 00000000000..28ff762bfcc --- /dev/null +++ b/src/main/plugins/plugin-command-invocation.ts @@ -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`) + } +} diff --git a/src/main/plugins/plugin-command-registry.test.ts b/src/main/plugins/plugin-command-registry.test.ts new file mode 100644 index 00000000000..e05c6351441 --- /dev/null +++ b/src/main/plugins/plugin-command-registry.test.ts @@ -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[] + keybindings?: Record[] + } +): 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() + }) +}) diff --git a/src/main/plugins/plugin-command-registry.ts b/src/main/plugins/plugin-command-registry.ts new file mode 100644 index 00000000000..3b3af499ebc --- /dev/null +++ b/src/main/plugins/plugin-command-registry.ts @@ -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() + private readonly errors = new Map() + + 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() + 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() + 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 +} diff --git a/src/main/plugins/plugin-content-pack-registry.ts b/src/main/plugins/plugin-content-pack-registry.ts index 80dd1395d03..00f4d8834b6 100644 --- a/src/main/plugins/plugin-content-pack-registry.ts +++ b/src/main/plugins/plugin-content-pack-registry.ts @@ -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() 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 { 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) ) } } diff --git a/src/main/plugins/plugin-event-delivery.ts b/src/main/plugins/plugin-event-delivery.ts new file mode 100644 index 00000000000..15aac71b316 --- /dev/null +++ b/src/main/plugins/plugin-event-delivery.ts @@ -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 + ) + } + } +} diff --git a/src/main/plugins/plugin-list-projection.test.ts b/src/main/plugins/plugin-list-projection.test.ts index 94cc0ad3ce9..55f24cef436 100644 --- a/src/main/plugins/plugin-list-projection.test.ts +++ b/src/main/plugins/plugin-list-projection.test.ts @@ -25,6 +25,7 @@ function serviceWith( activation?: ReturnType worker?: ReturnType vmRecipes?: ReturnType + commands?: ReturnType } = {} ): 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' }] + } + ]) + }) }) diff --git a/src/main/plugins/plugin-list-projection.ts b/src/main/plugins/plugin-list-projection.ts index 7a6b6508b5e..4ca0a0cf5b3 100644 --- a/src/main/plugins/plugin-list-projection.ts +++ b/src/main/plugins/plugin-list-projection.ts @@ -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, diff --git a/src/main/plugins/plugin-service-options.ts b/src/main/plugins/plugin-service-options.ts index 612e2dbef1b..799e49b0d8a 100644 --- a/src/main/plugins/plugin-service-options.ts +++ b/src/main/plugins/plugin-service-options.ts @@ -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 getDevPluginPaths: () => string[] + getKeybindings?: () => KeybindingOverrides hostEntryPath?: string workerFactory?: PluginWorkerFactory maxActiveWorkers?: number diff --git a/src/main/plugins/plugin-service-reconciliation.test.ts b/src/main/plugins/plugin-service-reconciliation.test.ts index 2ffe48370a4..7d9ed38ed38 100644 --- a/src/main/plugins/plugin-service-reconciliation.test.ts +++ b/src/main/plugins/plugin-service-reconciliation.test.ts @@ -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(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('

Panel

') + }) + 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() + 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) diff --git a/src/main/plugins/plugin-service.ts b/src/main/plugins/plugin-service.ts index 9d387dc71a6..df02847d37d 100644 --- a/src/main/plugins/plugin-service.ts +++ b/src/main/plugins/plugin-service.ts @@ -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 | null = null private refreshChain: Promise = 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 { 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 { @@ -315,12 +308,15 @@ export class PluginService { } private async performActivationStateReconciliation(): Promise { - 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) diff --git a/src/main/plugins/plugin-startup-budget.test.ts b/src/main/plugins/plugin-startup-budget.test.ts index 149e4ebc9ba..c92627f757c 100644 --- a/src/main/plugins/plugin-startup-budget.test.ts +++ b/src/main/plugins/plugin-startup-budget.test.ts @@ -22,6 +22,7 @@ let markerPaths: string[] = [] let consents: Record = {} 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(async () => { throw new Error('startup must not create a plugin worker') }) diff --git a/src/main/plugins/plugin-worker-controller.test.ts b/src/main/plugins/plugin-worker-controller.test.ts index 7418116563d..63e05cd6a7b 100644 --- a/src/main/plugins/plugin-worker-controller.test.ts +++ b/src/main/plugins/plugin-worker-controller.test.ts @@ -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().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() + }) }) diff --git a/src/main/plugins/plugin-worker-controller.ts b/src/main/plugins/plugin-worker-controller.ts index 2c06107d123..de166175b71 100644 --- a/src/main/plugins/plugin-worker-controller.ts +++ b/src/main/plugins/plugin-worker-controller.ts @@ -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) { diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 80ad5b25e02..1c4c2edc440 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -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?: { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 39be5cd8810..146f96c67ff 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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 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 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) diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index 8b1ee8fd01d..fb18e133bb6 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.tsx @@ -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] ) diff --git a/src/renderer/src/components/cmd-j/plugin-quick-actions.test.ts b/src/renderer/src/components/cmd-j/plugin-quick-actions.test.ts new file mode 100644 index 00000000000..0caadb75569 --- /dev/null +++ b/src/renderer/src/components/cmd-j/plugin-quick-actions.test.ts @@ -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 + }) + }) +}) diff --git a/src/renderer/src/components/cmd-j/plugin-quick-actions.ts b/src/renderer/src/components/cmd-j/plugin-quick-actions.ts new file mode 100644 index 00000000000..73b1ffbcb65 --- /dev/null +++ b/src/renderer/src/components/cmd-j/plugin-quick-actions.ts @@ -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' } + } + })) +} diff --git a/src/renderer/src/components/settings/PluginConsentDialog.test.tsx b/src/renderer/src/components/settings/PluginConsentDialog.test.tsx index 83fae19bc9b..dede5cb50aa 100644 --- a/src/renderer/src/components/settings/PluginConsentDialog.test.tsx +++ b/src/renderer/src/components/settings/PluginConsentDialog.test.tsx @@ -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) diff --git a/src/renderer/src/components/settings/PluginConsentDialog.tsx b/src/renderer/src/components/settings/PluginConsentDialog.tsx index 1a8b7f70ccd..560526c6536 100644 --- a/src/renderer/src/components/settings/PluginConsentDialog.tsx +++ b/src/renderer/src/components/settings/PluginConsentDialog.tsx @@ -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({ ) : null} + {error ?

{error}

: null} diff --git a/src/renderer/src/components/settings/PluginKeybindingConsentPreview.test.ts b/src/renderer/src/components/settings/PluginKeybindingConsentPreview.test.ts new file mode 100644 index 00000000000..86365a57f16 --- /dev/null +++ b/src/renderer/src/components/settings/PluginKeybindingConsentPreview.test.ts @@ -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') + }) +}) diff --git a/src/renderer/src/components/settings/PluginKeybindingConsentPreview.tsx b/src/renderer/src/components/settings/PluginKeybindingConsentPreview.tsx new file mode 100644 index 00000000000..cde5f0aacb3 --- /dev/null +++ b/src/renderer/src/components/settings/PluginKeybindingConsentPreview.tsx @@ -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 ( +
+
+ + + {translate( + 'auto.components.settings.PluginKeybindingConsentPreview.warning', + 'This plugin adds keyboard shortcuts. Plugin shortcuts take priority over matching Orca shortcuts while the plugin is enabled.' + )} + +
+ +
+ {bindings.map(({ command, keybinding }) => { + const shadowed = shadowedKeybindingTitles(keybinding.key, platform, overrides) + return ( +
+
+ {command.title} + + {formatKeybindingList([keybinding.key], platform)} + +
+

+ {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.' + )} +

+ {shadowed.length > 0 ? ( +

+ + + {translate( + 'auto.components.settings.PluginKeybindingConsentPreview.shadows', + 'Replaces: {{value0}}', + { value0: shadowed.join(', ') } + )} + +

+ ) : null} +
+ ) + })} +
+
+ ) +} diff --git a/src/renderer/src/components/settings/ShortcutsPane.tsx b/src/renderer/src/components/settings/ShortcutsPane.tsx index b6fe5b3556c..fccc53d737d 100644 --- a/src/renderer/src/components/settings/ShortcutsPane.tsx +++ b/src/renderer/src/components/settings/ShortcutsPane.tsx @@ -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>>({}) const [recordingActionId, setRecordingActionId] = useState(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() - 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( () => 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 => { 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) diff --git a/src/renderer/src/components/settings/shortcut-definition-catalog.ts b/src/renderer/src/components/settings/shortcut-definition-catalog.ts new file mode 100644 index 00000000000..882f74ab7fc --- /dev/null +++ b/src/renderer/src/components/settings/shortcut-definition-catalog.ts @@ -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 + ignoredConflictActionIds: KeybindingActionId[] + conflictByAction: Map +} + +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() + 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 + } +} diff --git a/src/renderer/src/components/settings/shortcut-groups.test.ts b/src/renderer/src/components/settings/shortcut-groups.test.ts new file mode 100644 index 00000000000..1feea91574b --- /dev/null +++ b/src/renderer/src/components/settings/shortcut-groups.test.ts @@ -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') + ]) + }) +}) diff --git a/src/renderer/src/components/settings/shortcut-groups.ts b/src/renderer/src/components/settings/shortcut-groups.ts index 48eef6640f9..054a95baf27 100644 --- a/src/renderer/src/components/settings/shortcut-groups.ts +++ b/src/renderer/src/components/settings/shortcut-groups.ts @@ -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() - for (const definition of KEYBINDING_DEFINITIONS) { + for (const definition of [...KEYBINDING_DEFINITIONS, ...additionalDefinitions]) { if (hiddenAgentActionIds.has(definition.id)) { continue } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 4fe4946eb35..7241d5037c2 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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" } } }, diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index c14592f3bf8..8c5cfb411ba 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -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" } } }, diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index b7330adea34..a0bbeb5953b 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -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": "プラグインのコマンド" } } }, diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index a67c34c6601..1d5a6e2052e 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -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": "플러그인 명령" } } }, diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 4d758a565d6..67a3b5a6484 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -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": "插件命令" } } }, diff --git a/src/renderer/src/lib/app-command-dispatch.test.ts b/src/renderer/src/lib/app-command-dispatch.test.ts new file mode 100644 index 00000000000..f898dd7cd89 --- /dev/null +++ b/src/renderer/src/lib/app-command-dispatch.test.ts @@ -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) + }) +}) diff --git a/src/renderer/src/lib/app-command-dispatch.ts b/src/renderer/src/lib/app-command-dispatch.ts new file mode 100644 index 00000000000..f9725aca8c7 --- /dev/null +++ b/src/renderer/src/lib/app-command-dispatch.ts @@ -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 +} diff --git a/src/renderer/src/lib/plugin-command-execution.test.ts b/src/renderer/src/lib/plugin-command-execution.test.ts new file mode 100644 index 00000000000..b8551c07845 --- /dev/null +++ b/src/renderer/src/lib/plugin-command-execution.test.ts @@ -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' + }) + }) +}) diff --git a/src/renderer/src/lib/plugin-command-execution.ts b/src/renderer/src/lib/plugin-command-execution.ts new file mode 100644 index 00000000000..575725a3ed1 --- /dev/null +++ b/src/renderer/src/lib/plugin-command-execution.ts @@ -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 { + 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 + }) +} diff --git a/src/renderer/src/lib/plugin-command-keybindings.test.ts b/src/renderer/src/lib/plugin-command-keybindings.test.ts new file mode 100644 index 00000000000..b4ddcb00dae --- /dev/null +++ b/src/renderer/src/lib/plugin-command-keybindings.test.ts @@ -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 + ) + }) +}) diff --git a/src/renderer/src/lib/plugin-command-keybindings.ts b/src/renderer/src/lib/plugin-command-keybindings.ts new file mode 100644 index 00000000000..7d85093b581 --- /dev/null +++ b/src/renderer/src/lib/plugin-command-keybindings.ts @@ -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 +): 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 +} diff --git a/src/renderer/src/store/plugin-panels.test.ts b/src/renderer/src/store/plugin-panels.test.ts index b230fef47a7..44eb7e3a91d 100644 --- a/src/renderer/src/store/plugin-panels.test.ts +++ b/src/renderer/src/store/plugin-panels.test.ts @@ -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'), diff --git a/src/renderer/src/store/plugin-panels.ts b/src/renderer/src/store/plugin-panels.ts index dbfb86037fd..56c168ce46f 100644 --- a/src/renderer/src/store/plugin-panels.ts +++ b/src/renderer/src/store/plugin-panels.ts @@ -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]) +} diff --git a/src/shared/keybindings.test.ts b/src/shared/keybindings.test.ts index 7437d19dd82..ff92c679996 100644 --- a/src/shared/keybindings.test.ts +++ b/src/shared/keybindings.test.ts @@ -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', { diff --git a/src/shared/keybindings.ts b/src/shared/keybindings.ts index daa8721a973..c3750cdb484 100644 --- a/src/shared/keybindings.ts +++ b/src/shared/keybindings.ts @@ -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> @@ -190,6 +192,7 @@ export type KeybindingConflict = { export type FindKeybindingConflictOptions = { ignoredActionIds?: Iterable + relevantActionIds?: Iterable } 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 +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 = { 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 }>() 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 diff --git a/src/shared/plugins/plugin-command-actions.ts b/src/shared/plugins/plugin-command-actions.ts new file mode 100644 index 00000000000..d1645c7fcc6 --- /dev/null +++ b/src/shared/plugins/plugin-command-actions.ts @@ -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(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}` +} diff --git a/src/shared/plugins/plugin-content-pack-contributions.test.ts b/src/shared/plugins/plugin-content-pack-contributions.test.ts index b96d3336d76..af6df25fbe7 100644 --- a/src/shared/plugins/plugin-content-pack-contributions.test.ts +++ b/src/shared/plugins/plugin-content-pack-contributions.test.ts @@ -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) } }) }) diff --git a/src/shared/plugins/plugin-content-pack-contributions.ts b/src/shared/plugins/plugin-content-pack-contributions.ts index f3e18ca2a54..ba81e9da2f5 100644 --- a/src/shared/plugins/plugin-content-pack-contributions.ts +++ b/src/shared/plugins/plugin-content-pack-contributions.ts @@ -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() diff --git a/src/shared/plugins/plugin-language-pack-artifact.test.ts b/src/shared/plugins/plugin-language-pack-artifact.test.ts index 6ccc6cc7c96..7953ae072a7 100644 --- a/src/shared/plugins/plugin-language-pack-artifact.test.ts +++ b/src/shared/plugins/plugin-language-pack-artifact.test.ts @@ -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'] } }], diff --git a/src/shared/plugins/plugin-language-pack-artifact.ts b/src/shared/plugins/plugin-language-pack-artifact.ts index 2813d10b9b0..fbf0c5561ae 100644 --- a/src/shared/plugins/plugin-language-pack-artifact.ts +++ b/src/shared/plugins/plugin-language-pack-artifact.ts @@ -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', diff --git a/src/shared/plugins/plugin-manifest-contribution-validation.ts b/src/shared/plugins/plugin-manifest-contribution-validation.ts index 8cbe4417e91..2d3807d18d0 100644 --- a/src/shared/plugins/plugin-manifest-contribution-validation.ts +++ b/src/shared/plugins/plugin-manifest-contribution-validation.ts @@ -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() + 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 &&