feat(output): improve table readability — truncate values, limit columns, summarize nested maps

This commit is contained in:
刘焱 2026-06-08 19:44:22 +08:00
parent 25ee6cc5fb
commit 295eda5a4e
2 changed files with 258 additions and 12 deletions

View File

@ -6,6 +6,7 @@ import (
"io"
"os"
"reflect"
"strconv"
"strings"
"text/tabwriter"
"unicode"
@ -130,6 +131,11 @@ func hasOnlyNestedMaps(m map[string]interface{}) bool {
return false
}
// maxTableColumns caps the number of columns rendered in a slice table so the
// output stays readable on standard terminal widths. Fields beyond this limit
// are dropped (priority fields are kept first; see collectKeys).
const maxTableColumns = 8
func printSliceTable(w io.Writer, items []interface{}) error {
if len(items) == 0 {
fmt.Fprintln(w, "No results")
@ -145,7 +151,15 @@ func printSliceTable(w io.Writer, items []interface{}) error {
}
headers := collectKeys(first)
tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
omitted := 0
if len(headers) > maxTableColumns {
omitted = len(headers) - maxTableColumns
headers = headers[:maxTableColumns]
}
// minwidth=0, tabwidth=2, padding=2 -> tighter columns than the old
// minwidth=4 setting, which helps when there are several wide fields.
tw := tabwriter.NewWriter(w, 0, 2, 2, ' ', 0)
// Print headers
fmt.Fprintln(tw, strings.Join(headers, "\t"))
@ -167,7 +181,13 @@ func printSliceTable(w io.Writer, items []interface{}) error {
}
fmt.Fprintln(tw, strings.Join(vals, "\t"))
}
return tw.Flush()
tw.Flush()
if omitted > 0 {
// Note appended after the table so it does not perturb column alignment.
fmt.Fprintf(w, "(已省略 %d 列)\n", omitted)
}
return nil
}
func printMapTable(w io.Writer, m map[string]interface{}) error {
@ -180,12 +200,20 @@ func printMapTable(w io.Writer, m map[string]interface{}) error {
return tw.Flush()
}
// preferredHeaders lists keys that are surfaced first (and therefore survive the
// maxTableColumns cap). Keep the most human-meaningful identity/status fields
// at the top so that even when a row has 11+ keys the table still shows the
// columns a user actually scans.
var preferredHeaders = []string{
"id", "name", "login", "title", "subject", "number",
"status", "state", "type", "time_ago", "created_time",
"created_at", "updated_at",
}
func collectKeys(m map[string]interface{}) []string {
keys := make([]string, 0, len(m))
// Prefer common keys first
priority := []string{"id", "name", "login", "title", "status", "state", "created_at", "updated_at"}
seen := map[string]bool{}
for _, k := range priority {
for _, k := range preferredHeaders {
if _, ok := m[k]; ok {
keys = append(keys, k)
seen[k] = true
@ -205,18 +233,98 @@ func formatValue(v interface{}) string {
}
rv := reflect.ValueOf(v)
switch rv.Kind() {
case reflect.Map, reflect.Slice:
data, _ := json.Marshal(v)
s := string(data)
if len(s) > 60 {
return s[:57] + "..."
case reflect.Map:
if m, ok := v.(map[string]interface{}); ok {
return formatNestedMap(m)
}
return s
// Non-{}-shaped map: fall back to JSON, truncated.
data, _ := json.Marshal(v)
return truncateString(string(data), 40)
case reflect.Slice:
// For slices, show the element count rather than an inline JSON blob,
// which is almost always wider than the terminal.
return fmt.Sprintf("[%d items]", rv.Len())
case reflect.Float32, reflect.Float64:
f := rv.Float()
// Render whole-valued floats as integers (e.g. 1779200970 instead of
// 1.77920097e+09); otherwise use plain 'f' formatting to avoid the
// scientific notation %v picks for large magnitudes.
if f == float64(int64(f)) {
return strconv.FormatInt(int64(f), 10)
}
return strconv.FormatFloat(f, 'f', -1, 64)
default:
return fmt.Sprintf("%v", v)
return truncateString(fmt.Sprintf("%v", v), 40)
}
}
// truncateString shortens s to at most maxLen bytes, appending "..." when it is
// truncated. Strings already within the limit are returned unchanged.
func truncateString(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
if maxLen <= 3 {
return s[:maxLen]
}
return s[:maxLen-3] + "..."
}
// formatNestedMap produces a compact, human-readable summary of a nested map by
// extracting a few well-known fields (name/login/title/message/sha/...) instead
// of dumping the full JSON. Unknown shapes collapse to "<object>". This keeps
// table cells narrow even for richly-nested API payloads (e.g. commit metadata).
func formatNestedMap(m map[string]interface{}) string {
// Ordered candidates: earlier keys win and become the leading part.
type candidate struct {
key string
max int // 0 = no truncation
}
cands := []candidate{
{"name", 0},
{"login", 0},
{"title", 30},
{"subject", 30},
{"message", 30},
{"sha", 8},
{"id", 0},
}
var parts []string
for _, c := range cands {
raw, ok := m[c.key]
if !ok || raw == nil {
continue
}
s := formatValue(raw)
if c.max > 0 {
if c.key == "sha" {
// Git-style short SHA: bare prefix, no ellipsis.
if len(s) > c.max {
s = s[:c.max]
}
} else {
s = truncateString(s, c.max)
}
}
if s == "" {
continue
}
// For text-ish fields, join with ":"; for sha/id use a bare token.
switch c.key {
case "sha", "id":
parts = append(parts, s)
default:
parts = append(parts, c.key+":"+s)
}
}
if len(parts) == 0 {
return "<object>"
}
return strings.Join(parts, " -> ")
}
// ShouldUseColor returns true if colorized output should be used.
func ShouldUseColor(mode string) bool {
switch mode {

View File

@ -122,3 +122,141 @@ func TestPrintTable_PicksLargestSlice(t *testing.T) {
}
}
}
// --- Readability improvements (truncation, column limits, nested-map summaries) ---
func TestFormatValue_TruncatesLongString(t *testing.T) {
long := strings.Repeat("a", 100)
got := formatValue(long)
if len(got) > 40 {
t.Errorf("expected output <= 40 chars, got %d: %q", len(got), got)
}
if !strings.HasSuffix(got, "...") {
t.Errorf("expected output to end with \"...\", got %q", got)
}
}
func TestFormatValue_ShortStringUntouched(t *testing.T) {
got := formatValue("hello")
if got != "hello" {
t.Errorf("expected short string unchanged, got %q", got)
}
}
func TestFormatValue_NestedMapShowsSummary(t *testing.T) {
m := map[string]interface{}{
"login": "alice",
"id": 123,
"image_url": "http://example.com/avatar.png",
}
got := formatValue(m)
if !strings.Contains(got, "alice") {
t.Errorf("expected summary to contain \"alice\", got %q", got)
}
if strings.Contains(got, "image_url") {
t.Errorf("expected summary to omit noisy field \"image_url\", got %q", got)
}
}
func TestFormatValue_NestedMapWithoutKnownFields(t *testing.T) {
m := map[string]interface{}{
"foo": "bar",
"baz": 42,
}
got := formatValue(m)
// No recognized key -> should be a short placeholder, not a JSON blob.
if strings.HasPrefix(got, "{") {
t.Errorf("expected placeholder for object without known fields, got JSON: %q", got)
}
}
func TestFormatValue_FloatNoScientificNotation(t *testing.T) {
got := formatValue(1.77920097e+09)
if strings.Contains(got, "e+") || strings.Contains(got, "E+") {
t.Errorf("expected float without scientific notation, got %q", got)
}
if !strings.Contains(got, "1779200970") {
t.Errorf("expected full decimal expansion, got %q", got)
}
}
func TestPrintSliceTable_LimitsColumns(t *testing.T) {
// Build a single row with 12 columns.
row := map[string]interface{}{}
cols := []string{"id", "name", "login", "title", "subject", "number",
"status", "state", "extra1", "extra2", "extra3", "extra4"}
for _, c := range cols {
row[c] = "v_" + c
}
var buf bytes.Buffer
if err := printSliceTable(&buf, []interface{}{row}); err != nil {
t.Fatalf("printSliceTable returned error: %v", err)
}
out := buf.String()
// The header line is the first non-empty line.
lines := strings.Split(out, "\n")
var header string
for _, l := range lines {
if strings.TrimSpace(l) != "" {
header = l
break
}
}
colCount := len(strings.Split(header, "\t"))
if colCount > 8 {
t.Errorf("expected <= 8 columns in header, got %d:\n%s", colCount, header)
}
// And the output should note that columns were omitted.
if !strings.Contains(out, "省略") && !strings.Contains(strings.ToLower(out), "omitted") {
t.Errorf("expected output to mention omitted columns (省略/omitted), got:\n%s", out)
}
}
func TestPrintSliceTable_PreservesPriorityHeaders(t *testing.T) {
// Even with >8 columns, priority fields like id/name should survive.
row := map[string]interface{}{}
cols := []string{"a1", "a2", "a3", "a4", "a5", "a6", "a7", "a8", "id", "name"}
for _, c := range cols {
row[c] = "v"
}
var buf bytes.Buffer
if err := printSliceTable(&buf, []interface{}{row}); err != nil {
t.Fatalf("printSliceTable returned error: %v", err)
}
out := buf.String()
if !strings.Contains(out, "id") || !strings.Contains(out, "name") {
t.Errorf("expected priority headers id/name to survive; got:\n%s", out)
}
}
func TestTruncateString(t *testing.T) {
if got := truncateString("abc", 10); got != "abc" {
t.Errorf("expected short string unchanged, got %q", got)
}
got := truncateString("abcdefghij", 5)
if len(got) > 5 {
t.Errorf("expected len <= 5, got %d: %q", len(got), got)
}
if !strings.HasSuffix(got, "...") {
t.Errorf("expected ... suffix, got %q", got)
}
}
func TestFormatNestedMap_LoginAndSha(t *testing.T) {
m := map[string]interface{}{
"login": "bob",
"sha": "0123456789abcdef0123456789abcdef",
}
got := formatNestedMap(m)
if !strings.Contains(got, "bob") {
t.Errorf("expected login in summary, got %q", got)
}
if !strings.Contains(got, "0123456") {
t.Errorf("expected truncated sha prefix in summary, got %q", got)
}
// Should not contain the full sha.
if strings.Contains(got, "0123456789abcdef0123456789abcdef") {
t.Errorf("expected sha to be truncated, got %q", got)
}
}