commit 54c849424134bf2c13af802d0a19c97472c955fe Author: Michael Wesemann Date: Fri Aug 7 14:41:01 2026 +0200 initial commit [141.14.140.180,mike] diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7f023f5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.DS_Store +.AppleDouble +.LSOverride +._* +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes diff --git a/bin/dx b/bin/dx new file mode 100755 index 0000000..b0281e5 Binary files /dev/null and b/bin/dx differ diff --git a/dx b/dx new file mode 100755 index 0000000..a4312ed --- /dev/null +++ b/dx @@ -0,0 +1,95 @@ +#!/usr/bin/perl +######################################################################## count files and directories (mwx'2016) +use Getopt::Std;getopts('dnh'); + +$version='1.0.2'; + +if ($opt_h) {;&help;exit;} + +if (-d $ARGV[0]) { + &dx($ARGV[0]); +} else { + &dx("."); +} + +sub dx() { + my($dir)=@_; + $ml=length($dir); + + opendir(DIR,$dir); + while($f=readdir(DIR)) { + if ($f ne '.' && $f ne '..') { + + if (-d "$dir/$f") { + $n=0;$s=0; + open(IN,"find '$dir/$f' -type f |"); + while() { + chomp(); + $s+=(stat($_))[7]; + $ts+=(stat($_))[7]; + $n++; + $tn++; + } + close(IN); + $N{"$f/"}=$n; + $S{"$f/"}=$s; + $ml=length($f) if (length($f)>$ml); + } + + if (-f "$dir/$f" && !$opt_d) { + $S{$f}+=(stat("$dir/$f"))[7]; + $ts+=(stat("$dir/$f"))[7]; + $N{$f}++; + $tn++; + $ml=length($f) if (length($f)>$ml); + } + + } + } + closedir(DIR); + + $frm=sprintf "%%-%ds",$ml+1; + $out=sprintf "$frm %8d %8s\n",$dir,$tn,&formsize($ts); + + if ($opt_n) { + for $f (sort { $N{$a} <=> $N{$b} } (keys(%N))) { + $out.=sprintf " $frm %8d %8s\n",$f,$N{$f},&formsize($S{$f}); + } + } else { + for $f (sort { $S{$a} <=> $S{$b} } (keys(%S))) { + $out.=sprintf " $frm %8d %8s\n",$f,$N{$f},&formsize($S{$f}); + } + } + print $out; +} + +####################################################################################################### SUPPORT + +sub formsize() { + my($b)=@_; + if ($b>1024*1024*1024*1024) { + $s=sprintf("%.0fT",$b/1024/1024/1024/1024) + } elsif ($b>1024*1024*1024) { + $s=sprintf("%.0fG",$b/1024/1024/1024) + } elsif ($b>1024*1024) { + $s=sprintf("%.0fM",$b/1024/1024) + } elsif ($b>1024) { + $s=sprintf("%.0fK",$b/1024) + } else { + $s=sprintf("%.0f",$b) + } + return $s; +} + +sub help() { +print <<"ENDOFHELP"; +dx ${version}, count files and directories, mwx'2016 +usage: dx [-n] [-d] [path] + -n sort by number of files (default by size) + -d count only directories + -h this help +ENDOFHELP +} + +########################################################################################################### END + 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..11c847e --- /dev/null +++ b/main.go @@ -0,0 +1,501 @@ +// 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" +) + +const 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 +}