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