diff options
| -rw-r--r-- | gui/internal/model/testrule.go | 75 | ||||
| -rw-r--r-- | gui/internal/model/testrule_test.go | 76 | ||||
| -rw-r--r-- | gui/internal/ui/forms.go | 107 | ||||
| -rw-r--r-- | gui/internal/ui/rules.go | 82 |
4 files changed, 328 insertions, 12 deletions
diff --git a/gui/internal/model/testrule.go b/gui/internal/model/testrule.go new file mode 100644 index 0000000..3cf0437 --- /dev/null +++ b/gui/internal/model/testrule.go @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package model + +import ( + "context" + "fmt" + + "krino/internal/engine" + "krino/internal/plan" +) + +// RuleHit is one file a rule would act on, and what it would do to it. +type RuleHit struct { + Rel string + Steps []plan.Step +} + +// RuleHits is the answer to "which files would this rule take?". +type RuleHits struct { + Rule string + Files []RuleHit + Scanned int +} + +// TestRule plans the directory with the unsaved text and reports the files +// the named rule would act on. It reads only: no lock is taken - the file +// the Plan tab is looking at is none of its business - and nothing is +// written or moved. Content extraction makes it as slow as a scan, so +// callers run it off the main loop. +func (r *Rules) TestRule(ctx context.Context, name string) (*RuleHits, error) { + e, diags := r.load() + if len(diags) > 0 { + return nil, fmt.Errorf("%s", diags[0]) + } + var dir *engine.Dir + for _, d := range e.Dirs { + if d.Name == r.Name { + dir = d + break + } + } + if dir == nil { + return nil, fmt.Errorf("model: %s is not in the configuration", r.Name) + } + found := false + for _, rule := range dir.Rules { + if rule.Conf.Name == name { + found = true + break + } + } + if !found { + return nil, fmt.Errorf("model: no rule called %q in %s", name, r.Name) + } + dp, err := e.Plan(ctx, dir, plan.NewClaims()) + if err != nil { + return nil, err + } + hits := &RuleHits{Rule: name} + res := dp.Result + hits.Scanned = len(res.Matched) + len(res.Unmatched) + len(res.Skipped) + for _, c := range dp.Chains { + var steps []plan.Step + for _, s := range c.Steps { + if s.Rule == name { + steps = append(steps, s) + } + } + if len(steps) > 0 { + hits.Files = append(hits.Files, RuleHit{Rel: c.File.Rel, Steps: steps}) + } + } + return hits, nil +} diff --git a/gui/internal/model/testrule_test.go b/gui/internal/model/testrule_test.go new file mode 100644 index 0000000..99989f4 --- /dev/null +++ b/gui/internal/model/testrule_test.go @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package model + +import ( + "context" + "strings" + "testing" +) + +// TestTestRule: testing one rule says which of the directory's files it +// would act on, and what would happen to each - answered from the unsaved +// text, and changing nothing (GUI design ยง5.3, his request 2026-09-16). +func TestTestRule(t *testing.T) { + conf := "(path \"~/dl\")\n" + + "(rule \"pdfs\" (when (type pdf)) (move \"Docs\") (stop))\n" + + "(rule \"rest\" (move \"Other\"))\n" + e, _ := sandboxDir(t, conf, map[string]string{ + "a.pdf": "one", "b.pdf": "two", "c.txt": "three", + }) + r, err := OpenRules(e, "dl") + if err != nil { + t.Fatal(err) + } + + hits, err := r.TestRule(context.Background(), "pdfs") + if err != nil { + t.Fatal(err) + } + if hits.Scanned != 3 || len(hits.Files) != 2 { + t.Fatalf("hits = %+v", hits) + } + for _, h := range hits.Files { + if !strings.HasSuffix(h.Rel, ".pdf") { + t.Errorf("a file the rule does not match: %+v", h) + } + if len(h.Steps) == 0 || !strings.Contains(h.Steps[0].Dst, "Docs") { + t.Errorf("%s: steps = %+v", h.Rel, h.Steps) + } + } + + // The unsaved text is what is tested, not what is on disk. + r.SetText("(path \"~/dl\")\n(rule \"pdfs\" (when (type text)) (move \"Docs\") (stop))\n") + hits, err = r.TestRule(context.Background(), "pdfs") + if err != nil { + t.Fatal(err) + } + if len(hits.Files) != 1 || hits.Files[0].Rel != "c.txt" { + t.Errorf("the unsaved rule was not the one tested: %+v", hits) + } + + // A rule that matches nothing says so rather than failing. + r.SetText("(path \"~/dl\")\n(rule \"pdfs\" (when (type iso)) (move \"Docs\") (stop))\n") + hits, err = r.TestRule(context.Background(), "pdfs") + if err != nil || len(hits.Files) != 0 || hits.Scanned != 3 { + t.Errorf("hits = %+v, err = %v", hits, err) + } +} + +// TestTestRuleRefusals: a rule that is not in the text, and text that does +// not load, are reported rather than answered. +func TestTestRuleRefusals(t *testing.T) { + conf := "(path \"~/dl\")\n(rule \"pdfs\" (when (type pdf)) (move \"Docs\"))\n" + e, _ := sandboxDir(t, conf, map[string]string{"a.pdf": "one"}) + r, err := OpenRules(e, "dl") + if err != nil { + t.Fatal(err) + } + if _, err := r.TestRule(context.Background(), "nosuch"); err == nil { + t.Error("a rule that is not in the file was tested") + } + r.SetText("(path \"~/dl\")\n(rule \"pdfs\" (when (type pdf)) (move \"Docs/{nope}\"))\n") + if _, err := r.TestRule(context.Background(), "pdfs"); err == nil { + t.Error("text that does not load was tested") + } +} diff --git a/gui/internal/ui/forms.go b/gui/internal/ui/forms.go index ecde546..55f6328 100644 --- a/gui/internal/ui/forms.go +++ b/gui/internal/ui/forms.go @@ -3,6 +3,7 @@ package ui import ( + "context" "fmt" "strings" @@ -50,8 +51,8 @@ var condHints = map[string]string{ "name": `"^faktura" "^fv" (regexes, any may match)`, "path": `"work/" (regex against the path under the root)`, "content": `"invoice" "faktura" (keywords, any may match)`, - "size": `> 10M`, - "age": `> 90d`, + "size": `10M (B, K, M, G)`, + "age": `90d (s, m, h, d, w)`, "duplicate": `"~/docs" (or empty: the directory's own tree)`, "matched": `(no arguments)`, "and": `(type pdf) (content "invoice")`, @@ -72,6 +73,7 @@ type formsView struct { list *gtk.ListBox add, del, up, down *gtk.Button + test *gtk.Button place *gtk.Box note *gtk.Label forms []model.Form @@ -98,12 +100,14 @@ func newFormsView(w *Window, owner *rulesView) *formsView { f.del = gtk.NewButtonWithLabel("Delete") f.up = gtk.NewButtonWithLabel("Up") f.down = gtk.NewButtonWithLabel("Down") + f.test = gtk.NewButtonWithLabel("Test rule") + f.test.SetTooltipText("scan the directory and list the files this rule would take") buttons := gtk.NewBox(gtk.OrientationHorizontal, 4) buttons.SetMarginStart(6) buttons.SetMarginEnd(6) buttons.SetMarginTop(4) buttons.SetMarginBottom(4) - for _, b := range []*gtk.Button{f.add, f.del, f.up, f.down} { + for _, b := range []*gtk.Button{f.add, f.del, f.up, f.down, f.test} { buttons.Append(b) } @@ -148,6 +152,7 @@ func newFormsView(w *Window, owner *rulesView) *formsView { f.del.ConnectClicked(f.onDelete) f.up.ConnectClicked(func() { f.move(-1) }) f.down.ConnectClicked(func() { f.move(1) }) + f.test.ConnectClicked(f.onTestRule) return f } @@ -565,6 +570,64 @@ func (f *formsView) move(delta int) { } } +// onTestRule scans the directory with the unsaved text and lists the files +// the selected rule would take, in the pane on the right. It reads only - +// no lock, nothing moved - but takes as long as a scan, so it runs off the +// main loop (his request, 2026-09-16). +func (f *formsView) onTestRule() { + if f.sel < 0 || f.sel >= len(f.forms) { + f.owner.showTestOutput("Select a rule in the list first.") + return + } + form := f.forms[f.sel] + if form.Kind != model.RuleForm { + f.owner.showTestOutput("Only a rule can be tested this way; an exclude sets files aside before the rules run.") + return + } + name := form.Label + f.owner.showTestOutput("scanning for the files " + name + " would take...") + f.test.SetSensitive(false) + var hits *model.RuleHits + runInBackground(func(ctx context.Context) error { + var err error + hits, err = f.owner.rules.TestRule(ctx, name) + return err + }, func(err error) { + f.test.SetSensitive(true) + if err != nil { + f.owner.showTestOutput(err.Error()) + return + } + f.owner.showTestOutput(hitsText(hits, f.w.dirRoot(f.owner.rules.Name))) + }) +} + +// hitsText renders what a rule test found, destinations shortened against +// the directory as the plan list shows them. +func hitsText(h *model.RuleHits, root string) string { + var b strings.Builder + fmt.Fprintf(&b, "rule %s: %d of %d files\n", h.Rule, len(h.Files), h.Scanned) + if len(h.Files) == 0 { + b.WriteString("\nNo file in the directory reaches this rule. A rule above it may\nbe taking them first - Test on file explains one file in full.\n") + return b.String() + } + b.WriteString("\n") + for _, hit := range h.Files { + fmt.Fprintf(&b, "%s\n", hit.Rel) + for _, s := range hit.Steps { + switch { + case s.Skip != "": + fmt.Fprintf(&b, " %s skipped: %s\n", s.Kind, s.Skip) + case s.Dst == "": + fmt.Fprintf(&b, " %s\n", s.Kind) + default: + fmt.Fprintf(&b, " %s -> %s\n", s.Kind, shorten(s.Dst, root)) + } + } + } + return b.String() +} + // formEditor is the widgets of one form, and can write it back. type formEditor struct { kind model.FormKind @@ -700,18 +763,28 @@ func (fe *formEditor) addAction(a config.Action) { fe.acts.Append(row.root) } -// condRow is one condition: its kind, and its arguments as written. +// compareOps are the comparisons (size ...) and (age ...) take. +var compareOps = []string{">", ">=", "<", "<=", "="} + +// condRow is one condition: its kind, and its arguments. A size or an age +// is a comparison, so it gets an operator of its own and a value to type +// rather than one field holding both (his request, 2026-09-16). type condRow struct { root *gtk.Box kind *gtk.DropDown + op *gtk.DropDown args *gtk.Entry gone bool } +// isCompare reports whether a kind is written as OPERATOR VALUE. +func isCompare(kind string) bool { return kind == "size" || kind == "age" } + func newCondRow(n *sexp.Node, changed func()) *condRow { c := &condRow{} c.root = gtk.NewBox(gtk.OrientationHorizontal, 6) c.kind = gtk.NewDropDownFromStrings(condItems()) + c.op = gtk.NewDropDownFromStrings(compareOps) c.args = gtk.NewEntry() c.args.SetHExpand(true) if n != nil { @@ -719,6 +792,13 @@ func newCondRow(n *sexp.Node, changed func()) *condRow { if i := indexOf(condKinds, head); i >= 0 { c.kind.SetSelected(uint(i)) } + if isCompare(head) { + op, value := splitCompare(args) + if i := indexOf(compareOps, op); i >= 0 { + c.op.SetSelected(uint(i)) + } + args = value + } c.args.SetText(args) } c.showHint() @@ -732,17 +812,32 @@ func newCondRow(n *sexp.Node, changed func()) *condRow { c.showHint() changed() }) + c.op.Connect("notify::selected", func() { changed() }) c.args.ConnectChanged(func() { changed() }) c.root.Append(c.kind) + c.root.Append(c.op) c.root.Append(c.args) c.root.Append(remove) return c } +// splitCompare takes an operator and a value apart, as (age > 90d) writes +// them; a value with no operator keeps the row's default. +func splitCompare(args string) (op, value string) { + fields := strings.Fields(args) + if len(fields) >= 2 && indexOf(compareOps, fields[0]) >= 0 { + return fields[0], strings.Join(fields[1:], " ") + } + return "", args +} + // text is the condition as it will be written. func (c *condRow) text() string { kind := condKinds[c.kind.Selected()] args := strings.TrimSpace(c.args.Text()) + if isCompare(kind) && args != "" { + args = compareOps[c.op.Selected()] + " " + args + } if args == "" { return "(" + kind + ")" } @@ -752,9 +847,11 @@ func (c *condRow) text() string { // showHint puts the example for the chosen kind in the entry, where it // shows while the row is empty and as its tooltip once it is not. func (c *condRow) showHint() { - hint := condHints[condKinds[c.kind.Selected()]] + kind := condKinds[c.kind.Selected()] + hint := condHints[kind] c.args.SetPlaceholderText(hint) c.args.SetTooltipText(hint) + c.op.SetVisible(isCompare(kind)) } // actionRow is one action: what it does, and its argument. diff --git a/gui/internal/ui/rules.go b/gui/internal/ui/rules.go index 939d77f..0a10996 100644 --- a/gui/internal/ui/rules.go +++ b/gui/internal/ui/rules.go @@ -27,13 +27,15 @@ type rulesView struct { w *Window root *gtk.Box - dirs *gtk.DropDown - names []string - save *gtk.Button - revert *gtk.Button - reload *gtk.Button + dirs *gtk.DropDown + names []string + save *gtk.Button + revert *gtk.Button + reload *gtk.Button + checkNow *gtk.Button view *gtk.TextView + nums *gtk.TextView buf *gtk.TextBuffer check *gtk.Label diags *gtk.ListBox @@ -46,6 +48,7 @@ type rulesView struct { forms *formsView rules *model.Rules pending glib.SourceHandle + lines int quiet bool // set while the view is being filled, so no check is armed inSwap bool // set while a sub-tab switch is being handled } @@ -64,6 +67,7 @@ func newRulesView(w *Window) *rulesView { r.save.AddCSSClass("suggested-action") r.revert = gtk.NewButtonWithLabel("Revert") r.reload = gtk.NewButtonWithLabel("Reload") + r.checkNow = gtk.NewButtonWithLabel("Check") bar := gtk.NewBox(gtk.OrientationHorizontal, 6) bar.SetMarginTop(6) @@ -78,6 +82,7 @@ func newRulesView(w *Window) *rulesView { r.check.SetEllipsize(pango.EllipsizeEnd) r.check.SetMaxWidthChars(20) bar.Append(r.check) + bar.Append(r.checkNow) bar.Append(r.reload) bar.Append(r.revert) bar.Append(r.save) @@ -88,8 +93,31 @@ func newRulesView(w *Window) *rulesView { r.view.SetRightMargin(8) r.view.SetTopMargin(6) r.buf = r.view.Buffer() + + // Line numbers: their own view beside the editor, in the same scrolled + // window so the two always line up. The editor does not wrap, so one + // line of text is one line on screen (his request, 2026-09-16). + r.nums = gtk.NewTextView() + r.nums.SetMonospace(true) + r.nums.SetEditable(false) + r.nums.SetCursorVisible(false) + r.nums.SetJustification(gtk.JustifyRight) + r.nums.SetLeftMargin(6) + r.nums.SetRightMargin(6) + r.nums.SetTopMargin(6) + r.nums.AddCSSClass("dim-label") + r.nums.SetCanFocus(false) + r.nums.SetVAlign(gtk.AlignStart) + r.nums.SetSizeRequest(46, -1) + r.view.SetVAlign(gtk.AlignStart) + editRow := gtk.NewBox(gtk.OrientationHorizontal, 0) + editRow.Append(r.nums) + editRow.Append(gtk.NewSeparator(gtk.OrientationVertical)) + editRow.Append(r.view) + r.view.SetHExpand(true) + editScroll := gtk.NewScrolledWindow() - editScroll.SetChild(r.view) + editScroll.SetChild(editRow) editScroll.SetVExpand(true) editScroll.SetHExpand(true) @@ -182,6 +210,14 @@ func newRulesView(w *Window) *rulesView { } }) r.reload.ConnectClicked(func() { r.reloadFromDisk() }) + r.checkNow.ConnectClicked(func() { + r.runCheck() + if len(r.rules.Diags) == 0 { + r.w.setStatus("check: no errors in %s", escape(xdg.Abbrev(r.rules.File))) + } else { + r.w.setStatus("check: %d problem(s); the list is under the editor", len(r.rules.Diags)) + } + }) r.testGo.ConnectClicked(r.onTest) r.testPath.ConnectActivate(r.onTest) @@ -201,6 +237,12 @@ func (r *rulesView) formsOf() ([]model.Form, error) { // setCheck writes one line in the bar where the check result goes. func (r *rulesView) setCheck(text string) { r.check.SetText(escape(text)) } +// showTestOutput writes in the pane on the right, where both Test on file +// and Test rule report. +func (r *rulesView) showTestOutput(text string) { + r.out.Buffer().SetText(escapeText(text)) +} + // showText brings the Text sub-tab forward. func (r *rulesView) showText() { r.inSwap = true @@ -327,8 +369,34 @@ func (r *rulesView) runCheck() { row.SetName(fmt.Sprintf("%d", line)) } } - r.save.SetSensitive(len(diags) == 0 && r.rules.Modified()) + canSave := len(diags) == 0 && r.rules.Modified() + r.save.SetSensitive(canSave) r.revert.SetSensitive(r.rules.Modified()) + switch { + case canSave: + r.save.SetTooltipText("write " + xdg.Abbrev(r.rules.File) + + ", keeping the previous text as " + r.rules.Name + ".conf.bak") + case len(diags) > 0: + r.save.SetTooltipText("Save waits until the problems below are fixed") + r.check.SetText(r.check.Text() + " - Save waits until they are fixed") + default: + r.save.SetTooltipText("nothing to save: the file matches what is on disk") + } + r.updateLineNumbers() +} + +// updateLineNumbers refills the gutter when the number of lines changes. +func (r *rulesView) updateLineNumbers() { + n := r.buf.LineCount() + if n == r.lines { + return + } + r.lines = n + var b strings.Builder + for i := 1; i <= n; i++ { + fmt.Fprintf(&b, "%3d\n", i) + } + r.nums.Buffer().SetText(strings.TrimSuffix(b.String(), "\n")) } // goToLine puts the cursor on the line a diagnostic names, so clicking one |
