-
Notifications
You must be signed in to change notification settings - Fork 1
antonmedv/ll
Folders and files
| Name | Name | Last commit message | Last commit date | |
|---|---|---|---|---|
Repository files navigation
// .-. .-.
// | | | |
// | `--.| `--.
// `----'`----'
//
// 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
}
About
Opinionated ls rewrite in Go 🧦