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

Configure Feed

Select the types of activity you want to include in your feed.

at main 363 lines 8.7 kB view raw
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}