10 "github.com/aymanbagabas/go-udiff"
11 "github.com/charmbracelet/lipgloss/v2"
12 "github.com/charmbracelet/x/ansi"
13 "github.com/opencode-ai/opencode/internal/config"
14 "github.com/opencode-ai/opencode/internal/highlight"
15 "github.com/opencode-ai/opencode/internal/tui/theme"
16 "github.com/sergi/go-diff/diffmatchpatch"
19// -------------------------------------------------------------------------
21// -------------------------------------------------------------------------
23// LineType represents the kind of line in a diff.
27 LineContext LineType = iota // Line exists in both files
28 LineAdded // Line added in the new file
29 LineRemoved // Line removed from the old file
32// Segment represents a portion of a line for intra-line highlighting
40// DiffLine represents a single line in a diff
42 OldLineNo int // Line number in old file (0 for added lines)
43 NewLineNo int // Line number in new file (0 for removed lines)
44 Kind LineType // Type of line (added, removed, context)
45 Content string // Content of the line
46 Segments []Segment // Segments for intraline highlighting
49// Hunk represents a section of changes in a diff
55// DiffResult contains the parsed result of a diff
56type DiffResult struct {
62// linePair represents a pair of lines for side-by-side display
68// -------------------------------------------------------------------------
70// -------------------------------------------------------------------------
72// ParseConfig configures the behavior of diff parsing
73type ParseConfig struct {
74 ContextSize int // Number of context lines to include
77// ParseOption modifies a ParseConfig
78type ParseOption func(*ParseConfig)
80// WithContextSize sets the number of context lines to include
81func WithContextSize(size int) ParseOption {
82 return func(p *ParseConfig) {
89// -------------------------------------------------------------------------
90// Side-by-Side Configuration
91// -------------------------------------------------------------------------
93// SideBySideConfig configures the rendering of side-by-side diffs
94type SideBySideConfig struct {
98// SideBySideOption modifies a SideBySideConfig
99type SideBySideOption func(*SideBySideConfig)
101// NewSideBySideConfig creates a SideBySideConfig with default values
102func NewSideBySideConfig(opts ...SideBySideOption) SideBySideConfig {
103 config := SideBySideConfig{
104 TotalWidth: 160, // Default width for side-by-side view
107 for _, opt := range opts {
114// WithTotalWidth sets the total width for side-by-side view
115func WithTotalWidth(width int) SideBySideOption {
116 return func(s *SideBySideConfig) {
123// -------------------------------------------------------------------------
125// -------------------------------------------------------------------------
127// ParseUnifiedDiff parses a unified diff format string into structured data
128func ParseUnifiedDiff(diff string) (DiffResult, error) {
129 var result DiffResult
130 var currentHunk *Hunk
132 hunkHeaderRe := regexp.MustCompile(`^@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@`)
133 lines := strings.Split(diff, "\n")
135 var oldLine, newLine int
138 for _, line := range lines {
139 // Parse file headers
141 if strings.HasPrefix(line, "--- a/") {
142 result.OldFile = strings.TrimPrefix(line, "--- a/")
145 if strings.HasPrefix(line, "+++ b/") {
146 result.NewFile = strings.TrimPrefix(line, "+++ b/")
152 // Parse hunk headers
153 if matches := hunkHeaderRe.FindStringSubmatch(line); matches != nil {
154 if currentHunk != nil {
155 result.Hunks = append(result.Hunks, *currentHunk)
162 oldStart, _ := strconv.Atoi(matches[1])
163 newStart, _ := strconv.Atoi(matches[3])
169 // Ignore "No newline at end of file" markers
170 if strings.HasPrefix(line, "\\ No newline at end of file") {
174 if currentHunk == nil {
178 // Process the line based on its prefix
182 currentHunk.Lines = append(currentHunk.Lines, DiffLine{
190 currentHunk.Lines = append(currentHunk.Lines, DiffLine{
198 currentHunk.Lines = append(currentHunk.Lines, DiffLine{
208 // Handle empty lines
209 currentHunk.Lines = append(currentHunk.Lines, DiffLine{
220 // Add the last hunk if there is one
221 if currentHunk != nil {
222 result.Hunks = append(result.Hunks, *currentHunk)
228// HighlightIntralineChanges updates lines in a hunk to show character-level differences
229func HighlightIntralineChanges(h *Hunk) {
230 var updated []DiffLine
231 dmp := diffmatchpatch.New()
233 for i := 0; i < len(h.Lines); i++ {
234 // Look for removed line followed by added line
235 if i+1 < len(h.Lines) && h.Lines[i].Kind == LineRemoved && h.Lines[i+1].Kind == LineAdded {
236 oldLine := h.Lines[i]
237 newLine := h.Lines[i+1]
239 // Find character-level differences
240 patches := dmp.DiffMain(oldLine.Content, newLine.Content, false)
241 patches = dmp.DiffCleanupSemantic(patches)
242 patches = dmp.DiffCleanupMerge(patches)
243 patches = dmp.DiffCleanupEfficiency(patches)
245 segments := make([]Segment, 0)
249 for _, patch := range patches {
251 case diffmatchpatch.DiffDelete:
252 segments = append(segments, Segment{
254 End: removeStart + len(patch.Text),
258 removeStart += len(patch.Text)
259 case diffmatchpatch.DiffInsert:
260 segments = append(segments, Segment{
262 End: addStart + len(patch.Text),
266 addStart += len(patch.Text)
268 // Context text, no highlighting needed
269 removeStart += len(patch.Text)
270 addStart += len(patch.Text)
273 oldLine.Segments = segments
274 newLine.Segments = segments
276 updated = append(updated, oldLine, newLine)
277 i++ // Skip the next line as we've already processed it
279 updated = append(updated, h.Lines[i])
286// pairLines converts a flat list of diff lines to pairs for side-by-side display
287func pairLines(lines []DiffLine) []linePair {
292 switch lines[i].Kind {
294 // Check if the next line is an addition, if so pair them
295 if i+1 < len(lines) && lines[i+1].Kind == LineAdded {
296 pairs = append(pairs, linePair{left: &lines[i], right: &lines[i+1]})
299 pairs = append(pairs, linePair{left: &lines[i], right: nil})
303 pairs = append(pairs, linePair{left: nil, right: &lines[i]})
306 pairs = append(pairs, linePair{left: &lines[i], right: &lines[i]})
314// -------------------------------------------------------------------------
315// Syntax Highlighting
316// -------------------------------------------------------------------------
317func getColor(c color.Color) string {
318 rgba := color.RGBAModel.Convert(c).(color.RGBA)
319 return fmt.Sprintf("#%02x%02x%02x", rgba.R, rgba.G, rgba.B)
322// highlightLine applies syntax highlighting to a single line
323func highlightLine(fileName string, line string, bg color.Color) string {
324 highlighted, err := highlight.SyntaxHighlight(line, fileName, bg)
331// createStyles generates the lipgloss styles needed for rendering diffs
332func createStyles(t theme.Theme) (removedLineStyle, addedLineStyle, contextLineStyle, lineNumberStyle lipgloss.Style) {
333 removedLineStyle = lipgloss.NewStyle().Background(t.DiffRemovedBg())
334 addedLineStyle = lipgloss.NewStyle().Background(t.DiffAddedBg())
335 contextLineStyle = lipgloss.NewStyle().Background(t.DiffContextBg())
336 lineNumberStyle = lipgloss.NewStyle().Foreground(t.DiffLineNumber())
341// -------------------------------------------------------------------------
342// Rendering Functions
343// -------------------------------------------------------------------------
345// applyHighlighting applies intra-line highlighting to a piece of text
346func applyHighlighting(content string, segments []Segment, segmentType LineType, highlightBg color.Color) string {
347 // Find all ANSI sequences in the content
348 ansiRegex := regexp.MustCompile(`\x1b(?:[@-Z\\-_]|\[[0-9?]*(?:;[0-9?]*)*[@-~])`)
349 ansiMatches := ansiRegex.FindAllStringIndex(content, -1)
351 // Build a mapping of visible character positions to their actual indices
353 ansiSequences := make(map[int]string)
354 lastAnsiSeq := "\x1b[0m" // Default reset sequence
356 for i := 0; i < len(content); {
358 for _, match := range ansiMatches {
360 ansiSequences[visibleIdx] = content[match[0]:match[1]]
361 lastAnsiSeq = content[match[0]:match[1]]
371 // For non-ANSI positions, store the last ANSI sequence
372 if _, exists := ansiSequences[visibleIdx]; !exists {
373 ansiSequences[visibleIdx] = lastAnsiSeq
379 // Apply highlighting
380 var sb strings.Builder
384 // Get the appropriate color based on terminal background
385 bgColor := lipgloss.Color(getColor(highlightBg))
386 // fgColor := lipgloss.Color(getColor(theme.CurrentTheme().Background()))
388 for i := 0; i < len(content); {
389 // Check if we're at an ANSI sequence
391 for _, match := range ansiMatches {
393 sb.WriteString(content[match[0]:match[1]]) // Preserve ANSI sequence
403 // Check for segment boundaries
404 for _, seg := range segments {
405 if seg.Type == segmentType {
406 if currentPos == seg.Start {
409 if currentPos == seg.End {
415 // Get current character
416 char := string(content[i])
419 // Get the current styling
420 currentStyle := ansiSequences[currentPos]
422 // Apply foreground and background highlight
423 // sb.WriteString("\x1b[38;2;")
424 // r, g, b, _ := fgColor.RGBA()
425 // sb.WriteString(fmt.Sprintf("%d;%d;%dm", r>>8, g>>8, b>>8))
426 sb.WriteString("\x1b[48;2;")
427 r, g, b, _ := bgColor.RGBA()
428 sb.WriteString(fmt.Sprintf("%d;%d;%dm", r>>8, g>>8, b>>8))
430 // Reset foreground and background
431 // sb.WriteString("\x1b[39m")
433 // Reapply the original ANSI sequence
434 sb.WriteString(currentStyle)
436 // Not in selection, just copy the character
447// renderLeftColumn formats the left side of a side-by-side diff
448func renderLeftColumn(fileName string, dl *DiffLine, colWidth int) string {
449 t := theme.CurrentTheme()
452 contextLineStyle := lipgloss.NewStyle().Background(t.DiffContextBg())
453 return contextLineStyle.Width(colWidth).Render("")
456 removedLineStyle, _, contextLineStyle, lineNumberStyle := createStyles(t)
458 // Determine line style based on line type
460 var bgStyle lipgloss.Style
463 marker = removedLineStyle.Foreground(t.DiffRemoved()).Render("-")
464 bgStyle = removedLineStyle
465 lineNumberStyle = lineNumberStyle.Foreground(t.DiffRemoved()).Background(t.DiffRemovedLineNumberBg())
468 bgStyle = contextLineStyle
470 marker = contextLineStyle.Render(" ")
471 bgStyle = contextLineStyle
474 // Format line number
476 if dl.OldLineNo > 0 {
477 lineNum = fmt.Sprintf("%6d", dl.OldLineNo)
480 // Create the line prefix
481 prefix := lineNumberStyle.Render(lineNum + " " + marker)
483 // Apply syntax highlighting
484 content := highlightLine(fileName, dl.Content, bgStyle.GetBackground())
486 // Apply intra-line highlighting for removed lines
487 if dl.Kind == LineRemoved && len(dl.Segments) > 0 {
488 content = applyHighlighting(content, dl.Segments, LineRemoved, t.DiffHighlightRemoved())
491 // Add a padding space for removed lines
492 if dl.Kind == LineRemoved {
493 content = bgStyle.Render(" ") + content
496 // Create the final line and truncate if needed
497 lineText := prefix + content
498 return bgStyle.MaxHeight(1).Width(colWidth).Render(
502 lipgloss.NewStyle().Background(bgStyle.GetBackground()).Foreground(t.TextMuted()).Render("..."),
507// renderRightColumn formats the right side of a side-by-side diff
508func renderRightColumn(fileName string, dl *DiffLine, colWidth int) string {
509 t := theme.CurrentTheme()
512 contextLineStyle := lipgloss.NewStyle().Background(t.DiffContextBg())
513 return contextLineStyle.Width(colWidth).Render("")
516 _, addedLineStyle, contextLineStyle, lineNumberStyle := createStyles(t)
518 // Determine line style based on line type
520 var bgStyle lipgloss.Style
523 marker = addedLineStyle.Foreground(t.DiffAdded()).Render("+")
524 bgStyle = addedLineStyle
525 lineNumberStyle = lineNumberStyle.Foreground(t.DiffAdded()).Background(t.DiffAddedLineNumberBg())
528 bgStyle = contextLineStyle
530 marker = contextLineStyle.Render(" ")
531 bgStyle = contextLineStyle
534 // Format line number
536 if dl.NewLineNo > 0 {
537 lineNum = fmt.Sprintf("%6d", dl.NewLineNo)
540 // Create the line prefix
541 prefix := lineNumberStyle.Render(lineNum + " " + marker)
543 // Apply syntax highlighting
544 content := highlightLine(fileName, dl.Content, bgStyle.GetBackground())
546 // Apply intra-line highlighting for added lines
547 if dl.Kind == LineAdded && len(dl.Segments) > 0 {
548 content = applyHighlighting(content, dl.Segments, LineAdded, t.DiffHighlightAdded())
551 // Add a padding space for added lines
552 if dl.Kind == LineAdded {
553 content = bgStyle.Render(" ") + content
556 // Create the final line and truncate if needed
557 lineText := prefix + content
558 return bgStyle.MaxHeight(1).Width(colWidth).Render(
562 lipgloss.NewStyle().Background(bgStyle.GetBackground()).Foreground(t.TextMuted()).Render("..."),
567// -------------------------------------------------------------------------
569// -------------------------------------------------------------------------
571// RenderSideBySideHunk formats a hunk for side-by-side display
572func RenderSideBySideHunk(fileName string, h Hunk, opts ...SideBySideOption) string {
573 // Apply options to create the configuration
574 config := NewSideBySideConfig(opts...)
576 // Make a copy of the hunk so we don't modify the original
577 hunkCopy := Hunk{Lines: make([]DiffLine, len(h.Lines))}
578 copy(hunkCopy.Lines, h.Lines)
580 // Highlight changes within lines
581 HighlightIntralineChanges(&hunkCopy)
583 // Pair lines for side-by-side display
584 pairs := pairLines(hunkCopy.Lines)
586 // Calculate column width
587 colWidth := config.TotalWidth / 2
589 leftWidth := colWidth
590 rightWidth := config.TotalWidth - colWidth
591 var sb strings.Builder
592 for _, p := range pairs {
593 leftStr := renderLeftColumn(fileName, p.left, leftWidth)
594 rightStr := renderRightColumn(fileName, p.right, rightWidth)
595 sb.WriteString(leftStr + rightStr + "\n")
601// FormatDiff creates a side-by-side formatted view of a diff
602func FormatDiff(diffText string, opts ...SideBySideOption) (string, error) {
603 diffResult, err := ParseUnifiedDiff(diffText)
608 var sb strings.Builder
609 for _, h := range diffResult.Hunks {
610 sb.WriteString(RenderSideBySideHunk(diffResult.OldFile, h, opts...))
613 return sb.String(), nil
616// GenerateDiff creates a unified diff from two file contents
617func GenerateDiff(beforeContent, afterContent, fileName string) (string, int, int) {
618 // remove the cwd prefix and ensure consistent path format
619 // this prevents issues with absolute paths in different environments
620 cwd := config.WorkingDirectory()
621 fileName = strings.TrimPrefix(fileName, cwd)
622 fileName = strings.TrimPrefix(fileName, "/")
625 unified = udiff.Unified("a/"+fileName, "b/"+fileName, beforeContent, afterContent)
630 lines := strings.SplitSeq(unified, "\n")
631 for line := range lines {
632 if strings.HasPrefix(line, "+") && !strings.HasPrefix(line, "+++") {
634 } else if strings.HasPrefix(line, "-") && !strings.HasPrefix(line, "---") {
639 return unified, 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