perf: reduce high-density DOM memory (#404)

This commit is contained in:
SuLea-IT 2026-05-22 14:02:13 +08:00 committed by GitHub
parent 6c83b10b42
commit 64c068bbf5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 408 additions and 219 deletions

View File

@ -286,6 +286,7 @@ function typeColorClass(t: string): string {
}
const contextCell = ref<{ rowId: number; rowIndex: number; col: number } | null>(null);
const detailCell = ref<{ rowIndex: number; col: number } | null>(null);
const hoveredDetailCell = ref<{ rowIndex: number; col: number } | null>(null);
const showCellDetail = ref(false);
const activeCellDetailTab = ref<CellDetailTab>(defaultCellDetailTab());
const detailWidth = ref(320);
@ -1734,6 +1735,24 @@ const multiRowCount = computed(() => {
const isMultiRow = computed(() => multiRowCount.value > 1);
function onCellMouseenter(rowIndex: number, visibleColIdx: number, actualColIdx: number) {
hoveredDetailCell.value = { rowIndex, col: actualColIdx };
extendCellSelection(rowIndex, visibleColIdx);
}
function onCellMouseleave(rowIndex: number, actualColIdx: number) {
if (hoveredDetailCell.value?.rowIndex === rowIndex && hoveredDetailCell.value.col === actualColIdx) {
hoveredDetailCell.value = null;
}
}
function cellDetailButtonVisible(rowIndex: number, actualColIdx: number) {
return (
(hoveredDetailCell.value?.rowIndex === rowIndex && hoveredDetailCell.value.col === actualColIdx) ||
(showCellDetail.value && detailCell.value?.rowIndex === rowIndex && detailCell.value.col === actualColIdx)
);
}
function affectedRowIds(): number[] {
if (hasRowSelection.value && selectedRowCount.value > 0) {
return [...selectedRowIds.value];
@ -4039,7 +4058,8 @@ defineExpose({
'line-through': item.isDeleted,
}"
@mousedown="handleDataCellMousedown(index, visibleColIdx, item.id, $event)"
@mouseenter="extendCellSelection(index, visibleColIdx)"
@mouseenter="onCellMouseenter(index, visibleColIdx, actualColIdx)"
@mouseleave="onCellMouseleave(index, actualColIdx)"
@dblclick="canEditCellItem(item, actualColIdx) && startEdit(item.id, actualColIdx)"
:data-visible-col-index="visibleColIdx"
@contextmenu="onCellContext(item.id, index, actualColIdx, visibleColIdx)"
@ -4067,7 +4087,8 @@ defineExpose({
<template v-else>
{{ formatCell(item.data[actualColIdx], actualColIdx) }}
<button
class="absolute right-0.5 top-0.5 hidden h-5 w-5 items-center justify-center rounded bg-background/90 text-muted-foreground shadow-sm ring-1 ring-border hover:text-foreground group-hover/cell:flex"
v-if="cellDetailButtonVisible(index, actualColIdx)"
class="absolute right-0.5 top-0.5 flex h-5 w-5 items-center justify-center rounded bg-background/90 text-muted-foreground shadow-sm ring-1 ring-border hover:text-foreground"
:title="t('grid.cellDetails')"
@mousedown.stop
@click.stop="showCellDetails(index, actualColIdx)"

View File

@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, ref, onBeforeUnmount, onMounted } from "vue";
import { useI18n } from "vue-i18n";
import { DynamicScroller, DynamicScrollerItem, RecycleScroller } from "vue-virtual-scroller";
import { Braces, Copy, Eye, FileText, Trash2, Save, RefreshCw, Plus, Loader2, Pencil, WrapText } from "lucide-vue-next";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@ -83,6 +84,8 @@ const hashGridStyle = computed(() => ({
const selectedMemberCanEdit = computed(
() => selectedMemberContext.value != null && canEditRedisMemberDetail(selectedMemberContext.value.kind),
);
const REDIS_COLLECTION_ROW_HEIGHT = 32;
const REDIS_STREAM_MIN_ROW_HEIGHT = 96;
type PendingDelete =
| { kind: "key" }
@ -105,6 +108,46 @@ type RedisMemberContext =
| { kind: "zset"; member: string; score: number }
| { kind: "stream"; field: string };
type RedisCollectionRow = {
id: string;
index: number;
value: any;
};
type RedisStreamRow = {
id: string;
index: number;
entry: {
id?: string | number;
fields?: Record<string, unknown>;
};
};
const collectionRows = computed<RedisCollectionRow[]>(() =>
collectionItems.value.map((value, index) => ({
id: `${index}`,
index,
value,
})),
);
const streamRows = computed<RedisStreamRow[]>(() => {
if (!data.value || data.value.key_type !== "stream" || !Array.isArray(data.value.value)) return [];
return data.value.value.map((entry, index) => ({
id: `${index}:${String(entry?.id ?? "")}`,
index,
entry,
}));
});
function streamFields(entry: RedisStreamRow["entry"]): [string, unknown][] {
return Object.entries(entry.fields ?? {});
}
function streamFieldCount(row: RedisStreamRow): number {
return streamFields(row.entry).length;
}
function readRedisJsonWordWrap(): boolean {
try {
return localStorage.getItem(REDIS_JSON_WRAP_STORAGE_KEY) !== "false";
@ -695,49 +738,60 @@ onBeforeUnmount(() => {
<div class="px-3 py-1 text-xs font-medium text-muted-foreground">Value</div>
<div />
</div>
<div class="flex-1 overflow-y-auto">
<div
v-for="(item, idx) in collectionItems"
:key="idx"
class="grid grid-cols-[60px_1fr_84px] border-b text-sm font-mono hover:bg-accent/50 group cursor-pointer"
:class="{ 'bg-accent/60': isSelectedMember(`#${idx}`, item) }"
@click="viewMember(`#${idx}`, item, { kind: 'list', index: Number(idx) })"
>
<div class="px-3 py-1.5 text-xs text-muted-foreground border-r">{{ idx }}</div>
<div class="px-3 py-1.5 truncate">{{ item }}</div>
<div class="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="icon"
class="h-5 w-5 opacity-0 group-hover:opacity-100"
:title="t('redis.viewMember')"
@click.stop="viewMember(`#${idx}`, item, { kind: 'list', index: Number(idx) })"
><Eye class="w-3 h-3"
/></Button>
<Button
variant="ghost"
size="icon"
class="h-5 w-5 opacity-0 group-hover:opacity-100"
:title="t('redis.copyMember')"
@click.stop="copyMember(item)"
><Copy class="w-3 h-3"
/></Button>
<Button
variant="ghost"
size="icon"
class="h-5 w-5 opacity-0 group-hover:opacity-100 text-destructive"
@click.stop="requestListRemove(Number(idx))"
><Trash2 class="w-3 h-3"
/></Button>
<RecycleScroller
class="flex-1 overflow-y-auto"
:items="collectionRows"
:item-size="REDIS_COLLECTION_ROW_HEIGHT"
:buffer="600"
:skip-hover="true"
key-field="id"
>
<template #default="{ item: row }">
<div
data-redis-value-row
class="grid grid-cols-[60px_1fr_84px] border-b text-sm font-mono hover:bg-accent/50 group cursor-pointer"
:class="{ 'bg-accent/60': isSelectedMember(`#${row.index}`, row.value) }"
:style="{ height: `${REDIS_COLLECTION_ROW_HEIGHT}px` }"
@click="viewMember(`#${row.index}`, row.value, { kind: 'list', index: row.index })"
>
<div class="px-3 py-1.5 text-xs text-muted-foreground border-r">{{ row.index }}</div>
<div class="px-3 py-1.5 truncate">{{ row.value }}</div>
<div class="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="icon"
class="h-5 w-5 opacity-0 group-hover:opacity-100"
:title="t('redis.viewMember')"
@click.stop="viewMember(`#${row.index}`, row.value, { kind: 'list', index: row.index })"
><Eye class="w-3 h-3"
/></Button>
<Button
variant="ghost"
size="icon"
class="h-5 w-5 opacity-0 group-hover:opacity-100"
:title="t('redis.copyMember')"
@click.stop="copyMember(row.value)"
><Copy class="w-3 h-3"
/></Button>
<Button
variant="ghost"
size="icon"
class="h-5 w-5 opacity-0 group-hover:opacity-100 text-destructive"
@click.stop="requestListRemove(row.index)"
><Trash2 class="w-3 h-3"
/></Button>
</div>
</div>
</div>
<div v-if="hasMore" class="p-2">
<Button variant="outline" size="sm" class="w-full h-7 text-xs" :disabled="loadingMore" @click="loadMore">
<Loader2 v-if="loadingMore" class="w-3 h-3 mr-1.5 animate-spin" />
{{ t("redis.loadMoreKeys") }}
</Button>
</div>
</div>
</template>
<template #after>
<div v-if="hasMore" class="p-2">
<Button variant="outline" size="sm" class="w-full h-7 text-xs" :disabled="loadingMore" @click="loadMore">
<Loader2 v-if="loadingMore" class="w-3 h-3 mr-1.5 animate-spin" />
{{ t("redis.loadMoreKeys") }}
</Button>
</div>
</template>
</RecycleScroller>
</div>
<!-- Set -->
@ -756,48 +810,59 @@ onBeforeUnmount(() => {
<div class="px-3 py-1 text-xs font-medium text-muted-foreground">Member</div>
<div />
</div>
<div class="flex-1 overflow-y-auto">
<div
v-for="(item, idx) in collectionItems"
:key="idx"
class="grid grid-cols-[1fr_84px] border-b text-sm font-mono hover:bg-accent/50 group cursor-pointer"
:class="{ 'bg-accent/60': isSelectedMember(t('redis.member'), item) }"
@click="viewMember(t('redis.member'), item, { kind: 'set', member: String(item) })"
>
<div class="px-3 py-1.5 truncate">{{ item }}</div>
<div class="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="icon"
class="h-5 w-5 opacity-0 group-hover:opacity-100"
:title="t('redis.viewMember')"
@click.stop="viewMember(t('redis.member'), item, { kind: 'set', member: String(item) })"
><Eye class="w-3 h-3"
/></Button>
<Button
variant="ghost"
size="icon"
class="h-5 w-5 opacity-0 group-hover:opacity-100"
:title="t('redis.copyMember')"
@click.stop="copyMember(item)"
><Copy class="w-3 h-3"
/></Button>
<Button
variant="ghost"
size="icon"
class="h-5 w-5 opacity-0 group-hover:opacity-100 text-destructive"
@click.stop="requestSetRemove(String(item))"
><Trash2 class="w-3 h-3"
/></Button>
<RecycleScroller
class="flex-1 overflow-y-auto"
:items="collectionRows"
:item-size="REDIS_COLLECTION_ROW_HEIGHT"
:buffer="600"
:skip-hover="true"
key-field="id"
>
<template #default="{ item: row }">
<div
data-redis-value-row
class="grid grid-cols-[1fr_84px] border-b text-sm font-mono hover:bg-accent/50 group cursor-pointer"
:class="{ 'bg-accent/60': isSelectedMember(t('redis.member'), row.value) }"
:style="{ height: `${REDIS_COLLECTION_ROW_HEIGHT}px` }"
@click="viewMember(t('redis.member'), row.value, { kind: 'set', member: String(row.value) })"
>
<div class="px-3 py-1.5 truncate">{{ row.value }}</div>
<div class="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="icon"
class="h-5 w-5 opacity-0 group-hover:opacity-100"
:title="t('redis.viewMember')"
@click.stop="viewMember(t('redis.member'), row.value, { kind: 'set', member: String(row.value) })"
><Eye class="w-3 h-3"
/></Button>
<Button
variant="ghost"
size="icon"
class="h-5 w-5 opacity-0 group-hover:opacity-100"
:title="t('redis.copyMember')"
@click.stop="copyMember(row.value)"
><Copy class="w-3 h-3"
/></Button>
<Button
variant="ghost"
size="icon"
class="h-5 w-5 opacity-0 group-hover:opacity-100 text-destructive"
@click.stop="requestSetRemove(String(row.value))"
><Trash2 class="w-3 h-3"
/></Button>
</div>
</div>
</div>
<div v-if="hasMore" class="p-2">
<Button variant="outline" size="sm" class="w-full h-7 text-xs" :disabled="loadingMore" @click="loadMore">
<Loader2 v-if="loadingMore" class="w-3 h-3 mr-1.5 animate-spin" />
{{ t("redis.loadMoreKeys") }}
</Button>
</div>
</div>
</template>
<template #after>
<div v-if="hasMore" class="p-2">
<Button variant="outline" size="sm" class="w-full h-7 text-xs" :disabled="loadingMore" @click="loadMore">
<Loader2 v-if="loadingMore" class="w-3 h-3 mr-1.5 animate-spin" />
{{ t("redis.loadMoreKeys") }}
</Button>
</div>
</template>
</RecycleScroller>
</div>
<!-- Hash -->
@ -824,50 +889,67 @@ onBeforeUnmount(() => {
<div class="px-3 py-1 text-xs font-medium text-muted-foreground">Value</div>
<div />
</div>
<div class="flex-1 overflow-y-auto">
<div
v-for="(item, idx) in collectionItems"
:key="idx"
class="grid border-b text-sm font-mono hover:bg-accent/50 group cursor-pointer"
:style="hashGridStyle"
:class="{ 'bg-accent/60': isSelectedMember(String(item.field), item.value) }"
@click="viewMember(String(item.field), item.value, { kind: 'hash', field: String(item.field) })"
>
<div class="px-3 py-1.5 text-blue-500 truncate border-r">{{ item.field }}</div>
<div class="px-3 py-1.5 truncate text-muted-foreground">{{ item.value }}</div>
<div class="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="icon"
class="h-5 w-5 opacity-0 group-hover:opacity-100"
:title="t('redis.viewMember')"
@click.stop="viewMember(String(item.field), item.value, { kind: 'hash', field: String(item.field) })"
><Eye class="w-3 h-3"
/></Button>
<Button
variant="ghost"
size="icon"
class="h-5 w-5 opacity-0 group-hover:opacity-100"
:title="t('redis.copyMember')"
@click.stop="copyMember(item.value)"
><Copy class="w-3 h-3"
/></Button>
<Button
variant="ghost"
size="icon"
class="h-5 w-5 opacity-0 group-hover:opacity-100 text-destructive"
@click.stop="requestHashDel(String(item.field))"
><Trash2 class="w-3 h-3"
/></Button>
<RecycleScroller
class="flex-1 overflow-y-auto"
:items="collectionRows"
:item-size="REDIS_COLLECTION_ROW_HEIGHT"
:buffer="600"
:skip-hover="true"
key-field="id"
>
<template #default="{ item: row }">
<div
data-redis-value-row
class="grid border-b text-sm font-mono hover:bg-accent/50 group cursor-pointer"
:style="{ ...hashGridStyle, height: `${REDIS_COLLECTION_ROW_HEIGHT}px` }"
:class="{ 'bg-accent/60': isSelectedMember(String(row.value.field), row.value.value) }"
@click="
viewMember(String(row.value.field), row.value.value, { kind: 'hash', field: String(row.value.field) })
"
>
<div class="px-3 py-1.5 text-blue-500 truncate border-r">{{ row.value.field }}</div>
<div class="px-3 py-1.5 truncate text-muted-foreground">{{ row.value.value }}</div>
<div class="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="icon"
class="h-5 w-5 opacity-0 group-hover:opacity-100"
:title="t('redis.viewMember')"
@click.stop="
viewMember(String(row.value.field), row.value.value, {
kind: 'hash',
field: String(row.value.field),
})
"
><Eye class="w-3 h-3"
/></Button>
<Button
variant="ghost"
size="icon"
class="h-5 w-5 opacity-0 group-hover:opacity-100"
:title="t('redis.copyMember')"
@click.stop="copyMember(row.value.value)"
><Copy class="w-3 h-3"
/></Button>
<Button
variant="ghost"
size="icon"
class="h-5 w-5 opacity-0 group-hover:opacity-100 text-destructive"
@click.stop="requestHashDel(String(row.value.field))"
><Trash2 class="w-3 h-3"
/></Button>
</div>
</div>
</div>
<div v-if="hasMore" class="p-2">
<Button variant="outline" size="sm" class="w-full h-7 text-xs" :disabled="loadingMore" @click="loadMore">
<Loader2 v-if="loadingMore" class="w-3 h-3 mr-1.5 animate-spin" />
{{ t("redis.loadMoreKeys") }}
</Button>
</div>
</div>
</template>
<template #after>
<div v-if="hasMore" class="p-2">
<Button variant="outline" size="sm" class="w-full h-7 text-xs" :disabled="loadingMore" @click="loadMore">
<Loader2 v-if="loadingMore" class="w-3 h-3 mr-1.5 animate-spin" />
{{ t("redis.loadMoreKeys") }}
</Button>
</div>
</template>
</RecycleScroller>
</div>
<!-- Sorted Set -->
@ -888,103 +970,127 @@ onBeforeUnmount(() => {
<div class="px-3 py-1 text-xs font-medium text-muted-foreground">Member</div>
<div />
</div>
<div class="flex-1 overflow-y-auto">
<div
v-for="(item, idx) in collectionItems"
:key="idx"
class="grid grid-cols-[100px_1fr_84px] border-b text-sm font-mono hover:bg-accent/50 group cursor-pointer"
:class="{ 'bg-accent/60': isSelectedMember(String(item.score), item.member) }"
@click="
viewMember(String(item.score), item.member, {
kind: 'zset',
member: String(item.member),
score: Number(item.score),
})
"
>
<div class="px-3 py-1.5 text-muted-foreground text-xs border-r">{{ item.score }}</div>
<div class="px-3 py-1.5 truncate">{{ item.member }}</div>
<div class="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="icon"
class="h-5 w-5 opacity-0 group-hover:opacity-100"
:title="t('redis.viewMember')"
@click.stop="
viewMember(String(item.score), item.member, {
kind: 'zset',
member: String(item.member),
score: Number(item.score),
})
"
><Eye class="w-3 h-3"
/></Button>
<Button
variant="ghost"
size="icon"
class="h-5 w-5 opacity-0 group-hover:opacity-100"
:title="t('redis.copyMember')"
@click.stop="copyMember(item.member)"
><Copy class="w-3 h-3"
/></Button>
<Button
variant="ghost"
size="icon"
class="h-5 w-5 opacity-0 group-hover:opacity-100 text-destructive"
@click.stop="requestZsetRemove(String(item.member))"
><Trash2 class="w-3 h-3"
/></Button>
<RecycleScroller
class="flex-1 overflow-y-auto"
:items="collectionRows"
:item-size="REDIS_COLLECTION_ROW_HEIGHT"
:buffer="600"
:skip-hover="true"
key-field="id"
>
<template #default="{ item: row }">
<div
data-redis-value-row
class="grid grid-cols-[100px_1fr_84px] border-b text-sm font-mono hover:bg-accent/50 group cursor-pointer"
:class="{ 'bg-accent/60': isSelectedMember(String(row.value.score), row.value.member) }"
:style="{ height: `${REDIS_COLLECTION_ROW_HEIGHT}px` }"
@click="
viewMember(String(row.value.score), row.value.member, {
kind: 'zset',
member: String(row.value.member),
score: Number(row.value.score),
})
"
>
<div class="px-3 py-1.5 text-muted-foreground text-xs border-r">{{ row.value.score }}</div>
<div class="px-3 py-1.5 truncate">{{ row.value.member }}</div>
<div class="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="icon"
class="h-5 w-5 opacity-0 group-hover:opacity-100"
:title="t('redis.viewMember')"
@click.stop="
viewMember(String(row.value.score), row.value.member, {
kind: 'zset',
member: String(row.value.member),
score: Number(row.value.score),
})
"
><Eye class="w-3 h-3"
/></Button>
<Button
variant="ghost"
size="icon"
class="h-5 w-5 opacity-0 group-hover:opacity-100"
:title="t('redis.copyMember')"
@click.stop="copyMember(row.value.member)"
><Copy class="w-3 h-3"
/></Button>
<Button
variant="ghost"
size="icon"
class="h-5 w-5 opacity-0 group-hover:opacity-100 text-destructive"
@click.stop="requestZsetRemove(String(row.value.member))"
><Trash2 class="w-3 h-3"
/></Button>
</div>
</div>
</div>
<div v-if="hasMore" class="p-2">
<Button variant="outline" size="sm" class="w-full h-7 text-xs" :disabled="loadingMore" @click="loadMore">
<Loader2 v-if="loadingMore" class="w-3 h-3 mr-1.5 animate-spin" />
{{ t("redis.loadMoreKeys") }}
</Button>
</div>
</div>
</template>
<template #after>
<div v-if="hasMore" class="p-2">
<Button variant="outline" size="sm" class="w-full h-7 text-xs" :disabled="loadingMore" @click="loadMore">
<Loader2 v-if="loadingMore" class="w-3 h-3 mr-1.5 animate-spin" />
{{ t("redis.loadMoreKeys") }}
</Button>
</div>
</template>
</RecycleScroller>
</div>
<!-- Stream (readonly) -->
<div v-else-if="data.key_type === 'stream'" class="flex-1 overflow-auto">
<div class="px-4 py-1 text-xs text-muted-foreground border-b">
{{ t("redis.entries", { count: Array.isArray(data.value) ? data.value.length : 0 }) }}
<div v-else-if="data.key_type === 'stream'" class="flex-1 flex flex-col overflow-hidden">
<div class="px-4 py-1 text-xs text-muted-foreground border-b shrink-0">
{{ t("redis.entries", { count: streamRows.length }) }}
</div>
<div
v-for="entry in data.value"
:key="entry.id"
class="px-4 py-2 border-b text-sm font-mono hover:bg-accent/50"
<DynamicScroller
class="flex-1 overflow-y-auto"
:items="streamRows"
:min-item-size="REDIS_STREAM_MIN_ROW_HEIGHT"
:buffer="600"
key-field="id"
>
<div class="mb-1 text-xs text-muted-foreground">{{ entry.id }}</div>
<div
v-for="(val, field) in entry.fields"
:key="String(field)"
class="grid grid-cols-[minmax(6rem,0.35fr)_1fr_56px] gap-3 py-0.5 group cursor-pointer"
:class="{ 'bg-accent/60': isSelectedMember(String(field), val) }"
@click="viewMember(String(field), val, { kind: 'stream', field: String(field) })"
>
<span class="truncate text-blue-500">{{ field }}</span>
<span class="truncate text-muted-foreground">{{ val }}</span>
<span class="flex justify-end gap-1">
<Button
variant="ghost"
size="icon"
class="h-5 w-5 opacity-0 group-hover:opacity-100"
:title="t('redis.viewMember')"
@click.stop="viewMember(String(field), val, { kind: 'stream', field: String(field) })"
><Eye class="w-3 h-3"
/></Button>
<Button
variant="ghost"
size="icon"
class="h-5 w-5 opacity-0 group-hover:opacity-100"
:title="t('redis.copyMember')"
@click.stop="copyMember(val)"
><Copy class="w-3 h-3"
/></Button>
</span>
</div>
</div>
<template #default="{ item: row, active }">
<DynamicScrollerItem
:item="row"
:active="active"
:size-dependencies="[streamFieldCount(row)]"
:data-index="row.index"
>
<div data-redis-stream-entry class="px-4 py-2 border-b text-sm font-mono hover:bg-accent/50">
<div class="mb-1 text-xs text-muted-foreground">{{ row.entry.id }}</div>
<div
v-for="[field, val] in streamFields(row.entry)"
:key="field"
class="grid grid-cols-[minmax(6rem,0.35fr)_1fr_56px] gap-3 py-0.5 group cursor-pointer"
:class="{ 'bg-accent/60': isSelectedMember(String(field), val) }"
@click="viewMember(String(field), val, { kind: 'stream', field: String(field) })"
>
<span class="truncate text-blue-500">{{ field }}</span>
<span class="truncate text-muted-foreground">{{ val }}</span>
<span class="flex justify-end gap-1">
<Button
variant="ghost"
size="icon"
class="h-5 w-5 opacity-0 group-hover:opacity-100"
:title="t('redis.viewMember')"
@click.stop="viewMember(String(field), val, { kind: 'stream', field: String(field) })"
><Eye class="w-3 h-3"
/></Button>
<Button
variant="ghost"
size="icon"
class="h-5 w-5 opacity-0 group-hover:opacity-100"
:title="t('redis.copyMember')"
@click.stop="copyMember(val)"
><Copy class="w-3 h-3"
/></Button>
</span>
</div>
</div>
</DynamicScrollerItem>
</template>
</DynamicScroller>
</div>
<!-- Unknown -->

View File

@ -0,0 +1,41 @@
# DOM density and memory audit
Date: 2026-05-22
Baseline: `upstream/main` at `6c83b10`
Candidate: `perf/dom-density-audit`
## Method
- Built both baseline and candidate as production Vite bundles.
- Ran each measurement in a fresh headless Google Chrome process with a fresh profile.
- Rendered the same synthetic large-result scenarios by driving the app stores and mocked desktop APIs.
- Ran 3 isolated samples per scenario and reported the median.
- Collected RSS from the full Chrome process tree after forced garbage collection. RSS includes browser process overhead, so the comparable signal is the delta.
- Collected JS heap with `Runtime.getHeapUsage` after `HeapProfiler.collectGarbage` and `globalThis.gc()`.
## Memory Results
| Scenario | Before RSS MiB | After RSS MiB | RSS Delta MiB | Before JS Heap MiB | After JS Heap MiB | JS Heap Delta MiB |
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
| Grid 1000 x 20 | 1226.3 | 1214.2 | -12.1 | 19.6 | 16.6 | -3.0 |
| Grid 1000 x 200 | 1462.5 | 1399.7 | -62.8 | 96.7 | 67.5 | -29.2 |
| Redis list value 3000 | 1396.6 | 1185.4 | -211.2 | 82.7 | 11.3 | -71.4 |
| Redis hash value 3000 | 1402.3 | 1183.8 | -218.5 | 83.7 | 11.5 | -72.2 |
| Redis stream 1000 x 5 | 1444.3 | 1182.1 | -262.2 | 92.5 | 13.3 | -79.2 |
## DOM Results
| Scenario | Before DOM Elements | After DOM Elements | Element Delta | Before DOM Nodes | After DOM Nodes | Node Delta | Listener Delta |
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| Grid 1000 x 20 | 6937 | 1837 | -5100 | 12644 | 8564 | -4080 | -1020 |
| Grid 1000 x 200 | 65257 | 14257 | -51000 | 106784 | 65984 | -40800 | -10200 |
| Redis list value 3000 | 57293 | 1096 | -56197 | 82472 | 2760 | -79712 | -11839 |
| Redis hash value 3000 | 57295 | 1098 | -56197 | 82478 | 2766 | -79712 | -11839 |
| Redis stream 1000 x 5 | 62281 | 1180 | -61101 | 96450 | 2928 | -93522 | -14759 |
## Notes
- The grid reduction comes from mounting the cell detail action only for the hovered or currently open detail cell.
- The Redis reduction comes from virtualizing large list, set, hash, sorted set, and stream value rows.
- Existing user operations remain available: the cell detail action appears on hover or while its detail panel is open, and Redis row actions still operate on the visible virtualized rows.

View File

@ -0,0 +1,12 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const dataGridSource = readFileSync("apps/desktop/src/components/grid/DataGrid.vue", "utf8");
test("data grid mounts the cell detail action only for the active cell", () => {
assert.match(dataGridSource, /cellDetailButtonVisible/);
assert.match(dataGridSource, /v-if="cellDetailButtonVisible\(index, actualColIdx\)"/);
assert.match(dataGridSource, /@mouseenter="onCellMouseenter\(index, visibleColIdx, actualColIdx\)"/);
assert.doesNotMatch(dataGridSource, /group-hover\/cell:flex/);
});

View File

@ -39,3 +39,12 @@ test("Redis JSON raw content uses Shiki highlighting safely", async () => {
assert.doesNotMatch(html, /<script>/);
assert.doesNotMatch(html, /<pre/);
});
test("Redis collection and stream values are virtualized", () => {
assert.match(redisViewerSource, /RecycleScroller/);
assert.match(redisViewerSource, /DynamicScroller/);
assert.match(redisViewerSource, /:items="collectionRows"/);
assert.match(redisViewerSource, /:items="streamRows"/);
assert.doesNotMatch(redisViewerSource, /v-for="\(item, idx\) in collectionItems"/);
assert.doesNotMatch(redisViewerSource, /v-for="entry in data\.value"/);
});