feat: diff syntax highlighting.
0ec536da2b6338c4ac6867dcb38d7218545cbb5c
Kevin Kortum <kevinkortum@me.com> · 2026-07-04 21:07
parent 2f358a30
7 files changed
- → Merge-conflict-marker-highlighting-in-the-editor.md +27 −0
@@ -0,0 +1,27 @@
+---
+id: TASK-037
+title: Merge-conflict marker highlighting in the editor
+status: "\U0001F3C1 Done"
+assignee: []
+created_date: '2026-07-05 03:51'
+updated_date: '2026-07-05 04:04'
+labels:
+ - feature
+dependencies: []
+priority: medium
+ordinal: 36000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+When a file has git conflict markers (<<<<<<< HEAD / ======= / >>>>>>> branch, plus ||||||| base for diff3), highlight the marker lines in bold and tint the two version blocks in slightly different background colors. Implemented as a post-pass over scanned spans in buildVisual so it applies to both prose (.md/.txt) and code files. Adds ConflictMarker/ConflictOurs/ConflictTheirs theme colors.
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 Conflict marker lines (<<<<<<<, |||||||, =======, >>>>>>>) render bold in a distinct color
+- [ ] #2 The 'ours' and 'theirs' blocks between markers get slightly different background tints
+- [ ] #3 Markers only detected inside a conflict block (no false hits on setext ===== or ==highlight==)
+- [ ] #4 Works in both prose and code files; markup-visible invariant preserved
+<!-- AC:END -->
internal/editor/scanner.go +52 −0
@@ -14,7 +14,59 @@ headingRe = regexp.MustCompile(`^\s*#{1,6}\s`)
listRe = regexp.MustCompile(`^\s*([-*+]|\d+\.)\s`)
blockquoteRe = regexp.MustCompile(`^\s*>\s?`)
refDefRe = regexp.MustCompile(`^\s*\[[^\]]+\]:\s*`) // [ref]: url
+
+ // Git merge-conflict markers, anchored at column 0. Git writes exactly seven
+ // marker characters followed by a space and a label (or end of line).
+ conflictOursRe = regexp.MustCompile(`^<{7}( |$)`) // <<<<<<< HEAD
+ conflictBaseRe = regexp.MustCompile(`^\|{7}( |$)`) // ||||||| base (diff3)
+ conflictSepRe = regexp.MustCompile(`^={7}\s*$`) // =======
+ conflictTheirsRe = regexp.MustCompile(`^>{7}( |$)`) // >>>>>>> branch
)
+
+// conflictPhase tracks which side of a merge conflict a line falls in.
+type conflictPhase int
+
+const (
+ conflictNone conflictPhase = iota
+ conflictOurs // between <<<<<<< and ||||||| / =======
+ conflictBase // between ||||||| and ======= (diff3)
+ conflictTheirs // between ======= and >>>>>>>
+)
+
+// applyConflictHighlight restyles conflict marker lines and tints the two
+// version blocks between them, mutating spans in place. It runs as a post-pass
+// over already-scanned spans so it works for both the prose and code scanners.
+// Marker characters are only recognized as markers once inside a conflict block
+// (opened by <<<<<<<), so a setext ===== underline or a lone ======= elsewhere
+// is never mistaken for a separator. The markup-visible invariant is preserved:
+// marker lines become one whole-line span, content lines only gain a background.
+func applyConflictHighlight(all [][]Span, lines []string, th theme.Theme) {
+ if len(all) != len(lines) {
+ return
+ }
+ marker := lipgloss.NewStyle().Foreground(th.ConflictMarker).Bold(true)
+ phase := conflictNone
+ for i, line := range lines {
+ switch {
+ case phase == conflictNone && conflictOursRe.MatchString(line):
+ phase = conflictOurs
+ all[i] = wholeLine(line, marker)
+ case phase != conflictNone && conflictBaseRe.MatchString(line):
+ phase = conflictBase
+ all[i] = wholeLine(line, marker)
+ case phase != conflictNone && conflictSepRe.MatchString(line):
+ phase = conflictTheirs
+ all[i] = wholeLine(line, marker)
+ case phase != conflictNone && conflictTheirsRe.MatchString(line):
+ phase = conflictNone
+ all[i] = wholeLine(line, marker)
+ case phase == conflictOurs || phase == conflictBase:
+ all[i] = withBackground(all[i], th.ConflictOurs)
+ case phase == conflictTheirs:
+ all[i] = withBackground(all[i], th.ConflictTheirs)
+ }
+ }
+}
// blockState carries cross-line context (fenced code, leading frontmatter).
type blockState struct {
internal/editor/scanner_test.go +91 −0
@@ -290,3 +290,94 @@ if out[7][0].Style.GetForeground() != th.Text {
t.Errorf("post-frontmatter line should be plain text")
}
}
+
+func TestConflictHighlight(t *testing.T) {
+ th := theme.FlexokiDark()
+ lines := []string{
+ "before",
+ "<<<<<<< HEAD",
+ "our change",
+ "=======",
+ "their change",
+ ">>>>>>> feature",
+ "after",
+ }
+ all := ScanLines(lines, th)
+ applyConflictHighlight(all, lines, th)
+
+ // Markup-visible invariant: span text still reproduces each raw line.
+ for i, line := range lines {
+ if spanText(all[i]) != line {
+ t.Errorf("line %d: spanText %q != raw %q", i, spanText(all[i]), line)
+ }
+ }
+
+ // The three marker lines are one bold span in the marker color.
+ for _, i := range []int{1, 3, 5} {
+ if len(all[i]) != 1 {
+ t.Fatalf("marker line %d: want 1 span, got %d", i, len(all[i]))
+ }
+ sp := all[i][0]
+ if sp.Style.GetForeground() != th.ConflictMarker || !sp.Style.GetBold() {
+ t.Errorf("marker line %d not bold ConflictMarker (fg=%v bold=%v)",
+ i, sp.Style.GetForeground(), sp.Style.GetBold())
+ }
+ }
+
+ // "ours" content tinted with ConflictOurs.
+ if all[2][0].Style.GetBackground() != th.ConflictOurs {
+ t.Errorf("ours block bg = %v, want ConflictOurs %v", all[2][0].Style.GetBackground(), th.ConflictOurs)
+ }
+ // "theirs" content tinted with ConflictTheirs.
+ if all[4][0].Style.GetBackground() != th.ConflictTheirs {
+ t.Errorf("theirs block bg = %v, want ConflictTheirs %v", all[4][0].Style.GetBackground(), th.ConflictTheirs)
+ }
+ // Lines outside the conflict are untouched (no conflict tint).
+ if bg := all[0][0].Style.GetBackground(); bg == th.ConflictOurs || bg == th.ConflictTheirs {
+ t.Errorf("pre-conflict line should not be tinted, got %v", bg)
+ }
+ if bg := all[6][0].Style.GetBackground(); bg == th.ConflictOurs || bg == th.ConflictTheirs {
+ t.Errorf("post-conflict line should not be tinted, got %v", bg)
+ }
+}
+
+func TestConflictDiff3BaseBlock(t *testing.T) {
+ th := theme.FlexokiDark()
+ lines := []string{
+ "<<<<<<< HEAD",
+ "ours",
+ "||||||| base",
+ "original",
+ "=======",
+ "theirs",
+ ">>>>>>> branch",
+ }
+ all := ScanLines(lines, th)
+ applyConflictHighlight(all, lines, th)
+
+ // ||||||| is a marker line.
+ if len(all[2]) != 1 || all[2][0].Style.GetForeground() != th.ConflictMarker {
+ t.Errorf("||||||| base line not marked as a conflict marker")
+ }
+ // The base block shares the "ours" tint.
+ if all[3][0].Style.GetBackground() != th.ConflictOurs {
+ t.Errorf("base block bg = %v, want ConflictOurs", all[3][0].Style.GetBackground())
+ }
+}
+
+func TestConflictSeparatorNotFalselyDetected(t *testing.T) {
+ th := theme.FlexokiDark()
+ // A lone ======= (setext-ish underline) outside a conflict must NOT be
+ // treated as a separator or gain a conflict tint.
+ lines := []string{"Title", "=======", "body"}
+ all := ScanLines(lines, th)
+ applyConflictHighlight(all, lines, th)
+ for i := range lines {
+ if len(all[i]) == 0 {
+ continue
+ }
+ if bg := all[i][0].Style.GetBackground(); bg == th.ConflictOurs || bg == th.ConflictTheirs {
+ t.Errorf("line %d falsely tinted as conflict content", i)
+ }
+ }
+}
internal/editor/wrap.go +1 −0
@@ -98,6 +98,7 @@ }
if e.spellActive() {
all = e.spellPass(all)
}
+ applyConflictHighlight(all, e.Lines, e.theme)
var rows []vrow
for li := range e.Lines {
for _, s := range wrapLine(e.Lines[li], e.Width) {
internal/theme/theme.go +5 −0
@@ -25,6 +25,11 @@ Accent lipgloss.Color // frontmatter keys, selection
Highlight lipgloss.Color // ==highlight== background tint
Spell lipgloss.Color // misspelled-word undercurl (red)
+ // Merge-conflict highlighting (git markers <<<<<<< ||||||| ======= >>>>>>>).
+ ConflictMarker lipgloss.Color // bold marker lines
+ ConflictOurs lipgloss.Color // background tint for the "ours"/base block
+ ConflictTheirs lipgloss.Color // background tint for the "theirs" block
+
// UI colors.
Background lipgloss.Color
Muted lipgloss.Color // markup punctuation, dimmed
internal/theme/theme_test.go +2 −0
@@ -17,6 +17,8 @@ "Blockquote": th.Blockquote, "Comment": th.Comment, "Accent": th.Accent,
"Highlight": th.Highlight, "Background": th.Background, "Muted": th.Muted,
"StatusFg": th.StatusFg, "StatusBg": th.StatusBg, "SelFg": th.SelFg,
"SelBg": th.SelBg, "Pointer": th.Pointer,
+ "ConflictMarker": th.ConflictMarker, "ConflictOurs": th.ConflictOurs,
+ "ConflictTheirs": th.ConflictTheirs,
}
for name, c := range colors {
if c == "" {
internal/theme/themes.go +30 −15
@@ -29,11 +29,16 @@ Comment: lipgloss.Color("#DA702C"), // orange-400 — visible meta
Accent: lipgloss.Color("#D0A215"), // yellow-400
Highlight: lipgloss.Color("#3A3517"), // deep olive — ==highlight== bg
Spell: lipgloss.Color("#D14D41"), // red-400 — misspell undercurl
- StatusFg: lipgloss.Color("#100F0F"),
- StatusBg: lipgloss.Color("#4385BE"),
- SelFg: lipgloss.Color("#100F0F"),
- SelBg: lipgloss.Color("#D0A215"),
- Pointer: lipgloss.Color("#CE5D97"),
+
+ ConflictMarker: lipgloss.Color("#8B7EC8"), // purple-400 — conflict marker lines
+ ConflictOurs: lipgloss.Color("#1A1E0C"), // green-950 — "ours" block tint
+ ConflictTheirs: lipgloss.Color("#261312"), // red-950 — "theirs" block tint
+
+ StatusFg: lipgloss.Color("#100F0F"),
+ StatusBg: lipgloss.Color("#4385BE"),
+ SelFg: lipgloss.Color("#100F0F"),
+ SelBg: lipgloss.Color("#D0A215"),
+ Pointer: lipgloss.Color("#CE5D97"),
}
}
@@ -57,11 +62,16 @@ Comment: lipgloss.Color("#BC5215"), // orange-600 — visible meta
Accent: lipgloss.Color("#AD8301"), // yellow-600
Highlight: lipgloss.Color("#F0E6BE"), // pale yellow — ==highlight== bg
Spell: lipgloss.Color("#AF3029"), // red-600 — misspell undercurl
- StatusFg: lipgloss.Color("#FFFCF0"),
- StatusBg: lipgloss.Color("#205EA6"),
- SelFg: lipgloss.Color("#FFFCF0"),
- SelBg: lipgloss.Color("#AD8301"),
- Pointer: lipgloss.Color("#A02F6F"),
+
+ ConflictMarker: lipgloss.Color("#5E409D"), // purple-600 — conflict marker lines
+ ConflictOurs: lipgloss.Color("#EDEECF"), // green-50 — "ours" block tint
+ ConflictTheirs: lipgloss.Color("#FFE1D5"), // red-50 — "theirs" block tint
+
+ StatusFg: lipgloss.Color("#FFFCF0"),
+ StatusBg: lipgloss.Color("#205EA6"),
+ SelFg: lipgloss.Color("#FFFCF0"),
+ SelBg: lipgloss.Color("#AD8301"),
+ Pointer: lipgloss.Color("#A02F6F"),
}
}
@@ -85,10 +95,15 @@ Comment: lipgloss.Color("#FFB454"),
Accent: lipgloss.Color("#FFD500"),
Highlight: lipgloss.Color("#3A2E4D"),
Spell: lipgloss.Color("#FF5F87"), // pink-red — misspell undercurl
- StatusFg: lipgloss.Color("#16161E"),
- StatusBg: lipgloss.Color("#6B50FF"),
- SelFg: lipgloss.Color("#16161E"),
- SelBg: lipgloss.Color("#FF5FAF"),
- Pointer: lipgloss.Color("#00FFA3"),
+
+ ConflictMarker: lipgloss.Color("#B794F6"), // soft purple — conflict marker lines
+ ConflictOurs: lipgloss.Color("#17251C"), // dark green — "ours" block tint
+ ConflictTheirs: lipgloss.Color("#2A1518"), // dark red — "theirs" block tint
+
+ StatusFg: lipgloss.Color("#16161E"),
+ StatusBg: lipgloss.Color("#6B50FF"),
+ SelFg: lipgloss.Color("#16161E"),
+ SelBg: lipgloss.Color("#FF5FAF"),
+ Pointer: lipgloss.Color("#00FFA3"),
}
}