aboutsummaryrefslogtreecommitdiff
path: root/gui/internal/ui
diff options
context:
space:
mode:
Diffstat (limited to 'gui/internal/ui')
-rw-r--r--gui/internal/ui/history.go426
-rw-r--r--gui/internal/ui/plan.go10
-rw-r--r--gui/internal/ui/window.go40
3 files changed, 460 insertions, 16 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)
+ }
+}
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...))