RSS Git Download  Clone
Raw Blame History 29kB 568 lines
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 (
        <>
            <Box
                ref={setNodeRef}
                {...attributes}
                {...listeners}
                sx={{
                    display: 'flex', alignItems: 'center', gap: 0.5,
                    px: 1, pl: 2, py: 1, minHeight: 56, boxSizing: 'border-box',
                    cursor: 'grab',
                    transform: CSS.Transform.toString(transform),
                    transition,
                    opacity: isDragging ? 0.4 : 1,
                    bgcolor: isDragging ? 'action.hover' : undefined,
                    borderRadius: isDragging ? '4px' : undefined,
                }}
            >
                {children}
            </Box>
            {!isLast && !isDragging && <Divider />}
        </>
    )
}

// --- 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 (
        <Box
            ref={setNodeRef}
            {...attributes}
            sx={{
                transform: CSS.Transform.toString(transform),
                transition,
                opacity: isDragging ? 0.4 : 1,
                bgcolor: isDragging ? 'action.hover' : undefined,
                borderRadius: isDragging ? '4px' : undefined,
            }}
        >
            {/* Group header is the drag handle */}
            <Box {...listeners} sx={{ cursor: 'grab' }}>
                {children}
            </Box>
        </Box>
    )
}

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<Set<string>>(() => {
        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<string, any[]>()
        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<any>(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 ? (
        <Tooltip title={label} placement="top">
            <Button variant="contained" color={color} onClick={onClick}
                sx={{ minWidth: 40, width: 40, height: 40, p: 0, borderRadius: '4px' }}
                aria-label={label}>
                {icon}
            </Button>
        </Tooltip>
    ) : (
        <Button variant="contained" color={color} size="small" onClick={onClick}
            sx={{ minWidth: 'auto', px: 1, textTransform: 'uppercase', letterSpacing: '0.01em' }}>
            {icon}<span style={{ marginLeft: 3 }}>{label}</span>
        </Button>
    )

    // --- Connection info + buttons (shared between flat and grouped) ---
    const ConnectionButtons = ({ conn }: { conn: any }) => (
        <>
            <Box sx={{ flex: 1, minWidth: 0, overflow: 'hidden' }}>
                <Box sx={{ fontWeight: 700 }}>{conn.name}</Box>
                <Box sx={{ fontSize: 13, opacity: 0.7 }}>{conn.host}:{conn.port}</Box>
                <Box sx={{ fontSize: 13, opacity: 0.7 }}>
                    {getConnectionClients(conn).map(entry => {
                        const fn = strings?.page?.overview?.connectedCount
                        return typeof fn === 'function' ? fn({ length: entry.clients }) : ''
                    }).join(' ')}&nbsp;
                </Box>
            </Box>
            {currentConnectionId !== conn.id ? (
                <ActionBtn icon={<Power fontSize="small" />} label={strings?.intention?.connect} color="secondary" onClick={() => handleConnect(conn)} />
            ) : (
                <ActionBtn icon={<i className="fa fa-power-off" />} label={strings?.intention?.disconnect} color="secondary" onClick={() => disconnect()} />
            )}
            {!readonlyConnections && (
                <>
                    <ActionBtn icon={<DeleteForever fontSize="small" />} label={strings?.intention?.delete} color="error" onClick={() => handleDelete(conn)} />
                    <ActionBtn icon={<Edit fontSize="small" />} label={strings?.intention?.edit} color="primary" onClick={() => handleConnectionForm('edit', conn)} />
                </>
            )}
            {readonlyConnections && (
                <ActionBtn icon={<ModeComment fontSize="small" />} label={strings?.intention?.view} color="primary" onClick={() => handleConnectionForm('edit', conn)} />
            )}
        </>
    )

    return (
        <>
            {/* === Connections === */}
            <P3xrAccordion title={strings?.label?.connections} accordionKey="settings"
                actions={
                    <>
                        <P3xrButton
                            label={strings?.label?.grouped}
                            icon={groupModeEnabled ? <CheckBox fontSize="small" /> : <CheckBoxOutlineBlank fontSize="small" />}
                            onClick={toggleGroupMode}
                        />
                        {!readonlyConnections && (
                            <P3xrButton
                                label={strings?.intention?.connectionAdd}
                                icon={<AddBox fontSize="small" />}
                                onClick={() => handleConnectionForm('new')}
                            />
                        )}
                    </>
                }>
                {connectionsList.length === 0 && (
                    <Box sx={{ p: 2 }}>{strings?.intention?.noConnectionsInSettings}</Box>
                )}

                {/* Grouped mode: groups are draggable + connections within each group are draggable */}
                {connectionsList.length > 0 && groupModeEnabled && (
                    <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEndGroupReorder}>
                        <SortableContext items={groupedConnections.map(g => `group-${g.name}`)} strategy={verticalListSortingStrategy}>
                            {groupedConnections.map(group => (
                                <SortableGroupBlock key={group.name} group={group}>
                                    <Box
                                        onClick={() => 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) ? <ChevronRight sx={{ fontSize: 18 }} /> : <ExpandMore sx={{ fontSize: 18 }} />}
                                        <span>{group.name || strings?.label?.ungrouped}</span>
                                        <span style={{ opacity: 0.5, fontWeight: 400, fontSize: 12 }}>({group.connections.length})</span>
                                    </Box>
                                    {!collapsedGroups.has(group.name) && (
                                        <DndContext sensors={sensors} collisionDetection={closestCenter}
                                            onDragEnd={e => handleDragEndGroup(e, group.name)}>
                                            <SortableContext items={group.connections.map((c: any) => c.id)} strategy={verticalListSortingStrategy}>
                                                {group.connections.map((conn: any, i: number) => (
                                                    <SortableConnectionItem key={conn.id} conn={conn} isLast={i === group.connections.length - 1}>
                                                        <ConnectionButtons conn={conn} />
                                                    </SortableConnectionItem>
                                                ))}
                                            </SortableContext>
                                        </DndContext>
                                    )}
                                </SortableGroupBlock>
                            ))}
                        </SortableContext>
                    </DndContext>
                )}

                {/* Flat mode with drag & drop */}
                {connectionsList.length > 0 && !groupModeEnabled && (
                    <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEndFlat}>
                        <SortableContext items={connectionsList.map((c: any) => c.id)} strategy={verticalListSortingStrategy}>
                            {connectionsList.map((conn: any, i: number) => (
                                <SortableConnectionItem key={conn.id} conn={conn} isLast={i === connectionsList.length - 1}>
                                    <ConnectionButtons conn={conn} />
                                </SortableConnectionItem>
                            ))}
                        </SortableContext>
                    </DndContext>
                )}
            </P3xrAccordion>
            <br />
            {/* === GUI Framework === */}
            <P3xrAccordion title="GUI" accordionKey="gui-framework">
                <Box sx={{ display: 'flex', justifyContent: 'flex-end', p: 2 }}>
                    <Box sx={{
                        display: 'inline-flex', borderRadius: '4px', overflow: 'hidden',
                        border: 1, borderColor: 'divider',
                    }}>
                        <Box component="span"
                            onClick={() => {
                                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
                        </Box>
                        <Box component="span"
                            sx={{
                                px: 3, py: 1, fontWeight: 700,
                                fontSize: 14, userSelect: 'none',
                                bgcolor: 'primary.main', color: 'primary.contrastText',
                            }}>
                            React
                        </Box>
                    </Box>
                </Box>
            </P3xrAccordion>
            <br />
            {/* === AI Settings === */}
            <P3xrAccordion title={strings?.label?.aiSettings} accordionKey="ai-settings"
                actions={
                    !readonlyConnections && !cfg?.groqApiKeyReadonly ? (
                        <P3xrButton
                            label={strings?.intention?.edit}
                            icon={<Edit fontSize="small" />}
                            onClick={() => setAiDialogOpen(true)}
                        />
                    ) : undefined
                }>
                <List disablePadding>
                    <ListItem>
                        <Box sx={{ display: 'flex', width: '100%', alignItems: 'center' }}>
                            <Box sx={{ flex: 1, fontWeight: 500 }}>{strings?.label?.aiEnabled}</Box>
                            <Switch checked={isAiEnabled} disabled={isAiReadonly} onChange={(_, checked) => toggleAiEnabled(checked)} />
                        </Box>
                    </ListItem>
                    {isAiEnabled && hasGroqApiKey && (
                        <>
                            <Divider />
                            <ListItem>
                                <Box sx={{ width: '100%' }}>
                                    <Box sx={{ display: 'flex', alignItems: 'center' }}>
                                        <Box sx={{ flex: 1, fontWeight: 500 }}>{strings?.label?.aiRouteViaNetwork}</Box>
                                        <Switch checked={!isUseOwnKey} disabled={isAiReadonly} onChange={(_, checked) => toggleUseOwnKey(!checked)} />
                                    </Box>
                                    <Box sx={{ fontSize: 12, opacity: 0.7 }}>
                                        {isUseOwnKey ? strings?.label?.aiRoutingDirect : strings?.label?.aiRoutingNetwork}
                                        {!isUseOwnKey && (
                                            <> <a href="https://console.groq.com" target="_blank" rel="noreferrer" style={{ color: 'inherit', textDecoration: 'underline' }}>console.groq.com</a></>
                                        )}
                                    </Box>
                                </Box>
                            </ListItem>
                            <Divider />
                            <ListItem>
                                <Box sx={{ display: 'flex', width: '100%' }}>
                                    <Box sx={{ flex: 1, fontWeight: 500 }}>{strings?.label?.aiGroqApiKey}</Box>
                                    <Box sx={{ fontFamily: hasGroqApiKey ? 'monospace' : 'inherit' }}>{groqApiKeyDisplay}</Box>
                                </Box>
                            </ListItem>
                        </>
                    )}
                </List>
            </P3xrAccordion>
            <br />
            {/* === Tree/Redis Settings === */}
            <P3xrAccordion title={strings?.form?.treeSettings?.label?.formName} accordionKey="tree-settings"
                actions={
                    <P3xrButton
                        label={strings?.intention?.edit}
                        icon={<Edit fontSize="small" />}
                        onClick={() => setTreeDialogOpen(true)}
                    />
                }>
                <List disablePadding>
                    {[
                        { 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) => (
                        <Box key={i}>
                            <ListItemButton onClick={() => setTreeDialogOpen(true)} sx={{ px: 2, py: 1 }}>
                                <Box sx={{ width: '100%' }}>
                                    <Box sx={{ display: 'flex', width: '100%' }}>
                                        {item.label && <Box sx={{ flex: 1, fontWeight: 500 }}>{item.label}</Box>}
                                        <Box sx={{ fontWeight: 400, opacity: 0.8 }}>{String(item.value)}</Box>
                                    </Box>
                                    {item.hint && <Box sx={{ fontSize: 12, opacity: 0.7 }}>{item.hint}</Box>}
                                </Box>
                            </ListItemButton>
                            {i < 9 && <Divider />}
                        </Box>
                    ))}
                </List>
            </P3xrAccordion>

            <Box sx={{ height: 8 }} />

            {/* Dialogs */}
            <ConnectionDialog
                open={dialogOpen}
                type={dialogType}
                model={dialogModel}
                onClose={() => setDialogOpen(false)}
            />
            <AiSettingsDialog
                open={aiDialogOpen}
                onClose={() => setAiDialogOpen(false)}
            />
            <TreeSettingsDialog
                open={treeDialogOpen}
                onClose={() => setTreeDialogOpen(false)}
            />
        </>
    )
}