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
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 |

## Hook events

Expand Down Expand Up @@ -70,6 +74,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 @@ -19,6 +19,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,
})

L.SetField(mod, "_VERSION", lua.LString("0.1.0"))
Expand Down Expand Up @@ -71,6 +75,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)
}
}
53 changes: 44 additions & 9 deletions plugin/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,14 @@ const (
StatusEmailView = "email_view"
)

type registeredHook struct {
fn *lua.LFunction
plugin string
}

// registerHook adds a callback for the given event.
func (m *Manager) registerHook(event string, fn *lua.LFunction) {
m.hooks[event] = append(m.hooks[event], fn)
m.hooks[event] = append(m.hooks[event], registeredHook{fn: fn, plugin: m.currentPlugin})
}

// CallHook invokes all callbacks registered for the given event.
Expand All @@ -38,9 +43,15 @@ func (m *Manager) CallHook(event string, args ...lua.LValue) {
return
}

for _, fn := range callbacks {
previousPlugin := m.currentPlugin
defer func() {
m.currentPlugin = previousPlugin
}()

for _, hook := range callbacks {
m.currentPlugin = hook.plugin
if err := m.state.CallByParam(lua.P{
Fn: fn,
Fn: hook.fn,
NRet: 0,
Protect: true,
}, args...); err != nil {
Expand All @@ -63,9 +74,15 @@ func (m *Manager) CallSendHook(event string, to, cc, subject, accountID string)
t.RawSetString("subject", lua.LString(subject))
t.RawSetString("account_id", lua.LString(accountID))

for _, fn := range callbacks {
previousPlugin := m.currentPlugin
defer func() {
m.currentPlugin = previousPlugin
}()

for _, hook := range callbacks {
m.currentPlugin = hook.plugin
if err := L.CallByParam(lua.P{
Fn: fn,
Fn: hook.fn,
NRet: 0,
Protect: true,
}, t); err != nil {
Expand All @@ -81,9 +98,15 @@ func (m *Manager) CallFolderHook(event string, folderName string) {
return
}

for _, fn := range callbacks {
previousPlugin := m.currentPlugin
defer func() {
m.currentPlugin = previousPlugin
}()

for _, hook := range callbacks {
m.currentPlugin = hook.plugin
if err := m.state.CallByParam(lua.P{
Fn: fn,
Fn: hook.fn,
NRet: 0,
Protect: true,
}, lua.LString(folderName)); err != nil {
Expand All @@ -108,9 +131,15 @@ func (m *Manager) CallComposerHook(event string, body, subject, to, cc, bcc stri
t.RawSetString("cc", lua.LString(cc))
t.RawSetString("bcc", lua.LString(bcc))

for _, fn := range callbacks {
previousPlugin := m.currentPlugin
defer func() {
m.currentPlugin = previousPlugin
}()

for _, hook := range callbacks {
m.currentPlugin = hook.plugin
if err := L.CallByParam(lua.P{
Fn: fn,
Fn: hook.fn,
NRet: 0,
Protect: true,
}, t); err != nil {
Expand All @@ -121,6 +150,12 @@ func (m *Manager) CallComposerHook(event string, body, subject, to, cc, bcc stri

// CallKeyBinding invokes a plugin key binding callback with the given arguments.
func (m *Manager) CallKeyBinding(binding KeyBinding, args ...lua.LValue) {
previousPlugin := m.currentPlugin
m.currentPlugin = binding.Plugin
defer func() {
m.currentPlugin = previousPlugin
}()

if err := m.state.CallByParam(lua.P{
Fn: binding.Fn,
NRet: 0,
Expand Down
Loading