diff.go 29 KB

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