changelog generator & diff tool stormlightlabs.github.io/git-storm/
changelog changeset markdown golang git

feat(unreleased): interactive commit re/viewer

+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
··· 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
··· 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
··· 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()