Detailed changes
@@ -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
@@ -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.
@@ -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\"")
@@ -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)
+ }
+}
@@ -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,
@@ -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)
@@ -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
+}
@@ -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)
+ }
+}
@@ -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)
- }
-}