mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-07-06 22:55:56 +08:00
Co-authored-by: fullex <106392080+0xfullex@users.noreply.github.com> Signed-off-by: kangfenmao <kangfenmao@qq.com>
133 lines
7.4 KiB
Markdown
133 lines
7.4 KiB
Markdown
# Command System
|
||
|
||
The command system is the single source of truth for **what the app can do** and
|
||
the wiring that lets a keyboard shortcut, an application/context menu item, or a
|
||
button all trigger the same behavior.
|
||
|
||
It replaces three previously independent systems (keyboard shortcuts, the native
|
||
application menu, and ad‑hoc context menus), each of which used to maintain its
|
||
own definitions, key‑formatting, and dispatch wiring.
|
||
|
||
- [command-usage.md](./command-usage.md) — how to register handlers, contribute
|
||
menus, render command‑backed UI, and add a new command.
|
||
|
||
## Commands, shortcuts, and menus — the relationship
|
||
|
||
**A command is *what* the app does; a shortcut is one *way* to ask for it.** They
|
||
are deliberately separate concepts:
|
||
|
||
- A **command** is the unit of behavior, identified by a `CommandId` (e.g.
|
||
`topic.create`, `app.zoom.in`, `chat.message.search`). It owns the behavior and
|
||
knows nothing about how it was triggered.
|
||
- A **shortcut** is a key binding *for* a command. A **menu item** is a menu entry
|
||
*for* a command. A **button** invokes a command. All of them are just triggers
|
||
that resolve to a `CommandId` and run its handler.
|
||
|
||
```
|
||
keyboard shortcut ─┐
|
||
menu item ─────────┼──▶ CommandId ──▶ handler (renderer or main)
|
||
button / palette ──┘
|
||
```
|
||
|
||
Two consequences fall out of this split:
|
||
|
||
- **There are no free‑floating shortcuts.** Every shortcut, menu item, and button
|
||
resolves to a command. You never bind a key to an inline callback — you bind it
|
||
to a `CommandId`, and a surface registers the handler separately. Adding a new
|
||
way to trigger something never touches the behavior, and changing the behavior
|
||
never touches its triggers.
|
||
- **A command can have zero, one, or several triggers.** A command may be
|
||
menu‑only (no default key), keyboard‑only, or both; the keybinding even allows
|
||
`additionalBindings` (e.g. numpad zoom). The command is the same either way.
|
||
|
||
### How a command relates to its shortcut
|
||
|
||
| Concept | Where it lives | Example for `topic.create` |
|
||
| --- | --- | --- |
|
||
| Command definition | `COMMAND_DEFINITIONS` (`src/shared/command/definitions.ts`) | `{ id: 'topic.create', scope: 'renderer', keybinding: { defaultBinding: ['CommandOrControl','N'] } }` |
|
||
| Default key binding | the command's `keybinding.defaultBinding` | `Cmd/Ctrl + N` |
|
||
| **User override** | the preference `shortcut.<commandId>` | `shortcut.topic.create` → `{ binding, enabled }` |
|
||
| Handler | a surface via `useCommandHandler` (renderer) or a built‑in (main) | `useCommandHandler('topic.create', addNewTopic)` |
|
||
| Menu entry (optional) | a `MENU_CONTRIBUTIONS` entry | `{ location: 'chat.input.tools.context', command: 'topic.create' }` |
|
||
|
||
So there is exactly **one shortcut preference key per command** (`shortcut.<id>`):
|
||
the command's *default* binding comes from its definition, and the user's edit in
|
||
**Settings → Shortcuts** overrides it through that preference key. At runtime the
|
||
effective binding is "user preference if set, else the definition default".
|
||
|
||
A command's `scope: 'main' | 'renderer' | 'both'` decides where its handler runs
|
||
and who listens for its key: the main‑process global‑shortcut registrar
|
||
(`ShortcutService`) for main/global, or the per‑window keydown dispatcher
|
||
(`CommandProvider`) for renderer.
|
||
|
||
`COMMAND_DEFINITIONS` is the single source of truth — the `CommandId` union, the
|
||
keybinding rules, the per‑command `shortcut.<id>` key, and the `when`/`enablement`
|
||
context expressions are all derived from it. Menu contributions are a parallel
|
||
declaration (`MENU_CONTRIBUTIONS`) keyed by the same `CommandId`s.
|
||
|
||
## Architecture — three layers
|
||
|
||
### 1. Shared declarations — `src/shared/command/`
|
||
|
||
Pure data and pure functions, no Electron or React.
|
||
|
||
| File | Responsibility |
|
||
| --- | --- |
|
||
| `definitions.ts` | `COMMAND_DEFINITIONS` (SoT), the derived `CommandId`, `KEYBINDING_RULES`, `REGISTERED_KEYBINDINGS`, lookups |
|
||
| `keybindings.ts` | resolve a binding → command, default/effective shortcut preference, conflict detection, label formatting |
|
||
| `menus.ts` | `MENU_CONTRIBUTIONS`, the `MenuRegistry`, and `resolveMenuPresentationMode` |
|
||
| `contextExpr.ts` | parser/evaluator for `when`/`enablement` expressions + `ContextKeyService` |
|
||
| `types.ts` | all command/keybinding/menu/context types |
|
||
|
||
Token formatting (typed shortcut vocabulary, normalization, display/accelerator
|
||
formatting) lives in `src/shared/shortcuts/tokens.ts`; `src/shared/shortcuts/types.ts`
|
||
keeps only `ShortcutPreferenceKey` + `ResolvedShortcut`.
|
||
|
||
### 2. Main runtime — `src/main/services/`
|
||
|
||
| Service | Responsibility |
|
||
| --- | --- |
|
||
| `CommandService` | holds the main‑side handler registry; `execute(command, window?, ctx?)` with context evaluation; wires built‑in handlers (window/zoom/settings/quick‑assistant/selection); registers the native popup menu IPC (`NativeCommandPopupMenu_Show`) |
|
||
| `nativePopupMenu.ts` | stateless module — materializes a renderer‑supplied menu model into an Electron native popup and reports the chosen command back; `CommandService` injects the execute/gate callback |
|
||
| `ShortcutService` | registers `globalShortcut` accelerators from `REGISTERED_KEYBINDINGS` (non‑renderer scope) → `CommandService.execute` |
|
||
| `AppMenuService` | builds the macOS app menu from `menuRegistry.resolve({ location: 'app.menu' })` via `menu/adapters/nativeMenuAdapter` → `CommandService.execute` |
|
||
|
||
### 3. Renderer runtime — `src/renderer/features/command/`
|
||
|
||
| Piece | Responsibility |
|
||
| --- | --- |
|
||
| `CommandProvider` | one window‑level `keydown` dispatcher + the handler stack (`useCommandHandler`, `useCommandRuntime`) |
|
||
| `ContextKeyProvider` | window‑local context keys (`useCommandContextKey`) |
|
||
| `presentation.tsx` | `CommandShortcut`, `CommandTooltip`, `CommandButton`, `useResolvedCommand` |
|
||
| `menus.tsx` | `CommandContextMenu` — renders Cherry UI or a native popup based on `menu.presentation_mode` |
|
||
|
||
Mount `<ContextKeyProvider><CommandProvider>` once per renderer window — every
|
||
window root mounts it: `windows/main/MainApp.tsx` and `windows/subWindow/SubWindowApp.tsx`.
|
||
|
||
### Preferences
|
||
|
||
- `shortcut.<commandId>` — `PreferenceShortcutType` (`{ binding, enabled }`), the
|
||
editable binding per command. Generated through the data‑classify pipeline (see
|
||
[command-usage.md](./command-usage.md#adding-a-command)).
|
||
- `menu.presentation_mode` — `'cherry' | 'native'`. Read by `CommandProvider`
|
||
to choose the menu renderer. There is no settings UI for it yet (planned); it
|
||
currently defaults via the preference schema.
|
||
|
||
## Dispatch flows
|
||
|
||
- **Keyboard (renderer):** `keydown` → `CommandProvider` →
|
||
`getShortcutBindingFromKeyboardEvent` →
|
||
`resolveCommandByKeybinding({ scope: 'renderer', canExecuteCommand: hasHandler })`
|
||
→ active handler. While an editable target (`<input>`, `<textarea>`, or a
|
||
`contenteditable` element) is focused the dispatcher skips no-modifier
|
||
shortcuts so typing isn't hijacked; modifier shortcuts (Ctrl/Meta/Alt) still
|
||
fire. It only `preventDefault`s when a command with a registered handler
|
||
resolves.
|
||
- **Keyboard (global):** OS `globalShortcut` → `ShortcutService` →
|
||
`CommandService.execute(command, window)`.
|
||
- **Native menu:** renderer builds a `NativePopupMenuModel` →
|
||
`window.api.command.showNativePopupMenu` → `CommandService`'s
|
||
`NativeCommandPopupMenu_Show` handler → `showNativePopupMenu` (in
|
||
`nativePopupMenu.ts`). Main‑handled commands run there; renderer‑handled ones
|
||
are returned to the renderer runtime to execute.
|