import { useState, useEffect, useCallback, useRef, useMemo } from 'react' import { Box, Tooltip, Menu, MenuItem, Divider, IconButton, useTheme } from '@mui/material' import { KeyboardArrowDown, KeyboardArrowUp, Refresh, Settings, ArrowDropDown, SkipPrevious, KeyboardArrowLeft, KeyboardArrowRight, SkipNext, Search, Clear, Add, MoreVert, FileDownload, FileUpload, DeleteSweep, } from '@mui/icons-material' import { useI18nStore } from '../../stores/i18n.store' import { useRedisStateStore, getPages, getFilteredKeys } from '../../stores/redis-state.store' import { useSettingsStore } from '../../stores/settings.store' import { useCommonStore, emitTreeEvent } from '../../stores/common.store' import { useMainCommandStore } from '../../stores/main-command.store' import { request } from '../../stores/socket.service' import { useOverlayStore } from '../../stores/overlay.store' import TreeSettingsDialog from '../../dialogs/TreeSettingsDialog' import KeyImportDialog from '../../dialogs/KeyImportDialog' // Icon button styles — will be set per-instance using theme colors const iconBtnBase = { p: 0, width: 24, height: 24, minWidth: 24, minHeight: 24, borderRadius: '50%', } export default function DatabaseTreeControls() { const strings = useI18nStore(s => s.strings) const keysRaw = useRedisStateStore(s => s.keysRaw) const search = useRedisStateStore(s => s.search) const page = useRedisStateStore(s => s.page) const cfg = useRedisStateStore(s => s.cfg) const connection = useRedisStateStore(s => s.connection) const redisTreeDivider = useSettingsStore(s => s.redisTreeDivider) const searchClientSide = useSettingsStore(s => s.searchClientSide) const pageCount = useSettingsStore(s => s.pageCount) const { toast, generalHandleError } = useCommonStore() const { refresh, addKey: cmdAddKey } = useMainCommandStore() const overlay = useOverlayStore() const muiTheme = useTheme() const iconBtnSx = { ...iconBtnBase, color: muiTheme.p3xr.treecontrolIconColor } const primaryIconBtnSx = { ...iconBtnBase, color: muiTheme.palette.primary.main } // p3xr-input base style const inputBase: React.CSSProperties = { boxSizing: 'border-box', borderStyle: 'solid', borderWidth: 2, margin: 1, borderColor: muiTheme.p3xr.inputBorderColor, background: muiTheme.p3xr.inputBg, color: muiTheme.p3xr.inputColor, outline: 'none', fontFamily: "'Roboto Mono', monospace", fontSize: 12, } // Compact: pager + divider (no padding) const inputCompact: React.CSSProperties = { ...inputBase, padding: 0 } // Normal: search (with padding) const inputStyle: React.CSSProperties = { ...inputBase, padding: 3 } const isReadonly = connection?.readonly === true const keyCount = Array.isArray(keysRaw) ? keysRaw.length : 0 const treeDividers: string[] = Array.isArray(cfg?.treeDividers) ? cfg.treeDividers : [] const pages = getPages() const [localSearch, setLocalSearch] = useState(search || '') const [localDivider, setLocalDivider] = useState(redisTreeDivider || ':') const [localPage, setLocalPage] = useState(page || 1) const dividerTimerRef = useRef(null) // Sync from store useEffect(() => { setLocalSearch(search || '') }, [search]) useEffect(() => { setLocalDivider(redisTreeDivider || ':') }, [redisTreeDivider]) useEffect(() => { setLocalPage(page || 1) }, [page]) // --- Expand menu --- const [expandAnchor, setExpandAnchor] = useState(null) // --- Divider menu --- const [dividerAnchor, setDividerAnchor] = useState(null) // --- Actions menu --- const [actionsAnchor, setActionsAnchor] = useState(null) // --- Tree settings dialog --- const [treeSettingsOpen, setTreeSettingsOpen] = useState(false) // --- Import dialog --- const [importDialogOpen, setImportDialogOpen] = useState(false) const [importDialogData, setImportDialogData] = useState(null) // --- Key count text --- const keyCountText = useMemo(() => { const fn = strings?.status?.keyCount return typeof fn === 'function' ? fn({ keyCount }) : String(keyCount) }, [strings, keyCount]) // --- Search --- const searchPlaceholder = useMemo(() => { const s = strings?.page?.treeControls?.search return searchClientSide ? (s?.placeholderClient || 'Search keys') : (s?.placeholderServer || 'Search keys on server') }, [strings, searchClientSide]) const onSearchChange = useCallback(async () => { useRedisStateStore.setState({ search: localSearch, page: 1 }) if (searchClientSide) useRedisStateStore.setState({ redisChanged: true }) await refresh() }, [localSearch, searchClientSide, refresh]) const clearSearch = useCallback(async () => { setLocalSearch('') useRedisStateStore.setState({ search: '', page: 1 }) if (searchClientSide) useRedisStateStore.setState({ redisChanged: true }) await refresh() }, [searchClientSide, refresh]) // --- Divider --- const onDividerInputChange = useCallback((value: string) => { setLocalDivider(value) useSettingsStore.getState().setSetting('p3xr-main-treecontrol-divider', value) clearTimeout(dividerTimerRef.current) dividerTimerRef.current = setTimeout(() => { useRedisStateStore.setState({ redisChanged: true }) }, 666) }, []) const setDivider = useCallback((value: string) => { setLocalDivider(value) useSettingsStore.getState().setSetting('p3xr-main-treecontrol-divider', value) useRedisStateStore.setState({ redisChanged: true }) setDividerAnchor(null) }, []) // --- Pagination --- const pageAction = useCallback((action: 'first' | 'prev' | 'next' | 'last') => { const currentPage = useRedisStateStore.getState().page ?? 1 const totalPages = getPages() let newPage = currentPage switch (action) { case 'first': newPage = 1; break case 'prev': newPage = Math.max(1, currentPage - 1); break case 'next': newPage = Math.min(totalPages, currentPage + 1); break case 'last': newPage = totalPages; break } setLocalPage(newPage) useRedisStateStore.setState({ page: newPage, redisChanged: true }) }, []) const onPageInputChange = useCallback((e: React.ChangeEvent) => { const parsed = parseInt(e.target.value, 10) const totalPages = getPages() const clamped = isNaN(parsed) ? 1 : Math.max(1, Math.min(totalPages, parsed)) setLocalPage(clamped) useRedisStateStore.setState({ page: clamped, redisChanged: true }) }, []) // --- Expand/Collapse --- const treeExpandToLevel = useCallback((level: number) => { setExpandAnchor(null) setTimeout(() => emitTreeEvent('expand-level-' + level)) }, []) const treeExpandAll = useCallback(() => { emitTreeEvent('expand-all') setExpandAnchor(null) }, []) const treeCollapseAll = useCallback(() => { emitTreeEvent('collapse-all') }, []) // --- Export --- const exportKeys = useCallback(async () => { setActionsAnchor(null) if (!Array.isArray(keysRaw) || keysRaw.length === 0) { toast(strings?.label?.noKeysToExport) return } try { overlay.show({ message: strings?.label?.exportProgress }) const response = await request({ action: 'key-export', payload: { keys: keysRaw } }) const json = JSON.stringify(response.data, null, 2) const blob = new Blob([json], { type: 'application/json' }) const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url const connName = connection?.name || 'redis' const db = useRedisStateStore.getState().currentDatabase ?? 0 a.download = `${connName}-db${db}-export.json` a.click() URL.revokeObjectURL(url) toast(strings?.status?.exportDone) } catch (e) { generalHandleError(e) } finally { overlay.hide() } }, [keysRaw, connection, strings, toast, generalHandleError, overlay]) // --- Import --- const importKeys = useCallback(() => { setActionsAnchor(null) const input = document.createElement('input') input.type = 'file' input.accept = '.json' input.onchange = () => { const file = input.files?.[0] if (!file) return const reader = new FileReader() reader.onload = (e: any) => { try { const parsed = JSON.parse(e.target.result) if (!parsed?.keys || !Array.isArray(parsed.keys) || parsed.keys.length === 0) { toast(strings?.label?.importNoKeys) return } setImportDialogData(parsed) setImportDialogOpen(true) } catch (err: any) { if (err !== undefined && err !== null) generalHandleError(err) } } reader.readAsText(file) } input.click() }, [strings, toast, generalHandleError]) const handleImportDialogClose = useCallback(async (result: any) => { setImportDialogOpen(false) setImportDialogData(null) if (!result?.pending) return try { overlay.show({ message: strings?.label?.importProgress }) const response = await request({ action: 'key-import', payload: { keys: result.keys, conflictMode: result.conflictMode }, }) const data = response.data const statusFn = strings?.status?.importDone const message = typeof statusFn === 'function' ? statusFn(data) : `Import complete: ${data.created} created, ${data.skipped} skipped, ${data.errors} errors` toast(message) await refresh() } catch (e: any) { if (e !== undefined && e !== null) generalHandleError(e) } finally { overlay.hide() } }, [strings, toast, refresh, generalHandleError, overlay]) // --- Delete search keys --- const deleteSearchLabel = useMemo(() => { if (localSearch.length > 0) { const fn = strings?.intention?.deleteSearchKeys return typeof fn === 'function' ? fn({ count: keyCount }) : `Delete ${keyCount} matching keys` } const fn = strings?.intention?.deleteAllKeysMenu return typeof fn === 'function' ? fn({ count: keyCount }) : `Delete all ${keyCount} keys` }, [strings, localSearch, keyCount]) const exportLabel = useMemo(() => { if (localSearch.length > 0) { const fn = strings?.intention?.exportSearchResults return typeof fn === 'function' ? fn({ count: keyCount }) : `Export ${keyCount} results` } const fn = strings?.intention?.exportAllKeys return typeof fn === 'function' ? fn({ count: keyCount }) : `Export all ${keyCount} keys` }, [strings, localSearch, keyCount]) const deleteSearchKeys = useCallback(async () => { setActionsAnchor(null) const searchStartsWith = useSettingsStore.getState().searchStartsWith let match: string if (localSearch.length > 0) { match = searchStartsWith ? localSearch + '*' : '*' + localSearch + '*' } else { match = '*' } try { const confirmFn = strings?.confirm?.deleteSearchKeys const confirmMsg = typeof confirmFn === 'function' ? confirmFn({ count: keyCount, pattern: match }) : `Are you sure to delete all keys matching "${match}"? Found ${keyCount} keys.` await useCommonStore.getState().confirm({ message: confirmMsg }) overlay.show({ message: strings?.label?.deletingSearchKeys }) const response = await request({ action: 'delete-search-keys', payload: { match } }) const deletedCount = response.deletedCount || 0 const statusFn = strings?.status?.deletedSearchKeys const message = typeof statusFn === 'function' ? statusFn({ count: deletedCount }) : `Deleted ${deletedCount} keys` toast(message) await refresh() } catch (e: any) { if (e !== undefined && e !== null) generalHandleError(e) } finally { overlay.hide() } }, [localSearch, keyCount, strings, toast, refresh, generalHandleError, overlay]) return ( {/* Leading row: controls + pager, single line */} setExpandAnchor(e.currentTarget)}> setExpandAnchor(null)}> {[1,2,3,4,5].map(n => ( treeExpandToLevel(n)}> {strings?.page?.treeControls?.level} {n} ))} {strings?.page?.treeControls?.expandAll} refresh()}> setTreeSettingsOpen(true)}> setTreeSettingsOpen(false)} /> {/* Divider input */} onDividerInputChange(e.target.value)} style={{ ...inputCompact, width: 23, fontFamily: "'Roboto Mono', monospace", fontSize: 14, fontWeight: 500, textAlign: 'center', verticalAlign: 'middle', }} /> {treeDividers.length > 0 && ( <> setDividerAnchor(e.currentTarget)}> setDividerAnchor(null)} slotProps={{ paper: { sx: { minWidth: '20px !important', maxWidth: '40px !important', transform: 'translateX(-23px) !important', marginTop: '4px', } }, list: { sx: { p: 0 } } }}> {treeDividers.map(d => ( setDivider(d)} sx={{ minHeight: 28, height: 28, p: '0 !important', minWidth: 0, textAlign: 'center', justifyContent: 'center', fontFamily: "'Roboto Mono', monospace", fontWeight: 500, fontSize: 14, }}> {d === '' ? '(empty)' : d} ))} )} {/* Pager or key count — same flex row */} {pages > 1 ? ( pageAction('first')}> pageAction('prev')}> / {pages} pageAction('next')}> pageAction('last')}> ) : ( {keyCountText}  )} {/* Search row */} setLocalSearch(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') onSearchChange() }} style={{ ...inputStyle, flex: 1, minWidth: 0, verticalAlign: 'middle', }} /> {localSearch.length > 0 && ( )} {!isReadonly && ( cmdAddKey({ event: e.nativeEvent })} sx={{ fontSize: 24, cursor: 'pointer', color: muiTheme.p3xr.commonWarnColor }} /> )} setActionsAnchor(e.currentTarget)}> setActionsAnchor(null)}> {exportLabel} {localSearch.length > 0 && ( {strings?.label?.exportSearchHint} )} {!isReadonly && ( {strings?.intention?.importKeys} )} {!isReadonly && localSearch.length > 0 && ( {strings?.label?.importSearchHint} )} {!isReadonly && } {!isReadonly && ( {deleteSearchLabel} )} {!isReadonly && localSearch.length > 0 && ( {strings?.label?.deleteSearchHint} )} ) }