diff --git a/package.json b/package.json index bb666f88f738d..3d3e286079c9f 100644 --- a/package.json +++ b/package.json @@ -910,6 +910,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", @@ -1017,9 +1020,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", @@ -1077,6 +1084,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", @@ -1332,6 +1340,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", diff --git a/packages/shared-ux/code_editor/code_editor.tsx b/packages/shared-ux/code_editor/code_editor.tsx index e6d54ddbff04d..1f5862a6112bf 100644 --- a/packages/shared-ux/code_editor/code_editor.tsx +++ b/packages/shared-ux/code_editor/code_editor.tsx @@ -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, @@ -151,6 +151,7 @@ export const CodeEditor: React.FC = ({ }), isCopyable = false, allowFullScreen = false, + original, }) => { const { colorMode, euiTheme } = useEuiTheme(); const useDarkTheme = useDarkThemeProp ?? colorMode === 'DARK'; @@ -162,6 +163,8 @@ export const CodeEditor: React.FC = ({ typeof ReactMonacoEditor === 'function' && ReactMonacoEditor.name === 'JestMockEditor'; return isMockedComponent ? (ReactMonacoEditor as unknown as () => typeof ReactMonacoEditor)() + : original + ? MonacoDiffEditor : ReactMonacoEditor; }, []); @@ -386,23 +389,27 @@ export const CodeEditor: React.FC = ({ 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); @@ -472,6 +479,7 @@ export const CodeEditor: React.FC = ({ ): string => + stringify(jsObject, { space: 2 }); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/mark_edits_by_word.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/mark_edits_by_word.tsx new file mode 100644 index 0000000000000..a348ca46d9ad5 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/mark_edits_by_word.tsx @@ -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 { + 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, newObject: Record) => 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( + (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)); +} diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_details_flyout.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_details_flyout.tsx index aa221b6cdb147..8d6ef88d92c74 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_details_flyout.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_details_flyout.tsx @@ -95,7 +95,7 @@ const tabPaddingClassName = css` padding: 0 ${euiThemeVars.euiSizeM} ${euiThemeVars.euiSizeXL} ${euiThemeVars.euiSizeM}; `; -const TabContentPadding: React.FC = ({ children }) => ( +export const TabContentPadding: React.FC = ({ children }) => (
{children}
); @@ -104,6 +104,7 @@ interface RuleDetailsFlyoutProps { ruleActions?: React.ReactNode; dataTestSubj?: string; closeFlyout: () => void; + getRuleTabs?: (rule: RuleResponse, defaultTabs: EuiTabbedContentTab[]) => EuiTabbedContentTab[]; } export const RuleDetailsFlyout = ({ @@ -111,6 +112,7 @@ export const RuleDetailsFlyout = ({ ruleActions, dataTestSubj, closeFlyout, + getRuleTabs, }: RuleDetailsFlyoutProps) => { const { expandedOverviewSections, toggleOverviewSection } = useOverviewTabSections(); @@ -145,12 +147,13 @@ export const RuleDetailsFlyout = ({ ); const tabs = useMemo(() => { + const defaultTabs = [overviewTab]; if (rule.note) { - return [overviewTab, investigationGuideTab]; - } else { - return [overviewTab]; + defaultTabs.push(investigationGuideTab); } - }, [overviewTab, investigationGuideTab, rule.note]); + + return getRuleTabs ? getRuleTabs(rule, defaultTabs) : defaultTabs; + }, [overviewTab, investigationGuideTab, rule, getRuleTabs]); const [selectedTabId, setSelectedTabId] = useState(tabs[0].id); const selectedTab = tabs.find((tab) => tab.id === selectedTabId) ?? tabs[0]; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_diff_tab_app_experience_team_poc.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_diff_tab_app_experience_team_poc.tsx new file mode 100644 index 0000000000000..474933a4ec970 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_diff_tab_app_experience_team_poc.tsx @@ -0,0 +1,154 @@ +/* + * 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 React, { useMemo } from 'react'; +import { css } from '@emotion/react'; +import { euiThemeVars } from '@kbn/ui-theme'; +import type { Change } from 'diff'; +import { diffLines } from 'diff'; +import { EuiSpacer, useEuiBackgroundColor, tint } from '@elastic/eui'; +import type { RuleResponse } from '../../../../../common/api/detection_engine/model/rule_schema/rule_schemas.gen'; +import { sortAndStringifyJson } from './json_diff/sort_stringify_json'; + +interface RuleDiffTabProps { + oldRule: RuleResponse; + newRule: RuleResponse; +} + +export const RuleDiffTabAppExperienceTeamPoc = ({ oldRule, newRule }: RuleDiffTabProps) => { + const diff = useDiff(oldRule, newRule); + + return ( + <> + + {diff.map((change, i) => ( + + ))} + + ); +}; + +const useDiff = (oldRule: RuleResponse, newRule: RuleResponse) => { + const memoizedDiff = useMemo(() => { + const oldSource = sortAndStringifyJson(oldRule); + const newSource = sortAndStringifyJson(newRule); + + return diffLines(JSON.stringify(oldSource), JSON.stringify(newSource), { + ignoreWhitespace: false, + }); + }, [oldRule, newRule]); + + return memoizedDiff; +}; + +// ------------------------------------------------------------------------------------------------- +// DiffSegment component + +const indicatorCss = css` + position: absolute; + width: ${euiThemeVars.euiSizeS}; + height: 100%; + margin-left: calc(-${euiThemeVars.euiSizeS} - calc(${euiThemeVars.euiSizeXS} / 2)); + text-align: center; + line-height: ${euiThemeVars.euiFontSizeM}; + font-weight: ${euiThemeVars.euiFontWeightMedium}; +`; + +const matchIndicatorCss = css` + &:before { + content: '+'; + ${indicatorCss} + background-color: ${euiThemeVars.euiColorSuccess}; + color: ${euiThemeVars.euiColorLightestShade}; + } +`; + +const diffIndicatorCss = css` + &:before { + content: '-'; + ${indicatorCss} + background-color: ${tint(euiThemeVars.euiColorDanger, 0.25)}; + color: ${euiThemeVars.euiColorLightestShade}; + } +`; + +const DiffSegment = ({ + change, + diffMode, + showDiffDecorations, +}: { + change: Change; + diffMode: 'lines' | undefined; + showDiffDecorations: boolean | undefined; +}) => { + const matchBackgroundColor = useEuiBackgroundColor('success'); + const diffBackgroundColor = useEuiBackgroundColor('danger'); + + const matchCss = { + backgroundColor: matchBackgroundColor, + color: euiThemeVars.euiColorSuccessText, + }; + + const diffCss = { + backgroundColor: diffBackgroundColor, + color: euiThemeVars.euiColorDangerText, + }; + + const highlightCss = change.added ? matchCss : change.removed ? diffCss : undefined; + + const paddingCss = useMemo(() => { + if (diffMode === 'lines') { + return css` + padding-left: calc(${euiThemeVars.euiSizeXS} / 2); + `; + } + }, [diffMode]); + + const decorationCss = useMemo(() => { + if (!showDiffDecorations) { + return undefined; + } + + if (diffMode === 'lines') { + if (change.added) { + return matchIndicatorCss; + } else if (change.removed) { + return diffIndicatorCss; + } + } else { + if (change.added) { + return css` + text-decoration: underline; + `; + } else if (change.removed) { + return css` + text-decoration: line-through; + `; + } + } + }, [change.added, change.removed, diffMode, showDiffDecorations]); + + return ( +
+ {change.value} +
+ ); +}; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_diff_tab_diff2html.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_diff_tab_diff2html.tsx new file mode 100644 index 0000000000000..e2c669c4c967e --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_diff_tab_diff2html.tsx @@ -0,0 +1,55 @@ +/* + * 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 React, { useMemo } from 'react'; +import * as Diff2Html from 'diff2html'; +import { formatLines, diffLines } from 'unidiff'; +import 'diff2html/bundles/css/diff2html.min.css'; +import { EuiSpacer } from '@elastic/eui'; +import type { RuleResponse } from '../../../../../common/api/detection_engine/model/rule_schema/rule_schemas.gen'; +import { sortAndStringifyJson } from './json_diff/sort_stringify_json'; + +interface RuleDiffTabProps { + oldRule: RuleResponse; + newRule: RuleResponse; +} + +export const RuleDiffTabDiff2Html = ({ oldRule, newRule }: RuleDiffTabProps) => { + const diffHtml = useDiffHtml(oldRule, newRule); + + return ( + <> + +
+ + ); +}; + +const useDiffHtml = (oldRule: RuleResponse, newRule: RuleResponse): string => { + const memoizedDiffHtml = useMemo(() => { + const unifiedDiffString = formatLines( + diffLines(sortAndStringifyJson(oldRule), sortAndStringifyJson(newRule)), + { context: 3 } + ); + + return Diff2Html.html(unifiedDiffString, { + inputFormat: 'json', + drawFileList: false, + fileListToggle: false, + fileListStartVisible: false, + fileContentToggle: false, + matching: 'lines', // "lines" or "words" + diffStyle: 'word', // "word" or "char" + outputFormat: 'side-by-side', + synchronisedScroll: true, + highlight: true, + renderNothingWhenEmpty: false, + }); + }, [oldRule, newRule]); + + return memoizedDiffHtml; +}; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_diff_tab_monaco.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_diff_tab_monaco.tsx new file mode 100644 index 0000000000000..98e2926256452 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_diff_tab_monaco.tsx @@ -0,0 +1,57 @@ +/* + * 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 React, { useMemo } from 'react'; +import { CodeEditorField } from '@kbn/kibana-react-plugin/public'; +import { XJsonLang } from '@kbn/monaco'; +import { EuiSpacer } from '@elastic/eui'; +import type { RuleResponse } from '../../../../../common/api/detection_engine/model/rule_schema/rule_schemas.gen'; +import { sortAndStringifyJson } from './json_diff/sort_stringify_json'; + +interface RuleDiffTabProps { + oldRule: RuleResponse; + newRule: RuleResponse; +} + +export const RuleDiffTabMonaco = ({ oldRule, newRule }: RuleDiffTabProps) => { + const [oldRuleString, newRuleString] = useMemo(() => { + return [sortAndStringifyJson(oldRule), sortAndStringifyJson(newRule)]; + }, [oldRule, newRule]); + + return ( + <> + + + + ); +}; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_diff_tab_react_diff_view.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_diff_tab_react_diff_view.tsx new file mode 100644 index 0000000000000..4d4d20fc20778 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_diff_tab_react_diff_view.tsx @@ -0,0 +1,459 @@ +/* + * 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 React, { useState, useMemo, useCallback } from 'react'; +import type { ReactElement } from 'react'; +import { css, Global } from '@emotion/react'; +import { + Diff, + Hunk, + useSourceExpansion, + useMinCollapsedLines, + Decoration, + getCollapsedLinesCountBetween, + parseDiff, + tokenize, + markEdits, +} from 'react-diff-view'; +import 'react-diff-view/style/index.css'; +import type { RenderGutter, HunkData, DecorationProps, TokenizeOptions } from 'react-diff-view'; +import unidiff from 'unidiff'; +import { EuiSpacer, EuiIcon, EuiLink, useEuiTheme, EuiSwitch, EuiRadioGroup } from '@elastic/eui'; +import type { RuleResponse } from '../../../../../common/api/detection_engine/model/rule_schema/rule_schemas.gen'; +import { markEditsBy, DiffMethod } from './mark_edits_by_word'; +import { sortAndStringifyJson } from './json_diff/sort_stringify_json'; + +interface UnfoldProps extends Omit { + start: number; + end: number; + direction: 'up' | 'down' | 'none'; + onExpand: (start: number, end: number) => void; +} + +function Unfold({ start, end, direction, onExpand, ...props }: UnfoldProps) { + const expand = useCallback(() => onExpand(start, end), [onExpand, start, end]); + + const linesCount = end - start; + + const iconType = { + up: 'sortUp', + down: 'sortDown', + none: 'sortable', + }; + + return ( + + + + {`Expand ${linesCount}${direction !== 'none' ? ' more' : ''} hidden line${ + linesCount > 1 ? 's' : '' + }`} + + + ); +} + +interface UnfoldCollapsedProps { + previousHunk: HunkData; + currentHunk?: HunkData; + linesCount: number; + onExpand: (start: number, end: number) => void; +} + +function UnfoldCollapsed({ + previousHunk, + currentHunk, + linesCount, + onExpand, +}: UnfoldCollapsedProps) { + if (!currentHunk) { + const nextStart = previousHunk.oldStart + previousHunk.oldLines; + const collapsedLines = linesCount - nextStart + 1; + + if (collapsedLines <= 0) { + return null; + } + + return ( + <> + {collapsedLines > 10 && ( + + )} + + + ); + } + + const collapsedLines = getCollapsedLinesCountBetween(previousHunk, currentHunk); + + if (!previousHunk) { + if (!collapsedLines) { + return null; + } + + const start = Math.max(currentHunk.oldStart - 10, 1); + + return ( + <> + + {collapsedLines > 10 && ( + + )} + + ); + } + + const collapsedStart = previousHunk.oldStart + previousHunk.oldLines; + const collapsedEnd = currentHunk.oldStart; + + if (collapsedLines < 10) { + return ( + + ); + } + + return ( + <> + + + + + ); +} + +const useExpand = (hunks: HunkData[], oldSource: string, newSource: string) => { + // useMemo(() => {}, [oldSource, newSource]); + const [hunksWithSourceExpanded, expandRange] = useSourceExpansion(hunks, oldSource); // Operates on hunks to allow "expansion" behaviour - substitutes two hunks with one hunk including data from two hunks and everything in between + const hunksWithMinLinesCollapsed = useMinCollapsedLines(0, hunksWithSourceExpanded, oldSource); + + return { + expandRange, + hunks: hunksWithMinLinesCollapsed, + }; +}; + +const useTokens = (hunks: HunkData[], diffMethod: DiffMethod, oldSource: string) => { + if (!hunks) { + return undefined; + } + + const options: TokenizeOptions = { + oldSource, + highlight: false, + enhancers: [ + /* + "markEditsBy" is a slightly modified version of "markEdits" enhancer from react-diff-view + to enable word-level highlighting. + */ + diffMethod === DiffMethod.CHARS + ? markEdits(hunks, { type: 'block' }) // Using built-in "markEdits" enhancer for char-level diffing + : markEditsBy(hunks, diffMethod), // Using custom "markEditsBy" enhancer for other-level diffing + ], + }; + + try { + /* + Synchroniously applies all the enhancers to the hunks and returns an array of tokens. + There's also a way to use a web worker to tokenize in a separate thread. + Example can be found here: https://github.com/otakustay/react-diff-view/blob/49cebd0958ef323c830395c1a1da601560a71781/site/components/DiffView/index.tsx#L43 + It didn't work for me right away, but theoretically the possibility is there. + */ + return tokenize(hunks, options); + } catch (ex) { + return undefined; + } +}; + +const convertToDiffFile = (oldSource: string, newSource: string) => { + /* + "diffLines" call below converts two strings of text into an array of Change objects. + Change objects look like this: + [ + ... + { + "count": 2, + "removed": true, + "value": "\"from\": \"now-540s\"" + }, + { + "count": 1, + "added": true, + "value": "\"from\": \"now-9m\"" + }, + ... + ] + + "formatLines" takes an array of Change objects and turns it into one big "unified Git diff" string. + Unified Git diff is a string with Git markers added. Looks something like this: + ` + @@ -3,16 +3,15 @@ + "author": ["Elastic"], + - "from": "now-540s", + + "from": "now-9m", + "history_window_start": "now-14d", + ` + */ + + const unifiedDiff: string = unidiff.formatLines(unidiff.diffLines(oldSource, newSource), { + context: 3, + }); + + /* + "parseDiff" converts a unified diff string into a JSDiff File object. + */ + const [diffFile] = parseDiff(unifiedDiff, { + nearbySequences: 'zip', + }); + /* + File object contains some metadata and the "hunks" property - an array of Hunk objects. + At this stage Hunks represent changed lines of code plus a few unchanged lines above and below for context. + Hunk objects look like this: + [ + ... + { + content: ' "from": "now-9m",' + isInsert: true, + lineNumber: 14, + type: "insert" + }, + { + content: ' "from": "now-540s",' + isDelete: true, + lineNumber: 15, + type: "delete" + }, + ... + ] + */ + + return diffFile; +}; + +interface DiffViewProps { + oldSource: string; + newSource: string; + diffMethod: DiffMethod; +} + +interface HunksProps { + hunks: HunkData[]; + oldSource: string; + expandRange: (start: number, end: number) => void; +} + +const Hunks = ({ hunks, oldSource, expandRange }: HunksProps) => { + const linesCount = oldSource.split('\n').length; + + const hunkElements = hunks.reduce((children: ReactElement[], hunk: HunkData, index: number) => { + const previousElement = children[children.length - 1]; + + children.push( + + ); + + children.push(); + + const isLastHunk = index === hunks.length - 1; + if (isLastHunk && oldSource) { + children.push( + + ); + } + + return children; + }, []); + + return <>{hunkElements}; +}; + +const CODE_CLASS_NAME = 'rule-update-diff-code'; +const GUTTER_CLASS_NAME = 'rule-update-diff-gutter'; + +interface CustomStylesProps { + children: React.ReactNode; +} + +const CustomStyles = ({ children }: CustomStylesProps) => { + const { euiTheme } = useEuiTheme(); + const [enabled, setEnabled] = useState(false); + + const customCss = css` + .${CODE_CLASS_NAME}.diff-code, .${GUTTER_CLASS_NAME}.diff-gutter { + background: transparent; + } + + .${CODE_CLASS_NAME}.diff-code-delete .diff-code-edit, + .${CODE_CLASS_NAME}.diff-code-insert .diff-code-edit { + background: transparent; + } + + .${CODE_CLASS_NAME}.diff-code-delete .diff-code-edit { + color: ${euiTheme.colors.dangerText}; + text-decoration: line-through; + } + + .${CODE_CLASS_NAME}.diff-code-insert .diff-code-edit { + color: ${euiTheme.colors.successText}; + } + `; + + return ( + <> + {enabled && } + { + setEnabled(!enabled); + }} + /> + + {children} + + ); +}; + +function DiffView({ oldSource, newSource, diffMethod }: DiffViewProps) { + /* + "react-diff-view" components consume diffs not as a strings, but as something they call "hunks". + So we first need to convert our "before" and "after" strings into these "hunks". + "hunks" are objects describing changed sections of code plus a few unchanged lines above and below for context. + */ + + /* + "diffFile" is essentially an object containing "hunks" and some metadata. + */ + const diffFile = useMemo(() => convertToDiffFile(oldSource, newSource), [oldSource, newSource]); + + /* + Sections of diff without changes are hidden by default, because they are not present in the "hunks" array. + + "useExpand" allows to show these hidden sections when user clicks on "Expand hidden lines" button. + + "expandRange" basically merges two hunks into one: takes first hunk, appends all the lines between it and the second hunk and finally appends the second hunk. + + returned "hunks" is the resulting array of hunks with hidden section expanded. + */ + const { expandRange, hunks } = useExpand(diffFile.hunks, oldSource, newSource); + + /* + Here we go over each hunk and extract tokens from it. For example, splitting strings into words, + so we can later highlight changes on a word-by-word basis vs line-by-line. + */ + const tokens = useTokens(hunks, diffMethod, oldSource); + + return ( + + {/* eslint-disable-next-line @typescript-eslint/no-shadow */} + {(hunks) => } + + ); +} + +const renderGutter: RenderGutter = ({ change }) => { + /* + Custom gutter (a column where you normally see line numbers). + Here's I am returning "+" or "-" so the diff is more readable by colorblind people. + */ + if (change.type === 'insert') { + return '+'; + } + + if (change.type === 'delete') { + return '-'; + } + + if (change.type === 'normal') { + return null; + } +}; + +interface RuleDiffTabProps { + oldRule: RuleResponse; + newRule: RuleResponse; +} + +export const RuleDiffTabReactDiffView = ({ oldRule, newRule }: RuleDiffTabProps) => { + const options = [ + { + id: DiffMethod.CHARS, + label: 'Chars', + }, + { + id: DiffMethod.WORDS, + label: 'Words', + }, + { + id: DiffMethod.WORDS_CUSTOM_USING_DMP, + label: 'Words, alternative method (using "diff-match-patch" library)', + }, + { + id: DiffMethod.LINES, + label: 'Lines', + }, + { + id: DiffMethod.SENTENCES, + label: 'Sentences', + }, + ]; + + const [diffMethod, setDiffMethod] = useState(DiffMethod.CHARS); + + const [oldSource, newSource] = useMemo(() => { + return [sortAndStringifyJson(oldRule), sortAndStringifyJson(newRule)]; + }, [oldRule, newRule]); + + return ( + <> + + { + setDiffMethod(optionId as DiffMethod); + }} + legend={{ + children: {'Diffing algorthm'}, + }} + /> + + + + + + ); +}; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_diff_tab_react_diff_viewer_continued.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_diff_tab_react_diff_viewer_continued.tsx new file mode 100644 index 0000000000000..969e1fe6410c5 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_diff_tab_react_diff_viewer_continued.tsx @@ -0,0 +1,160 @@ +/* + * 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 React, { useState, useContext, useMemo } from 'react'; +import ReactDiffViewer, { DiffMethod } from 'react-diff-viewer-continued'; +import { EuiSpacer, EuiSwitch, EuiRadioGroup, useEuiTheme } from '@elastic/eui'; +import type { RuleResponse } from '../../../../../common/api/detection_engine/model/rule_schema/rule_schemas.gen'; +import { sortAndStringifyJson } from './json_diff/sort_stringify_json'; + +const CustomStylesContext = React.createContext({}); +const DiffMethodContext = React.createContext(DiffMethod.CHARS); + +interface CustomStylesProps { + children: React.ReactNode; +} + +const CustomStyles = ({ children }: CustomStylesProps) => { + const { euiTheme } = useEuiTheme(); + const [enabled, setEnabled] = useState(false); + + const customStyles = { + variables: { + light: { + addedBackground: 'transparent', + removedBackground: 'transparent', + }, + }, + wordAdded: { + background: 'transparent', + color: euiTheme.colors.successText, + }, + wordRemoved: { + background: 'transparent', + color: euiTheme.colors.dangerText, + textDecoration: 'line-through', + }, + }; + + return ( + + { + setEnabled(!enabled); + }} + /> + + {children} + + ); +}; + +interface WholeObjectDiffProps { + oldRule: RuleResponse; + newRule: RuleResponse; +} + +const WholeObjectDiff = ({ oldRule, newRule }: WholeObjectDiffProps) => { + const diffMethod = useContext(DiffMethodContext); + const styles = useContext(CustomStylesContext); + + const [oldSource, newSource] = useMemo(() => { + const oldSrc = + diffMethod === DiffMethod.JSON && typeof oldRule === 'object' + ? oldRule + : sortAndStringifyJson(oldRule); + + const newSrc = + diffMethod === DiffMethod.JSON && typeof newRule === 'object' + ? newRule + : sortAndStringifyJson(newRule); + + return [oldSrc, newSrc]; + }, [oldRule, newRule, diffMethod]); + + return ( + + ); +}; + +interface RuleDiffTabProps { + oldRule: RuleResponse; + newRule: RuleResponse; +} + +export const RuleDiffTabReactDiffViewerContinued = ({ oldRule, newRule }: RuleDiffTabProps) => { + const options = [ + { + id: DiffMethod.CHARS, + label: 'Chars', + }, + { + id: DiffMethod.WORDS, + label: 'Words', + }, + { + id: DiffMethod.LINES, + label: 'Lines', + }, + { + id: DiffMethod.SENTENCES, + label: 'Sentences', + }, + { + id: DiffMethod.JSON, + label: 'JSON', + }, + ]; + + const [compareMethod, setCompareMethod] = useState(DiffMethod.JSON); + + return ( + <> + + { + setCompareMethod(optionId as DiffMethod); + }} + legend={{ + children: {'Diffing algorthm'}, + }} + /> + + + + + + + + ); +}; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/unidiff.d.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/unidiff.d.ts new file mode 100644 index 0000000000000..0ae55f9542bcb --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/unidiff.d.ts @@ -0,0 +1,16 @@ +/* + * 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. + */ + +declare module 'unidiff' { + export interface FormatOptions { + context?: number; + } + + export function diffLines(x: string, y: string): string[]; + + export function formatLines(line: string[], options?: FormatOptions): string; +} diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/upgrade_prebuilt_rules_table/upgrade_prebuilt_rules_table_context.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/upgrade_prebuilt_rules_table/upgrade_prebuilt_rules_table_context.tsx index 290f85ade3a03..697d6833ebaff 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/upgrade_prebuilt_rules_table/upgrade_prebuilt_rules_table_context.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/upgrade_prebuilt_rules_table/upgrade_prebuilt_rules_table_context.tsx @@ -24,11 +24,20 @@ import type { UpgradePrebuiltRulesTableFilterOptions } from './use_filter_prebui import { useFilterPrebuiltRulesToUpgrade } from './use_filter_prebuilt_rules_to_upgrade'; import { useAsyncConfirmation } from '../rules_table/use_async_confirmation'; import { useRuleDetailsFlyout } from '../../../../rule_management/components/rule_details/use_rule_details_flyout'; -import { RuleDetailsFlyout } from '../../../../rule_management/components/rule_details/rule_details_flyout'; +import { + RuleDetailsFlyout, + TabContentPadding, +} from '../../../../rule_management/components/rule_details/rule_details_flyout'; import * as i18n from './translations'; import { MlJobUpgradeModal } from '../../../../../detections/components/modals/ml_job_upgrade_modal'; +import { RuleDiffTabAppExperienceTeamPoc } from '../../../../rule_management/components/rule_details/rule_diff_tab_app_experience_team_poc'; +import { RuleDiffTabReactDiffViewerContinued } from '../../../../rule_management/components/rule_details/rule_diff_tab_react_diff_viewer_continued'; +import { RuleDiffTabReactDiffView } from '../../../../rule_management/components/rule_details/rule_diff_tab_react_diff_view'; +import { RuleDiffTabMonaco } from '../../../../rule_management/components/rule_details/rule_diff_tab_monaco'; +import { RuleDiffTabDiff2Html } from '../../../../rule_management/components/rule_details/rule_diff_tab_diff2html'; + export interface UpgradePrebuiltRulesTableState { /** * Rules available to be updated @@ -257,6 +266,8 @@ export const UpgradePrebuiltRulesTableContextProvider = ({ actions, ]); + // console.log('ReactDiffViewer pre', ReactDiffViewer); + return ( <> @@ -286,6 +297,88 @@ export const UpgradePrebuiltRulesTableContextProvider = ({ {i18n.UPDATE_BUTTON_LABEL} } + getRuleTabs={(rule, defaultTabs) => { + const activeRule = filteredRules.find(({ id }) => rule.id); + const diff = activeRule?.diff; + + if (!diff) { + return defaultTabs; + } + + const diffTabReactDiffViewerContinued = { + id: 'react-diff-viewer-continued', + name: 'react-diff-viewer-continued', + content: ( + + + + ), + }; + + const diffTabReactDiffView = { + id: 'react-diff-view', + name: 'react-diff-view', + content: ( + + + + ), + }; + + const diffTabMonaco = { + id: 'monaco', + name: 'monaco', + content: ( + + + + ), + }; + + const diffTabDiff2Html = { + id: 'diff2html', + name: 'diff2html', + content: ( + + + + ), + }; + + const diffTabAppExperienceTeamPoc = { + id: 'app-experience-team-poc', + name: 'app-experience-team-poc', + content: ( + + + + ), + }; + + return [ + diffTabReactDiffViewerContinued, + diffTabReactDiffView, + diffTabMonaco, + diffTabDiff2Html, + diffTabAppExperienceTeamPoc, + ...defaultTabs, + ]; + }} /> )} diff --git a/yarn.lock b/yarn.lock index e6b6c9eeef668..b639e0bd2063c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1887,6 +1887,17 @@ "@emotion/sheet" "^1.2.2" "@emotion/utils" "^1.2.1" +"@emotion/css@^11.11.2": + version "11.11.2" + resolved "https://registry.yarnpkg.com/@emotion/css/-/css-11.11.2.tgz#e5fa081d0c6e335352e1bc2b05953b61832dca5a" + integrity sha512-VJxe1ucoMYMS7DkiMdC2T7PWNbrEI0a39YRiyDvK2qq4lXwjRbVP/z4lpG+odCsRzadlR+1ywwrTzhdm5HNdew== + dependencies: + "@emotion/babel-plugin" "^11.11.0" + "@emotion/cache" "^11.11.0" + "@emotion/serialize" "^1.1.2" + "@emotion/sheet" "^1.2.2" + "@emotion/utils" "^1.2.1" + "@emotion/hash@0.8.0": version "0.8.0" resolved "https://registry.yarnpkg.com/@emotion/hash/-/hash-0.8.0.tgz#bbbff68978fefdbe68ccb533bc8cbe1d1afb5413" @@ -8964,6 +8975,11 @@ resolved "https://registry.yarnpkg.com/@types/delete-empty/-/delete-empty-2.0.0.tgz#1647ae9e68f708a6ba778531af667ec55bc61964" integrity sha512-sq+kwx8zA9BSugT9N+Jr8/uWjbHMZ+N/meJEzRyT3gmLq/WMtx/iSIpvdpmBUi/cvXl6Kzpvve8G2ESkabFwmg== +"@types/diff@^5.0.8": + version "5.0.8" + resolved "https://registry.yarnpkg.com/@types/diff/-/diff-5.0.8.tgz#28dc501cc3e7c62d4c5d096afe20755170acf276" + integrity sha512-kR0gRf0wMwpxQq6ME5s+tWk9zVCfJUl98eRkD05HWWRbhPB/eu4V1IbyZAsvzC1Gn4znBJ0HN01M4DGXdBEV8Q== + "@types/ejs@^3.0.6": version "3.0.6" resolved "https://registry.yarnpkg.com/@types/ejs/-/ejs-3.0.6.tgz#aca442289df623bfa8e47c23961f0357847b83fe" @@ -13624,6 +13640,16 @@ create-ecdh@^4.0.0: bn.js "^4.1.0" elliptic "^6.0.0" +create-emotion@^10.0.14, create-emotion@^10.0.27: + version "10.0.27" + resolved "https://registry.yarnpkg.com/create-emotion/-/create-emotion-10.0.27.tgz#cb4fa2db750f6ca6f9a001a33fbf1f6c46789503" + integrity sha512-fIK73w82HPPn/RsAij7+Zt8eCE8SptcJ3WoRMfxMtjteYxud8GDTKKld7MYwAX2TVhrw29uR1N/bVGxeStHILg== + dependencies: + "@emotion/cache" "^10.0.27" + "@emotion/serialize" "^0.11.15" + "@emotion/sheet" "0.9.4" + "@emotion/utils" "0.11.3" + create-hash@^1.1.0, create-hash@^1.1.2, create-hash@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/create-hash/-/create-hash-1.2.0.tgz#889078af11a63756bcfb59bd221996be3a9ef196" @@ -14868,7 +14894,12 @@ diacritics@^1.3.0: resolved "https://registry.yarnpkg.com/diacritics/-/diacritics-1.3.0.tgz#3efa87323ebb863e6696cebb0082d48ff3d6f7a1" integrity sha1-PvqHMj67hj5mls67AILUj/PW96E= -diff-match-patch@^1.0.0, diff-match-patch@^1.0.4: +diff-match-patch-line-and-word@^0.1.3: + version "0.1.3" + resolved "https://registry.yarnpkg.com/diff-match-patch-line-and-word/-/diff-match-patch-line-and-word-0.1.3.tgz#0f267c26ab7840785667cccd8c9dc1fb8b288964" + integrity sha512-CR+842NECOQO9qOvlyOf/9IAXMEW8km1Em9YrH8J4wVaeICXtEVJ8H9AZ5Xa0QBTSZUe4DFijGM5dZD5Dl3bEg== + +diff-match-patch@^1.0.0, diff-match-patch@^1.0.4, diff-match-patch@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/diff-match-patch/-/diff-match-patch-1.0.5.tgz#abb584d5f10cd1196dfc55aa03701592ae3f7b37" integrity sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw== @@ -14888,11 +14919,26 @@ diff-sequences@^29.4.3: resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-29.4.3.tgz#9314bc1fabe09267ffeca9cbafc457d8499a13f2" integrity sha512-ofrBgwpPhCD85kMKtE9RYFFq6OC1A89oW2vvgWZNCwxrUpRUILopY7lsYyMDSjc8g6U6aiO0Qubg6r4Wgt5ZnA== +diff2html@^3.1.6, diff2html@^3.4.45: + version "3.4.45" + resolved "https://registry.yarnpkg.com/diff2html/-/diff2html-3.4.45.tgz#6b8cc7af9bb18359635527e5128f40cf3d34ef94" + integrity sha512-1SxsjYZYbxX0GGMYJJM7gM0SpMSHqzvvG0UJVROCDpz4tylH2T+EGiinm2boDmTrMlLueVxGfKNxGNLZ9zDlkQ== + dependencies: + diff "5.1.0" + hogan.js "3.0.2" + optionalDependencies: + highlight.js "11.8.0" + diff@5.0.0, diff@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/diff/-/diff-5.0.0.tgz#7ed6ad76d859d030787ec35855f5b1daf31d852b" integrity sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w== +diff@5.1.0, diff@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/diff/-/diff-5.1.0.tgz#bc52d298c5ea8df9194800224445ed43ffc87e40" + integrity sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw== + diff@^1.3.2: version "1.4.0" resolved "https://registry.yarnpkg.com/diff/-/diff-1.4.0.tgz#7f28d2eb9ee7b15a97efd89ce63dcfdaa3ccbabf" @@ -14917,6 +14963,13 @@ diffie-hellman@^5.0.0: miller-rabin "^4.0.0" randombytes "^2.0.0" +difflib@^0.2.4: + version "0.2.4" + resolved "https://registry.yarnpkg.com/difflib/-/difflib-0.2.4.tgz#b5e30361a6db023176d562892db85940a718f47e" + integrity sha512-9YVwmMb0wQHQNr5J9m6BSj6fk4pfGITGQOOs+D9Fl+INODWFOfvhIU1hNv6GgR1RBoC/9NJcwu77zShxV0kT7w== + dependencies: + heap ">= 0.2.0" + digest-fetch@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/digest-fetch/-/digest-fetch-1.3.0.tgz#898e69264d00012a23cf26e8a3e40320143fc661" @@ -15393,6 +15446,14 @@ emoticon@^3.2.0: resolved "https://registry.yarnpkg.com/emoticon/-/emoticon-3.2.0.tgz#c008ca7d7620fac742fe1bf4af8ff8fed154ae7f" integrity sha512-SNujglcLTTg+lDAcApPNgEdudaqQFiAbJCqzjNxJkvN9vAwCGi0uu8IUVvx+f16h+V44KCY6Y2yboroc9pilHg== +emotion@^10.0.14: + version "10.0.27" + resolved "https://registry.yarnpkg.com/emotion/-/emotion-10.0.27.tgz#f9ca5df98630980a23c819a56262560562e5d75e" + integrity sha512-2xdDzdWWzue8R8lu4G76uWX5WhyQuzATon9LmNeCy/2BHVC6dsEpfhN1a0qhELgtDVdjyEA6J8Y/VlI5ZnaH0g== + dependencies: + babel-plugin-emotion "^10.0.27" + create-emotion "^10.0.27" + enabled@2.0.x: version "2.0.0" resolved "https://registry.yarnpkg.com/enabled/-/enabled-2.0.0.tgz#f9dd92ec2d6f4bbc0d5d1e64e21d61cd4665e7c2" @@ -17475,6 +17536,11 @@ git-hooks-list@1.0.3: resolved "https://registry.yarnpkg.com/git-hooks-list/-/git-hooks-list-1.0.3.tgz#be5baaf78203ce342f2f844a9d2b03dba1b45156" integrity sha512-Y7wLWcrLUXwk2noSka166byGCvhMtDRpgHdzCno1UQv/n/Hegp++a2xBWJL1lJarnKD3SWaljD+0z1ztqxuKyQ== +gitdiff-parser@^0.3.1: + version "0.3.1" + resolved "https://registry.yarnpkg.com/gitdiff-parser/-/gitdiff-parser-0.3.1.tgz#5eb3e66eb7862810ba962fab762134071601baa5" + integrity sha512-YQJnY8aew65id8okGxKCksH3efDCJ9HzV7M9rsvd65habf39Pkh4cgYJ27AaoDMqo1X98pgNJhNMrm/kpV7UVQ== + github-from-package@0.0.0: version "0.0.0" resolved "https://registry.yarnpkg.com/github-from-package/-/github-from-package-0.0.0.tgz#97fb5d96bfde8973313f20e8288ef9a167fa64ce" @@ -18204,6 +18270,11 @@ he@1.2.0, he@^1.2.0: resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== +"heap@>= 0.2.0": + version "0.2.7" + resolved "https://registry.yarnpkg.com/heap/-/heap-0.2.7.tgz#1e6adf711d3f27ce35a81fe3b7bd576c2260a8fc" + integrity sha512-2bsegYkkHO+h/9MGbn6KWcE45cHZgPANo5LXF7EvWdT0yT2EguSVO1nDgU5c8+ZOPwp2vMNa7YFsJhVcDR9Sdg== + heap@^0.2.6: version "0.2.6" resolved "https://registry.yarnpkg.com/heap/-/heap-0.2.6.tgz#087e1f10b046932fc8594dd9e6d378afc9d1e5ac" @@ -18214,6 +18285,11 @@ hexoid@^1.0.0: resolved "https://registry.yarnpkg.com/hexoid/-/hexoid-1.0.0.tgz#ad10c6573fb907de23d9ec63a711267d9dc9bc18" integrity sha512-QFLV0taWQOZtvIRIAdBChesmogZrtuXvVWsFHZTk2SU+anspqZ2vMnoLg7IE1+Uk16N19APic1BuF8bC8c2m5g== +highlight.js@11.8.0: + version "11.8.0" + resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-11.8.0.tgz#966518ea83257bae2e7c9a48596231856555bb65" + integrity sha512-MedQhoqVdr0U6SSnWPzfiadUcDHfN/Wzq25AkXiQv9oiOO/sG0S7XkvpFIqWBl9Yq1UYyYOOVORs5UW2XlPyzg== + highlight.js@^10.1.1, highlight.js@~10.4.0: version "10.4.1" resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-10.4.1.tgz#d48fbcf4a9971c4361b3f95f302747afe19dbad0" @@ -18252,6 +18328,14 @@ hmac-drbg@^1.0.1: minimalistic-assert "^1.0.0" minimalistic-crypto-utils "^1.0.1" +hogan.js@3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/hogan.js/-/hogan.js-3.0.2.tgz#4cd9e1abd4294146e7679e41d7898732b02c7bfd" + integrity sha512-RqGs4wavGYJWE07t35JQccByczmNUXQT0E12ZYV1VKYu5UiAU9lsos/yBAcf840+zrUQQxgVduCR5/B8nNtibg== + dependencies: + mkdirp "0.3.0" + nopt "1.0.10" + hoist-non-react-statics@^2.3.1, hoist-non-react-statics@^2.5.5, hoist-non-react-statics@^3.0.0, hoist-non-react-statics@^3.1.0, hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.1, hoist-non-react-statics@^3.3.2: version "3.3.2" resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45" @@ -21830,6 +21914,11 @@ memfs@^3.1.2, memfs@^3.4.3: resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.1.1.tgz#047b6e3199b508eaec03504de71229b8eb1d75c0" integrity sha512-HKeeBpWvqiVJD57ZUAsJNm71eHTykffzcLZVYWiVfQeI1rJtuEaS7hQiEpWfVVk18donPwJEcFKIkCmPJNOhHA== +memoize-one@^5.0.4: + version "5.2.1" + resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.2.1.tgz#8337aa3c4335581839ec01c3d594090cebe8f00e" + integrity sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q== + memoize-one@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-6.0.0.tgz#b2591b871ed82948aee4727dc6abceeeac8c1045" @@ -22278,6 +22367,11 @@ mkdirp-classic@^0.5.2, mkdirp-classic@^0.5.3: resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113" integrity sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A== +mkdirp@0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.3.0.tgz#1bbf5ab1ba827af23575143490426455f481fe1e" + integrity sha512-OHsdUcVAQ6pOtg5JYWpCBo9W/GySVuwvP9hueRMW7UqshC0tbfzLv8wjySTPm3tfUZ/21CE9E1pJagOA91Pxew== + "mkdirp@>=0.5 0", mkdirp@^0.5.1, mkdirp@^0.5.3, mkdirp@~0.5.1: version "0.5.5" resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.5.tgz#d91cefd62d1436ca0f41620e251288d420099def" @@ -22969,6 +23063,13 @@ nodemailer@^6.6.2: resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-6.6.2.tgz#e184c9ed5bee245a3e0bcabc7255866385757114" integrity sha512-YSzu7TLbI+bsjCis/TZlAXBoM4y93HhlIgo0P5oiA2ua9Z4k+E2Fod//ybIzdJxOlXGRcHIh/WaeCBehvxZb/Q== +nopt@1.0.10: + version "1.0.10" + resolved "https://registry.yarnpkg.com/nopt/-/nopt-1.0.10.tgz#6ddd21bd2a31417b92727dd585f8a6f37608ebee" + integrity sha512-NWmpvLSqUrgrAC9HCuxEvb+PSloHpqVu+FqcO4eeF2h5qYRhA7ev6KvelyQAKtegUbC6RypJnlEOhd8vloNKYg== + dependencies: + abbrev "1" + nopt@^4.0.1, nopt@~4.0.1: version "4.0.3" resolved "https://registry.yarnpkg.com/nopt/-/nopt-4.0.3.tgz#a375cad9d02fd921278d954c2254d5aa57e15e48" @@ -25305,6 +25406,41 @@ react-colorful@^5.1.2: resolved "https://registry.yarnpkg.com/react-colorful/-/react-colorful-5.5.1.tgz#29d9c4e496f2ca784dd2bb5053a3a4340cfaf784" integrity sha512-M1TJH2X3RXEt12sWkpa6hLc/bbYS0H6F4rIqjQZ+RxNBstpY67d9TrFXtqdZwhpmBXcCwEi7stKqFue3ZRkiOg== +react-diff-view@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/react-diff-view/-/react-diff-view-3.2.0.tgz#8fbf04782d78423903a59202ce7533f6312c1cc3" + integrity sha512-p58XoqMxgt71ujpiDQTs9Za3nqTawt1E4bTzKsYSqr8I8br6cjQj1b66HxGnV8Yrc6MD6iQPqS1aZiFoGqEw+g== + dependencies: + classnames "^2.3.2" + diff-match-patch "^1.0.5" + gitdiff-parser "^0.3.1" + lodash "^4.17.21" + shallow-equal "^3.1.0" + warning "^4.0.3" + +react-diff-viewer-continued@^3.3.1: + version "3.3.1" + resolved "https://registry.yarnpkg.com/react-diff-viewer-continued/-/react-diff-viewer-continued-3.3.1.tgz#1ef6af86fc92ad721a5461f8f3c44f74381ea81d" + integrity sha512-YhjWjCUq6cs8k9iErpWh/xB2jFCndigGAz2TKubdqrSTtDH5Ib+tdQgzBWVXMMqgtEwoPLi+WFmSsdSoYbDVpw== + dependencies: + "@emotion/css" "^11.11.2" + classnames "^2.3.2" + diff "^5.1.0" + memoize-one "^6.0.0" + prop-types "^15.8.1" + +react-diff-viewer@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/react-diff-viewer/-/react-diff-viewer-3.1.1.tgz#21ac9c891193d05a3734bfd6bd54b107ee6d46cc" + integrity sha512-rmvwNdcClp6ZWdS11m1m01UnBA4OwYaLG/li0dB781e/bQEzsGyj+qewVd6W5ztBwseQ72pO7nwaCcq5jnlzcw== + dependencies: + classnames "^2.2.6" + create-emotion "^10.0.14" + diff "^4.0.1" + emotion "^10.0.14" + memoize-one "^5.0.4" + prop-types "^15.6.2" + react-docgen-typescript@^2.0.0, react-docgen-typescript@^2.1.1: version "2.2.2" resolved "https://registry.yarnpkg.com/react-docgen-typescript/-/react-docgen-typescript-2.2.2.tgz#4611055e569edc071204aadb20e1c93e1ab1659c" @@ -25418,6 +25554,16 @@ react-focus-on@^3.9.1: use-callback-ref "^1.3.0" use-sidecar "^1.1.2" +react-gh-like-diff@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/react-gh-like-diff/-/react-gh-like-diff-2.0.2.tgz#9a0f91511d7af20407666e5950d2056db0600d62" + integrity sha512-Cd5Kjijx74kz0POQNCSRvFnpfvY4E28NxWea8z0UPZ1J6b2RThRkMBfoD/FwaFvrT/7XeYk5SrQ8qtc0e8iRoA== + dependencies: + diff2html "^3.1.6" + difflib "^0.2.4" + prop-types "^15.7.2" + recompose "^0.30.0" + react-grid-layout@^1.3.4: version "1.3.4" resolved "https://registry.yarnpkg.com/react-grid-layout/-/react-grid-layout-1.3.4.tgz#4fa819be24a1ba9268aa11b82d63afc4762a32ff" @@ -27176,6 +27322,11 @@ shallow-copy@~0.0.1: resolved "https://registry.yarnpkg.com/shallow-copy/-/shallow-copy-0.0.1.tgz#415f42702d73d810330292cc5ee86eae1a11a170" integrity sha1-QV9CcC1z2BAzApLMXuhurhoRoXA= +shallow-equal@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/shallow-equal/-/shallow-equal-3.1.0.tgz#e7a54bac629c7f248eff6c2f5b63122ba4320bec" + integrity sha512-pfVOw8QZIXpMbhBWvzBISicvToTiM5WBF1EeAUZDDSb5Dt29yl4AYbyywbJFSEsRUMr7gJaxqCdr4L3tQf9wVg== + shallowequal@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/shallowequal/-/shallowequal-1.1.0.tgz#188d521de95b9087404fd4dcb68b13df0ae4e7f8" @@ -29475,6 +29626,13 @@ unicode-trie@^2.0.0: pako "^0.2.5" tiny-inflate "^1.0.0" +unidiff@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/unidiff/-/unidiff-1.0.4.tgz#45096a898285821c51e22e84be4215c05d6511cd" + integrity sha512-ynU0vsAXw0ir8roa+xPCUHmnJ5goc5BTM2Kuc3IJd8UwgaeRs7VSD5+eeaQL+xp1JtB92hu/Zy/Lgy7RZcr1pQ== + dependencies: + diff "^5.1.0" + unified@9.2.0: version "9.2.0" resolved "https://registry.yarnpkg.com/unified/-/unified-9.2.0.tgz#67a62c627c40589edebbf60f53edfd4d822027f8" @@ -30484,7 +30642,7 @@ walker@^1.0.7, walker@^1.0.8, walker@~1.0.5: dependencies: makeerror "1.0.12" -warning@^4.0.2: +warning@^4.0.2, warning@^4.0.3: version "4.0.3" resolved "https://registry.yarnpkg.com/warning/-/warning-4.0.3.tgz#16e9e077eb8a86d6af7d64aa1e05fd85b4678ca3" integrity sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==