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