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
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
64type DiffResult struct {
70// linePair represents a pair of lines for side-by-side display
76// -------------------------------------------------------------------------
78// -------------------------------------------------------------------------
80// StyleConfig defines styling for diff rendering
81type 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
108type StyleOption func(*StyleConfig)
110// NewStyleConfig creates a StyleConfig with default values
111func 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
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
182func WithAddedHighlightColors(bg, fg lipgloss.Color) StyleOption {
183 return func(s *StyleConfig) {
184 s.AddedHighlightBg = bg
185 s.AddedHighlightFg = fg
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) {
234// -------------------------------------------------------------------------
235// Side-by-Side Configuration
236// -------------------------------------------------------------------------
238// SideBySideConfig configures the rendering of side-by-side diffs
239type SideBySideConfig struct {
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(),
254 for _, opt := range opts {
261// WithTotalWidth sets the total width for side-by-side view
262func WithTotalWidth(width int) SideBySideOption {
263 return func(s *SideBySideConfig) {
270// WithStyle sets the styling configuration
271func WithStyle(style StyleConfig) SideBySideOption {
272 return func(s *SideBySideConfig) {
277// WithStyleOptions applies the specified style options
278func 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
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
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/")
313 // Parse hunk headers
314 if matches := hunkHeaderRe.FindStringSubmatch(line); matches != nil {
315 if currentHunk != nil {
316 result.Hunks = append(result.Hunks, *currentHunk)
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
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)
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
451func pairLines(lines []DiffLine) []linePair {
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
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)
487 l = lexers.Analyse(source)
492 l = chroma.Coalesce(l)
495 f := formatters.Get(formatter)
497 f = formatters.Fallback
500 <style name="vscode-dark-plus">
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
608func highlightLine(fileName string, line string, bg lipgloss.TerminalColor) string {
610 err := SyntaxHighlight(&buf, line, fileName, "terminal16m", bg)
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)
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,
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
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))
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
726// renderLeftColumn formats the left side of a side-by-side diff
727func 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
785func 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
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")
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)
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)
902 Background(config.Style.ContextLineBg).
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 {
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
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()))
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
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list