changelog generator & diff tool stormlightlabs.github.io/git-storm/
changelog changeset markdown golang git
at main 11 kB view raw
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}