Skip to content

Menus, toolbars and command placements

Commands are declared once and placed anywhere. The flow does not change: a menu entry or toolbar button emits the command’s event, a handler updates the store, and a state source derives what the entry should look like.

import { fromSlice } from "@/kernel"; // bundled plugins; runtime plugins receive nothing extra
// 1. Declare the command, with an icon and (optionally) a default state.
ctx.registerCommand({
id: "notes.save",
title: ctx.i18n.key("command.save"), // shown in the palette, menus, tooltips
shortcut: "mod+s",
event: "notes/document.save",
icon: Save,
state: fromSlice(slice, (state, context) => ({
enabled: Boolean(targetDoc(context, state)?.dirty),
icon: targetDoc(context, state)?.dirty ? Save : CheckCircle2,
})),
});
// 2. Place it.
ctx.place({ commandId: "notes.save", menu: "file", group: "save", order: 30 });
ctx.place({ commandId: "notes.save", toolbar: "view:notes:editor", payload: docPayload });
ctx.place({ commandId: "notes.save", menu: "notes.document", payload: docPayload });
// 3. Menus: top-level (menubar), submenu, or standalone (context / dropdown).
ctx.registerMenu({ id: "notes", title: ctx.i18n.key("menu.notes"), order: 20 });
ctx.registerMenu({ id: "notes.recent", parent: "notes", title: ctx.i18n.key("menu.recent") });
ctx.registerMenu({ id: "notes.document", title: ctx.i18n.key("menu.document"), standalone: true });
Target Rendered by
menu: "file" / "view" Shell menubar (File, View are built in)
menu: "<your-menu>" Your own menubar menu, submenu or context menu
toolbar: "shell:main" Global toolbar under the top bar
toolbar: "view:<pluginId>:<viewId>" Above the active editor tab of that view
toolbar: "panel:<pluginId>:<contributionId>" Header of that side/bottom panel
toolbar: "<anything>" Wherever you render <ctx.ui.Toolbar id=… />
place({ …, dropdown: "menuId" }) Toolbar button that opens a menu instead

Ids are namespaced: a view registered as editor by plugin notes is notes:editor, a contribution documents is notes:documents.

A state source is { subscribe(listener), get(context) } returning any of { enabled, checked, icon, title }:

  • enabled: false greys the entry, disables the button, and blocks the shortcut and the palette.
  • checked turns the entry into a checkbox item and the button into a pressed toggle (the shell’s panel toggles work this way).
  • icon / title swap the visual per state (e.g. a check mark once saved).

Helpers: fromSlice(slice, derive), fromObservable(observable, derive), combineStates(a, b). A state on a placement overrides the command’s own state for that placement only.

Every placement receives a context: the editor toolbar passes { tabId, params } of the active tab, ctx.ui.ContextMenu passes whatever you give it. Both payload and state.get may be functions of that context, so one command can target “the active document” from the File menu and “the document under the pointer” from a context menu.

const { ContextMenu, Toolbar } = ctx.ui;
<ContextMenu menuId="notes.document" context={{ docId: doc.id }}>
<li>{doc.title}</li>
</ContextMenu>
<Toolbar id="notes:inline" context={{ docId }} />

Hovering a toolbar button shows the command title and its shortcut. Adjacent entries with a different group are separated. Everything a plugin registers is removed when it is disabled.

Documentation under CC BY 4.0 · © SCFlow contributors ·Back to supplychainflow.org