In short: a ConflictTracker re-parses every visible editor whenever its document changes.
The parser walks the lines looking for <<<<<<< conflict …
>>>>>>> conflict blocks and splits each into sides, one per
+++++++ snapshot or %%%%%%%/\\\\\\\ diff header that names a change
ID and a commit ID. A decorator paints the block, and a CodeLens provider offers
"Accept all sides" and one "Accept this side" per side. The two commands turn a side's text back into
plain content (applying a diff section, copying a snapshot section) and replace the whole block with it.
Only jj's default diff marker style with stored labels is understood. Git-style markers,
------- base sections and longer-than-seven markers are not.
1. Wiring
activateMergeConflict is called from the
JjUiBase constructor (ui.ts:27; see
Activation and wiring). It builds the tracker
first, hands it to the decorator and the CodeLens provider, registers the two commands, and returns
all of them as one Disposable.from(…). package.json declares the two commands
(source) and the third-side colour
(source). There are no menu entries or keybindings, so CodeLens is
the only way in.
2. The marker grammar
parseTextDocument is one pass over the document's
lines with a single open ConflictBuilder. Every rule is a prefix test on the line text:
| Line starts with | Effect | Source |
|---|---|---|
<<<<<<< conflict | Opens a new conflict at this line. An unfinished one is dropped. | 78–81 |
| anything, outside a conflict | Ignored. | 82–84 |
\\\\\\\, when the previous line starts with %%%%%%% | A diff section. Closes the open side at the end of the line before the %%%%%%% header. If this line names a change, opens a side that starts at the %%%%%%% line. | 87–112 |
+++++++ | A snapshot section. Closes the open side at the end of the previous line. If this line names a change, opens a side starting here. | 89–113 |
>>>>>>> conflict | Closes the open side, then emits the conflict, but only if it has at least one side. | 86–120 |
| anything else, inside a conflict | Content of the open side, or of nothing if no side is open. | — |
A header names a change when getChangeId matches
SIDE_HEADER_REGEX,
/\s+(\w+)\s+\w+(?:\s+".*")?(?:\s+\(.*\))*$/. Read from the end of the line: a change ID and a
commit ID (both word characters), then an optional quoted description, then any number of parenthesised
notes such as (no terminating newline). The captured word is the change ID. That is the shape
of jj's stored conflict labels. jj's fallback labels (side #1, base) do not
match, because # is not a word character. A side's range runs from its header line (the
%%%%%%% line for a diff section) to the end of its last content line
(ConflictSide). The doc comment shows the full shape
(source):
<<<<<<< conflict 1 of 1
%%%%%%% diff from: base_change_id base_commit_id "base commit message" (blah)
\\\\\\\ to: change_id_1 commit_id_1 (blah)
apple
-grape
+grapefruit
orange
+++++++ change_id_2 commit_id_2 "commit message 2" (blah)
APPLE
GRAPE
ORANGE
>>>>>>> conflict 1 of 1 ends
The base's IDs on the %%%%%%% line are never read. The side takes its change ID from the
to: line, which is the side the diff produces.
Test vectors
parseTextDocument (parser_test.ts). Lines are
0-based. "Side" gives the change ID and the line range.
| Case | Input shape | Result | Source |
|---|---|---|---|
| 1 side | begin, %%%%%%%, \\\\\\\ to:, 4 diff lines, end | begin 0; change_id_1 1–6; end 7 | 27–59 |
| 2 sides | the doc-comment shape above | change_id_1 1–6; change_id_2 7–10; end 11 | 61–104 |
| 3 sides | one diff section, then two +++++++ sections | change_id_1 1–3; change_id_2 4–5; change_id_3 6–7; end 8 | 106–153 |
| Missing sides | begin then end | no conflicts | 155–163 |
| Git style | <<<<<<< HEAD:file.txt / ======= / >>>>>>> <sha> | no conflicts | 165–176 |
| No change IDs | bare %%%%%%% and bare +++++++ | no conflicts | 178–191 |
No to: line, bare +++++++ | %%%%%%% diff from: … then an unlabelled snapshot | no conflicts | 193–206 |
| No diff header | begin, a content line, labelled +++++++ | change_id_2 2–3; end 4; line 1 belongs to no side | 208–237 |
No to: line, labelled +++++++ | %%%%%%% diff from: …, a content line, labelled snapshot | change_id_2 3–4; end 5; lines 1–2 belong to no side | 239–269 |
getChangeId (parser_test.ts):
| Header | Result |
|---|---|
+++++++ change_id commit_id, also with (foo), (foo) (no terminating newline), "commit message", or "commit message" (foo) | change_id |
\\\\\\\ to: change_id commit_id with the same five tails | change_id |
"", side #2 | undefined |
+++++++ change_id, also with "commit message" or "commit message" (foo) | undefined: a commit ID is required |
\\\\\\\ to: change_id, with the same three tails | undefined |
3. Types
| Field | Type | Meaning | Source |
|---|---|---|---|
Conflict.beginLine | vscode.Range | The <<<<<<< conflict line. | parser.ts |
Conflict.sides | ConflictSide[] | Sides in file order. Never empty for an emitted conflict. | parser.ts |
Conflict.endLine | vscode.Range | The >>>>>>> conflict … ends line. | parser.ts |
ConflictSide.changeId | string | The change ID from the side's header. Carried along but not used by any consumer at this pin. | parser.ts |
ConflictSide.range | vscode.Range | All of the side's lines, header lines included. | parser.ts |
ConflictEvent.editor | vscode.TextEditor | The editor that was last processed for this document. | tracker.ts |
ConflictEvent.conflicts | Conflict[] | The parse result. May be empty. | tracker.ts |
ConflictEvent.version | number | TextDocument.version at parse time. Used to skip re-parsing. | tracker.ts |
4. The tracker
ConflictTracker keeps a
HashMap<Uri, ConflictEvent> (source). On
construction it processes every visible editor. It then listens for text-document changes (re-processing
each visible editor showing that document) and for changes to the set of visible editors
(source). Only visible editors are ever parsed.
processEditor re-parses only when the document
version differs from the cached one; otherwise it just updates the cached editor. Either way it fires
onDidChange, so a decorator sees every editor that becomes visible again.
getConflict(uri) returns the cached event, and getConflicts() only those with at
least one conflict (source). Entries are never removed, so a
closed document's last parse stays in the map.
5. Decorations
ConflictDecorator creates five whole-line decoration
types, each with a background and an overview-ruler colour in the Full lane
(generateBlockRenderOptions):
| Applied to | Background | Overview ruler | Source |
|---|---|---|---|
| Begin line | merge.commonContentBackground | editorOverviewRuler.commonContentForeground | 26–33 |
| End line | merge.commonContentBackground | editorOverviewRuler.commonContentForeground | 34–40 |
| Sides 1, 4, 7, … | merge.currentContentBackground | editorOverviewRuler.currentContentForeground | 42–47 |
| Sides 2, 5, 8, … | merge.incomingContentBackground | editorOverviewRuler.incomingContentForeground | 48–53 |
| Sides 3, 6, 9, … | jj.mergeConflict.thirdSideBackground | the same colour | 54–62 |
The first four colours are VS Code's own theme colours for its built-in Git merge-conflict support, so jj
conflicts look like Git ones. VS Code has no third colour, so the extension contributes
jj.mergeConflict.thirdSideBackground, defaulting to #ff800033 (light) and
#ff983133 (dark) (package.json). More sides cycle
through the three (source). Because a side's range includes its
header, each header line takes its side's colour. The diff's +/- lines are not
coloured separately.
What upstream's test calls the "minimap" (decorator_test.ts) is
the overview ruler: the marks in the editor's scrollbar, across its full width. No minimap
render option is set. Each onDidChange event resets all five types on that editor with
setDecorations, so an empty parse clears them (source).
The constructor first decorates the editors the tracker has already parsed, because those events fired
before the decorator subscribed (source).
6. CodeLens
ConflictCodeLensProvider registers itself for the
file, vscode-vfs, untitled and vscode-userdata
schemes (source). Its doc comment presents it as the jj counterpart
of VS Code's built-in "Accept Current Change" lenses. For each conflict in
tracker.getConflict(document.uri), provideCodeLenses returns an
Accept all sides lens on the begin line (command jj.mergeConflict.acceptAllSides,
arguments [conflict]), then an Accept this side lens on each side's range
(jj.mergeConflict.acceptOneSide, [conflict, i]). It returns
null for a document the tracker has never seen (source;
tested in codelens_test.ts). The provider reads the tracker's
cache and never parses, as its doc comment asks (source).
7. The resolver and the two commands
getResolvedText(text) turns one side's text, header
included, into plain content:
- Drop the first line (the header). If it started with
%%%%%%%and the next line starts with\\\\\\\, drop that too. - If the header was
+++++++, return the rest unchanged. A snapshot is already content. - Otherwise, if any non-empty line starts with something other than a space,
+or-, return the rest unchanged. The user has edited the diff by hand, and stripping would guess wrong (source). - Otherwise apply the diff: drop
-lines and strip the first character of the rest.
getResolvedTextForAllSides resolves each side, drops
empty results, and joins the rest with \n. Upstream's cases
(resolver_test.ts):
| Input | Output | Source |
|---|---|---|
Diff side: apple, -grape, +grapefruit, orange | apple / grapefruit / orange | 26–37 |
The same lines under a +++++++ header | unchanged, prefixes kept | 39–50 |
| Diff side whose first line lost its leading space | unchanged, prefixes kept | 52–65 |
All sides: diff apple/-grape + snapshot GRAPE | apple\nGRAPE | 80–92 |
| All sides: same diff + empty snapshot | apple | 94–105 |
All sides: diff -grape only + snapshot GRAPE | GRAPE | 107–117 |
All sides: diff -grape only + empty snapshot | "" | 119–128 |
The commands (commands.ts) take the Conflict from the
lens arguments and read the text from the active editor. acceptAllSides resolves every
side. acceptOneSide resolves side i. Both throw if the arguments are missing or
there is no active editor. Each then replaces the whole block, from the start of the begin line to the
end of the end line, with the result in one editor.edit. If the result is empty, the range
extends to the start of the following line, so the block disappears without leaving a blank line
(getRangeToReplace). Nothing calls jj. The file is just
edited, and the user saves it.
8. How this maps to jj's conflicts
jj stores a conflicted file as a Merge of terms and writes markers into the working copy only
when it materializes it (/jj: materialization). In jj's default
diff style, a conflict is one +++++++ snapshot plus a
%%%%%%%/\\\\\\\ diff from a base to each remaining side, and that is the layout
this parser expects. Applying a diff section yields that side's content, which is why
"Accept this side" on a diff section gives the side and not the base. When the file is saved, jj's next
snapshot parses it (/jj: parsing markers on snapshot). With no markers
left, the path is resolved to the edited content. The extension and jj never talk to each other here.
The text on disk is the only interface.
Mismatches that follow from reading the code. None was observed in a running VS Code:
- Other marker styles. The
snapshotstyle's-------base sections are not side boundaries, so they become part of the preceding side's range and its "Accept" text. Git-style markers are ignored (tested). The parser's TODO points at jj's alternative marker styles (source). - Longer markers. jj lengthens its markers beyond seven characters when the file
already contains marker-like lines (/jj). The begin and end tests
require exactly seven
</>followed by a space, so such conflicts are not found. - Unlabelled terms. Without stored conflict labels jj writes
side #N.getChangeIdrejects those, so no side opens and the conflict is dropped.
9. Loose ends at this pin
ConflictSide.changeIdis parsed and tested but read by nothing.- The decorator's five decoration types and the CodeLens registration aren't added to any disposable list (decorator.ts, codelens.ts), so disposing the bundle doesn't remove them. In practice this only matters on deactivation.
- The commands edit the active editor's document, not the document the lens came from. They agree whenever the lens was clicked.