+52
-1
cmd/unreleased.go
+52
-1
cmd/unreleased.go
···
46
46
"slices"
47
47
"strings"
48
48
49
+
tea "github.com/charmbracelet/bubbletea"
49
50
"github.com/spf13/cobra"
50
51
"github.com/stormlightlabs/git-storm/internal/changeset"
51
52
"github.com/stormlightlabs/git-storm/internal/style"
53
+
"github.com/stormlightlabs/git-storm/internal/ui"
52
54
)
53
55
54
56
func unreleasedCmd() *cobra.Command {
···
128
130
Long: `Launches an interactive Bubble Tea TUI to review, edit, or categorize
129
131
unreleased entries before final release.`,
130
132
RunE: func(cmd *cobra.Command, args []string) error {
131
-
fmt.Println("unreleased review not implemented (TUI)")
133
+
changesDir := ".changes"
134
+
entries, err := changeset.List(changesDir)
135
+
if err != nil {
136
+
return fmt.Errorf("failed to list changelog entries: %w", err)
137
+
}
138
+
139
+
if len(entries) == 0 {
140
+
style.Println("No unreleased changes found")
141
+
return nil
142
+
}
143
+
144
+
model := ui.NewChangesetReviewModel(entries)
145
+
p := tea.NewProgram(model, tea.WithAltScreen())
146
+
147
+
finalModel, err := p.Run()
148
+
if err != nil {
149
+
return fmt.Errorf("failed to run review TUI: %w", err)
150
+
}
151
+
152
+
reviewModel, ok := finalModel.(ui.ChangesetReviewModel)
153
+
if !ok {
154
+
return fmt.Errorf("unexpected model type")
155
+
}
156
+
157
+
if reviewModel.IsCancelled() {
158
+
style.Headline("Review cancelled")
159
+
return nil
160
+
}
161
+
162
+
items := reviewModel.GetReviewedItems()
163
+
deleteCount := 0
164
+
editCount := 0
165
+
166
+
for _, item := range items {
167
+
switch item.Action {
168
+
case ui.ActionDelete:
169
+
deleteCount++
170
+
case ui.ActionEdit:
171
+
editCount++
172
+
}
173
+
}
174
+
175
+
if deleteCount == 0 && editCount == 0 {
176
+
style.Headline("No changes requested")
177
+
return nil
178
+
}
179
+
180
+
style.Headlinef("Review completed: %d to delete, %d to edit", deleteCount, editCount)
181
+
style.Println("Note: Delete and edit actions are not yet implemented")
182
+
132
183
return nil
133
184
},
134
185
}
+363
internal/ui/changeset_review.go
+363
internal/ui/changeset_review.go
···
1
+
package ui
2
+
3
+
import (
4
+
"fmt"
5
+
"strings"
6
+
7
+
"github.com/charmbracelet/bubbles/key"
8
+
"github.com/charmbracelet/bubbles/viewport"
9
+
tea "github.com/charmbracelet/bubbletea"
10
+
"github.com/charmbracelet/lipgloss"
11
+
"github.com/stormlightlabs/git-storm/internal/changeset"
12
+
"github.com/stormlightlabs/git-storm/internal/style"
13
+
)
14
+
15
+
// ReviewAction represents an action to perform on a changeset entry.
16
+
type ReviewAction int
17
+
18
+
const (
19
+
ActionKeep ReviewAction = iota
20
+
ActionDelete
21
+
ActionEdit
22
+
)
23
+
24
+
// ReviewItem wraps a changeset entry with its review state.
25
+
type ReviewItem struct {
26
+
Entry changeset.EntryWithFile
27
+
Action ReviewAction
28
+
}
29
+
30
+
// ChangesetReviewModel holds the state for the interactive changeset review TUI.
31
+
type ChangesetReviewModel struct {
32
+
viewport viewport.Model
33
+
items []ReviewItem
34
+
cursor int
35
+
ready bool
36
+
width int
37
+
height int
38
+
confirmed bool
39
+
cancelled bool
40
+
}
41
+
42
+
// changesetReviewKeyMap defines keyboard shortcuts for the changeset reviewer.
43
+
type changesetReviewKeyMap struct {
44
+
Up key.Binding
45
+
Down key.Binding
46
+
PageUp key.Binding
47
+
PageDown key.Binding
48
+
Top key.Binding
49
+
Bottom key.Binding
50
+
Delete key.Binding
51
+
Edit key.Binding
52
+
Keep key.Binding
53
+
Confirm key.Binding
54
+
Quit key.Binding
55
+
}
56
+
57
+
var reviewKeys = changesetReviewKeyMap{
58
+
Up: key.NewBinding(
59
+
key.WithKeys("up", "k"),
60
+
key.WithHelp("↑/k", "up"),
61
+
),
62
+
Down: key.NewBinding(
63
+
key.WithKeys("down", "j"),
64
+
key.WithHelp("↓/j", "down"),
65
+
),
66
+
PageUp: key.NewBinding(
67
+
key.WithKeys("pgup", "u"),
68
+
key.WithHelp("pgup/u", "page up"),
69
+
),
70
+
PageDown: key.NewBinding(
71
+
key.WithKeys("pgdown", "d"),
72
+
key.WithHelp("pgdn/d", "page down"),
73
+
),
74
+
Top: key.NewBinding(
75
+
key.WithKeys("g", "home"),
76
+
key.WithHelp("g/home", "top"),
77
+
),
78
+
Bottom: key.NewBinding(
79
+
key.WithKeys("G", "end"),
80
+
key.WithHelp("G/end", "bottom"),
81
+
),
82
+
Delete: key.NewBinding(
83
+
key.WithKeys("x"),
84
+
key.WithHelp("x", "mark delete"),
85
+
),
86
+
Edit: key.NewBinding(
87
+
key.WithKeys("e"),
88
+
key.WithHelp("e", "mark edit"),
89
+
),
90
+
Keep: key.NewBinding(
91
+
key.WithKeys(" "),
92
+
key.WithHelp("space", "keep"),
93
+
),
94
+
Confirm: key.NewBinding(
95
+
key.WithKeys("enter", "c"),
96
+
key.WithHelp("enter/c", "confirm"),
97
+
),
98
+
Quit: key.NewBinding(
99
+
key.WithKeys("q", "esc", "ctrl+c"),
100
+
key.WithHelp("q", "quit"),
101
+
),
102
+
}
103
+
104
+
// NewChangesetReviewModel creates a new changeset review model.
105
+
func NewChangesetReviewModel(entries []changeset.EntryWithFile) ChangesetReviewModel {
106
+
items := make([]ReviewItem, 0, len(entries))
107
+
108
+
for _, entry := range entries {
109
+
items = append(items, ReviewItem{
110
+
Entry: entry,
111
+
Action: ActionKeep,
112
+
})
113
+
}
114
+
115
+
return ChangesetReviewModel{
116
+
items: items,
117
+
cursor: 0,
118
+
ready: false,
119
+
}
120
+
}
121
+
122
+
// Init initializes the model (required by Bubble Tea).
123
+
func (m ChangesetReviewModel) Init() tea.Cmd {
124
+
return nil
125
+
}
126
+
127
+
// Update handles messages and updates the model state.
128
+
func (m ChangesetReviewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
129
+
var cmd tea.Cmd
130
+
131
+
switch msg := msg.(type) {
132
+
case tea.KeyMsg:
133
+
switch {
134
+
case key.Matches(msg, reviewKeys.Quit):
135
+
m.cancelled = true
136
+
return m, tea.Quit
137
+
138
+
case key.Matches(msg, reviewKeys.Confirm):
139
+
m.confirmed = true
140
+
return m, tea.Quit
141
+
142
+
case key.Matches(msg, reviewKeys.Up):
143
+
if m.cursor > 0 {
144
+
m.cursor--
145
+
m.ensureVisible()
146
+
}
147
+
148
+
case key.Matches(msg, reviewKeys.Down):
149
+
if m.cursor < len(m.items)-1 {
150
+
m.cursor++
151
+
m.ensureVisible()
152
+
}
153
+
154
+
case key.Matches(msg, reviewKeys.PageUp):
155
+
m.cursor -= m.viewport.Height
156
+
if m.cursor < 0 {
157
+
m.cursor = 0
158
+
}
159
+
m.ensureVisible()
160
+
161
+
case key.Matches(msg, reviewKeys.PageDown):
162
+
m.cursor += m.viewport.Height
163
+
if m.cursor >= len(m.items) {
164
+
m.cursor = len(m.items) - 1
165
+
}
166
+
m.ensureVisible()
167
+
168
+
case key.Matches(msg, reviewKeys.Top):
169
+
m.cursor = 0
170
+
m.ensureVisible()
171
+
172
+
case key.Matches(msg, reviewKeys.Bottom):
173
+
m.cursor = len(m.items) - 1
174
+
m.ensureVisible()
175
+
176
+
case key.Matches(msg, reviewKeys.Delete):
177
+
if m.cursor >= 0 && m.cursor < len(m.items) {
178
+
m.items[m.cursor].Action = ActionDelete
179
+
m.updateContent()
180
+
}
181
+
182
+
case key.Matches(msg, reviewKeys.Edit):
183
+
if m.cursor >= 0 && m.cursor < len(m.items) {
184
+
m.items[m.cursor].Action = ActionEdit
185
+
m.updateContent()
186
+
}
187
+
188
+
case key.Matches(msg, reviewKeys.Keep):
189
+
if m.cursor >= 0 && m.cursor < len(m.items) {
190
+
m.items[m.cursor].Action = ActionKeep
191
+
m.updateContent()
192
+
}
193
+
}
194
+
195
+
case tea.WindowSizeMsg:
196
+
m.width = msg.Width
197
+
m.height = msg.Height
198
+
199
+
if !m.ready {
200
+
m.viewport = viewport.New(msg.Width, msg.Height-4)
201
+
m.ready = true
202
+
m.updateContent()
203
+
} else {
204
+
m.viewport.Width = msg.Width
205
+
m.viewport.Height = msg.Height - 4
206
+
m.updateContent()
207
+
}
208
+
}
209
+
210
+
m.viewport, cmd = m.viewport.Update(msg)
211
+
return m, cmd
212
+
}
213
+
214
+
// View renders the current view of the changeset reviewer.
215
+
func (m ChangesetReviewModel) View() string {
216
+
if !m.ready {
217
+
return "\n Initializing..."
218
+
}
219
+
220
+
header := m.renderReviewHeader()
221
+
footer := m.renderReviewFooter()
222
+
223
+
return fmt.Sprintf("%s\n%s\n%s", header, m.viewport.View(), footer)
224
+
}
225
+
226
+
// GetReviewedItems returns all items with their review actions.
227
+
func (m ChangesetReviewModel) GetReviewedItems() []ReviewItem {
228
+
return m.items
229
+
}
230
+
231
+
// IsCancelled returns true if the user quit without confirming.
232
+
func (m ChangesetReviewModel) IsCancelled() bool {
233
+
return m.cancelled
234
+
}
235
+
236
+
// IsConfirmed returns true if the user confirmed their review.
237
+
func (m ChangesetReviewModel) IsConfirmed() bool {
238
+
return m.confirmed
239
+
}
240
+
241
+
// ensureVisible scrolls the viewport to keep the cursor visible.
242
+
func (m *ChangesetReviewModel) ensureVisible() {
243
+
lineHeight := 1
244
+
cursorY := m.cursor * lineHeight
245
+
246
+
if cursorY < m.viewport.YOffset {
247
+
m.viewport.YOffset = cursorY
248
+
} else if cursorY >= m.viewport.YOffset+m.viewport.Height {
249
+
m.viewport.YOffset = cursorY - m.viewport.Height + 1
250
+
}
251
+
252
+
m.updateContent()
253
+
}
254
+
255
+
// updateContent regenerates the viewport content.
256
+
func (m *ChangesetReviewModel) updateContent() {
257
+
if !m.ready {
258
+
return
259
+
}
260
+
261
+
var content strings.Builder
262
+
263
+
for i, item := range m.items {
264
+
content.WriteString(m.renderReviewLine(i, item))
265
+
content.WriteString("\n")
266
+
}
267
+
268
+
m.viewport.SetContent(content.String())
269
+
}
270
+
271
+
// renderReviewLine renders a single changeset entry line with action state.
272
+
func (m ChangesetReviewModel) renderReviewLine(index int, item ReviewItem) string {
273
+
var actionIcon string
274
+
var actionStyle lipgloss.Style
275
+
276
+
switch item.Action {
277
+
case ActionKeep:
278
+
actionIcon = "[✓]"
279
+
actionStyle = lipgloss.NewStyle().Foreground(style.AddedColor)
280
+
case ActionDelete:
281
+
actionIcon = "[✗]"
282
+
actionStyle = lipgloss.NewStyle().Foreground(style.RemovedColor)
283
+
case ActionEdit:
284
+
actionIcon = "[✎]"
285
+
actionStyle = lipgloss.NewStyle().Foreground(style.SecurityColor)
286
+
}
287
+
288
+
categoryStyle := getCategoryStyle(item.Entry.Entry.Type)
289
+
lineStyle := lipgloss.NewStyle()
290
+
291
+
if index == m.cursor {
292
+
lineStyle = lineStyle.Background(lipgloss.Color("#1f2428"))
293
+
actionStyle = actionStyle.Bold(true)
294
+
}
295
+
296
+
typeLabel := fmt.Sprintf("%-8s", item.Entry.Entry.Type)
297
+
scopePart := ""
298
+
if item.Entry.Entry.Scope != "" {
299
+
scopePart = fmt.Sprintf("(%s) ", item.Entry.Entry.Scope)
300
+
}
301
+
302
+
maxSummaryLen := max(m.width-40, 20)
303
+
summary := item.Entry.Entry.Summary
304
+
if len(summary) > maxSummaryLen {
305
+
summary = summary[:maxSummaryLen-3] + "..."
306
+
}
307
+
308
+
line := fmt.Sprintf("%s %s %s%s",
309
+
actionStyle.Render(actionIcon),
310
+
categoryStyle.Render(typeLabel),
311
+
scopePart,
312
+
summary,
313
+
)
314
+
315
+
return lineStyle.Render(line)
316
+
}
317
+
318
+
// renderReviewHeader creates the header showing entry count.
319
+
func (m ChangesetReviewModel) renderReviewHeader() string {
320
+
headerStyle := lipgloss.NewStyle().
321
+
Foreground(style.AccentBlue).
322
+
Bold(true).
323
+
Padding(0, 1)
324
+
325
+
return headerStyle.Render(
326
+
fmt.Sprintf("Review unreleased changes (%d entries)", len(m.items)),
327
+
)
328
+
}
329
+
330
+
// renderReviewFooter creates the footer with help text and action summary.
331
+
func (m ChangesetReviewModel) renderReviewFooter() string {
332
+
footerStyle := lipgloss.NewStyle().
333
+
Foreground(lipgloss.Color("#6C7A89")).
334
+
Faint(true).
335
+
Padding(0, 1)
336
+
337
+
keepCount := 0
338
+
deleteCount := 0
339
+
editCount := 0
340
+
341
+
for _, item := range m.items {
342
+
switch item.Action {
343
+
case ActionKeep:
344
+
keepCount++
345
+
case ActionDelete:
346
+
deleteCount++
347
+
case ActionEdit:
348
+
editCount++
349
+
}
350
+
}
351
+
352
+
helpText := "↑/↓: navigate • space: keep • x: delete • e: edit • enter: confirm • q: quit"
353
+
actionInfo := fmt.Sprintf("keep: %d | delete: %d | edit: %d", keepCount, deleteCount, editCount)
354
+
355
+
totalWidth := m.width
356
+
helpWidth := lipgloss.Width(helpText)
357
+
actionWidth := lipgloss.Width(actionInfo)
358
+
padding := max(totalWidth-helpWidth-actionWidth-2, 0)
359
+
360
+
return footerStyle.Render(
361
+
helpText + strings.Repeat(" ", padding) + actionInfo,
362
+
)
363
+
}
+477
internal/ui/changeset_review_test.go
+477
internal/ui/changeset_review_test.go
···
1
+
package ui
2
+
3
+
import (
4
+
"strings"
5
+
"testing"
6
+
"time"
7
+
8
+
tea "github.com/charmbracelet/bubbletea"
9
+
"github.com/charmbracelet/x/exp/teatest"
10
+
"github.com/stormlightlabs/git-storm/internal/changeset"
11
+
)
12
+
13
+
func createMockEntry(filename, entryType, scope, summary string) changeset.EntryWithFile {
14
+
return changeset.EntryWithFile{
15
+
Entry: changeset.Entry{
16
+
Type: entryType,
17
+
Scope: scope,
18
+
Summary: summary,
19
+
Breaking: false,
20
+
},
21
+
Filename: filename,
22
+
}
23
+
}
24
+
25
+
func TestChangesetReviewModel_Init(t *testing.T) {
26
+
entries := []changeset.EntryWithFile{
27
+
createMockEntry("test.md", "added", "cli", "Test entry"),
28
+
}
29
+
30
+
model := NewChangesetReviewModel(entries)
31
+
32
+
cmd := model.Init()
33
+
if cmd != nil {
34
+
t.Errorf("Init() should return nil, got %v", cmd)
35
+
}
36
+
}
37
+
38
+
func TestChangesetReviewModel_DefaultActions(t *testing.T) {
39
+
entries := []changeset.EntryWithFile{
40
+
createMockEntry("test1.md", "added", "cli", "Test entry 1"),
41
+
createMockEntry("test2.md", "fixed", "", "Test entry 2"),
42
+
}
43
+
44
+
model := NewChangesetReviewModel(entries)
45
+
46
+
for i, item := range model.items {
47
+
if item.Action != ActionKeep {
48
+
t.Errorf("Item %d should default to ActionKeep, got %v", i, item.Action)
49
+
}
50
+
}
51
+
}
52
+
53
+
func TestChangesetReviewModel_MarkDelete(t *testing.T) {
54
+
entries := []changeset.EntryWithFile{
55
+
createMockEntry("test.md", "added", "cli", "Test entry"),
56
+
}
57
+
58
+
model := NewChangesetReviewModel(entries)
59
+
60
+
updated, _ := model.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
61
+
model = updated.(ChangesetReviewModel)
62
+
63
+
updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}})
64
+
model = updated.(ChangesetReviewModel)
65
+
66
+
if model.items[0].Action != ActionDelete {
67
+
t.Errorf("Item should be marked for deletion, got action %v", model.items[0].Action)
68
+
}
69
+
}
70
+
71
+
func TestChangesetReviewModel_MarkEdit(t *testing.T) {
72
+
entries := []changeset.EntryWithFile{
73
+
createMockEntry("test.md", "added", "cli", "Test entry"),
74
+
}
75
+
76
+
model := NewChangesetReviewModel(entries)
77
+
78
+
updated, _ := model.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
79
+
model = updated.(ChangesetReviewModel)
80
+
81
+
updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'e'}})
82
+
model = updated.(ChangesetReviewModel)
83
+
84
+
if model.items[0].Action != ActionEdit {
85
+
t.Errorf("Item should be marked for editing, got action %v", model.items[0].Action)
86
+
}
87
+
}
88
+
89
+
func TestChangesetReviewModel_KeepAction(t *testing.T) {
90
+
entries := []changeset.EntryWithFile{
91
+
createMockEntry("test.md", "added", "cli", "Test entry"),
92
+
}
93
+
94
+
model := NewChangesetReviewModel(entries)
95
+
96
+
updated, _ := model.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
97
+
model = updated.(ChangesetReviewModel)
98
+
99
+
updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}})
100
+
model = updated.(ChangesetReviewModel)
101
+
102
+
if model.items[0].Action != ActionDelete {
103
+
t.Fatal("Setup failed: item should be marked for deletion")
104
+
}
105
+
106
+
updated, _ = model.Update(tea.KeyMsg{Type: tea.KeySpace})
107
+
model = updated.(ChangesetReviewModel)
108
+
109
+
if model.items[0].Action != ActionKeep {
110
+
t.Errorf("Item should be marked for keeping, got action %v", model.items[0].Action)
111
+
}
112
+
}
113
+
114
+
func TestChangesetReviewModel_Navigation(t *testing.T) {
115
+
entries := []changeset.EntryWithFile{
116
+
createMockEntry("test1.md", "added", "cli", "Test 1"),
117
+
createMockEntry("test2.md", "fixed", "", "Test 2"),
118
+
createMockEntry("test3.md", "changed", "api", "Test 3"),
119
+
}
120
+
121
+
model := NewChangesetReviewModel(entries)
122
+
123
+
updated, _ := model.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
124
+
model = updated.(ChangesetReviewModel)
125
+
126
+
if model.cursor != 0 {
127
+
t.Error("Cursor should start at 0")
128
+
}
129
+
130
+
updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyDown})
131
+
model = updated.(ChangesetReviewModel)
132
+
133
+
if model.cursor != 1 {
134
+
t.Errorf("Cursor should be at 1 after down, got %d", model.cursor)
135
+
}
136
+
137
+
updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyUp})
138
+
model = updated.(ChangesetReviewModel)
139
+
140
+
if model.cursor != 0 {
141
+
t.Errorf("Cursor should be at 0 after up, got %d", model.cursor)
142
+
}
143
+
}
144
+
145
+
func TestChangesetReviewModel_TopBottom(t *testing.T) {
146
+
entries := make([]changeset.EntryWithFile, 10)
147
+
for i := range entries {
148
+
entries[i] = createMockEntry("test.md", "added", "", "Test")
149
+
}
150
+
151
+
model := NewChangesetReviewModel(entries)
152
+
153
+
updated, _ := model.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
154
+
model = updated.(ChangesetReviewModel)
155
+
156
+
updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'G'}})
157
+
model = updated.(ChangesetReviewModel)
158
+
159
+
if model.cursor != len(entries)-1 {
160
+
t.Errorf("Cursor should be at bottom (index %d), got %d", len(entries)-1, model.cursor)
161
+
}
162
+
163
+
updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'g'}})
164
+
model = updated.(ChangesetReviewModel)
165
+
166
+
if model.cursor != 0 {
167
+
t.Errorf("Cursor should be at top (index 0), got %d", model.cursor)
168
+
}
169
+
}
170
+
171
+
func TestChangesetReviewModel_Confirm(t *testing.T) {
172
+
entries := []changeset.EntryWithFile{
173
+
createMockEntry("test.md", "added", "cli", "Test entry"),
174
+
}
175
+
176
+
model := NewChangesetReviewModel(entries)
177
+
tm := teatest.NewTestModel(t, model)
178
+
179
+
tm.Send(tea.KeyMsg{Type: tea.KeyEnter})
180
+
tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second))
181
+
182
+
finalModel := tm.FinalModel(t, teatest.WithFinalTimeout(time.Second))
183
+
reviewModel, ok := finalModel.(ChangesetReviewModel)
184
+
if !ok {
185
+
t.Fatal("Expected ChangesetReviewModel")
186
+
}
187
+
188
+
if !reviewModel.IsConfirmed() {
189
+
t.Error("Model should be confirmed after pressing enter")
190
+
}
191
+
if reviewModel.IsCancelled() {
192
+
t.Error("Model should not be cancelled")
193
+
}
194
+
}
195
+
196
+
func TestChangesetReviewModel_QuitKeys(t *testing.T) {
197
+
entries := []changeset.EntryWithFile{
198
+
createMockEntry("test.md", "added", "cli", "Test entry"),
199
+
}
200
+
201
+
quitKeys := []struct {
202
+
name string
203
+
keyType tea.KeyType
204
+
runes []rune
205
+
}{
206
+
{"q", tea.KeyRunes, []rune{'q'}},
207
+
{"esc", tea.KeyEsc, nil},
208
+
{"ctrl+c", tea.KeyCtrlC, nil},
209
+
}
210
+
211
+
for _, tc := range quitKeys {
212
+
t.Run(tc.name, func(t *testing.T) {
213
+
model := NewChangesetReviewModel(entries)
214
+
tm := teatest.NewTestModel(t, model)
215
+
216
+
var msg tea.Msg
217
+
if tc.runes != nil {
218
+
msg = tea.KeyMsg{Type: tc.keyType, Runes: tc.runes}
219
+
} else {
220
+
msg = tea.KeyMsg{Type: tc.keyType}
221
+
}
222
+
223
+
tm.Send(msg)
224
+
tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second))
225
+
226
+
finalModel := tm.FinalModel(t, teatest.WithFinalTimeout(time.Second))
227
+
reviewModel, ok := finalModel.(ChangesetReviewModel)
228
+
if !ok {
229
+
t.Fatal("Expected ChangesetReviewModel")
230
+
}
231
+
232
+
if !reviewModel.IsCancelled() {
233
+
t.Error("Model should be cancelled after quit key")
234
+
}
235
+
if reviewModel.IsConfirmed() {
236
+
t.Error("Model should not be confirmed")
237
+
}
238
+
})
239
+
}
240
+
}
241
+
242
+
func TestChangesetReviewModel_GetReviewedItems(t *testing.T) {
243
+
entries := []changeset.EntryWithFile{
244
+
createMockEntry("test1.md", "added", "cli", "Test 1"),
245
+
createMockEntry("test2.md", "fixed", "", "Test 2"),
246
+
createMockEntry("test3.md", "changed", "api", "Test 3"),
247
+
}
248
+
249
+
model := NewChangesetReviewModel(entries)
250
+
251
+
updated, _ := model.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
252
+
model = updated.(ChangesetReviewModel)
253
+
254
+
updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}})
255
+
model = updated.(ChangesetReviewModel)
256
+
257
+
updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyDown})
258
+
model = updated.(ChangesetReviewModel)
259
+
260
+
updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'e'}})
261
+
model = updated.(ChangesetReviewModel)
262
+
263
+
items := model.GetReviewedItems()
264
+
265
+
if len(items) != 3 {
266
+
t.Errorf("Expected 3 items, got %d", len(items))
267
+
}
268
+
269
+
if items[0].Action != ActionDelete {
270
+
t.Errorf("First item should be ActionDelete, got %v", items[0].Action)
271
+
}
272
+
273
+
if items[1].Action != ActionEdit {
274
+
t.Errorf("Second item should be ActionEdit, got %v", items[1].Action)
275
+
}
276
+
277
+
if items[2].Action != ActionKeep {
278
+
t.Errorf("Third item should be ActionKeep, got %v", items[2].Action)
279
+
}
280
+
}
281
+
282
+
func TestChangesetReviewModel_View(t *testing.T) {
283
+
entries := []changeset.EntryWithFile{
284
+
createMockEntry("test.md", "added", "cli", "Test entry"),
285
+
}
286
+
287
+
model := NewChangesetReviewModel(entries)
288
+
289
+
updated, _ := model.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
290
+
model = updated.(ChangesetReviewModel)
291
+
292
+
view := model.View()
293
+
294
+
if !strings.Contains(view, "Review unreleased changes") {
295
+
t.Error("View should contain header text")
296
+
}
297
+
if !strings.Contains(view, "navigate") {
298
+
t.Error("View should contain navigation help")
299
+
}
300
+
if !strings.Contains(view, "keep") {
301
+
t.Error("View should contain action counts")
302
+
}
303
+
}
304
+
305
+
func TestChangesetReviewModel_RenderHeader(t *testing.T) {
306
+
entries := []changeset.EntryWithFile{
307
+
createMockEntry("test1.md", "added", "cli", "Test 1"),
308
+
createMockEntry("test2.md", "fixed", "", "Test 2"),
309
+
}
310
+
311
+
model := NewChangesetReviewModel(entries)
312
+
model.width = 100
313
+
314
+
header := model.renderReviewHeader()
315
+
316
+
if !strings.Contains(header, "Review unreleased changes") {
317
+
t.Error("Header should contain title")
318
+
}
319
+
if !strings.Contains(header, "2") {
320
+
t.Error("Header should contain entry count")
321
+
}
322
+
}
323
+
324
+
func TestChangesetReviewModel_RenderFooter(t *testing.T) {
325
+
entries := []changeset.EntryWithFile{
326
+
createMockEntry("test1.md", "added", "cli", "Test 1"),
327
+
createMockEntry("test2.md", "fixed", "", "Test 2"),
328
+
createMockEntry("test3.md", "changed", "api", "Test 3"),
329
+
}
330
+
331
+
model := NewChangesetReviewModel(entries)
332
+
model.width = 100
333
+
334
+
updated, _ := model.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
335
+
model = updated.(ChangesetReviewModel)
336
+
337
+
updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}})
338
+
model = updated.(ChangesetReviewModel)
339
+
340
+
updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyDown})
341
+
model = updated.(ChangesetReviewModel)
342
+
343
+
updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'e'}})
344
+
model = updated.(ChangesetReviewModel)
345
+
346
+
footer := model.renderReviewFooter()
347
+
348
+
if !strings.Contains(footer, "keep: 1") {
349
+
t.Error("Footer should show 1 keep action")
350
+
}
351
+
if !strings.Contains(footer, "delete: 1") {
352
+
t.Error("Footer should show 1 delete action")
353
+
}
354
+
if !strings.Contains(footer, "edit: 1") {
355
+
t.Error("Footer should show 1 edit action")
356
+
}
357
+
}
358
+
359
+
func TestChangesetReviewModel_RenderReviewLine(t *testing.T) {
360
+
entries := []changeset.EntryWithFile{
361
+
createMockEntry("test.md", "added", "cli", "Test entry"),
362
+
}
363
+
364
+
model := NewChangesetReviewModel(entries)
365
+
model.width = 100
366
+
367
+
line := model.renderReviewLine(0, model.items[0])
368
+
369
+
if !strings.Contains(line, "[") {
370
+
t.Error("Line should contain action icon")
371
+
}
372
+
if !strings.Contains(line, "added") {
373
+
t.Error("Line should contain type")
374
+
}
375
+
if !strings.Contains(line, "cli") {
376
+
t.Error("Line should contain scope")
377
+
}
378
+
if !strings.Contains(line, "Test entry") {
379
+
t.Error("Line should contain summary")
380
+
}
381
+
}
382
+
383
+
func TestChangesetReviewModel_EmptyEntries(t *testing.T) {
384
+
entries := []changeset.EntryWithFile{}
385
+
386
+
model := NewChangesetReviewModel(entries)
387
+
388
+
if len(model.items) != 0 {
389
+
t.Errorf("Expected 0 items, got %d", len(model.items))
390
+
}
391
+
392
+
items := model.GetReviewedItems()
393
+
if len(items) != 0 {
394
+
t.Errorf("Expected 0 reviewed items, got %d", len(items))
395
+
}
396
+
}
397
+
398
+
func TestChangesetReviewModel_WindowResize(t *testing.T) {
399
+
entries := []changeset.EntryWithFile{
400
+
createMockEntry("test.md", "added", "cli", "Test entry"),
401
+
}
402
+
403
+
model := NewChangesetReviewModel(entries)
404
+
405
+
updated, _ := model.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
406
+
model = updated.(ChangesetReviewModel)
407
+
408
+
if model.width != 80 || model.height != 24 {
409
+
t.Errorf("Expected dimensions 80x24, got %dx%d", model.width, model.height)
410
+
}
411
+
if !model.ready {
412
+
t.Error("Model should be ready after window size message")
413
+
}
414
+
415
+
updated, _ = model.Update(tea.WindowSizeMsg{Width: 120, Height: 40})
416
+
model = updated.(ChangesetReviewModel)
417
+
418
+
if model.width != 120 || model.height != 40 {
419
+
t.Errorf("Expected dimensions 120x40, got %dx%d", model.width, model.height)
420
+
}
421
+
}
422
+
423
+
func TestChangesetReviewModel_ActionCounts(t *testing.T) {
424
+
entries := []changeset.EntryWithFile{
425
+
createMockEntry("test1.md", "added", "cli", "Test 1"),
426
+
createMockEntry("test2.md", "fixed", "", "Test 2"),
427
+
createMockEntry("test3.md", "changed", "api", "Test 3"),
428
+
createMockEntry("test4.md", "removed", "", "Test 4"),
429
+
}
430
+
431
+
model := NewChangesetReviewModel(entries)
432
+
433
+
updated, _ := model.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
434
+
model = updated.(ChangesetReviewModel)
435
+
436
+
updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}})
437
+
model = updated.(ChangesetReviewModel)
438
+
439
+
updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyDown})
440
+
model = updated.(ChangesetReviewModel)
441
+
442
+
updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}})
443
+
model = updated.(ChangesetReviewModel)
444
+
445
+
updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyDown})
446
+
model = updated.(ChangesetReviewModel)
447
+
448
+
updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'e'}})
449
+
model = updated.(ChangesetReviewModel)
450
+
451
+
items := model.GetReviewedItems()
452
+
453
+
deleteCount := 0
454
+
editCount := 0
455
+
keepCount := 0
456
+
457
+
for _, item := range items {
458
+
switch item.Action {
459
+
case ActionDelete:
460
+
deleteCount++
461
+
case ActionEdit:
462
+
editCount++
463
+
case ActionKeep:
464
+
keepCount++
465
+
}
466
+
}
467
+
468
+
if deleteCount != 2 {
469
+
t.Errorf("Expected 2 delete actions, got %d", deleteCount)
470
+
}
471
+
if editCount != 1 {
472
+
t.Errorf("Expected 1 edit action, got %d", editCount)
473
+
}
474
+
if keepCount != 1 {
475
+
t.Errorf("Expected 1 keep action, got %d", keepCount)
476
+
}
477
+
}
+6
-7
internal/ui/ui.go
+6
-7
internal/ui/ui.go
···
257
257
return m, tea.Quit
258
258
259
259
case key.Matches(msg, key.NewBinding(key.WithKeys("e"))):
260
-
// Toggle expanded/compressed view
261
260
m.expanded = !m.expanded
262
261
m.updateViewport()
263
262
···
272
271
m.viewport.GotoTop()
273
272
274
273
case key.Matches(msg, keys.Up):
275
-
m.viewport.LineUp(1)
274
+
m.viewport.ScrollUp(1)
276
275
277
276
case key.Matches(msg, keys.Down):
278
-
m.viewport.LineDown(1)
277
+
m.viewport.ScrollDown(1)
279
278
280
279
case key.Matches(msg, keys.PageUp):
281
-
m.viewport.ViewUp()
280
+
m.viewport.PageUp()
282
281
283
282
case key.Matches(msg, keys.PageDown):
284
-
m.viewport.ViewDown()
283
+
m.viewport.PageDown()
285
284
286
285
case key.Matches(msg, keys.HalfUp):
287
-
m.viewport.HalfViewUp()
286
+
m.viewport.HalfPageUp()
288
287
289
288
case key.Matches(msg, keys.HalfDown):
290
-
m.viewport.HalfViewDown()
289
+
m.viewport.HalfPageDown()
291
290
292
291
case key.Matches(msg, keys.Top):
293
292
m.viewport.GotoTop()