RSS Git Download  Clone
Raw Blame History 17kB 336 lines
/**
 * String key type renderer — exact port of Angular key-string.component.
 *
 * View mode: Upload, Download, JSON View, Copy, Format JSON, JSON Editor, Edit
 * Edit mode: Validate JSON toggle, Cancel, Upload, Save
 * Display: truncated value with format toggle, click to edit
 */
import { useState, useCallback, useRef } from 'react'
import { Box, Button, Tooltip, TextField, Switch, FormControlLabel, useMediaQuery } from '@mui/material'
import {
    Upload, Download, AccountTree, ContentCopy, FormatLineSpacing,
    Description, Edit, Cancel, Save, Numbers,
} from '@mui/icons-material'
import { useI18nStore } from '../../../stores/i18n.store'
import { useRedisStateStore } from '../../../stores/redis-state.store'
import { trackPage } from '../../../stores/analytics'
import { useSettingsStore } from '../../../stores/settings.store'
import { useCommonStore } from '../../../stores/common.store'
import { useOverlayStore } from '../../../stores/overlay.store'
import { request } from '../../../stores/socket.service'
import { KeyTypeProps, formatValue, truncateDisplay, isTruncated, copyValue, downloadBuffer } from './key-type-base'
import HexMonitor from './HexMonitor'
import { parseRedisVersion } from '../../../../core/redis-version'
import JsonViewDialog from '../../../dialogs/JsonViewDialog'
import JsonEditorDialog from '../../../dialogs/JsonEditorDialog'
import DiffDialog from '../../../dialogs/DiffDialog'

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

    const [editable, setEditable] = useState(false)
    const [buffer, setBuffer] = useState(false)
    const [validateJson, setValidateJson] = useState(false)
    const [value, setValue] = useState(initValue)
    const [valueBuffer, setValueBuffer] = useState(initBuffer)
    const [originalValue, setOriginalValue] = useState<any>(null)
    const [jsonViewOpen, setJsonViewOpen] = useState(false)
    const [jsonEditorOpen, setJsonEditorOpen] = useState(false)
    const [diffOpen, setDiffOpen] = useState(false)
    const [diffData, setDiffData] = useState({ oldValue: '', newValue: '' })
    const diffResolveRef = useRef<((v: boolean) => void) | null>(null)

    // --- Actions matching Angular exactly ---

    const edit = useCallback(() => {
        if (typeof value === 'string' && value.length >= settings.maxValueAsBuffer) {
            setBuffer(true)
            setOriginalValue(structuredClone(valueBuffer))
        } else {
            setBuffer(false)
            setOriginalValue(structuredClone(value))
        }
        setEditable(true)
    }, [value, valueBuffer, settings.maxValueAsBuffer])

    const cancelEdit = useCallback(() => {
        if (buffer) setValueBuffer(originalValue)
        else setValue(originalValue)
        setEditable(false)
        setBuffer(false)
    }, [buffer, originalValue])

    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 save = useCallback(async () => {
        const v = buffer ? valueBuffer : value
        const oldVal = originalValue
        try {
            if (validateJson) JSON.parse(v)
            if (oldVal != null) {
                const confirmed = await showDiff(oldVal, v)
                if (!confirmed) return
            }
            overlay.show({ message: strings?.intention?.save })
            await request({ action: 'key/set', payload: { type: response?.type, key: keyName, value: v } })
            trackPage('/key-set')
            setEditable(false)
            setBuffer(false)
            onRefresh()
            overlay.hide()

            // Undo support
            if (settings.undoEnabled && oldVal !== undefined && oldVal !== v) {
                const undoClicked = await useCommonStore.getState().toastWithUndo(strings?.status?.saved)
                if (undoClicked) {
                    try {
                        overlay.show({ message: 'Undo...' })
                        await request({ action: 'key/set', payload: { type: response?.type, key: keyName, value: oldVal } })
                        setValue(oldVal)
                        setOriginalValue(oldVal)
                        onRefresh()
                        overlay.hide()
                        useCommonStore.getState().toast(strings?.status?.reverted)
                    } catch(e) {
                        useCommonStore.getState().generalHandleError(e)
                        overlay.hide()
                    }
                }
            }
        } catch (e) { generalHandleError(e); overlay.hide() }
    }, [buffer, value, valueBuffer, validateJson, response, keyName, strings, onRefresh, generalHandleError, originalValue, settings.undoEnabled])

    const setBufferUpload = useCallback(() => {
        const input = document.createElement('input')
        input.type = 'file'
        input.onchange = async () => {
            const file = input.files?.[0]
            if (!file) return
            const reader = new FileReader()
            reader.onerror = (err) => generalHandleError(err)
            reader.onload = async (e: any) => {
                const arrayBuffer = e.target.result
                try {
                    if (editable) {
                        await confirm({ message: strings?.confirm?.uploadBuffer })
                        if (buffer) setValueBuffer(arrayBuffer)
                        else setValue(arrayBuffer)
                        toast(strings?.confirm?.uploadBufferDone)
                        return
                    }
                    await confirm({ message: strings?.confirm?.uploadBuffer })
                    overlay.show()
                    await request({ action: 'key/set', payload: { type: response?.type, value: arrayBuffer, key: keyName } })
                    trackPage('/key-set')
                    toast(strings?.confirm?.uploadBufferDoneAndSave)
                    onRefresh()
                } catch (e) { generalHandleError(e) }
                finally { overlay.hide() }
            }
            reader.readAsArrayBuffer(file)
        }
        input.click()
    }, [editable, buffer, response, keyName, strings, confirm, toast, generalHandleError, onRefresh])

    const jsonViewer = useCallback(() => setJsonViewOpen(true), [])

    const jsonEditor = useCallback(() => setJsonEditorOpen(true), [])

    const formatJson = useCallback(async () => {
        try {
            const formatted = JSON.stringify(JSON.parse(value), null, settings.jsonFormat || 2)
            setValue(formatted)
            overlay.show({ message: strings?.intention?.save })
            await request({ action: 'key/set', payload: { type: response?.type, key: keyName, value: formatted } })
            trackPage('/key-set')
            onRefresh()
        } catch { toast(strings?.label?.jsonViewNotParsable) }
        finally { overlay.hide() }
    }, [value, settings.jsonFormat, response, keyName, strings, onRefresh])

    const showDigest = useCallback(async () => {
        try {
            const res = await request({ action: 'key/string-digest', payload: { key: keyName } })
            toast(res.digest || 'No digest')
        } catch (e) { generalHandleError(e) }
    }, [keyName, toast, generalHandleError])

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

    const downloadBufferFile = useCallback(() => downloadBuffer(valueBuffer, keyName), [valueBuffer, keyName])

    const bufferDisplay = (): string => {
        if (valueBuffer?.byteLength !== undefined) {
            return '(' + (settings.prettyBytes?.(valueBuffer.byteLength) ?? `${valueBuffer.byteLength} bytes`) + ')'
        }
        return ''
    }

    // --- Responsive button (matches Angular @if(isGtSm) pattern) ---
    const Btn = ({ icon, label, color = 'primary' as const, onClick }: {
        icon: React.ReactNode; label: string; color?: 'primary' | 'secondary' | 'error'; onClick: () => void
    }) => isGtSm ? (
        <Button variant="contained" color={color} size="small" onClick={onClick} sx={{ gap: '3px' }}>
            {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>
    )

    return (
        <Box>
            {/* View mode actions — matches Angular template lines 1-88 */}
            {!editable && (
                <Box className="p3xr-key-type-actions">
                    {!isReadonly && (
                        <Btn icon={<Upload fontSize="small" />} label={strings?.intention?.setBuffer} onClick={setBufferUpload} />
                    )}
                    <Btn icon={<Download fontSize="small" />} label={strings?.intention?.downloadBuffer} color="secondary" onClick={downloadBufferFile} />
                    <Btn icon={<AccountTree fontSize="small" />} label={strings?.intention?.jsonViewShow} color="secondary" onClick={jsonViewer} />
                    <Btn icon={<ContentCopy fontSize="small" />} label={strings?.intention?.copy} color="secondary" onClick={copyVal} />
                    {!isReadonly && (
                        <Btn icon={<FormatLineSpacing fontSize="small" />} label={strings?.intention?.formatJson} onClick={formatJson} />
                    )}
                    <Btn icon={<Description fontSize="small" />} label={strings?.intention?.jsonViewEditor} onClick={jsonEditor} />
                    {parseRedisVersion(useRedisStateStore.getState().info?.server?.redis_version).isAtLeast(8, 4) && (
                        <Btn icon={<Numbers fontSize="small" />} label="Digest" color="secondary" onClick={showDigest} />
                    )}
                    {!isReadonly && (
                        <Btn icon={<Edit fontSize="small" />} label={strings?.intention?.edit} onClick={edit} />
                    )}
                </Box>
            )}

            {/* Edit mode actions — matches Angular template lines 90-136 */}
            {editable && (
                <Box className="p3xr-key-type-actions">
                    {!isReadonly && (
                        <FormControlLabel sx={{ mr: 1 }}
                            control={<Switch checked={validateJson} onChange={(_, v) => setValidateJson(v)} color="secondary" />}
                            label={strings?.label?.validateJson} />
                    )}
                    <Btn icon={<Cancel fontSize="small" />} label={strings?.intention?.cancel} color="error" onClick={cancelEdit} />
                    {!isReadonly && (
                        <>
                            <Btn icon={<Upload fontSize="small" />} label={strings?.intention?.setBuffer} onClick={setBufferUpload} />
                            <Btn icon={<Save fontSize="small" />} label={strings?.intention?.save} onClick={save} />
                        </>
                    )}
                </Box>
            )}

            {/* Value display / editor — matches Angular template lines 138-164 */}
            <Box className="p3xr-key-type-content">
                {editable ? (
                    <Box className="p3xr-key-type-editor">
                        {/* Buffer info — shown when value is ArrayBuffer or in buffer mode */}
                        {(String(value) === '[object ArrayBuffer]') && (
                            <Box className="p3xr-key-type-buffer-info">
                                {typeof strings?.label?.isBuffer === 'function'
                                    ? strings.label.isBuffer({ maxValueAsBuffer: settings.prettyBytes(settings.maxValueAsBuffer) })
                                    : ''}
                                {' '}{bufferDisplay()}
                            </Box>
                        )}
                        {buffer && (
                            <Box className="p3xr-key-type-buffer-info">
                                {typeof strings?.label?.isBuffer === 'function'
                                    ? strings.label.isBuffer({ maxValueAsBuffer: settings.prettyBytes(settings.maxValueAsBuffer) })
                                    : ''}
                                {' '}{bufferDisplay()}
                            </Box>
                        )}
                        {buffer ? (
                            <TextField fullWidth multiline minRows={4}
                                value={String(valueBuffer ?? '')}
                                onChange={e => setValueBuffer(e.target.value)}
                                slotProps={{ input: { sx: { fontFamily: "'Roboto Mono', monospace", fontSize: 13 } } }}
                            />
                        ) : (
                            <TextField fullWidth multiline minRows={4}
                                value={typeof value === 'string' ? value : String(value ?? '')}
                                onChange={e => setValue(e.target.value)}
                                slotProps={{ input: { sx: { fontFamily: "'Roboto Mono', monospace", fontSize: 13 } } }}
                            />
                        )}
                    </Box>
                ) : (
                    <Box className="p3xr-key-type-display" onClick={isReadonly ? undefined : edit}
                        sx={{ cursor: isReadonly ? 'default' : 'pointer', maxWidth: '100%', overflow: valueFormat === 'hex' ? 'visible' : 'auto' }}>
                        {settings.maxValueDisplay === -1 ? (
                            <Box sx={{ opacity: 0.5, fontStyle: 'italic' }}>
                                {strings?.label?.hiddenUntilEdit}
                            </Box>
                        ) : (
                            valueFormat === 'hex' ? (
                                <HexMonitor value={truncateDisplay(typeof value === 'string' ? value : '')} truncated={isTruncated(value)} />
                            ) : (
                                <Box component="span" className="p3xr-pre" sx={{
                                    wordBreak: 'break-all', whiteSpace: 'pre-wrap',
                                    fontFamily: "'Roboto Mono', monospace", fontSize: 16, lineHeight: '18px',
                                }}>
                                    {truncateDisplay(formatValue(typeof value === 'string' ? value : '', valueFormat))}
                                    {isTruncated(value) && <Box component="span" sx={{ opacity: 0.5 }}>...</Box>}
                                </Box>
                            )
                        )}
                    </Box>
                )}
            </Box>

            {/* Dialogs */}
            <JsonViewDialog open={jsonViewOpen} value={String(value ?? '')} onClose={() => setJsonViewOpen(false)} />
            <JsonEditorDialog open={jsonEditorOpen} value={String(value ?? '')}
                onClose={async (result) => {
                    setJsonEditorOpen(false)
                    if (result?.obj) {
                        const oldVal = String(value ?? '')
                        setOriginalValue(null)
                        setEditable(false)
                        setBuffer(false)
                        setValue(result.obj)
                        overlay.show({ message: strings?.intention?.save })
                        try {
                            await request({ action: 'key/set', payload: { type: response?.type, key: keyName, value: result.obj } })
                            trackPage('/key-set')
                            onRefresh()
                            overlay.hide()
                            if (settings.undoEnabled && oldVal !== result.obj) {
                                const undoClicked = await useCommonStore.getState().toastWithUndo(strings?.status?.saved)
                                if (undoClicked) {
                                    overlay.show({ message: 'Undo...' })
                                    await request({ action: 'key/set', payload: { type: response?.type, key: keyName, value: oldVal } })
                                    onRefresh()
                                    overlay.hide()
                                    useCommonStore.getState().toast(strings?.status?.reverted)
                                }
                            }
                        } catch (e) { generalHandleError(e); overlay.hide() }
                    }
                }} />

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