diff --git a/apps/web/src/components/merge-conflicts/ConflictMarkerViewer.test.ts b/apps/web/src/components/merge-conflicts/ConflictMarkerViewer.test.ts new file mode 100644 index 000000000..7bc30bd9f --- /dev/null +++ b/apps/web/src/components/merge-conflicts/ConflictMarkerViewer.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; +import { hasConflictMarkers, parseConflictMarkers } from "./ConflictMarkerViewer"; + +describe("parseConflictMarkers", () => { + it("parses a single conflict block", () => { + const input = [ + "line before", + "<<<<<<< HEAD", + "ours line 1", + "ours line 2", + "=======", + "theirs line 1", + ">>>>>>> feature-branch", + "line after", + ].join("\n"); + + const lines = parseConflictMarkers(input); + expect(lines).toEqual([ + { kind: "context", text: "line before", lineNumber: 1 }, + { kind: "ours-header", text: "<<<<<<< HEAD", lineNumber: 2 }, + { kind: "ours", text: "ours line 1", lineNumber: 3 }, + { kind: "ours", text: "ours line 2", lineNumber: 4 }, + { kind: "separator", text: "=======", lineNumber: 5 }, + { kind: "theirs", text: "theirs line 1", lineNumber: 6 }, + { kind: "theirs-header", text: ">>>>>>> feature-branch", lineNumber: 7 }, + { kind: "context", text: "line after", lineNumber: 8 }, + ]); + }); + + it("parses multiple conflict blocks", () => { + const input = [ + "<<<<<<< HEAD", + "a", + "=======", + "b", + ">>>>>>> branch", + "middle", + "<<<<<<< HEAD", + "c", + "=======", + "d", + ">>>>>>> branch", + ].join("\n"); + + const lines = parseConflictMarkers(input); + expect(lines.filter((l) => l.kind === "ours-header")).toHaveLength(2); + expect(lines.filter((l) => l.kind === "theirs-header")).toHaveLength(2); + expect(lines.filter((l) => l.kind === "context")).toHaveLength(1); + }); + + it("handles file with no conflict markers", () => { + const input = "just a normal file\nwith two lines"; + const lines = parseConflictMarkers(input); + expect(lines).toEqual([ + { kind: "context", text: "just a normal file", lineNumber: 1 }, + { kind: "context", text: "with two lines", lineNumber: 2 }, + ]); + }); + + it("handles trailing newline without adding an extra empty line", () => { + const input = "line 1\nline 2\n"; + const lines = parseConflictMarkers(input); + expect(lines).toHaveLength(2); + }); + + it("handles empty ours side", () => { + const input = ["<<<<<<< HEAD", "=======", "theirs only", ">>>>>>> branch"].join("\n"); + + const lines = parseConflictMarkers(input); + expect(lines.filter((l) => l.kind === "ours")).toHaveLength(0); + expect(lines.filter((l) => l.kind === "theirs")).toHaveLength(1); + }); +}); + +describe("hasConflictMarkers", () => { + it("returns true when markers are present", () => { + expect(hasConflictMarkers("<<<<<<< HEAD\nfoo\n=======\nbar\n>>>>>>> b")).toBe(true); + }); + + it("returns true when markers start at beginning of text", () => { + expect(hasConflictMarkers("<<<<<<< HEAD")).toBe(true); + }); + + it("returns false for normal text", () => { + expect(hasConflictMarkers("just some normal code")).toBe(false); + }); + + it("returns false for empty string", () => { + expect(hasConflictMarkers("")).toBe(false); + }); +}); diff --git a/apps/web/src/components/merge-conflicts/ConflictMarkerViewer.tsx b/apps/web/src/components/merge-conflicts/ConflictMarkerViewer.tsx new file mode 100644 index 000000000..04cab8b10 --- /dev/null +++ b/apps/web/src/components/merge-conflicts/ConflictMarkerViewer.tsx @@ -0,0 +1,192 @@ +import { useMemo } from "react"; +import { cn } from "~/lib/utils"; + +/** + * Represents a segment of a file parsed around Git conflict markers. + * + * - `"context"` – lines outside any conflict block + * - `"ours-header"` – the `<<<<<<< branch` line + * - `"ours"` – lines belonging to the current (local) side + * - `"separator"` – the `=======` divider + * - `"theirs"` – lines belonging to the incoming (remote) side + * - `"theirs-header"` – the `>>>>>>> branch` line + */ +type ConflictSegmentKind = + | "context" + | "ours-header" + | "ours" + | "separator" + | "theirs" + | "theirs-header"; + +interface ConflictLine { + kind: ConflictSegmentKind; + text: string; + lineNumber: number; +} + +// --------------------------------------------------------------------------- +// Parser +// --------------------------------------------------------------------------- + +const MARKER_OURS_RE = /^<{7} (.+)$/; +const MARKER_SEPARATOR_RE = /^={7}$/; +const MARKER_THEIRS_RE = /^>{7} (.+)$/; + +export function parseConflictMarkers(contents: string): ConflictLine[] { + const rawLines = contents.split("\n"); + // Drop a single trailing empty line that split() creates for files ending in \n + if (rawLines.length > 0 && rawLines[rawLines.length - 1] === "") { + rawLines.pop(); + } + + const result: ConflictLine[] = []; + let insideConflict: "ours" | "theirs" | false = false; + + for (let i = 0; i < rawLines.length; i++) { + const line = rawLines[i]!; + const lineNumber = i + 1; + + if (MARKER_OURS_RE.test(line)) { + insideConflict = "ours"; + result.push({ kind: "ours-header", text: line, lineNumber }); + } else if (insideConflict === "ours" && MARKER_SEPARATOR_RE.test(line)) { + insideConflict = "theirs"; + result.push({ kind: "separator", text: line, lineNumber }); + } else if (insideConflict === "theirs" && MARKER_THEIRS_RE.test(line)) { + insideConflict = false; + result.push({ kind: "theirs-header", text: line, lineNumber }); + } else if (insideConflict === "ours") { + result.push({ kind: "ours", text: line, lineNumber }); + } else if (insideConflict === "theirs") { + result.push({ kind: "theirs", text: line, lineNumber }); + } else { + result.push({ kind: "context", text: line, lineNumber }); + } + } + + return result; +} + +const HAS_CONFLICT_RE = /(?:^|\n)<{7} /; + +export function hasConflictMarkers(text: string): boolean { + return HAS_CONFLICT_RE.test(text); +} + +// --------------------------------------------------------------------------- +// Styling helpers – emulate GitHub's web conflict resolver +// --------------------------------------------------------------------------- + +function lineClassName(kind: ConflictSegmentKind): string { + switch (kind) { + case "ours-header": + return "bg-emerald-500/18 text-emerald-200 font-semibold"; + case "ours": + return "bg-emerald-500/10"; + case "separator": + return "bg-border/25 text-muted-foreground font-semibold"; + case "theirs": + return "bg-sky-500/10"; + case "theirs-header": + return "bg-sky-500/18 text-sky-200 font-semibold"; + case "context": + return ""; + } +} + +function gutterClassName(kind: ConflictSegmentKind): string { + switch (kind) { + case "ours-header": + case "ours": + return "border-e-emerald-500/40 text-emerald-400/60"; + case "separator": + return "border-e-border/50 text-muted-foreground/50"; + case "theirs-header": + case "theirs": + return "border-e-sky-500/40 text-sky-400/60"; + case "context": + return "border-e-border/30 text-muted-foreground/40"; + } +} + +function sectionLabel(kind: ConflictSegmentKind): string | null { + switch (kind) { + case "ours-header": + return "Current changes"; + case "theirs-header": + return "Incoming changes"; + default: + return null; + } +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +export function ConflictMarkerViewer({ + content, + className, +}: { + content: string; + className?: string; +}) { + const lines = useMemo(() => parseConflictMarkers(content), [content]); + const gutterWidth = String(lines.length).length; + + return ( +
| + {line.lineNumber} + | + + {/* Section label badge (only on marker header lines) */} + {label ? ( ++ + {label} + + | + ) : ( ++ )} + + {/* Code content */} + | + {line.text} + | +
+ Conflicted files +
++ OK Code could not derive a safe patch. Resolve the markers manually + or use the viewer below. +
++ {activeConflictFile} +
++ Resolve the conflict markers below or open the file in your editor. +
+
+ {conflictFileContentQuery.data.contents}
+
+ )}
+
- {selectedCandidate.previewPatch}
-
+ hasConflictMarkers(selectedCandidate.previewPatch) ? (
+
+ {selectedCandidate.previewPatch}
+
+