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