on 333ea6e help sign in
1047 lines 30 kB history raw
1package diff
3import (
4 "bytes"
5 "fmt"
6 "io"
7 "os"
8 "path/filepath"
9 "regexp"
10 "strconv"
11 "strings"
12 "time"
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// -------------------------------------------------------------------------
28// Core Types
29// -------------------------------------------------------------------------
31// LineType represents the kind of line in a diff.
32type LineType int
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
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
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
57// Hunk represents a section of changes in a diff
58type Hunk struct {
59 Header string
60 Lines []DiffLine
63// DiffResult contains the parsed result of a diff
64type DiffResult struct {
65 OldFile string
66 NewFile string
67 Hunks []Hunk
70// linePair represents a pair of lines for side-by-side display
71type linePair struct {
72 left *DiffLine
73 right *DiffLine
76// -------------------------------------------------------------------------
77// Style Configuration
78// -------------------------------------------------------------------------
80// StyleConfig defines styling for diff rendering
81type StyleConfig struct {
82 ShowHeader bool
83 ShowHunkHeader bool
84 FileNameFg lipgloss.Color
85 // Background colors
86 RemovedLineBg lipgloss.Color
87 AddedLineBg lipgloss.Color
88 ContextLineBg lipgloss.Color
89 HunkLineBg lipgloss.Color
90 RemovedLineNumberBg lipgloss.Color
91 AddedLineNamerBg lipgloss.Color
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
101 // Highlight settings
102 HighlightStyle string
103 RemovedHighlightBg lipgloss.Color
104 AddedHighlightBg lipgloss.Color
107// StyleOption is a function that modifies a StyleConfig
108type StyleOption func(*StyleConfig)
110// NewStyleConfig creates a StyleConfig with default values
111func NewStyleConfig(opts ...StyleOption) StyleConfig {
112 // Default color scheme
113 config := StyleConfig{
114 ShowHeader: true,
115 ShowHunkHeader: true,
116 FileNameFg: lipgloss.Color("#a0a0a0"),
117 RemovedLineBg: lipgloss.Color("#3A3030"),
118 AddedLineBg: lipgloss.Color("#303A30"),
119 ContextLineBg: lipgloss.Color("#212121"),
120 HunkLineBg: lipgloss.Color("#212121"),
121 HunkLineFg: lipgloss.Color("#a0a0a0"),
122 RemovedFg: lipgloss.Color("#7C4444"),
123 AddedFg: lipgloss.Color("#478247"),
124 LineNumberFg: lipgloss.Color("#888888"),
125 HighlightStyle: "dracula",
126 RemovedHighlightBg: lipgloss.Color("#612726"),
127 AddedHighlightBg: lipgloss.Color("#256125"),
128 RemovedLineNumberBg: lipgloss.Color("#332929"),
129 AddedLineNamerBg: lipgloss.Color("#293229"),
130 RemovedHighlightFg: lipgloss.Color("#FADADD"),
131 AddedHighlightFg: lipgloss.Color("#DAFADA"),
132 }
134 // Apply all provided options
135 for _, opt := range opts {
136 opt(&config)
137 }
139 return config
142// Style option functions
143func WithFileNameFg(color lipgloss.Color) StyleOption {
144 return func(s *StyleConfig) { s.FileNameFg = color }
147func WithRemovedLineBg(color lipgloss.Color) StyleOption {
148 return func(s *StyleConfig) { s.RemovedLineBg = color }
151func WithAddedLineBg(color lipgloss.Color) StyleOption {
152 return func(s *StyleConfig) { s.AddedLineBg = color }
155func WithContextLineBg(color lipgloss.Color) StyleOption {
156 return func(s *StyleConfig) { s.ContextLineBg = color }
159func WithRemovedFg(color lipgloss.Color) StyleOption {
160 return func(s *StyleConfig) { s.RemovedFg = color }
163func WithAddedFg(color lipgloss.Color) StyleOption {
164 return func(s *StyleConfig) { s.AddedFg = color }
167func WithLineNumberFg(color lipgloss.Color) StyleOption {
168 return func(s *StyleConfig) { s.LineNumberFg = color }
171func WithHighlightStyle(style string) StyleOption {
172 return func(s *StyleConfig) { s.HighlightStyle = style }
175func WithRemovedHighlightColors(bg, fg lipgloss.Color) StyleOption {
176 return func(s *StyleConfig) {
177 s.RemovedHighlightBg = bg
178 s.RemovedHighlightFg = fg
179 }
182func WithAddedHighlightColors(bg, fg lipgloss.Color) StyleOption {
183 return func(s *StyleConfig) {
184 s.AddedHighlightBg = bg
185 s.AddedHighlightFg = fg
186 }
189func WithRemovedLineNumberBg(color lipgloss.Color) StyleOption {
190 return func(s *StyleConfig) { s.RemovedLineNumberBg = color }
193func WithAddedLineNumberBg(color lipgloss.Color) StyleOption {
194 return func(s *StyleConfig) { s.AddedLineNamerBg = color }
197func WithHunkLineBg(color lipgloss.Color) StyleOption {
198 return func(s *StyleConfig) { s.HunkLineBg = color }
201func WithHunkLineFg(color lipgloss.Color) StyleOption {
202 return func(s *StyleConfig) { s.HunkLineFg = color }
205func WithShowHeader(show bool) StyleOption {
206 return func(s *StyleConfig) { s.ShowHeader = show }
209func 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
218type ParseConfig struct {
219 ContextSize int // Number of context lines to include
222// ParseOption modifies a ParseConfig
223type ParseOption func(*ParseConfig)
225// WithContextSize sets the number of context lines to include
226func WithContextSize(size int) ParseOption {
227 return func(p *ParseConfig) {
228 if size >= 0 {
229 p.ContextSize = size
230 }
231 }
234// -------------------------------------------------------------------------
235// Side-by-Side Configuration
236// -------------------------------------------------------------------------
238// SideBySideConfig configures the rendering of side-by-side diffs
239type SideBySideConfig struct {
240 TotalWidth int
241 Style StyleConfig
244// SideBySideOption modifies a SideBySideConfig
245type SideBySideOption func(*SideBySideConfig)
247// NewSideBySideConfig creates a SideBySideConfig with default values
248func NewSideBySideConfig(opts ...SideBySideOption) SideBySideConfig {
249 config := SideBySideConfig{
250 TotalWidth: 160, // Default width for side-by-side view
251 Style: NewStyleConfig(),
252 }
254 for _, opt := range opts {
255 opt(&config)
256 }
258 return config
261// WithTotalWidth sets the total width for side-by-side view
262func WithTotalWidth(width int) SideBySideOption {
263 return func(s *SideBySideConfig) {
264 if width > 0 {
265 s.TotalWidth = width
266 }
267 }
270// WithStyle sets the styling configuration
271func WithStyle(style StyleConfig) SideBySideOption {
272 return func(s *SideBySideConfig) {
273 s.Style = style
274 }
277// WithStyleOptions applies the specified style options
278func WithStyleOptions(opts ...StyleOption) SideBySideOption {
279 return func(s *SideBySideConfig) {
280 s.Style = NewStyleConfig(opts...)
281 }
284// -------------------------------------------------------------------------
285// Diff Parsing
286// -------------------------------------------------------------------------
288// ParseUnifiedDiff parses a unified diff format string into structured data
289func ParseUnifiedDiff(diff string) (DiffResult, error) {
290 var result DiffResult
291 var currentHunk *Hunk
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
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 }
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 }
323 oldStart, _ := strconv.Atoi(matches[1])
324 newStart, _ := strconv.Atoi(matches[3])
325 oldLine = oldStart
326 newLine = newStart
327 continue
328 }
330 // Ignore "No newline at end of file" markers
331 if strings.HasPrefix(line, "\\ No newline at end of file") {
332 continue
333 }
335 if currentHunk == nil {
336 continue
337 }
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 }
381 // Add the last hunk if there is one
382 if currentHunk != nil {
383 result.Hunks = append(result.Hunks, *currentHunk)
384 }
386 return result, nil
389// HighlightIntralineChanges updates lines in a hunk to show character-level differences
390func HighlightIntralineChanges(h *Hunk, style StyleConfig) {
391 var updated []DiffLine
392 dmp := diffmatchpatch.New()
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)
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
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 }
447 h.Lines = updated
450// pairLines converts a flat list of diff lines to pairs for side-by-side display
451func pairLines(lines []DiffLine) []linePair {
452 var pairs []linePair
453 i := 0
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 }
475 return pairs
478// -------------------------------------------------------------------------
479// Syntax Highlighting
480// -------------------------------------------------------------------------
482// SyntaxHighlight applies syntax highlighting to text based on file extension
483func SyntaxHighlight(w io.Writer, source, fileName, formatter string, bg lipgloss.TerminalColor) error {
484 // Determine the language lexer to use
485 l := lexers.Match(fileName)
486 if l == nil {
487 l = lexers.Analyse(source)
488 }
489 if l == nil {
490 l = lexers.Fallback
491 }
492 l = chroma.Coalesce(l)
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>
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 }
598 // Tokenize and format
599 it, err := l.Tokenise(nil, source)
600 if err != nil {
601 return err
602 }
604 return f.Format(w, s, it)
607// highlightLine applies syntax highlighting to a single line
608func highlightLine(fileName string, line string, bg lipgloss.TerminalColor) string {
609 var buf bytes.Buffer
610 err := SyntaxHighlight(&buf, line, fileName, "terminal16m", bg)
611 if err != nil {
612 return line
613 }
614 return buf.String()
617// createStyles generates the lipgloss styles needed for rendering diffs
618func createStyles(config StyleConfig) (removedLineStyle, addedLineStyle, contextLineStyle, lineNumberStyle lipgloss.Style) {
619 removedLineStyle = lipgloss.NewStyle().Background(config.RemovedLineBg)
620 addedLineStyle = lipgloss.NewStyle().Background(config.AddedLineBg)
621 contextLineStyle = lipgloss.NewStyle().Background(config.ContextLineBg)
622 lineNumberStyle = lipgloss.NewStyle().Foreground(config.LineNumberFg)
624 return
627// -------------------------------------------------------------------------
628// Rendering Functions
629// -------------------------------------------------------------------------
631// applyHighlighting applies intra-line highlighting to a piece of text
632func applyHighlighting(content string, segments []Segment, segmentType LineType, highlightBg lipgloss.Color,
633) string {
634 // Find all ANSI sequences in the content
635 ansiRegex := regexp.MustCompile(`\x1b(?:[@-Z\\-_]|\[[0-9?]*(?:;[0-9?]*)*[@-~])`)
636 ansiMatches := ansiRegex.FindAllStringIndex(content, -1)
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
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 }
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 }
666 // Apply highlighting
667 var sb strings.Builder
668 inSelection := false
669 currentPos := 0
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 }
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 }
698 // Get current character
699 char := string(content[i])
701 if inSelection {
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)
714 } else {
715 // Not in selection, just copy the character
716 sb.WriteString(char)
717 }
719 currentPos++
720 i++
721 }
723 return sb.String()
726// renderLeftColumn formats the left side of a side-by-side diff
727func renderLeftColumn(fileName string, dl *DiffLine, colWidth int, styles StyleConfig) string {
728 if dl == nil {
729 contextLineStyle := lipgloss.NewStyle().Background(styles.ContextLineBg)
730 return contextLineStyle.Width(colWidth).Render("")
731 }
733 removedLineStyle, _, contextLineStyle, lineNumberStyle := createStyles(styles)
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 }
751 // Format line number
752 lineNum := ""
753 if dl.OldLineNo > 0 {
754 lineNum = fmt.Sprintf("%6d", dl.OldLineNo)
755 }
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)
766 }
768 // Add a padding space for removed lines
769 if dl.Kind == LineRemoved {
770 content = bgStyle.Render(" ") + content
771 }
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 )
784// renderRightColumn formats the right side of a side-by-side diff
785func renderRightColumn(fileName string, dl *DiffLine, colWidth int, styles StyleConfig) string {
786 if dl == nil {
787 contextLineStyle := lipgloss.NewStyle().Background(styles.ContextLineBg)
788 return contextLineStyle.Width(colWidth).Render("")
789 }
791 _, addedLineStyle, contextLineStyle, lineNumberStyle := createStyles(styles)
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 }
809 // Format line number
810 lineNum := ""
811 if dl.NewLineNo > 0 {
812 lineNum = fmt.Sprintf("%6d", dl.NewLineNo)
813 }
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)
824 }
826 // Add a padding space for added lines
827 if dl.Kind == LineAdded {
828 content = bgStyle.Render(" ") + content
829 }
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 )
842// -------------------------------------------------------------------------
843// Public API
844// -------------------------------------------------------------------------
846// RenderSideBySideHunk formats a hunk for side-by-side display
847func RenderSideBySideHunk(fileName string, h Hunk, opts ...SideBySideOption) string {
848 // Apply options to create the configuration
849 config := NewSideBySideConfig(opts...)
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")
871 }
873 return sb.String()
876// FormatDiff creates a side-by-side formatted view of a diff
877func FormatDiff(diffText string, opts ...SideBySideOption) (string, error) {
878 diffResult, err := ParseUnifiedDiff(diffText)
879 if err != nil {
880 return "", err
881 }
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).
890 Render("⏹")
891 addIcon := lipgloss.NewStyle().
892 Background(config.Style.AddedLineBg).
893 Foreground(config.Style.AddedFg).
894 Render("⏹")
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 }
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 }
935 return sb.String(), nil
938// GenerateDiff creates a unified diff from two file contents
939func GenerateDiff(beforeContent, afterContent, fileName string) (string, int, int) {
940 // remove the cwd prefix and ensure consistent path format
941 // this prevents issues with absolute paths in different environments
942 cwd := config.WorkingDirectory()
943 fileName = strings.TrimPrefix(fileName, cwd)
944 fileName = strings.TrimPrefix(fileName, "/")
945 // Create temporary directory for git operations
946 tempDir, err := os.MkdirTemp("", fmt.Sprintf("git-diff-%d", time.Now().UnixNano()))
947 if err != nil {
948 logging.Error("Failed to create temp directory for git diff", "error", err)
949 return "", 0, 0
950 }
951 defer os.RemoveAll(tempDir)
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 }
960 wt, err := repo.Worktree()
961 if err != nil {
962 logging.Error("Failed to get git worktree", "error", err)
963 return "", 0, 0
964 }
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 }
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 }
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 }
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 }
1001 _, err = wt.Add(fileName)
1002 if err != nil {
1003 logging.Error("Failed to add file to git", "error", err)
1004 return "", 0, 0
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
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
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
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
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
1046 return patch.String(), additions, removals