readme.go

December 5, 2025 · View on GitHub

// .-. .-. // | | | | // | --.| --. // ----'----' // // ll – a small utility to list files in the current directory. // // # Why? // Because I wanted to display files in columns with git status. // // # Rationalize // One entry per line for lots of files can't be fitted on a screen // and requires scrolling. With the multi-column layout, space can be // used more efficiently. At the same time, git status information is // also often needed.

package main

import ( "bytes" "fmt" "io/ioutil" "math" "os" "os/exec" "path/filepath" . "strings" "sync" "time"

"golang.org/x/sys/unix"

)

const ( modified = "\033[0;34m%s\033[0m" added = "\033[0;32m%s\033[0m" untracked = "\033[0;31m%s\033[0m" bold = "\033[1m%v\033[0m" )

var ( spinner = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} sizes = []string{"B", "kB", "MB", "GB", "TB", "PB", "EB"} base = float64(1000) )

func main() { if len(os.Args) == 2 { ll(os.Args[1]) return }

if len(os.Args) > 2 {
	for i := 1; i < len(os.Args); i++ {
		path, _ := filepath.Abs(os.Args[i])
		printInfo(fileInfo(path), path)
	}
	return
}

pwd, err := os.Getwd()
if err != nil {
	panic(err)
}
ll(pwd)

}

func ll(cwd string) { // Maybe it is and argument, so get absolute path. cwd, _ = filepath.Abs(cwd)

// Is it a file?
if fi := fileInfo(cwd); !fi.IsDir() {
	printInfo(fi, cwd)
	return
}

// ReadDir already returns files and dirs sorted by filename.
files, err := ioutil.ReadDir(cwd)
if err != nil {
	fmt.Fprintln(os.Stderr, err)
	os.Exit(1)
}
if len(files) == 0 {
	return
}

// We need terminal size to nicely fit on screen.
var width, height int
ws, err := unix.IoctlGetWinsize(int(os.Stdout.Fd()), unix.TIOCGWINSZ)
if err != nil || ws == nil {
	width, height = 80, 60
} else {
	width, height = int(ws.Col), int(ws.Row)
}

// If it's possible to fit all files in one column on half of screen, just use one column.
// Otherwise let's squeeze listing in half of screen.
columns := len(files)/(height/2) + 1

// Gonna keep file names and format string for git status.
modes := map[string]string{}

// If stdout of ll piped, use ls behavior: one line, no colors.
fi, err := os.Stdout.Stat()
if err != nil {
	panic(err)
}
if (fi.Mode() & os.ModeCharDevice) == 0 {
	columns = 1
} else {
	status := gitStatus()
	for _, file := range files {
		name := file.Name()
		if file.IsDir() {
			name += "/"
		}
		// gitStatus returns file names of modified files from repo root.
		fullPath := filepath.Join(cwd, name)
		for path, mode := range status {
			if subPath(path, fullPath) {
				if mode[0] == '?' || mode[1] == '?' {
					modes[name] = untracked
				} else if mode[0] == 'A' || mode[1] == 'A' {
					modes[name] = added
				} else if mode[0] == 'M' || mode[1] == 'M' {
					modes[name] = modified
				}
			}
		}
	}
}

start: // Let's try to fit everything in terminal width with this many columns. // If we are not able to do it, decrease column number and goto start. rows := int(math.Ceil(float64(len(files)) / float64(columns))) names := make([][]string, columns) n := 0 for i := 0; i < columns; i++ { names[i] = make([]string, rows) // Columns size is going to be of max file name size. max := 0 for j := 0; j < rows; j++ { name := "" if n < len(files) { name = files[n].Name() if files[n].IsDir() { // Dir should have slash at end. name += "/" } n++ } if max < len(name) { max = len(name) } names[i][j] = name } // Append spaces to make all names in one column of same size. for j := 0; j < rows; j++ { names[i][j] += Repeat(" ", max-len(names[i][j])) } }

const separator = "    " // Separator between columns.
for j := 0; j < rows; j++ {
	row := make([]string, columns)
	for i := 0; i < columns; i++ {
		row[i] = names[i][j]
	}
	if len(Join(row, separator)) > width && columns > 1 {
		// Yep. No luck, let's decrease number of columns and try one more time.
		columns--
		goto start
	}
}

// Let's add colors from git status to file names.
output := make([]string, rows)
for j := 0; j < rows; j++ {
	row := make([]string, columns)
	for i := 0; i < columns; i++ {
		f, ok := modes[TrimRight(names[i][j], " ")]
		if !ok {
			f = "%s"
		}
		row[i] = fmt.Sprintf(f, names[i][j])
	}
	output[j] = Join(row, separator)
}
fmt.Println(Join(output, "\n"))

}

func subPath(path string, fullPath string) bool { p := Split(path, "/") for i, s := range Split(fullPath, "/") { if i >= len(p) { return false } if p[i] != s { return false } } return true }

func gitRepo() (string, error) { cmd := exec.Command("git", "rev-parse", "--show-toplevel") var out bytes.Buffer cmd.Stdout = &out err := cmd.Run() return Trim(out.String(), "\n"), err }

func gitStatus() map[string]string { repo, err := gitRepo() if err != nil { return nil } cmd := exec.Command("git", "status", "--porcelain=v1") var out bytes.Buffer cmd.Stdout = &out err = cmd.Run() if err != nil { return nil } m := map[string]string{} for _, line := range Split(Trim(out.String(), "\n"), "\n") { if len(line) == 0 { continue } m[filepath.Join(repo, line[3:])] = line[:2] } return m }

func printInfo(fi os.FileInfo, path string) { name := fi.Name() size := fi.Size() if fi.IsDir() { name += "/" done := make(chan bool) wg := sync.WaitGroup{} wg.Add(1) go func() { defer wg.Done() i, t := 0, time.Tick(100*time.Millisecond) for { select { case <-t: fmt.Printf("\r%v\t%v", spinner[i%len(spinner)], name) i++ case <-done: fmt.Print("\r") return } } }() size, _ = dirSize(path) done <- true wg.Wait() } fmt.Printf("%v\t%v\n", toHuman(size), name) }

func fileInfo(path string) os.FileInfo { fi, err := os.Stat(path) if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } return fi }

func toHuman(s int64) string { if s < 10 { value := fmt.Sprintf(bold, s) return fmt.Sprintf(" %v B", value) } e := math.Floor(math.Log(float64(s)) / math.Log(base)) suffix := sizes[int(e)] val := math.Floor(float64(s)/math.Pow(base, e)*10+0.5) / 10 f := "%3.0f" if val < 10 { f = "%3.1f" }

value := fmt.Sprintf(bold, fmt.Sprintf(f, val))
return fmt.Sprintf("%v %v", value, suffix)

}

func dirSize(path string) (int64, error) { var size int64 err := filepath.Walk(path, func(_ string, info os.FileInfo, err error) error { if err != nil { return err } if !info.IsDir() { size += info.Size() } return err }) return size, err }