diff.go 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817
  1. package diff
  2. import (
  3. "bytes"
  4. "fmt"
  5. "io"
  6. "regexp"
  7. "strconv"
  8. "strings"
  9. "github.com/alecthomas/chroma/v2"
  10. "github.com/alecthomas/chroma/v2/formatters"
  11. "github.com/alecthomas/chroma/v2/lexers"
  12. "github.com/alecthomas/chroma/v2/styles"
  13. "github.com/charmbracelet/lipgloss"
  14. "github.com/charmbracelet/x/ansi"
  15. "github.com/sergi/go-diff/diffmatchpatch"
  16. "github.com/sst/opencode/internal/theme"
  17. )
  18. // -------------------------------------------------------------------------
  19. // Core Types
  20. // -------------------------------------------------------------------------
  21. // LineType represents the kind of line in a diff.
  22. type LineType int
  23. const (
  24. LineContext LineType = iota // Line exists in both files
  25. LineAdded // Line added in the new file
  26. LineRemoved // Line removed from the old file
  27. )
  28. // Segment represents a portion of a line for intra-line highlighting
  29. type Segment struct {
  30. Start int
  31. End int
  32. Type LineType
  33. Text string
  34. }
  35. // DiffLine represents a single line in a diff
  36. type DiffLine struct {
  37. OldLineNo int // Line number in old file (0 for added lines)
  38. NewLineNo int // Line number in new file (0 for removed lines)
  39. Kind LineType // Type of line (added, removed, context)
  40. Content string // Content of the line
  41. Segments []Segment // Segments for intraline highlighting
  42. }
  43. // Hunk represents a section of changes in a diff
  44. type Hunk struct {
  45. Header string
  46. Lines []DiffLine
  47. }
  48. // DiffResult contains the parsed result of a diff
  49. type DiffResult struct {
  50. OldFile string
  51. NewFile string
  52. Hunks []Hunk
  53. }
  54. // linePair represents a pair of lines for side-by-side display
  55. type linePair struct {
  56. left *DiffLine
  57. right *DiffLine
  58. }
  59. // -------------------------------------------------------------------------
  60. // Side-by-Side Configuration
  61. // -------------------------------------------------------------------------
  62. // SideBySideConfig configures the rendering of side-by-side diffs
  63. type SideBySideConfig struct {
  64. TotalWidth int
  65. }
  66. // SideBySideOption modifies a SideBySideConfig
  67. type SideBySideOption func(*SideBySideConfig)
  68. // NewSideBySideConfig creates a SideBySideConfig with default values
  69. func NewSideBySideConfig(opts ...SideBySideOption) SideBySideConfig {
  70. config := SideBySideConfig{
  71. TotalWidth: 160, // Default width for side-by-side view
  72. }
  73. for _, opt := range opts {
  74. opt(&config)
  75. }
  76. return config
  77. }
  78. // WithTotalWidth sets the total width for side-by-side view
  79. func WithTotalWidth(width int) SideBySideOption {
  80. return func(s *SideBySideConfig) {
  81. if width > 0 {
  82. s.TotalWidth = width
  83. }
  84. }
  85. }
  86. // -------------------------------------------------------------------------
  87. // Diff Parsing
  88. // -------------------------------------------------------------------------
  89. // ParseUnifiedDiff parses a unified diff format string into structured data
  90. func ParseUnifiedDiff(diff string) (DiffResult, error) {
  91. var result DiffResult
  92. var currentHunk *Hunk
  93. hunkHeaderRe := regexp.MustCompile(`^@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@`)
  94. lines := strings.Split(diff, "\n")
  95. var oldLine, newLine int
  96. inFileHeader := true
  97. for _, line := range lines {
  98. // Parse file headers
  99. if inFileHeader {
  100. if strings.HasPrefix(line, "--- a/") {
  101. result.OldFile = strings.TrimPrefix(line, "--- a/")
  102. continue
  103. }
  104. if strings.HasPrefix(line, "+++ b/") {
  105. result.NewFile = strings.TrimPrefix(line, "+++ b/")
  106. inFileHeader = false
  107. continue
  108. }
  109. }
  110. // Parse hunk headers
  111. if matches := hunkHeaderRe.FindStringSubmatch(line); matches != nil {
  112. if currentHunk != nil {
  113. result.Hunks = append(result.Hunks, *currentHunk)
  114. }
  115. currentHunk = &Hunk{
  116. Header: line,
  117. Lines: []DiffLine{},
  118. }
  119. oldStart, _ := strconv.Atoi(matches[1])
  120. newStart, _ := strconv.Atoi(matches[3])
  121. oldLine = oldStart
  122. newLine = newStart
  123. continue
  124. }
  125. // Ignore "No newline at end of file" markers
  126. if strings.HasPrefix(line, "\\ No newline at end of file") {
  127. continue
  128. }
  129. if currentHunk == nil {
  130. continue
  131. }
  132. // Process the line based on its prefix
  133. if len(line) > 0 {
  134. switch line[0] {
  135. case '+':
  136. currentHunk.Lines = append(currentHunk.Lines, DiffLine{
  137. OldLineNo: 0,
  138. NewLineNo: newLine,
  139. Kind: LineAdded,
  140. Content: line[1:],
  141. })
  142. newLine++
  143. case '-':
  144. currentHunk.Lines = append(currentHunk.Lines, DiffLine{
  145. OldLineNo: oldLine,
  146. NewLineNo: 0,
  147. Kind: LineRemoved,
  148. Content: line[1:],
  149. })
  150. oldLine++
  151. default:
  152. currentHunk.Lines = append(currentHunk.Lines, DiffLine{
  153. OldLineNo: oldLine,
  154. NewLineNo: newLine,
  155. Kind: LineContext,
  156. Content: line,
  157. })
  158. oldLine++
  159. newLine++
  160. }
  161. } else {
  162. // Handle empty lines
  163. currentHunk.Lines = append(currentHunk.Lines, DiffLine{
  164. OldLineNo: oldLine,
  165. NewLineNo: newLine,
  166. Kind: LineContext,
  167. Content: "",
  168. })
  169. oldLine++
  170. newLine++
  171. }
  172. }
  173. // Add the last hunk if there is one
  174. if currentHunk != nil {
  175. result.Hunks = append(result.Hunks, *currentHunk)
  176. }
  177. return result, nil
  178. }
  179. // HighlightIntralineChanges updates lines in a hunk to show character-level differences
  180. func HighlightIntralineChanges(h *Hunk) {
  181. var updated []DiffLine
  182. dmp := diffmatchpatch.New()
  183. for i := 0; i < len(h.Lines); i++ {
  184. // Look for removed line followed by added line
  185. if i+1 < len(h.Lines) &&
  186. h.Lines[i].Kind == LineRemoved &&
  187. h.Lines[i+1].Kind == LineAdded {
  188. oldLine := h.Lines[i]
  189. newLine := h.Lines[i+1]
  190. // Find character-level differences
  191. patches := dmp.DiffMain(oldLine.Content, newLine.Content, false)
  192. patches = dmp.DiffCleanupSemantic(patches)
  193. patches = dmp.DiffCleanupMerge(patches)
  194. patches = dmp.DiffCleanupEfficiency(patches)
  195. segments := make([]Segment, 0)
  196. removeStart := 0
  197. addStart := 0
  198. for _, patch := range patches {
  199. switch patch.Type {
  200. case diffmatchpatch.DiffDelete:
  201. segments = append(segments, Segment{
  202. Start: removeStart,
  203. End: removeStart + len(patch.Text),
  204. Type: LineRemoved,
  205. Text: patch.Text,
  206. })
  207. removeStart += len(patch.Text)
  208. case diffmatchpatch.DiffInsert:
  209. segments = append(segments, Segment{
  210. Start: addStart,
  211. End: addStart + len(patch.Text),
  212. Type: LineAdded,
  213. Text: patch.Text,
  214. })
  215. addStart += len(patch.Text)
  216. default:
  217. // Context text, no highlighting needed
  218. removeStart += len(patch.Text)
  219. addStart += len(patch.Text)
  220. }
  221. }
  222. oldLine.Segments = segments
  223. newLine.Segments = segments
  224. updated = append(updated, oldLine, newLine)
  225. i++ // Skip the next line as we've already processed it
  226. } else {
  227. updated = append(updated, h.Lines[i])
  228. }
  229. }
  230. h.Lines = updated
  231. }
  232. // pairLines converts a flat list of diff lines to pairs for side-by-side display
  233. func pairLines(lines []DiffLine) []linePair {
  234. var pairs []linePair
  235. i := 0
  236. for i < len(lines) {
  237. switch lines[i].Kind {
  238. case LineRemoved:
  239. // Check if the next line is an addition, if so pair them
  240. if i+1 < len(lines) && lines[i+1].Kind == LineAdded {
  241. pairs = append(pairs, linePair{left: &lines[i], right: &lines[i+1]})
  242. i += 2
  243. } else {
  244. pairs = append(pairs, linePair{left: &lines[i], right: nil})
  245. i++
  246. }
  247. case LineAdded:
  248. pairs = append(pairs, linePair{left: nil, right: &lines[i]})
  249. i++
  250. case LineContext:
  251. pairs = append(pairs, linePair{left: &lines[i], right: &lines[i]})
  252. i++
  253. }
  254. }
  255. return pairs
  256. }
  257. // -------------------------------------------------------------------------
  258. // Syntax Highlighting
  259. // -------------------------------------------------------------------------
  260. // SyntaxHighlight applies syntax highlighting to text based on file extension
  261. func SyntaxHighlight(w io.Writer, source, fileName, formatter string, bg lipgloss.TerminalColor) error {
  262. t := theme.CurrentTheme()
  263. // Determine the language lexer to use
  264. l := lexers.Match(fileName)
  265. if l == nil {
  266. l = lexers.Analyse(source)
  267. }
  268. if l == nil {
  269. l = lexers.Fallback
  270. }
  271. l = chroma.Coalesce(l)
  272. // Get the formatter
  273. f := formatters.Get(formatter)
  274. if f == nil {
  275. f = formatters.Fallback
  276. }
  277. // Dynamic theme based on current theme values
  278. syntaxThemeXml := fmt.Sprintf(`
  279. <style name="opencode-theme">
  280. <!-- Base colors -->
  281. <entry type="Background" style="bg:%s"/>
  282. <entry type="Text" style="%s"/>
  283. <entry type="Other" style="%s"/>
  284. <entry type="Error" style="%s"/>
  285. <!-- Keywords -->
  286. <entry type="Keyword" style="%s"/>
  287. <entry type="KeywordConstant" style="%s"/>
  288. <entry type="KeywordDeclaration" style="%s"/>
  289. <entry type="KeywordNamespace" style="%s"/>
  290. <entry type="KeywordPseudo" style="%s"/>
  291. <entry type="KeywordReserved" style="%s"/>
  292. <entry type="KeywordType" style="%s"/>
  293. <!-- Names -->
  294. <entry type="Name" style="%s"/>
  295. <entry type="NameAttribute" style="%s"/>
  296. <entry type="NameBuiltin" style="%s"/>
  297. <entry type="NameBuiltinPseudo" style="%s"/>
  298. <entry type="NameClass" style="%s"/>
  299. <entry type="NameConstant" style="%s"/>
  300. <entry type="NameDecorator" style="%s"/>
  301. <entry type="NameEntity" style="%s"/>
  302. <entry type="NameException" style="%s"/>
  303. <entry type="NameFunction" style="%s"/>
  304. <entry type="NameLabel" style="%s"/>
  305. <entry type="NameNamespace" style="%s"/>
  306. <entry type="NameOther" style="%s"/>
  307. <entry type="NameTag" style="%s"/>
  308. <entry type="NameVariable" style="%s"/>
  309. <entry type="NameVariableClass" style="%s"/>
  310. <entry type="NameVariableGlobal" style="%s"/>
  311. <entry type="NameVariableInstance" style="%s"/>
  312. <!-- Literals -->
  313. <entry type="Literal" style="%s"/>
  314. <entry type="LiteralDate" style="%s"/>
  315. <entry type="LiteralString" style="%s"/>
  316. <entry type="LiteralStringBacktick" style="%s"/>
  317. <entry type="LiteralStringChar" style="%s"/>
  318. <entry type="LiteralStringDoc" style="%s"/>
  319. <entry type="LiteralStringDouble" style="%s"/>
  320. <entry type="LiteralStringEscape" style="%s"/>
  321. <entry type="LiteralStringHeredoc" style="%s"/>
  322. <entry type="LiteralStringInterpol" style="%s"/>
  323. <entry type="LiteralStringOther" style="%s"/>
  324. <entry type="LiteralStringRegex" style="%s"/>
  325. <entry type="LiteralStringSingle" style="%s"/>
  326. <entry type="LiteralStringSymbol" style="%s"/>
  327. <!-- Numbers -->
  328. <entry type="LiteralNumber" style="%s"/>
  329. <entry type="LiteralNumberBin" style="%s"/>
  330. <entry type="LiteralNumberFloat" style="%s"/>
  331. <entry type="LiteralNumberHex" style="%s"/>
  332. <entry type="LiteralNumberInteger" style="%s"/>
  333. <entry type="LiteralNumberIntegerLong" style="%s"/>
  334. <entry type="LiteralNumberOct" style="%s"/>
  335. <!-- Operators -->
  336. <entry type="Operator" style="%s"/>
  337. <entry type="OperatorWord" style="%s"/>
  338. <entry type="Punctuation" style="%s"/>
  339. <!-- Comments -->
  340. <entry type="Comment" style="%s"/>
  341. <entry type="CommentHashbang" style="%s"/>
  342. <entry type="CommentMultiline" style="%s"/>
  343. <entry type="CommentSingle" style="%s"/>
  344. <entry type="CommentSpecial" style="%s"/>
  345. <entry type="CommentPreproc" style="%s"/>
  346. <!-- Generic styles -->
  347. <entry type="Generic" style="%s"/>
  348. <entry type="GenericDeleted" style="%s"/>
  349. <entry type="GenericEmph" style="italic %s"/>
  350. <entry type="GenericError" style="%s"/>
  351. <entry type="GenericHeading" style="bold %s"/>
  352. <entry type="GenericInserted" style="%s"/>
  353. <entry type="GenericOutput" style="%s"/>
  354. <entry type="GenericPrompt" style="%s"/>
  355. <entry type="GenericStrong" style="bold %s"/>
  356. <entry type="GenericSubheading" style="bold %s"/>
  357. <entry type="GenericTraceback" style="%s"/>
  358. <entry type="GenericUnderline" style="underline"/>
  359. <entry type="TextWhitespace" style="%s"/>
  360. </style>
  361. `,
  362. getColor(t.BackgroundSubtle()), // Background
  363. getColor(t.Text()), // Text
  364. getColor(t.Text()), // Other
  365. getColor(t.Error()), // Error
  366. getColor(t.SyntaxKeyword()), // Keyword
  367. getColor(t.SyntaxKeyword()), // KeywordConstant
  368. getColor(t.SyntaxKeyword()), // KeywordDeclaration
  369. getColor(t.SyntaxKeyword()), // KeywordNamespace
  370. getColor(t.SyntaxKeyword()), // KeywordPseudo
  371. getColor(t.SyntaxKeyword()), // KeywordReserved
  372. getColor(t.SyntaxType()), // KeywordType
  373. getColor(t.Text()), // Name
  374. getColor(t.SyntaxVariable()), // NameAttribute
  375. getColor(t.SyntaxType()), // NameBuiltin
  376. getColor(t.SyntaxVariable()), // NameBuiltinPseudo
  377. getColor(t.SyntaxType()), // NameClass
  378. getColor(t.SyntaxVariable()), // NameConstant
  379. getColor(t.SyntaxFunction()), // NameDecorator
  380. getColor(t.SyntaxVariable()), // NameEntity
  381. getColor(t.SyntaxType()), // NameException
  382. getColor(t.SyntaxFunction()), // NameFunction
  383. getColor(t.Text()), // NameLabel
  384. getColor(t.SyntaxType()), // NameNamespace
  385. getColor(t.SyntaxVariable()), // NameOther
  386. getColor(t.SyntaxKeyword()), // NameTag
  387. getColor(t.SyntaxVariable()), // NameVariable
  388. getColor(t.SyntaxVariable()), // NameVariableClass
  389. getColor(t.SyntaxVariable()), // NameVariableGlobal
  390. getColor(t.SyntaxVariable()), // NameVariableInstance
  391. getColor(t.SyntaxString()), // Literal
  392. getColor(t.SyntaxString()), // LiteralDate
  393. getColor(t.SyntaxString()), // LiteralString
  394. getColor(t.SyntaxString()), // LiteralStringBacktick
  395. getColor(t.SyntaxString()), // LiteralStringChar
  396. getColor(t.SyntaxString()), // LiteralStringDoc
  397. getColor(t.SyntaxString()), // LiteralStringDouble
  398. getColor(t.SyntaxString()), // LiteralStringEscape
  399. getColor(t.SyntaxString()), // LiteralStringHeredoc
  400. getColor(t.SyntaxString()), // LiteralStringInterpol
  401. getColor(t.SyntaxString()), // LiteralStringOther
  402. getColor(t.SyntaxString()), // LiteralStringRegex
  403. getColor(t.SyntaxString()), // LiteralStringSingle
  404. getColor(t.SyntaxString()), // LiteralStringSymbol
  405. getColor(t.SyntaxNumber()), // LiteralNumber
  406. getColor(t.SyntaxNumber()), // LiteralNumberBin
  407. getColor(t.SyntaxNumber()), // LiteralNumberFloat
  408. getColor(t.SyntaxNumber()), // LiteralNumberHex
  409. getColor(t.SyntaxNumber()), // LiteralNumberInteger
  410. getColor(t.SyntaxNumber()), // LiteralNumberIntegerLong
  411. getColor(t.SyntaxNumber()), // LiteralNumberOct
  412. getColor(t.SyntaxOperator()), // Operator
  413. getColor(t.SyntaxKeyword()), // OperatorWord
  414. getColor(t.SyntaxPunctuation()), // Punctuation
  415. getColor(t.SyntaxComment()), // Comment
  416. getColor(t.SyntaxComment()), // CommentHashbang
  417. getColor(t.SyntaxComment()), // CommentMultiline
  418. getColor(t.SyntaxComment()), // CommentSingle
  419. getColor(t.SyntaxComment()), // CommentSpecial
  420. getColor(t.SyntaxKeyword()), // CommentPreproc
  421. getColor(t.Text()), // Generic
  422. getColor(t.Error()), // GenericDeleted
  423. getColor(t.Text()), // GenericEmph
  424. getColor(t.Error()), // GenericError
  425. getColor(t.Text()), // GenericHeading
  426. getColor(t.Success()), // GenericInserted
  427. getColor(t.TextMuted()), // GenericOutput
  428. getColor(t.Text()), // GenericPrompt
  429. getColor(t.Text()), // GenericStrong
  430. getColor(t.Text()), // GenericSubheading
  431. getColor(t.Error()), // GenericTraceback
  432. getColor(t.Text()), // TextWhitespace
  433. )
  434. r := strings.NewReader(syntaxThemeXml)
  435. style := chroma.MustNewXMLStyle(r)
  436. // Modify the style to use the provided background
  437. s, err := style.Builder().Transform(
  438. func(t chroma.StyleEntry) chroma.StyleEntry {
  439. r, g, b, _ := bg.RGBA()
  440. t.Background = chroma.NewColour(uint8(r>>8), uint8(g>>8), uint8(b>>8))
  441. return t
  442. },
  443. ).Build()
  444. if err != nil {
  445. s = styles.Fallback
  446. }
  447. // Tokenize and format
  448. it, err := l.Tokenise(nil, source)
  449. if err != nil {
  450. return err
  451. }
  452. return f.Format(w, s, it)
  453. }
  454. // getColor returns the appropriate hex color string based on terminal background
  455. func getColor(adaptiveColor lipgloss.AdaptiveColor) string {
  456. if lipgloss.HasDarkBackground() {
  457. return adaptiveColor.Dark
  458. }
  459. return adaptiveColor.Light
  460. }
  461. // highlightLine applies syntax highlighting to a single line
  462. func highlightLine(fileName string, line string, bg lipgloss.TerminalColor) 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 lipgloss.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 lipgloss.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. }