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