RSS Git Download  Clone
Raw Blame History 7kB 154 lines
import { useState, useMemo } from 'react'
import {
    Button, Box, ToggleButtonGroup, ToggleButton,
    useMediaQuery, Tooltip,
} from '@mui/material'
import { Save, Cancel } from '@mui/icons-material'
import { diffLines, Change } from 'diff'
import { useI18nStore } from '../stores/i18n.store'
import P3xrDialog from '../components/P3xrDialog'

interface DiffBlock {
    type: 'added' | 'removed' | 'unchanged' | 'collapse'
    lines: string[]
    collapsedCount?: number
}

const CONTEXT_LINES = 3

function buildBlocks(changes: Change[]): DiffBlock[] {
    const blocks: DiffBlock[] = []
    for (const change of changes) {
        const lines = change.value.replace(/\n$/, '').split('\n')
        if (change.added) {
            blocks.push({ type: 'added', lines })
        } else if (change.removed) {
            blocks.push({ type: 'removed', lines })
        } else {
            if (lines.length <= CONTEXT_LINES * 2 + 1) {
                blocks.push({ type: 'unchanged', lines })
            } else {
                blocks.push({ type: 'unchanged', lines: lines.slice(0, CONTEXT_LINES) })
                const collapsed = lines.slice(CONTEXT_LINES, -CONTEXT_LINES)
                blocks.push({ type: 'collapse', lines: collapsed, collapsedCount: collapsed.length })
                blocks.push({ type: 'unchanged', lines: lines.slice(-CONTEXT_LINES) })
            }
        }
    }
    return blocks
}

interface DiffDialogProps {
    open: boolean
    keyName: string
    fieldName?: string
    oldValue: string
    newValue: string
    onConfirm: () => void
    onCancel: () => void
}

export default function DiffDialog({ open, keyName, fieldName, oldValue, newValue, onConfirm, onCancel }: DiffDialogProps) {
    const strings = useI18nStore(s => s.strings)
    const d = strings?.diff || {} as any
    const isWide = useMediaQuery('(min-width: 600px)')
    const [mode, setMode] = useState<'inline' | 'side-by-side'>('inline')
    const [expanded, setExpanded] = useState<Set<number>>(new Set())

    const changes = useMemo(() => diffLines(String(oldValue ?? ''), String(newValue ?? '')), [oldValue, newValue])
    const blocks = useMemo(() => buildBlocks(changes), [changes])
    const additions = useMemo(() => changes.filter(c => c.added).reduce((n, c) => n + (c.value.split('\n').length - 1 || 1), 0), [changes])
    const deletions = useMemo(() => changes.filter(c => c.removed).reduce((n, c) => n + (c.value.split('\n').length - 1 || 1), 0), [changes])

    const toggleExpand = (i: number) => setExpanded(prev => { const s = new Set(prev); s.has(i) ? s.delete(i) : s.add(i); return s })

    const lineSx = (type: string) => ({
        px: 1, py: '1px', whiteSpace: 'pre-wrap', wordBreak: 'break-all',
        fontFamily: "'Roboto Mono', monospace", fontSize: 13,
        ...(type === 'added' ? { bgcolor: 'rgba(76,175,80,0.12)' } : {}),
        ...(type === 'removed' ? { bgcolor: 'rgba(244,67,54,0.12)' } : {}),
        ...(type === 'unchanged' || type === 'collapse' ? { opacity: 0.6 } : {}),
    })

    const collapseSx = {
        px: 1, py: '4px', opacity: 0.4, fontStyle: 'italic', cursor: 'pointer',
        fontFamily: "'Roboto Mono', monospace", fontSize: 13,
        '&:hover': { opacity: 0.7 },
    }

    const renderInline = () => blocks.map((block, i) => {
        if (block.type === 'collapse' && !expanded.has(i)) {
            return <Box key={i} sx={collapseSx} onClick={() => toggleExpand(i)}>... {block.collapsedCount} {d.unchangedLines} ...</Box>
        }
        return block.lines.map((line, j) => (
            <Box key={`${i}-${j}`} sx={lineSx(block.type)}>
                <Box component="span" sx={{ display: 'inline-block', width: 16, fontWeight: 700, userSelect: 'none' }}>
                    {block.type === 'added' ? '+' : block.type === 'removed' ? '-' : ' '}
                </Box>{line}
            </Box>
        ))
    })

    const renderSide = (side: 'before' | 'after') => {
        const skipType = side === 'before' ? 'added' : 'removed'
        return (<>
            <Box sx={{ px: 1, py: '4px', fontWeight: 500, borderBottom: 1, borderColor: 'divider', position: 'sticky', top: 0, zIndex: 1, bgcolor: 'background.paper' }}>
                {side === 'before' ? d.before : d.after}
            </Box>
            {blocks.map((block, i) => {
                if (block.type === 'collapse' && !expanded.has(i)) {
                    return <Box key={i} sx={collapseSx} onClick={() => toggleExpand(i)}>... {block.collapsedCount} {d.unchangedLines} ...</Box>
                }
                if (block.type === skipType) return null
                return block.lines.map((line, j) => <Box key={`${i}-${j}`} sx={lineSx(block.type)}>{line}</Box>)
            })}
        </>)
    }

    const title = `${d.reviewChanges} — ${fieldName ? `${fieldName} @ ` : ''}${keyName}`

    return (
        <P3xrDialog
            open={open}
            onClose={onCancel}
            title={title}
            width="800px"
            contentPadding={false}
            headerActions={<>
                <ToggleButtonGroup size="small" exclusive value={mode} onChange={(_, v) => v && setMode(v)}
                    sx={{ mr: 0.5, '& .MuiToggleButton-root': { py: '2px', px: 1.5, fontSize: 12, borderRadius: '4px', textTransform: 'none', color: 'rgba(255,255,255,0.7)', borderColor: 'rgba(255,255,255,0.3)' } }}>
                    <ToggleButton value="inline">{d.inline}</ToggleButton>
                    <ToggleButton value="side-by-side">{d.sideBySide}</ToggleButton>
                </ToggleButtonGroup>
                <Box sx={{ fontSize: 12, opacity: 0.8, mr: 0.5, whiteSpace: 'nowrap' }}>
                    <Box component="span" sx={{ color: '#81c784', fontWeight: 700 }}>+{additions}</Box>{' '}{d.additions},{' '}
                    <Box component="span" sx={{ color: '#ef9a9a', fontWeight: 700 }}>-{deletions}</Box>{' '}{d.deletions}
                </Box>
            </>}
            actions={<>
                <Tooltip title={strings?.intention?.cancel} disableHoverListener={isWide}>
                    <Button variant="contained" color="warning" onClick={onCancel}>
                        <Cancel sx={{ fontSize: 18, mr: isWide ? 0.5 : 0 }} />
                        {isWide && <span>{strings?.intention?.cancel}</span>}
                    </Button>
                </Tooltip>
                <Tooltip title={strings?.intention?.save} disableHoverListener={isWide}>
                    <Button variant="contained" color="primary" onClick={onConfirm}>
                        <Save sx={{ fontSize: 18, mr: isWide ? 0.5 : 0 }} />
                        {isWide && <span>{strings?.intention?.save}</span>}
                    </Button>
                </Tooltip>
            </>}
        >
            <Box sx={{ minHeight: 200, maxHeight: '60vh', overflow: 'auto', ...(mode === 'side-by-side' ? { display: 'grid', gridTemplateColumns: '1fr 1fr' } : {}) }}>
                {mode === 'inline' ? (
                    <Box>{renderInline()}</Box>
                ) : (<>
                    <Box sx={{ borderRight: 1, borderColor: 'divider', overflow: 'auto' }}>{renderSide('before')}</Box>
                    <Box sx={{ overflow: 'auto' }}>{renderSide('after')}</Box>
                </>)}
            </Box>
        </P3xrDialog>
    )
}