diff options
Diffstat (limited to 'gui/internal/ui')
| -rw-r--r-- | gui/internal/ui/forms.go | 27 | ||||
| -rw-r--r-- | gui/internal/ui/plan.go | 250 | ||||
| -rw-r--r-- | gui/internal/ui/rules.go | 11 | ||||
| -rw-r--r-- | gui/internal/ui/settings.go | 20 | ||||
| -rw-r--r-- | gui/internal/ui/window.go | 105 |
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 { |
