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.
48 chromaStyle *chroma.Style
52 unified udiff.UnifiedDiff
55 splitHunks []splitHunk
58 fullCodeWidth int // with leading symbols
59 extraColOnAfter bool // add extra column on after panel
64// New creates a new DiffView with default settings.
67 layout: layoutUnified,
68 contextLines: udiff.DefaultContextLines,
72 dv.style = DefaultDarkStyle()
76// Unified sets the layout of the DiffView to unified.
77func (dv *DiffView) Unified() *DiffView {
78 dv.layout = layoutUnified
82// Split sets the layout of the DiffView to split (side-by-side).
83func (dv *DiffView) Split() *DiffView {
84 dv.layout = layoutSplit
88// Before sets the "before" file for the DiffView.
89func (dv *DiffView) Before(path, content string) *DiffView {
90 dv.before = file{path: path, content: content}
94// After sets the "after" file for the DiffView.
95func (dv *DiffView) After(path, content string) *DiffView {
96 dv.after = file{path: path, content: content}
100// ContextLines sets the number of context lines for the DiffView.
101func (dv *DiffView) ContextLines(contextLines int) *DiffView {
102 dv.contextLines = contextLines
106// Style sets the style for the DiffView.
107func (dv *DiffView) Style(style Style) *DiffView {
112// LineNumbers sets whether to display line numbers in the DiffView.
113func (dv *DiffView) LineNumbers(lineNumbers bool) *DiffView {
114 dv.lineNumbers = lineNumbers
118// SyntaxHightlight sets whether to enable syntax highlighting in the DiffView.
119func (dv *DiffView) SyntaxHightlight(highlight bool) *DiffView {
120 dv.highlight = highlight
124// Height sets the height of the DiffView.
125func (dv *DiffView) Height(height int) *DiffView {
130// Width sets the width of the DiffView.
131func (dv *DiffView) Width(width int) *DiffView {
136// XOffset sets the horizontal offset for the DiffView.
137func (dv *DiffView) XOffset(xOffset int) *DiffView {
142// YOffset sets the vertical offset for the DiffView.
143func (dv *DiffView) YOffset(yOffset int) *DiffView {
148// TabWidth sets the tab width. Only relevant for code that contains tabs, like
150func (dv *DiffView) TabWidth(tabWidth int) *DiffView {
151 dv.tabWidth = tabWidth
155// ChromaStyle sets the chroma style for syntax highlighting.
156// If nil, no syntax highlighting will be applied.
157func (dv *DiffView) ChromaStyle(style *chroma.Style) *DiffView {
158 dv.chromaStyle = style
162// String returns the string representation of the DiffView.
163func (dv *DiffView) String() string {
165 if err := dv.computeDiff(); err != nil {
168 dv.convertDiffToSplit()
178 style := lipgloss.NewStyle()
180 style = style.MaxWidth(dv.width)
183 style = style.MaxHeight(dv.height)
188 return style.Render(strings.TrimSuffix(dv.renderUnified(), "\n"))
190 return style.Render(strings.TrimSuffix(dv.renderSplit(), "\n"))
192 panic("unknown diffview layout")
196// replaceTabs replaces tabs in the before and after file contents with spaces
197// according to the specified tab width.
198func (dv *DiffView) replaceTabs() {
199 spaces := strings.Repeat(" ", dv.tabWidth)
200 dv.before.content = strings.ReplaceAll(dv.before.content, "\t", spaces)
201 dv.after.content = strings.ReplaceAll(dv.after.content, "\t", spaces)
204// computeDiff computes the differences between the "before" and "after" files.
205func (dv *DiffView) computeDiff() error {
210 dv.edits = myers.ComputeEdits( //nolint:staticcheck
214 dv.unified, dv.err = udiff.ToUnifiedDiff(
224// convertDiffToSplit converts the unified diff to a split diff if the layout is
226func (dv *DiffView) convertDiffToSplit() {
227 if dv.layout != layoutSplit {
231 dv.splitHunks = make([]splitHunk, len(dv.unified.Hunks))
232 for i, h := range dv.unified.Hunks {
233 dv.splitHunks[i] = hunkToSplit(h)
237// adjustStyles adjusts adds padding and alignment to the styles.
238func (dv *DiffView) adjustStyles() {
239 setPadding := func(s lipgloss.Style) lipgloss.Style {
240 return s.Padding(0, lineNumPadding).Align(lipgloss.Right)
242 dv.style.MissingLine.LineNumber = setPadding(dv.style.MissingLine.LineNumber)
243 dv.style.DividerLine.LineNumber = setPadding(dv.style.DividerLine.LineNumber)
244 dv.style.EqualLine.LineNumber = setPadding(dv.style.EqualLine.LineNumber)
245 dv.style.InsertLine.LineNumber = setPadding(dv.style.InsertLine.LineNumber)
246 dv.style.DeleteLine.LineNumber = setPadding(dv.style.DeleteLine.LineNumber)
249// detectNumDigits calculates the maximum number of digits needed for before and
250// after line numbers.
251func (dv *DiffView) detectNumDigits() {
252 dv.beforeNumDigits = 0
253 dv.afterNumDigits = 0
255 for _, h := range dv.unified.Hunks {
256 dv.beforeNumDigits = max(dv.beforeNumDigits, len(strconv.Itoa(h.FromLine+len(h.Lines))))
257 dv.afterNumDigits = max(dv.afterNumDigits, len(strconv.Itoa(h.ToLine+len(h.Lines))))
261// detectCodeWidth calculates the maximum width of code lines in the diff view.
262func (dv *DiffView) detectCodeWidth() {
265 dv.detectUnifiedCodeWidth()
267 dv.detectSplitCodeWidth()
269 dv.fullCodeWidth = dv.codeWidth + leadingSymbolsSize
272// detectUnifiedCodeWidth calculates the maximum width of code lines in a
274func (dv *DiffView) detectUnifiedCodeWidth() {
277 for _, h := range dv.unified.Hunks {
278 shownLines := ansi.StringWidth(dv.hunkLineFor(h))
280 for _, l := range h.Lines {
281 lineWidth := ansi.StringWidth(strings.TrimSuffix(l.Content, "\n")) + 1
282 dv.codeWidth = max(dv.codeWidth, lineWidth, shownLines)
287// detectSplitCodeWidth calculates the maximum width of code lines in a
289func (dv *DiffView) detectSplitCodeWidth() {
292 for i, h := range dv.splitHunks {
293 shownLines := ansi.StringWidth(dv.hunkLineFor(dv.unified.Hunks[i]))
295 for _, l := range h.lines {
297 codeWidth := ansi.StringWidth(strings.TrimSuffix(l.before.Content, "\n")) + 1
298 dv.codeWidth = max(dv.codeWidth, codeWidth, shownLines)
301 codeWidth := ansi.StringWidth(strings.TrimSuffix(l.after.Content, "\n")) + 1
302 dv.codeWidth = max(dv.codeWidth, codeWidth, shownLines)
308// resizeCodeWidth resizes the code width to fit within the specified width.
309func (dv *DiffView) resizeCodeWidth() {
310 fullNumWidth := dv.beforeNumDigits + dv.afterNumDigits
311 fullNumWidth += lineNumPadding * 4 // left and right padding for both line numbers
315 dv.codeWidth = dv.width - fullNumWidth - leadingSymbolsSize
317 remainingWidth := dv.width - fullNumWidth - leadingSymbolsSize*2
318 dv.codeWidth = remainingWidth / 2
319 dv.extraColOnAfter = isOdd(remainingWidth)
322 dv.fullCodeWidth = dv.codeWidth + leadingSymbolsSize
325// renderUnified renders the unified diff view as a string.
326func (dv *DiffView) renderUnified() string {
327 var b strings.Builder
329 fullContentStyle := lipgloss.NewStyle().MaxWidth(dv.fullCodeWidth)
330 printedLines := -dv.yOffset
332 write := func(s string) {
333 if printedLines >= 0 {
339 for i, h := range dv.unified.Hunks {
340 ls := dv.style.DividerLine
342 write(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
343 write(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
345 content := ansi.Truncate(dv.hunkLineFor(h), dv.fullCodeWidth, "…")
346 write(ls.Code.Width(dv.fullCodeWidth).Render(content))
350 beforeLine := h.FromLine
351 afterLine := h.ToLine
353 for j, l := range h.Lines {
354 // print ellipis if we don't have enough space to print the rest of the diff
355 hasReachedHeight := dv.height > 0 && printedLines+1 == dv.height
356 isLastHunk := i+1 == len(dv.unified.Hunks)
357 isLastLine := j+1 == len(h.Lines)
358 if hasReachedHeight && (!isLastHunk || !isLastLine) {
359 ls := dv.lineStyleForType(l.Kind)
361 write(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
362 write(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
364 write(fullContentStyle.Render(
365 ls.Code.Width(dv.fullCodeWidth).Render(" …"),
371 getContent := func(ls LineStyle) string {
372 content := strings.TrimSuffix(l.Content, "\n")
373 content = dv.hightlightCode(content, ls.Code.GetBackground())
374 content = ansi.GraphemeWidth.Cut(content, dv.xOffset, len(content))
375 content = ansi.Truncate(content, dv.codeWidth, "…")
379 leadingEllipsis := dv.xOffset > 0 && strings.TrimSpace(content) != ""
383 ls := dv.style.EqualLine
384 content := getContent(ls)
386 write(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
387 write(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
389 write(fullContentStyle.Render(
390 ls.Code.Width(dv.fullCodeWidth).Render(ternary(leadingEllipsis, " …", " ") + content),
395 ls := dv.style.InsertLine
396 content := getContent(ls)
398 write(ls.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
399 write(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
401 write(fullContentStyle.Render(
402 ls.Symbol.Render(ternary(leadingEllipsis, "+…", "+ ")) +
403 ls.Code.Width(dv.codeWidth).Render(content),
407 ls := dv.style.DeleteLine
408 content := getContent(ls)
410 write(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
411 write(ls.LineNumber.Render(pad(" ", dv.afterNumDigits)))
413 write(fullContentStyle.Render(
414 ls.Symbol.Render(ternary(leadingEllipsis, "-…", "- ")) +
415 ls.Code.Width(dv.codeWidth).Render(content),
425 for printedLines < dv.height {
426 ls := dv.style.MissingLine
428 write(ls.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
429 write(ls.LineNumber.Render(pad(" ", dv.afterNumDigits)))
431 write(ls.Code.Width(dv.fullCodeWidth).Render(" "))
439// renderSplit renders the split (side-by-side) diff view as a string.
440func (dv *DiffView) renderSplit() string {
441 var b strings.Builder
443 beforeFullContentStyle := lipgloss.NewStyle().MaxWidth(dv.fullCodeWidth)
444 afterFullContentStyle := lipgloss.NewStyle().MaxWidth(dv.fullCodeWidth + btoi(dv.extraColOnAfter))
445 printedLines := -dv.yOffset
447 write := func(s string) {
448 if printedLines >= 0 {
454 for i, h := range dv.splitHunks {
455 ls := dv.style.DividerLine
457 write(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
459 content := ansi.Truncate(dv.hunkLineFor(dv.unified.Hunks[i]), dv.fullCodeWidth, "…")
460 write(ls.Code.Width(dv.fullCodeWidth).Render(content))
462 write(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
464 write(ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(" "))
468 beforeLine := h.fromLine
469 afterLine := h.toLine
471 for j, l := range h.lines {
472 // print ellipis if we don't have enough space to print the rest of the diff
473 hasReachedHeight := dv.height > 0 && printedLines+1 == dv.height
474 isLastHunk := i+1 == len(dv.unified.Hunks)
475 isLastLine := j+1 == len(h.lines)
476 if hasReachedHeight && (!isLastHunk || !isLastLine) {
477 ls := dv.style.MissingLine
479 ls = dv.lineStyleForType(l.before.Kind)
482 write(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
484 write(beforeFullContentStyle.Render(
485 ls.Code.Width(dv.fullCodeWidth).Render(" …"),
487 ls = dv.style.MissingLine
489 ls = dv.lineStyleForType(l.after.Kind)
492 write(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
494 write(afterFullContentStyle.Render(
495 ls.Code.Width(dv.fullCodeWidth).Render(" …"),
501 getContent := func(content string, ls LineStyle) string {
502 content = strings.TrimSuffix(content, "\n")
503 content = dv.hightlightCode(content, ls.Code.GetBackground())
504 content = ansi.GraphemeWidth.Cut(content, dv.xOffset, len(content))
505 content = ansi.Truncate(content, dv.codeWidth, "…")
508 getLeadingEllipsis := func(content string) bool {
509 return dv.xOffset > 0 && strings.TrimSpace(content) != ""
513 case l.before == nil:
514 ls := dv.style.MissingLine
516 write(ls.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
518 write(beforeFullContentStyle.Render(
519 ls.Code.Width(dv.fullCodeWidth).Render(" "),
521 case l.before.Kind == udiff.Equal:
522 ls := dv.style.EqualLine
523 content := getContent(l.before.Content, ls)
524 leadingEllipsis := getLeadingEllipsis(content)
526 write(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
528 write(beforeFullContentStyle.Render(
529 ls.Code.Width(dv.fullCodeWidth).Render(ternary(leadingEllipsis, " …", " ") + content),
532 case l.before.Kind == udiff.Delete:
533 ls := dv.style.DeleteLine
534 content := getContent(l.before.Content, ls)
535 leadingEllipsis := getLeadingEllipsis(content)
537 write(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
539 write(beforeFullContentStyle.Render(
540 ls.Symbol.Render(ternary(leadingEllipsis, "-…", "- ")) +
541 ls.Code.Width(dv.codeWidth).Render(content),
548 ls := dv.style.MissingLine
550 write(ls.LineNumber.Render(pad(" ", dv.afterNumDigits)))
552 write(afterFullContentStyle.Render(
553 ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(" "),
555 case l.after.Kind == udiff.Equal:
556 ls := dv.style.EqualLine
557 content := getContent(l.after.Content, ls)
558 leadingEllipsis := getLeadingEllipsis(content)
560 write(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
562 write(afterFullContentStyle.Render(
563 ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(ternary(leadingEllipsis, " …", " ") + content),
566 case l.after.Kind == udiff.Insert:
567 ls := dv.style.InsertLine
568 content := getContent(l.after.Content, ls)
569 leadingEllipsis := getLeadingEllipsis(content)
571 write(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
573 write(afterFullContentStyle.Render(
574 ls.Symbol.Render(ternary(leadingEllipsis, "+…", "+ ")) +
575 ls.Code.Width(dv.codeWidth+btoi(dv.extraColOnAfter)).Render(content),
586 for printedLines < dv.height {
587 ls := dv.style.MissingLine
589 write(ls.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
591 write(ls.Code.Width(dv.fullCodeWidth).Render(" "))
593 write(ls.LineNumber.Render(pad(" ", dv.afterNumDigits)))
595 write(ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(" "))
603// hunkLineFor formats the header line for a hunk in the unified diff view.
604func (dv *DiffView) hunkLineFor(h *udiff.Hunk) string {
605 beforeShownLines, afterShownLines := dv.hunkShownLines(h)
608 " @@ -%d,%d +%d,%d @@ ",
616// hunkShownLines calculates the number of lines shown in a hunk for both before
617// and after versions.
618func (dv *DiffView) hunkShownLines(h *udiff.Hunk) (before, after int) {
619 for _, l := range h.Lines {
633func (dv *DiffView) lineStyleForType(t udiff.OpKind) LineStyle {
636 return dv.style.EqualLine
638 return dv.style.InsertLine
640 return dv.style.DeleteLine
642 return dv.style.MissingLine
646func (dv *DiffView) hightlightCode(source string, bgColor color.Color) string {
647 if dv.chromaStyle == nil {
651 l := dv.getChromaLexer(source)
652 f := dv.getChromaFormatter(bgColor)
654 it, err := l.Tokenise(nil, source)
659 var b strings.Builder
660 if err := f.Format(&b, dv.chromaStyle, it); err != nil {
666func (dv *DiffView) getChromaLexer(source string) chroma.Lexer {
667 l := lexers.Match(dv.before.path)
669 l = lexers.Analyse(source)
674 return chroma.Coalesce(l)
677func (dv *DiffView) getChromaFormatter(gbColor color.Color) chroma.Formatter {
678 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