diff --git a/internal/tui/render_helpers.go b/internal/tui/render_helpers.go index 190d8be..98f1077 100644 --- a/internal/tui/render_helpers.go +++ b/internal/tui/render_helpers.go @@ -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, @@ -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 := "" @@ -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 { @@ -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() } @@ -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) { diff --git a/internal/tui/render_helpers_test.go b/internal/tui/render_helpers_test.go index a0a330d..9a6b7ec 100644 --- a/internal/tui/render_helpers_test.go +++ b/internal/tui/render_helpers_test.go @@ -1,6 +1,7 @@ package tui import ( + "fmt" "testing" "github.com/chojs23/ec/internal/markers" @@ -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}}