diff --git a/plugin/load_notify_test.go b/plugin/load_notify_test.go new file mode 100644 index 00000000..c691d540 --- /dev/null +++ b/plugin/load_notify_test.go @@ -0,0 +1,124 @@ +package plugin + +import ( + "strings" + "testing" +) + +func TestLoadPluginErrorQueuesErrorNotification(t *testing.T) { + setTestHome(t) + + m := newTestManager() + defer m.Close() + + bad := writePlugin(t, t.TempDir(), "broken.lua", `this is not valid lua !!!`) + m.loadPlugin("broken", bad) + + n, ok := m.TakePendingNotification() + if !ok { + t.Fatal("expected a pending notification for load error") + } + if n.Kind != NotifyKindError { + t.Fatalf("expected kind %q, got %q", NotifyKindError, n.Kind) + } + if n.Title != "Plugin load error" { + t.Fatalf("expected title %q, got %q", "Plugin load error", n.Title) + } + if !strings.Contains(n.Message, "broken") { + t.Fatalf("expected message to contain plugin name, got %q", n.Message) + } + if !n.Closable { + t.Fatal("expected closable true so user can dismiss") + } + if n.Duration <= 0 { + t.Fatalf("expected positive duration, got %f", n.Duration) + } +} + +func TestLoadPluginMultipleErrorsAreQueuedAndDrained(t *testing.T) { + setTestHome(t) + + m := newTestManager() + defer m.Close() + + dir := t.TempDir() + m.loadPlugin("first", writePlugin(t, dir, "first.lua", `!!! bad`)) + m.loadPlugin("second", writePlugin(t, dir, "second.lua", `!!! also bad`)) + + // Plugins should NOT be registered when they fail to load. + if len(m.Plugins()) != 0 { + t.Fatalf("expected no plugins registered, got %d", len(m.Plugins())) + } + + // Drain the first notification. + n1, ok := m.TakePendingNotification() + if !ok { + t.Fatal("expected first queued notification") + } + if !strings.Contains(n1.Message, "first") { + t.Fatalf("expected first message to mention first plugin, got %q", n1.Message) + } + + // Drain the second notification. + n2, ok := m.TakePendingNotification() + if !ok { + t.Fatal("expected second queued notification") + } + if !strings.Contains(n2.Message, "second") { + t.Fatalf("expected second message to mention second plugin, got %q", n2.Message) + } + + // Queue should now be empty. + if _, ok := m.TakePendingNotification(); ok { + t.Fatal("expected no more notifications after draining queue") + } +} + +func TestLoadNotificationRespectsExistingPendingNotification(t *testing.T) { + setTestHome(t) + + m := newTestManager() + defer m.Close() + + // Simulate a plugin-set notification occupying the single slot. + m.pendingNotification = &PendingNotification{Message: "plugin said hi", Kind: NotifyKindInfo} + + m.loadPlugin("broken", writePlugin(t, t.TempDir(), "broken.lua", `!!! bad`)) + + // The pre-existing notification should be returned first, not the load error. + n, ok := m.TakePendingNotification() + if !ok { + t.Fatal("expected pending notification") + } + if n.Message != "plugin said hi" { + t.Fatalf("expected pre-existing notification first, got %q", n.Message) + } + + // Now the load error should be drained from the queue. + n2, ok := m.TakePendingNotification() + if !ok { + t.Fatal("expected load error notification after pre-existing one") + } + if !strings.Contains(n2.Message, "broken") { + t.Fatalf("expected load error message, got %q", n2.Message) + } + if n2.Kind != NotifyKindError { + t.Fatalf("expected error kind, got %q", n2.Kind) + } +} + +func TestSuccessfulLoadDoesNotQueueNotification(t *testing.T) { + setTestHome(t) + + m := newTestManager() + defer m.Close() + + good := writePlugin(t, t.TempDir(), "good.lua", ` + local matcha = require("matcha") + `) + m.loadPlugin("good", good) + + if _, ok := m.TakePendingNotification(); ok { + t.Fatal("expected no notification for successful load") + } +} diff --git a/plugin/plugin.go b/plugin/plugin.go index 37dba1ea..46f7de33 100644 --- a/plugin/plugin.go +++ b/plugin/plugin.go @@ -1,11 +1,13 @@ package plugin import ( + "fmt" "log" "os" "path/filepath" "strings" + "github.com/floatpane/matcha/internal/loglevel" lua "github.com/yuin/gopher-lua" ) @@ -45,6 +47,11 @@ type Manager struct { statuses map[string]string // pendingNotification is set by matcha.notify() and consumed by the orchestrator. pendingNotification *PendingNotification + // pendingLoadNotifications holds plugin load errors queued for display. + // They are drained one-at-a-time into pendingNotification by the + // orchestrator so that multiple failed plugins don't get lost when the + // single-slot pendingNotification is already occupied. + pendingLoadNotifications []*PendingNotification // pendingFields holds compose field updates set by matcha.set_compose_field(). pendingFields map[string]string // bindings holds plugin-registered keyboard shortcuts. @@ -143,10 +150,11 @@ func (m *Manager) loadPlugin(name, path string) { if err := m.state.DoFile(path); err != nil { log.Printf("plugin %q: load error: %v", name, err) + m.queueLoadNotification(name, err) return } m.plugins = append(m.plugins, name) - log.Printf("plugin %q: loaded", name) + loglevel.Verbosef("plugin %q: loaded", name) } // Plugins returns the names of all loaded plugins. @@ -176,6 +184,10 @@ type PendingNotification struct { // TakePendingNotification returns and clears any pending notification. func (m *Manager) TakePendingNotification() (*PendingNotification, bool) { + if m.pendingNotification == nil && len(m.pendingLoadNotifications) > 0 { + m.pendingNotification = m.pendingLoadNotifications[0] + m.pendingLoadNotifications = m.pendingLoadNotifications[1:] + } if m.pendingNotification == nil { return nil, false } @@ -184,6 +196,20 @@ func (m *Manager) TakePendingNotification() (*PendingNotification, bool) { return n, true } +// queueLoadNotification records a plugin load error as a non-blocking +// notification so the orchestrator can surface it in the UI via the +// bubble-overlay notification system. Multiple failures are queued and +// drained one-at-a-time by TakePendingNotification. +func (m *Manager) queueLoadNotification(name string, err error) { + m.pendingLoadNotifications = append(m.pendingLoadNotifications, &PendingNotification{ + Title: "Plugin load error", + Message: fmt.Sprintf("%s: %v", name, err), + Duration: 6, + Kind: NotifyKindError, + Closable: true, + }) +} + // TakePendingFields returns and clears any pending compose field updates. func (m *Manager) TakePendingFields() map[string]string { if len(m.pendingFields) == 0 {