diff.go 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972
  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. // -------------------------------------------------------------------------
  23. // Core Types
  24. // -------------------------------------------------------------------------
  25. // LineType represents the kind of line in a diff.
  26. type LineType int
  27. const (
  28. LineContext LineType = iota // Line exists in both files
  29. LineAdded // Line added in the new file
  30. LineRemoved // Line removed from the old file
  31. )
  32. // Segment represents a portion of a line for intra-line highlighting
  33. type Segment struct {
  34. Start int
  35. End int
  36. Type LineType
  37. Text string
  38. }
  39. // DiffLine represents a single line in a diff
  40. type DiffLine struct {
  41. OldLineNo int // Line number in old file (0 for added lines)
  42. NewLineNo int // Line number in new file (0 for removed lines)
  43. Kind LineType // Type of line (added, removed, context)
  44. Content string // Content of the line
  45. Segments []Segment // Segments for intraline highlighting
  46. }
  47. // Hunk represents a section of changes in a diff
  48. type Hunk struct {
  49. Header string
  50. Lines []DiffLine
  51. }
  52. // DiffResult contains the parsed result of a diff
  53. type DiffResult struct {
  54. OldFile string
  55. NewFile string
  56. Hunks []Hunk
  57. }
  58. // linePair represents a pair of lines for side-by-side display
  59. type linePair struct {
  60. left *DiffLine
  61. right *DiffLine
  62. }
  63. // -------------------------------------------------------------------------
  64. // Style Configuration
  65. // -------------------------------------------------------------------------
  66. // StyleConfig defines styling for diff rendering
  67. type StyleConfig struct {
  68. // Background colors
  69. RemovedLineBg lipgloss.Color
  70. AddedLineBg lipgloss.Color
  71. ContextLineBg lipgloss.Color
  72. HunkLineBg lipgloss.Color
  73. RemovedLineNumberBg lipgloss.Color
  74. AddedLineNamerBg lipgloss.Color
  75. // Foreground colors
  76. HunkLineFg lipgloss.Color
  77. RemovedFg lipgloss.Color
  78. AddedFg lipgloss.Color
  79. LineNumberFg lipgloss.Color
  80. RemovedHighlightFg lipgloss.Color
  81. AddedHighlightFg lipgloss.Color
  82. // Highlight settings
  83. HighlightStyle string
  84. RemovedHighlightBg lipgloss.Color
  85. AddedHighlightBg lipgloss.Color
  86. }
  87. // StyleOption is a function that modifies a StyleConfig
  88. type StyleOption func(*StyleConfig)
  89. // NewStyleConfig creates a StyleConfig with default values
  90. func NewStyleConfig(opts ...StyleOption) StyleConfig {
  91. // Default color scheme
  92. config := StyleConfig{
  93. RemovedLineBg: lipgloss.Color("#3A3030"),
  94. AddedLineBg: lipgloss.Color("#303A30"),
  95. ContextLineBg: lipgloss.Color("#212121"),
  96. HunkLineBg: lipgloss.Color("#23252D"),
  97. HunkLineFg: lipgloss.Color("#8CA3B4"),
  98. RemovedFg: lipgloss.Color("#7C4444"),
  99. AddedFg: lipgloss.Color("#478247"),
  100. LineNumberFg: lipgloss.Color("#888888"),
  101. HighlightStyle: "dracula",
  102. RemovedHighlightBg: lipgloss.Color("#612726"),
  103. AddedHighlightBg: lipgloss.Color("#256125"),
  104. RemovedLineNumberBg: lipgloss.Color("#332929"),
  105. AddedLineNamerBg: lipgloss.Color("#293229"),
  106. RemovedHighlightFg: lipgloss.Color("#FADADD"),
  107. AddedHighlightFg: lipgloss.Color("#DAFADA"),
  108. }
  109. // Apply all provided options
  110. for _, opt := range opts {
  111. opt(&config)
  112. }
  113. return config
  114. }
  115. // Style option functions
  116. func WithRemovedLineBg(color lipgloss.Color) StyleOption {
  117. return func(s *StyleConfig) { s.RemovedLineBg = color }
  118. }
  119. func WithAddedLineBg(color lipgloss.Color) StyleOption {
  120. return func(s *StyleConfig) { s.AddedLineBg = color }
  121. }
  122. func WithContextLineBg(color lipgloss.Color) StyleOption {
  123. return func(s *StyleConfig) { s.ContextLineBg = color }
  124. }
  125. func WithRemovedFg(color lipgloss.Color) StyleOption {
  126. return func(s *StyleConfig) { s.RemovedFg = color }
  127. }
  128. func WithAddedFg(color lipgloss.Color) StyleOption {
  129. return func(s *StyleConfig) { s.AddedFg = color }
  130. }
  131. func WithLineNumberFg(color lipgloss.Color) StyleOption {
  132. return func(s *StyleConfig) { s.LineNumberFg = color }
  133. }
  134. func WithHighlightStyle(style string) StyleOption {
  135. return func(s *StyleConfig) { s.HighlightStyle = style }
  136. }
  137. func WithRemovedHighlightColors(bg, fg lipgloss.Color) StyleOption {
  138. return func(s *StyleConfig) {
  139. s.RemovedHighlightBg = bg
  140. s.RemovedHighlightFg = fg
  141. }
  142. }
  143. func WithAddedHighlightColors(bg, fg lipgloss.Color) StyleOption {
  144. return func(s *StyleConfig) {
  145. s.AddedHighlightBg = bg
  146. s.AddedHighlightFg = fg
  147. }
  148. }
  149. func WithRemovedLineNumberBg(color lipgloss.Color) StyleOption {
  150. return func(s *StyleConfig) { s.RemovedLineNumberBg = color }
  151. }
  152. func WithAddedLineNumberBg(color lipgloss.Color) StyleOption {
  153. return func(s *StyleConfig) { s.AddedLineNamerBg = color }
  154. }
  155. func WithHunkLineBg(color lipgloss.Color) StyleOption {
  156. return func(s *StyleConfig) { s.HunkLineBg = color }
  157. }
  158. func WithHunkLineFg(color lipgloss.Color) StyleOption {
  159. return func(s *StyleConfig) { s.HunkLineFg = color }
  160. }
  161. // -------------------------------------------------------------------------
  162. // Parse Configuration
  163. // -------------------------------------------------------------------------
  164. // ParseConfig configures the behavior of diff parsing
  165. type ParseConfig struct {
  166. ContextSize int // Number of context lines to include
  167. }
  168. // ParseOption modifies a ParseConfig
  169. type ParseOption func(*ParseConfig)
  170. // WithContextSize sets the number of context lines to include
  171. func WithContextSize(size int) ParseOption {
  172. return func(p *ParseConfig) {
  173. if size >= 0 {
  174. p.ContextSize = size
  175. }
  176. }
  177. }
  178. // -------------------------------------------------------------------------
  179. // Side-by-Side Configuration
  180. // -------------------------------------------------------------------------
  181. // SideBySideConfig configures the rendering of side-by-side diffs
  182. type SideBySideConfig struct {
  183. TotalWidth int
  184. Style StyleConfig
  185. }
  186. // SideBySideOption modifies a SideBySideConfig
  187. type SideBySideOption func(*SideBySideConfig)
  188. // NewSideBySideConfig creates a SideBySideConfig with default values
  189. func NewSideBySideConfig(opts ...SideBySideOption) SideBySideConfig {
  190. config := SideBySideConfig{
  191. TotalWidth: 160, // Default width for side-by-side view
  192. Style: NewStyleConfig(),
  193. }
  194. for _, opt := range opts {
  195. opt(&config)
  196. }
  197. return config
  198. }
  199. // WithTotalWidth sets the total width for side-by-side view
  200. func WithTotalWidth(width int) SideBySideOption {
  201. return func(s *SideBySideConfig) {
  202. if width > 0 {
  203. s.TotalWidth = width
  204. }
  205. }
  206. }
  207. // WithStyle sets the styling configuration
  208. func WithStyle(style StyleConfig) SideBySideOption {
  209. return func(s *SideBySideConfig) {
  210. s.Style = style
  211. }
  212. }
  213. // WithStyleOptions applies the specified style options
  214. func WithStyleOptions(opts ...StyleOption) SideBySideOption {
  215. return func(s *SideBySideConfig) {
  216. s.Style = NewStyleConfig(opts...)
  217. }
  218. }
  219. // -------------------------------------------------------------------------
  220. // Diff Parsing
  221. // -------------------------------------------------------------------------
  222. // ParseUnifiedDiff parses a unified diff format string into structured data
  223. func ParseUnifiedDiff(diff string) (DiffResult, error) {
  224. var result DiffResult
  225. var currentHunk *Hunk
  226. hunkHeaderRe := regexp.MustCompile(`^@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@`)
  227. lines := strings.Split(diff, "\n")
  228. var oldLine, newLine int
  229. inFileHeader := true
  230. for _, line := range lines {
  231. // Parse file headers
  232. if inFileHeader {
  233. if strings.HasPrefix(line, "--- a/") {
  234. result.OldFile = strings.TrimPrefix(line, "--- a/")
  235. continue
  236. }
  237. if strings.HasPrefix(line, "+++ b/") {
  238. result.NewFile = strings.TrimPrefix(line, "+++ b/")
  239. inFileHeader = false
  240. continue
  241. }
  242. }
  243. // Parse hunk headers
  244. if matches := hunkHeaderRe.FindStringSubmatch(line); matches != nil {
  245. if currentHunk != nil {
  246. result.Hunks = append(result.Hunks, *currentHunk)
  247. }
  248. currentHunk = &Hunk{
  249. Header: line,
  250. Lines: []DiffLine{},
  251. }
  252. oldStart, _ := strconv.Atoi(matches[1])
  253. newStart, _ := strconv.Atoi(matches[3])
  254. oldLine = oldStart
  255. newLine = newStart
  256. continue
  257. }
  258. // Ignore "No newline at end of file" markers
  259. if strings.HasPrefix(line, "\\ No newline at end of file") {
  260. continue
  261. }
  262. if currentHunk == nil {
  263. continue
  264. }
  265. // Process the line based on its prefix
  266. if len(line) > 0 {
  267. switch line[0] {
  268. case '+':
  269. currentHunk.Lines = append(currentHunk.Lines, DiffLine{
  270. OldLineNo: 0,
  271. NewLineNo: newLine,
  272. Kind: LineAdded,
  273. Content: line[1:],
  274. })
  275. newLine++
  276. case '-':
  277. currentHunk.Lines = append(currentHunk.Lines, DiffLine{
  278. OldLineNo: oldLine,
  279. NewLineNo: 0,
  280. Kind: LineRemoved,
  281. Content: line[1:],
  282. })
  283. oldLine++
  284. default:
  285. currentHunk.Lines = append(currentHunk.Lines, DiffLine{
  286. OldLineNo: oldLine,
  287. NewLineNo: newLine,
  288. Kind: LineContext,
  289. Content: line,
  290. })
  291. oldLine++
  292. newLine++
  293. }
  294. } else {
  295. // Handle empty lines
  296. currentHunk.Lines = append(currentHunk.Lines, DiffLine{
  297. OldLineNo: oldLine,
  298. NewLineNo: newLine,
  299. Kind: LineContext,
  300. Content: "",
  301. })
  302. oldLine++
  303. newLine++
  304. }
  305. }
  306. // Add the last hunk if there is one
  307. if currentHunk != nil {
  308. result.Hunks = append(result.Hunks, *currentHunk)
  309. }
  310. return result, nil
  311. }
  312. // HighlightIntralineChanges updates lines in a hunk to show character-level differences
  313. func HighlightIntralineChanges(h *Hunk, style StyleConfig) {
  314. var updated []DiffLine
  315. dmp := diffmatchpatch.New()
  316. for i := 0; i < len(h.Lines); i++ {
  317. // Look for removed line followed by added line
  318. if i+1 < len(h.Lines) &&
  319. h.Lines[i].Kind == LineRemoved &&
  320. h.Lines[i+1].Kind == LineAdded {
  321. oldLine := h.Lines[i]
  322. newLine := h.Lines[i+1]
  323. // Find character-level differences
  324. patches := dmp.DiffMain(oldLine.Content, newLine.Content, false)
  325. patches = dmp.DiffCleanupSemantic(patches)
  326. patches = dmp.DiffCleanupMerge(patches)
  327. patches = dmp.DiffCleanupEfficiency(patches)
  328. segments := make([]Segment, 0)
  329. removeStart := 0
  330. addStart := 0
  331. for _, patch := range patches {
  332. switch patch.Type {
  333. case diffmatchpatch.DiffDelete:
  334. segments = append(segments, Segment{
  335. Start: removeStart,
  336. End: removeStart + len(patch.Text),
  337. Type: LineRemoved,
  338. Text: patch.Text,
  339. })
  340. removeStart += len(patch.Text)
  341. case diffmatchpatch.DiffInsert:
  342. segments = append(segments, Segment{
  343. Start: addStart,
  344. End: addStart + len(patch.Text),
  345. Type: LineAdded,
  346. Text: patch.Text,
  347. })
  348. addStart += len(patch.Text)
  349. default:
  350. // Context text, no highlighting needed
  351. removeStart += len(patch.Text)
  352. addStart += len(patch.Text)
  353. }
  354. }
  355. oldLine.Segments = segments
  356. newLine.Segments = segments
  357. updated = append(updated, oldLine, newLine)
  358. i++ // Skip the next line as we've already processed it
  359. } else {
  360. updated = append(updated, h.Lines[i])
  361. }
  362. }
  363. h.Lines = updated
  364. }
  365. // pairLines converts a flat list of diff lines to pairs for side-by-side display
  366. func pairLines(lines []DiffLine) []linePair {
  367. var pairs []linePair
  368. i := 0
  369. for i < len(lines) {
  370. switch lines[i].Kind {
  371. case LineRemoved:
  372. // Check if the next line is an addition, if so pair them
  373. if i+1 < len(lines) && lines[i+1].Kind == LineAdded {
  374. pairs = append(pairs, linePair{left: &lines[i], right: &lines[i+1]})
  375. i += 2
  376. } else {
  377. pairs = append(pairs, linePair{left: &lines[i], right: nil})
  378. i++
  379. }
  380. case LineAdded:
  381. pairs = append(pairs, linePair{left: nil, right: &lines[i]})
  382. i++
  383. case LineContext:
  384. pairs = append(pairs, linePair{left: &lines[i], right: &lines[i]})
  385. i++
  386. }
  387. }
  388. return pairs
  389. }
  390. // -------------------------------------------------------------------------
  391. // Syntax Highlighting
  392. // -------------------------------------------------------------------------
  393. // SyntaxHighlight applies syntax highlighting to text based on file extension
  394. func SyntaxHighlight(w io.Writer, source, fileName, formatter string, bg lipgloss.TerminalColor) error {
  395. // Determine the language lexer to use
  396. l := lexers.Match(fileName)
  397. if l == nil {
  398. l = lexers.Analyse(source)
  399. }
  400. if l == nil {
  401. l = lexers.Fallback
  402. }
  403. l = chroma.Coalesce(l)
  404. // Get the formatter
  405. f := formatters.Get(formatter)
  406. if f == nil {
  407. f = formatters.Fallback
  408. }
  409. theme := `
  410. <style name="vscode-dark-plus">
  411. <!-- Base colors -->
  412. <entry type="Background" style="bg:#1E1E1E"/>
  413. <entry type="Text" style="#D4D4D4"/>
  414. <entry type="Other" style="#D4D4D4"/>
  415. <entry type="Error" style="#F44747"/>
  416. <!-- Keywords - using the Control flow / Special keywords color -->
  417. <entry type="Keyword" style="#C586C0"/>
  418. <entry type="KeywordConstant" style="#4FC1FF"/>
  419. <entry type="KeywordDeclaration" style="#C586C0"/>
  420. <entry type="KeywordNamespace" style="#C586C0"/>
  421. <entry type="KeywordPseudo" style="#C586C0"/>
  422. <entry type="KeywordReserved" style="#C586C0"/>
  423. <entry type="KeywordType" style="#4EC9B0"/>
  424. <!-- Names -->
  425. <entry type="Name" style="#D4D4D4"/>
  426. <entry type="NameAttribute" style="#9CDCFE"/>
  427. <entry type="NameBuiltin" style="#4EC9B0"/>
  428. <entry type="NameBuiltinPseudo" style="#9CDCFE"/>
  429. <entry type="NameClass" style="#4EC9B0"/>
  430. <entry type="NameConstant" style="#4FC1FF"/>
  431. <entry type="NameDecorator" style="#DCDCAA"/>
  432. <entry type="NameEntity" style="#9CDCFE"/>
  433. <entry type="NameException" style="#4EC9B0"/>
  434. <entry type="NameFunction" style="#DCDCAA"/>
  435. <entry type="NameLabel" style="#C8C8C8"/>
  436. <entry type="NameNamespace" style="#4EC9B0"/>
  437. <entry type="NameOther" style="#9CDCFE"/>
  438. <entry type="NameTag" style="#569CD6"/>
  439. <entry type="NameVariable" style="#9CDCFE"/>
  440. <entry type="NameVariableClass" style="#9CDCFE"/>
  441. <entry type="NameVariableGlobal" style="#9CDCFE"/>
  442. <entry type="NameVariableInstance" style="#9CDCFE"/>
  443. <!-- Literals -->
  444. <entry type="Literal" style="#CE9178"/>
  445. <entry type="LiteralDate" style="#CE9178"/>
  446. <entry type="LiteralString" style="#CE9178"/>
  447. <entry type="LiteralStringBacktick" style="#CE9178"/>
  448. <entry type="LiteralStringChar" style="#CE9178"/>
  449. <entry type="LiteralStringDoc" style="#CE9178"/>
  450. <entry type="LiteralStringDouble" style="#CE9178"/>
  451. <entry type="LiteralStringEscape" style="#d7ba7d"/>
  452. <entry type="LiteralStringHeredoc" style="#CE9178"/>
  453. <entry type="LiteralStringInterpol" style="#CE9178"/>
  454. <entry type="LiteralStringOther" style="#CE9178"/>
  455. <entry type="LiteralStringRegex" style="#d16969"/>
  456. <entry type="LiteralStringSingle" style="#CE9178"/>
  457. <entry type="LiteralStringSymbol" style="#CE9178"/>
  458. <!-- Numbers - using the numberLiteral color -->
  459. <entry type="LiteralNumber" style="#b5cea8"/>
  460. <entry type="LiteralNumberBin" style="#b5cea8"/>
  461. <entry type="LiteralNumberFloat" style="#b5cea8"/>
  462. <entry type="LiteralNumberHex" style="#b5cea8"/>
  463. <entry type="LiteralNumberInteger" style="#b5cea8"/>
  464. <entry type="LiteralNumberIntegerLong" style="#b5cea8"/>
  465. <entry type="LiteralNumberOct" style="#b5cea8"/>
  466. <!-- Operators -->
  467. <entry type="Operator" style="#D4D4D4"/>
  468. <entry type="OperatorWord" style="#C586C0"/>
  469. <entry type="Punctuation" style="#D4D4D4"/>
  470. <!-- Comments - standard VSCode Dark+ comment color -->
  471. <entry type="Comment" style="#6A9955"/>
  472. <entry type="CommentHashbang" style="#6A9955"/>
  473. <entry type="CommentMultiline" style="#6A9955"/>
  474. <entry type="CommentSingle" style="#6A9955"/>
  475. <entry type="CommentSpecial" style="#6A9955"/>
  476. <entry type="CommentPreproc" style="#C586C0"/>
  477. <!-- Generic styles -->
  478. <entry type="Generic" style="#D4D4D4"/>
  479. <entry type="GenericDeleted" style="#F44747"/>
  480. <entry type="GenericEmph" style="italic #D4D4D4"/>
  481. <entry type="GenericError" style="#F44747"/>
  482. <entry type="GenericHeading" style="bold #D4D4D4"/>
  483. <entry type="GenericInserted" style="#b5cea8"/>
  484. <entry type="GenericOutput" style="#808080"/>
  485. <entry type="GenericPrompt" style="#D4D4D4"/>
  486. <entry type="GenericStrong" style="bold #D4D4D4"/>
  487. <entry type="GenericSubheading" style="bold #D4D4D4"/>
  488. <entry type="GenericTraceback" style="#F44747"/>
  489. <entry type="GenericUnderline" style="underline"/>
  490. <entry type="TextWhitespace" style="#D4D4D4"/>
  491. </style>
  492. `
  493. r := strings.NewReader(theme)
  494. style := chroma.MustNewXMLStyle(r)
  495. // Modify the style to use the provided background
  496. s, err := style.Builder().Transform(
  497. func(t chroma.StyleEntry) chroma.StyleEntry {
  498. r, g, b, _ := bg.RGBA()
  499. t.Background = chroma.NewColour(uint8(r>>8), uint8(g>>8), uint8(b>>8))
  500. return t
  501. },
  502. ).Build()
  503. if err != nil {
  504. s = styles.Fallback
  505. }
  506. // Tokenize and format
  507. it, err := l.Tokenise(nil, source)
  508. if err != nil {
  509. return err
  510. }
  511. return f.Format(w, s, it)
  512. }
  513. // highlightLine applies syntax highlighting to a single line
  514. func highlightLine(fileName string, line string, bg lipgloss.TerminalColor) string {
  515. var buf bytes.Buffer
  516. err := SyntaxHighlight(&buf, line, fileName, "terminal16m", bg)
  517. if err != nil {
  518. return line
  519. }
  520. return buf.String()
  521. }
  522. // createStyles generates the lipgloss styles needed for rendering diffs
  523. func createStyles(config StyleConfig) (removedLineStyle, addedLineStyle, contextLineStyle, lineNumberStyle lipgloss.Style) {
  524. removedLineStyle = lipgloss.NewStyle().Background(config.RemovedLineBg)
  525. addedLineStyle = lipgloss.NewStyle().Background(config.AddedLineBg)
  526. contextLineStyle = lipgloss.NewStyle().Background(config.ContextLineBg)
  527. lineNumberStyle = lipgloss.NewStyle().Foreground(config.LineNumberFg)
  528. return
  529. }
  530. // -------------------------------------------------------------------------
  531. // Rendering Functions
  532. // -------------------------------------------------------------------------
  533. // applyHighlighting applies intra-line highlighting to a piece of text
  534. func applyHighlighting(content string, segments []Segment, segmentType LineType, highlightBg lipgloss.Color,
  535. ) string {
  536. // Find all ANSI sequences in the content
  537. ansiRegex := regexp.MustCompile(`\x1b(?:[@-Z\\-_]|\[[0-9?]*(?:;[0-9?]*)*[@-~])`)
  538. ansiMatches := ansiRegex.FindAllStringIndex(content, -1)
  539. // Build a mapping of visible character positions to their actual indices
  540. visibleIdx := 0
  541. ansiSequences := make(map[int]string)
  542. lastAnsiSeq := "\x1b[0m" // Default reset sequence
  543. for i := 0; i < len(content); {
  544. isAnsi := false
  545. for _, match := range ansiMatches {
  546. if match[0] == i {
  547. ansiSequences[visibleIdx] = content[match[0]:match[1]]
  548. lastAnsiSeq = content[match[0]:match[1]]
  549. i = match[1]
  550. isAnsi = true
  551. break
  552. }
  553. }
  554. if isAnsi {
  555. continue
  556. }
  557. // For non-ANSI positions, store the last ANSI sequence
  558. if _, exists := ansiSequences[visibleIdx]; !exists {
  559. ansiSequences[visibleIdx] = lastAnsiSeq
  560. }
  561. visibleIdx++
  562. i++
  563. }
  564. // Apply highlighting
  565. var sb strings.Builder
  566. inSelection := false
  567. currentPos := 0
  568. for i := 0; i < len(content); {
  569. // Check if we're at an ANSI sequence
  570. isAnsi := false
  571. for _, match := range ansiMatches {
  572. if match[0] == i {
  573. sb.WriteString(content[match[0]:match[1]]) // Preserve ANSI sequence
  574. i = match[1]
  575. isAnsi = true
  576. break
  577. }
  578. }
  579. if isAnsi {
  580. continue
  581. }
  582. // Check for segment boundaries
  583. for _, seg := range segments {
  584. if seg.Type == segmentType {
  585. if currentPos == seg.Start {
  586. inSelection = true
  587. }
  588. if currentPos == seg.End {
  589. inSelection = false
  590. }
  591. }
  592. }
  593. // Get current character
  594. char := string(content[i])
  595. if inSelection {
  596. // Get the current styling
  597. currentStyle := ansiSequences[currentPos]
  598. // Apply background highlight
  599. sb.WriteString("\x1b[48;2;")
  600. r, g, b, _ := highlightBg.RGBA()
  601. sb.WriteString(fmt.Sprintf("%d;%d;%dm", r>>8, g>>8, b>>8))
  602. sb.WriteString(char)
  603. sb.WriteString("\x1b[49m") // Reset only background
  604. // Reapply the original ANSI sequence
  605. sb.WriteString(currentStyle)
  606. } else {
  607. // Not in selection, just copy the character
  608. sb.WriteString(char)
  609. }
  610. currentPos++
  611. i++
  612. }
  613. return sb.String()
  614. }
  615. // renderLeftColumn formats the left side of a side-by-side diff
  616. func renderLeftColumn(fileName string, dl *DiffLine, colWidth int, styles StyleConfig) string {
  617. if dl == nil {
  618. contextLineStyle := lipgloss.NewStyle().Background(styles.ContextLineBg)
  619. return contextLineStyle.Width(colWidth).Render("")
  620. }
  621. removedLineStyle, _, contextLineStyle, lineNumberStyle := createStyles(styles)
  622. // Determine line style based on line type
  623. var marker string
  624. var bgStyle lipgloss.Style
  625. switch dl.Kind {
  626. case LineRemoved:
  627. marker = removedLineStyle.Foreground(styles.RemovedFg).Render("-")
  628. bgStyle = removedLineStyle
  629. lineNumberStyle = lineNumberStyle.Foreground(styles.RemovedFg).Background(styles.RemovedLineNumberBg)
  630. case LineAdded:
  631. marker = "?"
  632. bgStyle = contextLineStyle
  633. case LineContext:
  634. marker = contextLineStyle.Render(" ")
  635. bgStyle = contextLineStyle
  636. }
  637. // Format line number
  638. lineNum := ""
  639. if dl.OldLineNo > 0 {
  640. lineNum = fmt.Sprintf("%6d", dl.OldLineNo)
  641. }
  642. // Create the line prefix
  643. prefix := lineNumberStyle.Render(lineNum + " " + marker)
  644. // Apply syntax highlighting
  645. content := highlightLine(fileName, dl.Content, bgStyle.GetBackground())
  646. // Apply intra-line highlighting for removed lines
  647. if dl.Kind == LineRemoved && len(dl.Segments) > 0 {
  648. content = applyHighlighting(content, dl.Segments, LineRemoved, styles.RemovedHighlightBg)
  649. }
  650. // Add a padding space for removed lines
  651. if dl.Kind == LineRemoved {
  652. content = bgStyle.Render(" ") + content
  653. }
  654. // Create the final line and truncate if needed
  655. lineText := prefix + content
  656. return bgStyle.MaxHeight(1).Width(colWidth).Render(
  657. ansi.Truncate(
  658. lineText,
  659. colWidth,
  660. lipgloss.NewStyle().Background(styles.HunkLineBg).Foreground(styles.HunkLineFg).Render("..."),
  661. ),
  662. )
  663. }
  664. // renderRightColumn formats the right side of a side-by-side diff
  665. func renderRightColumn(fileName string, dl *DiffLine, colWidth int, styles StyleConfig) string {
  666. if dl == nil {
  667. contextLineStyle := lipgloss.NewStyle().Background(styles.ContextLineBg)
  668. return contextLineStyle.Width(colWidth).Render("")
  669. }
  670. _, addedLineStyle, contextLineStyle, lineNumberStyle := createStyles(styles)
  671. // Determine line style based on line type
  672. var marker string
  673. var bgStyle lipgloss.Style
  674. switch dl.Kind {
  675. case LineAdded:
  676. marker = addedLineStyle.Foreground(styles.AddedFg).Render("+")
  677. bgStyle = addedLineStyle
  678. lineNumberStyle = lineNumberStyle.Foreground(styles.AddedFg).Background(styles.AddedLineNamerBg)
  679. case LineRemoved:
  680. marker = "?"
  681. bgStyle = contextLineStyle
  682. case LineContext:
  683. marker = contextLineStyle.Render(" ")
  684. bgStyle = contextLineStyle
  685. }
  686. // Format line number
  687. lineNum := ""
  688. if dl.NewLineNo > 0 {
  689. lineNum = fmt.Sprintf("%6d", dl.NewLineNo)
  690. }
  691. // Create the line prefix
  692. prefix := lineNumberStyle.Render(lineNum + " " + marker)
  693. // Apply syntax highlighting
  694. content := highlightLine(fileName, dl.Content, bgStyle.GetBackground())
  695. // Apply intra-line highlighting for added lines
  696. if dl.Kind == LineAdded && len(dl.Segments) > 0 {
  697. content = applyHighlighting(content, dl.Segments, LineAdded, styles.AddedHighlightBg)
  698. }
  699. // Add a padding space for added lines
  700. if dl.Kind == LineAdded {
  701. content = bgStyle.Render(" ") + content
  702. }
  703. // Create the final line and truncate if needed
  704. lineText := prefix + content
  705. return bgStyle.MaxHeight(1).Width(colWidth).Render(
  706. ansi.Truncate(
  707. lineText,
  708. colWidth,
  709. lipgloss.NewStyle().Background(styles.HunkLineBg).Foreground(styles.HunkLineFg).Render("..."),
  710. ),
  711. )
  712. }
  713. // -------------------------------------------------------------------------
  714. // Public API
  715. // -------------------------------------------------------------------------
  716. // RenderSideBySideHunk formats a hunk for side-by-side display
  717. func RenderSideBySideHunk(fileName string, h Hunk, opts ...SideBySideOption) string {
  718. // Apply options to create the configuration
  719. config := NewSideBySideConfig(opts...)
  720. // Make a copy of the hunk so we don't modify the original
  721. hunkCopy := Hunk{Lines: make([]DiffLine, len(h.Lines))}
  722. copy(hunkCopy.Lines, h.Lines)
  723. // Highlight changes within lines
  724. HighlightIntralineChanges(&hunkCopy, config.Style)
  725. // Pair lines for side-by-side display
  726. pairs := pairLines(hunkCopy.Lines)
  727. // Calculate column width
  728. colWidth := config.TotalWidth / 2
  729. var sb strings.Builder
  730. for _, p := range pairs {
  731. leftStr := renderLeftColumn(fileName, p.left, colWidth, config.Style)
  732. rightStr := renderRightColumn(fileName, p.right, colWidth, config.Style)
  733. sb.WriteString(leftStr + rightStr + "\n")
  734. }
  735. return sb.String()
  736. }
  737. // FormatDiff creates a side-by-side formatted view of a diff
  738. func FormatDiff(diffText string, opts ...SideBySideOption) (string, error) {
  739. diffResult, err := ParseUnifiedDiff(diffText)
  740. if err != nil {
  741. return "", err
  742. }
  743. var sb strings.Builder
  744. config := NewSideBySideConfig(opts...)
  745. for i, h := range diffResult.Hunks {
  746. if i > 0 {
  747. // Render hunk header
  748. sb.WriteString(
  749. lipgloss.NewStyle().
  750. Background(config.Style.HunkLineBg).
  751. Foreground(config.Style.HunkLineFg).
  752. Width(config.TotalWidth).
  753. Render(h.Header) + "\n",
  754. )
  755. }
  756. sb.WriteString(RenderSideBySideHunk(diffResult.OldFile, h, opts...))
  757. }
  758. return sb.String(), nil
  759. }
  760. // GenerateDiff creates a unified diff from two file contents
  761. func GenerateDiff(beforeContent, afterContent, fileName string) (string, int, int) {
  762. // Create temporary directory for git operations
  763. tempDir, err := os.MkdirTemp("", "git-diff-temp")
  764. if err != nil {
  765. return "", 0, 0
  766. }
  767. defer os.RemoveAll(tempDir)
  768. // Initialize git repo
  769. repo, err := git.PlainInit(tempDir, false)
  770. if err != nil {
  771. return "", 0, 0
  772. }
  773. wt, err := repo.Worktree()
  774. if err != nil {
  775. return "", 0, 0
  776. }
  777. // Write the "before" content and commit it
  778. fullPath := filepath.Join(tempDir, fileName)
  779. if err = os.MkdirAll(filepath.Dir(fullPath), 0o755); err != nil {
  780. return "", 0, 0
  781. }
  782. if err = os.WriteFile(fullPath, []byte(beforeContent), 0o644); err != nil {
  783. return "", 0, 0
  784. }
  785. _, err = wt.Add(fileName)
  786. if err != nil {
  787. return "", 0, 0
  788. }
  789. beforeCommit, err := wt.Commit("Before", &git.CommitOptions{
  790. Author: &object.Signature{
  791. Name: "OpenCode",
  792. Email: "[email protected]",
  793. When: time.Now(),
  794. },
  795. })
  796. if err != nil {
  797. return "", 0, 0
  798. }
  799. // Write the "after" content and commit it
  800. if err = os.WriteFile(fullPath, []byte(afterContent), 0o644); err != nil {
  801. return "", 0, 0
  802. }
  803. _, err = wt.Add(fileName)
  804. if err != nil {
  805. return "", 0, 0
  806. }
  807. afterCommit, err := wt.Commit("After", &git.CommitOptions{
  808. Author: &object.Signature{
  809. Name: "OpenCode",
  810. Email: "[email protected]",
  811. When: time.Now(),
  812. },
  813. })
  814. if err != nil {
  815. return "", 0, 0
  816. }
  817. // Get the diff between the two commits
  818. beforeCommitObj, err := repo.CommitObject(beforeCommit)
  819. if err != nil {
  820. return "", 0, 0
  821. }
  822. afterCommitObj, err := repo.CommitObject(afterCommit)
  823. if err != nil {
  824. return "", 0, 0
  825. }
  826. patch, err := beforeCommitObj.Patch(afterCommitObj)
  827. if err != nil {
  828. return "", 0, 0
  829. }
  830. // Count additions and removals
  831. additions := 0
  832. removals := 0
  833. for _, fileStat := range patch.Stats() {
  834. additions += fileStat.Addition
  835. removals += fileStat.Deletion
  836. }
  837. return patch.String(), additions, removals
  838. }