├── .eslintignore ├── src ├── interface │ └── index.ts ├── hocs │ ├── index.ts │ ├── wrapDisplayName.ts │ ├── minCollapsedLines.tsx │ ├── withSourceExpansion.tsx │ ├── withChangeSelect.tsx │ └── withTokenizeWorker.tsx ├── Decoration │ ├── interface.ts │ ├── UnifiedDecoration.tsx │ ├── SplitDecoration.tsx │ └── index.tsx ├── utils │ ├── diff │ │ ├── util.ts │ │ ├── getChangeKey.ts │ │ ├── index.ts │ │ ├── factory.ts │ │ ├── insertHunk.ts │ │ └── expandCollapsedBlockBy.ts │ ├── index.ts │ ├── __test__ │ │ ├── parse.test.ts │ │ ├── diff-factory.test.ts │ │ ├── diff.test.ts │ │ └── __snapshots__ │ │ │ └── diff.test.ts.snap │ └── parse.ts ├── hooks │ ├── index.ts │ ├── useMinCollapsedLines.ts │ ├── useChangeSelect.ts │ ├── useSourceExpansion.ts │ ├── helpers.ts │ └── useTokenizeWorker.ts ├── Hunk │ ├── UnifiedHunk │ │ ├── UnifiedWidget.tsx │ │ ├── index.tsx │ │ └── UnifiedChange.tsx │ ├── index.tsx │ ├── interface.ts │ ├── utils.tsx │ ├── SplitHunk │ │ ├── SplitWidget.tsx │ │ ├── index.tsx │ │ └── SplitChange.tsx │ └── CodeCell.tsx ├── tokenize │ ├── interface.ts │ ├── markWord.ts │ ├── __test__ │ │ ├── toTokenTrees.test.ts │ │ ├── markWord.test.ts │ │ └── tokenize.test.ts │ ├── index.ts │ ├── normalizeToLines.ts │ ├── utils.ts │ ├── pickRanges.ts │ ├── backToTree.ts │ ├── toTokenTrees.ts │ └── markEdits.ts ├── index.ts ├── context │ └── index.ts ├── Diff │ ├── __test__ │ │ └── Diff.test.tsx │ └── index.tsx └── styles │ └── index.css ├── site ├── components │ ├── App │ │ ├── index.less │ │ ├── app.global.less │ │ └── index.tsx │ ├── DiffView │ │ ├── Unfold.less │ │ ├── syntax.global.less │ │ ├── HunkInfo.tsx │ │ ├── Unfold.tsx │ │ ├── CommentTrigger.tsx │ │ ├── Tokenize.ts │ │ ├── Comment │ │ │ ├── index.tsx │ │ │ ├── Display.tsx │ │ │ └── Editor.tsx │ │ ├── UnfoldCollapsed.tsx │ │ └── diff.global.less │ ├── Configuration │ │ ├── index.less │ │ ├── OptionsModal.less │ │ ├── index.tsx │ │ └── OptionsModal.tsx │ ├── InputArea │ │ ├── DiffSource.less │ │ ├── SubmitButton.tsx │ │ ├── SubmitButton.less │ │ ├── TextInput.less │ │ ├── TextInput.tsx │ │ ├── index.tsx │ │ ├── DiffText.less │ │ ├── DiffSource.tsx │ │ ├── DiffText.tsx │ │ ├── preset.src │ │ └── preset.diff │ └── InteractiveLabel │ │ └── index.tsx ├── interface │ ├── client.d.ts │ └── unidiff.d.ts ├── entries │ └── index.js ├── hooks │ └── selection.ts └── context │ └── configuration.tsx ├── .travis.yml ├── .vscode └── settings.json ├── .husky ├── pre-push └── pre-commit ├── screenshots ├── split-view.png ├── sequence-zip.png ├── unified-view.png ├── sequence-normal.png └── single-side-selection.png ├── .yarnrc.yml ├── tsconfig.test.json ├── scripts ├── fix-css.js └── build.sh ├── tsconfig.build.json ├── postcss.config.js ├── tsconfig.types.json ├── .eslintrc.cjs ├── reskript.config.ts ├── .babelrc ├── .github └── stale.yml ├── tsconfig.json ├── rollup.mjs ├── LICENSE ├── .gitignore ├── package.json └── CHANGELOG.md /.eslintignore: -------------------------------------------------------------------------------- 1 | demo/externals 2 | demo/assets 3 | demo/dist 4 | -------------------------------------------------------------------------------- /src/interface/index.ts: -------------------------------------------------------------------------------- 1 | export type Side = 'old' | 'new'; 2 | -------------------------------------------------------------------------------- /site/components/App/index.less: -------------------------------------------------------------------------------- 1 | .root { 2 | margin: 20px 20px; 3 | } 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - node 4 | script: 5 | - npm test 6 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | } 4 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npm run ci 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npm run lint 5 | -------------------------------------------------------------------------------- /screenshots/split-view.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/otakustay/react-diff-view/HEAD/screenshots/split-view.png -------------------------------------------------------------------------------- /screenshots/sequence-zip.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/otakustay/react-diff-view/HEAD/screenshots/sequence-zip.png -------------------------------------------------------------------------------- /screenshots/unified-view.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/otakustay/react-diff-view/HEAD/screenshots/unified-view.png -------------------------------------------------------------------------------- /screenshots/sequence-normal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/otakustay/react-diff-view/HEAD/screenshots/sequence-normal.png -------------------------------------------------------------------------------- /screenshots/single-side-selection.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/otakustay/react-diff-view/HEAD/screenshots/single-side-selection.png -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | compressionLevel: mixed 2 | 3 | enableGlobalCache: true 4 | 5 | nmMode: hardlinks-global 6 | 7 | nodeLinker: node-modules 8 | -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "CommonJS", 5 | "allowJs": true 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /scripts/fix-css.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | 3 | const content = fs.readFileSync(process.argv[2], 'utf-8'); 4 | fs.writeFileSync(process.argv[2], content.replace(/\}/g, ';}')); 5 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.types.json", 3 | "compilerOptions": { 4 | "outDir": "esm", 5 | "emitDeclarationOnly": false 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /site/components/DiffView/Unfold.less: -------------------------------------------------------------------------------- 1 | .root { 2 | text-align: center; 3 | cursor: pointer; 4 | 5 | :hover { 6 | background-color: var(--background-color-secondary); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /site/interface/client.d.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line spaced-comment 2 | /// 3 | 4 | declare module '*?raw' { 5 | declare const text: string; 6 | export default text; 7 | } 8 | -------------------------------------------------------------------------------- /scripts/build.sh: -------------------------------------------------------------------------------- 1 | npm run clean 2 | export NODE_ENV=production 3 | node rollup.mjs 4 | node node_modules/.bin/postcss -o style/index.css src/styles/index.css 5 | node scripts/fix-css.js style/index.css 6 | tsc -p tsconfig.types.json 7 | tsc -p tsconfig.build.json 8 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/unambiguous, import/no-commonjs, global-require */ 2 | module.exports = { 3 | plugins: [ 4 | require('autoprefixer'), 5 | require('postcss-custom-properties')(), 6 | require('cssnano')(), 7 | ], 8 | }; 9 | -------------------------------------------------------------------------------- /src/hocs/index.ts: -------------------------------------------------------------------------------- 1 | export {default as withSourceExpansion} from './withSourceExpansion'; 2 | export {default as minCollapsedLines} from './minCollapsedLines'; 3 | export {default as withChangeSelect} from './withChangeSelect'; 4 | export {default as withTokenizeWorker} from './withTokenizeWorker'; 5 | -------------------------------------------------------------------------------- /site/interface/unidiff.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'unidiff' { 2 | export interface FormatOptions { 3 | context?: number; 4 | } 5 | 6 | export function diffLines(x: string, y: string): string[]; 7 | 8 | export function formatLines(line: string[], options?: FormatOptions): string; 9 | } 10 | -------------------------------------------------------------------------------- /src/Decoration/interface.ts: -------------------------------------------------------------------------------- 1 | import {ReactNode} from 'react'; 2 | 3 | export interface ActualDecorationProps { 4 | hideGutter: boolean; 5 | monotonous: boolean; 6 | className: string; 7 | gutterClassName: string; 8 | contentClassName: string; 9 | children: ReactNode | [ReactNode, ReactNode]; 10 | } 11 | -------------------------------------------------------------------------------- /site/components/Configuration/index.less: -------------------------------------------------------------------------------- 1 | .root { 2 | display: flex; 3 | margin-bottom: 10px; 4 | flex-direction: row-reverse; 5 | align-items: center; 6 | } 7 | 8 | .setting { 9 | font-size: 20px; 10 | margin-left: 10px; 11 | cursor: pointer; 12 | } 13 | 14 | .language { 15 | width: 180px; 16 | } 17 | -------------------------------------------------------------------------------- /tsconfig.types.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "src", 5 | "outDir": "types", 6 | "noEmit": false, 7 | "declaration": true, 8 | "emitDeclarationOnly": true 9 | }, 10 | "include": ["src"], 11 | "exclude": ["**/__tests__"] 12 | } 13 | -------------------------------------------------------------------------------- /site/components/Configuration/OptionsModal.less: -------------------------------------------------------------------------------- 1 | .row { 2 | padding: 8px 0; 3 | border-bottom: 1px solid #ccc; 4 | } 5 | 6 | .field { 7 | display: flex; 8 | align-items: center; 9 | } 10 | 11 | .tooltip { 12 | color: #999; 13 | font-size: 12px; 14 | margin-top: 8px; 15 | } 16 | 17 | .row-title { 18 | flex: 1; 19 | } 20 | -------------------------------------------------------------------------------- /src/utils/diff/util.ts: -------------------------------------------------------------------------------- 1 | import {Side} from '../../interface'; 2 | 3 | export function first(array: T[]) { 4 | return array[0]; 5 | } 6 | 7 | export function last(array: T[]) { 8 | return array[array.length - 1]; 9 | } 10 | 11 | export function sideToProperty(side: Side) { 12 | return [`${side}Start`, `${side}Lines`] as const; 13 | } 14 | -------------------------------------------------------------------------------- /site/components/DiffView/syntax.global.less: -------------------------------------------------------------------------------- 1 | @import (inline) "../../../node_modules/prism-color-variables/variables.css"; 2 | @import (inline) "../../../node_modules/prism-color-variables/themes/visual-studio.css"; 3 | 4 | // @media (prefer-color-scheme: dark) { 5 | // @import (inline) "../../../node_modules/prism-color-variables/themes/duracula.css"; 6 | // } 7 | -------------------------------------------------------------------------------- /site/components/InputArea/DiffSource.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 | .input { 13 | display: flex; 14 | justify-content: space-between; 15 | } 16 | 17 | .input-text { 18 | width: 48%; 19 | } 20 | -------------------------------------------------------------------------------- /site/entries/index.js: -------------------------------------------------------------------------------- 1 | import 'core-js/stable'; 2 | import {createRoot} from 'react-dom/client'; 3 | import App from '../components/App'; 4 | import {StrictMode} from 'react'; 5 | 6 | 7 | const root = createRoot(document.body.appendChild(document.createElement('div'))); 8 | root.render( 9 | 10 | 11 | 12 | ); 13 | -------------------------------------------------------------------------------- /site/components/InputArea/SubmitButton.tsx: -------------------------------------------------------------------------------- 1 | import styles from './SubmitButton.less'; 2 | 3 | interface Props { 4 | onClick: () => void; 5 | } 6 | 7 | export default function SubmitButton({onClick}: Props) { 8 | return ( 9 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | '@ecomfe/eslint-config/strict', 4 | '@ecomfe/eslint-config/react/strict', 5 | '@ecomfe/eslint-config/typescript/strict', 6 | ], 7 | settings: { 8 | react: { 9 | version: 'detect', 10 | }, 11 | }, 12 | rules: { 13 | 'consistent-return': 'off', 14 | }, 15 | }; 16 | -------------------------------------------------------------------------------- /site/components/DiffView/HunkInfo.tsx: -------------------------------------------------------------------------------- 1 | import {Decoration, DecorationProps, HunkData} from 'react-diff-view'; 2 | 3 | interface Props extends Omit { 4 | hunk: HunkData; 5 | } 6 | 7 | export default function HunkInfo({hunk, ...props}: Props) { 8 | return ( 9 | 10 | {null} 11 | {hunk.content} 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /src/utils/diff/getChangeKey.ts: -------------------------------------------------------------------------------- 1 | import {ChangeData, isNormal, isInsert} from '../parse'; 2 | 3 | export function getChangeKey(change: ChangeData) { 4 | if (!change) { 5 | throw new Error('change is not provided'); 6 | } 7 | 8 | if (isNormal(change)) { 9 | return `N${change.oldLineNumber}`; 10 | } 11 | 12 | const prefix = isInsert(change) ? 'I' : 'D'; 13 | return `${prefix}${change.lineNumber}`; 14 | } 15 | -------------------------------------------------------------------------------- /src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export {default as useMinCollapsedLines} from './useMinCollapsedLines'; 2 | export {default as useChangeSelect} from './useChangeSelect'; 3 | export {default as useSourceExpansion} from './useSourceExpansion'; 4 | export {default as useTokenizeWorker} from './useTokenizeWorker'; 5 | export type {UseChangeSelectOptions} from './useChangeSelect'; 6 | export type {TokenizePayload, ShouldTokenize, TokenizeWorkerOptions, TokenizeResult} from './useTokenizeWorker'; 7 | -------------------------------------------------------------------------------- /site/components/InputArea/SubmitButton.less: -------------------------------------------------------------------------------- 1 | .root { 2 | display: block; 3 | margin-top: 10px; 4 | width: 100%; 5 | line-height: 2.5; 6 | font-size: 16px; 7 | font-weight: bold; 8 | color: var(--background-color-pure); 9 | border: none; 10 | background-color: #239956; 11 | cursor: pointer; 12 | outline: none; 13 | transition: background-color linear .3s; 14 | 15 | :hover { 16 | background-color: #1d8449; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /reskript.config.ts: -------------------------------------------------------------------------------- 1 | import {configure} from '@reskript/settings'; 2 | 3 | export default configure( 4 | 'webpack', 5 | { 6 | build: { 7 | appTitle: 'Online Diff', 8 | publicPath: '/react-diff-view/assets/', 9 | style: { 10 | modules: resource => resource.endsWith('.less') && !resource.includes('node_modules'), 11 | }, 12 | }, 13 | devServer: { 14 | port: 9031, 15 | }, 16 | } 17 | ); 18 | -------------------------------------------------------------------------------- /src/Hunk/UnifiedHunk/UnifiedWidget.tsx: -------------------------------------------------------------------------------- 1 | import {ReactNode} from 'react'; 2 | 3 | export interface UnifiedWidgetProps { 4 | hideGutter: boolean; 5 | element: ReactNode; 6 | } 7 | 8 | export default function UnifiedWidget({hideGutter, element}: UnifiedWidgetProps) { 9 | return ( 10 | 11 | 12 | {element} 13 | 14 | 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /src/tokenize/interface.ts: -------------------------------------------------------------------------------- 1 | export interface TokenNode { 2 | [key: string]: any; 3 | type: string; 4 | children?: TokenNode[]; 5 | } 6 | 7 | export interface TextNode extends TokenNode { 8 | type: 'text'; 9 | value: string; 10 | } 11 | 12 | export interface ProcessingNode { 13 | [key: string]: any; 14 | type: string; 15 | } 16 | 17 | export type TokenPath = ProcessingNode[]; 18 | 19 | export type Pair = [oldSide: T, newSide: T]; 20 | 21 | export type TokenizeEnhancer = (input: Pair) => Pair; 22 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "modules": false 7 | } 8 | ], 9 | "@babel/preset-react" 10 | ], 11 | "plugins": [ 12 | "lodash", 13 | "add-react-displayname", 14 | "@babel/plugin-proposal-class-properties" 15 | ], 16 | "env": { 17 | "test": { 18 | "presets": [ 19 | "@babel/preset-env", 20 | "@babel/preset-react" 21 | ] 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /site/components/App/app.global.less: -------------------------------------------------------------------------------- 1 | :root { 2 | --background-color-app: #fff; 3 | --background-color-base: #f5f5f5; 4 | --background-color-secondary: #fafafa; 5 | --background-color-pure: #fff; 6 | --border-color: #e8e8e8; 7 | --heading-color: #252525; 8 | --link-text-color: #1890ff; 9 | --link-text-hover-color: #40a9ff; 10 | --link-text-active-color: #096dd9; 11 | --disabled-text-color: #d9d9d9; 12 | } 13 | 14 | html, 15 | body { 16 | background-color: var(--background-color-app); 17 | } 18 | 19 | * { 20 | box-sizing: border-box; 21 | } 22 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | computeNewLineNumber, 3 | computeOldLineNumber, 4 | expandCollapsedBlockBy, 5 | expandFromRawCode, 6 | findChangeByNewLineNumber, 7 | findChangeByOldLineNumber, 8 | getChangeKey, 9 | getCollapsedLinesCountBetween, 10 | getCorrespondingNewLineNumber, 11 | getCorrespondingOldLineNumber, 12 | insertHunk, 13 | textLinesToHunk, 14 | } from './diff'; 15 | export {parseDiff, isInsert, isDelete, isNormal} from './parse'; 16 | export type {Source} from './diff'; 17 | export type {ParseOptions, FileData, HunkData, ChangeData} from './parse'; 18 | -------------------------------------------------------------------------------- /site/components/InputArea/TextInput.less: -------------------------------------------------------------------------------- 1 | .title { 2 | margin: 0; 3 | font-size: 16px; 4 | line-height: 30px; 5 | border: 1px solid var(--border-color); 6 | border-bottom: none; 7 | background-color: var(--background-color-secondary); 8 | color: var(--heading-color); 9 | text-align: center; 10 | font-weight: bold; 11 | } 12 | 13 | .input { 14 | display: block; 15 | width: 100%; 16 | border: 1px solid var(--border-color); 17 | font-family: monospace; 18 | background-color: var(--background-color-pure); 19 | resize: none; 20 | outline: none; 21 | height: 200px; 22 | } 23 | -------------------------------------------------------------------------------- /src/hocs/wrapDisplayName.ts: -------------------------------------------------------------------------------- 1 | import {ComponentType} from 'react'; 2 | 3 | // Based on https://github.com/acdlite/recompose/blob/a255b23/src/packages/recompose/getDisplayName.js 4 | function getDisplayName(Component: ComponentType) { 5 | return (typeof Component === 'string' || Component == null) 6 | ? Component 7 | : Component.displayName || Component.name || 'Component'; 8 | } 9 | 10 | // based on https://github.com/acdlite/recompose/blob/d55575f/src/packages/recompose/wrapDisplayName.js 11 | export function wrapDisplayName(BaseComponent: ComponentType, hocName: string) { 12 | return `${hocName}(${getDisplayName(BaseComponent)})`; 13 | } 14 | -------------------------------------------------------------------------------- /src/hooks/useMinCollapsedLines.ts: -------------------------------------------------------------------------------- 1 | import {useMemo} from 'react'; 2 | import {expandCollapsedBlockBy, HunkData, Source} from '../utils'; 3 | 4 | export default function useMinCollapsedLines(minLinesExclusive: number, hunks: HunkData[], oldSource: Source | null) { 5 | const renderingHunks = useMemo( 6 | () => { 7 | if (!oldSource) { 8 | return hunks; 9 | } 10 | 11 | const predicate = (lines: number) => lines < minLinesExclusive; 12 | return expandCollapsedBlockBy(hunks, oldSource, predicate); 13 | }, 14 | [minLinesExclusive, hunks, oldSource] 15 | ); 16 | return renderingHunks; 17 | } 18 | -------------------------------------------------------------------------------- /site/components/InputArea/TextInput.tsx: -------------------------------------------------------------------------------- 1 | import styles from './TextInput.less'; 2 | 3 | /* eslint-disable react/jsx-no-bind */ 4 | 5 | interface Props { 6 | className?: string; 7 | title: string; 8 | value: string; 9 | onChange: (value: string) => void; 10 | } 11 | 12 | export default function TextInput({title, value, className, onChange}: Props) { 13 | return ( 14 |
15 |

{title}

16 |