import { useMemo, useState, useEffect, useCallback } from 'react' import { Button, IconButton, Tooltip, Divider, List, ListItemButton, ListItem, Switch, Box, useMediaQuery, } from '@mui/material' import { Power, PowerOff, DeleteForever, Edit, ModeComment, AddBox, CheckBox, CheckBoxOutlineBlank, ChevronRight, ExpandMore, } from '@mui/icons-material' import { DndContext, closestCenter, PointerSensor, useSensor, useSensors, DragEndEvent, } from '@dnd-kit/core' import { SortableContext, verticalListSortingStrategy, useSortable, arrayMove, } from '@dnd-kit/sortable' import { CSS } from '@dnd-kit/utilities' import P3xrAccordion from '../../components/P3xrAccordion' import P3xrButton from '../../components/P3xrButton' import ConnectionDialog from '../../dialogs/ConnectionDialog' import AiSettingsDialog from '../../dialogs/AiSettingsDialog' import TreeSettingsDialog from '../../dialogs/TreeSettingsDialog' import { useI18nStore } from '../../stores/i18n.store' import { useRedisStateStore } from '../../stores/redis-state.store' import { useSettingsStore } from '../../stores/settings.store' import { useCommonStore } from '../../stores/common.store' import { useOverlayStore } from '../../stores/overlay.store' import { useMainCommandStore } from '../../stores/main-command.store' import { request, onSocketEvent } from '../../stores/socket.service' import { getPersistentItem, setPersistentItem } from '../../stores/electron-bridge' // --- Sortable connection row (whole row is draggable) --- function SortableConnectionItem({ conn, isLast, children }: { conn: any, isLast: boolean, children: React.ReactNode }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: conn.id }) return ( <> {children} {!isLast && !isDragging && } ) } // --- Sortable group block (group header is drag handle, whole block moves) --- function SortableGroupBlock({ group, children }: { group: { name: string; connections: any[] }, children: React.ReactNode }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: `group-${group.name}` }) return ( {/* Group header is the drag handle */} {children} ) } export default function SettingsPage() { const strings = useI18nStore(s => s.strings) const connection = useRedisStateStore(s => s.connection) const connections = useRedisStateStore(s => s.connections) const cfg = useRedisStateStore(s => s.cfg) const redisConnections = useRedisStateStore(s => s.redisConnections) const settings = useSettingsStore() const { toast, confirm, generalHandleError } = useCommonStore() const { disconnect } = useMainCommandStore() const isXs = useMediaQuery('(max-width: 599px)') const connectionsList = connections?.list ?? [] const readonlyConnections = cfg?.readonlyConnections === true const currentConnectionId = connection?.id const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }) ) // Group mode const [groupModeEnabled, setGroupModeEnabled] = useState(() => { return getPersistentItem('p3xr-connection-group-mode') === 'true' }) const [collapsedGroups, setCollapsedGroups] = useState>(() => { try { const stored = getPersistentItem('p3xr-collapsed-connection-groups') return stored ? new Set(JSON.parse(stored)) : new Set() } catch { return new Set() } }) // Force re-render on socket events const [, setTick] = useState(0) useEffect(() => { const unsubs = [ onSocketEvent('connections', () => setTick(t => t + 1)), onSocketEvent('configuration', () => setTick(t => t + 1)), onSocketEvent('redis-status', () => setTick(t => t + 1)), ] return () => unsubs.forEach(fn => fn()) }, []) const toggleGroupMode = () => { const next = !groupModeEnabled setGroupModeEnabled(next) setPersistentItem('p3xr-connection-group-mode', String(next)) } const toggleGroup = (name: string) => { setCollapsedGroups(prev => { const next = new Set(prev) next.has(name) ? next.delete(name) : next.add(name) setPersistentItem('p3xr-collapsed-connection-groups', JSON.stringify([...next])) return next }) } // Grouped connections const groupedConnections = useMemo(() => { const groups = new Map() for (const conn of connectionsList) { const name = conn.group?.trim() || '' if (!groups.has(name)) groups.set(name, []) groups.get(name)!.push(conn) } return Array.from(groups, ([name, conns]) => ({ name, connections: conns })) }, [connectionsList]) const getConnectionClients = useCallback((conn: any) => { const results: { key: string; clients: number }[] = [] for (const key of Object.keys(redisConnections || {})) { if (redisConnections[key]?.connection?.name === conn.name) { results.push({ key, clients: redisConnections[key].clients?.length || 0 }) } } return results }, [redisConnections]) // --- Drag & drop handlers --- const handleDragEndFlat = async (event: DragEndEvent) => { const { active, over } = event if (!over || active.id === over.id) return const oldIndex = connectionsList.findIndex((c: any) => c.id === active.id) const newIndex = connectionsList.findIndex((c: any) => c.id === over.id) if (oldIndex === -1 || newIndex === -1) return const reordered = arrayMove(connectionsList, oldIndex, newIndex) try { await request({ action: 'connections-reorder', payload: { ids: reordered.map((c: any) => c.id) } }) } catch (e) { generalHandleError(e) } } // Reorder groups themselves const handleDragEndGroupReorder = async (event: DragEndEvent) => { const { active, over } = event if (!over || active.id === over.id) return const oldIndex = groupedConnections.findIndex(g => `group-${g.name}` === active.id) const newIndex = groupedConnections.findIndex(g => `group-${g.name}` === over.id) if (oldIndex === -1 || newIndex === -1) return const reordered = arrayMove(groupedConnections, oldIndex, newIndex) // Flatten all connections in new group order and persist const allIds: string[] = [] for (const group of reordered) { for (const conn of group.connections) { allIds.push(conn.id) } } try { await request({ action: 'connections-reorder', payload: { ids: allIds } }) } catch (e) { generalHandleError(e) } } // Reorder connections within a group const handleDragEndGroup = async (event: DragEndEvent, groupName: string) => { const { active, over } = event if (!over || active.id === over.id) return const group = groupedConnections.find(g => g.name === groupName) if (!group) return const oldIndex = group.connections.findIndex((c: any) => c.id === active.id) const newIndex = group.connections.findIndex((c: any) => c.id === over.id) if (oldIndex === -1 || newIndex === -1) return const reordered = arrayMove(group.connections, oldIndex, newIndex) try { await request({ action: 'connections-reorder', payload: { group: groupName || undefined, ids: reordered.map((c: any) => c.id) } }) } catch (e) { generalHandleError(e) } } // Dialog states const [dialogOpen, setDialogOpen] = useState(false) const [dialogType, setDialogType] = useState<'new' | 'edit'>('new') const [dialogModel, setDialogModel] = useState(undefined) const [aiDialogOpen, setAiDialogOpen] = useState(false) const [treeDialogOpen, setTreeDialogOpen] = useState(false) const handleConnect = async (conn: any) => { const cloned = structuredClone(conn) const overlay = useOverlayStore.getState() try { const dbStorageKey = settings.getStorageKeyCurrentDatabase(cloned.id) let db: string | undefined try { db = localStorage.getItem(dbStorageKey) ?? undefined } catch {} overlay.show({ message: strings?.title?.connectingRedis }) const response = await request({ action: 'connection-connect', payload: { connection: cloned, db }, }) const databaseIndexes: number[] = [] let i = 0 while (i < response.databases) databaseIndexes.push(i++) const commands: string[] = [] Object.keys(response.commands ?? {}).forEach(k => commands.push(response.commands[k][0])) commands.sort() const modules = Array.isArray(response.modules) ? response.modules : [] useRedisStateStore.setState({ page: 1, monitor: false, dbsize: response.dbsize, databaseIndexes, connection: cloned, commands, commandsMeta: response.commandsMeta ?? {}, modules, hasReJSON: modules.some((m: any) => m.name === 'ReJSON'), hasRediSearch: modules.some((m: any) => m.name === 'search'), hasTimeSeries: modules.some((m: any) => m.name === 'timeseries' || m.name === 'Timeseries'), }) useCommonStore.getState().loadRedisInfoResponse({ response }) try { localStorage.setItem(settings.connectInfoStorageKey, JSON.stringify(cloned)) } catch {} } catch (error: any) { try { localStorage.removeItem(settings.connectInfoStorageKey) } catch {} useRedisStateStore.setState({ connection: undefined }) generalHandleError(error) } finally { overlay.hide() } } const handleDelete = async (conn: any) => { try { await confirm({ message: strings?.confirm?.deleteConnectionText }) await request({ action: 'connection-delete', payload: { id: conn.id } }) toast(strings?.status?.deleted) } catch (e: any) { if (e !== undefined) generalHandleError(e) } } const handleConnectionForm = (formType: 'new' | 'edit', formModel?: any) => { setDialogType(formType) setDialogModel(formModel) setDialogOpen(true) } // AI settings const isAiEnabled = cfg?.aiEnabled !== false const groqApiKey = cfg?.groqApiKey || '' const hasGroqApiKey = groqApiKey.startsWith('gsk_') && groqApiKey.length > 20 const isUseOwnKey = cfg?.aiUseOwnKey === true && hasGroqApiKey const isAiReadonly = readonlyConnections || cfg?.groqApiKeyReadonly === true const groqApiKeyDisplay = !groqApiKey ? strings?.label?.aiGroqApiKeyNotSet : groqApiKey.length <= 8 ? '****' : `${groqApiKey.slice(0, 4)}...${groqApiKey.slice(-4)}` const toggleAiEnabled = async (enabled: boolean) => { try { await request({ action: 'set-groq-api-key', payload: { apiKey: groqApiKey, aiEnabled: enabled } }) useRedisStateStore.setState({ cfg: { ...cfg, aiEnabled: enabled } }) } catch (e) { generalHandleError(e) } } const toggleUseOwnKey = async (useOwn: boolean) => { if (useOwn && !hasGroqApiKey) return try { await request({ action: 'set-groq-api-key', payload: { apiKey: groqApiKey, aiEnabled: isAiEnabled, aiUseOwnKey: useOwn } }) useRedisStateStore.setState({ cfg: { ...cfg, aiUseOwnKey: useOwn } }) } catch (e) { generalHandleError(e) } } // --- Responsive action button (filled/contained, tooltip only when icon-only) --- const ActionBtn = ({ icon, label, color, onClick }: { icon: React.ReactNode, label: string, color: 'primary' | 'secondary' | 'error', onClick: () => void }) => isXs ? ( ) : ( ) // --- Connection info + buttons (shared between flat and grouped) --- const ConnectionButtons = ({ conn }: { conn: any }) => ( <> {conn.name} {conn.host}:{conn.port} {getConnectionClients(conn).map(entry => { const fn = strings?.page?.overview?.connectedCount return typeof fn === 'function' ? fn({ length: entry.clients }) : '' }).join(' ')}  {currentConnectionId !== conn.id ? ( } label={strings?.intention?.connect} color="secondary" onClick={() => handleConnect(conn)} /> ) : ( } label={strings?.intention?.disconnect} color="secondary" onClick={() => disconnect()} /> )} {!readonlyConnections && ( <> } label={strings?.intention?.delete} color="error" onClick={() => handleDelete(conn)} /> } label={strings?.intention?.edit} color="primary" onClick={() => handleConnectionForm('edit', conn)} /> )} {readonlyConnections && ( } label={strings?.intention?.view} color="primary" onClick={() => handleConnectionForm('edit', conn)} /> )} ) return ( <> {/* === Connections === */} : } onClick={toggleGroupMode} /> {!readonlyConnections && ( } onClick={() => handleConnectionForm('new')} /> )} }> {connectionsList.length === 0 && ( {strings?.intention?.noConnectionsInSettings} )} {/* Grouped mode: groups are draggable + connections within each group are draggable */} {connectionsList.length > 0 && groupModeEnabled && ( `group-${g.name}`)} strategy={verticalListSortingStrategy}> {groupedConnections.map(group => ( toggleGroup(group.name)} sx={(theme) => ({ display: 'flex', alignItems: 'center', gap: 1, px: 2, py: 1, cursor: 'grab', fontWeight: 700, fontSize: 13, userSelect: 'none', opacity: 0.8, bgcolor: theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.04)', borderBottom: 1, borderColor: theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.06)', '&:hover': { opacity: 1, bgcolor: 'action.hover', }, })} > {collapsedGroups.has(group.name) ? : } {group.name || strings?.label?.ungrouped} ({group.connections.length}) {!collapsedGroups.has(group.name) && ( handleDragEndGroup(e, group.name)}> c.id)} strategy={verticalListSortingStrategy}> {group.connections.map((conn: any, i: number) => ( ))} )} ))} )} {/* Flat mode with drag & drop */} {connectionsList.length > 0 && !groupModeEnabled && ( c.id)} strategy={verticalListSortingStrategy}> {connectionsList.map((conn: any, i: number) => ( ))} )}
{/* === GUI Framework === */} { setPersistentItem('p3xr-frontend', 'ng') location.href = '/ng/settings' }} sx={{ px: 3, py: 1, cursor: 'pointer', fontWeight: 500, fontSize: 14, userSelect: 'none', bgcolor: 'transparent', color: 'text.primary', '&:hover': { bgcolor: 'action.hover' }, }}> Angular React
{/* === AI Settings === */} } onClick={() => setAiDialogOpen(true)} /> ) : undefined }> {strings?.label?.aiEnabled} toggleAiEnabled(checked)} /> {isAiEnabled && hasGroqApiKey && ( <> {strings?.label?.aiRouteViaNetwork} toggleUseOwnKey(!checked)} /> {isUseOwnKey ? strings?.label?.aiRoutingDirect : strings?.label?.aiRoutingNetwork} {!isUseOwnKey && ( <> console.groq.com )} {strings?.label?.aiGroqApiKey} {groqApiKeyDisplay} )}
{/* === Tree/Redis Settings === */} } onClick={() => setTreeDialogOpen(true)} /> }> {[ { label: strings?.form?.treeSettings?.field?.treeSeparator, value: settings.redisTreeDivider || strings?.label?.treeSeparatorEmptyNote }, { label: strings?.form?.treeSettings?.field?.page, value: settings.pageCount, hint: strings?.form?.treeSettings?.error?.page }, { label: strings?.form?.treeSettings?.field?.keyPageCount, value: settings.keyPageCount, hint: strings?.form?.treeSettings?.error?.keyPageCount }, { label: strings?.form?.treeSettings?.maxValueDisplay, value: settings.maxValueDisplay, hint: strings?.form?.treeSettings?.maxValueDisplayInfo }, { label: strings?.form?.treeSettings?.maxKeys, value: settings.maxKeys, hint: strings?.form?.treeSettings?.maxKeysInfo }, { label: strings?.form?.treeSettings?.field?.keysSort, value: settings.keysSort ? strings?.label?.keysSort?.on : strings?.label?.keysSort?.off }, { label: strings?.form?.treeSettings?.field?.searchMode, value: settings.searchClientSide ? strings?.form?.treeSettings?.label?.searchModeClient : strings?.form?.treeSettings?.label?.searchModeServer }, { label: strings?.form?.treeSettings?.field?.searchModeStartsWith, value: settings.searchStartsWith ? strings?.form?.treeSettings?.label?.searchModeStartsWith : strings?.form?.treeSettings?.label?.searchModeIncludes }, { label: null, value: settings.jsonFormat === 2 ? strings?.form?.treeSettings?.label?.jsonFormatTwoSpace : strings?.form?.treeSettings?.label?.jsonFormatFourSpace }, { label: null, value: settings.animation ? strings?.form?.treeSettings?.label?.animation : strings?.form?.treeSettings?.label?.noAnimation }, ].map((item, i) => ( setTreeDialogOpen(true)} sx={{ px: 2, py: 1 }}> {item.label && {item.label}} {String(item.value)} {item.hint && {item.hint}} {i < 9 && } ))} {/* Dialogs */} setDialogOpen(false)} /> setAiDialogOpen(false)} /> setTreeDialogOpen(false)} /> ) }