diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-16 09:39:59 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-16 09:39:59 +0200 |
| commit | 6ef83d6bdfb6120f9e1fbd145e0bc463196103d1 (patch) | |
| tree | 8eee1f2c29dcbef4ed96b23b08273d42ba0b5d49 /gui/internal | |
| parent | 27eb6353030953e91a45b0104bd0567e53622090 (diff) | |
| download | krino-6ef83d6bdfb6120f9e1fbd145e0bc463196103d1.tar.gz krino-6ef83d6bdfb6120f9e1fbd145e0bc463196103d1.zip | |
gui: History and undo tab; each plan and undo is its own run
Diffstat (limited to 'gui/internal')
| -rw-r--r-- | gui/internal/model/history.go | 280 | ||||
| -rw-r--r-- | gui/internal/model/history_test.go | 207 | ||||
| -rw-r--r-- | gui/internal/model/plan.go | 41 | ||||
| -rw-r--r-- | gui/internal/model/plan_test.go | 35 | ||||
| -rw-r--r-- | gui/internal/ui/history.go | 426 | ||||
| -rw-r--r-- | gui/internal/ui/plan.go | 10 | ||||
| -rw-r--r-- | gui/internal/ui/window.go | 40 |
7 files changed, 985 insertions, 54 deletions
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...)) |
