From ac437b74099a87398de70c0f85362115946e7dd0 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Mon, 7 Sep 2026 01:09:21 +0000 Subject: [PATCH 1/4] Added plugin support for Dequoter --- README.md | 21 ++++ app.go | 43 ++++++- main.go | 1 + plugins.go | 144 ++++++++++++++++++++++ plugins_test.go | 320 ++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 528 insertions(+), 1 deletion(-) create mode 100644 plugins.go create mode 100644 plugins_test.go diff --git a/README.md b/README.md index f727d8a..b17f494 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,27 @@ where all your chances will be lost when you close it. This project uses [Wails](https://wails.io) to build a desktop application with a Go backend and a Vite frontend. +## Plugins + +Dequoter can be extended with new filters written in [UCL](https://ucl.lmika.dev). +On startup, if the directory `$HOME/.config/dequoter/plugins` exists, every file ending in `.ucl` +found in it (including subdirectories) is evaluated. Filters are defined with the `d:filter` builtin: + +``` +d:filter "String: To Upper Case" { |in| + strs:to-upper $in +} +``` + +`d:filter` takes a label, which is what appears in the command palette (Cmd+P), and a proc. The proc is +called with the text to process as its only argument. The result of the proc becomes the filter output: +strings are used as-is, other values are converted to their string form, and a proc that returns nothing +is reported as an error. Errors raised with `error` are shown in the status bar. + +A plugin filter with the same label as a built-in filter replaces it in the palette. If two plugin files +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. + ## Download [Download Release](/dequoter-darwin-arm64.zip) diff --git a/app.go b/app.go index 7148f8b..3a8f012 100644 --- a/app.go +++ b/app.go @@ -17,13 +17,16 @@ import ( type App struct { store *Store uclInst *ucl.Inst + plugins *PluginFilters ctx context.Context } // NewApp creates a new App application struct func NewApp(store *Store) *App { + plugins := newPluginFilters() uclInst := ucl.New( + ucl.WithModule(plugins.Module()), ucl.WithModule(builtins.CSV(nil)), ucl.WithModule(builtins.Fns()), ucl.WithModule(builtins.FS(nil)), @@ -37,6 +40,7 @@ func NewApp(store *Store) *App { return &App{ store: store, uclInst: uclInst, + plugins: plugins, } } @@ -44,6 +48,35 @@ func NewApp(store *Store) *App { // so we can call the runtime methods func (a *App) startup(ctx context.Context) { a.ctx = context.WithValue(ctx, uclInstKey, a.uclInst) + + dir, err := pluginDir() + if err != nil { + log.Printf("cannot determine plugin directory: %v", err) + return + } + a.plugins.LoadDir(a.ctx, a.uclInst, 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 { + 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, + }) +} + +// 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 { + return proc, true + } + proc, ok := TextFilters[name] + return proc, ok } func (a *App) LoadCurrentBuffer() (string, error) { @@ -55,7 +88,15 @@ 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 { + 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 }) @@ -85,7 +126,7 @@ func (a *App) PromptUser(label string, resp func(string)) { } func (a *App) ProcessText(req ProcessTextRequest) { - filter, ok := TextFilters[req.Action] + filter, ok := a.lookupProcessor(req.Action) if !ok { log.Printf("Unknown filter: [%s]", req.Action) return diff --git a/main.go b/main.go index 2145e34..cc7ade6 100644 --- a/main.go +++ b/main.go @@ -57,6 +57,7 @@ func main() { }, BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1}, OnStartup: app.startup, + OnDomReady: app.domReady, Bind: []interface{}{ app, }, diff --git a/plugins.go b/plugins.go new file mode 100644 index 0000000..86dc977 --- /dev/null +++ b/plugins.go @@ -0,0 +1,144 @@ +package main + +import ( + "context" + "errors" + "fmt" + "io/fs" + "log" + "os" + "path/filepath" + "regexp" + "strings" + + "ucl.lmika.dev/ucl" +) + +const pluginKeyPrefix = "plugin:" + +// PluginFilters is the registry of text filters defined by UCL plugin files. +type PluginFilters struct { + inst *ucl.Inst + filters map[string]TextProcessor + errors []error +} + +func newPluginFilters() *PluginFilters { + return &PluginFilters{ + filters: make(map[string]TextProcessor), + } +} + +// Module returns the "d" UCL module, exposing d:filter. +func (p *PluginFilters) Module() ucl.Module { + return ucl.Module{ + Name: "d", + Builtins: map[string]ucl.BuiltinHandler{ + "filter": p.filterBuiltin, + }, + } +} + +// filterBuiltin implements `d:filter LABEL PROC`. PROC receives the filter input as its only +// argument and its result becomes the filter output. +func (p *PluginFilters) filterBuiltin(ctx context.Context, args ucl.CallArgs) (any, error) { + var ( + label string + proc ucl.Invokable + ) + + if err := args.Bind(&label, &proc); err != nil { + return nil, fmt.Errorf("d:filter: expected LABEL and PROC: %w", err) + } + if strings.TrimSpace(label) == "" { + return nil, errors.New("d:filter: label must not be empty") + } + if proc.IsNil() { + return nil, errors.New("d:filter: PROC must not be nil") + } + + p.filters[pluginKeyPrefix+slugify(label)] = TextProcessor{ + Label: label, + Filter: func(ctx context.Context, input string) (TextFilterResponse, error) { + res, err := proc.Invoke(ctx, input) + if err != nil { + return TextFilterResponse{}, err + } + + switch v := res.(type) { + case nil: + return TextFilterResponse{}, errors.New("filter returned no value") + case string: + return TextFilterResponse{Output: v}, nil + default: + return TextFilterResponse{Output: fmt.Sprint(v)}, nil + } + }, + } + return nil, nil +} + +// LoadDir evaluates every *.ucl file found under dir (recursively, in lexical order). Files +// that fail to load are recorded in Errors() and skipped; loading continues with the rest. +// A missing directory is not an error. +func (p *PluginFilters) LoadDir(ctx context.Context, inst *ucl.Inst, dir string) { + if _, err := os.Stat(dir); err != nil { + if !errors.Is(err, fs.ErrNotExist) { + p.recordError(fmt.Errorf("%s: %w", dir, err)) + } + return + } + + _ = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + p.recordError(fmt.Errorf("%s: %w", path, err)) + return nil + } + if d.IsDir() || !strings.EqualFold(filepath.Ext(path), ".ucl") { + return nil + } + + if err := p.loadFile(ctx, inst, path); err != nil { + p.recordError(fmt.Errorf("%s: %w", path, err)) + } + return nil + }) +} + +func (p *PluginFilters) loadFile(ctx context.Context, inst *ucl.Inst, path string) error { + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + + _, err = inst.Eval(ctx, f) + return err +} + +func (p *PluginFilters) recordError(err error) { + log.Printf("plugin load error: %v", err) + p.errors = append(p.errors, err) +} + +// Errors returns the errors encountered while loading plugin files. +func (p *PluginFilters) Errors() []error { + return p.errors +} + +// pluginDir returns the plugin directory: $HOME/.config/dequoter/plugins +func pluginDir() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".config", "dequoter", "plugins"), nil +} + +var nonSlugChars = regexp.MustCompile(`[^a-z0-9]+`) + +// slugify converts a label into a key-safe slug: lower-cased, with runs of characters other than +// [a-z0-9] replaced by a single hyphen and leading/trailing hyphens removed. +func slugify(label string) string { + return strings.Trim(nonSlugChars.ReplaceAllString(strings.ToLower(label), "-"), "-") +} diff --git a/plugins_test.go b/plugins_test.go new file mode 100644 index 0000000..522d2e6 --- /dev/null +++ b/plugins_test.go @@ -0,0 +1,320 @@ +package main + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "ucl.lmika.dev/ucl" + "ucl.lmika.dev/ucl/builtins" +) + +func newTestPlugins() (*PluginFilters, *ucl.Inst) { + plugins := newPluginFilters() + inst := ucl.New( + ucl.WithModule(plugins.Module()), + ucl.WithModule(builtins.Strs()), + ) + return plugins, inst +} + +func writePluginFile(t *testing.T, dir, name, content string) { + t.Helper() + path := filepath.Join(dir, name) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } +} + +func TestPluginFilters_LoadDir(t *testing.T) { + ctx := context.Background() + + t.Run("registers filter from upper-case example", func(t *testing.T) { + plugins, inst := newTestPlugins() + dir := t.TempDir() + writePluginFile(t, dir, "upper.ucl", ` +d:filter "String: To Upper Case" { |in| + strs:to-upper $in +} +`) + + plugins.LoadDir(ctx, inst, dir) + + if len(plugins.Errors()) != 0 { + t.Fatalf("unexpected errors: %v", plugins.Errors()) + } + proc, ok := plugins.filters["plugin:string-to-upper-case"] + if !ok { + t.Fatalf("filter not registered, have %v", plugins.filters) + } + if proc.Label != "String: To Upper Case" { + t.Errorf("label = %q", proc.Label) + } + res, err := proc.Filter(ctx, "abc") + if err != nil { + t.Fatal(err) + } + if res.Output != "ABC" || res.Append { + t.Errorf("got %+v", res) + } + }) + + t.Run("finds nested files and ignores non-ucl files", func(t *testing.T) { + plugins, inst := newTestPlugins() + dir := t.TempDir() + writePluginFile(t, dir, "sub/dir/nested.ucl", `d:filter "Nested" { |in| $in }`) + writePluginFile(t, dir, "notes.txt", `d:filter "Ignored" { |in| $in }`) + writePluginFile(t, dir, "README.md", `this is not ucl {`) + + plugins.LoadDir(ctx, inst, dir) + + if len(plugins.Errors()) != 0 { + t.Fatalf("unexpected errors: %v", plugins.Errors()) + } + if _, ok := plugins.filters["plugin:nested"]; !ok { + t.Error("nested filter not registered") + } + if _, ok := plugins.filters["plugin:ignored"]; ok { + t.Error("non-ucl file should have been ignored") + } + }) + + t.Run("missing directory is not an error", func(t *testing.T) { + plugins, inst := newTestPlugins() + + plugins.LoadDir(ctx, inst, filepath.Join(t.TempDir(), "does-not-exist")) + + if len(plugins.Errors()) != 0 { + t.Fatalf("unexpected errors: %v", plugins.Errors()) + } + if len(plugins.filters) != 0 { + t.Errorf("unexpected filters: %v", plugins.filters) + } + }) + + t.Run("bad file is reported and sibling still loads", func(t *testing.T) { + plugins, inst := newTestPlugins() + dir := t.TempDir() + writePluginFile(t, dir, "a-bad.ucl", `d:filter "Broken" { |in| `) + writePluginFile(t, dir, "b-good.ucl", `d:filter "Good" { |in| $in }`) + writePluginFile(t, dir, "c-bad-usage.ucl", `d:filter "Only Label"`) + + plugins.LoadDir(ctx, inst, dir) + + errs := plugins.Errors() + if len(errs) != 2 { + t.Fatalf("expected 2 errors, got %v", errs) + } + if !strings.Contains(errs[0].Error(), "a-bad.ucl") { + t.Errorf("first error should name the file: %v", errs[0]) + } + if !strings.Contains(errs[1].Error(), "c-bad-usage.ucl") { + t.Errorf("second error should name the file: %v", errs[1]) + } + if _, ok := plugins.filters["plugin:good"]; !ok { + t.Error("good filter not registered") + } + if _, ok := plugins.filters["plugin:broken"]; ok { + t.Error("broken filter should not be registered") + } + }) + + t.Run("later definition with same label wins", func(t *testing.T) { + plugins, inst := newTestPlugins() + dir := t.TempDir() + writePluginFile(t, dir, "01-first.ucl", `d:filter "Dup" { |in| "first" }`) + writePluginFile(t, dir, "02-second.ucl", `d:filter "Dup" { |in| "second" }`) + + plugins.LoadDir(ctx, inst, dir) + + if len(plugins.filters) != 1 { + t.Fatalf("expected 1 filter, got %v", plugins.filters) + } + res, err := plugins.filters["plugin:dup"].Filter(ctx, "x") + if err != nil { + t.Fatal(err) + } + if res.Output != "second" { + t.Errorf("output = %q", res.Output) + } + }) + + t.Run("procs defined in plugin files are reusable", func(t *testing.T) { + plugins, inst := newTestPlugins() + dir := t.TempDir() + writePluginFile(t, dir, "helpers.ucl", ` +proc shout { |s| strs:to-upper $s } +d:filter "Shout" { |in| shout $in } +`) + + plugins.LoadDir(ctx, inst, dir) + + if len(plugins.Errors()) != 0 { + t.Fatalf("unexpected errors: %v", plugins.Errors()) + } + res, err := plugins.filters["plugin:shout"].Filter(ctx, "hi") + if err != nil { + t.Fatal(err) + } + if res.Output != "HI" { + t.Errorf("output = %q", res.Output) + } + }) +} + +func TestPluginFilters_ProcResults(t *testing.T) { + ctx := context.Background() + + tests := []struct { + name string + script string + input string + want string + wantErr string + }{ + { + name: "string result used as-is", + script: `d:filter "F" { |in| strs:to-upper $in }`, + input: "abc", + want: "ABC", + }, + { + name: "non-string result is stringified", + script: `d:filter "F" { |in| add 40 2 }`, + input: "ignored", + want: "42", + }, + { + name: "nil result is an error", + script: `d:filter "F" { |in| }`, + input: "abc", + wantErr: "filter returned no value", + }, + { + name: "error raised in proc propagates", + script: `d:filter "F" { |in| error "boom" }`, + input: "abc", + wantErr: "boom", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + plugins, inst := newTestPlugins() + if _, err := inst.EvalString(ctx, tt.script); err != nil { + t.Fatal(err) + } + proc, ok := plugins.filters["plugin:f"] + if !ok { + t.Fatalf("filter not registered") + } + + res, err := proc.Filter(ctx, tt.input) + if tt.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("expected error containing %q, got %v (res %+v)", tt.wantErr, err, res) + } + return + } + if err != nil { + t.Fatal(err) + } + if res.Output != tt.want { + t.Errorf("output = %q, want %q", res.Output, tt.want) + } + }) + } +} + +func TestPluginFilters_FilterBuiltinValidation(t *testing.T) { + ctx := context.Background() + + tests := []struct { + name string + script string + }{ + {name: "missing proc", script: `d:filter "Label"`}, + {name: "no args", script: `d:filter`}, + {name: "empty label", script: `d:filter " " { |in| $in }`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + plugins, inst := newTestPlugins() + if _, err := inst.EvalString(ctx, tt.script); err == nil { + t.Fatal("expected an error") + } + if len(plugins.filters) != 0 { + t.Errorf("no filter should be registered, got %v", plugins.filters) + } + }) + } +} + +func TestSlugify(t *testing.T) { + tests := []struct { + in string + want string + }{ + {"String: To Upper Case", "string-to-upper-case"}, + {" Lines: Go Template… ", "lines-go-template"}, + {"already-a-slug", "already-a-slug"}, + {"MiXeD CaSe 123", "mixed-case-123"}, + {"---", ""}, + } + for _, tt := range tests { + if got := slugify(tt.in); got != tt.want { + t.Errorf("slugify(%q) = %q, want %q", tt.in, got, tt.want) + } + } +} + +func TestApp_PluginIntegration(t *testing.T) { + ctx := context.Background() + app := NewApp(nil) + dir := t.TempDir() + 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) + + t.Run("ListProcessors shadows built-in by label", func(t *testing.T) { + resp := app.ListProcessors() + + byLabel := map[string][]string{} + for _, r := range resp { + byLabel[r.Label] = append(byLabel[r.Label], r.Name) + } + if names := byLabel["String: To Upper Case"]; len(names) != 1 || names[0] != "plugin:string-to-upper-case" { + t.Errorf("expected only the plugin entry for the shadowed label, got %v", names) + } + if names := byLabel["Custom: Echo"]; len(names) != 1 || names[0] != "plugin:custom-echo" { + t.Errorf("expected plugin entry, got %v", names) + } + if names := byLabel["String: To Lower Case"]; len(names) != 1 || names[0] != "lower-case" { + t.Errorf("built-in should be unaffected, got %v", names) + } + for i := 1; i < len(resp); i++ { + if resp[i-1].Label > resp[i].Label { + t.Errorf("response not sorted by label at %d: %q > %q", i, resp[i-1].Label, resp[i].Label) + } + } + }) + + t.Run("lookupProcessor prefers plugin then falls back to built-in", func(t *testing.T) { + if proc, ok := app.lookupProcessor("plugin:custom-echo"); !ok || proc.Label != "Custom: Echo" { + t.Errorf("plugin lookup failed: %v %v", proc, ok) + } + if proc, ok := app.lookupProcessor("lower-case"); !ok || proc.Label != "String: To Lower Case" { + t.Errorf("built-in lookup failed: %v %v", proc, ok) + } + if _, ok := app.lookupProcessor("nope"); ok { + t.Error("unknown key should not be found") + } + }) +} From 18604ec5a64d71cd026ba4df9ffafef63536a639 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Mon, 7 Sep 2026 22:57:44 +0000 Subject: [PATCH 2/4] Added plugin reload --- README.md | 3 + app.go | 155 ++++++++++++++---- .../src/controllers/commands_controller.js | 24 ++- main.go | 7 +- plugins_test.go | 94 ++++++++++- 5 files changed, 247 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index b17f494..11a7169 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/app.go b/app.go index 3a8f012..e46d5b9 100644 --- a/app.go +++ b/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, }) diff --git a/frontend/src/controllers/commands_controller.js b/frontend/src/controllers/commands_controller.js index 5d74380..928863a 100644 --- a/frontend/src/controllers/commands_controller.js +++ b/frontend/src/controllers/commands_controller.js @@ -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) { diff --git a/main.go b/main.go index cc7ade6..73b5430 100644 --- a/main.go +++ b/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" { diff --git a/plugins_test.go b/plugins_test.go index 522d2e6..f82f69e 100644 --- a/plugins_test.go +++ b/plugins_test.go @@ -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()) + } + }) +} From add62fbfa054d10e046fb4dc836a64e1e638ab5c Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Tue, 8 Sep 2026 22:03:41 +1000 Subject: [PATCH 3/4] Added map-lines and upgraded UCL --- .idea/dequoter.iml | 1 + app.go | 2 ++ frontend/wailsjs/go/main/App.d.ts | 2 ++ frontend/wailsjs/go/main/App.js | 4 +++ go.mod | 2 +- go.sum | 6 ++-- plugins.go | 51 +++++++++++++++++++++++++++++-- 7 files changed, 63 insertions(+), 5 deletions(-) diff --git a/.idea/dequoter.iml b/.idea/dequoter.iml index 5e764c4..9c408cc 100644 --- a/.idea/dequoter.iml +++ b/.idea/dequoter.iml @@ -1,6 +1,7 @@ + diff --git a/app.go b/app.go index e46d5b9..37d2bd1 100644 --- a/app.go +++ b/app.go @@ -32,6 +32,7 @@ type App struct { 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)), @@ -41,6 +42,7 @@ func newUCLInst(plugins *PluginFilters) *ucl.Inst { ucl.WithModule(builtins.OS()), ucl.WithModule(builtins.Strs()), ucl.WithModule(builtins.Time()), + ucl.WithModule(builtins.URLs()), ) } diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 56c40cf..f44f801 100755 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -10,6 +10,8 @@ export function ProcessText(arg1:main.ProcessTextRequest):Promise; export function PromptUser(arg1:string,arg2:any):Promise; +export function ReloadPlugins():Promise; + export function SaveCurrentBuffer(arg1:string):Promise; export function TriggerTextProcess(arg1:string):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 144d9f3..78a313d 100755 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -18,6 +18,10 @@ export function PromptUser(arg1, arg2) { return window['go']['main']['App']['PromptUser'](arg1, arg2); } +export function ReloadPlugins() { + return window['go']['main']['App']['ReloadPlugins'](); +} + export function SaveCurrentBuffer(arg1) { return window['go']['main']['App']['SaveCurrentBuffer'](arg1); } diff --git a/go.mod b/go.mod index 9a8d136..d967a05 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( gopkg.in/yaml.v3 v3.0.1 lmika.dev/pkg/modash v0.1.0 lmika.dev/pkg/progdoc v0.0.0-20260207235039-984257fb414b - ucl.lmika.dev v0.1.3 + ucl.lmika.dev v0.1.7 ) require ( diff --git a/go.sum b/go.sum index 04dd12d..94bf82c 100644 --- a/go.sum +++ b/go.sum @@ -796,5 +796,7 @@ lmika.dev/pkg/modash v0.1.0/go.mod h1:8NDl/yR1eCCEhip9FJlVuMNXIeaztQ0Ks/tizExFcT lmika.dev/pkg/progdoc v0.0.0-20260207235039-984257fb414b h1:uarBEkpjAnpzO98btsA6ZDw9oGnJX8zSb8gZnCFl+6Y= lmika.dev/pkg/progdoc v0.0.0-20260207235039-984257fb414b/go.mod h1:GefQ+wxW6L0p2TfB4fzoP7ihOgEt5Lb/1qgXhruVHos= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= -ucl.lmika.dev v0.1.3 h1:Y2m+ORnLMA8boQmkXpW41VbkR3bwmnZjDLGVTYbZQic= -ucl.lmika.dev v0.1.3/go.mod h1:Hjl6Udph2yuOPcBSytz4SFJVt5MZbM5JyiSAyTMTbNU= +ucl.lmika.dev v0.1.6 h1:cZocpR/IQcy9nvE8u+SSLY7/PE9fd1HIVWhZr/m+U5M= +ucl.lmika.dev v0.1.6/go.mod h1:Hjl6Udph2yuOPcBSytz4SFJVt5MZbM5JyiSAyTMTbNU= +ucl.lmika.dev v0.1.7 h1:3yfvWOhBnWpQIm4NhKrUBh5PajB1wzUWzoPPa8ltaVo= +ucl.lmika.dev v0.1.7/go.mod h1:Hjl6Udph2yuOPcBSytz4SFJVt5MZbM5JyiSAyTMTbNU= diff --git a/plugins.go b/plugins.go index 86dc977..baaf476 100644 --- a/plugins.go +++ b/plugins.go @@ -1,6 +1,8 @@ package main import ( + "bufio" + "bytes" "context" "errors" "fmt" @@ -18,7 +20,6 @@ const pluginKeyPrefix = "plugin:" // PluginFilters is the registry of text filters defined by UCL plugin files. type PluginFilters struct { - inst *ucl.Inst filters map[string]TextProcessor errors []error } @@ -34,7 +35,8 @@ func (p *PluginFilters) Module() ucl.Module { return ucl.Module{ Name: "d", Builtins: map[string]ucl.BuiltinHandler{ - "filter": p.filterBuiltin, + "filter": p.filterBuiltin, + "map-lines": p.mapLinesBuiltin, }, } } @@ -78,6 +80,51 @@ func (p *PluginFilters) filterBuiltin(ctx context.Context, args ucl.CallArgs) (a return nil, nil } +// mapLinesBuiltin implements `d:map-lines INPUT PROC` which applies a transform for each line of the input. +func (p *PluginFilters) mapLinesBuiltin(ctx context.Context, args ucl.CallArgs) (any, error) { + var ( + input string + proc ucl.Invokable + ) + + if err := args.Bind(&input, &proc); err != nil { + return nil, fmt.Errorf("d:map-lines: expected INPUT and PROC: %w", err) + } + + var ( + dst bytes.Buffer + dstLine bytes.Buffer + ) + + scnr := bufio.NewScanner(strings.NewReader(input)) + isFirst := true + for scnr.Scan() { + dstLine.Reset() + line := scnr.Text() + + out, err := proc.Invoke(ctx, line) + if err != nil { + return TextFilterResponse{}, err + } else if out == nil { + continue + } else if strOut, ok := out.(fmt.Stringer); ok { + dstLine.WriteString(strOut.String()) + } else { + dstLine.WriteString(fmt.Sprint(out)) + } + + if isFirst { + isFirst = false + } else { + dst.WriteString("\n") + } + + dst.WriteString(dstLine.String()) + } + + return dst.String(), nil +} + // LoadDir evaluates every *.ucl file found under dir (recursively, in lexical order). Files // that fail to load are recorded in Errors() and skipped; loading continues with the rest. // A missing directory is not an error. From 9751be125e5f4995e3d689c4a88c1b67d6c556a7 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Wed, 9 Sep 2026 21:08:46 +1000 Subject: [PATCH 4/4] Moved the plugin directory --- README.md | 9 ++++++--- app.go | 27 ++++++++++++++++++++++----- go.sum | 2 -- main.go | 6 ++++++ plugins.go | 15 +++++++++++++-- 5 files changed, 47 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 11a7169..ef8a722 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,9 @@ This project uses [Wails](https://wails.io) to build a desktop application with ## Plugins Dequoter can be extended with new filters written in [UCL](https://ucl.lmika.dev). -On startup, if the directory `$HOME/.config/dequoter/plugins` exists, every file ending in `.ucl` -found in it (including subdirectories) is evaluated. Filters are defined with the `d:filter` builtin: +On startup, Dequoter creates `$HOME/Library/Application Support/dev.lmika.dequoter/plugins` if needed, +then evaluates every file ending in `.ucl` found in it (including subdirectories). +Filters are defined with the `d:filter` builtin: ``` d:filter "String: To Upper Case" { |in| @@ -33,6 +34,8 @@ fail to load are skipped and reported in the status bar; the remaining files are 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. +Use File → Open Plugins in Finder or File → Open Plugins in Terminal to access the directory. +If you have plugins in the old `$HOME/.config/dequoter/plugins` directory, move them to the new location. ## Download @@ -52,4 +55,4 @@ The above copyright notice and this permission notice shall be included in all c THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR -OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/app.go b/app.go index 37d2bd1..8a467ef 100644 --- a/app.go +++ b/app.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log" + "os/exec" "sort" "strings" "sync" @@ -96,10 +97,11 @@ func (a *App) loadPluginsFrom(dir string) int { func (a *App) startup(ctx context.Context) { a.baseCtx = ctx - dir, err := pluginDir() + dir, err := ensurePluginDir() if err != nil { - log.Printf("cannot determine plugin directory: %v", err) - a.setUCLInst(newUCLInst(newPluginFilters()), nil) + plugins := newPluginFilters() + plugins.recordError(err) + a.setUCLInst(newUCLInst(plugins), plugins) return } a.loadPluginsFrom(dir) @@ -114,11 +116,11 @@ func (a *App) domReady(ctx context.Context) { // 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() + dir, err := ensurePluginDir() if err != nil { _, _, ctx := a.current() runtime.EventsEmit(ctx, "set-statusbar-message", SetStatusbarMessage{ - Message: fmt.Sprintf("Cannot determine plugin directory: %v", err), + Message: fmt.Sprintf("Cannot prepare plugin directory: %v", err), Error: true, }) return @@ -131,6 +133,21 @@ func (a *App) ReloadPlugins() { a.reportPluginStatus(count, true) } +// openPluginDir opens the plugin directory using the specified macOS application. +func (a *App) openPluginDir(application string) { + dir, err := ensurePluginDir() + if err == nil { + err = exec.Command("/usr/bin/open", "-a", application, dir).Run() + } + if err != nil { + _, _, ctx := a.current() + runtime.EventsEmit(ctx, "set-statusbar-message", SetStatusbarMessage{ + Message: fmt.Sprintf("Cannot open plugins in %s: %v", application, err), + Error: 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) { diff --git a/go.sum b/go.sum index 94bf82c..f3643af 100644 --- a/go.sum +++ b/go.sum @@ -796,7 +796,5 @@ lmika.dev/pkg/modash v0.1.0/go.mod h1:8NDl/yR1eCCEhip9FJlVuMNXIeaztQ0Ks/tizExFcT lmika.dev/pkg/progdoc v0.0.0-20260207235039-984257fb414b h1:uarBEkpjAnpzO98btsA6ZDw9oGnJX8zSb8gZnCFl+6Y= lmika.dev/pkg/progdoc v0.0.0-20260207235039-984257fb414b/go.mod h1:GefQ+wxW6L0p2TfB4fzoP7ihOgEt5Lb/1qgXhruVHos= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= -ucl.lmika.dev v0.1.6 h1:cZocpR/IQcy9nvE8u+SSLY7/PE9fd1HIVWhZr/m+U5M= -ucl.lmika.dev v0.1.6/go.mod h1:Hjl6Udph2yuOPcBSytz4SFJVt5MZbM5JyiSAyTMTbNU= ucl.lmika.dev v0.1.7 h1:3yfvWOhBnWpQIm4NhKrUBh5PajB1wzUWzoPPa8ltaVo= ucl.lmika.dev v0.1.7/go.mod h1:Hjl6Udph2yuOPcBSytz4SFJVt5MZbM5JyiSAyTMTbNU= diff --git a/main.go b/main.go index 73b5430..167c39f 100644 --- a/main.go +++ b/main.go @@ -40,6 +40,12 @@ func main() { fileMenu.AddText("Reload Plugins", keys.Combo("r", keys.CmdOrCtrlKey, keys.ShiftKey), func(_ *menu.CallbackData) { app.ReloadPlugins() }) + fileMenu.AddText("Open Plugins in Finder", nil, func(_ *menu.CallbackData) { + app.openPluginDir("Finder") + }) + fileMenu.AddText("Open Plugins in Terminal", nil, func(_ *menu.CallbackData) { + app.openPluginDir("Terminal") + }) 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 diff --git a/plugins.go b/plugins.go index baaf476..036062e 100644 --- a/plugins.go +++ b/plugins.go @@ -173,13 +173,24 @@ func (p *PluginFilters) Errors() []error { return p.errors } -// pluginDir returns the plugin directory: $HOME/.config/dequoter/plugins +// pluginDir returns the plugin directory under macOS Application Support. func pluginDir() (string, error) { home, err := os.UserHomeDir() if err != nil { return "", err } - return filepath.Join(home, ".config", "dequoter", "plugins"), nil + return filepath.Join(home, "Library", "Application Support", "dev.lmika.dequoter", "Plugins"), nil +} + +func ensurePluginDir() (string, error) { + dir, err := pluginDir() + if err != nil { + return "", err + } + if err := os.MkdirAll(dir, 0755); err != nil { + return "", fmt.Errorf("create plugin directory: %w", err) + } + return dir, nil } var nonSlugChars = regexp.MustCompile(`[^a-z0-9]+`)