feat(redis): search list set and zset members

This commit is contained in:
onenewcode authored and GitHub committed 2026-09-03 11:56:46 +08:00
1 parent 2c7a420ef6
commit aa4f503353
17 files changed
+715 -78

No files matched your search

@@ -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<string, string>();
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<HTMLInputElement>("input[placeholder='Search members'], input[placeholder='Search items']");
}
function loadMoreButton(): HTMLButtonElement {
const buttons = [...document.querySelectorAll<HTMLButtonElement>("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<HTMLButtonElement>("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");
});
});
@@ -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<typeof setTimeout> | null = null;
let hashSearchRequestId = 0;
let collectionSearchTimer: ReturnType<typeof setTimeout> | 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 });
<!-- List -->
<div v-else-if="redisKind === 'list'" class="flex-1 flex flex-col overflow-hidden">
<div class="flex items-center gap-2 px-4 py-1.5 border-b shrink-0">
<span class="text-xs text-muted-foreground">{{ collectionCountLabel("items", listRows.length, collectionTotal) }}</span>
<span class="text-xs text-muted-foreground shrink-0">{{ collectionCountLabel("items", listRows.length, activeCollectionSearchQuery ? null : collectionTotal) }}</span>
<div class="relative flex-1 max-w-60">
<Search class="pointer-events-none absolute left-1.5 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground/80" />
<Input v-model="collectionSearchQuery" class="h-6 w-full pl-5 pr-2 text-xs" :placeholder="t('redis.searchItems')" @input="onCollectionSearchInput" @keydown="onCollectionSearchKeydown" />
</div>
<span class="flex-1" />
<Input v-model="newValue" class="h-6 w-40 text-xs" placeholder="value" @keydown.enter="listPush" />
<Button variant="ghost" size="sm" class="h-6 text-xs" @click="listPush"><Plus class="w-3 h-3 mr-1" />Push</Button>
@@ -2986,7 +2996,7 @@ defineExpose({ focusSearch });
</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">
<Button variant="outline" size="sm" class="w-full h-7 text-xs" :disabled="loadingMore || searchLoading" @click="loadMore">
<Loader2 v-if="loadingMore" class="w-3 h-3 mr-1.5 animate-spin" />
{{ t("redis.loadMoreKeys") }}
</Button>
@@ -2998,7 +3008,11 @@ defineExpose({ focusSearch });
<!-- Set -->
<div v-else-if="redisKind === 'set'" class="flex-1 flex flex-col overflow-hidden">
<div class="flex items-center gap-2 px-4 py-1.5 border-b shrink-0">
<span class="text-xs text-muted-foreground">{{ collectionCountLabel("items", setRows.length, collectionTotal) }}</span>
<span class="text-xs text-muted-foreground shrink-0">{{ collectionCountLabel("items", setRows.length, activeCollectionSearchQuery ? null : collectionTotal) }}</span>
<div class="relative flex-1 max-w-60">
<Search class="pointer-events-none absolute left-1.5 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground/80" />
<Input v-model="collectionSearchQuery" class="h-6 w-full pl-5 pr-2 text-xs" :placeholder="t('redis.searchMembers')" @input="onCollectionSearchInput" @keydown="onCollectionSearchKeydown" />
</div>
<span class="flex-1" />
<Input v-model="newValue" class="h-6 w-40 text-xs" placeholder="member" @keydown.enter="setAdd" />
<Button variant="ghost" size="sm" class="h-6 text-xs" @click="setAdd"><Plus class="w-3 h-3 mr-1" />Add</Button>
@@ -3033,7 +3047,7 @@ defineExpose({ focusSearch });
</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">
<Button variant="outline" size="sm" class="w-full h-7 text-xs" :disabled="loadingMore || searchLoading" @click="loadMore">
<Loader2 v-if="loadingMore" class="w-3 h-3 mr-1.5 animate-spin" />
{{ t("redis.loadMoreKeys") }}
</Button>
@@ -3045,10 +3059,10 @@ defineExpose({ focusSearch });
<!-- Hash -->
<div v-else-if="redisKind === 'hash'" ref="hashTableRef" class="flex-1 flex flex-col overflow-hidden">
<div class="flex items-center gap-2 px-4 py-1.5 border-b shrink-0">
<span class="text-xs text-muted-foreground shrink-0">{{ collectionCountLabel("fields", hashCollectionRows.length, activeHashSearchQuery ? null : collectionTotal) }}</span>
<span class="text-xs text-muted-foreground shrink-0">{{ collectionCountLabel("fields", hashCollectionRows.length, activeCollectionSearchQuery ? null : collectionTotal) }}</span>
<div class="relative flex-1 max-w-60">
<Search class="pointer-events-none absolute left-1.5 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground/80" />
<Input v-model="hashSearchQuery" class="h-6 w-full pl-5 pr-2 text-xs" :placeholder="t('redis.searchFields')" @input="onHashSearchInput" @keydown="onHashSearchKeydown" />
<Input v-model="collectionSearchQuery" class="h-6 w-full pl-5 pr-2 text-xs" :placeholder="t('redis.searchFields')" @input="onCollectionSearchInput" @keydown="onCollectionSearchKeydown" />
</div>
<span class="flex-1" />
<Input v-model="newField" class="h-6 w-24 text-xs" placeholder="field" />
@@ -3126,7 +3140,11 @@ defineExpose({ focusSearch });
<!-- Sorted Set -->
<div v-else-if="redisKind === 'zset'" ref="zsetTableRef" class="flex-1 flex flex-col overflow-hidden">
<div class="flex items-center gap-2 px-4 py-1.5 border-b shrink-0">
<span class="text-xs text-muted-foreground">{{ collectionCountLabel("members", zsetRows.length, collectionTotal) }}</span>
<span class="text-xs text-muted-foreground shrink-0">{{ collectionCountLabel("members", zsetRows.length, activeCollectionSearchQuery ? null : collectionTotal) }}</span>
<div class="relative flex-1 max-w-60">
<Search class="pointer-events-none absolute left-1.5 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground/80" />
<Input v-model="collectionSearchQuery" class="h-6 w-full pl-5 pr-2 text-xs" :placeholder="t('redis.searchMembers')" @input="onCollectionSearchInput" @keydown="onCollectionSearchKeydown" />
</div>
<span class="flex-1" />
<Input v-model="newScore" class="h-6 w-20 text-xs" placeholder="score" />
<Input v-model="newValue" class="h-6 w-32 text-xs" placeholder="member" @keydown.enter="zsetAdd" />
@@ -3189,7 +3207,7 @@ defineExpose({ focusSearch });
</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">
<Button variant="outline" size="sm" class="w-full h-7 text-xs" :disabled="loadingMore || searchLoading" @click="loadMore">
<Loader2 v-if="loadingMore" class="w-3 h-3 mr-1.5 animate-spin" />
{{ t("redis.loadMoreKeys") }}
</Button>