From c380718643817890669a5d8ce13b54b38bf2acb9 Mon Sep 17 00:00:00 2001 From: SayYoungMan Date: Wed, 3 Jun 2026 13:55:03 +0900 Subject: [PATCH 1/3] somewhat working --- internal/ui/actions.go | 59 +++++++++++++++++++++++++++++--------- internal/ui/keys.go | 2 ++ internal/ui/model.go | 7 +++-- internal/ui/render.go | 21 ++++++++++++++ internal/ui/render_list.go | 1 + pkg/terraform/diff.go | 16 ++++++++++- 6 files changed, 89 insertions(+), 17 deletions(-) diff --git a/internal/ui/actions.go b/internal/ui/actions.go index e585a43..964c910 100644 --- a/internal/ui/actions.go +++ b/internal/ui/actions.go @@ -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" @@ -169,14 +171,47 @@ 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, "") + } + 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."} + } + 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 { @@ -190,32 +225,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 { diff --git a/internal/ui/keys.go b/internal/ui/keys.go index 39750b5..24a07ed 100644 --- a/internal/ui/keys.go +++ b/internal/ui/keys.go @@ -21,6 +21,8 @@ func (m Model) listKeys(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { if !m.isRunning() { return m.startRescan() } + case "d": + m.openDiff() } // Below will be cases that requires a row so return early if not diff --git a/internal/ui/model.go b/internal/ui/model.go index b88b1ec..862a29f 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -69,6 +69,7 @@ const ( viewResourceOutput viewError viewDetail + viewDiff ) type workState int @@ -155,7 +156,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.outputKeys(msg) case viewError: return m.errorKeys(msg) - case viewDetail: + case viewDetail, viewDiff: return m.detailKeys(msg) default: return m.listKeys(msg) @@ -163,7 +164,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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 { @@ -307,6 +308,8 @@ func (m Model) View() tea.View { viewString = m.renderErrorView() case viewDetail: viewString = m.renderDetailView() + case viewDiff: + viewString = m.renderDiffView() default: viewString = m.renderListView() } diff --git a/internal/ui/render.go b/internal/ui/render.go index 2eda0b6..af393b6 100644 --- a/internal/ui/render.go +++ b/internal/ui/render.go @@ -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: "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] diff --git a/internal/ui/render_list.go b/internal/ui/render_list.go index 3183aa9..77a3989 100644 --- a/internal/ui/render_list.go +++ b/internal/ui/render_list.go @@ -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"}, diff --git a/pkg/terraform/diff.go b/pkg/terraform/diff.go index 477720b..6d004b6 100644 --- a/pkg/terraform/diff.go +++ b/pkg/terraform/diff.go @@ -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 +} From 248033ce1307041cc449d26acdbe17b8ffbaef42 Mon Sep 17 00:00:00 2001 From: SayYoungMan Date: Wed, 3 Jun 2026 20:01:02 +0900 Subject: [PATCH 2/3] fix some issues --- internal/ui/actions.go | 3 +++ internal/ui/keys.go | 26 ++++++++++++++++++++++++++ internal/ui/model.go | 4 +++- internal/ui/render.go | 2 +- internal/ui/utils.go | 2 +- 5 files changed, 34 insertions(+), 3 deletions(-) diff --git a/internal/ui/actions.go b/internal/ui/actions.go index 964c910..7d508bd 100644 --- a/internal/ui/actions.go +++ b/internal/ui/actions.go @@ -200,6 +200,8 @@ func (m *Model) openDiff() { 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)...) @@ -207,6 +209,7 @@ func (m *Model) openDiff() { if len(lines) == 0 { m.outputLines = []string{"No diffs available."} + return } m.outputLines = lines } diff --git a/internal/ui/keys.go b/internal/ui/keys.go index 24a07ed..b837453 100644 --- a/internal/ui/keys.go +++ b/internal/ui/keys.go @@ -318,3 +318,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 +} diff --git a/internal/ui/model.go b/internal/ui/model.go index 862a29f..e1627c0 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -156,8 +156,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.outputKeys(msg) case viewError: return m.errorKeys(msg) - case viewDetail, viewDiff: + case viewDetail: return m.detailKeys(msg) + case viewDiff: + return m.diffKeys(msg) default: return m.listKeys(msg) } diff --git a/internal/ui/render.go b/internal/ui/render.go index af393b6..26f6760 100644 --- a/internal/ui/render.go +++ b/internal/ui/render.go @@ -137,7 +137,7 @@ func (m Model) renderDiffView() string { keyInfo := []keyInfo{ {key: "↑/↓", info: "scroll"}, {key: "PgUp/PgDn", info: "page scroll"}, - {key: "Esc", info: "close"}, + {key: "d/Esc", info: "close"}, } help := " " + m.renderKeyInfo(keyInfo) diff --git a/internal/ui/utils.go b/internal/ui/utils.go index ff0ff5d..b41a6af 100644 --- a/internal/ui/utils.go +++ b/internal/ui/utils.go @@ -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 } From d4994dd930c6e101137679b51dbe8e10d08f0d10 Mon Sep 17 00:00:00 2001 From: SayYoungMan Date: Wed, 3 Jun 2026 20:23:05 +0900 Subject: [PATCH 3/3] add unit tests --- internal/ui/actions_test.go | 39 ++++++++++++++++++++++++++ internal/ui/keys.go | 4 ++- internal/ui/keys_test.go | 36 ++++++++++++++++++++++++ pkg/terraform/diff_test.go | 55 +++++++++++++++++++++++++++++++++++++ 4 files changed, 133 insertions(+), 1 deletion(-) diff --git a/internal/ui/actions_test.go b/internal/ui/actions_test.go index 27c2f24..15ba7eb 100644 --- a/internal/ui/actions_test.go +++ b/internal/ui/actions_test.go @@ -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) +} diff --git a/internal/ui/keys.go b/internal/ui/keys.go index b837453..b534372 100644 --- a/internal/ui/keys.go +++ b/internal/ui/keys.go @@ -22,7 +22,9 @@ func (m Model) listKeys(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { return m.startRescan() } case "d": - m.openDiff() + if !m.isRunning() { + m.openDiff() + } } // Below will be cases that requires a row so return early if not diff --git a/internal/ui/keys_test.go b/internal/ui/keys_test.go index 7d8840f..a753d14 100644 --- a/internal/ui/keys_test.go +++ b/internal/ui/keys_test.go @@ -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() @@ -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) + }) + } +} diff --git a/pkg/terraform/diff_test.go b/pkg/terraform/diff_test.go index 50e22cb..deb69eb 100644 --- a/pkg/terraform/diff_test.go +++ b/pkg/terraform/diff_test.go @@ -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") +}