Added plugin reload

This commit is contained in:
Leon Mika 2026-09-07 22:57:44 +00:00
parent ac437b7409
commit 18604ec5a6
5 changed files with 247 additions and 36 deletions

155
app.go
View file

@ -6,6 +6,7 @@ import (
"log"
"sort"
"strings"
"sync"
"github.com/wailsapp/wails/v2/pkg/runtime"
"lmika.dev/pkg/modash/moslice"
@ -15,17 +16,21 @@ import (
// App struct
type App struct {
store *Store
store *Store
// mu guards uclInst, plugins and ctx, which are swapped together when plugins are reloaded.
mu sync.RWMutex
uclInst *ucl.Inst
plugins *PluginFilters
ctx context.Context
ctx context.Context
// baseCtx is the raw Wails context supplied to startup. ctx is derived from it.
baseCtx context.Context
}
// NewApp creates a new App application struct
func NewApp(store *Store) *App {
plugins := newPluginFilters()
uclInst := ucl.New(
// newUCLInst creates a UCL interpreter with the standard modules plus the plugin module.
func newUCLInst(plugins *PluginFilters) *ucl.Inst {
return ucl.New(
ucl.WithModule(plugins.Module()),
ucl.WithModule(builtins.CSV(nil)),
ucl.WithModule(builtins.Fns()),
@ -37,42 +42,118 @@ func NewApp(store *Store) *App {
ucl.WithModule(builtins.Strs()),
ucl.WithModule(builtins.Time()),
)
return &App{
}
// NewApp creates a new App application struct
func NewApp(store *Store) *App {
app := &App{
store: store,
uclInst: uclInst,
plugins: plugins,
baseCtx: context.Background(),
}
app.setUCLInst(newUCLInst(newPluginFilters()), nil)
return app
}
// setUCLInst installs a UCL interpreter and its plugin registry, rebuilding the app context so that
// filters which fetch the interpreter from the context see the new instance. A nil plugins argument
// is replaced with an empty registry.
func (a *App) setUCLInst(inst *ucl.Inst, plugins *PluginFilters) {
if plugins == nil {
plugins = newPluginFilters()
}
a.mu.Lock()
defer a.mu.Unlock()
a.uclInst = inst
a.plugins = plugins
a.ctx = context.WithValue(a.baseCtx, uclInstKey, inst)
}
// current returns the interpreter, plugin registry and context in use.
func (a *App) current() (*ucl.Inst, *PluginFilters, context.Context) {
a.mu.RLock()
defer a.mu.RUnlock()
return a.uclInst, a.plugins, a.ctx
}
// loadPluginsFrom builds a fresh interpreter and plugin registry, loads the plugin files found under
// dir into them, and installs them as the current instance. Returns the number of plugin filters
// registered. Any load errors are available from the new registry's Errors method.
func (a *App) loadPluginsFrom(dir string) int {
plugins := newPluginFilters()
inst := newUCLInst(plugins)
plugins.LoadDir(context.WithValue(a.baseCtx, uclInstKey, inst), inst, dir)
a.setUCLInst(inst, plugins)
return len(plugins.filters)
}
// startup is called when the app starts. The context is saved
// so we can call the runtime methods
func (a *App) startup(ctx context.Context) {
a.ctx = context.WithValue(ctx, uclInstKey, a.uclInst)
a.baseCtx = ctx
dir, err := pluginDir()
if err != nil {
log.Printf("cannot determine plugin directory: %v", err)
a.setUCLInst(newUCLInst(newPluginFilters()), nil)
return
}
a.plugins.LoadDir(a.ctx, a.uclInst, dir)
a.loadPluginsFrom(dir)
}
// domReady is called once the frontend has loaded. Reports any plugin load errors in the status bar.
func (a *App) domReady(ctx context.Context) {
errs := a.plugins.Errors()
if len(errs) == 0 {
_, plugins, _ := a.current()
a.reportPluginStatus(len(plugins.filters), false)
}
// ReloadPlugins discards the current plugin filters and UCL interpreter, rescans the plugin
// directory, and refreshes the command palette. Invoked from the File menu.
func (a *App) ReloadPlugins() {
dir, err := pluginDir()
if err != nil {
_, _, ctx := a.current()
runtime.EventsEmit(ctx, "set-statusbar-message", SetStatusbarMessage{
Message: fmt.Sprintf("Cannot determine plugin directory: %v", err),
Error: true,
})
return
}
runtime.EventsEmit(a.ctx, "set-statusbar-message", SetStatusbarMessage{
Message: fmt.Sprintf("%d plugin file(s) failed to load: %v", len(errs), errs[0]),
Error: true,
})
count := a.loadPluginsFrom(dir)
_, _, ctx := a.current()
runtime.EventsEmit(ctx, "processors-changed")
a.reportPluginStatus(count, true)
}
// reportPluginStatus shows plugin load errors in the status bar. If there are no errors and
// reloaded is true, a confirmation message is shown instead.
func (a *App) reportPluginStatus(filterCount int, reloaded bool) {
_, plugins, ctx := a.current()
if errs := plugins.Errors(); len(errs) > 0 {
runtime.EventsEmit(ctx, "set-statusbar-message", SetStatusbarMessage{
Message: fmt.Sprintf("%d plugin file(s) failed to load: %v", len(errs), errs[0]),
Error: true,
})
return
}
if reloaded {
runtime.EventsEmit(ctx, "set-statusbar-message", SetStatusbarMessage{
Message: fmt.Sprintf("Plugins reloaded: %d filter(s)", filterCount),
})
}
}
// lookupProcessor finds a text processor by action key, preferring plugin-defined filters.
func (a *App) lookupProcessor(name string) (TextProcessor, bool) {
if proc, ok := a.plugins.filters[name]; ok {
_, plugins, _ := a.current()
if proc, ok := plugins.filters[name]; ok {
return proc, true
}
proc, ok := TextFilters[name]
@ -88,8 +169,10 @@ func (a *App) SaveCurrentBuffer(buffer string) error {
}
func (a *App) ListProcessors() (resp []ListProcessorsResponse) {
pluginLabels := make(map[string]struct{}, len(a.plugins.filters))
for k, v := range a.plugins.filters {
_, plugins, _ := a.current()
pluginLabels := make(map[string]struct{}, len(plugins.filters))
for k, v := range plugins.filters {
pluginLabels[v.Label] = struct{}{}
resp = append(resp, ListProcessorsResponse{Name: k, Label: v.Label})
}
@ -104,13 +187,15 @@ func (a *App) ListProcessors() (resp []ListProcessorsResponse) {
}
func (a *App) TriggerTextProcess(action string) {
runtime.EventsEmit(a.ctx, "request-text-process", RequestTextProcess{
_, _, ctx := a.current()
runtime.EventsEmit(ctx, "request-text-process", RequestTextProcess{
Action: action,
})
}
func (a *App) PromptUser(label string, resp func(string)) {
runtime.EventsOnce(a.ctx, "prompt-response", func(data ...interface{}) {
_, _, ctx := a.current()
runtime.EventsOnce(ctx, "prompt-response", func(data ...interface{}) {
if len(data) == 0 {
return
}
@ -120,21 +205,29 @@ func (a *App) PromptUser(label string, resp func(string)) {
}
resp(ans)
})
runtime.EventsEmit(a.ctx, "prompt-request", PromptRequest{
runtime.EventsEmit(ctx, "prompt-request", PromptRequest{
Label: label,
})
}
func (a *App) ProcessText(req ProcessTextRequest) {
// Capture the context once so the whole operation runs against a single interpreter, even if
// plugins are reloaded part way through.
_, _, ctx := a.current()
filter, ok := a.lookupProcessor(req.Action)
if !ok {
log.Printf("Unknown filter: [%s]", req.Action)
runtime.EventsEmit(ctx, "set-statusbar-message", SetStatusbarMessage{
Message: fmt.Sprintf("Unknown filter: %s", req.Action),
Error: true,
})
return
}
applyFilter := func(filter TextFilter) {
resp, err := moslice.MapWithError(req.Input, func(span TextSpan) (TextSpan, error) {
outRes, err := filter(a.ctx, span.Text)
outRes, err := filter(ctx, span.Text)
if err != nil {
return TextSpan{}, err
}
@ -147,14 +240,14 @@ func (a *App) ProcessText(req ProcessTextRequest) {
}, nil
})
if err != nil {
runtime.EventsEmit(a.ctx, "set-statusbar-message", SetStatusbarMessage{
runtime.EventsEmit(ctx, "set-statusbar-message", SetStatusbarMessage{
Message: fmt.Sprintf("Error running filter: %v", err.Error()),
Error: true,
})
return
}
runtime.EventsEmit(a.ctx, "process-text-response", ProcessTextResponse{
runtime.EventsEmit(ctx, "process-text-response", ProcessTextResponse{
Output: resp,
})
}
@ -169,25 +262,25 @@ func (a *App) ProcessText(req ProcessTextRequest) {
inBfr.WriteString(span.Text)
}
msg, err := filter.Analyze(a.ctx, inBfr.String())
msg, err := filter.Analyze(ctx, inBfr.String())
if err != nil {
runtime.EventsEmit(a.ctx, "set-statusbar-message", SetStatusbarMessage{
runtime.EventsEmit(ctx, "set-statusbar-message", SetStatusbarMessage{
Message: fmt.Sprintf("Error running analysis: %v", err.Error()),
Error: true,
})
return
}
runtime.EventsEmit(a.ctx, "set-statusbar-message", SetStatusbarMessage{
runtime.EventsEmit(ctx, "set-statusbar-message", SetStatusbarMessage{
Message: msg,
})
case filter.Filter != nil:
applyFilter(filter.Filter)
case filter.FilterWithArg != nil:
a.PromptUser(filter.FilterWithArg.Label, func(ans string) {
filter, err := filter.FilterWithArg.OnConfirm(a.ctx, ans)
filter, err := filter.FilterWithArg.OnConfirm(ctx, ans)
if err != nil {
runtime.EventsEmit(a.ctx, "set-statusbar-message", SetStatusbarMessage{
runtime.EventsEmit(ctx, "set-statusbar-message", SetStatusbarMessage{
Message: fmt.Sprintf("Error running filter: %v", err.Error()),
Error: true,
})