aboutsummaryrefslogtreecommitdiff
path: root/gui/internal/model
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-09-17 09:40:24 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-09-17 09:40:24 +0200
commit0e067e693609589d46f5aa6e161b95cc905987cc (patch)
tree913f5f0c63923863ab0cc90fd70568ed41a2c322 /gui/internal/model
parent4d6386960c981678a8e8123a4db5463df7ee60bb (diff)
downloadkrino-0e067e693609589d46f5aa6e161b95cc905987cc.tar.gz
krino-0e067e693609589d46f5aa6e161b95cc905987cc.zip
gui: size and age columns, column toggles, readable colours, a laid-out explanation
Diffstat (limited to 'gui/internal/model')
-rw-r--r--gui/internal/model/plan.go37
-rw-r--r--gui/internal/model/plan_test.go47
-rw-r--r--gui/internal/model/prefs.go6
-rw-r--r--gui/internal/model/preview.go5
4 files changed, 92 insertions, 3 deletions
diff --git a/gui/internal/model/plan.go b/gui/internal/model/plan.go
index 6d3e857..51a86d8 100644
--- a/gui/internal/model/plan.go
+++ b/gui/internal/model/plan.go
@@ -9,16 +9,20 @@ import (
"context"
"fmt"
"sort"
+ "time"
"krino/internal/engine"
"krino/internal/lock"
"krino/internal/plan"
+ "krino/internal/scan"
)
// 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
@@ -104,8 +108,10 @@ func (t *PlanTab) fill() {
r := t.dp.Result
t.Warnings = append([]string(nil), r.Warnings...)
warnings := map[string][]string{}
+ files := map[string]scan.File{}
for _, fms := range [][]engine.FileMatch{r.Matched, r.Unmatched} {
for _, fm := range fms {
+ files[fm.File.Rel] = fm.File
if len(fm.Warnings) > 0 {
warnings[fm.File.Rel] = fm.Warnings
}
@@ -119,7 +125,8 @@ func (t *PlanTab) fill() {
}
t.Rows = nil
for _, c := range t.dp.Chains {
- row := Row{Rel: c.File.Rel, Steps: c.Steps, Warnings: warnings[c.File.Rel]}
+ row := Row{Rel: c.File.Rel, Size: c.File.Size, ModTime: c.File.ModTime,
+ Steps: c.Steps, Warnings: warnings[c.File.Rel]}
for _, s := range c.Steps {
if s.Skip == "" {
row.Actable = true
@@ -142,7 +149,9 @@ func (t *PlanTab) fill() {
}
sort.Strings(rest)
for _, rel := range rest {
- t.Rows = append(t.Rows, Row{Rel: rel, Warnings: warnings[rel]})
+ f := files[rel]
+ t.Rows = append(t.Rows, Row{Rel: rel, Size: f.Size, ModTime: f.ModTime,
+ Warnings: warnings[rel]})
}
t.Counts = Counts{
Scanned: len(r.Matched) + len(r.Unmatched) + len(r.Skipped),
@@ -280,3 +289,27 @@ func (t *PlanTab) record(res *engine.ApplyResult) {
}
}
}
+
+// AgeText is how long ago a file was last written, in the units krino's own
+// (age ...) test uses: minutes, hours, days and weeks, and years past that,
+// so a plan can be read at a glance (his request, 2026-09-17).
+func AgeText(mod time.Time, now time.Time) string {
+ if mod.IsZero() {
+ return ""
+ }
+ d := now.Sub(mod)
+ if d < 0 {
+ return "0m" // a file dated in the future is not aged
+ }
+ switch {
+ case d < time.Hour:
+ return fmt.Sprintf("%dm", int(d.Minutes()))
+ case d < 24*time.Hour:
+ return fmt.Sprintf("%dh", int(d.Hours()))
+ case d < 7*24*time.Hour:
+ return fmt.Sprintf("%dd", int(d.Hours()/24))
+ case d < 52*7*24*time.Hour:
+ return fmt.Sprintf("%dw", int(d.Hours()/(24*7)))
+ }
+ return fmt.Sprintf("%dy", int(d.Hours()/(24*365)))
+}
diff --git a/gui/internal/model/plan_test.go b/gui/internal/model/plan_test.go
index ecbb81d..a54768c 100644
--- a/gui/internal/model/plan_test.go
+++ b/gui/internal/model/plan_test.go
@@ -361,3 +361,50 @@ func TestReplaceSelected(t *testing.T) {
t.Errorf("the unchecked file did not stay put: %v", err)
}
}
+
+// TestRowsCarrySizeAndAge: a plan's rows know how big each file is and when
+// it was last written, for the columns that show them.
+func TestRowsCarrySizeAndAge(t *testing.T) {
+ conf := "(path \"~/dl\")\n(rule \"all\" (move \"Out\"))\n"
+ e, h := sandboxDir(t, conf, map[string]string{"a.pdf": "three bytes and more"})
+ tab := planTab(t, e)
+ if len(tab.Rows) != 1 {
+ t.Fatalf("rows = %+v", tab.Rows)
+ }
+ fi, err := os.Stat(filepath.Join(h, "dl", "a.pdf"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if tab.Rows[0].Size != fi.Size() {
+ t.Errorf("size = %d, want %d", tab.Rows[0].Size, fi.Size())
+ }
+ if !tab.Rows[0].ModTime.Equal(fi.ModTime()) {
+ t.Errorf("mtime = %v, want %v", tab.Rows[0].ModTime, fi.ModTime())
+ }
+}
+
+// TestAgeText: the units krino's own (age ...) test uses, and nothing for a
+// file whose time is unknown.
+func TestAgeText(t *testing.T) {
+ now := time.Date(2026, 9, 17, 12, 0, 0, 0, time.UTC)
+ for _, c := range []struct {
+ ago time.Duration
+ want string
+ }{
+ {30 * time.Minute, "30m"},
+ {5 * time.Hour, "5h"},
+ {3 * 24 * time.Hour, "3d"},
+ {3 * 7 * 24 * time.Hour, "3w"},
+ {3 * 365 * 24 * time.Hour, "3y"},
+ } {
+ if got := AgeText(now.Add(-c.ago), now); got != c.want {
+ t.Errorf("%v ago = %q, want %q", c.ago, got, c.want)
+ }
+ }
+ if got := AgeText(time.Time{}, now); got != "" {
+ t.Errorf("an unknown time = %q, want nothing", got)
+ }
+ if got := AgeText(now.Add(time.Hour), now); got != "0m" {
+ t.Errorf("a file from the future = %q, want 0m", got)
+ }
+}
diff --git a/gui/internal/model/prefs.go b/gui/internal/model/prefs.go
index 77aa784..6b3a6cb 100644
--- a/gui/internal/model/prefs.go
+++ b/gui/internal/model/prefs.go
@@ -31,6 +31,11 @@ type Prefs struct {
// moves is kept (his request, 2026-09-17).
ListWidth int `json:"list_width"`
ListHeight int `json:"list_height"`
+ // ShowSize, ShowAge and ShowRule are the columns that can be turned off
+ // when the window is narrow or they are not wanted.
+ ShowSize bool `json:"show_size"`
+ ShowAge bool `json:"show_age"`
+ ShowRule bool `json:"show_rule"`
// Layout is how the Plan tab is arranged: "side" puts the file list
// beside the explanation, "stacked" puts it above, with the preview
// beside the explanation underneath.
@@ -55,6 +60,7 @@ const (
// DefaultPrefs is what a window does before anything is chosen.
func DefaultPrefs() Prefs {
return Prefs{Colours: true, Preview: true, SelectAll: true,
+ ShowSize: true, ShowAge: true, ShowRule: true,
PreviewHeight: DefaultPreviewHeight, PreviewWidth: DefaultPreviewWidth,
ListWidth: DefaultListWidth, ListHeight: DefaultListHeight,
Layout: LayoutSide}
diff --git a/gui/internal/model/preview.go b/gui/internal/model/preview.go
index 4ec0569..a180dff 100644
--- a/gui/internal/model/preview.go
+++ b/gui/internal/model/preview.go
@@ -192,7 +192,10 @@ func isImageExt(ext string) bool {
}
// size is a file's size in the units krino's own settings use.
-func size(n int64) string {
+func size(n int64) string { return SizeText(n) }
+
+// SizeText is a file's size in the units krino's settings are written in.
+func SizeText(n int64) string {
switch {
case n >= 1<<30:
return fmt.Sprintf("%.1fG", float64(n)/(1<<30))