Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 68 additions & 44 deletions internal/tui/render_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,13 @@ func splitLines(content []byte) []string {
return lines
}

func splitLogicalLines(content []byte) []string {
if len(content) == 0 {
return nil
}
return splitLines(content)
}

func renderLines(
lines []lineInfo,
numberStyle lipgloss.Style,
Expand Down Expand Up @@ -249,6 +256,7 @@ func buildPaneLinesFromEntries(doc markers.Document, side paneSide, highlightCon
baseStart := selectedRange.baseStart
baseEnd := selectedRange.baseEnd
sideStart, sideEnd := selectedRange.sideRange(side)
emptySideSelection := highlightConflict >= 0 && baseStart == baseEnd && sideStart >= 0 && sideStart == sideEnd

resolution := conflictResolutionForIndex(doc, highlightConflict, selectedSide)
connector := ""
Expand Down Expand Up @@ -287,6 +295,13 @@ func buildPaneLinesFromEntries(doc markers.Document, side paneSide, highlightCon
}

for _, entry := range entries {
if emptySideSelection && !selectedFound && entry.category != categoryRemoved && sideLineIndex == sideStart {
selectedFound = true
currentStart = len(lines)
addStartMarker()
addEndMarker()
}

selected := false
if highlightConflict >= 0 {
if entry.category == categoryRemoved {
Expand Down Expand Up @@ -333,6 +348,13 @@ func buildPaneLinesFromEntries(doc markers.Document, side paneSide, highlightCon
lastSelected = selected
}

if emptySideSelection && !selectedFound && sideLineIndex == sideStart {
selectedFound = true
currentStart = len(lines)
addStartMarker()
addEndMarker()
}

if lastSelected {
addEndMarker()
}
Expand Down Expand Up @@ -368,69 +390,71 @@ func computeConflictRanges(doc markers.Document, baseLines []string, oursLines [
oursPos := 0
theirsPos := 0

for _, ref := range doc.Conflicts {
seg, ok := doc.Segments[ref.SegmentIndex].(markers.ConflictSegment)
if !ok {
return nil, false
}
for _, seg := range doc.Segments {
switch s := seg.(type) {
case markers.TextSegment:
textLines := splitLogicalLines(s.Bytes)
if !matchLinesAt(baseLines, textLines, basePos) || !matchLinesAt(oursLines, textLines, oursPos) || !matchLinesAt(theirsLines, textLines, theirsPos) {
return nil, false
}
basePos += len(textLines)
oursPos += len(textLines)
theirsPos += len(textLines)
case markers.ConflictSegment:
baseSeq := splitLogicalLines(s.Base)
oursSeq := splitLogicalLines(s.Ours)
theirsSeq := splitLogicalLines(s.Theirs)

baseSeq := splitLines(seg.Base)
oursSeq := splitLines(seg.Ours)
theirsSeq := splitLines(seg.Theirs)
if !matchLinesAt(baseLines, baseSeq, basePos) || !matchLinesAt(oursLines, oursSeq, oursPos) || !matchLinesAt(theirsLines, theirsSeq, theirsPos) {
return nil, false
}

baseStart, baseEnd, okBase := findSequence(baseLines, baseSeq, basePos)
oursStart, oursEnd, okOurs := findSequence(oursLines, oursSeq, oursPos)
theirsStart, theirsEnd, okTheirs := findSequence(theirsLines, theirsSeq, theirsPos)
if !okBase || !okOurs || !okTheirs {
ranges = append(ranges, conflictRange{
baseStart: basePos,
baseEnd: basePos + len(baseSeq),
oursStart: oursPos,
oursEnd: oursPos + len(oursSeq),
theirsStart: theirsPos,
theirsEnd: theirsPos + len(theirsSeq),
})

basePos += len(baseSeq)
oursPos += len(oursSeq)
theirsPos += len(theirsSeq)
default:
return nil, false
}
}

ranges = append(ranges, conflictRange{
baseStart: baseStart,
baseEnd: baseEnd,
oursStart: oursStart,
oursEnd: oursEnd,
theirsStart: theirsStart,
theirsEnd: theirsEnd,
})
if len(ranges) != len(doc.Conflicts) {
return nil, false
}

basePos = baseEnd
oursPos = oursEnd
theirsPos = theirsEnd
if basePos != len(baseLines) || oursPos != len(oursLines) || theirsPos != len(theirsLines) {
return nil, false
}

return ranges, true
}

func findSequence(lines []string, seq []string, start int) (int, int, bool) {
if start < 0 {
start = 0
func matchLinesAt(lines []string, seq []string, start int) bool {
if start < 0 || start > len(lines) {
return false
}
if len(seq) == 0 {
return start, start, true
}
if len(lines) == 0 {
return -1, -1, false
return true
}
if len(seq) > len(lines) {
return -1, -1, false
if start+len(seq) > len(lines) {
return false
}

limit := len(lines) - len(seq)
for i := start; i <= limit; i++ {
match := true
for j, line := range seq {
if lines[i+j] != line {
match = false
break
}
}
if match {
return i, i + len(seq), true
for i, line := range seq {
if lines[start+i] != line {
return false
}
}

return -1, -1, false
return true
}

func buildResultLines(doc markers.Document, highlightConflict int, selectedSide selectionSide, manualResolved map[int][]byte, boundaryText [][]byte) ([]lineInfo, int) {
Expand Down
198 changes: 198 additions & 0 deletions internal/tui/render_helpers_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package tui

import (
"fmt"
"testing"

"github.com/chojs23/ec/internal/markers"
Expand Down Expand Up @@ -194,6 +195,203 @@ func TestBuildPaneLinesFromEntriesUsesSideRangeForNonRemoved(t *testing.T) {
}
}

func TestComputeConflictRangesTracksEmptySideInsertionPoint(t *testing.T) {
tests := []struct {
name string
input []byte
baseLines []string
oursLines []string
theirsLines []string
want conflictRange
}{
{
name: "empty ours",
input: []byte("alpha\n<<<<<<< HEAD\n=======\ntheirs\n>>>>>>> branch\nomega\n\nblank\n"),
baseLines: []string{"alpha", "omega", "", "blank"},
oursLines: []string{"alpha", "omega", "", "blank"},
theirsLines: []string{"alpha", "theirs", "omega", "", "blank"},
want: conflictRange{baseStart: 1, baseEnd: 1, oursStart: 1, oursEnd: 1, theirsStart: 1, theirsEnd: 2},
},
{
name: "empty theirs",
input: []byte("alpha\n<<<<<<< HEAD\nours\n=======\n>>>>>>> branch\nomega\n\nblank\n"),
baseLines: []string{"alpha", "omega", "", "blank"},
oursLines: []string{"alpha", "ours", "omega", "", "blank"},
theirsLines: []string{"alpha", "omega", "", "blank"},
want: conflictRange{baseStart: 1, baseEnd: 1, oursStart: 1, oursEnd: 2, theirsStart: 1, theirsEnd: 1},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
doc, err := markers.Parse(tt.input)
if err != nil {
t.Fatalf("Parse error: %v", err)
}

ranges, ok := computeConflictRanges(doc, tt.baseLines, tt.oursLines, tt.theirsLines)
if !ok {
t.Fatalf("computeConflictRanges failed")
}
if len(ranges) != 1 {
t.Fatalf("ranges len = %d, want 1", len(ranges))
}

if got := ranges[0]; got != tt.want {
t.Fatalf("range = %+v, want %+v", got, tt.want)
}
})
}
}

func TestBuildPaneLinesFromEntriesAnchorsEmptySideAtInsertionPoint(t *testing.T) {
tests := []struct {
name string
selectedPane paneSide
selectedSide selectionSide
segments []markers.Segment
conflictSegmentIndex int
entries []lineEntry
rangeForConflict conflictRange
wantStart int
wantLineCount int
wantMarkerIndex int
}{
{
name: "empty ours in middle",
selectedPane: paneOurs,
selectedSide: selectedOurs,
segments: []markers.Segment{
markers.TextSegment{Bytes: []byte("alpha\n")},
markers.ConflictSegment{Ours: nil, Theirs: []byte("theirs\n")},
markers.TextSegment{Bytes: []byte("omega\n")},
},
conflictSegmentIndex: 1,
entries: []lineEntry{{text: "alpha", category: categoryDefault, baseIndex: 0}, {text: "omega", category: categoryDefault, baseIndex: 1}},
rangeForConflict: conflictRange{baseStart: 1, baseEnd: 1, oursStart: 1, oursEnd: 1, theirsStart: 1, theirsEnd: 2},
wantStart: 1,
wantLineCount: 4,
wantMarkerIndex: 1,
},
{
name: "empty ours at bof",
selectedPane: paneOurs,
selectedSide: selectedOurs,
segments: []markers.Segment{
markers.ConflictSegment{Ours: nil, Theirs: []byte("theirs\n")},
markers.TextSegment{Bytes: []byte("tail\n")},
},
conflictSegmentIndex: 0,
entries: []lineEntry{{text: "tail", category: categoryDefault, baseIndex: 0}},
rangeForConflict: conflictRange{baseStart: 0, baseEnd: 0, oursStart: 0, oursEnd: 0, theirsStart: 0, theirsEnd: 1},
wantStart: 0,
wantLineCount: 3,
wantMarkerIndex: 0,
},
{
name: "empty ours at eof",
selectedPane: paneOurs,
selectedSide: selectedOurs,
segments: []markers.Segment{
markers.TextSegment{Bytes: []byte("head\n")},
markers.ConflictSegment{Ours: nil, Theirs: []byte("theirs\n")},
},
conflictSegmentIndex: 1,
entries: []lineEntry{{text: "head", category: categoryDefault, baseIndex: 0}},
rangeForConflict: conflictRange{baseStart: 1, baseEnd: 1, oursStart: 1, oursEnd: 1, theirsStart: 1, theirsEnd: 2},
wantStart: 1,
wantLineCount: 3,
wantMarkerIndex: 1,
},
{
name: "empty theirs in middle",
selectedPane: paneTheirs,
selectedSide: selectedTheirs,
segments: []markers.Segment{
markers.TextSegment{Bytes: []byte("alpha\n")},
markers.ConflictSegment{Ours: []byte("ours\n"), Theirs: nil},
markers.TextSegment{Bytes: []byte("omega\n")},
},
conflictSegmentIndex: 1,
entries: []lineEntry{{text: "alpha", category: categoryDefault, baseIndex: 0}, {text: "omega", category: categoryDefault, baseIndex: 1}},
rangeForConflict: conflictRange{baseStart: 1, baseEnd: 1, oursStart: 1, oursEnd: 2, theirsStart: 1, theirsEnd: 1},
wantStart: 1,
wantLineCount: 4,
wantMarkerIndex: 1,
},
{
name: "empty theirs at bof",
selectedPane: paneTheirs,
selectedSide: selectedTheirs,
segments: []markers.Segment{
markers.ConflictSegment{Ours: []byte("ours\n"), Theirs: nil},
markers.TextSegment{Bytes: []byte("tail\n")},
},
conflictSegmentIndex: 0,
entries: []lineEntry{{text: "tail", category: categoryDefault, baseIndex: 0}},
rangeForConflict: conflictRange{baseStart: 0, baseEnd: 0, oursStart: 0, oursEnd: 1, theirsStart: 0, theirsEnd: 0},
wantStart: 0,
wantLineCount: 3,
wantMarkerIndex: 0,
},
{
name: "empty theirs at eof",
selectedPane: paneTheirs,
selectedSide: selectedTheirs,
segments: []markers.Segment{
markers.TextSegment{Bytes: []byte("head\n")},
markers.ConflictSegment{Ours: []byte("ours\n"), Theirs: nil},
},
conflictSegmentIndex: 1,
entries: []lineEntry{{text: "head", category: categoryDefault, baseIndex: 0}},
rangeForConflict: conflictRange{baseStart: 1, baseEnd: 1, oursStart: 1, oursEnd: 2, theirsStart: 1, theirsEnd: 1},
wantStart: 1,
wantLineCount: 3,
wantMarkerIndex: 1,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
doc := markers.Document{
Segments: tt.segments,
Conflicts: []markers.ConflictRef{{SegmentIndex: tt.conflictSegmentIndex}},
}

lines, start := buildPaneLinesFromEntries(doc, tt.selectedPane, 0, tt.selectedSide, tt.entries, []conflictRange{tt.rangeForConflict})
if start != tt.wantStart {
t.Fatalf("start = %d, want %d", start, tt.wantStart)
}
if len(lines) != tt.wantLineCount {
t.Fatalf("lines len = %d, want %d", len(lines), tt.wantLineCount)
}

startMarker := fmt.Sprintf(">> selected hunk start (%s) >>", sideLabel(tt.selectedPane))
if lines[tt.wantMarkerIndex].text != startMarker {
t.Fatalf("lines[%d].text = %q, want %q", tt.wantMarkerIndex, lines[tt.wantMarkerIndex].text, startMarker)
}
if !lines[tt.wantMarkerIndex].selected {
t.Fatalf("start marker should be selected: %+v", lines[tt.wantMarkerIndex])
}
if lines[tt.wantMarkerIndex+1].text != ">> selected hunk end >>" {
t.Fatalf("lines[%d].text = %q", tt.wantMarkerIndex+1, lines[tt.wantMarkerIndex+1].text)
}
if !lines[tt.wantMarkerIndex+1].selected {
t.Fatalf("end marker should be selected: %+v", lines[tt.wantMarkerIndex+1])
}

for i, line := range lines {
if i == tt.wantMarkerIndex || i == tt.wantMarkerIndex+1 {
continue
}
if line.selected {
t.Fatalf("non-marker line %d should not be selected: %+v", i, line)
}
}
})
}
}

func TestBuildResultLinesFromEntriesUnresolvedRange(t *testing.T) {
entries := []lineEntry{{text: "ours", category: categoryAdded, baseIndex: -1}}
ranges := []resultRange{{start: 0, end: 1, resolved: false}}
Expand Down
Loading