on 66cd982 help sign in
crush/internal/exp/diffview/diffview.go
469 lines 13 kB history raw
1package diffview
3import (
4 "fmt"
5 "os"
6 "strconv"
7 "strings"
9 "github.com/aymanbagabas/go-udiff"
10 "github.com/aymanbagabas/go-udiff/myers"
11 "github.com/charmbracelet/lipgloss/v2"
12 "github.com/charmbracelet/x/ansi"
15const (
16 leadingSymbolsSize = 2
17 lineNumPadding = 1
20type file struct {
21 path string
22 content string
25type layout int
27const (
28 layoutUnified layout = iota + 1
29 layoutSplit
32// DiffView represents a view for displaying differences between two files.
33type DiffView struct {
34 layout layout
35 before file
36 after file
37 contextLines int
38 lineNumbers bool
39 highlight bool
40 height int
41 width int
42 style Style
44 isComputed bool
45 err error
46 unified udiff.UnifiedDiff
47 edits []udiff.Edit
49 splitHunks []splitHunk
51 codeWidth int
52 fullCodeWidth int // with leading symbols
53 extraColOnAfter bool // add extra column on after panel
54 beforeNumDigits int
55 afterNumDigits int
58// New creates a new DiffView with default settings.
59func New() *DiffView {
60 dv := &DiffView{
61 layout: layoutUnified,
62 contextLines: udiff.DefaultContextLines,
63 lineNumbers: true,
64 }
65 if lipgloss.HasDarkBackground(os.Stdin, os.Stdout) {
66 dv.style = DefaultDarkStyle
67 } else {
68 dv.style = DefaultLightStyle
69 }
70 return dv
73// Unified sets the layout of the DiffView to unified.
74func (dv *DiffView) Unified() *DiffView {
75 dv.layout = layoutUnified
76 return dv
79// Split sets the layout of the DiffView to split (side-by-side).
80func (dv *DiffView) Split() *DiffView {
81 dv.layout = layoutSplit
82 return dv
85// Before sets the "before" file for the DiffView.
86func (dv *DiffView) Before(path, content string) *DiffView {
87 dv.before = file{path: path, content: content}
88 return dv
91// After sets the "after" file for the DiffView.
92func (dv *DiffView) After(path, content string) *DiffView {
93 dv.after = file{path: path, content: content}
94 return dv
97// ContextLines sets the number of context lines for the DiffView.
98func (dv *DiffView) ContextLines(contextLines int) *DiffView {
99 dv.contextLines = contextLines
100 return dv
103// Style sets the style for the DiffView.
104func (dv *DiffView) Style(style Style) *DiffView {
105 dv.style = style
106 return dv
109// LineNumbers sets whether to display line numbers in the DiffView.
110func (dv *DiffView) LineNumbers(lineNumbers bool) *DiffView {
111 dv.lineNumbers = lineNumbers
112 return dv
115// SyntaxHightlight sets whether to enable syntax highlighting in the DiffView.
116func (dv *DiffView) SyntaxHightlight(highlight bool) *DiffView {
117 dv.highlight = highlight
118 return dv
121// Height sets the height of the DiffView.
122func (dv *DiffView) Height(height int) *DiffView {
123 dv.height = height
124 return dv
127// Width sets the width of the DiffView.
128func (dv *DiffView) Width(width int) *DiffView {
129 dv.width = width
130 return dv
133// String returns the string representation of the DiffView.
134func (dv *DiffView) String() string {
135 if err := dv.computeDiff(); err != nil {
136 return err.Error()
137 }
138 dv.convertDiffToSplit()
139 dv.adjustStyles()
140 dv.detectNumDigits()
142 if dv.width <= 0 {
143 dv.detectCodeWidth()
144 } else {
145 dv.resizeCodeWidth()
146 }
148 style := lipgloss.NewStyle()
149 if dv.width > 0 {
150 style = style.MaxWidth(dv.width)
151 }
153 switch dv.layout {
154 case layoutUnified:
155 return style.Render(strings.TrimSuffix(dv.renderUnified(), "\n"))
156 case layoutSplit:
157 return style.Render(strings.TrimSuffix(dv.renderSplit(), "\n"))
158 default:
159 panic("unknown diffview layout")
160 }
163// computeDiff computes the differences between the "before" and "after" files.
164func (dv *DiffView) computeDiff() error {
165 if dv.isComputed {
166 return dv.err
167 }
168 dv.isComputed = true
169 dv.edits = myers.ComputeEdits( //nolint:staticcheck
170 dv.before.content,
171 dv.after.content,
172 )
173 dv.unified, dv.err = udiff.ToUnifiedDiff(
174 dv.before.path,
175 dv.after.path,
176 dv.before.content,
177 dv.edits,
178 dv.contextLines,
179 )
180 return dv.err
183// convertDiffToSplit converts the unified diff to a split diff if the layout is
184// set to split.
185func (dv *DiffView) convertDiffToSplit() {
186 if dv.layout != layoutSplit {
187 return
188 }
190 dv.splitHunks = make([]splitHunk, len(dv.unified.Hunks))
191 for i, h := range dv.unified.Hunks {
192 dv.splitHunks[i] = hunkToSplit(h)
193 }
196// adjustStyles adjusts adds padding and alignment to the styles.
197func (dv *DiffView) adjustStyles() {
198 dv.style.MissingLine.LineNumber = setPadding(dv.style.MissingLine.LineNumber)
199 dv.style.DividerLine.LineNumber = setPadding(dv.style.DividerLine.LineNumber)
200 dv.style.EqualLine.LineNumber = setPadding(dv.style.EqualLine.LineNumber)
201 dv.style.InsertLine.LineNumber = setPadding(dv.style.InsertLine.LineNumber)
202 dv.style.DeleteLine.LineNumber = setPadding(dv.style.DeleteLine.LineNumber)
205// detectNumDigits calculates the maximum number of digits needed for before and
206// after line numbers.
207func (dv *DiffView) detectNumDigits() {
208 dv.beforeNumDigits = 0
209 dv.afterNumDigits = 0
211 for _, h := range dv.unified.Hunks {
212 dv.beforeNumDigits = max(dv.beforeNumDigits, len(strconv.Itoa(h.FromLine+len(h.Lines))))
213 dv.afterNumDigits = max(dv.afterNumDigits, len(strconv.Itoa(h.ToLine+len(h.Lines))))
214 }
217func setPadding(s lipgloss.Style) lipgloss.Style {
218 return s.Padding(0, lineNumPadding).Align(lipgloss.Right)
221// detectCodeWidth calculates the maximum width of code lines in the diff view.
222func (dv *DiffView) detectCodeWidth() {
223 switch dv.layout {
224 case layoutUnified:
225 dv.detectUnifiedCodeWidth()
226 case layoutSplit:
227 dv.detectSplitCodeWidth()
228 }
229 dv.fullCodeWidth = dv.codeWidth + leadingSymbolsSize
232// detectUnifiedCodeWidth calculates the maximum width of code lines in a
233// unified diff.
234func (dv *DiffView) detectUnifiedCodeWidth() {
235 dv.codeWidth = 0
237 for _, h := range dv.unified.Hunks {
238 shownLines := ansi.StringWidth(dv.hunkLineFor(h))
240 for _, l := range h.Lines {
241 lineWidth := ansi.StringWidth(strings.TrimSuffix(l.Content, "\n")) + 1
242 dv.codeWidth = max(dv.codeWidth, lineWidth, shownLines)
243 }
244 }
247// detectSplitCodeWidth calculates the maximum width of code lines in a
248// split diff.
249func (dv *DiffView) detectSplitCodeWidth() {
250 dv.codeWidth = 0
252 for i, h := range dv.splitHunks {
253 shownLines := ansi.StringWidth(dv.hunkLineFor(dv.unified.Hunks[i]))
255 for _, l := range h.lines {
256 if l.before != nil {
257 codeWidth := ansi.StringWidth(strings.TrimSuffix(l.before.Content, "\n")) + 1
258 dv.codeWidth = max(dv.codeWidth, codeWidth, shownLines)
259 }
260 if l.after != nil {
261 codeWidth := ansi.StringWidth(strings.TrimSuffix(l.after.Content, "\n")) + 1
262 dv.codeWidth = max(dv.codeWidth, codeWidth, shownLines)
263 }
264 }
265 }
268// resizeCodeWidth resizes the code width to fit within the specified width.
269func (dv *DiffView) resizeCodeWidth() {
270 fullNumWidth := dv.beforeNumDigits + dv.afterNumDigits
271 fullNumWidth += lineNumPadding * 4 // left and right padding for both line numbers
273 switch dv.layout {
274 case layoutUnified:
275 dv.codeWidth = dv.width - fullNumWidth - leadingSymbolsSize
276 case layoutSplit:
277 remainingWidth := dv.width - fullNumWidth - leadingSymbolsSize*2
278 dv.codeWidth = remainingWidth / 2
279 dv.extraColOnAfter = isOdd(remainingWidth)
280 }
282 dv.fullCodeWidth = dv.codeWidth + leadingSymbolsSize
285// renderUnified renders the unified diff view as a string.
286func (dv *DiffView) renderUnified() string {
287 var b strings.Builder
289 fullContentStyle := lipgloss.NewStyle().MaxWidth(dv.fullCodeWidth)
291 for _, h := range dv.unified.Hunks {
292 if dv.lineNumbers {
293 b.WriteString(dv.style.DividerLine.LineNumber.Render(pad("…", dv.beforeNumDigits)))
294 b.WriteString(dv.style.DividerLine.LineNumber.Render(pad("…", dv.afterNumDigits)))
295 }
296 content := ansi.Truncate(dv.hunkLineFor(h), dv.fullCodeWidth, "…")
297 b.WriteString(dv.style.DividerLine.Code.Width(dv.fullCodeWidth).Render(content))
298 b.WriteRune('\n')
300 beforeLine := h.FromLine
301 afterLine := h.ToLine
303 for _, l := range h.Lines {
304 content := strings.TrimSuffix(l.Content, "\n")
305 content = ansi.Truncate(content, dv.codeWidth, "…")
307 switch l.Kind {
308 case udiff.Equal:
309 if dv.lineNumbers {
310 b.WriteString(dv.style.EqualLine.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
311 b.WriteString(dv.style.EqualLine.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
312 }
313 b.WriteString(fullContentStyle.Render(
314 dv.style.EqualLine.Code.Width(dv.fullCodeWidth).Render(" " + content),
315 ))
316 beforeLine++
317 afterLine++
318 case udiff.Insert:
319 if dv.lineNumbers {
320 b.WriteString(dv.style.InsertLine.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
321 b.WriteString(dv.style.InsertLine.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
322 }
323 b.WriteString(fullContentStyle.Render(
324 dv.style.InsertLine.Symbol.Render("+ ") +
325 dv.style.InsertLine.Code.Width(dv.codeWidth).Render(content),
326 ))
327 afterLine++
328 case udiff.Delete:
329 if dv.lineNumbers {
330 b.WriteString(dv.style.DeleteLine.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
331 b.WriteString(dv.style.DeleteLine.LineNumber.Render(pad(" ", dv.afterNumDigits)))
332 }
333 b.WriteString(fullContentStyle.Render(
334 dv.style.DeleteLine.Symbol.Render("- ") +
335 dv.style.DeleteLine.Code.Width(dv.codeWidth).Render(content),
336 ))
337 beforeLine++
338 }
339 b.WriteRune('\n')
340 }
341 }
343 return b.String()
346// renderSplit renders the split (side-by-side) diff view as a string.
347func (dv *DiffView) renderSplit() string {
348 var b strings.Builder
350 beforeFullContentStyle := lipgloss.NewStyle().MaxWidth(dv.fullCodeWidth)
351 afterFullContentStyle := lipgloss.NewStyle().MaxWidth(dv.fullCodeWidth + btoi(dv.extraColOnAfter))
353 for i, h := range dv.splitHunks {
354 if dv.lineNumbers {
355 b.WriteString(dv.style.DividerLine.LineNumber.Render(pad("…", dv.beforeNumDigits)))
356 }
357 content := ansi.Truncate(dv.hunkLineFor(dv.unified.Hunks[i]), dv.fullCodeWidth, "…")
358 b.WriteString(dv.style.DividerLine.Code.Width(dv.fullCodeWidth).Render(content))
359 if dv.lineNumbers {
360 b.WriteString(dv.style.DividerLine.LineNumber.Render(pad("…", dv.afterNumDigits)))
361 }
362 b.WriteString(dv.style.DividerLine.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(" "))
363 b.WriteRune('\n')
365 beforeLine := h.fromLine
366 afterLine := h.toLine
368 for _, l := range h.lines {
369 var beforeContent string
370 var afterContent string
371 if l.before != nil {
372 beforeContent = strings.TrimSuffix(l.before.Content, "\n")
373 beforeContent = ansi.Truncate(beforeContent, dv.codeWidth, "…")
374 }
375 if l.after != nil {
376 afterContent = strings.TrimSuffix(l.after.Content, "\n")
377 afterContent = ansi.Truncate(afterContent, dv.codeWidth+btoi(dv.extraColOnAfter), "…")
378 }
380 switch {
381 case l.before == nil:
382 if dv.lineNumbers {
383 b.WriteString(dv.style.MissingLine.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
384 }
385 b.WriteString(beforeFullContentStyle.Render(
386 dv.style.MissingLine.Code.Width(dv.fullCodeWidth).Render(" "),
387 ))
388 case l.before.Kind == udiff.Equal:
389 if dv.lineNumbers {
390 b.WriteString(dv.style.EqualLine.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
391 }
392 b.WriteString(beforeFullContentStyle.Render(
393 dv.style.EqualLine.Code.Width(dv.fullCodeWidth).Render(" " + beforeContent),
394 ))
395 beforeLine++
396 case l.before.Kind == udiff.Delete:
397 if dv.lineNumbers {
398 b.WriteString(dv.style.DeleteLine.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
399 }
400 b.WriteString(beforeFullContentStyle.Render(
401 dv.style.DeleteLine.Symbol.Render("- ") +
402 dv.style.DeleteLine.Code.Width(dv.codeWidth).Render(beforeContent),
403 ))
404 beforeLine++
405 }
407 switch {
408 case l.after == nil:
409 if dv.lineNumbers {
410 b.WriteString(dv.style.MissingLine.LineNumber.Render(pad(" ", dv.afterNumDigits)))
411 }
412 b.WriteString(afterFullContentStyle.Render(
413 dv.style.MissingLine.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(" "),
414 ))
415 case l.after.Kind == udiff.Equal:
416 if dv.lineNumbers {
417 b.WriteString(dv.style.EqualLine.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
418 }
419 b.WriteString(afterFullContentStyle.Render(
420 dv.style.EqualLine.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(" " + afterContent),
421 ))
422 afterLine++
423 case l.after.Kind == udiff.Insert:
424 if dv.lineNumbers {
425 b.WriteString(dv.style.InsertLine.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
426 }
427 b.WriteString(afterFullContentStyle.Render(
428 dv.style.InsertLine.Symbol.Render("+ ") +
429 dv.style.InsertLine.Code.Width(dv.codeWidth+btoi(dv.extraColOnAfter)).Render(afterContent),
430 ))
431 afterLine++
432 }
434 b.WriteRune('\n')
435 }
436 }
438 return b.String()
441// hunkLineFor formats the header line for a hunk in the unified diff view.
442func (dv *DiffView) hunkLineFor(h *udiff.Hunk) string {
443 beforeShownLines, afterShownLines := dv.hunkShownLines(h)
445 return fmt.Sprintf(
446 " @@ -%d,%d +%d,%d @@ ",
447 h.FromLine,
448 beforeShownLines,
449 h.ToLine,
450 afterShownLines,
451 )
454// hunkShownLines calculates the number of lines shown in a hunk for both before
455// and after versions.
456func (dv *DiffView) hunkShownLines(h *udiff.Hunk) (before, after int) {
457 for _, l := range h.Lines {
458 switch l.Kind {
459 case udiff.Equal:
460 before++
461 after++
462 case udiff.Insert:
463 after++
464 case udiff.Delete:
465 before++
466 }
467 }
468 return