Add interactive snapshot comparison, CLI tests and treegen
--compare opens the TUI with growth/shrink colouring unless a report is requested. treegen builds deterministic synthetic trees (Unicode, hostile names, sparse files). MAPSIZE_DEBUG_PANIC verifies terminal restoration on UI and scanner panics.
This commit is contained in:
@@ -0,0 +1,130 @@
|
|||||||
|
// Command treegen creates synthetic directory trees for testing and
|
||||||
|
// benchmarking mapsize.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"math/rand"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
var (
|
||||||
|
out = flag.String("out", "", "output directory (required, must not exist or be empty)")
|
||||||
|
dirs = flag.Int("directories", 1000, "number of directories")
|
||||||
|
files = flag.Int("files", 10000, "number of files")
|
||||||
|
depth = flag.Int("max-depth", 8, "maximum directory depth")
|
||||||
|
sparse = flag.Bool("sparse", false, "make some large files sparse")
|
||||||
|
unicode = flag.Bool("unicode", false, "use Unicode and unusual (but safe) names")
|
||||||
|
hostile = flag.Bool("hostile", false, "include names with control characters/escape sequences (Unix only)")
|
||||||
|
maxSize = flag.Int64("max-size", 1<<20, "largest regular file in bytes (sizes are log-uniform; content is written)")
|
||||||
|
seed = flag.Int64("seed", 1, "random seed (deterministic output)")
|
||||||
|
hotspots = flag.Int("hotspots", 3, "directories that receive a large share of files")
|
||||||
|
)
|
||||||
|
flag.Parse()
|
||||||
|
if *out == "" {
|
||||||
|
flag.Usage()
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(*out, 0o755); err != nil {
|
||||||
|
fatal(err)
|
||||||
|
}
|
||||||
|
if ents, _ := os.ReadDir(*out); len(ents) > 0 {
|
||||||
|
fatal(fmt.Errorf("%s is not empty", *out))
|
||||||
|
}
|
||||||
|
r := rand.New(rand.NewSource(*seed))
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
type dir struct {
|
||||||
|
path string
|
||||||
|
depth int
|
||||||
|
}
|
||||||
|
all := []dir{{*out, 0}}
|
||||||
|
byDepth := [][]int{{0}}
|
||||||
|
for len(all) < *dirs+1 {
|
||||||
|
// Pick a depth first, then a directory at that depth, so the tree is
|
||||||
|
// broad near the top instead of one giant preferential-attachment chain.
|
||||||
|
lv := byDepth[r.Intn(len(byDepth))]
|
||||||
|
p := all[lv[r.Intn(len(lv))]]
|
||||||
|
if p.depth >= *depth {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
d := dir{filepath.Join(p.path, name(r, len(all), *unicode, *hostile)), p.depth + 1}
|
||||||
|
if err := os.Mkdir(d.path, 0o755); err != nil {
|
||||||
|
continue // name collision; try again
|
||||||
|
}
|
||||||
|
all = append(all, d)
|
||||||
|
if d.depth == len(byDepth) {
|
||||||
|
byDepth = append(byDepth, nil)
|
||||||
|
}
|
||||||
|
byDepth[d.depth] = append(byDepth[d.depth], len(all)-1)
|
||||||
|
}
|
||||||
|
hot := make([]int, *hotspots)
|
||||||
|
for i := range hot {
|
||||||
|
hot[i] = r.Intn(len(all))
|
||||||
|
}
|
||||||
|
var bytes int64
|
||||||
|
buf := make([]byte, *maxSize)
|
||||||
|
for i := range buf {
|
||||||
|
buf[i] = byte(r.Intn(256)) // incompressible, so allocation is real everywhere
|
||||||
|
}
|
||||||
|
for i := 0; i < *files; i++ {
|
||||||
|
var d dir
|
||||||
|
if len(hot) > 0 && r.Intn(3) == 0 {
|
||||||
|
d = all[hot[r.Intn(len(hot))]]
|
||||||
|
} else {
|
||||||
|
d = all[r.Intn(len(all))]
|
||||||
|
}
|
||||||
|
p := filepath.Join(d.path, name(r, i, *unicode, *hostile)+ext(r))
|
||||||
|
// Log-uniform sizes: many tiny files, a few large ones.
|
||||||
|
size := int64(math.Exp(r.Float64() * math.Log(float64(*maxSize))))
|
||||||
|
if r.Intn(20) == 0 {
|
||||||
|
size = 0
|
||||||
|
}
|
||||||
|
f, err := os.OpenFile(p, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if *sparse && r.Intn(200) == 0 {
|
||||||
|
// Sparse: a large logical size with almost nothing allocated.
|
||||||
|
f.Truncate(int64(1+r.Intn(8)) << 30)
|
||||||
|
f.WriteAt([]byte("x"), 0)
|
||||||
|
} else if size > 0 {
|
||||||
|
if _, err := f.Write(buf[:size]); err != nil {
|
||||||
|
fatal(err)
|
||||||
|
}
|
||||||
|
bytes += size
|
||||||
|
}
|
||||||
|
f.Close()
|
||||||
|
if i%100000 == 0 && i > 0 {
|
||||||
|
fmt.Fprintf(os.Stderr, "%d files…\n", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stderr, "created %d directories, %d files (%d logical bytes) in %s\n",
|
||||||
|
len(all)-1, *files, bytes, time.Since(start).Round(time.Millisecond))
|
||||||
|
}
|
||||||
|
|
||||||
|
var words = []string{"data", "cache", "backup", "photos", "src", "build", "logs", "tmp", "vm", "media", "docs", "lib", "node_modules", "archive"}
|
||||||
|
var exts = []string{".txt", ".log", ".jpg", ".png", ".mp4", ".iso", ".qcow2", ".go", ".c", ".pdf", ".zip", ".tar.gz", ".db", ".so", "", ".json", ".mkv", ".flac"}
|
||||||
|
var uni = []string{"日本語", "Ünïcödé", "emoji 🎉", "Ελληνικά", "кириллица", "space name", "quote'name", "dash-name"}
|
||||||
|
|
||||||
|
func name(r *rand.Rand, i int, unicode, hostile bool) string {
|
||||||
|
switch {
|
||||||
|
case hostile && r.Intn(50) == 0:
|
||||||
|
return fmt.Sprintf("evil\x1b[31m%d\x1b]0;pwned\x07", i)
|
||||||
|
case unicode && r.Intn(5) == 0:
|
||||||
|
return fmt.Sprintf("%s-%d", uni[r.Intn(len(uni))], i)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s-%d", words[r.Intn(len(words))], i)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ext(r *rand.Rand) string { return exts[r.Intn(len(exts))] }
|
||||||
|
|
||||||
|
func fatal(err error) {
|
||||||
|
fmt.Fprintln(os.Stderr, "treegen:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
+6
-5
@@ -93,6 +93,12 @@ func runReport(cfg *Config) error {
|
|||||||
if s != nil {
|
if s != nil {
|
||||||
waitWithProgress(s, cfg.Stderr)
|
waitWithProgress(s, cfg.Stderr)
|
||||||
}
|
}
|
||||||
|
if cfg.Duplicates && !cfg.JSON && !cfg.CSV {
|
||||||
|
// Find takes its own read lock; never nest RLock.
|
||||||
|
if err := runDuplicates(ctx, cfg, t); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
t.RLock()
|
t.RLock()
|
||||||
defer t.RUnlock()
|
defer t.RUnlock()
|
||||||
if s != nil && cfg.Save != "" {
|
if s != nil && cfg.Save != "" {
|
||||||
@@ -108,11 +114,6 @@ func runReport(cfg *Config) error {
|
|||||||
case cfg.CSV:
|
case cfg.CSV:
|
||||||
return export.CSV(out, t, t.Root(), cfg.Depth)
|
return export.CSV(out, t, t.Root(), cfg.Depth)
|
||||||
}
|
}
|
||||||
if cfg.Duplicates {
|
|
||||||
if err := runDuplicates(ctx, cfg, t); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if cfg.Top > 0 {
|
if cfg.Top > 0 {
|
||||||
ids := t.SortedChildren(t.Root(), cfg.SizeMode)
|
ids := t.SortedChildren(t.Root(), cfg.SizeMode)
|
||||||
if len(ids) > cfg.Top {
|
if len(ids) > cfg.Top {
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/csv"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/andydixon/mapsize/internal/scan"
|
||||||
|
)
|
||||||
|
|
||||||
|
func fixture(t *testing.T) string {
|
||||||
|
root := t.TempDir()
|
||||||
|
os.MkdirAll(filepath.Join(root, "big"), 0o755)
|
||||||
|
os.MkdirAll(filepath.Join(root, "small"), 0o755)
|
||||||
|
os.WriteFile(filepath.Join(root, "big", "a.iso"), make([]byte, 300_000), 0o644)
|
||||||
|
os.WriteFile(filepath.Join(root, "big", "b.iso"), make([]byte, 300_000), 0o644)
|
||||||
|
os.WriteFile(filepath.Join(root, "small", "c.txt"), []byte("hello"), 0o644)
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(t *testing.T, cfg *Config) string {
|
||||||
|
t.Helper()
|
||||||
|
var out bytes.Buffer
|
||||||
|
cfg.Stdout, cfg.Stderr = &out, &bytes.Buffer{}
|
||||||
|
cfg.ScanOpts = scan.Options{Workers: 2}
|
||||||
|
if err := Run(cfg); err != nil {
|
||||||
|
t.Fatalf("Run: %v", err)
|
||||||
|
}
|
||||||
|
return out.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTopAndLargest(t *testing.T) {
|
||||||
|
root := fixture(t)
|
||||||
|
out := run(t, &Config{Paths: []string{root}, Top: 5, LargestFiles: 2})
|
||||||
|
if !strings.Contains(out, filepath.Join(root, "big")) || !strings.Contains(out, "a.iso") {
|
||||||
|
t.Fatalf("report missing entries:\n%s", out)
|
||||||
|
}
|
||||||
|
if strings.Index(out, filepath.Join(root, "big")) > strings.Index(out, filepath.Join(root, "small")) {
|
||||||
|
t.Fatalf("not sorted by size:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJSONIsValid(t *testing.T) {
|
||||||
|
root := fixture(t)
|
||||||
|
out := run(t, &Config{Paths: []string{root}, JSON: true, Depth: -1})
|
||||||
|
var v struct {
|
||||||
|
Root string
|
||||||
|
Complete bool
|
||||||
|
Tree struct {
|
||||||
|
Size int64
|
||||||
|
Children []struct{ Name string }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(out), &v); err != nil {
|
||||||
|
t.Fatalf("invalid JSON: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
if !v.Complete || v.Tree.Size < 600_005 || len(v.Tree.Children) != 2 {
|
||||||
|
t.Fatalf("unexpected JSON: %+v", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCSV(t *testing.T) {
|
||||||
|
root := fixture(t)
|
||||||
|
out := run(t, &Config{Paths: []string{root}, CSV: true, Depth: -1})
|
||||||
|
rows, err := csv.NewReader(strings.NewReader(out)).ReadAll()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(rows) != 1+1+2+3 { // header, root, 2 dirs, 3 files
|
||||||
|
t.Fatalf("%d rows:\n%s", len(rows), out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSnapshotSaveLoadCompare(t *testing.T) {
|
||||||
|
root := fixture(t)
|
||||||
|
dir := t.TempDir()
|
||||||
|
a, b := filepath.Join(dir, "a.msz"), filepath.Join(dir, "b.msz")
|
||||||
|
run(t, &Config{Paths: []string{root}, NoUI: true, Save: a})
|
||||||
|
os.WriteFile(filepath.Join(root, "small", "grown.bin"), make([]byte, 500_000), 0o644)
|
||||||
|
os.Remove(filepath.Join(root, "big", "b.iso"))
|
||||||
|
run(t, &Config{Paths: []string{root}, NoUI: true, Save: b})
|
||||||
|
|
||||||
|
out := run(t, &Config{Load: a, Top: 3})
|
||||||
|
if !strings.Contains(out, "big") {
|
||||||
|
t.Fatalf("loaded snapshot report:\n%s", out)
|
||||||
|
}
|
||||||
|
out = run(t, &Config{Compare: [2]string{a, b}, NoUI: true})
|
||||||
|
for _, want := range []string{"new", "grown.bin", "removed", "b.iso"} {
|
||||||
|
if !strings.Contains(out, want) {
|
||||||
|
t.Fatalf("compare output missing %q:\n%s", want, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDuplicatesReport(t *testing.T) {
|
||||||
|
root := fixture(t)
|
||||||
|
out := run(t, &Config{Paths: []string{root}, Duplicates: true})
|
||||||
|
if !strings.Contains(out, "1 verified duplicate groups") {
|
||||||
|
t.Fatalf("duplicates:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,13 @@ func runCompare(cfg *Config) error {
|
|||||||
return fmt.Errorf("%s: %w", cfg.Compare[1], err)
|
return fmt.Errorf("%s: %w", cfg.Compare[1], err)
|
||||||
}
|
}
|
||||||
d := snapshot.Compare(old, nw)
|
d := snapshot.Compare(old, nw)
|
||||||
|
if !cfg.Report() {
|
||||||
|
return tui.Run(tui.Options{
|
||||||
|
ReadOnly: true, Theme: cfg.Theme, SizeMode: cfg.SizeMode, Mouse: cfg.Mouse, Color: cfg.Color,
|
||||||
|
Settings: cfg.Settings, Diff: d,
|
||||||
|
Start: func(context.Context) (*scan.Scanner, *inventory.Tree, error) { return nil, nw, nil },
|
||||||
|
})
|
||||||
|
}
|
||||||
k := cfg.Top
|
k := cfg.Top
|
||||||
if k <= 0 {
|
if k <= 0 {
|
||||||
k = 30
|
k = 30
|
||||||
@@ -50,8 +57,6 @@ func runCompare(cfg *Config) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func runDuplicates(ctx context.Context, cfg *Config, t *inventory.Tree) error {
|
func runDuplicates(ctx context.Context, cfg *Config, t *inventory.Tree) error {
|
||||||
// The caller holds RLock; Find takes its own read lock, which is fine
|
|
||||||
// for a RWMutex only if no writer is waiting — the scan is finished.
|
|
||||||
var f duplicate.Finder
|
var f duplicate.Finder
|
||||||
groups, err := f.Find(ctx, t, duplicate.Options{MinSize: 1})
|
groups, err := f.Find(ctx, t, duplicate.Options{MinSize: 1})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -59,6 +64,8 @@ func runDuplicates(ctx context.Context, cfg *Config, t *inventory.Tree) error {
|
|||||||
}
|
}
|
||||||
out := cfg.Stdout
|
out := cfg.Stdout
|
||||||
var wasted int64
|
var wasted int64
|
||||||
|
t.RLock()
|
||||||
|
defer t.RUnlock()
|
||||||
for _, g := range groups {
|
for _, g := range groups {
|
||||||
wasted += g.Wasted()
|
wasted += g.Wasted()
|
||||||
fmt.Fprintf(out, "%s × %d (sha256 %x…)\n", textutil.Size(g.Size), len(g.Files), g.Hash[:6])
|
fmt.Fprintf(out, "%s × %d (sha256 %x…)\n", textutil.Size(g.Size), len(g.Files), g.Hash[:6])
|
||||||
|
|||||||
@@ -212,7 +212,7 @@ func (v *dupView) paint(m *Model, cv *Canvas, r treemap.Rect) {
|
|||||||
if d.err != nil {
|
if d.err != nil {
|
||||||
title += " — stopped: " + textutil.Sanitize(d.err.Error())
|
title += " — stopped: " + textutil.Sanitize(d.err.Error())
|
||||||
}
|
}
|
||||||
v.tb.paint(m, cv, r, []column{{"WASTED", 10, true}, {"COPIES", 16, false}, {"", 0, false}}, tr, title)
|
v.tb.paint(m, cv, r, []column{{"WASTED", 10, true, false}, {"COPIES", 16, false, false}, {"", 0, false, false}}, tr, title)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *dupView) key(m *Model, k string) (bool, tea.Cmd) {
|
func (v *dupView) key(m *Model, k string) (bool, tea.Cmd) {
|
||||||
|
|||||||
@@ -131,14 +131,14 @@ func (v *infoView) paint(m *Model, cv *Canvas, r treemap.Rect) {
|
|||||||
if e.Name != "" {
|
if e.Name != "" {
|
||||||
p += "/" + e.Name
|
p += "/" + e.Name
|
||||||
}
|
}
|
||||||
rows[i] = tableRow{cells: []string{e.Kind.String(), textutil.Sanitize(e.Msg), textutil.Sanitize(p)},
|
rows[i] = tableRow{cells: []string{e.Kind.String(), textutil.Sanitize(p), textutil.Sanitize(e.Msg)},
|
||||||
styles: []*Style{{FG: t.Err, BG: t.Bg}, {FG: t.Muted, BG: t.Bg}, nil}, bar: -1}
|
styles: []*Style{{FG: t.Err, BG: t.Bg}, nil, {FG: t.Muted, BG: t.Bg}}, bar: -1}
|
||||||
}
|
}
|
||||||
title := fmt.Sprintf("ERROR LIST (%s)", textutil.Count(st.TotalErrors()))
|
title := fmt.Sprintf("ERROR LIST (%s)", textutil.Count(st.TotalErrors()))
|
||||||
if int64(len(st.Errors)) < st.TotalErrors() {
|
if int64(len(st.Errors)) < st.TotalErrors() {
|
||||||
title += fmt.Sprintf(" — first %s shown", textutil.Count(int64(len(st.Errors))))
|
title += fmt.Sprintf(" — first %s shown", textutil.Count(int64(len(st.Errors))))
|
||||||
}
|
}
|
||||||
v.tb.paint(m, cv, er, []column{{"KIND", 20, false}, {"DETAIL", 24, false}, {"PATH", 0, false}}, rows, title)
|
v.tb.paint(m, cv, er, []column{{"KIND", 18, false, false}, {"PATH", 0, false, true}, {"DETAIL", 22, false, false}}, rows, title)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *infoView) key(m *Model, k string) (bool, tea.Cmd) {
|
func (v *infoView) key(m *Model, k string) (bool, tea.Cmd) {
|
||||||
|
|||||||
@@ -150,6 +150,11 @@ func newModel(opts Options, s *scan.Scanner, t *inventory.Tree, cancel context.C
|
|||||||
|
|
||||||
const noSel int64 = 1 << 62
|
const noSel int64 = 1 << 62
|
||||||
|
|
||||||
|
// debugPanic lets developers verify terminal restoration on crashes:
|
||||||
|
// MAPSIZE_DEBUG_PANIC=ui panics on the first key press; =scanner panics in
|
||||||
|
// a background goroutine shortly after start.
|
||||||
|
var debugPanic = os.Getenv("MAPSIZE_DEBUG_PANIC")
|
||||||
|
|
||||||
// Init starts ticking and waits for scan completion.
|
// Init starts ticking and waits for scan completion.
|
||||||
func (m *Model) Init() tea.Cmd {
|
func (m *Model) Init() tea.Cmd {
|
||||||
cmds := []tea.Cmd{tea.RequestWindowSize}
|
cmds := []tea.Cmd{tea.RequestWindowSize}
|
||||||
@@ -271,6 +276,9 @@ func (m *Model) update(msg tea.Msg) tea.Cmd {
|
|||||||
m.fatal = &msg
|
m.fatal = &msg
|
||||||
m.quit = true
|
m.quit = true
|
||||||
case tea.KeyPressMsg:
|
case tea.KeyPressMsg:
|
||||||
|
if debugPanic == "ui" {
|
||||||
|
panic("MAPSIZE_DEBUG_PANIC=ui: deliberate panic to test terminal restoration")
|
||||||
|
}
|
||||||
m.dirty = true
|
m.dirty = true
|
||||||
prev := m.sel
|
prev := m.sel
|
||||||
m.zoomed = false
|
m.zoomed = false
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"runtime/debug"
|
||||||
|
"time"
|
||||||
|
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
"github.com/charmbracelet/colorprofile"
|
"github.com/charmbracelet/colorprofile"
|
||||||
@@ -47,6 +49,17 @@ func Run(opts Options) error {
|
|||||||
select {} // park the broken goroutine; the program is quitting
|
select {} // park the broken goroutine; the program is quitting
|
||||||
}
|
}
|
||||||
defer func() { scan.OnPanic = prev }()
|
defer func() { scan.OnPanic = prev }()
|
||||||
|
if debugPanic == "scanner" {
|
||||||
|
go func() {
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
defer func() {
|
||||||
|
if v := recover(); v != nil {
|
||||||
|
scan.OnPanic(v, debug.Stack())
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
panic("MAPSIZE_DEBUG_PANIC=scanner: deliberate panic to test terminal restoration")
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
_, err = p.Run()
|
_, err = p.Run()
|
||||||
cancel()
|
cancel()
|
||||||
|
|||||||
@@ -9,9 +9,10 @@ import (
|
|||||||
|
|
||||||
// column describes one table column.
|
// column describes one table column.
|
||||||
type column struct {
|
type column struct {
|
||||||
title string
|
title string
|
||||||
width int // 0 = flexible (takes the remaining space)
|
width int // 0 = flexible (takes the remaining space)
|
||||||
right bool
|
right bool
|
||||||
|
clipLeft bool // truncate from the left (paths: the tail matters)
|
||||||
}
|
}
|
||||||
|
|
||||||
// tableRow is one rendered row: cell strings plus optional per-cell styles.
|
// tableRow is one rendered row: cell strings plus optional per-cell styles.
|
||||||
@@ -129,6 +130,8 @@ func (tb *table) paint(m *Model, cv *Canvas, r treemap.Rect, cols []column, rows
|
|||||||
m.paintBar(cv, x, y, w, row.bar, row.barCol, sel)
|
m.paintBar(cv, x, y, w, row.bar, row.barCol, sel)
|
||||||
} else if c.right {
|
} else if c.right {
|
||||||
cv.Text(x, y, textutil.PadLeft(s, w), w, st)
|
cv.Text(x, y, textutil.PadLeft(s, w), w, st)
|
||||||
|
} else if c.clipLeft {
|
||||||
|
cv.Text(x, y, textutil.TruncateLeft(s, w), w, st)
|
||||||
} else {
|
} else {
|
||||||
cv.Text(x, y, textutil.Truncate(s, w), w, st)
|
cv.Text(x, y, textutil.Truncate(s, w), w, st)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,12 +75,12 @@ func (v *listView) paint(m *Model, cv *Canvas, r treemap.Rect) {
|
|||||||
rows[i].cells[4] = textutil.SignedSize(m.opts.Diff.Delta(id, m.sizeMode))
|
rows[i].cells[4] = textutil.SignedSize(m.opts.Diff.Delta(id, m.sizeMode))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cols := []column{{"SIZE", 10, true}, {"%", 5, true}, {"", 12, false}, {"FILES", 10, true}, {"MODIFIED", 10, false}, {"NAME", 0, false}}
|
cols := []column{{"SIZE", 10, true, false}, {"%", 5, true, false}, {"", 12, false, false}, {"FILES", 10, true, false}, {"MODIFIED", 10, false, false}, {"NAME", 0, false, false}}
|
||||||
if m.opts.Diff != nil {
|
if m.opts.Diff != nil {
|
||||||
cols[4] = column{"CHANGE", 12, true}
|
cols[4] = column{"CHANGE", 12, true, false}
|
||||||
}
|
}
|
||||||
if r.W < 70 {
|
if r.W < 70 {
|
||||||
cols = []column{{"SIZE", 10, true}, {"%", 5, true}, {"NAME", 0, false}}
|
cols = []column{{"SIZE", 10, true, false}, {"%", 5, true, false}, {"NAME", 0, false, false}}
|
||||||
for i := range rows {
|
for i := range rows {
|
||||||
rows[i].cells = []string{rows[i].cells[0], rows[i].cells[1], rows[i].cells[5]}
|
rows[i].cells = []string{rows[i].cells[0], rows[i].cells[1], rows[i].cells[5]}
|
||||||
rows[i].styles = []*Style{nil, rows[i].styles[1], rows[i].styles[5]}
|
rows[i].styles = []*Style{nil, rows[i].styles[1], rows[i].styles[5]}
|
||||||
@@ -160,7 +160,7 @@ func (v *extView) paint(m *Model, cv *Canvas, r treemap.Rect) {
|
|||||||
bar: frac, barCol: col,
|
bar: frac, barCol: col,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cols := []column{{"EXTENSION", 14, false}, {"CATEGORY", 12, false}, {"FILES", 11, true}, {"SIZE", 10, true}, {"%", 5, true}, {"", 0, false}}
|
cols := []column{{"EXTENSION", 14, false, false}, {"CATEGORY", 12, false, false}, {"FILES", 11, true, false}, {"SIZE", 10, true, false}, {"%", 5, true, false}, {"", 0, false, false}}
|
||||||
v.tb.paint(m, cv, r, cols, rows, fmt.Sprintf("File types under %s — Enter filters the map by extension", textutil.Sanitize(m.tree.Node(m.zoom).Name)))
|
v.tb.paint(m, cv, r, cols, rows, fmt.Sprintf("File types under %s — Enter filters the map by extension", textutil.Sanitize(m.tree.Node(m.zoom).Name)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -342,7 +342,7 @@ func (v *topView) paint(m *Model, cv *Canvas, r treemap.Rect) {
|
|||||||
case topSparse:
|
case topSparse:
|
||||||
valTitle = "SAVED"
|
valTitle = "SAVED"
|
||||||
}
|
}
|
||||||
cols := []column{{valTitle, 10, true}, {"MODIFIED", 10, false}, {"", 10, false}, {"PATH", 0, false}}
|
cols := []column{{valTitle, 10, true, false}, {"MODIFIED", 10, false, false}, {"", 10, false, false}, {"PATH", 0, false, true}}
|
||||||
v.tb.paint(m, cv, r, cols, rows, title)
|
v.tb.paint(m, cv, r, cols, rows, title)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -413,7 +413,8 @@ func (v *changesView) paint(m *Model, cv *Canvas, r treemap.Rect) {
|
|||||||
bar: float64(abs64(c.Delta)) / float64(maxAbs), barCol: col,
|
bar: float64(abs64(c.Delta)) / float64(maxAbs), barCol: col,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cols := []column{{"CHANGE", 13, true}, {"STATUS", 9, false}, {"OLD", 10, true}, {"NEW", 10, true}, {"", 10, false}, {"PATH", 0, false}}
|
cols := []column{{"CHANGE", 13, true, false}, {"STATUS", 9, false, false}, {"OLD", 10, true, false}, {"NEW", 10, true, false},
|
||||||
|
{"", 10, false, false}, {"PATH", 0, false, true}}
|
||||||
ro, rn := d.Old.Node(0).Total(m.sizeMode), d.New.Node(0).Total(m.sizeMode)
|
ro, rn := d.Old.Node(0).Total(m.sizeMode), d.New.Node(0).Total(m.sizeMode)
|
||||||
title := fmt.Sprintf("Changes %s → %s (%s)", textutil.Size(ro), textutil.Size(rn), textutil.SignedSize(rn-ro))
|
title := fmt.Sprintf("Changes %s → %s (%s)", textutil.Size(ro), textutil.Size(rn), textutil.SignedSize(rn-ro))
|
||||||
v.tb.paint(m, cv, r, cols, rows, title)
|
v.tb.paint(m, cv, r, cols, rows, title)
|
||||||
|
|||||||
Reference in New Issue
Block a user