Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Security Solution] JSON diff view PoC #171750

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -890,6 +890,9 @@
"deep-freeze-strict": "^1.1.1",
"deepmerge": "^4.2.2",
"del": "^6.1.0",
"diff-match-patch": "^1.0.5",
"diff-match-patch-line-and-word": "^0.1.3",
"diff2html": "^3.4.45",
"elastic-apm-node": "^4.1.0",
"email-addresses": "^5.0.0",
"execa": "^5.1.1",
Expand Down Expand Up @@ -996,9 +999,13 @@
"react": "^17.0.2",
"react-ace": "^7.0.5",
"react-color": "^2.13.8",
"react-diff-view": "^3.2.0",
"react-diff-viewer": "^3.1.1",
"react-diff-viewer-continued": "^3.3.1",
"react-dom": "^17.0.2",
"react-dropzone": "^4.2.9",
"react-fast-compare": "^2.0.4",
"react-gh-like-diff": "^2.0.2",
"react-grid-layout": "^1.3.4",
"react-hook-form": "^7.44.2",
"react-intl": "^2.8.0",
Expand Down Expand Up @@ -1056,6 +1063,7 @@
"type-detect": "^4.0.8",
"typescript-fsa": "^3.0.0",
"typescript-fsa-reducers": "^1.2.2",
"unidiff": "^1.0.4",
"unified": "9.2.2",
"use-resize-observer": "^9.1.0",
"usng.js": "^0.4.5",
Expand Down Expand Up @@ -1310,6 +1318,7 @@
"@types/dedent": "^0.7.0",
"@types/deep-freeze-strict": "^1.1.0",
"@types/delete-empty": "^2.0.0",
"@types/diff": "^5.0.8",
"@types/ejs": "^3.0.6",
"@types/enzyme": "^3.10.12",
"@types/eslint": "^8.44.2",
Expand Down
42 changes: 25 additions & 17 deletions packages/shared-ux/code_editor/code_editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

import React, { useState, useRef, useCallback, useMemo, useEffect, KeyboardEvent } from 'react';
import { useResizeDetector } from 'react-resize-detector';
import ReactMonacoEditor from 'react-monaco-editor';
import ReactMonacoEditor, { MonacoDiffEditor } from 'react-monaco-editor';
import {
htmlIdGenerator,
EuiToolTip,
Expand Down Expand Up @@ -151,6 +151,7 @@ export const CodeEditor: React.FC<CodeEditorProps> = ({
}),
isCopyable = false,
allowFullScreen = false,
original,
}) => {
const { colorMode, euiTheme } = useEuiTheme();
const useDarkTheme = useDarkThemeProp ?? colorMode === 'DARK';
Expand All @@ -162,6 +163,8 @@ export const CodeEditor: React.FC<CodeEditorProps> = ({
typeof ReactMonacoEditor === 'function' && ReactMonacoEditor.name === 'JestMockEditor';
return isMockedComponent
? (ReactMonacoEditor as unknown as () => typeof ReactMonacoEditor)()
: original
? MonacoDiffEditor
: ReactMonacoEditor;
}, []);

Expand Down Expand Up @@ -386,23 +389,27 @@ export const CodeEditor: React.FC<CodeEditorProps> = ({
textboxMutationObserver.current.observe(textbox, { attributes: true });
}

editor.onKeyDown(onKeydownMonaco);
editor.onDidBlurEditorText(onBlurMonaco);

// "widget" is not part of the TS interface but does exist
// @ts-expect-errors
const suggestionWidget = editor.getContribution('editor.contrib.suggestController')?.widget
?.value;
if (editor.onKeyDown && editor.onDidBlurEditorText) {
editor.onKeyDown(onKeydownMonaco);
editor.onDidBlurEditorText(onBlurMonaco);
}

// As I haven't found official documentation for "onDidShow" and "onDidHide"
// we guard from possible changes in the underlying lib
if (suggestionWidget && suggestionWidget.onDidShow && suggestionWidget.onDidHide) {
suggestionWidget.onDidShow(() => {
isSuggestionMenuOpen.current = true;
});
suggestionWidget.onDidHide(() => {
isSuggestionMenuOpen.current = false;
});
if (editor.getContribution) {
// "widget" is not part of the TS interface but does exist
// @ts-expect-errors
const suggestionWidget = editor.getContribution('editor.contrib.suggestController')?.widget
?.value;

// As I haven't found official documentation for "onDidShow" and "onDidHide"
// we guard from possible changes in the underlying lib
if (suggestionWidget && suggestionWidget.onDidShow && suggestionWidget.onDidHide) {
suggestionWidget.onDidShow(() => {
isSuggestionMenuOpen.current = true;
});
suggestionWidget.onDidHide(() => {
isSuggestionMenuOpen.current = false;
});
}
}

editorDidMount?.(editor);
Expand Down Expand Up @@ -472,6 +479,7 @@ export const CodeEditor: React.FC<CodeEditorProps> = ({
<MonacoEditor
theme={theme}
language={languageId}
original={original ?? undefined}
value={value}
onChange={onChange}
width={isFullScreen ? '100vw' : width}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import { findIndex, flatMap, flatten } from 'lodash';
import DiffMatchPatch from 'diff-match-patch';
import type { Diff } from 'diff-match-patch';
import 'diff-match-patch-line-and-word';
import * as diff from 'diff';
import type { Change } from 'diff';
import { isDelete, isInsert, isNormal, pickRanges } from 'react-diff-view';
import type { ChangeData, HunkData, RangeTokenNode, TokenizeEnhancer } from 'react-diff-view';

interface JsDiff {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could be refactored like:

type StringDiffFn = (oldStr: string, newStr: string) => Change[];

type JsonDiffFn = (
  oldObject: Record<string, unknown>,
  newObject: Record<string, unknown>
) => Change[];

interface JsDiff {
  diffChars: StringDiffFn;
  diffWords: StringDiffFn;
  diffWordsWithSpace: StringDiffFn;
  diffLines: StringDiffFn;
  diffTrimmedLines: StringDiffFn;
  diffSentences: StringDiffFn;
  diffCss: StringDiffFn;
  diffJson: JsonDiffFn;
}

diffChars: (oldStr: string, newStr: string) => Change[];
diffWords: (oldStr: string, newStr: string) => Change[];
diffWordsWithSpace: (oldStr: string, newStr: string) => Change[];
diffLines: (oldStr: string, newStr: string) => Change[];
diffTrimmedLines: (oldStr: string, newStr: string) => Change[];
diffSentences: (oldStr: string, newStr: string) => Change[];
diffCss: (oldStr: string, newStr: string) => Change[];
diffJson: (oldObject: Record<string, unknown>, newObject: Record<string, unknown>) => Change[];
}

const jsDiff: JsDiff = diff;

export enum DiffMethod {
CHARS = 'diffChars',
WORDS = 'diffWords',
WORDS_WITH_SPACE = 'diffWordsWithSpace',
LINES = 'diffLines',
TRIMMED_LINES = 'diffTrimmedLines',
SENTENCES = 'diffSentences',
CSS = 'diffCss',
JSON = 'diffJson',
WORDS_CUSTOM_USING_DMP = 'diffWordsCustomUsingDmp',
}

const { DIFF_EQUAL, DIFF_DELETE, DIFF_INSERT } = DiffMatchPatch;

function findChangeBlocks(changes: ChangeData[]): ChangeData[][] {
const start = findIndex(changes, (change) => !isNormal(change));

if (start === -1) {
return [];
}

const end = findIndex(changes, (change) => !!isNormal(change), start);

if (end === -1) {
return [changes.slice(start)];
}

return [changes.slice(start, end), ...findChangeBlocks(changes.slice(end))];
}

function groupDiffs(diffs: Diff[]): [Diff[], Diff[]] {
return diffs.reduce<[Diff[], Diff[]]>(
// eslint-disable-next-line @typescript-eslint/no-shadow
([oldDiffs, newDiffs], diff) => {
const [type] = diff;

switch (type) {
case DIFF_INSERT:
newDiffs.push(diff);
break;
case DIFF_DELETE:
oldDiffs.push(diff);
break;
default:
oldDiffs.push(diff);
newDiffs.push(diff);
break;
}

return [oldDiffs, newDiffs];
},
[[], []]
);
}

function splitDiffToLines(diffs: Diff[]): Diff[][] {
return diffs.reduce<Diff[][]>(
(lines, [type, value]) => {
const currentLines = value.split('\n');

const [currentLineRemaining, ...nextLines] = currentLines.map(
(line: string): Diff => [type, line]
);
const next: Diff[][] = [
...lines.slice(0, -1),
[...lines[lines.length - 1], currentLineRemaining],
...nextLines.map((line: string) => [line]),
];
return next;
},
[[]]
);
}

function diffsToEdits(diffs: Diff[], lineNumber: number): RangeTokenNode[] {
const output = diffs.reduce<[RangeTokenNode[], number]>(
// eslint-disable-next-line @typescript-eslint/no-shadow
(output, diff) => {
const [edits, start] = output;
const [type, value] = diff;
if (type !== DIFF_EQUAL) {
const edit: RangeTokenNode = {
type: 'edit',
lineNumber,
start,
length: value.length,
};
edits.push(edit);
}

return [edits, start + value.length];
},
[[], 0]
);

return output[0];
}

function convertToLinesOfEdits(linesOfDiffs: Diff[][], startLineNumber: number) {
return flatMap(linesOfDiffs, (diffs, i) => diffsToEdits(diffs, startLineNumber + i));
}

/*
UPDATE: I figured that there's a way to do it without relying on "diff-match-patch-line-and-word".
See a new function "diffBy" below. Leaving this function here for comparison.
*/
function diffByWord(x: string, y: string): [Diff[], Diff[]] {
/*
This is a modified version of "diffText" from react-diff-view.
Original: https://github.com/otakustay/react-diff-view/blob/49cebd0958ef323c830395c1a1da601560a71781/src/tokenize/markEdits.ts#L96
*/
const dmp = new DiffMatchPatch();
/*
"diff_wordMode" comes from "diff-match-patch-line-and-word".
"diff-match-patch-line-and-word" adds word-level diffing to Google's "diff-match-patch" lib by
adding a new method "diff_wordMode" to the prototype of DiffMatchPatch.
There's an instruction how to do it in the "diff-match-patch" docs and somebody just made it into a package.
https://github.com/google/diff-match-patch/wiki/Line-or-Word-Diffs#word-mode
*/
const diffs = dmp.diff_wordMode(x, y);

if (diffs.length <= 1) {
return [[], []];
}

return groupDiffs(diffs);
}

function diffBy(diffMethod: DiffMethod, x: string, y: string): [Diff[], Diff[]] {
const jsDiffChanges: Change[] = jsDiff[diffMethod](x, y);
const diffs: Diff[] = diff.convertChangesToDMP(jsDiffChanges);

if (diffs.length <= 1) {
return [[], []];
}

return groupDiffs(diffs);
}

function diffChangeBlock(
changes: ChangeData[],
diffMethod: DiffMethod
): [RangeTokenNode[], RangeTokenNode[]] {
/* Convert ChangeData array to two strings representing old source and new source of a change block, like

"created_at": "2023-11-20T16:47:52.801Z",
"created_by": "elastic",
...

and

"created_at": "1970-01-01T00:00:00.000Z",
"created_by": "",
...
*/
const [oldSource, newSource] = changes.reduce(
// eslint-disable-next-line @typescript-eslint/no-shadow
([oldSource, newSource], change) =>
isDelete(change)
? [oldSource + (oldSource ? '\n' : '') + change.content, newSource]
: [oldSource, newSource + (newSource ? '\n' : '') + change.content],
['', '']
);

const [oldDiffs, newDiffs] =
diffMethod === DiffMethod.WORDS_CUSTOM_USING_DMP // <-- That's basically the only change I made to allow word-level diffing
? diffByWord(oldSource, newSource)
: diffBy(diffMethod, oldSource, newSource);

if (oldDiffs.length === 0 && newDiffs.length === 0) {
return [[], []];
}

const getLineNumber = (change: ChangeData | undefined) => {
if (!change || isNormal(change)) {
return undefined;
}

return change.lineNumber;
};
const oldStartLineNumber = getLineNumber(changes.find(isDelete));
const newStartLineNumber = getLineNumber(changes.find(isInsert));

if (oldStartLineNumber === undefined || newStartLineNumber === undefined) {
throw new Error('Could not find start line number for edit');
}

const oldEdits = convertToLinesOfEdits(splitDiffToLines(oldDiffs), oldStartLineNumber);
const newEdits = convertToLinesOfEdits(splitDiffToLines(newDiffs), newStartLineNumber);

return [oldEdits, newEdits];
}

export function markEditsBy(hunks: HunkData[], diffMethod: DiffMethod): TokenizeEnhancer {
const changeBlocks = flatMap(
hunks.map((hunk) => hunk.changes),
findChangeBlocks
);

const [oldEdits, newEdits] = changeBlocks
.map((changes) => diffChangeBlock(changes, diffMethod))
.reduce(
// eslint-disable-next-line @typescript-eslint/no-shadow
([oldEdits, newEdits], [currentOld, currentNew]) => [
oldEdits.concat(currentOld),
newEdits.concat(currentNew),
],
[[], []]
);

return pickRanges(flatten(oldEdits), flatten(newEdits));
}
Loading