diff.go 25 KB

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