From 85d65ebe0adf1b156324a3a4c220e415a79ba9ce Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Wed, 16 Sep 2026 14:13:26 +0200 Subject: gui: Rules tab - the file as text, checked as you type, tested and saved --- gui/internal/model/rules.go | 261 ++++++++++++++++++++++++++ gui/internal/model/rules_test.go | 241 ++++++++++++++++++++++++ gui/internal/ui/rules.go | 388 +++++++++++++++++++++++++++++++++++++++ gui/internal/ui/window.go | 31 +++- internal/config/skel.go | 6 + 5 files changed, 926 insertions(+), 1 deletion(-) create mode 100644 gui/internal/model/rules.go create mode 100644 gui/internal/model/rules_test.go create mode 100644 gui/internal/ui/rules.go 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 { -- cgit v1.3