) {
9 | function ComponentOut(props: DiffProps & {oldSource: Source}) {
10 | const renderingHunks = useMinCollapsedLines(minLinesExclusive, props.hunks, props.oldSource);
11 | return ;
12 | }
13 |
14 | ComponentOut.displayName = wrapDisplayName(ComponentIn, 'minCollapsedLines');
15 |
16 | return ComponentOut;
17 | };
18 | }
19 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "module": "ESNext",
4 | "target": "ESNext",
5 | "lib": [
6 | "ESNext",
7 | "DOM",
8 | "DOM.Iterable"
9 | ],
10 | "moduleResolution": "node",
11 | "sourceMap": true,
12 | "declaration": false,
13 | "allowJs": false,
14 | "skipLibCheck": true,
15 | "jsx": "react-jsx",
16 | "esModuleInterop": true,
17 | "strict": true,
18 | "noFallthroughCasesInSwitch": true,
19 | "noUnusedLocals": true,
20 | "noEmit": true,
21 | "baseUrl": ".",
22 | "paths": {
23 | "react-diff-view": ["./src/index.ts"],
24 | "react-diff-view/*": ["./src/*"]
25 | },
26 | },
27 | "include": ["src", "site", "*.ts"]
28 | }
29 |
--------------------------------------------------------------------------------
/src/Hunk/index.tsx:
--------------------------------------------------------------------------------
1 | import {useDiffSettings} from '../context';
2 | import UnifiedHunk from './UnifiedHunk';
3 | import SplitHunk from './SplitHunk';
4 | import {HunkData} from '../utils';
5 |
6 | export interface HunkProps {
7 | hunk: HunkData;
8 | }
9 |
10 | function Hunk({hunk}: HunkProps) {
11 | const {gutterType, hunkClassName, ...context} = useDiffSettings();
12 | const hideGutter = gutterType === 'none';
13 | const gutterAnchor = gutterType === 'anchor';
14 | const RenderingHunk = context.viewType === 'unified' ? UnifiedHunk : SplitHunk;
15 |
16 | return (
17 |
24 | );
25 | }
26 |
27 | export default Hunk;
28 |
--------------------------------------------------------------------------------
/site/components/InputArea/DiffText.less:
--------------------------------------------------------------------------------
1 | .root {
2 | margin-bottom: 20px;
3 | }
4 |
5 | .action {
6 | display: flex;
7 | justify-content: flex-end;
8 | gap: 20px;
9 | margin-bottom: 6px;
10 | }
11 |
12 | .toggle {
13 | display: flex;
14 | align-items: center;
15 | margin: .3em 0;
16 | user-select: none;
17 |
18 | ::before {
19 | content: "";
20 | flex: 1;
21 | height: 1px;
22 | background-color: #666;
23 | margin-right: .3em;
24 | }
25 |
26 | ::after {
27 | content: "";
28 | flex: 1;
29 | height: 1px;
30 | background-color: #666;
31 | margin-left: .3em;
32 | }
33 | }
34 |
35 | .hidden {
36 | display: none;
37 | }
38 |
39 | .diff-code-mark-tab {
40 | display: inline-block;
41 | width: 8ch;
42 | background-color: #666;
43 | }
44 |
45 | .diff-code-mark-carriage-return {
46 | color: #f00;
47 | }
48 |
--------------------------------------------------------------------------------
/src/utils/diff/index.ts:
--------------------------------------------------------------------------------
1 | import {computeLineNumberFactory, findChangeByLineNumberFactory, getCorrespondingLineNumberFactory} from './factory';
2 |
3 | export {insertHunk, textLinesToHunk} from './insertHunk';
4 | export {getChangeKey} from './getChangeKey';
5 | export {expandCollapsedBlockBy, getCollapsedLinesCountBetween, expandFromRawCode} from './expandCollapsedBlockBy';
6 |
7 | export type {Source} from './expandCollapsedBlockBy';
8 |
9 | export const computeOldLineNumber = computeLineNumberFactory('old');
10 |
11 | export const computeNewLineNumber = computeLineNumberFactory('new');
12 |
13 | export const findChangeByOldLineNumber = findChangeByLineNumberFactory('old');
14 |
15 | export const findChangeByNewLineNumber = findChangeByLineNumberFactory('new');
16 |
17 | export const getCorrespondingOldLineNumber = getCorrespondingLineNumberFactory('new');
18 |
19 | export const getCorrespondingNewLineNumber = getCorrespondingLineNumberFactory('old');
20 |
--------------------------------------------------------------------------------
/src/hocs/withSourceExpansion.tsx:
--------------------------------------------------------------------------------
1 | import {ComponentType} from 'react';
2 | import {useSourceExpansion} from '../hooks';
3 | import {HunkData, Source} from '../utils';
4 | import {wrapDisplayName} from './wrapDisplayName';
5 |
6 | export default function withSourceExpansion() {
7 | return function wrap(ComponentIn: ComponentType
) {
8 | function ComponentOut(props: P & {onExpandRange: (start: number, end: number) => void}) {
9 | const [renderingHunks, expandRange] = useSourceExpansion(props.hunks, props.oldSource);
10 |
11 | return (
12 |
17 | );
18 | }
19 |
20 | ComponentOut.displayName = wrapDisplayName(ComponentIn, 'withSourceExpansion');
21 |
22 | return ComponentOut;
23 | };
24 | }
25 |
--------------------------------------------------------------------------------
/src/hooks/useChangeSelect.ts:
--------------------------------------------------------------------------------
1 | import {Hunk} from 'gitdiff-parser';
2 | import {useEffect} from 'react';
3 | import {ChangeEventArgs} from '../context';
4 | import {getChangeKey} from '../utils';
5 | import {useCollection} from './helpers';
6 |
7 | export interface UseChangeSelectOptions {
8 | multiple?: boolean;
9 | }
10 |
11 | export default function useChangeSelect(hunks: Hunk[], {multiple = false}: UseChangeSelectOptions = {}) {
12 | const {collection, clear, toggle, only} = useCollection();
13 | // eslint-disable-next-line react-hooks/exhaustive-deps
14 | useEffect(clear, [hunks]);
15 |
16 | return [
17 | collection,
18 | ({change}: ChangeEventArgs) => {
19 | if (!change) {
20 | return;
21 | }
22 |
23 | const changeKey = getChangeKey(change);
24 | if (multiple) {
25 | toggle(changeKey);
26 | }
27 | else {
28 | only(changeKey);
29 | }
30 | },
31 | ] as const;
32 | }
33 |
--------------------------------------------------------------------------------
/src/hocs/withChangeSelect.tsx:
--------------------------------------------------------------------------------
1 | import {ComponentType} from 'react';
2 | import {useChangeSelect, UseChangeSelectOptions} from '../hooks';
3 | import {ChangeEventArgs} from '../context';
4 | import {HunkData} from '../utils';
5 | import {wrapDisplayName} from './wrapDisplayName';
6 |
7 | export default function withChangeSelect(options: UseChangeSelectOptions) {
8 | return function wrap(ComponentIn: ComponentType
) {
9 | function ComponentOut(props: P & {onToggleChangeSelection: (args: ChangeEventArgs) => void}) {
10 | const [selectedChanges, toggleChangeSelection] = useChangeSelect(props.hunks, options);
11 | return (
12 |
17 | );
18 | }
19 |
20 | ComponentOut.displayName = wrapDisplayName(ComponentIn, 'withChangeSelect');
21 |
22 | return ComponentOut;
23 | };
24 | }
25 |
--------------------------------------------------------------------------------
/src/Hunk/interface.ts:
--------------------------------------------------------------------------------
1 | import {ReactNode} from 'react';
2 | import {ChangeData, HunkData} from '../utils';
3 | import {EventMap, RenderGutter, RenderToken} from '../context';
4 | import {HunkTokens} from '../tokenize';
5 |
6 | export interface SharedProps {
7 | hideGutter: boolean;
8 | gutterAnchor: boolean;
9 | monotonous: boolean;
10 | generateAnchorID: (change: ChangeData) => string | undefined;
11 | generateLineClassName: (params: {changes: ChangeData[], defaultGenerate: () => string}) => string | undefined;
12 | renderToken?: RenderToken;
13 | renderGutter: RenderGutter;
14 | }
15 |
16 | export interface ChangeSharedProps extends SharedProps {
17 | gutterClassName: string;
18 | codeClassName: string;
19 | gutterEvents: EventMap;
20 | codeEvents: EventMap;
21 | }
22 |
23 | export interface ActualHunkProps extends ChangeSharedProps {
24 | className: string;
25 | lineClassName: string;
26 | hunk: HunkData;
27 | widgets: Record;
28 | hideGutter: boolean;
29 | selectedChanges: string[];
30 | tokens?: HunkTokens | null;
31 | }
32 |
--------------------------------------------------------------------------------
/rollup.mjs:
--------------------------------------------------------------------------------
1 | import {rollup} from 'rollup';
2 | import resolve from 'rollup-plugin-node-resolve';
3 | import typescript from '@rollup/plugin-typescript';
4 | import commonjs from 'rollup-plugin-commonjs';
5 | import autoExternal from 'rollup-plugin-auto-external';
6 | import sourcemaps from 'rollup-plugin-sourcemaps';
7 | import babel from 'rollup-plugin-babel';
8 | import {terser} from 'rollup-plugin-terser';
9 |
10 | const inputOptions = {
11 | input: 'src/index.ts',
12 | plugins: [
13 | typescript(),
14 | resolve(),
15 | commonjs({include: 'node_modules/**'}),
16 | autoExternal({dependencies: false}),
17 | sourcemaps(),
18 | babel({exclude: 'node_modules/**', extensions: ['.js', '.ts', '.tsx']}),
19 | terser({mangle: false}),
20 | ],
21 | external: ['react/jsx-runtime'],
22 | };
23 |
24 | const build = async () => {
25 | const bundle = await rollup(inputOptions);
26 | bundle.write({format: 'cjs', file: 'cjs/index.js', sourcemap: true});
27 | bundle.write({format: 'es', file: 'es/index.js', sourcemap: true});
28 | };
29 |
30 | build();
31 |
--------------------------------------------------------------------------------
/src/Hunk/utils.tsx:
--------------------------------------------------------------------------------
1 | import {ReactNode} from 'react';
2 | import {Side} from '../interface';
3 | import {computeOldLineNumber, computeNewLineNumber, ChangeData} from '../utils';
4 |
5 | export function renderDefaultBy(change: ChangeData, side: Side) {
6 | return (): ReactNode => {
7 | const lineNumber = side === 'old' ? computeOldLineNumber(change) : computeNewLineNumber(change);
8 | return lineNumber === -1 ? undefined : lineNumber;
9 | };
10 | }
11 |
12 | export function wrapInAnchorBy(gutterAnchor: boolean, anchorTarget: string | null | undefined) {
13 | return (element: ReactNode): ReactNode => {
14 | if (!gutterAnchor || !element) {
15 | return element;
16 | }
17 |
18 | return {element};
19 | };
20 | }
21 |
22 | export function composeCallback(own: () => void, custom: ((e: E) => void) | undefined) {
23 | if (custom) {
24 | return (e: E) => {
25 | own();
26 | custom(e); // `custom` is already bound with `arg`
27 | };
28 | }
29 |
30 | return own;
31 | }
32 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2017 Gray Zhang
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/src/hooks/useSourceExpansion.ts:
--------------------------------------------------------------------------------
1 | import {useEffect, useMemo} from 'react';
2 | import {expandFromRawCode, HunkData, Source} from '../utils';
3 | import {useCollection} from './helpers';
4 |
5 | export default function useSourceExpansion(hunks: HunkData[], oldSource: Source | null) {
6 | const {collection: expandedRanges, clear, push} = useCollection<[number, number]>();
7 | // eslint-disable-next-line react-hooks/exhaustive-deps
8 | useEffect(clear, [hunks, oldSource]);
9 | const linesOfOldSource = useMemo(
10 | () => (Array.isArray(oldSource) ? oldSource : (oldSource || '').split('\n')),
11 | [oldSource]
12 | );
13 | const renderingHunks = useMemo(
14 | () => {
15 | if (!linesOfOldSource.length) {
16 | return hunks;
17 | }
18 |
19 | return expandedRanges.reduce(
20 | (hunks, [start, end]) => expandFromRawCode(hunks, linesOfOldSource, start, end),
21 | hunks
22 | );
23 | },
24 | [linesOfOldSource, hunks, expandedRanges]
25 | );
26 |
27 | return [
28 | renderingHunks,
29 | (start: number, end: number) => push([start, end]),
30 | ] as const;
31 | }
32 |
--------------------------------------------------------------------------------
/site/components/DiffView/Unfold.tsx:
--------------------------------------------------------------------------------
1 | import {createElement, useCallback} from 'react';
2 | import {CaretUpOutlined, CaretDownOutlined, PlusCircleOutlined} from '@ant-design/icons';
3 | import {Decoration, DecorationProps} from 'react-diff-view';
4 | import styles from './Unfold.less';
5 |
6 | const ICON_TYPE_MAPPING = {
7 | up: CaretUpOutlined,
8 | down: CaretDownOutlined,
9 | none: PlusCircleOutlined,
10 | };
11 |
12 | interface Props extends Omit {
13 | start: number;
14 | end: number;
15 | direction: 'up' | 'down' | 'none';
16 | onExpand: (start: number, end: number) => void;
17 | }
18 |
19 | export default function Unfold({start, end, direction, onExpand, ...props}: Props) {
20 | const expand = useCallback(
21 | () => onExpand(start, end),
22 | [onExpand, start, end]
23 | );
24 |
25 | const iconType = ICON_TYPE_MAPPING[direction];
26 | const lines = end - start;
27 |
28 | return (
29 |
30 |
31 | {createElement(iconType)}
32 | Expand hidden {lines} lines
33 |
34 |
35 | );
36 | }
37 |
--------------------------------------------------------------------------------
/site/components/DiffView/CommentTrigger.tsx:
--------------------------------------------------------------------------------
1 | import {useCallback} from 'react';
2 | import styled from '@emotion/styled';
3 | import {ChangeData, getChangeKey} from 'react-diff-view';
4 |
5 | const Trigger = styled.span`
6 | display: flex;
7 | justify-content: space-around;
8 | align-items: center;
9 | position: absolute;
10 | z-index: 1;
11 | top: 6px;
12 | right: 6px;
13 | width: 22px;
14 | height: 22px;
15 | font-size: 16px;
16 | font-weight: bold;
17 | background-color: var(--background-color-pure);
18 | box-shadow: 0 1px 4px rgba(27, 31, 35, .15);
19 | color: var(--diff-decoration-content-color);
20 |
21 | :hover {
22 | transition: all .2s linear;
23 | background-color: var(--background-color-secondary);
24 | color: #333;
25 | }
26 | `;
27 |
28 | interface Props {
29 | change: ChangeData;
30 | onClick: (value: string) => void;
31 | }
32 |
33 | export default function CommentTrigger({change, onClick}: Props) {
34 | const click = useCallback(
35 | () => onClick(getChangeKey(change)),
36 | [change, onClick]
37 | );
38 |
39 | return (
40 |
41 | +
42 |
43 | );
44 | }
45 |
--------------------------------------------------------------------------------
/src/Hunk/SplitHunk/SplitWidget.tsx:
--------------------------------------------------------------------------------
1 | import {ReactNode} from 'react';
2 |
3 | export interface SplitWidgetProps {
4 | hideGutter: boolean;
5 | oldElement: ReactNode | null;
6 | newElement: ReactNode | null;
7 | monotonous: boolean;
8 | }
9 |
10 | export default function SplitWidget({hideGutter, oldElement, newElement, monotonous}: SplitWidgetProps) {
11 | if (monotonous) {
12 | return (
13 |
14 | |
15 | {oldElement || newElement}
16 | |
17 |
18 | );
19 | }
20 |
21 | if (oldElement === newElement) {
22 | return (
23 |
24 | |
25 | {oldElement}
26 | |
27 |
28 | );
29 | }
30 |
31 | return (
32 |
33 | |
34 | {oldElement}
35 | |
36 |
37 | {newElement}
38 | |
39 |
40 | );
41 | }
42 |
--------------------------------------------------------------------------------
/site/components/DiffView/Tokenize.ts:
--------------------------------------------------------------------------------
1 | import {tokenize, markEdits, markWord} from 'react-diff-view/tokenize';
2 | import {compact} from 'lodash';
3 | import refractor from 'refractor';
4 |
5 | self.addEventListener(
6 | 'message',
7 | ({data: {id, payload}}) => {
8 | const {hunks, oldSource, language, editsType} = payload;
9 |
10 | const enhancers = [
11 | editsType === 'none' ? null : markEdits(hunks, {type: editsType}),
12 | markWord('\r', 'carriage-return', '␍'),
13 | markWord('\t', 'tab', '→'),
14 | ];
15 |
16 | const options = {
17 | highlight: language !== 'text',
18 | refractor: refractor,
19 | language: language,
20 | oldSource: oldSource,
21 | enhancers: compact(enhancers),
22 | };
23 |
24 | try {
25 | const tokens = tokenize(hunks, options);
26 | const payload = {
27 | success: true,
28 | tokens: tokens,
29 | };
30 | self.postMessage({id, payload});
31 | }
32 | catch (ex) {
33 | const payload = {
34 | success: false,
35 | reason: ex instanceof Error ? ex.message : `${ex}`,
36 | };
37 | self.postMessage({id, payload});
38 | }
39 | }
40 | );
41 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 |
8 | # Runtime data
9 | pids
10 | *.pid
11 | *.seed
12 | *.pid.lock
13 |
14 | # Directory for instrumented libs generated by jscoverage/JSCover
15 | lib-cov
16 |
17 | # Coverage directory used by tools like istanbul
18 | coverage
19 | /coverage
20 |
21 | # nyc test coverage
22 | .nyc_output
23 |
24 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
25 | .grunt
26 |
27 | # Bower dependency directory (https://bower.io/)
28 | bower_components
29 |
30 | # node-waf configuration
31 | .lock-wscript
32 |
33 | # Compiled binary addons (http://nodejs.org/api/addons.html)
34 | build/Release
35 |
36 | # Dependency directories
37 | node_modules/
38 | jspm_packages/
39 |
40 | # Typescript v1 declaration files
41 | typings/
42 |
43 | # Optional npm cache directory
44 | .npm
45 |
46 | # Optional eslint cache
47 | .eslintcache
48 |
49 | # Optional REPL history
50 | .node_repl_history
51 |
52 | # Output of 'npm pack'
53 | *.tgz
54 |
55 | # Yarn Integrity file
56 | .yarn-integrity
57 |
58 | # dotenv environment variables file
59 | .env
60 |
61 | # yarn
62 | .pnp.*
63 | .yarn/*
64 | !.yarn/patches
65 | !.yarn/plugins
66 | !.yarn/releases
67 | !.yarn/sdks
68 | !.yarn/versions
69 |
70 | .DS_Store
71 | /es
72 | /cjs
73 | /esm
74 | /style
75 | /dist
76 | /types
77 |
--------------------------------------------------------------------------------
/site/components/InteractiveLabel/index.tsx:
--------------------------------------------------------------------------------
1 | import {HTMLAttributes} from 'react';
2 | import styled from '@emotion/styled';
3 | import {css} from '@emotion/react';
4 |
5 | export const resetAsSpan = css`
6 | appearance: none;
7 | border: initial;
8 | background-color: initial;
9 | padding: initial;
10 | `;
11 |
12 | type StateType = 'hover' | 'active' | 'normal';
13 |
14 | const colorForState = (state: StateType, disabled: boolean | undefined) => {
15 | const colorVarName = `--link-text${state === 'normal' ? '' : '-' + state}-color`;
16 |
17 | return disabled ? 'var(--disabled-text-color)' : `var(${colorVarName})`;
18 | };
19 |
20 | interface InteractiveProps {
21 | disabled?: boolean;
22 | }
23 |
24 | const interactiveAsLink = ({disabled}: InteractiveProps) => css`
25 | color: ${colorForState('normal', disabled)};
26 | cursor: ${disabled ? 'not-allowed' : 'pointer'};
27 |
28 | &:hover {
29 | color: ${colorForState('hover', disabled)};
30 | }
31 |
32 | &:focus,
33 | &:active {
34 | color: ${colorForState('active', disabled)};
35 | }
36 | `;
37 |
38 | interface Props extends HTMLAttributes {
39 | disabled?: boolean;
40 | }
41 |
42 | const InteractiveLabel = styled.button`
43 | ${resetAsSpan};
44 | ${interactiveAsLink};
45 | display: unset;
46 | `;
47 |
48 | export default InteractiveLabel;
49 |
--------------------------------------------------------------------------------
/site/components/DiffView/Comment/index.tsx:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 | import Editor from './Editor';
3 | import Display from './Display';
4 |
5 | const Layout = styled.div`
6 | padding: 12px 8px;
7 | background-color: var(--background-color-secondary);
8 | `;
9 |
10 | interface Props {
11 | id: string;
12 | content: string;
13 | state: 'create' | 'edit' | 'display';
14 | time: Date;
15 | onSave: (id: string, content: string) => void;
16 | onEdit: (id: string) => void;
17 | onCancel: (id: string) => void;
18 | onDelete: (id: string) => void;
19 | }
20 |
21 | export default function Comment({id, content, state, time, onSave, onEdit, onCancel, onDelete}: Props) {
22 | return (
23 |
24 | {
25 | state === 'display'
26 | ?
27 | : (
28 |
36 | )
37 | }
38 |
39 | );
40 | }
41 |
--------------------------------------------------------------------------------
/site/components/DiffView/Comment/Display.tsx:
--------------------------------------------------------------------------------
1 | import {useCallback} from 'react';
2 | import styled from '@emotion/styled';
3 | import InteractiveLabel from '../../InteractiveLabel';
4 |
5 | const Text = styled.div`
6 | white-space: pre;
7 | word-break: break-all;
8 | `;
9 |
10 | const Footer = styled.footer`
11 | display: flex;
12 | align-items: center;
13 | justify-content: flex-end;;
14 | gap: 8px;
15 | margin-top: 12px;
16 | `;
17 |
18 | interface Props {
19 | commentId: string;
20 | content: string;
21 | time: Date;
22 | onEdit: (id: string) => void;
23 | onDelete: (id: string) => void;
24 | }
25 |
26 | export default function CommentDisplay({commentId, content, time, onEdit, onDelete}: Props) {
27 | const edit = useCallback(
28 | () => onEdit(commentId),
29 | [commentId, onEdit]
30 | );
31 | const remove = useCallback(
32 | () => onDelete(commentId),
33 | [commentId, onDelete]
34 | );
35 |
36 | return (
37 | <>
38 |
39 | {content}
40 |
41 |
46 | >
47 | );
48 | }
49 |
--------------------------------------------------------------------------------
/src/tokenize/markWord.ts:
--------------------------------------------------------------------------------
1 | import {flatMap} from 'lodash';
2 | import {TokenizeEnhancer, TokenPath} from './interface';
3 | import {leafOf, replace} from './utils';
4 |
5 | function markInPaths(word: string, name: string, replacement: string) {
6 | return (paths: TokenPath[]) => flatMap(
7 | paths,
8 | path => {
9 | const leaf = leafOf(path);
10 |
11 | if (!leaf.value.includes(word)) {
12 | return [path];
13 | }
14 |
15 | const segments = leaf.value.split(word);
16 |
17 | return segments.reduce(
18 | (output, text, i) => {
19 | if (i !== 0) {
20 | output.push(replace(path, {type: 'mark', markType: name, value: replacement}));
21 | }
22 |
23 | if (text) {
24 | output.push(replace(path, {...leaf, value: text}));
25 | }
26 |
27 | return output;
28 | },
29 | []
30 | );
31 | }
32 | );
33 | }
34 |
35 | export default function markWord(word: string, name: string, replacement = word): TokenizeEnhancer {
36 | const mark = markInPaths(word, name, replacement);
37 |
38 | return ([oldLinesOfPaths, newLinesOfPaths]) => [
39 | oldLinesOfPaths.map(mark),
40 | newLinesOfPaths.map(mark),
41 | ];
42 | }
43 |
--------------------------------------------------------------------------------
/src/tokenize/__test__/toTokenTrees.test.ts:
--------------------------------------------------------------------------------
1 | import {describe, test, expect} from 'vitest';
2 | import refractor from 'refractor';
3 | import {HunkData} from '../../utils';
4 | import toTokenTrees, {ToTokenTreeOptions} from '../toTokenTrees';
5 |
6 | // eslint-disable-next-line max-len
7 | const content = '
\'/>';
8 |
9 | describe('toTokenTrees', () => {
10 | test('php will renders __PHP__', () => {
11 | const options: ToTokenTreeOptions = {
12 | highlight: true,
13 | refractor: refractor,
14 | language: 'php',
15 | };
16 |
17 | const hunks: HunkData[] = [{
18 | changes: [
19 | {
20 | content,
21 | isInsert: true,
22 | lineNumber: 1,
23 | type: 'insert',
24 | },
25 | ],
26 | content: '@@ -1,10 +1,17 @@',
27 | newLines: 17,
28 | newStart: 1,
29 | oldLines: 10,
30 | oldStart: 1,
31 | }];
32 |
33 | const tokens = toTokenTrees(hunks, options);
34 | expect(tokens).toMatchSnapshot();
35 | });
36 |
37 | test('refractor highlight', () => {
38 | expect(refractor.highlight(content, 'php')).toMatchSnapshot();
39 | });
40 | });
41 |
--------------------------------------------------------------------------------
/src/Decoration/UnifiedDecoration.tsx:
--------------------------------------------------------------------------------
1 | import {Children, ReactNode} from 'react';
2 | import classNames from 'classnames';
3 | import {ActualDecorationProps} from './interface';
4 |
5 | export default function UnifiedDecoration(props: ActualDecorationProps) {
6 | const {hideGutter, className, gutterClassName, contentClassName, children} = props;
7 | const computedClassName = classNames('diff-decoration', className);
8 | const computedGutterClassName = classNames('diff-decoration-gutter', gutterClassName);
9 | const computedContentClassName = classNames('diff-decoration-content', contentClassName);
10 |
11 | // One element spans all gutter and content cells
12 | if (Children.count(children) === 1) {
13 | return (
14 |
15 |
16 | |
17 | {children}
18 | |
19 |
20 |
21 | );
22 | }
23 |
24 | const [gutter, content] = children as [ReactNode, ReactNode];
25 |
26 | return (
27 |
28 |
29 | {!hideGutter && | {gutter} | }
30 | {content} |
31 |
32 |
33 | );
34 | }
35 |
--------------------------------------------------------------------------------
/src/Decoration/SplitDecoration.tsx:
--------------------------------------------------------------------------------
1 | import {Children, ReactNode} from 'react';
2 | import classNames from 'classnames';
3 | import {ActualDecorationProps} from './interface';
4 |
5 | export default function SplitDecoration(props: ActualDecorationProps) {
6 | const {hideGutter, monotonous, className, gutterClassName, contentClassName, children} = props;
7 | const computedClassName = classNames('diff-decoration', className);
8 | const computedGutterClassName = classNames('diff-decoration-gutter', gutterClassName);
9 | const computedContentClassName = classNames('diff-decoration-content', contentClassName);
10 | const columnCount = (hideGutter ? 2 : 4) / (monotonous ? 2 : 1);
11 | const headerContentColSpan = columnCount - (hideGutter ? 0 : 1);
12 |
13 | // One element spans all gutter and content cells
14 | if (Children.count(children) === 1) {
15 | return (
16 |
17 |
18 | |
19 | {children}
20 | |
21 |
22 |
23 | );
24 | }
25 |
26 | const [gutter, content] = children as [ReactNode, ReactNode];
27 |
28 | return (
29 |
30 |
31 | {!hideGutter && | {gutter} | }
32 | {content} |
33 |
34 |
35 | );
36 | }
37 |
--------------------------------------------------------------------------------
/src/tokenize/index.ts:
--------------------------------------------------------------------------------
1 | import toTokenTrees, {ToTokenTreeOptions} from './toTokenTrees';
2 | import normalizeToLines from './normalizeToLines';
3 | import backToTree from './backToTree';
4 | import {HunkData} from '../utils';
5 | import {TokenizeEnhancer, TokenNode, TokenPath} from './interface';
6 |
7 | export {default as pickRanges} from './pickRanges';
8 | export {default as markEdits} from './markEdits';
9 | export {default as markWord} from './markWord';
10 | export type {Pair, TextNode, TokenNode, TokenPath, TokenizeEnhancer} from './interface';
11 | export type {MarkEditsOptions, MarkEditsType} from './markEdits';
12 | export type {RangeTokenNode} from './pickRanges';
13 |
14 | export type TokenizeOptions = ToTokenTreeOptions & {enhancers?: TokenizeEnhancer[]};
15 |
16 | export interface HunkTokens {
17 | old: TokenNode[][];
18 | new: TokenNode[][];
19 | }
20 |
21 | export const tokenize = (hunks: HunkData[], {enhancers = [], ...options}: TokenizeOptions = {}): HunkTokens => {
22 | const [oldTokenTree, newTokenTree] = toTokenTrees(hunks, options);
23 | const [oldLinesOfPaths, newLinesOfPaths] = [normalizeToLines(oldTokenTree), normalizeToLines(newTokenTree)];
24 |
25 | const enhance = (pair: [TokenPath[][], TokenPath[][]]) => enhancers.reduce(
26 | (input, enhance) => enhance(input),
27 | pair
28 | );
29 | const [oldEnhanced, newEnhanced] = enhance([oldLinesOfPaths, newLinesOfPaths]);
30 | const [oldTrees, newTrees] = [oldEnhanced.map(backToTree), newEnhanced.map(backToTree)];
31 | return {
32 | old: oldTrees.map(root => root.children ?? []),
33 | new: newTrees.map(root => root.children ?? []),
34 | };
35 | };
36 |
--------------------------------------------------------------------------------
/src/tokenize/normalizeToLines.ts:
--------------------------------------------------------------------------------
1 | import {TokenNode, TokenPath} from './interface';
2 | import {clone, leafOf, replace} from './utils';
3 |
4 | function treeToPathList(node: TokenNode, output: TokenPath[] = [], path: TokenPath = []): TokenPath[] {
5 | if (node.children) {
6 | const {children, ...nodeToUse} = node;
7 | path.push(nodeToUse);
8 | for (const child of children) {
9 | treeToPathList(child, output, path);
10 | }
11 | path.pop();
12 | }
13 | else {
14 | output.push(clone([...path.slice(1), node]));
15 | }
16 |
17 | return output;
18 | }
19 |
20 | function splitPathToLines(path: TokenPath) {
21 | const leaf = leafOf(path);
22 |
23 | if (!leaf.value.includes('\n')) {
24 | return [path];
25 | }
26 |
27 | const linesOfText = leaf.value.split('\n');
28 | return linesOfText.map(line => replace(path, {...leaf, value: line}));
29 | }
30 |
31 | function splitByLineBreak(paths: TokenPath[]): TokenPath[][] {
32 | return paths.reduce(
33 | (lines, path) => {
34 | const currentLine = lines[lines.length - 1];
35 | const [currentRemaining, ...nextLines] = splitPathToLines(path);
36 | return [
37 | ...lines.slice(0, -1),
38 | [...currentLine, currentRemaining],
39 | ...nextLines.map(path => [path]),
40 | ];
41 | },
42 | [[]]
43 | );
44 | }
45 |
46 | export default function normalizeToLines(tree: TokenNode): TokenPath[][] {
47 | const paths = treeToPathList(tree);
48 | const linesOfPaths = splitByLineBreak(paths);
49 | return linesOfPaths;
50 | }
51 |
--------------------------------------------------------------------------------
/src/Decoration/index.tsx:
--------------------------------------------------------------------------------
1 | import {Children, ReactNode} from 'react';
2 | import warning from 'warning';
3 | import {useDiffSettings} from '../context';
4 | import SplitDecoration from './SplitDecoration';
5 | import UnifiedDecoration from './UnifiedDecoration';
6 |
7 | export interface DecorationProps {
8 | className?: string;
9 | gutterClassName?: string;
10 | contentClassName?: string;
11 | children: ReactNode | [ReactNode, ReactNode];
12 | }
13 |
14 | export default function Decoration(props: DecorationProps) {
15 | const {
16 | className = '',
17 | gutterClassName = '',
18 | contentClassName = '',
19 | children,
20 | } = props;
21 | const {viewType, gutterType, monotonous} = useDiffSettings();
22 | const RenderingDecoration = viewType === 'split' ? SplitDecoration : UnifiedDecoration;
23 | const childrenCount = Children.count(children);
24 | const hideGutter = gutterType === 'none';
25 |
26 | warning(
27 | childrenCount <= 2,
28 | 'Decoration only accepts a maxium of 2 children'
29 | );
30 |
31 | warning(
32 | childrenCount < 2 || !hideGutter,
33 | 'Gutter element in decoration will not be rendered since hideGutter prop is set to true'
34 | );
35 |
36 |
37 | // TODO: maybe we should use union type to pass children
38 | return (
39 |
46 | {children}
47 |
48 | );
49 | }
50 |
--------------------------------------------------------------------------------
/src/hocs/withTokenizeWorker.tsx:
--------------------------------------------------------------------------------
1 | import {ComponentType} from 'react';
2 | import {TokenizePayload, TokenizeResult, TokenizeWorkerOptions, useTokenizeWorker} from '../hooks';
3 | import {HunkData, Source} from '../utils';
4 | import {wrapDisplayName} from './wrapDisplayName';
5 |
6 | export interface RequiredProps {
7 | hunks: HunkData[];
8 | oldSource: Source;
9 | language: string;
10 | }
11 |
12 | function defaultMapPayload(data: RequiredProps) {
13 | return data;
14 | }
15 |
16 | interface ToeknizeWorkerHocOptions extends TokenizeWorkerOptions {
17 | mapPayload?: (payload: RequiredProps, props: P) => any;
18 | }
19 |
20 | export default function withTokenizeWorkerwithTokenizeWorker(
21 | worker: Worker,
22 | options: ToeknizeWorkerHocOptions = {}
23 | ) {
24 | const {mapPayload = defaultMapPayload, ...hookOptions} = options;
25 |
26 | function resolveMessagePayload(props: P): T {
27 | const {hunks, oldSource, language} = props;
28 | const input = {language, oldSource, hunks};
29 | return mapPayload(input, props);
30 | }
31 |
32 | return function wrap
(ComponentIn: ComponentType
) {
33 | function ComponentOut(props: P & RequiredProps) {
34 | const payload = resolveMessagePayload(props);
35 | const tokenizationResult = useTokenizeWorker(worker, payload, hookOptions);
36 |
37 | return ;
38 | }
39 |
40 | ComponentOut.displayName = wrapDisplayName(ComponentIn, 'withTokenizeWorker');
41 |
42 | return ComponentOut;
43 | };
44 | }
45 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | export {default as Diff} from './Diff';
2 | export {default as Hunk} from './Hunk';
3 | export {default as Decoration} from './Decoration';
4 | export {
5 | computeNewLineNumber,
6 | computeOldLineNumber,
7 | expandCollapsedBlockBy,
8 | expandFromRawCode,
9 | findChangeByNewLineNumber,
10 | findChangeByOldLineNumber,
11 | getChangeKey,
12 | getCollapsedLinesCountBetween,
13 | getCorrespondingNewLineNumber,
14 | getCorrespondingOldLineNumber,
15 | insertHunk,
16 | parseDiff,
17 | textLinesToHunk,
18 | isInsert,
19 | isDelete,
20 | isNormal,
21 | } from './utils';
22 | export {markEdits, markWord, pickRanges, tokenize} from './tokenize';
23 | export {minCollapsedLines, withChangeSelect, withSourceExpansion, withTokenizeWorker} from './hocs';
24 | export {useChangeSelect, useMinCollapsedLines, useSourceExpansion, useTokenizeWorker} from './hooks';
25 | export type {DiffProps, DiffType} from './Diff';
26 | export type {HunkProps} from './Hunk';
27 | export type {DecorationProps} from './Decoration';
28 | export type {
29 | EventMap,
30 | GutterType,
31 | ViewType,
32 | RenderToken,
33 | RenderGutter,
34 | ChangeEventArgs,
35 | GutterOptions,
36 | } from './context';
37 | export type {ChangeData, FileData, HunkData, ParseOptions, Source} from './utils';
38 | export type {
39 | Pair,
40 | RangeTokenNode,
41 | TextNode,
42 | TokenNode,
43 | TokenPath,
44 | TokenizeEnhancer,
45 | TokenizeOptions,
46 | MarkEditsOptions,
47 | MarkEditsType,
48 | HunkTokens,
49 | } from './tokenize';
50 | export type {
51 | ShouldTokenize,
52 | TokenizePayload,
53 | TokenizeResult,
54 | TokenizeWorkerOptions,
55 | UseChangeSelectOptions,
56 | } from './hooks';
57 |
--------------------------------------------------------------------------------
/src/tokenize/utils.ts:
--------------------------------------------------------------------------------
1 | import {ProcessingNode, TextNode, TokenPath} from './interface';
2 |
3 | export function clone(path: TokenPath): TokenPath {
4 | return path.map(node => ({...node}));
5 | }
6 |
7 | export function replace(path: TokenPath, leaf: ProcessingNode): TokenPath {
8 | return [...clone(path.slice(0, -1)), leaf];
9 | }
10 |
11 | export function wrap(path: TokenPath, parent: ProcessingNode): TokenPath {
12 | return [parent, ...clone(path)];
13 | }
14 |
15 | function isTextNode(node: ProcessingNode): node is TextNode {
16 | return node.type === 'text';
17 | }
18 |
19 | export function leafOf(path: TokenPath): TextNode {
20 | const last = path[path.length - 1];
21 |
22 | if (isTextNode(last)) {
23 | return last;
24 | }
25 |
26 | throw new Error(`Invalid token path with leaf of type ${last.type}`);
27 | }
28 |
29 | export function split(path: TokenPath, splitStart: number, splitEnd: number, wrapSplitNode: ProcessingNode) {
30 | const parents = path.slice(0, -1);
31 | const leaf = leafOf(path);
32 | const output = [];
33 |
34 | if (splitEnd <= 0 || splitStart >= leaf?.value.length) {
35 | return [path];
36 | }
37 |
38 | const split = (start: number, end?: number) => {
39 | const value = leaf.value.slice(start, end);
40 | return [...parents, {...leaf, value}];
41 | };
42 |
43 | if (splitStart > 0) {
44 | const head = split(0, splitStart);
45 | output.push(clone(head));
46 | }
47 |
48 | const body = split(Math.max(splitStart, 0), splitEnd);
49 | output.push(wrapSplitNode ? wrap(body, wrapSplitNode) : clone(body));
50 |
51 | if (splitEnd < leaf.value.length) {
52 | const tail = split(splitEnd);
53 | output.push(clone(tail));
54 | }
55 |
56 | return output;
57 | }
58 |
--------------------------------------------------------------------------------
/site/components/DiffView/Comment/Editor.tsx:
--------------------------------------------------------------------------------
1 | import {ChangeEvent, useCallback, useState} from 'react';
2 | import styled from '@emotion/styled';
3 | import {Button, Input} from 'antd';
4 |
5 | const Layout = styled.div`
6 | display: flex;
7 | flex-direction: column;
8 | gap: 8px;
9 | `;
10 |
11 | const Footer = styled.footer`
12 | display: flex;
13 | gap: 12px;
14 | align-items: center;
15 | justify-content: flex-end;
16 | `;
17 |
18 | interface Props {
19 | commentId: string;
20 | type: 'edit' | 'create';
21 | defaultContent: string;
22 | onSave: (id: string, value: string) => void;
23 | onCancel: (id: string) => void;
24 | onDelete: (id: string) => void;
25 | }
26 |
27 | export default function CommentEditor({commentId, type, defaultContent, onSave, onCancel, onDelete}: Props) {
28 | const [value, setValue] = useState(defaultContent);
29 | const updateValue = useCallback(
30 | (e: ChangeEvent) => setValue(e.target.value),
31 | []
32 | );
33 | const save = useCallback(
34 | () => onSave(commentId, value),
35 | [commentId, value, onSave]
36 | );
37 | const cancel = useCallback(
38 | () => {
39 | if (type === 'edit') {
40 | onCancel(commentId);
41 | }
42 | else {
43 | onDelete(commentId);
44 | }
45 | },
46 | [commentId, type, onCancel, onDelete]
47 | );
48 |
49 | return (
50 |
51 |
52 |
56 |
57 | );
58 | }
59 |
--------------------------------------------------------------------------------
/src/tokenize/pickRanges.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @file 在高亮的语法节点上插入代码定义与引用的信息
3 | * @author zhanglili
4 | */
5 |
6 | import {isEmpty, groupBy} from 'lodash';
7 | import {TokenizeEnhancer, TokenPath} from './interface';
8 | import {leafOf, split} from './utils';
9 |
10 | export interface RangeTokenNode {
11 | type: string;
12 | lineNumber: number;
13 | start: number;
14 | length: number;
15 | }
16 |
17 | const splitPathToEncloseRange = (paths: TokenPath[], node: RangeTokenNode) => {
18 | const {start, length} = node;
19 | const rangeEnd = start + length;
20 | const [output] = paths.reduce<[TokenPath[], number]>(
21 | ([output, nodeStart], path) => {
22 | const leaf = leafOf(path);
23 | const nodeEnd = nodeStart + leaf.value.length;
24 |
25 | if (nodeStart > rangeEnd || nodeEnd < start) {
26 | output.push(path);
27 | }
28 | else {
29 | const segments = split(path, start - nodeStart, rangeEnd - nodeStart, node);
30 | output.push(...segments);
31 | }
32 |
33 | return [output, nodeEnd];
34 | },
35 | [[], 0]
36 | );
37 |
38 | return output;
39 | };
40 |
41 | function pickRangesFromPath(paths: TokenPath[], ranges: RangeTokenNode[]) {
42 | if (isEmpty(ranges)) {
43 | return paths;
44 | }
45 |
46 | return ranges.reduce(splitPathToEncloseRange, paths);
47 | }
48 |
49 | function process(linesOfPaths: TokenPath[][], ranges: RangeTokenNode[]) {
50 | const rangesByLine = groupBy(ranges, 'lineNumber');
51 | return linesOfPaths.map((line, i) => pickRangesFromPath(line, rangesByLine[i + 1]));
52 | }
53 |
54 | export default function pickRanges(oldRanges: RangeTokenNode[], newRanges: RangeTokenNode[]): TokenizeEnhancer {
55 | return ([oldLinesOfPaths, newLinesOfPaths]) => [
56 | process(oldLinesOfPaths, oldRanges),
57 | process(newLinesOfPaths, newRanges),
58 | ];
59 | }
60 |
--------------------------------------------------------------------------------
/src/tokenize/backToTree.ts:
--------------------------------------------------------------------------------
1 | import {last, isEqual, isEqualWith} from 'lodash';
2 | import {ProcessingNode, TokenNode, TokenPath} from './interface';
3 |
4 | function areNodesMeregable(x: TokenNode, y: TokenNode): boolean {
5 |
6 | if (x.type !== y.type) {
7 | return false;
8 | }
9 |
10 | if (x.type === 'text') {
11 | return true;
12 | }
13 |
14 | if (!x.children || !y.children) {
15 | return false;
16 | }
17 |
18 | return isEqualWith(x, y, (x, y, name) => (name === 'chlidren' || isEqual(x, y)));
19 | }
20 |
21 | function mergeNode(x: ProcessingNode, y: ProcessingNode) {
22 | if ('value' in x && 'value' in y) {
23 | return {
24 | ...x,
25 | value: `${x.value}${y.value}`,
26 | };
27 | }
28 |
29 | return x;
30 | }
31 |
32 | function attachNode(parent: TokenNode, node: TokenNode): TokenNode {
33 | if (!parent.children) {
34 | throw new Error('parent node missing children property');
35 | }
36 |
37 | const previousSibling = last(parent.children);
38 |
39 | if (previousSibling && areNodesMeregable(previousSibling, node)) {
40 | /* eslint-disable no-param-reassign */
41 | parent.children[parent.children.length - 1] = mergeNode(previousSibling, node);
42 | /* eslint-enable no-param-reassign */
43 | }
44 | else {
45 | parent.children.push(node);
46 | }
47 |
48 | const leaf = parent.children[parent.children.length - 1];
49 | return leaf;
50 | }
51 |
52 | export default function backToTree(pathList: TokenPath[]): TokenNode {
53 | const root: TokenNode = {type: 'root', children: []};
54 |
55 | for (const path of pathList) {
56 | path.reduce(
57 | (parent: TokenNode, node: ProcessingNode, i: number) => {
58 | const nodeToUse: TokenNode = i === path.length - 1 ? {...node} : {...node, children: []};
59 | return attachNode(parent, nodeToUse);
60 | },
61 | root
62 | );
63 | }
64 |
65 | return root;
66 | }
67 |
--------------------------------------------------------------------------------
/src/hooks/helpers.ts:
--------------------------------------------------------------------------------
1 | import {Reducer, useReducer, useRef} from 'react';
2 |
3 | interface ClearCommand {
4 | type: 'clear';
5 | }
6 |
7 | interface ModifyCommand {
8 | type: 'push' | 'toggle' | 'only';
9 | value: T;
10 | }
11 |
12 | type UpdateCommand = ClearCommand | ModifyCommand;
13 |
14 | function updateCollection(collection: T[], command: UpdateCommand) {
15 | switch (command.type) {
16 | case 'push':
17 | return [...collection, command.value];
18 | case 'clear':
19 | return collection.length ? [] : collection;
20 | case 'toggle':
21 | return collection.includes(command.value)
22 | ? collection.filter(item => item !== command.value)
23 | : collection.concat(command.value);
24 | case 'only':
25 | return [command.value];
26 | default:
27 | return collection;
28 | }
29 | }
30 |
31 | export function useCollection() {
32 | const [collection, dispatch] = useReducer>>(updateCollection, []);
33 |
34 | return {
35 | collection,
36 | clear() {
37 | dispatch({type: 'clear'});
38 | },
39 | push(value: T) {
40 | dispatch({value, type: 'push'});
41 | },
42 | toggle(value: T) {
43 | dispatch({value, type: 'toggle'});
44 | },
45 | only(value: T) {
46 | dispatch({value, type: 'only'});
47 | },
48 | };
49 | }
50 |
51 | // This is actually a hack around the lack of custom comparator support in `useEffect` hook.
52 | export function useCustomEqualIdentifier(value: T, equals: (x: T, y: T | undefined) => boolean) {
53 | const cache = useRef(undefined);
54 | const identifier = useRef(0);
55 | const isEqual = equals(value, cache.current);
56 |
57 | // TODO: this is not cocurrency safe
58 | if (!isEqual) {
59 | cache.current = value;
60 | identifier.current = identifier.current + 1;
61 | }
62 |
63 | return identifier.current;
64 | }
65 |
--------------------------------------------------------------------------------
/site/components/InputArea/DiffSource.tsx:
--------------------------------------------------------------------------------
1 | import {useState, useCallback} from 'react';
2 | import classNames from 'classnames';
3 | import {formatLines, diffLines} from 'unidiff';
4 | import InteractiveLabel from '../InteractiveLabel';
5 | import TextInput from './TextInput';
6 | import SubmitButton from './SubmitButton';
7 | import styles from './DiffSource.less';
8 |
9 | interface DiffData {
10 | diff: string;
11 | source: string | null;
12 | }
13 |
14 | export interface Props {
15 | className?: string;
16 | onSubmit: (data: DiffData) => void;
17 | onSwitchInputType: () => void;
18 | }
19 |
20 | export default function DiffSource({className, onSubmit, onSwitchInputType}: Props) {
21 | const [oldSource, setOldSource] = useState('');
22 | const [newSource, setNewSource] = useState('');
23 | const submit = useCallback(
24 | () => {
25 | if (!oldSource || !newSource) {
26 | return;
27 | }
28 |
29 | const diffText = formatLines(diffLines(oldSource, newSource), {context: 3});
30 | const data = {
31 | diff: diffText,
32 | source: oldSource,
33 | };
34 |
35 | onSubmit(data);
36 | },
37 | [oldSource, newSource, onSubmit]
38 | );
39 |
40 | return (
41 |
42 |
43 | I want to beautify a diff
44 |
45 |
46 |
52 |
58 |
59 |
60 |
61 | );
62 | }
63 |
--------------------------------------------------------------------------------
/site/components/App/index.tsx:
--------------------------------------------------------------------------------
1 | import {useState, useMemo} from 'react';
2 | import {uniqueId} from 'lodash';
3 | import sha from 'sha1';
4 | import {parseDiff} from 'react-diff-view';
5 | import {Provider as ConfigurationProvider} from '../../context/configuration';
6 | import DiffView from '../DiffView';
7 | import Configuration from '../Configuration';
8 | import InputArea from '../InputArea';
9 | import styles from './index.less';
10 | import './app.global.less';
11 |
12 | function fakeIndex() {
13 | return sha(uniqueId()).slice(0, 9);
14 | }
15 |
16 | function appendGitDiffHeaderIfNeeded(diffText: string) {
17 | if (diffText.startsWith('diff --git')) {
18 | return diffText;
19 | }
20 |
21 | const segments = [
22 | 'diff --git a/a b/b',
23 | `index ${fakeIndex()}..${fakeIndex()} 100644`,
24 | diffText,
25 | ];
26 | return segments.join('\n');
27 | }
28 |
29 | interface DiffData {
30 | diff: string;
31 | source: string | null;
32 | }
33 |
34 | export default function App() {
35 | const [{diff, source}, setData] = useState({diff: '', source: ''});
36 | const file = useMemo(
37 | () => {
38 | if (!diff) {
39 | return null;
40 | }
41 |
42 | const [file] = parseDiff(appendGitDiffHeaderIfNeeded(diff), {nearbySequences: 'zip'});
43 | return file;
44 | },
45 | [diff]
46 | );
47 |
48 | return (
49 |
50 |
51 |
52 | {
53 | file && (
54 | <>
55 |
56 |
62 | >
63 | )
64 | }
65 |
66 |
67 | );
68 | }
69 |
--------------------------------------------------------------------------------
/site/hooks/selection.ts:
--------------------------------------------------------------------------------
1 | import {MouseEvent, useCallback, useMemo, useState} from 'react';
2 | import {ChangeEventArgs, getChangeKey, HunkData} from 'react-diff-view';
3 |
4 | interface Selection {
5 | start: string | null;
6 | end: string | null;
7 | }
8 |
9 | interface InlineState {
10 | inSelection: boolean;
11 | keys: string[];
12 | }
13 |
14 | export const useSelection = (hunks: HunkData[]) => {
15 | const [{start, end}, setSelection] = useState({start: null, end: null});
16 | const [currentHunks, setCurrentHunks] = useState(hunks);
17 | const select = useCallback(
18 | ({change}: ChangeEventArgs, e: MouseEvent) => {
19 | if (!change) {
20 | return;
21 | }
22 |
23 | const key = getChangeKey(change);
24 | if (e.shiftKey && start) {
25 | setSelection(v => ({start: v.start, end: key}));
26 | }
27 | else {
28 | setSelection({start: key, end: key});
29 | }
30 | },
31 | [start]
32 | );
33 | const selected = useMemo(
34 | () => {
35 | if (!start || !end) {
36 | return [];
37 | }
38 |
39 | if (start === end) {
40 | return [start];
41 | }
42 |
43 | // Find all changes from start to end in all hunks
44 | const state: InlineState = {
45 | inSelection: false,
46 | keys: [],
47 | };
48 | for (const hunk of currentHunks) {
49 | for (const change of hunk.changes) {
50 | const key = getChangeKey(change);
51 | if (key === start || key === end) {
52 | state.keys.push(key);
53 | state.inSelection = !state.inSelection;
54 | }
55 | else if (state.inSelection) {
56 | state.keys.push(key);
57 | }
58 | }
59 | }
60 | return state.keys;
61 | },
62 | [currentHunks, end, start]
63 | );
64 |
65 | if (hunks !== currentHunks) {
66 | setSelection({start: null, end: null});
67 | setCurrentHunks(hunks);
68 | }
69 |
70 | return [selected, select] as const;
71 | };
72 |
--------------------------------------------------------------------------------
/src/tokenize/__test__/markWord.test.ts:
--------------------------------------------------------------------------------
1 | import {describe, test, expect} from 'vitest';
2 | import {Pair, TokenPath} from '../interface';
3 | import markWord from '../markWord';
4 |
5 | describe('markWord', () => {
6 | test('no match', () => {
7 | const mark = markWord('x', 'mark');
8 | const input: Pair = [
9 | [
10 | [[{type: 'text', value: 'abc'}]],
11 | ],
12 | [
13 | [[{type: 'text', value: 'abc'}]],
14 | ],
15 | ];
16 | const result = mark(input);
17 | expect(result).toEqual(input);
18 | });
19 |
20 | test('single occurence', () => {
21 | const mark = markWord('A', 'first', 'a');
22 | const input: Pair = [
23 | [
24 | [[{type: 'text', value: 'AAaabb'}]],
25 | ],
26 | [
27 | [[{type: 'text', value: ''}]],
28 | ],
29 | ];
30 | const result = mark(input);
31 | const expected: Pair = [
32 | [
33 | [
34 | [{type: 'mark', markType: 'first', value: 'a'}],
35 | [{type: 'mark', markType: 'first', value: 'a'}],
36 | [{type: 'text', value: 'aabb'}],
37 | ],
38 | ],
39 | [
40 | [[{type: 'text', value: ''}]],
41 | ],
42 | ];
43 | expect(result).toEqual(expected);
44 | });
45 |
46 | test('complex word', () => {
47 | const mark = markWord('\t', 'tab', ' ');
48 | const input: Pair = [
49 | [
50 | [[{type: 'text', value: '\t\t bb'}]],
51 | ],
52 | [
53 | [[{type: 'text', value: ''}]],
54 | ],
55 | ];
56 | const result = mark(input);
57 | const expected: Pair = [
58 | [
59 | [
60 | [{markType: 'tab', type: 'mark', value: ' '}],
61 | [{markType: 'tab', type: 'mark', value: ' '}],
62 | [{type: 'text', value: ' bb'}],
63 | ],
64 | ],
65 | [
66 | [[{type: 'text', value: ''}]],
67 | ],
68 | ];
69 | expect(result).toEqual(expected);
70 | });
71 | });
72 |
--------------------------------------------------------------------------------
/src/Hunk/CodeCell.tsx:
--------------------------------------------------------------------------------
1 | import {HTMLAttributes, memo} from 'react';
2 | import classNames from 'classnames';
3 | import {TokenNode} from '../tokenize';
4 | import {DefaultRenderToken, RenderToken} from '../context';
5 |
6 | const defaultRenderToken: DefaultRenderToken = ({type, value, markType, properties, className, children}, i) => {
7 | const renderWithClassName = (className: string) => (
8 |
9 | {value ? value : (children && children.map(defaultRenderToken))}
10 |
11 | );
12 |
13 |
14 | switch (type) {
15 | case 'text':
16 | return value;
17 | case 'mark':
18 | return renderWithClassName(`diff-code-mark diff-code-mark-${markType}`);
19 | case 'edit':
20 | return renderWithClassName('diff-code-edit');
21 | default: {
22 | // properties normally not exist since it is deconstructed in pickRange, remove in next major release
23 | const legacyClassName = properties && properties.className;
24 | return renderWithClassName(classNames(className || legacyClassName));
25 | }
26 | }
27 | };
28 |
29 | function isEmptyToken(tokens: TokenNode[]) {
30 | if (!Array.isArray(tokens)) {
31 | return true;
32 | }
33 |
34 | if (tokens.length > 1) {
35 | return false;
36 | }
37 |
38 | if (tokens.length === 1) {
39 | const [token] = tokens;
40 | return token.type === 'text' && !token.value;
41 | }
42 |
43 | return true;
44 | }
45 |
46 | export interface CodeCellProps extends HTMLAttributes {
47 | changeKey: string;
48 | text: string;
49 | tokens: TokenNode[] | null;
50 | renderToken: RenderToken | undefined;
51 | }
52 |
53 | function CodeCell(props: CodeCellProps) {
54 | const {changeKey, text, tokens, renderToken, ...attributes} = props;
55 | const actualRenderToken: DefaultRenderToken = renderToken
56 | ? (token, i) => renderToken(token, defaultRenderToken, i)
57 | : defaultRenderToken;
58 |
59 | return (
60 |
61 | {
62 | tokens
63 | ? (isEmptyToken(tokens) ? ' ' : tokens.map(actualRenderToken))
64 | : (text || ' ')
65 | }
66 | |
67 | );
68 | }
69 |
70 | export default memo(CodeCell);
71 |
--------------------------------------------------------------------------------
/site/components/Configuration/index.tsx:
--------------------------------------------------------------------------------
1 | import {useCallback, useState} from 'react';
2 | import {Select} from 'antd';
3 | import {SettingOutlined} from '@ant-design/icons';
4 | import {
5 | useConfiguration,
6 | useSwitchViewType,
7 | usechangeLanguage,
8 | useSwitchEditsType,
9 | useSwitchGutterVisibility,
10 | } from '../../context/configuration';
11 | import OptionsModal from './OptionsModal';
12 | import styles from './index.less';
13 |
14 | const {Option} = Select;
15 |
16 | function useBoolean(initialValue: boolean) {
17 | const [value, setValue] = useState(initialValue);
18 | const on = useCallback(() => setValue(true), []);
19 | const off = useCallback(() => setValue(false), []);
20 | return [value, on, off] as const;
21 | }
22 |
23 | export default function Configuration() {
24 | const [isModalVisible, openModal, closeModal] = useBoolean(false);
25 | const configuration = useConfiguration();
26 | const switchViewType = useSwitchViewType();
27 | const changeLanguage = usechangeLanguage();
28 | const switchEditsType = useSwitchEditsType();
29 | const switchGutterVisibility = useSwitchGutterVisibility();
30 |
31 | return (
32 |
33 |
34 |
47 |
55 |
56 | );
57 | }
58 |
--------------------------------------------------------------------------------
/src/Hunk/UnifiedHunk/index.tsx:
--------------------------------------------------------------------------------
1 | import classNames from 'classnames';
2 | import {ReactNode} from 'react';
3 | import {getChangeKey, computeOldLineNumber, computeNewLineNumber, ChangeData, isDelete} from '../../utils';
4 | import {ActualHunkProps} from '../interface';
5 | import UnifiedChange from './UnifiedChange';
6 | import UnifiedWidget from './UnifiedWidget';
7 |
8 | type ElementContext = ['change', string, ChangeData] | ['widget', string, ReactNode];
9 |
10 | function groupElements(changes: ChangeData[], widgets: Record) {
11 | return changes.reduce(
12 | (elements, change) => {
13 | const key = getChangeKey(change);
14 |
15 | elements.push(['change', key, change]);
16 |
17 | const widget = widgets[key];
18 |
19 | if (widget) {
20 | elements.push(['widget', key, widget]);
21 | }
22 |
23 | return elements;
24 | },
25 | []
26 | );
27 | }
28 |
29 | type RenderRowProps = Omit;
30 |
31 | function renderRow([type, key, value]: ElementContext, props: RenderRowProps) {
32 | const {hideGutter, selectedChanges, tokens, lineClassName, ...changeProps} = props;
33 |
34 | if (type === 'change') {
35 | const side = isDelete(value) ? 'old' : 'new';
36 | const lineNumber = isDelete(value) ? computeOldLineNumber(value) : computeNewLineNumber(value);
37 | const tokensOfLine = tokens ? tokens[side][lineNumber - 1] : null;
38 |
39 | return (
40 |
49 | );
50 | }
51 | else if (type === 'widget') {
52 | return ;
53 | }
54 |
55 | return null;
56 | }
57 |
58 | export default function UnifiedHunk(props: ActualHunkProps) {
59 | const {hunk, widgets, className, ...childrenProps} = props;
60 | const elements = groupElements(hunk.changes, widgets);
61 |
62 | return (
63 |
64 | {elements.map(element => renderRow(element, childrenProps))}
65 |
66 | );
67 | }
68 |
--------------------------------------------------------------------------------
/site/components/InputArea/DiffText.tsx:
--------------------------------------------------------------------------------
1 | import {useState, useReducer, useCallback} from 'react';
2 | import classNames from 'classnames';
3 | import {UpOutlined, DownOutlined} from '@ant-design/icons';
4 | import InteractiveLabel from '../InteractiveLabel';
5 | import TextInput from './TextInput';
6 | import SubmitButton from './SubmitButton';
7 | import styles from './DiffText.less';
8 | import preset from './preset.diff?raw';
9 | import presetSource from './preset.src?raw';
10 |
11 | function useToggle(initialValue: boolean) {
12 | return useReducer(v => !v, initialValue);
13 | }
14 |
15 | interface DiffData {
16 | diff: string;
17 | source: string | null;
18 | }
19 |
20 | interface Props {
21 | className?: string;
22 | onSwitchInputType: () => void;
23 | onSubmit: (data: DiffData) => void;
24 | }
25 |
26 |
27 | export default function DiffText({className, onSwitchInputType, onSubmit}: Props) {
28 | const [diff, setDiff] = useState('');
29 | const [source, setSource] = useState('');
30 | const [isSourceVisible, toggleSourceVisible] = useToggle(false);
31 | const submit = useCallback(
32 | () => {
33 | const data = {
34 | diff: diff,
35 | source: (isSourceVisible && source) ? source : null,
36 | };
37 |
38 | onSubmit(data);
39 | },
40 | [diff, isSourceVisible, source, onSubmit]
41 | );
42 | const loadPreset = useCallback(
43 | () => {
44 | setDiff(preset);
45 | setSource(presetSource);
46 | onSubmit({diff: preset, source: presetSource});
47 | },
48 | [onSubmit]
49 | );
50 |
51 | return (
52 |
53 |
54 | I want to compare text
55 | Use preset example
56 |
57 |
58 |
59 |
60 | {isSourceVisible ? : }
61 | {isSourceVisible ? 'Don\'t have any source code' : 'I have the old source code'}
62 |
63 |
69 |
70 |
71 |
72 | );
73 | }
74 |
--------------------------------------------------------------------------------
/site/components/Configuration/OptionsModal.tsx:
--------------------------------------------------------------------------------
1 | import {ReactNode} from 'react';
2 | import {Modal, Radio, Switch} from 'antd';
3 | import styles from './OptionsModal.less';
4 | import {MarkEditsType, ViewType} from 'react-diff-view';
5 |
6 | /* eslint-disable react/jsx-no-bind */
7 |
8 | const {Group: RadioGroup, Button: RadioButton} = Radio;
9 |
10 | interface RowProps {
11 | title: string;
12 | tooltip: string;
13 | children: ReactNode;
14 | }
15 |
16 | function Row({title, tooltip, children}: RowProps) {
17 | return (
18 |
19 |
20 |
21 | {title}
22 |
23 | {children}
24 |
25 |
26 | {tooltip}
27 |
28 |
29 | );
30 | }
31 |
32 | interface Props {
33 | visible: boolean;
34 | viewType: ViewType;
35 | editsType: MarkEditsType;
36 | showGutter: boolean;
37 | onSwitchViewType: (value: ViewType) => void;
38 | onSwitchEditsType: (value: MarkEditsType) => void;
39 | onSwitchGutterVisibility: (value: boolean) => void;
40 | onClose: () => void;
41 | }
42 |
43 | export default function OptionsModal(props: Props) {
44 | const {
45 | visible,
46 | viewType,
47 | editsType,
48 | showGutter,
49 | onSwitchViewType,
50 | onSwitchEditsType,
51 | onSwitchGutterVisibility,
52 | onClose,
53 | } = props;
54 |
55 | return (
56 |
57 |
58 | onSwitchViewType(e.target.value)}>
59 | Split
60 | Unfiied
61 |
62 |
63 |
64 | onSwitchEditsType(e.target.value)}>
65 | None
66 | Line
67 | Block
68 |
69 |
70 |
71 |
72 |
73 |
74 | );
75 | }
76 |
--------------------------------------------------------------------------------
/site/context/configuration.tsx:
--------------------------------------------------------------------------------
1 | import {createContext, useState, useContext, useMemo, useCallback, ReactNode} from 'react';
2 | import {MarkEditsType, ViewType} from 'react-diff-view';
3 |
4 | export interface AppConfiguration {
5 | viewType: ViewType;
6 | editsType: MarkEditsType;
7 | showGutter: boolean;
8 | language: string;
9 | }
10 |
11 | export interface AppConfigurationContextValue {
12 | configuration: AppConfiguration;
13 | switchViewType: (value: ViewType) => void;
14 | changeLanguage: (value: string) => void;
15 | switchEditsType: (value: MarkEditsType) => void;
16 | switchGutterVisibility: (value: boolean) => void;
17 | }
18 |
19 | const DEFAULT_VALUE: AppConfigurationContextValue = {
20 | configuration: {
21 | viewType: 'split',
22 | editsType: 'block',
23 | showGutter: true,
24 | language: 'text',
25 | },
26 | switchViewType: () => {},
27 | changeLanguage: () => {},
28 | switchEditsType: () => {},
29 | switchGutterVisibility: () => {},
30 | };
31 |
32 | const Context = createContext(DEFAULT_VALUE);
33 | Context.displayName = 'ConfigurationContext';
34 |
35 | interface Props {
36 | children: ReactNode;
37 | }
38 |
39 | export function Provider({children}: Props) {
40 | const [configuration, update] = useState(DEFAULT_VALUE.configuration);
41 | const switchViewType = useCallback(
42 | (value: ViewType) => update(configuration => ({...configuration, viewType: value})),
43 | []
44 | );
45 | const changeLanguage = useCallback(
46 | (value: string) => update(configuration => ({...configuration, language: value})),
47 | []
48 | );
49 | const switchEditsType = useCallback(
50 | (value: MarkEditsType) => update(configuration => ({...configuration, editsType: value})),
51 | []
52 | );
53 | const switchGutterVisibility = useCallback(
54 | (value: boolean) => update(configuration => ({...configuration, showGutter: value})),
55 | []
56 | );
57 | const contextValue = useMemo(
58 | () => ({configuration, switchViewType, changeLanguage, switchEditsType, switchGutterVisibility}),
59 | [configuration, switchViewType, changeLanguage, switchEditsType, switchGutterVisibility]
60 | );
61 | return (
62 |
63 | {children}
64 |
65 | );
66 | }
67 |
68 | function createContextHook(name: K) {
69 | return (): AppConfigurationContextValue[K] => {
70 | const context = useContext(Context);
71 | return context[name];
72 | };
73 | }
74 |
75 | export const useConfiguration = createContextHook('configuration');
76 | export const useSwitchViewType = createContextHook('switchViewType');
77 | export const usechangeLanguage = createContextHook('changeLanguage');
78 | export const useSwitchEditsType = createContextHook('switchEditsType');
79 | export const useSwitchGutterVisibility = createContextHook('switchGutterVisibility');
80 |
--------------------------------------------------------------------------------
/site/components/InputArea/preset.src:
--------------------------------------------------------------------------------
1 | {
2 | "name": "venue-proxy",
3 | "version": "1.0.0",
4 | "description": "proxy for appspace front-end apps",
5 | "scripts": {
6 | "prepare": "husky install",
7 | "lint": "eslint --fix --max-warnings=0 src",
8 | "dev": "LOG_LEVEL_CONSOLE=debug LOG_LEVEL_ACCESS=debug TS_NODE_FILES=true nodemon --esm src/index.ts | pino-pretty",
9 | "inspect": "LOG_LEVEL_CONSOLE=debug LOG_LEVEL_ACCESS=debug TS_NODE_FILES=true nodemon --exec node --loader ts-node/esm --inspect ./src/index.ts | pino-pretty",
10 | "build": "tsc -p tsconfig.build.json",
11 | "type-check": "tsc --noEmit",
12 | "lint-staged": "lint-staged",
13 | "test": "vitest run",
14 | "test-coverage": "vitest run --coverage",
15 | "ci": "yarn install --immutable && npm run lint && npm run type-check && npm run build && npm run test-coverage"
16 | },
17 | "engines": {
18 | "node": ">= 18"
19 | },
20 | "type": "module",
21 | "lint-staged": {
22 | "*.{ts,js}": [
23 | "prettier --write",
24 | "eslint --fix --max-warnings=0"
25 | ],
26 | "*.json": [
27 | "prettier --write"
28 | ]
29 | },
30 | "repository": {
31 | "type": "git",
32 | "url": "ssh://fuqiang05@icode.baidu.com:8235/baidu/ee-fe/venue-proxy"
33 | },
34 | "keywords": [
35 | "proxy",
36 | "fcnap"
37 | ],
38 | "author": "fuqiang05",
39 | "contibutors": [
40 | "zhanglili01",
41 | "wudengke"
42 | ],
43 | "license": "ISC",
44 | "dependencies": {
45 | "@baidu/venue-libs": "^1.3.0",
46 | "add-stream": "^1.0.0",
47 | "cookie": "^0.5.0",
48 | "execa": "^6.1.0",
49 | "http-proxy": "^1.18.1",
50 | "ioredis": "^5.2.3",
51 | "path-to-regexp": "^6.2.1",
52 | "pino": "^8.6.0",
53 | "prom-client": "14.1.0",
54 | "request-ip": "^3.3.0",
55 | "string-hash": "^1.1.3",
56 | "trumpet": "^1.7.2"
57 | },
58 | "devDependencies": {
59 | "@babel/core": "^7.19.1",
60 | "@babel/eslint-parser": "^7.19.1",
61 | "@babel/eslint-plugin": "^7.19.1",
62 | "@ecomfe/eslint-config": "^7.4.0",
63 | "@types/cookie": "^0.5.1",
64 | "@types/http-proxy": "^1.17.9",
65 | "@types/node": "^18.7.21",
66 | "@types/string-hash": "^1",
67 | "@types/ws": "^8.5.3",
68 | "@typescript-eslint/eslint-plugin": "^5.38.0",
69 | "@typescript-eslint/parser": "^5.38.0",
70 | "@vitest/coverage-c8": "^0.23.4",
71 | "c8": "^7.12.0",
72 | "eslint": "^8.24.0",
73 | "fastify": "^4.6.0",
74 | "husky": "^8.0.1",
75 | "lint-staged": "^13.0.3",
76 | "nodemon": "^2.0.20",
77 | "p-event": "^5.0.1",
78 | "pino-pretty": "^9.1.0",
79 | "prettier": "^2.7.1",
80 | "ts-node": "^10.9.1",
81 | "typescript": "^4.8.3",
82 | "vitest": "^0.23.4",
83 | "ws": "^8.9.0"
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/src/context/index.ts:
--------------------------------------------------------------------------------
1 | import {createContext, DOMAttributes, ReactNode, useContext} from 'react';
2 | import {ChangeData} from '../utils/parse';
3 | import {HunkTokens, TokenNode} from '../tokenize';
4 | import {Side} from '../interface';
5 |
6 | export type DefaultRenderToken = (token: TokenNode, index: number) => ReactNode;
7 |
8 | export type RenderToken = (token: TokenNode, renderDefault: DefaultRenderToken, index: number) => ReactNode;
9 |
10 | export interface GutterOptions {
11 | change: ChangeData;
12 | side: Side;
13 | inHoverState: boolean;
14 | renderDefault: () => ReactNode;
15 | wrapInAnchor: (element: ReactNode) => ReactNode;
16 | }
17 |
18 | export type RenderGutter = (options: GutterOptions) => ReactNode;
19 |
20 | export type ViewType = 'unified' | 'split';
21 |
22 | export type GutterType = 'default' | 'none' | 'anchor';
23 |
24 | type IsEvent = T extends `on${string}` ? T : never;
25 |
26 | export type EventKeys = IsEvent>;
27 |
28 | export type NativeEventMap = Partial<{[K in EventKeys]: DOMAttributes[K]}>;
29 |
30 | type ExtractEventHandler = Exclude;
31 |
32 | type ExtractEventType = Parameters>[0];
33 |
34 | export interface ChangeEventArgs {
35 | // TODO: use union type on next major version
36 | side?: Side;
37 | change: ChangeData | null;
38 | }
39 |
40 | type BindEvent = (args: ChangeEventArgs, event: ExtractEventType) => void;
41 |
42 | export type EventMap = Partial<{[K in EventKeys]: BindEvent}>;
43 |
44 | export interface ContextProps {
45 | hunkClassName: string;
46 | lineClassName: string;
47 | gutterClassName: string;
48 | codeClassName: string;
49 | monotonous: boolean;
50 | gutterType: GutterType;
51 | viewType: ViewType;
52 | widgets: Record;
53 | hideGutter: boolean;
54 | selectedChanges: string[];
55 | tokens?: HunkTokens | null;
56 | generateAnchorID: (change: ChangeData) => string | undefined;
57 | generateLineClassName: (params: {changes: ChangeData[], defaultGenerate: () => string}) => string | undefined;
58 | renderToken?: RenderToken;
59 | renderGutter: RenderGutter;
60 | gutterEvents: EventMap;
61 | codeEvents: EventMap;
62 | }
63 |
64 | export const DEFAULT_CONTEXT_VALUE: ContextProps = {
65 | hunkClassName: '',
66 | lineClassName: '',
67 | gutterClassName: '',
68 | codeClassName: '',
69 | monotonous: false,
70 | gutterType: 'default',
71 | viewType: 'split',
72 | widgets: {},
73 | hideGutter: false,
74 | selectedChanges: [],
75 | generateAnchorID: () => undefined,
76 | generateLineClassName: () => undefined,
77 | renderGutter: ({renderDefault, wrapInAnchor}) => wrapInAnchor(renderDefault()),
78 | codeEvents: {},
79 | gutterEvents: {},
80 | };
81 |
82 | const ContextType = createContext(DEFAULT_CONTEXT_VALUE);
83 |
84 | export const Provider = ContextType.Provider;
85 |
86 | export const useDiffSettings = () => useContext(ContextType);
87 |
--------------------------------------------------------------------------------
/site/components/DiffView/UnfoldCollapsed.tsx:
--------------------------------------------------------------------------------
1 | import {getCollapsedLinesCountBetween, HunkData} from 'react-diff-view';
2 | import Unfold from './Unfold';
3 |
4 | interface Props {
5 | previousHunk: HunkData;
6 | currentHunk?: HunkData;
7 | linesCount: number;
8 | onExpand: (start: number, end: number) => void;
9 | }
10 |
11 | export default function UnfoldCollapsed({previousHunk, currentHunk, linesCount, onExpand}: Props) {
12 | if (!currentHunk) {
13 | // eslint-disable-next-line @typescript-eslint/restrict-plus-operands
14 | const nextStart = previousHunk.oldStart + previousHunk.oldLines;
15 | const collapsedLines = linesCount - nextStart + 1;
16 |
17 | if (collapsedLines <= 0) {
18 | return null;
19 | }
20 |
21 | return (
22 | <>
23 | {
24 | collapsedLines > 10 && (
25 |
32 | )
33 | }
34 |
35 | >
36 | );
37 | }
38 |
39 | const collapsedLines = getCollapsedLinesCountBetween(previousHunk, currentHunk);
40 |
41 | if (!previousHunk) {
42 | if (!collapsedLines) {
43 | return null;
44 | }
45 |
46 | const start = Math.max(currentHunk.oldStart - 10, 1);
47 |
48 | return (
49 | <>
50 |
51 | {
52 | collapsedLines > 10 && (
53 |
59 | )
60 | }
61 | >
62 | );
63 | }
64 |
65 | // eslint-disable-next-line @typescript-eslint/restrict-plus-operands
66 | const collapsedStart = previousHunk.oldStart + previousHunk.oldLines;
67 | const collapsedEnd = currentHunk.oldStart;
68 |
69 | if (collapsedLines < 10) {
70 | return (
71 |
72 | );
73 | }
74 |
75 | return (
76 | <>
77 | {/* eslint-disable-next-line @typescript-eslint/restrict-plus-operands */}
78 |
79 |
80 |
81 | >
82 | );
83 | }
84 |
--------------------------------------------------------------------------------
/src/utils/__test__/parse.test.ts:
--------------------------------------------------------------------------------
1 | import {describe, test, expect} from 'vitest';
2 | import dedent from 'dedent';
3 | import {parseDiff} from '..';
4 |
5 | const sample = dedent`
6 | diff --git a/src/__test__/index.test.jsx b/src/__test__/index.test.jsx
7 | index 643c2f0..7883597 100644
8 | --- a/src/__test__/index.test.jsx
9 | +++ b/src/__test__/index.test.jsx
10 | @@ -21,3 +21,3 @@ describe('basic test', () => {
11 | test('App renders correctly', () => {
12 | - expect(renderer.create().toJSON()).toMatchSnapshot();
13 | + expect(renderer.create().toJSON()).toMatchSnapshot();
14 | });
15 | `;
16 |
17 | describe('parseDiff', () => {
18 | test('ensure test case', () => {
19 | expect(parseDiff(sample)).toMatchSnapshot();
20 | expect(parseDiff(`\n${sample}`)).toMatchSnapshot();
21 | });
22 |
23 | test('insert', async () => {
24 | const diff = dedent`diff --git a/src/common/utils/languages.js b/src/common/utils/languages.js
25 | index 1eadcc9..022bfd4 100644
26 | --- a/src/common/utils/languages.js
27 | +++ b/src/common/utils/languages.js
28 | @@ -155,5 +155,6 @@
29 | const genericExtension = new Set(['.tpl', '.tmp']);
30 | export const detectLanguage = filename => {
31 | + // 仅仅是为了处理特殊情况,特殊情况应该已经处理完毕
32 | if (!filename) {
33 | return 'text';
34 | }
35 | `;
36 | expect(parseDiff(diff, {nearbySequences: 'zip'})).toMatchSnapshot();
37 | });
38 |
39 | test('unidiff', () => {
40 | const diff = dedent`--- x.js 2002-02-21 23:30:39.942229878 -0800
41 | +++ x.js 2002-02-21 23:30:50.442260588 -0800
42 | @@ -155,5 +155,6 @@
43 | const genericExtension = new Set(['.tpl', '.tmp']);
44 | export const detectLanguage = filename => {
45 | + // 仅仅是为了处理特殊情况,特殊情况应该已经处理完毕
46 | if (!filename) {
47 | return 'text';
48 | }
49 | `;
50 | expect(parseDiff(diff, {nearbySequences: 'zip'})).toMatchSnapshot();
51 | });
52 |
53 | test('rename', () => {
54 | const diff = dedent`
55 | diff --git a/src/error/components/ErrorBase.jsx b/src/components/ErrorPages/ErrorBase.jsx
56 | similarity index 100%
57 | rename from src/error/components/ErrorBase.jsx
58 | rename to src/components/ErrorPages/ErrorBase.jsx
59 | `;
60 | expect(parseDiff(diff, {nearbySequences: 'zip'})).toMatchSnapshot();
61 | });
62 |
63 | test('no newline at end of file', () => {
64 | const diff = dedent`
65 | diff --git a/README.md b/README.md
66 | index 36f6985..9acdf95 100644
67 | --- a/README.md
68 | +++ b/README.md
69 | @@ -1,2 +1,2 @@
70 | iiiiiiiiiiiiiiiiiiiiii:WQiiiiiiiiiiiiejj
71 | -dsds
72 | \\ No newline at end of file
73 | +dsdsds
74 | \\ No newline at end of file
75 | `;
76 | expect(parseDiff(diff, {nearbySequences: 'zip'})).toMatchSnapshot();
77 | });
78 | });
79 |
--------------------------------------------------------------------------------
/site/components/InputArea/preset.diff:
--------------------------------------------------------------------------------
1 | --- a/package.json
2 | +++ b/package.json
3 | @@ -5,8 +5,7 @@
4 | "scripts": {
5 | "prepare": "husky install",
6 | "lint": "eslint --fix --max-warnings=0 src",
7 | - "dev": "LOG_LEVEL_CONSOLE=debug LOG_LEVEL_ACCESS=debug TS_NODE_FILES=true nodemon --esm src/index.ts | pino-pretty",
8 | - "inspect": "LOG_LEVEL_CONSOLE=debug LOG_LEVEL_ACCESS=debug TS_NODE_FILES=true nodemon --exec node --loader ts-node/esm --inspect ./src/index.ts | pino-pretty",
9 | + "dev": "node --watch --no-warnings --loader ts-node/esm --require dotenv/config src/index.ts | pino-pretty",
10 | "build": "tsc -p tsconfig.build.json",
11 | "type-check": "tsc --noEmit",
12 | "lint-staged": "lint-staged",
13 | @@ -15,7 +14,7 @@
14 | "ci": "yarn install --immutable && npm run lint && npm run type-check && npm run build && npm run test-coverage"
15 | },
16 | "engines": {
17 | - "node": ">= 18"
18 | + "node": ">= 18.11.0"
19 | },
20 | "type": "module",
21 | "lint-staged": {
22 | @@ -42,44 +41,45 @@
23 | ],
24 | "license": "ISC",
25 | "dependencies": {
26 | - "@baidu/venue-libs": "^1.3.0",
27 | + "@baidu/venue-libs": "^2.0.1",
28 | + "@otakustay/bce-sdk": "^0.12.2",
29 | "add-stream": "^1.0.0",
30 | "cookie": "^0.5.0",
31 | "execa": "^6.1.0",
32 | "http-proxy": "^1.18.1",
33 | - "ioredis": "^5.2.3",
34 | + "ioredis": "^5.2.4",
35 | "path-to-regexp": "^6.2.1",
36 | - "pino": "^8.6.0",
37 | + "pino": "^8.7.0",
38 | "prom-client": "14.1.0",
39 | "request-ip": "^3.3.0",
40 | "string-hash": "^1.1.3",
41 | "trumpet": "^1.7.2"
42 | },
43 | "devDependencies": {
44 | - "@babel/core": "^7.19.1",
45 | + "@babel/core": "^7.20.2",
46 | "@babel/eslint-parser": "^7.19.1",
47 | "@babel/eslint-plugin": "^7.19.1",
48 | "@ecomfe/eslint-config": "^7.4.0",
49 | "@types/cookie": "^0.5.1",
50 | "@types/http-proxy": "^1.17.9",
51 | - "@types/node": "^18.7.21",
52 | - "@types/string-hash": "^1",
53 | + "@types/node": "^18.11.9",
54 | + "@types/string-hash": "^1.1.1",
55 | "@types/ws": "^8.5.3",
56 | - "@typescript-eslint/eslint-plugin": "^5.38.0",
57 | - "@typescript-eslint/parser": "^5.38.0",
58 | - "@vitest/coverage-c8": "^0.23.4",
59 | + "@typescript-eslint/eslint-plugin": "^5.42.0",
60 | + "@typescript-eslint/parser": "^5.42.0",
61 | + "@vitest/coverage-c8": "^0.24.5",
62 | "c8": "^7.12.0",
63 | - "eslint": "^8.24.0",
64 | - "fastify": "^4.6.0",
65 | + "dotenv": "^16.0.3",
66 | + "eslint": "^8.27.0",
67 | + "fastify": "^4.9.2",
68 | "husky": "^8.0.1",
69 | "lint-staged": "^13.0.3",
70 | - "nodemon": "^2.0.20",
71 | "p-event": "^5.0.1",
72 | - "pino-pretty": "^9.1.0",
73 | + "pino-pretty": "^9.1.1",
74 | "prettier": "^2.7.1",
75 | "ts-node": "^10.9.1",
76 | - "typescript": "^4.8.3",
77 | - "vitest": "^0.23.4",
78 | - "ws": "^8.9.0"
79 | + "typescript": "^4.8.4",
80 | + "vitest": "^0.24.5",
81 | + "ws": "^8.11.0"
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/src/Diff/__test__/Diff.test.tsx:
--------------------------------------------------------------------------------
1 | import {describe, test, expect} from 'vitest';
2 | import dedent from 'dedent';
3 | import renderer from 'react-test-renderer';
4 | import Diff, {DiffProps} from '../index';
5 | import Decoration from '../../Decoration';
6 | import {getChangeKey, HunkData, parseDiff} from '../../utils';
7 |
8 | const sample = dedent`
9 | diff --git a/src/__test__/index.test.jsx b/src/__test__/index.test.jsx
10 | index 643c2f0..7883597 100644
11 | --- a/src/__test__/index.test.jsx
12 | +++ b/src/__test__/index.test.jsx
13 | @@ -21,3 +21,3 @@ describe('basic test', () => {
14 | test('App renders correctly', () => {
15 | - expect(renderer.create().toJSON()).toMatchSnapshot();
16 | + expect(renderer.create().toJSON()).toMatchSnapshot();
17 | });
18 | `;
19 |
20 | const [file] = parseDiff(sample);
21 |
22 | interface Props {
23 | children: DiffProps['children'];
24 | }
25 |
26 | function DiffSplit({children}: Props) {
27 | return (
28 |
29 | {children}
30 |
31 | );
32 | }
33 |
34 | function DiffUnified({children}: Props) {
35 | return (
36 |
37 | {children}
38 |
39 | );
40 | }
41 |
42 | describe('Diff', () => {
43 | const renderRawHunks = (hunks: HunkData[]) => {JSON.stringify(hunks)}
;
44 |
45 | test('renders correctly', () => {
46 | expect(renderer.create({renderRawHunks}).toJSON()).toMatchSnapshot();
47 | });
48 |
49 | test('unified Diff', () => {
50 | expect(renderer.create({renderRawHunks}).toJSON()).toMatchSnapshot();
51 | });
52 | });
53 |
54 | describe('Diff with Decoration', () => {
55 | const renderDecoration = () => xxx
;
56 |
57 | test('renders correctly', () => {
58 | expect(renderer.create({renderDecoration}).toJSON()).toMatchSnapshot();
59 | });
60 |
61 | test('unified Diff with Decoration', () => {
62 | expect(renderer.create({renderDecoration}).toJSON()).toMatchSnapshot();
63 | });
64 | });
65 |
66 | const getWidgets = (hunks: HunkData[]) => {
67 | const changes = hunks.flatMap(v => v.changes);
68 | const longLines = changes.filter(({content}) => content.length > 20);
69 | return longLines.reduce(
70 | (widgets, change) => {
71 | const changeKey = getChangeKey(change);
72 |
73 | return {
74 | ...widgets,
75 | [changeKey]: Line too long,
76 | };
77 | },
78 | {}
79 | );
80 | };
81 |
82 | describe('Diff with Widget', () => {
83 | test('split widget', () => {
84 | const result = renderer.create(
85 |
86 | );
87 | expect(result.toJSON()).toMatchSnapshot();
88 | });
89 |
90 | test('unified widget', () => {
91 | const result = renderer.create(
92 |
93 | );
94 | expect(result.toJSON()).toMatchSnapshot();
95 | });
96 | });
97 |
--------------------------------------------------------------------------------
/site/components/DiffView/diff.global.less:
--------------------------------------------------------------------------------
1 | :root {
2 | --diff-background-color: transparent;
3 | --diff-text-color: #000;
4 | --diff-selection-background-color: #b3d7ff;
5 | --diff-gutter-insert-background-color: #d6fedb;
6 | --diff-gutter-delete-background-color: #fadde0;
7 | --diff-gutter-selected-background-color: #fffce0;
8 | --diff-code-insert-background-color: #eaffee;
9 | --diff-code-delete-background-color: #fdeff0;
10 | --diff-code-insert-edit-background-color: #c0dc91;
11 | --diff-code-delete-edit-background-color: #f39ea2;
12 | --diff-code-selected-background-color: #fffce0;
13 | --diff-omit-background-color: #fafbfc;
14 | --diff-decoration-gutter-background-color: #f2f8ff;
15 | --diff-decoration-gutter-color: #999;
16 | --diff-decoration-content-background-color: #f2f8ff;
17 | --diff-decoration-content-color: #999;
18 | }
19 |
20 | // @media (prefers-color-scheme: dark) {
21 | // :root {
22 | // --diff-text-color: #fafafa;
23 | // --diff-selection-background-color: #5a5f80;
24 | // --diff-gutter-insert-background-color: #082525;
25 | // --diff-gutter-delete-background-color: #2b1523;
26 | // --diff-gutter-selected-background-color: #5a5f80;
27 | // --diff-code-insert-background-color: #082525;
28 | // --diff-code-delete-background-color: #2b1523;
29 | // --diff-code-insert-edit-background-color: #00462f;
30 | // --diff-code-delete-edit-background-color: #4e2436;
31 | // --diff-code-selected-background-color: #5a5f80;
32 | // --diff-omit-background-color: #101120;
33 | // --diff-decoration-gutter-background-color: #222;
34 | // --diff-decoration-gutter-color: #ababab;
35 | // --diff-decoration-content-background-color: #222;
36 | // --diff-decoration-content-color: #ababab;
37 | // }
38 | // }
39 |
40 | .diff {
41 | background-color: var(--diff-background-color);
42 | color: var(--diff-text-color);
43 | tab-size: 4;
44 | hyphens: none;
45 | }
46 |
47 | .diff::selection {
48 | background-color: var(--diff-selection-background-color);
49 | }
50 |
51 | .diff-decoration {
52 | line-height: 2;
53 | font-family: SFMono-Regular, Consolas, "Liberation Mono", Menlo, Courier, monospace;
54 | background-color: #f2f8ff;
55 | }
56 |
57 | .diff-decoration-content {
58 | padding-left: .5em;
59 | background-color: var(--diff-decoration-content-background-color);
60 | background-color: #f2f8ff;
61 | color: var(--diff-decoration-content-color);
62 | color: #999;
63 | }
64 |
65 | .diff-gutter {
66 | position: relative;
67 | }
68 |
69 | .diff-gutter-insert {
70 | background-color: var(--diff-gutter-insert-background-color);
71 | }
72 |
73 | .diff-gutter-delete {
74 | background-color: var(--diff-gutter-delete-background-color);
75 | }
76 |
77 | .diff-gutter-selected {
78 | background-color: var(--diff-gutter-selected-background-color);
79 | }
80 |
81 | .diff-code-insert {
82 | background-color: var(--diff-code-insert-background-color);
83 | }
84 |
85 | .diff-code-edit {
86 | color: inherit;
87 | }
88 |
89 | .diff-code-insert .diff-code-edit {
90 | background-color: var(--diff-code-insert-edit-background-color);
91 | }
92 |
93 | .diff-code-delete {
94 | background-color: var(--diff-code-delete-background-color);
95 | }
96 |
97 | .diff-code-delete .diff-code-edit {
98 | background-color: var(--diff-code-delete-edit-background-color);
99 | }
100 |
101 | .diff-code-selected {
102 | background-color: var(--diff-code-selected-background-color);
103 | }
104 |
105 | .diff-decoration-gutter {
106 | background-color: var(--diff-decoration-gutter-background-color);
107 | color: var(--diff-decoration-gutter-color);
108 | }
109 |
--------------------------------------------------------------------------------
/src/utils/parse.ts:
--------------------------------------------------------------------------------
1 | import parser, {Change, DeleteChange, File, Hunk, InsertChange, NormalChange} from 'gitdiff-parser';
2 |
3 | export function isInsert(change: Change): change is InsertChange {
4 | return change.type === 'insert';
5 | }
6 |
7 | export function isDelete(change: Change): change is DeleteChange {
8 | return change.type === 'delete';
9 | }
10 |
11 | export function isNormal(change: Change): change is NormalChange {
12 | return change.type === 'normal';
13 | }
14 |
15 | export type {File as FileData, Hunk as HunkData, Change as ChangeData};
16 |
17 | export interface ParseOptions {
18 | nearbySequences?: 'zip';
19 | }
20 |
21 | function zipChanges(changes: Change[]) {
22 | const [result] = changes.reduce<[Change[], Change | null, number]>(
23 | ([result, last, lastDeletionIndex], current, i) => {
24 | if (!last) {
25 | result.push(current);
26 | return [result, current, isDelete(current) ? i : -1];
27 | }
28 |
29 | if (isInsert(current) && lastDeletionIndex >= 0) {
30 | result.splice(lastDeletionIndex + 1, 0, current);
31 | // The new `lastDeletionIndex` may be out of range, but `splice` will fix it
32 | return [result, current, lastDeletionIndex + 2];
33 | }
34 |
35 | result.push(current);
36 |
37 | // Keep the `lastDeletionIndex` if there are lines of deletions,
38 | // otherwise update it to the new deletion line
39 | const newLastDeletionIndex = isDelete(current) ? (isDelete(last) ? lastDeletionIndex : i) : i;
40 |
41 | return [result, current, newLastDeletionIndex];
42 | },
43 | [[], null, -1]
44 | );
45 | return result;
46 | }
47 |
48 | function mapHunk(hunk: Hunk, options: ParseOptions) {
49 | const changes = options.nearbySequences === 'zip' ? zipChanges(hunk.changes) : hunk.changes;
50 |
51 | return {
52 | ...hunk,
53 | isPlain: false,
54 | changes: changes,
55 | };
56 | }
57 |
58 | function mapFile(file: File, options: ParseOptions) {
59 | const hunks = file.hunks.map(hunk => mapHunk(hunk, options));
60 |
61 | return {...file, hunks};
62 | }
63 |
64 | function normalizeDiffText(text: string) {
65 | // Git diff header:
66 | //
67 | // diff --git a/test/fixture/test/ci.go b/test/fixture/test/ci.go
68 | // index 6829b8a2..4c565f1b 100644
69 | // --- a/test/fixture/test/ci.go
70 | // +++ b/test/fixture/test/ci.go
71 | if (text.startsWith('diff --git')) {
72 | return text;
73 | }
74 |
75 | // Unidiff header:
76 | //
77 | // --- /test/fixture/test/ci.go 2002-02-21 23:30:39.942229878 -0800
78 | // +++ /test/fixture/test/ci.go 2002-02-21 23:30:50.442260588 -0800
79 | const indexOfFirstLineBreak = text.indexOf('\n');
80 | const indexOfSecondLineBreak = text.indexOf('\n', indexOfFirstLineBreak + 1);
81 | const firstLine = text.slice(0, indexOfFirstLineBreak);
82 | const secondLine = text.slice(indexOfFirstLineBreak + 1, indexOfSecondLineBreak);
83 | const oldPath = firstLine.split(' ').slice(1, -3).join(' ');
84 | const newPath = secondLine.split(' ').slice(1, -3).join(' ');
85 | const segments = [
86 | `diff --git a/${oldPath} b/${newPath}`,
87 | 'index 1111111..2222222 100644',
88 | `--- a/${oldPath}`,
89 | `+++ b/${newPath}`,
90 | text.slice(indexOfSecondLineBreak + 1),
91 | ];
92 |
93 | return segments.join('\n');
94 | }
95 |
96 | export function parseDiff(text: string, options: ParseOptions = {}): File[] {
97 | const diffText = normalizeDiffText(text.trimStart());
98 | const files = parser.parse(diffText);
99 |
100 | return files.map(file => mapFile(file, options));
101 | }
102 |
--------------------------------------------------------------------------------
/src/styles/index.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --diff-background-color: initial;
3 | --diff-text-color: initial;
4 | --diff-font-family: Consolas, Courier, monospace;
5 | --diff-selection-background-color: #b3d7ff;
6 | --diff-selection-text-color: var(--diff-text-color);;
7 | --diff-gutter-insert-background-color: #d6fedb;
8 | --diff-gutter-insert-text-color: var(--diff-text-color);
9 | --diff-gutter-delete-background-color: #fadde0;
10 | --diff-gutter-delete-text-color: var(--diff-text-color);
11 | --diff-gutter-selected-background-color: #fffce0;
12 | --diff-gutter-selected-text-color: var(--diff-text-color);
13 | --diff-code-insert-background-color: #eaffee;
14 | --diff-code-insert-text-color: var(--diff-text-color);
15 | --diff-code-delete-background-color: #fdeff0;
16 | --diff-code-delete-text-color: var(--diff-text-color);
17 | --diff-code-insert-edit-background-color: #c0dc91;
18 | --diff-code-insert-edit-text-color: var(--diff-text-color);
19 | --diff-code-delete-edit-background-color: #f39ea2;
20 | --diff-code-delete-edit-text-color: var(--diff-text-color);
21 | --diff-code-selected-background-color: #fffce0;
22 | --diff-code-selected-text-color: var(--diff-text-color);
23 | --diff-omit-gutter-line-color: #cb2a1d;
24 | }
25 |
26 | .diff {
27 | background-color: var(--diff-background-color);
28 | color: var(--diff-text-color);
29 | table-layout: fixed;
30 | border-collapse: collapse;
31 | width: 100%;
32 | }
33 |
34 | .diff::selection {
35 | background-color: var(--diff-selection-background-color);
36 | color: var(--diff-selection-text-color);
37 | }
38 |
39 | .diff td {
40 | vertical-align: top;
41 | padding-top: 0;
42 | padding-bottom: 0;
43 | }
44 |
45 | .diff-line {
46 | line-height: 1.5;
47 | font-family: var(--diff-font-family);
48 | }
49 |
50 | .diff-gutter > a {
51 | color: inherit;
52 | display: block;
53 | }
54 |
55 | .diff-gutter {
56 | padding: 0 1ch;
57 | text-align: right;
58 | cursor: pointer;
59 | user-select: none;
60 | }
61 |
62 | .diff-gutter-insert {
63 | background-color: var(--diff-gutter-insert-background-color);
64 | color: var(--diff-gutter-insert-text-color);
65 | }
66 |
67 | .diff-gutter-delete {
68 | background-color: var(--diff-gutter-delete-background-color);
69 | color: var(--diff-gutter-delete-text-color);
70 | }
71 |
72 | .diff-gutter-omit {
73 | cursor: default;
74 | }
75 |
76 | .diff-gutter-selected {
77 | background-color: var(--diff-gutter-selected-background-color);
78 | color: var(--diff-gutter-selected-text-color);
79 | }
80 |
81 | .diff-code {
82 | white-space: pre-wrap;
83 | word-wrap: break-word;
84 | word-break: break-all;
85 | padding: 0 0 0 0.5em;
86 | }
87 |
88 | .diff-code-edit {
89 | color: inherit;
90 | }
91 |
92 | .diff-code-insert {
93 | background-color: var(--diff-code-insert-background-color);
94 | color: var(--diff-code-insert-text-color);
95 | }
96 |
97 | .diff-code-insert .diff-code-edit {
98 | background-color: var(--diff-code-insert-edit-background-color);
99 | color: var(--diff-code-insert-edit-text-color);
100 | }
101 |
102 | .diff-code-delete {
103 | background-color: var(--diff-code-delete-background-color);
104 | color: var(--diff-code-delete-text-color);
105 | }
106 |
107 | .diff-code-delete .diff-code-edit {
108 | background-color: var(--diff-code-delete-edit-background-color);
109 | color: var(--diff-code-delete-edit-text-color);
110 | }
111 |
112 | .diff-code-selected {
113 | background-color: var(--diff-code-selected-background-color);
114 | color: var(--diff-code-selected-text-color);
115 | }
116 |
117 | .diff-widget-content {
118 | vertical-align: top;
119 | }
120 |
121 | .diff-gutter-col {
122 | width: 7ch;
123 | }
124 |
125 | .diff-gutter-omit {
126 | height: 0;
127 | }
128 |
129 | .diff-gutter-omit:before {
130 | content: ' ';
131 | display: block;
132 | white-space: pre;
133 | width: 2px;
134 | height: 100%;
135 | margin-left: 4.6ch;
136 | overflow: hidden;
137 | background-color: var(--diff-omit-gutter-line-color);
138 | }
139 |
140 | .diff-decoration {
141 | line-height: 1.5;
142 | user-select: none;
143 | }
144 |
145 | .diff-decoration-content {
146 | font-family: var(--diff-font-family);
147 | padding: 0;
148 | }
149 |
150 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-diff-view",
3 | "version": "3.3.2",
4 | "description": "A git diff component to consume the git unified diff output.",
5 | "main": "./cjs/index.js",
6 | "module": "./es/index.js",
7 | "types": "./types/index.d.ts",
8 | "publishConfig": {
9 | "registry": "https://registry.npmjs.com"
10 | },
11 | "sideEffects": [
12 | "*.css",
13 | "*.less"
14 | ],
15 | "scripts": {
16 | "prepare": "husky install",
17 | "test": "vitest run",
18 | "start": "skr dev --src-dir=site",
19 | "clean": "rm -rf cjs es style types",
20 | "build": "sh scripts/build.sh",
21 | "build-pages": "skr build --clean --src-dir=site --build-target=stable",
22 | "prepublishOnly": "npm run build",
23 | "lint": "eslint src site",
24 | "ci": "yarn install --immutable && npm run lint && npm run build-pages && npm run build && npm run test",
25 | "prerelease": "npm run ci",
26 | "release": "standard-version"
27 | },
28 | "author": "otakustay",
29 | "license": "MIT",
30 | "dependencies": {
31 | "classnames": "^2.3.2",
32 | "diff-match-patch": "^1.0.5",
33 | "gitdiff-parser": "^0.3.1",
34 | "lodash": "^4.17.21",
35 | "shallow-equal": "^3.1.0",
36 | "warning": "^4.0.3"
37 | },
38 | "devDependencies": {
39 | "@ant-design/icons": "^5.2.6",
40 | "@babel/core": "^7.23.3",
41 | "@babel/eslint-parser": "^7.23.3",
42 | "@babel/eslint-plugin": "^7.22.10",
43 | "@babel/plugin-proposal-class-properties": "^7.18.6",
44 | "@babel/preset-env": "^7.23.3",
45 | "@babel/preset-react": "^7.23.3",
46 | "@ecomfe/eslint-config": "^8.0.0",
47 | "@emotion/react": "^11.11.1",
48 | "@emotion/styled": "^11.11.0",
49 | "@reskript/cli": "6.0.3",
50 | "@reskript/cli-build": "6.0.3",
51 | "@reskript/cli-dev": "6.0.3",
52 | "@reskript/settings": "6.0.3",
53 | "@rollup/plugin-typescript": "^11.1.5",
54 | "@types/dedent": "^0.7.2",
55 | "@types/diff-match-patch": "^1.0.36",
56 | "@types/lodash": "^4.14.201",
57 | "@types/react": "^18.2.37",
58 | "@types/react-dom": "^18.2.15",
59 | "@types/react-test-renderer": "^18.0.6",
60 | "@types/refractor": "^2.8.0",
61 | "@types/sha1": "^1.1.5",
62 | "@types/warning": "^3.0.3",
63 | "@typescript-eslint/eslint-plugin": "^6.10.0",
64 | "@typescript-eslint/parser": "^6.10.0",
65 | "antd": "^5.11.1",
66 | "autoprefixer": "^10.4.16",
67 | "babel-plugin-add-react-displayname": "0.0.5",
68 | "babel-plugin-lodash": "^3.3.4",
69 | "core-js": "^3.33.2",
70 | "cssnano": "^6.0.1",
71 | "dedent": "^1.5.1",
72 | "eslint": "^8.53.0",
73 | "eslint-plugin-react": "^7.33.2",
74 | "eslint-plugin-react-hooks": "^4.6.0",
75 | "husky": "^8.0.3",
76 | "nanoid": "^5.0.3",
77 | "postcss": "^8.4.31",
78 | "postcss-cli": "^10.1.0",
79 | "postcss-custom-properties": "^13.3.2",
80 | "prism-color-variables": "^1.0.1",
81 | "react": "^18.2.0",
82 | "react-dom": "^18.2.0",
83 | "react-infinite-scroller": "^1.2.6",
84 | "react-test-renderer": "^18.2.0",
85 | "react-timeago": "^7.2.0",
86 | "refractor": "^2.10.1",
87 | "rollup": "^4.4.0",
88 | "rollup-plugin-auto-external": "^2.0.0",
89 | "rollup-plugin-babel": "^4.4.0",
90 | "rollup-plugin-commonjs": "^10.1.0",
91 | "rollup-plugin-node-resolve": "^5.2.0",
92 | "rollup-plugin-sourcemaps": "^0.6.3",
93 | "rollup-plugin-terser": "^7.0.2",
94 | "sha1": "^1.1.1",
95 | "standard-version": "^9.5.0",
96 | "typescript": "^5.2.2",
97 | "unidiff": "^1.0.4",
98 | "vitest": "^0.34.6",
99 | "webpack": "^5.89.0"
100 | },
101 | "peerDependencies": {
102 | "react": ">=16.14.0"
103 | },
104 | "repository": {
105 | "type": "git",
106 | "url": "git+https://github.com/otakustay/react-diff-view.git"
107 | },
108 | "keywords": [
109 | "git",
110 | "github",
111 | "diff",
112 | "git-diff",
113 | "react",
114 | "component"
115 | ],
116 | "bugs": {
117 | "url": "https://github.com/otakustay/react-diff-view/issues"
118 | },
119 | "homepage": "https://github.com/otakustay/react-diff-view#readme",
120 | "files": [
121 | "es",
122 | "cjs",
123 | "style",
124 | "types",
125 | "esm",
126 | "src"
127 | ],
128 | "packageManager": "yarn@4.1.0+sha256.81a00df816059803e6b5148acf03ce313cad36b7f6e5af6efa040a15981a6ffb"
129 | }
130 |
--------------------------------------------------------------------------------
/src/hooks/useTokenizeWorker.ts:
--------------------------------------------------------------------------------
1 | import {useState, useRef, useEffect} from 'react';
2 | import {shallowEqualArrays, shallowEqualObjects} from 'shallow-equal';
3 | import {flatMap} from 'lodash';
4 | import {useCustomEqualIdentifier} from './helpers';
5 | import {HunkData, isNormal} from '../utils';
6 | import {HunkTokens} from '../tokenize';
7 |
8 | const uid = (() => {
9 | let current = 0;
10 |
11 | return () => {
12 | current = current + 1;
13 | return current;
14 | };
15 | })();
16 |
17 | function findAbnormalChanges(hunks: HunkData[]) {
18 | return flatMap(hunks, hunk => hunk.changes.filter(change => !isNormal(change)));
19 | }
20 |
21 | function areHunksEqual(xHunks: HunkData[], yHunks: HunkData[]) {
22 | const xChanges = findAbnormalChanges(xHunks);
23 | const yChanges = findAbnormalChanges(yHunks);
24 |
25 | return shallowEqualArrays(xChanges, yChanges);
26 | }
27 |
28 | export interface TokenizePayload {
29 | hunks: HunkData[];
30 | oldSource: string | null;
31 | }
32 |
33 | export type ShouldTokenize = (current: P, prev: P | undefined) => boolean;
34 |
35 | function defaultShouldTokenize
(current: P, prev: P | undefined) {
36 | if (!prev) {
37 | return true;
38 | }
39 |
40 | const {hunks: currentHunks, ...currentPayload} = current;
41 | const {hunks: prevHunks, ...prevPayload} = prev;
42 | if (currentPayload.oldSource !== prevPayload.oldSource) {
43 | return true;
44 | }
45 |
46 | // When `oldSource` is provided, we can get the new source by applying diff,
47 | // so when hunks keep identical, the tokenize result will always remain the same.
48 | if (currentPayload.oldSource) {
49 | return !shallowEqualObjects(currentPayload, prevPayload) || !areHunksEqual(currentHunks, prevHunks);
50 | }
51 |
52 | return currentHunks !== prevHunks || !shallowEqualObjects(currentPayload, prevPayload);
53 | }
54 |
55 | export interface TokenizeWorkerOptions
{
56 | shouldTokenize?: ShouldTokenize
;
57 | }
58 |
59 | interface WorkerResultSuccess {
60 | success: true;
61 | id: string;
62 | tokens: HunkTokens;
63 | }
64 |
65 | interface WorkerResultFail {
66 | success: false;
67 | reason: string;
68 | }
69 |
70 | interface WorkerMessageData {
71 | id: number;
72 | payload: WorkerResultSuccess | WorkerResultFail;
73 | }
74 |
75 | export interface TokenizeResult {
76 | tokens: HunkTokens | null;
77 | tokenizationFailReason: string | null;
78 | }
79 |
80 | export default function useTokenizeWorker
(
81 | worker: Worker,
82 | payload: P,
83 | options: TokenizeWorkerOptions
= {}
84 | ) {
85 | const {shouldTokenize = defaultShouldTokenize} = options;
86 | const payloadIdentifier = useCustomEqualIdentifier(
87 | payload,
88 | (current, previous) => !shouldTokenize(current, previous)
89 | );
90 | const [tokenizeResult, setTokenizeResult] = useState({tokens: null, tokenizationFailReason: null});
91 | const job = useRef(null);
92 | useEffect(
93 | () => {
94 | const receiveTokens = ({data: {payload, id}}: MessageEvent) => {
95 | if (id !== job.current) {
96 | return;
97 | }
98 |
99 | if (payload.success) {
100 | setTokenizeResult({tokens: payload.tokens, tokenizationFailReason: null});
101 | }
102 | else {
103 | setTokenizeResult({tokens: null, tokenizationFailReason: payload.reason});
104 | }
105 | };
106 | worker.addEventListener('message', receiveTokens);
107 | return () => worker.removeEventListener('message', receiveTokens);
108 | },
109 | [worker] // We don't really expect the worker to be changed in an application's lifecycle
110 | );
111 | useEffect(
112 | () => {
113 | job.current = uid();
114 | const data = {
115 | payload,
116 | id: job.current,
117 | type: 'tokenize',
118 | };
119 | worker.postMessage(data);
120 | },
121 | // eslint-disable-next-line react-hooks/exhaustive-deps
122 | [payloadIdentifier, worker, shouldTokenize] // TODO: How about worker changes when payload keeps identical?
123 | );
124 |
125 | return tokenizeResult;
126 | }
127 |
--------------------------------------------------------------------------------
/src/Hunk/SplitHunk/index.tsx:
--------------------------------------------------------------------------------
1 | import classNames from 'classnames';
2 | import {ReactNode} from 'react';
3 | import {
4 | getChangeKey,
5 | computeOldLineNumber,
6 | computeNewLineNumber,
7 | ChangeData,
8 | isInsert,
9 | isDelete,
10 | isNormal,
11 | } from '../../utils';
12 | import {ActualHunkProps} from '../interface';
13 | import SplitChange from './SplitChange';
14 | import SplitWidget from './SplitWidget';
15 |
16 | type ChangeContext = ['change', string, ChangeData | null, ChangeData | null];
17 |
18 | type WidgetContext = ['widget', string, ReactNode | null, ReactNode | null];
19 |
20 | type ElementContext = ChangeContext | WidgetContext;
21 |
22 | function keyForPair(x: ChangeData | null, y: ChangeData | null) {
23 | const keyForX = x ? getChangeKey(x) : '00';
24 | const keyForY = y ? getChangeKey(y) : '00';
25 | return keyForX + keyForY;
26 | }
27 |
28 | function groupElements(changes: ChangeData[], widgets: Record) {
29 | const findWidget = (change: ChangeData | null) => {
30 | if (!change) {
31 | return null;
32 | }
33 |
34 | const key = getChangeKey(change);
35 | return widgets[key] || null;
36 | };
37 | const elements: ElementContext[] = [];
38 |
39 | // This could be a very complex reduce call, use `for` loop seems to make it a little more readable
40 | for (let i = 0; i < changes.length; i++) {
41 | const current = changes[i];
42 |
43 | // A normal change is displayed on both side
44 | if (isNormal(current)) {
45 | elements.push(['change', keyForPair(current, current), current, current]);
46 | }
47 | else if (isDelete(current)) {
48 | const next = changes[i + 1];
49 | // If an insert change is following a elete change, they should be displayed side by side
50 | if (next && isInsert(next)) {
51 | i = i + 1;
52 | elements.push(['change', keyForPair(current, next), current, next]);
53 | }
54 | else {
55 | elements.push(['change', keyForPair(current, null), current, null]);
56 | }
57 | }
58 | else {
59 | elements.push(['change', keyForPair(null, current), null, current]);
60 | }
61 |
62 | const rowChanges = elements[elements.length - 1] as ChangeContext;
63 | const oldWidget = findWidget(rowChanges[2]);
64 | const newWidget = findWidget(rowChanges[3]);
65 | if (oldWidget || newWidget) {
66 | const key = rowChanges[1];
67 | elements.push(['widget', key, oldWidget, newWidget]);
68 | }
69 | }
70 |
71 | return elements;
72 | }
73 |
74 |
75 | type RenderRowProps = Omit;
76 |
77 | function renderRow([type, key, oldValue, newValue]: ElementContext, props: RenderRowProps) {
78 | const {
79 | selectedChanges,
80 | monotonous,
81 | hideGutter,
82 | tokens,
83 | lineClassName,
84 | ...changeProps
85 | } = props;
86 |
87 | if (type === 'change') {
88 | const oldSelected = oldValue ? selectedChanges.includes(getChangeKey(oldValue)) : false;
89 | const newSelected = newValue ? selectedChanges.includes(getChangeKey(newValue)) : false;
90 | const oldTokens = (oldValue && tokens) ? tokens.old[computeOldLineNumber(oldValue) - 1] : null;
91 | const newTokens = (newValue && tokens) ? tokens.new[computeNewLineNumber(newValue) - 1] : null;
92 |
93 | return (
94 |
107 | );
108 | }
109 | else if (type === 'widget') {
110 | return (
111 |
118 | );
119 | }
120 |
121 | return null;
122 | }
123 |
124 | export default function SplitHunk(props: ActualHunkProps) {
125 | const {hunk, widgets, className, ...childrenProps} = props;
126 | const elements = groupElements(hunk.changes, widgets);
127 |
128 | return (
129 |
130 | {elements.map(item => renderRow(item, childrenProps))}
131 |
132 | );
133 | }
134 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
4 |
5 | ### [3.3.2](https://github.com/otakustay/react-diff-view/compare/v3.3.0...v3.3.2) (2025-07-24)
6 |
7 |
8 | ### Bug Fixes
9 |
10 | * empty file highlighting error ([#225](https://github.com/otakustay/react-diff-view/issues/225)) ([42e633f](https://github.com/otakustay/react-diff-view/commit/42e633f424ebad22d9fd3645590846dba78a8165))
11 | * publish original source code to fix sourcemap ([34024aa](https://github.com/otakustay/react-diff-view/commit/34024aa46537fabebdaa74847ffcaeb9be6c4a78))
12 |
13 | ### [3.3.1](https://github.com/otakustay/react-diff-view/compare/v3.3.0...v3.3.1) (2024-12-11)
14 |
15 |
16 | ### Bug Fixes
17 |
18 | * empty file highlighting error ([#225](https://github.com/otakustay/react-diff-view/issues/225)) ([42e633f](https://github.com/otakustay/react-diff-view/commit/42e633f424ebad22d9fd3645590846dba78a8165))
19 |
20 | ## [3.3.0](https://github.com/otakustay/react-diff-view/compare/v3.2.1...v3.3.0) (2024-11-21)
21 |
22 |
23 | ### Features
24 |
25 | * support custom line className with generateLineClassName ([3d1e35a](https://github.com/otakustay/react-diff-view/commit/3d1e35a694f442c5978400946cba957520b57d3f))
26 |
27 | ### [3.2.1](https://github.com/otakustay/react-diff-view/compare/v3.2.0...v3.2.1) (2024-02-18)
28 |
29 |
30 | ### Bug Fixes
31 |
32 | * not showing empty context line below diff ([b531e13](https://github.com/otakustay/react-diff-view/commit/b531e13f9fb9df2480d73e68fca7c01821a186fc))
33 |
34 | ## [3.2.0](https://github.com/otakustay/react-diff-view/compare/v3.1.0...v3.2.0) (2023-11-13)
35 |
36 |
37 | ### Features
38 |
39 | * add data-change-key attribute to chnage elements ([#185](https://github.com/otakustay/react-diff-view/issues/185)) ([3b9440c](https://github.com/otakustay/react-diff-view/commit/3b9440c5e4492490fc760df98a55abaf5f26bc1a))
40 |
41 |
42 | ### Bug Fixes
43 |
44 | * render whitespace on empty line tokenized ([#210](https://github.com/otakustay/react-diff-view/issues/210)) ([02a847c](https://github.com/otakustay/react-diff-view/commit/02a847c3a022ead324bce342c6d4778b1545b32c))
45 |
46 | ## [3.1.0](https://github.com/otakustay/react-diff-view/compare/v3.0.3...v3.1.0) (2023-08-31)
47 |
48 |
49 | ### Features
50 |
51 | * publish a unminified esm format ([#196](https://github.com/otakustay/react-diff-view/issues/196)) ([a6b6acf](https://github.com/otakustay/react-diff-view/commit/a6b6acfaa3b4df2fe53adbee4e5b928e7063f7f6))
52 |
53 |
54 | ### Bug Fixes
55 |
56 | * expose type dependencies ([#198](https://github.com/otakustay/react-diff-view/issues/198)) ([9fc7adc](https://github.com/otakustay/react-diff-view/commit/9fc7adcd74423cebdc8fc00709322dad76c7320b))
57 | * minimum react version should be 16.14 ([52b83f1](https://github.com/otakustay/react-diff-view/commit/52b83f15a098aee9192368e51759cad4cf96d441))
58 | * remove inline-block style of markEdit area ([2a030a8](https://github.com/otakustay/react-diff-view/commit/2a030a84261e161c9af35b7f646ecadb02d1ffd7))
59 | * **ts:** small typo in types ([158dc93](https://github.com/otakustay/react-diff-view/commit/158dc93afdf79dcdefbd5938f00f62ac70e2a9cf))
60 |
61 | ### [3.0.3](https://github.com/otakustay/react-diff-view/compare/v3.0.2...v3.0.3) (2023-03-14)
62 |
63 |
64 | ### Bug Fixes
65 |
66 | * type of withTokenizeWorker ([#193](https://github.com/otakustay/react-diff-view/issues/193)) ([1f3921d](https://github.com/otakustay/react-diff-view/commit/1f3921d63efcd9f23c669a2005a63b5a809fd24d))
67 | * upgrade parser to handle binary delta diff ([#192](https://github.com/otakustay/react-diff-view/issues/192)) ([5f3264f](https://github.com/otakustay/react-diff-view/commit/5f3264fb82c8d128b9dde41728870cd4b17096da))
68 |
69 | ### [3.0.2](https://github.com/otakustay/react-diff-view/compare/v3.0.1...v3.0.2) (2023-02-24)
70 |
71 |
72 | ### Bug Fixes
73 |
74 | * export more usefule types ([3685f87](https://github.com/otakustay/react-diff-view/commit/3685f877762de0f7a78112970f0c580aef92aefb))
75 |
76 | ### [3.0.1](https://github.com/otakustay/react-diff-view/compare/v3.0.0...v3.0.1) (2023-02-24)
77 |
78 |
79 | ### Bug Fixes
80 |
81 | * export types used in props ([62aabb6](https://github.com/otakustay/react-diff-view/commit/62aabb6aff7788f395523eaca38a4752cb07ed71))
82 |
83 | ## [3.0.0](https://github.com/otakustay/react-diff-view/compare/v2.6.0...v3.0.0) (2023-02-21)
84 |
85 |
86 | ### ⚠ BREAKING CHANGES
87 |
88 | * types may not be exactly what you expected
89 | * some code introduces modern grammar like optional chain and template string, not sure the are all transformed by babel
90 |
91 | ### Features
92 |
93 | * add TypeScript support ([#189](https://github.com/otakustay/react-diff-view/issues/189)) ([95f634b](https://github.com/otakustay/react-diff-view/commit/95f634b56926c3da540960d7dbc9be29214bf7e6))
94 | * export util functions to check change type ([30cd5cf](https://github.com/otakustay/react-diff-view/commit/30cd5cfa177897ee2ff328797873ab31347d6120))
95 |
--------------------------------------------------------------------------------
/src/tokenize/toTokenTrees.ts:
--------------------------------------------------------------------------------
1 | import {flatMap, keyBy} from 'lodash';
2 | import type {AST, RefractorNode, highlight} from 'refractor';
3 | import {Side} from '../interface';
4 | import {computeOldLineNumber, computeNewLineNumber, ChangeData, HunkData, isDelete, isInsert, isNormal} from '../utils';
5 | import {Pair, TokenNode} from './interface';
6 |
7 | interface Refractor {
8 | highlight: typeof highlight;
9 | }
10 |
11 | interface HunkApplyState {
12 | newStart: number;
13 | changes: ChangeData[];
14 | }
15 |
16 | // This function mutates `linesOfCode` argument.
17 | function applyHunk(linesOfCode: string[], {newStart, changes}: HunkApplyState) {
18 | // Within each hunk, changes are continous, so we can use a sequential algorithm here.
19 | //
20 | // When `linesOfCode` is received here, it has already patched by previous hunk,
21 | // thus the starting line number has changed due to possible unbanlanced deletions and insertions,
22 | // we should use `newStart` as the first line number of current reduce.
23 | const [patchedLines] = changes.reduce<[string[], number]>(
24 | ([lines, cursor], change) => {
25 | if (isDelete(change)) {
26 | lines.splice(cursor, 1);
27 | return [lines, cursor];
28 | }
29 |
30 | if (isInsert(change)) {
31 | lines.splice(cursor, 0, change.content);
32 | }
33 | return [lines, cursor + 1];
34 | },
35 | [linesOfCode, newStart - 1]
36 | );
37 |
38 | return patchedLines;
39 | }
40 |
41 | function applyDiff(oldSource: string, hunks: HunkData[]): string {
42 | // `hunks` must be ordered here.
43 | const patchedLines = hunks.reduce(applyHunk, oldSource.split('\n'));
44 | return patchedLines.join('\n');
45 | }
46 |
47 | function mapChanges(changes: ChangeData[], side: Side, toValue: (change: ChangeData | undefined) => T): T[] {
48 | if (!changes.length) {
49 | return [];
50 | }
51 |
52 | const computeLineNumber = side === 'old' ? computeOldLineNumber : computeNewLineNumber;
53 | const changesByLineNumber = keyBy(changes, computeLineNumber);
54 | const maxLineNumber = computeLineNumber(changes[changes.length - 1]);
55 | // TODO: why don't we start from the first change's line number?
56 | return Array.from({length: maxLineNumber}).map((value, i) => toValue(changesByLineNumber[i + 1]));
57 | }
58 |
59 | function groupChanges(hunks: HunkData[]): Pair {
60 | const changes = flatMap(hunks, hunk => hunk.changes);
61 | return changes.reduce<[ChangeData[], ChangeData[]]>(
62 | ([oldChanges, newChanges], change) => {
63 | if (isNormal(change)) {
64 | oldChanges.push(change);
65 | newChanges.push(change);
66 | }
67 | else if (isDelete(change)) {
68 | oldChanges.push(change);
69 | }
70 | else {
71 | newChanges.push(change);
72 | }
73 |
74 | return [oldChanges, newChanges];
75 | },
76 | [[], []]
77 | );
78 | }
79 |
80 | function toTextPair(hunks: HunkData[]): Pair {
81 | const [oldChanges, newChanges] = groupChanges(hunks);
82 | const toText = (change: ChangeData | undefined) => (change ? change.content : '');
83 | const oldText = mapChanges(oldChanges, 'old', toText).join('\n');
84 | const newText = mapChanges(newChanges, 'new', toText).join('\n');
85 | return [oldText, newText];
86 | }
87 |
88 | function createRoot(children: RefractorNode[]): TokenNode {
89 | return {type: 'root', children: children};
90 | }
91 |
92 | export interface ToTokenTreeOptionsNoHighlight {
93 | highlight?: false;
94 | oldSource?: string;
95 | }
96 |
97 | export interface ToTokenTreeOptionsHighlight {
98 | highlight: true;
99 | refractor: Refractor;
100 | oldSource?: string;
101 | language: string;
102 | }
103 |
104 | export type ToTokenTreeOptions = ToTokenTreeOptionsNoHighlight | ToTokenTreeOptionsHighlight;
105 |
106 | export default function toTokenTrees(hunks: HunkData[], options: ToTokenTreeOptions): Pair {
107 | if (options.oldSource) {
108 | const newSource = applyDiff(options.oldSource, hunks);
109 | const highlightText = options.highlight
110 | ? (text: string) => options.refractor.highlight(text, options.language)
111 | : (text: string): AST.Text[] => [{type: 'text', value: text}];
112 |
113 | return [
114 | createRoot(highlightText(options.oldSource)),
115 | createRoot(highlightText(newSource)),
116 | ];
117 | }
118 |
119 | const [oldText, newText] = toTextPair(hunks);
120 | const toTree = options.highlight
121 | ? (text: string) => createRoot(options.refractor.highlight(text, options.language))
122 | : (text: string) => createRoot([{type: 'text', value: text}]);
123 |
124 | return [toTree(oldText), toTree(newText)];
125 | }
126 |
--------------------------------------------------------------------------------
/src/Hunk/UnifiedHunk/UnifiedChange.tsx:
--------------------------------------------------------------------------------
1 | import {memo, useState, useMemo, useCallback} from 'react';
2 | import classNames from 'classnames';
3 | import {mapValues} from 'lodash';
4 | import {ChangeData, getChangeKey} from '../../utils';
5 | import {TokenNode} from '../../tokenize';
6 | import {Side} from '../../interface';
7 | import {ChangeEventArgs, EventMap, GutterOptions, NativeEventMap, RenderGutter} from '../../context';
8 | import {ChangeSharedProps} from '../interface';
9 | import CodeCell from '../CodeCell';
10 | import {composeCallback, renderDefaultBy, wrapInAnchorBy} from '../utils';
11 |
12 | interface UnifiedChangeProps extends ChangeSharedProps {
13 | change: ChangeData;
14 | tokens: TokenNode[] | null;
15 | className: string;
16 | selected: boolean;
17 | }
18 |
19 | function useBoundCallbacks(callbacks: EventMap, arg: ChangeEventArgs, hoverOn: () => void, hoverOff: () => void) {
20 | return useMemo(
21 | () => {
22 | const output: NativeEventMap = mapValues(callbacks, fn => (e: any) => fn && fn(arg, e));
23 | output.onMouseEnter = composeCallback(hoverOn, output.onMouseEnter);
24 | output.onMouseLeave = composeCallback(hoverOff, output.onMouseLeave);
25 | return output;
26 | },
27 | [callbacks, hoverOn, hoverOff, arg]
28 | );
29 | }
30 |
31 | function useBoolean() {
32 | const [value, setValue] = useState(false);
33 | const on = useCallback(() => setValue(true), []);
34 | const off = useCallback(() => setValue(false), []);
35 | return [value, on, off] as const;
36 | }
37 |
38 | function renderGutterCell(
39 | className: string,
40 | change: ChangeData,
41 | changeKey: string,
42 | side: Side,
43 | gutterAnchor: boolean,
44 | anchorTarget: string | undefined,
45 | events: NativeEventMap,
46 | inHoverState: boolean,
47 | renderGutter: RenderGutter
48 | ) {
49 | const gutterOptions: GutterOptions = {
50 | change,
51 | side,
52 | inHoverState,
53 | renderDefault: renderDefaultBy(change, side),
54 | wrapInAnchor: wrapInAnchorBy(gutterAnchor, anchorTarget),
55 | };
56 |
57 | return (
58 | |
59 | {renderGutter(gutterOptions)}
60 | |
61 | );
62 | }
63 |
64 | function UnifiedChange(props: UnifiedChangeProps) {
65 | const {
66 | change,
67 | selected,
68 | tokens,
69 | className,
70 | generateLineClassName,
71 | gutterClassName,
72 | codeClassName,
73 | gutterEvents,
74 | codeEvents,
75 | hideGutter,
76 | gutterAnchor,
77 | generateAnchorID,
78 | renderToken,
79 | renderGutter,
80 | } = props;
81 | const {type, content} = change;
82 | const changeKey = getChangeKey(change);
83 |
84 | const [hover, hoverOn, hoverOff] = useBoolean();
85 | const eventArg = useMemo(() => ({change}), [change]);
86 | const boundGutterEvents = useBoundCallbacks(gutterEvents, eventArg, hoverOn, hoverOff);
87 | const boundCodeEvents = useBoundCallbacks(codeEvents, eventArg, hoverOn, hoverOff);
88 |
89 | const anchorID = generateAnchorID(change);
90 | const lineClassName = generateLineClassName({
91 | changes: [change],
92 | defaultGenerate: () => className,
93 | });
94 |
95 | const gutterClassNameValue = classNames(
96 | 'diff-gutter',
97 | `diff-gutter-${type}`,
98 | gutterClassName,
99 | {'diff-gutter-selected': selected}
100 | );
101 | const codeClassNameValue = classNames(
102 | 'diff-code',
103 | `diff-code-${type}`,
104 | codeClassName,
105 | {'diff-code-selected': selected}
106 | );
107 |
108 | return (
109 |
110 | {
111 | !hideGutter && renderGutterCell(
112 | gutterClassNameValue,
113 | change,
114 | changeKey,
115 | 'old',
116 | gutterAnchor,
117 | anchorID,
118 | boundGutterEvents,
119 | hover,
120 | renderGutter
121 | )
122 | }
123 | {
124 | !hideGutter && renderGutterCell(
125 | gutterClassNameValue,
126 | change,
127 | changeKey,
128 | 'new',
129 | gutterAnchor,
130 | anchorID,
131 | boundGutterEvents,
132 | hover,
133 | renderGutter
134 | )
135 | }
136 |
144 |
145 | );
146 | }
147 |
148 | export default memo(UnifiedChange);
149 |
--------------------------------------------------------------------------------
/src/tokenize/__test__/tokenize.test.ts:
--------------------------------------------------------------------------------
1 | import {describe, test, expect} from 'vitest';
2 | import refractor from 'refractor';
3 | import dedent from 'dedent';
4 | import {parseDiff} from '../../utils';
5 | import {markEdits, pickRanges, tokenize, TokenizeOptions} from '../index';
6 |
7 | describe('tokenize', () => {
8 | test('basic', () => {
9 | const expected = {
10 | new: [[{type: 'text', value: ''}]],
11 | old: [[{type: 'text', value: ''}]],
12 | };
13 | expect(tokenize([], {})).toEqual(expected);
14 | });
15 |
16 | test('highlight', () => {
17 | const result = tokenize(
18 | [],
19 | {
20 | highlight: true,
21 | refractor,
22 | language: 'javascript',
23 | }
24 | );
25 | expect(result).toMatchSnapshot();
26 |
27 | });
28 |
29 | test('with old source', () => {
30 | const expected = {
31 | new: [[{type: 'text', value: 'A'}]],
32 | old: [[{type: 'text', value: 'A'}]],
33 | };
34 | expect(tokenize([], {oldSource: 'A'})).toEqual(expected);
35 | });
36 |
37 | test('withHunk', () => {
38 | const diff = dedent`
39 | diff --git a/package.json b/package.json
40 | index 4778f48..c0edd5f 100644
41 | --- a/package.json
42 | +++ b/package.json
43 | @@ -13,7 +13,7 @@
44 | ],
45 | "scripts": {
46 | "prepare": "husky install",
47 | - "test": "jest --config ./jest.config.js",
48 | + "test": "vitest run --coverage",
49 | "start": "skr dev --src-dir=site",
50 | "clean": "rm -rf cjs es style types",
51 | "build": "sh scripts/build.sh",
52 | @@ -49,11 +49,12 @@
53 | "@reskript/settings": "5.7.4",
54 | "@rollup/plugin-typescript": "^11.0.0",
55 | "@types/dedent": "^0.7.0",
56 | + "@types/diff-match-patch": "^1.0.32",
57 | "@types/lodash": "^4.14.191",
58 | "@types/react-test-renderer": "^18.0.0",
59 | + "@types/refractor": "^2.8.0",
60 | "antd": "^5.2.1",
61 | "autoprefixer": "^10.4.13",
62 | - "babel-jest": "^29.4.3",
63 | "babel-loader": "^9.1.2",
64 | "babel-plugin-add-react-displayname": "0.0.5",
65 | "babel-plugin-import": "^1.13.6",
66 | @@ -66,14 +67,12 @@
67 | "enzyme": "^3.11.0",
68 | "enzyme-adapter-react-16": "^1.15.7",
69 | "eslint": "^8.34.0",
70 | - "eslint-plugin-jest": "^27.2.1",
71 | "eslint-plugin-react": "^7.32.2",
72 | "eslint-plugin-react-hooks": "^4.6.0",
73 | "gitdiff-parser": "^0.2.2",
74 | "html-webpack-plugin": "^5.5.0",
75 | "husky": "^8.0.3",
76 | "identity-obj-proxy": "^3.0.0",
77 | - "jest": "^29.4.3",
78 | "less": "^4.1.3",
79 | "less-loader": "^11.1.0",
80 | "lodash": "^4.17.21",
81 | @@ -101,7 +100,6 @@
82 | "sha1": "^1.1.1",
83 | "style-loader": "^3.3.1",
84 | "styled-components": "^5.3.6",
85 | - "ts-jest": "^29.0.5",
86 | "typescript": "^4.9.5",
87 | "unidiff": "^1.0.2",
88 | "vitest": "^0.28.5",
89 | `;
90 | const [file] = parseDiff(diff);
91 | expect(tokenize(file.hunks)).toMatchSnapshot();
92 | });
93 |
94 | test('enhance', () => {
95 | const oldSource = dedent`
96 | // Copyright
97 | package com.xxx;
98 | import java.util.List;
99 | import java.util.Set;
100 | `;
101 |
102 | const diffText = `
103 | diff --git a/a b/b
104 | index 5b25e5b..772d084 100644
105 | --- a/a
106 | +++ b/b
107 | @@ -2,6 +2,7 @@
108 | package com.xxx;
109 | +import java.util.Date;
110 | import java.util.List;
111 | import java.util.Set;
112 | `;
113 |
114 | const defs = [
115 | {
116 | id: 'yz5k9m0BvISpUQ2asedw',
117 | type: 'def',
118 | row: 2,
119 | col: 9,
120 | binding: 'package',
121 | length: 7,
122 | token: 'com.xxx',
123 | },
124 | ];
125 |
126 | const [file] = parseDiff(diffText, {nearbySequences: 'zip'});
127 | const options: TokenizeOptions = {
128 | oldSource,
129 | highlight: false,
130 | enhancers: [
131 | markEdits(file.hunks, {type: 'block'}),
132 | pickRanges([], defs.map(i => ({...i, lineNumber: i.row, start: i.col - 1}))),
133 | ],
134 | };
135 | const tokens = tokenize(file.hunks, options);
136 | expect(tokens).toMatchSnapshot();
137 | });
138 | });
139 |
--------------------------------------------------------------------------------
/src/utils/__test__/diff-factory.test.ts:
--------------------------------------------------------------------------------
1 | import {describe, test, expect} from 'vitest';
2 | import {
3 | computeLineNumberFactory,
4 | isInHunkFactory,
5 | isBetweenHunksFactory,
6 | findChangeByLineNumberFactory,
7 | getCorrespondingLineNumberFactory,
8 | } from '../diff/factory';
9 | import {ChangeData, HunkData} from '../parse';
10 |
11 | const normalChange: ChangeData = {type: 'normal', isNormal: true, oldLineNumber: 1, newLineNumber: 1, content: ''};
12 |
13 | const insertChange: ChangeData = {type: 'insert', isInsert: true, lineNumber: 2, content: ''};
14 |
15 | const deleteChange: ChangeData = {type: 'delete', isDelete: true, lineNumber: 2, content: ''};
16 |
17 | const sampleHunk: HunkData = {
18 | content: '',
19 | oldStart: 1,
20 | oldLines: 2,
21 | newStart: 1,
22 | newLines: 2,
23 | changes: [],
24 | };
25 |
26 | const nextHunk: HunkData = {
27 | content: '',
28 | oldStart: 4,
29 | oldLines: 2,
30 | newStart: 4,
31 | newLines: 2,
32 | changes: [],
33 | };
34 |
35 | describe('computeLineNumber', () => {
36 | test('old', () => {
37 | const computeOldLineNumber = computeLineNumberFactory('old');
38 | expect(computeOldLineNumber(insertChange)).toBe(-1);
39 | expect(computeOldLineNumber(normalChange)).toBe(1);
40 | expect(computeOldLineNumber(deleteChange)).toBe(2);
41 | });
42 |
43 | test('new', () => {
44 | const computeNewLineNumber = computeLineNumberFactory('new');
45 | expect(computeNewLineNumber(deleteChange)).toBe(-1);
46 | expect(computeNewLineNumber(normalChange)).toBe(1);
47 | expect(computeNewLineNumber(insertChange)).toBe(2);
48 | });
49 | });
50 |
51 | describe('isInHunk', () => {
52 | test('old', () => {
53 | const isInOldHunk = isInHunkFactory('oldStart', 'oldLines');
54 | expect(isInOldHunk(sampleHunk, 2)).toBe(true);
55 | expect(isInOldHunk(sampleHunk, 3)).toBe(false);
56 | });
57 |
58 | test('new', () => {
59 | const isInNewHunk = isInHunkFactory('newStart', 'newLines');
60 | expect(isInNewHunk(sampleHunk, 2)).toBe(true);
61 | expect(isInNewHunk(sampleHunk, 3)).toBe(false);
62 | });
63 | });
64 |
65 | describe('isBetweenHunks', () => {
66 | test('old', () => {
67 | const isOldLineNumberBetweenHunks = isBetweenHunksFactory('oldStart', 'oldLines');
68 | expect(isOldLineNumberBetweenHunks(sampleHunk, nextHunk, 2)).toBe(false);
69 | expect(isOldLineNumberBetweenHunks(sampleHunk, nextHunk, 3)).toBe(true);
70 | });
71 |
72 | test('new', () => {
73 | const isNewLineNumberBetweenHunks = isBetweenHunksFactory('newStart', 'newLines');
74 | expect(isNewLineNumberBetweenHunks(sampleHunk, nextHunk, 2)).toBe(false);
75 | expect(isNewLineNumberBetweenHunks(sampleHunk, nextHunk, 3)).toBe(true);
76 | });
77 | });
78 |
79 | describe('findChangeByLineNumber', () => {
80 | test('old', () => {
81 | const findChangeByLineNumber = findChangeByLineNumberFactory('old');
82 | const change: ChangeData = {type: 'normal', isNormal: true, oldLineNumber: 1, newLineNumber: 1, content: ''};
83 | const hunk: HunkData = {oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, changes: [change], content: ''};
84 | expect(findChangeByLineNumber([hunk], 1)).toBe(change);
85 | expect(findChangeByLineNumber([hunk], 3)).toBe(undefined);
86 | });
87 |
88 | test('new', () => {
89 | const findChangeByLineNumber = findChangeByLineNumberFactory('new');
90 | const change: ChangeData = {type: 'normal', isNormal: true, oldLineNumber: 1, newLineNumber: 1, content: ''};
91 | const hunk: HunkData = {oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, changes: [change], content: ''};
92 | expect(findChangeByLineNumber([hunk], 1)).toBe(change);
93 | expect(findChangeByLineNumber([hunk], 3)).toBe(undefined);
94 | });
95 | });
96 |
97 | describe('getCorrespondingLineNumber', () => {
98 | test('old', () => {
99 | // getNewCorrespondingLineNumber is the same
100 | const getOldCorrespondingLineNumber = getCorrespondingLineNumberFactory('old');
101 | const hunk: HunkData = {oldStart: 10, oldLines: 5, newStart: 20, newLines: 5, changes: [], content: ''};
102 | expect(() => getOldCorrespondingLineNumber([], 0)).toThrow();
103 | expect(getOldCorrespondingLineNumber([hunk], 0)).toBe(10);
104 | expect(getOldCorrespondingLineNumber([hunk], 20)).toBe(30);
105 |
106 | hunk.changes = [{type: 'normal', isNormal: true, oldLineNumber: 12, newLineNumber: 22, content: ''}];
107 | expect(getOldCorrespondingLineNumber([hunk], 12)).toBe(22);
108 |
109 | hunk.changes = [{type: 'insert', isInsert: true, lineNumber: 13, content: ''}];
110 | expect(() => getOldCorrespondingLineNumber([hunk], 13)).toThrow();
111 |
112 | hunk.changes = [{type: 'delete', isDelete: true, lineNumber: 14, content: ''}];
113 | expect(getOldCorrespondingLineNumber([hunk], 14)).toBe(-1);
114 |
115 | const nextHunk = {oldStart: 20, oldLines: 5, newStart: 30, newLines: 5, changes: [], content: ''};
116 | expect(getOldCorrespondingLineNumber([hunk, nextHunk], 16)).toBe(26);
117 | });
118 | });
119 |
--------------------------------------------------------------------------------
/src/utils/__test__/diff.test.ts:
--------------------------------------------------------------------------------
1 | import {describe, test, expect} from 'vitest';
2 | import dedent from 'dedent';
3 | import {parseDiff} from '../parse';
4 | import {
5 | textLinesToHunk,
6 | insertHunk,
7 | expandFromRawCode,
8 | getCollapsedLinesCountBetween,
9 | expandCollapsedBlockBy,
10 | getChangeKey,
11 | ChangeData,
12 | HunkData,
13 | } from '../index';
14 |
15 | const sample = dedent`
16 | diff --git a/src/__test__/index.test.jsx b/src/__test__/index.test.jsx
17 | index 643c2f0..7883597 100644
18 | --- a/src/__test__/index.test.jsx
19 | +++ b/src/__test__/index.test.jsx
20 | @@ -21,3 +21,3 @@ describe('basic test', () => {
21 | test('App renders correctly', () => {
22 | - expect(renderer.create().toJSON()).toMatchSnapshot();
23 | + expect(renderer.create().toJSON()).toMatchSnapshot();
24 | });
25 | `;
26 |
27 | export const sampleHunk = parseDiff(sample)[0].hunks[0];
28 |
29 | const normalChange: ChangeData = {type: 'normal', isNormal: true, oldLineNumber: 1, newLineNumber: 1, content: ''};
30 |
31 | const insertChange: ChangeData = {type: 'insert', isInsert: true, lineNumber: 2, content: ''};
32 |
33 | const deleteChange: ChangeData = {type: 'delete', isDelete: true, lineNumber: 2, content: ''};
34 |
35 | describe('textLinesToHunk', () => {
36 | test('basic', () => {
37 | const lines = [''];
38 | expect(textLinesToHunk(lines, 0, 0)).toMatchSnapshot();
39 | });
40 | });
41 |
42 | describe('insertHunk', () => {
43 | test('basic', () => {
44 | const results = insertHunk(
45 | [{changes: [normalChange], content: '', oldStart: 1, newStart: 1, oldLines: 1, newLines: 1}],
46 | {changes: [deleteChange], content: '', oldStart: 2, newStart: 2, oldLines: 1, newLines: 1}
47 | );
48 | expect(results.length).toBe(1);
49 | expect(results[0].changes).toEqual([normalChange, deleteChange]);
50 | });
51 | });
52 |
53 | describe('expandFromRawCode', () => {
54 | test('basic', () => {
55 | const hunks = [sampleHunk];
56 | expect(expandFromRawCode(hunks, '', 22, 23)).toMatchSnapshot();
57 | });
58 | });
59 |
60 | describe('getCollapsedLinesCountBetween', () => {
61 | test('basic', () => {
62 | const previousHunk: HunkData = {content: '', oldStart: 1, oldLines: 2, newStart: 1, newLines: 2, changes: []};
63 | const nextHunk: HunkData = {content: '', oldStart: 10, oldLines: 2, newStart: 10, newLines: 2, changes: []};
64 | expect(getCollapsedLinesCountBetween(previousHunk, nextHunk)).toBe(7);
65 | });
66 |
67 | test('minus number', () => {
68 | const previousHunk: HunkData = {content: '', oldStart: 1, oldLines: 10, newStart: 1, newLines: 2, changes: []};
69 | const nextHunk: HunkData = {content: '', oldStart: 2, oldLines: 2, newStart: 10, newLines: 2, changes: []};
70 | expect(getCollapsedLinesCountBetween(previousHunk, nextHunk)).toBe(-9);
71 | });
72 |
73 | test('no previousHunk', () => {
74 | const nextHunk: HunkData = {content: '', oldStart: 2, oldLines: 2, newStart: 10, newLines: 2, changes: []};
75 | expect(getCollapsedLinesCountBetween(null, nextHunk)).toBe(1);
76 | });
77 | });
78 |
79 | describe('expandCollapsedBlockBy', () => {
80 | test('basic', () => {
81 | const hunks = [sampleHunk];
82 | expect(expandCollapsedBlockBy(hunks, '', () => false)).toMatchSnapshot();
83 | });
84 |
85 | test('normal hunk', () => {
86 | const hunks: HunkData[] = [
87 | {
88 | content: '@@ -1,2 +1,2 @@',
89 | oldStart: 1,
90 | newStart: 1,
91 | oldLines: 2,
92 | newLines: 2,
93 | changes: [{
94 | content: 'iiiiiiiiiiiiiiiiiiiiii:WQiiiiiiiiiiiiejj',
95 | type: 'normal',
96 | isNormal: true,
97 | oldLineNumber: 1,
98 | newLineNumber: 1,
99 | }, {
100 | content: 'dsds',
101 | type: 'delete',
102 | isDelete: true,
103 | lineNumber: 2,
104 | }, {
105 | content: 'dsdsds',
106 | type: 'insert',
107 | isInsert: true,
108 | lineNumber: 2,
109 | }],
110 | },
111 | ];
112 | const rawCode = 'iiiiiiiiiiiiiiiiiiiiii:WQiiiiiiiiiiiiejj\ndsds';
113 | expect(expandCollapsedBlockBy(hunks, rawCode, () => false)).toMatchSnapshot();
114 | expect(expandCollapsedBlockBy(hunks, rawCode.split('\n'), () => false)).toMatchSnapshot();
115 | expect(expandFromRawCode(hunks, rawCode, 0, 10)).toMatchSnapshot();
116 | });
117 | });
118 |
119 | describe('getChangeKey', () => {
120 | test('normal change', () => {
121 | expect(getChangeKey(normalChange)).toBe('N1');
122 | });
123 |
124 | test('insert change', () => {
125 | expect(getChangeKey(insertChange)).toBe('I2');
126 | });
127 |
128 | test('delete change', () => {
129 | expect(getChangeKey(deleteChange)).toBe('D2');
130 | });
131 | });
132 |
--------------------------------------------------------------------------------
/src/utils/__test__/__snapshots__/diff.test.ts.snap:
--------------------------------------------------------------------------------
1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2 |
3 | exports[`expandCollapsedBlockBy > basic 1`] = `
4 | [
5 | {
6 | "changes": [
7 | {
8 | "content": " test('App renders correctly', () => {",
9 | "isNormal": true,
10 | "newLineNumber": 21,
11 | "oldLineNumber": 21,
12 | "type": "normal",
13 | },
14 | {
15 | "content": " expect(renderer.create().toJSON()).toMatchSnapshot();",
16 | "isDelete": true,
17 | "lineNumber": 22,
18 | "type": "delete",
19 | },
20 | {
21 | "content": " expect(renderer.create().toJSON()).toMatchSnapshot();",
22 | "isInsert": true,
23 | "lineNumber": 22,
24 | "type": "insert",
25 | },
26 | {
27 | "content": " });",
28 | "isNormal": true,
29 | "newLineNumber": 23,
30 | "oldLineNumber": 23,
31 | "type": "normal",
32 | },
33 | ],
34 | "content": "@@ -21,3 +21,3 @@ describe('basic test', () => {",
35 | "isPlain": false,
36 | "newLines": 3,
37 | "newStart": 21,
38 | "oldLines": 3,
39 | "oldStart": 21,
40 | },
41 | ]
42 | `;
43 |
44 | exports[`expandCollapsedBlockBy > normal hunk 1`] = `
45 | [
46 | {
47 | "changes": [
48 | {
49 | "content": "iiiiiiiiiiiiiiiiiiiiii:WQiiiiiiiiiiiiejj",
50 | "isNormal": true,
51 | "newLineNumber": 1,
52 | "oldLineNumber": 1,
53 | "type": "normal",
54 | },
55 | {
56 | "content": "dsds",
57 | "isDelete": true,
58 | "lineNumber": 2,
59 | "type": "delete",
60 | },
61 | {
62 | "content": "dsdsds",
63 | "isInsert": true,
64 | "lineNumber": 2,
65 | "type": "insert",
66 | },
67 | ],
68 | "content": "@@ -1,2 +1,2 @@",
69 | "newLines": 2,
70 | "newStart": 1,
71 | "oldLines": 2,
72 | "oldStart": 1,
73 | },
74 | ]
75 | `;
76 |
77 | exports[`expandCollapsedBlockBy > normal hunk 2`] = `
78 | [
79 | {
80 | "changes": [
81 | {
82 | "content": "iiiiiiiiiiiiiiiiiiiiii:WQiiiiiiiiiiiiejj",
83 | "isNormal": true,
84 | "newLineNumber": 1,
85 | "oldLineNumber": 1,
86 | "type": "normal",
87 | },
88 | {
89 | "content": "dsds",
90 | "isDelete": true,
91 | "lineNumber": 2,
92 | "type": "delete",
93 | },
94 | {
95 | "content": "dsdsds",
96 | "isInsert": true,
97 | "lineNumber": 2,
98 | "type": "insert",
99 | },
100 | ],
101 | "content": "@@ -1,2 +1,2 @@",
102 | "newLines": 2,
103 | "newStart": 1,
104 | "oldLines": 2,
105 | "oldStart": 1,
106 | },
107 | ]
108 | `;
109 |
110 | exports[`expandCollapsedBlockBy > normal hunk 3`] = `
111 | [
112 | {
113 | "changes": [
114 | {
115 | "content": "iiiiiiiiiiiiiiiiiiiiii:WQiiiiiiiiiiiiejj",
116 | "isNormal": true,
117 | "newLineNumber": 0,
118 | "oldLineNumber": 0,
119 | "type": "normal",
120 | },
121 | {
122 | "content": "iiiiiiiiiiiiiiiiiiiiii:WQiiiiiiiiiiiiejj",
123 | "isNormal": true,
124 | "newLineNumber": 1,
125 | "oldLineNumber": 1,
126 | "type": "normal",
127 | },
128 | {
129 | "content": "dsds",
130 | "isDelete": true,
131 | "lineNumber": 2,
132 | "type": "delete",
133 | },
134 | {
135 | "content": "dsdsds",
136 | "isInsert": true,
137 | "lineNumber": 2,
138 | "type": "insert",
139 | },
140 | ],
141 | "content": "@@ -0,3 +0,3",
142 | "isPlain": false,
143 | "newLines": 3,
144 | "newStart": 0,
145 | "oldLines": 3,
146 | "oldStart": 0,
147 | },
148 | ]
149 | `;
150 |
151 | exports[`expandFromRawCode > basic 1`] = `
152 | [
153 | {
154 | "changes": [
155 | {
156 | "content": " test('App renders correctly', () => {",
157 | "isNormal": true,
158 | "newLineNumber": 21,
159 | "oldLineNumber": 21,
160 | "type": "normal",
161 | },
162 | {
163 | "content": " expect(renderer.create().toJSON()).toMatchSnapshot();",
164 | "isDelete": true,
165 | "lineNumber": 22,
166 | "type": "delete",
167 | },
168 | {
169 | "content": " expect(renderer.create().toJSON()).toMatchSnapshot();",
170 | "isInsert": true,
171 | "lineNumber": 22,
172 | "type": "insert",
173 | },
174 | {
175 | "content": " });",
176 | "isNormal": true,
177 | "newLineNumber": 23,
178 | "oldLineNumber": 23,
179 | "type": "normal",
180 | },
181 | ],
182 | "content": "@@ -21,3 +21,3 @@ describe('basic test', () => {",
183 | "isPlain": false,
184 | "newLines": 3,
185 | "newStart": 21,
186 | "oldLines": 3,
187 | "oldStart": 21,
188 | },
189 | ]
190 | `;
191 |
192 | exports[`textLinesToHunk > basic 1`] = `
193 | {
194 | "changes": [
195 | {
196 | "content": "",
197 | "isNormal": true,
198 | "newLineNumber": 0,
199 | "oldLineNumber": 0,
200 | "type": "normal",
201 | },
202 | ],
203 | "content": "@@ -0,1 +0,1",
204 | "isPlain": true,
205 | "newLines": 1,
206 | "newStart": 0,
207 | "oldLines": 1,
208 | "oldStart": 0,
209 | }
210 | `;
211 |
--------------------------------------------------------------------------------
/src/utils/diff/factory.ts:
--------------------------------------------------------------------------------
1 | import {Side} from '../../interface';
2 | import {ChangeData, HunkData, isDelete, isInsert, isNormal} from '../parse';
3 | import {first, last, sideToProperty} from './util';
4 |
5 | type ComputeLine = (change: ChangeData) => number;
6 |
7 | export function computeLineNumberFactory(side: Side): ComputeLine {
8 | if (side === 'old') {
9 | return change => {
10 | if (isInsert(change)) {
11 | return -1;
12 | }
13 |
14 | return isNormal(change) ? change.oldLineNumber : change.lineNumber;
15 | };
16 | }
17 |
18 | return change => {
19 | if (isDelete(change)) {
20 | return -1;
21 | }
22 |
23 | return isNormal(change) ? change.newLineNumber : change.lineNumber;
24 | };
25 | }
26 |
27 | type IsInHunk = (hunk: HunkData, lineNumber: number) => boolean;
28 |
29 | type StartProperty = 'oldStart' | 'newStart';
30 |
31 | type LinesProperty = 'oldLines' | 'newLines';
32 |
33 | export function isInHunkFactory(startProperty: StartProperty, linesProperty: LinesProperty): IsInHunk {
34 | return (hunk, lineNumber) => {
35 | const start = hunk[startProperty];
36 | const end = start + hunk[linesProperty];
37 |
38 | return lineNumber >= start && lineNumber < end;
39 | };
40 | }
41 |
42 | type IsBetweenHunks = (previousHunk: HunkData, nextHunk: HunkData, lineNumber: number) => boolean;
43 |
44 | export function isBetweenHunksFactory(startProperty: StartProperty, linesProperty: LinesProperty): IsBetweenHunks {
45 | return (previousHunk, nextHunk, lineNumber) => {
46 | const start = previousHunk[startProperty] + previousHunk[linesProperty];
47 | const end = nextHunk[startProperty];
48 |
49 | return lineNumber >= start && lineNumber < end;
50 | };
51 | }
52 |
53 | type FindContainerHunk = (hunks: HunkData[], lineNumber: number) => HunkData | undefined;
54 |
55 | function findContainerHunkFactory(side: Side): FindContainerHunk {
56 | const [startProperty, linesProperty] = sideToProperty(side);
57 | const isInHunk = isInHunkFactory(startProperty, linesProperty);
58 |
59 | return (hunks, lineNumber) => hunks.find(hunk => isInHunk(hunk, lineNumber));
60 | }
61 |
62 | type FindChangeByLineNumber = (hunks: HunkData[], lineNumber: number) => ChangeData | undefined;
63 |
64 | export function findChangeByLineNumberFactory(side: Side): FindChangeByLineNumber {
65 | const computeLineNumber = computeLineNumberFactory(side);
66 | const findContainerHunk = findContainerHunkFactory(side);
67 |
68 | return (hunks, lineNumber): ChangeData | undefined => {
69 | const containerHunk = findContainerHunk(hunks, lineNumber);
70 |
71 | if (!containerHunk) {
72 | return undefined;
73 | }
74 |
75 | return containerHunk.changes.find(change => computeLineNumber(change) === lineNumber);
76 | };
77 | }
78 |
79 | type GetCorrespondingLineNumber = (hunks: HunkData[], lineNumber: number) => number;
80 |
81 | export function getCorrespondingLineNumberFactory(baseSide: Side): GetCorrespondingLineNumber {
82 | const anotherSide = baseSide === 'old' ? 'new' : 'old';
83 | const [baseStart, baseLines] = sideToProperty(baseSide);
84 | const [correspondingStart, correspondingLines] = sideToProperty(anotherSide);
85 | const baseLineNumber = computeLineNumberFactory(baseSide);
86 | const correspondingLineNumber = computeLineNumberFactory(anotherSide);
87 | const isInHunk = isInHunkFactory(baseStart, baseLines);
88 | const isBetweenHunks = isBetweenHunksFactory(baseStart, baseLines);
89 |
90 | /* eslint-disable complexity */
91 | return (hunks, lineNumber) => {
92 | const firstHunk = first(hunks);
93 |
94 | // Before first hunk
95 | if (lineNumber < firstHunk[baseStart]) {
96 | const spanFromStart = firstHunk[baseStart] - lineNumber;
97 | return firstHunk[correspondingStart] - spanFromStart;
98 | }
99 |
100 | // After last hunk, this can be done in `for` loop, just a quick return path
101 | const lastHunk = last(hunks);
102 | if (lastHunk[baseStart] + lastHunk[baseLines] <= lineNumber) {
103 | const spanFromEnd = lineNumber - lastHunk[baseStart] - lastHunk[baseLines];
104 | return lastHunk[correspondingStart] + lastHunk[correspondingLines] + spanFromEnd;
105 | }
106 |
107 | for (let i = 0; i < hunks.length; i++) {
108 | const currentHunk = hunks[i];
109 | const nextHunk = hunks[i + 1];
110 |
111 | // Within current hunk
112 | if (isInHunk(currentHunk, lineNumber)) {
113 | const changeIndex = currentHunk.changes.findIndex(change => baseLineNumber(change) === lineNumber);
114 | const change = currentHunk.changes[changeIndex];
115 |
116 | if (isNormal(change)) {
117 | return correspondingLineNumber(change);
118 | }
119 |
120 | // For changes of type "insert" and "delete", the sibling change can be the corresponding one,
121 | // or they can have no corresponding change
122 | //
123 | // Git diff always put delete change before insert change
124 | //
125 | // Note that `nearbySequences: "zip"` option can affect this function
126 | const possibleCorrespondingChangeIndex = isDelete(change) ? changeIndex + 1 : changeIndex - 1;
127 | const possibleCorrespondingChange = currentHunk.changes[possibleCorrespondingChangeIndex];
128 |
129 | if (!possibleCorrespondingChange) {
130 | return -1;
131 | }
132 |
133 | const negativeChangeType = isInsert(change) ? 'delete' : 'insert';
134 |
135 | return possibleCorrespondingChange.type === negativeChangeType
136 | ? correspondingLineNumber(possibleCorrespondingChange)
137 | : -1;
138 | }
139 |
140 | // Between 2 hunks
141 | if (isBetweenHunks(currentHunk, nextHunk, lineNumber)) {
142 | const spanFromEnd = lineNumber - currentHunk[baseStart] - currentHunk[baseLines];
143 | return currentHunk[correspondingStart] + currentHunk[correspondingLines] + spanFromEnd;
144 | }
145 | }
146 |
147 | /* istanbul ignore next Should not arrive here */
148 | throw new Error(`Unexpected line position ${lineNumber}`);
149 | };
150 | /* eslint-enable complexity */
151 | }
152 |
--------------------------------------------------------------------------------
/src/tokenize/markEdits.ts:
--------------------------------------------------------------------------------
1 | import {findIndex, flatMap, flatten} from 'lodash';
2 | import DiffMatchPatch, {Diff} from 'diff-match-patch';
3 | import {ChangeData, HunkData, isDelete, isInsert, isNormal} from '../utils';
4 | import pickRanges, {RangeTokenNode} from './pickRanges';
5 | import {TokenizeEnhancer} from './interface';
6 |
7 | const {DIFF_EQUAL, DIFF_DELETE, DIFF_INSERT} = DiffMatchPatch;
8 |
9 | function findChangeBlocks(changes: ChangeData[]): ChangeData[][] {
10 | const start = findIndex(changes, change => !isNormal(change));
11 |
12 | if (start === -1) {
13 | return [];
14 | }
15 |
16 | const end = findIndex(changes, change => !!isNormal(change), start);
17 |
18 | if (end === -1) {
19 | return [changes.slice(start)];
20 | }
21 |
22 | return [
23 | changes.slice(start, end),
24 | ...findChangeBlocks(changes.slice(end)),
25 | ];
26 | }
27 |
28 | function groupDiffs(diffs: Diff[]): [Diff[], Diff[]] {
29 | return diffs.reduce<[Diff[], Diff[]]>(
30 | ([oldDiffs, newDiffs], diff) => {
31 | const [type] = diff;
32 |
33 | switch (type) {
34 | case DIFF_INSERT:
35 | newDiffs.push(diff);
36 | break;
37 | case DIFF_DELETE:
38 | oldDiffs.push(diff);
39 | break;
40 | default:
41 | oldDiffs.push(diff);
42 | newDiffs.push(diff);
43 | break;
44 | }
45 |
46 | return [oldDiffs, newDiffs];
47 | },
48 | [[], []]
49 | );
50 | }
51 |
52 | function splitDiffToLines(diffs: Diff[]): Diff[][] {
53 | return diffs.reduce(
54 | (lines, [type, value]) => {
55 | const currentLines = value.split('\n');
56 |
57 | const [currentLineRemaining, ...nextLines] = currentLines.map((line: string): Diff => [type, line]);
58 | const next = [
59 | ...lines.slice(0, -1),
60 | [...lines[lines.length - 1], currentLineRemaining],
61 | ...nextLines.map(line => [line]),
62 | ];
63 | return next;
64 | },
65 | [[]]
66 | );
67 | }
68 |
69 | function diffsToEdits(diffs: Diff[], lineNumber: number): RangeTokenNode[] {
70 | const output = diffs.reduce<[RangeTokenNode[], number]>(
71 | (output, diff) => {
72 | const [edits, start] = output;
73 | const [type, value] = diff;
74 | if (type !== DIFF_EQUAL) {
75 | const edit: RangeTokenNode = {
76 | type: 'edit',
77 | lineNumber: lineNumber,
78 | start: start,
79 | length: value.length,
80 | };
81 | edits.push(edit);
82 | }
83 |
84 | return [edits, start + value.length];
85 | },
86 | [[], 0]
87 | );
88 |
89 | return output[0];
90 | }
91 |
92 | function convertToLinesOfEdits(linesOfDiffs: Diff[][], startLineNumber: number) {
93 | return flatMap(linesOfDiffs, (diffs, i) => diffsToEdits(diffs, startLineNumber + i));
94 | }
95 |
96 | function diffText(x: string, y: string): [Diff[], Diff[]] {
97 | const dmp = new DiffMatchPatch();
98 | const diffs = dmp.diff_main(x, y);
99 | dmp.diff_cleanupSemantic(diffs);
100 |
101 | // for only one diff, it's a insertion or deletion, we won't mark it in UI
102 | if (diffs.length <= 1) {
103 | return [[], []];
104 | }
105 |
106 | return groupDiffs(diffs);
107 | }
108 |
109 | function diffChangeBlock(changes: ChangeData[]): [RangeTokenNode[], RangeTokenNode[]] {
110 | const [oldSource, newSource] = changes.reduce(
111 | ([oldSource, newSource], change) => (
112 | isDelete(change)
113 | ? [oldSource + (oldSource ? '\n' : '') + change.content, newSource]
114 | : [oldSource, newSource + (newSource ? '\n' : '') + change.content]
115 | ),
116 | ['', '']
117 | );
118 |
119 | const [oldDiffs, newDiffs] = diffText(oldSource, newSource);
120 |
121 | if (oldDiffs.length === 0 && newDiffs.length === 0) {
122 | return [[], []];
123 | }
124 |
125 | const getLineNumber = (change: ChangeData | undefined) => {
126 | if (!change || isNormal(change)) {
127 | return undefined;
128 | }
129 |
130 | return change.lineNumber;
131 | };
132 | const oldStartLineNumber = getLineNumber(changes.find(isDelete));
133 | const newStartLineNumber = getLineNumber(changes.find(isInsert));
134 |
135 | if (oldStartLineNumber === undefined || newStartLineNumber === undefined) {
136 | throw new Error('Could not find start line number for edit');
137 | }
138 |
139 | const oldEdits = convertToLinesOfEdits(splitDiffToLines(oldDiffs), oldStartLineNumber);
140 | const newEdits = convertToLinesOfEdits(splitDiffToLines(newDiffs), newStartLineNumber);
141 |
142 | return [oldEdits, newEdits];
143 | }
144 |
145 | function diffByLine(changes: ChangeData[]): [RangeTokenNode[], RangeTokenNode[]] {
146 | const [oldEdits, newEdits] = changes.reduce<[RangeTokenNode[], RangeTokenNode[], ChangeData | null]>(
147 | ([oldEdits, newEdits, previousChange], currentChange) => {
148 | if (!previousChange || !isDelete(previousChange) || !isInsert(currentChange)) {
149 | return [oldEdits, newEdits, currentChange];
150 | }
151 |
152 | const [oldDiffs, newDiffs] = diffText(previousChange.content, currentChange.content);
153 | return [
154 | oldEdits.concat(diffsToEdits(oldDiffs, previousChange.lineNumber)),
155 | newEdits.concat(diffsToEdits(newDiffs, currentChange.lineNumber)),
156 | currentChange,
157 | ];
158 | },
159 | [[], [], null]
160 | );
161 | return [oldEdits, newEdits];
162 | }
163 |
164 | export type MarkEditsType = 'block' | 'line';
165 |
166 | export interface MarkEditsOptions {
167 | type?: MarkEditsType;
168 | }
169 |
170 | export default function markEdits(hunks: HunkData[], {type = 'block'}: MarkEditsOptions = {}): TokenizeEnhancer {
171 | const changeBlocks = flatMap(hunks.map(hunk => hunk.changes), findChangeBlocks);
172 | const findEdits = type === 'block' ? diffChangeBlock : diffByLine;
173 |
174 | const [oldEdits, newEdits] = changeBlocks.map(findEdits).reduce(
175 | ([oldEdits, newEdits], [currentOld, currentNew]) => [
176 | oldEdits.concat(currentOld),
177 | newEdits.concat(currentNew),
178 | ],
179 | [[], []]
180 | );
181 |
182 | return pickRanges(flatten(oldEdits), flatten(newEdits));
183 | }
184 |
--------------------------------------------------------------------------------
/src/utils/diff/insertHunk.ts:
--------------------------------------------------------------------------------
1 | import {findLastIndex} from 'lodash';
2 | import {ChangeData, HunkData, isDelete, isInsert, isNormal} from '../parse';
3 | import {computeLineNumberFactory} from './factory';
4 | import {last} from './util';
5 |
6 | const computeOldLineNumber = computeLineNumberFactory('old');
7 |
8 | const computeNewLineNumber = computeLineNumberFactory('new');
9 |
10 | function getOldRangeFromHunk({oldStart, oldLines}: HunkData) {
11 | return [oldStart, oldStart + oldLines - 1];
12 | }
13 |
14 | interface HunkMayBePlain extends HunkData {
15 | isPlain?: boolean;
16 | }
17 |
18 | function createHunkFromChanges(changes: ChangeData[]): HunkMayBePlain | null {
19 | if (!changes.length) {
20 | return null;
21 | }
22 |
23 | const initial = {
24 | isPlain: true,
25 | content: '',
26 | oldStart: -1,
27 | oldLines: 0,
28 | newStart: -1,
29 | newLines: 0,
30 | };
31 | /* eslint-disable no-param-reassign */
32 | const hunk = changes.reduce(
33 | (hunk, change) => {
34 | if (!isNormal(change)) {
35 | hunk.isPlain = false;
36 | }
37 |
38 | if (!isInsert(change)) {
39 | hunk.oldLines = hunk.oldLines + 1;
40 |
41 | if (hunk.oldStart === -1) {
42 | hunk.oldStart = computeOldLineNumber(change);
43 | }
44 | }
45 |
46 | if (!isDelete(change)) {
47 | hunk.newLines = hunk.newLines + 1;
48 |
49 | if (hunk.newStart === -1) {
50 | hunk.newStart = computeNewLineNumber(change);
51 | }
52 | }
53 |
54 | return hunk;
55 | },
56 | initial
57 | );
58 | /* eslint-enable no-param-reassign */
59 | const {oldStart, oldLines, newStart, newLines} = hunk;
60 |
61 | return {
62 | ...hunk,
63 | content: `@@ -${oldStart},${oldLines} +${newStart},${newLines}`,
64 | changes: changes,
65 | };
66 | }
67 |
68 | export function textLinesToHunk(lines: string[], oldStartLine: number, newStartLine: number): HunkData | null {
69 | const lineToChange = (line: string, i: number): ChangeData => {
70 | return {
71 | type: 'normal',
72 | isNormal: true,
73 | oldLineNumber: oldStartLine + i,
74 | newLineNumber: newStartLine + i,
75 | content: '' + line,
76 | };
77 | };
78 | const changes = lines.map(lineToChange);
79 |
80 | return createHunkFromChanges(changes);
81 | }
82 |
83 | function sliceHunk({changes}: HunkData, oldStartLine: number, oldEndLine?: number): HunkMayBePlain | null {
84 | const changeIndex = changes.findIndex(change => computeOldLineNumber(change) >= oldStartLine);
85 |
86 | if (changeIndex === -1) {
87 | return null;
88 | }
89 |
90 | // It is possible to have some insert changes before `startOldLineNumber`,
91 | // since we slice from old line number, these changes can be ommited, so we need to grab them back
92 | const startIndex = (() => {
93 | if (changeIndex === 0) {
94 | return changeIndex;
95 | }
96 |
97 | const nearestHeadingNocmalChangeIndex = findLastIndex(changes, change => !isInsert(change), changeIndex - 1);
98 | return nearestHeadingNocmalChangeIndex === -1 ? changeIndex : nearestHeadingNocmalChangeIndex + 1;
99 | })();
100 |
101 | if (oldEndLine === undefined) {
102 | const slicedChanges = changes.slice(startIndex);
103 |
104 | return createHunkFromChanges(slicedChanges);
105 | }
106 |
107 | const endIndex = findLastIndex(changes, change => computeOldLineNumber(change) <= oldEndLine);
108 | const slicedChanges = changes.slice(startIndex, endIndex === -1 ? undefined : endIndex);
109 |
110 | return createHunkFromChanges(slicedChanges);
111 | }
112 |
113 | function mergeHunk(previousHunk: HunkMayBePlain | null, nextHunk: HunkMayBePlain | null): HunkData | null {
114 | if (!previousHunk) {
115 | return nextHunk;
116 | }
117 |
118 | if (!nextHunk) {
119 | return previousHunk;
120 | }
121 |
122 | const [previousStart, previousEnd] = getOldRangeFromHunk(previousHunk);
123 | const [nextStart, nextEnd] = getOldRangeFromHunk(nextHunk);
124 |
125 | // They are just neighboring, simply concat changes and adjust lines count
126 | if (previousEnd + 1 === nextStart) {
127 | return createHunkFromChanges([...previousHunk.changes, ...nextHunk.changes]);
128 | }
129 |
130 | // It is possible that `previousHunk` entirely **contains** `nextHunk`,
131 | // and if we are merging a fake hunk with a valid hunk, we need to replace `nextHunk`'s corresponding range
132 | if (previousStart <= nextStart && previousEnd >= nextEnd) {
133 | if (previousHunk.isPlain && !nextHunk.isPlain) {
134 | const head = sliceHunk(previousHunk, previousStart, nextStart);
135 | const tail = sliceHunk(previousHunk, nextEnd + 1);
136 | return mergeHunk(mergeHunk(head, nextHunk), tail);
137 | }
138 |
139 | return previousHunk;
140 | }
141 |
142 | // The 2 hunks have some overlapping, we need to slice the fake one in order to preserve non-normal changes
143 | if (previousHunk.isPlain) {
144 | const head = sliceHunk(previousHunk, previousStart, nextStart);
145 | return mergeHunk(head, nextHunk);
146 | }
147 |
148 | const tail = sliceHunk(nextHunk, previousEnd + 1);
149 | return mergeHunk(previousHunk, tail);
150 | }
151 |
152 | function appendOrMergeHunk(hunks: HunkData[], nextHunk: HunkData): HunkData[] {
153 | const lastHunk = last(hunks);
154 |
155 | if (!lastHunk) {
156 | return [nextHunk];
157 | }
158 |
159 | const expectedNextStart = lastHunk.oldStart + lastHunk.oldLines;
160 | const actualNextStart = nextHunk.oldStart;
161 |
162 | if (expectedNextStart < actualNextStart) {
163 | return hunks.concat(nextHunk);
164 | }
165 |
166 | const mergedHunk = mergeHunk(lastHunk, nextHunk);
167 |
168 | return mergedHunk ? [...hunks.slice(0, -1), mergedHunk] : hunks;
169 | }
170 |
171 | export function insertHunk(hunks: HunkData[], insertion: HunkData): HunkData[] {
172 | const insertionOldLineNumber = computeOldLineNumber(insertion.changes[0]);
173 | const isLaterThanInsertion = ({changes}: HunkData) => {
174 | if (!changes.length) {
175 | return false;
176 | }
177 |
178 | return computeOldLineNumber(changes[0]) >= insertionOldLineNumber;
179 | };
180 | const insertPosition = hunks.findIndex(isLaterThanInsertion);
181 | const hunksWithInsertion = insertPosition === -1
182 | ? hunks.concat(insertion)
183 | : [
184 | ...hunks.slice(0, insertPosition),
185 | insertion,
186 | ...hunks.slice(insertPosition),
187 | ];
188 |
189 | return hunksWithInsertion.reduce(appendOrMergeHunk, []);
190 | }
191 |
--------------------------------------------------------------------------------
/src/utils/diff/expandCollapsedBlockBy.ts:
--------------------------------------------------------------------------------
1 | import {HunkData, isNormal} from '../parse';
2 | import {insertHunk, textLinesToHunk} from './insertHunk';
3 | import {
4 | computeLineNumberFactory,
5 | isInHunkFactory,
6 | isBetweenHunksFactory,
7 | getCorrespondingLineNumberFactory,
8 | } from './factory';
9 | import {first} from './util';
10 |
11 | const getCorrespondingNewLineNumber = getCorrespondingLineNumberFactory('old');
12 |
13 | const computeOldLineNumber = computeLineNumberFactory('old');
14 |
15 | const isOldLineNumberInHunk = isInHunkFactory('oldStart', 'oldLines');
16 |
17 | const isOldLineNumberBetweenHunks = isBetweenHunksFactory('oldStart', 'oldLines');
18 |
19 | function findCorrespondingValidHunkIndex(hunks: HunkData[], oldLineNumber: number): number {
20 | if (!hunks.length) {
21 | return -1;
22 | }
23 |
24 | const firstHunk = first(hunks);
25 | if (oldLineNumber < firstHunk.oldStart || isOldLineNumberInHunk(firstHunk, oldLineNumber)) {
26 | return 0;
27 | }
28 |
29 | for (let i = 1; i < hunks.length; i++) {
30 | const currentHunk = hunks[i];
31 |
32 | if (isOldLineNumberInHunk(currentHunk, oldLineNumber)) {
33 | return i;
34 | }
35 |
36 | const previousHunk = hunks[i - 1];
37 |
38 | if (isOldLineNumberBetweenHunks(previousHunk, currentHunk, oldLineNumber)) {
39 | return i;
40 | }
41 | }
42 |
43 | return -1;
44 | }
45 |
46 | function findNearestNormalChangeIndex({changes}: HunkData, start: number): number {
47 | const index = changes.findIndex(change => computeOldLineNumber(change) === start);
48 |
49 | if (index < 0) {
50 | return -1;
51 | }
52 |
53 | for (let i = index; i < changes.length; i++) {
54 | const change = changes[i];
55 |
56 | if (isNormal(change)) {
57 | return i;
58 | }
59 | }
60 |
61 | return -1;
62 | }
63 |
64 | type Range = [start: number, end: number];
65 |
66 | function splitRangeToValidOnes(hunks: HunkData[], start: number, end: number): Range[] {
67 | const correspondingHunkIndex = findCorrespondingValidHunkIndex(hunks, start);
68 |
69 | // `start` is after all hunks, we believe all left lines are normal.
70 | if (correspondingHunkIndex === -1) {
71 | return [[start, end]];
72 | }
73 |
74 | const correspondingHunk = hunks[correspondingHunkIndex];
75 |
76 | // If `start` points to a line before this hunk, we collect all heading normal changes
77 | if (start < correspondingHunk.oldStart) {
78 | const headingChangesCount = correspondingHunk.changes.findIndex(change => !isNormal(change));
79 | const validEnd = correspondingHunk.oldStart + Math.max(headingChangesCount, 0);
80 |
81 | if (validEnd >= end) {
82 | return [[start, end]];
83 | }
84 |
85 | return [
86 | [start, validEnd],
87 | ...splitRangeToValidOnes(hunks, validEnd + 1, end),
88 | ];
89 | }
90 |
91 | // Now the `correspondingHunk` must be a hunk containing `start`,
92 | // however it is still possible that `start` is not a normal change
93 | const {changes} = correspondingHunk;
94 | const nearestNormalChangeIndex = findNearestNormalChangeIndex(correspondingHunk, start);
95 |
96 | // If there is no normal changes after `start`, splitting ends up here
97 | if (nearestNormalChangeIndex === -1) {
98 | return [];
99 | }
100 |
101 | const validStartChange = changes[nearestNormalChangeIndex];
102 | const validStart = computeOldLineNumber(validStartChange);
103 | // Iterate to `end`, if `end` falls out of hunk, we can split it to 2 ranges
104 | const adjacentChangesCount = changes.slice(nearestNormalChangeIndex + 1).findIndex(change => !isNormal(change));
105 | const validEnd = computeOldLineNumber(validStartChange) + Math.max(adjacentChangesCount, 0);
106 |
107 | if (validEnd >= end) {
108 | return [[validStart, end]];
109 | }
110 |
111 | return [
112 | [validStart, validEnd],
113 | ...splitRangeToValidOnes(hunks, validEnd + 1, end),
114 | ];
115 | }
116 |
117 | export type Source = string | string[];
118 |
119 | function expandCodeByValidRange(hunks: HunkData[], source: Source, [start, end]: Range): HunkData[] {
120 | // Note `end` is not inclusive, this is the same as `Array.prototype.slice` method
121 | const linesOfCode = typeof source === 'string' ? source.split('\n') : source;
122 | const slicedLines = linesOfCode.slice(Math.max(start, 1) - 1, end - 1);
123 |
124 | if (!slicedLines.length) {
125 | return hunks;
126 | }
127 |
128 | const slicedHunk = textLinesToHunk(slicedLines, start, getCorrespondingNewLineNumber(hunks, start));
129 | return slicedHunk ? insertHunk(hunks, slicedHunk) : hunks;
130 | }
131 |
132 | export function expandFromRawCode(hunks: HunkData[], source: Source, start: number, end: number): HunkData[] {
133 | // It is possible to have some insert or delete changes between `start` and `end`,
134 | // in order to be 100% safe, we need to split the range to one or more ranges which contains only normal changes.
135 | //
136 | // For each `start` line number, we can either:
137 | //
138 | // 1. Find a change and adjust to a nearest normal one.
139 | // 2. Find no corresponding change so it must be a collapsed normal change.
140 | //
141 | // For both cases we can have a starting normal change, then we iterate over its subsequent changes
142 | // (line numbers with no corresponding change is considered a normal one)
143 | // until an insert or delete is encountered, this is a **valid range**.
144 | //
145 | // After one valid range is resolved, discard all line numbers related to delete changes, the next normal change
146 | // is the start of next valid range.
147 | const validRanges = splitRangeToValidOnes(hunks, start, end);
148 |
149 | return validRanges.reduce((hunks, range) => expandCodeByValidRange(hunks, source, range), hunks);
150 | }
151 |
152 | export function getCollapsedLinesCountBetween(previousHunk: HunkData | null, nextHunk: HunkData): number {
153 | if (!previousHunk) {
154 | return nextHunk.oldStart - 1;
155 | }
156 |
157 | const previousEnd = previousHunk.oldStart + previousHunk.oldLines;
158 | const nextStart = nextHunk.oldStart;
159 |
160 | return nextStart - previousEnd;
161 | }
162 |
163 | type HunkPredicate = (lines: number, oldStart: number, newStart: number) => boolean;
164 |
165 | export function expandCollapsedBlockBy(hunks: HunkData[], source: Source, predicate: HunkPredicate): HunkData[] {
166 | const linesOfCode = typeof source === 'string' ? source.split('\n') : source;
167 | const firstHunk = first(hunks);
168 | const initialExpandingBlocks = predicate(firstHunk.oldStart - 1, 1, 1) ? [[1, firstHunk.oldStart]] : [];
169 |
170 | const expandingBlocks = hunks.reduce(
171 | (expandingBlocks, currentHunk, index, hunks) => {
172 | const nextHunk = hunks[index + 1];
173 | const oldStart = currentHunk.oldStart + currentHunk.oldLines;
174 | const newStart = currentHunk.newStart + currentHunk.newLines;
175 | const lines = nextHunk
176 | ? getCollapsedLinesCountBetween(currentHunk, nextHunk)
177 | : linesOfCode.length - oldStart + 1;
178 | const shouldExpand = predicate(lines, oldStart, newStart);
179 |
180 | if (shouldExpand) {
181 | // initialExpandingBlocks is scoped, it is redundant to copy the array
182 | expandingBlocks.push([oldStart, oldStart + lines]);
183 | }
184 | return expandingBlocks;
185 | },
186 | initialExpandingBlocks
187 | );
188 |
189 | return expandingBlocks.reduce((hunks, [start, end]) => expandFromRawCode(hunks, linesOfCode, start, end), hunks);
190 | }
191 |
--------------------------------------------------------------------------------
/src/Diff/index.tsx:
--------------------------------------------------------------------------------
1 | import {memo, useRef, useCallback, ReactElement, MouseEvent, useMemo, ReactNode} from 'react';
2 | import classNames from 'classnames';
3 | import {
4 | ContextProps,
5 | EventMap,
6 | GutterType,
7 | Provider,
8 | ViewType,
9 | RenderToken,
10 | RenderGutter,
11 | DEFAULT_CONTEXT_VALUE,
12 | } from '../context';
13 | import Hunk from '../Hunk';
14 | import {ChangeData, HunkData} from '../utils';
15 | import {HunkTokens} from '../tokenize';
16 |
17 | export type DiffType = 'add' | 'delete' | 'modify' | 'rename' | 'copy';
18 |
19 | export interface DiffProps {
20 | diffType: DiffType;
21 | hunks: HunkData[];
22 | viewType?: ViewType;
23 | gutterType?: GutterType;
24 | generateAnchorID?: (change: ChangeData) => string | undefined;
25 | selectedChanges?: string[];
26 | widgets?: Record;
27 | optimizeSelection?: boolean;
28 | className?: string;
29 | hunkClassName?: string;
30 | lineClassName?: string;
31 | generateLineClassName?: (params: {changes: ChangeData[], defaultGenerate: () => string}) => string | undefined;
32 | gutterClassName?: string;
33 | codeClassName?: string;
34 | tokens?: HunkTokens | null;
35 | renderToken?: RenderToken;
36 | renderGutter?: RenderGutter;
37 | gutterEvents?: EventMap;
38 | codeEvents?: EventMap;
39 | children?: (hunks: HunkData[]) => ReactElement | ReactElement[];
40 | }
41 |
42 | function noop() {}
43 |
44 | function findClosest(target: HTMLElement, className: string) {
45 | let current: HTMLElement | null = target;
46 | while (current && current !== document.documentElement && !current.classList.contains(className)) {
47 | current = current.parentElement;
48 | }
49 |
50 | return current === document.documentElement ? null : current;
51 | }
52 |
53 | function setUserSelectStyle(element: Element, selectable: boolean) {
54 | const value = selectable ? 'auto' : 'none';
55 |
56 | if (element instanceof HTMLElement && element.style.userSelect !== value) {
57 | element.style.userSelect = value; // eslint-disable-line no-param-reassign
58 | }
59 | }
60 |
61 | function defaultRenderChildren(hunks: HunkData[]) {
62 | const key = (hunk: HunkData) => `-${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines}`;
63 | return hunks.map(hunk => );
64 | }
65 |
66 | function Diff(props: DiffProps) {
67 | const {
68 | diffType,
69 | hunks,
70 | optimizeSelection,
71 | className,
72 | hunkClassName = DEFAULT_CONTEXT_VALUE.hunkClassName,
73 | lineClassName = DEFAULT_CONTEXT_VALUE.lineClassName,
74 | generateLineClassName = DEFAULT_CONTEXT_VALUE.generateLineClassName,
75 | gutterClassName = DEFAULT_CONTEXT_VALUE.gutterClassName,
76 | codeClassName = DEFAULT_CONTEXT_VALUE.codeClassName,
77 | gutterType = DEFAULT_CONTEXT_VALUE.gutterType,
78 | viewType = DEFAULT_CONTEXT_VALUE.viewType,
79 | gutterEvents = DEFAULT_CONTEXT_VALUE.gutterEvents,
80 | codeEvents = DEFAULT_CONTEXT_VALUE.codeEvents,
81 | generateAnchorID = DEFAULT_CONTEXT_VALUE.generateAnchorID,
82 | selectedChanges = DEFAULT_CONTEXT_VALUE.selectedChanges,
83 | widgets = DEFAULT_CONTEXT_VALUE.widgets,
84 | renderGutter = DEFAULT_CONTEXT_VALUE.renderGutter,
85 | tokens,
86 | renderToken,
87 | children = defaultRenderChildren,
88 | } = props;
89 | const root = useRef(null);
90 | const enableColumnSelection = useCallback(
91 | ({target, button}: MouseEvent) => {
92 | if (button !== 0) {
93 | return;
94 | }
95 |
96 | const closestCell = findClosest(target as HTMLElement, 'diff-code');
97 |
98 | if (!closestCell || !closestCell.parentElement) {
99 | return;
100 | }
101 |
102 | const selection = window.getSelection();
103 | if (selection) {
104 | selection.removeAllRanges();
105 | }
106 |
107 | const index = [...closestCell.parentElement.children].indexOf(closestCell);
108 |
109 | if (index !== 1 && index !== 3) {
110 | return;
111 | }
112 |
113 | const lines = root.current ? root.current.querySelectorAll('.diff-line') : [];
114 | for (const line of lines) {
115 | const cells = line.children;
116 | setUserSelectStyle(cells[1], index === 1);
117 | setUserSelectStyle(cells[3], index === 3);
118 | }
119 | },
120 | []
121 | );
122 | const hideGutter = gutterType === 'none';
123 | const monotonous = diffType === 'add' || diffType === 'delete';
124 | const onTableMouseDown = (viewType === 'split' && !monotonous && optimizeSelection) ? enableColumnSelection : noop;
125 | const cols = useMemo(
126 | () => {
127 | if (viewType === 'unified') {
128 | return (
129 |
130 | {!hideGutter && }
131 | {!hideGutter && }
132 |
133 |
134 | );
135 | }
136 |
137 | if (monotonous) {
138 | return (
139 |
140 | {!hideGutter && }
141 |
142 |
143 | );
144 | }
145 |
146 | return (
147 |
148 | {!hideGutter && }
149 |
150 | {!hideGutter && }
151 |
152 |
153 | );
154 | },
155 | [viewType, monotonous, hideGutter]
156 | );
157 | // TODO: in later versions, we can split context into multiple to reduce component render
158 | const settingsContextValue = useMemo(
159 | (): ContextProps => {
160 | return {
161 | hunkClassName,
162 | lineClassName,
163 | generateLineClassName,
164 | gutterClassName,
165 | codeClassName,
166 | monotonous,
167 | hideGutter,
168 | viewType,
169 | gutterType,
170 | codeEvents,
171 | gutterEvents,
172 | generateAnchorID,
173 | selectedChanges,
174 | widgets,
175 | renderGutter,
176 | tokens,
177 | renderToken,
178 | };
179 | },
180 | [
181 | codeClassName,
182 | codeEvents,
183 | generateAnchorID,
184 | gutterClassName,
185 | gutterEvents,
186 | gutterType,
187 | hideGutter,
188 | hunkClassName,
189 | lineClassName,
190 | generateLineClassName,
191 | monotonous,
192 | renderGutter,
193 | renderToken,
194 | selectedChanges,
195 | tokens,
196 | viewType,
197 | widgets,
198 | ]
199 | );
200 |
201 | return (
202 |
203 |
208 | {cols}
209 | {children(hunks)}
210 |
211 |
212 | );
213 | }
214 |
215 | export default memo(Diff);
216 |
--------------------------------------------------------------------------------
/src/Hunk/SplitHunk/SplitChange.tsx:
--------------------------------------------------------------------------------
1 | import {memo, useState, useMemo, useCallback} from 'react';
2 | import classNames from 'classnames';
3 | import {mapValues} from 'lodash';
4 | import {ChangeData, getChangeKey} from '../../utils';
5 | import {TokenNode} from '../../tokenize';
6 | import {Side} from '../../interface';
7 | import {RenderToken, RenderGutter, GutterOptions, EventMap, NativeEventMap} from '../../context';
8 | import {ChangeSharedProps} from '../interface';
9 | import CodeCell from '../CodeCell';
10 | import {composeCallback, renderDefaultBy, wrapInAnchorBy} from '../utils';
11 |
12 | const SIDE_OLD = 0;
13 | const SIDE_NEW = 1;
14 |
15 | type SetHover = (side: Side | '') => void;
16 |
17 | function useCallbackOnSide(side: Side, setHover: SetHover, change: ChangeData | null, customCallbacks: EventMap) {
18 | const markHover = useCallback(() => setHover(side), [side, setHover]);
19 | const unmarkHover = useCallback(() => setHover(''), [setHover]);
20 | // Unlike selectors, hooks do not provide native functionality to customize comparator,
21 | // on realizing that this does not reduce amount of renders, only preventing duplicate merge computations,
22 | // we decide not to optimize this extremely, leave it recomputed on certain rerenders.
23 | const callbacks = useMemo(
24 | () => {
25 | const callbacks: NativeEventMap = mapValues(customCallbacks, fn => (e: any) => fn && fn({side, change}, e));
26 | callbacks.onMouseEnter = composeCallback(markHover, callbacks.onMouseEnter);
27 | callbacks.onMouseLeave = composeCallback(unmarkHover, callbacks.onMouseLeave);
28 | return callbacks;
29 | },
30 | [change, customCallbacks, markHover, side, unmarkHover]
31 | );
32 | return callbacks;
33 | }
34 |
35 | interface RenderCellArgs {
36 | change: ChangeData | null;
37 | side: typeof SIDE_OLD | typeof SIDE_NEW;
38 | selected: boolean;
39 | tokens: TokenNode[] | null;
40 | gutterClassName: string;
41 | codeClassName: string;
42 | gutterEvents: NativeEventMap;
43 | codeEvents: NativeEventMap;
44 | anchorID: string | null | undefined;
45 | gutterAnchor: boolean;
46 | gutterAnchorTarget: string | null | undefined;
47 | hideGutter: boolean;
48 | hover: boolean;
49 | renderToken: RenderToken | undefined;
50 | renderGutter: RenderGutter;
51 | }
52 |
53 | function renderCells(args: RenderCellArgs) {
54 | const {
55 | change,
56 | side,
57 | selected,
58 | tokens,
59 | gutterClassName,
60 | codeClassName,
61 | gutterEvents,
62 | codeEvents,
63 | anchorID,
64 | gutterAnchor,
65 | gutterAnchorTarget,
66 | hideGutter,
67 | hover,
68 | renderToken,
69 | renderGutter,
70 | } = args;
71 |
72 | if (!change) {
73 | const gutterClassNameValue = classNames('diff-gutter', 'diff-gutter-omit', gutterClassName);
74 | const codeClassNameValue = classNames('diff-code', 'diff-code-omit', codeClassName);
75 |
76 | return [
77 | !hideGutter && | ,
78 | | ,
79 | ];
80 | }
81 |
82 | const {type, content} = change;
83 | const changeKey = getChangeKey(change);
84 | const sideName = side === SIDE_OLD ? 'old' : 'new';
85 | const gutterClassNameValue = classNames(
86 | 'diff-gutter',
87 | `diff-gutter-${type}`,
88 | {
89 | 'diff-gutter-selected': selected,
90 | ['diff-line-hover-' + sideName]: hover,
91 | },
92 | gutterClassName
93 | );
94 | const gutterOptions: GutterOptions = {
95 | change,
96 | side: sideName,
97 | inHoverState: hover,
98 | renderDefault: renderDefaultBy(change, sideName),
99 | wrapInAnchor: wrapInAnchorBy(gutterAnchor, gutterAnchorTarget),
100 | };
101 | const gutterProps = {
102 | id: anchorID || undefined,
103 | className: gutterClassNameValue,
104 | children: renderGutter(gutterOptions),
105 | ...gutterEvents,
106 | };
107 | const codeClassNameValue = classNames(
108 | 'diff-code',
109 | `diff-code-${type}`,
110 | {
111 | 'diff-code-selected': selected,
112 | ['diff-line-hover-' + sideName]: hover,
113 | },
114 | codeClassName
115 | );
116 |
117 | return [
118 | !hideGutter && | ,
119 | ,
128 | ];
129 | }
130 |
131 | interface SplitChangeProps extends ChangeSharedProps {
132 | className: string;
133 | oldChange: ChangeData | null;
134 | newChange: ChangeData | null;
135 | oldSelected: boolean;
136 | newSelected: boolean;
137 | oldTokens: TokenNode[] | null;
138 | newTokens: TokenNode[] | null;
139 | monotonous: boolean;
140 | }
141 |
142 | function SplitChange(props: SplitChangeProps) {
143 | const {
144 | className,
145 | oldChange,
146 | newChange,
147 | oldSelected,
148 | newSelected,
149 | oldTokens,
150 | newTokens,
151 | monotonous,
152 | gutterClassName,
153 | codeClassName,
154 | gutterEvents,
155 | codeEvents,
156 | hideGutter,
157 | generateAnchorID,
158 | generateLineClassName,
159 | gutterAnchor,
160 | renderToken,
161 | renderGutter,
162 | } = props;
163 |
164 | const [hover, setHover] = useState('');
165 | const oldGutterEvents = useCallbackOnSide('old', setHover, oldChange, gutterEvents);
166 | const newGutterEvents = useCallbackOnSide('new', setHover, newChange, gutterEvents);
167 | const oldCodeEvents = useCallbackOnSide('old', setHover, oldChange, codeEvents);
168 | const newCodeEvents = useCallbackOnSide('new', setHover, newChange, codeEvents);
169 | const oldAnchorID = oldChange && generateAnchorID(oldChange);
170 | const newAnchorID = newChange && generateAnchorID(newChange);
171 |
172 | const lineClassName = generateLineClassName({
173 | changes: [oldChange!, newChange!],
174 | defaultGenerate: () => className,
175 | });
176 |
177 | const commons = {
178 | monotonous,
179 | hideGutter,
180 | gutterClassName,
181 | codeClassName,
182 | gutterEvents,
183 | codeEvents,
184 | renderToken,
185 | renderGutter,
186 | };
187 | const oldArgs: RenderCellArgs = {
188 | ...commons,
189 | change: oldChange,
190 | side: SIDE_OLD,
191 | selected: oldSelected,
192 | tokens: oldTokens,
193 | gutterEvents: oldGutterEvents,
194 | codeEvents: oldCodeEvents,
195 | anchorID: oldAnchorID,
196 | gutterAnchor: gutterAnchor,
197 | gutterAnchorTarget: oldAnchorID,
198 | hover: hover === 'old',
199 | };
200 | const newArgs: RenderCellArgs = {
201 | ...commons,
202 | change: newChange,
203 | side: SIDE_NEW,
204 | selected: newSelected,
205 | tokens: newTokens,
206 | gutterEvents: newGutterEvents,
207 | codeEvents: newCodeEvents,
208 | anchorID: oldChange === newChange ? null : newAnchorID,
209 | gutterAnchor: gutterAnchor,
210 | gutterAnchorTarget: oldChange === newChange ? oldAnchorID : newAnchorID,
211 | hover: hover === 'new',
212 | };
213 |
214 | if (monotonous) {
215 | return (
216 |
217 | {renderCells(oldChange ? oldArgs : newArgs)}
218 |
219 | );
220 | }
221 |
222 | const lineTypeClassName = ((oldChange, newChange) => {
223 | if (oldChange && !newChange) {
224 | return 'diff-line-old-only';
225 | }
226 |
227 | if (!oldChange && newChange) {
228 | return 'diff-line-new-only';
229 | }
230 |
231 | if (oldChange === newChange) {
232 | return 'diff-line-normal';
233 | }
234 |
235 | return 'diff-line-compare';
236 | })(oldChange, newChange);
237 |
238 | return (
239 |
240 | {renderCells(oldArgs)}
241 | {renderCells(newArgs)}
242 |
243 | );
244 | }
245 |
246 | export default memo(SplitChange);
247 |
--------------------------------------------------------------------------------