Skip to content
Closed
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
53 changes: 36 additions & 17 deletions internal/jsonview/explorer.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ type TableView struct {
table table.Model
rowData []gjson.Result
iterator AnyIterator
transform string
isLoading bool
columns []table.Column
columnKeys []string
Expand Down Expand Up @@ -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 {
Expand All @@ -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
}
Expand Down Expand Up @@ -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
Expand All @@ -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
}
Expand All @@ -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()}
Expand All @@ -358,23 +357,39 @@ 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
}
}
Comment on lines +372 to +376
return data, nil
}

func marshalItemsToJSONArray(items []any, transform string) ([]byte, error) {
var buf bytes.Buffer
buf.WriteByte('[')

for i, item := range items {
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(']')
Expand Down Expand Up @@ -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))

Expand Down
2 changes: 1 addition & 1 deletion internal/jsonview/explorer_stream_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions internal/jsonview/explorer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand Down Expand Up @@ -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))
}
221 changes: 221 additions & 0 deletions internal/jsonview/explorer_transform_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
}
2 changes: 1 addition & 1 deletion pkg/cmd/cmdutil.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down