717 lines
23 KiB
Vue
717 lines
23 KiB
Vue
<script setup>
|
|
import { computed, h, nextTick, onMounted, reactive, ref } from 'vue'
|
|
import { ConnectionType } from '@/consts/connection_type.js'
|
|
import { NIcon, NSpace, NTag, 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 { find, get, includes, indexOf, isEmpty, remove, size, startsWith } 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 Connect from '@/components/icons/Connect.vue'
|
|
import useDialogStore from 'stores/dialog.js'
|
|
import { ClipboardSetText } from 'wailsjs/runtime/runtime.js'
|
|
import useConnectionStore from 'stores/connections.js'
|
|
import Unlink from '@/components/icons/Unlink.vue'
|
|
import Filter from '@/components/icons/Filter.vue'
|
|
import Close from '@/components/icons/Close.vue'
|
|
import { typesBgColor, typesColor } from '@/consts/support_redis_type.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'
|
|
|
|
const props = defineProps({
|
|
server: String,
|
|
keyView: String,
|
|
})
|
|
|
|
const themeVars = useThemeVars()
|
|
const i18n = useI18n()
|
|
const loading = ref(false)
|
|
const loadingConnections = ref(false)
|
|
const expandedKeys = ref([props.server])
|
|
const connectionStore = useConnectionStore()
|
|
const tabStore = useTabStore()
|
|
const dialogStore = useDialogStore()
|
|
|
|
/**
|
|
*
|
|
* @type {ComputedRef<string[]>}
|
|
*/
|
|
const selectedKeys = computed(() => {
|
|
const tab = find(tabStore.tabList, { name: props.server })
|
|
if (tab != null) {
|
|
return get(tab, 'selectedKeys', [props.server])
|
|
}
|
|
return [props.server]
|
|
})
|
|
|
|
const data = computed(() => {
|
|
const dbs = get(connectionStore.databases, props.server, [])
|
|
return dbs
|
|
})
|
|
|
|
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 renderIcon = (icon) => {
|
|
return () => {
|
|
return h(NIcon, null, {
|
|
default: () => h(icon),
|
|
})
|
|
}
|
|
}
|
|
const menuOptions = {
|
|
[ConnectionType.Server]: () => {
|
|
return [
|
|
{
|
|
key: 'server_reload',
|
|
label: i18n.t('interface.reload'),
|
|
icon: renderIcon(Refresh),
|
|
},
|
|
{
|
|
key: 'server_close',
|
|
label: i18n.t('interface.disconnect'),
|
|
icon: renderIcon(Unlink),
|
|
},
|
|
]
|
|
},
|
|
[ConnectionType.RedisDB]: ({ opened }) => {
|
|
if (opened) {
|
|
return [
|
|
{
|
|
key: 'db_reload',
|
|
label: i18n.t('interface.reload'),
|
|
icon: renderIcon(Refresh),
|
|
},
|
|
{
|
|
key: 'db_newkey',
|
|
label: i18n.t('interface.new_key'),
|
|
icon: renderIcon(Add),
|
|
},
|
|
{
|
|
key: 'db_filter',
|
|
label: i18n.t('interface.filter_key'),
|
|
icon: renderIcon(Filter),
|
|
},
|
|
{
|
|
type: 'divider',
|
|
key: 'd1',
|
|
},
|
|
{
|
|
key: 'db_flush',
|
|
label: i18n.t('interface.flush_db'),
|
|
icon: renderIcon(Delete),
|
|
},
|
|
{
|
|
type: 'divider',
|
|
key: 'd2',
|
|
},
|
|
{
|
|
key: 'db_close',
|
|
label: i18n.t('interface.close_db'),
|
|
icon: renderIcon(Close),
|
|
},
|
|
]
|
|
} else {
|
|
return [
|
|
{
|
|
key: 'db_open',
|
|
label: i18n.t('interface.open_db'),
|
|
icon: renderIcon(Connect),
|
|
},
|
|
]
|
|
}
|
|
},
|
|
[ConnectionType.RedisKey]: () => [
|
|
// {
|
|
// key: 'key_reload',
|
|
// label: i18n.t('interface.reload'),
|
|
// icon: renderIcon(Refresh),
|
|
// },
|
|
{
|
|
key: 'key_newkey',
|
|
label: i18n.t('interface.new_key'),
|
|
icon: renderIcon(Add),
|
|
},
|
|
{
|
|
key: 'key_copy',
|
|
label: i18n.t('interface.copy_path'),
|
|
icon: renderIcon(CopyLink),
|
|
},
|
|
{
|
|
type: 'divider',
|
|
key: 'd1',
|
|
},
|
|
{
|
|
key: 'key_remove',
|
|
label: i18n.t('interface.batch_delete_key'),
|
|
icon: renderIcon(Delete),
|
|
},
|
|
],
|
|
[ConnectionType.RedisValue]: () => [
|
|
{
|
|
key: 'value_reload',
|
|
label: i18n.t('interface.reload'),
|
|
icon: renderIcon(Refresh),
|
|
},
|
|
{
|
|
key: 'value_copy',
|
|
label: i18n.t('interface.copy_key'),
|
|
icon: renderIcon(CopyLink),
|
|
},
|
|
{
|
|
type: 'divider',
|
|
key: 'd1',
|
|
},
|
|
{
|
|
key: 'value_remove',
|
|
label: i18n.t('interface.remove_key'),
|
|
icon: renderIcon(Delete),
|
|
},
|
|
],
|
|
}
|
|
|
|
const renderContextLabel = (option) => {
|
|
return h('div', { class: 'context-menu-item' }, option.label)
|
|
}
|
|
|
|
onMounted(async () => {
|
|
try {
|
|
// TODO: Show loading list status
|
|
loadingConnections.value = true
|
|
} finally {
|
|
loadingConnections.value = false
|
|
}
|
|
})
|
|
|
|
const expandKey = (key) => {
|
|
const idx = indexOf(expandedKeys.value, key)
|
|
if (idx === -1) {
|
|
expandedKeys.value.push(key)
|
|
} else {
|
|
expandedKeys.value.splice(idx, 1)
|
|
}
|
|
}
|
|
|
|
const resetExpandKey = (server, db, includeDB) => {
|
|
const prefix = `${server}/db${db}`
|
|
remove(expandedKeys.value, (k) => {
|
|
if (!!!includeDB) {
|
|
return k !== prefix && startsWith(k, prefix)
|
|
} else {
|
|
return startsWith(k, prefix)
|
|
}
|
|
})
|
|
}
|
|
|
|
const handleSelectContextMenu = (key) => {
|
|
contextMenuParam.show = false
|
|
const selectedKey = get(selectedKeys.value, 0)
|
|
if (selectedKey == null) {
|
|
return
|
|
}
|
|
const node = connectionStore.getNode(selectedKey)
|
|
const { db = 0, key: nodeKey, redisKey: rk = '', redisKeyCode: rkc, label } = node || {}
|
|
const redisKey = rkc || rk
|
|
const redisKeyName = !!rkc ? label : redisKey
|
|
switch (key) {
|
|
case 'server_info':
|
|
tabStore.setSelectedKeys(props.server)
|
|
onUpdateSelectedKeys()
|
|
break
|
|
case 'server_reload':
|
|
expandedKeys.value = [props.server]
|
|
tabStore.setSelectedKeys(props.server)
|
|
connectionStore.openConnection(props.server, true).then(() => {
|
|
$message.success(i18n.t('dialogue.reload_succ'))
|
|
})
|
|
break
|
|
case 'server_close':
|
|
connectionStore.closeConnection(props.server)
|
|
break
|
|
case 'db_open':
|
|
nextTick().then(() => expandKey(nodeKey))
|
|
break
|
|
case 'db_reload':
|
|
resetExpandKey(props.server, db)
|
|
connectionStore.reopenDatabase(props.server, db)
|
|
break
|
|
case 'db_close':
|
|
resetExpandKey(props.server, db, true)
|
|
connectionStore.closeDatabase(props.server, db)
|
|
break
|
|
case 'db_flush':
|
|
dialogStore.openFlushDBDialog(props.server, db)
|
|
break
|
|
case 'db_newkey':
|
|
case 'key_newkey':
|
|
dialogStore.openNewKeyDialog(redisKey, props.server, db)
|
|
break
|
|
case 'db_filter':
|
|
const { match: pattern, type } = connectionStore.getKeyFilter(props.server, db)
|
|
dialogStore.openKeyFilterDialog(props.server, db, pattern, type)
|
|
break
|
|
// case 'key_reload':
|
|
// connectionStore.loadKeys(props.server, db, redisKey)
|
|
// break
|
|
case 'value_reload':
|
|
connectionStore.loadKeyValue(props.server, db, 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 }), () => {
|
|
connectionStore.deleteKey(props.server, db, redisKey).then((success) => {
|
|
if (success) {
|
|
$message.success(i18n.t('dialogue.delete_key_succ', { key: redisKeyName }))
|
|
}
|
|
})
|
|
})
|
|
break
|
|
case 'key_copy':
|
|
case 'value_copy':
|
|
ClipboardSetText(redisKey)
|
|
.then((succ) => {
|
|
if (succ) {
|
|
$message.success(i18n.t('dialogue.copy_succ'))
|
|
}
|
|
})
|
|
.catch((e) => {
|
|
$message.error(e.message)
|
|
})
|
|
break
|
|
case 'db_loadmore':
|
|
if (node != null && !!!node.loading && !!!node.fullLoaded) {
|
|
node.loading = true
|
|
connectionStore
|
|
.loadMoreKeys(props.server, db)
|
|
.then((end) => {
|
|
// fully loaded
|
|
node.fullLoaded = end === true
|
|
})
|
|
.catch((e) => {
|
|
$message.error(e.message)
|
|
})
|
|
.finally(() => {
|
|
delete node.loading
|
|
})
|
|
}
|
|
break
|
|
case 'db_loadall':
|
|
if (node != null && !!!node.loading) {
|
|
node.loading = true
|
|
connectionStore
|
|
.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:' + key)
|
|
}
|
|
}
|
|
|
|
defineExpose({
|
|
handleSelectContextMenu,
|
|
})
|
|
|
|
const onUpdateExpanded = (value, option, meta) => {
|
|
expandedKeys.value = value
|
|
if (!meta.node) {
|
|
return
|
|
}
|
|
|
|
// keep expand or collapse children while they own more than 1 child
|
|
let node = meta.node
|
|
while (node != null && size(node.children) === 1) {
|
|
const key = node.children[0].key
|
|
switch (meta.action) {
|
|
case 'expand':
|
|
node.expanded = true
|
|
if (!includes(expandedKeys.value, key)) {
|
|
expandedKeys.value.push(key)
|
|
}
|
|
break
|
|
case 'collapse':
|
|
node.expanded = false
|
|
remove(expandedKeys.value, (v) => v === key)
|
|
break
|
|
}
|
|
node = node.children[0]
|
|
}
|
|
}
|
|
|
|
const onUpdateSelectedKeys = (keys, options) => {
|
|
try {
|
|
if (!isEmpty(options)) {
|
|
// prevent load duplicate key
|
|
for (const node of options) {
|
|
if (node.type === ConnectionType.RedisValue) {
|
|
const { key, db } = node
|
|
const redisKey = node.redisKeyCode || node.redisKey
|
|
if (!includes(selectedKeys.value, key)) {
|
|
connectionStore.loadKeyValue(props.server, db, redisKey)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
}
|
|
// default is load blank key to display server status
|
|
connectionStore.loadKeyValue(props.server, 0)
|
|
} finally {
|
|
tabStore.setSelectedKeys(props.server, keys)
|
|
}
|
|
}
|
|
|
|
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:
|
|
return h(
|
|
NIcon,
|
|
{ size: 20 },
|
|
{
|
|
default: () => h(!!option.redisKeyCode ? Binary : Key),
|
|
},
|
|
)
|
|
}
|
|
}
|
|
|
|
// render tree item label
|
|
const renderLabel = ({ option }) => {
|
|
switch (option.type) {
|
|
case ConnectionType.Server:
|
|
return h('b', {}, { default: () => option.label })
|
|
case ConnectionType.RedisDB:
|
|
const { name: server, db, opened = false } = option
|
|
let { match: matchPattern, type: typeFilter } = connectionStore.getKeyFilter(server, db)
|
|
const items = []
|
|
if (opened) {
|
|
items.push(`${option.label} (${option.keys || 0}/${Math.max(option.maxKeys || 0, option.keys || 0)})`)
|
|
} else {
|
|
items.push(`${option.label} (${Math.max(option.maxKeys || 0, option.keys || 0)})`)
|
|
}
|
|
// show filter tag after label
|
|
// type filter tag
|
|
if (!isEmpty(typeFilter)) {
|
|
items.push(
|
|
h(
|
|
NTag,
|
|
{
|
|
size: 'small',
|
|
closable: true,
|
|
bordered: false,
|
|
color: {
|
|
color: typesBgColor[typeFilter],
|
|
textColor: typesColor[typeFilter],
|
|
},
|
|
onClose: () => {
|
|
// remove type filter
|
|
connectionStore.setKeyFilter(server, db, matchPattern)
|
|
connectionStore.reopenDatabase(server, db)
|
|
},
|
|
},
|
|
{ default: () => typeFilter },
|
|
),
|
|
)
|
|
}
|
|
// match pattern tag
|
|
if (!isEmpty(matchPattern) && matchPattern !== '*') {
|
|
items.push(
|
|
h(
|
|
NTag,
|
|
{
|
|
bordered: false,
|
|
closable: true,
|
|
size: 'small',
|
|
onClose: () => {
|
|
// remove key match pattern
|
|
connectionStore.setKeyFilter(server, db, '*', typeFilter)
|
|
connectionStore.reopenDatabase(server, db)
|
|
},
|
|
},
|
|
{ default: () => matchPattern },
|
|
),
|
|
)
|
|
}
|
|
return renderIconMenu(items)
|
|
case ConnectionType.RedisKey:
|
|
return `${option.label} (${option.keys || 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 getDatabaseMenu = (opened, loading, end) => {
|
|
const btns = []
|
|
if (opened) {
|
|
btns.push(
|
|
h(IconButton, {
|
|
tTooltip: 'interface.filter_key',
|
|
icon: Filter,
|
|
disabled: loading === true,
|
|
onClick: () => handleSelectContextMenu('db_filter'),
|
|
}),
|
|
h(IconButton, {
|
|
tTooltip: 'interface.reload',
|
|
icon: Refresh,
|
|
disabled: loading === true,
|
|
onClick: () => handleSelectContextMenu('db_reload'),
|
|
}),
|
|
h(IconButton, {
|
|
tTooltip: 'interface.new_key',
|
|
icon: Add,
|
|
disabled: loading === true,
|
|
onClick: () => handleSelectContextMenu('db_newkey'),
|
|
}),
|
|
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.flush_db',
|
|
icon: Delete,
|
|
disabled: loading === true,
|
|
onClick: () => handleSelectContextMenu('db_flush'),
|
|
}),
|
|
// h(IconButton, {
|
|
// tTooltip: 'interface.more_action',
|
|
// icon: More,
|
|
// onClick: () => handleSelectContextMenu('more_action'),
|
|
// }),
|
|
)
|
|
} else {
|
|
btns.push(
|
|
h(IconButton, {
|
|
tTooltip: 'interface.open_db',
|
|
icon: Connect,
|
|
onClick: () => handleSelectContextMenu('db_open'),
|
|
}),
|
|
)
|
|
}
|
|
return btns
|
|
}
|
|
|
|
const getLayerMenu = () => {
|
|
return [
|
|
// disable reload by layer, due to conflict with partial loading keys
|
|
// h(IconButton, {
|
|
// tTooltip: 'interface.reload',
|
|
// icon: Refresh,
|
|
// 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 getValueMenu = () => {
|
|
return [
|
|
h(IconButton, {
|
|
tTooltip: 'interface.remove_key',
|
|
icon: Delete,
|
|
onClick: () => handleSelectContextMenu('value_remove'),
|
|
}),
|
|
]
|
|
}
|
|
|
|
// render menu function icon
|
|
const renderSuffix = ({ option }) => {
|
|
if ((option.type === ConnectionType.RedisDB && option.opened) || includes(selectedKeys.value, option.key)) {
|
|
switch (option.type) {
|
|
case ConnectionType.RedisDB:
|
|
return renderIconMenu(getDatabaseMenu(option.opened, option.loading, option.fullLoaded))
|
|
case ConnectionType.RedisKey:
|
|
return renderIconMenu(getLayerMenu())
|
|
case ConnectionType.RedisValue:
|
|
return renderIconMenu(getValueMenu())
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
const nodeProps = ({ option }) => {
|
|
return {
|
|
onDblclick: () => {
|
|
if (loading.value) {
|
|
console.warn('TODO: alert to ignore double click when loading')
|
|
return
|
|
}
|
|
// default handle is expand current node
|
|
nextTick().then(() => expandKey(option.key))
|
|
},
|
|
onContextmenu(e) {
|
|
e.preventDefault()
|
|
if (!menuOptions.hasOwnProperty(option.type)) {
|
|
return
|
|
}
|
|
contextMenuParam.show = false
|
|
contextMenuParam.options = menuOptions[option.type](option)
|
|
nextTick().then(() => {
|
|
contextMenuParam.x = e.clientX
|
|
contextMenuParam.y = e.clientY
|
|
contextMenuParam.show = true
|
|
tabStore.setSelectedKeys(props.server, option.key)
|
|
})
|
|
},
|
|
// onMouseover() {
|
|
// console.log('mouse over')
|
|
// }
|
|
}
|
|
}
|
|
|
|
const onLoadTree = async (node) => {
|
|
switch (node.type) {
|
|
case ConnectionType.RedisDB:
|
|
loading.value = true
|
|
try {
|
|
await connectionStore.openDatabase(props.server, node.db)
|
|
} catch (e) {
|
|
$message.error(e.message)
|
|
node.isLeaf = undefined
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
break
|
|
// case ConnectionType.RedisKey:
|
|
// console.warn('load redis key', node.redisKey)
|
|
// node.keys = sumBy(node.children, 'keys')
|
|
// break
|
|
// case ConnectionType.RedisValue:
|
|
// node.keys = 1
|
|
// break
|
|
}
|
|
}
|
|
|
|
const handleOutsideContextMenu = () => {
|
|
contextMenuParam.show = false
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div :style="{ backgroundColor }" class="browser-tree-wrapper">
|
|
<n-tree
|
|
:animated="false"
|
|
:block-line="true"
|
|
:block-node="true"
|
|
:cancelable="false"
|
|
:data="data"
|
|
:expand-on-click="false"
|
|
:expanded-keys="expandedKeys"
|
|
:node-props="nodeProps"
|
|
:render-label="renderLabel"
|
|
:render-prefix="renderPrefix"
|
|
:render-suffix="renderSuffix"
|
|
:selected-keys="selectedKeys"
|
|
class="fill-height"
|
|
virtual-scroll
|
|
@load="onLoadTree"
|
|
@update:selected-keys="onUpdateSelectedKeys"
|
|
@update:expanded-keys="onUpdateExpanded" />
|
|
<n-dropdown
|
|
:options="contextMenuParam.options"
|
|
:render-label="renderContextLabel"
|
|
:show="contextMenuParam.show"
|
|
:x="contextMenuParam.x"
|
|
:y="contextMenuParam.y"
|
|
placement="bottom-start"
|
|
trigger="manual"
|
|
@clickoutside="handleOutsideContextMenu"
|
|
@select="handleSelectContextMenu" />
|
|
</div>
|
|
</template>
|
|
|
|
<style lang="scss" scoped>
|
|
.browser-tree-wrapper {
|
|
height: 100%;
|
|
overflow: hidden;
|
|
}
|
|
</style>
|