2020-10-02 15:10:00 +00:00
|
|
|
import { DataRowModel } from 'app/client/models/DataRowModel';
|
|
|
|
import { NewAbstractWidget } from 'app/client/widgets/NewAbstractWidget';
|
|
|
|
import { CellValue } from 'app/common/DocActions';
|
|
|
|
import { isVersions } from 'app/common/gristTypes';
|
2021-02-09 10:12:53 +00:00
|
|
|
import { inlineStyle } from 'app/common/gutil';
|
2020-10-02 15:10:00 +00:00
|
|
|
import { BaseFormatter } from 'app/common/ValueFormatter';
|
2021-04-26 21:54:09 +00:00
|
|
|
import { Diff, DIFF_DELETE, DIFF_EQUAL, DIFF_INSERT, diff_match_patch as DiffMatchPatch } from 'diff-match-patch';
|
2020-10-02 15:10:00 +00:00
|
|
|
import { Computed, dom } from 'grainjs';
|
|
|
|
|
|
|
|
/**
|
|
|
|
*
|
|
|
|
* A special widget used for rendering cell-level comparisons and conflicts.
|
|
|
|
*
|
|
|
|
*/
|
|
|
|
export class DiffBox extends NewAbstractWidget {
|
|
|
|
|
|
|
|
private _diffTool = new DiffMatchPatch();
|
|
|
|
|
|
|
|
public buildConfigDom() {
|
|
|
|
return dom('div');
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Render a cell-level diff as a series of styled spans.
|
|
|
|
*/
|
|
|
|
public buildDom(row: DataRowModel) {
|
|
|
|
const formattedValue = Computed.create(null, (use) => {
|
|
|
|
if (use(row._isAddRow) || this.isDisposed() || use(this.field.displayColModel).isDisposed()) {
|
|
|
|
// Work around JS errors during certain changes, following code in Reference.js
|
|
|
|
return [] as Diff[];
|
|
|
|
}
|
|
|
|
const value = use(row.cells[use(use(this.field.displayColModel).colId)]);
|
|
|
|
const formatter = use(this.valueFormatter);
|
|
|
|
return this._prepareCellDiff(value, formatter);
|
|
|
|
});
|
|
|
|
return dom(
|
|
|
|
'div.field_clip',
|
|
|
|
dom.autoDispose(formattedValue),
|
|
|
|
dom.style('text-align', this.options.prop('alignment')),
|
|
|
|
dom.cls('text_wrapping', (use) => Boolean(use(this.options.prop('wrap')))),
|
2021-02-09 10:12:53 +00:00
|
|
|
inlineStyle('--grist-diff-color', '#000000'),
|
|
|
|
inlineStyle('--grist-diff-background-color', '#00000000'),
|
2020-10-02 15:10:00 +00:00
|
|
|
dom.forEach(formattedValue, ([code, txt]) => {
|
|
|
|
if (code === DIFF_DELETE) {
|
|
|
|
return dom("span.diff-parent", txt);
|
|
|
|
} else if (code === DIFF_INSERT) {
|
|
|
|
return dom("span.diff-remote", txt);
|
|
|
|
} else if (code === DIFF_LOCAL) {
|
|
|
|
return dom("span.diff-local", txt);
|
|
|
|
} else {
|
|
|
|
return dom("span.diff-common", txt);
|
|
|
|
}
|
|
|
|
}),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Given the cell value and the formatter, construct a list of fragments in
|
|
|
|
* diff-match-patch format expressing the difference between versions.
|
|
|
|
* The format is a list of [CODE, STRING] pairs, where the possible values of
|
|
|
|
* CODE are:
|
|
|
|
* -1 -- meaning DELETION of the parent value.
|
|
|
|
* 0 -- meaning text common to all versions.
|
|
|
|
* 1 -- meaning INSERTION of the remote value.
|
|
|
|
* 2 -- meaning INSERTION of the local value.
|
|
|
|
*
|
|
|
|
* When a change is made only locally or remotely, then the list returned may
|
|
|
|
* include common text, deletions and insertions in any order.
|
|
|
|
*
|
|
|
|
* When a change is made both locally and remotely, the list returned does not
|
|
|
|
* include any common text, but just reports the parent value, then the local value,
|
|
|
|
* then the remote value. This may be optimized in future.
|
|
|
|
*/
|
|
|
|
private _prepareCellDiff(value: CellValue, formatter: BaseFormatter): Diff[] {
|
|
|
|
if (!isVersions(value)) {
|
|
|
|
// This can happen for reference columns, where the diff widget is
|
|
|
|
// selected on the basis of one column, but we are displaying the
|
|
|
|
// content of another. We have more version information for the
|
|
|
|
// reference column than for its display column.
|
2020-11-11 20:25:37 +00:00
|
|
|
return [[DIFF_EQUAL, formatter.formatAny(value)]];
|
2020-10-02 15:10:00 +00:00
|
|
|
}
|
|
|
|
const versions = value[1];
|
|
|
|
if (!('local' in versions)) {
|
|
|
|
// Change was made remotely only.
|
2020-11-11 20:25:37 +00:00
|
|
|
return this._prepareTextDiff(
|
|
|
|
formatter.formatAny(versions.parent),
|
|
|
|
formatter.formatAny(versions.remote));
|
2020-10-02 15:10:00 +00:00
|
|
|
} else if (!('remote' in versions)) {
|
|
|
|
// Change was made locally only.
|
2020-11-11 20:25:37 +00:00
|
|
|
return this._prepareTextDiff(
|
|
|
|
formatter.formatAny(versions.parent),
|
|
|
|
formatter.formatAny(versions.local))
|
2020-10-02 15:10:00 +00:00
|
|
|
.map(([code, txt]) => [code === DIFF_INSERT ? DIFF_LOCAL : code, txt]);
|
|
|
|
}
|
|
|
|
// Change was made both locally and remotely.
|
2020-11-11 20:25:37 +00:00
|
|
|
return [[DIFF_DELETE, formatter.formatAny(versions.parent)],
|
|
|
|
[DIFF_LOCAL, formatter.formatAny(versions.local)],
|
|
|
|
[DIFF_INSERT, formatter.formatAny(versions.remote)]];
|
2020-10-02 15:10:00 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// Run diff-match-patch on the text, do its cleanup, and then some extra
|
|
|
|
// ad-hoc cleanup of our own. Diffs are hard to read if they are too
|
|
|
|
// "choppy".
|
|
|
|
private _prepareTextDiff(txt1: string, txt2: string): Diff[] {
|
|
|
|
const diffs = this._diffTool.diff_main(txt1, txt2);
|
|
|
|
this._diffTool.diff_cleanupSemantic(diffs);
|
|
|
|
if (diffs.length > 2 && this._notDiffWorthy(txt1, diffs.length) &&
|
|
|
|
this._notDiffWorthy(txt2, diffs.length)) {
|
|
|
|
return [[DIFF_DELETE, txt1], [DIFF_INSERT, txt2]];
|
|
|
|
}
|
|
|
|
if (diffs.length === 1 && diffs[0][0] === DIFF_DELETE) {
|
|
|
|
// Add an empty set symbol, since otherwise it will be ambiguous
|
|
|
|
// whether the deletion was done locally or remotely.
|
|
|
|
diffs.push([1, '\u2205']);
|
|
|
|
}
|
|
|
|
return diffs;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Heuristic for whether to show common parts of versions, or to treat them
|
|
|
|
// as entirely distinct.
|
|
|
|
private _notDiffWorthy(txt: string, parts: number) {
|
|
|
|
return txt.length < 5 * parts || this._isMostlyNumeric(txt);
|
|
|
|
}
|
|
|
|
|
|
|
|
// Check is text has a lot of numeric content.
|
|
|
|
private _isMostlyNumeric(txt: string) {
|
|
|
|
return [...txt].filter(c => c >= '0' && c <= '9').length > txt.length / 2;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// A constant marking text fragments present locally but not in parent (or remote).
|
|
|
|
// Must be distinct from DiffMatchPatch.DIFF_* constants (-1, 0, 1).
|
|
|
|
const DIFF_LOCAL = 2;
|