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