The status field was not a column: "*", "↑2", "✓" and "✓ (no upstream)" are four different widths, so everything after them started somewhere else on every line and the eye had to hunt along each row instead of going down one. Each field now has its own measured column: name (with the branch appended when it is not master/main), a one-character dirty marker, the sync state, host and age, then the mirrors. "(no upstream)" was fifteen columns wide for something that is not even a problem, and is now "–". Colour weights the row rather than decorating it -- a project that is clean and in sync goes grey, the arrows and the dirty marker keep their colour -- and the rows needing action sort to the top, alphabetically within each group so positions stay predictable. padRight counted bytes, which was fine while everything it padded was ASCII; the arrows and check marks are three bytes and one column, so it counts runes now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
94 lines
2.7 KiB
Go
94 lines
2.7 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
// Colors for the prompt, banner and output, using the Catppuccin Mocha palette
|
|
// as 24-bit truecolor escapes
|
|
// (https://terminalcolors.com/themes/catppuccin/mocha/).
|
|
const (
|
|
cReset = "\033[0m"
|
|
cBold = "\033[1m"
|
|
cDim = "\033[2m"
|
|
|
|
cGreen = "\033[38;2;166;227;161m" // Green #a6e3a1
|
|
cYellow = "\033[38;2;249;226;175m" // Yellow #f9e2af
|
|
cRed = "\033[38;2;243;139;168m" // Red #f38ba8
|
|
cCyan = "\033[38;2;148;226;213m" // Teal #94e2d5
|
|
cPurple = "\033[38;2;203;166;247m" // Mauve #cba6f7
|
|
cWhite = "\033[38;2;205;214;244m" // Text #cdd6f4
|
|
cGray = "\033[38;2;108;112;134m" // Overlay0 #6c7086
|
|
)
|
|
|
|
// col wraps s in color c, but only when color output is enabled.
|
|
func col(c, s string) string {
|
|
if useColor {
|
|
return c + s + cReset
|
|
}
|
|
return s
|
|
}
|
|
|
|
// errorln prints an error/status message in red (when color is enabled).
|
|
func errorln(msg string) {
|
|
fmt.Println(col(cRed, msg))
|
|
}
|
|
|
|
// padRight pads s with trailing spaces to a width of n columns. It counts
|
|
// runes, not bytes: the overview pads fields holding ↑ ↓ ✓, each of which is
|
|
// one column wide but three bytes long.
|
|
func padRight(s string, n int) string {
|
|
if l := utf8.RuneCountInString(s); l < n {
|
|
return s + strings.Repeat(" ", n-l)
|
|
}
|
|
return s
|
|
}
|
|
|
|
// formatRepoList renders the server listing for `list`: the name first, in a
|
|
// column wide enough for the longest one, then the date, and for archives the
|
|
// size. Names come first because that is what the eye scans for; putting the
|
|
// ragged date there instead is what made the old output hard to read.
|
|
//
|
|
// The order is left as it arrives: `ls -ltr` sorts by modification time, and
|
|
// `push` touches the bare repository, so the most recently worked-on project
|
|
// ends up closest to the prompt.
|
|
func formatRepoList(entries []lsEntry, withSize bool) string {
|
|
width := 0
|
|
for _, e := range entries {
|
|
if len(e.name) > width {
|
|
width = len(e.name)
|
|
}
|
|
}
|
|
var b strings.Builder
|
|
for _, e := range entries {
|
|
fmt.Fprintf(&b, " %s %s", col(cGreen, padRight(e.name, width)), col(cYellow, e.date))
|
|
if withSize {
|
|
fmt.Fprintf(&b, " %s", col(cGray, fmt.Sprintf("%7s", humanSize(e.size))))
|
|
}
|
|
b.WriteByte('\n')
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// humanSize renders a byte count compactly, the way `ls -h` does: a decimal
|
|
// only while it still carries information, so "3.2M" but "512K".
|
|
func humanSize(n int64) string {
|
|
const unit = 1024
|
|
if n < unit {
|
|
return strconv.FormatInt(n, 10) + "B"
|
|
}
|
|
div, exp := int64(unit), 0
|
|
for v := n / unit; v >= unit && exp < 4; v /= unit {
|
|
div *= unit
|
|
exp++
|
|
}
|
|
v := float64(n) / float64(div)
|
|
if v < 10 {
|
|
return fmt.Sprintf("%.1f%c", v, "KMGTP"[exp])
|
|
}
|
|
return fmt.Sprintf("%.0f%c", v, "KMGTP"[exp])
|
|
}
|