diff options
Diffstat (limited to 'gui/internal/model/history.go')
| -rw-r--r-- | gui/internal/model/history.go | 280 |
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 +} |
