diff.go 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799
  1. package diff
  2. import (
  3. "bytes"
  4. "fmt"
  5. "io"
  6. "os"
  7. "path/filepath"
  8. "regexp"
  9. "strconv"
  10. "strings"
  11. "time"
  12. "github.com/alecthomas/chroma/v2"
  13. "github.com/alecthomas/chroma/v2/formatters"
  14. "github.com/alecthomas/chroma/v2/lexers"
  15. "github.com/alecthomas/chroma/v2/styles"
  16. "github.com/charmbracelet/lipgloss"
  17. "github.com/charmbracelet/x/ansi"
  18. "github.com/go-git/go-git/v5"
  19. "github.com/go-git/go-git/v5/plumbing/object"
  20. "github.com/sergi/go-diff/diffmatchpatch"
  21. )
  22. // LineType represents the kind of line in a diff.
  23. type LineType int
  24. const (
  25. // LineContext represents a line that exists in both the old and new file.
  26. LineContext LineType = iota
  27. // LineAdded represents a line added in the new file.
  28. LineAdded
  29. // LineRemoved represents a line removed from the old file.
  30. LineRemoved
  31. )
  32. // DiffLine represents a single line in a diff, either from the old file,
  33. // the new file, or a context line.
  34. type DiffLine struct {
  35. OldLineNo int // Line number in the old file (0 for added lines)
  36. NewLineNo int // Line number in the new file (0 for removed lines)
  37. Kind LineType // Type of line (added, removed, context)
  38. Content string // Content of the line
  39. }
  40. // Hunk represents a section of changes in a diff.
  41. type Hunk struct {
  42. Header string
  43. Lines []DiffLine
  44. }
  45. // DiffResult contains the parsed result of a diff.
  46. type DiffResult struct {
  47. OldFile string
  48. NewFile string
  49. Hunks []Hunk
  50. }
  51. // HunkDelta represents the change statistics for a hunk.
  52. type HunkDelta struct {
  53. StartLine1 int
  54. LineCount1 int
  55. StartLine2 int
  56. LineCount2 int
  57. }
  58. // linePair represents a pair of lines to be displayed side by side.
  59. type linePair struct {
  60. left *DiffLine
  61. right *DiffLine
  62. }
  63. // -------------------------------------------------------------------------
  64. // Style Configuration with Option Pattern
  65. // -------------------------------------------------------------------------
  66. // StyleConfig defines styling for diff rendering.
  67. type StyleConfig struct {
  68. RemovedLineBg lipgloss.Color
  69. AddedLineBg lipgloss.Color
  70. ContextLineBg lipgloss.Color
  71. HunkLineBg lipgloss.Color
  72. HunkLineFg lipgloss.Color
  73. RemovedFg lipgloss.Color
  74. AddedFg lipgloss.Color
  75. LineNumberFg lipgloss.Color
  76. HighlightStyle string
  77. RemovedHighlightBg lipgloss.Color
  78. AddedHighlightBg lipgloss.Color
  79. RemovedLineNumberBg lipgloss.Color
  80. AddedLineNamerBg lipgloss.Color
  81. RemovedHighlightFg lipgloss.Color
  82. AddedHighlightFg lipgloss.Color
  83. }
  84. // StyleOption defines a function that modifies a StyleConfig.
  85. type StyleOption func(*StyleConfig)
  86. // NewStyleConfig creates a StyleConfig with default values and applies any provided options.
  87. func NewStyleConfig(opts ...StyleOption) StyleConfig {
  88. // Set default values
  89. config := StyleConfig{
  90. RemovedLineBg: lipgloss.Color("#3A3030"),
  91. AddedLineBg: lipgloss.Color("#303A30"),
  92. ContextLineBg: lipgloss.Color("#212121"),
  93. HunkLineBg: lipgloss.Color("#2A2822"),
  94. HunkLineFg: lipgloss.Color("#D4AF37"),
  95. RemovedFg: lipgloss.Color("#7C4444"),
  96. AddedFg: lipgloss.Color("#478247"),
  97. LineNumberFg: lipgloss.Color("#888888"),
  98. HighlightStyle: "dracula",
  99. RemovedHighlightBg: lipgloss.Color("#612726"),
  100. AddedHighlightBg: lipgloss.Color("#256125"),
  101. RemovedLineNumberBg: lipgloss.Color("#332929"),
  102. AddedLineNamerBg: lipgloss.Color("#293229"),
  103. RemovedHighlightFg: lipgloss.Color("#FADADD"),
  104. AddedHighlightFg: lipgloss.Color("#DAFADA"),
  105. }
  106. // Apply all provided options
  107. for _, opt := range opts {
  108. opt(&config)
  109. }
  110. return config
  111. }
  112. // WithRemovedLineBg sets the background color for removed lines.
  113. func WithRemovedLineBg(color lipgloss.Color) StyleOption {
  114. return func(s *StyleConfig) {
  115. s.RemovedLineBg = color
  116. }
  117. }
  118. // WithAddedLineBg sets the background color for added lines.
  119. func WithAddedLineBg(color lipgloss.Color) StyleOption {
  120. return func(s *StyleConfig) {
  121. s.AddedLineBg = color
  122. }
  123. }
  124. // WithContextLineBg sets the background color for context lines.
  125. func WithContextLineBg(color lipgloss.Color) StyleOption {
  126. return func(s *StyleConfig) {
  127. s.ContextLineBg = color
  128. }
  129. }
  130. // WithRemovedFg sets the foreground color for removed line markers.
  131. func WithRemovedFg(color lipgloss.Color) StyleOption {
  132. return func(s *StyleConfig) {
  133. s.RemovedFg = color
  134. }
  135. }
  136. // WithAddedFg sets the foreground color for added line markers.
  137. func WithAddedFg(color lipgloss.Color) StyleOption {
  138. return func(s *StyleConfig) {
  139. s.AddedFg = color
  140. }
  141. }
  142. // WithLineNumberFg sets the foreground color for line numbers.
  143. func WithLineNumberFg(color lipgloss.Color) StyleOption {
  144. return func(s *StyleConfig) {
  145. s.LineNumberFg = color
  146. }
  147. }
  148. // WithHighlightStyle sets the syntax highlighting style.
  149. func WithHighlightStyle(style string) StyleOption {
  150. return func(s *StyleConfig) {
  151. s.HighlightStyle = style
  152. }
  153. }
  154. // WithRemovedHighlightColors sets the colors for highlighted parts in removed text.
  155. func WithRemovedHighlightColors(bg, fg lipgloss.Color) StyleOption {
  156. return func(s *StyleConfig) {
  157. s.RemovedHighlightBg = bg
  158. s.RemovedHighlightFg = fg
  159. }
  160. }
  161. // WithAddedHighlightColors sets the colors for highlighted parts in added text.
  162. func WithAddedHighlightColors(bg, fg lipgloss.Color) StyleOption {
  163. return func(s *StyleConfig) {
  164. s.AddedHighlightBg = bg
  165. s.AddedHighlightFg = fg
  166. }
  167. }
  168. // WithRemovedLineNumberBg sets the background color for removed line numbers.
  169. func WithRemovedLineNumberBg(color lipgloss.Color) StyleOption {
  170. return func(s *StyleConfig) {
  171. s.RemovedLineNumberBg = color
  172. }
  173. }
  174. // WithAddedLineNumberBg sets the background color for added line numbers.
  175. func WithAddedLineNumberBg(color lipgloss.Color) StyleOption {
  176. return func(s *StyleConfig) {
  177. s.AddedLineNamerBg = color
  178. }
  179. }
  180. func WithHunkLineBg(color lipgloss.Color) StyleOption {
  181. return func(s *StyleConfig) {
  182. s.HunkLineBg = color
  183. }
  184. }
  185. func WithHunkLineFg(color lipgloss.Color) StyleOption {
  186. return func(s *StyleConfig) {
  187. s.HunkLineFg = color
  188. }
  189. }
  190. // -------------------------------------------------------------------------
  191. // Parse Options with Option Pattern
  192. // -------------------------------------------------------------------------
  193. // ParseConfig configures the behavior of diff parsing.
  194. type ParseConfig struct {
  195. ContextSize int // Number of context lines to include
  196. }
  197. // ParseOption defines a function that modifies a ParseConfig.
  198. type ParseOption func(*ParseConfig)
  199. // WithContextSize sets the number of context lines to include.
  200. func WithContextSize(size int) ParseOption {
  201. return func(p *ParseConfig) {
  202. if size >= 0 {
  203. p.ContextSize = size
  204. }
  205. }
  206. }
  207. // -------------------------------------------------------------------------
  208. // Side-by-Side Options with Option Pattern
  209. // -------------------------------------------------------------------------
  210. // SideBySideConfig configures the rendering of side-by-side diffs.
  211. type SideBySideConfig struct {
  212. TotalWidth int
  213. Style StyleConfig
  214. }
  215. // SideBySideOption defines a function that modifies a SideBySideConfig.
  216. type SideBySideOption func(*SideBySideConfig)
  217. // NewSideBySideConfig creates a SideBySideConfig with default values and applies any provided options.
  218. func NewSideBySideConfig(opts ...SideBySideOption) SideBySideConfig {
  219. // Set default values
  220. config := SideBySideConfig{
  221. TotalWidth: 160, // Default width for side-by-side view
  222. Style: NewStyleConfig(),
  223. }
  224. // Apply all provided options
  225. for _, opt := range opts {
  226. opt(&config)
  227. }
  228. return config
  229. }
  230. // WithTotalWidth sets the total width for side-by-side view.
  231. func WithTotalWidth(width int) SideBySideOption {
  232. return func(s *SideBySideConfig) {
  233. if width > 0 {
  234. s.TotalWidth = width
  235. }
  236. }
  237. }
  238. // WithStyle sets the styling configuration.
  239. func WithStyle(style StyleConfig) SideBySideOption {
  240. return func(s *SideBySideConfig) {
  241. s.Style = style
  242. }
  243. }
  244. // WithStyleOptions applies the specified style options.
  245. func WithStyleOptions(opts ...StyleOption) SideBySideOption {
  246. return func(s *SideBySideConfig) {
  247. s.Style = NewStyleConfig(opts...)
  248. }
  249. }
  250. // -------------------------------------------------------------------------
  251. // Diff Parsing and Generation
  252. // -------------------------------------------------------------------------
  253. // ParseUnifiedDiff parses a unified diff format string into structured data.
  254. func ParseUnifiedDiff(diff string) (DiffResult, error) {
  255. var result DiffResult
  256. var currentHunk *Hunk
  257. hunkHeaderRe := regexp.MustCompile(`^@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@`)
  258. lines := strings.Split(diff, "\n")
  259. var oldLine, newLine int
  260. inFileHeader := true
  261. for _, line := range lines {
  262. // Parse the file headers
  263. if inFileHeader {
  264. if strings.HasPrefix(line, "--- a/") {
  265. result.OldFile = strings.TrimPrefix(line, "--- a/")
  266. continue
  267. }
  268. if strings.HasPrefix(line, "+++ b/") {
  269. result.NewFile = strings.TrimPrefix(line, "+++ b/")
  270. inFileHeader = false
  271. continue
  272. }
  273. }
  274. // Parse hunk headers
  275. if matches := hunkHeaderRe.FindStringSubmatch(line); matches != nil {
  276. if currentHunk != nil {
  277. result.Hunks = append(result.Hunks, *currentHunk)
  278. }
  279. currentHunk = &Hunk{
  280. Header: line,
  281. Lines: []DiffLine{},
  282. }
  283. oldStart, _ := strconv.Atoi(matches[1])
  284. newStart, _ := strconv.Atoi(matches[3])
  285. oldLine = oldStart
  286. newLine = newStart
  287. continue
  288. }
  289. // ignore the \\ No newline at end of file
  290. if strings.HasPrefix(line, "\\ No newline at end of file") {
  291. continue
  292. }
  293. if currentHunk == nil {
  294. continue
  295. }
  296. if len(line) > 0 {
  297. // Process the line based on its prefix
  298. switch line[0] {
  299. case '+':
  300. currentHunk.Lines = append(currentHunk.Lines, DiffLine{
  301. OldLineNo: 0,
  302. NewLineNo: newLine,
  303. Kind: LineAdded,
  304. Content: line[1:], // skip '+'
  305. })
  306. newLine++
  307. case '-':
  308. currentHunk.Lines = append(currentHunk.Lines, DiffLine{
  309. OldLineNo: oldLine,
  310. NewLineNo: 0,
  311. Kind: LineRemoved,
  312. Content: line[1:], // skip '-'
  313. })
  314. oldLine++
  315. default:
  316. currentHunk.Lines = append(currentHunk.Lines, DiffLine{
  317. OldLineNo: oldLine,
  318. NewLineNo: newLine,
  319. Kind: LineContext,
  320. Content: line,
  321. })
  322. oldLine++
  323. newLine++
  324. }
  325. } else {
  326. // Handle empty lines
  327. currentHunk.Lines = append(currentHunk.Lines, DiffLine{
  328. OldLineNo: oldLine,
  329. NewLineNo: newLine,
  330. Kind: LineContext,
  331. Content: "",
  332. })
  333. oldLine++
  334. newLine++
  335. }
  336. }
  337. // Add the last hunk if there is one
  338. if currentHunk != nil {
  339. result.Hunks = append(result.Hunks, *currentHunk)
  340. }
  341. return result, nil
  342. }
  343. // HighlightIntralineChanges updates the content of lines in a hunk to show
  344. // character-level differences within lines.
  345. func HighlightIntralineChanges(h *Hunk, style StyleConfig) {
  346. var updated []DiffLine
  347. dmp := diffmatchpatch.New()
  348. for i := 0; i < len(h.Lines); i++ {
  349. // Look for removed line followed by added line, which might have similar content
  350. if i+1 < len(h.Lines) &&
  351. h.Lines[i].Kind == LineRemoved &&
  352. h.Lines[i+1].Kind == LineAdded {
  353. oldLine := h.Lines[i]
  354. newLine := h.Lines[i+1]
  355. // Find character-level differences
  356. patches := dmp.DiffMain(oldLine.Content, newLine.Content, false)
  357. patches = dmp.DiffCleanupEfficiency(patches)
  358. patches = dmp.DiffCleanupSemantic(patches)
  359. // Apply highlighting to the differences
  360. oldLine.Content = colorizeSegments(patches, true, style)
  361. newLine.Content = colorizeSegments(patches, false, style)
  362. updated = append(updated, oldLine, newLine)
  363. i++ // Skip the next line as we've already processed it
  364. } else {
  365. updated = append(updated, h.Lines[i])
  366. }
  367. }
  368. h.Lines = updated
  369. }
  370. // colorizeSegments applies styles to the character-level diff segments.
  371. func colorizeSegments(diffs []diffmatchpatch.Diff, isOld bool, style StyleConfig) string {
  372. var buf strings.Builder
  373. removeBg := lipgloss.NewStyle().
  374. Background(style.RemovedHighlightBg).
  375. Foreground(style.RemovedHighlightFg)
  376. addBg := lipgloss.NewStyle().
  377. Background(style.AddedHighlightBg).
  378. Foreground(style.AddedHighlightFg)
  379. removedLineStyle := lipgloss.NewStyle().Background(style.RemovedLineBg)
  380. addedLineStyle := lipgloss.NewStyle().Background(style.AddedLineBg)
  381. for _, d := range diffs {
  382. switch d.Type {
  383. case diffmatchpatch.DiffEqual:
  384. // Handle text that's the same in both versions
  385. buf.WriteString(d.Text)
  386. case diffmatchpatch.DiffDelete:
  387. // Handle deleted text (only show in old version)
  388. if isOld {
  389. buf.WriteString(removeBg.Render(d.Text))
  390. buf.WriteString(removedLineStyle.Render(""))
  391. }
  392. case diffmatchpatch.DiffInsert:
  393. // Handle inserted text (only show in new version)
  394. if !isOld {
  395. buf.WriteString(addBg.Render(d.Text))
  396. buf.WriteString(addedLineStyle.Render(""))
  397. }
  398. }
  399. }
  400. return buf.String()
  401. }
  402. // pairLines converts a flat list of diff lines to pairs for side-by-side display.
  403. func pairLines(lines []DiffLine) []linePair {
  404. var pairs []linePair
  405. i := 0
  406. for i < len(lines) {
  407. switch lines[i].Kind {
  408. case LineRemoved:
  409. // Check if the next line is an addition, if so pair them
  410. if i+1 < len(lines) && lines[i+1].Kind == LineAdded {
  411. pairs = append(pairs, linePair{left: &lines[i], right: &lines[i+1]})
  412. i += 2
  413. } else {
  414. pairs = append(pairs, linePair{left: &lines[i], right: nil})
  415. i++
  416. }
  417. case LineAdded:
  418. pairs = append(pairs, linePair{left: nil, right: &lines[i]})
  419. i++
  420. case LineContext:
  421. pairs = append(pairs, linePair{left: &lines[i], right: &lines[i]})
  422. i++
  423. }
  424. }
  425. return pairs
  426. }
  427. // -------------------------------------------------------------------------
  428. // Syntax Highlighting
  429. // -------------------------------------------------------------------------
  430. // SyntaxHighlight applies syntax highlighting to a string based on the file extension.
  431. func SyntaxHighlight(w io.Writer, source, fileName, formatter string, bg lipgloss.TerminalColor) error {
  432. // Determine the language lexer to use
  433. l := lexers.Match(fileName)
  434. if l == nil {
  435. l = lexers.Analyse(source)
  436. }
  437. if l == nil {
  438. l = lexers.Fallback
  439. }
  440. l = chroma.Coalesce(l)
  441. // Get the formatter
  442. f := formatters.Get(formatter)
  443. if f == nil {
  444. f = formatters.Fallback
  445. }
  446. // Get the style
  447. s := styles.Get("dracula")
  448. if s == nil {
  449. s = styles.Fallback
  450. }
  451. // Modify the style to use the provided background
  452. s, err := s.Builder().Transform(
  453. func(t chroma.StyleEntry) chroma.StyleEntry {
  454. r, g, b, _ := bg.RGBA()
  455. ru8 := uint8(r >> 8)
  456. gu8 := uint8(g >> 8)
  457. bu8 := uint8(b >> 8)
  458. t.Background = chroma.NewColour(ru8, gu8, bu8)
  459. return t
  460. },
  461. ).Build()
  462. if err != nil {
  463. s = styles.Fallback
  464. }
  465. // Tokenize and format
  466. it, err := l.Tokenise(nil, source)
  467. if err != nil {
  468. return err
  469. }
  470. return f.Format(w, s, it)
  471. }
  472. // highlightLine applies syntax highlighting to a single line.
  473. func highlightLine(fileName string, line string, bg lipgloss.TerminalColor) string {
  474. var buf bytes.Buffer
  475. err := SyntaxHighlight(&buf, line, fileName, "terminal16m", bg)
  476. if err != nil {
  477. return line
  478. }
  479. return buf.String()
  480. }
  481. // createStyles generates the lipgloss styles needed for rendering diffs.
  482. func createStyles(config StyleConfig) (removedLineStyle, addedLineStyle, contextLineStyle, lineNumberStyle lipgloss.Style) {
  483. removedLineStyle = lipgloss.NewStyle().Background(config.RemovedLineBg)
  484. addedLineStyle = lipgloss.NewStyle().Background(config.AddedLineBg)
  485. contextLineStyle = lipgloss.NewStyle().Background(config.ContextLineBg)
  486. lineNumberStyle = lipgloss.NewStyle().Foreground(config.LineNumberFg)
  487. return
  488. }
  489. // renderLeftColumn formats the left side of a side-by-side diff.
  490. func renderLeftColumn(fileName string, dl *DiffLine, colWidth int, styles StyleConfig) string {
  491. if dl == nil {
  492. contextLineStyle := lipgloss.NewStyle().Background(styles.ContextLineBg)
  493. return contextLineStyle.Width(colWidth).Render("")
  494. }
  495. removedLineStyle, _, contextLineStyle, lineNumberStyle := createStyles(styles)
  496. var marker string
  497. var bgStyle lipgloss.Style
  498. switch dl.Kind {
  499. case LineRemoved:
  500. marker = removedLineStyle.Foreground(styles.RemovedFg).Render("-")
  501. bgStyle = removedLineStyle
  502. lineNumberStyle = lineNumberStyle.Foreground(styles.RemovedFg).Background(styles.RemovedLineNumberBg)
  503. case LineAdded:
  504. marker = "?"
  505. bgStyle = contextLineStyle
  506. case LineContext:
  507. marker = contextLineStyle.Render(" ")
  508. bgStyle = contextLineStyle
  509. }
  510. lineNum := ""
  511. if dl.OldLineNo > 0 {
  512. lineNum = fmt.Sprintf("%6d", dl.OldLineNo)
  513. }
  514. prefix := lineNumberStyle.Render(lineNum + " " + marker)
  515. content := highlightLine(fileName, dl.Content, bgStyle.GetBackground())
  516. if dl.Kind == LineRemoved {
  517. content = bgStyle.Render(" ") + content
  518. }
  519. lineText := prefix + content
  520. return bgStyle.MaxHeight(1).Width(colWidth).Render(
  521. ansi.Truncate(
  522. lineText,
  523. colWidth,
  524. lipgloss.NewStyle().Background(styles.HunkLineBg).Foreground(styles.HunkLineFg).Render("..."),
  525. ),
  526. )
  527. }
  528. // renderRightColumn formats the right side of a side-by-side diff.
  529. func renderRightColumn(fileName string, dl *DiffLine, colWidth int, styles StyleConfig) string {
  530. if dl == nil {
  531. contextLineStyle := lipgloss.NewStyle().Background(styles.ContextLineBg)
  532. return contextLineStyle.Width(colWidth).Render("")
  533. }
  534. _, addedLineStyle, contextLineStyle, lineNumberStyle := createStyles(styles)
  535. var marker string
  536. var bgStyle lipgloss.Style
  537. switch dl.Kind {
  538. case LineAdded:
  539. marker = addedLineStyle.Foreground(styles.AddedFg).Render("+")
  540. bgStyle = addedLineStyle
  541. lineNumberStyle = lineNumberStyle.Foreground(styles.AddedFg).Background(styles.AddedLineNamerBg)
  542. case LineRemoved:
  543. marker = "?"
  544. bgStyle = contextLineStyle
  545. case LineContext:
  546. marker = contextLineStyle.Render(" ")
  547. bgStyle = contextLineStyle
  548. }
  549. lineNum := ""
  550. if dl.NewLineNo > 0 {
  551. lineNum = fmt.Sprintf("%6d", dl.NewLineNo)
  552. }
  553. prefix := lineNumberStyle.Render(lineNum + " " + marker)
  554. content := highlightLine(fileName, dl.Content, bgStyle.GetBackground())
  555. if dl.Kind == LineAdded {
  556. content = bgStyle.Render(" ") + content
  557. }
  558. lineText := prefix + content
  559. return bgStyle.MaxHeight(1).Width(colWidth).Render(
  560. ansi.Truncate(
  561. lineText,
  562. colWidth,
  563. lipgloss.NewStyle().Background(styles.HunkLineBg).Foreground(styles.HunkLineFg).Render("..."),
  564. ),
  565. )
  566. }
  567. // -------------------------------------------------------------------------
  568. // Public API Methods
  569. // -------------------------------------------------------------------------
  570. // RenderSideBySideHunk formats a hunk for side-by-side display.
  571. func RenderSideBySideHunk(fileName string, h Hunk, opts ...SideBySideOption) string {
  572. // Apply options to create the configuration
  573. config := NewSideBySideConfig(opts...)
  574. // Make a copy of the hunk so we don't modify the original
  575. hunkCopy := Hunk{Lines: make([]DiffLine, len(h.Lines))}
  576. copy(hunkCopy.Lines, h.Lines)
  577. // Highlight changes within lines
  578. HighlightIntralineChanges(&hunkCopy, config.Style)
  579. // Pair lines for side-by-side display
  580. pairs := pairLines(hunkCopy.Lines)
  581. // Calculate column width
  582. colWidth := config.TotalWidth / 2
  583. var sb strings.Builder
  584. for _, p := range pairs {
  585. leftStr := renderLeftColumn(fileName, p.left, colWidth, config.Style)
  586. rightStr := renderRightColumn(fileName, p.right, colWidth, config.Style)
  587. sb.WriteString(leftStr + rightStr + "\n")
  588. }
  589. return sb.String()
  590. }
  591. // FormatDiff creates a side-by-side formatted view of a diff.
  592. func FormatDiff(diffText string, opts ...SideBySideOption) (string, error) {
  593. diffResult, err := ParseUnifiedDiff(diffText)
  594. if err != nil {
  595. return "", err
  596. }
  597. var sb strings.Builder
  598. config := NewSideBySideConfig(opts...)
  599. for i, h := range diffResult.Hunks {
  600. if i > 0 {
  601. sb.WriteString(lipgloss.NewStyle().Background(config.Style.HunkLineBg).Foreground(config.Style.HunkLineFg).Width(config.TotalWidth).Render(h.Header) + "\n")
  602. }
  603. sb.WriteString(RenderSideBySideHunk(diffResult.OldFile, h, opts...))
  604. }
  605. return sb.String(), nil
  606. }
  607. // GenerateDiff creates a unified diff from two file contents.
  608. func GenerateDiff(beforeContent, afterContent, fileName string) (string, int, int) {
  609. tempDir, err := os.MkdirTemp("", "git-diff-temp")
  610. if err != nil {
  611. return "", 0, 0
  612. }
  613. defer os.RemoveAll(tempDir)
  614. repo, err := git.PlainInit(tempDir, false)
  615. if err != nil {
  616. return "", 0, 0
  617. }
  618. wt, err := repo.Worktree()
  619. if err != nil {
  620. return "", 0, 0
  621. }
  622. fullPath := filepath.Join(tempDir, fileName)
  623. if err = os.MkdirAll(filepath.Dir(fullPath), 0o755); err != nil {
  624. return "", 0, 0
  625. }
  626. if err = os.WriteFile(fullPath, []byte(beforeContent), 0o644); err != nil {
  627. return "", 0, 0
  628. }
  629. _, err = wt.Add(fileName)
  630. if err != nil {
  631. return "", 0, 0
  632. }
  633. beforeCommit, err := wt.Commit("Before", &git.CommitOptions{
  634. Author: &object.Signature{
  635. Name: "OpenCode",
  636. Email: "[email protected]",
  637. When: time.Now(),
  638. },
  639. })
  640. if err != nil {
  641. return "", 0, 0
  642. }
  643. if err = os.WriteFile(fullPath, []byte(afterContent), 0o644); err != nil {
  644. }
  645. _, err = wt.Add(fileName)
  646. if err != nil {
  647. return "", 0, 0
  648. }
  649. afterCommit, err := wt.Commit("After", &git.CommitOptions{
  650. Author: &object.Signature{
  651. Name: "OpenCode",
  652. Email: "[email protected]",
  653. When: time.Now(),
  654. },
  655. })
  656. if err != nil {
  657. return "", 0, 0
  658. }
  659. beforeCommitObj, err := repo.CommitObject(beforeCommit)
  660. if err != nil {
  661. return "", 0, 0
  662. }
  663. afterCommitObj, err := repo.CommitObject(afterCommit)
  664. if err != nil {
  665. return "", 0, 0
  666. }
  667. patch, err := beforeCommitObj.Patch(afterCommitObj)
  668. if err != nil {
  669. return "", 0, 0
  670. }
  671. additions := 0
  672. removals := 0
  673. for _, fileStat := range patch.Stats() {
  674. additions += fileStat.Addition
  675. removals += fileStat.Deletion
  676. }
  677. return patch.String(), additions, removals
  678. }