changelog generator & diff tool
stormlightlabs.github.io/git-storm/
changelog
changeset
markdown
golang
git
1package ui
2
3import (
4 "fmt"
5 "strings"
6 "time"
7
8 "github.com/charmbracelet/bubbles/key"
9 "github.com/charmbracelet/bubbles/viewport"
10 tea "github.com/charmbracelet/bubbletea"
11 "github.com/charmbracelet/lipgloss"
12 "github.com/go-git/go-git/v6/plumbing/object"
13 "github.com/stormlightlabs/git-storm/internal/gitlog"
14 "github.com/stormlightlabs/git-storm/internal/style"
15)
16
17// CommitItem wraps a commit with its selection state and parsed metadata.
18type CommitItem struct {
19 Commit *object.Commit
20 Meta gitlog.CommitMeta
21 Category string
22 Selected bool
23}
24
25// CommitSelectorModel holds the state for the interactive commit selector TUI.
26type CommitSelectorModel struct {
27 viewport viewport.Model
28 items []CommitItem
29 cursor int
30 ready bool
31 fromRef string
32 toRef string
33 width int
34 height int
35 confirmed bool
36 cancelled bool
37}
38
39// commitSelectorKeyMap defines keyboard shortcuts for the commit selector.
40type commitSelectorKeyMap struct {
41 Up key.Binding
42 Down key.Binding
43 PageUp key.Binding
44 PageDown key.Binding
45 Top key.Binding
46 Bottom key.Binding
47 Toggle key.Binding
48 SelectAll key.Binding
49 DeselectAll key.Binding
50 Confirm key.Binding
51 Quit key.Binding
52}
53
54var commitKeys = commitSelectorKeyMap{
55 Up: key.NewBinding(
56 key.WithKeys("up", "k"),
57 key.WithHelp("↑/k", "up"),
58 ),
59 Down: key.NewBinding(
60 key.WithKeys("down", "j"),
61 key.WithHelp("↓/j", "down"),
62 ),
63 PageUp: key.NewBinding(
64 key.WithKeys("pgup", "u"),
65 key.WithHelp("pgup/u", "page up"),
66 ),
67 PageDown: key.NewBinding(
68 key.WithKeys("pgdown", "d"),
69 key.WithHelp("pgdn/d", "page down"),
70 ),
71 Top: key.NewBinding(
72 key.WithKeys("g", "home"),
73 key.WithHelp("g/home", "top"),
74 ),
75 Bottom: key.NewBinding(
76 key.WithKeys("G", "end"),
77 key.WithHelp("G/end", "bottom"),
78 ),
79 Toggle: key.NewBinding(
80 key.WithKeys(" "),
81 key.WithHelp("space", "toggle"),
82 ),
83 SelectAll: key.NewBinding(
84 key.WithKeys("a"),
85 key.WithHelp("a", "select all"),
86 ),
87 DeselectAll: key.NewBinding(
88 key.WithKeys("A"),
89 key.WithHelp("A", "deselect all"),
90 ),
91 Confirm: key.NewBinding(
92 key.WithKeys("enter", "c"),
93 key.WithHelp("enter/c", "confirm"),
94 ),
95 Quit: key.NewBinding(
96 key.WithKeys("q", "esc", "ctrl+c"),
97 key.WithHelp("q", "quit"),
98 ),
99}
100
101// NewCommitSelectorModel creates a new commit selector model.
102func NewCommitSelectorModel(commits []*object.Commit, fromRef, toRef string, parser gitlog.CommitParser) CommitSelectorModel {
103 items := make([]CommitItem, 0, len(commits))
104
105 for _, commit := range commits {
106 subject := commit.Message
107 body := ""
108 lines := strings.Split(commit.Message, "\n")
109 if len(lines) > 0 {
110 subject = lines[0]
111 if len(lines) > 1 {
112 body = strings.Join(lines[1:], "\n")
113 }
114 }
115
116 meta, err := parser.Parse(commit.Hash.String(), subject, body, commit.Author.When)
117 if err != nil {
118 meta = gitlog.CommitMeta{
119 Type: "unknown",
120 Description: subject,
121 Body: body,
122 }
123 }
124
125 category := parser.Categorize(meta)
126
127 items = append(items, CommitItem{
128 Commit: commit,
129 Meta: meta,
130 Category: category,
131 Selected: category != "",
132 })
133 }
134
135 return CommitSelectorModel{
136 items: items,
137 cursor: 0,
138 fromRef: fromRef,
139 toRef: toRef,
140 ready: false,
141 }
142}
143
144// Init initializes the model (required by Bubble Tea).
145func (m CommitSelectorModel) Init() tea.Cmd {
146 return nil
147}
148
149// Update handles messages and updates the model state.
150func (m CommitSelectorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
151 var cmd tea.Cmd
152
153 switch msg := msg.(type) {
154 case tea.KeyMsg:
155 switch {
156 case key.Matches(msg, commitKeys.Quit):
157 m.cancelled = true
158 return m, tea.Quit
159
160 case key.Matches(msg, commitKeys.Confirm):
161 m.confirmed = true
162 return m, tea.Quit
163
164 case key.Matches(msg, commitKeys.Up):
165 if m.cursor > 0 {
166 m.cursor--
167 m.ensureVisible()
168 }
169
170 case key.Matches(msg, commitKeys.Down):
171 if m.cursor < len(m.items)-1 {
172 m.cursor++
173 m.ensureVisible()
174 }
175
176 case key.Matches(msg, commitKeys.PageUp):
177 m.cursor -= m.viewport.Height
178 if m.cursor < 0 {
179 m.cursor = 0
180 }
181 m.ensureVisible()
182
183 case key.Matches(msg, commitKeys.PageDown):
184 m.cursor += m.viewport.Height
185 if m.cursor >= len(m.items) {
186 m.cursor = len(m.items) - 1
187 }
188 m.ensureVisible()
189
190 case key.Matches(msg, commitKeys.Top):
191 m.cursor = 0
192 m.ensureVisible()
193
194 case key.Matches(msg, commitKeys.Bottom):
195 m.cursor = len(m.items) - 1
196 m.ensureVisible()
197
198 case key.Matches(msg, commitKeys.Toggle):
199 if m.cursor >= 0 && m.cursor < len(m.items) {
200 m.items[m.cursor].Selected = !m.items[m.cursor].Selected
201 m.updateContent()
202 }
203
204 case key.Matches(msg, commitKeys.SelectAll):
205 for i := range m.items {
206 m.items[i].Selected = true
207 }
208 m.updateContent()
209
210 case key.Matches(msg, commitKeys.DeselectAll):
211 for i := range m.items {
212 m.items[i].Selected = false
213 }
214 m.updateContent()
215 }
216
217 case tea.WindowSizeMsg:
218 m.width = msg.Width
219 m.height = msg.Height
220
221 if !m.ready {
222 m.viewport = viewport.New(msg.Width, msg.Height-4)
223 m.ready = true
224 m.updateContent()
225 } else {
226 m.viewport.Width = msg.Width
227 m.viewport.Height = msg.Height - 4
228 m.updateContent()
229 }
230 }
231
232 m.viewport, cmd = m.viewport.Update(msg)
233 return m, cmd
234}
235
236// View renders the current view of the commit selector.
237func (m CommitSelectorModel) View() string {
238 if !m.ready {
239 return "\n Initializing..."
240 }
241
242 header := m.renderCommitHeader()
243 footer := m.renderCommitFooter()
244
245 return fmt.Sprintf("%s\n%s\n%s", header, m.viewport.View(), footer)
246}
247
248// GetSelectedCommits returns the list of selected commits.
249func (m CommitSelectorModel) GetSelectedCommits() []*object.Commit {
250 selected := make([]*object.Commit, 0)
251 for _, item := range m.items {
252 if item.Selected {
253 selected = append(selected, item.Commit)
254 }
255 }
256 return selected
257}
258
259// GetSelectedItems returns the list of selected commit items with metadata.
260func (m CommitSelectorModel) GetSelectedItems() []CommitItem {
261 selected := make([]CommitItem, 0)
262 for _, item := range m.items {
263 if item.Selected {
264 selected = append(selected, item)
265 }
266 }
267 return selected
268}
269
270// IsCancelled returns true if the user quit without confirming.
271func (m CommitSelectorModel) IsCancelled() bool {
272 return m.cancelled
273}
274
275// IsConfirmed returns true if the user confirmed their selection.
276func (m CommitSelectorModel) IsConfirmed() bool {
277 return m.confirmed
278}
279
280// ensureVisible scrolls the viewport to keep the cursor visible.
281func (m *CommitSelectorModel) ensureVisible() {
282 lineHeight := 1
283 cursorY := m.cursor * lineHeight
284
285 if cursorY < m.viewport.YOffset {
286 m.viewport.YOffset = cursorY
287 } else if cursorY >= m.viewport.YOffset+m.viewport.Height {
288 m.viewport.YOffset = cursorY - m.viewport.Height + 1
289 }
290
291 m.updateContent()
292}
293
294// updateContent regenerates the viewport content.
295func (m *CommitSelectorModel) updateContent() {
296 if !m.ready {
297 return
298 }
299
300 var content strings.Builder
301
302 for i, item := range m.items {
303 content.WriteString(m.renderCommitLine(i, item))
304 content.WriteString("\n")
305 }
306
307 m.viewport.SetContent(content.String())
308}
309
310// renderCommitLine renders a single commit line with selection state.
311func (m CommitSelectorModel) renderCommitLine(index int, item CommitItem) string {
312 checkbox := "[ ]"
313 if item.Selected {
314 checkbox = "[✓]"
315 }
316
317 shortHash := item.Commit.Hash.String()[:gitlog.ShaLen]
318 subject := item.Meta.Description
319 if subject == "" {
320 subject = strings.Split(item.Commit.Message, "\n")[0]
321 }
322
323 maxSubjectLen := max(m.width-60, 20)
324 if len(subject) > maxSubjectLen {
325 subject = subject[:maxSubjectLen-3] + "..."
326 }
327
328 author := item.Commit.Author.Name
329 if len(author) > 15 {
330 author = author[:12] + "..."
331 }
332
333 timeAgo := fmtTimeAgo(item.Commit.Author.When)
334
335 category := item.Category
336 if category == "" {
337 category = "skip"
338 }
339
340 categoryStyle := getCategoryStyle(category)
341 lineStyle := lipgloss.NewStyle()
342 checkboxStyle := lipgloss.NewStyle().Foreground(style.AccentBlue)
343 hashStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#6C7A89"))
344
345 if index == m.cursor {
346 lineStyle = lineStyle.Background(lipgloss.Color("#1f2428"))
347 checkboxStyle = checkboxStyle.Bold(true)
348 }
349
350 line := fmt.Sprintf("%s %s %s %s %s %s",
351 checkboxStyle.Render(checkbox),
352 hashStyle.Render(shortHash),
353 categoryStyle.Render(fmt.Sprintf("%-8s", category)),
354 subject,
355 lipgloss.NewStyle().Foreground(lipgloss.Color("#6C7A89")).Render(author),
356 lipgloss.NewStyle().Foreground(lipgloss.Color("#6C7A89")).Faint(true).Render(timeAgo),
357 )
358
359 return lineStyle.Render(line)
360}
361
362// renderCommitHeader creates the header showing the range.
363func (m CommitSelectorModel) renderCommitHeader() string {
364 headerStyle := lipgloss.NewStyle().
365 Foreground(style.AccentBlue).
366 Bold(true).
367 Padding(0, 1)
368
369 return headerStyle.Render(
370 fmt.Sprintf("Select commits to include (%s..%s)", m.fromRef, m.toRef),
371 )
372}
373
374// renderCommitFooter creates the footer with help text and selection count.
375func (m CommitSelectorModel) renderCommitFooter() string {
376 footerStyle := lipgloss.NewStyle().
377 Foreground(lipgloss.Color("#6C7A89")).
378 Faint(true).
379 Padding(0, 1)
380
381 selectedCount := 0
382 for _, item := range m.items {
383 if item.Selected {
384 selectedCount++
385 }
386 }
387
388 helpText := "↑/↓: navigate • space: toggle • a/A: select/deselect all • enter: confirm • q: quit"
389 selectionInfo := fmt.Sprintf("%d/%d selected", selectedCount, len(m.items))
390
391 totalWidth := m.width
392 helpWidth := lipgloss.Width(helpText)
393 selWidth := lipgloss.Width(selectionInfo)
394 padding := max(totalWidth-helpWidth-selWidth-2, 0)
395
396 return footerStyle.Render(
397 helpText + strings.Repeat(" ", padding) + selectionInfo,
398 )
399}
400
401// fmtTimeAgo returns a human-readable relative time string.
402func fmtTimeAgo(t time.Time) string {
403 duration := time.Since(t)
404
405 if duration < time.Minute {
406 return "just now"
407 } else if duration < time.Hour {
408 minutes := int(duration.Minutes())
409 return fmt.Sprintf("%dm ago", minutes)
410 } else if duration < 24*time.Hour {
411 hours := int(duration.Hours())
412 return fmt.Sprintf("%dh ago", hours)
413 } else if duration < 30*24*time.Hour {
414 days := int(duration.Hours() / 24)
415 return fmt.Sprintf("%dd ago", days)
416 } else if duration < 365*24*time.Hour {
417 months := int(duration.Hours() / 24 / 30)
418 return fmt.Sprintf("%dmo ago", months)
419 } else {
420 years := int(duration.Hours() / 24 / 365)
421 return fmt.Sprintf("%dy ago", years)
422 }
423}
424
425func getCategoryStyle(c string) lipgloss.Style {
426 s := lipgloss.NewStyle().Foreground(lipgloss.Color("#6C7A89"))
427 switch c {
428 case "added":
429 s = lipgloss.NewStyle().Foreground(style.AddedColor)
430 case "changed":
431 s = lipgloss.NewStyle().Foreground(style.ChangedColor)
432 case "fixed":
433 s = lipgloss.NewStyle().Foreground(style.AccentBlue)
434 case "removed":
435 s = lipgloss.NewStyle().Foreground(style.RemovedColor)
436 case "security":
437 s = lipgloss.NewStyle().Foreground(lipgloss.Color("#BF616A"))
438 }
439 return s
440}