aboutsummaryrefslogtreecommitdiff
path: root/gui/internal/ui
diff options
context:
space:
mode:
Diffstat (limited to 'gui/internal/ui')
-rw-r--r--gui/internal/ui/forms.go27
-rw-r--r--gui/internal/ui/plan.go250
-rw-r--r--gui/internal/ui/rules.go11
-rw-r--r--gui/internal/ui/settings.go20
-rw-r--r--gui/internal/ui/window.go105
5 files changed, 357 insertions, 56 deletions
diff --git a/gui/internal/ui/forms.go b/gui/internal/ui/forms.go
index 6fcf973..8e1fd33 100644
--- a/gui/internal/ui/forms.go
+++ b/gui/internal/ui/forms.go
@@ -298,6 +298,23 @@ var settingChoices = map[string][]string{
"on-conflict": {"", "suffix", "skip", "overwrite"},
}
+// settingHelp is what each setting does, shown when the pointer rests on
+// its row (his request, 2026-09-17). The wording follows krino.conf(5).
+var settingHelp = map[string]string{
+ "path": "the directory krino sorts; a directory's file must set it",
+ "recursive": "look in subdirectories too, not only the directory itself",
+ "max-depth": "how deep to look when recursive: 1 is the directory itself",
+ "min-age": "leave a file alone until it has been untouched this long - protection against sorting a download still being written",
+ "max-read": "the largest file whose text is read for (content ...) tests; 0 reads every size",
+ "max-size": "skip a file larger than this entirely",
+ "busy": "endings that mean a file is still being written, such as \".part\"; those files are left alone",
+ "case": "whether name and path patterns tell capitals apart: ignore (the default) or strict",
+ "fold": "whether a pattern without accents matches a name with them, so \"zazolc\" finds \"zażółć\"",
+ "on-conflict": "what to do when the destination is taken: suffix (name-1), skip, or overwrite",
+ "ignore": "gitignore patterns for files krino never looks at, such as \"*.part\" and \".*\"",
+ "log": "where every run is written; krino undo reads it",
+}
+
// settingHints is the example shown in an empty field.
var settingHints = map[string]string{
"path": `"~/downloads"`,
@@ -315,6 +332,9 @@ func newSettingRow(head, args string, changed func()) *settingRow {
label := gtk.NewLabel(head)
label.SetXAlign(0)
label.SetSizeRequest(110, -1)
+ help := settingHelp[head]
+ label.SetTooltipText(help)
+ r.root.SetTooltipText(help)
r.root.Append(label)
if items, ok := settingChoices[head]; ok {
r.items = items
@@ -326,6 +346,7 @@ func newSettingRow(head, args string, changed func()) *settingRow {
if indexOf(items, args) < 0 {
r.drop.SetSelected(0)
}
+ r.drop.SetTooltipText(help)
r.drop.Connect("notify::selected", func() { changed() })
r.root.Append(r.drop)
return r
@@ -334,7 +355,11 @@ func newSettingRow(head, args string, changed func()) *settingRow {
r.entry.SetText(args)
r.entry.SetHExpand(true)
r.entry.SetPlaceholderText(mainHint(head))
- r.entry.SetTooltipText(mainHint(head))
+ if help != "" {
+ r.entry.SetTooltipText(help + "\n\nfor example: " + mainHint(head))
+ } else {
+ r.entry.SetTooltipText(mainHint(head))
+ }
r.entry.ConnectChanged(func() { changed() })
r.root.Append(r.entry)
return r
diff --git a/gui/internal/ui/plan.go b/gui/internal/ui/plan.go
index 87afdff..740341c 100644
--- a/gui/internal/ui/plan.go
+++ b/gui/internal/ui/plan.go
@@ -34,9 +34,13 @@ type planView struct {
cancel *gtk.Button
selAll *gtk.Button
selNone *gtk.Button
+ checked *gtk.MenuButton
- list *gtk.ListBox
- details *gtk.TextView
+ filter *gtk.SearchEntry
+ shown []int
+ list *gtk.ListBox
+ headerBox *gtk.Box
+ details *gtk.TextView
previewNote *gtk.Label
picture *gtk.Picture
@@ -93,8 +97,18 @@ func newPlanView(w *Window) *planView {
bar.Append(p.dirs)
bar.Append(p.scan)
bar.Append(p.path)
+
+ // A filter over the plan: type a few letters of a name, as fzf does,
+ // and act on what is left (his request, 2026-09-17).
+ p.filter = gtk.NewSearchEntry()
+ p.filter.SetPlaceholderText("filter")
+ p.filter.SetTooltipText("show only the files whose name or rule has these letters, in order; Select all then checks those")
+ p.filter.SetSizeRequest(160, -1)
+ bar.Append(p.filter)
+
bar.Append(p.selAll)
bar.Append(p.selNone)
+ bar.Append(p.checkedMenu())
bar.Append(p.cancel)
bar.Append(p.apply)
@@ -104,6 +118,13 @@ func newPlanView(w *Window) *planView {
listScroll.SetChild(p.list)
listScroll.SetHExpand(true)
listScroll.SetVExpand(true)
+ p.headerBox = gtk.NewBox(gtk.OrientationHorizontal, 8)
+ p.headerBox.SetMarginTop(4)
+ p.headerBox.SetMarginBottom(4)
+ listSide := gtk.NewBox(gtk.OrientationVertical, 0)
+ listSide.Append(p.headerBox)
+ listSide.Append(gtk.NewSeparator(gtk.OrientationHorizontal))
+ listSide.Append(listScroll)
p.details = gtk.NewTextView()
p.details.Buffer().SetText("Select a file to see what would happen to it, and why.")
@@ -172,7 +193,7 @@ func newPlanView(w *Window) *planView {
// 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.SetStartChild(listSide)
panes.SetEndChild(detailBox)
panes.SetResizeStartChild(true)
panes.SetResizeEndChild(false)
@@ -191,11 +212,12 @@ func newPlanView(w *Window) *planView {
p.cancelOp()
}
})
+ p.filter.ConnectSearchChanged(func() { p.fillList() })
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.showDetails(p.planIndex(row.Index()))
}
})
// The right button on a row offers the two overrides the terminal
@@ -211,6 +233,83 @@ func newPlanView(w *Window) *planView {
return p
}
+// checkedMenu is "With checked": the same two overrides the row menu has,
+// for every file that is checked at once (his request, 2026-09-17).
+func (p *planView) checkedMenu() *gtk.MenuButton {
+ box := gtk.NewBox(gtk.OrientationVertical, 0)
+ trash := gtk.NewButtonWithLabel("Trash them instead")
+ perm := gtk.NewButtonWithLabel("Delete them permanently instead...")
+ for _, b := range []*gtk.Button{trash, perm} {
+ b.SetHasFrame(false)
+ b.SetHAlign(gtk.AlignFill)
+ box.Append(b)
+ }
+ pop := gtk.NewPopover()
+ pop.SetChild(box)
+ button := gtk.NewMenuButton()
+ button.SetLabel("With checked")
+ button.SetPopover(pop)
+ button.SetTooltipText("choose what happens to every checked file instead of what the rules decided")
+ trash.ConnectClicked(func() {
+ pop.Popdown()
+ p.replaceChecked(plan.Trash)
+ })
+ perm.ConnectClicked(func() {
+ pop.Popdown()
+ p.confirmDeleteChecked()
+ })
+ p.checked = button
+ return button
+}
+
+// replaceChecked gives every checked file the same action.
+func (p *planView) replaceChecked(kind plan.Kind) {
+ if p.tab == nil {
+ return
+ }
+ n, err := p.tab.ReplaceSelected(kind)
+ if err != nil {
+ p.w.setStatus("%v", err)
+ return
+ }
+ if n == 0 {
+ p.w.setStatus("no file is checked")
+ return
+ }
+ p.fillList()
+ p.w.setStatus("%d file(s) set to %s - press Apply to carry it out; nothing has moved yet", n, kind)
+}
+
+// confirmDeleteChecked asks before the one action nothing can undo, saying
+// how many files it would be.
+func (p *planView) confirmDeleteChecked() {
+ if p.tab == nil {
+ return
+ }
+ n := p.tab.SelectedCount()
+ if n == 0 {
+ p.w.setStatus("no file is checked")
+ return
+ }
+ d := gtk.NewMessageDialog(&p.w.win.Window, gtk.DialogModal|gtk.DialogDestroyWithParent,
+ gtk.MessageWarning, gtk.ButtonsNone)
+ d.SetObjectProperty("text", fmt.Sprintf("Delete %d checked file(s) permanently?", n))
+ d.SetObjectProperty("secondary-text",
+ "They are not moved to the Trash and undo cannot bring them back. Nothing happens until you press Apply.")
+ d.AddButton("Cancel", int(gtk.ResponseCancel))
+ del := d.AddButton("Delete permanently", int(gtk.ResponseAccept))
+ if b, ok := del.(*gtk.Button); ok {
+ b.AddCSSClass("destructive-action")
+ }
+ d.ConnectResponse(func(response int) {
+ d.Destroy()
+ if response == int(gtk.ResponseAccept) {
+ p.replaceChecked(plan.DeletePermanent)
+ }
+ })
+ d.Show()
+}
+
// newMenu builds the row menu: what to do with a file instead of what the
// rules decided.
func (p *planView) newMenu() *gtk.Popover {
@@ -237,6 +336,15 @@ func (p *planView) newMenu() *gtk.Popover {
return pop
}
+// planIndex is the plan row a list row stands for: with a filter on, the
+// two are not the same.
+func (p *planView) planIndex(listRow int) int {
+ if listRow < 0 || listRow >= len(p.shown) {
+ return -1
+ }
+ return p.shown[listRow]
+}
+
// onRightClick opens the menu on the row under the pointer. A plan that has
// been applied is history and cannot be changed.
func (p *planView) onRightClick(x, y float64) {
@@ -248,7 +356,10 @@ func (p *planView) onRightClick(x, y float64) {
return
}
p.list.SelectRow(row)
- p.menuRow = row.Index()
+ p.menuRow = p.planIndex(row.Index())
+ if p.menuRow < 0 {
+ return
+ }
at := gdk.NewRectangle(int(x), int(y), 1, 1)
p.menu.SetPointingTo(&at)
p.menu.Popup()
@@ -266,7 +377,8 @@ func (p *planView) replace(kind plan.Kind) {
rel := p.tab.Rows[p.menuRow].Rel
p.fillList()
p.showDetails(p.menuRow)
- p.w.setStatus("%s: %s instead", escape(rel), kind)
+ p.w.setStatus("%s: %s instead - press Apply to carry it out; nothing has moved yet",
+ escape(rel), kind)
}
// confirmDeletePermanent asks before a step nothing can undo, naming the
@@ -390,23 +502,47 @@ func (p *planView) setBusy(busy bool) {
p.dirs.SetSensitive(!busy)
p.selAll.SetSensitive(!busy)
p.selNone.SetSensitive(!busy)
+ if p.checked != nil {
+ p.checked.SetSensitive(!busy && p.tab != nil && !p.tab.Applied)
+ }
p.apply.SetSensitive(!busy && p.tab != nil && !p.tab.Applied)
p.cancel.SetSensitive(busy)
}
-// selectAll checks or unchecks every file that can be applied.
+// selectAll checks or unchecks every file that can be applied - and, while
+// a filter is on, only the files it leaves on screen, so what Apply acts on
+// is what was in front of him.
func (p *planView) selectAll(on bool) {
if p.tab == nil {
return
}
- if on {
- p.tab.SelectAll()
- } else {
+ switch {
+ case !on:
p.tab.SelectNone()
+ case p.filter.Text() == "":
+ p.tab.SelectAll()
+ default:
+ p.tab.SelectOnly(p.shown)
}
p.fillList()
}
+// sayWhatIsShown keeps the status line honest about a filter: how much of
+// the plan is on screen, and whether it is hiding something that is
+// checked and would be applied.
+func (p *planView) sayWhatIsShown() {
+ if p.tab == nil || p.filter.Text() == "" {
+ return
+ }
+ hidden := p.tab.HiddenSelected(p.shown)
+ if hidden > 0 {
+ p.w.setStatus("%d of %d files shown; %d checked file(s) are hidden and would still be applied",
+ len(p.shown), len(p.tab.Rows), hidden)
+ return
+ }
+ p.w.setStatus("%d of %d files shown", len(p.shown), len(p.tab.Rows))
+}
+
// fillList renders the rows: a checkbox, the file, what would happen, the
// rule, and the outcome once applied.
func (p *planView) fillList() {
@@ -415,36 +551,67 @@ func (p *planView) fillList() {
return
}
w := p.widths()
- for i, r := range p.tab.Rows {
- p.list.Append(p.rowWidget(i, r, w))
+ p.header(w)
+ p.shown = p.tab.Matching(p.filter.Text())
+ for _, i := range p.shown {
+ p.list.Append(p.rowWidget(i, p.tab.Rows[i], w))
}
p.apply.SetLabel(fmt.Sprintf("Apply %d selected", p.tab.SelectedCount()))
p.apply.SetSensitive(!p.tab.Applied && p.tab.SelectedCount() > 0)
+ // The plan arrives after setBusy has already run, so the menu over the
+ // checked files is enabled here rather than there.
+ p.checked.SetSensitive(!p.tab.Applied)
+ p.sayWhatIsShown()
}
// widths is how wide each column has to be for this plan: enough for the
// longest value it holds, within limits, so a rule name or an outcome is
// shown whole rather than cut to an ellipsis. The list scrolls sideways
// when the total does not fit (his report, 2026-09-16).
-func (p *planView) widths() [4]int {
- w := [4]int{16, 16, 8, 6}
+func (p *planView) widths() [5]int {
+ w := [5]int{16, 6, 16, 8, 6}
root := p.dirRoot()
for _, r := range p.tab.Rows {
+ action, _ := rowAction(r)
w[0] = max(w[0], len([]rune(r.Rel)))
- w[1] = max(w[1], len([]rune(rowLabel(r, root))))
- w[2] = max(w[2], len([]rune(r.Rule)))
- w[3] = max(w[3], len([]rune(r.Outcome)))
+ w[1] = max(w[1], len([]rune(action)))
+ w[2] = max(w[2], len([]rune(rowWhere(r, root))))
+ w[3] = max(w[3], len([]rune(r.Rule)))
+ w[4] = max(w[4], len([]rune(r.Outcome)))
}
// Past these a single long value would push every other column off the
// window; the details pane holds the whole text either way.
- for i, cap := range [4]int{40, 52, 34, 24} {
+ for i, cap := range [5]int{40, 16, 52, 34, 24} {
w[i] = min(w[i], cap)
}
return w
}
+// header is the line above the list saying what each column is.
+func (p *planView) header(w [5]int) {
+ if child := p.headerBox.FirstChild(); child != nil {
+ for child != nil {
+ next := gtk.BaseWidget(child).NextSibling()
+ p.headerBox.Remove(child)
+ child = next
+ }
+ }
+ p.headerBox.SetMarginStart(6)
+ p.headerBox.SetMarginEnd(6)
+ // The checkbox has no title, but its width has to be accounted for.
+ spacer := gtk.NewLabel("")
+ spacer.SetSizeRequest(24, -1)
+ p.headerBox.Append(spacer)
+ for i, title := range []string{"file", "action", "where it would go", "rule", "outcome"} {
+ l := columnMin(title, w[i], yieldChars, i == 0 || i == 2)
+ l.AddCSSClass("heading")
+ l.SetTooltipText("")
+ p.headerBox.Append(l)
+ }
+}
+
// rowWidget is one line of the list.
-func (p *planView) rowWidget(i int, r model.Row, w [4]int) *gtk.ListBoxRow {
+func (p *planView) rowWidget(i int, r model.Row, w [5]int) *gtk.ListBoxRow {
box := gtk.NewBox(gtk.OrientationHorizontal, 8)
box.SetMarginStart(6)
box.SetMarginEnd(6)
@@ -463,14 +630,16 @@ func (p *planView) rowWidget(i int, r model.Row, w [4]int) *gtk.ListBoxRow {
})
box.Append(check)
- // The name and what-would-happen columns share whatever space is left
- // and shrink first; the rule keeps its width, so it is never the column
- // cut to an ellipsis.
+ // The name and destination columns share whatever space is left and
+ // shrink first; the action and the rule keep their width, so neither is
+ // ever the column cut to an ellipsis.
+ action, colour := rowAction(r)
box.Append(column(escape(r.Rel), w[0], true))
- box.Append(columnMin(escape(rowLabel(r, p.dirRoot())), w[1], 18, true))
- box.Append(column(escape(r.Rule), w[2], false))
+ box.Append(colouredColumn(action, w[1], colour))
+ box.Append(columnMin(escape(rowWhere(r, p.dirRoot())), w[2], 18, true))
+ box.Append(column(escape(r.Rule), w[3], false))
if r.Outcome != "" {
- box.Append(column(escape(r.Outcome), w[3], false))
+ box.Append(column(escape(r.Outcome), w[4], false))
}
row := gtk.NewListBoxRow()
row.SetChild(box)
@@ -513,6 +682,31 @@ func columnMin(text string, chars, floor int, expand bool) *gtk.Label {
return l
}
+// colouredColumn is a fixed column painted in a colour - the actions, so
+// that a MOVE and a DELETE do not read alike. An empty colour leaves the
+// theme's own.
+func colouredColumn(text string, chars int, colour string) *gtk.Label {
+ l := column(escape(text), chars, false)
+ if colour == "" || text == "" {
+ return l
+ }
+ r, g, b := rgb16(colour)
+ attrs := pango.NewAttrList()
+ fg := pango.NewAttrForeground(r, g, b)
+ attrs.Insert(fg)
+ attrs.Insert(pango.NewAttrWeight(pango.WeightBold))
+ l.SetAttributes(attrs)
+ return l
+}
+
+// rgb16 reads "#rrggbb" as the 16-bit channels pango wants.
+func rgb16(hex string) (r, g, b uint16) {
+ var rr, gg, bb int
+ fmt.Sscanf(hex, "#%02x%02x%02x", &rr, &gg, &bb)
+ scale := func(v int) uint16 { return uint16(v)<<8 | uint16(v) }
+ return scale(rr), scale(gg), scale(bb)
+}
+
// yieldChars is how narrow an expanding column may become.
const yieldChars = 14
@@ -654,6 +848,12 @@ func (p *planView) dropRendered() {
}
}
+// hasUnapplied reports whether a plan is open with files checked and
+// nothing done about them yet.
+func (p *planView) hasUnapplied() bool {
+ return p.tab != nil && !p.tab.Applied && p.tab.SelectedCount() > 0
+}
+
// closePreview removes what the previews left behind.
func (p *planView) closePreview() {
p.dropRendered()
diff --git a/gui/internal/ui/rules.go b/gui/internal/ui/rules.go
index 97f3dcd..3bae8df 100644
--- a/gui/internal/ui/rules.go
+++ b/gui/internal/ui/rules.go
@@ -227,21 +227,12 @@ func newRulesView(w *Window) *rulesView {
return r
}
-// applyPrefs sets the editor's font and whether the text is coloured.
+// applyPrefs sets whether the configuration is coloured.
func (r *rulesView) applyPrefs(p model.Prefs) {
r.colours.setEnabled(p.Colours)
if r.rules != nil && p.Colours {
r.colours.paint(r.rules.Text)
}
- css := gtk.NewCSSProvider()
- if p.FontSize > 0 {
- css.LoadFromData(fmt.Sprintf("textview { font-size: %dpt; }", p.FontSize))
- } else {
- css.LoadFromData("")
- }
- for _, v := range []*gtk.TextView{r.view, r.nums, r.out} {
- v.StyleContext().AddProvider(css, 800)
- }
}
// formsOf is the forms of the text in the editor, for the Forms sub-tab.
diff --git a/gui/internal/ui/settings.go b/gui/internal/ui/settings.go
index 2db3450..732caf9 100644
--- a/gui/internal/ui/settings.go
+++ b/gui/internal/ui/settings.go
@@ -68,33 +68,35 @@ func (w *Window) showSettings() {
box.Append(heading("This window"))
prefs := w.prefs
- font := gtk.NewSpinButtonWithRange(0, 32, 1)
- font.SetValue(float64(prefs.FontSize))
- font.SetTooltipText("the editor's font size in points; 0 keeps the theme's")
colours := gtk.NewCheckButtonWithLabel("colour the configuration in the Text tab")
colours.SetActive(prefs.Colours)
+ colours.SetTooltipText("paint comments, strings, form heads and actions in the Text sub-tab")
preview := gtk.NewCheckButtonWithLabel("show the file behind the selected row")
preview.SetActive(prefs.Preview)
+ preview.SetTooltipText("show the selected file under its explanation: a picture, a PDF's first page, or the first lines of text")
selectAll := gtk.NewCheckButtonWithLabel("a scanned plan starts with every file checked")
selectAll.SetActive(prefs.SelectAll)
- box.Append(field("editor font", font))
+ selectAll.SetTooltipText("a freshly scanned plan starts with every file that can be acted on checked, as the terminal review does")
+ previewHeight := gtk.NewSpinButtonWithRange(80, 2000, 20)
+ previewHeight.SetValue(float64(prefs.PreviewHeight))
+ previewHeight.SetTooltipText("how tall the preview under an explanation is, in pixels; dragging the divider above it does the same")
+ box.Append(field("preview height", previewHeight))
box.Append(colours)
box.Append(preview)
box.Append(selectAll)
apply := func() {
p := model.Prefs{
- FontSize: int(font.Value()),
- Colours: colours.Active(),
- Preview: preview.Active(),
- SelectAll: selectAll.Active(),
+ Colours: colours.Active(),
+ Preview: preview.Active(),
+ SelectAll: selectAll.Active(),
+ PreviewHeight: int(previewHeight.Value()),
}
w.applyPrefs(p)
if err := p.Save(); err != nil {
w.setStatus("settings: %v", err)
}
}
- font.ConnectValueChanged(apply)
colours.ConnectToggled(apply)
preview.ConnectToggled(apply)
selectAll.ConnectToggled(apply)
diff --git a/gui/internal/ui/window.go b/gui/internal/ui/window.go
index 819591c..784fc64 100644
--- a/gui/internal/ui/window.go
+++ b/gui/internal/ui/window.go
@@ -15,6 +15,7 @@ import (
"krino/gui/internal/model"
"krino/internal/engine"
+ "krino/internal/plan"
"krino/internal/xdg"
)
@@ -30,6 +31,7 @@ type Window struct {
rules *rulesView
status *gtk.Label
prefs model.Prefs
+ leaving bool
}
// NewWindow builds the window for e. Each plan and each undo is its own
@@ -91,6 +93,12 @@ func NewWindow(app *gtk.Application, e *engine.Engine) *Window {
// Closing the window releases whatever directory lock the open plan
// holds, rather than leaving a lock file for the next run to find.
w.win.ConnectCloseRequest(func() bool {
+ // A plan is only a plan until Apply: leaving with one open throws
+ // it away, which is worth saying out loud (his report, 2026-09-17).
+ if w.plan.hasUnapplied() && !w.leaving {
+ w.confirmLeaving()
+ return true
+ }
w.plan.closeTab()
w.plan.closePreview()
w.history.closeTab()
@@ -118,6 +126,26 @@ func (w *Window) reloadEngine() error {
return nil
}
+// confirmLeaving asks before a window with an unapplied plan closes.
+func (w *Window) confirmLeaving() {
+ n := w.plan.tab.SelectedCount()
+ d := gtk.NewMessageDialog(&w.win.Window, gtk.DialogModal|gtk.DialogDestroyWithParent,
+ gtk.MessageQuestion, gtk.ButtonsNone)
+ d.SetObjectProperty("text", "Close without applying?")
+ d.SetObjectProperty("secondary-text", fmt.Sprintf(
+ "%d file(s) are checked but nothing has been moved: a plan lives in this window until Apply, and closing throws it away.", n))
+ d.AddButton("Stay", int(gtk.ResponseCancel))
+ d.AddButton("Close without applying", int(gtk.ResponseAccept))
+ d.ConnectResponse(func(response int) {
+ d.Destroy()
+ if response == int(gtk.ResponseAccept) {
+ w.leaving = true
+ w.win.Close()
+ }
+ })
+ d.Show()
+}
+
// applyPrefs takes a change from the settings window: the font of the
// editor, whether the configuration is coloured, and whether the file
// behind a row is shown. What is already on screen changes at once.
@@ -195,27 +223,82 @@ func runInBackground(work func(context.Context) error, done func(error)) context
return cancel
}
-// rowLabel is the middle cell of a row: what would happen to the file and
-// where it would land, or - when nothing would - why not. Destinations
-// inside root are shown relative to it, as the plan's own output does.
-func rowLabel(r model.Row, root string) string {
+// actionColours are what each action is painted in, so the eye finds the
+// deletions without reading: they are the ones that cannot be undone from
+// the window (his request, 2026-09-17).
+var actionColours = map[plan.Kind]string{
+ plan.Copy: "#2a9d8f",
+ plan.Move: "#3584e4",
+ plan.Rename: "#9141ac",
+ plan.Trash: "#c06014",
+ plan.DeletePermanent: "#c01c28",
+}
+
+// actionRank decides which action gives a row its colour when a file gets
+// several: the one that matters most to the reader.
+var actionRank = map[plan.Kind]int{
+ plan.Rename: 1, plan.Copy: 2, plan.Move: 3, plan.Trash: 4, plan.DeletePermanent: 5,
+}
+
+// rowAction is a row's actions in capitals - "MOVE", "RENAME+MOVE" - and
+// the colour they are shown in. A row that would do nothing has neither.
+func rowAction(r model.Row) (text, colour string) {
if len(r.Steps) == 0 {
- return strings.Join(r.Warnings, "; ")
+ return "", ""
}
var parts []string
+ var worst plan.Kind
+ rank := -1
+ skipped := 0
+ for _, s := range r.Steps {
+ if s.Skip != "" {
+ skipped++
+ continue
+ }
+ parts = append(parts, strings.ToUpper(s.Kind.String()))
+ if actionRank[s.Kind] > rank {
+ rank, worst = actionRank[s.Kind], s.Kind
+ }
+ }
+ if len(parts) == 0 {
+ return "SKIPPED", dimColour
+ }
+ return strings.Join(parts, "+"), actionColours[worst]
+}
+
+// rowWhere is where a row's file would end up - the last place its steps
+// put it - or, when nothing would happen, why not. Destinations inside root
+// are shown relative to it, as the plan's own output does.
+func rowWhere(r model.Row, root string) string {
+ if len(r.Steps) == 0 {
+ return strings.Join(r.Warnings, "; ")
+ }
+ where := ""
+ var notes []string
for _, s := range r.Steps {
switch {
case s.Skip != "":
- parts = append(parts, s.Kind.String()+" skipped: "+s.Skip)
- case s.Dst == "":
- parts = append(parts, s.Kind.String())
- default:
- parts = append(parts, s.Kind.String()+" "+shorten(s.Dst, root))
+ notes = append(notes, strings.ToLower(s.Kind.String())+" skipped: "+s.Skip)
+ case s.Kind == plan.Trash:
+ where = "the Trash"
+ case s.Kind == plan.DeletePermanent:
+ where = "gone for good"
+ case s.Dst != "":
+ where = shorten(s.Dst, root)
}
}
- return strings.Join(parts, ", ")
+ if where == "" {
+ return strings.Join(notes, "; ")
+ }
+ if len(notes) > 0 {
+ return where + " (" + strings.Join(notes, "; ") + ")"
+ }
+ return where
}
+// dimColour is for text that is not an action: a skip, a warning.
+const dimColour = "#8b8b8b"
+
// shorten writes a destination inside root relative to it, and any other
// with ~ for the home directory.
func shorten(dst, root string) string {