You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
gristlabs_grist-core/app/client/widgets/DiffBox.ts

134 lines
5.4 KiB

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';
import { inlineStyle } from 'app/common/gutil';
import { BaseFormatter } from 'app/common/ValueFormatter';
import { Diff, DIFF_DELETE, DIFF_EQUAL, DIFF_INSERT, diff_match_patch as DiffMatchPatch } from 'diff-match-patch';
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')))),
inlineStyle('--grist-diff-color', '#000000'),
inlineStyle('--grist-diff-background-color', '#00000000'),
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.
return [[DIFF_EQUAL, formatter.formatAny(value)]];
}
const versions = value[1];
if (!('local' in versions)) {
// Change was made remotely only.
return this._prepareTextDiff(
formatter.formatAny(versions.parent),
formatter.formatAny(versions.remote));
} else if (!('remote' in versions)) {
// Change was made locally only.
return this._prepareTextDiff(
formatter.formatAny(versions.parent),
formatter.formatAny(versions.local))
.map(([code, txt]) => [code === DIFF_INSERT ? DIFF_LOCAL : code, txt]);
}
// Change was made both locally and remotely.
return [[DIFF_DELETE, formatter.formatAny(versions.parent)],
[DIFF_LOCAL, formatter.formatAny(versions.local)],
[DIFF_INSERT, formatter.formatAny(versions.remote)]];
}
// 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;