RSS Git Download  Clone
Raw Blame History 20kB 565 lines
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted, nextTick, toRaw } from 'vue'
import { storeToRefs } from 'pinia'
import { useVirtualizer } from '@tanstack/vue-virtual'
import { useI18nStore } from '../../stores/i18n'
import { useRedisStateStore } from '../../stores/redis-state'
import { useSettingsStore } from '../../stores/settings'
import { useCommonStore } from '../../stores/common'
import { useThemeStore } from '../../stores/theme'
import { useMainCommandStore, onCommandEvent, emitCommand } from '../../stores/main-command'
import { request } from '../../stores/socket.service'
import { keysToTreeControl } from '../../stores/tree-builder'
import { navigateTo } from '../../stores/navigation.store'
import humanizeDuration from 'humanize-duration'

const ROW_HEIGHT = 28
const INDENT_PX = 20

interface FlatTreeNode {
    label: string
    key: string
    level: number
    expandable: boolean
    type: 'folder' | 'element'
    childCount: number
    keysInfo?: { type: string; length: number; ttl?: number }
    _sourceNode?: any
}

const typeIcons: Record<string, string> = {
    hash: 'fas fa-hashtag', list: 'fas fa-list-ol', set: 'fas fa-list',
    string: 'fas fa-ellipsis-h', zset: 'fas fa-chart-line', stream: 'fas fa-stream',
    json: 'fas fa-code', timeseries: 'fas fa-chart-area', bloom: 'fas fa-filter',
    cuckoo: 'fas fa-filter', topk: 'fas fa-trophy', cms: 'fas fa-chart-simple',
    tdigest: 'fas fa-chart-bar', vectorset: 'fas fa-brain',
}

// Theme-specific colors (from React themes/index.ts)
const TREE_BRANCH_COLOR: Record<string, string> = {
    enterprise: '#343dff', light: '#a900a9', redis: '#964900',
    dark: '#bec2ff', darkNeu: '#bec2ff', darkoBluo: '#bec2ff',
    matrix: '#76ff03',
}
const COMMON_WARN_COLOR: Record<string, string> = {
    enterprise: '#03a9f4', light: '#607d8b', redis: '#f44336',
    dark: '#9fa8da', darkNeu: '#2196f3', darkoBluo: '#03a9f4',
    matrix: '#4caf50',
}

const i18n = useI18nStore()
const state = useRedisStateStore()
const settings = useSettingsStore()
const common = useCommonStore()
const cmd = useMainCommandStore()
const { themeKey } = storeToRefs(useThemeStore())

const strings = computed(() => i18n.strings)
const isReadonly = computed(() => state.connection?.readonly === true)
const divider = computed(() => settings.redisTreeDivider)
const treeBranchColor = computed(() => TREE_BRANCH_COLOR[themeKey.value])
const warnColor = computed(() => COMMON_WARN_COLOR[themeKey.value])

// --- Expansion state (sessionStorage) ---
const expandedKeys = ref(new Set<string>())
const hierarchicalNodes = ref<any[]>([])
const parentRef = ref<HTMLDivElement | null>(null)
const tick = ref(0) // for TTL repaint

function getExpansionKey(): string {
    const connId = state.connection?.id || 'none'
    const db = state.currentDatabase ?? 0
    return `p3xr-tree-expanded-${connId}-${db}`
}

function saveExpansion() {
    try { sessionStorage.setItem(getExpansionKey(), JSON.stringify([...expandedKeys.value])) } catch {}
}

function restoreExpansion() {
    try {
        const raw = sessionStorage.getItem(getExpansionKey())
        if (raw) expandedKeys.value = new Set(JSON.parse(raw))
    } catch {}
}

// --- Tree building ---
async function rebuildTree() {
    const keys = toRaw(state.paginatedKeys)
    const info = toRaw(state.keysInfo)
    if (!keys || keys.length === 0) {
        hierarchicalNodes.value = []
        return
    }
    // Deep-clone to strip Vue reactivity — Worker postMessage needs plain objects
    const plainInfo = info ? JSON.parse(JSON.stringify(info)) : undefined
    const result = await keysToTreeControl({
        keys: [...keys],
        divider: divider.value,
        keysInfo: plainInfo,
        savedExpandedNodes: [...expandedKeys.value].map(k => ({ key: k })),
    })
    hierarchicalNodes.value = result.nodes || []
    if (result.expandedNodes?.length > 0) {
        expandedKeys.value = new Set(result.expandedNodes.map((n: any) => n.key || n))
    }
}

// --- Flatten (hierarchical → flat for virtualizer) ---
const dataSource = computed(() => {
    // eslint-disable-next-line no-unused-expressions
    tick.value // depend on tick for TTL repaints
    const result: FlatTreeNode[] = []
    const flatten = (nodes: any[], level: number) => {
        for (const node of nodes) {
            result.push({
                label: node.label,
                key: node.key,
                level,
                expandable: node.type === 'folder',
                type: node.type,
                childCount: node.childCount ?? 0,
                keysInfo: node.keysInfo,
                _sourceNode: node,
            })
            if (node.type === 'folder' && expandedKeys.value.has(node.key) && node.children?.length > 0) {
                flatten(node.children, level + 1)
            }
        }
    }
    flatten(hierarchicalNodes.value, 0)
    return result
})

// --- Virtual scrolling (@tanstack/vue-virtual) ---
const virtualizer = useVirtualizer(computed(() => ({
    count: dataSource.value.length,
    getScrollElement: () => parentRef.value,
    estimateSize: () => ROW_HEIGHT,
    overscan: 10,
})))

// --- Interaction handlers ---
function toggleExpand(node: FlatTreeNode) {
    const newSet = new Set(expandedKeys.value)
    if (newSet.has(node.key)) newSet.delete(node.key)
    else newSet.add(node.key)
    expandedKeys.value = newSet
    saveExpansion()
}

function selectNode(node: FlatTreeNode) {
    navigateTo('database.key', { key: node.key })
}

function str(val: any, opts?: any): string {
    if (typeof val === 'function') return val(opts)
    return val || ''
}

async function deleteKey(e: Event, key: string) {
    e.preventDefault()
    e.stopPropagation()
    try {
        const msg = str(strings.value?.confirm?.deleteKey, { key })
        await common.confirm({ message: msg })
        await request({ action: 'key/delete', payload: { key } })
        navigateTo('database.statistics')
        const toast = str(strings.value?.status?.deletedKey, { key })
        common.toast(toast)
        await cmd.refresh({ force: true })
    } catch (err) {
        if (err !== undefined) common.generalHandleError(err)
    }
}

async function deleteTree(e: Event, node: FlatTreeNode) {
    e.stopPropagation()
    try {
        const msg = str(strings.value?.confirm?.deleteTree, { key: node.key })
        await common.confirm({ message: msg })
        await request({ action: 'key/del-tree', payload: { key: node.key, redisTreeDivider: divider.value } })
        const toast = str(strings.value?.status?.deletedTree, { key: node.key })
        common.toast(toast)
        await cmd.refresh({ force: true })
    } catch (err) {
        if (err !== undefined) common.generalHandleError(err)
    }
}

function addKey(e: Event, node: FlatTreeNode) {
    e.stopPropagation()
    emitCommand('key-new', { event: e, node: node._sourceNode ?? { key: node.key } })
}

function nodeTooltip(node: FlatTreeNode): string {
    if (node.type === 'folder') return node.key
    const typeName = node.keysInfo?.type || ''
    return typeName ? `${typeName} - ${node.key}` : node.key
}

// --- TTL ---
function getRemainingTtl(node: FlatTreeNode): number {
    const ttl = node.keysInfo?.ttl
    if (!ttl || ttl <= 0) return -1
    const fetchedAt = state.keysInfoFetchedAt ?? Date.now()
    const elapsed = Math.floor((Date.now() - fetchedAt) / 1000)
    const remaining = ttl - elapsed
    return remaining > 0 ? remaining : -1
}

function formatTtl(node: FlatTreeNode): string {
    const remaining = getRemainingTtl(node)
    if (remaining <= 0) return ''
    try {
        const hdOpts = settings.getHumanizeDurationOptions()
        return humanizeDuration(remaining * 1000, { ...hdOpts, largest: 2, round: true, delimiter: ' ' })
    } catch {
        if (remaining < 60) return remaining + 's'
        if (remaining < 3600) return Math.floor(remaining / 60) + 'm'
        return Math.floor(remaining / 3600) + 'h'
    }
}

function getTtlColor(node: FlatTreeNode): string {
    const remaining = getRemainingTtl(node)
    if (remaining <= 0) return ''
    if (remaining < 300) return '#f44336'   // red (critical)
    if (remaining < 3600) return '#ff9800'  // orange (medium)
    return '#4caf50'                         // green (safe)
}

function isTtlPulsing(node: FlatTreeNode): boolean {
    const remaining = getRemainingTtl(node)
    return remaining > 0 && remaining < 30
}

// --- TTL adaptive repaint ---
let ttlTimer: any = null
function startTtlRepaint() {
    const doTick = () => {
        let minTtl = Infinity
        let hasExpired = false
        for (const node of dataSource.value) {
            if (node.type === 'folder') continue
            const remaining = getRemainingTtl(node)
            if (remaining === -1) continue
            if (remaining <= 0) hasExpired = true
            else if (remaining < minTtl) minTtl = remaining
        }
        if (hasExpired) {
            cmd.refresh({ force: true })
            ttlTimer = setTimeout(doTick, 3000)
            return
        }
        tick.value++ // trigger reactivity for repaint
        let interval: number
        if (minTtl <= 30) interval = 1000
        else if (minTtl <= 300) interval = 5000
        else interval = 30000
        ttlTimer = setTimeout(doTick, interval)
    }
    ttlTimer = setTimeout(doTick, 1000)
}

// --- Event subscriptions ---
const unsubs: Array<() => void> = []

onMounted(() => {
    restoreExpansion()
    rebuildTree()
    startTtlRepaint()

    // Command events
    unsubs.push(onCommandEvent('tree-refresh', () => rebuildTree()))
    unsubs.push(onCommandEvent('expand-all', () => {
        const allKeys = new Set<string>()
        const collect = (nodes: any[]) => {
            for (const n of nodes) {
                if (n.type === 'folder') { allKeys.add(n.key); if (n.children) collect(n.children) }
            }
        }
        collect(hierarchicalNodes.value)
        expandedKeys.value = allKeys
        saveExpansion()
    }))
    unsubs.push(onCommandEvent('collapse-all', () => {
        expandedKeys.value = new Set()
        saveExpansion()
    }))
    for (let lvl = 1; lvl <= 5; lvl++) {
        const level = lvl
        unsubs.push(onCommandEvent(`expand-level-${level}`, () => {
            const keys = new Set<string>()
            const collect = (nodes: any[], depth: number) => {
                for (const n of nodes) {
                    if (n.type === 'folder' && depth < level) {
                        keys.add(n.key)
                        if (n.children) collect(n.children, depth + 1)
                    }
                }
            }
            collect(hierarchicalNodes.value, 0)
            expandedKeys.value = keys
            saveExpansion()
        }))
    }
})

onUnmounted(() => {
    if (ttlTimer) clearTimeout(ttlTimer)
    unsubs.forEach(fn => fn())
})

// Watch for state changes that require tree rebuild
watch(
    () => [state.paginatedKeys, state.keysInfo, divider.value, state.page],
    () => rebuildTree(),
    { deep: true }
)

// Watch connection/database changes to restore expansion
watch(
    () => [state.connection?.id, state.currentDatabase],
    () => {
        expandedKeys.value = new Set()
        restoreExpansion()
        rebuildTree()
    }
)
</script>

<template>
    <div v-if="dataSource.length === 0" style="padding: 8px; opacity: 0.5; font-size: 13px;">
        {{ strings?.label?.noKeys }}
    </div>

    <div v-else ref="parentRef" class="p3xr-tree-viewport">
        <div :style="{ height: virtualizer.getTotalSize() + 'px', width: '100%', position: 'relative' }">
            <div
                v-for="virtualRow in virtualizer.getVirtualItems()"
                :key="dataSource[virtualRow.index].type + '-' + dataSource[virtualRow.index].key"
                :data-p3xr-tree-key="dataSource[virtualRow.index].type === 'folder' ? '' : dataSource[virtualRow.index].key"
                class="p3xr-tree-row"
                :style="{
                    position: 'absolute', top: 0, left: 0, width: '100%',
                    height: ROW_HEIGHT + 'px',
                    transform: 'translateY(' + virtualRow.start + 'px)',
                    paddingLeft: (dataSource[virtualRow.index].level * INDENT_PX + 4) + 'px',
                }"
            >
                <!-- Folder expand/collapse icon -->
                <span
                    v-if="dataSource[virtualRow.index].expandable"
                    class="p3xr-tree-branch"
                    :class="{ 'p3xr-tree-branch-expanded': expandedKeys.has(dataSource[virtualRow.index].key) }"
                    :style="{ color: treeBranchColor }"
                    @click.stop="toggleExpand(dataSource[virtualRow.index])"
                />

                <!-- Node content wrapper -->
                <span :data-p3xr-tree-key="dataSource[virtualRow.index].type === 'folder' ? '' : dataSource[virtualRow.index].key" style="display: inline-flex; align-items: center; height: 28px;">
                    <!-- Label with tooltip -->
                    <v-tooltip :text="nodeTooltip(dataSource[virtualRow.index])" location="right" :open-delay="500" :offset="36">
                        <template #activator="{ props: tp }">
                            <label
                                v-bind="tp"
                                class="p3xr-tree-node-label p3xr-database-tree-node"
                                @click="dataSource[virtualRow.index].expandable ? toggleExpand(dataSource[virtualRow.index]) : selectNode(dataSource[virtualRow.index])"
                            >
                                <!-- Type icon -->
                                <i
                                    v-if="dataSource[virtualRow.index].type !== 'folder' && dataSource[virtualRow.index].keysInfo"
                                    :class="typeIcons[dataSource[virtualRow.index].keysInfo.type] || 'fas fa-question'"
                                    class="p3xr-tree-type-icon"
                                    aria-hidden="true"
                                />

                                <span class="p3xr-database-tree-node-label">{{ dataSource[virtualRow.index].label }}</span>

                                <!-- Folder: divider* (count) -->
                                <span v-if="dataSource[virtualRow.index].type === 'folder'" style="opacity: 0.5; margin-left: 4px;">
                                    {{ divider }}* ({{ dataSource[virtualRow.index].childCount }})
                                </span>

                                <!-- Element length (non-string/json) -->
                                <span
                                    v-if="dataSource[virtualRow.index].type !== 'folder' && dataSource[virtualRow.index].keysInfo?.type !== 'string' && dataSource[virtualRow.index].keysInfo?.type !== 'json' && dataSource[virtualRow.index].keysInfo?.length"
                                    style="opacity: 0.5; margin-left: 4px;"
                                >({{ dataSource[virtualRow.index].keysInfo.length }})</span>
                            </label>
                        </template>
                    </v-tooltip>

                    <!-- TTL badge (outside label to avoid tooltip conflict) -->
                    <v-tooltip
                        v-if="dataSource[virtualRow.index].type !== 'folder' && getRemainingTtl(dataSource[virtualRow.index]) > 0"
                        :text="'TTL: ' + formatTtl(dataSource[virtualRow.index])"
                        location="right" :open-delay="300" :offset="36"
                    >
                        <template #activator="{ props: tp }">
                            <span
                                v-bind="tp"
                                class="p3xr-tree-ttl"
                                :class="{ 'p3xr-tree-ttl-pulse': isTtlPulsing(dataSource[virtualRow.index]) }"
                                :style="{ color: getTtlColor(dataSource[virtualRow.index]) }"
                            >
                                <v-icon size="16">mdi-clock-outline</v-icon>
                            </span>
                        </template>
                    </v-tooltip>
                </span>

                <!-- Action buttons (delete, add) — shown on hover, hidden in readonly -->
                <span v-if="!isReadonly" class="p3xr-tree-actions">
                    <!-- Delete tree (folder) -->
                    <v-tooltip v-if="dataSource[virtualRow.index].type === 'folder'"
                        :text="typeof strings?.confirm?.deleteAllKeys === 'function' ? strings.confirm.deleteAllKeys({ key: dataSource[virtualRow.index].key }) : strings?.confirm?.deleteAllKeys"
                        location="right" :open-delay="300" :offset="36">
                        <template #activator="{ props: tp }">
                            <v-icon v-bind="tp" class="p3xr-tree-action-delete"
                                @click="deleteTree($event, dataSource[virtualRow.index])">mdi-delete</v-icon>
                        </template>
                    </v-tooltip>
                    <!-- Delete key (element) -->
                    <v-tooltip v-else
                        :text="strings?.intention?.delete"
                        location="right" :open-delay="300" :offset="36">
                        <template #activator="{ props: tp }">
                            <v-icon v-bind="tp" class="p3xr-tree-action-delete"
                                @click="deleteKey($event, dataSource[virtualRow.index].key)">mdi-delete</v-icon>
                        </template>
                    </v-tooltip>
                    <!-- Add key -->
                    <v-tooltip :text="strings?.intention?.addKey"
                        location="right" :open-delay="300" :offset="36">
                        <template #activator="{ props: tp }">
                            <v-icon v-bind="tp" class="p3xr-tree-action-add"
                                :style="{ color: warnColor }"
                                @click="addKey($event, dataSource[virtualRow.index])">mdi-plus</v-icon>
                        </template>
                    </v-tooltip>
                </span>
            </div>
        </div>
    </div>
</template>

<style scoped>
.p3xr-tree-viewport {
    height: 100%;
    width: 100%;
    overflow: auto;
}

.p3xr-tree-row {
    display: flex;
    align-items: center;
    height: 28px;
    line-height: 28px;
    white-space: nowrap;
    cursor: default;
    box-sizing: border-box;
}

.p3xr-tree-row:hover .p3xr-tree-actions {
    visibility: visible;
}

/* Highlight node label on hover (matches Angular) */
[data-p3xr-tree-key]:hover .p3xr-database-tree-node-label {
    background-color: rgba(var(--v-theme-on-surface), 0.1);
}

/* Folder icon — Font Awesome 5 via ::before */
.p3xr-tree-branch {
    display: inline-block;
    font-family: 'Font Awesome 5 Free';
    font-style: normal;
    font-weight: 900;
    font-size: 24px;
    line-height: 28px;
    width: 28px;
    text-align: center;
    margin-right: 4px;
    cursor: pointer;
    flex-shrink: 0;
}
.p3xr-tree-branch::before {
    content: '\f07b';
}
.p3xr-tree-branch-expanded::before {
    content: '\f07c';
}

/* Node label */
.p3xr-tree-node-label {
    cursor: pointer;
    display: inline-flex;
    align-items: center;
    height: 28px;
    white-space: nowrap;
}

/* Type icon — same 28px width as folder icon for text alignment */
.p3xr-tree-type-icon {
    display: inline-block;
    width: 28px;
    text-align: center;
    margin-right: 4px;
    font-size: 14px;
}

/* Child count / length */
.p3xr-tree-node-count {
    opacity: 0.5;
}

/* TTL badge — override global v-icon 24px for 16px clock */
.p3xr-tree-ttl {
    display: inline-flex;
    align-items: center;
    height: 28px;
    margin-left: 4px;
    cursor: default;
}
.p3xr-tree-ttl .v-icon {
    font-size: 16px !important;
    width: 16px !important;
    height: 16px !important;
}

.p3xr-tree-ttl-pulse {
    animation: p3xr-ttl-pulse 1s infinite;
}

@keyframes p3xr-ttl-pulse {
    0%, 100% { opacity: 0.7; }
    50% { opacity: 1; }
}

/* Action buttons — hidden until row hover */
.p3xr-tree-actions {
    display: inline-flex;
    align-items: center;
    position: relative;
    top: -1px;
    visibility: hidden;
}

.p3xr-tree-actions .v-icon {
    font-size: 18px !important;
    height: 18px !important;
    width: 18px !important;
    min-width: 18px !important;
    min-height: 18px !important;
    line-height: 18px !important;
    cursor: pointer;
    vertical-align: middle;
}

.p3xr-tree-action-delete {
    color: rgb(var(--v-theme-error)) !important;
}
</style>