1package plugin
2
3import (
4 "log"
5 "os"
6 "path/filepath"
7 "strings"
8
9 lua "github.com/yuin/gopher-lua"
10)
11
12// KeyBinding represents a plugin-registered keyboard shortcut.
13type KeyBinding struct {
14 Key string
15 Area string // "inbox", "email_view", or "composer"
16 Description string
17 Fn *lua.LFunction
18 Plugin string
19}
20
21// Manager manages the Lua VM and loaded plugins.
22//
23// Manager is not safe for concurrent use. The Lua VM itself is single-
24// threaded, and all hook callbacks, key-binding invocations, and API calls
25// must be dispatched from the same goroutine that owns the Manager (the
26// orchestrator). Mutable Manager state (hooks, stores, bindings,
27// currentPlugin, pending* fields) is therefore unprotected by design; callers
28// that need to drive plugin events from multiple goroutines must serialize
29// access externally.
30type Manager struct {
31 state *lua.LState
32 hooks map[string][]registeredHook
33 plugins []string
34 currentPlugin string
35 stores map[string]*pluginStore
36 // statuses holds persistent status strings per view area, shown in the UI.
37 statuses map[string]string
38 // pendingNotification is set by matcha.notify() and consumed by the orchestrator.
39 pendingNotification string
40 pendingDuration float64 // seconds, 0 means default (2s)
41 // pendingFields holds compose field updates set by matcha.set_compose_field().
42 pendingFields map[string]string
43 // bindings holds plugin-registered keyboard shortcuts.
44 bindings []KeyBinding
45 // pendingPrompt is set by matcha.prompt() and consumed by the orchestrator.
46 pendingPrompt *PendingPrompt
47
48 // pluginSchemas holds settings declarations per plugin.
49 pluginSchemas map[string][]SettingDef
50 // pluginValues holds current setting values per plugin.
51 pluginValues map[string]map[string]interface{}
52}
53
54// NewManager creates a new plugin manager with a Lua VM.
55func NewManager() *Manager {
56 m := &Manager{
57 hooks: make(map[string][]registeredHook),
58 statuses: make(map[string]string),
59 pendingFields: make(map[string]string),
60 pluginSchemas: make(map[string][]SettingDef),
61 pluginValues: make(map[string]map[string]interface{}),
62 }
63
64 L := lua.NewState(lua.Options{
65 SkipOpenLibs: true,
66 })
67
68 // Open only safe standard libraries (no os, io, debug)
69 for _, lib := range []struct {
70 name string
71 fn lua.LGFunction
72 }{
73 {lua.LoadLibName, lua.OpenPackage},
74 {lua.BaseLibName, lua.OpenBase},
75 {lua.TabLibName, lua.OpenTable},
76 {lua.StringLibName, lua.OpenString},
77 {lua.MathLibName, lua.OpenMath},
78 } {
79 L.Push(L.NewFunction(lib.fn))
80 L.Push(lua.LString(lib.name))
81 L.Call(1, 0)
82 }
83
84 m.state = L
85 m.registerAPI()
86
87 return m
88}
89
90// LoadPlugins discovers and loads plugins from ~/.config/matcha/plugins/.
91func (m *Manager) LoadPlugins() {
92 home, err := os.UserHomeDir()
93 if err != nil {
94 return
95 }
96
97 pluginsDir := filepath.Join(home, ".config", "matcha", "plugins")
98 entries, err := os.ReadDir(pluginsDir)
99 if err != nil {
100 return
101 }
102
103 for _, entry := range entries {
104 path := filepath.Join(pluginsDir, entry.Name())
105
106 if entry.IsDir() {
107 // Directory plugin: look for init.lua
108 initPath := filepath.Join(path, "init.lua")
109 if _, err := os.Stat(initPath); err == nil {
110 m.loadPlugin(entry.Name(), initPath)
111 }
112 } else if strings.HasSuffix(entry.Name(), ".lua") {
113 // Single-file plugin
114 name := strings.TrimSuffix(entry.Name(), ".lua")
115 m.loadPlugin(name, path)
116 }
117 }
118}
119
120func (m *Manager) loadPlugin(name, path string) {
121 previousPlugin := m.currentPlugin
122 m.currentPlugin = name
123 defer func() {
124 m.currentPlugin = previousPlugin
125 }()
126
127 if err := m.state.DoFile(path); err != nil {
128 log.Printf("plugin %q: load error: %v", name, err)
129 return
130 }
131 m.plugins = append(m.plugins, name)
132 log.Printf("plugin %q: loaded", name)
133}
134
135// Plugins returns the names of all loaded plugins.
136func (m *Manager) Plugins() []string {
137 return m.plugins
138}
139
140// PendingNotification holds a notification message and its display duration.
141type PendingNotification struct {
142 Message string
143 Duration float64 // seconds, 0 means default
144}
145
146// TakePendingNotification returns and clears any pending notification.
147func (m *Manager) TakePendingNotification() (PendingNotification, bool) {
148 if m.pendingNotification == "" {
149 return PendingNotification{}, false
150 }
151 n := PendingNotification{
152 Message: m.pendingNotification,
153 Duration: m.pendingDuration,
154 }
155 m.pendingNotification = ""
156 m.pendingDuration = 0
157 return n, true
158}
159
160// TakePendingFields returns and clears any pending compose field updates.
161func (m *Manager) TakePendingFields() map[string]string {
162 if len(m.pendingFields) == 0 {
163 return nil
164 }
165 fields := m.pendingFields
166 m.pendingFields = make(map[string]string)
167 return fields
168}
169
170// Bindings returns all plugin-registered key bindings for the given view area.
171func (m *Manager) Bindings(area string) []KeyBinding {
172 var result []KeyBinding
173 for _, b := range m.bindings {
174 if b.Area == area {
175 result = append(result, b)
176 }
177 }
178 return result
179}
180
181// StatusText returns the plugin status string for the given view area.
182func (m *Manager) StatusText(area string) string {
183 return m.statuses[area]
184}
185
186// LuaState returns the Lua VM state for building tables.
187func (m *Manager) LuaState() *lua.LState {
188 return m.state
189}
190
191// Close shuts down the Lua VM.
192func (m *Manager) Close() {
193 if m.state != nil {
194 m.state.Close()
195 }
196}