PluginsEarly
Write a plugin
Build a small plugin step by step, with a command, a statusline segment and a panel, then test it and run it in the app.
This page builds a plugin called greeter. It starts with a command and a
statusline segment, then gains a panel that lists the project’s Markdown files.
You need gr and a project open in Graphide; nothing else to install.
Start from the scaffold
gr plugin new greeter --adds command,status --dir greeter
cd greeter
--adds takes command, panel, status, picker, badges and tool,
separated by commas. Without --dir the folder goes in the plugins folder
beside your settings file. You get:
| File | What it is |
|---|---|
plugin.toml |
The manifest: the plugin’s name, what it adds and what it may do |
plugin.tsx |
The script |
plugin.test.ts |
Its tests |
plugin.d.ts |
The interface’s types, for your editor |
tsconfig.json |
Points TypeScript at plugin.d.ts |
fixtures/files/ |
A pretend workspace the tests and previews read |
The manifest
Without its comments, plugin.toml says:
name = "greeter"
version = "0.1.0"
api = "0.1"
description = "Greeter: say here what it does and how to use it; agents search this."
main = "plugin.tsx"
[allow]
[[commands]]
name = "greeter.hello"
title = "Greeter: say hello"
description = "Says hello on the message line."
[[status]]
id = "greeter.status"
side = "right"
order = 50
api is the version of the plugin interface the plugin is written for. The
[[commands]] table adds a command to the palette, and [[status]] a segment
to the statusline. Every id starts with the plugin’s name and a dot. [allow]
is empty: this plugin may do nothing beyond drawing what it adds, so it asks
for nothing. plugin.toml has every key.
The script
import type Plugin from "graphide";
const plugin: Plugin = (on, $) => {
on("command", { command: "greeter.hello" }, () => [
{ cmd: "message", text: "Hello from greeter" },
]);
on("load", () => {
$.status.set("greeter.status", { text: "greeter" });
});
};
export default plugin;
The script’s default export is called once each time the plugin loads, with two arguments:
onregisters a handler for an event. Here,commandruns when someone runsgreeter.hello, andloadruns when the plugin starts.$is everything the plugin can call, its methods.$.status.setfills the segment.
A handler asks the app to act by returning
commands: here one message, shown on
the message line. The script never touches the window itself.
Test it
import { test } from "graphide/test";
test("hello says hello", async (t) => {
await t.command("greeter.hello");
t.expect(t.commands()).toEqual([{ cmd: "message", text: "Hello from greeter" }]);
});
Each test runs on a fresh copy of the plugin in a pretend host, with fake
time, no network and the files in fixtures/files/. Run the checks and the
tests together:
gr plugin check . --test
ok plugin.test.ts: hello says hello (6 ms)
greeter passes (hash 8de468210659)
gr plugin check reads the manifest and the script and names any problem at
its line. Test and debug has the rest of the test API.
Run it in the app
gr plugin install .
gr plugin approve greeter
gr plugin approve shows you what you are approving and asks you to confirm,
on the terminal. You can approve in the app instead, where the plugin shows in
the list of what needs you.
Now open the command palette (Ctrl+Shift+P), run Greeter: say hello, and see the message. The statusline shows greeter at the right.
Add a panel
Next, a panel that lists the project’s Markdown files and opens one when you
choose it. Reading files is something the plugin must ask for, so the manifest
gains a request in [allow], a [[panels]] table, and an event to hear when
files change. Add a default key while you are there:
name = "greeter"
version = "0.1.0"
api = "0.1"
description = "Greeter: says hello, and lists the project's Markdown files in a panel."
main = "plugin.tsx"
events = ["files.changed"]
[allow]
fs = ["read:workspace"]
[[commands]]
name = "greeter.hello"
title = "Greeter: say hello"
description = "Says hello on the message line."
[[status]]
id = "greeter.status"
side = "right"
order = 50
[[panels]]
id = "greeter.notes"
title = "Notes"
icon = "search"
dock = "right"
[keys.normal]
"<leader>xh" = "greeter.hello"
A default key is bound only where your own keys leave it free.
The script reads the files when it loads and again when they change, keeps the
list in $.state, and draws it when the panel asks for its view:
import type Plugin from "graphide";
const plugin: Plugin = (on, $) => {
on("command", { command: "greeter.hello" }, () => [
{ cmd: "message", text: "Hello from greeter" },
]);
async function scan() {
$.state.set("notes", await $.fs.files("**/*.md", { limit: 200 }));
}
on("load", async () => {
$.status.set("greeter.status", { text: "greeter" });
await scan();
});
on("files.changed", scan);
on("view", { view: "greeter.notes" }, () => {
const notes = $.state.get<string[]>("notes") ?? [];
return (
<col>
<text bold>Markdown in this project</text>
<list key="notes" empty="No Markdown files here">
{notes.map((path) => (
<item key={path} open={{ path }}>
{path}
</item>
))}
</list>
</col>
);
});
};
export default plugin;
A view is built from elements, written as
JSX: col, text, list and item here. The plugin never chooses a colour
or a font; the app draws each element in its own look. A view that reads
$.state is drawn again whenever that state is set, so scan needs nothing
else to update the panel. open={{ path }} on an item opens that file when
it is chosen, with no handler.
Run the checks again:
plugin.toml:6:1: error api.newer: events[0]: the event "files.changed" arrived in api 0.2; this plugin says api = "0.1" (set api = "0.2")
files.changed is newer than the interface version the scaffold wrote. Set
api = "0.2" and the checks pass. The reference marks everything that arrived
after 0.1 with its version.
Test the panel
Add a test that lays files over the fixtures and reads the panel as text:
test("the panel lists Markdown files only", async (t) => {
t.fixtures({ files: { "README.md": "# Hi", "docs/guide.md": "Guide", "main.go": "package main" } });
const v = await t.mount("greeter.notes", { size: [400, 300] });
t.expect(v.text()).toContain("docs/guide.md");
t.expect(v.text()).not.toContain("main.go");
});
gr plugin check . --test
ok plugin.test.ts: hello says hello (13 ms)
ok plugin.test.ts: the panel lists Markdown files only (13 ms)
the tests used: fs:read:workspace
greeter passes (hash f813e0398311)
To see a view without the app, draw it as text:
gr plugin render . --view greeter.notes --text
Markdown in this project
> notes.md
Work on it live
Each change to the files is a new version, and a new version waits for approval. While you work on a plugin, let it load as it stands instead:
gr plugin dev .
The plugin loads now and again on every save, without asking each time. Dev
mode grants nothing new: a save that asks for more runs without it until you
approve, and a save that fails to load leaves the last good version running.
It ends with gr plugin dev --off, after eight hours, or when the daemon
stops.
The new fs request needs your approval once: run gr plugin approve greeter
again. The panel’s id, greeter.notes, is also the command that shows and
hides it: find it in the command palette, or bind a key to it.
Where to go next
- plugin.toml: every key, and every kind of thing a plugin can add.
- What a plugin may do: every request a plugin can make and what each allows.
- Test and debug: fixtures, the test API, logs and status.
- Share a plugin: with a team, through git, or in the store.
- The API reference: every element, event and method.
gr plugin api prints the same reference on your machine, for the version you
have, and the scaffold’s plugin.d.ts gives your editor the types.
These docs describe Graphide 0.2.16, the latest release. Something wrong or missing? Tell us onDiscord or at hello@graphide.com.