summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-09-16 09:39:59 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-09-16 09:39:59 +0200
commit6ef83d6bdfb6120f9e1fbd145e0bc463196103d1 (patch)
tree8eee1f2c29dcbef4ed96b23b08273d42ba0b5d49
parent27eb6353030953e91a45b0104bd0567e53622090 (diff)
downloadkrino-6ef83d6bdfb6120f9e1fbd145e0bc463196103d1.tar.gz
krino-6ef83d6bdfb6120f9e1fbd145e0bc463196103d1.zip
gui: History and undo tab; each plan and undo is its own run
-rw-r--r--gui/cmd/krino-gui/main.go8
-rw-r--r--gui/internal/model/history.go280
-rw-r--r--gui/internal/model/history_test.go207
-rw-r--r--gui/internal/model/plan.go41
-rw-r--r--gui/internal/model/plan_test.go35
-rw-r--r--gui/internal/ui/history.go426
-rw-r--r--gui/internal/ui/plan.go10
-rw-r--r--gui/internal/ui/window.go40
-rw-r--r--internal/engine/apply.go14
-rw-r--r--internal/engine/apply_test.go43
10 files changed, 1041 insertions, 63 deletions
diff --git a/gui/cmd/krino-gui/main.go b/gui/cmd/krino-gui/main.go
index 9b46b22..f9a9759 100644
--- a/gui/cmd/krino-gui/main.go
+++ b/gui/cmd/krino-gui/main.go
@@ -34,15 +34,9 @@ func main() {
os.Exit(2)
}
e.CacheDir = cacheDir()
- sess, err := e.NewSession(false)
- if err != nil {
- fmt.Fprintln(os.Stderr, "krino-gui: "+err.Error())
- os.Exit(1)
- }
- defer sess.Close()
app := gtk.NewApplication("xyz.labunix.krino", 0)
- app.ConnectActivate(func() { ui.NewWindow(app, e, sess).Show() })
+ app.ConnectActivate(func() { ui.NewWindow(app, e).Show() })
if code := app.Run(nil); code != 0 {
os.Exit(code)
}
diff --git a/gui/internal/model/history.go b/gui/internal/model/history.go
new file mode 100644
index 0000000..9ce854c
--- /dev/null
+++ b/gui/internal/model/history.go
@@ -0,0 +1,280 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package model
+
+import (
+ "context"
+ "fmt"
+ "sort"
+ "strings"
+ "time"
+
+ "krino/internal/engine"
+ "krino/internal/journal"
+ "krino/internal/lock"
+)
+
+// Run is one line of the History tab's run list: what a run did, and
+// whether it has since been reversed (GUI design §4).
+type Run struct {
+ ID string
+ Start time.Time
+ Dirs []string
+ Summary string // "3 moved · 1 trashed", or "nothing applied"
+ Note string // "(undone)", "(partly undone)", or ""
+ UndoOf string // for an undo run, the run it reverses
+}
+
+// Runs lists the most recent runs, newest first. It reads the log only.
+func Runs(e *engine.Engine, n int) ([]Run, error) {
+ rs, err := journal.Runs(e.Config.LogFile(), n)
+ if err != nil {
+ return nil, err
+ }
+ out := make([]Run, 0, len(rs))
+ for _, r := range rs {
+ out = append(out, Run{
+ ID: r.ID,
+ Start: r.Start,
+ Dirs: r.Dirs,
+ Summary: countsText(r.Counts),
+ Note: note(r),
+ UndoOf: r.UndoOf,
+ })
+ }
+ return out, nil
+}
+
+// note is what the list says about a run that has been reversed.
+func note(r journal.Run) string {
+ switch {
+ case r.PartlyUndone:
+ return "(partly undone)"
+ case r.Undone:
+ return "(undone)"
+ }
+ return ""
+}
+
+// pastTense and countOrder are krino log's own display words and order
+// (cmd/krino/log.go): the log's action names are the wire format and are
+// never renamed, so both front ends translate them the same way.
+var pastTense = map[string]string{
+ "copy": "copied", "move": "moved", "rename": "renamed",
+ "trash": "trashed", "delete": "deleted", "displace": "displaced",
+ "undo-copy": "undo-copied", "undo-move": "undo-moved",
+ "undo-rename": "undo-renamed", "undo-trash": "undo-trashed",
+ "undo-displace": "undo-displaced",
+}
+
+var countOrder = []string{
+ "copy", "move", "rename", "trash", "delete", "displace",
+ "undo-copy", "undo-move", "undo-rename", "undo-trash", "undo-displace",
+}
+
+// countsText renders a run's counts in a fixed order, skipping actions with
+// no successful entries.
+func countsText(counts map[string]int) string {
+ var parts []string
+ for _, action := range countOrder {
+ if n := counts[action]; n > 0 {
+ parts = append(parts, fmt.Sprintf("%d %s", n, pastTense[action]))
+ }
+ }
+ if len(parts) == 0 {
+ return "nothing applied"
+ }
+ return strings.Join(parts, " · ")
+}
+
+// UndoRow is one file of a run's reversal.
+type UndoRow struct {
+ File string // the Rel the original run logged
+ Dir string
+ Steps []engine.UndoStep
+ Refused string // non-empty: nothing here is reversed, and why
+ // Selected is the checkbox. A refused file has none.
+ Selected bool
+ Actable bool
+ Outcome string // "", "done", "failed: ...", "declined"
+}
+
+// UndoCounts is the header "N files · N to reverse · N refused".
+type UndoCounts struct {
+ Files, ToReverse, Refused int
+}
+
+// UndoTab is one run's reversal, ready to show and approve.
+type UndoTab struct {
+ Run string
+ Rows []UndoRow
+ Counts UndoCounts
+ Applied bool
+
+ sess *engine.Session
+ up *engine.UndoPlan
+ locks []*lock.Lock
+}
+
+// PlanUndo builds the reversal of runID and locks every directory it
+// touches, in name order, so nothing moves under the user while they
+// choose; a directory another krino holds stops the whole undo, named
+// (spec §10, GUI design §4). Nothing is touched until Apply.
+//
+// Like a plan, an undo gets its own session: reversing a run is a run of
+// its own, logged under a new id that says which run it undoes.
+func PlanUndo(ctx context.Context, e *engine.Engine, runID string) (*UndoTab, error) {
+ sess, err := e.NewSession(false)
+ if err != nil {
+ return nil, err
+ }
+ up, err := sess.PlanUndo(runID)
+ if err != nil {
+ sess.Close()
+ return nil, err
+ }
+ locks, err := sess.LockDirs(ctx, undoDirNames(up.Files), false)
+ if err != nil {
+ sess.Close()
+ return nil, err
+ }
+ t := &UndoTab{Run: up.Run, sess: sess, up: up, locks: locks}
+ t.fill()
+ return t, nil
+}
+
+// fill turns the engine's undo plan into rows and counts.
+func (t *UndoTab) fill() {
+ t.Rows = nil
+ t.Counts = UndoCounts{Files: len(t.up.Files)}
+ for _, f := range t.up.Files {
+ row := UndoRow{File: f.File, Dir: f.Dir, Steps: f.Steps, Refused: f.Refused}
+ row.Actable = f.Refused == ""
+ row.Selected = row.Actable
+ if row.Actable {
+ t.Counts.ToReverse++
+ } else {
+ t.Counts.Refused++
+ }
+ t.Rows = append(t.Rows, row)
+ }
+}
+
+// undoDirNames is every directory the plan touches, once, in name order.
+func undoDirNames(files []engine.UndoFile) []string {
+ seen := map[string]bool{}
+ var out []string
+ for _, f := range files {
+ if f.Dir != "" && !seen[f.Dir] {
+ seen[f.Dir] = true
+ out = append(out, f.Dir)
+ }
+ }
+ sort.Strings(out)
+ return out
+}
+
+// SelectAll checks every file that can be reversed; SelectNone clears them.
+func (t *UndoTab) SelectAll() { t.setAll(true) }
+func (t *UndoTab) SelectNone() { t.setAll(false) }
+
+func (t *UndoTab) setAll(on bool) {
+ for i := range t.Rows {
+ t.Rows[i].Selected = on && t.Rows[i].Actable
+ }
+}
+
+// Toggle flips row i's checkbox; a refused row stays off.
+func (t *UndoTab) Toggle(i int) {
+ if i < 0 || i >= len(t.Rows) || !t.Rows[i].Actable {
+ return
+ }
+ t.Rows[i].Selected = !t.Rows[i].Selected
+}
+
+// SelectedCount is how many files Undo would reverse.
+func (t *UndoTab) SelectedCount() int {
+ n := 0
+ for _, r := range t.Rows {
+ if r.Selected {
+ n++
+ }
+ }
+ return n
+}
+
+// Apply reverses the checked files. An unchecked one is not dropped: it is
+// logged as declined, exactly as the terminal review logs a file the user
+// said no to (spec §9). Refused files ride along unchanged, as they do on
+// the command line. The locks are released afterwards: the plan is history.
+func (t *UndoTab) Apply(ctx context.Context) (*engine.ApplyResult, error) {
+ toApply := &engine.UndoPlan{Run: t.up.Run, Cleanup: t.up.Cleanup}
+ for i, f := range t.up.Files {
+ if f.Refused == "" && !t.Rows[i].Selected {
+ f.Declined = true
+ }
+ toApply.Files = append(toApply.Files, f)
+ }
+ res, err := t.sess.ApplyUndo(ctx, toApply)
+ t.Applied = true
+ t.Close()
+ if res != nil {
+ t.record(res)
+ }
+ return res, err
+}
+
+// record writes each file's outcome onto its row, matching on the file name
+// the undo result carries.
+func (t *UndoTab) record(res *engine.ApplyResult) {
+ rows := map[string]int{}
+ for i, r := range t.Rows {
+ rows[r.File] = i
+ }
+ for _, fr := range res.Files {
+ if i, ok := rows[fr.File.Rel]; ok {
+ t.Rows[i].Outcome = undoOutcome(t.Rows[i], fr)
+ }
+ }
+}
+
+// undoOutcome is what happened to one file. Removing a directory the
+// original run made is tidiness, not a restoration - it fails whenever
+// something else still lives there, a declined file of this very undo
+// included - so a failed undo-mkdir does not make the file itself a
+// failure, exactly as the engine's own counts treat it.
+func undoOutcome(row UndoRow, fr engine.FileResult) string {
+ out := "done"
+ for i, sr := range fr.Steps {
+ action := ""
+ if i < len(row.Steps) {
+ action = row.Steps[i].Action
+ }
+ switch sr.Status {
+ case "failed":
+ if action != "undo-mkdir" {
+ return "failed: " + sr.Detail
+ }
+ case "declined":
+ out = "declined"
+ }
+ }
+ return out
+}
+
+// Close releases the directories' locks and ends the run, which Apply also
+// does. One lock's failure never stops the rest from being released, or the
+// session from being closed. Closing twice is not an error.
+func (t *UndoTab) Close() error {
+ var first error
+ for _, l := range t.locks {
+ if err := l.Release(); err != nil && first == nil {
+ first = err
+ }
+ }
+ t.locks = nil
+ if err := t.sess.Close(); err != nil && first == nil {
+ first = err
+ }
+ return first
+}
diff --git a/gui/internal/model/history_test.go b/gui/internal/model/history_test.go
new file mode 100644
index 0000000..a486c2e
--- /dev/null
+++ b/gui/internal/model/history_test.go
@@ -0,0 +1,207 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package model
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "krino/internal/engine"
+ "krino/internal/lock"
+)
+
+// applyOneRun sorts the sandbox directory and returns the run id it wrote,
+// so the history tests have something to look back at.
+func applyOneRun(t *testing.T, e *engine.Engine) string {
+ t.Helper()
+ tab, err := Plan(context.Background(), e, e.Dirs[0])
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := tab.Apply(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ return tab.Run()
+}
+
+// TestRunsListsWhatHappened: the run list shows the run just applied, with
+// its directory and its counts in krino log's words, and says so once the
+// run has been undone.
+func TestRunsListsWhatHappened(t *testing.T) {
+ conf := "(path \"~/dl\")\n(rule \"all\" (move \"Out\"))\n"
+ e, _ := sandboxDir(t, conf, map[string]string{"a.pdf": "one", "b.pdf": "two"})
+ id := applyOneRun(t, e)
+
+ runs, err := Runs(e, 50)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(runs) != 1 {
+ t.Fatalf("runs = %+v, want the one run", runs)
+ }
+ r := runs[0]
+ if r.ID != id || len(r.Dirs) != 1 || r.Dirs[0] != "dl" {
+ t.Errorf("run = %+v, want %s in dl", r, id)
+ }
+ if r.Summary != "2 moved" {
+ t.Errorf("summary = %q, want %q", r.Summary, "2 moved")
+ }
+ if r.Note != "" || r.UndoOf != "" {
+ t.Errorf("a fresh run is not marked: %+v", r)
+ }
+
+ // Undo it, and the list says so - and carries the undo run itself.
+ tab, err := PlanUndo(context.Background(), e, id)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := tab.Apply(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ tab.Close()
+ runs, err = Runs(e, 50)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(runs) != 2 {
+ t.Fatalf("runs = %+v, want the run and its undo", runs)
+ }
+ if runs[0].UndoOf != id {
+ t.Errorf("newest run = %+v, want the undo of %s", runs[0], id)
+ }
+ if runs[1].Note != "(undone)" {
+ t.Errorf("undone run = %+v, want (undone)", runs[1])
+ }
+}
+
+// TestUndoRowsAndSelection: every file of the run is a row, unchecked files
+// are logged as declined rather than reversed, and the counts read as the
+// terminal's header does.
+func TestUndoRowsAndSelection(t *testing.T) {
+ conf := "(path \"~/dl\")\n(rule \"all\" (move \"Out\"))\n"
+ e, h := sandboxDir(t, conf, map[string]string{"a.pdf": "one", "b.pdf": "two"})
+ id := applyOneRun(t, e)
+
+ tab, err := PlanUndo(context.Background(), e, id)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer tab.Close()
+ if tab.Counts.Files != 2 || tab.Counts.ToReverse != 2 || tab.Counts.Refused != 0 {
+ t.Fatalf("counts = %+v", tab.Counts)
+ }
+ if tab.SelectedCount() != 2 {
+ t.Fatalf("rows do not start selected: %+v", tab.Rows)
+ }
+ for i, r := range tab.Rows {
+ if r.File == "b.pdf" {
+ tab.Toggle(i)
+ }
+ }
+ res, err := tab.Apply(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if res.Applied != 1 || res.Declined != 1 {
+ t.Errorf("result = %+v", res)
+ }
+ if _, err := os.Stat(filepath.Join(h, "dl", "a.pdf")); err != nil {
+ t.Errorf("the checked file was not put back: %v", err)
+ }
+ if _, err := os.Stat(filepath.Join(h, "dl", "Out", "b.pdf")); err != nil {
+ t.Errorf("the unchecked file was put back anyway: %v", err)
+ }
+ for _, r := range tab.Rows {
+ want := "done"
+ if r.File == "b.pdf" {
+ want = "declined"
+ }
+ if r.Outcome != want {
+ t.Errorf("%s: outcome %q, want %q", r.File, r.Outcome, want)
+ }
+ }
+}
+
+// TestUndoRefusedRowIsNotSelectable: a file undo will not touch - here its
+// destination is gone - is shown with the reason and can never be checked
+// (GUI design §4).
+func TestUndoRefusedRowIsNotSelectable(t *testing.T) {
+ conf := "(path \"~/dl\")\n(rule \"all\" (move \"Out\"))\n"
+ e, h := sandboxDir(t, conf, map[string]string{"a.pdf": "one", "b.pdf": "two"})
+ id := applyOneRun(t, e)
+ if err := os.Remove(filepath.Join(h, "dl", "Out", "a.pdf")); err != nil {
+ t.Fatal(err)
+ }
+
+ tab, err := PlanUndo(context.Background(), e, id)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer tab.Close()
+ if tab.Counts.Refused != 1 || tab.Counts.ToReverse != 1 {
+ t.Fatalf("counts = %+v", tab.Counts)
+ }
+ tab.SelectAll()
+ for i, r := range tab.Rows {
+ if r.File != "a.pdf" {
+ continue
+ }
+ if r.Refused == "" {
+ t.Errorf("the missing file carries no reason: %+v", r)
+ }
+ if r.Selected || r.Actable {
+ t.Errorf("a refused row is selectable: %+v", r)
+ }
+ tab.Toggle(i)
+ if tab.Rows[i].Selected {
+ t.Error("Toggle checked a refused row")
+ }
+ }
+}
+
+// TestUndoHoldsTheDirectoryLocks: while an undo plan is open nothing else
+// may work in the directories it covers, and Close - like Apply - frees
+// them (GUI design §4).
+func TestUndoHoldsTheDirectoryLocks(t *testing.T) {
+ conf := "(path \"~/dl\")\n(rule \"all\" (move \"Out\"))\n"
+ e, _ := sandboxDir(t, conf, map[string]string{"a.pdf": "one"})
+ id := applyOneRun(t, e)
+
+ tab, err := PlanUndo(context.Background(), e, id)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := lock.Acquire(context.Background(), e.Config.LockFile("dl"), false); !errors.Is(err, lock.ErrHeld) {
+ t.Fatalf("an open undo plan does not hold the lock: %v", err)
+ }
+ if err := tab.Close(); err != nil {
+ t.Fatal(err)
+ }
+ l, err := lock.Acquire(context.Background(), e.Config.LockFile("dl"), false)
+ if err != nil {
+ t.Fatalf("closing the tab did not release the lock: %v", err)
+ }
+ l.Release()
+}
+
+// TestPlanUndoRefusesAHeldDirectory: a directory another krino is working
+// in stops the undo before anything is shown, and the message names it.
+func TestPlanUndoRefusesAHeldDirectory(t *testing.T) {
+ conf := "(path \"~/dl\")\n(rule \"all\" (move \"Out\"))\n"
+ e, _ := sandboxDir(t, conf, map[string]string{"a.pdf": "one"})
+ id := applyOneRun(t, e)
+ held, err := lock.Acquire(context.Background(), e.Config.LockFile("dl"), false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer held.Release()
+ if _, err := PlanUndo(context.Background(), e, id); !errors.Is(err, lock.ErrHeld) {
+ t.Fatalf("PlanUndo = %v, want lock.ErrHeld", err)
+ } else if !strings.Contains(err.Error(), "dl") {
+ t.Errorf("the message does not name the directory: %v", err)
+ }
+}
diff --git a/gui/internal/model/plan.go b/gui/internal/model/plan.go
index 4066eae..86acab7 100644
--- a/gui/internal/model/plan.go
+++ b/gui/internal/model/plan.go
@@ -54,18 +54,26 @@ type Counts struct {
// chooses, and no other krino moves the files under them (GUI design §3).
// A directory another run is already working in is not planned at all: the
// error is lock.ErrHeld, naming the directory.
-func Plan(ctx context.Context, sess *engine.Session, d *engine.Dir) (*PlanTab, error) {
+//
+// Each plan gets its own session, so applying it is one run of krino with
+// its own run id in the log - the same shape a command line invocation
+// writes. A window is not a run: one that sorted a directory and later
+// undid something must not log both under one id, which would make the run
+// the undo of itself.
+func Plan(ctx context.Context, e *engine.Engine, d *engine.Dir) (*PlanTab, error) {
+ sess, err := e.NewSession(false)
+ if err != nil {
+ return nil, err
+ }
l, err := sess.Lock(ctx, d, false)
if err != nil {
+ sess.Close()
return nil, fmt.Errorf("%s: %w", d.Name, err)
}
- // Start from what this run has actually landed: a name a plan the user
- // closed had reserved is free again, so looking twice never creeps up
- // through name-1, name-2 (spec §7.4).
- sess.FinishDirectory()
dp, err := sess.Plan(ctx, d)
if err != nil {
l.Release()
+ sess.Close()
return nil, err
}
t := &PlanTab{Dir: d, sess: sess, dp: dp, lock: l}
@@ -73,17 +81,22 @@ func Plan(ctx context.Context, sess *engine.Session, d *engine.Dir) (*PlanTab, e
return t, nil
}
-// Close releases the directory's lock, which Apply also does once the plan
-// is history. The session outlives the tab, so closing one plan to open
-// another keeps the run - and its claims - going. Closing twice is not an
-// error.
+// Run is the run id this plan was applied under, "" until Apply.
+func (t *PlanTab) Run() string { return t.sess.Run() }
+
+// Close releases the directory's lock and ends the run, which Apply also
+// does once the plan is history. Closing twice is not an error.
func (t *PlanTab) Close() error {
if t.lock == nil {
return nil
}
l := t.lock
t.lock = nil
- return l.Release()
+ err := l.Release()
+ if cerr := t.sess.Close(); err == nil {
+ err = cerr
+ }
+ return err
}
// fill turns the engine's plan into rows and counts.
@@ -218,12 +231,8 @@ func (t *PlanTab) Apply(ctx context.Context) (*engine.ApplyResult, error) {
}
res, err := t.sess.Apply(ctx, t.dp, approved)
t.Applied = true
- // The disk is now the truth: a destination this plan reserved but never
- // used is free again, while one it did use stays protected for the rest
- // of the run (spec §7.4).
- t.sess.FinishDirectory()
- // An applied plan is history, so the directory is free again without
- // closing the window (GUI design §3).
+ // An applied plan is history: the run is over and the directory free
+ // again, without closing the window (GUI design §3).
t.Close()
if res != nil {
t.record(res)
diff --git a/gui/internal/model/plan_test.go b/gui/internal/model/plan_test.go
index bf8f19f..746ddfc 100644
--- a/gui/internal/model/plan_test.go
+++ b/gui/internal/model/plan_test.go
@@ -52,18 +52,14 @@ func sandboxDir(t *testing.T, conf string, files map[string]string) (*engine.Eng
return e, h
}
-func planTab(t *testing.T, e *engine.Engine) (*PlanTab, *engine.Session) {
+func planTab(t *testing.T, e *engine.Engine) *PlanTab {
t.Helper()
- s, err := e.NewSession(false)
+ tab, err := Plan(context.Background(), e, e.Dirs[0])
if err != nil {
t.Fatal(err)
}
- t.Cleanup(func() { s.Close() })
- tab, err := Plan(context.Background(), s, e.Dirs[0])
- if err != nil {
- t.Fatal(err)
- }
- return tab, s
+ t.Cleanup(func() { tab.Close() })
+ return tab
}
// TestPlanRowsAndCounts: the tab shows one row per file with steps, a row
@@ -79,7 +75,7 @@ func TestPlanRowsAndCounts(t *testing.T) {
"c.txt": strings.Repeat("x", 2048), // over max-read: content unknown
"plain.txt": "nothing",
})
- tab, _ := planTab(t, e)
+ tab := planTab(t, e)
if tab.Counts.Scanned != 4 || tab.Counts.Acting != 1 || tab.Counts.Excluded != 1 {
t.Errorf("counts = %+v", tab.Counts)
}
@@ -111,7 +107,7 @@ func TestPlanRowsAndCounts(t *testing.T) {
func TestSelectionAndApply(t *testing.T) {
conf := "(path \"~/dl\")\n(rule \"all\" (move \"Out\"))\n"
e, h := sandboxDir(t, conf, map[string]string{"a.pdf": "one", "b.pdf": "two"})
- tab, _ := planTab(t, e)
+ tab := planTab(t, e)
if tab.SelectedCount() != 2 {
t.Fatalf("rows do not start selected: %+v", tab.Rows)
}
@@ -154,7 +150,7 @@ func TestSelectionAndApply(t *testing.T) {
func TestReplaceWithTrash(t *testing.T) {
conf := "(path \"~/dl\")\n(rule \"all\" (move \"Out\"))\n"
e, h := sandboxDir(t, conf, map[string]string{"a.pdf": "one"})
- tab, _ := planTab(t, e)
+ tab := planTab(t, e)
if err := tab.Replace(0, plan.Trash); err != nil {
t.Fatal(err)
}
@@ -195,7 +191,7 @@ func TestPlanLeavesTheDirectoryAlone(t *testing.T) {
func TestPlanHoldsTheLock(t *testing.T) {
conf := "(path \"~/dl\")\n(rule \"all\" (move \"Out\"))\n"
e, _ := sandboxDir(t, conf, map[string]string{"a.pdf": "one"})
- tab, _ := planTab(t, e)
+ tab := planTab(t, e)
if _, err := lock.Acquire(context.Background(), e.Config.LockFile("dl"), false); !errors.Is(err, lock.ErrHeld) {
t.Fatalf("an open plan does not hold the lock: %v", err)
}
@@ -217,7 +213,7 @@ func TestPlanHoldsTheLock(t *testing.T) {
func TestApplyReleasesTheLock(t *testing.T) {
conf := "(path \"~/dl\")\n(rule \"all\" (move \"Out\"))\n"
e, _ := sandboxDir(t, conf, map[string]string{"a.pdf": "one"})
- tab, _ := planTab(t, e)
+ tab := planTab(t, e)
if _, err := tab.Apply(context.Background()); err != nil {
t.Fatal(err)
}
@@ -241,12 +237,7 @@ func TestPlanRefusesAHeldDirectory(t *testing.T) {
t.Fatal(err)
}
defer held.Release()
- s, err := e.NewSession(false)
- if err != nil {
- t.Fatal(err)
- }
- defer s.Close()
- if _, err := Plan(context.Background(), s, e.Dirs[0]); !errors.Is(err, lock.ErrHeld) {
+ if _, err := Plan(context.Background(), e, e.Dirs[0]); !errors.Is(err, lock.ErrHeld) {
t.Fatalf("Plan = %v, want lock.ErrHeld", err)
} else if !strings.Contains(err.Error(), "dl") {
t.Errorf("the message does not name the directory: %v", err)
@@ -259,7 +250,7 @@ func TestPlanRefusesAHeldDirectory(t *testing.T) {
func TestRescanForgetsUnusedNames(t *testing.T) {
conf := "(path \"~/dl\")\n(rule \"all\" (move \"Out\") (rename \"same.pdf\"))\n"
e, _ := sandboxDir(t, conf, map[string]string{"a.pdf": "one", "b.pdf": "two"})
- tab, s := planTab(t, e)
+ tab := planTab(t, e)
// Each file moves into Out and is then renamed; the second file's name
// is taken, so the plan reserves a suffixed one for it.
want := destinations(t, tab)
@@ -271,7 +262,7 @@ func TestRescanForgetsUnusedNames(t *testing.T) {
if err := tab.Close(); err != nil {
t.Fatal(err)
}
- again, err := Plan(context.Background(), s, e.Dirs[0])
+ again, err := Plan(context.Background(), e, e.Dirs[0])
if err != nil {
t.Fatal(err)
}
@@ -286,7 +277,7 @@ func TestRescanForgetsUnusedNames(t *testing.T) {
if err := again.Close(); err != nil {
t.Fatal(err)
}
- third, err := Plan(context.Background(), s, e.Dirs[0])
+ third, err := Plan(context.Background(), e, e.Dirs[0])
if err != nil {
t.Fatal(err)
}
diff --git a/gui/internal/ui/history.go b/gui/internal/ui/history.go
new file mode 100644
index 0000000..7d4ee7b
--- /dev/null
+++ b/gui/internal/ui/history.go
@@ -0,0 +1,426 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package ui
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ "github.com/diamondburned/gotk4/pkg/pango"
+
+ "github.com/diamondburned/gotk4/pkg/gtk/v4"
+
+ "krino/gui/internal/model"
+ "krino/internal/engine"
+)
+
+// historyView is the History & undo tab: the runs on the left, the selected
+// run's reversal on the right, and Undo (GUI design §4).
+type historyView struct {
+ w *Window
+ root *gtk.Box
+
+ runs *gtk.ListBox
+ reload *gtk.Button
+ more *gtk.Button
+ limit int
+ runRows []model.Run
+ selected int
+
+ header *gtk.Label
+ note *gtk.Label
+ list *gtk.ListBox
+ selAll *gtk.Button
+ selNone *gtk.Button
+ undo *gtk.Button
+ cancel *gtk.Button
+
+ tab *model.UndoTab
+ cancelOp context.CancelFunc
+}
+
+// firstLimit is how many runs the list shows before Show more.
+const firstLimit = 50
+
+func newHistoryView(w *Window) *historyView {
+ h := &historyView{w: w, limit: firstLimit, selected: -1}
+ h.root = gtk.NewBox(gtk.OrientationVertical, 0)
+
+ h.reload = gtk.NewButtonWithLabel("Reload")
+ h.more = gtk.NewButtonWithLabel("Show more")
+ h.selAll = gtk.NewButtonWithLabel("Select all")
+ h.selNone = gtk.NewButtonWithLabel("None")
+ h.undo = gtk.NewButtonWithLabel("Undo")
+ h.undo.AddCSSClass("destructive-action")
+ h.cancel = gtk.NewButtonWithLabel("Cancel")
+
+ bar := gtk.NewBox(gtk.OrientationHorizontal, 6)
+ bar.SetMarginTop(6)
+ bar.SetMarginStart(6)
+ bar.SetMarginEnd(6)
+ bar.SetMarginBottom(6)
+ bar.Append(gtk.NewLabel("Runs"))
+ bar.Append(h.reload)
+ bar.Append(h.more)
+ h.note = gtk.NewLabel("")
+ h.note.SetXAlign(0)
+ h.note.SetHExpand(true)
+ h.note.SetEllipsize(pango.EllipsizeEnd)
+ h.note.SetMaxWidthChars(20)
+ bar.Append(h.note)
+ bar.Append(h.selAll)
+ bar.Append(h.selNone)
+ bar.Append(h.cancel)
+ bar.Append(h.undo)
+
+ h.runs = gtk.NewListBox()
+ h.runs.SetSelectionMode(gtk.SelectionSingle)
+ runScroll := gtk.NewScrolledWindow()
+ runScroll.SetChild(h.runs)
+ runScroll.SetSizeRequest(360, -1)
+
+ h.header = gtk.NewLabel("Select a run to see what undoing it would do.")
+ h.header.SetXAlign(0)
+ h.header.SetMarginStart(8)
+ h.header.SetMarginTop(6)
+ h.header.SetMarginBottom(6)
+ h.list = gtk.NewListBox()
+ h.list.SetSelectionMode(gtk.SelectionSingle)
+ listScroll := gtk.NewScrolledWindow()
+ listScroll.SetChild(h.list)
+ listScroll.SetHExpand(true)
+ listScroll.SetVExpand(true)
+ right := gtk.NewBox(gtk.OrientationVertical, 0)
+ right.Append(h.header)
+ right.Append(listScroll)
+
+ panes := gtk.NewPaned(gtk.OrientationHorizontal)
+ panes.SetStartChild(runScroll)
+ panes.SetEndChild(right)
+ panes.SetResizeStartChild(false)
+ panes.SetResizeEndChild(true)
+ panes.SetShrinkStartChild(false)
+ panes.SetPosition(360)
+ panes.SetVExpand(true)
+
+ h.root.Append(bar)
+ h.root.Append(gtk.NewSeparator(gtk.OrientationHorizontal))
+ h.root.Append(panes)
+
+ h.reload.ConnectClicked(func() { h.loadRuns() })
+ h.more.ConnectClicked(func() {
+ h.limit *= 4
+ h.loadRuns()
+ })
+ h.runs.ConnectRowSelected(func(row *gtk.ListBoxRow) {
+ if row != nil {
+ h.onRunSelected(row.Index())
+ }
+ })
+ h.selAll.ConnectClicked(func() { h.selectAll(true) })
+ h.selNone.ConnectClicked(func() { h.selectAll(false) })
+ h.undo.ConnectClicked(h.onUndo)
+ h.cancel.ConnectClicked(func() {
+ if h.cancelOp != nil {
+ h.cancelOp()
+ }
+ })
+ h.setBusy(false)
+ return h
+}
+
+// loadRuns reads the log and fills the run list.
+func (h *historyView) loadRuns() {
+ h.closeTab()
+ h.setBusy(true)
+ var runs []model.Run
+ h.cancelOp = runInBackground(func(ctx context.Context) error {
+ var err error
+ runs, err = model.Runs(h.w.engine, h.limit)
+ return err
+ }, func(err error) {
+ h.setBusy(false)
+ if err != nil {
+ h.w.setStatus("log: %v", err)
+ return
+ }
+ h.runRows = runs
+ h.selected = -1
+ h.fillRuns()
+ h.more.SetSensitive(len(runs) >= h.limit)
+ h.w.setStatus("%d run(s)", len(runs))
+ })
+}
+
+// fillRuns renders the run list, newest first.
+func (h *historyView) fillRuns() {
+ clearList(h.runs)
+ for _, r := range h.runRows {
+ h.runs.Append(runRowWidget(r))
+ }
+ h.clearPlan()
+}
+
+// runRowWidget is one line of the run list.
+func runRowWidget(r model.Run) *gtk.ListBoxRow {
+ box := gtk.NewBox(gtk.OrientationHorizontal, 8)
+ box.SetMarginStart(6)
+ box.SetMarginEnd(6)
+ box.SetMarginTop(2)
+ box.SetMarginBottom(2)
+ box.Append(column(r.Start.Format("2006-01-02 15:04"), 16, false))
+ box.Append(column(escape(runWhat(r)), 26, true))
+ row := gtk.NewListBoxRow()
+ row.SetChild(box)
+ return row
+}
+
+// runWhat is what a run did, for its line in the list.
+func runWhat(r model.Run) string {
+ var parts []string
+ if len(r.Dirs) > 0 {
+ parts = append(parts, strings.Join(r.Dirs, ", "))
+ }
+ if r.UndoOf != "" {
+ parts = append(parts, "undo of "+r.UndoOf)
+ }
+ parts = append(parts, r.Summary)
+ if r.Note != "" {
+ parts = append(parts, r.Note)
+ }
+ return strings.Join(parts, " ")
+}
+
+// onRunSelected plans the reversal of the chosen run. Choosing an undo run
+// offers what is left of the run it reversed, as plain krino undo does.
+func (h *historyView) onRunSelected(i int) {
+ if i < 0 || i >= len(h.runRows) {
+ return
+ }
+ h.selected = i
+ r := h.runRows[i]
+ target := r.ID
+ h.note.SetText("")
+ if r.UndoOf != "" {
+ target = r.UndoOf
+ h.note.SetText("that run undid " + r.UndoOf + "; offering what is left of it")
+ }
+ h.closeTab()
+ h.setBusy(true)
+ h.header.SetText("planning the undo of " + target + "...")
+ var tab *model.UndoTab
+ h.cancelOp = runInBackground(func(ctx context.Context) error {
+ var err error
+ tab, err = model.PlanUndo(ctx, h.w.engine, target)
+ return err
+ }, func(err error) {
+ h.setBusy(false)
+ if err != nil {
+ h.header.SetText(escape(err.Error()))
+ h.w.setStatus("undo %s: %v", target, err)
+ return
+ }
+ h.tab = tab
+ h.fillPlan()
+ h.w.setStatus("undo %s: %d to reverse, %d refused",
+ tab.Run, tab.Counts.ToReverse, tab.Counts.Refused)
+ })
+}
+
+// fillPlan renders the undo plan: a row per file, refused ones marked and
+// never checkable.
+func (h *historyView) fillPlan() {
+ clearList(h.list)
+ if h.tab == nil {
+ return
+ }
+ // The header is the counts alone (GUI design §4); which run this is
+ // stands in the selected line on the left and in the status bar.
+ c := h.tab.Counts
+ if c.Files == 0 {
+ h.header.SetText("nothing left to reverse in " + h.tab.Run)
+ } else {
+ h.header.SetText(fmt.Sprintf("%d files · %d to reverse · %d refused",
+ c.Files, c.ToReverse, c.Refused))
+ }
+ for i, r := range h.tab.Rows {
+ h.list.Append(h.undoRowWidget(i, r))
+ }
+ h.updateUndoButton()
+}
+
+// clearPlan empties the right-hand side, between runs.
+func (h *historyView) clearPlan() {
+ clearList(h.list)
+ h.header.SetText("Select a run to see what undoing it would do.")
+ h.undo.SetLabel("Undo")
+ h.undo.SetSensitive(false)
+}
+
+// undoRowWidget is one file of the reversal.
+func (h *historyView) undoRowWidget(i int, r model.UndoRow) *gtk.ListBoxRow {
+ box := gtk.NewBox(gtk.OrientationHorizontal, 8)
+ box.SetMarginStart(6)
+ box.SetMarginEnd(6)
+ box.SetMarginTop(2)
+ box.SetMarginBottom(2)
+
+ check := gtk.NewCheckButton()
+ check.SetActive(r.Selected)
+ check.SetSensitive(r.Actable && !h.tab.Applied)
+ check.ConnectToggled(func() {
+ if h.tab != nil && h.tab.Rows[i].Selected != check.Active() {
+ h.tab.Toggle(i)
+ h.updateUndoButton()
+ }
+ })
+ box.Append(check)
+
+ box.Append(column(escape(r.Dir+"/"+r.File), 24, true))
+ // The outcome comes before the reversal: after an undo it is what the
+ // user is looking for, and the reversal is the column that ellipsizes.
+ if r.Outcome != "" {
+ box.Append(column(escape(r.Outcome), 12, false))
+ }
+ what := column(escape(undoWhat(r, h.w.dirRoot(r.Dir))), 30, false)
+ if r.Refused != "" {
+ what.AddCSSClass("error")
+ }
+ box.Append(what)
+ row := gtk.NewListBoxRow()
+ row.SetChild(box)
+ return row
+}
+
+// undoWhat is what would happen to one file, or why nothing will.
+// Destinations inside the directory are written relative to it.
+func undoWhat(r model.UndoRow, root string) string {
+ if r.Refused != "" {
+ return "refused: " + r.Refused
+ }
+ var parts []string
+ for _, s := range r.Steps {
+ if s.Refused != "" {
+ parts = append(parts, s.Action+" refused: "+s.Refused)
+ continue
+ }
+ if s.Dst == "" {
+ parts = append(parts, s.Action)
+ continue
+ }
+ parts = append(parts, s.Action+" "+shorten(s.Dst, root))
+ }
+ return strings.Join(parts, ", ")
+}
+
+// selectAll checks or unchecks every file that can be reversed.
+func (h *historyView) selectAll(on bool) {
+ if h.tab == nil {
+ return
+ }
+ if on {
+ h.tab.SelectAll()
+ } else {
+ h.tab.SelectNone()
+ }
+ h.fillPlan()
+}
+
+// onUndo reverses the checked files, off the main loop.
+func (h *historyView) onUndo() {
+ if h.tab == nil {
+ return
+ }
+ n := h.tab.SelectedCount()
+ h.setBusy(true)
+ h.w.setStatus("reversing %d file(s)...", n)
+ var res *engine.ApplyResult
+ h.cancelOp = runInBackground(func(ctx context.Context) error {
+ var err error
+ res, err = h.tab.Apply(ctx)
+ return err
+ }, func(err error) {
+ h.setBusy(false)
+ h.fillPlan()
+ h.undo.SetSensitive(false)
+ if err != nil {
+ h.w.setStatus("undo: %v", err)
+ return
+ }
+ if res == nil {
+ return
+ }
+ h.w.setStatus("%d reversed, %d failed, %d declined", res.Applied, res.Failed, res.Declined)
+ // The run list now says (undone); the plan stays as it is, showing
+ // each file's outcome.
+ h.refreshRunsKeepingPlan()
+ })
+}
+
+// refreshRunsKeepingPlan reloads the run list without dropping the plan the
+// user is looking at.
+func (h *historyView) refreshRunsKeepingPlan() {
+ runs, err := model.Runs(h.w.engine, h.limit)
+ if err != nil {
+ h.w.setStatus("log: %v", err)
+ return
+ }
+ h.runRows = runs
+ sel := h.selected
+ clearList(h.runs)
+ for _, r := range h.runRows {
+ h.runs.Append(runRowWidget(r))
+ }
+ h.selected = sel
+}
+
+// updateUndoButton keeps the button's label and state on the selection.
+func (h *historyView) updateUndoButton() {
+ if h.tab == nil {
+ h.undo.SetLabel("Undo")
+ h.undo.SetSensitive(false)
+ return
+ }
+ n := h.tab.SelectedCount()
+ h.undo.SetLabel(fmt.Sprintf("Undo %d selected", n))
+ h.undo.SetSensitive(!h.tab.Applied && n > 0)
+}
+
+// setBusy turns the buttons on or off around a background operation.
+func (h *historyView) setBusy(busy bool) {
+ h.reload.SetSensitive(!busy)
+ h.more.SetSensitive(!busy && len(h.runRows) >= h.limit)
+ h.runs.SetSensitive(!busy)
+ h.selAll.SetSensitive(!busy && h.tab != nil)
+ h.selNone.SetSensitive(!busy && h.tab != nil)
+ h.cancel.SetSensitive(busy)
+ if busy {
+ h.undo.SetSensitive(false)
+ return
+ }
+ h.updateUndoButton()
+}
+
+// closeTab drops the open undo plan and releases its locks.
+func (h *historyView) closeTab() {
+ if h.tab == nil {
+ return
+ }
+ if err := h.tab.Close(); err != nil {
+ h.w.setStatus("undo: %v", err)
+ }
+ h.tab = nil
+ h.clearPlan()
+}
+
+// clearList removes every row of a ListBox.
+func clearList(list *gtk.ListBox) {
+ for {
+ row := list.RowAtIndex(0)
+ if row == nil {
+ return
+ }
+ list.Remove(row)
+ }
+}
diff --git a/gui/internal/ui/plan.go b/gui/internal/ui/plan.go
index 8cbbdfd..fd2f563 100644
--- a/gui/internal/ui/plan.go
+++ b/gui/internal/ui/plan.go
@@ -263,7 +263,7 @@ func (p *planView) onScan() {
var tab *model.PlanTab
p.cancelOp = runInBackground(func(ctx context.Context) error {
var err error
- tab, err = model.Plan(ctx, p.w.sess, d)
+ tab, err = model.Plan(ctx, p.w.engine, d)
return err
}, func(err error) {
p.setBusy(false)
@@ -342,13 +342,7 @@ func (p *planView) selectAll(on bool) {
// fillList renders the rows: a checkbox, the file, what would happen, the
// rule, and the outcome once applied.
func (p *planView) fillList() {
- for {
- row := p.list.RowAtIndex(0)
- if row == nil {
- break
- }
- p.list.Remove(row)
- }
+ clearList(p.list)
if p.tab == nil {
return
}
diff --git a/gui/internal/ui/window.go b/gui/internal/ui/window.go
index e35a363..c42a7e8 100644
--- a/gui/internal/ui/window.go
+++ b/gui/internal/ui/window.go
@@ -24,16 +24,17 @@ type Window struct {
win *gtk.ApplicationWindow
engine *engine.Engine
- sess *engine.Session
- plan *planView
- status *gtk.Label
+ plan *planView
+ history *historyView
+ status *gtk.Label
}
-// NewWindow builds the window for e; the session is the run everything in
-// it goes through.
-func NewWindow(app *gtk.Application, e *engine.Engine, sess *engine.Session) *Window {
- w := &Window{app: app, engine: e, sess: sess}
+// NewWindow builds the window for e. Each plan and each undo is its own
+// run of krino, with its own run id in the log, so the window itself holds
+// no session.
+func NewWindow(app *gtk.Application, e *engine.Engine) *Window {
+ w := &Window{app: app, engine: e}
w.win = gtk.NewApplicationWindow(app)
w.win.SetTitle("krino")
w.win.SetDefaultSize(1000, 640)
@@ -41,8 +42,18 @@ func NewWindow(app *gtk.Application, e *engine.Engine, sess *engine.Session) *Wi
notebook := gtk.NewNotebook()
w.plan = newPlanView(w)
notebook.AppendPage(w.plan.root, gtk.NewLabel("Plan"))
- notebook.AppendPage(placeholder("History and undo arrives with the next milestone."), gtk.NewLabel("History & undo"))
+ 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"))
+ // The log is read when the tab is first opened, not at start-up: a
+ // window that only sorts never reads it.
+ loaded := false
+ notebook.ConnectSwitchPage(func(_ gtk.Widgetter, page uint) {
+ if page == 1 && !loaded {
+ loaded = true
+ w.history.loadRuns()
+ }
+ })
w.status = gtk.NewLabel("")
w.status.SetXAlign(0)
@@ -61,6 +72,7 @@ func NewWindow(app *gtk.Application, e *engine.Engine, sess *engine.Session) *Wi
// holds, rather than leaving a lock file for the next run to find.
w.win.ConnectCloseRequest(func() bool {
w.plan.closeTab()
+ w.history.closeTab()
return false
})
return w
@@ -69,6 +81,18 @@ func NewWindow(app *gtk.Application, e *engine.Engine, sess *engine.Session) *Wi
// Show puts the window on screen.
func (w *Window) Show() { w.win.Show() }
+// 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.
+func (w *Window) dirRoot(name string) string {
+ for _, d := range w.engine.Dirs {
+ if d.Name == name {
+ return d.Root
+ }
+ }
+ return ""
+}
+
// setStatus writes the line at the bottom of the window.
func (w *Window) setStatus(format string, args ...any) {
w.status.SetText(fmt.Sprintf(format, args...))
diff --git a/internal/engine/apply.go b/internal/engine/apply.go
index 46026ba..0d894c6 100644
--- a/internal/engine/apply.go
+++ b/internal/engine/apply.go
@@ -8,6 +8,7 @@ import (
"fmt"
"io"
"os"
+ "path"
"path/filepath"
"sort"
"strings"
@@ -990,7 +991,7 @@ func (e *Engine) declineUndoFile(f UndoFile, j *journal.Writer, run string) (Fil
return FileResult{}, err
}
}
- return FileResult{Steps: steps}, nil
+ return FileResult{File: undoFileIdentity(f), Steps: steps}, nil
}
// undoFile executes every step of f in order (already last-original-step
@@ -1034,7 +1035,16 @@ func (e *Engine) undoFile(f UndoFile, j *journal.Writer, run string) (FileResult
return FileResult{}, err
}
}
- return FileResult{Steps: steps}, nil
+ return FileResult{File: undoFileIdentity(f), Steps: steps}, nil
+}
+
+// undoFileIdentity is all an undo result can honestly say about the file it
+// reversed: the log records its path relative to the directory root and
+// nothing else - the file's size, mode and times belong to a state that no
+// longer exists. A front end showing a row per file matches on Rel (GUI
+// design §4); it must not read the rest.
+func undoFileIdentity(f UndoFile) scan.File {
+ return scan.File{Rel: f.File, Name: path.Base(f.File)}
}
// runUndoStep actually carries out one reversal. It reuses apply.StepResult
diff --git a/internal/engine/apply_test.go b/internal/engine/apply_test.go
index 104fb73..142e4d2 100644
--- a/internal/engine/apply_test.go
+++ b/internal/engine/apply_test.go
@@ -5,6 +5,7 @@ package engine
import (
"context"
"os"
+ "path"
"path/filepath"
"strings"
"testing"
@@ -461,6 +462,48 @@ func TestApplyUndoRestoresMovedFile(t *testing.T) {
}
}
+// TestApplyUndoNamesItsFiles: every file in an undo result says which file
+// it is, reversed or declined, so a front end showing a row per file can
+// put each outcome on the right row (GUI design §4). The log is the only
+// source of that name, so Rel and Name are all an undo result can carry.
+func TestApplyUndoNamesItsFiles(t *testing.T) {
+ h, e, dp, j, run := applyFixture(t)
+ if _, err := e.Apply(context.Background(), dp, map[string]bool{"a.pdf": true}, j, run); err != nil {
+ t.Fatal(err)
+ }
+ j.Close()
+
+ j2, err := journal.Open(filepath.Join(h, ".local", "state", "krino", "krino.log"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer j2.Close()
+
+ // Declined first - nothing is reversed, so the run is still undoable -
+ // then reversed for real. Both paths must name the file.
+ for _, declined := range []bool{true, false} {
+ up, err := e.PlanUndo(run)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(up.Files) != 1 || up.Files[0].Refused != "" {
+ t.Fatalf("undo plan = %+v", up.Files)
+ }
+ up.Files[0].Declined = declined
+ res, err := e.ApplyUndo(context.Background(), up, j2, journal.NewRunID(time.Now()))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(res.Files) != 1 {
+ t.Fatalf("declined=%v: result files = %+v, want one", declined, res.Files)
+ }
+ fr := res.Files[0]
+ if fr.File.Rel != "a.pdf" || fr.File.Name != path.Base("a.pdf") {
+ t.Errorf("declined=%v: file = %+v, want a.pdf", declined, fr.File)
+ }
+ }
+}
+
// TestApplyUndoSkipsRefusedFiles: rule 4 enforced at execution time too - a
// refused file must come back from ApplyUndo untouched.
func TestApplyUndoSkipsRefusedFiles(t *testing.T) {