@@ -0,0 +1,1047 @@
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"
27+// -------------------------------------------------------------------------
29+// -------------------------------------------------------------------------
31+// LineType represents the kind of line in a diff.
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
40+// Segment represents a portion of a line for intra-line highlighting
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
57+// Hunk represents a section of changes in a diff
63+// DiffResult contains the parsed result of a diff
64+type DiffResult struct {
70+// linePair represents a pair of lines for side-by-side display
71+type linePair struct {
76+// -------------------------------------------------------------------------
77+// Style Configuration
78+// -------------------------------------------------------------------------
80+// StyleConfig defines styling for diff rendering
81+type StyleConfig struct {
84+ FileNameFg lipgloss.Color
86+ RemovedLineBg lipgloss.Color
87+ AddedLineBg lipgloss.Color
88+ ContextLineBg lipgloss.Color
89+ HunkLineBg lipgloss.Color
90+ RemovedLineNumberBg lipgloss.Color
91+ AddedLineNamerBg lipgloss.Color
94+ HunkLineFg lipgloss.Color
95+ RemovedFg lipgloss.Color
96+ AddedFg lipgloss.Color
97+ LineNumberFg lipgloss.Color
98+ RemovedHighlightFg lipgloss.Color
99+ AddedHighlightFg lipgloss.Color
101+ // Highlight settings
102+ HighlightStyle string
103+ RemovedHighlightBg lipgloss.Color
104+ AddedHighlightBg lipgloss.Color
107+// StyleOption is a function that modifies a StyleConfig
108+type StyleOption func(*StyleConfig)
110+// NewStyleConfig creates a StyleConfig with default values
111+func NewStyleConfig(opts ...StyleOption) StyleConfig {
112+ // Default color scheme
113+ config := StyleConfig{
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"),
134+ // Apply all provided options
135+ for _, opt := range opts {
142+// Style option functions
143+func WithFileNameFg(color lipgloss.Color) StyleOption {
144+ return func(s *StyleConfig) { s.FileNameFg = color }
147+func WithRemovedLineBg(color lipgloss.Color) StyleOption {
148+ return func(s *StyleConfig) { s.RemovedLineBg = color }
151+func WithAddedLineBg(color lipgloss.Color) StyleOption {
152+ return func(s *StyleConfig) { s.AddedLineBg = color }
155+func WithContextLineBg(color lipgloss.Color) StyleOption {
156+ return func(s *StyleConfig) { s.ContextLineBg = color }
159+func WithRemovedFg(color lipgloss.Color) StyleOption {
160+ return func(s *StyleConfig) { s.RemovedFg = color }
163+func WithAddedFg(color lipgloss.Color) StyleOption {
164+ return func(s *StyleConfig) { s.AddedFg = color }
167+func WithLineNumberFg(color lipgloss.Color) StyleOption {
168+ return func(s *StyleConfig) { s.LineNumberFg = color }
171+func WithHighlightStyle(style string) StyleOption {
172+ return func(s *StyleConfig) { s.HighlightStyle = style }
175+func WithRemovedHighlightColors(bg, fg lipgloss.Color) StyleOption {
176+ return func(s *StyleConfig) {
177+ s.RemovedHighlightBg = bg
178+ s.RemovedHighlightFg = fg
182+func WithAddedHighlightColors(bg, fg lipgloss.Color) StyleOption {
183+ return func(s *StyleConfig) {
184+ s.AddedHighlightBg = bg
185+ s.AddedHighlightFg = fg
189+func WithRemovedLineNumberBg(color lipgloss.Color) StyleOption {
190+ return func(s *StyleConfig) { s.RemovedLineNumberBg = color }
193+func WithAddedLineNumberBg(color lipgloss.Color) StyleOption {
194+ return func(s *StyleConfig) { s.AddedLineNamerBg = color }
197+func WithHunkLineBg(color lipgloss.Color) StyleOption {
198+ return func(s *StyleConfig) { s.HunkLineBg = color }
201+func WithHunkLineFg(color lipgloss.Color) StyleOption {
202+ return func(s *StyleConfig) { s.HunkLineFg = color }
205+func WithShowHeader(show bool) StyleOption {
206+ return func(s *StyleConfig) { s.ShowHeader = show }
209+func WithShowHunkHeader(show bool) StyleOption {
210+ return func(s *StyleConfig) { s.ShowHunkHeader = show }
213+// -------------------------------------------------------------------------
214+// Parse Configuration
215+// -------------------------------------------------------------------------
217+// ParseConfig configures the behavior of diff parsing
218+type ParseConfig struct {
219+ ContextSize int // Number of context lines to include
222+// ParseOption modifies a ParseConfig
223+type ParseOption func(*ParseConfig)
225+// WithContextSize sets the number of context lines to include
226+func WithContextSize(size int) ParseOption {
227+ return func(p *ParseConfig) {
229+ p.ContextSize = size
234+// -------------------------------------------------------------------------
235+// Side-by-Side Configuration
236+// -------------------------------------------------------------------------
238+// SideBySideConfig configures the rendering of side-by-side diffs
239+type SideBySideConfig struct {
244+// SideBySideOption modifies a SideBySideConfig
245+type SideBySideOption func(*SideBySideConfig)
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(),
254+ for _, opt := range opts {
261+// WithTotalWidth sets the total width for side-by-side view
262+func WithTotalWidth(width int) SideBySideOption {
263+ return func(s *SideBySideConfig) {
265+ s.TotalWidth = width
270+// WithStyle sets the styling configuration
271+func WithStyle(style StyleConfig) SideBySideOption {
272+ return func(s *SideBySideConfig) {
277+// WithStyleOptions applies the specified style options
278+func WithStyleOptions(opts ...StyleOption) SideBySideOption {
279+ return func(s *SideBySideConfig) {
280+ s.Style = NewStyleConfig(opts...)
284+// -------------------------------------------------------------------------
286+// -------------------------------------------------------------------------
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
293+ hunkHeaderRe := regexp.MustCompile(`^@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@`)
294+ lines := strings.Split(diff, "\n")
296+ var oldLine, newLine int
297+ inFileHeader := true
299+ for _, line := range lines {
300+ // Parse file headers
302+ if strings.HasPrefix(line, "--- a/") {
303+ result.OldFile = strings.TrimPrefix(line, "--- a/")
306+ if strings.HasPrefix(line, "+++ b/") {
307+ result.NewFile = strings.TrimPrefix(line, "+++ b/")
308+ inFileHeader = false
313+ // Parse hunk headers
314+ if matches := hunkHeaderRe.FindStringSubmatch(line); matches != nil {
315+ if currentHunk != nil {
316+ result.Hunks = append(result.Hunks, *currentHunk)
318+ currentHunk = &Hunk{
320+ Lines: []DiffLine{},
323+ oldStart, _ := strconv.Atoi(matches[1])
324+ newStart, _ := strconv.Atoi(matches[3])
330+ // Ignore "No newline at end of file" markers
331+ if strings.HasPrefix(line, "\\ No newline at end of file") {
335+ if currentHunk == nil {
339+ // Process the line based on its prefix
343+ currentHunk.Lines = append(currentHunk.Lines, DiffLine{
351+ currentHunk.Lines = append(currentHunk.Lines, DiffLine{
359+ currentHunk.Lines = append(currentHunk.Lines, DiffLine{
369+ // Handle empty lines
370+ currentHunk.Lines = append(currentHunk.Lines, DiffLine{
381+ // Add the last hunk if there is one
382+ if currentHunk != nil {
383+ result.Hunks = append(result.Hunks, *currentHunk)
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()
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 {
400+ oldLine := h.Lines[i]
401+ newLine := h.Lines[i+1]
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)
409+ segments := make([]Segment, 0)
413+ for _, patch := range patches {
415+ case diffmatchpatch.DiffDelete:
416+ segments = append(segments, Segment{
418+ End: removeStart + len(patch.Text),
422+ removeStart += len(patch.Text)
423+ case diffmatchpatch.DiffInsert:
424+ segments = append(segments, Segment{
426+ End: addStart + len(patch.Text),
430+ addStart += len(patch.Text)
432+ // Context text, no highlighting needed
433+ removeStart += len(patch.Text)
434+ addStart += len(patch.Text)
437+ oldLine.Segments = segments
438+ newLine.Segments = segments
440+ updated = append(updated, oldLine, newLine)
441+ i++ // Skip the next line as we've already processed it
443+ updated = append(updated, h.Lines[i])
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
455+ for i < len(lines) {
456+ switch lines[i].Kind {
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]})
463+ pairs = append(pairs, linePair{left: &lines[i], right: nil})
467+ pairs = append(pairs, linePair{left: nil, right: &lines[i]})
470+ pairs = append(pairs, linePair{left: &lines[i], right: &lines[i]})
478+// -------------------------------------------------------------------------
479+// Syntax Highlighting
480+// -------------------------------------------------------------------------
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)
487+ l = lexers.Analyse(source)
492+ l = chroma.Coalesce(l)
494+ // Get the formatter
495+ f := formatters.Get(formatter)
497+ f = formatters.Fallback
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"/>
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"/>
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"/>
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"/>
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))
598+ // Tokenize and format
599+ it, err := l.Tokenise(nil, source)
604+ return f.Format(w, s, it)
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)
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)
627+// -------------------------------------------------------------------------
628+// Rendering Functions
629+// -------------------------------------------------------------------------
631+// applyHighlighting applies intra-line highlighting to a piece of text
632+func applyHighlighting(content string, segments []Segment, segmentType LineType, highlightBg lipgloss.Color,
634+ // Find all ANSI sequences in the content
635+ ansiRegex := regexp.MustCompile(`\x1b(?:[@-Z\\-_]|\[[0-9?]*(?:;[0-9?]*)*[@-~])`)
636+ ansiMatches := ansiRegex.FindAllStringIndex(content, -1)
638+ // Build a mapping of visible character positions to their actual indices
640+ ansiSequences := make(map[int]string)
641+ lastAnsiSeq := "\x1b[0m" // Default reset sequence
643+ for i := 0; i < len(content); {
645+ for _, match := range ansiMatches {
647+ ansiSequences[visibleIdx] = content[match[0]:match[1]]
648+ lastAnsiSeq = content[match[0]:match[1]]
658+ // For non-ANSI positions, store the last ANSI sequence
659+ if _, exists := ansiSequences[visibleIdx]; !exists {
660+ ansiSequences[visibleIdx] = lastAnsiSeq
666+ // Apply highlighting
667+ var sb strings.Builder
668+ inSelection := false
671+ for i := 0; i < len(content); {
672+ // Check if we're at an ANSI sequence
674+ for _, match := range ansiMatches {
676+ sb.WriteString(content[match[0]:match[1]]) // Preserve ANSI sequence
686+ // Check for segment boundaries
687+ for _, seg := range segments {
688+ if seg.Type == segmentType {
689+ if currentPos == seg.Start {
692+ if currentPos == seg.End {
698+ // Get current character
699+ char := string(content[i])
702+ // Get the current styling
703+ currentStyle := ansiSequences[currentPos]
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
712+ // Reapply the original ANSI sequence
713+ sb.WriteString(currentStyle)
715+ // Not in selection, just copy the character
716+ sb.WriteString(char)
726+// renderLeftColumn formats the left side of a side-by-side diff
727+func renderLeftColumn(fileName string, dl *DiffLine, colWidth int, styles StyleConfig) string {
729+ contextLineStyle := lipgloss.NewStyle().Background(styles.ContextLineBg)
730+ return contextLineStyle.Width(colWidth).Render("")
733+ removedLineStyle, _, contextLineStyle, lineNumberStyle := createStyles(styles)
735+ // Determine line style based on line type
737+ var bgStyle lipgloss.Style
740+ marker = removedLineStyle.Foreground(styles.RemovedFg).Render("-")
741+ bgStyle = removedLineStyle
742+ lineNumberStyle = lineNumberStyle.Foreground(styles.RemovedFg).Background(styles.RemovedLineNumberBg)
745+ bgStyle = contextLineStyle
747+ marker = contextLineStyle.Render(" ")
748+ bgStyle = contextLineStyle
751+ // Format line number
753+ if dl.OldLineNo > 0 {
754+ lineNum = fmt.Sprintf("%6d", dl.OldLineNo)
757+ // Create the line prefix
758+ prefix := lineNumberStyle.Render(lineNum + " " + marker)
760+ // Apply syntax highlighting
761+ content := highlightLine(fileName, dl.Content, bgStyle.GetBackground())
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)
768+ // Add a padding space for removed lines
769+ if dl.Kind == LineRemoved {
770+ content = bgStyle.Render(" ") + content
773+ // Create the final line and truncate if needed
774+ lineText := prefix + content
775+ return bgStyle.MaxHeight(1).Width(colWidth).Render(
779+ lipgloss.NewStyle().Background(styles.HunkLineBg).Foreground(styles.HunkLineFg).Render("..."),
784+// renderRightColumn formats the right side of a side-by-side diff
785+func renderRightColumn(fileName string, dl *DiffLine, colWidth int, styles StyleConfig) string {
787+ contextLineStyle := lipgloss.NewStyle().Background(styles.ContextLineBg)
788+ return contextLineStyle.Width(colWidth).Render("")
791+ _, addedLineStyle, contextLineStyle, lineNumberStyle := createStyles(styles)
793+ // Determine line style based on line type
795+ var bgStyle lipgloss.Style
798+ marker = addedLineStyle.Foreground(styles.AddedFg).Render("+")
799+ bgStyle = addedLineStyle
800+ lineNumberStyle = lineNumberStyle.Foreground(styles.AddedFg).Background(styles.AddedLineNamerBg)
803+ bgStyle = contextLineStyle
805+ marker = contextLineStyle.Render(" ")
806+ bgStyle = contextLineStyle
809+ // Format line number
811+ if dl.NewLineNo > 0 {
812+ lineNum = fmt.Sprintf("%6d", dl.NewLineNo)
815+ // Create the line prefix
816+ prefix := lineNumberStyle.Render(lineNum + " " + marker)
818+ // Apply syntax highlighting
819+ content := highlightLine(fileName, dl.Content, bgStyle.GetBackground())
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)
826+ // Add a padding space for added lines
827+ if dl.Kind == LineAdded {
828+ content = bgStyle.Render(" ") + content
831+ // Create the final line and truncate if needed
832+ lineText := prefix + content
833+ return bgStyle.MaxHeight(1).Width(colWidth).Render(
837+ lipgloss.NewStyle().Background(styles.HunkLineBg).Foreground(styles.HunkLineFg).Render("..."),
842+// -------------------------------------------------------------------------
844+// -------------------------------------------------------------------------
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...)
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)
855+ // Highlight changes within lines
856+ HighlightIntralineChanges(&hunkCopy, config.Style)
858+ // Pair lines for side-by-side display
859+ pairs := pairLines(hunkCopy.Lines)
861+ // Calculate column width
862+ colWidth := config.TotalWidth / 2
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")
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)
883+ var sb strings.Builder
884+ config := NewSideBySideConfig(opts...)
886+ if config.Style.ShowHeader {
887+ removeIcon := lipgloss.NewStyle().
888+ Background(config.Style.RemovedLineBg).
889+ Foreground(config.Style.RemovedFg).
891+ addIcon := lipgloss.NewStyle().
892+ Background(config.Style.AddedLineBg).
893+ Foreground(config.Style.AddedFg).
896+ fileName := lipgloss.NewStyle().
897+ Background(config.Style.ContextLineBg).
898+ Foreground(config.Style.FileNameFg).
899+ Render(" " + diffResult.OldFile)
901+ lipgloss.NewStyle().
902+ Background(config.Style.ContextLineBg).
903+ Padding(0, 1, 0, 1).
904+ Foreground(config.Style.FileNameFg).
905+ BorderStyle(lipgloss.NormalBorder()).
908+ BorderForeground(config.Style.FileNameFg).
909+ BorderBackground(config.Style.ContextLineBg).
910+ Width(config.TotalWidth).
912+ lipgloss.JoinHorizontal(lipgloss.Top,
921+ for _, h := range diffResult.Hunks {
922+ // Render hunk header
923+ if config.Style.ShowHunkHeader {
925+ lipgloss.NewStyle().
926+ Background(config.Style.HunkLineBg).
927+ Foreground(config.Style.HunkLineFg).
928+ Width(config.TotalWidth).
929+ Render(h.Header) + "\n",
932+ sb.WriteString(RenderSideBySideHunk(diffResult.OldFile, h, opts...))
935+ return sb.String(), nil
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()))
948+ logging.Error("Failed to create temp directory for git diff", "error", err)
951+ defer os.RemoveAll(tempDir)
953+ // Initialize git repo
954+ repo, err := git.PlainInit(tempDir, false)
956+ logging.Error("Failed to initialize git repository", "error", err)
960+ wt, err := repo.Worktree()
962+ logging.Error("Failed to get git worktree", "error", err)
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)
972+ if err = os.WriteFile(fullPath, []byte(beforeContent), 0o644); err != nil {
973+ logging.Error("Failed to write before content to file", "error", err)
977+ _, err = wt.Add(fileName)
979+ logging.Error("Failed to add file to git", "error", err)
983+ beforeCommit, err := wt.Commit("Before", &git.CommitOptions{
984+ Author: &object.Signature{
986+ Email: "coder@opencode.ai",
991+ logging.Error("Failed to commit before content", "error", err)
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)
1001+ _, err = wt.Add(fileName)
1003+ logging.Error("Failed to add file to git", "error", err)
1007+ afterCommit, err := wt.Commit("After", &git.CommitOptions{
1008+ Author: &object.Signature{
1010+ Email: "coder@opencode.ai",
1015+ logging.Error("Failed to commit after content", "error", err)
1019+ // Get the diff between the two commits
1020+ beforeCommitObj, err := repo.CommitObject(beforeCommit)
1022+ logging.Error("Failed to get before commit object", "error", err)
1026+ afterCommitObj, err := repo.CommitObject(afterCommit)
1028+ logging.Error("Failed to get after commit object", "error", err)
1032+ patch, err := beforeCommitObj.Patch(afterCommitObj)
1034+ logging.Error("Failed to create git diff patch", "error", err)
1038+ // Count additions and removals
1041+ for _, fileStat := range patch.Stats() {
1042+ additions += fileStat.Addition
1043+ removals += fileStat.Deletion
1046+ return patch.String(), additions, removals