feat(redis): improve key browser toolbar and infinite scroll loading

This commit is contained in:
zipg authored and GitHub committed 2026-08-09 14:14:45 +08:00
1 parent b1fcb23625
commit 328a2aa107
14 files changed
+254 -40

No files matched your search

@@ -26,6 +26,8 @@ const mocks = vi.hoisted(() => ({
toast: vi.fn(),
updateRedisDbKeyStats: vi.fn(),
redisScanPageSize: 100,
infiniteScroll: false,
infiniteScrollMaxRows: 5000,
}));
vi.mock("@/lib/backend/api", () => ({
@@ -60,6 +62,15 @@ vi.mock("@/stores/connectionStore", () => ({
}),
}));
vi.mock("@/stores/settingsStore", () => ({
useSettingsStore: () => ({
editorSettings: {
infiniteScroll: mocks.infiniteScroll,
infiniteScrollMaxRows: mocks.infiniteScrollMaxRows,
},
}),
}));
vi.mock("@/composables/useEditorFontFamilyStyle", () => ({
useEditorFontFamilyStyle: () => ({}),
}));
@@ -346,6 +357,8 @@ function deferred<T>() {
function resetApiMocks() {
vi.clearAllMocks();
mocks.redisScanPageSize = 100;
mocks.infiniteScroll = false;
mocks.infiniteScrollMaxRows = 5000;
mocks.redisScanKeysBatch.mockResolvedValue({ cursor: 0, keys: [], total_keys: 0 });
mocks.redisGetValue.mockImplementation((_connectionId: string, _db: number, keyRaw: string) => Promise.resolve(redisValue(keyRaw)));
mocks.redisSetString.mockResolvedValue(undefined);
@@ -25,6 +25,7 @@ import * as api from "@/lib/backend/api";
import type { RedisKeyInfo, RedisScanResult, RedisValue, HistoryEntry } from "@/lib/backend/api";
import { uuid } from "@/lib/common/utils";
import { useConnectionStore } from "@/stores/connectionStore";
import { useSettingsStore } from "@/stores/settingsStore";
import {
appendRedisKeysToTreeIndex,
canBuildRedisFuzzyTree,
@@ -52,10 +53,12 @@ import { chunkRedisKeyRaws, collectUniqueRedisKeys } from "@/lib/redis/redisKeyB
import { getRedisCreateKeyTypeHelp, redisCreateKeyTypeHelpOptionOnOpen, shouldActivateRedisCreateKeyTypeHelpOnFocus } from "@/lib/redis/redisCreateKeyTypeHelp";
import { optionHelpPanelOffsetTop } from "@/lib/common/optionHelpPanelOffset";
import { applyRedisExpiryPolicy, type RedisExpiryMode, validateRedisExpiry } from "@/lib/redis/redisExpiry";
import { shouldLoadMoreRedisKeys } from "@/lib/redis/redisKeyInfiniteScroll";
const { t, locale } = useI18n();
const { toast } = useToast();
const connectionStore = useConnectionStore();
const settingsStore = useSettingsStore();
const editorFontFamilyStyle = useEditorFontFamilyStyle();
type RedisSearchMode = "key" | "value" | "all";
@@ -145,6 +148,7 @@ let loadMoreOperationId = 0;
let redisBrowserIsActive = true;
let reloadKeysOnActivation = false;
let redisDbFlushedListenerRegistered = false;
let redisInfiniteScrollFrame = 0;
const loadedKeyRaws = new Set<string>();
let treeIndex: RedisKeyTreeIndex | null = null;
@@ -166,6 +170,8 @@ const searchPlaceholder = computed(() => {
const loadingEmptyText = computed(() => (isValueSearchMode.value && valueQuery.value ? t(searchMode.value === "all" ? "redis.searchingAll" : "redis.searchingValues") : t("redis.loadingKeys")));
const redisKeySeparator = computed(() => connectionStore.getConfig(props.connectionId)?.redis_key_separator ?? ":");
const redisScanPageSize = computed(() => connectionStore.getConfig(props.connectionId)?.redis_scan_page_size ?? REDIS_SCAN_PAGE_SIZE_DEFAULT);
const redisInfiniteScrollEnabled = computed(() => settingsStore.editorSettings.infiniteScroll);
const redisInfiniteScrollMaxKeys = computed(() => settingsStore.editorSettings.infiniteScrollMaxRows);
watch(redisKeySeparator, () => {
if (flatKeys.value.length === 0) return;
if (useFlatKeySearchRows.value) {
@@ -543,6 +549,9 @@ async function loadKeys() {
}
async function loadMore() {
// 与 loadKeys 对称:组件被 keep-alive 包裹且停用后,挂起的 rAF 仍可能触发本函数,
// 守卫掉停用态避免对隐藏组件跑一次冗余 SCAN。
if (!redisBrowserIsActive) return;
if (!hasMore.value || loadingMore.value) return;
const requestId = searchRequestId;
const operationId = ++loadMoreOperationId;
@@ -556,6 +565,27 @@ async function loadMore() {
}
}
function onRedisKeyScroll(event: Event) {
const scroller = event.target;
if (!(scroller instanceof HTMLElement) || redisInfiniteScrollFrame) return;
redisInfiniteScrollFrame = requestAnimationFrame(() => {
redisInfiniteScrollFrame = 0;
const shouldLoad = shouldLoadMoreRedisKeys({
enabled: redisInfiniteScrollEnabled.value,
hasMore: hasMore.value,
busy: loading.value || loadingMore.value || searchPending.value || deletingKeys.value || isFetchingAll.value,
loadedKeys: flatKeys.value.length,
maxKeys: redisInfiniteScrollMaxKeys.value,
scrollTop: scroller.scrollTop,
clientHeight: scroller.clientHeight,
scrollHeight: scroller.scrollHeight,
});
if (shouldLoad) {
void loadMore().catch((error) => toast(errorMessage(error), 5000));
}
});
}
// Fetch-all uses large key-only SCAN pages and rebuilds the tree once at the
// end; per-page tree sorting dominates runtime on million-key pattern scans.
const FETCH_ALL_SCAN_COUNT = 50000;
@@ -1360,6 +1390,10 @@ function pauseRedisBrowserBackgroundWork() {
// keys that were never rendered.
const discardIncompleteFetchAll = isFetchingAll.value;
redisBrowserIsActive = false;
// 与 onUnmounted 对称:组件被 keep-alive 包裹,停用时(onDeactivated)若不取消挂起的 rAF,
// 帧回调仍会在隐藏组件上触发并调用 loadMore() 跑一次冗余 SCAN,故在此一并取消并置 0。
if (redisInfiniteScrollFrame) cancelAnimationFrame(redisInfiniteScrollFrame);
redisInfiniteScrollFrame = 0;
invalidateScanRequests();
isFetchingAll.value = false;
fetchAllStopRequested.value = false;
@@ -1452,7 +1486,11 @@ onActivated(async () => {
onDeactivated(pauseRedisBrowserBackgroundWork);
onUnmounted(pauseRedisBrowserBackgroundWork);
onUnmounted(() => {
pauseRedisBrowserBackgroundWork();
if (redisInfiniteScrollFrame) cancelAnimationFrame(redisInfiniteScrollFrame);
redisInfiniteScrollFrame = 0;
});
watch(
() => [props.connectionId, props.db] as const,
@@ -1497,23 +1535,23 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand });
<Splitpanes class="redis-workspace-splitpanes h-full">
<!-- Key tree (left) -->
<Pane :size="36" :min-size="24">
<div class="relative h-full flex flex-col overflow-hidden">
<div class="redis-key-pane relative h-full flex flex-col overflow-hidden">
<!-- Toolbar -->
<div class="border-b px-2 py-2 shrink-0">
<div class="flex flex-wrap items-start gap-1.5">
<div class="flex min-w-0 flex-1 flex-wrap rounded-md border bg-muted/30 p-0.5" role="group">
<button type="button" class="h-5 px-2 text-xs rounded-sm transition-colors" :class="searchMode === 'key' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'" @click="setSearchMode('key')">
<div class="redis-key-toolbar-header">
<div class="redis-search-mode-group flex rounded-md border bg-muted/30 p-0.5" role="group">
<button type="button" class="redis-search-mode-button h-5 px-2 text-xs rounded-sm transition-colors" :class="searchMode === 'key' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'" @click="setSearchMode('key')">
{{ t("redis.searchByKey") }}
</button>
<button type="button" class="h-5 px-2 text-xs rounded-sm transition-colors" :class="searchMode === 'value' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'" @click="setSearchMode('value')">
<button type="button" class="redis-search-mode-button h-5 px-2 text-xs rounded-sm transition-colors" :class="searchMode === 'value' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'" @click="setSearchMode('value')">
{{ t("redis.searchByValue") }}
</button>
<button type="button" class="h-5 px-2 text-xs rounded-sm transition-colors" :class="searchMode === 'all' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'" @click="setSearchMode('all')">
<button type="button" class="redis-search-mode-button h-5 px-2 text-xs rounded-sm transition-colors" :class="searchMode === 'all' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'" @click="setSearchMode('all')">
{{ t("redis.searchByAll") }}
</button>
</div>
<div class="ml-auto flex min-w-0 flex-wrap items-center justify-end gap-1">
<span class="min-w-0 max-w-full truncate text-xs text-muted-foreground" :title="keyCountText">{{ keyCountText }}</span>
<span class="redis-key-count truncate text-xs text-muted-foreground" :title="keyCountText">{{ keyCountText }}</span>
<div class="redis-key-toolbar-actions flex items-center justify-end gap-1">
<Button v-if="checkedKeys.size > 0" variant="ghost" size="sm" class="h-6 shrink-0 text-xs text-destructive" :disabled="selectionBusy" @click="requestBatchDelete"> <Trash2 class="w-3 h-3 mr-1" />{{ checkedKeys.size }} </Button>
<Button variant="ghost" size="icon" class="h-6 w-6 shrink-0" :disabled="deletingKeys" @click="loadKeys">
<Loader2 v-if="loading" class="h-3 w-3 animate-spin" />
@@ -1524,8 +1562,8 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand });
</Button>
</div>
</div>
<div class="mt-2 flex min-w-0 flex-wrap items-center gap-1.5">
<div class="relative min-w-[120px] flex-1 basis-[180px]">
<div class="redis-key-search-row mt-2">
<div class="relative min-w-0">
<Search class="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground/80" />
<Input
v-model="searchPattern"
@@ -1540,14 +1578,14 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand });
v-if="searchMode === 'key'"
variant="ghost"
size="sm"
class="h-8 max-w-full shrink-0 px-2 text-xs"
class="h-8 max-w-full shrink-0 whitespace-nowrap px-2 text-xs"
:class="fuzzyKeySearch ? 'bg-accent text-accent-foreground' : 'border border-dashed border-border/70 text-muted-foreground hover:text-foreground'"
:title="t('redis.fuzzyMatchTitle')"
:aria-pressed="fuzzyKeySearch"
@click="toggleFuzzyKeySearch"
>
<Asterisk class="h-3 w-3 mr-1" />
{{ t("redis.fuzzyMatch") }}
<Asterisk class="redis-fuzzy-icon h-3 w-3 mr-1" />
<span class="redis-fuzzy-label">{{ t("redis.fuzzyMatch") }}</span>
</Button>
</div>
</div>
@@ -1568,7 +1606,7 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand });
<Loader2 class="w-3.5 h-3.5 animate-spin" />
<span>{{ loadingEmptyText }}</span>
</div>
<RecycleScroller v-else class="redis-key-scroller flex-1" :items="visibleRows" :item-size="30" :buffer="600" :skip-hover="true" key-field="id">
<RecycleScroller v-else class="redis-key-scroller flex-1" :items="visibleRows" :item-size="30" :buffer="600" :skip-hover="true" key-field="id" @scroll="onRedisKeyScroll">
<template #default="{ item: row }">
<CustomContextMenu :items="redisKeyContextMenuItems(row.node)" v-slot="{ onContextMenu }">
<div
@@ -1862,6 +1900,87 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand });
</template>
<style scoped>
.redis-key-pane {
container-type: inline-size;
}
.redis-key-toolbar-header {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 0.375rem;
}
.redis-search-mode-group,
.redis-key-toolbar-actions {
flex-wrap: nowrap;
min-width: 0;
}
.redis-search-mode-group {
justify-self: start;
}
.redis-search-mode-button {
flex: 0 0 auto;
white-space: nowrap;
}
.redis-key-count {
min-width: 0;
text-align: right;
white-space: nowrap;
}
.redis-key-search-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 0.375rem;
}
@container (max-width: 320px) {
.redis-key-toolbar-header {
grid-template-columns: auto auto minmax(0, 1fr);
}
.redis-key-count {
grid-column: 1 / -1;
grid-row: 2;
text-align: left;
}
.redis-key-toolbar-actions {
grid-column: 2;
grid-row: 1;
}
}
@container (max-width: 240px) {
.redis-search-mode-group {
grid-column: 1 / -1;
grid-row: 1;
}
.redis-key-count {
grid-column: 1;
grid-row: 2;
}
.redis-key-toolbar-actions {
grid-column: 2;
grid-row: 2;
}
.redis-fuzzy-label {
display: none;
}
.redis-fuzzy-icon {
margin-right: 0;
}
}
.redis-key-scroller {
will-change: scroll-position;
contain: content;
@@ -1871,6 +1990,10 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand });
contain: layout style paint;
}
.redis-workspace-splitpanes > :deep(.splitpanes__pane:first-child) {
min-width: min(256px, 64%);
}
.redis-workspace-splitpanes :deep(.splitpanes--vertical > .splitpanes__splitter) {
width: 1px !important;
border-left: 0;
@@ -239,6 +239,29 @@ async function setStringDraft(value: string) {
}
describe("RedisValueViewer expiry saving", () => {
it("opens member UTF-8 editing from blank space without hijacking text double-clicks", async () => {
mocks.redisGetValue.mockResolvedValueOnce(listValue());
mountViewer(vi.fn());
await settle();
Array.from(document.querySelectorAll<HTMLElement>("[data-redis-value-row]"))
.find((row) => row.textContent?.includes("second"))!
.click();
await settle();
const viewer = document.querySelector<HTMLElement>("[data-redis-member-utf8-viewer]")!;
const text = document.querySelector<HTMLElement>("[data-redis-member-utf8-text]")!;
text.dispatchEvent(new MouseEvent("dblclick", { bubbles: true, cancelable: true }));
await settle();
expect(document.querySelector("[data-redis-member-utf8-editor]")).toBeNull();
viewer.dispatchEvent(new MouseEvent("dblclick", { bubbles: true, cancelable: true }));
await settle();
const editor = document.querySelector<HTMLTextAreaElement>("[data-redis-member-utf8-editor]")!;
expect(editor.value).toBe("second");
expect(document.activeElement).toBe(editor);
});
it("defaults to manual refresh without automatic value polling", async () => {
vi.useFakeTimers();
mocks.redisGetValue.mockResolvedValueOnce(stringValue("dmFsdWU=", 60));
@@ -1700,6 +1700,7 @@ function startEditMember() {
// Do not demote a retained JSON draft to utf8; save still needs compact normalization.
if (memberDraftFormat.value !== "json") memberDraftFormat.value = "utf8";
isEditingMember.value = true;
nextTick(() => memberTextareaRef.value?.focus());
}
function cancelEditMember() {
@@ -3153,7 +3154,7 @@ defineExpose({ focusSearch });
</DialogTitle>
</DialogHeader>
<template v-if="isEditingMember">
<textarea ref="memberTextareaRef" v-model="memberEditValue" class="dbx-editor-font-family min-h-0 flex-1 resize-none bg-background p-5 text-[13px] leading-6 outline-none" :readonly="savingMember" spellcheck="false" />
<textarea ref="memberTextareaRef" data-redis-member-utf8-editor v-model="memberEditValue" class="dbx-editor-font-family min-h-0 flex-1 resize-none bg-background p-5 text-[13px] leading-6 outline-none" :readonly="savingMember" spellcheck="false" />
</template>
<template v-else>
<div class="flex h-9 items-center gap-2 border-b px-5 text-xs">
@@ -3225,6 +3226,10 @@ defineExpose({ focusSearch });
</div>
</template>
</div>
<div v-else-if="memberValueView === 'utf8' && canEditCurrentMemberFormat" data-redis-member-utf8-viewer class="dbx-editor-font-family min-h-0 flex-1 overflow-auto bg-background text-[13px] leading-6 cursor-text" @dblclick.self.prevent="startEditMember">
<pre v-if="canHighlightMemberSurface" data-redis-member-utf8-text class="inline-block min-w-0 p-5 align-top select-text" :class="[detailTextClass('utf8'), redisJsonWordWrap ? 'max-w-full' : 'min-w-max']" v-html="contentSearchHighlightedHtml" />
<pre v-else data-redis-member-utf8-text class="inline-block min-w-0 p-5 align-top select-text" :class="[detailTextClass('utf8'), redisJsonWordWrap ? 'max-w-full' : 'min-w-max']">{{ detailTextForFormat(selectedMemberDetail, "utf8") }}</pre>
</div>
<pre v-else-if="canHighlightMemberSurface" class="dbx-editor-font-family min-h-0 w-full min-w-0 max-w-full flex-1 overflow-auto bg-background p-5 text-[13px] leading-6" :class="detailTextClass(memberValueView)" v-html="contentSearchHighlightedHtml" />
<pre v-else class="dbx-editor-font-family min-h-0 w-full min-w-0 max-w-full flex-1 overflow-auto bg-background p-5 text-[13px] leading-6" :class="detailTextClass(memberValueView)">{{ detailTextForFormat(selectedMemberDetail, memberValueView) }}</pre>
</template>