Skip to content
DocsWrite a plugin

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:

  • on registers a handler for an event. Here, command runs when someone runs greeter.hello, and load runs when the plugin starts.
  • $ is everything the plugin can call, its methods. $.status.set fills 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

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.