aboutsummaryrefslogtreecommitdiff
path: root/gui
diff options
context:
space:
mode:
Diffstat (limited to 'gui')
-rw-r--r--gui/internal/model/testrule.go75
-rw-r--r--gui/internal/model/testrule_test.go76
-rw-r--r--gui/internal/ui/forms.go107
-rw-r--r--gui/internal/ui/rules.go82
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