diff --git a/internal/ui/actions.go b/internal/ui/actions.go index 9f97112..e585a43 100644 --- a/internal/ui/actions.go +++ b/internal/ui/actions.go @@ -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" ) @@ -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") } diff --git a/internal/ui/actions_test.go b/internal/ui/actions_test.go index 8f6d082..27c2f24 100644 --- a/internal/ui/actions_test.go +++ b/internal/ui/actions_test.go @@ -2,6 +2,7 @@ package ui import ( "errors" + "strings" "testing" "time" @@ -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") +} diff --git a/internal/ui/styles.go b/internal/ui/styles.go index a001676..d6a3db3 100644 --- a/internal/ui/styles.go +++ b/internal/ui/styles.go @@ -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 } @@ -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"), @@ -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"), @@ -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 } @@ -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(), diff --git a/pkg/jsondiff/jsondiff.go b/pkg/jsondiff/jsondiff.go index d07fc57..c16833e 100644 --- a/pkg/jsondiff/jsondiff.go +++ b/pkg/jsondiff/jsondiff.go @@ -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 @@ -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) @@ -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) { @@ -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: diff --git a/pkg/jsondiff/jsondiff_test.go b/pkg/jsondiff/jsondiff_test.go index ef40827..b16d99e 100644 --- a/pkg/jsondiff/jsondiff_test.go +++ b/pkg/jsondiff/jsondiff_test.go @@ -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"}`)