2
0

diff.go 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873
  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/opencode-ai/opencode/internal/config"
  17. "github.com/opencode-ai/opencode/internal/tui/theme"
  18. "github.com/sergi/go-diff/diffmatchpatch"
  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. func lipglossToHex(color lipgloss.Color) string {
  501. r, g, b, a := color.RGBA()
  502. // Scale uint32 values (0-65535) to uint8 (0-255).
  503. r8 := uint8(r >> 8)
  504. g8 := uint8(g >> 8)
  505. b8 := uint8(b >> 8)
  506. a8 := uint8(a >> 8)
  507. return fmt.Sprintf("#%02x%02x%02x%02x", r8, g8, b8, a8)
  508. }
  509. // applyHighlighting applies intra-line highlighting to a piece of text
  510. func applyHighlighting(content string, segments []Segment, segmentType LineType, highlightBg lipgloss.AdaptiveColor) string {
  511. // Find all ANSI sequences in the content
  512. ansiRegex := regexp.MustCompile(`\x1b(?:[@-Z\\-_]|\[[0-9?]*(?:;[0-9?]*)*[@-~])`)
  513. ansiMatches := ansiRegex.FindAllStringIndex(content, -1)
  514. // Build a mapping of visible character positions to their actual indices
  515. visibleIdx := 0
  516. ansiSequences := make(map[int]string)
  517. lastAnsiSeq := "\x1b[0m" // Default reset sequence
  518. for i := 0; i < len(content); {
  519. isAnsi := false
  520. for _, match := range ansiMatches {
  521. if match[0] == i {
  522. ansiSequences[visibleIdx] = content[match[0]:match[1]]
  523. lastAnsiSeq = content[match[0]:match[1]]
  524. i = match[1]
  525. isAnsi = true
  526. break
  527. }
  528. }
  529. if isAnsi {
  530. continue
  531. }
  532. // For non-ANSI positions, store the last ANSI sequence
  533. if _, exists := ansiSequences[visibleIdx]; !exists {
  534. ansiSequences[visibleIdx] = lastAnsiSeq
  535. }
  536. visibleIdx++
  537. i++
  538. }
  539. // Apply highlighting
  540. var sb strings.Builder
  541. inSelection := false
  542. currentPos := 0
  543. // Get the appropriate color based on terminal background
  544. bgColor := lipgloss.Color(getColor(highlightBg))
  545. fgColor := lipgloss.Color(getColor(theme.CurrentTheme().Background()))
  546. for i := 0; i < len(content); {
  547. // Check if we're at an ANSI sequence
  548. isAnsi := false
  549. for _, match := range ansiMatches {
  550. if match[0] == i {
  551. sb.WriteString(content[match[0]:match[1]]) // Preserve ANSI sequence
  552. i = match[1]
  553. isAnsi = true
  554. break
  555. }
  556. }
  557. if isAnsi {
  558. continue
  559. }
  560. // Check for segment boundaries
  561. for _, seg := range segments {
  562. if seg.Type == segmentType {
  563. if currentPos == seg.Start {
  564. inSelection = true
  565. }
  566. if currentPos == seg.End {
  567. inSelection = false
  568. }
  569. }
  570. }
  571. // Get current character
  572. char := string(content[i])
  573. if inSelection {
  574. // Get the current styling
  575. currentStyle := ansiSequences[currentPos]
  576. // Apply foreground and background highlight
  577. sb.WriteString("\x1b[38;2;")
  578. r, g, b, _ := fgColor.RGBA()
  579. sb.WriteString(fmt.Sprintf("%d;%d;%dm", r>>8, g>>8, b>>8))
  580. sb.WriteString("\x1b[48;2;")
  581. r, g, b, _ = bgColor.RGBA()
  582. sb.WriteString(fmt.Sprintf("%d;%d;%dm", r>>8, g>>8, b>>8))
  583. sb.WriteString(char)
  584. // Reset foreground and background
  585. sb.WriteString("\x1b[39m")
  586. // Reapply the original ANSI sequence
  587. sb.WriteString(currentStyle)
  588. } else {
  589. // Not in selection, just copy the character
  590. sb.WriteString(char)
  591. }
  592. currentPos++
  593. i++
  594. }
  595. return sb.String()
  596. }
  597. // renderLeftColumn formats the left side of a side-by-side diff
  598. func renderLeftColumn(fileName string, dl *DiffLine, colWidth int) string {
  599. t := theme.CurrentTheme()
  600. if dl == nil {
  601. contextLineStyle := lipgloss.NewStyle().Background(t.DiffContextBg())
  602. return contextLineStyle.Width(colWidth).Render("")
  603. }
  604. removedLineStyle, _, contextLineStyle, lineNumberStyle := createStyles(t)
  605. // Determine line style based on line type
  606. var marker string
  607. var bgStyle lipgloss.Style
  608. switch dl.Kind {
  609. case LineRemoved:
  610. marker = removedLineStyle.Foreground(t.DiffRemoved()).Render("-")
  611. bgStyle = removedLineStyle
  612. lineNumberStyle = lineNumberStyle.Foreground(t.DiffRemoved()).Background(t.DiffRemovedLineNumberBg())
  613. case LineAdded:
  614. marker = "?"
  615. bgStyle = contextLineStyle
  616. case LineContext:
  617. marker = contextLineStyle.Render(" ")
  618. bgStyle = contextLineStyle
  619. }
  620. // Format line number
  621. lineNum := ""
  622. if dl.OldLineNo > 0 {
  623. lineNum = fmt.Sprintf("%6d", dl.OldLineNo)
  624. }
  625. // Create the line prefix
  626. prefix := lineNumberStyle.Render(lineNum + " " + marker)
  627. // Apply syntax highlighting
  628. content := highlightLine(fileName, dl.Content, bgStyle.GetBackground())
  629. // Apply intra-line highlighting for removed lines
  630. if dl.Kind == LineRemoved && len(dl.Segments) > 0 {
  631. content = applyHighlighting(content, dl.Segments, LineRemoved, t.DiffHighlightRemoved())
  632. }
  633. // Add a padding space for removed lines
  634. if dl.Kind == LineRemoved {
  635. content = bgStyle.Render(" ") + content
  636. }
  637. // Create the final line and truncate if needed
  638. lineText := prefix + content
  639. return bgStyle.MaxHeight(1).Width(colWidth).Render(
  640. ansi.Truncate(
  641. lineText,
  642. colWidth,
  643. lipgloss.NewStyle().Background(bgStyle.GetBackground()).Foreground(t.TextMuted()).Render("..."),
  644. ),
  645. )
  646. }
  647. // renderRightColumn formats the right side of a side-by-side diff
  648. func renderRightColumn(fileName string, dl *DiffLine, colWidth int) string {
  649. t := theme.CurrentTheme()
  650. if dl == nil {
  651. contextLineStyle := lipgloss.NewStyle().Background(t.DiffContextBg())
  652. return contextLineStyle.Width(colWidth).Render("")
  653. }
  654. _, addedLineStyle, contextLineStyle, lineNumberStyle := createStyles(t)
  655. // Determine line style based on line type
  656. var marker string
  657. var bgStyle lipgloss.Style
  658. switch dl.Kind {
  659. case LineAdded:
  660. marker = addedLineStyle.Foreground(t.DiffAdded()).Render("+")
  661. bgStyle = addedLineStyle
  662. lineNumberStyle = lineNumberStyle.Foreground(t.DiffAdded()).Background(t.DiffAddedLineNumberBg())
  663. case LineRemoved:
  664. marker = "?"
  665. bgStyle = contextLineStyle
  666. case LineContext:
  667. marker = contextLineStyle.Render(" ")
  668. bgStyle = contextLineStyle
  669. }
  670. // Format line number
  671. lineNum := ""
  672. if dl.NewLineNo > 0 {
  673. lineNum = fmt.Sprintf("%6d", dl.NewLineNo)
  674. }
  675. // Create the line prefix
  676. prefix := lineNumberStyle.Render(lineNum + " " + marker)
  677. // Apply syntax highlighting
  678. content := highlightLine(fileName, dl.Content, bgStyle.GetBackground())
  679. // Apply intra-line highlighting for added lines
  680. if dl.Kind == LineAdded && len(dl.Segments) > 0 {
  681. content = applyHighlighting(content, dl.Segments, LineAdded, t.DiffHighlightAdded())
  682. }
  683. // Add a padding space for added lines
  684. if dl.Kind == LineAdded {
  685. content = bgStyle.Render(" ") + content
  686. }
  687. // Create the final line and truncate if needed
  688. lineText := prefix + content
  689. return bgStyle.MaxHeight(1).Width(colWidth).Render(
  690. ansi.Truncate(
  691. lineText,
  692. colWidth,
  693. lipgloss.NewStyle().Background(bgStyle.GetBackground()).Foreground(t.TextMuted()).Render("..."),
  694. ),
  695. )
  696. }
  697. // -------------------------------------------------------------------------
  698. // Public API
  699. // -------------------------------------------------------------------------
  700. // RenderSideBySideHunk formats a hunk for side-by-side display
  701. func RenderSideBySideHunk(fileName string, h Hunk, opts ...SideBySideOption) string {
  702. // Apply options to create the configuration
  703. config := NewSideBySideConfig(opts...)
  704. // Make a copy of the hunk so we don't modify the original
  705. hunkCopy := Hunk{Lines: make([]DiffLine, len(h.Lines))}
  706. copy(hunkCopy.Lines, h.Lines)
  707. // Highlight changes within lines
  708. HighlightIntralineChanges(&hunkCopy)
  709. // Pair lines for side-by-side display
  710. pairs := pairLines(hunkCopy.Lines)
  711. // Calculate column width
  712. colWidth := config.TotalWidth / 2
  713. leftWidth := colWidth
  714. rightWidth := config.TotalWidth - colWidth
  715. var sb strings.Builder
  716. for _, p := range pairs {
  717. leftStr := renderLeftColumn(fileName, p.left, leftWidth)
  718. rightStr := renderRightColumn(fileName, p.right, rightWidth)
  719. sb.WriteString(leftStr + rightStr + "\n")
  720. }
  721. return sb.String()
  722. }
  723. // FormatDiff creates a side-by-side formatted view of a diff
  724. func FormatDiff(diffText string, opts ...SideBySideOption) (string, error) {
  725. diffResult, err := ParseUnifiedDiff(diffText)
  726. if err != nil {
  727. return "", err
  728. }
  729. var sb strings.Builder
  730. for _, h := range diffResult.Hunks {
  731. sb.WriteString(RenderSideBySideHunk(diffResult.OldFile, h, opts...))
  732. }
  733. return sb.String(), nil
  734. }
  735. // GenerateDiff creates a unified diff from two file contents
  736. func GenerateDiff(beforeContent, afterContent, fileName string) (string, int, int) {
  737. // remove the cwd prefix and ensure consistent path format
  738. // this prevents issues with absolute paths in different environments
  739. cwd := config.WorkingDirectory()
  740. fileName = strings.TrimPrefix(fileName, cwd)
  741. fileName = strings.TrimPrefix(fileName, "/")
  742. var (
  743. unified = udiff.Unified("a/"+fileName, "b/"+fileName, beforeContent, afterContent)
  744. additions = 0
  745. removals = 0
  746. )
  747. lines := strings.SplitSeq(unified, "\n")
  748. for line := range lines {
  749. if strings.HasPrefix(line, "+") && !strings.HasPrefix(line, "+++") {
  750. additions++
  751. } else if strings.HasPrefix(line, "-") && !strings.HasPrefix(line, "---") {
  752. removals++
  753. }
  754. }
  755. return unified, additions, removals
  756. }