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