RSS Git Download  Clone
Raw Blame History 12kB 269 lines
/**
 * JSON (ReJSON) key type renderer — exact port of Angular key-json.component.
 * Inline JSON tree view with expand/collapse, wrap toggle, copy, download, edit.
 */
import { useState, useEffect, useCallback, useRef } from 'react'
import { Box, Button, Tooltip, useMediaQuery, useTheme as useMuiTheme } from '@mui/material'
import {
    ContentCopy, Download, UnfoldMore, UnfoldLess, WrapText, Notes, Edit,
    ExpandMore, ChevronRight,
} from '@mui/icons-material'
import { useI18nStore } from '../../../stores/i18n.store'
import { useRedisStateStore } from '../../../stores/redis-state.store'
import { useCommonStore } from '../../../stores/common.store'
import { useSettingsStore } from '../../../stores/settings.store'
import { useOverlayStore } from '../../../stores/overlay.store'
import { request } from '../../../stores/socket.service'
import { trackPage } from '../../../stores/analytics'
import { KeyTypeProps, truncateDisplay, copyValue } from './key-type-base'
import JsonEditorDialog from '../../../dialogs/JsonEditorDialog'
import DiffDialog from '../../../dialogs/DiffDialog'

// --- Inline JSON Tree (reused from JsonViewDialog pattern) ---

interface JsonNode {
    key: string; value: any; type: 'object' | 'array' | 'string' | 'number' | 'boolean' | 'null'
    children?: JsonNode[]; childCount?: number
}

function jsonToNode(key: string, value: any): JsonNode {
    if (value === null) return { key, value: null, type: 'null' }
    if (Array.isArray(value)) {
        const children = value.map((item, i) => jsonToNode(String(i), item))
        return { key, value, type: 'array', children, childCount: children.length }
    }
    if (typeof value === 'object') {
        const children = Object.keys(value).map(k => jsonToNode(k, value[k]))
        return { key, value, type: 'object', children, childCount: children.length }
    }
    return { key, value, type: typeof value as any }
}

function formatDisplay(node: JsonNode): string {
    if (node.type === 'null') return 'null'
    if (node.type === 'string') return `"${node.value}"`
    return String(node.value)
}

function useJsonColors() {
    const { palette } = useMuiTheme()
    const isDark = palette.mode === 'dark'
    return {
        key: isDark ? 'white' : 'black',
        string: palette.secondary.main,
        number: palette.primary.main,
        boolean: palette.error.main,
        null: isDark ? 'rgba(255,255,255,0.4)' : 'rgba(0,0,0,0.4)',
    }
}

function TreeNode({ node, level, expandedKeys, toggleExpand, wrap }: {
    node: JsonNode; level: number; expandedKeys: Set<string>; toggleExpand: (p: string) => void; wrap: boolean
}) {
    const colors = useJsonColors()
    const path = `${level}-${node.key}`
    const isExpandable = node.type === 'object' || node.type === 'array'
    const isExpanded = expandedKeys.has(path)
    const valueColor = isExpandable ? undefined : (colors as any)[node.type] ?? 'inherit'

    return (
        <>
            <Box sx={{
                display: 'flex', alignItems: 'flex-start', minHeight: 24, lineHeight: 1.6,
                pl: `${level * 20}px`, fontFamily: "'Roboto Mono', monospace", fontSize: 13,
            }}>
                {isExpandable ? (
                    <Box component="span" onClick={() => toggleExpand(path)} sx={{
                        width: 24, height: 24, display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
                        cursor: 'pointer', flexShrink: 0, opacity: 0.6,
                    }}>
                        {isExpanded ? <ExpandMore sx={{ fontSize: 18 }} /> : <ChevronRight sx={{ fontSize: 18 }} />}
                    </Box>
                ) : (
                    <Box sx={{ width: 24, height: 24, flexShrink: 0 }} />
                )}
                <Box sx={{ display: 'flex', alignItems: 'flex-start', gap: '6px', flex: 1, minWidth: 0, flexWrap: 'nowrap' }}>
                    <Box component="span" sx={{ flexShrink: 0, whiteSpace: 'nowrap' }}>
                        <Box component="span" sx={{ fontWeight: 'bold', color: colors.key }}>{node.key}</Box>
                        <Box component="span" sx={{ opacity: 0.6 }}>:</Box>
                    </Box>
                    {isExpandable ? (
                        !isExpanded ? (
                            <>
                                <Box component="span" sx={{ opacity: 0.5 }}>{node.type === 'array' ? '[' : '{'}</Box>
                                <Box component="span" sx={{ opacity: 0.4 }}>...</Box>
                                <Box component="span" sx={{ opacity: 0.5 }}>{node.type === 'array' ? ']' : '}'}</Box>
                                <Box component="span" sx={{ opacity: 0.4, fontSize: 11, ml: '4px' }}>({node.childCount})</Box>
                            </>
                        ) : null
                    ) : (
                        <Box component="span" sx={{
                            wordBreak: wrap ? 'break-word' : 'normal',
                            whiteSpace: wrap ? 'normal' : 'nowrap',
                            minWidth: 0, color: valueColor,
                            fontStyle: node.type === 'null' ? 'italic' : 'normal',
                        }}>
                            {formatDisplay(node)}
                        </Box>
                    )}
                </Box>
            </Box>
            {isExpandable && isExpanded && node.children?.map((child, i) => (
                <TreeNode key={`${child.key}-${i}`} node={child} level={level + 1}
                    expandedKeys={expandedKeys} toggleExpand={toggleExpand} wrap={wrap} />
            ))}
        </>
    )
}

// --- Main component ---

export default function KeyJson({ response, value, valueBuffer, keyName, valueFormat, onRefresh }: KeyTypeProps) {
    const strings = useI18nStore(s => s.strings)
    const connection = useRedisStateStore(s => s.connection)
    const { toast, generalHandleError } = useCommonStore()
    const overlay = useOverlayStore()
    const isGtSm = useMediaQuery('(min-width: 960px)')
    const isReadonly = connection?.readonly === true

    const [jsonObj, setJsonObj] = useState<any>(undefined)
    const [treeWrap, setTreeWrap] = useState(true)
    const [expandedKeys, setExpandedKeys] = useState<Set<string>>(new Set())
    const [editorOpen, setEditorOpen] = useState(false)
    const [diffOpen, setDiffOpen] = useState(false)
    const [diffData, setDiffData] = useState({ oldValue: '', newValue: '' })
    const diffResolveRef = useRef<((v: boolean) => void) | null>(null)

    const rootLabel = strings?.label?.tree ?? 'root'

    useEffect(() => {
        try {
            const obj = JSON.parse(value)
            setJsonObj(obj)
            setExpandedKeys(new Set([`0-${rootLabel}`]))
        } catch {
            setJsonObj(undefined)
        }
    }, [value, rootLabel])

    const toggleExpand = useCallback((path: string) => {
        setExpandedKeys(prev => {
            const next = new Set(prev)
            if (next.has(path)) next.delete(path); else next.add(path)
            return next
        })
    }, [])

    const expandAll = useCallback(() => {
        if (jsonObj === undefined) return
        const tree = jsonToNode(rootLabel, jsonObj)
        const keys = new Set<string>()
        const collect = (node: JsonNode, level: number) => {
            const path = `${level}-${node.key}`
            if (node.type === 'object' || node.type === 'array') {
                keys.add(path)
                node.children?.forEach(c => collect(c, level + 1))
            }
        }
        collect(tree, 0)
        setExpandedKeys(keys)
    }, [jsonObj, rootLabel])

    const collapseAll = useCallback(() => {
        setExpandedKeys(new Set([`0-${rootLabel}`]))
    }, [rootLabel])

    const copyVal = useCallback(() => copyValue(value), [value])

    const downloadJsonFile = useCallback(() => {
        const blob = new Blob([value], { type: 'application/json' })
        const url = URL.createObjectURL(blob)
        const a = document.createElement('a')
        a.href = url; a.download = `${keyName}.json`; a.click()
        URL.revokeObjectURL(url)
    }, [value, keyName])

    const showDiff = useCallback((oldValue: string, newValue: string): Promise<boolean> => {
        if (!useSettingsStore.getState().showDiffBeforeSave) return Promise.resolve(true)
        if (oldValue === newValue) return Promise.resolve(true)
        setDiffData({ oldValue, newValue })
        setDiffOpen(true)
        return new Promise(resolve => { diffResolveRef.current = resolve })
    }, [])

    const handleEditorClose = useCallback(async (result?: { obj: string } | null) => {
        setEditorOpen(false)
        if (!result?.obj) return
        const oldVal = value
        try {
            const val = typeof result.obj === 'string' ? result.obj : JSON.stringify(result.obj)
            overlay.show()
            await request({ action: 'key/json-set', payload: { key: keyName, path: '$', value: val } })
            trackPage('/key-json-set')
            onRefresh()
            overlay.hide()

            const settings = useSettingsStore.getState()
            if (settings.undoEnabled && oldVal !== undefined && oldVal !== val) {
                const undoClicked = await useCommonStore.getState().toastWithUndo(strings?.status?.set)
                if (undoClicked) {
                    overlay.show({ message: 'Undo...' })
                    await request({ action: 'key/json-set', payload: { key: keyName, path: '$', value: oldVal } })
                    onRefresh()
                    overlay.hide()
                    useCommonStore.getState().toast(strings?.status?.reverted)
                }
            }
        } catch (e) { if (e) generalHandleError(e); overlay.hide() }
    }, [keyName, strings, value, onRefresh, generalHandleError])

    const Btn = ({ icon, label, color = 'secondary' as const, onClick }: {
        icon: React.ReactNode; label: string; color?: 'primary' | 'secondary'; onClick: () => void
    }) => isGtSm ? (
        <Button variant="contained" color={color} onClick={onClick}>
            {icon}<span>{label}</span>
        </Button>
    ) : (
        <Tooltip title={label} placement="top">
            <Button variant="contained" color={color} onClick={onClick}
                sx={{ minWidth: 40, width: 40, height: 40, p: 0, borderRadius: '4px' }}>
                {icon}
            </Button>
        </Tooltip>
    )

    const tree = jsonObj !== undefined ? jsonToNode(rootLabel, jsonObj) : null

    return (
        <Box>
            <Box className="p3xr-key-type-actions">
                <Btn icon={<ContentCopy fontSize="small" />} label={strings?.intention?.copy} onClick={copyVal} />
                <Btn icon={<Download fontSize="small" />} label={strings?.intention?.downloadJson ?? 'Download JSON'} onClick={downloadJsonFile} />
                <Btn icon={<UnfoldMore fontSize="small" />} label={strings?.page?.treeControls?.expandAll} onClick={expandAll} />
                <Btn icon={<UnfoldLess fontSize="small" />} label={strings?.page?.treeControls?.collapseAll} onClick={collapseAll} />
                <Btn icon={treeWrap ? <WrapText fontSize="small" /> : <Notes fontSize="small" />}
                    label={treeWrap ? (strings?.intention?.unwrap ?? 'Unwrap') : (strings?.intention?.wrap ?? 'Wrap')}
                    onClick={() => setTreeWrap(w => !w)} />
                {!isReadonly && (
                    <Btn icon={<Edit fontSize="small" />} label={strings?.intention?.jsonViewEditor} color="primary" onClick={() => setEditorOpen(true)} />
                )}
            </Box>

            <Box className="p3xr-key-type-content" sx={{ overflow: 'auto' }}>
                {tree ? (
                    <TreeNode node={tree} level={0} expandedKeys={expandedKeys} toggleExpand={toggleExpand} wrap={treeWrap} />
                ) : (
                    <Box className="p3xr-pre">{truncateDisplay(value)}</Box>
                )}
            </Box>

            <JsonEditorDialog open={editorOpen} value={String(value ?? '')} hideFormatSave onClose={handleEditorClose} />

            <DiffDialog open={diffOpen} keyName={keyName}
                oldValue={diffData.oldValue} newValue={diffData.newValue}
                onConfirm={() => { setDiffOpen(false); diffResolveRef.current?.(true) }}
                onCancel={() => { setDiffOpen(false); diffResolveRef.current?.(false) }} />
        </Box>
    )
}