@@ -0,0 +1,1047 @@
1+package diff
2+
3+import (
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.
32+type LineType int
33+
34+const (
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
41+type 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
49+type 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
58+type Hunk struct {
59+ Header string
60+ Lines []DiffLine
61+}
62+
63+// DiffResult contains the parsed result of a diff
64+type 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
71+type linePair struct {
72+ left *DiffLine
73+ right *DiffLine
74+}
75+
76+// -------------------------------------------------------------------------
77+// Style Configuration
78+// -------------------------------------------------------------------------
79+
80+// StyleConfig defines styling for diff rendering
81+type 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
108+type StyleOption func(*StyleConfig)
109+
110+// NewStyleConfig creates a StyleConfig with default values
111+func 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
143+func WithFileNameFg(color lipgloss.Color) StyleOption {
144+ return func(s *StyleConfig) { s.FileNameFg = color }
145+}
146+
147+func WithRemovedLineBg(color lipgloss.Color) StyleOption {
148+ return func(s *StyleConfig) { s.RemovedLineBg = color }
149+}
150+
151+func WithAddedLineBg(color lipgloss.Color) StyleOption {
152+ return func(s *StyleConfig) { s.AddedLineBg = color }
153+}
154+
155+func WithContextLineBg(color lipgloss.Color) StyleOption {
156+ return func(s *StyleConfig) { s.ContextLineBg = color }
157+}
158+
159+func WithRemovedFg(color lipgloss.Color) StyleOption {
160+ return func(s *StyleConfig) { s.RemovedFg = color }
161+}
162+
163+func WithAddedFg(color lipgloss.Color) StyleOption {
164+ return func(s *StyleConfig) { s.AddedFg = color }
165+}
166+
167+func WithLineNumberFg(color lipgloss.Color) StyleOption {
168+ return func(s *StyleConfig) { s.LineNumberFg = color }
169+}
170+
171+func WithHighlightStyle(style string) StyleOption {
172+ return func(s *StyleConfig) { s.HighlightStyle = style }
173+}
174+
175+func WithRemovedHighlightColors(bg, fg lipgloss.Color) StyleOption {
176+ return func(s *StyleConfig) {
177+ s.RemovedHighlightBg = bg
178+ s.RemovedHighlightFg = fg
179+ }
180+}
181+
182+func WithAddedHighlightColors(bg, fg lipgloss.Color) StyleOption {
183+ return func(s *StyleConfig) {
184+ s.AddedHighlightBg = bg
185+ s.AddedHighlightFg = fg
186+ }
187+}
188+
189+func WithRemovedLineNumberBg(color lipgloss.Color) StyleOption {
190+ return func(s *StyleConfig) { s.RemovedLineNumberBg = color }
191+}
192+
193+func WithAddedLineNumberBg(color lipgloss.Color) StyleOption {
194+ return func(s *StyleConfig) { s.AddedLineNamerBg = color }
195+}
196+
197+func WithHunkLineBg(color lipgloss.Color) StyleOption {
198+ return func(s *StyleConfig) { s.HunkLineBg = color }
199+}
200+
201+func WithHunkLineFg(color lipgloss.Color) StyleOption {
202+ return func(s *StyleConfig) { s.HunkLineFg = color }
203+}
204+
205+func WithShowHeader(show bool) StyleOption {
206+ return func(s *StyleConfig) { s.ShowHeader = show }
207+}
208+
209+func 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
218+type ParseConfig struct {
219+ ContextSize int // Number of context lines to include
220+}
221+
222+// ParseOption modifies a ParseConfig
223+type ParseOption func(*ParseConfig)
224+
225+// WithContextSize sets the number of context lines to include
226+func 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
239+type SideBySideConfig struct {
240+ TotalWidth int
241+ Style StyleConfig
242+}
243+
244+// SideBySideOption modifies a SideBySideConfig
245+type SideBySideOption func(*SideBySideConfig)
246+
247+// NewSideBySideConfig creates a SideBySideConfig with default values
248+func 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
262+func 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
271+func WithStyle(style StyleConfig) SideBySideOption {
272+ return func(s *SideBySideConfig) {
273+ s.Style = style
274+ }
275+}
276+
277+// WithStyleOptions applies the specified style options
278+func 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
289+func 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
390+func 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
451+func 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
483+func 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
608+func 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
618+func 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
632+func 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
727+func 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
785+func 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
847+func 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
877+func 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
939+func 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+}