Files
orca/src/main/plugins/plugin-command-replacement-owners.ts
T
Neil 999160b3db feat(plugins): commands[].replaces lets a mod take over a built-in action (#37316)
* plugins: commands[].replaces lets a worker command take over a built-in action

A worker command with replaces: <action> (the declarative action list,
engines >=1.4.248, needs main) runs in place of that built-in from its
shortcut, plugin aliases and the sidebar menus; a failed or silent (2 s)
worker falls back to Orca's own. The install screen names each replacement,
the plugin page has a per-action switch (settings.pluginReplacementsOff), and
when two plugins replace one action the one installed first wins and the
other's log says so. Update review counts replaces as new access.

* fix(plugins): narrow replaced action ids before looking up their titles

* plugins: harden command replaces after review

- Consent includes the replaced actions, so adding replaces to a dev folder
  needs a new approval; CLI approval and update review name each takeover.
- Shortcuts main intercepts (Tasks, Workspace Board, history), search with a
  selection and floating maximize now go through the replacement too.
- A worker that only starts after the 2 s fallback no longer runs the
  command as well: main refuses to start it past the deadline.
- A workspace-only command does not replace an action without a workspace.
- An update keeps the plugin's first install time, so it keeps its takeover.
- Drop the commands.run promise until Orca offers it.

* plugins: commands.run always runs the built-in, never a replacement

A5b's commands.run dispatches with the 'plugin-host' source; the app
dispatcher now builds handlers that run Orca's own action for it, so a
plugin that replaces an action and calls commands.run cannot loop. Docs and
schema describe wrapping the built-in with commands.run; regenerated.
2026-11-01 21:59:27 -08:00

56 lines
2.1 KiB
TypeScript

import type { PluginHostListEntry } from '../../shared/plugins/plugin-list-entry'
import type { PluginLockfile } from '../../shared/plugins/installation/plugin-install-lockfile'
const LIVE = new Set(['running', 'idle', 'restarting'])
// Conflicts already logged, so a list refresh does not repeat them.
const logged = new Set<string>()
/**
* One plugin per built-in action: the one installed first replaces it, later ones are marked
* `replacedBy` and the conflict is logged to each loser's plugin log. Development folders, which
* have no install time, come after installed plugins.
*/
export function markCommandReplacementOwners(
entries: PluginHostListEntry[],
lock: Pick<PluginLockfile, 'plugins'>,
log: (pluginKey: string, line: string) => void
): PluginHostListEntry[] {
const installedAt = (key: string) => lock.plugins[key]?.installedAt ?? Number.POSITIVE_INFINITY
const owners = new Map<string, string>()
const contenders = entries
.filter((entry) => LIVE.has(entry.status))
.toSorted(
(a, b) =>
installedAt(a.pluginKey) - installedAt(b.pluginKey) ||
a.pluginKey.localeCompare(b.pluginKey)
)
for (const entry of contenders) {
for (const command of entry.commands) {
if (command.replaces !== undefined && !owners.has(command.replaces)) {
owners.set(command.replaces, entry.pluginKey)
}
}
}
return entries.map((entry) => {
if (!LIVE.has(entry.status) || !entry.commands.some((command) => command.replaces)) {
return entry
}
const commands = entry.commands.map((command) => {
const owner = command.replaces === undefined ? undefined : owners.get(command.replaces)
if (owner === undefined || owner === entry.pluginKey) {
return command
}
const conflict = `${entry.pluginKey}/${command.id}\0${owner}`
if (!logged.has(conflict)) {
logged.add(conflict)
log(
entry.pluginKey,
`command ${command.id} does not replace ${command.replaces}: ${owner} was installed first and replaces it`
)
}
return { ...command, replacedBy: owner }
})
return { ...entry, commands }
})
}