Added plugin reload
This commit is contained in:
parent
ac437b7409
commit
18604ec5a6
5 changed files with 247 additions and 36 deletions
|
|
@ -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
155
app.go
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
7
main.go
7
main.go
|
|
@ -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" {
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue