10 "github.com/alecthomas/chroma/v2"
11 "github.com/alecthomas/chroma/v2/lexers"
12 "github.com/aymanbagabas/go-udiff"
13 "github.com/aymanbagabas/go-udiff/myers"
14 "github.com/charmbracelet/lipgloss/v2"
15 "github.com/charmbracelet/x/ansi"
19 leadingSymbolsSize = 2
31 layoutUnified layout = iota + 1
35// DiffView represents a view for displaying differences between two files.
49 chromaStyle *chroma.Style
53 unified udiff.UnifiedDiff
56 splitHunks []splitHunk
59 fullCodeWidth int // with leading symbols
60 extraColOnAfter bool // add extra column on after panel
65// New creates a new DiffView with default settings.
68 layout: layoutUnified,
69 contextLines: udiff.DefaultContextLines,
73 if lipgloss.HasDarkBackground(os.Stdin, os.Stdout) {
74 dv.style = DefaultDarkStyle
76 dv.style = DefaultLightStyle
81// Unified sets the layout of the DiffView to unified.
82func (dv *DiffView) Unified() *DiffView {
83 dv.layout = layoutUnified
87// Split sets the layout of the DiffView to split (side-by-side).
88func (dv *DiffView) Split() *DiffView {
89 dv.layout = layoutSplit
93// Before sets the "before" file for the DiffView.
94func (dv *DiffView) Before(path, content string) *DiffView {
95 dv.before = file{path: path, content: content}
99// After sets the "after" file for the DiffView.
100func (dv *DiffView) After(path, content string) *DiffView {
101 dv.after = file{path: path, content: content}
105// ContextLines sets the number of context lines for the DiffView.
106func (dv *DiffView) ContextLines(contextLines int) *DiffView {
107 dv.contextLines = contextLines
111// Style sets the style for the DiffView.
112func (dv *DiffView) Style(style Style) *DiffView {
117// LineNumbers sets whether to display line numbers in the DiffView.
118func (dv *DiffView) LineNumbers(lineNumbers bool) *DiffView {
119 dv.lineNumbers = lineNumbers
123// SyntaxHightlight sets whether to enable syntax highlighting in the DiffView.
124func (dv *DiffView) SyntaxHightlight(highlight bool) *DiffView {
125 dv.highlight = highlight
129// Height sets the height of the DiffView.
130func (dv *DiffView) Height(height int) *DiffView {
135// Width sets the width of the DiffView.
136func (dv *DiffView) Width(width int) *DiffView {
141// XOffset sets the horizontal offset for the DiffView.
142func (dv *DiffView) XOffset(xOffset int) *DiffView {
147// YOffset sets the vertical offset for the DiffView.
148func (dv *DiffView) YOffset(yOffset int) *DiffView {
153// TabWidth sets the tab width. Only relevant for code that contains tabs, like
155func (dv *DiffView) TabWidth(tabWidth int) *DiffView {
156 dv.tabWidth = tabWidth
160// ChromaStyle sets the chroma style for syntax highlighting.
161// If nil, no syntax highlighting will be applied.
162func (dv *DiffView) ChromaStyle(style *chroma.Style) *DiffView {
163 dv.chromaStyle = style
167// String returns the string representation of the DiffView.
168func (dv *DiffView) String() string {
170 if err := dv.computeDiff(); err != nil {
173 dv.convertDiffToSplit()
183 style := lipgloss.NewStyle()
185 style = style.MaxWidth(dv.width)
188 style = style.MaxHeight(dv.height)
193 return style.Render(strings.TrimSuffix(dv.renderUnified(), "\n"))
195 return style.Render(strings.TrimSuffix(dv.renderSplit(), "\n"))
197 panic("unknown diffview layout")
201// replaceTabs replaces tabs in the before and after file contents with spaces
202// according to the specified tab width.
203func (dv *DiffView) replaceTabs() {
204 spaces := strings.Repeat(" ", dv.tabWidth)
205 dv.before.content = strings.ReplaceAll(dv.before.content, "\t", spaces)
206 dv.after.content = strings.ReplaceAll(dv.after.content, "\t", spaces)
209// computeDiff computes the differences between the "before" and "after" files.
210func (dv *DiffView) computeDiff() error {
215 dv.edits = myers.ComputeEdits( //nolint:staticcheck
219 dv.unified, dv.err = udiff.ToUnifiedDiff(
229// convertDiffToSplit converts the unified diff to a split diff if the layout is
231func (dv *DiffView) convertDiffToSplit() {
232 if dv.layout != layoutSplit {
236 dv.splitHunks = make([]splitHunk, len(dv.unified.Hunks))
237 for i, h := range dv.unified.Hunks {
238 dv.splitHunks[i] = hunkToSplit(h)
242// adjustStyles adjusts adds padding and alignment to the styles.
243func (dv *DiffView) adjustStyles() {
244 setPadding := func(s lipgloss.Style) lipgloss.Style {
245 return s.Padding(0, lineNumPadding).Align(lipgloss.Right)
247 dv.style.MissingLine.LineNumber = setPadding(dv.style.MissingLine.LineNumber)
248 dv.style.DividerLine.LineNumber = setPadding(dv.style.DividerLine.LineNumber)
249 dv.style.EqualLine.LineNumber = setPadding(dv.style.EqualLine.LineNumber)
250 dv.style.InsertLine.LineNumber = setPadding(dv.style.InsertLine.LineNumber)
251 dv.style.DeleteLine.LineNumber = setPadding(dv.style.DeleteLine.LineNumber)
254// detectNumDigits calculates the maximum number of digits needed for before and
255// after line numbers.
256func (dv *DiffView) detectNumDigits() {
257 dv.beforeNumDigits = 0
258 dv.afterNumDigits = 0
260 for _, h := range dv.unified.Hunks {
261 dv.beforeNumDigits = max(dv.beforeNumDigits, len(strconv.Itoa(h.FromLine+len(h.Lines))))
262 dv.afterNumDigits = max(dv.afterNumDigits, len(strconv.Itoa(h.ToLine+len(h.Lines))))
266// detectCodeWidth calculates the maximum width of code lines in the diff view.
267func (dv *DiffView) detectCodeWidth() {
270 dv.detectUnifiedCodeWidth()
272 dv.detectSplitCodeWidth()
274 dv.fullCodeWidth = dv.codeWidth + leadingSymbolsSize
277// detectUnifiedCodeWidth calculates the maximum width of code lines in a
279func (dv *DiffView) detectUnifiedCodeWidth() {
282 for _, h := range dv.unified.Hunks {
283 shownLines := ansi.StringWidth(dv.hunkLineFor(h))
285 for _, l := range h.Lines {
286 lineWidth := ansi.StringWidth(strings.TrimSuffix(l.Content, "\n")) + 1
287 dv.codeWidth = max(dv.codeWidth, lineWidth, shownLines)
292// detectSplitCodeWidth calculates the maximum width of code lines in a
294func (dv *DiffView) detectSplitCodeWidth() {
297 for i, h := range dv.splitHunks {
298 shownLines := ansi.StringWidth(dv.hunkLineFor(dv.unified.Hunks[i]))
300 for _, l := range h.lines {
302 codeWidth := ansi.StringWidth(strings.TrimSuffix(l.before.Content, "\n")) + 1
303 dv.codeWidth = max(dv.codeWidth, codeWidth, shownLines)
306 codeWidth := ansi.StringWidth(strings.TrimSuffix(l.after.Content, "\n")) + 1
307 dv.codeWidth = max(dv.codeWidth, codeWidth, shownLines)
313// resizeCodeWidth resizes the code width to fit within the specified width.
314func (dv *DiffView) resizeCodeWidth() {
315 fullNumWidth := dv.beforeNumDigits + dv.afterNumDigits
316 fullNumWidth += lineNumPadding * 4 // left and right padding for both line numbers
320 dv.codeWidth = dv.width - fullNumWidth - leadingSymbolsSize
322 remainingWidth := dv.width - fullNumWidth - leadingSymbolsSize*2
323 dv.codeWidth = remainingWidth / 2
324 dv.extraColOnAfter = isOdd(remainingWidth)
327 dv.fullCodeWidth = dv.codeWidth + leadingSymbolsSize
330// renderUnified renders the unified diff view as a string.
331func (dv *DiffView) renderUnified() string {
332 var b strings.Builder
334 fullContentStyle := lipgloss.NewStyle().MaxWidth(dv.fullCodeWidth)
335 printedLines := -dv.yOffset
337 write := func(s string) {
338 if printedLines >= 0 {
344 for i, h := range dv.unified.Hunks {
345 ls := dv.style.DividerLine
347 write(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
348 write(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
350 content := ansi.Truncate(dv.hunkLineFor(h), dv.fullCodeWidth, "…")
351 write(ls.Code.Width(dv.fullCodeWidth).Render(content))
355 beforeLine := h.FromLine
356 afterLine := h.ToLine
358 for j, l := range h.Lines {
359 // print ellipis if we don't have enough space to print the rest of the diff
360 hasReachedHeight := dv.height > 0 && printedLines+1 == dv.height
361 isLastHunk := i+1 == len(dv.unified.Hunks)
362 isLastLine := j+1 == len(h.Lines)
363 if hasReachedHeight && (!isLastHunk || !isLastLine) {
364 ls := dv.lineStyleForType(l.Kind)
366 write(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
367 write(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
369 write(fullContentStyle.Render(
370 ls.Code.Width(dv.fullCodeWidth).Render(" …"),
376 getContent := func(ls LineStyle) string {
377 content := strings.TrimSuffix(l.Content, "\n")
378 content = dv.hightlightCode(content, ls.Code.GetBackground())
379 content = ansi.GraphemeWidth.Cut(content, dv.xOffset, len(content))
380 content = ansi.Truncate(content, dv.codeWidth, "…")
384 leadingEllipsis := dv.xOffset > 0 && strings.TrimSpace(content) != ""
388 ls := dv.style.EqualLine
389 content := getContent(ls)
391 write(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
392 write(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
394 write(fullContentStyle.Render(
395 ls.Code.Width(dv.fullCodeWidth).Render(ternary(leadingEllipsis, " …", " ") + content),
400 ls := dv.style.InsertLine
401 content := getContent(ls)
403 write(ls.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
404 write(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
406 write(fullContentStyle.Render(
407 ls.Symbol.Render(ternary(leadingEllipsis, "+…", "+ ")) +
408 ls.Code.Width(dv.codeWidth).Render(content),
412 ls := dv.style.DeleteLine
413 content := getContent(ls)
415 write(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
416 write(ls.LineNumber.Render(pad(" ", dv.afterNumDigits)))
418 write(fullContentStyle.Render(
419 ls.Symbol.Render(ternary(leadingEllipsis, "-…", "- ")) +
420 ls.Code.Width(dv.codeWidth).Render(content),
430 for printedLines < dv.height {
431 ls := dv.style.MissingLine
433 write(ls.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
434 write(ls.LineNumber.Render(pad(" ", dv.afterNumDigits)))
436 write(ls.Code.Width(dv.fullCodeWidth).Render(" "))
444// renderSplit renders the split (side-by-side) diff view as a string.
445func (dv *DiffView) renderSplit() string {
446 var b strings.Builder
448 beforeFullContentStyle := lipgloss.NewStyle().MaxWidth(dv.fullCodeWidth)
449 afterFullContentStyle := lipgloss.NewStyle().MaxWidth(dv.fullCodeWidth + btoi(dv.extraColOnAfter))
450 printedLines := -dv.yOffset
452 write := func(s string) {
453 if printedLines >= 0 {
459 for i, h := range dv.splitHunks {
460 ls := dv.style.DividerLine
462 write(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
464 content := ansi.Truncate(dv.hunkLineFor(dv.unified.Hunks[i]), dv.fullCodeWidth, "…")
465 write(ls.Code.Width(dv.fullCodeWidth).Render(content))
467 write(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
469 write(ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(" "))
473 beforeLine := h.fromLine
474 afterLine := h.toLine
476 for j, l := range h.lines {
477 // print ellipis if we don't have enough space to print the rest of the diff
478 hasReachedHeight := dv.height > 0 && printedLines+1 == dv.height
479 isLastHunk := i+1 == len(dv.unified.Hunks)
480 isLastLine := j+1 == len(h.lines)
481 if hasReachedHeight && (!isLastHunk || !isLastLine) {
482 ls := dv.style.MissingLine
484 ls = dv.lineStyleForType(l.before.Kind)
487 write(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
489 write(beforeFullContentStyle.Render(
490 ls.Code.Width(dv.fullCodeWidth).Render(" …"),
492 ls = dv.style.MissingLine
494 ls = dv.lineStyleForType(l.after.Kind)
497 write(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
499 write(afterFullContentStyle.Render(
500 ls.Code.Width(dv.fullCodeWidth).Render(" …"),
506 getContent := func(content string, ls LineStyle) string {
507 content = strings.TrimSuffix(content, "\n")
508 content = dv.hightlightCode(content, ls.Code.GetBackground())
509 content = ansi.GraphemeWidth.Cut(content, dv.xOffset, len(content))
510 content = ansi.Truncate(content, dv.codeWidth, "…")
513 getLeadingEllipsis := func(content string) bool {
514 return dv.xOffset > 0 && strings.TrimSpace(content) != ""
518 case l.before == nil:
519 ls := dv.style.MissingLine
521 write(ls.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
523 write(beforeFullContentStyle.Render(
524 ls.Code.Width(dv.fullCodeWidth).Render(" "),
526 case l.before.Kind == udiff.Equal:
527 ls := dv.style.EqualLine
528 content := getContent(l.before.Content, ls)
529 leadingEllipsis := getLeadingEllipsis(content)
531 write(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
533 write(beforeFullContentStyle.Render(
534 ls.Code.Width(dv.fullCodeWidth).Render(ternary(leadingEllipsis, " …", " ") + content),
537 case l.before.Kind == udiff.Delete:
538 ls := dv.style.DeleteLine
539 content := getContent(l.before.Content, ls)
540 leadingEllipsis := getLeadingEllipsis(content)
542 write(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
544 write(beforeFullContentStyle.Render(
545 ls.Symbol.Render(ternary(leadingEllipsis, "-…", "- ")) +
546 ls.Code.Width(dv.codeWidth).Render(content),
553 ls := dv.style.MissingLine
555 write(ls.LineNumber.Render(pad(" ", dv.afterNumDigits)))
557 write(afterFullContentStyle.Render(
558 ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(" "),
560 case l.after.Kind == udiff.Equal:
561 ls := dv.style.EqualLine
562 content := getContent(l.after.Content, ls)
563 leadingEllipsis := getLeadingEllipsis(content)
565 write(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
567 write(afterFullContentStyle.Render(
568 ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(ternary(leadingEllipsis, " …", " ") + content),
571 case l.after.Kind == udiff.Insert:
572 ls := dv.style.InsertLine
573 content := getContent(l.after.Content, ls)
574 leadingEllipsis := getLeadingEllipsis(content)
576 write(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
578 write(afterFullContentStyle.Render(
579 ls.Symbol.Render(ternary(leadingEllipsis, "+…", "+ ")) +
580 ls.Code.Width(dv.codeWidth+btoi(dv.extraColOnAfter)).Render(content),
591 for printedLines < dv.height {
592 ls := dv.style.MissingLine
594 write(ls.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
596 write(ls.Code.Width(dv.fullCodeWidth).Render(" "))
598 write(ls.LineNumber.Render(pad(" ", dv.afterNumDigits)))
600 write(ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(" "))
608// hunkLineFor formats the header line for a hunk in the unified diff view.
609func (dv *DiffView) hunkLineFor(h *udiff.Hunk) string {
610 beforeShownLines, afterShownLines := dv.hunkShownLines(h)
613 " @@ -%d,%d +%d,%d @@ ",
621// hunkShownLines calculates the number of lines shown in a hunk for both before
622// and after versions.
623func (dv *DiffView) hunkShownLines(h *udiff.Hunk) (before, after int) {
624 for _, l := range h.Lines {
638func (dv *DiffView) lineStyleForType(t udiff.OpKind) LineStyle {
641 return dv.style.EqualLine
643 return dv.style.InsertLine
645 return dv.style.DeleteLine
647 return dv.style.MissingLine
651func (dv *DiffView) hightlightCode(source string, bgColor color.Color) string {
652 if dv.chromaStyle == nil {
656 l := dv.getChromaLexer(source)
657 f := dv.getChromaFormatter(bgColor)
659 it, err := l.Tokenise(nil, source)
664 var b strings.Builder
665 if err := f.Format(&b, dv.chromaStyle, it); err != nil {
671func (dv *DiffView) getChromaLexer(source string) chroma.Lexer {
672 l := lexers.Match(dv.before.path)
674 l = lexers.Analyse(source)
679 return chroma.Coalesce(l)
682func (dv *DiffView) getChromaFormatter(gbColor color.Color) chroma.Formatter {
683 return chromaFormatter{
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list