UNPKG

@codemirror/merge

Version:

A diff/merge view for CodeMirror

1,266 lines (1,259 loc) 70.9 kB
import { EditorView, ViewPlugin, gutter, Decoration, GutterMarker, WidgetType } from '@codemirror/view'; import { Facet, StateEffect, StateField, EditorSelection, Prec, RangeSet, RangeSetBuilder, Compartment, EditorState, ChangeSet, Text } from '@codemirror/state'; import { StyleModule } from 'style-mod'; import { language, highlightingFor } from '@codemirror/language'; import { highlightTree } from '@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 = /*@__PURE__*/new Frontier, frontier2 = /*@__PURE__*/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 = /*@__PURE__*/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 = /*@__PURE__*/Facet.define({ combine: values => values[0] }); const setChunks = /*@__PURE__*/StateEffect.define(); const computeChunks = /*@__PURE__*/Facet.define(); const ChunkField = /*@__PURE__*/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, dispatch }) => { let chunks = state.field(ChunkField, false), conf = state.facet(mergeConfig); if (!chunks || !chunks.length || !conf) return false; let { head } = state.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.update({ selection: { anchor: from }, userEvent: "select.byChunk", effects: EditorView.scrollIntoView(EditorSelection.range(to, from)) })); return true; }; /** Move the selection to the next changed chunk. */ const goToNextChunk = /*@__PURE__*/moveByChunk(1); /** Move the selection to the previous changed chunk. */ const goToPreviousChunk = /*@__PURE__*/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 = /*@__PURE__*/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 = /*@__PURE__*/Prec.low(/*@__PURE__*/gutter({ class: "cm-changeGutter", markers: view => { var _a; return ((_a = view.plugin(decorateChunks)) === null || _a === void 0 ? void 0 : _a.gutter) || 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 = /*@__PURE__*/Decoration.line({ class: "cm-changedLine" }); const changedText = /*@__PURE__*/Decoration.mark({ class: "cm-changedText" }); const inserted = /*@__PURE__*/Decoration.mark({ tagName: "ins", class: "cm-insertedLine" }); const deleted = /*@__PURE__*/Decoration.mark({ tagName: "del", class: "cm-deletedLine" }); const changedLineGutterMarker = /*@__PURE__*/new class extends 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 RangeSetBuilder(); let gutterBuilder = markGutter ? new 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 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 = /*@__PURE__*/StateEffect.define({ map: (value, mapping) => value.map(mapping) }); const Spacers = /*@__PURE__*/StateField.define({ create: () => 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 => 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 RangeSetBuilder(), buildB = new 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, Decoration.widget({ widget: new Spacer(-diff), block: true, side: -1 })); } else if (diff > epsilon) { offB += diff; buildB.add(posB, posB, 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, Decoration.widget({ widget: new Spacer(-docDiff), block: true, side: 1 })); } else if (docDiff > epsilon) { buildB.add(b.state.doc.length, b.state.doc.length, 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 = /*@__PURE__*/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 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 = /*@__PURE__*/StateField.define({ create(state) { return 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 => EditorView.decorations.from(f) }); function collapseUnchanged({ margin = 3, minSize = 4 }) { return CollapsedRanges.init(state => buildCollapsedRanges(state, margin, minSize)); } function buildCollapsedRanges(state, margin, minLines) { let builder = new RangeSetBuilder(); let isA = state.facet(mergeConfig).side == "a"; let chunks = state.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.doc.lineAt(isA ? chunk.fromA : chunk.fromB).number - 1 - margin : state.doc.lines; let lines = collapseTo - collapseFrom + 1; if (lines >= minLines) { builder.add(state.doc.line(collapseFrom).from, state.doc.line(collapseTo).to, Decoration.replace({ widget: new CollapseWidget(lines), block: true })); } if (!chunk) break; prevLine = state.doc.lineAt(Math.min(state.doc.length, isA ? chunk.toA : chunk.toB)).number; } return builder.finish(); } const externalTheme = /*@__PURE__*/EditorView.styleModule.of(/*@__PURE__*/new 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 = /*@__PURE__*/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 = /*@__PURE__*/new Compartment, configCompartment = /*@__PURE__*/new 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 = [ Prec.low(decorateChunks), baseTheme, externalTheme, Spacers, 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 = EditorState.create({ doc: config.a.doc, selection: config.a.selection, extensions: [ config.a.extensions || [], 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 = EditorState.create({ doc: config.b.doc, selection: config.b.selection, extensions: [ config.b.extensions || [], 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: StateEffect.appendConfig.of(add) }).state; stateB = stateB.update({ effects: 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 EditorView({ state: stateA, parent: wrapA, root: config.root, dispatchTransactions: trs => this.dispatch(trs, this.a) }); this.b = new 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 (confi