From bcfeb773afcc28641ef2fb47749c91fd0767054a Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Wed, 16 Sep 2026 09:17:08 +0200 Subject: gui: the Plan tab - scan, review, apply, with the directory locked --- gui/internal/ui/plan.go | 351 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 351 insertions(+) create mode 100644 gui/internal/ui/plan.go (limited to 'gui/internal/ui/plan.go') diff --git a/gui/internal/ui/plan.go b/gui/internal/ui/plan.go new file mode 100644 index 0000000..0fa429a --- /dev/null +++ b/gui/internal/ui/plan.go @@ -0,0 +1,351 @@ +// 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" + "krino/internal/plan" + "krino/internal/xdg" +) + +// planView is the Plan tab: a directory picker and Scan, the plan as a list +// with a checkbox per file, the selected file's explanation beside it, and +// Apply (GUI design ยง3). +type planView struct { + w *Window + root *gtk.Box + + dirs *gtk.DropDown + path *gtk.Label + scan *gtk.Button + apply *gtk.Button + cancel *gtk.Button + selAll *gtk.Button + selNone *gtk.Button + + list *gtk.ListBox + details *gtk.TextView + + tab *model.PlanTab + cancelOp context.CancelFunc +} + +func newPlanView(w *Window) *planView { + p := &planView{w: w} + p.root = gtk.NewBox(gtk.OrientationVertical, 0) + + // The picker holds names only: a long path in it would widen the + // window past the screen, so the path goes in a label that ellipsizes. + names := make([]string, len(w.engine.Dirs)) + for i, d := range w.engine.Dirs { + names[i] = d.Name + } + if len(names) == 0 { + names = []string{"none"} + } + p.dirs = gtk.NewDropDownFromStrings(names) + p.path = gtk.NewLabel("") + p.path.SetXAlign(0) + p.path.SetHExpand(true) + p.path.SetEllipsize(pango.EllipsizeMiddle) + p.path.SetMaxWidthChars(20) + p.dirs.Connect("notify::selected", p.showPath) + p.scan = gtk.NewButtonWithLabel("Scan") + p.selAll = gtk.NewButtonWithLabel("Select all") + p.selNone = gtk.NewButtonWithLabel("None") + p.apply = gtk.NewButtonWithLabel("Apply") + p.apply.AddCSSClass("suggested-action") + p.cancel = gtk.NewButtonWithLabel("Cancel") + p.cancel.SetSensitive(false) + + bar := gtk.NewBox(gtk.OrientationHorizontal, 6) + bar.SetMarginTop(6) + bar.SetMarginStart(6) + bar.SetMarginEnd(6) + bar.SetMarginBottom(6) + bar.Append(gtk.NewLabel("Directory")) + bar.Append(p.dirs) + bar.Append(p.scan) + bar.Append(p.path) + bar.Append(p.selAll) + bar.Append(p.selNone) + bar.Append(p.cancel) + bar.Append(p.apply) + + p.list = gtk.NewListBox() + p.list.SetSelectionMode(gtk.SelectionSingle) + listScroll := gtk.NewScrolledWindow() + listScroll.SetChild(p.list) + listScroll.SetHExpand(true) + listScroll.SetVExpand(true) + + p.details = gtk.NewTextView() + p.details.Buffer().SetText("Select a file to see what would happen to it, and why.") + p.details.SetEditable(false) + p.details.SetMonospace(true) + p.details.SetWrapMode(gtk.WrapWord) + p.details.SetLeftMargin(8) + p.details.SetRightMargin(8) + p.details.SetTopMargin(6) + p.details.SetBottomMargin(6) + detailScroll := gtk.NewScrolledWindow() + detailScroll.SetChild(p.details) + detailScroll.SetSizeRequest(320, -1) + + // A pane the user can drag: on a narrow window the list needs the room, + // on a wide one the explanation does. + panes := gtk.NewPaned(gtk.OrientationHorizontal) + panes.SetStartChild(listScroll) + panes.SetEndChild(detailScroll) + panes.SetResizeStartChild(true) + panes.SetResizeEndChild(false) + panes.SetShrinkEndChild(false) + panes.SetPosition(640) + panes.SetVExpand(true) + + p.root.Append(bar) + p.root.Append(gtk.NewSeparator(gtk.OrientationHorizontal)) + p.root.Append(panes) + + p.scan.ConnectClicked(p.onScan) + p.apply.ConnectClicked(p.onApply) + p.cancel.ConnectClicked(func() { + if p.cancelOp != nil { + p.cancelOp() + } + }) + p.selAll.ConnectClicked(func() { p.selectAll(true) }) + p.selNone.ConnectClicked(func() { p.selectAll(false) }) + p.list.ConnectRowSelected(func(row *gtk.ListBoxRow) { + if row != nil { + p.showDetails(row.Index()) + } + }) + p.showPath() + p.setBusy(false) + return p +} + +// showPath writes the chosen directory's path beside the picker. +func (p *planView) showPath() { + if d := p.currentDir(); d != nil { + p.path.SetText(escape(xdg.Abbrev(d.Root))) + return + } + p.path.SetText("no directory is included; add one with: krino new NAME PATH") +} + +// currentDir is the directory the picker names. +func (p *planView) currentDir() *engine.Dir { + i := int(p.dirs.Selected()) + if i < 0 || i >= len(p.w.engine.Dirs) { + return nil + } + return p.w.engine.Dirs[i] +} + +// onScan plans the chosen directory, off the main loop. +func (p *planView) onScan() { + d := p.currentDir() + if d == nil { + p.w.setStatus("no directory is included; add one with: krino new NAME PATH") + return + } + // One plan at a time: the open one holds its directory's lock, and + // scanning again - the same directory or another - replaces it. + p.closeTab() + p.setBusy(true) + p.w.setStatus("scanning %s...", escape(d.Name)) + var tab *model.PlanTab + p.cancelOp = runInBackground(func(ctx context.Context) error { + var err error + tab, err = model.Plan(ctx, p.w.sess, d) + return err + }, func(err error) { + p.setBusy(false) + if err != nil { + p.w.setStatus("%s: %v", escape(d.Name), err) + return + } + p.tab = tab + p.fillList() + c := tab.Counts + p.w.setStatus("%d scanned, %d to act on, %d excluded, %d skipped, %d with warnings", + c.Scanned, c.Acting, c.Excluded, c.Skipped, c.Warned) + }) +} + +// onApply acts on the checked files, off the main loop. +func (p *planView) onApply() { + if p.tab == nil { + return + } + n := p.tab.SelectedCount() + p.setBusy(true) + p.w.setStatus("applying %d file(s)...", n) + var res *engine.ApplyResult + p.cancelOp = runInBackground(func(ctx context.Context) error { + var err error + res, err = p.tab.Apply(ctx) + return err + }, func(err error) { + p.setBusy(false) + p.apply.SetSensitive(false) + p.fillList() + if err != nil { + p.w.setStatus("apply: %v", err) + return + } + p.w.setStatus("%d applied, %d failed, %d declined", res.Applied, res.Failed, res.Declined) + }) +} + +// closeTab drops the open plan and releases the directory's lock. +func (p *planView) closeTab() { + if p.tab == nil { + return + } + if err := p.tab.Close(); err != nil { + p.w.setStatus("%s: %v", escape(p.tab.Dir.Name), err) + } + p.tab = nil + p.fillList() +} + +// setBusy turns the buttons on or off around a background operation. +func (p *planView) setBusy(busy bool) { + p.scan.SetSensitive(!busy) + p.dirs.SetSensitive(!busy) + p.selAll.SetSensitive(!busy) + p.selNone.SetSensitive(!busy) + p.apply.SetSensitive(!busy && p.tab != nil && !p.tab.Applied) + p.cancel.SetSensitive(busy) +} + +// selectAll checks or unchecks every file that can be applied. +func (p *planView) selectAll(on bool) { + if p.tab == nil { + return + } + if on { + p.tab.SelectAll() + } else { + p.tab.SelectNone() + } + p.fillList() +} + +// 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) + } + if p.tab == nil { + return + } + for i, r := range p.tab.Rows { + p.list.Append(p.rowWidget(i, r)) + } + p.apply.SetLabel(fmt.Sprintf("Apply %d selected", p.tab.SelectedCount())) + p.apply.SetSensitive(!p.tab.Applied && p.tab.SelectedCount() > 0) +} + +// rowWidget is one line of the list. +func (p *planView) rowWidget(i int, r model.Row) *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 && (p.tab == nil || !p.tab.Applied)) + check.ConnectToggled(func() { + if p.tab != nil && p.tab.Rows[i].Selected != check.Active() { + p.tab.Toggle(i) + p.apply.SetLabel(fmt.Sprintf("Apply %d selected", p.tab.SelectedCount())) + p.apply.SetSensitive(!p.tab.Applied && p.tab.SelectedCount() > 0) + } + }) + box.Append(check) + + box.Append(column(escape(r.Rel), 24, true)) + box.Append(column(escape(rowLabel(r, p.dirRoot())), 30, false)) + box.Append(column(escape(r.Rule), 12, false)) + if r.Outcome != "" { + box.Append(column(escape(r.Outcome), 12, false)) + } + row := gtk.NewListBoxRow() + row.SetChild(box) + return row +} + +// dirRoot is the directory the list belongs to, for shortening destinations. +func (p *planView) dirRoot() string { + if p.tab != nil { + return p.tab.Dir.Root + } + return "" +} + +// column is one cell of a row: left-aligned, and ellipsized to chars so +// that a long name or warning cannot widen the window past the screen. The +// whole text is in the details pane beside the list. +func column(text string, chars int, expand bool) *gtk.Label { + l := gtk.NewLabel(text) + l.SetXAlign(0) + l.SetEllipsize(pango.EllipsizeEnd) + l.SetWidthChars(chars) + l.SetMaxWidthChars(chars) + l.SetHExpand(expand) + l.SetTooltipText(text) + return l +} + +// showDetails writes the selected file's steps and warnings into the pane. +func (p *planView) showDetails(i int) { + if p.tab == nil || i < 0 || i >= len(p.tab.Rows) { + return + } + r := p.tab.Rows[i] + var b strings.Builder + b.WriteString(escape(r.Rel) + "\n\n") + for _, s := range r.Steps { + switch { + case s.Skip != "": + fmt.Fprintf(&b, "%s skipped: %s\n", s.Kind, escape(s.Skip)) + case s.Kind == plan.Trash || s.Kind == plan.DeletePermanent: + fmt.Fprintf(&b, "%s\n", s.Kind) + default: + fmt.Fprintf(&b, "%s -> %s\n", s.Kind, escape(shorten(s.Dst, p.dirRoot()))) + } + if s.Rule != "" { + fmt.Fprintf(&b, " rule %s\n", escape(s.Rule)) + } + if s.Reason != "" { + fmt.Fprintf(&b, " because %s\n", escape(s.Reason)) + } + } + for _, warn := range r.Warnings { + b.WriteString("\n" + escape(warn) + "\n") + } + if r.Outcome != "" { + b.WriteString("\n" + escape(r.Outcome) + "\n") + } + p.details.Buffer().SetText(b.String()) +} -- cgit v1.3