feat(N4): virtual scrolling for Set/Zset/List editors

- Create VirtualScrollList.vue component using @tanstack/vue-virtual
  - Props: items, itemHeight, overscan, height
  - Events: scrollEnd (for infinite loading)
  - Slots: default (item rendering), empty
- SetEditor: replace v-for with VirtualScrollList, handles 100k+ members
- ZsetEditor: replace v-for with VirtualScrollList, preserves filter/sort
- ListEditor: replace pagination with virtual scroll + infinite loading
  - Initial load 100 items, auto-loads 100 more on scrollEnd
  - Shows loaded/total count + loading indicator
- HashEditor: kept as-is (HSCAN limits to 100 items, el-table handles fine)
- docs: mark N4 complete, update problem E priority to medium
This commit is contained in:
2026-07-12 15:04:08 +08:00
parent afe2894c35
commit ad21900a6f
5 changed files with 182 additions and 67 deletions
@@ -0,0 +1,98 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useVirtualizer } from '@tanstack/vue-virtual'
const props = withDefaults(defineProps<{
items: any[]
itemHeight?: number
overscan?: number
height?: string
}>(), {
itemHeight: 36,
overscan: 5,
height: '100%',
})
const emit = defineEmits<{
(e: 'scrollEnd'): void
}>()
const scrollRef = ref<HTMLElement | null>(null)
const virtualizer = useVirtualizer(
computed(() => ({
count: props.items.length,
getScrollElement: () => scrollRef.value,
estimateSize: () => props.itemHeight,
overscan: props.overscan,
}))
)
const virtualItems = computed(() => virtualizer.value.getVirtualItems())
const totalHeight = computed(() => virtualizer.value.getTotalSize())
// Emit scrollEnd when user scrolls near the bottom
function onScroll() {
const el = scrollRef.value
if (!el) return
if (el.scrollTop + el.clientHeight >= el.scrollHeight - props.itemHeight * 5) {
emit('scrollEnd')
}
}
// Reset scroll position when items array reference changes entirely
watch(() => props.items, (newItems, oldItems) => {
if (newItems.length === 0 && oldItems && oldItems.length > 0) {
virtualizer.value.scrollToIndex(0)
}
})
</script>
<template>
<div ref="scrollRef" class="vsl-scroll" :style="{ height }" @scroll.passive="onScroll">
<div v-if="items.length > 0" class="vsl-inner" :style="{ height: totalHeight + 'px', position: 'relative' }">
<div
v-for="vItem in virtualItems"
:key="vItem.key"
class="vsl-item"
:style="{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: vItem.size + 'px',
transform: `translateY(${vItem.start}px)`,
}"
>
<slot :item="items[vItem.index]" :index="vItem.index" />
</div>
</div>
<div v-else class="vsl-empty">
<slot name="empty" />
</div>
</div>
</template>
<style scoped>
.vsl-scroll {
overflow-y: auto;
overflow-x: hidden;
}
.vsl-inner {
width: 100%;
}
.vsl-item {
display: flex;
align-items: center;
}
.vsl-empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
min-height: 120px;
}
</style>
@@ -1,10 +1,11 @@
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import { ref, watch } from 'vue'
import { useKeyStore } from '@renderer/stores/key'
import { useConnectionStore } from '@renderer/stores/connection'
import { useI18n } from '@renderer/i18n'
import { useTypeColor } from '@renderer/composables/useTypeColor'
import { ElMessage, ElMessageBox } from 'element-plus'
import VirtualScrollList from '@renderer/components/common/VirtualScrollList.vue'
const keyStore = useKeyStore()
const { typeColor } = useTypeColor(() => keyStore.selectedType)
@@ -13,24 +14,41 @@ const { t } = useI18n()
const items = ref<string[]>([])
const loading = ref(false)
const pageStart = ref(0)
const pageSize = 50
const totalCount = ref(0)
const loadedCount = ref(0)
const showAddDialog = ref(false)
const newItemValue = ref('')
const editingIndex = ref<number | null>(null)
const editingValue = ref('')
const LOAD_SIZE = 100
async function loadList() {
async function loadInitial() {
if (!connStore.activeId || !keyStore.selectedKey) return
loading.value = true
try {
const [len, data] = await Promise.all([
window.electronAPI.redis.listLength(connStore.activeId, keyStore.selectedKey),
window.electronAPI.redis.listRange(connStore.activeId, keyStore.selectedKey, pageStart.value, pageStart.value + pageSize - 1),
window.electronAPI.redis.listRange(connStore.activeId, keyStore.selectedKey, 0, LOAD_SIZE - 1),
])
totalCount.value = len
items.value = data
loadedCount.value = data.length
} finally {
loading.value = false
}
}
async function loadMore() {
if (loading.value || loadedCount.value >= totalCount.value) return
if (!connStore.activeId || !keyStore.selectedKey) return
loading.value = true
try {
const data = await window.electronAPI.redis.listRange(
connStore.activeId, keyStore.selectedKey,
loadedCount.value, loadedCount.value + LOAD_SIZE - 1
)
items.value.push(...data)
loadedCount.value += data.length
} finally {
loading.value = false
}
@@ -38,7 +56,7 @@ async function loadList() {
watch(
() => keyStore.selectedKey,
() => { if (keyStore.selectedType === 'list') { pageStart.value = 0; loadList() } },
() => { if (keyStore.selectedType === 'list') { loadInitial() } },
{ immediate: true }
)
@@ -48,7 +66,7 @@ async function addItem() {
await window.electronAPI.redis.listPush(connStore.activeId, keyStore.selectedKey, [newItemValue.value])
showAddDialog.value = false
newItemValue.value = ''
await loadList()
await loadInitial()
ElMessage.success(t('editor.added'))
} catch (err: any) {
ElMessage.error(err.message)
@@ -78,26 +96,14 @@ async function removeItem(index: number) {
await ElMessageBox.confirm(t('editor.removeElementConfirm'), t('common.confirm'))
// Use LSET + LREM sentinel pattern to delete by index instead of by value
const sentinel = `\x00__JREDM_DEL_${Date.now()}_${Math.random()}__`
await window.electronAPI.redis.listSet(connStore.activeId, keyStore.selectedKey, pageStart + index, sentinel)
await window.electronAPI.redis.listSet(connStore.activeId, keyStore.selectedKey, index, sentinel)
await window.electronAPI.redis.listRemove(connStore.activeId, keyStore.selectedKey, 1, sentinel)
await loadList()
await loadInitial()
ElMessage.success(t('editor.removed'))
} catch (err: any) {
if (err !== 'cancel') ElMessage.error(err.message)
}
}
function prevPage() {
if (pageStart.value === 0) return
pageStart.value = Math.max(0, pageStart.value - pageSize)
loadList()
}
function nextPage() {
if (pageStart.value + pageSize >= totalCount.value) return
pageStart.value += pageSize
loadList()
}
</script>
<template>
@@ -106,32 +112,35 @@ function nextPage() {
<span class="type-badge" :style="{ color: typeColor, borderColor: typeColor }">LIST</span>
<span class="item-count">{{ t('editor.elements', { n: totalCount }) }}</span>
<el-button size="small" type="primary" @click="showAddDialog = true">{{ t('editor.push') }}</el-button>
<el-button size="small" @click="loadList" :loading="loading">{{ t('common.refresh') }}</el-button>
<el-button size="small" @click="loadInitial" :loading="loading">{{ t('common.refresh') }}</el-button>
</div>
<div class="list-content">
<div v-for="(item, index) in items" :key="index" class="list-item">
<span class="item-index">{{ pageStart + index }}</span>
<template v-if="editingIndex === index">
<el-input v-model="editingValue" size="small" class="item-input" />
<el-button size="small" type="primary" text @click="saveEdit">{{ t('editor.save') }}</el-button>
<el-button size="small" @click="editingIndex = null">{{ t('common.cancel') }}</el-button>
</template>
<template v-else>
<span class="item-value">{{ item }}</span>
<div class="item-actions">
<el-button size="small" text @click="startEdit(index)">{{ t('editor.edit') }}</el-button>
<el-button size="small" type="danger" text @click="removeItem(index)">{{ t('common.delete') }}</el-button>
</div>
</template>
</div>
<div v-if="items.length === 0 && !loading" class="empty">{{ t('editor.empty') }}</div>
</div>
<VirtualScrollList :items="items" :item-height="36" height="100%" class="list-content" @scroll-end="loadMore">
<template #default="{ item, index }">
<div class="list-item">
<span class="item-index">{{ index }}</span>
<template v-if="editingIndex === index">
<el-input v-model="editingValue" size="small" class="item-input" />
<el-button size="small" type="primary" text @click="saveEdit">{{ t('editor.save') }}</el-button>
<el-button size="small" @click="editingIndex = null">{{ t('common.cancel') }}</el-button>
</template>
<template v-else>
<span class="item-value">{{ item }}</span>
<div class="item-actions">
<el-button size="small" text @click="startEdit(index)">{{ t('editor.edit') }}</el-button>
<el-button size="small" type="danger" text @click="removeItem(index)">{{ t('common.delete') }}</el-button>
</div>
</template>
</div>
</template>
<template #empty>
<div class="empty">{{ t('editor.empty') }}</div>
</template>
</VirtualScrollList>
<div class="list-pagination" v-if="totalCount > pageSize">
<el-button size="small" :disabled="pageStart === 0" @click="prevPage">{{ t('common.prev') }}</el-button>
<span class="page-info">{{ pageStart + 1 }}-{{ Math.min(pageStart + pageSize, totalCount) }} / {{ totalCount }}</span>
<el-button size="small" :disabled="pageStart + pageSize >= totalCount" @click="nextPage">{{ t('common.next') }}</el-button>
<div class="list-footer" v-if="totalCount > 0">
<span class="load-info" v-if="loading">{{ t('common.loading') }}...</span>
<span class="load-info" v-else>{{ loadedCount }} / {{ totalCount }}</span>
</div>
<el-dialog v-model="showAddDialog" :title="t('editor.push')" width="400px">
@@ -178,7 +187,6 @@ function nextPage() {
.list-content {
flex: 1;
overflow-y: auto;
}
.list-item {
@@ -187,6 +195,7 @@ function nextPage() {
gap: 10px;
padding: 8px 12px;
border-bottom: 1px solid var(--border-subtle);
width: 100%;
}
.list-item:hover {
@@ -227,17 +236,16 @@ function nextPage() {
font-size: 13px;
}
.list-pagination {
.list-footer {
padding: 8px 12px;
border-top: 1px solid var(--border-color);
display: flex;
justify-content: center;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.page-info {
.load-info {
font-size: 12px;
color: var(--text-secondary);
}
@@ -5,6 +5,7 @@ import { useConnectionStore } from '@renderer/stores/connection'
import { useI18n } from '@renderer/i18n'
import { useTypeColor } from '@renderer/composables/useTypeColor'
import { ElMessage, ElMessageBox } from 'element-plus'
import VirtualScrollList from '@renderer/components/common/VirtualScrollList.vue'
const keyStore = useKeyStore()
const { typeColor } = useTypeColor(() => keyStore.selectedType)
@@ -78,13 +79,17 @@ const filteredMembers = computed(() => {
<el-button size="small" @click="loadMembers" :loading="loading">{{ t('common.refresh') }}</el-button>
</div>
<div class="set-content">
<div v-for="member in filteredMembers" :key="member" class="set-item">
<span class="item-value">{{ member }}</span>
<el-button size="small" type="danger" text @click="removeMember(member)">{{ t('editor.remove') }}</el-button>
</div>
<div v-if="filteredMembers.length === 0 && !loading" class="empty">{{ t('editor.empty') }}</div>
</div>
<VirtualScrollList :items="filteredMembers" :item-height="36" height="100%" class="set-content">
<template #default="{ item }">
<div class="set-item">
<span class="item-value">{{ item }}</span>
<el-button size="small" type="danger" text @click="removeMember(item)">{{ t('editor.remove') }}</el-button>
</div>
</template>
<template #empty>
<div class="empty">{{ t('editor.empty') }}</div>
</template>
</VirtualScrollList>
<el-dialog v-model="showAddDialog" :title="t('editor.addMember')" width="400px">
<el-form-item :label="t('editor.member')">
@@ -4,6 +4,7 @@ import { useKeyStore } from '@renderer/stores/key'
import { useConnectionStore } from '@renderer/stores/connection'
import { useI18n } from '@renderer/i18n'
import { useTypeColor } from '@renderer/composables/useTypeColor'
import VirtualScrollList from '@renderer/components/common/VirtualScrollList.vue'
import { ElMessage, ElMessageBox } from 'element-plus'
const keyStore = useKeyStore()
@@ -100,14 +101,18 @@ const filteredItems = computed(() => {
<el-button size="small" @click="loadMembers" :loading="loading">{{ t('common.refresh') }}</el-button>
</div>
<div class="zset-content">
<div v-for="item in filteredItems" :key="item.member" class="zset-item">
<span class="item-score">{{ item.score }}</span>
<span class="item-member">{{ item.member }}</span>
<el-button size="small" type="danger" text @click="removeMember(item.member)">{{ t('editor.remove') }}</el-button>
</div>
<div v-if="filteredItems.length === 0 && !loading" class="empty">{{ t('editor.empty') }}</div>
</div>
<VirtualScrollList :items="filteredItems" :item-height="36" height="100%" class="zset-content">
<template #default="{ item }">
<div class="zset-item">
<span class="item-score">{{ item.score }}</span>
<span class="item-member">{{ item.member }}</span>
<el-button size="small" type="danger" text @click="removeMember(item.member)">{{ t('editor.remove') }}</el-button>
</div>
</template>
<template #empty>
<div class="empty">{{ t('editor.empty') }}</div>
</template>
</VirtualScrollList>
<el-dialog v-model="showAddDialog" :title="t('editor.addMember')" width="400px">
<el-form label-position="top">
@@ -167,6 +172,7 @@ const filteredItems = computed(() => {
gap: 12px;
padding: 8px 12px;
border-bottom: 1px solid var(--border-subtle);
width: 100%;
}
.zset-item:hover {