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
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 Release](/dequoter-darwin-arm64.zip)

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,
})

View file

@ -12,15 +12,33 @@ export class CommandsController extends Controller {
async connect() {
this._lastCommand = null;
this._options = [];
await this._loadProcessors();
window.runtime.EventsOn("processors-changed", () => {
this._loadProcessors();
});
}
async _loadProcessors() {
let processors = await ListProcessors();
processors.forEach((processor) => {
let options = processors.map((processor) => {
let option = document.createElement("option");
option.value = processor.name;
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) {

View file

@ -37,9 +37,14 @@ func main() {
appMenu.Append(menu.AppMenu()) // On macOS platform, this must be done right after `NewMenu()`
}
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) {
// `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" {

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, "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) {
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())
}
})
}