From 328a2aa107c3e6bbeb5ac6edb8f2f85812d455aa Mon Sep 17 00:00:00 2001 From: zipg Date: Sun, 9 Aug 2026 14:14:45 +0800 Subject: [PATCH] feat(redis): improve key browser toolbar and infinite scroll loading --- .../redis/RedisKeyBrowser.expiry.spec.ts | 13 ++ .../src/components/redis/RedisKeyBrowser.vue | 153 ++++++++++++++++-- .../redis/RedisValueViewer.expiry.spec.ts | 23 +++ .../src/components/redis/RedisValueViewer.vue | 7 +- apps/desktop/src/i18n/locales/en.ts | 6 +- apps/desktop/src/i18n/locales/es.ts | 6 +- apps/desktop/src/i18n/locales/it.ts | 6 +- apps/desktop/src/i18n/locales/ja.ts | 6 +- apps/desktop/src/i18n/locales/ko.ts | 6 +- apps/desktop/src/i18n/locales/pt-BR.ts | 6 +- apps/desktop/src/i18n/locales/zh-CN.ts | 6 +- apps/desktop/src/i18n/locales/zh-TW.ts | 6 +- .../redis/redisKeyInfiniteScroll.spec.ts | 34 ++++ .../src/lib/redis/redisKeyInfiniteScroll.ts | 16 ++ 14 files changed, 254 insertions(+), 40 deletions(-) create mode 100644 apps/desktop/src/lib/__tests__/redis/redisKeyInfiniteScroll.spec.ts create mode 100644 apps/desktop/src/lib/redis/redisKeyInfiniteScroll.ts diff --git a/apps/desktop/src/components/redis/RedisKeyBrowser.expiry.spec.ts b/apps/desktop/src/components/redis/RedisKeyBrowser.expiry.spec.ts index f387471ac..e32549a67 100644 --- a/apps/desktop/src/components/redis/RedisKeyBrowser.expiry.spec.ts +++ b/apps/desktop/src/components/redis/RedisKeyBrowser.expiry.spec.ts @@ -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() { 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); diff --git a/apps/desktop/src/components/redis/RedisKeyBrowser.vue b/apps/desktop/src/components/redis/RedisKeyBrowser.vue index 07ae57edc..24cdbc44a 100644 --- a/apps/desktop/src/components/redis/RedisKeyBrowser.vue +++ b/apps/desktop/src/components/redis/RedisKeyBrowser.vue @@ -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(); 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 }); -
+
-
-
- - -
-
- {{ keyCountText }} + {{ keyCountText }} +
-
-
+
+
- - {{ t("redis.fuzzyMatch") }} + + {{ t("redis.fuzzyMatch") }}
@@ -1568,7 +1606,7 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand }); {{ loadingEmptyText }}
- +