forked from Gitlink/gitlink-cli
feat(output): improve table readability — truncate values, limit columns, summarize nested maps
This commit is contained in:
parent
25ee6cc5fb
commit
295eda5a4e
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue