Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions docs/docs/Features/Plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,44 @@ matcha.notify("Important!", 5) -- shows for 5 seconds
matcha.notify("Quick flash", 0.5) -- shows for half a second
```

### matcha.store_set(key, value)

Store a string value persistently for this plugin. Each plugin has its own isolated key/value space, so different plugins cannot read or overwrite each other's keys.

```lua
matcha.store_set("api_key", "sk-...")
matcha.store_set("last_seen_uid", "12345")
```

### matcha.store_get(key)

Retrieve a previously stored string value, or `nil` if the key does not exist.

```lua
local key = matcha.store_get("api_key")
if key then
matcha.log("found api key")
end
```

### matcha.store_delete(key)

Remove a key from this plugin's storage. Calling `store_delete` on a key that does not exist is a no-op.

```lua
matcha.store_delete("api_key")
```

### matcha.store_keys()

Return a 1-indexed table of all keys currently stored by this plugin. Useful for iterating over plugin state on startup.

```lua
for _, key in ipairs(matcha.store_keys()) do
matcha.log("stored key: " .. key)
end
```

## Events

### startup
Expand Down
20 changes: 20 additions & 0 deletions plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ end)
| `matcha.bind_key(key, area, description, callback)` | Register a custom keyboard shortcut for a view area (`"inbox"`, `"email_view"`, `"composer"`) |
| `matcha.http(options)` | Make an HTTP request (see below) |
| `matcha.prompt(placeholder, callback)` | Open a text input overlay in the composer (see below) |
| `matcha.store_set(key, value)` | Store a string value for this plugin |
| `matcha.store_get(key)` | Retrieve a stored string value, or `nil` |
| `matcha.store_delete(key)` | Delete a stored key for this plugin |
| `matcha.store_keys()` | Return a table of stored keys for this plugin |
| `matcha.style(text, opts)` | Wrap `text` in lipgloss styling and return an ANSI-styled string (see below) |

## Hook events
Expand Down Expand Up @@ -72,6 +76,22 @@ end
matcha.log("status: " .. res.status)
```

## Persistent storage

Plugins can store string key-value data between sessions. Storage is scoped per plugin and written to `~/.config/matcha/plugins/<plugin_name>/data.json`. Plugins that need structured values can encode them as strings.

```lua
local matcha = require("matcha")

-- Store a value
matcha.store_set("api_key", "sk-...")

-- Retrieve a value
local key = matcha.store_get("api_key")
```

Use `matcha.store_delete("api_key")` to remove a value. `matcha.store_keys()` returns a 1-indexed table of all keys stored by the current plugin.

## User input prompts

`matcha.prompt(placeholder, callback)` opens a text input overlay in the composer. When the user presses Enter, the callback receives their input string. Pressing Esc cancels without calling the callback.
Expand Down
5 changes: 5 additions & 0 deletions plugin/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ func (m *Manager) registerAPI() {
"bind_key": m.luaBindKey,
"http": m.luaHTTP,
"prompt": m.luaPrompt,
"store_set": m.luaStoreSet,
"store_get": m.luaStoreGet,
"store_delete": m.luaStoreDelete,
"store_keys": m.luaStoreKeys,
"style": m.luaStyle,
})

Expand Down Expand Up @@ -73,6 +77,7 @@ func (m *Manager) luaBindKey(L *lua.LState) int {
Area: area,
Description: description,
Fn: fn,
Plugin: m.currentPlugin,
})
default:
L.ArgError(2, "invalid area: must be \"inbox\", \"email_view\", or \"composer\"")
Expand Down
214 changes: 214 additions & 0 deletions plugin/api_storage_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
package plugin

import (
"os"
"path/filepath"
"strings"
"testing"

lua "github.com/yuin/gopher-lua"
)

func TestLuaStoreRoundTrip(t *testing.T) {
setTestHome(t)

m := newTestManager()
defer m.Close()
m.currentPlugin = "test_plugin"

err := m.state.DoString(`
local matcha = require("matcha")
matcha.store_set("token", "abc123")
result = matcha.store_get("token")
`)
if err != nil {
t.Fatal(err)
}

if got := m.state.GetGlobal("result"); got.String() != "abc123" {
t.Fatalf("expected abc123, got %q", got.String())
}
}

func TestLuaStoreSetWithoutPluginContext(t *testing.T) {
setTestHome(t)

m := newTestManager()
defer m.Close()

err := m.state.DoString(`
local matcha = require("matcha")
matcha.store_set("token", "abc123")
`)
if err == nil {
t.Fatal("expected store_set to fail without plugin context")
}
if !strings.Contains(err.Error(), "no plugin context") {
t.Fatalf("expected plugin context error, got %v", err)
}
}

func TestLuaStorePluginsAreIsolated(t *testing.T) {
setTestHome(t)

m := newTestManager()
defer m.Close()

pluginA := writePlugin(t, t.TempDir(), "a.lua", `
local matcha = require("matcha")
matcha.store_set("shared", "a")
`)
pluginB := writePlugin(t, t.TempDir(), "b.lua", `
local matcha = require("matcha")
matcha.store_set("shared", "b")
`)

m.loadPlugin("plugin_a", pluginA)
m.loadPlugin("plugin_b", pluginB)

storeA, err := newPluginStore("plugin_a")
if err != nil {
t.Fatal(err)
}
storeB, err := newPluginStore("plugin_b")
if err != nil {
t.Fatal(err)
}

gotA, ok := storeA.Get("shared")
if !ok {
t.Fatal("expected plugin_a key")
}
gotB, ok := storeB.Get("shared")
if !ok {
t.Fatal("expected plugin_b key")
}
if gotA != "a" {
t.Fatalf("expected plugin_a value a, got %q", gotA)
}
if gotB != "b" {
t.Fatalf("expected plugin_b value b, got %q", gotB)
}
}

func TestLuaStoreHookUsesRegisteredPluginContext(t *testing.T) {
setTestHome(t)

m := newTestManager()
defer m.Close()

pluginA := writePlugin(t, t.TempDir(), "a.lua", `
local matcha = require("matcha")
matcha.on("startup", function()
matcha.store_set("hook", "a")
end)
`)
pluginB := writePlugin(t, t.TempDir(), "b.lua", `
local matcha = require("matcha")
matcha.on("startup", function()
matcha.store_set("hook", "b")
end)
`)

m.loadPlugin("plugin_a", pluginA)
m.loadPlugin("plugin_b", pluginB)
m.CallHook(HookStartup)

assertStoredValue(t, "plugin_a", "hook", "a")
assertStoredValue(t, "plugin_b", "hook", "b")
}

func TestLuaStoreKeyBindingUsesRegisteredPluginContext(t *testing.T) {
setTestHome(t)

m := newTestManager()
defer m.Close()

pluginA := writePlugin(t, t.TempDir(), "a.lua", `
local matcha = require("matcha")
matcha.bind_key("ctrl+a", "inbox", "A", function()
matcha.store_set("binding", "a")
end)
`)
pluginB := writePlugin(t, t.TempDir(), "b.lua", `
local matcha = require("matcha")
matcha.bind_key("ctrl+b", "inbox", "B", function()
matcha.store_set("binding", "b")
end)
`)

m.loadPlugin("plugin_a", pluginA)
m.loadPlugin("plugin_b", pluginB)

bindings := m.Bindings(StatusInbox)
if len(bindings) != 2 {
t.Fatalf("expected 2 bindings, got %d", len(bindings))
}
for _, binding := range bindings {
m.CallKeyBinding(binding)
}

assertStoredValue(t, "plugin_a", "binding", "a")
assertStoredValue(t, "plugin_b", "binding", "b")
}

func TestLuaStoreKeysAndDelete(t *testing.T) {
setTestHome(t)

m := newTestManager()
defer m.Close()
m.currentPlugin = "test_plugin"

err := m.state.DoString(`
local matcha = require("matcha")
matcha.store_set("a", "1")
matcha.store_set("b", "2")
matcha.store_delete("a")
keys = matcha.store_keys()
deleted = matcha.store_get("a")
`)
if err != nil {
t.Fatal(err)
}

if got := m.state.GetGlobal("deleted"); got != lua.LNil {
t.Fatalf("expected deleted key to be nil, got %v", got)
}

keys, ok := m.state.GetGlobal("keys").(*lua.LTable)
if !ok {
t.Fatalf("expected keys table")
}
if keys.Len() != 1 {
t.Fatalf("expected 1 key, got %d", keys.Len())
}
if got := keys.RawGetInt(1); got.String() != "b" {
t.Fatalf("expected remaining key b, got %q", got.String())
}
}

func writePlugin(t *testing.T, dir, name, body string) string {
t.Helper()

path := filepath.Join(dir, name)
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
return path
}

func assertStoredValue(t *testing.T, pluginName, key, want string) {
t.Helper()

store, err := newPluginStore(pluginName)
if err != nil {
t.Fatal(err)
}
got, ok := store.Get(key)
if !ok {
t.Fatalf("expected %s key %q", pluginName, key)
}
if got != want {
t.Fatalf("expected %s key %q to be %q, got %q", pluginName, key, want, got)
}
}
Loading
Loading