diff.go 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872
  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. // 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. // Reset foreground and background
  576. sb.WriteString("\x1b[39m")
  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. // renderLeftColumn formats the left side of a side-by-side diff
  589. func renderLeftColumn(fileName string, dl *DiffLine, colWidth int) string {
  590. t := theme.CurrentTheme()
  591. if dl == nil {
  592. contextLineStyle := lipgloss.NewStyle().Background(t.DiffContextBg())
  593. return contextLineStyle.Width(colWidth).Render("")
  594. }
  595. removedLineStyle, _, contextLineStyle, lineNumberStyle := createStyles(t)
  596. // Determine line style based on line type
  597. var marker string
  598. var bgStyle lipgloss.Style
  599. switch dl.Kind {
  600. case LineRemoved:
  601. marker = removedLineStyle.Foreground(t.DiffRemoved()).Render("-")
  602. bgStyle = removedLineStyle
  603. lineNumberStyle = lineNumberStyle.Foreground(t.DiffRemoved()).Background(t.DiffRemovedLineNumberBg())
  604. case LineAdded:
  605. marker = "?"
  606. bgStyle = contextLineStyle
  607. case LineContext:
  608. marker = contextLineStyle.Render(" ")
  609. bgStyle = contextLineStyle
  610. }
  611. // Format line number
  612. lineNum := ""
  613. if dl.OldLineNo > 0 {
  614. lineNum = fmt.Sprintf("%6d", dl.OldLineNo)
  615. }
  616. // Create the line prefix
  617. prefix := lineNumberStyle.Render(lineNum + " " + marker)
  618. // Apply syntax highlighting
  619. content := highlightLine(fileName, dl.Content, bgStyle.GetBackground())
  620. // Apply intra-line highlighting for removed lines
  621. if dl.Kind == LineRemoved && len(dl.Segments) > 0 {
  622. content = applyHighlighting(content, dl.Segments, LineRemoved, t.DiffHighlightRemoved())
  623. }
  624. // Add a padding space for removed lines
  625. if dl.Kind == LineRemoved {
  626. content = bgStyle.Render(" ") + content
  627. }
  628. // Create the final line and truncate if needed
  629. lineText := prefix + content
  630. return bgStyle.MaxHeight(1).Width(colWidth).Render(
  631. ansi.Truncate(
  632. lineText,
  633. colWidth,
  634. lipgloss.NewStyle().Background(bgStyle.GetBackground()).Foreground(t.TextMuted()).Render("..."),
  635. ),
  636. )
  637. }
  638. // renderRightColumn formats the right side of a side-by-side diff
  639. func renderRightColumn(fileName string, dl *DiffLine, colWidth int) string {
  640. t := theme.CurrentTheme()
  641. if dl == nil {
  642. contextLineStyle := lipgloss.NewStyle().Background(t.DiffContextBg())
  643. return contextLineStyle.Width(colWidth).Render("")
  644. }
  645. _, addedLineStyle, contextLineStyle, lineNumberStyle := createStyles(t)
  646. // Determine line style based on line type
  647. var marker string
  648. var bgStyle lipgloss.Style
  649. switch dl.Kind {
  650. case LineAdded:
  651. marker = addedLineStyle.Foreground(t.DiffAdded()).Render("+")
  652. bgStyle = addedLineStyle
  653. lineNumberStyle = lineNumberStyle.Foreground(t.DiffAdded()).Background(t.DiffAddedLineNumberBg())
  654. case LineRemoved:
  655. marker = "?"
  656. bgStyle = contextLineStyle
  657. case LineContext:
  658. marker = contextLineStyle.Render(" ")
  659. bgStyle = contextLineStyle
  660. }
  661. // Format line number
  662. lineNum := ""
  663. if dl.NewLineNo > 0 {
  664. lineNum = fmt.Sprintf("%6d", dl.NewLineNo)
  665. }
  666. // Create the line prefix
  667. prefix := lineNumberStyle.Render(lineNum + " " + marker)
  668. // Apply syntax highlighting
  669. content := highlightLine(fileName, dl.Content, bgStyle.GetBackground())
  670. // Apply intra-line highlighting for added lines
  671. if dl.Kind == LineAdded && len(dl.Segments) > 0 {
  672. content = applyHighlighting(content, dl.Segments, LineAdded, t.DiffHighlightAdded())
  673. }
  674. // Add a padding space for added lines
  675. if dl.Kind == LineAdded {
  676. content = bgStyle.Render(" ") + content
  677. }
  678. // Create the final line and truncate if needed
  679. lineText := prefix + content
  680. return bgStyle.MaxHeight(1).Width(colWidth).Render(
  681. ansi.Truncate(
  682. lineText,
  683. colWidth,
  684. lipgloss.NewStyle().Background(bgStyle.GetBackground()).Foreground(t.TextMuted()).Render("..."),
  685. ),
  686. )
  687. }
  688. // -------------------------------------------------------------------------
  689. // Public API
  690. // -------------------------------------------------------------------------
  691. // RenderSideBySideHunk formats a hunk for side-by-side display
  692. func RenderSideBySideHunk(fileName string, h Hunk, opts ...SideBySideOption) string {
  693. // Apply options to create the configuration
  694. config := NewSideBySideConfig(opts...)
  695. // Make a copy of the hunk so we don't modify the original
  696. hunkCopy := Hunk{Lines: make([]DiffLine, len(h.Lines))}
  697. copy(hunkCopy.Lines, h.Lines)
  698. // Highlight changes within lines
  699. HighlightIntralineChanges(&hunkCopy)
  700. // Pair lines for side-by-side display
  701. pairs := pairLines(hunkCopy.Lines)
  702. // Calculate column width
  703. colWidth := config.TotalWidth / 2
  704. leftWidth := colWidth
  705. rightWidth := config.TotalWidth - colWidth
  706. var sb strings.Builder
  707. for _, p := range pairs {
  708. leftStr := renderLeftColumn(fileName, p.left, leftWidth)
  709. rightStr := renderRightColumn(fileName, p.right, rightWidth)
  710. sb.WriteString(leftStr + rightStr + "\n")
  711. }
  712. return sb.String()
  713. }
  714. // FormatDiff creates a side-by-side formatted view of a diff
  715. func FormatDiff(diffText string, opts ...SideBySideOption) (string, error) {
  716. t := theme.CurrentTheme()
  717. diffResult, err := ParseUnifiedDiff(diffText)
  718. if err != nil {
  719. return "", err
  720. }
  721. var sb strings.Builder
  722. config := NewSideBySideConfig(opts...)
  723. for _, h := range diffResult.Hunks {
  724. sb.WriteString(
  725. lipgloss.NewStyle().
  726. Background(t.DiffHunkHeader()).
  727. Foreground(t.Background()).
  728. Width(config.TotalWidth).
  729. Render(h.Header) + "\n",
  730. )
  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. edits := udiff.Strings(beforeContent, afterContent)
  743. unified, _ := udiff.ToUnified("a/"+fileName, "b/"+fileName, beforeContent, edits, 8)
  744. var (
  745. additions = 0
  746. removals = 0
  747. )
  748. lines := strings.SplitSeq(unified, "\n")
  749. for line := range lines {
  750. if strings.HasPrefix(line, "+") && !strings.HasPrefix(line, "+++") {
  751. additions++
  752. } else if strings.HasPrefix(line, "-") && !strings.HasPrefix(line, "---") {
  753. removals++
  754. }
  755. }
  756. return unified, additions, removals
  757. }