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