Something went wrong. Try again.
changelog generator & diff tool stormlightlabs.github.io/git-storm
changelog changeset markdown golang git
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578package diff
import ( "fmt" "strings"
"github.com/charmbracelet/lipgloss" "github.com/muesli/reflow/wordwrap" "github.com/stormlightlabs/git-storm/internal/style")
const ( SymbolAdd = "┃" // addition SymbolChange = "▎" // modification/change SymbolDeleteLine = "_" // line removed SymbolTopDelete = "‾" // deletion at top (overline) SymbolChangeDelete = "~" // change + delete (hunk combined) SymbolUntracked = "┆" // untracked lines/files
AsciiSymbolAdd = "|" // addition AsciiSymbolChange = "|" // modification (same as add fallback) AsciiSymbolDeleteLine = "-" // deletion line AsciiSymbolTopDelete = "^" // “top delete” fallback AsciiSymbolChangeDelete = "~" // change+delete still ~ AsciiSymbolUntracked = ":" // untracked fallback
lineNumWidth = 4 gutterWidth = 3 minPaneWidth = 40 contextLines = 3 // Lines to show before/after changes minUnchangedToHide = 10 // Minimum unchanged lines before hiding compressedIndicator = "⋮")
type Formatter interface { Format(edits []Edit) string}
// SideBySideFormatter renders diff edits in a split-pane layout with syntax highlighting.type SideBySideFormatter struct { // TerminalWidth is the total available width for rendering TerminalWidth int // ShowLineNumbers controls whether line numbers are displayed ShowLineNumbers bool // Expanded controls whether to show all unchanged lines or compress them Expanded bool // EnableWordWrap enables word wrapping for long lines EnableWordWrap bool}
// Format renders the edits as a styled side-by-side diff string.//// The left pane shows the old content (deletions and unchanged lines).// The right pane shows the new content (insertions and unchanged lines).func (f *SideBySideFormatter) Format(edits []Edit) string { if len(edits) == 0 { return style.StyleText.Render("No changes") }
processedEdits := MergeReplacements(edits)
if !f.Expanded { processedEdits = f.compressUnchangedBlocks(processedEdits) }
paneWidth := f.calculatePaneWidth()
var sb strings.Builder lineNumStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#6C7A89")).Faint(true)
for _, edit := range processedEdits { left, right := f.renderEdit(edit, paneWidth)
if f.ShowLineNumbers { leftNum := f.formatLineNum(edit.AIndex, lineNumStyle) rightNum := f.formatLineNum(edit.BIndex, lineNumStyle)
sb.WriteString(leftNum) sb.WriteString(left) sb.WriteString(f.renderGutter(edit.Kind)) sb.WriteString(rightNum) sb.WriteString(right) } else { sb.WriteString(left) sb.WriteString(f.renderGutter(edit.Kind)) sb.WriteString(right) } sb.WriteString("\n") }
return sb.String()}
// calculatePaneWidth determines the width available for each content pane.func (f *SideBySideFormatter) calculatePaneWidth() int { usedWidth := gutterWidth if f.ShowLineNumbers { usedWidth += 2 * lineNumWidth }
availableWidth := f.TerminalWidth - usedWidth if availableWidth < 0 { availableWidth = 0 }
paneWidth := availableWidth / 2
if paneWidth < minPaneWidth { totalNeeded := usedWidth + (2 * minPaneWidth) if totalNeeded > f.TerminalWidth { return paneWidth } return minPaneWidth }
return paneWidth}
// renderEdit formats a single edit operation for both left and right panes.func (f *SideBySideFormatter) renderEdit(edit Edit, paneWidth int) (left, right string) { content := detab(edit.Content, 8) content = f.truncateContent(content, paneWidth)
if edit.AIndex == -2 && edit.BIndex == -2 { compressedStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("#6C7A89")). Faint(true). Italic(true) styled := f.padToWidth(compressedStyle.Render(content), paneWidth) return styled, styled }
switch edit.Kind { case Equal: leftStyled := f.padToWidth(style.StyleText.Render(content), paneWidth) rightStyled := f.padToWidth(style.StyleText.Render(content), paneWidth) return leftStyled, rightStyled
case Delete: leftStyled := f.padToWidth(style.StyleRemoved.Render(content), paneWidth) rightStyled := f.padToWidth("", paneWidth) return leftStyled, rightStyled
case Insert: leftStyled := f.padToWidth("", paneWidth) rightStyled := f.padToWidth(style.StyleAdded.Render(content), paneWidth) return leftStyled, rightStyled
case Replace: newContent := detab(edit.NewContent, 8) newContent = f.truncateContent(newContent, paneWidth) leftStyled := f.padToWidth(style.StyleRemoved.Render(content), paneWidth) rightStyled := f.padToWidth(style.StyleAdded.Render(newContent), paneWidth) return leftStyled, rightStyled
default: return f.padToWidth(content, paneWidth), f.padToWidth(content, paneWidth) }}
// padToWidth pads a string with spaces to reach the target width.// If the string exceeds the target width, it truncates it.func (f *SideBySideFormatter) padToWidth(s string, targetWidth int) string { currentWidth := lipgloss.Width(s)
if currentWidth > targetWidth { return truncateToWidth(s, targetWidth) }
if currentWidth == targetWidth { return s }
padding := strings.Repeat(" ", targetWidth-currentWidth) return s + padding}
// renderGutter creates the visual separator between left and right panes.func (f *SideBySideFormatter) renderGutter(kind EditKind) string { var symbol string var st lipgloss.Style
switch kind { case Equal: symbol = " " + SymbolUntracked + " " st = lipgloss.NewStyle().Foreground(lipgloss.Color("#6C7A89")) case Delete: symbol = " " + SymbolDeleteLine + " " st = style.StyleRemoved case Insert: symbol = " " + SymbolAdd + " " st = style.StyleAdded case Replace: symbol = " " + SymbolChange + " " st = style.StyleChanged default: symbol = " " + SymbolUntracked + " " st = lipgloss.NewStyle() }
return st.Render(symbol)}
// formatLineNum renders a line number with styling.func (f *SideBySideFormatter) formatLineNum(index int, st lipgloss.Style) string { if index < 0 { return st.Width(lineNumWidth).Render("") } return st.Width(lineNumWidth).Render(fmt.Sprintf("%4d", index+1))}
// truncateContent ensures content fits within the pane width using proper display width.func (f *SideBySideFormatter) truncateContent(content string, maxWidth int) string { content = strings.TrimRight(content, " \t\r\n")
if f.EnableWordWrap { wrapped := wordwrap.String(content, maxWidth) lines := strings.Split(wrapped, "\n") if len(lines) > 0 { return lines[0] } return wrapped }
displayWidth := lipgloss.Width(content)
if displayWidth <= maxWidth { return content }
if maxWidth <= 3 { return truncateToWidth(content, maxWidth) }
targetWidth := maxWidth - 3 truncated := truncateToWidth(content, targetWidth) return truncated + "..."}
// truncateToWidth truncates a string to a specific display width.func truncateToWidth(s string, width int) string { if width <= 0 { return "" }
var result strings.Builder currentWidth := 0
for _, r := range s { runeWidth := lipgloss.Width(string(r))
if currentWidth+runeWidth > width { break }
result.WriteRune(r) currentWidth += runeWidth }
return result.String()}
// compressUnchangedBlocks compresses large blocks of unchanged lines.//// It keeps contextLines before and after changes, and replaces large// blocks of unchanged lines with a single compressed indicator.func (f *SideBySideFormatter) compressUnchangedBlocks(edits []Edit) []Edit { if len(edits) == 0 { return edits }
var result []Edit var unchangedRun []Edit
for i, edit := range edits { if edit.Kind == Equal { unchangedRun = append(unchangedRun, edit)
isLast := i == len(edits)-1 nextIsChanged := !isLast && edits[i+1].Kind != Equal
if isLast || nextIsChanged { if len(unchangedRun) >= minUnchangedToHide { for j := 0; j < contextLines && j < len(unchangedRun); j++ { result = append(result, unchangedRun[j]) }
hiddenCount := len(unchangedRun) - (2 * contextLines) if hiddenCount > 0 { result = append(result, Edit{ Kind: Equal, AIndex: -2, BIndex: -2, Content: fmt.Sprintf("%s %d unchanged lines", compressedIndicator, hiddenCount), }) }
start := max(len(unchangedRun)-contextLines, contextLines) for j := start; j < len(unchangedRun); j++ { result = append(result, unchangedRun[j]) } } else { result = append(result, unchangedRun...) } unchangedRun = nil } } else { if len(unchangedRun) > 0 { if len(unchangedRun) >= minUnchangedToHide { for j := 0; j < contextLines && j < len(unchangedRun); j++ { result = append(result, unchangedRun[j]) }
hiddenCount := len(unchangedRun) - (2 * contextLines) if hiddenCount > 0 { result = append(result, Edit{ Kind: Equal, AIndex: -2, BIndex: -2, Content: fmt.Sprintf("%s %d unchanged lines", compressedIndicator, hiddenCount), }) }
start := max(len(unchangedRun)-contextLines, contextLines) for j := start; j < len(unchangedRun); j++ { result = append(result, unchangedRun[j]) } } else { result = append(result, unchangedRun...) } unchangedRun = nil }
result = append(result, edit) } }
return result}
// detab replaces tabs with spaces so alignment stays consistent across terminals.func detab(s string, tabWidth int) string { if tabWidth <= 0 { tabWidth = 4 } return strings.ReplaceAll(s, "\t", strings.Repeat(" ", tabWidth))}
// UnifiedFormatter renders diff edits in a traditional unified diff layout.type UnifiedFormatter struct { // TerminalWidth is the total available width for rendering TerminalWidth int // ShowLineNumbers controls whether line numbers are displayed ShowLineNumbers bool // Expanded controls whether to show all unchanged lines or compress them Expanded bool // EnableWordWrap enables word wrapping for long lines EnableWordWrap bool}
// Format renders the edits as a styled unified diff string.//// The output shows deletions with "-" prefix, insertions with "+" prefix, and unchanged lines with " " prefix.func (f *UnifiedFormatter) Format(edits []Edit) string { if len(edits) == 0 { return style.StyleText.Render("No changes") }
processedEdits := MergeReplacements(edits)
if !f.Expanded { processedEdits = f.compressUnchangedBlocks(processedEdits) }
contentWidth := f.calculateContentWidth()
var sb strings.Builder lineNumStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#6C7A89")).Faint(true)
for _, edit := range processedEdits { line := f.renderEdit(edit, contentWidth, lineNumStyle) sb.WriteString(line) sb.WriteString("\n")
if edit.Kind == Replace { newLine := f.renderReplaceNew(edit, contentWidth, lineNumStyle) sb.WriteString(newLine) sb.WriteString("\n") } }
return sb.String()}
// calculateContentWidth determines the width available for content.func (f *UnifiedFormatter) calculateContentWidth() int { usedWidth := 2 if f.ShowLineNumbers { usedWidth += 2*lineNumWidth + 2 } return max(f.TerminalWidth-usedWidth, minPaneWidth)}
// renderEdit formats a single edit operation.func (f *UnifiedFormatter) renderEdit(edit Edit, contentWidth int, lineNumStyle lipgloss.Style) string { var sb strings.Builder
if edit.AIndex == -2 && edit.BIndex == -2 { compressedStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("#6C7A89")). Faint(true). Italic(true) if f.ShowLineNumbers { sb.WriteString(lineNumStyle.Width(lineNumWidth).Render("")) sb.WriteString(" ") sb.WriteString(lineNumStyle.Width(lineNumWidth).Render("")) sb.WriteString(" ") } sb.WriteString(compressedStyle.Render(edit.Content)) return sb.String() }
if f.ShowLineNumbers { oldNum := f.formatLineNum(edit.AIndex, lineNumStyle) newNum := f.formatLineNum(edit.BIndex, lineNumStyle) sb.WriteString(oldNum) sb.WriteString(" ") sb.WriteString(newNum) sb.WriteString(" ") }
content := detab(edit.Content, 8) content = f.truncateContent(content, contentWidth)
switch edit.Kind { case Equal: sb.WriteString(style.StyleText.Render(" " + content)) case Delete: sb.WriteString(style.StyleRemoved.Render("-" + content)) case Insert: sb.WriteString(style.StyleAdded.Render("+" + content)) case Replace: sb.WriteString(style.StyleRemoved.Render("-" + content)) default: sb.WriteString(" " + content) }
return sb.String()}
// renderReplaceNew renders the new content line for a Replace operation.func (f *UnifiedFormatter) renderReplaceNew(edit Edit, contentWidth int, lineNumStyle lipgloss.Style) string { var sb strings.Builder
if f.ShowLineNumbers { sb.WriteString(lineNumStyle.Width(lineNumWidth).Render("")) sb.WriteString(" ") sb.WriteString(f.formatLineNum(edit.BIndex, lineNumStyle)) sb.WriteString(" ") }
content := detab(edit.NewContent, 8) content = f.truncateContent(content, contentWidth) sb.WriteString(style.StyleAdded.Render("+" + content))
return sb.String()}
// formatLineNum renders a line number with styling.func (f *UnifiedFormatter) formatLineNum(index int, st lipgloss.Style) string { if index < 0 { return st.Width(lineNumWidth).Render("") } return st.Width(lineNumWidth).Render(fmt.Sprintf("%4d", index+1))}
// truncateContent ensures content fits within the available width.func (f *UnifiedFormatter) truncateContent(content string, maxWidth int) string { content = strings.TrimRight(content, " \t\r\n")
if f.EnableWordWrap { wrapped := wordwrap.String(content, maxWidth) lines := strings.Split(wrapped, "\n") if len(lines) > 0 { return lines[0] } return wrapped }
displayWidth := lipgloss.Width(content)
if displayWidth <= maxWidth { return content }
if maxWidth <= 3 { return truncateToWidth(content, maxWidth) }
return truncateToWidth(content, maxWidth-3) + "..."}
// compressUnchangedBlocks compresses large blocks of unchanged lines.func (f *UnifiedFormatter) compressUnchangedBlocks(edits []Edit) []Edit { if len(edits) == 0 { return edits }
var result []Edit var unchangedRun []Edit
for i, edit := range edits { if edit.Kind == Equal { unchangedRun = append(unchangedRun, edit)
isLast := i == len(edits)-1 nextIsChanged := !isLast && edits[i+1].Kind != Equal
if isLast || nextIsChanged { if len(unchangedRun) >= minUnchangedToHide { for j := 0; j < contextLines && j < len(unchangedRun); j++ { result = append(result, unchangedRun[j]) }
hiddenCount := len(unchangedRun) - (2 * contextLines) if hiddenCount > 0 { result = append(result, Edit{ Kind: Equal, AIndex: -2, BIndex: -2, Content: fmt.Sprintf("%s %d unchanged lines", compressedIndicator, hiddenCount), }) }
start := max(len(unchangedRun)-contextLines, contextLines) for j := start; j < len(unchangedRun); j++ { result = append(result, unchangedRun[j]) } } else { result = append(result, unchangedRun...) } unchangedRun = nil } } else { if len(unchangedRun) > 0 { if len(unchangedRun) >= minUnchangedToHide { for j := 0; j < contextLines && j < len(unchangedRun); j++ { result = append(result, unchangedRun[j]) }
hiddenCount := len(unchangedRun) - (2 * contextLines) if hiddenCount > 0 { result = append(result, Edit{ Kind: Equal, AIndex: -2, BIndex: -2, Content: fmt.Sprintf("%s %d unchanged lines", compressedIndicator, hiddenCount), }) }
start := max(len(unchangedRun)-contextLines, contextLines) for j := start; j < len(unchangedRun); j++ { result = append(result, unchangedRun[j]) } } else { result = append(result, unchangedRun...) } unchangedRun = nil }
result = append(result, edit) } }
return result}