summaryrefslogtreecommitdiff
path: root/gui/internal/model
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/internal/model
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/internal/model')
-rw-r--r--gui/internal/model/plan.go65
-rw-r--r--gui/internal/model/plan_test.go55
2 files changed, 111 insertions, 9 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")
+ }
+}