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