feat(plugin): per-plugin KV storage (#1187)

Matt Van Horn and drew created

## What?

- Add `matcha.store_set(key, value)`, `store_get(key)`,
`store_delete(key)`, `store_keys()` to the Lua plugin API
- Storage lives at `~/.config/matcha/plugins/<plugin_name>/data.json`
with mode `0o600`, JSON-encoded, atomic write via unique temp file +
`Rename`
- Per-plugin scoping: Manager tracks the active plugin during plugin
load, hook invocation, and keybinding callbacks; hooks/keybindings now
carry their plugin attribution (`registeredHook{fn, plugin}`)
- Plugin-name validation: only `^[a-zA-Z0-9_-]+$` is accepted as a path
component, blocking traversal
- Lua API surfaces real store-init errors (corrupt JSON, permission
denied) to plugin authors instead of swallowing them as "no plugin
context"
- Tests cover: round-trip set/get/delete/keys, persistence across
`Manager` instances, concurrent writes, file mode `0o600` (including
survives overwrite), invalid plugin name rejection, Lua-level error
propagation on corrupt JSON, hook/keybinding plugin attribution
- Demo helper: `screenshots/cmd/plugin_storage_demo/main.go` loads the
same plugin in two `Manager` instances against the same `HOME` to prove
cross-session persistence

## Why?

Closes #510.

---------

Signed-off-by: drew <me@andrinoff.com>
Co-authored-by: drew <me@andrinoff.com>

Change summary

docs/docs/Features/Plugins.md          |  38 +++
plugin/README.md                       |  20 ++
plugin/api.go                          |   5 
plugin/api_storage_test.go             | 231 +++++++++++++++++++++++
plugin/hooks.go                        |  65 +++++-
plugin/plugin.go                       |  28 +
plugin/storage.go                      | 228 +++++++++++++++++++++++
plugin/storage_test.go                 | 272 ++++++++++++++++++++++++++++
screenshots/cmd/threading_demo/main.go | 107 -----------
9 files changed, 865 insertions(+), 129 deletions(-)

Detailed changes

docs/docs/Features/Plugins.md 🔗

@@ -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. When called from outside a plugin context (matching `store_get`'s behavior), it is also a silent no-op — only `store_set` raises an error in that case, so missing-context writes are surfaced loudly.
+
+```lua
+matcha.store_delete("api_key")
+```
+
+### matcha.store_keys()
+
+Return a 1-indexed table of all keys currently stored by this plugin, sorted lexicographically. Useful for iterating over plugin state on startup with a stable order.
+
+```lua
+for _, key in ipairs(matcha.store_keys()) do
+  matcha.log("stored key: " .. key)
+end
+```
+
 ## Events
 
 ### startup

plugin/README.md 🔗

@@ -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) |
 | `matcha.settings(spec)` | Declare configurable settings; returns a read-only proxy table for live values (see below) |
 | `matcha.get_setting(key [, plugin])` | Look up a setting value by key (defaults to current plugin) |
@@ -74,6 +78,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, sorted lexicographically.
+
 ## 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.

plugin/api.go 🔗

@@ -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,
 		"settings":          m.luaSettings,
 		"get_setting":       m.luaGetSetting,
@@ -75,6 +79,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\"")

plugin/api_storage_test.go 🔗

@@ -0,0 +1,231 @@
+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)
+	}
+}
+
+// store_delete is intentionally a silent no-op outside a plugin context to
+// match store_get's read-side behavior. Only store_set raises in that case.
+func TestLuaStoreDeleteWithoutPluginContextIsNoOp(t *testing.T) {
+	setTestHome(t)
+
+	m := newTestManager()
+	defer m.Close()
+
+	err := m.state.DoString(`
+		local matcha = require("matcha")
+		matcha.store_delete("token")
+	`)
+	if err != nil {
+		t.Fatalf("expected store_delete to be silent without plugin context, 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)
+	}
+}

plugin/hooks.go 🔗

@@ -27,9 +27,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.
@@ -39,9 +44,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 {
@@ -53,7 +64,7 @@ func (m *Manager) CallHook(event string, args ...lua.LValue) {
 // CallSendHook calls a hook with email send metadata.
 func (m *Manager) CallSendHook(event string, to, cc, subject, accountID string) {
 	callbacks, ok := m.hooks[event]
-	if !ok {
+	if !ok || len(callbacks) == 0 {
 		return
 	}
 
@@ -64,9 +75,14 @@ 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 {
@@ -82,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 {
@@ -96,7 +118,7 @@ func (m *Manager) CallFolderHook(event string, folderName string) {
 // CallComposerHook calls a hook with composer state info.
 func (m *Manager) CallComposerHook(event string, body, subject, to, cc, bcc string) {
 	callbacks, ok := m.hooks[event]
-	if !ok {
+	if !ok || len(callbacks) == 0 {
 		return
 	}
 
@@ -109,9 +131,14 @@ 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 {
@@ -138,9 +165,15 @@ func (m *Manager) CallBodyRenderHook(email *lua.LTable, rendered, raw string) st
 	}
 
 	L := m.state
-	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:    1,
 			Protect: true,
 		}, email, lua.LString(rendered), lua.LString(raw)); err != nil {
@@ -158,6 +191,12 @@ func (m *Manager) CallBodyRenderHook(email *lua.LTable, rendered, raw string) st
 
 // 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,

plugin/plugin.go 🔗

@@ -15,13 +15,24 @@ type KeyBinding struct {
 	Area        string // "inbox", "email_view", or "composer"
 	Description string
 	Fn          *lua.LFunction
+	Plugin      string
 }
 
 // Manager manages the Lua VM and loaded plugins.
+//
+// Manager is not safe for concurrent use. The Lua VM itself is single-
+// threaded, and all hook callbacks, key-binding invocations, and API calls
+// must be dispatched from the same goroutine that owns the Manager (the
+// orchestrator). Mutable Manager state (hooks, stores, bindings,
+// currentPlugin, pending* fields) is therefore unprotected by design; callers
+// that need to drive plugin events from multiple goroutines must serialize
+// access externally.
 type Manager struct {
-	state   *lua.LState
-	hooks   map[string][]*lua.LFunction
-	plugins []string
+	state         *lua.LState
+	hooks         map[string][]registeredHook
+	plugins       []string
+	currentPlugin string
+	stores        map[string]*pluginStore
 	// statuses holds persistent status strings per view area, shown in the UI.
 	statuses map[string]string
 	// pendingNotification is set by matcha.notify() and consumed by the orchestrator.
@@ -38,15 +49,12 @@ type Manager struct {
 	pluginSchemas map[string][]SettingDef
 	// pluginValues holds current setting values per plugin.
 	pluginValues map[string]map[string]interface{}
-	// currentPlugin names the plugin file currently being loaded; used to
-	// attribute matcha.settings() declarations.
-	currentPlugin string
 }
 
 // NewManager creates a new plugin manager with a Lua VM.
 func NewManager() *Manager {
 	m := &Manager{
-		hooks:         make(map[string][]*lua.LFunction),
+		hooks:         make(map[string][]registeredHook),
 		statuses:      make(map[string]string),
 		pendingFields: make(map[string]string),
 		pluginSchemas: make(map[string][]SettingDef),
@@ -110,9 +118,11 @@ func (m *Manager) LoadPlugins() {
 }
 
 func (m *Manager) loadPlugin(name, path string) {
-	prev := m.currentPlugin
+	previousPlugin := m.currentPlugin
 	m.currentPlugin = name
-	defer func() { m.currentPlugin = prev }()
+	defer func() {
+		m.currentPlugin = previousPlugin
+	}()
 
 	if err := m.state.DoFile(path); err != nil {
 		log.Printf("plugin %q: load error: %v", name, err)

plugin/storage.go 🔗

@@ -0,0 +1,228 @@
+package plugin
+
+import (
+	"encoding/json"
+	"errors"
+	"io/fs"
+	"os"
+	"path/filepath"
+	"regexp"
+	"sort"
+	"sync"
+
+	"github.com/floatpane/matcha/config"
+	lua "github.com/yuin/gopher-lua"
+)
+
+var validPluginStoreName = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
+
+type pluginStore struct {
+	path string
+	mu   sync.Mutex
+	data map[string]string
+}
+
+func newPluginStore(pluginName string) (*pluginStore, error) {
+	if !validPluginStoreName.MatchString(pluginName) {
+		return nil, errors.New("invalid plugin name for storage")
+	}
+
+	cfgDir, err := config.GetConfigDir()
+	if err != nil {
+		return nil, err
+	}
+
+	dir := filepath.Join(cfgDir, "plugins", pluginName)
+	if err := os.MkdirAll(dir, 0o700); err != nil {
+		return nil, err
+	}
+
+	s := &pluginStore{
+		path: filepath.Join(dir, "data.json"),
+		data: map[string]string{},
+	}
+	if err := s.load(); err != nil {
+		return nil, err
+	}
+	return s, nil
+}
+
+func (s *pluginStore) load() error {
+	raw, err := os.ReadFile(s.path)
+	if errors.Is(err, fs.ErrNotExist) {
+		return nil
+	}
+	if err != nil {
+		return err
+	}
+	if err := json.Unmarshal(raw, &s.data); err != nil {
+		return err
+	}
+	if s.data == nil {
+		s.data = map[string]string{}
+	}
+	return nil
+}
+
+func (s *pluginStore) flush() error {
+	raw, err := json.MarshalIndent(s.data, "", "  ")
+	if err != nil {
+		return err
+	}
+
+	tmp, err := os.CreateTemp(filepath.Dir(s.path), ".data-*.json")
+	if err != nil {
+		return err
+	}
+	tmpPath := tmp.Name()
+	defer os.Remove(tmpPath)
+
+	if _, err := tmp.Write(raw); err != nil {
+		tmp.Close()
+		return err
+	}
+	if err := os.Chmod(tmpPath, 0o600); err != nil {
+		tmp.Close()
+		return err
+	}
+	if err := tmp.Close(); err != nil {
+		return err
+	}
+	return os.Rename(tmpPath, s.path)
+}
+
+func (s *pluginStore) Get(k string) (string, bool) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+
+	v, ok := s.data[k]
+	return v, ok
+}
+
+func (s *pluginStore) Set(k, v string) error {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+
+	s.data[k] = v
+	return s.flush()
+}
+
+func (s *pluginStore) Delete(k string) error {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+
+	delete(s.data, k)
+	return s.flush()
+}
+
+// Keys returns the keys currently stored, sorted lexicographically so plugin
+// authors can rely on a stable iteration order across calls.
+func (s *pluginStore) Keys() []string {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+
+	out := make([]string, 0, len(s.data))
+	for k := range s.data {
+		out = append(out, k)
+	}
+	sort.Strings(out)
+	return out
+}
+
+func (m *Manager) currentStore() (*pluginStore, error) {
+	if m.currentPlugin == "" {
+		return nil, nil
+	}
+	if m.stores == nil {
+		m.stores = make(map[string]*pluginStore)
+	}
+	if s, ok := m.stores[m.currentPlugin]; ok {
+		return s, nil
+	}
+
+	s, err := newPluginStore(m.currentPlugin)
+	if err != nil {
+		return nil, err
+	}
+	m.stores[m.currentPlugin] = s
+	return s, nil
+}
+
+func (m *Manager) luaStoreSet(L *lua.LState) int {
+	key := L.CheckString(1)
+	val := L.CheckString(2)
+
+	s, err := m.currentStore()
+	if err != nil {
+		L.RaiseError("store_set: %v", err)
+		return 0
+	}
+	if s == nil {
+		L.RaiseError("store_set: no plugin context")
+		return 0
+	}
+	if err := s.Set(key, val); err != nil {
+		L.RaiseError("store_set: %v", err)
+	}
+	return 0
+}
+
+func (m *Manager) luaStoreGet(L *lua.LState) int {
+	key := L.CheckString(1)
+
+	s, err := m.currentStore()
+	if err != nil {
+		L.RaiseError("store_get: %v", err)
+		return 0
+	}
+	if s == nil {
+		L.Push(lua.LNil)
+		return 1
+	}
+	if v, ok := s.Get(key); ok {
+		L.Push(lua.LString(v))
+	} else {
+		L.Push(lua.LNil)
+	}
+	return 1
+}
+
+func (m *Manager) luaStoreDelete(L *lua.LState) int {
+	key := L.CheckString(1)
+
+	s, err := m.currentStore()
+	if err != nil {
+		L.RaiseError("store_delete: %v", err)
+		return 0
+	}
+	// No plugin context: silently no-op, matching store_get's behavior so
+	// read+remove operations behave the same when called outside a plugin
+	// (e.g. from a non-plugin Lua chunk). store_set still raises so a
+	// missing-context write is surfaced loudly.
+	if s == nil {
+		return 0
+	}
+	if err := s.Delete(key); err != nil {
+		L.RaiseError("store_delete: %v", err)
+	}
+	return 0
+}
+
+func (m *Manager) luaStoreKeys(L *lua.LState) int {
+	s, err := m.currentStore()
+	if err != nil {
+		L.RaiseError("store_keys: %v", err)
+		return 0
+	}
+	if s == nil {
+		L.Push(L.NewTable())
+		return 1
+	}
+
+	t := L.NewTable()
+	for i, key := range s.Keys() {
+		t.RawSetInt(i+1, lua.LString(key))
+	}
+	L.Push(t)
+	return 1
+}

plugin/storage_test.go 🔗

@@ -0,0 +1,272 @@
+package plugin
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+	"reflect"
+	"runtime"
+	"strings"
+	"sync"
+	"testing"
+)
+
+// setTestHome makes t.TempDir() the effective home directory for the duration
+// of the test on both Unix and Windows. Go's os.UserHomeDir() reads $HOME on
+// Unix but %USERPROFILE% on Windows, so we set both.
+func setTestHome(t *testing.T) string {
+	t.Helper()
+	dir := t.TempDir()
+	t.Setenv("HOME", dir)
+	if runtime.GOOS == "windows" {
+		t.Setenv("USERPROFILE", dir)
+	}
+	return dir
+}
+
+func TestPluginStoreSetGet(t *testing.T) {
+	setTestHome(t)
+
+	store, err := newPluginStore("test_plugin")
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if err := store.Set("token", "abc123"); err != nil {
+		t.Fatal(err)
+	}
+
+	got, ok := store.Get("token")
+	if !ok {
+		t.Fatal("expected stored key")
+	}
+	if got != "abc123" {
+		t.Fatalf("expected abc123, got %q", got)
+	}
+}
+
+func TestPluginStoreDelete(t *testing.T) {
+	setTestHome(t)
+
+	store, err := newPluginStore("test_plugin")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if err := store.Set("token", "abc123"); err != nil {
+		t.Fatal(err)
+	}
+	if err := store.Delete("token"); err != nil {
+		t.Fatal(err)
+	}
+
+	if got, ok := store.Get("token"); ok {
+		t.Fatalf("expected key to be deleted, got %q", got)
+	}
+}
+
+func TestPluginStoreKeys(t *testing.T) {
+	setTestHome(t)
+
+	store, err := newPluginStore("test_plugin")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if err := store.Set("a", "1"); err != nil {
+		t.Fatal(err)
+	}
+	if err := store.Set("b", "2"); err != nil {
+		t.Fatal(err)
+	}
+
+	got := map[string]bool{}
+	for _, key := range store.Keys() {
+		got[key] = true
+	}
+
+	want := map[string]bool{"a": true, "b": true}
+	if !reflect.DeepEqual(got, want) {
+		t.Fatalf("expected keys %v, got %v", want, got)
+	}
+}
+
+func TestPluginStoreKeysSortedOrder(t *testing.T) {
+	setTestHome(t)
+
+	store, err := newPluginStore("test_plugin")
+	if err != nil {
+		t.Fatal(err)
+	}
+	// Insert in non-sorted order so map iteration order won't accidentally
+	// produce the expected result.
+	for _, k := range []string{"c", "a", "b", "z", "m"} {
+		if err := store.Set(k, k); err != nil {
+			t.Fatal(err)
+		}
+	}
+
+	got := store.Keys()
+	want := []string{"a", "b", "c", "m", "z"}
+	if !reflect.DeepEqual(got, want) {
+		t.Fatalf("expected sorted keys %v, got %v", want, got)
+	}
+}
+
+func TestPluginStoreKeysEmpty(t *testing.T) {
+	setTestHome(t)
+
+	store, err := newPluginStore("test_plugin")
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if keys := store.Keys(); len(keys) != 0 {
+		t.Fatalf("expected no keys, got %v", keys)
+	}
+}
+
+func TestPluginStoreConcurrentSets(t *testing.T) {
+	setTestHome(t)
+
+	store, err := newPluginStore("test_plugin")
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	var wg sync.WaitGroup
+	for i := 0; i < 20; i++ {
+		wg.Add(1)
+		go func(i int) {
+			defer wg.Done()
+			if err := store.Set(fmt.Sprintf("key%d", i), fmt.Sprintf("value%d", i)); err != nil {
+				t.Errorf("set key%d: %v", i, err)
+			}
+		}(i)
+	}
+	wg.Wait()
+
+	for i := 0; i < 20; i++ {
+		key := fmt.Sprintf("key%d", i)
+		want := fmt.Sprintf("value%d", i)
+		got, ok := store.Get(key)
+		if !ok {
+			t.Fatalf("expected %s to be stored", key)
+		}
+		if got != want {
+			t.Fatalf("expected %s, got %q", want, got)
+		}
+	}
+}
+
+func TestPluginStorePersistence(t *testing.T) {
+	setTestHome(t)
+
+	store, err := newPluginStore("test_plugin")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if err := store.Set("token", "abc123"); err != nil {
+		t.Fatal(err)
+	}
+
+	reloaded, err := newPluginStore("test_plugin")
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	got, ok := reloaded.Get("token")
+	if !ok {
+		t.Fatal("expected persisted key")
+	}
+	if got != "abc123" {
+		t.Fatalf("expected abc123, got %q", got)
+	}
+}
+
+func TestPluginStoreFileMode(t *testing.T) {
+	setTestHome(t)
+
+	store, err := newPluginStore("test_plugin")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if err := store.Set("token", "abc123"); err != nil {
+		t.Fatal(err)
+	}
+
+	info, err := os.Stat(store.path)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if runtime.GOOS != "windows" {
+		if got := info.Mode().Perm(); got != 0o600 {
+			t.Fatalf("expected mode 0600, got %o", got)
+		}
+	}
+}
+
+func TestPluginStoreFileModeAfterOverwrite(t *testing.T) {
+	setTestHome(t)
+
+	store, err := newPluginStore("test_plugin")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if err := store.Set("token", "abc123"); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.Chmod(store.path, 0o666); err != nil {
+		t.Fatal(err)
+	}
+	if err := store.Set("token", "def456"); err != nil {
+		t.Fatal(err)
+	}
+
+	info, err := os.Stat(store.path)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if runtime.GOOS != "windows" {
+		if got := info.Mode().Perm(); got != 0o600 {
+			t.Fatalf("expected mode 0600 after overwrite, got %o", got)
+		}
+	}
+}
+
+func TestNewPluginStoreRejectsInvalidPluginName(t *testing.T) {
+	setTestHome(t)
+
+	for _, name := range []string{"", ".", "..", "../etc", "foo/bar", `foo\bar`, "foo.bar"} {
+		t.Run(name, func(t *testing.T) {
+			if _, err := newPluginStore(name); err == nil {
+				t.Fatal("expected invalid plugin name error")
+			}
+		})
+	}
+}
+
+func TestLuaStoreInitErrorPropagates(t *testing.T) {
+	home := setTestHome(t)
+
+	dir := filepath.Join(home, ".config", "matcha", "plugins", "test_plugin")
+	if err := os.MkdirAll(dir, 0o700); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(filepath.Join(dir, "data.json"), []byte("{"), 0o600); err != nil {
+		t.Fatal(err)
+	}
+
+	m := newTestManager()
+	defer m.Close()
+	m.currentPlugin = "test_plugin"
+
+	err := m.state.DoString(`
+		local matcha = require("matcha")
+		matcha.store_get("token")
+	`)
+	if err == nil {
+		t.Fatal("expected store_get to fail on store init error")
+	}
+	if !strings.Contains(err.Error(), "store_get:") {
+		t.Fatalf("expected store_get error, got %v", err)
+	}
+}

screenshots/cmd/threading_demo/main.go 🔗

@@ -1,107 +0,0 @@
-package main
-
-import (
-	"fmt"
-	"os"
-	"time"
-
-	tea "charm.land/bubbletea/v2"
-	"github.com/floatpane/matcha/config"
-	"github.com/floatpane/matcha/fetcher"
-	"github.com/floatpane/matcha/tui"
-)
-
-type wrapper struct {
-	inbox *tui.Inbox
-}
-
-func (w wrapper) Init() tea.Cmd {
-	return w.inbox.Init()
-}
-
-func (w wrapper) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
-	m, cmd := w.inbox.Update(msg)
-	if inbox, ok := m.(*tui.Inbox); ok {
-		w.inbox = inbox
-	}
-	return w, cmd
-}
-
-func (w wrapper) View() tea.View {
-	v := w.inbox.View()
-	v.AltScreen = true
-	return v
-}
-
-func main() {
-	now := time.Now()
-	account := config.Account{
-		ID:         "demo-user",
-		Name:       "Matcha Demo",
-		Email:      "demo@floatpane.com",
-		FetchEmail: "demo@floatpane.com",
-	}
-
-	emails := []fetcher.Email{
-		{
-			UID:        304,
-			From:       "Priya Shah <priya@example.com>",
-			To:         []string{"demo@floatpane.com"},
-			Subject:    "Re: Release checklist for 1.8",
-			Date:       now.Add(-8 * time.Minute),
-			MessageID:  "<release-304@example.com>",
-			References: []string{"<release-301@example.com>", "<release-302@example.com>"},
-			AccountID:  account.ID,
-		},
-		{
-			UID:       303,
-			From:      "Buildkite <buildkite@example.com>",
-			To:        []string{"demo@floatpane.com"},
-			Subject:   "main passed",
-			Date:      now.Add(-20 * time.Minute),
-			MessageID: "<build-303@example.com>",
-			AccountID: account.ID,
-			IsRead:    true,
-		},
-		{
-			UID:        302,
-			From:       "Noah Reed <noah@example.com>",
-			To:         []string{"demo@floatpane.com"},
-			Subject:    "Re: Release checklist for 1.8",
-			Date:       now.Add(-33 * time.Minute),
-			MessageID:  "<release-302@example.com>",
-			References: []string{"<release-301@example.com>"},
-			AccountID:  account.ID,
-			IsRead:     true,
-		},
-		{
-			UID:       301,
-			From:      "Avery Stone <avery@example.com>",
-			To:        []string{"demo@floatpane.com"},
-			Subject:   "Release checklist for 1.8",
-			Date:      now.Add(-52 * time.Minute),
-			MessageID: "<release-301@example.com>",
-			AccountID: account.ID,
-			IsRead:    true,
-		},
-		{
-			UID:       300,
-			From:      "Finance <finance@example.com>",
-			To:        []string{"demo@floatpane.com"},
-			Subject:   "Invoice approvals",
-			Date:      now.Add(-2 * time.Hour),
-			MessageID: "<invoice-300@example.com>",
-			AccountID: account.ID,
-			IsRead:    true,
-		},
-	}
-
-	inbox := tui.NewInbox(emails, []config.Account{account})
-	inbox.SetFolderName("INBOX")
-
-	p := tea.NewProgram(wrapper{inbox: inbox})
-	if _, err := p.Run(); err != nil {
-		fmt.Fprintf(os.Stderr, "Error: %v\n", err)
-		os.Exit(1)
-	}
-}