on 5859dcd help sign in
957 lines 28 kB history raw
1package diff
3import (
4 "bytes"
5 "fmt"
6 "io"
7 "regexp"
8 "strconv"
9 "strings"
11 "github.com/alecthomas/chroma/v2"
12 "github.com/alecthomas/chroma/v2/formatters"
13 "github.com/alecthomas/chroma/v2/lexers"
14 "github.com/alecthomas/chroma/v2/styles"
15 "github.com/aymanbagabas/go-udiff"
16 "github.com/charmbracelet/lipgloss"
17 "github.com/charmbracelet/x/ansi"
18 "github.com/opencode-ai/opencode/internal/config"
19 "github.com/sergi/go-diff/diffmatchpatch"
22// -------------------------------------------------------------------------
23// Core Types
24// -------------------------------------------------------------------------
26// LineType represents the kind of line in a diff.
27type LineType int
29const (
30 LineContext LineType = iota // Line exists in both files
31 LineAdded // Line added in the new file
32 LineRemoved // Line removed from the old file
35// Segment represents a portion of a line for intra-line highlighting
36type Segment struct {
37 Start int
38 End int
39 Type LineType
40 Text string
43// DiffLine represents a single line in a diff
44type DiffLine struct {
45 OldLineNo int // Line number in old file (0 for added lines)
46 NewLineNo int // Line number in new file (0 for removed lines)
47 Kind LineType // Type of line (added, removed, context)
48 Content string // Content of the line
49 Segments []Segment // Segments for intraline highlighting
52// Hunk represents a section of changes in a diff
53type Hunk struct {
54 Header string
55 Lines []DiffLine
58// DiffResult contains the parsed result of a diff
59type DiffResult struct {
60 OldFile string
61 NewFile string
62 Hunks []Hunk
65// linePair represents a pair of lines for side-by-side display
66type linePair struct {
67 left *DiffLine
68 right *DiffLine
71// -------------------------------------------------------------------------
72// Style Configuration
73// -------------------------------------------------------------------------
75// StyleConfig defines styling for diff rendering
76type StyleConfig struct {
77 ShowHeader bool
78 ShowHunkHeader bool
79 FileNameFg lipgloss.Color
80 // Background colors
81 RemovedLineBg lipgloss.Color
82 AddedLineBg lipgloss.Color
83 ContextLineBg lipgloss.Color
84 HunkLineBg lipgloss.Color
85 RemovedLineNumberBg lipgloss.Color
86 AddedLineNamerBg lipgloss.Color
88 // Foreground colors
89 HunkLineFg lipgloss.Color
90 RemovedFg lipgloss.Color
91 AddedFg lipgloss.Color
92 LineNumberFg lipgloss.Color
93 RemovedHighlightFg lipgloss.Color
94 AddedHighlightFg lipgloss.Color
96 // Highlight settings
97 HighlightStyle string
98 RemovedHighlightBg lipgloss.Color
99 AddedHighlightBg lipgloss.Color
102// StyleOption is a function that modifies a StyleConfig
103type StyleOption func(*StyleConfig)
105// NewStyleConfig creates a StyleConfig with default values
106func NewStyleConfig(opts ...StyleOption) StyleConfig {
107 // Default color scheme
108 config := StyleConfig{
109 ShowHeader: true,
110 ShowHunkHeader: true,
111 FileNameFg: lipgloss.Color("#a0a0a0"),
112 RemovedLineBg: lipgloss.Color("#3A3030"),
113 AddedLineBg: lipgloss.Color("#303A30"),
114 ContextLineBg: lipgloss.Color("#212121"),
115 HunkLineBg: lipgloss.Color("#212121"),
116 HunkLineFg: lipgloss.Color("#a0a0a0"),
117 RemovedFg: lipgloss.Color("#7C4444"),
118 AddedFg: lipgloss.Color("#478247"),
119 LineNumberFg: lipgloss.Color("#888888"),
120 HighlightStyle: "dracula",
121 RemovedHighlightBg: lipgloss.Color("#612726"),
122 AddedHighlightBg: lipgloss.Color("#256125"),
123 RemovedLineNumberBg: lipgloss.Color("#332929"),
124 AddedLineNamerBg: lipgloss.Color("#293229"),
125 RemovedHighlightFg: lipgloss.Color("#FADADD"),
126 AddedHighlightFg: lipgloss.Color("#DAFADA"),
127 }
129 // Apply all provided options
130 for _, opt := range opts {
131 opt(&config)
132 }
134 return config
137// Style option functions
138func WithFileNameFg(color lipgloss.Color) StyleOption {
139 return func(s *StyleConfig) { s.FileNameFg = color }
142func WithRemovedLineBg(color lipgloss.Color) StyleOption {
143 return func(s *StyleConfig) { s.RemovedLineBg = color }
146func WithAddedLineBg(color lipgloss.Color) StyleOption {
147 return func(s *StyleConfig) { s.AddedLineBg = color }
150func WithContextLineBg(color lipgloss.Color) StyleOption {
151 return func(s *StyleConfig) { s.ContextLineBg = color }
154func WithRemovedFg(color lipgloss.Color) StyleOption {
155 return func(s *StyleConfig) { s.RemovedFg = color }
158func WithAddedFg(color lipgloss.Color) StyleOption {
159 return func(s *StyleConfig) { s.AddedFg = color }
162func WithLineNumberFg(color lipgloss.Color) StyleOption {
163 return func(s *StyleConfig) { s.LineNumberFg = color }
166func WithHighlightStyle(style string) StyleOption {
167 return func(s *StyleConfig) { s.HighlightStyle = style }
170func WithRemovedHighlightColors(bg, fg lipgloss.Color) StyleOption {
171 return func(s *StyleConfig) {
172 s.RemovedHighlightBg = bg
173 s.RemovedHighlightFg = fg
174 }
177func WithAddedHighlightColors(bg, fg lipgloss.Color) StyleOption {
178 return func(s *StyleConfig) {
179 s.AddedHighlightBg = bg
180 s.AddedHighlightFg = fg
181 }
184func WithRemovedLineNumberBg(color lipgloss.Color) StyleOption {
185 return func(s *StyleConfig) { s.RemovedLineNumberBg = color }
188func WithAddedLineNumberBg(color lipgloss.Color) StyleOption {
189 return func(s *StyleConfig) { s.AddedLineNamerBg = color }
192func WithHunkLineBg(color lipgloss.Color) StyleOption {
193 return func(s *StyleConfig) { s.HunkLineBg = color }
196func WithHunkLineFg(color lipgloss.Color) StyleOption {
197 return func(s *StyleConfig) { s.HunkLineFg = color }
200func WithShowHeader(show bool) StyleOption {
201 return func(s *StyleConfig) { s.ShowHeader = show }
204func WithShowHunkHeader(show bool) StyleOption {
205 return func(s *StyleConfig) { s.ShowHunkHeader = show }
208// -------------------------------------------------------------------------
209// Parse Configuration
210// -------------------------------------------------------------------------
212// ParseConfig configures the behavior of diff parsing
213type ParseConfig struct {
214 ContextSize int // Number of context lines to include
217// ParseOption modifies a ParseConfig
218type ParseOption func(*ParseConfig)
220// WithContextSize sets the number of context lines to include
221func WithContextSize(size int) ParseOption {
222 return func(p *ParseConfig) {
223 if size >= 0 {
224 p.ContextSize = size
225 }
226 }
229// -------------------------------------------------------------------------
230// Side-by-Side Configuration
231// -------------------------------------------------------------------------
233// SideBySideConfig configures the rendering of side-by-side diffs
234type SideBySideConfig struct {
235 TotalWidth int
236 Style StyleConfig
239// SideBySideOption modifies a SideBySideConfig
240type SideBySideOption func(*SideBySideConfig)
242// NewSideBySideConfig creates a SideBySideConfig with default values
243func NewSideBySideConfig(opts ...SideBySideOption) SideBySideConfig {
244 config := SideBySideConfig{
245 TotalWidth: 160, // Default width for side-by-side view
246 Style: NewStyleConfig(),
247 }
249 for _, opt := range opts {
250 opt(&config)
251 }
253 return config
256// WithTotalWidth sets the total width for side-by-side view
257func WithTotalWidth(width int) SideBySideOption {
258 return func(s *SideBySideConfig) {
259 if width > 0 {
260 s.TotalWidth = width
261 }
262 }
265// WithStyle sets the styling configuration
266func WithStyle(style StyleConfig) SideBySideOption {
267 return func(s *SideBySideConfig) {
268 s.Style = style
269 }
272// WithStyleOptions applies the specified style options
273func WithStyleOptions(opts ...StyleOption) SideBySideOption {
274 return func(s *SideBySideConfig) {
275 s.Style = NewStyleConfig(opts...)
276 }
279// -------------------------------------------------------------------------
280// Diff Parsing
281// -------------------------------------------------------------------------
283// ParseUnifiedDiff parses a unified diff format string into structured data
284func ParseUnifiedDiff(diff string) (DiffResult, error) {
285 var result DiffResult
286 var currentHunk *Hunk
288 hunkHeaderRe := regexp.MustCompile(`^@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@`)
289 lines := strings.Split(diff, "\n")
291 var oldLine, newLine int
292 inFileHeader := true
294 for _, line := range lines {
295 // Parse file headers
296 if inFileHeader {
297 if strings.HasPrefix(line, "--- a/") {
298 result.OldFile = strings.TrimPrefix(line, "--- a/")
299 continue
300 }
301 if strings.HasPrefix(line, "+++ b/") {
302 result.NewFile = strings.TrimPrefix(line, "+++ b/")
303 inFileHeader = false
304 continue
305 }
306 }
308 // Parse hunk headers
309 if matches := hunkHeaderRe.FindStringSubmatch(line); matches != nil {
310 if currentHunk != nil {
311 result.Hunks = append(result.Hunks, *currentHunk)
312 }
313 currentHunk = &Hunk{
314 Header: line,
315 Lines: []DiffLine{},
316 }
318 oldStart, _ := strconv.Atoi(matches[1])
319 newStart, _ := strconv.Atoi(matches[3])
320 oldLine = oldStart
321 newLine = newStart
322 continue
323 }
325 // Ignore "No newline at end of file" markers
326 if strings.HasPrefix(line, "\\ No newline at end of file") {
327 continue
328 }
330 if currentHunk == nil {
331 continue
332 }
334 // Process the line based on its prefix
335 if len(line) > 0 {
336 switch line[0] {
337 case '+':
338 currentHunk.Lines = append(currentHunk.Lines, DiffLine{
339 OldLineNo: 0,
340 NewLineNo: newLine,
341 Kind: LineAdded,
342 Content: line[1:],
343 })
344 newLine++
345 case '-':
346 currentHunk.Lines = append(currentHunk.Lines, DiffLine{
347 OldLineNo: oldLine,
348 NewLineNo: 0,
349 Kind: LineRemoved,
350 Content: line[1:],
351 })
352 oldLine++
353 default:
354 currentHunk.Lines = append(currentHunk.Lines, DiffLine{
355 OldLineNo: oldLine,
356 NewLineNo: newLine,
357 Kind: LineContext,
358 Content: line,
359 })
360 oldLine++
361 newLine++
362 }
363 } else {
364 // Handle empty lines
365 currentHunk.Lines = append(currentHunk.Lines, DiffLine{
366 OldLineNo: oldLine,
367 NewLineNo: newLine,
368 Kind: LineContext,
369 Content: "",
370 })
371 oldLine++
372 newLine++
373 }
374 }
376 // Add the last hunk if there is one
377 if currentHunk != nil {
378 result.Hunks = append(result.Hunks, *currentHunk)
379 }
381 return result, nil
384// HighlightIntralineChanges updates lines in a hunk to show character-level differences
385func HighlightIntralineChanges(h *Hunk, style StyleConfig) {
386 var updated []DiffLine
387 dmp := diffmatchpatch.New()
389 for i := 0; i < len(h.Lines); i++ {
390 // Look for removed line followed by added line
391 if i+1 < len(h.Lines) &&
392 h.Lines[i].Kind == LineRemoved &&
393 h.Lines[i+1].Kind == LineAdded {
395 oldLine := h.Lines[i]
396 newLine := h.Lines[i+1]
398 // Find character-level differences
399 patches := dmp.DiffMain(oldLine.Content, newLine.Content, false)
400 patches = dmp.DiffCleanupSemantic(patches)
401 patches = dmp.DiffCleanupMerge(patches)
402 patches = dmp.DiffCleanupEfficiency(patches)
404 segments := make([]Segment, 0)
406 removeStart := 0
407 addStart := 0
408 for _, patch := range patches {
409 switch patch.Type {
410 case diffmatchpatch.DiffDelete:
411 segments = append(segments, Segment{
412 Start: removeStart,
413 End: removeStart + len(patch.Text),
414 Type: LineRemoved,
415 Text: patch.Text,
416 })
417 removeStart += len(patch.Text)
418 case diffmatchpatch.DiffInsert:
419 segments = append(segments, Segment{
420 Start: addStart,
421 End: addStart + len(patch.Text),
422 Type: LineAdded,
423 Text: patch.Text,
424 })
425 addStart += len(patch.Text)
426 default:
427 // Context text, no highlighting needed
428 removeStart += len(patch.Text)
429 addStart += len(patch.Text)
430 }
431 }
432 oldLine.Segments = segments
433 newLine.Segments = segments
435 updated = append(updated, oldLine, newLine)
436 i++ // Skip the next line as we've already processed it
437 } else {
438 updated = append(updated, h.Lines[i])
439 }
440 }
442 h.Lines = updated
445// pairLines converts a flat list of diff lines to pairs for side-by-side display
446func pairLines(lines []DiffLine) []linePair {
447 var pairs []linePair
448 i := 0
450 for i < len(lines) {
451 switch lines[i].Kind {
452 case LineRemoved:
453 // Check if the next line is an addition, if so pair them
454 if i+1 < len(lines) && lines[i+1].Kind == LineAdded {
455 pairs = append(pairs, linePair{left: &lines[i], right: &lines[i+1]})
456 i += 2
457 } else {
458 pairs = append(pairs, linePair{left: &lines[i], right: nil})
459 i++
460 }
461 case LineAdded:
462 pairs = append(pairs, linePair{left: nil, right: &lines[i]})
463 i++
464 case LineContext:
465 pairs = append(pairs, linePair{left: &lines[i], right: &lines[i]})
466 i++
467 }
468 }
470 return pairs
473// -------------------------------------------------------------------------
474// Syntax Highlighting
475// -------------------------------------------------------------------------
477// SyntaxHighlight applies syntax highlighting to text based on file extension
478func SyntaxHighlight(w io.Writer, source, fileName, formatter string, bg lipgloss.TerminalColor) error {
479 // Determine the language lexer to use
480 l := lexers.Match(fileName)
481 if l == nil {
482 l = lexers.Analyse(source)
483 }
484 if l == nil {
485 l = lexers.Fallback
486 }
487 l = chroma.Coalesce(l)
489 // Get the formatter
490 f := formatters.Get(formatter)
491 if f == nil {
492 f = formatters.Fallback
493 }
494 theme := `
495 <style name="vscode-dark-plus">
496 <!-- Base colors -->
497 <entry type="Background" style="bg:#1E1E1E"/>
498 <entry type="Text" style="#D4D4D4"/>
499 <entry type="Other" style="#D4D4D4"/>
500 <entry type="Error" style="#F44747"/>
501 <!-- Keywords - using the Control flow / Special keywords color -->
502 <entry type="Keyword" style="#C586C0"/>
503 <entry type="KeywordConstant" style="#4FC1FF"/>
504 <entry type="KeywordDeclaration" style="#C586C0"/>
505 <entry type="KeywordNamespace" style="#C586C0"/>
506 <entry type="KeywordPseudo" style="#C586C0"/>
507 <entry type="KeywordReserved" style="#C586C0"/>
508 <entry type="KeywordType" style="#4EC9B0"/>
509 <!-- Names -->
510 <entry type="Name" style="#D4D4D4"/>
511 <entry type="NameAttribute" style="#9CDCFE"/>
512 <entry type="NameBuiltin" style="#4EC9B0"/>
513 <entry type="NameBuiltinPseudo" style="#9CDCFE"/>
514 <entry type="NameClass" style="#4EC9B0"/>
515 <entry type="NameConstant" style="#4FC1FF"/>
516 <entry type="NameDecorator" style="#DCDCAA"/>
517 <entry type="NameEntity" style="#9CDCFE"/>
518 <entry type="NameException" style="#4EC9B0"/>
519 <entry type="NameFunction" style="#DCDCAA"/>
520 <entry type="NameLabel" style="#C8C8C8"/>
521 <entry type="NameNamespace" style="#4EC9B0"/>
522 <entry type="NameOther" style="#9CDCFE"/>
523 <entry type="NameTag" style="#569CD6"/>
524 <entry type="NameVariable" style="#9CDCFE"/>
525 <entry type="NameVariableClass" style="#9CDCFE"/>
526 <entry type="NameVariableGlobal" style="#9CDCFE"/>
527 <entry type="NameVariableInstance" style="#9CDCFE"/>
528 <!-- Literals -->
529 <entry type="Literal" style="#CE9178"/>
530 <entry type="LiteralDate" style="#CE9178"/>
531 <entry type="LiteralString" style="#CE9178"/>
532 <entry type="LiteralStringBacktick" style="#CE9178"/>
533 <entry type="LiteralStringChar" style="#CE9178"/>
534 <entry type="LiteralStringDoc" style="#CE9178"/>
535 <entry type="LiteralStringDouble" style="#CE9178"/>
536 <entry type="LiteralStringEscape" style="#d7ba7d"/>
537 <entry type="LiteralStringHeredoc" style="#CE9178"/>
538 <entry type="LiteralStringInterpol" style="#CE9178"/>
539 <entry type="LiteralStringOther" style="#CE9178"/>
540 <entry type="LiteralStringRegex" style="#d16969"/>
541 <entry type="LiteralStringSingle" style="#CE9178"/>
542 <entry type="LiteralStringSymbol" style="#CE9178"/>
543 <!-- Numbers - using the numberLiteral color -->
544 <entry type="LiteralNumber" style="#b5cea8"/>
545 <entry type="LiteralNumberBin" style="#b5cea8"/>
546 <entry type="LiteralNumberFloat" style="#b5cea8"/>
547 <entry type="LiteralNumberHex" style="#b5cea8"/>
548 <entry type="LiteralNumberInteger" style="#b5cea8"/>
549 <entry type="LiteralNumberIntegerLong" style="#b5cea8"/>
550 <entry type="LiteralNumberOct" style="#b5cea8"/>
551 <!-- Operators -->
552 <entry type="Operator" style="#D4D4D4"/>
553 <entry type="OperatorWord" style="#C586C0"/>
554 <entry type="Punctuation" style="#D4D4D4"/>
555 <!-- Comments - standard VSCode Dark+ comment color -->
556 <entry type="Comment" style="#6A9955"/>
557 <entry type="CommentHashbang" style="#6A9955"/>
558 <entry type="CommentMultiline" style="#6A9955"/>
559 <entry type="CommentSingle" style="#6A9955"/>
560 <entry type="CommentSpecial" style="#6A9955"/>
561 <entry type="CommentPreproc" style="#C586C0"/>
562 <!-- Generic styles -->
563 <entry type="Generic" style="#D4D4D4"/>
564 <entry type="GenericDeleted" style="#F44747"/>
565 <entry type="GenericEmph" style="italic #D4D4D4"/>
566 <entry type="GenericError" style="#F44747"/>
567 <entry type="GenericHeading" style="bold #D4D4D4"/>
568 <entry type="GenericInserted" style="#b5cea8"/>
569 <entry type="GenericOutput" style="#808080"/>
570 <entry type="GenericPrompt" style="#D4D4D4"/>
571 <entry type="GenericStrong" style="bold #D4D4D4"/>
572 <entry type="GenericSubheading" style="bold #D4D4D4"/>
573 <entry type="GenericTraceback" style="#F44747"/>
574 <entry type="GenericUnderline" style="underline"/>
575 <entry type="TextWhitespace" style="#D4D4D4"/>
576</style>
579 r := strings.NewReader(theme)
580 style := chroma.MustNewXMLStyle(r)
581 // Modify the style to use the provided background
582 s, err := style.Builder().Transform(
583 func(t chroma.StyleEntry) chroma.StyleEntry {
584 r, g, b, _ := bg.RGBA()
585 t.Background = chroma.NewColour(uint8(r>>8), uint8(g>>8), uint8(b>>8))
586 return t
587 },
588 ).Build()
589 if err != nil {
590 s = styles.Fallback
591 }
593 // Tokenize and format
594 it, err := l.Tokenise(nil, source)
595 if err != nil {
596 return err
597 }
599 return f.Format(w, s, it)
602// highlightLine applies syntax highlighting to a single line
603func highlightLine(fileName string, line string, bg lipgloss.TerminalColor) string {
604 var buf bytes.Buffer
605 err := SyntaxHighlight(&buf, line, fileName, "terminal16m", bg)
606 if err != nil {
607 return line
608 }
609 return buf.String()
612// createStyles generates the lipgloss styles needed for rendering diffs
613func createStyles(config StyleConfig) (removedLineStyle, addedLineStyle, contextLineStyle, lineNumberStyle lipgloss.Style) {
614 removedLineStyle = lipgloss.NewStyle().Background(config.RemovedLineBg)
615 addedLineStyle = lipgloss.NewStyle().Background(config.AddedLineBg)
616 contextLineStyle = lipgloss.NewStyle().Background(config.ContextLineBg)
617 lineNumberStyle = lipgloss.NewStyle().Foreground(config.LineNumberFg)
619 return
622// -------------------------------------------------------------------------
623// Rendering Functions
624// -------------------------------------------------------------------------
626// applyHighlighting applies intra-line highlighting to a piece of text
627func applyHighlighting(content string, segments []Segment, segmentType LineType, highlightBg lipgloss.Color,
628) string {
629 // Find all ANSI sequences in the content
630 ansiRegex := regexp.MustCompile(`\x1b(?:[@-Z\\-_]|\[[0-9?]*(?:;[0-9?]*)*[@-~])`)
631 ansiMatches := ansiRegex.FindAllStringIndex(content, -1)
633 // Build a mapping of visible character positions to their actual indices
634 visibleIdx := 0
635 ansiSequences := make(map[int]string)
636 lastAnsiSeq := "\x1b[0m" // Default reset sequence
638 for i := 0; i < len(content); {
639 isAnsi := false
640 for _, match := range ansiMatches {
641 if match[0] == i {
642 ansiSequences[visibleIdx] = content[match[0]:match[1]]
643 lastAnsiSeq = content[match[0]:match[1]]
644 i = match[1]
645 isAnsi = true
646 break
647 }
648 }
649 if isAnsi {
650 continue
651 }
653 // For non-ANSI positions, store the last ANSI sequence
654 if _, exists := ansiSequences[visibleIdx]; !exists {
655 ansiSequences[visibleIdx] = lastAnsiSeq
656 }
657 visibleIdx++
658 i++
659 }
661 // Apply highlighting
662 var sb strings.Builder
663 inSelection := false
664 currentPos := 0
666 for i := 0; i < len(content); {
667 // Check if we're at an ANSI sequence
668 isAnsi := false
669 for _, match := range ansiMatches {
670 if match[0] == i {
671 sb.WriteString(content[match[0]:match[1]]) // Preserve ANSI sequence
672 i = match[1]
673 isAnsi = true
674 break
675 }
676 }
677 if isAnsi {
678 continue
679 }
681 // Check for segment boundaries
682 for _, seg := range segments {
683 if seg.Type == segmentType {
684 if currentPos == seg.Start {
685 inSelection = true
686 }
687 if currentPos == seg.End {
688 inSelection = false
689 }
690 }
691 }
693 // Get current character
694 char := string(content[i])
696 if inSelection {
697 // Get the current styling
698 currentStyle := ansiSequences[currentPos]
700 // Apply background highlight
701 sb.WriteString("\x1b[48;2;")
702 r, g, b, _ := highlightBg.RGBA()
703 sb.WriteString(fmt.Sprintf("%d;%d;%dm", r>>8, g>>8, b>>8))
704 sb.WriteString(char)
705 sb.WriteString("\x1b[49m") // Reset only background
707 // Reapply the original ANSI sequence
708 sb.WriteString(currentStyle)
709 } else {
710 // Not in selection, just copy the character
711 sb.WriteString(char)
712 }
714 currentPos++
715 i++
716 }
718 return sb.String()
721// renderLeftColumn formats the left side of a side-by-side diff
722func renderLeftColumn(fileName string, dl *DiffLine, colWidth int, styles StyleConfig) string {
723 if dl == nil {
724 contextLineStyle := lipgloss.NewStyle().Background(styles.ContextLineBg)
725 return contextLineStyle.Width(colWidth).Render("")
726 }
728 removedLineStyle, _, contextLineStyle, lineNumberStyle := createStyles(styles)
730 // Determine line style based on line type
731 var marker string
732 var bgStyle lipgloss.Style
733 switch dl.Kind {
734 case LineRemoved:
735 marker = removedLineStyle.Foreground(styles.RemovedFg).Render("-")
736 bgStyle = removedLineStyle
737 lineNumberStyle = lineNumberStyle.Foreground(styles.RemovedFg).Background(styles.RemovedLineNumberBg)
738 case LineAdded:
739 marker = "?"
740 bgStyle = contextLineStyle
741 case LineContext:
742 marker = contextLineStyle.Render(" ")
743 bgStyle = contextLineStyle
744 }
746 // Format line number
747 lineNum := ""
748 if dl.OldLineNo > 0 {
749 lineNum = fmt.Sprintf("%6d", dl.OldLineNo)
750 }
752 // Create the line prefix
753 prefix := lineNumberStyle.Render(lineNum + " " + marker)
755 // Apply syntax highlighting
756 content := highlightLine(fileName, dl.Content, bgStyle.GetBackground())
758 // Apply intra-line highlighting for removed lines
759 if dl.Kind == LineRemoved && len(dl.Segments) > 0 {
760 content = applyHighlighting(content, dl.Segments, LineRemoved, styles.RemovedHighlightBg)
761 }
763 // Add a padding space for removed lines
764 if dl.Kind == LineRemoved {
765 content = bgStyle.Render(" ") + content
766 }
768 // Create the final line and truncate if needed
769 lineText := prefix + content
770 return bgStyle.MaxHeight(1).Width(colWidth).Render(
771 ansi.Truncate(
772 lineText,
773 colWidth,
774 lipgloss.NewStyle().Background(styles.HunkLineBg).Foreground(styles.HunkLineFg).Render("..."),
775 ),
776 )
779// renderRightColumn formats the right side of a side-by-side diff
780func renderRightColumn(fileName string, dl *DiffLine, colWidth int, styles StyleConfig) string {
781 if dl == nil {
782 contextLineStyle := lipgloss.NewStyle().Background(styles.ContextLineBg)
783 return contextLineStyle.Width(colWidth).Render("")
784 }
786 _, addedLineStyle, contextLineStyle, lineNumberStyle := createStyles(styles)
788 // Determine line style based on line type
789 var marker string
790 var bgStyle lipgloss.Style
791 switch dl.Kind {
792 case LineAdded:
793 marker = addedLineStyle.Foreground(styles.AddedFg).Render("+")
794 bgStyle = addedLineStyle
795 lineNumberStyle = lineNumberStyle.Foreground(styles.AddedFg).Background(styles.AddedLineNamerBg)
796 case LineRemoved:
797 marker = "?"
798 bgStyle = contextLineStyle
799 case LineContext:
800 marker = contextLineStyle.Render(" ")
801 bgStyle = contextLineStyle
802 }
804 // Format line number
805 lineNum := ""
806 if dl.NewLineNo > 0 {
807 lineNum = fmt.Sprintf("%6d", dl.NewLineNo)
808 }
810 // Create the line prefix
811 prefix := lineNumberStyle.Render(lineNum + " " + marker)
813 // Apply syntax highlighting
814 content := highlightLine(fileName, dl.Content, bgStyle.GetBackground())
816 // Apply intra-line highlighting for added lines
817 if dl.Kind == LineAdded && len(dl.Segments) > 0 {
818 content = applyHighlighting(content, dl.Segments, LineAdded, styles.AddedHighlightBg)
819 }
821 // Add a padding space for added lines
822 if dl.Kind == LineAdded {
823 content = bgStyle.Render(" ") + content
824 }
826 // Create the final line and truncate if needed
827 lineText := prefix + content
828 return bgStyle.MaxHeight(1).Width(colWidth).Render(
829 ansi.Truncate(
830 lineText,
831 colWidth,
832 lipgloss.NewStyle().Background(styles.HunkLineBg).Foreground(styles.HunkLineFg).Render("..."),
833 ),
834 )
837// -------------------------------------------------------------------------
838// Public API
839// -------------------------------------------------------------------------
841// RenderSideBySideHunk formats a hunk for side-by-side display
842func RenderSideBySideHunk(fileName string, h Hunk, opts ...SideBySideOption) string {
843 // Apply options to create the configuration
844 config := NewSideBySideConfig(opts...)
846 // Make a copy of the hunk so we don't modify the original
847 hunkCopy := Hunk{Lines: make([]DiffLine, len(h.Lines))}
848 copy(hunkCopy.Lines, h.Lines)
850 // Highlight changes within lines
851 HighlightIntralineChanges(&hunkCopy, config.Style)
853 // Pair lines for side-by-side display
854 pairs := pairLines(hunkCopy.Lines)
856 // Calculate column width
857 colWidth := config.TotalWidth / 2
859 leftWidth := colWidth
860 rightWidth := config.TotalWidth - colWidth
861 var sb strings.Builder
862 for _, p := range pairs {
863 leftStr := renderLeftColumn(fileName, p.left, leftWidth, config.Style)
864 rightStr := renderRightColumn(fileName, p.right, rightWidth, config.Style)
865 sb.WriteString(leftStr + rightStr + "\n")
866 }
868 return sb.String()
871// FormatDiff creates a side-by-side formatted view of a diff
872func FormatDiff(diffText string, opts ...SideBySideOption) (string, error) {
873 diffResult, err := ParseUnifiedDiff(diffText)
874 if err != nil {
875 return "", err
876 }
878 var sb strings.Builder
879 config := NewSideBySideConfig(opts...)
881 if config.Style.ShowHeader {
882 removeIcon := lipgloss.NewStyle().
883 Background(config.Style.RemovedLineBg).
884 Foreground(config.Style.RemovedFg).
885 Render("⏹")
886 addIcon := lipgloss.NewStyle().
887 Background(config.Style.AddedLineBg).
888 Foreground(config.Style.AddedFg).
889 Render("⏹")
891 fileName := lipgloss.NewStyle().
892 Background(config.Style.ContextLineBg).
893 Foreground(config.Style.FileNameFg).
894 Render(" " + diffResult.OldFile)
895 sb.WriteString(
896 lipgloss.NewStyle().
897 Background(config.Style.ContextLineBg).
898 Padding(0, 1, 0, 1).
899 Foreground(config.Style.FileNameFg).
900 BorderStyle(lipgloss.NormalBorder()).
901 BorderTop(true).
902 BorderBottom(true).
903 BorderForeground(config.Style.FileNameFg).
904 BorderBackground(config.Style.ContextLineBg).
905 Width(config.TotalWidth).
906 Render(
907 lipgloss.JoinHorizontal(lipgloss.Top,
908 removeIcon,
909 addIcon,
910 fileName,
911 ),
912 ) + "\n",
913 )
914 }
916 for _, h := range diffResult.Hunks {
917 // Render hunk header
918 if config.Style.ShowHunkHeader {
919 sb.WriteString(
920 lipgloss.NewStyle().
921 Background(config.Style.HunkLineBg).
922 Foreground(config.Style.HunkLineFg).
923 Width(config.TotalWidth).
924 Render(h.Header) + "\n",
925 )
926 }
927 sb.WriteString(RenderSideBySideHunk(diffResult.OldFile, h, opts...))
928 }
930 return sb.String(), nil
933// GenerateDiff creates a unified diff from two file contents
934func GenerateDiff(beforeContent, afterContent, fileName string) (string, int, int) {
935 // remove the cwd prefix and ensure consistent path format
936 // this prevents issues with absolute paths in different environments
937 cwd := config.WorkingDirectory()
938 fileName = strings.TrimPrefix(fileName, cwd)
939 fileName = strings.TrimPrefix(fileName, "/")
941 var (
942 unified = udiff.Unified("a/"+fileName, "b/"+fileName, beforeContent, afterContent)
943 additions = 0
944 removals = 0
945 )
947 lines := strings.Split(unified, "\n")
948 for _, line := range lines {
949 if strings.HasPrefix(line, "+") && !strings.HasPrefix(line, "+++") {
950 additions++
951 } else if strings.HasPrefix(line, "-") && !strings.HasPrefix(line, "---") {
952 removals++
953 }
954 }
956 return unified, additions, removals