changelog generator & diff tool stormlightlabs.github.io/git-storm/
changelog changeset markdown golang git
at main 16 kB view raw
1package diff 2 3import ( 4 "fmt" 5 "strings" 6 7 "github.com/charmbracelet/lipgloss" 8 "github.com/muesli/reflow/wordwrap" 9 "github.com/stormlightlabs/git-storm/internal/style" 10) 11 12const ( 13 SymbolAdd = "┃" // addition 14 SymbolChange = "▎" // modification/change 15 SymbolDeleteLine = "_" // line removed 16 SymbolTopDelete = "‾" // deletion at top (overline) 17 SymbolChangeDelete = "~" // change + delete (hunk combined) 18 SymbolUntracked = "┆" // untracked lines/files 19 20 AsciiSymbolAdd = "|" // addition 21 AsciiSymbolChange = "|" // modification (same as add fallback) 22 AsciiSymbolDeleteLine = "-" // deletion line 23 AsciiSymbolTopDelete = "^" // “top delete” fallback 24 AsciiSymbolChangeDelete = "~" // change+delete still ~ 25 AsciiSymbolUntracked = ":" // untracked fallback 26 27 lineNumWidth = 4 28 gutterWidth = 3 29 minPaneWidth = 40 30 contextLines = 3 // Lines to show before/after changes 31 minUnchangedToHide = 10 // Minimum unchanged lines before hiding 32 compressedIndicator = "⋮" 33) 34 35type Formatter interface { 36 Format(edits []Edit) string 37} 38 39// SideBySideFormatter renders diff edits in a split-pane layout with syntax highlighting. 40type SideBySideFormatter struct { 41 // TerminalWidth is the total available width for rendering 42 TerminalWidth int 43 // ShowLineNumbers controls whether line numbers are displayed 44 ShowLineNumbers bool 45 // Expanded controls whether to show all unchanged lines or compress them 46 Expanded bool 47 // EnableWordWrap enables word wrapping for long lines 48 EnableWordWrap bool 49} 50 51// Format renders the edits as a styled side-by-side diff string. 52// 53// The left pane shows the old content (deletions and unchanged lines). 54// The right pane shows the new content (insertions and unchanged lines). 55func (f *SideBySideFormatter) Format(edits []Edit) string { 56 if len(edits) == 0 { 57 return style.StyleText.Render("No changes") 58 } 59 60 processedEdits := MergeReplacements(edits) 61 62 if !f.Expanded { 63 processedEdits = f.compressUnchangedBlocks(processedEdits) 64 } 65 66 paneWidth := f.calculatePaneWidth() 67 68 var sb strings.Builder 69 lineNumStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#6C7A89")).Faint(true) 70 71 for _, edit := range processedEdits { 72 left, right := f.renderEdit(edit, paneWidth) 73 74 if f.ShowLineNumbers { 75 leftNum := f.formatLineNum(edit.AIndex, lineNumStyle) 76 rightNum := f.formatLineNum(edit.BIndex, lineNumStyle) 77 78 sb.WriteString(leftNum) 79 sb.WriteString(left) 80 sb.WriteString(f.renderGutter(edit.Kind)) 81 sb.WriteString(rightNum) 82 sb.WriteString(right) 83 } else { 84 sb.WriteString(left) 85 sb.WriteString(f.renderGutter(edit.Kind)) 86 sb.WriteString(right) 87 } 88 sb.WriteString("\n") 89 } 90 91 return sb.String() 92} 93 94// calculatePaneWidth determines the width available for each content pane. 95func (f *SideBySideFormatter) calculatePaneWidth() int { 96 usedWidth := gutterWidth 97 if f.ShowLineNumbers { 98 usedWidth += 2 * lineNumWidth 99 } 100 101 availableWidth := f.TerminalWidth - usedWidth 102 if availableWidth < 0 { 103 availableWidth = 0 104 } 105 106 paneWidth := availableWidth / 2 107 108 if paneWidth < minPaneWidth { 109 totalNeeded := usedWidth + (2 * minPaneWidth) 110 if totalNeeded > f.TerminalWidth { 111 return paneWidth 112 } 113 return minPaneWidth 114 } 115 116 return paneWidth 117} 118 119// renderEdit formats a single edit operation for both left and right panes. 120func (f *SideBySideFormatter) renderEdit(edit Edit, paneWidth int) (left, right string) { 121 content := detab(edit.Content, 8) 122 content = f.truncateContent(content, paneWidth) 123 124 if edit.AIndex == -2 && edit.BIndex == -2 { 125 compressedStyle := lipgloss.NewStyle(). 126 Foreground(lipgloss.Color("#6C7A89")). 127 Faint(true). 128 Italic(true) 129 styled := f.padToWidth(compressedStyle.Render(content), paneWidth) 130 return styled, styled 131 } 132 133 switch edit.Kind { 134 case Equal: 135 leftStyled := f.padToWidth(style.StyleText.Render(content), paneWidth) 136 rightStyled := f.padToWidth(style.StyleText.Render(content), paneWidth) 137 return leftStyled, rightStyled 138 139 case Delete: 140 leftStyled := f.padToWidth(style.StyleRemoved.Render(content), paneWidth) 141 rightStyled := f.padToWidth("", paneWidth) 142 return leftStyled, rightStyled 143 144 case Insert: 145 leftStyled := f.padToWidth("", paneWidth) 146 rightStyled := f.padToWidth(style.StyleAdded.Render(content), paneWidth) 147 return leftStyled, rightStyled 148 149 case Replace: 150 newContent := detab(edit.NewContent, 8) 151 newContent = f.truncateContent(newContent, paneWidth) 152 leftStyled := f.padToWidth(style.StyleRemoved.Render(content), paneWidth) 153 rightStyled := f.padToWidth(style.StyleAdded.Render(newContent), paneWidth) 154 return leftStyled, rightStyled 155 156 default: 157 return f.padToWidth(content, paneWidth), 158 f.padToWidth(content, paneWidth) 159 } 160} 161 162// padToWidth pads a string with spaces to reach the target width. 163// If the string exceeds the target width, it truncates it. 164func (f *SideBySideFormatter) padToWidth(s string, targetWidth int) string { 165 currentWidth := lipgloss.Width(s) 166 167 if currentWidth > targetWidth { 168 return truncateToWidth(s, targetWidth) 169 } 170 171 if currentWidth == targetWidth { 172 return s 173 } 174 175 padding := strings.Repeat(" ", targetWidth-currentWidth) 176 return s + padding 177} 178 179// renderGutter creates the visual separator between left and right panes. 180func (f *SideBySideFormatter) renderGutter(kind EditKind) string { 181 var symbol string 182 var st lipgloss.Style 183 184 switch kind { 185 case Equal: 186 symbol = " " + SymbolUntracked + " " 187 st = lipgloss.NewStyle().Foreground(lipgloss.Color("#6C7A89")) 188 case Delete: 189 symbol = " " + SymbolDeleteLine + " " 190 st = style.StyleRemoved 191 case Insert: 192 symbol = " " + SymbolAdd + " " 193 st = style.StyleAdded 194 case Replace: 195 symbol = " " + SymbolChange + " " 196 st = style.StyleChanged 197 default: 198 symbol = " " + SymbolUntracked + " " 199 st = lipgloss.NewStyle() 200 } 201 202 return st.Render(symbol) 203} 204 205// formatLineNum renders a line number with styling. 206func (f *SideBySideFormatter) formatLineNum(index int, st lipgloss.Style) string { 207 if index < 0 { 208 return st.Width(lineNumWidth).Render("") 209 } 210 return st.Width(lineNumWidth).Render(fmt.Sprintf("%4d", index+1)) 211} 212 213// truncateContent ensures content fits within the pane width using proper display width. 214func (f *SideBySideFormatter) truncateContent(content string, maxWidth int) string { 215 content = strings.TrimRight(content, " \t\r\n") 216 217 if f.EnableWordWrap { 218 wrapped := wordwrap.String(content, maxWidth) 219 lines := strings.Split(wrapped, "\n") 220 if len(lines) > 0 { 221 return lines[0] 222 } 223 return wrapped 224 } 225 226 displayWidth := lipgloss.Width(content) 227 228 if displayWidth <= maxWidth { 229 return content 230 } 231 232 if maxWidth <= 3 { 233 return truncateToWidth(content, maxWidth) 234 } 235 236 targetWidth := maxWidth - 3 237 truncated := truncateToWidth(content, targetWidth) 238 return truncated + "..." 239} 240 241// truncateToWidth truncates a string to a specific display width. 242func truncateToWidth(s string, width int) string { 243 if width <= 0 { 244 return "" 245 } 246 247 var result strings.Builder 248 currentWidth := 0 249 250 for _, r := range s { 251 runeWidth := lipgloss.Width(string(r)) 252 253 if currentWidth+runeWidth > width { 254 break 255 } 256 257 result.WriteRune(r) 258 currentWidth += runeWidth 259 } 260 261 return result.String() 262} 263 264// compressUnchangedBlocks compresses large blocks of unchanged lines. 265// 266// It keeps contextLines before and after changes, and replaces large 267// blocks of unchanged lines with a single compressed indicator. 268func (f *SideBySideFormatter) compressUnchangedBlocks(edits []Edit) []Edit { 269 if len(edits) == 0 { 270 return edits 271 } 272 273 var result []Edit 274 var unchangedRun []Edit 275 276 for i, edit := range edits { 277 if edit.Kind == Equal { 278 unchangedRun = append(unchangedRun, edit) 279 280 isLast := i == len(edits)-1 281 nextIsChanged := !isLast && edits[i+1].Kind != Equal 282 283 if isLast || nextIsChanged { 284 if len(unchangedRun) >= minUnchangedToHide { 285 for j := 0; j < contextLines && j < len(unchangedRun); j++ { 286 result = append(result, unchangedRun[j]) 287 } 288 289 hiddenCount := len(unchangedRun) - (2 * contextLines) 290 if hiddenCount > 0 { 291 result = append(result, Edit{ 292 Kind: Equal, 293 AIndex: -2, 294 BIndex: -2, 295 Content: fmt.Sprintf("%s %d unchanged lines", compressedIndicator, hiddenCount), 296 }) 297 } 298 299 start := max(len(unchangedRun)-contextLines, contextLines) 300 for j := start; j < len(unchangedRun); j++ { 301 result = append(result, unchangedRun[j]) 302 } 303 } else { 304 result = append(result, unchangedRun...) 305 } 306 unchangedRun = nil 307 } 308 } else { 309 if len(unchangedRun) > 0 { 310 if len(unchangedRun) >= minUnchangedToHide { 311 for j := 0; j < contextLines && j < len(unchangedRun); j++ { 312 result = append(result, unchangedRun[j]) 313 } 314 315 hiddenCount := len(unchangedRun) - (2 * contextLines) 316 if hiddenCount > 0 { 317 result = append(result, Edit{ 318 Kind: Equal, 319 AIndex: -2, 320 BIndex: -2, 321 Content: fmt.Sprintf("%s %d unchanged lines", compressedIndicator, hiddenCount), 322 }) 323 } 324 325 start := max(len(unchangedRun)-contextLines, contextLines) 326 for j := start; j < len(unchangedRun); j++ { 327 result = append(result, unchangedRun[j]) 328 } 329 } else { 330 result = append(result, unchangedRun...) 331 } 332 unchangedRun = nil 333 } 334 335 result = append(result, edit) 336 } 337 } 338 339 return result 340} 341 342// detab replaces tabs with spaces so alignment stays consistent across terminals. 343func detab(s string, tabWidth int) string { 344 if tabWidth <= 0 { 345 tabWidth = 4 346 } 347 return strings.ReplaceAll(s, "\t", strings.Repeat(" ", tabWidth)) 348} 349 350// UnifiedFormatter renders diff edits in a traditional unified diff layout. 351type UnifiedFormatter struct { 352 // TerminalWidth is the total available width for rendering 353 TerminalWidth int 354 // ShowLineNumbers controls whether line numbers are displayed 355 ShowLineNumbers bool 356 // Expanded controls whether to show all unchanged lines or compress them 357 Expanded bool 358 // EnableWordWrap enables word wrapping for long lines 359 EnableWordWrap bool 360} 361 362// Format renders the edits as a styled unified diff string. 363// 364// The output shows deletions with "-" prefix, insertions with "+" prefix, and unchanged lines with " " prefix. 365func (f *UnifiedFormatter) Format(edits []Edit) string { 366 if len(edits) == 0 { 367 return style.StyleText.Render("No changes") 368 } 369 370 processedEdits := MergeReplacements(edits) 371 372 if !f.Expanded { 373 processedEdits = f.compressUnchangedBlocks(processedEdits) 374 } 375 376 contentWidth := f.calculateContentWidth() 377 378 var sb strings.Builder 379 lineNumStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#6C7A89")).Faint(true) 380 381 for _, edit := range processedEdits { 382 line := f.renderEdit(edit, contentWidth, lineNumStyle) 383 sb.WriteString(line) 384 sb.WriteString("\n") 385 386 if edit.Kind == Replace { 387 newLine := f.renderReplaceNew(edit, contentWidth, lineNumStyle) 388 sb.WriteString(newLine) 389 sb.WriteString("\n") 390 } 391 } 392 393 return sb.String() 394} 395 396// calculateContentWidth determines the width available for content. 397func (f *UnifiedFormatter) calculateContentWidth() int { 398 usedWidth := 2 399 if f.ShowLineNumbers { 400 usedWidth += 2*lineNumWidth + 2 401 } 402 return max(f.TerminalWidth-usedWidth, minPaneWidth) 403} 404 405// renderEdit formats a single edit operation. 406func (f *UnifiedFormatter) renderEdit(edit Edit, contentWidth int, lineNumStyle lipgloss.Style) string { 407 var sb strings.Builder 408 409 if edit.AIndex == -2 && edit.BIndex == -2 { 410 compressedStyle := lipgloss.NewStyle(). 411 Foreground(lipgloss.Color("#6C7A89")). 412 Faint(true). 413 Italic(true) 414 if f.ShowLineNumbers { 415 sb.WriteString(lineNumStyle.Width(lineNumWidth).Render("")) 416 sb.WriteString(" ") 417 sb.WriteString(lineNumStyle.Width(lineNumWidth).Render("")) 418 sb.WriteString(" ") 419 } 420 sb.WriteString(compressedStyle.Render(edit.Content)) 421 return sb.String() 422 } 423 424 if f.ShowLineNumbers { 425 oldNum := f.formatLineNum(edit.AIndex, lineNumStyle) 426 newNum := f.formatLineNum(edit.BIndex, lineNumStyle) 427 sb.WriteString(oldNum) 428 sb.WriteString(" ") 429 sb.WriteString(newNum) 430 sb.WriteString(" ") 431 } 432 433 content := detab(edit.Content, 8) 434 content = f.truncateContent(content, contentWidth) 435 436 switch edit.Kind { 437 case Equal: 438 sb.WriteString(style.StyleText.Render(" " + content)) 439 case Delete: 440 sb.WriteString(style.StyleRemoved.Render("-" + content)) 441 case Insert: 442 sb.WriteString(style.StyleAdded.Render("+" + content)) 443 case Replace: 444 sb.WriteString(style.StyleRemoved.Render("-" + content)) 445 default: 446 sb.WriteString(" " + content) 447 } 448 449 return sb.String() 450} 451 452// renderReplaceNew renders the new content line for a Replace operation. 453func (f *UnifiedFormatter) renderReplaceNew(edit Edit, contentWidth int, lineNumStyle lipgloss.Style) string { 454 var sb strings.Builder 455 456 if f.ShowLineNumbers { 457 sb.WriteString(lineNumStyle.Width(lineNumWidth).Render("")) 458 sb.WriteString(" ") 459 sb.WriteString(f.formatLineNum(edit.BIndex, lineNumStyle)) 460 sb.WriteString(" ") 461 } 462 463 content := detab(edit.NewContent, 8) 464 content = f.truncateContent(content, contentWidth) 465 sb.WriteString(style.StyleAdded.Render("+" + content)) 466 467 return sb.String() 468} 469 470// formatLineNum renders a line number with styling. 471func (f *UnifiedFormatter) formatLineNum(index int, st lipgloss.Style) string { 472 if index < 0 { 473 return st.Width(lineNumWidth).Render("") 474 } 475 return st.Width(lineNumWidth).Render(fmt.Sprintf("%4d", index+1)) 476} 477 478// truncateContent ensures content fits within the available width. 479func (f *UnifiedFormatter) truncateContent(content string, maxWidth int) string { 480 content = strings.TrimRight(content, " \t\r\n") 481 482 if f.EnableWordWrap { 483 wrapped := wordwrap.String(content, maxWidth) 484 lines := strings.Split(wrapped, "\n") 485 if len(lines) > 0 { 486 return lines[0] 487 } 488 return wrapped 489 } 490 491 displayWidth := lipgloss.Width(content) 492 493 if displayWidth <= maxWidth { 494 return content 495 } 496 497 if maxWidth <= 3 { 498 return truncateToWidth(content, maxWidth) 499 } 500 501 return truncateToWidth(content, maxWidth-3) + "..." 502} 503 504// compressUnchangedBlocks compresses large blocks of unchanged lines. 505func (f *UnifiedFormatter) compressUnchangedBlocks(edits []Edit) []Edit { 506 if len(edits) == 0 { 507 return edits 508 } 509 510 var result []Edit 511 var unchangedRun []Edit 512 513 for i, edit := range edits { 514 if edit.Kind == Equal { 515 unchangedRun = append(unchangedRun, edit) 516 517 isLast := i == len(edits)-1 518 nextIsChanged := !isLast && edits[i+1].Kind != Equal 519 520 if isLast || nextIsChanged { 521 if len(unchangedRun) >= minUnchangedToHide { 522 for j := 0; j < contextLines && j < len(unchangedRun); j++ { 523 result = append(result, unchangedRun[j]) 524 } 525 526 hiddenCount := len(unchangedRun) - (2 * contextLines) 527 if hiddenCount > 0 { 528 result = append(result, Edit{ 529 Kind: Equal, 530 AIndex: -2, 531 BIndex: -2, 532 Content: fmt.Sprintf("%s %d unchanged lines", compressedIndicator, hiddenCount), 533 }) 534 } 535 536 start := max(len(unchangedRun)-contextLines, contextLines) 537 for j := start; j < len(unchangedRun); j++ { 538 result = append(result, unchangedRun[j]) 539 } 540 } else { 541 result = append(result, unchangedRun...) 542 } 543 unchangedRun = nil 544 } 545 } else { 546 if len(unchangedRun) > 0 { 547 if len(unchangedRun) >= minUnchangedToHide { 548 for j := 0; j < contextLines && j < len(unchangedRun); j++ { 549 result = append(result, unchangedRun[j]) 550 } 551 552 hiddenCount := len(unchangedRun) - (2 * contextLines) 553 if hiddenCount > 0 { 554 result = append(result, Edit{ 555 Kind: Equal, 556 AIndex: -2, 557 BIndex: -2, 558 Content: fmt.Sprintf("%s %d unchanged lines", compressedIndicator, hiddenCount), 559 }) 560 } 561 562 start := max(len(unchangedRun)-contextLines, contextLines) 563 for j := start; j < len(unchangedRun); j++ { 564 result = append(result, unchangedRun[j]) 565 } 566 } else { 567 result = append(result, unchangedRun...) 568 } 569 unchangedRun = nil 570 } 571 572 result = append(result, edit) 573 } 574 } 575 576 return result 577}