diff.go

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