9 "github.com/alecthomas/chroma/v2"
10 "github.com/alecthomas/chroma/v2/lexers"
11 "github.com/aymanbagabas/go-udiff"
12 "github.com/aymanbagabas/go-udiff/myers"
13 "github.com/charmbracelet/lipgloss/v2"
14 "github.com/charmbracelet/x/ansi"
18 leadingSymbolsSize = 2
30 layoutUnified layout = iota + 1
34// DiffView represents a view for displaying differences between two files.
47 chromaStyle *chroma.Style
51 unified udiff.UnifiedDiff
54 splitHunks []splitHunk
57 fullCodeWidth int // with leading symbols
58 extraColOnAfter bool // add extra column on after panel
63// New creates a new DiffView with default settings.
66 layout: layoutUnified,
67 contextLines: udiff.DefaultContextLines,
71 dv.style = DefaultDarkStyle()
75// Unified sets the layout of the DiffView to unified.
76func (dv *DiffView) Unified() *DiffView {
77 dv.layout = layoutUnified
81// Split sets the layout of the DiffView to split (side-by-side).
82func (dv *DiffView) Split() *DiffView {
83 dv.layout = layoutSplit
87// Before sets the "before" file for the DiffView.
88func (dv *DiffView) Before(path, content string) *DiffView {
89 dv.before = file{path: path, content: content}
93// After sets the "after" file for the DiffView.
94func (dv *DiffView) After(path, content string) *DiffView {
95 dv.after = file{path: path, content: content}
99// ContextLines sets the number of context lines for the DiffView.
100func (dv *DiffView) ContextLines(contextLines int) *DiffView {
101 dv.contextLines = contextLines
105// Style sets the style for the DiffView.
106func (dv *DiffView) Style(style Style) *DiffView {
111// LineNumbers sets whether to display line numbers in the DiffView.
112func (dv *DiffView) LineNumbers(lineNumbers bool) *DiffView {
113 dv.lineNumbers = lineNumbers
117// Height sets the height of the DiffView.
118func (dv *DiffView) Height(height int) *DiffView {
123// Width sets the width of the DiffView.
124func (dv *DiffView) Width(width int) *DiffView {
129// XOffset sets the horizontal offset for the DiffView.
130func (dv *DiffView) XOffset(xOffset int) *DiffView {
135// YOffset sets the vertical offset for the DiffView.
136func (dv *DiffView) YOffset(yOffset int) *DiffView {
141// TabWidth sets the tab width. Only relevant for code that contains tabs, like
143func (dv *DiffView) TabWidth(tabWidth int) *DiffView {
144 dv.tabWidth = tabWidth
148// ChromaStyle sets the chroma style for syntax highlighting.
149// If nil, no syntax highlighting will be applied.
150func (dv *DiffView) ChromaStyle(style *chroma.Style) *DiffView {
151 dv.chromaStyle = style
155// String returns the string representation of the DiffView.
156func (dv *DiffView) String() string {
158 if err := dv.computeDiff(); err != nil {
161 dv.convertDiffToSplit()
171 style := lipgloss.NewStyle()
173 style = style.MaxWidth(dv.width)
176 style = style.MaxHeight(dv.height)
181 return style.Render(strings.TrimSuffix(dv.renderUnified(), "\n"))
183 return style.Render(strings.TrimSuffix(dv.renderSplit(), "\n"))
185 panic("unknown diffview layout")
189// replaceTabs replaces tabs in the before and after file contents with spaces
190// according to the specified tab width.
191func (dv *DiffView) replaceTabs() {
192 spaces := strings.Repeat(" ", dv.tabWidth)
193 dv.before.content = strings.ReplaceAll(dv.before.content, "\t", spaces)
194 dv.after.content = strings.ReplaceAll(dv.after.content, "\t", spaces)
197// computeDiff computes the differences between the "before" and "after" files.
198func (dv *DiffView) computeDiff() error {
203 dv.edits = myers.ComputeEdits( //nolint:staticcheck
207 dv.unified, dv.err = udiff.ToUnifiedDiff(
217// convertDiffToSplit converts the unified diff to a split diff if the layout is
219func (dv *DiffView) convertDiffToSplit() {
220 if dv.layout != layoutSplit {
224 dv.splitHunks = make([]splitHunk, len(dv.unified.Hunks))
225 for i, h := range dv.unified.Hunks {
226 dv.splitHunks[i] = hunkToSplit(h)
230// adjustStyles adjusts adds padding and alignment to the styles.
231func (dv *DiffView) adjustStyles() {
232 setPadding := func(s lipgloss.Style) lipgloss.Style {
233 return s.Padding(0, lineNumPadding).Align(lipgloss.Right)
235 dv.style.MissingLine.LineNumber = setPadding(dv.style.MissingLine.LineNumber)
236 dv.style.DividerLine.LineNumber = setPadding(dv.style.DividerLine.LineNumber)
237 dv.style.EqualLine.LineNumber = setPadding(dv.style.EqualLine.LineNumber)
238 dv.style.InsertLine.LineNumber = setPadding(dv.style.InsertLine.LineNumber)
239 dv.style.DeleteLine.LineNumber = setPadding(dv.style.DeleteLine.LineNumber)
242// detectNumDigits calculates the maximum number of digits needed for before and
243// after line numbers.
244func (dv *DiffView) detectNumDigits() {
245 dv.beforeNumDigits = 0
246 dv.afterNumDigits = 0
248 for _, h := range dv.unified.Hunks {
249 dv.beforeNumDigits = max(dv.beforeNumDigits, len(strconv.Itoa(h.FromLine+len(h.Lines))))
250 dv.afterNumDigits = max(dv.afterNumDigits, len(strconv.Itoa(h.ToLine+len(h.Lines))))
254// detectCodeWidth calculates the maximum width of code lines in the diff view.
255func (dv *DiffView) detectCodeWidth() {
258 dv.detectUnifiedCodeWidth()
260 dv.detectSplitCodeWidth()
262 dv.fullCodeWidth = dv.codeWidth + leadingSymbolsSize
265// detectUnifiedCodeWidth calculates the maximum width of code lines in a
267func (dv *DiffView) detectUnifiedCodeWidth() {
270 for _, h := range dv.unified.Hunks {
271 shownLines := ansi.StringWidth(dv.hunkLineFor(h))
273 for _, l := range h.Lines {
274 lineWidth := ansi.StringWidth(strings.TrimSuffix(l.Content, "\n")) + 1
275 dv.codeWidth = max(dv.codeWidth, lineWidth, shownLines)
280// detectSplitCodeWidth calculates the maximum width of code lines in a
282func (dv *DiffView) detectSplitCodeWidth() {
285 for i, h := range dv.splitHunks {
286 shownLines := ansi.StringWidth(dv.hunkLineFor(dv.unified.Hunks[i]))
288 for _, l := range h.lines {
290 codeWidth := ansi.StringWidth(strings.TrimSuffix(l.before.Content, "\n")) + 1
291 dv.codeWidth = max(dv.codeWidth, codeWidth, shownLines)
294 codeWidth := ansi.StringWidth(strings.TrimSuffix(l.after.Content, "\n")) + 1
295 dv.codeWidth = max(dv.codeWidth, codeWidth, shownLines)
301// resizeCodeWidth resizes the code width to fit within the specified width.
302func (dv *DiffView) resizeCodeWidth() {
303 fullNumWidth := dv.beforeNumDigits + dv.afterNumDigits
304 fullNumWidth += lineNumPadding * 4 // left and right padding for both line numbers
308 dv.codeWidth = dv.width - fullNumWidth - leadingSymbolsSize
310 remainingWidth := dv.width - fullNumWidth - leadingSymbolsSize*2
311 dv.codeWidth = remainingWidth / 2
312 dv.extraColOnAfter = isOdd(remainingWidth)
315 dv.fullCodeWidth = dv.codeWidth + leadingSymbolsSize
318// renderUnified renders the unified diff view as a string.
319func (dv *DiffView) renderUnified() string {
320 var b strings.Builder
322 fullContentStyle := lipgloss.NewStyle().MaxWidth(dv.fullCodeWidth)
323 printedLines := -dv.yOffset
325 write := func(s string) {
326 if printedLines >= 0 {
332 for i, h := range dv.unified.Hunks {
333 ls := dv.style.DividerLine
335 write(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
336 write(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
338 content := ansi.Truncate(dv.hunkLineFor(h), dv.fullCodeWidth, "…")
339 write(ls.Code.Width(dv.fullCodeWidth).Render(content))
343 beforeLine := h.FromLine
344 afterLine := h.ToLine
346 for j, l := range h.Lines {
347 // print ellipis if we don't have enough space to print the rest of the diff
348 hasReachedHeight := dv.height > 0 && printedLines+1 == dv.height
349 isLastHunk := i+1 == len(dv.unified.Hunks)
350 isLastLine := j+1 == len(h.Lines)
351 if hasReachedHeight && (!isLastHunk || !isLastLine) {
352 ls := dv.lineStyleForType(l.Kind)
354 write(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
355 write(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
357 write(fullContentStyle.Render(
358 ls.Code.Width(dv.fullCodeWidth).Render(" …"),
364 getContent := func(ls LineStyle) string {
365 content := strings.TrimSuffix(l.Content, "\n")
366 content = dv.hightlightCode(content, ls.Code.GetBackground())
367 content = ansi.GraphemeWidth.Cut(content, dv.xOffset, len(content))
368 content = ansi.Truncate(content, dv.codeWidth, "…")
372 leadingEllipsis := dv.xOffset > 0 && strings.TrimSpace(content) != ""
376 ls := dv.style.EqualLine
377 content := getContent(ls)
379 write(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
380 write(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
382 write(fullContentStyle.Render(
383 ls.Code.Width(dv.fullCodeWidth).Render(ternary(leadingEllipsis, " …", " ") + content),
388 ls := dv.style.InsertLine
389 content := getContent(ls)
391 write(ls.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
392 write(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
394 write(fullContentStyle.Render(
395 ls.Symbol.Render(ternary(leadingEllipsis, "+…", "+ ")) +
396 ls.Code.Width(dv.codeWidth).Render(content),
400 ls := dv.style.DeleteLine
401 content := getContent(ls)
403 write(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
404 write(ls.LineNumber.Render(pad(" ", dv.afterNumDigits)))
406 write(fullContentStyle.Render(
407 ls.Symbol.Render(ternary(leadingEllipsis, "-…", "- ")) +
408 ls.Code.Width(dv.codeWidth).Render(content),
418 for printedLines < dv.height {
419 ls := dv.style.MissingLine
421 write(ls.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
422 write(ls.LineNumber.Render(pad(" ", dv.afterNumDigits)))
424 write(ls.Code.Width(dv.fullCodeWidth).Render(" "))
432// renderSplit renders the split (side-by-side) diff view as a string.
433func (dv *DiffView) renderSplit() string {
434 var b strings.Builder
436 beforeFullContentStyle := lipgloss.NewStyle().MaxWidth(dv.fullCodeWidth)
437 afterFullContentStyle := lipgloss.NewStyle().MaxWidth(dv.fullCodeWidth + btoi(dv.extraColOnAfter))
438 printedLines := -dv.yOffset
440 write := func(s string) {
441 if printedLines >= 0 {
447 for i, h := range dv.splitHunks {
448 ls := dv.style.DividerLine
450 write(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
452 content := ansi.Truncate(dv.hunkLineFor(dv.unified.Hunks[i]), dv.fullCodeWidth, "…")
453 write(ls.Code.Width(dv.fullCodeWidth).Render(content))
455 write(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
457 write(ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(" "))
461 beforeLine := h.fromLine
462 afterLine := h.toLine
464 for j, l := range h.lines {
465 // print ellipis if we don't have enough space to print the rest of the diff
466 hasReachedHeight := dv.height > 0 && printedLines+1 == dv.height
467 isLastHunk := i+1 == len(dv.unified.Hunks)
468 isLastLine := j+1 == len(h.lines)
469 if hasReachedHeight && (!isLastHunk || !isLastLine) {
470 ls := dv.style.MissingLine
472 ls = dv.lineStyleForType(l.before.Kind)
475 write(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
477 write(beforeFullContentStyle.Render(
478 ls.Code.Width(dv.fullCodeWidth).Render(" …"),
480 ls = dv.style.MissingLine
482 ls = dv.lineStyleForType(l.after.Kind)
485 write(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
487 write(afterFullContentStyle.Render(
488 ls.Code.Width(dv.fullCodeWidth).Render(" …"),
494 getContent := func(content string, ls LineStyle) string {
495 content = strings.TrimSuffix(content, "\n")
496 content = dv.hightlightCode(content, ls.Code.GetBackground())
497 content = ansi.GraphemeWidth.Cut(content, dv.xOffset, len(content))
498 content = ansi.Truncate(content, dv.codeWidth, "…")
501 getLeadingEllipsis := func(content string) bool {
502 return dv.xOffset > 0 && strings.TrimSpace(content) != ""
506 case l.before == nil:
507 ls := dv.style.MissingLine
509 write(ls.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
511 write(beforeFullContentStyle.Render(
512 ls.Code.Width(dv.fullCodeWidth).Render(" "),
514 case l.before.Kind == udiff.Equal:
515 ls := dv.style.EqualLine
516 content := getContent(l.before.Content, ls)
517 leadingEllipsis := getLeadingEllipsis(content)
519 write(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
521 write(beforeFullContentStyle.Render(
522 ls.Code.Width(dv.fullCodeWidth).Render(ternary(leadingEllipsis, " …", " ") + content),
525 case l.before.Kind == udiff.Delete:
526 ls := dv.style.DeleteLine
527 content := getContent(l.before.Content, ls)
528 leadingEllipsis := getLeadingEllipsis(content)
530 write(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
532 write(beforeFullContentStyle.Render(
533 ls.Symbol.Render(ternary(leadingEllipsis, "-…", "- ")) +
534 ls.Code.Width(dv.codeWidth).Render(content),
541 ls := dv.style.MissingLine
543 write(ls.LineNumber.Render(pad(" ", dv.afterNumDigits)))
545 write(afterFullContentStyle.Render(
546 ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(" "),
548 case l.after.Kind == udiff.Equal:
549 ls := dv.style.EqualLine
550 content := getContent(l.after.Content, ls)
551 leadingEllipsis := getLeadingEllipsis(content)
553 write(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
555 write(afterFullContentStyle.Render(
556 ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(ternary(leadingEllipsis, " …", " ") + content),
559 case l.after.Kind == udiff.Insert:
560 ls := dv.style.InsertLine
561 content := getContent(l.after.Content, ls)
562 leadingEllipsis := getLeadingEllipsis(content)
564 write(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
566 write(afterFullContentStyle.Render(
567 ls.Symbol.Render(ternary(leadingEllipsis, "+…", "+ ")) +
568 ls.Code.Width(dv.codeWidth+btoi(dv.extraColOnAfter)).Render(content),
579 for printedLines < dv.height {
580 ls := dv.style.MissingLine
582 write(ls.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
584 write(ls.Code.Width(dv.fullCodeWidth).Render(" "))
586 write(ls.LineNumber.Render(pad(" ", dv.afterNumDigits)))
588 write(ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(" "))
596// hunkLineFor formats the header line for a hunk in the unified diff view.
597func (dv *DiffView) hunkLineFor(h *udiff.Hunk) string {
598 beforeShownLines, afterShownLines := dv.hunkShownLines(h)
601 " @@ -%d,%d +%d,%d @@ ",
609// hunkShownLines calculates the number of lines shown in a hunk for both before
610// and after versions.
611func (dv *DiffView) hunkShownLines(h *udiff.Hunk) (before, after int) {
612 for _, l := range h.Lines {
626func (dv *DiffView) lineStyleForType(t udiff.OpKind) LineStyle {
629 return dv.style.EqualLine
631 return dv.style.InsertLine
633 return dv.style.DeleteLine
635 return dv.style.MissingLine
639func (dv *DiffView) hightlightCode(source string, bgColor color.Color) string {
640 if dv.chromaStyle == nil {
644 l := dv.getChromaLexer(source)
645 f := dv.getChromaFormatter(bgColor)
647 it, err := l.Tokenise(nil, source)
652 var b strings.Builder
653 if err := f.Format(&b, dv.chromaStyle, it); err != nil {
659func (dv *DiffView) getChromaLexer(source string) chroma.Lexer {
660 l := lexers.Match(dv.before.path)
662 l = lexers.Analyse(source)
667 return chroma.Coalesce(l)
670func (dv *DiffView) getChromaFormatter(gbColor color.Color) chroma.Formatter {
671 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