Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -103,3 +103,4 @@ examples/enitity/webapp/dist/
!examples/enitity/webapp/dist/
!examples/enitity/webapp/dist/index.html
examples/enitity/enitity
go.work.sum
44 changes: 44 additions & 0 deletions api/keyvalue_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package api

import (
"strconv"
"strings"
"testing"
)
Expand Down Expand Up @@ -188,6 +189,49 @@ func TestDescriptionList_String(t *testing.T) {
}
}

// TestDescriptionList_StacksLargeMaps pins the fix for the trace-output bug:
// a DescriptionList with more than 3 entries renders one pair per line instead
// of joining them onto a single multi-thousand-char line. That giant line is
// what made lipgloss right-pad every sibling tree line with thousands of
// trailing spaces (a screenful of apparent blank rows). Small lists still join
// inline.
func TestDescriptionList_StacksLargeMaps(t *testing.T) {
small := DescriptionList{Items: []KeyValuePair{
{Key: "Name", Value: "John"},
{Key: "Age", Value: 30},
}}
if got := small.String(); got != "Name: John, Age: 30" {
t.Fatalf("small list must stay inline, got %q", got)
}
if strings.Contains(small.ANSI(), "\n") {
t.Fatalf("small list ANSI must stay inline, got newline in %q", small.ANSI())
}

items := make([]KeyValuePair, 0, 50)
for i := 0; i < 50; i++ {
items = append(items, KeyValuePair{Key: "K" + strconv.Itoa(i), Value: i})
}
large := DescriptionList{Items: items}

for label, out := range map[string]string{"String": large.String(), "ANSI": large.ANSI()} {
lines := strings.Split(out, "\n")
if len(lines) != len(items) {
t.Errorf("%s: large map must stack one pair per line: got %d lines for %d items", label, len(lines), len(items))
}
widest := 0
for _, l := range lines {
if w := len(l); w > widest {
widest = w
}
}
// A single "Kxx: yy" pair is well under 20 chars; the inline join would
// be 50× that. The widest stacked line must stay near one pair.
if widest > 30 {
t.Errorf("%s: widest stacked line %d chars — looks like it joined inline, not stacked", label, widest)
}
}
}

func TestDescriptionList_HTML_Compact(t *testing.T) {
tests := []struct {
name string
Expand Down
25 changes: 25 additions & 0 deletions api/meta.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,33 @@ import (
"strings"

lipglosstree "github.com/charmbracelet/lipgloss/tree"
"github.com/charmbracelet/x/ansi"
"github.com/samber/lo"
)

// normalizeTreeLabel drops blank lines from a tree-node label and trims
// trailing whitespace per line. lipgloss prefixes every physical line of a
// multi-line node label with the tree continuation gutter, so a blank line
// (including one that is only ANSI escape codes) renders as a bare "│"-gutter
// line. A node label is one tree row's content, not a paragraph — blank-line
// spacing that reads well in flat output is noise inside the tree. Operates on
// already-rendered text (ANSI or plain): a line counts as blank when its
// ANSI-stripped, space-trimmed form is empty.
func normalizeTreeLabel(s string) string {
if !strings.Contains(s, "\n") {
return s
}
lines := strings.Split(s, "\n")
out := make([]string, 0, len(lines))
for _, line := range lines {
if strings.TrimSpace(ansi.Strip(line)) == "" {
continue
}
out = append(out, strings.TrimRight(line, " \t"))
}
return strings.Join(out, "\n")
}

// Interface types for reflection-based slice checking
var (
tableProviderType = reflect.TypeOf((*TableProvider)(nil)).Elem()
Expand Down Expand Up @@ -278,6 +302,7 @@ func (tt TextTree) buildLipglossTree(withColors bool) *lipglosstree.Tree {
} else {
nodeLabel = tt.Node.String()
}
nodeLabel = normalizeTreeLabel(nodeLabel)
}

// If we have no node and only one child, return the child tree directly
Expand Down
84 changes: 84 additions & 0 deletions api/meta_tree_normalize_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package api

import (
"regexp"
"strings"
"testing"

"github.com/charmbracelet/x/ansi"
)

func TestNormalizeTreeLabel(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{"single line", "hello", "hello"},
{"clean multiline", "a\nb\nc", "a\nb\nc"},
{"leading blank", "\nhello", "hello"},
{"trailing blank", "hello\n", "hello"},
{"internal blank dropped", "a\n\nb", "a\nb"},
{"doubled internal blank dropped", "a\n\n\nb", "a\nb"},
{"trailing spaces trimmed", "a \nb\t", "a\nb"},
{"line of only spaces dropped", "a\n \nb", "a\nb"},
{"ansi-only line dropped", "a\n\x1b[1;38;2;8;145;178m\x1b[0m\nb", "a\nb"},
{"ansi content kept", "\x1b[1mTitle\x1b[0m\nbody", "\x1b[1mTitle\x1b[0m\nbody"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := normalizeTreeLabel(tt.input); got != tt.expected {
t.Errorf("normalizeTreeLabel(%q) = %q, want %q", tt.input, got, tt.expected)
}
})
}
}

// treeNodeStub returns a Pretty() whose first node is short but whose children
// carry multi-line content with a deliberate blank-line separator — mirroring a
// gavel Test node wrapping an activity-trace detail.
type treeNodeStub struct{}

func (treeNodeStub) Pretty() Text {
return Text{Content: "✗ trace"}.
NewLine().Add(Text{Content: "Scheme: GL Scheme"}).
NewLine().NewLine().Add(Text{Content: "Activity 123 Foo"})
}
func (treeNodeStub) GetChildren() []TreeNode { return nil }

var gutterOnly = regexp.MustCompile(`^[\s│├╰└─]*$`)

// TestTextTreeRender_NoBlankGutterLines is the regression guard for stray
// "│"-gutter lines: a multi-line node label with an internal blank separator
// must not produce a bare gutter line in either the plain or ANSI tree render.
func TestTextTreeRender_NoBlankGutterLines(t *testing.T) {
root := &SimpleTreeNode{
Label: "suite",
Children: []TreeNode{
treeNodeStub{},
&SimpleTreeNode{Label: "sibling"},
},
}
tree := NewTree[TreeNode](root)

for _, tc := range []struct {
name string
out string
}{
{"String", tree.String()},
{"ANSI", tree.ANSI()},
} {
t.Run(tc.name, func(t *testing.T) {
for i, line := range strings.Split(tc.out, "\n") {
if gutterOnly.MatchString(ansi.Strip(line)) {
t.Errorf("blank gutter line at index %d:\n%s", i, tc.out)
}
}
for _, want := range []string{"trace", "Scheme: GL Scheme", "Activity 123 Foo", "sibling"} {
if !strings.Contains(tc.out, want) {
t.Errorf("expected %q in:\n%s", want, tc.out)
}
}
})
}
}
24 changes: 20 additions & 4 deletions api/text.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"strings"
"time"

"github.com/charmbracelet/x/ansi"
"github.com/flanksource/clicky/api/tailwind"
"github.com/samber/lo"
)
Expand Down Expand Up @@ -548,22 +549,37 @@ func (dl DescriptionList) String() string {
if len(dl.Items) == 0 {
return ""
}
var parts []string
parts := make([]string, 0, len(dl.Items))
for _, item := range dl.Items {
parts = append(parts, item.String())
}
return strings.Join(parts, ", ")
return joinDescriptionParts(parts)
}

func (dl DescriptionList) ANSI() string {
if len(dl.Items) == 0 {
return ""
}
var parts []string
parts := make([]string, 0, len(dl.Items))
for _, item := range dl.Items {
parts = append(parts, item.ANSI())
}
return strings.Join(parts, ", ")
return joinDescriptionParts(parts)
}

// joinDescriptionParts renders pre-formatted key/value parts inline (", "
// separated) for small lists that fit the terminal, and stacks them one per
// line otherwise. Inline joining is fine for a handful of pairs, but a large
// map (e.g. a 493-entry activity Math map) becomes a single multi-thousand-char
// line. When that line is the widest in a tree, lipgloss right-pads every
// sibling line to match — flooding the terminal with trailing-space "blank"
// rows. Stacking removes the giant line so nothing forces that padding.
func joinDescriptionParts(parts []string) string {
inline := strings.Join(parts, ", ")
if len(parts) <= 3 && ansi.StringWidth(inline) <= GetTerminalWidth() {
return inline
}
return strings.Join(parts, "\n")
}

func (dl DescriptionList) HTML() string {
Expand Down
95 changes: 95 additions & 0 deletions docs/cobra_walk.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package docs

import (
"strings"

"github.com/spf13/cobra"
"github.com/spf13/pflag"
)

// walkCommands invokes fn for cmd and every descendant, depth-first.
func walkCommands(cmd *cobra.Command, fn func(*cobra.Command)) {
fn(cmd)
for _, sub := range cmd.Commands() {
walkCommands(sub, fn)
}
}

// isRunnable reports whether the command does work (has a Run/RunE) and is not
// the root. Grouping commands (no Run) are skipped in the reference body.
func isRunnable(cmd *cobra.Command) bool {
return cmd.Parent() != nil && (cmd.Run != nil || cmd.RunE != nil)
}

// depthBelow returns how many levels cmd sits below ancestor: 0 if cmd is the
// ancestor itself, 1 for a direct child, and so on.
func depthBelow(ancestor, cmd *cobra.Command) int {
depth := 0
for c := cmd; c != nil && c != ancestor; c = c.Parent() {
depth++
}
return depth
}

// commandPath returns the space-delimited path below the root, e.g. "stack create".
func commandPath(cmd *cobra.Command) string {
var parts []string
for c := cmd; c.Parent() != nil; c = c.Parent() {
parts = append([]string{c.Name()}, parts...)
}
return strings.Join(parts, " ")
}

func flagDocs(cmd *cobra.Command) []FlagDoc {
var flags []FlagDoc
seen := map[string]bool{}
collect := func(flag *pflag.Flag) {
if flag.Hidden || seen[flag.Name] {
return
}
seen[flag.Name] = true
flags = append(flags, FlagDoc{
Name: flag.Name,
Shorthand: flag.Shorthand,
Type: flag.Value.Type(),
Default: nonEmptyDefault(flag),
Required: flagRequired(flag),
Usage: flag.Usage,
})
}
cmd.LocalFlags().VisitAll(collect)
cmd.InheritedFlags().VisitAll(collect)
return flags
}

func nonEmptyDefault(flag *pflag.Flag) interface{} {
if flag.DefValue == "" || (flag.DefValue == "false" && flag.Value.Type() == "bool") {
return nil
}
return flag.DefValue
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

func flagRequired(flag *pflag.Flag) bool {
if flag.Annotations == nil {
return false
}
_, ok := flag.Annotations["cobra_annotation_bash_completion_one_required_flag"]
return ok
}

func title(root *cobra.Command, cfg *DocsConfig) string {
if cfg != nil && cfg.Title != "" {
return cfg.Title
}
return root.Name()
}

func description(root *cobra.Command, cfg *DocsConfig) string {
if cfg != nil && cfg.Description != "" {
return cfg.Description
}
if root.Long != "" {
return root.Long
}
return root.Short
}
Loading
Loading