diff.go 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817
  1. package diff
  2. import (
  3. "bytes"
  4. "fmt"
  5. "image/color"
  6. "io"
  7. "regexp"
  8. "strconv"
  9. "strings"
  10. "github.com/alecthomas/chroma/v2"
  11. "github.com/alecthomas/chroma/v2/formatters"
  12. "github.com/alecthomas/chroma/v2/lexers"
  13. "github.com/alecthomas/chroma/v2/styles"
  14. "github.com/charmbracelet/lipgloss/v2"
  15. "github.com/charmbracelet/lipgloss/v2/compat"
  16. "github.com/charmbracelet/x/ansi"
  17. "github.com/sergi/go-diff/diffmatchpatch"
  18. stylesi "github.com/sst/opencode/internal/styles"
  19. "github.com/sst/opencode/internal/theme"
  20. )
  21. // -------------------------------------------------------------------------
  22. // Core Types
  23. // -------------------------------------------------------------------------
  24. // LineType represents the kind of line in a diff.
  25. type LineType int
  26. const (
  27. LineContext LineType = iota // Line exists in both files
  28. LineAdded // Line added in the new file
  29. LineRemoved // Line removed from the old file
  30. )
  31. // Segment represents a portion of a line for intra-line highlighting
  32. type Segment struct {
  33. Start int
  34. End int
  35. Type LineType
  36. Text string
  37. }
  38. // DiffLine represents a single line in a diff
  39. type DiffLine struct {
  40. OldLineNo int // Line number in old file (0 for added lines)
  41. NewLineNo int // Line number in new file (0 for removed lines)
  42. Kind LineType // Type of line (added, removed, context)
  43. Content string // Content of the line
  44. Segments []Segment // Segments for intraline highlighting
  45. }
  46. // Hunk represents a section of changes in a diff
  47. type Hunk struct {
  48. Header string
  49. Lines []DiffLine
  50. }
  51. // DiffResult contains the parsed result of a diff
  52. type DiffResult struct {
  53. OldFile string
  54. NewFile string
  55. Hunks []Hunk
  56. }
  57. // linePair represents a pair of lines for side-by-side display
  58. type linePair struct {
  59. left *DiffLine
  60. right *DiffLine
  61. }
  62. // -------------------------------------------------------------------------
  63. // Side-by-Side Configuration
  64. // -------------------------------------------------------------------------
  65. // SideBySideConfig configures the rendering of side-by-side diffs
  66. type SideBySideConfig struct {
  67. TotalWidth int
  68. }
  69. // SideBySideOption modifies a SideBySideConfig
  70. type SideBySideOption func(*SideBySideConfig)
  71. // NewSideBySideConfig creates a SideBySideConfig with default values
  72. func NewSideBySideConfig(opts ...SideBySideOption) SideBySideConfig {
  73. config := SideBySideConfig{
  74. TotalWidth: 160, // Default width for side-by-side view
  75. }
  76. for _, opt := range opts {
  77. opt(&config)
  78. }
  79. return config
  80. }
  81. // WithTotalWidth sets the total width for side-by-side view
  82. func WithTotalWidth(width int) SideBySideOption {
  83. return func(s *SideBySideConfig) {
  84. if width > 0 {
  85. s.TotalWidth = width
  86. }
  87. }
  88. }
  89. // -------------------------------------------------------------------------
  90. // Diff Parsing
  91. // -------------------------------------------------------------------------
  92. // ParseUnifiedDiff parses a unified diff format string into structured data
  93. func ParseUnifiedDiff(diff string) (DiffResult, error) {
  94. var result DiffResult
  95. var currentHunk *Hunk
  96. hunkHeaderRe := regexp.MustCompile(`^@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@`)
  97. lines := strings.Split(diff, "\n")
  98. var oldLine, newLine int
  99. inFileHeader := true
  100. for _, line := range lines {
  101. // Parse file headers
  102. if inFileHeader {
  103. if strings.HasPrefix(line, "--- a/") {
  104. result.OldFile = strings.TrimPrefix(line, "--- a/")
  105. continue
  106. }
  107. if strings.HasPrefix(line, "+++ b/") {
  108. result.NewFile = strings.TrimPrefix(line, "+++ b/")
  109. inFileHeader = false
  110. continue
  111. }
  112. }
  113. // Parse hunk headers
  114. if matches := hunkHeaderRe.FindStringSubmatch(line); matches != nil {
  115. if currentHunk != nil {
  116. result.Hunks = append(result.Hunks, *currentHunk)
  117. }
  118. currentHunk = &Hunk{
  119. Header: line,
  120. Lines: []DiffLine{},
  121. }
  122. oldStart, _ := strconv.Atoi(matches[1])
  123. newStart, _ := strconv.Atoi(matches[3])
  124. oldLine = oldStart
  125. newLine = newStart
  126. continue
  127. }
  128. // Ignore "No newline at end of file" markers
  129. if strings.HasPrefix(line, "\\ No newline at end of file") {
  130. continue
  131. }
  132. if currentHunk == nil {
  133. continue
  134. }
  135. // Process the line based on its prefix
  136. if len(line) > 0 {
  137. switch line[0] {
  138. case '+':
  139. currentHunk.Lines = append(currentHunk.Lines, DiffLine{
  140. OldLineNo: 0,
  141. NewLineNo: newLine,
  142. Kind: LineAdded,
  143. Content: line[1:],
  144. })
  145. newLine++
  146. case '-':
  147. currentHunk.Lines = append(currentHunk.Lines, DiffLine{
  148. OldLineNo: oldLine,
  149. NewLineNo: 0,
  150. Kind: LineRemoved,
  151. Content: line[1:],
  152. })
  153. oldLine++
  154. default:
  155. currentHunk.Lines = append(currentHunk.Lines, DiffLine{
  156. OldLineNo: oldLine,
  157. NewLineNo: newLine,
  158. Kind: LineContext,
  159. Content: line,
  160. })
  161. oldLine++
  162. newLine++
  163. }
  164. } else {
  165. // Handle empty lines
  166. currentHunk.Lines = append(currentHunk.Lines, DiffLine{
  167. OldLineNo: oldLine,
  168. NewLineNo: newLine,
  169. Kind: LineContext,
  170. Content: "",
  171. })
  172. oldLine++
  173. newLine++
  174. }
  175. }
  176. // Add the last hunk if there is one
  177. if currentHunk != nil {
  178. result.Hunks = append(result.Hunks, *currentHunk)
  179. }
  180. return result, nil
  181. }
  182. // HighlightIntralineChanges updates lines in a hunk to show character-level differences
  183. func HighlightIntralineChanges(h *Hunk) {
  184. var updated []DiffLine
  185. dmp := diffmatchpatch.New()
  186. for i := 0; i < len(h.Lines); i++ {
  187. // Look for removed line followed by added line
  188. if i+1 < len(h.Lines) &&
  189. h.Lines[i].Kind == LineRemoved &&
  190. h.Lines[i+1].Kind == LineAdded {
  191. oldLine := h.Lines[i]
  192. newLine := h.Lines[i+1]
  193. // Find character-level differences
  194. patches := dmp.DiffMain(oldLine.Content, newLine.Content, false)
  195. patches = dmp.DiffCleanupSemantic(patches)
  196. patches = dmp.DiffCleanupMerge(patches)
  197. patches = dmp.DiffCleanupEfficiency(patches)
  198. segments := make([]Segment, 0)
  199. removeStart := 0
  200. addStart := 0
  201. for _, patch := range patches {
  202. switch patch.Type {
  203. case diffmatchpatch.DiffDelete:
  204. segments = append(segments, Segment{
  205. Start: removeStart,
  206. End: removeStart + len(patch.Text),
  207. Type: LineRemoved,
  208. Text: patch.Text,
  209. })
  210. removeStart += len(patch.Text)
  211. case diffmatchpatch.DiffInsert:
  212. segments = append(segments, Segment{
  213. Start: addStart,
  214. End: addStart + len(patch.Text),
  215. Type: LineAdded,
  216. Text: patch.Text,
  217. })
  218. addStart += len(patch.Text)
  219. default:
  220. // Context text, no highlighting needed
  221. removeStart += len(patch.Text)
  222. addStart += len(patch.Text)
  223. }
  224. }
  225. oldLine.Segments = segments
  226. newLine.Segments = segments
  227. updated = append(updated, oldLine, newLine)
  228. i++ // Skip the next line as we've already processed it
  229. } else {
  230. updated = append(updated, h.Lines[i])
  231. }
  232. }
  233. h.Lines = updated
  234. }
  235. // pairLines converts a flat list of diff lines to pairs for side-by-side display
  236. func pairLines(lines []DiffLine) []linePair {
  237. var pairs []linePair
  238. i := 0
  239. for i < len(lines) {
  240. switch lines[i].Kind {
  241. case LineRemoved:
  242. // Check if the next line is an addition, if so pair them
  243. if i+1 < len(lines) && lines[i+1].Kind == LineAdded {
  244. pairs = append(pairs, linePair{left: &lines[i], right: &lines[i+1]})
  245. i += 2
  246. } else {
  247. pairs = append(pairs, linePair{left: &lines[i], right: nil})
  248. i++
  249. }
  250. case LineAdded:
  251. pairs = append(pairs, linePair{left: nil, right: &lines[i]})
  252. i++
  253. case LineContext:
  254. pairs = append(pairs, linePair{left: &lines[i], right: &lines[i]})
  255. i++
  256. }
  257. }
  258. return pairs
  259. }
  260. // -------------------------------------------------------------------------
  261. // Syntax Highlighting
  262. // -------------------------------------------------------------------------
  263. // SyntaxHighlight applies syntax highlighting to text based on file extension
  264. func SyntaxHighlight(w io.Writer, source, fileName, formatter string, bg color.Color) error {
  265. t := theme.CurrentTheme()
  266. // Determine the language lexer to use
  267. l := lexers.Match(fileName)
  268. if l == nil {
  269. l = lexers.Analyse(source)
  270. }
  271. if l == nil {
  272. l = lexers.Fallback
  273. }
  274. l = chroma.Coalesce(l)
  275. // Get the formatter
  276. f := formatters.Get(formatter)
  277. if f == nil {
  278. f = formatters.Fallback
  279. }
  280. // Dynamic theme based on current theme values
  281. syntaxThemeXml := fmt.Sprintf(`
  282. <style name="opencode-theme">
  283. <!-- Base colors -->
  284. <entry type="Background" style="bg:%s"/>
  285. <entry type="Text" style="%s"/>
  286. <entry type="Other" style="%s"/>
  287. <entry type="Error" style="%s"/>
  288. <!-- Keywords -->
  289. <entry type="Keyword" style="%s"/>
  290. <entry type="KeywordConstant" style="%s"/>
  291. <entry type="KeywordDeclaration" style="%s"/>
  292. <entry type="KeywordNamespace" style="%s"/>
  293. <entry type="KeywordPseudo" style="%s"/>
  294. <entry type="KeywordReserved" style="%s"/>
  295. <entry type="KeywordType" style="%s"/>
  296. <!-- Names -->
  297. <entry type="Name" style="%s"/>
  298. <entry type="NameAttribute" style="%s"/>
  299. <entry type="NameBuiltin" style="%s"/>
  300. <entry type="NameBuiltinPseudo" style="%s"/>
  301. <entry type="NameClass" style="%s"/>
  302. <entry type="NameConstant" style="%s"/>
  303. <entry type="NameDecorator" style="%s"/>
  304. <entry type="NameEntity" style="%s"/>
  305. <entry type="NameException" style="%s"/>
  306. <entry type="NameFunction" style="%s"/>
  307. <entry type="NameLabel" style="%s"/>
  308. <entry type="NameNamespace" style="%s"/>
  309. <entry type="NameOther" style="%s"/>
  310. <entry type="NameTag" style="%s"/>
  311. <entry type="NameVariable" style="%s"/>
  312. <entry type="NameVariableClass" style="%s"/>
  313. <entry type="NameVariableGlobal" style="%s"/>
  314. <entry type="NameVariableInstance" style="%s"/>
  315. <!-- Literals -->
  316. <entry type="Literal" style="%s"/>
  317. <entry type="LiteralDate" style="%s"/>
  318. <entry type="LiteralString" style="%s"/>
  319. <entry type="LiteralStringBacktick" style="%s"/>
  320. <entry type="LiteralStringChar" style="%s"/>
  321. <entry type="LiteralStringDoc" style="%s"/>
  322. <entry type="LiteralStringDouble" style="%s"/>
  323. <entry type="LiteralStringEscape" style="%s"/>
  324. <entry type="LiteralStringHeredoc" style="%s"/>
  325. <entry type="LiteralStringInterpol" style="%s"/>
  326. <entry type="LiteralStringOther" style="%s"/>
  327. <entry type="LiteralStringRegex" style="%s"/>
  328. <entry type="LiteralStringSingle" style="%s"/>
  329. <entry type="LiteralStringSymbol" style="%s"/>
  330. <!-- Numbers -->
  331. <entry type="LiteralNumber" style="%s"/>
  332. <entry type="LiteralNumberBin" style="%s"/>
  333. <entry type="LiteralNumberFloat" style="%s"/>
  334. <entry type="LiteralNumberHex" style="%s"/>
  335. <entry type="LiteralNumberInteger" style="%s"/>
  336. <entry type="LiteralNumberIntegerLong" style="%s"/>
  337. <entry type="LiteralNumberOct" style="%s"/>
  338. <!-- Operators -->
  339. <entry type="Operator" style="%s"/>
  340. <entry type="OperatorWord" style="%s"/>
  341. <entry type="Punctuation" style="%s"/>
  342. <!-- Comments -->
  343. <entry type="Comment" style="%s"/>
  344. <entry type="CommentHashbang" style="%s"/>
  345. <entry type="CommentMultiline" style="%s"/>
  346. <entry type="CommentSingle" style="%s"/>
  347. <entry type="CommentSpecial" style="%s"/>
  348. <entry type="CommentPreproc" style="%s"/>
  349. <!-- Generic styles -->
  350. <entry type="Generic" style="%s"/>
  351. <entry type="GenericDeleted" style="%s"/>
  352. <entry type="GenericEmph" style="italic %s"/>
  353. <entry type="GenericError" style="%s"/>
  354. <entry type="GenericHeading" style="bold %s"/>
  355. <entry type="GenericInserted" style="%s"/>
  356. <entry type="GenericOutput" style="%s"/>
  357. <entry type="GenericPrompt" style="%s"/>
  358. <entry type="GenericStrong" style="bold %s"/>
  359. <entry type="GenericSubheading" style="bold %s"/>
  360. <entry type="GenericTraceback" style="%s"/>
  361. <entry type="GenericUnderline" style="underline"/>
  362. <entry type="TextWhitespace" style="%s"/>
  363. </style>
  364. `,
  365. getColor(t.BackgroundSubtle()), // Background
  366. getColor(t.Text()), // Text
  367. getColor(t.Text()), // Other
  368. getColor(t.Error()), // Error
  369. getColor(t.SyntaxKeyword()), // Keyword
  370. getColor(t.SyntaxKeyword()), // KeywordConstant
  371. getColor(t.SyntaxKeyword()), // KeywordDeclaration
  372. getColor(t.SyntaxKeyword()), // KeywordNamespace
  373. getColor(t.SyntaxKeyword()), // KeywordPseudo
  374. getColor(t.SyntaxKeyword()), // KeywordReserved
  375. getColor(t.SyntaxType()), // KeywordType
  376. getColor(t.Text()), // Name
  377. getColor(t.SyntaxVariable()), // NameAttribute
  378. getColor(t.SyntaxType()), // NameBuiltin
  379. getColor(t.SyntaxVariable()), // NameBuiltinPseudo
  380. getColor(t.SyntaxType()), // NameClass
  381. getColor(t.SyntaxVariable()), // NameConstant
  382. getColor(t.SyntaxFunction()), // NameDecorator
  383. getColor(t.SyntaxVariable()), // NameEntity
  384. getColor(t.SyntaxType()), // NameException
  385. getColor(t.SyntaxFunction()), // NameFunction
  386. getColor(t.Text()), // NameLabel
  387. getColor(t.SyntaxType()), // NameNamespace
  388. getColor(t.SyntaxVariable()), // NameOther
  389. getColor(t.SyntaxKeyword()), // NameTag
  390. getColor(t.SyntaxVariable()), // NameVariable
  391. getColor(t.SyntaxVariable()), // NameVariableClass
  392. getColor(t.SyntaxVariable()), // NameVariableGlobal
  393. getColor(t.SyntaxVariable()), // NameVariableInstance
  394. getColor(t.SyntaxString()), // Literal
  395. getColor(t.SyntaxString()), // LiteralDate
  396. getColor(t.SyntaxString()), // LiteralString
  397. getColor(t.SyntaxString()), // LiteralStringBacktick
  398. getColor(t.SyntaxString()), // LiteralStringChar
  399. getColor(t.SyntaxString()), // LiteralStringDoc
  400. getColor(t.SyntaxString()), // LiteralStringDouble
  401. getColor(t.SyntaxString()), // LiteralStringEscape
  402. getColor(t.SyntaxString()), // LiteralStringHeredoc
  403. getColor(t.SyntaxString()), // LiteralStringInterpol
  404. getColor(t.SyntaxString()), // LiteralStringOther
  405. getColor(t.SyntaxString()), // LiteralStringRegex
  406. getColor(t.SyntaxString()), // LiteralStringSingle
  407. getColor(t.SyntaxString()), // LiteralStringSymbol
  408. getColor(t.SyntaxNumber()), // LiteralNumber
  409. getColor(t.SyntaxNumber()), // LiteralNumberBin
  410. getColor(t.SyntaxNumber()), // LiteralNumberFloat
  411. getColor(t.SyntaxNumber()), // LiteralNumberHex
  412. getColor(t.SyntaxNumber()), // LiteralNumberInteger
  413. getColor(t.SyntaxNumber()), // LiteralNumberIntegerLong
  414. getColor(t.SyntaxNumber()), // LiteralNumberOct
  415. getColor(t.SyntaxOperator()), // Operator
  416. getColor(t.SyntaxKeyword()), // OperatorWord
  417. getColor(t.SyntaxPunctuation()), // Punctuation
  418. getColor(t.SyntaxComment()), // Comment
  419. getColor(t.SyntaxComment()), // CommentHashbang
  420. getColor(t.SyntaxComment()), // CommentMultiline
  421. getColor(t.SyntaxComment()), // CommentSingle
  422. getColor(t.SyntaxComment()), // CommentSpecial
  423. getColor(t.SyntaxKeyword()), // CommentPreproc
  424. getColor(t.Text()), // Generic
  425. getColor(t.Error()), // GenericDeleted
  426. getColor(t.Text()), // GenericEmph
  427. getColor(t.Error()), // GenericError
  428. getColor(t.Text()), // GenericHeading
  429. getColor(t.Success()), // GenericInserted
  430. getColor(t.TextMuted()), // GenericOutput
  431. getColor(t.Text()), // GenericPrompt
  432. getColor(t.Text()), // GenericStrong
  433. getColor(t.Text()), // GenericSubheading
  434. getColor(t.Error()), // GenericTraceback
  435. getColor(t.Text()), // TextWhitespace
  436. )
  437. r := strings.NewReader(syntaxThemeXml)
  438. style := chroma.MustNewXMLStyle(r)
  439. // Modify the style to use the provided background
  440. s, err := style.Builder().Transform(
  441. func(t chroma.StyleEntry) chroma.StyleEntry {
  442. r, g, b, _ := bg.RGBA()
  443. t.Background = chroma.NewColour(uint8(r>>8), uint8(g>>8), uint8(b>>8))
  444. return t
  445. },
  446. ).Build()
  447. if err != nil {
  448. s = styles.Fallback
  449. }
  450. // Tokenize and format
  451. it, err := l.Tokenise(nil, source)
  452. if err != nil {
  453. return err
  454. }
  455. return f.Format(w, s, it)
  456. }
  457. // getColor returns the appropriate hex color string based on terminal background
  458. func getColor(adaptiveColor compat.AdaptiveColor) string {
  459. return stylesi.AdaptiveColorToString(adaptiveColor)
  460. }
  461. // highlightLine applies syntax highlighting to a single line
  462. func highlightLine(fileName string, line string, bg color.Color) string {
  463. var buf bytes.Buffer
  464. err := SyntaxHighlight(&buf, line, fileName, "terminal16m", bg)
  465. if err != nil {
  466. return line
  467. }
  468. return buf.String()
  469. }
  470. // createStyles generates the lipgloss styles needed for rendering diffs
  471. func createStyles(t theme.Theme) (removedLineStyle, addedLineStyle, contextLineStyle, lineNumberStyle lipgloss.Style) {
  472. removedLineStyle = lipgloss.NewStyle().Background(t.DiffRemovedBg())
  473. addedLineStyle = lipgloss.NewStyle().Background(t.DiffAddedBg())
  474. contextLineStyle = lipgloss.NewStyle().Background(t.DiffContextBg())
  475. lineNumberStyle = lipgloss.NewStyle().Background(t.DiffLineNumber()).Foreground(t.TextMuted())
  476. return
  477. }
  478. // -------------------------------------------------------------------------
  479. // Rendering Functions
  480. // -------------------------------------------------------------------------
  481. // applyHighlighting applies intra-line highlighting to a piece of text
  482. func applyHighlighting(content string, segments []Segment, segmentType LineType, highlightBg compat.AdaptiveColor) string {
  483. // Find all ANSI sequences in the content
  484. ansiRegex := regexp.MustCompile(`\x1b(?:[@-Z\\-_]|\[[0-9?]*(?:;[0-9?]*)*[@-~])`)
  485. ansiMatches := ansiRegex.FindAllStringIndex(content, -1)
  486. // Build a mapping of visible character positions to their actual indices
  487. visibleIdx := 0
  488. ansiSequences := make(map[int]string)
  489. lastAnsiSeq := "\x1b[0m" // Default reset sequence
  490. for i := 0; i < len(content); {
  491. isAnsi := false
  492. for _, match := range ansiMatches {
  493. if match[0] == i {
  494. ansiSequences[visibleIdx] = content[match[0]:match[1]]
  495. lastAnsiSeq = content[match[0]:match[1]]
  496. i = match[1]
  497. isAnsi = true
  498. break
  499. }
  500. }
  501. if isAnsi {
  502. continue
  503. }
  504. // For non-ANSI positions, store the last ANSI sequence
  505. if _, exists := ansiSequences[visibleIdx]; !exists {
  506. ansiSequences[visibleIdx] = lastAnsiSeq
  507. }
  508. visibleIdx++
  509. i++
  510. }
  511. // Apply highlighting
  512. var sb strings.Builder
  513. inSelection := false
  514. currentPos := 0
  515. // Get the appropriate color based on terminal background
  516. bgColor := lipgloss.Color(getColor(highlightBg))
  517. fgColor := lipgloss.Color(getColor(theme.CurrentTheme().BackgroundSubtle()))
  518. for i := 0; i < len(content); {
  519. // Check if we're at an ANSI sequence
  520. isAnsi := false
  521. for _, match := range ansiMatches {
  522. if match[0] == i {
  523. sb.WriteString(content[match[0]:match[1]]) // Preserve ANSI sequence
  524. i = match[1]
  525. isAnsi = true
  526. break
  527. }
  528. }
  529. if isAnsi {
  530. continue
  531. }
  532. // Check for segment boundaries
  533. for _, seg := range segments {
  534. if seg.Type == segmentType {
  535. if currentPos == seg.Start {
  536. inSelection = true
  537. }
  538. if currentPos == seg.End {
  539. inSelection = false
  540. }
  541. }
  542. }
  543. // Get current character
  544. char := string(content[i])
  545. if inSelection {
  546. // Get the current styling
  547. currentStyle := ansiSequences[currentPos]
  548. // Apply foreground and background highlight
  549. sb.WriteString("\x1b[38;2;")
  550. r, g, b, _ := fgColor.RGBA()
  551. sb.WriteString(fmt.Sprintf("%d;%d;%dm", r>>8, g>>8, b>>8))
  552. sb.WriteString("\x1b[48;2;")
  553. r, g, b, _ = bgColor.RGBA()
  554. sb.WriteString(fmt.Sprintf("%d;%d;%dm", r>>8, g>>8, b>>8))
  555. sb.WriteString(char)
  556. // Full reset of all attributes to ensure clean state
  557. sb.WriteString("\x1b[0m")
  558. // Reapply the original ANSI sequence
  559. sb.WriteString(currentStyle)
  560. } else {
  561. // Not in selection, just copy the character
  562. sb.WriteString(char)
  563. }
  564. currentPos++
  565. i++
  566. }
  567. return sb.String()
  568. }
  569. // renderDiffColumnLine is a helper function that handles the common logic for rendering diff columns
  570. func renderDiffColumnLine(
  571. fileName string,
  572. dl *DiffLine,
  573. colWidth int,
  574. isLeftColumn bool,
  575. t theme.Theme,
  576. ) string {
  577. if dl == nil {
  578. contextLineStyle := lipgloss.NewStyle().Background(t.DiffContextBg())
  579. return contextLineStyle.Width(colWidth).Render("")
  580. }
  581. removedLineStyle, addedLineStyle, contextLineStyle, lineNumberStyle := createStyles(t)
  582. // Determine line style based on line type and column
  583. var marker string
  584. var bgStyle lipgloss.Style
  585. var lineNum string
  586. var highlightType LineType
  587. var highlightColor compat.AdaptiveColor
  588. if isLeftColumn {
  589. // Left column logic
  590. switch dl.Kind {
  591. case LineRemoved:
  592. marker = "-"
  593. bgStyle = removedLineStyle
  594. lineNumberStyle = lineNumberStyle.Foreground(t.DiffRemoved()).Background(t.DiffRemovedLineNumberBg())
  595. highlightType = LineRemoved
  596. highlightColor = t.DiffHighlightRemoved()
  597. case LineAdded:
  598. marker = "?"
  599. bgStyle = contextLineStyle
  600. case LineContext:
  601. marker = " "
  602. bgStyle = contextLineStyle
  603. }
  604. // Format line number for left column
  605. if dl.OldLineNo > 0 {
  606. lineNum = fmt.Sprintf("%6d", dl.OldLineNo)
  607. }
  608. } else {
  609. // Right column logic
  610. switch dl.Kind {
  611. case LineAdded:
  612. marker = "+"
  613. bgStyle = addedLineStyle
  614. lineNumberStyle = lineNumberStyle.Foreground(t.DiffAdded()).Background(t.DiffAddedLineNumberBg())
  615. highlightType = LineAdded
  616. highlightColor = t.DiffHighlightAdded()
  617. case LineRemoved:
  618. marker = "?"
  619. bgStyle = contextLineStyle
  620. case LineContext:
  621. marker = " "
  622. bgStyle = contextLineStyle
  623. }
  624. // Format line number for right column
  625. if dl.NewLineNo > 0 {
  626. lineNum = fmt.Sprintf("%6d", dl.NewLineNo)
  627. }
  628. }
  629. // Style the marker based on line type
  630. var styledMarker string
  631. switch dl.Kind {
  632. case LineRemoved:
  633. styledMarker = removedLineStyle.Foreground(t.DiffRemoved()).Render(marker)
  634. case LineAdded:
  635. styledMarker = addedLineStyle.Foreground(t.DiffAdded()).Render(marker)
  636. case LineContext:
  637. styledMarker = contextLineStyle.Foreground(t.TextMuted()).Render(marker)
  638. default:
  639. styledMarker = marker
  640. }
  641. // Create the line prefix
  642. prefix := lineNumberStyle.Render(lineNum + " " + styledMarker)
  643. // Apply syntax highlighting
  644. content := highlightLine(fileName, dl.Content, bgStyle.GetBackground())
  645. // Apply intra-line highlighting if needed
  646. if (dl.Kind == LineRemoved && isLeftColumn || dl.Kind == LineAdded && !isLeftColumn) && len(dl.Segments) > 0 {
  647. content = applyHighlighting(content, dl.Segments, highlightType, highlightColor)
  648. }
  649. // Add a padding space for added/removed lines
  650. if (dl.Kind == LineRemoved && isLeftColumn) || (dl.Kind == LineAdded && !isLeftColumn) {
  651. content = bgStyle.Render(" ") + content
  652. }
  653. // Create the final line and truncate if needed
  654. lineText := prefix + content
  655. return bgStyle.MaxHeight(1).Width(colWidth).Render(
  656. ansi.Truncate(
  657. lineText,
  658. colWidth,
  659. lipgloss.NewStyle().Background(bgStyle.GetBackground()).Foreground(t.TextMuted()).Render("..."),
  660. ),
  661. )
  662. }
  663. // renderLeftColumn formats the left side of a side-by-side diff
  664. func renderLeftColumn(fileName string, dl *DiffLine, colWidth int) string {
  665. return renderDiffColumnLine(fileName, dl, colWidth, true, theme.CurrentTheme())
  666. }
  667. // renderRightColumn formats the right side of a side-by-side diff
  668. func renderRightColumn(fileName string, dl *DiffLine, colWidth int) string {
  669. return renderDiffColumnLine(fileName, dl, colWidth, false, theme.CurrentTheme())
  670. }
  671. // -------------------------------------------------------------------------
  672. // Public API
  673. // -------------------------------------------------------------------------
  674. // RenderSideBySideHunk formats a hunk for side-by-side display
  675. func RenderSideBySideHunk(fileName string, h Hunk, opts ...SideBySideOption) string {
  676. // Apply options to create the configuration
  677. config := NewSideBySideConfig(opts...)
  678. // Make a copy of the hunk so we don't modify the original
  679. hunkCopy := Hunk{Lines: make([]DiffLine, len(h.Lines))}
  680. copy(hunkCopy.Lines, h.Lines)
  681. // Highlight changes within lines
  682. HighlightIntralineChanges(&hunkCopy)
  683. // Pair lines for side-by-side display
  684. pairs := pairLines(hunkCopy.Lines)
  685. // Calculate column width
  686. colWidth := config.TotalWidth / 2
  687. leftWidth := colWidth
  688. rightWidth := config.TotalWidth - colWidth
  689. var sb strings.Builder
  690. for _, p := range pairs {
  691. leftStr := renderLeftColumn(fileName, p.left, leftWidth)
  692. rightStr := renderRightColumn(fileName, p.right, rightWidth)
  693. sb.WriteString(leftStr + rightStr + "\n")
  694. }
  695. return sb.String()
  696. }
  697. // FormatDiff creates a side-by-side formatted view of a diff
  698. func FormatDiff(filename string, diffText string, opts ...SideBySideOption) (string, error) {
  699. // t := theme.CurrentTheme()
  700. diffResult, err := ParseUnifiedDiff(diffText)
  701. if err != nil {
  702. return "", err
  703. }
  704. var sb strings.Builder
  705. // config := NewSideBySideConfig(opts...)
  706. for _, h := range diffResult.Hunks {
  707. // sb.WriteString(
  708. // lipgloss.NewStyle().
  709. // Background(t.DiffHunkHeader()).
  710. // Foreground(t.Background()).
  711. // Width(config.TotalWidth).
  712. // Render(h.Header) + "\n",
  713. // )
  714. sb.WriteString(RenderSideBySideHunk(filename, h, opts...))
  715. }
  716. return sb.String(), nil
  717. }