diff --git a/internal/output/formatter.go b/internal/output/formatter.go index c677651..a4d7f52 100644 --- a/internal/output/formatter.go +++ b/internal/output/formatter.go @@ -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 "". 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 "" + } + return strings.Join(parts, " -> ") +} + // ShouldUseColor returns true if colorized output should be used. func ShouldUseColor(mode string) bool { switch mode { diff --git a/internal/output/formatter_test.go b/internal/output/formatter_test.go index a8b270b..1d0eb22 100644 --- a/internal/output/formatter_test.go +++ b/internal/output/formatter_test.go @@ -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) + } +}