perf: reduce high-density DOM memory (#404)
This commit is contained in:
parent
6c83b10b42
commit
64c068bbf5
|
|
@ -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)"
|
||||
|
|
|
|||
|
|
@ -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 -->
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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/);
|
||||
});
|
||||
|
|
@ -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"/);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue