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/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}