diff options
Diffstat (limited to 'gui/internal/ui/history.go')
| -rw-r--r-- | gui/internal/ui/history.go | 426 |
1 files changed, 426 insertions, 0 deletions
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) + } +} |
