diff.go 28 KB

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