changelog generator & diff tool
stormlightlabs.github.io/git-storm/
changelog
changeset
markdown
golang
git
1package ui
2
3import (
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.
16type ReviewAction int
17
18const (
19 ActionKeep ReviewAction = iota
20 ActionDelete
21 ActionEdit
22)
23
24// ReviewItem wraps a changeset entry with its review state.
25type ReviewItem struct {
26 Entry changeset.EntryWithFile
27 Action ReviewAction
28}
29
30// ChangesetReviewModel holds the state for the interactive changeset review TUI.
31type 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.
43type 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
57var 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.
105func 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).
123func (m ChangesetReviewModel) Init() tea.Cmd {
124 return nil
125}
126
127// Update handles messages and updates the model state.
128func (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.
215func (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.
227func (m ChangesetReviewModel) GetReviewedItems() []ReviewItem {
228 return m.items
229}
230
231// IsCancelled returns true if the user quit without confirming.
232func (m ChangesetReviewModel) IsCancelled() bool {
233 return m.cancelled
234}
235
236// IsConfirmed returns true if the user confirmed their review.
237func (m ChangesetReviewModel) IsConfirmed() bool {
238 return m.confirmed
239}
240
241// ensureVisible scrolls the viewport to keep the cursor visible.
242func (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.
256func (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.
272func (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.
319func (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.
331func (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}