Lay overview out as a table
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>
This commit is contained in:
+96
-46
@@ -18,6 +18,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// projStatus is the collected state of one project for the overview.
|
||||
@@ -74,19 +75,18 @@ func overviewAll() {
|
||||
rows := scanProjects(repos)
|
||||
srv := <-srvCh
|
||||
|
||||
width := 0
|
||||
for _, n := range repos {
|
||||
if len(n) > width {
|
||||
width = len(n)
|
||||
}
|
||||
}
|
||||
// what needs doing first, alphabetical within each group
|
||||
sort.SliceStable(rows, func(i, j int) bool {
|
||||
return attentionRank(rows[i]) < attentionRank(rows[j])
|
||||
})
|
||||
|
||||
if len(rows) == 0 {
|
||||
fmt.Println(col(cGray, "no git projects under "+BASE))
|
||||
}
|
||||
w := measureOverview(rows)
|
||||
dirtyN, syncN := 0, 0
|
||||
for _, r := range rows {
|
||||
fmt.Println(formatProjStatus(r, width))
|
||||
fmt.Println(formatProjStatus(r, w))
|
||||
if r.dirty {
|
||||
dirtyN++
|
||||
}
|
||||
@@ -152,13 +152,15 @@ func reportInventory(local, server []string, err error) {
|
||||
sort.Strings(missingRemote)
|
||||
sort.Strings(missingLocal)
|
||||
|
||||
// the two labels share a column so their contents line up as well
|
||||
const labelW = 24
|
||||
if len(missingRemote) > 0 {
|
||||
fmt.Printf("%s %s\n", col(cYellow, " not on the git server:"),
|
||||
strings.Join(missingRemote, ", ")+col(cGray, " (init)"))
|
||||
fmt.Printf(" %s %s\n", col(cYellow, padRight("not on the server (init)", labelW)),
|
||||
col(cGray, strings.Join(missingRemote, ", ")))
|
||||
}
|
||||
if len(missingLocal) > 0 {
|
||||
fmt.Printf("%s %s\n", col(cCyan, " not cloned here: "),
|
||||
strings.Join(missingLocal, ", ")+col(cGray, " (clone)"))
|
||||
fmt.Printf(" %s %s\n", col(cCyan, padRight("not cloned here (clone)", labelW)),
|
||||
col(cGray, strings.Join(missingLocal, ", ")))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,50 +246,98 @@ func configuredMirrors(dir string) []string {
|
||||
return found
|
||||
}
|
||||
|
||||
// formatProjStatus renders one aligned overview row.
|
||||
func formatProjStatus(s projStatus, width int) string {
|
||||
var marks []string
|
||||
// overviewWidths are the column widths of the overview table, measured from the
|
||||
// rows so every field starts at the same place. Ragged columns were what made
|
||||
// the old one-line-per-project output hard to read.
|
||||
type overviewWidths struct{ label, sync, host int }
|
||||
|
||||
// measureOverview sizes the columns for a set of rows.
|
||||
func measureOverview(rows []projStatus) overviewWidths {
|
||||
var w overviewWidths
|
||||
for _, r := range rows {
|
||||
w.label = max(w.label, utf8.RuneCountInString(projLabel(r)))
|
||||
w.sync = max(w.sync, utf8.RuneCountInString(syncState(r)))
|
||||
w.host = max(w.host, utf8.RuneCountInString(r.lastHost))
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
// projLabel is the first column: the project, with its branch appended when it
|
||||
// is not the usual one. Keeping the branch attached to the name costs no extra
|
||||
// column and keeps the table narrow.
|
||||
func projLabel(s projStatus) string {
|
||||
if s.branch != "" && s.branch != "-" && s.branch != "master" && s.branch != "main" {
|
||||
return s.name + " (" + s.branch + ")"
|
||||
}
|
||||
return s.name
|
||||
}
|
||||
|
||||
// syncState renders the relation to the upstream as one short field: ahead,
|
||||
// behind, both, in sync, or "–" for a branch that tracks nothing. The old
|
||||
// spelled-out "(no upstream)" was fifteen columns wide and pushed every
|
||||
// following field out of line.
|
||||
func syncState(s projStatus) string {
|
||||
switch {
|
||||
case s.ahead > 0 && s.behind > 0:
|
||||
return fmt.Sprintf("↑%d↓%d", s.ahead, s.behind)
|
||||
case s.ahead > 0:
|
||||
return fmt.Sprintf("↑%d", s.ahead)
|
||||
case s.behind > 0:
|
||||
return fmt.Sprintf("↓%d", s.behind)
|
||||
case s.hasUpstream:
|
||||
return "✓"
|
||||
default:
|
||||
return "–"
|
||||
}
|
||||
}
|
||||
|
||||
// syncColor weights a row visually: anything needing action is coloured, a
|
||||
// project that is clean and in sync recedes into grey.
|
||||
func syncColor(s projStatus) string {
|
||||
switch {
|
||||
case s.behind > 0:
|
||||
return cRed
|
||||
case s.ahead > 0:
|
||||
return cGreen
|
||||
default:
|
||||
return cGray
|
||||
}
|
||||
}
|
||||
|
||||
// attentionRank sorts the rows worth acting on to the top. With many projects,
|
||||
// scanning the whole list for the two dirty ones is the actual work.
|
||||
func attentionRank(s projStatus) int {
|
||||
if s.dirty || s.ahead > 0 || s.behind > 0 {
|
||||
return 0
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
// formatProjStatus renders one row of the overview table.
|
||||
func formatProjStatus(s projStatus, w overviewWidths) string {
|
||||
dirty := " "
|
||||
if s.dirty {
|
||||
marks = append(marks, col(cYellow, "*"))
|
||||
}
|
||||
if s.ahead > 0 {
|
||||
marks = append(marks, col(cGreen, fmt.Sprintf("↑%d", s.ahead)))
|
||||
}
|
||||
if s.behind > 0 {
|
||||
marks = append(marks, col(cRed, fmt.Sprintf("↓%d", s.behind)))
|
||||
}
|
||||
state := strings.Join(marks, " ")
|
||||
if state == "" {
|
||||
if s.hasUpstream {
|
||||
state = col(cGreen, "✓")
|
||||
} else {
|
||||
state = col(cGray, "✓ (no upstream)")
|
||||
}
|
||||
dirty = "*"
|
||||
}
|
||||
|
||||
line := " " + col(cGreen, padRight(s.name, width+2)) + state
|
||||
if s.branch != "master" && s.branch != "main" && s.branch != "-" {
|
||||
line += col(cGray, " ("+s.branch+")")
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString(" ")
|
||||
b.WriteString(col(cGreen, padRight(projLabel(s), w.label)))
|
||||
b.WriteString(" " + col(cYellow, dirty) + " ")
|
||||
b.WriteString(col(syncColor(s), padRight(syncState(s), w.sync)))
|
||||
|
||||
var tail []string
|
||||
if s.lastHost != "" || !s.lastWhen.IsZero() {
|
||||
t := s.lastHost
|
||||
if w.host > 0 {
|
||||
age := ""
|
||||
if !s.lastWhen.IsZero() {
|
||||
if t != "" {
|
||||
t += " "
|
||||
}
|
||||
t += shortAge(time.Since(s.lastWhen))
|
||||
age = shortAge(time.Since(s.lastWhen))
|
||||
}
|
||||
tail = append(tail, t)
|
||||
b.WriteString(" " + col(cGray, padRight(s.lastHost, w.host)))
|
||||
b.WriteString(" " + col(cGray, fmt.Sprintf("%4s", age)))
|
||||
}
|
||||
if len(s.mirrors) > 0 {
|
||||
tail = append(tail, "→ "+strings.Join(s.mirrors, ","))
|
||||
b.WriteString(col(cGray, " → "+strings.Join(s.mirrors, " ")))
|
||||
}
|
||||
if len(tail) > 0 {
|
||||
line += col(cGray, " · "+strings.Join(tail, " · "))
|
||||
}
|
||||
return line
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// shortAge renders a duration compactly: 90s -> "1m", 36h -> "1d".
|
||||
|
||||
Reference in New Issue
Block a user