From 2a031a25ecbacd001731013ed036371b116f6110 Mon Sep 17 00:00:00 2001 From: markstuart-oai Date: Tue, 8 Sep 2026 12:53:21 +0000 Subject: [PATCH] fix(output): apply transforms to interactive list items --- internal/jsonview/explorer.go | 53 +++-- internal/jsonview/explorer_stream_test.go | 2 +- internal/jsonview/explorer_test.go | 4 +- internal/jsonview/explorer_transform_test.go | 221 +++++++++++++++++++ pkg/cmd/cmdutil.go | 2 +- 5 files changed, 261 insertions(+), 21 deletions(-) create mode 100644 internal/jsonview/explorer_transform_test.go diff --git a/internal/jsonview/explorer.go b/internal/jsonview/explorer.go index 139e80d5..296923a8 100644 --- a/internal/jsonview/explorer.go +++ b/internal/jsonview/explorer.go @@ -117,6 +117,7 @@ type TableView struct { table table.Model rowData []gjson.Result iterator AnyIterator + transform string isLoading bool columns []table.Column columnKeys []string @@ -155,6 +156,7 @@ type tableItemMsg struct { func (tv *TableView) loadMoreData() tea.Cmd { iterator := tv.iterator + transform := tv.transform return func() tea.Msg { msg := tableItemMsg{view: tv} if iterator == nil { @@ -165,14 +167,10 @@ func (tv *TableView) loadMoreData() tea.Cmd { return msg } item := iterator.Current() - if hasRaw, ok := item.(hasRawJSON); ok { - msg.result = gjson.Parse(hasRaw.RawJSON()) - return msg - } - jsonBytes, err := json.Marshal(item) + jsonData, err := marshalExplorerItem(item, transform) msg.err = err if err == nil { - msg.result = gjson.ParseBytes(jsonBytes) + msg.result = gjson.Parse(jsonData) } return msg } @@ -316,7 +314,7 @@ type hasRawJSON interface { } // ExploreJSONStream explores JSON data loaded incrementally via an iterator -func ExploreJSONStream[T any](title string, it Iterator[T]) error { +func ExploreJSONStream[T any](title string, it Iterator[T], transform string) error { anyIt := genericToAnyIterator(it) preloadCount := 20 @@ -333,7 +331,7 @@ func ExploreJSONStream[T any](title string, it Iterator[T]) error { return err } - arrayJSONBytes, err := marshalItemsToJSONArray(items) + arrayJSONBytes, err := marshalItemsToJSONArray(items, transform) if err != nil { return err } @@ -347,6 +345,7 @@ func ExploreJSONStream[T any](title string, it Iterator[T]) error { // Set iterator if there might be more data if len(items) == preloadCount { view.iterator = anyIt + view.transform = transform } viewer := &JSONViewer{stack: []JSONView{view}, root: title, rawMode: false, help: help.New()} @@ -358,7 +357,27 @@ func ExploreJSONStream[T any](title string, it Iterator[T]) error { return err } -func marshalItemsToJSONArray(items []any) ([]byte, error) { +// marshalExplorerItem applies the projection once, before a value becomes row data. +func marshalExplorerItem(item any, transform string) (string, error) { + var data string + if hasRaw, ok := item.(hasRawJSON); ok { + data = hasRaw.RawJSON() + } else { + jsonData, err := json.Marshal(item) + if err != nil { + return "", err + } + data = string(jsonData) + } + if transform != "" { + if result := gjson.Get(data, transform); result.Exists() { + data = result.Raw + } + } + return data, nil +} + +func marshalItemsToJSONArray(items []any, transform string) ([]byte, error) { var buf bytes.Buffer buf.WriteByte('[') @@ -366,15 +385,11 @@ func marshalItemsToJSONArray(items []any) ([]byte, error) { if i > 0 { buf.WriteByte(',') } - if hasRaw, ok := item.(hasRawJSON); ok { - buf.WriteString(hasRaw.RawJSON()) - } else { - jsonData, err := json.Marshal(item) - if err != nil { - return nil, err - } - buf.Write(jsonData) + jsonData, err := marshalExplorerItem(item, transform) + if err != nil { + return nil, err } + buf.WriteString(jsonData) } buf.WriteByte(']') @@ -634,6 +649,10 @@ func newArrayOfObjectsTableView(path string, data gjson.Result, array []gjson.Re } } + // Empty objects have no columns; keep their values and later items visible. + if len(columns) == 0 { + return newArrayTableView(path, data, array, raw) + } rows := make([]table.Row, 0, len(array)) rowData := make([]gjson.Result, 0, len(array)) diff --git a/internal/jsonview/explorer_stream_test.go b/internal/jsonview/explorer_stream_test.go index 3f83eed1..f3ee9498 100644 --- a/internal/jsonview/explorer_stream_test.go +++ b/internal/jsonview/explorer_stream_test.go @@ -34,7 +34,7 @@ func TestExplorerLazyLoadPreservesSDKResponse(t *testing.T) { t.Run(raw, func(t *testing.T) { var item openai.Model require.NoError(t, json.Unmarshal([]byte(raw), &item)) - preloaded, err := marshalItemsToJSONArray([]any{item}) + preloaded, err := marshalItemsToJSONArray([]any{item}, "") require.NoError(t, err) require.JSONEq(t, "["+raw+"]", string(preloaded)) view, err := newTableView("", gjson.ParseBytes(preloaded), false) diff --git a/internal/jsonview/explorer_test.go b/internal/jsonview/explorer_test.go index 0a08774b..37359769 100644 --- a/internal/jsonview/explorer_test.go +++ b/internal/jsonview/explorer_test.go @@ -48,7 +48,7 @@ func TestMarshalItemsToJSONArray_WithHasRawJSON(t *testing.T) { rawJSONItem{raw: `{"id":2,"name":"bob"}`}, } - got, err := marshalItemsToJSONArray(items) + got, err := marshalItemsToJSONArray(items, "") require.NoError(t, err) require.JSONEq(t, `[{"id":1,"name":"alice"},{"id":2,"name":"bob"}]`, string(got)) } @@ -183,7 +183,7 @@ func TestMarshalItemsToJSONArray_WithoutHasRawJSON(t *testing.T) { map[string]any{"id": 2, "name": "bob"}, } - got, err := marshalItemsToJSONArray(items) + got, err := marshalItemsToJSONArray(items, "") require.NoError(t, err) require.JSONEq(t, `[{"id":1,"name":"alice"},{"id":2,"name":"bob"}]`, string(got)) } diff --git a/internal/jsonview/explorer_transform_test.go b/internal/jsonview/explorer_transform_test.go new file mode 100644 index 00000000..060f2f34 --- /dev/null +++ b/internal/jsonview/explorer_transform_test.go @@ -0,0 +1,221 @@ +package jsonview + +import ( + "context" + "encoding/json" + "fmt" + "testing" + + "github.com/charmbracelet/bubbles/help" + tea "github.com/charmbracelet/bubbletea" + "github.com/openai/openai-go/v3" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestExplorerTransformValues(t *testing.T) { + for _, value := range []string{`null`, `""`, `0`, `false`, `{}`, `[]`, `"hello"`, `42`, `true`, `{"projection":"must not apply twice","extra":null}`, `[1,"two",null]`} { + t.Run(value, func(t *testing.T) { + raw := `{"id":"model-synthetic","projection":` + value + `,"unknown":"keep","explicit_null":null}` + var sdk openai.Model + require.NoError(t, json.Unmarshal([]byte(raw), &sdk)) + var ordinary map[string]any + require.NoError(t, json.Unmarshal([]byte(raw), &ordinary)) + for _, item := range []any{sdk, ordinary} { + for _, transform := range []string{"projection", "missing", ""} { + want := value + if transform != "projection" { + want = raw + } + initial, err := marshalItemsToJSONArray([]any{item, item}, transform) + require.NoError(t, err) + require.JSONEq(t, "["+want+","+want+"]", string(initial)) + view, err := newTableView("", gjson.ParseBytes(initial), false) + require.NoError(t, err) + it := &explorerIterator{items: []any{item, item}} + view.iterator, view.transform = it, transform + viewer := &JSONViewer{stack: []JSONView{view}, help: help.New()} + viewer.resize(100, 24) + for loaded := 2; loaded < 4; loaded++ { + view.table.SetCursor(loaded - 1) + command := explorerKey(viewer, "j") + require.NotNil(t, command) + message := command().(tableItemMsg) + require.NoError(t, message.err) + require.Len(t, view.rowData, loaded, "command must not mutate UI state") + viewer.Update(message) + require.Len(t, view.rowData, loaded+1) + require.Equal(t, loaded-1, it.index, "one read per scheduled load") + } + for toggle := 0; toggle < 3; toggle++ { + explorerKey(viewer, "r") + require.Same(t, view, viewer.current()) + for index, result := range view.rowData { + require.JSONEq(t, want, result.Raw) + view.table.SetCursor(index) + explorerKey(viewer, "p") + expected := want + if result.Type == gjson.String { + expected = result.Str + } + if result.Type == gjson.String { + require.Equal(t, expected, viewer.message) + } else { + require.JSONEq(t, expected, viewer.message) + } + if viewer.canNavigateInto(result) { + explorerKey(viewer, "l") + require.Equal(t, fmt.Sprintf("[%d]", index), viewer.current().GetPath()) + require.JSONEq(t, want, viewer.current().GetData().Raw) + explorerKey(viewer, "h") + } + } + } + require.Equal(t, 2, it.index, "printing, toggling and navigation must not consume items") + } + } + }) + } +} + +func TestExplorerTransformPendingLoad(t *testing.T) { + for _, nested := range []bool{false, true} { + t.Run(fmt.Sprint(nested), func(t *testing.T) { + initial, err := marshalItemsToJSONArray([]any{json.RawMessage(`{"value":{"text":"initial"}}`)}, "value") + require.NoError(t, err) + view, err := newTableView("", gjson.ParseBytes(initial), false) + require.NoError(t, err) + it := &blockedExplorerIterator{started: make(chan struct{}), release: make(chan struct{})} + view.iterator, view.transform = it, "value" + viewer := &JSONViewer{stack: []JSONView{view}, help: help.New()} + viewer.resize(100, 24) + command := explorerKey(viewer, "j") + require.NotNil(t, command) + done := make(chan tea.Msg, 1) + defer close(it.release) + go func() { done <- command() }() + <-it.started + for i := 0; i < 3; i++ { + explorerKey(viewer, "r") + require.Same(t, view, viewer.current()) + require.Nil(t, explorerKey(viewer, "j")) + viewer.Update(tea.WindowSizeMsg{Width: 90, Height: 30}) + _ = viewer.View() + } + if nested { + explorerKey(viewer, "l") + require.Len(t, viewer.stack, 2) + } + it.release <- struct{}{} + message := <-done + require.Len(t, view.rowData, 1) + viewer.Update(message) + if nested { + require.Len(t, viewer.stack, 2) + explorerKey(viewer, "h") + } + require.Same(t, view, viewer.current()) + require.False(t, view.isLoading) + require.Len(t, view.rowData, 2) + require.Equal(t, `"\u001b]52;c;sample\u0007"`, view.rowData[1].Raw) + for i := 0; i < 3; i++ { + explorerKey(viewer, "r") + requireNoRawTerminalControls(t, view.table.Rows()[1][0]) + view.table.SetCursor(1) + explorerKey(viewer, "p") + require.Equal(t, `\u001b]52;c;sample\u0007`, viewer.message) + } + }) + } +} + +type transformErrorIterator struct { + currentCalls int + nextCalls int + err error +} + +func (it *transformErrorIterator) Next() bool { it.nextCalls++; return false } +func (it *transformErrorIterator) Current() any { it.currentCalls++; return nil } +func (it *transformErrorIterator) Err() error { return it.err } + +func TestExplorerTransformErrors(t *testing.T) { + for _, transform := range []string{"", "projection"} { + t.Run(transform, func(t *testing.T) { + _, err := marshalItemsToJSONArray([]any{make(chan int)}, transform) + var unsupported *json.UnsupportedTypeError + require.ErrorAs(t, err, &unsupported) + for _, upstream := range []error{nil, context.Canceled} { + it := &transformErrorIterator{err: upstream} + if upstream != nil { + require.ErrorIs(t, ExploreJSONStream("synthetic", it, transform), upstream) + require.Equal(t, 1, it.nextCalls) + require.Zero(t, it.currentCalls) + } + view, err := newTableView("", gjson.Parse(`["initial"]`), false) + require.NoError(t, err) + view.iterator, view.transform = it, transform + viewer := &JSONViewer{stack: []JSONView{view}, help: help.New()} + viewer.resize(100, 24) + command := explorerKey(viewer, "j") + require.NotNil(t, command) + message := command().(tableItemMsg) + require.ErrorIs(t, message.err, upstream) + require.False(t, message.result.Exists()) + viewer.Update(message) + require.False(t, view.isLoading) + require.Len(t, view.rowData, 1) + require.Zero(t, it.currentCalls) + } + view, err := newTableView("", gjson.Parse(`["initial"]`), false) + require.NoError(t, err) + it := &explorerIterator{items: []any{make(chan int)}} + view.iterator, view.transform = it, transform + message := view.loadMoreData()().(tableItemMsg) + require.ErrorAs(t, message.err, &unsupported) + require.False(t, message.result.Exists()) + require.Len(t, view.rowData, 1) + require.Equal(t, 1, it.index) + }) + } +} + +func TestExplorerTransformAfterEmptyObjects(t *testing.T) { + for _, value := range []string{`"visible"`, `null`, `false`, `0`, `["visible"]`, `{"text":"visible"}`} { + t.Run(value, func(t *testing.T) { + initial, err := marshalItemsToJSONArray([]any{json.RawMessage(`{"projection":{}}`)}, "projection") + require.NoError(t, err) + view, err := newTableView("", gjson.ParseBytes(initial), false) + require.NoError(t, err) + it := &explorerIterator{items: []any{json.RawMessage(`{"projection":` + value + `}`)}} + view.iterator, view.transform = it, "projection" + viewer := &JSONViewer{stack: []JSONView{view}, help: help.New()} + viewer.resize(100, 24) + command := explorerKey(viewer, "j") + require.NotNil(t, command) + viewer.Update(command()) + require.Len(t, view.rowData, 2) + // The loaded value must be visible before a toggle rebuilds the table. + require.Len(t, view.table.Rows()[1], 1) + require.Equal(t, formatValue(gjson.Parse(value), false), view.table.Rows()[1][0]) + for i := 0; i < 3; i++ { + view.table.SetCursor(1) + explorerKey(viewer, "p") + if value == `"visible"` { + require.Equal(t, "visible", viewer.message) + } else { + require.JSONEq(t, value, viewer.message) + } + if viewer.canNavigateInto(view.rowData[1]) { + explorerKey(viewer, "l") + require.JSONEq(t, value, viewer.current().GetData().Raw) + explorerKey(viewer, "h") + } + explorerKey(viewer, "r") + require.Same(t, view, viewer.current()) + require.Same(t, it, view.iterator) + } + require.Equal(t, 1, it.index) + }) + } +} diff --git a/pkg/cmd/cmdutil.go b/pkg/cmd/cmdutil.go index 733db80c..78a0411d 100644 --- a/pkg/cmd/cmdutil.go +++ b/pkg/cmd/cmdutil.go @@ -502,7 +502,7 @@ func ShowJSONIterator[T any](iter jsonview.Iterator[T], itemsToDisplay int64, op if opts.Format == "explore" { if isTerminal(opts.Stdout) { - return jsonview.ExploreJSONStream(opts.Title, iter) + return jsonview.ExploreJSONStream(opts.Title, iter, opts.Transform) } if opts.ExplicitFormat { fmt.Fprint(opts.Stderr, warningExploreNotSupported)