feat(redis): add custom key groups

This commit is contained in:
Guoyu Su
2026-09-09 14:44:45 +08:00
committed by GitHub
parent a535cf8f51
commit 1cbb5abf3b
53 changed files with 1951 additions and 13 deletions
@@ -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>
+2
View File
@@ -1,3 +1,4 @@
import { redisGroupingEn as redisGrouping } from "./redisGrouping";
import docs from "./docs/en";
import { consulUiMessages } from "./consulUi";
import { sqlServerTraceMessages as sqlServerTrace } from "./sqlServerTraceMessages";
@@ -79,6 +80,7 @@ const consul = {
};
export default {
redisGrouping,
customType: {
kinds: {
base: "Base",
@@ -0,0 +1,84 @@
export const redisGroupingEn = {
preparingLegacy: "Preparing the previous view; custom groups remain available…",
fuzzyTreeUnavailable: "This fuzzy search exceeds the existing tree resource limit. Choose List to browse the loaded matches, or narrow the search to use Tree.",
useList: "Use List",
settings: "Grouping rules",
toggle: "Custom groups",
list: "List",
tree: "Tree",
help: "Connection-wide rules, first match wins. One case-sensitive Redis glob per line (*, ?, [a-z], backslash escape). Includes are OR; exclusions veto. Empty includes match all. Counts cover loaded keys only. Groups are not deletion boundaries.",
enabled: "Enable rule",
name: "Group name",
up: "Move up",
down: "Move down",
remove: "Remove",
includes: "Include patterns",
excludes: "Exclude patterns",
add: "Add rule",
example: "Add Redisson example",
invalid: "Enter unique rule IDs, a name and at least one pattern per rule (maximum 64 rules, 64 patterns per list, 1024 bytes per pattern).",
cancel: "Cancel",
save: "Save",
unmatched: "Unmatched",
preparing: "Updating loaded groups…",
loadedOnly: "Groups show loaded matches only; expand does not scan Redis.",
count: "({count} loaded)",
emptyKey: "(empty key)",
selectLoaded: "Select loaded matches",
};
export const redisGroupingZhCN = {
preparingLegacy: "正在准备原有视图,期间仍可浏览自定义分组…",
fuzzyTreeUnavailable: "当前模糊搜索超过已有树视图资源限制。请选择列表浏览已加载匹配项,或缩小搜索范围后使用前缀树。",
useList: "使用列表",
settings: "分组规则",
toggle: "自定义分组",
list: "列表",
tree: "前缀树",
help: "规则按连接保存,按顺序首次匹配归组。每行一个区分大小写的 Redis 通配模式(*、?、[a-z]、反斜杠转义)。包含条件任一命中即可,排除条件优先;包含为空表示全部。计数仅涵盖已加载 Key,分组不是删除边界。",
enabled: "启用规则",
name: "分组名称",
up: "上移",
down: "下移",
remove: "移除",
includes: "包含模式",
excludes: "排除模式",
add: "添加规则",
example: "添加 Redisson 示例",
invalid: "请填写唯一规则 ID、名称及至少一个模式(最多 64 个规则,每组包含/排除各 64 个模式,每个模式 1024 字节)。",
cancel: "取消",
save: "保存",
unmatched: "未匹配",
preparing: "正在更新已加载分组…",
loadedOnly: "分组仅展示已加载匹配结果,展开不会扫描 Redis。",
count: "(已加载 {count})",
emptyKey: "(空 Key)",
selectLoaded: "选择已加载匹配项",
};
export const redisGroupingZhTW = {
preparingLegacy: "正在準備原有檢視,期間仍可瀏覽自訂分組…",
fuzzyTreeUnavailable: "目前模糊搜尋超過既有樹狀檢視資源限制。請選擇列表瀏覽已載入匹配項,或縮小搜尋範圍後使用前綴樹。",
useList: "使用列表",
settings: "分組規則",
toggle: "自訂分組",
list: "列表",
tree: "前綴樹",
help: "規則按連線儲存,按順序首次匹配歸組。每行一個區分大小寫的 Redis 萬用模式(*、?、[a-z]、反斜線跳脫)。包含條件任一命中即可,排除條件優先;包含為空表示全部。計數僅涵蓋已載入 Key,分組不是刪除邊界。",
enabled: "啟用規則",
name: "分組名稱",
up: "上移",
down: "下移",
remove: "移除",
includes: "包含模式",
excludes: "排除模式",
add: "新增規則",
example: "新增 Redisson 範例",
invalid: "請填寫唯一規則 ID、名稱及至少一個模式(最多 64 個規則,每組包含/排除各 64 個模式,每個模式 1024 位元組)。",
cancel: "取消",
save: "儲存",
unmatched: "未匹配",
preparing: "正在更新已載入分組…",
loadedOnly: "分組僅展示已載入匹配結果,展開不會掃描 Redis。",
count: "(已載入 {count})",
emptyKey: "(空 Key)",
selectLoaded: "選擇已載入匹配項",
};
+2
View File
@@ -1,9 +1,11 @@
import { redisGroupingZhCN as redisGrouping } from "./redisGrouping";
import { withEnglishFallback } from "./fallback";
import docs from "./docs/zh-CN";
import { consul } from "./consulZhCN";
import { meilisearchManagementZhCN } from "./meilisearchManagement";
export default withEnglishFallback({
redisGrouping,
customType: {
kinds: {
base: "基础类型",
+2
View File
@@ -1,3 +1,4 @@
import { redisGroupingZhTW as redisGrouping } from "./redisGrouping";
import { withEnglishFallback } from "./fallback";
import { meilisearchManagementZhTW } from "./meilisearchManagement";
import docs from "./docs/zh-TW";
@@ -81,6 +82,7 @@ const consul = {
};
export default withEnglishFallback({
redisGrouping,
customType: {
kinds: {
base: "基礎型別",
@@ -0,0 +1,43 @@
import { expect, it } from "vitest";
import { RedisKeyGroupIndex, findRedisOuterGroup } from "../redisKeyGroupIndex";
const work = { current: () => true, yield: async () => {} };
const rules = [
{ id: "a", name: "A", enabled: true, includes: ["a:*"], excludes: [] },
{ id: "empty", name: "Empty", enabled: true, includes: ["missing:*"], excludes: [] },
];
const key = (name: string) => ({ key_raw: btoa(name), key_display: name, key_type: "string", ttl: -1 });
it.each(["list", "tree"] as const)("captures atomic outer boundaries for %s, excluding namespace headers", async (view) => {
const index = new RedisKeyGroupIndex(rules, "Unmatched");
await index.append([key("a:1"), key("a:2"), key("b:1")], work);
const expanded = new Set(['custom:"a"', 'custom:"a":namespace:["a"]', "custom:unmatched"]);
const first = (await index.project(view, ":", expanded, work))!;
expect(first.outerGroups).toHaveLength(3);
expect(first.outerGroups.map((group) => group.row.label)).toEqual(["A", "Empty", "Unmatched"]);
for (const group of first.outerGroups) {
expect(first.rows[group.start]).toBe(group.row);
expect(findRedisOuterGroup(first.outerGroups, group.start)).toBe(group);
expect(findRedisOuterGroup(first.outerGroups, group.end - 0.1)).toBe(group);
}
expect(findRedisOuterGroup(first.outerGroups, -1)).toBeUndefined();
expect(findRedisOuterGroup(first.outerGroups, first.rows.length)).toBeUndefined();
const before = first.outerGroups.map((group) => ({ ...group }));
await index.append([key("a:1"), key("a:2"), key("b:1"), key("a:3")], work);
const collapsed = (await index.project(view, ":", new Set(), work))!;
expect(collapsed.outerGroups.map((group) => group.end - group.start)).toEqual([1, 1, 1]);
expect(first.outerGroups).toEqual(before);
expect(first.outerGroups[0]!.row.count).toBe(2);
expect(collapsed.outerGroups[0]!.row.count).toBe(3);
});
it("keeps million-key boundary storage bounded by groups, including unmatched", async () => {
const index = new RedisKeyGroupIndex(rules, "Unmatched");
const keys = Array.from({ length: 1_000_000 }, (_, n) => key(`a:${n}`));
await index.append(keys, work);
const snapshot = (await index.project("list", ":", new Set(['custom:"a"']), work))!;
expect(snapshot.rows.length).toBe(1_000_002);
expect(snapshot.outerGroups).toHaveLength(2);
expect(findRedisOuterGroup(snapshot.outerGroups, 999_999)?.row.id).toBe('custom:"a"');
expect(findRedisOuterGroup(snapshot.outerGroups, 1_000_001)?.row.label).toBe("Empty");
});
@@ -0,0 +1,94 @@
import { describe, expect, it } from "vitest";
import { compileRedisGroupGlob, compileRedisKeyGroups, defaultRedisKeyGrouping, validateRedisKeyGrouping } from "../redisKeyGrouping";
import { RedisKeyGroupIndex } from "../redisKeyGroupIndex";
import type { RedisKeyInfo } from "@/lib/backend/api";
const raw = (value: string) => btoa(String.fromCharCode(...new TextEncoder().encode(value)));
const key = (name: string): RedisKeyInfo => ({ key_raw: raw(name), key_display: name, key_type: "string", ttl: -1, size: 1 });
const rule = { id: "suffix", name: "Metadata", enabled: true, includes: ["*:redisson_options"], excludes: [] };
const work = { current: () => true, yield: async () => {} };
describe("Redis grouping contracts", () => {
it("is opt-in and rejects malformed edits", () => {
expect(validateRedisKeyGrouping(defaultRedisKeyGrouping()).enabled).toBe(false);
expect(() => validateRedisKeyGrouping({ ...defaultRedisKeyGrouping(), rules: [{ ...rule, includes: [] }] })).toThrow();
expect(() => validateRedisKeyGrouping({ ...defaultRedisKeyGrouping(), rules: [rule, rule] })).toThrow();
expect(() => validateRedisKeyGrouping({ ...defaultRedisKeyGrouping(), version: 2 })).toThrow();
});
it("uses whole-byte glob semantics, escapes and ranges", () => {
const matches = (pattern: string, text: string) => compileRedisGroupGlob(pattern)(new TextEncoder().encode(text));
expect(matches("?", "中")).toBe(false);
expect(matches("???", "中")).toBe(true);
expect(matches("[z-a]", "q")).toBe(true);
expect(matches("[^a]", "b")).toBe(true);
expect(matches("a\\*", "a*")).toBe(true);
expect(matches("a", "ab")).toBe(false);
expect(matches("*", "")).toBe(true);
expect(matches("**", "")).toBe(false);
expect(compileRedisGroupGlob("?")(new Uint8Array([255]))).toBe(true);
expect(matches("*a*a*a*a*a*b", "a".repeat(10000))).toBe(false);
});
it("uses first match and exclusion veto without lossy display text", () => {
const classify = compileRedisKeyGroups([rule, { ...rule, id: "business", includes: [], excludes: ["private:*"] }]);
expect(classify(raw("cache:redisson_options"))).toBe("suffix");
expect(classify(raw("cache:one"))).toBe("business");
expect(classify(raw("private:one"))).toBe(null);
expect(compileRedisKeyGroups([{ ...rule, includes: ["?"], enabled: false }])("/w==")).toBe(null);
});
it("deduplicates append and exposes immutable-length range snapshots", async () => {
const index = new RedisKeyGroupIndex([rule], "Other");
const first = [key("a:redisson_options"), key("b")];
await index.append(first, work);
const expanded = new Set(['custom:"suffix"', "custom:unmatched"]);
const before = (await index.project("list", ":", expanded, work))!;
expect(before.rows.map((row) => row.kind)).toEqual(["virtual-group", "key", "virtual-group", "key"]);
await index.append([...first, first[0]!, key("c")], work);
expect(before.rows.length).toBe(4);
const after = (await index.project("list", ":", expanded, work))!;
expect(after.rows.length).toBe(5);
expect(after.positions.get(`key:${raw("c")}`)).toBe(4);
});
it("scopes namespace IDs by outer group and never produces legacy group nodes", async () => {
const index = new RedisKeyGroupIndex([rule], "Other");
await index.append([key("a:redisson_options"), key("a:other")], work);
const result = (await index.project("tree", ":", new Set(['custom:"suffix"', "custom:unmatched"]), work))!;
expect(new Set(result.rows.map((row) => row.id)).size).toBe(result.rows.length);
expect(result.rows.every((row) => row.kind === "virtual-group")).toBe(true);
const expanded = new Set(result.rows.map((row) => row.id));
const leaves = (await index.project("tree", ":", expanded, work))!;
expect(leaves.rows.filter((row) => row.kind === "key")).toHaveLength(2);
});
it("cancels bounded work and resumes append without duplicates", async () => {
const index = new RedisKeyGroupIndex([], "Other");
const keys = Array.from({ length: 2000 }, (_, i) => key(String(i)));
let alive = true;
expect(
await index.append(keys, {
current: () => alive,
yield: async () => {
alive = false;
},
}),
).toBe(false);
await index.append(keys, work);
const result = (await index.project("list", ":", new Set(["custom:unmatched"]), work))!;
expect(result.rows.length).toBe(2001);
});
it("does not materialize each loaded key again when projecting a large list", async () => {
const index = new RedisKeyGroupIndex([], "Other");
await index.append(
Array.from({ length: 100000 }, (_, i) => key(String(i))),
work,
);
let yields = 0;
const result = (await index.project("list", ":", new Set(["custom:unmatched"]), {
current: () => true,
yield: async () => {
yields++;
},
}))!;
expect(yields).toBe(0);
expect(result.rows.length).toBe(100001);
expect(result.positions.get(`key:${raw("99999")}`)).toBe(100000);
});
});
@@ -0,0 +1,42 @@
import { expect, it } from "vitest";
import { createRedisKeyViewYield } from "../redisKeyViewScheduler";
import { buildRedisKeySnapshotCooperatively } from "../redisKeyTree";
it("uses small checkpoints without paying a task/frame for every checkpoint", async () => {
let clock = 0;
let tasks = 0;
const checkpoint = createRedisKeyViewYield({
now: () => clock,
yieldTask: async () => {
tasks++;
},
budgetMs: 8,
});
for (let i = 0; i < 1000; i++) {
clock += 0.125;
await checkpoint();
}
expect(tasks).toBe(15);
});
it("retains builder cancellation checks before a real task is needed", async () => {
let checks = 0;
let tasks = 0;
const keys = Array.from({ length: 2000 }, (_, i) => ({ key_raw: String(i), key_display: String(i), ttl: -1, key_type: "string" }));
const result = await buildRedisKeySnapshotCooperatively(
[keys],
{ db: 0, flatRows: true, expandAll: false, expandedGroupIds: new Set() },
{
workChunkSize: 512,
shouldContinue: () => ++checks < 5,
yieldControl: createRedisKeyViewYield({
now: () => 0,
yieldTask: async () => {
tasks++;
},
}),
},
);
expect(result).toBeNull();
expect(tasks).toBe(0);
});
@@ -0,0 +1,74 @@
import { expect, it } from "vitest";
import { findRedisHeaderChain, RedisKeyGroupIndex } from "../redisKeyGroupIndex";
import { redisStickyHeaders, redisStickyHeight } from "../redisStickyHeaders";
const work = { current: () => true, yield: async () => {} };
async function fixture() {
const index = new RedisKeyGroupIndex([], "Unmatched");
await index.append(
Array.from({ length: 1000 }, (_, n) => ({ key_display: `a:b:c:d:${n}`, key_raw: btoa(`a:b:c:d:${n}`), ttl: -1, key_type: "string" })),
work,
);
const expanded = new Set(["custom:unmatched"]);
for (const path of [["a"], ["a", "b"], ["a", "b", "c"], ["a", "b", "c", "d"]]) expanded.add(`custom:unmatched:namespace:${JSON.stringify(path)}`);
return { index, expanded, snapshot: (await index.project("tree", ":", expanded, work))! };
}
it("indexes ancestry once per header, with atomic end ranges and no leaf access during lookup", async () => {
const { index, expanded, snapshot } = await fixture();
expect(snapshot.headerBoundaries).toHaveLength(5);
let reads = 0;
const headers = new Proxy(snapshot.headerBoundaries, {
get(target, prop, receiver) {
if (typeof prop === "string" && /^\d+$/.test(prop)) reads++;
return Reflect.get(target, prop, receiver);
},
});
expect(findRedisHeaderChain(headers, 999).map((header) => header.row.label)).toEqual(["Unmatched", "a", "b", "c", "d"]);
expect(reads).toBeLessThanOrEqual(5);
expanded.delete('custom:unmatched:namespace:["a","b"]');
const collapsed = (await index.project("tree", ":", expanded, work))!;
expect(collapsed.headerBoundaries).toHaveLength(3);
expect(snapshot.headerBoundaries[0]!.end).toBe(1005);
expect(findRedisHeaderChain(collapsed.headerBoundaries, 3)).toEqual([]);
});
it("bounds height at zero, one, two and three rows and indicates omitted ancestors", async () => {
const { snapshot } = await fixture();
const sticky = (height: number, scroll = 500) => redisStickyHeaders(snapshot.outerGroups, snapshot.headerBoundaries, scroll, height, true);
expect(sticky(99)).toEqual([]);
expect(sticky(100).map((entry) => entry.row.label)).toEqual(["Unmatched"]);
expect(sticky(200).map((entry) => entry.row.label)).toEqual(["Unmatched", "d"]);
expect(sticky(300).map((entry) => entry.row.label)).toEqual(["Unmatched", "c", "d"]);
expect(sticky(300)[1]!.omitted).toBe(true);
expect(sticky(300)[1]!.path).toBe("Unmatched › a › b › c › d");
expect(redisStickyHeight(sticky(1000))).toBe(90);
expect(sticky(300, 0)).toEqual([]);
expect(redisStickyHeaders(snapshot.outerGroups, snapshot.headerBoundaries, 500, 99, false)).toHaveLength(1);
});
it("retains expanded parents above a collapsed current namespace before compressing", async () => {
const { index, expanded } = await fixture();
expanded.delete('custom:unmatched:namespace:["a","b","c","d"]');
const snapshot = (await index.project("tree", ":", expanded, work))!;
expect(redisStickyHeaders(snapshot.outerGroups, snapshot.headerBoundaries, 60, 300, true).map((entry) => entry.row.label)).toEqual(["Unmatched", "b", "c"]);
});
it("pushes the outgoing sibling behind its parents and never pins the incoming natural header twice", async () => {
const index = new RedisKeyGroupIndex([], "Unmatched");
await index.append(
["a", "b"].flatMap((branch) => Array.from({ length: 10 }, (_, n) => ({ key_display: `p:${branch}:${n}`, key_raw: btoa(`p:${branch}:${n}`), ttl: -1, key_type: "string" }))),
work,
);
const expanded = new Set(["custom:unmatched", 'custom:unmatched:namespace:["p"]', 'custom:unmatched:namespace:["p","a"]', 'custom:unmatched:namespace:["p","b"]']);
const snapshot = (await index.project("tree", ":", expanded, work))!;
const b = snapshot.headerBoundaries.find((header) => header.row.label === "b")!;
const before = redisStickyHeaders(snapshot.outerGroups, snapshot.headerBoundaries, b.start * 30 - 75, 300, true);
expect(before.map((entry) => entry.row.label)).toEqual(["Unmatched", "p", "a"]);
expect(before[2]!.top).toBe(45);
expect(redisStickyHeight(before)).toBe(75);
const aligned = redisStickyHeaders(snapshot.outerGroups, snapshot.headerBoundaries, b.start * 30 - 60, 300, true);
expect(aligned.map((entry) => entry.row.label)).toEqual(["Unmatched", "p"]);
const after = redisStickyHeaders(snapshot.outerGroups, snapshot.headerBoundaries, b.start * 30 - 59, 300, true);
expect(after.map((entry) => entry.row.label)).toEqual(["Unmatched", "p", "b"]);
});
@@ -0,0 +1,255 @@
import type { RedisKeyInfo } from "@/lib/backend/api";
import { compileRedisKeyGroups, type RedisKeyGroupRule } from "./redisKeyGrouping";
export interface RedisVirtualGroupRow {
kind: "virtual-group";
id: string;
label: string;
depth: number;
count: number;
}
export interface RedisGroupedKeyRow {
kind: "key";
id: string;
key: RedisKeyInfo;
label: string;
depth: number;
}
export type RedisGroupedRow = RedisVirtualGroupRow | RedisGroupedKeyRow;
export interface RedisOuterGroupBoundary {
start: number;
end: number;
row: RedisVirtualGroupRow;
}
export interface RedisHeaderBoundary extends RedisOuterGroupBoundary {
parent?: RedisHeaderBoundary;
}
/** Header-only binary lookup plus parent links; never reads leaf rows. */
export function findRedisHeaderChain(headers: readonly RedisHeaderBoundary[], position: number): RedisHeaderBoundary[] {
let low = 0;
let high = headers.length;
while (low < high) {
const mid = (low + high) >>> 1;
if (headers[mid]!.start <= position) low = mid + 1;
else high = mid;
}
let header: RedisHeaderBoundary | undefined = headers[low - 1];
const chain: RedisHeaderBoundary[] = [];
while (header) {
if (position < header.end) chain.push(header);
header = header.parent;
}
return chain.reverse();
}
/** Only searches outer sections (at most 65), never the virtual key source. */
export function findRedisOuterGroup(groups: readonly RedisOuterGroupBoundary[], position: number): RedisOuterGroupBoundary | undefined {
let low = 0;
let high = groups.length - 1;
while (low <= high) {
const mid = (low + high) >>> 1;
const group = groups[mid]!;
if (position < group.start) high = mid - 1;
else if (position >= group.end) low = mid + 1;
else return group;
}
return undefined;
}
interface Namespace {
id: string;
label: string;
count: number;
children: Map<string, Namespace>;
keys: RedisKeyInfo[];
}
interface Group {
id: string;
label: string;
keys: RedisKeyInfo[];
tree?: Namespace;
treeLength: number;
}
export interface GroupWork {
current: () => boolean;
yield: () => Promise<void>;
}
const CHUNK = 512;
/** Non-reactive index: ordinary append batches classify only new identities. */
export class RedisKeyGroupIndex {
readonly groups: Group[];
private readonly classify: ReturnType<typeof compileRedisKeyGroups>;
private readonly byId = new Map<string | null, Group>();
private readonly locations = new Map<string, { group: Group; offset: number; namespace?: Namespace; namespaceOffset?: number }>();
private consumed = 0;
private lastRaw: string | undefined;
constructor(rules: readonly RedisKeyGroupRule[], unmatched: string) {
this.classify = compileRedisKeyGroups(rules);
this.groups = rules
.filter((rule) => rule.enabled)
.map((rule) => {
const group = { id: `custom:${JSON.stringify(rule.id)}`, label: rule.name, keys: [], treeLength: 0 };
this.byId.set(rule.id, group);
return group;
});
const group = { id: "custom:unmatched", label: unmatched, keys: [], treeLength: 0 };
this.byId.set(null, group);
this.groups.push(group);
}
canAppend(keys: readonly RedisKeyInfo[]): boolean {
return keys.length >= this.consumed && (!this.consumed || keys[this.consumed - 1]?.key_raw === this.lastRaw);
}
async append(keys: readonly RedisKeyInfo[], work: GroupWork): Promise<boolean> {
for (let offset = this.consumed; offset < keys.length; ) {
if (!work.current()) return false;
const end = Math.min(offset + CHUNK, keys.length);
for (; offset < end; offset++) {
const key = keys[offset]!;
if (this.locations.has(key.key_raw)) continue;
const group = this.byId.get(this.classify(key.key_raw))!;
this.locations.set(key.key_raw, { group, offset: group.keys.length });
group.keys.push(key);
}
this.consumed = offset;
this.lastRaw = keys[offset - 1]?.key_raw;
if (offset < keys.length) await work.yield();
}
return work.current();
}
async project(view: "list" | "tree", separator: string, expanded: ReadonlySet<string>, work: GroupWork): Promise<{ rows: RedisGroupedRow[]; outerGroups: RedisOuterGroupBoundary[]; headerBoundaries: RedisHeaderBoundary[]; positions: { get: (id: string) => number | undefined } } | null> {
// Row ranges reference canonical key arrays. A million-key list projection
// is O(groups), not O(keys) per incoming batch, and only mounted leaves
// allocate presentation objects. Captured lengths make publication atomic.
const ranges: { start: number; length: number; row?: RedisVirtualGroupRow; keys?: readonly RedisKeyInfo[]; depth: number }[] = [];
const headers = new Map<string, number>();
const outerGroups: RedisOuterGroupBoundary[] = [];
const headerBoundaries: RedisHeaderBoundary[] = [];
const openHeaders: RedisHeaderBoundary[] = [];
const keyRanges = new Map<string, number>();
let length = 0;
let units = 0;
const checkpoint = async () => {
if (++units % CHUNK === 0) await work.yield();
return work.current();
};
const add = (row: RedisVirtualGroupRow) => {
while (openHeaders.length && openHeaders[openHeaders.length - 1]!.row.depth >= row.depth) openHeaders.pop()!.end = length;
const boundary = { start: length, end: length + 1, row, parent: openHeaders[openHeaders.length - 1] };
headerBoundaries.push(boundary);
openHeaders.push(boundary);
headers.set(row.id, length);
ranges.push({ start: length++, length: 1, row, depth: row.depth });
};
const addKeys = (id: string, keys: readonly RedisKeyInfo[], depth: number) => {
keyRanges.set(id, length);
if (keys.length) ranges.push({ start: length, length: keys.length, keys, depth });
length += keys.length;
};
const leaf = (key: RedisKeyInfo, label: string, depth: number): RedisGroupedKeyRow => ({ kind: "key", id: `key:${key.key_raw}`, key, label, depth });
for (const group of this.groups) {
if (!work.current()) return null;
if (group.id === "custom:unmatched" && !group.keys.length) continue;
const previous = outerGroups[outerGroups.length - 1];
if (previous) previous.end = length;
const row: RedisVirtualGroupRow = { kind: "virtual-group", id: group.id, label: group.label, depth: 0, count: group.keys.length };
outerGroups.push({ start: length, end: length + 1, row });
add(row);
if (!expanded.has(group.id)) continue;
if (view === "list" || !separator) {
addKeys(group.id, group.keys, 1);
} else {
// A namespace index is built only for an expanded outer group. It holds
// references to canonical records, not copies of metadata.
group.tree ??= { id: group.id, label: "", count: 0, children: new Map(), keys: [] };
for (let i = group.treeLength; i < group.keys.length; i++) {
const key = group.keys[i]!;
const parts = key.key_display.split(separator);
let node = group.tree;
const path: string[] = [];
node.count++;
for (const part of parts.slice(0, -1)) {
path.push(part);
let child = node.children.get(part);
if (!child) {
child = { id: `${group.id}:namespace:${JSON.stringify(path)}`, label: part, count: 0, children: new Map(), keys: [] };
node.children.set(part, child);
}
child.count++;
node = child;
}
const location = this.locations.get(key.key_raw)!;
location.namespace = node;
location.namespaceOffset = node.keys.length;
node.keys.push(key);
group.treeLength = i + 1;
if (!(await checkpoint())) return null;
}
const stack: { children: Iterator<Namespace>; depth: number }[] = [{ children: group.tree.children.values(), depth: 1 }];
addKeys(group.tree.id, group.tree.keys, 1);
while (stack.length) {
const frame = stack[stack.length - 1]!;
const result = frame.children.next();
if (result.done) {
stack.pop();
continue;
}
const node = result.value;
add({ kind: "virtual-group", id: node.id, label: node.label, depth: frame.depth, count: node.count });
if (expanded.has(node.id)) {
addKeys(node.id, node.keys, frame.depth + 1);
stack.push({ children: node.children.values(), depth: frame.depth + 1 });
}
if (!(await checkpoint())) return null;
}
}
}
const lastGroup = outerGroups[outerGroups.length - 1];
if (lastGroup) lastGroup.end = length;
for (const header of openHeaders) header.end = length;
const at = (index: number): RedisGroupedRow | undefined => {
if (index < 0 || index >= length) return undefined;
let low = 0;
let high = ranges.length - 1;
while (low <= high) {
const mid = (low + high) >>> 1;
const range = ranges[mid]!;
if (index < range.start) high = mid - 1;
else if (index >= range.start + range.length) low = mid + 1;
else {
if (range.row) return range.row;
const key = range.keys![index - range.start]!;
const parts = view === "tree" && separator ? key.key_display.split(separator) : [key.key_display];
return leaf(key, parts[parts.length - 1] ?? "", range.depth);
}
}
return undefined;
};
const rows = new Proxy([] as RedisGroupedRow[], {
get(target, property, receiver) {
if (property === "length") return length;
const n = typeof property === "string" && /^(0|[1-9]\d*)$/.test(property) ? Number(property) : -1;
return n >= 0 ? at(n) : Reflect.get(target, property, receiver);
},
has(target, property) {
const n = typeof property === "string" && /^(0|[1-9]\d*)$/.test(property) ? Number(property) : -1;
return n >= 0 ? n < length : Reflect.has(target, property);
},
});
const positions = {
get: (id: string) => {
if (!id.startsWith("key:")) return headers.get(id);
const location = this.locations.get(id.slice(4));
if (!location) return undefined;
const tree = view === "tree" && !!separator;
const start = keyRanges.get(tree ? (location.namespace?.id ?? "") : location.group.id);
return start === undefined ? undefined : start + (tree ? location.namespaceOffset! : location.offset);
},
};
return work.current() ? { rows, outerGroups, headerBoundaries, positions } : null;
}
}
@@ -0,0 +1,109 @@
/** Connection-persisted rules. Classification never changes Redis keys or SCAN. */
export interface RedisKeyGroupRule {
id: string;
name: string;
enabled: boolean;
includes: string[];
excludes: string[];
}
export interface RedisKeyGrouping {
version: 1;
enabled: boolean;
inner_view: "list" | "tree";
rules: RedisKeyGroupRule[];
}
export function defaultRedisKeyGrouping(): RedisKeyGrouping {
return { version: 1, enabled: false, inner_view: "list", rules: [] };
}
/** Reject invalid edits/imports rather than silently discarding saved rules. */
export function validateRedisKeyGrouping(value: unknown): RedisKeyGrouping {
if (!value || typeof value !== "object") throw new Error("Invalid Redis grouping configuration");
const config = value as Partial<RedisKeyGrouping>;
if (config.version !== 1 || typeof config.enabled !== "boolean" || !["list", "tree"].includes(config.inner_view ?? "") || !Array.isArray(config.rules) || config.rules.length > 64) throw new Error("Invalid Redis grouping configuration");
const ids = new Set<string>();
const rules = config.rules.map((rule) => {
if (!rule || typeof rule.id !== "string" || !rule.id || ids.has(rule.id) || typeof rule.name !== "string" || !rule.name.trim() || typeof rule.enabled !== "boolean") throw new Error("Invalid Redis grouping rule");
ids.add(rule.id);
for (const patterns of [rule.includes, rule.excludes]) {
if (!Array.isArray(patterns) || patterns.length > 64 || patterns.some((pattern) => typeof pattern !== "string" || new TextEncoder().encode(pattern).length > 1024)) throw new Error("Invalid Redis grouping patterns");
}
if (!rule.includes.length && !rule.excludes.length) throw new Error("Empty Redis grouping rule");
return { ...rule, name: rule.name.trim(), includes: [...rule.includes], excludes: [...rule.excludes] };
});
return { version: 1, enabled: config.enabled, inner_view: config.inner_view!, rules };
}
type Token = { kind: "star" } | { kind: "byte"; value: number } | { kind: "any" } | { kind: "class"; values: Set<number>; negate: boolean };
/** Redis MATCH is byte-oriented, including ? and bracket ranges. */
export function compileRedisGroupGlob(pattern: string): (bytes: Uint8Array) => boolean {
const source = new TextEncoder().encode(pattern);
const tokens: Token[] = [];
for (let i = 0; i < source.length; i++) {
const byte = source[i]!;
if (byte === 42) {
if (tokens[tokens.length - 1]?.kind !== "star") tokens.push({ kind: "star" });
} else if (byte === 63) tokens.push({ kind: "any" });
else if (byte === 92 && i + 1 < source.length) tokens.push({ kind: "byte", value: source[++i]! });
else if (byte === 91) {
const values = new Set<number>();
let cursor = i + 1;
const negate = source[cursor] === 94;
if (negate) cursor++;
while (cursor < source.length && source[cursor] !== 93) {
if (source[cursor] === 92 && cursor + 1 < source.length) values.add(source[++cursor]!);
else if (cursor + 2 < source.length && source[cursor + 1] === 45) {
const a = source[cursor]!;
const b = source[cursor + 2]!;
for (let n = Math.min(a, b); n <= Math.max(a, b); n++) values.add(n);
cursor += 2;
} else values.add(source[cursor]!);
cursor++;
}
tokens.push({ kind: "class", values, negate });
i = cursor;
} else tokens.push({ kind: "byte", value: byte });
}
// Greedy star retry is bounded O(pattern * key), not recursive/exponential.
return (bytes) => {
// Redis SCAN special-cases MATCH "*"; other nonempty patterns do not
// match an empty key, even a run of stars such as "**".
if (!bytes.length) return source.length === 0 || pattern === "*";
let p = 0;
let k = 0;
let star = -1;
let retry = 0;
while (k < bytes.length) {
const token = tokens[p];
if (token?.kind === "star") {
star = p++;
retry = k;
continue;
}
if (token && (token.kind === "any" || (token.kind === "byte" && token.value === bytes[k]) || (token.kind === "class" && token.values.has(bytes[k]!) !== token.negate))) {
p++;
k++;
continue;
}
if (star < 0) return false;
p = star + 1;
k = ++retry;
}
while (tokens[p]?.kind === "star") p++;
return p === tokens.length;
};
}
export function compileRedisKeyGroups(rules: readonly RedisKeyGroupRule[]): (keyRaw: string) => string | null {
const compiled = rules.filter((rule) => rule.enabled).map((rule) => ({ id: rule.id, includes: rule.includes.map(compileRedisGroupGlob), excludes: rule.excludes.map(compileRedisGroupGlob) }));
return (keyRaw) => {
const bytes = Uint8Array.from(atob(keyRaw), (char) => char.charCodeAt(0));
for (const rule of compiled) {
if ((!rule.includes.length || rule.includes.some((matches) => matches(bytes))) && !rule.excludes.some((matches) => matches(bytes))) return rule.id;
}
return null;
};
}
@@ -0,0 +1,12 @@
/** Small cancellation checkpoints need not each consume a browser frame. */
export function createRedisKeyViewYield(options: { now?: () => number; yieldTask?: () => Promise<void>; budgetMs?: number } = {}): () => Promise<void> {
const now = options.now ?? (() => performance.now());
const yieldTask = options.yieldTask ?? (() => new Promise<void>((resolve) => setTimeout(resolve, 0)));
const budget = options.budgetMs ?? 8;
let started = now();
return async () => {
if (now() - started < budget) return;
await yieldTask();
started = now();
};
}
@@ -0,0 +1,27 @@
import { findRedisHeaderChain, findRedisOuterGroup, type RedisHeaderBoundary, type RedisOuterGroupBoundary } from "./redisKeyGroupIndex";
export const REDIS_GROUP_ROW_HEIGHT = 30;
export function redisStickyHeaders(outer: readonly RedisOuterGroupBoundary[], headers: readonly RedisHeaderBoundary[], scroll: number, height: number, tree: boolean) {
const size = REDIS_GROUP_ROW_HEIGHT;
const capacity = tree ? Math.min(3, Math.floor((height * 0.3) / size)) : 1;
const group = findRedisOuterGroup(outer, scroll / size);
if (!capacity || !group || group.end <= group.start + 1 || scroll <= group.start * size) return [];
const outerOffset = Math.min(0, group.end * size - scroll - size);
if (!tree) return [{ row: group.row, top: outerOffset, omitted: false, path: group.row.label }];
// Probe only the small overlay band, never the virtual source. Do not cross
// the outer boundary while choosing descendants at a section transition.
const chain = findRedisHeaderChain(headers, Math.min(group.end - 0.001, (scroll + (capacity - 1) * size) / size)).filter((entry) => entry.end > entry.start + 1);
const candidates = chain.length > capacity ? [chain[0]!, ...chain.slice(-(capacity - 1))] : chain;
if (capacity === 1) candidates.splice(1);
const path = chain.map((entry) => entry.row.label).join(" › ");
return candidates.flatMap((entry, slot) => {
const top = slot * size;
if (entry.start * size >= scroll + top || entry.end * size <= scroll + top || entry.end <= entry.start + 1) return [];
return [{ row: entry.row, top: Math.min(top, entry.end * size - scroll - size), omitted: slot === 1 && chain.length > capacity, path }];
});
}
export function redisStickyHeight(sticky: ReturnType<typeof redisStickyHeaders>) {
return sticky.reduce((height, row) => Math.max(height, row.top + REDIS_GROUP_ROW_HEIGHT), 0);
}
@@ -0,0 +1,25 @@
import { createPinia, setActivePinia } from "pinia";
import { beforeEach, expect, it, vi } from "vitest";
import { defaultRedisKeyGrouping } from "@/lib/redis/redisKeyGrouping";
beforeEach(() => {
vi.resetModules();
setActivePinia(createPinia());
vi.stubGlobal("localStorage", { getItem: () => null, setItem: () => {}, removeItem: () => {} });
});
it("persists presentation without reconnecting and keeps old config after failure", async () => {
const saveConnections = vi.fn().mockResolvedValue(undefined);
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/backend/api", () => ({ saveConnections }));
const { useConnectionStore } = await import("../connectionStore");
const store = useConnectionStore();
store.addEphemeralConnection({ id: "group-test", name: "Redis", db_type: "redis", host: "localhost", port: 6379, username: "", password: "" });
const config = { ...defaultRedisKeyGrouping(), enabled: true };
await store.updateRedisKeyGrouping("group-test", config);
expect(saveConnections).toHaveBeenCalledWith([expect.objectContaining({ redis_key_grouping: config })]);
expect(store.connectedIds.has("group-test")).toBe(true);
saveConnections.mockRejectedValueOnce(new Error("disk full"));
await expect(store.updateRedisKeyGrouping("group-test", { ...config, enabled: false })).rejects.toThrow("disk full");
expect(store.getConfig("group-test")?.redis_key_grouping?.enabled).toBe(true);
});
@@ -3433,6 +3433,18 @@ export const useConnectionStore = defineStore("connection", () => {
}
}
async function updateRedisKeyGrouping(connectionId: string, grouping: import("@/lib/redis/redisKeyGrouping").RedisKeyGrouping) {
const { validateRedisKeyGrouping } = await import("@/lib/redis/redisKeyGrouping");
const validated = validateRedisKeyGrouping(grouping);
const index = connections.value.findIndex((connection) => connection.id === connectionId);
if (index < 0) throw new Error("Connection not found");
const next = [...connections.value];
next[index] = { ...next[index]!, redis_key_grouping: validated };
await persistConnections(next);
// Presentation-only preferences must not invalidate a live connection.
connections.value = next;
}
async function renameConnection(connectionId: string, name: string): Promise<boolean> {
const trimmed = name.trim();
if (!trimmed) return false;
@@ -8970,6 +8982,7 @@ export const useConnectionStore = defineStore("connection", () => {
pasteConnectionClipboard,
addEphemeralConnection,
updateConnection,
updateRedisKeyGrouping,
renameConnection,
applyGlobalTimeouts,
updateConnectionDatabaseInfo,
+1
View File
@@ -126,6 +126,7 @@ export interface ConnectionConfig {
redis_database_aliases?: Record<string, string>;
/** Key-search templates for the Redis browser. Non-empty overrides global settings. */
redis_key_templates?: string[];
redis_key_grouping?: import("@/lib/redis/redisKeyGrouping").RedisKeyGrouping;
etcd_endpoints?: string;
gbase_server?: string;
informix_server?: string;
@@ -199,6 +199,7 @@ fn connection_config(id: &str, database: BenchDatabase) -> Result<ConnectionConf
redis_scan_page_size: None,
redis_database_aliases: Default::default(),
redis_key_templates: Vec::new(),
redis_key_grouping: None,
etcd_endpoints: String::new(),
gbase_server: String::new(),
informix_server: String::new(),
+1
View File
@@ -741,6 +741,7 @@ mod tests {
redis_scan_page_size: None,
redis_database_aliases: Default::default(),
redis_key_templates: Vec::new(),
redis_key_grouping: None,
etcd_endpoints: String::new(),
gbase_server: String::new(),
informix_server: String::new(),
+1
View File
@@ -1485,6 +1485,7 @@ for line in sys.stdin:
redis_scan_page_size: None,
redis_database_aliases: Default::default(),
redis_key_templates: Vec::new(),
redis_key_grouping: None,
etcd_endpoints: String::new(),
gbase_server: String::new(),
informix_server: String::new(),
+3
View File
@@ -1802,6 +1802,7 @@ mod tests {
redis_scan_page_size: None,
redis_database_aliases: Default::default(),
redis_key_templates: Vec::new(),
redis_key_grouping: None,
etcd_endpoints: String::new(),
gbase_server: String::new(),
informix_server: String::new(),
@@ -1864,6 +1865,7 @@ mod tests {
redis_scan_page_size: None,
redis_database_aliases: Default::default(),
redis_key_templates: Vec::new(),
redis_key_grouping: None,
etcd_endpoints: String::new(),
gbase_server: String::new(),
informix_server: String::new(),
@@ -2124,6 +2126,7 @@ mod tests {
redis_scan_page_size: None,
redis_database_aliases: Default::default(),
redis_key_templates: Vec::new(),
redis_key_grouping: None,
etcd_endpoints: String::new(),
gbase_server: String::new(),
informix_server: String::new(),
+1
View File
@@ -6060,6 +6060,7 @@ mod tests {
redis_scan_page_size: None,
redis_database_aliases: Default::default(),
redis_key_templates: Vec::new(),
redis_key_grouping: None,
etcd_endpoints: String::new(),
gbase_server: String::new(),
informix_server: String::new(),
@@ -869,6 +869,7 @@ mod tests {
redis_scan_page_size: None,
redis_database_aliases: Default::default(),
redis_key_templates: Vec::new(),
redis_key_grouping: None,
etcd_endpoints: String::new(),
gbase_server: String::new(),
informix_server: String::new(),
+1
View File
@@ -6988,6 +6988,7 @@ mod tests {
redis_scan_page_size: None,
redis_database_aliases: Default::default(),
redis_key_templates: Vec::new(),
redis_key_grouping: None,
etcd_endpoints: String::new(),
gbase_server: String::new(),
informix_server: String::new(),
+70
View File
@@ -69,6 +69,30 @@ pub fn database_info_from_protocol_value(value: &Value) -> Option<DatabaseConnec
serde_json::from_value::<DatabaseInfoEnvelope>(value.clone()).ok()?.database_info
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct RedisKeyGroupRule {
pub id: String,
pub name: String,
pub enabled: bool,
pub includes: Vec<String>,
pub excludes: Vec<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum RedisKeyGroupView {
List,
Tree,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct RedisKeyGrouping {
pub version: u8,
pub enabled: bool,
pub inner_view: RedisKeyGroupView,
pub rules: Vec<RedisKeyGroupRule>,
}
#[derive(Clone, Serialize, PartialEq)]
pub struct ConnectionConfig {
pub id: String,
@@ -159,6 +183,8 @@ pub struct ConnectionConfig {
/// Empty means inherit the global editor setting.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub redis_key_templates: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub redis_key_grouping: Option<RedisKeyGrouping>,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub etcd_endpoints: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
@@ -596,6 +622,8 @@ struct ConnectionConfigData {
#[serde(default)]
pub redis_key_templates: Vec<String>,
#[serde(default)]
pub redis_key_grouping: Option<RedisKeyGrouping>,
#[serde(default)]
pub etcd_endpoints: String,
#[serde(default)]
pub gbase_server: String,
@@ -669,6 +697,7 @@ impl From<ConnectionConfigData> for ConnectionConfig {
redis_scan_page_size: data.redis_scan_page_size,
redis_database_aliases: data.redis_database_aliases,
redis_key_templates: data.redis_key_templates,
redis_key_grouping: data.redis_key_grouping,
etcd_endpoints: data.etcd_endpoints,
gbase_server: data.gbase_server,
informix_server: data.informix_server,
@@ -693,6 +722,23 @@ impl<'de> Deserialize<'de> for ConnectionConfig {
let mut value = Value::deserialize(deserializer)?;
migrate_legacy_transport_layers(&mut value);
let data = ConnectionConfigData::deserialize(value).map_err(serde::de::Error::custom)?;
if let Some(grouping) = &data.redis_key_grouping {
let mut ids = std::collections::HashSet::new();
if grouping.version != 1
|| grouping.rules.len() > 64
|| grouping.rules.iter().any(|rule| {
rule.id.is_empty()
|| !ids.insert(&rule.id)
|| rule.name.trim().is_empty()
|| (rule.includes.is_empty() && rule.excludes.is_empty())
|| rule.includes.len() > 64
|| rule.excludes.len() > 64
|| rule.includes.iter().chain(&rule.excludes).any(|pattern| pattern.len() > 1024)
})
{
return Err(serde::de::Error::custom("Invalid Redis grouping configuration"));
}
}
Ok(data.into())
}
}
@@ -2556,6 +2602,29 @@ fn gaussdb_single_host_port(host: &str, default_port: u16) -> (String, u16) {
#[cfg(test)]
mod tests {
#[test]
fn redis_key_grouping_roundtrip_and_validation() {
let mut config = mysql_config("root", "", None);
assert!(config.redis_key_grouping.is_none());
config.redis_key_grouping = Some(super::RedisKeyGrouping {
version: 1,
enabled: true,
inner_view: super::RedisKeyGroupView::Tree,
rules: vec![super::RedisKeyGroupRule {
id: "r1".into(),
name: "Metadata".into(),
enabled: true,
includes: vec!["*:redisson_options".into()],
excludes: vec![],
}],
});
let value = serde_json::to_value(&config).unwrap();
let restored: super::ConnectionConfig = serde_json::from_value(value.clone()).unwrap();
assert_eq!(restored.redis_key_grouping, config.redis_key_grouping);
let mut invalid = value;
invalid["redis_key_grouping"]["version"] = serde_json::json!(2);
assert!(serde_json::from_value::<super::ConnectionConfig>(invalid).is_err());
}
use super::{
database_info_from_protocol_value, default_query_timeout_secs, default_redis_key_separator,
default_ssh_connect_timeout_secs, sqlserver_legacy_compatibility_param,
@@ -2679,6 +2748,7 @@ mod tests {
redis_scan_page_size: None,
redis_database_aliases: Default::default(),
redis_key_templates: Vec::new(),
redis_key_grouping: None,
etcd_endpoints: String::new(),
gbase_server: String::new(),
informix_server: String::new(),
+1
View File
@@ -224,6 +224,7 @@ mod tests {
redis_scan_page_size: None,
redis_database_aliases: Default::default(),
redis_key_templates: Vec::new(),
redis_key_grouping: None,
etcd_endpoints: String::new(),
gbase_server: String::new(),
informix_server: String::new(),
+1
View File
@@ -944,6 +944,7 @@ mod tests {
redis_scan_page_size: None,
redis_database_aliases: Default::default(),
redis_key_templates: Vec::new(),
redis_key_grouping: None,
etcd_endpoints: String::new(),
gbase_server: String::new(),
informix_server: String::new(),
+1
View File
@@ -411,6 +411,7 @@ mod tests {
redis_scan_page_size: None,
redis_database_aliases: Default::default(),
redis_key_templates: Vec::new(),
redis_key_grouping: None,
etcd_endpoints: String::new(),
gbase_server: String::new(),
informix_server: String::new(),
+2
View File
@@ -687,6 +687,7 @@ mod tests {
redis_scan_page_size: None,
redis_database_aliases: Default::default(),
redis_key_templates: Vec::new(),
redis_key_grouping: None,
etcd_endpoints: String::new(),
gbase_server: String::new(),
informix_server: String::new(),
@@ -764,6 +765,7 @@ mod tests {
redis_scan_page_size: None,
redis_database_aliases: Default::default(),
redis_key_templates: Vec::new(),
redis_key_grouping: None,
etcd_endpoints: String::new(),
gbase_server: String::new(),
informix_server: String::new(),
+1
View File
@@ -805,6 +805,7 @@ mod tests {
redis_scan_page_size: Some(1000),
redis_database_aliases: Default::default(),
redis_key_templates: Vec::new(),
redis_key_grouping: None,
etcd_endpoints: String::new(),
gbase_server: String::new(),
informix_server: String::new(),
+2
View File
@@ -6739,6 +6739,7 @@ for line in sys.stdin:
redis_scan_page_size: None,
redis_database_aliases: Default::default(),
redis_key_templates: Vec::new(),
redis_key_grouping: None,
etcd_endpoints: String::new(),
gbase_server: String::new(),
informix_server: String::new(),
@@ -9258,6 +9259,7 @@ for line in sys.stdin:
redis_scan_page_size: None,
redis_database_aliases: Default::default(),
redis_key_templates: Vec::new(),
redis_key_grouping: None,
etcd_endpoints: String::new(),
gbase_server: String::new(),
informix_server: String::new(),
+1
View File
@@ -3402,6 +3402,7 @@ mod tests {
redis_scan_page_size: None,
redis_database_aliases: Default::default(),
redis_key_templates: Vec::new(),
redis_key_grouping: None,
etcd_endpoints: String::new(),
gbase_server: String::new(),
informix_server: String::new(),
+2
View File
@@ -5968,6 +5968,7 @@ mod tests {
redis_scan_page_size: None,
redis_database_aliases: Default::default(),
redis_key_templates: Vec::new(),
redis_key_grouping: None,
etcd_endpoints: String::new(),
gbase_server: String::new(),
informix_server: String::new(),
@@ -6037,6 +6038,7 @@ mod tests {
redis_scan_page_size: None,
redis_database_aliases: Default::default(),
redis_key_templates: Vec::new(),
redis_key_grouping: None,
etcd_endpoints: String::new(),
gbase_server: String::new(),
informix_server: String::new(),
+2
View File
@@ -9573,6 +9573,7 @@ mod tests {
redis_scan_page_size: None,
redis_database_aliases: Default::default(),
redis_key_templates: Vec::new(),
redis_key_grouping: None,
etcd_endpoints: String::new(),
gbase_server: String::new(),
informix_server: String::new(),
@@ -14380,6 +14381,7 @@ SELECT 1 FROM dual"#
redis_scan_page_size: None,
redis_database_aliases: Default::default(),
redis_key_templates: Vec::new(),
redis_key_grouping: None,
etcd_endpoints: String::new(),
gbase_server: String::new(),
informix_server: String::new(),
@@ -66,6 +66,7 @@ fn live_postgres_config(
redis_scan_page_size: None,
redis_database_aliases: Default::default(),
redis_key_templates: Vec::new(),
redis_key_grouping: None,
etcd_endpoints: String::new(),
gbase_server: String::new(),
informix_server: String::new(),
@@ -62,6 +62,7 @@ fn live_postgres_config(
redis_scan_page_size: None,
redis_database_aliases: Default::default(),
redis_key_templates: Vec::new(),
redis_key_grouping: None,
etcd_endpoints: String::new(),
gbase_server: String::new(),
informix_server: String::new(),
@@ -57,6 +57,7 @@ fn live_postgres_config(
redis_scan_page_size: None,
redis_database_aliases: Default::default(),
redis_key_templates: Vec::new(),
redis_key_grouping: None,
etcd_endpoints: String::new(),
gbase_server: String::new(),
informix_server: String::new(),
@@ -62,6 +62,7 @@ fn live_postgres_config(
redis_scan_page_size: None,
redis_database_aliases: Default::default(),
redis_key_templates: Vec::new(),
redis_key_grouping: None,
etcd_endpoints: String::new(),
gbase_server: String::new(),
informix_server: String::new(),
@@ -56,6 +56,7 @@ fn postgres_test_config(id: &str, database: &str) -> ConnectionConfig {
redis_scan_page_size: None,
redis_database_aliases: Default::default(),
redis_key_templates: Vec::new(),
redis_key_grouping: None,
etcd_endpoints: String::new(),
gbase_server: String::new(),
informix_server: String::new(),
@@ -63,6 +63,7 @@ fn live_sqlserver_config(id: &str, database: &str) -> dbx_core::models::connecti
redis_scan_page_size: None,
redis_database_aliases: Default::default(),
redis_key_templates: Vec::new(),
redis_key_grouping: None,
etcd_endpoints: String::new(),
gbase_server: String::new(),
informix_server: String::new(),
@@ -52,6 +52,7 @@ fn live_sqlserver_config(id: &str, database: &str) -> dbx_core::models::connecti
redis_scan_page_size: None,
redis_database_aliases: Default::default(),
redis_key_templates: Vec::new(),
redis_key_grouping: None,
etcd_endpoints: String::new(),
gbase_server: String::new(),
informix_server: String::new(),
@@ -55,6 +55,7 @@ fn live_sqlserver_config(id: &str, database: &str) -> ConnectionConfig {
redis_scan_page_size: None,
redis_database_aliases: Default::default(),
redis_key_templates: Vec::new(),
redis_key_grouping: None,
etcd_endpoints: String::new(),
gbase_server: String::new(),
informix_server: String::new(),
+1
View File
@@ -176,6 +176,7 @@ pub fn postgres_test_config(id: &str, port: u16) -> ConnectionConfig {
redis_scan_page_size: None,
redis_database_aliases: Default::default(),
redis_key_templates: Vec::new(),
redis_key_grouping: None,
etcd_endpoints: String::new(),
gbase_server: String::new(),
informix_server: String::new(),
+1
View File
@@ -864,6 +864,7 @@ mod tests {
redis_scan_page_size: None,
redis_database_aliases: Default::default(),
redis_key_templates: Vec::new(),
redis_key_grouping: None,
etcd_endpoints: String::new(),
gbase_server: String::new(),
informix_server: String::new(),
+1
View File
@@ -719,6 +719,7 @@ mod tests {
redis_scan_page_size: None,
redis_database_aliases: Default::default(),
redis_key_templates: Vec::new(),
redis_key_grouping: None,
etcd_endpoints: String::new(),
gbase_server: String::new(),
informix_server: String::new(),
+1
View File
@@ -271,6 +271,7 @@ mod tests {
redis_scan_page_size: None,
redis_database_aliases: Default::default(),
redis_key_templates: Vec::new(),
redis_key_grouping: None,
etcd_endpoints: String::new(),
gbase_server: String::new(),
informix_server: String::new(),