Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Plugins

Plugins add commands, key bindings, hooks and more, written in Lua like config.lua. They work like Neovim's: a plugin is a folder with lua/<name>/init.lua, and you set it up from config.lua.

Adding a plugin

The quickest way is the Plugins tab (:plugins): Browse lists the plugins in riptide-plugins (or the repository plugins.catalog names) with what each asks for, and Add installs one. You can also paste a git URL and the folder in it, or run :pack-add <url> [folder]. Plugins added this way are kept in plugins.toml in the config folder, which riptide writes for you:

# plugins.toml
[[plugin]]
src = "https://github.com/joshzcold/riptide-plugins"
subdir = "keepassxc"
opts = { database = "~/Passwords.kdbx" }  # options, which you can add by hand

Remove on the tab takes such a plugin out of plugins.toml again. For more control, add plugins in config.lua instead; if both name the same plugin, config.lua's wins:

-- config.lua
rt.pack.add({
  "https://github.com/someone/riptide-tab-tools",
  { "https://github.com/someone/reading-list", version = "v1.2", opts = { folder = "~/Reading" } },
  { dir = "~/code/my-plugin", config = function() require("my-plugin").setup() end },
})

A spec is a git URL, or a table:

Key
"url" or srcWhere to clone it from. riptide installs it into <data>/pack/<name> the first time, in the background.
versionA tag, branch or commit to install; by default the newest commit.
subdirThe plugin's folder in a repository of several, e.g. subdir = "passwords"; its name is the folder's by default.
dirA folder on your computer instead (~/ works), for writing your own.
nameIts name, which require uses; by default the URL's or folder's last part, without a riptide- prefix or a .nvim-style suffix.
optsPassed to require(name).setup(opts) once it loads.
configA function run once it loads, instead of opts.
trusted = trueSkip the sandbox and give it everything, as Neovim does. Only for code you vouch for.
event, cmd, keysLoad it only when needed; see below.

Plugins load after config.lua has run, so set them up with opts or config rather than calling require straight away.

Loading when needed

A plugin with event, cmd or keys loads only once you need it, which keeps startup quick:

rt.pack.add({
  { "https://github.com/someone/reading-list", cmd = { "read-later", "reading-list" }, opts = {} },
  { dir = "~/code/tab-tools", keys = { "<Space>t", { "<C-t>", mode = "insert" } }, opts = {} },
  { dir = "~/code/history-sync", event = "window_closed", opts = {} },
})
KeyLoads it
cmdwhen you run one of these commands; it then runs with your arguments
keyswhen you press one of these keys (normal mode unless a mode is given); the keys are pressed again for the plugin's own binding
eventbefore the first of these events' hooks run, so the plugin's hooks see it too

Its permissions are still asked for at startup, so a key or command never stops to ask. The Plugins tab lists what each one waits for, and Load now or :pack-load <name> loads it straight away.

Versions and the lockfile

rt-pack-lock.json in the config folder pins each plugin from git to the commit installed, along with the permissions you approved, so a plugin never changes on its own and the same config gives the same plugins on every computer. Keep it with your dotfiles: on another computer, riptide installs exactly the pinned commits, and if the lockfile moves a plugin to another commit, riptide checks that one out at the next start, or straight away with :pack-restore. Plugins from one repository share a checkout, so they're always on the same commit and update together. Git runs without asking for passwords, so a private repository fails with a message instead of waiting.

CommandPlugins tabDoes
:pack-check [name]Check all, Check for updatesfetches and lists each plugin's new commits; nothing changes yet
Update, Update allmoves to the commits a check listed, the ones you read
:pack-update [name]fetches and moves straight to the newest commits
:pack-syncSynccleans, updates everything and installs what's missing
:pack-restoreRestoreputs every plugin back on its commit in rt-pack-lock.json
:pack-cleanCleandeletes checkouts and lockfile entries of plugins no longer in config.lua

Every update is recorded in rt-pack-lock.json and reloads your config once; if a new version asks for more permissions, you're asked about those first.

Every plugins.check_interval days (1 by default; 0 turns it off), riptide checks in the background a few seconds after it starts and says which plugins have updates, such as "Plugin updates for pass, passwords; review them on :plugins". It mentions each update once, so a plugin you leave as it is doesn't come up again until it has newer commits, and the check changes nothing.

The plugins page

:plugins opens the Plugins tab of :settings: each plugin with where it comes from, the commit it's on, whether it loaded (and the error if it didn't) and the permissions you approved, with the buttons above.

To review before updating, press Check all (or :pack-check), read each plugin's new commits on the page, then press Update on one or Update all. Revoke forgets the permissions you approved, so the plugin asks again. Remove deletes a plugin's lockfile entry and its checkout, unless another plugin uses it; take it out of config.lua too, or it's installed again at the next start.

Permissions

Plugins run in a sandbox. Without asking, a plugin can react to events, bind keys to its functions, add commands, use timers, show messages, ask you questions (rt.ui, which names the plugin asking), open URLs and keep its own data (rt.store, which other plugins can't read). Anything more is a permission it lists in its riptide-plugin.toml, and the first time it loads, riptide shows them and asks you:

PermissionLets it
spawnrun programs on your computer (rt.spawn)
filesread and write your files (Lua's io and os)
commandsrun any riptide command (rt.run) and bind keys to command lines; this includes running programs
settingsread and change your settings (rt.get, rt.set, c)
clipboardread and write the clipboard
keyssee every key you press
network = ["host", …]connect to these hosts
pages = ["*.example.com", …]type and fill logins into pages on these sites (rt.page); ["*"] is every site
frames = ["host", …]show these sites inside its own pages

Your answer is kept in rt-pack-lock.json in the config folder, so it's asked once; a new version that asks for more asks again, for the new permissions only. Saying no leaves the plugin unloaded. To be asked again, press Revoke on :plugins, or delete its entry in rt-pack-lock.json.

In the sandbox, load only runs text in the plugin's own globals, require only finds the plugin's own modules and those of the plugins it lists in dependencies (not your config's, nor other plugins'), and a plugin's changes to its rt table don't affect anyone else. Every callback, a plugin's included, is stopped after 2 seconds.

Writing a plugin

reading-list/
├── riptide-plugin.toml     what it is and what it needs
├── lua/reading-list/
│   └── init.lua            require("reading-list")
└── plugin/
    └── reading-list.lua    runs when it loads (optional)
# riptide-plugin.toml
name = "reading-list"
description = "Save pages to read later"
[permissions]
spawn = true
-- lua/reading-list/init.lua
local M = {}
function M.setup(opts)
  local list = rt.store()
  rt.keymap.set("normal", "<Space>r", function()
    local urls = list.get("urls") or {}
    table.insert(urls, rt.url())
    list.set("urls", urls)
    rt.notify("Saved for later")
  end, { desc = "Read later" })
end
return M

Options

A plugin lists its options in riptide-plugin.toml, and the Plugins tab shows a form for them; for a plugin added there, Save options keeps them in plugins.toml and reloads it, so setup(opts) gets them. For one from config.lua, the tab shows them and they go in rt.pack.add's opts.

[[option]]
name = "database"
type = "path"          # string (the default), path, number, bool, choice or secret
description = "Your .kdbx file"
required = true

[[option]]
name = "remember"
type = "number"
default = 0            # shown in the form; the plugin applies it when unset

[[option]]
name = "password"
type = "secret"
description = "The database password, so it doesn't ask"

A riptide older than the plugin ignores keys it doesn't know, such as [[option]] before 0.4, with a warning in its log; it still refuses a permission it doesn't know.

A secret option is never written to a file: the form saves it in your OS keyring (the macOS Keychain, Windows' Credential Manager, or the Secret Service, such as GNOME Keyring or KWallet, on Linux), and the plugin reads it when it needs it, with rt.secret.get("password", function(value, err) … end). A plugin can read only its own secret options, and only the ones its manifest declares. The Plugins tab shows whether a secret is set, never its value; Clear takes it out of the keyring. On Linux without a keyring service, saving a secret says so and the plugin can ask instead.

Dependencies

A plugin that builds on another lists it in riptide-plugin.toml:

dependencies = ["passwords"]

It loads once its dependencies have, and may require their modules; their functions run with their own permissions, not the dependent's. You don't have to add a dependency yourself: riptide installs it from the same repository (the folder beside it, for one from subdir) or from the folder beside a dir plugin, and asks for its permissions as usual. Add it to rt.pack.add only to pass it options. A plugin whose dependency is refused or fails doesn't load, and :plugins says why.

Pages

A plugin can ship HTML in a pages/ folder and open it in a tab with rt.ui.page({ path, on_message }). Its pages are served as riptide://<name>.plugin/…, each plugin its own origin, and they talk to the plugin, and only to it, with messages:

-- plugin/reading-list.lua
rt.command("reading-list", function()
  rt.ui.page({
    path = "index.html",
    on_message = function(name, data, page)
      if name == "ready" then page:send("urls", rt.store().get("urls") or {}) end
    end,
  })
end)
// pages/app.js, loaded with <script src="app.js"> from pages/index.html
addEventListener("rtmessage", (e) => {
  if (e.detail.name === "urls") render(e.detail.data);
});
rt.send("ready", JSON.stringify({}));

where = "panel" docks the page beside the page area instead, with side (left, right or bottom) and size in pixels: a sidebar that shows a web app in an iframe, say. Clicking a field in it types there, as in a tab, and :panel-focus moves the keyboard to it and back. page:close() closes it.

Pages run only their own files: inline scripts, other sites' scripts and eval are blocked. They may show other sites in iframes only over https and only for the hosts the frames permission lists, and connect to only the hosts network lists. A plugin's pages are served once it has loaded.

Testing

riptide --plugin-test DIR runs the plugin in DIR in a throwaway profile, with the permissions its manifest asks for already approved, and runs its test/*_spec.lua files. It prints TAP and exits with 1 if a test failed, so it works in CI under xvfb-run:

-- test/reading_list_spec.lua
describe("reading-list", function()
  it("saves the page", function()
    keys("<Space>r")
    wait_until(function() return last_message() end)
    assert.matches("^Saved", last_message())
  end)
end)
In a spec
describe(name, fn), it(name, fn), before_each(fn)group and name tests
assert(v), assert.equals(expected, actual), assert.same (deep), assert.truthy, assert.falsy, assert.matches(pattern, s), assert.has_error(fn, pattern)checks
keys("<Space>r"), run("open x")press riptide keys or run a command
wait(ms), wait_until(fn, timeout), wait_for(event, { pattern, timeout })let the browser work; wait_for returns the event
messages(), last_message(), clear_messages()what rt.notify showed, the plugin's included
page("login.html")the address of a file in the plugin's test/ folder, served as an ordinary web page (http://plugin-test.localhost/…)
plugin_test.dirthe plugin's folder, e.g. for opts.command = plugin_test.dir .. "/test/bin/fake-tool"

Secret options are kept in memory for the run, never in your keyring. Each test gets 10 seconds. test/config.lua, if there is one, replaces the default rt.pack.add({ dir = DIR, opts = {} }), to pass other options. riptide-plugin-template is a plugin to start from, with a spec and a GitHub workflow that runs it, and riptide-plugins collects plugins, each installed with subdir.

:help <name> shows a plugin's description, permissions and its doc/<name>.md (or README.md), as text.

The Lua page describes the API, and :help rt.ui.float (any rt. function) shows one function; errors name the plugin's file and line, e.g. reading-list/lua/reading-list/init.lua:7: ….