// SPDX-License-Identifier: GPL-3.0-or-later package model import ( "context" "fmt" "sort" "strings" "sync/atomic" "time" "git.labunix.xyz/krino/internal/engine" "git.labunix.xyz/krino/internal/journal" "git.labunix.xyz/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 // applying is set while the reversal is in flight; Close refuses then, // for the reason PlanTab.applying gives. Atomic for the same reason. applying atomic.Bool } // 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) { t.applying.Store(true) defer t.applying.Store(false) 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 { if t.applying.Load() { return ErrApplying } 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 }