aboutsummaryrefslogtreecommitdiff
path: root/gui/internal/model/history.go
diff options
context:
space:
mode:
Diffstat (limited to 'gui/internal/model/history.go')
-rw-r--r--gui/internal/model/history.go280
1 files changed, 280 insertions, 0 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
+}