feat(redis): add custom key groups

This commit is contained in:
Guoyu Su authored and GitHub committed 2026-09-09 14:44:45 +08:00
1 parent a535cf8f51
commit 1cbb5abf3b
53 files changed
+1951 -13

No files matched your search

@@ -2541,6 +2541,7 @@ watch(
redis_key_separator: config.redis_key_separator ?? ":",
redis_scan_page_size: config.redis_scan_page_size ?? REDIS_SCAN_PAGE_SIZE_DEFAULT,
redis_key_templates: normalizeRedisKeyTemplates(config.redis_key_templates),
redis_key_grouping: config.redis_key_grouping,
etcd_endpoints: config.etcd_endpoints || "",
gbase_server: config.gbase_server || "",
informix_server: config.informix_server || "",
@@ -4038,6 +4039,7 @@ function connectionConfigForSubmit(id: string, generatedName = ""): ConnectionCo
config.redis_scan_page_size = undefined;
config.redis_database_aliases = undefined;
config.redis_key_templates = undefined;
config.redis_key_grouping = undefined;
} else if (config.redis_connection_mode === "sentinel") {
config.redis_sentinel_master = config.redis_sentinel_master?.trim() || "";
config.redis_sentinel_nodes = normalizeRedisSentinelNodes(config.redis_sentinel_nodes || "");
@@ -0,0 +1,40 @@
<script setup lang="ts">
import { ChevronDown, ChevronRight } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { useId } from "vue";
import type { RedisVirtualGroupRow } from "@/lib/redis/redisKeyGroupIndex";
defineProps<{ row: RedisVirtualGroupRow; expanded: boolean; navigation?: boolean; omitted?: boolean; path?: string; stickyDepth?: number }>();
const emit = defineEmits<{ toggle: []; navigate: [] }>();
const { t } = useI18n();
const pathHintId = useId();
</script>
<template>
<div v-if="navigation" class="group/redis-sticky relative flex h-[30px] items-center gap-1 border-b bg-background px-2 text-xs" :style="{ paddingLeft: `${8 + (stickyDepth ?? row.depth) * 10}px` }" :title="path">
<button type="button" class="flex h-6 w-5 shrink-0 items-center justify-center hover:bg-accent/40 focus-visible:outline focus-visible:outline-ring" data-redis-sticky-toggle :aria-label="row.label" :aria-describedby="pathHintId" :aria-expanded="expanded" @click="emit('toggle')">
<component :is="expanded ? ChevronDown : ChevronRight" class="h-3 w-3" />
</button>
<button type="button" class="flex h-full min-w-0 flex-1 items-center gap-1 text-left hover:bg-accent/40 focus-visible:outline focus-visible:outline-ring" data-redis-sticky-navigate :aria-label="row.label" :aria-describedby="pathHintId" :title="path" @click="emit('navigate')">
<span v-if="omitted" aria-hidden="true">…</span>
<span class="truncate">{{ row.label }}</span>
<span class="shrink-0 text-muted-foreground">{{ t("redisGrouping.count", { count: row.count }) }}</span>
</button>
<span :id="pathHintId" role="tooltip" class="pointer-events-none absolute inset-x-0 top-full z-30 hidden break-all rounded border bg-popover p-2 text-popover-foreground shadow-md group-hover/redis-sticky:block group-focus-within/redis-sticky:block">{{ path }}</span>
</div>
<div
v-else
class="flex h-[30px] cursor-pointer items-center gap-1 border-b px-2 text-xs hover:bg-accent/40 focus-visible:outline focus-visible:outline-ring focus-visible:outline-offset-[-2px]"
:style="{ paddingLeft: `${8 + row.depth * 10}px` }"
role="button"
tabindex="0"
:aria-expanded="expanded"
@click="emit('toggle')"
@keydown.enter.prevent="emit('toggle')"
@keydown.space.prevent="emit('toggle')"
>
<component :is="expanded ? ChevronDown : ChevronRight" class="h-3 w-3 shrink-0" />
<span class="truncate">{{ row.label }}</span>
<span class="shrink-0 text-muted-foreground">{{ t("redisGrouping.count", { count: row.count }) }}</span>
</div>
</template>
@@ -0,0 +1,239 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick, shallowRef } from "vue";
import { afterEach, expect, it, vi } from "vitest";
import type { RedisKeyInfo } from "@/lib/backend/api";
import { defaultRedisKeyGrouping } from "@/lib/redis/redisKeyGrouping";
import RedisGroupedKeyList from "./RedisGroupedKeyList.vue";
// Deliberately use the installed RecycleScroller, not a mock that rerenders
// whenever updateVisibleItems is called. Same-ID headers must really rebind.
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string, values?: { count: number }) => (values ? `${key}:${values.count}` : key) }) }));
const cleanups: (() => void)[] = [];
afterEach(() => {
cleanups.splice(0).forEach((cleanup) => cleanup());
vi.restoreAllMocks();
vi.useRealTimers();
});
function mountGroups(customConfig = { ...defaultRedisKeyGrouping(), enabled: true }) {
const height = vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(300);
vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockReturnValue(600);
const keys = shallowRef<RedisKeyInfo[]>([]);
const scope = shallowRef("c:0");
const host = document.createElement("div");
document.body.appendChild(host);
const config = shallowRef(customConfig);
const checked = new Set<string>();
const selected = shallowRef<string | null>(null);
const onSelect = vi.fn();
const onCheck = vi.fn();
const list = shallowRef<InstanceType<typeof RedisGroupedKeyList> | null>(null);
const app = createApp(
defineComponent({
setup: () => () => h(RedisGroupedKeyList, { ref: list, keys: keys.value, config: config.value, scope: scope.value, structureRevision: 0, separator: ":", selected: selected.value, checked, busy: false, metadataEpoch: 0, treeAllowed: true, onSelect, onCheck }),
}),
);
app.mount(host);
let mounted = true;
const unmount = () => {
if (!mounted) return;
mounted = false;
app.unmount();
host.remove();
};
cleanups.push(unmount);
return { host, keys, scope, config, checked, selected, onSelect, onCheck, list, unmount, height };
}
function key(n: number): RedisKeyInfo {
return { key_raw: btoa(`key:${n}`), key_display: `key:${n}`, ttl: -1, key_type: "string" };
}
async function flushPublication() {
if (!vi.isFakeTimers()) await new Promise((resolve) => setTimeout(resolve, 20));
for (let i = 0; i < 12; i++) await nextTick();
}
it("automatically rebinds same-ID group counts through the real scroller", async () => {
const { host, keys } = mountGroups();
keys.value = [key(0)];
await flushPublication();
expect(host.textContent).toContain("redisGrouping.count:1");
const header = host.querySelector('[role="button"]');
keys.value = [...keys.value, key(1), key(2)];
await flushPublication();
expect(host.textContent).toContain("redisGrouping.count:3");
expect(host.textContent).not.toContain("redisGrouping.preparing");
expect(host.querySelector('[role="button"]')).toBe(header);
});
it("finishes without interaction when timers are delayed instead of paying a timer per checkpoint", async () => {
vi.useFakeTimers();
let workTime = 0;
vi.spyOn(performance, "now").mockImplementation(() => workTime++);
const timer = globalThis.setTimeout;
vi.spyOn(globalThis, "setTimeout").mockImplementation(((callback, delay, ...args) => timer(callback, Math.max(1000, delay ?? 0), ...args)) as typeof setTimeout);
const { host, keys } = mountGroups();
keys.value = [key(0)];
await flushPublication();
expect(host.textContent).toContain("redisGrouping.count:1");
keys.value = Array.from({ length: 10_000 }, (_, n) => key(n));
await flushPublication();
expect(host.textContent).toContain("redisGrouping.preparing");
// 20 chunks, but only two exhausted 8ms work budgets.
await vi.advanceTimersByTimeAsync(3000);
await flushPublication();
expect(host.textContent).toContain("redisGrouping.count:10000");
expect(host.textContent).not.toContain("redisGrouping.preparing");
});
it("does not publish a delayed build after changing database scope", async () => {
vi.useFakeTimers();
let workTime = 0;
vi.spyOn(performance, "now").mockImplementation(() => workTime++);
const { host, keys, scope } = mountGroups();
keys.value = Array.from({ length: 10_000 }, (_, n) => key(n));
await flushPublication();
expect(host.textContent).toContain("redisGrouping.preparing");
scope.value = "c:1";
keys.value = [key(20_000), key(20_001)];
await flushPublication();
await vi.advanceTimersByTimeAsync(1000);
await flushPublication();
expect(host.textContent).toContain("redisGrouping.count:2");
expect(host.textContent).not.toContain("redisGrouping.count:10000");
expect(host.textContent).not.toContain("redisGrouping.preparing");
});
it("cancels a delayed build on unmount without trying to refresh its scroller", async () => {
vi.useFakeTimers();
let workTime = 0;
vi.spyOn(performance, "now").mockImplementation(() => workTime++);
const { host, keys, unmount } = mountGroups();
keys.value = Array.from({ length: 10_000 }, (_, n) => key(n));
await flushPublication();
expect(host.textContent).toContain("redisGrouping.preparing");
unmount();
await vi.advanceTimersByTimeAsync(1000);
expect(vi.getTimerCount()).toBe(0);
expect(host.childNodes).toHaveLength(0);
});
it.each(["list", "tree"] as const)("pins the allowed ancestors in %s and collapses to a reachable header without changing selection", async (inner_view) => {
const state = mountGroups({ ...defaultRedisKeyGrouping(), enabled: true, inner_view });
state.keys.value = Array.from({ length: 200 }, (_, n) => key(n));
await flushPublication();
const outer = () => state.host.querySelector<HTMLElement>('[data-redis-group-header="custom:unmatched"]')!;
expect(state.host.querySelector("[data-redis-sticky-group]")).toBeNull();
expect(outer()).not.toBeNull();
outer().click();
await flushPublication();
if (inner_view === "tree") {
state.host.querySelector<HTMLElement>('[data-redis-group-header*="namespace"]')!.click();
await flushPublication();
}
state.checked.add(key(20).key_raw);
state.selected.value = key(20).key_raw;
await nextTick();
const element = state.host.querySelector<HTMLElement>(".redis-key-scroller")!;
element.scrollTop = 1500;
element.dispatchEvent(new Event("scroll"));
await nextTick();
const pinned = state.host.querySelector<HTMLElement>("[data-redis-sticky-group]")!;
expect(pinned.dataset.redisStickyGroup).toBe("custom:unmatched");
expect(pinned.textContent).toContain("redisGrouping.count:200");
expect((inner_view === "tree" ? pinned.querySelector("[data-redis-sticky-toggle]")! : pinned).getAttribute("aria-expanded")).toBe("true");
expect(state.host.querySelectorAll("[data-redis-sticky-group]")).toHaveLength(inner_view === "tree" ? 2 : 1);
// The outgoing legacy-view anchor must identify a visible descendant, not
// the row hidden beneath the pinned header. Tree adds its namespace row.
expect(state.list.value!.getViewportAnchor()).toEqual({ rowIndex: inner_view === "tree" ? 52 : 51, keyRaw: key(50).key_raw });
if (inner_view === "tree") pinned.querySelector<HTMLButtonElement>("[data-redis-sticky-toggle]")!.click();
else pinned.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
await flushPublication();
expect(element.scrollTop).toBe(0);
expect(outer().getAttribute("aria-expanded")).toBe("false");
expect(document.activeElement).toBe(outer());
expect(state.host.querySelector("[data-redis-sticky-group]")).toBeNull();
expect(state.checked.has(key(20).key_raw)).toBe(true);
expect(state.selected.value).toBe(key(20).key_raw);
expect(state.onSelect).not.toHaveBeenCalled();
expect(state.onCheck).not.toHaveBeenCalled();
});
it("pushes off at the next group, switches its identity and clears stale headers on scope replacement", async () => {
const state = mountGroups({ ...defaultRedisKeyGrouping(), enabled: true, rules: [{ id: "first", name: "First", enabled: true, includes: ["key:?"], excludes: [] }] });
state.keys.value = Array.from({ length: 200 }, (_, n) => key(n));
await flushPublication();
Array.from(state.host.querySelectorAll<HTMLElement>("[data-redis-group-header]"))
.find((node) => node.dataset.redisGroupHeader === 'custom:"first"')!
.click();
await flushPublication();
state.host.querySelector<HTMLElement>('[data-redis-group-header="custom:unmatched"]')!.click();
await flushPublication();
const element = state.host.querySelector<HTMLElement>(".redis-key-scroller")!;
// First has 10 keys + its header; next boundary is at 11 * 30.
element.scrollTop = 11 * 30 - 15;
element.dispatchEvent(new Event("scroll"));
await nextTick();
let pinned = state.host.querySelector<HTMLElement>("[data-redis-sticky-group]")!;
expect(pinned).not.toBeNull();
expect(pinned.dataset.redisStickyGroup).toBe('custom:"first"');
expect(pinned.parentElement!.style.transform).toBe("translateY(-15px)");
// During push-off only 15px is covered; the next natural header is visible.
expect(state.list.value!.getViewportAnchor()).toEqual({ rowIndex: 11, keyRaw: undefined });
element.scrollTop = 12 * 30;
element.dispatchEvent(new Event("scroll"));
await nextTick();
pinned = state.host.querySelector<HTMLElement>("[data-redis-sticky-group]")!;
expect(pinned.dataset.redisStickyGroup).toBe("custom:unmatched");
state.scope.value = "c:1";
state.keys.value = [];
await flushPublication();
expect(state.host.querySelector("[data-redis-sticky-group]")).toBeNull();
});
it("compresses deep ancestors, navigates without collapsing and restores an inner collapse through the installed scroller", async () => {
const state = mountGroups({ ...defaultRedisKeyGrouping(), enabled: true, inner_view: "tree" });
const names = Array.from({ length: 200 }, (_, n) => `a:b:c:d:${n}`);
state.keys.value = names.map((name) => ({ key_display: name, key_raw: btoa(name), ttl: -1, key_type: "string" }));
await flushPublication();
const natural = (id: string) => Array.from(state.host.querySelectorAll<HTMLElement>("[data-redis-group-header]")).find((node) => node.dataset.redisGroupHeader === id)!;
const namespace = (parts: string[]) => `custom:unmatched:namespace:${JSON.stringify(parts)}`;
for (const id of ["custom:unmatched", namespace(["a"]), namespace(["a", "b"]), namespace(["a", "b", "c"]), namespace(["a", "b", "c", "d"])]) {
natural(id).click();
await flushPublication();
}
const element = state.host.querySelector<HTMLElement>(".redis-key-scroller")!;
const scroll = async (top: number) => {
element.scrollTop = top;
element.dispatchEvent(new Event("scroll"));
await nextTick();
};
const pinned = () => Array.from(state.host.querySelectorAll<HTMLElement>("[data-redis-sticky-group]"));
expect(natural(namespace(["a", "b", "c", "d"])).style.paddingLeft).toBe("48px");
await scroll(1500);
expect(pinned().map((node) => node.dataset.redisStickyGroup)).toEqual(["custom:unmatched", namespace(["a", "b", "c"]), namespace(["a", "b", "c", "d"])]);
expect(pinned().map((node) => node.style.paddingLeft)).toEqual(["8px", "18px", "28px"]);
expect(pinned()[1]!.textContent).toContain("…");
expect(pinned()[1]!.querySelector("[data-redis-sticky-navigate]")!.getAttribute("aria-label")).toBe("c");
expect(pinned()[1]!.querySelector('[role="tooltip"]')!.textContent).toContain("a › b › c › d");
expect(pinned()[1]!.querySelectorAll("button button")).toHaveLength(0);
expect(state.list.value!.getViewportAnchor()).toEqual({ rowIndex: 53, keyRaw: btoa(names[48]!) });
state.checked.add(btoa(names[48]!));
state.selected.value = btoa(names[48]!);
pinned()[1]!.querySelector<HTMLButtonElement>("[data-redis-sticky-navigate]")!.click();
await flushPublication();
expect(natural(namespace(["a", "b", "c"])).getAttribute("aria-expanded")).toBe("true");
expect(document.activeElement).toBe(natural(namespace(["a", "b", "c"])));
await scroll(1500);
pinned()[1]!.querySelector<HTMLButtonElement>("[data-redis-sticky-toggle]")!.click();
await flushPublication();
expect(natural(namespace(["a", "b", "c"])).getAttribute("aria-expanded")).toBe("false");
expect(pinned().some((node) => node.dataset.redisStickyGroup === namespace(["a", "b", "c", "d"]))).toBe(false);
expect(document.activeElement).toBe(natural(namespace(["a", "b", "c"])));
expect(state.checked).toEqual(new Set([btoa(names[48]!)]));
expect(state.selected.value).toBe(btoa(names[48]!));
expect(state.onCheck).not.toHaveBeenCalled();
expect(state.onSelect).not.toHaveBeenCalled();
});
@@ -0,0 +1,98 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick, ref } from "vue";
import { afterEach, expect, it, vi } from "vitest";
import { defaultRedisKeyGrouping } from "@/lib/redis/redisKeyGrouping";
import RedisGroupedKeyList from "./RedisGroupedKeyList.vue";
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
const refresh = vi.fn();
const scroll = vi.fn();
let bound: unknown;
vi.mock("vue-virtual-scroller", () => ({
RecycleScroller: defineComponent({
props: ["items"],
setup(props, { slots, expose }) {
bound = props.items;
const epoch = ref(0);
expose({
findItemIndex: () => 0,
getScroll: () => ({ start: 0 }),
scrollToItem: scroll,
updateVisibleItems: (changed: boolean) => {
refresh(changed);
epoch.value++;
},
});
return () => {
void epoch.value;
return h(
"div",
Array.from(props.items as unknown[]).map((item) => slots.default?.({ item })),
);
};
},
}),
}));
afterEach(() => vi.clearAllMocks());
it("does not build a fuzzy tree over the existing limit or silently switch views", async () => {
const config = { ...defaultRedisKeyGrouping(), enabled: true, inner_view: "tree" as const };
const requestList = vi.fn();
const host = document.createElement("div");
document.body.appendChild(host);
const app = createApp(RedisGroupedKeyList, { keys: [], config, scope: "c:0", structureRevision: 0, separator: ":", selected: null, checked: new Set(), busy: false, metadataEpoch: 0, treeAllowed: false, onRequestList: requestList });
app.mount(host);
await nextTick();
expect(host.querySelector(".redis-key-scroller")).toBeNull();
expect(host.textContent).toContain("redisGrouping.fuzzyTreeUnavailable");
expect(requestList).not.toHaveBeenCalled();
expect(config.inner_view).toBe("tree");
host.querySelector<HTMLButtonElement>("button")!.click();
expect(requestList).toHaveBeenCalledOnce();
app.unmount();
host.remove();
});
it("headers have no check/delete controls and expanding is local", async () => {
const config = { ...defaultRedisKeyGrouping(), enabled: true };
const deleted = vi.fn();
const scrolled = vi.fn();
const resized = vi.fn();
const host = document.createElement("div");
document.body.appendChild(host);
const app = createApp(RedisGroupedKeyList, {
keys: [{ key_raw: btoa("a"), key_display: "a", ttl: -1, key_type: "string" }],
config,
scope: "c:0",
structureRevision: 0,
separator: ":",
selected: null,
checked: new Set(),
busy: false,
metadataEpoch: 0,
onDelete: deleted,
onScroll: scrolled,
onResize: resized,
});
app.mount(host);
await new Promise((resolve) => setTimeout(resolve, 0));
await nextTick();
const original = bound;
expect(host.querySelectorAll('input[type="checkbox"]')).toHaveLength(0);
expect(host.querySelectorAll("button")).toHaveLength(0);
host.querySelector<HTMLElement>('[role="button"]')!.click();
await new Promise((resolve) => setTimeout(resolve, 0));
await nextTick();
expect(bound).toBe(original);
expect(refresh).toHaveBeenCalledWith(true);
expect(host.querySelectorAll('input[type="checkbox"]')).toHaveLength(1);
expect(deleted).not.toHaveBeenCalled();
const element = host.querySelector<HTMLElement>(".redis-key-scroller")!;
element.dispatchEvent(new Event("scroll"));
element.dispatchEvent(new Event("resize"));
expect(scrolled).toHaveBeenCalledOnce();
expect(scrolled.mock.calls[0]![0].target).toBe(element);
expect(resized).toHaveBeenCalledOnce();
app.unmount();
host.remove();
});
@@ -0,0 +1,246 @@
<script setup lang="ts">
import { computed, markRaw, nextTick, onActivated, onDeactivated, onUnmounted, ref, shallowRef, watch } from "vue";
import { RecycleScroller } from "vue-virtual-scroller";
import { KeyRound, Trash2, Copy } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import CustomContextMenu from "@/components/ui/CustomContextMenu.vue";
import type { RedisKeyInfo } from "@/lib/backend/api";
import type { RedisKeyGrouping } from "@/lib/redis/redisKeyGrouping";
import { RedisKeyGroupIndex, type RedisGroupedRow, type RedisOuterGroupBoundary, type RedisHeaderBoundary } from "@/lib/redis/redisKeyGroupIndex";
import { redisStickyHeaders, redisStickyHeight, REDIS_GROUP_ROW_HEIGHT } from "@/lib/redis/redisStickyHeaders";
import RedisGroupHeader from "./RedisGroupHeader.vue";
import { createRedisKeyViewYield } from "@/lib/redis/redisKeyViewScheduler";
const props = defineProps<{ keys: readonly RedisKeyInfo[]; config: RedisKeyGrouping; separator: string; scope: string; structureRevision: number; selected: string | null; checked: ReadonlySet<string>; busy: boolean; metadataEpoch: number; treeAllowed?: boolean }>();
const emit = defineEmits<{ select: [RedisKeyInfo]; check: [RedisKeyInfo, MouseEvent]; delete: [RedisKeyInfo, MouseEvent]; copy: [RedisKeyInfo]; scroll: [Event]; resize: []; requestList: [] }>();
const { t } = useI18n();
const expanded = shallowRef(new Set<string>());
const working = ref(false);
const active = ref(true);
const scroller = ref<InstanceType<typeof RecycleScroller> | null>(null);
const outerGroups = shallowRef<readonly RedisOuterGroupBoundary[]>([]);
const headerBoundaries = shallowRef<readonly RedisHeaderBoundary[]>([]);
const publishedTree = ref(false);
const publishedExpanded = shallowRef<ReadonlySet<string>>(new Set());
const scrollTop = ref(0);
const viewportHeight = ref(0);
const rowHeight = REDIS_GROUP_ROW_HEIGHT;
let positions: { get: (id: string) => number | undefined } = { get: () => undefined };
let pendingHeaderAnchor: string | undefined;
const sticky = computed(() => {
if (!active.value || treeBlocked.value) return [];
return redisStickyHeaders(outerGroups.value, headerBoundaries.value, scrollTop.value, viewportHeight.value, publishedTree.value);
});
const stickyIds = computed(() => new Set(sticky.value.map((entry) => entry.row.id)));
function updateScroll() {
scrollTop.value = Math.max(0, scroller.value?.getScroll().start ?? 0);
viewportHeight.value = (scroller.value?.$el as HTMLElement | undefined)?.clientHeight ?? 0;
}
function onScroll(event: Event) {
updateScroll();
emit("scroll", event);
}
function onResize() {
const anchor = firstUnobscuredRowIndex();
updateScroll();
positionRow(anchor);
emit("resize");
}
function collapseSticky(id: string) {
pendingHeaderAnchor = id;
toggle(id);
}
function positionRow(position: number) {
// Solve using the destination overlay, not the previous branch's height.
// Fixed rows and at most three sticky slots bound this to four lookups.
let target = position * rowHeight;
for (let step = 0; step <= 3; step++) {
target = Math.max(0, (position - step) * rowHeight);
const occupied = redisStickyHeight(redisStickyHeaders(outerGroups.value, headerBoundaries.value, target, viewportHeight.value, publishedTree.value));
if (target + occupied <= position * rowHeight) break;
}
const api = scroller.value;
api?.scrollToItem(Math.floor(target / rowHeight), { align: "start" });
api?.updateVisibleItems(true);
updateScroll();
}
function focusHeader(id: string) {
const element = scroller.value?.$el as HTMLElement | undefined;
const header = Array.from(element?.querySelectorAll<HTMLElement>("[data-redis-group-header]") ?? []).find((node) => node.dataset.redisGroupHeader === id);
header?.focus({ preventScroll: true });
}
async function navigateHeader(id: string) {
const position = positions.get(id);
if (position === undefined) return;
positionRow(position);
await nextTick();
focusHeader(id);
}
function revealFocusedRow(event: FocusEvent) {
const target = event.target as HTMLElement;
const element = scroller.value?.$el as HTMLElement | undefined;
if (!element || !sticky.value.length) return;
const overlap = element.getBoundingClientRect().top + redisStickyHeight(sticky.value) - target.getBoundingClientRect().top;
if (overlap > 0) {
element.scrollTop = Math.max(0, element.scrollTop - overlap);
updateScroll();
}
}
const treeBlocked = computed(() => props.treeAllowed === false && props.config.inner_view === "tree");
function firstUnobscuredRowIndex() {
const api = scroller.value;
return api?.findItemIndex((api.getScroll().start ?? 0) + redisStickyHeight(sticky.value)) ?? 0;
}
defineExpose({
getScrollElement: () => scroller.value?.$el as HTMLElement | undefined,
getViewportAnchor: () => {
const rowIndex = firstUnobscuredRowIndex();
const row = source[rowIndex];
return { rowIndex, keyRaw: row?.kind === "key" ? row.key.key_raw : undefined };
},
});
let source: readonly RedisGroupedRow[] = [];
const rows = markRaw(
new Proxy([] as RedisGroupedRow[], {
get(target, property, receiver) {
if (property === "length") return source.length;
const index = typeof property === "string" && /^(0|[1-9]\d*)$/.test(property) ? Number(property) : -1;
return index >= 0 ? source[index] : Reflect.get(target, property, receiver);
},
has(target, property) {
const index = typeof property === "string" && /^(0|[1-9]\d*)$/.test(property) ? Number(property) : -1;
return index >= 0 ? index < source.length : Reflect.has(target, property);
},
}),
);
let generation = 0;
let index: RedisKeyGroupIndex | null = null;
let signature = "";
let previousKeys: readonly RedisKeyInfo[] = [];
let previousScope = props.scope;
watch(
[() => props.keys, () => props.config, () => props.scope, () => props.separator, () => props.structureRevision, expanded, active, treeBlocked],
async () => {
const currentGeneration = ++generation;
if (props.scope !== previousScope || !active.value || treeBlocked.value) pendingHeaderAnchor = undefined;
previousScope = props.scope;
if (!active.value) return;
if (treeBlocked.value) {
working.value = false;
return;
}
const current = () => currentGeneration === generation;
const nextSignature = JSON.stringify([props.scope, props.separator, props.structureRevision, props.config.rules]);
// A same-length structural replacement can rename a key anywhere, not just
// at the tail. Reclassify it; ordinary append keeps the membership index.
if (!index || nextSignature !== signature || !index.canAppend(props.keys) || (previousKeys !== props.keys && previousKeys.length === props.keys.length)) {
index = new RedisKeyGroupIndex(props.config.rules, t("redisGrouping.unmatched"));
signature = nextSignature;
}
previousKeys = props.keys;
const building = index;
working.value = true;
// Cancellation checkpoints are cheap; only yield a browser task when this
// build has exhausted its time budget. Nested timers can be throttled.
const yieldControl = createRedisKeyViewYield();
if (!(await building.append(props.keys, { current, yield: yieldControl }))) return;
const snapshot = await building.project(props.config.inner_view, props.separator, expanded.value, { current, yield: yieldControl });
if (!snapshot || !current()) return;
const oldPosition = firstUnobscuredRowIndex();
const headerAnchor = pendingHeaderAnchor;
const anchorId = headerAnchor ?? source[oldPosition]?.id;
const position = anchorId ? (snapshot.positions.get(anchorId) ?? Math.min(oldPosition, Math.max(0, snapshot.rows.length - 1))) : 0;
source = snapshot.rows;
outerGroups.value = snapshot.outerGroups;
headerBoundaries.value = snapshot.headerBoundaries;
positions = snapshot.positions;
publishedTree.value = props.config.inner_view === "tree";
publishedExpanded.value = new Set(expanded.value);
pendingHeaderAnchor = undefined;
positionRow(position);
await nextTick();
if (!current()) return;
positionRow(position);
if (headerAnchor) {
await nextTick();
if (!current()) return;
focusHeader(headerAnchor);
}
working.value = false;
},
{ immediate: true },
);
function toggle(id: string) {
const next = new Set(expanded.value);
if (!next.delete(id)) next.add(id);
expanded.value = next;
}
onUnmounted(() => {
generation++;
});
onActivated(() => {
active.value = true;
});
onDeactivated(() => {
active.value = false;
generation++;
});
</script>
<template>
<div class="flex min-h-0 flex-1 flex-col" :data-metadata-epoch="metadataEpoch">
<div v-if="treeBlocked" role="status" class="space-y-2 p-3 text-xs text-muted-foreground">
<p>{{ t("redisGrouping.fuzzyTreeUnavailable") }}</p>
<Button variant="outline" size="sm" @click="emit('requestList')">{{ t("redisGrouping.useList") }}</Button>
</div>
<template v-else>
<p class="shrink-0 px-2 py-1 text-xs text-muted-foreground">{{ working ? t("redisGrouping.preparing") : t("redisGrouping.loadedOnly") }}</p>
<div class="relative flex min-h-0 flex-1 flex-col overflow-hidden" data-redis-group-viewport>
<RecycleScroller ref="scroller" class="redis-key-scroller min-h-0 flex-1" :items="rows" :item-size="rowHeight" :buffer="600" key-field="id" @scroll="onScroll" @resize="onResize" @focusin="revealFocusedRow">
<template #default="{ item }">
<RedisGroupHeader
v-if="item.kind === 'virtual-group'"
:row="item"
:expanded="publishedExpanded.has(item.id)"
:data-redis-group-header="item.id"
:class="stickyIds.has(item.id) ? 'invisible' : ''"
:tabindex="stickyIds.has(item.id) ? -1 : 0"
:aria-hidden="stickyIds.has(item.id) ? true : undefined"
@toggle="toggle(item.id)"
/>
<CustomContextMenu v-else :items="[{ label: t('redis.copyKeyName'), icon: Copy, action: () => emit('copy', item.key) }]" v-slot="{ onContextMenu }">
<div
class="group flex h-[30px] cursor-pointer items-center gap-1 border-b px-2 text-[13px] hover:bg-accent/40"
:class="selected === item.key.key_raw ? 'bg-accent' : checked.has(item.key.key_raw) ? 'bg-primary/10' : ''"
:style="{ paddingLeft: `${8 + item.depth * 10}px` }"
@click="emit('select', item.key)"
@contextmenu="onContextMenu"
>
<input type="checkbox" :checked="checked.has(item.key.key_raw)" :disabled="busy" :aria-label="item.key.key_display" @click.stop="emit('check', item.key, $event)" />
<KeyRound class="h-3 w-3 shrink-0 text-muted-foreground" /><span class="dbx-editor-font-family min-w-0 flex-1 truncate" :title="item.key.key_display">{{ item.label || t("redisGrouping.emptyKey") }}</span>
<span class="text-xs text-muted-foreground">{{ item.key.key_type }}</span>
<Button variant="ghost" size="icon" class="h-5 w-5 text-destructive opacity-0 group-hover:opacity-100" :disabled="busy" :aria-label="t('redis.deleteKey')" @click.stop="emit('delete', item.key, $event)"><Trash2 class="h-3 w-3" /></Button>
</div>
</CustomContextMenu>
</template>
</RecycleScroller>
<div v-for="(entry, slot) in sticky" :key="entry.row.id" class="absolute inset-x-0 top-0 bg-background" :style="{ transform: `translateY(${entry.top}px)`, zIndex: 20 - slot }">
<RedisGroupHeader
:row="entry.row"
:expanded="publishedExpanded.has(entry.row.id)"
:navigation="publishedTree"
:sticky-depth="slot"
:omitted="entry.omitted"
:path="entry.path"
:data-redis-sticky-group="entry.row.id"
@toggle="collapseSticky(entry.row.id)"
@navigate="navigateHeader(entry.row.id)"
/>
</div>
</div>
</template>
</div>
</template>
@@ -0,0 +1,11 @@
import source from "./RedisKeyBrowser.vue?raw";
import { expect, it } from "vitest";
it("uses the fuzzy toggle visual states while preserving grouping persistence and busy guards", () => {
const button = source.match(/<Button\b[^>]*data-redis-grouping-toggle[^>]*>/s)?.[0];
expect(button).toBeDefined();
expect(button).toContain("customGrouping.enabled ? 'bg-accent text-accent-foreground' : 'border border-dashed border-border/70 text-muted-foreground hover:text-foreground'");
expect(button).toContain(':aria-pressed="customGrouping.enabled"');
expect(button).toContain(':disabled="groupingSaving || isFetchingAll"');
expect(button).toContain("saveGrouping({ ...customGrouping, enabled: !customGrouping.enabled })");
});
@@ -4,8 +4,14 @@ import { createApp, defineComponent, h, KeepAlive, nextTick, ref } from "vue";
import { createI18n } from "vue-i18n";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { RedisKeyInfo } from "@/lib/backend/api";
import { defaultRedisKeyGrouping, type RedisKeyGrouping } from "@/lib/redis/redisKeyGrouping";
const grouping = ref<RedisKeyGrouping>();
vi.mock("@/lib/redis/redisKeyViewScheduler", () => ({ createRedisKeyViewYield: () => () => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())) }));
const mocks = vi.hoisted(() => ({
scrollerInitialSnapshot: vi.fn(),
scrollerRefresh: vi.fn(),
ensureConnected: vi.fn(),
redisScanKeysBatch: vi.fn(),
redisScanValues: vi.fn(),
@@ -57,7 +63,10 @@ vi.mock("@/lib/backend/api", () => ({
vi.mock("@/stores/connectionStore", () => ({
useConnectionStore: () => ({
ensureConnected: mocks.ensureConnected,
getConfig: () => ({ name: "Redis", redis_key_separator: ":", redis_scan_page_size: mocks.redisScanPageSize }),
getConfig: () => ({ name: "Redis", redis_key_separator: ":", redis_scan_page_size: mocks.redisScanPageSize, redis_key_grouping: grouping.value }),
updateRedisKeyGrouping: async (_id: string, config: RedisKeyGrouping) => {
grouping.value = config;
},
updateRedisDbKeyStats: mocks.updateRedisDbKeyStats,
listRedisCompletionCommandDocs: mocks.listRedisCompletionCommandDocs,
listRedisCompletionKeys: mocks.listRedisCompletionKeys,
@@ -238,17 +247,32 @@ vi.mock("vue-virtual-scroller", async () => {
RecycleScroller: defineComponent({
inheritAttrs: false,
props: { items: { type: Array, default: () => [] } },
setup(props, { attrs, slots }) {
setup(props, { attrs, slots, expose }) {
// The installed library copies/keys all initial items in setup, even
// though its rendered pool only contains the visible viewport.
mocks.scrollerInitialSnapshot(props.items.slice().length);
const epoch = ref(0);
expose({
findItemIndex: () => 0,
getScroll: () => ({ start: 0 }),
scrollToItem: () => {},
updateVisibleItems: () => {
mocks.scrollerRefresh(props.items.length);
epoch.value++;
},
});
// Real RecycleScroller only mounts as many rows as fit the viewport;
// rendering everything here would defeat the point of this test, so
// cap it the same way the expiry spec's mock does.
const visibleItemCount = 50;
return () =>
h(
return () => {
void epoch.value;
return h(
"div",
attrs,
props.items.slice(0, visibleItemCount).map((item) => slots.default?.({ item })),
);
};
},
}),
};
@@ -378,6 +402,7 @@ function restoreViewportStub() {
}
beforeEach(() => {
grouping.value = undefined;
resetApiMocks();
});
@@ -390,6 +415,124 @@ afterEach(() => {
});
describe("RedisKeyBrowser infinite scroll auto-continue (issue #6022)", () => {
it.each(["mount", "refresh"])("cancels legacy publication when grouping is re-enabled during %s", async (phase) => {
grouping.value = { ...defaultRedisKeyGrouping(), enabled: true };
mocks.redisScanKeysBatch.mockResolvedValueOnce({ cursor: 0, keys: [{ key_display: "a", key_raw: btoa("a"), key_type: "string", ttl: -1 }], total_keys: 1 });
const host = mountBrowser();
await settleThoroughly();
const reenable = () => {
grouping.value = { ...grouping.value!, enabled: true };
};
if (phase === "mount") mocks.scrollerInitialSnapshot.mockImplementationOnce(reenable);
else mocks.scrollerRefresh.mockImplementationOnce(reenable);
grouping.value = { ...grouping.value!, enabled: false };
await settleThoroughly();
expect(grouping.value.enabled).toBe(true);
expect(host.querySelector('[role="button"]')).not.toBeNull();
expect(host.textContent).not.toContain("redisGrouping.preparingLegacy");
expect(mocks.redisScanKeysBatch).toHaveBeenCalledTimes(1);
});
it("does not publish a prepared old scope after the database changes or keys are cleared", async () => {
grouping.value = { ...defaultRedisKeyGrouping(), enabled: true };
const keys = Array.from({ length: 1100 }, (_, i) => ({ key_display: `old:${i}`, key_raw: btoa(`old:${i}`), key_type: "string", ttl: -1 }));
mocks.redisScanKeysBatch.mockResolvedValueOnce({ cursor: 0, keys, total_keys: keys.length });
const db = ref(0);
const host = document.createElement("div");
document.body.append(host);
const app = createApp(defineComponent({ setup: () => () => h(RedisKeyBrowser, { connectionId: "connection", db: db.value, blockDangerousRedisCommands: false }) }));
app.use(createI18n({ legacy: false, locale: "en", messages: { en: {} }, missingWarn: false, fallbackWarn: false }));
app.mount(host);
mountedApps.push({ unmount: () => app.unmount(), host });
await new Promise((resolve) => setTimeout(resolve, 20));
await settleThoroughly();
const frames: FrameRequestCallback[] = [];
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
frames.push(callback);
return frames.length;
});
try {
grouping.value = { ...grouping.value!, enabled: false };
await settle();
expect(frames.length).toBeGreaterThan(0);
mocks.redisScanKeysBatch.mockResolvedValueOnce({ cursor: 0, keys: [{ key_display: "new", key_raw: btoa("new"), key_type: "string", ttl: -1 }], total_keys: 1 });
db.value = 1;
await settleThoroughly();
for (let i = 0; i < 30; i++) {
for (const callback of frames.splice(0)) callback(i);
await settle();
}
expect(host.querySelector(`[data-redis-leaf='${btoa("new")}']`)).not.toBeNull();
expect(host.textContent).not.toContain("old");
window.dispatchEvent(new CustomEvent("dbx-redis-db-flushed", { detail: { connectionId: "connection", db: 1 } }));
await settleThoroughly();
expect(host.querySelector(`[data-redis-leaf='${btoa("new")}']`)).toBeNull();
} finally {
vi.unstubAllGlobals();
}
});
it("keeps custom rows during cooperative legacy preparation, cancels and retries", async () => {
grouping.value = { ...defaultRedisKeyGrouping(), enabled: true };
const keys = Array.from({ length: 1100 }, (_, i) => ({ key_display: `namespace:${i}`, key_raw: btoa(`namespace:${i}`), key_type: "string", ttl: -1 }));
mocks.redisScanKeysBatch.mockResolvedValueOnce({ cursor: 0, keys, total_keys: keys.length });
const host = mountBrowser();
await settleThoroughly();
await new Promise((resolve) => setTimeout(resolve, 20));
await settleThoroughly();
expect(host.querySelector('[role="button"]')).not.toBeNull();
const frames: FrameRequestCallback[] = [];
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
frames.push(callback);
return frames.length;
});
try {
grouping.value = { ...grouping.value!, enabled: false };
await settle();
expect(frames.length).toBeGreaterThan(0);
expect(host.textContent).toContain("redisGrouping.preparingLegacy");
expect(host.querySelector('[role="button"]')).not.toBeNull();
grouping.value = { ...grouping.value!, enabled: true };
for (const callback of frames.splice(0)) callback(0);
await settle();
expect(host.textContent).not.toContain("redisGrouping.preparingLegacy");
expect(host.querySelector('[role="button"]')).not.toBeNull();
mocks.scrollerInitialSnapshot.mockClear();
grouping.value = { ...grouping.value!, enabled: false };
for (let i = 0; i < 30; i++) {
for (const callback of frames.splice(0)) callback(i);
await settle();
}
expect(host.textContent).not.toContain("redisGrouping.preparingLegacy");
expect(host.querySelector("[data-redis-group]")).not.toBeNull();
expect(mocks.scrollerInitialSnapshot).toHaveBeenCalledWith(0);
expect(mocks.scrollerInitialSnapshot.mock.calls.every(([length]) => length === 0)).toBe(true);
expect(mocks.redisScanKeysBatch).toHaveBeenCalledTimes(1);
} finally {
vi.unstubAllGlobals();
}
});
it("continues sparse custom groups and preserves explicit checks across layout toggles", async () => {
grouping.value = { ...defaultRedisKeyGrouping(), enabled: true };
stubNonOverflowingViewport();
mocks.redisScanKeysBatch.mockResolvedValueOnce({ cursor: 7, keys: [{ key_display: "a", key_raw: btoa("a"), key_type: "string", ttl: -1 }], total_keys: 2 });
mocks.redisScanKeysBatch.mockResolvedValueOnce({ cursor: 0, keys: [{ key_display: "b", key_raw: btoa("b"), key_type: "string", ttl: -1 }], total_keys: 2 });
const host = mountBrowser();
await settleThoroughly();
expect(mocks.redisScanKeysBatch).toHaveBeenCalledTimes(2);
host.querySelector<HTMLButtonElement>("[data-redis-select-all]")!.click();
await settleThoroughly();
expect(host.querySelector("[data-redis-batch-delete]")?.textContent).toContain("2");
grouping.value = { ...grouping.value!, enabled: false };
await settleThoroughly();
expect(host.querySelector("[data-redis-batch-delete]")?.textContent).toContain("2");
grouping.value = { ...grouping.value!, enabled: true, inner_view: "tree" };
await settleThoroughly();
expect(host.querySelector("[data-redis-batch-delete]")?.textContent).toContain("2");
expect(mocks.redisScanKeysBatch).toHaveBeenCalledTimes(2);
});
it("keeps fetching pages on its own when the loaded rows don't overflow the viewport, without any scroll event", async () => {
stubNonOverflowingViewport();
@@ -21,6 +21,9 @@ import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
import RedisValueViewer from "./RedisValueViewer.vue";
import RedisPubSubPanel from "./RedisPubSubPanel.vue";
import RedisSlowlogPanel from "./RedisSlowlogPanel.vue";
import RedisGroupedKeyList from "./RedisGroupedKeyList.vue";
import RedisKeyGroupingDialog from "./RedisKeyGroupingDialog.vue";
import { defaultRedisKeyGrouping, type RedisKeyGrouping } from "@/lib/redis/redisKeyGrouping";
import * as api from "@/lib/backend/api";
import type { RedisKeyInfo, RedisScanResult, RedisValue, HistoryEntry } from "@/lib/backend/api";
import { uuid } from "@/lib/common/utils";
@@ -65,10 +68,34 @@ import { applyRedisExpiryPolicy, type RedisExpiryMode, validateRedisExpiry } fro
import { shouldLoadMoreRedisKeys } from "@/lib/redis/redisKeyInfiniteScroll";
import { formatTtl } from "@/lib/common/ttlFormat";
import { computeTtlCountdownValue } from "@/lib/redis/redisAutoRefresh";
import { createRedisKeyViewYield } from "@/lib/redis/redisKeyViewScheduler";
const { t, locale } = useI18n();
const { toast } = useToast();
const connectionStore = useConnectionStore();
const customGrouping = computed(() => connectionStore.getConfig(props.connectionId)?.redis_key_grouping ?? defaultRedisKeyGrouping());
const groupingDialogOpen = ref(false);
const groupingSaving = ref(false);
const groupingError = ref("");
const groupingStructureRevision = ref(0);
const legacyGroupingPreparing = ref(false);
let legacyGroupingPublishing = false;
const showCustomGrouping = computed(() => customGrouping.value.enabled || legacyGroupingPreparing.value);
let legacyGroupingGeneration = 0;
let legacyExpandedBeforeGrouping: ReadonlySet<string> = new Set();
async function saveGrouping(config: RedisKeyGrouping) {
if (groupingSaving.value) return;
groupingSaving.value = true;
groupingError.value = "";
try {
await connectionStore.updateRedisKeyGrouping(props.connectionId, config);
groupingDialogOpen.value = false;
} catch (error) {
groupingError.value = errorMessage(error);
} finally {
groupingSaving.value = false;
}
}
const settingsStore = useSettingsStore();
const editorFontFamilyStyle = useEditorFontFamilyStyle();
@@ -113,6 +140,7 @@ const fetchAllLoadedCount = ref(0);
const rootRef = ref<HTMLElement>();
const keyPaneRef = ref<HTMLElement>();
const redisKeyScrollerRef = ref<InstanceType<typeof RecycleScroller> | null>(null);
const groupedKeyListRef = ref<InstanceType<typeof RedisGroupedKeyList> | null>(null);
let redisKeyScrollRevision = 0;
let latestRedisKeyScrollAnchor: RedisKeyViewportAnchor | null = null;
const valueViewerRef = ref<{ focusSearch: () => boolean } | null>(null);
@@ -252,12 +280,12 @@ const isSearchMode = computed(() => (searchMode.value === "key" ? effectivePatte
// opts into the namespace hierarchy that users need for group selection.
const isFuzzyKeySearch = computed(() => searchMode.value === "key" && isSearchMode.value && fuzzyKeySearch.value);
const fuzzyTreeLimitReached = computed(() => isFuzzyKeySearch.value && !canBuildRedisFuzzyTree(flatKeys.value.length));
const useFlatKeySearchRows = computed(() => (searchMode.value === "key" && isSearchMode.value && !fuzzyKeySearch.value) || fuzzyTreeLimitReached.value);
const useFlatKeySearchRows = computed(() => showCustomGrouping.value || (searchMode.value === "key" && isSearchMode.value && !fuzzyKeySearch.value) || fuzzyTreeLimitReached.value);
const isFuzzyHierarchyView = computed(() => isFuzzyKeySearch.value && !fuzzyTreeLimitReached.value);
const selectionBusy = computed(() => deletingKeys.value || loading.value || loadingMore.value || isFetchingAll.value || searchPending.value);
// checkedKeys is always a subset of loaded keys, so size equality is enough.
const allLoadedKeysSelected = computed(() => flatKeys.value.length > 0 && checkedKeys.value.size === flatKeys.value.length);
const allKeysSelected = computed(() => allLoadedKeysSelected.value && !hasMore.value);
const allKeysSelected = computed(() => (customGrouping.value.enabled ? filteredFlatKeys.value.length > 0 && checkedKeys.value.size === filteredFlatKeys.value.length : allLoadedKeysSelected.value && !hasMore.value));
const searchPlaceholder = computed(() => {
if (searchMode.value === "key") return fuzzyKeySearch.value ? t("redis.fuzzyPattern") : t("redis.pattern");
return searchMode.value === "all" ? t("redis.allSearchPlaceholder") : t("redis.valueSearchPlaceholder");
@@ -415,6 +443,9 @@ watch(activeCreateKeyTypeHelp, () => {
void updateCreateKeyTypeHelpOffset();
});
const regularVisibleRows = computed(() => {
// Prepared snapshots already own their rows. Even a watcher whose callback
// ignores them would otherwise synchronously re-map/flatten the keyspace.
if (showCustomGrouping.value || fetchAllVisibleRowsActive.value) return [];
return useFlatKeySearchRows.value ? filteredFlatKeys.value.map((key) => redisKeyToFlatTreeRow(key, props.db)) : flattenVisibleRedisKeyTree(filteredTreeKeys.value, expandedGroupIds.value);
});
let fetchAllVisibleRowsSource: readonly RedisKeyTreeRow[] = [];
@@ -545,6 +576,7 @@ function appendFlatKeyRecords(keys: readonly RedisKeyInfo[]) {
}
function replaceFlatKeyRecords(keys: RedisKeyInfo[]) {
groupingStructureRevision.value++;
flatKeyByRaw.clear();
for (const key of keys) flatKeyByRaw.set(key.key_raw, key);
flatKeys.value = keys;
@@ -662,7 +694,7 @@ function toggleNodeCheck(node: RedisKeyTreeNode, event: MouseEvent) {
if (selectionBusy.value) return;
focusKeyPane();
if (event.shiftKey) {
if (event.shiftKey && !customGrouping.value.enabled) {
const rows = visibleRows.value;
const to = rows.findIndex((row) => row.node.id === node.id);
if (to < 0) return;
@@ -682,11 +714,15 @@ function toggleNodeCheck(node: RedisKeyTreeNode, event: MouseEvent) {
function selectAllLoadedKeys() {
if (selectionBusy.value || loadedKeyRaws.size === 0) return;
focusKeyPane();
setKeysChecked(loadedKeyRaws, true);
setKeysChecked(customGrouping.value.enabled ? filteredFlatKeys.value.map((key) => key.key_raw) : loadedKeyRaws, true);
selectionAnchorRowId.value = visibleRows.value[0]?.id ?? null;
}
async function selectAllKeys() {
if (customGrouping.value.enabled) {
selectAllLoadedKeys();
return;
}
if (selectionBusy.value || (loadedKeyRaws.size === 0 && !hasMore.value)) return;
const requestId = searchRequestId;
if (hasMore.value) {
@@ -1037,7 +1073,7 @@ async function maybeAutoLoadMoreRedisKeys() {
// operation is spent, independent of how many (if any) new keys prior calls
// yielded.
if (autoLoadBudget.remaining <= 0) return;
const scroller = redisKeyScrollerRef.value?.$el as HTMLElement | undefined;
const scroller = showCustomGrouping.value ? groupedKeyListRef.value?.getScrollElement() : (redisKeyScrollerRef.value?.$el as HTMLElement | undefined);
if (!scroller) return;
const shouldLoad = shouldLoadMoreRedisKeys({
enabled: redisInfiniteScrollEnabled.value,
@@ -1253,7 +1289,7 @@ async function fetchAll(): Promise<boolean> {
let published = !changed && !fetchAllStopRequested.value;
try {
if (changed) {
snapshotConfig.flatRows = (searchMode.value === "key" && isSearchMode.value && !fuzzyKeySearch.value) || (isFuzzyKeySearch.value && !canBuildRedisFuzzyTree(initialFlatKeys.length + bufferedKeys.length));
snapshotConfig.flatRows = customGrouping.value.enabled || (searchMode.value === "key" && isSearchMode.value && !fuzzyKeySearch.value) || (isFuzzyKeySearch.value && !canBuildRedisFuzzyTree(initialFlatKeys.length + bufferedKeys.length));
snapshotConfig.expandAll = isSearchMode.value;
snapshotConfig.expandedGroupIds = expandedGroupIds.value;
snapshotConfig.noExpiryOnly = noExpiryOnly.value;
@@ -2668,6 +2704,7 @@ onMounted(async () => {
onActivated(async () => {
resumeRedisBrowserBackgroundWork();
if (legacyGroupingPreparing.value || legacyGroupingPublishing) void prepareLegacyGroupingView();
void autofocusSearchOnce();
// Ensure the connection is still alive after reactivation (e.g. tab switch).
// Retry an empty failed/interrupted load, but retain successful sparse pages.
@@ -2729,11 +2766,114 @@ async function executeAiCommand(command: string): Promise<boolean> {
return true;
}
watch(
() => JSON.stringify(customGrouping.value.rules),
() => resetCheckedKeys(),
);
watch(noExpiryOnly, () => resetCheckedKeys());
async function prepareLegacyGroupingView() {
const generation = ++legacyGroupingGeneration;
legacyGroupingPublishing = false;
const keys = flatKeys.value;
const connectionId = props.connectionId;
const db = props.db;
const current = () => generation === legacyGroupingGeneration && !customGrouping.value.enabled && redisBrowserIsActive && props.connectionId === connectionId && props.db === db && flatKeys.value === keys;
const yieldView = createRedisKeyViewYield();
legacyGroupingPreparing.value = true;
const snapshot = await buildRedisKeySnapshotCooperatively(
[keys],
{
db,
separator: redisKeySeparator.value,
flatRows: (searchMode.value === "key" && isSearchMode.value && !fuzzyKeySearch.value) || fuzzyTreeLimitReached.value,
expandAll: false,
expandedGroupIds: legacyExpandedBeforeGrouping,
noExpiryOnly: noExpiryOnly.value,
},
{ workChunkSize: 512, shouldContinue: current, yieldControl: yieldView },
);
if (!snapshot || !current()) return;
let anchor = groupedKeyListRef.value?.getViewportAnchor();
let rowIndex = Math.min(anchor?.rowIndex ?? 0, Math.max(0, snapshot.visibleRows.length - 1));
if (anchor?.keyRaw) {
for (let offset = 0; offset < snapshot.visibleRows.length; ) {
if (!current()) return;
const end = Math.min(offset + 512, snapshot.visibleRows.length);
let found = false;
for (; offset < end; offset++) {
const row = snapshot.visibleRows[offset]!;
if (row.node.kind === "leaf" && row.node.keyRaw === anchor.keyRaw) {
rowIndex = offset;
found = true;
break;
}
}
if (found) break;
if (offset < snapshot.visibleRows.length) await yieldView();
const latest = groupedKeyListRef.value?.getViewportAnchor();
if (latest?.keyRaw !== anchor.keyRaw) {
anchor = latest;
rowIndex = Math.min(latest?.rowIndex ?? 0, Math.max(0, snapshot.visibleRows.length - 1));
if (!anchor?.keyRaw) break;
offset = 0;
}
}
}
if (!current()) return;
// Keep the complete custom view mounted until all expensive work is done.
// Activate prepared rows before changing structural refs, so the regular
// projection watcher cannot accidentally rebuild this snapshot synchronously.
fetchAllVisibleRowsActive.value = true;
treeIndex = snapshot.treeIndex;
treeKeys.value = snapshot.treeIndex?.root ?? [];
expandedGroupIds.value = snapshot.expandedGroupIds;
// RecycleScroller snapshots every initial item during setup. Mount with an
// empty backing source, then switch the same facade before the next paint.
fetchAllVisibleRowsSource = [];
fetchAllFilteredKeyCount.value = snapshot.filteredKeyCount;
refreshSelectedGroupLeafCounts();
legacyGroupingPublishing = true;
legacyGroupingPreparing.value = false;
await nextTick();
if (current()) {
fetchAllVisibleRowsSource = snapshot.visibleRows;
redisKeyScroller()?.scrollToItem(rowIndex, { align: "start" });
refreshRedisKeyScroller();
await nextTick();
if (!current()) return;
redisKeyScroller()?.scrollToItem(rowIndex, { align: "start" });
refreshRedisKeyScroller();
legacyGroupingPublishing = false;
}
}
watch(
() => customGrouping.value.enabled,
(enabled, previous) => {
if (enabled) {
legacyGroupingGeneration++;
legacyGroupingPublishing = false;
if (!legacyGroupingPreparing.value) legacyExpandedBeforeGrouping = expandedGroupIds.value;
legacyGroupingPreparing.value = false;
rebuildTree(false);
} else if (previous) {
void prepareLegacyGroupingView();
}
},
{ flush: "sync" },
);
watch(
[flatKeys, groupingStructureRevision, redisKeySeparator, noExpiryOnly, noExpiryProjectionEpoch, effectivePattern, () => props.connectionId, () => props.db],
() => {
if ((legacyGroupingPreparing.value || legacyGroupingPublishing) && !customGrouping.value.enabled) void prepareLegacyGroupingView();
},
{ flush: "sync" },
);
defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand });
</script>
<template>
<div ref="rootRef" class="h-full" :style="editorFontFamilyStyle">
<RedisKeyGroupingDialog v-model:open="groupingDialogOpen" :config="customGrouping" :saving="groupingSaving" :error="groupingError" @save="saveGrouping" />
<Splitpanes class="redis-workspace-splitpanes h-full">
<!-- Key tree (left) -->
<Pane :size="36" :min-size="24">
@@ -2754,7 +2894,17 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand });
</div>
<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="(flatKeys.length > 0 || hasMore) && !allKeysSelected" variant="ghost" size="sm" class="h-6 shrink-0 px-1.5 text-xs" :disabled="selectionBusy" :title="t('redis.selectAllLoadedTitle')" data-redis-select-all @click="selectAllKeys">{{ t("redis.selectAllLoaded") }}</Button>
<Button
v-if="(flatKeys.length > 0 || hasMore) && !allKeysSelected"
variant="ghost"
size="sm"
class="h-6 shrink-0 px-1.5 text-xs"
:disabled="selectionBusy"
:title="customGrouping.enabled ? t('redisGrouping.selectLoaded') : t('redis.selectAllLoadedTitle')"
data-redis-select-all
@click="selectAllKeys"
>{{ customGrouping.enabled ? t("redisGrouping.selectLoaded") : t("redis.selectAllLoaded") }}</Button
>
<Button v-if="checkedKeys.size > 0" variant="ghost" size="sm" class="h-6 shrink-0 px-1.5 text-xs" :disabled="selectionBusy" data-redis-deselect-all @click="clearAllCheckedKeys">{{ t("redis.deselectAll") }}</Button>
<Button v-if="checkedKeys.size > 0" variant="ghost" size="sm" class="h-6 shrink-0 text-xs text-destructive" :disabled="selectionBusy" data-redis-batch-delete @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 || loading || loadingMore || isFetchingAll" @click="loadKeys">
@@ -2833,7 +2983,48 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand });
</div>
</div>
<div v-if="flatKeys.length === 0 && !loading" class="flex-1 flex flex-col items-center justify-center text-muted-foreground text-xs p-4 text-center">
<div class="flex shrink-0 flex-wrap items-center gap-1 border-b px-2 py-1">
<Button
variant="ghost"
size="sm"
class="h-6 text-xs"
:class="customGrouping.enabled ? 'bg-accent text-accent-foreground' : 'border border-dashed border-border/70 text-muted-foreground hover:text-foreground'"
:aria-pressed="customGrouping.enabled"
:disabled="groupingSaving || isFetchingAll"
data-redis-grouping-toggle
@click="saveGrouping({ ...customGrouping, enabled: !customGrouping.enabled })"
>{{ t("redisGrouping.toggle") }}</Button
>
<Button v-if="customGrouping.enabled" variant="ghost" size="sm" class="h-6 text-xs" :disabled="groupingSaving" @click="saveGrouping({ ...customGrouping, inner_view: customGrouping.inner_view === 'list' ? 'tree' : 'list' })">{{
t(customGrouping.inner_view === "list" ? "redisGrouping.list" : "redisGrouping.tree")
}}</Button>
<Button variant="ghost" size="sm" class="h-6 text-xs" @click="groupingDialogOpen = true">{{ t("redisGrouping.settings") }}</Button>
<span v-if="groupingError" role="alert" class="text-xs text-destructive">{{ groupingError }}</span>
</div>
<p v-if="legacyGroupingPreparing" role="status" class="shrink-0 px-2 py-1 text-xs text-muted-foreground">{{ t("redisGrouping.preparingLegacy") }}</p>
<RedisGroupedKeyList
v-if="showCustomGrouping"
ref="groupedKeyListRef"
:key="`${connectionId}:${db}`"
:keys="filteredFlatKeys"
:config="customGrouping"
:separator="redisKeySeparator"
:scope="`${connectionId}:${db}`"
:structure-revision="groupingStructureRevision"
:selected="selectedKeyRaw"
:checked="checkedKeys"
:busy="selectionBusy"
:metadata-epoch="keyMetadataEpoch"
:tree-allowed="!fuzzyTreeLimitReached"
@request-list="saveGrouping({ ...customGrouping, inner_view: 'list' })"
@scroll="onRedisKeyScroll"
@resize="maybeAutoLoadMoreRedisKeys"
@select="(key) => onRowClick(redisKeyToFlatTreeRow(key, db).node)"
@check="(key, event) => toggleNodeCheck(redisKeyToFlatTreeRow(key, db).node, event)"
@copy="(key) => copyRedisKeyName(key.key_display)"
@delete="(key, event) => requestKeyDelete(redisKeyToFlatTreeRow(key, db).node, event)"
/>
<div v-else-if="flatKeys.length === 0 && !loading" class="flex-1 flex flex-col items-center justify-center text-muted-foreground text-xs p-4 text-center">
<template v-if="hasMore">
<span class="mb-3">{{ t("redis.noKeysInScanHint") }}</span>
<Button variant="outline" size="sm" class="h-7 text-xs" :disabled="loadingMore || searchPending || deletingKeys" @click="loadMore()">
@@ -2931,7 +3122,7 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand });
</CustomContextMenu>
</template>
</RecycleScroller>
<div v-if="fuzzyTreeLimitReached" class="shrink-0 border-t px-3 py-2 text-center text-xs text-muted-foreground">
<div v-if="fuzzyTreeLimitReached && !showCustomGrouping" class="shrink-0 border-t px-3 py-2 text-center text-xs text-muted-foreground">
{{ t("redis.fuzzyTreeLimit", { count: flatKeys.length }) }}
</div>
<div v-if="hasMore && !isFetchingAll" class="shrink-0 border-t px-2 py-1.5 flex items-center gap-1.5">
@@ -0,0 +1,79 @@
<script setup lang="ts">
import { ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { defaultRedisKeyGrouping, validateRedisKeyGrouping, type RedisKeyGrouping } from "@/lib/redis/redisKeyGrouping";
import { uuid } from "@/lib/common/utils";
const props = defineProps<{ open: boolean; config: RedisKeyGrouping; saving: boolean; error: string }>();
const emit = defineEmits<{ "update:open": [boolean]; save: [RedisKeyGrouping] }>();
const { t } = useI18n();
const draft = ref(defaultRedisKeyGrouping());
const invalid = ref(false);
watch(
() => props.open,
(open) => {
if (open) {
draft.value = validateRedisKeyGrouping(props.config);
invalid.value = false;
}
},
);
function add(example = false) {
draft.value.rules.push({ id: uuid(), name: example ? "Redisson" : "", enabled: true, includes: example ? ["*:redisson_options"] : ["*"], excludes: [] });
}
function move(index: number, direction: number) {
const target = index + direction;
if (target < 0 || target >= draft.value.rules.length) return;
const rule = draft.value.rules.splice(index, 1)[0]!;
draft.value.rules.splice(target, 0, rule);
}
function save() {
try {
emit("save", validateRedisKeyGrouping(draft.value));
invalid.value = false;
} catch {
invalid.value = true;
}
}
function patterns(event: Event): string[] {
return (event.target as HTMLTextAreaElement).value.split("\n").filter((line) => line.length > 0);
}
</script>
<template>
<Dialog :open="open" @update:open="emit('update:open', $event)">
<DialogContent class="sm:max-w-2xl">
<DialogHeader
><DialogTitle>{{ t("redisGrouping.settings") }}</DialogTitle></DialogHeader
>
<p class="text-xs text-muted-foreground">{{ t("redisGrouping.help") }}</p>
<div class="max-h-[55vh] space-y-3 overflow-y-auto">
<fieldset v-for="(rule, index) in draft.rules" :key="rule.id" class="space-y-2 rounded border p-3" :disabled="saving">
<div class="flex items-center gap-2">
<input v-model="rule.enabled" type="checkbox" :aria-label="t('redisGrouping.enabled')" />
<Input v-model="rule.name" :placeholder="t('redisGrouping.name')" :aria-label="t('redisGrouping.name')" />
<Button variant="outline" size="sm" :disabled="index === 0" :aria-label="t('redisGrouping.up')" @click="move(index, -1)">↑</Button>
<Button variant="outline" size="sm" :disabled="index === draft.rules.length - 1" :aria-label="t('redisGrouping.down')" @click="move(index, 1)">↓</Button>
<Button variant="outline" size="sm" @click="draft.rules.splice(index, 1)">{{ t("redisGrouping.remove") }}</Button>
</div>
<div class="grid grid-cols-2 gap-2">
<label class="text-xs">{{ t("redisGrouping.includes") }}<textarea class="w-full rounded border bg-background p-2" :value="rule.includes.join('\n')" @input="rule.includes = patterns($event)" /></label>
<label class="text-xs">{{ t("redisGrouping.excludes") }}<textarea class="w-full rounded border bg-background p-2" :value="rule.excludes.join('\n')" @input="rule.excludes = patterns($event)" /></label>
</div>
</fieldset>
</div>
<div class="flex gap-2">
<Button variant="outline" :disabled="saving || draft.rules.length >= 64" @click="add()">{{ t("redisGrouping.add") }}</Button>
<Button variant="outline" :disabled="saving || draft.rules.length >= 64" @click="add(true)">{{ t("redisGrouping.example") }}</Button>
</div>
<p v-if="invalid || error" role="alert" class="text-sm text-destructive">{{ error || t("redisGrouping.invalid") }}</p>
<DialogFooter>
<Button variant="outline" :disabled="saving" @click="emit('update:open', false)">{{ t("redisGrouping.cancel") }}</Button>
<Button :disabled="saving" @click="save">{{ t("redisGrouping.save") }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>