Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions plugin/load_notify_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
28 changes: 27 additions & 1 deletion plugin/plugin.go
Original file line number Diff line number Diff line change
@@ -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"
)

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
}
Expand All @@ -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 {
Expand Down
Loading