From 6ef83d6bdfb6120f9e1fbd145e0bc463196103d1 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Wed, 16 Sep 2026 09:39:59 +0200 Subject: gui: History and undo tab; each plan and undo is its own run --- gui/internal/ui/history.go | 426 +++++++++++++++++++++++++++++++++++++++++++++ gui/internal/ui/plan.go | 10 +- gui/internal/ui/window.go | 40 ++++- 3 files changed, 460 insertions(+), 16 deletions(-) create mode 100644 gui/internal/ui/history.go (limited to 'gui/internal/ui') 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) + } +} diff --git a/gui/internal/ui/plan.go b/gui/internal/ui/plan.go index 8cbbdfd..fd2f563 100644 --- a/gui/internal/ui/plan.go +++ b/gui/internal/ui/plan.go @@ -263,7 +263,7 @@ func (p *planView) onScan() { var tab *model.PlanTab p.cancelOp = runInBackground(func(ctx context.Context) error { var err error - tab, err = model.Plan(ctx, p.w.sess, d) + tab, err = model.Plan(ctx, p.w.engine, d) return err }, func(err error) { p.setBusy(false) @@ -342,13 +342,7 @@ func (p *planView) selectAll(on bool) { // fillList renders the rows: a checkbox, the file, what would happen, the // rule, and the outcome once applied. func (p *planView) fillList() { - for { - row := p.list.RowAtIndex(0) - if row == nil { - break - } - p.list.Remove(row) - } + clearList(p.list) if p.tab == nil { return } diff --git a/gui/internal/ui/window.go b/gui/internal/ui/window.go index e35a363..c42a7e8 100644 --- a/gui/internal/ui/window.go +++ b/gui/internal/ui/window.go @@ -24,16 +24,17 @@ type Window struct { win *gtk.ApplicationWindow engine *engine.Engine - sess *engine.Session - plan *planView - status *gtk.Label + plan *planView + history *historyView + status *gtk.Label } -// NewWindow builds the window for e; the session is the run everything in -// it goes through. -func NewWindow(app *gtk.Application, e *engine.Engine, sess *engine.Session) *Window { - w := &Window{app: app, engine: e, sess: sess} +// NewWindow builds the window for e. Each plan and each undo is its own +// run of krino, with its own run id in the log, so the window itself holds +// no session. +func NewWindow(app *gtk.Application, e *engine.Engine) *Window { + w := &Window{app: app, engine: e} w.win = gtk.NewApplicationWindow(app) w.win.SetTitle("krino") w.win.SetDefaultSize(1000, 640) @@ -41,8 +42,18 @@ func NewWindow(app *gtk.Application, e *engine.Engine, sess *engine.Session) *Wi notebook := gtk.NewNotebook() w.plan = newPlanView(w) notebook.AppendPage(w.plan.root, gtk.NewLabel("Plan")) - notebook.AppendPage(placeholder("History and undo arrives with the next milestone."), gtk.NewLabel("History & undo")) + w.history = newHistoryView(w) + notebook.AppendPage(w.history.root, gtk.NewLabel("History & undo")) notebook.AppendPage(placeholder("The rules editor arrives with a later milestone."), gtk.NewLabel("Rules")) + // The log is read when the tab is first opened, not at start-up: a + // window that only sorts never reads it. + loaded := false + notebook.ConnectSwitchPage(func(_ gtk.Widgetter, page uint) { + if page == 1 && !loaded { + loaded = true + w.history.loadRuns() + } + }) w.status = gtk.NewLabel("") w.status.SetXAlign(0) @@ -61,6 +72,7 @@ func NewWindow(app *gtk.Application, e *engine.Engine, sess *engine.Session) *Wi // holds, rather than leaving a lock file for the next run to find. w.win.ConnectCloseRequest(func() bool { w.plan.closeTab() + w.history.closeTab() return false }) return w @@ -69,6 +81,18 @@ func NewWindow(app *gtk.Application, e *engine.Engine, sess *engine.Session) *Wi // Show puts the window on screen. func (w *Window) Show() { w.win.Show() } +// dirRoot is the path of the configured directory called name, or "" if +// the configuration no longer has one - a log entry can outlive its +// directory. +func (w *Window) dirRoot(name string) string { + for _, d := range w.engine.Dirs { + if d.Name == name { + return d.Root + } + } + return "" +} + // setStatus writes the line at the bottom of the window. func (w *Window) setStatus(format string, args ...any) { w.status.SetText(fmt.Sprintf(format, args...)) -- cgit v1.3