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
76 changes: 69 additions & 7 deletions internal/ui/actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"time"

tea "charm.land/bubbletea/v2"
"github.com/SayYoungMan/tfui/pkg/jsondiff"
"github.com/SayYoungMan/tfui/pkg/terraform"
"github.com/alecthomas/chroma/v2/quick"
)
Expand Down Expand Up @@ -167,22 +168,83 @@ func (m *Model) openDetail() {
m.offset = 0
m.viewState = viewDetail

if len(r.Attributes) == 0 {
if r.PlannedChange != nil {
m.detailFromPlannedChange(*r.PlannedChange)
return
}

m.detailFromAttributes(r.Attributes)
}

func (m *Model) detailFromPlannedChange(change terraform.PlannedChange) {
detailWidth := max(0, m.viewWidth-8)
rendered, err := jsondiff.Render(change.Before, change.After, jsondiff.Options{
AddedStyle: func(s string) string {
return m.styles.diffAdded.Width(detailWidth).Render("+ " + s)
},
RemovedStyle: func(s string) string {
return m.styles.diffRemoved.Width(detailWidth).Render("- " + s)
},
NormalStyle: func(s string) string {
return " " + m.highlightJSON(s)
},
})
if err != nil {
m.outputLines = []string{"Failed to render diff: " + err.Error()}
return
}

line := splitDetailString(rendered)
if len(line) == 0 {
m.outputLines = []string{"No diff available."}
return
}

m.outputLines = line
}

func (m *Model) detailFromAttributes(attributes json.RawMessage) {
if len(attributes) == 0 {
m.outputLines = []string{"No details available."}
return
}

var indented bytes.Buffer
if err := json.Indent(&indented, r.Attributes, "", " "); err != nil {
m.outputLines = strings.Split(string(r.Attributes), "\n")
if err := json.Indent(&indented, attributes, "", " "); err != nil {
m.outputLines = strings.Split(string(attributes), "\n")
return
}

m.outputLines = splitDetailString(m.highlightJSON(indented.String()))
}

func (m *Model) highlightJSON(s string) string {
// chroma colours the closing bracket alone, which shouldn't be so we escape explicitly
if isBracketToken(s) {
return s
}

var highlighted bytes.Buffer
if err := quick.Highlight(&highlighted, indented.String(), "json", "terminal256", m.styles.chromaTheme); err != nil {
m.outputLines = strings.Split(indented.String(), "\n")
return
if err := quick.Highlight(&highlighted, s, "json", "terminal256", m.styles.chromaTheme); err != nil {
return s
}
return strings.TrimRight(highlighted.String(), "\n")
}

m.outputLines = strings.Split(strings.TrimRight(highlighted.String(), "\n"), "\n")
func isBracketToken(s string) bool {
trimmed := strings.TrimSpace(s)
return trimmed == "{" ||
trimmed == "}" ||
trimmed == "}," ||
trimmed == "[" ||
trimmed == "]" ||
trimmed == "],"
}

func splitDetailString(s string) []string {
trimmed := strings.TrimRight(s, "\n")
if trimmed == "" {
return nil
}
return strings.Split(trimmed, "\n")
}
42 changes: 42 additions & 0 deletions internal/ui/actions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package ui

import (
"errors"
"strings"
"testing"
"time"

Expand Down Expand Up @@ -269,3 +270,44 @@ func TestStartAction_ExpandsModuleSelection(t *testing.T) {
assert.Contains(t, m.progresses, "module.a.aws_s3.x")
assert.Contains(t, m.progresses, "module.a.aws_s3.y")
}

func TestOpenDetail_PlannedChangeShowsDiff(t *testing.T) {
change := terraform.PlannedChange{
Actions: []string{"update"},
Before: []byte(`{"acl":"private"}`),
After: []byte(`{"acl":"public-read"}`),
}
r := terraform.Resource{
Address: "aws_s3_bucket.uploads",
Action: terraform.ActionUpdate,
Attributes: []byte(`{"attributes_only":"old"}`),
PlannedChange: &change,
}
m := newTestModelWithResources([]*terraform.Resource{&r})

m.openDetail()

out := strings.Join(m.outputLines, "\n")
assert.Contains(t, out, `- "acl": "private"`)
assert.Contains(t, out, `+ "acl": "public-read"`)
assert.NotContains(t, out, `"attributes_only": "old"`)
}

func TestOpenDetail_PlannedChangeInvalidJSONShowsError(t *testing.T) {
change := terraform.PlannedChange{
Actions: []string{"update"},
Before: []byte(`{"acl":`),
After: []byte(`{"acl":"public-read"}`),
}
r := terraform.Resource{
Address: "aws_s3_bucket.uploads",
Action: terraform.ActionUpdate,
PlannedChange: &change,
}
m := newTestModelWithResources([]*terraform.Resource{&r})

m.openDetail()

require.Len(t, m.outputLines, 1)
assert.Contains(t, m.outputLines[0], "Failed to render diff")
}
6 changes: 6 additions & 0 deletions internal/ui/styles.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ type theme struct {
blue, green, red, amber color.Color
cursorBg, cursorFg color.Color
selectedBg, selectedFg color.Color
diffFg color.Color
border, focus, dim, soft color.Color
chroma string
}
Expand All @@ -23,6 +24,7 @@ var darkTheme = theme{
cursorBg: lipgloss.Color("230"),
cursorFg: lipgloss.Color("234"),
selectedBg: lipgloss.Color("240"),
diffFg: lipgloss.Color("0"),
border: lipgloss.Color("245"),
focus: lipgloss.Color("230"),
dim: lipgloss.Color("245"),
Expand All @@ -39,6 +41,7 @@ var lightTheme = theme{
cursorFg: lipgloss.Color("255"),
selectedBg: lipgloss.Color("254"),
selectedFg: lipgloss.Color("235"),
diffFg: lipgloss.Color("255"),
border: lipgloss.Color("244"),
focus: lipgloss.Color("236"),
dim: lipgloss.Color("244"),
Expand All @@ -54,6 +57,7 @@ type styles struct {
error, warning, success lipgloss.Style
infoBar, helpKey, helpInfo lipgloss.Style
module, treePrefixDefault, treePrefixCurrent lipgloss.Style
diffAdded, diffRemoved lipgloss.Style
actions map[terraform.Action]lipgloss.Style
chromaTheme string
}
Expand Down Expand Up @@ -95,6 +99,8 @@ func (t *theme) styles() styles {
module: lipgloss.NewStyle().Foreground(t.soft),
treePrefixDefault: lipgloss.NewStyle().Foreground(t.dim),
treePrefixCurrent: lipgloss.NewStyle().Foreground(t.focus),
diffAdded: lipgloss.NewStyle().Background(t.green).Foreground(t.diffFg),
diffRemoved: lipgloss.NewStyle().Background(t.red).Foreground(t.diffFg),

chromaTheme: t.chroma,
actions: t.actionStyles(),
Expand Down
36 changes: 22 additions & 14 deletions pkg/jsondiff/jsondiff.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,13 @@ import (
"reflect"
"sort"
"strings"

"github.com/alecthomas/chroma/v2/quick"
)

type Options struct {
Indent string // indent used for result json string (default: " ")
SyntaxHighlighter string // chroma style used for syntax highlight (default: none). List available in https://xyproto.github.io/splash/docs/
AddedStyle func(string) string // style function used to style line added (default: str -> + str)
RemovedStyle func(string) string // style function used to style line removed (default: str -> - str)
NormalStyle func(string) string // style function used to style normal lines (defualt: str -> str)
Indent string // indent used for result json string (default: " ")
AddedStyle func(string) string // style function used to style line added (default: str -> + str)
RemovedStyle func(string) string // style function used to style line removed (default: str -> - str)
NormalStyle func(string) string // style function used to style normal lines (defualt: str -> str)
}

type LineKind int
Expand Down Expand Up @@ -100,7 +97,7 @@ func RenderResult(before, after json.RawMessage, opts Options) (Result, error) {
return Result{}, fmt.Errorf("failed to parse after JSON: %w", err)
}

root := buildDiff(beforeVal, afterVal)
root := buildRoot(beforeVal, afterVal)
res := &Result{opts: opts}

err = res.renderNode(root, nil, 0, false)
Expand All @@ -127,6 +124,23 @@ func decodeJSON(raw json.RawMessage) (any, error) {
return value, nil
}

func buildRoot(before, after any) *diffNode {
if reflect.DeepEqual(before, after) {
return &diffNode{kind: diffSame, after: after}
}

// For the cases where whole resource is created/removed we don't want to show - null
// Therefore, explicitly drop the before/after if it's nil for the root node
if before != nil && after == nil {
return &diffNode{kind: diffRemoved, before: before}
}
if before == nil && after != nil {
return &diffNode{kind: diffAdded, after: after}
}

return buildDiff(before, after)
}

// construct tree of JSON diff object nodes recursively and return the root
func buildDiff(before, after any) *diffNode {
if reflect.DeepEqual(before, after) {
Expand Down Expand Up @@ -175,12 +189,6 @@ func (r Result) String() string {
var b strings.Builder
for _, line := range r.Lines {
text := line.Text
if r.opts.SyntaxHighlighter != "" {
var highlighted bytes.Buffer
if err := quick.Highlight(&highlighted, text, "json", "terminal256", r.opts.SyntaxHighlighter); err == nil {
text = strings.TrimRight(highlighted.String(), "\n")
}
}

switch line.Kind {
case LineAdded:
Expand Down
24 changes: 24 additions & 0 deletions pkg/jsondiff/jsondiff_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,30 @@ func TestRender_AppliesLineStyles(t *testing.T) {
assert.NotContains(t, out, `+ [add]`)
}

func TestRender_RootCreateDoesNotShowRemovedNull(t *testing.T) {
before := json.RawMessage(`null`)
after := json.RawMessage(`{"bucket":"b"}`)

out, err := Render(before, after, Options{})

require.NoError(t, err)
assert.Contains(t, out, "+ {")
assert.Contains(t, out, `+ "bucket": "b"`)
assert.NotContains(t, out, "- null")
}

func TestRender_RootDeleteDoesNotShowAddedNull(t *testing.T) {
before := json.RawMessage(`{"bucket":"b"}`)
after := json.RawMessage(`null`)

out, err := Render(before, after, Options{})

require.NoError(t, err)
assert.Contains(t, out, "- {")
assert.Contains(t, out, `- "bucket": "b"`)
assert.NotContains(t, out, "+ null")
}

func TestRenderDocument_ReturnsLineKinds(t *testing.T) {
before := json.RawMessage(`{"env":"dev"}`)
after := json.RawMessage(`{"env":"prod"}`)
Expand Down