From aa4f503353ebbf346f11b3f942c15bab508b01dd Mon Sep 17 00:00:00 2001 From: onenewcode Date: Thu, 3 Sep 2026 11:56:46 +0800 Subject: [PATCH] feat(redis): search list set and zset members --- .../RedisValueViewer.collectionSearch.spec.ts | 275 +++++++++++++ .../src/components/redis/RedisValueViewer.vue | 128 +++--- apps/desktop/src/i18n/locales/en.ts | 2 + apps/desktop/src/i18n/locales/es.ts | 2 + apps/desktop/src/i18n/locales/it.ts | 2 + apps/desktop/src/i18n/locales/ja.ts | 2 + apps/desktop/src/i18n/locales/ko.ts | 2 + apps/desktop/src/i18n/locales/pt-BR.ts | 2 + apps/desktop/src/i18n/locales/zh-CN.ts | 2 + apps/desktop/src/i18n/locales/zh-TW.ts | 2 + .../redis/RedisValueViewer.redisJson.spec.ts | 4 +- crates/dbx-core/src/db/redis_driver.rs | 366 +++++++++++++++++- .../redis-list-item-search-filtered.png | Bin 0 -> 30998 bytes .../redis-set-member-search-filtered.png | Bin 0 -> 30654 bytes .../redis-zset-member-search-filtered.png | Bin 0 -> 35721 bytes issue-assets/redis-zset-member-search.png | Bin 0 -> 101146 bytes packages/app-tests/redisValueSearch.test.ts | 4 +- 17 files changed, 715 insertions(+), 78 deletions(-) create mode 100644 apps/desktop/src/components/redis/RedisValueViewer.collectionSearch.spec.ts create mode 100644 issue-assets/redis-list-item-search-filtered.png create mode 100644 issue-assets/redis-set-member-search-filtered.png create mode 100644 issue-assets/redis-zset-member-search-filtered.png create mode 100644 issue-assets/redis-zset-member-search.png diff --git a/apps/desktop/src/components/redis/RedisValueViewer.collectionSearch.spec.ts b/apps/desktop/src/components/redis/RedisValueViewer.collectionSearch.spec.ts new file mode 100644 index 0000000000..77adb90866 --- /dev/null +++ b/apps/desktop/src/components/redis/RedisValueViewer.collectionSearch.spec.ts @@ -0,0 +1,275 @@ +// @vitest-environment happy-dom + +import { createApp, defineComponent, h, nextTick } from "vue"; +import { createI18n } from "vue-i18n"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + redisGetValue: vi.fn(), + redisGetTtl: vi.fn(), + redisSetTtl: vi.fn(), + redisSetExpireAt: vi.fn(), + redisLoadMore: vi.fn(), + toast: vi.fn(), +})); + +vi.mock("@/lib/backend/api", () => ({ + redisGetValue: mocks.redisGetValue, + redisGetTtl: mocks.redisGetTtl, + redisSetTtl: mocks.redisSetTtl, + redisSetExpireAt: mocks.redisSetExpireAt, + redisLoadMore: mocks.redisLoadMore, +})); + +vi.mock("@/composables/useEditorFontFamilyStyle", () => ({ + useEditorFontFamilyStyle: () => ({}), +})); + +vi.mock("@/composables/useToast", () => ({ + useToast: () => ({ toast: mocks.toast }), +})); + +vi.mock("@/lib/common/shikiJsonHighlighter", () => ({ + createShikiJsonHighlighter: vi.fn().mockResolvedValue(() => ""), +})); + +import RedisValueViewer from "./RedisValueViewer.vue"; + +const mountedApps: Array<{ unmount: () => void; host: HTMLElement }> = []; + +function createLocalStorage(): Storage { + const entries = new Map(); + return { + get length() { + return entries.size; + }, + clear() { + entries.clear(); + }, + getItem(key) { + return entries.get(key) ?? null; + }, + key(index) { + return [...entries.keys()][index] ?? null; + }, + removeItem(key) { + entries.delete(key); + }, + setItem(key, value) { + entries.set(key, String(value)); + }, + }; +} + +afterEach(() => { + for (const { unmount, host } of mountedApps.splice(0)) { + unmount(); + host.remove(); + } + vi.unstubAllGlobals(); +}); + +beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("localStorage", createLocalStorage()); +}); + +async function settle() { + await nextTick(); + await Promise.resolve(); + await nextTick(); + await Promise.resolve(); + await nextTick(); +} + +function blob(text: string) { + return { raw_base64: btoa(text), encoding: "utf8" as const }; +} + +function zsetValue() { + return { + key_display: "rank:order", + key_raw: "rank:order", + ttl: -1, + redis_type: "zset", + data: { + kind: "zset" as const, + items: [ + { score: "1", member: blob("2314099896:11842851748888") }, + { score: "2", member: blob("2314101855:22903060973580") }, + ], + total: 100, + scan_cursor: 2, + }, + }; +} + +function setValue() { + return { + key_display: "members", + key_raw: "members", + ttl: -1, + redis_type: "set", + data: { kind: "set" as const, items: [{ member: blob("alpha") }], total: 1, scan_cursor: undefined }, + }; +} + +function listValue() { + return { + key_display: "queue", + key_raw: "queue", + ttl: -1, + redis_type: "list", + data: { kind: "list" as const, items: [{ index: 0, value: blob("first") }], total: 1, scan_cursor: undefined }, + }; +} + +const testI18nMessages = { + en: { + redis: { + searchFields: "Search fields and values", + searchMembers: "Search members", + searchItems: "Search items", + members: "{count} members", + items: "{count} items", + loadedMembers: "{loaded} / {total} members loaded", + loadedItems: "{loaded} / {total} items loaded", + loadMoreKeys: "Load more", + }, + }, +}; + +function mountViewer() { + const host = document.createElement("div"); + document.body.append(host); + const app = createApp( + defineComponent({ + setup() { + return () => h(RedisValueViewer, { connectionId: "connection", db: 0, keyDisplay: "key", keyRaw: "key" }); + }, + }), + ); + app.use(createI18n({ legacy: false, locale: "en", messages: testI18nMessages, missingWarn: false, fallbackWarn: false })); + app.mount(host); + mountedApps.push({ unmount: () => app.unmount(), host }); +} + +function searchInput(): HTMLInputElement | null { + return document.querySelector("input[placeholder='Search members'], input[placeholder='Search items']"); +} + +function loadMoreButton(): HTMLButtonElement { + const buttons = [...document.querySelectorAll("button")]; + return buttons.find((button) => button.textContent?.includes("Load more"))!; +} + +async function typeSearch(query: string) { + const input = searchInput()!; + input.value = query; + input.dispatchEvent(new Event("input", { bubbles: true })); + // The Input wrapper syncs its v-model through a passive watcher, so the query only + // reaches the viewer on the next tick — press Enter after it lands, like real typing. + await nextTick(); + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + await settle(); +} + +describe("Redis collection member search", () => { + it("offers a member search box for zset keys", async () => { + mocks.redisGetValue.mockResolvedValue(zsetValue()); + mountViewer(); + await settle(); + + expect(searchInput()).not.toBeNull(); + }); + + it("offers a search box for set and list keys", async () => { + mocks.redisGetValue.mockResolvedValue(setValue()); + mountViewer(); + await settle(); + expect(searchInput()?.placeholder).toBe("Search members"); + + for (const { unmount, host } of mountedApps.splice(0)) { + unmount(); + host.remove(); + } + + mocks.redisGetValue.mockResolvedValue(listValue()); + mountViewer(); + await settle(); + expect(searchInput()?.placeholder).toBe("Search items"); + }); + + it("filters zset members server-side and keeps the sort direction", async () => { + mocks.redisGetValue.mockResolvedValue(zsetValue()); + mocks.redisLoadMore.mockResolvedValue({ + kind: "zset", + items: [{ score: "2", member: blob("2314101855:22903060973580") }], + scan_cursor: undefined, + }); + mountViewer(); + await settle(); + + await typeSearch("22903060"); + + expect(mocks.redisLoadMore).toHaveBeenCalledWith("connection", 0, "key", "zset", 0, 200, "22903060", "asc"); + expect(document.body.textContent).toContain("2314101855:22903060973580"); + expect(document.body.textContent).not.toContain("2314099896:11842851748888"); + }); + + it("carries the active query into load-more paging", async () => { + mocks.redisGetValue.mockResolvedValue(zsetValue()); + mocks.redisLoadMore.mockResolvedValue({ + kind: "zset", + items: [{ score: "2", member: blob("2314101855:22903060973580") }], + scan_cursor: 200, + }); + mountViewer(); + await settle(); + await typeSearch("22903060"); + + mocks.redisLoadMore.mockClear(); + mocks.redisLoadMore.mockResolvedValue({ kind: "zset", items: [], scan_cursor: undefined }); + loadMoreButton().click(); + await settle(); + + expect(mocks.redisLoadMore).toHaveBeenCalledWith("connection", 0, "key", "zset", 200, 200, "22903060", "asc"); + }); + + it("keeps the query when the zset sort direction is toggled", async () => { + mocks.redisGetValue.mockResolvedValue(zsetValue()); + mocks.redisLoadMore.mockResolvedValue({ + kind: "zset", + items: [{ score: "2", member: blob("2314101855:22903060973580") }], + scan_cursor: undefined, + }); + mountViewer(); + await settle(); + await typeSearch("22903060"); + + mocks.redisLoadMore.mockClear(); + [...document.querySelectorAll("button")].find((button) => button.textContent?.includes("Score"))!.click(); + await settle(); + + // Reloading for the new sort order must re-apply the query instead of silently dropping it. + expect(mocks.redisLoadMore).toHaveBeenCalledWith("connection", 0, "key", "zset", 0, 200, "22903060", "desc"); + expect(searchInput()?.value).toBe("22903060"); + }); + + it("drops the total from the count label while a search is active", async () => { + mocks.redisGetValue.mockResolvedValue(zsetValue()); + mocks.redisLoadMore.mockResolvedValue({ + kind: "zset", + items: [{ score: "2", member: blob("2314101855:22903060973580") }], + scan_cursor: undefined, + }); + mountViewer(); + await settle(); + expect(document.body.textContent).toContain("2 / 100 members loaded"); + + await typeSearch("22903060"); + + expect(document.body.textContent).toContain("1 members"); + expect(document.body.textContent).not.toContain("/ 100 members loaded"); + }); +}); diff --git a/apps/desktop/src/components/redis/RedisValueViewer.vue b/apps/desktop/src/components/redis/RedisValueViewer.vue index ef162fbc9f..54229609db 100644 --- a/apps/desktop/src/components/redis/RedisValueViewer.vue +++ b/apps/desktop/src/components/redis/RedisValueViewer.vue @@ -398,8 +398,8 @@ const valueSearchOpen = ref(false); const valueSearchQuery = ref(""); const valueSearchMatchIndex = ref(0); const valueSearchHasNavigated = ref(false); -const hashSearchQuery = ref(""); -const activeHashSearchQuery = ref(""); +const collectionSearchQuery = ref(""); +const activeCollectionSearchQuery = ref(""); const searchLoading = ref(false); const valueSearchBarRef = ref<{ focusInput: (select?: boolean) => void } | null>(null); type JsonEditorHandle = { openSearch: () => boolean; selectRange?: (from: number, to: number, options?: { focus?: boolean }) => boolean }; @@ -426,7 +426,7 @@ async function toggleZsetSort() { const previousDirection = zsetSortDir.value; zsetSortDir.value = previousDirection === "asc" ? "desc" : "asc"; try { - await load({ notifyParent: false }); + await reloadPreservingCollectionSearch({ notifyParent: false }); } catch (error) { zsetSortDir.value = previousDirection; toast(errorMessage(error), 3000); @@ -764,8 +764,8 @@ const canHighlightContentSearch = computed(() => valueSearchOpen.value && Boolea const canHighlightStringSurface = computed(() => canHighlightContentSearch.value && !showMemberDetail.value); const canHighlightMemberSurface = computed(() => canHighlightContentSearch.value && showMemberDetail.value); -let hashSearchTimer: ReturnType | null = null; -let hashSearchRequestId = 0; +let collectionSearchTimer: ReturnType | null = null; +let collectionSearchRequestId = 0; let hashResizeStartX = 0; let hashResizeStartWidth = 0; let zsetResizeStartX = 0; @@ -774,7 +774,7 @@ let zsetResizeStartWidth = 0; function shouldPauseAutoValueRefresh(): boolean { const loadedPageSize = data.value ? redisValueCollectionItems(data.value).length : 0; const hasExpandedCollectionPage = collectionItems.value.length > loadedPageSize; - return showMemberDetail.value || editingZsetMemberKey.value !== null || showHashFieldTtlDialog.value || valueSearchOpen.value || Boolean(hashSearchQuery.value.trim()) || Boolean(activeHashSearchQuery.value) || searchLoading.value || loadingMore.value || hasExpandedCollectionPage; + return showMemberDetail.value || editingZsetMemberKey.value !== null || showHashFieldTtlDialog.value || valueSearchOpen.value || Boolean(collectionSearchQuery.value.trim()) || Boolean(activeCollectionSearchQuery.value) || searchLoading.value || loadingMore.value || hasExpandedCollectionPage; } type PendingDelete = { kind: "key" } | { kind: "hash"; field: string } | { kind: "list"; index: number } | { kind: "set"; member: string } | { kind: "zset"; member: string }; @@ -1092,40 +1092,55 @@ function collectionCountLabel(kind: "items" | "fields" | "members", loaded: numb return t(`redis.loaded${kind[0].toUpperCase()}${kind.slice(1)}`, { loaded, total }); } -function onHashSearchInput() { - if (hashSearchTimer) clearTimeout(hashSearchTimer); - hashSearchTimer = setTimeout(() => void onHashSearch(), 400); +/** True for the collection kinds whose toolbar offers a server-filtered member search. */ +const collectionSearchSupported = computed(() => redisKind.value === "hash" || redisKind.value === "list" || redisKind.value === "set" || redisKind.value === "zset"); + +function onCollectionSearchInput() { + if (collectionSearchTimer) clearTimeout(collectionSearchTimer); + collectionSearchTimer = setTimeout(() => void onCollectionSearch(), 400); } -function onHashSearchKeydown(event: KeyboardEvent) { +function onCollectionSearchKeydown(event: KeyboardEvent) { if (event.key === "Enter") { - if (hashSearchTimer) clearTimeout(hashSearchTimer); - hashSearchTimer = null; - void onHashSearch(); + if (collectionSearchTimer) clearTimeout(collectionSearchTimer); + collectionSearchTimer = null; + void onCollectionSearch(); return; } if (event.key === "Escape") { - if (hashSearchTimer) clearTimeout(hashSearchTimer); - hashSearchTimer = null; - hashSearchQuery.value = ""; - void onHashSearch(); + if (collectionSearchTimer) clearTimeout(collectionSearchTimer); + collectionSearchTimer = null; + collectionSearchQuery.value = ""; + void onCollectionSearch(); } } -async function onHashSearch() { - const query = hashSearchQuery.value.trim(); - if (redisKind.value !== "hash") return; - const requestId = ++hashSearchRequestId; +async function onCollectionSearch() { + const query = collectionSearchQuery.value.trim(); + const keyType = redisKind.value; + if (!collectionSearchSupported.value) return; + const requestId = ++collectionSearchRequestId; searchLoading.value = true; try { - const result = await api.redisLoadMore(props.connectionId, props.db, props.keyRaw, "hash", 0, 200, query || undefined); - if (requestId !== hashSearchRequestId || result.kind !== "hash") return; - activeHashSearchQuery.value = query; + const sortDirection = keyType === "zset" ? zsetSortDir.value : undefined; + const result = await api.redisLoadMore(props.connectionId, props.db, props.keyRaw, keyType, 0, 200, query || undefined, sortDirection); + if (requestId !== collectionSearchRequestId || result.kind !== keyType) return; + activeCollectionSearchQuery.value = query; collectionItems.value = result.items; scanCursor.value = result.scan_cursor ?? undefined; if (!hasRetainedMemberDraft.value) clearSelectedMember(); } finally { - if (requestId === hashSearchRequestId) searchLoading.value = false; + if (requestId === collectionSearchRequestId) searchLoading.value = false; + } +} + +/** `load()` clears the search box, so anything that reloads mid-search has to re-apply the query. */ +async function reloadPreservingCollectionSearch(options: { notifyParent?: boolean } = {}) { + const query = activeCollectionSearchQuery.value || collectionSearchQuery.value.trim(); + await load({ ...options, selectDefaultMember: false }); + if (query) { + collectionSearchQuery.value = query; + await onCollectionSearch(); } } @@ -1526,11 +1541,11 @@ async function load(options: { background?: boolean; notifyParent?: boolean; pre return false; } - if (hashSearchTimer) clearTimeout(hashSearchTimer); - hashSearchTimer = null; - hashSearchRequestId++; - hashSearchQuery.value = ""; - activeHashSearchQuery.value = ""; + if (collectionSearchTimer) clearTimeout(collectionSearchTimer); + collectionSearchTimer = null; + collectionSearchRequestId++; + collectionSearchQuery.value = ""; + activeCollectionSearchQuery.value = ""; searchLoading.value = false; resetValueSearch(); data.value = loadedValue; @@ -1587,16 +1602,16 @@ async function load(options: { background?: boolean; notifyParent?: boolean; pre } async function loadMore() { - if (!data.value || !hasMore.value || loadingMore.value || (redisKind.value === "hash" && searchLoading.value)) return; - if (!(redisKind.value === "list" || redisKind.value === "set" || redisKind.value === "hash" || redisKind.value === "zset")) return; + if (!data.value || !hasMore.value || loadingMore.value || searchLoading.value) return; + if (!collectionSearchSupported.value) return; const keyType = redisKind.value; - const hashFilter = keyType === "hash" ? activeHashSearchQuery.value || undefined : undefined; - const requestId = hashSearchRequestId; + const filter = activeCollectionSearchQuery.value || undefined; + const requestId = collectionSearchRequestId; loadingMore.value = true; try { const sortDirection = keyType === "zset" ? zsetSortDir.value : undefined; - const result = await api.redisLoadMore(props.connectionId, props.db, props.keyRaw, keyType, scanCursor.value!, 200, hashFilter, sortDirection); - if (keyType === "hash" && requestId !== hashSearchRequestId) return; + const result = await api.redisLoadMore(props.connectionId, props.db, props.keyRaw, keyType, scanCursor.value!, 200, filter, sortDirection); + if (requestId !== collectionSearchRequestId) return; const newItems = redisCollectionPageItems(result); collectionItems.value = [...collectionItems.value, ...newItems]; scanCursor.value = result.scan_cursor ?? undefined; @@ -2402,15 +2417,6 @@ function handleHashFieldTtlOpenChange(open: boolean) { } } -async function reloadHashPreservingSearch() { - const query = activeHashSearchQuery.value || hashSearchQuery.value.trim(); - await load({ selectDefaultMember: false }); - if (query) { - hashSearchQuery.value = query; - await onHashSearch(); - } -} - async function saveHashFieldTtl() { if (savingHashFieldTtl.value || !editingHashField.value) return; const validation = validateRedisExpiry(hashFieldTtlMode.value, hashFieldTtlInput.value, hashFieldExpireAt.value); @@ -2430,7 +2436,7 @@ async function saveHashFieldTtl() { await api.redisHashFieldSetExpireAt(props.connectionId, props.db, props.keyRaw, field, validation.policy.expireAt); } cancelEditHashFieldTtl(true); - await reloadHashPreservingSearch(); + await reloadPreservingCollectionSearch(); } catch (error) { toast(errorMessage(error), 3000); } finally { @@ -2691,7 +2697,7 @@ onBeforeUnmount(() => { stopRefreshTimers(); stopResizeHashColumns(); stopResizeZsetColumns(); - if (hashSearchTimer) clearTimeout(hashSearchTimer); + if (collectionSearchTimer) clearTimeout(collectionSearchTimer); }); defineExpose({ focusSearch }); @@ -2954,7 +2960,11 @@ defineExpose({ focusSearch });
- {{ collectionCountLabel("items", listRows.length, collectionTotal) }} + {{ collectionCountLabel("items", listRows.length, activeCollectionSearchQuery ? null : collectionTotal) }} +
+ + +
@@ -2986,7 +2996,7 @@ defineExpose({ focusSearch });