diff options
Diffstat (limited to 'gui/internal/model')
| -rw-r--r-- | gui/internal/model/plan.go | 65 | ||||
| -rw-r--r-- | gui/internal/model/plan_test.go | 55 |
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") + } +} |
