package main import ( "context" "fmt" "log" "sort" "strings" "sync" "github.com/wailsapp/wails/v2/pkg/runtime" "lmika.dev/pkg/modash/moslice" "ucl.lmika.dev/ucl" "ucl.lmika.dev/ucl/builtins" ) // App struct type App struct { 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 // baseCtx is the raw Wails context supplied to startup. ctx is derived from it. baseCtx context.Context } // 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.Bytes()), ucl.WithModule(builtins.CSV(nil)), ucl.WithModule(builtins.Fns()), ucl.WithModule(builtins.FS(nil)), ucl.WithModule(builtins.Itrs()), ucl.WithModule(builtins.Lists()), ucl.WithModule(builtins.Log(nil)), ucl.WithModule(builtins.OS()), ucl.WithModule(builtins.Strs()), ucl.WithModule(builtins.Time()), ucl.WithModule(builtins.URLs()), ) } // NewApp creates a new App application struct func NewApp(store *Store) *App { app := &App{ store: store, 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.baseCtx = ctx dir, err := pluginDir() if err != nil { log.Printf("cannot determine plugin directory: %v", err) a.setUCLInst(newUCLInst(newPluginFilters()), nil) return } 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) { _, 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 } 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) { _, plugins, _ := a.current() if proc, ok := plugins.filters[name]; ok { return proc, true } proc, ok := TextFilters[name] return proc, ok } func (a *App) LoadCurrentBuffer() (string, error) { return a.store.LoadBuffer() } func (a *App) SaveCurrentBuffer(buffer string) error { return a.store.SaveBuffer(buffer) } func (a *App) ListProcessors() (resp []ListProcessorsResponse) { _, 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}) } for k, v := range TextFilters { if _, shadowed := pluginLabels[v.Label]; shadowed { continue } resp = append(resp, ListProcessorsResponse{Name: k, Label: v.Label}) } sort.Slice(resp, func(i, j int) bool { return resp[i].Label < resp[j].Label }) return resp } func (a *App) TriggerTextProcess(action string) { _, _, ctx := a.current() runtime.EventsEmit(ctx, "request-text-process", RequestTextProcess{ Action: action, }) } func (a *App) PromptUser(label string, resp func(string)) { _, _, ctx := a.current() runtime.EventsOnce(ctx, "prompt-response", func(data ...interface{}) { if len(data) == 0 { return } ans, ok := data[0].(string) if !ok { return } resp(ans) }) 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(ctx, span.Text) if err != nil { return TextSpan{}, err } return TextSpan{ Text: outRes.Output, Pos: span.Pos, Len: span.Len, Append: outRes.Append, }, nil }) if err != nil { runtime.EventsEmit(ctx, "set-statusbar-message", SetStatusbarMessage{ Message: fmt.Sprintf("Error running filter: %v", err.Error()), Error: true, }) return } runtime.EventsEmit(ctx, "process-text-response", ProcessTextResponse{ Output: resp, }) } switch { case filter.Analyze != nil: inBfr := strings.Builder{} for _, span := range req.Input { if inBfr.Len() > 0 { inBfr.WriteString("\n") } inBfr.WriteString(span.Text) } msg, err := filter.Analyze(ctx, inBfr.String()) if err != nil { runtime.EventsEmit(ctx, "set-statusbar-message", SetStatusbarMessage{ Message: fmt.Sprintf("Error running analysis: %v", err.Error()), Error: true, }) return } 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(ctx, ans) if err != nil { runtime.EventsEmit(ctx, "set-statusbar-message", SetStatusbarMessage{ Message: fmt.Sprintf("Error running filter: %v", err.Error()), Error: true, }) return } applyFilter(filter) }) } }