diff.go

   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}