From acfc334cc9d3711f217191d7957157c750d0067a Mon Sep 17 00:00:00 2001 From: neo Date: Thu, 19 Mar 2026 11:50:10 +0900 Subject: [PATCH] Fix empty-side conflict pane alignment in full diff mode Map conflict ranges by document order so empty conflict sides anchor to their true insertion point, and add regressions for middle, BOF, and EOF cases. --- internal/tui/render_helpers.go | 112 +++++++++------- internal/tui/render_helpers_test.go | 198 ++++++++++++++++++++++++++++ 2 files changed, 266 insertions(+), 44 deletions(-) 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}}