// SPDX-License-Identifier: GPL-3.0-or-later package ui import ( "context" "errors" "fmt" "strings" "github.com/diamondburned/gotk4/pkg/pango" "github.com/diamondburned/gotk4/pkg/gtk/v4" "git.labunix.xyz/krino/gui/internal/model" "git.labunix.xyz/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 // applying is set while a reversal is in flight; the window refuses // anything that would take its lock or its log away. applying bool 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)) } w := h.widths() for i, r := range h.tab.Rows { h.list.Append(h.undoRowWidget(i, r, w)) } 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) } // widths sizes the undo columns to what this plan holds, as the plan tab's // do, so a file or a reversal is not cut short for want of a few characters. func (h *historyView) widths() [3]int { w := [3]int{16, 16, 6} for _, r := range h.tab.Rows { w[0] = max(w[0], len([]rune(r.Dir+"/"+r.File))) w[1] = max(w[1], len([]rune(undoWhat(r, h.w.dirRoot(r.Dir))))) w[2] = max(w[2], len([]rune(r.Outcome))) } for i, cap := range [3]int{40, 52, 24} { w[i] = min(w[i], cap) } return w } // undoRowWidget is one file of the reversal. func (h *historyView) undoRowWidget(i int, r model.UndoRow, w [3]int) *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), w[0], 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), w[2], false)) } what := columnMin(escape(undoWhat(r, h.w.dirRoot(r.Dir))), w[1], 18, true) 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.applying = true h.w.setApplying(true) 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.applying = false h.w.setApplying(false) 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. // busyApplying reports whether an undo is in flight. func (h *historyView) busyApplying() bool { return h.applying } 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() bool { if h.tab == nil { return true } if err := h.tab.Close(); err != nil { h.w.setStatus("undo: %v", err) if errors.Is(err, model.ErrApplying) { return false } } h.tab = nil h.clearPlan() return true } // clearList removes every row of a ListBox. func clearList(list *gtk.ListBox) { for { row := list.RowAtIndex(0) if row == nil { return } list.Remove(row) } }