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

View file

@ -31,6 +31,9 @@ A plugin filter with the same label as a built-in filter replaces it in the pale
define the same label, the one loaded later wins (files are loaded in lexical path order). Files that define the same label, the one loaded later wins (files are loaded in lexical path order). Files that
fail to load are skipped and reported in the status bar; the remaining files are still loaded. fail to load are skipped and reported in the status bar; the remaining files are still loaded.
After editing plugin files, choose File → Reload Plugins (Shift+Cmd+R) to rescan the directory without
restarting. This discards the previous plugin filters and UCL interpreter state before loading the files again.
## Download ## Download
[Download Release](/dequoter-darwin-arm64.zip) [Download Release](/dequoter-darwin-arm64.zip)

155
app.go
View file

@ -6,6 +6,7 @@ import (
"log" "log"
"sort" "sort"
"strings" "strings"
"sync"
"github.com/wailsapp/wails/v2/pkg/runtime" "github.com/wailsapp/wails/v2/pkg/runtime"
"lmika.dev/pkg/modash/moslice" "lmika.dev/pkg/modash/moslice"
@ -15,17 +16,21 @@ import (
// App struct // App struct
type 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 uclInst *ucl.Inst
plugins *PluginFilters 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 // newUCLInst creates a UCL interpreter with the standard modules plus the plugin module.
func NewApp(store *Store) *App { func newUCLInst(plugins *PluginFilters) *ucl.Inst {
plugins := newPluginFilters() return ucl.New(
uclInst := ucl.New(
ucl.WithModule(plugins.Module()), ucl.WithModule(plugins.Module()),
ucl.WithModule(builtins.CSV(nil)), ucl.WithModule(builtins.CSV(nil)),
ucl.WithModule(builtins.Fns()), ucl.WithModule(builtins.Fns()),
@ -37,42 +42,118 @@ func NewApp(store *Store) *App {
ucl.WithModule(builtins.Strs()), ucl.WithModule(builtins.Strs()),
ucl.WithModule(builtins.Time()), ucl.WithModule(builtins.Time()),
) )
return &App{ }
// NewApp creates a new App application struct
func NewApp(store *Store) *App {
app := &App{
store: store, store: store,
uclInst: uclInst, baseCtx: context.Background(),
plugins: plugins,
} }
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 // startup is called when the app starts. The context is saved
// so we can call the runtime methods // so we can call the runtime methods
func (a *App) startup(ctx context.Context) { func (a *App) startup(ctx context.Context) {
a.ctx = context.WithValue(ctx, uclInstKey, a.uclInst) a.baseCtx = ctx
dir, err := pluginDir() dir, err := pluginDir()
if err != nil { if err != nil {
log.Printf("cannot determine plugin directory: %v", err) log.Printf("cannot determine plugin directory: %v", err)
a.setUCLInst(newUCLInst(newPluginFilters()), nil)
return 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. // domReady is called once the frontend has loaded. Reports any plugin load errors in the status bar.
func (a *App) domReady(ctx context.Context) { func (a *App) domReady(ctx context.Context) {
errs := a.plugins.Errors() _, plugins, _ := a.current()
if len(errs) == 0 { 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 return
} }
runtime.EventsEmit(a.ctx, "set-statusbar-message", SetStatusbarMessage{ count := a.loadPluginsFrom(dir)
Message: fmt.Sprintf("%d plugin file(s) failed to load: %v", len(errs), errs[0]),
Error: true, _, _, 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. // lookupProcessor finds a text processor by action key, preferring plugin-defined filters.
func (a *App) lookupProcessor(name string) (TextProcessor, bool) { 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 return proc, true
} }
proc, ok := TextFilters[name] proc, ok := TextFilters[name]
@ -88,8 +169,10 @@ func (a *App) SaveCurrentBuffer(buffer string) error {
} }
func (a *App) ListProcessors() (resp []ListProcessorsResponse) { func (a *App) ListProcessors() (resp []ListProcessorsResponse) {
pluginLabels := make(map[string]struct{}, len(a.plugins.filters)) _, plugins, _ := a.current()
for k, v := range a.plugins.filters {
pluginLabels := make(map[string]struct{}, len(plugins.filters))
for k, v := range plugins.filters {
pluginLabels[v.Label] = struct{}{} pluginLabels[v.Label] = struct{}{}
resp = append(resp, ListProcessorsResponse{Name: k, Label: v.Label}) 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) { 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, Action: action,
}) })
} }
func (a *App) PromptUser(label string, resp func(string)) { 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 { if len(data) == 0 {
return return
} }
@ -120,21 +205,29 @@ func (a *App) PromptUser(label string, resp func(string)) {
} }
resp(ans) resp(ans)
}) })
runtime.EventsEmit(a.ctx, "prompt-request", PromptRequest{ runtime.EventsEmit(ctx, "prompt-request", PromptRequest{
Label: label, Label: label,
}) })
} }
func (a *App) ProcessText(req ProcessTextRequest) { 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) filter, ok := a.lookupProcessor(req.Action)
if !ok { if !ok {
log.Printf("Unknown filter: [%s]", req.Action) 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 return
} }
applyFilter := func(filter TextFilter) { applyFilter := func(filter TextFilter) {
resp, err := moslice.MapWithError(req.Input, func(span TextSpan) (TextSpan, error) { 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 { if err != nil {
return TextSpan{}, err return TextSpan{}, err
} }
@ -147,14 +240,14 @@ func (a *App) ProcessText(req ProcessTextRequest) {
}, nil }, nil
}) })
if err != 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()), Message: fmt.Sprintf("Error running filter: %v", err.Error()),
Error: true, Error: true,
}) })
return return
} }
runtime.EventsEmit(a.ctx, "process-text-response", ProcessTextResponse{ runtime.EventsEmit(ctx, "process-text-response", ProcessTextResponse{
Output: resp, Output: resp,
}) })
} }
@ -169,25 +262,25 @@ func (a *App) ProcessText(req ProcessTextRequest) {
inBfr.WriteString(span.Text) inBfr.WriteString(span.Text)
} }
msg, err := filter.Analyze(a.ctx, inBfr.String()) msg, err := filter.Analyze(ctx, inBfr.String())
if err != nil { 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()), Message: fmt.Sprintf("Error running analysis: %v", err.Error()),
Error: true, Error: true,
}) })
return return
} }
runtime.EventsEmit(a.ctx, "set-statusbar-message", SetStatusbarMessage{ runtime.EventsEmit(ctx, "set-statusbar-message", SetStatusbarMessage{
Message: msg, Message: msg,
}) })
case filter.Filter != nil: case filter.Filter != nil:
applyFilter(filter.Filter) applyFilter(filter.Filter)
case filter.FilterWithArg != nil: case filter.FilterWithArg != nil:
a.PromptUser(filter.FilterWithArg.Label, func(ans string) { 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 { 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()), Message: fmt.Sprintf("Error running filter: %v", err.Error()),
Error: true, Error: true,
}) })

View file

@ -12,15 +12,33 @@ export class CommandsController extends Controller {
async connect() { async connect() {
this._lastCommand = null; this._lastCommand = null;
this._options = [];
await this._loadProcessors();
window.runtime.EventsOn("processors-changed", () => {
this._loadProcessors();
});
}
async _loadProcessors() {
let processors = await ListProcessors(); let processors = await ListProcessors();
processors.forEach((processor) => {
let options = processors.map((processor) => {
let option = document.createElement("option"); let option = document.createElement("option");
option.value = processor.name; option.value = processor.name;
option.text = processor.label; option.text = processor.label;
this.commandSelectTarget.appendChild(option); return option;
}); });
this._options = Array.from(this.commandSelectTarget.options); this._options = options;
this.commandSelectTarget.replaceChildren(...options);
if (this._lastCommand !== null && !options.some(opt => opt.value === this._lastCommand)) {
this._lastCommand = null;
}
if (this.element.open) {
this._filterOptions(this.commandInputTarget.value);
}
} }
showCommands(ev) { showCommands(ev) {

View file

@ -37,9 +37,14 @@ func main() {
appMenu.Append(menu.AppMenu()) // On macOS platform, this must be done right after `NewMenu()` appMenu.Append(menu.AppMenu()) // On macOS platform, this must be done right after `NewMenu()`
} }
fileMenu := appMenu.AddSubmenu("File") fileMenu := appMenu.AddSubmenu("File")
fileMenu.AddText("Reload Plugins", keys.Combo("r", keys.CmdOrCtrlKey, keys.ShiftKey), func(_ *menu.CallbackData) {
app.ReloadPlugins()
})
fileMenu.AddSeparator()
fileMenu.AddText("Quit", keys.CmdOrCtrl("q"), func(_ *menu.CallbackData) { fileMenu.AddText("Quit", keys.CmdOrCtrl("q"), func(_ *menu.CallbackData) {
// `rt` is an alias of "github.com/wailsapp/wails/v2/pkg/runtime" to prevent collision with standard package // `rt` is an alias of "github.com/wailsapp/wails/v2/pkg/runtime" to prevent collision with standard package
rt.Quit(app.ctx) _, _, ctx := app.current()
rt.Quit(ctx)
}) })
if runtime.GOOS == "darwin" { if runtime.GOOS == "darwin" {

View file

@ -281,7 +281,10 @@ func TestApp_PluginIntegration(t *testing.T) {
writePluginFile(t, dir, "upper.ucl", `d:filter "String: To Upper Case" { |in| strs:to-upper $in }`) writePluginFile(t, dir, "upper.ucl", `d:filter "String: To Upper Case" { |in| strs:to-upper $in }`)
writePluginFile(t, dir, "rev.ucl", `d:filter "Custom: Echo" { |in| $in }`) writePluginFile(t, dir, "rev.ucl", `d:filter "Custom: Echo" { |in| $in }`)
app.plugins.LoadDir(ctx, app.uclInst, dir) if n := app.loadPluginsFrom(dir); n != 2 {
t.Fatalf("expected 2 filters loaded, got %d", n)
}
_ = ctx
t.Run("ListProcessors shadows built-in by label", func(t *testing.T) { t.Run("ListProcessors shadows built-in by label", func(t *testing.T) {
resp := app.ListProcessors() resp := app.ListProcessors()
@ -318,3 +321,92 @@ func TestApp_PluginIntegration(t *testing.T) {
} }
}) })
} }
func TestApp_ReloadPlugins(t *testing.T) {
ctx := context.Background()
app := NewApp(nil)
dir := t.TempDir()
writePluginFile(t, dir, "a.ucl", `
proc helper { |s| strs:to-upper $s }
d:filter "Filter A" { |in| helper $in }
`)
if n := app.loadPluginsFrom(dir); n != 1 {
t.Fatalf("expected 1 filter, got %d", n)
}
firstInst, firstPlugins, firstCtx := app.current()
if uclInstFromContext(firstCtx) != firstInst {
t.Fatal("context does not carry the current interpreter")
}
if res, err := firstInst.EvalString(ctx, `helper "x"`); err != nil || res != "X" {
t.Fatalf("helper proc should be defined: %v %v", res, err)
}
filterA, ok := firstPlugins.filters["plugin:filter-a"]
if !ok {
t.Fatal("Filter A not registered")
}
// Replace a.ucl with b.ucl and reload.
if err := os.Remove(filepath.Join(dir, "a.ucl")); err != nil {
t.Fatal(err)
}
writePluginFile(t, dir, "b.ucl", `d:filter "Filter B" { |in| strs:to-lower $in }`)
if n := app.loadPluginsFrom(dir); n != 1 {
t.Fatalf("expected 1 filter after reload, got %d", n)
}
secondInst, secondPlugins, secondCtx := app.current()
if secondInst == firstInst {
t.Error("interpreter should have been rebuilt")
}
if uclInstFromContext(secondCtx) != secondInst {
t.Error("context should carry the new interpreter")
}
if _, ok := secondPlugins.filters["plugin:filter-a"]; ok {
t.Error("Filter A should have been deregistered")
}
if _, ok := app.lookupProcessor("plugin:filter-a"); ok {
t.Error("lookupProcessor should no longer find Filter A")
}
if proc, ok := app.lookupProcessor("plugin:filter-b"); !ok || proc.Label != "Filter B" {
t.Errorf("Filter B should be registered, got %v %v", proc, ok)
}
if _, err := secondInst.EvalString(ctx, `helper "x"`); err == nil {
t.Error("helper proc from the removed file should no longer be defined")
}
t.Run("filter captured before reload still works", func(t *testing.T) {
res, err := filterA.Filter(ctx, "abc")
if err != nil {
t.Fatal(err)
}
if res.Output != "ABC" {
t.Errorf("output = %q", res.Output)
}
})
t.Run("reload with bad file reports fresh errors", func(t *testing.T) {
writePluginFile(t, dir, "c.ucl", `d:filter "Broken" { |in|`)
n := app.loadPluginsFrom(dir)
_, plugins, _ := app.current()
if n != 1 || len(plugins.filters) != 1 {
t.Errorf("expected only Filter B to survive, got n=%d filters=%v", n, plugins.filters)
}
if errs := plugins.Errors(); len(errs) != 1 || !strings.Contains(errs[0].Error(), "c.ucl") {
t.Errorf("expected one error naming c.ucl, got %v", errs)
}
if err := os.Remove(filepath.Join(dir, "c.ucl")); err != nil {
t.Fatal(err)
}
app.loadPluginsFrom(dir)
if _, plugins, _ := app.current(); len(plugins.Errors()) != 0 {
t.Errorf("errors should be cleared after a clean reload, got %v", plugins.Errors())
}
})
}