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
62 changes: 48 additions & 14 deletions internal/ui/actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ import (
"bytes"
"context"
"encoding/json"
"fmt"
"strings"
"time"

tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/SayYoungMan/tfui/pkg/jsondiff"
"github.com/SayYoungMan/tfui/pkg/terraform"
"github.com/alecthomas/chroma/v2/quick"
Expand Down Expand Up @@ -169,14 +171,50 @@ func (m *Model) openDetail() {
m.viewState = viewDetail

if r.PlannedChange != nil {
m.detailFromPlannedChange(*r.PlannedChange)
m.outputLines = m.detailFromPlannedChange(*r.PlannedChange)
return
}

m.detailFromAttributes(r.Attributes)
m.outputLines = m.detailFromAttributes(r.Attributes)
}

func (m *Model) detailFromPlannedChange(change terraform.PlannedChange) {
func (m *Model) openDiff() {
m.offset = 0
m.viewState = viewDiff

var lines []string
for _, r := range m.visibleResources() {
if r.PlannedChange == nil {
continue
}

header := fmt.Sprintf("%s %s", r.Action.Symbol(), r.Address)
if r.Reason != "" {
header += fmt.Sprintf(" (%s)", r.Reason)
}
if style, ok := m.styles.actions[r.Action]; ok {
header = style.Render(header)
}

// add empty line between resources
if len(lines) > 0 {
lines = append(lines, "")
}

// TODO: change to showing contexts around the change
lines = append(lines, header)
lines = append(lines, strings.Repeat("─", lipgloss.Width(header)))
lines = append(lines, m.detailFromPlannedChange(*r.PlannedChange)...)
}

if len(lines) == 0 {
m.outputLines = []string{"No diffs available."}
return
}
m.outputLines = lines
}

func (m *Model) detailFromPlannedChange(change terraform.PlannedChange) []string {
detailWidth := max(0, m.viewWidth-8)
rendered, err := jsondiff.Render(change.Before, change.After, jsondiff.Options{
AddedStyle: func(s string) string {
Expand All @@ -190,32 +228,28 @@ func (m *Model) detailFromPlannedChange(change terraform.PlannedChange) {
},
})
if err != nil {
m.outputLines = []string{"Failed to render diff: " + err.Error()}
return
return []string{"Failed to render diff: " + err.Error()}
}

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

m.outputLines = line
return line
}

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

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

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

func (m *Model) highlightJSON(s string) string {
Expand Down
39 changes: 39 additions & 0 deletions internal/ui/actions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -311,3 +311,42 @@ func TestOpenDetail_PlannedChangeInvalidJSONShowsError(t *testing.T) {
require.Len(t, m.outputLines, 1)
assert.Contains(t, m.outputLines[0], "Failed to render diff")
}

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

newModel, _ := m.Update(tea.KeyPressMsg{Code: 'd'})
m = newModel.(Model)

assert.Equal(t, viewDiff, m.viewState)
out := strings.Join(m.outputLines, "\n")
assert.Contains(t, out, "aws_s3_bucket.changed")
assert.Contains(t, out, `- "acl": "private"`)
assert.Contains(t, out, `+ "acl": "public-read"`)
assert.NotContains(t, out, "aws_s3_bucket.unchanged")
}

func TestOpenDiff_NoDiffsAvailable(t *testing.T) {
m := newTestModel()

m.openDiff()

assert.Equal(t, viewDiff, m.viewState)
assert.Equal(t, []string{"No diffs available."}, m.outputLines)
}
30 changes: 30 additions & 0 deletions internal/ui/keys.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ func (m Model) listKeys(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
if !m.isRunning() {
return m.startRescan()
}
case "d":
if !m.isRunning() {
m.openDiff()
}
}

// Below will be cases that requires a row so return early if not
Expand Down Expand Up @@ -316,3 +320,29 @@ func (m Model) detailKeys(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
}
return m, nil
}

func (m Model) diffKeys(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
visible := max(1, m.viewHeight-m.getReservedRows())
contentLen := len(m.outputLines)
switch msg.String() {
case "esc", "enter", "d":
m.viewState = viewList
m.outputLines = nil
m.offset = 0
case "ctrl+u", "pgup":
m.offset = max(m.offset-visible, 0)
case "ctrl+d", "pgdown":
if contentLen > 0 {
m.offset = min(m.offset+visible, contentLen-1)
}
case "k", "up":
if m.offset > 0 {
m.offset--
}
case "j", "down":
if m.offset < contentLen-1 {
m.offset++
}
}
return m, nil
}
36 changes: 36 additions & 0 deletions internal/ui/keys_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,16 @@ func TestListKeys_CtrlASelectAll(t *testing.T) {
assert.False(t, m.selectAll)
}

func TestListKeys_DiffBlockedWhileRunning(t *testing.T) {
m := newTestModel()
m.workState = workShowPlan

newModel, _ := m.Update(tea.KeyPressMsg{Code: 'd'})
m = newModel.(Model)

assert.Equal(t, viewList, m.viewState)
}

func TestFilterModeKeys_FilterFocusAndUnfocus(t *testing.T) {
m := newTestModel()

Expand Down Expand Up @@ -909,3 +919,29 @@ func TestDetailKeys_PageNavigation(t *testing.T) {
m = newModel.(Model)
assert.Equal(t, 0, m.offset)
}

func TestDiffKeys_CloseWithDAndEsc(t *testing.T) {
tests := []struct {
name string
msg tea.KeyPressMsg
}{
{"d", tea.KeyPressMsg{Code: 'd'}},
{"esc", tea.KeyPressMsg{Code: tea.KeyEscape}},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
m := newTestModel()
m.viewState = viewDiff
m.outputLines = []string{"diff"}
m.offset = 2

newModel, _ := m.Update(tt.msg)
m = newModel.(Model)

assert.Equal(t, viewList, m.viewState)
assert.Empty(t, m.outputLines)
assert.Zero(t, m.offset)
})
}
}
7 changes: 6 additions & 1 deletion internal/ui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ const (
viewResourceOutput
viewError
viewDetail
viewDiff
)

type workState int
Expand Down Expand Up @@ -157,13 +158,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.errorKeys(msg)
case viewDetail:
return m.detailKeys(msg)
case viewDiff:
return m.diffKeys(msg)
default:
return m.listKeys(msg)
}

case tea.MouseWheelMsg:
switch m.viewState {
case viewOutput, viewDetail:
case viewOutput, viewDetail, viewDiff:
if msg.Button == tea.MouseWheelUp && m.offset > 0 {
m.offset--
} else if msg.Button == tea.MouseWheelDown {
Expand Down Expand Up @@ -307,6 +310,8 @@ func (m Model) View() tea.View {
viewString = m.renderErrorView()
case viewDetail:
viewString = m.renderDetailView()
case viewDiff:
viewString = m.renderDiffView()
default:
viewString = m.renderListView()
}
Expand Down
21 changes: 21 additions & 0 deletions internal/ui/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,27 @@ func (m Model) renderDetailView() string {
return s.String()
}

func (m Model) renderDiffView() string {
title := " Diffs"
box := m.renderScrollableBox(m.outputLines, m.viewWidth, m.viewHeight-m.getReservedRows())

keyInfo := []keyInfo{
{key: "↑/↓", info: "scroll"},
{key: "PgUp/PgDn", info: "page scroll"},
{key: "d/Esc", info: "close"},
}
help := " " + m.renderKeyInfo(keyInfo)

var s strings.Builder
fmt.Fprintln(&s, title)
fmt.Fprintln(&s)
fmt.Fprintln(&s, box)
fmt.Fprintln(&s)
fmt.Fprint(&s, help)

return s.String()
}

func (m Model) renderOutputView() string {
action := actionChoices[m.actionCursor]

Expand Down
1 change: 1 addition & 0 deletions internal/ui/render_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ func (m Model) renderHelpBar() string {
{key: "Space", info: "select"},
{key: "Ctrl+a", info: "select all"},
{key: "Enter", info: "detail"},
{key: "d", info: "diffs"},
{key: "Tab", info: "action"},
{key: "H", info: HKeyInfo},
{key: "Ctrl+r", info: "refresh"},
Expand Down
2 changes: 1 addition & 1 deletion internal/ui/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ func (m *Model) getReservedRows() int {
case viewOutput, viewResourceOutput:
// title with margin (2) + extra gap (4) + help bar with margin (4)
return 10
case viewDetail:
case viewDetail, viewDiff:
// title with margin (2) + help bar with margin (4)
return 6
}
Expand Down
16 changes: 15 additions & 1 deletion pkg/terraform/diff.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,22 @@ func (tr *TerraformRunner) PlannedChanges(ctx context.Context) (map[string]Plann

changes := make(map[string]PlannedChange, len(plan.ResourceChanges))
for _, rc := range plan.ResourceChanges {
changes[rc.Address] = rc.Change
if isMeaningfulChange(rc.Change) {
changes[rc.Address] = rc.Change
}
}

return changes, nil
}

func isMeaningfulChange(change PlannedChange) bool {
for _, action := range change.Actions {
switch action {
case "", "noop", "no-op", "read":
continue
default:
return true
}
}
return false
}
55 changes: 55 additions & 0 deletions pkg/terraform/diff_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,3 +127,58 @@ func TestPlannedChanges_ShowErrorIncludesStderr(t *testing.T) {
assert.Contains(t, err.Error(), "terraform show plan failed")
assert.Contains(t, err.Error(), "saved plan is stale")
}

func TestPlannedChanges_FiltersNonMeaningfulChanges(t *testing.T) {
output := `{
"resource_changes": [
{
"address": "aws_s3_bucket.noop",
"change": {
"actions": ["no-op"],
"before": {"acl": "private"},
"after": {"acl": "private"}
}
},
{
"address": "data.aws_region.current",
"change": {
"actions": ["read"],
"before": null,
"after": {"name": "us-east-1"}
}
},
{
"address": "aws_s3_bucket.changed",
"change": {
"actions": ["update"],
"before": {"acl": "private"},
"after": {"acl": "public-read"}
}
},
{
"address": "aws_instance.replaced",
"change": {
"actions": ["delete", "create"],
"before": {"ami": "old"},
"after": {"ami": "new"}
}
}
]
}`

var calls []string
runner := &TerraformRunner{
binary: "terraform",
workdir: t.TempDir(),
cmdFactory: mockShowCmdFactory(output, "", 0, &calls),
}

changes, err := runner.PlannedChanges(context.Background())

require.NoError(t, err)
require.Len(t, changes, 2)
assert.Contains(t, changes, "aws_s3_bucket.changed")
assert.Contains(t, changes, "aws_instance.replaced")
assert.NotContains(t, changes, "aws_s3_bucket.noop")
assert.NotContains(t, changes, "data.aws_region.current")
}