diff.go

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