@codemirror/merge
Version:
A diff/merge view for CodeMirror
1,275 lines (1,266 loc) • 71.1 kB
JavaScript
'use strict';
var view = require('@codemirror/view');
var state = require('@codemirror/state');
var styleMod = require('style-mod');
var language = require('@codemirror/language');
var highlight = require('@lezer/highlight');
// This algorithm was heavily inspired by Neil Fraser's
// diff-match-patch library. See https://github.com/google/diff-match-patch/
/**
A changed range.
*/
class Change {
constructor(
/**
The start of the change in document A.
*/
fromA,
/**
The end of the change in document A. This is equal to `fromA`
in case of insertions.
*/
toA,
/**
The start of the change in document B.
*/
fromB,
/**
The end of the change in document B. This is equal to `fromB`
for deletions.
*/
toB) {
this.fromA = fromA;
this.toA = toA;
this.fromB = fromB;
this.toB = toB;
}
/**
@internal
*/
offset(offA, offB = offA) {
return new Change(this.fromA + offA, this.toA + offA, this.fromB + offB, this.toB + offB);
}
}
function findDiff(a, fromA, toA, b, fromB, toB) {
if (a == b)
return [];
// Remove identical prefix and suffix
let prefix = commonPrefix(a, fromA, toA, b, fromB, toB);
let suffix = commonSuffix(a, fromA + prefix, toA, b, fromB + prefix, toB);
fromA += prefix;
toA -= suffix;
fromB += prefix;
toB -= suffix;
let lenA = toA - fromA, lenB = toB - fromB;
// Nothing left in one of them
if (!lenA || !lenB)
return [new Change(fromA, toA, fromB, toB)];
// Try to find one string in the other to cover cases with just 2
// deletions/insertions.
if (lenA > lenB) {
let found = a.slice(fromA, toA).indexOf(b.slice(fromB, toB));
if (found > -1)
return [
new Change(fromA, fromA + found, fromB, fromB),
new Change(fromA + found + lenB, toA, toB, toB)
];
}
else if (lenB > lenA) {
let found = b.slice(fromB, toB).indexOf(a.slice(fromA, toA));
if (found > -1)
return [
new Change(fromA, fromA, fromB, fromB + found),
new Change(toA, toA, fromB + found + lenA, toB)
];
}
// Only one character left on one side, does not occur in other
// string.
if (lenA == 1 || lenB == 1)
return [new Change(fromA, toA, fromB, toB)];
// Try to split the problem in two by finding a substring of one of
// the strings in the other.
let half = halfMatch(a, fromA, toA, b, fromB, toB);
if (half) {
let [sharedA, sharedB, sharedLen] = half;
return findDiff(a, fromA, sharedA, b, fromB, sharedB)
.concat(findDiff(a, sharedA + sharedLen, toA, b, sharedB + sharedLen, toB));
}
// Fall back to more expensive general search for a shared
// subsequence.
return findSnake(a, fromA, toA, b, fromB, toB);
}
let scanLimit = 1e9;
let timeout = 0;
let crude = false;
// Implementation of Myers 1986 "An O(ND) Difference Algorithm and Its Variations"
function findSnake(a, fromA, toA, b, fromB, toB) {
let lenA = toA - fromA, lenB = toB - fromB;
if (scanLimit < 1e9 && Math.min(lenA, lenB) > scanLimit * 16 ||
timeout > 0 && Date.now() > timeout) {
if (Math.min(lenA, lenB) > scanLimit * 64)
return [new Change(fromA, toA, fromB, toB)];
return crudeMatch(a, fromA, toA, b, fromB, toB);
}
let off = Math.ceil((lenA + lenB) / 2);
frontier1.reset(off);
frontier2.reset(off);
let match1 = (x, y) => a.charCodeAt(fromA + x) == b.charCodeAt(fromB + y);
let match2 = (x, y) => a.charCodeAt(toA - x - 1) == b.charCodeAt(toB - y - 1);
let test1 = (lenA - lenB) % 2 != 0 ? frontier2 : null, test2 = test1 ? null : frontier1;
for (let depth = 0; depth < off; depth++) {
if (depth > scanLimit || timeout > 0 && !(depth & 63) && Date.now() > timeout)
return crudeMatch(a, fromA, toA, b, fromB, toB);
let done = frontier1.advance(depth, lenA, lenB, off, test1, false, match1) ||
frontier2.advance(depth, lenA, lenB, off, test2, true, match2);
if (done)
return bisect(a, fromA, toA, fromA + done[0], b, fromB, toB, fromB + done[1]);
}
// No commonality at all.
return [new Change(fromA, toA, fromB, toB)];
}
class Frontier {
constructor() {
this.vec = [];
}
reset(off) {
this.len = off << 1;
for (let i = 0; i < this.len; i++)
this.vec[i] = -1;
this.vec[off + 1] = 0;
this.start = this.end = 0;
}
advance(depth, lenX, lenY, vOff, other, fromBack, match) {
for (let k = -depth + this.start; k <= depth - this.end; k += 2) {
let off = vOff + k;
let x = k == -depth || (k != depth && this.vec[off - 1] < this.vec[off + 1])
? this.vec[off + 1] : this.vec[off - 1] + 1;
let y = x - k;
while (x < lenX && y < lenY && match(x, y)) {
x++;
y++;
}
this.vec[off] = x;
if (x > lenX) {
this.end += 2;
}
else if (y > lenY) {
this.start += 2;
}
else if (other) {
let offOther = vOff + (lenX - lenY) - k;
if (offOther >= 0 && offOther < this.len && other.vec[offOther] != -1) {
if (!fromBack) {
let xOther = lenX - other.vec[offOther];
if (x >= xOther)
return [x, y];
}
else {
let xOther = other.vec[offOther];
if (xOther >= lenX - x)
return [xOther, vOff + xOther - offOther];
}
}
}
}
return null;
}
}
// Reused across calls to avoid growing the vectors again and again
const frontier1 = new Frontier, frontier2 = new Frontier;
// Given a position in both strings, recursively call `findDiff` with
// the sub-problems before and after that position. Make sure cut
// points lie on character boundaries.
function bisect(a, fromA, toA, splitA, b, fromB, toB, splitB) {
let stop = false;
if (!validIndex(a, splitA) && ++splitA == toA)
stop = true;
if (!validIndex(b, splitB) && ++splitB == toB)
stop = true;
if (stop)
return [new Change(fromA, toA, fromB, toB)];
return findDiff(a, fromA, splitA, b, fromB, splitB).concat(findDiff(a, splitA, toA, b, splitB, toB));
}
function chunkSize(lenA, lenB) {
let size = 1, max = Math.min(lenA, lenB);
while (size < max)
size = size << 1;
return size;
}
// Common prefix length of the given ranges. Because string comparison
// is so much faster than a JavaScript by-character loop, this
// compares whole chunks at a time.
function commonPrefix(a, fromA, toA, b, fromB, toB) {
if (fromA == toA || fromA == toB || a.charCodeAt(fromA) != b.charCodeAt(fromB))
return 0;
let chunk = chunkSize(toA - fromA, toB - fromB);
for (let pA = fromA, pB = fromB;;) {
let endA = pA + chunk, endB = pB + chunk;
if (endA > toA || endB > toB || a.slice(pA, endA) != b.slice(pB, endB)) {
if (chunk == 1)
return pA - fromA - (validIndex(a, pA) ? 0 : 1);
chunk = chunk >> 1;
}
else if (endA == toA || endB == toB) {
return endA - fromA;
}
else {
pA = endA;
pB = endB;
}
}
}
// Common suffix length
function commonSuffix(a, fromA, toA, b, fromB, toB) {
if (fromA == toA || fromB == toB || a.charCodeAt(toA - 1) != b.charCodeAt(toB - 1))
return 0;
let chunk = chunkSize(toA - fromA, toB - fromB);
for (let pA = toA, pB = toB;;) {
let sA = pA - chunk, sB = pB - chunk;
if (sA < fromA || sB < fromB || a.slice(sA, pA) != b.slice(sB, pB)) {
if (chunk == 1)
return toA - pA - (validIndex(a, pA) ? 0 : 1);
chunk = chunk >> 1;
}
else if (sA == fromA || sB == fromB) {
return toA - sA;
}
else {
pA = sA;
pB = sB;
}
}
}
// a assumed to be be longer than b
function findMatch(a, fromA, toA, b, fromB, toB, size, divideTo) {
let rangeB = b.slice(fromB, toB);
// Try some substrings of A of length `size` and see if they exist
// in B.
let best = null;
for (;;) {
if (best || size < divideTo)
return best;
for (let start = fromA + size;;) {
if (!validIndex(a, start))
start++;
let end = start + size;
if (!validIndex(a, end))
end += end == start + 1 ? 1 : -1;
if (end >= toA)
break;
let seed = a.slice(start, end);
let found = -1;
while ((found = rangeB.indexOf(seed, found + 1)) != -1) {
let prefixAfter = commonPrefix(a, end, toA, b, fromB + found + seed.length, toB);
let suffixBefore = commonSuffix(a, fromA, start, b, fromB, fromB + found);
let length = seed.length + prefixAfter + suffixBefore;
if (!best || best[2] < length)
best = [start - suffixBefore, fromB + found - suffixBefore, length];
}
start = end;
}
if (divideTo < 0)
return best;
size = size >> 1;
}
}
// Find a shared substring that is at least half the length of the
// longer range. Returns an array describing the substring [startA,
// startB, len], or null.
function halfMatch(a, fromA, toA, b, fromB, toB) {
let lenA = toA - fromA, lenB = toB - fromB;
if (lenA < lenB) {
let result = halfMatch(b, fromB, toB, a, fromA, toA);
return result && [result[1], result[0], result[2]];
}
// From here a is known to be at least as long as b
if (lenA < 4 || lenB * 2 < lenA)
return null;
return findMatch(a, fromA, toA, b, fromB, toB, Math.floor(lenA / 4), -1);
}
function crudeMatch(a, fromA, toA, b, fromB, toB) {
crude = true;
let lenA = toA - fromA, lenB = toB - fromB;
let result;
if (lenA < lenB) {
let inv = findMatch(b, fromB, toB, a, fromA, toA, Math.floor(lenA / 6), 50);
result = inv && [inv[1], inv[0], inv[2]];
}
else {
result = findMatch(a, fromA, toA, b, fromB, toB, Math.floor(lenB / 6), 50);
}
if (!result)
return [new Change(fromA, toA, fromB, toB)];
let [sharedA, sharedB, sharedLen] = result;
return findDiff(a, fromA, sharedA, b, fromB, sharedB)
.concat(findDiff(a, sharedA + sharedLen, toA, b, sharedB + sharedLen, toB));
}
function mergeAdjacent(changes, minGap) {
for (let i = 1; i < changes.length; i++) {
let prev = changes[i - 1], cur = changes[i];
if (prev.toA > cur.fromA - minGap && prev.toB > cur.fromB - minGap) {
changes[i - 1] = new Change(prev.fromA, cur.toA, prev.fromB, cur.toB);
changes.splice(i--, 1);
}
}
}
// Reorder and merge changes
function normalize(a, b, changes) {
for (;;) {
mergeAdjacent(changes, 1);
let moved = false;
// Move unchanged ranges that can be fully moved across an
// adjacent insertion/deletion, to simplify the diff.
for (let i = 0; i < changes.length; i++) {
let ch = changes[i], pre, post;
// The half-match heuristic sometimes produces non-minimal
// diffs. Strip matching pre- and post-fixes again here.
if (pre = commonPrefix(a, ch.fromA, ch.toA, b, ch.fromB, ch.toB))
ch = changes[i] = new Change(ch.fromA + pre, ch.toA, ch.fromB + pre, ch.toB);
if (post = commonSuffix(a, ch.fromA, ch.toA, b, ch.fromB, ch.toB))
ch = changes[i] = new Change(ch.fromA, ch.toA - post, ch.fromB, ch.toB - post);
let lenA = ch.toA - ch.fromA, lenB = ch.toB - ch.fromB;
// Only look at plain insertions/deletions
if (lenA && lenB)
continue;
let beforeLen = ch.fromA - (i ? changes[i - 1].toA : 0);
let afterLen = (i < changes.length - 1 ? changes[i + 1].fromA : a.length) - ch.toA;
if (!beforeLen || !afterLen)
continue;
let text = lenA ? a.slice(ch.fromA, ch.toA) : b.slice(ch.fromB, ch.toB);
if (beforeLen <= text.length &&
a.slice(ch.fromA - beforeLen, ch.fromA) == text.slice(text.length - beforeLen)) {
// Text before matches the end of the change
changes[i] = new Change(ch.fromA - beforeLen, ch.toA - beforeLen, ch.fromB - beforeLen, ch.toB - beforeLen);
moved = true;
}
else if (afterLen <= text.length &&
a.slice(ch.toA, ch.toA + afterLen) == text.slice(0, afterLen)) {
// Text after matches the start of the change
changes[i] = new Change(ch.fromA + afterLen, ch.toA + afterLen, ch.fromB + afterLen, ch.toB + afterLen);
moved = true;
}
}
if (!moved)
break;
}
return changes;
}
// Process a change set to make it suitable for presenting to users.
function makePresentable(changes, a, b) {
for (let posA = 0, i = 0; i < changes.length; i++) {
let change = changes[i];
let lenA = change.toA - change.fromA, lenB = change.toB - change.fromB;
// Don't touch short insertions or deletions.
if (lenA && lenB || lenA > 3 || lenB > 3) {
let nextChangeA = i == changes.length - 1 ? a.length : changes[i + 1].fromA;
let maxScanBefore = change.fromA - posA, maxScanAfter = nextChangeA - change.toA;
let boundBefore = findWordBoundaryBefore(a, change.fromA, maxScanBefore);
let boundAfter = findWordBoundaryAfter(a, change.toA, maxScanAfter);
let lenBefore = change.fromA - boundBefore, lenAfter = boundAfter - change.toA;
// An insertion or deletion that falls inside words on both
// sides can maybe be moved to align with word boundaries.
if ((!lenA || !lenB) && lenBefore && lenAfter) {
let changeLen = Math.max(lenA, lenB);
let [changeText, changeFrom, changeTo] = lenA ? [a, change.fromA, change.toA] : [b, change.fromB, change.toB];
if (changeLen > lenBefore &&
a.slice(boundBefore, change.fromA) == changeText.slice(changeTo - lenBefore, changeTo)) {
change = changes[i] = new Change(boundBefore, boundBefore + lenA, change.fromB - lenBefore, change.toB - lenBefore);
boundBefore = change.fromA;
boundAfter = findWordBoundaryAfter(a, change.toA, nextChangeA - change.toA);
}
else if (changeLen > lenAfter &&
a.slice(change.toA, boundAfter) == changeText.slice(changeFrom, changeFrom + lenAfter)) {
change = changes[i] = new Change(boundAfter - lenA, boundAfter, change.fromB + lenAfter, change.toB + lenAfter);
boundAfter = change.toA;
boundBefore = findWordBoundaryBefore(a, change.fromA, change.fromA - posA);
}
lenBefore = change.fromA - boundBefore;
lenAfter = boundAfter - change.toA;
}
if (lenBefore || lenAfter) {
// Expand the change to cover the entire word
change = changes[i] = new Change(change.fromA - lenBefore, change.toA + lenAfter, change.fromB - lenBefore, change.toB + lenAfter);
}
else if (!lenA) {
// Align insertion to line boundary, when possible
let first = findLineBreakAfter(b, change.fromB, change.toB), len;
let last = first < 0 ? -1 : findLineBreakBefore(b, change.toB, change.fromB);
if (first > -1 && (len = first - change.fromB) <= maxScanAfter &&
b.slice(change.fromB, first) == b.slice(change.toB, change.toB + len))
change = changes[i] = change.offset(len);
else if (last > -1 && (len = change.toB - last) <= maxScanBefore &&
b.slice(change.fromB - len, change.fromB) == b.slice(last, change.toB))
change = changes[i] = change.offset(-len);
}
else if (!lenB) {
// Align deletion to line boundary
let first = findLineBreakAfter(a, change.fromA, change.toA), len;
let last = first < 0 ? -1 : findLineBreakBefore(a, change.toA, change.fromA);
if (first > -1 && (len = first - change.fromA) <= maxScanAfter &&
a.slice(change.fromA, first) == a.slice(change.toA, change.toA + len))
change = changes[i] = change.offset(len);
else if (last > -1 && (len = change.toA - last) <= maxScanBefore &&
a.slice(change.fromA - len, change.fromA) == a.slice(last, change.toA))
change = changes[i] = change.offset(-len);
}
}
posA = change.toA;
}
mergeAdjacent(changes, 3);
return changes;
}
let wordChar;
try {
wordChar = new RegExp("[\\p{Alphabetic}\\p{Number}]", "u");
}
catch (_) { }
function asciiWordChar(code) {
return code > 48 && code < 58 || code > 64 && code < 91 || code > 96 && code < 123;
}
function wordCharAfter(s, pos) {
if (pos == s.length)
return 0;
let next = s.charCodeAt(pos);
if (next < 192)
return asciiWordChar(next) ? 1 : 0;
if (!wordChar)
return 0;
if (!isSurrogate1(next) || pos == s.length - 1)
return wordChar.test(String.fromCharCode(next)) ? 1 : 0;
return wordChar.test(s.slice(pos, pos + 2)) ? 2 : 0;
}
function wordCharBefore(s, pos) {
if (!pos)
return 0;
let prev = s.charCodeAt(pos - 1);
if (prev < 192)
return asciiWordChar(prev) ? 1 : 0;
if (!wordChar)
return 0;
if (!isSurrogate2(prev) || pos == 1)
return wordChar.test(String.fromCharCode(prev)) ? 1 : 0;
return wordChar.test(s.slice(pos - 2, pos)) ? 2 : 0;
}
const MAX_SCAN = 8;
function findWordBoundaryAfter(s, pos, max) {
if (pos == s.length || !wordCharBefore(s, pos))
return pos;
for (let cur = pos, end = pos + max, i = 0; i < MAX_SCAN; i++) {
let size = wordCharAfter(s, cur);
if (!size || cur + size > end)
return cur;
cur += size;
}
return pos;
}
function findWordBoundaryBefore(s, pos, max) {
if (!pos || !wordCharAfter(s, pos))
return pos;
for (let cur = pos, end = pos - max, i = 0; i < MAX_SCAN; i++) {
let size = wordCharBefore(s, cur);
if (!size || cur - size < end)
return cur;
cur -= size;
}
return pos;
}
function findLineBreakBefore(s, pos, stop) {
for (; pos != stop; pos--)
if (s.charCodeAt(pos - 1) == 10)
return pos;
return -1;
}
function findLineBreakAfter(s, pos, stop) {
for (; pos != stop; pos++)
if (s.charCodeAt(pos) == 10)
return pos;
return -1;
}
const isSurrogate1 = (code) => code >= 0xD800 && code <= 0xDBFF;
const isSurrogate2 = (code) => code >= 0xDC00 && code <= 0xDFFF;
// Returns false if index looks like it is in the middle of a
// surrogate pair.
function validIndex(s, index) {
return !index || index == s.length || !isSurrogate1(s.charCodeAt(index - 1)) || !isSurrogate2(s.charCodeAt(index));
}
/**
Compute the difference between two strings.
*/
function diff(a, b, config) {
var _a;
scanLimit = ((_a = config === null || config === void 0 ? void 0 : config.scanLimit) !== null && _a !== void 0 ? _a : 1e9) >> 1;
timeout = (config === null || config === void 0 ? void 0 : config.timeout) ? Date.now() + config.timeout : 0;
crude = false;
return normalize(a, b, findDiff(a, 0, a.length, b, 0, b.length));
}
// Return whether the last diff fell back to the imprecise algorithm.
function diffIsPrecise() { return !crude; }
/**
Compute the difference between the given strings, and clean up the
resulting diff for presentation to users by dropping short
unchanged ranges, and aligning changes to word boundaries when
appropriate.
*/
function presentableDiff(a, b, config) {
return makePresentable(diff(a, b, config), a, b);
}
const mergeConfig = state.Facet.define({
combine: values => values[0]
});
const setChunks = state.StateEffect.define();
const computeChunks = state.Facet.define();
const ChunkField = state.StateField.define({
create(state) {
return null;
},
update(current, tr) {
for (let e of tr.effects)
if (e.is(setChunks))
current = e.value;
for (let comp of tr.state.facet(computeChunks))
current = comp(current, tr);
return current;
}
});
/**
Get the changed chunks for the merge view that this editor is part
of, plus the side it is on if it is part of a `MergeView`. Returns
null if the editor doesn't have a merge extension active or the
merge view hasn't finished initializing yet.
*/
function getChunks(state) {
let field = state.field(ChunkField, false);
if (!field)
return null;
let conf = state.facet(mergeConfig);
return { chunks: field, side: conf ? conf.side : null };
}
let moveByChunk = (dir) => ({ state: state$1, dispatch }) => {
let chunks = state$1.field(ChunkField, false), conf = state$1.facet(mergeConfig);
if (!chunks || !chunks.length || !conf)
return false;
let { head } = state$1.selection.main, pos = 0;
for (let i = chunks.length - 1; i >= 0; i--) {
let chunk = chunks[i];
let [from, to] = conf.side == "b" ? [chunk.fromB, chunk.toB] : [chunk.fromA, chunk.toA];
if (to < head) {
pos = i + 1;
break;
}
if (from <= head) {
if (chunks.length == 1)
return false;
pos = i + (dir < 0 ? 0 : 1);
break;
}
}
let next = chunks[(pos + (dir < 0 ? chunks.length - 1 : 0)) % chunks.length];
let [from, to] = conf.side == "b" ? [next.fromB, next.toB] : [next.fromA, next.toA];
dispatch(state$1.update({
selection: { anchor: from },
userEvent: "select.byChunk",
effects: view.EditorView.scrollIntoView(state.EditorSelection.range(to, from))
}));
return true;
};
/**
Move the selection to the next changed chunk.
*/
const goToNextChunk = moveByChunk(1);
/**
Move the selection to the previous changed chunk.
*/
const goToPreviousChunk = moveByChunk(-1);
/**
A chunk describes a range of lines which have changed content in
them. Either side (a/b) may either be empty (when its `to` is
equal to its `from`), or points at a range starting at the start
of the first changed line, to 1 past the end of the last changed
line. Note that `to` positions may point past the end of the
document. Use `endA`/`endB` if you need an end position that is
certain to be a valid document position.
*/
class Chunk {
constructor(
/**
The individual changes inside this chunk. These are stored
relative to the start of the chunk, so you have to add
`chunk.fromA`/`fromB` to get document positions.
*/
changes,
/**
The start of the chunk in document A.
*/
fromA,
/**
The end of the chunk in document A. This is equal to `fromA`
when the chunk covers no lines in document A, or is one unit
past the end of the last line in the chunk if it does.
*/
toA,
/**
The start of the chunk in document B.
*/
fromB,
/**
The end of the chunk in document A.
*/
toB,
/**
This is set to false when the diff used to compute this chunk
fell back to fast, imprecise diffing.
*/
precise = true) {
this.changes = changes;
this.fromA = fromA;
this.toA = toA;
this.fromB = fromB;
this.toB = toB;
this.precise = precise;
}
/**
@internal
*/
offset(offA, offB) {
return offA || offB
? new Chunk(this.changes, this.fromA + offA, this.toA + offA, this.fromB + offB, this.toB + offB, this.precise)
: this;
}
/**
Returns `fromA` if the chunk is empty in A, or the end of the
last line in the chunk otherwise.
*/
get endA() { return Math.max(this.fromA, this.toA - 1); }
/**
Returns `fromB` if the chunk is empty in B, or the end of the
last line in the chunk otherwise.
*/
get endB() { return Math.max(this.fromB, this.toB - 1); }
/**
Build a set of changed chunks for the given documents.
*/
static build(a, b, conf) {
let diff = presentableDiff(a.toString(), b.toString(), conf);
return toChunks(diff, a, b, 0, 0, diffIsPrecise());
}
/**
Update a set of chunks for changes in document A. `a` should
hold the updated document A.
*/
static updateA(chunks, a, b, changes, conf) {
return updateChunks(findRangesForChange(chunks, changes, true, b.length), chunks, a, b, conf);
}
/**
Update a set of chunks for changes in document B.
*/
static updateB(chunks, a, b, changes, conf) {
return updateChunks(findRangesForChange(chunks, changes, false, a.length), chunks, a, b, conf);
}
}
function fromLine(fromA, fromB, a, b) {
let lineA = a.lineAt(fromA), lineB = b.lineAt(fromB);
return lineA.to == fromA && lineB.to == fromB && fromA < a.length && fromB < b.length
? [fromA + 1, fromB + 1] : [lineA.from, lineB.from];
}
function toLine(toA, toB, a, b) {
let lineA = a.lineAt(toA), lineB = b.lineAt(toB);
return lineA.from == toA && lineB.from == toB ? [toA, toB] : [lineA.to + 1, lineB.to + 1];
}
function toChunks(changes, a, b, offA, offB, precise) {
let chunks = [];
for (let i = 0; i < changes.length; i++) {
let change = changes[i];
let [fromA, fromB] = fromLine(change.fromA + offA, change.fromB + offB, a, b);
let [toA, toB] = toLine(change.toA + offA, change.toB + offB, a, b);
let chunk = [change.offset(-fromA + offA, -fromB + offB)];
while (i < changes.length - 1) {
let next = changes[i + 1];
let [nextA, nextB] = fromLine(next.fromA + offA, next.fromB + offB, a, b);
if (nextA > toA + 1 && nextB > toB + 1)
break;
chunk.push(next.offset(-fromA + offA, -fromB + offB));
[toA, toB] = toLine(next.toA + offA, next.toB + offB, a, b);
i++;
}
chunks.push(new Chunk(chunk, fromA, Math.max(fromA, toA), fromB, Math.max(fromB, toB), precise));
}
return chunks;
}
const updateMargin = 1000;
// Finds the given position in the chunks. Returns the extent of the
// chunk it overlaps with if it overlaps, or a position corresponding
// to that position on both sides otherwise.
function findPos(chunks, pos, isA, start) {
let lo = 0, hi = chunks.length;
for (;;) {
if (lo == hi) {
let refA = 0, refB = 0;
if (lo)
({ toA: refA, toB: refB } = chunks[lo - 1]);
let off = pos - (isA ? refA : refB);
return [refA + off, refB + off];
}
let mid = (lo + hi) >> 1, chunk = chunks[mid];
let [from, to] = isA ? [chunk.fromA, chunk.toA] : [chunk.fromB, chunk.toB];
if (from > pos)
hi = mid;
else if (to <= pos)
lo = mid + 1;
else
return start ? [chunk.fromA, chunk.fromB] : [chunk.toA, chunk.toB];
}
}
function findRangesForChange(chunks, changes, isA, otherLen) {
let ranges = [];
changes.iterChangedRanges((cFromA, cToA, cFromB, cToB) => {
let fromA = 0, toA = isA ? changes.length : otherLen;
let fromB = 0, toB = isA ? otherLen : changes.length;
if (cFromA > updateMargin)
[fromA, fromB] = findPos(chunks, cFromA - updateMargin, isA, true);
if (cToA < changes.length - updateMargin)
[toA, toB] = findPos(chunks, cToA + updateMargin, isA, false);
let lenDiff = (cToB - cFromB) - (cToA - cFromA), last;
let [diffA, diffB] = isA ? [lenDiff, 0] : [0, lenDiff];
if (ranges.length && (last = ranges[ranges.length - 1]).toA >= fromA)
ranges[ranges.length - 1] = { fromA: last.fromA, fromB: last.fromB, toA, toB,
diffA: last.diffA + diffA, diffB: last.diffB + diffB };
else
ranges.push({ fromA, toA, fromB, toB, diffA, diffB });
});
return ranges;
}
function updateChunks(ranges, chunks, a, b, conf) {
if (!ranges.length)
return chunks;
let result = [];
for (let i = 0, offA = 0, offB = 0, chunkI = 0;; i++) {
let range = i == ranges.length ? null : ranges[i];
let fromA = range ? range.fromA + offA : a.length, fromB = range ? range.fromB + offB : b.length;
while (chunkI < chunks.length) {
let next = chunks[chunkI];
if (next.toA + offA > fromA || next.toB + offB > fromB)
break;
result.push(next.offset(offA, offB));
chunkI++;
}
if (!range)
break;
let toA = range.toA + offA + range.diffA, toB = range.toB + offB + range.diffB;
let diff = presentableDiff(a.sliceString(fromA, toA), b.sliceString(fromB, toB), conf);
for (let chunk of toChunks(diff, a, b, fromA, fromB, diffIsPrecise()))
result.push(chunk);
offA += range.diffA;
offB += range.diffB;
while (chunkI < chunks.length) {
let next = chunks[chunkI];
if (next.fromA + offA > toA && next.fromB + offB > toB)
break;
chunkI++;
}
}
return result;
}
const defaultDiffConfig = { scanLimit: 500 };
const decorateChunks = view.ViewPlugin.fromClass(class {
constructor(view) {
({ deco: this.deco, gutter: this.gutter } = getChunkDeco(view));
}
update(update) {
if (update.docChanged || update.viewportChanged || chunksChanged(update.startState, update.state) ||
configChanged(update.startState, update.state))
({ deco: this.deco, gutter: this.gutter } = getChunkDeco(update.view));
}
}, {
decorations: d => d.deco
});
const changeGutter = state.Prec.low(view.gutter({
class: "cm-changeGutter",
markers: view => { var _a; return ((_a = view.plugin(decorateChunks)) === null || _a === void 0 ? void 0 : _a.gutter) || state.RangeSet.empty; }
}));
function chunksChanged(s1, s2) {
return s1.field(ChunkField, false) != s2.field(ChunkField, false);
}
function configChanged(s1, s2) {
return s1.facet(mergeConfig) != s2.facet(mergeConfig);
}
const changedLine = view.Decoration.line({ class: "cm-changedLine" });
const changedText = view.Decoration.mark({ class: "cm-changedText" });
const inserted = view.Decoration.mark({ tagName: "ins", class: "cm-insertedLine" });
const deleted = view.Decoration.mark({ tagName: "del", class: "cm-deletedLine" });
const changedLineGutterMarker = new class extends view.GutterMarker {
constructor() {
super(...arguments);
this.elementClass = "cm-changedLineGutter";
}
};
function buildChunkDeco(chunk, doc, isA, highlight, builder, gutterBuilder) {
let from = isA ? chunk.fromA : chunk.fromB, to = isA ? chunk.toA : chunk.toB;
let changeI = 0;
if (from != to) {
builder.add(from, from, changedLine);
builder.add(from, to, isA ? deleted : inserted);
if (gutterBuilder)
gutterBuilder.add(from, from, changedLineGutterMarker);
for (let iter = doc.iterRange(from, to - 1), pos = from; !iter.next().done;) {
if (iter.lineBreak) {
pos++;
builder.add(pos, pos, changedLine);
if (gutterBuilder)
gutterBuilder.add(pos, pos, changedLineGutterMarker);
continue;
}
let lineEnd = pos + iter.value.length;
if (highlight)
while (changeI < chunk.changes.length) {
let nextChange = chunk.changes[changeI];
let nextFrom = from + (isA ? nextChange.fromA : nextChange.fromB);
let nextTo = from + (isA ? nextChange.toA : nextChange.toB);
let chFrom = Math.max(pos, nextFrom), chTo = Math.min(lineEnd, nextTo);
if (chFrom < chTo)
builder.add(chFrom, chTo, changedText);
if (nextTo < lineEnd)
changeI++;
else
break;
}
pos = lineEnd;
}
}
}
function getChunkDeco(view) {
let chunks = view.state.field(ChunkField);
let { side, highlightChanges, markGutter, overrideChunk } = view.state.facet(mergeConfig), isA = side == "a";
let builder = new state.RangeSetBuilder();
let gutterBuilder = markGutter ? new state.RangeSetBuilder() : null;
let { from, to } = view.viewport;
for (let chunk of chunks) {
if ((isA ? chunk.fromA : chunk.fromB) >= to)
break;
if ((isA ? chunk.toA : chunk.toB) > from) {
if (!overrideChunk || !overrideChunk(view.state, chunk, builder, gutterBuilder))
buildChunkDeco(chunk, view.state.doc, isA, highlightChanges, builder, gutterBuilder);
}
}
return { deco: builder.finish(), gutter: gutterBuilder && gutterBuilder.finish() };
}
class Spacer extends view.WidgetType {
constructor(height) {
super();
this.height = height;
}
eq(other) { return this.height == other.height; }
toDOM() {
let elt = document.createElement("div");
elt.className = "cm-mergeSpacer";
elt.style.height = this.height + "px";
return elt;
}
updateDOM(dom) {
dom.style.height = this.height + "px";
return true;
}
get estimatedHeight() { return this.height; }
ignoreEvent() { return false; }
}
const adjustSpacers = state.StateEffect.define({
map: (value, mapping) => value.map(mapping)
});
const Spacers = state.StateField.define({
create: () => view.Decoration.none,
update: (spacers, tr) => {
for (let e of tr.effects)
if (e.is(adjustSpacers))
return e.value;
return spacers.map(tr.changes);
},
provide: f => view.EditorView.decorations.from(f)
});
const epsilon = .01;
function compareSpacers(a, b) {
if (a.size != b.size)
return false;
let iA = a.iter(), iB = b.iter();
while (iA.value) {
if (iA.from != iB.from ||
Math.abs(iA.value.spec.widget.height - iB.value.spec.widget.height) > 1)
return false;
iA.next();
iB.next();
}
return true;
}
function updateSpacers(a, b, chunks) {
let buildA = new state.RangeSetBuilder(), buildB = new state.RangeSetBuilder();
let spacersA = a.state.field(Spacers).iter(), spacersB = b.state.field(Spacers).iter();
let posA = 0, posB = 0, offA = 0, offB = 0, vpA = a.viewport, vpB = b.viewport;
for (let chunkI = 0;; chunkI++) {
let chunk = chunkI < chunks.length ? chunks[chunkI] : null;
let endA = chunk ? chunk.fromA : a.state.doc.length, endB = chunk ? chunk.fromB : b.state.doc.length;
// A range at posA/posB is unchanged, must be aligned.
if (posA < endA) {
let heightA = a.lineBlockAt(posA).top + offA;
let heightB = b.lineBlockAt(posB).top + offB;
let diff = heightA - heightB;
if (diff < -epsilon) {
offA -= diff;
buildA.add(posA, posA, view.Decoration.widget({
widget: new Spacer(-diff),
block: true,
side: -1
}));
}
else if (diff > epsilon) {
offB += diff;
buildB.add(posB, posB, view.Decoration.widget({
widget: new Spacer(diff),
block: true,
side: -1
}));
}
}
// If the viewport starts inside the unchanged range (on both
// sides), add another sync at the top of the viewport. That way,
// big unchanged chunks with possibly inaccurate estimated heights
// won't cause the content to misalign (#1408)
if (endA > posA + 1000 && posA < vpA.from && endA > vpA.from && posB < vpB.from && endB > vpB.from) {
let off = Math.min(vpA.from - posA, vpB.from - posB);
posA += off;
posB += off;
chunkI--;
}
else if (!chunk) {
break;
}
else {
posA = chunk.toA;
posB = chunk.toB;
}
while (spacersA.value && spacersA.from < posA) {
offA -= spacersA.value.spec.widget.height;
spacersA.next();
}
while (spacersB.value && spacersB.from < posB) {
offB -= spacersB.value.spec.widget.height;
spacersB.next();
}
}
while (spacersA.value) {
offA -= spacersA.value.spec.widget.height;
spacersA.next();
}
while (spacersB.value) {
offB -= spacersB.value.spec.widget.height;
spacersB.next();
}
let docDiff = (a.contentHeight + offA) - (b.contentHeight + offB);
if (docDiff < epsilon) {
buildA.add(a.state.doc.length, a.state.doc.length, view.Decoration.widget({
widget: new Spacer(-docDiff),
block: true,
side: 1
}));
}
else if (docDiff > epsilon) {
buildB.add(b.state.doc.length, b.state.doc.length, view.Decoration.widget({
widget: new Spacer(docDiff),
block: true,
side: 1
}));
}
let decoA = buildA.finish(), decoB = buildB.finish();
if (!compareSpacers(decoA, a.state.field(Spacers)))
a.dispatch({ effects: adjustSpacers.of(decoA) });
if (!compareSpacers(decoB, b.state.field(Spacers)))
b.dispatch({ effects: adjustSpacers.of(decoB) });
}
/**
A state effect that expands the section of collapsed unchanged
code starting at the given position.
*/
const uncollapseUnchanged = state.StateEffect.define({
map: (value, change) => change.mapPos(value)
});
/**
Query whether the given view is displayed next to another editor
in a merge view. Returns `null` if it isn't, and a pair of editors
(one of which will be the view itself) otherwise.
*/
function mergeViewSiblings(view) {
let conf = view.state.facet(mergeConfig);
return !conf || !conf.sibling ? null : conf.side == "a" ? { a: view, b: conf.sibling() } : { a: conf.sibling(), b: view };
}
class CollapseWidget extends view.WidgetType {
constructor(lines) {
super();
this.lines = lines;
}
eq(other) { return this.lines == other.lines; }
toDOM(view) {
let outer = document.createElement("div");
outer.className = "cm-collapsedLines";
outer.textContent = view.state.phrase("$ unchanged lines", this.lines);
outer.addEventListener("click", e => {
let pos = view.posAtDOM(e.target);
view.dispatch({ effects: uncollapseUnchanged.of(pos) });
let { side, sibling } = view.state.facet(mergeConfig);
if (sibling)
sibling().dispatch({ effects: uncollapseUnchanged.of(mapPos(pos, view.state.field(ChunkField), side == "a")) });
});
return outer;
}
ignoreEvent(e) { return e instanceof MouseEvent; }
get estimatedHeight() { return 27; }
get type() { return "collapsed-unchanged-code"; }
}
function mapPos(pos, chunks, isA) {
let startOur = 0, startOther = 0;
for (let i = 0;; i++) {
let next = i < chunks.length ? chunks[i] : null;
if (!next || (isA ? next.fromA : next.fromB) >= pos)
return startOther + (pos - startOur);
[startOur, startOther] = isA ? [next.toA, next.toB] : [next.toB, next.toA];
}
}
const CollapsedRanges = state.StateField.define({
create(state) { return view.Decoration.none; },
update(deco, tr) {
deco = deco.map(tr.changes);
for (let e of tr.effects)
if (e.is(uncollapseUnchanged))
deco = deco.update({ filter: from => from != e.value });
return deco;
},
provide: f => view.EditorView.decorations.from(f)
});
function collapseUnchanged({ margin = 3, minSize = 4 }) {
return CollapsedRanges.init(state => buildCollapsedRanges(state, margin, minSize));
}
function buildCollapsedRanges(state$1, margin, minLines) {
let builder = new state.RangeSetBuilder();
let isA = state$1.facet(mergeConfig).side == "a";
let chunks = state$1.field(ChunkField);
let prevLine = 1;
for (let i = 0;; i++) {
let chunk = i < chunks.length ? chunks[i] : null;
let collapseFrom = i ? prevLine + margin : 1;
let collapseTo = chunk ? state$1.doc.lineAt(isA ? chunk.fromA : chunk.fromB).number - 1 - margin : state$1.doc.lines;
let lines = collapseTo - collapseFrom + 1;
if (lines >= minLines) {
builder.add(state$1.doc.line(collapseFrom).from, state$1.doc.line(collapseTo).to, view.Decoration.replace({
widget: new CollapseWidget(lines),
block: true
}));
}
if (!chunk)
break;
prevLine = state$1.doc.lineAt(Math.min(state$1.doc.length, isA ? chunk.toA : chunk.toB)).number;
}
return builder.finish();
}
const externalTheme = view.EditorView.styleModule.of(new styleMod.StyleModule({
".cm-mergeView": {
overflowY: "auto",
},
".cm-mergeViewEditors": {
display: "flex",
alignItems: "stretch",
},
".cm-mergeViewEditor": {
flexGrow: 1,
flexBasis: 0,
overflow: "hidden"
},
".cm-merge-revert": {
width: "1.6em",
flexGrow: 0,
flexShrink: 0,
position: "relative"
},
".cm-merge-revert button": {
position: "absolute",
display: "block",
width: "100%",
boxSizing: "border-box",
textAlign: "center",
background: "none",
border: "none",
font: "inherit",
cursor: "pointer"
}
}));
const baseTheme = view.EditorView.baseTheme({
".cm-mergeView & .cm-scroller, .cm-mergeView &": {
height: "auto !important",
overflowY: "visible !important"
},
"&.cm-merge-a .cm-changedLine, .cm-deletedChunk": {
backgroundColor: "rgba(160, 128, 100, .08)"
},
"&.cm-merge-b .cm-changedLine, .cm-inlineChangedLine": {
backgroundColor: "rgba(100, 160, 128, .08)"
},
"&light.cm-merge-a .cm-changedText, &light .cm-deletedChunk .cm-deletedText": {
background: "linear-gradient(#ee443366, #ee443366) bottom/100% 2px no-repeat",
},
"&dark.cm-merge-a .cm-changedText, &dark .cm-deletedChunk .cm-deletedText": {
background: "linear-gradient(#ffaa9966, #ffaa9966) bottom/100% 2px no-repeat",
},
"&light.cm-merge-b .cm-changedText": {
background: "linear-gradient(#22bb22aa, #22bb22aa) bottom/100% 2px no-repeat",
},
"&dark.cm-merge-b .cm-changedText": {
background: "linear-gradient(#88ff88aa, #88ff88aa) bottom/100% 2px no-repeat",
},
"&.cm-merge-b .cm-deletedText": {
background: "#ff000033"
},
".cm-insertedLine, .cm-deletedLine, .cm-deletedLine del": {
textDecoration: "none"
},
".cm-deletedChunk": {
paddingLeft: "6px",
"& .cm-chunkButtons": {
position: "absolute",
insetInlineEnd: "5px"
},
"& button": {
border: "none",
cursor: "pointer",
color: "white",
margin: "0 2px",
borderRadius: "3px",
"&[name=accept]": { background: "#2a2" },
"&[name=reject]": { background: "#d43" }
},
},
".cm-collapsedLines": {
padding: "5px 5px 5px 10px",
cursor: "pointer",
"&:before": {
content: '"⦚"',
marginInlineEnd: "7px"
},
"&:after": {
content: '"⦚"',
marginInlineStart: "7px"
},
},
"&light .cm-collapsedLines": {
color: "#444",
background: "linear-gradient(to bottom, transparent 0, #f3f3f3 30%, #f3f3f3 70%, transparent 100%)"
},
"&dark .cm-collapsedLines": {
color: "#ddd",
background: "linear-gradient(to bottom, transparent 0, #222 30%, #222 70%, transparent 100%)"
},
".cm-changeGutter": { width: "3px", paddingLeft: "1px" },
"&light.cm-merge-a .cm-changedLineGutter, &light .cm-deletedLineGutter": { background: "#e43" },
"&dark.cm-merge-a .cm-changedLineGutter, &dark .cm-deletedLineGutter": { background: "#fa9" },
"&light.cm-merge-b .cm-changedLineGutter": { background: "#2b2" },
"&dark.cm-merge-b .cm-changedLineGutter": { background: "#8f8" },
".cm-inlineChangedLineGutter": { background: "#75d" }
});
const collapseCompartment = new state.Compartment, configCompartment = new state.Compartment;
/**
A merge view manages two editors side-by-side, highlighting the
difference between them and vertically aligning unchanged lines.
If you want one of the editors to be read-only, you have to
configure that in its extensions.
By default, views are not scrollable. Style them (`.cm-mergeView`)
with a height and `overflow: auto` to make them scrollable.
*/
class MergeView {
/**
Create a new merge view.
*/
constructor(config) {
this.revertDOM = null;
this.revertToA = false;
this.revertToLeft = false;
this.measuring = -1;
this.diffConf = config.diffConfig || defaultDiffConfig;
let sharedExtensions = [
state.Prec.low(decorateChunks),
baseTheme,
externalTheme,
Spacers,
view.EditorView.updateListener.of(update => {
if (this.measuring < 0 && (update.heightChanged || update.viewportChanged) &&
!update.transactions.some(tr => tr.effects.some(e => e.is(adjustSpacers))))
this.measure();
}),
];
let configA = [mergeConfig.of({
side: "a",
sibling: () => this.b,
highlightChanges: config.highlightChanges !== false,
markGutter: config.gutter !== false
})];
if (config.gutter !== false)
configA.push(changeGutter);
let stateA = state.EditorState.create({
doc: config.a.doc,
selection: config.a.selection,
extensions: [
config.a.extensions || [],
view.EditorView.editorAttributes.of({ class: "cm-merge-a" }),
configCompartment.of(configA),
sharedExtensions
]
});
let configB = [mergeConfig.of({
side: "b",
sibling: () => this.a,
highlightChanges: config.highlightChanges !== false,
markGutter: config.gutter !== false
})];
if (config.gutter !== false)
configB.push(changeGutter);
let stateB = state.EditorState.create({
doc: config.b.doc,
selection: config.b.selection,
extensions: [
config.b.extensions || [],
view.EditorView.editorAttributes.of({ class: "cm-merge-b" }),
configCompartment.of(configB),
sharedExtensions
]
});
this.chunks = Chunk.build(stateA.doc, stateB.doc, this.diffConf);
let add = [
ChunkField.init(() => this.chunks),
collapseCompartment.of(config.collapseUnchanged ? collapseUnchanged(config.collapseUnchanged) : [])
];
stateA = stateA.update({ effects: state.StateEffect.appendConfig.of(add) }).state;
stateB = stateB.update({ effects: state.StateEffect.appendConfig.of(add) }).state;
this.dom = document.createElement("div");
this.dom.className = "cm-mergeView";
this.editorDOM = this.dom.appendChild(document.createElement("div"));
this.editorDOM.className = "cm-mergeViewEditors";
let orientation = config.orientation || "a-b";
let wrapA = document.createElement("div");
wrapA.className = "cm-mergeViewEditor";
let wrapB = document.createElement("div");
wrapB.className = "cm-mergeViewEditor";
this.editorDOM.appendChild(orientation == "a-b" ? wrapA : wrapB);
this.editorDOM.appendChild(orientation == "a-b" ? wrapB : wrapA);
this.a = new view.EditorView({
state: stateA,
parent: wrapA,
root: config.root,
dispatchTransactions: trs => this.dispatch(trs, this.a)
});
this.b = new view.EditorView({
state: stateB,
parent: wrapB,
root: config.root,
dispatchTransactions: trs => this.dispatch(trs, this.b)
});
this.setupRevertControls(!!config.revertControls, config.revertControls == "b-to-a", config.renderRevertControl);
if (config.parent)
config.parent.appendChild(this.dom);
this.scheduleMeasure();
}
dispatch(trs, target) {
if (trs.some(tr => tr.docChanged)) {
let last = trs[trs.length - 1];