commit bc197fe752a1fca3e8758aef706963dab87f908b Author: Michael Wesemann Date: Mon Aug 10 16:35:51 2026 +0200 initial commit [141.14.140.180,mike] 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