feat(plugins): notifications with command buttons (#1974)

Adds API 1.3 `ui.notify(level, title, body, actions) -> result<_, error>`: up to three buttons, each running one of the plugin's declared commands with an optional argument in the balloon's window and dismissing the balloon. An invalid action or an undeclared command fails the call and shows nothing. The plugin `Notification` event carries the actions; SDK `host::notify_with_actions` and `NotificationAction`. Closes #1955.

Co-authored-by: arnaud-ritti <[email protected]>
Цей коміт міститься в:
Arnaud Ritti authored and GitHub committed 2026-11-07 22:35:36 +08:00
1 джерело f3837e0fe7
коміт 112ed773fb
43 files changed
+419 -39

No files matched your search

+10 -1
Переглянути файл
@@ -5730,6 +5730,13 @@ export type NotebookSnapshot = {
cells: NotebookCell[],
};
/** A notification button that runs one of the publishing plugin's commands. */
export type NotificationAction = {
label: string,
command: string,
argument: string | null,
};
export type NotificationGroupSettings = {
/** `balloon` | `sticky` | `none` */
display?: string,
@@ -5944,7 +5951,9 @@ export type PluginEvent =
* The marketplace offers newer versions of installed plugins (announced
* once per set of versions).
*/
{ type: "updatesAvailable"; updates: PluginUpdate[] } | { type: "notification"; pluginId: string; level: NotificationLevel; title: string; body: string } |
{ type: "updatesAvailable"; updates: PluginUpdate[] } | { type: "notification"; pluginId: string; level: NotificationLevel; title: string; body: string;
/** Buttons running the plugin's own commands (`ui.notify`). */
actions: NotificationAction[] } |
/** A plugin's status bar item was set (`item`) or removed. */
{ type: "statusItemChanged"; pluginId: string; id: string; item: StatusItem | null } |
/**
+31 -2
Переглянути файл
@@ -488,12 +488,41 @@ describe("bridge events", () => {
expect(useTasks.getState().tasks.find((t) => t.id === "plugin:7")?.done).toBe(true);
});
it("runs notification buttons as commands of the publishing plugin with their arguments", async () => {
const runCommand = vi.fn().mockResolvedValue(null);
setPluginsBackend({ ...backend, runCommand });
handlePluginEvent({
type: "notification",
pluginId: "hello",
level: "info",
title: "Done",
body: "Pick an action",
actions: [
{ label: "Open", command: "insert-date", argument: "first\nsecond" },
{ label: "Refresh", command: "word-count", argument: null },
{ label: "Undo", command: "insert-date", argument: "undo" },
],
});
expect(notes[0]?.actions?.map((a) => a.label)).toEqual(["Open", "Refresh", "Undo"]);
notes[0]!.actions![0]!.run();
await waitFor(() => expect(runCommand).toHaveBeenCalledWith("hello", "insert-date", "first\nsecond", null, []));
});
it("shows plugin notifications, copies to clipboard, opens URLs and tracks theme changes", () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true });
const open = vi.spyOn(window, "open").mockImplementation(() => null);
handlePluginEvent({ type: "notification", pluginId: "p", level: "info", title: "Done", body: "3 words" });
expect(notes).toEqual([{ group: "plugins", level: "info", title: "Done", body: "3 words", verbatim: true }]);
handlePluginEvent({
type: "notification",
pluginId: "p",
level: "info",
title: "Done",
body: "3 words",
actions: [],
});
expect(notes).toEqual([
{ group: "plugins", level: "info", title: "Done", body: "3 words", verbatim: true, actions: [] },
]);
handlePluginEvent({ type: "copyToClipboard", pluginId: "p", text: "https://github.com/o/r" });
expect(writeText).toHaveBeenCalledWith("https://github.com/o/r");
handlePluginEvent({ type: "openUrl", pluginId: "p", url: "https://github.com/o/r/blob/main/a.rs" });
+8 -1
Переглянути файл
@@ -540,7 +540,14 @@ export function createMemoryPluginsBackend(
if (commandId === "word-count") {
if (!activeFile) throw new IpcError({ kind: "internal", message: "no file is open in the editor" });
const name = activeFile.split("/").pop() ?? activeFile;
emit({ type: "notification", pluginId, level: "info", title: `Word count: ${name}`, body: "(simulated)" });
emit({
type: "notification",
pluginId,
level: "info",
title: `Word count: ${name}`,
body: "(simulated)",
actions: [],
});
return null;
}
throw new IpcError({ kind: "internal", message: `unknown command: ${commandId}` });
+12 -1
Переглянути файл
@@ -36,7 +36,14 @@ import { defineBuiltinUiPoints } from "./points";
import { localizeContributions, loadPluginCatalogs, usePluginCatalogs } from "./pluginText";
import { clearUiContributions, syncUiContributions } from "./projection";
import { handleRuntimeEvent, refreshEventKinds, startRuntimeUi } from "./runtimeUi";
import { grantPlugin, refreshPlugins, selectPluginTheme, updateAllPlugins, usePlugins } from "./store";
import {
grantPlugin,
runPluginCommand,
refreshPlugins,
selectPluginTheme,
updateAllPlugins,
usePlugins,
} from "./store";
export { pluginActionId } from "./points";
@@ -284,6 +291,10 @@ export function handlePluginEvent(e: PluginEvent) {
title: e.title || name,
body: e.body || undefined,
verbatim: true,
actions: e.actions.map((a) => ({
label: a.label,
run: () => void runPluginCommand(e.pluginId, a.command, a.argument),
})),
});
break;
}