summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-09-16 14:13:26 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-09-16 14:13:26 +0200
commit85d65ebe0adf1b156324a3a4c220e415a79ba9ce (patch)
tree4e648edce13cbc370e2685475944cafc2236f44a
parent6ef83d6bdfb6120f9e1fbd145e0bc463196103d1 (diff)
downloadkrino-85d65ebe0adf1b156324a3a4c220e415a79ba9ce.tar.gz
krino-85d65ebe0adf1b156324a3a4c220e415a79ba9ce.zip
gui: Rules tab - the file as text, checked as you type, tested and saved
-rw-r--r--gui/internal/model/rules.go261
-rw-r--r--gui/internal/model/rules_test.go241
-rw-r--r--gui/internal/ui/rules.go388
-rw-r--r--gui/internal/ui/window.go31
-rw-r--r--internal/config/skel.go6
5 files changed, 926 insertions, 1 deletions
diff --git a/gui/internal/model/rules.go b/gui/internal/model/rules.go
new file mode 100644
index 0000000..1b2eec8
--- /dev/null
+++ b/gui/internal/model/rules.go
@@ -0,0 +1,261 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package model
+
+import (
+ "bytes"
+ "context"
+ "crypto/sha256"
+ "errors"
+ "fmt"
+ "os"
+ "strings"
+
+ "krino/internal/cond"
+ "krino/internal/config"
+ "krino/internal/engine"
+ "krino/internal/xdg"
+)
+
+// ErrChangedOnDisk is Save's refusal when the file was edited elsewhere
+// since the editor read it: saving would throw that edit away, so the
+// caller is asked what to do (GUI design §5.3).
+var ErrChangedOnDisk = errors.New("the file changed on disk since it was opened")
+
+// Rules is one directory's own configuration file, open for editing. Only
+// dirs/NAME.conf is edited; krino.conf is not, in version 1 (GUI design §5).
+type Rules struct {
+ Name string // the directory
+ File string // its file, absolute
+ Text string // what the editor holds, saved or not
+ Diags []*config.Diag
+
+ e *engine.Engine
+ saved string // the text as last read from or written to disk
+ stamp [32]byte // what was on disk then, to catch another editor
+}
+
+// RuleFiles is every included directory that has a file of its own, in the
+// order krino.conf includes them.
+func RuleFiles(e *engine.Engine) []string {
+ var out []string
+ for _, d := range e.Dirs {
+ if _, err := os.Stat(config.DirFile(e.MainFile, d.Name)); err == nil {
+ out = append(out, d.Name)
+ }
+ }
+ return out
+}
+
+// OpenRules reads the file of the included directory called name.
+func OpenRules(e *engine.Engine, name string) (*Rules, error) {
+ found := false
+ for _, d := range e.Dirs {
+ if d.Name == name {
+ found = true
+ break
+ }
+ }
+ if !found {
+ return nil, fmt.Errorf("model: %s is not an included directory", name)
+ }
+ file := config.DirFile(e.MainFile, name)
+ text, err := os.ReadFile(file)
+ if err != nil {
+ return nil, err
+ }
+ r := &Rules{Name: name, File: file, Text: string(text), e: e,
+ saved: string(text), stamp: sha256.Sum256(text)}
+ return r, nil
+}
+
+// SetText replaces what the editor holds. Nothing is written until Save.
+func (r *Rules) SetText(s string) { r.Text = s }
+
+// Modified reports whether the text differs from what is on disk.
+func (r *Rules) Modified() bool { return r.Text != r.saved }
+
+// Revert throws the unsaved text away.
+func (r *Rules) Revert() { r.Text = r.saved }
+
+// Reload re-reads the file, dropping unsaved text - what to do when
+// something else has edited it.
+func (r *Rules) Reload() error {
+ text, err := os.ReadFile(r.File)
+ if err != nil {
+ return err
+ }
+ r.Text, r.saved, r.stamp = string(text), string(text), sha256.Sum256(text)
+ return nil
+}
+
+// Check loads the whole configuration with this file's unsaved text in
+// place of what is on disk, and reports every problem it finds - in this
+// file or in another, since one file's text can break another's include.
+// Nothing is written.
+func (r *Rules) Check() []*config.Diag {
+ _, diags := r.load()
+ r.Diags = diags
+ return diags
+}
+
+// ErrorsHere is the subset of the last Check's diagnostics that belong to
+// this file, for marking lines in the editor.
+func (r *Rules) ErrorsHere() []*config.Diag {
+ var out []*config.Diag
+ for _, d := range r.Diags {
+ if d.File == r.File {
+ out = append(out, d)
+ }
+ }
+ return out
+}
+
+// load builds an engine from the unsaved text.
+func (r *Rules) load() (*engine.Engine, []*config.Diag) {
+ over := map[string][]byte{r.File: []byte(r.Text)}
+ e, diags := engine.LoadWith(r.e.MainFile, over)
+ if e != nil {
+ e.CacheDir = r.e.CacheDir
+ }
+ return e, diags
+}
+
+// Explain answers what the unsaved rules would do to one file - "Test on
+// file" - as text. It can take seconds: content is extracted, so callers
+// run it off the main loop. Nothing is written, and the file is not
+// touched (GUI design §5.3).
+func (r *Rules) Explain(ctx context.Context, path string) (string, error) {
+ e, diags := r.load()
+ if len(diags) > 0 {
+ return "", fmt.Errorf("%s", diags[0])
+ }
+ x, err := e.ExplainWithChain(ctx, xdg.Expand(path))
+ if err != nil {
+ return "", err
+ }
+ return explainText(x), nil
+}
+
+// Save writes the text, keeping what was there as NAME.conf.bak. It
+// refuses a file that will not load - a window must not leave krino unable
+// to run - and refuses to overwrite an edit made elsewhere since the file
+// was opened (GUI design §5.3).
+func (r *Rules) Save() error {
+ if diags := r.Check(); len(diags) > 0 {
+ return fmt.Errorf("%s", diags[0])
+ }
+ on, err := os.ReadFile(r.File)
+ if err != nil {
+ return err
+ }
+ if sha256.Sum256(on) != r.stamp {
+ return ErrChangedOnDisk
+ }
+ // The backup is the text being replaced, under the file's own
+ // permissions: a rules file can hold real tax numbers, and a backup
+ // readable by everyone would leak them.
+ mode := os.FileMode(0o600)
+ if fi, err := os.Stat(r.File); err == nil {
+ mode = fi.Mode().Perm()
+ }
+ if err := os.WriteFile(r.File+".bak", on, mode); err != nil {
+ return err
+ }
+ text := []byte(r.Text)
+ if err := config.Replace(r.File, text); err != nil {
+ return err
+ }
+ r.saved, r.stamp = r.Text, sha256.Sum256(text)
+ return nil
+}
+
+// SaveOverwriting saves over an edit made elsewhere - the Overwrite the
+// caller offers after ErrChangedOnDisk. The other edit is not lost: it
+// becomes NAME.conf.bak, as any replaced text does.
+func (r *Rules) SaveOverwriting() error {
+ on, err := os.ReadFile(r.File)
+ if err != nil {
+ return err
+ }
+ r.stamp = sha256.Sum256(on)
+ return r.Save()
+}
+
+// explainText renders an explanation for the Test on file pane: every
+// exclude and rule with each test's answer, the captures a matching rule
+// took, and the chain the file alone would get. It is the GUI's own
+// rendering - krino explain(1) prints the same traces but no captures or
+// chain.
+func explainText(x *engine.Explanation) string {
+ var b strings.Builder
+ fmt.Fprintf(&b, "%s (directory %s)\n", xdg.Abbrev(x.File.Path), x.Dir.Name)
+ if x.Skip != "" {
+ fmt.Fprintf(&b, "krino would not look at this file: %s\n", x.Skip)
+ }
+ if x.Excluded != "" {
+ fmt.Fprintf(&b, "krino would set this file aside: %s\n", x.Excluded)
+ }
+ b.WriteString("\n")
+ for _, xt := range x.Excludes {
+ status := "no"
+ if xt.Match {
+ status = "MATCH"
+ }
+ fmt.Fprintf(&b, "%s: %s\n", xt.Text, status)
+ writeTrace(&b, xt.Trace)
+ }
+ for _, rt := range x.Rules {
+ if rt.Stopped != "" {
+ fmt.Fprintf(&b, "rule %s: not evaluated, %s\n", rt.Rule.Name, rt.Stopped)
+ continue
+ }
+ status := "no"
+ switch {
+ case rt.Match:
+ status = "MATCH"
+ case rt.Trace != nil && rt.Trace.Unknown:
+ status = "undecided"
+ }
+ fmt.Fprintf(&b, "rule %s: %s\n", rt.Rule.Name, status)
+ writeTrace(&b, rt.Trace)
+ writeCaptures(&b, rt.Captures)
+ }
+ if x.NoDelete != "" {
+ fmt.Fprintf(&b, "\nkrino would skip deleting this file: %s\n", x.NoDelete)
+ }
+ if len(x.Chain) > 0 {
+ b.WriteString("\nthis file alone would get:\n")
+ for _, s := range x.Chain {
+ 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, xdg.Abbrev(s.Dst))
+ }
+ }
+ }
+ return b.String()
+}
+
+// writeTrace writes a condition's tree, indented two spaces.
+func writeTrace(b *strings.Builder, t *cond.Trace) {
+ if t == nil {
+ return
+ }
+ var buf bytes.Buffer
+ t.Format(&buf)
+ for _, line := range strings.Split(strings.TrimRight(buf.String(), "\n"), "\n") {
+ b.WriteString(" " + line + "\n")
+ }
+}
+
+// writeCaptures lists what a matching rule's patterns captured, numbered as
+// its actions' {1}, {2} ... see them.
+func writeCaptures(b *strings.Builder, captures []string) {
+ for i, c := range captures {
+ fmt.Fprintf(b, " {%d} = %s\n", i+1, c)
+ }
+}
diff --git a/gui/internal/model/rules_test.go b/gui/internal/model/rules_test.go
new file mode 100644
index 0000000..c7fa384
--- /dev/null
+++ b/gui/internal/model/rules_test.go
@@ -0,0 +1,241 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package model
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "krino/internal/engine"
+)
+
+// TestOpenAndCheck: the editor opens a directory's own file, reports the
+// mistakes in unsaved text with their positions, and says so when there are
+// none (GUI design §5.3).
+func TestOpenAndCheck(t *testing.T) {
+ conf := "(path \"~/dl\")\n(rule \"all\" (move \"Out\"))\n"
+ e, _ := sandboxDir(t, conf, map[string]string{"a.pdf": "one"})
+
+ r, err := OpenRules(e, "dl")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if r.Text != conf {
+ t.Fatalf("text = %q, want the file as written", r.Text)
+ }
+ if diags := r.Check(); len(diags) != 0 {
+ t.Fatalf("a good file reports %v", diags)
+ }
+ if r.Modified() {
+ t.Error("an untouched file counts as modified")
+ }
+
+ // An unknown placeholder is refused at check time (plan 12 task 3), and
+ // the position is the action's, inside this file.
+ r.SetText("(path \"~/dl\")\n(rule \"all\" (move \"Out/{nope}\"))\n")
+ if !r.Modified() {
+ t.Error("edited text does not count as modified")
+ }
+ diags := r.Check()
+ if len(diags) != 1 {
+ t.Fatalf("diags = %v, want one", diags)
+ }
+ if diags[0].File != r.File || diags[0].Pos.Line != 2 {
+ t.Errorf("diag = %s, want it in %s at line 2", diags[0], r.File)
+ }
+ if !strings.Contains(diags[0].Msg, "nope") {
+ t.Errorf("message does not name the placeholder: %s", diags[0].Msg)
+ }
+ // Checking never writes: the file on disk still holds the good text.
+ on, _ := os.ReadFile(r.File)
+ if string(on) != conf {
+ t.Errorf("check wrote to the file: %q", on)
+ }
+}
+
+// TestSaveKeepsABackup: saving writes the text, keeps what was there as
+// NAME.conf.bak, and the engine then loads the new rules (GUI design §5.3).
+func TestSaveKeepsABackup(t *testing.T) {
+ conf := "(path \"~/dl\")\n(rule \"all\" (move \"Out\"))\n"
+ e, h := sandboxDir(t, conf, map[string]string{"a.pdf": "one"})
+ r, err := OpenRules(e, "dl")
+ if err != nil {
+ t.Fatal(err)
+ }
+ next := "(path \"~/dl\")\n(rule \"all\" (move \"Elsewhere\"))\n"
+ r.SetText(next)
+ if err := r.Save(); err != nil {
+ t.Fatal(err)
+ }
+ if on, _ := os.ReadFile(r.File); string(on) != next {
+ t.Errorf("file = %q, want the new text", on)
+ }
+ bak := filepath.Join(filepath.Dir(r.File), "dl.conf.bak")
+ if on, _ := os.ReadFile(bak); string(on) != conf {
+ t.Errorf("backup = %q, want the previous text", on)
+ }
+ if r.Modified() {
+ t.Error("a saved file still counts as modified")
+ }
+ // The saved rules are what krino now reads.
+ fresh := reload(t, h)
+ tab, err := Plan(context.Background(), fresh, fresh.Dirs[0])
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer tab.Close()
+ if len(tab.Rows) != 1 || len(tab.Rows[0].Steps) == 0 ||
+ !strings.Contains(tab.Rows[0].Steps[0].Dst, "Elsewhere") {
+ t.Errorf("the saved rule is not in effect: %+v", tab.Rows)
+ }
+}
+
+// TestSaveRefusesBrokenRules: a file that will not load is not written, so
+// a window cannot leave krino unable to run (GUI design §5.3).
+func TestSaveRefusesBrokenRules(t *testing.T) {
+ conf := "(path \"~/dl\")\n(rule \"all\" (move \"Out\"))\n"
+ e, _ := sandboxDir(t, conf, map[string]string{"a.pdf": "one"})
+ r, err := OpenRules(e, "dl")
+ if err != nil {
+ t.Fatal(err)
+ }
+ r.SetText("(path \"~/dl\")\n(rule \"all\" (move \"Out/{nope}\"))\n")
+ err = r.Save()
+ if err == nil {
+ t.Fatal("Save accepted a file with errors")
+ }
+ if !strings.Contains(err.Error(), "nope") {
+ t.Errorf("the refusal does not say what is wrong: %v", err)
+ }
+ if on, _ := os.ReadFile(r.File); string(on) != conf {
+ t.Errorf("the refused save wrote anyway: %q", on)
+ }
+ if _, err := os.Stat(filepath.Join(filepath.Dir(r.File), "dl.conf.bak")); !os.IsNotExist(err) {
+ t.Error("the refused save left a backup")
+ }
+}
+
+// TestSaveRefusesAFileChangedOnDisk: something else edited the file while
+// the window had it open, so saving would lose that edit; the caller is
+// told, and can Reload (GUI design §5.3).
+func TestSaveRefusesAFileChangedOnDisk(t *testing.T) {
+ conf := "(path \"~/dl\")\n(rule \"all\" (move \"Out\"))\n"
+ e, _ := sandboxDir(t, conf, map[string]string{"a.pdf": "one"})
+ r, err := OpenRules(e, "dl")
+ if err != nil {
+ t.Fatal(err)
+ }
+ elsewhere := "(path \"~/dl\")\n(rule \"all\" (move \"Elsewhere\"))\n"
+ if err := os.WriteFile(r.File, []byte(elsewhere), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ r.SetText("(path \"~/dl\")\n(rule \"all\" (move \"Mine\"))\n")
+ if err := r.Save(); !errors.Is(err, ErrChangedOnDisk) {
+ t.Fatalf("Save = %v, want ErrChangedOnDisk", err)
+ }
+ if on, _ := os.ReadFile(r.File); string(on) != elsewhere {
+ t.Errorf("the refused save overwrote the other edit: %q", on)
+ }
+ // Overwrite keeps the other edit as the backup rather than losing it.
+ if err := r.SaveOverwriting(); err != nil {
+ t.Fatal(err)
+ }
+ if on, _ := os.ReadFile(r.File); !strings.Contains(string(on), "Mine") {
+ t.Errorf("overwrite did not save: %q", on)
+ }
+ if on, _ := os.ReadFile(r.File + ".bak"); string(on) != elsewhere {
+ t.Errorf("backup = %q, want the edit that was overwritten", on)
+ }
+
+ // And from the other direction: reload takes the other edit and drops
+ // ours; saving then works.
+ if err := os.WriteFile(r.File, []byte(elsewhere), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := r.Reload(); err != nil {
+ t.Fatal(err)
+ }
+ if r.Text != elsewhere {
+ t.Errorf("reload = %q, want what is on disk", r.Text)
+ }
+ r.SetText("(path \"~/dl\")\n(rule \"all\" (move \"Mine\"))\n")
+ if err := r.Save(); err != nil {
+ t.Errorf("save after reload: %v", err)
+ }
+}
+
+// TestExplainWithUnsavedText: Test on file answers for the text in the
+// editor, not for what is on disk (GUI design §5.3).
+func TestExplainWithUnsavedText(t *testing.T) {
+ conf := "(path \"~/dl\")\n(rule \"pdfs\" (when (type pdf)) (move \"Out\"))\n"
+ e, h := sandboxDir(t, conf, map[string]string{"a.pdf": "one"})
+ r, err := OpenRules(e, "dl")
+ if err != nil {
+ t.Fatal(err)
+ }
+ file := filepath.Join(h, "dl", "a.pdf")
+
+ text, err := r.Explain(context.Background(), file)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(text, "rule pdfs: MATCH") || !strings.Contains(text, "Out") {
+ t.Errorf("explanation of the saved rules = %q", text)
+ }
+
+ r.SetText("(path \"~/dl\")\n(rule \"pdfs\" (when (type image)) (move \"Out\"))\n")
+ text, err = r.Explain(context.Background(), file)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if strings.Contains(text, "MATCH") {
+ t.Errorf("the unsaved rule was ignored: %q", text)
+ }
+ // The unsaved text is never written by a test run.
+ if on, _ := os.ReadFile(r.File); string(on) != conf {
+ t.Errorf("Test on file wrote to the config: %q", on)
+ }
+}
+
+// TestExplainReportsBrokenRules: testing a file against text that will not
+// load says so instead of answering from the saved rules.
+func TestExplainReportsBrokenRules(t *testing.T) {
+ conf := "(path \"~/dl\")\n(rule \"pdfs\" (when (type pdf)) (move \"Out\"))\n"
+ e, h := sandboxDir(t, conf, map[string]string{"a.pdf": "one"})
+ r, err := OpenRules(e, "dl")
+ if err != nil {
+ t.Fatal(err)
+ }
+ r.SetText("(path \"~/dl\")\n(rule \"pdfs\" (when (type pdf)) (move \"Out/{nope}\"))\n")
+ if _, err := r.Explain(context.Background(), filepath.Join(h, "dl", "a.pdf")); err == nil {
+ t.Fatal("Test on file answered from a file that will not load")
+ }
+}
+
+// TestRuleFilesLister: the picker offers every included directory that has
+// a file of its own.
+func TestRuleFilesLister(t *testing.T) {
+ conf := "(path \"~/dl\")\n(rule \"all\" (move \"Out\"))\n"
+ e, _ := sandboxDir(t, conf, map[string]string{"a.pdf": "one"})
+ if got := RuleFiles(e); len(got) != 1 || got[0] != "dl" {
+ t.Errorf("RuleFiles = %v, want [dl]", got)
+ }
+ if _, err := OpenRules(e, "nosuch"); err == nil {
+ t.Error("OpenRules accepted a directory that is not included")
+ }
+}
+
+// reload builds a fresh engine from the sandbox's config, the way the next
+// krino run would read it.
+func reload(t *testing.T, home string) *engine.Engine {
+ t.Helper()
+ e, errs := engine.Load(filepath.Join(home, ".config", "krino", "krino.conf"))
+ if len(errs) > 0 {
+ t.Fatal(errs)
+ }
+ return e
+}
diff --git a/gui/internal/ui/rules.go b/gui/internal/ui/rules.go
new file mode 100644
index 0000000..741f6b1
--- /dev/null
+++ b/gui/internal/ui/rules.go
@@ -0,0 +1,388 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package ui
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "strconv"
+ "strings"
+
+ "github.com/diamondburned/gotk4/pkg/glib/v2"
+ "github.com/diamondburned/gotk4/pkg/gtk/v4"
+ "github.com/diamondburned/gotk4/pkg/pango"
+
+ "krino/gui/internal/model"
+ "krino/internal/xdg"
+)
+
+// checkDelay is how long the editor waits after a keystroke before it
+// checks the text (GUI design §5.3).
+const checkDelay = 300
+
+// rulesView is the Rules tab: one directory's file as text, checked as it
+// is typed, with Test on file and Save (GUI design §5.2, §5.3).
+type rulesView struct {
+ w *Window
+ root *gtk.Box
+
+ dirs *gtk.DropDown
+ names []string
+ save *gtk.Button
+ revert *gtk.Button
+ reload *gtk.Button
+
+ view *gtk.TextView
+ buf *gtk.TextBuffer
+ check *gtk.Label
+ diags *gtk.ListBox
+
+ testPath *gtk.Entry
+ testGo *gtk.Button
+ out *gtk.TextView
+
+ rules *model.Rules
+ pending glib.SourceHandle
+ quiet bool // set while the view is being filled, so no check is armed
+}
+
+func newRulesView(w *Window) *rulesView {
+ r := &rulesView{w: w}
+ r.root = gtk.NewBox(gtk.OrientationVertical, 0)
+
+ r.names = model.RuleFiles(w.engine)
+ names := r.names
+ if len(names) == 0 {
+ names = []string{"none"}
+ }
+ r.dirs = gtk.NewDropDownFromStrings(names)
+ r.save = gtk.NewButtonWithLabel("Save")
+ r.save.AddCSSClass("suggested-action")
+ r.revert = gtk.NewButtonWithLabel("Revert")
+ r.reload = gtk.NewButtonWithLabel("Reload")
+
+ bar := gtk.NewBox(gtk.OrientationHorizontal, 6)
+ bar.SetMarginTop(6)
+ bar.SetMarginStart(6)
+ bar.SetMarginEnd(6)
+ bar.SetMarginBottom(6)
+ bar.Append(gtk.NewLabel("Rules for"))
+ bar.Append(r.dirs)
+ r.check = gtk.NewLabel("")
+ r.check.SetXAlign(0)
+ r.check.SetHExpand(true)
+ r.check.SetEllipsize(pango.EllipsizeEnd)
+ r.check.SetMaxWidthChars(20)
+ bar.Append(r.check)
+ bar.Append(r.reload)
+ bar.Append(r.revert)
+ bar.Append(r.save)
+
+ r.view = gtk.NewTextView()
+ r.view.SetMonospace(true)
+ r.view.SetLeftMargin(8)
+ r.view.SetRightMargin(8)
+ r.view.SetTopMargin(6)
+ r.buf = r.view.Buffer()
+ editScroll := gtk.NewScrolledWindow()
+ editScroll.SetChild(r.view)
+ editScroll.SetVExpand(true)
+ editScroll.SetHExpand(true)
+
+ r.diags = gtk.NewListBox()
+ r.diags.SetSelectionMode(gtk.SelectionSingle)
+ diagScroll := gtk.NewScrolledWindow()
+ diagScroll.SetChild(r.diags)
+ diagScroll.SetSizeRequest(-1, 110)
+
+ left := gtk.NewBox(gtk.OrientationVertical, 0)
+ left.Append(editScroll)
+ left.Append(gtk.NewSeparator(gtk.OrientationHorizontal))
+ left.Append(diagScroll)
+
+ // Test on file: any file under the directory, answered from the text in
+ // the editor rather than from what is saved.
+ r.testPath = gtk.NewEntry()
+ r.testPath.SetPlaceholderText("a file to test, e.g. ~/downloads/a.pdf")
+ r.testPath.SetHExpand(true)
+ r.testGo = gtk.NewButtonWithLabel("Test on file")
+ testBar := gtk.NewBox(gtk.OrientationHorizontal, 6)
+ testBar.SetMarginStart(6)
+ testBar.SetMarginEnd(6)
+ testBar.SetMarginTop(6)
+ testBar.SetMarginBottom(6)
+ testBar.Append(r.testPath)
+ testBar.Append(r.testGo)
+
+ r.out = gtk.NewTextView()
+ r.out.SetEditable(false)
+ r.out.SetMonospace(true)
+ r.out.SetWrapMode(gtk.WrapWordChar)
+ r.out.SetLeftMargin(8)
+ r.out.SetRightMargin(8)
+ r.out.SetTopMargin(6)
+ r.out.Buffer().SetText("Type a file's path and press Test on file to see what these rules would do to it.")
+ outScroll := gtk.NewScrolledWindow()
+ outScroll.SetChild(r.out)
+ outScroll.SetVExpand(true)
+ right := gtk.NewBox(gtk.OrientationVertical, 0)
+ right.Append(testBar)
+ right.Append(gtk.NewSeparator(gtk.OrientationHorizontal))
+ right.Append(outScroll)
+
+ panes := gtk.NewPaned(gtk.OrientationHorizontal)
+ panes.SetStartChild(left)
+ panes.SetEndChild(right)
+ panes.SetResizeStartChild(true)
+ panes.SetResizeEndChild(false)
+ panes.SetShrinkEndChild(false)
+ panes.SetPosition(620)
+ panes.SetVExpand(true)
+
+ r.root.Append(bar)
+ r.root.Append(gtk.NewSeparator(gtk.OrientationHorizontal))
+ r.root.Append(panes)
+
+ r.buf.ConnectChanged(r.onChanged)
+ r.diags.ConnectRowSelected(func(row *gtk.ListBoxRow) {
+ if row != nil {
+ r.goToLine(row.Name())
+ }
+ })
+ r.dirs.Connect("notify::selected", func() { r.open(r.selectedName()) })
+ r.save.ConnectClicked(r.onSave)
+ r.revert.ConnectClicked(func() {
+ if r.rules != nil {
+ r.rules.Revert()
+ r.fill()
+ }
+ })
+ r.reload.ConnectClicked(func() { r.reloadFromDisk() })
+ r.testGo.ConnectClicked(r.onTest)
+ r.testPath.ConnectActivate(r.onTest)
+
+ r.open(r.selectedName())
+ return r
+}
+
+// selectedName is the directory the picker names, "" when none is offered.
+func (r *rulesView) selectedName() string {
+ i := int(r.dirs.Selected())
+ if i < 0 || i >= len(r.names) {
+ return ""
+ }
+ return r.names[i]
+}
+
+// open reads a directory's file into the editor.
+func (r *rulesView) open(name string) {
+ if name == "" {
+ r.check.SetText("no directory has rules of its own yet; add one with: krino new NAME PATH")
+ r.setEditable(false)
+ return
+ }
+ rules, err := model.OpenRules(r.w.engine, name)
+ if err != nil {
+ r.check.SetText(escape(err.Error()))
+ r.setEditable(false)
+ return
+ }
+ r.rules = rules
+ r.setEditable(true)
+ if root := r.w.dirRoot(name); root != "" {
+ r.testPath.SetText(xdg.Abbrev(root) + "/")
+ }
+ r.fill()
+}
+
+// fill puts the model's text in the view and checks it.
+func (r *rulesView) fill() {
+ r.quiet = true
+ r.buf.SetText(r.rules.Text)
+ r.quiet = false
+ r.runCheck()
+}
+
+// setEditable turns the editor on or off as a whole.
+func (r *rulesView) setEditable(on bool) {
+ r.view.SetEditable(on)
+ r.save.SetSensitive(false)
+ r.revert.SetSensitive(on)
+ r.reload.SetSensitive(on)
+ r.testGo.SetSensitive(on)
+}
+
+// onChanged arms the check, one timer at a time: checking on every
+// keystroke would load the whole configuration as the user types.
+func (r *rulesView) onChanged() {
+ if r.quiet || r.rules == nil {
+ return
+ }
+ if r.pending != 0 {
+ glib.SourceRemove(r.pending)
+ }
+ r.pending = glib.TimeoutAdd(checkDelay, func() bool {
+ r.pending = 0
+ r.runCheck()
+ return false
+ })
+}
+
+// text is what the editor holds.
+func (r *rulesView) text() string {
+ start, end := r.buf.Bounds()
+ return r.buf.Text(start, end, true)
+}
+
+// runCheck loads the configuration with the unsaved text and shows what is
+// wrong with it.
+func (r *rulesView) runCheck() {
+ if r.rules == nil {
+ return
+ }
+ r.rules.SetText(r.text())
+ diags := r.rules.Check()
+ clearList(r.diags)
+ switch {
+ case len(diags) == 0:
+ r.check.SetText("check: no errors")
+ case len(diags) == 1:
+ r.check.SetText("check: 1 problem")
+ default:
+ r.check.SetText(fmt.Sprintf("check: %d problems", len(diags)))
+ }
+ for _, d := range diags {
+ where := xdg.Abbrev(d.File)
+ if d.Pos.Line > 0 {
+ where = fmt.Sprintf("%s:%d:%d", where, d.Pos.Line, d.Pos.Col)
+ }
+ line := d.Pos.Line
+ row := gtk.NewListBoxRow()
+ label := gtk.NewLabel(escape(where + ": " + d.Msg))
+ label.SetXAlign(0)
+ label.SetMarginStart(6)
+ label.SetMarginEnd(6)
+ label.SetWrap(true)
+ label.AddCSSClass("error")
+ row.SetChild(label)
+ r.diags.Append(row)
+ if d.File == r.rules.File && line > 0 {
+ row.SetName(fmt.Sprintf("%d", line))
+ }
+ }
+ r.save.SetSensitive(len(diags) == 0 && r.rules.Modified())
+ r.revert.SetSensitive(r.rules.Modified())
+}
+
+// goToLine puts the cursor on the line a diagnostic names, so clicking one
+// shows what it is about. A diagnostic in another file carries no line.
+func (r *rulesView) goToLine(name string) {
+ line, err := strconv.Atoi(name)
+ if err != nil || line <= 0 {
+ return
+ }
+ iter, ok := r.buf.IterAtLine(line - 1)
+ if !ok {
+ return
+ }
+ r.buf.PlaceCursor(iter)
+ r.view.ScrollToIter(iter, 0, true, 0, 0.3)
+ r.view.GrabFocus()
+}
+
+// onSave writes the file, or asks what to do when someone else has.
+func (r *rulesView) onSave() {
+ if r.rules == nil {
+ return
+ }
+ err := r.rules.Save()
+ switch {
+ case err == nil:
+ r.w.setStatus("saved %s; the previous text is beside it as %s.bak",
+ escape(xdg.Abbrev(r.rules.File)), escape(r.rules.Name+".conf"))
+ r.reloadEngine()
+ r.runCheck()
+ case errors.Is(err, model.ErrChangedOnDisk):
+ r.askAboutDiskChange()
+ default:
+ r.w.setStatus("save: %v", err)
+ }
+}
+
+// askAboutDiskChange offers Reload, Overwrite or Cancel when the file has
+// changed since it was opened (GUI design §5.3).
+func (r *rulesView) askAboutDiskChange() {
+ d := gtk.NewMessageDialog(&r.w.win.Window, gtk.DialogModal|gtk.DialogDestroyWithParent,
+ gtk.MessageWarning, gtk.ButtonsNone)
+ d.SetObjectProperty("text", "Something else changed "+escape(xdg.Abbrev(r.rules.File)))
+ d.SetObjectProperty("secondary-text",
+ "Reload takes what is on disk and drops your changes. Overwrite keeps yours and puts the other text in the .bak file.")
+ d.AddButton("Cancel", int(gtk.ResponseCancel))
+ d.AddButton("Reload", int(gtk.ResponseReject))
+ d.AddButton("Overwrite", int(gtk.ResponseAccept))
+ d.ConnectResponse(func(response int) {
+ d.Destroy()
+ switch response {
+ case int(gtk.ResponseReject):
+ r.reloadFromDisk()
+ case int(gtk.ResponseAccept):
+ if err := r.rules.SaveOverwriting(); err != nil {
+ r.w.setStatus("save: %v", err)
+ return
+ }
+ r.w.setStatus("saved over the other change; it is in the .bak file")
+ r.reloadEngine()
+ r.runCheck()
+ }
+ })
+ d.Show()
+}
+
+// reloadFromDisk drops unsaved text and re-reads the file.
+func (r *rulesView) reloadFromDisk() {
+ if r.rules == nil {
+ return
+ }
+ if err := r.rules.Reload(); err != nil {
+ r.w.setStatus("reload: %v", err)
+ return
+ }
+ r.fill()
+}
+
+// reloadEngine makes the saved rules the ones the other tabs use.
+func (r *rulesView) reloadEngine() {
+ if err := r.w.reloadEngine(); err != nil {
+ r.w.setStatus("saved, but the new rules do not load: %v", err)
+ }
+}
+
+// onTest explains one file against the unsaved text, off the main loop:
+// content extraction can take seconds.
+func (r *rulesView) onTest() {
+ if r.rules == nil {
+ return
+ }
+ path := strings.TrimSpace(r.testPath.Text())
+ if path == "" {
+ r.out.Buffer().SetText("Type the path of a file under the directory first.")
+ return
+ }
+ r.rules.SetText(r.text())
+ r.testGo.SetSensitive(false)
+ r.out.Buffer().SetText("testing " + escape(path) + "...")
+ var text string
+ runInBackground(func(ctx context.Context) error {
+ var err error
+ text, err = r.rules.Explain(ctx, path)
+ return err
+ }, func(err error) {
+ r.testGo.SetSensitive(true)
+ if err != nil {
+ r.out.Buffer().SetText(escapeText(err.Error()))
+ return
+ }
+ r.out.Buffer().SetText(escapeText(text))
+ })
+}
diff --git a/gui/internal/ui/window.go b/gui/internal/ui/window.go
index c42a7e8..2453444 100644
--- a/gui/internal/ui/window.go
+++ b/gui/internal/ui/window.go
@@ -27,6 +27,7 @@ type Window struct {
plan *planView
history *historyView
+ rules *rulesView
status *gtk.Label
}
@@ -44,7 +45,8 @@ func NewWindow(app *gtk.Application, e *engine.Engine) *Window {
notebook.AppendPage(w.plan.root, gtk.NewLabel("Plan"))
w.history = newHistoryView(w)
notebook.AppendPage(w.history.root, gtk.NewLabel("History & undo"))
- notebook.AppendPage(placeholder("The rules editor arrives with a later milestone."), gtk.NewLabel("Rules"))
+ w.rules = newRulesView(w)
+ notebook.AppendPage(w.rules.root, gtk.NewLabel("Rules"))
// The log is read when the tab is first opened, not at start-up: a
// window that only sorts never reads it.
loaded := false
@@ -81,6 +83,21 @@ func NewWindow(app *gtk.Application, e *engine.Engine) *Window {
// Show puts the window on screen.
func (w *Window) Show() { w.win.Show() }
+// reloadEngine re-reads the configuration, after the rules editor saves, so
+// every tab works from the rules the user just wrote. An open plan came
+// from the old ones, so it is closed and its lock released.
+func (w *Window) reloadEngine() error {
+ e, diags := engine.Load(w.engine.MainFile)
+ if len(diags) > 0 {
+ return diags[0]
+ }
+ e.CacheDir = w.engine.CacheDir
+ w.plan.closeTab()
+ w.history.closeTab()
+ w.engine = e
+ return nil
+}
+
// dirRoot is the path of the configured directory called name, or "" if
// the configuration no longer has one - a log entry can outlive its
// directory.
@@ -126,6 +143,18 @@ func escape(s string) string {
return b.String()
}
+// escapeText is escape for text shown in a pane rather than on one line:
+// line breaks are the layout, so they are kept, and every other control or
+// bidirectional character is still written as an escape.
+func escapeText(s string) string {
+ var b strings.Builder
+ for _, line := range strings.Split(s, "\n") {
+ b.WriteString(escape(line))
+ b.WriteString("\n")
+ }
+ return strings.TrimSuffix(b.String(), "\n")
+}
+
// runInBackground runs work off the main loop and hands its result back to
// the GTK thread with done. GTK may only be touched from the main loop.
func runInBackground(work func(context.Context) error, done func(error)) context.CancelFunc {
diff --git a/internal/config/skel.go b/internal/config/skel.go
index 2e54894..e0d7dd8 100644
--- a/internal/config/skel.go
+++ b/internal/config/skel.go
@@ -165,6 +165,12 @@ func writeNew(path string, data []byte) error {
return f.Close()
}
+// Replace atomically replaces the file at path with data, keeping its
+// permissions and following a symlink to its target - what krino new does
+// when it rewrites krino.conf. The GUI's rules editor saves through it, so
+// there is one way krino writes a configuration file (GUI design §5.3).
+func Replace(path string, data []byte) error { return replaceFile(path, data) }
+
// replaceFile atomically replaces the file at path, keeping its permissions.
// A symlink is followed and its target replaced, so dotfile links survive.
func replaceFile(path string, data []byte) error {