aboutsummaryrefslogtreecommitdiff
path: root/gui
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-09-17 10:15:45 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-09-17 10:15:45 +0200
commita2cb20851499e9c00bd3bf642680a9ce3148cae8 (patch)
tree09ef80b9662308b758a88ed2f43078c92e525f29 /gui
parent47b6776c2cb9b017ad95acb5aae8e34b8776fc7c (diff)
downloadkrino-a2cb20851499e9c00bd3bf642680a9ce3148cae8.tar.gz
krino-a2cb20851499e9c00bd3bf642680a9ce3148cae8.zip
gui: name the other copy of a duplicate, and offer to keep this one instead
Diffstat (limited to 'gui')
-rw-r--r--gui/internal/model/plan.go65
-rw-r--r--gui/internal/model/plan_test.go55
-rw-r--r--gui/internal/ui/plan.go58
3 files changed, 164 insertions, 14 deletions
diff --git a/gui/internal/model/plan.go b/gui/internal/model/plan.go
index 51a86d8..7c24991 100644
--- a/gui/internal/model/plan.go
+++ b/gui/internal/model/plan.go
@@ -20,12 +20,16 @@ import (
// Row is one line of the Plan tab: a file krino would act on, or one it
// could not decide about.
type Row struct {
- Rel string // the file, relative to the directory's root
- Size int64
- ModTime time.Time
- Steps []plan.Step
- Rule string // the rule that matched first, for the Rule column
- Warnings []string
+ Rel string // the file, relative to the directory's root
+ Path string // absolute, for acting on the file itself
+ Size int64
+ ModTime time.Time
+ // DuplicateOf is the copy a (duplicate) test matched this file
+ // against, absolute; "" when none did.
+ DuplicateOf string
+ Steps []plan.Step
+ Rule string // the rule that matched first, for the Rule column
+ Warnings []string
// Selected is the checkbox. A row that cannot be applied - every step
// skipped, or nothing but warnings - is never selected and has no box.
Selected bool
@@ -109,9 +113,13 @@ func (t *PlanTab) fill() {
t.Warnings = append([]string(nil), r.Warnings...)
warnings := map[string][]string{}
files := map[string]scan.File{}
+ dupes := map[string]string{}
for _, fms := range [][]engine.FileMatch{r.Matched, r.Unmatched} {
for _, fm := range fms {
files[fm.File.Rel] = fm.File
+ if fm.DuplicateOf != "" {
+ dupes[fm.File.Rel] = fm.DuplicateOf
+ }
if len(fm.Warnings) > 0 {
warnings[fm.File.Rel] = fm.Warnings
}
@@ -125,7 +133,8 @@ func (t *PlanTab) fill() {
}
t.Rows = nil
for _, c := range t.dp.Chains {
- row := Row{Rel: c.File.Rel, Size: c.File.Size, ModTime: c.File.ModTime,
+ row := Row{Rel: c.File.Rel, Path: c.File.Path, Size: c.File.Size,
+ ModTime: c.File.ModTime, DuplicateOf: dupes[c.File.Rel],
Steps: c.Steps, Warnings: warnings[c.File.Rel]}
for _, s := range c.Steps {
if s.Skip == "" {
@@ -150,8 +159,8 @@ func (t *PlanTab) fill() {
sort.Strings(rest)
for _, rel := range rest {
f := files[rel]
- t.Rows = append(t.Rows, Row{Rel: rel, Size: f.Size, ModTime: f.ModTime,
- Warnings: warnings[rel]})
+ t.Rows = append(t.Rows, Row{Rel: rel, Path: f.Path, Size: f.Size,
+ ModTime: f.ModTime, DuplicateOf: dupes[rel], Warnings: warnings[rel]})
}
t.Counts = Counts{
Scanned: len(r.Matched) + len(r.Unmatched) + len(r.Skipped),
@@ -247,6 +256,44 @@ func (t *PlanTab) ReplaceSelected(kind plan.Kind) (int, error) {
return n, nil
}
+// KeepThisCopy is the answer to "I want this one, not the one already
+// filed": the file takes the other copy's place, and the other copy goes to
+// the Trash, where krino undo can still reach it. It is a review decision,
+// like trashing a file by hand, so the rule that a duplicate is never
+// deleted - which binds rules, not the person reading the plan - does not
+// stand in its way (his request, 2026-09-17).
+func (t *PlanTab) KeepThisCopy(i int) error {
+ if i < 0 || i >= len(t.Rows) {
+ return fmt.Errorf("model: no row %d", i)
+ }
+ row := t.Rows[i]
+ if row.DuplicateOf == "" {
+ return fmt.Errorf("model: %s is not a duplicate of anything krino looked at", row.Rel)
+ }
+ if row.Path == "" {
+ return fmt.Errorf("model: %s has no path", row.Rel)
+ }
+ for j, c := range t.dp.Chains {
+ if c.File.Rel != row.Rel {
+ continue
+ }
+ t.dp.Chains[j].Steps = []plan.Step{{
+ Kind: plan.Move,
+ Rule: "(review)",
+ Src: row.Path,
+ Dst: row.DuplicateOf,
+ Displaces: row.DuplicateOf,
+ Reason: "chosen in review: this copy replaces the one already there",
+ }}
+ t.Rows[i].Steps = t.dp.Chains[j].Steps
+ t.Rows[i].Rule = "(review)"
+ t.Rows[i].Actable = true
+ t.Rows[i].Selected = true
+ return nil
+ }
+ return fmt.Errorf("model: %s is not in this plan", row.Rel)
+}
+
// Apply acts on the selected files and logs the rest as declined, exactly
// as choosing per file in the terminal does. Each row then carries its
// outcome.
diff --git a/gui/internal/model/plan_test.go b/gui/internal/model/plan_test.go
index a54768c..0aa0361 100644
--- a/gui/internal/model/plan_test.go
+++ b/gui/internal/model/plan_test.go
@@ -408,3 +408,58 @@ func TestAgeText(t *testing.T) {
t.Errorf("a file from the future = %q, want 0m", got)
}
}
+
+// TestKeepThisCopy: choosing the downloaded copy over the filed one puts it
+// in the other's place and sends the other to the Trash, where undo can
+// still reach it (his request, 2026-09-17).
+func TestKeepThisCopy(t *testing.T) {
+ conf := "(path \"~/dl\")\n(rule \"dupes\" (when (duplicate \"~/docs\")) (move \"Dupes\"))\n"
+ e, h := sandboxDir(t, conf, map[string]string{"report.pdf": "the same bytes"})
+ filed := filepath.Join(h, "docs", "work", "report.pdf")
+ if err := os.MkdirAll(filepath.Dir(filed), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filed, []byte("the same bytes"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ tab := planTab(t, e)
+ if len(tab.Rows) != 1 || tab.Rows[0].DuplicateOf != filed {
+ t.Fatalf("row = %+v, want it a duplicate of %s", tab.Rows[0], filed)
+ }
+ if err := tab.KeepThisCopy(0); err != nil {
+ t.Fatal(err)
+ }
+ step := tab.Rows[0].Steps[0]
+ if step.Kind != plan.Move || step.Dst != filed || step.Displaces != filed {
+ t.Fatalf("step = %+v", step)
+ }
+ if _, err := tab.Apply(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ // The download is now the filed copy, with its own bytes.
+ if body, err := os.ReadFile(filed); err != nil || string(body) != "the same bytes" {
+ t.Errorf("the kept copy is not in place: %v", err)
+ }
+ if _, err := os.Stat(filepath.Join(h, "dl", "report.pdf")); !os.IsNotExist(err) {
+ t.Errorf("the download is still where it was: %v", err)
+ }
+ // The copy it replaced went to the Trash, not to oblivion.
+ entries, _ := os.ReadDir(filepath.Join(h, ".local", "share", "Trash", "files"))
+ if len(entries) != 1 {
+ t.Errorf("the Trash holds %d files, want the replaced one", len(entries))
+ }
+}
+
+// TestKeepThisCopyRefusesANonDuplicate: the choice only means something for
+// a file krino found another copy of.
+func TestKeepThisCopyRefusesANonDuplicate(t *testing.T) {
+ conf := "(path \"~/dl\")\n(rule \"all\" (move \"Out\"))\n"
+ e, _ := sandboxDir(t, conf, map[string]string{"a.pdf": "one"})
+ tab := planTab(t, e)
+ if err := tab.KeepThisCopy(0); err == nil {
+ t.Error("a file that duplicates nothing was accepted")
+ }
+ if err := tab.KeepThisCopy(9); err == nil {
+ t.Error("a row that does not exist was accepted")
+ }
+}
diff --git a/gui/internal/ui/plan.go b/gui/internal/ui/plan.go
index 8b6a394..0a9b718 100644
--- a/gui/internal/ui/plan.go
+++ b/gui/internal/ui/plan.go
@@ -64,6 +64,7 @@ type planView struct {
sortFollowsPrefs bool
startSelected bool
menu *gtk.Popover
+ keep *gtk.Button
menuRow int
tab *model.PlanTab
@@ -317,21 +318,61 @@ func (p *planView) confirmDeleteChecked() {
d.Show()
}
+// confirmKeepThisCopy asks before one copy replaces another, naming both.
+func (p *planView) confirmKeepThisCopy() {
+ if p.tab == nil || p.menuRow < 0 || p.menuRow >= len(p.tab.Rows) {
+ return
+ }
+ r := p.tab.Rows[p.menuRow]
+ if r.DuplicateOf == "" {
+ return
+ }
+ d := gtk.NewMessageDialog(&p.w.win.Window, gtk.DialogModal|gtk.DialogDestroyWithParent,
+ gtk.MessageQuestion, gtk.ButtonsNone)
+ d.SetObjectProperty("text", "Keep "+escape(r.Rel)+" and replace the other copy?")
+ d.SetObjectProperty("secondary-text", "This file takes the place of\n"+
+ escape(xdg.Abbrev(r.DuplicateOf))+
+ "\n\nThat copy goes to the Trash, and krino undo can bring it back. Nothing happens until you press Apply.")
+ d.AddButton("Cancel", int(gtk.ResponseCancel))
+ d.AddButton("Keep this copy", int(gtk.ResponseAccept))
+ d.ConnectResponse(func(response int) {
+ d.Destroy()
+ if response != int(gtk.ResponseAccept) {
+ return
+ }
+ if err := p.tab.KeepThisCopy(p.menuRow); err != nil {
+ p.w.setStatus("%v", err)
+ return
+ }
+ p.fillList()
+ p.showDetails(p.menuRow)
+ p.w.setStatus("%s will replace %s when you press Apply; nothing has moved yet",
+ escape(r.Rel), escape(xdg.Abbrev(r.DuplicateOf)))
+ })
+ 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 {
box := gtk.NewBox(gtk.OrientationVertical, 0)
+ pop := gtk.NewPopover()
+ pop.SetChild(box)
+ pop.SetParent(p.list)
+ pop.SetHasArrow(false)
trash := gtk.NewButtonWithLabel("Trash instead")
perm := gtk.NewButtonWithLabel("Delete permanently instead...")
- for _, b := range []*gtk.Button{trash, perm} {
+ p.keep = gtk.NewButtonWithLabel("Keep this copy, replace the other...")
+ p.keep.SetTooltipText("put this file where the copy it duplicates is, and send that one to the Trash")
+ for _, b := range []*gtk.Button{trash, perm, p.keep} {
b.SetHasFrame(false)
b.SetHAlign(gtk.AlignFill)
box.Append(b)
}
- pop := gtk.NewPopover()
- pop.SetChild(box)
- pop.SetParent(p.list)
- pop.SetHasArrow(false)
+ p.keep.ConnectClicked(func() {
+ pop.Popdown()
+ p.confirmKeepThisCopy()
+ })
trash.ConnectClicked(func() {
pop.Popdown()
p.replace(plan.Trash)
@@ -367,6 +408,7 @@ func (p *planView) onRightClick(x, y float64) {
if p.menuRow < 0 {
return
}
+ p.keep.SetVisible(p.tab.Rows[p.menuRow].DuplicateOf != "")
at := gdk.NewRectangle(int(x), int(y), 1, 1)
p.menu.SetPointingTo(&at)
p.menu.Popup()
@@ -869,6 +911,12 @@ func (p *planView) showDetails(i int) {
if what := describeFile(r); what != "" {
p.details.Append(detailLine(what, "dim-label"))
}
+ // Where the other copy is, in full: the reason names it relative to the
+ // directory when it is inside it, which reads as no place at all (his
+ // report, 2026-09-17).
+ if r.DuplicateOf != "" {
+ p.details.Append(detailLine("the same bytes as "+escape(xdg.Abbrev(r.DuplicateOf)), "dim-label"))
+ }
for _, s := range r.Steps {
row := gtk.NewBox(gtk.OrientationHorizontal, 8)