diff.go 28 KB

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