forked from Gitlink/gitlink-cli
449 lines
12 KiB
Go
449 lines
12 KiB
Go
package output
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"reflect"
|
|
"strconv"
|
|
"strings"
|
|
"text/tabwriter"
|
|
"unicode"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
func Print(envelope *Envelope, format string) error {
|
|
if format == "" {
|
|
format = "json"
|
|
}
|
|
return PrintTo(os.Stdout, envelope, format)
|
|
}
|
|
|
|
func PrintTo(w io.Writer, envelope *Envelope, format string) error {
|
|
switch format {
|
|
case "json":
|
|
return printJSON(w, envelope)
|
|
case "yaml":
|
|
return printYAML(w, envelope)
|
|
case "table":
|
|
return printTable(w, envelope)
|
|
default:
|
|
return printJSON(w, envelope)
|
|
}
|
|
}
|
|
|
|
func printJSON(w io.Writer, envelope *Envelope) error {
|
|
data, err := json.MarshalIndent(envelope, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = fmt.Fprintln(w, string(data))
|
|
return err
|
|
}
|
|
|
|
func printYAML(w io.Writer, envelope *Envelope) error {
|
|
data, err := yaml.Marshal(envelope)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = fmt.Fprint(w, string(data))
|
|
return err
|
|
}
|
|
|
|
func printTable(w io.Writer, envelope *Envelope) error {
|
|
if !envelope.OK {
|
|
if envelope.Error != nil {
|
|
fmt.Fprintf(w, "Error: %s\n", envelope.Error.Message)
|
|
if envelope.Error.Suggestion != "" {
|
|
fmt.Fprintf(w, "Suggestion: %s\n", envelope.Error.Suggestion)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
if envelope.Data == nil {
|
|
fmt.Fprintln(w, "No data")
|
|
return nil
|
|
}
|
|
|
|
// Try to render as table if data is a slice of maps
|
|
switch data := envelope.Data.(type) {
|
|
case []interface{}:
|
|
return printSliceTable(w, data)
|
|
case map[string]interface{}:
|
|
// If the map contains a list of objects, render that list as a table
|
|
// (picking the largest such list, which is usually the primary payload).
|
|
if slice := findLargestSlice(data); slice != nil {
|
|
return printSliceTable(w, slice)
|
|
}
|
|
// Single-object detail: render as a key/value table with nested maps
|
|
// expanded inline (indented sub-rows) for readability.
|
|
return printMapTable(w, data)
|
|
default:
|
|
// Fallback to JSON
|
|
return printJSON(w, envelope)
|
|
}
|
|
}
|
|
|
|
// findLargestSlice scans the values of m for the largest slice whose elements
|
|
// are maps (e.g. []interface{} of map[string]interface{}, or native
|
|
// []map[string]interface{}/[]map[string]string built locally by some commands).
|
|
// Elements are normalized to map[string]interface{} so the table renderer has a
|
|
// consistent shape. Returns nil if no suitable slice is found.
|
|
func findLargestSlice(m map[string]interface{}) []interface{} {
|
|
var best []interface{}
|
|
for _, v := range m {
|
|
rv := reflect.ValueOf(v)
|
|
if rv.Kind() != reflect.Slice || rv.Len() == 0 {
|
|
continue
|
|
}
|
|
// Every element must be a map for it to be table-renderable.
|
|
normalized := make([]interface{}, 0, rv.Len())
|
|
ok := true
|
|
for i := 0; i < rv.Len(); i++ {
|
|
el, alright := normalizeMap(rv.Index(i).Interface())
|
|
if !alright {
|
|
ok = false
|
|
break
|
|
}
|
|
normalized = append(normalized, el)
|
|
}
|
|
if !ok {
|
|
continue
|
|
}
|
|
if best == nil || len(normalized) > len(best) {
|
|
best = normalized
|
|
}
|
|
}
|
|
return best
|
|
}
|
|
|
|
// normalizeMap coerces a map value (map[string]interface{} or a native
|
|
// map[string]X built by command code) into map[string]interface{} for uniform
|
|
// rendering. Returns ok=false if v is not a string-keyed map.
|
|
func normalizeMap(v interface{}) (map[string]interface{}, bool) {
|
|
rv := reflect.ValueOf(v)
|
|
if rv.Kind() != reflect.Map {
|
|
return nil, false
|
|
}
|
|
out := make(map[string]interface{}, rv.Len())
|
|
for _, key := range rv.MapKeys() {
|
|
ks, ok := key.Interface().(string)
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
out[ks] = rv.MapIndex(key).Interface()
|
|
}
|
|
return out, true
|
|
}
|
|
|
|
// 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")
|
|
return nil
|
|
}
|
|
|
|
// If the first item isn't a map, fall back to JSON (unchanged behavior).
|
|
if _, ok := items[0].(map[string]interface{}); !ok {
|
|
data, _ := json.MarshalIndent(items, "", " ")
|
|
fmt.Fprintln(w, string(data))
|
|
return nil
|
|
}
|
|
|
|
// Headers are the UNION of every row's keys, so a column appears whenever
|
|
// any row carries the field. GitLink payloads (e.g. branch lists) do not
|
|
// always include every key on every item; taking headers from items[0]
|
|
// alone made columns like `protected` appear or vanish depending on which
|
|
// item happened to rank first.
|
|
headers := collectKeysUnion(items)
|
|
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"))
|
|
dashes := make([]string, len(headers))
|
|
for i, h := range headers {
|
|
dashes[i] = strings.Repeat("-", len(h))
|
|
}
|
|
fmt.Fprintln(tw, strings.Join(dashes, "\t"))
|
|
|
|
// Print rows
|
|
for _, item := range items {
|
|
m, ok := item.(map[string]interface{})
|
|
if !ok {
|
|
continue
|
|
}
|
|
vals := make([]string, len(headers))
|
|
for i, h := range headers {
|
|
vals[i] = formatValue(m[h])
|
|
}
|
|
fmt.Fprintln(tw, strings.Join(vals, "\t"))
|
|
}
|
|
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
|
|
}
|
|
|
|
// printMapTable renders a single-object detail as a two-column KEY/VALUE table.
|
|
// Top-level keys are ordered by collectKeys (identity/status fields first).
|
|
// Nested map values are expanded as indented sub-rows so rich detail (e.g. the
|
|
// commit/tagger block of a tag) stays readable instead of collapsing to a
|
|
// summary token or a JSON blob.
|
|
func printMapTable(w io.Writer, m map[string]interface{}) error {
|
|
tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
|
|
fmt.Fprintln(tw, "KEY\tVALUE")
|
|
fmt.Fprintln(tw, "---\t-----")
|
|
for _, k := range collectKeys(m) {
|
|
writeMapRow(tw, k, m[k], 0)
|
|
}
|
|
return tw.Flush()
|
|
}
|
|
|
|
// writeMapRow writes one row for a key/value pair. Nested maps recurse with
|
|
// increasing indent so sub-fields appear under their parent.
|
|
func writeMapRow(tw *tabwriter.Writer, key string, v interface{}, depth int) {
|
|
indent := strings.Repeat(" ", depth)
|
|
displayKey := key
|
|
if depth > 0 {
|
|
displayKey = "└ " + key
|
|
}
|
|
|
|
if m, ok := v.(map[string]interface{}); ok {
|
|
// Parent header row, then each child indented one level deeper.
|
|
fmt.Fprintf(tw, "%s%s\t\n", indent, displayKey)
|
|
for _, ck := range collectKeys(m) {
|
|
writeMapRow(tw, ck, m[ck], depth+1)
|
|
}
|
|
return
|
|
}
|
|
|
|
fmt.Fprintf(tw, "%s%s\t%s\n", indent, displayKey, formatValue(v))
|
|
}
|
|
|
|
// 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", "protected", "last_commit", "commit_time",
|
|
"type", "time_ago", "created_time", "created_at", "updated_at",
|
|
}
|
|
|
|
// orderKeySet returns the keys of keySet ordered by preferredHeaders: priority
|
|
// fields come first in their defined order, the rest follow in set-iteration
|
|
// order. Keys absent from preferredHeaders still appear, just after the
|
|
// priority fields.
|
|
func orderKeySet(keySet map[string]bool) []string {
|
|
keys := make([]string, 0, len(keySet))
|
|
seen := map[string]bool{}
|
|
for _, p := range preferredHeaders {
|
|
if keySet[p] {
|
|
keys = append(keys, p)
|
|
seen[p] = true
|
|
}
|
|
}
|
|
for k := range keySet {
|
|
if !seen[k] {
|
|
keys = append(keys, k)
|
|
}
|
|
}
|
|
return keys
|
|
}
|
|
|
|
// collectKeysUnion merges the keys of every map in items into a single
|
|
// priority-ordered header list. Used by printSliceTable so a column shows
|
|
// whenever any row has the field — preventing columns from disappearing when
|
|
// the first row happens to omit a key (e.g. `protected` on GitLink branches).
|
|
func collectKeysUnion(items []interface{}) []string {
|
|
keySet := map[string]bool{}
|
|
for _, it := range items {
|
|
m, ok := it.(map[string]interface{})
|
|
if !ok {
|
|
continue
|
|
}
|
|
for k := range m {
|
|
keySet[k] = true
|
|
}
|
|
}
|
|
return orderKeySet(keySet)
|
|
}
|
|
|
|
func collectKeys(m map[string]interface{}) []string {
|
|
keySet := make(map[string]bool, len(m))
|
|
for k := range m {
|
|
keySet[k] = true
|
|
}
|
|
return orderKeySet(keySet)
|
|
}
|
|
|
|
func formatValue(v interface{}) string {
|
|
if v == nil {
|
|
return ""
|
|
}
|
|
rv := reflect.ValueOf(v)
|
|
switch rv.Kind() {
|
|
case reflect.Map:
|
|
if m, ok := v.(map[string]interface{}); ok {
|
|
return formatNestedMap(m)
|
|
}
|
|
// Native map (e.g. map[string]string built locally): normalize then summarize.
|
|
if m, ok := normalizeMap(v); ok {
|
|
return formatNestedMap(m)
|
|
}
|
|
// Non-string-keyed 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 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 {
|
|
case "always":
|
|
return true
|
|
case "never":
|
|
return false
|
|
default: // "auto"
|
|
if os.Getenv("NO_COLOR") != "" {
|
|
return false
|
|
}
|
|
if os.Getenv("TERM") == "dumb" {
|
|
return false
|
|
}
|
|
if os.Getenv("WT_SESSION") != "" || os.Getenv("ConEmuANSI") == "ON" {
|
|
return true
|
|
}
|
|
if os.Getenv("COLORTERM") != "" {
|
|
return true
|
|
}
|
|
if os.Getenv("TERM") != "" {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
}
|
|
|
|
// runeWidth returns the display width of a rune (2 for CJK, 1 for others).
|
|
func runeWidth(r rune) int {
|
|
if unicode.Is(unicode.Han, r) {
|
|
return 2
|
|
}
|
|
if r >= 0xFF01 && r <= 0xFF60 {
|
|
return 2
|
|
}
|
|
if r >= 0x3000 && r <= 0x303F {
|
|
return 2
|
|
}
|
|
return 1
|
|
}
|
|
|
|
// StringWidth returns the display width of a string accounting for CJK characters.
|
|
func StringWidth(s string) int {
|
|
w := 0
|
|
for _, r := range s {
|
|
w += runeWidth(r)
|
|
}
|
|
return w
|
|
}
|