<script setup> import { computed, h, markRaw, nextTick, reactive, ref, watchEffect } from 'vue' import { ConnectionType } from '@/consts/connection_type.js' import { NIcon, NSpace, NText, useThemeVars } from 'naive-ui' import Key from '@/components/icons/Key.vue' import Binary from '@/components/icons/Binary.vue' import Database from '@/components/icons/Database.vue' import { filter, find, get, includes, isEmpty, map, size, toUpper } from 'lodash' import { useI18n } from 'vue-i18n' import Refresh from '@/components/icons/Refresh.vue' import CopyLink from '@/components/icons/CopyLink.vue' import Add from '@/components/icons/Add.vue' import Layer from '@/components/icons/Layer.vue' import Delete from '@/components/icons/Delete.vue' import useDialogStore from 'stores/dialog.js' import { ClipboardSetText } from 'wailsjs/runtime/runtime.js' import useConnectionStore from 'stores/connections.js' import useTabStore from 'stores/tab.js' import IconButton from '@/components/common/IconButton.vue' import { parseHexColor } from '@/utils/rgb.js' import LoadList from '@/components/icons/LoadList.vue' import LoadAll from '@/components/icons/LoadAll.vue' import useBrowserStore from 'stores/browser.js' import { useRender } from '@/utils/render.js' import RedisTypeTag from '@/components/common/RedisTypeTag.vue' import usePreferencesStore from 'stores/preferences.js' import { typesIconStyle } from '@/consts/support_redis_type.js' import { nativeRedisKey } from '@/utils/key_convert.js' const props = defineProps({ server: String, db: Number, keyView: String, loading: Boolean, pattern: String, fullLoaded: Boolean, checkMode: Boolean, }) const themeVars = useThemeVars() const render = useRender() const i18n = useI18n() const connectionStore = useConnectionStore() const browserStore = useBrowserStore() const prefStore = usePreferencesStore() const tabStore = useTabStore() const dialogStore = useDialogStore() /** * * @type {ComputedRef<string[]>} */ const expandedKeys = computed(() => { const tab = find(tabStore.tabList, { name: props.server }) if (tab != null) { return get(tab, 'expandedKeys', []) } return [] }) /** * * @type {ComputedRef<string[]>} */ const selectedKeys = computed(() => { const tab = find(tabStore.tabList, { name: props.server }) if (tab != null) { return get(tab, 'selectedKeys', []) } return [] }) /** * * @type {ComputedRef<string[]>} */ const checkedKeys = computed(() => { const tab = find(tabStore.tabList, { name: props.server }) if (tab != null) { const checkedKeys = get(tab, 'checkedKeys', []) return map(checkedKeys, 'key') } return [] }) const data = computed(() => { return browserStore.getKeyStruct(props.server, props.checkMode) }) const backgroundColor = computed(() => { const { markColor: hex = '' } = connectionStore.serverProfile[props.server] || {} if (isEmpty(hex)) { return '' } const { r, g, b } = parseHexColor(hex) return `rgba(${r}, ${g}, ${b}, 0.2)` }) const contextMenuParam = reactive({ show: false, x: 0, y: 0, options: null, }) const menuOptions = { [ConnectionType.RedisKey]: [ // { // key: 'key_reload', // label: 'interface.reload'), // icon: Refresh, // }, { key: 'key_newkey', label: 'interface.new_key', icon: Add, }, { key: 'key_copy', label: 'interface.copy_path', icon: CopyLink, }, { type: 'divider', key: 'd1', }, { key: 'key_remove', label: 'interface.batch_delete_key', icon: Delete, }, ], [ConnectionType.RedisValue]: [ { key: 'value_reload', label: 'interface.reload', icon: Refresh, }, { key: 'value_copy', label: 'interface.copy_key', icon: CopyLink, }, { type: 'divider', key: 'd1', }, { key: 'value_remove', label: 'interface.remove_key', icon: Delete, }, ], } const handleSelectContextMenu = (action) => { contextMenuParam.show = false const selectedKey = get(selectedKeys.value, 0) if (selectedKey == null) { return } const node = browserStore.getNode(selectedKey) const { db = 0, key: nodeKey, redisKey: rk = '', redisKeyCode: rkc, label } = node || {} const redisKey = rkc || rk const redisKeyName = !!rkc ? label : redisKey switch (action) { case 'key_newkey': dialogStore.openNewKeyDialog(redisKey, props.server, db) break case 'db_filter': // const { match: pattern, type } = browserStore.getKeyFilter(props.server) // dialogStore.openKeyFilterDialog(props.server, db, pattern, type) break case 'key_reload': if (node != null && !!!node.loading) { node.loading = true browserStore.reloadLayer(props.server, db, redisKey).finally(() => { delete node.loading }) } break case 'value_reload': browserStore.reloadKey({ server: props.server, db, key: redisKey, }) break case 'key_remove': dialogStore.openDeleteKeyDialog(props.server, db, isEmpty(redisKey) ? '*' : redisKey + ':*') break case 'value_remove': $dialog.warning(i18n.t('dialogue.remove_tip', { name: redisKeyName }), () => { browserStore.deleteKey(props.server, db, redisKey).then((success) => { if (success) { $message.success(i18n.t('dialogue.delete.success', { key: redisKeyName })) } }) }) break case 'key_copy': case 'value_copy': ClipboardSetText(nativeRedisKey(redisKey)) .then((succ) => { if (succ) { $message.success(i18n.t('interface.copy_succ')) } }) .catch((e) => { $message.error(e.message) }) break case 'db_loadall': if (node != null && !!!node.loading) { node.loading = true browserStore .loadAllKeys(props.server, db) .catch((e) => { $message.error(e.message) }) .finally(() => { delete node.loading node.fullLoaded = true }) } break case 'more_action': default: console.warn('TODO: handle context menu:' + action) } } const onUpdateSelectedKeys = (keys, options) => { if (!isEmpty(keys)) { tabStore.setSelectedKeys(props.server, keys) } else { // default is load blank key to display server status // tabStore.openBlank(props.server) } } const onUpdateExpanded = (value, option, meta) => { const expand = meta.action === 'expand' if (expand) { tabStore.addExpandedKey(props.server, value) } else { tabStore.removeExpandedKey(props.server, value) } let node = meta.node if (!node) { return } // keep expand or collapse children while they own more than 1 child do { const key = node.key if (expand) { if (node.type === ConnectionType.RedisKey) { node.expanded = true tabStore.addExpandedKey(props.server, key) } } else { node.expanded = false tabStore.removeExpandedKey(props.server, key) } if (size(node.children) === 1) { node = node.children[0] } else { break } } while (true) } /** * * @param {string[]} keys * @param {TreeOption[]} options */ const onUpdateCheckedKeys = (keys, options) => { const checkedKeys = map( filter(options, (o) => o.type === ConnectionType.RedisValue), (o) => ({ key: o.key, redisKey: o.redisKeyCode || o.redisKey }), ) tabStore.setCheckedKeys(props.server, checkedKeys) } const renderPrefix = ({ option }) => { switch (option.type) { // case ConnectionType.Server: // const icon = option.cluster === true ? ToggleCluster : ToggleServer // return h( // NIcon, // { size: 20 }, // { // default: () => h(icon, { modelValue: false }), // }, // ) case ConnectionType.RedisDB: return h( NIcon, { size: 20, color: option.opened === true ? '#dc423c' : undefined }, { default: () => h(Database, { inverse: option.opened === true }), }, ) case ConnectionType.RedisKey: return h( NIcon, { size: 20 }, { default: () => h(Layer), }, ) case ConnectionType.RedisValue: if (prefStore.keyIconType === typesIconStyle.ICON) { return h(NIcon, { size: 20 }, () => h(Key)) } const loading = isEmpty(option.redisType) || option.redisType === 'loading' if (loading) { browserStore.loadKeyType({ server: props.server, db: option.db, key: option.redisKeyCode || option.redisKey, }) } switch (prefStore.keyIconType) { case typesIconStyle.FULL: return h(RedisTypeTag, { type: toUpper(option.redisType), short: false, size: 'small', inverse: includes(selectedKeys.value, option.key), loading, }) case typesIconStyle.POINT: return h(RedisTypeTag, { type: toUpper(option.redisType), point: true, loading, }) case typesIconStyle.SHORT: default: return h(RedisTypeTag, { type: toUpper(option.redisType), short: true, size: 'small', loading, inverse: includes(selectedKeys.value, option.key), }) } } } // render tree item label const renderLabel = ({ option }) => { switch (option.type) { case ConnectionType.RedisKey: if (option.label === '') { // blank label name return h('div', [ h(NText, { italic: true, depth: 3 }, () => '[NO NAME]'), h('span', () => ` (${option.keyCount || 0})`), ]) } return `${option.label} (${option.keyCount || 0})` // case ConnectionType.RedisValue: // return `[${option.keyType}]${option.label}` } return option.label } // render horizontal item const renderIconMenu = (items) => { return h( NSpace, { align: 'center', inline: true, size: 3, wrapItem: false, wrap: false, style: 'margin-right: 5px', }, () => items, ) } const calcDBMenu = (opened, loading, end) => { const btns = [] if (opened) { btns.push( h(IconButton, { tTooltip: 'interface.load_more', icon: LoadList, disabled: end === true, loading: loading === true, color: loading === true ? themeVars.value.primaryColor : '', onClick: () => handleSelectContextMenu('db_loadmore'), }), h(IconButton, { tTooltip: 'interface.load_all', icon: LoadAll, disabled: end === true, loading: loading === true, color: loading === true ? themeVars.value.primaryColor : '', onClick: () => handleSelectContextMenu('db_loadall'), }), // h(IconButton, { // tTooltip: 'interface.more_action', // icon: More, // onClick: () => handleSelectContextMenu('more_action'), // }), ) } return btns } const calcLayerMenu = (loading) => { return [ // reload layer enable only full loaded h(IconButton, { tTooltip: props.fullLoaded ? 'interface.reload' : 'interface.reload_disable', icon: Refresh, loading: loading === true, disabled: !props.fullLoaded, onClick: () => handleSelectContextMenu('key_reload'), }), h(IconButton, { tTooltip: 'interface.new_key', icon: Add, onClick: () => handleSelectContextMenu('key_newkey'), }), h(IconButton, { tTooltip: 'interface.batch_delete_key', icon: Delete, onClick: () => handleSelectContextMenu('key_remove'), }), ] } const calcValueMenu = () => { return [ h(IconButton, { tTooltip: 'interface.remove_key', icon: Delete, onClick: () => handleSelectContextMenu('value_remove'), }), ] } // render menu function icon const renderSuffix = ({ option }) => { const selected = includes(selectedKeys.value, option.key) if (selected && !props.checkMode) { switch (option.type) { case ConnectionType.RedisDB: return renderIconMenu(calcDBMenu(option.opened, option.loading, option.fullLoaded)) case ConnectionType.RedisKey: return renderIconMenu(calcLayerMenu(option.loading)) case ConnectionType.RedisValue: return renderIconMenu(calcValueMenu()) } } else if (!selected && !!option.redisKeyCode && option.type === ConnectionType.RedisValue) { // render binary icon return renderIconMenu(h(NIcon, { size: 20 }, () => h(Binary))) } return null } const nodeProps = ({ option }) => { return { onClick: () => { if (option.type === ConnectionType.RedisValue) { if (tabStore.setActivatedKey(props.server, option.key)) { const { db, redisKey, redisKeyCode } = option browserStore.loadKeySummary({ server: props.server, db, key: redisKeyCode || redisKey, clearValue: true, }) } } }, onDblclick: () => { if (props.loading) { console.warn('TODO: alert to ignore double click when loading') return } if (!props.checkMode) { // default handle is toggle expand current node nextTick().then(() => tabStore.toggleExpandKey(props.server, option.key)) } }, onContextmenu(e) { e.preventDefault() if (!menuOptions.hasOwnProperty(option.type)) { return } contextMenuParam.show = false nextTick().then(() => { contextMenuParam.options = markRaw(menuOptions[option.type] || []) contextMenuParam.x = e.clientX contextMenuParam.y = e.clientY contextMenuParam.show = true onUpdateSelectedKeys([option.key], [option]) }) }, // onMouseover() { // console.log('mouse over') // } } } const handleOutsideContextMenu = () => { contextMenuParam.show = false } watchEffect( () => { if (!props.checkMode) { tabStore.setCheckedKeys(props.server) } else { tabStore.addExpandedKey(props.server, `${ConnectionType.RedisDB}`) } }, { flush: 'post' }, ) // the NTree node may get incorrect height after change data // add key property for force refresh the component so that everything back to normal const treeKey = ref(0) defineExpose({ handleSelectContextMenu, refreshTree: () => { treeKey.value = Date.now() tabStore.setExpandedKeys(props.server) tabStore.setCheckedKeys(props.server) }, deleteCheckedItems: () => { const checkedKeys = tabStore.currentCheckedKeys const redisKeys = map(checkedKeys, 'redisKey') if (!isEmpty(redisKeys)) { dialogStore.openDeleteKeyDialog(props.server, props.db, redisKeys) } }, exportCheckedItems: () => { const checkedKeys = tabStore.currentCheckedKeys const redisKeys = map(checkedKeys, 'redisKey') if (!isEmpty(redisKeys)) { dialogStore.openExportKeyDialog(props.server, props.db, redisKeys) } }, updateTTLCheckedItems: () => { const checkedKeys = tabStore.currentCheckedKeys const redisKeys = map(checkedKeys, 'redisKey') if (!isEmpty(redisKeys)) { dialogStore.openTTLDialog({ server: props.server, db: props.db, keys: redisKeys, }) } }, getSelectedKey: () => { return selectedKeys.value || [] }, }) </script> <template> <div :style="{ backgroundColor }" class="flex-box-v browser-tree-wrapper" @contextmenu="(e) => e.preventDefault()" @keydown.esc="contextMenuParam.show = false"> <n-spin v-if="props.loading" class="fill-height" /> <n-empty v-else-if="!props.loading && isEmpty(data)" class="empty-content" /> <n-tree v-show="!props.loading && !isEmpty(data)" :key="treeKey" :animated="false" :block-line="true" :block-node="true" :cancelable="false" :cascade="true" :checkable="props.checkMode" :checked-keys="checkedKeys" :data="data" :expand-on-click="false" :expanded-keys="expandedKeys" :filter="(pattern, node) => includes(node.redisKey, pattern)" :node-props="nodeProps" :pattern="props.pattern" :render-label="renderLabel" :render-prefix="renderPrefix" :render-suffix="renderSuffix" :selected-keys="selectedKeys" :show-irrelevant-nodes="false" check-strategy="child" class="fill-height" virtual-scroll @keydown.delete="handleSelectContextMenu('value_remove')" @update:selected-keys="onUpdateSelectedKeys" @update:expanded-keys="onUpdateExpanded" @update:checked-keys="onUpdateCheckedKeys"> <template #empty> <n-empty class="empty-content" /> </template> </n-tree> <!-- context menu --> <n-dropdown :options="contextMenuParam.options" :render-icon="({ icon }) => render.renderIcon(icon)" :render-label="({ label }) => render.renderLabel($t(label), { class: 'context-menu-item' })" :show="contextMenuParam.show" :x="contextMenuParam.x" :y="contextMenuParam.y" placement="bottom-start" trigger="manual" @clickoutside="handleOutsideContextMenu" @select="handleSelectContextMenu" /> </div> </template> <style lang="scss" scoped> @import '@/styles/content'; .browser-tree-wrapper { height: 100%; overflow: hidden; } </style>