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