From bc197fe752a1fca3e8758aef706963dab87f908b Mon Sep 17 00:00:00 2001 From: Michael Wesemann Date: Mon, 10 Aug 2026 16:35:51 +0200 Subject: [PATCH] initial commit [141.14.140.180,mike] --- .gitignore | 12 ++ README.md | 172 ++++++++++++++++++ build.sh | 60 ++++++ filetypes.go | 93 ++++++++++ go.mod | 11 ++ go.sum | 9 + main.go | 504 +++++++++++++++++++++++++++++++++++++++++++++++++++ version.txt | 1 + 8 files changed, 862 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100755 build.sh create mode 100644 filetypes.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 version.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..09718fd --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +.DS_Store +.AppleDouble +.LSOverride +._* +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +bin/ +dx +.mgshrc diff --git a/README.md b/README.md new file mode 100644 index 0000000..83d73a6 --- /dev/null +++ b/README.md @@ -0,0 +1,172 @@ +# dx + +Zählt, was in einem Verzeichnis liegt, und wie schwer es ist — eine Ebene tief, +ein Blick, sofort fertig. + +``` +$ dx ~/src -r -t 8 +/Users/mike/src 2489 1.1G + Wetter/ 297 316M ██▉ 29% + gblog/ 99 135M █▎ 12% + gbld/ 82 131M █▏ 12% + goca/ 80 108M █ 10% + futterturm/ 62 68M ▋ 6% + BugSim/ 323 68M ▋ 6% + gvm/ 37 57M ▌ 5% + mJournal/ 204 42M ▍ 4% + … 16 more 1305 179M █▋ 16% +``` + +Links der Name, dann die Zahl der enthaltenen Dateien, dann die Summe ihrer +Größen, dann der Anteil am Ganzen. Die Kopfzeile ist das Verzeichnis selbst. +Verzeichnisse werden vollständig durchlaufen, aber nicht aufgeklappt — für +alles Weitere ist `ncdu` da. + +Im Terminal ist die Ausgabe farbig, nach der Palette aus +[mwxcol](../mwxcol/README.md). + +## Bauen und installieren + +```sh +cd ~/src/dx +./build.sh +cp bin/dx ~/bin/dx +``` + +`build.sh` baut nach `./bin`, je ein Binary für macOS und Linux auf arm64 und +amd64, und legt `bin/dx` als Symlink auf das für diese Maschine. Für eine +einzelne Plattform: + +```sh +PLATFORMS="linux/amd64" ./build.sh +``` + +Jeder Lauf zählt die Patch-Nummer in `version.txt` um eins hoch und baut sie +per `-ldflags` in alle Binaries ein — `dx --version` sagt also, welcher Bau +läuft. Ein nacktes `go build` geht auch, meldet dann aber die Vorgabe aus +`main.go` statt der wirklichen Nummer. + +Braucht Go ≥ 1.26 und `github.com/fatih/color`. Das Perl-Original (`dx`, 1.0.2, +2016) liegt unverändert daneben und wird vom Bau nicht angefasst; es hat keine +Farben und keine Balken, tut aber sonst dasselbe. + +## Optionen + +| | | | +|---|---|---| +| `-n` | `--number` | nach Dateizahl sortieren statt nach Größe | +| `-d` | `--dirs` | nur Verzeichnisse zählen, Dateien der obersten Ebene weglassen | +| `-r` | `--reverse` | größtes zuerst | +| `-t N` | `--top=N` | nur die N größten zeigen, den Rest zu einer Zeile summieren | +| `-h` | `--help` | Hilfe | +| | `--bar`, `--no-bar` | Balken und Prozent erzwingen bzw. abschalten | +| | `--color=auto\|always\|never` | Farbe erzwingen bzw. abschalten | +| | `--version` | Version | + +Kurzoptionen lassen sich bündeln (`-rt 3`, `-dn`), der Wert von `-t` darf kleben +(`-t5`) oder folgen (`-t 5`). Ein `--` trennt Optionen von einem Pfad, der mit +`-` beginnt. Ohne Pfad gilt das aktuelle Verzeichnis. + +Sortiert wird aufsteigend, das Größte steht also direkt über dem Prompt und man +muss nicht scrollen. `-r` dreht das um, für Pipes und lange Listen. + +Bei `-t` steht die Sammelzeile immer am kleinen Ende: oben bei normaler +Sortierung, unten bei `-r`. + +| Exit | | +|---|---| +| 0 | alles gelesen | +| 1 | Pfad existiert nicht oder ist kein Verzeichnis | +| 2 | Option falsch benutzt | + +## Farbe und Balken + +Beides gilt am Terminal und entfällt in einer Pipe — `dx | sort` bekommt reinen +Text. `--color=always` schaltet beides wieder ein, für `less -R`. Wer nur eins +davon will, sagt es mit `--bar` / `--no-bar` ausdrücklich. `NO_COLOR` wird +beachtet. + +Die Farben folgen den `file_type`-Rollen aus `themes/eza/theme.yml`, damit +dieselbe Datei in `eza` und `dx` gleich aussieht: + +| | | | | | +|---|---|---|---|---| +| Verzeichnis | blue, fett | | Archive | orange | +| ausführbar | green, fett | | Bilder | violet | +| Symlink | pink | | Video | pink | +| Quelltext, Konfiguration | blue | | Audio | green | +| README, Makefile, `go.mod` | yellow | | Schlüssel, Zertifikate | red | +| Dokumente | white | | Kompilate (`.o`, `.dylib`) | grey | +| sonstige Dateien | white | | `.tmp`, `.bak`, `.DS_Store` | darker | + +Die Zahlenspalte ist grey, eine Null dark. Die Größenspalte trägt die +Größenrampe des eza-Themes — bis K green, M yellow, G orange, T red — und der +Balken nimmt die Farbe der Spalte auf, deren Anteil er zeigt: die der Größe, +mit `-n` die der Dateizahl. + +Der Balken ist zehn Zellen breit und rechnet in Achteln (`▏▎▍▌▋▊▉█`). Was +weniger als ein halbes Prozent ausmacht, bekommt keinen Strich und steht als +`<1%` da statt als geschöntes `0%`. Dadurch bleiben genau die Zeilen sichtbar, +auf die es ankommt. + +## Größen + +Die Schwellen sind die des Originals: ab 1024 der jeweiligen Einheit wird +umgeschaltet. Unterhalb von 10 kommt eine Nachkommastelle dazu, weil `2G` sonst +alles zwischen 1,5 und 2,4 Gigabyte bedeuten könnte. Ganze Bytes bleiben ganz. + + 8 4.9K 316M 1.1G + +## Was genau gezählt wird + +Gezählt werden **reguläre Dateien**, wie bei `find -type f`. Verzeichnisse, +Sockets, Fifos und Geräte zählen nicht mit, auch nicht die Verzeichnisse selbst. + +* Ein **Symlink auf ein Verzeichnis** wird nicht verfolgt und steht deshalb mit + `0` da — pink eingefärbt, damit die Null erklärt ist. +* Ein **Symlink auf eine Datei** auf oberster Ebene zählt mit der Größe seines + Ziels, ebenfalls pink. +* **Hardlinks** zählen so oft, wie sie vorkommen. In einem Baum mit + `rsync --link-dest` oder Time Machine sind die Zahlen dadurch zu hoch; für so + etwas ist `dx` nicht gedacht. +* Gemessen wird die **Dateigröße**, nicht der belegte Platz. Sparse-Dateien und + APFS-Klone erscheinen größer, als sie auf der Platte sind. +* Was sich nicht lesen lässt — gesperrte Verzeichnisse, tote Symlinks — wird + übersprungen und am Ende auf **stderr** gemeldet, damit die Tabelle heil + bleibt. +* `-d` lässt Dateien der obersten Ebene ganz weg, auch aus der Summe. + +Unterverzeichnisse werden nebenläufig durchlaufen, so viele auf einmal wie +Kerne da sind. Das Original startete pro Verzeichnis einen `find`-Prozess; auf +`~/go` sind das 0,19 s statt 0,34 s. + +## Unterschiede zum Perl-Original + +1. **Gleichstände sortieren nach Namen.** Perl ließ sie in der Reihenfolge des + Hashes, `dx -n` gab bei jedem Aufruf eine andere Reihenfolge aus. +2. **Ein ungültiger Pfad ist ein Fehler.** Perl listete stillschweigend das + aktuelle Verzeichnis — plausible, aber falsche Zahlen. +3. **Die Namensspalte ist eine Stelle breiter**, wenn der längste Eintrag ein + Verzeichnis ist. Perl zählte den angehängten `/` bei der Spaltenbreite nicht + mit, der längste Name klebte am Zahlenblock. +4. **Fehler stehen auf stderr**, statt als `find: … Permission denied` mitten in + der Ausgabe. +5. **Größen haben eine Nachkommastelle**, siehe oben. Das ist die einzige + Abweichung im alten Ausgabeformat; ohne Farbe und Balken ist der Rest + byteweise identisch. + +Neu sind `-r`, `-t`, die Balkenspalte, die Farben und die langen Optionen. + +## Aufbau + +| Datei | | +|---|---| +| `main.go` | Zählen, Sortieren, Formatieren, Optionen | +| `filetypes.go` | Endung und Dateiname → Farbe | +| `build.sh` | Bau für alle Plattformen, zählt `version.txt` hoch | +| `version.txt` | die zuletzt gebaute Version | +| `dx` | das Perl-Original von 2016 | + +Die Palette steht in `main.go` doppelt — einmal als Zahlenwerte, einmal in den +Kommentaren als Rolle. Quelle der Wahrheit bleibt `~/src/mwxcol/mwxcol.go`; +ändert sie sich dort, müssen die elf `rgb{…}` hier nachgezogen werden. diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..33e8921 --- /dev/null +++ b/build.sh @@ -0,0 +1,60 @@ +#!/bin/sh +# Build dx for the usual platforms into ./bin, auto-incrementing the patch +# version by 0.0.1 on every build. +# +# version.txt holds the currently built version. Each run increments the patch +# component, then builds every platform with that one version injected via +# -ldflags, and writes it back. So version.txt always reflects the version of +# the binaries just built, and all of them carry the same one. +# +# Override the platform list to build just one: +# PLATFORMS="linux/amd64" ./build.sh +# +# Windows is not in the list: dx compiles there, but the output leans on a +# terminal that speaks 24-bit colour and draws U+2588 block characters, so +# shipping it would promise more than has been tested. PLATFORMS can add it. +# +# -s -w drops the symbol table and DWARF info, -trimpath keeps build paths out +# of the binary; together they roughly halve it. Neither affects a panic trace. +set -e +cd "$(dirname "$0")" + +PLATFORMS=${PLATFORMS:-"darwin/arm64 darwin/amd64 linux/amd64 linux/arm64"} + +V=$(cat version.txt 2>/dev/null || echo 2.1.0) + +# split MAJOR.MINOR.PATCH and increment PATCH (no carry: 2.1.9 -> 2.1.10) +MAJOR=${V%%.*} +REST=${V#*.} +MINOR=${REST%%.*} +PATCH=${REST#*.} +PATCH=$((PATCH + 1)) +NV="$MAJOR.$MINOR.$PATCH" + +# NOTE: ./dx in the repo root is the Perl original from 2016, not a build +# artefact. Nothing here writes to or removes it — the build only touches ./bin. + +mkdir -p bin +HOST="$(go env GOOS)/$(go env GOARCH)" + +for p in $PLATFORMS; do + os=${p%/*} + arch=${p#*/} + out="bin/dx-$os-$arch" + + # CGO_ENABLED=0 throughout: it makes the cross builds work without a + # toolchain per target and the binaries static. dx only needs the + # filesystem, so nothing is lost by dropping cgo. + CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" \ + go build -trimpath -ldflags "-s -w -X main.version=$NV" -o "$out" . + + if [ "$p" = "$HOST" ]; then + ln -sf "dx-$os-$arch" bin/dx # the one for this machine + echo " $out -> bin/dx" + else + echo " $out" + fi +done + +echo "$NV" > version.txt +echo "built dx v$NV" diff --git a/filetypes.go b/filetypes.go new file mode 100644 index 0000000..62a49b5 --- /dev/null +++ b/filetypes.go @@ -0,0 +1,93 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + + "github.com/fatih/color" +) + +// Dateityp → Farbe, nach den file_type-Rollen des eza-Themes +// (~/src/mwxcol/themes/eza/theme.yml), damit dieselbe Datei in eza und dx +// dieselbe Farbe hat. +var byExt = map[string]*color.Color{} + +func register(c *color.Color, exts ...string) { + for _, e := range exts { + byExt[e] = c + } +} + +func init() { + // source — blau + register(cBlue, + "go", "c", "h", "cc", "cpp", "cxx", "hpp", "rs", "py", "pl", "pm", "t", + "sh", "bash", "zsh", "fish", "js", "mjs", "cjs", "ts", "jsx", "tsx", + "rb", "java", "kt", "swift", "m", "mm", "php", "lua", "sql", "r", "jl", + "hs", "el", "vim", "scala", "cs", "dart", "zig", "asm", "s", "f90", + "html", "htm", "css", "scss", "sass", "less", "vue", "svelte", + "json", "yml", "yaml", "toml", "ini", "conf", "cfg", "xml", "plist", + "md", "markdown", "tex", "awk", "sed") + // compressed — orange + register(cOrange, + "zip", "tar", "gz", "tgz", "bz2", "tbz", "xz", "txz", "zst", "7z", + "rar", "lz", "lzma", "z", "jar", "war", "dmg", "iso", "pkg", "deb", "rpm") + // image — violett + register(cViolet, + "jpg", "jpeg", "png", "gif", "bmp", "tif", "tiff", "svg", "webp", + "heic", "heif", "ico", "psd", "ai", "eps", "raw", "cr2", "nef", "dng", + "stl", "obj3d") + // video — pink + register(cPink, + "mp4", "mov", "avi", "mkv", "webm", "m4v", "mpg", "mpeg", "wmv", "flv", "m2ts") + // music / lossless — grün + register(cGreen, + "mp3", "aac", "m4a", "ogg", "opus", "wma", "flac", "wav", "aiff", "aif", "alac") + // crypto — rot + register(cRed, + "asc", "gpg", "pgp", "sig", "key", "pem", "crt", "cer", "p12", "kdbx") + // document — weiß + register(cWhite, + "pdf", "doc", "docx", "odt", "xls", "xlsx", "ods", "ppt", "pptx", "odp", + "epub", "rtf", "txt", "csv", "pages", "numbers") + // compiled — grau + register(cGrey, + "o", "a", "so", "dylib", "obj", "lib", "class", "pyc", "pyo", "wasm", "d") + // temp — dunkel + register(cDarker, + "tmp", "temp", "swp", "swo", "bak", "old", "orig", "rej", "lock", + "part", "crdownload", "log") +} + +// build — gelb (README, Makefile, …) +var byName = map[string]*color.Color{ + "makefile": cYellow, "gnumakefile": cYellow, "cmakelists.txt": cYellow, + "dockerfile": cYellow, "justfile": cYellow, "rakefile": cYellow, + "go.mod": cYellow, "go.sum": cYellow, "package.json": cYellow, + "package-lock.json": cYellow, "cargo.toml": cYellow, "cargo.lock": cYellow, + "license": cYellow, "copying": cYellow, + ".ds_store": cDarker, ".gitignore": cDark, ".gitattributes": cDark, +} + +func fileColor(name string, mode os.FileMode) *color.Color { + if mode&0o111 != 0 { // ausführbar schlägt alles andere, wie in eza + return cExecB + } + low := strings.ToLower(name) + if c, ok := byName[low]; ok { + return c + } + if strings.HasPrefix(low, "readme") || strings.HasPrefix(low, "makefile") { + return cYellow + } + if strings.HasSuffix(name, "~") { + return cDarker + } + if ext := strings.TrimPrefix(filepath.Ext(low), "."); ext != "" { + if c, ok := byExt[ext]; ok { + return c + } + } + return cWhite +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a51e022 --- /dev/null +++ b/go.mod @@ -0,0 +1,11 @@ +module dx + +go 1.26.1 + +require github.com/fatih/color v1.19.0 + +require ( + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + golang.org/x/sys v0.42.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..3d002f3 --- /dev/null +++ b/go.sum @@ -0,0 +1,9 @@ +github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= +github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/main.go b/main.go new file mode 100644 index 0000000..2317a85 --- /dev/null +++ b/main.go @@ -0,0 +1,504 @@ +// dx — count files and directories, mwx'2016 +// +// Go-Portierung des Perl-Originals (dx 1.0.2), Ausgabe eingefärbt nach der +// mwxcol-Palette (~/src/mwxcol). Ohne Terminal (Pipe, NO_COLOR, --color=never) +// bleibt die Ausgabe unverändert einfarbig. +package main + +import ( + "bufio" + "fmt" + "io/fs" + "os" + "path/filepath" + "runtime" + "sort" + "strconv" + "strings" + "sync" + "sync/atomic" + "unicode/utf8" + + "github.com/fatih/color" +) + +// version ist eine var (keine const), damit build.sh die gebaute Nummer per +// -ldflags "-X main.version=…" einsetzen kann. Der Wert hier erscheint nur bei +// einem nackten `go build`; die wirklich gebaute Version steht in version.txt. +var version = "2.1.0" + +// ------------------------------------------------------------------ Palette + +type rgb struct{ r, g, b int } + +var ( + white = rgb{0xf0, 0xf0, 0xea} + yellow = rgb{0xf9, 0xe2, 0xaf} + orange = rgb{0xfa, 0xb3, 0x87} + red = rgb{0xf3, 0x8b, 0xa8} + green = rgb{0x94, 0xe2, 0xd5} + blue = rgb{0xb4, 0xbe, 0xfe} + pink = rgb{0xf5, 0xb2, 0xf7} + violet = rgb{0xcb, 0xa6, 0xf7} + grey = rgb{0xaa, 0xaa, 0xbb} + dark = rgb{0x77, 0x77, 0x88} + darker = rgb{0x44, 0x44, 0x55} +) + +func plain(c rgb) *color.Color { return color.RGB(c.r, c.g, c.b) } +func bold(c rgb) *color.Color { return color.New(color.Bold).AddRGB(c.r, c.g, c.b) } + +// Rollen wie im eza-Theme, damit dx und eza dieselbe Datei gleich einfärben. +var ( + cWhite = plain(white) + cYellow = plain(yellow) + cOrange = plain(orange) + cRed = plain(red) + cGreen = plain(green) + cBlue = plain(blue) + cPink = plain(pink) + cViolet = plain(violet) + cGrey = plain(grey) + cDark = plain(dark) + cDarker = plain(darker) + cDirB = bold(blue) // Verzeichnis + cExecB = bold(green) // ausführbar + cWhiteB = bold(white) + cYellowB = bold(yellow) + cOrangeB = bold(orange) + cRedB = bold(red) + cGreenB = bold(green) + cDarkB = bold(dark) +) + +// ------------------------------------------------------------------- Zählen + +type entry struct { + name string // Anzeigename, Verzeichnisse mit "/" + col *color.Color + n int64 + size int64 + agg bool // Sammelzeile aus -t, kein echter Eintrag +} + +type result struct { + entries []entry + totalN int64 + totalS int64 + unreadbl int64 +} + +func scan(dir string, onlyDirs bool) (result, error) { + des, err := os.ReadDir(dir) + if err != nil { + return result{}, err + } + + var res result + var mu sync.Mutex + var wg sync.WaitGroup + sem := make(chan struct{}, runtime.NumCPU()) + + for _, de := range des { + name := de.Name() + path := filepath.Join(dir, name) + + // Perls -d/-f folgen Symlinks; ein toter Link fällt durch beide Tests. + st, err := os.Stat(path) + if err != nil { + atomic.AddInt64(&res.unreadbl, 1) + continue + } + isLink := de.Type()&os.ModeSymlink != 0 + + switch { + case st.IsDir(): + wg.Go(func() { + sem <- struct{}{} + defer func() { <-sem }() + + // WalkDir folgt Symlinks nicht — wie `find -type f`. + n, size := walk(path, &res.unreadbl) + + col := cDirB + if isLink { + col = cPink + } + mu.Lock() + res.entries = append(res.entries, entry{name: name + "/", col: col, n: n, size: size}) + res.totalN += n + res.totalS += size + mu.Unlock() + }) + + case st.Mode().IsRegular() && !onlyDirs: + col := fileColor(name, st.Mode()) + if isLink { + col = cPink + } + mu.Lock() + res.entries = append(res.entries, entry{name: name, col: col, n: 1, size: st.Size()}) + res.totalN++ + res.totalS += st.Size() + mu.Unlock() + } + } + wg.Wait() + + return res, nil +} + +func walk(root string, unreadable *int64) (n, size int64) { + filepath.WalkDir(root, func(_ string, d fs.DirEntry, err error) error { + if err != nil { + atomic.AddInt64(unreadable, 1) + return nil + } + if !d.Type().IsRegular() { // wie find -type f + return nil + } + info, err := d.Info() + if err != nil { + atomic.AddInt64(unreadable, 1) + return nil + } + n++ + size += info.Size() + return nil + }) + return +} + +// ------------------------------------------------------------------ Ausgabe + +// Schwellen wie im Perl-Original; unterhalb von 10 der jeweiligen Einheit +// jedoch mit einer Nachkommastelle — "1.9G" statt "2G". +func formSize(b int64) string { + const k = 1024 + f := float64(b) + switch { + case b > k*k*k*k: + return scaled(f/k/k/k/k, "T") + case b > k*k*k: + return scaled(f/k/k/k, "G") + case b > k*k: + return scaled(f/k/k, "M") + case b > k: + return scaled(f/k, "K") + default: + return fmt.Sprintf("%.0f", f) // ganze Bytes, nie gebrochen + } +} + +func scaled(v float64, unit string) string { + if v < 9.95 { + return fmt.Sprintf("%.1f%s", v, unit) + } + return fmt.Sprintf("%.0f%s", v, unit) +} + +// Größenrampe wie im eza-Theme: byte/kilo grün, mega gelb, giga orange, huge rot. +func sizeColor(b int64, strong bool) *color.Color { + switch { + case b == 0: + if strong { + return cDarkB + } + return cDark + case b > 1024*1024*1024*1024: + if strong { + return cRedB + } + return cRed + case b > 1024*1024*1024: + if strong { + return cOrangeB + } + return cOrange + case b > 1024*1024: + if strong { + return cYellowB + } + return cYellow + default: + if strong { + return cGreenB + } + return cGreen + } +} + +func countColor(n int64) *color.Color { + if n == 0 { + return cDark + } + return cGrey +} + +func pad(s string, w int) string { + if n := w - utf8.RuneCountInString(s); n > 0 { + return strings.Repeat(" ", n) + } + return "" +} + +// Anteilsbalken in Achtelschritten, feste Breite barCells. +const barCells = 10 + +var eighths = []rune{' ', '▏', '▎', '▍', '▌', '▋', '▊', '▉'} + +func bar(frac float64) string { + if frac > 1 { + frac = 1 + } + if !(frac > 0) { // fängt auch NaN + return strings.Repeat(" ", barCells) + } + units := int(frac*barCells*8 + 0.5) + full, rem := units/8, units%8 + + var b strings.Builder + b.WriteString(strings.Repeat("█", full)) + used := full + if rem > 0 { + b.WriteRune(eighths[rem]) + used++ + } + b.WriteString(strings.Repeat(" ", barCells-used)) + return b.String() +} + +func percent(frac float64) string { + p := frac * 100 + if p > 0 && p < 0.5 { + return " <1%" + } + return fmt.Sprintf("%3.0f%%", p) +} + +func printDir(w *bufio.Writer, dir string, res result, o opts) { + metric := func(e entry) int64 { + if o.byCount { + return e.n + } + return e.size + } + sort.SliceStable(res.entries, func(i, j int) bool { + a, b := res.entries[i], res.entries[j] + if ka, kb := metric(a), metric(b); ka != kb { + return ka < kb + } + return a.name < b.name // Perl ließ Gleichstände der Hash-Ordnung + }) + + // -t N: nur die N größten zeigen, der Rest wird zu einer Zeile. + rows := res.entries + var rest *entry + if o.top > 0 && len(rows) > o.top { + cut := len(rows) - o.top + var agg entry + for _, e := range rows[:cut] { + agg.n += e.n + agg.size += e.size + } + agg.name = fmt.Sprintf("… %d more", cut) + agg.col, agg.agg = cDark, true + rest, rows = &agg, rows[cut:] + } + if o.reverse { + for i, j := 0, len(rows)-1; i < j; i, j = i+1, j-1 { + rows[i], rows[j] = rows[j], rows[i] + } + } + if rest != nil { // Restzeile bleibt am kleinen Ende + if o.reverse { + rows = append(rows, *rest) + } else { + rows = append([]entry{*rest}, rows...) + } + } + + total := res.totalS + if o.byCount { + total = res.totalN + } + + ml := utf8.RuneCountInString(dir) + for _, e := range rows { + if n := utf8.RuneCountInString(e.name); n > ml { + ml = n + } + } + width := ml + 1 + + fmt.Fprintf(w, "%s%s %s %s\n", + cDirB.Sprint(dir), pad(dir, width), + cWhiteB.Sprintf("%8d", res.totalN), + sizeColor(res.totalS, true).Sprintf("%8s", formSize(res.totalS))) + + for _, e := range rows { + fmt.Fprintf(w, " %s%s %s %s", + e.col.Sprint(e.name), pad(e.name, width), + countColor(e.n).Sprintf("%8d", e.n), + sizeColor(e.size, false).Sprintf("%8s", formSize(e.size))) + + if o.bar { + var frac float64 + if total > 0 { + frac = float64(metric(e)) / float64(total) + } + // Balken trägt die Farbe der Spalte, deren Anteil er zeigt. + col := sizeColor(e.size, false) + if o.byCount { + col = countColor(e.n) + } + if e.agg { // Sammelzeile bleibt zurückhaltend + col = cDark + } + fmt.Fprintf(w, " %s %s", + col.Sprint(bar(frac)), cGrey.Sprint(percent(frac))) + } + fmt.Fprintln(w) + } +} + +// --------------------------------------------------------------------- CLI + +type opts struct { + byCount bool // -n + onlyDirs bool // -d + reverse bool // -r + top int // -t N, 0 = alle + bar bool +} + +func usage(w *os.File) { + fmt.Fprintf(w, `dx %s, count files and directories, mwx'2016 +usage: dx [-n] [-d] [-r] [-t N] [path] + -n sort by number of files (default by size) + -d count only directories + -r reverse order (largest first) + -t N show only the N largest, sum up the rest + -h this help + --bar / --no-bar share bar and percentage (default: when colorized) + --color=auto|always|never colorize output (default auto) + --version +`, version) +} + +func die(format string, a ...any) { + fmt.Fprintf(os.Stderr, "dx: "+format+"\n", a...) + os.Exit(2) +} + +func main() { + var o opts + var barSet bool // sonst folgt der Balken der Farbentscheidung + var dir string + when := "auto" + + args := os.Args[1:] + for i := 0; i < len(args); i++ { + a := args[i] + switch { + case a == "--": + if i+1 < len(args) { + dir = args[i+1] + } + i = len(args) + case a == "--help": + usage(os.Stdout) + return + case a == "--version": + fmt.Printf("dx %s\n", version) + return + case a == "--number": + o.byCount = true + case a == "--dirs": + o.onlyDirs = true + case a == "--reverse": + o.reverse = true + case a == "--bar": + o.bar, barSet = true, true + case a == "--no-bar": + o.bar, barSet = false, true + case a == "--no-color": + when = "never" + case strings.HasPrefix(a, "--top="): + o.top = mustNum(strings.TrimPrefix(a, "--top=")) + case strings.HasPrefix(a, "--color="): + when = strings.TrimPrefix(a, "--color=") + case len(a) > 1 && a[0] == '-': + flags := a[1:] + for j := 0; j < len(flags); j++ { // Bündelung wie Getopt::Std: -dn + switch flags[j] { + case 'n': + o.byCount = true + case 'd': + o.onlyDirs = true + case 'r': + o.reverse = true + case 'h': + usage(os.Stdout) + return + case 't': // Wert klebt am Flag (-t5) oder folgt (-t 5) + val := flags[j+1:] + if val == "" { + if i+1 >= len(args) { + die("option requires an argument -- t") + } + i++ + val = args[i] + } + o.top = mustNum(val) + j = len(flags) + default: + fmt.Fprintf(os.Stderr, "dx: unknown option -- %c\n", flags[j]) + usage(os.Stderr) + os.Exit(2) + } + } + default: + dir = a + } + } + + switch when { + case "always": + color.NoColor = false + case "never": + color.NoColor = true + case "auto": // fatih/color entscheidet per isatty und NO_COLOR + default: + die("--color: expected auto|always|never, got %q", when) + } + if !barSet { + o.bar = !color.NoColor + } + + if dir == "" { + dir = "." + } + + res, err := scan(dir, o.onlyDirs) + if err != nil { + fmt.Fprintf(os.Stderr, "dx: %v\n", err) + os.Exit(1) + } + + w := bufio.NewWriter(os.Stdout) + printDir(w, dir, res, o) + w.Flush() + + if res.unreadbl > 0 { + fmt.Fprintln(os.Stderr, cDark.Sprintf("dx: %d unreadable entries skipped", res.unreadbl)) + } +} + +func mustNum(s string) int { + n, err := strconv.Atoi(s) + if err != nil || n < 1 { + die("-t: expected a positive number, got %q", s) + } + return n +} diff --git a/version.txt b/version.txt new file mode 100644 index 0000000..7d2ed7c --- /dev/null +++ b/version.txt @@ -0,0 +1 @@ +2.1.4