gristlabs_grist-core/app/client/widgets/DiffBox.ts
Paul Fitzpatrick c67966775b (core) simplify document comparison code, and flesh out diff with local changes
Summary:
With recent changes to action history, we can now remove the temporary
`finalRowContent` field from change details, since all the information
we need is now in the ActionSummary.

We also now have more information about the state of the common ancestor,
which previously we could not get either from ActionSummary or from
`finalRowContent`. We take advantage of that to flesh out rendering
differences where there are some changes locally and some changes
remotely.

There's still a lot more to do, this is just one step.

I have added a link to the UI for viewing the comparison. I wouldn't
want to advertise that link until diffs are robust to name changes.

Test Plan: added test, updated tests

Reviewers: dsagal

Reviewed By: dsagal

Differential Revision: https://phab.getgrist.com/D2658
2020-11-11 15:49:16 -05:00

131 lines
5.2 KiB
TypeScript

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 { BaseFormatter } from 'app/common/ValueFormatter';
import { Diff, DIFF_DELETE, DIFF_INSERT, diff_match_patch as DiffMatchPatch, DIFF_EQUAL } 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')))),
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;