RSS Git Download  Clone
Raw Blame History 8kB 190 lines
import { useState, useCallback, useEffect } from 'react'
import { Box, IconButton, Tooltip } from '@mui/material'
import { useTheme } from '@mui/material'
import { Button } from '@mui/material'
import { Close, KeyboardArrowDown, KeyboardArrowUp, ChevronRight, ExpandMore } from '@mui/icons-material'
import { useI18nStore } from '../stores/i18n.store'
import P3xrDialog from '../components/P3xrDialog'

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)
}

// Color map from Angular: string=accent, number=primary, boolean=warn, null=muted
function useJsonColors() {
    const muiTheme = useTheme()
    const isDark = muiTheme.palette.mode === 'dark'
    return {
        key: isDark ? 'white' : 'black',
        string: muiTheme.palette.secondary.main,   // --p3xr-btn-accent-bg
        number: muiTheme.palette.primary.main,      // --p3xr-btn-primary-bg
        boolean: muiTheme.palette.error.main,        // --p3xr-btn-warn-bg
        null: isDark ? 'rgba(255,255,255,0.4)' : 'rgba(0,0,0,0.4)',
    }
}

function TreeNode({ node, level, expandedKeys, toggleExpand }: {
    node: JsonNode; level: number; expandedKeys: Set<string>; toggleExpand: (path: string) => void
}) {
    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 ? (
                    <IconButton size="small" onClick={() => toggleExpand(path)}
                        sx={{ width: 24, height: 24, p: 0, flexShrink: 0, opacity: 0.6 }}>
                        {isExpanded ? <ExpandMore sx={{ fontSize: 18 }} /> : <ChevronRight sx={{ fontSize: 18 }} />}
                    </IconButton>
                ) : (
                    <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: 'break-word', 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} />
            ))}
        </>
    )
}

interface Props {
    open: boolean
    value: string
    onClose: () => void
}

export default function JsonViewDialog({ open, value, onClose }: Props) {
    const strings = useI18nStore(s => s.strings)
    // Start with only root expanded (level 0) — matches Angular expanded=true (first level only)
    const [expandedKeys, setExpandedKeys] = useState<Set<string>>(new Set())

    const rootLabel = strings?.label?.tree ?? 'root'
    let isJson = false
    let tree: JsonNode | null = null
    try {
        const obj = JSON.parse(value)
        isJson = true
        tree = jsonToNode(rootLabel, obj)
    } catch { /* not parsable */ }

    // Reset to root-only expanded when value changes
    useEffect(() => {
        if (open && isJson) setExpandedKeys(new Set([`0-${rootLabel}`]))
    }, [open, value])

    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 (!tree) return
        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, i) => collect(c, level + 1))
            }
        }
        collect(tree, 0)
        setExpandedKeys(keys)
    }, [tree])

    const collapseAll = useCallback(() => {
        // Collapse to level 1: only root expanded
        const rootPath = `0-${strings?.label?.tree ?? 'root'}`
        setExpandedKeys(new Set([rootPath]))
    }, [strings])

    if (!open) return null

    return (
        <P3xrDialog open onClose={onClose}
            title={strings?.intention?.jsonViewShow}
            headerActions={isJson ? (
                <>
                    <Tooltip title={strings?.page?.treeControls?.expandAll} placement="top">
                        <IconButton color="inherit" size="small" onClick={expandAll}><KeyboardArrowDown fontSize="small" /></IconButton>
                    </Tooltip>
                    <Tooltip title={strings?.page?.treeControls?.collapseAll} placement="top">
                        <IconButton color="inherit" size="small" onClick={collapseAll}><KeyboardArrowUp fontSize="small" /></IconButton>
                    </Tooltip>
                </>
            ) : undefined}
            actions={
                <Button variant="contained" color="secondary" size="small" onClick={onClose}>
                    <Close fontSize="small" /><span style={{ marginLeft: 3 }}>{strings?.intention?.close}</span>
                </Button>
            }>
            <Box sx={{ minHeight: 200, maxHeight: '70vh', overflow: 'auto' }}>
                {isJson && tree ? (
                    <TreeNode node={tree} level={0} expandedKeys={expandedKeys} toggleExpand={toggleExpand} />
                ) : (
                    <Box>{strings?.label?.jsonViewNotParsable}</Box>
                )}
            </Box>
        </P3xrDialog>
    )
}