diffview.go 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774
  1. package diffview
  2. import (
  3. "fmt"
  4. "image/color"
  5. "strconv"
  6. "strings"
  7. "charm.land/lipgloss/v2"
  8. "github.com/alecthomas/chroma/v2"
  9. "github.com/alecthomas/chroma/v2/lexers"
  10. "github.com/aymanbagabas/go-udiff"
  11. "github.com/charmbracelet/x/ansi"
  12. "github.com/zeebo/xxh3"
  13. )
  14. const (
  15. leadingSymbolsSize = 2
  16. lineNumPadding = 1
  17. )
  18. type file struct {
  19. path string
  20. content string
  21. }
  22. type layout int
  23. const (
  24. layoutUnified layout = iota + 1
  25. layoutSplit
  26. )
  27. // DiffView represents a view for displaying differences between two files.
  28. type DiffView struct {
  29. layout layout
  30. before file
  31. after file
  32. contextLines int
  33. lineNumbers bool
  34. height int
  35. width int
  36. xOffset int
  37. yOffset int
  38. infiniteYScroll bool
  39. style Style
  40. tabWidth int
  41. chromaStyle *chroma.Style
  42. isComputed bool
  43. err error
  44. unified udiff.UnifiedDiff
  45. edits []udiff.Edit
  46. splitHunks []splitHunk
  47. totalLines int
  48. codeWidth int
  49. fullCodeWidth int // with leading symbols
  50. extraColOnAfter bool // add extra column on after panel
  51. beforeNumDigits int
  52. afterNumDigits int
  53. // Cache lexer to avoid expensive file pattern matching on every line
  54. cachedLexer chroma.Lexer
  55. // Cache highlighted lines to avoid re-highlighting the same content
  56. // Key: hash of (content + background color), Value: highlighted string
  57. syntaxCache map[string]string
  58. }
  59. // New creates a new DiffView with default settings.
  60. func New() *DiffView {
  61. dv := &DiffView{
  62. layout: layoutUnified,
  63. contextLines: udiff.DefaultContextLines,
  64. lineNumbers: true,
  65. tabWidth: 8,
  66. syntaxCache: make(map[string]string),
  67. }
  68. dv.style = DefaultDarkStyle()
  69. return dv
  70. }
  71. // Unified sets the layout of the DiffView to unified.
  72. func (dv *DiffView) Unified() *DiffView {
  73. dv.layout = layoutUnified
  74. return dv
  75. }
  76. // Split sets the layout of the DiffView to split (side-by-side).
  77. func (dv *DiffView) Split() *DiffView {
  78. dv.layout = layoutSplit
  79. return dv
  80. }
  81. // Before sets the "before" file for the DiffView.
  82. func (dv *DiffView) Before(path, content string) *DiffView {
  83. dv.before = file{path: path, content: content}
  84. // Clear caches when content changes
  85. dv.clearCaches()
  86. return dv
  87. }
  88. // After sets the "after" file for the DiffView.
  89. func (dv *DiffView) After(path, content string) *DiffView {
  90. dv.after = file{path: path, content: content}
  91. // Clear caches when content changes
  92. dv.clearCaches()
  93. return dv
  94. }
  95. // clearCaches clears all caches when content or major settings change.
  96. func (dv *DiffView) clearCaches() {
  97. dv.cachedLexer = nil
  98. dv.clearSyntaxCache()
  99. dv.isComputed = false
  100. }
  101. // ContextLines sets the number of context lines for the DiffView.
  102. func (dv *DiffView) ContextLines(contextLines int) *DiffView {
  103. dv.contextLines = contextLines
  104. return dv
  105. }
  106. // Style sets the style for the DiffView.
  107. func (dv *DiffView) Style(style Style) *DiffView {
  108. dv.style = style
  109. return dv
  110. }
  111. // LineNumbers sets whether to display line numbers in the DiffView.
  112. func (dv *DiffView) LineNumbers(lineNumbers bool) *DiffView {
  113. dv.lineNumbers = lineNumbers
  114. return dv
  115. }
  116. // Height sets the height of the DiffView.
  117. func (dv *DiffView) Height(height int) *DiffView {
  118. dv.height = height
  119. return dv
  120. }
  121. // Width sets the width of the DiffView.
  122. func (dv *DiffView) Width(width int) *DiffView {
  123. dv.width = width
  124. return dv
  125. }
  126. // XOffset sets the horizontal offset for the DiffView.
  127. func (dv *DiffView) XOffset(xOffset int) *DiffView {
  128. dv.xOffset = xOffset
  129. return dv
  130. }
  131. // YOffset sets the vertical offset for the DiffView.
  132. func (dv *DiffView) YOffset(yOffset int) *DiffView {
  133. dv.yOffset = yOffset
  134. return dv
  135. }
  136. // InfiniteYScroll allows the YOffset to scroll beyond the last line.
  137. func (dv *DiffView) InfiniteYScroll(infiniteYScroll bool) *DiffView {
  138. dv.infiniteYScroll = infiniteYScroll
  139. return dv
  140. }
  141. // TabWidth sets the tab width. Only relevant for code that contains tabs, like
  142. // Go code.
  143. func (dv *DiffView) TabWidth(tabWidth int) *DiffView {
  144. dv.tabWidth = tabWidth
  145. return dv
  146. }
  147. // ChromaStyle sets the chroma style for syntax highlighting.
  148. // If nil, no syntax highlighting will be applied.
  149. func (dv *DiffView) ChromaStyle(style *chroma.Style) *DiffView {
  150. dv.chromaStyle = style
  151. // Clear syntax cache when style changes since highlighting will be different
  152. dv.clearSyntaxCache()
  153. return dv
  154. }
  155. // clearSyntaxCache clears the syntax highlighting cache.
  156. func (dv *DiffView) clearSyntaxCache() {
  157. if dv.syntaxCache != nil {
  158. // Clear the map but keep it allocated
  159. for k := range dv.syntaxCache {
  160. delete(dv.syntaxCache, k)
  161. }
  162. }
  163. }
  164. // String returns the string representation of the DiffView.
  165. func (dv *DiffView) String() string {
  166. dv.normalizeLineEndings()
  167. dv.replaceTabs()
  168. if err := dv.computeDiff(); err != nil {
  169. return err.Error()
  170. }
  171. dv.convertDiffToSplit()
  172. dv.adjustStyles()
  173. dv.detectNumDigits()
  174. dv.detectTotalLines()
  175. dv.preventInfiniteYScroll()
  176. if dv.width <= 0 {
  177. dv.detectCodeWidth()
  178. } else {
  179. dv.resizeCodeWidth()
  180. }
  181. style := lipgloss.NewStyle()
  182. if dv.width > 0 {
  183. style = style.MaxWidth(dv.width)
  184. }
  185. if dv.height > 0 {
  186. style = style.MaxHeight(dv.height)
  187. }
  188. switch dv.layout {
  189. case layoutUnified:
  190. return style.Render(strings.TrimSuffix(dv.renderUnified(), "\n"))
  191. case layoutSplit:
  192. return style.Render(strings.TrimSuffix(dv.renderSplit(), "\n"))
  193. default:
  194. panic("unknown diffview layout")
  195. }
  196. }
  197. // normalizeLineEndings ensures the file contents use Unix-style line endings.
  198. func (dv *DiffView) normalizeLineEndings() {
  199. dv.before.content = strings.ReplaceAll(dv.before.content, "\r\n", "\n")
  200. dv.after.content = strings.ReplaceAll(dv.after.content, "\r\n", "\n")
  201. }
  202. // replaceTabs replaces tabs in the before and after file contents with spaces
  203. // according to the specified tab width.
  204. func (dv *DiffView) replaceTabs() {
  205. spaces := strings.Repeat(" ", dv.tabWidth)
  206. dv.before.content = strings.ReplaceAll(dv.before.content, "\t", spaces)
  207. dv.after.content = strings.ReplaceAll(dv.after.content, "\t", spaces)
  208. }
  209. // computeDiff computes the differences between the "before" and "after" files.
  210. func (dv *DiffView) computeDiff() error {
  211. if dv.isComputed {
  212. return dv.err
  213. }
  214. dv.isComputed = true
  215. dv.edits = udiff.Strings(
  216. dv.before.content,
  217. dv.after.content,
  218. )
  219. dv.unified, dv.err = udiff.ToUnifiedDiff(
  220. dv.before.path,
  221. dv.after.path,
  222. dv.before.content,
  223. dv.edits,
  224. dv.contextLines,
  225. )
  226. return dv.err
  227. }
  228. // convertDiffToSplit converts the unified diff to a split diff if the layout is
  229. // set to split.
  230. func (dv *DiffView) convertDiffToSplit() {
  231. if dv.layout != layoutSplit {
  232. return
  233. }
  234. dv.splitHunks = make([]splitHunk, len(dv.unified.Hunks))
  235. for i, h := range dv.unified.Hunks {
  236. dv.splitHunks[i] = hunkToSplit(h)
  237. }
  238. }
  239. // adjustStyles adjusts adds padding and alignment to the styles.
  240. func (dv *DiffView) adjustStyles() {
  241. setPadding := func(s lipgloss.Style) lipgloss.Style {
  242. return s.Padding(0, lineNumPadding).Align(lipgloss.Right)
  243. }
  244. dv.style.MissingLine.LineNumber = setPadding(dv.style.MissingLine.LineNumber)
  245. dv.style.DividerLine.LineNumber = setPadding(dv.style.DividerLine.LineNumber)
  246. dv.style.EqualLine.LineNumber = setPadding(dv.style.EqualLine.LineNumber)
  247. dv.style.InsertLine.LineNumber = setPadding(dv.style.InsertLine.LineNumber)
  248. dv.style.DeleteLine.LineNumber = setPadding(dv.style.DeleteLine.LineNumber)
  249. }
  250. // detectNumDigits calculates the maximum number of digits needed for before and
  251. // after line numbers.
  252. func (dv *DiffView) detectNumDigits() {
  253. dv.beforeNumDigits = 0
  254. 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))))
  258. }
  259. }
  260. func (dv *DiffView) detectTotalLines() {
  261. dv.totalLines = 0
  262. switch dv.layout {
  263. case layoutUnified:
  264. for _, h := range dv.unified.Hunks {
  265. dv.totalLines += 1 + len(h.Lines)
  266. }
  267. case layoutSplit:
  268. for _, h := range dv.splitHunks {
  269. dv.totalLines += 1 + len(h.lines)
  270. }
  271. }
  272. }
  273. func (dv *DiffView) preventInfiniteYScroll() {
  274. if dv.infiniteYScroll {
  275. return
  276. }
  277. // clamp yOffset to prevent scrolling beyond the last line
  278. if dv.height > 0 {
  279. maxYOffset := max(0, dv.totalLines-dv.height)
  280. dv.yOffset = min(dv.yOffset, maxYOffset)
  281. } else {
  282. // if no height limit, ensure yOffset doesn't exceed total lines
  283. dv.yOffset = min(dv.yOffset, max(0, dv.totalLines-1))
  284. }
  285. dv.yOffset = max(0, dv.yOffset) // ensure yOffset is not negative
  286. }
  287. // detectCodeWidth calculates the maximum width of code lines in the diff view.
  288. func (dv *DiffView) detectCodeWidth() {
  289. switch dv.layout {
  290. case layoutUnified:
  291. dv.detectUnifiedCodeWidth()
  292. case layoutSplit:
  293. dv.detectSplitCodeWidth()
  294. }
  295. dv.fullCodeWidth = dv.codeWidth + leadingSymbolsSize
  296. }
  297. // detectUnifiedCodeWidth calculates the maximum width of code lines in a
  298. // unified diff.
  299. func (dv *DiffView) detectUnifiedCodeWidth() {
  300. dv.codeWidth = 0
  301. for _, h := range dv.unified.Hunks {
  302. shownLines := ansi.StringWidth(dv.hunkLineFor(h))
  303. for _, l := range h.Lines {
  304. lineWidth := ansi.StringWidth(strings.TrimSuffix(l.Content, "\n")) + 1
  305. dv.codeWidth = max(dv.codeWidth, lineWidth, shownLines)
  306. }
  307. }
  308. }
  309. // detectSplitCodeWidth calculates the maximum width of code lines in a
  310. // split diff.
  311. func (dv *DiffView) detectSplitCodeWidth() {
  312. dv.codeWidth = 0
  313. for i, h := range dv.splitHunks {
  314. shownLines := ansi.StringWidth(dv.hunkLineFor(dv.unified.Hunks[i]))
  315. for _, l := range h.lines {
  316. if l.before != nil {
  317. codeWidth := ansi.StringWidth(strings.TrimSuffix(l.before.Content, "\n")) + 1
  318. dv.codeWidth = max(dv.codeWidth, codeWidth, shownLines)
  319. }
  320. if l.after != nil {
  321. codeWidth := ansi.StringWidth(strings.TrimSuffix(l.after.Content, "\n")) + 1
  322. dv.codeWidth = max(dv.codeWidth, codeWidth, shownLines)
  323. }
  324. }
  325. }
  326. }
  327. // resizeCodeWidth resizes the code width to fit within the specified width.
  328. func (dv *DiffView) resizeCodeWidth() {
  329. fullNumWidth := dv.beforeNumDigits + dv.afterNumDigits
  330. fullNumWidth += lineNumPadding * 4 // left and right padding for both line numbers
  331. switch dv.layout {
  332. case layoutUnified:
  333. dv.codeWidth = dv.width - fullNumWidth - leadingSymbolsSize
  334. case layoutSplit:
  335. remainingWidth := dv.width - fullNumWidth - leadingSymbolsSize*2
  336. dv.codeWidth = remainingWidth / 2
  337. dv.extraColOnAfter = isOdd(remainingWidth)
  338. }
  339. dv.fullCodeWidth = dv.codeWidth + leadingSymbolsSize
  340. }
  341. // renderUnified renders the unified diff view as a string.
  342. func (dv *DiffView) renderUnified() string {
  343. var b strings.Builder
  344. fullContentStyle := lipgloss.NewStyle().MaxWidth(dv.fullCodeWidth)
  345. printedLines := -dv.yOffset
  346. shouldWrite := func() bool { return printedLines >= 0 }
  347. getContent := func(in string, ls LineStyle) (content string, leadingEllipsis bool) {
  348. content = strings.TrimSuffix(in, "\n")
  349. content = dv.hightlightCode(content, ls.Code.GetBackground())
  350. content = ansi.GraphemeWidth.Cut(content, dv.xOffset, len(content))
  351. content = ansi.Truncate(content, dv.codeWidth, "…")
  352. leadingEllipsis = dv.xOffset > 0 && strings.TrimSpace(content) != ""
  353. return content, leadingEllipsis
  354. }
  355. outer:
  356. for i, h := range dv.unified.Hunks {
  357. if shouldWrite() {
  358. ls := dv.style.DividerLine
  359. if dv.lineNumbers {
  360. b.WriteString(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
  361. b.WriteString(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
  362. }
  363. content := ansi.Truncate(dv.hunkLineFor(h), dv.fullCodeWidth, "…")
  364. b.WriteString(ls.Code.Width(dv.fullCodeWidth).Render(content))
  365. b.WriteString("\n")
  366. }
  367. printedLines++
  368. beforeLine := h.FromLine
  369. afterLine := h.ToLine
  370. for j, l := range h.Lines {
  371. // print ellipis if we don't have enough space to print the rest of the diff
  372. hasReachedHeight := dv.height > 0 && printedLines+1 == dv.height
  373. isLastHunk := i+1 == len(dv.unified.Hunks)
  374. isLastLine := j+1 == len(h.Lines)
  375. if hasReachedHeight && (!isLastHunk || !isLastLine) {
  376. if shouldWrite() {
  377. ls := dv.lineStyleForType(l.Kind)
  378. if dv.lineNumbers {
  379. b.WriteString(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
  380. b.WriteString(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
  381. }
  382. b.WriteString(fullContentStyle.Render(
  383. ls.Code.Width(dv.fullCodeWidth).Render(" …"),
  384. ))
  385. b.WriteRune('\n')
  386. }
  387. break outer
  388. }
  389. switch l.Kind {
  390. case udiff.Equal:
  391. if shouldWrite() {
  392. ls := dv.style.EqualLine
  393. content, leadingEllipsis := getContent(l.Content, ls)
  394. if dv.lineNumbers {
  395. b.WriteString(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
  396. b.WriteString(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
  397. }
  398. b.WriteString(fullContentStyle.Render(
  399. ls.Code.Width(dv.fullCodeWidth).Render(ternary(leadingEllipsis, " …", " ") + content),
  400. ))
  401. }
  402. beforeLine++
  403. afterLine++
  404. case udiff.Insert:
  405. if shouldWrite() {
  406. ls := dv.style.InsertLine
  407. content, leadingEllipsis := getContent(l.Content, ls)
  408. if dv.lineNumbers {
  409. b.WriteString(ls.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
  410. b.WriteString(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
  411. }
  412. b.WriteString(fullContentStyle.Render(
  413. ls.Symbol.Render(ternary(leadingEllipsis, "+…", "+ ")) +
  414. ls.Code.Width(dv.codeWidth).Render(content),
  415. ))
  416. }
  417. afterLine++
  418. case udiff.Delete:
  419. if shouldWrite() {
  420. ls := dv.style.DeleteLine
  421. content, leadingEllipsis := getContent(l.Content, ls)
  422. if dv.lineNumbers {
  423. b.WriteString(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
  424. b.WriteString(ls.LineNumber.Render(pad(" ", dv.afterNumDigits)))
  425. }
  426. b.WriteString(fullContentStyle.Render(
  427. ls.Symbol.Render(ternary(leadingEllipsis, "-…", "- ")) +
  428. ls.Code.Width(dv.codeWidth).Render(content),
  429. ))
  430. }
  431. beforeLine++
  432. }
  433. if shouldWrite() {
  434. b.WriteRune('\n')
  435. }
  436. printedLines++
  437. }
  438. }
  439. return b.String()
  440. }
  441. // renderSplit renders the split (side-by-side) diff view as a string.
  442. func (dv *DiffView) renderSplit() string {
  443. var b strings.Builder
  444. beforeFullContentStyle := lipgloss.NewStyle().MaxWidth(dv.fullCodeWidth)
  445. afterFullContentStyle := lipgloss.NewStyle().MaxWidth(dv.fullCodeWidth + btoi(dv.extraColOnAfter))
  446. printedLines := -dv.yOffset
  447. shouldWrite := func() bool { return printedLines >= 0 }
  448. getContent := func(in string, ls LineStyle) (content string, leadingEllipsis bool) {
  449. content = strings.TrimSuffix(in, "\n")
  450. content = dv.hightlightCode(content, ls.Code.GetBackground())
  451. content = ansi.GraphemeWidth.Cut(content, dv.xOffset, len(content))
  452. content = ansi.Truncate(content, dv.codeWidth, "…")
  453. leadingEllipsis = dv.xOffset > 0 && strings.TrimSpace(content) != ""
  454. return content, leadingEllipsis
  455. }
  456. outer:
  457. for i, h := range dv.splitHunks {
  458. if shouldWrite() {
  459. ls := dv.style.DividerLine
  460. if dv.lineNumbers {
  461. b.WriteString(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
  462. }
  463. content := ansi.Truncate(dv.hunkLineFor(dv.unified.Hunks[i]), dv.fullCodeWidth, "…")
  464. b.WriteString(ls.Code.Width(dv.fullCodeWidth).Render(content))
  465. if dv.lineNumbers {
  466. b.WriteString(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
  467. }
  468. b.WriteString(ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(" "))
  469. b.WriteRune('\n')
  470. }
  471. printedLines++
  472. beforeLine := h.fromLine
  473. afterLine := h.toLine
  474. for j, l := range h.lines {
  475. // print ellipis if we don't have enough space to print the rest of the diff
  476. hasReachedHeight := dv.height > 0 && printedLines+1 == dv.height
  477. isLastHunk := i+1 == len(dv.unified.Hunks)
  478. isLastLine := j+1 == len(h.lines)
  479. if hasReachedHeight && (!isLastHunk || !isLastLine) {
  480. if shouldWrite() {
  481. ls := dv.style.MissingLine
  482. if l.before != nil {
  483. ls = dv.lineStyleForType(l.before.Kind)
  484. }
  485. if dv.lineNumbers {
  486. b.WriteString(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
  487. }
  488. b.WriteString(beforeFullContentStyle.Render(
  489. ls.Code.Width(dv.fullCodeWidth).Render(" …"),
  490. ))
  491. ls = dv.style.MissingLine
  492. if l.after != nil {
  493. ls = dv.lineStyleForType(l.after.Kind)
  494. }
  495. if dv.lineNumbers {
  496. b.WriteString(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
  497. }
  498. b.WriteString(afterFullContentStyle.Render(
  499. ls.Code.Width(dv.fullCodeWidth).Render(" …"),
  500. ))
  501. b.WriteRune('\n')
  502. }
  503. break outer
  504. }
  505. switch {
  506. case l.before == nil:
  507. if shouldWrite() {
  508. ls := dv.style.MissingLine
  509. if dv.lineNumbers {
  510. b.WriteString(ls.LineNumber.Render(pad(" ", dv.beforeNumDigits)))
  511. }
  512. b.WriteString(beforeFullContentStyle.Render(
  513. ls.Code.Width(dv.fullCodeWidth).Render(" "),
  514. ))
  515. }
  516. case l.before.Kind == udiff.Equal:
  517. if shouldWrite() {
  518. ls := dv.style.EqualLine
  519. content, leadingEllipsis := getContent(l.before.Content, ls)
  520. if dv.lineNumbers {
  521. b.WriteString(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
  522. }
  523. b.WriteString(beforeFullContentStyle.Render(
  524. ls.Code.Width(dv.fullCodeWidth).Render(ternary(leadingEllipsis, " …", " ") + content),
  525. ))
  526. }
  527. beforeLine++
  528. case l.before.Kind == udiff.Delete:
  529. if shouldWrite() {
  530. ls := dv.style.DeleteLine
  531. content, leadingEllipsis := getContent(l.before.Content, ls)
  532. if dv.lineNumbers {
  533. b.WriteString(ls.LineNumber.Render(pad(beforeLine, dv.beforeNumDigits)))
  534. }
  535. b.WriteString(beforeFullContentStyle.Render(
  536. ls.Symbol.Render(ternary(leadingEllipsis, "-…", "- ")) +
  537. ls.Code.Width(dv.codeWidth).Render(content),
  538. ))
  539. }
  540. beforeLine++
  541. }
  542. switch {
  543. case l.after == nil:
  544. if shouldWrite() {
  545. ls := dv.style.MissingLine
  546. if dv.lineNumbers {
  547. b.WriteString(ls.LineNumber.Render(pad(" ", dv.afterNumDigits)))
  548. }
  549. b.WriteString(afterFullContentStyle.Render(
  550. ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(" "),
  551. ))
  552. }
  553. case l.after.Kind == udiff.Equal:
  554. if shouldWrite() {
  555. ls := dv.style.EqualLine
  556. content, leadingEllipsis := getContent(l.after.Content, ls)
  557. if dv.lineNumbers {
  558. b.WriteString(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
  559. }
  560. b.WriteString(afterFullContentStyle.Render(
  561. ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(ternary(leadingEllipsis, " …", " ") + content),
  562. ))
  563. }
  564. afterLine++
  565. case l.after.Kind == udiff.Insert:
  566. if shouldWrite() {
  567. ls := dv.style.InsertLine
  568. content, leadingEllipsis := getContent(l.after.Content, ls)
  569. if dv.lineNumbers {
  570. b.WriteString(ls.LineNumber.Render(pad(afterLine, dv.afterNumDigits)))
  571. }
  572. b.WriteString(afterFullContentStyle.Render(
  573. ls.Symbol.Render(ternary(leadingEllipsis, "+…", "+ ")) +
  574. ls.Code.Width(dv.codeWidth+btoi(dv.extraColOnAfter)).Render(content),
  575. ))
  576. }
  577. afterLine++
  578. }
  579. if shouldWrite() {
  580. b.WriteRune('\n')
  581. }
  582. printedLines++
  583. }
  584. }
  585. return b.String()
  586. }
  587. // hunkLineFor formats the header line for a hunk in the unified diff view.
  588. func (dv *DiffView) hunkLineFor(h *udiff.Hunk) string {
  589. beforeShownLines, afterShownLines := dv.hunkShownLines(h)
  590. return fmt.Sprintf(
  591. " @@ -%d,%d +%d,%d @@ ",
  592. h.FromLine,
  593. beforeShownLines,
  594. h.ToLine,
  595. afterShownLines,
  596. )
  597. }
  598. // hunkShownLines calculates the number of lines shown in a hunk for both before
  599. // and after versions.
  600. func (dv *DiffView) hunkShownLines(h *udiff.Hunk) (before, after int) {
  601. for _, l := range h.Lines {
  602. switch l.Kind {
  603. case udiff.Equal:
  604. before++
  605. after++
  606. case udiff.Insert:
  607. after++
  608. case udiff.Delete:
  609. before++
  610. }
  611. }
  612. return before, after
  613. }
  614. func (dv *DiffView) lineStyleForType(t udiff.OpKind) LineStyle {
  615. switch t {
  616. case udiff.Equal:
  617. return dv.style.EqualLine
  618. case udiff.Insert:
  619. return dv.style.InsertLine
  620. case udiff.Delete:
  621. return dv.style.DeleteLine
  622. default:
  623. return dv.style.MissingLine
  624. }
  625. }
  626. func (dv *DiffView) hightlightCode(source string, bgColor color.Color) string {
  627. if dv.chromaStyle == nil {
  628. return source
  629. }
  630. // Create cache key from content and background color
  631. cacheKey := dv.createSyntaxCacheKey(source, bgColor)
  632. // Check if we already have this highlighted
  633. if cached, exists := dv.syntaxCache[cacheKey]; exists {
  634. return cached
  635. }
  636. l := dv.getChromaLexer()
  637. f := dv.getChromaFormatter(bgColor)
  638. it, err := l.Tokenise(nil, source)
  639. if err != nil {
  640. return source
  641. }
  642. var b strings.Builder
  643. if err := f.Format(&b, dv.chromaStyle, it); err != nil {
  644. return source
  645. }
  646. result := b.String()
  647. // Cache the result for future use
  648. dv.syntaxCache[cacheKey] = result
  649. return result
  650. }
  651. // createSyntaxCacheKey creates a cache key from source content and background color.
  652. // We use a simple hash to keep memory usage reasonable.
  653. func (dv *DiffView) createSyntaxCacheKey(source string, bgColor color.Color) string {
  654. // Convert color to string representation
  655. r, g, b, a := bgColor.RGBA()
  656. colorStr := fmt.Sprintf("%d,%d,%d,%d", r, g, b, a)
  657. // Create a hash of the content + color to use as cache key
  658. h := xxh3.New()
  659. h.Write([]byte(source))
  660. h.Write([]byte(colorStr))
  661. return fmt.Sprintf("%x", h.Sum(nil))
  662. }
  663. func (dv *DiffView) getChromaLexer() chroma.Lexer {
  664. if dv.cachedLexer != nil {
  665. return dv.cachedLexer
  666. }
  667. l := lexers.Match(dv.before.path)
  668. if l == nil {
  669. l = lexers.Analyse(dv.before.content)
  670. }
  671. if l == nil {
  672. l = lexers.Fallback
  673. }
  674. dv.cachedLexer = chroma.Coalesce(l)
  675. return dv.cachedLexer
  676. }
  677. func (dv *DiffView) getChromaFormatter(bgColor color.Color) chroma.Formatter {
  678. return chromaFormatter{
  679. bgColor: bgColor,
  680. }
  681. }