diff --git a/plugin/README.md b/plugin/README.md index 80cf6f6..1a6f6d5 100644 --- a/plugin/README.md +++ b/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 | ## Hook events @@ -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//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. diff --git a/plugin/api.go b/plugin/api.go index ec3750b..9645adf 100644 --- a/plugin/api.go +++ b/plugin/api.go @@ -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")) @@ -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\"") diff --git a/plugin/api_storage_test.go b/plugin/api_storage_test.go new file mode 100644 index 0000000..83c640e --- /dev/null +++ b/plugin/api_storage_test.go @@ -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) + } +} diff --git a/plugin/hooks.go b/plugin/hooks.go index 03cb752..bd07c6c 100644 --- a/plugin/hooks.go +++ b/plugin/hooks.go @@ -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. @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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, diff --git a/plugin/plugin.go b/plugin/plugin.go index 82c2ea6..3527dbc 100644 --- a/plugin/plugin.go +++ b/plugin/plugin.go @@ -15,13 +15,16 @@ 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. 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,7 +41,7 @@ type Manager struct { // 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), } @@ -100,6 +103,12 @@ func (m *Manager) LoadPlugins() { } func (m *Manager) loadPlugin(name, path string) { + previousPlugin := m.currentPlugin + m.currentPlugin = name + defer func() { + m.currentPlugin = previousPlugin + }() + if err := m.state.DoFile(path); err != nil { log.Printf("plugin %q: load error: %v", name, err) return diff --git a/plugin/storage.go b/plugin/storage.go new file mode 100644 index 0000000..9bd5a94 --- /dev/null +++ b/plugin/storage.go @@ -0,0 +1,220 @@ +package plugin + +import ( + "encoding/json" + "errors" + "io/fs" + "os" + "path/filepath" + "regexp" + "sync" + + 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") + } + + home, err := os.UserHomeDir() + if err != nil { + return nil, err + } + + dir := filepath.Join(home, ".config", "matcha", "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() +} + +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) + } + 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 + } + if s == nil { + L.RaiseError("store_delete: no plugin context") + 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 +} diff --git a/plugin/storage_test.go b/plugin/storage_test.go new file mode 100644 index 0000000..b73b435 --- /dev/null +++ b/plugin/storage_test.go @@ -0,0 +1,250 @@ +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 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) + } +} diff --git a/public/assets/plugin-storage-remotion-demo.gif b/public/assets/plugin-storage-remotion-demo.gif new file mode 100644 index 0000000..9899ea0 Binary files /dev/null and b/public/assets/plugin-storage-remotion-demo.gif differ diff --git a/public/assets/plugin_storage_demo.gif b/public/assets/plugin_storage_demo.gif new file mode 100644 index 0000000..9c1c63d Binary files /dev/null and b/public/assets/plugin_storage_demo.gif differ diff --git a/screenshots/cmd/plugin_storage_demo/main.go b/screenshots/cmd/plugin_storage_demo/main.go new file mode 100644 index 0000000..5b8c5f7 --- /dev/null +++ b/screenshots/cmd/plugin_storage_demo/main.go @@ -0,0 +1,90 @@ +// plugin_storage_demo loads a small Lua plugin twice (in two separate +// Manager instances against the same HOME) to demonstrate that the new +// matcha.store_set / store_get / store_delete / store_keys API persists +// data across sessions to ~/.config/matcha/plugins//data.json. +package main + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/floatpane/matcha/plugin" +) + +func main() { + tmp, err := os.MkdirTemp("", "matcha-plugin-demo-*") + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + defer os.RemoveAll(tmp) + _ = os.Setenv("HOME", tmp) + _ = os.Setenv("XDG_CONFIG_HOME", filepath.Join(tmp, ".config")) + + pluginsDir := filepath.Join(tmp, ".config", "matcha", "plugins") + if err := os.MkdirAll(pluginsDir, 0o755); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + // Plugin source used in BOTH sessions, so we exercise persistence. + pluginPath := filepath.Join(pluginsDir, "demo.lua") + pluginSrc := ` +local matcha = require("matcha") + +local mode = matcha.store_get("__mode__") or "first" +matcha.store_set("__mode__", "second") + +if mode == "first" then + print("=== Session 1: writing values ===") + matcha.store_set("api_key", "sk-demo-12345") + matcha.store_set("theme", "catppuccin") + matcha.store_set("last_run", "2026-04-28") + + print("[get] api_key -> " .. matcha.store_get("api_key")) + print("[get] theme -> " .. matcha.store_get("theme")) + print("[get] missing -> " .. tostring(matcha.store_get("missing"))) + + print("\n[delete] api_key") + matcha.store_delete("api_key") + print("[get] api_key after delete -> " .. tostring(matcha.store_get("api_key"))) + + print("\n[keys]") + local keys = matcha.store_keys() + table.sort(keys) + for i, k in ipairs(keys) do + print(string.format(" %d. %s", i, k)) + end +else + print("=== Session 2: same plugin, fresh Manager ===") + print("[get] api_key -> " .. tostring(matcha.store_get("api_key")) .. " (deleted in session 1)") + print("[get] theme -> " .. matcha.store_get("theme") .. " (persisted from session 1)") + print("[get] last_run -> " .. matcha.store_get("last_run") .. " (persisted from session 1)") +end +` + if err := os.WriteFile(pluginPath, []byte(pluginSrc), 0o644); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + // SESSION 1 + mgr := plugin.NewManager() + mgr.LoadPlugins() + mgr.Close() + + fmt.Println() + + // SESSION 2 (same HOME, fresh Manager) + mgr2 := plugin.NewManager() + mgr2.LoadPlugins() + mgr2.Close() + + // Show the persisted file + dataFile := filepath.Join(pluginsDir, "demo", "data.json") + body, _ := os.ReadFile(dataFile) + stat, _ := os.Stat(dataFile) + fmt.Printf("\n=== %s ===\n", dataFile) + fmt.Printf("(mode: %v, %d bytes)\n", stat.Mode().Perm(), len(body)) + fmt.Println(string(body)) +} diff --git a/screenshots/plugin_storage_demo.tape b/screenshots/plugin_storage_demo.tape new file mode 100644 index 0000000..b156572 --- /dev/null +++ b/screenshots/plugin_storage_demo.tape @@ -0,0 +1,28 @@ +# VHS tape demonstrating plugin persistent storage (#510) +# Run with: vhs screenshots/plugin_storage_demo.tape + +Output plugin_storage_demo.gif + +Set FontSize 14 +Set FontFamily "JetBrainsMono Nerd Font" +Set Width 1400 +Set Height 900 +Set Theme "Catppuccin Mocha" +Set Padding 20 +Set Framerate 30 +Set PlaybackSpeed 0.7 + +Set WindowBar Colorful +Set WindowBarSize 40 +Set BorderRadius 10 + +Sleep 500ms + +Type "./bin/plugin_storage_demo" +Sleep 300ms +Enter + +# Demo runs in <1s; let the output linger +Sleep 7s + +Ctrl+c