diff --git a/package.json b/package.json index 7db00b814d99d..c40f51cb7541f 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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", @@ -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", @@ -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", 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 = ({ 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.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_diff_tab.tsx new file mode 100644 index 0000000000000..e7f5f814b89a7 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_diff_tab.tsx @@ -0,0 +1,142 @@ +/* + * 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 type { Change } from 'diff'; +import { diffLines } from 'diff'; +import { css } from '@emotion/react'; +import { euiThemeVars } from '@kbn/ui-theme'; +import { EuiSpacer, useEuiBackgroundColor, tint } from '@elastic/eui'; +import type { RuleFieldsDiff } from '../../../../../common/api/detection_engine/prebuilt_rules/model/diff/rule_diff/rule_diff'; + +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 = css` + background-color: ${matchBackgroundColor}; + color: ${euiThemeVars.euiColorSuccessText}; + `; + const diffCss = css` + background-color: ${diffBackgroundColor}; + color: ${euiThemeVars.euiColorDangerText}; + `; + + const highlightCss = useMemo( + () => (change.added ? matchCss : change.removed ? diffCss : undefined), + [change.added, change.removed, diffCss, matchCss] + ); + + 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} +
+ ); +}; + +interface RuleDiffTabProps { + fields: RuleFieldsDiff; +} + +export const RuleDiffTab = ({ fields }: RuleDiffTabProps) => { + const diff = diffLines( + JSON.stringify(fields.references.current_version, null, 2), + JSON.stringify(fields.references.merged_version, null, 2), + { ignoreWhitespace: false } + ); + + return ( + <> + + {diff.map((change, i) => ( + + ))} + + ); +}; 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..e923aa794502e --- /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,288 @@ +/* + * 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 } from 'react'; +import { css } from '@emotion/react'; +import { euiThemeVars } from '@kbn/ui-theme'; +import { get } from 'lodash'; +import type { Change } from 'diff'; +import { diffLines } from 'diff'; +import { + EuiSpacer, + EuiAccordion, + EuiTitle, + EuiFlexGroup, + EuiHorizontalRule, + useGeneratedHtmlId, + useEuiBackgroundColor, + tint, +} from '@elastic/eui'; +import type { RuleFieldsDiff } from '../../../../../common/api/detection_engine/prebuilt_rules/model/diff/rule_diff/rule_diff'; +import type { RuleResponse } from '../../../../../common/api/detection_engine/model/rule_schema/rule_schemas.gen'; +import { sortAndStringifyJson } from './sort_stringify_json'; + +const HIDDEN_FIELDS = ['meta', 'rule_schedule', 'version']; + +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} +
+ ); +}; + +interface FieldsProps { + fields: Partial; + openSections: Record; + toggleSection: (sectionName: string) => void; +} + +const Fields = ({ fields, openSections, toggleSection }: FieldsProps) => { + const visibleFields = Object.keys(fields).filter( + (fieldName) => !HIDDEN_FIELDS.includes(fieldName) + ); + + return ( + <> + {visibleFields.map((fieldName) => { + const currentVersion: string = get(fields, [fieldName, 'current_version'], ''); + const mergedVersion: string = get(fields, [fieldName, 'merged_version'], ''); + + const oldSource = JSON.stringify(currentVersion, null, 2); + const newSource = JSON.stringify(mergedVersion, null, 2); + + const diff = diffLines(oldSource, newSource, { ignoreWhitespace: false }); + + return ( + <> + { + toggleSection(fieldName); + }} + > + <> + + {diff.map((change, i) => ( + + ))} + + + + + ); + })} + + ); +}; + +interface ExpandableSectionProps { + title: string; + isOpen: boolean; + toggle: () => void; + children: React.ReactNode; +} + +const ExpandableSection = ({ title, isOpen, toggle, children }: ExpandableSectionProps) => { + const accordionId = useGeneratedHtmlId({ prefix: 'accordion' }); + + return ( + +

{title}

+ + } + initialIsOpen={true} + > + + + {children} + +
+ ); +}; + +interface WholeObjectDiffProps { + oldRule: RuleResponse; + newRule: RuleResponse; + openSections: Record; + toggleSection: (sectionName: string) => void; +} + +const WholeObjectDiff = ({ + oldRule, + newRule, + openSections, + toggleSection, +}: WholeObjectDiffProps) => { + const oldSource = sortAndStringifyJson(oldRule); + const newSource = sortAndStringifyJson(newRule); + + const diff = diffLines(JSON.stringify(oldSource), JSON.stringify(newSource), { + ignoreWhitespace: false, + }); + + return ( + <> + { + toggleSection('whole'); + }} + > + <> + + {diff.map((change, i) => ( + + ))} + + + + + ); +}; + +interface RuleDiffTabProps { + oldRule: RuleResponse; + newRule: RuleResponse; + fields: Partial; +} + +export const RuleDiffTabAppExperienceTeamPoc = ({ fields, oldRule, newRule }: RuleDiffTabProps) => { + const [openSections, setOpenSections] = useState>( + Object.keys(fields).reduce((sections, fieldName) => ({ ...sections, [fieldName]: true }), {}) + ); + + const toggleSection = (sectionName: string) => { + setOpenSections((prevOpenSections) => ({ + ...prevOpenSections, + [sectionName]: !prevOpenSections[sectionName], + })); + }; + + return ( + <> + + + + + ); +}; 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..8fa5eff526f86 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_diff_tab_diff2html.tsx @@ -0,0 +1,191 @@ +/* + * 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 } from 'react'; +import * as Diff2Html from 'diff2html'; +import { get } from 'lodash'; +import { formatLines, diffLines } from 'unidiff'; +import 'diff2html/bundles/css/diff2html.min.css'; +import { + EuiSpacer, + EuiAccordion, + EuiTitle, + EuiFlexGroup, + EuiHorizontalRule, + useGeneratedHtmlId, +} from '@elastic/eui'; +import type { RuleFieldsDiff } from '../../../../../common/api/detection_engine/prebuilt_rules/model/diff/rule_diff/rule_diff'; +import type { RuleResponse } from '../../../../../common/api/detection_engine/model/rule_schema/rule_schemas.gen'; +import { sortAndStringifyJson } from './sort_stringify_json'; + +const HIDDEN_FIELDS = ['meta', 'rule_schedule', 'version']; + +interface FieldsProps { + fields: Partial; + openSections: Record; + toggleSection: (sectionName: string) => void; +} + +const Fields = ({ fields, openSections, toggleSection }: FieldsProps) => { + const visibleFields = Object.keys(fields).filter( + (fieldName) => !HIDDEN_FIELDS.includes(fieldName) + ); + + return ( + <> + {visibleFields.map((fieldName) => { + const currentVersion: string = get(fields, [fieldName, 'current_version'], ''); + const mergedVersion: string = get(fields, [fieldName, 'merged_version'], ''); + + const oldSource = JSON.stringify(currentVersion, null, 2); + const newSource = JSON.stringify(mergedVersion, null, 2); + + const unifiedDiffString = formatLines(diffLines(oldSource, newSource), { context: 3 }); + + const diffHtml = 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, + }); + + return ( + <> + { + toggleSection(fieldName); + }} + > +
+ + + + ); + })} + + ); +}; + +interface ExpandableSectionProps { + title: string; + isOpen: boolean; + toggle: () => void; + children: React.ReactNode; +} + +const ExpandableSection = ({ title, isOpen, toggle, children }: ExpandableSectionProps) => { + const accordionId = useGeneratedHtmlId({ prefix: 'accordion' }); + + return ( + +

{title}

+ + } + initialIsOpen={true} + > + + + {children} + +
+ ); +}; + +interface WholeObjectDiffProps { + oldRule: RuleResponse; + newRule: RuleResponse; + openSections: Record; + toggleSection: (sectionName: string) => void; +} + +const WholeObjectDiff = ({ + oldRule, + newRule, + openSections, + toggleSection, +}: WholeObjectDiffProps) => { + const unifiedDiffString = formatLines( + diffLines(sortAndStringifyJson(oldRule), sortAndStringifyJson(newRule)), + { context: 3 } + ); + + const diffHtml = 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, + }); + + return ( + <> + { + toggleSection('whole'); + }} + > +
+ + + + ); +}; + +interface RuleDiffTabProps { + oldRule: RuleResponse; + newRule: RuleResponse; + fields: Partial; +} + +export const RuleDiffTabDiff2Html = ({ fields, oldRule, newRule }: RuleDiffTabProps) => { + const [openSections, setOpenSections] = useState>( + Object.keys(fields).reduce((sections, fieldName) => ({ ...sections, [fieldName]: true }), {}) + ); + + const toggleSection = (sectionName: string) => { + setOpenSections((prevOpenSections) => ({ + ...prevOpenSections, + [sectionName]: !prevOpenSections[sectionName], + })); + }; + + return ( + <> + + + + + ); +}; 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..318acabf12219 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_diff_tab_monaco.tsx @@ -0,0 +1,213 @@ +/* + * 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 } from 'react'; +import { CodeEditorField } from '@kbn/kibana-react-plugin/public'; +import { XJsonLang } from '@kbn/monaco'; +import { get } from 'lodash'; +import { + EuiSpacer, + EuiAccordion, + EuiTitle, + EuiFlexGroup, + EuiHorizontalRule, + useGeneratedHtmlId, +} from '@elastic/eui'; +import type { RuleFieldsDiff } from '../../../../../common/api/detection_engine/prebuilt_rules/model/diff/rule_diff/rule_diff'; +import type { RuleResponse } from '../../../../../common/api/detection_engine/model/rule_schema/rule_schemas.gen'; +import { sortAndStringifyJson } from './sort_stringify_json'; + +const HIDDEN_FIELDS = ['meta', 'rule_schedule', 'version']; + +interface WholeObjectDiffProps { + oldRule: RuleResponse; + newRule: RuleResponse; + openSections: Record; + toggleSection: (sectionName: string) => void; +} + +const WholeObjectDiff = ({ + oldRule, + newRule, + openSections, + toggleSection, +}: WholeObjectDiffProps) => { + return ( + <> + { + toggleSection('whole'); + }} + > + + + + + ); +}; + +interface FieldsProps { + fields: Partial; + openSections: Record; + toggleSection: (sectionName: string) => void; +} + +const Fields = ({ fields, openSections, toggleSection }: FieldsProps) => { + const visibleFields = Object.keys(fields).filter( + (fieldName) => !HIDDEN_FIELDS.includes(fieldName) + ); + + return ( + <> + {visibleFields.map((fieldName) => { + const currentVersion: string = get(fields, [fieldName, 'current_version'], ''); + const mergedVersion: string = get(fields, [fieldName, 'merged_version'], ''); + + const oldSource = JSON.stringify(currentVersion, null, 2); + const newSource = JSON.stringify(mergedVersion, null, 2); + + return ( + <> + { + toggleSection(fieldName); + }} + > + + + + + ); + })} + + ); +}; + +interface ExpandableSectionProps { + title: string; + isOpen: boolean; + toggle: () => void; + children: React.ReactNode; +} + +const ExpandableSection = ({ title, isOpen, toggle, children }: ExpandableSectionProps) => { + const accordionId = useGeneratedHtmlId({ prefix: 'accordion' }); + + return ( + +

{title}

+ + } + initialIsOpen={true} + > + + + {children} + +
+ ); +}; + +interface RuleDiffTabProps { + oldRule: RuleResponse; + newRule: RuleResponse; + fields: Partial; +} + +export const RuleDiffTabMonaco = ({ fields, oldRule, newRule }: RuleDiffTabProps) => { + const [openSections, setOpenSections] = useState>( + Object.keys(fields).reduce((sections, fieldName) => ({ ...sections, [fieldName]: true }), {}) + ); + + const toggleSection = (sectionName: string) => { + setOpenSections((prevOpenSections) => ({ + ...prevOpenSections, + [sectionName]: !prevOpenSections[sectionName], + })); + }; + + 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..7a49547450310 --- /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,569 @@ +/* + * 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, useContext, 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 { get } from 'lodash'; +import { + EuiSpacer, + EuiAccordion, + EuiIcon, + EuiLink, + EuiTitle, + EuiFlexGroup, + EuiHorizontalRule, + useGeneratedHtmlId, + useEuiTheme, + EuiSwitch, + EuiRadioGroup, +} from '@elastic/eui'; +import type { RuleFieldsDiff } from '../../../../../common/api/detection_engine/prebuilt_rules/model/diff/rule_diff/rule_diff'; +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 './sort_stringify_json'; + +const HIDDEN_FIELDS = ['meta', 'rule_schedule', 'version']; + +const CompareMethodContext = React.createContext(DiffMethod.CHARS); + +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 ; + } + + const collapsedLines = getCollapsedLinesCountBetween(previousHunk, currentHunk); + + if (!previousHunk) { + if (!collapsedLines) { + return null; + } + + return ; + } + + 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[], compareMethod: 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. + */ + compareMethod === DiffMethod.CHARS + ? markEdits(hunks, { type: 'block' }) // Using built-in "markEdits" enhancer for char-level diffing + : markEditsBy(hunks, compareMethod), // 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; +} + +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 }: DiffViewProps) { + const compareMethod = useContext(CompareMethodContext); + + /* + "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, compareMethod, oldSource); + + return ( + + {/* eslint-disable-next-line @typescript-eslint/no-shadow */} + {(hunks) => } + + ); +} + +interface FieldsProps { + fields: Partial; + openSections: Record; + toggleSection: (sectionName: string) => void; +} + +const Fields = ({ fields, openSections, toggleSection }: FieldsProps) => { + const visibleFields = Object.keys(fields).filter( + (fieldName) => !HIDDEN_FIELDS.includes(fieldName) + ); + + return ( + <> + {visibleFields.map((fieldName) => { + const currentVersion: string = get(fields, [fieldName, 'current_version'], ''); + const mergedVersion: string = get(fields, [fieldName, 'merged_version'], ''); + + const oldSource = JSON.stringify(currentVersion, null, 2); + const newSource = JSON.stringify(mergedVersion, null, 2); + + return ( + <> + { + toggleSection(fieldName); + }} + > + + + + + ); + })} + + ); +}; + +interface ExpandableSectionProps { + title: string; + isOpen: boolean; + toggle: () => void; + children: React.ReactNode; +} + +const ExpandableSection = ({ title, isOpen, toggle, children }: ExpandableSectionProps) => { + const accordionId = useGeneratedHtmlId({ prefix: 'accordion' }); + + return ( + +

{title}

+ + } + initialIsOpen={true} + > + + + {children} + +
+ ); +}; + +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 WholeObjectDiffProps { + oldRule: RuleResponse; + newRule: RuleResponse; + openSections: Record; + toggleSection: (sectionName: string) => void; +} + +const WholeObjectDiff = ({ + oldRule, + newRule, + openSections, + toggleSection, +}: WholeObjectDiffProps) => { + const oldSource = sortAndStringifyJson(oldRule); + const newSource = sortAndStringifyJson(newRule); + + return ( + <> + { + toggleSection('whole'); + }} + > + + + + + ); +}; + +interface RuleDiffTabProps { + oldRule: RuleResponse; + newRule: RuleResponse; + fields: Partial; +} + +export const RuleDiffTabReactDiffView = ({ fields, oldRule, newRule }: RuleDiffTabProps) => { + const [openSections, setOpenSections] = useState>( + Object.keys(fields).reduce((sections, fieldName) => ({ ...sections, [fieldName]: true }), {}) + ); + + const toggleSection = (sectionName: string) => { + setOpenSections((prevOpenSections) => ({ + ...prevOpenSections, + [sectionName]: !prevOpenSections[sectionName], + })); + }; + + 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 [compareMethod, setCompareMethod] = useState(DiffMethod.CHARS); + + 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/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..5a6eaaec1b599 --- /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,291 @@ +/* + * 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 } from 'react'; +import ReactDiffViewer, { DiffMethod } from 'react-diff-viewer-continued'; +import { get } from 'lodash'; +import { + EuiSpacer, + EuiAccordion, + EuiTitle, + EuiFlexGroup, + EuiSwitch, + EuiHorizontalRule, + EuiRadioGroup, + useGeneratedHtmlId, + useEuiTheme, +} from '@elastic/eui'; +import type { RuleFieldsDiff } from '../../../../../common/api/detection_engine/prebuilt_rules/model/diff/rule_diff/rule_diff'; +import type { RuleResponse } from '../../../../../common/api/detection_engine/model/rule_schema/rule_schemas.gen'; +import { sortAndStringifyJson } from './sort_stringify_json'; + +const HIDDEN_FIELDS = ['meta', 'rule_schedule', 'version']; + +const CustomStylesContext = React.createContext({}); +const CompareMethodContext = React.createContext(DiffMethod.CHARS); + +interface FieldsProps { + fields: Partial; + openSections: Record; + toggleSection: (sectionName: string) => void; +} + +const Fields = ({ fields, openSections, toggleSection }: FieldsProps) => { + const styles = useContext(CustomStylesContext); + const compareMethod = useContext(CompareMethodContext); + + const visibleFields = Object.keys(fields).filter( + (fieldName) => !HIDDEN_FIELDS.includes(fieldName) + ); + + return ( + <> + {visibleFields.map((fieldName) => { + const currentVersion: string = get(fields, [fieldName, 'current_version'], ''); + const mergedVersion: string = get(fields, [fieldName, 'merged_version'], ''); + + const oldSource = + compareMethod === DiffMethod.JSON && typeof currentVersion === 'object' + ? currentVersion + : JSON.stringify(currentVersion, null, 2); + + const newSource = + compareMethod === DiffMethod.JSON && typeof currentVersion === 'object' + ? mergedVersion + : JSON.stringify(mergedVersion, null, 2); + + return ( + <> + { + toggleSection(fieldName); + }} + > + + + + + ); + })} + + ); +}; + +interface ExpandableSectionProps { + title: string; + isOpen: boolean; + toggle: () => void; + children: React.ReactNode; +} + +const ExpandableSection = ({ title, isOpen, toggle, children }: ExpandableSectionProps) => { + const accordionId = useGeneratedHtmlId({ prefix: 'accordion' }); + + return ( + +

{title}

+ + } + initialIsOpen={true} + > + + + {children} + +
+ ); +}; + +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; + openSections: Record; + toggleSection: (sectionName: string) => void; +} + +const WholeObjectDiff = ({ + oldRule, + newRule, + openSections, + toggleSection, +}: WholeObjectDiffProps) => { + const compareMethod = useContext(CompareMethodContext); + + const oldSource = compareMethod === DiffMethod.JSON ? oldRule : sortAndStringifyJson(oldRule); + const newSource = compareMethod === DiffMethod.JSON ? newRule : sortAndStringifyJson(newRule); + + const styles = useContext(CustomStylesContext); + + return ( + <> + { + toggleSection('whole'); + }} + > + + + + + ); +}; + +interface RuleDiffTabProps { + oldRule: RuleResponse; + newRule: RuleResponse; + fields: Partial; +} + +export const RuleDiffTabReactDiffViewerContinued = ({ + fields, + oldRule, + newRule, +}: RuleDiffTabProps) => { + const [openSections, setOpenSections] = useState>( + Object.keys(fields).reduce((sections, fieldName) => ({ ...sections, [fieldName]: true }), {}) + ); + + const toggleSection = (sectionName: string) => { + setOpenSections((prevOpenSections) => ({ + ...prevOpenSections, + [sectionName]: !prevOpenSections[sectionName], + })); + }; + + 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.CHARS); + + 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/sort_stringify_json.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/sort_stringify_json.tsx new file mode 100644 index 0000000000000..f9de80c34ea3e --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/sort_stringify_json.tsx @@ -0,0 +1,26 @@ +/* + * 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. + */ + +export const sortObject = (jsObject) => { + if (typeof jsObject !== 'object' || jsObject === null) { + return jsObject; + } + + if (Array.isArray(jsObject)) { + return jsObject.map((item) => sortObject(item)); + } + + return Object.keys(jsObject) + .sort() + .reduce((sorted, key) => { + sorted[key] = sortObject(jsObject[key]); + return sorted; + }, {}); +}; + +export const sortAndStringifyJson = (jsObject: Record): string => + JSON.stringify(sortObject(jsObject), null, 2); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/translations.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/translations.ts index 1d159bf24a392..b1d3af9321429 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/translations.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/translations.ts @@ -21,6 +21,13 @@ export const INVESTIGATION_GUIDE_TAB_LABEL = i18n.translate( } ); +export const DIFF_TAB_LABEL = i18n.translate( + 'xpack.securitySolution.detectionEngine.ruleDetails.diffTabLabel', + { + defaultMessage: 'Updates', + } +); + export const DISMISS_BUTTON_LABEL = i18n.translate( 'xpack.securitySolution.detectionEngine.ruleDetails.dismissButtonLabel', { 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..03c3c5c19e603 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,22 @@ 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 { RuleDiffTab } from '../../../../rule_management/components/rule_details/rule_diff_tab'; +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'; +// import * as ruleDetailsI18n from '../../../../rule_management/components/rule_details/translations.ts'; + export interface UpgradePrebuiltRulesTableState { /** * Rules available to be updated @@ -257,6 +268,8 @@ export const UpgradePrebuiltRulesTableContextProvider = ({ actions, ]); + // console.log('ReactDiffViewer pre', ReactDiffViewer); + return ( <> @@ -286,6 +299,93 @@ export const UpgradePrebuiltRulesTableContextProvider = ({ {i18n.UPDATE_BUTTON_LABEL} } + getRuleTabs={(rule, defaultTabs) => { + const activeRule = filteredRules.find(({ id }) => 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 dbf3facc5ec10..1326e35e58b8a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1854,6 +1854,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" @@ -8808,6 +8819,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" @@ -13463,6 +13479,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" @@ -14707,7 +14733,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== @@ -14727,11 +14758,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" @@ -14756,6 +14802,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" @@ -15232,6 +15285,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" @@ -17314,6 +17375,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" @@ -18043,6 +18109,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" @@ -18053,6 +18124,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" @@ -18091,6 +18167,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" @@ -21670,6 +21754,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" @@ -22118,6 +22207,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" @@ -22809,6 +22903,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" @@ -25149,6 +25250,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" @@ -25262,6 +25398,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" @@ -27020,6 +27166,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" @@ -29319,6 +29470,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" @@ -30328,7 +30486,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==